更新若干功能
This commit is contained in:
44
.cursor/rules/build-packaging.mdc
Normal file
44
.cursor/rules/build-packaging.mdc
Normal 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 并嵌入,无需手动。
|
||||
1
.task/checksum/build-frontend--DEV--RUNNER-npm-
Normal file
1
.task/checksum/build-frontend--DEV--RUNNER-npm-
Normal file
@@ -0,0 +1 @@
|
||||
cedbecabd994aac478c9127719ea7b09
|
||||
1
.task/checksum/common-generate-icons
Normal file
1
.task/checksum/common-generate-icons
Normal file
@@ -0,0 +1 @@
|
||||
b49e83b187f47c4d3f792713163199ef
|
||||
1
.task/checksum/windows-common-generate-bindings
Normal file
1
.task/checksum/windows-common-generate-bindings
Normal file
@@ -0,0 +1 @@
|
||||
4fa5aa6193227681772cfc8412c2ad90
|
||||
1
.task/checksum/windows-common-generate-icons
Normal file
1
.task/checksum/windows-common-generate-icons
Normal file
@@ -0,0 +1 @@
|
||||
b49e83b187f47c4d3f792713163199ef
|
||||
1
.task/checksum/windows-common-install-frontend-deps-npm
Normal file
1
.task/checksum/windows-common-install-frontend-deps-npm
Normal file
@@ -0,0 +1 @@
|
||||
5d6a0612785e17429ea40e69d09a5b14
|
||||
37
Taskfile.yml
Normal file
37
Taskfile.yml
Normal 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
803
ai.go
Normal 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 / 低危 %d,TODO 标记 %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
228
ai_scope.go
Normal file
@@ -0,0 +1,228 @@
|
||||
package main
|
||||
|
||||
// ai_scope.go 提供页面级 AI 总结的上下文聚合:
|
||||
// 启动台 / 运行日志 / 排期日历 / 配置 / 笔记 五个全局 scope,
|
||||
// 结果统一存入 day_briefs(projectID=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,端口 %v,PID %d)CPU %.1f%%,内存 %.0fMB,IO %.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 %s,DeepSeek Key %s,Key 云同步 %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
168
avatar.go
Normal file
@@ -0,0 +1,168 @@
|
||||
package main
|
||||
|
||||
// avatar.go 实现用户头像的几种来源:
|
||||
// base64(选本地图缩放后存 dataURL,可随账号同步)、url(OSS/图床直链)、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 把本地图片读成缩放后的 dataURL(path 模式启动时用来渲染头像)。
|
||||
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
178
avatar_test.go
Normal 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
398
build/Taskfile.yml
Normal 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
79
build/config.yml
Normal 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
BIN
build/darwin/Assets.car
Normal file
Binary file not shown.
220
build/darwin/Taskfile.yml
Normal file
220
build/darwin/Taskfile.yml
Normal 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}}
|
||||
BIN
build/darwin/dmg-background.png
Normal file
BIN
build/darwin/dmg-background.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
BIN
build/darwin/dmg-file-icon.icns
Normal file
BIN
build/darwin/dmg-file-icon.icns
Normal file
Binary file not shown.
BIN
build/darwin/dmg-file-icon.png
Normal file
BIN
build/darwin/dmg-file-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.7 MiB |
BIN
build/darwin/icons.icns
Normal file
BIN
build/darwin/icons.icns
Normal file
Binary file not shown.
220
build/docker/Dockerfile.cross
Normal file
220
build/docker/Dockerfile.cross
Normal 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"]
|
||||
67
build/docker/Dockerfile.server
Normal file
67
build/docker/Dockerfile.server
Normal 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
220
build/linux/Taskfile.yml
Normal 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
|
||||
35
build/linux/appimage/build.sh
Normal file
35
build/linux/appimage/build.sh
Normal 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
13
build/linux/desktop
Normal 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
|
||||
|
||||
|
||||
80
build/linux/nfpm/nfpm.yaml
Normal file
80
build/linux/nfpm/nfpm.yaml
Normal 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"
|
||||
21
build/linux/nfpm/scripts/postinstall.sh
Normal file
21
build/linux/nfpm/scripts/postinstall.sh
Normal 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
|
||||
1
build/linux/nfpm/scripts/postremove.sh
Normal file
1
build/linux/nfpm/scripts/postremove.sh
Normal file
@@ -0,0 +1 @@
|
||||
#!/bin/bash
|
||||
1
build/linux/nfpm/scripts/preinstall.sh
Normal file
1
build/linux/nfpm/scripts/preinstall.sh
Normal file
@@ -0,0 +1 @@
|
||||
#!/bin/bash
|
||||
1
build/linux/nfpm/scripts/preremove.sh
Normal file
1
build/linux/nfpm/scripts/preremove.sh
Normal file
@@ -0,0 +1 @@
|
||||
#!/bin/bash
|
||||
195
build/windows/Taskfile.yml
Normal file
195
build/windows/Taskfile.yml
Normal 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}}
|
||||
55
build/windows/msix/app_manifest.xml
Normal file
55
build/windows/msix/app_manifest.xml
Normal 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>
|
||||
54
build/windows/msix/template.xml
Normal file
54
build/windows/msix/template.xml
Normal 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>
|
||||
119
build/windows/nsis/project.nsi
Normal file
119
build/windows/nsis/project.nsi
Normal 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
|
||||
261
build/windows/nsis/wails_tools.nsh
Normal file
261
build/windows/nsis/wails_tools.nsh
Normal 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
150
clone.go
Normal 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
45
clone_test.go
Normal 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
197
cloudbind.go
Normal 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
215
festival.go
Normal 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
219
festival_test.go
Normal 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
183
fileapi.go
Normal 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/admin);scope=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
175
filestorage.go
Normal 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
BIN
frontend/public/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
BIN
frontend/src/assets/logo.png
Normal file
BIN
frontend/src/assets/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
66
frontend/src/components/AIBrief.vue
Normal file
66
frontend/src/components/AIBrief.vue
Normal 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>
|
||||
92
frontend/src/components/AIDayPanel.vue
Normal file
92
frontend/src/components/AIDayPanel.vue
Normal 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>
|
||||
101
frontend/src/components/AIScopeDrawer.vue
Normal file
101
frontend/src/components/AIScopeDrawer.vue
Normal 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_briefs(projectID=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>
|
||||
53
frontend/src/components/AboutModal.vue
Normal file
53
frontend/src/components/AboutModal.vue
Normal 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>
|
||||
103
frontend/src/components/CommandPalette.vue
Normal file
103
frontend/src/components/CommandPalette.vue
Normal 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>
|
||||
141
frontend/src/components/DailyCard.vue
Normal file
141
frontend/src/components/DailyCard.vue
Normal 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>
|
||||
125
frontend/src/components/DatePicker.vue
Normal file
125
frontend/src/components/DatePicker.vue
Normal 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>
|
||||
74
frontend/src/components/DueQuickPick.vue
Normal file
74
frontend/src/components/DueQuickPick.vue
Normal 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>
|
||||
123
frontend/src/components/FilePreviewModal.vue
Normal file
123
frontend/src/components/FilePreviewModal.vue
Normal 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>
|
||||
96
frontend/src/components/LifecycleTimeline.vue
Normal file
96
frontend/src/components/LifecycleTimeline.vue
Normal 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>
|
||||
117
frontend/src/components/LoginModal.vue
Normal file
117
frontend/src/components/LoginModal.vue
Normal 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>
|
||||
59
frontend/src/components/MarkdownView.vue
Normal file
59
frontend/src/components/MarkdownView.vue
Normal 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>
|
||||
79
frontend/src/components/MessageBell.vue
Normal file
79
frontend/src/components/MessageBell.vue
Normal 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>
|
||||
74
frontend/src/components/NoteCenter.vue
Normal file
74
frontend/src/components/NoteCenter.vue
Normal 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>
|
||||
74
frontend/src/components/NoteModal.vue
Normal file
74
frontend/src/components/NoteModal.vue
Normal 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>
|
||||
225
frontend/src/components/ProjectAIDrawer.vue
Normal file
225
frontend/src/components/ProjectAIDrawer.vue
Normal 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>
|
||||
105
frontend/src/components/TaskCenter.vue
Normal file
105
frontend/src/components/TaskCenter.vue
Normal 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>
|
||||
140
frontend/src/components/TaskDetailModal.vue
Normal file
140
frontend/src/components/TaskDetailModal.vue
Normal 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>
|
||||
68
frontend/src/components/TeamSwitcher.vue
Normal file
68
frontend/src/components/TeamSwitcher.vue
Normal 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
31
frontend/src/dateutil.js
Normal 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
151
frontend/src/eggart.js
Normal 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
53
frontend/src/mdeditor.js
Normal 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' : ''}\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
56
frontend/src/team.js
Normal 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 key(errors.XXX),未识别返回空。
|
||||
export function teamErrCode(e) {
|
||||
return String(e).split(':')[0].trim()
|
||||
}
|
||||
268
frontend/src/views/AIChat.vue
Normal file
268
frontend/src/views/AIChat.vue
Normal 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>
|
||||
504
frontend/src/views/CalendarPage.vue
Normal file
504
frontend/src/views/CalendarPage.vue
Normal 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>
|
||||
237
frontend/src/views/Launchpad.vue
Normal file
237
frontend/src/views/Launchpad.vue
Normal 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>
|
||||
82
frontend/src/views/Messages.vue
Normal file
82
frontend/src/views/Messages.vue
Normal 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>
|
||||
66
frontend/src/views/Notes.vue
Normal file
66
frontend/src/views/Notes.vue
Normal 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>
|
||||
515
frontend/src/views/Profile.vue
Normal file
515
frontend/src/views/Profile.vue
Normal 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)
|
||||
}
|
||||
// 后端存 UTC(RFC3339),展示时转为本地时区
|
||||
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)
|
||||
// 存储走向由后端按管理员全局配置实时决定(auto):server 时上传返回 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>
|
||||
268
frontend/src/views/TeamHome.vue
Normal file
268
frontend/src/views/TeamHome.vue
Normal 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>
|
||||
173
frontend/src/views/TeamReports.vue
Normal file
173
frontend/src/views/TeamReports.vue
Normal 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>
|
||||
234
frontend/src/views/TeamTasks.vue
Normal file
234
frontend/src/views/TeamTasks.vue
Normal 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>
|
||||
193
frontend/src/views/Tickets.vue
Normal file
193
frontend/src/views/Tickets.vue
Normal 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>
|
||||
97
frontend/src/views/Today.vue
Normal file
97
frontend/src/views/Today.vue
Normal 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>
|
||||
206
frontend/src/views/Todos.vue
Normal file
206
frontend/src/views/Todos.vue
Normal 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>
|
||||
172
frontend/src/views/Workbench.vue
Normal file
172
frontend/src/views/Workbench.vue
Normal 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
171
images.go
Normal 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 引用的本地图片读成 dataURL(path 模式渲染时用)。
|
||||
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
187
images_test.go
Normal 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
211
init.sql
Normal file
@@ -0,0 +1,211 @@
|
||||
-- ============================================================
|
||||
-- 年糕崽崽项目管理(PMS)同步服务初始化脚本(MySQL 5.7+ / 8.x)
|
||||
-- 用法:mysql -u root -p < init.sql
|
||||
-- 应用不执行任何建表/迁移(DDL),使用同步功能前必须先执行本脚本;
|
||||
-- 应用连接账号只需要对 code_count 库的 SELECT/INSERT/UPDATE 权限。
|
||||
-- 默认账号:liqi / qiqi991012(bcrypt 哈希存储,可在应用内注册新账号)
|
||||
-- ============================================================
|
||||
|
||||
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 为 TEXT(64KB),
|
||||
-- 待办/工单内容支持 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
428
launchpad.go
Normal 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
92
launchpad_test.go
Normal 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
21
machineid_other.go
Normal 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
19
machineid_windows.go
Normal 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
173
profile.go
Normal 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 源缩到 64px,url 源原样返回。
|
||||
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
55
search.go
Normal 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
60
search_test.go
Normal 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
68
service/ai/ai_test.go
Normal 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
49
service/ai/factory.go
Normal 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 创建讯飞星火 Lite(OpenAI 兼容 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 创建 DeepSeek(OpenAI 兼容)。
|
||||
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
117
service/ai/openai.go
Normal 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
24
service/ai/provider.go
Normal 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
49
service/scheduler.go
Normal 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 已过当天 at(HH: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 | everyNHours;at 为 HH:MM;last 为上次执行时间(调用方保证非零)。
|
||||
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
69
service/scheduler_test.go
Normal 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
343
shell.go
Normal 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()
|
||||
}
|
||||
141
sync_docs_test.go
Normal file
141
sync_docs_test.go
Normal 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
306
sync_integration_test.go
Normal file
@@ -0,0 +1,306 @@
|
||||
package main
|
||||
|
||||
// 账号全链路集成测试:注册 → 登录 → 加密 Key 推送 → 修改密码(密文轮换)→ 新密码登录 → 再同步。
|
||||
// 需要本机 MySQL(root/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
290
sync_test.go
Normal 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)
|
||||
}
|
||||
// 其它设备的 note(uuid 不同)按多条模型 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
Reference in New Issue
Block a user