diff --git a/.task/checksum/build-frontend--DEV--RUNNER-npm- b/.task/checksum/build-frontend--DEV--RUNNER-npm-
index d6b3dc8..e008a24 100644
--- a/.task/checksum/build-frontend--DEV--RUNNER-npm-
+++ b/.task/checksum/build-frontend--DEV--RUNNER-npm-
@@ -1 +1 @@
-8ef156c82c13dcfb55a861fc1e1b9483
+cd84020b5c141a3842ad91546a552d0
diff --git a/.task/checksum/windows-common-generate-bindings b/.task/checksum/windows-common-generate-bindings
index fdd825b..843ebf7 100644
--- a/.task/checksum/windows-common-generate-bindings
+++ b/.task/checksum/windows-common-generate-bindings
@@ -1 +1 @@
-a59624793eae992b65534b38e0fef064
+d56685eca1698f613f797ff4d81f460a
diff --git a/admin.go b/admin.go
index bf0906c..31114ec 100644
--- a/admin.go
+++ b/admin.go
@@ -368,7 +368,7 @@ func (a *App) checkAIPolicy() error {
}
// reportAIUsage 上报一次 AI 用量(失败忽略)。
-func (a *App) reportAIUsage(provider string, prompt, completion int64, estimated bool) {
+func (a *App) reportAIUsage(provider string, prompt, completion, cached int64, estimated bool) {
if a.syncUserID() <= 0 || a.store.Meta("sync_access_token") == "" {
return
}
@@ -376,6 +376,7 @@ func (a *App) reportAIUsage(provider string, prompt, completion int64, estimated
"provider": provider,
"promptTokens": prompt,
"completionTokens": completion,
+ "cachedTokens": cached,
"estimated": estimated,
}, nil, true)
}
diff --git a/ai.go b/ai.go
index ab1a4d0..1e98cf8 100644
--- a/ai.go
+++ b/ai.go
@@ -258,9 +258,7 @@ func (a *App) runAIStream(ctx context.Context, provider ai.Provider, convID int6
a.emit("ai:stream", aiStreamEvent{ConversationID: convID, Error: e.Error(), Done: true})
return
}
- if usage != nil {
- go a.reportAIUsage(provider.Name(), usage.PromptTokens, usage.CompletionTokens, usage.Estimated)
- }
+ a.recordAIUsage(provider.Name(), "chat", usage, msgs, sb.String())
a.store.TouchAIConversation(convID, provider.Name())
a.emit("ai:stream", aiStreamEvent{ConversationID: convID, Done: true, MessageID: m.ID})
}
@@ -339,6 +337,21 @@ func (a *App) aiProvider() (ai.Provider, error) {
return ai.New(st.AIProvider, key)
}
+// recordAIUsage 写入本地「AI」生成记录并上报云端日汇总。usage 为空时按消息粗估,避免生成成功却没有记录。
+func (a *App) recordAIUsage(provider, scene string, usage *ai.Usage, msgs []ai.Message, completion string) {
+ if usage == nil {
+ usage = ai.EstimateUsage(msgs, completion)
+ }
+ cache := "none"
+ if usage.CachedTokens > 0 {
+ cache = fmt.Sprintf("hit:%d", usage.CachedTokens)
+ }
+ a.store.Log("info", "AI", "AI 调用完成", fmt.Sprintf(
+ "scene=%s provider=%s prompt=%d completion=%d cached=%d cache=%s estimated=%v",
+ scene, provider, usage.PromptTokens, usage.CompletionTokens, usage.CachedTokens, cache, usage.Estimated))
+ go a.reportAIUsage(provider, usage.PromptTokens, usage.CompletionTokens, usage.CachedTokens, usage.Estimated)
+}
+
// aiSummarySem 全局串行生成,避免批量分析后并发打爆免费额度接口。
var aiSummarySem = make(chan struct{}, 1)
@@ -501,6 +514,7 @@ func (a *App) generateAISummaries(projectID int64, kinds ...string) {
// 无数据的全局简报直接落固定文案,不浪费一次注定编造的模型调用。
if content, ok := a.staticDayBrief(kind, st.Locale); ok {
if e := a.store.SaveAISummary(projectID, kind, "system", content); e == nil {
+ a.store.Log("info", "AI", "AI 调用完成", fmt.Sprintf("scene=summary:%s cache=local provider=system", kind))
a.emit("ai:summary", aiSummaryEvent{ProjectID: projectID, Kind: kind})
}
continue
@@ -518,8 +532,12 @@ func (a *App) generateAISummaries(projectID int64, kinds ...string) {
continue
}
var sb strings.Builder
+ var usage *ai.Usage
var streamErr error
for chunk := range stream {
+ if chunk.Usage != nil {
+ usage = chunk.Usage
+ }
if chunk.Err != nil {
streamErr = chunk.Err
break
@@ -536,6 +554,7 @@ func (a *App) generateAISummaries(projectID int64, kinds ...string) {
a.emit("ai:summary", aiSummaryEvent{ProjectID: projectID, Kind: kind, Error: msg})
continue
}
+ a.recordAIUsage(provider.Name(), "summary:"+kind, usage, msgs, sb.String())
if e := a.store.SaveAISummary(projectID, kind, provider.Name(), dedentCommon(stripFence(strings.TrimSpace(sb.String())))); e != nil {
a.store.Log("warning", "AI", "AI 模块介绍保存失败", e.Error())
continue
diff --git a/ai_tasks.go b/ai_tasks.go
index 9007e76..5024413 100644
--- a/ai_tasks.go
+++ b/ai_tasks.go
@@ -45,8 +45,12 @@ func (a *App) AIGenerateTasks(kind, text string) ([]AITaskDraft, error) {
if e := a.checkAIPolicy(); e != nil {
return nil, e
}
+ locale := "zh-CN"
+ if st, se := a.store.Settings(); se == nil && st.Locale != "" {
+ locale = st.Locale
+ }
msgs := []ai.Message{
- {Role: "system", Content: aiTaskPrompt(kind)},
+ {Role: "system", Content: aiTaskPrompt(kind, locale)},
{Role: "user", Content: text},
}
ctx, cancel := context.WithTimeout(a.ctx, 2*time.Minute)
@@ -68,9 +72,7 @@ func (a *App) AIGenerateTasks(kind, text string) ([]AITaskDraft, error) {
}
sb.WriteString(chunk.Content)
}
- if usage != nil {
- go a.reportAIUsage(provider.Name(), usage.PromptTokens, usage.CompletionTokens, usage.Estimated)
- }
+ a.recordAIUsage(provider.Name(), "generate:"+kind, usage, msgs, sb.String())
drafts, pe := parseAITaskDrafts(kind, sb.String())
if pe != nil {
a.store.Log("warning", "AI", "AI 任务生成解析失败", sb.String())
@@ -80,20 +82,90 @@ func (a *App) AIGenerateTasks(kind, text string) ([]AITaskDraft, error) {
return drafts, nil
}
-func aiTaskPrompt(kind string) string {
- now := time.Now()
- weekdays := []string{"日", "一", "二", "三", "四", "五", "六"}
- head := fmt.Sprintf("今天是 %s(星期%s),当前时间 %s。\n", now.Format("2006-01-02"), weekdays[int(now.Weekday())], now.Format("15:04"))
+func aiTaskPrompt(kind, locale string) string {
+ head := aiCalendarContext(locale)
if kind == "ticket" {
- return head + `你是研发工单拆解助手。把用户的一句话拆成 1~10 条工单。
+ return head + `你是研发工单拆解助手。把用户输入拆成 1~10 条工单。
只输出 JSON 数组,禁止任何解释、markdown 围栏或多余文字。每个元素:
{"title":"简短标题(<=40字)","description":"补充细节,可为空","type":"feature|bug|task|improvement","priority":"low|medium|high","startAt":"YYYY-MM-DD","dueAt":"YYYY-MM-DD"}
-规则:标题用与用户输入相同的语言;startAt 默认今天;dueAt 不得早于 startAt,用户未提及工期时按任务量合理估算(1~7 天);能从用户话中推断出的日期(如“周五前”“下周”)必须转换为具体日期。`
+日期规则(必须遵守):
+1. 按用户输入的语言理解相对日期(中文:今天/明天/后天/大后天/本周X/下周X/这周五/下周一/月底/下个月;英文:today/tomorrow/this Friday/next Monday/end of week 等),对照上面日历换成具体 YYYY-MM-DD。
+2. 用户说的不一定是今天:若整段话指向某一天或某一周,所有条目的 startAt/dueAt 都落在那个时间范围内,不要一律填今天。
+3. 一句话里提到多天(如“周三做A,周五做B”“这周每天…”),每条工单各自写对应的 startAt 和 dueAt,不要全部同一天。
+4. 每条都必须写 dueAt。用户给了截止就用;只给了开始日则按工作量估 1~5 天;完全没提日期则 startAt=今天、dueAt 按工作量估。
+5. dueAt 不得早于 startAt。标题语言与用户输入一致。
+6. 用户提到节假日/黄金周/放假/调休(国庆、十一、春节、过年、中秋、端午、元旦、五一、清明、圣诞、感恩节等),必须对照上面节假日表换成具体 YYYY-MM-DD,禁止自己换算农历。「X前」= 节日前一天;「X期间」= 表中放假区间(没有则用当天);「X后」= 假期结束后一天。`
}
- return head + `你是待办事项拆解助手。把用户的一句话拆成 1~10 条待办。
+ return head + `你是待办事项拆解助手。把用户输入拆成 1~10 条待办。
只输出 JSON 数组,禁止任何解释、markdown 围栏或多余文字。每个元素:
-{"title":"简短标题(<=40字)","content":"补充细节,可为空","priority":"low|medium|high","dueAt":"YYYY-MM-DDTHH:MM 或空字符串"}
-规则:标题用与用户输入相同的语言;只有用户明确或可合理推断截止时间时才填 dueAt(如“明天下午”→ 明天 18:00),否则留空字符串。`
+{"title":"简短标题(<=40字)","content":"补充细节,可为空","priority":"low|medium|high","dueAt":"YYYY-MM-DDTHH:MM"}
+日期规则(必须遵守):
+1. 按用户输入的语言理解相对日期和时间(中文:今天/明天/后天/本周X/下周X/上午/下午/晚上/点钟;英文:today/tomorrow/this Friday/next week/afternoon 等),对照上面日历换成具体 YYYY-MM-DDTHH:MM。
+2. 用户说的不一定是今天:若整段话指向某一天或某一周,所有条目的 dueAt 都落在那个时间,不要一律填今天。
+3. 一句话里提到多天(如“周一买菜,周三交报告”),每条待办各自写对应的 dueAt。
+4. 每条都必须写 dueAt,不要留空。用户给了时刻就用;只给了日期则:上午→10:00、下午→18:00、晚上→21:00、未提时段→18:00。完全没提日期则按紧急度估:高优今天 18:00,中优明天 18:00,低优 3 天后 18:00。
+5. 标题语言与用户输入一致。
+6. 用户提到节假日/黄金周/放假(国庆、十一、春节、过年、中秋、端午、元旦、五一、清明、圣诞、感恩节等),必须对照上面节假日表换成具体日期,禁止自己换算农历。「X前」= 节日前一天 18:00;「X期间」落在放假区间;「X后」从假期结束后一天起。`
+}
+
+// aiCalendarContext 给模型一份「今天 + 本周/下周对照表」,避免相对日期猜错,也不默认全是今天。
+func aiCalendarContext(locale string) string {
+ now := time.Now()
+ en := strings.HasPrefix(strings.ToLower(locale), "en")
+ wdCN := []string{"日", "一", "二", "三", "四", "五", "六"}
+ wdEN := []string{"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"}
+ weekday := func(t time.Time) string {
+ if en {
+ return wdEN[int(t.Weekday())]
+ }
+ return "星期" + wdCN[int(t.Weekday())]
+ }
+ // 本周一:Go Weekday 周日=0
+ offset := int(now.Weekday())
+ if offset == 0 {
+ offset = 7
+ }
+ monday := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).AddDate(0, 0, 1-offset)
+ var b strings.Builder
+ if en {
+ fmt.Fprintf(&b, "Today is %s (%s), current time %s. Timezone is the user's local time.\n", now.Format("2006-01-02"), weekday(now), now.Format("15:04"))
+ b.WriteString("This week:\n")
+ } else {
+ fmt.Fprintf(&b, "今天是 %s(%s),当前时间 %s。时区按用户本机。\n", now.Format("2006-01-02"), weekday(now), now.Format("15:04"))
+ b.WriteString("本周对照:\n")
+ }
+ for i := 0; i < 7; i++ {
+ d := monday.AddDate(0, 0, i)
+ mark := ""
+ if d.Format("2006-01-02") == now.Format("2006-01-02") {
+ if en {
+ mark = " ← today"
+ } else {
+ mark = " ← 今天"
+ }
+ }
+ fmt.Fprintf(&b, "- %s %s%s\n", weekday(d), d.Format("2006-01-02"), mark)
+ }
+ if en {
+ b.WriteString("Next week:\n")
+ } else {
+ b.WriteString("下周对照:\n")
+ }
+ for i := 0; i < 7; i++ {
+ d := monday.AddDate(0, 0, 7+i)
+ fmt.Fprintf(&b, "- %s %s\n", weekday(d), d.Format("2006-01-02"))
+ }
+ monthEnd := time.Date(now.Year(), now.Month()+1, 0, 0, 0, 0, 0, now.Location())
+ nextMonth := time.Date(now.Year(), now.Month()+1, 1, 0, 0, 0, 0, now.Location())
+ if en {
+ fmt.Fprintf(&b, "End of this month: %s. First day of next month: %s.\n", monthEnd.Format("2006-01-02"), nextMonth.Format("2006-01-02"))
+ b.WriteString("Convert relative phrases using this calendar. Do not default every item to today unless the user said today or gave no date at all.\n")
+ } else {
+ fmt.Fprintf(&b, "本月最后一天:%s。下月 1 日:%s。\n", monthEnd.Format("2006-01-02"), nextMonth.Format("2006-01-02"))
+ b.WriteString("相对日期必须对照上面日历换算。用户没说今天时,不要把每条都填成今天。\n")
+ }
+ b.WriteString(formatHolidayBlock(now, en))
+ return b.String()
}
// parseAITaskDrafts 从模型输出中提取 JSON 数组并规范化字段。
@@ -128,11 +200,21 @@ func parseAITaskDrafts(kind, raw string) ([]AITaskDraft, error) {
d.Type = "task"
}
d.Content = ""
- if !validDateStr(d.StartAt) {
+ // 优先保留模型按用户语义写出的日期;缺了才补,避免把「下周三」整批改成今天。
+ d.StartAt = dateOnly(d.StartAt)
+ d.DueAt = dateOnly(d.DueAt)
+ if d.StartAt == "" && d.DueAt != "" {
+ d.StartAt = d.DueAt
+ }
+ if d.StartAt == "" {
d.StartAt = today
}
- if !validDateStr(d.DueAt) {
- d.DueAt = time.Now().AddDate(0, 0, 3).Format("2006-01-02")
+ if d.DueAt == "" {
+ if t, e := time.Parse("2006-01-02", d.StartAt); e == nil {
+ d.DueAt = t.AddDate(0, 0, 2).Format("2006-01-02")
+ } else {
+ d.DueAt = time.Now().AddDate(0, 0, 2).Format("2006-01-02")
+ }
}
if d.DueAt < d.StartAt {
d.DueAt = d.StartAt
@@ -140,6 +222,16 @@ func parseAITaskDrafts(kind, raw string) ([]AITaskDraft, error) {
} else {
d.Type, d.Description, d.StartAt = "", "", ""
d.DueAt = normalizeTodoDue(d.DueAt)
+ if d.DueAt == "" {
+ // 待办也必须有截止日期,缺省按优先级估。
+ days := 1
+ if d.Priority == "high" {
+ days = 0
+ } else if d.Priority == "low" {
+ days = 3
+ }
+ d.DueAt = time.Now().AddDate(0, 0, days).Format("2006-01-02") + "T18:00"
+ }
}
out = append(out, d)
if len(out) >= 20 {
@@ -153,21 +245,45 @@ func parseAITaskDrafts(kind, raw string) ([]AITaskDraft, error) {
}
func validDateStr(s string) bool {
- _, e := time.Parse("2006-01-02", strings.TrimSpace(s))
- return e == nil
+ return dateOnly(s) != ""
}
-// normalizeTodoDue 接受 YYYY-MM-DDTHH:MM 或纯日期(补 18:00),其余返回空。
+// dateOnly 从 YYYY-MM-DD 或带时间的写法里抽出日期;解析失败返回空。
+func dateOnly(s string) string {
+ s = strings.TrimSpace(s)
+ if s == "" {
+ return ""
+ }
+ if i := strings.IndexAny(s, "T "); i > 0 {
+ s = s[:i]
+ }
+ if _, e := time.Parse("2006-01-02", s); e == nil {
+ return s
+ }
+ return ""
+}
+
+// normalizeTodoDue 接受常见日期/时间写法,统一成 datetime-local 用的 YYYY-MM-DDTHH:MM。
func normalizeTodoDue(s string) string {
s = strings.TrimSpace(s)
if s == "" {
return ""
}
- if _, e := time.Parse("2006-01-02T15:04", s); e == nil {
- return s
+ s = strings.ReplaceAll(s, " ", "T")
+ for _, layout := range []string{
+ "2006-01-02T15:04",
+ "2006-01-02T15:04:05",
+ time.RFC3339,
+ } {
+ if t, e := time.Parse(layout, s); e == nil {
+ return t.Format("2006-01-02T15:04")
+ }
}
if validDateStr(s) {
return s + "T18:00"
}
+ if i := strings.Index(s, "T"); i == 10 && validDateStr(s[:10]) {
+ return s[:10] + "T18:00"
+ }
return ""
}
diff --git a/frontend/src/App.vue b/frontend/src/App.vue
index 2ce32bb..34f9408 100644
--- a/frontend/src/App.vue
+++ b/frontend/src/App.vue
@@ -2,7 +2,7 @@
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
-import { LayoutDashboard, ScrollText, Settings, X, Database, SlidersHorizontal, Home, ListTodo, TicketCheck, CalendarDays, CalendarCheck2, Sparkles, UserRound, ClipboardList, Wrench, Rocket, ChevronDown, Bell, StickyNote, ListFilter, Palette, Bot, Users, NotebookPen, CloudUpload, Power, Image, Activity, Package, Shield } from 'lucide-vue-next'
+import { LayoutDashboard, ScrollText, Settings, X, Database, SlidersHorizontal, Home, ListTodo, TicketCheck, CalendarDays, CalendarCheck2, Sparkles, UserRound, ClipboardList, Wrench, Rocket, ChevronDown, Bell, StickyNote, ListFilter, Palette, Bot, Users, NotebookPen, CloudUpload, Power, Image, Activity, Package, Shield, Plus, Pin, PinOff } from 'lucide-vue-next'
import { useAppStore } from './store'
import DatabaseSetup from './components/DatabaseSetup.vue'
import BrowserBlocked from './components/BrowserBlocked.vue'
@@ -107,14 +107,74 @@ const navGroups = [
{ to: '/settings?tab=database', icon: Database, label: 'tabDatabase', match: r => r.path === '/settings' && settingsTab(r) === 'database' }
] }
]
+
+// ---- 概览快捷入口:把其它分区菜单钉到「概览」二级导航 ----
+const SHORTCUT_KEY = 'cc-nav-shortcuts'
+const loadShortcuts = () => {
+ try {
+ const raw = JSON.parse(localStorage.getItem(SHORTCUT_KEY) || '[]')
+ return Array.isArray(raw) ? raw.filter(x => typeof x === 'string') : []
+ } catch { return [] }
+}
+const navShortcuts = ref(loadShortcuts())
+const shortcutPicker = ref(false)
+function saveShortcuts() {
+ localStorage.setItem(SHORTCUT_KEY, JSON.stringify(navShortcuts.value))
+}
+const catalogByTo = computed(() => {
+ const map = new Map()
+ for (const g of navGroups) {
+ for (const it of g.items) map.set(it.to, { ...it, groupId: g.id, groupLabel: g.label })
+ }
+ return map
+})
+// 可钉到概览的项:非概览内置、且当前用户可见
+const pinnableItems = computed(() => {
+ const overviewTos = new Set(navGroups[0].items.map(it => it.to))
+ const out = []
+ for (const g of navGroups) {
+ if (g.id === 'overview') continue
+ for (const it of g.items) {
+ if (overviewTos.has(it.to)) continue
+ if (it.adminOnly && store.syncStatus.userId !== 1) continue
+ out.push({ ...it, groupId: g.id, groupLabel: g.label })
+ }
+ }
+ return out
+})
+function addShortcut(to) {
+ if (navShortcuts.value.includes(to)) return
+ navShortcuts.value = [...navShortcuts.value, to]
+ saveShortcuts()
+ shortcutPicker.value = false
+}
+function removeShortcut(to) {
+ navShortcuts.value = navShortcuts.value.filter(x => x !== to)
+ saveShortcuts()
+}
+function toggleShortcut(to) {
+ if (navShortcuts.value.includes(to)) removeShortcut(to)
+ else addShortcut(to)
+}
+
// adminOnly 项只对云端管理员(id=1)展示
-const visibleItems = g => g.items.filter(it => !it.adminOnly || store.syncStatus.userId === 1)
+const visibleItems = g => {
+ let items = g.items.filter(it => !it.adminOnly || store.syncStatus.userId === 1)
+ if (g.id === 'overview') {
+ const extras = navShortcuts.value
+ .map(to => catalogByTo.value.get(to))
+ .filter(it => it && (!it.adminOnly || store.syncStatus.userId === 1))
+ .map(it => ({ ...it, shortcut: true }))
+ items = [...items, ...extras]
+ }
+ return items
+}
const itemActive = it => it.match ? it.match(route) : route.path === it.to.split('?')[0]
// 导航徽标:badge 显示数字,dot 只显示小圆点;一级 rail 在组内任一非零时亮点
const badgeVal = it => it.badge ? (store.badges[it.badge] || 0) : 0
const badgeText = it => { const n = badgeVal(it); return n > 99 ? '99+' : String(n) }
const dotVal = it => it.dot ? !!store.badges[it.dot] : false
-const groupDot = g => g.items.some(it => badgeVal(it) > 0 || dotVal(it))
+const groupDot = g => visibleItems(g).some(it => badgeVal(it) > 0 || dotVal(it))
const childActive = c => route.path === '/settings' && String(route.query.tab || '') === c.tab
const groupOfRoute = () => navGroups.find(g => g.items.some(it => itemActive(it)))?.id
const activeGroupId = ref(groupOfRoute() || 'overview')
@@ -126,6 +186,7 @@ watch(() => route.fullPath, () => {
const g = groupOfRoute()
if (g) activeGroupId.value = g
railFlyout.value = null
+ if (g !== 'overview') shortcutPicker.value = false
for (const grp of navGroups) {
for (const it of grp.items) if (it.children && itemActive(it)) expandedParents.value[it.label] = true
}
@@ -263,16 +324,29 @@ watch(activeTask, task => {
{{ t(activeGroup.label) }}
+
+
+
+
+ {{ t('navShortcutPicker') }}
+
+
+ {{ t('navShortcutHint') }}
+
+
+
+
+
+
{{ activeTaskProject || visibleTask.stage }}{{ t(visibleTask.messageKey || 'task.start', visibleTask.params || {}) }}
diff --git a/frontend/src/components/AITaskGenerator.vue b/frontend/src/components/AITaskGenerator.vue
index b034494..c0b0e71 100644
--- a/frontend/src/components/AITaskGenerator.vue
+++ b/frontend/src/components/AITaskGenerator.vue
@@ -111,6 +111,7 @@ function toggleAll() {
+ {{ t('aiGenDateHint') }}
{{ kind === 'todo' ? d.content : d.description }}
@@ -175,7 +185,6 @@ function toggleAll() {
padding: .45rem 0;
font-size: .88rem;
}
-.btn.sm { padding: .38rem .7rem; font-size: .82rem; }
.ai-gen-modal { width: min(680px, 94vw); max-height: 84vh; display: flex; flex-direction: column; }
.ai-gen-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: .6rem; }
.ai-gen-head h2 { display: flex; align-items: center; gap: .45rem; font-size: 1.05rem; margin: 0; }
@@ -189,8 +198,11 @@ function toggleAll() {
.ai-gen-check { padding-top: .35rem; }
.ai-gen-fields { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: .4rem; }
.ai-gen-title { width: 100%; padding: .4rem .55rem; border-radius: 8px; border: 1px solid var(--border); background: transparent; color: var(--text); font-weight: 600; }
-.ai-gen-row { display: flex; gap: .4rem; flex-wrap: wrap; }
+.ai-gen-date-hint { margin: 0 0 .55rem; font-size: .78rem; color: var(--muted); }
+.ai-gen-row { display: flex; gap: .4rem; flex-wrap: wrap; align-items: flex-end; }
.ai-gen-row select, .ai-gen-row input { padding: .3rem .45rem; border-radius: 8px; border: 1px solid var(--border); background: transparent; color: var(--text); font-size: .82rem; }
+.ai-gen-date { display: inline-flex; flex-direction: column; gap: .15rem; font-size: .72rem; color: var(--muted); }
+.ai-gen-date input { min-width: 9.5rem; }
.ai-gen-desc { margin: 0; font-size: .8rem; color: var(--muted); white-space: pre-wrap; }
.ai-gen-err { color: #ef4444; font-size: .85rem; margin: .5rem 0 0; }
.ai-gen-hint { color: #f59e0b; font-size: .85rem; margin: .5rem 0 0; }
diff --git a/frontend/src/components/CloudClaimModal.vue b/frontend/src/components/CloudClaimModal.vue
index 129a340..de4c4a4 100644
--- a/frontend/src/components/CloudClaimModal.vue
+++ b/frontend/src/components/CloudClaimModal.vue
@@ -141,7 +141,7 @@ onUnmounted(() => { offSync?.() })
.claim-rules svg { width: 12px; height: 12px; }
.claim-err { margin: .15rem 0 0; color: #ef4444; font-size: .78rem; }
.claim-ops { display: flex; flex-direction: column; gap: .35rem; flex: none; }
-.btn.sm { padding: .34rem .65rem; font-size: .8rem; }
+.claim-ops .btn { white-space: nowrap; }
.claim-empty { text-align: center; color: var(--muted); padding: 1.5rem 0; }
.claim-foot { display: flex; justify-content: flex-end; margin-top: .85rem; }
.spin { animation: claim-spin 1s linear infinite; }
diff --git a/frontend/src/components/NetworkInfoModal.vue b/frontend/src/components/NetworkInfoModal.vue
new file mode 100644
index 0000000..baeccf7
--- /dev/null
+++ b/frontend/src/components/NetworkInfoModal.vue
@@ -0,0 +1,195 @@
+
+
+
+
+
+
+
+ {{ t('netInfoTitle') }}
+
+
+
+
+
+
+
+ {{ t('loading') }}
+ {{ err }}
+
+
+
+ {{ t('netInfoHostname') }}
+ {{ info.hostname || '—' }}
+
+
+
+ {{ t('netInfoPrimaryIP') }}
+ {{ info.primaryIPv4 || '—' }}
+
+
+
+
+
+
+
+
+
+ - MAC
+ - IPv4
+ - IPv6
+ - {{ t('netInfoGateway') }}
+ - DNS
+ - DHCP
- {{ t('optOn') }}
+
+
+
{{ t('netInfoEmpty') }}
+
+
+
+ {{ t('netInfoRaw') }}
+ {{ info.raw }}
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/NoteModal.vue b/frontend/src/components/NoteModal.vue
index 54707e7..2176f08 100644
--- a/frontend/src/components/NoteModal.vue
+++ b/frontend/src/components/NoteModal.vue
@@ -1,16 +1,22 @@
@@ -58,16 +68,21 @@ onUnmounted(() => { removeEventListener('keydown', onKey); clearTimeout(timer) }
-
+
{{ noteId ? t('noteEdit') : t('noteNew') }}
{{ t('autoSaved') }} {{ savedAt }}
+
-
+
+
diff --git a/frontend/src/main.js b/frontend/src/main.js
index 991f268..4a9954a 100644
--- a/frontend/src/main.js
+++ b/frontend/src/main.js
@@ -37,6 +37,10 @@ const zh = {
logs: '运行日志',
settings: '设置',
navOverview: '概览',
+ navAddShortcut: '添加快捷入口',
+ navShortcutPicker: '选择快捷入口',
+ navShortcutHint: '把常用菜单钉到概览,方便一键到达',
+ navUnpinShortcut: '从概览移除',
navProjects: '项目',
navWork: '事务',
navSystem: '系统',
@@ -92,6 +96,20 @@ const zh = {
lpMyApps: '我的应用',
lpScanned: '检测到的服务',
lpAddApp: '添加应用',
+ netInfoBtn: '本机网络',
+ netInfoTitle: '本机网络信息',
+ netInfoHostname: '主机名',
+ netInfoPrimaryIP: '主局域网 IP',
+ netInfoPrimary: '主网卡',
+ netInfoVirtual: '虚拟网卡',
+ netInfoGateway: '网关',
+ netInfoCopyIP: '复制 IP',
+ netInfoCopyAll: '复制全部',
+ netInfoCopied: '已复制到剪贴板',
+ netInfoCopyFail: '复制失败',
+ netInfoEmpty: '未检测到可用网卡',
+ netInfoRaw: '原始 ipconfig 输出',
+ copy: '复制',
lpEditApp: '编辑应用',
lpRefresh: '刷新',
lpShowSys: '显示系统进程',
@@ -196,8 +214,8 @@ const zh = {
ticketStatus: { open: '待处理', in_progress: '处理中', resolved: '已解决', closed: '已关闭' },
ticketFlow: { start: '开始处理', resolve: '标记解决', close: '关闭', reopen: '重新打开' },
noTickets: '暂无工单',
- aiGenPhTodo: '一句话描述,AI 帮你拆成多条待办,回车生成...',
- aiGenPhTicket: '一句话描述需求,AI 帮你拆成多条工单,回车生成...',
+ aiGenPhTodo: '例如:国庆前交报告,中秋给家里打电话… AI 会按日期/节日拆条',
+ aiGenPhTicket: '例如:十一前改登录,春节后做导出… AI 会按日期/节日拆条',
aiGenBtn: 'AI 生成',
aiGenBusy: '生成中...',
aiGenPreviewTitle: 'AI 生成预览',
@@ -208,6 +226,7 @@ const zh = {
aiGenNeedProject: '工单必须关联项目,请先选择归属项目',
aiGenSaveN: '保存所选({n})',
aiGenSavedToast: '已保存 {n} 条',
+ aiGenDateHint: '日期由 AI 根据你的描述和节假日推断(不一定是今天,多天会拆开),可直接改。',
today: '今天',
selectDate: '选择日期',
noSchedule: '当日无排期',
@@ -232,6 +251,7 @@ const zh = {
noteEdit: '编辑笔记',
noteEmpty: '还没有笔记,点右上角新建一条',
noteUntitled: '(空白笔记)',
+ noteExpand: '展开查看',
noteDeleteConfirm: '删除这条笔记?',
notesPage: '笔记',
notesSubtitle: '共 {n} 条笔记,点击卡片编辑',
@@ -768,9 +788,11 @@ const zh = {
adminStatTeams: '团队数',
adminStatDAU: '今日日活',
adminStatTokens: '今日 Token',
+ adminStatCached: '今日缓存命中',
adminDAUSeries: '近 14 日日活',
adminTokenSeries: '近 14 日 Token',
adminCalls: '调用次数',
+ adminCached: '缓存命中',
adminProviderPie: 'AI 提供商用量分布',
adminDataPie: '云端数据构成',
adminViewDetail: '查看详情',
@@ -1097,6 +1119,10 @@ const en = {
logs: 'Activity Log',
settings: 'Settings',
navOverview: 'Overview',
+ navAddShortcut: 'Add shortcut',
+ navShortcutPicker: 'Pick shortcuts',
+ navShortcutHint: 'Pin frequent menus to Overview for one-tap access',
+ navUnpinShortcut: 'Remove from Overview',
navProjects: 'Projects',
navWork: 'Work',
navSystem: 'System',
@@ -1152,6 +1178,20 @@ const en = {
lpMyApps: 'My apps',
lpScanned: 'Detected services',
lpAddApp: 'Add app',
+ netInfoBtn: 'Network info',
+ netInfoTitle: 'Local network info',
+ netInfoHostname: 'Hostname',
+ netInfoPrimaryIP: 'Primary LAN IP',
+ netInfoPrimary: 'Primary',
+ netInfoVirtual: 'Virtual',
+ netInfoGateway: 'Gateway',
+ netInfoCopyIP: 'Copy IP',
+ netInfoCopyAll: 'Copy all',
+ netInfoCopied: 'Copied to clipboard',
+ netInfoCopyFail: 'Copy failed',
+ netInfoEmpty: 'No adapters found',
+ netInfoRaw: 'Raw ipconfig output',
+ copy: 'Copy',
lpEditApp: 'Edit app',
lpRefresh: 'Refresh',
lpShowSys: 'Show system processes',
@@ -1256,8 +1296,8 @@ const en = {
ticketStatus: { open: 'Open', in_progress: 'In progress', resolved: 'Resolved', closed: 'Closed' },
ticketFlow: { start: 'Start', resolve: 'Resolve', close: 'Close', reopen: 'Reopen' },
noTickets: 'No tickets yet',
- aiGenPhTodo: 'Describe in one sentence and AI splits it into todos. Enter to generate...',
- aiGenPhTicket: 'Describe in one sentence and AI splits it into tickets. Enter to generate...',
+ aiGenPhTodo: 'e.g. submit the report before National Day, call home on Mid-Autumn… AI splits by date/holiday',
+ aiGenPhTicket: 'e.g. fix login before Oct 1, export after Spring Festival… AI splits by date/holiday',
aiGenBtn: 'AI generate',
aiGenBusy: 'Generating...',
aiGenPreviewTitle: 'AI generated preview',
@@ -1268,6 +1308,7 @@ const en = {
aiGenNeedProject: 'Tickets must belong to a project. Pick one first.',
aiGenSaveN: 'Save selected ({n})',
aiGenSavedToast: 'Saved {n} item(s)',
+ aiGenDateHint: 'Dates are inferred from your wording and holidays (not always today; multi-day items are split). You can edit them.',
today: 'Today',
selectDate: 'Select a date',
noSchedule: 'Nothing scheduled',
@@ -1292,6 +1333,7 @@ const en = {
noteEdit: 'Edit note',
noteEmpty: 'No notes yet — create one from the top right',
noteUntitled: '(Blank note)',
+ noteExpand: 'Open full view',
noteDeleteConfirm: 'Delete this note?',
notesPage: 'Notes',
notesSubtitle: '{n} notes in total, click a card to edit',
@@ -1828,9 +1870,11 @@ const en = {
adminStatTeams: 'Teams',
adminStatDAU: 'DAU today',
adminStatTokens: 'Tokens today',
+ adminStatCached: 'Cache hits today',
adminDAUSeries: 'DAU (14d)',
adminTokenSeries: 'Tokens (14d)',
adminCalls: 'Calls',
+ adminCached: 'Cache hits',
adminProviderPie: 'AI provider usage',
adminDataPie: 'Cloud data breakdown',
adminViewDetail: 'View detail',
diff --git a/frontend/src/polish.css b/frontend/src/polish.css
index 30b0d51..8d6968c 100644
--- a/frontend/src/polish.css
+++ b/frontend/src/polish.css
@@ -653,7 +653,7 @@ html[data-theme=light] .heat-cell.l1{background:#b9efd4}html[data-theme=light] .
@media(min-width:1700px){.wb-grid{grid-template-columns:repeat(4,minmax(0,1fr))}}
.wb-col{margin-bottom:0}
.wb-col .section-head h2 small{margin-left:8px;color:var(--muted);font-weight:normal;font-size:12px}
-.wb-col .section-head .btn{height:32px;padding:0 11px;font-size:12px}
+.wb-col .section-head .btn{height:32px;padding:0 11px;font-size:12px;white-space:nowrap;flex-shrink:0}
.wb-col .section-head .btn svg{width:13px}
.wb-list{display:grid;gap:8px;margin-top:14px}
.wb-item{position:relative;display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:10px;align-items:center;background:var(--surface-2);border:1px solid var(--glass-border);border-radius:11px;padding:10px 12px;overflow:hidden;transition:border-color .18s}
@@ -678,11 +678,12 @@ html[data-theme=light] .heat-cell.l1{background:#b9efd4}html[data-theme=light] .
.wb-note-saved{color:var(--green);font-size:11px}
.wb-note-dirty{color:var(--yellow);font-size:11px}
.wb-note-panel{display:flex;flex-direction:column;min-height:0}
-.wb-note-panel .section-head{flex-wrap:wrap;gap:8px}
-.wb-note-acts{display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin-left:auto}
-.wb-note-acts .btn{height:30px;padding:0 10px;font-size:12px}
-.wb-note-acts .btn svg{width:13px;height:13px}
-.wb-note-body{display:grid;grid-template-columns:minmax(120px,38%) 1fr;gap:10px;margin-top:12px;min-height:180px}
+.wb-note-head{flex-wrap:nowrap;gap:8px;align-items:center}
+.wb-note-head h2{flex:none;min-width:0}
+.wb-note-acts{display:flex;align-items:center;gap:6px;flex-wrap:nowrap;margin-left:auto;flex:1;justify-content:flex-end;min-width:0}
+.wb-note-acts .btn{height:30px;padding:0 10px;font-size:12px;flex:none;white-space:nowrap}
+.wb-note-acts .btn svg{width:13px;height:13px;flex:none}
+.wb-note-body{display:grid;grid-template-columns:minmax(120px,38%) 1fr;gap:10px;margin-top:12px;min-height:0}
.wb-note-list{display:flex;flex-direction:column;gap:4px;max-height:220px;overflow:auto;padding:4px;border:1px solid var(--border);border-radius:8px;background:var(--surface-2)}
.wb-note-item{display:flex;flex-direction:column;align-items:flex-start;gap:2px;width:100%;text-align:left;padding:8px 9px;border:0;border-radius:7px;background:transparent;color:var(--text);cursor:pointer;font:inherit}
.wb-note-item:hover{background:var(--surface-3)}
@@ -690,13 +691,19 @@ html[data-theme=light] .heat-cell.l1{background:#b9efd4}html[data-theme=light] .
.wb-note-item b{font-size:12.5px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:100%}
.wb-note-item time{font-size:10.5px;color:var(--muted)}
.wb-note-list-empty{padding:16px 8px;text-align:center;color:var(--muted);font-size:12px}
-.wb-note-body .wb-note{min-height:180px;height:100%;resize:vertical}
-/* 编辑/预览切换 + Markdown 预览框 */
-.wb-note-tabs{margin-right:2px}
-.wb-note-tabs button{display:inline-flex;align-items:center;gap:5px;height:30px;padding:0 10px;font-size:12px}
-.wb-note-tabs button svg{width:13px;height:13px}
-.wb-note-body .wb-note-preview{min-height:180px;max-height:none;height:100%;overflow:auto}
+.wb-note-tabs{margin-right:2px;flex:none}
+.wb-note-tabs button{display:inline-flex;align-items:center;justify-content:center;width:30px;height:30px;padding:0}
+.wb-note-tabs button svg{width:14px;height:14px}
+.wb-note-stage{position:relative;min-height:0;max-height:220px;display:flex;flex-direction:column}
+.wb-note-stage .wb-note{min-height:160px;max-height:220px;height:220px;resize:none}
+.wb-note-stage .wb-note-preview{min-height:160px;max-height:220px;height:220px;overflow:auto;cursor:pointer}
+.wb-note-stage.preview:hover .wb-note-preview{border-color:var(--primary)}
+.wb-note-expand{position:absolute;right:10px;bottom:10px;display:inline-flex;align-items:center;gap:5px;height:28px;padding:0 10px;border-radius:7px;border:1px solid var(--border);background:color-mix(in srgb,var(--surface) 88%,transparent);color:var(--text);font:inherit;font-size:11.5px;cursor:pointer;backdrop-filter:blur(6px)}
+.wb-note-expand svg{width:12px;height:12px}
+.wb-note-expand:hover{border-color:var(--primary);color:var(--primary)}
@media (max-width:980px){
+ .wb-note-head{flex-wrap:wrap}
+ .wb-note-acts{flex-wrap:wrap;justify-content:flex-start;width:100%;margin-left:0}
.wb-note-body{grid-template-columns:1fr}
.wb-note-list{max-height:120px;flex-direction:row;flex-wrap:wrap}
.wb-note-item{width:auto;max-width:46%}
@@ -860,17 +867,21 @@ html[data-theme=light] .heat-cell.l1{background:#b9efd4}html[data-theme=light] .
.nc-item b{flex:1;min-width:0;font-size:12.5px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.nc-item time{flex:none;font-size:10.5px;color:var(--muted);font-variant-numeric:tabular-nums}
/* 笔记详情模态框 */
-.note-modal{width:560px;max-width:94vw;padding:0;overflow:hidden}
-.nm-head{display:flex;align-items:center;gap:10px;padding:13px 16px;border-bottom:1px solid var(--border)}
+.note-modal{width:min(720px,94vw);max-width:94vw;padding:0;overflow:hidden;display:flex;flex-direction:column;max-height:86vh}
+.nm-head{display:flex;align-items:center;gap:10px;padding:13px 16px;border-bottom:1px solid var(--border);flex-wrap:wrap}
.nm-head h2{display:flex;align-items:center;gap:8px;margin:0;font-size:14px}
.nm-head h2 svg{width:15px;height:15px;color:var(--yellow)}
.nm-saved{color:var(--muted);font-size:11px}
-.nm-tools{margin-left:auto;display:flex;gap:6px}
+.nm-mode{margin-left:auto}
+.nm-mode button{display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;padding:0}
+.nm-mode button svg{width:14px;height:14px}
+.nm-tools{display:flex;gap:6px}
.nm-tools button{display:grid;place-items:center;width:28px;height:28px;border:0;border-radius:8px;background:transparent;color:var(--muted);cursor:pointer}
.nm-tools button:hover{background:var(--surface-3);color:var(--text)}
.nm-tools .nm-del:hover{color:var(--red)}
.nm-tools svg{width:15px;height:15px}
-.note-modal .nm-area{display:block;width:100%;height:340px;border:0;border-radius:0;background:transparent;color:var(--text);padding:16px;margin:0;resize:none;outline:none;font:inherit;font-size:13px;line-height:1.75}
+.note-modal .nm-area{display:block;width:100%;height:min(520px,58vh);border:0;border-radius:0;background:transparent;color:var(--text);padding:16px;margin:0;resize:none;outline:none;font:inherit;font-size:13px;line-height:1.75}
+.note-modal .nm-preview{height:min(520px,58vh);max-height:none;border:0;border-radius:0;overflow:auto;padding:16px 18px;background:transparent;box-shadow:none}
.ai-input-row{display:flex;gap:10px;align-items:flex-end;padding-top:14px;border-top:1px solid var(--border)}
.ai-input-row textarea{flex:1;min-height:52px;max-height:180px;border:1px solid var(--border);border-radius:8px;background:var(--surface-2);color:var(--text);padding:12px;resize:vertical;outline:none;font:inherit;font-size:13px;line-height:1.6}
.ai-input-row textarea:focus{border-color:var(--primary)}
@@ -1025,7 +1036,11 @@ input[type=checkbox],input[type=radio]{accent-color:var(--primary)}
.form-error{margin:14px 26px 0;padding:9px 13px;border-radius:10px;background:rgba(240,94,104,.12);border:1px solid rgba(240,94,104,.32);color:#ff9aa2;font-size:12.5px}
/* 按钮:主按钮渐变 + 光泽,悬停轻微上浮 */
-.btn{border-radius:11px;font-weight:600}
+.btn{border-radius:11px;font-weight:600;white-space:nowrap;flex-shrink:0}
+.btn.sm{height:32px;padding:0 12px;font-size:12.5px;gap:6px}
+.btn.sm svg{width:14px;height:14px;flex:none}
+.btn.icon{width:32px;height:32px;padding:0;gap:0}
+.btn.icon svg{width:15px;height:15px}
.btn.primary{background:linear-gradient(140deg,#8f83f9,#6e5ff2 52%,#5b4ce6);border-color:rgba(255,255,255,.16);box-shadow:0 10px 26px rgba(101,87,232,.36),inset 0 1px 0 rgba(255,255,255,.24);text-shadow:0 1px 2px rgba(0,0,0,.16)}
.btn.primary:hover:not(:disabled){filter:brightness(1.08);transform:translateY(-1px);box-shadow:0 14px 30px rgba(101,87,232,.44),inset 0 1px 0 rgba(255,255,255,.28)}
.btn.primary:active:not(:disabled){transform:translateY(0) scale(.985);filter:brightness(.98)}
@@ -1484,6 +1499,7 @@ html[data-theme=light] .ph-stats{background:rgba(255,255,255,.5)}
.lp-head{display:flex;flex-direction:column;align-items:stretch;justify-content:flex-start;gap:12px}
.lp-head-top{display:flex;align-items:flex-start;justify-content:space-between;gap:16px}
.lp-tools{display:flex;align-items:center;gap:10px;flex:none;flex-wrap:nowrap}
+.lp-tools .btn{white-space:nowrap;flex-shrink:0}
.lp-filters{display:flex;align-items:center;gap:10px}
.lp-search{display:inline-flex;align-items:center;gap:6px;height:32px;padding:0 10px;border-radius:8px;border:1px solid var(--border);background:var(--surface-2);color:var(--muted);min-width:0}
.lp-search svg{width:13px;height:13px;flex:none}
@@ -1624,6 +1640,29 @@ html[data-theme=light] .lp-log-line{color:#1f2937}
.sidebar .sub-nav a svg{width:16px;height:16px;flex:none}
.sidebar .sub-nav a:hover{background:var(--surface-3);color:var(--text)}
.sidebar .sub-nav a.active{background:color-mix(in srgb,var(--primary) 15%,transparent);color:#a9a2ff}
+.sidebar .sub-nav a.shortcut{border:1px dashed color-mix(in srgb,var(--primary) 28%,transparent)}
+.nav-unpin{margin-left:4px;display:grid;place-items:center;width:22px;height:22px;border:0;border-radius:6px;background:transparent;color:var(--muted);cursor:pointer;padding:0;flex:none}
+.nav-unpin svg{width:12px!important;height:12px!important}
+.nav-unpin:hover{background:rgba(240,84,84,.12);color:var(--red)}
+.nav-add-shortcut{display:flex;align-items:center;gap:8px;height:34px;margin:6px 0 0;padding:0 10px;border:1px dashed var(--border);border-radius:8px;background:transparent;color:var(--muted);font:inherit;font-size:12px;cursor:pointer;white-space:nowrap;flex-shrink:0}
+.nav-add-shortcut svg{width:14px;height:14px;flex:none}
+.nav-add-shortcut:hover{border-color:var(--primary);color:var(--primary);background:color-mix(in srgb,var(--primary) 8%,transparent)}
+.nav-shortcut-modal{width:min(420px,92vw);max-height:min(560px,80vh);padding:0;overflow:hidden;display:flex;flex-direction:column}
+.nav-shortcut-modal .modal-head{flex:none}
+.nav-shortcut-modal .modal-head h2{display:flex;align-items:center;gap:8px;margin:0;font-size:15px}
+.nav-shortcut-modal .modal-head h2 svg{width:16px;height:16px;color:var(--primary)}
+.nav-shortcut-hint{margin:0;padding:0 20px 10px;font-size:12.5px;color:var(--muted);line-height:1.45}
+.nav-shortcut-list{overflow:auto;display:flex;flex-direction:column;gap:4px;padding:0 12px 16px;min-height:0}
+.nav-shortcut-item{display:flex;align-items:center;gap:10px;min-height:40px;height:40px;padding:0 12px;border:1px solid transparent;border-radius:9px;background:var(--surface-2);color:var(--text);font:inherit;font-size:13px;cursor:pointer;text-align:left;width:100%;flex-shrink:0}
+.nav-shortcut-item:hover{border-color:var(--border);background:var(--surface-3)}
+.nav-shortcut-item.on{border-color:color-mix(in srgb,var(--primary) 40%,var(--border));background:color-mix(in srgb,var(--primary) 12%,transparent);color:#a9a2ff}
+.nav-shortcut-ico{width:16px;height:16px;flex:none;color:var(--muted)}
+.nav-shortcut-item.on .nav-shortcut-ico{color:#a9a2ff}
+.nav-shortcut-label{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:600}
+.nav-shortcut-group{flex:none;font-size:11px;color:var(--muted);white-space:nowrap}
+.nav-shortcut-mark{width:14px;height:14px;flex:none;color:var(--muted);margin-left:4px}
+.nav-shortcut-mark.on{color:var(--primary)}
+.side-sub{position:relative}
.sub-caret{margin-left:auto;display:grid;place-items:center;width:20px;height:20px;border:0;border-radius:6px;background:transparent;color:inherit;cursor:pointer;padding:0}
.sub-caret svg{width:13px;height:13px;transition:transform .18s}
.sub-caret.open svg{transform:rotate(180deg)}
diff --git a/frontend/src/style.css b/frontend/src/style.css
index c1b184a..bbebb8c 100644
--- a/frontend/src/style.css
+++ b/frontend/src/style.css
@@ -1 +1 @@
-@font-face{font-family:Nunito;src:url('./assets/fonts/nunito-v16-latin-regular.woff2')}*{box-sizing:border-box}html{--bg:#0e141d;--side:#151b24;--surface:#19212b;--surface-2:#202838;--surface-3:#252e40;--border:#303949;--text:#f4f5f8;--muted:#929bac;--primary:#7367f5;--green:#43c996;--blue:#4f9df5;--red:#f05e68;--yellow:#e7bd35;background:var(--bg);color:var(--text);font-family:Nunito,"Segoe UI",sans-serif;letter-spacing:0}html[data-theme=light]{--bg:#f4f6fa;--side:#fff;--surface:#fff;--surface-2:#f6f7fa;--surface-3:#edf0f6;--border:#dce1ea;--text:#111827;--muted:#526071;--primary:#6557e8}body{margin:0;min-width:960px}button,input,textarea,select{font:inherit;letter-spacing:0}.shell{min-height:100vh}.sidebar{position:fixed;inset:0 auto 0 0;width:232px;background:var(--side);border-right:1px solid var(--border);padding:24px 16px;display:flex;flex-direction:column;z-index:10}.brand{height:52px;display:flex;align-items:center;gap:14px;font-size:18px;padding:0 8px}.brand-mark{display:grid;place-items:center;width:40px;height:40px;background:var(--primary);border-radius:8px;color:white}.brand-mark svg{width:23px}.sidebar nav{display:grid;gap:8px;margin-top:35px}.sidebar nav a{height:54px;display:flex;align-items:center;gap:16px;padding:0 18px;color:var(--muted);text-decoration:none;border-radius:7px;transition:background .2s,color .2s}.sidebar nav a:hover,.sidebar nav a.active{background:var(--surface-3);color:var(--text)}.sidebar nav a.active{color:#867eff}.sidebar svg{width:21px}.version{margin-top:auto;border-top:1px solid var(--border);padding:24px 8px 0;color:var(--muted);font-size:13px}.version i{display:inline-block;width:8px;height:8px;border-radius:50%;background:#26c795;margin-right:8px}main{margin-left:232px;min-height:100vh;background:radial-gradient(circle at 100% 0,rgba(91,86,192,.12),transparent 34%),var(--bg)}.page{padding:34px 42px 60px;max-width:1540px;margin:auto}.page-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:30px}.page h1{font-size:30px;margin:0 0 6px}.page-head p,.project-head p{margin:0;color:var(--muted)}.actions{display:flex;align-items:center;gap:12px}.btn{height:40px;border:1px solid var(--border);border-radius:7px;padding:0 16px;display:inline-flex;align-items:center;gap:9px;color:var(--text);background:var(--surface-2);cursor:pointer;transition:border-color .2s,background .2s}.btn svg{width:17px}.btn:hover{border-color:var(--primary)}.btn.primary{background:var(--primary);border-color:var(--primary);color:white;box-shadow:0 8px 18px rgba(115,103,245,.25)}.btn.danger{background:#ef4444;border-color:#ef4444;color:white}.stats-grid{display:grid;gap:16px;margin-bottom:30px}.stats-grid.three{grid-template-columns:repeat(3,1fr)}.stats-grid.four{grid-template-columns:repeat(4,1fr)}.stats-grid.five{grid-template-columns:repeat(5,1fr)}.stat-card{height:124px;border:1px solid var(--border);background:var(--surface);border-radius:8px;padding:23px 24px;display:flex;align-items:center;gap:16px}.stat-icon{width:48px;height:48px;border-radius:8px;display:grid;place-items:center;background:rgba(115,103,245,.18);color:#8178ff}.stat-icon.green{background:rgba(67,201,150,.16);color:var(--green)}.stat-icon.blue{background:rgba(79,157,245,.16);color:var(--blue)}.stat-icon.red{background:rgba(240,94,104,.16);color:var(--red)}.stat-icon svg{width:22px}.stat-card strong{font-size:29px;display:block}.stat-card small{display:block;color:var(--muted);margin-top:5px}.section-head{display:flex;align-items:center;justify-content:space-between}.section-head h2,.panel h2{font-size:18px;margin:0}.search{height:38px;width:280px;border:1px solid var(--border);background:var(--surface-2);border-radius:7px;display:flex;align-items:center;padding:0 13px;color:var(--muted)}.search svg{width:17px}.search input{border:0;outline:0;background:transparent;color:var(--text);width:100%;padding-left:9px}.project-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:20px;margin-top:20px}.project-card{min-height:280px;border:1px solid var(--border);background:var(--surface);border-radius:8px;padding:24px;cursor:pointer;transition:border-color .2s,background .2s}.project-card:hover{border-color:#555f77;background:var(--surface-2)}.project-title{display:flex;justify-content:space-between}.project-title h3{margin:0 0 5px}.project-title p{color:var(--muted);font-size:12px;margin:0;white-space:nowrap;text-overflow:ellipsis;overflow:hidden;max-width:280px}.icon-actions{display:flex}.icon-actions button,.toast button{border:0;background:transparent;color:var(--muted);cursor:pointer;padding:5px}.icon-actions svg{width:16px}.metric-row{display:grid;grid-template-columns:repeat(3,1fr);margin:27px 0 17px}.metric-row b{display:block;font-size:25px}.metric-row span{font-size:12px;color:var(--muted)}.language-bar{height:6px;border-radius:4px;overflow:hidden;display:flex;background:var(--surface-3)}.legend{display:flex;gap:14px;flex-wrap:wrap;margin-top:15px;color:var(--muted);font-size:11px;min-height:34px}.legend i{width:9px;height:9px;border-radius:50%;display:inline-block;margin-right:5px}.project-card footer{border-top:1px solid var(--border);margin-top:13px;padding-top:13px;display:flex;gap:14px;color:var(--muted);font-size:12px}.positive{color:var(--green)!important}.negative{color:var(--red)!important}.add-card{min-height:280px;border:1px dashed #3a4558;background:transparent;border-radius:8px;color:var(--muted);display:grid;place-content:center;gap:15px;cursor:pointer}.add-card span{width:48px;height:48px;background:var(--surface-2);display:grid;place-items:center;border-radius:50%;margin:auto}.overlay{position:fixed;inset:0;background:rgba(0,0,0,.66);backdrop-filter:blur(5px);z-index:30;display:grid;place-items:center}.modal{width:500px;background:var(--surface);border:1px solid var(--border);border-radius:8px}.modal header,.modal footer{padding:20px 24px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between}.modal footer{border:0;border-top:1px solid var(--border);justify-content:flex-end}.modal header h2{margin:0}.modal header button{border:0;background:transparent;color:var(--muted);font-size:25px;cursor:pointer}.modal>label{display:block;margin:20px 24px 0;font-size:13px}.modal input,.modal textarea,.rule-add input,.rule-add select,.form-panel select{width:100%;background:var(--surface-2);border:1px solid var(--border);border-radius:7px;color:var(--text);padding:10px 12px;margin-top:7px;outline:none}.modal textarea{height:80px;resize:vertical}.browse{display:flex;gap:8px}.browse input{margin-top:7px}.browse .btn{margin-top:7px;flex:none}.form-error{color:var(--red);padding:0 24px}.project-head{display:flex;align-items:center;gap:30px;background:rgba(8,12,19,.65);padding:24px;margin:-10px 0 12px}.project-head h1{font-size:25px}.back{border:0;background:transparent;color:var(--text);display:flex;align-items:center;gap:7px;cursor:pointer}.back svg{width:18px}.tabs{display:flex;gap:3px;background:var(--surface);width:max-content;padding:4px;border-radius:8px;margin:10px 0 25px}.tabs button{height:37px;padding:0 14px;border:0;background:transparent;color:var(--muted);border-radius:7px;display:flex;align-items:center;gap:8px;cursor:pointer}.tabs svg{width:18px}.tabs button.active{background:var(--primary);color:white}.split{display:grid;grid-template-columns:1fr 1fr;gap:22px;margin-bottom:22px}.panel{border:1px solid var(--border);background:var(--surface);border-radius:8px;padding:24px;margin-bottom:22px}.panel>.chart{height:310px}.language-list{height:310px;overflow:auto;margin-top:14px}.language-list>div{display:grid;grid-template-columns:1fr auto auto;gap:20px;background:var(--surface-2);padding:13px;margin-bottom:8px;border-radius:7px}.language-list i{display:inline-block;width:10px;height:10px;border-radius:50%;margin-right:8px}.language-list span{color:var(--muted)}.center-action{text-align:center}.heat-panel{height:220px}.heatmap{display:grid;grid-template-rows:repeat(7,12px);grid-auto-flow:column;grid-auto-columns:12px;gap:4px;overflow:hidden;margin-top:25px}.heatmap i{background:#2b3441;border-radius:2px}.heatmap i.l1{background:#28594b}.heatmap i.l2{background:#318266}.heatmap i.l3{background:#3cab7d}.heatmap i.l4{background:#4dd69b}.branch{height:76px;background:var(--surface-2);border-radius:7px;margin-top:10px;padding:12px 16px;display:grid;grid-template-columns:1fr auto;align-items:center}.branch span{display:flex;gap:9px;align-items:center;font-weight:bold}.branch svg{width:17px}.branch .current{color:var(--green)}.branch small{grid-column:1;color:var(--muted)}.branch code{grid-row:1/3;grid-column:2}.commit{min-height:76px;background:var(--surface-2);border-radius:7px;margin-top:10px;padding:12px;display:grid;grid-template-columns:42px 1fr auto auto;gap:9px;align-items:center}.avatar{width:36px;height:36px;border-radius:8px;background:var(--primary);color:#fff;display:grid;place-items:center;font-weight:bold}.commit div{min-width:0}.commit div b,.commit small{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.commit small,.contributor small{color:var(--muted);margin-top:5px}.trend .chart{height:250px}.segments{display:flex;background:var(--surface-2);padding:3px;border-radius:7px}.segments button{border:0;background:transparent;color:var(--muted);height:31px;padding:0 13px;border-radius:5px;cursor:pointer}.segments button.active{background:var(--primary);color:white}.contributor{height:65px;background:var(--surface-2);display:grid;grid-template-columns:35px 45px 1fr auto auto auto;gap:12px;align-items:center;padding:0 15px;margin-top:8px;border-radius:7px}.contributor div strong,.contributor div small{display:block}.file-tree{height:620px;overflow:auto;margin-top:18px}.file-tree>div{height:32px;display:flex;align-items:center;gap:7px}.file-tree svg{width:15px;color:var(--yellow)}.file-tree small{margin-left:auto;color:var(--muted)}.folder-size{display:grid;grid-template-columns:1fr auto;gap:8px 20px;background:var(--surface-2);padding:14px;margin-top:10px;border-radius:7px}.folder-size span{color:var(--muted);font-size:12px}.folder-size i{height:5px;background:#3b4353;border-radius:3px}.folder-size em{display:block;height:100%;background:var(--primary);border-radius:3px}.folder-size strong{font-size:13px}.large-file{display:flex;justify-content:space-between;background:var(--surface-2);padding:14px;margin-top:10px}.large-file b,.large-file small{display:block}.large-file small{color:var(--muted);margin-top:5px}.large-file>strong{color:var(--red)}.extension-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;margin-top:15px}.extension-grid>div{background:var(--surface-2);padding:16px;display:flex;gap:10px;justify-content:space-between}.extension-grid code{color:#8178ff}.extension-grid span{color:var(--muted)}.empty{height:100%;min-height:130px;display:grid;place-content:center;text-align:center;color:var(--muted);gap:10px}.empty svg{margin:auto;width:38px}.taskbar{position:fixed;bottom:18px;left:50%;transform:translateX(-50%);z-index:25;width:460px;background:var(--surface-3);border:1px solid var(--border);box-shadow:0 12px 30px rgba(0,0,0,.3);padding:12px 15px;border-radius:8px;display:grid;grid-template-columns:1fr 60px;gap:7px}.taskbar div:first-child{display:flex;gap:10px}.taskbar span{color:var(--muted)}.taskbar .progress{grid-column:1/3;height:4px;background:#3b4353}.taskbar .progress i{display:block;height:100%;background:var(--primary)}.toast{position:fixed;right:24px;top:22px;z-index:40;background:var(--surface-3);border:1px solid var(--border);padding:13px 15px;border-radius:7px}.toast.success{border-color:var(--green)}.toast.error{border-color:var(--red)}.toggle{display:flex;align-items:center;gap:7px;color:var(--primary)}.log-panel{min-height:400px}.log{min-height:96px;border-left:3px solid var(--blue);background:var(--surface-2);padding:15px 16px;margin-bottom:8px;display:grid;grid-template-columns:34px 1fr auto;gap:14px}.log.warning{border-color:var(--yellow)}.log.error{border-color:var(--red)}.log>span{width:30px;height:30px;background:rgba(79,157,245,.13);display:grid;place-items:center;border-radius:6px;color:var(--blue)}.log svg{width:15px}.log small,.log b,.log code{display:block}.log small{color:var(--muted)}.log code{background:rgba(0,0,0,.22);padding:8px;margin-top:7px}.log time{color:var(--muted);font-size:12px}.settings-page{max-width:1000px}.settings-tabs{width:100%;display:grid;grid-auto-flow:column;grid-auto-columns:1fr}.settings-tabs button{justify-content:center}.rule-add h2,.form-panel h2{display:flex;gap:10px;align-items:center}.rule-add h2 svg,.form-panel h2 svg{width:20px;color:var(--primary)}.rule-add>div{display:grid;grid-template-columns:1fr 240px auto;gap:15px;margin-top:20px}.rule-add input,.rule-add select{margin:0}.rule-add>small{color:var(--muted);display:block;margin-top:12px}.rule-group h2 small{color:var(--muted);font-weight:normal}.rule-group>div{display:flex;flex-wrap:wrap;gap:8px;border-top:1px solid var(--border);padding-top:15px;margin-top:15px}.rule-group button{border:0;background:var(--surface-3);color:var(--text);border-radius:7px;padding:8px 10px;display:flex;align-items:center;gap:7px;cursor:pointer}.rule-group button.builtin{cursor:default}.rule-group button small{color:#8178ff}.rule-group svg{width:13px}.form-panel label{display:grid;grid-template-columns:180px 1fr;align-items:center;margin-top:18px}.db-path{background:var(--surface-2);padding:18px;margin:20px 0}.db-path code{color:#8178ff}.migrate{height:78px}.danger-zone h2{color:var(--red);display:flex;gap:9px}.danger-zone h2 svg{width:20px}.danger-zone p{color:var(--muted)}.danger-zone>div{display:grid;grid-template-columns:repeat(3,1fr);gap:15px}.danger-zone button{border:1px solid var(--border);background:var(--surface-2);color:var(--text);padding:20px;text-align:left;display:flex;gap:14px;align-items:center;border-radius:8px;cursor:pointer}.danger-zone button.danger{border-color:#713947}.danger-zone svg{width:25px}.danger-zone b,.danger-zone small{display:block}.danger-zone small{color:var(--muted);margin-top:5px}@media(max-width:1150px){.sidebar{width:72px}.brand b,.sidebar nav span{display:none}.brand{padding:0}.sidebar nav a{justify-content:center;padding:0}.version{font-size:0}.version i{margin:0}main{margin-left:72px}.project-grid{grid-template-columns:repeat(2,1fr)}.stats-grid.five{grid-template-columns:repeat(3,1fr)}.split{grid-template-columns:1fr}.extension-grid{grid-template-columns:repeat(3,1fr)}}@media(prefers-reduced-motion:reduce){*{transition:none!important;scroll-behavior:auto!important}}
+@font-face{font-family:Nunito;src:url('./assets/fonts/nunito-v16-latin-regular.woff2')}*{box-sizing:border-box}html{--bg:#0e141d;--side:#151b24;--surface:#19212b;--surface-2:#202838;--surface-3:#252e40;--border:#303949;--text:#f4f5f8;--muted:#929bac;--primary:#7367f5;--green:#43c996;--blue:#4f9df5;--red:#f05e68;--yellow:#e7bd35;background:var(--bg);color:var(--text);font-family:Nunito,"Segoe UI",sans-serif;letter-spacing:0}html[data-theme=light]{--bg:#f4f6fa;--side:#fff;--surface:#fff;--surface-2:#f6f7fa;--surface-3:#edf0f6;--border:#dce1ea;--text:#111827;--muted:#526071;--primary:#6557e8}body{margin:0;min-width:960px}button,input,textarea,select{font:inherit;letter-spacing:0}.shell{min-height:100vh}.sidebar{position:fixed;inset:0 auto 0 0;width:232px;background:var(--side);border-right:1px solid var(--border);padding:24px 16px;display:flex;flex-direction:column;z-index:10}.brand{height:52px;display:flex;align-items:center;gap:14px;font-size:18px;padding:0 8px}.brand-mark{display:grid;place-items:center;width:40px;height:40px;background:var(--primary);border-radius:8px;color:white}.brand-mark svg{width:23px}.sidebar nav{display:grid;gap:8px;margin-top:35px}.sidebar nav a{height:54px;display:flex;align-items:center;gap:16px;padding:0 18px;color:var(--muted);text-decoration:none;border-radius:7px;transition:background .2s,color .2s}.sidebar nav a:hover,.sidebar nav a.active{background:var(--surface-3);color:var(--text)}.sidebar nav a.active{color:#867eff}.sidebar svg{width:21px}.version{margin-top:auto;border-top:1px solid var(--border);padding:24px 8px 0;color:var(--muted);font-size:13px}.version i{display:inline-block;width:8px;height:8px;border-radius:50%;background:#26c795;margin-right:8px}main{margin-left:232px;min-height:100vh;background:radial-gradient(circle at 100% 0,rgba(91,86,192,.12),transparent 34%),var(--bg)}.page{padding:34px 42px 60px;max-width:1540px;margin:auto}.page-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:30px}.page h1{font-size:30px;margin:0 0 6px}.page-head p,.project-head p{margin:0;color:var(--muted)}.actions{display:flex;align-items:center;gap:12px}.btn{height:40px;border:1px solid var(--border);border-radius:7px;padding:0 16px;display:inline-flex;align-items:center;justify-content:center;gap:9px;color:var(--text);background:var(--surface-2);cursor:pointer;transition:border-color .2s,background .2s;white-space:nowrap;flex-shrink:0;line-height:1}.btn svg{width:17px}.btn:hover{border-color:var(--primary)}.btn.primary{background:var(--primary);border-color:var(--primary);color:white;box-shadow:0 8px 18px rgba(115,103,245,.25)}.btn.danger{background:#ef4444;border-color:#ef4444;color:white}.stats-grid{display:grid;gap:16px;margin-bottom:30px}.stats-grid.three{grid-template-columns:repeat(3,1fr)}.stats-grid.four{grid-template-columns:repeat(4,1fr)}.stats-grid.five{grid-template-columns:repeat(5,1fr)}.stat-card{height:124px;border:1px solid var(--border);background:var(--surface);border-radius:8px;padding:23px 24px;display:flex;align-items:center;gap:16px}.stat-icon{width:48px;height:48px;border-radius:8px;display:grid;place-items:center;background:rgba(115,103,245,.18);color:#8178ff}.stat-icon.green{background:rgba(67,201,150,.16);color:var(--green)}.stat-icon.blue{background:rgba(79,157,245,.16);color:var(--blue)}.stat-icon.red{background:rgba(240,94,104,.16);color:var(--red)}.stat-icon svg{width:22px}.stat-card strong{font-size:29px;display:block}.stat-card small{display:block;color:var(--muted);margin-top:5px}.section-head{display:flex;align-items:center;justify-content:space-between}.section-head h2,.panel h2{font-size:18px;margin:0}.search{height:38px;width:280px;border:1px solid var(--border);background:var(--surface-2);border-radius:7px;display:flex;align-items:center;padding:0 13px;color:var(--muted)}.search svg{width:17px}.search input{border:0;outline:0;background:transparent;color:var(--text);width:100%;padding-left:9px}.project-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:20px;margin-top:20px}.project-card{min-height:280px;border:1px solid var(--border);background:var(--surface);border-radius:8px;padding:24px;cursor:pointer;transition:border-color .2s,background .2s}.project-card:hover{border-color:#555f77;background:var(--surface-2)}.project-title{display:flex;justify-content:space-between}.project-title h3{margin:0 0 5px}.project-title p{color:var(--muted);font-size:12px;margin:0;white-space:nowrap;text-overflow:ellipsis;overflow:hidden;max-width:280px}.icon-actions{display:flex}.icon-actions button,.toast button{border:0;background:transparent;color:var(--muted);cursor:pointer;padding:5px}.icon-actions svg{width:16px}.metric-row{display:grid;grid-template-columns:repeat(3,1fr);margin:27px 0 17px}.metric-row b{display:block;font-size:25px}.metric-row span{font-size:12px;color:var(--muted)}.language-bar{height:6px;border-radius:4px;overflow:hidden;display:flex;background:var(--surface-3)}.legend{display:flex;gap:14px;flex-wrap:wrap;margin-top:15px;color:var(--muted);font-size:11px;min-height:34px}.legend i{width:9px;height:9px;border-radius:50%;display:inline-block;margin-right:5px}.project-card footer{border-top:1px solid var(--border);margin-top:13px;padding-top:13px;display:flex;gap:14px;color:var(--muted);font-size:12px}.positive{color:var(--green)!important}.negative{color:var(--red)!important}.add-card{min-height:280px;border:1px dashed #3a4558;background:transparent;border-radius:8px;color:var(--muted);display:grid;place-content:center;gap:15px;cursor:pointer}.add-card span{width:48px;height:48px;background:var(--surface-2);display:grid;place-items:center;border-radius:50%;margin:auto}.overlay{position:fixed;inset:0;background:rgba(0,0,0,.66);backdrop-filter:blur(5px);z-index:30;display:grid;place-items:center}.modal{width:500px;background:var(--surface);border:1px solid var(--border);border-radius:8px}.modal header,.modal footer{padding:20px 24px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between}.modal footer{border:0;border-top:1px solid var(--border);justify-content:flex-end}.modal header h2{margin:0}.modal header button{border:0;background:transparent;color:var(--muted);font-size:25px;cursor:pointer}.modal>label{display:block;margin:20px 24px 0;font-size:13px}.modal input,.modal textarea,.rule-add input,.rule-add select,.form-panel select{width:100%;background:var(--surface-2);border:1px solid var(--border);border-radius:7px;color:var(--text);padding:10px 12px;margin-top:7px;outline:none}.modal textarea{height:80px;resize:vertical}.browse{display:flex;gap:8px}.browse input{margin-top:7px}.browse .btn{margin-top:7px;flex:none}.form-error{color:var(--red);padding:0 24px}.project-head{display:flex;align-items:center;gap:30px;background:rgba(8,12,19,.65);padding:24px;margin:-10px 0 12px}.project-head h1{font-size:25px}.back{border:0;background:transparent;color:var(--text);display:flex;align-items:center;gap:7px;cursor:pointer}.back svg{width:18px}.tabs{display:flex;gap:3px;background:var(--surface);width:max-content;padding:4px;border-radius:8px;margin:10px 0 25px}.tabs button{height:37px;padding:0 14px;border:0;background:transparent;color:var(--muted);border-radius:7px;display:flex;align-items:center;gap:8px;cursor:pointer}.tabs svg{width:18px}.tabs button.active{background:var(--primary);color:white}.split{display:grid;grid-template-columns:1fr 1fr;gap:22px;margin-bottom:22px}.panel{border:1px solid var(--border);background:var(--surface);border-radius:8px;padding:24px;margin-bottom:22px}.panel>.chart{height:310px}.language-list{height:310px;overflow:auto;margin-top:14px}.language-list>div{display:grid;grid-template-columns:1fr auto auto;gap:20px;background:var(--surface-2);padding:13px;margin-bottom:8px;border-radius:7px}.language-list i{display:inline-block;width:10px;height:10px;border-radius:50%;margin-right:8px}.language-list span{color:var(--muted)}.center-action{text-align:center}.heat-panel{height:220px}.heatmap{display:grid;grid-template-rows:repeat(7,12px);grid-auto-flow:column;grid-auto-columns:12px;gap:4px;overflow:hidden;margin-top:25px}.heatmap i{background:#2b3441;border-radius:2px}.heatmap i.l1{background:#28594b}.heatmap i.l2{background:#318266}.heatmap i.l3{background:#3cab7d}.heatmap i.l4{background:#4dd69b}.branch{height:76px;background:var(--surface-2);border-radius:7px;margin-top:10px;padding:12px 16px;display:grid;grid-template-columns:1fr auto;align-items:center}.branch span{display:flex;gap:9px;align-items:center;font-weight:bold}.branch svg{width:17px}.branch .current{color:var(--green)}.branch small{grid-column:1;color:var(--muted)}.branch code{grid-row:1/3;grid-column:2}.commit{min-height:76px;background:var(--surface-2);border-radius:7px;margin-top:10px;padding:12px;display:grid;grid-template-columns:42px 1fr auto auto;gap:9px;align-items:center}.avatar{width:36px;height:36px;border-radius:8px;background:var(--primary);color:#fff;display:grid;place-items:center;font-weight:bold}.commit div{min-width:0}.commit div b,.commit small{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.commit small,.contributor small{color:var(--muted);margin-top:5px}.trend .chart{height:250px}.segments{display:flex;background:var(--surface-2);padding:3px;border-radius:7px}.segments button{border:0;background:transparent;color:var(--muted);height:31px;padding:0 13px;border-radius:5px;cursor:pointer}.segments button.active{background:var(--primary);color:white}.contributor{height:65px;background:var(--surface-2);display:grid;grid-template-columns:35px 45px 1fr auto auto auto;gap:12px;align-items:center;padding:0 15px;margin-top:8px;border-radius:7px}.contributor div strong,.contributor div small{display:block}.file-tree{height:620px;overflow:auto;margin-top:18px}.file-tree>div{height:32px;display:flex;align-items:center;gap:7px}.file-tree svg{width:15px;color:var(--yellow)}.file-tree small{margin-left:auto;color:var(--muted)}.folder-size{display:grid;grid-template-columns:1fr auto;gap:8px 20px;background:var(--surface-2);padding:14px;margin-top:10px;border-radius:7px}.folder-size span{color:var(--muted);font-size:12px}.folder-size i{height:5px;background:#3b4353;border-radius:3px}.folder-size em{display:block;height:100%;background:var(--primary);border-radius:3px}.folder-size strong{font-size:13px}.large-file{display:flex;justify-content:space-between;background:var(--surface-2);padding:14px;margin-top:10px}.large-file b,.large-file small{display:block}.large-file small{color:var(--muted);margin-top:5px}.large-file>strong{color:var(--red)}.extension-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;margin-top:15px}.extension-grid>div{background:var(--surface-2);padding:16px;display:flex;gap:10px;justify-content:space-between}.extension-grid code{color:#8178ff}.extension-grid span{color:var(--muted)}.empty{height:100%;min-height:130px;display:grid;place-content:center;text-align:center;color:var(--muted);gap:10px}.empty svg{margin:auto;width:38px}.taskbar{position:fixed;bottom:18px;left:50%;transform:translateX(-50%);z-index:25;width:460px;background:var(--surface-3);border:1px solid var(--border);box-shadow:0 12px 30px rgba(0,0,0,.3);padding:12px 15px;border-radius:8px;display:grid;grid-template-columns:1fr 60px;gap:7px}.taskbar div:first-child{display:flex;gap:10px}.taskbar span{color:var(--muted)}.taskbar .progress{grid-column:1/3;height:4px;background:#3b4353}.taskbar .progress i{display:block;height:100%;background:var(--primary)}.toast{position:fixed;right:24px;top:22px;z-index:40;background:var(--surface-3);border:1px solid var(--border);padding:13px 15px;border-radius:7px}.toast.success{border-color:var(--green)}.toast.error{border-color:var(--red)}.toggle{display:flex;align-items:center;gap:7px;color:var(--primary)}.log-panel{min-height:400px}.log{min-height:96px;border-left:3px solid var(--blue);background:var(--surface-2);padding:15px 16px;margin-bottom:8px;display:grid;grid-template-columns:34px 1fr auto;gap:14px}.log.warning{border-color:var(--yellow)}.log.error{border-color:var(--red)}.log>span{width:30px;height:30px;background:rgba(79,157,245,.13);display:grid;place-items:center;border-radius:6px;color:var(--blue)}.log svg{width:15px}.log small,.log b,.log code{display:block}.log small{color:var(--muted)}.log code{background:rgba(0,0,0,.22);padding:8px;margin-top:7px}.log time{color:var(--muted);font-size:12px}.settings-page{max-width:1000px}.settings-tabs{width:100%;display:grid;grid-auto-flow:column;grid-auto-columns:1fr}.settings-tabs button{justify-content:center}.rule-add h2,.form-panel h2{display:flex;gap:10px;align-items:center}.rule-add h2 svg,.form-panel h2 svg{width:20px;color:var(--primary)}.rule-add>div{display:grid;grid-template-columns:1fr 240px auto;gap:15px;margin-top:20px}.rule-add input,.rule-add select{margin:0}.rule-add>small{color:var(--muted);display:block;margin-top:12px}.rule-group h2 small{color:var(--muted);font-weight:normal}.rule-group>div{display:flex;flex-wrap:wrap;gap:8px;border-top:1px solid var(--border);padding-top:15px;margin-top:15px}.rule-group button{border:0;background:var(--surface-3);color:var(--text);border-radius:7px;padding:8px 10px;display:flex;align-items:center;gap:7px;cursor:pointer}.rule-group button.builtin{cursor:default}.rule-group button small{color:#8178ff}.rule-group svg{width:13px}.form-panel label{display:grid;grid-template-columns:180px 1fr;align-items:center;margin-top:18px}.db-path{background:var(--surface-2);padding:18px;margin:20px 0}.db-path code{color:#8178ff}.migrate{height:78px}.danger-zone h2{color:var(--red);display:flex;gap:9px}.danger-zone h2 svg{width:20px}.danger-zone p{color:var(--muted)}.danger-zone>div{display:grid;grid-template-columns:repeat(3,1fr);gap:15px}.danger-zone button{border:1px solid var(--border);background:var(--surface-2);color:var(--text);padding:20px;text-align:left;display:flex;gap:14px;align-items:center;border-radius:8px;cursor:pointer}.danger-zone button.danger{border-color:#713947}.danger-zone svg{width:25px}.danger-zone b,.danger-zone small{display:block}.danger-zone small{color:var(--muted);margin-top:5px}@media(max-width:1150px){.sidebar{width:72px}.brand b,.sidebar nav span{display:none}.brand{padding:0}.sidebar nav a{justify-content:center;padding:0}.version{font-size:0}.version i{margin:0}main{margin-left:72px}.project-grid{grid-template-columns:repeat(2,1fr)}.stats-grid.five{grid-template-columns:repeat(3,1fr)}.split{grid-template-columns:1fr}.extension-grid{grid-template-columns:repeat(3,1fr)}}@media(prefers-reduced-motion:reduce){*{transition:none!important;scroll-behavior:auto!important}}
diff --git a/frontend/src/views/Admin.vue b/frontend/src/views/Admin.vue
index 6edd16c..305e999 100644
--- a/frontend/src/views/Admin.vue
+++ b/frontend/src/views/Admin.vue
@@ -160,6 +160,7 @@ const tokenChart = computed(() => {
series: [
{ name: 'Prompt', type: 'bar', stack: 'tok', barMaxWidth: 16, itemStyle: { color: '#7b73ff', borderRadius: [0, 0, 0, 0] }, data: s.map(p => p.promptTokens || 0) },
{ name: 'Completion', type: 'bar', stack: 'tok', barMaxWidth: 16, itemStyle: { color: '#4fd1a1', borderRadius: [3, 3, 0, 0] }, data: s.map(p => p.completionTokens || 0) },
+ { name: t('adminCached'), type: 'line', smooth: true, showSymbol: false, lineStyle: { width: 2, color: '#23b5d3' }, itemStyle: { color: '#23b5d3' }, data: s.map(p => p.cachedTokens || 0) },
{ name: t('adminCalls'), type: 'line', yAxisIndex: 1, smooth: true, showSymbol: false, lineStyle: { width: 2, color: '#f4c84a' }, itemStyle: { color: '#f4c84a' }, data: s.map(p => p.calls || 0) }
]
}
@@ -307,6 +308,7 @@ onUnmounted(() => { offFilesDropped?.() })
{{ t('adminStatTeams') }}{{ overview.teamCount }}
{{ t('adminStatDAU') }}{{ overview.dauToday }}
{{ t('adminStatTokens') }}{{ (overview.tokenToday?.promptTokens||0)+(overview.tokenToday?.completionTokens||0) }}
+ {{ t('adminStatCached') }}{{ overview.tokenToday?.cachedTokens||0 }}
@@ -501,7 +503,7 @@ onUnmounted(() => { offFilesDropped?.() })
.admin-tabs{display:flex;flex-wrap:wrap;gap:.4rem;margin-bottom:1rem}
.admin-tabs button{display:inline-flex;align-items:center;gap:.35rem;padding:.45rem .75rem;border-radius:8px;border:1px solid var(--border);background:transparent;color:inherit;cursor:pointer}
.admin-tabs button.on{background:var(--accent, #3b82f6);color:#fff;border-color:transparent}
-.stat-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:.75rem;margin-bottom:1.25rem}
+.stat-grid{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:.75rem;margin-bottom:1.25rem}
.stat{padding:1rem;border:1px solid var(--border);border-radius:10px;display:flex;flex-direction:column;gap:.35rem}
.stat b{font-size:1.6rem}
.chart-grid{display:grid;grid-template-columns:1fr 1fr;gap:.75rem}
diff --git a/frontend/src/views/Launchpad.vue b/frontend/src/views/Launchpad.vue
index c5215f1..531b2ca 100644
--- a/frontend/src/views/Launchpad.vue
+++ b/frontend/src/views/Launchpad.vue
@@ -3,13 +3,14 @@ import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { Browser } from '@wailsio/runtime'
-import { Plus, RefreshCw, Play, Square, Pencil, Trash2, X, Hexagon, Zap, FileCode2, Coffee, Database, Globe, Boxes, Box, Cpu, MemoryStick, HardDrive, Sparkles, Search, ExternalLink, FolderGit2, ScrollText, LoaderCircle, Image, ImageUp, Tags, Settings2, RotateCcw, ChevronRight, Package, Pin } from 'lucide-vue-next'
+import { Plus, RefreshCw, Play, Square, Pencil, Trash2, X, Hexagon, Zap, FileCode2, Coffee, Database, Globe, Boxes, Box, Cpu, MemoryStick, HardDrive, Sparkles, Search, ExternalLink, FolderGit2, ScrollText, LoaderCircle, Image, ImageUp, Tags, Settings2, RotateCcw, ChevronRight, Package, Pin, Network } from 'lucide-vue-next'
import { call, on } from '../api'
import { useAppStore } from '../store'
import AIScopeDrawer from '../components/AIScopeDrawer.vue'
import PackCmdsModal from '../components/PackCmdsModal.vue'
import LaunchAppFormModal from '../components/LaunchAppFormModal.vue'
import RefreshIntervalPicker from '../components/RefreshIntervalPicker.vue'
+import NetworkInfoModal from '../components/NetworkInfoModal.vue'
import { getPackCmds } from '../packCmds'
// 启动台:扫描本机监听端口的服务 + 管理保存的应用(启动/停止/资源占用)。
@@ -18,6 +19,7 @@ const store = useAppStore()
const route = useRoute()
const router = useRouter()
const aiOpen = ref(false)
+const netInfoOpen = ref(false)
const entries = ref([])
const loading = ref(false)
const nameQ = ref('')
@@ -510,6 +512,7 @@ watch(() => route.query.pin, async v => {
{{ t('launchpad') }}
{{ t('launchpadSubtitle') }}
+
@@ -705,6 +708,7 @@ watch(() => route.query.pin, async v => {
+
diff --git a/frontend/src/views/Workbench.vue b/frontend/src/views/Workbench.vue
index 5fb09fc..5faa383 100644
--- a/frontend/src/views/Workbench.vue
+++ b/frontend/src/views/Workbench.vue
@@ -2,10 +2,11 @@
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
-import { Star, Folder, Code2, GitCommitHorizontal, ListTodo, TicketCheck, StickyNote, ArrowRight, Circle, CircleDot, CircleCheck, Play, Check, CalendarDays, Plus, Bell, Save, Pencil, Eye } from 'lucide-vue-next'
+import { Star, Folder, Code2, GitCommitHorizontal, ListTodo, TicketCheck, StickyNote, ArrowRight, Circle, CircleDot, CircleCheck, Play, Check, CalendarDays, Plus, Bell, Save, Pencil, Eye, Maximize2 } from 'lucide-vue-next'
import StatCard from '../components/StatCard.vue'
import AIDayPanel from '../components/AIDayPanel.vue'
import MarkdownView from '../components/MarkdownView.vue'
+import NoteModal from '../components/NoteModal.vue'
import { call } from '../api'
import { useAppStore } from '../store'
@@ -20,8 +21,10 @@ const noteText = ref('')
const noteSavedAt = ref('')
const noteDirty = ref(false)
const noteSaving = ref(false)
-// 编辑/预览切换(预览走 Markdown 渲染),选择记忆在本地
+// 面板内编辑/预览;点击内容区打开模态框查看全文
const notePreview = ref(localStorage.getItem('cc-wb-note-preview') === '1')
+const noteModal = ref(false)
+const noteModalMode = ref('preview')
const messages = ref([])
let noteTimer
@@ -79,13 +82,31 @@ function editNote() {
clearTimeout(noteTimer)
noteTimer = setTimeout(() => { saveNote(false) }, 800)
}
-// 切到预览前先落盘,避免防抖窗口内的改动丢失
async function setNotePreview(on) {
if (notePreview.value === on) return
if (on && noteDirty.value) await saveNote(false)
notePreview.value = on
localStorage.setItem('cc-wb-note-preview', on ? '1' : '0')
}
+async function openNoteModal(mode = 'preview') {
+ if (noteDirty.value) await saveNote(false)
+ noteModalMode.value = mode
+ noteModal.value = true
+}
+async function onNoteModalChanged() {
+ await loadNotes()
+ if (note.value?.id) {
+ const fresh = notes.value.find(n => n.id === note.value.id)
+ if (fresh) {
+ note.value = fresh
+ noteText.value = fresh.content || ''
+ noteDirty.value = false
+ }
+ } else if (notes.value.length) {
+ note.value = notes.value[0]
+ noteText.value = note.value.content || ''
+ }
+}
async function saveNote(manual = true) {
if (noteSaving.value) return
noteSaving.value = true
@@ -206,17 +227,18 @@ onUnmounted(() => clearTimeout(noteTimer))
-
+
{{ t('notepad') }}{{ notes.length }}
{{ t('noteUnsaved') }}
{{ t('autoSaved') }} {{ noteSavedAt }}
-
-
+
+
+
-
+
@@ -235,8 +257,11 @@ onUnmounted(() => clearTimeout(noteTimer))
{{ t('noteEmpty') }}
-
-
+
+
+
+
+
@@ -254,4 +279,11 @@ onUnmounted(() => clearTimeout(noteTimer))
+
diff --git a/holidays.go b/holidays.go
new file mode 100644
index 0000000..7de34b7
--- /dev/null
+++ b/holidays.go
@@ -0,0 +1,261 @@
+package main
+
+// holidays.go 给 AI 任务拆解提供「本月 + 未来几个月」节假日公历对照。
+// 农历节日每年换算不同,法定放假以国务院已公布年份为准;未公布年份只给节日当天。
+
+import (
+ "fmt"
+ "sort"
+ "strings"
+ "time"
+)
+
+type holidayItem struct {
+ day time.Time
+ offStart time.Time // 放假开始;零值表示仅节日当天
+ offEnd time.Time // 放假结束(含)
+ work []time.Time
+ zh, en string
+ aka string
+}
+
+// upcomingHolidays 从本月 1 日到未来 months 个月月末(默认 6)的节日,按日期排序。
+func upcomingHolidays(now time.Time, months int) []holidayItem {
+ if months <= 0 {
+ months = 6
+ }
+ loc := now.Location()
+ start := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, loc)
+ end := start.AddDate(0, months+1, 0) // 不含:覆盖「未来几个月」整月
+ var out []holidayItem
+ for y := start.Year(); y <= end.Year(); y++ {
+ for _, h := range yearHolidays(y, loc) {
+ if !h.day.Before(start) && h.day.Before(end) {
+ out = append(out, h)
+ }
+ }
+ }
+ sort.Slice(out, func(i, j int) bool { return out[i].day.Before(out[j].day) })
+ return out
+}
+
+func formatHolidayBlock(now time.Time, en bool) string {
+ list := upcomingHolidays(now, 6)
+ if len(list) == 0 {
+ return ""
+ }
+ var b strings.Builder
+ if en {
+ b.WriteString("Holidays this month and the next 6 months (use these exact dates; do not guess):\n")
+ } else {
+ b.WriteString("本月及未来 6 个月节假日对照(必须用表中公历,禁止自己换算农历):\n")
+ }
+ for _, h := range list {
+ name := h.zh
+ if en {
+ name = h.en
+ if h.aka != "" {
+ name += " / " + h.aka
+ } else if h.zh != "" {
+ name += " / " + h.zh
+ }
+ } else if h.aka != "" {
+ name += " / " + h.aka
+ }
+ line := fmt.Sprintf("- %s:%s", name, h.day.Format("2006-01-02"))
+ if !h.offStart.IsZero() && !h.offEnd.IsZero() {
+ line += fmt.Sprintf("(放假 %s~%s", h.offStart.Format("2006-01-02"), h.offEnd.Format("2006-01-02"))
+ if len(h.work) > 0 {
+ ws := make([]string, len(h.work))
+ for i, w := range h.work {
+ ws[i] = w.Format("2006-01-02")
+ }
+ line += ";调休上班 " + strings.Join(ws, "、")
+ }
+ line += ")"
+ }
+ b.WriteString(line + "\n")
+ }
+ if en {
+ b.WriteString("Phrases: “before X” → day before the festival; “during X” → official holiday span if listed, else the festival day; “after X” → day after the holiday ends.\n")
+ } else {
+ b.WriteString("说法对照:「X前 / X之前」= 节日前一天;「X期间 / X放假」= 表中放假区间(没有区间就用当天);「X后 / X之后」= 假期结束后一天。国庆可说十一,春节可说过年,元旦可说新年(公历)。\n")
+ }
+ return b.String()
+}
+
+func yearHolidays(year int, loc *time.Location) []holidayItem {
+ d := func(m, day int) time.Time {
+ return time.Date(year, time.Month(m), day, 0, 0, 0, 0, loc)
+ }
+ items := []holidayItem{
+ {day: d(1, 1), zh: "元旦", en: "New Year's Day", aka: "新年"},
+ {day: d(2, 14), zh: "情人节", en: "Valentine's Day"},
+ {day: d(3, 8), zh: "妇女节", en: "Women's Day"},
+ {day: d(3, 12), zh: "植树节", en: "Arbor Day (CN)"},
+ {day: d(4, 1), zh: "愚人节", en: "April Fools' Day"},
+ {day: d(5, 1), zh: "劳动节", en: "Labor Day", aka: "五一"},
+ {day: d(5, 4), zh: "青年节", en: "Youth Day"},
+ {day: nthWeekday(year, time.May, time.Sunday, 2, loc), zh: "母亲节", en: "Mother's Day"},
+ {day: d(6, 1), zh: "儿童节", en: "Children's Day"},
+ {day: nthWeekday(year, time.June, time.Sunday, 3, loc), zh: "父亲节", en: "Father's Day"},
+ {day: d(7, 1), zh: "建党节", en: "CPC Founding Day"},
+ {day: d(8, 1), zh: "建军节", en: "PLA Day"},
+ {day: d(9, 10), zh: "教师节", en: "Teachers' Day"},
+ {day: d(10, 1), zh: "国庆节", en: "National Day", aka: "十一、国庆黄金周"},
+ {day: d(10, 24), zh: "程序员节", en: "Programmer's Day"},
+ {day: d(10, 31), zh: "万圣节", en: "Halloween"},
+ {day: nthWeekday(year, time.November, time.Thursday, 4, loc), zh: "感恩节", en: "Thanksgiving"},
+ {day: d(11, 11), zh: "双十一", en: "Singles' Day", aka: "光棍节"},
+ {day: d(12, 12), zh: "双十二", en: "Double 12"},
+ {day: d(12, 24), zh: "平安夜", en: "Christmas Eve"},
+ {day: d(12, 25), zh: "圣诞节", en: "Christmas"},
+ }
+ // 黑色星期五 = 感恩节次日
+ for i := range items {
+ if items[i].zh == "感恩节" {
+ bf := items[i].day.AddDate(0, 0, 1)
+ items = append(items, holidayItem{day: bf, zh: "黑色星期五", en: "Black Friday"})
+ break
+ }
+ }
+ items = append(items, lunarHolidays(year, loc)...)
+ items = append(items, solarTermHolidays(year, loc)...)
+ applyOfficialOff(year, loc, items)
+ return items
+}
+
+func nthWeekday(year int, month time.Month, wd time.Weekday, n int, loc *time.Location) time.Time {
+ d := time.Date(year, month, 1, 0, 0, 0, 0, loc)
+ delta := int(wd - d.Weekday())
+ if delta < 0 {
+ delta += 7
+ }
+ return d.AddDate(0, 0, delta+7*(n-1))
+}
+
+// lunarHolidays 农历节日的公历对照(2025–2029)。春节 day=正月初一。
+func lunarHolidays(year int, loc *time.Location) []holidayItem {
+ type row struct{ m, d int }
+ // key → 公历月日
+ table := map[int]map[string]row{
+ 2025: {
+ "除夕": {1, 28}, "春节": {1, 29}, "元宵": {2, 12}, "端午": {5, 31},
+ "七夕": {8, 29}, "中元": {9, 6}, "中秋": {10, 6}, "重阳": {10, 29},
+ },
+ 2026: {
+ "除夕": {2, 16}, "春节": {2, 17}, "元宵": {3, 3}, "端午": {6, 19},
+ "七夕": {8, 19}, "中元": {8, 27}, "中秋": {9, 25}, "重阳": {10, 18},
+ },
+ 2027: {
+ "除夕": {2, 5}, "春节": {2, 6}, "元宵": {2, 20}, "端午": {6, 9},
+ "七夕": {8, 8}, "中元": {8, 16}, "中秋": {9, 15}, "重阳": {10, 8},
+ },
+ 2028: {
+ "除夕": {1, 25}, "春节": {1, 26}, "元宵": {2, 9}, "端午": {5, 28},
+ "七夕": {8, 26}, "中元": {9, 3}, "中秋": {10, 3}, "重阳": {10, 26},
+ },
+ 2029: {
+ "除夕": {2, 12}, "春节": {2, 13}, "元宵": {2, 27}, "端午": {6, 16},
+ "七夕": {8, 16}, "中元": {8, 24}, "中秋": {9, 22}, "重阳": {10, 16},
+ },
+ }
+ names := map[string][2]string{
+ "除夕": {"除夕", "Lunar New Year's Eve"},
+ "春节": {"春节", "Spring Festival"},
+ "元宵": {"元宵节", "Lantern Festival"},
+ "端午": {"端午节", "Dragon Boat Festival"},
+ "七夕": {"七夕", "Qixi Festival"},
+ "中元": {"中元节", "Ghost Festival"},
+ "中秋": {"中秋节", "Mid-Autumn Festival"},
+ "重阳": {"重阳节", "Double Ninth Festival"},
+ }
+ aka := map[string]string{
+ "春节": "过年、农历新年、正月初一",
+ "端午": "端阳",
+ "中秋": "八月十五",
+ "除夕": "年三十",
+ }
+ m, ok := table[year]
+ if !ok {
+ return nil
+ }
+ var out []holidayItem
+ for k, r := range m {
+ n := names[k]
+ out = append(out, holidayItem{
+ day: time.Date(year, time.Month(r.m), r.d, 0, 0, 0, 0, loc),
+ zh: n[0], en: n[1], aka: aka[k],
+ })
+ }
+ return out
+}
+
+// solarTermHolidays 常用节气(用户会当日期说)。
+func solarTermHolidays(year int, loc *time.Location) []holidayItem {
+ type row struct {
+ m, d int
+ zh, en string
+ }
+ table := map[int][]row{
+ 2025: {{2, 3, "立春", "Start of Spring"}, {4, 4, "清明", "Qingming"}, {6, 21, "夏至", "Summer Solstice"}, {12, 21, "冬至", "Winter Solstice"}},
+ 2026: {{2, 4, "立春", "Start of Spring"}, {4, 5, "清明", "Qingming"}, {6, 21, "夏至", "Summer Solstice"}, {12, 22, "冬至", "Winter Solstice"}},
+ 2027: {{2, 4, "立春", "Start of Spring"}, {4, 5, "清明", "Qingming"}, {6, 21, "夏至", "Summer Solstice"}, {12, 22, "冬至", "Winter Solstice"}},
+ 2028: {{2, 4, "立春", "Start of Spring"}, {4, 4, "清明", "Qingming"}, {6, 21, "夏至", "Summer Solstice"}, {12, 21, "冬至", "Winter Solstice"}},
+ 2029: {{2, 3, "立春", "Start of Spring"}, {4, 4, "清明", "Qingming"}, {6, 21, "夏至", "Summer Solstice"}, {12, 22, "冬至", "Winter Solstice"}},
+ }
+ var out []holidayItem
+ for _, r := range table[year] {
+ aka := ""
+ if r.zh == "清明" {
+ aka = "清明节"
+ }
+ out = append(out, holidayItem{
+ day: time.Date(year, time.Month(r.m), r.d, 0, 0, 0, 0, loc),
+ zh: r.zh, en: r.en, aka: aka,
+ })
+ }
+ return out
+}
+
+type offSpan struct {
+ offStart [2]int // month, day(可能早于节日当天,如春节)
+ offEnd [2]int
+ work [][2]int
+}
+
+// applyOfficialOff 套上已公布的国务院放假调休(2026)。
+func applyOfficialOff(year int, loc *time.Location, items []holidayItem) {
+ spans, ok := officialOff[year]
+ if !ok {
+ return
+ }
+ md := func(p [2]int) time.Time {
+ return time.Date(year, time.Month(p[0]), p[1], 0, 0, 0, 0, loc)
+ }
+ for i := range items {
+ sp, hit := spans[items[i].zh]
+ if !hit {
+ continue
+ }
+ items[i].offStart = md(sp.offStart)
+ items[i].offEnd = md(sp.offEnd)
+ for _, w := range sp.work {
+ items[i].work = append(items[i].work, md(w))
+ }
+ }
+}
+
+// officialOff 国务院已公布年份。2026:国办发〔2025〕通知。
+var officialOff = map[int]map[string]offSpan{
+ 2026: {
+ "元旦": {offStart: [2]int{1, 1}, offEnd: [2]int{1, 3}, work: [][2]int{{1, 4}}},
+ "春节": {offStart: [2]int{2, 15}, offEnd: [2]int{2, 23}, work: [][2]int{{2, 14}, {2, 28}}},
+ "清明": {offStart: [2]int{4, 4}, offEnd: [2]int{4, 6}},
+ "清明节": {offStart: [2]int{4, 4}, offEnd: [2]int{4, 6}},
+ "劳动节": {offStart: [2]int{5, 1}, offEnd: [2]int{5, 5}, work: [][2]int{{5, 9}}},
+ "端午节": {offStart: [2]int{6, 19}, offEnd: [2]int{6, 21}},
+ "中秋节": {offStart: [2]int{9, 25}, offEnd: [2]int{9, 27}},
+ "国庆节": {offStart: [2]int{10, 1}, offEnd: [2]int{10, 7}, work: [][2]int{{9, 20}, {10, 10}}},
+ },
+}
diff --git a/holidays_test.go b/holidays_test.go
new file mode 100644
index 0000000..4b6c08a
--- /dev/null
+++ b/holidays_test.go
@@ -0,0 +1,62 @@
+package main
+
+import (
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestUpcomingHolidaysNationalDay2026(t *testing.T) {
+ now := time.Date(2026, 8, 17, 12, 0, 0, 0, time.Local)
+ list := upcomingHolidays(now, 6)
+ var nd *holidayItem
+ for i := range list {
+ if list[i].zh == "国庆节" {
+ nd = &list[i]
+ break
+ }
+ }
+ if nd == nil {
+ t.Fatal("2026-08 起 6 个月内应包含国庆节")
+ }
+ if nd.day.Format("2006-01-02") != "2026-10-01" {
+ t.Fatalf("国庆正日 got %s", nd.day.Format("2006-01-02"))
+ }
+ if nd.offStart.Format("2006-01-02") != "2026-10-01" || nd.offEnd.Format("2006-01-02") != "2026-10-07" {
+ t.Fatalf("国庆放假区间 got %s~%s", nd.offStart.Format("2006-01-02"), nd.offEnd.Format("2006-01-02"))
+ }
+}
+
+func TestUpcomingHolidaysMidAutumnQixi2026(t *testing.T) {
+ now := time.Date(2026, 8, 17, 12, 0, 0, 0, time.Local)
+ got := map[string]string{}
+ for _, h := range upcomingHolidays(now, 6) {
+ got[h.zh] = h.day.Format("2006-01-02")
+ }
+ want := map[string]string{
+ "七夕": "2026-08-19",
+ "中秋节": "2026-09-25",
+ "重阳节": "2026-10-18",
+ "元旦": "2027-01-01",
+ "春节": "2027-02-06",
+ }
+ for name, day := range want {
+ if got[name] != day {
+ t.Errorf("%s: got %q want %s", name, got[name], day)
+ }
+ }
+}
+
+func TestFormatHolidayBlockUsesTableDates(t *testing.T) {
+ now := time.Date(2026, 8, 17, 12, 0, 0, 0, time.Local)
+ zh := formatHolidayBlock(now, false)
+ for _, s := range []string{"国庆节", "2026-10-01", "2026-10-07", "中秋节", "2026-09-25", "七夕", "2026-08-19"} {
+ if !strings.Contains(zh, s) {
+ t.Errorf("中文节日块缺少 %q\n%s", s, zh)
+ }
+ }
+ en := formatHolidayBlock(now, true)
+ if !strings.Contains(en, "National Day") || !strings.Contains(en, "2026-10-01") {
+ t.Errorf("英文节日块缺少国庆\n%s", en)
+ }
+}
diff --git a/network_info.go b/network_info.go
new file mode 100644
index 0000000..7e364fe
--- /dev/null
+++ b/network_info.go
@@ -0,0 +1,276 @@
+package main
+
+// network_info.go:采集本机网卡信息(Windows 跑 ipconfig /all 并结构化解析,其它平台用 Go net)。
+// 自动识别主局域网 IPv4,供启动台一键复制。
+
+import (
+ "net"
+ "os"
+ "os/exec"
+ "runtime"
+ "strings"
+)
+
+// NetAdapter 单块网卡的摘要信息。
+type NetAdapter struct {
+ Name string `json:"name"`
+ Description string `json:"description,omitempty"`
+ MAC string `json:"mac,omitempty"`
+ IPv4 []string `json:"ipv4,omitempty"`
+ IPv6 []string `json:"ipv6,omitempty"`
+ Gateway []string `json:"gateway,omitempty"`
+ DNS []string `json:"dns,omitempty"`
+ DHCP bool `json:"dhcp"`
+ Primary bool `json:"primary"`
+ Virtual bool `json:"virtual"`
+}
+
+// NetworkInfo 本机网络总览。
+type NetworkInfo struct {
+ Hostname string `json:"hostname"`
+ PrimaryIPv4 string `json:"primaryIPv4"`
+ Adapters []NetAdapter `json:"adapters"`
+ Raw string `json:"raw"`
+}
+
+// GetNetworkInfo 一键采集本机网卡信息。
+func (a *App) GetNetworkInfo() (NetworkInfo, error) {
+ out := NetworkInfo{Adapters: []NetAdapter{}}
+ out.Hostname, _ = os.Hostname()
+
+ if runtime.GOOS == "windows" {
+ raw, err := exec.Command("powershell", "-NoProfile", "-Command",
+ "[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; ipconfig /all").CombinedOutput()
+ if err != nil || len(raw) == 0 {
+ raw, err = exec.Command("ipconfig", "/all").CombinedOutput()
+ }
+ out.Raw = string(raw)
+ if err == nil && len(raw) > 0 {
+ out.Adapters = parseIPConfig(out.Raw)
+ }
+ }
+ if len(out.Adapters) == 0 {
+ out.Adapters = listAdaptersGo()
+ if out.Raw == "" {
+ out.Raw = formatAdaptersRaw(out.Hostname, out.Adapters)
+ }
+ }
+ markPrimary(&out)
+ return out, nil
+}
+
+func isVirtualName(s string) bool {
+ low := strings.ToLower(s)
+ for _, k := range []string{
+ "virtual", "vmware", "vbox", "virtualbox", "hyper-v", "vethernet",
+ "wsl", "docker", "vpn", "tap-windows", "zerotier", "tailscale",
+ "loopback", "蓝牙", "bluetooth", "isatap", "teredo", "microsoft wi-fi direct",
+ } {
+ if strings.Contains(low, k) {
+ return true
+ }
+ }
+ return false
+}
+
+func isPrivateIPv4(ip string) bool {
+ p := net.ParseIP(strings.Split(ip, "/")[0])
+ if p == nil {
+ return false
+ }
+ v4 := p.To4()
+ if v4 == nil {
+ return false
+ }
+ return v4[0] == 10 ||
+ (v4[0] == 172 && v4[1] >= 16 && v4[1] <= 31) ||
+ (v4[0] == 192 && v4[1] == 168)
+}
+
+func markPrimary(info *NetworkInfo) {
+ best := -1
+ bestScore := -1
+ for i := range info.Adapters {
+ info.Adapters[i].Virtual = isVirtualName(info.Adapters[i].Name + " " + info.Adapters[i].Description)
+ score := 0
+ if info.Adapters[i].Virtual {
+ score -= 50
+ }
+ for _, ip := range info.Adapters[i].IPv4 {
+ if strings.HasPrefix(ip, "127.") {
+ continue
+ }
+ score += 10
+ if isPrivateIPv4(ip) {
+ score += 30
+ }
+ if len(info.Adapters[i].Gateway) > 0 {
+ score += 20
+ }
+ break
+ }
+ if score > bestScore {
+ bestScore = score
+ best = i
+ }
+ }
+ if best >= 0 && bestScore > 0 {
+ info.Adapters[best].Primary = true
+ for _, ip := range info.Adapters[best].IPv4 {
+ if !strings.HasPrefix(ip, "127.") {
+ info.PrimaryIPv4 = strings.Split(ip, "/")[0]
+ break
+ }
+ }
+ }
+}
+
+// parseIPConfig 解析 Windows `ipconfig /all` 文本(兼容中英文关键字)。
+func parseIPConfig(raw string) []NetAdapter {
+ lines := strings.Split(strings.ReplaceAll(raw, "\r\n", "\n"), "\n")
+ var list []NetAdapter
+ var cur *NetAdapter
+
+ flush := func() {
+ if cur == nil {
+ return
+ }
+ if cur.Name != "" || len(cur.IPv4) > 0 || len(cur.IPv6) > 0 {
+ list = append(list, *cur)
+ }
+ cur = nil
+ }
+
+ for _, line := range lines {
+ trim := strings.TrimSpace(line)
+ if trim == "" {
+ continue
+ }
+ // 适配器标题行:不以空格开头,且以冒号结束或含“适配器/adapter”
+ if len(line) > 0 && line[0] != ' ' && line[0] != '\t' {
+ flush()
+ name := strings.TrimRight(trim, ":")
+ cur = &NetAdapter{Name: name}
+ continue
+ }
+ if cur == nil {
+ continue
+ }
+ key, val, ok := splitKV(trim)
+ if !ok {
+ continue
+ }
+ lk := strings.ToLower(key)
+ switch {
+ case strings.Contains(lk, "description") || strings.Contains(key, "描述"):
+ cur.Description = val
+ case strings.Contains(lk, "physical address") || strings.Contains(key, "物理地址"):
+ cur.MAC = val
+ case strings.Contains(lk, "dhcp enabled") || strings.Contains(key, "dhcp 已启用") || strings.Contains(key, "DHCP 已启用"):
+ cur.DHCP = strings.EqualFold(val, "yes") || val == "是"
+ case strings.Contains(lk, "ipv4") || strings.Contains(key, "IPv4"):
+ cur.IPv4 = append(cur.IPv4, cleanIP(val))
+ case strings.Contains(lk, "ipv6") || strings.Contains(key, "IPv6"):
+ if !strings.HasPrefix(strings.ToLower(val), "fe80") {
+ cur.IPv6 = append(cur.IPv6, cleanIP(val))
+ }
+ case strings.Contains(lk, "default gateway") || strings.Contains(key, "默认网关"):
+ if v := cleanIP(val); v != "" {
+ cur.Gateway = append(cur.Gateway, v)
+ }
+ case strings.Contains(lk, "dns servers") || strings.Contains(key, "dns 服务器") || strings.Contains(key, "DNS 服务器"):
+ if v := cleanIP(val); v != "" {
+ cur.DNS = append(cur.DNS, v)
+ }
+ }
+ }
+ flush()
+ return list
+}
+
+func splitKV(line string) (key, val string, ok bool) {
+ // ipconfig 用 ". . . . :" 或直接 ":" 分隔
+ idx := strings.LastIndex(line, ":")
+ if idx <= 0 {
+ return "", "", false
+ }
+ key = strings.TrimSpace(strings.TrimRight(line[:idx], ". \t"))
+ val = strings.TrimSpace(line[idx+1:])
+ if key == "" {
+ return "", "", false
+ }
+ return key, val, true
+}
+
+func cleanIP(v string) string {
+ v = strings.TrimSpace(v)
+ // 去掉 "(首选)" / "(Preferred)" 等后缀
+ if i := strings.IndexAny(v, " ("); i > 0 {
+ v = strings.TrimSpace(v[:i])
+ }
+ return v
+}
+
+func listAdaptersGo() []NetAdapter {
+ ifaces, err := net.Interfaces()
+ if err != nil {
+ return nil
+ }
+ var list []NetAdapter
+ for _, iface := range ifaces {
+ if iface.Flags&net.FlagLoopback != 0 {
+ continue
+ }
+ addrs, _ := iface.Addrs()
+ a := NetAdapter{Name: iface.Name, MAC: iface.HardwareAddr.String()}
+ for _, addr := range addrs {
+ s := addr.String()
+ ip, _, _ := net.ParseCIDR(s)
+ if ip == nil {
+ ip = net.ParseIP(s)
+ }
+ if ip == nil {
+ continue
+ }
+ if v4 := ip.To4(); v4 != nil {
+ a.IPv4 = append(a.IPv4, v4.String())
+ } else if !ip.IsLinkLocalUnicast() {
+ a.IPv6 = append(a.IPv6, ip.String())
+ }
+ }
+ if len(a.IPv4) == 0 && len(a.IPv6) == 0 {
+ continue
+ }
+ list = append(list, a)
+ }
+ return list
+}
+
+func formatAdaptersRaw(host string, adapters []NetAdapter) string {
+ var b strings.Builder
+ b.WriteString("Hostname: " + host + "\n\n")
+ for _, a := range adapters {
+ b.WriteString(a.Name)
+ if a.Description != "" {
+ b.WriteString(" (" + a.Description + ")")
+ }
+ b.WriteString(":\n")
+ if a.MAC != "" {
+ b.WriteString(" MAC: " + a.MAC + "\n")
+ }
+ for _, ip := range a.IPv4 {
+ b.WriteString(" IPv4: " + ip + "\n")
+ }
+ for _, ip := range a.IPv6 {
+ b.WriteString(" IPv6: " + ip + "\n")
+ }
+ for _, g := range a.Gateway {
+ b.WriteString(" Gateway: " + g + "\n")
+ }
+ for _, d := range a.DNS {
+ b.WriteString(" DNS: " + d + "\n")
+ }
+ b.WriteString("\n")
+ }
+ return b.String()
+}
diff --git a/service/ai/ai_test.go b/service/ai/ai_test.go
index 7ee1e82..7e660d3 100644
--- a/service/ai/ai_test.go
+++ b/service/ai/ai_test.go
@@ -56,6 +56,51 @@ func TestChatStreamSSE(t *testing.T) {
}
}
+func TestParseUsageCachedTokens(t *testing.T) {
+ u := parseUsage(&chatUsage{
+ PromptTokens: 100,
+ CompletionTokens: 20,
+ PromptCacheHitTokens: 80,
+ })
+ if u == nil || u.CachedTokens != 80 || u.PromptTokens != 100 {
+ t.Fatalf("deepseek cache field: %+v", u)
+ }
+ u = parseUsage(&chatUsage{
+ PromptTokens: 90,
+ CompletionTokens: 10,
+ PromptTokensDetails: &struct {
+ CachedTokens int64 `json:"cached_tokens"`
+ }{CachedTokens: 64},
+ })
+ if u == nil || u.CachedTokens != 64 {
+ t.Fatalf("openai cached_tokens: %+v", u)
+ }
+}
+
+func TestChatStreamSendsUsage(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/event-stream")
+ w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"ok\"}}]}\n\n"))
+ w.Write([]byte("data: {\"usage\":{\"prompt_tokens\":12,\"completion_tokens\":3,\"prompt_cache_hit_tokens\":8}}\n\n"))
+ w.Write([]byte("data: [DONE]\n\n"))
+ }))
+ defer srv.Close()
+ p := &openAICompatible{name: "test", baseURL: srv.URL, model: "m", apiKey: "k", client: srv.Client()}
+ stream, e := p.ChatStream(context.Background(), []Message{{Role: "user", Content: "hi"}})
+ if e != nil {
+ t.Fatal(e)
+ }
+ var usage *Usage
+ for c := range stream {
+ if c.Usage != nil {
+ usage = c.Usage
+ }
+ }
+ if usage == nil || usage.PromptTokens != 12 || usage.CachedTokens != 8 {
+ t.Fatalf("usage not delivered: %+v", usage)
+ }
+}
+
func TestChatStreamAuthError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
diff --git a/service/ai/openai.go b/service/ai/openai.go
index 0f88d45..98ca335 100644
--- a/service/ai/openai.go
+++ b/service/ai/openai.go
@@ -26,9 +26,14 @@ type openAICompatible struct {
func (p *openAICompatible) Name() string { return p.name }
type chatRequest struct {
- Model string `json:"model"`
- Messages []Message `json:"messages"`
- Stream bool `json:"stream"`
+ Model string `json:"model"`
+ Messages []Message `json:"messages"`
+ Stream bool `json:"stream"`
+ StreamOptions *streamOptions `json:"stream_options,omitempty"`
+}
+
+type streamOptions struct {
+ IncludeUsage bool `json:"include_usage"`
}
type chatDelta struct {
@@ -37,17 +42,42 @@ type chatDelta struct {
Content string `json:"content"`
} `json:"delta"`
} `json:"choices"`
- Usage *struct {
- PromptTokens int64 `json:"prompt_tokens"`
- CompletionTokens int64 `json:"completion_tokens"`
- } `json:"usage"`
+ Usage *chatUsage `json:"usage"`
Error *struct {
Message string `json:"message"`
} `json:"error"`
}
+// chatUsage 兼容 OpenAI / DeepSeek / 星火:缓存命中字段名不统一。
+type chatUsage struct {
+ PromptTokens int64 `json:"prompt_tokens"`
+ CompletionTokens int64 `json:"completion_tokens"`
+ PromptCacheHitTokens int64 `json:"prompt_cache_hit_tokens"`
+ PromptTokensDetails *struct {
+ CachedTokens int64 `json:"cached_tokens"`
+ } `json:"prompt_tokens_details"`
+}
+
+func parseUsage(u *chatUsage) *Usage {
+ if u == nil {
+ return nil
+ }
+ cached := u.PromptCacheHitTokens
+ if u.PromptTokensDetails != nil && u.PromptTokensDetails.CachedTokens > cached {
+ cached = u.PromptTokensDetails.CachedTokens
+ }
+ return &Usage{
+ PromptTokens: u.PromptTokens,
+ CompletionTokens: u.CompletionTokens,
+ CachedTokens: cached,
+ }
+}
+
func (p *openAICompatible) ChatStream(ctx context.Context, messages []Message) (<-chan Chunk, error) {
- body, e := json.Marshal(chatRequest{Model: p.model, Messages: messages, Stream: true})
+ body, e := json.Marshal(chatRequest{
+ Model: p.model, Messages: messages, Stream: true,
+ StreamOptions: &streamOptions{IncludeUsage: true},
+ })
if e != nil {
return nil, e
}
@@ -100,10 +130,7 @@ func (p *openAICompatible) ChatStream(ctx context.Context, messages []Message) (
return
}
if d.Usage != nil {
- lastUsage = &Usage{
- PromptTokens: d.Usage.PromptTokens,
- CompletionTokens: d.Usage.CompletionTokens,
- }
+ lastUsage = parseUsage(d.Usage)
}
if len(d.Choices) > 0 && d.Choices[0].Delta.Content != "" {
completionChars += len(d.Choices[0].Delta.Content)
@@ -119,7 +146,6 @@ func (p *openAICompatible) ChatStream(ctx context.Context, messages []Message) (
return
}
if lastUsage == nil {
- // 粗估:约 4 字符 ≈ 1 token
lastUsage = &Usage{
PromptTokens: int64((promptChars + 3) / 4),
CompletionTokens: int64((completionChars + 3) / 4),
@@ -128,7 +154,7 @@ func (p *openAICompatible) ChatStream(ctx context.Context, messages []Message) (
}
select {
case out <- Chunk{Usage: lastUsage}:
- default:
+ case <-ctx.Done():
}
}()
return out, nil
diff --git a/service/ai/provider.go b/service/ai/provider.go
index 9a8c6eb..1eddb49 100644
--- a/service/ai/provider.go
+++ b/service/ai/provider.go
@@ -22,9 +22,23 @@ type Chunk struct {
type Usage struct {
PromptTokens int64
CompletionTokens int64
+ CachedTokens int64 // 提示缓存命中(服务商返回时才有)
Estimated bool
}
+// EstimateUsage 在服务商未回 usage 时按字符粗估(约 4 字符 ≈ 1 token)。
+func EstimateUsage(messages []Message, completion string) *Usage {
+ n := 0
+ for _, m := range messages {
+ n += len(m.Content)
+ }
+ return &Usage{
+ PromptTokens: int64((n + 3) / 4),
+ CompletionTokens: int64((len(completion) + 3) / 4),
+ Estimated: true,
+ }
+}
+
// Provider 是 AI 服务商的统一抽象。
type Provider interface {
Name() string
diff --git a/team.go b/team.go
index 750baef..03ac727 100644
--- a/team.go
+++ b/team.go
@@ -619,16 +619,21 @@ func (a *App) generateTeamDigest(teamID int64, date string) {
prompt := fmt.Sprintf("以下是团队「%s」%s 的 %d 份成员日报,请生成一份团队日报摘要(Markdown):\n"+
"1. 今日完成事项汇总(合并同类项)\n2. 进行中的工作\n3. 风险与阻塞(没有则省略)\n4. 每位成员一句话点评\n5. 明日建议\n\n%s",
teamName, date, n, sb.String())
- stream, e := provider.ChatStream(ctx, []ai.Message{
+ digestMsgs := []ai.Message{
{Role: "system", Content: "你是资深研发团队负责人助理,输出简洁、结构化的中文 Markdown 团队日报摘要,不要编造日报里没有的内容。"},
{Role: "user", Content: prompt},
- })
+ }
+ stream, e := provider.ChatStream(ctx, digestMsgs)
if e != nil {
emitErr(e.Error())
return
}
var out strings.Builder
+ var usage *ai.Usage
for chunk := range stream {
+ if chunk.Usage != nil {
+ usage = chunk.Usage
+ }
if chunk.Err != nil {
emitErr(chunk.Err.Error())
return
@@ -647,6 +652,7 @@ func (a *App) generateTeamDigest(teamID int64, date string) {
emitErr(e.Error())
return
}
+ a.recordAIUsage(provider.Name(), "team_digest", usage, digestMsgs, content)
a.store.Log("info", "团队", "团队日报摘要已生成", fmt.Sprintf("team=%d date=%s", teamID, date))
a.emit("team:digest", teamDigestEvent{TeamID: teamID, Date: date})
}