更新若干功能
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user