516 lines
26 KiB
Vue
516 lines
26 KiB
Vue
|
|
<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)
|
|||
|
|
}
|
|||
|
|
// 后端存 UTC(RFC3339),展示时转为本地时区
|
|||
|
|
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)
|
|||
|
|
// 存储走向由后端按管理员全局配置实时决定(auto):server 时上传返回 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>
|