Files
code-utils/frontend/src/store.js
2026-08-14 07:51:46 +08:00

206 lines
8.9 KiB
JavaScript
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.
import { defineStore } from 'pinia'
import { call, on, onTasksChanged } from './api'
export const useAppStore = defineStore('app', {
state: () => ({
bootstrap: { state: 'loading' },
projects: [],
projectGroups: [],
selectedProjectGroupId: Number(localStorage.getItem('cc-project-group-id') || 0),
dashboard: { projects: 0, totalLines: 0, commits: 0 },
settings: { theme: 'dark', locale: 'zh-CN', glassOpacity: 55, gitScope: 'current', autoRefresh: true, loadingStyle: 'fullscreen-orbit', imageMode: 'base64' },
tasks: {},
batchTaskIds: [],
batchSummary: null,
pendingAction: '',
favorites: [],
unreadMessages: 0,
// 左侧导航徽标(消息未读/待办/工单/今日到期/日历红点/团队指派)
badges: { unread: 0, todosOpen: 0, ticketsActive: 0, todayDue: 0, calendarDot: false, teamAssigned: 0 },
toast: null,
toastTimer: null,
toastLeaveTimer: null,
loading: false,
syncStatus: { configured: false, loggedIn: false, userId: 0, username: '', online: false, syncing: false },
avatarSrc: '',
loginOpen: false,
paletteOpen: false,
// 日历页"每日心语"入口:设为 'YYYY-MM-DD' 时 DailyCard 打开对应日期的卡片
dailyCardDate: '',
// 节日名 → { mode: 'photo'|'art', image: dataURL }(管理员上传,随同步分发)
festivalImages: {},
// 探测到加载失败的节日图key → true日历格自动回退动态插画
festBroken: {}
}),
actions: {
async boot() {
this.bootstrap = await call('GetBootstrapStatus')
if (this.bootstrap.state === 'ready') {
this.settings = await call('GetSettings')
this.applyAppearance(this.settings)
try { this.syncStatus = await call('GetSyncStatus') } catch {}
this.loadFestivalImages()
await this.refresh()
}
},
async loadFestivalImages() {
try { this.festivalImages = (await call('ListFestivalImages')) || {} } catch { return }
// 预探测 photo 模式的图片:解码失败的标记为 broken展示时回退动态插画
for (const [key, it] of Object.entries(this.festivalImages)) {
if (it.mode !== 'photo' || !it.image) continue
const probe = new Image()
probe.onerror = () => { this.festBroken = { ...this.festBroken, [key]: true } }
probe.onload = () => { if (this.festBroken[key]) { const n = { ...this.festBroken }; delete n[key]; this.festBroken = n } }
probe.src = it.image
}
},
applyAppearance(settings) {
this.settings = { ...this.settings, ...settings }
let theme = this.settings.theme || 'dark'
if (theme === 'system') theme = matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
document.documentElement.dataset.theme = theme
document.documentElement.style.setProperty('--glass-user-opacity', String((this.settings.glassOpacity || 55) / 100))
localStorage.setItem('cc-settings', JSON.stringify(this.settings))
this.resolveAvatar()
},
// resolveAvatar 把设置中的头像解析为可显示的 <img> 源path 模式需经后端读文件转 dataURL。
async resolveAvatar() {
const { avatarMode, avatarValue } = this.settings
if (!avatarMode || !avatarValue) { this.avatarSrc = ''; return }
if (avatarMode === 'path') {
try { this.avatarSrc = await call('ReadImageAsDataURL', avatarValue) } catch { this.avatarSrc = '' }
return
}
this.avatarSrc = avatarValue
},
async refreshSyncStatus() {
try { this.syncStatus = await call('GetSyncStatus') } catch {}
},
// openAccount 是所有登录入口的统一行为:未登录弹全屏登录层,已登录进个人主页。
openAccount(router) {
if (this.syncStatus.loggedIn) router?.push('/profile')
else this.loginOpen = true
},
async saveSettings(patch) {
const next = { ...this.settings, ...patch }
await call('SaveSettings', next)
this.applyAppearance(next)
return next
},
showToast(payload) {
clearTimeout(this.toastTimer)
clearTimeout(this.toastLeaveTimer)
this.toast = { ...payload, leaving: false, muted: false, id: Date.now() }
this.toastTimer = setTimeout(() => {
if (this.toast) this.toast.muted = true
}, 3000)
this.toastLeaveTimer = setTimeout(() => {
if (this.toast) this.toast.leaving = true
setTimeout(() => {
this.toast = null
}, 360)
}, 5000)
},
closeToast() {
clearTimeout(this.toastTimer)
clearTimeout(this.toastLeaveTimer)
if (this.toast) this.toast.leaving = true
setTimeout(() => {
this.toast = null
}, 220)
},
async refresh() {
this.loading = true
this.refreshSyncStatus()
try {
this.projectGroups = await call('ListProjectGroups')
if (this.selectedProjectGroupId && !this.projectGroups.some(g => g.id === this.selectedProjectGroupId)) {
this.setProjectGroup(0)
}
const groupId = this.selectedProjectGroupId || 0
;[this.projects, this.dashboard, this.favorites, this.unreadMessages] = await Promise.all([
groupId ? call('ListProjectsByGroup', groupId) : call('ListProjects'),
groupId ? call('GetDashboardByGroup', groupId) : call('GetDashboard'),
call('ListFavorites').catch(() => []),
call('UnreadMessageCount').catch(() => 0)
])
} finally {
this.loading = false
}
},
async toggleFavorite(projectId) {
const fav = await call('ToggleFavorite', projectId)
this.favorites = fav ? [projectId, ...this.favorites.filter(x => x !== projectId)] : this.favorites.filter(x => x !== projectId)
return fav
},
async refreshUnread() {
try { this.unreadMessages = await call('UnreadMessageCount') } catch {}
this.refreshBadges()
},
async refreshBadges() {
try { this.badges = await call('GetNavBadges') } catch {}
},
async changeProjectGroup(groupId) {
this.setProjectGroup(groupId)
await this.refresh()
},
setProjectGroup(groupId, persist = true) {
this.selectedProjectGroupId = Number(groupId) || 0
if (persist) localStorage.setItem('cc-project-group-id', String(this.selectedProjectGroupId))
},
listen() {
const offProgress = on('analysis:progress', e => {
delete this.tasks.__batch_pending__
this.tasks[e.taskId] = e
if (['completed', 'error', 'cancelled'].includes(e.stage)) {
if (this.batchTaskIds.includes(e.taskId)) {
this.batchTaskIds = this.batchTaskIds.filter(id => id !== e.taskId)
if (this.batchTaskIds.length) {
this.tasks.__batch_pending__ = { taskId: '__batch_pending__', projectId: 0, stage: 'queued', progress: 100, messageKey: e.messageKey, params: e.params }
}
}
this.showToast({ type: e.stage === 'completed' ? 'success' : 'error', key: e.messageKey, params: e.params })
this.refresh()
}
})
const offBatch = on('batch:done', s => {
this.batchSummary = s
this.batchTaskIds = []
delete this.tasks.__batch_pending__
this.showToast({ type: s.failed ? 'error' : 'success', key: 'batchDoneToast', params: { completed: s.completed, total: s.total } })
this.refresh()
})
const offMessage = on('message:new', () => { this.unreadMessages += 1; this.refreshBadges() })
const offSync = on('sync:done', async st => {
this.syncStatus = st || this.syncStatus
this.refreshBadges()
// 拉取可能更新了设置(头像 / API Key刷新本地副本
try { this.applyAppearance(await call('GetSettings')) } catch {}
// 拉取可能带来新项目/分组/收藏,刷新项目列表
if (st?.pulled > 0) {
this.refresh().catch(() => {})
this.loadFestivalImages()
}
})
// 徽标:启动即取,任务变更事件 + 60s 轮询兜底
this.refreshBadges()
const offTasks = onTasksChanged(() => this.refreshBadges())
const badgeTimer = setInterval(() => this.refreshBadges(), 60000)
return () => { offProgress?.(); offBatch?.(); offMessage?.(); offSync?.(); offTasks?.(); clearInterval(badgeTimer) }
},
async analyze(id, kind = 'all') {
const task = await call('StartAnalysis', id, kind)
this.tasks[task] = { taskId: task, projectId: id, stage: 'start', progress: 1, messageKey: 'task.start' }
return task
},
async batchAnalyze(groupId = 0) {
const ids = groupId ? await call('StartBatchAnalysisByGroup', groupId) : await call('StartBatchAnalysis')
this.batchTaskIds = ids || []
if (this.batchTaskIds.length && !Object.values(this.tasks).some(x => !['completed', 'error', 'cancelled'].includes(x.stage))) {
this.tasks.__batch_pending__ = { taskId: '__batch_pending__', projectId: 0, stage: 'queued', progress: 1, messageKey: 'task.start' }
}
return this.batchTaskIds
}
}
})