diff --git a/.cursor/rules/build-packaging.mdc b/.cursor/rules/build-packaging.mdc index 36fba5d..b085788 100644 --- a/.cursor/rules/build-packaging.mdc +++ b/.cursor/rules/build-packaging.mdc @@ -11,9 +11,14 @@ alwaysApply: true # ✅ 正确:产出 bin/code-count.exe(带图标、版本信息、无控制台窗口) wails3 task windows:build -# ✅ 安装包(NSIS,内部会先执行上面的 build) +# ✅ 安装包(NSIS;先 api:package,再 windows:build + makensis) +# 若报 makensis not found,把 NSIS 加入 PATH 后再跑,例如: +# $env:Path = "C:\Program Files (x86)\NSIS;" + $env:Path wails3 task package +# ✅ 仅打包后端(有改动才重编;强制:wails3 task api:package FORCE=1) +wails3 task api:package + # ❌ 错误:裸 go build 出的 exe 没有图标、双击会闪控制台黑框 go build -o bin/code-count.exe . ``` @@ -27,6 +32,28 @@ production 构建还带 `-tags production -ldflags "-w -s -H windowsgui"`。 裸 `go build` 仅可用于快速验证编译通过(如 `go vet` / `go test` 前置检查), 产物不得交付给用户。 +## 后端一并打包(必须是 linux/amd64) + +`wails3 task package` 会先跑 `api:package`:检测同级目录 `../nl-pms-api` 源码指纹, +有改动才交叉编译到 `bin/nl-pms-api/`,并带上 `init.sql`、`migrations/*.sql`、 +`config.example.yaml`。部署服务器时拷该目录即可。 + +### 硬性要求(打包后必须核对) + +- 目标平台固定为 **`GOOS=linux` + `GOARCH=amd64`**(线上 Linux 服务器用)。 +- 产物路径:`bin/nl-pms-api/nl-pms-api`(**无** `.exe` 后缀)。 +- **禁止**把 Windows 本机 `go build` 出的 `nl-pms-api.exe` 当作后端交付物。 +- 脚本 `tools/package-api.ps1` 编完后会校验文件头为 ELF(`7F 45 4C 46`);不是 ELF 则失败。 +- 人工抽查(可选): + +```powershell +# 应为 ELF 64-bit LSB executable, x86-64 +Format-Hex bin\nl-pms-api\nl-pms-api -Count 4 +# 前 4 字节应为 7F 45 4C 46 +``` + +单独只打后端:`wails3 task api:package`;强制重编:`wails3 task api:package FORCE=1`。 + ## 换 logo 的流程 1. 替换 `build/appicon.png`(源头,正方形 PNG)。 diff --git a/.task/checksum/build-frontend--DEV--RUNNER-npm- b/.task/checksum/build-frontend--DEV--RUNNER-npm- index f21d393..ab2d06c 100644 --- a/.task/checksum/build-frontend--DEV--RUNNER-npm- +++ b/.task/checksum/build-frontend--DEV--RUNNER-npm- @@ -1 +1 @@ -e4f8c1296afa8a28d19333bcd28c94b1 +4243843db83040eda88090772ddad099 diff --git a/.task/checksum/windows-common-generate-bindings b/.task/checksum/windows-common-generate-bindings index c1f373f..4b8b1ef 100644 --- a/.task/checksum/windows-common-generate-bindings +++ b/.task/checksum/windows-common-generate-bindings @@ -1 +1 @@ -62c10364921311d089da2985d202f33e +64c6e855501f26014a9444278e67488d diff --git a/Taskfile.yml b/Taskfile.yml index 433a6aa..bb08030 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -22,10 +22,18 @@ tasks: - task: "{{.GOOS}}:build" package: - summary: Packages a production build of the application + summary: Packages a production build of the application (+ nl-pms-api when changed) cmds: + - task: api:package - task: "{{.GOOS}}:package" + # 打包云端 API:源码有改动才重编;FORCE=1 强制。 + # 产出 bin/nl-pms-api/(linux/amd64 二进制 + init.sql + migrations + config.example.yaml) + api:package: + summary: Packages nl-pms-api into bin/nl-pms-api (skip if unchanged) + cmds: + - powershell -NoProfile -ExecutionPolicy Bypass -File ./tools/package-api.ps1 {{if eq .FORCE "1"}}-Force{{end}} + run: summary: Runs the application cmds: diff --git a/admin.go b/admin.go new file mode 100644 index 0000000..3b358f3 --- /dev/null +++ b/admin.go @@ -0,0 +1,372 @@ +package main + +// admin.go 云端管理员(id=1)运营后台:TOTP/stepup、统计、用户/团队、发版。 + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "mime/multipart" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/wailsapp/wails/v3/pkg/application" +) + +func (a *App) requireCloudAdmin() error { + if a.syncUserID() != festivalAdminID { + return errors.New("FORBIDDEN") + } + return nil +} + +func (a *App) currentStepUpToken() string { + a.stepupMu.Lock() + defer a.stepupMu.Unlock() + if a.stepupToken == "" || time.Now().After(a.stepupExp) { + a.stepupToken = "" + return "" + } + return a.stepupToken +} + +func (a *App) setStepUpToken(tok string, exp time.Time) { + a.stepupMu.Lock() + a.stepupToken = strings.TrimSpace(tok) + a.stepupExp = exp + a.stepupMu.Unlock() +} + +func (a *App) clearStepUpToken() { + a.stepupMu.Lock() + a.stepupToken = "" + a.stepupExp = time.Time{} + a.stepupMu.Unlock() +} + +func (a *App) stepUpHeaders() (map[string]string, error) { + tok := a.currentStepUpToken() + if tok == "" { + return nil, errors.New("ADMIN_STEPUP_REQUIRED") + } + return map[string]string{"X-Admin-StepUp": tok}, nil +} + +func (a *App) adminDecode(method, path string, body, out any, needStepUp bool) error { + if e := a.ready(); e != nil { + return e + } + if e := a.requireCloudAdmin(); e != nil { + return e + } + var headers map[string]string + if needStepUp { + h, e := a.stepUpHeaders() + if e != nil { + return e + } + headers = h + } + err := a.apiDecodeHeaders(method, path, body, out, true, headers) + if err != nil { + code := err.Error() + if code == "ADMIN_IP_CHANGED" || code == "ADMIN_STEPUP_REQUIRED" { + a.clearStepUpToken() + } + } + return err +} + +// AdminTOTPStatus 查询 TOTP 绑定状态。 +func (a *App) AdminTOTPStatus() (map[string]any, error) { + var out map[string]any + if e := a.adminDecode(http.MethodGet, "/api/v1/admin/totp/status", nil, &out, false); e != nil { + return nil, e + } + out["stepupActive"] = a.currentStepUpToken() != "" + if tok := a.currentStepUpToken(); tok != "" { + a.stepupMu.Lock() + out["stepupExpiresAt"] = a.stepupExp.UTC().Format(time.RFC3339) + a.stepupMu.Unlock() + } + return out, nil +} + +// AdminTOTPSetupBegin 开始绑定 Google Authenticator。 +func (a *App) AdminTOTPSetupBegin() (map[string]any, error) { + var out map[string]any + if e := a.adminDecode(http.MethodPost, "/api/v1/admin/totp/setup", map[string]any{}, &out, false); e != nil { + return nil, e + } + return out, nil +} + +// AdminTOTPSetupConfirm 确认绑定。 +func (a *App) AdminTOTPSetupConfirm(code string) error { + return a.adminDecode(http.MethodPost, "/api/v1/admin/totp/confirm", map[string]string{"code": code}, nil, false) +} + +// AdminStepUp 用动态码换取 2h 敏感操作凭证。 +func (a *App) AdminStepUp(code string) (map[string]any, error) { + var out struct { + StepupToken string `json:"stepupToken"` + ExpiresAt string `json:"expiresAt"` + } + if e := a.adminDecode(http.MethodPost, "/api/v1/admin/stepup", map[string]string{"code": code}, &out, false); e != nil { + return nil, e + } + exp, _ := time.Parse(time.RFC3339, out.ExpiresAt) + if exp.IsZero() { + exp = time.Now().UTC().Add(2 * time.Hour) + } + a.setStepUpToken(out.StepupToken, exp) + return map[string]any{ + "ok": true, + "expiresAt": exp.UTC().Format(time.RFC3339), + "stepupActive": true, + }, nil +} + +// AdminHasStepUp 前端判断是否还需弹动态码。 +func (a *App) AdminHasStepUp() bool { + return a.currentStepUpToken() != "" +} + +// AdminOverview 运营概览。 +func (a *App) AdminOverview(days int) (map[string]any, error) { + if days <= 0 { + days = 14 + } + var out map[string]any + if e := a.adminDecode(http.MethodGet, "/api/v1/admin/stats/overview?days="+strconv.Itoa(days), nil, &out, false); e != nil { + return nil, e + } + return out, nil +} + +// AdminListUsers 用户列表。 +func (a *App) AdminListUsers() ([]map[string]any, error) { + var resp struct { + Items []map[string]any `json:"items"` + } + if e := a.adminDecode(http.MethodGet, "/api/v1/admin/users", nil, &resp, false); e != nil { + return nil, e + } + if resp.Items == nil { + return []map[string]any{}, nil + } + return resp.Items, nil +} + +// AdminPatchUser 更新用户禁 AI / 禁用。field: aiBanned | disabled。 +func (a *App) AdminPatchUser(id int64, field string, value int) error { + body := map[string]any{} + switch field { + case "aiBanned": + body["aiBanned"] = value + case "disabled": + body["disabled"] = value + default: + return errors.New("BAD_REQUEST") + } + return a.adminDecode(http.MethodPatch, "/api/v1/admin/users/"+strconv.FormatInt(id, 10), body, nil, true) +} + +// AdminListTeams 团队列表。 +func (a *App) AdminListTeams() ([]map[string]any, error) { + var resp struct { + Items []map[string]any `json:"items"` + } + if e := a.adminDecode(http.MethodGet, "/api/v1/admin/teams", nil, &resp, false); e != nil { + return nil, e + } + if resp.Items == nil { + return []map[string]any{}, nil + } + return resp.Items, nil +} + +// AdminPatchTeam 更新团队禁 AI。 +func (a *App) AdminPatchTeam(id int64, aiBanned int) error { + return a.adminDecode(http.MethodPatch, "/api/v1/admin/teams/"+strconv.FormatInt(id, 10), map[string]any{ + "aiBanned": aiBanned, + }, nil, true) +} + +// AdminListReleases 发版列表。 +func (a *App) AdminListReleases() ([]map[string]any, error) { + var resp struct { + Items []map[string]any `json:"items"` + } + if e := a.adminDecode(http.MethodGet, "/api/v1/admin/releases", nil, &resp, false); e != nil { + return nil, e + } + if resp.Items == nil { + return []map[string]any{}, nil + } + return resp.Items, nil +} + +// AdminSelectReleaseFile 选择本地 NSIS 安装包。 +func (a *App) AdminSelectReleaseFile() (string, error) { + return application.Get().Dialog.OpenFile(). + SetTitle(a.localized("选择安装包", "Select installer")). + CanChooseDirectories(false). + CanChooseFiles(true). + AddFilter("Installer", "*.exe"). + PromptForSingleSelection() +} + +// AdminUploadRelease 上传发版包(需 stepup)。 +func (a *App) AdminUploadRelease(version, channel, changelog, filePath string) (map[string]any, error) { + if e := a.ready(); e != nil { + return nil, e + } + if e := a.requireCloudAdmin(); e != nil { + return nil, e + } + headers, e := a.stepUpHeaders() + if e != nil { + return nil, e + } + filePath = strings.TrimSpace(filePath) + if filePath == "" { + return nil, errors.New("FILE_REQUIRED") + } + f, e := os.Open(filePath) + if e != nil { + return nil, errors.New("FILE_REQUIRED") + } + defer f.Close() + + var buf bytes.Buffer + w := multipart.NewWriter(&buf) + _ = w.WriteField("version", strings.TrimSpace(version)) + _ = w.WriteField("channel", strings.TrimSpace(channel)) + _ = w.WriteField("changelog", changelog) + part, e := w.CreateFormFile("file", filepath.Base(filePath)) + if e != nil { + return nil, e + } + if _, e = io.Copy(part, f); e != nil { + return nil, errors.New("SAVE_FAILED") + } + _ = w.Close() + + base := a.apiBaseURL() + if base == "" { + return nil, errors.New("SYNC_NOT_CONFIGURED") + } + req, e := http.NewRequest(http.MethodPost, base+"/api/v1/admin/releases", &buf) + if e != nil { + return nil, errors.New("SYNC_OFFLINE") + } + req.Header.Set("Content-Type", w.FormDataContentType()) + tok := strings.TrimSpace(a.store.Meta("sync_access_token")) + if tok == "" { + return nil, errors.New("SYNC_NOT_LOGGED_IN") + } + req.Header.Set("Authorization", "Bearer "+tok) + for k, v := range headers { + req.Header.Set(k, v) + } + client := &http.Client{Timeout: 10 * time.Minute} + resp, e := client.Do(req) + if e != nil { + return nil, errors.New("SYNC_OFFLINE") + } + defer resp.Body.Close() + raw, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20)) + if resp.StatusCode >= 400 { + var er struct { + Error string `json:"error"` + } + if json.Unmarshal(raw, &er) == nil && er.Error != "" { + if er.Error == "ADMIN_IP_CHANGED" || er.Error == "ADMIN_STEPUP_REQUIRED" { + a.clearStepUpToken() + } + return nil, errors.New(er.Error) + } + return nil, errors.New("SYNC_HTTP_" + strconv.Itoa(resp.StatusCode)) + } + var out map[string]any + if json.Unmarshal(raw, &out) != nil { + return nil, errors.New("SYNC_BAD_RESPONSE") + } + return out, nil +} + +// AdminPublishRelease 发布为最新。 +func (a *App) AdminPublishRelease(id int64) (map[string]any, error) { + var out map[string]any + if e := a.adminDecode(http.MethodPost, "/api/v1/admin/releases/"+strconv.FormatInt(id, 10)+"/publish", map[string]any{}, &out, true); e != nil { + return nil, e + } + return out, nil +} + +// runActivityPingLoop 登录后每 10 分钟上报一次日活。 +func (a *App) runActivityPingLoop() { + t := time.NewTicker(10 * time.Minute) + defer t.Stop() + // 启动稍后 ping 一次 + time.Sleep(45 * time.Second) + a.activityPingOnce() + for { + select { + case <-a.ctx.Done(): + return + case <-t.C: + a.activityPingOnce() + } + } +} + +func (a *App) activityPingOnce() { + if a.store == nil || a.bootstrap.State != BootstrapReady { + return + } + if a.syncUserID() <= 0 || a.store.Meta("sync_access_token") == "" { + return + } + _ = a.apiDecode(http.MethodPost, "/api/v1/activity/ping", map[string]any{}, nil, true) +} + +// checkAIPolicy 登录用户调用 AI 前检查服务端策略。 +func (a *App) checkAIPolicy() error { + if a.syncUserID() <= 0 || a.store.Meta("sync_access_token") == "" { + return nil // 离线/未登录:本地 BYOK 不拦 + } + var out struct { + Allowed bool `json:"allowed"` + Reason string `json:"reason"` + } + if e := a.apiDecode(http.MethodGet, "/api/v1/ai/policy", nil, &out, true); e != nil { + return nil // 策略接口失败不阻断本地 AI + } + if !out.Allowed { + if out.Reason == "" { + return errors.New("USER_AI_BANNED") + } + return errors.New(out.Reason) + } + return nil +} + +// reportAIUsage 上报一次 AI 用量(失败忽略)。 +func (a *App) reportAIUsage(provider string, prompt, completion int64, estimated bool) { + if a.syncUserID() <= 0 || a.store.Meta("sync_access_token") == "" { + return + } + _ = a.apiDecode(http.MethodPost, "/api/v1/ai/usage", map[string]any{ + "provider": provider, + "promptTokens": prompt, + "completionTokens": completion, + "estimated": estimated, + }, nil, true) +} diff --git a/ai.go b/ai.go index 08a7b24..ab1a4d0 100644 --- a/ai.go +++ b/ai.go @@ -160,6 +160,9 @@ func (a *App) SendAIMessage(conversationID, projectID int64, scenario, content s if e != nil { return AIConversation{}, e } + if e := a.checkAIPolicy(); e != nil { + return AIConversation{}, e + } var conv AIConversation if conversationID == 0 { @@ -224,7 +227,11 @@ func (a *App) runAIStream(ctx context.Context, provider ai.Provider, convID int6 return } var sb strings.Builder + var usage *ai.Usage for chunk := range stream { + if chunk.Usage != nil { + usage = chunk.Usage + } if chunk.Err != nil { // 已有部分内容则保留落库,方便用户继续。 if sb.Len() > 0 { @@ -236,6 +243,9 @@ func (a *App) runAIStream(ctx context.Context, provider ai.Provider, convID int6 a.emit("ai:stream", aiStreamEvent{ConversationID: convID, Error: chunk.Err.Error(), Done: true}) return } + if chunk.Content == "" { + continue + } sb.WriteString(chunk.Content) a.emit("ai:stream", aiStreamEvent{ConversationID: convID, Delta: chunk.Content}) } @@ -248,6 +258,9 @@ func (a *App) runAIStream(ctx context.Context, provider ai.Provider, convID int6 a.emit("ai:stream", aiStreamEvent{ConversationID: convID, Error: e.Error(), Done: true}) return } + if usage != nil { + go a.reportAIUsage(provider.Name(), usage.PromptTokens, usage.CompletionTokens, usage.Estimated) + } a.store.TouchAIConversation(convID, provider.Name()) a.emit("ai:stream", aiStreamEvent{ConversationID: convID, Done: true, MessageID: m.ID}) } @@ -478,6 +491,9 @@ func (a *App) generateAISummaries(projectID int64, kinds ...string) { if e != nil { return } + if e := a.checkAIPolicy(); e != nil { + return + } st, _ := a.store.Settings() aiSummarySem <- struct{}{} defer func() { <-aiSummarySem }() diff --git a/apiclient.go b/apiclient.go new file mode 100644 index 0000000..33bb128 --- /dev/null +++ b/apiclient.go @@ -0,0 +1,256 @@ +package main + +// apiclient.go 是桌面端访问 nl-pms-api 的 HTTP+JWT 客户端: +// 基址来自打包/本地 sync_base_url;带鉴权请求在 401 时用 refresh token 续期一次后重试。 + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "net/http" + "strconv" + "strings" + "time" +) + +const apiBodyLimit = 16 << 20 + +var apiHTTPClient = &http.Client{Timeout: 30 * time.Second} + +// apiBaseURL 返回当前 API 基址(无尾斜杠)。优先本地 meta,再回退打包默认。 +func (a *App) apiBaseURL() string { + if a.store != nil { + if u := strings.TrimRight(strings.TrimSpace(a.store.Meta("sync_base_url")), "/"); u != "" { + return u + } + } + return strings.TrimRight(strings.TrimSpace(packagedSyncDefaults().BaseURL), "/") +} + +// apiDo 发 JSON HTTP 请求。auth=true 时带 Bearer access token,遇 401 尝试刷新后重试一次。 +// 网络失败 → SYNC_OFFLINE;业务错误体 {"error":"CODE"} → errors.New(CODE)。 +func (a *App) apiDo(method, path string, body any, auth bool) (int, []byte, error) { + return a.apiDoOnce(method, path, body, auth, true, nil) +} + +// apiDoWithHeaders 同 apiDo,可附加额外请求头(如 X-Admin-StepUp)。 +func (a *App) apiDoWithHeaders(method, path string, body any, auth bool, headers map[string]string) (int, []byte, error) { + return a.apiDoOnce(method, path, body, auth, true, headers) +} + +func (a *App) apiDoOnce(method, path string, body any, auth, allowRefresh bool, headers map[string]string) (int, []byte, error) { + base := a.apiBaseURL() + if base == "" { + return 0, nil, errors.New("SYNC_NOT_CONFIGURED") + } + var rdr io.Reader + if body != nil { + b, e := json.Marshal(body) + if e != nil { + return 0, nil, e + } + rdr = bytes.NewReader(b) + } + req, e := http.NewRequest(method, base+path, rdr) + if e != nil { + return 0, nil, errors.New("SYNC_OFFLINE") + } + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + for k, v := range headers { + if strings.TrimSpace(k) != "" && strings.TrimSpace(v) != "" { + req.Header.Set(k, v) + } + } + if auth { + tok := "" + if a.store != nil { + tok = strings.TrimSpace(a.store.Meta("sync_access_token")) + } + if tok == "" { + return 0, nil, errors.New("SYNC_NOT_LOGGED_IN") + } + req.Header.Set("Authorization", "Bearer "+tok) + } + resp, e := apiHTTPClient.Do(req) + if e != nil { + return 0, nil, errors.New("SYNC_OFFLINE") + } + defer resp.Body.Close() + raw, _ := io.ReadAll(io.LimitReader(resp.Body, apiBodyLimit)) + if resp.StatusCode == http.StatusUnauthorized && auth && allowRefresh { + if a.apiTryRefresh() == nil { + return a.apiDoOnce(method, path, body, auth, false, headers) + } + } + if resp.StatusCode >= 400 { + var er struct { + Error string `json:"error"` + } + if json.Unmarshal(raw, &er) == nil && er.Error != "" { + return resp.StatusCode, raw, errors.New(er.Error) + } + return resp.StatusCode, raw, errors.New("SYNC_HTTP_" + strconv.Itoa(resp.StatusCode)) + } + return resp.StatusCode, raw, nil +} + +// apiTryRefresh 用 refresh token 换新双令牌并写入 meta;失败返回错误码。 +func (a *App) apiTryRefresh() error { + if a.store == nil { + return errors.New("SYNC_NOT_LOGGED_IN") + } + rt := strings.TrimSpace(a.store.Meta("sync_refresh_token")) + if rt == "" { + return errors.New("SYNC_NOT_LOGGED_IN") + } + status, raw, e := a.apiDoOnce(http.MethodPost, "/api/v1/auth/refresh", map[string]string{ + "refreshToken": rt, + }, false, false, nil) + if e != nil { + return e + } + if status != http.StatusOK { + return errors.New("UNAUTHORIZED") + } + var out struct { + AccessToken string `json:"accessToken"` + RefreshToken string `json:"refreshToken"` + UserID int64 `json:"userId"` + Username string `json:"username"` + } + if json.Unmarshal(raw, &out) != nil || out.AccessToken == "" { + return errors.New("UNAUTHORIZED") + } + _ = a.store.SetMeta("sync_access_token", out.AccessToken) + if out.RefreshToken != "" { + _ = a.store.SetMeta("sync_refresh_token", out.RefreshToken) + } + if out.UserID > 0 { + _ = a.store.SetMeta("sync_user_id", strconv.FormatInt(out.UserID, 10)) + } + if out.Username != "" { + _ = a.store.SetMeta("sync_username", out.Username) + } + return nil +} + +// apiDecode 调用 apiDo 并把 2xx JSON 解到 out(out 可为 nil)。 +func (a *App) apiDecode(method, path string, body, out any, auth bool) error { + return a.apiDecodeHeaders(method, path, body, out, auth, nil) +} + +func (a *App) apiDecodeHeaders(method, path string, body, out any, auth bool, headers map[string]string) error { + _, raw, e := a.apiDoWithHeaders(method, path, body, auth, headers) + if e != nil { + return e + } + if out == nil { + return nil + } + if e := json.Unmarshal(raw, out); e != nil { + return errors.New("SYNC_BAD_RESPONSE") + } + return nil +} + +// apiPutSetting 写入(或 LWW 更新)单条设置。 +func (a *App) apiPutSetting(name, value, updatedAt string) error { + return a.apiPutSettingWithStepUp(name, value, updatedAt, false) +} + +func (a *App) apiPutSettingWithStepUp(name, value, updatedAt string, withStepUp bool) error { + headers := map[string]string{} + if withStepUp { + tok := a.currentStepUpToken() + if tok == "" { + return errors.New("ADMIN_STEPUP_REQUIRED") + } + headers["X-Admin-StepUp"] = tok + } + return a.apiDecodeHeaders(http.MethodPut, "/api/v1/settings/"+pathEscape(name), map[string]string{ + "value": value, + "updatedAt": updatedAt, + }, nil, true, headers) +} + +// apiGetSetting 读单条设置;NOT_FOUND 时 ok=false 且 err=nil。 +func (a *App) apiGetSetting(name string) (value, updatedAt string, ok bool, err error) { + var row struct { + Value string `json:"value"` + UpdatedAt string `json:"updatedAt"` + } + e := a.apiDecode(http.MethodGet, "/api/v1/settings/"+pathEscape(name), nil, &row, true) + if e != nil { + if e.Error() == "NOT_FOUND" { + return "", "", false, nil + } + return "", "", false, e + } + return row.Value, row.UpdatedAt, true, nil +} + +// apiGetGlobalSetting 读挂在管理员名下的全局设置。 +func (a *App) apiGetGlobalSetting(name string) (value, updatedAt string, ok bool, err error) { + var row struct { + Value string `json:"value"` + UpdatedAt string `json:"updatedAt"` + } + e := a.apiDecode(http.MethodGet, "/api/v1/settings/global/"+pathEscape(name), nil, &row, true) + if e != nil { + if e.Error() == "NOT_FOUND" { + return "", "", false, nil + } + return "", "", false, e + } + return row.Value, row.UpdatedAt, true, nil +} + +// apiListSettings 按前缀批量拉取(如 fest_img:)。 +func (a *App) apiListSettings(prefix string) ([]struct { + Name string `json:"name"` + Value string `json:"value"` + UpdatedAt string `json:"updatedAt"` +}, error) { + var resp struct { + Items []struct { + Name string `json:"name"` + Value string `json:"value"` + UpdatedAt string `json:"updatedAt"` + } `json:"items"` + } + q := "/api/v1/settings?prefix=" + pathEscape(prefix) + if e := a.apiDecode(http.MethodGet, q, nil, &resp, true); e != nil { + return nil, e + } + if resp.Items == nil { + return []struct { + Name string `json:"name"` + Value string `json:"value"` + UpdatedAt string `json:"updatedAt"` + }{}, nil + } + return resp.Items, nil +} + +// pathEscape 对路径段做 URL 转义(保留常见安全字符)。 +func pathEscape(s string) string { + var b strings.Builder + for _, r := range s { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', + r == '-', r == '_', r == '.', r == '~', r == ':': + b.WriteRune(r) + default: + for _, c := range []byte(string(r)) { + b.WriteByte('%') + const hex = "0123456789ABCDEF" + b.WriteByte(hex[c>>4]) + b.WriteByte(hex[c&0xf]) + } + } + } + return b.String() +} diff --git a/app.go b/app.go index eb7c808..bfbfa59 100644 --- a/app.go +++ b/app.go @@ -39,6 +39,10 @@ type App struct { fsMu sync.Mutex fsCfg FileStorageConfig fsCfgAt time.Time + // 管理员敏感操作二次验证(内存,进程内有效) + stepupMu sync.Mutex + stepupToken string + stepupExp time.Time } func NewApp() *App { @@ -57,9 +61,11 @@ func (a *App) ServiceStartup(ctx context.Context, _ application.ServiceOptions) a.startup(ctx) a.RefreshShell() go a.runAutoUpdateLoop() + go a.runAppUpdateLoop() go a.runReminderLoop() go a.runSyncLoop() go a.runTeamDigestLoop() + go a.runActivityPingLoop() return nil } diff --git a/build/sync.defaults.json b/build/sync.defaults.json index 194bdf6..29c98ef 100644 --- a/build/sync.defaults.json +++ b/build/sync.defaults.json @@ -1,7 +1,3 @@ { - "host": "101.43.12.11", - "port": 3306, - "user": "code_count", - "password": "code_count", - "database": "code_count" + "baseUrl": "https://o-api.nailaoyun.cn/pms-api" } diff --git a/build/windows/info.json b/build/windows/info.json index d336bef..dfc5cc5 100644 --- a/build/windows/info.json +++ b/build/windows/info.json @@ -1,15 +1,15 @@ { "fixed": { - "file_version": "0.1.0" + "file_version": "2.0.0" }, "info": { "0000": { - "ProductVersion": "0.1.0", + "ProductVersion": "2.0.0", "CompanyName": "liqi", - "FileDescription": "My Product Description", - "LegalCopyright": "© now, My Company", - "ProductName": "My Product", + "FileDescription": "本地代码统计与项目工作台", + "LegalCopyright": "(c) 2026, liqi", + "ProductName": "年糕崽崽项目管理(PMS)", "Comments": "本地代码统计与项目工作台" } } -} \ No newline at end of file +} diff --git a/build/windows/nsis/MicrosoftEdgeWebview2Setup.exe b/build/windows/nsis/MicrosoftEdgeWebview2Setup.exe new file mode 100644 index 0000000..89a56ec Binary files /dev/null and b/build/windows/nsis/MicrosoftEdgeWebview2Setup.exe differ diff --git a/build/windows/nsis/project.nsi b/build/windows/nsis/project.nsi index 68d5176..fdd2c8f 100644 --- a/build/windows/nsis/project.nsi +++ b/build/windows/nsis/project.nsi @@ -1,4 +1,4 @@ -Unicode true +Unicode true #### ## Please note: Template replacements don't work in this file. They are provided with default defines like @@ -56,6 +56,9 @@ ManifestDPIAware true # !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. +# Finish page: run app checkbox, checked by default +!define MUI_FINISHPAGE_RUN "$INSTDIR\${PRODUCT_EXECUTABLE}" +!define MUI_FINISHPAGE_RUN_TEXT "运行 ${INFO_PRODUCTNAME}" !insertmacro MUI_PAGE_WELCOME # Welcome to the installer page. # !insertmacro MUI_PAGE_LICENSE "resources\eula.txt" # Adds a EULA page to the installer @@ -65,7 +68,7 @@ ManifestDPIAware true !insertmacro MUI_UNPAGE_INSTFILES # Uninstalling page -!insertmacro MUI_LANGUAGE "English" # Set the Language of the installer +!insertmacro MUI_LANGUAGE "SimpChinese" # Simplified Chinese installer UI ## 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"' diff --git a/build/windows/nsis/wails_tools.nsh b/build/windows/nsis/wails_tools.nsh index cd7c4c3..bb169a3 100644 --- a/build/windows/nsis/wails_tools.nsh +++ b/build/windows/nsis/wails_tools.nsh @@ -1,4 +1,4 @@ -# DO NOT EDIT - Generated automatically by `wails build` +# DO NOT EDIT - Generated automatically by `wails build` !include "x64.nsh" !include "WinVer.nsh" @@ -11,13 +11,13 @@ !define INFO_COMPANYNAME "liqi" !endif !ifndef INFO_PRODUCTNAME - !define INFO_PRODUCTNAME "My Product" + !define INFO_PRODUCTNAME "年糕崽崽项目管理(PMS)" !endif !ifndef INFO_PRODUCTVERSION - !define INFO_PRODUCTVERSION "0.1.0" + !define INFO_PRODUCTVERSION "2.0.0" !endif !ifndef INFO_COPYRIGHT - !define INFO_COPYRIGHT "© now, My Company" + !define INFO_COPYRIGHT "(c) 2026, liqi" !endif !ifndef PRODUCT_EXECUTABLE !define PRODUCT_EXECUTABLE "${INFO_PROJECTNAME}.exe" @@ -65,11 +65,11 @@ RequestExecutionLevel "${REQUEST_EXECUTION_LEVEL}" !macro wails.checkArchitecture !ifndef WAILS_WIN10_REQUIRED - !define WAILS_WIN10_REQUIRED "This product is only supported on Windows 10 (Server 2016) and later." + !define WAILS_WIN10_REQUIRED "本产品仅支持 Windows 10(Server 2016)及更高版本。" !endif !ifndef WAILS_ARCHITECTURE_NOT_SUPPORTED - !define WAILS_ARCHITECTURE_NOT_SUPPORTED "This product can't be installed on the current Windows architecture. Supports: ${ARCH}" + !define WAILS_ARCHITECTURE_NOT_SUPPORTED "当前 Windows 架构不支持安装本产品。支持:${ARCH}" !endif ${If} ${AtLeastWin10} @@ -171,7 +171,7 @@ RequestExecutionLevel "${REQUEST_EXECUTION_LEVEL}" # 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" + !define WAILS_INSTALL_WEBVIEW_DETAILPRINT "正在安装:WebView2 运行时" !endif SetRegView 64 diff --git a/build/windows/wails.exe.manifest b/build/windows/wails.exe.manifest index 3754c6b..a0e4b38 100644 --- a/build/windows/wails.exe.manifest +++ b/build/windows/wails.exe.manifest @@ -1,6 +1,6 @@ - + diff --git a/database.go b/database.go index 8cebdd5..ef9ad65 100644 --- a/database.go +++ b/database.go @@ -55,6 +55,8 @@ func OpenStore(path string) (*Store, error) { db.Close() return nil, err } + // 每次打开库:把上次异常退出留下的 running 任务标为失败(表不存在时忽略)。 + s.markInterruptedPackTasks() return s, nil } @@ -66,7 +68,12 @@ func OpenStore(path string) (*Store, error) { // v6:新增 day_briefs(工作台 AI 今日规划 / 下班日报)。 // v7:新增 launch_apps(启动台保存的应用与启停命令)。 // v8:todos/tickets 新增 team_id 团队共享列。 -const schemaVersion = 8 +// v9:新增 launch_logs(启动台进程输出,仅本地,不同步)。 +// v10:launch_apps 新增 icon(本地抓取的 favicon/logo dataURL)。 +// v11:launch_apps 新增 category(我的应用分类筛选)。 +// v12:launch_categories 一级分类;launch_apps.category_id;projects.icon;kind_icons。 +// v13:pack_tasks / pack_task_logs(打包任务与控制台输出,仅本地,不同步)。 +const schemaVersion = 13 func (s *Store) migrate() error { // PRAGMA 是连接级/文件级配置,不属于迁移,每次打开都需执行。 @@ -111,6 +118,14 @@ func (s *Store) migrate() error { `CREATE TABLE IF NOT EXISTS ai_summaries(project_id INTEGER NOT NULL, kind TEXT NOT NULL, content TEXT NOT NULL DEFAULT '', provider TEXT NOT NULL DEFAULT '', generated_at TEXT NOT NULL, PRIMARY KEY(project_id,kind), FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE)`, `CREATE TABLE IF NOT EXISTS day_briefs(kind TEXT PRIMARY KEY, content TEXT NOT NULL DEFAULT '', provider TEXT NOT NULL DEFAULT '', generated_at TEXT NOT NULL)`, `CREATE TABLE IF NOT EXISTS launch_apps(id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, kind TEXT NOT NULL DEFAULT 'other', port INTEGER NOT NULL DEFAULT 0, dir TEXT NOT NULL DEFAULT '', start_cmd TEXT NOT NULL DEFAULT '', stop_cmd TEXT NOT NULL DEFAULT '', last_pid INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)`, + `CREATE TABLE IF NOT EXISTS launch_logs(id INTEGER PRIMARY KEY AUTOINCREMENT, app_id INTEGER NOT NULL, level TEXT NOT NULL DEFAULT 'info', line TEXT NOT NULL, created_at TEXT NOT NULL)`, + `CREATE TABLE IF NOT EXISTS launch_categories(id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, sort INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL)`, + `CREATE TABLE IF NOT EXISTS kind_icons(kind TEXT PRIMARY KEY, value TEXT NOT NULL DEFAULT '', updated_at TEXT NOT NULL DEFAULT '')`, + `CREATE TABLE IF NOT EXISTS pack_tasks(id TEXT PRIMARY KEY, title TEXT NOT NULL DEFAULT '', cmd TEXT NOT NULL DEFAULT '', dir TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT 'running', pid INTEGER NOT NULL DEFAULT 0, started_at TEXT NOT NULL DEFAULT '', ended_at TEXT NOT NULL DEFAULT '', error TEXT NOT NULL DEFAULT '')`, + `CREATE TABLE IF NOT EXISTS pack_task_logs(id INTEGER PRIMARY KEY AUTOINCREMENT, task_id TEXT NOT NULL, line TEXT NOT NULL, created_at TEXT NOT NULL)`, + `CREATE INDEX IF NOT EXISTS idx_launch_logs_app ON launch_logs(app_id, id DESC)`, + `CREATE INDEX IF NOT EXISTS idx_pack_tasks_started ON pack_tasks(started_at DESC)`, + `CREATE INDEX IF NOT EXISTS idx_pack_task_logs_task ON pack_task_logs(task_id, id)`, `CREATE INDEX IF NOT EXISTS idx_ai_messages_conv ON ai_messages(conversation_id)`, `CREATE INDEX IF NOT EXISTS idx_messages_read ON messages(is_read)`, `CREATE INDEX IF NOT EXISTS idx_todos_status ON todos(deleted,status)`, @@ -150,6 +165,34 @@ func (s *Store) migrate() error { } } } + if ok, err := s.columnExists("launch_apps", "icon"); err != nil { + return err + } else if !ok { + if _, err = s.db.Exec(`ALTER TABLE launch_apps ADD COLUMN icon TEXT NOT NULL DEFAULT ''`); err != nil { + return fmt.Errorf("database migration: %w", err) + } + } + if ok, err := s.columnExists("launch_apps", "category"); err != nil { + return err + } else if !ok { + if _, err = s.db.Exec(`ALTER TABLE launch_apps ADD COLUMN category TEXT NOT NULL DEFAULT ''`); err != nil { + return fmt.Errorf("database migration: %w", err) + } + } + if ok, err := s.columnExists("launch_apps", "category_id"); err != nil { + return err + } else if !ok { + if _, err = s.db.Exec(`ALTER TABLE launch_apps ADD COLUMN category_id INTEGER NOT NULL DEFAULT 0`); err != nil { + return fmt.Errorf("database migration: %w", err) + } + } + if ok, err := s.columnExists("projects", "icon"); err != nil { + return err + } else if !ok { + if _, err = s.db.Exec(`ALTER TABLE projects ADD COLUMN icon TEXT NOT NULL DEFAULT ''`); err != nil { + return fmt.Errorf("database migration: %w", err) + } + } _, _ = s.db.Exec(`INSERT OR IGNORE INTO project_groups(id,name,created_at,updated_at) VALUES(1,'My Projects',?,?)`, time.Now().Format(time.RFC3339), time.Now().Format(time.RFC3339)) _, _ = s.db.Exec(`UPDATE projects SET group_id=1 WHERE group_id IS NULL OR group_id=0`) defaults := map[string][]string{"general": {".git", ".idea", ".vscode", "node_modules", "dist", "coverage", "*.min.js", "*.min.css", ".DS_Store"}, "php": {"vendor", "storage/framework", "bootstrap/cache", ".phpunit.cache"}, "go": {"go.sum"}, "vue": {".nuxt", ".next", ".output", "unpackage"}} @@ -291,7 +334,7 @@ func (s *Store) DeleteProjectGroup(id int64) error { return tx.Commit() } func (s *Store) ListProjects(groupID int64) ([]Project, error) { - q := `SELECT p.id,p.name,p.path,p.description,p.group_id,COALESCE(g.name,''),p.created_at,p.updated_at FROM projects p LEFT JOIN project_groups g ON g.id=p.group_id` + q := `SELECT p.id,p.name,p.path,p.description,p.group_id,COALESCE(g.name,''),COALESCE(p.icon,''),p.created_at,p.updated_at FROM projects p LEFT JOIN project_groups g ON g.id=p.group_id` args := []any{} if groupID > 0 { q += ` WHERE p.group_id=?` @@ -305,7 +348,7 @@ func (s *Store) ListProjects(groupID int64) ([]Project, error) { out := []Project{} for rows.Next() { var p Project - if e = rows.Scan(&p.ID, &p.Name, &p.Path, &p.Description, &p.GroupID, &p.GroupName, &p.CreatedAt, &p.UpdatedAt); e != nil { + if e = rows.Scan(&p.ID, &p.Name, &p.Path, &p.Description, &p.GroupID, &p.GroupName, &p.Icon, &p.CreatedAt, &p.UpdatedAt); e != nil { rows.Close() return nil, e } @@ -326,7 +369,7 @@ func (s *Store) ListProjects(groupID int64) ([]Project, error) { } func (s *Store) GetProject(id int64) (Project, error) { var p Project - e := s.db.QueryRow(`SELECT p.id,p.name,p.path,p.description,p.group_id,COALESCE(g.name,''),p.created_at,p.updated_at FROM projects p LEFT JOIN project_groups g ON g.id=p.group_id WHERE p.id=?`, id).Scan(&p.ID, &p.Name, &p.Path, &p.Description, &p.GroupID, &p.GroupName, &p.CreatedAt, &p.UpdatedAt) + e := s.db.QueryRow(`SELECT p.id,p.name,p.path,p.description,p.group_id,COALESCE(g.name,''),COALESCE(p.icon,''),p.created_at,p.updated_at FROM projects p LEFT JOIN project_groups g ON g.id=p.group_id WHERE p.id=?`, id).Scan(&p.ID, &p.Name, &p.Path, &p.Description, &p.GroupID, &p.GroupName, &p.Icon, &p.CreatedAt, &p.UpdatedAt) if e != nil { return p, e } diff --git a/festival_test.go b/festival_test.go index 4abc76c..128dc0f 100644 --- a/festival_test.go +++ b/festival_test.go @@ -18,10 +18,7 @@ import ( // 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") + _ = a.store.SetMeta("sync_base_url", "http://127.0.0.1:1") return a } diff --git a/fileapi.go b/fileapi.go index 93bab19..e20647d 100644 --- a/fileapi.go +++ b/fileapi.go @@ -2,8 +2,8 @@ package main // fileapi.go 调 nl-pms-api 的最小客户端:上传图片换取公开访问 URL, // 以及素材库的列表/删除透传(权限由服务端按 用户/团队角色 判定)。 -// 服务器地址与密钥来自管理员下发的全局文件存储配置(见 filestorage.go)。 -// 请求由 Go 端发起(不走前端 fetch),天然绕开 webview 的 CORS 限制。 +// 服务器地址来自管理员下发的全局文件存储配置(见 filestorage.go); +// 鉴权使用同步登录的 JWT(sync_access_token),不再使用 apiKey / 表单 userId。 import ( "bytes" @@ -20,7 +20,6 @@ import ( 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 { @@ -29,8 +28,6 @@ func (a *App) currentTeamID() int64 { return n } -// fileAPIBase 返回已启用的服务器存储地址(实时读全局配置,离线回本地缓存); -// 未启用返回 FILE_API_UNCONFIGURED。 func (a *App) fileAPIBase() (FileStorageConfig, error) { cfg := a.currentFileStorage() if cfg.Mode != "server" || cfg.BaseURL == "" { @@ -39,7 +36,44 @@ func (a *App) fileAPIBase() (FileStorageConfig, error) { return cfg, nil } -// fileAPIStatusErr 把 nl-pms-api 的非 200 响应映射为客户端错误码。 +func (a *App) syncBearerToken() (string, error) { + tok := "" + if a.store != nil { + tok = strings.TrimSpace(a.store.Meta("sync_access_token")) + } + if tok == "" { + return "", errors.New("SYNC_NOT_LOGGED_IN") + } + return tok, nil +} + +// fileAPIDo 对文件服务发请求;401 时尝试 refresh 后重试一次。 +func (a *App) fileAPIDo(req *http.Request) (*http.Response, error) { + tok, e := a.syncBearerToken() + if e != nil { + return nil, e + } + req.Header.Set("Authorization", "Bearer "+tok) + resp, e := fileAPIClient.Do(req) + if e != nil { + return nil, e + } + if resp.StatusCode == http.StatusUnauthorized { + resp.Body.Close() + if a.apiTryRefresh() != nil { + return nil, errors.New("UNAUTHORIZED") + } + tok, e = a.syncBearerToken() + if e != nil { + return nil, e + } + // 重建请求体不可行时由调用方重试;此处仅对无 body 的 GET/DELETE 重试。 + req.Header.Set("Authorization", "Bearer "+tok) + return fileAPIClient.Do(req) + } + return resp, nil +} + func fileAPIStatusErr(status int) error { switch status { case http.StatusForbidden: @@ -51,14 +85,14 @@ func fileAPIStatusErr(status int) error { } } -// 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 } + if _, e := a.syncBearerToken(); e != nil { + return "", e + } ext := ".jpg" switch mime { case "image/png": @@ -68,51 +102,60 @@ func (a *App) uploadImageToServer(data []byte, mime, kind string) (string, error 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") + doUpload := func() (string, error) { + 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 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()) + tok, _ := a.syncBearerToken() + req.Header.Set("Authorization", "Bearer "+tok) + 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.StatusUnauthorized { + if a.apiTryRefresh() == nil { + return "", errors.New("RETRY") + } + return "", errors.New("IMAGE_UPLOAD_FAILED") + } + 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 } - if _, e = fw.Write(data); e != nil { - return "", errors.New("IMAGE_UPLOAD_FAILED") + url, e := doUpload() + if e != nil && e.Error() == "RETRY" { + return doUpload() } - _ = 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 + return url, e } -// 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 @@ -121,23 +164,24 @@ func (a *App) ListServerFiles(scope string, teamID int64, page int) (ServerFileL if e != nil { return ServerFileList{}, e } - uid := a.syncUserID() - if uid <= 0 { + if a.syncUserID() <= 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) + u := fmt.Sprintf("%s/api/v1/files?scope=%s&teamId=%d&page=%d&pageSize=24", + cfg.BaseURL, scope, 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) + resp, e := a.fileAPIDo(req) if e != nil { - return ServerFileList{}, errors.New("FILE_STORAGE_UNREACHABLE") + if e.Error() == "SYNC_OFFLINE" || e.Error() == "UNAUTHORIZED" { + return ServerFileList{}, errors.New("FILE_STORAGE_UNREACHABLE") + } + return ServerFileList{}, e } defer resp.Body.Close() body, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<20)) @@ -151,8 +195,6 @@ func (a *App) ListServerFiles(scope string, teamID int64, page int) (ServerFileL 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 @@ -161,19 +203,20 @@ func (a *App) DeleteServerFile(id int64) error { if e != nil { return e } - uid := a.syncUserID() - if uid <= 0 { + if a.syncUserID() <= 0 { return errors.New("SYNC_NOT_LOGGED_IN") } - u := fmt.Sprintf("%s/api/v1/files/%d?userId=%d", cfg.BaseURL, id, uid) + u := fmt.Sprintf("%s/api/v1/files/%d", cfg.BaseURL, id) 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) + resp, e := a.fileAPIDo(req) if e != nil { - return errors.New("FILE_STORAGE_UNREACHABLE") + if e.Error() == "SYNC_OFFLINE" || e.Error() == "UNAUTHORIZED" { + return errors.New("FILE_STORAGE_UNREACHABLE") + } + return e } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { diff --git a/filestorage.go b/filestorage.go index 4b624c7..b40375f 100644 --- a/filestorage.go +++ b/filestorage.go @@ -1,15 +1,11 @@ package main // filestorage.go 全局「文件存储方式」配置:由管理员(云端账号 id=1)在设置页配置, -// 权威数据存远端 MySQL 的 sync_settings 表(user_id=1, name='file_storage' 行)。 -// 保存时直接写库立即生效;各端上传图片时实时拉取该配置(短 TTL 缓存), +// 权威数据经 nl-pms-api settings(全局键 file_storage)。 +// 保存时直接写 API 立即生效;各端上传图片时实时拉取该配置(短 TTL 缓存), // 拉取失败(离线/未配置同步)回退本地缓存副本(由同步循环下发,见 syncFileStorageConfig)。 -// mode=local 时一切维持本地行为;mode=server 时内容图与头像选图会上传到 -// nl-pms-api 换取 http URL(远程/跨设备场景无法使用本地路径)。 import ( - "context" - "database/sql" "encoding/json" "errors" "net/http" @@ -23,7 +19,6 @@ const ( fileStorageTTL = 30 * time.Second // 实时配置的内存缓存时长,避免连续上传反复查库 ) -// normalizeFileStorage 收敛非法值:mode 只认 local/server,地址去尾斜杠。 func normalizeFileStorage(c FileStorageConfig) FileStorageConfig { if c.Mode != "server" { c.Mode = "local" @@ -33,37 +28,35 @@ func normalizeFileStorage(c FileStorageConfig) FileStorageConfig { return c } -// fileStorageConfig 读本地缓存的全局配置(未配置返回 local 零值)。 +// defaultFileStorage 未配置时的默认:服务器模式,地址与打包同步 API 基址一致。 +func (a *App) defaultFileStorage() FileStorageConfig { + return normalizeFileStorage(FileStorageConfig{ + Mode: "server", + BaseURL: a.apiBaseURL(), + }) +} + func (a *App) fileStorageConfig() FileStorageConfig { var c FileStorageConfig if raw := a.store.Meta(fileStorageKey); raw != "" { _ = json.Unmarshal([]byte(raw), &c) + return normalizeFileStorage(c) } - return normalizeFileStorage(c) + return a.defaultFileStorage() } -// fetchRemoteFileStorage 从远端 MySQL 读权威配置。第二个返回值表示"远端可用且结果权威" -// (无行记录视作权威的 local);成功时顺带刷新本地缓存副本,供离线兜底。 +// fetchRemoteFileStorage 从 API 读权威配置。第二个返回值表示"远端可用且结果权威" +// (无行记录时回落默认服务器配置);成功时顺带刷新本地缓存副本,供离线兜底。 func (a *App) fetchRemoteFileStorage() (FileStorageConfig, bool) { - base := a.ctx - if base == nil { - base = context.Background() + if a.syncUserID() <= 0 || a.store.Meta("sync_access_token") == "" || a.apiBaseURL() == "" { + return FileStorageConfig{}, false } - ctx, cancel := context.WithTimeout(base, 5*time.Second) - defer cancel() - db, e := a.openRemote(ctx) + val, at, ok, e := a.apiGetGlobalSetting(fileStorageKey) 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 + if !ok { + return a.defaultFileStorage(), true } var c FileStorageConfig if json.Unmarshal([]byte(val), &c) != nil { @@ -77,9 +70,6 @@ func (a *App) fetchRemoteFileStorage() (FileStorageConfig, bool) { return c, true } -// currentFileStorage 返回上传时应采用的全局配置:优先远端实时值(TTL 内存缓存), -// 远端不可达时回退本地缓存副本。管理员改完配置后各端最迟 TTL 内生效,无需等同步轮。 -// 失败结果同样缓存 TTL,避免离线时每次上传都白等一轮连接超时。 func (a *App) currentFileStorage() FileStorageConfig { a.fsMu.Lock() if !a.fsCfgAt.IsZero() && time.Since(a.fsCfgAt) < fileStorageTTL { @@ -98,15 +88,12 @@ func (a *App) currentFileStorage() FileStorageConfig { 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 @@ -114,8 +101,7 @@ func (a *App) GetFileStorageConfig() (FileStorageConfig, error) { return a.currentFileStorage(), nil } -// SaveFileStorageConfig 管理员(id=1)保存全局配置:直接写远端 MySQL 立即全员生效, -// 同时更新本地缓存副本与 LWW 时间戳(同步循环不会再把旧值推回)。要求在线。 +// SaveFileStorageConfig 管理员(id=1)保存全局配置:直接写 API 立即全员生效。 func (a *App) SaveFileStorageConfig(c FileStorageConfig) error { if e := a.ready(); e != nil { return e @@ -131,18 +117,9 @@ func (a *App) SaveFileStorageConfig(c FileStorageConfig) error { 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.apiPutSettingWithStepUp(fileStorageKey, string(b), now, true); e != nil { + return e } if e := a.store.SetMeta(fileStorageKey, string(b)); e != nil { return e @@ -153,7 +130,7 @@ func (a *App) SaveFileStorageConfig(c FileStorageConfig) error { return nil } -// TestFileStorage 探测服务器连通性(GET {baseURL}/healthz),配置界面「测试连接」用。 +// TestFileStorage 探测服务器连通性(GET {baseURL}/healthz)。 func (a *App) TestFileStorage(c FileStorageConfig) error { if e := a.ready(); e != nil { return e diff --git a/frontend/src/App.vue b/frontend/src/App.vue index cf732a1..065c0f2 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -2,7 +2,7 @@ import { computed, onMounted, onUnmounted, ref, watch } from 'vue' import { useRoute, useRouter } from 'vue-router' import { useI18n } from 'vue-i18n' -import { LayoutDashboard, ScrollText, Settings, X, Database, SlidersHorizontal, Home, ListTodo, TicketCheck, CalendarDays, CalendarCheck2, Sparkles, UserRound, ClipboardList, Wrench, Rocket, ChevronDown, Bell, StickyNote, ListFilter, Palette, Bot, Users, NotebookPen, CloudUpload, Power } from 'lucide-vue-next' +import { LayoutDashboard, ScrollText, Settings, X, Database, SlidersHorizontal, Home, ListTodo, TicketCheck, CalendarDays, CalendarCheck2, Sparkles, UserRound, ClipboardList, Wrench, Rocket, ChevronDown, Bell, StickyNote, ListFilter, Palette, Bot, Users, NotebookPen, CloudUpload, Power, Image, Activity, Package, Shield } from 'lucide-vue-next' import { useAppStore } from './store' import DatabaseSetup from './components/DatabaseSetup.vue' import BrowserBlocked from './components/BrowserBlocked.vue' @@ -14,6 +14,7 @@ import AboutModal from './components/AboutModal.vue' import MessageBell from './components/MessageBell.vue' import TaskCenter from './components/TaskCenter.vue' import NoteCenter from './components/NoteCenter.vue' +import LocalPackCenter from './components/LocalPackCenter.vue' import TeamSwitcher from './components/TeamSwitcher.vue' import TitleBar from './components/TitleBar.vue' import { call, isNative, on } from './api' @@ -25,6 +26,9 @@ const { t, locale } = useI18n() const native = isNative() const quickOpen = ref(false) const aboutOpen = ref(false) +const exitOpen = ref(false) +const updateInfo = ref(null) +const updateBusy = ref(false) const appVersion = ref('1.0.0') const displayedTask = ref(null) let off @@ -47,9 +51,17 @@ function openSettings() { router.push('/settings') } -// 侧边栏底部退出:与原生菜单/托盘「退出」一致,绕过最小化到托盘直接退出应用。 -async function quitApp() { - if (!confirm(t('quitConfirm'))) return +// 侧栏头像下退出:模态框选择「退出登录」或「退出程序」。 +async function exitLogout() { + exitOpen.value = false + try { + await call('SyncLogout') + await store.refreshSyncStatus() + store.showToast({ type: 'success', key: 'logoutToast' }) + } catch { /* ignore */ } +} +async function exitQuit() { + exitOpen.value = false try { await call('QuitApp') } catch { /* 预览模式无原生桥 */ } } @@ -79,14 +91,18 @@ const navGroups = [ ] }, { id: 'system', icon: Wrench, label: 'navSystem', items: [ { to: '/launchpad', icon: Rocket, label: 'launchpad' }, + { to: '/ports', icon: Activity, label: 'portMonitor' }, + { to: '/pack-tasks', icon: Package, label: 'packTasksPage' }, { to: '/logs', icon: ScrollText, label: 'logs' } ] }, { id: 'settings', icon: Settings, label: 'settings', items: [ + { to: '/admin', icon: Shield, label: 'adminTitle', adminOnly: true, match: r => r.path === '/admin' }, { to: '/settings?tab=rules', icon: ListFilter, label: 'tabRules', match: r => r.path === '/settings' && settingsTab(r) === 'rules' }, { to: '/settings?tab=appearance', icon: Palette, label: 'tabAppearance', match: r => r.path === '/settings' && settingsTab(r) === 'appearance' }, { to: '/settings?tab=ai', icon: Bot, label: 'aiAnalysis', match: r => r.path === '/settings' && settingsTab(r) === 'ai' }, // 全局文件存储配置:权威数据在服务器,仅管理员(云端账号 id=1)可见可改 { to: '/settings?tab=filestorage', icon: CloudUpload, label: 'tabFileStorage', adminOnly: true, match: r => r.path === '/settings' && settingsTab(r) === 'filestorage' }, + { to: '/settings?tab=kindicons', icon: Image, label: 'tabKindIcons', adminOnly: true, match: r => r.path === '/settings' && settingsTab(r) === 'kindicons' }, { to: '/settings?tab=database', icon: Database, label: 'tabDatabase', match: r => r.path === '/settings' && settingsTab(r) === 'database' } ] } ] @@ -161,12 +177,28 @@ onMounted(async () => { store.refreshSyncStatus() } }), - on('menu:set', p => p?.key && updateSetting(p.key, p.value)) + on('menu:set', p => p?.key && updateSetting(p.key, p.value)), + on('app:update-available', p => { + if (p && !p.upToDate && !p.skipped) updateInfo.value = p + }) ] await store.boot() locale.value = store.settings.locale || 'zh-CN' try { appVersion.value = await call('GetAppVersion') } catch { /* keep fallback */ } }) +async function installUpdate() { + updateBusy.value = true + try { + await call('DownloadAndInstallUpdate') + } catch (e) { + store.showToast({ type: 'error', text: String(e) }) + updateBusy.value = false + } +} +async function skipUpdate() { + try { await call('SkipAppUpdate', updateInfo.value?.latest || '') } catch {} + updateInfo.value = null +} onUnmounted(() => { removeEventListener('keydown', onGlobalKey) removeEventListener('click', onFlyoutAway) @@ -211,7 +243,7 @@ watch(activeTask, task => { - +
+ + +
{{ t('app') }}
@@ -239,7 +275,6 @@ watch(activeTask, task => { + +
+ +
+
+ +
+ +
+
{{ activeTaskProject || visibleTask.stage }}{{ t(visibleTask.messageKey || 'task.start', visibleTask.params || {}) }}
diff --git a/frontend/src/api.js b/frontend/src/api.js index 3859f20..c79ef63 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -52,3 +52,40 @@ export async function copyText(text) { document.body.removeChild(ta) if (!ok) throw new Error('COPY_FAILED') } + +/** 远程 http(s) 图经本地 AssetServer 代理,避免 WebView 直接拉外站破图。 */ +export function displayUrl(url) { + const v = String(url || '').trim() + if (!v) return '' + if (/^(data:|blob:)/i.test(v)) return v + if (/^https?:\/\//i.test(v) && isNative()) { + return `/__ccimg?u=${encodeURIComponent(v)}` + } + return v +} + +/** 远程图优先走 Go 拉成 dataURL(最稳);失败再回退 /__ccimg。 */ +const _imgSrcCache = new Map() +export async function resolveImageSrc(url) { + const v = String(url || '').trim() + if (!v) return '' + if (/^(data:|blob:)/i.test(v)) return v + if (!/^https?:\/\//i.test(v) || !isNative()) return v + if (_imgSrcCache.has(v)) return _imgSrcCache.get(v) + const p = (async () => { + try { + return await call('FetchRemoteImageAsDataURL', v) + } catch { + return displayUrl(v) + } + })() + _imgSrcCache.set(v, p) + try { + const out = await p + _imgSrcCache.set(v, out) + return out + } catch { + _imgSrcCache.delete(v) + return displayUrl(v) + } +} diff --git a/frontend/src/components/AboutModal.vue b/frontend/src/components/AboutModal.vue index 3e07387..e56e126 100644 --- a/frontend/src/components/AboutModal.vue +++ b/frontend/src/components/AboutModal.vue @@ -1,14 +1,17 @@