更新若干功能
This commit is contained in:
@@ -1 +1 @@
|
||||
cedbecabd994aac478c9127719ea7b09
|
||||
bea799c633664a7311e58ec89934e52
|
||||
|
||||
@@ -1 +1 @@
|
||||
4fa5aa6193227681772cfc8412c2ad90
|
||||
b4270031ccf5e17f6edfe9de8d3baa19
|
||||
|
||||
2
app.go
2
app.go
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
218
frontend/src/components/TitleBar.vue
Normal file
218
frontend/src/components/TitleBar.vue
Normal 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>
|
||||
@@ -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',
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
220
init.sql
@@ -1,7 +1,7 @@
|
||||
-- ============================================================
|
||||
-- 年糕崽崽项目管理(PMS)同步服务初始化脚本(MySQL 5.7+ / 8.x)
|
||||
-- 用法:mysql -u root -p < init.sql
|
||||
-- 应用不执行任何建表/迁移(DDL),使用同步功能前必须先执行本脚本;
|
||||
-- 应用与前端不执行任何建表/迁移(DDL),使用同步功能前必须先执行本脚本;
|
||||
-- 应用连接账号只需要对 code_count 库的 SELECT/INSERT/UPDATE 权限。
|
||||
-- 默认账号:liqi / qiqi991012(bcrypt 哈希存储,可在应用内注册新账号)
|
||||
-- ============================================================
|
||||
@@ -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 '共享团队ID,0=私密',
|
||||
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 '共享团队ID,0=私密',
|
||||
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 为 TEXT(64KB),
|
||||
-- 待办/工单内容支持 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 '指派人用户ID,0=未指派',
|
||||
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 ''共享团队ID,0=私密'' 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 ''共享团队ID,0=私密'' 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
14
main.go
@@ -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
143
shell.go
@@ -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()
|
||||
|
||||
3
sync.go
3
sync.go
@@ -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 {
|
||||
|
||||
@@ -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
139
tray_status.go
Normal 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()
|
||||
}
|
||||
11
workbench.go
11
workbench.go
@@ -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},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user