Files
code-utils/frontend/src/App.vue

321 lines
16 KiB
Vue
Raw Normal View History

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-14 07:51:46 +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 } 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'
import TeamSwitcher from './components/TeamSwitcher.vue'
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)
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-14 07:51:46 +08:00
// 侧边栏底部退出:与原生菜单/托盘「退出」一致,绕过最小化到托盘直接退出应用。
async function quitApp() {
if (!confirm(t('quitConfirm'))) return
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: '/logs', icon: ScrollText, label: 'logs' }
] },
{ id: 'settings', icon: Settings, label: 'settings', items: [
{ 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=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()
}
}),
on('menu:set', p => p?.key && updateSetting(p.key, p.value))
]
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
})
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" />
<DatabaseSetup v-else-if="store.bootstrap.state !== 'ready' && store.bootstrap.state !== 'loading'" :status="store.bootstrap" />
<div v-else-if="store.bootstrap.state === 'ready'" class="shell">
<aside class="sidebar">
2026-08-14 07:51:46 +08:00
<div class="side-rail">
<span class="brand-mark animated-logo rail-logo" aria-hidden="true" :title="t('app')">
2026-08-11 19:07:05 +08:00
<svg viewBox="0 0 48 48" role="img">
<defs>
<linearGradient id="logoGlow" x1="0" x2="1" y1="0" y2="1">
<stop offset="0%" stop-color="#6ee7ff" />
<stop offset="48%" stop-color="#8b5cf6" />
<stop offset="100%" stop-color="#34d399" />
</linearGradient>
</defs>
<rect class="logo-frame" x="6" y="6" width="36" height="36" rx="9" />
<path class="logo-track" d="M17 18l-6 6 6 6M31 18l6 6-6 6M27 14l-6 20" />
<path class="logo-spark" d="M12 10h8M28 38h8" />
</svg>
</span>
2026-08-14 07:51:46 +08:00
<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" :class="{ active: route.path === '/profile' }" :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>
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 quit-app-btn" :title="t('quitApp')" :disabled="!native" @click="quitApp"><Power /></button>
<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>
<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" />
<DailyCard />
2026-08-11 19:07:05 +08:00
</div>
<div v-else class="boot-loading"><Database class="spin" />正在检查数据库...</div>
</template>