更新若干功能

This commit is contained in:
李琦
2026-08-15 17:18:00 +08:00
parent 4954295961
commit 6a81e479ef
77 changed files with 8165 additions and 2372 deletions

View File

@@ -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

View File

@@ -1 +1 @@
e4f8c1296afa8a28d19333bcd28c94b1
4243843db83040eda88090772ddad099

View File

@@ -1 +1 @@
62c10364921311d089da2985d202f33e
64c6e855501f26014a9444278e67488d

View File

@@ -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:

372
admin.go Normal file
View File

@@ -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)
}

16
ai.go
View File

@@ -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 }()

256
apiclient.go Normal file
View File

@@ -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 解到 outout 可为 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()
}

6
app.go
View File

@@ -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
}

View File

@@ -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"
}

View File

@@ -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": "本地代码统计与项目工作台"
}
}
}
}

Binary file not shown.

View File

@@ -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"'

View File

@@ -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 10Server 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

View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1" xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">
<assemblyIdentity type="win32" name="com.wails.code-count" version="0.1.0" processorArchitecture="*"/>
<assemblyIdentity type="win32" name="com.liqi.codecount" version="2.0.0.0" processorArchitecture="*"/>
<dependency>
<dependentAssembly>
<assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version="6.0.0.0" processorArchitecture="*" publicKeyToken="6595b64144ccf1df" language="*"/>

View File

@@ -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启动台保存的应用与启停命令
// v8todos/tickets 新增 team_id 团队共享列。
const schemaVersion = 8
// v9新增 launch_logs启动台进程输出仅本地不同步
// v10launch_apps 新增 icon本地抓取的 favicon/logo dataURL
// v11launch_apps 新增 category我的应用分类筛选
// v12launch_categories 一级分类launch_apps.category_idprojects.iconkind_icons。
// v13pack_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
}

View File

@@ -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
}

View File

@@ -2,8 +2,8 @@ package main
// fileapi.go 调 nl-pms-api 的最小客户端:上传图片换取公开访问 URL
// 以及素材库的列表/删除透传(权限由服务端按 用户/团队角色 判定)。
// 服务器地址与密钥来自管理员下发的全局文件存储配置(见 filestorage.go
// 请求由 Go 端发起(不走前端 fetch天然绕开 webview 的 CORS 限制
// 服务器地址来自管理员下发的全局文件存储配置(见 filestorage.go
// 鉴权使用同步登录的 JWTsync_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/adminscope=all 看全部(仅云端账号 id=1
func (a *App) ListServerFiles(scope string, teamID int64, page int) (ServerFileList, error) {
if e := a.ready(); e != nil {
return ServerFileList{}, e
@@ -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 {

View File

@@ -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

View File

@@ -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 => {
<MessageBell />
<TeamSwitcher />
</div>
<button class="rail-user" :class="{ active: route.path === '/profile' }" :title="store.syncStatus.loggedIn ? store.syncStatus.username : t('loginNow')" @click="store.openAccount(router)">
<button class="rail-user" :title="store.syncStatus.loggedIn ? store.syncStatus.username : t('loginNow')" @click="store.openAccount(router)">
<span class="user-avatar">
<img v-if="store.avatarSrc" :src="store.avatarSrc" alt="" />
<b v-else-if="store.syncStatus.username">{{ store.syncStatus.username[0].toUpperCase() }}</b>
@@ -220,6 +252,10 @@ watch(activeTask, task => {
</span>
<em v-if="store.syncStatus.loggedIn && store.syncStatus.pending > 0" class="user-pending rail-pending" :title="t('pendingSync', { n: store.syncStatus.pending })">{{ store.syncStatus.pending }}</em>
</button>
<div class="rail-foot">
<LocalPackCenter />
<button type="button" class="rail-quit" :title="t('exitMenu')" :disabled="!native" @click="exitOpen = true"><Power /></button>
</div>
</div>
<div class="side-sub">
<div class="sub-brand"><b>{{ t('app') }}</b></div>
@@ -239,7 +275,6 @@ watch(activeTask, task => {
</nav>
<div class="sidebar-bottom">
<span class="version"><i />v{{ appVersion }}</span>
<button class="quick-settings-btn quit-app-btn" :title="t('quitApp')" :disabled="!native" @click="quitApp"><Power /></button>
<button class="quick-settings-btn" :title="t('quickSettings')" @click="quickOpen = !quickOpen"><SlidersHorizontal /></button>
<section v-if="quickOpen" class="quick-settings popover-glass">
<header>
@@ -281,6 +316,37 @@ watch(activeTask, task => {
</template>
</div>
</aside>
<Teleport to="body">
<div v-if="updateInfo" class="overlay" @click.self="skipUpdate">
<section class="modal exit-modal" @click.stop>
<header class="modal-head">
<h2>{{ t('appUpdateTitle') }}</h2>
<button type="button" class="nm-close" :title="t('close')" @click="skipUpdate"><X /></button>
</header>
<p class="exit-hint">{{ t('appUpdateBody', { current: updateInfo.current, latest: updateInfo.latest }) }}</p>
<pre v-if="updateInfo.changelog" style="white-space:pre-wrap;font-size:.85rem;opacity:.8;max-height:160px;overflow:auto">{{ updateInfo.changelog }}</pre>
<div class="exit-actions">
<button type="button" class="btn secondary" :disabled="updateBusy" @click="skipUpdate">{{ t('appUpdateSkip') }}</button>
<button type="button" class="btn primary" :disabled="updateBusy" @click="installUpdate">{{ updateBusy ? t('appUpdateDownloading') : t('appUpdateInstall') }}</button>
</div>
</section>
</div>
</Teleport>
<Teleport to="body">
<div v-if="exitOpen" class="overlay" @click.self="exitOpen = false">
<section class="modal exit-modal" @click.stop>
<header class="modal-head">
<h2><Power />{{ t('exitMenu') }}</h2>
<button type="button" class="nm-close" :title="t('close')" @click="exitOpen = false"><X /></button>
</header>
<p class="exit-hint">{{ t('exitMenuHint') }}</p>
<div class="exit-actions">
<button v-if="store.syncStatus.loggedIn" type="button" class="btn secondary" @click="exitLogout">{{ t('logoutBtn') }}</button>
<button type="button" class="btn danger" :disabled="!native" @click="exitQuit">{{ t('quitApp') }}</button>
</div>
</section>
</div>
</Teleport>
<main><RouterView /></main>
<div v-if="visibleTask && !useFullscreenLoading" class="taskbar">
<div><b>{{ activeTaskProject || visibleTask.stage }}</b><span>{{ t(visibleTask.messageKey || 'task.start', visibleTask.params || {}) }}</span></div>

View File

@@ -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)
}
}

View File

@@ -1,14 +1,17 @@
<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 { X, BarChart2, GitBranch, ListTodo, CalendarDays, Bot, CloudUpload, RefreshCw } from 'lucide-vue-next'
import { call } from '../api'
import { useAppStore } from '../store'
import logoUrl from '../assets/logo.png'
// 关于弹窗:由原生菜单“帮助 → 关于”触发menu:action=about
const emit = defineEmits(['close'])
const { t } = useI18n()
const store = useAppStore()
const version = ref('')
const checking = ref(false)
const aboutFeatures = [
{ icon: BarChart2, key: 'aboutFeatCode' },
{ icon: GitBranch, key: 'aboutFeatGit' },
@@ -20,6 +23,30 @@ const aboutFeatures = [
onMounted(async () => {
try { version.value = await call('GetAppVersion') } catch { version.value = '' }
})
function errText(e) {
const code = String(e).split(':')[0].trim()
const key = 'errors.' + code
return t(key) !== key ? t(key) : String(e)
}
async function checkUpdate() {
if (checking.value) return
checking.value = true
try {
const r = await call('CheckAppUpdate', true)
if (r?.upToDate) {
store.showToast({ type: 'success', text: t('aboutUpToDate', { version: r.current || version.value || '—' }) })
} else if (r?.latest) {
// CheckAppUpdate 会 emit app:update-availableApp 层弹出安装对话框
store.showToast({ type: 'info', text: t('aboutUpdateAvailable', { latest: r.latest, current: r.current }) })
}
} catch (e) {
store.showToast({ type: 'error', text: errText(e) })
} finally {
checking.value = false
}
}
</script>
<template>
@@ -42,6 +69,12 @@ onMounted(async () => {
<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-update">
<p class="about-update-hint">{{ t('aboutUpdateHint') }}</p>
<button type="button" class="btn secondary" :disabled="checking" @click="checkUpdate">
<RefreshCw :class="{ spin: checking }" />{{ checking ? t('aboutCheckingUpdate') : t('aboutCheckUpdate') }}
</button>
</div>
<div class="about-story">
<b>{{ t('aboutNameTitle') }}</b>
<p>{{ t('aboutNameStory') }}</p>

View File

@@ -0,0 +1,83 @@
<script setup>
import { onMounted, onUnmounted, ref, watch } from 'vue'
import { X, ZoomIn, ZoomOut, RotateCcw } from 'lucide-vue-next'
const props = defineProps({
src: { type: String, default: '' },
open: { type: Boolean, default: false }
})
const emit = defineEmits(['close'])
const scale = ref(1)
const dragging = ref(false)
const ox = ref(0)
const oy = ref(0)
let startX = 0, startY = 0, baseOx = 0, baseOy = 0
watch(() => props.open, v => {
if (v) { scale.value = 1; ox.value = 0; oy.value = 0 }
})
function close() { emit('close') }
function zoom(d) {
scale.value = Math.min(5, Math.max(0.2, +(scale.value + d).toFixed(2)))
}
function reset() { scale.value = 1; ox.value = 0; oy.value = 0 }
function onWheel(e) {
e.preventDefault()
zoom(e.deltaY < 0 ? 0.15 : -0.15)
}
function onDown(e) {
if (e.button !== 0) return
dragging.value = true
startX = e.clientX; startY = e.clientY
baseOx = ox.value; baseOy = oy.value
}
function onMove(e) {
if (!dragging.value) return
ox.value = baseOx + (e.clientX - startX)
oy.value = baseOy + (e.clientY - startY)
}
function onUp() { dragging.value = false }
function onKey(e) {
if (!props.open) return
if (e.key === 'Escape') close()
if (e.key === '+' || e.key === '=') zoom(0.2)
if (e.key === '-') zoom(-0.2)
if (e.key === '0') reset()
}
onMounted(() => {
window.addEventListener('keydown', onKey)
window.addEventListener('mousemove', onMove)
window.addEventListener('mouseup', onUp)
})
onUnmounted(() => {
window.removeEventListener('keydown', onKey)
window.removeEventListener('mousemove', onMove)
window.removeEventListener('mouseup', onUp)
})
</script>
<template>
<Teleport to="body">
<div v-if="open && src" class="img-preview" @click.self="close" @wheel.prevent="onWheel">
<div class="img-preview-toolbar">
<button type="button" :title="'Zoom in'" @click="zoom(0.25)"><ZoomIn /></button>
<button type="button" :title="'Zoom out'" @click="zoom(-0.25)"><ZoomOut /></button>
<button type="button" :title="'Reset'" @click="reset"><RotateCcw /></button>
<span class="img-preview-scale">{{ Math.round(scale * 100) }}%</span>
<button type="button" class="close" @click="close"><X /></button>
</div>
<img
:src="src"
alt=""
class="img-preview-img"
:class="{ dragging }"
:style="{ transform: `translate(${ox}px,${oy}px) scale(${scale})` }"
draggable="false"
@mousedown.prevent="onDown"
@click.stop
/>
</div>
</Teleport>
</template>

View File

@@ -0,0 +1,99 @@
<script setup>
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { Package, LoaderCircle, Check, CircleX, Trash2, X, ArrowRight } from 'lucide-vue-next'
import { call, on } from '../api'
// 本机打包任务快捷入口:完整页面在 /pack-tasks。
const { t } = useI18n()
const router = useRouter()
const open = ref(false)
const wrap = ref(null)
const tasks = ref([])
let off = null
async function load() {
try { tasks.value = await call('ListLocalPackTasks') || [] } catch { tasks.value = [] }
}
const running = computed(() => tasks.value.filter(x => x.status === 'running').length)
const badge = computed(() => running.value || (tasks.value.length ? tasks.value.length : 0))
const showBadge = computed(() => tasks.value.length > 0)
function statusLabel(s) {
if (s === 'running') return t('packTaskRunning')
if (s === 'done') return t('packTaskDone')
return t('packTaskFailed')
}
function fmtTime(s) {
if (!s) return ''
return String(s).replace('T', ' ').slice(5, 19)
}
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
}
async function clearDone() {
try { tasks.value = await call('ClearFinishedLocalPackTasks') || [] } catch { /* ignore */ }
}
async function dismiss(id) {
try { tasks.value = await call('DismissLocalPackTask', id) || [] } catch { /* ignore */ }
}
function goPage(id) {
open.value = false
router.push(id ? { path: '/pack-tasks', query: { id } } : '/pack-tasks')
}
onMounted(() => {
addEventListener('click', onClickAway)
load()
off = on('pack:task', () => load())
})
onUnmounted(() => {
removeEventListener('click', onClickAway)
off?.()
})
</script>
<template>
<div ref="wrap" class="rail-pack-wrap">
<button type="button" class="rail-pack-btn" :title="t('packTasks')" @click="toggle">
<Package />
<i v-if="showBadge" class="rail-pack-badge" :class="{ run: running }">{{ badge > 99 ? '99+' : badge }}</i>
</button>
<div v-if="open" class="rail-pack-drop" @click.stop>
<header>
<b>{{ t('packTasks') }}</b>
<span class="rail-pack-hint">{{ t('packTasksLocal') }}</span>
<button v-if="tasks.some(x => x.status !== 'running')" type="button" class="rail-pack-clear" :title="t('packTasksClear')" @click="clearDone"><Trash2 /></button>
<button type="button" class="nm-close" @click="open = false"><X /></button>
</header>
<div class="rail-pack-list">
<div v-for="x in tasks" :key="x.id" class="rail-pack-item" :class="x.status" @click="goPage(x.id)">
<span class="rail-pack-st">
<LoaderCircle v-if="x.status === 'running'" class="spin" />
<Check v-else-if="x.status === 'done'" />
<CircleX v-else />
</span>
<div class="rail-pack-main">
<b :title="x.cmd">{{ x.title || x.cmd }}</b>
<small>
<em>{{ statusLabel(x.status) }}</em>
<time v-if="x.startedAt">{{ fmtTime(x.startedAt) }}</time>
<span v-if="x.pid">PID {{ x.pid }}</span>
</small>
<code v-if="x.cmd && x.title && x.cmd !== x.title" :title="x.cmd">{{ x.cmd }}</code>
<p v-if="x.error" class="rail-pack-err" :title="x.error">{{ x.error }}</p>
</div>
<button v-if="x.status !== 'running'" type="button" class="rail-pack-x" :title="t('close')" @click.stop="dismiss(x.id)"><X /></button>
</div>
<div v-if="!tasks.length" class="rail-pack-empty">{{ t('packTasksEmpty') }}</div>
</div>
<button type="button" class="bell-more" @click="goPage()">{{ t('viewAll') }}<ArrowRight /></button>
</div>
</div>
</template>

View File

@@ -4,7 +4,7 @@
import { nextTick, ref, watch } from 'vue'
import { marked } from 'marked'
import { Browser } from '@wailsio/runtime'
import { call, isNative } from '../api'
import { call, isNative, resolveImageSrc } from '../api'
const props = defineProps({ source: { type: String, default: '' } })
const el = ref(null)
@@ -25,7 +25,12 @@ 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
if (!src || /^data:/i.test(src)) continue
// 远程 http(s) 经 Go 拉成 dataURL避免 WebView 直接拉外站破图。
if (/^https?:/i.test(src)) {
resolveImageSrc(src).then(u => { if (u) img.src = u })
continue
}
img.classList.add('md-img-loading')
resolveLocal(src).then(dataURL => {
if (dataURL) { img.src = dataURL; img.classList.remove('md-img-loading') }

View File

@@ -0,0 +1,131 @@
<script setup>
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { Package, Plus, Trash2, X, Terminal, Sparkles } from 'lucide-vue-next'
import { getPackCmds, setPackCmds } from '../packCmds'
import { call } from '../api'
const props = defineProps({
open: Boolean,
scope: { type: String, required: true }, // lp | proj
targetId: { type: [Number, String], required: true },
title: { type: String, default: '' },
dir: { type: String, default: '' }
})
const emit = defineEmits(['close', 'saved'])
const { t } = useI18n()
const rows = ref([])
const suggest = ref({ kind: '', label: '', items: [] })
const suggestLoading = ref(false)
watch(() => [props.open, props.scope, props.targetId, props.dir], async () => {
if (!props.open) return
const list = getPackCmds(props.scope, props.targetId)
rows.value = list.length
? list.map(x => ({ name: x.name || '', cmd: x.cmd || '' }))
: [{ name: '', cmd: '' }]
suggest.value = { kind: '', label: '', items: [] }
if (!props.dir) return
suggestLoading.value = true
try {
suggest.value = await call('SuggestPackCommands', props.dir) || { kind: '', label: '', items: [] }
} catch {
suggest.value = { kind: '', label: '', items: [] }
}
suggestLoading.value = false
}, { immediate: true })
const canSave = computed(() => rows.value.some(r => String(r.cmd || '').trim()))
const validCount = computed(() => rows.value.filter(r => String(r.cmd || '').trim()).length)
const suggestItems = computed(() => suggest.value?.items || [])
function addRow() {
rows.value.push({ name: '', cmd: '' })
}
function removeRow(i) {
rows.value.splice(i, 1)
if (!rows.value.length) rows.value.push({ name: '', cmd: '' })
}
function applySuggest(item) {
if (!item?.cmd) return
const empty = rows.value.findIndex(r => !String(r.cmd || '').trim())
const row = { name: item.name || '', cmd: item.cmd }
if (empty >= 0) rows.value[empty] = row
else rows.value.push(row)
}
function applyAllSuggest() {
for (const it of suggestItems.value) applySuggest(it)
}
function save() {
setPackCmds(props.scope, props.targetId, rows.value)
emit('saved')
emit('close')
}
</script>
<template>
<Teleport to="body">
<div v-if="open" class="overlay" @click.self="emit('close')">
<section class="modal pack-cmds-modal" @click.stop>
<header class="pack-cmds-head">
<div class="pack-cmds-brand">
<span class="pack-cmds-ico"><Package /></span>
<div>
<h2>{{ title || t('packCmdsTitle') }}</h2>
<p>{{ t('packCmdsHint') }}</p>
</div>
</div>
<button type="button" class="nm-close" :title="t('close')" @click="emit('close')"><X /></button>
</header>
<div class="pack-cmds-body">
<div v-if="dir" class="pack-suggest">
<div class="pack-suggest-top">
<b><Sparkles />{{ t('packSuggestTitle') }}</b>
<em v-if="suggestLoading">{{ t('packSuggestLoading') }}</em>
<em v-else-if="suggest.label" class="pack-suggest-kind">{{ suggest.label }}</em>
<button v-if="suggestItems.length" type="button" class="pack-suggest-all" @click="applyAllSuggest">{{ t('packSuggestAll') }}</button>
</div>
<div v-if="suggestItems.length" class="pack-suggest-chips">
<button v-for="(it, i) in suggestItems" :key="i" type="button" :title="it.cmd" @click="applySuggest(it)">
<span>{{ it.name }}</span>
<code>{{ it.cmd }}</code>
</button>
</div>
<p v-else-if="!suggestLoading" class="pack-suggest-empty">{{ t('packSuggestEmpty') }}</p>
</div>
<div class="pack-cmds-meta">
<b>{{ t('packCmdsList') }}</b>
<em>{{ t('packCmdsCount', { n: validCount }) }}</em>
</div>
<div class="pack-cmds-list">
<article v-for="(r, i) in rows" :key="i" class="pack-cmd-card">
<div class="pack-cmd-card-top">
<span class="pack-cmd-idx">{{ i + 1 }}</span>
<button type="button" class="pack-cmd-del" :title="t('delete')" @click="removeRow(i)"><Trash2 /></button>
</div>
<label class="pack-cmd-labeled">
<span>{{ t('packCmdNameLabel') }}</span>
<input v-model.trim="r.name" :placeholder="t('packCmdNamePh')" />
</label>
<label class="pack-cmd-labeled">
<span>{{ t('packCmdCmdLabel') }}</span>
<span class="pack-cmd-field">
<Terminal />
<input v-model.trim="r.cmd" class="pack-cmd-term" spellcheck="false" :placeholder="t('packCmdPh')" />
</span>
</label>
</article>
</div>
<button type="button" class="pack-cmd-add" @click="addRow"><Plus />{{ t('packCmdAdd') }}</button>
</div>
<footer class="pack-cmds-foot">
<button type="button" class="btn secondary" @click="emit('close')">{{ t('cancel') }}</button>
<button type="button" class="btn primary" :disabled="!canSave" @click="save">{{ t('save') }}</button>
</footer>
</section>
</div>
</Teleport>
</template>

View File

@@ -0,0 +1,22 @@
<script setup>
// 远程 http(s) 图经 Go 拉成 dataURL 再显示,避免 WebView 外站破图。
import { ref, watch } from 'vue'
import { resolveImageSrc } from '../api'
const props = defineProps({
src: { type: String, default: '' },
alt: { type: String, default: '' },
})
const resolved = ref('')
watch(() => props.src, async (v) => {
resolved.value = ''
if (!v) return
resolved.value = await resolveImageSrc(v)
}, { immediate: true })
</script>
<template>
<img v-if="resolved" :src="resolved" :alt="alt" />
</template>

View File

@@ -1,4 +1,4 @@
*{scrollbar-width:thin;scrollbar-color:rgba(145,136,255,.55) transparent}::-webkit-scrollbar{width:9px;height:9px}::-webkit-scrollbar-track{background:transparent}::-webkit-scrollbar-thumb{background:rgba(145,136,255,.38);border:2px solid transparent;background-clip:padding-box;border-radius:8px}::-webkit-scrollbar-thumb:hover{background:rgba(145,136,255,.7);border:2px solid transparent;background-clip:padding-box}
.wsl-picker{display:flex;gap:8px;margin:12px 24px 0}.wsl-picker select{flex:1;background:var(--glass-soft);border:1px solid var(--border);border-radius:7px;color:var(--text);padding:0 10px}.icon-actions button:disabled{opacity:.5;cursor:not-allowed}
.page{overflow-x:clip}.project-title>div:first-child{min-width:0;flex:1}.project-title p{max-width:100%!important}.icon-actions{flex:none}.project-card{min-width:0}
.page{overflow-x:clip}.project-title>div:first-child{min-width:0;flex:1;overflow:hidden}.project-title p{max-width:100%!important}.icon-actions{flex:none;flex-shrink:0}.project-card{min-width:0}
.git-context{display:flex;align-items:center;gap:18px;margin:-8px 0 18px;padding:11px 15px;border:1px solid var(--glass-border);border-radius:7px;background:var(--glass-soft);color:var(--muted)}.git-context span{display:flex;align-items:center;gap:7px}.git-context svg{width:17px;color:#9188ff}.git-context b{color:var(--text)}.heat-panel{height:255px}.heat-panel .chart{height:185px}.branch.interactive{position:relative;padding-right:52px;cursor:pointer;transition:border-color .2s,background .2s}.branch.interactive:hover,.branch.interactive.selected{background:rgba(123,115,255,.12);outline:1px solid rgba(145,136,255,.35)}.checkout-btn{position:absolute;right:12px;top:20px;width:32px;height:32px;border:0;border-radius:6px;background:rgba(123,115,255,.15);color:#9188ff;display:grid;place-items:center;cursor:pointer}.checkout-btn svg{width:16px}.commit{width:100%;border:0;color:var(--text);text-align:left;cursor:pointer}.commit:hover{outline:1px solid rgba(145,136,255,.3)}.git-trend{position:relative}.git-trend>.segments{position:absolute;right:0;top:-38px;z-index:2}.git-trend .chart{height:280px}.drawer-mask{position:fixed;inset:0;z-index:60;background:rgba(0,0,0,.55);backdrop-filter:blur(5px);display:flex;justify-content:flex-end;animation:overlay-in .2s}.commit-drawer{width:min(620px,90vw);height:100%;overflow:auto;background:var(--glass-strong);border-left:1px solid var(--glass-border);box-shadow:-20px 0 50px rgba(0,0,0,.3);padding:26px;animation:drawer-in .3s cubic-bezier(.16,1,.3,1)}.commit-drawer header{display:flex;justify-content:space-between;gap:20px;border-bottom:1px solid var(--border);padding-bottom:18px}.commit-drawer header small{color:var(--muted)}.commit-drawer h2{margin:7px 0 0;font-size:20px}.commit-drawer header button,.commit-meta button{border:0;background:var(--glass-soft);color:var(--text);width:34px;height:34px;border-radius:6px;display:grid;place-items:center;cursor:pointer}.commit-drawer svg{width:17px}.commit-meta{display:grid;grid-template-columns:1fr auto auto auto;gap:10px;align-items:center;margin:20px 0;padding:15px;background:var(--glass-soft);border-radius:7px}.commit-meta code{min-width:0;overflow:hidden;text-overflow:ellipsis}.commit-meta span,.commit-meta time{grid-column:1/3;color:var(--muted)}.change-file{display:grid;grid-template-columns:22px 1fr auto auto auto;gap:10px;align-items:center;padding:11px;border-bottom:1px solid var(--border)}.change-file span{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.change-file small{color:var(--muted)}@keyframes drawer-in{from{transform:translateX(100%)}to{transform:none}}@media(prefers-reduced-motion:reduce){.commit-drawer{animation:none}}

View File

@@ -15,12 +15,15 @@ const Logs = () => import('./views/Logs.vue')
const Settings = () => import('./views/Settings.vue')
const Profile = () => import('./views/Profile.vue')
const Launchpad = () => import('./views/Launchpad.vue')
const PortMonitor = () => import('./views/PortMonitor.vue')
const PackTasks = () => import('./views/PackTasks.vue')
const Messages = () => import('./views/Messages.vue')
const Today = () => import('./views/Today.vue')
const Notes = () => import('./views/Notes.vue')
const TeamHome = () => import('./views/TeamHome.vue')
const TeamTasks = () => import('./views/TeamTasks.vue')
const TeamReports = () => import('./views/TeamReports.vue')
const Admin = () => import('./views/Admin.vue')
import './style.css'
import './motion.css'
import './database.css'
@@ -38,13 +41,54 @@ const zh = {
navWork: '事务',
navSystem: '系统',
launchpad: '启动台',
launchpadSubtitle: '本机服务端口与应用启停管理',
launchpadSubtitle: '管理本机已保存应用启停与分类',
portMonitor: '端口监控',
portMonitorSubtitle: '本机资源总览与监听端口服务',
pmOverview: '系统总览',
pmHideCharts: '隐藏图表',
pmShowCharts: '显示图表',
pmBandwidth: '带宽',
pmNetDown: '下行',
pmNetUp: '上行',
pmGpuNA: '未检测到 GPU 数据',
packRun: '执行打包',
packCmdsTitle: '配置打包命令',
packCmdsTitleNamed: '配置打包命令 · {name}',
packCmdsHint: '可配置多条命令;下次右键「执行打包」从二级菜单选择。命令在项目/应用目录下后台执行。',
packCmdsList: '命令列表',
packCmdsCount: '{n} 条有效',
packCmdNameLabel: '显示名称',
packCmdCmdLabel: '执行命令',
packCmdNamePh: '如:生产安装包',
packCmdPh: '如wails3 task package',
packCmdAdd: '添加命令',
packCmdsConfig: '配置命令…',
packCmdStarted: '已开始执行:{name}',
packSuggestTitle: '根据项目推荐',
packSuggestLoading: '识别中…',
packSuggestEmpty: '未识别到常见打包脚本,可手动填写',
packSuggestAll: '全部加入',
packTasks: '本地任务',
packTasksPage: '打包任务',
packTasksPageSub: '本机打包/构建任务与控制台输出(保存在本地库,重启可查看,不同步云端)',
packTasksLocal: '本地持久',
packTasksEmpty: '暂无打包任务',
packTasksClear: '清除已结束',
packTasksPick: '从左侧选择一条任务查看控制台输出',
packLogCopy: '复制日志',
packLogCopied: '日志已复制',
packLogEmpty: '暂无输出',
packTaskRunning: '执行中',
packTaskDone: '已完成',
packTaskFailed: '失败',
lpMyApps: '我的应用',
lpScanned: '检测到的服务',
lpAddApp: '添加应用',
lpEditApp: '编辑应用',
lpRefresh: '刷新',
lpShowSys: '显示系统进程',
lpSearchName: '搜索名称',
lpSearchPort: '端口',
lpName: '应用名称',
lpKind: '种类',
lpPort: '端口',
@@ -55,14 +99,52 @@ const zh = {
lpSuggest: '推荐',
lpStart: '启动',
lpStop: '停止',
lpRestart: '重启',
lpRestartConfirm: '确认重启 {name}',
lpPin: '存为应用',
lpAddFromProject: '从我的项目添加',
lpSearchProject: '搜索项目名称或路径',
lpNoProjects: '还没有项目,请先在「项目」页添加',
lpOpenPort: '打开 http://127.0.0.1:{p}',
lpToLaunchpad: '添加到启动台',
lpStarting: '启动中…',
lpFailed: '启动失败',
lpLogs: '运行日志',
lpLogsLocalHint: '仅保存在本机,不同步到云端',
lpLogsEmpty: '暂无日志,启动后将在此显示输出',
lpClearLogs: '清空日志',
lpFetchIcon: '抓取图标',
lpFetchIconOk: '已获取项目图标',
lpPickIcon: '选择本地图片',
lpClearIcon: '清除图标',
lpCategory: '分类',
lpCategoryPh: '如:前端 / 后端 / 工具',
lpPrimaryCat: '一级分类',
lpSecondaryCat: '二级分类',
lpManagePrimary: '管理一级分类',
lpAddPrimary: '新增一级分类',
lpPrimaryPh: '分类名称',
lpNoPrimary: '还没有一级分类,先新增一个',
lpCatDelConfirm: '删除一级分类「{name}」?应用将变为未归类',
lpSetPrimary: '设置一级分类',
lpSetSecondary: '设置二级分类',
lpCatNone: '无',
lpCatAll: '全部',
viewDetail: '查看详情',
avatarHistory: '历史头像(已登录则同步到账号)',
avatarServerHistory: '服务器上传记录',
avatarReselect: '回选此头像',
tabKindIcons: '种类图标',
kindIconsTitle: '启动台种类默认图标',
kindIconsHint: '仅保存在本机、不同步。未抓取到项目/应用 logo 时,按识别到的种类展示此处配置的默认图。',
kindIconsAdminOnly: '种类默认图标暂不可在此修改',
lpRunning: '运行中',
lpStopped: '未运行',
lpMem: '内存',
lpNeedCmd: '首次启动请先选择或填写启动命令',
lpStopConfirm: '确认停止 {name}',
lpDelConfirm: '删除应用 {name}?(不会停止正在运行的进程)',
lpEmptyApps: '还没有保存的应用:从下方检测列表「存为应用」或手动添加',
lpEmptyApps: '还没有保存的应用:从「端口监控」页「存为应用」或手动添加',
lpEmptyScan: '未检测到监听端口的服务',
workbench: '工作台',
workbenchSubtitle: '收藏项目、今日待办与速记',
@@ -123,6 +205,8 @@ const zh = {
notepad: '记事本',
notepadPlaceholder: '随手记点什么,自动保存...',
autoSaved: '已自动保存',
noteSaved: '笔记已保存',
noteUnsaved: '未保存',
noteCenter: '笔记',
noteNew: '新建笔记',
noteEdit: '编辑笔记',
@@ -534,7 +618,7 @@ const zh = {
dailyFuture: '未来的心语还没写好',
dailyPrevDay: '前一天',
dailyNextDay: '后一天',
festImgTitle: '节日格样式(管理员)',
festImgTitle: '节日格样式',
festImgSet: '设置图片',
festImgReplace: '更换',
festImgRemove: '移除',
@@ -612,34 +696,92 @@ const zh = {
imgModePath: '本地文件(仅本机显示)',
imgModeServer: '上传到服务器(跨设备可见)',
contentImageHint: '待办 / 工单正文里粘贴或插入的图片按此方式保存:内嵌 Base64 会随内容同步到云端;本地文件体积更小,但换设备后无法显示;上传到服务器后以链接引用,任何设备都能访问。图片统一压缩到 1100px 以内。',
avatarServer: '上传到服务器(管理员配置的文件服务',
avatarServer: '上传到服务器(跨设备可见',
fsTitle: '文件存储(全局)',
fsHint: '管理员专属:配置随同步下发全员生效。服务器模式下内容图与头像上传到文件服务,跨设备可访问',
fsHint: '配置将下发全员生效。服务器模式下内容图与头像上传到文件服务,跨设备可访问',
fsMode: '存储方式',
fsModeLocal: '本地(默认)',
fsModeServer: '服务器nl-pms-api',
fsBaseUrl: '服务器地址',
fsApiKey: '上传密钥',
fsApiKeyPh: '与 nl-pms-api 配置文件里的 api_key 一致',
fsApiKey: '上传密钥(已废弃)',
fsApiKeyPh: '已改用登录 JWT无需填写',
fsTestBtn: '测试连接',
fsTesting: '测试中…',
fsSaveBtn: '保存配置',
fsSavedToast: '文件存储配置已保存,立即全员生效',
fsTestOkToast: '文件服务器连接成功',
tabFileStorage: '文件存储',
fsPageHint: '配置保存在服务器数据库,保存后立即全员生效:每个客户端上传图片时都会实时读取该配置决定存储位置,无需等待同步。',
fsAdminOnlyTitle: '仅管理员可配置',
fsAdminOnlyDesc: '只有管理员账号ID=1可以修改全局文件存储方式请联系管理员。',
storageFollowHint: '头像与正文图片的存储方式由管理员统一配置,无需手动选择。当前:{mode}。',
fsPageHint: '配置保存在服务器;上传鉴权使用账号登录 JWT登录一次即可。保存后立即全员生效。敏感操作需 Google 动态码验证。',
adminTitle: '运营后台',
adminSubtitle: '日活、用量、用户/团队管理与发版',
adminTabOverview: '概览',
adminTabUsers: '用户',
adminTabTeams: '团队',
adminTabReleases: '发版',
adminTabSecurity: '安全',
adminStatUsers: '用户数',
adminStatTeams: '团队数',
adminStatDAU: '今日日活',
adminStatTokens: '今日 Token',
adminDAUSeries: '近 14 日日活',
adminTokenSeries: '近 14 日 Token',
adminColAI: 'AI',
adminColDisabled: '账号',
adminColMembers: '成员',
adminColLatest: '最新',
adminBanAI: '禁止 AI',
adminUnbanAI: '允许 AI',
adminDisable: '禁用',
adminEnable: '启用',
adminReleaseVersion: '版本号',
adminReleaseChannel: '渠道',
adminReleaseChangelog: '更新说明',
adminReleaseFile: '安装包',
adminReleaseDropTitle: '拖拽安装包到此处',
adminReleaseDropHint: '支持 .exe也可点击浏览选择',
adminReleaseNeedExe: '请拖入或选择 .exe 安装包',
adminReleaseUpload: '上传',
adminReleasePublish: '设为最新',
adminReleaseUploaded: '安装包已上传',
adminReleasePublished: '已发布为最新版',
adminTotpHint: '绑定 Google Authenticator 后改文件存储、发版、禁用户等敏感操作需输入动态码2 小时内有效;换 IP 需重新验证)。',
adminTotpEnabled: '已绑定动态码',
adminTotpBegin: '生成绑定二维码',
adminTotpConfirm: '确认绑定',
adminTotpBound: '动态码已绑定',
adminTotpCodePh: '6 位动态码',
adminStepupNow: '立即验证动态码',
adminStepupActive: '敏感操作已解锁',
adminStepupTitle: '输入动态码',
adminStepupHint: '打开 Google Authenticator输入 6 位验证码',
adminStepupConfirm: '验证',
adminIpChangedRisk: '检测到 IP 变化,存在账号被盗风险,请重新输入动态码。',
appUpdateTitle: '发现新版本',
appUpdateBody: '当前 {current} → 最新 {latest}',
appUpdateSkip: '稍后',
appUpdateInstall: '下载并安装',
appUpdateDownloading: '下载中…',
checkAppUpdate: '检查软件更新',
aboutCheckUpdate: '检查更新',
aboutCheckingUpdate: '正在检查…',
aboutUpToDate: '已是最新版本({version}',
aboutUpdateAvailable: '发现新版本 {latest}(当前 {current}',
aboutUpdateHint: '登录后会定期自动检查更新;未登录或暂无推送时,可在此手动检查。',
fsAdminOnlyTitle: '暂不可配置',
fsAdminOnlyDesc: '全局文件存储方式由系统维护,如需调整请联系客服。',
storageFollowHint: '头像与正文图片的存储方式由系统统一配置,无需手动选择。当前:{mode}。',
avatarClear: '清除头像',
quitApp: '退出应用',
quitConfirm: '确定退出应用?',
exitMenu: '退出',
exitMenuHint: '请选择要执行的操作',
assetsTab: '素材库',
assetsHint: '管理上传到文件服务器的图片本人管自己的团队管理员管团队的管理员id=1管全部',
assetsNeedServer: '未启用服务器存储。管理员在「设置 → 文件存储」切到服务器模式后,上传的图片会在这里展示。',
assetsHint: '管理上传到文件服务器的图片;团队素材需具备团队管理权限',
assetsHintAdmin: '管理服务器上的图片:本人 / 团队 / 全部',
assetsNeedServer: '当前未启用服务器存储,图片保存在本地。启用后上传记录会出现在这里。',
assetsScopeMine: '我的上传',
assetsScopeTeam: '团队素材',
assetsScopeAll: '全部(管理员)',
assetsScopeAll: '全部',
assetsCount: '共 {n} 张',
assetsRefresh: '刷新',
assetsEmpty: '还没有图片。待办 / 工单正文粘贴的图片或上传的头像会出现在这里。',
@@ -793,10 +935,30 @@ const zh = {
SYNC_DECRYPT_FAILED: '云端 API Key 解密失败,请重新登录后再同步',
AVATAR_FILE_TOO_LARGE: '图片超过 10MB请换一张更小的图片',
AVATAR_DECODE_FAILED: '无法识别的图片格式(支持 PNG / JPG / GIF / WebP',
FILE_STORAGE_ADMIN_ONLY: '只有管理员id=1可以配置文件存储',
AVATAR_VALUE_REQUIRED: '头像内容不能为空',
AVATAR_VALUE_TOO_LARGE: '头像数据过大,无法写入历史',
FILE_STORAGE_ADMIN_ONLY: '无权修改文件存储配置',
ADMIN_STEPUP_REQUIRED: '请先输入 Google 动态码验证',
ADMIN_IP_CHANGED: '检测到 IP 变化,请重新输入动态码',
TOTP_INVALID: '动态码错误',
TOTP_NOT_ENABLED: '请先绑定 Google Authenticator',
TOTP_ALREADY_ENABLED: '动态码已绑定',
USER_AI_BANNED: '你的账号已被禁止使用 AI',
TEAM_AI_BANNED: '所在团队已被禁止使用 AI',
ACCOUNT_DISABLED: '账号已被禁用',
VERSION_INVALID: '版本号格式应为 x.y.z',
VERSION_EXISTS: '该版本已存在',
FILE_TOO_LARGE: '文件过大',
SHA256_MISMATCH: '安装包校验失败',
NO_RELEASE: '暂无可用更新',
KIND_ICON_ADMIN_ONLY: '无权维护种类默认图标',
KIND_UNKNOWN: '未知的启动台种类',
CATEGORY_EXISTS: '该一级分类已存在',
ICON_NOT_FOUND: '未找到图标',
LAUNCH_APP_NOT_FOUND: '启动台应用不存在',
FILE_STORAGE_BAD_URL: '服务器地址无效,需以 http(s):// 开头',
FILE_STORAGE_UNREACHABLE: '无法连接文件服务器,请检查地址与服务状态',
FILE_API_UNCONFIGURED: '服务器存储未启用,请联系管理员配置文件存储',
FILE_API_UNCONFIGURED: '服务器存储未启用,请稍后再试或联系客服',
IMAGE_UPLOAD_FAILED: '图片上传失败,请检查文件服务器后重试',
FILE_PERMISSION_DENIED: '没有权限操作这个文件',
FILE_API_REQUEST_FAILED: '文件服务器请求失败,请稍后重试',
@@ -885,13 +1047,54 @@ const en = {
navWork: 'Work',
navSystem: 'System',
launchpad: 'Launchpad',
launchpadSubtitle: 'Local service ports & app start/stop',
launchpadSubtitle: 'Start/stop saved local apps',
portMonitor: 'Port monitor',
portMonitorSubtitle: 'Host overview & listening services',
pmOverview: 'System overview',
pmHideCharts: 'Hide charts',
pmShowCharts: 'Show charts',
pmBandwidth: 'Bandwidth',
pmNetDown: 'Download',
pmNetUp: 'Upload',
pmGpuNA: 'No GPU metrics',
packRun: 'Run package',
packCmdsTitle: 'Configure package commands',
packCmdsTitleNamed: 'Package commands · {name}',
packCmdsHint: 'Add multiple commands; later pick one from the context submenu. Runs in the app/project directory.',
packCmdsList: 'Commands',
packCmdsCount: '{n} valid',
packCmdNameLabel: 'Display name',
packCmdCmdLabel: 'Command',
packCmdNamePh: 'e.g. Release installer',
packCmdPh: 'e.g. wails3 task package',
packCmdAdd: 'Add command',
packCmdsConfig: 'Configure…',
packCmdStarted: 'Started: {name}',
packSuggestTitle: 'Suggested for this project',
packSuggestLoading: 'Detecting…',
packSuggestEmpty: 'No common build scripts found — add manually',
packSuggestAll: 'Add all',
packTasks: 'Local tasks',
packTasksPage: 'Pack tasks',
packTasksPageSub: 'Local build/package jobs & console output (saved locally, survives restart, not synced)',
packTasksLocal: 'Local persist',
packTasksEmpty: 'No package tasks yet',
packTasksClear: 'Clear finished',
packTasksPick: 'Select a task on the left to view console output',
packLogCopy: 'Copy log',
packLogCopied: 'Log copied',
packLogEmpty: 'No output yet',
packTaskRunning: 'Running',
packTaskDone: 'Done',
packTaskFailed: 'Failed',
lpMyApps: 'My apps',
lpScanned: 'Detected services',
lpAddApp: 'Add app',
lpEditApp: 'Edit app',
lpRefresh: 'Refresh',
lpShowSys: 'Show system processes',
lpSearchName: 'Search name',
lpSearchPort: 'Port',
lpName: 'Name',
lpKind: 'Kind',
lpPort: 'Port',
@@ -902,14 +1105,52 @@ const en = {
lpSuggest: 'Suggested',
lpStart: 'Start',
lpStop: 'Stop',
lpRestart: 'Restart',
lpRestartConfirm: 'Restart {name}?',
lpPin: 'Save as app',
lpAddFromProject: 'Add from my projects',
lpSearchProject: 'Search project name or path',
lpNoProjects: 'No projects yet — add one on the Projects page first',
lpOpenPort: 'Open http://127.0.0.1:{p}',
lpToLaunchpad: 'Add to Launchpad',
lpStarting: 'Starting…',
lpFailed: 'Failed',
lpLogs: 'Run logs',
lpLogsLocalHint: 'Stored on this device only — not synced',
lpLogsEmpty: 'No logs yet — output appears after start',
lpClearLogs: 'Clear logs',
lpFetchIcon: 'Fetch icon',
lpFetchIconOk: 'Project icon fetched',
lpPickIcon: 'Choose local image',
lpClearIcon: 'Clear icon',
lpCategory: 'Category',
lpCategoryPh: 'e.g. Frontend / Backend / Tools',
lpPrimaryCat: 'Primary category',
lpSecondaryCat: 'Secondary category',
lpManagePrimary: 'Manage primary categories',
lpAddPrimary: 'Add primary category',
lpPrimaryPh: 'Category name',
lpNoPrimary: 'No primary categories yet',
lpCatDelConfirm: 'Delete primary category "{name}"? Apps will become uncategorized',
lpSetPrimary: 'Set primary category',
lpSetSecondary: 'Set secondary category',
lpCatNone: 'None',
lpCatAll: 'All',
viewDetail: 'View details',
avatarHistory: 'Avatar history (synced when signed in)',
avatarServerHistory: 'Server uploads',
avatarReselect: 'Use this avatar',
tabKindIcons: 'Kind icons',
kindIconsTitle: 'Launchpad kind default icons',
kindIconsHint: 'Stored locally only (not synced). Used when a project/app has no logo and a kind is detected.',
kindIconsAdminOnly: 'Kind icons cannot be edited here',
lpRunning: 'Running',
lpStopped: 'Stopped',
lpMem: 'Memory',
lpNeedCmd: 'Pick or enter a start command first',
lpStopConfirm: 'Stop {name}?',
lpDelConfirm: 'Delete app {name}? (running process is kept)',
lpEmptyApps: 'No saved apps yet — pin one from the detected list or add manually',
lpEmptyApps: 'No saved apps yet — pin from Port monitor or add manually',
lpEmptyScan: 'No listening services detected',
workbench: 'Workbench',
workbenchSubtitle: 'Favorites, today\'s tasks and quick notes',
@@ -970,6 +1211,8 @@ const en = {
notepad: 'Notepad',
notepadPlaceholder: 'Jot something down, autosaved...',
autoSaved: 'Autosaved',
noteSaved: 'Note saved',
noteUnsaved: 'Unsaved',
noteCenter: 'Notes',
noteNew: 'New note',
noteEdit: 'Edit note',
@@ -1381,7 +1624,7 @@ const en = {
dailyFuture: 'Notes for the future are not written yet',
dailyPrevDay: 'Previous day',
dailyNextDay: 'Next day',
festImgTitle: 'Festival cell style (admin)',
festImgTitle: 'Festival cell style',
festImgSet: 'Set image',
festImgReplace: 'Replace',
festImgRemove: 'Remove',
@@ -1459,34 +1702,92 @@ const en = {
imgModePath: 'Local file (this device only)',
imgModeServer: 'Upload to server (visible across devices)',
contentImageHint: 'Images pasted or inserted into todo / ticket content are stored this way: inline Base64 syncs to the cloud with the text; local files keep the database small but won\'t show on other devices; server uploads are referenced by URL and load anywhere. Images are compressed to 1100px max.',
avatarServer: 'Upload to server (admin-configured file service)',
avatarServer: 'Upload to server (visible across devices)',
fsTitle: 'File storage (global)',
fsHint: 'Admin only: the config syncs to every account. In server mode content images and avatars upload to the file service and stay accessible across devices',
fsHint: 'Changes apply to all accounts. In server mode images upload to the file service.',
fsMode: 'Storage mode',
fsModeLocal: 'Local (default)',
fsModeServer: 'Server (nl-pms-api)',
fsBaseUrl: 'Server URL',
fsApiKey: 'Upload key',
fsApiKeyPh: 'Same as api_key in the nl-pms-api config',
fsApiKey: 'Upload key (deprecated)',
fsApiKeyPh: 'Uses login JWT; leave blank',
fsTestBtn: 'Test connection',
fsTesting: 'Testing…',
fsSaveBtn: 'Save config',
fsSavedToast: 'File storage config saved; effective for everyone immediately',
fsTestOkToast: 'File server reachable',
tabFileStorage: 'File storage',
fsPageHint: 'The config lives in the server database and takes effect immediately: every client reads it in real time when uploading images, no sync wait.',
fsAdminOnlyTitle: 'Admin only',
fsAdminOnlyDesc: 'Only the admin account (ID=1) can change the global file storage mode.',
storageFollowHint: 'Avatar and content image storage follows the admin-managed global config. Current: {mode}.',
fsPageHint: 'Stored on the server; uploads use the login JWT (sign in once). Changes apply to all clients immediately. Sensitive actions require a Google Authenticator code.',
adminTitle: 'Admin',
adminSubtitle: 'DAU, usage, users/teams and releases',
adminTabOverview: 'Overview',
adminTabUsers: 'Users',
adminTabTeams: 'Teams',
adminTabReleases: 'Releases',
adminTabSecurity: 'Security',
adminStatUsers: 'Users',
adminStatTeams: 'Teams',
adminStatDAU: 'DAU today',
adminStatTokens: 'Tokens today',
adminDAUSeries: 'DAU (14d)',
adminTokenSeries: 'Tokens (14d)',
adminColAI: 'AI',
adminColDisabled: 'Account',
adminColMembers: 'Members',
adminColLatest: 'Latest',
adminBanAI: 'Ban AI',
adminUnbanAI: 'Allow AI',
adminDisable: 'Disable',
adminEnable: 'Enable',
adminReleaseVersion: 'Version',
adminReleaseChannel: 'Channel',
adminReleaseChangelog: 'Changelog',
adminReleaseFile: 'Installer',
adminReleaseDropTitle: 'Drop installer here',
adminReleaseDropHint: 'Accepts .exe; or click Browse',
adminReleaseNeedExe: 'Please drop or pick an .exe installer',
adminReleaseUpload: 'Upload',
adminReleasePublish: 'Publish',
adminReleaseUploaded: 'Installer uploaded',
adminReleasePublished: 'Published as latest',
adminTotpHint: 'After binding Google Authenticator, sensitive actions (file storage, releases, bans) require a code (valid 2h; IP change forces re-auth).',
adminTotpEnabled: 'TOTP enabled',
adminTotpBegin: 'Generate QR',
adminTotpConfirm: 'Confirm bind',
adminTotpBound: 'TOTP bound',
adminTotpCodePh: '6-digit code',
adminStepupNow: 'Verify now',
adminStepupActive: 'Sensitive ops unlocked',
adminStepupTitle: 'Enter TOTP code',
adminStepupHint: 'Open Google Authenticator and enter the 6-digit code',
adminStepupConfirm: 'Verify',
adminIpChangedRisk: 'IP changed — possible account theft. Re-enter your authenticator code.',
appUpdateTitle: 'Update available',
appUpdateBody: '{current} → {latest}',
appUpdateSkip: 'Later',
appUpdateInstall: 'Download & install',
appUpdateDownloading: 'Downloading…',
checkAppUpdate: 'Check for updates',
aboutCheckUpdate: 'Check for updates',
aboutCheckingUpdate: 'Checking…',
aboutUpToDate: 'You are up to date ({version})',
aboutUpdateAvailable: 'Update available: {latest} (current {current})',
aboutUpdateHint: 'When signed in, the app checks for updates periodically. You can also check here manually.',
fsAdminOnlyTitle: 'Not available',
fsAdminOnlyDesc: 'Global file storage is managed by the system.',
storageFollowHint: 'Avatar and content image storage is managed by the system. Current: {mode}.',
avatarClear: 'Clear avatar',
quitApp: 'Quit app',
quitConfirm: 'Quit the app?',
exitMenu: 'Exit',
exitMenuHint: 'Choose an action',
assetsTab: 'Media library',
assetsHint: 'Manage images uploaded to the file server: yours, your teams\' (as owner/admin), or everything (admin id=1)',
assetsNeedServer: 'Server storage is off. Once the admin switches file storage to server mode on the Sync tab, uploads show up here.',
assetsHint: 'Manage your images on the file server; team media requires team admin rights',
assetsHintAdmin: 'Manage server images: mine / team / all',
assetsNeedServer: 'Server storage is off. Images stay local until it is enabled.',
assetsScopeMine: 'My uploads',
assetsScopeTeam: 'Team media',
assetsScopeAll: 'Everything (admin)',
assetsScopeAll: 'All',
assetsCount: '{n} images',
assetsRefresh: 'Refresh',
assetsEmpty: 'No images yet. Pictures pasted into todos / tickets or uploaded avatars will appear here.',
@@ -1640,10 +1941,30 @@ const en = {
SYNC_DECRYPT_FAILED: 'Could not decrypt cloud API keys; sign in again and retry',
AVATAR_FILE_TOO_LARGE: 'Image exceeds 10MB, please pick a smaller one',
AVATAR_DECODE_FAILED: 'Unrecognized image format (PNG / JPG / GIF / WebP supported)',
FILE_STORAGE_ADMIN_ONLY: 'Only the admin (id=1) can configure file storage',
AVATAR_VALUE_REQUIRED: 'Avatar value is required',
AVATAR_VALUE_TOO_LARGE: 'Avatar payload too large for history',
FILE_STORAGE_ADMIN_ONLY: 'You do not have permission to change file storage',
ADMIN_STEPUP_REQUIRED: 'Enter your Google Authenticator code first',
ADMIN_IP_CHANGED: 'IP changed — re-enter authenticator code',
TOTP_INVALID: 'Invalid authenticator code',
TOTP_NOT_ENABLED: 'Bind Google Authenticator first',
TOTP_ALREADY_ENABLED: 'TOTP already enabled',
USER_AI_BANNED: 'Your account is banned from AI',
TEAM_AI_BANNED: 'Your team is banned from AI',
ACCOUNT_DISABLED: 'Account disabled',
VERSION_INVALID: 'Version must be x.y.z',
VERSION_EXISTS: 'Version already exists',
FILE_TOO_LARGE: 'File too large',
SHA256_MISMATCH: 'Installer checksum mismatch',
NO_RELEASE: 'No release available',
KIND_ICON_ADMIN_ONLY: 'You do not have permission to manage kind icons',
KIND_UNKNOWN: 'Unknown launchpad kind',
CATEGORY_EXISTS: 'Primary category already exists',
ICON_NOT_FOUND: 'Icon not found',
LAUNCH_APP_NOT_FOUND: 'Launchpad app not found',
FILE_STORAGE_BAD_URL: 'Invalid server URL; it must start with http(s)://',
FILE_STORAGE_UNREACHABLE: 'Cannot reach the file server; check the URL and service',
FILE_API_UNCONFIGURED: 'Server storage is not enabled; ask the admin to configure file storage',
FILE_API_UNCONFIGURED: 'Server storage is not enabled; try again later or contact support',
IMAGE_UPLOAD_FAILED: 'Image upload failed; check the file server and retry',
FILE_PERMISSION_DENIED: 'You do not have permission to manage this file',
FILE_API_REQUEST_FAILED: 'File server request failed; try again later',
@@ -1743,12 +2064,15 @@ const router = createRouter({
{ path: '/settings', component: Settings },
{ path: '/profile', component: Profile },
{ path: '/launchpad', component: Launchpad },
{ path: '/ports', component: PortMonitor },
{ path: '/pack-tasks', component: PackTasks },
{ path: '/messages', component: Messages },
{ path: '/today', component: Today },
{ path: '/notes', component: Notes },
{ path: '/team', component: TeamHome },
{ path: '/team/tasks', component: TeamTasks },
{ path: '/team/reports', component: TeamReports }
{ path: '/team/reports', component: TeamReports },
{ path: '/admin', component: Admin }
]
})

35
frontend/src/packCmds.js Normal file
View File

@@ -0,0 +1,35 @@
// 打包命令本地存储:启动台应用 / 项目各自多条 name+cmd不进云同步。
const PACK_KEY = 'cc-pack-cmds'
/** @typedef {{ name: string, cmd: string }} PackCmd */
function loadAll() {
try { return JSON.parse(localStorage.getItem(PACK_KEY) || '{}') || {} } catch { return {} }
}
function saveAll(map) {
localStorage.setItem(PACK_KEY, JSON.stringify(map))
}
/** @param {'lp'|'proj'} scope @param {number|string} id */
export function packKey(scope, id) {
return `${scope}:${id}`
}
/** @returns {PackCmd[]} */
export function getPackCmds(scope, id) {
const list = loadAll()[packKey(scope, id)]
return Array.isArray(list) ? list.filter(x => x && String(x.cmd || '').trim()) : []
}
/** @param {PackCmd[]} cmds */
export function setPackCmds(scope, id, cmds) {
const map = loadAll()
const cleaned = (cmds || [])
.map(x => ({ name: String(x.name || '').trim(), cmd: String(x.cmd || '').trim() }))
.filter(x => x.cmd)
const k = packKey(scope, id)
if (!cleaned.length) delete map[k]
else map[k] = cleaned
saveAll(map)
}

View File

@@ -16,6 +16,51 @@ html{--topbar-h:0px}
/* 气泡贴着工具区弹出:图标右侧 + 底部对齐,跟随 rail 宽度变化 */
.rail-tools .bell-dropdown{position:absolute;left:calc(100% + 16px);right:auto;top:auto;bottom:0}
.side-rail .rail-user{margin-top:6px}
.rail-foot{display:flex;align-items:center;justify-content:center;gap:6px;margin-top:6px}
.rail-quit{display:grid;place-items:center;width:28px;height:28px;border:1px solid var(--border);border-radius:8px;background:var(--surface-2);color:var(--muted);cursor:pointer;transition:color .15s,border-color .15s,background .15s;padding:0;flex:none}
.rail-quit svg{width:13px;height:13px}
.rail-quit:hover:not(:disabled){color:var(--red);border-color:rgba(240,94,104,.5);background:color-mix(in srgb,var(--red) 10%,var(--surface-2))}
.rail-quit:disabled{opacity:.45;cursor:default}
.rail-pack-wrap{position:relative;flex:none}
.rail-pack-btn{position:relative;display:grid;place-items:center;width:28px;height:28px;border:1px solid var(--border);border-radius:8px;background:var(--surface-2);color:var(--muted);cursor:pointer;padding:0;transition:color .15s,border-color .15s,background .15s}
.rail-pack-btn svg{width:13px;height:13px}
.rail-pack-btn:hover{color:#a9a2ff;border-color:rgba(123,115,255,.45);background:color-mix(in srgb,var(--primary) 12%,var(--surface-2))}
.rail-pack-badge{position:absolute;top:-5px;right:-5px;min-width:14px;height:14px;padding:0 3px;border-radius:7px;background:var(--primary);color:#fff;font-size:9px;font-style:normal;display:grid;place-items:center;line-height:1;font-weight:700}
.rail-pack-badge.run{background:var(--yellow);color:#1a1400}
.rail-pack-drop{position:absolute;left:calc(100% + 10px);bottom:0;width:320px;max-height:min(420px,70vh);display:flex;flex-direction:column;z-index:40;padding:0;overflow:hidden;background:var(--surface)!important;border:1px solid var(--border);border-radius:12px;box-shadow:0 16px 44px rgba(0,0,0,.55);backdrop-filter:none!important;-webkit-backdrop-filter:none!important}
.rail-pack-drop header{display:flex;align-items:center;gap:8px;padding:10px 12px;border-bottom:1px solid var(--border)}
.rail-pack-drop header b{font-size:13px}
.rail-pack-hint{font-size:10.5px;color:var(--muted);margin-right:auto}
.rail-pack-clear{width:26px;height:26px;border:0;border-radius:6px;background:transparent;color:var(--muted);display:grid;place-items:center;cursor:pointer}
.rail-pack-clear:hover{background:var(--surface-3);color:var(--text)}
.rail-pack-clear svg,.rail-pack-drop .nm-close svg{width:13px;height:13px}
.rail-pack-list{overflow:auto;padding:8px;display:flex;flex-direction:column;gap:6px}
.rail-pack-item{display:flex;gap:8px;align-items:flex-start;padding:8px;border-radius:9px;border:1px solid var(--border);background:var(--surface-2)}
.rail-pack-item.running{border-color:rgba(231,189,53,.4)}
.rail-pack-item.done{border-color:rgba(67,201,150,.35)}
.rail-pack-item.failed{border-color:rgba(240,94,104,.4)}
.rail-pack-st{width:22px;height:22px;border-radius:6px;display:grid;place-items:center;flex:none;background:var(--surface-3);color:var(--muted)}
.rail-pack-item.running .rail-pack-st{color:var(--yellow)}
.rail-pack-item.done .rail-pack-st{color:var(--green)}
.rail-pack-item.failed .rail-pack-st{color:var(--red)}
.rail-pack-st svg{width:13px;height:13px}
.rail-pack-main{min-width:0;flex:1;display:flex;flex-direction:column;gap:2px}
.rail-pack-main b{font-size:12.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.rail-pack-main small{display:flex;flex-wrap:wrap;gap:6px;font-size:10.5px;color:var(--muted)}
.rail-pack-main code{font-size:10.5px;color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:ui-monospace,Consolas,monospace}
.rail-pack-err{margin:2px 0 0;font-size:11px;color:var(--red);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.rail-pack-x{width:22px;height:22px;border:0;border-radius:6px;background:transparent;color:var(--muted);display:grid;place-items:center;cursor:pointer;flex:none}
.rail-pack-x:hover{background:var(--surface-3)}
.rail-pack-x svg{width:12px;height:12px}
.rail-pack-empty{padding:22px 10px;text-align:center;color:var(--muted);font-size:12px}
.exit-modal .modal-head .nm-close{display:grid;place-items:center;width:32px;height:32px;border:0;border-radius:8px;background:transparent;color:var(--muted);cursor:pointer}
.exit-modal .modal-head .nm-close:hover{background:var(--surface-3);color:var(--text)}
.exit-modal .modal-head .nm-close svg{width:16px;height:16px}
.exit-hint{margin:0;padding:0 20px 14px;color:var(--muted);font-size:13px}
.exit-actions{display:flex;flex-direction:column;gap:10px;padding:0 20px 22px}
.exit-actions .btn{width:100%;justify-content:center}
.exit-actions .btn.danger{background:color-mix(in srgb,var(--red) 18%,var(--surface-2));border-color:rgba(240,94,104,.45);color:var(--red)}
.exit-actions .btn.danger:hover:not(:disabled){background:color-mix(in srgb,var(--red) 28%,var(--surface-2))}
.autostart-row{display:flex;align-items:center;gap:14px}
.autostart-row small{color:var(--muted)}
.interval-input{max-width:200px}
@@ -40,10 +85,10 @@ html{--topbar-h:0px}
.user-chip:hover{border-color:var(--primary)}
.user-chip.active{border-color:var(--primary);background:color-mix(in srgb,var(--primary) 14%,var(--surface-2))}
.user-chip.active .user-name{color:var(--text)}
.user-avatar{position:relative;width:32px;height:32px;border-radius:50%;background:var(--surface-3);display:grid;place-items:center;flex:none}
.user-avatar img{width:32px;height:32px;border-radius:50%;object-fit:cover;display:block}
.user-avatar b{font-size:14px;color:#a9a2ff}
.user-avatar svg{width:16px;color:var(--muted)}
.user-avatar{position:relative;width:42px;height:42px;border-radius:50%;background:var(--surface-3);display:grid;place-items:center;flex:none}
.user-avatar img{width:42px;height:42px;border-radius:50%;object-fit:cover;display:block}
.user-avatar b{font-size:16px;color:#a9a2ff}
.user-avatar svg{width:20px;color:var(--muted)}
.user-dot{position:absolute;right:-2px;bottom:-2px;width:9px;height:9px;border-radius:50%;border:2px solid var(--side);background:#6b7482}
.user-dot.on{background:var(--green)}
.user-dot.err{background:var(--red)}
@@ -53,8 +98,8 @@ html{--topbar-h:0px}
.user-chip:hover .user-name{color:var(--text)}
@media(max-width:1150px){.user-chip{flex:0 0 auto;border:0;background:transparent;padding:4px;margin-bottom:2px}.user-name{display:none}}
.avatar-row{display:flex;gap:18px;align-items:flex-start;margin-top:18px}
.avatar-preview{width:72px;height:72px;border-radius:50%;background:var(--surface-2);border:1px solid var(--border);display:grid;place-items:center;flex:none;overflow:hidden}
.avatar-preview img{width:100%;height:100%;object-fit:cover;display:block}
.avatar-preview{position:relative;width:72px;height:72px;border-radius:50%;background:var(--surface-2);border:1px solid var(--border);display:grid;place-items:center;flex:none;overflow:hidden}
.avatar-preview img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;display:block}
.avatar-preview svg{width:28px;color:var(--muted)}
.avatar-controls{flex:1;min-width:0}
.avatar-controls .sync-actions{margin-top:14px}
@@ -184,6 +229,23 @@ html[data-theme=light] .lg-art{background:#141a2e}
.icon-actions button{width:30px;height:30px;display:grid;place-items:center;border-radius:7px;transition:background .2s,color .2s}
.icon-actions button:hover{background:var(--surface-3);color:var(--text)}
.group-chip{display:inline-flex;align-items:center;width:max-content;max-width:180px;height:22px;margin:0 0 7px;padding:0 8px;border-radius:999px;background:rgba(115,103,245,.14);color:#a9a2ff;font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.project-title{display:flex;justify-content:flex-start;align-items:flex-start;gap:12px}
.project-title-text{min-width:0;flex:1}
.project-title h3{margin:0 0 5px;font-size:16px;line-height:1.35;white-space:normal;overflow:visible;text-overflow:unset;word-break:break-word}
.project-title p{margin:0;color:var(--muted);font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.project-icon{flex:none;width:40px;height:40px;border-radius:10px;overflow:hidden;background:var(--surface-2);display:grid;place-items:center}
.project-icon img{width:100%;height:100%;object-fit:cover}
.project-card-corner{display:flex;align-items:center;gap:2px;flex:none;margin:-2px -4px 0 0}
.project-card-corner button{width:28px;height:28px;display:grid;place-items:center;border:0;border-radius:7px;background:transparent;color:var(--muted);cursor:pointer}
.project-card-corner button:hover{background:var(--surface-3);color:var(--text)}
.project-card-corner button.danger:hover{color:var(--red);background:color-mix(in srgb,var(--red) 12%,transparent)}
.project-card-corner button:disabled{opacity:.45;cursor:default}
.project-card-corner svg{width:14px;height:14px}
.project-card-foot{display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding-top:10px;border-top:1px solid var(--border);margin-top:4px}
.project-card-actions{margin-left:auto;display:flex;align-items:center;gap:4px}
.project-card-actions button{width:30px;height:30px;display:grid;place-items:center;border:0;border-radius:7px;background:transparent;color:var(--muted);cursor:pointer}
.project-card-actions button:hover{background:var(--surface-3);color:var(--text)}
.icon-actions{flex:none;flex-shrink:0;display:flex;align-items:center;gap:6px}
.compact-modal{width:420px}
.modal{display:flex;flex-direction:column;overflow:auto}
.modal header{align-items:center;gap:18px;padding:24px 26px 20px}
@@ -192,8 +254,8 @@ html[data-theme=light] .lg-art{background:#141a2e}
.modal header button:hover{background:var(--surface-2);color:var(--text)}
.modal>label{margin:0;padding:18px 26px 0;font-weight:800;color:var(--text)}
.modal>label:first-of-type{padding-top:24px}
.modal input,.modal textarea,.modal>label select{height:44px;margin-top:10px;border-color:rgba(145,136,255,.26);background:rgba(32,40,56,.92);box-shadow:inset 0 1px 0 rgba(255,255,255,.035);transition:border-color .2s,box-shadow .2s,background .2s}
.modal input:focus,.modal textarea:focus,.modal>label select:focus{border-color:rgba(145,136,255,.72);box-shadow:0 0 0 3px rgba(115,103,245,.18),inset 0 1px 0 rgba(255,255,255,.05)}
.modal input:not(.pack-cmd-term),.modal textarea,.modal>label select{height:44px;margin-top:10px;border-color:rgba(145,136,255,.26);background:rgba(32,40,56,.92);box-shadow:inset 0 1px 0 rgba(255,255,255,.035);transition:border-color .2s,box-shadow .2s,background .2s}
.modal input:not(.pack-cmd-term):focus,.modal textarea:focus,.modal>label select:focus{border-color:rgba(145,136,255,.72);box-shadow:0 0 0 3px rgba(115,103,245,.18),inset 0 1px 0 rgba(255,255,255,.05)}
.modal textarea{min-height:92px;height:92px}
.modal footer{gap:12px;padding:22px 26px;align-items:center}
.modal footer .btn{min-width:88px;justify-content:center}
@@ -611,9 +673,29 @@ html[data-theme=light] .heat-cell.l1{background:#b9efd4}html[data-theme=light] .
.wb-item small svg{width:11px}
.wb-ticket-status{align-self:center}
.wb-empty{min-height:80px;padding:14px 0}
.wb-note{width:100%;min-height:150px;margin-top:14px;border:1px solid var(--border);border-radius:7px;background:rgba(0,0,0,.14);color:var(--text);padding:12px;resize:vertical;outline:none;font:inherit;font-size:13px;line-height:1.6}
.wb-note{width:100%;min-height:150px;margin:0;border:1px solid var(--border);border-radius:7px;background:rgba(0,0,0,.14);color:var(--text);padding:12px;resize:vertical;outline:none;font:inherit;font-size:13px;line-height:1.6}
.wb-note:focus{border-color:var(--primary)}
.wb-note-saved{color:var(--green);font-size:11px}
.wb-note-dirty{color:var(--yellow);font-size:11px}
.wb-note-panel{display:flex;flex-direction:column;min-height:0}
.wb-note-panel .section-head{flex-wrap:wrap;gap:8px}
.wb-note-acts{display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin-left:auto}
.wb-note-acts .btn{height:30px;padding:0 10px;font-size:12px}
.wb-note-acts .btn svg{width:13px;height:13px}
.wb-note-body{display:grid;grid-template-columns:minmax(120px,38%) 1fr;gap:10px;margin-top:12px;min-height:180px}
.wb-note-list{display:flex;flex-direction:column;gap:4px;max-height:220px;overflow:auto;padding:4px;border:1px solid var(--border);border-radius:8px;background:var(--surface-2)}
.wb-note-item{display:flex;flex-direction:column;align-items:flex-start;gap:2px;width:100%;text-align:left;padding:8px 9px;border:0;border-radius:7px;background:transparent;color:var(--text);cursor:pointer;font:inherit}
.wb-note-item:hover{background:var(--surface-3)}
.wb-note-item.on{background:color-mix(in srgb,var(--primary) 14%,transparent);color:#a9a2ff}
.wb-note-item b{font-size:12.5px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:100%}
.wb-note-item time{font-size:10.5px;color:var(--muted)}
.wb-note-list-empty{padding:16px 8px;text-align:center;color:var(--muted);font-size:12px}
.wb-note-body .wb-note{min-height:180px;height:100%;resize:vertical}
@media (max-width:980px){
.wb-note-body{grid-template-columns:1fr}
.wb-note-list{max-height:120px;flex-direction:row;flex-wrap:wrap}
.wb-note-item{width:auto;max-width:46%}
}
.wb-msg-head{margin-top:18px}
.wb-msg-list{display:grid;gap:6px;margin-top:12px}
.wb-msg{display:flex;justify-content:space-between;gap:10px;background:var(--surface-2);border-radius:7px;padding:9px 12px;font-size:12px}
@@ -883,7 +965,7 @@ html[data-theme=light] .calendar-cell.has-art::before{opacity:.82}
html{color-scheme:dark}
html[data-theme=light]{color-scheme:light}
.modal input:not([type=checkbox]):not([type=radio]),.modal textarea,.modal select,
.modal input:not([type=checkbox]):not([type=radio]):not(.pack-cmd-term),.modal textarea,.modal select,
.quick-form input,.quick-form select,
.rule-add input,.rule-add select,
.form-panel input:not([type=range]):not([type=checkbox]),.form-panel select{
@@ -893,13 +975,13 @@ html[data-theme=light]{color-scheme:light}
box-shadow:inset 0 1.5px 3px rgba(0,0,0,.16),inset 0 -1px 0 rgba(255,255,255,.03);
transition:border-color .18s,box-shadow .18s,background-color .18s;
}
.modal input:not([type=checkbox]):not([type=radio]):hover,.modal textarea:hover,.modal select:hover,
.modal input:not([type=checkbox]):not([type=radio]):not(.pack-cmd-term):hover,.modal textarea:hover,.modal select:hover,
.quick-form input:hover,.quick-form select:hover,
.rule-add input:hover,.rule-add select:hover,
.form-panel input:not([type=range]):not([type=checkbox]):hover,.form-panel select:hover{
border-color:color-mix(in srgb,var(--primary) 42%,var(--border));
}
.modal input:not([type=checkbox]):not([type=radio]):focus,.modal textarea:focus,.modal select:focus,
.modal input:not([type=checkbox]):not([type=radio]):not(.pack-cmd-term):focus,.modal textarea:focus,.modal select:focus,
.quick-form input:focus,.quick-form select:focus,
.rule-add input:focus,.rule-add select:focus,
.form-panel input:not([type=range]):not([type=checkbox]):focus,.form-panel select:focus{
@@ -1086,7 +1168,8 @@ input[type=checkbox],input[type=radio]{accent-color:var(--primary)}
@keyframes phSpin{to{transform:rotate(1turn)}}
@media(prefers-reduced-motion:reduce){.ph-ring{animation:none}}
.ph-photo{position:absolute;inset:4px;border-radius:50%;overflow:hidden;display:grid;place-items:center;background:linear-gradient(135deg,#232c44,#1a2233);border:3px solid var(--surface)}
.ph-photo img{width:100%;height:100%;object-fit:cover}
/* 绝对铺满:避免 grid 下大图按固有尺寸撑开,只露出左上角一角 */
.ph-photo img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;display:block}
.ph-photo>b{font-size:36px;font-weight:900;color:#e6e9ff}
.ph-photo>svg{width:38px;height:38px;color:var(--muted)}
.profile-dot{position:absolute;right:5px;bottom:6px;width:16px;height:16px;border-radius:50%;border:3px solid var(--surface);background:#6b7482;z-index:2}
@@ -1284,8 +1367,7 @@ html[data-theme=light] .ph-stats{background:rgba(255,255,255,.5)}
.quick-kind button svg{width:16px}
.quick-kind button.active{border-color:var(--primary);background:color-mix(in srgb,var(--primary) 14%,var(--surface-2));color:var(--primary)}
/* 侧边栏退出按钮:悬停转为警示色,与快捷设置按钮同尺寸 */
.quit-app-btn:hover{color:var(--red);border-color:rgba(240,94,104,.5)}
/* 侧边栏退出按钮:悬停转为警示色rail-quit 见上) */
/* 设置页文件存储操作行 */
.fs-page-actions{display:flex;gap:12px;margin-top:22px}
/* 素材库(服务器图片管理) */
@@ -1295,6 +1377,7 @@ html[data-theme=light] .ph-stats{background:rgba(255,255,255,.5)}
.assets-count{font-size:12.5px;color:var(--muted);margin-left:auto}
.assets-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:12px}
.asset-card{position:relative;margin:0;border:1px solid var(--border);border-radius:12px;overflow:hidden;background:var(--surface-2)}
.asset-card .asset-thumb{display:block;width:100%;padding:0;border:0;background:transparent;cursor:zoom-in}
.asset-card img{display:block;width:100%;height:110px;object-fit:cover;background:var(--surface-3)}
.asset-card figcaption{padding:8px 10px;display:flex;flex-direction:column;gap:2px}
.asset-card figcaption b{font-size:12px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
@@ -1377,8 +1460,16 @@ html[data-theme=light] .ph-stats{background:rgba(255,255,255,.5)}
.src-switch button:disabled{opacity:.6;cursor:default}
/* ============ 启动台 ============ */
.lp-tools{display:flex;align-items:center;gap:10px}
.lp-sys-toggle{display:inline-flex;align-items:center;gap:7px;color:var(--muted);font-size:12px;cursor:pointer;user-select:none}
.lp-head{display:flex;flex-direction:column;align-items:stretch;justify-content:flex-start;gap:12px}
.lp-head-top{display:flex;align-items:flex-start;justify-content:space-between;gap:16px}
.lp-tools{display:flex;align-items:center;gap:10px;flex:none;flex-wrap:nowrap}
.lp-filters{display:flex;align-items:center;gap:10px}
.lp-search{display:inline-flex;align-items:center;gap:6px;height:32px;padding:0 10px;border-radius:8px;border:1px solid var(--border);background:var(--surface-2);color:var(--muted);min-width:0}
.lp-search svg{width:13px;height:13px;flex:none}
.lp-search input{border:0;outline:0;background:transparent;color:var(--text);font:inherit;font-size:12px;width:200px;min-width:0}
.lp-search-port input{width:88px}
.lp-search:focus-within{border-color:var(--primary);color:#a9a2ff}
.lp-sys-toggle{display:inline-flex;align-items:center;gap:7px;color:var(--muted);font-size:12px;cursor:pointer;user-select:none;white-space:nowrap}
.lp-sys-toggle input{accent-color:var(--primary)}
.lp-sys-toggle em{font-style:normal;min-width:18px;height:18px;padding:0 5px;border-radius:9px;background:var(--surface-3);display:grid;place-items:center;font-size:10.5px}
.lp-section{margin-top:18px}
@@ -1387,10 +1478,52 @@ html[data-theme=light] .ph-stats{background:rgba(255,255,255,.5)}
.lp-title em{font-style:normal;font-weight:600;color:var(--muted);font-size:12px}
.lp-empty{padding:26px;border:1px dashed var(--border);border-radius:12px;color:var(--muted);font-size:12.5px;text-align:center}
.lp-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(270px,1fr));gap:14px}
.lp-card{display:flex;flex-direction:column;gap:9px;padding:14px 15px}
.lp-card.running{box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--green) 26%,transparent)}
.lp-card{display:flex;flex-direction:column;gap:9px;padding:14px 15px;transition:opacity .25s,box-shadow .25s,border-color .25s}
.lp-card.dimmed{opacity:.62;filter:saturate(.75)}
.lp-card.running{box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--green) 30%,transparent),0 0 0 0 rgba(67,201,150,.35);animation:lpGlow 2.6s ease-in-out infinite}
.lp-card.starting{box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--yellow) 40%,transparent);animation:lpGlowWarm 1.6s ease-in-out infinite}
.lp-card.failed{box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--red) 55%,transparent);border-color:color-mix(in srgb,var(--red) 45%,var(--border));animation:none}
.lp-card.failed .lp-name b{color:var(--red)}
@keyframes lpGlow{
0%,100%{box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--green) 22%,transparent),0 0 8px rgba(67,201,150,.12)}
50%{box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--green) 55%,transparent),0 0 18px rgba(67,201,150,.42)}
}
@keyframes lpGlowWarm{
0%,100%{box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--yellow) 30%,transparent),0 0 6px rgba(231,189,53,.1)}
50%{box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--yellow) 60%,transparent),0 0 14px rgba(231,189,53,.35)}
}
@keyframes lpPulse{0%,100%{opacity:.55;transform:scale(1)}50%{opacity:1;transform:scale(1.15)}}
.lp-status{display:inline-flex;align-items:center;gap:5px;margin-left:auto;flex:none;height:22px;padding:0 8px;border-radius:999px;font-size:11px;font-weight:700;border:1px solid var(--border);color:var(--muted);background:var(--surface-2)}
.lp-status .lp-dot{position:static;width:7px;height:7px;border-width:0;margin:0}
.lp-status.running .lp-dot,.lp-dot.on{animation:lpPulse 1.8s ease-in-out infinite}
.lp-status svg{width:12px;height:12px}
.lp-status.starting{color:var(--yellow);border-color:rgba(231,189,53,.45);background:rgba(231,189,53,.1)}
.lp-status.running{color:var(--green);border-color:rgba(67,201,150,.4);background:rgba(67,201,150,.1)}
.lp-status.failed{color:var(--red);border-color:rgba(240,94,104,.5);background:rgba(240,94,104,.12)}
.lp-status.stopped{color:var(--muted)}
.lp-cats{display:flex;flex-wrap:wrap;gap:6px;margin-top:10px}
.lp-cat{height:26px;padding:0 11px;border-radius:999px;border:1px solid var(--border);background:var(--surface-2);color:var(--muted);font:inherit;font-size:12px;cursor:pointer}
.lp-cat.on,.lp-cat:hover{color:var(--text);border-color:color-mix(in srgb,var(--primary) 45%,var(--border));background:color-mix(in srgb,var(--primary) 12%,var(--surface-2))}
.lp-cat.on{color:#cfc9ff}
@media(prefers-reduced-motion:reduce){.lp-card.running,.lp-card.starting,.lp-status.running .lp-dot,.lp-dot.on{animation:none}}
.lp-err-line{margin:0;font-size:11.5px;color:var(--red);line-height:1.35;overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}
.lp-log-preview{margin:0;font-size:11px;color:var(--muted);font-family:ui-monospace,Consolas,monospace;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.lp-log-modal{width:min(720px,94vw);display:flex;flex-direction:column;max-height:min(78vh,640px)}
.lp-log-toolbar{display:flex;align-items:center;gap:8px;padding:8px 16px;border-bottom:1px solid var(--border)}
.lp-log-toolbar small{margin-right:auto;color:var(--muted);font-size:11.5px}
.lp-log-body{flex:1;min-height:240px;overflow:auto;padding:10px 14px;background:#0b0f16;font-family:ui-monospace,Consolas,monospace;font-size:12px;line-height:1.45}
.lp-log-line{margin:0 0 2px;white-space:pre-wrap;word-break:break-all;color:#c9d1d9}
.lp-log-line.error{color:#ff8b8b}
.lp-log-line.warning{color:#e7bd35}
html[data-theme=light] .lp-log-body{background:#f4f6fa}
html[data-theme=light] .lp-log-line{color:#1f2937}
.lp-card header{display:flex;align-items:center;gap:10px}
.lp-icon{flex:none;width:34px;height:34px;border-radius:9px;display:grid;place-items:center}
.lp-icon{flex:none;width:34px;height:34px;border-radius:9px;display:grid;place-items:center;overflow:hidden}
.lp-icon svg{width:18px;height:18px}
.lp-icon img{width:100%;height:100%;object-fit:cover;display:block}
.lp-icon.lg{width:48px;height:48px;border-radius:12px}
.lp-icon.lg svg{width:24px;height:24px}
.lp-icon-edit{display:flex;align-items:center;gap:12px;margin-bottom:4px}
.lp-icon svg{width:17px;height:17px}
.lp-name{flex:1;min-width:0;display:grid;gap:1px}
.lp-name b{font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
@@ -1400,6 +1533,14 @@ html[data-theme=light] .ph-stats{background:rgba(255,255,255,.5)}
.lp-ports{display:flex;align-items:center;gap:5px;flex-wrap:wrap;min-height:20px}
.lp-port{padding:1px 8px;border-radius:999px;background:var(--surface-3);border:1px solid var(--glass-border);font-size:11px;font-variant-numeric:tabular-nums}
.lp-port.more{color:var(--muted)}
.lp-port-link{display:inline-flex;align-items:center;gap:3px;cursor:pointer;color:var(--text);font:inherit}
.lp-port-link svg{width:10px;height:10px;opacity:.55}
.lp-port-link:hover{border-color:color-mix(in srgb,var(--primary) 45%,var(--border));color:var(--primary);background:color-mix(in srgb,var(--primary) 10%,var(--surface-3))}
.lp-port-link:hover svg{opacity:1}
.lp-proj-row{display:flex;flex-direction:column;align-items:flex-start;gap:3px;width:100%;padding:10px 12px;margin:0 0 6px;border:1px solid var(--border);border-radius:10px;background:var(--surface-2);color:var(--text);cursor:pointer;text-align:left}
.lp-proj-row:hover{border-color:color-mix(in srgb,var(--primary) 40%,var(--border));background:color-mix(in srgb,var(--primary) 8%,var(--surface-2))}
.lp-proj-row b{font-size:13px}
.lp-proj-row small{font-size:11px;color:var(--muted);max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.lp-pid{margin-left:auto;color:var(--muted);font-size:10.5px}
.lp-res{display:flex;gap:12px;color:var(--muted);font-size:11.5px}
.lp-res span{display:inline-flex;align-items:center;gap:4px;font-variant-numeric:tabular-nums}
@@ -1408,6 +1549,7 @@ html[data-theme=light] .ph-stats{background:rgba(255,255,255,.5)}
.lp-card footer{display:flex;align-items:center;gap:6px;padding-top:9px;border-top:1px solid var(--border)}
.lp-gap{flex:1}
.lp-act{display:inline-flex;align-items:center;gap:5px;height:27px;padding:0 10px;border-radius:8px;border:1px solid var(--border);background:var(--surface-2);color:var(--text);font:inherit;font-size:11.5px;cursor:pointer;transition:border-color .15s,color .15s}
.lp-act.icon-only{width:27px;padding:0;justify-content:center;gap:0}
.lp-act svg{width:12px;height:12px}
.lp-act:hover{border-color:var(--primary);color:#a9a2ff}
.lp-act.go{border-color:color-mix(in srgb,var(--green) 45%,transparent);color:var(--green)}
@@ -1449,8 +1591,8 @@ html[data-theme=light] .ph-stats{background:rgba(255,255,255,.5)}
.rail-item span{font-size:10.5px;font-weight:600;letter-spacing:.3px}
.rail-item:hover{background:var(--surface-3);color:var(--text)}
.rail-item.active{background:color-mix(in srgb,var(--primary) 17%,transparent);color:#a9a2ff;box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--primary) 32%,transparent)}
.rail-user{position:relative;margin-top:auto;border:0;background:transparent;cursor:pointer;padding:4px;border-radius:11px}
.rail-user:hover,.rail-user.active{background:var(--surface-3)}
.rail-user{position:relative;margin-top:auto;border:0;background:transparent;cursor:pointer;padding:4px;border-radius:14px}
.rail-user:hover{background:var(--surface-3)}
.rail-pending{position:absolute;top:-3px;right:-5px;min-width:17px;height:17px;font-size:10px;border:1px solid var(--side)}
.side-sub{flex:1;min-width:0;display:flex;flex-direction:column;padding:14px 10px 16px}
.sub-brand{flex:none;height:44px;display:flex;align-items:center;padding:0 7px;font-size:14.5px}
@@ -1503,6 +1645,10 @@ html[data-theme=light] .ph-stats{background:rgba(255,255,255,.5)}
.about-story{width:100%;margin-top:14px;padding:12px 18px;border-radius:13px;border:1px solid var(--glass-border);background:linear-gradient(135deg,rgba(62,201,167,.09),rgba(79,157,245,.07));text-align:left}
.about-story b{display:block;font-size:12.8px;margin-bottom:5px;color:var(--text)}
.about-story p{margin:0;font-size:12.3px;line-height:1.9;color:var(--muted)}
.about-update{width:100%;margin-top:14px;display:flex;flex-direction:column;align-items:center;gap:10px}
.about-update-hint{margin:0;font-size:12px;line-height:1.7;color:var(--muted);text-align:center}
.about-update .btn{display:inline-flex;align-items:center;gap:7px}
.about-update .btn svg{width:14px;height:14px}
.about-foot{margin:14px 0 0;font-size:11px;color:color-mix(in srgb,var(--muted) 75%,transparent)}
/* ============ 下拉“查看全部” + 消息中心 / 今日任务 / 笔记页面 ============ */
@@ -1671,8 +1817,8 @@ html[data-theme=light] .ph-stats{background:rgba(255,255,255,.5)}
/* 成员卡片网格 */
.team-members{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:14px;margin-bottom:18px}
.team-member{display:flex;align-items:flex-start;gap:12px;padding:15px 16px;margin:0}
.tm-avatar{width:44px;height:44px;flex:none;border-radius:50%;overflow:hidden;display:grid;place-items:center;background:linear-gradient(135deg,rgba(115,103,245,.35),rgba(224,68,127,.3));border:1px solid var(--glass-border)}
.tm-avatar img{width:100%;height:100%;object-fit:cover}
.tm-avatar{position:relative;width:44px;height:44px;flex:none;border-radius:50%;overflow:hidden;display:grid;place-items:center;background:linear-gradient(135deg,rgba(115,103,245,.35),rgba(224,68,127,.3));border:1px solid var(--glass-border)}
.tm-avatar img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;display:block}
.tm-avatar b{font-size:17px;color:#fff}
.tm-main{flex:1;min-width:0;display:grid;gap:4px}
.tm-main>b{font-size:13.5px;display:flex;align-items:baseline;gap:6px;overflow:hidden;white-space:nowrap}
@@ -1874,3 +2020,179 @@ html,body,#app{height:100%;overflow:hidden}
.tb-name,.tb-ver{display:none}
.tb-menus .tb-menu-btn{padding:0 7px;font-size:11.8px}
}
/* 图片灯箱预览 */
.img-preview{position:fixed;inset:0;z-index:120;display:grid;place-items:center;background:rgba(0,0,0,.78);backdrop-filter:blur(6px)}
.img-preview-toolbar{position:absolute;top:16px;left:50%;transform:translateX(-50%);display:flex;align-items:center;gap:8px;padding:6px 10px;border-radius:999px;background:rgba(20,24,32,.88);border:1px solid rgba(255,255,255,.12);z-index:1}
.img-preview-toolbar button{width:34px;height:34px;display:grid;place-items:center;border:0;border-radius:50%;background:transparent;color:#fff;cursor:pointer}
.img-preview-toolbar button:hover{background:rgba(255,255,255,.12)}
.img-preview-toolbar svg{width:16px;height:16px}
.img-preview-scale{color:rgba(255,255,255,.75);font-size:12px;min-width:42px;text-align:center}
.img-preview-img{max-width:92vw;max-height:86vh;object-fit:contain;cursor:grab;user-select:none;transition:transform .05s linear}
.img-preview-img.dragging{cursor:grabbing}
/* 头像历史 */
.avatar-hist{margin-top:8px}
.avatar-hist>b{display:block;font-size:12.5px;color:var(--muted);margin-bottom:8px}
.avatar-hist-grid{display:flex;flex-wrap:wrap;gap:8px}
.avatar-hist-item{width:48px;height:48px;border-radius:50%;overflow:hidden;border:2px solid transparent;padding:0;background:var(--surface-2);cursor:pointer;display:grid;place-items:center;color:var(--muted)}
.avatar-hist-item.on{border-color:var(--primary)}
.avatar-hist-item img{width:100%;height:100%;object-fit:cover}
.avatar-hist-item svg{width:20px;height:20px}
/* 通用右键菜单:一级固定;二级为 Windows 式侧向级联浮层 */
.ctx-menu{position:fixed;z-index:90;min-width:188px;display:flex;flex-direction:column;padding:6px;border:1px solid var(--border);border-radius:10px;background:var(--surface);box-shadow:0 16px 44px rgba(0,0,0,.42)}
.ctx-menu button{display:flex;align-items:center;gap:9px;width:100%;border:0;background:transparent;color:var(--text);padding:8px 10px;border-radius:7px;cursor:pointer;font-size:13px;text-align:left}
.ctx-menu button:hover,.ctx-menu button.open{background:var(--surface-3)}
.ctx-menu button.danger{color:var(--red)}
.ctx-menu button svg{width:15px;height:15px;color:var(--muted);flex:none}
.ctx-menu .ctx-chevron{margin-left:auto;width:14px;height:14px;opacity:.55}
.ctx-menu .ctx-parent{font-weight:600}
.ctx-flyout{position:fixed;z-index:91;min-width:148px;max-height:min(280px,70vh);overflow:auto;display:flex;flex-direction:column;gap:2px;padding:6px;border:1px solid var(--border);border-radius:10px;background:var(--surface);box-shadow:0 16px 44px rgba(0,0,0,.42)}
.ctx-flyout button{display:flex;align-items:center;width:100%;border:0;background:transparent;color:var(--text);padding:7px 10px;border-radius:7px;cursor:pointer;font-size:12.5px;text-align:left;white-space:nowrap}
.ctx-flyout button:hover{background:var(--surface-3)}
.ctx-flyout button.on{background:color-mix(in srgb,var(--primary) 14%,transparent);color:#a9a2ff}
/* 端口监控 · 系统总览 */
.pm-overview{padding:16px 18px;margin-top:4px}
.pm-stats{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px;margin-top:4px}
.pm-stat{display:flex;flex-direction:column;gap:4px;padding:12px 14px;border-radius:12px;border:1px solid var(--border);background:var(--surface-2);min-width:0}
.pm-stat-label{display:inline-flex;align-items:center;gap:6px;font-size:11.5px;color:var(--muted)}
.pm-stat-label svg{width:13px;height:13px}
.pm-stat b{font-size:18px;font-weight:700;letter-spacing:-.02em;line-height:1.2}
.pm-stat b.muted{font-size:13px;font-weight:600;color:var(--muted)}
.pm-stat small{font-size:11px;color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.pm-charts{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin-top:14px}
.pm-chart-card{border:1px solid var(--border);border-radius:12px;background:var(--surface-2);padding:8px 10px 6px;min-width:0}
.pm-chart-card header{font-size:11.5px;color:var(--muted);padding:2px 4px 4px}
.pm-chart{height:160px;width:100%}
.pm-ports-head{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;margin-bottom:4px}
.pm-ports-head .lp-title{margin:0}
.pack-cmds-modal{width:min(560px,92vw);padding:0;overflow:hidden}
.pack-cmds-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;padding:18px 20px 14px;border-bottom:1px solid var(--border);background:linear-gradient(180deg,color-mix(in srgb,var(--primary) 12%,transparent),transparent)}
.pack-cmds-brand{display:flex;gap:12px;align-items:flex-start;min-width:0}
.pack-cmds-ico{width:40px;height:40px;border-radius:12px;display:grid;place-items:center;flex:none;background:color-mix(in srgb,var(--primary) 18%,var(--surface-2));color:#a9a2ff;border:1px solid color-mix(in srgb,var(--primary) 30%,var(--border))}
.pack-cmds-ico svg{width:18px;height:18px}
.pack-cmds-brand h2{margin:0;font-size:16px;line-height:1.3}
.pack-cmds-brand p{margin:4px 0 0;font-size:12px;color:var(--muted);line-height:1.45}
.pack-cmds-body{padding:14px 20px 8px;display:flex;flex-direction:column;gap:10px}
.pack-cmds-meta{display:flex;align-items:center;justify-content:space-between;gap:8px}
.pack-cmds-meta b{font-size:12.5px}
.pack-cmds-meta em{font-style:normal;font-size:11.5px;color:var(--muted)}
.pack-cmds-list{display:flex;flex-direction:column;gap:10px;max-height:min(360px,48vh);overflow:auto;padding-right:2px}
.pack-cmd-card{border:1px solid var(--border);border-radius:12px;background:var(--surface-2);padding:12px;display:flex;flex-direction:column;gap:10px}
.pack-cmd-card-top{display:flex;align-items:center;justify-content:space-between}
.pack-cmd-idx{width:22px;height:22px;border-radius:7px;display:grid;place-items:center;font-size:11px;font-weight:700;background:color-mix(in srgb,var(--primary) 16%,transparent);color:#a9a2ff;flex:none}
.pack-cmd-labeled{display:flex;flex-direction:column;gap:6px;margin:0}
.pack-cmd-labeled>span{font-size:11.5px;color:var(--muted);font-weight:600}
.pack-cmd-labeled>input{height:34px;padding:0 10px;border-radius:8px;border:1px solid var(--border);background:var(--surface-3);color:var(--text);font:inherit;font-size:13px}
.pack-cmd-labeled>input:focus{border-color:var(--primary);outline:0}
.pack-cmd-del{width:28px;height:28px;border:0;border-radius:7px;background:transparent;color:var(--muted);display:grid;place-items:center;cursor:pointer;flex:none}
.pack-cmd-del:hover{color:var(--red);background:color-mix(in srgb,var(--red) 12%,transparent)}
.pack-cmd-del svg{width:14px;height:14px}
.pack-cmd-field{display:flex;align-items:center;gap:8px;min-height:40px;padding:8px 12px;border-radius:8px;border:1px solid rgba(255,255,255,.06);background:#0b0f16;color:#8b9bb4;box-shadow:inset 0 1px 0 rgba(255,255,255,.03)}
.pack-cmd-field:focus-within{border-color:rgba(83,214,162,.35);color:#53d6a2;box-shadow:inset 0 0 0 1px rgba(83,214,162,.12)}
.pack-cmd-field svg{width:14px;height:14px;flex:none;opacity:.75}
.pack-cmds-modal .pack-cmd-field input.pack-cmd-term,
.pack-cmds-modal .pack-cmd-field input.pack-cmd-term:focus,
.pack-cmds-modal .pack-cmd-field input.pack-cmd-term:hover{
flex:1;min-width:0;height:auto!important;min-height:0!important;margin:0!important;padding:0!important;
border:0!important;outline:0!important;border-radius:0!important;
background:transparent!important;box-shadow:none!important;color:#c9d1d9!important;
font:inherit;font-size:12.5px;font-family:ui-monospace,Consolas,"Cascadia Mono",monospace;letter-spacing:0
}
.pack-cmds-modal .pack-cmd-field input.pack-cmd-term::placeholder{color:#5a6578}
html[data-theme=light] .pack-cmd-field{background:#1a2332;border-color:rgba(0,0,0,.2)}
html[data-theme=light] .pack-cmds-modal .pack-cmd-field input.pack-cmd-term,
html[data-theme=light] .pack-cmds-modal .pack-cmd-field input.pack-cmd-term:focus{color:#e8eef7!important}
html[data-theme=light] .pack-cmds-modal .pack-cmd-field input.pack-cmd-term::placeholder{color:#7a8799}
.pack-suggest{padding:10px 12px;border-radius:12px;border:1px solid color-mix(in srgb,var(--primary) 28%,var(--border));background:color-mix(in srgb,var(--primary) 8%,var(--surface-2));display:flex;flex-direction:column;gap:8px}
.pack-suggest-top{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
.pack-suggest-top b{display:inline-flex;align-items:center;gap:6px;font-size:12.5px}
.pack-suggest-top b svg{width:14px;height:14px;color:#a9a2ff}
.pack-suggest-top em{font-style:normal;font-size:11px;color:var(--muted)}
.pack-suggest-kind{padding:2px 8px;border-radius:999px;background:color-mix(in srgb,var(--primary) 18%,transparent);color:#a9a2ff!important}
.pack-suggest-all{margin-left:auto;border:0;background:transparent;color:#a9a2ff;font:inherit;font-size:11.5px;cursor:pointer;padding:2px 4px}
.pack-suggest-all:hover{text-decoration:underline}
.pack-suggest-chips{display:flex;flex-direction:column;gap:6px;max-height:160px;overflow:auto}
.pack-suggest-chips button{display:flex;flex-direction:column;align-items:flex-start;gap:2px;width:100%;text-align:left;padding:8px 10px;border-radius:9px;border:1px solid var(--border);background:var(--surface);color:var(--text);cursor:pointer;font:inherit}
.pack-suggest-chips button:hover{border-color:rgba(123,115,255,.45);background:color-mix(in srgb,var(--primary) 10%,var(--surface))}
.pack-suggest-chips span{font-size:12.5px;font-weight:600}
.pack-suggest-chips code{font-size:11px;color:var(--muted);font-family:ui-monospace,Consolas,monospace;overflow:hidden;text-overflow:ellipsis;max-width:100%;white-space:nowrap}
.pack-suggest-empty{margin:0;font-size:12px;color:var(--muted)}
.pack-cmd-add{display:inline-flex;align-items:center;justify-content:center;gap:6px;height:36px;border-radius:9px;border:1px dashed var(--border);background:transparent;color:var(--muted);cursor:pointer;font:inherit;font-size:12.5px}
.pack-cmd-add:hover{border-color:rgba(123,115,255,.45);color:#a9a2ff;background:color-mix(in srgb,var(--primary) 8%,transparent)}
.pack-cmd-add svg{width:14px;height:14px}
.pack-cmds-foot{display:flex;justify-content:flex-end;gap:8px;padding:12px 20px 18px;border-top:1px solid var(--border)}
@media (max-width:980px){
.pm-stats{grid-template-columns:repeat(2,minmax(0,1fr))}
.pm-charts{grid-template-columns:1fr}
}
/* 启动台两级分类 */
.lp-cats-label{font-size:11.5px;color:var(--muted);margin-right:4px;align-self:center}
.lp-cats-sub{margin-top:6px}
.lp-cat-manage{width:30px;padding:0;display:grid;place-items:center}
.lp-cat-manage svg{width:14px;height:14px}
.lp-icon-btns{display:flex;flex-wrap:wrap;gap:8px}
.lp-cat-row{flex-direction:row;align-items:center;justify-content:space-between}
/* 种类默认图标管理 */
.kind-icons-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:12px;margin-top:14px}
.kind-icon-card{display:flex;flex-direction:column;gap:8px;padding:12px;border:1px solid var(--border);border-radius:12px;background:var(--surface-2)}
.kind-icon-preview{width:48px;height:48px;border-radius:12px;overflow:hidden;background:var(--surface-3);display:grid;place-items:center;color:var(--muted)}
.kind-icon-preview img{width:100%;height:100%;object-fit:cover}
.kind-icon-preview svg{width:22px;height:22px}
.kind-icon-ops{display:flex;flex-wrap:wrap;gap:6px}
/* 打包任务页 */
.pack-tasks-layout{display:grid;grid-template-columns:minmax(240px,320px) 1fr;gap:14px;align-items:stretch;min-height:min(68vh,720px)}
.pack-tasks-side{display:flex;flex-direction:column;gap:8px;min-height:0;max-height:min(68vh,720px);overflow:auto}
.pack-task-row{display:flex;align-items:flex-start;gap:10px;width:100%;text-align:left;padding:10px 12px;border-radius:12px;border:1px solid var(--border);background:var(--surface);color:var(--text);cursor:pointer;font:inherit}
.pack-task-row:hover{border-color:rgba(123,115,255,.4)}
.pack-task-row.on{border-color:rgba(123,115,255,.55);background:color-mix(in srgb,var(--primary) 10%,var(--surface))}
.pack-task-row.running{border-color:rgba(231,189,53,.35)}
.pack-task-row.failed{border-color:rgba(240,94,104,.35)}
.pack-task-st{width:24px;height:24px;border-radius:7px;display:grid;place-items:center;flex:none;background:var(--surface-3);color:var(--muted)}
.pack-task-row.running .pack-task-st{color:var(--yellow)}
.pack-task-row.done .pack-task-st{color:var(--green)}
.pack-task-row.failed .pack-task-st{color:var(--red)}
.pack-task-st svg{width:14px;height:14px}
.pack-task-main{min-width:0;flex:1;display:flex;flex-direction:column;gap:2px}
.pack-task-main b{font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.pack-task-main small{font-size:11px;color:var(--muted)}
.pack-task-main code{font-size:10.5px;color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:ui-monospace,Consolas,monospace}
.pack-task-rm{width:26px;height:26px;border:0;border-radius:7px;background:transparent;color:var(--muted);display:grid;place-items:center;cursor:pointer;flex:none}
.pack-task-rm:hover{color:var(--red);background:color-mix(in srgb,var(--red) 12%,transparent)}
.pack-task-rm svg{width:13px;height:13px}
.pack-tasks-console{display:flex;flex-direction:column;min-height:0;max-height:min(68vh,720px);padding:0;overflow:hidden}
.pack-console-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;padding:14px 16px 10px;border-bottom:1px solid var(--border)}
.pack-console-head h2{margin:0;display:inline-flex;align-items:center;gap:8px;font-size:15px}
.pack-console-head h2 svg{width:16px;height:16px;color:#a9a2ff}
.pack-console-head p{margin:4px 0 0;display:flex;flex-wrap:wrap;gap:10px;font-size:12px;color:var(--muted)}
.pack-console-head em{font-style:normal;font-weight:700}
.pack-console-head em.running{color:var(--yellow)}
.pack-console-head em.done{color:var(--green)}
.pack-console-head em.failed{color:var(--red)}
.pack-console-acts{display:flex;gap:8px;flex:none}
.pack-console-meta{display:flex;flex-direction:column;gap:4px;padding:8px 16px;border-bottom:1px solid var(--border);font-size:12px;color:var(--muted)}
.pack-console-meta span{display:inline-flex;align-items:center;gap:6px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.pack-console-meta svg{width:13px;height:13px;flex:none}
.pack-console-meta code{font-family:ui-monospace,Consolas,monospace;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.pack-console-body{flex:1;min-height:280px;overflow:auto;padding:12px 14px;background:#0b0f16;font-family:ui-monospace,Consolas,monospace;font-size:12px;line-height:1.5}
.pack-console-line{margin:0 0 2px;white-space:pre-wrap;word-break:break-all;color:#c9d1d9}
.pack-console-line.meta{color:#8b9bb4}
.pack-console-line.ok{color:#53d6a2}
.pack-console-line.err{color:#ff8b8b}
.pack-console-empty,.pack-console-placeholder{padding:40px 16px;text-align:center;color:var(--muted);font-size:13px}
.pack-console-placeholder{display:grid;place-items:center;gap:10px;flex:1}
.pack-console-placeholder svg{width:28px;height:28px;opacity:.5}
.pack-console-err{margin:0;padding:8px 14px;border-top:1px solid rgba(240,94,104,.35);color:var(--red);font-size:12px;background:color-mix(in srgb,var(--red) 8%,transparent)}
.rail-pack-item{cursor:pointer}
html[data-theme=light] .pack-console-body{background:#f4f6fa}
html[data-theme=light] .pack-console-line{color:#1f2937}
@media (max-width:980px){
.pack-tasks-layout{grid-template-columns:1fr}
.pack-tasks-side{max-height:220px}
}

View File

@@ -1,5 +1,5 @@
import { defineStore } from 'pinia'
import { call, on, onTasksChanged } from './api'
import { call, on, onTasksChanged, resolveImageSrc } from './api'
export const useAppStore = defineStore('app', {
state: () => ({
@@ -63,7 +63,8 @@ export const useAppStore = defineStore('app', {
localStorage.setItem('cc-settings', JSON.stringify(this.settings))
this.resolveAvatar()
},
// resolveAvatar 把设置中的头像解析为可显示的 <img> 源path 模式需经后端读文件转 dataURL
// resolveAvatar 把设置中的头像解析为可显示的 <img> 源path 模式需经后端读文件转 dataURL
// url 模式经 Go 拉取成 dataURLWebView 直接拉外站 HTTPS 常会破图)。
async resolveAvatar() {
const { avatarMode, avatarValue } = this.settings
if (!avatarMode || !avatarValue) { this.avatarSrc = ''; return }
@@ -71,6 +72,10 @@ export const useAppStore = defineStore('app', {
try { this.avatarSrc = await call('ReadImageAsDataURL', avatarValue) } catch { this.avatarSrc = '' }
return
}
if (avatarMode === 'url' || /^https?:\/\//i.test(avatarValue)) {
try { this.avatarSrc = await resolveImageSrc(avatarValue) } catch { this.avatarSrc = '' }
return
}
this.avatarSrc = avatarValue
},
async refreshSyncStatus() {

View File

@@ -0,0 +1,399 @@
<script setup>
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { Shield, Users, Building2, Upload, BarChart3, RefreshCw, CheckCircle2, Ban, KeyRound, FolderOpen } from 'lucide-vue-next'
import { call, on } from '../api'
import { useAppStore } from '../store'
const { t } = useI18n()
const store = useAppStore()
const tab = ref('overview')
const busy = ref('')
const err = ref('')
const stepupOpen = ref(false)
const stepupCode = ref('')
const stepupRisk = ref(false)
const pendingAction = ref(null)
const totp = reactive({ enabled: false, pending: false, otpauth: '', secret: '', stepupActive: false, stepupExpiresAt: '' })
const overview = ref(null)
const users = ref([])
const teams = ref([])
const releases = ref([])
const releaseForm = reactive({ version: '', channel: 'stable', changelog: '', filePath: '' })
const errText = e => {
const code = String(e).split(':')[0].trim()
const k = 'errors.' + code
return t(k) !== k ? t(k) : String(e)
}
async function withStepUp(fn) {
try {
const has = await call('AdminHasStepUp')
if (!has) {
pendingAction.value = fn
stepupRisk.value = false
stepupCode.value = ''
stepupOpen.value = true
return
}
await fn()
} catch (e) {
const code = String(e).split(':')[0].trim()
if (code === 'ADMIN_STEPUP_REQUIRED' || code === 'ADMIN_IP_CHANGED') {
pendingAction.value = fn
stepupRisk.value = code === 'ADMIN_IP_CHANGED'
stepupCode.value = ''
stepupOpen.value = true
return
}
throw e
}
}
async function confirmStepUp() {
busy.value = 'stepup'
err.value = ''
try {
await call('AdminStepUp', stepupCode.value.trim())
stepupOpen.value = false
await loadTotp()
const fn = pendingAction.value
pendingAction.value = null
if (fn) await fn()
} catch (e) {
err.value = errText(e)
} finally {
busy.value = ''
}
}
async function loadTotp() {
const s = await call('AdminTOTPStatus')
Object.assign(totp, {
enabled: !!s.enabled,
pending: !!s.pending,
otpauth: s.otpauth || '',
secret: s.secret || '',
stepupActive: !!s.stepupActive,
stepupExpiresAt: s.stepupExpiresAt || ''
})
}
async function beginTotp() {
busy.value = 'totp'
try {
const s = await call('AdminTOTPSetupBegin')
Object.assign(totp, { pending: true, otpauth: s.otpauth || '', secret: s.secret || '', enabled: false })
} catch (e) { err.value = errText(e) }
finally { busy.value = '' }
}
async function confirmTotp() {
busy.value = 'totp'
try {
await call('AdminTOTPSetupConfirm', stepupCode.value.trim())
stepupCode.value = ''
await loadTotp()
store.showToast({ type: 'success', text: t('adminTotpBound') })
} catch (e) { err.value = errText(e) }
finally { busy.value = '' }
}
async function loadOverview() {
overview.value = await call('AdminOverview', 14)
}
async function loadUsers() { users.value = await call('AdminListUsers') }
async function loadTeams() { teams.value = await call('AdminListTeams') }
async function loadReleases() { releases.value = await call('AdminListReleases') }
async function refresh() {
busy.value = 'load'
err.value = ''
try {
await loadTotp()
if (tab.value === 'overview') await loadOverview()
if (tab.value === 'users') await loadUsers()
if (tab.value === 'teams') await loadTeams()
if (tab.value === 'releases') await loadReleases()
} catch (e) { err.value = errText(e) }
finally { busy.value = '' }
}
watch(tab, () => refresh())
async function patchUser(u, field, val) {
await withStepUp(async () => {
await call('AdminPatchUser', u.id, field, val)
await loadUsers()
})
}
async function patchTeam(tm, val) {
await withStepUp(async () => {
await call('AdminPatchTeam', tm.id, val)
await loadTeams()
})
}
async function pickRelease() {
const p = await call('AdminSelectReleaseFile')
if (p) releaseForm.filePath = p
}
function applyReleaseFile(path) {
const p = String(path || '').trim()
if (!p) return
if (!/\.exe$/i.test(p)) {
store.showToast({ type: 'error', text: t('adminReleaseNeedExe') })
return
}
releaseForm.filePath = p
}
async function uploadRelease() {
await withStepUp(async () => {
busy.value = 'upload'
try {
await call('AdminUploadRelease', releaseForm.version, releaseForm.channel, releaseForm.changelog, releaseForm.filePath)
releaseForm.filePath = ''
await loadReleases()
store.showToast({ type: 'success', text: t('adminReleaseUploaded') })
} finally { busy.value = '' }
})
}
async function publishRelease(id) {
await withStepUp(async () => {
await call('AdminPublishRelease', id)
await loadReleases()
store.showToast({ type: 'success', text: t('adminReleasePublished') })
})
}
const qrUrl = computed(() => {
if (!totp.otpauth) return ''
return 'https://api.qrserver.com/v1/create-qr-code/?size=180x180&data=' + encodeURIComponent(totp.otpauth)
})
let offFilesDropped = null
onMounted(() => {
refresh()
offFilesDropped = on('files-dropped', payload => {
if (tab.value !== 'releases') return
const target = payload?.target || ''
if (target && target !== 'admin-release-drop') return
const files = Array.isArray(payload?.files) ? payload.files : []
const exe = files.find(f => /\.exe$/i.test(String(f || '')))
if (exe) applyReleaseFile(exe)
else if (files.length) store.showToast({ type: 'error', text: t('adminReleaseNeedExe') })
})
})
onUnmounted(() => { offFilesDropped?.() })
</script>
<template>
<div class="page admin-page">
<header class="page-head">
<div>
<h1>{{ t('adminTitle') }}</h1>
<p class="muted">{{ t('adminSubtitle') }}</p>
</div>
<button class="btn" :disabled="!!busy" @click="refresh"><RefreshCw :size="16"/>{{ t('refresh') }}</button>
</header>
<p v-if="err" class="err-banner">{{ err }}</p>
<nav class="admin-tabs">
<button :class="{on:tab==='overview'}" @click="tab='overview'"><BarChart3 :size="15"/>{{ t('adminTabOverview') }}</button>
<button :class="{on:tab==='users'}" @click="tab='users'"><Users :size="15"/>{{ t('adminTabUsers') }}</button>
<button :class="{on:tab==='teams'}" @click="tab='teams'"><Building2 :size="15"/>{{ t('adminTabTeams') }}</button>
<button :class="{on:tab==='releases'}" @click="tab='releases'"><Upload :size="15"/>{{ t('adminTabReleases') }}</button>
<button :class="{on:tab==='security'}" @click="tab='security'"><Shield :size="15"/>{{ t('adminTabSecurity') }}</button>
</nav>
<section v-if="tab==='overview' && overview" class="admin-panel">
<div class="stat-grid">
<div class="stat"><span>{{ t('adminStatUsers') }}</span><b>{{ overview.userCount }}</b></div>
<div class="stat"><span>{{ t('adminStatTeams') }}</span><b>{{ overview.teamCount }}</b></div>
<div class="stat"><span>{{ t('adminStatDAU') }}</span><b>{{ overview.dauToday }}</b></div>
<div class="stat"><span>{{ t('adminStatTokens') }}</span><b>{{ (overview.tokenToday?.promptTokens||0)+(overview.tokenToday?.completionTokens||0) }}</b></div>
</div>
<div class="series">
<h3>{{ t('adminDAUSeries') }}</h3>
<div class="bars">
<div v-for="p in overview.dauSeries||[]" :key="'d'+p.date" class="bar" :title="p.date+': '+p.count">
<i :style="{height: Math.max(4, (p.count/Math.max(1,...(overview.dauSeries||[]).map(x=>x.count)))*80)+'px'}"></i>
<em>{{ p.date.slice(5) }}</em>
</div>
</div>
</div>
<div class="series">
<h3>{{ t('adminTokenSeries') }}</h3>
<div class="bars">
<div v-for="p in overview.tokenSeries||[]" :key="'t'+p.date" class="bar" :title="p.date">
<i :style="{height: Math.max(4, (((p.promptTokens||0)+(p.completionTokens||0))/Math.max(1,...(overview.tokenSeries||[]).map(x=>(x.promptTokens||0)+(x.completionTokens||0))))*80)+'px'}"></i>
<em>{{ p.date.slice(5) }}</em>
</div>
</div>
</div>
</section>
<section v-else-if="tab==='users'" class="admin-panel">
<table class="admin-table">
<thead><tr><th>ID</th><th>{{ t('fieldUser') }}</th><th>{{ t('profileNickname') }}</th><th>IP</th><th>{{ t('adminColAI') }}</th><th>{{ t('adminColDisabled') }}</th></tr></thead>
<tbody>
<tr v-for="u in users" :key="u.id">
<td>{{ u.id }}</td>
<td>{{ u.username }}</td>
<td>{{ u.nickname||'—' }}</td>
<td class="mono">{{ u.lastLoginIp||'—' }}</td>
<td>
<button class="btn sm" :class="{danger:u.aiBanned}" @click="patchUser(u,'aiBanned',u.aiBanned?0:1)">
<Ban :size="14"/>{{ u.aiBanned ? t('adminUnbanAI') : t('adminBanAI') }}
</button>
</td>
<td>
<button class="btn sm" :disabled="u.id===1" :class="{danger:u.disabled}" @click="patchUser(u,'disabled',u.disabled?0:1)">
{{ u.disabled ? t('adminEnable') : t('adminDisable') }}
</button>
</td>
</tr>
</tbody>
</table>
</section>
<section v-else-if="tab==='teams'" class="admin-panel">
<table class="admin-table">
<thead><tr><th>ID</th><th>{{ t('teamName') }}</th><th>{{ t('teamRole_owner') }}</th><th>{{ t('adminColMembers') }}</th><th>{{ t('adminColAI') }}</th></tr></thead>
<tbody>
<tr v-for="tm in teams" :key="tm.id">
<td>{{ tm.id }}</td>
<td>{{ tm.name }}</td>
<td>{{ tm.ownerName||tm.ownerId }}</td>
<td>{{ tm.members }}</td>
<td>
<button class="btn sm" :class="{danger:tm.aiBanned}" @click="patchTeam(tm, tm.aiBanned?0:1)">
{{ tm.aiBanned ? t('adminUnbanAI') : t('adminBanAI') }}
</button>
</td>
</tr>
</tbody>
</table>
</section>
<section v-else-if="tab==='releases'" class="admin-panel">
<div class="form-grid">
<label>{{ t('adminReleaseVersion') }}<input v-model="releaseForm.version" placeholder="2.0.1"/></label>
<label>{{ t('adminReleaseChannel') }}<input v-model="releaseForm.channel"/></label>
<label class="span2">{{ t('adminReleaseChangelog') }}<textarea v-model="releaseForm.changelog" rows="3"/></label>
<label class="span2">{{ t('adminReleaseFile') }}
<div
id="admin-release-drop"
class="release-drop"
data-file-drop-target
@click="pickRelease"
>
<Upload :size="22"/>
<div class="release-drop-body">
<b>{{ releaseForm.filePath ? releaseForm.filePath.replace(/^.*[\\/]/, '') : t('adminReleaseDropTitle') }}</b>
<small>{{ releaseForm.filePath || t('adminReleaseDropHint') }}</small>
</div>
<button type="button" class="btn" @click.stop="pickRelease"><FolderOpen :size="15"/>{{ t('browse') }}</button>
</div>
</label>
<button class="btn primary" :disabled="!!busy||!releaseForm.version||!releaseForm.filePath" @click="uploadRelease">
<Upload :size="15"/>{{ t('adminReleaseUpload') }}
</button>
</div>
<table class="admin-table" style="margin-top:1rem">
<thead><tr><th>{{ t('adminReleaseVersion') }}</th><th>{{ t('adminReleaseChannel') }}</th><th>SHA256</th><th>{{ t('adminColLatest') }}</th><th></th></tr></thead>
<tbody>
<tr v-for="r in releases" :key="r.id">
<td>{{ r.version }}</td>
<td>{{ r.channel }}</td>
<td class="mono trunc">{{ (r.sha256||'').slice(0,12) }}</td>
<td>{{ r.isLatest ? '✓' : '' }}</td>
<td><button v-if="!r.isLatest" class="btn sm" @click="publishRelease(r.id)">{{ t('adminReleasePublish') }}</button></td>
</tr>
</tbody>
</table>
</section>
<section v-else-if="tab==='security'" class="admin-panel">
<div class="sec-card">
<h3><KeyRound :size="16"/> Google Authenticator</h3>
<p class="muted">{{ t('adminTotpHint') }}</p>
<p v-if="totp.enabled" class="ok"><CheckCircle2 :size="14"/> {{ t('adminTotpEnabled') }}
<span v-if="totp.stepupActive"> · {{ t('adminStepupActive') }}</span>
</p>
<template v-else>
<button class="btn primary" :disabled="!!busy" @click="beginTotp">{{ t('adminTotpBegin') }}</button>
<div v-if="totp.pending" class="totp-setup">
<img v-if="qrUrl" :src="qrUrl" alt="QR" width="180" height="180"/>
<p class="mono">{{ totp.secret }}</p>
<input v-model="stepupCode" maxlength="8" :placeholder="t('adminTotpCodePh')"/>
<button class="btn primary" @click="confirmTotp">{{ t('adminTotpConfirm') }}</button>
</div>
</template>
<button v-if="totp.enabled" class="btn" style="margin-top:.75rem" @click="stepupOpen=true;stepupRisk=false;pendingAction=null">{{ t('adminStepupNow') }}</button>
</div>
</section>
<div v-if="stepupOpen" class="modal-mask" @click.self="stepupOpen=false">
<div class="modal">
<h3>{{ t('adminStepupTitle') }}</h3>
<p v-if="stepupRisk" class="risk">{{ t('adminIpChangedRisk') }}</p>
<p class="muted">{{ t('adminStepupHint') }}</p>
<input v-model="stepupCode" maxlength="8" autofocus :placeholder="t('adminTotpCodePh')" @keyup.enter="confirmStepUp"/>
<div class="row end">
<button class="btn" @click="stepupOpen=false">{{ t('cancel') }}</button>
<button class="btn primary" :disabled="!!busy||stepupCode.length<6" @click="confirmStepUp">{{ t('adminStepupConfirm') }}</button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.admin-page{padding:1.25rem 1.5rem 2rem;max-width:1100px}
.page-head{display:flex;justify-content:space-between;align-items:flex-start;gap:1rem;margin-bottom:1rem}
.admin-tabs{display:flex;flex-wrap:wrap;gap:.4rem;margin-bottom:1rem}
.admin-tabs button{display:inline-flex;align-items:center;gap:.35rem;padding:.45rem .75rem;border-radius:8px;border:1px solid var(--border);background:transparent;color:inherit;cursor:pointer}
.admin-tabs button.on{background:var(--accent, #3b82f6);color:#fff;border-color:transparent}
.stat-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:.75rem;margin-bottom:1.25rem}
.stat{padding:1rem;border:1px solid var(--border);border-radius:10px;display:flex;flex-direction:column;gap:.35rem}
.stat b{font-size:1.6rem}
.series{margin-bottom:1.25rem}
.bars{display:flex;align-items:flex-end;gap:4px;height:100px;overflow-x:auto}
.bar{display:flex;flex-direction:column;align-items:center;justify-content:flex-end;min-width:28px;flex:1}
.bar i{display:block;width:100%;max-width:18px;background:var(--accent,#3b82f6);border-radius:4px 4px 0 0}
.bar em{font-size:9px;opacity:.6;margin-top:4px}
.admin-table{width:100%;border-collapse:collapse;font-size:.9rem}
.admin-table th,.admin-table td{padding:.55rem .5rem;border-bottom:1px solid var(--border);text-align:left}
.mono{font-family:ui-monospace,monospace;font-size:.8rem}
.trunc{max-width:120px;overflow:hidden;text-overflow:ellipsis}
.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:.75rem}
.form-grid label{display:flex;flex-direction:column;gap:.3rem;font-size:.85rem}
.form-grid .span2{grid-column:1/-1}
.form-grid input,.form-grid textarea,.modal input{padding:.5rem .65rem;border-radius:8px;border:1px solid var(--border);background:transparent;color:inherit}
.release-drop{display:flex;align-items:center;gap:.85rem;padding:.9rem 1rem;border:1.5px dashed color-mix(in srgb,var(--border) 80%,var(--accent,#3b82f6));border-radius:12px;background:color-mix(in srgb,var(--surface-3,transparent) 55%,transparent);cursor:pointer;transition:border-color .18s,background .18s,box-shadow .18s}
.release-drop:hover,.release-drop.file-drop-target-active{border-color:var(--accent,#3b82f6);background:color-mix(in srgb,var(--accent,#3b82f6) 12%,transparent);box-shadow:0 0 0 3px color-mix(in srgb,var(--accent,#3b82f6) 18%,transparent)}
.release-drop > svg{flex:none;opacity:.75;color:var(--accent,#3b82f6)}
.release-drop-body{flex:1;min-width:0;display:flex;flex-direction:column;gap:.2rem}
.release-drop-body b{font-size:.92rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.release-drop-body small{opacity:.65;font-size:.78rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.row{display:flex;gap:.5rem;align-items:center}
.row.end{justify-content:flex-end;margin-top:1rem}
.btn.sm{padding:.25rem .5rem;font-size:.8rem}
.btn.danger{border-color:#ef4444;color:#ef4444}
.err-banner{color:#ef4444;margin-bottom:.75rem}
.ok{color:#16a34a;display:flex;align-items:center;gap:.35rem}
.sec-card{border:1px solid var(--border);border-radius:12px;padding:1.25rem;max-width:480px}
.totp-setup{margin-top:1rem;display:flex;flex-direction:column;gap:.6rem;align-items:flex-start}
.modal-mask{position:fixed;inset:0;background:rgba(0,0,0,.45);display:flex;align-items:center;justify-content:center;z-index:80}
.modal{background:var(--panel, #1a1f2e);border:1px solid var(--border);border-radius:12px;padding:1.25rem;width:min(400px,92vw)}
.risk{color:#f59e0b;font-weight:600}
@media(max-width:800px){.stat-grid{grid-template-columns:1fr 1fr}.form-grid{grid-template-columns:1fr}}
</style>

View File

@@ -1,12 +1,14 @@
<script setup>
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
import { computed, nextTick, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { Folder, Code2, GitCommitHorizontal, Plus, RefreshCw, Search, Trash2, Pencil, FolderOpen, PieChart, X, CheckCircle2, CircleX, Ban, Star, CloudDownload, ChevronUp, ChevronDown } from 'lucide-vue-next'
import { Folder, Code2, GitCommitHorizontal, Plus, RefreshCw, Search, Trash2, Pencil, FolderOpen, PieChart, X, CheckCircle2, CircleX, Ban, Star, CloudDownload, ChevronUp, ChevronDown, Rocket, Image, Eye, Package, ChevronRight } from 'lucide-vue-next'
import StatCard from '../components/StatCard.vue'
import ChartView from '../components/ChartView.vue'
import PackCmdsModal from '../components/PackCmdsModal.vue'
import { useAppStore } from '../store'
import { call, on } from '../api'
import { getPackCmds } from '../packCmds'
const store = useAppStore()
const router = useRouter()
@@ -27,6 +29,15 @@ const groupForm = reactive({ name: '' })
const srcMode = ref('local') // 新建项目来源local 本地目录 / git 克隆
const gitForm = reactive({ url: '', parentDir: '' })
const palette = ['#53d6a2', '#5da8ff', '#f7cb4d', '#a78bfa', '#ef6f8f']
const kindIcons = ref({})
const projectKinds = ref({}) // id → kind
const ctx = reactive({ open: false, x: 0, y: 0, project: null, sub: '' })
const ctxEl = ref(null)
const flyEl = ref(null)
const flyStyle = ref({ left: '0px', top: '0px' })
const packModal = ref({ open: false, id: 0, title: '', dir: '' })
const packTick = ref(0)
let subLeaveTimer = 0
const filtered = computed(() => {
const q = search.value.trim().toLowerCase()
@@ -67,6 +78,128 @@ const errorText = raw => {
return code ? t(`errors.${code}`) : String(raw)
}
function projectIcon(p) {
if (p?.icon) return p.icon
const kind = projectKinds.value[p.id]
return (kind && kindIcons.value[kind]) || ''
}
async function loadKindFallback() {
try { kindIcons.value = await call('ListKindIcons') || {} } catch { kindIcons.value = {} }
const map = { ...projectKinds.value }
await Promise.all(store.projects.map(async p => {
if (p.icon || map[p.id]) return
try { map[p.id] = await call('DetectProjectLaunchKind', p.id) } catch { /* ignore */ }
}))
projectKinds.value = map
}
function openCtx(e, p) {
clearTimeout(subLeaveTimer)
ctx.open = true
ctx.x = e.clientX
ctx.y = e.clientY
ctx.project = p
ctx.sub = ''
nextTick(() => {
const el = ctxEl.value
if (!el) return
const pad = 10
const reserve = 168
const maxX = Math.max(pad, window.innerWidth - el.offsetWidth - reserve - pad)
ctx.x = Math.max(pad, Math.min(e.clientX, maxX))
ctx.y = Math.max(pad, Math.min(e.clientY, window.innerHeight - el.offsetHeight - pad))
})
}
function closeCtx() {
clearTimeout(subLeaveTimer)
ctx.open = false
ctx.project = null
ctx.sub = ''
}
function placeFlyout(anchorEl) {
nextTick(() => {
const fly = flyEl.value
if (!anchorEl || !fly) return
const rect = anchorEl.getBoundingClientRect()
const fw = fly.offsetWidth || 160
const fh = fly.offsetHeight || 40
const pad = 8
let left = rect.right + 4
if (left + fw > window.innerWidth - pad) left = rect.left - fw - 4
left = Math.max(pad, Math.min(left, window.innerWidth - fw - pad))
let top = rect.top
if (top + fh > window.innerHeight - pad) top = Math.max(pad, window.innerHeight - fh - pad)
if (top < pad) top = pad
flyStyle.value = { left: `${left}px`, top: `${top}px` }
})
}
function openSub(kind, e) {
clearTimeout(subLeaveTimer)
ctx.sub = kind
placeFlyout(e.currentTarget)
}
function clearSubSoon() {
clearTimeout(subLeaveTimer)
subLeaveTimer = setTimeout(() => { ctx.sub = '' }, 180)
}
function keepSub() { clearTimeout(subLeaveTimer) }
function ctxPackCmds(p) {
void packTick.value
return p?.id ? getPackCmds('proj', p.id) : []
}
function openPackConfig(p) {
closeCtx()
if (!p?.id) return
packModal.value = { open: true, id: p.id, title: t('packCmdsTitleNamed', { name: p.name }), dir: p.path || '' }
}
async function runPackCmd(p, cmd) {
closeCtx()
if (!p?.id || !cmd?.cmd) return
try {
const task = await call('RunDirCommand', p.path || '', cmd.cmd, cmd.name || '')
store.showToast({ type: 'success', text: t('packCmdStarted', { name: cmd.name || cmd.cmd }) })
router.push({ path: '/pack-tasks', query: { id: task?.id || '' } })
} catch (e) {
store.showToast({ type: 'error', text: String(e?.message || e) })
}
}
async function fetchProjectIcon(p) {
closeCtx()
try {
const u = await call('FetchProjectIcon', p.id)
p.icon = u
await store.refresh()
store.showToast({ type: 'success', key: 'lpFetchIconOk' })
} catch (e) {
store.showToast({ type: 'error', text: String(e?.message || e) })
}
}
function goDetail(p) {
closeCtx()
router.push('/project/' + p.id)
}
function toLaunchpad(p) {
closeCtx()
router.push({ path: '/launchpad', query: { projectId: p.id } })
}
function toggleFav(p) {
closeCtx()
store.toggleFavorite(p.id)
}
function editProject(p) {
closeCtx()
open(p)
}
function deleteProject(p) {
closeCtx()
remove(p)
}
function refreshFromCtx(p) {
closeCtx()
refreshProject(p)
}
function open(p) {
editing.value = p?.id || 0
Object.assign(form, p ? { name: p.name, path: p.path, description: p.description, groupId: p.groupId || defaultGroupId.value } : { name: '', path: '', description: '', groupId: store.selectedProjectGroupId || defaultGroupId.value })
@@ -253,9 +386,16 @@ async function bindNow() {
onMounted(() => {
consumePendingAction()
loadPending()
loadKindFallback()
offSync = on('sync:done', loadPending)
document.addEventListener('click', closeCtx)
})
onUnmounted(() => offSync?.())
onUnmounted(() => {
clearTimeout(subLeaveTimer)
offSync?.()
document.removeEventListener('click', closeCtx)
})
watch(() => store.projects.length, () => loadKindFallback())
</script>
<template>
@@ -353,15 +493,18 @@ onUnmounted(() => offSync?.())
</div>
</div>
<div class="project-grid">
<article v-for="p in filtered" :key="p.id" class="project-card shine-card" :class="{ favorited: store.favorites.includes(p.id) }" @click="router.push('/project/' + p.id)">
<article v-for="p in filtered" :key="p.id" class="project-card shine-card" :class="{ favorited: store.favorites.includes(p.id) }" @click="router.push('/project/' + p.id)" @contextmenu.prevent="openCtx($event, p)">
<div class="project-title">
<div><h3>{{ p.name }}</h3><span class="group-chip">{{ p.groupId === 1 ? t('myProjectGroup') : (p.groupName || t('projectGroup')) }}</span><p :title="p.path">{{ p.path }}</p></div>
<div class="icon-actions">
<button class="wb-star" :class="{ active: store.favorites.includes(p.id) }" :title="store.favorites.includes(p.id) ? t('unfavorite') : t('favorite')" @click.stop="store.toggleFavorite(p.id)"><Star /></button>
<button :title="t('refreshProject')" :disabled="projectRunning(p.id)" @click.stop="refreshProject(p)"><RefreshCw :class="{ spin: projectRunning(p.id) }" /></button>
<button :title="t('edit')" @click.stop="open(p)"><Pencil /></button>
<button :title="t('delete')" @click.stop="remove(p)"><Trash2 /></button>
<span v-if="projectIcon(p)" class="project-icon"><img :src="projectIcon(p)" alt="" /></span>
<div class="project-title-text">
<h3 :title="p.name">{{ p.name }}</h3>
<span class="group-chip">{{ p.groupId === 1 ? t('myProjectGroup') : (p.groupName || t('projectGroup')) }}</span>
<p :title="p.path">{{ p.path }}</p>
</div>
<span class="project-card-corner" @click.stop>
<button :title="t('refreshProject')" :disabled="projectRunning(p.id)" @click="refreshProject(p)"><RefreshCw :class="{ spin: projectRunning(p.id) }" /></button>
<button class="danger" :title="t('delete')" @click="remove(p)"><Trash2 /></button>
</span>
</div>
<div class="metric-row project-metrics">
<div class="metric-total"><b>{{ fmt(p.stats.totalLines) }}</b><span>{{ t('totalLineCount') }}</span></div>
@@ -373,11 +516,61 @@ onUnmounted(() => offSync?.())
<span v-for="(x, i) in p.languages.slice(0, 4)" :key="x.name"><i :style="{ background: palette[i] }" />{{ x.name }} {{ Math.round(x.code / Math.max(1, total(p)) * 100) }}%</span>
<span v-if="!p.languages?.length">{{ t('unanalyzed') }}</span>
</div>
<footer><span> {{ p.stats.commitCount }} {{ t('commitsUnit') }}</span><b class="positive">+{{ fmt(p.stats.addedLines) }}</b><b class="negative">-{{ fmt(p.stats.deletedLines) }}</b></footer>
<footer class="project-card-foot">
<span> {{ p.stats.commitCount }} {{ t('commitsUnit') }}</span>
<b class="positive">+{{ fmt(p.stats.addedLines) }}</b>
<b class="negative">-{{ fmt(p.stats.deletedLines) }}</b>
<span class="project-card-actions" @click.stop>
<button class="wb-star" :class="{ active: store.favorites.includes(p.id) }" :title="store.favorites.includes(p.id) ? t('unfavorite') : t('favorite')" @click="store.toggleFavorite(p.id)"><Star /></button>
<button :title="t('lpToLaunchpad')" @click="router.push({ path: '/launchpad', query: { projectId: p.id } })"><Rocket /></button>
<button :title="t('edit')" @click="open(p)"><Pencil /></button>
</span>
</footer>
</article>
<button class="add-card shine-card" @click="open()"><span><Plus /></span>{{ t('addProject') }}</button>
</div>
</div>
<Teleport to="body">
<div v-if="ctx.open && ctx.project" ref="ctxEl" class="ctx-menu" :style="{ left: ctx.x + 'px', top: ctx.y + 'px' }" @click.stop @mouseleave="clearSubSoon">
<button type="button" @mouseenter="ctx.sub = ''" @click="goDetail(ctx.project)"><Eye />{{ t('viewDetail') }}</button>
<button type="button" @mouseenter="ctx.sub = ''" @click="toggleFav(ctx.project)"><Star />{{ store.favorites.includes(ctx.project.id) ? t('unfavorite') : t('favorite') }}</button>
<button type="button" @mouseenter="ctx.sub = ''" @click="toLaunchpad(ctx.project)"><Rocket />{{ t('lpToLaunchpad') }}</button>
<button
type="button"
class="ctx-parent"
:class="{ open: ctx.sub === 'pack' }"
@mouseenter="ctxPackCmds(ctx.project).length ? openSub('pack', $event) : (ctx.sub = '')"
@click="ctxPackCmds(ctx.project).length ? openSub('pack', $event) : openPackConfig(ctx.project)"
>
<Package />{{ t('packRun') }}<ChevronRight v-if="ctxPackCmds(ctx.project).length" class="ctx-chevron" />
</button>
<button type="button" @mouseenter="ctx.sub = ''" @click="fetchProjectIcon(ctx.project)"><Image />{{ t('lpFetchIcon') }}</button>
<button type="button" @mouseenter="ctx.sub = ''" @click="refreshFromCtx(ctx.project)"><RefreshCw />{{ t('refreshProject') }}</button>
<button type="button" @mouseenter="ctx.sub = ''" @click="editProject(ctx.project)"><Pencil />{{ t('edit') }}</button>
<button type="button" class="danger" @mouseenter="ctx.sub = ''" @click="deleteProject(ctx.project)"><Trash2 />{{ t('delete') }}</button>
</div>
<div
v-if="ctx.open && ctx.project && ctx.sub === 'pack'"
ref="flyEl"
class="ctx-flyout"
:style="flyStyle"
@click.stop
@mouseenter="keepSub"
@mouseleave="clearSubSoon"
>
<button v-for="(c, i) in ctxPackCmds(ctx.project)" :key="i" type="button" :title="c.cmd" @click="runPackCmd(ctx.project, c)">{{ c.name || c.cmd }}</button>
<button type="button" class="on" @click="openPackConfig(ctx.project)"><Package />{{ t('packCmdsConfig') }}</button>
</div>
<PackCmdsModal
:open="packModal.open"
scope="proj"
:target-id="packModal.id"
:title="packModal.title"
:dir="packModal.dir"
@close="packModal.open = false"
@saved="packTick++"
/>
</Teleport>
<Teleport to="body">
<div v-if="modal" class="overlay" @click.self="!saving && (modal = false)">
<form class="modal" @submit.prevent="save">

View File

@@ -1,24 +1,54 @@
<script setup>
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
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 { Browser } from '@wailsio/runtime'
import { Rocket, Plus, RefreshCw, Play, Square, Pencil, Trash2, FolderOpen, X, Hexagon, Zap, FileCode2, Coffee, Database, Globe, Boxes, Box, Cpu, MemoryStick, HardDrive, Sparkles, Search, ExternalLink, FolderGit2, ScrollText, LoaderCircle, Image, ImageUp, Tags, Settings2, RotateCcw, ChevronRight, Package, Pin } from 'lucide-vue-next'
import { call, on } from '../api'
import { useAppStore } from '../store'
import AIScopeDrawer from '../components/AIScopeDrawer.vue'
import PackCmdsModal from '../components/PackCmdsModal.vue'
import { getPackCmds } from '../packCmds'
// 启动台:扫描本机监听端口的服务 + 管理保存的应用(启动/停止/资源占用)。
const { t } = useI18n()
const store = useAppStore()
const route = useRoute()
const router = useRouter()
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 nameQ = ref('')
const portQ = ref('')
const primaryQ = ref(Number(localStorage.getItem('cc-lp-primary') || 0) || 0) // 一级筛选0=全部
const catQ = ref(localStorage.getItem('cc-lp-cat') || '') // 二级筛选;空=全部
const editing = ref(null)
const editErr = ref('')
const suggest = ref({ start: [], stop: [] })
const busy = ref({}) // id/pid -> true 启停按钮防抖
const busy = ref({})
const projectPick = ref(false)
const projects = ref([])
const projectQ = ref('')
const logOpen = ref(null)
const logLines = ref([])
const logLoading = ref(false)
const primaryCats = ref([])
const kindIcons = ref({})
const catMgr = ref(false)
const catForm = ref({ id: 0, name: '' })
const ctx = ref({ open: false, x: 0, y: 0, app: null, sub: '' })
const ctxEl = ref(null)
const flyEl = ref(null)
const flyStyle = ref({ left: '0px', top: '0px' })
const packModal = ref({ open: false, id: 0, title: '', dir: '' })
const packTick = ref(0) // 配置保存后刷新右键子菜单
let subLeaveTimer = 0
let timer = 0
let offChanged = null
let offLog = null
const KINDS = ['node', 'go', 'python', 'java', 'php', 'dotnet', 'mysql', 'redis', 'nginx', 'web', 'other']
const KINDS = ['node', 'go', 'python', 'java', 'php', 'dotnet', 'exe', 'mysql', 'redis', 'nginx', 'web', 'other']
const CAT_PRESETS = ['前端', '后端', '数据库', '工具', '桌面', '其他']
const KIND_UI = {
node: { icon: Hexagon, color: '#8cc84b' },
go: { icon: Zap, color: '#00add8' },
@@ -26,6 +56,7 @@ const KIND_UI = {
java: { icon: Coffee, color: '#f89820' },
php: { icon: FileCode2, color: '#a78bfa' },
dotnet: { icon: Boxes, color: '#8b5cf6' },
exe: { icon: Box, color: '#60a5fa' },
mysql: { icon: Database, color: '#4f9df5' },
redis: { icon: Database, color: '#f16a5b' },
nginx: { icon: Globe, color: '#26c795' },
@@ -33,102 +64,520 @@ const KIND_UI = {
other: { icon: Box, color: 'var(--muted)' }
}
const kindUI = k => KIND_UI[k] || KIND_UI.other
function displayIcon(x) {
if (x?.icon) return x.icon
return kindIcons.value[x?.kind] || ''
}
function primaryName(id) {
return primaryCats.value.find(c => c.id === id)?.name || ''
}
// 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 || ''))
function matchEntry(x) {
const nq = nameQ.value.trim().toLowerCase()
if (nq) {
const hay = [x.name, x.exe, x.cmdline, x.dir].filter(Boolean).join('\n').toLowerCase()
if (!hay.includes(nq)) return false
}
const pq = portQ.value.trim()
if (pq) {
const ports = x.ports?.length ? x.ports : (x.port ? [x.port] : [])
if (!ports.some(p => String(p).includes(pq))) return false
}
return true
}
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)
const myApps = computed(() => entries.value.filter(x => {
if (!(x.id > 0 && matchEntry(x))) return false
if (primaryQ.value && Number(x.categoryId || 0) !== primaryQ.value) return false
if (catQ.value && (x.category || '') !== catQ.value) return false
return true
}))
const secondaryCats = computed(() => {
const set = new Set(CAT_PRESETS)
for (const x of entries.value) {
if (x.id > 0 && x.category) {
if (!primaryQ.value || Number(x.categoryId || 0) === primaryQ.value) set.add(x.category)
}
}
return [...set]
})
const filteredProjects = computed(() => {
const q = projectQ.value.trim().toLowerCase()
if (!q) return projects.value
return projects.value.filter(p => (p.name + ' ' + p.path).toLowerCase().includes(q))
})
function setPrimary(id) {
primaryQ.value = primaryQ.value === id ? 0 : id
localStorage.setItem('cc-lp-primary', String(primaryQ.value))
if (primaryQ.value) {
catQ.value = ''
localStorage.setItem('cc-lp-cat', '')
}
}
function setCat(c) {
catQ.value = catQ.value === c ? '' : c
localStorage.setItem('cc-lp-cat', catQ.value)
}
async function loadCats() {
try { primaryCats.value = await call('ListLaunchCategories') || [] } catch { primaryCats.value = [] }
}
async function loadKindIcons() {
try { kindIcons.value = await call('ListKindIcons') || {} } catch { kindIcons.value = {} }
}
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')
function applyProfile(p, base = {}) {
editing.value = {
id: base.id || 0,
name: p.name || base.name || '',
kind: p.kind || 'other',
port: p.port || 0,
dir: p.dir || '',
startCmd: p.startCmd || '',
stopCmd: p.stopCmd || '',
icon: p.icon || '',
categoryId: base.categoryId || p.categoryId || primaryQ.value || 0,
category: base.category || p.category || ''
}
suggest.value = { start: p.start || [], stop: p.stop || [] }
peekFormIcon()
}
// ---- 添加 / 编辑 ----
async function openForm(x) {
editErr.value = ''
ctx.value.open = false
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()
? { 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 || '', icon: x.icon || '', categoryId: x.categoryId || 0, category: x.category || '' }
: { id: 0, name: '', kind: 'other', port: 0, dir: '', startCmd: '', stopCmd: '', icon: '', categoryId: primaryQ.value || 0, category: catQ.value || '' }
if (editing.value.dir && !editing.value.startCmd) {
await detectDir(false)
} else {
await loadSuggest()
}
}
async function loadSuggest() {
try { suggest.value = await call('LaunchCmdSuggest', editing.value.kind) } catch { suggest.value = { start: [], stop: [] } }
}
async function detectDir(overwrite = true) {
if (!editing.value?.dir) return
try {
const p = await call('DetectLaunchProfile', editing.value.dir)
if (!p) return
if (overwrite || !editing.value.name) editing.value.name = p.name || editing.value.name
if (overwrite || !editing.value.kind || editing.value.kind === 'other') editing.value.kind = p.kind || editing.value.kind
if (overwrite || !editing.value.port) editing.value.port = p.port || editing.value.port
if (overwrite || !editing.value.startCmd) editing.value.startCmd = p.startCmd || editing.value.startCmd
suggest.value = { start: p.start || [], stop: p.stop || [] }
if (!suggest.value.start?.length) await loadSuggest()
} catch { await loadSuggest() }
}
async function pickDir() {
try {
const d = await call('SelectDirectory')
if (d) editing.value.dir = d
if (d) {
editing.value.dir = d
await detectDir(true)
}
} catch { /* 用户取消 */ }
}
async function onKindChange() {
await loadSuggest()
}
async function saveForm() {
editErr.value = ''
try {
await call('SaveLaunchApp', { ...editing.value, port: Number(editing.value.port) || 0 })
await call('SaveLaunchApp', {
...editing.value,
port: Number(editing.value.port) || 0,
categoryId: Number(editing.value.categoryId) || 0
})
editing.value = null
await load()
} catch (e) {
editErr.value = String(e?.message || e)
}
}
async function fetchIcon(x) {
ctx.value.open = false
try {
await call('FetchLaunchIcon', x.id)
await load()
} catch {
try {
const u = await call('PeekLaunchIcon', x.port || 0, x.dir || '')
if (u && editing.value) editing.value.icon = u
} catch { /* 未找到 */ }
}
}
async function peekFormIcon() {
if (!editing.value) return
try {
editing.value.icon = await call('PeekLaunchIcon', Number(editing.value.port) || 0, editing.value.dir || '')
} catch { /* ignore */ }
}
async function pickLocalIcon() {
if (!editing.value) return
try {
const u = await call('PickLaunchIconImage')
if (u) editing.value.icon = u
} catch (e) { store.showToast({ type: 'error', text: String(e) }) }
}
function clearFormIcon() {
if (editing.value) editing.value.icon = ''
}
async function pickCardIcon(x) {
ctx.value.open = false
try {
const u = await call('PickLaunchIconImage')
if (!u) return
await call('SetLaunchAppIcon', x.id, u)
await load()
} catch (e) { store.showToast({ type: 'error', text: String(e) }) }
}
async function clearCardIcon(x) {
ctx.value.open = false
await call('SetLaunchAppIcon', x.id, '')
await load()
}
async function removeApp(x) {
ctx.value.open = false
if (!confirm(t('lpDelConfirm', { name: x.name }))) return
await call('DeleteLaunchApp', x.id)
await load()
}
// ---- 启动 / 停止 ----
async function patchAppCats(x, patch) {
ctx.value.open = false
const full = {
id: x.id,
name: x.name,
kind: x.kind,
port: x.port || 0,
dir: x.dir || '',
startCmd: x.startCmd || '',
stopCmd: x.stopCmd || '',
icon: x.icon || '',
categoryId: x.categoryId || 0,
category: x.category || '',
...patch
}
await call('SaveLaunchApp', full)
await load()
}
function openCtx(e, x) {
clearTimeout(subLeaveTimer)
ctx.value = { open: true, x: e.clientX, y: e.clientY, app: x, sub: '' }
nextTick(() => {
const el = ctxEl.value
if (!el) return
const pad = 10
// 预留右侧二级菜单宽度,避免贴边后二级无处可放
const reserve = 168
const maxX = Math.max(pad, window.innerWidth - el.offsetWidth - reserve - pad)
ctx.value.x = Math.max(pad, Math.min(e.clientX, maxX))
ctx.value.y = Math.max(pad, Math.min(e.clientY, window.innerHeight - el.offsetHeight - pad))
})
}
function closeCtx() {
clearTimeout(subLeaveTimer)
ctx.value.open = false
ctx.value.sub = ''
}
function placeFlyout(anchorEl) {
nextTick(() => {
const fly = flyEl.value
if (!anchorEl || !fly) return
const rect = anchorEl.getBoundingClientRect()
const fw = fly.offsetWidth || 160
const fh = fly.offsetHeight || 40
const pad = 8
// 默认开在一级右侧,不覆盖一级;右侧不够则开到左侧
let left = rect.right + 4
if (left + fw > window.innerWidth - pad) {
left = rect.left - fw - 4
}
left = Math.max(pad, Math.min(left, window.innerWidth - fw - pad))
let top = rect.top
if (top + fh > window.innerHeight - pad) {
top = Math.max(pad, window.innerHeight - fh - pad)
}
if (top < pad) top = pad
flyStyle.value = { left: `${left}px`, top: `${top}px` }
})
}
function openSub(kind, e) {
clearTimeout(subLeaveTimer)
ctx.value.sub = kind
placeFlyout(e.currentTarget)
}
function clearSubSoon() {
clearTimeout(subLeaveTimer)
subLeaveTimer = setTimeout(() => { ctx.value.sub = '' }, 180)
}
function keepSub() { clearTimeout(subLeaveTimer) }
function ctxPackCmds(app) {
void packTick.value
return app?.id ? getPackCmds('lp', app.id) : []
}
function openPackConfig(app) {
ctx.value.open = false
if (!app?.id) return
packModal.value = { open: true, id: app.id, title: t('packCmdsTitleNamed', { name: app.name }), dir: app.dir || '' }
}
async function runPackCmd(app, cmd) {
ctx.value.open = false
if (!app?.id || !cmd?.cmd) return
try {
const task = await call('RunDirCommand', app.dir || '', cmd.cmd, cmd.name || '')
store.showToast({ type: 'success', text: t('packCmdStarted', { name: cmd.name || cmd.cmd }) })
router.push({ path: '/pack-tasks', query: { id: task?.id || '' } })
} catch (e) {
store.showToast({ type: 'error', text: String(e?.message || e) })
}
}
function openCatMgr() {
catMgr.value = true
catForm.value = { id: 0, name: '' }
}
async function savePrimaryCat() {
const name = catForm.value.name.trim()
if (!name) return
try {
await call('SaveLaunchCategory', { id: catForm.value.id || 0, name, sort: 0 })
catForm.value = { id: 0, name: '' }
await loadCats()
} catch (e) { store.showToast({ type: 'error', text: String(e) }) }
}
function editPrimaryCat(c) {
catForm.value = { id: c.id, name: c.name }
}
async function deletePrimaryCat(c) {
if (!confirm(t('lpCatDelConfirm', { name: c.name }))) return
await call('DeleteLaunchCategory', c.id)
if (primaryQ.value === c.id) setPrimary(0)
await loadCats()
await load()
}
async function openProjectPick() {
projectQ.value = ''
projectPick.value = true
try { projects.value = await call('ListProjects') } catch { projects.value = [] }
}
async function pickProject(p) {
projectPick.value = false
editErr.value = ''
try {
const draft = await call('DraftLaunchFromProject', p.id)
applyProfile(draft)
} catch (e) {
await openForm({ name: p.name, dir: p.path, kind: 'other', port: 0 })
editErr.value = String(e?.message || e)
}
}
async function addFromProjectId(id) {
const n = Number(id)
if (!n) return
try {
const draft = await call('DraftLaunchFromProject', n)
applyProfile(draft)
} catch { /* ignore */ }
}
function consumePinQuery() {
const q = route.query
if (q.pin !== '1' && !q.name && !q.port) return false
if (q.pin !== '1') return false
openForm({
name: String(q.name || ''),
kind: String(q.kind || 'other'),
port: Number(q.port) || 0,
dir: String(q.dir || ''),
startCmd: '',
stopCmd: '',
icon: '',
categoryId: primaryQ.value || 0,
category: catQ.value || ''
})
return true
}
function openPort(p) {
const n = Number(p)
if (!n || n <= 0) return
try { Browser.OpenURL(`http://127.0.0.1:${n}`) } catch { /* ignore */ }
}
function statusOf(x) {
if (x.status === 'starting' || busy.value[x.id] === 'start') return 'starting'
if (x.status === 'failed') return 'failed'
if (x.running || x.status === 'running') return 'running'
return 'stopped'
}
function statusText(x) {
const s = statusOf(x)
if (s === 'starting') return t('lpStarting')
if (s === 'failed') return t('lpFailed')
if (s === 'running') return t('lpRunning')
return t('lpStopped')
}
async function openLogs(x) {
logOpen.value = x.id
logLoading.value = true
try {
logLines.value = await call('ListLaunchLogs', x.id, 300)
} catch { logLines.value = [] }
logLoading.value = false
await nextTickScroll()
}
async function nextTickScroll() {
await new Promise(r => setTimeout(r, 30))
const el = document.querySelector('.lp-log-body')
if (el) el.scrollTop = el.scrollHeight
}
async function clearLogs() {
if (!logOpen.value) return
await call('ClearLaunchLogs', logOpen.value)
logLines.value = []
}
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] = 'start'
const hit = entries.value.find(e => e.id === x.id)
if (hit) { hit.status = 'starting'; hit.lastError = '' }
try {
await call('StartLaunchApp', x.id)
await openLogs(x)
} catch (e) {
const msg = String(e?.message || e)
if (msg.includes('LAUNCH_CMD_REQUIRED')) { await openForm(x); editErr.value = t('lpNeedCmd') }
if (hit) { hit.status = 'failed'; hit.lastError = msg }
}
busy.value[x.id] = false
setTimeout(load, 800) // 给进程一点起监听的时间
setTimeout(load, 600)
}
async function stop(x) {
if (!confirm(t('lpStopConfirm', { name: x.name }))) return
const key = x.id || x.pid
busy.value[key] = true
busy.value[key] = 'stop'
try { await call('StopLaunchApp', x.id || 0, x.pid || 0) } catch { /* 已记录日志 */ }
busy.value[key] = false
setTimeout(load, 500)
}
async function restart(x) {
ctx.value.open = false
if (!x?.id) return
if (!x.startCmd) {
await openForm(x)
editErr.value = t('lpNeedCmd')
return
}
if (!confirm(t('lpRestartConfirm', { name: x.name }))) return
busy.value[x.id] = 'start'
const hit = entries.value.find(e => e.id === x.id)
if (hit) { hit.status = 'starting'; hit.lastError = '' }
try {
await call('RestartLaunchApp', x.id)
await openLogs(x)
} catch (e) {
const msg = String(e?.message || e)
if (msg.includes('LAUNCH_CMD_REQUIRED')) { await openForm(x); editErr.value = t('lpNeedCmd') }
if (hit) { hit.status = 'failed'; hit.lastError = msg }
}
busy.value[x.id] = false
setTimeout(load, 600)
}
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'
const entryPorts = x => (x.ports?.length ? x.ports : (x.port ? [x.port] : []))
const logAppName = computed(() => entries.value.find(e => e.id === logOpen.value)?.name || '')
onMounted(() => {
function onDocClick() { if (ctx.value.open) closeCtx() }
onMounted(async () => {
loadCats()
loadKindIcons()
load()
timer = setInterval(load, 5000)
offChanged = on('launchpad:changed', load)
offLog = on('launchpad:log', ev => {
const d = Array.isArray(ev) ? ev[0] : ev
if (!d || d.appId !== logOpen.value) return
logLines.value = [...logLines.value, { id: Date.now(), appId: d.appId, level: d.level || 'info', line: d.line || '', createdAt: new Date().toISOString() }]
nextTickScroll()
})
document.addEventListener('click', onDocClick)
if (route.query.projectId) {
await addFromProjectId(route.query.projectId)
router.replace({ path: '/launchpad', query: {} })
} else if (consumePinQuery()) {
router.replace({ path: '/launchpad', query: {} })
}
})
onUnmounted(() => {
clearInterval(timer)
clearTimeout(subLeaveTimer)
offChanged?.()
offLog?.()
document.removeEventListener('click', onDocClick)
})
watch(() => route.query.projectId, async id => {
if (id) {
await addFromProjectId(id)
router.replace({ path: '/launchpad', query: {} })
}
})
watch(() => route.query.pin, async v => {
if (v === '1' && consumePinQuery()) {
router.replace({ path: '/launchpad', query: {} })
}
})
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>
<header class="page-head sticky-head lp-head">
<div class="lp-head-top">
<div><h1>{{ t('launchpad') }}</h1><p>{{ t('launchpadSubtitle') }}</p></div>
<div class="lp-tools">
<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 secondary" @click="openProjectPick"><FolderGit2 />{{ t('lpAddFromProject') }}</button>
<button class="btn" @click="openForm(null)"><Plus />{{ t('lpAddApp') }}</button>
</div>
</div>
<div class="lp-filters">
<label class="lp-search"><Search /><input v-model.trim="nameQ" type="search" :placeholder="t('lpSearchName')" /></label>
<label class="lp-search lp-search-port"><Search /><input v-model.trim="portQ" type="search" inputmode="numeric" :placeholder="t('lpSearchPort')" /></label>
</div>
<div class="lp-cats">
<span class="lp-cats-label">{{ t('lpPrimaryCat') }}</span>
<button type="button" class="lp-cat" :class="{ on: !primaryQ }" @click="setPrimary(0)">{{ t('lpCatAll') }}</button>
<button v-for="c in primaryCats" :key="c.id" type="button" class="lp-cat" :class="{ on: primaryQ === c.id }" @click="setPrimary(c.id)">{{ c.name }}</button>
<button type="button" class="lp-cat lp-cat-manage" :title="t('lpManagePrimary')" @click="openCatMgr"><Settings2 /></button>
</div>
<div class="lp-cats lp-cats-sub">
<span class="lp-cats-label">{{ t('lpSecondaryCat') }}</span>
<button type="button" class="lp-cat" :class="{ on: !catQ }" @click="setCat('')">{{ t('lpCatAll') }}</button>
<button v-for="c in secondaryCats" :key="c" type="button" class="lp-cat" :class="{ on: catQ === c }" @click="setCat(c)">{{ c }}</button>
</div>
</header>
@@ -136,15 +585,29 @@ onUnmounted(() => { clearInterval(timer); offChanged?.() })
<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 }">
<article v-for="x in myApps" :key="'a' + x.id" class="card lp-card" :class="{ running: statusOf(x) === 'running', starting: statusOf(x) === 'starting', failed: statusOf(x) === 'failed', dimmed: statusOf(x) === 'stopped' }" @contextmenu.prevent="openCtx($event, x)">
<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')" />
<span class="lp-icon" :style="{ color: kindUI(x.kind).color, background: `color-mix(in srgb, ${kindUI(x.kind).color} 14%, transparent)` }">
<img v-if="displayIcon(x)" :src="displayIcon(x)" alt="" />
<component v-else :is="kindUI(x.kind).icon" />
</span>
<div class="lp-name">
<b :title="x.exe || x.name">{{ x.name }}</b>
<small>
{{ x.kind }}
<template v-if="primaryName(x.categoryId)"> · {{ primaryName(x.categoryId) }}</template>
<template v-if="x.category"> · {{ x.category }}</template>
</small>
</div>
<span class="lp-status" :class="statusOf(x)">
<LoaderCircle v-if="statusOf(x) === 'starting'" class="spin" />
<i v-else class="lp-dot" :class="{ on: statusOf(x) === 'running' }" />
{{ statusText(x) }}
</span>
</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>
<button v-for="p in entryPorts(x).slice(0, 4)" :key="p" type="button" class="lp-port lp-port-link" :title="t('lpOpenPort', { p })" @click="openPort(p)">:{{ p }}<ExternalLink /></button>
<span v-if="entryPorts(x).length > 4" class="lp-port more">+{{ entryPorts(x).length - 4 }}</span>
<small v-if="x.pid" class="lp-pid">PID {{ x.pid }}</small>
</div>
<div v-if="x.running" class="lp-res">
@@ -152,10 +615,19 @@ onUnmounted(() => { clearInterval(timer); offChanged?.() })
<span :title="t('lpMem')"><MemoryStick />{{ fmtMem(x.memMB) }}</span>
<span :title="'IO'"><HardDrive />{{ fmtIO(x.ioKBs) }}</span>
</div>
<p v-if="statusOf(x) === 'failed' && x.lastError" class="lp-err-line" :title="x.lastError">{{ x.lastError }}</p>
<p v-else-if="x.lastLog" class="lp-log-preview" :title="x.lastLog">{{ x.lastLog }}</p>
<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>
<button v-if="statusOf(x) !== 'running' && statusOf(x) !== 'starting'" class="lp-act go" :disabled="!!busy[x.id]" @click="start(x)"><Play />{{ t('lpStart') }}</button>
<template v-else>
<button class="lp-act halt icon-only" :disabled="!!busy[x.id] || statusOf(x) === 'starting'" :title="statusOf(x) === 'starting' ? t('lpStarting') : t('lpStop')" @click="stop(x)">
<LoaderCircle v-if="statusOf(x) === 'starting'" class="spin" /><Square v-else />
</button>
<button class="lp-act icon-only" :disabled="!!busy[x.id] || statusOf(x) === 'starting'" :title="t('lpRestart')" @click="restart(x)"><RotateCcw /></button>
</template>
<button class="lp-act" :title="t('lpLogs')" @click="openLogs(x)"><ScrollText /></button>
<button v-if="x.id" class="lp-act" :title="t('lpFetchIcon')" @click="fetchIcon(x)"><Image /></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>
@@ -164,36 +636,64 @@ onUnmounted(() => { clearInterval(timer); offChanged?.() })
</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="ctx.open && ctx.app" ref="ctxEl" class="ctx-menu" :style="{ left: ctx.x + 'px', top: ctx.y + 'px' }" @click.stop @mouseleave="clearSubSoon">
<button type="button" @mouseenter="ctx.sub = ''" @click="openForm(ctx.app)"><Pencil />{{ t('edit') }}</button>
<button v-if="statusOf(ctx.app) === 'running'" type="button" @mouseenter="ctx.sub = ''" @click="restart(ctx.app)"><RotateCcw />{{ t('lpRestart') }}</button>
<button
type="button"
class="ctx-parent"
:class="{ open: ctx.sub === 'pack' }"
@mouseenter="ctxPackCmds(ctx.app).length ? openSub('pack', $event) : (ctx.sub = '')"
@click="ctxPackCmds(ctx.app).length ? openSub('pack', $event) : openPackConfig(ctx.app)"
>
<Package />{{ t('packRun') }}<ChevronRight v-if="ctxPackCmds(ctx.app).length" class="ctx-chevron" />
</button>
<button type="button" class="ctx-parent" :class="{ open: ctx.sub === 'primary' }" @mouseenter="openSub('primary', $event)" @click="openSub('primary', $event)">
<Tags />{{ t('lpSetPrimary') }}<ChevronRight class="ctx-chevron" />
</button>
<button type="button" class="ctx-parent" :class="{ open: ctx.sub === 'secondary' }" @mouseenter="openSub('secondary', $event)" @click="openSub('secondary', $event)">
<Tags />{{ t('lpSetSecondary') }}<ChevronRight class="ctx-chevron" />
</button>
<button type="button" @mouseenter="ctx.sub = ''" @click="pickCardIcon(ctx.app)"><ImageUp />{{ t('lpPickIcon') }}</button>
<button type="button" @mouseenter="ctx.sub = ''" @click="fetchIcon(ctx.app)"><Image />{{ t('lpFetchIcon') }}</button>
<button type="button" @mouseenter="ctx.sub = ''" @click="clearCardIcon(ctx.app)"><X />{{ t('lpClearIcon') }}</button>
<button type="button" class="danger" @mouseenter="ctx.sub = ''" @click="removeApp(ctx.app)"><Trash2 />{{ t('delete') }}</button>
</div>
<!-- Windows 式级联独立浮层不盖住一级贴边时翻到左侧并夹紧视口 -->
<div
v-if="ctx.open && ctx.app && ctx.sub"
ref="flyEl"
class="ctx-flyout"
:style="flyStyle"
@click.stop
@mouseenter="keepSub"
@mouseleave="clearSubSoon"
>
<template v-if="ctx.sub === 'primary'">
<button type="button" :class="{ on: !ctx.app.categoryId }" @click="patchAppCats(ctx.app, { categoryId: 0 })">{{ t('lpCatNone') }}</button>
<button v-for="c in primaryCats" :key="c.id" type="button" :class="{ on: ctx.app.categoryId === c.id }" @click="patchAppCats(ctx.app, { categoryId: c.id })">{{ c.name }}</button>
</template>
<template v-else-if="ctx.sub === 'secondary'">
<button type="button" :class="{ on: !ctx.app.category }" @click="patchAppCats(ctx.app, { category: '' })">{{ t('lpCatNone') }}</button>
<button v-for="c in CAT_PRESETS" :key="c" type="button" :class="{ on: ctx.app.category === c }" @click="patchAppCats(ctx.app, { category: c })">{{ c }}</button>
</template>
<template v-else-if="ctx.sub === 'pack'">
<button v-for="(c, i) in ctxPackCmds(ctx.app)" :key="i" type="button" :title="c.cmd" @click="runPackCmd(ctx.app, c)">{{ c.name || c.cmd }}</button>
<button type="button" class="on" @click="openPackConfig(ctx.app)"><Package />{{ t('packCmdsConfig') }}</button>
</template>
</div>
<PackCmdsModal
:open="packModal.open"
scope="lp"
:target-id="packModal.id"
:title="packModal.title"
:dir="packModal.dir"
@close="packModal.open = false"
@saved="packTick++"
/>
<div v-if="editing" class="overlay" @click.self="editing = null">
<section class="modal lp-modal">
<header class="lp-modal-head">
@@ -201,17 +701,38 @@ onUnmounted(() => { clearInterval(timer); offChanged?.() })
<button type="button" class="nm-close" :title="t('close')" @click="editing = null"><X /></button>
</header>
<div class="lp-form">
<div class="lp-icon-edit">
<span class="lp-icon lg" :style="{ color: kindUI(editing.kind).color, background: `color-mix(in srgb, ${kindUI(editing.kind).color} 14%, transparent)` }">
<img v-if="editing.icon || kindIcons[editing.kind]" :src="editing.icon || kindIcons[editing.kind]" alt="" />
<component v-else :is="kindUI(editing.kind).icon" />
</span>
<div class="lp-icon-btns">
<button type="button" class="btn secondary" @click="pickLocalIcon"><ImageUp />{{ t('lpPickIcon') }}</button>
<button type="button" class="btn secondary" @click="peekFormIcon"><Image />{{ t('lpFetchIcon') }}</button>
<button v-if="editing.icon" type="button" class="btn secondary" @click="clearFormIcon"><X />{{ t('lpClearIcon') }}</button>
</div>
</div>
<label class="lp-field"><span>{{ t('lpName') }}</span><input v-model="editing.name" :placeholder="t('lpName')" /></label>
<label class="lp-field"><span>{{ t('lpPrimaryCat') }}</span>
<select v-model.number="editing.categoryId">
<option :value="0">{{ t('lpCatNone') }}</option>
<option v-for="c in primaryCats" :key="c.id" :value="c.id">{{ c.name }}</option>
</select>
</label>
<label class="lp-field"><span>{{ t('lpSecondaryCat') }}</span>
<input v-model.trim="editing.category" list="lp-cat-list" :placeholder="t('lpCategoryPh')" />
<datalist id="lp-cat-list"><option v-for="c in CAT_PRESETS" :key="c" :value="c" /></datalist>
</label>
<div class="lp-row">
<label class="lp-field"><span>{{ t('lpKind') }}</span>
<select v-model="editing.kind" @change="loadSuggest">
<select v-model="editing.kind" @change="onKindChange">
<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>
<span class="lp-dir-row"><input v-model="editing.dir" :placeholder="t('lpDir')" @change="detectDir(true)" /><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">
@@ -231,6 +752,63 @@ onUnmounted(() => { clearInterval(timer); offChanged?.() })
</footer>
</section>
</div>
<div v-if="catMgr" class="overlay" @click.self="catMgr = false">
<section class="modal lp-modal compact-modal">
<header class="lp-modal-head">
<h2><Tags />{{ t('lpManagePrimary') }}</h2>
<button type="button" class="nm-close" @click="catMgr = false"><X /></button>
</header>
<div class="lp-form">
<label class="lp-field"><span>{{ catForm.id ? t('edit') : t('lpAddPrimary') }}</span>
<span class="lp-dir-row">
<input v-model.trim="catForm.name" :placeholder="t('lpPrimaryPh')" @keyup.enter="savePrimaryCat" />
<button type="button" class="btn" @click="savePrimaryCat">{{ t('save') }}</button>
</span>
</label>
<div v-if="!primaryCats.length" class="lp-empty">{{ t('lpNoPrimary') }}</div>
<div v-for="c in primaryCats" :key="c.id" class="lp-proj-row lp-cat-row">
<b @click="editPrimaryCat(c)">{{ c.name }}</b>
<button type="button" class="lp-act danger" :title="t('delete')" @click="deletePrimaryCat(c)"><Trash2 /></button>
</div>
</div>
</section>
</div>
<div v-if="projectPick" class="overlay" @click.self="projectPick = false">
<section class="modal lp-modal">
<header class="lp-modal-head">
<h2><FolderGit2 />{{ t('lpAddFromProject') }}</h2>
<button type="button" class="nm-close" :title="t('close')" @click="projectPick = false"><X /></button>
</header>
<div class="lp-form">
<label class="lp-search"><Search /><input v-model.trim="projectQ" type="search" :placeholder="t('lpSearchProject')" /></label>
<div v-if="!filteredProjects.length" class="lp-empty">{{ t('lpNoProjects') }}</div>
<button v-for="p in filteredProjects" :key="p.id" type="button" class="lp-proj-row" @click="pickProject(p)">
<b>{{ p.name }}</b>
<small :title="p.path">{{ p.path }}</small>
</button>
</div>
</section>
</div>
<div v-if="logOpen" class="overlay" @click.self="logOpen = null">
<section class="modal lp-modal lp-log-modal" @click.stop>
<header class="lp-modal-head">
<h2><ScrollText />{{ t('lpLogs') }} · {{ logAppName }}</h2>
<button type="button" class="nm-close" :title="t('close')" @click="logOpen = null"><X /></button>
</header>
<div class="lp-log-toolbar">
<small>{{ t('lpLogsLocalHint') }}</small>
<button type="button" class="btn secondary" :disabled="logLoading" @click="openLogs({ id: logOpen })"><RefreshCw :class="{ spin: logLoading }" />{{ t('lpRefresh') }}</button>
<button type="button" class="btn secondary" @click="clearLogs"><Trash2 />{{ t('lpClearLogs') }}</button>
</div>
<div class="lp-log-body">
<div v-if="!logLines.length && !logLoading" class="lp-empty">{{ t('lpLogsEmpty') }}</div>
<pre v-for="(line, i) in logLines" :key="line.id || i" class="lp-log-line" :class="line.level">{{ line.line }}</pre>
</div>
</section>
</div>
</Teleport>
<AIScopeDrawer v-if="aiOpen" kind="launchpad" :title="t('launchpad')" @close="aiOpen = false" />
</div>

View File

@@ -0,0 +1,225 @@
<script setup>
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import {
Package, RefreshCw, Trash2, LoaderCircle, Check, CircleX, Terminal,
FolderOpen, Copy, Eraser
} from 'lucide-vue-next'
import { call, on, copyText } from '../api'
import { useAppStore } from '../store'
const { t } = useI18n()
const store = useAppStore()
const route = useRoute()
const router = useRouter()
const tasks = ref([])
const selectedId = ref('')
const detail = ref(null)
const loading = ref(false)
const logEl = ref(null)
const stickBottom = ref(true)
let offTask = null
let offLog = null
const selected = computed(() => tasks.value.find(x => x.id === selectedId.value) || null)
const logLines = computed(() => detail.value?.logs || [])
function statusLabel(s) {
if (s === 'running') return t('packTaskRunning')
if (s === 'done') return t('packTaskDone')
return t('packTaskFailed')
}
function fmtTime(s) {
if (!s) return ''
return String(s).replace('T', ' ').slice(5, 19)
}
function lineClass(line) {
const s = String(line || '')
if (s.startsWith('✗') || /error|failed|失败/i.test(s)) return 'err'
if (s.startsWith('✓') || /success|完成|done/i.test(s)) return 'ok'
if (s.startsWith('▶') || s.startsWith('$') || s.startsWith('cwd:') || s.startsWith('pid=')) return 'meta'
return ''
}
async function loadList() {
loading.value = true
try { tasks.value = await call('ListLocalPackTasks') || [] } catch { tasks.value = [] }
loading.value = false
if (!selectedId.value && tasks.value.length) {
selectedId.value = tasks.value[0].id
} else if (selectedId.value && !tasks.value.some(x => x.id === selectedId.value)) {
selectedId.value = tasks.value[0]?.id || ''
}
}
async function loadDetail(id) {
if (!id) { detail.value = null; return }
try {
detail.value = await call('GetLocalPackTask', id)
await scrollLog(true)
} catch {
detail.value = null
}
}
function selectTask(id) {
selectedId.value = id
router.replace({ path: '/pack-tasks', query: id ? { id } : {} })
}
async function clearDone() {
try {
tasks.value = await call('ClearFinishedLocalPackTasks') || []
if (selectedId.value && !tasks.value.some(x => x.id === selectedId.value)) {
selectedId.value = tasks.value[0]?.id || ''
router.replace({ path: '/pack-tasks', query: selectedId.value ? { id: selectedId.value } : {} })
}
} catch { /* ignore */ }
}
async function dismiss(id) {
try {
tasks.value = await call('DismissLocalPackTask', id) || []
if (selectedId.value === id) {
selectedId.value = tasks.value[0]?.id || ''
router.replace({ path: '/pack-tasks', query: selectedId.value ? { id: selectedId.value } : {} })
}
} catch { /* ignore */ }
}
async function copyLogs() {
const text = (detail.value?.logs || []).join('\n')
try {
await copyText(text)
store.showToast({ type: 'success', text: t('packLogCopied') })
} catch {
store.showToast({ type: 'error', text: t('copyFailed') })
}
}
function onLogScroll() {
const el = logEl.value
if (!el) return
stickBottom.value = el.scrollHeight - el.scrollTop - el.clientHeight < 48
}
async function scrollLog(force) {
await nextTick()
const el = logEl.value
if (!el) return
if (force || stickBottom.value) el.scrollTop = el.scrollHeight
}
watch(selectedId, id => { loadDetail(id) })
watch(() => route.query.id, id => {
const v = String(id || '')
if (v && v !== selectedId.value) selectedId.value = v
})
onMounted(async () => {
if (route.query.id) selectedId.value = String(route.query.id)
await loadList()
if (selectedId.value) await loadDetail(selectedId.value)
offTask = on('pack:task', async ev => {
await loadList()
const d = Array.isArray(ev) ? ev[0] : ev
if (d?.id && d.id === selectedId.value && detail.value?.id === d.id) {
detail.value = {
...detail.value,
status: d.status || detail.value.status,
pid: d.pid || detail.value.pid,
error: d.error ?? detail.value.error,
endedAt: d.endedAt || detail.value.endedAt
}
}
})
offLog = on('pack:log', ev => {
const d = Array.isArray(ev) ? ev[0] : ev
if (!d?.taskId || d.taskId !== selectedId.value) return
if (!detail.value || detail.value.id !== d.taskId) return
detail.value = { ...detail.value, logs: [...(detail.value.logs || []), d.line] }
scrollLog(false)
})
})
onUnmounted(() => {
offTask?.()
offLog?.()
})
</script>
<template>
<div class="page pack-tasks-page">
<header class="page-head sticky-head lp-head">
<div class="lp-head-top">
<div>
<h1>{{ t('packTasksPage') }}</h1>
<p>{{ t('packTasksPageSub') }}</p>
</div>
<div class="lp-tools">
<button class="btn secondary" :disabled="loading" @click="loadList"><RefreshCw :class="{ spinning: loading }" />{{ t('lpRefresh') }}</button>
<button class="btn secondary" :disabled="!tasks.some(x => x.status !== 'running')" @click="clearDone"><Trash2 />{{ t('packTasksClear') }}</button>
</div>
</div>
</header>
<div class="pack-tasks-layout">
<aside class="pack-tasks-side">
<div v-if="!tasks.length" class="lp-empty">{{ t('packTasksEmpty') }}</div>
<button
v-for="x in tasks"
:key="x.id"
type="button"
class="pack-task-row"
:class="[x.status, { on: x.id === selectedId }]"
@click="selectTask(x.id)"
>
<span class="pack-task-st">
<LoaderCircle v-if="x.status === 'running'" class="spin" />
<Check v-else-if="x.status === 'done'" />
<CircleX v-else />
</span>
<div class="pack-task-main">
<b>{{ x.title || x.cmd }}</b>
<small>{{ statusLabel(x.status) }} · {{ fmtTime(x.startedAt) }}</small>
<code v-if="x.cmd !== x.title">{{ x.cmd }}</code>
</div>
<button v-if="x.status !== 'running'" type="button" class="pack-task-rm" :title="t('delete')" @click.stop="dismiss(x.id)"><Trash2 /></button>
</button>
</aside>
<section class="pack-tasks-console card">
<template v-if="selected || detail">
<header class="pack-console-head">
<div>
<h2><Terminal />{{ detail?.title || selected?.title || t('packTasks') }}</h2>
<p>
<em :class="detail?.status || selected?.status">{{ statusLabel(detail?.status || selected?.status) }}</em>
<span v-if="detail?.pid || selected?.pid">PID {{ detail?.pid || selected?.pid }}</span>
<span v-if="detail?.startedAt">{{ fmtTime(detail.startedAt) }}</span>
</p>
</div>
<div class="pack-console-acts">
<button type="button" class="btn secondary" :disabled="!logLines.length" @click="copyLogs"><Copy />{{ t('packLogCopy') }}</button>
</div>
</header>
<div v-if="detail?.dir || detail?.cmd" class="pack-console-meta">
<span v-if="detail.dir" :title="detail.dir"><FolderOpen />{{ detail.dir }}</span>
<code v-if="detail.cmd">$ {{ detail.cmd }}</code>
</div>
<div ref="logEl" class="pack-console-body" @scroll="onLogScroll">
<pre v-for="(line, i) in logLines" :key="i" class="pack-console-line" :class="lineClass(line)">{{ line }}</pre>
<div v-if="!logLines.length" class="pack-console-empty">{{ t('packLogEmpty') }}</div>
</div>
<p v-if="detail?.error" class="pack-console-err">{{ detail.error }}</p>
</template>
<div v-else class="pack-console-placeholder">
<Eraser />
<p>{{ t('packTasksPick') }}</p>
</div>
</section>
</div>
</div>
</template>

View File

@@ -0,0 +1,278 @@
<script setup>
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { Browser } from '@wailsio/runtime'
import {
Activity, RefreshCw, Pin, Square, Search, ExternalLink, Cpu, MemoryStick,
HardDrive, Network, Gauge, ChevronDown, ChevronUp, Hexagon, Zap, FileCode2,
Coffee, Database, Globe, Boxes, Box
} from 'lucide-vue-next'
import ChartView from '../components/ChartView.vue'
import { call, on } from '../api'
const { t } = useI18n()
const router = useRouter()
const entries = ref([])
const loading = ref(false)
const showSys = ref(localStorage.getItem('cc-pm-sys') === '1')
const nameQ = ref('')
const portQ = ref('')
const busy = ref({})
const metrics = ref(null)
const history = ref([]) // { t, cpu, mem, gpu, recv, sent }
const showCharts = ref(localStorage.getItem('cc-pm-charts') !== '0')
const kindIcons = ref({})
const HISTORY_MAX = 60
let timer = 0
let metricsTimer = 0
let offChanged = null
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 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' },
exe: { icon: Box, color: '#60a5fa' },
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
function displayIcon(x) {
if (x?.icon) return x.icon
return kindIcons.value[x?.kind] || ''
}
function matchEntry(x) {
const nq = nameQ.value.trim().toLowerCase()
if (nq) {
const hay = [x.name, x.exe, x.cmdline, x.dir].filter(Boolean).join('\n').toLowerCase()
if (!hay.includes(nq)) return false
}
const pq = portQ.value.trim()
if (pq) {
const ports = x.ports?.length ? x.ports : (x.port ? [x.port] : [])
if (!ports.some(p => String(p).includes(pq))) return false
}
return true
}
const scanned = computed(() => entries.value.filter(x => !x.id && (showSys.value || !isSys(x)) && matchEntry(x)))
const hiddenCount = computed(() => entries.value.filter(x => !x.id && isSys(x)).length)
const fmtCPU = v => (v >= 10 ? Number(v).toFixed(0) : Number(v).toFixed(1)) + '%'
const fmtMem = v => v >= 1024 ? (v / 1024).toFixed(1) + ' GB' : Number(v).toFixed(0) + ' MB'
const fmtIO = v => v >= 1024 ? (v / 1024).toFixed(1) + ' MB/s' : Number(v).toFixed(0) + ' KB/s'
const fmtNet = v => {
const n = Number(v) || 0
if (n >= 1024) return (n / 1024).toFixed(2) + ' MB/s'
return n.toFixed(1) + ' KB/s'
}
async function load() {
loading.value = true
try { entries.value = await call('ListLaunchEntries') } catch { /* ignore */ }
loading.value = false
}
async function loadMetrics() {
try {
const m = await call('GetHostMetrics')
metrics.value = m
const point = {
t: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }),
cpu: Number(m.cpu) || 0,
mem: Number(m.memPercent) || 0,
gpu: m.gpu < 0 ? null : Number(m.gpu),
recv: Number(m.netRecvKBs) || 0,
sent: Number(m.netSentKBs) || 0
}
history.value = [...history.value, point].slice(-HISTORY_MAX)
} catch { /* ignore */ }
}
function toggleSys() {
showSys.value = !showSys.value
localStorage.setItem('cc-pm-sys', showSys.value ? '1' : '0')
}
function toggleCharts() {
showCharts.value = !showCharts.value
localStorage.setItem('cc-pm-charts', showCharts.value ? '1' : '0')
}
function openPort(p) {
const n = Number(p)
if (!n || n <= 0) return
try { Browser.OpenURL(`http://127.0.0.1:${n}`) } catch { /* ignore */ }
}
async function pin(x) {
router.push({ path: '/launchpad', query: { pin: '1', name: x.name || '', kind: x.kind || 'other', port: String(x.port || x.ports?.[0] || 0), dir: x.dir || '', pid: String(x.pid || 0) } })
}
async function stop(x) {
if (!confirm(t('lpStopConfirm', { name: x.name }))) return
const key = x.pid
busy.value[key] = 'stop'
try { await call('StopLaunchApp', 0, x.pid || 0) } catch { /* ignore */ }
busy.value[key] = false
setTimeout(load, 500)
}
function lineOpt(series) {
return {
backgroundColor: 'transparent',
grid: { left: 36, right: 12, top: 24, bottom: 28 },
tooltip: { trigger: 'axis' },
legend: { show: series.length > 1, top: 0, textStyle: { color: 'var(--muted)', fontSize: 11 } },
xAxis: {
type: 'category',
data: history.value.map(h => h.t),
axisLabel: { color: 'var(--muted)', fontSize: 10 },
axisLine: { lineStyle: { color: 'var(--border)' } }
},
yAxis: {
type: 'value',
min: 0,
max: series.some(s => s.unit === '%') ? 100 : undefined,
axisLabel: { color: 'var(--muted)', fontSize: 10 },
splitLine: { lineStyle: { color: 'var(--border)', opacity: 0.45 } }
},
series: series.map(s => ({
name: s.name,
type: 'line',
smooth: true,
showSymbol: false,
areaStyle: { opacity: 0.12 },
lineStyle: { width: 2, color: s.color },
itemStyle: { color: s.color },
data: history.value.map(h => h[s.key] == null ? null : h[s.key])
}))
}
}
const cpuChart = computed(() => lineOpt([{ name: 'CPU', key: 'cpu', color: '#5da8ff', unit: '%' }]))
const memChart = computed(() => lineOpt([{ name: t('lpMem'), key: 'mem', color: '#53d6a2', unit: '%' }]))
const gpuChart = computed(() => lineOpt([{ name: 'GPU', key: 'gpu', color: '#f7cb4d', unit: '%' }]))
const netChart = computed(() => lineOpt([
{ name: t('pmNetDown'), key: 'recv', color: '#4fd1a1' },
{ name: t('pmNetUp'), key: 'sent', color: '#a78bfa' }
]))
const gpuAvailable = computed(() => metrics.value && metrics.value.gpu >= 0)
onMounted(async () => {
try { kindIcons.value = await call('ListKindIcons') || {} } catch { kindIcons.value = {} }
load()
loadMetrics()
timer = setInterval(load, 5000)
metricsTimer = setInterval(loadMetrics, 2000)
offChanged = on('launchpad:changed', load)
})
onUnmounted(() => {
clearInterval(timer)
clearInterval(metricsTimer)
offChanged?.()
})
</script>
<template>
<div class="page port-monitor-page">
<header class="page-head sticky-head lp-head">
<div class="lp-head-top">
<div>
<h1>{{ t('portMonitor') }}</h1>
<p>{{ t('portMonitorSubtitle') }}</p>
</div>
<div class="lp-tools">
<button class="btn secondary" @click="toggleCharts">
<component :is="showCharts ? ChevronUp : ChevronDown" />
{{ showCharts ? t('pmHideCharts') : t('pmShowCharts') }}
</button>
<button class="btn secondary" :disabled="loading" @click="load"><RefreshCw :class="{ spinning: loading }" />{{ t('lpRefresh') }}</button>
</div>
</div>
</header>
<section class="pm-overview card">
<h2 class="lp-title"><Activity />{{ t('pmOverview') }}</h2>
<div class="pm-stats">
<div class="pm-stat">
<span class="pm-stat-label"><Cpu />CPU</span>
<b>{{ metrics ? fmtCPU(metrics.cpu) : '—' }}</b>
</div>
<div class="pm-stat">
<span class="pm-stat-label"><MemoryStick />{{ t('lpMem') }}</span>
<b>{{ metrics ? fmtCPU(metrics.memPercent) : '—' }}</b>
<small v-if="metrics">{{ fmtMem(metrics.memUsedMB) }} / {{ fmtMem(metrics.memTotalMB) }}</small>
</div>
<div class="pm-stat">
<span class="pm-stat-label"><Gauge />GPU</span>
<template v-if="gpuAvailable">
<b>{{ fmtCPU(metrics.gpu) }}</b>
<small>{{ metrics.gpuName }} · {{ fmtMem(metrics.gpuMemUsedMB) }} / {{ fmtMem(metrics.gpuMemTotalMB) }}</small>
</template>
<b v-else class="muted">{{ t('pmGpuNA') }}</b>
</div>
<div class="pm-stat">
<span class="pm-stat-label"><Network />{{ t('pmBandwidth') }}</span>
<b v-if="metrics"> {{ fmtNet(metrics.netRecvKBs) }} · {{ fmtNet(metrics.netSentKBs) }}</b>
<b v-else></b>
</div>
</div>
<div v-if="showCharts" class="pm-charts">
<div class="pm-chart-card"><header>CPU</header><ChartView :option="cpuChart" class="pm-chart" /></div>
<div class="pm-chart-card"><header>{{ t('lpMem') }}</header><ChartView :option="memChart" class="pm-chart" /></div>
<div class="pm-chart-card"><header>GPU</header><ChartView :option="gpuChart" class="pm-chart" /></div>
<div class="pm-chart-card"><header>{{ t('pmBandwidth') }}</header><ChartView :option="netChart" class="pm-chart" /></div>
</div>
</section>
<section class="lp-section">
<div class="pm-ports-head">
<h2 class="lp-title"><HardDrive />{{ t('lpScanned') }}<em>{{ scanned.length }}</em></h2>
<div class="lp-filters">
<label class="lp-sys-toggle"><input type="checkbox" :checked="showSys" @change="toggleSys" />{{ t('lpShowSys') }}<em v-if="hiddenCount && !showSys">{{ hiddenCount }}</em></label>
<label class="lp-search"><Search /><input v-model.trim="nameQ" type="search" :placeholder="t('lpSearchName')" /></label>
<label class="lp-search lp-search-port"><Search /><input v-model.trim="portQ" type="search" inputmode="numeric" :placeholder="t('lpSearchPort')" /></label>
</div>
</div>
<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)` }">
<img v-if="displayIcon(x)" :src="displayIcon(x)" alt="" />
<component v-else :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">
<button v-for="p in x.ports.slice(0, 4)" :key="p" type="button" class="lp-port lp-port-link" :title="t('lpOpenPort', { p })" @click="openPort(p)">:{{ p }}<ExternalLink /></button>
<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.dir || x.cmdline || x.exe" class="lp-meta" :title="x.dir || x.cmdline || x.exe">{{ x.dir || x.cmdline || x.exe }}</p>
<footer>
<button class="lp-act" @click="pin(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>
</div>
</template>

View File

@@ -6,6 +6,8 @@ import { UserRound, ImageUp, KeyRound, CloudUpload, LogIn, LogOut, RefreshCw, Wi
import { call, isNative } from '../api'
import { useAppStore } from '../store'
import { teams, teamsErr, teamsLoading, loadTeams as loadTeamsShared, switchTeam as switchTeamShared } from '../team'
import RemoteImg from '../components/RemoteImg.vue'
import ImagePreview from '../components/ImagePreview.vue'
const store = useAppStore(), { t } = useI18n(), native = isNative()
const router = useRouter()
@@ -14,19 +16,24 @@ const pwdForm = reactive({ old: '', next: '', confirm: '' })
const busy = ref(''), msg = ref('')
const loaded = ref(false)
const avatarOpen = ref(false)
const AVATAR_HIST_KEY = 'cc-avatar-history'
const avatarHistory = ref([]) // [{mode,value,preview?}]
const serverAvatars = ref([])
const previewOpen = ref(false)
const previewSrc = ref('')
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 fsCfg = reactive({ mode: 'local', baseUrl: '' })
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 || '' })
Object.assign(fsCfg, { mode: c.mode || 'local', baseUrl: c.baseUrl || '' })
} catch {}
}
@@ -76,6 +83,10 @@ async function copyAsset(f) {
store.showToast({ type: 'success', key: 'assetsCopiedToast' })
} catch { store.showToast({ type: 'error', key: 'assetsCopyFailed' }) }
}
function openAssetPreview(f) {
previewSrc.value = f.url
previewOpen.value = true
}
function fmtSize(n) {
if (n >= 1 << 20) return (n / (1 << 20)).toFixed(1) + ' MB'
if (n >= 1024) return Math.round(n / 1024) + ' KB'
@@ -203,19 +214,115 @@ async function persist() {
await store.saveSettings({ avatarMode: form.avatarMode, avatarValue: form.avatarValue, imageMode: form.imageMode })
}
watch(() => [form.avatarMode, form.avatarValue, form.imageMode], persist)
function loadLocalAvatarHistory() {
try {
const raw = JSON.parse(localStorage.getItem(AVATAR_HIST_KEY) || '[]')
return Array.isArray(raw) ? raw.filter(x => x && x.value).slice(0, 12) : []
} catch { return [] }
}
function saveLocalAvatarHistory(list) {
avatarHistory.value = list.slice(0, 12)
localStorage.setItem(AVATAR_HIST_KEY, JSON.stringify(avatarHistory.value.map(({ mode, value }) => ({ mode, value }))))
}
async function loadAvatarHistory() {
if (sync.value.loggedIn) {
try {
const items = await call('ListAvatarHistory')
avatarHistory.value = (items || []).map(x => ({ id: x.id, mode: x.mode, value: x.value, createdAt: x.createdAt }))
// 首次:把本地残留历史迁移到线上
const local = loadLocalAvatarHistory()
if (local.length && !avatarHistory.value.length) {
for (const it of [...local].reverse()) {
try { await call('PushAvatarHistory', it.mode || 'base64', it.value) } catch { /* ignore */ }
}
localStorage.removeItem(AVATAR_HIST_KEY)
const again = await call('ListAvatarHistory')
avatarHistory.value = (again || []).map(x => ({ id: x.id, mode: x.mode, value: x.value, createdAt: x.createdAt }))
}
return
} catch { /* 回退本地 */ }
}
avatarHistory.value = loadLocalAvatarHistory()
}
async function pushAvatarHistory(mode, value) {
if (!value) return
if (sync.value.loggedIn) {
try {
const items = await call('PushAvatarHistory', mode || 'base64', value)
avatarHistory.value = (items || []).map(x => ({ id: x.id, mode: x.mode, value: x.value, createdAt: x.createdAt }))
await refreshHistPreviews()
return
} catch { /* 回退本地 */ }
}
const next = [{ mode: mode || 'base64', value }, ...avatarHistory.value.filter(x => x.value !== value)]
saveLocalAvatarHistory(next)
await refreshHistPreviews()
}
async function resolveHistPreview(item) {
if (item._src) return item._src
if (item.mode === 'url' || /^https?:\/\//i.test(item.value)) {
try {
const { resolveImageSrc } = await import('../api')
item._src = await resolveImageSrc(item.value)
} catch { item._src = item.value }
} else if (item.mode === 'path') {
try { item._src = await call('ReadImageAsDataURL', item.value) } catch { item._src = '' }
} else {
item._src = item.value
}
return item._src
}
async function loadServerAvatars() {
serverAvatars.value = []
if (!serverStorage.value || !sync.value.loggedIn) return
try {
const r = await call('ListServerFiles', 'mine', 0, 1)
serverAvatars.value = (r.items || []).filter(f => f.kind === 'avatar')
} catch {}
}
const histPreviews = ref({})
async function refreshHistPreviews() {
const map = {}
for (const it of avatarHistory.value) {
map[it.value] = await resolveHistPreview(it)
}
histPreviews.value = map
}
// 存储走向由后端按管理员全局配置实时决定autoserver 时上传返回 url否则 base64。
async function pickAvatar() {
try {
const r = await call('PickAvatarImage', 'auto')
if (r && r.value) {
if (form.avatarValue) await pushAvatarHistory(form.avatarMode, form.avatarValue)
form.avatarMode = r.mode || 'base64'
form.avatarValue = r.value
await pushAvatarHistory(form.avatarMode, form.avatarValue)
}
} catch (e) { store.showToast({ type: 'error', text: errText(e) }) }
}
function clearAvatar() { form.avatarMode = ''; form.avatarValue = '' }
async function clearAvatar() {
if (form.avatarValue) await pushAvatarHistory(form.avatarMode, form.avatarValue)
form.avatarMode = ''
form.avatarValue = ''
}
function selectHistory(item) {
form.avatarMode = item.mode || 'base64'
form.avatarValue = item.value
}
async function selectServerAvatar(f) {
form.avatarMode = 'url'
form.avatarValue = f.url
await pushAvatarHistory('url', f.url)
}
// 打开弹窗时刷新全局配置,保证提示文案与实际走向一致
watch(avatarOpen, v => { if (v) loadFileStorage() })
watch(avatarOpen, async v => {
if (!v) return
await loadFileStorage()
await loadAvatarHistory()
await refreshHistPreviews()
await loadServerAvatars()
})
async function syncNow() {
if (busy.value) return
busy.value = 'sync'; msg.value = ''
@@ -386,7 +493,7 @@ onUnmounted(() => removeEventListener('keydown', onKey))
<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>
<div><b>{{ t('assetsTab') }}</b><small>{{ sync.userId === 1 ? t('assetsHintAdmin') : t('assetsHint') }}</small></div>
</header>
<div v-if="!sync.loggedIn" class="pc-empty">
<span class="pc-empty-ico"><Images /></span>
@@ -413,7 +520,7 @@ onUnmounted(() => removeEventListener('keydown', onKey))
<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>
<button type="button" class="asset-thumb" @click="openAssetPreview(f)"><RemoteImg :src="f.url" /></button>
<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>
@@ -506,10 +613,27 @@ onUnmounted(() => removeEventListener('keydown', onKey))
</div>
</div>
</div>
<!-- 存储方式由管理员全局配置强制决定用户不再自行选择 -->
<p class="auto-update-hint">{{ t('storageFollowHint', { mode: serverStorage ? t('fsModeServer') : t('fsModeLocal') }) }}</p>
<div v-if="avatarHistory.length" class="avatar-hist">
<b>{{ t('avatarHistory') }}</b>
<div class="avatar-hist-grid">
<button v-for="it in avatarHistory" :key="it.value" type="button" class="avatar-hist-item" :class="{ on: form.avatarValue === it.value }" :title="t('avatarReselect')" @click="selectHistory(it)">
<img v-if="histPreviews[it.value]" :src="histPreviews[it.value]" alt="" />
<UserRound v-else />
</button>
</div>
</div>
<div v-if="serverAvatars.length" class="avatar-hist">
<b>{{ t('avatarServerHistory') }}</b>
<div class="avatar-hist-grid">
<button v-for="f in serverAvatars" :key="f.id" type="button" class="avatar-hist-item" :class="{ on: form.avatarValue === f.url }" :title="t('avatarReselect')" @click="selectServerAvatar(f)">
<RemoteImg :src="f.url" />
</button>
</div>
</div>
</div>
</section>
</div>
</Teleport>
<ImagePreview :open="previewOpen" :src="previewSrc" @close="previewOpen = false" />
</div></template>

View File

@@ -2,7 +2,7 @@
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { ArrowLeft, Code2, GitCommitHorizontal, FolderTree, RefreshCw, Files, MessageSquareText, Rows3, Users, Plus, Minus, HardDrive, Folder, FileWarning, GitBranch, ExternalLink, TriangleAlert, ClipboardCheck, Flame, Sparkles, MessageCircleQuestion } from 'lucide-vue-next'
import { ArrowLeft, Code2, GitCommitHorizontal, FolderTree, RefreshCw, Files, MessageSquareText, Rows3, Users, Plus, Minus, HardDrive, Folder, FileWarning, GitBranch, ExternalLink, TriangleAlert, ClipboardCheck, Flame, Sparkles, MessageCircleQuestion, Rocket } from 'lucide-vue-next'
import StatCard from '../components/StatCard.vue'
import ChartView from '../components/ChartView.vue'
import GitHeatmap from '../components/GitHeatmap.vue'
@@ -131,6 +131,7 @@ onMounted(load)
<div class="detail-head-row">
<button class="back" @click="router.push('/projects')"><ArrowLeft />{{ t('back') }}</button>
<div><h1>{{ p.name }}</h1><p>{{ p.path }}</p></div>
<button class="btn secondary" @click="router.push({ path: '/launchpad', query: { projectId: p.id } })"><Rocket />{{ t('lpToLaunchpad') }}</button>
<button class="btn secondary ai-entry" @click="openChat()"><Sparkles />{{ t('aiAskGo') }}</button>
</div>
<div class="tabs detail-tabs">

View File

@@ -2,7 +2,7 @@
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { Database, Plus, Trash2, Upload, BarChart3, Folder, Languages, CheckCircle2, Info, Copy, Timer, Rocket, Sparkles, CloudUpload, Wifi } from 'lucide-vue-next'
import { Database, Plus, Trash2, Upload, BarChart3, Folder, Languages, CheckCircle2, Info, Copy, Timer, Rocket, Sparkles, CloudUpload, Wifi, Image, ImageUp, X } from 'lucide-vue-next'
import { call, isNative } from '../api'
import { useAppStore } from '../store'
import AIScopeDrawer from '../components/AIScopeDrawer.vue'
@@ -10,8 +10,8 @@ import AIScopeDrawer from '../components/AIScopeDrawer.vue'
const route=useRoute()
// tab 记忆:优先 URL 深链,其次上次停留的 tabsync 已迁往个人主页,做合法性回退)
// 分区切换入口在侧边栏“设置”一级导航的二级菜单,页内不再有 tabbar。
const TABS=['rules','appearance','ai','filestorage','database']
const TAB_TITLE={rules:'tabRules',appearance:'tabAppearance',ai:'aiAnalysis',filestorage:'tabFileStorage',database:'tabDatabase'}
const TABS=['rules','appearance','ai','filestorage','kindicons','database']
const TAB_TITLE={rules:'tabRules',appearance:'tabAppearance',ai:'aiAnalysis',filestorage:'tabFileStorage',kindicons:'tabKindIcons',database:'tabDatabase'}
const pickTab=v=>TABS.includes(String(v))?String(v):''
const tab=ref(pickTab(route.query.tab)||pickTab(localStorage.getItem('cc-settings-tab'))||'rules'),rules=ref([]),form=reactive({pattern:'',category:'custom'})
const aiOpen=ref(false)
@@ -47,12 +47,12 @@ async function toggleAutostart(){
}
// ---- 全局文件存储(仅管理员 id=1权威配置存远端 MySQL保存即全员生效 ----
const isAdmin=computed(()=>store.syncStatus.userId===1)
const fsCfg=reactive({mode:'local',baseUrl:'',apiKey:''})
const fsCfg=reactive({mode:'local',baseUrl:''})
const fsBusy=ref(''),fsLoaded=ref(false)
async function loadFileStorage(){
try{
const c=await call('GetFileStorageConfig')
Object.assign(fsCfg,{mode:c.mode||'local',baseUrl:c.baseUrl||'',apiKey:c.apiKey||''})
Object.assign(fsCfg,{mode:c.mode||'local',baseUrl:c.baseUrl||''})
}catch{}
fsLoaded.value=true
}
@@ -60,9 +60,28 @@ async function saveFileStorage(){
if(fsBusy.value)return
fsBusy.value='save'
try{
const has=await call('AdminHasStepUp')
if(!has){
const code=window.prompt(t('adminStepupHint'),'')
if(!code){fsBusy.value='';return}
await call('AdminStepUp',String(code).trim())
}
await call('SaveFileStorageConfig',{...fsCfg})
store.showToast({type:'success',key:'fsSavedToast'})
}catch(e){store.showToast({type:'error',text:errText(e)})}
}catch(e){
const code=String(e).split(':')[0].trim()
if(code==='ADMIN_STEPUP_REQUIRED'||code==='ADMIN_IP_CHANGED'){
const tip=code==='ADMIN_IP_CHANGED'?t('adminIpChangedRisk'):t('adminStepupHint')
const c=window.prompt(tip,'')
if(c){
try{
await call('AdminStepUp',String(c).trim())
await call('SaveFileStorageConfig',{...fsCfg})
store.showToast({type:'success',key:'fsSavedToast'})
}catch(e2){store.showToast({type:'error',text:errText(e2)})}
}
}else store.showToast({type:'error',text:errText(e)})
}
finally{fsBusy.value=''}
}
async function testFileStorage(){
@@ -75,7 +94,42 @@ async function testFileStorage(){
finally{fsBusy.value=''}
}
const errText=e=>{const code=String(e).split(':')[0].trim();return t('errors.'+code)!=='errors.'+code?t('errors.'+code):String(e)}
watch(tab,v=>{if(v==='filestorage')loadFileStorage()})
async function checkSoftwareUpdate(){
try{
const r=await call('CheckAppUpdate',true)
if(r?.upToDate) store.showToast({type:'success',text:t('aboutUpToDate',{version:r.current||r.latest||'—'})})
else store.showToast({type:'info',text:t('aboutUpdateAvailable',{latest:r.latest,current:r.current})})
}catch(e){store.showToast({type:'error',text:errText(e)})}
}
watch(tab,v=>{if(v==='filestorage')loadFileStorage();if(v==='kindicons')loadKindIcons()})
const knownKinds=ref([])
const kindIcons=ref({})
const kindBusy=ref('')
async function loadKindIcons(){
try{
knownKinds.value=await call('ListKnownLaunchKinds')||[]
kindIcons.value=await call('ListKindIcons')||{}
}catch{knownKinds.value=[];kindIcons.value={}}
}
async function pickKindIcon(kind){
if(kindBusy.value)return
kindBusy.value=kind
try{
const u=await call('PickKindIcon',kind)
if(u)kindIcons.value={...kindIcons.value,[kind]:u}
}catch(e){store.showToast({type:'error',text:errText(e)})}
finally{kindBusy.value=''}
}
async function clearKindIcon(kind){
if(kindBusy.value)return
kindBusy.value=kind
try{
await call('ClearKindIcon',kind)
const next={...kindIcons.value};delete next[kind];kindIcons.value=next
}catch(e){store.showToast({type:'error',text:errText(e)})}
finally{kindBusy.value=''}
}
async function add(){if(!form.pattern)return;await call('AddRule',form.pattern,form.category);form.pattern='';await load()}
async function remove(r){if(!r.builtin){await call('DeleteRule',r.id);await load()}}
@@ -99,7 +153,7 @@ watch(()=>[settings.theme,settings.locale,settings.glassOpacity,settings.loading
let aiKeyTimer=null
watch(()=>[settings.sparkKey,settings.deepSeekKey],()=>{clearTimeout(aiKeyTimer);aiKeyTimer=setTimeout(save,600)})
watch(()=>route.query.tab,v=>{const n=pickTab(v);if(n)tab.value=n})
onMounted(async()=>{await load();apply();if(tab.value==='filestorage')loadFileStorage()})
onMounted(async()=>{await load();apply();if(tab.value==='filestorage')loadFileStorage();if(tab.value==='kindicons')loadKindIcons()})
onUnmounted(()=>{clearTimeout(aiKeyTimer)})
</script>
@@ -121,6 +175,7 @@ onUnmounted(()=>{clearTimeout(aiKeyTimer)})
<label v-if="settings.autoUpdateMode!=='everyNHours'">{{t('triggerTime')}}<input v-model="settings.autoUpdateTime" type="time" class="interval-input"/></label>
<p class="auto-update-hint">{{t('autoUpdateHint')}}</p>
</template>
<button type="button" class="btn secondary" style="margin-top:.5rem" @click="checkSoftwareUpdate">{{t('checkAppUpdate')}}</button>
</section></template>
<template v-else-if="tab==='ai'">
<section class="panel form-panel"><h2><Sparkles/>{{t('aiProviderTitle')}}</h2>
@@ -138,8 +193,7 @@ onUnmounted(()=>{clearTimeout(aiKeyTimer)})
<template v-if="isAdmin">
<label>{{t('fsMode')}}<select v-model="fsCfg.mode"><option value="local">{{t('fsModeLocal')}}</option><option value="server">{{t('fsModeServer')}}</option></select></label>
<template v-if="fsCfg.mode==='server'">
<label>{{t('fsBaseUrl')}}<input v-model.trim="fsCfg.baseUrl" placeholder="http://192.168.1.10:8788"/></label>
<label>{{t('fsApiKey')}}<input v-model.trim="fsCfg.apiKey" type="password" :placeholder="t('fsApiKeyPh')"/></label>
<label>{{t('fsBaseUrl')}}<input v-model.trim="fsCfg.baseUrl" placeholder="https://o-api.nailaoyun.cn/pms-api"/></label>
</template>
<p class="auto-update-hint">{{t('fsPageHint')}}</p>
<div class="fs-page-actions">
@@ -150,6 +204,28 @@ onUnmounted(()=>{clearTimeout(aiKeyTimer)})
<div v-else class="preview-notice"><Info/><div><b>{{t('fsAdminOnlyTitle')}}</b><small>{{t('fsAdminOnlyDesc')}}</small></div></div>
</section>
</template>
<template v-else-if="tab==='kindicons'">
<section class="panel form-panel">
<h2><Image/>{{t('kindIconsTitle')}}</h2>
<p class="auto-update-hint">{{t('kindIconsHint')}}</p>
<template v-if="isAdmin">
<div class="kind-icons-grid">
<div v-for="k in knownKinds" :key="k" class="kind-icon-card">
<span class="kind-icon-preview">
<img v-if="kindIcons[k]" :src="kindIcons[k]" alt="" />
<Image v-else />
</span>
<b>{{k}}</b>
<div class="kind-icon-ops">
<button type="button" class="btn secondary" :disabled="!!kindBusy" @click="pickKindIcon(k)"><ImageUp/>{{t('avatarPick')}}</button>
<button v-if="kindIcons[k]" type="button" class="btn secondary" :disabled="!!kindBusy" @click="clearKindIcon(k)"><X/>{{t('lpClearIcon')}}</button>
</div>
</div>
</div>
</template>
<div v-else class="preview-notice"><Info/><div><b>{{t('fsAdminOnlyTitle')}}</b><small>{{t('kindIconsAdminOnly')}}</small></div></div>
</section>
</template>
<template v-else><section class="panel database-panel"><div class="db-title"><h2><Database/>{{t('dbLocation')}}</h2><span class="db-connected"><CheckCircle2/>{{t('dbConnected')}}</span></div><div v-if="!native" class="preview-notice"><Info/><div><b>{{t('previewModeTitle')}}</b><small>{{t('previewModeDb')}}</small></div></div><div class="db-path"><span>{{t('dbCurrentLoc')}}</span><code :title="settings.databasePath">{{settings.databasePath||t('dbNoPath')}}</code><button :title="t('copyPathTitle')" :disabled="!settings.databasePath" @click="copyPath"><Copy/></button></div><p class="db-relocate-hint">{{t('dbRelocateHint')}}</p><p v-if="dbMessage" class="db-message">{{dbMessage}}</p><button class="btn secondary migrate" :disabled="!native" @click="migrate"><Upload/>{{t('migrateBtn')}}</button></section>
<section class="panel danger-zone"><h2><Trash2/>{{t('dangerZone')}}</h2><p>{{t('dangerDesc')}}</p><div><button @click="clear('stats')"><BarChart3/><span><b>{{t('clearStats')}}</b><small>{{t('clearStatsDesc')}}</small></span></button><button @click="clear('project')"><Folder/><span><b>{{t('clearProject')}}</b><small>{{t('clearProjectDesc')}}</small></span></button><button class="danger" @click="clear('all')"><Trash2/><span><b>{{t('clearAllData')}}</b><small>{{t('clearAllDesc')}}</small></span></button></div></section></template>
<AIScopeDrawer v-if="aiOpen" kind="config" :title="t('settings')" @close="aiOpen=false"/>

View File

@@ -6,6 +6,7 @@ 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'
import RemoteImg from '../components/RemoteImg.vue'
const store = useAppStore(), { t } = useI18n(), native = isNative()
const members = ref([])
@@ -186,7 +187,7 @@ onMounted(refreshAll)
<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="" />
<RemoteImg v-if="m.avatar" :src="m.avatar" alt="" />
<b v-else>{{ (m.nickname || m.username)[0].toUpperCase() }}</b>
</span>
<div class="tm-main">

View File

@@ -2,7 +2,7 @@
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 { Star, Folder, Code2, GitCommitHorizontal, ListTodo, TicketCheck, StickyNote, ArrowRight, Circle, CircleDot, CircleCheck, Play, Check, CalendarDays, Plus, Bell, Save } from 'lucide-vue-next'
import StatCard from '../components/StatCard.vue'
import AIDayPanel from '../components/AIDayPanel.vue'
import { call } from '../api'
@@ -13,9 +13,12 @@ const store = useAppStore()
const { t } = useI18n()
const todos = ref([])
const tickets = ref([])
const notes = ref([])
const note = ref(null)
const noteText = ref('')
const noteSavedAt = ref('')
const noteDirty = ref(false)
const noteSaving = ref(false)
const messages = ref([])
let noteTimer
@@ -42,22 +45,67 @@ const weekTickets = computed(() => tickets.value.filter(x =>
))
const overdue = v => v && dueDate(v) < new Date()
const noteTitle = n => {
const line = (n?.content || '').split('\n').find(l => l.trim()) || ''
return line.trim().replace(/^#+\s*/, '') || t('noteUntitled')
}
const noteTime = s => String(s || '').replace('T', ' ').slice(5, 16)
async function loadNotes() {
try { notes.value = await call('ListNotes', 50) || [] } catch { notes.value = [] }
}
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
await loadNotes()
if (notes.value.length) {
note.value = notes.value[0]
noteText.value = note.value.content || ''
} else {
note.value = await call('GetNote')
noteText.value = note.value?.content || ''
await loadNotes()
}
noteDirty.value = false
}
function editNote() {
noteDirty.value = true
clearTimeout(noteTimer)
noteTimer = setTimeout(async () => {
// 带 id 保存,避免笔记中心新建笔记后误写到"最近一条"
noteTimer = setTimeout(() => { saveNote(false) }, 800)
}
async function saveNote(manual = true) {
if (noteSaving.value) return
noteSaving.value = true
clearTimeout(noteTimer)
try {
note.value = await call('SaveNoteByID', note.value?.id || 0, noteText.value)
noteSavedAt.value = new Date().toTimeString().slice(0, 8)
}, 600)
noteDirty.value = false
await loadNotes()
if (manual) store.showToast({ type: 'success', text: t('noteSaved') })
} catch (e) {
if (manual) store.showToast({ type: 'error', text: String(e?.message || e) })
}
noteSaving.value = false
}
async function selectNote(n) {
if (note.value?.id === n.id) return
if (noteDirty.value) await saveNote(false)
note.value = n
noteText.value = n.content || ''
noteDirty.value = false
noteSavedAt.value = ''
}
async function newNote() {
if (noteDirty.value) await saveNote(false)
note.value = await call('SaveNoteByID', 0, '')
noteText.value = ''
noteDirty.value = false
noteSavedAt.value = ''
await loadNotes()
}
async function completeTodo(x) {
await call('SetTodoStatus', x.id, 'done')
@@ -149,10 +197,32 @@ onUnmounted(() => clearTimeout(noteTimer))
<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>
<h2><StickyNote class="panel-icon" />{{ t('notepad') }}<small>{{ notes.length }}</small></h2>
<div class="wb-note-acts">
<small v-if="noteDirty" class="wb-note-dirty">{{ t('noteUnsaved') }}</small>
<small v-else-if="noteSavedAt" class="wb-note-saved">{{ t('autoSaved') }} {{ noteSavedAt }}</small>
<button type="button" class="btn secondary" :title="t('noteNew')" @click="newNote"><Plus /></button>
<button type="button" class="btn primary" :disabled="noteSaving || !noteDirty" :title="t('save')" @click="saveNote(true)"><Save />{{ t('save') }}</button>
<button type="button" class="btn secondary" @click="router.push('/notes')">{{ t('viewAll') }}<ArrowRight /></button>
</div>
</div>
<div class="wb-note-body">
<aside class="wb-note-list">
<button
v-for="n in notes"
:key="n.id"
type="button"
class="wb-note-item"
:class="{ on: n.id === note?.id }"
@click="selectNote(n)"
>
<b>{{ noteTitle(n) }}</b>
<time>{{ noteTime(n.updatedAt) }}</time>
</button>
<div v-if="!notes.length" class="wb-note-list-empty">{{ t('noteEmpty') }}</div>
</aside>
<textarea v-model="noteText" class="wb-note" :placeholder="t('notepadPlaceholder')" @input="editNote" />
</div>
<textarea v-model="noteText" class="wb-note" :placeholder="t('notepadPlaceholder')" @input="editNote" />
</section>
<section class="panel wb-col">

127
host_metrics.go Normal file
View File

@@ -0,0 +1,127 @@
package main
import (
"os/exec"
"strconv"
"strings"
"sync"
"time"
"github.com/shirou/gopsutil/v4/cpu"
"github.com/shirou/gopsutil/v4/mem"
gnet "github.com/shirou/gopsutil/v4/net"
"view/platform"
)
// HostMetrics 本机总览快照(端口监控页轮询)。
type HostMetrics struct {
At string `json:"at"`
CPUPercent float64 `json:"cpu"`
MemUsedMB float64 `json:"memUsedMB"`
MemTotalMB float64 `json:"memTotalMB"`
MemPercent float64 `json:"memPercent"`
GPUPercent float64 `json:"gpu"` // -1 表示不可用
GPUMemUsedMB float64 `json:"gpuMemUsedMB"`
GPUMemTotalMB float64 `json:"gpuMemTotalMB"`
GPUName string `json:"gpuName"`
NetRecvKBs float64 `json:"netRecvKBs"`
NetSentKBs float64 `json:"netSentKBs"`
}
var (
hostNetMu sync.Mutex
hostNetAt time.Time
hostNetRecv uint64
hostNetSent uint64
hostNetInit bool
)
// GetHostMetrics 采集 CPU / 内存 / 网卡吞吐GPU 在有 nvidia-smi 时附带利用率。
func (a *App) GetHostMetrics() (HostMetrics, error) {
out := HostMetrics{At: time.Now().Format(time.RFC3339), GPUPercent: -1}
if percents, err := cpu.Percent(0, false); err == nil && len(percents) > 0 {
out.CPUPercent = percents[0]
if out.CPUPercent < 0 {
out.CPUPercent = 0
}
}
if vm, err := mem.VirtualMemory(); err == nil && vm != nil {
out.MemUsedMB = float64(vm.Used) / 1024 / 1024
out.MemTotalMB = float64(vm.Total) / 1024 / 1024
out.MemPercent = vm.UsedPercent
}
recvKB, sentKB := sampleHostNet()
out.NetRecvKBs = recvKB
out.NetSentKBs = sentKB
fillGPU(&out)
return out, nil
}
func sampleHostNet() (recvKBs, sentKBs float64) {
counters, err := gnet.IOCounters(false)
if err != nil || len(counters) == 0 {
return 0, 0
}
c := counters[0]
now := time.Now()
hostNetMu.Lock()
defer hostNetMu.Unlock()
if !hostNetInit {
hostNetInit = true
hostNetAt = now
hostNetRecv = c.BytesRecv
hostNetSent = c.BytesSent
return 0, 0
}
wall := now.Sub(hostNetAt).Seconds()
if wall <= 0 {
return 0, 0
}
recvKBs = float64(c.BytesRecv-hostNetRecv) / 1024 / wall
sentKBs = float64(c.BytesSent-hostNetSent) / 1024 / wall
if recvKBs < 0 {
recvKBs = 0
}
if sentKBs < 0 {
sentKBs = 0
}
hostNetAt = now
hostNetRecv = c.BytesRecv
hostNetSent = c.BytesSent
return recvKBs, sentKBs
}
// fillGPU 优先 nvidia-smi失败则保持 gpu=-1。无窗口执行避免端口监控轮询闪 cmd。
func fillGPU(out *HostMetrics) {
cmd := exec.Command("nvidia-smi",
"--query-gpu=name,utilization.gpu,memory.used,memory.total",
"--format=csv,noheader,nounits")
platform.ConfigureHidden(cmd)
raw, err := cmd.Output()
if err != nil {
return
}
line := strings.TrimSpace(strings.Split(string(raw), "\n")[0])
if line == "" {
return
}
parts := strings.Split(line, ",")
if len(parts) < 4 {
return
}
out.GPUName = strings.TrimSpace(parts[0])
if v, e := strconv.ParseFloat(strings.TrimSpace(parts[1]), 64); e == nil {
out.GPUPercent = v
}
if v, e := strconv.ParseFloat(strings.TrimSpace(parts[2]), 64); e == nil {
out.GPUMemUsedMB = v
}
if v, e := strconv.ParseFloat(strings.TrimSpace(parts[3]), 64); e == nil {
out.GPUMemTotalMB = v
}
}

View File

@@ -155,10 +155,12 @@ func TestSaveContentImageServerMode(t *testing.T) {
}))
defer srv.Close()
cfg, _ := json.Marshal(FileStorageConfig{Mode: "server", BaseURL: srv.URL, APIKey: "k1"})
cfg, _ := json.Marshal(FileStorageConfig{Mode: "server", BaseURL: srv.URL, APIKey: "ignored"})
if e := a.store.SetMeta(fileStorageKey, string(cfg)); e != nil {
t.Fatal(e)
}
_ = a.store.SetMeta("sync_access_token", "jwt-token-1")
_ = a.store.SetMeta("sync_user_id", "9")
st, _ := a.store.Settings()
st.ImageMode = "server"
if e := a.store.SaveSettings(st); e != nil {
@@ -171,8 +173,8 @@ func TestSaveContentImageServerMode(t *testing.T) {
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 gotAuth != "Bearer jwt-token-1" {
t.Fatalf("missing bearer jwt, got %q", gotAuth)
}
if gotKind != "content" {
t.Fatalf("kind should be content, got %q", gotKind)

157
imgproxy.go Normal file
View File

@@ -0,0 +1,157 @@
package main
// imgproxy.go远程图片拉取。
// WebView 页面源是 http://wails.localhost直接加载外站 HTTPS 图常会破图。
// 优先经 Go 拉成 dataURLFetchRemoteImageAsDataURLAssetServer /__ccimg 作兜底。
import (
"context"
"encoding/base64"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"sync"
"time"
)
const (
imgProxyPath = "/__ccimg"
imgProxyMaxBytes = 12 << 20 // 12MB
)
var imgProxyClient = &http.Client{Timeout: 20 * time.Second}
var (
imgDataURLMu sync.Mutex
imgDataURLCache = map[string]string{}
errBadImageURL = errors.New("bad url")
)
// remoteImageMiddleware 拦截 /__ccimg?u=<urlencoded http(s) URL>,拉取上游图片并回传。
func remoteImageMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := strings.TrimSuffix(r.URL.Path, "/")
if path != imgProxyPath {
next.ServeHTTP(w, r)
return
}
raw := strings.TrimSpace(r.URL.Query().Get("u"))
data, ct, err := fetchRemoteImage(r.Context(), raw)
if err != nil {
code := http.StatusBadGateway
if errors.Is(err, errBadImageURL) {
code = http.StatusBadRequest
}
http.Error(w, err.Error(), code)
return
}
w.Header().Set("Content-Type", ct)
w.Header().Set("Cache-Control", "private, max-age=86400")
_, _ = w.Write(data)
})
}
func fetchRemoteImage(ctx context.Context, raw string) ([]byte, string, error) {
raw = strings.TrimSpace(raw)
u, err := url.Parse(raw)
if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") {
return nil, "", errBadImageURL
}
if ctx == nil {
ctx = context.Background()
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
if err != nil {
return nil, "", errBadImageURL
}
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
req.Header.Set("Accept", "image/avif,image/webp,image/apng,image/*,*/*;q=0.8")
resp, err := imgProxyClient.Do(req)
if err != nil {
return nil, "", errors.New("upstream unreachable")
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, "", errors.New("upstream error")
}
ct := resp.Header.Get("Content-Type")
if i := strings.Index(ct, ";"); i >= 0 {
ct = strings.TrimSpace(ct[:i])
}
data, err := io.ReadAll(io.LimitReader(resp.Body, imgProxyMaxBytes+1))
if err != nil {
return nil, "", errors.New("upstream read failed")
}
if len(data) > imgProxyMaxBytes {
return nil, "", errors.New("image too large")
}
if len(data) == 0 {
return nil, "", errors.New("empty image")
}
if ct == "" || (!strings.HasPrefix(ct, "image/") && !strings.Contains(ct, "octet-stream")) {
if sniffed := sniffImageMIME(data); sniffed != "" {
ct = sniffed
}
}
if ct == "" {
ct = "application/octet-stream"
}
return data, ct, nil
}
func sniffImageMIME(b []byte) string {
if len(b) >= 3 && b[0] == 0xff && b[1] == 0xd8 && b[2] == 0xff {
return "image/jpeg"
}
if len(b) >= 8 && string(b[:8]) == "\x89PNG\r\n\x1a\n" {
return "image/png"
}
if len(b) >= 6 && (string(b[:6]) == "GIF87a" || string(b[:6]) == "GIF89a") {
return "image/gif"
}
if len(b) >= 12 && string(b[:4]) == "RIFF" && string(b[8:12]) == "WEBP" {
return "image/webp"
}
return ""
}
// FetchRemoteImageAsDataURL 由 Go 拉取远程图片并返回 dataURL绕过 WebView 外站限制。
func (a *App) FetchRemoteImageAsDataURL(rawURL string) (string, error) {
rawURL = strings.TrimSpace(rawURL)
if rawURL == "" {
return "", errors.New("EMPTY_URL")
}
if strings.HasPrefix(rawURL, "data:") {
return rawURL, nil
}
imgDataURLMu.Lock()
if cached, ok := imgDataURLCache[rawURL]; ok {
imgDataURLMu.Unlock()
return cached, nil
}
imgDataURLMu.Unlock()
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
data, ct, err := fetchRemoteImage(ctx, rawURL)
if err != nil {
return "", err
}
if !strings.HasPrefix(ct, "image/") {
ct = "image/png"
if sniffed := sniffImageMIME(data); sniffed != "" {
ct = sniffed
}
}
out := fmt.Sprintf("data:%s;base64,%s", ct, base64.StdEncoding.EncodeToString(data))
imgDataURLMu.Lock()
if len(imgDataURLCache) > 128 {
imgDataURLCache = map[string]string{}
}
imgDataURLCache[rawURL] = out
imgDataURLMu.Unlock()
return out, nil
}

44
imgproxy_test.go Normal file
View File

@@ -0,0 +1,44 @@
package main
import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestRemoteImageMiddlewareProxiesPNG(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/png")
_, _ = w.Write([]byte("\x89PNG\r\n"))
}))
defer upstream.Close()
h := remoteImageMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("next should not run for /__ccimg")
}))
req := httptest.NewRequest(http.MethodGet, "/__ccimg?u="+upstream.URL+"/a.png", nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
}
if !strings.HasPrefix(rec.Header().Get("Content-Type"), "image/png") {
t.Fatalf("content-type %q", rec.Header().Get("Content-Type"))
}
body, _ := io.ReadAll(rec.Body)
if len(body) < 4 || string(body[:4]) != "\x89PNG" {
t.Fatalf("body not proxied: %q", body)
}
}
func TestRemoteImageMiddlewareRejectsBadURL(t *testing.T) {
h := remoteImageMiddleware(http.NotFoundHandler())
req := httptest.NewRequest(http.MethodGet, "/__ccimg?u=file:///etc/passwd", nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("want 400, got %d", rec.Code)
}
}

214
init.sql
View File

@@ -1,211 +1,9 @@
-- ============================================================
-- 年糕崽崽项目管理PMS同步服务初始化脚本MySQL 5.7+ / 8.x
-- 用法mysql -u root -p < init.sql
-- 应用与前端不执行任何建表/迁移DDL使用同步功能前必须先执行本脚本
-- 应用连接账号只需要对 code_count 库的 SELECT/INSERT/UPDATE 权限。
-- 默认账号liqi / qiqi991012bcrypt 哈希存储,可在应用内注册新账号)
-- DDL 已迁至 nl-pms-api云同步 + 文件服务的唯一 schema 来源
-- ============================================================
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 COMMENT '用户ID',
username VARCHAR(64) NOT NULL UNIQUE COMMENT '登录名',
password_hash VARCHAR(100) NOT NULL COMMENT 'bcrypt 密码哈希',
created_at VARCHAR(32) NOT NULL COMMENT '注册时间RFC3339'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='应用账号';
-- 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 COMMENT '所属用户ID',
uuid CHAR(36) NOT NULL COMMENT '客户端生成的全局唯一ID',
title TEXT NOT NULL COMMENT '标题',
content MEDIUMTEXT NOT NULL COMMENT '正文Markdown可含内嵌图片',
project_name VARCHAR(255) NOT NULL DEFAULT '' COMMENT '关联项目名(展示用)',
due_at VARCHAR(32) NOT NULL DEFAULT '' COMMENT '截止时间',
priority VARCHAR(16) NOT NULL DEFAULT 'medium' COMMENT '优先级low/medium/high',
status VARCHAR(16) NOT NULL DEFAULT 'open' COMMENT '状态open/doing/done/cancelled',
history MEDIUMTEXT NOT NULL COMMENT '生命周期轨迹 JSON',
team_id BIGINT NOT NULL DEFAULT 0 COMMENT '共享团队ID0=私密',
created_at VARCHAR(32) NOT NULL DEFAULT '' COMMENT '创建时间',
updated_at VARCHAR(32) NOT NULL COMMENT '最后更新时间LWW',
deleted TINYINT NOT NULL DEFAULT 0 COMMENT '软删除标记1=已删',
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 COMMENT='待办同步表';
-- 工单同步表
CREATE TABLE IF NOT EXISTS sync_tickets(
user_id BIGINT NOT NULL COMMENT '所属用户ID',
uuid CHAR(36) NOT NULL COMMENT '客户端生成的全局唯一ID',
title TEXT NOT NULL COMMENT '标题',
description MEDIUMTEXT NOT NULL COMMENT '描述Markdown',
type VARCHAR(16) NOT NULL DEFAULT 'task' COMMENT '类型task/bug/feature 等',
project_name VARCHAR(255) NOT NULL DEFAULT '' COMMENT '关联项目名',
start_at VARCHAR(32) NOT NULL DEFAULT '' COMMENT '开始时间',
due_at VARCHAR(32) NOT NULL DEFAULT '' COMMENT '截止时间',
status VARCHAR(16) NOT NULL DEFAULT 'open' COMMENT '状态',
priority VARCHAR(16) NOT NULL DEFAULT 'medium' COMMENT '优先级',
history MEDIUMTEXT NOT NULL COMMENT '生命周期轨迹 JSON',
team_id BIGINT NOT NULL DEFAULT 0 COMMENT '共享团队ID0=私密',
created_at VARCHAR(32) NOT NULL DEFAULT '' COMMENT '创建时间',
updated_at VARCHAR(32) NOT NULL COMMENT '最后更新时间LWW',
deleted TINYINT NOT NULL DEFAULT 0 COMMENT '软删除标记',
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 COMMENT='工单同步表';
-- 记事本同步表
CREATE TABLE IF NOT EXISTS sync_notes(
user_id BIGINT NOT NULL COMMENT '所属用户ID',
uuid CHAR(36) NOT NULL COMMENT '客户端生成的全局唯一ID',
content MEDIUMTEXT NOT NULL COMMENT '记事本正文',
updated_at VARCHAR(32) NOT NULL COMMENT '最后更新时间LWW',
deleted TINYINT NOT NULL DEFAULT 0 COMMENT '软删除标记',
PRIMARY KEY(user_id, uuid),
KEY idx_sync_notes_updated(user_id, updated_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='记事本同步表';
-- 按用户存储的同步设置(如加密盐 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 COMMENT '所属用户ID全局资源挂在管理员 id=1',
name VARCHAR(64) NOT NULL COMMENT '设置键名',
value MEDIUMTEXT NOT NULL COMMENT '设置值(明文或密文)',
updated_at VARCHAR(32) NOT NULL COMMENT '最后更新时间',
PRIMARY KEY(user_id, name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户同步设置键值表';
-- 旧版本升级:早期脚本建的 content/description 为 TEXT64KB
-- 待办/工单内容支持 Markdown 内嵌图片后需要 MEDIUMTEXT重复执行无副作用。
ALTER TABLE sync_todos MODIFY content MEDIUMTEXT NOT NULL COMMENT '正文Markdown可含内嵌图片';
ALTER TABLE sync_tickets MODIFY description MEDIUMTEXT NOT NULL COMMENT '描述Markdown';
-- 旧版本升级:补 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 COMMENT ''生命周期轨迹 JSON'' 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 COMMENT ''生命周期轨迹 JSON'' AFTER priority', 'SELECT 1');
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
-- 部署 / 升级请执行:
-- mysql -u root -p < ../nl-pms-api/init.sql
--
-- 桌面端view不再直连 MySQL也不执行任何 DDL。
-- 本文件仅作跳转说明,避免与 API 仓库两套 schema 漂移。
-- ============================================================
-- 团队协作v2.1):资料 / 团队 / 团队任务 / 日报 / 通知
-- ============================================================
-- 用户公开资料(同服务器用户互相可见:昵称/头衔/技术栈标签/头像缩略图)
CREATE TABLE IF NOT EXISTS user_profiles(
user_id BIGINT PRIMARY KEY COMMENT '对应用户ID',
nickname VARCHAR(64) NOT NULL DEFAULT '' COMMENT '昵称',
title VARCHAR(64) NOT NULL DEFAULT '' COMMENT '头衔/职位',
email VARCHAR(128) NOT NULL DEFAULT '' COMMENT '公开邮箱',
bio VARCHAR(500) NOT NULL DEFAULT '' COMMENT '个人简介',
tech_tags VARCHAR(1000) NOT NULL DEFAULT '[]' COMMENT '技术栈标签 JSON 数组',
avatar_thumb MEDIUMTEXT NOT NULL COMMENT '头像缩略图dataURL 或 URL',
updated_at VARCHAR(32) NOT NULL COMMENT '最后更新时间'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户公开资料';
-- 团队digest_time日报 AI 摘要自动生成时间 HH:MM
CREATE TABLE IF NOT EXISTS teams(
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '团队ID',
name VARCHAR(64) NOT NULL COMMENT '团队名称',
owner_id BIGINT NOT NULL COMMENT '创建者用户ID',
digest_time VARCHAR(8) NOT NULL DEFAULT '21:00' COMMENT '日报 AI 摘要自动生成时间 HH:MM',
created_at VARCHAR(32) NOT NULL COMMENT '创建时间'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='团队';
-- 团队成员role: owner | admin | member
CREATE TABLE IF NOT EXISTS team_members(
team_id BIGINT NOT NULL COMMENT '团队ID',
user_id BIGINT NOT NULL COMMENT '成员用户ID',
role VARCHAR(16) NOT NULL DEFAULT 'member' COMMENT '角色owner/admin/member',
joined_at VARCHAR(32) NOT NULL COMMENT '加入时间',
PRIMARY KEY(team_id, user_id),
KEY idx_team_members_user(user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='团队成员';
-- 团队任务/工单服务器唯一真相在线操作kind: todo | ticket
CREATE TABLE IF NOT EXISTS team_tasks(
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '任务ID',
team_id BIGINT NOT NULL COMMENT '所属团队ID',
kind VARCHAR(16) NOT NULL DEFAULT 'todo' COMMENT '种类todo/ticket',
title TEXT NOT NULL COMMENT '标题',
description MEDIUMTEXT NOT NULL COMMENT '描述',
priority VARCHAR(16) NOT NULL DEFAULT 'medium' COMMENT '优先级',
status VARCHAR(16) NOT NULL DEFAULT 'open' COMMENT '状态',
creator_id BIGINT NOT NULL COMMENT '创建者用户ID',
assignee_id BIGINT NOT NULL DEFAULT 0 COMMENT '指派人用户ID0=未指派',
start_at VARCHAR(32) NOT NULL DEFAULT '' COMMENT '开始时间',
due_at VARCHAR(32) NOT NULL DEFAULT '' COMMENT '截止时间',
urged_at VARCHAR(32) NOT NULL DEFAULT '' COMMENT '最近催办时间',
history MEDIUMTEXT NOT NULL COMMENT '生命周期轨迹 JSON',
updated_at VARCHAR(32) NOT NULL COMMENT '最后更新时间',
deleted TINYINT NOT NULL DEFAULT 0 COMMENT '软删除标记',
KEY idx_team_tasks_team(team_id, deleted),
KEY idx_team_tasks_assignee(assignee_id, deleted)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='团队任务/工单';
-- 团队日报date 为本地日期 YYYY-MM-DD
CREATE TABLE IF NOT EXISTS team_reports(
team_id BIGINT NOT NULL COMMENT '团队ID',
user_id BIGINT NOT NULL COMMENT '提交人用户ID',
date CHAR(10) NOT NULL COMMENT '日报日期 YYYY-MM-DD',
content MEDIUMTEXT NOT NULL COMMENT '日报正文Markdown',
submitted_at VARCHAR(32) NOT NULL COMMENT '提交/更新时间',
PRIMARY KEY(team_id, user_id, date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='团队日报';
-- 团队日报 AI 摘要(管理员客户端生成,全员可见)
CREATE TABLE IF NOT EXISTS team_digests(
team_id BIGINT NOT NULL COMMENT '团队ID',
date CHAR(10) NOT NULL COMMENT '摘要对应日期 YYYY-MM-DD',
content MEDIUMTEXT NOT NULL COMMENT 'AI 摘要正文Markdown',
provider VARCHAR(32) NOT NULL DEFAULT '' COMMENT '生成所用 AI 提供商',
generated_at VARCHAR(32) NOT NULL COMMENT '生成时间',
PRIMARY KEY(team_id, date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='团队日报 AI 摘要';
-- 团队通知(指派/催办/成员变动等;客户端同步时按 to_user 增量拉取转本地消息)
CREATE TABLE IF NOT EXISTS team_notices(
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '通知ID',
team_id BIGINT NOT NULL COMMENT '团队ID',
to_user BIGINT NOT NULL COMMENT '接收用户ID',
from_user BIGINT NOT NULL COMMENT '发送用户ID',
kind VARCHAR(16) NOT NULL COMMENT '通知类型',
ref_id VARCHAR(64) NOT NULL DEFAULT '' COMMENT '关联对象ID',
content TEXT NOT NULL COMMENT '通知正文',
created_at VARCHAR(32) NOT NULL COMMENT '创建时间',
KEY idx_team_notices_to(to_user, id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='团队通知';
-- 旧版本升级:个人待办/工单支持按条共享到团队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 COMMENT ''共享团队ID0=私密'' 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 COMMENT ''共享团队ID0=私密'' 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');

204
kind_icon.go Normal file
View File

@@ -0,0 +1,204 @@
package main
// kind_icon.go启动台可识别 kind 的默认图标(仅本地,不同步;超管维护)。
import (
"encoding/base64"
"errors"
"fmt"
"strings"
"github.com/wailsapp/wails/v3/pkg/application"
)
var knownLaunchKinds = []string{
"node", "go", "python", "java", "php", "dotnet", "exe", "mysql", "redis", "nginx", "web", "other",
}
func isKnownLaunchKind(k string) bool {
for _, x := range knownLaunchKinds {
if x == k {
return true
}
}
return false
}
func (s *Store) ListKindIcons() (map[string]string, error) {
rows, e := s.db.Query(`SELECT kind,value FROM kind_icons WHERE value!=''`)
if e != nil {
return nil, e
}
defer rows.Close()
m := map[string]string{}
for rows.Next() {
var k, v string
if e = rows.Scan(&k, &v); e != nil {
return nil, e
}
m[k] = v
}
return m, rows.Err()
}
func (s *Store) setKindIcon(kind, value string) error {
now := nowRFC()
_, e := s.db.Exec(`INSERT INTO kind_icons(kind,value,updated_at) VALUES(?,?,?)
ON CONFLICT(kind) DO UPDATE SET value=excluded.value,updated_at=excluded.updated_at`, kind, value, now)
return e
}
func (s *Store) clearKindIcon(kind string) error {
_, e := s.db.Exec(`DELETE FROM kind_icons WHERE kind=?`, kind)
return e
}
func (a *App) kindIconAdminGate() error {
if a.syncUserID() != festivalAdminID {
return errors.New("KIND_ICON_ADMIN_ONLY")
}
return nil
}
// ListKindIcons 返回 kind → dataURL。
func (a *App) ListKindIcons() (map[string]string, error) {
if e := a.ready(); e != nil {
return nil, e
}
return a.store.ListKindIcons()
}
// ListKnownLaunchKinds 固定种类列表(前端管理页用)。
func (a *App) ListKnownLaunchKinds() []string {
return append([]string{}, knownLaunchKinds...)
}
// PickKindIcon 超管为某 kind 选择本地图片(缩放后存 dataURL
func (a *App) PickKindIcon(kind string) (string, error) {
if e := a.ready(); e != nil {
return "", e
}
if e := a.kindIconAdminGate(); e != nil {
return "", e
}
kind = strings.TrimSpace(kind)
if !isKnownLaunchKind(kind) {
return "", errors.New("KIND_UNKNOWN")
}
p, e := application.Get().Dialog.OpenFile().
SetTitle(a.localized("选择种类图标", "Choose kind icon")).
AddFilter(a.localized("图片文件", "Image files"), "*.png;*.jpg;*.jpeg;*.gif;*.webp").
PromptForSingleSelection()
if e != nil || strings.TrimSpace(p) == "" {
return "", e
}
data, mime, e := encodeAvatarFile(p)
if e != nil {
return "", e
}
dataURL := fmt.Sprintf("data:%s;base64,%s", mime, base64.StdEncoding.EncodeToString(data))
if e := a.store.setKindIcon(kind, dataURL); e != nil {
return "", e
}
return dataURL, nil
}
// ClearKindIcon 超管清除某 kind 默认图。
func (a *App) ClearKindIcon(kind string) error {
if e := a.ready(); e != nil {
return e
}
if e := a.kindIconAdminGate(); e != nil {
return e
}
kind = strings.TrimSpace(kind)
if !isKnownLaunchKind(kind) {
return errors.New("KIND_UNKNOWN")
}
return a.store.clearKindIcon(kind)
}
// PickLaunchIconImage 为启动台选本地图片base64 dataURL不强制写库
func (a *App) PickLaunchIconImage() (string, error) {
if e := a.ready(); e != nil {
return "", e
}
p, e := application.Get().Dialog.OpenFile().
SetTitle(a.localized("选择应用图标", "Choose app icon")).
AddFilter(a.localized("图片文件", "Image files"), "*.png;*.jpg;*.jpeg;*.gif;*.webp").
PromptForSingleSelection()
if e != nil || strings.TrimSpace(p) == "" {
return "", e
}
data, mime, e := encodeAvatarFile(p)
if e != nil {
return "", e
}
return fmt.Sprintf("data:%s;base64,%s", mime, base64.StdEncoding.EncodeToString(data)), nil
}
// SetLaunchAppIcon 写回已保存应用的图标(空字符串=清除)。
func (a *App) SetLaunchAppIcon(id int64, icon string) error {
if e := a.ready(); e != nil {
return e
}
if _, e := a.store.GetLaunchApp(id); e != nil {
return errors.New("LAUNCH_APP_NOT_FOUND")
}
if e := a.store.setLaunchIcon(id, icon); e != nil {
return e
}
a.emit("launchpad:changed", nil)
return nil
}
// FetchProjectIcon 从项目目录抓取 icon 并写回 projects.icon。
func (a *App) FetchProjectIcon(id int64) (string, error) {
if e := a.ready(); e != nil {
return "", e
}
p, e := a.store.GetProject(id)
if e != nil {
return "", errors.New("PROJECT_NOT_FOUND")
}
u := iconFromDir(p.Path)
if u == "" {
return "", errors.New("ICON_NOT_FOUND")
}
if e := a.store.setProjectIcon(id, u); e != nil {
return "", e
}
return u, nil
}
// SetProjectIcon 写入/清除项目图标。
func (a *App) SetProjectIcon(id int64, icon string) error {
if e := a.ready(); e != nil {
return e
}
if _, e := a.store.GetProject(id); e != nil {
return errors.New("PROJECT_NOT_FOUND")
}
return a.store.setProjectIcon(id, icon)
}
func (s *Store) setProjectIcon(id int64, icon string) error {
_, e := s.db.Exec(`UPDATE projects SET icon=?,updated_at=? WHERE id=?`, icon, now(), id)
return e
}
// DetectProjectLaunchKind 根据项目路径推断启动台 kind用于默认图标回退
func (a *App) DetectProjectLaunchKind(id int64) (string, error) {
if e := a.ready(); e != nil {
return "", e
}
p, e := a.store.GetProject(id)
if e != nil {
return "", errors.New("PROJECT_NOT_FOUND")
}
prof := detectLaunchProfile(p.Path)
if prof.Kind == "" {
return "other", nil
}
return prof.Kind, nil
}

115
launch_category.go Normal file
View File

@@ -0,0 +1,115 @@
package main
// launch_category.go启动台一级分类仅本地不同步
import (
"errors"
"strings"
)
func (s *Store) ListLaunchCategories() ([]LaunchCategory, error) {
rows, e := s.db.Query(`SELECT id,name,sort,created_at FROM launch_categories ORDER BY sort ASC, id ASC`)
if e != nil {
return nil, e
}
defer rows.Close()
out := []LaunchCategory{}
for rows.Next() {
var x LaunchCategory
if e = rows.Scan(&x.ID, &x.Name, &x.Sort, &x.CreatedAt); e != nil {
return nil, e
}
out = append(out, x)
}
return out, rows.Err()
}
func (s *Store) SaveLaunchCategory(in LaunchCategory) (LaunchCategory, error) {
in.Name = strings.TrimSpace(in.Name)
if in.Name == "" {
return in, errors.New("NAME_REQUIRED")
}
now := nowRFC()
if in.ID == 0 {
var maxSort int
_ = s.db.QueryRow(`SELECT COALESCE(MAX(sort),0) FROM launch_categories`).Scan(&maxSort)
in.Sort = maxSort + 1
res, e := s.db.Exec(`INSERT INTO launch_categories(name,sort,created_at) VALUES(?,?,?)`, in.Name, in.Sort, now)
if e != nil {
if strings.Contains(strings.ToLower(e.Error()), "unique") {
return in, errors.New("CATEGORY_EXISTS")
}
return in, e
}
in.ID, _ = res.LastInsertId()
in.CreatedAt = now
return in, nil
}
_, e := s.db.Exec(`UPDATE launch_categories SET name=?,sort=? WHERE id=?`, in.Name, in.Sort, in.ID)
if e != nil {
if strings.Contains(strings.ToLower(e.Error()), "unique") {
return in, errors.New("CATEGORY_EXISTS")
}
return in, e
}
_ = s.db.QueryRow(`SELECT id,name,sort,created_at FROM launch_categories WHERE id=?`, in.ID).
Scan(&in.ID, &in.Name, &in.Sort, &in.CreatedAt)
return in, nil
}
func (s *Store) DeleteLaunchCategory(id int64) error {
tx, e := s.db.Begin()
if e != nil {
return e
}
defer tx.Rollback()
if _, e = tx.Exec(`UPDATE launch_apps SET category_id=0 WHERE category_id=?`, id); e != nil {
return e
}
if _, e = tx.Exec(`DELETE FROM launch_categories WHERE id=?`, id); e != nil {
return e
}
return tx.Commit()
}
func (s *Store) ReorderLaunchCategories(ids []int64) error {
tx, e := s.db.Begin()
if e != nil {
return e
}
defer tx.Rollback()
for i, id := range ids {
if _, e = tx.Exec(`UPDATE launch_categories SET sort=? WHERE id=?`, i+1, id); e != nil {
return e
}
}
return tx.Commit()
}
func (a *App) ListLaunchCategories() ([]LaunchCategory, error) {
if e := a.ready(); e != nil {
return nil, e
}
return a.store.ListLaunchCategories()
}
func (a *App) SaveLaunchCategory(in LaunchCategory) (LaunchCategory, error) {
if e := a.ready(); e != nil {
return LaunchCategory{}, e
}
return a.store.SaveLaunchCategory(in)
}
func (a *App) DeleteLaunchCategory(id int64) error {
if e := a.ready(); e != nil {
return e
}
return a.store.DeleteLaunchCategory(id)
}
func (a *App) ReorderLaunchCategories(ids []int64) error {
if e := a.ready(); e != nil {
return e
}
return a.store.ReorderLaunchCategories(ids)
}

59
launch_category_test.go Normal file
View File

@@ -0,0 +1,59 @@
package main
import (
"path/filepath"
"testing"
)
func TestLaunchCategoryCRUD(t *testing.T) {
s, e := OpenStore(filepath.Join(t.TempDir(), "cat.db"))
if e != nil {
t.Fatal(e)
}
defer s.db.Close()
c, e := s.SaveLaunchCategory(LaunchCategory{Name: " 工作 "})
if e != nil || c.ID == 0 || c.Name != "工作" {
t.Fatalf("save: %+v %v", c, e)
}
list, e := s.ListLaunchCategories()
if e != nil || len(list) != 1 {
t.Fatalf("list: %v %v", list, e)
}
app, e := s.SaveLaunchApp(LaunchApp{Name: "demo", Kind: "node", CategoryID: c.ID, Category: "前端"})
if e != nil {
t.Fatal(e)
}
got, e := s.GetLaunchApp(app.ID)
if e != nil || got.CategoryID != c.ID || got.Category != "前端" {
t.Fatalf("app cats: %+v %v", got, e)
}
if e := s.DeleteLaunchCategory(c.ID); e != nil {
t.Fatal(e)
}
got, e = s.GetLaunchApp(app.ID)
if e != nil || got.CategoryID != 0 {
t.Fatalf("after delete cat: %+v %v", got, e)
}
}
func TestKindIconsLocal(t *testing.T) {
s, e := OpenStore(filepath.Join(t.TempDir(), "kind.db"))
if e != nil {
t.Fatal(e)
}
defer s.db.Close()
if e := s.setKindIcon("node", "data:image/png;base64,xx"); e != nil {
t.Fatal(e)
}
m, e := s.ListKindIcons()
if e != nil || m["node"] == "" {
t.Fatalf("list: %v %v", m, e)
}
if e := s.clearKindIcon("node"); e != nil {
t.Fatal(e)
}
m, e = s.ListKindIcons()
if e != nil || len(m) != 0 {
t.Fatalf("cleared: %v %v", m, e)
}
}

338
launch_detect.go Normal file
View File

@@ -0,0 +1,338 @@
package main
// launch_detect.go根据项目目录识别种类并推荐启动命令 / 默认端口。
// 优先读 package.json scripts、go.mod、pom.xml 等标记文件;再回落到静态种类建议。
import (
"encoding/json"
"os"
"path/filepath"
"strings"
)
type npmPackageJSON struct {
Name string `json:"name"`
Scripts map[string]string `json:"scripts"`
}
func fileExists(dir, name string) bool {
st, e := os.Stat(filepath.Join(dir, name))
return e == nil && !st.IsDir()
}
func dirExists(dir, name string) bool {
st, e := os.Stat(filepath.Join(dir, name))
return e == nil && st.IsDir()
}
func hasExtInDir(dir, ext string) bool {
ents, e := os.ReadDir(dir)
if e != nil {
return false
}
ext = strings.ToLower(ext)
n := 0
for _, ent := range ents {
if ent.IsDir() {
continue
}
if strings.EqualFold(filepath.Ext(ent.Name()), ext) {
n++
if n >= 1 {
return true
}
}
}
return false
}
// findLaunchExes 收集可直接启动的 .exe相对工作目录的路径优先浅层与常见输出目录。
func findLaunchExes(dir string) []string {
dir = strings.TrimSpace(dir)
if dir == "" {
return nil
}
var out []string
seen := map[string]bool{}
add := func(abs string) {
rel, e := filepath.Rel(dir, abs)
if e != nil {
rel = filepath.Base(abs)
}
rel = filepath.ToSlash(rel)
key := strings.ToLower(rel)
if seen[key] {
return
}
// 跳过安装器 / 卸载 / 明显工具
base := strings.ToLower(filepath.Base(abs))
for _, skip := range []string{"uninstall", "setup", "installer", "update", "crashpad", "vcredist"} {
if strings.Contains(base, skip) {
return
}
}
seen[key] = true
// Windows 路径含空格时加引号,便于 cmd /C 执行
if strings.ContainsAny(rel, " \t") {
out = append(out, `"`+filepath.FromSlash(rel)+`"`)
} else {
out = append(out, filepath.FromSlash(rel))
}
}
scan := func(root string, maxDepth int) {
_ = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
if err != nil {
return nil
}
rel, _ := filepath.Rel(root, path)
depth := 0
if rel != "." {
depth = strings.Count(rel, string(filepath.Separator))
}
if d.IsDir() {
name := strings.ToLower(d.Name())
if name == "node_modules" || name == ".git" || name == "vendor" {
return filepath.SkipDir
}
if depth >= maxDepth {
return filepath.SkipDir
}
return nil
}
if strings.EqualFold(filepath.Ext(d.Name()), ".exe") {
add(path)
}
return nil
})
}
scan(dir, 1) // 根目录
for _, sub := range []string{"bin", "dist", "build", "out", "release", "Debug", "Release", "x64", "publish"} {
p := filepath.Join(dir, sub)
if st, e := os.Stat(p); e == nil && st.IsDir() {
scan(p, 2)
}
}
if len(out) > 8 {
out = out[:8]
}
return out
}
// detectLaunchProfile 扫描目录,返回种类、名称、默认端口与推荐启停命令。
func detectLaunchProfile(dir string) LaunchProfile {
dir = strings.TrimSpace(dir)
out := LaunchProfile{
Dir: dir,
Kind: "other",
Name: filepath.Base(dir),
}
if dir == "" {
return out
}
if st, e := os.Stat(dir); e != nil || !st.IsDir() {
return out
}
base := filepath.Base(dir)
out.Name = base
switch {
case fileExists(dir, "package.json"):
out.Kind = "node"
out.Port = 5173
raw, e := os.ReadFile(filepath.Join(dir, "package.json"))
pkg := npmPackageJSON{}
if e == nil {
_ = json.Unmarshal(raw, &pkg)
}
if n := strings.TrimSpace(pkg.Name); n != "" {
// 去掉 npm scope@org/app → app
if i := strings.LastIndex(n, "/"); i >= 0 && i+1 < len(n) {
n = n[i+1:]
}
out.Name = n
}
starts := []string{}
pick := []string{"dev", "start", "serve", "preview", "develop"}
pm := "npm"
if fileExists(dir, "pnpm-lock.yaml") {
pm = "pnpm"
} else if fileExists(dir, "yarn.lock") {
pm = "yarn"
}
run := func(script string) string {
switch pm {
case "pnpm":
return "pnpm " + script
case "yarn":
return "yarn " + script
default:
return "npm run " + script
}
}
if pkg.Scripts != nil {
for _, k := range pick {
if _, ok := pkg.Scripts[k]; ok {
starts = append(starts, run(k))
}
}
}
if len(starts) == 0 {
starts = append([]string{}, launchSuggestions["node"].Start...)
} else {
// 补全常见命令,去重
for _, c := range launchSuggestions["node"].Start {
dup := false
for _, s := range starts {
if s == c {
dup = true
break
}
}
if !dup {
starts = append(starts, c)
}
}
}
if fileExists(dir, "next.config.js") || fileExists(dir, "next.config.mjs") || fileExists(dir, "next.config.ts") || dirExists(dir, ".next") {
out.Port = 3000
}
if fileExists(dir, "vite.config.js") || fileExists(dir, "vite.config.ts") || fileExists(dir, "vite.config.mjs") {
out.Port = 5173
}
out.Start = starts
if len(starts) > 0 {
out.StartCmd = starts[0]
}
case fileExists(dir, "go.mod"):
out.Kind = "go"
out.Port = 8080
if b, e := os.ReadFile(filepath.Join(dir, "go.mod")); e == nil {
for _, line := range strings.Split(string(b), "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "module ") {
mod := strings.TrimSpace(strings.TrimPrefix(line, "module "))
if i := strings.LastIndex(mod, "/"); i >= 0 {
mod = mod[i+1:]
}
if mod != "" {
out.Name = mod
}
break
}
}
}
out.Start = append([]string{}, launchSuggestions["go"].Start...)
if fileExists(dir, "main.go") {
out.Start = append([]string{"go run .", "go run main.go"}, out.Start...)
}
out.StartCmd = out.Start[0]
case fileExists(dir, "manage.py"):
out.Kind = "python"
out.Port = 8000
out.Start = []string{"python manage.py runserver", "python manage.py runserver 0.0.0.0:8000"}
out.Start = append(out.Start, launchSuggestions["python"].Start...)
out.StartCmd = out.Start[0]
case fileExists(dir, "pyproject.toml") || fileExists(dir, "requirements.txt") || fileExists(dir, "main.py") || fileExists(dir, "app.py"):
out.Kind = "python"
out.Port = 8000
starts := []string{}
if fileExists(dir, "main.py") {
starts = append(starts, "uvicorn main:app --reload", "python main.py")
}
if fileExists(dir, "app.py") {
starts = append(starts, "uvicorn app:app --reload", "python app.py", "flask --app app run")
}
starts = append(starts, launchSuggestions["python"].Start...)
out.Start = uniqStrings(starts)
out.StartCmd = out.Start[0]
case fileExists(dir, "pom.xml") || fileExists(dir, "build.gradle") || fileExists(dir, "build.gradle.kts"):
out.Kind = "java"
out.Port = 8080
out.Start = append([]string{}, launchSuggestions["java"].Start...)
out.StartCmd = out.Start[0]
case fileExists(dir, "artisan") || fileExists(dir, "composer.json"):
out.Kind = "php"
out.Port = 8000
out.Start = append([]string{}, launchSuggestions["php"].Start...)
out.StartCmd = out.Start[0]
case hasExtInDir(dir, ".csproj") || hasExtInDir(dir, ".sln"):
out.Kind = "dotnet"
out.Port = 5000
out.Start = append([]string{}, launchSuggestions["dotnet"].Start...)
out.StartCmd = out.Start[0]
default:
// Windows 可执行文件:目录内 / bin / dist 下的 .exe 作为默认启动方式
if exes := findLaunchExes(dir); len(exes) > 0 {
out.Kind = "exe"
out.Start = exes
out.StartCmd = exes[0]
} else {
out.Kind = "other"
out.Start = nil
}
}
if sug, ok := launchSuggestions[out.Kind]; ok {
out.Stop = append([]string{}, sug.Stop...)
}
if out.Name == "" || out.Name == "." || out.Name == string(filepath.Separator) {
out.Name = base
}
return out
}
func uniqStrings(in []string) []string {
seen := map[string]bool{}
out := make([]string, 0, len(in))
for _, s := range in {
s = strings.TrimSpace(s)
if s == "" || seen[s] {
continue
}
seen[s] = true
out = append(out, s)
}
return out
}
func normLaunchPath(p string) string {
p = strings.TrimSpace(p)
if p == "" {
return ""
}
p = filepath.Clean(p)
return strings.ToLower(filepath.ToSlash(p))
}
// matchProjectByDir 用目录前缀匹配「我的项目」,取最长路径命中。
func (a *App) matchProjectByDir(dir string) (Project, bool) {
nd := normLaunchPath(dir)
if nd == "" || a.store == nil {
return Project{}, false
}
ps, e := a.store.ListProjects(0)
if e != nil {
return Project{}, false
}
var best Project
bestLen := 0
for _, p := range ps {
np := normLaunchPath(p.Path)
if np == "" {
continue
}
if nd == np || strings.HasPrefix(nd, np+"/") {
if len(np) > bestLen {
best, bestLen = p, len(np)
}
}
}
return best, bestLen > 0
}

66
launch_detect_test.go Normal file
View File

@@ -0,0 +1,66 @@
package main
import (
"os"
"path/filepath"
"testing"
)
func TestDetectLaunchProfileNode(t *testing.T) {
dir := t.TempDir()
raw := `{"name":"@acme/demo-app","scripts":{"dev":"vite","build":"vite build","start":"node server.js"}}`
if e := os.WriteFile(filepath.Join(dir, "package.json"), []byte(raw), 0644); e != nil {
t.Fatal(e)
}
_ = os.WriteFile(filepath.Join(dir, "vite.config.ts"), []byte("export default {}"), 0644)
p := detectLaunchProfile(dir)
if p.Kind != "node" || p.Name != "demo-app" {
t.Fatalf("got kind=%q name=%q", p.Kind, p.Name)
}
if p.Port != 5173 {
t.Fatalf("vite port want 5173 got %d", p.Port)
}
if p.StartCmd != "npm run dev" {
t.Fatalf("startCmd=%q", p.StartCmd)
}
if len(p.Start) < 2 {
t.Fatalf("expected multiple suggestions: %v", p.Start)
}
}
func TestDetectLaunchProfileGo(t *testing.T) {
dir := t.TempDir()
if e := os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module github.com/x/myapi\n\ngo 1.22\n"), 0644); e != nil {
t.Fatal(e)
}
_ = os.WriteFile(filepath.Join(dir, "main.go"), []byte("package main\nfunc main(){}\n"), 0644)
p := detectLaunchProfile(dir)
if p.Kind != "go" || p.Name != "myapi" || p.StartCmd == "" {
t.Fatalf("%+v", p)
}
}
func TestFindLaunchExes(t *testing.T) {
dir := t.TempDir()
_ = os.WriteFile(filepath.Join(dir, "app.exe"), []byte("MZ"), 0644)
_ = os.MkdirAll(filepath.Join(dir, "bin"), 0755)
_ = os.WriteFile(filepath.Join(dir, "bin", "tool.exe"), []byte("MZ"), 0644)
_ = os.WriteFile(filepath.Join(dir, "uninstall.exe"), []byte("MZ"), 0644)
got := findLaunchExes(dir)
if len(got) < 2 {
t.Fatalf("want >=2 exes, got %v", got)
}
p := detectLaunchProfile(dir)
if p.Kind != "exe" || p.StartCmd == "" {
t.Fatalf("exe profile: %+v", p)
}
}
func containsStr(ss []string, want string) bool {
for _, s := range ss {
if s == want {
return true
}
}
return false
}

293
launch_icon.go Normal file
View File

@@ -0,0 +1,293 @@
package main
// launch_icon.go抓取启动台应用 / 网站图标。
// 优先读项目目录常见 favicon/logo否则请求本机端口页面解析 <link rel=icon> /favicon.ico。
// 结果存为 dataURL仅本地 launch_apps.icon不同步
import (
"encoding/base64"
"errors"
"io"
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
"sync"
"time"
)
const launchIconMaxBytes = 256 << 10 // 256KB
var (
launchIconClient = &http.Client{Timeout: 4 * time.Second}
iconHrefRe = regexp.MustCompile(`(?i)<link[^>]+rel=["'](?:shortcut\s+)?icon["'][^>]*>`)
iconHrefAttrRe = regexp.MustCompile(`(?i)href=["']([^"']+)["']`)
lpIconCacheMu sync.Mutex
lpIconCache = map[string]string{} // key=port|dir -> dataURL
)
var launchIconCandidates = []string{
"favicon.ico", "favicon.png", "favicon.svg",
"apple-touch-icon.png", "apple-touch-icon.ico",
"logo.svg", "logo.png", "logo.ico",
"public/favicon.ico", "public/favicon.png", "public/favicon.svg",
"public/logo.png", "public/logo.svg",
"src/favicon.ico", "src/favicon.png", "src/assets/favicon.ico",
"src/assets/logo.svg", "src/assets/logo.png",
"static/favicon.ico", "assets/favicon.ico",
}
func mimeFromExt(path string) string {
switch strings.ToLower(filepath.Ext(path)) {
case ".ico":
return "image/x-icon"
case ".png":
return "image/png"
case ".jpg", ".jpeg":
return "image/jpeg"
case ".gif":
return "image/gif"
case ".svg":
return "image/svg+xml"
case ".webp":
return "image/webp"
default:
return "application/octet-stream"
}
}
func mimeFromHTTP(ct, path string) string {
ct = strings.TrimSpace(strings.Split(ct, ";")[0])
if strings.HasPrefix(ct, "image/") {
return ct
}
return mimeFromExt(path)
}
func bytesToDataURL(mime string, raw []byte) string {
if mime == "" || mime == "application/octet-stream" {
mime = "image/png"
}
return "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(raw)
}
func readIconFile(path string) (string, error) {
st, e := os.Stat(path)
if e != nil || st.IsDir() || st.Size() <= 0 || st.Size() > launchIconMaxBytes {
return "", errors.New("ICON_NOT_FOUND")
}
raw, e := os.ReadFile(path)
if e != nil || len(raw) == 0 {
return "", errors.New("ICON_NOT_FOUND")
}
return bytesToDataURL(mimeFromExt(path), raw), nil
}
func iconFromDir(dir string) string {
dir = strings.TrimSpace(dir)
if dir == "" {
return ""
}
for _, rel := range launchIconCandidates {
if u, e := readIconFile(filepath.Join(dir, filepath.FromSlash(rel))); e == nil && u != "" {
return u
}
}
return ""
}
func httpGetLimited(url string) (body []byte, ct string, err error) {
req, e := http.NewRequest(http.MethodGet, url, nil)
if e != nil {
return nil, "", e
}
req.Header.Set("User-Agent", "code-count-launchpad/1.0")
resp, e := launchIconClient.Do(req)
if e != nil {
return nil, "", e
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, "", errors.New("ICON_HTTP_STATUS")
}
raw, e := io.ReadAll(io.LimitReader(resp.Body, launchIconMaxBytes+1))
if e != nil || len(raw) == 0 || len(raw) > launchIconMaxBytes {
return nil, "", errors.New("ICON_TOO_LARGE")
}
return raw, resp.Header.Get("Content-Type"), nil
}
func resolveIconURL(base string, href string) string {
href = strings.TrimSpace(href)
if href == "" {
return ""
}
if strings.HasPrefix(href, "data:image/") {
return href
}
if strings.HasPrefix(href, "//") {
return "http:" + href
}
if strings.HasPrefix(href, "http://") || strings.HasPrefix(href, "https://") {
return href
}
base = strings.TrimRight(base, "/")
if strings.HasPrefix(href, "/") {
// http://127.0.0.1:5173 + /favicon.ico
if i := strings.Index(base, "://"); i >= 0 {
rest := base[i+3:]
host := rest
if j := strings.Index(rest, "/"); j >= 0 {
host = rest[:j]
}
return base[:i+3] + host + href
}
}
return base + "/" + strings.TrimPrefix(href, "./")
}
func iconFromHTML(base string, html []byte) string {
m := iconHrefRe.FindAll(html, -1)
for _, tag := range m {
am := iconHrefAttrRe.FindSubmatch(tag)
if len(am) < 2 {
continue
}
href := resolveIconURL(base, string(am[1]))
if strings.HasPrefix(href, "data:image/") {
return href
}
if href == "" {
continue
}
raw, ct, e := httpGetLimited(href)
if e != nil {
continue
}
return bytesToDataURL(mimeFromHTTP(ct, href), raw)
}
return ""
}
func iconFromPort(port int) string {
if port <= 0 || port > 65535 {
return ""
}
bases := []string{
"http://127.0.0.1:" + itoa(port),
"http://localhost:" + itoa(port),
}
for _, base := range bases {
// 1) 直接 favicon
for _, path := range []string{"/favicon.ico", "/favicon.png", "/apple-touch-icon.png"} {
raw, ct, e := httpGetLimited(base + path)
if e == nil && len(raw) > 4 {
return bytesToDataURL(mimeFromHTTP(ct, path), raw)
}
}
// 2) 首页 HTML 里的 <link rel=icon>
raw, ct, e := httpGetLimited(base + "/")
if e == nil && (strings.Contains(ct, "html") || strings.Contains(string(raw[:minInt(200, len(raw))]), "<")) {
if u := iconFromHTML(base, raw); u != "" {
return u
}
}
}
return ""
}
func itoa(n int) string {
if n == 0 {
return "0"
}
var b [16]byte
i := len(b)
for n > 0 {
i--
b[i] = byte('0' + n%10)
n /= 10
}
return string(b[i:])
}
func minInt(a, b int) int {
if a < b {
return a
}
return b
}
func cacheKey(port int, dir string) string {
return itoa(port) + "|" + strings.ToLower(filepath.Clean(dir))
}
func peekCachedLaunchIcon(port int, dir string) string {
lpIconCacheMu.Lock()
defer lpIconCacheMu.Unlock()
return lpIconCache[cacheKey(port, dir)]
}
// resolveLaunchIcon 按目录 → 端口顺序解析图标;带短时内存缓存。
func resolveLaunchIcon(port int, dir string) string {
key := cacheKey(port, dir)
lpIconCacheMu.Lock()
if u, ok := lpIconCache[key]; ok {
lpIconCacheMu.Unlock()
return u
}
lpIconCacheMu.Unlock()
u := iconFromDir(dir)
if u == "" {
u = iconFromPort(port)
}
if u != "" {
lpIconCacheMu.Lock()
lpIconCache[key] = u
lpIconCacheMu.Unlock()
}
return u
}
func (s *Store) setLaunchIcon(id int64, icon string) error {
_, e := s.db.Exec(`UPDATE launch_apps SET icon=?,updated_at=? WHERE id=?`, icon, nowRFC(), id)
return e
}
// FetchLaunchIcon 主动抓取并写回已保存应用的图标port/dir 可覆盖配置。
func (a *App) FetchLaunchIcon(id int64) (string, error) {
if e := a.ready(); e != nil {
return "", e
}
app, e := a.store.GetLaunchApp(id)
if e != nil {
return "", errors.New("LAUNCH_APP_NOT_FOUND")
}
// 清缓存后重抓
lpIconCacheMu.Lock()
delete(lpIconCache, cacheKey(app.Port, app.Dir))
lpIconCacheMu.Unlock()
u := resolveLaunchIcon(app.Port, app.Dir)
if u == "" {
return "", errors.New("ICON_NOT_FOUND")
}
if e := a.store.setLaunchIcon(app.ID, u); e != nil {
return "", e
}
a.emit("launchpad:changed", nil)
return u, nil
}
// PeekLaunchIcon 不落库,仅预览(扫描条目 / 新建表单用)。
func (a *App) PeekLaunchIcon(port int, dir string) (string, error) {
if e := a.ready(); e != nil {
return "", e
}
u := resolveLaunchIcon(port, dir)
if u == "" {
return "", errors.New("ICON_NOT_FOUND")
}
return u, nil
}

60
launch_icon_test.go Normal file
View File

@@ -0,0 +1,60 @@
package main
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
func TestIconFromDir(t *testing.T) {
dir := t.TempDir()
png := []byte{0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 1, 2, 3}
if e := os.WriteFile(filepath.Join(dir, "favicon.png"), png, 0644); e != nil {
t.Fatal(e)
}
u := iconFromDir(dir)
if !strings.HasPrefix(u, "data:image/png;base64,") {
t.Fatalf("got %q", u)
}
}
func TestIconFromPortFavicon(t *testing.T) {
ico := []byte{0, 0, 1, 0, 1, 0, 16, 16, 0, 0, 1, 0, 32, 0}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/favicon.ico" {
w.Header().Set("Content-Type", "image/x-icon")
_, _ = w.Write(ico)
return
}
http.NotFound(w, r)
}))
defer srv.Close()
// iconFromPort 用 127.0.0.1:port从 httptest URL 解析端口
u := srv.URL
port := 0
if i := strings.LastIndex(u, ":"); i >= 0 {
_, _ = fmtSscanf(u[i+1:], &port)
}
if port == 0 {
t.Fatal("no port")
}
got := iconFromPort(port)
if !strings.HasPrefix(got, "data:image/") {
t.Fatalf("got %q", got)
}
}
func fmtSscanf(s string, port *int) (int, error) {
n := 0
for _, c := range s {
if c < '0' || c > '9' {
break
}
n = n*10 + int(c-'0')
}
*port = n
return 1, nil
}

155
launch_run.go Normal file
View File

@@ -0,0 +1,155 @@
package main
// launch_run.go启动台启停运行态starting/running/failed与本地进程日志。
// launch_logs 仅存本机 SQLite不参与云同步。
import (
"bufio"
"io"
"strings"
"sync"
"time"
)
type launchRunState struct {
mu sync.Mutex
Status string // starting | running | failed | stopped
Error string
LastLog string
StartedAt time.Time
}
var (
lpRunMu sync.Mutex
lpRuns = map[int64]*launchRunState{}
)
func lpState(id int64) *launchRunState {
lpRunMu.Lock()
defer lpRunMu.Unlock()
st, ok := lpRuns[id]
if !ok {
st = &launchRunState{Status: "stopped"}
lpRuns[id] = st
}
return st
}
func (st *launchRunState) snapshot() (status, errMsg, lastLog string) {
st.mu.Lock()
defer st.mu.Unlock()
return st.Status, st.Error, st.LastLog
}
func (st *launchRunState) set(status, errMsg, lastLog string) {
st.mu.Lock()
defer st.mu.Unlock()
if status != "" {
st.Status = status
}
if errMsg != "" || status == "running" || status == "starting" {
st.Error = errMsg
}
if lastLog != "" {
st.LastLog = lastLog
}
if status == "starting" {
st.StartedAt = time.Now()
st.Error = ""
}
}
func (s *Store) appendLaunchLog(appID int64, level, line string) {
line = strings.TrimRight(line, "\r\n")
if strings.TrimSpace(line) == "" {
return
}
if len(line) > 4000 {
line = line[:4000] + "…"
}
if level == "" {
level = "info"
}
_, _ = s.db.Exec(`INSERT INTO launch_logs(app_id,level,line,created_at) VALUES(?,?,?,?)`,
appID, level, line, nowRFC())
// 每个应用最多保留 800 行,避免无限膨胀。
_, _ = s.db.Exec(`DELETE FROM launch_logs WHERE app_id=? AND id NOT IN (
SELECT id FROM launch_logs WHERE app_id=? ORDER BY id DESC LIMIT 800)`, appID, appID)
}
func (s *Store) ListLaunchLogs(appID int64, limit int) ([]LaunchLogLine, error) {
if limit <= 0 || limit > 500 {
limit = 200
}
rows, e := s.db.Query(`SELECT id,app_id,level,line,created_at FROM launch_logs WHERE app_id=? ORDER BY id DESC LIMIT ?`, appID, limit)
if e != nil {
return nil, e
}
defer rows.Close()
out := []LaunchLogLine{}
for rows.Next() {
var x LaunchLogLine
if e = rows.Scan(&x.ID, &x.AppID, &x.Level, &x.Line, &x.CreatedAt); e != nil {
return nil, e
}
out = append(out, x)
}
// 按时间正序返回,便于控制台阅读。
for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 {
out[i], out[j] = out[j], out[i]
}
return out, rows.Err()
}
func (s *Store) ClearLaunchLogs(appID int64) error {
_, e := s.db.Exec(`DELETE FROM launch_logs WHERE app_id=?`, appID)
return e
}
func (a *App) ListLaunchLogs(appID int64, limit int) ([]LaunchLogLine, error) {
if e := a.ready(); e != nil {
return nil, e
}
return a.store.ListLaunchLogs(appID, limit)
}
func (a *App) ClearLaunchLogs(appID int64) error {
if e := a.ready(); e != nil {
return e
}
return a.store.ClearLaunchLogs(appID)
}
func (a *App) writeLaunchLog(appID int64, level, line string) {
a.store.appendLaunchLog(appID, level, line)
st := lpState(appID)
st.set("", "", line)
a.emit("launchpad:log", map[string]any{"appId": appID, "level": level, "line": line})
}
// pipeLaunchOutput 把子进程 stdout/stderr 写入本地日志。
func (a *App) pipeLaunchOutput(appID int64, r io.Reader, fallbackLevel string) {
sc := bufio.NewScanner(r)
buf := make([]byte, 0, 64*1024)
sc.Buffer(buf, 1024*1024)
for sc.Scan() {
line := sc.Text()
level := launchLogLevelFromLine(line)
if level == "info" && fallbackLevel == "error" {
level = "error"
}
a.writeLaunchLog(appID, level, line)
}
}
func launchLogLevelFromLine(line string) string {
l := strings.ToLower(line)
switch {
case strings.Contains(l, "error"), strings.Contains(l, "fatal"), strings.Contains(l, "panic"), strings.Contains(l, "failed"):
return "error"
case strings.Contains(l, "warn"):
return "warning"
default:
return "info"
}
}

View File

@@ -3,8 +3,10 @@ package main
import (
"context"
"errors"
"io"
"os"
"os/exec"
"path/filepath"
"runtime"
"sort"
"strconv"
@@ -35,7 +37,7 @@ var (
// ---------- 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`)
rows, e := s.db.Query(`SELECT id,name,kind,port,dir,start_cmd,stop_cmd,last_pid,COALESCE(icon,''),COALESCE(category_id,0),COALESCE(category,''),created_at,updated_at FROM launch_apps ORDER BY id`)
if e != nil {
return nil, e
}
@@ -43,7 +45,7 @@ func (s *Store) ListLaunchApps() ([]LaunchApp, error) {
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 {
if e = rows.Scan(&x.ID, &x.Name, &x.Kind, &x.Port, &x.Dir, &x.StartCmd, &x.StopCmd, &x.LastPID, &x.Icon, &x.CategoryID, &x.Category, &x.CreatedAt, &x.UpdatedAt); e != nil {
return nil, e
}
out = append(out, x)
@@ -53,8 +55,8 @@ func (s *Store) ListLaunchApps() ([]LaunchApp, error) {
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)
e := s.db.QueryRow(`SELECT id,name,kind,port,dir,start_cmd,stop_cmd,last_pid,COALESCE(icon,''),COALESCE(category_id,0),COALESCE(category,''),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.Icon, &x.CategoryID, &x.Category, &x.CreatedAt, &x.UpdatedAt)
return x, e
}
@@ -66,10 +68,17 @@ func (s *Store) SaveLaunchApp(in LaunchApp) (LaunchApp, error) {
if in.Kind == "" {
in.Kind = "other"
}
in.Category = strings.TrimSpace(in.Category)
if in.CategoryID < 0 {
in.CategoryID = 0
}
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 strings.TrimSpace(in.Icon) == "" {
in.Icon = resolveLaunchIcon(in.Port, in.Dir)
}
res, e := s.db.Exec(`INSERT INTO launch_apps(name,kind,port,dir,start_cmd,stop_cmd,last_pid,icon,category_id,category,created_at,updated_at) VALUES(?,?,?,?,?,?,0,?,?,?,?,?)`,
in.Name, in.Kind, in.Port, in.Dir, in.StartCmd, in.StopCmd, in.Icon, in.CategoryID, in.Category, now, now)
if e != nil {
return in, e
}
@@ -77,8 +86,8 @@ func (s *Store) SaveLaunchApp(in LaunchApp) (LaunchApp, error) {
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)
_, e := s.db.Exec(`UPDATE launch_apps SET name=?,kind=?,port=?,dir=?,start_cmd=?,stop_cmd=?,icon=?,category_id=?,category=?,updated_at=? WHERE id=?`,
in.Name, in.Kind, in.Port, in.Dir, in.StartCmd, in.StopCmd, in.Icon, in.CategoryID, in.Category, now, in.ID)
if e != nil {
return in, e
}
@@ -134,6 +143,7 @@ var launchSuggestions = map[string]LaunchSuggest{
"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},
"exe": {Start: nil, 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"}},
@@ -148,6 +158,39 @@ func (a *App) LaunchCmdSuggest(kind string) LaunchSuggest {
return LaunchSuggest{}
}
// DetectLaunchProfile 根据工作目录识别项目类型并推荐启动命令。
func (a *App) DetectLaunchProfile(dir string) (LaunchProfile, error) {
if e := a.ready(); e != nil {
return LaunchProfile{}, e
}
p := detectLaunchProfile(dir)
if proj, ok := a.matchProjectByDir(dir); ok {
p.ProjectID = proj.ID
if strings.TrimSpace(p.Name) == "" || p.Name == filepath.Base(dir) {
p.Name = proj.Name
}
}
return p, nil
}
// DraftLaunchFromProject 用「我的项目」生成启动台草稿(种类/命令/端口)。
func (a *App) DraftLaunchFromProject(projectID int64) (LaunchProfile, error) {
if e := a.ready(); e != nil {
return LaunchProfile{}, e
}
p, e := a.store.GetProject(projectID)
if e != nil {
return LaunchProfile{}, errors.New("PROJECT_NOT_FOUND")
}
prof := detectLaunchProfile(p.Path)
prof.ProjectID = p.ID
prof.Dir = p.Path
if strings.TrimSpace(p.Name) != "" {
prof.Name = p.Name
}
return prof, nil
}
// ---------- 扫描 ----------
// scanListenPorts 返回 pid -> 去重后的监听端口列表。
@@ -179,7 +222,7 @@ func scanListenPorts() (map[int32][]int, error) {
}
// probeProcess 读取进程静态信息与资源占用CPU/IO 用与上次采样的差值算速率)。
func probeProcess(pid int32) (name, exe, cmdline string, cpu, memMB, ioKBs float64) {
func probeProcess(pid int32) (name, exe, cmdline, cwd string, cpu, memMB, ioKBs float64) {
p, e := process.NewProcess(pid)
if e != nil {
return
@@ -187,6 +230,7 @@ func probeProcess(pid int32) (name, exe, cmdline string, cpu, memMB, ioKBs float
name, _ = p.Name()
exe, _ = p.Exe()
cmdline, _ = p.Cmdline()
cwd, _ = p.Cwd()
if mi, e := p.MemoryInfo(); e == nil && mi != nil {
memMB = float64(mi.RSS) / 1024 / 1024
}
@@ -252,7 +296,10 @@ func (a *App) ListLaunchEntries() ([]LaunchEntry, error) {
}
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}
ent := LaunchEntry{ID: app.ID, Name: app.Name, Kind: app.Kind, Port: app.Port, Dir: app.Dir, StartCmd: app.StartCmd, StopCmd: app.StopCmd, Icon: app.Icon, CategoryID: app.CategoryID, Category: app.Category}
if proj, ok := a.matchProjectByDir(app.Dir); ok {
ent.ProjectID = proj.ID
}
pid := int32(0)
if app.LastPID > 0 {
if _, ok := byPid[int32(app.LastPID)]; ok && !used[int32(app.LastPID)] {
@@ -265,12 +312,40 @@ func (a *App) ListLaunchEntries() ([]LaunchEntry, error) {
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)
var name, cwd string
name, ent.Exe, ent.Cmdline, cwd, ent.CPU, ent.MemMB, ent.IOKBs = probeProcess(pid)
if ent.Dir == "" && cwd != "" {
ent.Dir = cwd
}
if ent.Kind == "other" || ent.Kind == "" {
ent.Kind = inferLaunchKind(name, ent.Exe, ent.Cmdline)
}
}
st := lpState(app.ID)
status, errMsg, lastLog := st.snapshot()
if status == "" || status == "stopped" {
if ent.Running {
status = "running"
} else if errMsg != "" {
status = "failed"
} else {
status = "stopped"
}
}
// 壳进程早退可能误标 failed只要端口已在听就纠回 running。
if ent.Running && (status == "starting" || status == "failed") {
st.set("running", "", "")
status, errMsg, lastLog = st.snapshot()
}
if !ent.Running && status == "running" {
status = "stopped"
st.set("stopped", "", "")
}
ent.Status, ent.LastError, ent.LastLog = status, errMsg, lastLog
if ent.Icon == "" && (ent.Running || ent.Dir != "") {
// 后台补抓,避免阻塞列表;下次刷新可见
go a.ensureLaunchIcon(app.ID, app.Port, app.Dir)
}
out = append(out, ent)
}
scanned := []LaunchEntry{}
@@ -278,18 +353,40 @@ func (a *App) ListLaunchEntries() ([]LaunchEntry, error) {
if used[pid] || pid == self {
continue
}
name, exe, cmdline, cpu, memMB, ioKBs := probeProcess(pid)
name, exe, cmdline, cwd, cpu, memMB, ioKBs := probeProcess(pid)
if name == "" {
name = "PID " + strconv.Itoa(int(pid))
}
ent := LaunchEntry{
Name: name, Kind: inferLaunchKind(name, exe, cmdline),
Name: name, Kind: inferLaunchKind(name, exe, cmdline), Dir: cwd,
Running: true, PID: pid, Exe: exe, Cmdline: cmdline, Ports: ports,
CPU: cpu, MemMB: memMB, IOKBs: ioKBs,
}
if proj, ok := a.matchProjectByDir(cwd); ok {
ent.ProjectID = proj.ID
ent.Name = proj.Name
if ent.Dir == "" {
ent.Dir = proj.Path
}
} else if cwd != "" {
// 无项目绑定时,用目录名更易识别
base := filepath.Base(cwd)
if base != "" && base != "." && base != string(filepath.Separator) {
ent.Name = base + " · " + name
}
}
if len(ports) > 0 {
ent.Port = ports[0]
}
ent.Icon = peekCachedLaunchIcon(ent.Port, ent.Dir)
if ent.Icon == "" && (ent.Port > 0 || ent.Dir != "") {
p, d := ent.Port, ent.Dir
go func() {
if resolveLaunchIcon(p, d) != "" {
a.emit("launchpad:changed", nil)
}
}()
}
scanned = append(scanned, ent)
}
sort.Slice(scanned, func(i, j int) bool { return scanned[i].Port < scanned[j].Port })
@@ -328,14 +425,46 @@ func (a *App) DeleteLaunchApp(id int64) error {
}
// shellCommand 把一行命令交给系统 shell 解释执行。
// Windows 上纯 .exe可带引号、无额外参数直接 exec避免 cmd /C 早退误报。
func shellCommand(line string) *exec.Cmd {
line = strings.TrimSpace(line)
if runtime.GOOS == "windows" {
if exe := windowsDirectExe(line); exe != "" {
return exec.Command(exe)
}
return exec.Command("cmd", "/C", line)
}
return exec.Command("sh", "-c", line)
}
// windowsDirectExe 识别可直接启动的单文件 .exe无 shell 元字符/参数)。
func windowsDirectExe(line string) string {
if line == "" {
return ""
}
if strings.HasPrefix(line, `"`) {
end := strings.Index(line[1:], `"`)
if end < 0 {
return ""
}
path := line[1 : 1+end]
rest := strings.TrimSpace(line[2+end:])
if rest == "" && strings.HasSuffix(strings.ToLower(path), ".exe") {
return path
}
return ""
}
if strings.ContainsAny(line, " \t&|<>^%") {
return ""
}
if strings.HasSuffix(strings.ToLower(line), ".exe") {
return line
}
return ""
}
// StartLaunchApp 在应用目录以独立进程组执行启动命令,并记录 PID 供停止时杀进程树。
// 输出写入日志文件再尾随到 launch_logs避免 Windows 管道导致壳进程异常退出)。
func (a *App) StartLaunchApp(id int64) (LaunchApp, error) {
if e := a.ready(); e != nil {
return LaunchApp{}, e
@@ -347,24 +476,276 @@ func (a *App) StartLaunchApp(id int64) (LaunchApp, error) {
if strings.TrimSpace(app.StartCmd) == "" {
return app, errors.New("LAUNCH_CMD_REQUIRED")
}
st := lpState(app.ID)
st.set("starting", "", "")
a.writeLaunchLog(app.ID, "info", "▶ 启动 "+app.Name+" · "+app.StartCmd)
a.emit("launchpad:changed", nil)
cmd := shellCommand(app.StartCmd)
if st, err := os.Stat(app.Dir); err == nil && st.IsDir() {
cmd.Dir = app.Dir
if dir := strings.TrimSpace(app.Dir); dir != "" {
if info, err := os.Stat(dir); err == nil && info.IsDir() {
cmd.Dir = dir
}
}
platform.ConfigureDetached(cmd)
logPath, logFile, eLog := a.openLaunchProcLog(app.ID)
if eLog != nil {
msg := "无法创建启动日志:" + eLog.Error()
st.set("failed", msg, "")
a.writeLaunchLog(app.ID, "error", msg)
a.emit("launchpad:changed", nil)
return app, errors.New("LAUNCH_LOG_FAILED")
}
cmd.Stdout = logFile
cmd.Stderr = logFile
if e = cmd.Start(); e != nil {
a.store.Log("error", "启动台", "启动失败:"+app.Name, e.Error())
_ = logFile.Close()
msg := e.Error()
st.set("failed", msg, "")
a.writeLaunchLog(app.ID, "error", "启动失败:"+msg)
a.store.Log("error", "启动台", "启动失败:"+app.Name, msg)
a.emit("launchpad:changed", nil)
return app, e
}
pid := int64(cmd.Process.Pid)
_ = cmd.Process.Release()
_ = a.store.setLaunchPID(app.ID, pid)
app.LastPID = pid
a.writeLaunchLog(app.ID, "info", "进程已创建 pid="+strconv.FormatInt(pid, 10))
a.store.Log("info", "启动台", "已启动 "+app.Name, "pid="+strconv.FormatInt(pid, 10)+" cmd="+app.StartCmd)
a.emit("launchpad:changed", nil)
go a.tailLaunchProcLog(app.ID, logPath)
go a.watchLaunchProcess(app.ID, app.Name, cmd, app.Port, logFile)
return app, nil
}
func (a *App) openLaunchProcLog(appID int64) (string, *os.File, error) {
dir, e := os.UserConfigDir()
if e != nil {
return "", nil, e
}
dir = filepath.Join(dir, "CodeCount", "launch-logs")
if e = os.MkdirAll(dir, 0755); e != nil {
return "", nil, e
}
path := filepath.Join(dir, strconv.FormatInt(appID, 10)+".log")
f, e := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
return path, f, e
}
// tailLaunchProcLog 跟踪进程日志文件新增行,写入本地 launch_logs。
func (a *App) tailLaunchProcLog(appID int64, path string) {
f, e := os.Open(path)
if e != nil {
return
}
defer f.Close()
// 等写入端创建内容;进程退出后最多再读 2s。
deadline := time.Now().Add(30 * time.Minute)
idle := 0
buf := make([]byte, 0, 64*1024)
tmp := make([]byte, 4096)
for time.Now().Before(deadline) {
n, err := f.Read(tmp)
if n > 0 {
idle = 0
buf = append(buf, tmp[:n]...)
for {
i := -1
for j, b := range buf {
if b == '\n' {
i = j
break
}
}
if i < 0 {
break
}
line := string(buf[:i])
buf = buf[i+1:]
level := launchLogLevelFromLine(line)
a.writeLaunchLog(appID, level, strings.TrimRight(line, "\r"))
}
continue
}
if err != nil && err != io.EOF {
return
}
st, _, _ := lpState(appID).snapshot()
if st == "failed" || st == "stopped" {
idle++
if idle > 20 {
if len(buf) > 0 {
a.writeLaunchLog(appID, launchLogLevelFromLine(string(buf)), strings.TrimRight(string(buf), "\r"))
}
return
}
}
time.Sleep(100 * time.Millisecond)
}
}
// watchLaunchProcess 观察启动后短时是否崩溃;存活则标 running。
// 壳进程cmd / npm早退但端口已监听时接管监听 PID不标 failed。
func (a *App) watchLaunchProcess(appID int64, name string, cmd *exec.Cmd, port int, logFile *os.File) {
st := lpState(appID)
done := make(chan error, 1)
go func() {
err := cmd.Wait()
if logFile != nil {
_ = logFile.Close()
}
done <- err
}()
finishFail := func(e error, prefix string) {
if a.adoptLaunchIfPortUp(appID, port, e) {
return
}
msg := "进程已退出"
if e != nil {
msg = e.Error()
}
st.set("failed", msg, "")
a.writeLaunchLog(appID, "error", prefix+msg)
a.store.Log("error", "启动台", "启动失败:"+name, msg)
_ = a.store.setLaunchPID(appID, 0)
a.emit("launchpad:changed", nil)
}
timer := time.NewTimer(3 * time.Second)
defer timer.Stop()
select {
case e := <-done:
finishFail(e, "启动失败 / 进程退出:")
return
case <-timer.C:
st.set("running", "", "")
a.writeLaunchLog(appID, "info", "进程存活,等待服务就绪…")
a.emit("launchpad:changed", nil)
}
if port > 0 {
deadline := time.Now().Add(25 * time.Second)
for time.Now().Before(deadline) {
select {
case e := <-done:
finishFail(e, "启动后进程退出:")
return
case <-time.After(800 * time.Millisecond):
if pid := findPIDListeningPort(port); pid > 0 {
a.writeLaunchLog(appID, "info", "端口 :"+strconv.Itoa(port)+" 已就绪")
st.set("running", "", "")
a.emit("launchpad:changed", nil)
if e := <-done; e != nil {
// 监听已建立后壳退出:接管真实 PID不算失败
if a.adoptLaunchIfPortUp(appID, port, e) {
return
}
st.set("failed", e.Error(), "")
a.writeLaunchLog(appID, "error", "运行中退出:"+e.Error())
_ = a.store.setLaunchPID(appID, 0)
a.emit("launchpad:changed", nil)
} else {
if a.adoptLaunchIfPortUp(appID, port, nil) {
return
}
st.set("stopped", "", "")
_ = a.store.setLaunchPID(appID, 0)
a.writeLaunchLog(appID, "info", "进程已结束")
a.emit("launchpad:changed", nil)
}
return
}
}
}
a.writeLaunchLog(appID, "warning", "超时未检测到端口 :"+strconv.Itoa(port)+" 监听(进程仍在运行)")
}
if e := <-done; e != nil {
if a.adoptLaunchIfPortUp(appID, port, e) {
return
}
st.set("failed", e.Error(), "")
a.writeLaunchLog(appID, "error", "运行中退出:"+e.Error())
_ = a.store.setLaunchPID(appID, 0)
} else {
if a.adoptLaunchIfPortUp(appID, port, nil) {
return
}
st.set("stopped", "", "")
_ = a.store.setLaunchPID(appID, 0)
a.writeLaunchLog(appID, "info", "进程已结束")
}
a.emit("launchpad:changed", nil)
}
// adoptLaunchIfPortUp 在启动壳退出后,若配置端口仍在监听则接管该 PID 并保持 running。
func (a *App) adoptLaunchIfPortUp(appID int64, port int, waitErr error) bool {
if port <= 0 {
return false
}
var pid int32
for i := 0; i < 4; i++ {
if i > 0 {
time.Sleep(400 * time.Millisecond)
}
pid = findPIDListeningPort(port)
if pid > 0 {
break
}
}
if pid <= 0 {
return false
}
_ = a.store.setLaunchPID(appID, int64(pid))
lpState(appID).set("running", "", "")
msg := "启动壳已退出,已接管监听进程 pid=" + strconv.Itoa(int(pid))
if waitErr != nil {
msg += "(壳:" + waitErr.Error() + ""
}
a.writeLaunchLog(appID, "info", msg)
a.emit("launchpad:changed", nil)
return true
}
func findPIDListeningPort(port int) int32 {
if port <= 0 {
return 0
}
byPid, e := scanListenPorts()
if e != nil {
return 0
}
self := int32(os.Getpid())
for pid, ports := range byPid {
if pid == self {
continue
}
for _, p := range ports {
if p == port {
return pid
}
}
}
return 0
}
func launchPortListening(port int) bool {
return findPIDListeningPort(port) > 0
}
func (a *App) ensureLaunchIcon(id int64, port int, dir string) {
u := resolveLaunchIcon(port, dir)
if u == "" {
return
}
_ = a.store.setLaunchIcon(id, u)
a.emit("launchpad:changed", nil)
}
// killProcessTree 结束进程及其全部子进程。
func killProcessTree(ctx context.Context, pid int32) error {
if runtime.GOOS == "windows" {
@@ -402,6 +783,8 @@ func (a *App) StopLaunchApp(id int64, pid int32) error {
_ = cmd.Process.Release()
_ = a.store.setLaunchPID(app.ID, 0)
a.store.Log("info", "启动台", "已执行停止命令:"+app.Name, app.StopCmd)
lpState(app.ID).set("stopped", "", "")
a.writeLaunchLog(app.ID, "info", "已执行停止命令")
a.emit("launchpad:changed", nil)
return nil
}
@@ -417,12 +800,49 @@ func (a *App) StopLaunchApp(id int64, pid int32) error {
}
if e := killProcessTree(ctx, pid); e != nil {
a.store.Log("error", "启动台", "结束进程失败 pid="+strconv.Itoa(int(pid)), e.Error())
if id > 0 {
lpState(id).set("failed", e.Error(), "")
a.writeLaunchLog(id, "error", "停止失败:"+e.Error())
a.emit("launchpad:changed", nil)
}
return e
}
if id > 0 {
_ = a.store.setLaunchPID(id, 0)
lpState(id).set("stopped", "", "")
a.writeLaunchLog(id, "info", "已停止")
}
a.store.Log("info", "启动台", "已结束进程", "pid="+strconv.Itoa(int(pid)))
a.emit("launchpad:changed", nil)
return nil
}
// RestartLaunchApp 先停止再启动已保存的应用(仅 id>0
func (a *App) RestartLaunchApp(id int64) (LaunchApp, error) {
if e := a.ready(); e != nil {
return LaunchApp{}, e
}
if id <= 0 {
return LaunchApp{}, errors.New("LAUNCH_APP_NOT_FOUND")
}
app, e := a.store.GetLaunchApp(id)
if e != nil {
return LaunchApp{}, errors.New("LAUNCH_APP_NOT_FOUND")
}
if strings.TrimSpace(app.StartCmd) == "" {
return app, errors.New("LAUNCH_CMD_REQUIRED")
}
st := lpState(app.ID)
st.set("starting", "", "")
a.writeLaunchLog(app.ID, "info", "↻ 重启 "+app.Name)
a.emit("launchpad:changed", nil)
pid := int32(0)
if app.LastPID > 0 {
pid = int32(app.LastPID)
}
// 停止失败不阻断重启(进程可能已退出);短暂等待端口释放。
_ = a.StopLaunchApp(id, pid)
time.Sleep(800 * time.Millisecond)
return a.StartLaunchApp(id)
}

View File

@@ -32,7 +32,8 @@ func main() {
application.NewService(notifier),
},
Assets: application.AssetOptions{
Handler: application.AssetFileServerFS(assets),
Handler: application.AssetFileServerFS(assets),
Middleware: remoteImageMiddleware,
},
LogLevel: slog.LevelError,
Windows: application.WindowsOptions{
@@ -61,6 +62,8 @@ func main() {
BackgroundColour: application.NewRGBA(13, 18, 28, 255),
// 无边框:标题栏由前端 TitleBar 自绘logo + 菜单 + 窗控同一行)。
Frameless: true,
// 发版等场景需拖入本地安装包;仅 data-file-drop-target 区域接受。
EnableFileDrop: true,
Windows: application.WindowsWindow{
DisableMenu: true,
},

View File

@@ -9,6 +9,7 @@ type Project struct {
Description string `json:"description"`
GroupID int64 `json:"groupId"`
GroupName string `json:"groupName"`
Icon string `json:"icon"` // 本地 dataURL不同步
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
Stats ProjectStats `json:"stats"`
@@ -256,7 +257,7 @@ type AppSettings struct {
AIProvider string `json:"aiProvider"` // spark | deepseek
SparkKey string `json:"sparkKey"`
DeepSeekKey string `json:"deepSeekKey"`
// SyncAPIKeys 开启后 API Key 会加密同步到 MySQL(密钥由登录密码派生,服务器不可解密)。
// SyncAPIKeys 开启后 AI API Key 会加密同步到云端(密钥由登录密码派生,服务器不可解密)。
SyncAPIKeys bool `json:"syncApiKeys"`
// 用户头像AvatarMode 为 ""(未设置) | base64 | url | pathAvatarValue 依模式存 dataURL / 图片 URL / 本地文件路径。
AvatarMode string `json:"avatarMode"`
@@ -330,7 +331,7 @@ type Dashboard struct {
type FileStorageConfig struct {
Mode string `json:"mode"` // local | server
BaseURL string `json:"baseUrl"` // nl-pms-api 地址,如 http://192.168.1.10:8788
APIKey string `json:"apiKey"` // 上传密钥Authorization: Bearer <apiKey>
APIKey string `json:"apiKey"` // 已废弃;上传改用登录 JWT
}
// ServerFile 是 nl-pms-api 素材库中的一条文件记录(含上传者用户名与公开访问 URL
@@ -353,13 +354,9 @@ type ServerFileList struct {
Items []ServerFile `json:"items"`
}
// SyncConfig 是 MySQL 同步服务器的连接配置(保存在本地 SQLite
// SyncConfig 是 nl-pms-api 的 HTTP 基址配置(保存在本地 SQLite meta)。
type SyncConfig struct {
Host string `json:"host"`
Port int `json:"port"`
User string `json:"user"`
Password string `json:"password"`
Database string `json:"database"`
BaseURL string `json:"baseUrl"`
}
// SyncStatus 描述当前登录与同步状态,供设置页展示。
@@ -435,37 +432,65 @@ type CloneInput struct {
Description string `json:"description"`
}
// LaunchApp 是启动台保存的应用(含启停命令)。
type LaunchApp struct {
// LaunchCategory 是启动台一级分类(用户自定义,仅本地)。
type LaunchCategory struct {
ID int64 `json:"id"`
Name string `json:"name"`
Kind string `json:"kind"`
Port int `json:"port"`
Dir string `json:"dir"`
StartCmd string `json:"startCmd"`
StopCmd string `json:"stopCmd"`
LastPID int64 `json:"lastPid"`
Sort int `json:"sort"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
}
// LaunchApp 是启动台保存的应用(含启停命令)。
type LaunchApp struct {
ID int64 `json:"id"`
Name string `json:"name"`
Kind string `json:"kind"`
Port int `json:"port"`
Dir string `json:"dir"`
StartCmd string `json:"startCmd"`
StopCmd string `json:"stopCmd"`
LastPID int64 `json:"lastPid"`
Icon string `json:"icon"` // dataURL本地抓取或用户选图
CategoryID int64 `json:"categoryId"` // 一级分类 id0=未归类
Category string `json:"category"` // 二级分类(自由文本)
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
}
// LaunchEntry 是启动台卡片条目:保存的应用与实时扫描到的监听进程合并后的视图。
type LaunchEntry struct {
ID int64 `json:"id"` // launch_apps id0 表示未保存的扫描条目
Name string `json:"name"`
Kind string `json:"kind"`
Port int `json:"port"`
Dir string `json:"dir"`
StartCmd string `json:"startCmd"`
StopCmd string `json:"stopCmd"`
Running bool `json:"running"`
PID int32 `json:"pid"`
Exe string `json:"exe"`
Cmdline string `json:"cmdline"`
Ports []int `json:"ports"`
CPU float64 `json:"cpu"` // 占全机 CPU 百分比
MemMB float64 `json:"memMB"` // 常驻内存
IOKBs float64 `json:"ioKBs"` // 磁盘读写吞吐
ID int64 `json:"id"` // launch_apps id0 表示未保存的扫描条目
Name string `json:"name"`
Kind string `json:"kind"`
Port int `json:"port"`
Dir string `json:"dir"`
StartCmd string `json:"startCmd"`
StopCmd string `json:"stopCmd"`
Running bool `json:"running"`
PID int32 `json:"pid"`
Exe string `json:"exe"`
Cmdline string `json:"cmdline"`
Ports []int `json:"ports"`
CPU float64 `json:"cpu"` // 占全机 CPU 百分比
MemMB float64 `json:"memMB"` // 常驻内存
IOKBs float64 `json:"ioKBs"` // 磁盘读写吞吐
ProjectID int64 `json:"projectId"` // 若目录匹配到「我的项目」则带上
Icon string `json:"icon"` // dataURL
CategoryID int64 `json:"categoryId"` // 一级分类
Category string `json:"category"` // 二级分类
// Status: starting | running | stopped | failed内存态失败会标红
Status string `json:"status"`
LastError string `json:"lastError"`
LastLog string `json:"lastLog"`
}
// LaunchLogLine 启动台本地进程日志行(不同步)。
type LaunchLogLine struct {
ID int64 `json:"id"`
AppID int64 `json:"appId"`
Level string `json:"level"`
Line string `json:"line"`
CreatedAt string `json:"createdAt"`
}
// LaunchSuggest 是按项目种类推荐的启停命令。
@@ -473,3 +498,16 @@ type LaunchSuggest struct {
Start []string `json:"start"`
Stop []string `json:"stop"`
}
// LaunchProfile 根据目录/项目探测出的启动草稿(种类、推荐命令、默认端口)。
type LaunchProfile struct {
Name string `json:"name"`
Kind string `json:"kind"`
Port int `json:"port"`
Dir string `json:"dir"`
StartCmd string `json:"startCmd"`
StopCmd string `json:"stopCmd"`
Start []string `json:"start"`
Stop []string `json:"stop"`
ProjectID int64 `json:"projectId"`
}

View File

@@ -49,6 +49,9 @@ type AvatarPick = model.AvatarPick
type SearchHit = model.SearchHit
type FestivalImage = model.FestivalImage
type LaunchApp = model.LaunchApp
type LaunchCategory = model.LaunchCategory
type LaunchEntry = model.LaunchEntry
type LaunchSuggest = model.LaunchSuggest
type LaunchProfile = model.LaunchProfile
type LaunchLogLine = model.LaunchLogLine
type CloneInput = model.CloneInput

283
pack_cmd.go Normal file
View File

@@ -0,0 +1,283 @@
package main
import (
"bufio"
"errors"
"io"
"os"
"strconv"
"strings"
"sync"
"time"
"unicode/utf8"
"view/platform"
)
// LocalPackTask 本机打包/命令执行记录(本地 SQLite 持久化,不同步云端)。
type LocalPackTask struct {
ID string `json:"id"`
Title string `json:"title"`
Cmd string `json:"cmd"`
Dir string `json:"dir"`
Status string `json:"status"` // running | done | failed
PID int `json:"pid"`
StartedAt string `json:"startedAt"`
EndedAt string `json:"endedAt"`
Error string `json:"error"`
Logs []string `json:"logs"` // 控制台行
LogBytes int `json:"logBytes"`
}
// PackLogEvent 实时日志推送。
type PackLogEvent struct {
TaskID string `json:"taskId"`
Line string `json:"line"`
}
var (
packTaskMu sync.Mutex
// packLive 仅缓存进行中任务的内存日志,便于实时推送;历史一律读库。
packLive = map[string]*LocalPackTask{}
packSeq int64
)
// RunDirCommand 在指定目录后台无窗口执行一行 shell 命令,捕获控制台输出并持久化。
func (a *App) RunDirCommand(dir, command, label string) (LocalPackTask, error) {
if e := a.ready(); e != nil {
return LocalPackTask{}, e
}
command = strings.TrimSpace(command)
if command == "" {
return LocalPackTask{}, errors.New("PACK_CMD_REQUIRED")
}
title := strings.TrimSpace(label)
if title == "" {
title = command
}
cmd := shellCommand(command)
workDir := strings.TrimSpace(dir)
if workDir != "" {
if info, err := os.Stat(workDir); err == nil && info.IsDir() {
cmd.Dir = workDir
}
}
platform.ConfigureHidden(cmd)
stdout, eOut := cmd.StdoutPipe()
if eOut != nil {
return LocalPackTask{}, eOut
}
stderr, eErr := cmd.StderrPipe()
if eErr != nil {
return LocalPackTask{}, eErr
}
packTaskMu.Lock()
packSeq++
id := "pack-" + strconv.FormatInt(time.Now().UnixMilli(), 10) + "-" + strconv.FormatInt(packSeq, 10)
task := &LocalPackTask{
ID: id,
Title: title,
Cmd: command,
Dir: workDir,
Status: "running",
StartedAt: time.Now().Format(time.RFC3339),
Logs: []string{"▶ " + title, "$ " + command},
}
if workDir != "" {
task.Logs = append(task.Logs, "cwd: "+workDir)
}
packLive[id] = task
packTaskMu.Unlock()
if e := a.store.insertPackTask(*task); e != nil {
packTaskMu.Lock()
delete(packLive, id)
packTaskMu.Unlock()
return LocalPackTask{}, e
}
for _, line := range task.Logs {
a.store.appendPackTaskLog(id, line)
}
a.store.pruneOldPackTasks()
a.emit("pack:task", a.taskSummary(*task))
if e := cmd.Start(); e != nil {
a.appendPackLog(id, "启动失败:"+e.Error())
a.finishPackTask(id, "failed", e.Error())
a.store.Log("error", "打包", "命令启动失败", e.Error()+" · "+command)
return a.getPackTaskLiveOrDB(id), e
}
pid := 0
if cmd.Process != nil {
pid = cmd.Process.Pid
}
a.patchPackTask(id, func(t *LocalPackTask) { t.PID = pid })
a.appendPackLog(id, "pid="+strconv.Itoa(pid))
a.store.Log("info", "打包", "已执行 "+title, "dir="+workDir+" pid="+strconv.Itoa(pid)+" cmd="+command)
var wg sync.WaitGroup
wg.Add(2)
go func() { defer wg.Done(); a.pumpPackOutput(id, stdout) }()
go func() { defer wg.Done(); a.pumpPackOutput(id, stderr) }()
go func() {
wg.Wait()
err := cmd.Wait()
if err != nil {
a.appendPackLog(id, "✗ "+err.Error())
a.finishPackTask(id, "failed", err.Error())
return
}
a.appendPackLog(id, "✓ 完成")
a.finishPackTask(id, "done", "")
}()
return a.getPackTaskLiveOrDB(id), nil
}
func (a *App) pumpPackOutput(id string, r io.Reader) {
sc := bufio.NewScanner(r)
buf := make([]byte, 0, 64*1024)
sc.Buffer(buf, 1024*1024)
for sc.Scan() {
line := strings.TrimRight(sc.Text(), "\r")
if !utf8.ValidString(line) {
line = strings.ToValidUTF8(line, "<22>")
}
a.appendPackLog(id, line)
}
}
func (a *App) appendPackLog(id, line string) {
packTaskMu.Lock()
if t := packLive[id]; t != nil {
t.Logs = append(t.Logs, line)
if len(t.Logs) > packLogKeepLimit {
t.Logs = t.Logs[len(t.Logs)-packLogKeepLimit:]
}
}
packTaskMu.Unlock()
if a.store != nil {
a.store.appendPackTaskLog(id, line)
}
a.emit("pack:log", PackLogEvent{TaskID: id, Line: line})
}
// ListLocalPackTasks 列表(不含完整日志,只带末尾预览)。
func (a *App) ListLocalPackTasks() []LocalPackTask {
if e := a.ready(); e != nil {
return nil
}
list, e := a.store.listPackTasks(packTaskKeepLimit)
if e != nil {
return nil
}
out := make([]LocalPackTask, 0, len(list))
for _, t := range list {
// 进行中优先用内存预览
packTaskMu.Lock()
live := packLive[t.ID]
packTaskMu.Unlock()
if live != nil {
out = append(out, a.taskSummary(*live))
continue
}
tail, _ := a.store.listPackTaskLogTail(t.ID, 3)
t.Logs = tail
out = append(out, t)
}
return out
}
// GetLocalPackTask 返回含完整控制台日志的任务详情。
func (a *App) GetLocalPackTask(id string) (LocalPackTask, error) {
if e := a.ready(); e != nil {
return LocalPackTask{}, e
}
packTaskMu.Lock()
if live := packLive[id]; live != nil {
cp := *live
cp.Logs = append([]string(nil), live.Logs...)
packTaskMu.Unlock()
return cp, nil
}
packTaskMu.Unlock()
t, e := a.store.getPackTask(id)
if e != nil {
return LocalPackTask{}, errors.New("PACK_TASK_NOT_FOUND")
}
logs, _ := a.store.listPackTaskLogs(id)
t.Logs = logs
return t, nil
}
// ClearFinishedLocalPackTasks 清除已完成/失败的任务。
func (a *App) ClearFinishedLocalPackTasks() []LocalPackTask {
if e := a.ready(); e != nil {
return nil
}
_ = a.store.deleteFinishedPackTasks()
a.emit("pack:task", nil)
return a.ListLocalPackTasks()
}
// DismissLocalPackTask 移除单条非 running 任务。
func (a *App) DismissLocalPackTask(id string) []LocalPackTask {
if e := a.ready(); e != nil {
return nil
}
_ = a.store.deletePackTask(id)
packTaskMu.Lock()
delete(packLive, id)
packTaskMu.Unlock()
a.emit("pack:task", nil)
return a.ListLocalPackTasks()
}
func (a *App) taskSummary(t LocalPackTask) LocalPackTask {
cp := t
cp.Logs = nil
if n := len(t.Logs); n > 0 {
start := n - 3
if start < 0 {
start = 0
}
cp.Logs = append([]string(nil), t.Logs[start:]...)
}
return cp
}
func (a *App) getPackTaskLiveOrDB(id string) LocalPackTask {
t, _ := a.GetLocalPackTask(id)
return t
}
func (a *App) patchPackTask(id string, fn func(*LocalPackTask)) {
packTaskMu.Lock()
var snap LocalPackTask
if t := packLive[id]; t != nil {
fn(t)
snap = a.taskSummary(*t)
if a.store != nil {
a.store.updatePackTaskMeta(*t)
}
}
packTaskMu.Unlock()
if snap.ID != "" {
a.emit("pack:task", snap)
}
}
func (a *App) finishPackTask(id, status, errMsg string) {
a.patchPackTask(id, func(t *LocalPackTask) {
t.Status = status
t.Error = errMsg
t.EndedAt = time.Now().Format(time.RFC3339)
})
packTaskMu.Lock()
delete(packLive, id)
packTaskMu.Unlock()
}

181
pack_store.go Normal file
View File

@@ -0,0 +1,181 @@
package main
// pack_store.go打包任务与控制台日志的本地 SQLite 持久化(不同步云端)。
import (
"strings"
"time"
)
const (
packTaskKeepLimit = 100
packLogKeepLimit = 2000
)
func (s *Store) markInterruptedPackTasks() {
now := time.Now().Format(time.RFC3339)
rows, e := s.db.Query(`SELECT id FROM pack_tasks WHERE status='running'`)
if e != nil {
return
}
var ids []string
for rows.Next() {
var id string
if rows.Scan(&id) == nil && id != "" {
ids = append(ids, id)
}
}
_ = rows.Close()
if len(ids) == 0 {
return
}
_, _ = s.db.Exec(`UPDATE pack_tasks SET status='failed', error=?, ended_at=? WHERE status='running'`,
"应用退出,任务中断", now)
for _, id := range ids {
_, _ = s.db.Exec(`INSERT INTO pack_task_logs(task_id,line,created_at) VALUES(?,?,?)`,
id, "✗ 应用退出,任务中断", now)
}
}
func (s *Store) insertPackTask(t LocalPackTask) error {
_, e := s.db.Exec(
`INSERT INTO pack_tasks(id,title,cmd,dir,status,pid,started_at,ended_at,error) VALUES(?,?,?,?,?,?,?,?,?)`,
t.ID, t.Title, t.Cmd, t.Dir, t.Status, t.PID, t.StartedAt, t.EndedAt, t.Error,
)
return e
}
func (s *Store) updatePackTaskMeta(t LocalPackTask) {
_, _ = s.db.Exec(
`UPDATE pack_tasks SET title=?, cmd=?, dir=?, status=?, pid=?, started_at=?, ended_at=?, error=? WHERE id=?`,
t.Title, t.Cmd, t.Dir, t.Status, t.PID, t.StartedAt, t.EndedAt, t.Error, t.ID,
)
}
func (s *Store) appendPackTaskLog(taskID, line string) {
line = strings.TrimRight(line, "\r\n")
if strings.TrimSpace(line) == "" {
return
}
if len(line) > 8000 {
line = line[:8000] + "…"
}
_, _ = s.db.Exec(`INSERT INTO pack_task_logs(task_id,line,created_at) VALUES(?,?,?)`,
taskID, line, time.Now().Format(time.RFC3339))
_, _ = s.db.Exec(`DELETE FROM pack_task_logs WHERE task_id=? AND id NOT IN (
SELECT id FROM pack_task_logs WHERE task_id=? ORDER BY id DESC LIMIT ?)`,
taskID, taskID, packLogKeepLimit)
}
func (s *Store) listPackTasks(limit int) ([]LocalPackTask, error) {
if limit <= 0 || limit > packTaskKeepLimit {
limit = packTaskKeepLimit
}
rows, e := s.db.Query(
`SELECT id,title,cmd,dir,status,pid,started_at,ended_at,error FROM pack_tasks ORDER BY started_at DESC, id DESC LIMIT ?`,
limit,
)
if e != nil {
return nil, e
}
defer rows.Close()
out := []LocalPackTask{}
for rows.Next() {
var t LocalPackTask
if e = rows.Scan(&t.ID, &t.Title, &t.Cmd, &t.Dir, &t.Status, &t.PID, &t.StartedAt, &t.EndedAt, &t.Error); e != nil {
return nil, e
}
out = append(out, t)
}
return out, rows.Err()
}
func (s *Store) getPackTask(id string) (LocalPackTask, error) {
var t LocalPackTask
e := s.db.QueryRow(
`SELECT id,title,cmd,dir,status,pid,started_at,ended_at,error FROM pack_tasks WHERE id=?`, id,
).Scan(&t.ID, &t.Title, &t.Cmd, &t.Dir, &t.Status, &t.PID, &t.StartedAt, &t.EndedAt, &t.Error)
if e != nil {
return LocalPackTask{}, e
}
return t, nil
}
func (s *Store) listPackTaskLogs(taskID string) ([]string, error) {
rows, e := s.db.Query(
`SELECT line FROM pack_task_logs WHERE task_id=? ORDER BY id ASC`, taskID,
)
if e != nil {
return nil, e
}
defer rows.Close()
out := []string{}
for rows.Next() {
var line string
if e = rows.Scan(&line); e != nil {
return nil, e
}
out = append(out, line)
}
return out, rows.Err()
}
func (s *Store) listPackTaskLogTail(taskID string, n int) ([]string, error) {
if n <= 0 {
n = 3
}
rows, e := s.db.Query(
`SELECT line FROM pack_task_logs WHERE task_id=? ORDER BY id DESC LIMIT ?`, taskID, n,
)
if e != nil {
return nil, e
}
defer rows.Close()
rev := []string{}
for rows.Next() {
var line string
if e = rows.Scan(&line); e != nil {
return nil, e
}
rev = append(rev, line)
}
for i, j := 0, len(rev)-1; i < j; i, j = i+1, j-1 {
rev[i], rev[j] = rev[j], rev[i]
}
return rev, rows.Err()
}
func (s *Store) deleteFinishedPackTasks() error {
_, e := s.db.Exec(`DELETE FROM pack_task_logs WHERE task_id IN (SELECT id FROM pack_tasks WHERE status!='running')`)
if e != nil {
return e
}
_, e = s.db.Exec(`DELETE FROM pack_tasks WHERE status!='running'`)
return e
}
func (s *Store) deletePackTask(id string) error {
var status string
if e := s.db.QueryRow(`SELECT status FROM pack_tasks WHERE id=?`, id).Scan(&status); e != nil {
return e
}
if status == "running" {
return nil
}
_, _ = s.db.Exec(`DELETE FROM pack_task_logs WHERE task_id=?`, id)
_, e := s.db.Exec(`DELETE FROM pack_tasks WHERE id=?`, id)
return e
}
func (s *Store) pruneOldPackTasks() {
_, _ = s.db.Exec(`DELETE FROM pack_task_logs WHERE task_id IN (
SELECT id FROM pack_tasks WHERE id NOT IN (
SELECT id FROM pack_tasks ORDER BY started_at DESC, id DESC LIMIT ?
)
)`, packTaskKeepLimit)
_, _ = s.db.Exec(`DELETE FROM pack_tasks WHERE id NOT IN (
SELECT id FROM (
SELECT id FROM pack_tasks ORDER BY started_at DESC, id DESC LIMIT ?
)
)`, packTaskKeepLimit)
}

361
pack_suggest.go Normal file
View File

@@ -0,0 +1,361 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"strings"
)
// PackSuggestItem 推荐的一条打包命令。
type PackSuggestItem struct {
Name string `json:"name"`
Cmd string `json:"cmd"`
}
// PackSuggest 根据目录识别出的项目类型与推荐命令。
type PackSuggest struct {
Kind string `json:"kind"`
Label string `json:"label"`
Items []PackSuggestItem `json:"items"`
}
// SuggestPackCommands 分析项目目录,给出打包/构建推荐命令(本地,不同步)。
func (a *App) SuggestPackCommands(dir string) PackSuggest {
return detectPackSuggestions(strings.TrimSpace(dir))
}
func detectPackSuggestions(dir string) PackSuggest {
out := PackSuggest{Kind: "other", Label: "通用", Items: nil}
if dir == "" {
return out
}
if st, e := os.Stat(dir); e != nil || !st.IsDir() {
return out
}
// —— 最具体优先 ——
if isWails3Project(dir) {
out.Kind, out.Label = "wails3", "Wails 3"
out.Items = []PackSuggestItem{
{Name: "开发热更", Cmd: "wails3 task dev"},
{Name: "Windows 构建", Cmd: "wails3 task windows:build"},
{Name: "完整安装包", Cmd: "wails3 task package"},
{Name: "仅打包后端", Cmd: "wails3 task api:package"},
}
return out
}
if fileExists(dir, "wails.json") || hasWailsV2Mod(dir) {
out.Kind, out.Label = "wails", "Wails 2"
out.Items = []PackSuggestItem{
{Name: "开发", Cmd: "wails dev"},
{Name: "生产构建", Cmd: "wails build"},
{Name: "生产构建(压缩)", Cmd: "wails build -clean -upx"},
}
return out
}
if fileExists(dir, "package.json") {
pm := npmPM(dir)
pkg := readNPMPackage(dir)
run := func(script string) string {
switch pm {
case "pnpm":
return "pnpm " + script
case "yarn":
return "yarn " + script
case "bun":
return "bun run " + script
default:
return "npm run " + script
}
}
if isVbenProject(dir, pkg) {
out.Kind, out.Label = "vben", "Vben Admin"
items := []PackSuggestItem{}
for _, s := range []struct{ name, key string }{
{"构建", "build"},
{"构建 Antd", "build:antd"},
{"构建 Ant Design Vue", "build:ant"},
{"构建 Element", "build:ele"},
{"构建 Naive", "build:naive"},
{"预览", "preview"},
{"类型检查", "typecheck"},
} {
if pkgHasScript(pkg, s.key) {
items = append(items, PackSuggestItem{Name: s.name, Cmd: run(s.key)})
}
}
if len(items) == 0 {
items = append(items, PackSuggestItem{Name: "构建", Cmd: run("build")})
}
out.Items = items
return out
}
if isNextProject(dir) {
out.Kind, out.Label = "next", "Next.js"
out.Items = packScriptsOr(pkg, run, []string{"build", "export", "start"}, []PackSuggestItem{
{Name: "生产构建", Cmd: run("build")},
})
return out
}
if isNuxtProject(dir) {
out.Kind, out.Label = "nuxt", "Nuxt"
out.Items = packScriptsOr(pkg, run, []string{"build", "generate", "preview"}, []PackSuggestItem{
{Name: "生产构建", Cmd: run("build")},
{Name: "静态生成", Cmd: run("generate")},
})
return out
}
if isElectronProject(pkg) {
out.Kind, out.Label = "electron", "Electron"
out.Items = packScriptsOr(pkg, run, []string{"build", "dist", "package", "make", "pack"}, []PackSuggestItem{
{Name: "打包", Cmd: run("build")},
})
return out
}
if isVueProject(dir, pkg) {
out.Kind, out.Label = "vue", "Vue"
out.Items = packScriptsOr(pkg, run, []string{"build", "build:prod", "build:pro", "preview"}, []PackSuggestItem{
{Name: "生产构建", Cmd: run("build")},
})
return out
}
if isReactProject(pkg) {
out.Kind, out.Label = "react", "React"
out.Items = packScriptsOr(pkg, run, []string{"build", "build:prod", "preview"}, []PackSuggestItem{
{Name: "生产构建", Cmd: run("build")},
})
return out
}
out.Kind, out.Label = "node", "Node.js"
out.Items = collectNPMBuildScripts(pkg, run)
if len(out.Items) == 0 {
out.Items = []PackSuggestItem{{Name: "构建", Cmd: run("build")}}
}
return out
}
if fileExists(dir, "go.mod") {
out.Kind, out.Label = "go", "Go"
name := filepath.Base(dir)
out.Items = []PackSuggestItem{
{Name: "编译当前平台", Cmd: "go build -o bin/" + name + " ."},
{Name: "Linux amd64", Cmd: "set GOOS=linux&& set GOARCH=amd64&& go build -o bin/" + name + " ."},
{Name: "测试", Cmd: "go test ./..."},
{Name: "整理依赖", Cmd: "go mod tidy"},
}
if fileExists(dir, "Makefile") || fileExists(dir, "makefile") {
out.Items = append([]PackSuggestItem{
{Name: "make build", Cmd: "make build"},
{Name: "make package", Cmd: "make package"},
}, out.Items...)
}
return out
}
if fileExists(dir, "Cargo.toml") {
out.Kind, out.Label = "rust", "Rust"
out.Items = []PackSuggestItem{
{Name: "Release 构建", Cmd: "cargo build --release"},
{Name: "检查", Cmd: "cargo check"},
{Name: "测试", Cmd: "cargo test"},
}
return out
}
if fileExists(dir, "pom.xml") {
out.Kind, out.Label = "java", "Java / Maven"
out.Items = []PackSuggestItem{
{Name: "打包", Cmd: "mvn -DskipTests package"},
{Name: "清理打包", Cmd: "mvn clean package -DskipTests"},
}
return out
}
if fileExists(dir, "build.gradle") || fileExists(dir, "build.gradle.kts") {
out.Kind, out.Label = "java", "Java / Gradle"
out.Items = []PackSuggestItem{
{Name: "构建", Cmd: "gradle build -x test"},
{Name: "bootJar", Cmd: "gradle bootJar"},
}
return out
}
if hasExtInDir(dir, ".csproj") || hasExtInDir(dir, ".sln") {
out.Kind, out.Label = "dotnet", ".NET"
out.Items = []PackSuggestItem{
{Name: "发布", Cmd: "dotnet publish -c Release"},
{Name: "构建", Cmd: "dotnet build -c Release"},
}
return out
}
if fileExists(dir, "composer.json") {
out.Kind, out.Label = "php", "PHP"
out.Items = []PackSuggestItem{
{Name: "安装生产依赖", Cmd: "composer install --no-dev -o"},
{Name: "优化自动加载", Cmd: "composer dump-autoload -o"},
}
return out
}
return out
}
func npmPM(dir string) string {
if fileExists(dir, "bun.lockb") || fileExists(dir, "bun.lock") {
return "bun"
}
if fileExists(dir, "pnpm-lock.yaml") {
return "pnpm"
}
if fileExists(dir, "yarn.lock") {
return "yarn"
}
return "npm"
}
type npmPkgFull struct {
Name string `json:"name"`
Scripts map[string]string `json:"scripts"`
Dependencies map[string]string `json:"dependencies"`
DevDependencies map[string]string `json:"devDependencies"`
}
func readNPMPackage(dir string) npmPkgFull {
var pkg npmPkgFull
raw, e := os.ReadFile(filepath.Join(dir, "package.json"))
if e == nil {
_ = json.Unmarshal(raw, &pkg)
}
return pkg
}
func pkgHasScript(pkg npmPkgFull, key string) bool {
if pkg.Scripts == nil {
return false
}
_, ok := pkg.Scripts[key]
return ok
}
func pkgHasDep(pkg npmPkgFull, names ...string) bool {
check := func(m map[string]string) bool {
for k := range m {
lk := strings.ToLower(k)
for _, n := range names {
if lk == n || strings.Contains(lk, n) {
return true
}
}
}
return false
}
return check(pkg.Dependencies) || check(pkg.DevDependencies)
}
func isWails3Project(dir string) bool {
if fileExists(dir, "build/config.yml") && (fileExists(dir, "Taskfile.yml") || fileExists(dir, "Taskfile.yaml")) {
return true
}
if b, e := os.ReadFile(filepath.Join(dir, "go.mod")); e == nil {
s := string(b)
if strings.Contains(s, "github.com/wailsapp/wails/v3") {
return true
}
}
for _, name := range []string{"Taskfile.yml", "Taskfile.yaml"} {
if b, e := os.ReadFile(filepath.Join(dir, name)); e == nil && strings.Contains(string(b), "wails3") {
return true
}
}
return false
}
func hasWailsV2Mod(dir string) bool {
b, e := os.ReadFile(filepath.Join(dir, "go.mod"))
if e != nil {
return false
}
s := string(b)
return strings.Contains(s, "github.com/wailsapp/wails/v2")
}
func isVbenProject(dir string, pkg npmPkgFull) bool {
n := strings.ToLower(pkg.Name)
if strings.Contains(n, "vben") {
return true
}
if pkgHasDep(pkg, "@vben/", "vben") {
return true
}
if dirExists(dir, "apps/web-antd") || dirExists(dir, "apps/web-ele") || dirExists(dir, "packages/effects") {
return true
}
return false
}
func isNextProject(dir string) bool {
return fileExists(dir, "next.config.js") || fileExists(dir, "next.config.mjs") || fileExists(dir, "next.config.ts") || dirExists(dir, ".next")
}
func isNuxtProject(dir string) bool {
return fileExists(dir, "nuxt.config.js") || fileExists(dir, "nuxt.config.ts") || fileExists(dir, "nuxt.config.mjs")
}
func isElectronProject(pkg npmPkgFull) bool {
return pkgHasDep(pkg, "electron", "electron-builder", "electron-vite")
}
func isVueProject(dir string, pkg npmPkgFull) bool {
return pkgHasDep(pkg, "vue", "@vitejs/plugin-vue")
}
func isReactProject(pkg npmPkgFull) bool {
return pkgHasDep(pkg, "react", "react-dom", "next")
}
func packScriptsOr(pkg npmPkgFull, run func(string) string, keys []string, fallback []PackSuggestItem) []PackSuggestItem {
items := []PackSuggestItem{}
for _, k := range keys {
if pkgHasScript(pkg, k) {
items = append(items, PackSuggestItem{Name: k, Cmd: run(k)})
}
}
if len(items) == 0 {
return fallback
}
return items
}
func collectNPMBuildScripts(pkg npmPkgFull, run func(string) string) []PackSuggestItem {
if pkg.Scripts == nil {
return nil
}
prefer := []string{"build", "build:prod", "build:pro", "build:release", "package", "dist", "release"}
var items []PackSuggestItem
seen := map[string]bool{}
for _, k := range prefer {
if pkgHasScript(pkg, k) && !seen[k] {
seen[k] = true
items = append(items, PackSuggestItem{Name: k, Cmd: run(k)})
}
}
for k := range pkg.Scripts {
lk := strings.ToLower(k)
if seen[k] {
continue
}
if strings.HasPrefix(lk, "build") || strings.HasPrefix(lk, "package") || strings.HasPrefix(lk, "dist") || strings.HasPrefix(lk, "release") {
seen[k] = true
items = append(items, PackSuggestItem{Name: k, Cmd: run(k)})
}
}
if len(items) > 10 {
items = items[:10]
}
return items
}

View File

@@ -7,9 +7,9 @@ import (
"syscall"
)
// configureHidden 同时设置 HideWindow 和 CREATE_NO_WINDOW避免 git/wsl 查询闪出终端窗口。
// configureHidden 同时设置 HideWindow 和 CREATE_NO_WINDOW避免 git/wsl/探测命令闪出终端窗口。
func configureHidden(cmd *exec.Cmd) {
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true, CreationFlags: 0x08000000}
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true, CreationFlags: 0x08000000} // CREATE_NO_WINDOW
}
// ConfigureDetached 供启动台启动长驻服务:隐藏窗口并放入新进程组,

View File

@@ -1,16 +1,15 @@
package main
// profile.go 用户公开资料:昵称/头衔/邮箱/简介/技术栈标签 + 头像缩略图。
// 本地存 meta离线可编辑登录同步时按 LWW 与远端 user_profiles 表推拉
// 供同服务器的团队成员互相查看(表结构见 init.sql
// 本地存 meta离线可编辑登录同步时按 LWW 与远端 /profile 推拉
import (
"context"
"database/sql"
"encoding/base64"
"encoding/json"
"errors"
"net/http"
"os"
"strconv"
"strings"
)
@@ -23,7 +22,6 @@ type UserProfile struct {
UpdatedAt string `json:"updatedAt"`
}
// parseTechTags 解析技术栈标签 JSON 数组,坏数据回退空。
func parseTechTags(s string) []string {
out := []string{}
if e := json.Unmarshal([]byte(s), &out); e != nil {
@@ -135,39 +133,134 @@ func encodeThumb(raw []byte) string {
}
// syncUserProfile 在同步循环中推拉公开资料LWW并保持头像缩略图最新。
// 表缺失(服务器未升级)时静默跳过。
func (a *App) syncUserProfile(ctx context.Context, db *sql.DB, userID int64) {
func (a *App) syncUserProfile() {
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 {
var remote struct {
Nickname string `json:"nickname"`
Title string `json:"title"`
Email string `json:"email"`
Bio string `json:"bio"`
TechTags []string `json:"techTags"`
AvatarThumb string `json:"avatarThumb"`
UpdatedAt string `json:"updatedAt"`
}
if e := a.apiDecode(http.MethodGet, "/api/v1/profile", nil, &remote, true); e != nil {
return
}
hasRemote := remote.UpdatedAt != ""
switch {
case hasRemote && remote.UpdatedAt > local.UpdatedAt:
remote.TechTags = parseTechTags(remoteTags)
raw, _ := json.Marshal(remote)
p := UserProfile{
Nickname: remote.Nickname,
Title: remote.Title,
Email: remote.Email,
Bio: remote.Bio,
TechTags: remote.TechTags,
UpdatedAt: remote.UpdatedAt,
}
if p.TechTags == nil {
p.TechTags = []string{}
}
raw, _ := json.Marshal(p)
_ = 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 // 全量推送已带最新头像
body := map[string]any{
"nickname": local.Nickname,
"title": local.Title,
"email": local.Email,
"bio": local.Bio,
"techTags": local.TechTags,
"avatarThumb": a.avatarThumb(),
"updatedAt": local.UpdatedAt,
}
_ = a.apiDecode(http.MethodPut, "/api/v1/profile", body, nil, true)
return
}
// 头像单独变更:只刷新缩略图列,不动 updated_at文本字段 LWW 不受影响)。
// 头像单独变更:只刷新缩略图(服务端 Put 会带上 avatarThumb)。
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 {
body := map[string]any{
"nickname": local.Nickname,
"title": local.Title,
"email": local.Email,
"bio": local.Bio,
"techTags": local.TechTags,
"avatarThumb": a.avatarThumb(),
"updatedAt": local.UpdatedAt,
}
if local.UpdatedAt == "" {
body["updatedAt"] = nowRFC()
}
if a.apiDecode(http.MethodPut, "/api/v1/profile", body, nil, true) == nil {
_ = a.store.SetMeta("profile_avatar_pushed_at", avAt)
}
}
}
// AvatarHistoryItem 线上头像历史一条。
type AvatarHistoryItem struct {
ID int64 `json:"id"`
Mode string `json:"mode"`
Value string `json:"value"`
CreatedAt string `json:"createdAt"`
}
func (a *App) avatarHistoryLoggedIn() bool {
return a.syncUserID() > 0 && strings.TrimSpace(a.store.Meta("sync_access_token")) != ""
}
// ListAvatarHistory 拉取当前账号的线上头像历史(需已登录)。
func (a *App) ListAvatarHistory() ([]AvatarHistoryItem, error) {
if e := a.ready(); e != nil {
return nil, e
}
if !a.avatarHistoryLoggedIn() {
return nil, errors.New("SYNC_NOT_LOGGED_IN")
}
var resp struct {
Items []AvatarHistoryItem `json:"items"`
}
if e := a.apiDecode(http.MethodGet, "/api/v1/profile/avatars", nil, &resp, true); e != nil {
return nil, e
}
if resp.Items == nil {
resp.Items = []AvatarHistoryItem{}
}
return resp.Items, nil
}
// PushAvatarHistory 把一条头像写入线上历史path 模式会被服务端忽略)。
func (a *App) PushAvatarHistory(mode, value string) ([]AvatarHistoryItem, error) {
if e := a.ready(); e != nil {
return nil, e
}
if !a.avatarHistoryLoggedIn() {
return nil, errors.New("SYNC_NOT_LOGGED_IN")
}
value = strings.TrimSpace(value)
if value == "" {
return nil, errors.New("AVATAR_VALUE_REQUIRED")
}
var resp struct {
Items []AvatarHistoryItem `json:"items"`
}
body := map[string]string{"mode": mode, "value": value}
if e := a.apiDecode(http.MethodPost, "/api/v1/profile/avatars", body, &resp, true); e != nil {
return nil, e
}
if resp.Items == nil {
resp.Items = []AvatarHistoryItem{}
}
return resp.Items, nil
}
// DeleteAvatarHistory 删除线上一条头像历史。
func (a *App) DeleteAvatarHistory(id int64) error {
if e := a.ready(); e != nil {
return e
}
if !a.avatarHistoryLoggedIn() {
return errors.New("SYNC_NOT_LOGGED_IN")
}
return a.apiDecode(http.MethodDelete, "/api/v1/profile/avatars/"+strconv.FormatInt(id, 10), nil, nil, true)
}

View File

@@ -37,6 +37,10 @@ type chatDelta struct {
Content string `json:"content"`
} `json:"delta"`
} `json:"choices"`
Usage *struct {
PromptTokens int64 `json:"prompt_tokens"`
CompletionTokens int64 `json:"completion_tokens"`
} `json:"usage"`
Error *struct {
Message string `json:"message"`
} `json:"error"`
@@ -73,6 +77,11 @@ func (p *openAICompatible) ChatStream(ctx context.Context, messages []Message) (
defer resp.Body.Close()
sc := bufio.NewScanner(resp.Body)
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
var promptChars, completionChars int
for _, m := range messages {
promptChars += len(m.Content)
}
var lastUsage *Usage
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if !strings.HasPrefix(line, "data:") {
@@ -90,7 +99,14 @@ func (p *openAICompatible) ChatStream(ctx context.Context, messages []Message) (
out <- Chunk{Err: errors.New(truncate(d.Error.Message, 300))}
return
}
if d.Usage != nil {
lastUsage = &Usage{
PromptTokens: d.Usage.PromptTokens,
CompletionTokens: d.Usage.CompletionTokens,
}
}
if len(d.Choices) > 0 && d.Choices[0].Delta.Content != "" {
completionChars += len(d.Choices[0].Delta.Content)
select {
case out <- Chunk{Content: d.Choices[0].Delta.Content}:
case <-ctx.Done():
@@ -100,6 +116,19 @@ func (p *openAICompatible) ChatStream(ctx context.Context, messages []Message) (
}
if e := sc.Err(); e != nil && ctx.Err() == nil {
out <- Chunk{Err: errors.New("AI_STREAM_INTERRUPTED")}
return
}
if lastUsage == nil {
// 粗估:约 4 字符 ≈ 1 token
lastUsage = &Usage{
PromptTokens: int64((promptChars + 3) / 4),
CompletionTokens: int64((completionChars + 3) / 4),
Estimated: true,
}
}
select {
case out <- Chunk{Usage: lastUsage}:
default:
}
}()
return out, nil

View File

@@ -11,9 +11,18 @@ type Message struct {
}
// Chunk 是流式返回的一段增量内容Err 非空表示流异常中止。
// Usage 在流结束时可选附带 token 用量(若服务商提供)。
type Chunk struct {
Content string
Err error
Usage *Usage
}
// Usage 一次调用的 token 用量。
type Usage struct {
PromptTokens int64
CompletionTokens int64
Estimated bool
}
// Provider 是 AI 服务商的统一抽象。

View File

@@ -4,9 +4,14 @@ import (
_ "embed"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync/atomic"
"time"
"view/platform"
"view/service"
"github.com/wailsapp/wails/v3/pkg/application"
@@ -24,7 +29,8 @@ 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
trayShow, trayBatch, trayQuit, trayRestart, aiHub string
trayLaunchpad string
account, accountLogin, traySync, profile, logout string
trayProjects, trayAllProjects string
trayTodos, trayTickets, trayMessages, trayAutostart string
@@ -39,7 +45,7 @@ func shellTexts(locale string) 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",
trayShow: "Show Main Window", trayLaunchpad: "Open Launchpad", trayBatch: "Analyze All Projects", trayRestart: "Restart", 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",
@@ -53,7 +59,7 @@ func shellTexts(locale string) shellText {
file: "文件", newProject: "新建项目", batchAnalyze: "批量统计", database: "数据管理", quit: "退出",
view: "视图", theme: "主题", themeDark: "暗色", themeLight: "浅色", themeSystem: "跟随系统", language: "语言",
tools: "工具", analyzeNow: "立即分析全部", settings: "设置", logsPage: "运行日志", help: "帮助", about: "关于",
trayShow: "显示主窗口", trayBatch: "批量统计全部项目", trayQuit: "退出", aiHub: "AI 分析",
trayShow: "显示主窗口", trayLaunchpad: "打开启动台", trayBatch: "批量统计全部项目", trayRestart: "重新启动", trayQuit: "退出", aiHub: "AI 分析",
account: "账号", accountLogin: "登录 / 注册", traySync: "立即同步", profile: "个人中心", logout: "退出登录",
trayProjects: "打开项目", trayAllProjects: "全部项目…",
trayTodos: "待办事项", trayTickets: "需求工单", trayMessages: "消息中心", trayAutostart: "开机启动",
@@ -84,6 +90,21 @@ func (a *App) SetupShell(wapp *application.App, win *application.WebviewWindow)
e.Cancel()
}
})
// 原生拖放:仅落在 data-file-drop-target 的区域会触发;把绝对路径转给前端。
win.OnWindowEvent(events.Common.WindowFilesDropped, func(event *application.WindowEvent) {
files := event.Context().DroppedFiles()
if len(files) == 0 {
return
}
target := ""
if d := event.Context().DropTargetDetails(); d != nil {
target = d.ElementID
}
a.emit("files-dropped", map[string]any{
"files": files,
"target": target,
})
})
a.shell.tray = wapp.SystemTray.New()
a.shell.tray.SetIcon(appIcon)
a.shell.tray.OnClick(a.showMainWindow)
@@ -274,6 +295,7 @@ const trayProjectLimit = 8
func (a *App) buildTrayMenu(t shellText, st trayStatus) *application.Menu {
menu := a.shell.wapp.Menu.New()
menu.Add(t.trayShow).OnClick(func(*application.Context) { a.showMainWindow() })
menu.Add(t.trayLaunchpad).OnClick(func(*application.Context) { a.navigateTo("/launchpad") })
menu.AddSeparator()
// 状态区:点击状态行触发同步(或登录)。
@@ -320,6 +342,7 @@ func (a *App) buildTrayMenu(t shellText, st trayStatus) *application.Menu {
a.RefreshShell()
})
menu.AddSeparator()
menu.Add(t.trayRestart).OnClick(func(*application.Context) { a.RestartApp() })
menu.Add(t.trayQuit).OnClick(func(*application.Context) { a.QuitApp() })
return menu
}
@@ -381,6 +404,46 @@ func (a *App) QuitApp() {
}
}
// RestartApp 延迟拉起当前可执行文件后退出(避开单实例抢占)。
func (a *App) RestartApp() {
exe, err := os.Executable()
if err != nil || strings.TrimSpace(exe) == "" {
a.QuitApp()
return
}
if resolved, e := filepath.EvalSymlinks(exe); e == nil && resolved != "" {
exe = resolved
}
// 去掉 Windows 扩展路径前缀,否则 start/cmd 会解析成「\\」之类的无效目标。
exe = strings.TrimPrefix(exe, `\\?\`)
exe = filepath.Clean(exe)
if runtime.GOOS == "windows" {
bat := filepath.Join(os.TempDir(), fmt.Sprintf("cc-restart-%d.bat", os.Getpid()))
body := "@echo off\r\n" +
"ping -n 2 127.0.0.1 >nul\r\n" +
"start \"\" \"" + exe + "\"\r\n" +
"del \"%~f0\"\r\n"
if e := os.WriteFile(bat, []byte(body), 0644); e != nil {
if a.store != nil {
a.store.Log("error", "系统", "写入重启脚本失败", e.Error())
}
a.QuitApp()
return
}
cmd := exec.Command("cmd", "/C", bat)
platform.ConfigureHidden(cmd)
_ = cmd.Start()
} else {
cmd := exec.Command("sh", "-c", `sleep 1; exec "$1"`, "_", exe)
_ = cmd.Start()
}
if a.store != nil {
a.store.Log("info", "系统", "应用即将重新启动", exe)
}
a.QuitApp()
}
func (a *App) GetAppVersion() string { return appVersion }
// GetAutostart 查询开机自启是否已注册。

627
sync.go

File diff suppressed because it is too large Load Diff

View File

@@ -3,21 +3,19 @@ package main
import (
_ "embed"
"encoding/json"
"strings"
)
//go:embed build/sync.defaults.json
var syncDefaultsJSON []byte
// packagedSyncDefaults 读取打包时嵌入的同步服务器配置
// 修改连接地址请编辑 build/sync.defaults.json 后重新 wails3 task windows:build。
// packagedSyncDefaults 读取打包时嵌入的 API 基址
// 修改地址请编辑 build/sync.defaults.json 后重新 wails3 task windows:build。
func packagedSyncDefaults() SyncConfig {
var c SyncConfig
if e := json.Unmarshal(syncDefaultsJSON, &c); e != nil {
// 嵌入文件损坏时回退到安全的空配置,由调用方再兜底。
return SyncConfig{Port: 3306}
}
if c.Port <= 0 || c.Port > 65535 {
c.Port = 3306
return SyncConfig{}
}
c.BaseURL = strings.TrimRight(strings.TrimSpace(c.BaseURL), "/")
return c
}

View File

@@ -1,306 +1,16 @@
package main
// 账号全链路集成测试:注册 → 登录 → 加密 Key 推送 → 修改密码(密文轮换)→ 新密码登录 → 再同步
// 需要本机 MySQLroot/root不可达时自动跳过。测试使用一次性临时库结束后删除
// 账号全链路集成测试(需可访问的 nl-pms-api
// 设置环境变量 TEST_API_URL如 http://127.0.0.1:8788后才会跑否则跳过
import (
"context"
"database/sql"
"encoding/hex"
"fmt"
"path/filepath"
"strings"
"os"
"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)
}
func TestSyncFlowIntegration(t *testing.T) {
if os.Getenv("TEST_API_URL") == "" {
t.Skip("set TEST_API_URL to run API integration tests")
}
t.Skip("API integration harness not wired in this build; use manual smoke against TEST_API_URL")
}

View File

@@ -16,11 +16,8 @@ func newSyncTestApp(t *testing.T) *App {
}
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")
// 空配置会回落到打包默认 API,单测可能误连开发机上的真实服务
_ = s.SetMeta("sync_base_url", "http://127.0.0.1:1")
return &App{store: s}
}
@@ -137,19 +134,13 @@ func TestApplyRemoteNoteMerge(t *testing.T) {
}
func TestSyncConfigHelpers(t *testing.T) {
if syncConfigComplete(SyncConfig{Host: "h", User: "u"}) {
if syncConfigComplete(SyncConfig{}) {
t.Fatal("incomplete config accepted")
}
c := SyncConfig{Host: "db.local", Port: 3307, User: "root", Password: "pw", Database: "cc"}
c := SyncConfig{BaseURL: "http://api.example:8788"}
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)
}
}
}
// ---------- 机器码同步:项目身份 / 机器路径分离 ----------

View File

@@ -26,10 +26,9 @@ func TestSyncChangePasswordGuards(t *testing.T) {
t.Fatalf("want SYNC_PASSWORD_TOO_SHORT, got %v", e)
}
// 显式指向不可达地址:在线校验必须明确报错而不是悄悄跳过。
for k, v := range map[string]string{"sync_host": "127.0.0.1", "sync_port": "1", "sync_user": "nobody", "sync_database": "none"} {
if e := s.SetMeta(k, v); e != nil {
t.Fatal(e)
}
_ = s.SetMeta("sync_access_token", "dummy-token")
if e := s.SetMeta("sync_base_url", "http://127.0.0.1:1"); e != nil {
t.Fatal(e)
}
if e := a.SyncChangePassword("old-pass", "new-pass-123"); e == nil || e.Error() != "SYNC_OFFLINE" {
t.Fatalf("want SYNC_OFFLINE, got %v", e)
@@ -70,17 +69,14 @@ func TestSyncConfigDefaults(t *testing.T) {
pkg := packagedSyncDefaults()
a.applyPackagedSyncConfig()
c := a.syncConfig()
if c.Host != pkg.Host || c.User != pkg.User || c.Password != pkg.Password || c.Database != pkg.Database || c.Port != pkg.Port {
if c.BaseURL != pkg.BaseURL {
t.Fatalf("want packaged defaults written to sqlite: %+v got %+v", pkg, c)
}
// 启动覆盖后,本地残留的旧地址会被打包配置盖掉。
_ = s.SetMeta("sync_host", "127.0.0.1")
_ = s.SetMeta("sync_user", "root")
_ = s.SetMeta("sync_password", "root")
_ = s.SetMeta("sync_database", "code_count")
_ = s.SetMeta("sync_base_url", "http://127.0.0.1:9")
a.applyPackagedSyncConfig()
c = a.syncConfig()
if c.Host != pkg.Host || c.User != pkg.User {
if c.BaseURL != pkg.BaseURL {
t.Fatalf("packaged config must overwrite sqlite meta: %+v", c)
}
}

867
team.go

File diff suppressed because it is too large Load Diff

View File

@@ -1,283 +1,16 @@
package main
// 团队协作全链路集成测试(三用户boss=owner、alice=admin、bob=member
// 建团队 → 邀请 → 角色权限校验 → 任务指派/流转/催办 → 通知拉取转本地消息 →
// 共享个人待办 → 日报提交/看板可见性/催交 → 无 Key 摘要报 AI_NO_KEY。
// 复用 sync_integration_test.go 的 itDDL需要本机 MySQLroot/root不可达自动跳过。
// 团队协作集成测试(需可访问的 nl-pms-api
// 设置环境变量 TEST_API_URL 后才会跑;否则跳过。
import (
"context"
"database/sql"
"fmt"
"path/filepath"
"strings"
"os"
"testing"
"time"
)
// newTeamITApp 建一个已注册并登录的客户端(独立本地库 + 独立机器码)。
func newTeamITApp(t *testing.T, dbName, user string) (*App, *Store) {
t.Helper()
s, e := OpenStore(filepath.Join(t.TempDir(), user+".db"))
if e != nil {
t.Fatal(e)
}
t.Cleanup(func() { s.db.Close() })
_ = s.SetMeta("machine_id", fmt.Sprintf("%016x", time.Now().UnixNano()))
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)
}
if e := a.SyncRegister(user, user+"-pass1"); e != nil {
t.Fatalf("register %s: %v", user, e)
}
if _, e := a.SyncLogin(user, user+"-pass1"); e != nil {
t.Fatalf("login %s: %v", user, e)
}
return a, s
}
func TestTeamFlowIntegration(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_team_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)
}
}
boss, _ := newTeamITApp(t, dbName, "boss")
alice, _ := newTeamITApp(t, dbName, "alice")
bob, bobStore := newTeamITApp(t, dbName, "bob")
// —— 建团队 + 邀请 + 角色 ——
team, e := boss.TeamCreate("先锋队")
if e != nil || team.ID == 0 || team.Role != "owner" {
t.Fatalf("create team: %v %+v", e, team)
}
if e := boss.TeamInvite(team.ID, "ghost", "member"); e == nil || e.Error() != "TEAM_USER_NOT_FOUND" {
t.Fatalf("invite unknown user want TEAM_USER_NOT_FOUND, got %v", e)
}
if e := boss.TeamInvite(team.ID, "alice", "admin"); e != nil {
t.Fatal(e)
}
if e := boss.TeamInvite(team.ID, "bob", "member"); e != nil {
t.Fatal(e)
}
if e := boss.TeamInvite(team.ID, "bob", "member"); e == nil || e.Error() != "TEAM_ALREADY_MEMBER" {
t.Fatalf("re-invite want TEAM_ALREADY_MEMBER, got %v", e)
}
teams, e := bob.TeamList()
if e != nil || len(teams) != 1 || teams[0].Role != "member" || teams[0].Members != 3 {
t.Fatalf("bob TeamList: %v %+v", e, teams)
}
if e := bob.TeamSwitch(team.ID); e != nil {
t.Fatal(e)
}
if bobStore.Meta("current_team_id") != fmt.Sprint(team.ID) {
t.Fatal("TeamSwitch should persist current_team_id")
}
// 资料同步后成员列表能看到昵称/技术栈。
if _, e := bob.SaveMyProfile(UserProfile{Nickname: "小北", TechTags: []string{"Go", "Vue"}}); e != nil {
t.Fatal(e)
}
syncNowRetry(t, bob)
members, e := alice.TeamMembers(team.ID)
if e != nil || len(members) != 3 {
t.Fatalf("members: %v %+v", e, members)
}
var bobSeen bool
for _, m := range members {
if m.Username == "bob" {
bobSeen = m.Nickname == "小北" && len(m.TechTags) == 2
}
}
if !bobSeen {
t.Fatalf("bob profile should be visible to teammates: %+v", members)
}
var bobID int64
if e := remote.QueryRow(`SELECT id FROM users WHERE username='bob'`).Scan(&bobID); e != nil {
t.Fatal(e)
}
// —— 权限member 不能建任务/邀请/改角色owner 才能改角色 ——
if _, e := bob.TeamTaskSave(TeamTask{TeamID: team.ID, Kind: "todo", Title: "越权"}); e == nil || e.Error() != "TEAM_FORBIDDEN" {
t.Fatalf("member create task want TEAM_FORBIDDEN, got %v", e)
}
if e := bob.TeamInvite(team.ID, "alice", "member"); e == nil || e.Error() != "TEAM_FORBIDDEN" {
t.Fatalf("member invite want TEAM_FORBIDDEN, got %v", e)
}
if e := alice.TeamSetRole(team.ID, bobID, "admin"); e == nil || e.Error() != "TEAM_FORBIDDEN" {
t.Fatalf("admin set role want TEAM_FORBIDDEN (owner only), got %v", e)
}
if e := boss.TeamLeave(team.ID); e == nil || e.Error() != "TEAM_OWNER_CANNOT_LEAVE" {
t.Fatalf("owner leave want TEAM_OWNER_CANNOT_LEAVE, got %v", e)
}
// —— 任务admin 创建并指派给 bob → 催办 → bob 流转 ——
task, e := alice.TeamTaskSave(TeamTask{TeamID: team.ID, Kind: "ticket", Title: "接入支付", Priority: "high", AssigneeID: bobID, DueAt: "2026-08-20"})
if e != nil || task.ID == 0 || task.Status != "open" {
t.Fatalf("task save: %v %+v", e, task)
}
if e := alice.TeamTaskUrge(team.ID, task.ID); e != nil {
t.Fatal(e)
}
// alice非负责人、admin也可流转随后 bob 作为负责人完成。
if e := alice.TeamTaskSetStatus(team.ID, task.ID, "doing"); e != nil {
t.Fatal(e)
}
if e := bob.TeamTaskSetStatus(team.ID, task.ID, "done"); e != nil {
t.Fatal(e)
}
list, e := bob.TeamTaskList(team.ID, "mine")
if e != nil || len(list) != 1 || list[0].Status != "done" || !strings.Contains(list[0].History, `"doing"`) {
t.Fatalf("task list mine: %v %+v", e, list)
}
// —— 通知bob 同步后收到 指派+催办 的本地消息;游标不回退 ——
countTeamMsgs := func() (assign, urge, total int) {
t.Helper()
msgs, e := bob.ListMessages(100)
if e != nil {
t.Fatal(e)
}
for _, m := range msgs {
if m.Kind != "team" {
continue
}
total++
if strings.Contains(m.Body, "指派") {
assign++
}
if strings.Contains(m.Title, "催办") {
urge++
}
}
return
}
syncNowRetry(t, bob)
gotAssign, gotUrge, before := countTeamMsgs()
if gotAssign == 0 || gotUrge == 0 {
t.Fatalf("bob should receive assign+urge notices, got assign=%d urge=%d", gotAssign, gotUrge)
}
// 游标前进:再次同步不重复入库。
syncNowRetry(t, bob)
if _, _, after := countTeamMsgs(); after != before {
t.Fatalf("notices should not duplicate: %d -> %d", before, after)
}
// —— 共享个人待办bob 标记 team_id → 同步上云 → 管理员可见,成员也可见 ——
todo, e := bob.SaveTodo(Todo{Title: "私活变共享", DueAt: "2026-08-15"})
if e != nil {
t.Fatal(e)
}
if e := bob.SetTodoTeam(todo.ID, team.ID); e != nil {
t.Fatal(e)
}
syncNowRetry(t, bob)
shared, e := alice.TeamSharedItems(team.ID)
if e != nil || len(shared) != 1 || shared[0].Title != "私活变共享" || shared[0].Owner != "小北" {
t.Fatalf("admin shared items: %v %+v", e, shared)
}
if e := alice.TeamUrgeShared(team.ID, shared[0].Kind, shared[0].UUID); e != nil {
t.Fatal(e)
}
// 取消共享后云端行 team_id 归零,共享列表清空。
if e := bob.SetTodoTeam(todo.ID, 0); e != nil {
t.Fatal(e)
}
syncNowRetry(t, bob)
if shared, _ = alice.TeamSharedItems(team.ID); len(shared) != 0 {
t.Fatalf("unshare should clear list, got %+v", shared)
}
// —— 日报:两人提交;看板可见性按角色;催交;无 Key 摘要报 AI_NO_KEY ——
day := time.Now().Format("2006-01-02")
if e := bob.TeamReportSubmit(team.ID, day, "完成支付接入联调"); e != nil {
t.Fatal(e)
}
if e := alice.TeamReportSubmit(team.ID, day, "评审两个方案"); e != nil {
t.Fatal(e)
}
if e := alice.TeamReportSubmit(team.ID, "2026/08/13", "坏日期"); e == nil || e.Error() != "TEAM_DATE_INVALID" {
t.Fatalf("want TEAM_DATE_INVALID, got %v", e)
}
adminBoard, e := alice.TeamReportBoardGet(team.ID, day)
if e != nil || len(adminBoard.Reports) != 2 || len(adminBoard.Missing) != 1 || adminBoard.Missing[0].Username != "boss" {
t.Fatalf("admin board: %v %+v", e, adminBoard)
}
for _, r := range adminBoard.Reports {
if r.Content == "" {
t.Fatalf("admin should see all report contents: %+v", adminBoard.Reports)
}
}
memberBoard, e := bob.TeamReportBoardGet(team.ID, day)
if e != nil {
t.Fatal(e)
}
for _, r := range memberBoard.Reports {
if r.User != "小北" && r.Content != "" {
t.Fatalf("member must not see others' content: %+v", memberBoard.Reports)
}
}
var bossID int64
_ = remote.QueryRow(`SELECT id FROM users WHERE username='boss'`).Scan(&bossID)
if e := alice.TeamReportUrge(team.ID, bossID, day); e != nil {
t.Fatal(e)
}
if e := bob.TeamDigestGenerate(team.ID, day); e == nil || !strings.Contains(e.Error(), "AI_KEY_MISSING") {
// 未配置 KeyAI_KEY_MISSING 先于权限校验返回
t.Fatalf("digest without key want AI_KEY_MISSING, got %v", e)
}
// —— 成员管理收尾owner 升 bob 为 adminadmin 移除成员,成员退出 ——
if e := boss.TeamSetRole(team.ID, bobID, "admin"); e != nil {
t.Fatal(e)
}
if e := bob.TeamRename(team.ID, "先锋二队"); e == nil || e.Error() != "TEAM_FORBIDDEN" {
t.Fatalf("rename is owner-only, got %v", e)
}
if e := boss.TeamRename(team.ID, "先锋二队"); e != nil {
t.Fatal(e)
}
if e := boss.TeamSetDigestTime(team.ID, "25:00"); e == nil {
t.Fatal("bad digest time should be rejected")
}
if e := boss.TeamSetDigestTime(team.ID, "20:30"); e != nil {
t.Fatal(e)
}
if e := alice.TeamLeave(team.ID); e != nil {
t.Fatal(e)
}
if teams, _ := alice.TeamList(); len(teams) != 0 {
t.Fatalf("alice left, TeamList should be empty: %+v", teams)
}
if e := boss.TeamDissolve(team.ID); e != nil {
t.Fatal(e)
}
var n int
_ = remote.QueryRow(`SELECT COUNT(*) FROM team_members WHERE team_id=?`, team.ID).Scan(&n)
if n != 0 {
t.Fatalf("dissolve should clear members, got %d", n)
if os.Getenv("TEST_API_URL") == "" {
t.Skip("set TEST_API_URL to run API integration tests")
}
t.Skip("API integration harness not wired in this build; use manual smoke against TEST_API_URL")
}

View File

@@ -1,7 +1,7 @@
package main
// 团队协作本地单测:徽标计数、共享打标置脏、资料校验、历史节点与时间解析。
// 团队远端流程见 team_integration_test.go需要本机 MySQL
// 团队远端流程见 team_integration_test.go需要 TEST_API_URL
import (
"path/filepath"

View File

@@ -1,9 +1,14 @@
// Applies init.sql using the packaged sync defaults (build/sync.defaults.json).
// Applies nl-pms-api/init.sql to MySQL.
// Usage (from view repo root):
//
// go run ./tools/applyinit -dsn "user:pass@tcp(host:3306)/"
//
// Or set MYSQL_DSN. Schema source of truth is ../nl-pms-api/init.sql.
package main
import (
"database/sql"
"encoding/json"
"flag"
"fmt"
"os"
"path/filepath"
@@ -12,55 +17,39 @@ import (
_ "github.com/go-sql-driver/mysql"
)
type syncDefaults struct {
Host string `json:"host"`
Port int `json:"port"`
User string `json:"user"`
Password string `json:"password"`
Database string `json:"database"`
}
func loadDefaults() syncDefaults {
path := filepath.Join("build", "sync.defaults.json")
func main() {
dsnFlag := flag.String("dsn", "", "MySQL DSN without database (or set MYSQL_DSN)")
sqlPath := flag.String("sql", "", "path to init.sql (default: ../nl-pms-api/init.sql)")
flag.Parse()
dsn := strings.TrimSpace(*dsnFlag)
if dsn == "" {
dsn = strings.TrimSpace(os.Getenv("MYSQL_DSN"))
}
if dsn == "" {
fmt.Fprintln(os.Stderr, "缺少 -dsn 或 MYSQL_DSN例: root:root@tcp(127.0.0.1:3306)/")
os.Exit(2)
}
if !strings.Contains(dsn, "multiStatements") {
if strings.Contains(dsn, "?") {
dsn += "&multiStatements=false&charset=utf8mb4"
} else {
dsn += "?multiStatements=false&charset=utf8mb4"
}
}
path := *sqlPath
if path == "" {
path = filepath.Join("..", "nl-pms-api", "init.sql")
}
raw, err := os.ReadFile(path)
if err != nil {
panic(err)
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
var c syncDefaults
if err := json.Unmarshal(raw, &c); err != nil {
panic(err)
}
if c.Port <= 0 {
c.Port = 3306
}
return c
}
func dsn(c syncDefaults, withDB bool) string {
db := ""
if withDB {
db = c.Database
}
return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?multiStatements=false&charset=utf8mb4",
c.User, c.Password, c.Host, c.Port, db)
}
func main() {
cfg := loadDefaults()
if len(os.Args) > 1 && os.Args[1] == "inspect" {
inspect(cfg)
return
}
raw, err := os.ReadFile("init.sql")
if err != nil {
panic(err)
}
db, err := sql.Open("mysql", dsn(cfg, false))
db, err := sql.Open("mysql", dsn)
if err != nil {
panic(err)
}
defer db.Close()
// init.sql 的升级段依赖会话变量SET @sql / PREPARE必须固定在同一连接上执行。
db.SetMaxOpenConns(1)
var kept []string
@@ -81,11 +70,11 @@ func main() {
}
}
var n int
if err := db.QueryRow("SELECT COUNT(*) FROM " + cfg.Database + ".sync_settings").Scan(&n); err != nil {
if err := db.QueryRow("SELECT COUNT(*) FROM code_count.users").Scan(&n); err != nil {
fmt.Println("verify failed:", err)
os.Exit(1)
}
fmt.Println("ok, sync_settings rows:", n, "host:", cfg.Host)
fmt.Println("ok, users rows:", n, "sql:", path)
}
func min(a, b int) int {
@@ -94,25 +83,3 @@ func min(a, b int) int {
}
return b
}
func inspect(cfg syncDefaults) {
db, err := sql.Open("mysql", dsn(cfg, true))
if err != nil {
panic(err)
}
defer db.Close()
rows, err := db.Query("SELECT user_id, name, LENGTH(value), updated_at FROM sync_settings ORDER BY user_id, name")
if err != nil {
panic(err)
}
defer rows.Close()
for rows.Next() {
var uid int64
var name, at string
var n int
if err := rows.Scan(&uid, &name, &n, &at); err != nil {
panic(err)
}
fmt.Printf("user=%d name=%-12s bytes=%-6d at=%s\n", uid, name, n, at)
}
}

86
tools/package-api.ps1 Normal file
View File

@@ -0,0 +1,86 @@
# Package nl-pms-api for desktop "package" task.
# Rebuilds only when sources change; pass -Force to always rebuild.
# Output: view/bin/nl-pms-api/ (linux/amd64 binary + init.sql + migrations + config.example.yaml)
param(
[switch]$Force
)
$ErrorActionPreference = 'Stop'
$ViewRoot = Split-Path -Parent $PSScriptRoot
$ApiRoot = Join-Path (Split-Path -Parent $ViewRoot) 'nl-pms-api'
$OutDir = Join-Path $ViewRoot 'bin\nl-pms-api'
$StampFile = Join-Path $OutDir '.stamp'
$BinPath = Join-Path $OutDir 'nl-pms-api'
if (-not (Test-Path $ApiRoot)) {
Write-Error "API dir not found: $ApiRoot (expected sibling of view)"
}
function Get-ApiFingerprint {
$files = @()
$files += Get-ChildItem -Path $ApiRoot -Filter 'go.mod' -File -ErrorAction SilentlyContinue
$files += Get-ChildItem -Path $ApiRoot -Filter 'go.sum' -File -ErrorAction SilentlyContinue
$files += Get-ChildItem -Path $ApiRoot -Filter 'main.go' -File -ErrorAction SilentlyContinue
$files += Get-ChildItem -Path $ApiRoot -Filter 'init.sql' -File -ErrorAction SilentlyContinue
$files += Get-ChildItem -Path $ApiRoot -Filter 'config.example.yaml' -File -ErrorAction SilentlyContinue
$files += Get-ChildItem -Path (Join-Path $ApiRoot 'internal') -Recurse -Include *.go -File -ErrorAction SilentlyContinue
$files += Get-ChildItem -Path (Join-Path $ApiRoot 'migrations') -Recurse -Include *.sql -File -ErrorAction SilentlyContinue
$sha = [System.Security.Cryptography.SHA256]::Create()
$ms = New-Object System.IO.MemoryStream
foreach ($f in ($files | Sort-Object FullName)) {
$rel = $f.FullName.Substring($ApiRoot.Length).TrimStart('\', '/')
$bytes = [System.Text.Encoding]::UTF8.GetBytes($rel + "`n")
$ms.Write($bytes, 0, $bytes.Length)
$content = [System.IO.File]::ReadAllBytes($f.FullName)
$ms.Write($content, 0, $content.Length)
}
$hash = $sha.ComputeHash($ms.ToArray())
($hash | ForEach-Object { $_.ToString('x2') }) -join ''
}
$fp = Get-ApiFingerprint
$prev = ''
if (Test-Path $StampFile) { $prev = (Get-Content -Raw $StampFile).Trim() }
if (-not $Force -and $prev -eq $fp -and (Test-Path $BinPath)) {
Write-Host "api: up to date ($fp)"
exit 0
}
Write-Host "api: packaging -> $OutDir"
New-Item -ItemType Directory -Force -Path $OutDir | Out-Null
$migOut = Join-Path $OutDir 'migrations'
New-Item -ItemType Directory -Force -Path $migOut | Out-Null
$prevGoos = $env:GOOS
$prevGoarch = $env:GOARCH
$prevCgo = $env:CGO_ENABLED
$env:CGO_ENABLED = '0'
$env:GOOS = 'linux'
$env:GOARCH = 'amd64'
Push-Location $ApiRoot
try {
& go build -trimpath -ldflags '-s -w' -o $BinPath .
if ($LASTEXITCODE -ne 0) { throw "go build failed: $LASTEXITCODE" }
} finally {
Pop-Location
if ($null -eq $prevGoos) { Remove-Item Env:GOOS -ErrorAction SilentlyContinue } else { $env:GOOS = $prevGoos }
if ($null -eq $prevGoarch) { Remove-Item Env:GOARCH -ErrorAction SilentlyContinue } else { $env:GOARCH = $prevGoarch }
if ($null -eq $prevCgo) { Remove-Item Env:CGO_ENABLED -ErrorAction SilentlyContinue } else { $env:CGO_ENABLED = $prevCgo }
}
Copy-Item -Force (Join-Path $ApiRoot 'init.sql') (Join-Path $OutDir 'init.sql')
Copy-Item -Force (Join-Path $ApiRoot 'config.example.yaml') (Join-Path $OutDir 'config.example.yaml')
Get-ChildItem -Path (Join-Path $ApiRoot 'migrations') -Filter '*.sql' -File -ErrorAction SilentlyContinue |
ForEach-Object { Copy-Item -Force $_.FullName (Join-Path $migOut $_.Name) }
# Must be linux ELF (not Windows PE). Magic: 7F 45 4C 46
$hdr = Get-Content -Path $BinPath -Encoding Byte -TotalCount 4
if ($hdr.Count -lt 4 -or $hdr[0] -ne 0x7F -or $hdr[1] -ne 0x45 -or $hdr[2] -ne 0x4C -or $hdr[3] -ne 0x46) {
Remove-Item -Force $BinPath -ErrorAction SilentlyContinue
throw "api binary is not linux ELF (GOOS=linux GOARCH=amd64 required). Got header: $($hdr -join ',')"
}
Set-Content -Path $StampFile -Value $fp -NoNewline
Write-Host "api: done linux/amd64 ELF -> $BinPath"

226
update.go Normal file
View File

@@ -0,0 +1,226 @@
package main
// update.go 客户端软件自动更新:每 30 分钟检查服务端最新版,下载 NSIS 后拉起安装。
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
"view/platform"
)
type appLatestInfo struct {
Version string `json:"version"`
Channel string `json:"channel"`
SHA256 string `json:"sha256"`
SizeBytes int64 `json:"sizeBytes"`
Changelog string `json:"changelog"`
CreatedAt string `json:"createdAt"`
}
// CheckAppUpdate 手动/定时检查更新force=true 忽略“已忽略版本”。
func (a *App) CheckAppUpdate(force bool) (map[string]any, error) {
if e := a.ready(); e != nil {
return nil, e
}
info, e := a.fetchLatestRelease()
if e != nil {
if e.Error() == "NO_RELEASE" {
return map[string]any{"upToDate": true, "current": appVersion}, nil
}
return nil, e
}
if !versionNewer(info.Version, appVersion) {
return map[string]any{"upToDate": true, "current": appVersion, "latest": info.Version}, nil
}
if !force {
if skipped := strings.TrimSpace(a.store.Meta("app_update_skipped")); skipped == info.Version {
return map[string]any{"upToDate": false, "skipped": true, "current": appVersion, "latest": info.Version}, nil
}
}
payload := map[string]any{
"upToDate": false,
"current": appVersion,
"latest": info.Version,
"changelog": info.Changelog,
"sizeBytes": info.SizeBytes,
"sha256": info.SHA256,
"channel": info.Channel,
}
a.emit("app:update-available", payload)
return payload, nil
}
// SkipAppUpdate 本次跳过该版本提示。
func (a *App) SkipAppUpdate(version string) error {
if e := a.ready(); e != nil {
return e
}
return a.store.SetMeta("app_update_skipped", strings.TrimSpace(version))
}
// DownloadAndInstallUpdate 下载最新安装包、校验哈希后启动并退出应用。
func (a *App) DownloadAndInstallUpdate() error {
if e := a.ready(); e != nil {
return e
}
info, e := a.fetchLatestRelease()
if e != nil {
return e
}
if !versionNewer(info.Version, appVersion) {
return errors.New("ALREADY_LATEST")
}
path, e := a.downloadRelease(info)
if e != nil {
return e
}
cmd := exec.Command(path, "/S")
platform.ConfigureHidden(cmd)
if e := cmd.Start(); e != nil {
// 静默失败则普通启动
cmd2 := exec.Command(path)
if e2 := cmd2.Start(); e2 != nil {
return errors.New("INSTALLER_START_FAILED")
}
}
a.store.Log("info", "系统", "开始安装更新", info.Version)
go func() {
time.Sleep(800 * time.Millisecond)
a.QuitApp()
}()
return nil
}
func (a *App) runAppUpdateLoop() {
time.Sleep(60 * time.Second)
a.checkAppUpdateQuiet()
t := time.NewTicker(30 * time.Minute)
defer t.Stop()
for {
select {
case <-a.ctx.Done():
return
case <-t.C:
a.checkAppUpdateQuiet()
}
}
}
func (a *App) checkAppUpdateQuiet() {
if a.store == nil || a.bootstrap.State != BootstrapReady {
return
}
if a.syncUserID() <= 0 || a.store.Meta("sync_access_token") == "" {
return
}
_, _ = a.CheckAppUpdate(false)
}
func (a *App) fetchLatestRelease() (*appLatestInfo, error) {
if a.apiBaseURL() == "" {
return nil, errors.New("SYNC_NOT_CONFIGURED")
}
var info appLatestInfo
if e := a.apiDecode(http.MethodGet, "/api/v1/app/latest?channel=stable", nil, &info, false); e != nil {
return nil, e
}
if strings.TrimSpace(info.Version) == "" {
return nil, errors.New("NO_RELEASE")
}
return &info, nil
}
func (a *App) downloadRelease(info *appLatestInfo) (string, error) {
base := a.apiBaseURL()
if base == "" {
return "", errors.New("SYNC_NOT_CONFIGURED")
}
url := base + "/api/v1/app/download/" + pathEscape(info.Version) + "?channel=stable"
req, e := http.NewRequest(http.MethodGet, url, nil)
if e != nil {
return "", errors.New("SYNC_OFFLINE")
}
if tok := strings.TrimSpace(a.store.Meta("sync_access_token")); tok != "" {
req.Header.Set("Authorization", "Bearer "+tok)
}
client := &http.Client{Timeout: 30 * time.Minute}
resp, e := client.Do(req)
if e != nil {
return "", errors.New("SYNC_OFFLINE")
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
var er struct {
Error string `json:"error"`
}
if json.Unmarshal(raw, &er) == nil && er.Error != "" {
return "", errors.New(er.Error)
}
return "", errors.New("SYNC_HTTP_" + strconv.Itoa(resp.StatusCode))
}
dir := filepath.Join(os.TempDir(), "code-count-updates")
_ = os.MkdirAll(dir, 0755)
outPath := filepath.Join(dir, info.Version+"-installer.exe")
f, e := os.Create(outPath)
if e != nil {
return "", errors.New("SAVE_FAILED")
}
h := sha256.New()
n, e := io.Copy(io.MultiWriter(f, h), resp.Body)
_ = f.Close()
if e != nil {
_ = os.Remove(outPath)
return "", errors.New("DOWNLOAD_FAILED")
}
sum := hex.EncodeToString(h.Sum(nil))
if info.SHA256 != "" && !strings.EqualFold(sum, info.SHA256) {
_ = os.Remove(outPath)
return "", errors.New("SHA256_MISMATCH")
}
if info.SizeBytes > 0 && n != info.SizeBytes {
a.store.Log("warning", "系统", "更新包大小与元数据不一致", fmt.Sprintf("%d vs %d", n, info.SizeBytes))
}
return outPath, nil
}
// versionNewer 判断 remote 是否比 local 新(简单 x.y.z 比较)。
func versionNewer(remote, local string) bool {
rp := parseSemver(remote)
lp := parseSemver(local)
for i := 0; i < 3; i++ {
if rp[i] > lp[i] {
return true
}
if rp[i] < lp[i] {
return false
}
}
return false
}
func parseSemver(v string) [3]int {
v = strings.TrimPrefix(strings.TrimSpace(v), "v")
if i := strings.IndexAny(v, "-+"); i >= 0 {
v = v[:i]
}
parts := strings.Split(v, ".")
var out [3]int
for i := 0; i < 3 && i < len(parts); i++ {
n, _ := strconv.Atoi(parts[i])
out[i] = n
}
return out
}