2026-08-11 19:07:05 +08:00
|
|
|
|
<script setup>
|
|
|
|
|
|
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
|
|
|
|
|
import { useRoute, useRouter } from 'vue-router'
|
|
|
|
|
|
import { useI18n } from 'vue-i18n'
|
2026-08-15 17:18:00 +08:00
|
|
|
|
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 } from 'lucide-vue-next'
|
2026-08-11 19:07:05 +08:00
|
|
|
|
import { useAppStore } from './store'
|
|
|
|
|
|
import DatabaseSetup from './components/DatabaseSetup.vue'
|
|
|
|
|
|
import BrowserBlocked from './components/BrowserBlocked.vue'
|
|
|
|
|
|
import AnalysisCanvas from './components/AnalysisCanvas.vue'
|
2026-08-14 07:51:46 +08:00
|
|
|
|
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'
|
2026-08-15 17:18:00 +08:00
|
|
|
|
import LocalPackCenter from './components/LocalPackCenter.vue'
|
2026-08-14 07:51:46 +08:00
|
|
|
|
import TeamSwitcher from './components/TeamSwitcher.vue'
|
2026-08-14 17:54:06 +08:00
|
|
|
|
import TitleBar from './components/TitleBar.vue'
|
2026-08-17 09:04:53 +08:00
|
|
|
|
import CloudClaimModal from './components/CloudClaimModal.vue'
|
2026-08-14 07:51:46 +08:00
|
|
|
|
import { call, isNative, on } from './api'
|
2026-08-11 19:07:05 +08:00
|
|
|
|
|
|
|
|
|
|
const route = useRoute()
|
|
|
|
|
|
const router = useRouter()
|
|
|
|
|
|
const store = useAppStore()
|
|
|
|
|
|
const { t, locale } = useI18n()
|
|
|
|
|
|
const native = isNative()
|
|
|
|
|
|
const quickOpen = ref(false)
|
2026-08-14 07:51:46 +08:00
|
|
|
|
const aboutOpen = ref(false)
|
2026-08-15 17:18:00 +08:00
|
|
|
|
const exitOpen = ref(false)
|
|
|
|
|
|
const updateInfo = ref(null)
|
|
|
|
|
|
const updateBusy = ref(false)
|
2026-08-14 07:51:46 +08:00
|
|
|
|
const appVersion = ref('1.0.0')
|
2026-08-11 19:07:05 +08:00
|
|
|
|
const displayedTask = ref(null)
|
|
|
|
|
|
let off
|
2026-08-14 07:51:46 +08:00
|
|
|
|
let offMenus = []
|
2026-08-11 19:07:05 +08:00
|
|
|
|
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')
|
|
|
|
|
|
|
|
|
|
|
|
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')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-15 17:18:00 +08:00
|
|
|
|
// 侧栏头像下退出:模态框选择「退出登录」或「退出程序」。
|
|
|
|
|
|
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
|
2026-08-14 07:51:46 +08:00
|
|
|
|
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' },
|
2026-08-15 17:18:00 +08:00
|
|
|
|
{ to: '/ports', icon: Activity, label: 'portMonitor' },
|
|
|
|
|
|
{ to: '/pack-tasks', icon: Package, label: 'packTasksPage' },
|
2026-08-14 07:51:46 +08:00
|
|
|
|
{ to: '/logs', icon: ScrollText, label: 'logs' }
|
|
|
|
|
|
] },
|
|
|
|
|
|
{ id: 'settings', icon: Settings, label: 'settings', items: [
|
2026-08-15 17:18:00 +08:00
|
|
|
|
{ to: '/admin', icon: Shield, label: 'adminTitle', adminOnly: true, match: r => r.path === '/admin' },
|
2026-08-14 07:51:46 +08:00
|
|
|
|
{ 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' },
|
2026-08-15 17:18:00 +08:00
|
|
|
|
{ to: '/settings?tab=kindicons', icon: Image, label: 'tabKindIcons', adminOnly: true, match: r => r.path === '/settings' && settingsTab(r) === 'kindicons' },
|
2026-08-14 07:51:46 +08:00
|
|
|
|
{ to: '/settings?tab=database', icon: Database, label: 'tabDatabase', match: r => r.path === '/settings' && settingsTab(r) === 'database' }
|
|
|
|
|
|
] }
|
|
|
|
|
|
]
|
|
|
|
|
|
// 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 => g.items.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
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-08-11 19:07:05 +08:00
|
|
|
|
onMounted(async () => {
|
2026-08-14 07:51:46 +08:00
|
|
|
|
addEventListener('keydown', onGlobalKey)
|
|
|
|
|
|
addEventListener('click', onFlyoutAway)
|
2026-08-11 19:07:05 +08:00
|
|
|
|
if (!native) return
|
|
|
|
|
|
off = store.listen()
|
2026-08-14 07:51:46 +08:00
|
|
|
|
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()
|
|
|
|
|
|
}
|
|
|
|
|
|
}),
|
2026-08-15 17:18:00 +08:00
|
|
|
|
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
|
|
|
|
|
|
})
|
2026-08-14 07:51:46 +08:00
|
|
|
|
]
|
2026-08-11 19:07:05 +08:00
|
|
|
|
await store.boot()
|
|
|
|
|
|
locale.value = store.settings.locale || 'zh-CN'
|
2026-08-14 07:51:46 +08:00
|
|
|
|
try { appVersion.value = await call('GetAppVersion') } catch { /* keep fallback */ }
|
2026-08-11 19:07:05 +08:00
|
|
|
|
})
|
2026-08-15 17:18:00 +08:00
|
|
|
|
async function installUpdate() {
|
|
|
|
|
|
updateBusy.value = true
|
|
|
|
|
|
try {
|
|
|
|
|
|
await call('DownloadAndInstallUpdate')
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
store.showToast({ type: 'error', text: String(e) })
|
|
|
|
|
|
updateBusy.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
async function skipUpdate() {
|
|
|
|
|
|
try { await call('SkipAppUpdate', updateInfo.value?.latest || '') } catch {}
|
|
|
|
|
|
updateInfo.value = null
|
|
|
|
|
|
}
|
2026-08-11 19:07:05 +08:00
|
|
|
|
onUnmounted(() => {
|
2026-08-14 07:51:46 +08:00
|
|
|
|
removeEventListener('keydown', onGlobalKey)
|
|
|
|
|
|
removeEventListener('click', onFlyoutAway)
|
2026-08-11 19:07:05 +08:00
|
|
|
|
clearTimeout(loadingHoldTimer)
|
|
|
|
|
|
off?.()
|
2026-08-14 07:51:46 +08:00
|
|
|
|
offMenus.forEach(f => f?.())
|
2026-08-11 19:07:05 +08:00
|
|
|
|
})
|
|
|
|
|
|
watch(() => store.settings.locale, v => {
|
|
|
|
|
|
if (v) locale.value = v
|
|
|
|
|
|
})
|
2026-08-14 07:51:46 +08:00
|
|
|
|
// 切页时顺带刷新同步徽标(待推送数在本地增删改后保持新鲜)
|
|
|
|
|
|
watch(() => route.path, () => { if (store.syncStatus.loggedIn) store.refreshSyncStatus() })
|
2026-08-11 19:07:05 +08:00
|
|
|
|
watch(activeTask, task => {
|
|
|
|
|
|
clearTimeout(loadingHoldTimer)
|
|
|
|
|
|
if (task) {
|
|
|
|
|
|
displayedTask.value = task
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
loadingHoldTimer = setTimeout(() => {
|
|
|
|
|
|
displayedTask.value = null
|
|
|
|
|
|
}, 900)
|
|
|
|
|
|
}, { immediate: true })
|
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<template>
|
|
|
|
|
|
<BrowserBlocked v-if="!native" />
|
2026-08-14 17:54:06 +08:00
|
|
|
|
<template v-else>
|
|
|
|
|
|
<TitleBar :app-version="appVersion" @about="aboutOpen = 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">
|
2026-08-11 19:07:05 +08:00
|
|
|
|
<aside class="sidebar">
|
2026-08-14 07:51:46 +08:00
|
|
|
|
<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>
|
2026-08-15 17:18:00 +08:00
|
|
|
|
<button class="rail-user" :title="store.syncStatus.loggedIn ? store.syncStatus.username : t('loginNow')" @click="store.openAccount(router)">
|
2026-08-14 07:51:46 +08:00
|
|
|
|
<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>
|
2026-08-15 17:18:00 +08:00
|
|
|
|
<div class="rail-foot">
|
|
|
|
|
|
<LocalPackCenter />
|
|
|
|
|
|
<button type="button" class="rail-quit" :title="t('exitMenu')" :disabled="!native" @click="exitOpen = true"><Power /></button>
|
|
|
|
|
|
</div>
|
2026-08-11 19:07:05 +08:00
|
|
|
|
</div>
|
2026-08-14 07:51:46 +08:00
|
|
|
|
<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">
|
2026-08-11 19:07:05 +08:00
|
|
|
|
<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>
|
2026-08-14 07:51:46 +08:00
|
|
|
|
<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>
|
2026-08-11 19:07:05 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</aside>
|
2026-08-15 17:18:00 +08:00
|
|
|
|
<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')" @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>
|
|
|
|
|
|
<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>
|
2026-08-11 19:07:05 +08:00
|
|
|
|
<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>
|
2026-08-14 07:51:46 +08:00
|
|
|
|
<LoginModal v-if="store.loginOpen" />
|
|
|
|
|
|
<CommandPalette v-if="store.paletteOpen" />
|
|
|
|
|
|
<AboutModal v-if="aboutOpen" @close="aboutOpen = false" />
|
2026-08-17 09:04:53 +08:00
|
|
|
|
<CloudClaimModal />
|
2026-08-14 07:51:46 +08:00
|
|
|
|
<DailyCard />
|
2026-08-11 19:07:05 +08:00
|
|
|
|
</div>
|
2026-08-14 17:54:06 +08:00
|
|
|
|
<div v-else class="boot-loading with-titlebar"><Database class="spin" />正在检查数据库...</div>
|
|
|
|
|
|
</template>
|
2026-08-11 19:07:05 +08:00
|
|
|
|
</template>
|