更新若干功能
This commit is contained in:
@@ -2,12 +2,20 @@
|
||||
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 } from 'lucide-vue-next'
|
||||
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'
|
||||
import { useAppStore } from './store'
|
||||
import DatabaseSetup from './components/DatabaseSetup.vue'
|
||||
import BrowserBlocked from './components/BrowserBlocked.vue'
|
||||
import AnalysisCanvas from './components/AnalysisCanvas.vue'
|
||||
import { isNative } from './api'
|
||||
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'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -15,8 +23,11 @@ const store = useAppStore()
|
||||
const { t, locale } = useI18n()
|
||||
const native = isNative()
|
||||
const quickOpen = ref(false)
|
||||
const aboutOpen = ref(false)
|
||||
const appVersion = ref('1.0.0')
|
||||
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)))
|
||||
@@ -35,19 +46,138 @@ function openSettings() {
|
||||
router.push('/settings')
|
||||
}
|
||||
|
||||
// 侧边栏底部退出:与原生菜单/托盘「退出」一致,绕过最小化到托盘直接退出应用。
|
||||
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
|
||||
}
|
||||
}
|
||||
onMounted(async () => {
|
||||
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))
|
||||
]
|
||||
await store.boot()
|
||||
locale.value = store.settings.locale || 'zh-CN'
|
||||
try { appVersion.value = await call('GetAppVersion') } catch { /* keep fallback */ }
|
||||
})
|
||||
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) {
|
||||
@@ -65,8 +195,8 @@ watch(activeTask, task => {
|
||||
<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">
|
||||
<div class="brand">
|
||||
<span class="brand-mark animated-logo" aria-hidden="true">
|
||||
<div class="side-rail">
|
||||
<span class="brand-mark animated-logo rail-logo" aria-hidden="true" :title="t('app')">
|
||||
<svg viewBox="0 0 48 48" role="img">
|
||||
<defs>
|
||||
<linearGradient id="logoGlow" x1="0" x2="1" y1="0" y2="1">
|
||||
@@ -80,17 +210,49 @@ watch(activeTask, task => {
|
||||
<path class="logo-spark" d="M12 10h8M28 38h8" />
|
||||
</svg>
|
||||
</span>
|
||||
<b>{{ t('app') }}</b>
|
||||
<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>
|
||||
</div>
|
||||
<nav aria-label="Primary">
|
||||
<RouterLink to="/" :class="{ active: route.path === '/' }"><LayoutDashboard /><span>{{ t('dashboard') }}</span></RouterLink>
|
||||
<RouterLink to="/logs" :class="{ active: route.path === '/logs' }"><ScrollText /><span>{{ t('logs') }}</span></RouterLink>
|
||||
<RouterLink to="/settings" :class="{ active: route.path === '/settings' }"><Settings /><span>{{ t('settings') }}</span></RouterLink>
|
||||
</nav>
|
||||
<div class="sidebar-bottom">
|
||||
<span class="version"><i />v1.0.0</span>
|
||||
<button class="quick-settings-btn" :title="t('quickSettings')" @click="quickOpen = !quickOpen"><SlidersHorizontal /></button>
|
||||
<section v-if="quickOpen" class="quick-settings popover-glass">
|
||||
<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">
|
||||
<header>
|
||||
<b>{{ t('quickSettings') }}</b>
|
||||
<button @click="quickOpen = false" aria-label="Close"><X /></button>
|
||||
@@ -114,8 +276,20 @@ watch(activeTask, task => {
|
||||
<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>
|
||||
<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>
|
||||
<main><RouterView /></main>
|
||||
@@ -137,6 +311,10 @@ watch(activeTask, task => {
|
||||
{{ 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" />
|
||||
<DailyCard />
|
||||
</div>
|
||||
<div v-else class="boot-loading"><Database class="spin" />正在检查数据库...</div>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user