更新若干功能
This commit is contained in:
@@ -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)。
|
||||
|
||||
@@ -1 +1 @@
|
||||
e4f8c1296afa8a28d19333bcd28c94b1
|
||||
4243843db83040eda88090772ddad099
|
||||
|
||||
@@ -1 +1 @@
|
||||
62c10364921311d089da2985d202f33e
|
||||
64c6e855501f26014a9444278e67488d
|
||||
|
||||
10
Taskfile.yml
10
Taskfile.yml
@@ -22,10 +22,18 @@ tasks:
|
||||
- task: "{{.GOOS}}:build"
|
||||
|
||||
package:
|
||||
summary: Packages a production build of the application
|
||||
summary: Packages a production build of the application (+ nl-pms-api when changed)
|
||||
cmds:
|
||||
- task: api:package
|
||||
- task: "{{.GOOS}}:package"
|
||||
|
||||
# 打包云端 API:源码有改动才重编;FORCE=1 强制。
|
||||
# 产出 bin/nl-pms-api/(linux/amd64 二进制 + init.sql + migrations + config.example.yaml)
|
||||
api:package:
|
||||
summary: Packages nl-pms-api into bin/nl-pms-api (skip if unchanged)
|
||||
cmds:
|
||||
- powershell -NoProfile -ExecutionPolicy Bypass -File ./tools/package-api.ps1 {{if eq .FORCE "1"}}-Force{{end}}
|
||||
|
||||
run:
|
||||
summary: Runs the application
|
||||
cmds:
|
||||
|
||||
372
admin.go
Normal file
372
admin.go
Normal 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
16
ai.go
@@ -160,6 +160,9 @@ func (a *App) SendAIMessage(conversationID, projectID int64, scenario, content s
|
||||
if e != nil {
|
||||
return AIConversation{}, e
|
||||
}
|
||||
if e := a.checkAIPolicy(); e != nil {
|
||||
return AIConversation{}, e
|
||||
}
|
||||
|
||||
var conv AIConversation
|
||||
if conversationID == 0 {
|
||||
@@ -224,7 +227,11 @@ func (a *App) runAIStream(ctx context.Context, provider ai.Provider, convID int6
|
||||
return
|
||||
}
|
||||
var sb strings.Builder
|
||||
var usage *ai.Usage
|
||||
for chunk := range stream {
|
||||
if chunk.Usage != nil {
|
||||
usage = chunk.Usage
|
||||
}
|
||||
if chunk.Err != nil {
|
||||
// 已有部分内容则保留落库,方便用户继续。
|
||||
if sb.Len() > 0 {
|
||||
@@ -236,6 +243,9 @@ func (a *App) runAIStream(ctx context.Context, provider ai.Provider, convID int6
|
||||
a.emit("ai:stream", aiStreamEvent{ConversationID: convID, Error: chunk.Err.Error(), Done: true})
|
||||
return
|
||||
}
|
||||
if chunk.Content == "" {
|
||||
continue
|
||||
}
|
||||
sb.WriteString(chunk.Content)
|
||||
a.emit("ai:stream", aiStreamEvent{ConversationID: convID, Delta: chunk.Content})
|
||||
}
|
||||
@@ -248,6 +258,9 @@ func (a *App) runAIStream(ctx context.Context, provider ai.Provider, convID int6
|
||||
a.emit("ai:stream", aiStreamEvent{ConversationID: convID, Error: e.Error(), Done: true})
|
||||
return
|
||||
}
|
||||
if usage != nil {
|
||||
go a.reportAIUsage(provider.Name(), usage.PromptTokens, usage.CompletionTokens, usage.Estimated)
|
||||
}
|
||||
a.store.TouchAIConversation(convID, provider.Name())
|
||||
a.emit("ai:stream", aiStreamEvent{ConversationID: convID, Done: true, MessageID: m.ID})
|
||||
}
|
||||
@@ -478,6 +491,9 @@ func (a *App) generateAISummaries(projectID int64, kinds ...string) {
|
||||
if e != nil {
|
||||
return
|
||||
}
|
||||
if e := a.checkAIPolicy(); e != nil {
|
||||
return
|
||||
}
|
||||
st, _ := a.store.Settings()
|
||||
aiSummarySem <- struct{}{}
|
||||
defer func() { <-aiSummarySem }()
|
||||
|
||||
256
apiclient.go
Normal file
256
apiclient.go
Normal 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 解到 out(out 可为 nil)。
|
||||
func (a *App) apiDecode(method, path string, body, out any, auth bool) error {
|
||||
return a.apiDecodeHeaders(method, path, body, out, auth, nil)
|
||||
}
|
||||
|
||||
func (a *App) apiDecodeHeaders(method, path string, body, out any, auth bool, headers map[string]string) error {
|
||||
_, raw, e := a.apiDoWithHeaders(method, path, body, auth, headers)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
if out == nil {
|
||||
return nil
|
||||
}
|
||||
if e := json.Unmarshal(raw, out); e != nil {
|
||||
return errors.New("SYNC_BAD_RESPONSE")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// apiPutSetting 写入(或 LWW 更新)单条设置。
|
||||
func (a *App) apiPutSetting(name, value, updatedAt string) error {
|
||||
return a.apiPutSettingWithStepUp(name, value, updatedAt, false)
|
||||
}
|
||||
|
||||
func (a *App) apiPutSettingWithStepUp(name, value, updatedAt string, withStepUp bool) error {
|
||||
headers := map[string]string{}
|
||||
if withStepUp {
|
||||
tok := a.currentStepUpToken()
|
||||
if tok == "" {
|
||||
return errors.New("ADMIN_STEPUP_REQUIRED")
|
||||
}
|
||||
headers["X-Admin-StepUp"] = tok
|
||||
}
|
||||
return a.apiDecodeHeaders(http.MethodPut, "/api/v1/settings/"+pathEscape(name), map[string]string{
|
||||
"value": value,
|
||||
"updatedAt": updatedAt,
|
||||
}, nil, true, headers)
|
||||
}
|
||||
|
||||
// apiGetSetting 读单条设置;NOT_FOUND 时 ok=false 且 err=nil。
|
||||
func (a *App) apiGetSetting(name string) (value, updatedAt string, ok bool, err error) {
|
||||
var row struct {
|
||||
Value string `json:"value"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
e := a.apiDecode(http.MethodGet, "/api/v1/settings/"+pathEscape(name), nil, &row, true)
|
||||
if e != nil {
|
||||
if e.Error() == "NOT_FOUND" {
|
||||
return "", "", false, nil
|
||||
}
|
||||
return "", "", false, e
|
||||
}
|
||||
return row.Value, row.UpdatedAt, true, nil
|
||||
}
|
||||
|
||||
// apiGetGlobalSetting 读挂在管理员名下的全局设置。
|
||||
func (a *App) apiGetGlobalSetting(name string) (value, updatedAt string, ok bool, err error) {
|
||||
var row struct {
|
||||
Value string `json:"value"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
e := a.apiDecode(http.MethodGet, "/api/v1/settings/global/"+pathEscape(name), nil, &row, true)
|
||||
if e != nil {
|
||||
if e.Error() == "NOT_FOUND" {
|
||||
return "", "", false, nil
|
||||
}
|
||||
return "", "", false, e
|
||||
}
|
||||
return row.Value, row.UpdatedAt, true, nil
|
||||
}
|
||||
|
||||
// apiListSettings 按前缀批量拉取(如 fest_img:)。
|
||||
func (a *App) apiListSettings(prefix string) ([]struct {
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}, error) {
|
||||
var resp struct {
|
||||
Items []struct {
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
} `json:"items"`
|
||||
}
|
||||
q := "/api/v1/settings?prefix=" + pathEscape(prefix)
|
||||
if e := a.apiDecode(http.MethodGet, q, nil, &resp, true); e != nil {
|
||||
return nil, e
|
||||
}
|
||||
if resp.Items == nil {
|
||||
return []struct {
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}{}, nil
|
||||
}
|
||||
return resp.Items, nil
|
||||
}
|
||||
|
||||
// pathEscape 对路径段做 URL 转义(保留常见安全字符)。
|
||||
func pathEscape(s string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9',
|
||||
r == '-', r == '_', r == '.', r == '~', r == ':':
|
||||
b.WriteRune(r)
|
||||
default:
|
||||
for _, c := range []byte(string(r)) {
|
||||
b.WriteByte('%')
|
||||
const hex = "0123456789ABCDEF"
|
||||
b.WriteByte(hex[c>>4])
|
||||
b.WriteByte(hex[c&0xf])
|
||||
}
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
6
app.go
6
app.go
@@ -39,6 +39,10 @@ type App struct {
|
||||
fsMu sync.Mutex
|
||||
fsCfg FileStorageConfig
|
||||
fsCfgAt time.Time
|
||||
// 管理员敏感操作二次验证(内存,进程内有效)
|
||||
stepupMu sync.Mutex
|
||||
stepupToken string
|
||||
stepupExp time.Time
|
||||
}
|
||||
|
||||
func NewApp() *App {
|
||||
@@ -57,9 +61,11 @@ func (a *App) ServiceStartup(ctx context.Context, _ application.ServiceOptions)
|
||||
a.startup(ctx)
|
||||
a.RefreshShell()
|
||||
go a.runAutoUpdateLoop()
|
||||
go a.runAppUpdateLoop()
|
||||
go a.runReminderLoop()
|
||||
go a.runSyncLoop()
|
||||
go a.runTeamDigestLoop()
|
||||
go a.runActivityPingLoop()
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": "本地代码统计与项目工作台"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BIN
build/windows/nsis/MicrosoftEdgeWebview2Setup.exe
Normal file
BIN
build/windows/nsis/MicrosoftEdgeWebview2Setup.exe
Normal file
Binary file not shown.
@@ -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"'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# DO NOT EDIT - Generated automatically by `wails build`
|
||||
# DO NOT EDIT - Generated automatically by `wails build`
|
||||
|
||||
!include "x64.nsh"
|
||||
!include "WinVer.nsh"
|
||||
@@ -11,13 +11,13 @@
|
||||
!define INFO_COMPANYNAME "liqi"
|
||||
!endif
|
||||
!ifndef INFO_PRODUCTNAME
|
||||
!define INFO_PRODUCTNAME "My Product"
|
||||
!define INFO_PRODUCTNAME "年糕崽崽项目管理(PMS)"
|
||||
!endif
|
||||
!ifndef INFO_PRODUCTVERSION
|
||||
!define INFO_PRODUCTVERSION "0.1.0"
|
||||
!define INFO_PRODUCTVERSION "2.0.0"
|
||||
!endif
|
||||
!ifndef INFO_COPYRIGHT
|
||||
!define INFO_COPYRIGHT "© now, My Company"
|
||||
!define INFO_COPYRIGHT "(c) 2026, liqi"
|
||||
!endif
|
||||
!ifndef PRODUCT_EXECUTABLE
|
||||
!define PRODUCT_EXECUTABLE "${INFO_PROJECTNAME}.exe"
|
||||
@@ -65,11 +65,11 @@ RequestExecutionLevel "${REQUEST_EXECUTION_LEVEL}"
|
||||
|
||||
!macro wails.checkArchitecture
|
||||
!ifndef WAILS_WIN10_REQUIRED
|
||||
!define WAILS_WIN10_REQUIRED "This product is only supported on Windows 10 (Server 2016) and later."
|
||||
!define WAILS_WIN10_REQUIRED "本产品仅支持 Windows 10(Server 2016)及更高版本。"
|
||||
!endif
|
||||
|
||||
!ifndef WAILS_ARCHITECTURE_NOT_SUPPORTED
|
||||
!define WAILS_ARCHITECTURE_NOT_SUPPORTED "This product can't be installed on the current Windows architecture. Supports: ${ARCH}"
|
||||
!define WAILS_ARCHITECTURE_NOT_SUPPORTED "当前 Windows 架构不支持安装本产品。支持:${ARCH}"
|
||||
!endif
|
||||
|
||||
${If} ${AtLeastWin10}
|
||||
@@ -171,7 +171,7 @@ RequestExecutionLevel "${REQUEST_EXECUTION_LEVEL}"
|
||||
# See https://docs.microsoft.com/en-us/microsoft-edge/webview2/concepts/distribution#online-only-deployment
|
||||
!macro wails.webview2runtime
|
||||
!ifndef WAILS_INSTALL_WEBVIEW_DETAILPRINT
|
||||
!define WAILS_INSTALL_WEBVIEW_DETAILPRINT "Installing: WebView2 Runtime"
|
||||
!define WAILS_INSTALL_WEBVIEW_DETAILPRINT "正在安装:WebView2 运行时"
|
||||
!endif
|
||||
|
||||
SetRegView 64
|
||||
|
||||
@@ -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="*"/>
|
||||
|
||||
51
database.go
51
database.go
@@ -55,6 +55,8 @@ func OpenStore(path string) (*Store, error) {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
// 每次打开库:把上次异常退出留下的 running 任务标为失败(表不存在时忽略)。
|
||||
s.markInterruptedPackTasks()
|
||||
return s, nil
|
||||
}
|
||||
|
||||
@@ -66,7 +68,12 @@ func OpenStore(path string) (*Store, error) {
|
||||
// v6:新增 day_briefs(工作台 AI 今日规划 / 下班日报)。
|
||||
// v7:新增 launch_apps(启动台保存的应用与启停命令)。
|
||||
// v8:todos/tickets 新增 team_id 团队共享列。
|
||||
const schemaVersion = 8
|
||||
// v9:新增 launch_logs(启动台进程输出,仅本地,不同步)。
|
||||
// v10:launch_apps 新增 icon(本地抓取的 favicon/logo dataURL)。
|
||||
// v11:launch_apps 新增 category(我的应用分类筛选)。
|
||||
// v12:launch_categories 一级分类;launch_apps.category_id;projects.icon;kind_icons。
|
||||
// v13:pack_tasks / pack_task_logs(打包任务与控制台输出,仅本地,不同步)。
|
||||
const schemaVersion = 13
|
||||
|
||||
func (s *Store) migrate() error {
|
||||
// PRAGMA 是连接级/文件级配置,不属于迁移,每次打开都需执行。
|
||||
@@ -111,6 +118,14 @@ func (s *Store) migrate() error {
|
||||
`CREATE TABLE IF NOT EXISTS ai_summaries(project_id INTEGER NOT NULL, kind TEXT NOT NULL, content TEXT NOT NULL DEFAULT '', provider TEXT NOT NULL DEFAULT '', generated_at TEXT NOT NULL, PRIMARY KEY(project_id,kind), FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE)`,
|
||||
`CREATE TABLE IF NOT EXISTS day_briefs(kind TEXT PRIMARY KEY, content TEXT NOT NULL DEFAULT '', provider TEXT NOT NULL DEFAULT '', generated_at TEXT NOT NULL)`,
|
||||
`CREATE TABLE IF NOT EXISTS launch_apps(id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, kind TEXT NOT NULL DEFAULT 'other', port INTEGER NOT NULL DEFAULT 0, dir TEXT NOT NULL DEFAULT '', start_cmd TEXT NOT NULL DEFAULT '', stop_cmd TEXT NOT NULL DEFAULT '', last_pid INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)`,
|
||||
`CREATE TABLE IF NOT EXISTS launch_logs(id INTEGER PRIMARY KEY AUTOINCREMENT, app_id INTEGER NOT NULL, level TEXT NOT NULL DEFAULT 'info', line TEXT NOT NULL, created_at TEXT NOT NULL)`,
|
||||
`CREATE TABLE IF NOT EXISTS launch_categories(id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, sort INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL)`,
|
||||
`CREATE TABLE IF NOT EXISTS kind_icons(kind TEXT PRIMARY KEY, value TEXT NOT NULL DEFAULT '', updated_at TEXT NOT NULL DEFAULT '')`,
|
||||
`CREATE TABLE IF NOT EXISTS pack_tasks(id TEXT PRIMARY KEY, title TEXT NOT NULL DEFAULT '', cmd TEXT NOT NULL DEFAULT '', dir TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT 'running', pid INTEGER NOT NULL DEFAULT 0, started_at TEXT NOT NULL DEFAULT '', ended_at TEXT NOT NULL DEFAULT '', error TEXT NOT NULL DEFAULT '')`,
|
||||
`CREATE TABLE IF NOT EXISTS pack_task_logs(id INTEGER PRIMARY KEY AUTOINCREMENT, task_id TEXT NOT NULL, line TEXT NOT NULL, created_at TEXT NOT NULL)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_launch_logs_app ON launch_logs(app_id, id DESC)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_pack_tasks_started ON pack_tasks(started_at DESC)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_pack_task_logs_task ON pack_task_logs(task_id, id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_ai_messages_conv ON ai_messages(conversation_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_messages_read ON messages(is_read)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_todos_status ON todos(deleted,status)`,
|
||||
@@ -150,6 +165,34 @@ func (s *Store) migrate() error {
|
||||
}
|
||||
}
|
||||
}
|
||||
if ok, err := s.columnExists("launch_apps", "icon"); err != nil {
|
||||
return err
|
||||
} else if !ok {
|
||||
if _, err = s.db.Exec(`ALTER TABLE launch_apps ADD COLUMN icon TEXT NOT NULL DEFAULT ''`); err != nil {
|
||||
return fmt.Errorf("database migration: %w", err)
|
||||
}
|
||||
}
|
||||
if ok, err := s.columnExists("launch_apps", "category"); err != nil {
|
||||
return err
|
||||
} else if !ok {
|
||||
if _, err = s.db.Exec(`ALTER TABLE launch_apps ADD COLUMN category TEXT NOT NULL DEFAULT ''`); err != nil {
|
||||
return fmt.Errorf("database migration: %w", err)
|
||||
}
|
||||
}
|
||||
if ok, err := s.columnExists("launch_apps", "category_id"); err != nil {
|
||||
return err
|
||||
} else if !ok {
|
||||
if _, err = s.db.Exec(`ALTER TABLE launch_apps ADD COLUMN category_id INTEGER NOT NULL DEFAULT 0`); err != nil {
|
||||
return fmt.Errorf("database migration: %w", err)
|
||||
}
|
||||
}
|
||||
if ok, err := s.columnExists("projects", "icon"); err != nil {
|
||||
return err
|
||||
} else if !ok {
|
||||
if _, err = s.db.Exec(`ALTER TABLE projects ADD COLUMN icon TEXT NOT NULL DEFAULT ''`); err != nil {
|
||||
return fmt.Errorf("database migration: %w", err)
|
||||
}
|
||||
}
|
||||
_, _ = s.db.Exec(`INSERT OR IGNORE INTO project_groups(id,name,created_at,updated_at) VALUES(1,'My Projects',?,?)`, time.Now().Format(time.RFC3339), time.Now().Format(time.RFC3339))
|
||||
_, _ = s.db.Exec(`UPDATE projects SET group_id=1 WHERE group_id IS NULL OR group_id=0`)
|
||||
defaults := map[string][]string{"general": {".git", ".idea", ".vscode", "node_modules", "dist", "coverage", "*.min.js", "*.min.css", ".DS_Store"}, "php": {"vendor", "storage/framework", "bootstrap/cache", ".phpunit.cache"}, "go": {"go.sum"}, "vue": {".nuxt", ".next", ".output", "unpackage"}}
|
||||
@@ -291,7 +334,7 @@ func (s *Store) DeleteProjectGroup(id int64) error {
|
||||
return tx.Commit()
|
||||
}
|
||||
func (s *Store) ListProjects(groupID int64) ([]Project, error) {
|
||||
q := `SELECT p.id,p.name,p.path,p.description,p.group_id,COALESCE(g.name,''),p.created_at,p.updated_at FROM projects p LEFT JOIN project_groups g ON g.id=p.group_id`
|
||||
q := `SELECT p.id,p.name,p.path,p.description,p.group_id,COALESCE(g.name,''),COALESCE(p.icon,''),p.created_at,p.updated_at FROM projects p LEFT JOIN project_groups g ON g.id=p.group_id`
|
||||
args := []any{}
|
||||
if groupID > 0 {
|
||||
q += ` WHERE p.group_id=?`
|
||||
@@ -305,7 +348,7 @@ func (s *Store) ListProjects(groupID int64) ([]Project, error) {
|
||||
out := []Project{}
|
||||
for rows.Next() {
|
||||
var p Project
|
||||
if e = rows.Scan(&p.ID, &p.Name, &p.Path, &p.Description, &p.GroupID, &p.GroupName, &p.CreatedAt, &p.UpdatedAt); e != nil {
|
||||
if e = rows.Scan(&p.ID, &p.Name, &p.Path, &p.Description, &p.GroupID, &p.GroupName, &p.Icon, &p.CreatedAt, &p.UpdatedAt); e != nil {
|
||||
rows.Close()
|
||||
return nil, e
|
||||
}
|
||||
@@ -326,7 +369,7 @@ func (s *Store) ListProjects(groupID int64) ([]Project, error) {
|
||||
}
|
||||
func (s *Store) GetProject(id int64) (Project, error) {
|
||||
var p Project
|
||||
e := s.db.QueryRow(`SELECT p.id,p.name,p.path,p.description,p.group_id,COALESCE(g.name,''),p.created_at,p.updated_at FROM projects p LEFT JOIN project_groups g ON g.id=p.group_id WHERE p.id=?`, id).Scan(&p.ID, &p.Name, &p.Path, &p.Description, &p.GroupID, &p.GroupName, &p.CreatedAt, &p.UpdatedAt)
|
||||
e := s.db.QueryRow(`SELECT p.id,p.name,p.path,p.description,p.group_id,COALESCE(g.name,''),COALESCE(p.icon,''),p.created_at,p.updated_at FROM projects p LEFT JOIN project_groups g ON g.id=p.group_id WHERE p.id=?`, id).Scan(&p.ID, &p.Name, &p.Path, &p.Description, &p.GroupID, &p.GroupName, &p.Icon, &p.CreatedAt, &p.UpdatedAt)
|
||||
if e != nil {
|
||||
return p, e
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
173
fileapi.go
173
fileapi.go
@@ -2,8 +2,8 @@ package main
|
||||
|
||||
// fileapi.go 调 nl-pms-api 的最小客户端:上传图片换取公开访问 URL,
|
||||
// 以及素材库的列表/删除透传(权限由服务端按 用户/团队角色 判定)。
|
||||
// 服务器地址与密钥来自管理员下发的全局文件存储配置(见 filestorage.go)。
|
||||
// 请求由 Go 端发起(不走前端 fetch),天然绕开 webview 的 CORS 限制。
|
||||
// 服务器地址来自管理员下发的全局文件存储配置(见 filestorage.go);
|
||||
// 鉴权使用同步登录的 JWT(sync_access_token),不再使用 apiKey / 表单 userId。
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -20,7 +20,6 @@ import (
|
||||
|
||||
var fileAPIClient = &http.Client{Timeout: 30 * time.Second}
|
||||
|
||||
// currentTeamID 返回客户端当前所在团队 id(未加入团队为 0)。
|
||||
func (a *App) currentTeamID() int64 {
|
||||
n, _ := strconv.ParseInt(strings.TrimSpace(a.store.Meta("current_team_id")), 10, 64)
|
||||
if n < 0 {
|
||||
@@ -29,8 +28,6 @@ func (a *App) currentTeamID() int64 {
|
||||
return n
|
||||
}
|
||||
|
||||
// fileAPIBase 返回已启用的服务器存储地址(实时读全局配置,离线回本地缓存);
|
||||
// 未启用返回 FILE_API_UNCONFIGURED。
|
||||
func (a *App) fileAPIBase() (FileStorageConfig, error) {
|
||||
cfg := a.currentFileStorage()
|
||||
if cfg.Mode != "server" || cfg.BaseURL == "" {
|
||||
@@ -39,7 +36,44 @@ func (a *App) fileAPIBase() (FileStorageConfig, error) {
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// fileAPIStatusErr 把 nl-pms-api 的非 200 响应映射为客户端错误码。
|
||||
func (a *App) syncBearerToken() (string, error) {
|
||||
tok := ""
|
||||
if a.store != nil {
|
||||
tok = strings.TrimSpace(a.store.Meta("sync_access_token"))
|
||||
}
|
||||
if tok == "" {
|
||||
return "", errors.New("SYNC_NOT_LOGGED_IN")
|
||||
}
|
||||
return tok, nil
|
||||
}
|
||||
|
||||
// fileAPIDo 对文件服务发请求;401 时尝试 refresh 后重试一次。
|
||||
func (a *App) fileAPIDo(req *http.Request) (*http.Response, error) {
|
||||
tok, e := a.syncBearerToken()
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+tok)
|
||||
resp, e := fileAPIClient.Do(req)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
if resp.StatusCode == http.StatusUnauthorized {
|
||||
resp.Body.Close()
|
||||
if a.apiTryRefresh() != nil {
|
||||
return nil, errors.New("UNAUTHORIZED")
|
||||
}
|
||||
tok, e = a.syncBearerToken()
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
// 重建请求体不可行时由调用方重试;此处仅对无 body 的 GET/DELETE 重试。
|
||||
req.Header.Set("Authorization", "Bearer "+tok)
|
||||
return fileAPIClient.Do(req)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func fileAPIStatusErr(status int) error {
|
||||
switch status {
|
||||
case http.StatusForbidden:
|
||||
@@ -51,14 +85,14 @@ func fileAPIStatusErr(status int) error {
|
||||
}
|
||||
}
|
||||
|
||||
// uploadImageToServer 把编码好的图片字节上传到 nl-pms-api,返回可公开访问的 http URL。
|
||||
// 归属:头像记个人(teamId=0),内容图记当前团队,素材库据此划定管理范围。
|
||||
// 未启用服务器存储时返回 FILE_API_UNCONFIGURED;失败不静默降级,由调用方向用户报错。
|
||||
func (a *App) uploadImageToServer(data []byte, mime, kind string) (string, error) {
|
||||
cfg, e := a.fileAPIBase()
|
||||
if e != nil {
|
||||
return "", e
|
||||
}
|
||||
if _, e := a.syncBearerToken(); e != nil {
|
||||
return "", e
|
||||
}
|
||||
ext := ".jpg"
|
||||
switch mime {
|
||||
case "image/png":
|
||||
@@ -68,51 +102,60 @@ func (a *App) uploadImageToServer(data []byte, mime, kind string) (string, error
|
||||
case "image/webp":
|
||||
ext = ".webp"
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
w := multipart.NewWriter(&buf)
|
||||
fw, e := w.CreateFormFile("file", "img"+ext)
|
||||
if e != nil {
|
||||
return "", errors.New("IMAGE_UPLOAD_FAILED")
|
||||
doUpload := func() (string, error) {
|
||||
var buf bytes.Buffer
|
||||
w := multipart.NewWriter(&buf)
|
||||
fw, e := w.CreateFormFile("file", "img"+ext)
|
||||
if e != nil {
|
||||
return "", errors.New("IMAGE_UPLOAD_FAILED")
|
||||
}
|
||||
if _, e = fw.Write(data); e != nil {
|
||||
return "", errors.New("IMAGE_UPLOAD_FAILED")
|
||||
}
|
||||
_ = w.WriteField("kind", kind)
|
||||
if kind == "content" {
|
||||
_ = w.WriteField("teamId", strconv.FormatInt(a.currentTeamID(), 10))
|
||||
}
|
||||
if e = w.Close(); e != nil {
|
||||
return "", errors.New("IMAGE_UPLOAD_FAILED")
|
||||
}
|
||||
req, e := http.NewRequest(http.MethodPost, cfg.BaseURL+"/api/v1/files", &buf)
|
||||
if e != nil {
|
||||
return "", errors.New("IMAGE_UPLOAD_FAILED")
|
||||
}
|
||||
req.Header.Set("Content-Type", w.FormDataContentType())
|
||||
tok, _ := a.syncBearerToken()
|
||||
req.Header.Set("Authorization", "Bearer "+tok)
|
||||
resp, e := fileAPIClient.Do(req)
|
||||
if e != nil {
|
||||
return "", errors.New("IMAGE_UPLOAD_FAILED")
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if resp.StatusCode == http.StatusUnauthorized {
|
||||
if a.apiTryRefresh() == nil {
|
||||
return "", errors.New("RETRY")
|
||||
}
|
||||
return "", errors.New("IMAGE_UPLOAD_FAILED")
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", errors.New("IMAGE_UPLOAD_FAILED")
|
||||
}
|
||||
var out struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
if json.Unmarshal(body, &out) != nil || out.URL == "" {
|
||||
return "", errors.New("IMAGE_UPLOAD_FAILED")
|
||||
}
|
||||
return out.URL, nil
|
||||
}
|
||||
if _, e = fw.Write(data); e != nil {
|
||||
return "", errors.New("IMAGE_UPLOAD_FAILED")
|
||||
url, e := doUpload()
|
||||
if e != nil && e.Error() == "RETRY" {
|
||||
return doUpload()
|
||||
}
|
||||
_ = w.WriteField("kind", kind)
|
||||
if id := a.syncUserID(); id > 0 {
|
||||
_ = w.WriteField("userId", strconv.FormatInt(id, 10))
|
||||
}
|
||||
if kind == "content" {
|
||||
_ = w.WriteField("teamId", strconv.FormatInt(a.currentTeamID(), 10))
|
||||
}
|
||||
if e = w.Close(); e != nil {
|
||||
return "", errors.New("IMAGE_UPLOAD_FAILED")
|
||||
}
|
||||
req, e := http.NewRequest(http.MethodPost, cfg.BaseURL+"/api/v1/files", &buf)
|
||||
if e != nil {
|
||||
return "", errors.New("IMAGE_UPLOAD_FAILED")
|
||||
}
|
||||
req.Header.Set("Content-Type", w.FormDataContentType())
|
||||
req.Header.Set("Authorization", "Bearer "+cfg.APIKey)
|
||||
resp, e := fileAPIClient.Do(req)
|
||||
if e != nil {
|
||||
return "", errors.New("IMAGE_UPLOAD_FAILED")
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", errors.New("IMAGE_UPLOAD_FAILED")
|
||||
}
|
||||
var out struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
if json.Unmarshal(body, &out) != nil || out.URL == "" {
|
||||
return "", errors.New("IMAGE_UPLOAD_FAILED")
|
||||
}
|
||||
return out.URL, nil
|
||||
return url, e
|
||||
}
|
||||
|
||||
// ListServerFiles 素材库分页列表。scope=mine 看自己;scope=team 看指定团队
|
||||
// (需为该团队 owner/admin);scope=all 看全部(仅云端账号 id=1)。
|
||||
func (a *App) ListServerFiles(scope string, teamID int64, page int) (ServerFileList, error) {
|
||||
if e := a.ready(); e != nil {
|
||||
return ServerFileList{}, e
|
||||
@@ -121,23 +164,24 @@ func (a *App) ListServerFiles(scope string, teamID int64, page int) (ServerFileL
|
||||
if e != nil {
|
||||
return ServerFileList{}, e
|
||||
}
|
||||
uid := a.syncUserID()
|
||||
if uid <= 0 {
|
||||
if a.syncUserID() <= 0 {
|
||||
return ServerFileList{}, errors.New("SYNC_NOT_LOGGED_IN")
|
||||
}
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
u := fmt.Sprintf("%s/api/v1/files?scope=%s&userId=%d&teamId=%d&page=%d&pageSize=24",
|
||||
cfg.BaseURL, scope, uid, teamID, page)
|
||||
u := fmt.Sprintf("%s/api/v1/files?scope=%s&teamId=%d&page=%d&pageSize=24",
|
||||
cfg.BaseURL, scope, teamID, page)
|
||||
req, e := http.NewRequest(http.MethodGet, u, nil)
|
||||
if e != nil {
|
||||
return ServerFileList{}, errors.New("FILE_API_REQUEST_FAILED")
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+cfg.APIKey)
|
||||
resp, e := fileAPIClient.Do(req)
|
||||
resp, e := a.fileAPIDo(req)
|
||||
if e != nil {
|
||||
return ServerFileList{}, errors.New("FILE_STORAGE_UNREACHABLE")
|
||||
if e.Error() == "SYNC_OFFLINE" || e.Error() == "UNAUTHORIZED" {
|
||||
return ServerFileList{}, errors.New("FILE_STORAGE_UNREACHABLE")
|
||||
}
|
||||
return ServerFileList{}, e
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
|
||||
@@ -151,8 +195,6 @@ func (a *App) ListServerFiles(scope string, teamID int64, page int) (ServerFileL
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// DeleteServerFile 删除素材(记录+服务器磁盘文件)。服务端校验:本人、
|
||||
// 超管 id=1、或该文件归属团队的 owner/admin;越权返回 FILE_PERMISSION_DENIED。
|
||||
func (a *App) DeleteServerFile(id int64) error {
|
||||
if e := a.ready(); e != nil {
|
||||
return e
|
||||
@@ -161,19 +203,20 @@ func (a *App) DeleteServerFile(id int64) error {
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
uid := a.syncUserID()
|
||||
if uid <= 0 {
|
||||
if a.syncUserID() <= 0 {
|
||||
return errors.New("SYNC_NOT_LOGGED_IN")
|
||||
}
|
||||
u := fmt.Sprintf("%s/api/v1/files/%d?userId=%d", cfg.BaseURL, id, uid)
|
||||
u := fmt.Sprintf("%s/api/v1/files/%d", cfg.BaseURL, id)
|
||||
req, e := http.NewRequest(http.MethodDelete, u, nil)
|
||||
if e != nil {
|
||||
return errors.New("FILE_API_REQUEST_FAILED")
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+cfg.APIKey)
|
||||
resp, e := fileAPIClient.Do(req)
|
||||
resp, e := a.fileAPIDo(req)
|
||||
if e != nil {
|
||||
return errors.New("FILE_STORAGE_UNREACHABLE")
|
||||
if e.Error() == "SYNC_OFFLINE" || e.Error() == "UNAUTHORIZED" {
|
||||
return errors.New("FILE_STORAGE_UNREACHABLE")
|
||||
}
|
||||
return e
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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-available,App 层弹出安装对话框
|
||||
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>
|
||||
|
||||
83
frontend/src/components/ImagePreview.vue
Normal file
83
frontend/src/components/ImagePreview.vue
Normal 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>
|
||||
99
frontend/src/components/LocalPackCenter.vue
Normal file
99
frontend/src/components/LocalPackCenter.vue
Normal 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>
|
||||
@@ -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') }
|
||||
|
||||
131
frontend/src/components/PackCmdsModal.vue
Normal file
131
frontend/src/components/PackCmdsModal.vue
Normal 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>
|
||||
22
frontend/src/components/RemoteImg.vue
Normal file
22
frontend/src/components/RemoteImg.vue
Normal 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>
|
||||
@@ -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}}
|
||||
|
||||
@@ -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
35
frontend/src/packCmds.js
Normal 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)
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 拉取成 dataURL(WebView 直接拉外站 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() {
|
||||
|
||||
399
frontend/src/views/Admin.vue
Normal file
399
frontend/src/views/Admin.vue
Normal 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>
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
225
frontend/src/views/PackTasks.vue
Normal file
225
frontend/src/views/PackTasks.vue
Normal 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>
|
||||
278
frontend/src/views/PortMonitor.vue
Normal file
278
frontend/src/views/PortMonitor.vue
Normal 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>
|
||||
@@ -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
|
||||
}
|
||||
// 存储走向由后端按管理员全局配置实时决定(auto):server 时上传返回 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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 深链,其次上次停留的 tab(sync 已迁往个人主页,做合法性回退)
|
||||
// 分区切换入口在侧边栏“设置”一级导航的二级菜单,页内不再有 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"/>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
127
host_metrics.go
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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
157
imgproxy.go
Normal file
@@ -0,0 +1,157 @@
|
||||
package main
|
||||
|
||||
// imgproxy.go:远程图片拉取。
|
||||
// WebView 页面源是 http://wails.localhost,直接加载外站 HTTPS 图常会破图。
|
||||
// 优先经 Go 拉成 dataURL(FetchRemoteImageAsDataURL);AssetServer /__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
44
imgproxy_test.go
Normal 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
214
init.sql
@@ -1,211 +1,9 @@
|
||||
-- ============================================================
|
||||
-- 年糕崽崽项目管理(PMS)同步服务初始化脚本(MySQL 5.7+ / 8.x)
|
||||
-- 用法:mysql -u root -p < init.sql
|
||||
-- 应用与前端不执行任何建表/迁移(DDL),使用同步功能前必须先执行本脚本;
|
||||
-- 应用连接账号只需要对 code_count 库的 SELECT/INSERT/UPDATE 权限。
|
||||
-- 默认账号:liqi / qiqi991012(bcrypt 哈希存储,可在应用内注册新账号)
|
||||
-- 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 '共享团队ID,0=私密',
|
||||
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 '共享团队ID,0=私密',
|
||||
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 为 TEXT(64KB),
|
||||
-- 待办/工单内容支持 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 '指派人用户ID,0=未指派',
|
||||
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 ''共享团队ID,0=私密'' AFTER history', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA='code_count' AND TABLE_NAME='sync_tickets' AND COLUMN_NAME='team_id')=0,
|
||||
'ALTER TABLE sync_tickets ADD COLUMN team_id BIGINT NOT NULL DEFAULT 0 COMMENT ''共享团队ID,0=私密'' AFTER history', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA='code_count' AND TABLE_NAME='sync_todos' AND INDEX_NAME='idx_sync_todos_team')=0,
|
||||
'ALTER TABLE sync_todos ADD KEY idx_sync_todos_team(team_id)', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA='code_count' AND TABLE_NAME='sync_tickets' AND INDEX_NAME='idx_sync_tickets_team')=0,
|
||||
'ALTER TABLE sync_tickets ADD KEY idx_sync_tickets_team(team_id)', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
|
||||
-- 默认账号 liqi(密码 qiqi991012 的 bcrypt 哈希);已存在则跳过,不会覆盖改过的密码
|
||||
INSERT IGNORE INTO users(username, password_hash, created_at)
|
||||
VALUES ('liqi', '$2a$10$XWBtGPu9xYRyr8diFEfvBeEHRkO6pa3CDduE09OurJztuXVcJZOB2', '2026-08-11T13:20:00Z');
|
||||
|
||||
204
kind_icon.go
Normal file
204
kind_icon.go
Normal 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
115
launch_category.go
Normal 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
59
launch_category_test.go
Normal 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
338
launch_detect.go
Normal 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
66
launch_detect_test.go
Normal 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
293
launch_icon.go
Normal 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
60
launch_icon_test.go
Normal 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
155
launch_run.go
Normal 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"
|
||||
}
|
||||
}
|
||||
456
launchpad.go
456
launchpad.go
@@ -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)
|
||||
}
|
||||
|
||||
5
main.go
5
main.go
@@ -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,
|
||||
},
|
||||
|
||||
102
model/models.go
102
model/models.go
@@ -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 | path,AvatarValue 依模式存 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"` // 一级分类 id,0=未归类
|
||||
Category string `json:"category"` // 二级分类(自由文本)
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// LaunchEntry 是启动台卡片条目:保存的应用与实时扫描到的监听进程合并后的视图。
|
||||
type LaunchEntry struct {
|
||||
ID int64 `json:"id"` // launch_apps id;0 表示未保存的扫描条目
|
||||
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 id;0 表示未保存的扫描条目
|
||||
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"`
|
||||
}
|
||||
|
||||
@@ -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
283
pack_cmd.go
Normal 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
181
pack_store.go
Normal 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
361
pack_suggest.go
Normal 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
|
||||
}
|
||||
@@ -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 供启动台启动长驻服务:隐藏窗口并放入新进程组,
|
||||
|
||||
147
profile.go
147
profile.go
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 服务商的统一抽象。
|
||||
|
||||
69
shell.go
69
shell.go
@@ -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 查询开机自启是否已注册。
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -1,306 +1,16 @@
|
||||
package main
|
||||
|
||||
// 账号全链路集成测试:注册 → 登录 → 加密 Key 推送 → 修改密码(密文轮换)→ 新密码登录 → 再同步。
|
||||
// 需要本机 MySQL(root/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")
|
||||
}
|
||||
|
||||
17
sync_test.go
17
sync_test.go
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 机器码同步:项目身份 / 机器路径分离 ----------
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,283 +1,16 @@
|
||||
package main
|
||||
|
||||
// 团队协作全链路集成测试(三用户:boss=owner、alice=admin、bob=member):
|
||||
// 建团队 → 邀请 → 角色权限校验 → 任务指派/流转/催办 → 通知拉取转本地消息 →
|
||||
// 共享个人待办 → 日报提交/看板可见性/催交 → 无 Key 摘要报 AI_NO_KEY。
|
||||
// 复用 sync_integration_test.go 的 itDDL;需要本机 MySQL(root/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") {
|
||||
// 未配置 Key:AI_KEY_MISSING 先于权限校验返回
|
||||
t.Fatalf("digest without key want AI_KEY_MISSING, got %v", e)
|
||||
}
|
||||
|
||||
// —— 成员管理收尾:owner 升 bob 为 admin,admin 移除成员,成员退出 ——
|
||||
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")
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package main
|
||||
|
||||
// 团队协作本地单测:徽标计数、共享打标置脏、资料校验、历史节点与时间解析。
|
||||
// 团队远端流程见 team_integration_test.go(需要本机 MySQL)。
|
||||
// 团队远端流程见 team_integration_test.go(需要 TEST_API_URL)。
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
|
||||
@@ -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
86
tools/package-api.ps1
Normal 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
226
update.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user