1. 后台图表多样化 API 的 /admin/overview 新增了按 AI 提供商聚合的用量(providerSeries)和云端数据构成(dataDist)。Admin.vue 概览页改用 ECharts:日活折线图、Token 堆叠柱状图(叠加调用次数折线)、AI 提供商用量饼图、云端数据构成饼图。

2. 端口监控与刷新间隔 「存为应用」不再跳转启动台,而是在本页弹出复用的 LaunchAppFormModal 表单,保存成功后弹确认框询问「留在本页 / 前往启动台」。启动台和端口监控页头都加了 RefreshIntervalPicker(仅手动 / 5 / 10 / 30 / 60 秒,默认 30 秒),选择记忆在 localStorage。

3. 团队所有者与用户详情 团队列表的所有者、用户列表的用户名都改为「头像 + 昵称 + ID」的可点击单元格,点击弹出用户详情:头像、昵称、头衔、待办/工单/笔记/团队数量、注册时间、最近活跃、拥有团队数。API 新增 GET /admin/users/:id,团队列表关联查询出 ownerNickname/ownerAvatar。

4. 项目专属排除规则 SQLite exclusion_rules 加了 project_id 列(唯一约束改为 (project_id, pattern),旧库自动重建迁移)。项目详情页「代码」标签下新增专属规则面板,可增删;扫描时全局规则 + 项目规则叠加生效。规则随项目身份上云,其它机器认领项目后自动带回。

5. 一句话 AI 生成待办/工单 待办页和工单页工具栏各嵌入一个 AI 生成输入条:一句话回车后由后端 AIGenerateTasks 调用 AI 拆解成多条草稿,弹预览框逐条勾选、可改标题/类型/优先级/日期,选归属项目后批量保存。

6. 项目云同步与认领

全局挂载了 CloudClaimModal:登录同步后发现云端有本机未落地的项目时自动弹出(同一批只打扰一次),每个项目可「选目录认领」或「忽略」;工作台横幅和个人主页可随时再打开。
统计数据按机器码推送到 stats:<机器码> 文档,只推不拉,不同电脑的扫描历史互不覆盖。
个人主页 · 云同步新增「项目云同步」区块:自动/手动模式切换(手动模式下常规同步跳过项目文档,仅点「立即同步项目」时推拉)、待认领入口、已忽略项目的恢复列表。
途中补齐了约 60 个中英双语 i18n 键,并修掉了 PortMonitor 引用但缺失的 pmSavedToast 键。
This commit is contained in:
李琦
2026-08-17 09:04:53 +08:00
parent c65cedf9fa
commit c430a0f6da
25 changed files with 1666 additions and 240 deletions

173
ai_tasks.go Normal file
View File

@@ -0,0 +1,173 @@
package main
// ai_tasks.go 一句话生成多条待办/工单:调用 AI 把自然语言拆解成结构化草稿,
// 前端预览确认后再逐条走 SaveTodo / SaveTicket 正常落库。
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"view/service/ai"
)
// AITaskDraft 是 AI 生成的一条草稿todo 与 ticket 共用,前端按 kind 取字段)。
type AITaskDraft struct {
Kind string `json:"kind"` // todo | ticket
Title string `json:"title"`
Content string `json:"content"` // todo 备注
Description string `json:"description"` // ticket 描述
Type string `json:"type"` // ticket: feature | bug | task | improvement
Priority string `json:"priority"` // low | medium | high
StartAt string `json:"startAt"` // ticket: YYYY-MM-DD
DueAt string `json:"dueAt"` // todo: YYYY-MM-DDTHH:MMticket: YYYY-MM-DD
}
// AIGenerateTasks 把一句话拆解成多条待办或工单草稿(不落库,由前端确认后保存)。
func (a *App) AIGenerateTasks(kind, text string) ([]AITaskDraft, error) {
if e := a.ready(); e != nil {
return nil, e
}
if kind != "todo" && kind != "ticket" {
return nil, errors.New("BAD_REQUEST")
}
text = strings.TrimSpace(text)
if text == "" {
return nil, errors.New("AI_EMPTY_MESSAGE")
}
provider, e := a.aiProvider()
if e != nil {
return nil, e
}
if e := a.checkAIPolicy(); e != nil {
return nil, e
}
msgs := []ai.Message{
{Role: "system", Content: aiTaskPrompt(kind)},
{Role: "user", Content: text},
}
ctx, cancel := context.WithTimeout(a.ctx, 2*time.Minute)
defer cancel()
stream, e := provider.ChatStream(ctx, msgs)
if e != nil {
a.store.Log("error", "AI", "AI 任务生成请求失败", e.Error())
return nil, e
}
var sb strings.Builder
var usage *ai.Usage
for chunk := range stream {
if chunk.Usage != nil {
usage = chunk.Usage
}
if chunk.Err != nil {
a.store.Log("error", "AI", "AI 任务生成失败", chunk.Err.Error())
return nil, chunk.Err
}
sb.WriteString(chunk.Content)
}
if usage != nil {
go a.reportAIUsage(provider.Name(), usage.PromptTokens, usage.CompletionTokens, usage.Estimated)
}
drafts, pe := parseAITaskDrafts(kind, sb.String())
if pe != nil {
a.store.Log("warning", "AI", "AI 任务生成解析失败", sb.String())
return nil, pe
}
a.store.Log("info", "AI", "AI 任务生成完成", fmt.Sprintf("kind=%s count=%d", kind, len(drafts)))
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"))
if kind == "ticket" {
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 天);能从用户话中推断出的日期(如“周五前”“下周”)必须转换为具体日期。`
}
return head + `你是待办事项拆解助手。把用户的一句话拆成 1~10 条待办。
只输出 JSON 数组禁止任何解释、markdown 围栏或多余文字。每个元素:
{"title":"简短标题(<=40字)","content":"补充细节,可为空","priority":"low|medium|high","dueAt":"YYYY-MM-DDTHH:MM 或空字符串"}
规则:标题用与用户输入相同的语言;只有用户明确或可合理推断截止时间时才填 dueAt如“明天下午”→ 明天 18:00否则留空字符串。`
}
// parseAITaskDrafts 从模型输出中提取 JSON 数组并规范化字段。
func parseAITaskDrafts(kind, raw string) ([]AITaskDraft, error) {
raw = stripFence(strings.TrimSpace(raw))
start, end := strings.Index(raw, "["), strings.LastIndex(raw, "]")
if start < 0 || end <= start {
return nil, errors.New("AI_BAD_RESPONSE")
}
var list []AITaskDraft
if json.Unmarshal([]byte(raw[start:end+1]), &list) != nil {
return nil, errors.New("AI_BAD_RESPONSE")
}
today := time.Now().Format("2006-01-02")
out := make([]AITaskDraft, 0, len(list))
for _, d := range list {
d.Kind = kind
d.Title = strings.TrimSpace(d.Title)
if d.Title == "" {
continue
}
if r := []rune(d.Title); len(r) > 120 {
d.Title = string(r[:120])
}
if d.Priority != "low" && d.Priority != "medium" && d.Priority != "high" {
d.Priority = "medium"
}
if kind == "ticket" {
switch d.Type {
case "feature", "bug", "task", "improvement":
default:
d.Type = "task"
}
d.Content = ""
if !validDateStr(d.StartAt) {
d.StartAt = today
}
if !validDateStr(d.DueAt) {
d.DueAt = time.Now().AddDate(0, 0, 3).Format("2006-01-02")
}
if d.DueAt < d.StartAt {
d.DueAt = d.StartAt
}
} else {
d.Type, d.Description, d.StartAt = "", "", ""
d.DueAt = normalizeTodoDue(d.DueAt)
}
out = append(out, d)
if len(out) >= 20 {
break
}
}
if len(out) == 0 {
return nil, errors.New("AI_EMPTY_RESULT")
}
return out, nil
}
func validDateStr(s string) bool {
_, e := time.Parse("2006-01-02", strings.TrimSpace(s))
return e == nil
}
// normalizeTodoDue 接受 YYYY-MM-DDTHH:MM 或纯日期(补 18:00其余返回空。
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
}
if validDateStr(s) {
return s + "T18:00"
}
return ""
}