更新若干功能

This commit is contained in:
李琦
2026-08-14 17:54:06 +08:00
parent 89bc265f68
commit ce00c9d193
18 changed files with 779 additions and 158 deletions

View File

@@ -15,6 +15,7 @@ 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 TitleBar from './components/TitleBar.vue'
import { call, isNative, on } from './api'
const route = useRoute()
@@ -192,24 +193,12 @@ watch(activeTask, task => {
<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">
<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">
<aside class="sidebar">
<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">
<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>
<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>
@@ -316,5 +305,6 @@ watch(activeTask, task => {
<AboutModal v-if="aboutOpen" @close="aboutOpen = false" />
<DailyCard />
</div>
<div v-else class="boot-loading"><Database class="spin" />正在检查数据库...</div>
<div v-else class="boot-loading with-titlebar"><Database class="spin" />正在检查数据库...</div>
</template>
</template>

View File

@@ -32,3 +32,23 @@ export function onTasksChanged(cb) {
// 调试/自动化钩子:桌面环境内页面 JS 本就能访问 Wails 桥,这里只是给控制台一个入口。
if (typeof window !== 'undefined') window.__cc = { call }
/** 复制纯文本到剪贴板;失败时抛错供调用方 toast。 */
export async function copyText(text) {
const v = String(text ?? '')
if (!v) throw new Error('EMPTY')
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(v)
return
}
// 无 clipboard API 时的降级(极少数环境)
const ta = document.createElement('textarea')
ta.value = v
ta.setAttribute('readonly', '')
ta.style.cssText = 'position:fixed;left:-9999px;top:0'
document.body.appendChild(ta)
ta.select()
const ok = document.execCommand('copy')
document.body.removeChild(ta)
if (!ok) throw new Error('COPY_FAILED')
}

View File

@@ -2,8 +2,8 @@
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { Sparkles, RefreshCw, NotebookPen, Settings } from 'lucide-vue-next'
import { call, on } from '../api'
import { Sparkles, RefreshCw, NotebookPen, Settings, Copy } from 'lucide-vue-next'
import { call, on, copyText } from '../api'
import { useAppStore } from '../store'
import MarkdownView from './MarkdownView.vue'
@@ -34,6 +34,14 @@ async function gen(kind) {
store.showToast({ type: 'error', text: t('errors.' + code) !== 'errors.' + code ? t('errors.' + code) : String(e) })
}
}
async function copyContent(text) {
try {
await copyText(text)
store.showToast({ type: 'success', key: 'copied' })
} catch {
store.showToast({ type: 'error', key: 'copyFailed' })
}
}
const fmtTime = at => {
const d = new Date(at)
if (isNaN(d)) return ''
@@ -69,6 +77,7 @@ onUnmounted(() => offSummary?.())
<header>
<b><Sparkles />{{ t('wbAiPlan') }}</b>
<time v-if="plan" :class="{ stale: !isToday(plan.generatedAt) }">{{ fmtTime(plan.generatedAt) }}</time>
<button v-if="plan?.content" class="btn secondary" :title="t('copyReport')" @click="copyContent(plan.content)"><Copy />{{ t('copyReport') }}</button>
<button class="btn secondary" :disabled="!!busy" @click="gen('dayplan')">
<RefreshCw :class="{ spin: busy === 'dayplan' }" />{{ busy === 'dayplan' ? t('wbGenerating') : (plan ? t('wbPlanRegen') : t('wbPlanGen')) }}
</button>
@@ -80,6 +89,7 @@ onUnmounted(() => offSummary?.())
<header>
<b><NotebookPen />{{ t('wbReport') }}</b>
<time v-if="report" :class="{ stale: !isToday(report.generatedAt) }">{{ fmtTime(report.generatedAt) }}</time>
<button v-if="report?.content" class="btn secondary" :title="t('copyReport')" @click="copyContent(report.content)"><Copy />{{ t('copyReport') }}</button>
<button class="btn primary" :disabled="!!busy" @click="gen('dayreport')">
<NotebookPen :class="{ spin: busy === 'dayreport' }" />{{ busy === 'dayreport' ? t('wbGenerating') : t('wbReportGen') }}
</button>

View File

@@ -0,0 +1,218 @@
<script setup>
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { Window } from '@wailsio/runtime'
import { Minus, Square, Copy, X, ChevronDown } from 'lucide-vue-next'
import { useAppStore } from '../store'
import { call, isNative } from '../api'
defineProps({
appVersion: { type: String, default: '2.0.0' }
})
const emit = defineEmits(['about'])
const { t, locale } = useI18n()
const router = useRouter()
const store = useAppStore()
const native = isNative()
const openMenu = ref('')
const maximised = ref(false)
const menus = computed(() => [
{
id: 'file',
label: t('menuFile'),
items: [
{ label: t('menuNewProject'), action: 'addProject', accel: 'Ctrl+N' },
{ label: t('menuAnalyzeNow'), action: 'batch' },
{ label: t('menuDatabase'), nav: '/settings?tab=database' },
{ sep: true },
{ label: t('menuQuit'), action: 'quit', accel: 'Ctrl+Q' }
]
},
{
id: 'view',
label: t('menuView'),
items: [
{ label: t('themeDark'), set: { key: 'theme', value: 'dark' }, check: store.settings.theme === 'dark' },
{ label: t('themeLight'), set: { key: 'theme', value: 'light' }, check: store.settings.theme === 'light' },
{ label: t('themeSystem'), set: { key: 'theme', value: 'system' }, check: store.settings.theme === 'system' },
{ sep: true },
{ label: '简体中文', set: { key: 'locale', value: 'zh-CN' }, check: store.settings.locale === 'zh-CN' },
{ label: 'English', set: { key: 'locale', value: 'en' }, check: store.settings.locale === 'en' }
]
},
{
id: 'tools',
label: t('menuTools'),
items: [
{ label: t('menuAnalyzeNow'), action: 'batch' },
{ label: t('aiChat'), nav: '/ai' },
{ label: t('settings'), nav: '/settings' },
{ label: t('logs'), nav: '/logs' }
]
},
{
id: 'account',
label: t('menuAccount'),
items: store.syncStatus.loggedIn
? [
{ label: (store.syncStatus.username || t('profilePage')) + ' · ' + t('profilePage'), nav: '/profile' },
{ label: t('syncNowBtn'), action: 'sync' },
{ sep: true },
{ label: t('logoutBtn'), action: 'logout' }
]
: [
{ label: t('loginOrRegister'), action: 'account' }
]
},
{
id: 'help',
label: t('menuHelp'),
items: [
{ label: t('menuAbout'), action: 'about' }
]
}
])
function toggleMenu(id) {
openMenu.value = openMenu.value === id ? '' : id
}
function closeMenus() {
openMenu.value = ''
}
async function updateSetting(key, value) {
const next = await store.saveSettings({ [key]: value })
if (key === 'locale') locale.value = next.locale
}
async function runItem(it) {
closeMenus()
if (it.nav) {
router.push(it.nav)
return
}
if (it.set) {
await updateSetting(it.set.key, it.set.value)
return
}
switch (it.action) {
case 'addProject':
store.pendingAction = 'addProject'
router.push('/projects')
break
case 'batch':
try { await call('StartBatchAnalysis') } catch { /* ignore */ }
break
case 'sync':
try { await call('SyncNow') } catch { /* ignore */ }
break
case 'logout':
try { await call('SyncLogout') } catch { /* ignore */ }
store.refreshSyncStatus()
break
case 'account':
store.openAccount(router)
break
case 'about':
emit('about')
break
case 'quit':
if (confirm(t('quitConfirm'))) {
try { await call('QuitApp') } catch { /* ignore */ }
}
break
}
}
async function refreshMax() {
if (!native) return
try { maximised.value = await Window.IsMaximised() } catch { /* ignore */ }
}
async function minimise() {
if (!native) return
try { await Window.Minimise() } catch { /* ignore */ }
}
async function toggleMax() {
if (!native) return
try {
await Window.ToggleMaximise()
await refreshMax()
} catch { /* ignore */ }
}
async function closeWin() {
if (!native) return
// Window.Close → WindowClosing hook开启「最小化到托盘」时隐藏而非退出。
try { await Window.Close() } catch { /* ignore */ }
}
function onDocClick(e) {
if (!e.target.closest('.tb-menu')) closeMenus()
}
function onKey(e) {
if (e.key === 'Escape') closeMenus()
}
onMounted(() => {
addEventListener('click', onDocClick)
addEventListener('keydown', onKey)
refreshMax()
})
onUnmounted(() => {
removeEventListener('click', onDocClick)
removeEventListener('keydown', onKey)
})
</script>
<template>
<header class="titlebar" @dblclick="toggleMax">
<div class="tb-brand">
<span class="brand-mark animated-logo tb-logo" aria-hidden="true">
<svg viewBox="0 0 48 48" role="img">
<defs>
<linearGradient id="tbLogoGlow" 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>
<b class="tb-name">{{ t('app') }}</b>
<em class="tb-ver">v{{ appVersion }}</em>
</div>
<nav class="tb-menus" aria-label="Application">
<div v-for="m in menus" :key="m.id" class="tb-menu" :class="{ open: openMenu === m.id }">
<button type="button" class="tb-menu-btn" @click.stop="toggleMenu(m.id)">
{{ m.label }}<ChevronDown />
</button>
<div v-if="openMenu === m.id" class="tb-drop popover-glass" @click.stop>
<template v-for="(it, i) in m.items" :key="i">
<hr v-if="it.sep" />
<button v-else type="button" class="tb-item" :class="{ checked: it.check }" @click="runItem(it)">
<span>{{ it.label }}</span>
<kbd v-if="it.accel">{{ it.accel }}</kbd>
<i v-else-if="it.check" class="tb-check"></i>
</button>
</template>
</div>
</div>
</nav>
<div class="tb-spacer" />
<div class="tb-controls">
<button type="button" class="tb-win" :title="t('winMin')" @click.stop="minimise"><Minus /></button>
<button type="button" class="tb-win" :title="maximised ? t('winRestore') : t('winMax')" @click.stop="toggleMax">
<Copy v-if="maximised" /><Square v-else />
</button>
<button type="button" class="tb-win close" :title="t('winClose')" @click.stop="closeWin"><X /></button>
</div>
</header>
</template>

View File

@@ -399,7 +399,12 @@ const zh = {
menuDatabase: '数据管理',
menuQuit: '退出',
menuAnalyzeNow: '立即分析全部',
menuNewProject: '新建项目',
menuAbout: '关于',
winMin: '最小化',
winMax: '最大化',
winRestore: '还原',
winClose: '关闭',
aiChat: 'AI 分析',
aiSubtitle: '基于项目数据的智能分析与问答',
aiSparkLite: '星火 Lite',
@@ -442,6 +447,8 @@ const zh = {
copyContent: '复制内容',
copied: '已复制到剪贴板',
copyFailed: '复制失败',
copyReport: '复制',
copyDigest: '复制摘要',
settingsSubtitle: '管理排除规则、界面和数据库配置',
tabRules: '排除规则',
tabAppearance: '界面设置',
@@ -741,11 +748,12 @@ const zh = {
dbCurrentLoc: '当前位置',
dbNoPath: '未获取到数据库路径',
copyPathTitle: '复制路径',
dbMigratedMsg: '数据库已迁移并切换到新位置',
dbMigrateFail: '数据库迁移失败',
dbMigratedMsg: '本地数据库文件已移动到新位置',
dbMigrateFail: '移动数据库文件失败',
dbPathCopied: '数据库路径已复制',
dbCopyFail: '无法复制路径',
migrateBtn: '选择新位置并迁移',
migrateBtn: '更换数据库文件位置',
dbRelocateHint: '仅移动本机 SQLite 文件;云端 MySQL 表结构请用仓库根目录 init.sql 初始化,应用与前端不会执行建表/迁移。',
dangerZone: '清空数据',
dangerDesc: '选择要清空的数据类型,此操作不可恢复。',
clearStats: '清空统计数据',
@@ -1238,7 +1246,12 @@ const en = {
menuDatabase: 'Data management',
menuQuit: 'Quit',
menuAnalyzeNow: 'Analyze all now',
menuNewProject: 'New project',
menuAbout: 'About',
winMin: 'Minimize',
winMax: 'Maximize',
winRestore: 'Restore',
winClose: 'Close',
aiChat: 'AI Analysis',
aiSubtitle: 'Project-aware analysis and chat',
aiSparkLite: 'Spark Lite',
@@ -1281,6 +1294,8 @@ const en = {
copyContent: 'Copy content',
copied: 'Copied to clipboard',
copyFailed: 'Copy failed',
copyReport: 'Copy',
copyDigest: 'Copy digest',
settingsSubtitle: 'Manage exclusion rules, appearance and database',
tabRules: 'Exclusion rules',
tabAppearance: 'Appearance',
@@ -1580,11 +1595,12 @@ const en = {
dbCurrentLoc: 'Current location',
dbNoPath: 'Database path unavailable',
copyPathTitle: 'Copy path',
dbMigratedMsg: 'Database migrated to the new location',
dbMigrateFail: 'Database migration failed',
dbMigratedMsg: 'Local SQLite file moved to the new location',
dbMigrateFail: 'Failed to move the database file',
dbPathCopied: 'Database path copied',
dbCopyFail: 'Could not copy the path',
migrateBtn: 'Choose a new location and migrate',
migrateBtn: 'Change database file location',
dbRelocateHint: 'Only moves the local SQLite file. Cloud MySQL schema must be created with init.sql; the app and frontend never run DDL migrations.',
dangerZone: 'Clear data',
dangerDesc: 'Choose what to clear. This cannot be undone.',
clearStats: 'Clear statistics',

View File

@@ -1328,9 +1328,9 @@ html[data-theme=light] .ph-stats{background:rgba(255,255,255,.5)}
.ai-day-block>header{display:flex;align-items:center;gap:10px;margin-bottom:4px}
.ai-day-block>header b{display:flex;align-items:center;gap:8px;font-size:14px}
.ai-day-block>header b svg{width:16px;height:16px;color:var(--primary)}
.ai-day-block>header time{font-size:11.5px;color:var(--muted)}
.ai-day-block>header time{font-size:11.5px;color:var(--muted);margin-right:auto}
.ai-day-block>header time.stale{color:var(--yellow)}
.ai-day-block>header .btn{margin-left:auto;height:32px;padding:0 13px;font-size:12.5px;flex:none}
.ai-day-block>header .btn{height:32px;padding:0 13px;font-size:12.5px;flex:none;margin-left:0}
.ai-day-body{font-size:13px;line-height:1.7;color:var(--text);overflow-wrap:anywhere}
.ai-day-body :is(h1,h2,h3){font-size:13.5px;margin:10px 0 4px}
.ai-day-body ul,.ai-day-body ol{margin:6px 0;padding-left:20px}
@@ -1755,6 +1755,7 @@ html[data-theme=light] .ph-stats{background:rgba(255,255,255,.5)}
.team-digest{padding:18px 20px;margin-bottom:16px}
.team-digest .pc-head{margin-bottom:0;align-items:center}
.team-digest .pc-head .spacer{flex:1}
.db-relocate-hint{margin:10px 0 0;font-size:12px;line-height:1.55;color:var(--muted)}
.team-digest .md-body{margin-top:14px;padding-top:14px;border-top:1px solid var(--border)}
.team-report-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:13px}
.team-report-card{padding:14px 16px;margin:0}
@@ -1762,7 +1763,9 @@ html[data-theme=light] .ph-stats{background:rgba(255,255,255,.5)}
.team-report-card header b{flex:1;min-width:0;font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.team-report-card header time{color:var(--muted);font-size:11px;flex:none}
.team-report-card header em{font-style:normal;color:var(--yellow);font-size:11px;font-weight:800;flex:none}
.team-report-card header .btn.sm{height:26px;padding:0 9px;font-size:11px;flex:none}
.team-report-card header .icon-btn{width:28px;height:28px;flex:none;border:0;border-radius:8px;background:transparent;color:var(--muted);display:grid;place-items:center;cursor:pointer}
.team-report-card header .icon-btn:hover{background:var(--surface-3);color:var(--text)}
.team-report-card header .icon-btn svg{width:14px;height:14px}
.team-report-card header .btn.sm svg{width:12px}
.trc-ava{width:26px;height:26px;flex:none;border-radius:50%;display:grid;place-items:center;background:rgba(67,201,150,.14);color:var(--green)}
.trc-ava.miss{background:rgba(231,189,53,.13);color:var(--yellow)}
@@ -1774,3 +1777,68 @@ html[data-theme=light] .ph-stats{background:rgba(255,255,255,.5)}
.share-team{display:inline-flex;align-items:center;gap:6px;margin-left:auto}
.share-team svg{width:13px;color:var(--muted)}
.share-team select{height:26px;border:1px solid var(--border);border-radius:8px;background:var(--surface-2);color:var(--text);padding:0 7px;outline:none;font:inherit;font-size:11.5px;max-width:150px}
/* ============ 无边框自定义标题栏logo + 菜单 + 窗控同一行) ============ */
:root{--titlebar-h:40px}
.titlebar{
position:fixed;inset:0 0 auto 0;height:var(--titlebar-h);z-index:40;
display:flex;align-items:center;gap:4px;padding:0 0 0 10px;
background:linear-gradient(180deg,color-mix(in srgb,var(--side) 92%,#fff 4%),var(--side));
border-bottom:1px solid var(--border);
/* 整条顶栏可拖拽窗口;交互控件用 no-drag 退出 */
--wails-draggable:drag;
-webkit-user-select:none;user-select:none;
}
.tb-brand{display:flex;align-items:center;gap:8px;flex:none;min-width:0;padding-right:6px}
.tb-logo{width:26px;height:26px;flex:none}
.tb-logo svg{width:20px;height:20px}
.tb-name{font-size:12.5px;font-weight:700;letter-spacing:.2px;white-space:nowrap}
.tb-ver{font-style:normal;font-size:10.5px;color:var(--muted);padding:1px 6px;border-radius:999px;border:1px solid var(--border);background:color-mix(in srgb,var(--surface-3) 60%,transparent)}
.tb-menus{display:flex;align-items:center;gap:1px;flex:none;--wails-draggable:no-drag}
.tb-menu{position:relative}
.tb-menu-btn{
height:28px;border:0;border-radius:7px;background:transparent;color:var(--muted);
padding:0 9px;display:inline-flex;align-items:center;gap:3px;cursor:pointer;font:inherit;font-size:12.2px;font-weight:600;
}
.tb-menu-btn svg{width:12px;height:12px;opacity:.7}
.tb-menu-btn:hover,.tb-menu.open .tb-menu-btn{background:var(--surface-3);color:var(--text)}
.tb-drop{
position:absolute;top:calc(100% + 4px);left:0;min-width:196px;padding:6px;
border:1px solid var(--border);border-radius:11px;z-index:50;
background:color-mix(in srgb,var(--surface-2) 94%,transparent);
box-shadow:0 18px 40px rgba(0,0,0,.42);backdrop-filter:blur(20px) saturate(150%);
display:flex;flex-direction:column;gap:2px;animation:popoverIn .14s ease-out;
}
.tb-drop hr{border:0;border-top:1px solid var(--border);margin:4px 2px}
.tb-item{
height:32px;border:0;border-radius:8px;background:transparent;color:var(--text);
padding:0 10px;display:flex;align-items:center;gap:10px;cursor:pointer;font:inherit;font-size:12.4px;text-align:left;width:100%;
}
.tb-item:hover{background:var(--surface-3)}
.tb-item span{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.tb-item kbd{font:inherit;font-size:10.5px;color:var(--muted)}
.tb-check{font-style:normal;color:#a9a2ff;font-size:12px}
.tb-spacer{flex:1;min-width:12px;height:100%}
.tb-controls{display:flex;align-items:stretch;height:100%;flex:none;--wails-draggable:no-drag}
.tb-win{
width:46px;border:0;background:transparent;color:var(--muted);cursor:pointer;
display:grid;place-items:center;transition:background .15s,color .15s;
}
.tb-win svg{width:14px;height:14px}
.tb-win:hover{background:var(--surface-3);color:var(--text)}
.tb-win.close:hover{background:#e04852;color:#fff}
/* 侧栏/主区为标题栏让出顶部空间 */
.shell .sidebar{top:var(--titlebar-h)}
.shell main{margin-top:var(--titlebar-h);min-height:calc(100vh - var(--titlebar-h))}
.shell main::before,.shell main::after{top:var(--titlebar-h)}
.with-titlebar,.boot-loading.with-titlebar{padding-top:var(--titlebar-h);min-height:100vh;box-sizing:border-box}
.setup-screen.with-titlebar,.boot-loading.with-titlebar{min-height:100vh}
.rail-flyout{top:auto}
.side-rail{padding-top:10px}
.side-sub{padding-top:10px}
.sub-brand{display:none}
@media(max-width:1150px){
.tb-name,.tb-ver{display:none}
.tb-menus .tb-menu-btn{padding:0 7px;font-size:11.8px}
}

View File

@@ -150,7 +150,7 @@ onUnmounted(()=>{clearTimeout(aiKeyTimer)})
<div v-else class="preview-notice"><Info/><div><b>{{t('fsAdminOnlyTitle')}}</b><small>{{t('fsAdminOnlyDesc')}}</small></div></div>
</section>
</template>
<template v-else><section class="panel database-panel"><div class="db-title"><h2><Database/>{{t('dbLocation')}}</h2><span class="db-connected"><CheckCircle2/>{{t('dbConnected')}}</span></div><div v-if="!native" class="preview-notice"><Info/><div><b>{{t('previewModeTitle')}}</b><small>{{t('previewModeDb')}}</small></div></div><div class="db-path"><span>{{t('dbCurrentLoc')}}</span><code :title="settings.databasePath">{{settings.databasePath||t('dbNoPath')}}</code><button :title="t('copyPathTitle')" :disabled="!settings.databasePath" @click="copyPath"><Copy/></button></div><p v-if="dbMessage" class="db-message">{{dbMessage}}</p><button class="btn secondary migrate" :disabled="!native" @click="migrate"><Upload/>{{t('migrateBtn')}}</button></section>
<template v-else><section class="panel database-panel"><div class="db-title"><h2><Database/>{{t('dbLocation')}}</h2><span class="db-connected"><CheckCircle2/>{{t('dbConnected')}}</span></div><div v-if="!native" class="preview-notice"><Info/><div><b>{{t('previewModeTitle')}}</b><small>{{t('previewModeDb')}}</small></div></div><div class="db-path"><span>{{t('dbCurrentLoc')}}</span><code :title="settings.databasePath">{{settings.databasePath||t('dbNoPath')}}</code><button :title="t('copyPathTitle')" :disabled="!settings.databasePath" @click="copyPath"><Copy/></button></div><p class="db-relocate-hint">{{t('dbRelocateHint')}}</p><p v-if="dbMessage" class="db-message">{{dbMessage}}</p><button class="btn secondary migrate" :disabled="!native" @click="migrate"><Upload/>{{t('migrateBtn')}}</button></section>
<section class="panel danger-zone"><h2><Trash2/>{{t('dangerZone')}}</h2><p>{{t('dangerDesc')}}</p><div><button @click="clear('stats')"><BarChart3/><span><b>{{t('clearStats')}}</b><small>{{t('clearStatsDesc')}}</small></span></button><button @click="clear('project')"><Folder/><span><b>{{t('clearProject')}}</b><small>{{t('clearProjectDesc')}}</small></span></button><button class="danger" @click="clear('all')"><Trash2/><span><b>{{t('clearAllData')}}</b><small>{{t('clearAllDesc')}}</small></span></button></div></section></template>
<AIScopeDrawer v-if="aiOpen" kind="config" :title="t('settings')" @close="aiOpen=false"/>
</div></template>

View File

@@ -1,8 +1,8 @@
<script setup>
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { NotebookPen, Send, BellRing, Sparkles, RefreshCw, Users, LogIn, WifiOff, Quote, Check, ChevronLeft, ChevronRight, UserRound } from 'lucide-vue-next'
import { call, isNative, on } from '../api'
import { NotebookPen, Send, BellRing, Sparkles, RefreshCw, Users, LogIn, WifiOff, Quote, Check, ChevronLeft, ChevronRight, UserRound, Copy } from 'lucide-vue-next'
import { call, isNative, on, copyText } from '../api'
import { useAppStore } from '../store'
import { teamsErr, currentTeam, isTeamAdmin, loadTeams, teamErrCode } from '../team'
import MarkdownView from '../components/MarkdownView.vue'
@@ -59,6 +59,14 @@ async function quoteDayReport() {
else store.showToast({ type: 'error', key: 'teamNoDayReport' })
} catch (e) { store.showToast({ type: 'error', text: errText(e) }) }
}
async function copyReport(text) {
try {
await copyText(text)
store.showToast({ type: 'success', key: 'copied' })
} catch {
store.showToast({ type: 'error', key: 'copyFailed' })
}
}
async function submit() {
if (!myDraft.value.trim() || busy.value) return
busy.value = 'submit'
@@ -135,6 +143,7 @@ onUnmounted(() => offDigest?.())
<textarea v-model="myDraft" class="team-report-editor" rows="6" :placeholder="t('teamReportPh')" />
<div class="team-report-ops">
<button v-if="isToday" class="btn secondary" :title="t('teamQuoteDayReportHint')" @click="quoteDayReport"><Quote />{{ t('teamQuoteDayReport') }}</button>
<button class="btn secondary" :disabled="!myDraft.trim()" :title="t('copyReport')" @click="copyReport(myDraft)"><Copy />{{ t('copyReport') }}</button>
<span class="spacer" />
<button class="btn primary" :disabled="!myDraft.trim() || !!busy" @click="submit"><Send />{{ myReport ? t('teamReportUpdateBtn') : t('teamReportSubmitBtn') }}</button>
</div>
@@ -146,6 +155,7 @@ onUnmounted(() => offDigest?.())
<span class="pc-badge ai"><Sparkles /></span>
<div><b>{{ t('teamDigest') }}</b><small>{{ board?.digest ? t('teamDigestAt', { at: fmtTime(board.digest.generatedAt), provider: board.digest.provider }) : t('teamDigestNone') }}</small></div>
<span class="spacer" />
<button v-if="board?.digest?.content" class="btn secondary" :title="t('copyDigest')" @click="copyReport(board.digest.content)"><Copy />{{ t('copyDigest') }}</button>
<button v-if="isTeamAdmin" class="btn secondary" :disabled="digestBusy" @click="generateDigest">
<component :is="digestBusy ? RefreshCw : Sparkles" :class="{ spin: digestBusy }" />{{ digestBusy ? t('generating') : (board?.digest ? t('teamDigestRegen') : t('teamDigestGen')) }}
</button>
@@ -156,7 +166,10 @@ onUnmounted(() => offDigest?.())
<!-- 成员提交状态 -->
<section class="team-report-grid">
<div v-for="r in otherReports" :key="r.userId" class="panel team-report-card ok">
<header><span class="trc-ava"><Check /></span><b>{{ r.user }}</b><time>{{ fmtTime(r.submittedAt) }}</time></header>
<header>
<span class="trc-ava"><Check /></span><b>{{ r.user }}</b><time>{{ fmtTime(r.submittedAt) }}</time>
<button v-if="r.content" class="icon-btn" :title="t('copyReport')" @click="copyReport(r.content)"><Copy /></button>
</header>
<MarkdownView v-if="r.content" class="md-body sm" :source="r.content" />
<p v-else class="team-none">{{ t('teamReportContentHidden') }}</p>
</div>