Files
code-utils/frontend/src/views/Profile.vue
李琦 c430a0f6da 1. 后台图表多样化 API 的 /admin/overview 新增了按 AI 提供商聚合的用量(providerSeries)和云端数据构成(dataDist)。Admin.vue 概览页改用 ECharts:日活折线图、Token 堆叠柱状图(叠加调用次数折线)、AI 提供商用量饼图、云端数据构成饼图。
2. 端口监控与刷新间隔 「存为应用」不再跳转启动台,而是在本页弹出复用的 LaunchAppFormModal 表单,保存成功后弹确认框询问「留在本页 / 前往启动台」。启动台和端口监控页头都加了 RefreshIntervalPicker(仅手动 / 5 / 10 / 30 / 60 秒,默认 30 秒),选择记忆在 localStorage。

3. 团队所有者与用户详情 团队列表的所有者、用户列表的用户名都改为「头像 + 昵称 + ID」的可点击单元格,点击弹出用户详情:头像、昵称、头衔、待办/工单/笔记/团队数量、注册时间、最近活跃、拥有团队数。API 新增 GET /admin/users/:id,团队列表关联查询出 ownerNickname/ownerAvatar。

4. 项目专属排除规则 SQLite exclusion_rules 加了 project_id 列(唯一约束改为 (project_id, pattern),旧库自动重建迁移)。项目详情页「代码」标签下新增专属规则面板,可增删;扫描时全局规则 + 项目规则叠加生效。规则随项目身份上云,其它机器认领项目后自动带回。

5. 一句话 AI 生成待办/工单 待办页和工单页工具栏各嵌入一个 AI 生成输入条:一句话回车后由后端 AIGenerateTasks 调用 AI 拆解成多条草稿,弹预览框逐条勾选、可改标题/类型/优先级/日期,选归属项目后批量保存。

6. 项目云同步与认领

全局挂载了 CloudClaimModal:登录同步后发现云端有本机未落地的项目时自动弹出(同一批只打扰一次),每个项目可「选目录认领」或「忽略」;工作台横幅和个人主页可随时再打开。
统计数据按机器码推送到 stats:<机器码> 文档,只推不拉,不同电脑的扫描历史互不覆盖。
个人主页 · 云同步新增「项目云同步」区块:自动/手动模式切换(手动模式下常规同步跳过项目文档,仅点「立即同步项目」时推拉)、待认领入口、已忽略项目的恢复列表。
途中补齐了约 60 个中英双语 i18n 键,并修掉了 PortMonitor 引用但缺失的 pmSavedToast 键。
2026-08-17 09:04:53 +08:00

711 lines
34 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup>
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { UserRound, ImageUp, KeyRound, CloudUpload, LogIn, LogOut, RefreshCw, Wifi, WifiOff, Info, ShieldCheck, Images, Camera, X, Lock, Folder, CircleCheck, TicketCheck, Star, ListTodo, Settings as SettingsIcon, FileText, CalendarClock, UploadCloud, IdCard, Users, Plus, Check, ArrowRight, Tags, Copy, Trash2 } from 'lucide-vue-next'
import { call, isNative } from '../api'
import { useAppStore } from '../store'
import { teams, teamsErr, teamsLoading, loadTeams as loadTeamsShared, switchTeam as switchTeamShared } from '../team'
import RemoteImg from '../components/RemoteImg.vue'
import ImagePreview from '../components/ImagePreview.vue'
const store = useAppStore(), { t } = useI18n(), native = isNative()
const router = useRouter()
const form = reactive({ avatarMode: '', avatarValue: '', imageMode: 'base64' })
const pwdForm = reactive({ old: '', next: '', confirm: '' })
const busy = ref(''), msg = ref('')
const loaded = ref(false)
const avatarOpen = ref(false)
const AVATAR_HIST_KEY = 'cc-avatar-history'
const avatarHistory = ref([]) // [{mode,value,preview?}]
const serverAvatars = ref([])
const previewOpen = ref(false)
const previewSrc = ref('')
const TABS = ['info', 'teams', 'assets', 'security', 'sync']
const tab = ref(TABS.includes(localStorage.getItem('cc-profile-tab')) ? localStorage.getItem('cc-profile-tab') : 'info')
const doneTodos = ref(0)
const resolvedTickets = ref(0)
const sync = computed(() => store.syncStatus)
// ---- 全局文件存储(管理员在设置页配置;此处只读,决定素材库可用性与提示文案) ----
const fsCfg = reactive({ mode: 'local', baseUrl: '' })
const serverStorage = computed(() => fsCfg.mode === 'server')
async function loadFileStorage() {
try {
const c = await call('GetFileStorageConfig')
Object.assign(fsCfg, { mode: c.mode || 'local', baseUrl: c.baseUrl || '' })
} catch {}
}
// ---- 素材库(服务器存储的图片管理:本人 / 团队管理员 / 超管三档范围) ----
const assets = ref([])
const assetsTotal = ref(0)
const assetsPage = ref(1)
const assetsScope = ref('mine')
const assetsTeamId = ref(0)
const assetsLoading = ref(false)
const assetsErr = ref('')
// 只有我担任 owner/admin 的团队才能看团队素材
const adminTeams = computed(() => teams.value.filter(x => ['owner', 'admin'].includes(x.role)))
async function loadAssets(reset = true) {
if (!serverStorage.value || !sync.value.loggedIn) return
if (reset) assetsPage.value = 1
assetsLoading.value = true; assetsErr.value = ''
try {
const tid = assetsScope.value === 'team' ? Number(assetsTeamId.value) : 0
const r = await call('ListServerFiles', assetsScope.value, tid, assetsPage.value)
assets.value = reset ? (r.items || []) : assets.value.concat(r.items || [])
assetsTotal.value = r.total || 0
} catch (e) {
assetsErr.value = errText(e)
if (reset) { assets.value = []; assetsTotal.value = 0 }
} finally { assetsLoading.value = false }
}
function reloadAssets() { loadAssets(true) }
function moreAssets() { assetsPage.value++; loadAssets(false) }
function onAssetsScope() {
if (assetsScope.value === 'team' && !assetsTeamId.value && adminTeams.value.length) assetsTeamId.value = adminTeams.value[0].id
reloadAssets()
}
async function deleteAsset(f) {
if (!confirm(t('assetsDeleteConfirm'))) return
try {
await call('DeleteServerFile', f.id)
assets.value = assets.value.filter(x => x.id !== f.id)
if (assetsTotal.value > 0) assetsTotal.value--
store.showToast({ type: 'success', key: 'assetsDeletedToast' })
} catch (e) { store.showToast({ type: 'error', text: errText(e) }) }
}
async function copyAsset(f) {
try {
await navigator.clipboard.writeText(f.url)
store.showToast({ type: 'success', key: 'assetsCopiedToast' })
} catch { store.showToast({ type: 'error', key: 'assetsCopyFailed' }) }
}
function openAssetPreview(f) {
previewSrc.value = f.url
previewOpen.value = true
}
function fmtSize(n) {
if (n >= 1 << 20) return (n / (1 << 20)).toFixed(1) + ' MB'
if (n >= 1024) return Math.round(n / 1024) + ' KB'
return (n || 0) + ' B'
}
// ---- 个人资料(昵称/头衔/邮箱/简介/技术栈标签) ----
const profile = reactive({ nickname: '', title: '', email: '', bio: '', techTags: [] })
const tagInput = ref('')
const profileMsg = ref('')
async function loadProfile() {
try {
const p = await call('GetMyProfile')
Object.assign(profile, { nickname: p.nickname || '', title: p.title || '', email: p.email || '', bio: p.bio || '', techTags: p.techTags || [] })
} catch {}
}
function addTag() {
const v = tagInput.value.trim()
if (!v) return
if (!profile.techTags.some(x => x.toLowerCase() === v.toLowerCase())) profile.techTags.push(v)
tagInput.value = ''
}
function removeTag(i) { profile.techTags.splice(i, 1) }
async function saveProfile() {
if (busy.value) return
busy.value = 'profile'; profileMsg.value = ''
if (tagInput.value.trim()) addTag()
try {
const p = await call('SaveMyProfile', { ...profile })
Object.assign(profile, { nickname: p.nickname, title: p.title, email: p.email, bio: p.bio, techTags: p.techTags || [] })
store.showToast({ type: 'success', key: 'profileSavedToast' })
} catch (e) { profileMsg.value = errText(e) }
finally { busy.value = '' }
}
// ---- 我的团队(状态共享自 team.js切换会联动窗口标题 ----
const newTeamName = ref('')
async function loadTeams() {
if (!sync.value.loggedIn) return
await loadTeamsShared()
}
async function switchTeam(tm) {
if (tm.current) return
try { await switchTeamShared(tm.id) } catch (e) { store.showToast({ type: 'error', text: errText(e) }) }
}
async function createTeam() {
const name = newTeamName.value.trim()
if (!name || busy.value) return
busy.value = 'team'
try {
await call('TeamCreate', name)
newTeamName.value = ''
await loadTeams()
store.showToast({ type: 'success', key: 'teamCreatedToast' })
} catch (e) { store.showToast({ type: 'error', text: errText(e) }) }
finally { busy.value = '' }
}
function setTab(v) {
tab.value = v
localStorage.setItem('cc-profile-tab', v)
if (v === 'teams') loadTeams()
// 素材库可用性跟随全局配置,进入时先刷新配置再拉列表(管理员可能刚在设置页改过)
if (v === 'assets') { loadTeams(); loadFileStorage().then(() => reloadAssets()) }
if (v === 'sync') loadProjSync()
}
// 按时段问候,让页面更像“我的空间”而不是后台表单
const greeting = computed(() => {
const h = new Date().getHours()
const key = h < 6 ? 'greetNight' : h < 11 ? 'greetMorning' : h < 14 ? 'greetNoon' : h < 18 ? 'greetAfternoon' : 'greetEvening'
return t(key)
})
// 新密码强度0 无 / 1 弱 / 2 中 / 3 强
const pwdStrength = computed(() => {
const v = pwdForm.next
if (!v) return 0
let s = v.length >= 6 ? 1 : 0
if (v.length >= 10) s++
if (/[A-Za-z]/.test(v) && /[0-9]/.test(v)) s++
if (/[^A-Za-z0-9]/.test(v)) s++
return Math.max(1, Math.min(3, s - (v.length < 6 ? 1 : 0)))
})
const strengthLabel = computed(() => [null, 'pwdWeak', 'pwdMedium', 'pwdStrong'][pwdStrength.value])
const errText = e => {
const code = String(e).split(':')[0].trim()
return t('errors.' + code) !== 'errors.' + code ? t('errors.' + code) : String(e)
}
// 后端存 UTCRFC3339展示时转为本地时区
const localSyncTime = computed(() => {
const s = sync.value.lastSyncAt
if (!s) return ''
const d = new Date(/[zZ]$|[+-]\d\d:?\d\d$/.test(s) ? s : s + 'Z')
if (isNaN(d.getTime())) return s.replace('T', ' ')
const p = n => String(n).padStart(2, '0')
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`
})
const scopeTags = [
{ icon: ListTodo, key: 'todos' },
{ icon: TicketCheck, key: 'tickets' },
{ icon: SettingsIcon, key: 'settings' },
{ icon: Star, key: 'scopeFavs' },
{ icon: Folder, key: 'projects' },
{ icon: FileText, key: 'scopeDocs' }
]
async function load() {
try {
const saved = await call('GetSettings')
form.avatarMode = saved.avatarMode || ''
form.avatarValue = saved.avatarValue || ''
form.imageMode = saved.imageMode || 'base64'
} catch {}
loaded.value = true
store.refreshSyncStatus()
try {
const [ts, ks] = await Promise.all([call('ListTodos', 'all', 0), call('ListTickets', 'all', 0)])
doneTodos.value = ts.filter(x => x.status === 'done').length
resolvedTickets.value = ks.filter(x => ['resolved', 'closed'].includes(x.status)).length
} catch {}
}
async function persist() {
if (!loaded.value) return
await store.saveSettings({ avatarMode: form.avatarMode, avatarValue: form.avatarValue, imageMode: form.imageMode })
}
watch(() => [form.avatarMode, form.avatarValue, form.imageMode], persist)
function loadLocalAvatarHistory() {
try {
const raw = JSON.parse(localStorage.getItem(AVATAR_HIST_KEY) || '[]')
return Array.isArray(raw) ? raw.filter(x => x && x.value).slice(0, 12) : []
} catch { return [] }
}
function saveLocalAvatarHistory(list) {
avatarHistory.value = list.slice(0, 12)
localStorage.setItem(AVATAR_HIST_KEY, JSON.stringify(avatarHistory.value.map(({ mode, value }) => ({ mode, value }))))
}
async function loadAvatarHistory() {
if (sync.value.loggedIn) {
try {
const items = await call('ListAvatarHistory')
avatarHistory.value = (items || []).map(x => ({ id: x.id, mode: x.mode, value: x.value, createdAt: x.createdAt }))
// 首次:把本地残留历史迁移到线上
const local = loadLocalAvatarHistory()
if (local.length && !avatarHistory.value.length) {
for (const it of [...local].reverse()) {
try { await call('PushAvatarHistory', it.mode || 'base64', it.value) } catch { /* ignore */ }
}
localStorage.removeItem(AVATAR_HIST_KEY)
const again = await call('ListAvatarHistory')
avatarHistory.value = (again || []).map(x => ({ id: x.id, mode: x.mode, value: x.value, createdAt: x.createdAt }))
}
return
} catch { /* 回退本地 */ }
}
avatarHistory.value = loadLocalAvatarHistory()
}
async function pushAvatarHistory(mode, value) {
if (!value) return
if (sync.value.loggedIn) {
try {
const items = await call('PushAvatarHistory', mode || 'base64', value)
avatarHistory.value = (items || []).map(x => ({ id: x.id, mode: x.mode, value: x.value, createdAt: x.createdAt }))
await refreshHistPreviews()
return
} catch { /* 回退本地 */ }
}
const next = [{ mode: mode || 'base64', value }, ...avatarHistory.value.filter(x => x.value !== value)]
saveLocalAvatarHistory(next)
await refreshHistPreviews()
}
async function resolveHistPreview(item) {
if (item._src) return item._src
if (item.mode === 'url' || /^https?:\/\//i.test(item.value)) {
try {
const { resolveImageSrc } = await import('../api')
item._src = await resolveImageSrc(item.value)
} catch { item._src = item.value }
} else if (item.mode === 'path') {
try { item._src = await call('ReadImageAsDataURL', item.value) } catch { item._src = '' }
} else {
item._src = item.value
}
return item._src
}
async function loadServerAvatars() {
serverAvatars.value = []
if (!serverStorage.value || !sync.value.loggedIn) return
try {
const r = await call('ListServerFiles', 'mine', 0, 1)
serverAvatars.value = (r.items || []).filter(f => f.kind === 'avatar')
} catch {}
}
const histPreviews = ref({})
async function refreshHistPreviews() {
const map = {}
for (const it of avatarHistory.value) {
map[it.value] = await resolveHistPreview(it)
}
histPreviews.value = map
}
// 存储走向由后端按管理员全局配置实时决定autoserver 时上传返回 url否则 base64。
async function pickAvatar() {
try {
const r = await call('PickAvatarImage', 'auto')
if (r && r.value) {
if (form.avatarValue) await pushAvatarHistory(form.avatarMode, form.avatarValue)
form.avatarMode = r.mode || 'base64'
form.avatarValue = r.value
await pushAvatarHistory(form.avatarMode, form.avatarValue)
}
} catch (e) { store.showToast({ type: 'error', text: errText(e) }) }
}
async function clearAvatar() {
if (form.avatarValue) await pushAvatarHistory(form.avatarMode, form.avatarValue)
form.avatarMode = ''
form.avatarValue = ''
}
function selectHistory(item) {
form.avatarMode = item.mode || 'base64'
form.avatarValue = item.value
}
async function selectServerAvatar(f) {
form.avatarMode = 'url'
form.avatarValue = f.url
await pushAvatarHistory('url', f.url)
}
// 打开弹窗时刷新全局配置,保证提示文案与实际走向一致
watch(avatarOpen, async v => {
if (!v) return
await loadFileStorage()
await loadAvatarHistory()
await refreshHistPreviews()
await loadServerAvatars()
})
async function syncNow() {
if (busy.value) return
busy.value = 'sync'; msg.value = ''
try {
const st = await call('SyncNow')
store.syncStatus = st
store.showToast({ type: 'success', key: 'syncDoneToast', params: { pushed: st.pushed, pulled: st.pulled } })
} catch (e) { msg.value = errText(e); store.showToast({ type: 'error', key: 'syncFailToast' }) }
finally { busy.value = '' }
}
// ---- 项目云同步:自动/手动模式、手动拉取、认领入口与忽略恢复 ----
const projSyncMode = ref('auto')
const ignoredProjects = ref([])
const pendingCount = ref(0)
async function loadProjSync() {
projSyncMode.value = store.settings.projectSyncMode || 'auto'
if (!sync.value.loggedIn) { ignoredProjects.value = []; pendingCount.value = 0; return }
try { ignoredProjects.value = (await call('ListCloudIgnoredProjects')) || [] } catch { ignoredProjects.value = [] }
try { pendingCount.value = ((await call('ListCloudPendingProjects')) || []).length } catch { pendingCount.value = 0 }
}
async function changeProjSyncMode() {
await store.saveSettings({ projectSyncMode: projSyncMode.value })
store.showToast({ type: 'success', key: projSyncMode.value === 'auto' ? 'projSyncAutoToast' : 'projSyncManualToast' })
}
async function syncProjectsNow() {
if (busy.value) return
busy.value = 'projsync'
try {
const st = await call('SyncProjectsNow')
store.syncStatus = st
store.showToast({ type: 'success', key: 'syncDoneToast', params: { pushed: st.pushed, pulled: st.pulled } })
await loadProjSync()
} catch (e) { store.showToast({ type: 'error', text: errText(e) }) }
finally { busy.value = '' }
}
async function unignoreProject(name) {
try {
await call('UnignoreCloudProject', name)
store.showToast({ type: 'success', key: 'projUnignoredToast', params: { name } })
await loadProjSync()
} catch (e) { store.showToast({ type: 'error', text: errText(e) }) }
}
function openClaim() {
store.cloudClaimOpen = true
}
// 认领弹窗关闭后刷新待认领计数
watch(() => store.cloudClaimOpen, v => { if (!v && tab.value === 'sync') loadProjSync() })
async function syncLogout() {
await call('SyncLogout')
store.showToast({ type: 'success', key: 'logoutToast' })
await store.refreshSyncStatus()
}
async function changePassword() {
if (busy.value) return
if (pwdForm.next.length < 6) { msg.value = t('errors.SYNC_PASSWORD_TOO_SHORT'); return }
if (pwdForm.next !== pwdForm.confirm) { msg.value = t('passwordMismatch'); return }
busy.value = 'password'; msg.value = ''
try {
await call('SyncChangePassword', pwdForm.old, pwdForm.next)
pwdForm.old = ''; pwdForm.next = ''; pwdForm.confirm = ''
store.showToast({ type: 'success', key: 'passwordChangedToast' })
} catch (e) { msg.value = errText(e) }
finally { busy.value = '' }
}
function onKey(e) {
if (e.key === 'Escape') avatarOpen.value = false
}
onMounted(async () => {
load()
loadProfile()
await loadFileStorage()
if (tab.value === 'teams') loadTeams()
if (tab.value === 'assets') { loadTeams(); reloadAssets() }
if (tab.value === 'sync') loadProjSync()
addEventListener('keydown', onKey)
})
onUnmounted(() => removeEventListener('keydown', onKey))
</script>
<template><div class="page profile-page">
<header class="page-head sticky-head"><div><h1>{{ t('profilePage') }}</h1><p>{{ t('profileSubtitle') }}</p></div></header>
<div v-if="!native" class="preview-notice panel"><Info /><div><b>{{ t('previewModeTitle') }}</b><small>{{ t('previewModeSync') }}</small></div></div>
<section class="profile-hero">
<i class="hero-orb a" aria-hidden="true" /><i class="hero-orb b" aria-hidden="true" /><i class="hero-orb c" aria-hidden="true" />
<i class="ph-grid" aria-hidden="true" />
<div class="profile-hero-main">
<button class="profile-avatar editable" :title="t('avatarEdit')" @click="avatarOpen = true">
<span class="ph-ring" aria-hidden="true" />
<span class="ph-photo">
<img v-if="store.avatarSrc" :src="store.avatarSrc" alt="" />
<b v-else-if="sync.username">{{ sync.username[0].toUpperCase() }}</b>
<UserRound v-else />
</span>
<i v-if="sync.loggedIn" class="profile-dot" :class="sync.lastError ? 'err' : (sync.online ? 'on' : 'off')" />
<span class="avatar-edit-mask"><Camera /></span>
</button>
<div class="profile-id">
<small class="ph-greet">{{ greeting }}</small>
<b class="ph-name">{{ profile.nickname || (sync.loggedIn ? sync.username : t('notLoggedIn')) }}</b>
<small v-if="profile.title" class="ph-title-tag">{{ profile.title }}</small>
<div v-if="sync.loggedIn" class="profile-badges">
<span class="p-badge" :class="sync.online ? 'on' : 'off'"><component :is="sync.online ? Wifi : WifiOff" />{{ sync.online ? t('online') : t('offline') }}</span>
<span v-if="sync.lastSyncAt" class="p-badge dim"><CalendarClock />{{ localSyncTime.slice(5, 16) }}</span>
<span v-else class="p-badge dim">{{ t('neverSynced') }}</span>
<span v-if="sync.pending > 0" class="p-badge warn"><UploadCloud />{{ t('pendingPush', { n: sync.pending }) }}</span>
</div>
<p v-else class="profile-guest-hint">{{ t('profileGuest') }}</p>
</div>
<div class="profile-hero-actions">
<template v-if="sync.loggedIn">
<button class="btn primary" :disabled="!!busy || sync.syncing" @click="syncNow"><RefreshCw :class="{ spin: busy === 'sync' || sync.syncing }" />{{ busy === 'sync' || sync.syncing ? t('syncingBtn') : t('syncNowBtn') }}</button>
<button class="btn secondary ghost-btn" @click="syncLogout"><LogOut />{{ t('logoutBtn') }}</button>
</template>
<button v-else class="btn primary" :disabled="!native" @click="store.loginOpen = true"><LogIn />{{ t('loginOrRegister') }}</button>
</div>
</div>
<p v-if="sync.lastError" class="db-message">{{ errText(sync.lastError) }}</p>
<p v-if="msg" class="db-message">{{ msg }}</p>
<div class="ph-stats">
<div class="ph-stat"><span class="ph-stat-ico folder"><Folder /></span><div><b>{{ store.dashboard.projects }}</b><span>{{ t('projects') }}</span></div></div>
<div class="ph-stat"><span class="ph-stat-ico done"><CircleCheck /></span><div><b>{{ doneTodos }}</b><span>{{ t('phDoneTodos') }}</span></div></div>
<div class="ph-stat"><span class="ph-stat-ico ticket"><TicketCheck /></span><div><b>{{ resolvedTickets }}</b><span>{{ t('phResolvedTickets') }}</span></div></div>
<div class="ph-stat"><span class="ph-stat-ico star"><Star /></span><div><b>{{ store.favorites.length }}</b><span>{{ t('favoriteProjects') }}</span></div></div>
</div>
</section>
<div class="profile-layout">
<nav class="profile-side" role="tablist">
<button role="tab" :aria-selected="tab === 'info'" :class="{ active: tab === 'info' }" @click="setTab('info')"><IdCard />{{ t('profileTabInfo') }}</button>
<button role="tab" :aria-selected="tab === 'teams'" :class="{ active: tab === 'teams' }" @click="setTab('teams')"><Users />{{ t('profileTabTeams') }}</button>
<button role="tab" :aria-selected="tab === 'assets'" :class="{ active: tab === 'assets' }" @click="setTab('assets')"><Images />{{ t('assetsTab') }}</button>
<button role="tab" :aria-selected="tab === 'security'" :class="{ active: tab === 'security' }" @click="setTab('security')"><ShieldCheck />{{ t('profileSecurity') }}</button>
<button role="tab" :aria-selected="tab === 'sync'" :class="{ active: tab === 'sync' }" @click="setTab('sync')"><CloudUpload />{{ t('profileTabSync') }}</button>
</nav>
<div class="profile-main">
<section v-if="tab === 'info'" class="panel profile-card">
<header class="pc-head">
<span class="pc-badge id"><IdCard /></span>
<div><b>{{ t('profileTabInfo') }}</b><small>{{ t('profileInfoHint') }}</small></div>
</header>
<div class="pc-form">
<label class="pc-field">
<span>{{ t('profileNickname') }}</span>
<div class="pc-input"><UserRound /><input v-model="profile.nickname" :placeholder="sync.username || t('profileNicknamePh')" maxlength="32" /></div>
</label>
<label class="pc-field">
<span>{{ t('profileJobTitle') }}</span>
<div class="pc-input"><IdCard /><input v-model="profile.title" :placeholder="t('profileJobTitlePh')" maxlength="48" /></div>
</label>
<label class="pc-field">
<span>{{ t('profileEmail') }}</span>
<div class="pc-input"><Info /><input v-model="profile.email" type="email" placeholder="you@example.com" maxlength="128" /></div>
</label>
<label class="pc-field pc-field-wide">
<span>{{ t('profileBio') }}</span>
<textarea v-model="profile.bio" class="pc-textarea" :placeholder="t('profileBioPh')" maxlength="300" rows="3" />
</label>
<div class="pc-field pc-field-wide">
<span class="pc-tags-label"><Tags />{{ t('profileTags') }}</span>
<div class="tag-editor">
<span v-for="(tg, i) in profile.techTags" :key="tg" class="tech-tag">{{ tg }}<button type="button" :title="t('delete')" @click="removeTag(i)"><X /></button></span>
<input v-model="tagInput" :placeholder="t('profileTagsPh')" maxlength="24" @keydown.enter.prevent="addTag" @keydown.188.prevent="addTag" @blur="addTag" />
</div>
<small class="pc-hint">{{ t('profileTagsHint') }}</small>
</div>
</div>
<p v-if="profileMsg" class="db-message">{{ profileMsg }}</p>
<div class="pc-actions">
<button class="btn primary" :disabled="!!busy" @click="saveProfile"><Check />{{ busy === 'profile' ? t('saving') : t('profileSaveBtn') }}</button>
</div>
</section>
<section v-else-if="tab === 'teams'" class="panel profile-card">
<header class="pc-head">
<span class="pc-badge team"><Users /></span>
<div><b>{{ t('profileTabTeams') }}</b><small>{{ t('profileTeamsHint') }}</small></div>
</header>
<div v-if="!sync.loggedIn" class="pc-empty">
<span class="pc-empty-ico"><Users /></span>
<p>{{ t('teamLoginHint') }}</p>
<button class="btn primary" :disabled="!native" @click="store.loginOpen = true"><LogIn />{{ t('loginOrRegister') }}</button>
</div>
<template v-else>
<p v-if="teamsErr" class="db-message">{{ errText(teamsErr) }}</p>
<p v-if="teamsLoading" class="pc-hint"><RefreshCw class="spin" /> {{ t('loading') }}</p>
<div v-else-if="teams.length" class="team-pick-list">
<button v-for="tm in teams" :key="tm.id" class="team-pick" :class="{ current: tm.current }" @click="switchTeam(tm)">
<span class="team-pick-badge">{{ tm.name[0] }}</span>
<span class="team-pick-main"><b>{{ tm.name }}</b><small>{{ t('teamRole_' + tm.role) }} · {{ t('teamMembersCount', { n: tm.members }) }}</small></span>
<Check v-if="tm.current" class="team-pick-check" />
</button>
</div>
<p v-else-if="!teamsErr" class="pc-hint">{{ t('teamNoneHint') }}</p>
<div class="team-create-row">
<input v-model="newTeamName" :placeholder="t('teamNamePh')" maxlength="64" @keyup.enter="createTeam" />
<button class="btn secondary" :disabled="!newTeamName.trim() || !!busy" @click="createTeam"><Plus />{{ t('teamCreateBtn') }}</button>
</div>
<div class="pc-actions">
<button class="btn secondary" @click="router.push('/team')">{{ t('teamGoHome') }}<ArrowRight /></button>
</div>
</template>
</section>
<section v-else-if="tab === 'assets'" class="panel profile-card">
<header class="pc-head">
<span class="pc-badge img"><Images /></span>
<div><b>{{ t('assetsTab') }}</b><small>{{ sync.userId === 1 ? t('assetsHintAdmin') : t('assetsHint') }}</small></div>
</header>
<div v-if="!sync.loggedIn" class="pc-empty">
<span class="pc-empty-ico"><Images /></span>
<p>{{ t('syncLoginHint') }}</p>
<button class="btn primary" :disabled="!native" @click="store.loginOpen = true"><LogIn />{{ t('loginOrRegister') }}</button>
</div>
<div v-else-if="!serverStorage" class="pc-empty">
<span class="pc-empty-ico"><Images /></span>
<p>{{ t('assetsNeedServer') }}</p>
</div>
<template v-else>
<div class="assets-bar">
<select v-model="assetsScope" class="fs-select slim" @change="onAssetsScope">
<option value="mine">{{ t('assetsScopeMine') }}</option>
<option v-if="adminTeams.length" value="team">{{ t('assetsScopeTeam') }}</option>
<option v-if="sync.userId === 1" value="all">{{ t('assetsScopeAll') }}</option>
</select>
<select v-if="assetsScope === 'team'" v-model.number="assetsTeamId" class="fs-select slim" @change="reloadAssets">
<option v-for="tm in adminTeams" :key="tm.id" :value="tm.id">{{ tm.name }}</option>
</select>
<span class="assets-count">{{ t('assetsCount', { n: assetsTotal }) }}</span>
<button class="btn secondary" :disabled="assetsLoading" @click="reloadAssets"><RefreshCw :class="{ spin: assetsLoading }" />{{ t('assetsRefresh') }}</button>
</div>
<p v-if="assetsErr" class="db-message">{{ assetsErr }}</p>
<div v-if="assets.length" class="assets-grid">
<figure v-for="f in assets" :key="f.id" class="asset-card">
<button type="button" class="asset-thumb" @click="openAssetPreview(f)"><RemoteImg :src="f.url" /></button>
<figcaption>
<b :title="f.original || f.name">{{ f.kind === 'avatar' ? t('assetsKindAvatar') : t('assetsKindContent') }} · {{ fmtSize(f.size) }}</b>
<small v-if="assetsScope !== 'mine'">{{ f.username || ('#' + f.userId) }}</small>
<small>{{ (f.createdAt || '').slice(0, 10) }}</small>
</figcaption>
<span class="asset-ops">
<button type="button" :title="t('assetsCopy')" @click="copyAsset(f)"><Copy /></button>
<button type="button" class="danger" :title="t('delete')" @click="deleteAsset(f)"><Trash2 /></button>
</span>
</figure>
</div>
<p v-else-if="!assetsLoading && !assetsErr" class="pc-hint">{{ t('assetsEmpty') }}</p>
<div v-if="assets.length && assets.length < assetsTotal" class="pc-actions assets-more">
<button class="btn secondary" :disabled="assetsLoading" @click="moreAssets">{{ assetsLoading ? t('loading') : t('assetsLoadMore') }}</button>
</div>
</template>
</section>
<section v-else-if="tab === 'security'" class="panel profile-card">
<header class="pc-head">
<span class="pc-badge shield"><ShieldCheck /></span>
<div><b>{{ t('changePasswordBtn') }}</b><small>{{ t('changePasswordHint') }}</small></div>
</header>
<template v-if="sync.loggedIn">
<div class="pc-form">
<label class="pc-field">
<span>{{ t('oldPasswordLabel') }}</span>
<div class="pc-input"><Lock /><input v-model="pwdForm.old" type="password" autocomplete="current-password" /></div>
</label>
<label class="pc-field">
<span>{{ t('newPasswordLabel') }}</span>
<div class="pc-input"><KeyRound /><input v-model="pwdForm.next" type="password" :placeholder="t('passwordPh')" autocomplete="new-password" /></div>
<div v-if="pwdForm.next" class="pc-strength" :data-level="pwdStrength">
<i /><i /><i />
<em>{{ t(strengthLabel) }}</em>
</div>
</label>
<label class="pc-field">
<span>{{ t('confirmPasswordLabel') }}</span>
<div class="pc-input" :class="{ err: pwdForm.confirm && pwdForm.confirm !== pwdForm.next }"><KeyRound /><input v-model="pwdForm.confirm" type="password" autocomplete="new-password" @keyup.enter="changePassword" /></div>
</label>
</div>
<div class="pc-actions">
<button class="btn primary" :disabled="!native || !!busy || !pwdForm.old || !pwdForm.next" @click="changePassword"><KeyRound />{{ busy === 'password' ? t('changingPassword') : t('changePasswordBtn') }}</button>
</div>
</template>
<div v-else class="pc-empty">
<span class="pc-empty-ico"><UserRound /></span>
<p>{{ t('syncLoginHint') }}</p>
<button class="btn primary" :disabled="!native" @click="store.loginOpen = true"><LogIn />{{ t('loginOrRegister') }}</button>
</div>
</section>
<section v-else class="panel profile-card">
<header class="pc-head">
<span class="pc-badge cloud"><CloudUpload /></span>
<div><b>{{ t('profileTabSync') }}</b><small>{{ t('profileSyncHint') }}</small></div>
</header>
<div class="pc-stats">
<div class="pc-stat"><span class="pc-stat-ico user"><UserRound /></span><div><span>{{ t('accountLabel') }}</span><b>{{ sync.loggedIn ? sync.username : t('notLoggedIn') }}</b></div></div>
<div class="pc-stat"><span class="pc-stat-ico" :class="sync.online ? 'net-on' : 'net-off'"><component :is="sync.online ? Wifi : WifiOff" /></span><div><span>{{ t('connectionState') }}</span><b :class="sync.online ? 'ok' : 'warn'">{{ sync.online ? t('online') : t('offline') }}</b></div></div>
<div class="pc-stat"><span class="pc-stat-ico time"><CalendarClock /></span><div><span>{{ t('lastSyncLabel') }}</span><b>{{ localSyncTime || t('neverSynced') }}</b></div></div>
<div class="pc-stat"><span class="pc-stat-ico push" :class="{ warn: sync.pending > 0 }"><UploadCloud /></span><div><span>{{ t('pendingLabel') }}</span><b :class="{ warn: sync.pending > 0 }">{{ sync.pending || 0 }}</b></div></div>
</div>
<div class="pc-scope">
<small>{{ t('syncScope') }}</small>
<div class="pc-scope-tags">
<span v-for="s in scopeTags" :key="s.key" class="pc-tag"><component :is="s.icon" />{{ t(s.key) }}</span>
</div>
</div>
<template v-if="sync.loggedIn">
<div class="proj-sync">
<header class="proj-sync-head">
<b><Folder />{{ t('projSyncTitle') }}</b>
<small>{{ t('projSyncHint') }}</small>
</header>
<div class="proj-sync-row">
<label class="proj-sync-mode">
<span>{{ t('projSyncModeLabel') }}</span>
<select v-model="projSyncMode" @change="changeProjSyncMode">
<option value="auto">{{ t('projSyncModeAuto') }}</option>
<option value="manual">{{ t('projSyncModeManual') }}</option>
</select>
</label>
<button class="btn secondary" :disabled="!!busy" @click="syncProjectsNow">
<RefreshCw :class="{ spin: busy === 'projsync' }" />{{ busy === 'projsync' ? t('syncingBtn') : t('projSyncNowBtn') }}
</button>
<button v-if="pendingCount" class="btn primary" @click="openClaim">
<CloudUpload />{{ t('projClaimBtn', { n: pendingCount }) }}
</button>
</div>
<div v-if="ignoredProjects.length" class="proj-ignored">
<small>{{ t('projIgnoredTitle') }}</small>
<div class="proj-ignored-list">
<span v-for="n in ignoredProjects" :key="n" class="proj-ignored-item">
{{ n }}<button type="button" :title="t('projUnignoreBtn')" @click="unignoreProject(n)"><X /></button>
</span>
</div>
</div>
</div>
</template>
</section>
</div>
</div>
<Teleport to="body">
<div v-if="avatarOpen" class="overlay" @click.self="avatarOpen = false">
<section class="modal avatar-modal" @click.stop>
<header>
<h2><Images class="panel-icon" />{{ t('avatarModalTitle') }}</h2>
<button type="button" @click="avatarOpen = false"><X /></button>
</header>
<div class="avatar-modal-body">
<div class="avatar-row">
<span class="avatar-preview"><img v-if="store.avatarSrc" :src="store.avatarSrc" alt="" /><UserRound v-else /></span>
<div class="avatar-controls">
<div class="sync-actions">
<button class="btn secondary" :disabled="!native" @click="pickAvatar"><ImageUp />{{ t('avatarPick') }}</button>
<button v-if="form.avatarValue" class="btn secondary" @click="clearAvatar"><X />{{ t('avatarClear') }}</button>
</div>
</div>
</div>
<p class="auto-update-hint">{{ t('storageFollowHint', { mode: serverStorage ? t('fsModeServer') : t('fsModeLocal') }) }}</p>
<div v-if="avatarHistory.length" class="avatar-hist">
<b>{{ t('avatarHistory') }}</b>
<div class="avatar-hist-grid">
<button v-for="it in avatarHistory" :key="it.value" type="button" class="avatar-hist-item" :class="{ on: form.avatarValue === it.value }" :title="t('avatarReselect')" @click="selectHistory(it)">
<img v-if="histPreviews[it.value]" :src="histPreviews[it.value]" alt="" />
<UserRound v-else />
</button>
</div>
</div>
<div v-if="serverAvatars.length" class="avatar-hist">
<b>{{ t('avatarServerHistory') }}</b>
<div class="avatar-hist-grid">
<button v-for="f in serverAvatars" :key="f.id" type="button" class="avatar-hist-item" :class="{ on: form.avatarValue === f.url }" :title="t('avatarReselect')" @click="selectServerAvatar(f)">
<RemoteImg :src="f.url" />
</button>
</div>
</div>
</div>
</section>
</div>
</Teleport>
<ImagePreview :open="previewOpen" :src="previewSrc" @close="previewOpen = false" />
</div></template>