模块介绍 / 今日规划 / 下班日报 / 团队摘要 生成成功后没有上报,也几乎不写「AI」日志。

流结束时的 usage 用了非阻塞发送,通道一满就被丢掉;很多服务商默认还不在流里带 usage。
现在每次真正打到模型的调用都会:

在本地日志(分类 AI,消息 AI 调用完成)写下 scene / prompt / completion / cached
登录后同步上报到后台日汇总
This commit is contained in:
李琦
2026-08-17 14:00:47 +08:00
parent 83e69317d4
commit e71ce451ff
23 changed files with 1363 additions and 93 deletions

View File

@@ -1 +1 @@
8ef156c82c13dcfb55a861fc1e1b9483
cd84020b5c141a3842ad91546a552d0

View File

@@ -1 +1 @@
a59624793eae992b65534b38e0fef064
d56685eca1698f613f797ff4d81f460a

View File

@@ -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)
}

25
ai.go
View File

@@ -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

View File

@@ -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 ""
}

View File

@@ -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 => {
<div class="sub-title">{{ t(activeGroup.label) }}</div>
<nav class="sub-nav" :aria-label="t(activeGroup.label)">
<template v-for="it in visibleItems(activeGroup)" :key="it.to">
<RouterLink :to="it.to" :class="{ active: itemActive(it) }">
<RouterLink :to="it.to" :class="{ active: itemActive(it), shortcut: it.shortcut }">
<component :is="it.icon" /><span>{{ t(it.label) }}</span>
<em v-if="badgeVal(it)" class="nav-badge">{{ badgeText(it) }}</em>
<i v-else-if="dotVal(it)" class="nav-dot" aria-hidden="true" />
<button
v-if="it.shortcut"
type="button"
class="nav-unpin"
:title="t('navUnpinShortcut')"
@click.prevent.stop="removeShortcut(it.to)"
><PinOff /></button>
<button v-if="it.children" type="button" class="sub-caret" :class="{ open: expandedParents[it.label] }" :aria-label="t(it.label)" @click="toggleParent(it, $event)"><ChevronDown /></button>
</RouterLink>
<div v-if="it.children && expandedParents[it.label]" class="sub-children">
<RouterLink v-for="c in it.children" :key="c.to" :to="c.to" :class="{ active: childActive(c) }">{{ t(c.label) }}</RouterLink>
</div>
</template>
<button
v-if="activeGroup.id === 'overview'"
type="button"
class="nav-add-shortcut"
@click="shortcutPicker = !shortcutPicker"
><Plus />{{ t('navAddShortcut') }}</button>
</nav>
<div class="sidebar-bottom">
<span class="version"><i />v{{ appVersion }}</span>
@@ -348,6 +422,33 @@ watch(activeTask, task => {
</section>
</div>
</Teleport>
<Teleport to="body">
<div v-if="shortcutPicker" class="overlay" @click.self="shortcutPicker = false">
<section class="modal nav-shortcut-modal" @click.stop>
<header class="modal-head">
<h2><Pin />{{ t('navShortcutPicker') }}</h2>
<button type="button" class="nm-close" :title="t('close')" @click="shortcutPicker = false"><X /></button>
</header>
<p class="nav-shortcut-hint">{{ t('navShortcutHint') }}</p>
<div class="nav-shortcut-list">
<button
v-for="it in pinnableItems"
:key="it.to"
type="button"
class="nav-shortcut-item"
:class="{ on: navShortcuts.includes(it.to) }"
@click="toggleShortcut(it.to)"
>
<component :is="it.icon" class="nav-shortcut-ico" />
<span class="nav-shortcut-label">{{ t(it.label) }}</span>
<small class="nav-shortcut-group">{{ t(it.groupLabel) }}</small>
<Pin v-if="navShortcuts.includes(it.to)" class="nav-shortcut-mark on" />
<Plus v-else class="nav-shortcut-mark" />
</button>
</div>
</section>
</div>
</Teleport>
<main><RouterView /></main>
<div v-if="visibleTask && !useFullscreenLoading" class="taskbar">
<div><b>{{ activeTaskProject || visibleTask.stage }}</b><span>{{ t(visibleTask.messageKey || 'task.start', visibleTask.params || {}) }}</span></div>

View File

@@ -111,6 +111,7 @@ function toggleAll() {
</select>
</label>
</div>
<p class="ai-gen-date-hint">{{ t('aiGenDateHint') }}</p>
<div class="ai-gen-list">
<article v-for="(d, i) in drafts" :key="i" class="ai-gen-item" :class="{ off: !d._on }">
<label class="ai-gen-check"><input v-model="d._on" type="checkbox" /></label>
@@ -129,10 +130,19 @@ function toggleAll() {
<option value="high">{{ t('priority.high') }}</option>
</select>
<template v-if="kind === 'ticket'">
<input v-model="d.startAt" type="date" :title="t('startDate')" />
<input v-model="d.dueAt" type="date" :title="t('dueDate')" />
<label class="ai-gen-date">
<span>{{ t('startDate') }}</span>
<input v-model="d.startAt" type="date" :title="t('startDate')" />
</label>
<label class="ai-gen-date">
<span>{{ t('dueDate') }}</span>
<input v-model="d.dueAt" type="date" :title="t('dueDate')" />
</label>
</template>
<input v-else v-model="d.dueAt" type="datetime-local" :title="t('dueDate')" />
<label v-else class="ai-gen-date">
<span>{{ t('dueDate') }}</span>
<input v-model="d.dueAt" type="datetime-local" :title="t('dueDate')" />
</label>
</div>
<p v-if="kind === 'todo' ? d.content : d.description" class="ai-gen-desc">{{ kind === 'todo' ? d.content : d.description }}</p>
</div>
@@ -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; }

View File

@@ -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; }

View File

@@ -0,0 +1,195 @@
<script setup>
// 本机网络信息模态框:调用 GetNetworkInfo展示主 IP / 各网卡,支持一键复制。
import { onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { Network, X, Copy, RefreshCw, Check, Star } from 'lucide-vue-next'
import { call } from '../api'
import { useAppStore } from '../store'
const emit = defineEmits(['close'])
const { t } = useI18n()
const store = useAppStore()
const loading = ref(true)
const info = ref(null)
const err = ref('')
const copied = ref('')
async function load() {
loading.value = true
err.value = ''
try {
info.value = await call('GetNetworkInfo')
} catch (e) {
err.value = String(e?.message || e)
} finally {
loading.value = false
}
}
async function copyText(text, key) {
const v = String(text || '').trim()
if (!v) return
try {
await navigator.clipboard.writeText(v)
copied.value = key
store.showToast({ type: 'success', key: 'netInfoCopied' })
setTimeout(() => { if (copied.value === key) copied.value = '' }, 1500)
} catch {
store.showToast({ type: 'error', key: 'netInfoCopyFail' })
}
}
function copyAdapter(a) {
const lines = [
a.name + (a.description ? ` (${a.description})` : ''),
a.mac ? `MAC: ${a.mac}` : '',
...(a.ipv4 || []).map(ip => `IPv4: ${ip}`),
...(a.ipv6 || []).map(ip => `IPv6: ${ip}`),
...(a.gateway || []).map(g => `Gateway: ${g}`),
...(a.dns || []).map(d => `DNS: ${d}`)
].filter(Boolean)
copyText(lines.join('\n'), 'a:' + a.name)
}
function copyAll() {
if (!info.value) return
const parts = []
if (info.value.hostname) parts.push('Hostname: ' + info.value.hostname)
if (info.value.primaryIPv4) parts.push('Primary IPv4: ' + info.value.primaryIPv4)
parts.push('')
parts.push(info.value.raw || '')
copyText(parts.join('\n').trim(), 'all')
}
const cleanName = s => String(s || '').replace(/\uFFFD/g, '').trim() || '—'
onMounted(load)
</script>
<template>
<Teleport to="body">
<div class="overlay" @click.self="emit('close')">
<section class="modal net-modal" @click.stop>
<header class="net-head">
<h2><Network />{{ t('netInfoTitle') }}</h2>
<div class="net-head-acts">
<button type="button" class="btn secondary icon" :disabled="loading" :title="t('refresh')" @click="load">
<RefreshCw :class="{ spin: loading }" />
</button>
<button type="button" class="btn primary icon" :disabled="!info" :title="t('netInfoCopyAll')" @click="copyAll">
<Copy />
</button>
<button type="button" class="nm-close" :title="t('close')" @click="emit('close')"><X /></button>
</div>
</header>
<p v-if="loading" class="net-hint">{{ t('loading') }}</p>
<p v-else-if="err" class="net-err">{{ err }}</p>
<template v-else-if="info">
<div class="net-primary">
<div>
<small>{{ t('netInfoHostname') }}</small>
<b>{{ info.hostname || '—' }}</b>
</div>
<div class="net-primary-ip">
<div>
<small>{{ t('netInfoPrimaryIP') }}</small>
<b>{{ info.primaryIPv4 || '—' }}</b>
</div>
<button
v-if="info.primaryIPv4"
type="button"
class="btn secondary sm"
:title="t('netInfoCopyIP')"
@click="copyText(info.primaryIPv4, 'primary')"
>
<Check v-if="copied === 'primary'" /><Copy v-else /><span>{{ t('netInfoCopyIP') }}</span>
</button>
</div>
</div>
<div class="net-list">
<article v-for="a in info.adapters" :key="a.name + (a.mac || '')" class="net-card" :class="{ primary: a.primary, virtual: a.virtual }">
<header>
<div class="net-card-id">
<b>{{ cleanName(a.name) }}</b>
<em v-if="a.primary"><Star />{{ t('netInfoPrimary') }}</em>
<em v-else-if="a.virtual" class="dim">{{ t('netInfoVirtual') }}</em>
<small v-if="cleanName(a.description) !== '—'">{{ cleanName(a.description) }}</small>
</div>
<button type="button" class="btn secondary icon" :title="t('copy')" @click="copyAdapter(a)"><Copy /></button>
</header>
<dl>
<template v-if="a.mac"><dt>MAC</dt><dd><button type="button" @click="copyText(a.mac, 'mac:'+a.name)">{{ a.mac }}</button></dd></template>
<template v-for="(ip, i) in (a.ipv4 || [])" :key="'v4'+i"><dt>IPv4</dt><dd><button type="button" @click="copyText(ip, 'v4:'+a.name+i)">{{ ip }}</button></dd></template>
<template v-for="(ip, i) in (a.ipv6 || [])" :key="'v6'+i"><dt>IPv6</dt><dd><button type="button" @click="copyText(ip, 'v6:'+a.name+i)">{{ ip }}</button></dd></template>
<template v-for="(g, i) in (a.gateway || [])" :key="'gw'+i"><dt>{{ t('netInfoGateway') }}</dt><dd><button type="button" @click="copyText(g, 'gw:'+a.name+i)">{{ g }}</button></dd></template>
<template v-for="(d, i) in (a.dns || [])" :key="'dns'+i"><dt>DNS</dt><dd><button type="button" @click="copyText(d, 'dns:'+a.name+i)">{{ d }}</button></dd></template>
<template v-if="a.dhcp"><dt>DHCP</dt><dd>{{ t('optOn') }}</dd></template>
</dl>
</article>
<p v-if="!info.adapters?.length" class="net-hint">{{ t('netInfoEmpty') }}</p>
</div>
<details class="net-raw">
<summary>{{ t('netInfoRaw') }}</summary>
<pre>{{ info.raw }}</pre>
</details>
</template>
</section>
</div>
</Teleport>
</template>
<style scoped>
.net-modal { width: min(680px, 94vw); max-height: 86vh; display: flex; flex-direction: column; padding: 0; overflow: hidden; }
.net-head {
display: flex; align-items: center; justify-content: space-between; gap: .75rem;
padding: 14px 16px; border-bottom: 1px solid var(--border); flex: none;
}
.net-head h2 { display: flex; align-items: center; gap: .45rem; margin: 0; font-size: 1.05rem; min-width: 0; }
.net-head h2 svg { width: 18px; height: 18px; color: #38bdf8; flex: none; }
.net-head-acts { display: flex; align-items: center; gap: .4rem; flex: none; }
.net-hint, .net-err { margin: .75rem 1rem; font-size: .88rem; color: var(--muted); }
.net-err { color: #ef4444; }
.net-primary {
display: grid; grid-template-columns: 1fr 1.4fr; gap: .75rem; margin: .85rem 1rem 0;
padding: .75rem .85rem; border: 1px solid color-mix(in srgb, #38bdf8 35%, var(--border));
border-radius: 10px; background: color-mix(in srgb, #38bdf8 8%, transparent);
}
.net-primary small { display: block; font-size: .72rem; color: var(--muted); margin-bottom: .2rem; }
.net-primary b { font-size: .95rem; word-break: break-all; }
.net-primary-ip { display: flex; align-items: center; justify-content: space-between; gap: .6rem; min-width: 0; }
.net-primary-ip .btn { flex: none; }
.net-list { flex: 1; overflow: auto; display: flex; flex-direction: column; gap: .55rem; min-height: 100px; padding: .75rem 1rem; }
.net-card { border: 1px solid var(--border); border-radius: 10px; padding: .65rem .75rem; }
.net-card.primary { border-color: color-mix(in srgb, #38bdf8 45%, var(--border)); }
.net-card.virtual { opacity: .72; }
.net-card > header { display: flex; align-items: flex-start; justify-content: space-between; gap: .6rem; margin-bottom: .45rem; }
.net-card-id { display: flex; flex-direction: column; gap: .15rem; min-width: 0; }
.net-card-id b { font-size: .9rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.net-card-id small { color: var(--muted); font-size: .75rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.net-card-id em {
display: inline-flex; align-items: center; gap: .25rem; width: fit-content;
font-style: normal; font-size: .7rem; padding: .1rem .45rem; border-radius: 999px;
background: color-mix(in srgb, #38bdf8 16%, transparent); color: #38bdf8;
}
.net-card-id em.dim { background: var(--surface-3); color: var(--muted); }
.net-card-id em svg { width: 11px; height: 11px; }
.net-card dl { display: grid; grid-template-columns: 72px 1fr; gap: .25rem .5rem; margin: 0; font-size: .82rem; }
.net-card dt { color: var(--muted); }
.net-card dd { margin: 0; min-width: 0; }
.net-card dd button { all: unset; cursor: pointer; color: var(--text); word-break: break-all; }
.net-card dd button:hover { color: #38bdf8; text-decoration: underline; }
.net-raw { margin: 0 1rem 1rem; font-size: .82rem; color: var(--muted); }
.net-raw pre {
margin: .4rem 0 0; max-height: 180px; overflow: auto; padding: .65rem .75rem;
border-radius: 8px; border: 1px solid var(--border); background: var(--surface-2);
color: var(--text); font-size: .75rem; white-space: pre-wrap; word-break: break-word;
}
.spin { animation: net-spin 1s linear infinite; }
@keyframes net-spin { to { transform: rotate(360deg); } }
@media (max-width: 640px) {
.net-primary { grid-template-columns: 1fr; }
}
</style>

View File

@@ -1,16 +1,22 @@
<script setup>
// 笔记详情模态框:编辑/预览切换 + Markdown 渲染600ms 防抖自动保存;关闭时空内容自动清理。
import { onMounted, onUnmounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { StickyNote, Trash2, X } from 'lucide-vue-next'
import { StickyNote, Trash2, X, Pencil, Eye } from 'lucide-vue-next'
import { call } from '../api'
import MarkdownView from './MarkdownView.vue'
// 笔记详情模态框打开即编辑600ms 防抖自动保存;关闭时空内容自动清理。
const props = defineProps({ note: { type: Object, default: null } })
const props = defineProps({
note: { type: Object, default: null },
// 打开时默认模式edit | preview
initialMode: { type: String, default: 'edit' }
})
const emit = defineEmits(['close', 'changed'])
const { t } = useI18n()
const noteId = ref(props.note?.id || 0)
const text = ref(props.note?.content || '')
const savedAt = ref('')
const preview = ref(props.initialMode === 'preview')
const area = ref(null)
let timer = 0
let saving = null
@@ -30,9 +36,13 @@ async function flush() {
}).catch(() => {})
await saving
}
async function setPreview(on) {
if (preview.value === on) return
if (on) await flush()
preview.value = on
}
async function close() {
await flush()
// 内容清空的已有笔记视为不再需要,顺手删除
if (noteId.value && !text.value.trim()) {
try { await call('DeleteNote', noteId.value); emit('changed') } catch {}
}
@@ -50,7 +60,7 @@ function onKey(e) {
}
onMounted(() => {
addEventListener('keydown', onKey)
requestAnimationFrame(() => area.value?.focus())
if (!preview.value) requestAnimationFrame(() => area.value?.focus())
})
onUnmounted(() => { removeEventListener('keydown', onKey); clearTimeout(timer) })
</script>
@@ -58,16 +68,21 @@ onUnmounted(() => { removeEventListener('keydown', onKey); clearTimeout(timer) }
<template>
<Teleport to="body">
<div class="overlay" @click.self="close">
<section class="modal note-modal">
<section class="modal note-modal" @click.stop>
<header class="nm-head">
<h2><StickyNote />{{ noteId ? t('noteEdit') : t('noteNew') }}</h2>
<small v-if="savedAt" class="nm-saved">{{ t('autoSaved') }} {{ savedAt }}</small>
<div class="tabs compact nm-mode">
<button type="button" :class="{ active: !preview }" :title="t('mdEdit')" @click="setPreview(false)"><Pencil /></button>
<button type="button" :class="{ active: preview }" :title="t('mdPreviewTab')" @click="setPreview(true)"><Eye /></button>
</div>
<div class="nm-tools">
<button v-if="noteId" type="button" class="nm-del" :title="t('delete')" @click="removeNote"><Trash2 /></button>
<button type="button" class="nm-close" :title="t('close')" @click="close"><X /></button>
</div>
</header>
<textarea ref="area" v-model="text" class="nm-area" :placeholder="t('notepadPlaceholder')" @input="onInput" />
<textarea v-show="!preview" ref="area" v-model="text" class="nm-area" :placeholder="t('mdPlaceholder')" @input="onInput" />
<MarkdownView v-if="preview" class="nm-preview md-preview-box" :source="text || t('mdEmpty')" />
</section>
</div>
</Teleport>

View File

@@ -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',

View File

@@ -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)}

File diff suppressed because one or more lines are too long

View File

@@ -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?.() })
<div class="stat"><span>{{ t('adminStatTeams') }}</span><b>{{ overview.teamCount }}</b></div>
<div class="stat"><span>{{ t('adminStatDAU') }}</span><b>{{ overview.dauToday }}</b></div>
<div class="stat"><span>{{ t('adminStatTokens') }}</span><b>{{ (overview.tokenToday?.promptTokens||0)+(overview.tokenToday?.completionTokens||0) }}</b></div>
<div class="stat"><span>{{ t('adminStatCached') }}</span><b>{{ overview.tokenToday?.cachedTokens||0 }}</b></div>
</div>
<div class="chart-grid">
<div class="chart-card">
@@ -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}

View File

@@ -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 => {
<div class="lp-head-top">
<div><h1>{{ t('launchpad') }}</h1><p>{{ t('launchpadSubtitle') }}</p></div>
<div class="lp-tools">
<button class="btn secondary" @click="netInfoOpen = true"><Network />{{ t('netInfoBtn') }}</button>
<button class="btn secondary" @click="aiOpen = true"><Sparkles />{{ t('aiScopeBtn') }}</button>
<RefreshIntervalPicker v-model="refreshSec" />
<button class="btn secondary" :disabled="loading" @click="load"><RefreshCw :class="{ spinning: loading }" />{{ t('lpRefresh') }}</button>
@@ -705,6 +708,7 @@ watch(() => route.query.pin, async v => {
</div>
</Teleport>
<LaunchAppFormModal :initial="editing" :initial-error="editErr" @close="editing = null; editErr = ''" @saved="onFormSaved" />
<NetworkInfoModal v-if="netInfoOpen" @close="netInfoOpen = false" />
<AIScopeDrawer v-if="aiOpen" kind="launchpad" :title="t('launchpad')" @close="aiOpen = false" />
</div>
</template>

View File

@@ -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))
</section>
<section class="panel wb-col wb-note-panel">
<div class="section-head">
<div class="section-head wb-note-head">
<h2><StickyNote class="panel-icon" />{{ t('notepad') }}<small>{{ notes.length }}</small></h2>
<div class="wb-note-acts">
<small v-if="noteDirty" class="wb-note-dirty">{{ t('noteUnsaved') }}</small>
<small v-else-if="noteSavedAt" class="wb-note-saved">{{ t('autoSaved') }} {{ noteSavedAt }}</small>
<div class="tabs compact wb-note-tabs">
<button type="button" :class="{ active: !notePreview }" @click="setNotePreview(false)"><Pencil />{{ t('mdEdit') }}</button>
<button type="button" :class="{ active: notePreview }" @click="setNotePreview(true)"><Eye />{{ t('mdPreviewTab') }}</button>
<button type="button" :class="{ active: !notePreview }" :title="t('mdEdit')" @click="setNotePreview(false)"><Pencil /></button>
<button type="button" :class="{ active: notePreview }" :title="t('mdPreviewTab')" @click="setNotePreview(true)"><Eye /></button>
</div>
<button type="button" class="btn secondary" :title="t('noteExpand')" @click="openNoteModal(notePreview ? 'preview' : 'edit')"><Maximize2 /></button>
<button type="button" class="btn secondary" :title="t('noteNew')" @click="newNote"><Plus /></button>
<button type="button" class="btn primary" :disabled="noteSaving || !noteDirty" :title="t('save')" @click="saveNote(true)"><Save />{{ t('save') }}</button>
<button type="button" class="btn primary" :disabled="noteSaving || !noteDirty" :title="t('save')" @click="saveNote(true)"><Save /></button>
<button type="button" class="btn secondary" @click="router.push('/notes')">{{ t('viewAll') }}<ArrowRight /></button>
</div>
</div>
@@ -235,8 +257,11 @@ onUnmounted(() => clearTimeout(noteTimer))
</button>
<div v-if="!notes.length" class="wb-note-list-empty">{{ t('noteEmpty') }}</div>
</aside>
<textarea v-show="!notePreview" v-model="noteText" class="wb-note" :placeholder="t('mdPlaceholder')" @input="editNote" />
<MarkdownView v-if="notePreview" class="wb-note-preview md-preview-box" :source="noteText || t('mdEmpty')" />
<div class="wb-note-stage" :class="{ preview: notePreview }" @click="notePreview && openNoteModal('preview')">
<textarea v-show="!notePreview" v-model="noteText" class="wb-note" :placeholder="t('mdPlaceholder')" @input="editNote" @click.stop />
<MarkdownView v-if="notePreview" class="wb-note-preview md-preview-box" :source="noteText || t('mdEmpty')" />
<button v-if="notePreview" type="button" class="wb-note-expand" @click.stop="openNoteModal('preview')"><Maximize2 />{{ t('noteExpand') }}</button>
</div>
</div>
</section>
@@ -254,4 +279,11 @@ onUnmounted(() => clearTimeout(noteTimer))
</section>
</div>
</div>
<NoteModal
v-if="noteModal"
:note="note"
:initial-mode="noteModalMode"
@close="noteModal = false; onNoteModalChanged()"
@changed="onNoteModalChanged"
/>
</template>

261
holidays.go Normal file
View File

@@ -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 农历节日的公历对照20252029。春节 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}}},
},
}

62
holidays_test.go Normal file
View File

@@ -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)
}
}

276
network_info.go Normal file
View File

@@ -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()
}

View File

@@ -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)

View File

@@ -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

View File

@@ -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

10
team.go
View File

@@ -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})
}