Files
code-utils/frontend/src/App.vue
李琦 7c4109f687 feat: 提交详情能看 diff,也能丢给 AI 审这一次改动。文件检查可单独排除路径,TODO 只认大写,避免把 todo 表和模块当成待办标记。
热力图按容器宽度铺满一年,不再横向滚动。托盘右键换成可换皮肤的弹层;更新下载显示进度,退出后再由脚本拉起安装器,避免还占着 exe。
2026-08-19 15:46:03 +08:00

506 lines
24 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, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { LayoutDashboard, ScrollText, Settings, X, Database, SlidersHorizontal, Home, ListTodo, TicketCheck, CalendarDays, CalendarCheck2, Sparkles, UserRound, ClipboardList, Wrench, Rocket, ChevronDown, Bell, StickyNote, ListFilter, Palette, Bot, Users, NotebookPen, CloudUpload, Power, Image, Activity, Package, Shield, Plus, Pin } from 'lucide-vue-next'
import { useAppStore } from './store'
import DatabaseSetup from './components/DatabaseSetup.vue'
import BrowserBlocked from './components/BrowserBlocked.vue'
import AnalysisCanvas from './components/AnalysisCanvas.vue'
import LoginModal from './components/LoginModal.vue'
import CommandPalette from './components/CommandPalette.vue'
import DailyCard from './components/DailyCard.vue'
import AboutModal from './components/AboutModal.vue'
import MessageBell from './components/MessageBell.vue'
import TaskCenter from './components/TaskCenter.vue'
import NoteCenter from './components/NoteCenter.vue'
import LocalPackCenter from './components/LocalPackCenter.vue'
import TeamSwitcher from './components/TeamSwitcher.vue'
import TitleBar from './components/TitleBar.vue'
import CloudClaimModal from './components/CloudClaimModal.vue'
import { call, isNative, on } from './api'
import TrayPopup from './views/TrayPopup.vue'
const route = useRoute()
const router = useRouter()
const store = useAppStore()
const { t, locale } = useI18n()
const native = isNative()
const quickOpen = ref(false)
const aboutOpen = ref(false)
const exitOpen = ref(false)
const updateInfo = ref(null)
const updateBusy = ref(false)
const updateProgress = ref({ received: 0, total: 0 })
const appVersion = ref('1.0.0')
const updatePercent = computed(() => {
const total = Number(updateProgress.value.total) || 0
const received = Number(updateProgress.value.received) || 0
if (total <= 0) return 0
return Math.min(100, Math.round(received / total * 100))
})
function fmtSize(n) {
n = Number(n) || 0
if (n < 1024) return `${n} B`
if (n < 1048576) return `${(n / 1024).toFixed(1)} KB`
return `${(n / 1048576).toFixed(1)} MB`
}
function errText(e) {
const code = String(e).split(':')[0].trim()
const key = 'errors.' + code
return t(key) !== key ? t(key) : String(e)
}
const displayedTask = ref(null)
let off
let offMenus = []
let loadingHoldTimer
const activeTask = computed(() => Object.values(store.tasks).find(x => !['completed', 'error', 'cancelled'].includes(x.stage)))
const visibleTask = computed(() => activeTask.value || displayedTask.value)
const activeTaskProject = computed(() => visibleTask.value?.params?.project || store.projects.find(p => p.id === visibleTask.value?.projectId)?.name || '')
const loadingStyle = computed(() => store.settings.loadingStyle === 'fullscreen' ? 'fullscreen-orbit' : (store.settings.loadingStyle || 'fullscreen-orbit'))
const useFullscreenLoading = computed(() => loadingStyle.value !== 'bar')
const isTrayWindow = document.documentElement.classList.contains('tray-window')
async function updateSetting(key, value) {
const next = await store.saveSettings({ [key]: value })
if (key === 'locale') locale.value = next.locale
}
function openSettings() {
quickOpen.value = false
router.push('/settings')
}
// 侧栏头像下退出:模态框选择「退出登录」或「退出程序」。
async function exitLogout() {
exitOpen.value = false
try {
await call('SyncLogout')
await store.refreshSyncStatus()
store.showToast({ type: 'success', key: 'logoutToast' })
} catch { /* ignore */ }
}
async function exitQuit() {
exitOpen.value = false
try { await call('QuitApp') } catch { /* 预览模式无原生桥 */ }
}
// ---- 双列导航一级分类rail+ 二级菜单sub窄屏点一级弹浮层 ----
// 设置页无 query 时沿用本地记忆的分区,保证二级高亮与页面内容一致。
const settingsTab = r => String(r.query.tab || localStorage.getItem('cc-settings-tab') || 'rules')
const navGroups = [
{ id: 'overview', icon: Home, label: 'navOverview', items: [
{ to: '/', icon: Home, label: 'workbench', match: r => r.path === '/' },
{ to: '/today', icon: CalendarCheck2, label: 'todayTasks', badge: 'todayDue' },
{ to: '/calendar', icon: CalendarDays, label: 'calendar', dot: 'calendarDot' },
{ to: '/messages', icon: Bell, label: 'messages', badge: 'unread' }
] },
{ id: 'projects', icon: LayoutDashboard, label: 'navProjects', items: [
{ to: '/projects', icon: LayoutDashboard, label: 'projects', match: r => r.path === '/projects' || r.path.startsWith('/project/') },
{ to: '/ai', icon: Sparkles, label: 'aiChat' }
] },
{ id: 'work', icon: ClipboardList, label: 'navWork', items: [
{ to: '/todos', icon: ListTodo, label: 'todos', badge: 'todosOpen' },
{ to: '/tickets', icon: TicketCheck, label: 'tickets', badge: 'ticketsActive' },
{ to: '/notes', icon: StickyNote, label: 'notesPage' }
] },
{ id: 'team', icon: Users, label: 'navTeam', items: [
{ to: '/team', icon: Users, label: 'teamHome', match: r => r.path === '/team' },
{ to: '/team/tasks', icon: ClipboardList, label: 'teamTasks', badge: 'teamAssigned' },
{ to: '/team/reports', icon: NotebookPen, label: 'teamReports' }
] },
{ id: 'system', icon: Wrench, label: 'navSystem', items: [
{ to: '/launchpad', icon: Rocket, label: 'launchpad' },
{ to: '/ports', icon: Activity, label: 'portMonitor' },
{ to: '/pack-tasks', icon: Package, label: 'packTasksPage' },
{ to: '/logs', icon: ScrollText, label: 'logs' }
] },
{ id: 'settings', icon: Settings, label: 'settings', items: [
{ to: '/admin', icon: Shield, label: 'adminTitle', adminOnly: true, match: r => r.path === '/admin' },
{ to: '/settings?tab=rules', icon: ListFilter, label: 'tabRules', match: r => r.path === '/settings' && settingsTab(r) === 'rules' },
{ to: '/settings?tab=appearance', icon: Palette, label: 'tabAppearance', match: r => r.path === '/settings' && settingsTab(r) === 'appearance' },
{ to: '/settings?tab=ai', icon: Bot, label: 'aiAnalysis', match: r => r.path === '/settings' && settingsTab(r) === 'ai' },
// 全局文件存储配置:权威数据在服务器,仅管理员(云端账号 id=1可见可改
{ to: '/settings?tab=filestorage', icon: CloudUpload, label: 'tabFileStorage', adminOnly: true, match: r => r.path === '/settings' && settingsTab(r) === 'filestorage' },
{ to: '/settings?tab=kindicons', icon: Image, label: 'tabKindIcons', adminOnly: true, match: r => r.path === '/settings' && settingsTab(r) === 'kindicons' },
{ to: '/settings?tab=database', icon: Database, label: 'tabDatabase', match: r => r.path === '/settings' && settingsTab(r) === 'database' }
] }
]
// ---- 顶栏快捷入口:把常用菜单钉到 TitleBar「快捷」下拉 ----
const SHORTCUT_KEY = 'cc-nav-shortcuts'
const loadShortcuts = () => {
try {
const raw = JSON.parse(localStorage.getItem(SHORTCUT_KEY) || '[]')
return Array.isArray(raw) ? raw.filter(x => typeof x === 'string') : []
} catch { return [] }
}
const navShortcuts = ref(loadShortcuts())
const shortcutPicker = ref(false)
function saveShortcuts() {
localStorage.setItem(SHORTCUT_KEY, JSON.stringify(navShortcuts.value))
}
const catalogByTo = computed(() => {
const map = new Map()
for (const g of navGroups) {
for (const it of g.items) map.set(it.to, { ...it, groupId: g.id, groupLabel: g.label })
}
return map
})
// 可钉到顶栏的项:非概览内置、且当前用户可见
const pinnableItems = computed(() => {
const overviewTos = new Set(navGroups[0].items.map(it => it.to))
const out = []
for (const g of navGroups) {
if (g.id === 'overview') continue
for (const it of g.items) {
if (overviewTos.has(it.to)) continue
if (it.adminOnly && store.syncStatus.userId !== 1) continue
out.push({ ...it, groupId: g.id, groupLabel: g.label })
}
}
return out
})
function addShortcut(to) {
if (navShortcuts.value.includes(to)) return
navShortcuts.value = [...navShortcuts.value, to]
saveShortcuts()
}
function removeShortcut(to) {
navShortcuts.value = navShortcuts.value.filter(x => x !== to)
saveShortcuts()
}
function toggleShortcut(to) {
if (navShortcuts.value.includes(to)) removeShortcut(to)
else addShortcut(to)
}
// 钉到顶栏「快捷」菜单的项(带徽标数字,供 TitleBar 渲染)
const pinnedShortcuts = computed(() => navShortcuts.value
.map(to => catalogByTo.value.get(to))
.filter(it => it && (!it.adminOnly || store.syncStatus.userId === 1))
.map(it => ({
to: it.to,
label: it.label,
icon: it.icon,
badge: it.badge ? (store.badges[it.badge] || 0) : 0
})))
// adminOnly 项只对云端管理员id=1展示
const visibleItems = g => g.items.filter(it => !it.adminOnly || store.syncStatus.userId === 1)
const itemActive = it => it.match ? it.match(route) : route.path === it.to.split('?')[0]
// 导航徽标badge 显示数字dot 只显示小圆点;一级 rail 在组内任一非零时亮点
const badgeVal = it => it.badge ? (store.badges[it.badge] || 0) : 0
const badgeText = it => { const n = badgeVal(it); return n > 99 ? '99+' : String(n) }
const dotVal = it => it.dot ? !!store.badges[it.dot] : false
const groupDot = g => visibleItems(g).some(it => badgeVal(it) > 0 || dotVal(it))
const childActive = c => route.path === '/settings' && String(route.query.tab || '') === c.tab
const groupOfRoute = () => navGroups.find(g => g.items.some(it => itemActive(it)))?.id
const activeGroupId = ref(groupOfRoute() || 'overview')
const activeGroup = computed(() => navGroups.find(g => g.id === activeGroupId.value) || navGroups[0])
const expandedParents = ref({})
const railFlyout = ref(null) // 窄屏浮层 { group, top }
watch(() => route.fullPath, () => {
const g = groupOfRoute()
if (g) activeGroupId.value = g
railFlyout.value = null
for (const grp of navGroups) {
for (const it of grp.items) if (it.children && itemActive(it)) expandedParents.value[it.label] = true
}
}, { immediate: true })
const isNarrow = () => window.matchMedia('(max-width: 1150px)').matches
function clickGroup(g, ev) {
if (isNarrow()) {
railFlyout.value = railFlyout.value?.group.id === g.id
? null
: { group: g, top: Math.min(ev.currentTarget.getBoundingClientRect().top, innerHeight - 320) }
return
}
activeGroupId.value = g.id
const first = g.items[0]
if (first && !itemActive(first)) router.push(first.to)
}
function toggleParent(it, ev) {
ev.preventDefault()
ev.stopPropagation()
expandedParents.value[it.label] = !expandedParents.value[it.label]
}
function onFlyoutAway(e) {
if (railFlyout.value && !e.target.closest('.rail-flyout') && !e.target.closest('.rail-item')) railFlyout.value = null
}
// Ctrl+K / Cmd+K 呼出全局搜索面板
function onGlobalKey(e) {
if ((e.ctrlKey || e.metaKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 'k') {
e.preventDefault()
store.paletteOpen = !store.paletteOpen
}
}
onMounted(async () => {
if (isTrayWindow) {
document.documentElement.classList.add('tray-window')
try {
store.applyAppearance(await call('GetSettings'))
locale.value = store.settings.locale || 'zh-CN'
} catch { /* 托盘弹层只需要外观,失败时沿用本地缓存 */ }
return
}
addEventListener('keydown', onGlobalKey)
addEventListener('click', onFlyoutAway)
if (!native) return
off = store.listen()
offMenus = [
on('menu:navigate', p => router.push(String(p))),
on('menu:action', k => {
if (k === 'addProject') {
store.pendingAction = 'addProject'
router.push('/projects')
} else if (k === 'account') {
store.openAccount(router)
} else if (k === 'about') {
aboutOpen.value = true
} else if (k === 'sync-refresh') {
store.refreshSyncStatus()
}
}),
on('menu:set', p => p?.key && updateSetting(p.key, p.value)),
on('app:update-available', p => {
if (p && !p.upToDate && !p.skipped) updateInfo.value = p
}),
on('app:update-progress', p => {
if (p) updateProgress.value = { received: Number(p.received) || 0, total: Number(p.total) || 0 }
})
]
await store.boot()
locale.value = store.settings.locale || 'zh-CN'
try { appVersion.value = await call('GetAppVersion') } catch { /* keep fallback */ }
})
async function installUpdate() {
if (updateBusy.value) return
updateBusy.value = true
updateProgress.value = { received: 0, total: Number(updateInfo.value?.sizeBytes) || 0 }
try {
await call('DownloadAndInstallUpdate')
} catch (e) {
store.showToast({ type: 'error', text: errText(e) })
updateBusy.value = false
}
}
async function skipUpdate() {
if (updateBusy.value) return
try { await call('SkipAppUpdate', updateInfo.value?.latest || '') } catch {}
updateInfo.value = null
}
onUnmounted(() => {
removeEventListener('keydown', onGlobalKey)
removeEventListener('click', onFlyoutAway)
clearTimeout(loadingHoldTimer)
off?.()
offMenus.forEach(f => f?.())
})
watch(() => store.settings.locale, v => {
if (v) locale.value = v
})
// 切页时顺带刷新同步徽标(待推送数在本地增删改后保持新鲜)
watch(() => route.path, () => { if (store.syncStatus.loggedIn) store.refreshSyncStatus() })
watch(activeTask, task => {
clearTimeout(loadingHoldTimer)
if (task) {
displayedTask.value = task
return
}
loadingHoldTimer = setTimeout(() => {
displayedTask.value = null
}, 900)
}, { immediate: true })
</script>
<template>
<TrayPopup v-if="isTrayWindow" />
<BrowserBlocked v-else-if="!native" />
<template v-else>
<TitleBar :app-version="appVersion" :shortcuts="pinnedShortcuts" @about="aboutOpen = true" @manage-shortcuts="shortcutPicker = true" />
<DatabaseSetup v-if="store.bootstrap.state !== 'ready' && store.bootstrap.state !== 'loading'" :status="store.bootstrap" class="with-titlebar" />
<div v-else-if="store.bootstrap.state === 'ready'" class="shell">
<aside class="sidebar">
<div class="side-rail">
<nav class="rail-nav" aria-label="Primary">
<button v-for="g in navGroups" :key="g.id" type="button" class="rail-item" :class="{ active: g.id === activeGroupId }" @click="clickGroup(g, $event)">
<component :is="g.icon" /><span>{{ t(g.label) }}</span>
<i v-if="groupDot(g)" class="rail-dot" aria-hidden="true" />
</button>
</nav>
<div class="rail-tools">
<NoteCenter />
<TaskCenter />
<MessageBell />
<TeamSwitcher />
</div>
<button class="rail-user" :title="store.syncStatus.loggedIn ? store.syncStatus.username : t('loginNow')" @click="store.openAccount(router)">
<span class="user-avatar">
<img v-if="store.avatarSrc" :src="store.avatarSrc" alt="" />
<b v-else-if="store.syncStatus.username">{{ store.syncStatus.username[0].toUpperCase() }}</b>
<UserRound v-else />
<i v-if="store.syncStatus.loggedIn" class="user-dot" :class="store.syncStatus.lastError ? 'err' : (store.syncStatus.online ? 'on' : 'off')" :title="store.syncStatus.lastError || ''" />
</span>
<em v-if="store.syncStatus.loggedIn && store.syncStatus.pending > 0" class="user-pending rail-pending" :title="t('pendingSync', { n: store.syncStatus.pending })">{{ store.syncStatus.pending }}</em>
</button>
<div class="rail-foot">
<LocalPackCenter />
<button type="button" class="rail-quit" :title="t('exitMenu')" :disabled="!native" @click="exitOpen = true"><Power /></button>
</div>
</div>
<div class="side-sub">
<div class="sub-brand"><b>{{ t('app') }}</b></div>
<div class="sub-title">{{ t(activeGroup.label) }}</div>
<nav class="sub-nav" :aria-label="t(activeGroup.label)">
<template v-for="it in visibleItems(activeGroup)" :key="it.to">
<RouterLink :to="it.to" :class="{ active: itemActive(it) }">
<component :is="it.icon" /><span>{{ t(it.label) }}</span>
<em v-if="badgeVal(it)" class="nav-badge">{{ badgeText(it) }}</em>
<i v-else-if="dotVal(it)" class="nav-dot" aria-hidden="true" />
<button v-if="it.children" type="button" class="sub-caret" :class="{ open: expandedParents[it.label] }" :aria-label="t(it.label)" @click="toggleParent(it, $event)"><ChevronDown /></button>
</RouterLink>
<div v-if="it.children && expandedParents[it.label]" class="sub-children">
<RouterLink v-for="c in it.children" :key="c.to" :to="c.to" :class="{ active: childActive(c) }">{{ t(c.label) }}</RouterLink>
</div>
</template>
</nav>
<div class="sidebar-bottom">
<span class="version"><i />v{{ appVersion }}</span>
<button class="quick-settings-btn" :title="t('quickSettings')" @click="quickOpen = !quickOpen"><SlidersHorizontal /></button>
<section v-if="quickOpen" class="quick-settings popover-glass">
<header>
<b>{{ t('quickSettings') }}</b>
<button @click="quickOpen = false" aria-label="Close"><X /></button>
</header>
<label>
<span>{{ t('language') }}</span>
<select :value="store.settings.locale" @change="updateSetting('locale', $event.target.value)">
<option value="zh-CN">中文</option>
<option value="en">English</option>
</select>
</label>
<label>
<span>{{ t('theme') }}</span>
<select :value="store.settings.theme" @change="updateSetting('theme', $event.target.value)">
<option value="dark">{{ t('themeDark') }}</option>
<option value="light">{{ t('themeLight') }}</option>
<option value="system">{{ t('themeSystem') }}</option>
</select>
</label>
<label>
<span>{{ t('glassOpacity') }} · {{ store.settings.glassOpacity }}%</span>
<input type="range" min="30" max="75" :value="store.settings.glassOpacity" @input="updateSetting('glassOpacity', Number($event.target.value))" />
</label>
<button class="btn secondary full" @click="openSettings"><Settings />{{ t('openSettings') }}</button>
</section>
</div>
</div>
<div v-if="railFlyout" class="rail-flyout popover-glass" :style="{ top: railFlyout.top + 'px' }">
<b class="fly-title">{{ t(railFlyout.group.label) }}</b>
<template v-for="it in visibleItems(railFlyout.group)" :key="it.to">
<RouterLink :to="it.to" :class="{ active: itemActive(it) }">
<component :is="it.icon" /><span>{{ t(it.label) }}</span>
<em v-if="badgeVal(it)" class="nav-badge">{{ badgeText(it) }}</em>
<i v-else-if="dotVal(it)" class="nav-dot" aria-hidden="true" />
</RouterLink>
<RouterLink v-for="c in it.children || []" :key="c.to" :to="c.to" class="fly-child" :class="{ active: childActive(c) }">{{ t(c.label) }}</RouterLink>
</template>
</div>
</aside>
<Teleport to="body">
<div v-if="updateInfo" class="overlay" @click.self="skipUpdate">
<section class="modal exit-modal" @click.stop>
<header class="modal-head">
<h2>{{ t('appUpdateTitle') }}</h2>
<button type="button" class="nm-close" :title="t('close')" :disabled="updateBusy" @click="skipUpdate"><X /></button>
</header>
<p class="exit-hint">{{ t('appUpdateBody', { current: updateInfo.current, latest: updateInfo.latest }) }}</p>
<pre v-if="updateInfo.changelog" style="white-space:pre-wrap;font-size:.85rem;opacity:.8;max-height:160px;overflow:auto">{{ updateInfo.changelog }}</pre>
<p class="exit-hint">{{ t('appUpdateHint') }}</p>
<template v-if="updateBusy">
<p class="exit-hint">{{ updateProgress.total > 0 ? t('appUpdateProgress', { received: fmtSize(updateProgress.received), total: fmtSize(updateProgress.total), percent: updatePercent }) : t('appUpdateDownloading') }}</p>
<div class="update-dl-bar" role="progressbar" :aria-valuenow="updatePercent" aria-valuemin="0" aria-valuemax="100"><i :style="{ width: updatePercent + '%' }"/></div>
</template>
<div class="exit-actions">
<button type="button" class="btn secondary" :disabled="updateBusy" @click="skipUpdate">{{ t('appUpdateSkip') }}</button>
<button type="button" class="btn primary" :disabled="updateBusy" @click="installUpdate">{{ updateBusy ? t('appUpdateDownloading') : t('appUpdateInstall') }}</button>
</div>
</section>
</div>
</Teleport>
<Teleport to="body">
<div v-if="exitOpen" class="overlay" @click.self="exitOpen = false">
<section class="modal exit-modal" @click.stop>
<header class="modal-head">
<h2><Power />{{ t('exitMenu') }}</h2>
<button type="button" class="nm-close" :title="t('close')" @click="exitOpen = false"><X /></button>
</header>
<p class="exit-hint">{{ t('exitMenuHint') }}</p>
<div class="exit-actions">
<button v-if="store.syncStatus.loggedIn" type="button" class="btn secondary" @click="exitLogout">{{ t('logoutBtn') }}</button>
<button type="button" class="btn danger" :disabled="!native" @click="exitQuit">{{ t('quitApp') }}</button>
</div>
</section>
</div>
</Teleport>
<Teleport to="body">
<div v-if="shortcutPicker" class="overlay" @click.self="shortcutPicker = false">
<section class="modal nav-shortcut-modal" @click.stop>
<header class="modal-head">
<h2><Pin />{{ t('navShortcutPicker') }}</h2>
<button type="button" class="nm-close" :title="t('close')" @click="shortcutPicker = false"><X /></button>
</header>
<p class="nav-shortcut-hint">{{ t('navShortcutHint') }}</p>
<div class="nav-shortcut-list">
<button
v-for="it in pinnableItems"
:key="it.to"
type="button"
class="nav-shortcut-item"
:class="{ on: navShortcuts.includes(it.to) }"
@click="toggleShortcut(it.to)"
>
<component :is="it.icon" class="nav-shortcut-ico" />
<span class="nav-shortcut-label">{{ t(it.label) }}</span>
<small class="nav-shortcut-group">{{ t(it.groupLabel) }}</small>
<Pin v-if="navShortcuts.includes(it.to)" class="nav-shortcut-mark on" />
<Plus v-else class="nav-shortcut-mark" />
</button>
</div>
</section>
</div>
</Teleport>
<main><RouterView /></main>
<div v-if="visibleTask && !useFullscreenLoading" class="taskbar">
<div><b>{{ activeTaskProject || visibleTask.stage }}</b><span>{{ t(visibleTask.messageKey || 'task.start', visibleTask.params || {}) }}</span></div>
<div class="progress"><i :style="{ width: visibleTask.progress + '%' }" /></div>
<b>{{ visibleTask.progress }}%</b>
</div>
<div v-if="visibleTask && useFullscreenLoading" class="analysis-loading" :class="[loadingStyle, { holding: !activeTask }]">
<AnalysisCanvas :progress="visibleTask.progress" :variant="loadingStyle" />
<section>
<b>{{ activeTaskProject || t('analyzingProject') }}</b>
<span>{{ t(visibleTask.messageKey || 'task.start', visibleTask.params || {}) }}</span>
<div class="progress"><i :style="{ width: visibleTask.progress + '%' }" /></div>
<strong>{{ visibleTask.progress }}%</strong>
</section>
</div>
<div v-if="store.toast" class="toast" :class="[store.toast.type, { muted: store.toast.muted, leaving: store.toast.leaving }]">
{{ store.toast.key ? t(store.toast.key, store.toast.params || {}) : store.toast.text }}
<button @click="store.closeToast()" aria-label="Close"><X /></button>
</div>
<LoginModal v-if="store.loginOpen" />
<CommandPalette v-if="store.paletteOpen" />
<AboutModal v-if="aboutOpen" @close="aboutOpen = false" />
<CloudClaimModal />
<DailyCard />
</div>
<div v-else class="boot-loading with-titlebar"><Database class="spin" />正在检查数据库...</div>
</template>
</template>