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

379 lines
19 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-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'
1. 后台图表多样化 API 的 /admin/overview 新增了按 AI 提供商聚合的用量(providerSeries)和云端数据构成(dataDist)。Admin.vue 概览页改用 ECharts:日活折线图、Token 堆叠柱状图(叠加调用次数折线)、AI 提供商用量饼图、云端数据构成饼图。 2. 端口监控与刷新间隔 「存为应用」不再跳转启动台,而是在本页弹出复用的 LaunchAppFormModal 表单,保存成功后弹确认框询问「留在本页 / 前往启动台」。启动台和端口监控页头都加了 RefreshIntervalPicker(仅手动 / 5 / 10 / 30 / 60 秒,默认 30 秒),选择记忆在 localStorage。 3. 团队所有者与用户详情 团队列表的所有者、用户列表的用户名都改为「头像 + 昵称 + ID」的可点击单元格,点击弹出用户详情:头像、昵称、头衔、待办/工单/笔记/团队数量、注册时间、最近活跃、拥有团队数。API 新增 GET /admin/users/:id,团队列表关联查询出 ownerNickname/ownerAvatar。 4. 项目专属排除规则 SQLite exclusion_rules 加了 project_id 列(唯一约束改为 (project_id, pattern),旧库自动重建迁移)。项目详情页「代码」标签下新增专属规则面板,可增删;扫描时全局规则 + 项目规则叠加生效。规则随项目身份上云,其它机器认领项目后自动带回。 5. 一句话 AI 生成待办/工单 待办页和工单页工具栏各嵌入一个 AI 生成输入条:一句话回车后由后端 AIGenerateTasks 调用 AI 拆解成多条草稿,弹预览框逐条勾选、可改标题/类型/优先级/日期,选归属项目后批量保存。 6. 项目云同步与认领 全局挂载了 CloudClaimModal:登录同步后发现云端有本机未落地的项目时自动弹出(同一批只打扰一次),每个项目可「选目录认领」或「忽略」;工作台横幅和个人主页可随时再打开。 统计数据按机器码推送到 stats:<机器码> 文档,只推不拉,不同电脑的扫描历史互不覆盖。 个人主页 · 云同步新增「项目云同步」区块:自动/手动模式切换(手动模式下常规同步跳过项目文档,仅点「立即同步项目」时推拉)、待认领入口、已忽略项目的恢复列表。 途中补齐了约 60 个中英双语 i18n 键,并修掉了 PortMonitor 引用但缺失的 pmSavedToast 键。
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" />
1. 后台图表多样化 API 的 /admin/overview 新增了按 AI 提供商聚合的用量(providerSeries)和云端数据构成(dataDist)。Admin.vue 概览页改用 ECharts:日活折线图、Token 堆叠柱状图(叠加调用次数折线)、AI 提供商用量饼图、云端数据构成饼图。 2. 端口监控与刷新间隔 「存为应用」不再跳转启动台,而是在本页弹出复用的 LaunchAppFormModal 表单,保存成功后弹确认框询问「留在本页 / 前往启动台」。启动台和端口监控页头都加了 RefreshIntervalPicker(仅手动 / 5 / 10 / 30 / 60 秒,默认 30 秒),选择记忆在 localStorage。 3. 团队所有者与用户详情 团队列表的所有者、用户列表的用户名都改为「头像 + 昵称 + ID」的可点击单元格,点击弹出用户详情:头像、昵称、头衔、待办/工单/笔记/团队数量、注册时间、最近活跃、拥有团队数。API 新增 GET /admin/users/:id,团队列表关联查询出 ownerNickname/ownerAvatar。 4. 项目专属排除规则 SQLite exclusion_rules 加了 project_id 列(唯一约束改为 (project_id, pattern),旧库自动重建迁移)。项目详情页「代码」标签下新增专属规则面板,可增删;扫描时全局规则 + 项目规则叠加生效。规则随项目身份上云,其它机器认领项目后自动带回。 5. 一句话 AI 生成待办/工单 待办页和工单页工具栏各嵌入一个 AI 生成输入条:一句话回车后由后端 AIGenerateTasks 调用 AI 拆解成多条草稿,弹预览框逐条勾选、可改标题/类型/优先级/日期,选归属项目后批量保存。 6. 项目云同步与认领 全局挂载了 CloudClaimModal:登录同步后发现云端有本机未落地的项目时自动弹出(同一批只打扰一次),每个项目可「选目录认领」或「忽略」;工作台横幅和个人主页可随时再打开。 统计数据按机器码推送到 stats:<机器码> 文档,只推不拉,不同电脑的扫描历史互不覆盖。 个人主页 · 云同步新增「项目云同步」区块:自动/手动模式切换(手动模式下常规同步跳过项目文档,仅点「立即同步项目」时推拉)、待认领入口、已忽略项目的恢复列表。 途中补齐了约 60 个中英双语 i18n 键,并修掉了 PortMonitor 引用但缺失的 pmSavedToast 键。
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>