Files
code-utils/frontend/src/views/Profile.vue

516 lines
26 KiB
Vue
Raw Normal View History

2026-08-14 07:52:01 +08:00
<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'
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 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: '', apiKey: '' })
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 || '', apiKey: c.apiKey || '' })
} 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 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()) }
}
// 按时段问候,让页面更像“我的空间”而不是后台表单
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)
// 存储走向由后端按管理员全局配置实时决定autoserver 时上传返回 url否则 base64。
async function pickAvatar() {
try {
const r = await call('PickAvatarImage', 'auto')
if (r && r.value) {
form.avatarMode = r.mode || 'base64'
form.avatarValue = r.value
}
} catch (e) { store.showToast({ type: 'error', text: errText(e) }) }
}
function clearAvatar() { form.avatarMode = ''; form.avatarValue = '' }
// 打开弹窗时刷新全局配置,保证提示文案与实际走向一致
watch(avatarOpen, v => { if (v) loadFileStorage() })
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 = '' }
}
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() }
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>{{ 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">
<a :href="f.url" target="_blank" rel="noreferrer"><img :src="f.url" loading="lazy" alt="" /></a>
<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>
</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>
</section>
</div>
</Teleport>
</div></template>