模块介绍 / 今日规划 / 下班日报 / 团队摘要 生成成功后没有上报,也几乎不写「AI」日志。
流结束时的 usage 用了非阻塞发送,通道一满就被丢掉;很多服务商默认还不在流里带 usage。 现在每次真正打到模型的调用都会: 在本地日志(分类 AI,消息 AI 调用完成)写下 scene / prompt / completion / cached 登录后同步上报到后台日汇总
This commit is contained in:
@@ -1 +1 @@
|
||||
8ef156c82c13dcfb55a861fc1e1b9483
|
||||
cd84020b5c141a3842ad91546a552d0
|
||||
|
||||
@@ -1 +1 @@
|
||||
a59624793eae992b65534b38e0fef064
|
||||
d56685eca1698f613f797ff4d81f460a
|
||||
|
||||
3
admin.go
3
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)
|
||||
}
|
||||
|
||||
25
ai.go
25
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
|
||||
|
||||
158
ai_tasks.go
158
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 ""
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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; }
|
||||
|
||||
195
frontend/src/components/NetworkInfoModal.vue
Normal file
195
frontend/src/components/NetworkInfoModal.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
261
holidays.go
Normal 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 农历节日的公历对照(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}}},
|
||||
},
|
||||
}
|
||||
62
holidays_test.go
Normal file
62
holidays_test.go
Normal 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
276
network_info.go
Normal 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()
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
10
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})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user