更新若干功能

This commit is contained in:
李琦
2026-08-14 07:52:01 +08:00
parent 153c7ed448
commit 89bc265f68
196 changed files with 17675 additions and 0 deletions

View File

@@ -0,0 +1,44 @@
---
description: Windows 构建与打包规范:必须用 wails3 task禁止裸 go build 出包
alwaysApply: true
---
# 构建与打包Windows
## 出包必须用官方任务,禁止裸 `go build`
```powershell
# ✅ 正确:产出 bin/code-count.exe带图标、版本信息、无控制台窗口
wails3 task windows:build
# ✅ 安装包NSIS内部会先执行上面的 build
wails3 task package
# ❌ 错误:裸 go build 出的 exe 没有图标、双击会闪控制台黑框
go build -o bin/code-count.exe .
```
原因:图标不是自动带上的。官方任务先执行 `wails3 generate syso`,把
`build/windows/icon.ico` + `build/windows/info.json`(版本信息) +
`wails.exe.manifest` 编成 `wails_windows_amd64.syso` 链接进 exe,
构建完会删除 `*.syso`,所以仓库里平时看不到这个文件。
production 构建还带 `-tags production -ldflags "-w -s -H windowsgui"`。
裸 `go build` 仅可用于快速验证编译通过(如 `go vet` / `go test` 前置检查),
产物不得交付给用户。
## 换 logo 的流程
1. 替换 `build/appicon.png`(源头,正方形 PNG
2. 跑 `wails3 task common:generate:icons` 重新生成 `build/windows/icon.ico`
与 `build/darwin/icons.icns`(该任务按 appicon.png 的 checksum 缓存,
没改动会显示 up to date
3. 重新 `wails3 task windows:build`。
4. 验证:`powershell -ExecutionPolicy Bypass -File tools\extract-icon.ps1`
会从 exe 提取图标存成 `tools/exe-icon-check.png`,肉眼确认。
5. 资源管理器仍显示旧图标属 Windows 图标缓存问题,改名或重启 explorer 即可。
## 开发运行
- 热更开发:`wails3 dev -config ./build/config.yml -port 9245`(即 `wails3 task dev`)。
- 只改前端时,`wails3 task windows:build` 会自动 npm build 并嵌入,无需手动。

View File

@@ -0,0 +1 @@
cedbecabd994aac478c9127719ea7b09

View File

@@ -0,0 +1 @@
b49e83b187f47c4d3f792713163199ef

View File

@@ -0,0 +1 @@
4fa5aa6193227681772cfc8412c2ad90

View File

@@ -0,0 +1 @@
b49e83b187f47c4d3f792713163199ef

View File

@@ -0,0 +1 @@
5d6a0612785e17429ea40e69d09a5b14

37
Taskfile.yml Normal file
View File

@@ -0,0 +1,37 @@
version: '3'
vars:
APP_NAME: "code-count"
BIN_DIR: "bin"
PACKAGE_MANAGER: '{{.PACKAGE_MANAGER | default "npm"}}'
VITE_PORT: '{{.WAILS_VITE_PORT | default 9245}}'
# Target OS for build/package/run. Defaults to the host OS, and is overridden
# by `wails3 build GOOS=...` (or the GOOS env var) for cross-compilation.
GOOS: '{{.GOOS | default OS}}'
includes:
common: ./build/Taskfile.yml
windows: ./build/windows/Taskfile.yml
darwin: ./build/darwin/Taskfile.yml
linux: ./build/linux/Taskfile.yml
tasks:
build:
summary: Builds the application
cmds:
- task: "{{.GOOS}}:build"
package:
summary: Packages a production build of the application
cmds:
- task: "{{.GOOS}}:package"
run:
summary: Runs the application
cmds:
- task: "{{.GOOS}}:run"
dev:
summary: Runs the application in development mode
cmds:
- wails3 dev -config ./build/config.yml -port {{.VITE_PORT}}

803
ai.go Normal file
View File

@@ -0,0 +1,803 @@
package main
// ai.go 实现 AI 分析编排:会话/消息存储、场景化提示词构建、
// 通过 service/ai 工厂创建 Provider 并以 "ai:stream" 事件流式推送回复。
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"view/service/ai"
)
// ---------- 会话与消息存储 ----------
func (s *Store) ListAIConversations(projectID int64) ([]AIConversation, error) {
q := `SELECT c.id,c.project_id,COALESCE(p.name,''),c.provider,c.title,c.created_at,c.updated_at
FROM ai_conversations c LEFT JOIN projects p ON p.id=c.project_id`
args := []any{}
if projectID > 0 {
q += ` WHERE c.project_id=?`
args = append(args, projectID)
}
q += ` ORDER BY c.updated_at DESC LIMIT 200`
rows, e := s.db.Query(q, args...)
if e != nil {
return nil, e
}
defer rows.Close()
out := []AIConversation{}
for rows.Next() {
var x AIConversation
if e = rows.Scan(&x.ID, &x.ProjectID, &x.ProjectName, &x.Provider, &x.Title, &x.CreatedAt, &x.UpdatedAt); e != nil {
return nil, e
}
out = append(out, x)
}
return out, rows.Err()
}
func (s *Store) CreateAIConversation(projectID int64, provider, title string) (AIConversation, error) {
now := nowRFC()
res, e := s.db.Exec(`INSERT INTO ai_conversations(project_id,provider,title,created_at,updated_at) VALUES(?,?,?,?,?)`,
projectID, provider, title, now, now)
if e != nil {
return AIConversation{}, e
}
id, _ := res.LastInsertId()
var name string
_ = s.db.QueryRow(`SELECT name FROM projects WHERE id=?`, projectID).Scan(&name)
return AIConversation{ID: id, ProjectID: projectID, ProjectName: name, Provider: provider, Title: title, CreatedAt: now, UpdatedAt: now}, nil
}
func (s *Store) TouchAIConversation(id int64, provider string) {
_, _ = s.db.Exec(`UPDATE ai_conversations SET updated_at=?,provider=? WHERE id=?`, nowRFC(), provider, id)
}
func (s *Store) DeleteAIConversation(id int64) error {
if _, e := s.db.Exec(`DELETE FROM ai_messages WHERE conversation_id=?`, id); e != nil {
return e
}
_, e := s.db.Exec(`DELETE FROM ai_conversations WHERE id=?`, id)
return e
}
func (s *Store) ListAIMessages(conversationID int64) ([]AIMessage, error) {
rows, e := s.db.Query(`SELECT id,conversation_id,role,content,created_at FROM ai_messages WHERE conversation_id=? ORDER BY id`, conversationID)
if e != nil {
return nil, e
}
defer rows.Close()
out := []AIMessage{}
for rows.Next() {
var x AIMessage
if e = rows.Scan(&x.ID, &x.ConversationID, &x.Role, &x.Content, &x.CreatedAt); e != nil {
return nil, e
}
out = append(out, x)
}
return out, rows.Err()
}
func (s *Store) AddAIMessage(conversationID int64, role, content string) (AIMessage, error) {
now := nowRFC()
res, e := s.db.Exec(`INSERT INTO ai_messages(conversation_id,role,content,created_at) VALUES(?,?,?,?)`, conversationID, role, content, now)
if e != nil {
return AIMessage{}, e
}
id, _ := res.LastInsertId()
return AIMessage{ID: id, ConversationID: conversationID, Role: role, Content: content, CreatedAt: now}, nil
}
// ---------- 绑定接口 ----------
func (a *App) ListAIConversations(projectID int64) ([]AIConversation, error) {
if e := a.ready(); e != nil {
return nil, e
}
return a.store.ListAIConversations(projectID)
}
func (a *App) GetAIMessages(conversationID int64) ([]AIMessage, error) {
if e := a.ready(); e != nil {
return nil, e
}
return a.store.ListAIMessages(conversationID)
}
func (a *App) DeleteAIConversation(id int64) error {
if e := a.ready(); e != nil {
return e
}
a.stopAIStream(id)
return a.store.DeleteAIConversation(id)
}
// StopAIStream 中止指定会话正在进行的流式回复。
func (a *App) StopAIStream(conversationID int64) error {
a.stopAIStream(conversationID)
return nil
}
func (a *App) stopAIStream(conversationID int64) {
a.mu.Lock()
cancel := a.aiStreams[conversationID]
delete(a.aiStreams, conversationID)
a.mu.Unlock()
if cancel != nil {
cancel()
}
}
// aiStreamEvent 是 "ai:stream" 事件载荷。
type aiStreamEvent struct {
ConversationID int64 `json:"conversationId"`
Delta string `json:"delta,omitempty"`
Done bool `json:"done,omitempty"`
Error string `json:"error,omitempty"`
MessageID int64 `json:"messageId,omitempty"`
}
// SendAIMessage 发送一条用户消息并启动流式回复。
// conversationID 为 0 时自动创建会话scenario: chat | project | git | todo | ticket。
func (a *App) SendAIMessage(conversationID, projectID int64, scenario, content string) (AIConversation, error) {
if e := a.ready(); e != nil {
return AIConversation{}, e
}
content = strings.TrimSpace(content)
if content == "" {
return AIConversation{}, errors.New("AI_EMPTY_MESSAGE")
}
st, e := a.store.Settings()
if e != nil {
return AIConversation{}, e
}
provider, e := a.aiProvider()
if e != nil {
return AIConversation{}, e
}
var conv AIConversation
if conversationID == 0 {
title := content
if r := []rune(title); len(r) > 40 {
title = string(r[:40]) + "…"
}
conv, e = a.store.CreateAIConversation(projectID, provider.Name(), title)
if e != nil {
return AIConversation{}, e
}
} else {
convs, _ := a.store.ListAIConversations(0)
for _, c := range convs {
if c.ID == conversationID {
conv = c
break
}
}
if conv.ID == 0 {
return AIConversation{}, errors.New("AI_CONVERSATION_NOT_FOUND")
}
projectID = conv.ProjectID
}
a.mu.Lock()
if _, running := a.aiStreams[conv.ID]; running {
a.mu.Unlock()
return conv, errors.New("AI_STREAM_RUNNING")
}
ctx, cancel := context.WithCancel(a.ctx)
a.aiStreams[conv.ID] = cancel
a.mu.Unlock()
// 组装消息:场景系统提示词 + 最近历史 + 本条用户消息。
history, _ := a.store.ListAIMessages(conv.ID)
msgs := []ai.Message{{Role: "system", Content: a.buildAIContext(projectID, scenario, st.Locale)}}
if len(history) > 20 {
history = history[len(history)-20:]
}
for _, m := range history {
msgs = append(msgs, ai.Message{Role: m.Role, Content: m.Content})
}
msgs = append(msgs, ai.Message{Role: "user", Content: content})
if _, e = a.store.AddAIMessage(conv.ID, "user", content); e != nil {
a.stopAIStream(conv.ID)
return conv, e
}
a.store.TouchAIConversation(conv.ID, provider.Name())
go a.runAIStream(ctx, provider, conv.ID, msgs)
return conv, nil
}
// runAIStream 消费 Provider 流并转发到前端,结束后落库助手回复。
func (a *App) runAIStream(ctx context.Context, provider ai.Provider, convID int64, msgs []ai.Message) {
defer a.stopAIStream(convID)
stream, e := provider.ChatStream(ctx, msgs)
if e != nil {
a.store.Log("error", "AI", "AI 请求失败", e.Error())
a.emit("ai:stream", aiStreamEvent{ConversationID: convID, Error: e.Error(), Done: true})
return
}
var sb strings.Builder
for chunk := range stream {
if chunk.Err != nil {
// 已有部分内容则保留落库,方便用户继续。
if sb.Len() > 0 {
if m, e2 := a.store.AddAIMessage(convID, "assistant", sb.String()); e2 == nil {
a.emit("ai:stream", aiStreamEvent{ConversationID: convID, Error: chunk.Err.Error(), Done: true, MessageID: m.ID})
return
}
}
a.emit("ai:stream", aiStreamEvent{ConversationID: convID, Error: chunk.Err.Error(), Done: true})
return
}
sb.WriteString(chunk.Content)
a.emit("ai:stream", aiStreamEvent{ConversationID: convID, Delta: chunk.Content})
}
if ctx.Err() != nil && sb.Len() == 0 {
a.emit("ai:stream", aiStreamEvent{ConversationID: convID, Error: "AI_CANCELLED", Done: true})
return
}
m, e := a.store.AddAIMessage(convID, "assistant", sb.String())
if e != nil {
a.emit("ai:stream", aiStreamEvent{ConversationID: convID, Error: e.Error(), Done: true})
return
}
a.store.TouchAIConversation(convID, provider.Name())
a.emit("ai:stream", aiStreamEvent{ConversationID: convID, Done: true, MessageID: m.ID})
}
// ---------- 模块 AI 介绍(分析完成后异步生成,按项目+模块存最新一份) ----------
func (s *Store) GetAISummaries(projectID int64) ([]AISummary, error) {
// projectID=0 表示工作台全局简报(今日规划/下班日报),存于 day_briefs。
q, args := `SELECT project_id,kind,content,provider,generated_at FROM ai_summaries WHERE project_id=?`, []any{projectID}
if projectID == 0 {
q, args = `SELECT 0,kind,content,provider,generated_at FROM day_briefs`, nil
}
rows, e := s.db.Query(q, args...)
if e != nil {
return nil, e
}
defer rows.Close()
out := []AISummary{}
for rows.Next() {
var x AISummary
if e = rows.Scan(&x.ProjectID, &x.Kind, &x.Content, &x.Provider, &x.GeneratedAt); e != nil {
return nil, e
}
// 兜底清理历史坏数据:早期版本可能存入了带围栏的原始回答。
x.Content = stripFence(x.Content)
out = append(out, x)
}
return out, rows.Err()
}
func (s *Store) SaveAISummary(projectID int64, kind, provider, content string) error {
if projectID == 0 {
_, e := s.db.Exec(`INSERT INTO day_briefs(kind,provider,content,generated_at) VALUES(?,?,?,?)
ON CONFLICT(kind) DO UPDATE SET content=excluded.content,provider=excluded.provider,generated_at=excluded.generated_at`,
kind, provider, content, nowRFC())
return e
}
_, e := s.db.Exec(`INSERT INTO ai_summaries(project_id,kind,provider,content,generated_at) VALUES(?,?,?,?,?)
ON CONFLICT(project_id,kind) DO UPDATE SET content=excluded.content,provider=excluded.provider,generated_at=excluded.generated_at`,
projectID, kind, provider, content, nowRFC())
return e
}
func (a *App) GetAISummaries(projectID int64) ([]AISummary, error) {
if e := a.ready(); e != nil {
return nil, e
}
return a.store.GetAISummaries(projectID)
}
// RegenerateAISummary 手动重新生成某个模块的 AI 介绍(后台执行,完成后发 ai:summary 事件)。
func (a *App) RegenerateAISummary(projectID int64, kind string) error {
if e := a.ready(); e != nil {
return e
}
if kind != "project" && kind != "git" && kind != "structure" && kind != "insights" && kind != "dayplan" && kind != "dayreport" && !scopeSummaryKinds[kind] {
return errors.New("AI_SUMMARY_KIND_INVALID")
}
if _, e := a.aiProvider(); e != nil {
return e
}
go a.generateAISummaries(projectID, kind)
return nil
}
// aiProvider 按当前设置创建 Provider未配置 Key 时返回 AI_NO_KEY。
func (a *App) aiProvider() (ai.Provider, error) {
st, e := a.store.Settings()
if e != nil {
return nil, e
}
key := st.SparkKey
if st.AIProvider == ai.ProviderDeepSeek {
key = st.DeepSeekKey
}
return ai.New(st.AIProvider, key)
}
// aiSummarySem 全局串行生成,避免批量分析后并发打爆免费额度接口。
var aiSummarySem = make(chan struct{}, 1)
// aiSummaryEvent 是 "ai:summary" 事件载荷。
type aiSummaryEvent struct {
ProjectID int64 `json:"projectId"`
Kind string `json:"kind"`
Error string `json:"error,omitempty"`
}
// stripFence 去掉包裹全文的代码围栏(模型偶尔把整段回答包进 ```markdown 中,
// 甚至只输出开头围栏忘了闭合——这种孤立围栏也要剥掉,否则整段被渲染成代码块)。
func stripFence(s string) string {
if !strings.HasPrefix(s, "```") {
return s
}
lines := strings.Split(s, "\n")
last := len(lines) - 1
for last > 0 && strings.TrimSpace(lines[last]) == "" {
last--
}
if last >= 1 && strings.TrimSpace(lines[last]) == "```" {
return strings.TrimSpace(strings.Join(lines[1:last], "\n"))
}
return strings.TrimSpace(strings.Join(lines[1:], "\n"))
}
// dedentCommon 去除各行公共的行首空白,避免模型输出整体缩进被 Markdown 误判为代码块。
func dedentCommon(s string) string {
lines := strings.Split(s, "\n")
common := -1
for _, ln := range lines {
t := strings.TrimLeft(ln, " \t")
if t == "" {
continue
}
n := len(ln) - len(t)
if common < 0 || n < common {
common = n
}
if common == 0 {
return s
}
}
if common <= 0 {
return s
}
for i, ln := range lines {
if len(ln) >= common {
lines[i] = ln[common:]
} else {
lines[i] = strings.TrimLeft(ln, " \t")
}
}
return strings.Join(lines, "\n")
}
// summaryInstruction 摘要指令:与聊天场景共用数据上下文,但任务改为生成简介。
func summaryInstruction(kind, locale string) string {
if s, ok := scopeInstruction(kind, locale); ok {
return s
}
if locale == "en" {
switch kind {
case "git":
return "Based on the data above, write a concise Git overview of this project (within 120 words): collaboration structure, activity trend and risky hotspots. Output Markdown text only, no headings."
case "structure":
return "Based on the data above, write a concise overview of this project's directory layout (within 120 words): module organization, size distribution and large-file risks. Output Markdown text only, no headings."
case "insights":
return "Based on the data above, write a concise health-check summary (within 120 words): health score, main issue types and what to fix first. Output Markdown text only, no headings."
case "dayplan":
return "Based on the todos and tickets above, plan my work day: sort by priority and due time, split into Morning and Afternoon, one line per item with a short reason (overdue, due today, high priority); end with one line about the biggest risk. Output a Markdown list, no big headings."
case "dayreport":
return "Based on today's activity above, write a first-person end-of-day report (one paragraph, 100-200 words, ready to paste into a work chat): what I completed, what is in progress, and what carries over to tomorrow. Output plain text, no headings or lists."
}
return "Based on the data above, write a concise project introduction (within 120 words): what it is, tech stack, scale and code health. Output Markdown text only, no headings."
}
switch kind {
case "git":
return "基于以上数据,用不超过 150 字写一段该项目的 Git 协作介绍:贡献结构、活跃趋势、高风险热点。直接输出 Markdown 正文,不要标题。"
case "structure":
return "基于以上数据,用不超过 150 字介绍该项目的目录结构与文件组织:模块划分、体积分布、大文件风险。直接输出 Markdown 正文,不要标题。"
case "insights":
return "基于以上数据,用不超过 150 字总结该项目的健康检查结果:健康分、主要问题类型与整改优先级。直接输出 Markdown 正文,不要标题。"
case "dayplan":
return "基于以上待办与工单,为我制定今天的工作规划:按优先级和截止时间排序,分【上午】【下午】两个时段,每项一行并注明理由(如已逾期、今天截止、高优先级);最后用一行提示最需要警惕的风险。只能引用上面列出的条目,不要编造任务。直接输出 Markdown 列表,不要大标题。"
case "dayreport":
return "基于以上今天的工作记录,用第一人称写一段 100~200 字的下班日报(一个自然段,适合直接粘贴到工作群):概述今天完成了哪些事项、推进中的事项,以及遗留或需要明天跟进的内容。只能引用上面列出的记录,不要编造。直接输出正文,不要标题和列表。"
}
return "基于以上数据,用不超过 150 字写一段项目介绍:项目定位、技术栈、代码规模与质量状况。直接输出 Markdown 正文,不要标题。"
}
// staticDayBrief 数据为空时的固定文案:避免模型在无数据时凭空编造任务。
func (a *App) staticDayBrief(kind, locale string) (string, bool) {
switch kind {
case "dayplan":
todos, _ := a.store.ListTodos("all", 0)
tickets, _ := a.store.ListTickets("all", 0)
for _, x := range todos {
if x.Status != "done" {
return "", false
}
}
for _, x := range tickets {
if x.Status != "resolved" && x.Status != "closed" {
return "", false
}
}
if locale == "en" {
return "No open todos or tickets today — enjoy the free time or plan something new.", true
}
return "今天没有待处理的待办或工单,可以安排一些新的计划,或者好好休息一下。", true
case "dayreport":
today := time.Now().Format("2006-01-02")
todos, _ := a.store.ListTodos("all", 0)
tickets, _ := a.store.ListTickets("all", 0)
hasEvent := func(history string) bool {
var list []histEntry
if json.Unmarshal([]byte(history), &list) != nil {
return false
}
for _, h := range list {
if ts, e := time.Parse(time.RFC3339, h.At); e == nil && ts.Local().Format("2006-01-02") == today {
return true
}
}
return false
}
for _, x := range todos {
if hasEvent(x.History) {
return "", false
}
}
for _, x := range tickets {
if hasEvent(x.History) {
return "", false
}
}
if locale == "en" {
return "No todo or ticket activity recorded today, so there is nothing to report yet.", true
}
return "今天还没有记录到待办或工单的状态变化,暂时没有可总结的内容。", true
}
return "", false
}
// generateAISummaries 依次为指定模块生成 AI 介绍并落库;未配置 Key 时静默跳过。
func (a *App) generateAISummaries(projectID int64, kinds ...string) {
provider, e := a.aiProvider()
if e != nil {
return
}
st, _ := a.store.Settings()
aiSummarySem <- struct{}{}
defer func() { <-aiSummarySem }()
for _, kind := range kinds {
// 无数据的全局简报直接落固定文案,不浪费一次注定编造的模型调用。
if content, ok := a.staticDayBrief(kind, st.Locale); ok {
if e := a.store.SaveAISummary(projectID, kind, "system", content); e == nil {
a.emit("ai:summary", aiSummaryEvent{ProjectID: projectID, Kind: kind})
}
continue
}
msgs := []ai.Message{
{Role: "system", Content: a.buildAIContext(projectID, kind, st.Locale)},
{Role: "user", Content: summaryInstruction(kind, st.Locale)},
}
ctx, cancel := context.WithTimeout(a.ctx, 2*time.Minute)
stream, se := provider.ChatStream(ctx, msgs)
if se != nil {
cancel()
a.store.Log("warning", "AI", "AI 模块介绍生成失败", se.Error())
a.emit("ai:summary", aiSummaryEvent{ProjectID: projectID, Kind: kind, Error: se.Error()})
continue
}
var sb strings.Builder
var streamErr error
for chunk := range stream {
if chunk.Err != nil {
streamErr = chunk.Err
break
}
sb.WriteString(chunk.Content)
}
cancel()
if streamErr != nil || sb.Len() == 0 {
msg := "empty response"
if streamErr != nil {
msg = streamErr.Error()
}
a.store.Log("warning", "AI", "AI 模块介绍生成失败", msg)
a.emit("ai:summary", aiSummaryEvent{ProjectID: projectID, Kind: kind, Error: msg})
continue
}
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
}
a.store.Log("info", "AI", "AI 模块介绍已更新", fmt.Sprintf("project=%d kind=%s", projectID, kind))
a.emit("ai:summary", aiSummaryEvent{ProjectID: projectID, Kind: kind})
}
}
// ---------- 场景化提示词 ----------
// dayEvent 是今天发生的一次状态变化(用于下班日报)。
type dayEvent struct {
kind, title, project, status, at string
}
// writeDayContext 聚合“今天”的待办/工单数据dayplan 看待处理项dayreport 看今天的轨迹)。
func (a *App) writeDayContext(b *strings.Builder, scenario string) {
now := time.Now()
weekdays := []string{"周日", "周一", "周二", "周三", "周四", "周五", "周六"}
today := now.Format("2006-01-02")
fmt.Fprintf(b, "\n## 今天\n- 日期:%s%s现在时间 %s\n", today, weekdays[int(now.Weekday())], now.Format("15:04"))
todos, _ := a.store.ListTodos("all", 0)
tickets, _ := a.store.ListTickets("all", 0)
if scenario == "dayplan" {
b.WriteString("\n## 待处理的待办\n")
n := 0
for _, x := range todos {
if x.Status == "done" || n >= 25 {
continue
}
n++
fmt.Fprintf(b, "- [%s|%s] %s截止 %s项目 %s%s\n", x.Status, x.Priority, x.Title, orDash(x.DueAt), orDash(x.ProjectName), dueFlag(x.DueAt, today))
}
if n == 0 {
b.WriteString("- 无\n")
}
b.WriteString("\n## 待处理的工单\n")
n = 0
for _, x := range tickets {
if x.Status == "resolved" || x.Status == "closed" || n >= 25 {
continue
}
n++
fmt.Fprintf(b, "- [%s|%s] %s截止 %s项目 %s%s\n", x.Status, x.Priority, x.Title, orDash(x.DueAt), orDash(x.ProjectName), dueFlag(x.DueAt, today))
}
if n == 0 {
b.WriteString("- 无\n")
}
return
}
// dayreport从生命周期轨迹里筛出今天发生的状态变化
var events []dayEvent
collect := func(kind, title, project, history string) {
var list []histEntry
if json.Unmarshal([]byte(history), &list) != nil {
return
}
for _, h := range list {
ts, e := time.Parse(time.RFC3339, h.At)
if e != nil || ts.Local().Format("2006-01-02") != today {
continue
}
events = append(events, dayEvent{kind: kind, title: title, project: project, status: h.Status, at: ts.Local().Format("15:04")})
}
}
for _, x := range todos {
collect("待办", x.Title, x.ProjectName, x.History)
}
for _, x := range tickets {
collect("工单", x.Title, x.ProjectName, x.History)
}
b.WriteString("\n## 今天的工作记录(按状态变化)\n")
if len(events) == 0 {
b.WriteString("- 今天没有记录到状态变化\n")
}
for i, ev := range events {
if i >= 40 {
break
}
fmt.Fprintf(b, "- %s %s「%s」→ %s项目 %s\n", ev.at, ev.kind, ev.title, ev.status, orDash(ev.project))
}
b.WriteString("\n状态说明open=新建/待开始doing/in_progress=开始处理done/resolved=完成closed=关闭。\n")
}
func orDash(s string) string {
if strings.TrimSpace(s) == "" {
return "无"
}
return s
}
// dueFlag 给截止时间补充“已逾期/今天截止”标记,帮助模型排优先级。
func dueFlag(due, today string) string {
if due == "" {
return ""
}
d := due
if len(d) > 10 {
d = d[:10]
}
switch {
case d < today:
return "【已逾期】"
case d == today:
return "【今天截止】"
}
return ""
}
// buildAIContext 按场景聚合项目数据生成系统提示词。
func (a *App) buildAIContext(projectID int64, scenario, locale string) string {
var b strings.Builder
if locale == "en" {
b.WriteString("You are the built-in AI analyst of 年糕崽崽 PMS, a local code statistics and project workbench app. Answer in English, be specific and actionable, use Markdown.\n")
} else {
b.WriteString("你是代码统计与项目工作台应用「年糕崽崽 PMS」的内置 AI 分析师。请用中文回答,结论要具体、可执行,使用 Markdown 排版。\n")
}
// 工作台全局场景:不依赖具体项目,聚合今天的待办与工单。
if scenario == "dayplan" || scenario == "dayreport" {
a.writeDayContext(&b, scenario)
return b.String()
}
// 页面级全局总结:启动台 / 日志 / 日历 / 配置 / 笔记。
if scopeSummaryKinds[scenario] {
a.writeScopeContext(&b, scenario)
return b.String()
}
if projectID <= 0 {
return b.String()
}
p, e := a.store.GetProject(projectID)
if e != nil || p.ID == 0 {
return b.String()
}
fmt.Fprintf(&b, "\n## 项目基础信息\n- 名称: %s\n- 路径: %s\n- 描述: %s\n- 代码行: %d代码 %d / 注释 %d / 空行 %d\n- 文件数: %d\n- 提交数: %d贡献者: %d\n- 最近分析: %s\n",
p.Name, p.Path, p.Description, p.Stats.TotalLines, p.Stats.CodeLines, p.Stats.CommentLines, p.Stats.BlankLines, p.Stats.FileCount, p.Stats.CommitCount, p.Stats.ContributorCount, p.Stats.LastAnalyzed)
if len(p.Languages) > 0 {
b.WriteString("\n## 语言分布\n")
for i, l := range p.Languages {
if i >= 12 {
break
}
fmt.Fprintf(&b, "- %s: %d 文件, 代码 %d 行, 注释 %d 行\n", l.Name, l.Files, l.Code, l.Comments)
}
}
switch scenario {
case "project":
if st, e := a.store.Structure(projectID); e == nil {
b.WriteString("\n## 目录结构概览\n")
for i, f := range st.Folders {
if i >= 10 {
break
}
fmt.Fprintf(&b, "- 目录 %s: %d 文件, %d 字节\n", f.Name, f.Files, f.Size)
}
for i, f := range st.LargeFiles {
if i >= 8 {
break
}
fmt.Fprintf(&b, "- 大文件 %s: %d 字节\n", f.Path, f.Size)
}
}
if ins, e := a.store.Insights(projectID); e == nil && len(ins.Issues) > 0 {
fmt.Fprintf(&b, "\n## 静态洞察(健康分 %d\n", ins.HealthScore)
for i, x := range ins.Issues {
if i >= 12 {
break
}
fmt.Fprintf(&b, "- [%s] %s%s%s\n", x.Severity, x.Title, x.Detail, x.Path)
}
}
b.WriteString("\n任务基于以上数据分析该项目的技术栈、代码规模与质量风险并给出改进建议。\n")
case "git":
if g, e := a.store.GitStats(projectID); e == nil {
fmt.Fprintf(&b, "\n## Git 概览\n- 分支: %s提交 %d+%d/-%d 行\n", g.CurrentBranch, g.CommitCount, g.Added, g.Deleted)
b.WriteString("\n## 贡献者\n")
for i, c := range g.Contributors {
if i >= 10 {
break
}
fmt.Fprintf(&b, "- %s: %d 次提交, +%d/-%d\n", c.Name, c.Commits, c.Added, c.Deleted)
}
b.WriteString("\n## 最近提交\n")
for i, c := range g.Commits {
if i >= 15 {
break
}
fmt.Fprintf(&b, "- %s %s%s\n", c.Date, c.Message, c.Author)
}
if len(g.Hotspots) > 0 {
b.WriteString("\n## 高频变更文件\n")
for i, h := range g.Hotspots {
if i >= 10 {
break
}
fmt.Fprintf(&b, "- %s: %d 次变更, +%d/-%d\n", h.Path, h.Changes, h.Added, h.Deleted)
}
}
}
b.WriteString("\n任务分析该项目的 Git 贡献情况:贡献结构是否健康、活跃度趋势、高风险热点文件,并给出协作改进建议。\n")
case "structure":
if st, e := a.store.Structure(projectID); e == nil {
fmt.Fprintf(&b, "\n## 目录结构\n- 总文件 %d总目录 %d总大小 %d 字节\n", st.TotalFiles, st.TotalDirs, st.TotalSize)
for i, f := range st.Folders {
if i >= 14 {
break
}
fmt.Fprintf(&b, "- 目录 %s: %d 文件, %d 字节\n", f.Name, f.Files, f.Size)
}
if len(st.LargeFiles) > 0 {
b.WriteString("\n## 大文件\n")
for i, f := range st.LargeFiles {
if i >= 10 {
break
}
fmt.Fprintf(&b, "- %s: %d 字节\n", f.Path, f.Size)
}
}
if len(st.Extensions) > 0 {
b.WriteString("\n## 扩展名分布\n")
for i, x := range st.Extensions {
if i >= 12 {
break
}
fmt.Fprintf(&b, "- %s: %d 文件, %d 字节\n", x.Extension, x.Files, x.Size)
}
}
}
b.WriteString("\n任务分析该项目的目录组织与模块划分是否合理指出体积集中点与大文件风险并给出精简建议。\n")
case "insights":
ins, e := a.store.Insights(projectID)
if e != nil {
// 从未做过检查时现算一次,避免 AI 在无数据下臆造
ins, e = a.RefreshProjectInsights(projectID)
}
if e == nil {
fmt.Fprintf(&b, "\n## 静态检查(健康分 %d\n- 高危 %d / 中危 %d / 低危 %dTODO 标记 %d超长文件 %d大文件 %d\n",
ins.HealthScore, ins.Summary.High, ins.Summary.Medium, ins.Summary.Low, ins.Summary.TodoCount, ins.Summary.LongFiles, ins.Summary.LargeFiles)
for i, x := range ins.Issues {
if i >= 20 {
break
}
fmt.Fprintf(&b, "- [%s|%s] %s%s%s建议%s\n", x.Severity, x.Type, x.Title, x.Detail, x.Path, x.Suggestion)
}
}
b.WriteString("\n任务解读这些检查结果整体健康状况、最需要优先处理的问题类别与整改顺序建议。\n")
case "todo":
todos, _ := a.store.ListTodos("all", projectID)
b.WriteString("\n## 项目待办\n")
if len(todos) == 0 {
b.WriteString("(暂无待办)\n")
}
for i, t := range todos {
if i >= 30 {
break
}
fmt.Fprintf(&b, "- [%s|%s] %s截止 %s%s\n", t.Status, t.Priority, t.Title, t.DueAt, t.Content)
}
b.WriteString("\n任务分析这些待办的优先级排布是否合理识别逾期风险给出本周执行顺序建议。\n")
case "ticket":
tickets, _ := a.store.ListTickets("all", projectID)
b.WriteString("\n## 项目工单\n")
if len(tickets) == 0 {
b.WriteString("(暂无工单)\n")
}
for i, t := range tickets {
if i >= 30 {
break
}
fmt.Fprintf(&b, "- [%s|%s|%s] %s%s ~ %s%s\n", t.Type, t.Status, t.Priority, t.Title, t.StartAt, t.DueAt, t.Description)
}
b.WriteString("\n任务从需求管理角度分析这些工单排期是否合理、类型分布、阻塞风险并给出处理顺序与拆解建议。\n")
}
return b.String()
}

228
ai_scope.go Normal file
View File

@@ -0,0 +1,228 @@
package main
// ai_scope.go 提供页面级 AI 总结的上下文聚合:
// 启动台 / 运行日志 / 排期日历 / 配置 / 笔记 五个全局 scope
// 结果统一存入 day_briefsprojectID=0复用 RegenerateAISummary 流程。
import (
"fmt"
"strings"
"time"
)
// scopeSummaryKinds 是页面级总结的合法 kind 集合。
var scopeSummaryKinds = map[string]bool{
"launchpad": true, "logs": true, "calendar": true, "config": true, "notes": true,
}
// writeScopeContext 按 scope 聚合当前应用数据,写入系统提示词。
func (a *App) writeScopeContext(b *strings.Builder, scope string) {
switch scope {
case "launchpad":
a.writeLaunchpadContext(b)
case "logs":
a.writeLogsContext(b)
case "calendar":
a.writeCalendarContext(b)
case "config":
a.writeConfigContext(b)
case "notes":
a.writeNotesContext(b)
}
}
func (a *App) writeLaunchpadContext(b *strings.Builder) {
entries, e := a.ListLaunchEntries()
b.WriteString("\n## 本机运行服务与已保存应用\n")
if e != nil || len(entries) == 0 {
b.WriteString("- 无\n")
return
}
n := 0
for _, x := range entries {
if n >= 40 {
break
}
n++
state := "已停止"
if x.Running {
state = "运行中"
}
saved := "扫描到"
if x.ID > 0 {
saved = "我的应用"
}
ports := x.Ports
if len(ports) == 0 && x.Port > 0 {
ports = []int{x.Port}
}
fmt.Fprintf(b, "- [%s|%s] %s类型 %s端口 %vPID %dCPU %.1f%%,内存 %.0fMBIO %.0fKB/s\n",
saved, state, x.Name, x.Kind, ports, x.PID, x.CPU, x.MemMB, x.IOKBs)
}
}
func (a *App) writeLogsContext(b *strings.Builder) {
logs, e := a.store.Logs("all")
b.WriteString("\n## 最近运行日志(新→旧)\n")
if e != nil || len(logs) == 0 {
b.WriteString("- 无\n")
return
}
counts := map[string]int{}
for _, l := range logs {
counts[l.Level]++
}
fmt.Fprintf(b, "- 总量统计:错误 %d 条,警告 %d 条,信息 %d 条\n", counts["error"], counts["warning"], counts["info"])
for i, l := range logs {
if i >= 200 {
break
}
detail := l.Detail
if r := []rune(detail); len(r) > 120 {
detail = string(r[:120]) + "…"
}
fmt.Fprintf(b, "- %s [%s|%s] %s %s\n", strings.Replace(l.CreatedAt, "T", " ", 1), l.Level, l.Category, l.Message, detail)
}
}
func (a *App) writeCalendarContext(b *strings.Builder) {
now := time.Now()
today := now.Format("2006-01-02")
limit := now.AddDate(0, 0, 30).Format("2006-01-02")
fmt.Fprintf(b, "\n## 排期视角(今天 %s展望 30 天)\n", today)
todos, _ := a.store.ListTodos("all", 0)
tickets, _ := a.store.ListTickets("all", 0)
type item struct{ kind, title, status, priority, due, project string }
var overdue, upcoming []item
push := func(kind, title, status, priority, due, project string, doneLike bool) {
if doneLike {
return
}
d := due
if len(d) > 10 {
d = d[:10]
}
it := item{kind, title, status, priority, due, project}
switch {
case d != "" && d < today:
overdue = append(overdue, it)
case d != "" && d <= limit:
upcoming = append(upcoming, it)
}
}
for _, x := range todos {
push("待办", x.Title, x.Status, x.Priority, x.DueAt, x.ProjectName, x.Status == "done")
}
for _, x := range tickets {
push("工单", x.Title, x.Status, x.Priority, x.DueAt, x.ProjectName, x.Status == "resolved" || x.Status == "closed")
}
b.WriteString("\n## 已逾期\n")
if len(overdue) == 0 {
b.WriteString("- 无\n")
}
for i, x := range overdue {
if i >= 20 {
break
}
fmt.Fprintf(b, "- [%s|%s|%s] %s截止 %s项目 %s\n", x.kind, x.status, x.priority, x.title, x.due, orDash(x.project))
}
b.WriteString("\n## 未来 30 天到期\n")
if len(upcoming) == 0 {
b.WriteString("- 无\n")
}
for i, x := range upcoming {
if i >= 40 {
break
}
fmt.Fprintf(b, "- [%s|%s|%s] %s截止 %s项目 %s\n", x.kind, x.status, x.priority, x.title, x.due, orDash(x.project))
}
}
func (a *App) writeConfigContext(b *strings.Builder) {
st, e := a.store.Settings()
if e != nil {
return
}
keyState := func(k string) string {
if strings.TrimSpace(k) != "" {
return "已配置"
}
return "未配置"
}
b.WriteString("\n## 当前应用配置\n")
fmt.Fprintf(b, "- 界面:主题 %s语言 %s加载动画 %s玻璃透明度 %d%%\n", st.Theme, st.Locale, st.LoadingStyle, st.GlassOpacity)
fmt.Fprintf(b, "- 统计Git 统计范围 %s列表自动刷新 %v\n", st.GitScope, st.AutoRefresh)
fmt.Fprintf(b, "- 系统集成:关闭窗口最小化到托盘 %v\n", st.MinimizeToTray)
fmt.Fprintf(b, "- 自动更新统计:开启 %v模式 %s间隔 %d触发时间 %s\n", st.AutoUpdateEnabled, st.AutoUpdateMode, st.AutoUpdateInterval, st.AutoUpdateTime)
fmt.Fprintf(b, "- AI当前服务商 %s星火 Key %sDeepSeek Key %sKey 云同步 %v\n", st.AIProvider, keyState(st.SparkKey), keyState(st.DeepSeekKey), st.SyncAPIKeys)
fmt.Fprintf(b, "- 图片存储模式:%s\n", st.ImageMode)
if rules, e := a.store.Rules(); e == nil {
custom := 0
for _, r := range rules {
if !r.Builtin {
custom++
}
}
fmt.Fprintf(b, "- 排除规则:共 %d 条(自定义 %d 条)\n", len(rules), custom)
}
if projects, e := a.store.ListProjects(0); e == nil {
fmt.Fprintf(b, "- 项目:共 %d 个\n", len(projects))
}
if a.syncUserID() > 0 {
fmt.Fprintf(b, "- 云同步:已登录(%s\n", a.store.Meta("sync_username"))
} else {
b.WriteString("- 云同步:未登录\n")
}
}
func (a *App) writeNotesContext(b *strings.Builder) {
notes, e := a.store.ListNotes(200)
b.WriteString("\n## 全部笔记(新→旧)\n")
if e != nil || len(notes) == 0 {
b.WriteString("- 无\n")
return
}
for i, n := range notes {
if i >= 100 {
break
}
content := strings.TrimSpace(n.Content)
if r := []rune(content); len(r) > 400 {
content = string(r[:400]) + "…"
}
fmt.Fprintf(b, "\n### 笔记 %d更新于 %s\n%s\n", i+1, strings.Replace(n.UpdatedAt, "T", " ", 1), content)
}
}
// scopeInstruction 页面级总结指令(与 summaryInstruction 同构,按 locale 切换)。
func scopeInstruction(kind, locale string) (string, bool) {
if locale == "en" {
switch kind {
case "launchpad":
return "Based on the services above, summarize what is running on this machine (within 200 words): notable resource hogs, suspicious or duplicated services, and cleanup suggestions. Output a short Markdown list.", true
case "logs":
return "Based on the logs above, summarize recent application activity (within 200 words): recurring errors or warnings with likely causes, notable events, and suggested follow-ups. Output a short Markdown list.", true
case "calendar":
return "Based on the schedule above, summarize the coming month (within 200 words): overdue items to rescue first, busy periods, and scheduling advice. Only reference listed items. Output a short Markdown list.", true
case "config":
return "Based on the configuration above, review the app setup (within 200 words): risky or missing settings (e.g. AI keys, auto update, sync), and concrete tuning suggestions. Output a short Markdown list.", true
case "notes":
return "Based on the notes above, produce a digest (within 200 words): main themes, actionable items hidden in notes, and anything worth converting into todos. Only reference listed notes. Output a short Markdown list.", true
}
return "", false
}
switch kind {
case "launchpad":
return "基于以上服务清单,用不超过 250 字总结本机运行情况:资源占用突出的服务、可疑或重复的进程、值得关停或固化为常用应用的建议。直接输出 Markdown 列表,不要大标题。", true
case "logs":
return "基于以上日志,用不超过 250 字总结应用近期运行状况:反复出现的错误/警告及可能原因、值得关注的事件、建议的后续动作。直接输出 Markdown 列表,不要大标题。", true
case "calendar":
return "基于以上排期数据,用不超过 250 字总结未来一个月的日程:需要优先补救的逾期项、任务密集的时间段、排期调整建议。只能引用上面列出的条目,不要编造。直接输出 Markdown 列表,不要大标题。", true
case "config":
return "基于以上配置,用不超过 250 字点评当前应用设置:存在风险或缺失的配置(如 AI Key、自动统计、云同步以及具体的调优建议。直接输出 Markdown 列表,不要大标题。", true
case "notes":
return "基于以上笔记,用不超过 250 字生成一份笔记摘要:主要主题、笔记里隐藏的待办事项、值得转成正式待办的内容。只能引用上面列出的笔记,不要编造。直接输出 Markdown 列表,不要大标题。", true
}
return "", false
}

168
avatar.go Normal file
View File

@@ -0,0 +1,168 @@
package main
// avatar.go 实现用户头像的几种来源:
// base64选本地图缩放后存 dataURL可随账号同步、urlOSS/图床直链、path本地路径仅本机显示
// 当管理员把全局文件存储方式配成 server 时,选图走 server 模式:缩放后上传 nl-pms-api
// 以 url 模式保存返回的 http URL显示与同步复用现有 url 逻辑)。
import (
"bytes"
"encoding/base64"
"errors"
"fmt"
"image"
_ "image/gif"
"image/jpeg"
"image/png"
"os"
"strings"
_ "golang.org/x/image/webp"
"github.com/wailsapp/wails/v3/pkg/application"
"golang.org/x/image/draw"
)
const (
avatarMaxFileBytes = 10 << 20 // 原始文件上限 10MB
avatarMaxEdge = 256 // 存储/显示的最长边
)
// PickAvatarImage 打开图片选择框。mode=auto推荐入口由管理员的全局文件存储
// 配置决定走向server 时缩放后上传 nl-pms-api 以 url 模式保存,否则存 base64。
// 兼容旧 mode 取值base64 存 dataURL、path 存本地路径、server 强制上传。
// 返回的 Mode 是实际采用的存储模式Preview 一律为可立即显示的 dataURL。
// 用户取消选择时返回零值。
func (a *App) PickAvatarImage(mode string) (AvatarPick, error) {
if e := a.ready(); e != nil {
return AvatarPick{}, e
}
p, e := application.Get().Dialog.OpenFile().
SetTitle(a.localized("选择头像图片", "Choose avatar image")).
AddFilter(a.localized("图片文件", "Image files"), "*.png;*.jpg;*.jpeg;*.gif;*.webp").
PromptForSingleSelection()
if e != nil || strings.TrimSpace(p) == "" {
return AvatarPick{}, e
}
data, mime, e := encodeAvatarFile(p)
if e != nil {
return AvatarPick{}, e
}
dataURL := fmt.Sprintf("data:%s;base64,%s", mime, base64.StdEncoding.EncodeToString(data))
if mode == "auto" {
// 上传时实时获取全局配置,管理员切换存储方式后立即生效。
if a.currentFileStorage().Mode == "server" {
mode = "server"
} else {
mode = "base64"
}
}
switch mode {
case "path":
return AvatarPick{Mode: "path", Value: p, Preview: dataURL}, nil
case "server":
url, e := a.uploadImageToServer(data, mime, "avatar")
if e != nil {
return AvatarPick{}, e
}
// 落库为 url 模式:显示与同步复用现有 url 逻辑。
return AvatarPick{Mode: "url", Value: url, Preview: dataURL}, nil
}
return AvatarPick{Mode: "base64", Value: dataURL, Preview: dataURL}, nil
}
// ReadImageAsDataURL 把本地图片读成缩放后的 dataURLpath 模式启动时用来渲染头像)。
func (a *App) ReadImageAsDataURL(path string) (string, error) {
if e := a.ready(); e != nil {
return "", e
}
return imageFileToDataURL(path)
}
// ensureDefaultAvatar 给尚未设置头像的本机用户设默认头像:内嵌应用 logo 缩放后的
// base64 dataURL。注册成功后调用随后的自动登录同步会把它推成账号头像
// sync_settings 的 avatar + user_profiles 的 64px 缩略图),队友即可看到默认 logo 头像。
// 本机已设置过头像则不覆盖;失败静默(默认头像不值得打断注册流程)。
func (a *App) ensureDefaultAvatar() {
st, e := a.store.Settings()
if e != nil || strings.TrimSpace(st.AvatarValue) != "" {
return
}
data, mime, e := encodeImageEdge(appIcon, avatarMaxEdge)
if e != nil {
return
}
st.AvatarMode = "base64"
st.AvatarValue = fmt.Sprintf("data:%s;base64,%s", mime, base64.StdEncoding.EncodeToString(data))
// SaveSettings 检测到头像变更会自动记录 avatar_updated_at触发后续同步推送。
if e := a.store.SaveSettings(st); e == nil {
a.store.Log("info", "同步", "已设置默认头像", "应用 logo")
}
}
// encodeAvatarFile 读取图片、把最长边缩放到 avatarMaxEdge 以内,返回编码后的字节与 mime。
// 源为 JPEG 时输出 JPEG体积小其余格式输出 PNG保留透明度
func encodeAvatarFile(path string) ([]byte, string, error) {
info, e := os.Stat(path)
if e != nil {
return nil, "", errors.New("FILE_NOT_FOUND")
}
if info.Size() > avatarMaxFileBytes {
return nil, "", errors.New("AVATAR_FILE_TOO_LARGE")
}
raw, e := os.ReadFile(path)
if e != nil {
return nil, "", errors.New("FILE_READ_FAILED")
}
src, format, e := image.Decode(bytes.NewReader(raw))
if e != nil {
return nil, "", errors.New("AVATAR_DECODE_FAILED")
}
img := downscaleImage(src, avatarMaxEdge)
var buf bytes.Buffer
mime := "image/png"
if format == "jpeg" {
mime = "image/jpeg"
e = jpeg.Encode(&buf, img, &jpeg.Options{Quality: 85})
} else {
e = png.Encode(&buf, img)
}
if e != nil {
return nil, "", errors.New("AVATAR_DECODE_FAILED")
}
return buf.Bytes(), mime, nil
}
// imageFileToDataURL 读取并缩放图片,编码为可直接显示的 dataURL。
func imageFileToDataURL(path string) (string, error) {
data, mime, e := encodeAvatarFile(path)
if e != nil {
return "", e
}
return fmt.Sprintf("data:%s;base64,%s", mime, base64.StdEncoding.EncodeToString(data)), nil
}
// downscaleImage 等比缩放到最长边 ≤ maxEdge本身足够小则原样返回。
func downscaleImage(src image.Image, maxEdge int) image.Image {
b := src.Bounds()
w, h := b.Dx(), b.Dy()
if w <= maxEdge && h <= maxEdge {
return src
}
if w >= h {
h = h * maxEdge / w
w = maxEdge
} else {
w = w * maxEdge / h
h = maxEdge
}
if w < 1 {
w = 1
}
if h < 1 {
h = 1
}
dst := image.NewRGBA(image.Rect(0, 0, w, h))
draw.CatmullRom.Scale(dst, dst.Bounds(), src, b, draw.Over, nil)
return dst
}

178
avatar_test.go Normal file
View File

@@ -0,0 +1,178 @@
package main
import (
"bytes"
"encoding/base64"
"image"
"image/color"
"image/jpeg"
"image/png"
"os"
"path/filepath"
"strings"
"testing"
)
func writeTestImage(t *testing.T, name string, w, h int, asJPEG bool) string {
t.Helper()
img := image.NewRGBA(image.Rect(0, 0, w, h))
for x := 0; x < w; x += 3 {
for y := 0; y < h; y += 3 {
img.Set(x, y, color.RGBA{R: uint8(x % 255), G: uint8(y % 255), B: 128, A: 255})
}
}
var buf bytes.Buffer
var e error
if asJPEG {
e = jpeg.Encode(&buf, img, nil)
} else {
e = png.Encode(&buf, img)
}
if e != nil {
t.Fatal(e)
}
p := filepath.Join(t.TempDir(), name)
if e := os.WriteFile(p, buf.Bytes(), 0o644); e != nil {
t.Fatal(e)
}
return p
}
func decodeDataURL(t *testing.T, dataURL string) image.Image {
t.Helper()
i := strings.Index(dataURL, ";base64,")
if !strings.HasPrefix(dataURL, "data:image/") || i < 0 {
t.Fatalf("not a data URL: %.40s", dataURL)
}
raw, e := base64.StdEncoding.DecodeString(dataURL[i+8:])
if e != nil {
t.Fatal(e)
}
img, _, e := image.Decode(bytes.NewReader(raw))
if e != nil {
t.Fatal(e)
}
return img
}
func TestImageFileToDataURLDownscalesLargeImages(t *testing.T) {
p := writeTestImage(t, "big.png", 600, 400, false)
dataURL, e := imageFileToDataURL(p)
if e != nil {
t.Fatal(e)
}
if !strings.HasPrefix(dataURL, "data:image/png;base64,") {
t.Fatalf("png source should stay png: %.40s", dataURL)
}
img := decodeDataURL(t, dataURL)
if b := img.Bounds(); b.Dx() != 256 || b.Dy() != 170 {
t.Fatalf("expected 256x170, got %dx%d", b.Dx(), b.Dy())
}
}
func TestImageFileToDataURLKeepsSmallJPEG(t *testing.T) {
p := writeTestImage(t, "small.jpg", 120, 80, true)
dataURL, e := imageFileToDataURL(p)
if e != nil {
t.Fatal(e)
}
if !strings.HasPrefix(dataURL, "data:image/jpeg;base64,") {
t.Fatalf("jpeg source should stay jpeg: %.40s", dataURL)
}
img := decodeDataURL(t, dataURL)
if b := img.Bounds(); b.Dx() != 120 || b.Dy() != 80 {
t.Fatalf("small image should keep size, got %dx%d", b.Dx(), b.Dy())
}
}
func TestImageFileToDataURLErrors(t *testing.T) {
if _, e := imageFileToDataURL(filepath.Join(t.TempDir(), "missing.png")); e == nil || e.Error() != "FILE_NOT_FOUND" {
t.Fatalf("want FILE_NOT_FOUND, got %v", e)
}
bad := filepath.Join(t.TempDir(), "not-image.png")
if e := os.WriteFile(bad, []byte("hello world"), 0o644); e != nil {
t.Fatal(e)
}
if _, e := imageFileToDataURL(bad); e == nil || e.Error() != "AVATAR_DECODE_FAILED" {
t.Fatalf("want AVATAR_DECODE_FAILED, got %v", e)
}
}
func TestEnsureDefaultAvatar(t *testing.T) {
s, e := OpenStore(filepath.Join(t.TempDir(), "defavatar.db"))
if e != nil {
t.Fatal(e)
}
defer s.db.Close()
a := &App{store: s}
a.ensureDefaultAvatar()
st, e := s.Settings()
if e != nil {
t.Fatal(e)
}
if st.AvatarMode != "base64" || !strings.HasPrefix(st.AvatarValue, "data:image/") {
t.Fatalf("default avatar not set: mode=%q value=%.40s", st.AvatarMode, st.AvatarValue)
}
if s.Meta("avatar_updated_at") == "" {
t.Fatal("default avatar should bump avatar_updated_at so sync pushes it")
}
if img := decodeDataURL(t, st.AvatarValue); img.Bounds().Dx() > avatarMaxEdge || img.Bounds().Dy() > avatarMaxEdge {
t.Fatalf("default avatar should fit %dpx, got %v", avatarMaxEdge, img.Bounds())
}
// 已设置过头像时不覆盖。
st.AvatarMode, st.AvatarValue = "url", "https://cdn.example.com/me.png"
if e = s.SaveSettings(st); e != nil {
t.Fatal(e)
}
a.ensureDefaultAvatar()
got, e := s.Settings()
if e != nil {
t.Fatal(e)
}
if got.AvatarMode != "url" || got.AvatarValue != "https://cdn.example.com/me.png" {
t.Fatalf("existing avatar should not be overwritten: mode=%q", got.AvatarMode)
}
}
func TestSaveSettingsBumpsAvatarTimestampOnlyOnAvatarChange(t *testing.T) {
s, e := OpenStore(filepath.Join(t.TempDir(), "avatar.db"))
if e != nil {
t.Fatal(e)
}
defer s.db.Close()
base, e := s.Settings()
if e != nil {
t.Fatal(e)
}
base.Theme = "light"
if e = s.SaveSettings(base); e != nil {
t.Fatal(e)
}
if got := s.Meta("avatar_updated_at"); got != "" {
t.Fatalf("non-avatar save should not bump avatar timestamp, got %q", got)
}
base.AvatarMode, base.AvatarValue = "url", "https://cdn.example.com/a.png"
if e = s.SaveSettings(base); e != nil {
t.Fatal(e)
}
first := s.Meta("avatar_updated_at")
if first == "" {
t.Fatal("avatar change should bump timestamp")
}
got, e := s.Settings()
if e != nil {
t.Fatal(e)
}
if got.AvatarMode != "url" || got.AvatarValue != "https://cdn.example.com/a.png" {
t.Fatalf("avatar not persisted: %+v", got)
}
// 非法模式会被清空存储。
base.AvatarMode, base.AvatarValue = "weird", "x"
if e = s.SaveSettings(base); e != nil {
t.Fatal(e)
}
got, _ = s.Settings()
if got.AvatarMode != "" || got.AvatarValue != "" {
t.Fatalf("invalid mode should clear avatar, got %+v", got)
}
}

398
build/Taskfile.yml Normal file
View File

@@ -0,0 +1,398 @@
version: '3'
tasks:
go:mod:tidy:
summary: Runs `go mod tidy`
internal: true
# Universal/multi-arch builds invoke this concurrently from parallel deps;
# two `go mod tidy` processes racing on go.mod can corrupt it (#4637).
run: once
cmds:
- go mod tidy
install:frontend:deps:
summary: Install frontend dependencies
run: once
cmds:
- task: install:frontend:deps:{{.PACKAGE_MANAGER}}
install:frontend:deps:npm:
dir: frontend
sources:
- package.json
- package-lock.json
generates:
- node_modules
preconditions:
- sh: npm version
msg: "Looks like npm isn't installed. Npm is part of the Node installer: https://nodejs.org/en/download/"
cmds:
- npm install
install:frontend:deps:bun:
dir: frontend
sources:
- package.json
- bun.lock
- bun.lockb
generates:
- node_modules
preconditions:
- sh: bun --version
msg: "bun not found"
cmds:
- bun install
install:frontend:deps:pnpm:
dir: frontend
sources:
- package.json
- pnpm-lock.yaml
generates:
- node_modules
preconditions:
- sh: pnpm --version
msg: "pnpm not found"
cmds:
- pnpm install
install:frontend:deps:yarn:
dir: frontend
sources:
- package.json
- yarn.lock
status:
- test -d node_modules || test -f .pnp.cjs
preconditions:
- sh: yarn --version
msg: "yarn not found"
cmds:
- yarn install
build:frontend:
label: build:frontend (DEV={{.DEV}} RUNNER={{.PACKAGE_MANAGER}})
summary: Build the frontend project
# darwin:build:universal runs its per-arch builds as parallel deps, each of
# which depends on this task. Without run:once the two executions race:
# one regenerates frontend/bindings (-clean deletes it first) while the
# other's bundler is reading it, failing intermittently with
# 'Could not resolve "./bindings/<pkg>"' (#4637).
run: once
dir: frontend
sources:
- "**/*"
- exclude: node_modules/**/*
generates:
- dist/**/*
deps:
- task: install:frontend:deps
- task: generate:bindings
vars:
BUILD_FLAGS:
ref: .BUILD_FLAGS
OBFUSCATED:
ref: .OBFUSCATED
cmds:
- task: frontend:run
vars:
SCRIPT: '{{if eq .DEV "true"}}build:dev{{else}}build{{end}}'
env:
PRODUCTION: '{{if eq .DEV "true"}}false{{else}}true{{end}}'
frontend:run:
summary: Run a frontend script with selected runner
cmds:
- task: frontend:run:{{.PACKAGE_MANAGER}}
vars:
SCRIPT: "{{.SCRIPT}}"
vars:
SCRIPT: "{{.SCRIPT}}"
frontend:run:npm:
dir: frontend
cmds:
- npm run {{.SCRIPT}} -q
vars:
SCRIPT: "{{.SCRIPT}}"
frontend:run:yarn:
dir: frontend
cmds:
- yarn {{.SCRIPT}}
vars:
SCRIPT: "{{.SCRIPT}}"
frontend:run:pnpm:
dir: frontend
cmds:
- pnpm run {{.SCRIPT}}
vars:
SCRIPT: "{{.SCRIPT}}"
frontend:run:bun:
dir: frontend
cmds:
- bun run {{.SCRIPT}}
vars:
SCRIPT: "{{.SCRIPT}}"
frontend:vendor:puppertino:
summary: Fetches Puppertino CSS into frontend/public for consistent mobile styling
sources:
- frontend/public/puppertino/puppertino.css
generates:
- frontend/public/puppertino/puppertino.css
cmds:
- |
set -euo pipefail
mkdir -p frontend/public/puppertino
# If bundled Puppertino exists, prefer it. Otherwise, try to fetch, but don't fail build on error.
if [ ! -f frontend/public/puppertino/puppertino.css ]; then
echo "No bundled Puppertino found. Attempting to fetch from GitHub..."
if curl -fsSL https://raw.githubusercontent.com/codedgar/Puppertino/main/dist/css/full.css -o frontend/public/puppertino/puppertino.css; then
curl -fsSL https://raw.githubusercontent.com/codedgar/Puppertino/main/LICENSE -o frontend/public/puppertino/LICENSE || true
echo "Puppertino CSS downloaded to frontend/public/puppertino/puppertino.css"
else
echo "Warning: Could not fetch Puppertino CSS. Proceeding without download since template may bundle it."
fi
else
echo "Using bundled Puppertino at frontend/public/puppertino/puppertino.css"
fi
# Ensure index.html includes Puppertino CSS and button classes
INDEX_HTML=frontend/index.html
if [ -f "$INDEX_HTML" ]; then
if ! grep -q 'href="/puppertino/puppertino.css"' "$INDEX_HTML"; then
# Insert Puppertino link tag after style.css link
awk '
/href="\/style.css"\/?/ && !x { print; print " <link rel=\"stylesheet\" href=\"/puppertino/puppertino.css\"/>"; x=1; next }1
' "$INDEX_HTML" > "$INDEX_HTML.tmp" && mv "$INDEX_HTML.tmp" "$INDEX_HTML"
fi
# Replace default .btn with Puppertino primary button classes if present
sed -E -i'' 's/class=\"btn\"/class=\"p-btn p-prim-col\"/g' "$INDEX_HTML" || true
fi
generate:bindings:
summary: Generates bindings for the frontend
run: once
deps:
- task: go:mod:tidy
sources:
- "**/*.[jt]s"
- exclude: frontend/**/*
- frontend/bindings/**/* # Rerun when switching between dev/production mode causes changes in output
- "**/*.go"
- go.mod
- go.sum
generates:
- frontend/bindings/**/*
cmds:
- wails3 generate bindings -f '{{.BUILD_FLAGS}}' -clean=true -time-type=Date{{if eq .OBFUSCATED "true"}} -obfuscated{{end}}
generate:icons:
summary: Generates Windows `.ico` and Mac `.icns` from an image; on macOS, `-iconcomposerinput appicon.icon -macassetdir darwin` also produces `Assets.car` from a `.icon` file (skipped on other platforms).
run: once
dir: build
sources:
- "appicon.png"
- "appicon.icon"
generates:
- "darwin/icons.icns"
- "windows/icon.ico"
cmds:
- wails3 generate icons -input appicon.png -macfilename darwin/icons.icns -windowsfilename windows/icon.ico -iconcomposerinput appicon.icon -macassetdir darwin
dev:frontend:
summary: Runs the frontend in development mode
deps:
- task: install:frontend:deps
cmds:
- task: frontend:dev:{{.PACKAGE_MANAGER}}
frontend:dev:npm:
dir: frontend
cmds:
- npm run dev -- --port {{.VITE_PORT}} --strictPort
frontend:dev:yarn:
dir: frontend
cmds:
- yarn dev --port {{.VITE_PORT}} --strictPort
frontend:dev:pnpm:
dir: frontend
cmds:
- pnpm dev --port {{.VITE_PORT}} --strictPort
frontend:dev:bun:
dir: frontend
cmds:
- bun run dev --port {{.VITE_PORT}} --strictPort
update:build-assets:
summary: Updates the build assets
dir: build
cmds:
- wails3 update build-assets -name "{{.APP_NAME}}" -binaryname "{{.APP_NAME}}" -config config.yml -dir .
build:server:
summary: Builds the application in server mode (no GUI, HTTP server only)
desc: |
Builds a production server binary by default: -tags server,production,
-trimpath and a stripped binary, mirroring the desktop `build` task.
Server mode runs as a pure HTTP server without native GUI dependencies.
Usage: task build:server [DEV=true] [OBFUSCATED=true] [EXTRA_TAGS=tag1,tag2]
DEV=true development server (-tags server, no strip, inlining kept)
OBFUSCATED=true obfuscated build via garble (requires garble installed)
EXTRA_TAGS additional comma-separated build tags
deps:
- task: build:frontend
vars:
DEV:
ref: .DEV
BUILD_FLAGS:
ref: .BUILD_FLAGS
OBFUSCATED:
ref: .OBFUSCATED
preconditions:
- sh: '{{if eq .OBFUSCATED "true"}}command -v garble >/dev/null 2>&1{{else}}true{{end}}'
msg: "garble is required for obfuscated builds. Install it with: go install mvdan.cc/garble@v0.16.0 (requires Go 1.24+). See https://github.com/burrowers/garble/releases for version/toolchain compatibility."
cmds:
- '{{if eq .OBFUSCATED "true"}}garble {{.GARBLE_ARGS}} build{{else}}go build{{end}} {{.BUILD_FLAGS}} -o "{{.BIN_DIR}}/{{.APP_NAME}}-server{{exeExt}}"'
vars:
BUILD_FLAGS: '-tags server{{if eq .DEV "true"}}{{if eq .OBFUSCATED "true"}},wails_obfuscated{{end}}{{if .EXTRA_TAGS}},{{.EXTRA_TAGS}}{{end}} -buildvcs=false -gcflags=all="-l"{{else}},production{{if eq .OBFUSCATED "true"}},wails_obfuscated{{end}}{{if .EXTRA_TAGS}},{{.EXTRA_TAGS}}{{end}} -trimpath -buildvcs=false -ldflags="-w -s"{{end}}'
run:server:
summary: Builds and runs a development server (DEV=true)
deps:
- task: build:server
vars:
DEV: "true"
cmds:
- '"./{{.BIN_DIR}}/{{.APP_NAME}}-server{{exeExt}}"'
build:docker:
summary: Builds a Docker image for server mode deployment
desc: |
Creates a minimal Docker image containing the production server binary.
Defaults to a pure-Go static binary on a distroless/static base. The
production frontend is built first so the embedded assets are current.
Usage: task build:docker [TAG=myapp:latest] [CGO_ENABLED=1] [GO_IMAGE=...] [RUNTIME_IMAGE=...]
For CGO apps, set CGO_ENABLED=1 with libc-compatible builder/runtime images, e.g.:
task build:docker CGO_ENABLED=1 GO_IMAGE=golang:bookworm RUNTIME_IMAGE=gcr.io/distroless/base-debian12
deps:
# Build the production frontend so frontend/dist (embedded by the Go build
# inside the image) is present and current in the Docker build context.
# Pass the server,production tags so binding generation analyses the same
# build the Docker image compiles, not the default-tag build.
- task: build:frontend
vars:
BUILD_FLAGS: "-tags server,production"
cmds:
- >-
docker build
--build-arg CGO_ENABLED={{.CGO_ENABLED | default "0"}}
--build-arg GO_IMAGE={{.GO_IMAGE | default "golang:alpine"}}
--build-arg RUNTIME_IMAGE={{.RUNTIME_IMAGE | default "gcr.io/distroless/static-debian12"}}
-t {{.TAG | default (printf "%s:latest" .APP_NAME)}}
-f build/docker/Dockerfile.server .
vars:
TAG: "{{.TAG}}"
preconditions:
- sh: docker info > /dev/null 2>&1
msg: "Docker is required. Please install Docker first."
- sh: test -f build/docker/Dockerfile.server
msg: "Dockerfile.server not found. Run 'wails3 update build-assets' to generate it."
run:docker:
summary: Builds and runs the Docker image
desc: |
Builds the Docker image and runs it, exposing port 8080.
Usage: task run:docker [TAG=myapp:latest] [PORT=8080]
Note: The internal container port is always 8080. The PORT variable
only changes the host port mapping. Ensure your app uses port 8080
or modify the Dockerfile to match your ServerOptions.Port setting.
deps:
- task: build:docker
vars:
TAG:
ref: .TAG
cmds:
- docker run --rm -p {{.PORT | default "8080"}}:8080 {{.TAG | default (printf "%s:latest" .APP_NAME)}}
vars:
TAG: "{{.TAG}}"
PORT: "{{.PORT}}"
setup:docker:
summary: Builds Docker image for cross-compilation (~800MB download)
desc: |
Builds the Docker image needed for cross-compiling to any platform.
Run this once to enable cross-platform builds from any OS.
cmds:
- docker build -t wails-cross -f build/docker/Dockerfile.cross build/docker/
preconditions:
- sh: docker info > /dev/null 2>&1
msg: "Docker is required. Please install Docker first."
ios:device:list:
summary: Lists connected iOS devices (UDIDs)
cmds:
- xcrun xcdevice list
ios:run:device:
summary: Build, install, and launch on a physical iPhone using Apple tools (xcodebuild/devicectl)
vars:
PROJECT: '{{.PROJECT}}' # e.g., build/ios/xcode/<YourProject>.xcodeproj
SCHEME: '{{.SCHEME}}' # e.g., ios.dev
CONFIG: '{{.CONFIG | default "Debug"}}'
DERIVED: '{{.DERIVED | default "build/ios/DerivedData"}}'
UDID: '{{.UDID}}' # from `task ios:device:list`
BUNDLE_ID: '{{.BUNDLE_ID}}' # e.g., com.yourco.wails.ios.dev
TEAM_ID: '{{.TEAM_ID}}' # optional, if your project is not already set up for signing
preconditions:
- sh: xcrun -f xcodebuild
msg: "xcodebuild not found. Please install Xcode."
- sh: xcrun -f devicectl
msg: "devicectl not found. Please update to Xcode 15+ (which includes devicectl)."
- sh: test -n '{{.PROJECT}}'
msg: "Set PROJECT to your .xcodeproj path (e.g., PROJECT=build/ios/xcode/App.xcodeproj)."
- sh: test -n '{{.SCHEME}}'
msg: "Set SCHEME to your app scheme (e.g., SCHEME=ios.dev)."
- sh: test -n '{{.UDID}}'
msg: "Set UDID to your device UDID (see: task ios:device:list)."
- sh: test -n '{{.BUNDLE_ID}}'
msg: "Set BUNDLE_ID to your app's bundle identifier (e.g., com.yourco.wails.ios.dev)."
cmds:
- |
set -euo pipefail
echo "Building for device: UDID={{.UDID}} SCHEME={{.SCHEME}} PROJECT={{.PROJECT}}"
XCB_ARGS=(
-project "{{.PROJECT}}"
-scheme "{{.SCHEME}}"
-configuration "{{.CONFIG}}"
-destination "id={{.UDID}}"
-derivedDataPath "{{.DERIVED}}"
-allowProvisioningUpdates
-allowProvisioningDeviceRegistration
)
# Optionally inject signing identifiers if provided
if [ -n '{{.TEAM_ID}}' ]; then XCB_ARGS+=(DEVELOPMENT_TEAM={{.TEAM_ID}}); fi
if [ -n '{{.BUNDLE_ID}}' ]; then XCB_ARGS+=(PRODUCT_BUNDLE_IDENTIFIER={{.BUNDLE_ID}}); fi
xcodebuild "${XCB_ARGS[@]}" build | xcpretty || true
# If xcpretty isn't installed, run without it
if [ "${PIPESTATUS[0]}" -ne 0 ]; then
xcodebuild "${XCB_ARGS[@]}" build
fi
# Find built .app
APP_PATH=$(find "{{.DERIVED}}/Build/Products" -type d -name "*.app" -maxdepth 3 | head -n 1)
if [ -z "$APP_PATH" ]; then
echo "Could not locate built .app under {{.DERIVED}}/Build/Products" >&2
exit 1
fi
echo "Installing: $APP_PATH"
xcrun devicectl device install app --device "{{.UDID}}" "$APP_PATH"
echo "Launching: {{.BUNDLE_ID}}"
xcrun devicectl device process launch --device "{{.UDID}}" --stderr console --stdout console "{{.BUNDLE_ID}}"

79
build/config.yml Normal file
View File

@@ -0,0 +1,79 @@
# This file contains the configuration for this project.
# When you update `info` or `fileAssociations`, run `wails3 task common:update:build-assets` to update the assets.
# Note that this will overwrite any changes you have made to the assets.
version: '3'
# This information is used to generate the build assets.
info:
companyName: "liqi" # The name of the company
productName: "年糕崽崽项目管理PMS" # The name of the application
productIdentifier: "com.liqi.codecount" # The unique product identifier
description: "本地代码统计与项目工作台" # The application description
copyright: "(c) 2026, liqi" # Copyright text
comments: "本地代码统计与项目工作台" # Comments
version: "2.0.0" # The application version
# cfBundleIconName: "appicon" # The macOS icon name in Assets.car icon bundles (optional)
# # Should match the name of your .icon file without the extension
# # If not set and Assets.car exists, defaults to "appicon"
# iOS build configuration (uncomment to customise iOS project generation)
# Note: Keys under `ios` OVERRIDE values under `info` when set.
# ios:
# # The iOS bundle identifier used in the generated Xcode project (CFBundleIdentifier)
# bundleID: "com.mycompany.myproduct"
# # The display name shown under the app icon (CFBundleDisplayName/CFBundleName)
# displayName: "My Product"
# # The app version to embed in Info.plist (CFBundleShortVersionString/CFBundleVersion)
# version: "0.0.1"
# # The company/organisation name for templates and project settings
# company: "My Company"
# # Additional comments to embed in Info.plist metadata
# comments: "Some Product Comments"
# Dev mode configuration
dev_mode:
root_path: .
log_level: warn
debounce: 1000
ignore:
dir:
- .git
- node_modules
- frontend
- bin
file:
- .DS_Store
- .gitignore
- .gitkeep
- "*_test.go"
watched_extension:
- "*.go"
- "*.js" # Watch for changes to JS/TS files included using the //wails:include directive.
- "*.ts" # The frontend directory will be excluded entirely by the setting above.
git_ignore: true
executes:
- cmd: wails3 build DEV=true
type: blocking
- cmd: wails3 task common:dev:frontend
type: background
- cmd: wails3 task run
type: primary
# File Associations
# More information at: https://v3.wails.io/noit/done/yet
fileAssociations:
# - ext: wails
# name: Wails
# description: Wails Application File
# iconName: wailsFileIcon
# role: Editor
# - ext: jpg
# name: JPEG
# description: Image File
# iconName: jpegFileIcon
# role: Editor
# mimeType: image/jpeg # (optional)
# Other data
other:
- name: My Other Data

BIN
build/darwin/Assets.car Normal file

Binary file not shown.

220
build/darwin/Taskfile.yml Normal file
View File

@@ -0,0 +1,220 @@
version: '3'
includes:
common: ../Taskfile.yml
vars:
# Docker image for cross-compilation (used when building on non-macOS)
CROSS_IMAGE: wails-cross
tasks:
build:
summary: Builds the application
cmds:
- task: '{{if eq OS "darwin"}}build:native{{else}}build:docker{{end}}'
vars:
ARCH: '{{.ARCH}}'
DEV: '{{.DEV}}'
OUTPUT: '{{.OUTPUT}}'
EXTRA_TAGS: '{{.EXTRA_TAGS}}'
OBFUSCATED: '{{.OBFUSCATED}}'
GARBLE_ARGS: '{{.GARBLE_ARGS}}'
vars:
DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}'
OUTPUT: '{{ .OUTPUT | default .DEFAULT_OUTPUT }}'
build:native:
summary: Builds the application natively on macOS
internal: true
deps:
- task: common:go:mod:tidy
- task: common:build:frontend
vars:
BUILD_FLAGS:
ref: .BUILD_FLAGS
OBFUSCATED:
ref: .OBFUSCATED
DEV:
ref: .DEV
- task: common:generate:icons
preconditions:
- sh: '{{if eq .OBFUSCATED "true"}}command -v garble >/dev/null 2>&1{{else}}true{{end}}'
msg: "garble is required for obfuscated builds. Install it with: go install mvdan.cc/garble@v0.16.0 (requires Go 1.24+). See https://github.com/burrowers/garble/releases for version/toolchain compatibility."
cmds:
- '{{if eq .OBFUSCATED "true"}}garble {{.GARBLE_ARGS}} build{{else}}go build{{end}} {{.BUILD_FLAGS}} -o "{{.OUTPUT}}"'
vars:
BUILD_FLAGS: '{{if eq .DEV "true"}}{{if or .EXTRA_TAGS (eq .OBFUSCATED "true")}}-tags {{if eq .OBFUSCATED "true"}}wails_obfuscated{{if .EXTRA_TAGS}},{{end}}{{end}}{{.EXTRA_TAGS}} {{end}}-buildvcs=false -gcflags=all="-l"{{else}}-tags production{{if eq .OBFUSCATED "true"}},wails_obfuscated{{end}}{{if .EXTRA_TAGS}},{{.EXTRA_TAGS}}{{end}} -trimpath -buildvcs=false -ldflags="-w -s"{{end}}'
DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}'
OUTPUT: '{{ .OUTPUT | default .DEFAULT_OUTPUT }}'
env:
GOOS: darwin
CGO_ENABLED: 1
GOARCH: '{{.ARCH | default ARCH}}'
CGO_CFLAGS: "-mmacosx-version-min=12.0"
CGO_LDFLAGS: "-mmacosx-version-min=12.0"
MACOSX_DEPLOYMENT_TARGET: "12.0"
build:docker:
summary: Cross-compiles for macOS using Docker (for Linux/Windows hosts)
internal: true
deps:
- task: common:build:frontend
vars:
OBFUSCATED:
ref: .OBFUSCATED
- task: common:generate:icons
preconditions:
- sh: docker info > /dev/null 2>&1
msg: "Docker is required for cross-compilation. Please install Docker."
- sh: docker image inspect {{.CROSS_IMAGE}} > /dev/null 2>&1
msg: |
Docker image '{{.CROSS_IMAGE}}' not found.
Build it first: wails3 task setup:docker
cmds:
- docker run --rm -v "{{.ROOT_DIR}}:/app" {{.DOCKER_MOUNTS}} -e APP_NAME="{{.APP_NAME}}" {{if .EXTRA_TAGS}}-e EXTRA_TAGS="{{.EXTRA_TAGS}}"{{end}} {{if eq .OBFUSCATED "true"}}-e OBFUSCATED=true{{end}} {{if .GARBLE_ARGS}}-e GARBLE_ARGS="{{.GARBLE_ARGS}}"{{end}} {{.CROSS_IMAGE}} darwin {{.DOCKER_ARCH}}
- cmd: docker run --rm -v "{{.ROOT_DIR}}:/app" alpine chown -R $(id -u):$(id -g) /app/bin
platforms: [linux, darwin]
- mkdir -p {{.BIN_DIR}}
- mv "bin/{{.APP_NAME}}-darwin-{{.DOCKER_ARCH}}" "{{.OUTPUT}}"
vars:
DOCKER_ARCH: '{{if eq .ARCH "arm64"}}arm64{{else if eq .ARCH "amd64"}}amd64{{else}}arm64{{end}}'
DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}'
OUTPUT: '{{ .OUTPUT | default .DEFAULT_OUTPUT }}'
# Generate Docker volume mounts: Go module cache + go.mod replace directives
# Uses wails3 tool docker-mounts for cross-platform compatibility (Windows/Linux/macOS)
DOCKER_MOUNTS:
sh: 'wails3 tool docker-mounts'
build:universal:
summary: Builds darwin universal binary (arm64 + amd64)
deps:
- task: build
vars:
ARCH: amd64
OUTPUT: "{{.BIN_DIR}}/{{.APP_NAME}}-amd64"
- task: build
vars:
ARCH: arm64
OUTPUT: "{{.BIN_DIR}}/{{.APP_NAME}}-arm64"
cmds:
- task: '{{if eq OS "darwin"}}build:universal:lipo:native{{else}}build:universal:lipo:go{{end}}'
build:universal:lipo:native:
summary: Creates universal binary using native lipo (macOS)
internal: true
cmds:
- lipo -create -output "{{.BIN_DIR}}/{{.APP_NAME}}" "{{.BIN_DIR}}/{{.APP_NAME}}-amd64" "{{.BIN_DIR}}/{{.APP_NAME}}-arm64"
- rm "{{.BIN_DIR}}/{{.APP_NAME}}-amd64" "{{.BIN_DIR}}/{{.APP_NAME}}-arm64"
build:universal:lipo:go:
summary: Creates universal binary using wails3 tool lipo (Linux/Windows)
internal: true
cmds:
- wails3 tool lipo -output "{{.BIN_DIR}}/{{.APP_NAME}}" -input "{{.BIN_DIR}}/{{.APP_NAME}}-amd64" -input "{{.BIN_DIR}}/{{.APP_NAME}}-arm64"
- rm -f "{{.BIN_DIR}}/{{.APP_NAME}}-amd64" "{{.BIN_DIR}}/{{.APP_NAME}}-arm64"
package:
summary: Packages the application into a `.app` bundle
deps:
- task: build
cmds:
- task: create:app:bundle
package:universal:
summary: Packages darwin universal binary (arm64 + amd64)
deps:
- task: build:universal
cmds:
- task: create:app:bundle
package:dmg:
summary: Packages the application into a styled `.dmg`
deps:
- task: package
cmds:
- task: create:dmg
create:dmg:
summary: Creates a configurable `.dmg` from the `.app` bundle
platforms: [darwin]
cmds:
- >-
wails3 tool package --format dmg --name "{{.APP_NAME}}" --out "{{.BIN_DIR}}"
--background "{{.DMG_BACKGROUND}}"
--volume-icon "{{.DMG_VOLUME_ICON}}"
--file-icon "{{.DMG_FILE_ICON}}"
--window-width "{{.DMG_WINDOW_WIDTH}}"
--window-height "{{.DMG_WINDOW_HEIGHT}}"
{{if .DMG_FILES}}--files "{{.DMG_FILES}}"{{end}}
vars:
DMG_BACKGROUND: '{{.DMG_BACKGROUND | default "build/darwin/dmg-background.png"}}'
DMG_VOLUME_ICON: '{{.DMG_VOLUME_ICON | default "build/darwin/icons.icns"}}'
DMG_FILE_ICON: '{{.DMG_FILE_ICON | default "build/darwin/dmg-file-icon.icns"}}'
DMG_WINDOW_WIDTH: '{{.DMG_WINDOW_WIDTH | default "540"}}'
DMG_WINDOW_HEIGHT: '{{.DMG_WINDOW_HEIGHT | default "380"}}'
DMG_FILES: '{{.DMG_FILES | default ""}}'
create:app:bundle:
summary: Creates an `.app` bundle
cmds:
- mkdir -p "{{.BIN_DIR}}/{{.APP_NAME}}.app/Contents/MacOS"
- mkdir -p "{{.BIN_DIR}}/{{.APP_NAME}}.app/Contents/Resources"
- cp build/darwin/icons.icns "{{.BIN_DIR}}/{{.APP_NAME}}.app/Contents/Resources"
- |
if [ -f build/darwin/Assets.car ]; then
cp build/darwin/Assets.car "{{.BIN_DIR}}/{{.APP_NAME}}.app/Contents/Resources"
fi
- cp "{{.BIN_DIR}}/{{.APP_NAME}}" "{{.BIN_DIR}}/{{.APP_NAME}}.app/Contents/MacOS"
- cp build/darwin/Info.plist "{{.BIN_DIR}}/{{.APP_NAME}}.app/Contents"
- task: '{{if eq OS "darwin"}}codesign:adhoc{{else}}codesign:skip{{end}}'
codesign:adhoc:
summary: Ad-hoc signs the app bundle (macOS only)
internal: true
cmds:
- codesign --force --deep --sign - "{{.BIN_DIR}}/{{.APP_NAME}}.app"
codesign:skip:
summary: Skips codesigning when cross-compiling
internal: true
cmds:
- 'echo "Skipping codesign (not available on {{OS}}). Sign the .app on macOS before distribution."'
run:
cmds:
- mkdir -p "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/MacOS"
- mkdir -p "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/Resources"
- cp build/darwin/icons.icns "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/Resources"
- |
if [ -f build/darwin/Assets.car ]; then
cp build/darwin/Assets.car "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/Resources"
fi
- cp "{{.BIN_DIR}}/{{.APP_NAME}}" "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/MacOS"
- cp "build/darwin/Info.dev.plist" "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/Info.plist"
- codesign --force --deep --sign - "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app"
- '"{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/MacOS/{{.APP_NAME}}"'
sign:
summary: Signs the application bundle with Developer ID
desc: |
Signs the .app bundle for distribution.
Uses signing identity from `wails3 setup` (stored in ~/.config/wails/defaults.yaml).
Override with: task darwin:sign -- --identity "Developer ID Application: ..."
deps:
- task: package
cmds:
- wails3 tool sign --input "{{.BIN_DIR}}/{{.APP_NAME}}.app" {{.CLI_ARGS}}
sign:notarize:
summary: Signs and notarizes the application bundle
desc: |
Signs the .app bundle and submits it for notarization.
Uses signing identity and keychain profile from `wails3 setup`.
First-time setup:
wails3 setup
deps:
- task: package
cmds:
- wails3 tool sign --input "{{.BIN_DIR}}/{{.APP_NAME}}.app" --notarize {{.CLI_ARGS}}

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

BIN
build/darwin/icons.icns Normal file

Binary file not shown.

View File

@@ -0,0 +1,220 @@
# Cross-compile Wails v3 apps to any platform
#
# Darwin: Zig + macOS SDK
# Linux: Native GCC when host matches target, Zig for cross-arch
# Windows: Zig + bundled mingw
#
# Usage:
# docker build -t wails-cross -f Dockerfile.cross .
# docker run --rm -v $(pwd):/app wails-cross darwin arm64
# docker run --rm -v $(pwd):/app wails-cross darwin amd64
# docker run --rm -v $(pwd):/app wails-cross linux amd64
# docker run --rm -v $(pwd):/app wails-cross linux arm64
# docker run --rm -v $(pwd):/app wails-cross windows amd64
# docker run --rm -v $(pwd):/app wails-cross windows arm64
FROM golang:1.26-bookworm
ARG TARGETARCH
ARG GARBLE_VERSION=v0.16.0
# Install base tools, GCC, and GTK/WebKit dev packages
RUN apt-get update && apt-get install -y --no-install-recommends \
curl xz-utils python3 nodejs npm pkg-config gcc libc6-dev \
libgtk-3-dev libwebkit2gtk-4.1-dev \
libgtk-4-dev libwebkitgtk-6.0-dev \
&& rm -rf /var/lib/apt/lists/*
RUN go install mvdan.cc/garble@${GARBLE_VERSION}
# Install Zig - automatically selects correct binary for host architecture.
# The SHA-256 is fetched from Zig's official download manifest and verified
# before extraction to protect against MITM or corrupted downloads.
ARG ZIG_VERSION=0.14.0
RUN ZIG_ARCH=$(case "${TARGETARCH}" in arm64) echo "aarch64" ;; *) echo "x86_64" ;; esac) && \
ZIG_FILE="zig-linux-${ZIG_ARCH}-${ZIG_VERSION}.tar.xz" && \
curl -fSL "https://ziglang.org/download/${ZIG_VERSION}/${ZIG_FILE}" -o "/tmp/${ZIG_FILE}" && \
EXPECTED=$(curl -fsSL "https://ziglang.org/download/index.json" | \
python3 -c "import json,sys; d=json.load(sys.stdin); print(d[sys.argv[1]][sys.argv[2]+'-linux']['shasum'])" \
"${ZIG_VERSION}" "${ZIG_ARCH}") && \
echo "${EXPECTED} /tmp/${ZIG_FILE}" | sha256sum -c - && \
tar -xJ -f "/tmp/${ZIG_FILE}" -C /opt && \
rm "/tmp/${ZIG_FILE}" && \
ln -s /opt/zig-linux-${ZIG_ARCH}-${ZIG_VERSION}/zig /usr/local/bin/zig
# Download macOS SDK (required for darwin targets)
ARG MACOS_SDK_VERSION=14.5
RUN curl -L "https://github.com/joseluisq/macosx-sdks/releases/download/${MACOS_SDK_VERSION}/MacOSX${MACOS_SDK_VERSION}.sdk.tar.xz" \
| tar -xJ -C /opt \
&& mv /opt/MacOSX${MACOS_SDK_VERSION}.sdk /opt/macos-sdk
ENV MACOS_SDK_PATH=/opt/macos-sdk
# Create Zig CC wrappers for cross-compilation targets
# Darwin and Windows use Zig; Linux uses native GCC (run with --platform for cross-arch)
# Darwin arm64
COPY <<'ZIGWRAP' /usr/local/bin/zcc-darwin-arm64
#!/bin/sh
ARGS=""
SKIP_NEXT=0
for arg in "$@"; do
if [ $SKIP_NEXT -eq 1 ]; then
SKIP_NEXT=0
continue
fi
case "$arg" in
-target) SKIP_NEXT=1 ;;
-mmacosx-version-min=*) ;;
*) ARGS="$ARGS $arg" ;;
esac
done
exec zig cc -fno-sanitize=all -target aarch64-macos-none -isysroot /opt/macos-sdk -I/opt/macos-sdk/usr/include -L/opt/macos-sdk/usr/lib -F/opt/macos-sdk/System/Library/Frameworks -w $ARGS
ZIGWRAP
RUN chmod +x /usr/local/bin/zcc-darwin-arm64
# Darwin amd64
COPY <<'ZIGWRAP' /usr/local/bin/zcc-darwin-amd64
#!/bin/sh
ARGS=""
SKIP_NEXT=0
for arg in "$@"; do
if [ $SKIP_NEXT -eq 1 ]; then
SKIP_NEXT=0
continue
fi
case "$arg" in
-target) SKIP_NEXT=1 ;;
-mmacosx-version-min=*) ;;
*) ARGS="$ARGS $arg" ;;
esac
done
exec zig cc -fno-sanitize=all -target x86_64-macos-none -isysroot /opt/macos-sdk -I/opt/macos-sdk/usr/include -L/opt/macos-sdk/usr/lib -F/opt/macos-sdk/System/Library/Frameworks -w $ARGS
ZIGWRAP
RUN chmod +x /usr/local/bin/zcc-darwin-amd64
# Windows amd64 - uses Zig's bundled mingw
COPY <<'ZIGWRAP' /usr/local/bin/zcc-windows-amd64
#!/bin/sh
ARGS=""
SKIP_NEXT=0
for arg in "$@"; do
if [ $SKIP_NEXT -eq 1 ]; then
SKIP_NEXT=0
continue
fi
case "$arg" in
-target) SKIP_NEXT=1 ;;
-Wl,*) ;;
*) ARGS="$ARGS $arg" ;;
esac
done
exec zig cc -target x86_64-windows-gnu $ARGS
ZIGWRAP
RUN chmod +x /usr/local/bin/zcc-windows-amd64
# Windows arm64 - uses Zig's bundled mingw
COPY <<'ZIGWRAP' /usr/local/bin/zcc-windows-arm64
#!/bin/sh
ARGS=""
SKIP_NEXT=0
for arg in "$@"; do
if [ $SKIP_NEXT -eq 1 ]; then
SKIP_NEXT=0
continue
fi
case "$arg" in
-target) SKIP_NEXT=1 ;;
-Wl,*) ;;
*) ARGS="$ARGS $arg" ;;
esac
done
exec zig cc -target aarch64-windows-gnu $ARGS
ZIGWRAP
RUN chmod +x /usr/local/bin/zcc-windows-arm64
# Build script
COPY <<'SCRIPT' /usr/local/bin/build.sh
#!/bin/sh
set -e
OS=${1:-darwin}
ARCH=${2:-arm64}
case "${OS}-${ARCH}" in
darwin-arm64|darwin-aarch64)
export CC=zcc-darwin-arm64
export GOARCH=arm64
export GOOS=darwin
;;
darwin-amd64|darwin-x86_64)
export CC=zcc-darwin-amd64
export GOARCH=amd64
export GOOS=darwin
;;
linux-arm64|linux-aarch64)
export CC=gcc
export GOARCH=arm64
export GOOS=linux
;;
linux-amd64|linux-x86_64)
export CC=gcc
export GOARCH=amd64
export GOOS=linux
;;
windows-arm64|windows-aarch64)
export CC=zcc-windows-arm64
export GOARCH=arm64
export GOOS=windows
;;
windows-amd64|windows-x86_64)
export CC=zcc-windows-amd64
export GOARCH=amd64
export GOOS=windows
;;
*)
echo "Usage: <os> <arch>"
echo " os: darwin, linux, windows"
echo " arch: amd64, arm64"
exit 1
;;
esac
export CGO_ENABLED=1
export CGO_CFLAGS="-w"
# Build frontend if exists and not already built (host may have built it)
if [ -d "frontend" ] && [ -f "frontend/package.json" ] && [ ! -d "frontend/dist" ]; then
(cd frontend && npm install --silent && npm run build --silent)
fi
# Build
APP=${APP_NAME:-$(basename $(pwd))}
mkdir -p bin
EXT=""
LDFLAGS="-s -w"
if [ "$GOOS" = "windows" ]; then
EXT=".exe"
LDFLAGS="-s -w -H windowsgui"
fi
TAGS="production"
if [ -n "$EXTRA_TAGS" ]; then
TAGS="${TAGS},${EXTRA_TAGS}"
fi
COMPILER="go build"
if [ "$OBFUSCATED" = "true" ]; then
COMPILER="garble ${GARBLE_ARGS} build"
TAGS="${TAGS},wails_obfuscated"
fi
${COMPILER} -tags "$TAGS" -trimpath -buildvcs=false -ldflags="$LDFLAGS" -o bin/${APP}-${GOOS}-${GOARCH}${EXT} .
echo "Built: bin/${APP}-${GOOS}-${GOARCH}${EXT}"
SCRIPT
RUN chmod +x /usr/local/bin/build.sh
WORKDIR /app
ENTRYPOINT ["/usr/local/bin/build.sh"]
CMD ["darwin", "arm64"]

View File

@@ -0,0 +1,67 @@
# Wails Server Mode Dockerfile
# Multi-stage build for a minimal image.
#
# Defaults produce a pure-Go, fully static binary on a distroless/static base:
# the smallest, most secure image, and correct for the default (CGO-free) app.
#
# If your app needs CGO, override the build args, e.g.:
# docker build --build-arg CGO_ENABLED=1 \
# --build-arg GO_IMAGE=golang:bookworm \
# --build-arg RUNTIME_IMAGE=gcr.io/distroless/base-debian12 \
# -f build/docker/Dockerfile.server .
# CGO needs a C toolchain in the builder and a libc in the runtime image; keep
# the builder and runtime libc compatible (a glibc builder -> a glibc runtime).
# The Taskfile passes these through: task build:docker CGO_ENABLED=1 GO_IMAGE=... RUNTIME_IMAGE=...
ARG GO_IMAGE=golang:alpine
ARG RUNTIME_IMAGE=gcr.io/distroless/static-debian12
ARG CGO_ENABLED=0
# Build stage
FROM ${GO_IMAGE} AS builder
ARG CGO_ENABLED
WORKDIR /app
# Install build dependencies: git always; a C compiler only when CGO is enabled.
# Supports both apk (alpine) and apt (debian/bookworm) base images.
RUN if command -v apk >/dev/null 2>&1; then \
apk add --no-cache git $([ "$CGO_ENABLED" = "1" ] && echo gcc musl-dev); \
else \
apt-get update && apt-get install -y --no-install-recommends git $([ "$CGO_ENABLED" = "1" ] && echo gcc libc6-dev) && rm -rf /var/lib/apt/lists/*; \
fi
# Copy source code
COPY . .
# Remove local replace directive if present (for production builds)
RUN sed -i '/^replace/d' go.mod || true
# Download dependencies
RUN go mod tidy
# Build the production server binary.
# - server,production tags: HTTP server mode with production code paths (no dev
# logger/asset middleware), matching the desktop production build.
# - CGO_ENABLED defaults to 0 for a fully static binary (distroless/static);
# override with --build-arg CGO_ENABLED=1 (see header) for CGO apps.
RUN CGO_ENABLED=${CGO_ENABLED} go build -tags server,production -trimpath -buildvcs=false -ldflags="-s -w" -o server .
# Runtime stage - minimal image
FROM ${RUNTIME_IMAGE}
# Copy the binary
COPY --from=builder /app/server /server
# Copy frontend assets
COPY --from=builder /app/frontend/dist /frontend/dist
# Expose the default port
EXPOSE 8080
# Bind to all interfaces (required for Docker)
# Can be overridden at runtime with -e WAILS_SERVER_HOST=...
ENV WAILS_SERVER_HOST=0.0.0.0
# Run the server
ENTRYPOINT ["/server"]

220
build/linux/Taskfile.yml Normal file
View File

@@ -0,0 +1,220 @@
version: '3'
includes:
common: ../Taskfile.yml
vars:
# Signing configuration - edit these values for your project
# PGP_KEY: "path/to/signing-key.asc"
# SIGN_ROLE: "builder" # Options: origin, maint, archive, builder
#
# Password is stored securely in system keychain. Run: wails3 setup signing
# Docker image for cross-compilation (used when building on non-Linux or no CC available)
CROSS_IMAGE: wails-cross
tasks:
build:
summary: Builds the application for Linux
cmds:
# Linux requires CGO - use Docker when:
# 1. Cross-compiling from non-Linux, OR
# 2. No C compiler is available, OR
# 3. Target architecture differs from host architecture (cross-arch compilation)
- task: '{{if and (eq OS "linux") (eq .HAS_CC "true") (eq .TARGET_ARCH ARCH)}}build:native{{else}}build:docker{{end}}'
vars:
ARCH: '{{.ARCH}}'
DEV: '{{.DEV}}'
OUTPUT: '{{.OUTPUT}}'
EXTRA_TAGS: '{{.EXTRA_TAGS}}'
OBFUSCATED: '{{.OBFUSCATED}}'
GARBLE_ARGS: '{{.GARBLE_ARGS}}'
vars:
DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}'
OUTPUT: '{{ .OUTPUT | default .DEFAULT_OUTPUT }}'
# Determine target architecture (defaults to host ARCH if not specified)
TARGET_ARCH: '{{.ARCH | default ARCH}}'
# Check if a C compiler is available (gcc or clang) — cross-platform via wails3 tool
HAS_CC:
sh: 'wails3 tool has "gcc|clang"'
build:native:
summary: Builds the application natively on Linux
internal: true
deps:
- task: common:go:mod:tidy
- task: common:build:frontend
vars:
BUILD_FLAGS:
ref: .BUILD_FLAGS
OBFUSCATED:
ref: .OBFUSCATED
DEV:
ref: .DEV
- task: common:generate:icons
- task: generate:dotdesktop
preconditions:
- sh: '{{if eq .OBFUSCATED "true"}}command -v garble >/dev/null 2>&1{{else}}true{{end}}'
msg: "garble is required for obfuscated builds. Install it with: go install mvdan.cc/garble@v0.16.0 (requires Go 1.24+). See https://github.com/burrowers/garble/releases for version/toolchain compatibility."
cmds:
- '{{if eq .OBFUSCATED "true"}}garble {{.GARBLE_ARGS}} build{{else}}go build{{end}} {{.BUILD_FLAGS}} -o {{.OUTPUT}}'
vars:
BUILD_FLAGS: '{{if eq .DEV "true"}}{{if or .EXTRA_TAGS (eq .OBFUSCATED "true")}}-tags {{if eq .OBFUSCATED "true"}}wails_obfuscated{{if .EXTRA_TAGS}},{{end}}{{end}}{{.EXTRA_TAGS}} {{end}}-buildvcs=false -gcflags=all="-l"{{else}}-tags production{{if eq .OBFUSCATED "true"}},wails_obfuscated{{end}}{{if .EXTRA_TAGS}},{{.EXTRA_TAGS}}{{end}} -trimpath -buildvcs=false -ldflags="-w -s"{{end}}'
DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}'
OUTPUT: '{{ .OUTPUT | default .DEFAULT_OUTPUT }}'
env:
GOOS: linux
CGO_ENABLED: 1
GOARCH: '{{.ARCH | default ARCH}}'
build:docker:
summary: Builds for Linux using Docker (for non-Linux hosts or when no C compiler available)
internal: true
deps:
- task: common:build:frontend
vars:
OBFUSCATED:
ref: .OBFUSCATED
- task: common:generate:icons
- task: generate:dotdesktop
preconditions:
- sh: docker info > /dev/null 2>&1
msg: "Docker is required for cross-compilation to Linux. Please install Docker."
- sh: docker image inspect {{.CROSS_IMAGE}} > /dev/null 2>&1
msg: |
Docker image '{{.CROSS_IMAGE}}' not found.
Build it first: wails3 task setup:docker
cmds:
- docker run --rm -v "{{.ROOT_DIR}}:/app" {{.DOCKER_MOUNTS}} -e APP_NAME="{{.APP_NAME}}" {{if .EXTRA_TAGS}}-e EXTRA_TAGS="{{.EXTRA_TAGS}}"{{end}} {{if eq .OBFUSCATED "true"}}-e OBFUSCATED=true{{end}} {{if .GARBLE_ARGS}}-e GARBLE_ARGS="{{.GARBLE_ARGS}}"{{end}} "{{.CROSS_IMAGE}}" linux {{.DOCKER_ARCH}}
- cmd: docker run --rm -v "{{.ROOT_DIR}}:/app" alpine chown -R $(id -u):$(id -g) /app/bin
platforms: [linux, darwin]
- mkdir -p {{.BIN_DIR}}
- mv "bin/{{.APP_NAME}}-linux-{{.DOCKER_ARCH}}" "{{.OUTPUT}}"
vars:
DOCKER_ARCH: '{{.ARCH | default "amd64"}}'
DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}'
OUTPUT: '{{ .OUTPUT | default .DEFAULT_OUTPUT }}'
# Generate Docker volume mounts: Go module cache + go.mod replace directives
# Uses wails3 tool docker-mounts for cross-platform compatibility (Windows/Linux/macOS)
DOCKER_MOUNTS:
sh: 'wails3 tool docker-mounts'
package:
summary: Packages the application for Linux
deps:
- task: build
cmds:
- task: create:appimage
- task: create:deb
- task: create:rpm
- task: create:aur
create:appimage:
summary: Creates an AppImage
dir: build/linux/appimage
deps:
- task: build
- task: generate:dotdesktop
cmds:
- cp "{{.APP_BINARY}}" "{{.APP_NAME}}"
- cp ../../appicon.png "{{.APP_NAME}}.png"
- wails3 generate appimage -binary "{{.APP_NAME}}" -icon {{.ICON}} -desktopfile {{.DESKTOP_FILE}} -outputdir {{.OUTPUT_DIR}} -builddir {{.ROOT_DIR}}/build/linux/appimage/build
vars:
APP_NAME: '{{.APP_NAME}}'
APP_BINARY: '../../../bin/{{.APP_NAME}}'
ICON: '{{.APP_NAME}}.png'
DESKTOP_FILE: '../{{.APP_NAME}}.desktop'
OUTPUT_DIR: '../../../bin'
create:deb:
summary: Creates a deb package
deps:
- task: build
cmds:
- task: generate:dotdesktop
- task: generate:deb
create:rpm:
summary: Creates a rpm package
deps:
- task: build
cmds:
- task: generate:dotdesktop
- task: generate:rpm
create:aur:
summary: Creates a arch linux packager package
deps:
- task: build
cmds:
- task: generate:dotdesktop
- task: generate:aur
generate:deb:
summary: Creates a deb package
cmds:
- wails3 tool package -name "{{.APP_NAME}}" -format deb -config ./build/linux/nfpm/nfpm.yaml -out {{.ROOT_DIR}}/bin
generate:rpm:
summary: Creates a rpm package
cmds:
- wails3 tool package -name "{{.APP_NAME}}" -format rpm -config ./build/linux/nfpm/nfpm.yaml -out {{.ROOT_DIR}}/bin
generate:aur:
summary: Creates a arch linux packager package
cmds:
- wails3 tool package -name "{{.APP_NAME}}" -format archlinux -config ./build/linux/nfpm/nfpm.yaml -out {{.ROOT_DIR}}/bin
generate:dotdesktop:
summary: Generates a `.desktop` file
dir: build
cmds:
- mkdir -p {{.ROOT_DIR}}/build/linux/appimage
- wails3 generate .desktop -name "{{.APP_NAME}}" -exec "{{.EXEC}}" -icon "{{.ICON}}" -outputfile "{{.ROOT_DIR}}/build/linux/{{.APP_NAME}}.desktop" -categories "{{.CATEGORIES}}"
vars:
APP_NAME: '{{.APP_NAME}}'
EXEC: '{{.APP_NAME}}'
ICON: '{{.APP_NAME}}'
CATEGORIES: 'Development;'
OUTPUTFILE: '{{.ROOT_DIR}}/build/linux/{{.APP_NAME}}.desktop'
run:
cmds:
- '{{.BIN_DIR}}/{{.APP_NAME}}'
sign:deb:
summary: Signs the DEB package
desc: |
Signs the .deb package with a PGP key.
Set PGP_KEY in the vars section to override the key configured globally via
`wails3 setup` (~/.config/wails/defaults.yaml).
Password is retrieved from system keychain (run: wails3 setup signing)
deps:
- task: create:deb
cmds:
# PGP_KEY is optional: if unset, `wails3 tool sign` falls back to the key
# configured globally via `wails3 setup`.
- wails3 tool sign --input "{{.BIN_DIR}}/{{.APP_NAME}}*.deb" {{if .PGP_KEY}}--pgp-key "{{.PGP_KEY}}"{{end}} {{if .SIGN_ROLE}}--role "{{.SIGN_ROLE}}"{{end}}
sign:rpm:
summary: Signs the RPM package
desc: |
Signs the .rpm package with a PGP key.
Set PGP_KEY in the vars section to override the key configured globally via
`wails3 setup` (~/.config/wails/defaults.yaml).
Password is retrieved from system keychain (run: wails3 setup signing)
deps:
- task: create:rpm
cmds:
- wails3 tool sign --input "{{.BIN_DIR}}/{{.APP_NAME}}*.rpm" {{if .PGP_KEY}}--pgp-key "{{.PGP_KEY}}"{{end}}
sign:packages:
summary: Signs all Linux packages (DEB and RPM)
desc: |
Signs both .deb and .rpm packages with a PGP key.
Set PGP_KEY in the vars section to override the key configured globally via
`wails3 setup` (~/.config/wails/defaults.yaml).
Password is retrieved from system keychain (run: wails3 setup signing)
cmds:
- task: sign:deb
- task: sign:rpm

View File

@@ -0,0 +1,35 @@
#!/usr/bin/env bash
# Copyright (c) 2018-Present Lea Anthony
# SPDX-License-Identifier: MIT
# Fail script on any error
set -euxo pipefail
# Define variables
APP_DIR="${APP_NAME}.AppDir"
# Create AppDir structure
mkdir -p "${APP_DIR}/usr/bin"
cp -r "${APP_BINARY}" "${APP_DIR}/usr/bin/"
cp "${ICON_PATH}" "${APP_DIR}/"
cp "${DESKTOP_FILE}" "${APP_DIR}/"
if [[ $(uname -m) == *x86_64* ]]; then
# Download linuxdeploy and make it executable
wget -q -4 -N https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage
chmod +x linuxdeploy-x86_64.AppImage
# Run linuxdeploy to bundle the application
./linuxdeploy-x86_64.AppImage --appdir "${APP_DIR}" --output appimage
else
# Download linuxdeploy and make it executable (arm64)
wget -q -4 -N https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-aarch64.AppImage
chmod +x linuxdeploy-aarch64.AppImage
# Run linuxdeploy to bundle the application (arm64)
./linuxdeploy-aarch64.AppImage --appdir "${APP_DIR}" --output appimage
fi
# Rename the generated AppImage
mv "${APP_NAME}*.AppImage" "${APP_NAME}.AppImage"

13
build/linux/desktop Normal file
View File

@@ -0,0 +1,13 @@
[Desktop Entry]
Version=1.0
Name=My Product
Comment=My Product Description
# The Exec line includes %u to pass the URL to the application
Exec=/usr/local/bin/code-count.exe %u
Terminal=false
Type=Application
Icon=code-count.exe
Categories=Utility;
StartupWMClass=code-count.exe

View File

@@ -0,0 +1,80 @@
# Feel free to remove those if you don't want/need to use them.
# Make sure to check the documentation at https://nfpm.goreleaser.com
#
# The lines below are called `modelines`. See `:help modeline`
name: "code-count.exe"
arch: ${GOARCH}
platform: "linux"
version: "0.1.0"
section: "default"
priority: "extra"
maintainer: ${GIT_COMMITTER_NAME} <${GIT_COMMITTER_EMAIL}>
description: "My Product Description"
vendor: "liqi"
homepage: "https://wails.io"
license: "MIT"
release: "1"
contents:
- src: "./bin/code-count.exe"
dst: "/usr/local/bin/code-count.exe"
- src: "./build/appicon.png"
dst: "/usr/share/icons/hicolor/128x128/apps/code-count.exe.png"
- src: "./build/linux/code-count.exe.desktop"
dst: "/usr/share/applications/code-count.exe.desktop"
# Default dependencies for the GTK4 + WebKitGTK 6.0 stack (Ubuntu 24.04+ / Debian 13+)
depends:
- libgtk-4-1
- libwebkitgtk-6.0-4
# Distribution-specific overrides for different package formats
overrides:
# RPM packages for Fedora / RHEL / AlmaLinux / Rocky Linux
rpm:
depends:
- gtk4
- webkitgtk6.0
# Arch Linux packages
archlinux:
depends:
- gtk4
- webkitgtk-6.0
# scripts section to ensure desktop database is updated after install
scripts:
postinstall: "./build/linux/nfpm/scripts/postinstall.sh"
# You can also add preremove, postremove if needed
# preremove: "./build/linux/nfpm/scripts/preremove.sh"
# postremove: "./build/linux/nfpm/scripts/postremove.sh"
# If you build your app with -tags gtk3 (legacy WebKit2GTK 4.1 stack — supported through v3.0.x, removed in v3.1),
# replace the depends/overrides above with these:
#
# depends:
# - libgtk-3-0
# - libwebkit2gtk-4.1-0
# overrides:
# rpm:
# depends:
# - gtk3
# - webkit2gtk4.1
# archlinux:
# depends:
# - gtk3
# - webkit2gtk-4.1
#
# replaces:
# - foobar
# provides:
# - bar
# recommends:
# - whatever
# suggests:
# - something-else
# conflicts:
# - not-foo
# - not-bar
# changelog: "changelog.yaml"

View File

@@ -0,0 +1,21 @@
#!/bin/sh
# Update desktop database for .desktop file changes
# This makes the application appear in application menus and registers its capabilities.
if command -v update-desktop-database >/dev/null 2>&1; then
echo "Updating desktop database..."
update-desktop-database -q /usr/share/applications
else
echo "Warning: update-desktop-database command not found. Desktop file may not be immediately recognized." >&2
fi
# Update MIME database for custom URL schemes (x-scheme-handler)
# This ensures the system knows how to handle your custom protocols.
if command -v update-mime-database >/dev/null 2>&1; then
echo "Updating MIME database..."
update-mime-database -n /usr/share/mime
else
echo "Warning: update-mime-database command not found. Custom URL schemes may not be immediately recognized." >&2
fi
exit 0

View File

@@ -0,0 +1 @@
#!/bin/bash

View File

@@ -0,0 +1 @@
#!/bin/bash

View File

@@ -0,0 +1 @@
#!/bin/bash

195
build/windows/Taskfile.yml Normal file
View File

@@ -0,0 +1,195 @@
version: '3'
includes:
common: ../Taskfile.yml
vars:
# Signing configuration - edit these values for your project
# SIGN_CERTIFICATE: "path/to/certificate.pfx"
# SIGN_THUMBPRINT: "certificate-thumbprint" # Alternative to SIGN_CERTIFICATE
# TIMESTAMP_SERVER: "http://timestamp.digicert.com"
#
# Password is stored securely in system keychain. Run: wails3 setup signing
# Docker image for cross-compilation with CGO (used when CGO_ENABLED=1 on non-Windows)
CROSS_IMAGE: wails-cross
tasks:
build:
summary: Builds the application for Windows
cmds:
# Auto-detect CGO: if CGO_ENABLED=1, use Docker; otherwise use native Go cross-compile
- task: '{{if and (ne OS "windows") (eq .CGO_ENABLED "1")}}build:docker{{else}}build:native{{end}}'
vars:
ARCH: '{{.ARCH}}'
DEV: '{{.DEV}}'
EXTRA_TAGS: '{{.EXTRA_TAGS}}'
OBFUSCATED: '{{.OBFUSCATED}}'
GARBLE_ARGS: '{{.GARBLE_ARGS}}'
vars:
# Default to CGO_ENABLED=0 if not explicitly set
CGO_ENABLED: '{{.CGO_ENABLED | default "0"}}'
build:native:
summary: Builds the application using native Go cross-compilation
internal: true
deps:
- task: common:go:mod:tidy
- task: common:build:frontend
vars:
BUILD_FLAGS:
ref: .BUILD_FLAGS
OBFUSCATED:
ref: .OBFUSCATED
DEV:
ref: .DEV
- task: common:generate:icons
preconditions:
- sh: '{{if eq .OBFUSCATED "true"}}command -v garble >/dev/null 2>&1{{else}}true{{end}}'
msg: "garble is required for obfuscated builds. Install it with: go install mvdan.cc/garble@v0.16.0 (requires Go 1.24+). See https://github.com/burrowers/garble/releases for version/toolchain compatibility."
cmds:
- task: generate:syso
vars:
ARCH: '{{.ARCH}}'
- '{{if eq .OBFUSCATED "true"}}garble {{.GARBLE_ARGS}} build{{else}}go build{{end}} {{.BUILD_FLAGS}} -o "{{.BIN_DIR}}/{{.APP_NAME}}.exe"'
- cmd: powershell Remove-item *.syso
platforms: [windows]
- cmd: rm -f *.syso
platforms: [linux, darwin]
vars:
BUILD_FLAGS: '{{if eq .DEV "true"}}{{if or .EXTRA_TAGS (eq .OBFUSCATED "true")}}-tags {{if eq .OBFUSCATED "true"}}wails_obfuscated{{if .EXTRA_TAGS}},{{end}}{{end}}{{.EXTRA_TAGS}} {{end}}-buildvcs=false -gcflags=all="-l"{{else}}-tags production{{if eq .OBFUSCATED "true"}},wails_obfuscated{{end}}{{if .EXTRA_TAGS}},{{.EXTRA_TAGS}}{{end}} -trimpath -buildvcs=false -ldflags="-w -s -H windowsgui"{{end}}'
env:
GOOS: windows
CGO_ENABLED: '{{.CGO_ENABLED | default "0"}}'
GOARCH: '{{.ARCH | default ARCH}}'
build:docker:
summary: Cross-compiles for Windows using Docker with Zig (for CGO builds on non-Windows)
internal: true
deps:
- task: common:build:frontend
vars:
OBFUSCATED:
ref: .OBFUSCATED
- task: common:generate:icons
preconditions:
- sh: docker info > /dev/null 2>&1
msg: "Docker is required for CGO cross-compilation. Please install Docker."
- sh: docker image inspect {{.CROSS_IMAGE}} > /dev/null 2>&1
msg: |
Docker image '{{.CROSS_IMAGE}}' not found.
Build it first: wails3 task setup:docker
cmds:
- task: generate:syso
vars:
ARCH: '{{.ARCH}}'
- docker run --rm -v "{{.ROOT_DIR}}:/app" {{.DOCKER_MOUNTS}} -e APP_NAME="{{.APP_NAME}}" {{if .EXTRA_TAGS}}-e EXTRA_TAGS="{{.EXTRA_TAGS}}"{{end}} {{if eq .OBFUSCATED "true"}}-e OBFUSCATED=true{{end}} {{if .GARBLE_ARGS}}-e GARBLE_ARGS="{{.GARBLE_ARGS}}"{{end}} {{.CROSS_IMAGE}} windows {{.DOCKER_ARCH}}
- cmd: docker run --rm -v "{{.ROOT_DIR}}:/app" alpine chown -R $(id -u):$(id -g) /app/bin
platforms: [linux, darwin]
- rm -f *.syso
vars:
DOCKER_ARCH: '{{.ARCH | default "amd64"}}'
# Generate Docker volume mounts: Go module cache + go.mod replace directives
# Uses wails3 tool docker-mounts for cross-platform compatibility (Windows/Linux/macOS)
DOCKER_MOUNTS:
sh: 'wails3 tool docker-mounts'
package:
summary: Packages the application
cmds:
- task: '{{if eq (.FORMAT | default "nsis") "msix"}}create:msix:package{{else}}create:nsis:installer{{end}}'
vars:
INSTALL_SCOPE: '{{.INSTALL_SCOPE | default "machine"}}'
vars:
FORMAT: '{{.FORMAT | default "nsis"}}'
INSTALL_SCOPE: '{{.INSTALL_SCOPE | default "machine"}}'
generate:syso:
summary: Generates Windows `.syso` file
dir: build
cmds:
- wails3 generate syso -arch {{.ARCH}} -icon windows/icon.ico -manifest windows/wails.exe.manifest -info windows/info.json -out ../wails_windows_{{.ARCH}}.syso
vars:
ARCH: '{{.ARCH | default ARCH}}'
create:nsis:installer:
summary: Creates an NSIS installer
dir: build/windows/nsis
deps:
- task: build
preconditions:
- sh: '[ "{{.INSTALL_SCOPE}}" = "user" ] || [ "{{.INSTALL_SCOPE}}" = "machine" ]'
msg: "INSTALL_SCOPE must be 'user' or 'machine', got '{{.INSTALL_SCOPE}}'"
cmds:
# Create the Microsoft WebView2 bootstrapper if it doesn't exist
- wails3 generate webview2bootstrapper -dir "{{.ROOT_DIR}}/build/windows/nsis"
- |
{{if eq OS "windows"}}
makensis {{if eq .INSTALL_SCOPE "user"}}-DWAILS_INSTALL_SCOPE=user -DREQUEST_EXECUTION_LEVEL=user {{end}}-DARG_WAILS_{{.ARG_FLAG}}_BINARY="{{.ROOT_DIR}}\{{.BIN_DIR}}\{{.APP_NAME}}.exe" project.nsi
{{else}}
makensis {{if eq .INSTALL_SCOPE "user"}}-DWAILS_INSTALL_SCOPE=user -DREQUEST_EXECUTION_LEVEL=user {{end}}-DARG_WAILS_{{.ARG_FLAG}}_BINARY="{{.ROOT_DIR}}/{{.BIN_DIR}}/{{.APP_NAME}}.exe" project.nsi
{{end}}
vars:
ARCH: '{{.ARCH | default ARCH}}'
ARG_FLAG: '{{if eq .ARCH "amd64"}}AMD64{{else}}ARM64{{end}}'
INSTALL_SCOPE: '{{.INSTALL_SCOPE | default "machine"}}'
create:msix:package:
summary: Creates an MSIX package
deps:
- task: build
cmds:
- |-
wails3 tool msix \
--config "{{.ROOT_DIR}}/wails.json" \
--name "{{.APP_NAME}}" \
--executable "{{.ROOT_DIR}}/{{.BIN_DIR}}/{{.APP_NAME}}.exe" \
--arch "{{.ARCH}}" \
--out "{{.ROOT_DIR}}/{{.BIN_DIR}}/{{.APP_NAME}}-{{.ARCH}}.msix" \
{{if .CERT_PATH}}--cert "{{.CERT_PATH}}"{{end}} \
{{if .PUBLISHER}}--publisher "{{.PUBLISHER}}"{{end}} \
{{if .USE_MSIX_TOOL}}--use-msix-tool{{else}}--use-makeappx{{end}}
vars:
ARCH: '{{.ARCH | default ARCH}}'
CERT_PATH: '{{.CERT_PATH | default ""}}'
PUBLISHER: '{{.PUBLISHER | default ""}}'
USE_MSIX_TOOL: '{{.USE_MSIX_TOOL | default "false"}}'
install:msix:tools:
summary: Installs tools required for MSIX packaging
cmds:
- wails3 tool msix-install-tools
run:
cmds:
- '{{.BIN_DIR}}/{{.APP_NAME}}.exe'
sign:
summary: Signs the Windows executable
desc: |
Signs the .exe with an Authenticode certificate.
Set SIGN_CERTIFICATE or SIGN_THUMBPRINT to override the certificate
configured globally via `wails3 setup` (~/.config/wails/defaults.yaml).
Password is retrieved from system keychain (run: wails3 setup signing)
deps:
- task: build
cmds:
# Certificate vars are optional: if unset, `wails3 tool sign` falls back to
# the certificate configured globally via `wails3 setup`.
- wails3 tool sign --input "{{.BIN_DIR}}/{{.APP_NAME}}.exe" {{if .SIGN_CERTIFICATE}}--certificate "{{.SIGN_CERTIFICATE}}"{{end}} {{if .SIGN_THUMBPRINT}}--thumbprint "{{.SIGN_THUMBPRINT}}"{{end}} {{if .TIMESTAMP_SERVER}}--timestamp "{{.TIMESTAMP_SERVER}}"{{end}}
sign:installer:
summary: Signs the NSIS installer
desc: |
Creates and signs the NSIS installer.
Set SIGN_CERTIFICATE or SIGN_THUMBPRINT to override the certificate
configured globally via `wails3 setup` (~/.config/wails/defaults.yaml).
Password is retrieved from system keychain (run: wails3 setup signing)
deps:
- task: create:nsis:installer
vars:
INSTALL_SCOPE: '{{.INSTALL_SCOPE | default "machine"}}'
vars:
INSTALL_SCOPE: '{{.INSTALL_SCOPE | default "machine"}}'
cmds:
- wails3 tool sign --input "build/windows/nsis/{{.APP_NAME}}-installer.exe" {{if .SIGN_CERTIFICATE}}--certificate "{{.SIGN_CERTIFICATE}}"{{end}} {{if .SIGN_THUMBPRINT}}--thumbprint "{{.SIGN_THUMBPRINT}}"{{end}} {{if .TIMESTAMP_SERVER}}--timestamp "{{.TIMESTAMP_SERVER}}"{{end}}

View File

@@ -0,0 +1,55 @@
<?xml version="1.0" encoding="utf-8"?>
<Package
xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10"
xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"
xmlns:uap3="http://schemas.microsoft.com/appx/manifest/uap/windows10/3"
xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"
xmlns:desktop="http://schemas.microsoft.com/appx/manifest/desktop/windows10"
IgnorableNamespaces="uap3">
<Identity
Name="com.wails.code-count"
Publisher="CN=liqi"
Version="0.1.0.0"
ProcessorArchitecture="x64" />
<Properties>
<DisplayName>My Product</DisplayName>
<PublisherDisplayName>liqi</PublisherDisplayName>
<Description>My Product Description</Description>
<Logo>Assets\StoreLogo.png</Logo>
</Properties>
<Dependencies>
<TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.17763.0" MaxVersionTested="10.0.19041.0" />
</Dependencies>
<Resources>
<Resource Language="en-us" />
</Resources>
<Applications>
<Application Id="com.wails.code-count" Executable="code-count.exe" EntryPoint="Windows.FullTrustApplication">
<uap:VisualElements
DisplayName="My Product"
Description="My Product Description"
BackgroundColor="transparent"
Square150x150Logo="Assets\Square150x150Logo.png"
Square44x44Logo="Assets\Square44x44Logo.png">
<uap:DefaultTile Wide310x150Logo="Assets\Wide310x150Logo.png" />
<uap:SplashScreen Image="Assets\SplashScreen.png" />
</uap:VisualElements>
<Extensions>
<desktop:Extension Category="windows.fullTrustProcess" Executable="code-count.exe" />
</Extensions>
</Application>
</Applications>
<Capabilities>
<rescap:Capability Name="runFullTrust" />
</Capabilities>
</Package>

View File

@@ -0,0 +1,54 @@
<?xml version="1.0" encoding="utf-8"?>
<MsixPackagingToolTemplate
xmlns="http://schemas.microsoft.com/msix/packaging/msixpackagingtool/template/2022">
<Settings
AllowTelemetry="false"
ApplyACLsToPackageFiles="true"
GenerateCommandLineFile="true"
AllowPromptForPassword="false">
</Settings>
<Installer
Path="code-count.exe"
Arguments=""
InstallLocation="C:\Program Files\liqi\My Product">
</Installer>
<PackageInformation
PackageName="My Product"
PackageDisplayName="My Product"
PublisherName="CN=liqi"
PublisherDisplayName="liqi"
Version="0.1.0.0"
PackageDescription="My Product Description">
<Capabilities>
<Capability Name="runFullTrust" />
</Capabilities>
<Applications>
<Application
Id="com.wails.code-count"
Description="My Product Description"
DisplayName="My Product"
ExecutableName="code-count.exe"
EntryPoint="Windows.FullTrustApplication">
</Application>
</Applications>
<Resources>
<Resource Language="en-us" />
</Resources>
<Dependencies>
<TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.17763.0" MaxVersionTested="10.0.19041.0" />
</Dependencies>
<Properties>
<Framework>false</Framework>
<DisplayName>My Product</DisplayName>
<PublisherDisplayName>liqi</PublisherDisplayName>
<Description>My Product Description</Description>
<Logo>Assets\AppIcon.png</Logo>
</Properties>
</PackageInformation>
<SaveLocation PackagePath="code-count.msix" />
<PackageIntegrity>
<CertificatePath></CertificatePath>
</PackageIntegrity>
</MsixPackagingToolTemplate>

View File

@@ -0,0 +1,119 @@
Unicode true
####
## Please note: Template replacements don't work in this file. They are provided with default defines like
## mentioned underneath.
## If the keyword is not defined, "wails_tools.nsh" will populate them.
## If they are defined here, "wails_tools.nsh" will not touch them. This allows you to use this project.nsi manually
## from outside of Wails for debugging and development of the installer.
##
## For development first make a wails nsis build to populate the "wails_tools.nsh":
## > wails build --target windows/amd64 --nsis
## Then you can call makensis on this file with specifying the path to your binary:
## For a AMD64 only installer:
## > makensis -DARG_WAILS_AMD64_BINARY=..\..\bin\app.exe
## For a ARM64 only installer:
## > makensis -DARG_WAILS_ARM64_BINARY=..\..\bin\app.exe
## For a installer with both architectures:
## > makensis -DARG_WAILS_AMD64_BINARY=..\..\bin\app-amd64.exe -DARG_WAILS_ARM64_BINARY=..\..\bin\app-arm64.exe
####
## The following information is taken from the wails_tools.nsh file, but they can be overwritten here.
####
## !define INFO_PROJECTNAME "my-project" # Default "Code Count"
## !define INFO_COMPANYNAME "My Company" # Default "liqi"
## !define INFO_PRODUCTNAME "My Product Name" # Default "My Product"
## !define INFO_PRODUCTVERSION "1.0.0" # Default "0.1.0"
## !define INFO_COPYRIGHT "(c) Now, My Company" # Default "© now, My Company"
###
## !define PRODUCT_EXECUTABLE "Application.exe" # Default "${INFO_PROJECTNAME}.exe"
## !define UNINST_KEY_NAME "UninstKeyInRegistry" # Default "${INFO_COMPANYNAME}${INFO_PRODUCTNAME}"
####
## !define REQUEST_EXECUTION_LEVEL "admin" # Default "admin" see also https://nsis.sourceforge.io/Docs/Chapter4.html
## !define WAILS_INSTALL_SCOPE "user" # Default "machine" - set to "user" for per-user install ($LOCALAPPDATA) without UAC prompt
####
## Include the wails tools
####
!include "wails_tools.nsh"
# The version information for this two must consist of 4 parts
VIProductVersion "${INFO_PRODUCTVERSION}.0"
VIFileVersion "${INFO_PRODUCTVERSION}.0"
VIAddVersionKey "CompanyName" "${INFO_COMPANYNAME}"
VIAddVersionKey "FileDescription" "${INFO_PRODUCTNAME} Installer"
VIAddVersionKey "ProductVersion" "${INFO_PRODUCTVERSION}"
VIAddVersionKey "FileVersion" "${INFO_PRODUCTVERSION}"
VIAddVersionKey "LegalCopyright" "${INFO_COPYRIGHT}"
VIAddVersionKey "ProductName" "${INFO_PRODUCTNAME}"
# Enable HiDPI support. https://nsis.sourceforge.io/Reference/ManifestDPIAware
ManifestDPIAware true
!include "MUI.nsh"
!define MUI_ICON "..\icon.ico"
!define MUI_UNICON "..\icon.ico"
# !define MUI_WELCOMEFINISHPAGE_BITMAP "resources\leftimage.bmp" #Include this to add a bitmap on the left side of the Welcome Page. Must be a size of 164x314
!define MUI_FINISHPAGE_NOAUTOCLOSE # Wait on the INSTFILES page so the user can take a look into the details of the installation steps
!define MUI_ABORTWARNING # This will warn the user if they exit from the installer.
!insertmacro MUI_PAGE_WELCOME # Welcome to the installer page.
# !insertmacro MUI_PAGE_LICENSE "resources\eula.txt" # Adds a EULA page to the installer
!insertmacro MUI_PAGE_DIRECTORY # In which folder install page.
!insertmacro MUI_PAGE_INSTFILES # Installing page.
!insertmacro MUI_PAGE_FINISH # Finished installation page.
!insertmacro MUI_UNPAGE_INSTFILES # Uninstalling page
!insertmacro MUI_LANGUAGE "English" # Set the Language of the installer
## The following two statements can be used to sign the installer and the uninstaller. The path to the binaries are provided in %1
#!uninstfinalize 'signtool --file "%1"'
#!finalize 'signtool --file "%1"'
Name "${INFO_PRODUCTNAME}"
OutFile "..\..\..\bin\${INFO_PROJECTNAME}-${ARCH}-installer.exe" # Name of the installer's file.
!if "${WAILS_INSTALL_SCOPE}" == "user"
InstallDir "$LOCALAPPDATA\Programs\${INFO_PRODUCTNAME}"
!else
InstallDir "$PROGRAMFILES64\${INFO_COMPANYNAME}\${INFO_PRODUCTNAME}"
!endif
ShowInstDetails show # This will always show the installation details.
Function .onInit
!insertmacro wails.checkArchitecture
FunctionEnd
Section
!insertmacro wails.setShellContext
!insertmacro wails.webview2runtime
SetOutPath $INSTDIR
!insertmacro wails.files
CreateShortcut "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}"
CreateShortCut "$DESKTOP\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}"
!insertmacro wails.associateFiles
!insertmacro wails.associateCustomProtocols
!insertmacro wails.writeUninstaller
SectionEnd
Section "uninstall"
!insertmacro wails.setShellContext
RMDir /r "$AppData\${PRODUCT_EXECUTABLE}" # Remove the WebView2 DataPath
RMDir /r $INSTDIR
Delete "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk"
Delete "$DESKTOP\${INFO_PRODUCTNAME}.lnk"
!insertmacro wails.unassociateFiles
!insertmacro wails.unassociateCustomProtocols
!insertmacro wails.deleteUninstaller
SectionEnd

View File

@@ -0,0 +1,261 @@
# DO NOT EDIT - Generated automatically by `wails build`
!include "x64.nsh"
!include "WinVer.nsh"
!include "FileFunc.nsh"
!ifndef INFO_PROJECTNAME
!define INFO_PROJECTNAME "年糕崽崽项目管理PMS"
!endif
!ifndef INFO_COMPANYNAME
!define INFO_COMPANYNAME "liqi"
!endif
!ifndef INFO_PRODUCTNAME
!define INFO_PRODUCTNAME "My Product"
!endif
!ifndef INFO_PRODUCTVERSION
!define INFO_PRODUCTVERSION "0.1.0"
!endif
!ifndef INFO_COPYRIGHT
!define INFO_COPYRIGHT "© now, My Company"
!endif
!ifndef PRODUCT_EXECUTABLE
!define PRODUCT_EXECUTABLE "${INFO_PROJECTNAME}.exe"
!endif
!ifndef UNINST_KEY_NAME
!define UNINST_KEY_NAME "${INFO_COMPANYNAME}${INFO_PRODUCTNAME}"
!endif
!define UNINST_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\${UNINST_KEY_NAME}"
!ifndef WAILS_INSTALL_SCOPE
!define WAILS_INSTALL_SCOPE "machine"
!endif
!ifndef REQUEST_EXECUTION_LEVEL
!if "${WAILS_INSTALL_SCOPE}" == "user"
!define REQUEST_EXECUTION_LEVEL "user"
!else
!define REQUEST_EXECUTION_LEVEL "admin"
!endif
!endif
RequestExecutionLevel "${REQUEST_EXECUTION_LEVEL}"
!ifdef ARG_WAILS_AMD64_BINARY
!define SUPPORTS_AMD64
!endif
!ifdef ARG_WAILS_ARM64_BINARY
!define SUPPORTS_ARM64
!endif
!ifdef SUPPORTS_AMD64
!ifdef SUPPORTS_ARM64
!define ARCH "amd64_arm64"
!else
!define ARCH "amd64"
!endif
!else
!ifdef SUPPORTS_ARM64
!define ARCH "arm64"
!else
!error "Wails: Undefined ARCH, please provide at least one of ARG_WAILS_AMD64_BINARY or ARG_WAILS_ARM64_BINARY"
!endif
!endif
!macro wails.checkArchitecture
!ifndef WAILS_WIN10_REQUIRED
!define WAILS_WIN10_REQUIRED "This product is only supported on Windows 10 (Server 2016) and later."
!endif
!ifndef WAILS_ARCHITECTURE_NOT_SUPPORTED
!define WAILS_ARCHITECTURE_NOT_SUPPORTED "This product can't be installed on the current Windows architecture. Supports: ${ARCH}"
!endif
${If} ${AtLeastWin10}
!ifdef SUPPORTS_AMD64
${if} ${IsNativeAMD64}
Goto ok
${EndIf}
!endif
!ifdef SUPPORTS_ARM64
${if} ${IsNativeARM64}
Goto ok
${EndIf}
!endif
IfSilent silentArch notSilentArch
silentArch:
SetErrorLevel 65
Abort
notSilentArch:
MessageBox MB_OK "${WAILS_ARCHITECTURE_NOT_SUPPORTED}"
Quit
${else}
IfSilent silentWin notSilentWin
silentWin:
SetErrorLevel 64
Abort
notSilentWin:
MessageBox MB_OK "${WAILS_WIN10_REQUIRED}"
Quit
${EndIf}
ok:
!macroend
!macro wails.files
!ifdef SUPPORTS_AMD64
${if} ${IsNativeAMD64}
File "/oname=${PRODUCT_EXECUTABLE}" "${ARG_WAILS_AMD64_BINARY}"
${EndIf}
!endif
!ifdef SUPPORTS_ARM64
${if} ${IsNativeARM64}
File "/oname=${PRODUCT_EXECUTABLE}" "${ARG_WAILS_ARM64_BINARY}"
${EndIf}
!endif
!macroend
!macro wails.writeUninstaller
WriteUninstaller "$INSTDIR\uninstall.exe"
SetRegView 64
!if "${WAILS_INSTALL_SCOPE}" == "user"
WriteRegStr HKCU "${UNINST_KEY}" "Publisher" "${INFO_COMPANYNAME}"
WriteRegStr HKCU "${UNINST_KEY}" "DisplayName" "${INFO_PRODUCTNAME}"
WriteRegStr HKCU "${UNINST_KEY}" "DisplayVersion" "${INFO_PRODUCTVERSION}"
WriteRegStr HKCU "${UNINST_KEY}" "DisplayIcon" "$INSTDIR\${PRODUCT_EXECUTABLE}"
WriteRegStr HKCU "${UNINST_KEY}" "UninstallString" "$\"$INSTDIR\uninstall.exe$\""
WriteRegStr HKCU "${UNINST_KEY}" "QuietUninstallString" "$\"$INSTDIR\uninstall.exe$\" /S"
${GetSize} "$INSTDIR" "/S=0K" $0 $1 $2
IntFmt $0 "0x%08X" $0
WriteRegDWORD HKCU "${UNINST_KEY}" "EstimatedSize" "$0"
!else
WriteRegStr HKLM "${UNINST_KEY}" "Publisher" "${INFO_COMPANYNAME}"
WriteRegStr HKLM "${UNINST_KEY}" "DisplayName" "${INFO_PRODUCTNAME}"
WriteRegStr HKLM "${UNINST_KEY}" "DisplayVersion" "${INFO_PRODUCTVERSION}"
WriteRegStr HKLM "${UNINST_KEY}" "DisplayIcon" "$INSTDIR\${PRODUCT_EXECUTABLE}"
WriteRegStr HKLM "${UNINST_KEY}" "UninstallString" "$\"$INSTDIR\uninstall.exe$\""
WriteRegStr HKLM "${UNINST_KEY}" "QuietUninstallString" "$\"$INSTDIR\uninstall.exe$\" /S"
${GetSize} "$INSTDIR" "/S=0K" $0 $1 $2
IntFmt $0 "0x%08X" $0
WriteRegDWORD HKLM "${UNINST_KEY}" "EstimatedSize" "$0"
!endif
!macroend
!macro wails.deleteUninstaller
Delete "$INSTDIR\uninstall.exe"
SetRegView 64
!if "${WAILS_INSTALL_SCOPE}" == "user"
DeleteRegKey HKCU "${UNINST_KEY}"
!else
DeleteRegKey HKLM "${UNINST_KEY}"
!endif
!macroend
!macro wails.setShellContext
${If} ${REQUEST_EXECUTION_LEVEL} == "admin"
SetShellVarContext all
${else}
SetShellVarContext current
${EndIf}
!macroend
# Install webview2 by launching the bootstrapper
# See https://docs.microsoft.com/en-us/microsoft-edge/webview2/concepts/distribution#online-only-deployment
!macro wails.webview2runtime
!ifndef WAILS_INSTALL_WEBVIEW_DETAILPRINT
!define WAILS_INSTALL_WEBVIEW_DETAILPRINT "Installing: WebView2 Runtime"
!endif
SetRegView 64
# If the admin key exists and is not empty then webview2 is already installed
ReadRegStr $0 HKLM "SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv"
${If} $0 != ""
Goto ok
${EndIf}
${If} ${REQUEST_EXECUTION_LEVEL} == "user"
# If the installer is run in user level, check the user specific key exists and is not empty then webview2 is already installed
ReadRegStr $0 HKCU "Software\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv"
${If} $0 != ""
Goto ok
${EndIf}
${EndIf}
SetDetailsPrint both
DetailPrint "${WAILS_INSTALL_WEBVIEW_DETAILPRINT}"
SetDetailsPrint listonly
InitPluginsDir
CreateDirectory "$pluginsdir\webview2bootstrapper"
SetOutPath "$pluginsdir\webview2bootstrapper"
File "MicrosoftEdgeWebview2Setup.exe"
ExecWait '"$pluginsdir\webview2bootstrapper\MicrosoftEdgeWebview2Setup.exe" /silent /install'
SetDetailsPrint both
ok:
!macroend
# Copy of APP_ASSOCIATE and APP_UNASSOCIATE macros from here https://gist.github.com/nikku/281d0ef126dbc215dd58bfd5b3a5cd5b
!macro APP_ASSOCIATE EXT FILECLASS DESCRIPTION ICON COMMANDTEXT COMMAND
; Backup the previously associated file class
ReadRegStr $R0 SHELL_CONTEXT "Software\Classes\.${EXT}" ""
WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "${FILECLASS}_backup" "$R0"
WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "" "${FILECLASS}"
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}" "" `${DESCRIPTION}`
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\DefaultIcon" "" `${ICON}`
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell" "" "open"
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell\open" "" `${COMMANDTEXT}`
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell\open\command" "" `${COMMAND}`
!macroend
!macro APP_UNASSOCIATE EXT FILECLASS
; Backup the previously associated file class
ReadRegStr $R0 SHELL_CONTEXT "Software\Classes\.${EXT}" `${FILECLASS}_backup`
WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "" "$R0"
DeleteRegKey SHELL_CONTEXT `Software\Classes\${FILECLASS}`
!macroend
!macro wails.associateFiles
; Create file associations
!macroend
!macro wails.unassociateFiles
; Delete app associations
!macroend
!macro CUSTOM_PROTOCOL_ASSOCIATE PROTOCOL DESCRIPTION ICON COMMAND
DeleteRegKey SHELL_CONTEXT "Software\Classes\${PROTOCOL}"
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}" "" "${DESCRIPTION}"
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}" "URL Protocol" ""
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\DefaultIcon" "" "${ICON}"
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell" "" ""
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell\open" "" ""
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell\open\command" "" "${COMMAND}"
!macroend
!macro CUSTOM_PROTOCOL_UNASSOCIATE PROTOCOL
DeleteRegKey SHELL_CONTEXT "Software\Classes\${PROTOCOL}"
!macroend
!macro wails.associateCustomProtocols
; Create custom protocols associations
!macroend
!macro wails.unassociateCustomProtocols
; Delete app custom protocol associations
!macroend

150
clone.go Normal file
View File

@@ -0,0 +1,150 @@
package main
import (
"bufio"
"bytes"
"context"
"errors"
"os"
"os/exec"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
"view/platform"
)
// ---------------- 从 Git 克隆并添加项目 ----------------
// repoNameFromURL 从 Git 地址推导目录名(支持 https / ssh / 本地路径形式)。
func repoNameFromURL(u string) string {
s := strings.TrimSpace(u)
s = strings.TrimRight(s, "/\\")
s = strings.TrimSuffix(s, ".git")
if i := strings.LastIndexAny(s, "/:\\"); i >= 0 {
s = s[i+1:]
}
s = regexp.MustCompile(`[<>:"/\\|?*\s]+`).ReplaceAllString(s, "-")
return strings.Trim(s, "-.")
}
var cloneProgressRe = regexp.MustCompile(`(Counting objects|Compressing objects|Receiving objects|Resolving deltas):\s+(\d+)%`)
// cloneProgress 把 git 各阶段百分比映射到 0-98 的总体进度。
func cloneProgress(line string) (int, bool) {
m := cloneProgressRe.FindStringSubmatch(line)
if m == nil {
return 0, false
}
pct, _ := strconv.Atoi(m[2])
switch m[1] {
case "Counting objects":
return 2 + pct*3/100, true
case "Compressing objects":
return 5 + pct*5/100, true
case "Receiving objects":
return 10 + pct*75/100, true
default: // Resolving deltas
return 85 + pct*13/100, true
}
}
// scanCRLines 以 \r 或 \n 切行git 进度用回车原地刷新,普通行以换行结束。
func scanCRLines(data []byte, atEOF bool) (int, []byte, error) {
if atEOF && len(data) == 0 {
return 0, nil, nil
}
if i := bytes.IndexAny(data, "\r\n"); i >= 0 {
return i + 1, data[:i], nil
}
if atEOF {
return len(data), data, nil
}
return 0, nil, nil
}
// CloneProject 把远程仓库克隆到指定父目录后直接加入项目列表。
// 进度通过 analysis:progress 事件复用任务条 / 全屏加载展示。
func (a *App) CloneProject(in CloneInput) (Project, error) {
if e := a.ready(); e != nil {
return Project{}, e
}
url := strings.TrimSpace(in.URL)
if url == "" {
return Project{}, errors.New("URL_REQUIRED")
}
parent := strings.TrimSpace(in.ParentDir)
if st, e := os.Stat(parent); e != nil || !st.IsDir() {
return Project{}, errors.New("DIR_NOT_FOUND")
}
name := strings.TrimSpace(in.Name)
if name == "" {
name = repoNameFromURL(url)
}
if name == "" {
return Project{}, errors.New("NAME_REQUIRED")
}
target := filepath.Join(parent, name)
if ents, e := os.ReadDir(target); e == nil && len(ents) > 0 {
return Project{}, errors.New("TARGET_NOT_EMPTY")
}
taskID := "clone-" + name
emitP := func(stage string, progress int, key string) {
a.emit("analysis:progress", TaskEvent{TaskID: taskID, Stage: stage, Progress: progress, MessageKey: key, Params: map[string]any{"project": name}})
}
emitP("cloning", 1, "task.cloning")
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute)
defer cancel()
cmd := exec.CommandContext(ctx, "git", "clone", "--progress", url, target)
// 禁止 git 弹出交互式凭据询问,私有仓库直接快速失败
cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0", "GCM_INTERACTIVE=never")
platform.ConfigureHidden(cmd)
stderr, e := cmd.StderrPipe()
if e != nil {
emitP("error", 100, "task.cloneFailed")
return Project{}, e
}
if e := cmd.Start(); e != nil {
emitP("error", 100, "task.cloneFailed")
a.store.Log("error", "项目", "Git 克隆失败", url+" | "+e.Error())
return Project{}, e
}
sc := bufio.NewScanner(stderr)
sc.Split(scanCRLines)
tail := []string{}
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if line == "" {
continue
}
if len(tail) >= 6 {
tail = tail[1:]
}
tail = append(tail, line)
if p, ok := cloneProgress(line); ok {
emitP("cloning", p, "task.cloning")
}
}
if e := cmd.Wait(); e != nil {
_ = os.RemoveAll(target) // 清掉半成品目录(克隆前已确认为空/不存在)
msg := strings.Join(tail, "\n")
a.store.Log("error", "项目", "Git 克隆失败", url+" | "+msg)
emitP("error", 100, "task.cloneFailed")
if msg != "" {
return Project{}, errors.New(msg)
}
return Project{}, e
}
p, e := a.SaveProject(0, ProjectInput{Name: name, Path: target, Description: in.Description, GroupID: in.GroupID})
if e != nil {
emitP("error", 100, "task.cloneFailed")
return p, e
}
a.store.Log("info", "项目", "Git 克隆成功", url+" -> "+target)
emitP("completed", 100, "task.cloneDone")
return p, nil
}

45
clone_test.go Normal file
View File

@@ -0,0 +1,45 @@
package main
import "testing"
func TestRepoNameFromURL(t *testing.T) {
cases := map[string]string{
"https://github.com/user/my-repo.git": "my-repo",
"https://github.com/user/my-repo": "my-repo",
"https://github.com/user/my-repo/": "my-repo",
"git@github.com:user/awesome.git": "awesome",
"ssh://git@host:2222/team/svc.git": "svc",
"https://gitee.com/a/b c.git": "b-c",
" https://github.com/x/trim.git ": "trim",
"https://github.com/user/dots...git": "dots", // 残余尾点会被修剪Windows 目录名尾部点非法)
"https://github.com/user/UPPER.git": "UPPER",
`D:\code\my-repo`: "my-repo", // Windows 本地路径克隆
`D:\code\my-repo\`: "my-repo",
}
for in, want := range cases {
if got := repoNameFromURL(in); got != want {
t.Errorf("repoNameFromURL(%q)=%q want %q", in, got, want)
}
}
}
func TestCloneProgress(t *testing.T) {
cases := []struct {
line string
want int
ok bool
}{
{"Receiving objects: 0% (1/100)", 10, true},
{"Receiving objects: 100% (100/100), done.", 85, true},
{"Resolving deltas: 100% (50/50), done.", 98, true},
{"Counting objects: 50% (5/10)", 3, true},
{"Compressing objects: 100% (9/9), done.", 10, true},
{"Cloning into 'demo'...", 0, false},
}
for _, c := range cases {
got, ok := cloneProgress(c.line)
if ok != c.ok || got != c.want {
t.Errorf("cloneProgress(%q)=(%d,%v) want (%d,%v)", c.line, got, ok, c.want, c.ok)
}
}
}

197
cloudbind.go Normal file
View File

@@ -0,0 +1,197 @@
package main
// cloudbind.go 实现“项目身份与机器路径分离”的同步支撑:
// - machineID本机稳定标识哈希后缓存云端行名 paths:<machineID>
// - machinePathsDoc / applyMachinePaths只推拉本机的 {项目名:路径} 行
// - 云端有身份但本机没路径的项目进入待绑定清单,由 Dashboard 引导用户选目录绑定。
import (
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"os"
"strings"
)
// machineID 返回本机稳定标识(原始 ID 哈希后取 16 个十六进制字符),缓存于 meta。
func (a *App) machineID() string {
if a.store == nil {
return "unknown"
}
if v := a.store.Meta("machine_id"); v != "" {
return v
}
raw := rawMachineID()
if raw == "" {
raw, _ = os.Hostname()
}
if raw == "" {
raw = "unknown-machine"
}
sum := sha256.Sum256([]byte(strings.ToLower(strings.TrimSpace(raw))))
id := hex.EncodeToString(sum[:8])
_ = a.store.SetMeta("machine_id", id)
return id
}
// machinePathsDoc 生成本机路径文档 {项目名: 本机路径}map 序列化按键排序,内容稳定可比较)。
func (a *App) machinePathsDoc() (string, error) {
rows, e := a.store.db.Query(`SELECT name,path FROM projects ORDER BY name`)
if e != nil {
return "", e
}
defer rows.Close()
m := map[string]string{}
for rows.Next() {
var name, path string
if e := rows.Scan(&name, &path); e != nil {
return "", e
}
if name != "" && path != "" {
m[name] = path
}
}
b, e := json.Marshal(m)
return string(b), e
}
// applyMachinePaths 套用本机路径文档(换库/重装同机场景):
// 本地没有该名项目且路径非空 → 直接入库;已有同名项目但路径不同 → 以远端为准更新。
func (a *App) applyMachinePaths(doc string) error {
var m map[string]string
if json.Unmarshal([]byte(doc), &m) != nil {
return nil // 远端数据异常时忽略,不阻断同步
}
for name, path := range m {
name, path = strings.TrimSpace(name), strings.TrimSpace(path)
if name == "" || path == "" {
continue
}
var pid int64
var cur string
e := a.store.db.QueryRow(`SELECT id,path FROM projects WHERE name=? LIMIT 1`, name).Scan(&pid, &cur)
switch {
case e == sql.ErrNoRows:
now := nowRFC()
// path 全局唯一同路径已有别名项目时不强插IGNORE
if _, e := a.store.db.Exec(`INSERT OR IGNORE INTO projects(name,path,description,group_id,created_at,updated_at) VALUES(?,?,'',1,?,?)`,
name, path, now, now); e != nil {
return e
}
case e != nil:
return e
case cur != path:
if _, e := a.store.db.Exec(`UPDATE projects SET path=?,updated_at=? WHERE id=?
AND NOT EXISTS(SELECT 1 FROM projects p2 WHERE p2.path=? AND p2.id!=?)`,
path, nowRFC(), pid, path, pid); e != nil {
return e
}
}
}
return nil
}
// rebuildCloudPending 以最近一次云端身份文档为基准,重算仍未落地本机的项目清单(幂等)。
func (a *App) rebuildCloudPending(entries []projectDocEntry) {
pending := []projectDocEntry{}
for _, it := range entries {
name := strings.TrimSpace(it.Name)
if name == "" {
continue
}
var pid int64
if a.store.db.QueryRow(`SELECT id FROM projects WHERE name=? LIMIT 1`, name).Scan(&pid) == sql.ErrNoRows {
it.Path = "" // 待绑定清单不携带其它机器的路径
pending = append(pending, it)
}
}
b, _ := json.Marshal(pending)
_ = a.store.SetMeta("cloud_projects_pending", string(b))
}
// ListCloudPendingProjects 返回云端存在但本机尚未绑定路径的项目。
func (a *App) ListCloudPendingProjects() ([]projectDocEntry, error) {
if e := a.ready(); e != nil {
return nil, e
}
out := []projectDocEntry{}
raw := a.store.Meta("cloud_projects_pending")
if raw == "" {
return out, nil
}
var list []projectDocEntry
if json.Unmarshal([]byte(raw), &list) != nil {
return out, nil
}
for _, it := range list {
// 本地可能在同步间隙手动建了同名项目,再过滤一遍
var pid int64
if a.store.db.QueryRow(`SELECT id FROM projects WHERE name=? LIMIT 1`, it.Name).Scan(&pid) == sql.ErrNoRows {
out = append(out, it)
}
}
return out, nil
}
// BindCloudProject 把云端项目绑定到本机目录:带身份信息入库、移出待绑定清单,并触发一轮推送。
func (a *App) BindCloudProject(name, path string) (Project, error) {
if e := a.ready(); e != nil {
return Project{}, e
}
name, path = strings.TrimSpace(name), strings.TrimSpace(path)
if name == "" {
return Project{}, errors.New("NAME_REQUIRED")
}
if fi, e := os.Stat(path); e != nil || !fi.IsDir() {
return Project{}, errors.New("DIR_NOT_FOUND")
}
entry := projectDocEntry{Name: name}
if raw := a.store.Meta("cloud_projects_pending"); raw != "" {
var list []projectDocEntry
if json.Unmarshal([]byte(raw), &list) == nil {
rest := make([]projectDocEntry, 0, len(list))
for _, it := range list {
if strings.TrimSpace(it.Name) == name {
entry = it
continue
}
rest = append(rest, it)
}
b, _ := json.Marshal(rest)
_ = a.store.SetMeta("cloud_projects_pending", string(b))
}
}
gid, e := a.ensureGroupID(entry.Group)
if e != nil {
return Project{}, e
}
var pid int64
now := nowRFC()
switch e := a.store.db.QueryRow(`SELECT id FROM projects WHERE path=?`, path).Scan(&pid); {
case e == sql.ErrNoRows:
r, e2 := a.store.db.Exec(`INSERT INTO projects(name,path,description,group_id,created_at,updated_at) VALUES(?,?,?,?,?,?)`,
name, path, entry.Description, gid, now, now)
if e2 != nil {
return Project{}, e2
}
pid, _ = r.LastInsertId()
case e != nil:
return Project{}, e
default:
// 该目录已是本地项目:改名并入云端身份,避免出现两个同路径项目。
if _, e := a.store.db.Exec(`UPDATE projects SET name=?,description=?,group_id=?,updated_at=? WHERE id=?`,
name, entry.Description, gid, now, pid); e != nil {
return Project{}, e
}
}
if entry.Favorite {
_, _ = a.store.db.Exec(`INSERT OR IGNORE INTO favorites(project_id,created_at) VALUES(?,?)`, pid, now)
}
a.store.Log("info", "同步", "云端项目已绑定本机目录", name+" → "+path)
if a.syncUserID() > 0 {
go a.syncOnce(true)
}
return a.store.GetProject(pid)
}

215
festival.go Normal file
View File

@@ -0,0 +1,215 @@
package main
// festival.go 日历节日/节气自定义背景图。
// 管理员(云端账号 id=1上传后写入本地 festival_images 表并随同步分发:
// 云端存放在 sync_settings 的 fest_img:<节日名> 行(统一挂在 id=1 名下),
// 管理员推送本地修改,所有账号拉取,实现"管理员配置、全员可见"。
//
// 行 value 为 JSON {"mode":"photo|art","image":"data:..."}
// mode 决定日历格用照片还是动态插画art 模式仍保留图片,切回无需重传);
// 兼容旧格式value 直接是 dataURL视为 photo 模式);空值为删除墓碑。
import (
"encoding/base64"
"encoding/json"
"errors"
"os"
"strings"
"time"
"github.com/wailsapp/wails/v3/pkg/application"
)
const (
festivalAdminID = 1
festivalImgEdge = 760 // 铺在日历格背景不需要原图精度,压小以控制同步体积
)
func parseFestivalValue(v string) (FestivalImage, bool) {
if v == "" {
return FestivalImage{}, false
}
if strings.HasPrefix(v, "data:") {
return FestivalImage{Mode: "photo", Image: v}, true // 旧格式:裸 dataURL
}
var f FestivalImage
if json.Unmarshal([]byte(v), &f) != nil || f.Image == "" {
return FestivalImage{}, false
}
if f.Mode != "art" {
f.Mode = "photo"
}
return f, true
}
// ListFestivalImages 返回节日名 → 背景配置的映射(本地缓存,含拉取到的云端配置)。
func (a *App) ListFestivalImages() (map[string]FestivalImage, error) {
if e := a.ready(); e != nil {
return nil, e
}
rows, e := a.store.db.Query(`SELECT fest_key,value FROM festival_images WHERE value!=''`)
if e != nil {
return nil, e
}
defer rows.Close()
m := map[string]FestivalImage{}
for rows.Next() {
var k, v string
if e := rows.Scan(&k, &v); e != nil {
return nil, e
}
if f, ok := parseFestivalValue(v); ok {
m[k] = f
}
}
return m, rows.Err()
}
func (a *App) festivalAdminGate() error {
if a.syncUserID() != festivalAdminID {
return errors.New("FESTIVAL_IMG_ADMIN_ONLY")
}
return nil
}
// PickFestivalImage 管理员为某个节日选择本地图片;处理保存后返回背景配置,取消返回空 image。
func (a *App) PickFestivalImage(key string) (FestivalImage, error) {
if e := a.ready(); e != nil {
return FestivalImage{}, e
}
if e := a.festivalAdminGate(); e != nil {
return FestivalImage{}, e
}
p, e := application.Get().Dialog.OpenFile().
SetTitle(a.localized("选择节日背景图", "Choose festival background")).
AddFilter(a.localized("图片文件", "Image files"), "*.png;*.jpg;*.jpeg;*.gif;*.webp").
PromptForSingleSelection()
if e != nil || strings.TrimSpace(p) == "" {
return FestivalImage{}, e
}
info, e := os.Stat(p)
if e != nil {
return FestivalImage{}, errors.New("FILE_NOT_FOUND")
}
if info.Size() > contentImgMaxBytes {
return FestivalImage{}, errors.New("IMAGE_FILE_TOO_LARGE")
}
raw, e := os.ReadFile(p)
if e != nil {
return FestivalImage{}, errors.New("FILE_READ_FAILED")
}
return a.saveFestivalImage(key, raw)
}
// SetFestivalImageData 管理员以 dataURL 形式设置节日图(自动化/粘贴场景)。
func (a *App) SetFestivalImageData(key, dataURL string) (FestivalImage, error) {
if e := a.ready(); e != nil {
return FestivalImage{}, e
}
if e := a.festivalAdminGate(); e != nil {
return FestivalImage{}, e
}
raw, e := dataURLBytes(dataURL)
if e != nil {
return FestivalImage{}, e
}
return a.saveFestivalImage(key, raw)
}
// SetFestivalImageMode 切换某节日的显示模式photo=自定义照片art=动态插画。
// 仅在已有图片的行上生效(无图时本就显示动态插画)。
func (a *App) SetFestivalImageMode(key, mode string) error {
if e := a.ready(); e != nil {
return e
}
if e := a.festivalAdminGate(); e != nil {
return e
}
key = strings.TrimSpace(key)
if key == "" || (mode != "photo" && mode != "art") {
return errors.New("FESTIVAL_IMG_BAD_KEY")
}
var v string
if e := a.store.db.QueryRow(`SELECT value FROM festival_images WHERE fest_key=?`, key).Scan(&v); e != nil {
return errors.New("FESTIVAL_IMG_NOT_FOUND")
}
f, ok := parseFestivalValue(v)
if !ok {
return errors.New("FESTIVAL_IMG_NOT_FOUND")
}
if f.Mode == mode {
return nil
}
f.Mode = mode
payload, e := json.Marshal(f)
if e != nil {
return e
}
if _, e := a.store.db.Exec(`UPDATE festival_images SET value=?,updated_at=?,dirty=1 WHERE fest_key=?`,
string(payload), a.festNextStamp(key), key); e != nil {
return e
}
a.store.Log("info", "日历", "节日背景模式已切换", key+" → "+mode)
go a.syncOnce(true)
return nil
}
// RemoveFestivalImage 移除自定义图(保留行并置空,作为删除标记同步给其他设备)。
func (a *App) RemoveFestivalImage(key string) error {
if e := a.ready(); e != nil {
return e
}
if e := a.festivalAdminGate(); e != nil {
return e
}
key = strings.TrimSpace(key)
if key == "" {
return errors.New("FESTIVAL_IMG_BAD_KEY")
}
if _, e := a.store.db.Exec(`INSERT INTO festival_images(fest_key,value,updated_at,dirty) VALUES(?,'',?,1)
ON CONFLICT(fest_key) DO UPDATE SET value='',updated_at=excluded.updated_at,dirty=1`, key, a.festNextStamp(key)); e != nil {
return e
}
a.store.Log("info", "日历", "节日图片已移除", key)
go a.syncOnce(true)
return nil
}
// festNextStamp 生成该节日行的下一个时间戳,保证严格大于现有值:
// nowRFC 精度只有秒,同一秒内先设图再移除会得到相同时间戳,
// 云端 LWW 的严格大于比较会拒绝第二次写入,本地却已清 dirty造成永久分叉。
func (a *App) festNextStamp(key string) string {
now := nowRFC()
var prev string
_ = a.store.db.QueryRow(`SELECT updated_at FROM festival_images WHERE fest_key=?`, key).Scan(&prev)
if prev < now {
return now
}
if t, e := time.Parse(time.RFC3339, prev); e == nil {
return t.Add(time.Second).UTC().Format(time.RFC3339)
}
return now
}
func (a *App) saveFestivalImage(key string, raw []byte) (FestivalImage, error) {
key = strings.TrimSpace(key)
if key == "" || len(key) > 40 {
return FestivalImage{}, errors.New("FESTIVAL_IMG_BAD_KEY")
}
data, mime, e := encodeImageEdge(raw, festivalImgEdge)
if e != nil {
return FestivalImage{}, e
}
f := FestivalImage{Mode: "photo", Image: "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(data)}
payload, e := json.Marshal(f)
if e != nil {
return FestivalImage{}, e
}
if _, e := a.store.db.Exec(`INSERT INTO festival_images(fest_key,value,updated_at,dirty) VALUES(?,?,?,1)
ON CONFLICT(fest_key) DO UPDATE SET value=excluded.value,updated_at=excluded.updated_at,dirty=1`, key, string(payload), a.festNextStamp(key)); e != nil {
return FestivalImage{}, e
}
a.store.Log("info", "日历", "节日图片已更新", key)
go a.syncOnce(true)
return f, nil
}

219
festival_test.go Normal file
View File

@@ -0,0 +1,219 @@
package main
// festival_test.go covers festival background image bindings:
// admin gate (cloud user id must be 1), save/list/remove round trip,
// display mode switching, legacy payload parsing, dirty flags for sync.
import (
"bytes"
"encoding/base64"
"image"
"image/color"
"image/png"
"strings"
"testing"
)
// festTestApp returns an app whose sync target points at an unreachable port,
// so the background sync kick started by save/remove fails fast and touches nothing.
func festTestApp(t *testing.T) *App {
a := newSyncTestApp(t)
_ = a.store.SetMeta("sync_host", "127.0.0.1")
_ = a.store.SetMeta("sync_port", "1")
_ = a.store.SetMeta("sync_user", "x")
_ = a.store.SetMeta("sync_database", "nope")
return a
}
func festPngDataURL(t *testing.T, w, h int, c color.Color) string {
t.Helper()
img := image.NewRGBA(image.Rect(0, 0, w, h))
for x := 0; x < w; x++ {
for y := 0; y < h; y++ {
img.Set(x, y, c)
}
}
var buf bytes.Buffer
if e := png.Encode(&buf, img); e != nil {
t.Fatal(e)
}
return "data:image/png;base64," + base64.StdEncoding.EncodeToString(buf.Bytes())
}
func TestFestivalImageAdminGate(t *testing.T) {
a := festTestApp(t)
data := festPngDataURL(t, 8, 8, color.RGBA{200, 40, 40, 255})
// not logged in
if _, e := a.SetFestivalImageData("春节", data); e == nil || e.Error() != "FESTIVAL_IMG_ADMIN_ONLY" {
t.Fatalf("expected admin-only error when logged out, got %v", e)
}
// logged in as a regular user
_ = a.store.SetMeta("sync_user_id", "2")
if _, e := a.SetFestivalImageData("春节", data); e == nil || e.Error() != "FESTIVAL_IMG_ADMIN_ONLY" {
t.Fatalf("expected admin-only error for user 2, got %v", e)
}
if e := a.RemoveFestivalImage("春节"); e == nil || e.Error() != "FESTIVAL_IMG_ADMIN_ONLY" {
t.Fatalf("expected admin-only error on remove, got %v", e)
}
if e := a.SetFestivalImageMode("春节", "art"); e == nil || e.Error() != "FESTIVAL_IMG_ADMIN_ONLY" {
t.Fatalf("expected admin-only error on mode switch, got %v", e)
}
}
func TestFestivalImageSaveListRemove(t *testing.T) {
a := festTestApp(t)
_ = a.store.SetMeta("sync_user_id", "1")
f, e := a.SetFestivalImageData("春节", festPngDataURL(t, 900, 300, color.RGBA{220, 30, 30, 255}))
if e != nil {
t.Fatal(e)
}
// uploading implies photo mode; opaque input re-encodes as jpeg below the edge cap
if f.Mode != "photo" || !strings.HasPrefix(f.Image, "data:image/jpeg;base64,") {
t.Fatalf("unexpected payload: mode=%q image=%.40s", f.Mode, f.Image)
}
if len(f.Image) > 200_000 {
t.Fatalf("encoded image unexpectedly large: %d", len(f.Image))
}
m, e := a.ListFestivalImages()
if e != nil {
t.Fatal(e)
}
if m["春节"].Image != f.Image || m["春节"].Mode != "photo" {
t.Fatalf("list mismatch: got %d entries", len(m))
}
var dirty int
if e := a.store.db.QueryRow(`SELECT dirty FROM festival_images WHERE fest_key='春节'`).Scan(&dirty); e != nil || dirty != 1 {
t.Fatalf("expected dirty=1 after save, got %d (%v)", dirty, e)
}
// second save overwrites the value
f2, e := a.SetFestivalImageData("春节", festPngDataURL(t, 100, 100, color.RGBA{20, 90, 220, 255}))
if e != nil {
t.Fatal(e)
}
if f2.Image == f.Image {
t.Fatal("expected a different data URL after overwrite")
}
if e := a.RemoveFestivalImage("春节"); e != nil {
t.Fatal(e)
}
m, _ = a.ListFestivalImages()
if len(m) != 0 {
t.Fatalf("expected empty list after remove, got %d", len(m))
}
// removal keeps the row as an empty-value tombstone marked dirty
var v string
if e := a.store.db.QueryRow(`SELECT value,dirty FROM festival_images WHERE fest_key='春节'`).Scan(&v, &dirty); e != nil || v != "" || dirty != 1 {
t.Fatalf("expected empty dirty tombstone, got value=%q dirty=%d (%v)", v, dirty, e)
}
}
func TestFestivalImageModeSwitch(t *testing.T) {
a := festTestApp(t)
_ = a.store.SetMeta("sync_user_id", "1")
if _, e := a.SetFestivalImageData("中秋", festPngDataURL(t, 50, 50, color.RGBA{230, 190, 90, 255})); e != nil {
t.Fatal(e)
}
var at1 string
_ = a.store.db.QueryRow(`SELECT updated_at FROM festival_images WHERE fest_key='中秋'`).Scan(&at1)
if e := a.SetFestivalImageMode("中秋", "art"); e != nil {
t.Fatal(e)
}
m, _ := a.ListFestivalImages()
if m["中秋"].Mode != "art" || m["中秋"].Image == "" {
t.Fatalf("mode switch must keep image, got %+v", m["中秋"])
}
// stamp must strictly increase so the switch wins cloud-side LWW
var at2 string
var dirty int
_ = a.store.db.QueryRow(`SELECT updated_at,dirty FROM festival_images WHERE fest_key='中秋'`).Scan(&at2, &dirty)
if at2 <= at1 || dirty != 1 {
t.Fatalf("expected newer dirty stamp, at1=%q at2=%q dirty=%d", at1, at2, dirty)
}
// switching to the same mode is a no-op; unknown mode / missing row are rejected
if e := a.SetFestivalImageMode("中秋", "art"); e != nil {
t.Fatal(e)
}
if e := a.SetFestivalImageMode("中秋", "gif"); e == nil {
t.Fatal("expected error for bad mode")
}
if e := a.SetFestivalImageMode("不存在", "art"); e == nil || e.Error() != "FESTIVAL_IMG_NOT_FOUND" {
t.Fatalf("expected FESTIVAL_IMG_NOT_FOUND, got %v", e)
}
if e := a.SetFestivalImageMode("中秋", "photo"); e != nil {
t.Fatal(e)
}
m, _ = a.ListFestivalImages()
if m["中秋"].Mode != "photo" {
t.Fatalf("expected photo mode, got %+v", m["中秋"])
}
}
// Rows written by the previous version store the raw data URL; they must be
// read back as photo mode so existing cloud data keeps working.
func TestFestivalImageLegacyPayload(t *testing.T) {
a := festTestApp(t)
_ = a.store.SetMeta("sync_user_id", "1")
raw := festPngDataURL(t, 4, 4, color.RGBA{1, 2, 3, 255})
if _, e := a.store.db.Exec(`INSERT INTO festival_images(fest_key,value,updated_at,dirty) VALUES('元旦',?,?,0)`, raw, nowRFC()); e != nil {
t.Fatal(e)
}
m, e := a.ListFestivalImages()
if e != nil {
t.Fatal(e)
}
if m["元旦"].Mode != "photo" || m["元旦"].Image != raw {
t.Fatalf("legacy payload mismatch: %+v", m["元旦"])
}
// mode switch upgrades the row to the JSON payload
if e := a.SetFestivalImageMode("元旦", "art"); e != nil {
t.Fatal(e)
}
var v string
_ = a.store.db.QueryRow(`SELECT value FROM festival_images WHERE fest_key='元旦'`).Scan(&v)
if !strings.HasPrefix(v, "{") || !strings.Contains(v, `"mode":"art"`) {
t.Fatalf("row not upgraded to JSON: %.60s", v)
}
}
// Save and remove within the same second must still produce strictly increasing
// timestamps, otherwise the cloud-side strict LWW comparison rejects the second write.
func TestFestivalImageStampMonotonic(t *testing.T) {
a := festTestApp(t)
_ = a.store.SetMeta("sync_user_id", "1")
if _, e := a.SetFestivalImageData("端午", festPngDataURL(t, 6, 6, color.RGBA{10, 160, 90, 255})); e != nil {
t.Fatal(e)
}
var at1 string
_ = a.store.db.QueryRow(`SELECT updated_at FROM festival_images WHERE fest_key='端午'`).Scan(&at1)
if e := a.RemoveFestivalImage("端午"); e != nil {
t.Fatal(e)
}
var at2 string
_ = a.store.db.QueryRow(`SELECT updated_at FROM festival_images WHERE fest_key='端午'`).Scan(&at2)
if at2 <= at1 {
t.Fatalf("tombstone stamp %q must be greater than save stamp %q", at2, at1)
}
}
func TestFestivalImageBadInput(t *testing.T) {
a := festTestApp(t)
_ = a.store.SetMeta("sync_user_id", "1")
if _, e := a.SetFestivalImageData("", festPngDataURL(t, 4, 4, color.White)); e == nil {
t.Fatal("expected error for empty key")
}
if _, e := a.SetFestivalImageData("春节", "data:image/png;base64,!!!"); e == nil {
t.Fatal("expected error for invalid base64 payload")
}
if _, e := a.SetFestivalImageData("春节", "hello"); e == nil {
t.Fatal("expected error for non-dataURL input")
}
}

183
fileapi.go Normal file
View File

@@ -0,0 +1,183 @@
package main
// fileapi.go 调 nl-pms-api 的最小客户端:上传图片换取公开访问 URL
// 以及素材库的列表/删除透传(权限由服务端按 用户/团队角色 判定)。
// 服务器地址与密钥来自管理员下发的全局文件存储配置(见 filestorage.go
// 请求由 Go 端发起(不走前端 fetch天然绕开 webview 的 CORS 限制。
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"mime/multipart"
"net/http"
"strconv"
"strings"
"time"
)
var fileAPIClient = &http.Client{Timeout: 30 * time.Second}
// currentTeamID 返回客户端当前所在团队 id未加入团队为 0
func (a *App) currentTeamID() int64 {
n, _ := strconv.ParseInt(strings.TrimSpace(a.store.Meta("current_team_id")), 10, 64)
if n < 0 {
return 0
}
return n
}
// fileAPIBase 返回已启用的服务器存储地址(实时读全局配置,离线回本地缓存);
// 未启用返回 FILE_API_UNCONFIGURED。
func (a *App) fileAPIBase() (FileStorageConfig, error) {
cfg := a.currentFileStorage()
if cfg.Mode != "server" || cfg.BaseURL == "" {
return cfg, errors.New("FILE_API_UNCONFIGURED")
}
return cfg, nil
}
// fileAPIStatusErr 把 nl-pms-api 的非 200 响应映射为客户端错误码。
func fileAPIStatusErr(status int) error {
switch status {
case http.StatusForbidden:
return errors.New("FILE_PERMISSION_DENIED")
case http.StatusNotFound:
return errors.New("FILE_NOT_FOUND")
default:
return errors.New("FILE_API_REQUEST_FAILED")
}
}
// uploadImageToServer 把编码好的图片字节上传到 nl-pms-api返回可公开访问的 http URL。
// 归属头像记个人teamId=0内容图记当前团队素材库据此划定管理范围。
// 未启用服务器存储时返回 FILE_API_UNCONFIGURED失败不静默降级由调用方向用户报错。
func (a *App) uploadImageToServer(data []byte, mime, kind string) (string, error) {
cfg, e := a.fileAPIBase()
if e != nil {
return "", e
}
ext := ".jpg"
switch mime {
case "image/png":
ext = ".png"
case "image/gif":
ext = ".gif"
case "image/webp":
ext = ".webp"
}
var buf bytes.Buffer
w := multipart.NewWriter(&buf)
fw, e := w.CreateFormFile("file", "img"+ext)
if e != nil {
return "", errors.New("IMAGE_UPLOAD_FAILED")
}
if _, e = fw.Write(data); e != nil {
return "", errors.New("IMAGE_UPLOAD_FAILED")
}
_ = w.WriteField("kind", kind)
if id := a.syncUserID(); id > 0 {
_ = w.WriteField("userId", strconv.FormatInt(id, 10))
}
if kind == "content" {
_ = w.WriteField("teamId", strconv.FormatInt(a.currentTeamID(), 10))
}
if e = w.Close(); e != nil {
return "", errors.New("IMAGE_UPLOAD_FAILED")
}
req, e := http.NewRequest(http.MethodPost, cfg.BaseURL+"/api/v1/files", &buf)
if e != nil {
return "", errors.New("IMAGE_UPLOAD_FAILED")
}
req.Header.Set("Content-Type", w.FormDataContentType())
req.Header.Set("Authorization", "Bearer "+cfg.APIKey)
resp, e := fileAPIClient.Do(req)
if e != nil {
return "", errors.New("IMAGE_UPLOAD_FAILED")
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode != http.StatusOK {
return "", errors.New("IMAGE_UPLOAD_FAILED")
}
var out struct {
URL string `json:"url"`
}
if json.Unmarshal(body, &out) != nil || out.URL == "" {
return "", errors.New("IMAGE_UPLOAD_FAILED")
}
return out.URL, nil
}
// ListServerFiles 素材库分页列表。scope=mine 看自己scope=team 看指定团队
// (需为该团队 owner/adminscope=all 看全部(仅云端账号 id=1
func (a *App) ListServerFiles(scope string, teamID int64, page int) (ServerFileList, error) {
if e := a.ready(); e != nil {
return ServerFileList{}, e
}
cfg, e := a.fileAPIBase()
if e != nil {
return ServerFileList{}, e
}
uid := a.syncUserID()
if uid <= 0 {
return ServerFileList{}, errors.New("SYNC_NOT_LOGGED_IN")
}
if page < 1 {
page = 1
}
u := fmt.Sprintf("%s/api/v1/files?scope=%s&userId=%d&teamId=%d&page=%d&pageSize=24",
cfg.BaseURL, scope, uid, teamID, page)
req, e := http.NewRequest(http.MethodGet, u, nil)
if e != nil {
return ServerFileList{}, errors.New("FILE_API_REQUEST_FAILED")
}
req.Header.Set("Authorization", "Bearer "+cfg.APIKey)
resp, e := fileAPIClient.Do(req)
if e != nil {
return ServerFileList{}, errors.New("FILE_STORAGE_UNREACHABLE")
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
if resp.StatusCode != http.StatusOK {
return ServerFileList{}, fileAPIStatusErr(resp.StatusCode)
}
var out ServerFileList
if json.Unmarshal(body, &out) != nil {
return ServerFileList{}, errors.New("FILE_API_REQUEST_FAILED")
}
return out, nil
}
// DeleteServerFile 删除素材(记录+服务器磁盘文件)。服务端校验:本人、
// 超管 id=1、或该文件归属团队的 owner/admin越权返回 FILE_PERMISSION_DENIED。
func (a *App) DeleteServerFile(id int64) error {
if e := a.ready(); e != nil {
return e
}
cfg, e := a.fileAPIBase()
if e != nil {
return e
}
uid := a.syncUserID()
if uid <= 0 {
return errors.New("SYNC_NOT_LOGGED_IN")
}
u := fmt.Sprintf("%s/api/v1/files/%d?userId=%d", cfg.BaseURL, id, uid)
req, e := http.NewRequest(http.MethodDelete, u, nil)
if e != nil {
return errors.New("FILE_API_REQUEST_FAILED")
}
req.Header.Set("Authorization", "Bearer "+cfg.APIKey)
resp, e := fileAPIClient.Do(req)
if e != nil {
return errors.New("FILE_STORAGE_UNREACHABLE")
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fileAPIStatusErr(resp.StatusCode)
}
return nil
}

175
filestorage.go Normal file
View File

@@ -0,0 +1,175 @@
package main
// filestorage.go 全局「文件存储方式」配置:由管理员(云端账号 id=1在设置页配置
// 权威数据存远端 MySQL 的 sync_settings 表user_id=1, name='file_storage' 行)。
// 保存时直接写库立即生效;各端上传图片时实时拉取该配置(短 TTL 缓存),
// 拉取失败(离线/未配置同步)回退本地缓存副本(由同步循环下发,见 syncFileStorageConfig
// mode=local 时一切维持本地行为mode=server 时内容图与头像选图会上传到
// nl-pms-api 换取 http URL远程/跨设备场景无法使用本地路径)。
import (
"context"
"database/sql"
"encoding/json"
"errors"
"net/http"
"strings"
"time"
)
const (
fileStorageKey = "file_storage" // settings 表 KV 键本地缓存JSON
fileStorageAtKey = "file_storage_updated_at" // LWW 时间戳,同步推拉的判定依据
fileStorageTTL = 30 * time.Second // 实时配置的内存缓存时长,避免连续上传反复查库
)
// normalizeFileStorage 收敛非法值mode 只认 local/server地址去尾斜杠。
func normalizeFileStorage(c FileStorageConfig) FileStorageConfig {
if c.Mode != "server" {
c.Mode = "local"
}
c.BaseURL = strings.TrimRight(strings.TrimSpace(c.BaseURL), "/")
c.APIKey = strings.TrimSpace(c.APIKey)
return c
}
// fileStorageConfig 读本地缓存的全局配置(未配置返回 local 零值)。
func (a *App) fileStorageConfig() FileStorageConfig {
var c FileStorageConfig
if raw := a.store.Meta(fileStorageKey); raw != "" {
_ = json.Unmarshal([]byte(raw), &c)
}
return normalizeFileStorage(c)
}
// fetchRemoteFileStorage 从远端 MySQL 读权威配置。第二个返回值表示"远端可用且结果权威"
// (无行记录视作权威的 local成功时顺带刷新本地缓存副本供离线兜底。
func (a *App) fetchRemoteFileStorage() (FileStorageConfig, bool) {
base := a.ctx
if base == nil {
base = context.Background()
}
ctx, cancel := context.WithTimeout(base, 5*time.Second)
defer cancel()
db, e := a.openRemote(ctx)
if e != nil {
return FileStorageConfig{}, false
}
defer db.Close()
var val, at string
e = db.QueryRowContext(ctx, `SELECT value,updated_at FROM sync_settings WHERE user_id=? AND name=?`,
festivalAdminID, fileStorageKey).Scan(&val, &at)
if e == sql.ErrNoRows {
return FileStorageConfig{}, true // 管理员尚未配置 → 权威的 local
}
if e != nil {
return FileStorageConfig{}, false
}
var c FileStorageConfig
if json.Unmarshal([]byte(val), &c) != nil {
return FileStorageConfig{}, false
}
c = normalizeFileStorage(c)
if b, e := json.Marshal(c); e == nil {
_ = a.store.SetMeta(fileStorageKey, string(b))
_ = a.store.SetMeta(fileStorageAtKey, at)
}
return c, true
}
// currentFileStorage 返回上传时应采用的全局配置优先远端实时值TTL 内存缓存),
// 远端不可达时回退本地缓存副本。管理员改完配置后各端最迟 TTL 内生效,无需等同步轮。
// 失败结果同样缓存 TTL避免离线时每次上传都白等一轮连接超时。
func (a *App) currentFileStorage() FileStorageConfig {
a.fsMu.Lock()
if !a.fsCfgAt.IsZero() && time.Since(a.fsCfgAt) < fileStorageTTL {
c := a.fsCfg
a.fsMu.Unlock()
return c
}
a.fsMu.Unlock()
c, ok := a.fetchRemoteFileStorage()
if !ok {
c = a.fileStorageConfig()
}
a.fsMu.Lock()
a.fsCfg, a.fsCfgAt = c, time.Now()
a.fsMu.Unlock()
return c
}
// invalidateFileStorageCache 让下一次读取强制回源(保存配置后调用)。
func (a *App) invalidateFileStorageCache() {
a.fsMu.Lock()
a.fsCfgAt = time.Time{}
a.fsMu.Unlock()
}
// GetFileStorageConfig 返回全局文件存储配置(远端优先,离线回本地缓存)。
// 所有账号可读:界面据此决定头像/内容图的存储走向与素材库可用性。
func (a *App) GetFileStorageConfig() (FileStorageConfig, error) {
if e := a.ready(); e != nil {
return FileStorageConfig{}, e
}
return a.currentFileStorage(), nil
}
// SaveFileStorageConfig 管理员id=1保存全局配置直接写远端 MySQL 立即全员生效,
// 同时更新本地缓存副本与 LWW 时间戳(同步循环不会再把旧值推回)。要求在线。
func (a *App) SaveFileStorageConfig(c FileStorageConfig) error {
if e := a.ready(); e != nil {
return e
}
if a.syncUserID() != festivalAdminID {
return errors.New("FILE_STORAGE_ADMIN_ONLY")
}
c = normalizeFileStorage(c)
if c.Mode == "server" && !strings.HasPrefix(c.BaseURL, "http") {
return errors.New("FILE_STORAGE_BAD_URL")
}
b, e := json.Marshal(c)
if e != nil {
return e
}
ctx, cancel := context.WithTimeout(a.ctx, syncTimeout)
defer cancel()
db, e := a.openRemote(ctx)
if e != nil {
return e
}
defer db.Close()
now := nowRFC()
if _, e = db.ExecContext(ctx, `INSERT INTO sync_settings(user_id,name,value,updated_at) VALUES(?,?,?,?)
ON DUPLICATE KEY UPDATE value=VALUES(value), updated_at=VALUES(updated_at)`,
festivalAdminID, fileStorageKey, string(b), now); e != nil {
return mapSyncErr(e)
}
if e := a.store.SetMeta(fileStorageKey, string(b)); e != nil {
return e
}
_ = a.store.SetMeta(fileStorageAtKey, now)
a.invalidateFileStorageCache()
a.store.Log("info", "系统", "文件存储配置已保存", "mode="+c.Mode)
return nil
}
// TestFileStorage 探测服务器连通性GET {baseURL}/healthz配置界面「测试连接」用。
func (a *App) TestFileStorage(c FileStorageConfig) error {
if e := a.ready(); e != nil {
return e
}
c = normalizeFileStorage(c)
if !strings.HasPrefix(c.BaseURL, "http") {
return errors.New("FILE_STORAGE_BAD_URL")
}
client := &http.Client{Timeout: 5 * time.Second}
resp, e := client.Get(c.BaseURL + "/healthz")
if e != nil {
return errors.New("FILE_STORAGE_UNREACHABLE")
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return errors.New("FILE_STORAGE_UNREACHABLE")
}
return nil
}

BIN
frontend/public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

View File

@@ -0,0 +1,66 @@
<script setup>
import { onMounted, onUnmounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { Sparkles, RefreshCw } from 'lucide-vue-next'
import { call, on } from '../api'
import { useAppStore } from '../store'
import MarkdownView from './MarkdownView.vue'
// 模块 AI 分析卡片:展示分析后自动生成的模块介绍,支持手动重新生成。
const props = defineProps({
projectId: { type: Number, required: true },
kind: { type: String, default: 'project' } // project | git | structure | insights
})
const { t } = useI18n()
const store = useAppStore()
const sum = ref(null)
const busy = ref(false)
let offSummary = null
const titleKey = { project: 'aiBriefProject', git: 'aiBriefGit', structure: 'aiBriefStructure', insights: 'aiBriefInsights' }
async function load() {
const list = await call('GetAISummaries', props.projectId)
sum.value = list.find(x => x.kind === props.kind) || null
}
async function regen() {
if (busy.value) return
busy.value = true
try {
await call('RegenerateAISummary', props.projectId, props.kind)
} catch (e) {
busy.value = false
const code = String(e).split(':')[0].trim()
store.showToast({ type: 'error', text: t('errors.' + code) !== 'errors.' + code ? t('errors.' + code) : String(e) })
}
}
// RFC3339(UTC) 转本地时区显示
const fmtTime = at => {
const d = new Date(at)
if (isNaN(d)) return at || ''
const p = n => String(n).padStart(2, '0')
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`
}
onMounted(async () => {
await load()
offSummary = on('ai:summary', async e => {
if (e.projectId !== props.projectId || e.kind !== props.kind) return
busy.value = false
if (!e.error) await load()
})
})
onUnmounted(() => offSummary?.())
</script>
<template>
<section class="panel ai-brief">
<header class="ai-brief-head">
<b><Sparkles />{{ t(titleKey[kind] || 'aiBriefProject') }}</b>
<time v-if="sum">{{ fmtTime(sum.generatedAt) }}</time>
<button class="ai-brief-regen" :class="{ busy }" :title="t('aiBriefRegen')" :disabled="busy" @click="regen"><RefreshCw /></button>
</header>
<MarkdownView v-if="sum?.content" class="ai-brief-body" :source="sum.content" />
<p v-else class="ai-brief-empty">{{ busy ? t('aiBriefGenerating') : t('aiBriefEmpty') }}</p>
</section>
</template>

View File

@@ -0,0 +1,92 @@
<script setup>
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { Sparkles, RefreshCw, NotebookPen, Settings } from 'lucide-vue-next'
import { call, on } from '../api'
import { useAppStore } from '../store'
import MarkdownView from './MarkdownView.vue'
// 工作台 AI 简报:根据今天的待办/工单生成工作规划,下班时一键总结日报。
const router = useRouter()
const store = useAppStore()
const { t } = useI18n()
const plan = ref(null)
const report = ref(null)
const busy = ref('') // '' | dayplan | dayreport
let offSummary = null
const hasKey = computed(() => store.settings.aiProvider === 'deepseek' ? !!store.settings.deepSeekKey : !!store.settings.sparkKey)
async function load() {
const list = await call('GetAISummaries', 0)
plan.value = list.find(x => x.kind === 'dayplan') || null
report.value = list.find(x => x.kind === 'dayreport') || null
}
async function gen(kind) {
if (busy.value) return
busy.value = kind
try {
await call('RegenerateAISummary', 0, kind)
} catch (e) {
busy.value = ''
const code = String(e).split(':')[0].trim()
store.showToast({ type: 'error', text: t('errors.' + code) !== 'errors.' + code ? t('errors.' + code) : String(e) })
}
}
const fmtTime = at => {
const d = new Date(at)
if (isNaN(d)) return ''
const p = n => String(n).padStart(2, '0')
return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`
}
// 昨天生成的内容仍会展示,但提示已过期,鼓励重新生成
const isToday = at => {
const d = new Date(at)
return !isNaN(d) && d.toDateString() === new Date().toDateString()
}
onMounted(async () => {
await load()
offSummary = on('ai:summary', async e => {
if (e.projectId !== 0 || (e.kind !== 'dayplan' && e.kind !== 'dayreport')) return
if (e.kind === busy.value) busy.value = ''
if (!e.error) await load()
})
})
onUnmounted(() => offSummary?.())
</script>
<template>
<section class="panel ai-day">
<div v-if="!hasKey" class="ai-day-nokey">
<Sparkles />
<p>{{ t('wbAiNoKey') }}</p>
<button class="btn secondary" @click="router.push('/settings?tab=ai')"><Settings />{{ t('aiConfigureNow') }}</button>
</div>
<div v-else class="ai-day-grid">
<div class="ai-day-block">
<header>
<b><Sparkles />{{ t('wbAiPlan') }}</b>
<time v-if="plan" :class="{ stale: !isToday(plan.generatedAt) }">{{ fmtTime(plan.generatedAt) }}</time>
<button class="btn secondary" :disabled="!!busy" @click="gen('dayplan')">
<RefreshCw :class="{ spin: busy === 'dayplan' }" />{{ busy === 'dayplan' ? t('wbGenerating') : (plan ? t('wbPlanRegen') : t('wbPlanGen')) }}
</button>
</header>
<MarkdownView v-if="plan?.content" class="ai-day-body" :source="plan.content" />
<p v-else class="ai-day-empty">{{ busy === 'dayplan' ? t('wbGenerating') : t('wbPlanEmpty') }}</p>
</div>
<div class="ai-day-block">
<header>
<b><NotebookPen />{{ t('wbReport') }}</b>
<time v-if="report" :class="{ stale: !isToday(report.generatedAt) }">{{ fmtTime(report.generatedAt) }}</time>
<button class="btn primary" :disabled="!!busy" @click="gen('dayreport')">
<NotebookPen :class="{ spin: busy === 'dayreport' }" />{{ busy === 'dayreport' ? t('wbGenerating') : t('wbReportGen') }}
</button>
</header>
<MarkdownView v-if="report?.content" class="ai-day-body" :source="report.content" />
<p v-else class="ai-day-empty">{{ busy === 'dayreport' ? t('wbGenerating') : t('wbReportEmpty') }}</p>
</div>
</div>
</section>
</template>

View File

@@ -0,0 +1,101 @@
<script setup>
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { Sparkles, RefreshCw, X, Settings } from 'lucide-vue-next'
import { call, on } from '../api'
import { useAppStore } from '../store'
import MarkdownView from './MarkdownView.vue'
// 页面级 AI 总结抽屉launchpad / logs / calendar / config / notes 共用。
// 结果存 day_briefsprojectID=0生成走 RegenerateAISummary + ai:summary 事件。
const props = defineProps({
kind: { type: String, required: true },
title: { type: String, default: '' }
})
const emit = defineEmits(['close'])
const router = useRouter()
const store = useAppStore()
const { t } = useI18n()
const summary = ref(null)
const busy = ref(false)
const err = ref('')
let offSummary = null
const hasKey = computed(() => store.settings.aiProvider === 'deepseek' ? !!store.settings.deepSeekKey : !!store.settings.sparkKey)
async function load() {
try {
const list = await call('GetAISummaries', 0)
summary.value = list.find(x => x.kind === props.kind) || null
} catch { /* 后端未就绪时静默 */ }
}
async function gen() {
if (busy.value) return
err.value = ''
busy.value = true
try {
await call('RegenerateAISummary', 0, props.kind)
} catch (e) {
busy.value = false
const code = String(e).split(':')[0].trim()
err.value = t('errors.' + code) !== 'errors.' + code ? t('errors.' + code) : String(e)
}
}
const fmtTime = at => {
const d = new Date(at)
if (isNaN(d)) return ''
const p = n => String(n).padStart(2, '0')
return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`
}
function goKey() {
emit('close')
router.push('/settings?tab=ai')
}
function onKey(e) {
if (e.key === 'Escape') { e.stopPropagation(); emit('close') }
}
onMounted(async () => {
addEventListener('keydown', onKey)
await load()
offSummary = on('ai:summary', async e => {
if (e.projectId !== 0 || e.kind !== props.kind) return
busy.value = false
if (e.error) err.value = e.error
else await load()
})
})
onUnmounted(() => { removeEventListener('keydown', onKey); offSummary?.() })
</script>
<template>
<Teleport to="body">
<div class="overlay scope-overlay" @click.self="emit('close')">
<aside class="scope-drawer">
<header class="scope-head">
<b><Sparkles />{{ t('aiScopeBtn') }}<span v-if="title" class="scope-title">· {{ title }}</span></b>
<time v-if="summary">{{ fmtTime(summary.generatedAt) }}</time>
<button type="button" class="nm-close" :title="t('close')" @click="emit('close')"><X /></button>
</header>
<div class="scope-body">
<div v-if="!hasKey" class="scope-nokey">
<Sparkles />
<p>{{ t('wbAiNoKey') }}</p>
<button class="btn secondary" @click="goKey"><Settings />{{ t('aiConfigureNow') }}</button>
</div>
<template v-else>
<MarkdownView v-if="summary?.content" :source="summary.content" />
<p v-else-if="!busy" class="scope-empty">{{ t('aiScopeEmpty') }}</p>
<p v-if="busy" class="scope-generating"><RefreshCw class="spin" />{{ t('wbGenerating') }}</p>
<p v-if="err" class="scope-err">{{ err }}</p>
</template>
</div>
<footer v-if="hasKey" class="scope-foot">
<button class="btn" :disabled="busy" @click="gen">
<RefreshCw :class="{ spin: busy }" />{{ busy ? t('wbGenerating') : (summary ? t('aiScopeRegen') : t('aiScopeGen')) }}
</button>
</footer>
</aside>
</div>
</Teleport>
</template>

View File

@@ -0,0 +1,53 @@
<script setup>
import { onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { X, BarChart2, GitBranch, ListTodo, CalendarDays, Bot, CloudUpload } from 'lucide-vue-next'
import { call } from '../api'
import logoUrl from '../assets/logo.png'
// 关于弹窗:由原生菜单“帮助 → 关于”触发menu:action=about
const emit = defineEmits(['close'])
const { t } = useI18n()
const version = ref('')
const aboutFeatures = [
{ icon: BarChart2, key: 'aboutFeatCode' },
{ icon: GitBranch, key: 'aboutFeatGit' },
{ icon: ListTodo, key: 'aboutFeatTask' },
{ icon: CalendarDays, key: 'aboutFeatCal' },
{ icon: Bot, key: 'aboutFeatAI' },
{ icon: CloudUpload, key: 'aboutFeatSync' }
]
onMounted(async () => {
try { version.value = await call('GetAppVersion') } catch { version.value = '' }
})
</script>
<template>
<Teleport to="body">
<div class="overlay" @click.self="emit('close')">
<section class="modal about-modal">
<button type="button" class="about-close" :aria-label="t('close')" @click="emit('close')"><X /></button>
<div class="about-hero">
<span class="about-glow" aria-hidden="true"></span>
<img class="about-logo" :src="logoUrl" alt="logo" />
<h2 class="about-name">{{ t('app') }}</h2>
<p class="about-slogan">{{ t('aboutSlogan') }}</p>
<div class="about-badges">
<span v-if="version" class="about-badge ver">v{{ version }}</span>
<span class="about-badge">NL PMS</span>
<span class="about-badge">Wails v3 · Go · Vue 3</span>
</div>
</div>
<p class="about-intro">{{ t('aboutIntro') }}</p>
<div class="about-feats">
<div v-for="f in aboutFeatures" :key="f.key" class="about-feat"><component :is="f.icon" /><span>{{ t(f.key) }}</span></div>
</div>
<div class="about-story">
<b>{{ t('aboutNameTitle') }}</b>
<p>{{ t('aboutNameStory') }}</p>
</div>
<p class="about-foot">{{ t('aboutFoot') }}</p>
</section>
</div>
</Teleport>
</template>

View File

@@ -0,0 +1,103 @@
<script setup>
import { computed, onMounted, onUnmounted, ref, watch, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { Search, Folder, ListTodo, TicketCheck, Bot, CornerDownLeft } from 'lucide-vue-next'
import { call } from '../api'
import { useAppStore } from '../store'
const store = useAppStore()
const router = useRouter()
const { t } = useI18n()
const q = ref('')
const hits = ref([])
const sel = ref(0)
const busy = ref(false)
const searched = ref(false)
const input = ref(null)
const listEl = ref(null)
let timer = null
const kindMeta = {
project: { icon: Folder, label: 'kindProject' },
todo: { icon: ListTodo, label: 'kindTodo' },
ticket: { icon: TicketCheck, label: 'kindTicket' },
conversation: { icon: Bot, label: 'kindConversation' }
}
const groups = computed(() => {
const by = {}
const g = []
for (const h of hits.value) {
if (!by[h.kind]) { by[h.kind] = { kind: h.kind, items: [] }; g.push(by[h.kind]) }
by[h.kind].items.push(h)
}
return g
})
watch(q, () => {
clearTimeout(timer)
timer = setTimeout(search, 180)
})
async function search() {
const s = q.value.trim()
if (!s) { hits.value = []; sel.value = 0; searched.value = false; return }
busy.value = true
try {
hits.value = await call('GlobalSearch', s) || []
sel.value = 0
searched.value = true
} catch { hits.value = [] }
busy.value = false
}
function close() { store.paletteOpen = false }
function go(h) {
close()
if (h.kind === 'project') router.push(`/project/${h.id}`)
else if (h.kind === 'todo') router.push('/todos')
else if (h.kind === 'ticket') router.push('/tickets')
else router.push({ path: '/ai', query: { conversation: h.id } })
}
function onKey(e) {
if (e.key === 'Escape') { e.preventDefault(); close() }
else if (e.key === 'ArrowDown') { e.preventDefault(); move(1) }
else if (e.key === 'ArrowUp') { e.preventDefault(); move(-1) }
else if (e.key === 'Enter' && hits.value[sel.value]) { e.preventDefault(); go(hits.value[sel.value]) }
}
function move(d) {
if (!hits.value.length) return
sel.value = (sel.value + d + hits.value.length) % hits.value.length
nextTick(() => listEl.value?.querySelector('.cp-item.active')?.scrollIntoView({ block: 'nearest' }))
}
const flatIndex = h => hits.value.indexOf(h)
onMounted(() => {
addEventListener('keydown', onKey)
nextTick(() => input.value?.focus())
})
onUnmounted(() => { removeEventListener('keydown', onKey); clearTimeout(timer) })
</script>
<template>
<div class="cp-overlay" @click.self="close">
<section class="cp-panel popover-glass">
<label class="cp-input">
<Search />
<input ref="input" v-model="q" :placeholder="t('searchPlaceholder')" spellcheck="false" />
<kbd>Esc</kbd>
</label>
<div v-if="groups.length" ref="listEl" class="cp-list">
<div v-for="g in groups" :key="g.kind" class="cp-group">
<small>{{ t(kindMeta[g.kind].label) }}</small>
<button v-for="h in g.items" :key="g.kind + h.id" class="cp-item" :class="{ active: flatIndex(h) === sel }"
@mouseenter="sel = flatIndex(h)" @click="go(h)">
<component :is="kindMeta[g.kind].icon" />
<span class="cp-title">{{ h.title || '—' }}</span>
<span v-if="h.sub" class="cp-sub">{{ h.sub }}</span>
<CornerDownLeft v-if="flatIndex(h) === sel" class="cp-enter" />
</button>
</div>
</div>
<p v-else-if="searched && !busy" class="cp-empty">{{ t('searchEmpty') }}</p>
<p v-else class="cp-empty muted">{{ t('searchHint') }}</p>
</section>
</div>
</template>

View File

@@ -0,0 +1,141 @@
<script setup>
// 每日心语:登录状态下每天首次进入自动弹出一次;
// 也可从日历页手动打开store.dailyCardDate支持前后翻页浏览历史卡片。
// 图片按日期作种子picsum seed保证"一天一张固定的随机图",离线时退化为日期渐变底。
import { computed, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { X, Quote, ChevronLeft, ChevronRight } from 'lucide-vue-next'
import { useAppStore } from '../store'
const store = useAppStore()
const { t, locale } = useI18n()
const open = ref(false)
const imgOk = ref(true)
const autoMode = ref(false)
const KEY = 'cc-daily-card'
const QUOTES_ZH = [
'把每一件平凡的事做好,就是不平凡。', '慢慢来,比较快。', '代码如诗,生活如歌。', '今天的努力,是明天的底气。',
'保持热爱,奔赴山海。', '先完成,再完美。', '少即是多,慢即是快。', '你写下的每一行,都在塑造未来的你。',
'别害怕重构人生,版本迭代是常态。', '心之所向,素履以往。', '星光不问赶路人,时光不负有心人。', '简单是可靠的前提。',
'日拱一卒,功不唐捐。', '生活不是等待风暴过去,而是学会在雨中起舞。', '种一棵树最好的时间是十年前,其次是现在。', '路虽远行则将至,事虽难做则必成。',
'允许一切发生,然后继续前行。', '热爱可抵岁月漫长。', '所有的惊艳,都来自长久的努力。', '不乱于心,不困于情。',
'你若盛开,清风自来。', '万事尽头,终将如意。', '愿你眼中有光,心中有梦。', '认真生活的人,运气不会太差。'
]
const QUOTES_EN = [
'Make each day your masterpiece.', 'Slow is smooth, smooth is fast.', 'Code is poetry; life is music.', "Today's effort is tomorrow's confidence.",
'Stay hungry, stay foolish.', 'Done is better than perfect.', 'Less is more.', 'Every line you write shapes who you become.',
'Refactor your life; iteration is normal.', 'Where there is a will, there is a way.', "The stars don't ask the traveler why.", 'Simplicity is a prerequisite for reliability.',
'Small steps every day add up to big results.', "Life isn't about waiting for the storm to pass, it's about dancing in the rain.", 'The best time to plant a tree was ten years ago; the second best is now.', 'A long road tests a willing heart.',
'Let it be, and keep going.', 'Passion outlasts the years.', 'Great things take time.', 'Calm mind, steady hands.',
'Bloom, and the breeze will come.', 'All will be well in the end.', 'May your eyes hold light and your heart hold dreams.', 'Live earnestly and luck will follow.'
]
const pad = n => String(n).padStart(2, '0')
const fmt = d => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
const todayStr = fmt(new Date())
const cardDate = ref(new Date())
const dateStr = computed(() => fmt(cardDate.value))
const dayIndex = computed(() => Math.floor(Date.UTC(cardDate.value.getFullYear(), cardDate.value.getMonth(), cardDate.value.getDate()) / 86400000))
const zh = computed(() => locale.value === 'zh-CN')
const dateTitle = computed(() => zh.value
? `${cardDate.value.getFullYear()}${cardDate.value.getMonth() + 1}${cardDate.value.getDate()}`
: cardDate.value.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }))
const weekday = computed(() => cardDate.value.toLocaleDateString(zh.value ? 'zh-CN' : 'en-US', { weekday: 'long' }))
const quote = computed(() => (zh.value ? QUOTES_ZH : QUOTES_EN)[((dayIndex.value % QUOTES_ZH.length) + QUOTES_ZH.length) % QUOTES_ZH.length])
const imgUrl = computed(() => `https://picsum.photos/seed/cc-${dateStr.value}/840/560`)
const fallbackStyle = computed(() => {
const hue = ((dayIndex.value * 47) % 360 + 360) % 360
return { background: `linear-gradient(135deg, hsl(${hue} 62% 36%), hsl(${(hue + 70) % 360} 55% 24%))` }
})
// 黄历:农历、干支、节日/节气、宜忌lunar 库较大,打开时才动态加载,避免拖慢启动)
const almanac = ref(null)
let SolarMod = null
async function loadAlmanac() {
try {
if (!SolarMod) SolarMod = (await import('lunar-javascript')).Solar
const solar = SolarMod.fromDate(cardDate.value)
const lunar = solar.getLunar()
almanac.value = {
lunarDate: `${lunar.getMonthInChinese()}${lunar.getDayInChinese()}`,
ganzhi: `${lunar.getYearInGanZhi()}${lunar.getYearShengXiao()}`,
yi: lunar.getDayYi().slice(0, 4),
ji: lunar.getDayJi().slice(0, 4),
fests: [...lunar.getFestivals(), ...solar.getFestivals(), ...(lunar.getJieQi() ? [lunar.getJieQi()] : [])]
}
} catch {
almanac.value = { lunarDate: '', ganzhi: '', yi: [], ji: [], fests: [] }
}
}
const canNext = computed(() => dateStr.value < todayStr)
function nav(delta) {
if (delta > 0 && !canNext.value) return
const d = new Date(cardDate.value)
d.setDate(d.getDate() + delta)
cardDate.value = d
imgOk.value = true
loadAlmanac()
}
function close() {
if (autoMode.value) localStorage.setItem(KEY, todayStr)
open.value = false
store.dailyCardDate = ''
}
function openFor(d, auto) {
autoMode.value = auto
cardDate.value = d
imgOk.value = true
loadAlmanac()
open.value = true
}
function maybeOpen() {
if (!store.syncStatus.loggedIn || open.value) return
if (localStorage.getItem(KEY) === todayStr) return
setTimeout(() => { if (!open.value) openFor(new Date(), true) }, 700)
}
onMounted(maybeOpen)
watch(() => store.syncStatus.loggedIn, v => { if (v) maybeOpen() })
// 日历页入口:设置 store.dailyCardDate = 'YYYY-MM-DD' 打开对应日期的卡片(不超过今天)
watch(() => store.dailyCardDate, v => {
if (!v) return
const d = new Date(`${v}T00:00:00`)
if (isNaN(d) || fmt(d) > todayStr) return
openFor(d, false)
})
</script>
<template>
<Teleport to="body">
<div v-if="open && almanac" class="overlay daily-overlay" @click.self="close">
<section class="daily-card" role="dialog" aria-modal="true">
<div class="daily-hero" :style="fallbackStyle">
<img v-if="imgOk" :key="dateStr" :src="imgUrl" alt="" @error="imgOk = false" />
<div class="daily-mask" />
<button class="daily-close" :aria-label="t('close')" @click="close"><X /></button>
<button class="daily-nav prev" :title="t('dailyPrevDay')" @click.stop="nav(-1)"><ChevronLeft /></button>
<button class="daily-nav next" :disabled="!canNext" :title="t('dailyNextDay')" @click.stop="nav(1)"><ChevronRight /></button>
<div class="daily-head">
<span class="daily-kicker">{{ t('dailyCard') }}<em v-if="dateStr !== todayStr" class="daily-history-tag">{{ t('dailyHistory') }}</em></span>
<b class="daily-date">{{ dateTitle }}</b>
<span class="daily-sub">{{ weekday }} · {{ almanac.ganzhi }} {{ almanac.lunarDate }}</span>
<span v-if="almanac.fests.length" class="daily-fest">{{ almanac.fests.join(' · ') }}</span>
</div>
</div>
<div class="daily-body">
<p class="daily-quote"><Quote />{{ quote }}</p>
<div class="daily-almanac">
<div class="daily-yi"><i>{{ t('dailyYi') }}</i><span v-for="x in almanac.yi" :key="x">{{ x }}</span></div>
<div class="daily-ji"><i>{{ t('dailyJi') }}</i><span v-for="x in almanac.ji" :key="x">{{ x }}</span></div>
</div>
<button class="btn primary daily-ok" @click="close">{{ t('dailyGotIt') }}</button>
</div>
</section>
</div>
</Teleport>
</template>

View File

@@ -0,0 +1,125 @@
<script setup>
// DatePicker 全工程统一的日期选择器v-model 传 'YYYY-MM-DD'(空串表示未选)。
// 输入框支持手动输入灵活格式20260501 / 0501 / 05-01 等,见 dateutil.js
// 弹出面板支持 日/月/年 三级视图切换;替代原生 input[type=date](样式不统一且不好用)。
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { CalendarDays, ChevronLeft, ChevronRight, X } from 'lucide-vue-next'
import { parseFlexDate, toYmd, pad2 } from '../dateutil'
const props = defineProps({
modelValue: { type: String, default: '' },
placeholder: { type: String, default: '' },
disabled: { type: Boolean, default: false },
clearable: { type: Boolean, default: true }
})
const emit = defineEmits(['update:modelValue'])
const { t, locale } = useI18n()
const open = ref(false)
const view = ref('day') // day | month | year
const typed = ref(props.modelValue)
const root = ref(null)
// 面板游标:打开时定位到已选日期或今天
const cur = ref(startDate())
function startDate() {
const p = parseFlexDate(props.modelValue)
return p ? new Date(Number(p.slice(0, 4)), Number(p.slice(5, 7)) - 1, 1) : new Date()
}
watch(() => props.modelValue, v => { typed.value = v })
const zh = computed(() => locale.value === 'zh-CN')
const weekdays = computed(() => zh.value ? ['日', '一', '二', '三', '四', '五', '六'] : ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'])
const monthNames = computed(() => zh.value
? ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月']
: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'])
const yearLabel = computed(() => zh.value ? `${cur.value.getFullYear()}` : String(cur.value.getFullYear()))
const monthLabel = computed(() => monthNames.value[cur.value.getMonth()])
const todayStr = toYmd(new Date())
const grid = computed(() => {
const first = new Date(cur.value.getFullYear(), cur.value.getMonth(), 1)
const start = new Date(first)
start.setDate(1 - first.getDay())
const cells = []
for (let i = 0; i < 42; i++) {
const d = new Date(start)
d.setDate(start.getDate() + i)
const v = toYmd(d)
cells.push({ v, day: d.getDate(), inMonth: d.getMonth() === cur.value.getMonth(), today: v === todayStr, active: v === props.modelValue })
}
return cells
})
// 年视图:以游标年为中心的 12 年窗口
const yearBase = computed(() => Math.floor(cur.value.getFullYear() / 12) * 12)
const years = computed(() => Array.from({ length: 12 }, (_, i) => yearBase.value + i))
function toggle() {
if (props.disabled) return
open.value = !open.value
if (open.value) { view.value = 'day'; cur.value = startDate() }
}
function moveMonth(d) { cur.value = new Date(cur.value.getFullYear(), cur.value.getMonth() + d, 1) }
function nav(d) {
if (view.value === 'day') moveMonth(d)
else if (view.value === 'month') cur.value = new Date(cur.value.getFullYear() + d, cur.value.getMonth(), 1)
else cur.value = new Date(cur.value.getFullYear() + d * 12, cur.value.getMonth(), 1)
}
function pickDay(c) { emit('update:modelValue', c.v); open.value = false }
function pickMonth(i) { cur.value = new Date(cur.value.getFullYear(), i, 1); view.value = 'day' }
function pickYear(y) { cur.value = new Date(y, cur.value.getMonth(), 1); view.value = 'month' }
function pickToday() { emit('update:modelValue', todayStr); open.value = false }
function clearVal() { emit('update:modelValue', ''); typed.value = ''; open.value = false }
// 手动输入:回车/失焦时解析(默认年份取面板游标年),非法输入回退当前值
function commitTyped() {
const raw = typed.value.trim()
if (raw === '') { if (props.modelValue) emit('update:modelValue', ''); return }
const p = parseFlexDate(raw, cur.value.getFullYear())
if (p) {
emit('update:modelValue', p)
cur.value = new Date(Number(p.slice(0, 4)), Number(p.slice(5, 7)) - 1, 1)
} else typed.value = props.modelValue
}
function onDocClick(e) { if (root.value && !root.value.contains(e.target)) open.value = false }
onMounted(() => addEventListener('mousedown', onDocClick))
onUnmounted(() => removeEventListener('mousedown', onDocClick))
</script>
<template>
<div ref="root" class="dp" :class="{ disabled }">
<div class="dp-box" @click="toggle">
<CalendarDays class="dp-ico" />
<input v-model="typed" class="dp-input" :placeholder="placeholder || (zh ? '如 0501 / 2026-05-01' : 'e.g. 0501 / 2026-05-01')"
:disabled="disabled" @click.stop="open = true" @keydown.enter.prevent="commitTyped" @blur="commitTyped" />
<button v-if="clearable && modelValue && !disabled" type="button" class="dp-clear" :title="t('dpClear')" @click.stop="clearVal"><X /></button>
</div>
<div v-if="open" class="dp-panel popover-glass" @mousedown.stop>
<header class="dp-head">
<button type="button" class="dp-nav" @click="nav(-1)"><ChevronLeft /></button>
<div class="dp-ym">
<button type="button" :class="{ on: view === 'year' }" @click="view = view === 'year' ? 'day' : 'year'">{{ view === 'year' ? `${years[0]} - ${years[11]}` : yearLabel }}</button>
<button v-if="view !== 'year'" type="button" :class="{ on: view === 'month' }" @click="view = view === 'month' ? 'day' : 'month'">{{ monthLabel }}</button>
</div>
<button type="button" class="dp-nav" @click="nav(1)"><ChevronRight /></button>
</header>
<template v-if="view === 'day'">
<div class="dp-week"><span v-for="w in weekdays" :key="w">{{ w }}</span></div>
<div class="dp-grid">
<button v-for="c in grid" :key="c.v" type="button" class="dp-day"
:class="{ dim: !c.inMonth, today: c.today, active: c.active }" @click="pickDay(c)">{{ c.day }}</button>
</div>
</template>
<div v-else-if="view === 'month'" class="dp-grid months">
<button v-for="(m, i) in monthNames" :key="m" type="button" class="dp-cell" :class="{ active: i === cur.getMonth() }" @click="pickMonth(i)">{{ m }}</button>
</div>
<div v-else class="dp-grid months">
<button v-for="y in years" :key="y" type="button" class="dp-cell" :class="{ active: y === cur.getFullYear() }" @click="pickYear(y)">{{ y }}</button>
</div>
<footer class="dp-foot">
<button type="button" @click="pickToday">{{ t('today') }}</button>
<button v-if="clearable" type="button" @click="clearVal">{{ t('dpClear') }}</button>
</footer>
</div>
</div>
</template>

View File

@@ -0,0 +1,74 @@
<script setup>
// 截止日期快捷标签:今天 / 明天 / N 天后。标签可自定义添加、可删除,列表存 localStorage 全局共享。
import { nextTick, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { Plus, X } from 'lucide-vue-next'
const props = defineProps({
modelValue: { type: String, default: '' },
withTime: { type: Boolean, default: false },
disabled: { type: Boolean, default: false }
})
const emit = defineEmits(['update:modelValue'])
const { t } = useI18n()
const KEY = 'cc-due-quick'
function load() {
try {
const v = JSON.parse(localStorage.getItem(KEY))
if (Array.isArray(v) && v.every(n => Number.isInteger(n) && n >= 0)) return v
} catch {}
return [0, 1, 3, 7]
}
const days = ref(load())
const adding = ref(false)
const addVal = ref('')
const addInput = ref(null)
const save = () => localStorage.setItem(KEY, JSON.stringify(days.value))
const ymdAfter = n => {
const d = new Date()
d.setDate(d.getDate() + n)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
const label = n => n === 0 ? t('dqToday') : n === 1 ? t('dqTomorrow') : n === 2 ? t('dqDayAfter') : t('dqDays', { n })
const isActive = n => (props.modelValue || '').slice(0, 10) === ymdAfter(n)
function pick(n) {
if (props.disabled) return
emit('update:modelValue', props.withTime ? `${ymdAfter(n)}T18:00` : ymdAfter(n))
}
function remove(n) {
days.value = days.value.filter(x => x !== n)
save()
}
function openAdd() {
if (props.disabled) return
adding.value = true
addVal.value = ''
nextTick(() => addInput.value?.focus())
}
function confirmAdd() {
const n = parseInt(addVal.value, 10)
adding.value = false
if (!Number.isInteger(n) || n < 0 || n > 365) return
if (!days.value.includes(n)) {
days.value = [...days.value, n].sort((a, b) => a - b)
save()
}
}
</script>
<template>
<div class="due-quick">
<button v-for="n in days" :key="n" type="button" class="dq-chip" :class="{ active: isActive(n) }" :disabled="disabled" @click="pick(n)">
{{ label(n) }}
<i class="dq-x" :title="t('dqRemove')" @click.stop="remove(n)"><X /></i>
</button>
<button v-if="!adding" type="button" class="dq-chip dq-add" :title="t('dqAddTip')" :disabled="disabled" @click="openAdd"><Plus /></button>
<span v-else class="dq-chip dq-input">
<input ref="addInput" v-model="addVal" type="number" min="0" max="365" @keyup.enter="confirmAdd" @keyup.esc="adding = false" @blur="confirmAdd" />
{{ t('dqSuffix') }}
</span>
</div>
</template>

View File

@@ -0,0 +1,123 @@
<script setup>
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { X, FileCode2, Copy, Binary, RefreshCw } from 'lucide-vue-next'
import hljs from 'highlight.js/lib/common'
import 'highlight.js/styles/github-dark.css'
import { call } from '../api'
import { useAppStore } from '../store'
const props = defineProps({ projectId: { type: Number, required: true }, path: { type: String, required: true }, line: { type: Number, default: 0 } })
const emit = defineEmits(['close'])
const { t } = useI18n()
const store = useAppStore()
const file = ref(null)
const error = ref('')
const loading = ref(true)
const bodyEl = ref(null)
const hl = ref({ top: -1, height: 21 })
const extLang = {
'.js': 'javascript', '.mjs': 'javascript', '.cjs': 'javascript', '.jsx': 'javascript',
'.ts': 'typescript', '.tsx': 'typescript', '.vue': 'xml', '.html': 'xml', '.xml': 'xml', '.svg': 'xml',
'.css': 'css', '.scss': 'scss', '.less': 'less', '.go': 'go', '.py': 'python', '.rb': 'ruby',
'.php': 'php', '.java': 'java', '.kt': 'kotlin', '.rs': 'rust', '.c': 'c', '.h': 'c',
'.cpp': 'cpp', '.hpp': 'cpp', '.cs': 'csharp', '.sh': 'bash', '.ps1': 'powershell',
'.sql': 'sql', '.json': 'json', '.yml': 'yaml', '.yaml': 'yaml', '.toml': 'ini', '.ini': 'ini',
'.md': 'markdown', '.dockerfile': 'dockerfile'
}
const lineCount = computed(() => (file.value?.content ? file.value.content.split('\n').length : 0))
const highlighted = computed(() => {
const src = file.value?.content ?? ''
if (!src || src.length > 300000) return ''
const lang = extLang[file.value?.extension || '']
try {
const out = lang && hljs.getLanguage(lang) ? hljs.highlight(src, { language: lang }) : hljs.highlightAuto(src)
return out.value
} catch {
return ''
}
})
const sizeText = computed(() => {
const n = file.value?.size || 0
return n >= 1048576 ? (n / 1048576).toFixed(1) + ' MB' : n >= 1024 ? (n / 1024).toFixed(1) + ' KB' : n + ' B'
})
async function load() {
loading.value = true
error.value = ''
hl.value = { top: -1, height: 21 }
try {
file.value = await call('ReadProjectFile', props.projectId, props.path)
} catch (e) {
const code = String(e).split(':')[0]
error.value = ['FILE_NOT_FOUND', 'FILE_IS_DIRECTORY', 'FILE_PATH_INVALID', 'FILE_READ_FAILED'].includes(code) ? t(`errors.${code}`) : String(e)
} finally {
loading.value = false
}
locate()
}
// 定位到指定行:滚动至视口上 1/3 处并放置高亮条
async function locate() {
if (!props.line || props.line > lineCount.value) return
await nextTick()
const el = bodyEl.value
const span = el?.querySelector(`.preview-gutter span:nth-child(${props.line})`)
if (!span) return
hl.value = { top: span.offsetTop, height: span.offsetHeight || 21 }
el.scrollTop = Math.max(0, span.offsetTop - el.clientHeight * 0.33)
}
async function copy() {
try {
await navigator.clipboard.writeText(file.value?.content || '')
store.showToast({ type: 'success', key: 'copied' })
} catch {
store.showToast({ type: 'error', text: t('copyFailed') })
}
}
function onKey(e) {
if (e.key === 'Escape') emit('close')
}
watch(() => props.path, load)
onMounted(() => {
load()
addEventListener('keydown', onKey)
})
onUnmounted(() => removeEventListener('keydown', onKey))
</script>
<template>
<Teleport to="body">
<div class="overlay preview-overlay" @click.self="emit('close')">
<section class="modal preview-modal">
<header>
<div class="preview-title">
<FileCode2 />
<div>
<h2>{{ file?.name || path.split('/').pop() }}</h2>
<p :title="path">{{ path }}</p>
</div>
</div>
<div class="preview-meta" v-if="file && !file.binary">
<span>{{ sizeText }}</span>
<span>{{ file.lines }} {{ t('lines') }}</span>
<span v-if="file.truncated" class="preview-truncated">{{ t('previewTruncated') }}</span>
</div>
<div class="preview-actions">
<button v-if="file && !file.binary" :title="t('copyContent')" @click="copy"><Copy /></button>
<button :title="t('cancel')" @click="emit('close')"><X /></button>
</div>
</header>
<div v-if="loading" class="preview-state"><RefreshCw class="spin" />{{ t('previewLoading') }}</div>
<div v-else-if="error" class="preview-state error">{{ error }}</div>
<div v-else-if="file?.binary" class="preview-state"><Binary />{{ t('previewBinary') }} · {{ sizeText }}</div>
<div v-else ref="bodyEl" class="preview-body">
<i v-if="hl.top >= 0" class="preview-hl-line" :style="{ top: hl.top + 'px', height: hl.height + 'px' }" aria-hidden="true" />
<div class="preview-gutter" aria-hidden="true"><span v-for="i in lineCount" :key="i" :class="{ hit: i === line }">{{ i }}</span></div>
<pre class="preview-src"><code v-if="highlighted" class="hljs" v-html="highlighted"></code><code v-else>{{ file?.content }}</code></pre>
</div>
</section>
</div>
</Teleport>
</template>

View File

@@ -0,0 +1,96 @@
<script setup>
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { History, Plus, Circle, CircleDot, CircleCheck, Play, Check, Archive, Flag } from 'lucide-vue-next'
// 生命周期时间线:解析 history JSON[{status,at}]),从创建到完结逐节点展示。
const props = defineProps({
history: { type: String, default: '' },
createdAt: { type: String, default: '' },
updatedAt: { type: String, default: '' },
status: { type: String, default: 'open' },
kind: { type: String, default: 'todo' } // todo | ticket
})
const { t } = useI18n()
const doneStatuses = { todo: ['done'], ticket: ['resolved', 'closed'] }
const icons = {
todo: { open: Circle, doing: CircleDot, done: CircleCheck },
ticket: { open: Circle, in_progress: Play, resolved: Check, closed: Archive }
}
const raw = computed(() => {
let list = []
try { list = JSON.parse(props.history || '[]') || [] } catch { list = [] }
list = list.filter(n => n && n.at)
// 旧数据兜底:没有轨迹时至少给出创建节点;当前状态与最后节点不一致时用 updatedAt 近似补齐。
if (!list.length && props.createdAt) list = [{ status: 'open', at: props.createdAt }]
if (list.length && list[list.length - 1].status !== props.status && props.updatedAt) {
list = [...list, { status: props.status, at: props.updatedAt }]
}
return list
})
const fmtTs = at => {
const d = new Date(at)
if (isNaN(d)) return at
const p = n => String(n).padStart(2, '0')
const ymd = `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`
return `${ymd} ${p(d.getHours())}:${p(d.getMinutes())}`
}
const fmtDur = ms => {
const min = Math.floor(ms / 60000)
if (min < 1) return t('durMoment')
if (min < 60) return t('durMin', { n: min })
const h = Math.floor(min / 60)
if (h < 24) return min % 60 ? `${t('durHour', { n: h })} ${t('durMin', { n: min % 60 })}` : t('durHour', { n: h })
const d = Math.floor(h / 24)
return h % 24 ? `${t('durDay', { n: d })} ${t('durHour', { n: h % 24 })}` : t('durDay', { n: d })
}
const nodes = computed(() => raw.value.map((n, i) => {
const prev = i > 0 ? new Date(raw.value[i - 1].at) : null
const cur = new Date(n.at)
return {
key: i + n.status,
status: n.status,
icon: i === 0 ? Plus : (icons[props.kind][n.status] || Circle),
label: i === 0 ? t('lifecycleCreated') : t((props.kind === 'ticket' ? 'ticketStatus.' : 'todoStatus.') + n.status),
sub: i === 0 && n.status !== 'open' ? t((props.kind === 'ticket' ? 'ticketStatus.' : 'todoStatus.') + n.status) : '',
time: fmtTs(n.at),
gap: prev && !isNaN(prev) && !isNaN(cur) && cur - prev >= 0 ? fmtDur(cur - prev) : '',
done: i > 0 && doneStatuses[props.kind].includes(n.status)
}
}))
const finished = computed(() => raw.value.length > 0 && doneStatuses[props.kind].includes(raw.value[raw.value.length - 1].status))
const total = computed(() => {
if (!finished.value || raw.value.length < 2) return ''
const a = new Date(raw.value[0].at), b = new Date(raw.value[raw.value.length - 1].at)
return isNaN(a) || isNaN(b) || b - a < 0 ? '' : fmtDur(b - a)
})
</script>
<template>
<div v-if="nodes.length" class="lifecycle">
<div class="lc-head"><History />{{ t('lifecycle') }}</div>
<div class="lc-list">
<div v-for="n in nodes" :key="n.key" class="lc-node" :class="[kind + '-' + n.status, { done: n.done }]">
<span class="lc-dot"><component :is="n.icon" /></span>
<div class="lc-body">
<div class="lc-row">
<b>{{ n.label }}</b>
<i v-if="n.sub" class="lc-sub">{{ n.sub }}</i>
<span v-if="n.gap" class="lc-gap">+{{ n.gap }}</span>
</div>
<span class="lc-time">{{ n.time }}</span>
</div>
</div>
<div v-if="!finished" class="lc-node running">
<span class="lc-dot pulse"><Flag /></span>
<div class="lc-body"><div class="lc-row"><b>{{ t('lifecycleNow') }}</b></div></div>
</div>
</div>
<p v-if="total" class="lc-total"><CircleCheck />{{ t('lifecycleTotal', { dur: total }) }}</p>
</div>
</template>

View File

@@ -0,0 +1,117 @@
<script setup>
import { reactive, ref, onMounted, onUnmounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { X, ShieldCheck, Eye, EyeOff, ArrowRight } from 'lucide-vue-next'
import { call, isNative } from '../api'
import { useAppStore } from '../store'
const store = useAppStore()
const { t } = useI18n()
const native = isNative()
const mode = ref('login') // login | register
const form = reactive({ username: '', password: '', confirm: '' })
const busy = ref(false)
const msg = ref('')
const showPwd = ref(false)
const userInput = ref(null)
function errText(e) {
const code = String(e?.message || e).trim()
return t('errors.' + code) !== 'errors.' + code ? t('errors.' + code) : String(e)
}
function switchMode(m) {
mode.value = m
msg.value = ''
}
async function submit() {
if (busy.value || !form.username || !form.password) return
msg.value = ''
if (mode.value === 'register' && form.password !== form.confirm) {
msg.value = t('passwordMismatch')
return
}
busy.value = true
try {
if (mode.value === 'register') await call('SyncRegister', form.username, form.password)
await call('SyncLogin', form.username, form.password)
await store.refreshSyncStatus()
store.showToast({ type: 'success', key: mode.value === 'register' ? 'registerOkToast' : 'loginOkToast' })
form.password = form.confirm = ''
store.loginOpen = false
} catch (e) {
msg.value = errText(e)
} finally {
busy.value = false
}
}
function onKey(e) {
if (e.key === 'Escape') store.loginOpen = false
}
watch(mode, () => userInput.value?.focus())
onMounted(() => {
addEventListener('keydown', onKey)
userInput.value?.focus()
})
onUnmounted(() => removeEventListener('keydown', onKey))
</script>
<template>
<div class="login-overlay" @click.self="store.loginOpen = false">
<section class="login-card">
<button class="login-close" :aria-label="t('close')" @click="store.loginOpen = false"><X /></button>
<aside class="lg-art" aria-hidden="true">
<i class="lg-rings" />
<span class="lg-logo">
<svg viewBox="0 0 48 48" role="img">
<path d="M17 18l-6 6 6 6M31 18l6 6-6 6M27 14l-6 20" fill="none" stroke="currentColor" stroke-width="3.2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
<b>年糕崽崽 PMS</b>
</span>
<div class="lg-copy">
<h3>{{ t('loginTagline') }}</h3>
<p>{{ t('loginModalHint') }}</p>
</div>
<span class="lg-badge"><ShieldCheck />{{ t('loginSecureBadge') }}</span>
</aside>
<div class="lg-main">
<nav class="lg-tabs">
<button :class="{ active: mode === 'login' }" @click="switchMode('login')">{{ t('loginBtn') }}</button>
<button :class="{ active: mode === 'register' }" @click="switchMode('register')">{{ t('registerBtn') }}</button>
</nav>
<h2 class="lg-title">{{ mode === 'login' ? t('loginWelcome') : t('registerWelcome') }}</h2>
<div class="lg-fields">
<label class="lg-field">
<span>{{ t('fieldUser') }}</span>
<input ref="userInput" v-model.trim="form.username" :placeholder="t('usernamePh')" autocomplete="username" @keyup.enter="submit" />
</label>
<label class="lg-field lg-pass">
<span>{{ t('fieldPass') }}</span>
<input v-model="form.password" :type="showPwd ? 'text' : 'password'" :placeholder="t('passwordPh')" :autocomplete="mode === 'register' ? 'new-password' : 'current-password'" @keyup.enter="submit" />
<button type="button" class="login-eye" :aria-label="showPwd ? t('hidePassword') : t('showPassword')" @click="showPwd = !showPwd"><EyeOff v-if="showPwd" /><Eye v-else /></button>
</label>
<Transition name="login-field">
<label v-if="mode === 'register'" class="lg-field">
<span>{{ t('fieldConfirm') }}</span>
<input v-model="form.confirm" :type="showPwd ? 'text' : 'password'" :placeholder="t('confirmPh')" autocomplete="new-password" @keyup.enter="submit" />
</label>
</Transition>
<Transition name="login-field">
<p v-if="msg" class="lg-error">{{ msg }}</p>
</Transition>
<button class="lg-submit" :disabled="!native || busy || !form.username || !form.password" @click="submit">
{{ busy ? (mode === 'register' ? t('registering') : t('loggingIn')) : (mode === 'register' ? t('registerAndLogin') : t('loginBtn')) }}
<ArrowRight v-if="!busy" />
</button>
<button class="login-switch" @click="switchMode(mode === 'login' ? 'register' : 'login')">
{{ mode === 'login' ? t('switchToRegister') : t('switchToLogin') }}
</button>
</div>
<p class="lg-foot"><ShieldCheck />{{ t('syncLoginHint') }}</p>
</div>
</section>
</div>
</template>

View File

@@ -0,0 +1,59 @@
<script setup>
// MarkdownView待办/工单内容的 Markdown 渲染。
// 本地路径图片path 存储模式)经后端读成 dataURL 再显示;外部链接交给系统浏览器打开。
import { nextTick, ref, watch } from 'vue'
import { marked } from 'marked'
import { Browser } from '@wailsio/runtime'
import { call, isNative } from '../api'
const props = defineProps({ source: { type: String, default: '' } })
const el = ref(null)
const html = ref('')
// 各实例共享本地图片缓存,同一张图只读一次。
const imgCache = new Map()
watch(() => props.source, render, { immediate: true })
async function render() {
html.value = marked.parse(props.source || '', { breaks: true, gfm: true, async: false })
await nextTick()
hydrateImages()
}
function hydrateImages() {
if (!el.value) return
for (const img of el.value.querySelectorAll('img')) {
const src = img.getAttribute('src') || ''
if (!src || /^(data:|https?:)/i.test(src)) continue
img.classList.add('md-img-loading')
resolveLocal(src).then(dataURL => {
if (dataURL) { img.src = dataURL; img.classList.remove('md-img-loading') }
else img.classList.replace('md-img-loading', 'md-img-broken')
})
}
}
async function resolveLocal(path) {
if (imgCache.has(path)) return imgCache.get(path)
let dataURL = ''
if (isNative()) {
try { dataURL = await call('ReadContentImageAsDataURL', path) } catch { dataURL = '' }
}
imgCache.set(path, dataURL)
return dataURL
}
function onClick(e) {
const a = e.target.closest('a')
if (!a) return
e.preventDefault()
e.stopPropagation()
const href = a.getAttribute('href') || ''
if (/^https?:/i.test(href)) Browser.OpenURL(href)
}
</script>
<template>
<div ref="el" class="md-content" v-html="html" @click="onClick" />
</template>

View File

@@ -0,0 +1,79 @@
<script setup>
import { onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { Bell, CheckCheck, Trash2, ListTodo, TicketCheck, BarChart3, RefreshCw, Info, ArrowRight } from 'lucide-vue-next'
import { call } from '../api'
import { useAppStore } from '../store'
const router = useRouter()
const store = useAppStore()
const { t } = useI18n()
const open = ref(false)
const items = ref([])
const wrap = ref(null)
const kindIcon = { todo_due: ListTodo, ticket_due: TicketCheck, analysis: BarChart3, sync: RefreshCw }
async function toggle() {
open.value = !open.value
if (open.value) items.value = await call('ListMessages', 50)
}
async function markRead(m) {
if (!m.read) {
await call('MarkMessageRead', m.id)
m.read = true
store.refreshUnread()
}
if (m.sourceType === 'todo') router.push('/todos')
else if (m.sourceType === 'ticket') router.push('/tickets')
open.value = false
}
async function markAll() {
await call('MarkAllMessagesRead')
items.value.forEach(m => { m.read = true })
store.refreshUnread()
}
async function clearAll() {
if (!confirm(t('clearMessages') + '?')) return
await call('ClearMessages')
items.value = []
store.refreshUnread()
}
function onClickAway(e) {
if (wrap.value && !wrap.value.contains(e.target)) open.value = false
}
onMounted(() => addEventListener('click', onClickAway))
onUnmounted(() => removeEventListener('click', onClickAway))
</script>
<template>
<div ref="wrap" class="bell-wrap">
<button class="bell-btn" :title="t('messages')" @click="toggle">
<Bell />
<i v-if="store.unreadMessages" class="bell-badge">{{ store.unreadMessages > 99 ? '99+' : store.unreadMessages }}</i>
</button>
<div v-if="open" class="bell-dropdown popover-glass">
<header>
<b>{{ t('messages') }}</b>
<div class="bell-tools">
<button :title="t('markAllRead')" @click="markAll"><CheckCheck /></button>
<button :title="t('clearMessages')" @click="clearAll"><Trash2 /></button>
</div>
</header>
<div class="bell-list">
<button v-for="m in items" :key="m.id" class="bell-item" :class="{ unread: !m.read }" @click="markRead(m)">
<span class="bell-icon" :class="m.kind"><component :is="kindIcon[m.kind] || Info" /></span>
<div>
<b>{{ m.title }}</b>
<p v-if="m.body">{{ m.body }}</p>
<time>{{ m.createdAt?.replace('T', ' ').slice(0, 16) }}</time>
</div>
<i v-if="!m.read" class="bell-dot" />
</button>
<div v-if="!items.length" class="bell-empty">{{ t('noMessages') }}</div>
</div>
<button type="button" class="bell-more" @click="open = false; router.push('/messages')">{{ t('viewAll') }}<ArrowRight /></button>
</div>
</div>
</template>

View File

@@ -0,0 +1,74 @@
<script setup>
import { onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { StickyNote, Plus, ArrowRight } from 'lucide-vue-next'
import { call } from '../api'
import NoteModal from './NoteModal.vue'
// 笔记中心:任意页面查看最近笔记、快速新建与编辑。
const router = useRouter()
const { t } = useI18n()
const open = ref(false)
const wrap = ref(null)
const notes = ref([])
const editing = ref(null) // null=关闭;{} = 新建;{id,...} = 编辑
async function load() {
try { notes.value = await call('ListNotes', 5) } catch { /* 后端未就绪时静默 */ }
}
async function toggle() {
open.value = !open.value
if (open.value) await load()
}
function openNote(n) {
open.value = false
editing.value = n || {}
}
const title = n => {
const line = (n.content || '').split('\n').find(l => l.trim()) || ''
return line.trim().replace(/^#+\s*/, '') || t('noteUntitled')
}
const fullTime = s => String(s || '').replace('T', ' ').slice(0, 19)
function relTime(s) {
const ms = Date.now() - new Date(s).getTime()
if (!isFinite(ms) || ms < 0) return ''
const m = Math.floor(ms / 60000)
if (m < 1) return t('justNow')
if (m < 60) return t('minAgo', { n: m })
const h = Math.floor(m / 60)
if (h < 24) return t('hourAgo', { n: h })
const d = Math.floor(h / 24)
if (d < 7) return t('dayAgo', { n: d })
const local = new Date(s)
return isFinite(local) ? `${local.getMonth() + 1}-${String(local.getDate()).padStart(2, '0')}` : ''
}
function onClickAway(e) {
if (wrap.value && !wrap.value.contains(e.target)) open.value = false
}
onMounted(() => { addEventListener('click', onClickAway); load() })
onUnmounted(() => removeEventListener('click', onClickAway))
</script>
<template>
<div ref="wrap" class="bell-wrap">
<button class="bell-btn" :title="t('noteCenter')" @click="toggle">
<StickyNote />
</button>
<div v-if="open" class="bell-dropdown popover-glass nc-dropdown">
<header>
<b>{{ t('noteCenter') }}</b>
<button type="button" class="nc-new" @click="openNote(null)"><Plus />{{ t('noteNew') }}</button>
</header>
<div class="bell-list">
<div v-for="n in notes" :key="n.id" class="nc-item" @click="openNote(n)">
<b>{{ title(n) }}</b>
<time :title="fullTime(n.updatedAt)">{{ relTime(n.updatedAt) }}</time>
</div>
<div v-if="!notes.length" class="bell-empty">{{ t('noteEmpty') }}</div>
</div>
<button type="button" class="bell-more" @click="open = false; router.push('/notes')">{{ t('viewAll') }}<ArrowRight /></button>
</div>
<NoteModal v-if="editing" :note="editing" @close="editing = null" @changed="load" />
</div>
</template>

View File

@@ -0,0 +1,74 @@
<script setup>
import { onMounted, onUnmounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { StickyNote, Trash2, X } from 'lucide-vue-next'
import { call } from '../api'
// 笔记详情模态框打开即编辑600ms 防抖自动保存;关闭时空内容自动清理。
const props = defineProps({ note: { type: Object, default: null } })
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 area = ref(null)
let timer = 0
let saving = null
function onInput() {
clearTimeout(timer)
timer = setTimeout(flush, 600)
}
async function flush() {
clearTimeout(timer)
const content = text.value
if (!content.trim() && !noteId.value) return
saving = call('SaveNoteByID', noteId.value, content).then(n => {
noteId.value = n.id
savedAt.value = new Date().toTimeString().slice(0, 8)
emit('changed')
}).catch(() => {})
await saving
}
async function close() {
await flush()
// 内容清空的已有笔记视为不再需要,顺手删除
if (noteId.value && !text.value.trim()) {
try { await call('DeleteNote', noteId.value); emit('changed') } catch {}
}
emit('close')
}
async function removeNote() {
if (!noteId.value) { emit('close'); return }
if (!confirm(t('noteDeleteConfirm'))) return
clearTimeout(timer)
try { await call('DeleteNote', noteId.value); emit('changed') } catch {}
emit('close')
}
function onKey(e) {
if (e.key === 'Escape') { e.stopPropagation(); close() }
}
onMounted(() => {
addEventListener('keydown', onKey)
requestAnimationFrame(() => area.value?.focus())
})
onUnmounted(() => { removeEventListener('keydown', onKey); clearTimeout(timer) })
</script>
<template>
<Teleport to="body">
<div class="overlay" @click.self="close">
<section class="modal note-modal">
<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="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" />
</section>
</div>
</Teleport>
</template>

View File

@@ -0,0 +1,225 @@
<script setup>
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { Sparkles, Plus, Trash2, SendHorizonal, Square, Bot, User, PieChart, GitCommitHorizontal, ListTodo, TicketCheck, ExternalLink, X } from 'lucide-vue-next'
import { marked } from 'marked'
import hljs from 'highlight.js/lib/common'
import 'highlight.js/styles/github-dark.css'
import { call, on } from '../api'
import { useAppStore } from '../store'
// 项目内 AI 问答抽屉:不跳页的“二级页面”,左侧为该项目的聊天记录,右侧为聊天区。
const props = defineProps({
projectId: { type: Number, required: true },
projectName: { type: String, default: '' },
ask: { type: String, default: '' } // 打开时自动发送的问题
})
const emit = defineEmits(['close'])
const router = useRouter()
const store = useAppStore()
const { t } = useI18n()
const conversations = ref([])
const activeId = ref(0)
const messages = ref([])
const input = ref('')
const streamingId = ref(0)
const streamText = ref('')
const streamError = ref('')
const listEl = ref(null)
const streamBuf = {}
let offStream = null
const streaming = computed(() => streamingId.value !== 0 && streamingId.value === activeId.value)
const hasKey = computed(() => store.settings.aiProvider === 'deepseek' ? !!store.settings.deepSeekKey : !!store.settings.sparkKey)
marked.setOptions({ breaks: true, gfm: true })
const md = s => marked.parse(s || '')
const quicks = [
{ key: 'project', icon: PieChart, label: 'aiQuickProject', prompt: 'aiPromptProject' },
{ key: 'git', icon: GitCommitHorizontal, label: 'aiQuickGit', prompt: 'aiPromptGit' },
{ key: 'todo', icon: ListTodo, label: 'aiQuickTodo', prompt: 'aiPromptTodo' },
{ key: 'ticket', icon: TicketCheck, label: 'aiQuickTicket', prompt: 'aiPromptTicket' }
]
function errText(e) {
const code = String(e).split(':')[0].trim()
return t('errors.' + code) !== 'errors.' + code ? t('errors.' + code) : String(e)
}
async function loadConversations() {
conversations.value = await call('ListAIConversations', props.projectId)
}
async function select(c) {
activeId.value = c.id
streamError.value = ''
streamText.value = streamBuf[c.id] || ''
messages.value = await call('GetAIMessages', c.id)
scrollBottom()
}
function newChat() {
activeId.value = 0
messages.value = []
streamError.value = ''
streamText.value = ''
}
async function del(c) {
if (!confirm(t('aiDeleteConfirm'))) return
await call('DeleteAIConversation', c.id)
if (activeId.value === c.id) newChat()
await loadConversations()
}
async function send(text, scen) {
const content = (text ?? input.value).trim()
if (!content || streaming.value) return
streamError.value = ''
try {
const conv = await call('SendAIMessage', activeId.value, props.projectId, scen || 'chat', content)
if (!activeId.value) {
activeId.value = conv.id
await loadConversations()
}
messages.value.push({ id: -Date.now(), role: 'user', content })
input.value = ''
streamingId.value = conv.id
streamBuf[conv.id] = ''
streamText.value = ''
scrollBottom()
} catch (e) {
streamError.value = errText(e)
}
}
function quick(q) {
send(t(q.prompt), q.key)
}
async function stop() {
try { await call('StopAIStream', streamingId.value || activeId.value) } catch {}
}
function openFull() {
const q = activeId.value ? `?project=${props.projectId}&conversation=${activeId.value}` : `?project=${props.projectId}`
emit('close')
router.push('/ai' + q)
}
// RFC3339(UTC) 转本地时区的 MM-DD HH:mm
function convTime(at) {
const d = new Date(at)
if (isNaN(d)) return ''
const p = n => String(n).padStart(2, '0')
return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`
}
function scrollBottom() {
nextTick(() => { if (listEl.value) listEl.value.scrollTop = listEl.value.scrollHeight })
}
function highlightAll() {
nextTick(() => {
listEl.value?.querySelectorAll('pre code:not([data-hl])').forEach(el => {
el.dataset.hl = '1'
hljs.highlightElement(el)
})
})
}
function onKey(e) {
if (e.key === 'Escape') emit('close')
}
onMounted(async () => {
addEventListener('keydown', onKey)
await loadConversations()
if (props.ask && hasKey.value) {
// 从提问框进入:新开会话直接发送
newChat()
await send(props.ask)
} else if (conversations.value.length) {
await select(conversations.value[0])
}
offStream = on('ai:stream', e => {
const cid = e.conversationId
if (e.delta) {
streamBuf[cid] = (streamBuf[cid] || '') + e.delta
if (cid === activeId.value) {
streamText.value = streamBuf[cid]
scrollBottom()
}
}
if (e.done) {
if (cid === streamingId.value) streamingId.value = 0
const finalText = streamBuf[cid] || ''
delete streamBuf[cid]
if (cid === activeId.value) {
if (e.error) streamError.value = errText(e.error)
if (finalText) messages.value.push({ id: e.messageId || -Date.now(), role: 'assistant', content: finalText })
streamText.value = ''
highlightAll()
}
loadConversations()
}
})
})
onUnmounted(() => {
removeEventListener('keydown', onKey)
offStream?.()
})
</script>
<template>
<Teleport to="body">
<div class="ai-drawer-overlay" @click.self="emit('close')">
<section class="ai-drawer">
<header class="ai-drawer-head">
<b><Sparkles />{{ t('aiAskDrawer') }}<span v-if="projectName" class="todo-chip">{{ projectName }}</span></b>
<div class="ai-drawer-tools">
<button class="btn secondary" :title="t('aiOpenFull')" @click="openFull"><ExternalLink />{{ t('aiOpenFull') }}</button>
<button class="ai-drawer-close" :aria-label="t('close')" @click="emit('close')"><X /></button>
</div>
</header>
<div class="ai-drawer-body">
<aside class="ai-drawer-convs">
<button class="btn secondary full ai-drawer-new" @click="newChat"><Plus />{{ t('aiNewChat') }}</button>
<div class="ai-conv-list">
<button v-for="c in conversations" :key="c.id" class="ai-conv" :class="{ active: c.id === activeId }" @click="select(c)">
<b>{{ c.title }}</b>
<small>{{ convTime(c.updatedAt) }}</small>
<i class="ai-conv-del" :title="t('delete')" @click.stop="del(c)"><Trash2 /></i>
</button>
<div v-if="!conversations.length" class="empty wb-empty">{{ t('aiNoHistory') }}</div>
</div>
</aside>
<section class="ai-drawer-chat">
<div class="ai-toolbar ai-drawer-quicks">
<div class="ai-quicks">
<button v-for="q in quicks" :key="q.key" class="btn secondary" :disabled="streaming || !hasKey" @click="quick(q)">
<component :is="q.icon" />{{ t(q.label) }}
</button>
</div>
</div>
<div ref="listEl" class="ai-messages">
<div v-if="!messages.length && !streaming" class="ai-welcome">
<Bot />
<p>{{ hasKey ? t('aiWelcome') : t('aiNoKeyHint') }}</p>
</div>
<div v-for="m in messages" :key="m.id" class="ai-msg" :class="m.role">
<span class="ai-avatar"><component :is="m.role === 'user' ? User : Bot" /></span>
<div v-if="m.role === 'assistant'" class="ai-bubble markdown" v-html="md(m.content)" />
<div v-else class="ai-bubble">{{ m.content }}</div>
</div>
<div v-if="streaming" class="ai-msg assistant">
<span class="ai-avatar"><Bot /></span>
<div class="ai-bubble markdown">
<div v-if="streamText" v-html="md(streamText)" />
<span class="ai-typing"><i /><i /><i /></span>
</div>
</div>
<p v-if="streamError" class="ai-error">{{ streamError }}</p>
</div>
<div class="ai-input-row">
<textarea v-model="input" :placeholder="t('aiAskPlaceholder')" rows="2" :disabled="!hasKey"
@keydown.enter.exact.prevent="send()" />
<button v-if="streaming" class="btn secondary ai-send" @click="stop"><Square />{{ t('aiStop') }}</button>
<button v-else class="btn primary ai-send" :disabled="!input.trim() || !hasKey" @click="send()"><SendHorizonal />{{ t('aiSend') }}</button>
</div>
</section>
</div>
</section>
</div>
</Teleport>
</template>

View File

@@ -0,0 +1,105 @@
<script setup>
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { ClipboardList, ListTodo, TicketCheck, Play, Check, Flag, ArrowRight } from 'lucide-vue-next'
import { call, notifyTasksChanged, onTasksChanged } from '../api'
import TaskDetailModal from './TaskDetailModal.vue'
// 任务中心:任意页面查看/流转进行中与待开始的待办、工单。
const route = useRoute()
const router = useRouter()
const { t } = useI18n()
const open = ref(false)
const wrap = ref(null)
const todos = ref([])
const tickets = ref([])
const detail = ref(null) // { kind, item }:点条目直接弹详情模态框处理
let timer = 0
async function load() {
try {
;[todos.value, tickets.value] = await Promise.all([call('ListTodos', 'all', 0), call('ListTickets', 'all', 0)])
} catch { /* 启动早期后端未就绪时静默 */ }
}
const byDue = (a, b) => (a.dueAt || '9999') < (b.dueAt || '9999') ? -1 : 1
const doing = computed(() => [
...todos.value.filter(x => x.status === 'doing').map(x => ({ ...x, kind: 'todo' })),
...tickets.value.filter(x => x.status === 'in_progress').map(x => ({ ...x, kind: 'ticket' }))
].sort(byDue))
const pending = computed(() => [
...todos.value.filter(x => x.status === 'open').map(x => ({ ...x, kind: 'todo' })),
...tickets.value.filter(x => x.status === 'open').map(x => ({ ...x, kind: 'ticket' }))
].sort(byDue))
const badge = computed(() => doing.value.length + pending.value.length)
const overdue = x => x.dueAt && new Date(x.dueAt.length === 10 ? x.dueAt + 'T23:59' : x.dueAt) < new Date()
// 快捷流转:待办 开始→完成;工单 开始→解决
const actions = x => x.kind === 'todo'
? (x.status === 'open' ? [{ s: 'doing', label: t('taskStart'), icon: Play }, { s: 'done', label: t('taskDone'), icon: Check }] : [{ s: 'done', label: t('taskDone'), icon: Check }])
: (x.status === 'open' ? [{ s: 'in_progress', label: t('taskStart'), icon: Play }] : [{ s: 'resolved', label: t('taskResolve'), icon: Check }])
async function act(x, status) {
await call(x.kind === 'todo' ? 'SetTodoStatus' : 'SetTicketStatus', x.id, status)
await load()
notifyTasksChanged()
}
function goto(x) {
open.value = false
detail.value = { kind: x.kind, item: x }
}
async function toggle() {
open.value = !open.value
if (open.value) await load()
}
function onClickAway(e) {
if (wrap.value && !wrap.value.contains(e.target)) open.value = false
}
watch(() => route.path, load)
let offTasks = null
onMounted(() => {
addEventListener('click', onClickAway)
load()
timer = setInterval(load, 30000)
offTasks = onTasksChanged(load)
})
onUnmounted(() => {
removeEventListener('click', onClickAway)
clearInterval(timer)
offTasks?.()
})
</script>
<template>
<div ref="wrap" class="bell-wrap">
<button class="bell-btn" :title="t('taskCenter')" @click="toggle">
<ClipboardList />
<i v-if="badge" class="bell-badge tc-badge">{{ badge > 99 ? '99+' : badge }}</i>
</button>
<div v-if="open" class="bell-dropdown popover-glass tc-dropdown">
<header><b>{{ t('taskCenter') }}</b></header>
<div class="bell-list">
<template v-for="grp in [{ key: 'doing', label: t('taskDoingGroup'), items: doing }, { key: 'pending', label: t('taskPendingGroup'), items: pending }]" :key="grp.key">
<div v-if="grp.items.length" class="tc-group" :class="grp.key"><i />{{ grp.label }}<em>{{ grp.items.length }}</em></div>
<div v-for="x in grp.items" :key="x.kind + x.id" class="tc-item" @click="goto(x)">
<span class="tc-icon" :class="x.kind"><component :is="x.kind === 'todo' ? ListTodo : TicketCheck" /></span>
<div class="tc-main">
<b>{{ x.title }}</b>
<small>
<span v-if="x.projectName" class="tc-proj">{{ x.projectName }}</span>
<span v-if="x.priority === 'high'" class="tc-pri"><Flag />{{ t('priority.high') }}</span>
<time v-if="x.dueAt" :class="{ overdue: overdue(x) }">{{ x.dueAt.replace('T', ' ').slice(5, 16) }}</time>
</small>
</div>
<div class="tc-acts">
<button v-for="a in actions(x)" :key="a.s" :title="a.label" @click.stop="act(x, a.s)"><component :is="a.icon" />{{ a.label }}</button>
</div>
</div>
</template>
<div v-if="!badge" class="bell-empty">{{ t('taskEmpty') }}</div>
</div>
<button type="button" class="bell-more" @click="open = false; router.push('/today')">{{ t('viewAll') }}<ArrowRight /></button>
</div>
<TaskDetailModal v-if="detail" :kind="detail.kind" :item="detail.item" @close="detail = null" @changed="load" />
</div>
</template>

View File

@@ -0,0 +1,140 @@
<script setup>
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { X, ListTodo, TicketCheck, Flag, CalendarDays, Pencil, Trash2, Play, Check, Archive, RotateCcw, Share2 } from 'lucide-vue-next'
import { call, notifyTasksChanged } from '../api'
import { useAppStore } from '../store'
import MarkdownView from './MarkdownView.vue'
import LifecycleTimeline from './LifecycleTimeline.vue'
// 待办/工单详情模态框:查看内容与生命周期、就地流转状态。日历与顶栏任务中心共用。
const props = defineProps({
kind: { type: String, required: true }, // todo | ticket
item: { type: Object, required: true }
})
const emit = defineEmits(['close', 'changed'])
const router = useRouter()
const store = useAppStore()
const { t } = useI18n()
const cur = ref({ ...props.item })
const busy = ref(false)
// 共享到团队(登录且加入团队时才显示选择器)
const myTeams = ref([])
onMounted(async () => {
if (!store.syncStatus.loggedIn) return
try { myTeams.value = (await call('TeamList')) || [] } catch {}
})
async function setTeam(ev) {
const teamId = Number(ev.target.value) || 0
try {
await call(props.kind === 'ticket' ? 'SetTicketTeam' : 'SetTodoTeam', cur.value.id, teamId)
cur.value.teamId = teamId
store.showToast({ type: 'success', key: 'sharedToTeamToast' })
} catch (e) { store.showToast({ type: 'error', text: String(e) }) }
}
const content = computed(() => (props.kind === 'ticket' ? cur.value.description : cur.value.content) || '')
const dates = computed(() => {
const it = cur.value
if (props.kind === 'ticket') return it.startAt && it.startAt !== it.dueAt ? `${it.startAt}${it.dueAt}` : (it.dueAt || '')
return (it.dueAt || '').replace('T', ' ')
})
// 与工单页一致的状态流转:开始处理 → 解决 → 关闭;已解决/已关闭可重新打开
const flows = computed(() => {
if (props.kind !== 'ticket') return []
return {
open: [{ key: 'in_progress', label: t('ticketFlow.start'), icon: Play }],
in_progress: [{ key: 'resolved', label: t('ticketFlow.resolve'), icon: Check }],
resolved: [{ key: 'closed', label: t('ticketFlow.close'), icon: Archive }, { key: 'open', label: t('ticketFlow.reopen'), icon: RotateCcw }],
closed: [{ key: 'open', label: t('ticketFlow.reopen'), icon: RotateCcw }]
}[cur.value.status] || []
})
async function setStatus(status) {
if (busy.value || cur.value.status === status) return
busy.value = true
try {
await call(props.kind === 'ticket' ? 'SetTicketStatus' : 'SetTodoStatus', cur.value.id, status)
// 拉取最新条目,时间线立即显示新节点
const list = await call(props.kind === 'ticket' ? 'ListTickets' : 'ListTodos', 'all', 0)
cur.value = list.find(i => i.id === cur.value.id) || { ...cur.value, status }
notifyTasksChanged()
emit('changed')
} catch (e) {
store.showToast({ type: 'error', text: String(e) })
} finally {
busy.value = false
}
}
function edit() {
emit('close')
// 携带 edit 深链,进入列表页后直接弹出对应编辑框
router.push({ path: props.kind === 'ticket' ? '/tickets' : '/todos', query: { edit: cur.value.id } })
}
async function remove() {
if (busy.value || !confirm(`${t('delete')} ${cur.value.title}?`)) return
busy.value = true
try {
await call(props.kind === 'ticket' ? 'DeleteTicket' : 'DeleteTodo', cur.value.id)
notifyTasksChanged()
emit('changed')
emit('close')
} catch (e) {
store.showToast({ type: 'error', text: String(e) })
} finally {
busy.value = false
}
}
function onKey(e) {
if (e.key === 'Escape' && !busy.value) emit('close')
}
onMounted(() => addEventListener('keydown', onKey))
onUnmounted(() => removeEventListener('keydown', onKey))
</script>
<template>
<Teleport to="body">
<div class="overlay" @click.self="!busy && emit('close')">
<section class="modal cal-detail-modal" @click.stop>
<header>
<h2><component :is="kind === 'ticket' ? TicketCheck : ListTodo" class="panel-icon" />{{ cur.title }}</h2>
<button type="button" :disabled="busy" @click="emit('close')"><X /></button>
</header>
<div class="cal-detail-meta">
<span v-if="kind === 'ticket'" class="ticket-status" :class="cur.status">{{ t('ticketStatus.' + cur.status) }}</span>
<span v-else class="ticket-status" :class="'todo-' + cur.status">{{ t('todoStatus.' + cur.status) }}</span>
<span class="todo-priority" :class="cur.priority"><Flag />{{ t('priority.' + cur.priority) }}</span>
<span v-if="kind === 'ticket'" class="todo-chip">{{ t('ticketType.' + cur.type) }}</span>
<span v-if="cur.projectName" class="todo-chip">{{ cur.projectName }}</span>
<span v-if="dates" class="todo-due"><CalendarDays />{{ dates }}</span>
<label v-if="myTeams.length" class="share-team" :title="t('shareToTeam')">
<Share2 />
<select :value="cur.teamId || 0" @change="setTeam">
<option :value="0">{{ t('sharePrivate') }}</option>
<option v-for="tm in myTeams" :key="tm.id" :value="tm.id">{{ tm.name }}</option>
</select>
</label>
</div>
<div class="cal-detail-body">
<MarkdownView v-if="content" :source="content" />
<p v-else class="cal-detail-empty">{{ t('mdEmpty') }}</p>
<LifecycleTimeline :history="cur.history" :created-at="cur.createdAt" :updated-at="cur.updatedAt" :status="cur.status" :kind="kind" />
</div>
<footer class="cal-detail-foot">
<div v-if="kind === 'todo'" class="tabs compact">
<button v-for="s in ['open', 'doing', 'done']" :key="s" :class="{ active: cur.status === s }" :disabled="busy" @click="setStatus(s)">{{ t('todoStatus.' + s) }}</button>
</div>
<div v-else class="cal-detail-flow">
<button v-for="a in flows" :key="a.key" class="btn secondary flow-btn" :disabled="busy" @click="setStatus(a.key)"><component :is="a.icon" />{{ a.label }}</button>
</div>
<div class="icon-actions">
<button :title="t('edit')" :disabled="busy" @click="edit"><Pencil /></button>
<button :title="t('delete')" :disabled="busy" @click="remove"><Trash2 /></button>
</div>
</footer>
</section>
</div>
</Teleport>
</template>

View File

@@ -0,0 +1,68 @@
<script setup>
import { onMounted, onUnmounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { ArrowLeftRight, ArrowRight, Check, RefreshCw } from 'lucide-vue-next'
import { useAppStore } from '../store'
import { teams, teamsLoading, loadTeams, switchTeam, clearTeams } from '../team'
// 团队切换器:左侧 rail 常驻入口,弹出团队列表一键切换;当前团队名同步到窗口标题(见 team.js
const router = useRouter()
const store = useAppStore()
const { t } = useI18n()
const open = ref(false)
const wrap = ref(null)
const busy = ref(false)
async function toggle() {
open.value = !open.value
if (open.value && store.syncStatus.loggedIn) await loadTeams()
}
async function pick(tm) {
if (tm.current || busy.value) return
busy.value = true
try {
await switchTeam(tm.id)
store.showToast({ type: 'success', text: t('teamSwitchedToast', { name: tm.name }) })
open.value = false
} catch (e) {
store.showToast({ type: 'error', text: String(e) })
} finally { busy.value = false }
}
function onClickAway(e) {
if (wrap.value && !wrap.value.contains(e.target)) open.value = false
}
onMounted(() => {
addEventListener('click', onClickAway)
if (store.syncStatus.loggedIn) loadTeams()
})
onUnmounted(() => removeEventListener('click', onClickAway))
// 登录后拉取团队(顺带设置窗口标题),登出还原。
watch(() => store.syncStatus.loggedIn, v => { v ? loadTeams() : clearTeams() })
</script>
<template>
<div ref="wrap" class="bell-wrap">
<button class="bell-btn" :title="t('teamSwitcher')" @click="toggle">
<ArrowLeftRight />
</button>
<div v-if="open" class="bell-dropdown popover-glass ts-dropdown">
<header><b>{{ t('teamSwitcher') }}</b></header>
<div class="bell-list ts-list">
<template v-if="store.syncStatus.loggedIn">
<p v-if="teamsLoading" class="bell-empty"><RefreshCw class="spin" /> {{ t('loading') }}</p>
<template v-else-if="teams.length">
<button v-for="tm in teams" :key="tm.id" type="button" class="team-pick" :class="{ current: tm.current }" :disabled="busy" @click="pick(tm)">
<span class="team-pick-badge">{{ tm.name[0] }}</span>
<span class="team-pick-main"><b>{{ tm.name }}</b><small>{{ t('teamRole_' + tm.role) }} · {{ t('teamMembersCount', { n: tm.members }) }}</small></span>
<Check v-if="tm.current" class="team-pick-check" />
</button>
</template>
<div v-else class="bell-empty">{{ t('teamNoneHint') }}</div>
</template>
<div v-else class="bell-empty">{{ t('teamLoginHint') }}</div>
</div>
<button type="button" class="bell-more" @click="open = false; router.push('/team')">{{ t('teamGoHome') }}<ArrowRight /></button>
</div>
</div>
</template>

31
frontend/src/dateutil.js Normal file
View File

@@ -0,0 +1,31 @@
// dateutil.js 灵活日期输入解析:日历跳转与 DatePicker 手动输入共用。
// 支持 20260501 / 2026-05-01 / 2026/5/1 / 0501 / 05-01 / 5-1 / 501 等写法;
// 未带年份的按 defaultYear通常取当前视图年份补全。
export const pad2 = n => String(n).padStart(2, '0')
export const toYmd = d => `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`
const daysInMonth = (y, m) => new Date(y, m, 0).getDate()
function build(y, m, d) {
y = Number(y); m = Number(m); d = Number(d)
if (!y || y < 1900 || y > 9999) return ''
if (m < 1 || m > 12 || d < 1 || d > daysInMonth(y, m)) return ''
return `${y}-${pad2(m)}-${pad2(d)}`
}
// parseFlexDate 返回 'YYYY-MM-DD',解析失败返回 ''。
export function parseFlexDate(input, defaultYear = new Date().getFullYear()) {
const s = String(input || '').trim().replace(/[./年月]/g, '-').replace(/日$/, '')
if (!s) return ''
if (/^\d+$/.test(s)) {
if (s.length === 8) return build(s.slice(0, 4), s.slice(4, 6), s.slice(6)) // 20260501
if (s.length === 4) return build(defaultYear, s.slice(0, 2), s.slice(2)) // 0501
if (s.length === 3) return build(defaultYear, s.slice(0, 1), s.slice(1)) // 501
return ''
}
const parts = s.split('-').map(x => x.trim()).filter(Boolean)
if (parts.length === 3) return build(parts[0], parts[1], parts[2]) // 2026-5-1
if (parts.length === 2) return build(defaultYear, parts[0], parts[1]) // 05-01
return ''
}

151
frontend/src/eggart.js Normal file
View File

@@ -0,0 +1,151 @@
// 日历节日/节气彩蛋:手绘线条风 SVG 图案库(不用 emoji
// 每个节日映射一组图形 + 专属配色compose 成 dataURL 铺在格子背景。
// 图形均画在 24x24 视图内stroke 线条风,个别点缀用 fill。
const M = {
lantern: '<ellipse cx="12" cy="11.5" rx="6.2" ry="7"/><path d="M9 5.2h6M9 17.8h6M12 18v3"/><path d="M9.6 5.8v11.4M14.4 5.8v11.4"/>',
firecracker: '<rect x="9" y="7.5" width="6" height="11.5" rx="1.6"/><path d="M12 7.5V5M12 5c0-1.3 1.5-1.1 1.5-2.4"/><path d="M6.2 10.4l-2.2-1M6.2 14l-2.2 1M17.8 10.4l2.2-1M17.8 14l2.2 1"/>',
envelope: '<rect x="5.5" y="4" width="13" height="16" rx="2"/><path d="M5.5 8.2h13"/><circle cx="12" cy="13.6" r="2.5"/>',
moon: '<path d="M18.8 13.4A7.3 7.3 0 1 1 10.6 5a5.7 5.7 0 0 0 8.2 8.4z"/>',
mooncake: '<circle cx="12" cy="12" r="7.4"/><circle cx="12" cy="12" r="4.2" stroke-dasharray="2.2 2"/><circle cx="12" cy="12" r=".9" fill="currentColor" stroke="none"/>',
star: '<path d="M12 4.4l1.7 5.9L19.6 12l-5.9 1.7L12 19.6l-1.7-5.9L4.4 12l5.9-1.7z"/>',
heart: '<path d="M12 19.2S5.4 15 3.8 11.3A4.5 4.5 0 0 1 12 7.6a4.5 4.5 0 0 1 8.2 3.7C18.6 15 12 19.2 12 19.2z"/>',
leaf: '<path d="M5.2 18.8C5.2 9.4 12 5 19.2 5c0 8.2-5.4 13.8-14 13.8z"/><path d="M5.2 18.8C8 13.6 11.8 9.8 15.6 7.2"/>',
sprout: '<path d="M12 21v-8.2"/><path d="M12 12.8c0-3.8-2.9-6.2-6.8-6.2 0 4.3 2.9 6.2 6.8 6.2z"/><path d="M12 10.8c0-3.2 2.5-5.5 6.2-5.5-.3 3.7-2.7 5.5-6.2 5.5z"/>',
flower: '<circle cx="12" cy="12" r="2"/><circle cx="12" cy="6.6" r="2.4"/><circle cx="17.2" cy="10.3" r="2.4"/><circle cx="15.2" cy="16.4" r="2.4"/><circle cx="8.8" cy="16.4" r="2.4"/><circle cx="6.8" cy="10.3" r="2.4"/>',
rain: '<path d="M6.6 10.2a5.4 5.4 0 0 1 10.5-1.7 3.9 3.9 0 0 1-.4 7.7H8a3.9 3.9 0 0 1-1.4-6z"/><path d="M9 18.6l-.9 2M13 18.6l-.9 2M17 18.6l-.9 2"/>',
drop: '<path d="M12 3.6c3.5 4.1 5.8 7.2 5.8 10.2a5.8 5.8 0 0 1-11.6 0c0-3 2.3-6.1 5.8-10.2z"/>',
wheat: '<path d="M12 21V7.6"/><path d="M12 11.6c-2.4 0-4.3-1.9-4.3-4.3 2.4 0 4.3 1.9 4.3 4.3zM12 11.6c2.4 0 4.3-1.9 4.3-4.3-2.4 0-4.3 1.9-4.3 4.3zM12 15.8c-2.4 0-4.3-1.9-4.3-4.3 2.4 0 4.3 1.9 4.3 4.3zM12 15.8c2.4 0 4.3-1.9 4.3-4.3-2.4 0-4.3 1.9-4.3 4.3z"/>',
sun: '<circle cx="12" cy="12" r="4"/><path d="M12 3.2v2.2M12 18.6v2.2M3.2 12h2.2M18.6 12h2.2M5.8 5.8l1.6 1.6M16.6 16.6l1.6 1.6M18.2 5.8l-1.6 1.6M7.4 16.6l-1.6 1.6"/>',
snow: '<path d="M12 3.4v17.2M4.5 7.7l15 8.6M19.5 7.7l-15 8.6"/><path d="M12 6.8l-1.7-1.7M12 6.8l1.7-1.7M12 17.2l-1.7 1.7M12 17.2l1.7 1.7"/>',
fire: '<path d="M12 20.8c-3.6 0-6.2-2.4-6.2-5.7 0-2.4 1.5-4 2.9-5.6.3 1.2 1 1.9 1.9 2.2-.2-3.2.5-5.6 2.9-7.9-.4 2.8.7 4 2.2 5.7 1.2 1.4 2.5 2.8 2.5 5.6 0 3.3-2.6 5.7-6.2 5.7z"/>',
boat: '<path d="M4 15.8h16l-2.3 3.8H6.3z"/><path d="M12 15.8V4.4"/><path d="M12 5c3.8 1.3 5.7 4.3 5.7 8.1"/>',
candle: '<rect x="9.5" y="10.2" width="5" height="9.8" rx="1.2"/><path d="M12 10.2V8.2"/><path d="M12 3.6c1.2 1.3 1.2 2.7 0 4-1.2-1.3-1.2-2.7 0-4z"/>',
book: '<path d="M4.4 5.4A2.4 2.4 0 0 1 6.8 3h12.8v15.6H6.8a2.4 2.4 0 0 0-2.4 2.4z"/><path d="M19.6 18.6H6.8a2.4 2.4 0 0 0-2.4 2.4"/>',
balloon: '<ellipse cx="12" cy="8.8" rx="5.2" ry="6.2"/><path d="M12 15l-.9 1.5h1.8z"/><path d="M12 16.5c0 2-1.5 2-1.5 4.2"/>',
rocket: '<path d="M12 3.2c3 1.7 4.4 4.8 4.4 8.2l-1.9 4.8H9.5l-1.9-4.8c0-3.4 1.4-6.5 4.4-8.2z"/><circle cx="12" cy="9.2" r="1.7"/><path d="M9.5 16.2L7.6 19.6M14.5 16.2l1.9 3.4"/>',
gift: '<rect x="4.8" y="9.2" width="14.4" height="10.8" rx="1.6"/><path d="M12 9.2V20M4.8 13.6h14.4"/><path d="M12 9.2c-4.3 0-5.2-5.2-1.5-5.2 1.9 0 1.5 5.2 1.5 5.2zM12 9.2c4.3 0 5.2-5.2 1.5-5.2-1.9 0-1.5 5.2-1.5 5.2z"/>',
pine: '<path d="M12 3.2l4.8 5.8h-2.5l3.9 5.8h-4.4v3h-3.6v-3H5.8l3.9-5.8H7.2z"/><path d="M9 20.8h6"/>',
tree: '<circle cx="12" cy="9.2" r="5.4"/><path d="M12 14.6V21M9.4 21h5.2"/>',
pumpkin: '<path d="M12 7.4c4.8 0 7.8 2.6 7.8 6.6s-3 6.6-7.8 6.6-7.8-2.6-7.8-6.6 3-6.6 7.8-6.6z"/><path d="M9.5 7.8c-1.5 3.6-1.5 8.8 0 12.4M14.5 7.8c1.5 3.6 1.5 8.8 0 12.4"/><path d="M12 7.4V5c0-.9.8-1.5 1.9-1.5"/>',
bell: '<path d="M12 4.2a5.8 5.8 0 0 1 5.8 5.8v3.8l1.7 2.5H4.5L6.2 13.8V10A5.8 5.8 0 0 1 12 4.2z"/><path d="M10.1 19.2a1.9 1.9 0 0 0 3.8 0"/>',
wind: '<path d="M3.4 8.4h9.2a2.5 2.5 0 1 0-2.5-2.5M3.4 12.8h13.9a2.5 2.5 0 1 1-2.5 2.5M3.4 17.2h6.7a2.1 2.1 0 1 1-2.1 2.1"/>',
mountain: '<path d="M3.2 18.8L9.4 7.4l3.8 6.6 1.9-3.2 5.7 8z"/>',
wave: '<path d="M3.2 11c2.4-2.9 5.3-2.9 7.6 0s5.2 2.9 7.6 0M3.2 16.2c2.4-2.9 5.3-2.9 7.6 0s5.2 2.9 7.6 0"/>',
ice: '<path d="M12 3.2l7.6 4.4v8.8L12 20.8l-7.6-4.4V7.6z"/><path d="M12 3.2v17.6M4.4 7.6l15.2 8.8M19.6 7.6L4.4 16.4" opacity=".45"/>',
gear: '<circle cx="12" cy="12" r="3"/><path d="M12 3v2.6M12 18.4V21M3 12h2.6M18.4 12H21M5.6 5.6l1.9 1.9M16.5 16.5l1.9 1.9M18.4 5.6l-1.9 1.9M7.5 16.5l-1.9 1.9"/>',
bolt: '<path d="M13.2 3L6 13.4h4.9L9.4 21 18 10.6h-4.9z"/>',
firework: '<circle cx="12" cy="12" r="1.1" fill="currentColor" stroke="none"/><path d="M12 3.6v3M12 17.4v3M3.6 12h3M17.4 12h3M6 6l2.1 2.1M15.9 15.9L18 18M18 6l-2.1 2.1M8.1 15.9L6 18"/><circle cx="12" cy="12" r="7.6" stroke-dasharray="1.4 3.4"/>',
bowl: '<path d="M4 11.4h16a8 8 0 0 1-16 0z"/><path d="M8.4 8.4c1.4-1.4 3.6-.9 3.6.9M12.6 7.2c1.4-1.4 3.6-.9 3.6.9"/>',
dumpling: '<path d="M4 14.4a8 8 0 0 1 16 0c0 1.9-1.7 3.8-3.8 3.8H7.8c-2.1 0-3.8-1.9-3.8-3.8z"/><path d="M8.2 9.8L9.3 7.6M12 8.9V6.6M15.8 9.8l-1.1-2.2"/>'
}
// 每个节日i = [主图形, 点缀1, 点缀2]c = [主色, 点缀色]
const RULES = [
['除夕', { i: ['firecracker', 'lantern', 'star'], c: ['#f0656e', '#e7bd6a'] }],
['春节', { i: ['envelope', 'lantern', 'star'], c: ['#f0656e', '#e7bd6a'] }],
['元宵', { i: ['lantern', 'moon', 'star'], c: ['#f08c5a', '#e7bd6a'] }],
['龙头', { i: ['wave', 'sun', 'star'], c: ['#4fb6c9', '#e7bd6a'] }],
['端午', { i: ['boat', 'leaf', 'drop'], c: ['#43c996', '#4fb6c9'] }],
['七夕', { i: ['heart', 'star', 'moon'], c: ['#ef7ea8', '#9a8cf8'] }],
['中元', { i: ['candle', 'moon', 'star'], c: ['#e7bd6a', '#9a8cf8'] }],
['中秋', { i: ['mooncake', 'moon', 'star'], c: ['#e7bd6a', '#9a8cf8'] }],
['重阳', { i: ['flower', 'mountain', 'leaf'], c: ['#ef9950', '#c98f5a'] }],
['腊八', { i: ['bowl', 'wheat', 'snow'], c: ['#c98f5a', '#6db5ee'] }],
['元旦', { i: ['firework', 'star', 'bell'], c: ['#9a8cf8', '#e7bd6a'] }],
['情人', { i: ['heart', 'star', 'gift'], c: ['#ef7ea8', '#f0656e'] }],
['妇女', { i: ['flower', 'heart', 'sprout'], c: ['#ef7ea8', '#43c996'] }],
['植树', { i: ['tree', 'sprout', 'leaf'], c: ['#43c996', '#7ac96b'] }],
['愚人', { i: ['balloon', 'star', 'wind'], c: ['#ef9950', '#9a8cf8'] }],
['劳动', { i: ['gear', 'wheat', 'sun'], c: ['#e7bd35', '#c98f5a'] }],
['青年', { i: ['rocket', 'star', 'wind'], c: ['#5b9df5', '#9a8cf8'] }],
['儿童', { i: ['balloon', 'gift', 'star'], c: ['#4fb6c9', '#ef7ea8'] }],
['教师', { i: ['book', 'star', 'flower'], c: ['#5b9df5', '#e7bd6a'] }],
['国庆', { i: ['firework', 'lantern', 'star'], c: ['#f0656e', '#e7bd6a'] }],
['万圣', { i: ['pumpkin', 'moon', 'star'], c: ['#ef9950', '#9a8cf8'] }],
['感恩', { i: ['wheat', 'leaf', 'heart'], c: ['#c98f5a', '#ef9950'] }],
['平安夜', { i: ['bell', 'star', 'snow'], c: ['#e7bd6a', '#43c996'] }],
['圣诞', { i: ['pine', 'gift', 'snow'], c: ['#43c996', '#f0656e'] }],
['母亲', { i: ['flower', 'heart', 'sprout'], c: ['#ef7ea8', '#f0656e'] }],
['父亲', { i: ['mountain', 'heart', 'star'], c: ['#5b9df5', '#e7bd6a'] }],
// 二十四节气:春绿 / 夏金 / 秋橙 / 冬蓝
['立春', { i: ['sprout', 'leaf', 'rain'], c: ['#43c996', '#7ac96b'] }],
['雨水', { i: ['rain', 'drop', 'sprout'], c: ['#4fb6c9', '#43c996'] }],
['惊蛰', { i: ['bolt', 'sprout', 'rain'], c: ['#e7bd35', '#43c996'] }],
['春分', { i: ['flower', 'leaf', 'sun'], c: ['#7ac96b', '#e7bd6a'] }],
['清明', { i: ['leaf', 'rain', 'sprout'], c: ['#43c996', '#4fb6c9'] }],
['谷雨', { i: ['wheat', 'rain', 'drop'], c: ['#7ac96b', '#4fb6c9'] }],
['立夏', { i: ['sun', 'flower', 'leaf'], c: ['#e7bd35', '#43c996'] }],
['小满', { i: ['wheat', 'drop', 'sun'], c: ['#d9c05a', '#4fb6c9'] }],
['芒种', { i: ['wheat', 'sun', 'wind'], c: ['#d9c05a', '#e7bd35'] }],
['夏至', { i: ['sun', 'wave', 'drop'], c: ['#e7bd35', '#4fb6c9'] }],
['小暑', { i: ['fire', 'wave', 'sun'], c: ['#ef9950', '#4fb6c9'] }],
['大暑', { i: ['fire', 'sun', 'wave'], c: ['#f0656e', '#e7bd35'] }],
['立秋', { i: ['leaf', 'wheat', 'wind'], c: ['#ef9950', '#c98f5a'] }],
['处暑', { i: ['leaf', 'sun', 'wind'], c: ['#ef9950', '#e7bd6a'] }],
['白露', { i: ['drop', 'moon', 'leaf'], c: ['#6db5ee', '#ef9950'] }],
['秋分', { i: ['leaf', 'moon', 'wheat'], c: ['#c98f5a', '#e7bd6a'] }],
['寒露', { i: ['drop', 'snow', 'leaf'], c: ['#6db5ee', '#ef9950'] }],
['霜降', { i: ['snow', 'leaf', 'wind'], c: ['#6db5ee', '#c98f5a'] }],
['立冬', { i: ['snow', 'wind', 'mountain'], c: ['#6db5ee', '#9a8cf8'] }],
['小雪', { i: ['snow', 'wind', 'star'], c: ['#6db5ee', '#b8d4f2'] }],
['大雪', { i: ['snow', 'mountain', 'wind'], c: ['#8cc3f0', '#6db5ee'] }],
['冬至', { i: ['dumpling', 'snow', 'lantern'], c: ['#e7bd6a', '#6db5ee'] }],
['小寒', { i: ['ice', 'snow', 'wind'], c: ['#6db5ee', '#9a8cf8'] }],
['大寒', { i: ['ice', 'mountain', 'snow'], c: ['#8cc3f0', '#9a8cf8'] }]
]
export function festEgg(names) {
for (const n of names || []) {
for (const [k, egg] of RULES) if (n.includes(k)) return { key: k, ...egg }
}
return null
}
const place = (name, x, y, s, rot, color, op) =>
`<g transform="translate(${x} ${y}) rotate(${rot}) scale(${s}) translate(-12 -12)" fill="none" stroke="${color}" color="${color}" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" opacity="${op}">${M[name] || M.star}</g>`
const cache = new Map()
export function eggBg(egg) {
if (!egg) return ''
const key = egg.i.join() + egg.c.join()
if (cache.has(key)) return cache.get(key)
const [main, a1, a2] = egg.i
const [c1, c2] = egg.c
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 192 192">${place(a1, 42, 44, 2.1, -16, c2, .8)}${place(a2, 150, 50, 1.7, 14, c2, .65)}${place(main, 96, 118, 3.8, -7, c1, 1)}</svg>`
const url = `url("data:image/svg+xml,${encodeURIComponent(svg)}")`
cache.set(key, url)
return url
}
// eggArt整卡插画背景深色渐变底 + 节日色光晕 + 装饰环/星点 + 大图案),
// 铺满日历格当"配图"用;管理员未上传自定义照片时的默认背景。
const artCache = new Map()
export function eggArt(egg) {
if (!egg) return ''
const key = 'art:' + egg.i.join() + egg.c.join()
if (artCache.has(key)) return artCache.get(key)
const [main, a1, a2] = egg.i
const [c1, c2] = egg.c
const dots = [[26, 92, 1.6], [58, 22, 1.2], [104, 40, 1.8], [170, 96, 1.3], [148, 160, 1.6], [36, 168, 1.2], [176, 22, 1.1]]
.map(([x, y, r], n) => `<circle cx="${x}" cy="${y}" r="${r}" fill="${n % 2 ? c2 : c1}" opacity="${n % 2 ? .5 : .38}"/>`).join('')
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 192 192">
<defs>
<linearGradient id="b" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#161c2e"/><stop offset="1" stop-color="#0b0f1a"/></linearGradient>
<radialGradient id="p" cx="26%" cy="18%" r="80%"><stop offset="0" stop-color="${c1}" stop-opacity=".5"/><stop offset="1" stop-color="${c1}" stop-opacity="0"/></radialGradient>
<radialGradient id="q" cx="88%" cy="94%" r="86%"><stop offset="0" stop-color="${c2}" stop-opacity=".42"/><stop offset="1" stop-color="${c2}" stop-opacity="0"/></radialGradient>
</defs>
<rect width="192" height="192" fill="url(#b)"/><rect width="192" height="192" fill="url(#p)"/><rect width="192" height="192" fill="url(#q)"/>
<circle cx="158" cy="30" r="44" fill="none" stroke="${c2}" stroke-opacity=".22" stroke-width="1.4"/>
<circle cx="158" cy="30" r="58" fill="none" stroke="${c2}" stroke-opacity=".1" stroke-width="1.2"/>
<circle cx="14" cy="148" r="30" fill="none" stroke="${c1}" stroke-opacity=".18" stroke-width="1.4"/>
${dots}
${place(a1, 38, 52, 1.9, -14, c2, .62)}
${place(a2, 152, 66, 1.5, 12, c2, .5)}
${place(main, 118, 134, 3.9, -6, '#0a0e18', .6)}
${place(main, 116, 132, 3.7, -6, c1, .95)}
</svg>`
const url = `url("data:image/svg+xml,${encodeURIComponent(svg)}")`
artCache.set(key, url)
return url
}

53
frontend/src/mdeditor.js Normal file
View File

@@ -0,0 +1,53 @@
// 待办/工单编辑器共用的 Markdown 能力:编辑/预览切换、粘贴图片、选图插入。
// 图片经后端 SaveContentImage 处理(缩放压缩,按设置存 base64 或本地文件)。
import { ref } from 'vue'
import { call } from './api'
export function useMdEditor(form, key, onError) {
const preview = ref(false)
const inputEl = ref(null)
const uploading = ref(false)
function reset() {
preview.value = false
uploading.value = false
}
function insert(text) {
const el = inputEl.value
const v = form[key] || ''
const s = el && el.selectionStart != null ? el.selectionStart : v.length
const e = el && el.selectionEnd != null ? el.selectionEnd : v.length
form[key] = v.slice(0, s) + text + v.slice(e)
}
const snippet = v => `${form[key] && !form[key].endsWith('\n') ? '\n' : ''}![img](${v})\n`
async function onPaste(e) {
const item = [...(e.clipboardData?.items || [])].find(i => i.type.startsWith('image/'))
if (!item) return
e.preventDefault()
const file = item.getAsFile()
if (!file) return
const b64 = await new Promise(r => {
const fr = new FileReader()
fr.onload = () => r(fr.result)
fr.readAsDataURL(file)
})
uploading.value = true
try { insert(snippet(await call('SaveContentImage', b64))) }
catch (err) { onError(err) }
finally { uploading.value = false }
}
async function pickImage() {
uploading.value = true
try {
const v = await call('PickContentImage')
if (v) insert(snippet(v))
} catch (err) { onError(err) }
finally { uploading.value = false }
}
return { preview, inputEl, uploading, reset, onPaste, pickImage }
}

56
frontend/src/team.js Normal file
View File

@@ -0,0 +1,56 @@
// team.js 团队共享状态:团队列表 / 当前团队 / 角色,避免各页面各自维护。
// 同时负责把当前团队名同步到原生窗口标题。
import { computed, ref } from 'vue'
import { Window } from '@wailsio/runtime'
import { call, isNative } from './api'
import { useAppStore } from './store'
export const teams = ref([])
export const teamsErr = ref('')
export const teamsLoading = ref(false)
export const currentTeam = computed(() => teams.value.find(x => x.current) || null)
export const isTeamAdmin = computed(() => ['owner', 'admin'].includes(currentTeam.value?.role))
// 与 main.go 窗口初始标题保持一致。
const BASE_TITLE = '年糕崽崽项目管理PMS'
function syncTitle() {
if (!isNative()) return
const cur = teams.value.find(x => x.current)
try { Window.SetTitle(cur ? `${BASE_TITLE} · ${cur.name}` : BASE_TITLE) } catch { /* 运行时未就绪时忽略 */ }
}
export async function loadTeams() {
teamsLoading.value = true
teamsErr.value = ''
try {
teams.value = (await call('TeamList')) || []
} catch (e) {
teams.value = []
teamsErr.value = String(e)
} finally {
teamsLoading.value = false
syncTitle()
}
}
export async function switchTeam(id) {
await call('TeamSwitch', id)
teams.value.forEach(x => { x.current = x.id === id })
syncTitle()
// 团队任务徽标按当前团队统计,切换后立即刷新
try { useAppStore().refreshBadges() } catch { /* pinia 未就绪时忽略 */ }
}
// clearTeams 登出时清空团队状态并还原窗口标题。
export function clearTeams() {
teams.value = []
teamsErr.value = ''
syncTitle()
}
// teamErrKey 把后端错误串转成 i18n keyerrors.XXX未识别返回空。
export function teamErrCode(e) {
return String(e).split(':')[0].trim()
}

View File

@@ -0,0 +1,268 @@
<script setup>
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { Sparkles, Plus, Trash2, SendHorizonal, Square, Bot, User, PieChart, GitCommitHorizontal, ListTodo, TicketCheck, Settings, Folder } from 'lucide-vue-next'
import { marked } from 'marked'
import hljs from 'highlight.js/lib/common'
import 'highlight.js/styles/github-dark.css'
import { call, on } from '../api'
import { useAppStore } from '../store'
const route = useRoute()
const router = useRouter()
const store = useAppStore()
const { t } = useI18n()
const conversations = ref([])
const activeId = ref(0)
const messages = ref([])
const input = ref('')
const streamingId = ref(0)
const streamText = ref('')
const streamError = ref('')
const projectId = ref(Number(route.query.project) || 0)
const scenario = ref('chat')
const listEl = ref(null)
const streamBuf = {}
let offStream = null
const streaming = computed(() => streamingId.value !== 0 && streamingId.value === activeId.value)
// 会话区“虚拟滚动”:只渲染最近 N 条,向上滚动增量加载更早消息,长会话不卡顿。
const WINDOW = 40
const windowSize = ref(WINDOW)
const visibleMessages = computed(() => messages.value.slice(-windowSize.value))
const hiddenCount = computed(() => Math.max(0, messages.value.length - windowSize.value))
function expandWindow() {
const el = listEl.value
if (!hiddenCount.value || !el) return
const prevH = el.scrollHeight
const prevTop = el.scrollTop
windowSize.value += 60
// 渲染更早消息后保持视觉位置不跳
nextTick(() => { el.scrollTop = el.scrollHeight - prevH + prevTop })
}
function onListScroll() {
if (listEl.value && listEl.value.scrollTop < 40 && hiddenCount.value) expandWindow()
}
marked.setOptions({ breaks: true, gfm: true })
const md = s => marked.parse(s || '')
const activeConv = computed(() => conversations.value.find(c => c.id === activeId.value))
const providerLabel = computed(() => store.settings.aiProvider === 'deepseek' ? 'DeepSeek' : t('aiSparkLite'))
const hasKey = computed(() => store.settings.aiProvider === 'deepseek' ? !!store.settings.deepSeekKey : !!store.settings.sparkKey)
const quicks = [
{ key: 'project', icon: PieChart, label: 'aiQuickProject', prompt: 'aiPromptProject' },
{ key: 'git', icon: GitCommitHorizontal, label: 'aiQuickGit', prompt: 'aiPromptGit' },
{ key: 'todo', icon: ListTodo, label: 'aiQuickTodo', prompt: 'aiPromptTodo' },
{ key: 'ticket', icon: TicketCheck, label: 'aiQuickTicket', prompt: 'aiPromptTicket' }
]
function errText(e) {
const code = String(e).split(':')[0].trim()
return t('errors.' + code) !== 'errors.' + code ? t('errors.' + code) : String(e)
}
async function loadConversations() {
conversations.value = await call('ListAIConversations', 0)
}
async function select(c) {
activeId.value = c.id
projectId.value = c.projectId
streamError.value = ''
streamText.value = streamBuf[c.id] || ''
windowSize.value = WINDOW
messages.value = await call('GetAIMessages', c.id)
scrollBottom()
}
function newChat() {
activeId.value = 0
messages.value = []
streamError.value = ''
streamText.value = ''
}
async function del(c) {
if (!confirm(t('aiDeleteConfirm'))) return
await call('DeleteAIConversation', c.id)
if (activeId.value === c.id) newChat()
await loadConversations()
}
async function send(text, scen) {
const content = (text ?? input.value).trim()
if (!content || streaming.value) return
streamError.value = ''
try {
const conv = await call('SendAIMessage', activeId.value, projectId.value, scen || scenario.value, content)
if (!activeId.value) {
activeId.value = conv.id
await loadConversations()
}
messages.value.push({ id: -Date.now(), role: 'user', content })
input.value = ''
streamingId.value = conv.id
streamBuf[conv.id] = ''
streamText.value = ''
scrollBottom()
} catch (e) {
streamError.value = errText(e)
}
}
function quick(q) {
if (!projectId.value) { streamError.value = t('aiNeedProject'); return }
send(t(q.prompt), q.key)
}
async function stop() {
try { await call('StopAIStream', streamingId.value || activeId.value) } catch {}
}
// RFC3339(UTC) 转本地时区的 MM-DD HH:mm
function convTime(at) {
const d = new Date(at)
if (isNaN(d)) return ''
const p = n => String(n).padStart(2, '0')
return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`
}
function scrollBottom() {
nextTick(() => { if (listEl.value) listEl.value.scrollTop = listEl.value.scrollHeight })
}
function highlightAll() {
nextTick(() => {
listEl.value?.querySelectorAll('pre code:not([data-hl])').forEach(el => {
el.dataset.hl = '1'
hljs.highlightElement(el)
})
})
}
watch(visibleMessages, highlightAll, { deep: true })
// 已在本页时通过全局搜索跳转到其它会话
watch(() => route.query.conversation, async v => {
const conv = conversations.value.find(c => c.id === Number(v))
if (conv && conv.id !== activeId.value) await select(conv)
})
onMounted(async () => {
await loadConversations()
const deepConv = conversations.value.find(c => c.id === Number(route.query.conversation))
if (deepConv) {
// 全局搜索 / 深链进入:直接选中指定会话
await select(deepConv)
} else if (route.query.project) {
// 从项目详情进入:优先打开该项目最近的会话,否则新建并预选该项目
const conv = conversations.value.find(c => c.projectId === projectId.value)
if (conv) await select(conv)
else newChat()
// 详情页就地提问:携带 ask 参数时自动发送,并清掉 query 防止刷新重发
const ask = String(route.query.ask || '').trim()
if (ask && hasKey.value) {
router.replace('/ai')
await send(ask)
}
} else if (conversations.value.length) {
await select(conversations.value[0])
}
offStream = on('ai:stream', e => {
const cid = e.conversationId
if (e.delta) {
streamBuf[cid] = (streamBuf[cid] || '') + e.delta
if (cid === activeId.value) {
streamText.value = streamBuf[cid]
scrollBottom()
}
}
if (e.done) {
if (cid === streamingId.value) streamingId.value = 0
const finalText = streamBuf[cid] || ''
delete streamBuf[cid]
if (cid === activeId.value) {
if (e.error) streamError.value = errText(e.error)
if (finalText) messages.value.push({ id: e.messageId || -Date.now(), role: 'assistant', content: finalText })
streamText.value = ''
highlightAll()
}
loadConversations()
}
})
})
onUnmounted(() => offStream?.())
</script>
<template>
<div class="page ai-page">
<header class="page-head sticky-head">
<div><h1>{{ t('aiChat') }}</h1><p>{{ t('aiSubtitle') }}</p></div>
<div class="actions">
<span class="ai-provider-badge"><Sparkles />{{ providerLabel }}</span>
<button class="btn secondary" @click="router.push('/settings?tab=ai')"><Settings />{{ t('aiKeys') }}</button>
<button class="btn primary" @click="newChat"><Plus />{{ t('aiNewChat') }}</button>
</div>
</header>
<div v-if="!hasKey" class="preview-notice panel ai-key-notice">
<Sparkles />
<div><b>{{ t('aiNoKeyTitle') }}</b><small>{{ t('aiNoKeyHint') }}</small></div>
<button class="btn primary" @click="router.push('/settings?tab=ai')">{{ t('aiConfigureNow') }}</button>
</div>
<div class="ai-layout">
<aside class="panel ai-history">
<h2>{{ t('aiHistory') }}<small>{{ conversations.length }}</small></h2>
<div class="ai-conv-list">
<button v-for="c in conversations" :key="c.id" class="ai-conv" :class="{ active: c.id === activeId }" @click="select(c)">
<b>{{ c.title }}</b>
<small>
<span v-if="c.projectName" class="todo-chip">{{ c.projectName }}</span>
{{ convTime(c.updatedAt) }}
</small>
<i class="ai-conv-del" :title="t('delete')" @click.stop="del(c)"><Trash2 /></i>
</button>
<div v-if="!conversations.length" class="empty wb-empty">{{ t('aiNoHistory') }}</div>
</div>
</aside>
<section class="panel ai-chat">
<div class="ai-toolbar">
<label class="ai-project-pick">
<Folder />
<select v-model.number="projectId" :disabled="!!activeConv">
<option :value="0">{{ t('aiNoProject') }}</option>
<option v-for="p in store.projects" :key="p.id" :value="p.id">{{ p.name }}</option>
</select>
</label>
<div class="ai-quicks">
<button v-for="q in quicks" :key="q.key" class="btn secondary" :disabled="streaming || !hasKey" :title="!projectId ? t('aiNeedProject') : ''" @click="quick(q)">
<component :is="q.icon" />{{ t(q.label) }}
</button>
</div>
</div>
<div ref="listEl" class="ai-messages" @scroll.passive="onListScroll">
<div v-if="!messages.length && !streaming" class="ai-welcome">
<Bot />
<p>{{ t('aiWelcome') }}</p>
</div>
<button v-if="hiddenCount" class="ai-load-earlier" @click="expandWindow">{{ t('aiLoadEarlier', { n: hiddenCount }) }}</button>
<div v-for="m in visibleMessages" :key="m.id" class="ai-msg" :class="m.role">
<span class="ai-avatar"><component :is="m.role === 'user' ? User : Bot" /></span>
<div v-if="m.role === 'assistant'" class="ai-bubble markdown" v-html="md(m.content)" />
<div v-else class="ai-bubble">{{ m.content }}</div>
</div>
<div v-if="streaming" class="ai-msg assistant">
<span class="ai-avatar"><Bot /></span>
<div class="ai-bubble markdown">
<div v-if="streamText" v-html="md(streamText)" />
<span class="ai-typing"><i /><i /><i /></span>
</div>
</div>
<p v-if="streamError" class="ai-error">{{ streamError }}</p>
</div>
<div class="ai-input-row">
<textarea v-model="input" :placeholder="t('aiAskPlaceholder')" rows="2" :disabled="!hasKey"
@keydown.enter.exact.prevent="send()" />
<button v-if="streaming" class="btn secondary ai-send" @click="stop"><Square />{{ t('aiStop') }}</button>
<button v-else class="btn primary ai-send" :disabled="!input.trim() || !hasKey" @click="send()"><SendHorizonal />{{ t('aiSend') }}</button>
</div>
</section>
</div>
</div>
</template>

View File

@@ -0,0 +1,504 @@
<script setup>
import { computed, nextTick, onMounted, onUnmounted, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { ChevronLeft, ChevronRight, CalendarDays, ListTodo, TicketCheck, ArrowRight, Plus, AlarmClock, X, ImagePlus, Sparkles, Check, Image as ImageIcon } from 'lucide-vue-next'
import { Solar } from 'lunar-javascript'
import { call, notifyTasksChanged, onTasksChanged } from '../api'
import { parseFlexDate } from '../dateutil'
import { useAppStore } from '../store'
import MarkdownView from '../components/MarkdownView.vue'
import DueQuickPick from '../components/DueQuickPick.vue'
import DatePicker from '../components/DatePicker.vue'
import TaskDetailModal from '../components/TaskDetailModal.vue'
import AIScopeDrawer from '../components/AIScopeDrawer.vue'
import { useMdEditor } from '../mdeditor'
import { festEgg, eggBg, eggArt } from '../eggart'
const store = useAppStore()
const { t, locale } = useI18n()
const todos = ref([])
const tickets = ref([])
const cursor = ref(new Date())
const selected = ref('')
const aiOpen = ref(false)
const ymd = d => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
const todayStr = ymd(new Date())
const lunarOn = computed(() => locale.value === 'zh-CN')
// ---- 头部年月导航:年/月分别可点快选,输入框支持灵活格式直达某天 ----
const monthNames = computed(() => locale.value === 'zh-CN'
? ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月']
: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'])
const dispYear = computed(() => locale.value === 'zh-CN' ? `${cursor.value.getFullYear()}` : String(cursor.value.getFullYear()))
const dispMonth = computed(() => monthNames.value[cursor.value.getMonth()])
const ymPick = ref('') // '' | 'year' | 'month'
const yearBase = ref(0)
const yearWindow = computed(() => Array.from({ length: 12 }, (_, i) => yearBase.value + i))
const calSwitchEl = ref(null)
function openYm(kind) {
ymPick.value = ymPick.value === kind ? '' : kind
if (ymPick.value === 'year') yearBase.value = Math.floor(cursor.value.getFullYear() / 12) * 12
}
function setYear(y) { cursor.value = new Date(y, cursor.value.getMonth(), 1); ymPick.value = '' }
function setMonth(i) { cursor.value = new Date(cursor.value.getFullYear(), i, 1); ymPick.value = '' }
function onDocDown(e) { if (ymPick.value && calSwitchEl.value && !calSwitchEl.value.contains(e.target)) ymPick.value = '' }
// 跳转某天:支持 20260501 / 2026-05-01 / 0501 / 05-01 等,缺年份按当前视图年份补全
const jumpVal = ref('')
function jumpTo() {
const p = parseFlexDate(jumpVal.value, cursor.value.getFullYear())
if (!p) { store.showToast({ type: 'error', key: 'calBadDate' }); return }
cursor.value = new Date(Number(p.slice(0, 4)), Number(p.slice(5, 7)) - 1, 1)
selected.value = p
jumpVal.value = ''
}
// 农历 / 节气 / 节假日lunar-javascript含农历节日、24 节气、公历与“第 n 个星期 x”规则节日
// 彩蛋图案由 eggart.js 提供SVG 线条画 + 节日专属配色
function lunarInfo(d) {
const solar = Solar.fromYmd(d.getFullYear(), d.getMonth() + 1, d.getDate())
const lunar = solar.getLunar()
const jieQi = lunar.getJieQi()
const fests = [...lunar.getFestivals(), ...solar.getFestivals()]
const dayLabel = lunar.getDay() === 1 ? lunar.getMonthInChinese() + '月' : lunar.getDayInChinese()
const tags = [...fests, ...(jieQi ? [jieQi] : [])]
return {
label: dayLabel,
festLabel: fests[0] || jieQi || '',
fest: fests.length > 0,
jieqi: !fests.length && !!jieQi,
full: `${lunar.getYearInGanZhi()}${lunar.getYearShengXiao()}${lunar.getMonthInChinese()}${lunar.getDayInChinese()}`,
tags,
egg: festEgg(tags)
}
}
// 6x7 月视图网格(周日起始)
const grid = computed(() => {
const first = new Date(cursor.value.getFullYear(), cursor.value.getMonth(), 1)
const start = new Date(first)
start.setDate(1 - first.getDay())
const cells = []
for (let i = 0; i < 42; i++) {
const d = new Date(start)
d.setDate(start.getDate() + i)
const date = ymd(d)
cells.push({
date,
day: d.getDate(),
inMonth: d.getMonth() === cursor.value.getMonth(),
today: date === todayStr,
lunar: lunarOn.value ? lunarInfo(d) : null,
todos: todosByDate.value.get(date) || [],
tickets: ticketsByDate.value.get(date) || []
})
}
return cells
})
const dateOf = v => (v || '').slice(0, 10)
const todosByDate = computed(() => {
const m = new Map()
for (const x of todos.value) {
if (!x.dueAt || x.status === 'done') continue
const k = dateOf(x.dueAt)
m.set(k, [...(m.get(k) || []), x])
}
return m
})
// 工单在 start→due 区间内每天都记一条(限制区间过长时只标记首尾+当月)
const ticketsByDate = computed(() => {
const m = new Map()
for (const x of tickets.value) {
if (['resolved', 'closed'].includes(x.status)) continue
const s = new Date(dateOf(x.startAt))
const e = new Date(dateOf(x.dueAt))
if (isNaN(s) || isNaN(e)) continue
for (let d = new Date(s); d <= e; d.setDate(d.getDate() + 1)) {
const k = ymd(d)
m.set(k, [...(m.get(k) || []), { ...x, isStart: k === dateOf(x.startAt), isEnd: k === dateOf(x.dueAt) }])
if (m.get(k).length > 20) break
}
}
return m
})
const selectedCell = computed(() => grid.value.find(c => c.date === selected.value))
// 点击上/下月的日期时自动翻到对应月份,选中保持在该日期上
function onCellClick(c) {
selected.value = c.date
if (!c.inMonth) cursor.value = new Date(Number(c.date.slice(0, 4)), Number(c.date.slice(5, 7)) - 1, 1)
}
// 格子背景photo 模式且图片可用时显示照片,否则(无图 / art 模式 / 加载失败)回退动态插画
function cellArt(c) {
if (!c.lunar) return null
for (const tg of c.lunar.tags) {
const it = store.festivalImages[tg]
if (it && it.mode === 'photo' && it.image && !store.festBroken[tg]) {
return { cls: 'has-photo', style: { '--art-bg': `url("${it.image}")` } }
}
}
if (c.lunar.egg) return { cls: 'has-art', style: { '--art-bg': eggArt(c.lunar.egg) } }
return null
}
// ---- 节日配图管理(云端账号 id=1 专属)----
const isAdmin = computed(() => store.syncStatus.userId === 1)
const festBusy = ref(false)
const festAdmin = ref(false)
async function pickFestImg(tag) {
if (festBusy.value) return
festBusy.value = true
try {
const f = await call('PickFestivalImage', tag)
if (f && f.image) {
store.festivalImages = { ...store.festivalImages, [tag]: f }
store.showToast({ type: 'success', key: 'festImgSaved' })
}
} catch (e) {
store.showToast({ type: 'error', text: String(e) })
} finally {
festBusy.value = false
}
}
async function setFestMode(tag, mode) {
const it = store.festivalImages[tag]
if (festBusy.value || !it || it.mode === mode) return
festBusy.value = true
try {
await call('SetFestivalImageMode', tag, mode)
store.festivalImages = { ...store.festivalImages, [tag]: { ...it, mode } }
} catch (e) {
store.showToast({ type: 'error', text: String(e) })
} finally {
festBusy.value = false
}
}
const hasFestImg = tg => !!(store.festivalImages[tg] && store.festivalImages[tg].image)
const festModeOf = tg => (hasFestImg(tg) && store.festivalImages[tg].mode === 'photo' ? 'photo' : 'art')
const festPhotoStyle = tg => (hasFestImg(tg) ? { backgroundImage: `url("${store.festivalImages[tg].image}")` } : null)
// 左卡:切回动态插画(图片保留,随时可切回);右卡:无图先选图,有图则启用图片模式
function chooseFestArt(tg) {
if (festModeOf(tg) === 'photo') setFestMode(tg, 'art')
}
function chooseFestPhoto(tg) {
if (!hasFestImg(tg)) return pickFestImg(tg)
if (festModeOf(tg) === 'art') setFestMode(tg, 'photo')
}
async function removeFestImg(tag) {
if (festBusy.value) return
festBusy.value = true
try {
await call('RemoveFestivalImage', tag)
const next = { ...store.festivalImages }
delete next[tag]
store.festivalImages = next
store.showToast({ type: 'success', key: 'festImgRemoved' })
} catch (e) {
store.showToast({ type: 'error', text: String(e) })
} finally {
festBusy.value = false
}
}
// ---- 右键菜单与快速创建 ----
const ctx = reactive({ open: false, x: 0, y: 0, date: '' })
const ctxEl = ref(null)
const quick = reactive({ open: false, kind: 'todo', date: '', title: '', content: '', time: '09:00', projectId: 0, type: 'task', priority: 'medium', startAt: '', dueAt: '', busy: false })
const quickInput = ref(null)
const qmd = useMdEditor(quick, 'content', e => store.showToast({ type: 'error', text: String(e) }))
function onCellCtx(e, c) {
selected.value = c.date
ctx.date = c.date
ctx.x = e.clientX
ctx.y = e.clientY
ctx.open = true
// 渲染后按实际尺寸夹紧到视口内,避免贴边右键时菜单溢出
nextTick(() => {
const el = ctxEl.value
if (!el) return
const pad = 10
ctx.x = Math.max(pad, Math.min(e.clientX, window.innerWidth - el.offsetWidth - pad))
ctx.y = Math.max(pad, Math.min(e.clientY, window.innerHeight - el.offsetHeight - pad))
})
}
function openQuick(kind) {
ctx.open = false
quick.kind = kind
quick.date = ctx.date
quick.title = ''
quick.content = ''
quick.time = '09:00'
quick.type = 'task'
quick.priority = kind === 'reminder' ? 'high' : 'medium'
quick.startAt = ctx.date
quick.dueAt = kind === 'ticket' ? ctx.date : `${ctx.date}T18:00`
quick.projectId = kind === 'ticket' ? (store.projects[0]?.id || 0) : 0
qmd.reset()
quick.open = true
nextTick(() => quickInput.value?.focus())
}
async function createQuick() {
const title = quick.title.trim()
if (!title || quick.busy) return
quick.busy = true
try {
if (quick.kind === 'ticket') {
await call('SaveTicket', { id: 0, title, description: quick.content, type: quick.type, projectId: quick.projectId, startAt: quick.startAt, dueAt: quick.dueAt, priority: quick.priority, status: 'open' })
} else if (quick.kind === 'reminder') {
// reminder = 到点提醒的高优先级待办,复用现有提醒循环与云同步
await call('SaveTodo', { id: 0, title, content: '', projectId: 0, dueAt: `${quick.date}T${quick.time}`, priority: 'high', status: 'open' })
} else {
await call('SaveTodo', { id: 0, title, content: quick.content, projectId: quick.projectId, dueAt: quick.dueAt, priority: quick.priority, status: 'open' })
}
quick.open = false
store.showToast({ type: 'success', key: 'quickCreated' })
await load()
notifyTasksChanged()
} catch (e) {
store.showToast({ type: 'error', text: String(e) })
} finally {
quick.busy = false
}
}
function closeCtx() { ctx.open = false }
function onGlobalKey(e) {
if (e.key === 'Escape') { ctx.open = false; quick.open = false; festAdmin.value = false }
}
// ---- 待办/工单详情:复用 TaskDetailModal日历内直接查看与处理不跳转页面 ----
const detail = reactive({ open: false, kind: 'todo', item: null })
function openDetail(kind, x) {
detail.kind = kind
detail.item = x
detail.open = true
}
function move(delta) {
cursor.value = new Date(cursor.value.getFullYear(), cursor.value.getMonth() + delta, 1)
}
async function load() {
;[todos.value, tickets.value] = await Promise.all([call('ListTodos', 'all', 0), call('ListTickets', 'all', 0)])
}
const offTasks = onTasksChanged(load)
onMounted(async () => {
addEventListener('click', closeCtx)
addEventListener('keydown', onGlobalKey)
addEventListener('mousedown', onDocDown)
if (!store.projects.length) store.refresh().catch(() => {})
await load()
selected.value = todayStr
})
onUnmounted(() => {
removeEventListener('click', closeCtx)
removeEventListener('keydown', onGlobalKey)
removeEventListener('mousedown', onDocDown)
offTasks()
})
</script>
<template>
<div class="page calendar-page">
<header class="page-head sticky-head calendar-head">
<div><h1>{{ t('calendar') }}</h1><p>{{ t('calendarSubtitle') }}</p></div>
<div class="actions calendar-nav">
<div ref="calSwitchEl" class="cal-switch">
<button type="button" class="cal-nav-btn" @click="move(-1)"><ChevronLeft /></button>
<div class="cal-ym">
<button type="button" :class="{ on: ymPick === 'year' }" @click="openYm('year')">{{ dispYear }}</button>
<button type="button" :class="{ on: ymPick === 'month' }" @click="openYm('month')">{{ dispMonth }}</button>
</div>
<button type="button" class="cal-nav-btn" @click="move(1)"><ChevronRight /></button>
<div v-if="ymPick" class="cal-pop popover-glass">
<header v-if="ymPick === 'year'" class="cal-pop-head">
<button type="button" @click="yearBase -= 12"><ChevronLeft /></button>
<b>{{ yearWindow[0] }} - {{ yearWindow[11] }}</b>
<button type="button" @click="yearBase += 12"><ChevronRight /></button>
</header>
<div class="cal-pop-grid">
<template v-if="ymPick === 'year'">
<button v-for="y in yearWindow" :key="y" type="button" :class="{ active: y === cursor.getFullYear() }" @click="setYear(y)">{{ y }}</button>
</template>
<template v-else>
<button v-for="(m, i) in monthNames" :key="m" type="button" :class="{ active: i === cursor.getMonth() }" @click="setMonth(i)">{{ m }}</button>
</template>
</div>
</div>
</div>
<div class="cal-jump" :title="t('calJumpTitle')">
<ArrowRight />
<input v-model="jumpVal" :placeholder="t('calJumpPh')" @keydown.enter="jumpTo" />
</div>
<button class="btn secondary" @click="cursor = new Date(); selected = todayStr">{{ t('today') }}</button>
<button class="btn secondary daily-open-btn" @click="store.dailyCardDate = todayStr"><Sparkles />{{ t('dailyTodayBtn') }}</button>
<button class="btn secondary" @click="aiOpen = true"><Sparkles />{{ t('aiScopeBtn') }}</button>
<button v-if="isAdmin && selectedCell && selectedCell.lunar && selectedCell.lunar.tags.length" class="btn secondary fest-admin-btn" :title="t('festImgTitle')" @click="festAdmin = true"><ImagePlus /></button>
</div>
</header>
<div class="calendar-layout">
<section class="panel calendar-grid-panel">
<div class="calendar-weekdays"><span v-for="d in [t('weekdays.sun'), t('weekdays.mon'), t('weekdays.tue'), t('weekdays.wed'), t('weekdays.thu'), t('weekdays.fri'), t('weekdays.sat')]" :key="d">{{ d }}</span></div>
<div class="calendar-grid">
<button v-for="c in grid" :key="c.date" class="calendar-cell" :class="[{ out: !c.inMonth, today: c.today, selected: c.date === selected }, cellArt(c)?.cls]"
:style="cellArt(c)?.style"
@click="onCellClick(c)" @contextmenu.prevent="onCellCtx($event, c)">
<span class="calendar-head-row">
<span class="calendar-day">{{ c.day }}</span>
<small v-if="c.lunar" class="calendar-lunar">{{ c.lunar.label }}</small>
</span>
<small v-if="c.lunar && c.lunar.festLabel" class="calendar-fest" :class="{ jieqi: c.lunar.jieqi }">{{ c.lunar.festLabel }}</small>
<div class="calendar-marks">
<i v-for="x in c.todos.slice(0, 3)" :key="'t' + x.id" class="mark-todo" :class="x.priority" :title="x.title" />
<em v-for="x in c.tickets.slice(0, 2)" :key="'k' + x.id" class="mark-ticket" :class="[x.status, { start: x.isStart, end: x.isEnd }]" :title="x.title">{{ x.isStart ? x.title : '' }}</em>
<small v-if="c.todos.length + c.tickets.length > 5">+{{ c.todos.length + c.tickets.length - 5 }}</small>
</div>
</button>
</div>
</section>
<section class="panel calendar-detail-panel">
<h2><CalendarDays class="panel-icon" />{{ selected || t('selectDate') }}</h2>
<template v-if="selectedCell">
<div v-if="selectedCell.lunar" class="calendar-lunar-card" :class="{ festive: !!selectedCell.lunar.egg }">
<i v-if="selectedCell.lunar.egg" class="calendar-egg-vec" aria-hidden="true" :style="{ backgroundImage: eggBg(selectedCell.lunar.egg) }" />
<b>{{ selectedCell.lunar.full }}</b>
<div v-if="selectedCell.lunar.tags.length" class="calendar-fest-tags">
<span v-for="f in selectedCell.lunar.tags" :key="f">{{ f }}</span>
</div>
</div>
<button class="daily-entry" :disabled="selected > todayStr" :title="selected > todayStr ? t('dailyFuture') : ''" @click="store.dailyCardDate = selected">
<Sparkles />
<span>{{ selected === todayStr ? t('dailyTodayBtn') : t('dailyViewBtn') }}</span>
<ArrowRight class="daily-entry-arrow" />
</button>
<div class="calendar-group" v-if="selectedCell.todos.length">
<h3><ListTodo />{{ t('todos') }}</h3>
<button v-for="x in selectedCell.todos" :key="x.id" class="calendar-item" :class="x.priority" @click="openDetail('todo', x)">
<b>{{ x.title }}</b>
<span class="todo-priority" :class="x.priority">{{ t('priority.' + x.priority) }}</span>
<ArrowRight />
</button>
</div>
<div class="calendar-group" v-if="selectedCell.tickets.length">
<h3><TicketCheck />{{ t('tickets') }}</h3>
<button v-for="x in selectedCell.tickets" :key="x.id" class="calendar-item" :class="x.priority" @click="openDetail('ticket', x)">
<b>{{ x.title }}</b>
<span class="ticket-status" :class="x.status">{{ t('ticketStatus.' + x.status) }}</span>
<ArrowRight />
</button>
</div>
<div v-if="!selectedCell.todos.length && !selectedCell.tickets.length" class="empty">{{ t('noSchedule') }}</div>
</template>
</section>
</div>
<!-- 右键菜单 / 快速创建Teleport body避免 .page 的入场动画成为 fixed 定位包含块导致偏移 -->
<Teleport to="body">
<div v-if="ctx.open" ref="ctxEl" class="calendar-ctx popover-glass" :style="{ left: ctx.x + 'px', top: ctx.y + 'px' }" @click.stop>
<small>{{ ctx.date }}</small>
<button @click="openQuick('todo')"><ListTodo />{{ t('ctxNewTodo') }}</button>
<button :disabled="!store.projects.length" :title="store.projects.length ? '' : t('ctxNeedProject')" @click="openQuick('ticket')"><TicketCheck />{{ t('ctxNewTicket') }}</button>
<button @click="openQuick('reminder')"><AlarmClock />{{ t('ctxNewReminder') }}</button>
</div>
<div v-if="festAdmin && selectedCell && selectedCell.lunar" class="overlay" @click.self="!festBusy && (festAdmin = false)">
<section class="modal fest-admin-modal" @click.stop>
<header>
<h2><ImagePlus class="panel-icon" />{{ t('festImgTitle') }}<span class="quick-date">{{ selected }}</span></h2>
<button type="button" :disabled="festBusy" @click="festAdmin = false"><X /></button>
</header>
<div class="fest-admin-body">
<div v-for="tg in selectedCell.lunar.tags" :key="tg" class="fest-style-group">
<div class="fest-style-head">
<span class="fest-style-name">{{ tg }}</span>
<span v-if="hasFestImg(tg)" class="fest-style-ops">
<button class="fest-img-btn" :disabled="festBusy" @click="pickFestImg(tg)">{{ t('festImgReplace') }}</button>
<button class="fest-img-btn danger" :disabled="festBusy" @click="removeFestImg(tg)"><X />{{ t('festImgRemove') }}</button>
</span>
</div>
<div class="fest-style-cards">
<button class="fest-card" :class="{ active: festModeOf(tg) === 'art' }" :disabled="festBusy" @click="chooseFestArt(tg)">
<span class="fest-card-preview" :style="selectedCell.lunar.egg ? { backgroundImage: eggArt(selectedCell.lunar.egg) } : null"></span>
<span class="fest-card-label"><Sparkles />{{ t('festModeArtCard') }}</span>
<span v-if="festModeOf(tg) === 'art'" class="fest-card-check"><Check /></span>
</button>
<button class="fest-card" :class="{ active: festModeOf(tg) === 'photo' }" :disabled="festBusy" @click="chooseFestPhoto(tg)">
<span class="fest-card-preview photo" :style="festPhotoStyle(tg)">
<span v-if="!hasFestImg(tg)" class="fest-card-upload"><ImagePlus />{{ t('festImgSet') }}</span>
</span>
<span class="fest-card-label"><ImageIcon />{{ t('festModePhotoCard') }}</span>
<span v-if="festModeOf(tg) === 'photo'" class="fest-card-check"><Check /></span>
</button>
</div>
</div>
</div>
</section>
</div>
<TaskDetailModal v-if="detail.open && detail.item" :kind="detail.kind" :item="detail.item" @close="detail.open = false" @changed="load" />
<div v-if="quick.open" class="overlay" @click.self="!quick.busy && (quick.open = false)">
<!-- 提醒仅标题+时间两个字段保留紧凑框待办/工单与对应页面的分栏模态框完全对齐 -->
<section v-if="quick.kind === 'reminder'" class="modal quick-modal" @click.stop>
<button class="login-close" :aria-label="t('close')" @click="quick.open = false"><X /></button>
<h2>
<AlarmClock class="panel-icon" />
{{ t('ctxNewReminder') }}
<small class="quick-date">{{ quick.date }}</small>
</h2>
<div class="quick-form">
<input ref="quickInput" v-model="quick.title" :placeholder="t('quickTitlePh')" @keyup.enter="createQuick" />
<input v-model="quick.time" type="time" />
<button class="btn primary" :disabled="!quick.title.trim() || quick.busy" @click="createQuick"><Plus />{{ t('quickCreate') }}</button>
</div>
</section>
<form v-else class="modal modal-split" @click.stop @submit.prevent="createQuick">
<header>
<h2>
{{ t(quick.kind === 'ticket' ? 'ctxNewTicket' : 'ctxNewTodo') }}
<small class="quick-date">{{ quick.date }}</small>
</h2>
<button type="button" :disabled="quick.busy" @click="quick.open = false"><X /></button>
</header>
<div class="split-body">
<div class="split-fields">
<label>{{ t(quick.kind === 'ticket' ? 'ticketTitle' : 'todoTitle') }}<input ref="quickInput" v-model="quick.title" :disabled="quick.busy" required /></label>
<label v-if="quick.kind === 'ticket'">{{ t('relatedProject') }} *<select v-model.number="quick.projectId" :disabled="quick.busy" required><option :value="0" disabled>{{ t('selectProject') }}</option><option v-for="p in store.projects" :key="p.id" :value="p.id">{{ p.name }}</option></select></label>
<label v-else>{{ t('relatedProject') }}<select v-model.number="quick.projectId" :disabled="quick.busy"><option :value="0">{{ t('noProject') }}</option><option v-for="p in store.projects" :key="p.id" :value="p.id">{{ p.name }}</option></select></label>
<label v-if="quick.kind === 'ticket'">{{ t('ticketTypeLabel') }}<select v-model="quick.type" :disabled="quick.busy"><option v-for="k in ['feature', 'bug', 'task', 'improvement']" :key="k" :value="k">{{ t('ticketType.' + k) }}</option></select></label>
<label v-if="quick.kind === 'ticket'">{{ t('startDate') }}<DatePicker v-model="quick.startAt" :disabled="quick.busy" :clearable="false" /></label>
<label>{{ t('dueDate') }}
<DatePicker v-if="quick.kind === 'ticket'" v-model="quick.dueAt" :disabled="quick.busy" :clearable="false" />
<input v-else v-model="quick.dueAt" type="datetime-local" :disabled="quick.busy" />
<DueQuickPick v-model="quick.dueAt" :with-time="quick.kind !== 'ticket'" :disabled="quick.busy" />
</label>
<label>{{ t('priorityLabel') }}<select v-model="quick.priority" :disabled="quick.busy"><option value="low">{{ t('priority.low') }}</option><option value="medium">{{ t('priority.medium') }}</option><option value="high">{{ t('priority.high') }}</option></select></label>
</div>
<div class="split-editor">
<div class="md-toolbar">
<span class="md-field-label">{{ t(quick.kind === 'ticket' ? 'ticketDesc' : 'todoContent') }}</span>
<div class="tabs compact">
<button type="button" :class="{ active: !qmd.preview.value }" @click="qmd.preview.value = false">{{ t('mdEdit') }}</button>
<button type="button" :class="{ active: qmd.preview.value }" @click="qmd.preview.value = true">{{ t('mdPreviewTab') }}</button>
</div>
<button type="button" class="btn secondary md-img-btn" :disabled="quick.busy || qmd.uploading.value" @click="qmd.pickImage"><ImagePlus />{{ qmd.uploading.value ? t('mdInserting') : t('insertImage') }}</button>
</div>
<div class="md-editor">
<textarea v-show="!qmd.preview.value" :ref="qmd.inputEl" v-model="quick.content" :disabled="quick.busy" :placeholder="t('mdPlaceholder')" @paste="qmd.onPaste" />
<MarkdownView v-if="qmd.preview.value" class="md-preview-box" :source="quick.content || t('mdEmpty')" />
</div>
</div>
</div>
<footer>
<button type="button" class="btn secondary" :disabled="quick.busy" @click="quick.open = false">{{ t('cancel') }}</button>
<button class="btn primary" :disabled="!quick.title.trim() || quick.busy"><Plus />{{ quick.busy ? t('saving') : t('quickCreate') }}</button>
</footer>
</form>
</div>
</Teleport>
<AIScopeDrawer v-if="aiOpen" kind="calendar" :title="t('calendar')" @close="aiOpen = false" />
</div>
</template>

View File

@@ -0,0 +1,237 @@
<script setup>
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { Rocket, Plus, RefreshCw, Play, Square, Pencil, Trash2, Pin, FolderOpen, X, Hexagon, Zap, FileCode2, Coffee, Database, Globe, Boxes, Box, Cpu, MemoryStick, HardDrive, Sparkles } from 'lucide-vue-next'
import { call, on } from '../api'
import AIScopeDrawer from '../components/AIScopeDrawer.vue'
// 启动台:扫描本机监听端口的服务 + 管理保存的应用(启动/停止/资源占用)。
const { t } = useI18n()
const aiOpen = ref(false)
const entries = ref([])
const loading = ref(false)
const showSys = ref(localStorage.getItem('cc-lp-sys') === '1')
const editing = ref(null) // 编辑/新建表单数据
const editErr = ref('')
const suggest = ref({ start: [], stop: [] })
const busy = ref({}) // id/pid -> true 启停按钮防抖
let timer = 0
let offChanged = null
const KINDS = ['node', 'go', 'python', 'java', 'php', 'dotnet', 'mysql', 'redis', 'nginx', 'web', 'other']
const KIND_UI = {
node: { icon: Hexagon, color: '#8cc84b' },
go: { icon: Zap, color: '#00add8' },
python: { icon: FileCode2, color: '#ffd343' },
java: { icon: Coffee, color: '#f89820' },
php: { icon: FileCode2, color: '#a78bfa' },
dotnet: { icon: Boxes, color: '#8b5cf6' },
mysql: { icon: Database, color: '#4f9df5' },
redis: { icon: Database, color: '#f16a5b' },
nginx: { icon: Globe, color: '#26c795' },
web: { icon: Globe, color: '#4f9df5' },
other: { icon: Box, color: 'var(--muted)' }
}
const kindUI = k => KIND_UI[k] || KIND_UI.other
// Windows 关键系统进程:默认隐藏,避免误停
const SYS_NAMES = ['system', 'svchost.exe', 'lsass.exe', 'wininit.exe', 'services.exe', 'csrss.exe', 'winlogon.exe', 'spoolsv.exe', 'searchindexer.exe', 'memcompression', 'registry']
const isSys = x => !x.id && (SYS_NAMES.includes((x.name || '').toLowerCase()) || /^PID \d+$/.test(x.name || ''))
const myApps = computed(() => entries.value.filter(x => x.id > 0))
const scanned = computed(() => entries.value.filter(x => !x.id && (showSys.value || !isSys(x))))
const hiddenCount = computed(() => entries.value.filter(x => !x.id && isSys(x)).length)
async function load() {
loading.value = true
try { entries.value = await call('ListLaunchEntries') } catch { /* 后端未就绪 */ }
loading.value = false
}
function toggleSys() {
showSys.value = !showSys.value
localStorage.setItem('cc-lp-sys', showSys.value ? '1' : '0')
}
// ---- 添加 / 编辑 ----
async function openForm(x) {
editErr.value = ''
editing.value = x
? { id: x.id || 0, name: x.name || '', kind: x.kind || 'other', port: x.port || x.ports?.[0] || 0, dir: x.dir || '', startCmd: x.startCmd || '', stopCmd: x.stopCmd || '' }
: { id: 0, name: '', kind: 'other', port: 0, dir: '', startCmd: '', stopCmd: '' }
await loadSuggest()
}
async function loadSuggest() {
try { suggest.value = await call('LaunchCmdSuggest', editing.value.kind) } catch { suggest.value = { start: [], stop: [] } }
}
async function pickDir() {
try {
const d = await call('SelectDirectory')
if (d) editing.value.dir = d
} catch { /* 用户取消 */ }
}
async function saveForm() {
editErr.value = ''
try {
await call('SaveLaunchApp', { ...editing.value, port: Number(editing.value.port) || 0 })
editing.value = null
await load()
} catch (e) {
editErr.value = String(e?.message || e)
}
}
async function removeApp(x) {
if (!confirm(t('lpDelConfirm', { name: x.name }))) return
await call('DeleteLaunchApp', x.id)
await load()
}
// ---- 启动 / 停止 ----
async function start(x) {
if (!x.startCmd) {
await openForm(x)
editErr.value = t('lpNeedCmd')
return
}
busy.value[x.id] = true
try { await call('StartLaunchApp', x.id) } catch (e) {
if (String(e?.message || e).includes('LAUNCH_CMD_REQUIRED')) { await openForm(x); editErr.value = t('lpNeedCmd') }
}
busy.value[x.id] = false
setTimeout(load, 800) // 给进程一点起监听的时间
}
async function stop(x) {
if (!confirm(t('lpStopConfirm', { name: x.name }))) return
const key = x.id || x.pid
busy.value[key] = true
try { await call('StopLaunchApp', x.id || 0, x.pid || 0) } catch { /* 已记录日志 */ }
busy.value[key] = false
setTimeout(load, 500)
}
const fmtCPU = v => (v >= 10 ? v.toFixed(0) : v.toFixed(1)) + '%'
const fmtMem = v => v >= 1024 ? (v / 1024).toFixed(1) + ' GB' : v.toFixed(0) + ' MB'
const fmtIO = v => v >= 1024 ? (v / 1024).toFixed(1) + ' MB/s' : v.toFixed(0) + ' KB/s'
onMounted(() => {
load()
timer = setInterval(load, 5000)
offChanged = on('launchpad:changed', load)
})
onUnmounted(() => { clearInterval(timer); offChanged?.() })
</script>
<template>
<div class="page launchpad-page">
<header class="page-head sticky-head">
<div><h1>{{ t('launchpad') }}</h1><p>{{ t('launchpadSubtitle') }}</p></div>
<div class="lp-tools">
<label class="lp-sys-toggle"><input type="checkbox" :checked="showSys" @change="toggleSys" />{{ t('lpShowSys') }}<em v-if="hiddenCount && !showSys">{{ hiddenCount }}</em></label>
<button class="btn secondary" @click="aiOpen = true"><Sparkles />{{ t('aiScopeBtn') }}</button>
<button class="btn secondary" :disabled="loading" @click="load"><RefreshCw :class="{ spinning: loading }" />{{ t('lpRefresh') }}</button>
<button class="btn" @click="openForm(null)"><Plus />{{ t('lpAddApp') }}</button>
</div>
</header>
<section class="lp-section">
<h2 class="lp-title"><Pin />{{ t('lpMyApps') }}<em>{{ myApps.length }}</em></h2>
<div v-if="!myApps.length" class="lp-empty">{{ t('lpEmptyApps') }}</div>
<div v-else class="lp-grid">
<article v-for="x in myApps" :key="'a' + x.id" class="card lp-card" :class="{ running: x.running }">
<header>
<span class="lp-icon" :style="{ color: kindUI(x.kind).color, background: `color-mix(in srgb, ${kindUI(x.kind).color} 14%, transparent)` }"><component :is="kindUI(x.kind).icon" /></span>
<div class="lp-name"><b :title="x.exe || x.name">{{ x.name }}</b><small>{{ x.kind }}</small></div>
<i class="lp-dot" :class="{ on: x.running }" :title="x.running ? t('lpRunning') : t('lpStopped')" />
</header>
<div class="lp-ports">
<span v-for="p in (x.ports?.length ? x.ports : (x.port ? [x.port] : [])).slice(0, 4)" :key="p" class="lp-port">:{{ p }}</span>
<span v-if="(x.ports?.length || 0) > 4" class="lp-port more">+{{ x.ports.length - 4 }}</span>
<small v-if="x.pid" class="lp-pid">PID {{ x.pid }}</small>
</div>
<div v-if="x.running" class="lp-res">
<span :title="'CPU'"><Cpu />{{ fmtCPU(x.cpu) }}</span>
<span :title="t('lpMem')"><MemoryStick />{{ fmtMem(x.memMB) }}</span>
<span :title="'IO'"><HardDrive />{{ fmtIO(x.ioKBs) }}</span>
</div>
<p v-if="x.dir || x.cmdline" class="lp-meta" :title="x.cmdline || x.dir">{{ x.dir || x.cmdline }}</p>
<footer>
<button v-if="!x.running" class="lp-act go" :disabled="busy[x.id]" @click="start(x)"><Play />{{ t('lpStart') }}</button>
<button v-else class="lp-act halt" :disabled="busy[x.id]" @click="stop(x)"><Square />{{ t('lpStop') }}</button>
<span class="lp-gap" />
<button class="lp-act" :title="t('edit')" @click="openForm(x)"><Pencil /></button>
<button class="lp-act danger" :title="t('delete')" @click="removeApp(x)"><Trash2 /></button>
</footer>
</article>
</div>
</section>
<section class="lp-section">
<h2 class="lp-title"><Rocket />{{ t('lpScanned') }}<em>{{ scanned.length }}</em></h2>
<div v-if="!scanned.length" class="lp-empty">{{ t('lpEmptyScan') }}</div>
<div v-else class="lp-grid">
<article v-for="x in scanned" :key="'p' + x.pid" class="card lp-card running">
<header>
<span class="lp-icon" :style="{ color: kindUI(x.kind).color, background: `color-mix(in srgb, ${kindUI(x.kind).color} 14%, transparent)` }"><component :is="kindUI(x.kind).icon" /></span>
<div class="lp-name"><b :title="x.exe || x.name">{{ x.name }}</b><small>{{ x.kind }} · PID {{ x.pid }}</small></div>
<i class="lp-dot on" :title="t('lpRunning')" />
</header>
<div class="lp-ports">
<span v-for="p in x.ports.slice(0, 4)" :key="p" class="lp-port">:{{ p }}</span>
<span v-if="x.ports.length > 4" class="lp-port more">+{{ x.ports.length - 4 }}</span>
</div>
<div class="lp-res">
<span :title="'CPU'"><Cpu />{{ fmtCPU(x.cpu) }}</span>
<span :title="t('lpMem')"><MemoryStick />{{ fmtMem(x.memMB) }}</span>
<span :title="'IO'"><HardDrive />{{ fmtIO(x.ioKBs) }}</span>
</div>
<p v-if="x.cmdline || x.exe" class="lp-meta" :title="x.cmdline || x.exe">{{ x.cmdline || x.exe }}</p>
<footer>
<button class="lp-act" @click="openForm(x)"><Pin />{{ t('lpPin') }}</button>
<span class="lp-gap" />
<button class="lp-act halt" :disabled="busy[x.pid]" @click="stop(x)"><Square />{{ t('lpStop') }}</button>
</footer>
</article>
</div>
</section>
<Teleport to="body">
<div v-if="editing" class="overlay" @click.self="editing = null">
<section class="modal lp-modal">
<header class="lp-modal-head">
<h2><Rocket />{{ editing.id ? t('lpEditApp') : t('lpAddApp') }}</h2>
<button type="button" class="nm-close" :title="t('close')" @click="editing = null"><X /></button>
</header>
<div class="lp-form">
<label class="lp-field"><span>{{ t('lpName') }}</span><input v-model="editing.name" :placeholder="t('lpName')" /></label>
<div class="lp-row">
<label class="lp-field"><span>{{ t('lpKind') }}</span>
<select v-model="editing.kind" @change="loadSuggest">
<option v-for="k in KINDS" :key="k" :value="k">{{ k }}</option>
</select>
</label>
<label class="lp-field"><span>{{ t('lpPort') }}</span><input v-model="editing.port" type="number" min="0" max="65535" /></label>
</div>
<label class="lp-field"><span>{{ t('lpDir') }}</span>
<span class="lp-dir-row"><input v-model="editing.dir" :placeholder="t('lpDir')" /><button type="button" class="btn secondary" @click="pickDir"><FolderOpen /></button></span>
</label>
<label class="lp-field"><span>{{ t('lpStartCmd') }}</span><input v-model="editing.startCmd" placeholder="npm run dev" /></label>
<div v-if="suggest.start?.length" class="lp-suggest">
<small>{{ t('lpSuggest') }}</small>
<button v-for="c in suggest.start" :key="c" type="button" @click="editing.startCmd = c">{{ c }}</button>
</div>
<label class="lp-field"><span>{{ t('lpStopCmd') }}</span><input v-model="editing.stopCmd" :placeholder="t('lpStopBlank')" /></label>
<div v-if="suggest.stop?.length" class="lp-suggest">
<small>{{ t('lpSuggest') }}</small>
<button v-for="c in suggest.stop" :key="c" type="button" @click="editing.stopCmd = c">{{ c }}</button>
</div>
<p v-if="editErr" class="lp-err">{{ editErr }}</p>
</div>
<footer class="lp-modal-foot">
<button class="btn secondary" @click="editing = null">{{ t('cancel') }}</button>
<button class="btn" @click="saveForm">{{ t('save') }}</button>
</footer>
</section>
</div>
</Teleport>
<AIScopeDrawer v-if="aiOpen" kind="launchpad" :title="t('launchpad')" @close="aiOpen = false" />
</div>
</template>

View File

@@ -0,0 +1,82 @@
<script setup>
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { Bell, CheckCheck, Trash2, ListTodo, TicketCheck, BarChart3, RefreshCw, Info } from 'lucide-vue-next'
import { call, on } from '../api'
import { useAppStore } from '../store'
// 消息中心页:全部消息 + 分类/未读筛选,与顶部铃铛下拉共用视觉语言。
const router = useRouter()
const store = useAppStore()
const { t } = useI18n()
const items = ref([])
const kind = ref('all')
const unreadOnly = ref(false)
const kindIcon = { todo_due: ListTodo, ticket_due: TicketCheck, analysis: BarChart3, sync: RefreshCw }
const KINDS = ['all', 'todo_due', 'ticket_due', 'analysis', 'sync']
let offNew = null
async function load() {
try { items.value = await call('ListMessages', 500) } catch { /* 后端未就绪时静默 */ }
}
const countOf = k => k === 'all' ? items.value.length : items.value.filter(m => m.kind === k).length
const shown = computed(() => items.value.filter(m =>
(kind.value === 'all' || m.kind === kind.value) && (!unreadOnly.value || !m.read)))
async function openItem(m) {
if (!m.read) {
try { await call('MarkMessageRead', m.id) } catch { return }
m.read = true
store.refreshUnread()
}
if (m.sourceType === 'todo') router.push('/todos')
else if (m.sourceType === 'ticket') router.push('/tickets')
else if (m.sourceType === 'project' && m.sourceId) router.push(`/project/${m.sourceId}`)
}
async function markAll() {
await call('MarkAllMessagesRead')
items.value.forEach(m => { m.read = true })
store.refreshUnread()
}
async function clearAll() {
if (!confirm(t('clearMessages') + '?')) return
await call('ClearMessages')
items.value = []
store.refreshUnread()
}
onMounted(() => { load(); offNew = on('message:new', load) })
onUnmounted(() => offNew?.())
</script>
<template>
<div class="page msg-page">
<header class="page-head sticky-head">
<div><h1>{{ t('messages') }}</h1><p>{{ t('messagesSubtitle') }}</p></div>
<div class="actions">
<label class="msg-unread-toggle"><input v-model="unreadOnly" type="checkbox" />{{ t('msgUnreadOnly') }}</label>
<button class="btn secondary" @click="markAll"><CheckCheck />{{ t('markAllRead') }}</button>
<button class="btn secondary" @click="clearAll"><Trash2 />{{ t('clearMessages') }}</button>
</div>
</header>
<div class="msg-chips">
<button v-for="k in KINDS" :key="k" type="button" class="msg-chip" :class="{ active: kind === k }" @click="kind = k">
{{ k === 'all' ? t('msgKindAll') : t('msgKind.' + k) }}<em>{{ countOf(k) }}</em>
</button>
</div>
<section class="panel msg-panel">
<button v-for="m in shown" :key="m.id" class="bell-item msg-row" :class="{ unread: !m.read }" @click="openItem(m)">
<span class="bell-icon" :class="m.kind"><component :is="kindIcon[m.kind] || Info" /></span>
<div>
<b>{{ m.title }}</b>
<p v-if="m.body">{{ m.body }}</p>
<time>{{ m.createdAt?.replace('T', ' ').slice(0, 19) }}</time>
</div>
<i v-if="!m.read" class="bell-dot" />
</button>
<div v-if="!shown.length" class="bell-empty msg-empty"><Bell />{{ t('noMessages') }}</div>
</section>
</div>
</template>

View File

@@ -0,0 +1,66 @@
<script setup>
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { StickyNote, Plus, Search, Trash2, Sparkles } from 'lucide-vue-next'
import { call } from '../api'
import NoteModal from '../components/NoteModal.vue'
import AIScopeDrawer from '../components/AIScopeDrawer.vue'
// 笔记页:全部笔记卡片网格,点卡片进入编辑模态框。
const { t } = useI18n()
const notes = ref([])
const query = ref('')
const editing = ref(null) // null=关闭;{} = 新建;{id,...} = 编辑
const aiOpen = ref(false)
async function load() {
try { notes.value = await call('ListNotes', 500) } catch { /* 后端未就绪时静默 */ }
}
const shown = computed(() => {
const q = query.value.trim().toLowerCase()
return q ? notes.value.filter(n => (n.content || '').toLowerCase().includes(q)) : notes.value
})
const title = n => {
const line = (n.content || '').split('\n').find(l => l.trim()) || ''
return line.trim().replace(/^#+\s*/, '') || t('noteUntitled')
}
const preview = n => {
const lines = (n.content || '').split('\n')
const idx = lines.findIndex(l => l.trim())
return lines.slice(idx + 1).filter(l => l.trim()).join(' ').slice(0, 160)
}
const fullTime = s => String(s || '').replace('T', ' ').slice(0, 19)
async function removeNote(n) {
if (!confirm(t('noteDeleteConfirm'))) return
try { await call('DeleteNote', n.id) } catch { return }
await load()
}
onMounted(load)
</script>
<template>
<div class="page notes-page">
<header class="page-head sticky-head">
<div><h1>{{ t('notesPage') }}</h1><p>{{ t('notesSubtitle', { n: notes.length }) }}</p></div>
<div class="actions">
<label class="note-search"><Search /><input v-model="query" :placeholder="t('noteSearchPh')" /></label>
<button class="btn secondary" @click="aiOpen = true"><Sparkles />{{ t('aiScopeBtn') }}</button>
<button class="btn" @click="editing = {}"><Plus />{{ t('noteNew') }}</button>
</div>
</header>
<div v-if="!shown.length" class="today-empty"><StickyNote />{{ t('noteEmpty') }}</div>
<div v-else class="notes-grid">
<article v-for="n in shown" :key="n.id" class="card note-card" @click="editing = n">
<b class="note-card-title">{{ title(n) }}</b>
<p v-if="preview(n)" class="note-card-preview">{{ preview(n) }}</p>
<footer>
<time>{{ fullTime(n.updatedAt) }}</time>
<button type="button" class="note-card-del" :title="t('delete')" @click.stop="removeNote(n)"><Trash2 /></button>
</footer>
</article>
</div>
<NoteModal v-if="editing" :note="editing" @close="editing = null" @changed="load" />
<AIScopeDrawer v-if="aiOpen" kind="notes" :title="t('notesPage')" @close="aiOpen = false" />
</div>
</template>

View File

@@ -0,0 +1,515 @@
<script setup>
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { UserRound, ImageUp, KeyRound, CloudUpload, LogIn, LogOut, RefreshCw, Wifi, WifiOff, Info, ShieldCheck, Images, Camera, X, Lock, Folder, CircleCheck, TicketCheck, Star, ListTodo, Settings as SettingsIcon, FileText, CalendarClock, UploadCloud, IdCard, Users, Plus, Check, ArrowRight, Tags, Copy, Trash2 } from 'lucide-vue-next'
import { call, isNative } from '../api'
import { useAppStore } from '../store'
import { teams, teamsErr, teamsLoading, loadTeams as loadTeamsShared, switchTeam as switchTeamShared } from '../team'
const store = useAppStore(), { t } = useI18n(), native = isNative()
const router = useRouter()
const form = reactive({ avatarMode: '', avatarValue: '', imageMode: 'base64' })
const pwdForm = reactive({ old: '', next: '', confirm: '' })
const busy = ref(''), msg = ref('')
const loaded = ref(false)
const avatarOpen = ref(false)
const TABS = ['info', 'teams', 'assets', 'security', 'sync']
const tab = ref(TABS.includes(localStorage.getItem('cc-profile-tab')) ? localStorage.getItem('cc-profile-tab') : 'info')
const doneTodos = ref(0)
const resolvedTickets = ref(0)
const sync = computed(() => store.syncStatus)
// ---- 全局文件存储(管理员在设置页配置;此处只读,决定素材库可用性与提示文案) ----
const fsCfg = reactive({ mode: 'local', baseUrl: '', apiKey: '' })
const serverStorage = computed(() => fsCfg.mode === 'server')
async function loadFileStorage() {
try {
const c = await call('GetFileStorageConfig')
Object.assign(fsCfg, { mode: c.mode || 'local', baseUrl: c.baseUrl || '', apiKey: c.apiKey || '' })
} catch {}
}
// ---- 素材库(服务器存储的图片管理:本人 / 团队管理员 / 超管三档范围) ----
const assets = ref([])
const assetsTotal = ref(0)
const assetsPage = ref(1)
const assetsScope = ref('mine')
const assetsTeamId = ref(0)
const assetsLoading = ref(false)
const assetsErr = ref('')
// 只有我担任 owner/admin 的团队才能看团队素材
const adminTeams = computed(() => teams.value.filter(x => ['owner', 'admin'].includes(x.role)))
async function loadAssets(reset = true) {
if (!serverStorage.value || !sync.value.loggedIn) return
if (reset) assetsPage.value = 1
assetsLoading.value = true; assetsErr.value = ''
try {
const tid = assetsScope.value === 'team' ? Number(assetsTeamId.value) : 0
const r = await call('ListServerFiles', assetsScope.value, tid, assetsPage.value)
assets.value = reset ? (r.items || []) : assets.value.concat(r.items || [])
assetsTotal.value = r.total || 0
} catch (e) {
assetsErr.value = errText(e)
if (reset) { assets.value = []; assetsTotal.value = 0 }
} finally { assetsLoading.value = false }
}
function reloadAssets() { loadAssets(true) }
function moreAssets() { assetsPage.value++; loadAssets(false) }
function onAssetsScope() {
if (assetsScope.value === 'team' && !assetsTeamId.value && adminTeams.value.length) assetsTeamId.value = adminTeams.value[0].id
reloadAssets()
}
async function deleteAsset(f) {
if (!confirm(t('assetsDeleteConfirm'))) return
try {
await call('DeleteServerFile', f.id)
assets.value = assets.value.filter(x => x.id !== f.id)
if (assetsTotal.value > 0) assetsTotal.value--
store.showToast({ type: 'success', key: 'assetsDeletedToast' })
} catch (e) { store.showToast({ type: 'error', text: errText(e) }) }
}
async function copyAsset(f) {
try {
await navigator.clipboard.writeText(f.url)
store.showToast({ type: 'success', key: 'assetsCopiedToast' })
} catch { store.showToast({ type: 'error', key: 'assetsCopyFailed' }) }
}
function fmtSize(n) {
if (n >= 1 << 20) return (n / (1 << 20)).toFixed(1) + ' MB'
if (n >= 1024) return Math.round(n / 1024) + ' KB'
return (n || 0) + ' B'
}
// ---- 个人资料(昵称/头衔/邮箱/简介/技术栈标签) ----
const profile = reactive({ nickname: '', title: '', email: '', bio: '', techTags: [] })
const tagInput = ref('')
const profileMsg = ref('')
async function loadProfile() {
try {
const p = await call('GetMyProfile')
Object.assign(profile, { nickname: p.nickname || '', title: p.title || '', email: p.email || '', bio: p.bio || '', techTags: p.techTags || [] })
} catch {}
}
function addTag() {
const v = tagInput.value.trim()
if (!v) return
if (!profile.techTags.some(x => x.toLowerCase() === v.toLowerCase())) profile.techTags.push(v)
tagInput.value = ''
}
function removeTag(i) { profile.techTags.splice(i, 1) }
async function saveProfile() {
if (busy.value) return
busy.value = 'profile'; profileMsg.value = ''
if (tagInput.value.trim()) addTag()
try {
const p = await call('SaveMyProfile', { ...profile })
Object.assign(profile, { nickname: p.nickname, title: p.title, email: p.email, bio: p.bio, techTags: p.techTags || [] })
store.showToast({ type: 'success', key: 'profileSavedToast' })
} catch (e) { profileMsg.value = errText(e) }
finally { busy.value = '' }
}
// ---- 我的团队(状态共享自 team.js切换会联动窗口标题 ----
const newTeamName = ref('')
async function loadTeams() {
if (!sync.value.loggedIn) return
await loadTeamsShared()
}
async function switchTeam(tm) {
if (tm.current) return
try { await switchTeamShared(tm.id) } catch (e) { store.showToast({ type: 'error', text: errText(e) }) }
}
async function createTeam() {
const name = newTeamName.value.trim()
if (!name || busy.value) return
busy.value = 'team'
try {
await call('TeamCreate', name)
newTeamName.value = ''
await loadTeams()
store.showToast({ type: 'success', key: 'teamCreatedToast' })
} catch (e) { store.showToast({ type: 'error', text: errText(e) }) }
finally { busy.value = '' }
}
function setTab(v) {
tab.value = v
localStorage.setItem('cc-profile-tab', v)
if (v === 'teams') loadTeams()
// 素材库可用性跟随全局配置,进入时先刷新配置再拉列表(管理员可能刚在设置页改过)
if (v === 'assets') { loadTeams(); loadFileStorage().then(() => reloadAssets()) }
}
// 按时段问候,让页面更像“我的空间”而不是后台表单
const greeting = computed(() => {
const h = new Date().getHours()
const key = h < 6 ? 'greetNight' : h < 11 ? 'greetMorning' : h < 14 ? 'greetNoon' : h < 18 ? 'greetAfternoon' : 'greetEvening'
return t(key)
})
// 新密码强度0 无 / 1 弱 / 2 中 / 3 强
const pwdStrength = computed(() => {
const v = pwdForm.next
if (!v) return 0
let s = v.length >= 6 ? 1 : 0
if (v.length >= 10) s++
if (/[A-Za-z]/.test(v) && /[0-9]/.test(v)) s++
if (/[^A-Za-z0-9]/.test(v)) s++
return Math.max(1, Math.min(3, s - (v.length < 6 ? 1 : 0)))
})
const strengthLabel = computed(() => [null, 'pwdWeak', 'pwdMedium', 'pwdStrong'][pwdStrength.value])
const errText = e => {
const code = String(e).split(':')[0].trim()
return t('errors.' + code) !== 'errors.' + code ? t('errors.' + code) : String(e)
}
// 后端存 UTCRFC3339展示时转为本地时区
const localSyncTime = computed(() => {
const s = sync.value.lastSyncAt
if (!s) return ''
const d = new Date(/[zZ]$|[+-]\d\d:?\d\d$/.test(s) ? s : s + 'Z')
if (isNaN(d.getTime())) return s.replace('T', ' ')
const p = n => String(n).padStart(2, '0')
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`
})
const scopeTags = [
{ icon: ListTodo, key: 'todos' },
{ icon: TicketCheck, key: 'tickets' },
{ icon: SettingsIcon, key: 'settings' },
{ icon: Star, key: 'scopeFavs' },
{ icon: Folder, key: 'projects' },
{ icon: FileText, key: 'scopeDocs' }
]
async function load() {
try {
const saved = await call('GetSettings')
form.avatarMode = saved.avatarMode || ''
form.avatarValue = saved.avatarValue || ''
form.imageMode = saved.imageMode || 'base64'
} catch {}
loaded.value = true
store.refreshSyncStatus()
try {
const [ts, ks] = await Promise.all([call('ListTodos', 'all', 0), call('ListTickets', 'all', 0)])
doneTodos.value = ts.filter(x => x.status === 'done').length
resolvedTickets.value = ks.filter(x => ['resolved', 'closed'].includes(x.status)).length
} catch {}
}
async function persist() {
if (!loaded.value) return
await store.saveSettings({ avatarMode: form.avatarMode, avatarValue: form.avatarValue, imageMode: form.imageMode })
}
watch(() => [form.avatarMode, form.avatarValue, form.imageMode], persist)
// 存储走向由后端按管理员全局配置实时决定autoserver 时上传返回 url否则 base64。
async function pickAvatar() {
try {
const r = await call('PickAvatarImage', 'auto')
if (r && r.value) {
form.avatarMode = r.mode || 'base64'
form.avatarValue = r.value
}
} catch (e) { store.showToast({ type: 'error', text: errText(e) }) }
}
function clearAvatar() { form.avatarMode = ''; form.avatarValue = '' }
// 打开弹窗时刷新全局配置,保证提示文案与实际走向一致
watch(avatarOpen, v => { if (v) loadFileStorage() })
async function syncNow() {
if (busy.value) return
busy.value = 'sync'; msg.value = ''
try {
const st = await call('SyncNow')
store.syncStatus = st
store.showToast({ type: 'success', key: 'syncDoneToast', params: { pushed: st.pushed, pulled: st.pulled } })
} catch (e) { msg.value = errText(e); store.showToast({ type: 'error', key: 'syncFailToast' }) }
finally { busy.value = '' }
}
async function syncLogout() {
await call('SyncLogout')
store.showToast({ type: 'success', key: 'logoutToast' })
await store.refreshSyncStatus()
}
async function changePassword() {
if (busy.value) return
if (pwdForm.next.length < 6) { msg.value = t('errors.SYNC_PASSWORD_TOO_SHORT'); return }
if (pwdForm.next !== pwdForm.confirm) { msg.value = t('passwordMismatch'); return }
busy.value = 'password'; msg.value = ''
try {
await call('SyncChangePassword', pwdForm.old, pwdForm.next)
pwdForm.old = ''; pwdForm.next = ''; pwdForm.confirm = ''
store.showToast({ type: 'success', key: 'passwordChangedToast' })
} catch (e) { msg.value = errText(e) }
finally { busy.value = '' }
}
function onKey(e) {
if (e.key === 'Escape') avatarOpen.value = false
}
onMounted(async () => {
load()
loadProfile()
await loadFileStorage()
if (tab.value === 'teams') loadTeams()
if (tab.value === 'assets') { loadTeams(); reloadAssets() }
addEventListener('keydown', onKey)
})
onUnmounted(() => removeEventListener('keydown', onKey))
</script>
<template><div class="page profile-page">
<header class="page-head sticky-head"><div><h1>{{ t('profilePage') }}</h1><p>{{ t('profileSubtitle') }}</p></div></header>
<div v-if="!native" class="preview-notice panel"><Info /><div><b>{{ t('previewModeTitle') }}</b><small>{{ t('previewModeSync') }}</small></div></div>
<section class="profile-hero">
<i class="hero-orb a" aria-hidden="true" /><i class="hero-orb b" aria-hidden="true" /><i class="hero-orb c" aria-hidden="true" />
<i class="ph-grid" aria-hidden="true" />
<div class="profile-hero-main">
<button class="profile-avatar editable" :title="t('avatarEdit')" @click="avatarOpen = true">
<span class="ph-ring" aria-hidden="true" />
<span class="ph-photo">
<img v-if="store.avatarSrc" :src="store.avatarSrc" alt="" />
<b v-else-if="sync.username">{{ sync.username[0].toUpperCase() }}</b>
<UserRound v-else />
</span>
<i v-if="sync.loggedIn" class="profile-dot" :class="sync.lastError ? 'err' : (sync.online ? 'on' : 'off')" />
<span class="avatar-edit-mask"><Camera /></span>
</button>
<div class="profile-id">
<small class="ph-greet">{{ greeting }}</small>
<b class="ph-name">{{ profile.nickname || (sync.loggedIn ? sync.username : t('notLoggedIn')) }}</b>
<small v-if="profile.title" class="ph-title-tag">{{ profile.title }}</small>
<div v-if="sync.loggedIn" class="profile-badges">
<span class="p-badge" :class="sync.online ? 'on' : 'off'"><component :is="sync.online ? Wifi : WifiOff" />{{ sync.online ? t('online') : t('offline') }}</span>
<span v-if="sync.lastSyncAt" class="p-badge dim"><CalendarClock />{{ localSyncTime.slice(5, 16) }}</span>
<span v-else class="p-badge dim">{{ t('neverSynced') }}</span>
<span v-if="sync.pending > 0" class="p-badge warn"><UploadCloud />{{ t('pendingPush', { n: sync.pending }) }}</span>
</div>
<p v-else class="profile-guest-hint">{{ t('profileGuest') }}</p>
</div>
<div class="profile-hero-actions">
<template v-if="sync.loggedIn">
<button class="btn primary" :disabled="!!busy || sync.syncing" @click="syncNow"><RefreshCw :class="{ spin: busy === 'sync' || sync.syncing }" />{{ busy === 'sync' || sync.syncing ? t('syncingBtn') : t('syncNowBtn') }}</button>
<button class="btn secondary ghost-btn" @click="syncLogout"><LogOut />{{ t('logoutBtn') }}</button>
</template>
<button v-else class="btn primary" :disabled="!native" @click="store.loginOpen = true"><LogIn />{{ t('loginOrRegister') }}</button>
</div>
</div>
<p v-if="sync.lastError" class="db-message">{{ errText(sync.lastError) }}</p>
<p v-if="msg" class="db-message">{{ msg }}</p>
<div class="ph-stats">
<div class="ph-stat"><span class="ph-stat-ico folder"><Folder /></span><div><b>{{ store.dashboard.projects }}</b><span>{{ t('projects') }}</span></div></div>
<div class="ph-stat"><span class="ph-stat-ico done"><CircleCheck /></span><div><b>{{ doneTodos }}</b><span>{{ t('phDoneTodos') }}</span></div></div>
<div class="ph-stat"><span class="ph-stat-ico ticket"><TicketCheck /></span><div><b>{{ resolvedTickets }}</b><span>{{ t('phResolvedTickets') }}</span></div></div>
<div class="ph-stat"><span class="ph-stat-ico star"><Star /></span><div><b>{{ store.favorites.length }}</b><span>{{ t('favoriteProjects') }}</span></div></div>
</div>
</section>
<div class="profile-layout">
<nav class="profile-side" role="tablist">
<button role="tab" :aria-selected="tab === 'info'" :class="{ active: tab === 'info' }" @click="setTab('info')"><IdCard />{{ t('profileTabInfo') }}</button>
<button role="tab" :aria-selected="tab === 'teams'" :class="{ active: tab === 'teams' }" @click="setTab('teams')"><Users />{{ t('profileTabTeams') }}</button>
<button role="tab" :aria-selected="tab === 'assets'" :class="{ active: tab === 'assets' }" @click="setTab('assets')"><Images />{{ t('assetsTab') }}</button>
<button role="tab" :aria-selected="tab === 'security'" :class="{ active: tab === 'security' }" @click="setTab('security')"><ShieldCheck />{{ t('profileSecurity') }}</button>
<button role="tab" :aria-selected="tab === 'sync'" :class="{ active: tab === 'sync' }" @click="setTab('sync')"><CloudUpload />{{ t('profileTabSync') }}</button>
</nav>
<div class="profile-main">
<section v-if="tab === 'info'" class="panel profile-card">
<header class="pc-head">
<span class="pc-badge id"><IdCard /></span>
<div><b>{{ t('profileTabInfo') }}</b><small>{{ t('profileInfoHint') }}</small></div>
</header>
<div class="pc-form">
<label class="pc-field">
<span>{{ t('profileNickname') }}</span>
<div class="pc-input"><UserRound /><input v-model="profile.nickname" :placeholder="sync.username || t('profileNicknamePh')" maxlength="32" /></div>
</label>
<label class="pc-field">
<span>{{ t('profileJobTitle') }}</span>
<div class="pc-input"><IdCard /><input v-model="profile.title" :placeholder="t('profileJobTitlePh')" maxlength="48" /></div>
</label>
<label class="pc-field">
<span>{{ t('profileEmail') }}</span>
<div class="pc-input"><Info /><input v-model="profile.email" type="email" placeholder="you@example.com" maxlength="128" /></div>
</label>
<label class="pc-field pc-field-wide">
<span>{{ t('profileBio') }}</span>
<textarea v-model="profile.bio" class="pc-textarea" :placeholder="t('profileBioPh')" maxlength="300" rows="3" />
</label>
<div class="pc-field pc-field-wide">
<span class="pc-tags-label"><Tags />{{ t('profileTags') }}</span>
<div class="tag-editor">
<span v-for="(tg, i) in profile.techTags" :key="tg" class="tech-tag">{{ tg }}<button type="button" :title="t('delete')" @click="removeTag(i)"><X /></button></span>
<input v-model="tagInput" :placeholder="t('profileTagsPh')" maxlength="24" @keydown.enter.prevent="addTag" @keydown.188.prevent="addTag" @blur="addTag" />
</div>
<small class="pc-hint">{{ t('profileTagsHint') }}</small>
</div>
</div>
<p v-if="profileMsg" class="db-message">{{ profileMsg }}</p>
<div class="pc-actions">
<button class="btn primary" :disabled="!!busy" @click="saveProfile"><Check />{{ busy === 'profile' ? t('saving') : t('profileSaveBtn') }}</button>
</div>
</section>
<section v-else-if="tab === 'teams'" class="panel profile-card">
<header class="pc-head">
<span class="pc-badge team"><Users /></span>
<div><b>{{ t('profileTabTeams') }}</b><small>{{ t('profileTeamsHint') }}</small></div>
</header>
<div v-if="!sync.loggedIn" class="pc-empty">
<span class="pc-empty-ico"><Users /></span>
<p>{{ t('teamLoginHint') }}</p>
<button class="btn primary" :disabled="!native" @click="store.loginOpen = true"><LogIn />{{ t('loginOrRegister') }}</button>
</div>
<template v-else>
<p v-if="teamsErr" class="db-message">{{ errText(teamsErr) }}</p>
<p v-if="teamsLoading" class="pc-hint"><RefreshCw class="spin" /> {{ t('loading') }}</p>
<div v-else-if="teams.length" class="team-pick-list">
<button v-for="tm in teams" :key="tm.id" class="team-pick" :class="{ current: tm.current }" @click="switchTeam(tm)">
<span class="team-pick-badge">{{ tm.name[0] }}</span>
<span class="team-pick-main"><b>{{ tm.name }}</b><small>{{ t('teamRole_' + tm.role) }} · {{ t('teamMembersCount', { n: tm.members }) }}</small></span>
<Check v-if="tm.current" class="team-pick-check" />
</button>
</div>
<p v-else-if="!teamsErr" class="pc-hint">{{ t('teamNoneHint') }}</p>
<div class="team-create-row">
<input v-model="newTeamName" :placeholder="t('teamNamePh')" maxlength="64" @keyup.enter="createTeam" />
<button class="btn secondary" :disabled="!newTeamName.trim() || !!busy" @click="createTeam"><Plus />{{ t('teamCreateBtn') }}</button>
</div>
<div class="pc-actions">
<button class="btn secondary" @click="router.push('/team')">{{ t('teamGoHome') }}<ArrowRight /></button>
</div>
</template>
</section>
<section v-else-if="tab === 'assets'" class="panel profile-card">
<header class="pc-head">
<span class="pc-badge img"><Images /></span>
<div><b>{{ t('assetsTab') }}</b><small>{{ t('assetsHint') }}</small></div>
</header>
<div v-if="!sync.loggedIn" class="pc-empty">
<span class="pc-empty-ico"><Images /></span>
<p>{{ t('syncLoginHint') }}</p>
<button class="btn primary" :disabled="!native" @click="store.loginOpen = true"><LogIn />{{ t('loginOrRegister') }}</button>
</div>
<div v-else-if="!serverStorage" class="pc-empty">
<span class="pc-empty-ico"><Images /></span>
<p>{{ t('assetsNeedServer') }}</p>
</div>
<template v-else>
<div class="assets-bar">
<select v-model="assetsScope" class="fs-select slim" @change="onAssetsScope">
<option value="mine">{{ t('assetsScopeMine') }}</option>
<option v-if="adminTeams.length" value="team">{{ t('assetsScopeTeam') }}</option>
<option v-if="sync.userId === 1" value="all">{{ t('assetsScopeAll') }}</option>
</select>
<select v-if="assetsScope === 'team'" v-model.number="assetsTeamId" class="fs-select slim" @change="reloadAssets">
<option v-for="tm in adminTeams" :key="tm.id" :value="tm.id">{{ tm.name }}</option>
</select>
<span class="assets-count">{{ t('assetsCount', { n: assetsTotal }) }}</span>
<button class="btn secondary" :disabled="assetsLoading" @click="reloadAssets"><RefreshCw :class="{ spin: assetsLoading }" />{{ t('assetsRefresh') }}</button>
</div>
<p v-if="assetsErr" class="db-message">{{ assetsErr }}</p>
<div v-if="assets.length" class="assets-grid">
<figure v-for="f in assets" :key="f.id" class="asset-card">
<a :href="f.url" target="_blank" rel="noreferrer"><img :src="f.url" loading="lazy" alt="" /></a>
<figcaption>
<b :title="f.original || f.name">{{ f.kind === 'avatar' ? t('assetsKindAvatar') : t('assetsKindContent') }} · {{ fmtSize(f.size) }}</b>
<small v-if="assetsScope !== 'mine'">{{ f.username || ('#' + f.userId) }}</small>
<small>{{ (f.createdAt || '').slice(0, 10) }}</small>
</figcaption>
<span class="asset-ops">
<button type="button" :title="t('assetsCopy')" @click="copyAsset(f)"><Copy /></button>
<button type="button" class="danger" :title="t('delete')" @click="deleteAsset(f)"><Trash2 /></button>
</span>
</figure>
</div>
<p v-else-if="!assetsLoading && !assetsErr" class="pc-hint">{{ t('assetsEmpty') }}</p>
<div v-if="assets.length && assets.length < assetsTotal" class="pc-actions assets-more">
<button class="btn secondary" :disabled="assetsLoading" @click="moreAssets">{{ assetsLoading ? t('loading') : t('assetsLoadMore') }}</button>
</div>
</template>
</section>
<section v-else-if="tab === 'security'" class="panel profile-card">
<header class="pc-head">
<span class="pc-badge shield"><ShieldCheck /></span>
<div><b>{{ t('changePasswordBtn') }}</b><small>{{ t('changePasswordHint') }}</small></div>
</header>
<template v-if="sync.loggedIn">
<div class="pc-form">
<label class="pc-field">
<span>{{ t('oldPasswordLabel') }}</span>
<div class="pc-input"><Lock /><input v-model="pwdForm.old" type="password" autocomplete="current-password" /></div>
</label>
<label class="pc-field">
<span>{{ t('newPasswordLabel') }}</span>
<div class="pc-input"><KeyRound /><input v-model="pwdForm.next" type="password" :placeholder="t('passwordPh')" autocomplete="new-password" /></div>
<div v-if="pwdForm.next" class="pc-strength" :data-level="pwdStrength">
<i /><i /><i />
<em>{{ t(strengthLabel) }}</em>
</div>
</label>
<label class="pc-field">
<span>{{ t('confirmPasswordLabel') }}</span>
<div class="pc-input" :class="{ err: pwdForm.confirm && pwdForm.confirm !== pwdForm.next }"><KeyRound /><input v-model="pwdForm.confirm" type="password" autocomplete="new-password" @keyup.enter="changePassword" /></div>
</label>
</div>
<div class="pc-actions">
<button class="btn primary" :disabled="!native || !!busy || !pwdForm.old || !pwdForm.next" @click="changePassword"><KeyRound />{{ busy === 'password' ? t('changingPassword') : t('changePasswordBtn') }}</button>
</div>
</template>
<div v-else class="pc-empty">
<span class="pc-empty-ico"><UserRound /></span>
<p>{{ t('syncLoginHint') }}</p>
<button class="btn primary" :disabled="!native" @click="store.loginOpen = true"><LogIn />{{ t('loginOrRegister') }}</button>
</div>
</section>
<section v-else class="panel profile-card">
<header class="pc-head">
<span class="pc-badge cloud"><CloudUpload /></span>
<div><b>{{ t('profileTabSync') }}</b><small>{{ t('profileSyncHint') }}</small></div>
</header>
<div class="pc-stats">
<div class="pc-stat"><span class="pc-stat-ico user"><UserRound /></span><div><span>{{ t('accountLabel') }}</span><b>{{ sync.loggedIn ? sync.username : t('notLoggedIn') }}</b></div></div>
<div class="pc-stat"><span class="pc-stat-ico" :class="sync.online ? 'net-on' : 'net-off'"><component :is="sync.online ? Wifi : WifiOff" /></span><div><span>{{ t('connectionState') }}</span><b :class="sync.online ? 'ok' : 'warn'">{{ sync.online ? t('online') : t('offline') }}</b></div></div>
<div class="pc-stat"><span class="pc-stat-ico time"><CalendarClock /></span><div><span>{{ t('lastSyncLabel') }}</span><b>{{ localSyncTime || t('neverSynced') }}</b></div></div>
<div class="pc-stat"><span class="pc-stat-ico push" :class="{ warn: sync.pending > 0 }"><UploadCloud /></span><div><span>{{ t('pendingLabel') }}</span><b :class="{ warn: sync.pending > 0 }">{{ sync.pending || 0 }}</b></div></div>
</div>
<div class="pc-scope">
<small>{{ t('syncScope') }}</small>
<div class="pc-scope-tags">
<span v-for="s in scopeTags" :key="s.key" class="pc-tag"><component :is="s.icon" />{{ t(s.key) }}</span>
</div>
</div>
</section>
</div>
</div>
<Teleport to="body">
<div v-if="avatarOpen" class="overlay" @click.self="avatarOpen = false">
<section class="modal avatar-modal" @click.stop>
<header>
<h2><Images class="panel-icon" />{{ t('avatarModalTitle') }}</h2>
<button type="button" @click="avatarOpen = false"><X /></button>
</header>
<div class="avatar-modal-body">
<div class="avatar-row">
<span class="avatar-preview"><img v-if="store.avatarSrc" :src="store.avatarSrc" alt="" /><UserRound v-else /></span>
<div class="avatar-controls">
<div class="sync-actions">
<button class="btn secondary" :disabled="!native" @click="pickAvatar"><ImageUp />{{ t('avatarPick') }}</button>
<button v-if="form.avatarValue" class="btn secondary" @click="clearAvatar"><X />{{ t('avatarClear') }}</button>
</div>
</div>
</div>
<!-- 存储方式由管理员全局配置强制决定用户不再自行选择 -->
<p class="auto-update-hint">{{ t('storageFollowHint', { mode: serverStorage ? t('fsModeServer') : t('fsModeLocal') }) }}</p>
</div>
</section>
</div>
</Teleport>
</div></template>

View File

@@ -0,0 +1,268 @@
<script setup>
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { Users, UserRound, Plus, Check, Crown, Shield, UserPlus, UserMinus, Pencil, Clock, LogOut, Trash2, RefreshCw, X, WifiOff, LogIn, ListTodo, TicketCheck, ClipboardList } from 'lucide-vue-next'
import { call, isNative } from '../api'
import { useAppStore } from '../store'
import { teams, teamsErr, teamsLoading, currentTeam, isTeamAdmin, loadTeams, switchTeam, teamErrCode } from '../team'
import DatePicker from '../components/DatePicker.vue'
const store = useAppStore(), { t } = useI18n(), native = isNative()
const members = ref([])
const busy = ref('')
const newTeamName = ref('')
const renameOpen = ref(false)
const renameVal = ref('')
const digestVal = ref('21:00')
const invite = reactive({ open: false, username: '', role: 'member', err: '' })
const sync = computed(() => store.syncStatus)
const isOwner = computed(() => currentTeam.value?.role === 'owner')
const errText = e => {
const code = teamErrCode(e)
return t('errors.' + code) !== 'errors.' + code ? t('errors.' + code) : String(e)
}
async function loadMembers() {
if (!currentTeam.value) { members.value = []; return }
try { members.value = (await call('TeamMembers', currentTeam.value.id)) || [] }
catch (e) { store.showToast({ type: 'error', text: errText(e) }); members.value = [] }
}
async function refreshAll() {
await loadTeams()
digestVal.value = currentTeam.value?.digestTime || '21:00'
await loadMembers()
}
async function pick(tm) {
if (!tm.current) await switchTeam(tm.id)
}
// 团队切换(本页或 rail 切换器)后刷新成员与摘要时间。
watch(() => currentTeam.value?.id, () => {
digestVal.value = currentTeam.value?.digestTime || '21:00'
loadMembers()
})
async function createTeam() {
const name = newTeamName.value.trim()
if (!name || busy.value) return
busy.value = 'create'
try {
await call('TeamCreate', name)
newTeamName.value = ''
await refreshAll()
store.showToast({ type: 'success', key: 'teamCreatedToast' })
} catch (e) { store.showToast({ type: 'error', text: errText(e) }) }
finally { busy.value = '' }
}
async function doInvite() {
const name = invite.username.trim()
if (!name || busy.value) return
busy.value = 'invite'; invite.err = ''
try {
await call('TeamInvite', currentTeam.value.id, name, invite.role)
invite.open = false; invite.username = ''
await loadMembers()
store.showToast({ type: 'success', key: 'teamInvitedToast' })
} catch (e) { invite.err = errText(e) }
finally { busy.value = '' }
}
async function setRole(m, role) {
try { await call('TeamSetRole', currentTeam.value.id, m.userId, role); await loadMembers() }
catch (e) { store.showToast({ type: 'error', text: errText(e) }) }
}
async function removeMember(m) {
if (!confirm(t('teamRemoveConfirm', { name: m.nickname || m.username }))) return
try { await call('TeamRemoveMember', currentTeam.value.id, m.userId); await loadMembers() }
catch (e) { store.showToast({ type: 'error', text: errText(e) }) }
}
async function doRename() {
const name = renameVal.value.trim()
if (!name) return
try {
await call('TeamRename', currentTeam.value.id, name)
renameOpen.value = false
await loadTeams()
} catch (e) { store.showToast({ type: 'error', text: errText(e) }) }
}
async function saveDigestTime() {
try {
await call('TeamSetDigestTime', currentTeam.value.id, digestVal.value)
store.showToast({ type: 'success', key: 'savedToast' })
} catch (e) { store.showToast({ type: 'error', text: errText(e) }) }
}
async function leaveTeam() {
if (!confirm(t('teamLeaveConfirm'))) return
try { await call('TeamLeave', currentTeam.value.id); await refreshAll() }
catch (e) { store.showToast({ type: 'error', text: errText(e) }) }
}
async function dissolveTeam() {
if (!confirm(t('teamDissolveConfirm', { name: currentTeam.value.name }))) return
try { await call('TeamDissolve', currentTeam.value.id); await refreshAll() }
catch (e) { store.showToast({ type: 'error', text: errText(e) }) }
}
const roleIcon = r => r === 'owner' ? Crown : r === 'admin' ? Shield : UserRound
// ---- 成员卡片快捷创建:给指定成员直接建团队任务/工单(管理员专用) ----
const quick = reactive({ open: false, member: null, kind: 'todo', title: '', priority: 'medium', dueAt: '', err: '' })
function openQuick(m) {
Object.assign(quick, { open: true, member: m, kind: 'todo', title: '', priority: 'medium', dueAt: '', err: '' })
}
async function saveQuick() {
if (!quick.title.trim() || busy.value) return
busy.value = 'quick'; quick.err = ''
try {
await call('TeamTaskSave', {
id: 0, teamId: currentTeam.value.id, kind: quick.kind, title: quick.title, description: '',
priority: quick.priority, assigneeId: quick.member.userId, startAt: '', dueAt: quick.dueAt,
status: '', creatorId: 0, creator: '', assignee: '', urgedAt: '', history: '', updatedAt: ''
})
quick.open = false
store.showToast({ type: 'success', key: 'teamQuickDoneToast', params: { name: quick.member.nickname || quick.member.username } })
store.refreshBadges()
} catch (e) { quick.err = errText(e) }
finally { busy.value = '' }
}
onMounted(refreshAll)
</script>
<template>
<div class="page team-page">
<header class="page-head sticky-head">
<div><h1>{{ t('teamHome') }}</h1><p>{{ t('teamHomeSubtitle') }}</p></div>
<div v-if="currentTeam" class="actions">
<button class="btn secondary" @click="refreshAll"><RefreshCw />{{ t('refresh') }}</button>
</div>
</header>
<!-- 未登录 / 加载失败 / 无团队 -->
<section v-if="!sync.loggedIn" class="panel team-empty">
<span class="team-empty-ico"><Users /></span>
<p>{{ t('teamLoginHint') }}</p>
<button class="btn primary" :disabled="!native" @click="store.loginOpen = true"><LogIn />{{ t('loginOrRegister') }}</button>
</section>
<section v-else-if="teamsErr" class="panel team-empty">
<span class="team-empty-ico warn"><WifiOff /></span>
<p>{{ errText(teamsErr) }}</p>
<button class="btn secondary" @click="refreshAll"><RefreshCw />{{ t('retry') }}</button>
</section>
<section v-else-if="!teamsLoading && !teams.length" class="panel team-empty">
<span class="team-empty-ico"><Users /></span>
<p>{{ t('teamNoneHint') }}</p>
<div class="team-create-row">
<input v-model="newTeamName" :placeholder="t('teamNamePh')" maxlength="64" @keyup.enter="createTeam" />
<button class="btn primary" :disabled="!newTeamName.trim() || !!busy" @click="createTeam"><Plus />{{ t('teamCreateBtn') }}</button>
</div>
</section>
<template v-else-if="currentTeam">
<!-- 团队卡 + 切换器 -->
<section class="panel team-card">
<div class="team-card-head">
<span class="team-logo">{{ currentTeam.name[0] }}</span>
<div class="team-card-main">
<b>{{ currentTeam.name }}
<button v-if="isOwner" class="icon-btn sm" :title="t('teamRenameBtn')" @click="renameVal = currentTeam.name; renameOpen = true"><Pencil /></button>
</b>
<small>{{ t('teamRole_' + currentTeam.role) }} · {{ t('teamMembersCount', { n: members.length || currentTeam.members }) }}</small>
</div>
<div v-if="teams.length > 1" class="team-switch">
<button v-for="tm in teams" :key="tm.id" class="team-chip" :class="{ current: tm.current }" @click="pick(tm)">
{{ tm.name }}<Check v-if="tm.current" />
</button>
</div>
</div>
<div class="team-card-ops">
<label v-if="isTeamAdmin" class="team-digest-time" :title="t('teamDigestTimeHint')">
<Clock /><span>{{ t('teamDigestTime') }}</span>
<input v-model="digestVal" type="time" @change="saveDigestTime" />
</label>
<span class="spacer" />
<button v-if="isTeamAdmin" class="btn secondary" @click="invite.open = true; invite.err = ''"><UserPlus />{{ t('teamInviteBtn') }}</button>
<button v-if="!isOwner" class="btn secondary danger-ghost" @click="leaveTeam"><LogOut />{{ t('teamLeaveBtn') }}</button>
<button v-if="isOwner" class="btn secondary danger-ghost" @click="dissolveTeam"><Trash2 />{{ t('teamDissolveBtn') }}</button>
</div>
</section>
<!-- 成员卡片 -->
<section class="team-members">
<div v-for="m in members" :key="m.userId" class="panel team-member">
<span class="tm-avatar">
<img v-if="m.avatar" :src="m.avatar" alt="" />
<b v-else>{{ (m.nickname || m.username)[0].toUpperCase() }}</b>
</span>
<div class="tm-main">
<b>{{ m.nickname || m.username }}<small v-if="m.nickname" class="tm-account">@{{ m.username }}</small></b>
<small v-if="m.title" class="tm-title">{{ m.title }}</small>
<div v-if="m.techTags?.length" class="tm-tags"><span v-for="tg in m.techTags.slice(0, 6)" :key="tg" class="tech-tag mini">{{ tg }}</span></div>
</div>
<span class="tm-role" :class="m.role"><component :is="roleIcon(m.role)" />{{ t('teamRole_' + m.role) }}</span>
<button v-if="isTeamAdmin" class="tm-quick-btn" :title="t('teamQuickCreate')" @click="openQuick(m)"><ClipboardList /><Plus class="tm-quick-plus" /></button>
<div v-if="m.role !== 'owner' && m.userId !== sync.userId && (isOwner || (isTeamAdmin && m.role === 'member'))" class="tm-ops">
<button v-if="isOwner && m.role === 'member'" :title="t('teamMakeAdmin')" @click="setRole(m, 'admin')"><Shield /></button>
<button v-if="isOwner && m.role === 'admin'" :title="t('teamMakeMember')" @click="setRole(m, 'member')"><UserRound /></button>
<button class="danger" :title="t('teamRemoveBtn')" @click="removeMember(m)"><UserMinus /></button>
</div>
</div>
</section>
<!-- 再建一个团队 -->
<section class="panel team-create-panel">
<small>{{ t('teamCreateMore') }}</small>
<div class="team-create-row">
<input v-model="newTeamName" :placeholder="t('teamNamePh')" maxlength="64" @keyup.enter="createTeam" />
<button class="btn secondary" :disabled="!newTeamName.trim() || !!busy" @click="createTeam"><Plus />{{ t('teamCreateBtn') }}</button>
</div>
</section>
</template>
<!-- 邀请成员 -->
<Teleport to="body">
<div v-if="invite.open" class="overlay" @click.self="invite.open = false">
<section class="modal team-modal" @click.stop>
<header><h2><UserPlus class="panel-icon" />{{ t('teamInviteBtn') }}</h2><button type="button" @click="invite.open = false"><X /></button></header>
<div class="team-modal-body">
<label>{{ t('teamInviteUser') }}<input v-model="invite.username" :placeholder="t('teamInviteUserPh')" @keyup.enter="doInvite" /></label>
<label>{{ t('teamInviteRole') }}<select v-model="invite.role">
<option value="member">{{ t('teamRole_member') }}</option>
<option value="admin">{{ t('teamRole_admin') }}</option>
</select></label>
<p v-if="invite.err" class="db-message">{{ invite.err }}</p>
<div class="modal-actions"><button class="btn primary" :disabled="!invite.username.trim() || !!busy" @click="doInvite"><UserPlus />{{ t('teamInviteBtn') }}</button></div>
</div>
</section>
</div>
<div v-if="renameOpen" class="overlay" @click.self="renameOpen = false">
<section class="modal team-modal" @click.stop>
<header><h2><Pencil class="panel-icon" />{{ t('teamRenameBtn') }}</h2><button type="button" @click="renameOpen = false"><X /></button></header>
<div class="team-modal-body">
<label>{{ t('teamName') }}<input v-model="renameVal" maxlength="64" @keyup.enter="doRename" /></label>
<div class="modal-actions"><button class="btn primary" :disabled="!renameVal.trim()" @click="doRename"><Check />{{ t('save') }}</button></div>
</div>
</section>
</div>
<!-- 成员快捷创建任务 / 工单 -->
<div v-if="quick.open" class="overlay" @click.self="quick.open = false">
<section class="modal team-modal" @click.stop>
<header>
<h2><ClipboardList class="panel-icon" />{{ t('teamQuickFor', { name: quick.member.nickname || quick.member.username }) }}</h2>
<button type="button" @click="quick.open = false"><X /></button>
</header>
<div class="team-modal-body">
<div class="quick-kind" role="radiogroup">
<button type="button" role="radio" :aria-checked="quick.kind === 'todo'" :class="{ active: quick.kind === 'todo' }" @click="quick.kind = 'todo'"><ListTodo />{{ t('teamKindTodo') }}</button>
<button type="button" role="radio" :aria-checked="quick.kind === 'ticket'" :class="{ active: quick.kind === 'ticket' }" @click="quick.kind = 'ticket'"><TicketCheck />{{ t('teamKindTicket') }}</button>
</div>
<label>{{ t('todoTitle') }}<input v-model="quick.title" :placeholder="t('teamTaskTitlePh')" maxlength="200" @keyup.enter="saveQuick" /></label>
<label>{{ t('priorityLabel') }}<select v-model="quick.priority">
<option value="low">{{ t('priority.low') }}</option>
<option value="medium">{{ t('priority.medium') }}</option>
<option value="high">{{ t('priority.high') }}</option>
</select></label>
<label>{{ t('dueDate') }}<DatePicker v-model="quick.dueAt" /></label>
<p v-if="quick.err" class="db-message">{{ quick.err }}</p>
<div class="modal-actions"><button class="btn primary" :disabled="!quick.title.trim() || !!busy" @click="saveQuick"><Check />{{ t('teamQuickCreateBtn') }}</button></div>
</div>
</section>
</div>
</Teleport>
</div>
</template>

View File

@@ -0,0 +1,173 @@
<script setup>
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { NotebookPen, Send, BellRing, Sparkles, RefreshCw, Users, LogIn, WifiOff, Quote, Check, ChevronLeft, ChevronRight, UserRound } from 'lucide-vue-next'
import { call, isNative, on } from '../api'
import { useAppStore } from '../store'
import { teamsErr, currentTeam, isTeamAdmin, loadTeams, teamErrCode } from '../team'
import MarkdownView from '../components/MarkdownView.vue'
import DatePicker from '../components/DatePicker.vue'
const store = useAppStore(), { t } = useI18n(), native = isNative()
const sync = computed(() => store.syncStatus)
const dayStr = d => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
const date = ref(dayStr(new Date()))
const board = ref(null)
const myDraft = ref('')
const busy = ref('')
const digestBusy = ref(false)
let offDigest = null
const errText = e => {
const code = teamErrCode(e)
return t('errors.' + code) !== 'errors.' + code ? t('errors.' + code) : String(e)
}
const isToday = computed(() => date.value === dayStr(new Date()))
const myReport = computed(() => board.value?.reports?.find(r => r.userId === sync.value.userId))
const otherReports = computed(() => (board.value?.reports || []).filter(r => r.userId !== sync.value.userId))
async function load() {
if (!currentTeam.value) return
try {
board.value = await call('TeamReportBoardGet', currentTeam.value.id, date.value)
if (myReport.value && !myDraft.value) myDraft.value = myReport.value.content
} catch (e) { store.showToast({ type: 'error', text: errText(e) }) }
}
async function boot() {
await loadTeams()
if (currentTeam.value) await load()
}
// rail 切换器换团队后重置看板并重新加载。
watch(() => currentTeam.value?.id, id => {
board.value = null
myDraft.value = ''
if (id) load()
})
function shiftDay(n) {
const d = new Date(date.value + 'T12:00')
d.setDate(d.getDate() + n)
date.value = dayStr(d)
myDraft.value = ''
board.value = null
load()
}
async function quoteDayReport() {
try {
const briefs = await call('GetAISummaries', 0)
const r = (briefs || []).find(x => x.kind === 'dayreport')
if (r?.content) myDraft.value = myDraft.value ? myDraft.value + '\n\n' + r.content : r.content
else store.showToast({ type: 'error', key: 'teamNoDayReport' })
} catch (e) { store.showToast({ type: 'error', text: errText(e) }) }
}
async function submit() {
if (!myDraft.value.trim() || busy.value) return
busy.value = 'submit'
try {
await call('TeamReportSubmit', currentTeam.value.id, date.value, myDraft.value)
await load()
store.showToast({ type: 'success', key: 'teamReportSubmittedToast' })
} catch (e) { store.showToast({ type: 'error', text: errText(e) }) }
finally { busy.value = '' }
}
async function urgeReport(m) {
try {
await call('TeamReportUrge', currentTeam.value.id, m.userId, date.value)
store.showToast({ type: 'success', key: 'teamUrgedToast' })
} catch (e) { store.showToast({ type: 'error', text: errText(e) }) }
}
async function generateDigest() {
if (digestBusy.value) return
digestBusy.value = true
try {
await call('TeamDigestGenerate', currentTeam.value.id, date.value)
store.showToast({ type: 'success', key: 'teamDigestStartedToast' })
} catch (e) {
digestBusy.value = false
store.showToast({ type: 'error', text: errText(e) })
}
}
const fmtTime = v => (v || '').replace('T', ' ').slice(5, 16)
onMounted(() => {
boot()
offDigest = on('team:digest', p => {
if (p?.teamId !== currentTeam.value?.id || p?.date !== date.value) return
digestBusy.value = false
if (p.error) store.showToast({ type: 'error', text: errText(p.error) })
else { store.showToast({ type: 'success', key: 'teamDigestDoneToast' }); load() }
})
})
onUnmounted(() => offDigest?.())
</script>
<template>
<div class="page team-page">
<header class="page-head sticky-head">
<div><h1>{{ t('teamReports') }}</h1><p>{{ t('teamReportsSubtitle') }}</p></div>
<div v-if="currentTeam" class="actions team-date-nav">
<button class="icon-btn" :title="t('prevDay')" @click="shiftDay(-1)"><ChevronLeft /></button>
<DatePicker v-model="date" class="report-date" :clearable="false" @update:model-value="myDraft = ''; board = null; load()" />
<button class="icon-btn" :disabled="isToday" :title="t('nextDay')" @click="shiftDay(1)"><ChevronRight /></button>
</div>
</header>
<section v-if="!sync.loggedIn" class="panel team-empty">
<span class="team-empty-ico"><Users /></span>
<p>{{ t('teamLoginHint') }}</p>
<button class="btn primary" :disabled="!native" @click="store.loginOpen = true"><LogIn />{{ t('loginOrRegister') }}</button>
</section>
<section v-else-if="teamsErr" class="panel team-empty">
<span class="team-empty-ico warn"><WifiOff /></span><p>{{ errText(teamsErr) }}</p>
<button class="btn secondary" @click="boot"><RefreshCw />{{ t('retry') }}</button>
</section>
<section v-else-if="!currentTeam" class="panel team-empty">
<span class="team-empty-ico"><Users /></span>
<p>{{ t('teamNoneHint') }}</p>
<router-link class="btn primary" to="/team">{{ t('teamGoHome') }}</router-link>
</section>
<template v-else>
<!-- 我的日报 -->
<section class="panel team-report-mine">
<header class="pc-head">
<span class="pc-badge id"><NotebookPen /></span>
<div><b>{{ t('teamMyReport') }}</b><small>{{ myReport ? t('teamReportSubmittedAt', { at: fmtTime(myReport.submittedAt) }) : t('teamReportNotSubmitted') }}</small></div>
</header>
<textarea v-model="myDraft" class="team-report-editor" rows="6" :placeholder="t('teamReportPh')" />
<div class="team-report-ops">
<button v-if="isToday" class="btn secondary" :title="t('teamQuoteDayReportHint')" @click="quoteDayReport"><Quote />{{ t('teamQuoteDayReport') }}</button>
<span class="spacer" />
<button class="btn primary" :disabled="!myDraft.trim() || !!busy" @click="submit"><Send />{{ myReport ? t('teamReportUpdateBtn') : t('teamReportSubmitBtn') }}</button>
</div>
</section>
<!-- AI 摘要 -->
<section class="panel team-digest">
<header class="pc-head">
<span class="pc-badge ai"><Sparkles /></span>
<div><b>{{ t('teamDigest') }}</b><small>{{ board?.digest ? t('teamDigestAt', { at: fmtTime(board.digest.generatedAt), provider: board.digest.provider }) : t('teamDigestNone') }}</small></div>
<span class="spacer" />
<button v-if="isTeamAdmin" class="btn secondary" :disabled="digestBusy" @click="generateDigest">
<component :is="digestBusy ? RefreshCw : Sparkles" :class="{ spin: digestBusy }" />{{ digestBusy ? t('generating') : (board?.digest ? t('teamDigestRegen') : t('teamDigestGen')) }}
</button>
</header>
<MarkdownView v-if="board?.digest" class="md-body" :source="board.digest.content" />
</section>
<!-- 成员提交状态 -->
<section class="team-report-grid">
<div v-for="r in otherReports" :key="r.userId" class="panel team-report-card ok">
<header><span class="trc-ava"><Check /></span><b>{{ r.user }}</b><time>{{ fmtTime(r.submittedAt) }}</time></header>
<MarkdownView v-if="r.content" class="md-body sm" :source="r.content" />
<p v-else class="team-none">{{ t('teamReportContentHidden') }}</p>
</div>
<div v-for="m in board?.missing || []" :key="'m' + m.userId" class="panel team-report-card miss">
<header>
<span class="trc-ava miss"><UserRound /></span><b>{{ m.nickname || m.username }}</b>
<em>{{ t('teamReportMissing') }}</em>
<button v-if="isTeamAdmin && m.userId !== sync.userId" class="btn secondary sm" @click="urgeReport(m)"><BellRing />{{ t('teamUrgeBtn') }}</button>
</header>
</div>
</section>
</template>
</div>
</template>

View File

@@ -0,0 +1,234 @@
<script setup>
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { ClipboardList, ListTodo, TicketCheck, Plus, Play, Check, X, BellRing, Trash2, Pencil, Flag, RefreshCw, Users, Share2, LogIn, WifiOff, UserRound } from 'lucide-vue-next'
import { call, isNative } from '../api'
import { useAppStore } from '../store'
import { teams, teamsErr, currentTeam, isTeamAdmin, loadTeams, teamErrCode } from '../team'
import MarkdownView from '../components/MarkdownView.vue'
const store = useAppStore(), { t } = useI18n(), native = isNative()
const tasks = ref([])
const shared = ref([])
const filter = ref('all')
const busy = ref('')
const detail = ref(null)
const members = ref([])
const edit = reactive({ open: false, id: 0, kind: 'todo', title: '', description: '', priority: 'medium', assigneeId: 0, startAt: '', dueAt: '', err: '' })
const sync = computed(() => store.syncStatus)
const errText = e => {
const code = teamErrCode(e)
return t('errors.' + code) !== 'errors.' + code ? t('errors.' + code) : String(e)
}
const filters = ['all', 'mine', 'created', 'open']
async function load() {
if (!currentTeam.value) return
try {
;[tasks.value, shared.value] = await Promise.all([
call('TeamTaskList', currentTeam.value.id, filter.value),
call('TeamSharedItems', currentTeam.value.id)
])
} catch (e) { store.showToast({ type: 'error', text: errText(e) }) }
}
async function boot() {
await loadTeams()
if (!currentTeam.value) return
load()
try { members.value = (await call('TeamMembers', currentTeam.value.id)) || [] } catch {}
}
// rail 切换器换团队后刷新任务与成员。
watch(() => currentTeam.value?.id, async id => {
tasks.value = []; shared.value = []; members.value = []
if (!id) return
load()
try { members.value = (await call('TeamMembers', id)) || [] } catch {}
})
function setFilter(f) { filter.value = f; load() }
function openCreate() {
Object.assign(edit, { open: true, id: 0, kind: 'todo', title: '', description: '', priority: 'medium', assigneeId: 0, startAt: '', dueAt: '', err: '' })
}
function openEdit(x) {
Object.assign(edit, { open: true, id: x.id, kind: x.kind, title: x.title, description: x.description, priority: x.priority, assigneeId: x.assigneeId, startAt: x.startAt, dueAt: x.dueAt, err: '' })
}
async function saveTask() {
if (!edit.title.trim() || busy.value) return
busy.value = 'save'; edit.err = ''
try {
await call('TeamTaskSave', {
id: edit.id, teamId: currentTeam.value.id, kind: edit.kind, title: edit.title, description: edit.description,
priority: edit.priority, assigneeId: Number(edit.assigneeId) || 0, startAt: edit.startAt, dueAt: edit.dueAt,
status: '', creatorId: 0, creator: '', assignee: '', urgedAt: '', history: '', updatedAt: ''
})
edit.open = false
await load()
store.showToast({ type: 'success', key: 'savedToast' })
store.refreshBadges()
} catch (e) { edit.err = errText(e) }
finally { busy.value = '' }
}
async function setStatus(x, status) {
try { await call('TeamTaskSetStatus', currentTeam.value.id, x.id, status); await load(); store.refreshBadges() }
catch (e) { store.showToast({ type: 'error', text: errText(e) }) }
}
async function urge(x) {
try { await call('TeamTaskUrge', currentTeam.value.id, x.id); store.showToast({ type: 'success', key: 'teamUrgedToast' }); await load() }
catch (e) { store.showToast({ type: 'error', text: errText(e) }) }
}
async function del(x) {
if (!confirm(t('teamTaskDeleteConfirm', { title: x.title }))) return
try { await call('TeamTaskDelete', currentTeam.value.id, x.id); detail.value = null; await load(); store.refreshBadges() }
catch (e) { store.showToast({ type: 'error', text: errText(e) }) }
}
async function urgeShared(it) {
try { await call('TeamUrgeShared', currentTeam.value.id, it.kind, it.uuid); store.showToast({ type: 'success', key: 'teamUrgedToast' }) }
catch (e) { store.showToast({ type: 'error', text: errText(e) }) }
}
const canFlow = x => isTeamAdmin.value || x.assigneeId === sync.value.userId
const flowActions = x => {
if (!canFlow(x)) return []
if (x.status === 'open') return [{ s: 'doing', label: t('taskStart'), icon: Play }, { s: 'done', label: t('taskDone'), icon: Check }]
if (x.status === 'doing') return [{ s: 'done', label: t('taskDone'), icon: Check }]
return [{ s: 'open', label: t('teamReopen'), icon: RefreshCw }]
}
const fmtTime = v => (v || '').replace('T', ' ').slice(5, 16)
const statusCls = s => ({ open: 'st-open', doing: 'st-doing', in_progress: 'st-doing', done: 'st-done', resolved: 'st-done', closed: 'st-closed' }[s] || 'st-open')
// 共享条目状态来自个人待办/工单两套枚举,按序查表
const sharedStatusLabel = s => {
for (const k of ['todoStatus.' + s, 'ticketStatus.' + s]) if (t(k) !== k) return t(k)
return s
}
onMounted(boot)
</script>
<template>
<div class="page team-page">
<header class="page-head sticky-head">
<div><h1>{{ t('teamTasks') }}</h1><p>{{ t('teamTasksSubtitle') }}</p></div>
<div v-if="currentTeam && isTeamAdmin" class="actions">
<button class="btn primary" @click="openCreate"><Plus />{{ t('teamTaskNew') }}</button>
</div>
</header>
<section v-if="!sync.loggedIn" class="panel team-empty">
<span class="team-empty-ico"><Users /></span>
<p>{{ t('teamLoginHint') }}</p>
<button class="btn primary" :disabled="!native" @click="store.loginOpen = true"><LogIn />{{ t('loginOrRegister') }}</button>
</section>
<section v-else-if="teamsErr" class="panel team-empty">
<span class="team-empty-ico warn"><WifiOff /></span><p>{{ errText(teamsErr) }}</p>
<button class="btn secondary" @click="boot"><RefreshCw />{{ t('retry') }}</button>
</section>
<section v-else-if="!currentTeam" class="panel team-empty">
<span class="team-empty-ico"><Users /></span>
<p>{{ t('teamNoneHint') }}</p>
<router-link class="btn primary" to="/team">{{ t('teamGoHome') }}</router-link>
</section>
<template v-else>
<div class="team-filter" role="tablist">
<button v-for="f in filters" :key="f" role="tab" :class="{ active: filter === f }" @click="setFilter(f)">{{ t('teamFilter_' + f) }}</button>
</div>
<section class="panel team-task-panel">
<p v-if="!tasks.length" class="team-none">{{ t('teamTasksEmpty') }}</p>
<div v-for="x in tasks" :key="x.id" class="team-task" :class="{ done: x.status === 'done' || x.status === 'closed' }" @click="detail = x">
<span class="tc-icon" :class="x.kind"><component :is="x.kind === 'todo' ? ListTodo : TicketCheck" /></span>
<div class="tt-main">
<b>{{ x.title }}<span v-if="x.urgedAt" class="tt-urged" :title="t('teamUrgedAt') + ' ' + fmtTime(x.urgedAt)"><BellRing /></span></b>
<small>
<span class="tt-status" :class="statusCls(x.status)">{{ t('teamStatus_' + x.status) }}</span>
<span v-if="x.priority === 'high'" class="tc-pri"><Flag />{{ t('priority.high') }}</span>
<span v-if="x.assignee" class="tt-assignee"><UserRound />{{ x.assignee }}</span>
<time v-if="x.dueAt">{{ fmtTime(x.dueAt) }}</time>
</small>
</div>
<div class="tc-acts" @click.stop>
<button v-for="a in flowActions(x)" :key="a.s" :title="a.label" @click="setStatus(x, a.s)"><component :is="a.icon" />{{ a.label }}</button>
<button v-if="isTeamAdmin && x.assigneeId && ['open', 'doing'].includes(x.status)" class="warn" :title="t('teamUrgeBtn')" @click="urge(x)"><BellRing /></button>
<button v-if="isTeamAdmin" :title="t('edit')" @click="openEdit(x)"><Pencil /></button>
<button v-if="isTeamAdmin" class="danger" :title="t('delete')" @click="del(x)"><Trash2 /></button>
</div>
</div>
</section>
<!-- 成员共享的个人条目 -->
<section class="team-shared">
<h2 class="today-title"><Share2 />{{ t('teamSharedItems') }}<em>{{ shared.length }}</em></h2>
<div class="panel team-task-panel">
<p v-if="!shared.length" class="team-none">{{ t('teamSharedEmpty') }}</p>
<div v-for="it in shared" :key="it.kind + it.uuid" class="team-task shared">
<span class="tc-icon" :class="it.kind"><component :is="it.kind === 'todo' ? ListTodo : TicketCheck" /></span>
<div class="tt-main">
<b>{{ it.title }}</b>
<small>
<span class="tt-owner"><UserRound />{{ it.owner }}</span>
<span class="tt-status" :class="statusCls(it.status)">{{ sharedStatusLabel(it.status) }}</span>
<span v-if="it.priority === 'high'" class="tc-pri"><Flag />{{ t('priority.high') }}</span>
<time v-if="it.dueAt">{{ fmtTime(it.dueAt) }}</time>
</small>
</div>
<div class="tc-acts">
<button v-if="isTeamAdmin && it.userId !== sync.userId" class="warn" :title="t('teamUrgeBtn')" @click="urgeShared(it)"><BellRing />{{ t('teamUrgeBtn') }}</button>
</div>
</div>
</div>
</section>
</template>
<Teleport to="body">
<!-- 新建 / 编辑 -->
<div v-if="edit.open" class="overlay" @click.self="edit.open = false">
<section class="modal team-modal wide" @click.stop>
<header><h2><ClipboardList class="panel-icon" />{{ edit.id ? t('teamTaskEdit') : t('teamTaskNew') }}</h2><button type="button" @click="edit.open = false"><X /></button></header>
<div class="team-modal-body grid2">
<label>{{ t('todoTitle') }}<input v-model="edit.title" :placeholder="t('teamTaskTitlePh')" maxlength="200" /></label>
<label>{{ t('ticketTypeLabel') }}<select v-model="edit.kind">
<option value="todo">{{ t('teamKindTodo') }}</option>
<option value="ticket">{{ t('teamKindTicket') }}</option>
</select></label>
<label>{{ t('priorityLabel') }}<select v-model="edit.priority">
<option value="low">{{ t('priority.low') }}</option>
<option value="medium">{{ t('priority.medium') }}</option>
<option value="high">{{ t('priority.high') }}</option>
</select></label>
<label>{{ t('teamAssignee') }}<select v-model="edit.assigneeId">
<option :value="0">{{ t('teamUnassigned') }}</option>
<option v-for="m in members" :key="m.userId" :value="m.userId">{{ m.nickname || m.username }}</option>
</select></label>
<label>{{ t('startDate') }}<input v-model="edit.startAt" type="datetime-local" /></label>
<label>{{ t('dueDate') }}<input v-model="edit.dueAt" type="datetime-local" /></label>
<label class="span2">{{ t('teamTaskDesc') }}<textarea v-model="edit.description" rows="6" :placeholder="t('mdPlaceholder')" /></label>
</div>
<p v-if="edit.err" class="db-message">{{ edit.err }}</p>
<div class="modal-actions pad">
<button class="btn primary" :disabled="!edit.title.trim() || !!busy" @click="saveTask"><Check />{{ t('save') }}</button>
</div>
</section>
</div>
<!-- 详情 -->
<div v-if="detail" class="overlay" @click.self="detail = null">
<section class="modal team-modal wide" @click.stop>
<header>
<h2><component :is="detail.kind === 'todo' ? ListTodo : TicketCheck" class="panel-icon" />{{ detail.title }}</h2>
<button type="button" @click="detail = null"><X /></button>
</header>
<div class="team-detail">
<div class="team-detail-meta">
<span class="tt-status" :class="statusCls(detail.status)">{{ t('teamStatus_' + detail.status) }}</span>
<span v-if="detail.creator"><b>{{ t('teamCreator') }}:</b> {{ detail.creator }}</span>
<span><b>{{ t('teamAssignee') }}:</b> {{ detail.assignee || t('teamUnassigned') }}</span>
<span v-if="detail.dueAt"><b>{{ t('dueDate') }}:</b> {{ fmtTime(detail.dueAt) }}</span>
<span v-if="detail.urgedAt" class="warn"><BellRing />{{ t('teamUrgedAt') }} {{ fmtTime(detail.urgedAt) }}</span>
</div>
<MarkdownView v-if="detail.description" class="md-body" :source="detail.description" />
<p v-else class="team-none">{{ t('noDescription') }}</p>
</div>
</section>
</div>
</Teleport>
</div>
</template>

View File

@@ -0,0 +1,193 @@
<script setup>
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { Plus, TicketCheck, Pencil, Trash2, RefreshCw, CalendarDays, Flag, Play, Check, Archive, RotateCcw, X, Bug, Sparkles, Wrench, ListChecks, ImagePlus } from 'lucide-vue-next'
import { call, notifyTasksChanged, onTasksChanged } from '../api'
import { useAppStore } from '../store'
import MarkdownView from '../components/MarkdownView.vue'
import DueQuickPick from '../components/DueQuickPick.vue'
import DatePicker from '../components/DatePicker.vue'
import LifecycleTimeline from '../components/LifecycleTimeline.vue'
import { useMdEditor } from '../mdeditor'
const route = useRoute()
const router = useRouter()
const { t } = useI18n()
const store = useAppStore()
const tickets = ref([])
const statusFilter = ref('all')
const projectFilter = ref(0)
const modal = ref(false)
const saving = ref(false)
const error = ref('')
const form = reactive({ id: 0, title: '', description: '', type: 'task', projectId: 0, startAt: '', dueAt: '', priority: 'medium', status: 'open' })
const current = ref(null) // 正在编辑的原始条目(时间线展示已保存的轨迹)
const md = useMdEditor(form, 'description', e => { error.value = errText(e) })
const statuses = ['open', 'in_progress', 'resolved', 'closed']
const typeIcon = { feature: Sparkles, bug: Bug, task: ListChecks, improvement: Wrench }
const filtered = computed(() => tickets.value.filter(x =>
(statusFilter.value === 'all' || x.status === statusFilter.value) &&
(!projectFilter.value || x.projectId === projectFilter.value)
))
const counts = computed(() => Object.fromEntries(['all', ...statuses].map(s => [s, s === 'all' ? tickets.value.length : tickets.value.filter(x => x.status === s).length])))
const overdue = x => x.dueAt && ['open', 'in_progress'].includes(x.status) && new Date(x.dueAt.length === 10 ? x.dueAt + 'T23:59' : x.dueAt) < new Date()
// 状态流转:开始处理 → 解决 → 关闭;已解决/已关闭可重新打开。
const flowActions = x => ({
open: [{ key: 'in_progress', label: t('ticketFlow.start'), icon: Play }],
in_progress: [{ key: 'resolved', label: t('ticketFlow.resolve'), icon: Check }],
resolved: [{ key: 'closed', label: t('ticketFlow.close'), icon: Archive }, { key: 'open', label: t('ticketFlow.reopen'), icon: RotateCcw }],
closed: [{ key: 'open', label: t('ticketFlow.reopen'), icon: RotateCcw }]
}[x.status] || [])
async function load() {
tickets.value = await call('ListTickets', 'all', 0)
}
// 本页写入后广播(顶栏任务中心等同步刷新);也接收其它入口的变更
async function reloadAndNotify() {
await load()
notifyTasksChanged()
}
function openModal(x) {
Object.assign(form, x
? { id: x.id, title: x.title, description: x.description, type: x.type, projectId: x.projectId, startAt: x.startAt, dueAt: x.dueAt, priority: x.priority, status: x.status }
: { id: 0, title: '', description: '', type: 'task', projectId: projectFilter.value || store.projects[0]?.id || 0, startAt: new Date().toISOString().slice(0, 10), dueAt: '', priority: 'medium', status: 'open' })
current.value = x || null
error.value = ''
md.reset()
modal.value = true
}
const errText = raw => {
const code = ['TICKET_TITLE_REQUIRED', 'TICKET_PROJECT_REQUIRED', 'TICKET_SCHEDULE_REQUIRED', 'TICKET_SCHEDULE_INVALID', 'TICKET_STATUS_INVALID'].find(c => String(raw).includes(c))
return code ? t(`errors.${code}`) : String(raw)
}
async function save() {
if (saving.value) return
saving.value = true
error.value = ''
try {
await call('SaveTicket', { ...form, projectId: Number(form.projectId) || 0 })
modal.value = false
await reloadAndNotify()
} catch (e) {
error.value = errText(e)
} finally {
saving.value = false
}
}
async function setStatus(x, status) {
await call('SetTicketStatus', x.id, status)
await reloadAndNotify()
}
async function remove(x) {
if (!confirm(`${t('delete')} ${x.title}?`)) return
await call('DeleteTicket', x.id)
await reloadAndNotify()
}
// 深链 ?edit=id任务中心/日历详情点“编辑”后直接弹出编辑框
function openEditFromQuery() {
const id = Number(route.query.edit)
if (!id) return
router.replace('/tickets')
const x = tickets.value.find(i => i.id === id)
if (x) openModal(x)
}
onMounted(async () => {
await load()
openEditFromQuery()
})
watch(() => route.query.edit, v => { if (v) openEditFromQuery() })
const offTasks = onTasksChanged(load)
onUnmounted(() => offTasks())
</script>
<template>
<div class="page tickets-page">
<header class="page-head sticky-head">
<div><h1>{{ t('tickets') }}</h1><p>{{ t('ticketsSubtitle') }}</p></div>
<div class="actions">
<button class="btn secondary" @click="load"><RefreshCw />{{ t('refresh') }}</button>
<button class="btn primary" @click="openModal()"><Plus />{{ t('addTicket') }}</button>
</div>
</header>
<div class="todo-toolbar">
<div class="tabs compact ticket-tabs">
<button v-for="s in ['all', ...statuses]" :key="s" :class="{ active: statusFilter === s }" @click="statusFilter = s">
{{ s === 'all' ? t('allLogs') : t('ticketStatus.' + s) }}<i class="tab-count">{{ counts[s] }}</i>
</button>
</div>
<select v-model.number="projectFilter" class="log-category">
<option :value="0">{{ t('allProjects') }}</option>
<option v-for="p in store.projects" :key="p.id" :value="p.id">{{ p.name }}</option>
</select>
</div>
<section class="panel ticket-panel">
<article v-for="x in filtered" :key="x.id" class="ticket-row" :class="[x.status, x.priority, { overdue: overdue(x) }]">
<span class="ticket-type" :class="x.type" :title="t('ticketType.' + x.type)"><component :is="typeIcon[x.type] || ListChecks" /></span>
<div class="ticket-main" @click="openModal(x)">
<div class="ticket-title"><b>{{ x.title }}</b><span class="ticket-status" :class="x.status">{{ t('ticketStatus.' + x.status) }}</span></div>
<MarkdownView v-if="x.description && x.description.trim() !== x.title.trim()" class="md-clamp ticket-desc" :source="x.description" />
<div class="todo-meta">
<span class="todo-chip">{{ x.projectName || ('#' + x.projectId) }}</span>
<span class="todo-due" :class="{ overdue: overdue(x) }"><CalendarDays />{{ x.startAt.replace('T', ' ') }} {{ x.dueAt.replace('T', ' ') }}</span>
<span class="todo-priority" :class="x.priority"><Flag />{{ t('priority.' + x.priority) }}</span>
</div>
</div>
<div class="ticket-actions">
<button v-for="act in flowActions(x)" :key="act.key" class="btn secondary flow-btn" @click="setStatus(x, act.key)"><component :is="act.icon" />{{ act.label }}</button>
<div class="icon-actions">
<button :title="t('edit')" @click="openModal(x)"><Pencil /></button>
<button :title="t('delete')" @click="remove(x)"><Trash2 /></button>
</div>
</div>
</article>
<div v-if="!filtered.length" class="empty"><TicketCheck />{{ t('noTickets') }}</div>
</section>
</div>
<Teleport to="body">
<div v-if="modal" class="overlay" @click.self="!saving && (modal = false)">
<form class="modal modal-split" @submit.prevent="save">
<header><h2>{{ form.id ? t('editTicket') : t('addTicket') }}</h2><button type="button" :disabled="saving" @click="modal = false"><X /></button></header>
<div class="split-body">
<div class="split-fields">
<label>{{ t('ticketTitle') }}<input v-model="form.title" :disabled="saving" required /></label>
<label>{{ t('relatedProject') }} *<select v-model.number="form.projectId" :disabled="saving" required><option :value="0" disabled>{{ t('selectProject') }}</option><option v-for="p in store.projects" :key="p.id" :value="p.id">{{ p.name }}</option></select></label>
<label>{{ t('ticketTypeLabel') }}<select v-model="form.type" :disabled="saving"><option v-for="k in ['feature', 'bug', 'task', 'improvement']" :key="k" :value="k">{{ t('ticketType.' + k) }}</option></select></label>
<label>{{ t('startDate') }} *<DatePicker v-model="form.startAt" :disabled="saving" :clearable="false" /></label>
<label>{{ t('dueDate') }} *<DatePicker v-model="form.dueAt" :disabled="saving" :clearable="false" />
<DueQuickPick v-model="form.dueAt" :disabled="saving" />
</label>
<div class="field-pair">
<label>{{ t('priorityLabel') }}<select v-model="form.priority" :disabled="saving"><option value="low">{{ t('priority.low') }}</option><option value="medium">{{ t('priority.medium') }}</option><option value="high">{{ t('priority.high') }}</option></select></label>
<label>{{ t('statusLabel') }}<select v-model="form.status" :disabled="saving"><option v-for="s in statuses" :key="s" :value="s">{{ t('ticketStatus.' + s) }}</option></select></label>
</div>
<LifecycleTimeline v-if="current" :history="current.history" :created-at="current.createdAt" :updated-at="current.updatedAt" :status="current.status" kind="ticket" />
</div>
<div class="split-editor">
<div class="md-toolbar">
<span class="md-field-label">{{ t('ticketDesc') }}</span>
<div class="tabs compact">
<button type="button" :class="{ active: !md.preview.value }" @click="md.preview.value = false">{{ t('mdEdit') }}</button>
<button type="button" :class="{ active: md.preview.value }" @click="md.preview.value = true">{{ t('mdPreviewTab') }}</button>
</div>
<button type="button" class="btn secondary md-img-btn" :disabled="saving || md.uploading.value" @click="md.pickImage"><ImagePlus />{{ md.uploading.value ? t('mdInserting') : t('insertImage') }}</button>
</div>
<div class="md-editor">
<textarea v-show="!md.preview.value" :ref="md.inputEl" v-model="form.description" :disabled="saving" :placeholder="t('mdPlaceholder')" @paste="md.onPaste" />
<MarkdownView v-if="md.preview.value" class="md-preview-box" :source="form.description || t('mdEmpty')" />
</div>
</div>
</div>
<footer>
<p v-if="error" class="form-error">{{ error }}</p>
<button type="button" class="btn secondary" :disabled="saving" @click="modal = false">{{ t('cancel') }}</button>
<button class="btn primary" :disabled="saving">{{ saving ? t('saving') : t('save') }}</button>
</footer>
</form>
</div>
</Teleport>
</template>

View File

@@ -0,0 +1,97 @@
<script setup>
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { CalendarCheck2, AlarmClockOff, CalendarClock, Play, Check, Flag, ListTodo, TicketCheck, PartyPopper } from 'lucide-vue-next'
import { call, notifyTasksChanged, onTasksChanged } from '../api'
import TaskDetailModal from '../components/TaskDetailModal.vue'
// 今日任务页:聚合今天需要处理的待办与工单(逾期 / 今天到期 / 进行中 / 未来 7 天)。
const { t } = useI18n()
const todos = ref([])
const tickets = ref([])
const detail = ref(null)
let offTasks = null
let timer = 0
async function load() {
try {
;[todos.value, tickets.value] = await Promise.all([call('ListTodos', 'all', 0), call('ListTickets', 'all', 0)])
} catch { /* 后端未就绪时静默 */ }
}
const all = computed(() => [
...todos.value.filter(x => !['done'].includes(x.status)).map(x => ({ ...x, kind: 'todo' })),
...tickets.value.filter(x => !['resolved', 'closed'].includes(x.status)).map(x => ({ ...x, kind: 'ticket' }))
])
const byDue = (a, b) => (a.dueAt || '9999') < (b.dueAt || '9999') ? -1 : 1
const dayStr = d => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
const todayStr = dayStr(new Date())
const dueDay = x => (x.dueAt || '').slice(0, 10)
const isOverdue = x => x.dueAt && new Date(x.dueAt.length === 10 ? x.dueAt + 'T23:59' : x.dueAt) < new Date() && dueDay(x) !== todayStr
const isDoing = x => x.status === 'doing' || x.status === 'in_progress'
const within7 = x => {
const d = dueDay(x)
if (!d || d <= todayStr) return false
const limit = new Date()
limit.setDate(limit.getDate() + 7)
return d <= dayStr(limit)
}
// 每项只归入一个分区:逾期 > 今天到期 > 进行中 > 未来 7 天
const secOverdue = computed(() => all.value.filter(isOverdue).sort(byDue))
const secToday = computed(() => all.value.filter(x => !isOverdue(x) && dueDay(x) === todayStr).sort(byDue))
const secDoing = computed(() => all.value.filter(x => !isOverdue(x) && dueDay(x) !== todayStr && isDoing(x)).sort(byDue))
const secUpcoming = computed(() => all.value.filter(x => !isOverdue(x) && dueDay(x) !== todayStr && !isDoing(x) && within7(x)).sort(byDue))
const sections = computed(() => [
{ key: 'overdue', icon: AlarmClockOff, label: t('secOverdue'), items: secOverdue.value },
{ key: 'today', icon: CalendarCheck2, label: t('secToday'), items: secToday.value },
{ key: 'doing', icon: Play, label: t('secDoing'), items: secDoing.value },
{ key: 'upcoming', icon: CalendarClock, label: t('secUpcoming'), items: secUpcoming.value }
])
const total = computed(() => sections.value.reduce((n, s) => n + s.items.length, 0))
const actions = x => x.kind === 'todo'
? (x.status === 'open' ? [{ s: 'doing', label: t('taskStart'), icon: Play }, { s: 'done', label: t('taskDone'), icon: Check }] : [{ s: 'done', label: t('taskDone'), icon: Check }])
: (x.status === 'open' ? [{ s: 'in_progress', label: t('taskStart'), icon: Play }] : [{ s: 'resolved', label: t('taskResolve'), icon: Check }])
async function act(x, status) {
await call(x.kind === 'todo' ? 'SetTodoStatus' : 'SetTicketStatus', x.id, status)
await load()
notifyTasksChanged()
}
onMounted(() => {
load()
offTasks = onTasksChanged(load)
timer = setInterval(load, 60000)
})
onUnmounted(() => { offTasks?.(); clearInterval(timer) })
</script>
<template>
<div class="page today-page">
<header class="page-head sticky-head">
<div><h1>{{ t('todayTasks') }}</h1><p>{{ t('todaySubtitle') }}</p></div>
</header>
<div v-if="!total" class="today-empty"><PartyPopper />{{ t('todayEmpty') }}</div>
<template v-for="sec in sections" :key="sec.key">
<section v-if="sec.items.length" class="today-section" :class="sec.key">
<h2 class="today-title"><component :is="sec.icon" />{{ sec.label }}<em>{{ sec.items.length }}</em></h2>
<div class="panel today-panel">
<div v-for="x in sec.items" :key="x.kind + x.id" class="tc-item today-item" @click="detail = { kind: x.kind, item: x }">
<span class="tc-icon" :class="x.kind"><component :is="x.kind === 'todo' ? ListTodo : TicketCheck" /></span>
<div class="tc-main">
<b>{{ x.title }}</b>
<small>
<span v-if="x.projectName" class="tc-proj">{{ x.projectName }}</span>
<span v-if="x.priority === 'high'" class="tc-pri"><Flag />{{ t('priority.high') }}</span>
<time v-if="x.dueAt" :class="{ overdue: sec.key === 'overdue' }">{{ x.dueAt.replace('T', ' ').slice(5, 16) }}</time>
</small>
</div>
<div class="tc-acts">
<button v-for="a in actions(x)" :key="a.s" :title="a.label" @click.stop="act(x, a.s)"><component :is="a.icon" />{{ a.label }}</button>
</div>
</div>
</div>
</section>
</template>
<TaskDetailModal v-if="detail" :kind="detail.kind" :item="detail.item" @close="detail = null" @changed="load" />
</div>
</template>

View File

@@ -0,0 +1,206 @@
<script setup>
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { Plus, ListTodo, LayoutGrid, Rows3, Pencil, Trash2, RefreshCw, Circle, CircleDot, CircleCheck, Flag, CalendarDays, X, ImagePlus } from 'lucide-vue-next'
import { call, notifyTasksChanged, onTasksChanged } from '../api'
import { useAppStore } from '../store'
import MarkdownView from '../components/MarkdownView.vue'
import DueQuickPick from '../components/DueQuickPick.vue'
import LifecycleTimeline from '../components/LifecycleTimeline.vue'
import { useMdEditor } from '../mdeditor'
const route = useRoute()
const router = useRouter()
const { t } = useI18n()
const store = useAppStore()
const todos = ref([])
const view = ref(localStorage.getItem('cc-todo-view') || 'board')
const projectFilter = ref(0)
const quickTitle = ref('')
const modal = ref(false)
const saving = ref(false)
const error = ref('')
const form = reactive({ id: 0, title: '', content: '', projectId: 0, dueAt: '', priority: 'medium', status: 'open' })
const current = ref(null) // 正在编辑的原始条目(时间线展示已保存的轨迹)
const md = useMdEditor(form, 'content', e => { error.value = String(e) })
const statuses = ['open', 'doing', 'done']
const statusIcon = { open: Circle, doing: CircleDot, done: CircleCheck }
const filtered = computed(() => projectFilter.value ? todos.value.filter(x => x.projectId === projectFilter.value) : todos.value)
const columns = computed(() => statuses.map(s => ({ status: s, items: filtered.value.filter(x => x.status === s) })))
const overdue = x => x.dueAt && x.status !== 'done' && new Date(x.dueAt.length === 10 ? x.dueAt + 'T23:59' : x.dueAt) < new Date()
async function load() {
todos.value = await call('ListTodos', 'all', 0)
}
// 本页写入后广播(顶栏任务中心等同步刷新);也接收其它入口的变更
async function reloadAndNotify() {
await load()
notifyTasksChanged()
}
function setView(v) {
view.value = v
localStorage.setItem('cc-todo-view', v)
}
function openModal(x) {
Object.assign(form, x
? { id: x.id, title: x.title, content: x.content, projectId: x.projectId, dueAt: x.dueAt, priority: x.priority, status: x.status }
: { id: 0, title: '', content: '', projectId: projectFilter.value || 0, dueAt: '', priority: 'medium', status: 'open' })
current.value = x || null
error.value = ''
md.reset()
modal.value = true
}
async function save() {
if (saving.value) return
saving.value = true
error.value = ''
try {
await call('SaveTodo', { ...form, projectId: Number(form.projectId) || 0 })
modal.value = false
await reloadAndNotify()
} catch (e) {
const code = String(e).split(':')[0]
error.value = code === 'TODO_TITLE_REQUIRED' ? t('errors.TODO_TITLE_REQUIRED') : String(e)
} finally {
saving.value = false
}
}
async function quickAdd() {
const title = quickTitle.value.trim()
if (!title) return
quickTitle.value = ''
await call('SaveTodo', { id: 0, title, content: '', projectId: projectFilter.value || 0, dueAt: '', priority: 'medium', status: 'open' })
await reloadAndNotify()
}
async function setStatus(x, status) {
await call('SetTodoStatus', x.id, status)
await reloadAndNotify()
}
async function remove(x) {
if (!confirm(`${t('delete')} ${x.title}?`)) return
await call('DeleteTodo', x.id)
await reloadAndNotify()
}
function cycle(x) {
const next = { open: 'doing', doing: 'done', done: 'open' }[x.status]
setStatus(x, next)
}
// 深链 ?edit=id任务中心/日历详情点“编辑”后直接弹出编辑框
function openEditFromQuery() {
const id = Number(route.query.edit)
if (!id) return
router.replace('/todos')
const x = todos.value.find(i => i.id === id)
if (x) openModal(x)
}
onMounted(async () => {
await load()
openEditFromQuery()
})
watch(() => route.query.edit, v => { if (v) openEditFromQuery() })
const offTasks = onTasksChanged(load)
onUnmounted(() => offTasks())
</script>
<template>
<div class="page todos-page">
<header class="page-head sticky-head">
<div><h1>{{ t('todos') }}</h1><p>{{ t('todosSubtitle') }}</p></div>
<div class="actions">
<div class="tabs compact view-toggle">
<button :class="{ active: view === 'board' }" @click="setView('board')"><LayoutGrid />{{ t('boardView') }}</button>
<button :class="{ active: view === 'list' }" @click="setView('list')"><Rows3 />{{ t('listView') }}</button>
</div>
<button class="btn primary" @click="openModal()"><Plus />{{ t('addTodo') }}</button>
</div>
</header>
<div class="todo-toolbar">
<label class="search quick-add"><Plus /><input v-model="quickTitle" :placeholder="t('quickAddTodo')" @keyup.enter="quickAdd" /></label>
<select v-model.number="projectFilter" class="log-category">
<option :value="0">{{ t('allProjects') }}</option>
<option v-for="p in store.projects" :key="p.id" :value="p.id">{{ p.name }}</option>
</select>
<button class="btn secondary" @click="load"><RefreshCw />{{ t('refresh') }}</button>
</div>
<div v-if="view === 'board'" class="todo-board">
<section v-for="col in columns" :key="col.status" class="todo-column" :class="col.status">
<header><component :is="statusIcon[col.status]" /><b>{{ t('todoStatus.' + col.status) }}</b><span>{{ col.items.length }}</span></header>
<article v-for="x in col.items" :key="x.id" class="todo-card" :class="[x.priority, { overdue: overdue(x) }]">
<button class="todo-check" :title="t('todoStatus.' + x.status)" @click="cycle(x)"><component :is="statusIcon[x.status]" /></button>
<div class="todo-main" @click="openModal(x)">
<b :class="{ done: x.status === 'done' }">{{ x.title }}</b>
<!-- 内容与标题一字不差时不再重复展示摘要 -->
<MarkdownView v-if="x.content && x.content.trim() !== x.title.trim()" class="md-clamp" :source="x.content" />
<div class="todo-meta">
<span v-if="x.projectName" class="todo-chip">{{ x.projectName }}</span>
<span v-if="x.dueAt" class="todo-due" :class="{ overdue: overdue(x) }"><CalendarDays />{{ x.dueAt.replace('T', ' ') }}</span>
<span class="todo-priority" :class="x.priority"><Flag />{{ t('priority.' + x.priority) }}</span>
</div>
</div>
<button class="todo-remove" :title="t('delete')" @click="remove(x)"><Trash2 /></button>
</article>
<div v-if="!col.items.length" class="todo-empty">{{ t('empty') }}</div>
</section>
</div>
<section v-else class="panel todo-list-panel">
<div v-for="x in filtered" :key="x.id" class="todo-row" :class="[x.priority, { overdue: overdue(x) }]">
<button class="todo-check" @click="cycle(x)"><component :is="statusIcon[x.status]" /></button>
<b :class="{ done: x.status === 'done' }">{{ x.title }}</b>
<span v-if="x.projectName" class="todo-chip">{{ x.projectName }}</span>
<span class="todo-priority" :class="x.priority"><Flag />{{ t('priority.' + x.priority) }}</span>
<span class="todo-due" :class="{ overdue: overdue(x) }">{{ x.dueAt ? x.dueAt.replace('T', ' ') : '—' }}</span>
<div class="icon-actions">
<button :title="t('edit')" @click="openModal(x)"><Pencil /></button>
<button :title="t('delete')" @click="remove(x)"><Trash2 /></button>
</div>
</div>
<div v-if="!filtered.length" class="empty"><ListTodo />{{ t('noTodos') }}</div>
</section>
</div>
<Teleport to="body">
<div v-if="modal" class="overlay" @click.self="!saving && (modal = false)">
<form class="modal modal-split" @submit.prevent="save">
<header><h2>{{ form.id ? t('editTodo') : t('addTodo') }}</h2><button type="button" :disabled="saving" @click="modal = false"><X /></button></header>
<div class="split-body">
<div class="split-fields">
<label>{{ t('todoTitle') }}<input v-model="form.title" :disabled="saving" required /></label>
<label>{{ t('relatedProject') }}<select v-model.number="form.projectId" :disabled="saving"><option :value="0">{{ t('noProject') }}</option><option v-for="p in store.projects" :key="p.id" :value="p.id">{{ p.name }}</option></select></label>
<label>{{ t('dueDate') }}<input v-model="form.dueAt" type="datetime-local" :disabled="saving" />
<DueQuickPick v-model="form.dueAt" with-time :disabled="saving" />
</label>
<div class="field-pair">
<label>{{ t('priorityLabel') }}<select v-model="form.priority" :disabled="saving"><option value="low">{{ t('priority.low') }}</option><option value="medium">{{ t('priority.medium') }}</option><option value="high">{{ t('priority.high') }}</option></select></label>
<label>{{ t('statusLabel') }}<select v-model="form.status" :disabled="saving"><option v-for="s in statuses" :key="s" :value="s">{{ t('todoStatus.' + s) }}</option></select></label>
</div>
<LifecycleTimeline v-if="current" :history="current.history" :created-at="current.createdAt" :updated-at="current.updatedAt" :status="current.status" kind="todo" />
</div>
<div class="split-editor">
<div class="md-toolbar">
<span class="md-field-label">{{ t('todoContent') }}</span>
<div class="tabs compact">
<button type="button" :class="{ active: !md.preview.value }" @click="md.preview.value = false">{{ t('mdEdit') }}</button>
<button type="button" :class="{ active: md.preview.value }" @click="md.preview.value = true">{{ t('mdPreviewTab') }}</button>
</div>
<button type="button" class="btn secondary md-img-btn" :disabled="saving || md.uploading.value" @click="md.pickImage"><ImagePlus />{{ md.uploading.value ? t('mdInserting') : t('insertImage') }}</button>
</div>
<div class="md-editor">
<textarea v-show="!md.preview.value" :ref="md.inputEl" v-model="form.content" :disabled="saving" :placeholder="t('mdPlaceholder')" @paste="md.onPaste" />
<MarkdownView v-if="md.preview.value" class="md-preview-box" :source="form.content || t('mdEmpty')" />
</div>
</div>
</div>
<footer>
<p v-if="error" class="form-error">{{ error }}</p>
<button type="button" class="btn secondary" :disabled="saving" @click="modal = false">{{ t('cancel') }}</button>
<button class="btn primary" :disabled="saving">{{ saving ? t('saving') : t('save') }}</button>
</footer>
</form>
</div>
</Teleport>
</template>

View File

@@ -0,0 +1,172 @@
<script setup>
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 } from 'lucide-vue-next'
import StatCard from '../components/StatCard.vue'
import AIDayPanel from '../components/AIDayPanel.vue'
import { call } from '../api'
import { useAppStore } from '../store'
const router = useRouter()
const store = useAppStore()
const { t } = useI18n()
const todos = ref([])
const tickets = ref([])
const note = ref(null)
const noteText = ref('')
const noteSavedAt = ref('')
const messages = ref([])
let noteTimer
const statusIcon = { open: Circle, doing: CircleDot, done: CircleCheck }
const fmt = n => {
n = +n || 0
return n >= 1e6 ? (n / 1e6).toFixed(1) + 'M' : n >= 1e3 ? (n / 1e3).toFixed(1) + 'K' : n.toString()
}
const favoriteProjects = computed(() => store.favorites.map(id => store.projects.find(p => p.id === id)).filter(Boolean))
const endOfWeek = () => {
const d = new Date()
d.setDate(d.getDate() + (7 - d.getDay()))
d.setHours(23, 59, 59, 0)
return d
}
const dueDate = v => new Date(v.length === 10 ? v + 'T23:59' : v)
const openTodos = computed(() => todos.value.filter(x => x.status !== 'done'))
const todayTodos = computed(() => {
const today = new Date().toISOString().slice(0, 10)
return openTodos.value.filter(x => x.dueAt && (x.dueAt.slice(0, 10) <= today))
})
const weekTickets = computed(() => tickets.value.filter(x =>
['open', 'in_progress'].includes(x.status) && x.dueAt && dueDate(x.dueAt) <= endOfWeek()
))
const overdue = v => v && dueDate(v) < new Date()
async function load() {
;[todos.value, tickets.value, messages.value] = await Promise.all([
call('ListTodos', 'all', 0),
call('ListTickets', 'all', 0),
call('ListMessages', 8)
])
note.value = await call('GetNote')
noteText.value = note.value.content
}
function editNote() {
clearTimeout(noteTimer)
noteTimer = setTimeout(async () => {
// 带 id 保存,避免笔记中心新建笔记后误写到"最近一条"
note.value = await call('SaveNoteByID', note.value?.id || 0, noteText.value)
noteSavedAt.value = new Date().toTimeString().slice(0, 8)
}, 600)
}
async function completeTodo(x) {
await call('SetTodoStatus', x.id, 'done')
todos.value = await call('ListTodos', 'all', 0)
}
async function advanceTicket(x) {
await call('SetTicketStatus', x.id, x.status === 'open' ? 'in_progress' : 'resolved')
tickets.value = await call('ListTickets', 'all', 0)
}
async function unfavorite(p) {
await store.toggleFavorite(p.id)
}
onMounted(load)
onUnmounted(() => clearTimeout(noteTimer))
</script>
<template>
<div class="page workbench-page">
<header class="page-head sticky-head">
<div><h1>{{ t('workbench') }}</h1><p>{{ t('workbenchSubtitle') }}</p></div>
<div class="actions">
<button class="btn secondary" @click="router.push('/projects')"><Folder />{{ t('projects') }}</button>
<button class="btn primary" @click="store.pendingAction = 'addProject'; router.push('/projects')"><Plus />{{ t('addProject') }}</button>
</div>
</header>
<div class="stats-grid four">
<StatCard :icon="Folder" :value="store.dashboard.projects" :label="t('totalProjects')" />
<StatCard :icon="Code2" tone="green" :value="fmt(store.dashboard.totalLines)" :label="t('totalLines')" />
<StatCard :icon="ListTodo" tone="blue" :value="openTodos.length" :label="t('openTodos')" />
<StatCard :icon="TicketCheck" tone="red" :value="tickets.filter(x => ['open', 'in_progress'].includes(x.status)).length" :label="t('openTickets')" />
</div>
<AIDayPanel />
<section class="panel wb-favorites shine-card">
<div class="section-head">
<h2><Star class="panel-icon star-icon" />{{ t('favoriteProjects') }}</h2>
<button class="btn secondary" @click="router.push('/projects')">{{ t('allProjects') }}<ArrowRight /></button>
</div>
<div v-if="favoriteProjects.length" class="wb-fav-grid">
<article v-for="p in favoriteProjects" :key="p.id" class="wb-fav-card" @click="router.push('/project/' + p.id)">
<div class="wb-fav-head">
<b>{{ p.name }}</b>
<button class="wb-star active" :title="t('unfavorite')" @click.stop="unfavorite(p)"><Star /></button>
</div>
<p :title="p.path">{{ p.path }}</p>
<footer>
<span><Code2 />{{ fmt(p.stats?.totalLines) }}</span>
<span><GitCommitHorizontal />{{ fmt(p.stats?.commitCount) }}</span>
</footer>
</article>
</div>
<div v-else class="empty wb-empty"><Star />{{ t('noFavorites') }}</div>
</section>
<div class="wb-grid">
<section class="panel wb-col">
<div class="section-head">
<h2><ListTodo class="panel-icon" />{{ t('todayTodos') }}<small>{{ todayTodos.length }}</small></h2>
<button class="btn secondary" @click="router.push('/todos')">{{ t('todos') }}<ArrowRight /></button>
</div>
<div class="wb-list">
<div v-for="x in todayTodos.slice(0, 8)" :key="x.id" class="wb-item" :class="x.priority">
<button class="todo-check" @click="completeTodo(x)"><component :is="statusIcon[x.status]" /></button>
<div><b>{{ x.title }}</b><small :class="{ 'overdue-text': overdue(x.dueAt) }"><CalendarDays />{{ x.dueAt.replace('T', ' ') }}</small></div>
<span v-if="x.projectName" class="todo-chip">{{ x.projectName }}</span>
</div>
<div v-if="!todayTodos.length" class="empty wb-empty">{{ t('noTodayTodos') }}</div>
</div>
</section>
<section class="panel wb-col">
<div class="section-head">
<h2><TicketCheck class="panel-icon" />{{ t('weekTickets') }}<small>{{ weekTickets.length }}</small></h2>
<button class="btn secondary" @click="router.push('/tickets')">{{ t('tickets') }}<ArrowRight /></button>
</div>
<div class="wb-list">
<div v-for="x in weekTickets.slice(0, 8)" :key="x.id" class="wb-item" :class="x.priority">
<span class="ticket-status wb-ticket-status" :class="x.status">{{ t('ticketStatus.' + x.status) }}</span>
<div><b>{{ x.title }}</b><small :class="{ 'overdue-text': overdue(x.dueAt) }"><CalendarDays />{{ x.dueAt.replace('T', ' ') }} · {{ x.projectName }}</small></div>
<button class="btn secondary flow-btn" @click="advanceTicket(x)">
<component :is="x.status === 'open' ? Play : Check" />{{ x.status === 'open' ? t('ticketFlow.start') : t('ticketFlow.resolve') }}
</button>
</div>
<div v-if="!weekTickets.length" class="empty wb-empty">{{ t('noWeekTickets') }}</div>
</div>
</section>
<section class="panel wb-col wb-note-panel">
<div class="section-head">
<h2><StickyNote class="panel-icon" />{{ t('notepad') }}</h2>
<small v-if="noteSavedAt" class="wb-note-saved">{{ t('autoSaved') }} {{ noteSavedAt }}</small>
</div>
<textarea v-model="noteText" class="wb-note" :placeholder="t('notepadPlaceholder')" @input="editNote" />
</section>
<section class="panel wb-col">
<div class="section-head">
<h2><Bell class="panel-icon" />{{ t('recentMessages') }}<small>{{ messages.length }}</small></h2>
<button class="btn secondary" @click="router.push('/messages')">{{ t('viewAll') }}<ArrowRight /></button>
</div>
<div class="wb-msg-list">
<div v-for="m in messages.slice(0, 8)" :key="m.id" class="wb-msg" :class="{ unread: !m.read }">
<b>{{ m.title }}</b><time>{{ m.createdAt?.replace('T', ' ').slice(5, 16) }}</time>
</div>
<div v-if="!messages.length" class="empty wb-empty">{{ t('noMessages') }}</div>
</div>
</section>
</div>
</div>
</template>

171
images.go Normal file
View File

@@ -0,0 +1,171 @@
package main
// images.go 处理待办/工单 Markdown 内容里的图片:
// 粘贴或选择的图片统一解码、缩放(最长边 contentImgMaxEdge后按设置存储 ——
// base64 模式返回内嵌 dataURL随内容同步到云端path 模式写入数据目录 images/ 并返回路径(仅本机可见),
// server 模式上传到 nl-pms-api 返回 http URL跨设备/团队可访问,见 fileapi.go
import (
"bytes"
"encoding/base64"
"errors"
"fmt"
"image"
"image/jpeg"
"image/png"
"os"
"path/filepath"
"strings"
"time"
"github.com/wailsapp/wails/v3/pkg/application"
)
const (
contentImgMaxEdge = 1100 // 内容图最长边,兼顾清晰度与同步体积
contentImgMaxBytes = 20 << 20 // 原始输入上限 20MB
contentJPEGQuality = 82
)
// SaveContentImage 处理编辑器里粘贴的图片dataURL返回可直接写进 Markdown 的图片地址。
func (a *App) SaveContentImage(dataURL string) (string, error) {
if e := a.ready(); e != nil {
return "", e
}
raw, e := dataURLBytes(dataURL)
if e != nil {
return "", e
}
return a.storeContentImage(raw)
}
// PickContentImage 打开图片选择框,处理后返回 Markdown 图片地址;用户取消时返回空串。
func (a *App) PickContentImage() (string, error) {
if e := a.ready(); e != nil {
return "", e
}
p, e := application.Get().Dialog.OpenFile().
SetTitle(a.localized("选择要插入的图片", "Choose image to insert")).
AddFilter(a.localized("图片文件", "Image files"), "*.png;*.jpg;*.jpeg;*.gif;*.webp").
PromptForSingleSelection()
if e != nil || strings.TrimSpace(p) == "" {
return "", e
}
info, e := os.Stat(p)
if e != nil {
return "", errors.New("FILE_NOT_FOUND")
}
if info.Size() > contentImgMaxBytes {
return "", errors.New("IMAGE_FILE_TOO_LARGE")
}
raw, e := os.ReadFile(p)
if e != nil {
return "", errors.New("FILE_READ_FAILED")
}
return a.storeContentImage(raw)
}
// ReadContentImageAsDataURL 把 Markdown 引用的本地图片读成 dataURLpath 模式渲染时用)。
func (a *App) ReadContentImageAsDataURL(path string) (string, error) {
if e := a.ready(); e != nil {
return "", e
}
info, e := os.Stat(path)
if e != nil {
return "", errors.New("FILE_NOT_FOUND")
}
if info.Size() > contentImgMaxBytes {
return "", errors.New("IMAGE_FILE_TOO_LARGE")
}
raw, e := os.ReadFile(path)
if e != nil {
return "", errors.New("FILE_READ_FAILED")
}
data, mime, e := encodeContentImage(raw)
if e != nil {
return "", e
}
return "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(data), nil
}
// storeContentImage 缩放压缩后按设置存储server 模式上传换 http URL
// path 模式写入数据目录返回正斜杠路径,默认返回内嵌 dataURL。
func (a *App) storeContentImage(raw []byte) (string, error) {
data, mime, e := encodeContentImage(raw)
if e != nil {
return "", e
}
// 存储走向由管理员的全局配置决定上传时实时获取server 一律上传;
// local 下沿用本机 imageMode老用户的 path 数据继续可用),默认 base64。
if a.currentFileStorage().Mode == "server" {
return a.uploadImageToServer(data, mime, "content")
}
s, err := a.store.Settings()
if err == nil && s.ImageMode == "path" {
dir := filepath.Join(filepath.Dir(a.store.path), "images")
if e := os.MkdirAll(dir, 0755); e != nil {
return "", errors.New("IMAGE_SAVE_FAILED")
}
ext := ".jpg"
if mime == "image/png" {
ext = ".png"
}
p := filepath.Join(dir, fmt.Sprintf("img-%d%s", time.Now().UnixNano(), ext))
if e := os.WriteFile(p, data, 0644); e != nil {
return "", errors.New("IMAGE_SAVE_FAILED")
}
// Markdown 里用正斜杠路径,避免反斜杠被当成转义。
return filepath.ToSlash(p), nil
}
return "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(data), nil
}
// encodeContentImage 解码任意支持格式,缩放后重新编码:
// 不透明图输出 JPEG体积小带透明通道的输出 PNG。
func encodeContentImage(raw []byte) ([]byte, string, error) {
return encodeImageEdge(raw, contentImgMaxEdge)
}
// encodeImageEdge 同上,但可指定最长边(节日背景等场景用更小尺寸控制体积)。
func encodeImageEdge(raw []byte, maxEdge int) ([]byte, string, error) {
src, _, e := image.Decode(bytes.NewReader(raw))
if e != nil {
return nil, "", errors.New("IMAGE_DECODE_FAILED")
}
img := downscaleImage(src, maxEdge)
var buf bytes.Buffer
if imageOpaque(img) {
if e = jpeg.Encode(&buf, img, &jpeg.Options{Quality: contentJPEGQuality}); e != nil {
return nil, "", errors.New("IMAGE_DECODE_FAILED")
}
return buf.Bytes(), "image/jpeg", nil
}
if e = png.Encode(&buf, img); e != nil {
return nil, "", errors.New("IMAGE_DECODE_FAILED")
}
return buf.Bytes(), "image/png", nil
}
// imageOpaque 判断图片是否完全不透明;无法判断时按含透明处理(走 PNG
func imageOpaque(img image.Image) bool {
if o, ok := img.(interface{ Opaque() bool }); ok {
return o.Opaque()
}
return false
}
// dataURLBytes 解出 dataURL 里的原始字节。
func dataURLBytes(s string) ([]byte, error) {
i := strings.Index(s, ";base64,")
if !strings.HasPrefix(s, "data:image/") || i < 0 {
return nil, errors.New("IMAGE_DECODE_FAILED")
}
if len(s)-i > contentImgMaxBytes {
return nil, errors.New("IMAGE_FILE_TOO_LARGE")
}
raw, e := base64.StdEncoding.DecodeString(s[i+8:])
if e != nil {
return nil, errors.New("IMAGE_DECODE_FAILED")
}
return raw, nil
}

187
images_test.go Normal file
View File

@@ -0,0 +1,187 @@
package main
import (
"bytes"
"encoding/base64"
"encoding/json"
"image"
"image/color"
"image/png"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
// pngDataURL builds a w-by-h PNG dataURL; opaque=false adds transparency
// so tests can hit both the JPEG (opaque) and PNG (alpha) encode branches.
func pngDataURL(t *testing.T, w, h int, opaque bool) string {
t.Helper()
img := image.NewNRGBA(image.Rect(0, 0, w, h))
a := uint8(255)
if !opaque {
a = 128
}
for x := 0; x < w; x++ {
for y := 0; y < h; y++ {
img.Set(x, y, color.NRGBA{R: 200, G: 90, B: 60, A: a})
}
}
var buf bytes.Buffer
if e := png.Encode(&buf, img); e != nil {
t.Fatal(e)
}
return "data:image/png;base64," + base64.StdEncoding.EncodeToString(buf.Bytes())
}
func TestSaveContentImageBase64Mode(t *testing.T) {
a := newSyncTestApp(t)
// Opaque large image: downscaled to contentImgMaxEdge and re-encoded as JPEG.
got, e := a.SaveContentImage(pngDataURL(t, 2200, 1100, true))
if e != nil {
t.Fatal(e)
}
if !strings.HasPrefix(got, "data:image/jpeg;base64,") {
t.Fatalf("opaque image should become jpeg dataURL: %.40s", got)
}
img := decodeDataURL(t, got)
if b := img.Bounds(); b.Dx() != contentImgMaxEdge {
t.Fatalf("expected width %d, got %d", contentImgMaxEdge, b.Dx())
}
// Semi-transparent image must stay PNG to keep the alpha channel.
got, e = a.SaveContentImage(pngDataURL(t, 40, 40, false))
if e != nil {
t.Fatal(e)
}
if !strings.HasPrefix(got, "data:image/png;base64,") {
t.Fatalf("transparent image should stay png: %.40s", got)
}
}
func TestSaveContentImagePathMode(t *testing.T) {
a := newSyncTestApp(t)
st, _ := a.store.Settings()
st.ImageMode = "path"
if e := a.store.SaveSettings(st); e != nil {
t.Fatal(e)
}
got, e := a.SaveContentImage(pngDataURL(t, 64, 64, true))
if e != nil {
t.Fatal(e)
}
if strings.Contains(got, "\\") || !strings.Contains(got, "/images/img-") {
t.Fatalf("path mode should return forward-slash path in images dir: %s", got)
}
if _, e := os.Stat(filepath.FromSlash(got)); e != nil {
t.Fatalf("saved image file missing: %v", e)
}
// Files written in path mode must be readable back as dataURL for rendering.
dataURL, e := a.ReadContentImageAsDataURL(filepath.FromSlash(got))
if e != nil {
t.Fatal(e)
}
if !strings.HasPrefix(dataURL, "data:image/") {
t.Fatalf("unexpected dataURL: %.40s", dataURL)
}
}
func TestSaveContentImageRejectsBadInput(t *testing.T) {
a := newSyncTestApp(t)
for _, bad := range []string{"", "hello", "data:text/plain;base64,aGk=", "data:image/png;base64,!!!"} {
if _, e := a.SaveContentImage(bad); e == nil {
t.Fatalf("input %q should fail", bad)
}
}
}
func TestSettingsImageModeRoundTrip(t *testing.T) {
s, e := OpenStore(filepath.Join(t.TempDir(), "im.db"))
if e != nil {
t.Fatal(e)
}
defer s.db.Close()
st, _ := s.Settings()
if st.ImageMode != "base64" {
t.Fatalf("default should be base64, got %q", st.ImageMode)
}
st.ImageMode = "path"
if e = s.SaveSettings(st); e != nil {
t.Fatal(e)
}
st, _ = s.Settings()
if st.ImageMode != "path" {
t.Fatalf("imageMode not persisted, got %q", st.ImageMode)
}
// server 是合法模式(上传到 nl-pms-api
st.ImageMode = "server"
if e = s.SaveSettings(st); e != nil {
t.Fatal(e)
}
st, _ = s.Settings()
if st.ImageMode != "server" {
t.Fatalf("server mode should persist, got %q", st.ImageMode)
}
// Invalid value falls back to the default.
st.ImageMode = "oss"
if e = s.SaveSettings(st); e != nil {
t.Fatal(e)
}
st, _ = s.Settings()
if st.ImageMode != "base64" {
t.Fatalf("invalid mode should fall back to base64, got %q", st.ImageMode)
}
}
// TestSaveContentImageServerMode 用本地 httptest 假扮 nl-pms-api
// server 模式下内容图上传成功返回 http URL服务器不可达时报错而非静默降级。
func TestSaveContentImageServerMode(t *testing.T) {
a := newSyncTestApp(t)
var gotAuth, gotKind string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/api/v1/files" {
http.NotFound(w, r)
return
}
gotAuth = r.Header.Get("Authorization")
if e := r.ParseMultipartForm(32 << 20); e != nil {
http.Error(w, "bad form", 400)
return
}
gotKind = r.FormValue("kind")
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"id":1,"name":"2026/08/13/abc.jpg","url":"` + serverBase(r) + `/files/2026/08/13/abc.jpg"}`))
}))
defer srv.Close()
cfg, _ := json.Marshal(FileStorageConfig{Mode: "server", BaseURL: srv.URL, APIKey: "k1"})
if e := a.store.SetMeta(fileStorageKey, string(cfg)); e != nil {
t.Fatal(e)
}
st, _ := a.store.Settings()
st.ImageMode = "server"
if e := a.store.SaveSettings(st); e != nil {
t.Fatal(e)
}
got, e := a.SaveContentImage(pngDataURL(t, 64, 64, true))
if e != nil {
t.Fatal(e)
}
if !strings.HasPrefix(got, "http") || !strings.Contains(got, "/files/") {
t.Fatalf("server mode should return http url, got %q", got)
}
if gotAuth != "Bearer k1" {
t.Fatalf("missing bearer key, got %q", gotAuth)
}
if gotKind != "content" {
t.Fatalf("kind should be content, got %q", gotKind)
}
// 服务器关闭后上传必须报错(不能静默转 base64
srv.Close()
if _, e := a.SaveContentImage(pngDataURL(t, 32, 32, true)); e == nil {
t.Fatal("upload to dead server should fail")
}
}
func serverBase(r *http.Request) string { return "http://" + r.Host }

211
init.sql Normal file
View File

@@ -0,0 +1,211 @@
-- ============================================================
-- 年糕崽崽项目管理PMS同步服务初始化脚本MySQL 5.7+ / 8.x
-- 用法mysql -u root -p < init.sql
-- 应用不执行任何建表/迁移DDL使用同步功能前必须先执行本脚本
-- 应用连接账号只需要对 code_count 库的 SELECT/INSERT/UPDATE 权限。
-- 默认账号liqi / qiqi991012bcrypt 哈希存储,可在应用内注册新账号)
-- ============================================================
CREATE DATABASE IF NOT EXISTS code_count DEFAULT CHARSET utf8mb4;
USE code_count;
-- 应用账号(密码为 bcrypt 哈希)
CREATE TABLE IF NOT EXISTS users(
id BIGINT PRIMARY KEY AUTO_INCREMENT,
username VARCHAR(64) NOT NULL UNIQUE,
password_hash VARCHAR(100) NOT NULL,
created_at VARCHAR(32) NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Todo 同步表(按 user_id 隔离LWW 以 updated_at 判定)
-- history 为生命周期轨迹 JSON[{"status":"open","at":"..."},...],记录每次进入某状态的时间
-- team_id>0 表示该条已共享给对应团队团队管理员可见0 为私密
CREATE TABLE IF NOT EXISTS sync_todos(
user_id BIGINT NOT NULL,
uuid CHAR(36) NOT NULL,
title TEXT NOT NULL,
content MEDIUMTEXT NOT NULL,
project_name VARCHAR(255) NOT NULL DEFAULT '',
due_at VARCHAR(32) NOT NULL DEFAULT '',
priority VARCHAR(16) NOT NULL DEFAULT 'medium',
status VARCHAR(16) NOT NULL DEFAULT 'open',
history MEDIUMTEXT NOT NULL,
team_id BIGINT NOT NULL DEFAULT 0,
created_at VARCHAR(32) NOT NULL DEFAULT '',
updated_at VARCHAR(32) NOT NULL,
deleted TINYINT NOT NULL DEFAULT 0,
PRIMARY KEY(user_id, uuid),
KEY idx_sync_todos_updated(user_id, updated_at),
KEY idx_sync_todos_team(team_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 工单同步表
CREATE TABLE IF NOT EXISTS sync_tickets(
user_id BIGINT NOT NULL,
uuid CHAR(36) NOT NULL,
title TEXT NOT NULL,
description MEDIUMTEXT NOT NULL,
type VARCHAR(16) NOT NULL DEFAULT 'task',
project_name VARCHAR(255) NOT NULL DEFAULT '',
start_at VARCHAR(32) NOT NULL DEFAULT '',
due_at VARCHAR(32) NOT NULL DEFAULT '',
status VARCHAR(16) NOT NULL DEFAULT 'open',
priority VARCHAR(16) NOT NULL DEFAULT 'medium',
history MEDIUMTEXT NOT NULL,
team_id BIGINT NOT NULL DEFAULT 0,
created_at VARCHAR(32) NOT NULL DEFAULT '',
updated_at VARCHAR(32) NOT NULL,
deleted TINYINT NOT NULL DEFAULT 0,
PRIMARY KEY(user_id, uuid),
KEY idx_sync_tickets_updated(user_id, updated_at),
KEY idx_sync_tickets_team(team_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 记事本同步表
CREATE TABLE IF NOT EXISTS sync_notes(
user_id BIGINT NOT NULL,
uuid CHAR(36) NOT NULL,
content MEDIUMTEXT NOT NULL,
updated_at VARCHAR(32) NOT NULL,
deleted TINYINT NOT NULL DEFAULT 0,
PRIMARY KEY(user_id, uuid),
KEY idx_sync_notes_updated(user_id, updated_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 按用户存储的同步设置(如加密盐 enc_salt、加密后的 AI API Key api_keys
-- api_keys 的值为 AES-256-GCM 密文(密钥由登录密码派生),服务器无法解密。
-- 另有全局资源行:日历节日背景图存为 fest_img:<节日名>,统一挂在管理员账号
-- id=1名下 —— 管理员在应用内上传推送,所有账号登录后拉取展示。
CREATE TABLE IF NOT EXISTS sync_settings(
user_id BIGINT NOT NULL,
name VARCHAR(64) NOT NULL,
value MEDIUMTEXT NOT NULL,
updated_at VARCHAR(32) NOT NULL,
PRIMARY KEY(user_id, name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 旧版本升级:早期脚本建的 content/description 为 TEXT64KB
-- 待办/工单内容支持 Markdown 内嵌图片后需要 MEDIUMTEXT重复执行无副作用。
ALTER TABLE sync_todos MODIFY content MEDIUMTEXT NOT NULL;
ALTER TABLE sync_tickets MODIFY description MEDIUMTEXT NOT NULL;
-- 旧版本升级:补 history 生命周期列(不存在时才添加,重复执行无副作用)。
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA='code_count' AND TABLE_NAME='sync_todos' AND COLUMN_NAME='history')=0,
'ALTER TABLE sync_todos ADD COLUMN history MEDIUMTEXT NOT NULL AFTER status', 'SELECT 1');
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA='code_count' AND TABLE_NAME='sync_tickets' AND COLUMN_NAME='history')=0,
'ALTER TABLE sync_tickets ADD COLUMN history MEDIUMTEXT NOT NULL AFTER priority', 'SELECT 1');
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
-- ============================================================
-- 团队协作v2.1):资料 / 团队 / 团队任务 / 日报 / 通知
-- ============================================================
-- 用户公开资料(同服务器用户互相可见:昵称/头衔/技术栈标签/头像缩略图)
CREATE TABLE IF NOT EXISTS user_profiles(
user_id BIGINT PRIMARY KEY,
nickname VARCHAR(64) NOT NULL DEFAULT '',
title VARCHAR(64) NOT NULL DEFAULT '',
email VARCHAR(128) NOT NULL DEFAULT '',
bio VARCHAR(500) NOT NULL DEFAULT '',
tech_tags VARCHAR(1000) NOT NULL DEFAULT '[]',
avatar_thumb MEDIUMTEXT NOT NULL,
updated_at VARCHAR(32) NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 团队digest_time日报 AI 摘要自动生成时间 HH:MM
CREATE TABLE IF NOT EXISTS teams(
id BIGINT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(64) NOT NULL,
owner_id BIGINT NOT NULL,
digest_time VARCHAR(8) NOT NULL DEFAULT '21:00',
created_at VARCHAR(32) NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 团队成员role: owner | admin | member
CREATE TABLE IF NOT EXISTS team_members(
team_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
role VARCHAR(16) NOT NULL DEFAULT 'member',
joined_at VARCHAR(32) NOT NULL,
PRIMARY KEY(team_id, user_id),
KEY idx_team_members_user(user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 团队任务/工单服务器唯一真相在线操作kind: todo | ticket
CREATE TABLE IF NOT EXISTS team_tasks(
id BIGINT PRIMARY KEY AUTO_INCREMENT,
team_id BIGINT NOT NULL,
kind VARCHAR(16) NOT NULL DEFAULT 'todo',
title TEXT NOT NULL,
description MEDIUMTEXT NOT NULL,
priority VARCHAR(16) NOT NULL DEFAULT 'medium',
status VARCHAR(16) NOT NULL DEFAULT 'open',
creator_id BIGINT NOT NULL,
assignee_id BIGINT NOT NULL DEFAULT 0,
start_at VARCHAR(32) NOT NULL DEFAULT '',
due_at VARCHAR(32) NOT NULL DEFAULT '',
urged_at VARCHAR(32) NOT NULL DEFAULT '',
history MEDIUMTEXT NOT NULL,
updated_at VARCHAR(32) NOT NULL,
deleted TINYINT NOT NULL DEFAULT 0,
KEY idx_team_tasks_team(team_id, deleted),
KEY idx_team_tasks_assignee(assignee_id, deleted)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 团队日报date 为本地日期 YYYY-MM-DD
CREATE TABLE IF NOT EXISTS team_reports(
team_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
date CHAR(10) NOT NULL,
content MEDIUMTEXT NOT NULL,
submitted_at VARCHAR(32) NOT NULL,
PRIMARY KEY(team_id, user_id, date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 团队日报 AI 摘要(管理员客户端生成,全员可见)
CREATE TABLE IF NOT EXISTS team_digests(
team_id BIGINT NOT NULL,
date CHAR(10) NOT NULL,
content MEDIUMTEXT NOT NULL,
provider VARCHAR(32) NOT NULL DEFAULT '',
generated_at VARCHAR(32) NOT NULL,
PRIMARY KEY(team_id, date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 团队通知(指派/催办/成员变动等;客户端同步时按 to_user 增量拉取转本地消息)
CREATE TABLE IF NOT EXISTS team_notices(
id BIGINT PRIMARY KEY AUTO_INCREMENT,
team_id BIGINT NOT NULL,
to_user BIGINT NOT NULL,
from_user BIGINT NOT NULL,
kind VARCHAR(16) NOT NULL,
ref_id VARCHAR(64) NOT NULL DEFAULT '',
content TEXT NOT NULL,
created_at VARCHAR(32) NOT NULL,
KEY idx_team_notices_to(to_user, id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 旧版本升级:个人待办/工单支持按条共享到团队team_id=0 私密;重复执行无副作用)。
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA='code_count' AND TABLE_NAME='sync_todos' AND COLUMN_NAME='team_id')=0,
'ALTER TABLE sync_todos ADD COLUMN team_id BIGINT NOT NULL DEFAULT 0 AFTER history', 'SELECT 1');
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA='code_count' AND TABLE_NAME='sync_tickets' AND COLUMN_NAME='team_id')=0,
'ALTER TABLE sync_tickets ADD COLUMN team_id BIGINT NOT NULL DEFAULT 0 AFTER history', 'SELECT 1');
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
SET @sql = IF((SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA='code_count' AND TABLE_NAME='sync_todos' AND INDEX_NAME='idx_sync_todos_team')=0,
'ALTER TABLE sync_todos ADD KEY idx_sync_todos_team(team_id)', 'SELECT 1');
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
SET @sql = IF((SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA='code_count' AND TABLE_NAME='sync_tickets' AND INDEX_NAME='idx_sync_tickets_team')=0,
'ALTER TABLE sync_tickets ADD KEY idx_sync_tickets_team(team_id)', 'SELECT 1');
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
-- 默认账号 liqi密码 qiqi991012 的 bcrypt 哈希);已存在则跳过,不会覆盖改过的密码
INSERT IGNORE INTO users(username, password_hash, created_at)
VALUES ('liqi', '$2a$10$XWBtGPu9xYRyr8diFEfvBeEHRkO6pa3CDduE09OurJztuXVcJZOB2', '2026-08-11T13:20:00Z');

428
launchpad.go Normal file
View File

@@ -0,0 +1,428 @@
package main
import (
"context"
"errors"
"os"
"os/exec"
"runtime"
"sort"
"strconv"
"strings"
"sync"
"time"
gnet "github.com/shirou/gopsutil/v4/net"
"github.com/shirou/gopsutil/v4/process"
"view/platform"
)
// ---------------- 启动台:本机监听端口扫描 + 应用启停 ----------------
// launchSample 是进程资源的上一次采样,用于差值计算瞬时 CPU / IO 速率。
type launchSample struct {
at time.Time
cpuTotal float64
ioBytes uint64
}
var (
lpSampleMu sync.Mutex
lpSamples = map[int32]launchSample{}
)
// ---------- Store ----------
func (s *Store) ListLaunchApps() ([]LaunchApp, error) {
rows, e := s.db.Query(`SELECT id,name,kind,port,dir,start_cmd,stop_cmd,last_pid,created_at,updated_at FROM launch_apps ORDER BY id`)
if e != nil {
return nil, e
}
defer rows.Close()
out := []LaunchApp{}
for rows.Next() {
var x LaunchApp
if e = rows.Scan(&x.ID, &x.Name, &x.Kind, &x.Port, &x.Dir, &x.StartCmd, &x.StopCmd, &x.LastPID, &x.CreatedAt, &x.UpdatedAt); e != nil {
return nil, e
}
out = append(out, x)
}
return out, rows.Err()
}
func (s *Store) GetLaunchApp(id int64) (LaunchApp, error) {
var x LaunchApp
e := s.db.QueryRow(`SELECT id,name,kind,port,dir,start_cmd,stop_cmd,last_pid,created_at,updated_at FROM launch_apps WHERE id=?`, id).
Scan(&x.ID, &x.Name, &x.Kind, &x.Port, &x.Dir, &x.StartCmd, &x.StopCmd, &x.LastPID, &x.CreatedAt, &x.UpdatedAt)
return x, e
}
func (s *Store) SaveLaunchApp(in LaunchApp) (LaunchApp, error) {
in.Name = strings.TrimSpace(in.Name)
if in.Name == "" {
return in, errors.New("NAME_REQUIRED")
}
if in.Kind == "" {
in.Kind = "other"
}
now := nowRFC()
if in.ID == 0 {
res, e := s.db.Exec(`INSERT INTO launch_apps(name,kind,port,dir,start_cmd,stop_cmd,last_pid,created_at,updated_at) VALUES(?,?,?,?,?,?,0,?,?)`,
in.Name, in.Kind, in.Port, in.Dir, in.StartCmd, in.StopCmd, now, now)
if e != nil {
return in, e
}
in.ID, _ = res.LastInsertId()
in.CreatedAt, in.UpdatedAt = now, now
return in, nil
}
_, e := s.db.Exec(`UPDATE launch_apps SET name=?,kind=?,port=?,dir=?,start_cmd=?,stop_cmd=?,updated_at=? WHERE id=?`,
in.Name, in.Kind, in.Port, in.Dir, in.StartCmd, in.StopCmd, now, in.ID)
if e != nil {
return in, e
}
return s.GetLaunchApp(in.ID)
}
func (s *Store) DeleteLaunchApp(id int64) error {
_, e := s.db.Exec(`DELETE FROM launch_apps WHERE id=?`, id)
return e
}
func (s *Store) setLaunchPID(id, pid int64) error {
_, e := s.db.Exec(`UPDATE launch_apps SET last_pid=?,updated_at=? WHERE id=?`, pid, nowRFC(), id)
return e
}
// ---------- 种类推断与命令推荐 ----------
// inferLaunchKind 根据进程名 / 可执行路径 / 命令行猜项目种类。
func inferLaunchKind(name, exe, cmdline string) string {
s := strings.ToLower(name + " " + exe + " " + cmdline)
switch {
case strings.Contains(s, "mysqld"):
return "mysql"
case strings.Contains(s, "redis"):
return "redis"
case strings.Contains(s, "nginx"):
return "nginx"
case strings.Contains(s, "node") || strings.Contains(s, "vite") || strings.Contains(s, "webpack") || strings.Contains(s, "npm") || strings.Contains(s, "pnpm") || strings.Contains(s, "yarn"):
return "node"
case strings.Contains(s, "javaw") || strings.Contains(s, "java ") || strings.HasSuffix(strings.TrimSpace(s), "java") || strings.Contains(s, "java.exe"):
return "java"
case strings.Contains(s, "python") || strings.Contains(s, "uvicorn") || strings.Contains(s, "gunicorn") || strings.Contains(s, "flask") || strings.Contains(s, "django"):
return "python"
case strings.Contains(s, "php"):
return "php"
case strings.Contains(s, "dotnet") || strings.Contains(s, "iisexpress") || strings.Contains(s, "w3wp"):
return "dotnet"
case strings.Contains(s, "go.exe") || strings.Contains(s, "go run") || strings.Contains(s, "__debug_bin"):
return "go"
case strings.Contains(s, "httpd") || strings.Contains(s, "apache") || strings.Contains(s, "caddy"):
return "web"
default:
return "other"
}
}
// launchSuggestions 按种类推荐启停命令;停止命令留空表示直接结束进程树。
var launchSuggestions = map[string]LaunchSuggest{
"node": {Start: []string{"npm run dev", "npm start", "pnpm dev", "yarn dev"}, Stop: nil},
"go": {Start: []string{"go run .", "go run main.go"}, Stop: nil},
"python": {Start: []string{"python main.py", "uvicorn main:app --reload", "python manage.py runserver"}, Stop: nil},
"java": {Start: []string{"mvn spring-boot:run", "java -jar app.jar", "gradle bootRun"}, Stop: nil},
"php": {Start: []string{"php artisan serve", "php -S 127.0.0.1:8000"}, Stop: nil},
"dotnet": {Start: []string{"dotnet run", "dotnet watch"}, Stop: nil},
"nginx": {Start: []string{"nginx"}, Stop: []string{"nginx -s stop", "nginx -s quit"}},
"mysql": {Start: []string{"net start mysql", "mysqld --console"}, Stop: []string{"net stop mysql", "mysqladmin -uroot shutdown"}},
"redis": {Start: []string{"redis-server"}, Stop: []string{"redis-cli shutdown"}},
"web": {Start: []string{"caddy run"}, Stop: []string{"caddy stop"}},
"other": {},
}
func (a *App) LaunchCmdSuggest(kind string) LaunchSuggest {
if s, ok := launchSuggestions[kind]; ok {
return s
}
return LaunchSuggest{}
}
// ---------- 扫描 ----------
// scanListenPorts 返回 pid -> 去重后的监听端口列表。
func scanListenPorts() (map[int32][]int, error) {
conns, e := gnet.Connections("tcp")
if e != nil {
return nil, e
}
seen := map[int32]map[int]bool{}
for _, c := range conns {
if c.Status != "LISTEN" || c.Pid <= 0 {
continue
}
if seen[c.Pid] == nil {
seen[c.Pid] = map[int]bool{}
}
seen[c.Pid][int(c.Laddr.Port)] = true
}
out := map[int32][]int{}
for pid, ports := range seen {
lst := make([]int, 0, len(ports))
for p := range ports {
lst = append(lst, p)
}
sort.Ints(lst)
out[pid] = lst
}
return out, nil
}
// probeProcess 读取进程静态信息与资源占用CPU/IO 用与上次采样的差值算速率)。
func probeProcess(pid int32) (name, exe, cmdline string, cpu, memMB, ioKBs float64) {
p, e := process.NewProcess(pid)
if e != nil {
return
}
name, _ = p.Name()
exe, _ = p.Exe()
cmdline, _ = p.Cmdline()
if mi, e := p.MemoryInfo(); e == nil && mi != nil {
memMB = float64(mi.RSS) / 1024 / 1024
}
now := time.Now()
var cpuTotal float64
if ts, e := p.Times(); e == nil && ts != nil {
cpuTotal = ts.User + ts.System
}
var ioBytes uint64
if io, e := p.IOCounters(); e == nil && io != nil {
ioBytes = io.ReadBytes + io.WriteBytes
}
lpSampleMu.Lock()
prev, ok := lpSamples[pid]
lpSamples[pid] = launchSample{at: now, cpuTotal: cpuTotal, ioBytes: ioBytes}
lpSampleMu.Unlock()
if ok {
wall := now.Sub(prev.at).Seconds()
if wall > 0.2 {
cpu = (cpuTotal - prev.cpuTotal) / wall * 100 / float64(runtime.NumCPU())
if cpu < 0 {
cpu = 0
}
if ioBytes >= prev.ioBytes {
ioKBs = float64(ioBytes-prev.ioBytes) / wall / 1024
}
}
}
return
}
// ListLaunchEntries 合并「保存的应用」与「实时扫描到的监听进程」。
func (a *App) ListLaunchEntries() ([]LaunchEntry, error) {
if e := a.ready(); e != nil {
return nil, e
}
apps, e := a.store.ListLaunchApps()
if e != nil {
return nil, e
}
byPid, e := scanListenPorts()
if e != nil {
a.store.Log("warning", "启动台", "端口扫描失败", e.Error())
byPid = map[int32][]int{}
}
self := int32(os.Getpid())
used := map[int32]bool{}
findByPort := func(port int) int32 {
if port <= 0 {
return 0
}
for pid, ports := range byPid {
if used[pid] || pid == self {
continue
}
for _, p := range ports {
if p == port {
return pid
}
}
}
return 0
}
out := []LaunchEntry{}
for _, app := range apps {
ent := LaunchEntry{ID: app.ID, Name: app.Name, Kind: app.Kind, Port: app.Port, Dir: app.Dir, StartCmd: app.StartCmd, StopCmd: app.StopCmd}
pid := int32(0)
if app.LastPID > 0 {
if _, ok := byPid[int32(app.LastPID)]; ok && !used[int32(app.LastPID)] {
pid = int32(app.LastPID)
}
}
if pid == 0 {
pid = findByPort(app.Port)
}
if pid > 0 {
used[pid] = true
ent.Running, ent.PID, ent.Ports = true, pid, byPid[pid]
var name string
name, ent.Exe, ent.Cmdline, ent.CPU, ent.MemMB, ent.IOKBs = probeProcess(pid)
if ent.Kind == "other" || ent.Kind == "" {
ent.Kind = inferLaunchKind(name, ent.Exe, ent.Cmdline)
}
}
out = append(out, ent)
}
scanned := []LaunchEntry{}
for pid, ports := range byPid {
if used[pid] || pid == self {
continue
}
name, exe, cmdline, cpu, memMB, ioKBs := probeProcess(pid)
if name == "" {
name = "PID " + strconv.Itoa(int(pid))
}
ent := LaunchEntry{
Name: name, Kind: inferLaunchKind(name, exe, cmdline),
Running: true, PID: pid, Exe: exe, Cmdline: cmdline, Ports: ports,
CPU: cpu, MemMB: memMB, IOKBs: ioKBs,
}
if len(ports) > 0 {
ent.Port = ports[0]
}
scanned = append(scanned, ent)
}
sort.Slice(scanned, func(i, j int) bool { return scanned[i].Port < scanned[j].Port })
return append(out, scanned...), nil
}
// ---------- 应用增删与启停 ----------
func (a *App) ListLaunchApps() ([]LaunchApp, error) {
if e := a.ready(); e != nil {
return nil, e
}
return a.store.ListLaunchApps()
}
func (a *App) SaveLaunchApp(in LaunchApp) (LaunchApp, error) {
if e := a.ready(); e != nil {
return in, e
}
out, e := a.store.SaveLaunchApp(in)
if e == nil {
a.emit("launchpad:changed", nil)
}
return out, e
}
func (a *App) DeleteLaunchApp(id int64) error {
if e := a.ready(); e != nil {
return e
}
e := a.store.DeleteLaunchApp(id)
if e == nil {
a.emit("launchpad:changed", nil)
}
return e
}
// shellCommand 把一行命令交给系统 shell 解释执行。
func shellCommand(line string) *exec.Cmd {
if runtime.GOOS == "windows" {
return exec.Command("cmd", "/C", line)
}
return exec.Command("sh", "-c", line)
}
// StartLaunchApp 在应用目录以独立进程组执行启动命令,并记录 PID 供停止时杀进程树。
func (a *App) StartLaunchApp(id int64) (LaunchApp, error) {
if e := a.ready(); e != nil {
return LaunchApp{}, e
}
app, e := a.store.GetLaunchApp(id)
if e != nil {
return app, errors.New("LAUNCH_APP_NOT_FOUND")
}
if strings.TrimSpace(app.StartCmd) == "" {
return app, errors.New("LAUNCH_CMD_REQUIRED")
}
cmd := shellCommand(app.StartCmd)
if st, err := os.Stat(app.Dir); err == nil && st.IsDir() {
cmd.Dir = app.Dir
}
platform.ConfigureDetached(cmd)
if e = cmd.Start(); e != nil {
a.store.Log("error", "启动台", "启动失败:"+app.Name, e.Error())
return app, e
}
pid := int64(cmd.Process.Pid)
_ = cmd.Process.Release()
_ = a.store.setLaunchPID(app.ID, pid)
app.LastPID = pid
a.store.Log("info", "启动台", "已启动 "+app.Name, "pid="+strconv.FormatInt(pid, 10)+" cmd="+app.StartCmd)
a.emit("launchpad:changed", nil)
return app, nil
}
// killProcessTree 结束进程及其全部子进程。
func killProcessTree(ctx context.Context, pid int32) error {
if runtime.GOOS == "windows" {
_, e := platform.RunHidden(ctx, "taskkill", "/T", "/F", "/PID", strconv.Itoa(int(pid)))
return e
}
_, e := platform.RunHidden(ctx, "kill", "-15", strconv.Itoa(int(pid)))
return e
}
// StopLaunchApp 停止应用:优先执行停止命令,否则结束记录/指定的进程树。
// pid 参数供未保存的扫描条目直接停止。
func (a *App) StopLaunchApp(id int64, pid int32) error {
if e := a.ready(); e != nil {
return e
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
var app LaunchApp
if id > 0 {
var e error
if app, e = a.store.GetLaunchApp(id); e != nil {
return errors.New("LAUNCH_APP_NOT_FOUND")
}
if strings.TrimSpace(app.StopCmd) != "" {
cmd := shellCommand(app.StopCmd)
if st, err := os.Stat(app.Dir); err == nil && st.IsDir() {
cmd.Dir = app.Dir
}
platform.ConfigureDetached(cmd)
if e := cmd.Start(); e != nil {
a.store.Log("error", "启动台", "停止命令执行失败:"+app.Name, e.Error())
return e
}
_ = cmd.Process.Release()
_ = a.store.setLaunchPID(app.ID, 0)
a.store.Log("info", "启动台", "已执行停止命令:"+app.Name, app.StopCmd)
a.emit("launchpad:changed", nil)
return nil
}
if pid <= 0 && app.LastPID > 0 {
pid = int32(app.LastPID)
}
}
if pid <= 0 {
return errors.New("LAUNCH_PID_REQUIRED")
}
if int(pid) == os.Getpid() {
return errors.New("LAUNCH_SELF_FORBIDDEN")
}
if e := killProcessTree(ctx, pid); e != nil {
a.store.Log("error", "启动台", "结束进程失败 pid="+strconv.Itoa(int(pid)), e.Error())
return e
}
if id > 0 {
_ = a.store.setLaunchPID(id, 0)
}
a.store.Log("info", "启动台", "已结束进程", "pid="+strconv.Itoa(int(pid)))
a.emit("launchpad:changed", nil)
return nil
}

92
launchpad_test.go Normal file
View File

@@ -0,0 +1,92 @@
package main
import (
"path/filepath"
"testing"
)
func TestInferLaunchKind(t *testing.T) {
cases := []struct {
name, exe, cmdline, want string
}{
{"node.exe", `C:\nodejs\node.exe`, "node vite dev", "node"},
{"mysqld.exe", `C:\mysql\bin\mysqld.exe`, "mysqld --console", "mysql"},
{"redis-server", "/usr/bin/redis-server", "redis-server *:6379", "redis"},
{"nginx.exe", `C:\nginx\nginx.exe`, "nginx", "nginx"},
{"java.exe", `C:\jdk\bin\java.exe`, "java -jar app.jar", "java"},
{"python.exe", `C:\py\python.exe`, "uvicorn main:app", "python"},
{"php-cgi.exe", `C:\php\php-cgi.exe`, "", "php"},
{"dotnet.exe", `C:\dotnet\dotnet.exe`, "dotnet run", "dotnet"},
{"main.exe", `C:\Temp\go-build\__debug_bin.exe`, "", "go"},
{"svchost.exe", `C:\Windows\svchost.exe`, "-k netsvcs", "other"},
}
for _, c := range cases {
if got := inferLaunchKind(c.name, c.exe, c.cmdline); got != c.want {
t.Errorf("inferLaunchKind(%q)=%q want %q", c.name, got, c.want)
}
}
}
func TestLaunchCmdSuggest(t *testing.T) {
a := &App{}
if s := a.LaunchCmdSuggest("node"); len(s.Start) == 0 {
t.Fatal("node suggestions empty")
}
if s := a.LaunchCmdSuggest("mysql"); len(s.Stop) == 0 {
t.Fatal("mysql stop suggestions empty")
}
if s := a.LaunchCmdSuggest("unknown-kind"); len(s.Start) != 0 || len(s.Stop) != 0 {
t.Fatal("unknown kind should be empty")
}
}
func TestLaunchAppCRUD(t *testing.T) {
s, e := OpenStore(filepath.Join(t.TempDir(), "lp.db"))
if e != nil {
t.Fatal(e)
}
defer s.db.Close()
if _, e := s.SaveLaunchApp(LaunchApp{Name: " "}); e == nil {
t.Fatal("blank name accepted")
}
app, e := s.SaveLaunchApp(LaunchApp{Name: "demo-api", Port: 8080, Dir: t.TempDir(), StartCmd: "npm run dev"})
if e != nil || app.ID == 0 || app.Kind != "other" {
t.Fatalf("create: %#v %v", app, e)
}
app.Kind, app.StopCmd = "node", "npx kill-port 8080"
app2, e := s.SaveLaunchApp(app)
if e != nil || app2.Kind != "node" || app2.StopCmd != "npx kill-port 8080" {
t.Fatalf("update: %#v %v", app2, e)
}
if e := s.setLaunchPID(app.ID, 4321); e != nil {
t.Fatal(e)
}
got, e := s.GetLaunchApp(app.ID)
if e != nil || got.LastPID != 4321 {
t.Fatalf("get: %#v %v", got, e)
}
lst, e := s.ListLaunchApps()
if e != nil || len(lst) != 1 {
t.Fatalf("list: %d %v", len(lst), e)
}
if e := s.DeleteLaunchApp(app.ID); e != nil {
t.Fatal(e)
}
if lst, _ = s.ListLaunchApps(); len(lst) != 0 {
t.Fatalf("after delete: %d", len(lst))
}
}
// TestScanListenPorts 冒烟:真机上应能扫出至少 0 个监听进程且不报错。
func TestScanListenPorts(t *testing.T) {
m, e := scanListenPorts()
if e != nil {
t.Fatalf("scan failed: %v", e)
}
for pid, ports := range m {
if pid <= 0 || len(ports) == 0 {
t.Fatalf("bad entry pid=%d ports=%v", pid, ports)
}
}
t.Logf("scanned %d listening processes", len(m))
}

21
machineid_other.go Normal file
View File

@@ -0,0 +1,21 @@
//go:build !windows
package main
import (
"os"
"strings"
)
// rawMachineID 读取 Linux/macOS 的机器标识;读不到时返回空串由调用方兜底。
func rawMachineID() string {
for _, p := range []string{"/etc/machine-id", "/var/lib/dbus/machine-id"} {
if b, e := os.ReadFile(p); e == nil {
if v := strings.TrimSpace(string(b)); v != "" {
return v
}
}
}
// macOS 没有 machine-id 文件,退回 IOPlatformUUID 太重,直接用主机名兜底(由调用方哈希)。
return ""
}

19
machineid_windows.go Normal file
View File

@@ -0,0 +1,19 @@
//go:build windows
package main
import "golang.org/x/sys/windows/registry"
// rawMachineID 读取 Windows 的 MachineGuid系统安装时生成重装才会变
func rawMachineID() string {
k, e := registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\Microsoft\Cryptography`, registry.QUERY_VALUE|registry.WOW64_64KEY)
if e != nil {
return ""
}
defer k.Close()
v, _, e := k.GetStringValue("MachineGuid")
if e != nil {
return ""
}
return v
}

173
profile.go Normal file
View File

@@ -0,0 +1,173 @@
package main
// profile.go 用户公开资料:昵称/头衔/邮箱/简介/技术栈标签 + 头像缩略图。
// 本地存 meta离线可编辑登录同步时按 LWW 与远端 user_profiles 表推拉,
// 供同服务器的团队成员互相查看(表结构见 init.sql
import (
"context"
"database/sql"
"encoding/base64"
"encoding/json"
"errors"
"os"
"strings"
)
type UserProfile struct {
Nickname string `json:"nickname"`
Title string `json:"title"`
Email string `json:"email"`
Bio string `json:"bio"`
TechTags []string `json:"techTags"`
UpdatedAt string `json:"updatedAt"`
}
// parseTechTags 解析技术栈标签 JSON 数组,坏数据回退空。
func parseTechTags(s string) []string {
out := []string{}
if e := json.Unmarshal([]byte(s), &out); e != nil {
return []string{}
}
return out
}
func sanitizeProfile(p *UserProfile) error {
trim := func(s string, max int) string {
s = strings.TrimSpace(s)
if r := []rune(s); len(r) > max {
return string(r[:max])
}
return s
}
p.Nickname = trim(p.Nickname, 32)
p.Title = trim(p.Title, 48)
p.Email = trim(p.Email, 128)
p.Bio = trim(p.Bio, 300)
if p.Email != "" && (!strings.Contains(p.Email, "@") || strings.ContainsAny(p.Email, " \t")) {
return errors.New("PROFILE_EMAIL_INVALID")
}
tags, seen := []string{}, map[string]bool{}
for _, t := range p.TechTags {
t = trim(t, 24)
if t == "" || seen[strings.ToLower(t)] {
continue
}
seen[strings.ToLower(t)] = true
tags = append(tags, t)
if len(tags) >= 20 {
break
}
}
p.TechTags = tags
return nil
}
func (a *App) myProfileLocal() UserProfile {
p := UserProfile{TechTags: []string{}}
raw := a.store.Meta("user_profile")
if raw != "" {
_ = json.Unmarshal([]byte(raw), &p)
}
if p.TechTags == nil {
p.TechTags = []string{}
}
return p
}
func (a *App) GetMyProfile() (UserProfile, error) {
if e := a.ready(); e != nil {
return UserProfile{}, e
}
return a.myProfileLocal(), nil
}
// SaveMyProfile 保存资料到本地并异步推送(登录状态下)。
func (a *App) SaveMyProfile(p UserProfile) (UserProfile, error) {
if e := a.ready(); e != nil {
return UserProfile{}, e
}
if e := sanitizeProfile(&p); e != nil {
return UserProfile{}, e
}
p.UpdatedAt = nowRFC()
raw, _ := json.Marshal(p)
if e := a.store.SetMeta("user_profile", string(raw)); e != nil {
return UserProfile{}, e
}
if a.syncUserID() > 0 {
go a.syncOnce(true)
}
return p, nil
}
// avatarThumb 生成成员列表用的小头像base64/path 源缩到 64pxurl 源原样返回。
func (a *App) avatarThumb() string {
st, e := a.store.Settings()
if e != nil {
return ""
}
switch st.AvatarMode {
case "url":
return st.AvatarValue
case "base64":
raw, e := dataURLBytes(st.AvatarValue)
if e != nil {
return ""
}
return encodeThumb(raw)
case "path":
raw, e := os.ReadFile(st.AvatarValue)
if e != nil || len(raw) > avatarMaxFileBytes {
return ""
}
return encodeThumb(raw)
}
return ""
}
func encodeThumb(raw []byte) string {
data, mime, e := encodeImageEdge(raw, 64)
if e != nil {
return ""
}
return "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(data)
}
// syncUserProfile 在同步循环中推拉公开资料LWW并保持头像缩略图最新。
// 表缺失(服务器未升级)时静默跳过。
func (a *App) syncUserProfile(ctx context.Context, db *sql.DB, userID int64) {
local := a.myProfileLocal()
var remote UserProfile
var remoteTags string
e := db.QueryRowContext(ctx, `SELECT nickname,title,email,bio,tech_tags,updated_at FROM user_profiles WHERE user_id=?`, userID).
Scan(&remote.Nickname, &remote.Title, &remote.Email, &remote.Bio, &remoteTags, &remote.UpdatedAt)
hasRemote := e == nil
if e != nil && e != sql.ErrNoRows {
return
}
switch {
case hasRemote && remote.UpdatedAt > local.UpdatedAt:
remote.TechTags = parseTechTags(remoteTags)
raw, _ := json.Marshal(remote)
_ = a.store.SetMeta("user_profile", string(raw))
case local.UpdatedAt != "" && (!hasRemote || local.UpdatedAt > remote.UpdatedAt):
tags, _ := json.Marshal(local.TechTags)
_, _ = db.ExecContext(ctx, `INSERT INTO user_profiles(user_id,nickname,title,email,bio,tech_tags,avatar_thumb,updated_at)
VALUES(?,?,?,?,?,?,?,?)
ON DUPLICATE KEY UPDATE nickname=VALUES(nickname),title=VALUES(title),email=VALUES(email),bio=VALUES(bio),
tech_tags=VALUES(tech_tags),avatar_thumb=VALUES(avatar_thumb),updated_at=VALUES(updated_at)`,
userID, local.Nickname, local.Title, local.Email, local.Bio, string(tags), a.avatarThumb(), local.UpdatedAt)
return // 全量推送已带最新头像
}
// 头像单独变更:只刷新缩略图列,不动 updated_at文本字段 LWW 不受影响)。
avAt := a.store.Meta("avatar_updated_at")
if avAt != "" && avAt != a.store.Meta("profile_avatar_pushed_at") {
if _, e := db.ExecContext(ctx, `INSERT INTO user_profiles(user_id,nickname,title,email,bio,tech_tags,avatar_thumb,updated_at)
VALUES(?,?,?,?,?,?,?,?)
ON DUPLICATE KEY UPDATE avatar_thumb=VALUES(avatar_thumb)`,
userID, local.Nickname, local.Title, local.Email, local.Bio, "[]", a.avatarThumb(), local.UpdatedAt); e == nil {
_ = a.store.SetMeta("profile_avatar_pushed_at", avAt)
}
}
}

55
search.go Normal file
View File

@@ -0,0 +1,55 @@
package main
// search.go 全局搜索Ctrl+K 命令面板):项目 / 待办 / 工单 / AI 会话各取前 8 条。
import "strings"
// escapeLike 转义 LIKE 通配符,配合 ESCAPE '\' 使用。
func escapeLike(s string) string {
r := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`)
return r.Replace(s)
}
func (a *App) GlobalSearch(q string) ([]SearchHit, error) {
if e := a.ready(); e != nil {
return nil, e
}
q = strings.TrimSpace(q)
out := []SearchHit{}
if q == "" {
return out, nil
}
pat := "%" + escapeLike(q) + "%"
type src struct {
kind, query string
args int // pat 占位数量
}
for _, s := range []src{
{"project", `SELECT id,name,path,'' FROM projects WHERE name LIKE ? ESCAPE '\' OR path LIKE ? ESCAPE '\' ORDER BY updated_at DESC LIMIT 8`, 2},
{"todo", `SELECT id,title,status,priority FROM todos WHERE deleted=0 AND (title LIKE ? ESCAPE '\' OR content LIKE ? ESCAPE '\') ORDER BY updated_at DESC LIMIT 8`, 2},
{"ticket", `SELECT id,title,status,priority FROM tickets WHERE deleted=0 AND (title LIKE ? ESCAPE '\' OR description LIKE ? ESCAPE '\') ORDER BY updated_at DESC LIMIT 8`, 2},
{"conversation", `SELECT id,title,provider,'' FROM ai_conversations WHERE title LIKE ? ESCAPE '\' ORDER BY updated_at DESC LIMIT 8`, 1},
} {
args := make([]any, s.args)
for i := range args {
args[i] = pat
}
rows, e := a.store.db.Query(s.query, args...)
if e != nil {
return nil, e
}
for rows.Next() {
h := SearchHit{Kind: s.kind}
if e := rows.Scan(&h.ID, &h.Title, &h.Sub, &h.Extra); e != nil {
rows.Close()
return nil, e
}
out = append(out, h)
}
rows.Close()
if e := rows.Err(); e != nil {
return nil, e
}
}
return out, nil
}

60
search_test.go Normal file
View File

@@ -0,0 +1,60 @@
package main
import (
"path/filepath"
"testing"
)
func TestGlobalSearch(t *testing.T) {
dir := t.TempDir()
s, e := OpenStore(filepath.Join(dir, "search.db"))
if e != nil {
t.Fatal(e)
}
defer s.db.Close()
a := NewApp()
a.store = s
if hits, e := a.GlobalSearch(" "); e != nil || len(hits) != 0 {
t.Fatalf("blank query should return empty, got %v err=%v", hits, e)
}
if _, e := s.SaveProject(0, ProjectInput{Name: "CodeCounter", Path: t.TempDir()}); e != nil {
t.Fatal(e)
}
if _, e := a.SaveTodo(Todo{Title: "写周报 counter 汇总"}); e != nil {
t.Fatal(e)
}
deleted, e := a.SaveTodo(Todo{Title: "counter 已删待办"})
if e != nil {
t.Fatal(e)
}
if e := a.DeleteTodo(deleted.ID); e != nil {
t.Fatal(e)
}
hits, e := a.GlobalSearch("counter")
if e != nil {
t.Fatal(e)
}
var nProj, nTodo int
for _, h := range hits {
switch h.Kind {
case "project":
nProj++
case "todo":
nTodo++
if h.Title == "counter 已删待办" {
t.Fatal("deleted todo must be excluded")
}
}
}
if nProj != 1 || nTodo != 1 {
t.Fatalf("want 1 project + 1 todo, got proj=%d todo=%d (%v)", nProj, nTodo, hits)
}
// LIKE 通配符按字面匹配:% 不应命中所有行。
if hits, e := a.GlobalSearch("%"); e != nil || len(hits) != 0 {
t.Fatalf("literal %% should not match, got %v err=%v", hits, e)
}
}

68
service/ai/ai_test.go Normal file
View File

@@ -0,0 +1,68 @@
package ai
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestFactory(t *testing.T) {
if _, e := New("bogus", "k"); e == nil || e.Error() != "AI_PROVIDER_INVALID" {
t.Fatalf("expected AI_PROVIDER_INVALID, got %v", e)
}
if _, e := New(ProviderSpark, ""); e == nil || e.Error() != "AI_KEY_MISSING" {
t.Fatalf("expected AI_KEY_MISSING, got %v", e)
}
p, e := New(ProviderDeepSeek, "key")
if e != nil || p.Name() != ProviderDeepSeek {
t.Fatalf("unexpected: %v %v", p, e)
}
p, e = New(ProviderSpark, "key")
if e != nil || p.Name() != ProviderSpark {
t.Fatalf("unexpected: %v %v", p, e)
}
}
func TestChatStreamSSE(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/chat/completions" {
t.Errorf("unexpected path %s", r.URL.Path)
}
if got := r.Header.Get("Authorization"); got != "Bearer test-key" {
t.Errorf("unexpected auth header %q", got)
}
w.Header().Set("Content-Type", "text/event-stream")
w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"你好\"}}]}\n\n"))
w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\",世界\"}}]}\n\n"))
w.Write([]byte("data: [DONE]\n\n"))
}))
defer srv.Close()
p := &openAICompatible{name: "test", baseURL: srv.URL, model: "m", apiKey: "test-key", client: srv.Client()}
stream, e := p.ChatStream(context.Background(), []Message{{Role: "user", Content: "hi"}})
if e != nil {
t.Fatal(e)
}
var sb strings.Builder
for c := range stream {
if c.Err != nil {
t.Fatal(c.Err)
}
sb.WriteString(c.Content)
}
if sb.String() != "你好,世界" {
t.Fatalf("unexpected content %q", sb.String())
}
}
func TestChatStreamAuthError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
}))
defer srv.Close()
p := &openAICompatible{name: "test", baseURL: srv.URL, model: "m", apiKey: "bad", client: srv.Client()}
if _, e := p.ChatStream(context.Background(), nil); e == nil || e.Error() != "AI_KEY_INVALID" {
t.Fatalf("expected AI_KEY_INVALID, got %v", e)
}
}

49
service/ai/factory.go Normal file
View File

@@ -0,0 +1,49 @@
package ai
import "errors"
// 支持的 Provider 标识。
const (
ProviderSpark = "spark"
ProviderDeepSeek = "deepseek"
)
// New 是 Provider 工厂:按标识与 API Key 创建对应实例。
func New(provider, apiKey string) (Provider, error) {
switch provider {
case ProviderSpark:
return newSpark(apiKey)
case ProviderDeepSeek:
return newDeepSeek(apiKey)
default:
return nil, errors.New("AI_PROVIDER_INVALID")
}
}
// newSpark 创建讯飞星火 LiteOpenAI 兼容 HTTP 端点APIPassword 鉴权Lite 免费)。
func newSpark(apiPassword string) (Provider, error) {
if apiPassword == "" {
return nil, errors.New("AI_KEY_MISSING")
}
return &openAICompatible{
name: ProviderSpark,
baseURL: "https://spark-api-open.xf-yun.com/v1",
model: "lite",
apiKey: apiPassword,
client: newHTTPClient(),
}, nil
}
// newDeepSeek 创建 DeepSeekOpenAI 兼容)。
func newDeepSeek(apiKey string) (Provider, error) {
if apiKey == "" {
return nil, errors.New("AI_KEY_MISSING")
}
return &openAICompatible{
name: ProviderDeepSeek,
baseURL: "https://api.deepseek.com",
model: "deepseek-chat",
apiKey: apiKey,
client: newHTTPClient(),
}, nil
}

117
service/ai/openai.go Normal file
View File

@@ -0,0 +1,117 @@
package ai
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// openAICompatible 是 OpenAI Chat Completions 兼容端点的通用流式客户端;
// 讯飞星火 Lite 与 DeepSeek 均暴露该协议,只是 baseURL/model/key 不同。
type openAICompatible struct {
name string
baseURL string
model string
apiKey string
client *http.Client
}
func (p *openAICompatible) Name() string { return p.name }
type chatRequest struct {
Model string `json:"model"`
Messages []Message `json:"messages"`
Stream bool `json:"stream"`
}
type chatDelta struct {
Choices []struct {
Delta struct {
Content string `json:"content"`
} `json:"delta"`
} `json:"choices"`
Error *struct {
Message string `json:"message"`
} `json:"error"`
}
func (p *openAICompatible) ChatStream(ctx context.Context, messages []Message) (<-chan Chunk, error) {
body, e := json.Marshal(chatRequest{Model: p.model, Messages: messages, Stream: true})
if e != nil {
return nil, e
}
req, e := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(p.baseURL, "/")+"/chat/completions", bytes.NewReader(body))
if e != nil {
return nil, e
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+p.apiKey)
req.Header.Set("Accept", "text/event-stream")
resp, e := p.client.Do(req)
if e != nil {
return nil, errors.New("AI_NETWORK_ERROR")
}
if resp.StatusCode != http.StatusOK {
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
resp.Body.Close()
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
return nil, errors.New("AI_KEY_INVALID")
}
return nil, fmt.Errorf("AI_HTTP_%d: %s", resp.StatusCode, truncate(string(raw), 300))
}
out := make(chan Chunk, 16)
go func() {
defer close(out)
defer resp.Body.Close()
sc := bufio.NewScanner(resp.Body)
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if !strings.HasPrefix(line, "data:") {
continue
}
payload := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
if payload == "" || payload == "[DONE]" {
continue
}
var d chatDelta
if json.Unmarshal([]byte(payload), &d) != nil {
continue
}
if d.Error != nil {
out <- Chunk{Err: errors.New(truncate(d.Error.Message, 300))}
return
}
if len(d.Choices) > 0 && d.Choices[0].Delta.Content != "" {
select {
case out <- Chunk{Content: d.Choices[0].Delta.Content}:
case <-ctx.Done():
return
}
}
}
if e := sc.Err(); e != nil && ctx.Err() == nil {
out <- Chunk{Err: errors.New("AI_STREAM_INTERRUPTED")}
}
}()
return out, nil
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "..."
}
func newHTTPClient() *http.Client {
return &http.Client{Timeout: 5 * time.Minute}
}

24
service/ai/provider.go Normal file
View File

@@ -0,0 +1,24 @@
// Package ai 定义 AI 聊天 Provider 抽象与工厂:
// 上层只依赖 Provider 接口,通过 New 按设置创建讯飞星火 Lite 或 DeepSeek 实例。
package ai
import "context"
// Message 是一条对话消息role: system | user | assistant
type Message struct {
Role string `json:"role"`
Content string `json:"content"`
}
// Chunk 是流式返回的一段增量内容Err 非空表示流异常中止。
type Chunk struct {
Content string
Err error
}
// Provider 是 AI 服务商的统一抽象。
type Provider interface {
Name() string
// ChatStream 发起流式对话,返回增量内容通道;通道关闭即流结束。
ChatStream(ctx context.Context, messages []Message) (<-chan Chunk, error)
}

49
service/scheduler.go Normal file
View File

@@ -0,0 +1,49 @@
package service
import (
"strconv"
"strings"
"time"
)
// parseClock 解析 HH:MM非法输入回退 09:00。
func parseClock(at string) (int, int) {
parts := strings.SplitN(at, ":", 2)
if len(parts) != 2 {
return 9, 0
}
h, e1 := strconv.Atoi(parts[0])
m, e2 := strconv.Atoi(parts[1])
if e1 != nil || e2 != nil || h < 0 || h > 23 || m < 0 || m > 59 {
return 9, 0
}
return h, m
}
// DigestDue 判断团队日报摘要是否到达自动生成时间now 已过当天 atHH:MM
func DigestDue(at string, now time.Time) bool {
hh, mm := parseClock(at)
due := time.Date(now.Year(), now.Month(), now.Day(), hh, mm, 0, 0, now.Location())
return !now.Before(due)
}
// NextAutoUpdate 计算下一次自动更新的时间点(纯函数,便于测试)。
// mode: daily | everyNDays | everyNHoursat 为 HH:MMlast 为上次执行时间(调用方保证非零)。
func NextAutoUpdate(mode string, interval int, at string, last, now time.Time) time.Time {
if interval < 1 {
interval = 1
}
hh, mm := parseClock(at)
switch mode {
case "everyNHours":
return last.Add(time.Duration(interval) * time.Hour)
case "everyNDays":
return time.Date(last.Year(), last.Month(), last.Day(), hh, mm, 0, 0, last.Location()).AddDate(0, 0, interval)
default: // daily今天的 HH:MM若上次已在该时点之后执行过则推到明天。
t := time.Date(now.Year(), now.Month(), now.Day(), hh, mm, 0, 0, now.Location())
if !last.Before(t) {
t = t.AddDate(0, 0, 1)
}
return t
}
}

69
service/scheduler_test.go Normal file
View File

@@ -0,0 +1,69 @@
package service
import (
"testing"
"time"
)
func at(day, clock string) time.Time {
t, _ := time.ParseInLocation("2006-01-02 15:04", day+" "+clock, time.Local)
return t
}
func TestNextAutoUpdateDaily(t *testing.T) {
// 昨天 09:00 跑过,现在是今天 08:00 → 下一次今天 09:00
next := NextAutoUpdate("daily", 1, "09:00", at("2026-08-10", "09:00"), at("2026-08-11", "08:00"))
if !next.Equal(at("2026-08-11", "09:00")) {
t.Fatalf("want today 09:00, got %v", next)
}
// 今天 09:01 已跑,现在 15:00 → 明天 09:00
next = NextAutoUpdate("daily", 1, "09:00", at("2026-08-11", "09:01"), at("2026-08-11", "15:00"))
if !next.Equal(at("2026-08-12", "09:00")) {
t.Fatalf("want tomorrow 09:00, got %v", next)
}
// 昨天跑过,现在 10:00已过 09:00→ 今天 09:00补跑
next = NextAutoUpdate("daily", 1, "09:00", at("2026-08-10", "09:00"), at("2026-08-11", "10:00"))
if !next.Equal(at("2026-08-11", "09:00")) {
t.Fatalf("want catch-up today 09:00, got %v", next)
}
}
func TestNextAutoUpdateEveryNDays(t *testing.T) {
next := NextAutoUpdate("everyNDays", 3, "22:30", at("2026-08-08", "22:30"), at("2026-08-11", "08:00"))
if !next.Equal(at("2026-08-11", "22:30")) {
t.Fatalf("want 08-11 22:30, got %v", next)
}
}
func TestNextAutoUpdateEveryNHours(t *testing.T) {
next := NextAutoUpdate("everyNHours", 4, "", at("2026-08-11", "06:15"), at("2026-08-11", "08:00"))
if !next.Equal(at("2026-08-11", "10:15")) {
t.Fatalf("want 10:15, got %v", next)
}
}
func TestNextAutoUpdateBadClock(t *testing.T) {
next := NextAutoUpdate("daily", 1, "bogus", at("2026-08-10", "09:00"), at("2026-08-11", "08:00"))
if !next.Equal(at("2026-08-11", "09:00")) {
t.Fatalf("bad clock should fall back to 09:00, got %v", next)
}
}
func TestDigestDue(t *testing.T) {
if DigestDue("21:00", at("2026-08-11", "20:59")) {
t.Fatal("20:59 should not be due for 21:00")
}
if !DigestDue("21:00", at("2026-08-11", "21:00")) {
t.Fatal("21:00 sharp should be due")
}
if !DigestDue("21:00", at("2026-08-11", "23:40")) {
t.Fatal("23:40 should be due")
}
// 非法时间回退 09:00与 parseClock 兜底一致)
if DigestDue("bogus", at("2026-08-11", "08:00")) {
t.Fatal("bad clock falls back to 09:00, 08:00 not due")
}
if !DigestDue("bogus", at("2026-08-11", "09:30")) {
t.Fatal("bad clock falls back to 09:00, 09:30 due")
}
}

343
shell.go Normal file
View File

@@ -0,0 +1,343 @@
package main
import (
_ "embed"
"errors"
"fmt"
"sync/atomic"
"time"
"view/service"
"github.com/wailsapp/wails/v3/pkg/application"
"github.com/wailsapp/wails/v3/pkg/events"
)
//go:embed build/appicon.png
var appIcon []byte
const appVersion = "2.0.0"
// shellText 是原生菜单/托盘的本地化文案。
type shellText struct {
file, newProject, batchAnalyze, database, quit string
view, theme, themeDark, themeLight, themeSystem, language string
tools, analyzeNow, settings, logsPage, help, about string
trayShow, trayBatch, trayQuit, aiHub string
account, accountLogin, traySync, profile, logout string
trayProjects, trayAllProjects string
trayTodos, trayTickets, trayMessages, trayAutostart string
}
func shellTexts(locale string) shellText {
if locale == "en" {
return shellText{
file: "File", newProject: "New Project", batchAnalyze: "Analyze All", database: "Data Management", quit: "Quit",
view: "View", theme: "Theme", themeDark: "Dark", themeLight: "Light", themeSystem: "System", language: "Language",
tools: "Tools", analyzeNow: "Analyze All Now", settings: "Settings", logsPage: "Activity Log", help: "Help", about: "About",
trayShow: "Show Main Window", trayBatch: "Analyze All Projects", trayQuit: "Quit", aiHub: "AI Analysis",
account: "Account", accountLogin: "Sign In / Register", traySync: "Sync Now", profile: "Profile", logout: "Sign Out",
trayProjects: "Open Project", trayAllProjects: "All Projects…",
trayTodos: "Todos", trayTickets: "Tickets", trayMessages: "Messages", trayAutostart: "Start at Login",
}
}
return shellText{
file: "文件", newProject: "新建项目", batchAnalyze: "批量统计", database: "数据管理", quit: "退出",
view: "视图", theme: "主题", themeDark: "暗色", themeLight: "浅色", themeSystem: "跟随系统", language: "语言",
tools: "工具", analyzeNow: "立即分析全部", settings: "设置", logsPage: "运行日志", help: "帮助", about: "关于",
trayShow: "显示主窗口", trayBatch: "批量统计全部项目", trayQuit: "退出", aiHub: "AI 分析",
account: "账号", accountLogin: "登录 / 注册", traySync: "立即同步", profile: "个人中心", logout: "退出登录",
trayProjects: "打开项目", trayAllProjects: "全部项目…",
trayTodos: "待办事项", trayTickets: "需求工单", trayMessages: "消息中心", trayAutostart: "开机启动",
}
}
// shellState 保存原生外壳(窗口/托盘/菜单)的运行引用。
type shellState struct {
wapp *application.App
win *application.WebviewWindow
tray *application.SystemTray
quitting atomic.Bool
}
// SetupShell 在窗口创建后接管关闭行为,并挂载原生菜单与系统托盘。
func (a *App) SetupShell(wapp *application.App, win *application.WebviewWindow) {
a.shell = &shellState{wapp: wapp, win: win}
win.RegisterHook(events.Common.WindowClosing, func(e *application.WindowEvent) {
if a.shell.quitting.Load() {
return
}
if a.minimizeToTrayEnabled() {
win.Hide()
e.Cancel()
}
})
a.shell.tray = wapp.SystemTray.New()
a.shell.tray.SetIcon(appIcon)
a.RefreshShell()
}
// RefreshShell 按当前语言与登录态重建原生菜单与托盘菜单(设置/登录变更后调用)。
func (a *App) RefreshShell() {
if a.shell == nil {
return
}
locale, theme := "zh-CN", "dark"
if a.store != nil {
if st, e := a.store.Settings(); e == nil {
locale, theme = st.Locale, st.Theme
}
}
t := shellTexts(locale)
menu := a.buildAppMenu(t, theme, locale)
a.shell.wapp.Menu.SetApplicationMenu(menu)
// Windows/Linux 的菜单栏挂在窗口上macOS 用全局应用菜单SetMenu 是 no-op
if a.shell.win != nil {
a.shell.win.SetMenu(menu)
}
a.shell.tray.SetTooltip("年糕崽崽项目管理PMS")
a.shell.tray.SetMenu(a.buildTrayMenu(t))
a.shell.tray.OnClick(a.showMainWindow)
}
func (a *App) buildAppMenu(t shellText, theme, locale string) *application.Menu {
menu := a.shell.wapp.Menu.New()
file := menu.AddSubmenu(t.file)
file.Add(t.newProject).SetAccelerator("CmdOrCtrl+N").OnClick(func(*application.Context) {
a.showMainWindow()
a.emit("menu:action", "addProject")
})
file.Add(t.batchAnalyze).SetAccelerator("CmdOrCtrl+Shift+A").OnClick(func(*application.Context) {
_, _ = a.StartBatchAnalysis()
})
file.Add(t.database).OnClick(func(*application.Context) {
a.showMainWindow()
a.emit("menu:navigate", "/settings?tab=database")
})
file.AddSeparator()
file.Add(t.quit).SetAccelerator("CmdOrCtrl+Q").OnClick(func(*application.Context) { a.QuitApp() })
view := menu.AddSubmenu(t.view)
themeMenu := view.AddSubmenu(t.theme)
for _, x := range []struct{ label, value string }{{t.themeDark, "dark"}, {t.themeLight, "light"}, {t.themeSystem, "system"}} {
v := x.value
themeMenu.AddRadio(x.label, theme == v).OnClick(func(*application.Context) {
a.emit("menu:set", map[string]string{"key": "theme", "value": v})
})
}
langMenu := view.AddSubmenu(t.language)
for _, x := range []struct{ label, value string }{{"简体中文", "zh-CN"}, {"English", "en"}} {
v := x.value
langMenu.AddRadio(x.label, locale == v).OnClick(func(*application.Context) {
a.emit("menu:set", map[string]string{"key": "locale", "value": v})
})
}
tools := menu.AddSubmenu(t.tools)
tools.Add(t.analyzeNow).OnClick(func(*application.Context) { _, _ = a.StartBatchAnalysis() })
tools.Add(t.aiHub).SetAccelerator("CmdOrCtrl+I").OnClick(func(*application.Context) {
a.showMainWindow()
a.emit("menu:navigate", "/ai")
})
tools.Add(t.settings).SetAccelerator("CmdOrCtrl+,").OnClick(func(*application.Context) {
a.showMainWindow()
a.emit("menu:navigate", "/settings")
})
tools.Add(t.logsPage).OnClick(func(*application.Context) {
a.showMainWindow()
a.emit("menu:navigate", "/logs")
})
account := menu.AddSubmenu(t.account)
if a.store != nil && a.syncUserID() > 0 {
name := a.store.Meta("sync_username")
if name == "" {
name = t.profile
}
account.Add(name + "" + t.profile + "").OnClick(func(*application.Context) {
a.showMainWindow()
a.emit("menu:navigate", "/profile")
})
account.Add(t.traySync).OnClick(func(*application.Context) { a.trayQuickSync() })
account.AddSeparator()
account.Add(t.logout).OnClick(func(*application.Context) {
_ = a.SyncLogout()
a.emit("menu:action", "sync-refresh")
})
} else {
account.Add(t.accountLogin).OnClick(func(*application.Context) {
a.showMainWindow()
a.emit("menu:action", "account")
})
}
help := menu.AddSubmenu(t.help)
help.Add(t.about).OnClick(func(*application.Context) {
a.showMainWindow()
a.emit("menu:action", "about")
})
return menu
}
// trayQuickSync 托盘/菜单快捷同步:已登录直接后台同步一轮,未登录弹出登录层。
func (a *App) trayQuickSync() {
if a.store != nil && a.syncUserID() > 0 {
go a.syncOnce(true)
return
}
a.showMainWindow()
a.emit("menu:action", "account")
}
// trayProjectLimit 是托盘「打开项目」子菜单最多列出的项目数,超出走「全部项目…」。
const trayProjectLimit = 12
func (a *App) buildTrayMenu(t shellText) *application.Menu {
menu := a.shell.wapp.Menu.New()
menu.Add(t.trayShow).OnClick(func(*application.Context) { a.showMainWindow() })
menu.AddSeparator()
if projects, e := a.ListProjects(); e == nil && len(projects) > 0 {
sub := menu.AddSubmenu(t.trayProjects)
for i, p := range projects {
if i >= trayProjectLimit {
break
}
path := fmt.Sprintf("/project/%d", p.ID)
sub.Add(p.Name).OnClick(func(*application.Context) { a.navigateTo(path) })
}
if len(projects) > trayProjectLimit {
sub.AddSeparator()
sub.Add(t.trayAllProjects).OnClick(func(*application.Context) { a.navigateTo("/projects") })
}
}
menu.Add(t.trayTodos).OnClick(func(*application.Context) { a.navigateTo("/todos") })
menu.Add(t.trayTickets).OnClick(func(*application.Context) { a.navigateTo("/tickets") })
menu.Add(t.trayMessages).OnClick(func(*application.Context) { a.navigateTo("/messages") })
menu.AddSeparator()
menu.Add(t.trayBatch).OnClick(func(*application.Context) { _, _ = a.StartBatchAnalysis() })
menu.Add(t.traySync).OnClick(func(*application.Context) { a.trayQuickSync() })
menu.AddSeparator()
// 勾选态取自系统注册状态;点击写入后重建托盘,让勾选跟随真实结果。
autostart, autoErr := a.GetAutostart()
menu.AddCheckbox(t.trayAutostart, autoErr == nil && autostart).OnClick(func(*application.Context) {
_ = a.SetAutostart(!autostart)
a.RefreshShell()
})
menu.AddSeparator()
menu.Add(t.trayQuit).OnClick(func(*application.Context) { a.QuitApp() })
return menu
}
// navigateTo 唤起主窗口并让前端路由跳到指定页面(托盘/原生菜单共用)。
func (a *App) navigateTo(path string) {
a.showMainWindow()
a.emit("menu:navigate", path)
}
func (a *App) showMainWindow() {
if a.shell == nil || a.shell.win == nil {
return
}
a.shell.win.Show()
a.shell.win.Restore()
a.shell.win.Focus()
}
func (a *App) minimizeToTrayEnabled() bool {
if a.store == nil {
return true
}
st, e := a.store.Settings()
if e != nil {
return true
}
return st.MinimizeToTray
}
// QuitApp 退出应用(绕过最小化到托盘逻辑)。
func (a *App) QuitApp() {
if a.shell != nil {
a.shell.quitting.Store(true)
a.shell.wapp.Quit()
}
}
func (a *App) GetAppVersion() string { return appVersion }
// GetAutostart 查询开机自启是否已注册。
func (a *App) GetAutostart() (bool, error) {
app := application.Get()
if app == nil {
return false, errors.New("AUTOSTART_UNAVAILABLE")
}
return app.Autostart.IsEnabled()
}
// SetAutostart 注册/注销开机自启。
func (a *App) SetAutostart(enable bool) error {
app := application.Get()
if app == nil {
return errors.New("AUTOSTART_UNAVAILABLE")
}
var e error
if enable {
e = app.Autostart.Enable()
} else {
e = app.Autostart.Disable()
}
if e != nil {
return coded("AUTOSTART_FAILED", e)
}
if a.store != nil {
state := "已开启"
if !enable {
state = "已关闭"
}
a.store.Log("info", "系统", "开机自启"+state, "")
}
return nil
}
// runAutoUpdateLoop 周期检查是否到达自动更新时间点。
func (a *App) runAutoUpdateLoop() {
t := time.NewTicker(30 * time.Second)
defer t.Stop()
for {
select {
case <-a.ctx.Done():
return
case <-t.C:
a.checkAutoUpdate()
}
}
}
func (a *App) checkAutoUpdate() {
if a.store == nil || a.bootstrap.State != BootstrapReady {
return
}
st, e := a.store.Settings()
if e != nil || !st.AutoUpdateEnabled {
return
}
now := time.Now()
last, _ := time.Parse(time.RFC3339, a.store.Meta("lastAutoUpdateAt"))
if last.IsZero() {
// 首次启用:以当前时间为基线,避免立即触发一轮全量统计。
_ = a.store.SetMeta("lastAutoUpdateAt", now.Format(time.RFC3339))
return
}
next := service.NextAutoUpdate(st.AutoUpdateMode, st.AutoUpdateInterval, st.AutoUpdateTime, last, now)
if now.Before(next) {
return
}
a.mu.Lock()
busy := len(a.tasks) > 0
a.mu.Unlock()
if busy {
return
}
_ = a.store.SetMeta("lastAutoUpdateAt", now.Format(time.RFC3339))
a.store.Log("info", "自动更新", "定时批量统计开始", "模式: "+st.AutoUpdateMode)
_, _ = a.StartBatchAnalysis()
}

1298
sync.go Normal file

File diff suppressed because it is too large Load Diff

141
sync_docs_test.go Normal file
View File

@@ -0,0 +1,141 @@
package main
import (
"path/filepath"
"strings"
"testing"
)
// 项目/规则文档的生成与套用(不依赖 MySQL 的本地部分)。
// v2 语义:身份文档只按 name 匹配更新,不再从文档落库项目;
// 项目落地依赖 paths:<machineID> 行applyMachinePaths或用户手动绑定。
func TestProjectsDocRoundTrip(t *testing.T) {
dir := t.TempDir()
s, e := OpenStore(filepath.Join(dir, "docs.db"))
if e != nil {
t.Fatal(e)
}
defer s.db.Close()
a := NewApp()
a.store = s
doc, e := a.projectsDoc()
if e != nil || doc != "[]" {
t.Fatalf("empty doc want [], got %q err=%v", doc, e)
}
// 远端身份文档v1 旧格式,元素带 path本机没有这些项目 → 全部进待绑定,不落库。
p1 := filepath.Join(dir, "alpha")
p2 := filepath.Join(dir, "beta")
remote := `[{"name":"Alpha","path":` + jsonStr(p1) + `,"description":"d1","group":"Work","favorite":true},` +
`{"name":"Beta","path":` + jsonStr(p2) + `}]`
if e := a.applyProjectsDoc(remote); e != nil {
t.Fatal(e)
}
var n int
_ = s.db.QueryRow(`SELECT COUNT(*) FROM projects`).Scan(&n)
if n != 0 {
t.Fatalf("identity doc must not insert projects, got %d", n)
}
if pending, _ := a.ListCloudPendingProjects(); len(pending) != 2 {
t.Fatalf("expect 2 pending cloud projects, got %d", len(pending))
}
// 本机 paths 行到达(同机换库场景)→ 项目落库,随后身份文档补齐分组/收藏。
if e := a.applyMachinePaths(`{"Alpha":` + jsonStr(p1) + `,"Beta":` + jsonStr(p2) + `}`); e != nil {
t.Fatal(e)
}
if e := a.applyProjectsDoc(remote); e != nil {
t.Fatal(e)
}
var gid int64
if e := s.db.QueryRow(`SELECT group_id FROM projects WHERE path=?`, p1).Scan(&gid); e != nil || gid == 1 {
t.Fatalf("alpha should be in new group, gid=%d err=%v", gid, e)
}
var gname string
_ = s.db.QueryRow(`SELECT name FROM project_groups WHERE id=?`, gid).Scan(&gname)
if gname != "Work" {
t.Fatalf("group name want Work, got %q", gname)
}
var fav int
_ = s.db.QueryRow(`SELECT COUNT(*) FROM favorites f JOIN projects p ON p.id=f.project_id WHERE p.path=?`, p1).Scan(&fav)
if fav != 1 {
t.Fatal("alpha should be favorite")
}
if pending, _ := a.ListCloudPendingProjects(); len(pending) != 0 {
t.Fatalf("pending should be empty after paths applied, got %d", len(pending))
}
// 再生成文档应包含两个项目且不带 path重复套用幂等。
doc, e = a.projectsDoc()
if e != nil || !strings.Contains(doc, "Alpha") || !strings.Contains(doc, "Beta") {
t.Fatalf("doc missing entries: %q err=%v", doc, e)
}
if strings.Contains(doc, `"path"`) {
t.Fatalf("v2 doc must not contain path: %q", doc)
}
if e := a.applyProjectsDoc(remote); e != nil {
t.Fatal(e)
}
_ = s.db.QueryRow(`SELECT COUNT(*) FROM projects`).Scan(&n)
if n != 2 {
t.Fatalf("apply should be idempotent, projects=%d", n)
}
// 更新改名v1 文档按 path 匹配到本地项目)+ 取消收藏;本地多出的项目不受影响(只增改不删)。
remote2 := `[{"name":"Alpha2","path":` + jsonStr(p1) + `,"description":"d2","group":"Work","favorite":false}]`
if e := a.applyProjectsDoc(remote2); e != nil {
t.Fatal(e)
}
var name string
_ = s.db.QueryRow(`SELECT name FROM projects WHERE path=?`, p1).Scan(&name)
if name != "Alpha2" {
t.Fatalf("want renamed Alpha2, got %q", name)
}
_ = s.db.QueryRow(`SELECT COUNT(*) FROM favorites f JOIN projects p ON p.id=f.project_id WHERE p.path=?`, p1).Scan(&fav)
if fav != 0 {
t.Fatal("favorite should be removed")
}
_ = s.db.QueryRow(`SELECT COUNT(*) FROM projects`).Scan(&n)
if n != 2 {
t.Fatalf("pull must not delete local projects, got %d", n)
}
}
func TestRulesDocRoundTrip(t *testing.T) {
s, e := OpenStore(filepath.Join(t.TempDir(), "rules.db"))
if e != nil {
t.Fatal(e)
}
defer s.db.Close()
a := NewApp()
a.store = s
if _, e := s.AddRule("*.bak", "custom"); e != nil {
t.Fatal(e)
}
doc, e := a.rulesDoc()
if e != nil || !strings.Contains(doc, "*.bak") {
t.Fatalf("doc should contain custom rule: %q err=%v", doc, e)
}
var builtinBefore int
_ = s.db.QueryRow(`SELECT COUNT(*) FROM exclusion_rules WHERE builtin=1`).Scan(&builtinBefore)
// 远端替换自定义部分:*.bak 消失,*.tmp 出现,内置规则数不变。
if e := a.applyRulesDoc(`[{"pattern":"*.tmp","category":"custom"}]`); e != nil {
t.Fatal(e)
}
var nBak, nTmp, builtinAfter int
_ = s.db.QueryRow(`SELECT COUNT(*) FROM exclusion_rules WHERE pattern='*.bak'`).Scan(&nBak)
_ = s.db.QueryRow(`SELECT COUNT(*) FROM exclusion_rules WHERE pattern='*.tmp' AND builtin=0`).Scan(&nTmp)
_ = s.db.QueryRow(`SELECT COUNT(*) FROM exclusion_rules WHERE builtin=1`).Scan(&builtinAfter)
if nBak != 0 || nTmp != 1 || builtinAfter != builtinBefore {
t.Fatalf("replace failed: bak=%d tmp=%d builtin %d->%d", nBak, nTmp, builtinBefore, builtinAfter)
}
}
// jsonStr 把路径安全编码为 JSON 字符串Windows 反斜杠)。
func jsonStr(s string) string {
b := strings.ReplaceAll(s, `\`, `\\`)
return `"` + b + `"`
}

306
sync_integration_test.go Normal file
View File

@@ -0,0 +1,306 @@
package main
// 账号全链路集成测试:注册 → 登录 → 加密 Key 推送 → 修改密码(密文轮换)→ 新密码登录 → 再同步。
// 需要本机 MySQLroot/root不可达时自动跳过。测试使用一次性临时库结束后删除。
import (
"context"
"database/sql"
"encoding/hex"
"fmt"
"path/filepath"
"strings"
"testing"
"time"
"golang.org/x/crypto/bcrypt"
)
var itDDL = []string{
`CREATE TABLE users(
id BIGINT PRIMARY KEY AUTO_INCREMENT,
username VARCHAR(64) NOT NULL UNIQUE,
password_hash VARCHAR(100) NOT NULL,
created_at VARCHAR(32) NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE sync_todos(
user_id BIGINT NOT NULL, uuid CHAR(36) NOT NULL,
title TEXT NOT NULL, content TEXT NOT NULL,
project_name VARCHAR(255) NOT NULL DEFAULT '', due_at VARCHAR(32) NOT NULL DEFAULT '',
priority VARCHAR(16) NOT NULL DEFAULT 'medium', status VARCHAR(16) NOT NULL DEFAULT 'open',
history MEDIUMTEXT NOT NULL, team_id BIGINT NOT NULL DEFAULT 0,
created_at VARCHAR(32) NOT NULL DEFAULT '', updated_at VARCHAR(32) NOT NULL,
deleted TINYINT NOT NULL DEFAULT 0, PRIMARY KEY(user_id, uuid)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE sync_tickets(
user_id BIGINT NOT NULL, uuid CHAR(36) NOT NULL,
title TEXT NOT NULL, description TEXT NOT NULL, type VARCHAR(16) NOT NULL DEFAULT 'task',
project_name VARCHAR(255) NOT NULL DEFAULT '', start_at VARCHAR(32) NOT NULL DEFAULT '',
due_at VARCHAR(32) NOT NULL DEFAULT '', status VARCHAR(16) NOT NULL DEFAULT 'open',
priority VARCHAR(16) NOT NULL DEFAULT 'medium', history MEDIUMTEXT NOT NULL,
team_id BIGINT NOT NULL DEFAULT 0,
created_at VARCHAR(32) NOT NULL DEFAULT '', updated_at VARCHAR(32) NOT NULL,
deleted TINYINT NOT NULL DEFAULT 0, PRIMARY KEY(user_id, uuid)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE sync_notes(
user_id BIGINT NOT NULL, uuid CHAR(36) NOT NULL, content MEDIUMTEXT NOT NULL,
updated_at VARCHAR(32) NOT NULL, deleted TINYINT NOT NULL DEFAULT 0, PRIMARY KEY(user_id, uuid)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE sync_settings(
user_id BIGINT NOT NULL, name VARCHAR(64) NOT NULL, value MEDIUMTEXT NOT NULL,
updated_at VARCHAR(32) NOT NULL, PRIMARY KEY(user_id, name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE user_profiles(
user_id BIGINT PRIMARY KEY, nickname VARCHAR(64) NOT NULL DEFAULT '', title VARCHAR(64) NOT NULL DEFAULT '',
email VARCHAR(128) NOT NULL DEFAULT '', bio VARCHAR(500) NOT NULL DEFAULT '',
tech_tags VARCHAR(1000) NOT NULL DEFAULT '[]', avatar_thumb MEDIUMTEXT NOT NULL, updated_at VARCHAR(32) NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE teams(
id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(64) NOT NULL, owner_id BIGINT NOT NULL,
digest_time VARCHAR(8) NOT NULL DEFAULT '21:00', created_at VARCHAR(32) NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE team_members(
team_id BIGINT NOT NULL, user_id BIGINT NOT NULL, role VARCHAR(16) NOT NULL DEFAULT 'member',
joined_at VARCHAR(32) NOT NULL, PRIMARY KEY(team_id, user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE team_tasks(
id BIGINT PRIMARY KEY AUTO_INCREMENT, team_id BIGINT NOT NULL, kind VARCHAR(16) NOT NULL DEFAULT 'todo',
title TEXT NOT NULL, description MEDIUMTEXT NOT NULL, priority VARCHAR(16) NOT NULL DEFAULT 'medium',
status VARCHAR(16) NOT NULL DEFAULT 'open', creator_id BIGINT NOT NULL, assignee_id BIGINT NOT NULL DEFAULT 0,
start_at VARCHAR(32) NOT NULL DEFAULT '', due_at VARCHAR(32) NOT NULL DEFAULT '',
urged_at VARCHAR(32) NOT NULL DEFAULT '', history MEDIUMTEXT NOT NULL, updated_at VARCHAR(32) NOT NULL,
deleted TINYINT NOT NULL DEFAULT 0
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE team_reports(
team_id BIGINT NOT NULL, user_id BIGINT NOT NULL, date CHAR(10) NOT NULL,
content MEDIUMTEXT NOT NULL, submitted_at VARCHAR(32) NOT NULL, PRIMARY KEY(team_id, user_id, date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE team_digests(
team_id BIGINT NOT NULL, date CHAR(10) NOT NULL, content MEDIUMTEXT NOT NULL,
provider VARCHAR(32) NOT NULL DEFAULT '', generated_at VARCHAR(32) NOT NULL, PRIMARY KEY(team_id, date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE team_notices(
id BIGINT PRIMARY KEY AUTO_INCREMENT, team_id BIGINT NOT NULL, to_user BIGINT NOT NULL, from_user BIGINT NOT NULL,
kind VARCHAR(16) NOT NULL, ref_id VARCHAR(64) NOT NULL DEFAULT '', content TEXT NOT NULL, created_at VARCHAR(32) NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
}
// syncNowRetry 容忍登录后台首轮同步占用的 SYNC_IN_PROGRESS。
func syncNowRetry(t *testing.T, a *App) SyncStatus {
t.Helper()
for i := 0; i < 100; i++ {
st, e := a.SyncNow()
if e == nil {
return st
}
if e.Error() != "SYNC_IN_PROGRESS" {
t.Fatalf("SyncNow: %v", e)
}
time.Sleep(50 * time.Millisecond)
}
t.Fatal("sync stayed busy")
return SyncStatus{}
}
func TestAccountFlowIntegration(t *testing.T) {
boot, e := sql.Open("mysql", "root:root@tcp(127.0.0.1:3306)/?timeout=2s")
if e != nil {
t.Skip("mysql driver: " + e.Error())
}
defer boot.Close()
if e = boot.Ping(); e != nil {
t.Skip("local MySQL not available: " + e.Error())
}
dbName := fmt.Sprintf("cc_it_%d", time.Now().UnixNano())
if _, e = boot.Exec("CREATE DATABASE " + dbName + " DEFAULT CHARSET utf8mb4"); e != nil {
t.Skip("cannot create scratch db: " + e.Error())
}
defer boot.Exec("DROP DATABASE " + dbName)
remote, e := sql.Open("mysql", "root:root@tcp(127.0.0.1:3306)/"+dbName+"?timeout=2s")
if e != nil {
t.Fatal(e)
}
defer remote.Close()
for _, ddl := range itDDL {
if _, e = remote.Exec(ddl); e != nil {
t.Fatal(e)
}
}
s, e := OpenStore(filepath.Join(t.TempDir(), "it.db"))
if e != nil {
t.Fatal(e)
}
defer s.db.Close()
a := NewApp()
a.store = s
a.ctx = context.Background()
if e = a.SaveSyncConfig(SyncConfig{Host: "127.0.0.1", Port: 3306, User: "root", Password: "root", Database: dbName}); e != nil {
t.Fatal(e)
}
// 注册:成功一次,重复注册报 SYNC_USER_EXISTS。
if e := a.SyncRegister("ituser", "oldpass-1"); e != nil {
t.Fatalf("register: %v", e)
}
if e := a.SyncRegister("ituser", "oldpass-1"); e == nil || e.Error() != "SYNC_USER_EXISTS" {
t.Fatalf("want SYNC_USER_EXISTS, got %v", e)
}
// 登录:错密码拒绝,正确密码成功并派生加密密钥。
if _, e := a.SyncLogin("ituser", "wrong-pass"); e == nil || e.Error() != "SYNC_BAD_CREDENTIALS" {
t.Fatalf("want SYNC_BAD_CREDENTIALS, got %v", e)
}
st, e := a.SyncLogin("ituser", "oldpass-1")
if e != nil || !st.LoggedIn {
t.Fatalf("login: %v %+v", e, st)
}
oldKey := a.encKey()
if oldKey == nil {
t.Fatal("enc key not derived after login")
}
// 配置 API Key 并开启加密同步,推送到云端。
set, _ := s.Settings()
set.SyncAPIKeys, set.SparkKey, set.DeepSeekKey = true, "sk-spark-123", "dsk-456"
if e = s.SaveSettings(set); e != nil {
t.Fatal(e)
}
syncNowRetry(t, a)
var blob string
if e = remote.QueryRow(`SELECT value FROM sync_settings WHERE name='api_keys'`).Scan(&blob); e != nil {
t.Fatalf("api_keys row not pushed: %v", e)
}
if plain, e := decryptWithKey(oldKey, blob); e != nil || !strings.Contains(plain, "sk-spark-123") {
t.Fatalf("old key should decrypt pushed blob: %v %q", e, plain)
}
// 修改密码:旧密码错误被拒;正确旧密码成功。
if e := a.SyncChangePassword("wrong-pass", "newpass-2"); e == nil || e.Error() != "SYNC_OLD_PASSWORD_WRONG" {
t.Fatalf("want SYNC_OLD_PASSWORD_WRONG, got %v", e)
}
if e := a.SyncChangePassword("oldpass-1", "newpass-2"); e != nil {
t.Fatalf("change password: %v", e)
}
// 服务器密码哈希已更新为新密码。
var hash string
if e = remote.QueryRow(`SELECT password_hash FROM users WHERE username='ituser'`).Scan(&hash); e != nil {
t.Fatal(e)
}
if bcrypt.CompareHashAndPassword([]byte(hash), []byte("newpass-2")) != nil {
t.Fatal("password hash not rotated")
}
// 云端密文已轮换:旧密钥解不开,新密钥解得开且内容不变。
if e = remote.QueryRow(`SELECT value FROM sync_settings WHERE name='api_keys'`).Scan(&blob); e != nil {
t.Fatal(e)
}
if _, e := decryptWithKey(oldKey, blob); e == nil {
t.Fatal("old key still decrypts after rotation")
}
newKey := a.encKey()
if plain, e := decryptWithKey(newKey, blob); e != nil || !strings.Contains(plain, "sk-spark-123") {
t.Fatalf("new key should decrypt rotated blob: %v %q", e, plain)
}
// 旧密码登录失败;新密码登录成功且派生出同一把新密钥。
if _, e := a.SyncLogin("ituser", "oldpass-1"); e == nil || e.Error() != "SYNC_BAD_CREDENTIALS" {
t.Fatalf("old password should be rejected, got %v", e)
}
if _, e := a.SyncLogin("ituser", "newpass-2"); e != nil {
t.Fatalf("login with new password: %v", e)
}
if hex.EncodeToString(a.encKey()) != hex.EncodeToString(newKey) {
t.Fatal("re-login derived a different key")
}
// 改密码后整轮同步依旧正常(含 avatar/api_keys 行)。
syncNowRetry(t, a)
// —— 文档同步 v2设备 A 本地建项目并推送(身份 + 本机 paths 行分离),设备 B同机器码拉取 ——
projPath := t.TempDir()
if _, e := s.SaveProject(0, ProjectInput{Name: "Alpha", Path: projPath}); e != nil {
t.Fatal(e)
}
// 身份文档按名匹配补分组/收藏(模拟从旧客户端拉到的 v1 文档也能套用)
if e := a.applyProjectsDoc(`[{"name":"Alpha","group":"Work","favorite":true}]`); e != nil {
t.Fatal(e)
}
if _, e := s.AddRule("*.itbak", "custom"); e != nil {
t.Fatal(e)
}
syncNowRetry(t, a)
var doc string
if e = remote.QueryRow(`SELECT value FROM sync_settings WHERE name='projects'`).Scan(&doc); e != nil || !strings.Contains(doc, "Alpha") {
t.Fatalf("projects doc not pushed: %v %q", e, doc)
}
if strings.Contains(doc, `"path"`) {
t.Fatalf("v2 projects doc must not contain machine paths: %q", doc)
}
var pathsDoc string
if e = remote.QueryRow(`SELECT value FROM sync_settings WHERE name=?`, "paths:"+a.machineID()).Scan(&pathsDoc); e != nil || !strings.Contains(pathsDoc, "Alpha") {
t.Fatalf("machine paths row not pushed: %v %q", e, pathsDoc)
}
s2, e := OpenStore(filepath.Join(t.TempDir(), "it2.db"))
if e != nil {
t.Fatal(e)
}
defer s2.db.Close()
b := NewApp()
b.store = s2
b.ctx = context.Background()
if e = b.SaveSyncConfig(SyncConfig{Host: "127.0.0.1", Port: 3306, User: "root", Password: "root", Database: dbName}); e != nil {
t.Fatal(e)
}
if _, e := b.SyncLogin("ituser", "newpass-2"); e != nil {
t.Fatalf("device B login: %v", e)
}
syncNowRetry(t, b)
// 设备 B 与 A 同一物理机machineID 相同)→ paths 行直接补齐本机路径
var n int
_ = s2.db.QueryRow(`SELECT COUNT(*) FROM projects p JOIN project_groups g ON g.id=p.group_id JOIN favorites f ON f.project_id=p.id
WHERE p.path=? AND g.name='Work'`, projPath).Scan(&n)
if n != 1 {
t.Fatalf("device B should pull project+group+favorite, got %d", n)
}
_ = s2.db.QueryRow(`SELECT COUNT(*) FROM exclusion_rules WHERE pattern='*.itbak' AND builtin=0`).Scan(&n)
if n != 1 {
t.Fatal("device B should pull custom rule")
}
// —— 设备 C不同机器码只有身份没有路径 → 待绑定 → 绑定后回推本机 paths 行 ——
s3, e := OpenStore(filepath.Join(t.TempDir(), "it3.db"))
if e != nil {
t.Fatal(e)
}
defer s3.db.Close()
_ = s3.SetMeta("machine_id", "beef00beef00beef") // 模拟另一台电脑
c := NewApp()
c.store = s3
c.ctx = context.Background()
if e = c.SaveSyncConfig(SyncConfig{Host: "127.0.0.1", Port: 3306, User: "root", Password: "root", Database: dbName}); e != nil {
t.Fatal(e)
}
if _, e := c.SyncLogin("ituser", "newpass-2"); e != nil {
t.Fatalf("device C login: %v", e)
}
syncNowRetry(t, c)
_ = s3.db.QueryRow(`SELECT COUNT(*) FROM projects`).Scan(&n)
if n != 0 {
t.Fatalf("device C must not adopt other machine's paths, got %d projects", n)
}
pending, e := c.ListCloudPendingProjects()
if e != nil || len(pending) != 1 || pending[0].Name != "Alpha" {
t.Fatalf("device C pending want [Alpha], got %#v err=%v", pending, e)
}
dirC := t.TempDir()
if _, e := c.BindCloudProject("Alpha", dirC); e != nil {
t.Fatalf("bind on device C: %v", e)
}
syncNowRetry(t, c)
if e = remote.QueryRow(`SELECT value FROM sync_settings WHERE name=?`, "paths:beef00beef00beef").Scan(&pathsDoc); e != nil || !strings.Contains(pathsDoc, "Alpha") {
t.Fatalf("device C paths row not pushed: %v %q", e, pathsDoc)
}
}

290
sync_test.go Normal file
View File

@@ -0,0 +1,290 @@
package main
import (
"encoding/json"
"path/filepath"
"strconv"
"strings"
"testing"
)
func newSyncTestApp(t *testing.T) *App {
t.Helper()
s, e := OpenStore(filepath.Join(t.TempDir(), "sync.db"))
if e != nil {
t.Fatal(e)
}
t.Cleanup(func() { s.db.Close() })
// 显式指向本机未监听端口,让远端访问立即失败(离线语义):
// 空配置会回落到内置默认服务器,单测可能误连开发机上的真实 MySQL。
_ = s.SetMeta("sync_host", "127.0.0.1")
_ = s.SetMeta("sync_port", "1")
_ = s.SetMeta("sync_user", "test")
_ = s.SetMeta("sync_database", "test")
return &App{store: s}
}
func TestApplyRemoteRowInsertAndProjectMapping(t *testing.T) {
a := newSyncTestApp(t)
p, e := a.store.SaveProject(0, ProjectInput{Name: "demo", Path: t.TempDir()})
if e != nil {
t.Fatal(e)
}
applied, e := a.applyRemoteRow(todoSync, map[string]any{
"uuid": "u-1", "title": "远端待办", "content": "", "project_name": "demo",
"due_at": "2026-08-20", "priority": "high", "status": "open",
"created_at": "2026-08-10T00:00:00Z", "updated_at": "2026-08-11T00:00:00Z", "deleted": int64(0),
})
if e != nil || !applied {
t.Fatalf("apply failed: %v %v", applied, e)
}
todos, _ := a.store.ListTodos("all", 0)
if len(todos) != 1 || todos[0].ProjectID != p.ID || todos[0].Title != "远端待办" {
t.Fatalf("unexpected todos: %#v", todos)
}
var dirty int
_ = a.store.db.QueryRow(`SELECT dirty FROM todos WHERE uuid='u-1'`).Scan(&dirty)
if dirty != 0 {
t.Fatal("pulled row must not be dirty")
}
}
func TestApplyRemoteRowLWW(t *testing.T) {
a := newSyncTestApp(t)
saved, e := a.store.SaveTodo(Todo{Title: "本地标题"})
if e != nil {
t.Fatal(e)
}
// 远端 updated_at 更旧 → 保留本地
applied, e := a.applyRemoteRow(todoSync, map[string]any{
"uuid": saved.UUID, "title": "旧远端", "content": "", "project_name": "",
"due_at": "", "priority": "low", "status": "open",
"created_at": "2020-01-01T00:00:00Z", "updated_at": "2020-01-01T00:00:00Z", "deleted": int64(0),
})
if e != nil || applied {
t.Fatalf("stale remote should be skipped: %v %v", applied, e)
}
// 远端 updated_at 更新 → 覆盖本地,且 dirty 清零
applied, e = a.applyRemoteRow(todoSync, map[string]any{
"uuid": saved.UUID, "title": "新远端", "content": "x", "project_name": "",
"due_at": "", "priority": "high", "status": "done",
"created_at": "2020-01-01T00:00:00Z", "updated_at": "2999-01-01T00:00:00Z", "deleted": int64(0),
})
if e != nil || !applied {
t.Fatalf("newer remote should apply: %v %v", applied, e)
}
got, _ := a.store.GetTodo(saved.ID)
if got.Title != "新远端" || got.Status != "done" {
t.Fatalf("unexpected merge result: %#v", got)
}
}
func TestApplyRemoteRowDelete(t *testing.T) {
a := newSyncTestApp(t)
saved, _ := a.store.SaveTodo(Todo{Title: "将被远端删除"})
applied, e := a.applyRemoteRow(todoSync, map[string]any{
"uuid": saved.UUID, "title": saved.Title, "content": "", "project_name": "",
"due_at": "", "priority": "medium", "status": "open",
"created_at": saved.CreatedAt, "updated_at": "2999-01-01T00:00:00Z", "deleted": int64(1),
})
if e != nil || !applied {
t.Fatalf("remote delete should apply: %v %v", applied, e)
}
todos, _ := a.store.ListTodos("all", 0)
if len(todos) != 0 {
t.Fatalf("todo should be soft-deleted, got %#v", todos)
}
}
func TestApplyRemoteNoteMerge(t *testing.T) {
a := newSyncTestApp(t)
if _, e := a.store.SaveNote("本地内容"); e != nil {
t.Fatal(e)
}
// 其它设备的 noteuuid 不同)按多条模型 upsert 为一条新笔记
applied, e := a.applyRemoteRow(noteSync, map[string]any{
"uuid": "other-device-uuid", "content": "远端更新内容", "updated_at": "2999-01-01T00:00:00Z", "deleted": int64(0),
})
if e != nil || !applied {
t.Fatalf("newer remote note should apply: %v %v", applied, e)
}
notes, _ := a.store.ListNotes(0)
if len(notes) != 2 {
t.Fatalf("expect 2 notes after pull, got %d", len(notes))
}
// 最近更新的一条应是远端行;本地行内容不被覆盖
n, _ := a.store.GetNote()
if n.Content != "远端更新内容" {
t.Fatalf("unexpected latest note content: %q", n.Content)
}
// 更旧的远端更新不应覆盖同 uuid 行
applied, _ = a.applyRemoteRow(noteSync, map[string]any{
"uuid": "other-device-uuid", "content": "旧内容", "updated_at": "2020-01-01T00:00:00Z", "deleted": int64(0),
})
if applied {
t.Fatal("stale remote note should be skipped")
}
// 远端软删同 uuid 行应生效
applied, e = a.applyRemoteRow(noteSync, map[string]any{
"uuid": "other-device-uuid", "content": "远端更新内容", "updated_at": "2999-01-02T00:00:00Z", "deleted": int64(1),
})
if e != nil || !applied {
t.Fatalf("remote note delete should apply: %v %v", applied, e)
}
if notes, _ = a.store.ListNotes(0); len(notes) != 1 {
t.Fatalf("expect 1 note after remote delete, got %d", len(notes))
}
}
func TestSyncConfigHelpers(t *testing.T) {
if syncConfigComplete(SyncConfig{Host: "h", User: "u"}) {
t.Fatal("incomplete config accepted")
}
c := SyncConfig{Host: "db.local", Port: 3307, User: "root", Password: "pw", Database: "cc"}
if !syncConfigComplete(c) {
t.Fatal("complete config rejected")
}
dsn := syncDSN(c)
for _, want := range []string{"db.local:3307", "root:pw@", "/cc", "charset=utf8mb4"} {
if !strings.Contains(dsn, want) {
t.Fatalf("dsn %q missing %q", dsn, want)
}
}
}
// ---------- 机器码同步:项目身份 / 机器路径分离 ----------
func TestMachineIDStableAndCached(t *testing.T) {
a := newSyncTestApp(t)
id1 := a.machineID()
if id1 == "" || len(id1) != 16 {
t.Fatalf("unexpected machine id: %q", id1)
}
if id2 := a.machineID(); id2 != id1 {
t.Fatalf("machine id not stable: %q vs %q", id1, id2)
}
if a.store.Meta("machine_id") != id1 {
t.Fatal("machine id should be cached in meta")
}
}
func TestProjectsDocV2OmitsPath(t *testing.T) {
a := newSyncTestApp(t)
dir := t.TempDir()
if _, e := a.store.SaveProject(0, ProjectInput{Name: "alpha", Path: dir, Description: "d1"}); e != nil {
t.Fatal(e)
}
doc, e := a.projectsDoc()
if e != nil {
t.Fatal(e)
}
if strings.Contains(doc, `"path"`) || strings.Contains(doc, filepath.Base(dir)) {
t.Fatalf("v2 doc must not contain machine paths: %s", doc)
}
if !strings.Contains(doc, `"name":"alpha"`) || !strings.Contains(doc, `"description":"d1"`) {
t.Fatalf("doc missing identity fields: %s", doc)
}
}
func TestApplyProjectsDocV1CompatAndPending(t *testing.T) {
a := newSyncTestApp(t)
dir := t.TempDir()
if _, e := a.store.SaveProject(0, ProjectInput{Name: "oldname", Path: dir}); e != nil {
t.Fatal(e)
}
// v1 文档:元素含 path。本地按 path 匹配 → 改名并更新身份;其它机器的项目 → 待绑定,不落库。
doc := `[{"name":"renamed","path":` + strconv.Quote(dir) + `,"description":"from v1","group":"G1","favorite":true},` +
`{"name":"remote-only","path":"C:\\other\\pc\\proj","description":"elsewhere"}]`
if e := a.applyProjectsDoc(doc); e != nil {
t.Fatal(e)
}
var name, desc string
var gid int64
if e := a.store.db.QueryRow(`SELECT name,description,group_id FROM projects WHERE path=?`, dir).Scan(&name, &desc, &gid); e != nil {
t.Fatal(e)
}
if name != "renamed" || desc != "from v1" || gid < 2 {
t.Fatalf("v1 doc not applied by path match: %s %s %d", name, desc, gid)
}
var cnt int
_ = a.store.db.QueryRow(`SELECT COUNT(*) FROM projects`).Scan(&cnt)
if cnt != 1 {
t.Fatalf("remote-only project must not be inserted, got %d projects", cnt)
}
pending, e := a.ListCloudPendingProjects()
if e != nil {
t.Fatal(e)
}
if len(pending) != 1 || pending[0].Name != "remote-only" || pending[0].Path != "" {
t.Fatalf("unexpected pending list: %#v", pending)
}
}
func TestMachinePathsDocRoundtrip(t *testing.T) {
a := newSyncTestApp(t)
dir1, dir2 := t.TempDir(), t.TempDir()
if _, e := a.store.SaveProject(0, ProjectInput{Name: "p1", Path: dir1}); e != nil {
t.Fatal(e)
}
doc, e := a.machinePathsDoc()
if e != nil {
t.Fatal(e)
}
if !strings.Contains(doc, `"p1"`) {
t.Fatalf("paths doc missing project: %s", doc)
}
// 远端本机行里多一个项目(如重装后换库)→ 自动入库
var m map[string]string
if e := json.Unmarshal([]byte(doc), &m); e != nil {
t.Fatal(e)
}
m["p2"] = dir2
b, _ := json.Marshal(m)
if e := a.applyMachinePaths(string(b)); e != nil {
t.Fatal(e)
}
var got string
if e := a.store.db.QueryRow(`SELECT path FROM projects WHERE name='p2'`).Scan(&got); e != nil || got != dir2 {
t.Fatalf("p2 not inserted from machine paths: %q %v", got, e)
}
// 同名项目路径变化 → 以远端为准更新
m["p1"] = dir2 + "_moved"
b, _ = json.Marshal(m)
if e := a.applyMachinePaths(string(b)); e != nil {
t.Fatal(e)
}
if e := a.store.db.QueryRow(`SELECT path FROM projects WHERE name='p1'`).Scan(&got); e != nil || got != dir2+"_moved" {
t.Fatalf("p1 path not updated: %q %v", got, e)
}
}
func TestBindCloudProject(t *testing.T) {
a := newSyncTestApp(t)
// 构造待绑定清单(模拟拉取到云端身份但本机无路径)
doc := `[{"name":"cloudproj","description":"云端项目","group":"G2","favorite":true}]`
if e := a.applyProjectsDoc(doc); e != nil {
t.Fatal(e)
}
if pending, _ := a.ListCloudPendingProjects(); len(pending) != 1 {
t.Fatalf("expect 1 pending, got %d", len(pending))
}
if _, e := a.BindCloudProject("cloudproj", filepath.Join(t.TempDir(), "not-exist")); e == nil {
t.Fatal("bind to missing dir should fail")
}
dir := t.TempDir()
p, e := a.BindCloudProject("cloudproj", dir)
if e != nil {
t.Fatal(e)
}
if p.Name != "cloudproj" || p.Path != dir || p.Description != "云端项目" {
t.Fatalf("unexpected bound project: %#v", p)
}
var fav int
_ = a.store.db.QueryRow(`SELECT COUNT(*) FROM favorites WHERE project_id=?`, p.ID).Scan(&fav)
if fav != 1 {
t.Fatal("favorite flag should be applied on bind")
}
if pending, _ := a.ListCloudPendingProjects(); len(pending) != 0 {
t.Fatalf("pending should be cleared after bind, got %#v", pending)
}
}

Some files were not shown because too many files have changed in this diff Show More