更新若干功能

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

@@ -1 +1 @@
cedbecabd994aac478c9127719ea7b09
bea799c633664a7311e58ec89934e52

View File

@@ -1 +1 @@
4fa5aa6193227681772cfc8412c2ad90
b4270031ccf5e17f6edfe9de8d3baa19

2
app.go
View File

@@ -543,6 +543,8 @@ func (a *App) ClearData(mode string, projectID int64) error {
return e
}
// MigrateDatabase 仅把本机 SQLite 文件搬到新路径并切换连接。
// 不做任何 schema DDL云端 MySQL 建表/升级只允许通过仓库根目录 init.sql或 tools/applyinit执行。
func (a *App) MigrateDatabase(target string) error {
if e := a.ready(); e != nil {
return e

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>

220
init.sql
View File

@@ -1,7 +1,7 @@
-- ============================================================
-- 年糕崽崽项目管理PMS同步服务初始化脚本MySQL 5.7+ / 8.x
-- 用法mysql -u root -p < init.sql
-- 应用不执行任何建表/迁移DDL使用同步功能前必须先执行本脚本
-- 应用与前端不执行任何建表/迁移DDL使用同步功能前必须先执行本脚本
-- 应用连接账号只需要对 code_count 库的 SELECT/INSERT/UPDATE 权限。
-- 默认账号liqi / qiqi991012bcrypt 哈希存储,可在应用内注册新账号)
-- ============================================================
@@ -11,92 +11,92 @@ USE code_count;
-- 应用账号(密码为 bcrypt 哈希)
CREATE TABLE IF NOT EXISTS users(
id BIGINT PRIMARY KEY AUTO_INCREMENT,
username VARCHAR(64) NOT NULL UNIQUE,
password_hash VARCHAR(100) NOT NULL,
created_at VARCHAR(32) NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '用户ID',
username VARCHAR(64) NOT NULL UNIQUE COMMENT '登录名',
password_hash VARCHAR(100) NOT NULL COMMENT 'bcrypt 密码哈希',
created_at VARCHAR(32) NOT NULL COMMENT '注册时间RFC3339'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='应用账号';
-- Todo 同步表(按 user_id 隔离LWW 以 updated_at 判定)
-- history 为生命周期轨迹 JSON[{"status":"open","at":"..."},...],记录每次进入某状态的时间
-- team_id>0 表示该条已共享给对应团队团队管理员可见0 为私密
CREATE TABLE IF NOT EXISTS sync_todos(
user_id BIGINT NOT NULL,
uuid CHAR(36) NOT NULL,
title TEXT NOT NULL,
content MEDIUMTEXT NOT NULL,
project_name VARCHAR(255) NOT NULL DEFAULT '',
due_at VARCHAR(32) NOT NULL DEFAULT '',
priority VARCHAR(16) NOT NULL DEFAULT 'medium',
status VARCHAR(16) NOT NULL DEFAULT 'open',
history MEDIUMTEXT NOT NULL,
team_id BIGINT NOT NULL DEFAULT 0,
created_at VARCHAR(32) NOT NULL DEFAULT '',
updated_at VARCHAR(32) NOT NULL,
deleted TINYINT NOT NULL DEFAULT 0,
user_id BIGINT NOT NULL COMMENT '所属用户ID',
uuid CHAR(36) NOT NULL COMMENT '客户端生成的全局唯一ID',
title TEXT NOT NULL COMMENT '标题',
content MEDIUMTEXT NOT NULL COMMENT '正文Markdown可含内嵌图片',
project_name VARCHAR(255) NOT NULL DEFAULT '' COMMENT '关联项目名(展示用)',
due_at VARCHAR(32) NOT NULL DEFAULT '' COMMENT '截止时间',
priority VARCHAR(16) NOT NULL DEFAULT 'medium' COMMENT '优先级low/medium/high',
status VARCHAR(16) NOT NULL DEFAULT 'open' COMMENT '状态open/doing/done/cancelled',
history MEDIUMTEXT NOT NULL COMMENT '生命周期轨迹 JSON',
team_id BIGINT NOT NULL DEFAULT 0 COMMENT '共享团队ID0=私密',
created_at VARCHAR(32) NOT NULL DEFAULT '' COMMENT '创建时间',
updated_at VARCHAR(32) NOT NULL COMMENT '最后更新时间LWW',
deleted TINYINT NOT NULL DEFAULT 0 COMMENT '软删除标记1=已删',
PRIMARY KEY(user_id, uuid),
KEY idx_sync_todos_updated(user_id, updated_at),
KEY idx_sync_todos_team(team_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='待办同步表';
-- 工单同步表
CREATE TABLE IF NOT EXISTS sync_tickets(
user_id BIGINT NOT NULL,
uuid CHAR(36) NOT NULL,
title TEXT NOT NULL,
description MEDIUMTEXT NOT NULL,
type VARCHAR(16) NOT NULL DEFAULT 'task',
project_name VARCHAR(255) NOT NULL DEFAULT '',
start_at VARCHAR(32) NOT NULL DEFAULT '',
due_at VARCHAR(32) NOT NULL DEFAULT '',
status VARCHAR(16) NOT NULL DEFAULT 'open',
priority VARCHAR(16) NOT NULL DEFAULT 'medium',
history MEDIUMTEXT NOT NULL,
team_id BIGINT NOT NULL DEFAULT 0,
created_at VARCHAR(32) NOT NULL DEFAULT '',
updated_at VARCHAR(32) NOT NULL,
deleted TINYINT NOT NULL DEFAULT 0,
user_id BIGINT NOT NULL COMMENT '所属用户ID',
uuid CHAR(36) NOT NULL COMMENT '客户端生成的全局唯一ID',
title TEXT NOT NULL COMMENT '标题',
description MEDIUMTEXT NOT NULL COMMENT '描述Markdown',
type VARCHAR(16) NOT NULL DEFAULT 'task' COMMENT '类型task/bug/feature 等',
project_name VARCHAR(255) NOT NULL DEFAULT '' COMMENT '关联项目名',
start_at VARCHAR(32) NOT NULL DEFAULT '' COMMENT '开始时间',
due_at VARCHAR(32) NOT NULL DEFAULT '' COMMENT '截止时间',
status VARCHAR(16) NOT NULL DEFAULT 'open' COMMENT '状态',
priority VARCHAR(16) NOT NULL DEFAULT 'medium' COMMENT '优先级',
history MEDIUMTEXT NOT NULL COMMENT '生命周期轨迹 JSON',
team_id BIGINT NOT NULL DEFAULT 0 COMMENT '共享团队ID0=私密',
created_at VARCHAR(32) NOT NULL DEFAULT '' COMMENT '创建时间',
updated_at VARCHAR(32) NOT NULL COMMENT '最后更新时间LWW',
deleted TINYINT NOT NULL DEFAULT 0 COMMENT '软删除标记',
PRIMARY KEY(user_id, uuid),
KEY idx_sync_tickets_updated(user_id, updated_at),
KEY idx_sync_tickets_team(team_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='工单同步表';
-- 记事本同步表
CREATE TABLE IF NOT EXISTS sync_notes(
user_id BIGINT NOT NULL,
uuid CHAR(36) NOT NULL,
content MEDIUMTEXT NOT NULL,
updated_at VARCHAR(32) NOT NULL,
deleted TINYINT NOT NULL DEFAULT 0,
user_id BIGINT NOT NULL COMMENT '所属用户ID',
uuid CHAR(36) NOT NULL COMMENT '客户端生成的全局唯一ID',
content MEDIUMTEXT NOT NULL COMMENT '记事本正文',
updated_at VARCHAR(32) NOT NULL COMMENT '最后更新时间LWW',
deleted TINYINT NOT NULL DEFAULT 0 COMMENT '软删除标记',
PRIMARY KEY(user_id, uuid),
KEY idx_sync_notes_updated(user_id, updated_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='记事本同步表';
-- 按用户存储的同步设置(如加密盐 enc_salt、加密后的 AI API Key api_keys
-- api_keys 的值为 AES-256-GCM 密文(密钥由登录密码派生),服务器无法解密。
-- 另有全局资源行:日历节日背景图存为 fest_img:<节日名>,统一挂在管理员账号
-- id=1名下 —— 管理员在应用内上传推送,所有账号登录后拉取展示。
CREATE TABLE IF NOT EXISTS sync_settings(
user_id BIGINT NOT NULL,
name VARCHAR(64) NOT NULL,
value MEDIUMTEXT NOT NULL,
updated_at VARCHAR(32) NOT NULL,
user_id BIGINT NOT NULL COMMENT '所属用户ID全局资源挂在管理员 id=1',
name VARCHAR(64) NOT NULL COMMENT '设置键名',
value MEDIUMTEXT NOT NULL COMMENT '设置值(明文或密文)',
updated_at VARCHAR(32) NOT NULL COMMENT '最后更新时间',
PRIMARY KEY(user_id, name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户同步设置键值表';
-- 旧版本升级:早期脚本建的 content/description 为 TEXT64KB
-- 待办/工单内容支持 Markdown 内嵌图片后需要 MEDIUMTEXT重复执行无副作用。
ALTER TABLE sync_todos MODIFY content MEDIUMTEXT NOT NULL;
ALTER TABLE sync_tickets MODIFY description MEDIUMTEXT NOT NULL;
ALTER TABLE sync_todos MODIFY content MEDIUMTEXT NOT NULL COMMENT '正文Markdown可含内嵌图片';
ALTER TABLE sync_tickets MODIFY description MEDIUMTEXT NOT NULL COMMENT '描述Markdown';
-- 旧版本升级:补 history 生命周期列(不存在时才添加,重复执行无副作用)。
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA='code_count' AND TABLE_NAME='sync_todos' AND COLUMN_NAME='history')=0,
'ALTER TABLE sync_todos ADD COLUMN history MEDIUMTEXT NOT NULL AFTER status', 'SELECT 1');
'ALTER TABLE sync_todos ADD COLUMN history MEDIUMTEXT NOT NULL COMMENT ''生命周期轨迹 JSON'' AFTER status', 'SELECT 1');
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA='code_count' AND TABLE_NAME='sync_tickets' AND COLUMN_NAME='history')=0,
'ALTER TABLE sync_tickets ADD COLUMN history MEDIUMTEXT NOT NULL AFTER priority', 'SELECT 1');
'ALTER TABLE sync_tickets ADD COLUMN history MEDIUMTEXT NOT NULL COMMENT ''生命周期轨迹 JSON'' AFTER priority', 'SELECT 1');
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
-- ============================================================
@@ -105,97 +105,97 @@ PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
-- 用户公开资料(同服务器用户互相可见:昵称/头衔/技术栈标签/头像缩略图)
CREATE TABLE IF NOT EXISTS user_profiles(
user_id BIGINT PRIMARY KEY,
nickname VARCHAR(64) NOT NULL DEFAULT '',
title VARCHAR(64) NOT NULL DEFAULT '',
email VARCHAR(128) NOT NULL DEFAULT '',
bio VARCHAR(500) NOT NULL DEFAULT '',
tech_tags VARCHAR(1000) NOT NULL DEFAULT '[]',
avatar_thumb MEDIUMTEXT NOT NULL,
updated_at VARCHAR(32) NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
user_id BIGINT PRIMARY KEY COMMENT '对应用户ID',
nickname VARCHAR(64) NOT NULL DEFAULT '' COMMENT '昵称',
title VARCHAR(64) NOT NULL DEFAULT '' COMMENT '头衔/职位',
email VARCHAR(128) NOT NULL DEFAULT '' COMMENT '公开邮箱',
bio VARCHAR(500) NOT NULL DEFAULT '' COMMENT '个人简介',
tech_tags VARCHAR(1000) NOT NULL DEFAULT '[]' COMMENT '技术栈标签 JSON 数组',
avatar_thumb MEDIUMTEXT NOT NULL COMMENT '头像缩略图dataURL 或 URL',
updated_at VARCHAR(32) NOT NULL COMMENT '最后更新时间'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户公开资料';
-- 团队digest_time日报 AI 摘要自动生成时间 HH:MM
CREATE TABLE IF NOT EXISTS teams(
id BIGINT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(64) NOT NULL,
owner_id BIGINT NOT NULL,
digest_time VARCHAR(8) NOT NULL DEFAULT '21:00',
created_at VARCHAR(32) NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '团队ID',
name VARCHAR(64) NOT NULL COMMENT '团队名称',
owner_id BIGINT NOT NULL COMMENT '创建者用户ID',
digest_time VARCHAR(8) NOT NULL DEFAULT '21:00' COMMENT '日报 AI 摘要自动生成时间 HH:MM',
created_at VARCHAR(32) NOT NULL COMMENT '创建时间'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='团队';
-- 团队成员role: owner | admin | member
CREATE TABLE IF NOT EXISTS team_members(
team_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
role VARCHAR(16) NOT NULL DEFAULT 'member',
joined_at VARCHAR(32) NOT NULL,
team_id BIGINT NOT NULL COMMENT '团队ID',
user_id BIGINT NOT NULL COMMENT '成员用户ID',
role VARCHAR(16) NOT NULL DEFAULT 'member' COMMENT '角色owner/admin/member',
joined_at VARCHAR(32) NOT NULL COMMENT '加入时间',
PRIMARY KEY(team_id, user_id),
KEY idx_team_members_user(user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='团队成员';
-- 团队任务/工单服务器唯一真相在线操作kind: todo | ticket
CREATE TABLE IF NOT EXISTS team_tasks(
id BIGINT PRIMARY KEY AUTO_INCREMENT,
team_id BIGINT NOT NULL,
kind VARCHAR(16) NOT NULL DEFAULT 'todo',
title TEXT NOT NULL,
description MEDIUMTEXT NOT NULL,
priority VARCHAR(16) NOT NULL DEFAULT 'medium',
status VARCHAR(16) NOT NULL DEFAULT 'open',
creator_id BIGINT NOT NULL,
assignee_id BIGINT NOT NULL DEFAULT 0,
start_at VARCHAR(32) NOT NULL DEFAULT '',
due_at VARCHAR(32) NOT NULL DEFAULT '',
urged_at VARCHAR(32) NOT NULL DEFAULT '',
history MEDIUMTEXT NOT NULL,
updated_at VARCHAR(32) NOT NULL,
deleted TINYINT NOT NULL DEFAULT 0,
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '任务ID',
team_id BIGINT NOT NULL COMMENT '所属团队ID',
kind VARCHAR(16) NOT NULL DEFAULT 'todo' COMMENT '种类todo/ticket',
title TEXT NOT NULL COMMENT '标题',
description MEDIUMTEXT NOT NULL COMMENT '描述',
priority VARCHAR(16) NOT NULL DEFAULT 'medium' COMMENT '优先级',
status VARCHAR(16) NOT NULL DEFAULT 'open' COMMENT '状态',
creator_id BIGINT NOT NULL COMMENT '创建者用户ID',
assignee_id BIGINT NOT NULL DEFAULT 0 COMMENT '指派人用户ID0=未指派',
start_at VARCHAR(32) NOT NULL DEFAULT '' COMMENT '开始时间',
due_at VARCHAR(32) NOT NULL DEFAULT '' COMMENT '截止时间',
urged_at VARCHAR(32) NOT NULL DEFAULT '' COMMENT '最近催办时间',
history MEDIUMTEXT NOT NULL COMMENT '生命周期轨迹 JSON',
updated_at VARCHAR(32) NOT NULL COMMENT '最后更新时间',
deleted TINYINT NOT NULL DEFAULT 0 COMMENT '软删除标记',
KEY idx_team_tasks_team(team_id, deleted),
KEY idx_team_tasks_assignee(assignee_id, deleted)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='团队任务/工单';
-- 团队日报date 为本地日期 YYYY-MM-DD
CREATE TABLE IF NOT EXISTS team_reports(
team_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
date CHAR(10) NOT NULL,
content MEDIUMTEXT NOT NULL,
submitted_at VARCHAR(32) NOT NULL,
team_id BIGINT NOT NULL COMMENT '团队ID',
user_id BIGINT NOT NULL COMMENT '提交人用户ID',
date CHAR(10) NOT NULL COMMENT '日报日期 YYYY-MM-DD',
content MEDIUMTEXT NOT NULL COMMENT '日报正文Markdown',
submitted_at VARCHAR(32) NOT NULL COMMENT '提交/更新时间',
PRIMARY KEY(team_id, user_id, date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='团队日报';
-- 团队日报 AI 摘要(管理员客户端生成,全员可见)
CREATE TABLE IF NOT EXISTS team_digests(
team_id BIGINT NOT NULL,
date CHAR(10) NOT NULL,
content MEDIUMTEXT NOT NULL,
provider VARCHAR(32) NOT NULL DEFAULT '',
generated_at VARCHAR(32) NOT NULL,
team_id BIGINT NOT NULL COMMENT '团队ID',
date CHAR(10) NOT NULL COMMENT '摘要对应日期 YYYY-MM-DD',
content MEDIUMTEXT NOT NULL COMMENT 'AI 摘要正文Markdown',
provider VARCHAR(32) NOT NULL DEFAULT '' COMMENT '生成所用 AI 提供商',
generated_at VARCHAR(32) NOT NULL COMMENT '生成时间',
PRIMARY KEY(team_id, date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='团队日报 AI 摘要';
-- 团队通知(指派/催办/成员变动等;客户端同步时按 to_user 增量拉取转本地消息)
CREATE TABLE IF NOT EXISTS team_notices(
id BIGINT PRIMARY KEY AUTO_INCREMENT,
team_id BIGINT NOT NULL,
to_user BIGINT NOT NULL,
from_user BIGINT NOT NULL,
kind VARCHAR(16) NOT NULL,
ref_id VARCHAR(64) NOT NULL DEFAULT '',
content TEXT NOT NULL,
created_at VARCHAR(32) NOT NULL,
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '通知ID',
team_id BIGINT NOT NULL COMMENT '团队ID',
to_user BIGINT NOT NULL COMMENT '接收用户ID',
from_user BIGINT NOT NULL COMMENT '发送用户ID',
kind VARCHAR(16) NOT NULL COMMENT '通知类型',
ref_id VARCHAR(64) NOT NULL DEFAULT '' COMMENT '关联对象ID',
content TEXT NOT NULL COMMENT '通知正文',
created_at VARCHAR(32) NOT NULL COMMENT '创建时间',
KEY idx_team_notices_to(to_user, id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='团队通知';
-- 旧版本升级:个人待办/工单支持按条共享到团队team_id=0 私密;重复执行无副作用)。
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA='code_count' AND TABLE_NAME='sync_todos' AND COLUMN_NAME='team_id')=0,
'ALTER TABLE sync_todos ADD COLUMN team_id BIGINT NOT NULL DEFAULT 0 AFTER history', 'SELECT 1');
'ALTER TABLE sync_todos ADD COLUMN team_id BIGINT NOT NULL DEFAULT 0 COMMENT ''共享团队ID0=私密'' AFTER history', 'SELECT 1');
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA='code_count' AND TABLE_NAME='sync_tickets' AND COLUMN_NAME='team_id')=0,
'ALTER TABLE sync_tickets ADD COLUMN team_id BIGINT NOT NULL DEFAULT 0 AFTER history', 'SELECT 1');
'ALTER TABLE sync_tickets ADD COLUMN team_id BIGINT NOT NULL DEFAULT 0 COMMENT ''共享团队ID0=私密'' AFTER history', 'SELECT 1');
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
SET @sql = IF((SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA='code_count' AND TABLE_NAME='sync_todos' AND INDEX_NAME='idx_sync_todos_team')=0,

14
main.go
View File

@@ -39,6 +39,15 @@ func main() {
// Opt-in DevTools protocol endpoint for automated UI testing; inert unless the env var is set.
AdditionalBrowserArgs: devtoolsArgs(),
},
// 单实例:第二个进程启动时通过 Wails 内置的 WM_COPYDATA / DBus 通道
// 通知首个进程,回调里唤起已存在的主窗口并切到前台。
SingleInstance: &application.SingleInstanceOptions{
UniqueID: "com.niangaozai.view-pms",
OnSecondInstanceLaunch: func(_ application.SecondInstanceData) {
// 回调运行在后台 goroutine需派发到 UI 线程操作窗口。
application.InvokeAsync(func() { app.showMainWindow() })
},
},
})
win := wapp.Window.NewWithOptions(application.WebviewWindowOptions{
@@ -50,6 +59,11 @@ func main() {
MinHeight: 680,
URL: "/",
BackgroundColour: application.NewRGBA(13, 18, 28, 255),
// 无边框:标题栏由前端 TitleBar 自绘logo + 菜单 + 窗控同一行)。
Frameless: true,
Windows: application.WindowsWindow{
DisableMenu: true,
},
})
app.SetupShell(wapp, win)

143
shell.go
View File

@@ -4,12 +4,14 @@ import (
_ "embed"
"errors"
"fmt"
"strings"
"sync/atomic"
"time"
"view/service"
"github.com/wailsapp/wails/v3/pkg/application"
"github.com/wailsapp/wails/v3/pkg/events"
"github.com/wailsapp/wails/v3/pkg/services/notifications"
)
//go:embed build/appicon.png
@@ -26,6 +28,9 @@ type shellText struct {
account, accountLogin, traySync, profile, logout string
trayProjects, trayAllProjects string
trayTodos, trayTickets, trayMessages, trayAutostart string
trayToday, trayQuick, trayStatusSyncing string
trayStatusOnline, trayStatusOffline, trayStatusSignedOut string
trayStatusError, trayPending, trayUnread, trayDueToday string
}
func shellTexts(locale string) shellText {
@@ -38,6 +43,10 @@ func shellTexts(locale string) shellText {
account: "Account", accountLogin: "Sign In / Register", traySync: "Sync Now", profile: "Profile", logout: "Sign Out",
trayProjects: "Open Project", trayAllProjects: "All Projects…",
trayTodos: "Todos", trayTickets: "Tickets", trayMessages: "Messages", trayAutostart: "Start at Login",
trayToday: "Today", trayQuick: "Quick Open",
trayStatusSyncing: "Syncing…", trayStatusOnline: "Online", trayStatusOffline: "Offline",
trayStatusSignedOut: "Signed out", trayStatusError: "Sync error",
trayPending: "Pending %d", trayUnread: "Unread %d", trayDueToday: "Due today %d",
}
}
return shellText{
@@ -48,6 +57,10 @@ func shellTexts(locale string) shellText {
account: "账号", accountLogin: "登录 / 注册", traySync: "立即同步", profile: "个人中心", logout: "退出登录",
trayProjects: "打开项目", trayAllProjects: "全部项目…",
trayTodos: "待办事项", trayTickets: "需求工单", trayMessages: "消息中心", trayAutostart: "开机启动",
trayToday: "今日任务", trayQuick: "快捷入口",
trayStatusSyncing: "同步中…", trayStatusOnline: "在线", trayStatusOffline: "离线",
trayStatusSignedOut: "未登录", trayStatusError: "同步异常",
trayPending: "待推送 %d", trayUnread: "未读消息 %d", trayDueToday: "今日到期 %d",
}
}
@@ -73,9 +86,53 @@ func (a *App) SetupShell(wapp *application.App, win *application.WebviewWindow)
})
a.shell.tray = wapp.SystemTray.New()
a.shell.tray.SetIcon(appIcon)
a.shell.tray.OnClick(a.showMainWindow)
a.shell.tray.OnDoubleClick(a.showMainWindow)
a.wireNotificationClicks()
a.RefreshShell()
}
// wireNotificationClicks 点击系统通知时唤起窗口并跳到对应页面。
func (a *App) wireNotificationClicks() {
if a.notifier == nil {
return
}
a.notifier.OnNotificationResponse(func(result notifications.NotificationResult) {
if result.Error != nil {
return
}
application.InvokeAsync(func() {
a.showMainWindow()
path := notificationNavPath(result.Response)
if path != "" {
a.emit("menu:navigate", path)
}
})
})
}
func notificationNavPath(r notifications.NotificationResponse) string {
if r.UserInfo != nil {
if p, ok := r.UserInfo["path"].(string); ok && p != "" {
return p
}
}
switch {
case strings.HasPrefix(r.ID, "cc-todo_due-"):
return "/todos"
case strings.HasPrefix(r.ID, "cc-ticket_due-"):
return "/tickets"
case strings.HasPrefix(r.ID, "cc-team-"):
return "/team/tasks"
case strings.HasPrefix(r.ID, "cc-sync-"):
return "/messages"
case strings.HasPrefix(r.ID, "cc-analysis-"):
return "/logs"
default:
return "/messages"
}
}
// RefreshShell 按当前语言与登录态重建原生菜单与托盘菜单(设置/登录变更后调用)。
func (a *App) RefreshShell() {
if a.shell == nil {
@@ -89,14 +146,36 @@ func (a *App) RefreshShell() {
}
t := shellTexts(locale)
menu := a.buildAppMenu(t, theme, locale)
// macOS 用全局应用菜单Windows 已走无边框自定义顶栏,不再 SetMenu。
a.shell.wapp.Menu.SetApplicationMenu(menu)
// Windows/Linux 的菜单栏挂在窗口上macOS 用全局应用菜单SetMenu 是 no-op
if a.shell.win != nil {
a.shell.win.SetMenu(menu)
a.refreshTray(t, locale)
}
// refreshTray 更新托盘 tooltip / 状态色点图标 / 右键菜单。
func (a *App) refreshTray(t shellText, locale string) {
if a.shell == nil || a.shell.tray == nil {
return
}
a.shell.tray.SetTooltip("年糕崽崽项目管理PMS")
a.shell.tray.SetMenu(a.buildTrayMenu(t))
st := a.collectTrayStatus()
a.shell.tray.SetTooltip(st.tooltip(locale))
a.shell.tray.SetIcon(trayIconWithBadge(appIcon, st.badgeColor()))
a.shell.tray.SetMenu(a.buildTrayMenu(t, st))
a.shell.tray.OnClick(a.showMainWindow)
a.shell.tray.OnDoubleClick(a.showMainWindow)
}
// RefreshTrayStatus 轻量刷新托盘状态(同步/消息变更时调用,不重建应用菜单)。
func (a *App) RefreshTrayStatus() {
if a.shell == nil {
return
}
locale := "zh-CN"
if a.store != nil {
if st, e := a.store.Settings(); e == nil {
locale = st.Locale
}
}
a.refreshTray(shellTexts(locale), locale)
}
func (a *App) buildAppMenu(t shellText, theme, locale string) *application.Menu {
@@ -190,12 +269,24 @@ func (a *App) trayQuickSync() {
}
// trayProjectLimit 是托盘「打开项目」子菜单最多列出的项目数,超出走「全部项目…」。
const trayProjectLimit = 12
const trayProjectLimit = 8
func (a *App) buildTrayMenu(t shellText) *application.Menu {
func (a *App) buildTrayMenu(t shellText, st trayStatus) *application.Menu {
menu := a.shell.wapp.Menu.New()
menu.Add(t.trayShow).OnClick(func(*application.Context) { a.showMainWindow() })
menu.AddSeparator()
// 状态区:点击状态行触发同步(或登录)。
statusLabel := trayStatusLabel(t, st)
menu.Add(statusLabel).OnClick(func(*application.Context) { a.trayQuickSync() })
if st.unread > 0 {
menu.Add(fmt.Sprintf(t.trayUnread, st.unread)).OnClick(func(*application.Context) { a.navigateTo("/messages") })
}
if st.todayDue > 0 {
menu.Add(fmt.Sprintf(t.trayDueToday, st.todayDue)).OnClick(func(*application.Context) { a.navigateTo("/today") })
}
menu.AddSeparator()
if projects, e := a.ListProjects(); e == nil && len(projects) > 0 {
sub := menu.AddSubmenu(t.trayProjects)
for i, p := range projects {
@@ -210,14 +301,19 @@ func (a *App) buildTrayMenu(t shellText) *application.Menu {
sub.Add(t.trayAllProjects).OnClick(func(*application.Context) { a.navigateTo("/projects") })
}
}
menu.Add(t.trayTodos).OnClick(func(*application.Context) { a.navigateTo("/todos") })
menu.Add(t.trayTickets).OnClick(func(*application.Context) { a.navigateTo("/tickets") })
menu.Add(t.trayMessages).OnClick(func(*application.Context) { a.navigateTo("/messages") })
quick := menu.AddSubmenu(t.trayQuick)
quick.Add(t.trayToday).OnClick(func(*application.Context) { a.navigateTo("/today") })
quick.Add(t.trayTodos).OnClick(func(*application.Context) { a.navigateTo("/todos") })
quick.Add(t.trayTickets).OnClick(func(*application.Context) { a.navigateTo("/tickets") })
quick.Add(t.trayMessages).OnClick(func(*application.Context) { a.navigateTo("/messages") })
quick.Add(t.aiHub).OnClick(func(*application.Context) { a.navigateTo("/ai") })
menu.AddSeparator()
menu.Add(t.trayBatch).OnClick(func(*application.Context) { _, _ = a.StartBatchAnalysis() })
menu.Add(t.traySync).OnClick(func(*application.Context) { a.trayQuickSync() })
menu.Add(t.trayBatch).OnClick(func(*application.Context) { _, _ = a.StartBatchAnalysis() })
menu.AddSeparator()
// 勾选态取自系统注册状态;点击写入后重建托盘,让勾选跟随真实结果。
autostart, autoErr := a.GetAutostart()
menu.AddCheckbox(t.trayAutostart, autoErr == nil && autostart).OnClick(func(*application.Context) {
_ = a.SetAutostart(!autostart)
@@ -228,6 +324,29 @@ func (a *App) buildTrayMenu(t shellText) *application.Menu {
return menu
}
func trayStatusLabel(t shellText, st trayStatus) string {
var core string
switch {
case st.syncing:
core = t.trayStatusSyncing
case !st.loggedIn:
core = t.trayStatusSignedOut
case st.lastErr != "":
core = t.trayStatusError
case st.online:
core = t.trayStatusOnline
if st.username != "" {
core = st.username + " · " + core
}
default:
core = t.trayStatusOffline
}
if st.pending > 0 {
core += " · " + fmt.Sprintf(t.trayPending, st.pending)
}
return core
}
// navigateTo 唤起主窗口并让前端路由跳到指定页面(托盘/原生菜单共用)。
func (a *App) navigateTo(path string) {
a.showMainWindow()

View File

@@ -126,7 +126,7 @@ func (a *App) GetSyncStatus() (SyncStatus, error) {
// ---------- 远端连接 ----------
// openRemote 建立 MySQL 连接。应用不执行任何建表/迁移DDL
// openRemote 建立 MySQL 连接。应用与前端不执行任何建表/迁移DDL
// 数据库与数据表须提前用仓库根目录的 init.sql 初始化。
func (a *App) openRemote(ctx context.Context) (*sql.DB, error) {
c := a.syncConfig()
@@ -575,6 +575,7 @@ func (a *App) emitSyncDone() {
if st, e := a.GetSyncStatus(); e == nil {
a.emit("sync:done", st)
}
a.RefreshTrayStatus()
}
func (a *App) syncText(k string) string {

View File

@@ -19,7 +19,7 @@ func main() {
if err != nil {
panic(err)
}
db, err := sql.Open("mysql", "root:root@tcp(127.0.0.1:3306)/?multiStatements=false&charset=utf8mb4")
db, err := sql.Open("mysql", "code_count:code_count@tcp(101.43.12.11:3306)/?multiStatements=false&charset=utf8mb4")
if err != nil {
panic(err)
}

139
tray_status.go Normal file
View File

@@ -0,0 +1,139 @@
package main
import (
"bytes"
"fmt"
"image"
"image/color"
"image/draw"
"image/png"
"strings"
)
// trayStatus 汇总托盘展示所需的运行态。
type trayStatus struct {
loggedIn bool
online bool
syncing bool
pending int
unread int
todayDue int
lastErr string
username string
}
func (a *App) collectTrayStatus() trayStatus {
st := trayStatus{}
if a.store == nil {
return st
}
st.loggedIn = a.syncUserID() > 0
st.online = a.syncOnline.Load()
st.syncing = a.syncing.Load()
st.username = a.store.Meta("sync_username")
a.syncMu.Lock()
st.lastErr = a.syncLastErr
a.syncMu.Unlock()
_ = a.store.db.QueryRow(`SELECT (SELECT COUNT(*) FROM todos WHERE dirty=1)
+(SELECT COUNT(*) FROM tickets WHERE dirty=1)
+(SELECT COUNT(*) FROM notes WHERE dirty=1)`).Scan(&st.pending)
_ = a.store.db.QueryRow(`SELECT COUNT(*) FROM messages WHERE is_read=0`).Scan(&st.unread)
if badges, e := a.GetNavBadges(); e == nil {
st.todayDue = badges.TodayDue
}
return st
}
func (s trayStatus) tooltip(locale string) string {
base := "年糕崽崽项目管理PMS"
if locale == "en" {
base = "Niangaozai PMS"
}
parts := []string{base}
switch {
case s.syncing:
parts = append(parts, pick(locale, "同步中…", "Syncing…"))
case !s.loggedIn:
parts = append(parts, pick(locale, "未登录", "Signed out"))
case s.lastErr != "":
parts = append(parts, pick(locale, "同步异常", "Sync error"))
case s.online:
parts = append(parts, pick(locale, "在线", "Online"))
default:
parts = append(parts, pick(locale, "离线", "Offline"))
}
if s.pending > 0 {
parts = append(parts, fmt.Sprintf(pick(locale, "待推送 %d", "Pending %d"), s.pending))
}
if s.unread > 0 {
parts = append(parts, fmt.Sprintf(pick(locale, "未读 %d", "Unread %d"), s.unread))
}
if s.todayDue > 0 {
parts = append(parts, fmt.Sprintf(pick(locale, "今日到期 %d", "Due today %d"), s.todayDue))
}
return strings.Join(parts, " · ")
}
func (s trayStatus) badgeColor() color.RGBA {
switch {
case s.syncing:
return color.RGBA{R: 79, G: 157, B: 245, A: 255} // blue
case s.lastErr != "":
return color.RGBA{R: 240, G: 94, B: 104, A: 255} // red
case !s.loggedIn:
return color.RGBA{R: 146, G: 155, B: 172, A: 255} // muted
case !s.online:
return color.RGBA{R: 231, G: 189, B: 53, A: 255} // yellow
case s.pending > 0 || s.unread > 0:
return color.RGBA{R: 231, G: 189, B: 53, A: 255} // yellow attention
default:
return color.RGBA{R: 67, G: 201, B: 150, A: 255} // green
}
}
func pick(locale, zh, en string) string {
if locale == "en" {
return en
}
return zh
}
// trayIconWithBadge 在应用图标右下角叠一个状态色点,供托盘 SetIcon 使用。
func trayIconWithBadge(src []byte, badge color.RGBA) []byte {
img, e := png.Decode(bytes.NewReader(src))
if e != nil {
return src
}
b := img.Bounds()
out := image.NewRGBA(b)
draw.Draw(out, b, img, b.Min, draw.Src)
size := b.Dx()
if size < 8 {
return src
}
r := size / 5
if r < 3 {
r = 3
}
cx := b.Max.X - r - size/12
cy := b.Max.Y - r - size/12
for y := cy - r; y <= cy+r; y++ {
for x := cx - r; x <= cx+r; x++ {
dx, dy := x-cx, y-cy
if dx*dx+dy*dy > r*r {
continue
}
// 外圈深色描边提升对比度。
if dx*dx+dy*dy > (r-1)*(r-1) {
out.Set(x, y, color.RGBA{0, 0, 0, 180})
} else {
out.Set(x, y, badge)
}
}
}
var buf bytes.Buffer
if e := png.Encode(&buf, out); e != nil {
return src
}
return buf.Bytes()
}

View File

@@ -152,11 +152,22 @@ func (a *App) pushMessage(kind, title, body, sourceType string, sourceID int64,
return
}
a.emit("message:new", m)
a.RefreshTrayStatus()
if notify && a.notifier != nil {
path := "/messages"
switch sourceType {
case "todo":
path = "/todos"
case "ticket":
path = "/tickets"
case "team":
path = "/team/tasks"
}
_ = a.notifier.SendNotification(notifications.NotificationOptions{
ID: fmt.Sprintf("cc-%s-%d", kind, m.ID),
Title: title,
Body: body,
Data: map[string]interface{}{"path": path, "kind": kind, "sourceType": sourceType, "sourceId": sourceID},
})
}
}