更新若干功能

This commit is contained in:
李琦
2026-08-15 17:18:00 +08:00
parent 4954295961
commit 6a81e479ef
77 changed files with 8165 additions and 2372 deletions

View File

@@ -6,6 +6,8 @@ import { UserRound, ImageUp, KeyRound, CloudUpload, LogIn, LogOut, RefreshCw, Wi
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()
@@ -14,19 +16,24 @@ 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: '', apiKey: '' })
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 || '', apiKey: c.apiKey || '' })
Object.assign(fsCfg, { mode: c.mode || 'local', baseUrl: c.baseUrl || '' })
} catch {}
}
@@ -76,6 +83,10 @@ async function copyAsset(f) {
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'
@@ -203,19 +214,115 @@ async function persist() {
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) }) }
}
function clearAvatar() { form.avatarMode = ''; form.avatarValue = '' }
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, v => { if (v) loadFileStorage() })
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 = ''
@@ -386,7 +493,7 @@ onUnmounted(() => removeEventListener('keydown', onKey))
<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>
<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>
@@ -413,7 +520,7 @@ onUnmounted(() => removeEventListener('keydown', onKey))
<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>
<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>
@@ -506,10 +613,27 @@ onUnmounted(() => removeEventListener('keydown', onKey))
</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>