From 9ccaa81497b48aef1e17917ecae80709bc9c6037 Mon Sep 17 00:00:00 2001 From: ji ruo <79234113+RuoJi6@users.noreply.github.com> Date: Tue, 10 Nov 2026 20:03:14 +0800 Subject: [PATCH] feat: add English interface localization and fix preview crashes --- server/auth.go | 61 +- server/basic_auth.go | 21 +- server/chat_mentions.go | 4 +- server/chat_mentions_test.go | 27 + server/egress_probe.go | 43 +- server/product_errors.go | 19 + server/product_errors_test.go | 86 + web/package-lock.json | 68 + web/package.json | 7 +- web/src/app/(auth)/layout.tsx | 11 +- web/src/app/(auth)/login/page.tsx | 111 +- web/src/app/(auth)/setup/page.tsx | 38 +- .../(main)/_components/egress-ip-banner.tsx | 48 +- web/src/app/(main)/_components/live-clock.tsx | 7 +- .../app/(main)/_components/main-content.tsx | 9 +- .../_components/sidebar/account-switcher.tsx | 6 +- .../_components/sidebar/app-sidebar.tsx | 7 +- .../sidebar/change-password-dialog.tsx | 26 +- .../_components/sidebar/layout-controls.tsx | 76 +- .../(main)/_components/sidebar/nav-user.tsx | 6 +- .../_components/sidebar/search-dialog.tsx | 60 +- .../_components/sidebar/theme-switcher.tsx | 4 +- .../app/(main)/_components/update-badge.tsx | 10 +- web/src/app/(main)/chat/page.tsx | 313 +- web/src/app/(main)/dashboard/page.tsx | 274 +- web/src/app/(main)/function/assets/page.tsx | 241 +- web/src/app/(main)/function/commands/page.tsx | 71 +- .../findings/_components/asset-tree.tsx | 55 +- .../findings/_components/findings-table.tsx | 123 +- .../(main)/function/findings/case/page.tsx | 73 +- .../function/findings/detail/lineage.tsx | 12 +- .../(main)/function/findings/detail/page.tsx | 560 ++-- web/src/app/(main)/function/findings/page.tsx | 191 +- .../app/(main)/function/llm-records/page.tsx | 117 +- .../app/(main)/function/schedules/page.tsx | 417 +-- web/src/app/(main)/function/sync/page.tsx | 167 +- .../tasks/detail/_tabs/assets-tab.tsx | 171 +- .../tasks/detail/_tabs/broadcast-tab.tsx | 203 +- .../tasks/detail/_tabs/coverage-graph-tab.tsx | 601 ++-- .../tasks/detail/_tabs/findings-tab.tsx | 114 +- .../tasks/detail/_tabs/overview-tab.tsx | 288 +- .../tasks/detail/_tabs/report-tab.tsx | 16 +- .../tasks/detail/_tabs/retests-tab.tsx | 64 +- .../tasks/detail/_tabs/sessions-tab.tsx | 330 +- .../app/(main)/function/tasks/detail/page.tsx | 169 +- web/src/app/(main)/function/tasks/page.tsx | 1966 ++++------- .../app/(main)/function/workspace/page.tsx | 140 +- web/src/app/(main)/system/agents/page.tsx | 147 +- .../(main)/system/intercept/assets/page.tsx | 155 +- web/src/app/(main)/system/intercept/page.tsx | 695 ++-- .../(main)/system/llm/_components/retry.tsx | 191 +- web/src/app/(main)/system/llm/page.tsx | 340 +- web/src/app/(main)/system/logs/page.tsx | 88 +- web/src/app/(main)/system/mcp/page.tsx | 142 +- .../notify/_components/channel-fields.ts | 83 +- .../notify/_components/channel-form.tsx | 43 +- .../notify/_components/delivery-list.tsx | 70 +- .../system/notify/_components/stat-tile.tsx | 10 +- web/src/app/(main)/system/notify/page.tsx | 183 +- .../settings/_components/basic-auth-card.tsx | 43 +- .../settings/_components/update-card.tsx | 201 +- web/src/app/(main)/system/settings/page.tsx | 698 ++-- web/src/app/(main)/system/skills/page.tsx | 661 ++-- web/src/app/(main)/system/tools/page.tsx | 461 ++- web/src/app/layout.tsx | 29 +- web/src/components/agent-editor.tsx | 712 ++-- .../components/approval-execution-focus.tsx | 22 +- web/src/components/approval-records.tsx | 559 ++-- web/src/components/asset-dsl-search.tsx | 225 +- .../asset-intercept-rules-editor.tsx | 18 +- web/src/components/copy-button.tsx | 23 +- web/src/components/credential-content.tsx | 98 +- web/src/components/date-range-picker.tsx | 11 +- web/src/components/date-select.tsx | 15 +- web/src/components/exploration-graph.tsx | Bin 26890 -> 28855 bytes web/src/components/finding-auto-chat.tsx | 182 +- web/src/components/finding-case-list.tsx | 133 +- web/src/components/finding-date-filter.tsx | 61 +- .../components/finding-reproduction-panel.tsx | 59 +- web/src/components/finding-retest-dialog.tsx | 26 +- web/src/components/finding-retest-panel.tsx | 58 +- web/src/components/finding-traffic-panel.tsx | 76 +- web/src/components/http-code-block.tsx | 46 +- web/src/components/intelligence-panel.tsx | 192 +- web/src/components/language-switcher.tsx | 49 + web/src/components/link-traffic-dialog.tsx | 35 +- web/src/components/markdown.tsx | 73 +- web/src/components/mention-textarea.tsx | 55 +- web/src/components/packet-code-editor.tsx | 14 +- web/src/components/remark-editor.tsx | 53 +- .../components/repeater/repeater-editor.tsx | 58 +- .../components/repeater/repeater-history.tsx | 19 +- .../repeater/repeater-navigator.tsx | 106 +- web/src/components/repeater/repeater-store.ts | 10 +- .../repeater/repeater-text-pane.tsx | 20 +- .../repeater/repeater-workspace.tsx | 57 +- web/src/components/scope-text-editor.tsx | 41 +- .../components/side-question-workspace.tsx | 147 +- web/src/components/status-badge.tsx | 19 +- web/src/components/table-pagination.tsx | 14 +- web/src/components/task-llm-profile-chain.tsx | 48 +- web/src/components/task-template-controls.tsx | 121 +- web/src/components/time-select.tsx | 21 +- web/src/components/todo-popover.tsx | 22 +- .../components/traffic-evidence-viewer.tsx | 50 +- web/src/components/traffic-history.tsx | 176 +- web/src/components/traffic-picker-dialog.tsx | 61 +- web/src/components/transcript.tsx | 243 +- web/src/components/ui/carousel.tsx | 8 +- web/src/components/ui/command.tsx | 11 +- web/src/components/ui/dialog.tsx | 8 +- web/src/components/ui/pagination.tsx | 21 +- web/src/components/ui/sheet.tsx | 5 +- web/src/components/ui/sidebar.tsx | 15 +- web/src/components/ui/sortable-head.tsx | 7 +- web/src/components/ui/spinner.tsx | 7 +- web/src/hooks/use-side-questions.ts | 2 +- web/src/i18n/PAGE-AUDIT.md | 43 + web/src/i18n/README.md | 51 + web/src/i18n/agent-display.ts | 31 + web/src/i18n/chat-ui-messages.ts | 102 + web/src/i18n/config.ts | 8 + web/src/i18n/error-messages.ts | 34 + web/src/i18n/errors.ts | 10 + web/src/i18n/fixed-ui-text.ts | 18 + web/src/i18n/i18n.test.cjs | 459 +++ web/src/i18n/inline-editor-messages.ts | 23 + web/src/i18n/instance.ts | 21 + web/src/i18n/messages.ts | 300 ++ web/src/i18n/notify-messages.ts | 242 ++ web/src/i18n/notify-runtime.ts | 46 + web/src/i18n/overview-messages.ts | 204 ++ web/src/i18n/provider.tsx | 41 + web/src/i18n/schedule-messages.ts | 205 ++ web/src/i18n/schedule-runtime.ts | 58 + web/src/i18n/screen-catalog.json | 2974 +++++++++++++++++ web/src/i18n/screen-pages.test.cjs | 368 ++ web/src/i18n/settings-messages.ts | 642 ++++ web/src/i18n/settings-runtime-messages.ts | 51 + web/src/i18n/settings-search.ts | 8 + web/src/i18n/task-messages.ts | 845 +++++ web/src/i18n/task-runtime.ts | 46 + web/src/i18n/terms.ts | 79 + web/src/i18n/use-date-locale.ts | 17 + web/src/i18n/use-live-translation.ts | 15 + web/src/i18n/use-ui-translation.ts | 15 + web/src/lib/api-errors.test.cjs | 93 + web/src/lib/api-intelligence.test.cjs | 106 + web/src/lib/api-notify.test.cjs | 110 + web/src/lib/api-schedules.test.cjs | 109 + web/src/lib/api.ts | 94 +- web/src/lib/chat-mentions.test.mjs | 20 + web/src/lib/chat-mentions.ts | 63 +- web/src/lib/coverage-graph-lifecycle.test.cjs | 217 ++ web/src/lib/execution-display.ts | 48 + web/src/lib/mock/handler.ts | 88 +- web/src/lib/mock/notifications.ts | 105 + web/src/lib/mock/schedules.ts | 120 + web/src/lib/schedule-display.ts | 91 + web/src/lib/status.ts | 6 +- web/src/lib/task-assets.ts | 12 +- web/src/lib/types.ts | 10 +- web/src/navigation/sidebar/sidebar-items.ts | 13 + 163 files changed, 17100 insertions(+), 6944 deletions(-) create mode 100644 server/product_errors.go create mode 100644 server/product_errors_test.go create mode 100644 web/src/components/language-switcher.tsx create mode 100644 web/src/i18n/PAGE-AUDIT.md create mode 100644 web/src/i18n/README.md create mode 100644 web/src/i18n/agent-display.ts create mode 100644 web/src/i18n/chat-ui-messages.ts create mode 100644 web/src/i18n/config.ts create mode 100644 web/src/i18n/error-messages.ts create mode 100644 web/src/i18n/errors.ts create mode 100644 web/src/i18n/fixed-ui-text.ts create mode 100644 web/src/i18n/i18n.test.cjs create mode 100644 web/src/i18n/inline-editor-messages.ts create mode 100644 web/src/i18n/instance.ts create mode 100644 web/src/i18n/messages.ts create mode 100644 web/src/i18n/notify-messages.ts create mode 100644 web/src/i18n/notify-runtime.ts create mode 100644 web/src/i18n/overview-messages.ts create mode 100644 web/src/i18n/provider.tsx create mode 100644 web/src/i18n/schedule-messages.ts create mode 100644 web/src/i18n/schedule-runtime.ts create mode 100644 web/src/i18n/screen-catalog.json create mode 100644 web/src/i18n/screen-pages.test.cjs create mode 100644 web/src/i18n/settings-messages.ts create mode 100644 web/src/i18n/settings-runtime-messages.ts create mode 100644 web/src/i18n/settings-search.ts create mode 100644 web/src/i18n/task-messages.ts create mode 100644 web/src/i18n/task-runtime.ts create mode 100644 web/src/i18n/terms.ts create mode 100644 web/src/i18n/use-date-locale.ts create mode 100644 web/src/i18n/use-live-translation.ts create mode 100644 web/src/i18n/use-ui-translation.ts create mode 100644 web/src/lib/api-errors.test.cjs create mode 100644 web/src/lib/api-intelligence.test.cjs create mode 100644 web/src/lib/api-notify.test.cjs create mode 100644 web/src/lib/api-schedules.test.cjs create mode 100644 web/src/lib/coverage-graph-lifecycle.test.cjs create mode 100644 web/src/lib/execution-display.ts create mode 100644 web/src/lib/mock/notifications.ts create mode 100644 web/src/lib/mock/schedules.ts create mode 100644 web/src/lib/schedule-display.ts diff --git a/server/auth.go b/server/auth.go index 00368ce..701b3be 100644 --- a/server/auth.go +++ b/server/auth.go @@ -126,11 +126,11 @@ func (s *Server) requireAuth(h http.Handler) http.Handler { } tok := extractToken(r) if tok == "" { - writeErr(w, 401, "未授权") + writeProductError(w, 401, "unauthorized", "未授权", nil) return } if !verifyJWT(tok, s.jwtKey) { - writeErr(w, 401, "token 无效或已过期") + writeProductError(w, 401, "token_expired", "token 无效或已过期", nil) return } h.ServeHTTP(w, r) @@ -148,7 +148,7 @@ func (s *Server) authStatus(w http.ResponseWriter, r *http.Request) { } hash, _, err := pg.GetSetting(authPassKey) if err != nil { - writeErr(w, 503, errDataSourceUnavailable) + writeProductError(w, 503, "data_unavailable", errDataSourceUnavailable, nil) return } writeJSON(w, 200, map[string]any{"initialized": hash != ""}) @@ -162,27 +162,28 @@ func (s *Server) authInit(w http.ResponseWriter, r *http.Request) { } existing, _, err := pg.GetSetting(authPassKey) if err != nil { - writeErr(w, 503, errDataSourceUnavailable) + writeProductError(w, 503, "data_unavailable", errDataSourceUnavailable, nil) return } if existing != "" { - writeErr(w, 403, "密码已设置") + writeProductError(w, 403, "password_already_set", "密码已设置", nil) return } var req struct { Password string `json:"password"` } if err := decode(r, &req); err != nil || req.Password == "" { - writeErr(w, 400, "密码不能为空") + writeProductError(w, 400, "password_required", "密码不能为空", nil) return } if msg := validatePassword(req.Password); msg != "" { - writeErr(w, 400, msg) + code, params := passwordValidationCode(req.Password) + writeProductError(w, 400, code, msg, params) return } hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) if err != nil { - writeErr(w, 500, "密码加密失败") + writeProductError(w, 500, "password_hash_failed", "密码加密失败", nil) return } // 用 INSERT ... ON CONFLICT DO NOTHING 而不是 upsert:上面那次 GetSetting 只是 @@ -190,16 +191,16 @@ func (s *Server) authInit(w http.ResponseWriter, r *http.Request) { // 这期间别的请求完全可能先把密码设好,而读检查本身也可能因故障而失效。 inserted, err := pg.InsertSettingIfAbsent(authPassKey, string(hash)) if err != nil { - writeErr(w, 500, "保存失败: "+err.Error()) + writeProductError(w, 500, "save_failed", "保存失败: "+err.Error(), nil) return } if !inserted { - writeErr(w, 403, "密码已设置") + writeProductError(w, 403, "password_already_set", "密码已设置", nil) return } tok, err := signJWT(s.jwtKey) if err != nil { - writeErr(w, 500, "token 生成失败") + writeProductError(w, 500, "token_failed", "token 生成失败", nil) return } writeJSON(w, 200, map[string]any{"token": tok}) @@ -214,7 +215,7 @@ func (s *Server) authChangePassword(w http.ResponseWriter, r *http.Request) { return } if !verifyJWT(extractToken(r), s.jwtKey) { - writeErr(w, 401, "未授权") + writeProductError(w, 401, "unauthorized", "未授权", nil) return } var req struct { @@ -222,37 +223,38 @@ func (s *Server) authChangePassword(w http.ResponseWriter, r *http.Request) { NewPassword string `json:"new_password"` } if err := decode(r, &req); err != nil { - writeErr(w, 400, "请求格式错误") + writeProductError(w, 400, "invalid_request", "请求格式错误", nil) return } if req.NewPassword == "" { - writeErr(w, 400, "新密码不能为空") + writeProductError(w, 400, "new_password_required", "新密码不能为空", nil) return } if msg := validatePassword(req.NewPassword); msg != "" { - writeErr(w, 400, msg) + code, params := passwordValidationCode(req.NewPassword) + writeProductError(w, 400, code, msg, params) return } hash, ok, err := pg.GetSetting(authPassKey) if err != nil { - writeErr(w, 503, errDataSourceUnavailable) + writeProductError(w, 503, "data_unavailable", errDataSourceUnavailable, nil) return } if !ok || hash == "" { - writeErr(w, 403, "密码未初始化,请先设置密码") + writeProductError(w, 403, "password_not_initialized", "密码未初始化,请先设置密码", nil) return } if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(req.OldPassword)); err != nil { - writeErr(w, 401, "当前密码错误") + writeProductError(w, 401, "current_password_incorrect", "当前密码错误", nil) return } newHash, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost) if err != nil { - writeErr(w, 500, "密码加密失败") + writeProductError(w, 500, "password_hash_failed", "密码加密失败", nil) return } if err := pg.SetSetting(authPassKey, string(newHash)); err != nil { - writeErr(w, 500, "保存失败: "+err.Error()) + writeProductError(w, 500, "save_failed", "保存失败: "+err.Error(), nil) return } writeJSON(w, 200, map[string]any{"ok": true}) @@ -269,30 +271,37 @@ func (s *Server) authLogin(w http.ResponseWriter, r *http.Request) { Password string `json:"password"` } if err := decode(r, &req); err != nil { - writeErr(w, 400, "请求格式错误") + writeProductError(w, 400, "invalid_request", "请求格式错误", nil) return } if req.Username != "ARTEX" { - writeErr(w, 401, "用户名或密码错误") + writeProductError(w, 401, "invalid_credentials", "用户名或密码错误", nil) return } hash, ok, err := pg.GetSetting(authPassKey) if err != nil { - writeErr(w, 503, errDataSourceUnavailable) + writeProductError(w, 503, "data_unavailable", errDataSourceUnavailable, nil) return } if !ok || hash == "" { - writeErr(w, 403, "密码未初始化,请先设置密码") + writeProductError(w, 403, "password_not_initialized", "密码未初始化,请先设置密码", nil) return } if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(req.Password)); err != nil { - writeErr(w, 401, "用户名或密码错误") + writeProductError(w, 401, "invalid_credentials", "用户名或密码错误", nil) return } tok, err := signJWT(s.jwtKey) if err != nil { - writeErr(w, 500, "token 生成失败") + writeProductError(w, 500, "token_failed", "token 生成失败", nil) return } writeJSON(w, 200, map[string]any{"token": tok}) } + +func passwordValidationCode(password string) (string, map[string]any) { + if utf8.RuneCountInString(password) < minPasswordRunes { + return "password_too_short", map[string]any{"min": minPasswordRunes} + } + return "password_too_long", map[string]any{"max": maxPasswordBytes} +} diff --git a/server/basic_auth.go b/server/basic_auth.go index dc9c1c4..1add6c1 100644 --- a/server/basic_auth.go +++ b/server/basic_auth.go @@ -140,13 +140,13 @@ func (s *Server) requireBasicAuth(next http.Handler) http.Handler { if !ok || len(password) > maxPasswordBytes || username != cfg.Username || bcrypt.CompareHashAndPassword([]byte(cfg.PasswordHash), []byte(password)) != nil { w.Header().Set("WWW-Authenticate", `Basic realm="Restricted", charset="UTF-8"`) - writeErr(w, http.StatusUnauthorized, "请先完成 HTTP Basic Auth 验证") + writeProductError(w, http.StatusUnauthorized, "basic_auth_required", "请先完成 HTTP Basic Auth 验证", nil) return } // Browser API requests already use Authorization: Bearer. A separate // HttpOnly cookie keeps the gate satisfied without replacing that header. if err := s.setBasicAuthCookie(w, r, cfg); err != nil { - writeErr(w, 500, "认证会话生成失败") + writeProductError(w, 500, "auth_session_failed", "认证会话生成失败", nil) return } next.ServeHTTP(w, r) @@ -169,7 +169,7 @@ func (s *Server) saveBasicAuthSettings(w http.ResponseWriter, r *http.Request) { Password string `json:"password"` } if err := decode(r, &req); err != nil { - writeErr(w, 400, "请求格式错误") + writeProductError(w, 400, "invalid_request", "请求格式错误", nil) return } g := &s.basicAuth @@ -179,7 +179,7 @@ func (s *Server) saveBasicAuthSettings(w http.ResponseWriter, r *http.Request) { cfg, store := g.cfg, g.store g.mu.RUnlock() if store == nil { - writeErr(w, 503, errDataSourceUnavailable) + writeProductError(w, 503, "data_unavailable", errDataSourceUnavailable, nil) return } if req.Enabled != nil { @@ -189,23 +189,24 @@ func (s *Server) saveBasicAuthSettings(w http.ResponseWriter, r *http.Request) { cfg.Username = strings.TrimSpace(*req.Username) } if (cfg.Enabled || cfg.Username != "") && !validBasicUsername(cfg.Username) { - writeErr(w, 400, "Basic Auth 用户名不能为空、超过 128 字节或包含冒号及控制字符") + writeProductError(w, 400, "basic_auth_username_invalid", "Basic Auth 用户名不能为空、超过 128 字节或包含冒号及控制字符", nil) return } if req.Password != "" { if msg := validatePassword(req.Password); msg != "" { - writeErr(w, 400, msg) + code, params := passwordValidationCode(req.Password) + writeProductError(w, 400, code, msg, params) return } hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) if err != nil { - writeErr(w, 500, "密码加密失败") + writeProductError(w, 500, "password_hash_failed", "密码加密失败", nil) return } cfg.PasswordHash = string(hash) } if cfg.Enabled && cfg.PasswordHash == "" { - writeErr(w, 400, "请先设置 Basic Auth 密码") + writeProductError(w, 400, "basic_auth_password_required", "请先设置 Basic Auth 密码", nil) return } // Every save revokes previous gate cookies, including disable/re-enable. @@ -214,7 +215,7 @@ func (s *Server) saveBasicAuthSettings(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), basicAuthSaveTimeout) defer cancel() if err != nil || store.SetSettingContext(ctx, basicAuthKey, string(data)) != nil { - writeErr(w, 503, "Basic Auth 配置保存失败,请重试") + writeProductError(w, 503, "basic_auth_save_failed", "Basic Auth 配置保存失败,请重试", nil) return } // Hashing, persistence and response output never hold the request mutex. @@ -226,7 +227,7 @@ func (s *Server) saveBasicAuthSettings(w http.ResponseWriter, r *http.Request) { w.Header().Set("Cache-Control", "no-store") // Keep the administrator who just saved the settings in their session. if err := s.setBasicAuthCookie(w, r, cfg); err != nil { - writeErr(w, 500, "认证会话生成失败") + writeProductError(w, 500, "auth_session_failed", "认证会话生成失败", nil) return } writeJSON(w, 200, basicAuthPublicSettings(cfg)) diff --git a/server/chat_mentions.go b/server/chat_mentions.go index 6f237cb..6adf61d 100644 --- a/server/chat_mentions.go +++ b/server/chat_mentions.go @@ -17,10 +17,12 @@ const maxChatMentions = 10 // The visible token survives drafts, uploads, retries and conversation history. // Labels are only for display: the server trusts only the type and numeric ID. -var chatMentionPattern = regexp.MustCompile(`@\[(漏洞|资产|企业|接口|IP|应用|域名|子域名|服务)#([0-9]+)(?: [^\]\r\n]*)?\]`) +var chatMentionPattern = regexp.MustCompile(`@\[(finding|asset|company|endpoint|ip|app|root_domain|subdomain|service|漏洞|资产|企业|接口|IP|应用|域名|子域名|服务)#([0-9]+)(?: [^\]\r\n]*)?\]`) var chatMentionKinds = map[string]string{ "漏洞": "finding", "资产": "asset", "企业": "company", "接口": "endpoint", "IP": "ip", "应用": "app", "域名": "root_domain", "子域名": "subdomain", "服务": "service", + "finding": "finding", "asset": "asset", "company": "company", "endpoint": "endpoint", + "ip": "ip", "app": "app", "root_domain": "root_domain", "subdomain": "subdomain", "service": "service", } type chatMentionRef struct { diff --git a/server/chat_mentions_test.go b/server/chat_mentions_test.go index 616a00e..a7fcee1 100644 --- a/server/chat_mentions_test.go +++ b/server/chat_mentions_test.go @@ -349,3 +349,30 @@ func TestChatMentionConversationReceivesServerDetails(t *testing.T) { t.Fatal("invalid reference persisted a turn") } } + +func TestChatMentionStableAndLegacyTokens(t *testing.T) { + kinds := []string{"finding", "asset", "company", "endpoint", "ip", "app", "root_domain", "subdomain", "service"} + legacy := []string{"漏洞", "资产", "企业", "接口", "IP", "应用", "域名", "子域名", "服务"} + for i, kind := range kinds { + message := fmt.Sprintf("@[%s#%d original name] @[%s#%d 旧名称]", kind, i+1, legacy[i], i+1) + refs, err := parseChatMentions(message) + if err != nil || len(refs) != 1 || refs[0].Kind != kind || refs[0].ID != int64(i+1) { + t.Fatalf("%s: refs=%+v err=%v", kind, refs, err) + } + } + for _, input := range []string{"@[finding#0]", "@[endpoint#999999999999999999999999]"} { + if _, err := parseChatMentions(input); err == nil { + t.Fatalf("accepted invalid token %s", input) + } + } + var many strings.Builder + for i := 1; i <= 11; i++ { + fmt.Fprintf(&many, "@[asset#%d] ", i) + } + if _, err := parseChatMentions(many.String()); err == nil { + t.Fatal("accepted too many stable references") + } + if refs, err := parseChatMentions("@[unknown#1]"); err != nil || len(refs) != 0 { + t.Fatalf("accepted unknown kind: %+v %v", refs, err) + } +} diff --git a/server/egress_probe.go b/server/egress_probe.go index a848d7d..b5f6648 100644 --- a/server/egress_probe.go +++ b/server/egress_probe.go @@ -167,17 +167,19 @@ type egressCheck struct { Path string `json:"path"` // 实际使用的代理地址(空 = 直连) Skipped bool `json:"skipped,omitempty"` SkipReason string `json:"skip_reason,omitempty"` + SkipCode string `json:"skip_code,omitempty"` Result egressProbeResult `json:"result"` } // egressReport 是 POST /api/settings/proxy/test 的返回体。 type egressReport struct { - OK bool `json:"ok"` // 以"② 经全局代理"为准;未配代理时看"① 直连" - Capture bool `json:"capture"` // 当前是否开启流量捕获 - Configured string `json:"configured"` // 表单传上来的 global_proxy - AgentProxy string `json:"agent_proxy"` // ProxyAddr() 的实际返回值 - Checks []egressCheck `json:"checks"` - Warnings []string `json:"warnings,omitempty"` + OK bool `json:"ok"` // 以"② 经全局代理"为准;未配代理时看"① 直连" + Capture bool `json:"capture"` // 当前是否开启流量捕获 + Configured string `json:"configured"` // 表单传上来的 global_proxy + AgentProxy string `json:"agent_proxy"` // ProxyAddr() 的实际返回值 + Checks []egressCheck `json:"checks"` + Warnings []string `json:"warnings,omitempty"` + WarningDetails []productMessage `json:"warning_details,omitempty"` } // probeEgress 不碰数据库、不碰 Server,所以测试里可以直接调、完全离线。 @@ -430,21 +432,25 @@ func (s *Server) testEgressProxy(w http.ResponseWriter, r *http.Request) { }) } else { skipReason := "输入框为空,未配置代理" + skipCode := "no_proxy" if savedProxy != "" { // 说"未配置代理"会和 W1("Agent 仍使用 xxx")互相否定。 skipReason = "输入框为空;已保存的代理仍在生效" + skipCode = "saved_proxy_active" } rep.Checks = append(rep.Checks, egressCheck{ Key: "global", Label: "② 经全局代理的出口 IP", Skipped: true, SkipReason: skipReason, + SkipCode: skipCode, Result: egressProbeResult{Probes: []egressAttempt{}}, }) } rep.OK = judgeEgress(rep) rep.Warnings = egressWarnings(rep, pending, savedProxy) + rep.WarningDetails = egressWarningDetails(rep, pending, savedProxy) if req.Resume && rep.OK && !pending { // The navbar uses the same probe endpoint for display-only polling but // omits resume. Only an explicit settings-page test can release the @@ -477,18 +483,29 @@ func judgeEgress(rep egressReport) bool { // egressWarnings 生成"看数字容易忽略"的提醒。 func egressWarnings(rep egressReport, pending bool, savedProxy string) []string { var out []string + for _, notice := range egressWarningDetails(rep, pending, savedProxy) { + out = append(out, notice.Message) + } + return out +} + +func egressWarningDetails(rep egressReport, pending bool, savedProxy string) []productMessage { + var out []productMessage + add := func(code string, params map[string]any, message string) { + out = append(out, productMessage{Code: code, Params: params, Message: message}) + } safeSavedProxy := maskSecrets(savedProxy) // W1 agent 走的是已保存配置,不说清会被当成"测通了=配好了"。 if pending { switch { case rep.Configured == "": - out = append(out, "此地址尚未保存,Agent 当前仍使用 "+safeSavedProxy+"。保存后即改为直连。") + add("pending_direct", map[string]any{"proxy": safeSavedProxy}, "此地址尚未保存,Agent 当前仍使用 "+safeSavedProxy+"。保存后即改为直连。") case savedProxy == "": - out = append(out, "此地址尚未保存,Agent 当前仍为直连。"+ + add("pending_proxy", nil, "此地址尚未保存,Agent 当前仍为直连。"+ "上方结果对应输入框中的值,保存后生效。") default: - out = append(out, "此地址尚未保存,Agent 当前仍使用 "+safeSavedProxy+ + add("pending_change", map[string]any{"proxy": safeSavedProxy}, "此地址尚未保存,Agent 当前仍使用 "+safeSavedProxy+ "。上方结果对应输入框中的值,保存后生效。") } } @@ -509,26 +526,26 @@ func egressWarnings(rep egressReport, pending bool, savedProxy string) []string // W2 看已保存值;只在 ② 通过时提示——没通的话"这里通不代表"本身就不成立。 if !rep.Capture && proxyOK && strings.HasPrefix(strings.ToLower(savedProxy), "socks5") { - out = append(out, "当前是「关闭捕获 + socks5」:本测试用的是 Go 客户端(一定遵守代理),"+ + add("socks_without_capture", nil, "当前是「关闭捕获 + socks5」:本测试用的是 Go 客户端(一定遵守代理),"+ "但 agent 的工具不一定——curl 能走,python urllib 会直接报错。"+ "所以这里通不代表工具都能走;要稳定生效请开启流量捕获(由 MITM 亲自拨号)。") } // W3 两行相同 → 代理可能没生效,或对端只是透明转发。 if directIP != "" && proxyIP != "" && directIP == proxyIP { - out = append(out, fmt.Sprintf("直连与代理的出口 IP 相同(%s):可能代理未生效、只是透明转发,"+ + add("same_ip", map[string]any{"ip": directIP}, fmt.Sprintf("直连与代理的出口 IP 相同(%s):可能代理未生效、只是透明转发,"+ "也可能跳板机与本机在同一 NAT 后面(出口本来就相同)。"+ "要区分:在「自定义 IP 探测服务」里填一台内网回显服务,看它看到的源 IP 是否变成了跳板机。", directIP)) } // W4 同样看已保存值,否则会和 W1 自相矛盾。 if rep.Capture && savedProxy == "" { - out = append(out, "已开启流量捕获,但全局代理为空:捕获只负责记录,出口仍是直连。") + add("capture_direct", nil, "已开启流量捕获,但全局代理为空:捕获只负责记录,出口仍是直连。") } // W5 容器里的 127.0.0.1 指容器自己,连不到宿主机上的代理。 if isLoopbackProxy(rep.Configured) { - out = append(out, "代理地址是回环地址(127.0.0.1 / localhost)。"+ + add("loopback_proxy", nil, "代理地址是回环地址(127.0.0.1 / localhost)。"+ "如果 ARTEX 跑在容器里,这里的 127.0.0.1 指容器自身,连不到宿主机上的代理;"+ "请改用宿主机的可达 IP(如 http://192.168.x.x:端口)。") } diff --git a/server/product_errors.go b/server/product_errors.go new file mode 100644 index 0000000..b580729 --- /dev/null +++ b/server/product_errors.go @@ -0,0 +1,19 @@ +package server + +import "net/http" + +// productMessage keeps the original detail alongside a stable translation key. +type productMessage struct { + Code string `json:"code"` + Params map[string]any `json:"params,omitempty"` + Message string `json:"message"` +} + +// Preserve error for existing clients and add stable fields for localized UIs. +func writeProductError(w http.ResponseWriter, status int, code, message string, params map[string]any) { + payload := map[string]any{"error": message, "code": code} + if len(params) > 0 { + payload["params"] = params + } + writeJSON(w, status, payload) +} diff --git a/server/product_errors_test.go b/server/product_errors_test.go new file mode 100644 index 0000000..7f3789b --- /dev/null +++ b/server/product_errors_test.go @@ -0,0 +1,86 @@ +package server + +import ( + "encoding/json" + "net/http/httptest" + "strings" + "testing" +) + +func TestProductErrorPreservesLegacyMessage(t *testing.T) { + w := httptest.NewRecorder() + writeProductError(w, 400, "password_too_short", "密码长度至少 8 位", map[string]any{"min": 8}) + var payload struct { + Error string `json:"error"` + Code string `json:"code"` + Params map[string]int `json:"params"` + } + if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil { + t.Fatal(err) + } + if w.Code != 400 || payload.Error != "密码长度至少 8 位" || payload.Code != "password_too_short" || payload.Params["min"] != 8 { + t.Fatalf("unexpected localized error: %d %+v", w.Code, payload) + } +} + +func TestPasswordValidationCodes(t *testing.T) { + for _, item := range []struct { + password, code, param string + value int + }{ + {"短密码", "password_too_short", "min", 8}, + {"这是一个超过七十二字节上限的密码这是一个超过七十二字节上限的密码", "password_too_long", "max", 72}, + } { + if validatePassword(item.password) == "" { + t.Fatalf("expected invalid password for %s", item.code) + } + code, params := passwordValidationCode(item.password) + if code != item.code || params[item.param] != item.value { + t.Fatalf("code=%s params=%v", code, params) + } + } +} + +func TestBasicAuthErrorsIncludeTranslationCodes(t *testing.T) { + s, _ := basicAuthTestServer(t) + for _, tc := range []struct{ body, code string }{ + {`not-json`, "invalid_request"}, + {`{"enabled":true}`, "basic_auth_username_invalid"}, + {`{"username":"bad:name"}`, "basic_auth_username_invalid"}, + {`{"username":"entry","password":"short"}`, "password_too_short"}, + {`{"enabled":true,"username":"entry"}`, "basic_auth_password_required"}, + } { + w := saveBasicAuthForTest(s, tc.body) + var payload struct { + Error string `json:"error"` + Code string `json:"code"` + } + if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil { + t.Fatal(err) + } + if w.Code != 400 || payload.Code != tc.code || payload.Error == "" { + t.Fatalf("body=%s status=%d payload=%+v", tc.body, w.Code, payload) + } + } +} + +func TestEgressWarningDetailsPreserveLegacyTextAndMaskCredentials(t *testing.T) { + rep := egressReport{Configured: "http://new-proxy:8080"} + legacy := egressWarnings(rep, true, "http://user:secret@old-proxy:8080") + details := egressWarningDetails(rep, true, "http://user:secret@old-proxy:8080") + if len(legacy) != len(details) || len(details) != 1 || details[0].Code != "pending_change" { + t.Fatalf("legacy=%v details=%+v", legacy, details) + } + for i, notice := range details { + if notice.Message != legacy[i] { + t.Fatalf("legacy message changed: %+v", notice) + } + data, err := json.Marshal(notice) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(data), "secret") { + t.Fatal("proxy credentials exposed in localized warning") + } + } +} diff --git a/web/package-lock.json b/web/package-lock.json index 217ec58..b2bc4c9 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -33,6 +33,7 @@ "date-fns": "^4.4.0", "embla-carousel-react": "^8.6.0", "geist": "^1.7.2", + "i18next": "^27.1.0", "input-otp": "^1.4.2", "lucide-react": "^1.22.0", "next": "^16.2.9", @@ -42,6 +43,7 @@ "react-day-picker": "^10.0.1", "react-dom": "^19.2.7", "react-hook-form": "^7.80.0", + "react-i18next": "^18.0.1", "react-is": "^19.2.7", "react-markdown": "^10.1.0", "react-resizable-panels": "^4.12.0", @@ -6822,6 +6824,15 @@ "node": ">=16.9.0" } }, + "node_modules/html-parse-stringify": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-4.0.1.tgz", + "integrity": "sha512-0zHsZJrK7S3K2aucXWL6ycoYJ/iNtIcFHC/nYQgFklPtrv5LpJctIiSCroWZWeuoXvuyFdzp6KzjJQ+OT5MfFw==", + "license": "MIT", + "funding": { + "url": "https://locize.com" + } + }, "node_modules/html-url-attributes": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", @@ -6890,6 +6901,37 @@ "url": "https://github.com/sponsors/typicode" } }, + "node_modules/i18next": { + "version": "27.1.0", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-27.1.0.tgz", + "integrity": "sha512-xdjb2OXFRm33rClqknpJmmYBvrPcaoEQ7/4kur5uTrbqckqzkbRO0ns6gFFki5TDmHzbIrFYpNBXhaRafmhJ/Q==", + "funding": [ + { + "type": "individual", + "url": "https://www.locize.com/i18next" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + }, + { + "type": "individual", + "url": "https://www.locize.com" + } + ], + "license": "MIT", + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "typescript": "^5 || ^6 || ^7" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/iconv-lite": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", @@ -9595,6 +9637,32 @@ "react": "^16.8.0 || ^17 || ^18 || ^19" } }, + "node_modules/react-i18next": { + "version": "18.0.1", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-18.0.1.tgz", + "integrity": "sha512-kPWXI0E/lrPj3+/umcKNzYZVnWmEedLiamMzDIHfbs+5ANf6yzvlZsjKou8f7kaGkfMMDLIZBejV+4aSN1CyBw==", + "license": "MIT", + "dependencies": { + "html-parse-stringify": "^4.0.1" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "@types/react": "*", + "i18next": ">= 27.0.1", + "react": ">= 18.0.0", + "typescript": "^5 || ^6 || ^7" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, "node_modules/react-is": { "version": "19.2.7", "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.7.tgz", diff --git a/web/package.json b/web/package.json index 9f8c6ef..29b3b7f 100644 --- a/web/package.json +++ b/web/package.json @@ -11,13 +11,12 @@ "format": "biome format --write", "check": "biome check", "check:fix": "biome check --write", + "test:i18n": "node --experimental-strip-types --test src/i18n/i18n.test.cjs src/i18n/screen-pages.test.cjs src/lib/chat-mentions.test.mjs src/lib/api-errors.test.cjs src/lib/api-intelligence.test.cjs src/lib/coverage-graph-lifecycle.test.cjs", "prepare": "husky", "generate:presets": "ts-node -P tsconfig.scripts.json src/scripts/generate-theme-presets.ts" }, "lint-staged": { - "*.{js,ts,jsx,tsx}": [ - "biome check --write --no-errors-on-unmatched" - ] + "*.{js,ts,jsx,tsx}": ["biome check --write --no-errors-on-unmatched"] }, "dependencies": { "@antv/g6": "^5.1.1", @@ -45,6 +44,7 @@ "date-fns": "^4.4.0", "embla-carousel-react": "^8.6.0", "geist": "^1.7.2", + "i18next": "^27.1.0", "input-otp": "^1.4.2", "lucide-react": "^1.22.0", "next": "^16.2.9", @@ -54,6 +54,7 @@ "react-day-picker": "^10.0.1", "react-dom": "^19.2.7", "react-hook-form": "^7.80.0", + "react-i18next": "^18.0.1", "react-is": "^19.2.7", "react-markdown": "^10.1.0", "react-resizable-panels": "^4.12.0", diff --git a/web/src/app/(auth)/layout.tsx b/web/src/app/(auth)/layout.tsx index 5fbad15..d4d3768 100644 --- a/web/src/app/(auth)/layout.tsx +++ b/web/src/app/(auth)/layout.tsx @@ -1,5 +1,14 @@ import type { ReactNode } from "react"; +import { LanguageSwitcher } from "@/components/language-switcher"; + export default function AuthLayout({ children }: { readonly children: ReactNode }) { - return <>{children}; + return ( + <> +
+ +
+ {children} + + ); } diff --git a/web/src/app/(auth)/login/page.tsx b/web/src/app/(auth)/login/page.tsx index 851522b..35c1416 100644 --- a/web/src/app/(auth)/login/page.tsx +++ b/web/src/app/(auth)/login/page.tsx @@ -5,6 +5,7 @@ import { useEffect, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { AlertTriangle, ShieldCheck } from "lucide-react"; +import { useTranslation } from "react-i18next"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; @@ -15,6 +16,7 @@ import { api } from "@/lib/api"; import { auth } from "@/lib/auth"; export default function LoginPage() { + const { t } = useTranslation(); const router = useRouter(); const [password, setPassword] = useState(""); const [error, setError] = useState(""); @@ -55,14 +57,14 @@ export default function LoginPage() { .then(({ initialized }) => { if (!initialized) router.replace("/setup"); }) - .catch(() => setError("无法连接到后端服务")) + .catch(() => setError("auth.backendUnavailable")) .finally(() => setChecking(false)); }, [router]); async function handleSubmit(e: React.FormEvent) { e.preventDefault(); if (!agreed) { - setError("请先阅读并同意《使用须知》"); + setError("auth.agreementRequired"); return; } setLoading(true); @@ -72,7 +74,7 @@ export default function LoginPage() { auth.setToken(token); window.location.replace("/function/tasks"); } catch { - setError("用户名或密码错误"); + setError("auth.invalidCredentials"); } finally { setLoading(false); } @@ -81,7 +83,7 @@ export default function LoginPage() { if (checking) { return (
- 正在检查登录状态… + {t("auth.checking")}
); } @@ -103,22 +105,22 @@ export default function LoginPage() {
-

登录

-

欢迎回来,请输入密码以继续使用 ARTEX

+

{t("auth.login")}

+

{t("auth.welcome")}

- +
- + setPassword(e.target.value)} - placeholder="请输入密码" + placeholder={t("auth.enterPassword")} autoFocus autoComplete="current-password" /> @@ -131,19 +133,19 @@ export default function LoginPage() { className="mt-0.5" />
- {error &&

{error}

} + {error &&

{t(error, { defaultValue: error })}

}
@@ -156,10 +158,8 @@ export default function LoginPage() {
- ARTEX 使用须知与免责声明 -

- 版本 v1.0 · 生效日期 2026-09-18 · 请在登录前完整阅读以下全部条款 -

+ {t("terms.title")} +

{t("terms.version")}

@@ -169,12 +169,8 @@ export default function LoginPage() { className="max-h-[60vh] space-y-5 overflow-y-auto px-6 py-5 text-sm leading-relaxed text-muted-foreground" >

- 本《使用须知与免责声明》(以下简称"本声明")是您与 ARTEX - 项目作者及贡献者之间就使用本软件所达成的约定。请您在使用前审慎阅读、充分理解各条款内容,特别是以粗体或色块标注的免责、责任限制及禁止性条款。 - - {" "} - 一旦您下载、安装、访问或以任何方式使用本软件,即视为您已阅读、理解并同意接受本声明的全部约束。 - + {t("terms.introduction")} + {t("terms.acceptance")}

@@ -182,13 +178,9 @@ export default function LoginPage() { 1 - 第一条 · 定义与开源许可 + {t("terms.licenseTitle")} -

- 本软件(ARTEX)是一款基于 GNU Affero General Public License - v3.0(AGPL-3.0)发布的开源程序。您可依据该协议自由使用、复制、修改和分发本软件;但任何衍生作品(包括通过网络向第三方提供的在线服务)均须同样以 - AGPL-3.0 协议开源并向使用者公开对应的完整源代码。AGPL-3.0 完整条款以随附的 LICENSE 文件为准。 -

+

{t("terms.license")}

@@ -196,11 +188,9 @@ export default function LoginPage() { 2 - 第二条 · 授权使用范围 + {t("terms.scopeTitle")} -

- 本软件仅供个人学习、代码研究、安全技术原理探讨,以及在您自行搭建的本地隔离环境中进行技术验证之用,适用于学习、学术研究、代码审阅等非攻击性、非破坏性用途。除本条明确许可的情形外,您不得将本软件用于任何其他目的。 -

+

{t("terms.scope")}

@@ -209,16 +199,14 @@ export default function LoginPage() { 3 - 第三条 · 禁止行为 + {t("terms.prohibitedTitle")}
@@ -227,12 +215,9 @@ export default function LoginPage() { 4 - 第四条 · 知识产权 + {t("terms.ipTitle")} -

- 本软件的著作权及相关知识产权归项目作者及贡献者所有,并在 AGPL-3.0 - 协议约定的范围内向您授予相应权利。除该协议明确授予的权利外,本声明未以明示或默示方式授予您任何其他权利。 -

+

{t("terms.ip")}

@@ -240,11 +225,9 @@ export default function LoginPage() { 5 - 第五条 · 数据与隐私 + {t("terms.privacyTitle")} -

- 本软件为可自行部署的开源程序,作者不运营任何集中式服务、亦不会收集或上传您的使用数据。您在使用过程中产生、处理或接触的一切数据,均由您自行掌控并负责其合法性与安全性;因数据处理不当引发的任何后果由您自行承担。 -

+

{t("terms.privacy")}

@@ -252,14 +235,11 @@ export default function LoginPage() { 6 - 第六条 · 合规与法律责任 + {t("terms.complianceTitle")}

- 您应自行遵守所在国家或地区关于网络安全、数据安全与个人信息保护、计算机犯罪等方面的全部法律法规(在中国大陆包括但不限于《网络安全法》《数据安全法》《个人信息保护法》及相关司法解释)。 - - {" "} - 因您违反上述法律法规或本声明约定而产生的一切法律责任与后果,均由您本人独立承担,与本软件作者及贡献者无关。 - + {t("terms.compliance")} + {t("terms.responsibility")}

@@ -268,12 +248,9 @@ export default function LoginPage() { 7 - 第七条 · 免责声明与责任限制 + {t("terms.disclaimerTitle")} -

- 本软件按"现状(AS IS)"与"现有(AS - AVAILABLE)"状态提供,不附带任何明示或默示的担保,包括但不限于对适销性、特定用途适用性、准确性及不侵权的担保。在适用法律允许的最大范围内,本软件作者及贡献者不对因使用或无法使用本软件(无论使用方式是否得当)而导致的任何直接、间接、偶然、特殊或后果性损失承担责任,包括但不限于数据丢失、系统损坏、业务中断、利润损失或法律纠纷。 -

+

{t("terms.disclaimer")}

@@ -281,18 +258,14 @@ export default function LoginPage() { 8 - 第八条 · 条款变更与最终解释 + {t("terms.changesTitle")} -

- 作者有权根据法律法规或项目发展需要不时更新本声明,更新后的版本将随项目发布并自公布之日起生效;您继续使用本软件即视为接受修订后的条款。在法律允许的范围内,本声明的最终解释权归项目作者所有。若本声明任一条款被认定为无效,不影响其余条款的效力。 -

+

{t("terms.changes")}

-

- {readToEnd ? "您已浏览全部条款" : "请将条款滚动至底部后再确认"} -

+

{readToEnd ? t("terms.readAll") : t("terms.scrollToEnd")}

diff --git a/web/src/app/(auth)/setup/page.tsx b/web/src/app/(auth)/setup/page.tsx index b09cf02..fbc7ddb 100644 --- a/web/src/app/(auth)/setup/page.tsx +++ b/web/src/app/(auth)/setup/page.tsx @@ -4,17 +4,21 @@ import { useEffect, useState } from "react"; import { useRouter } from "next/navigation"; +import { useTranslation } from "react-i18next"; + import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { localizedError } from "@/i18n/errors"; import { api } from "@/lib/api"; import { auth } from "@/lib/auth"; export default function SetupPage() { + const { t } = useTranslation(); const router = useRouter(); const [password, setPassword] = useState(""); const [confirm, setConfirm] = useState(""); - const [error, setError] = useState(""); + const [error, setError] = useState(""); const [loading, setLoading] = useState(false); const [checking, setChecking] = useState(true); // 查不到初始化状态时不能默认当成"未初始化"——那样会把初始化表单摆给一个 @@ -28,7 +32,7 @@ export default function SetupPage() { if (initialized) router.replace("/login"); }) .catch((err) => { - setError(err instanceof Error ? err.message : "无法连接到后端服务"); + setError(err); setUnavailable(true); }) .finally(() => setChecking(false)); @@ -37,11 +41,11 @@ export default function SetupPage() { async function handleSubmit(e: React.FormEvent) { e.preventDefault(); if (password !== confirm) { - setError("两次输入的密码不一致"); + setError("auth.passwordMismatch"); return; } if (password.length < 8) { - setError("密码长度至少 8 位"); + setError("auth.passwordMin"); return; } setLoading(true); @@ -51,7 +55,7 @@ export default function SetupPage() { auth.setToken(token); router.replace("/function/tasks"); } catch (err) { - setError(err instanceof Error ? err.message : "初始化失败"); + setError(err); } finally { setLoading(false); } @@ -76,48 +80,48 @@ export default function SetupPage() {
-

{unavailable ? "无法确认初始化状态" : "初始化密码"}

+

+ {unavailable ? t("auth.statusUnknown") : t("auth.initPassword")} +

- {unavailable - ? "后端或数据库暂时不可用。为避免覆盖实例上已有的密码,初始化入口已临时关闭——请恢复服务后重试。" - : "首次使用 ARTEX,请为账户设置一个登录密码(至少 8 位)"} + {unavailable ? t("auth.setupUnavailable") : t("auth.setupWelcome")}

{unavailable ? (
- {error &&

{error}

} + {Boolean(error) &&

{localizedError(error, t)}

}
) : (
- + setPassword(e.target.value)} - placeholder="至少 8 位" + placeholder={t("auth.minPlaceholder")} autoFocus autoComplete="new-password" />
- + setConfirm(e.target.value)} - placeholder="再次输入密码" + placeholder={t("auth.reenterPassword")} autoComplete="new-password" />
- {error &&

{error}

} + {Boolean(error) &&

{localizedError(error, t)}

}
)} diff --git a/web/src/app/(main)/_components/egress-ip-banner.tsx b/web/src/app/(main)/_components/egress-ip-banner.tsx index c66560a..1f3145d 100644 --- a/web/src/app/(main)/_components/egress-ip-banner.tsx +++ b/web/src/app/(main)/_components/egress-ip-banner.tsx @@ -2,9 +2,11 @@ import * as React from "react"; +import type { TFunction } from "i18next"; import { Globe2Icon, Loader2Icon, RefreshCwIcon, XIcon } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { useLiveTranslation } from "@/i18n/use-live-translation"; import { api } from "@/lib/api"; import type { EgressCheck, EgressReport } from "@/lib/types"; import { cn } from "@/lib/utils"; @@ -36,7 +38,7 @@ export function EgressIpProvider({ children }: { children: React.ReactNode }) { return nextReport; } catch (cause) { setReport(null); - const message = cause instanceof Error ? cause.message : "未知错误"; + const message = cause instanceof Error ? cause.message : "settings.unknownError"; setError(message); throw cause; } finally { @@ -64,7 +66,10 @@ export function EgressIpProvider({ children }: { children: React.ReactNode }) { React.useEffect(() => { if (!enabled) return; const timer = window.setInterval(() => { - void api.settings().then((settings) => probeForProxy(settings.global_proxy ?? "")).catch(() => undefined); + void api + .settings() + .then((settings) => probeForProxy(settings.global_proxy ?? "")) + .catch(() => undefined); }, 10000); return () => window.clearInterval(timer); }, [enabled, probeForProxy]); @@ -108,24 +113,26 @@ export function useEgressIp() { return context; } -function checkMessage(check: EgressCheck | undefined) { - if (!check) return "未返回结果"; - if (check.skipped) return "未配置"; - if (check.result.ok) return check.result.exit_ip || "已连通"; - return "探测失败"; +function checkMessage(check: EgressCheck | undefined, t: TFunction) { + if (!check) return t("settings.noProbeResult"); + if (check.skipped) return t("settings.notConfigured"); + if (check.result.ok) return check.result.exit_ip || t("settings.connected"); + return t("settings.probeFailed"); } function IpValue({ label, check }: { label: string; check: EgressCheck | undefined }) { + const { t } = useLiveTranslation(); const failed = !!check && !check.skipped && !check.result.ok; return ( - + {label} {"· "} - {checkMessage(check)} + {checkMessage(check, t)} ); } export function EgressIpNavbar() { + const { t } = useLiveTranslation(); const { enabled, report, error, isLoading, probeEgress, dismiss } = useEgressIp(); if (!enabled) return null; @@ -136,19 +143,20 @@ export function EgressIpNavbar() {
diff --git a/web/src/app/(main)/_components/live-clock.tsx b/web/src/app/(main)/_components/live-clock.tsx index ed19102..9cea122 100644 --- a/web/src/app/(main)/_components/live-clock.tsx +++ b/web/src/app/(main)/_components/live-clock.tsx @@ -2,6 +2,8 @@ import * as React from "react"; +import { useTranslation } from "react-i18next"; + import { cn } from "@/lib/utils"; function formatClock(value: Date) { @@ -10,6 +12,7 @@ function formatClock(value: Date) { } export function LiveClock({ className }: { className?: string }) { + const { t } = useTranslation(); const [now, setNow] = React.useState(null); React.useEffect(() => { @@ -27,8 +30,8 @@ export function LiveClock({ className }: { className?: string }) { className, )} dateTime={now?.toISOString()} - aria-label="当前时间" - title="当前本地时间" + aria-label={t("clock.current")} + title={t("clock.local")} > {now ? formatClock(now) : "---- -- -- --:--:--"} diff --git a/web/src/app/(main)/_components/main-content.tsx b/web/src/app/(main)/_components/main-content.tsx index 4b9d453..b61ddb0 100644 --- a/web/src/app/(main)/_components/main-content.tsx +++ b/web/src/app/(main)/_components/main-content.tsx @@ -4,6 +4,9 @@ import { type ReactNode, useEffect, useState } from "react"; import { usePathname } from "next/navigation"; +import { useTranslation } from "react-i18next"; + +import { LanguageSwitcher } from "@/components/language-switcher"; import { Separator } from "@/components/ui/separator"; import { SidebarTrigger } from "@/components/ui/sidebar"; import { useCurrentUser } from "@/hooks/use-current-user"; @@ -42,6 +45,7 @@ export function MainContent({ children }: { children: ReactNode }) { } function MainContentInner({ children }: { children: ReactNode }) { + const { t } = useTranslation(); const currentUser = useCurrentUser(); const pathname = usePathname(); const [version, setVersion] = useState(""); @@ -80,9 +84,12 @@ function MainContentInner({ children }: { children: ReactNode }) {
{version && ( - 版本 · {version} + + {t("common.version")} · {version} + )} + diff --git a/web/src/app/(main)/_components/sidebar/account-switcher.tsx b/web/src/app/(main)/_components/sidebar/account-switcher.tsx index 6c69067..3181500 100644 --- a/web/src/app/(main)/_components/sidebar/account-switcher.tsx +++ b/web/src/app/(main)/_components/sidebar/account-switcher.tsx @@ -3,6 +3,7 @@ import { useState } from "react"; import { Check, KeyRound, LogOut } from "lucide-react"; +import { useTranslation } from "react-i18next"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { @@ -28,6 +29,7 @@ export function AccountSwitcher({ readonly role: string; }>; }) { + const { t } = useTranslation(); const [activeUser, setActiveUser] = useState(users[0]); const [pwOpen, setPwOpen] = useState(false); @@ -80,11 +82,11 @@ export function AccountSwitcher({ setPwOpen(true)}> - 修改密码 + {t("account.changePassword")} - 退出登录 + {t("account.logout")} diff --git a/web/src/app/(main)/_components/sidebar/app-sidebar.tsx b/web/src/app/(main)/_components/sidebar/app-sidebar.tsx index 7d72693..ddf2ecc 100644 --- a/web/src/app/(main)/_components/sidebar/app-sidebar.tsx +++ b/web/src/app/(main)/_components/sidebar/app-sidebar.tsx @@ -2,6 +2,7 @@ import Link from "next/link"; +import { useTranslation } from "react-i18next"; import { useShallow } from "zustand/react/shallow"; import { @@ -15,15 +16,15 @@ import { } from "@/components/ui/sidebar"; import { APP_CONFIG } from "@/config/app-config"; import { useCurrentUser } from "@/hooks/use-current-user"; -import { sidebarItems } from "@/navigation/sidebar/sidebar-items"; +import { getSidebarItems } from "@/navigation/sidebar/sidebar-items"; import { usePreferencesStore } from "@/stores/preferences/preferences-provider"; import { NavMain } from "./nav-main"; import { NavUser } from "./nav-user"; import { SidebarSupportCard } from "./sidebar-support-card"; - export function AppSidebar({ ...props }: React.ComponentProps) { + const { t } = useTranslation(); const currentUser = useCurrentUser(); const { sidebarVariant, sidebarCollapsible, isSynced } = usePreferencesStore( useShallow((s) => ({ @@ -52,7 +53,7 @@ export function AppSidebar({ ...props }: React.ComponentProps) { - + {/* */} {/* */} diff --git a/web/src/app/(main)/_components/sidebar/change-password-dialog.tsx b/web/src/app/(main)/_components/sidebar/change-password-dialog.tsx index 8db93f0..0a30a39 100644 --- a/web/src/app/(main)/_components/sidebar/change-password-dialog.tsx +++ b/web/src/app/(main)/_components/sidebar/change-password-dialog.tsx @@ -2,6 +2,7 @@ import * as React from "react"; +import { useTranslation } from "react-i18next"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; @@ -15,6 +16,7 @@ import { } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { localizedError } from "@/i18n/errors"; import { api } from "@/lib/api"; export function ChangePasswordDialog({ @@ -24,6 +26,7 @@ export function ChangePasswordDialog({ readonly open: boolean; readonly onOpenChange: (open: boolean) => void; }) { + const { t } = useTranslation(); const [oldPassword, setOldPassword] = React.useState(""); const [newPassword, setNewPassword] = React.useState(""); const [confirmPassword, setConfirmPassword] = React.useState(""); @@ -41,21 +44,21 @@ export function ChangePasswordDialog({ function submit(e: React.FormEvent) { e.preventDefault(); if (!oldPassword || !newPassword) { - toast.error("请填写当前密码和新密码"); + toast.error(t("account.passwordRequired")); return; } if (newPassword !== confirmPassword) { - toast.error("两次输入的新密码不一致"); + toast.error(t("account.passwordMismatch")); return; } setSaving(true); api .changePassword(oldPassword, newPassword) .then(() => { - toast.success("密码已修改"); + toast.success(t("account.passwordChanged")); onOpenChange(false); }) - .catch((err) => toast.error(`修改失败:${(err as Error).message}`)) + .catch((err) => toast.error(t("account.changeFailed", { message: localizedError(err, t) }))) .finally(() => setSaving(false)); } @@ -64,14 +67,15 @@ export function ChangePasswordDialog({
- 修改密码 + {t("account.changePassword")} - 用户名固定为 ARTEX。需先输入当前密码验证;修改后已签发的登录 token 仍有效直至过期。 + {t("account.usernameFixed")} ARTEX + {t("account.tokenNotice")}
- +
- +
- + diff --git a/web/src/app/(main)/_components/sidebar/layout-controls.tsx b/web/src/app/(main)/_components/sidebar/layout-controls.tsx index 28091b4..bf36530 100644 --- a/web/src/app/(main)/_components/sidebar/layout-controls.tsx +++ b/web/src/app/(main)/_components/sidebar/layout-controls.tsx @@ -1,6 +1,7 @@ "use client"; import { Settings } from "lucide-react"; +import { useTranslation } from "react-i18next"; import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; @@ -23,6 +24,7 @@ import { applyThemePreset } from "@/lib/preferences/theme-utils"; import { usePreferencesStore } from "@/stores/preferences/preferences-provider"; export function LayoutControls() { + const { t } = useTranslation(); const themeMode = usePreferencesStore((s) => s.themeMode); const resolvedThemeMode = usePreferencesStore((s) => s.resolvedThemeMode); const setThemeMode = usePreferencesStore((s) => s.setThemeMode); @@ -99,22 +101,22 @@ export function LayoutControls() { return ( -
-

Preferences

-

Customize your dashboard layout preferences.

+

{t("layout.preferences")}

+

{t("layout.description")}

- + - + @@ -154,7 +156,7 @@ export function LayoutControls() {
- + - - Light + + {t("theme.light")} - - Dark + + {t("theme.dark")} - - System + + {t("theme.system")}
- + - - Centered + + {t("layout.centered")} - - Full Width + + {t("layout.fullWidth")}
- + - - Sticky + + {t("layout.sticky")} - - Scroll + + {t("layout.scroll")}
- + - - Inset + + {t("layout.inset")} - - Sidebar + + {t("layout.sidebar")} - - Floating + + {t("layout.floating")}
- + - - Icon + + {t("layout.icon")} - - OffCanvas + + {t("layout.offcanvas")}
diff --git a/web/src/app/(main)/_components/sidebar/nav-user.tsx b/web/src/app/(main)/_components/sidebar/nav-user.tsx index 6c0d314..743c1fa 100644 --- a/web/src/app/(main)/_components/sidebar/nav-user.tsx +++ b/web/src/app/(main)/_components/sidebar/nav-user.tsx @@ -3,6 +3,7 @@ import * as React from "react"; import { EllipsisVertical, KeyRound, LogOut } from "lucide-react"; +import { useTranslation } from "react-i18next"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { @@ -28,6 +29,7 @@ export function NavUser({ readonly avatar: string; }; }) { + const { t } = useTranslation(); const { isMobile } = useSidebar(); const [pwOpen, setPwOpen] = React.useState(false); @@ -79,12 +81,12 @@ export function NavUser({ setPwOpen(true)}> - 修改密码 + {t("account.changePassword")} - 退出登录 + {t("account.logout")} diff --git a/web/src/app/(main)/_components/sidebar/search-dialog.tsx b/web/src/app/(main)/_components/sidebar/search-dialog.tsx index d07251d..44d1a3d 100644 --- a/web/src/app/(main)/_components/sidebar/search-dialog.tsx +++ b/web/src/app/(main)/_components/sidebar/search-dialog.tsx @@ -5,6 +5,7 @@ import * as React from "react"; import { useRouter } from "next/navigation"; import { Search } from "lucide-react"; +import { useTranslation } from "react-i18next"; import { Button } from "@/components/ui/button"; import { @@ -18,7 +19,7 @@ import { CommandSeparator, } from "@/components/ui/command"; import type { NavMainItem } from "@/navigation/sidebar/sidebar-items"; -import { sidebarItems } from "@/navigation/sidebar/sidebar-items"; +import { getSidebarItems } from "@/navigation/sidebar/sidebar-items"; type SearchItem = { id: string; @@ -30,45 +31,6 @@ type SearchItem = { newTab?: boolean; }; -const sidebarGroupLabels = new Set(sidebarItems.flatMap((group) => (group.label ? [group.label] : []))); - -function getSubItemGroup(groupLabel: string | undefined, itemTitle: string) { - return sidebarGroupLabels.has(itemTitle) ? (groupLabel ?? "Other") : itemTitle; -} - -const searchItems: SearchItem[] = sidebarItems.flatMap((group) => - group.items.flatMap((item) => { - if (item.subItems) { - return item.subItems.map((sub) => ({ - id: sub.id, - group: getSubItemGroup(group.label, item.title), - label: sub.title, - url: sub.url, - icon: item.icon, - disabled: sub.disabled, - newTab: sub.newTab, - })); - } - return [ - { - id: item.id, - group: group.label ?? "Other", - label: item.title, - url: item.url, - icon: item.icon, - disabled: item.disabled, - newTab: item.newTab, - }, - ]; - }), -); - -function getAvailableItems(items: SearchItem[]) { - return items.filter((item) => !item.disabled && !item.url.includes("coming-soon")); -} - -const recommendations = getAvailableItems(searchItems); - function groupBy(items: SearchItem[]) { const groups = [...new Set(items.map((item) => item.group))]; return groups.map((group) => ({ @@ -78,6 +40,16 @@ function groupBy(items: SearchItem[]) { } export function SearchDialog() { + const { t } = useTranslation(); + const searchItems: SearchItem[] = getSidebarItems(t) + .flatMap((group) => + group.items.flatMap((item) => + item.subItems + ? item.subItems.map((sub) => ({ ...sub, group: item.title, label: sub.title })) + : [{ ...item, group: group.label ?? t("common.other"), label: item.title }], + ), + ) + .filter((item) => !item.disabled && !item.url.includes("coming-soon")); const [open, setOpen] = React.useState(false); const [query, setQuery] = React.useState(""); const router = useRouter(); @@ -138,17 +110,17 @@ export function SearchDialog() { className="px-0! font-normal text-muted-foreground hover:no-underline" > - Search + {t("common.search")} ⌘J - + - No results found. - {query ? renderGroups(searchItems) : renderGroups(recommendations)} + {t("search.empty")} + {renderGroups(searchItems)} diff --git a/web/src/app/(main)/_components/sidebar/theme-switcher.tsx b/web/src/app/(main)/_components/sidebar/theme-switcher.tsx index a95a83a..073ebbd 100644 --- a/web/src/app/(main)/_components/sidebar/theme-switcher.tsx +++ b/web/src/app/(main)/_components/sidebar/theme-switcher.tsx @@ -1,6 +1,7 @@ "use client"; import { Monitor, Moon, Sun } from "lucide-react"; +import { useTranslation } from "react-i18next"; import { Button } from "@/components/ui/button"; import { persistPreference } from "@/lib/preferences/preferences-storage"; @@ -9,6 +10,7 @@ import { usePreferencesStore } from "@/stores/preferences/preferences-provider"; const THEME_CYCLE = ["light", "dark", "system"] as const; export function ThemeSwitcher() { + const { t } = useTranslation(); const themeMode = usePreferencesStore((s) => s.themeMode); const setThemeMode = usePreferencesStore((s) => s.setThemeMode); @@ -21,7 +23,7 @@ export function ThemeSwitcher() { }; return ( - @@ -234,7 +239,7 @@ function Composer({ variant="ghost" onClick={() => fileInputRef.current?.click()} disabled={disabled || uploading} - title="上传文件" + title={translate("overview.uploadFile")} > {uploading ? : } @@ -250,14 +255,25 @@ function Composer({ onKeyDown={onKeyDown} /> {running && allowBtw && isBtwCommand(value) && ( - )} {running ? ( // while a run is in flight the send button becomes a stop button — // aborts just this session (the trigger queue keeps going). - ) : ( @@ -265,8 +281,8 @@ function Composer({ size="icon" onClick={onSend} disabled={disabled || (!value.trim() && atts.length === 0)} - title="发送消息" - aria-label="发送消息" + title={translate("overview.sendMessage")} + aria-label={translate("overview.sendMessage")} > @@ -291,10 +307,15 @@ function LLMProfileRow({ disabled?: boolean; rightSlot?: React.ReactNode; }) { + const { t: translate } = useLiveTranslation(); const [open, setOpen] = React.useState(false); const activeDefault = profiles.find((p) => p.is_default); const current = selected != null ? profiles.find((p) => Number(p.id) === selected) : null; - const label = current ? current.name : `默认${activeDefault ? `(${activeDefault.name})` : ""}`; + const label = current + ? current.name + : activeDefault + ? translate("overview.defaultProfileWithName", { name: activeDefault.name }) + : translate("overview.default"); return (
@@ -309,12 +330,14 @@ function LLMProfileRow({ disabled={disabled} className="flex shrink-0 items-center gap-0.5 text-primary text-xs hover:underline disabled:pointer-events-none disabled:opacity-40" > - 更换 + {translate("overview.change")} -

选择 LLM 配置

+

+ {translate("overview.chooseProfile")} +

{/* default option */} - {profiles.filter((p) => p.format !== "jev").map((p) => ( - - ))} + {profiles + .filter((p) => p.format !== "jev") + .map((p) => ( + + ))}
{rightSlot} @@ -373,6 +402,7 @@ function DraftChat({ profiles: LLMProfile[]; onStarted: (c: Conversation, pending?: { input?: string; attachments?: ChatAttachment[] }) => void; }) { + const { t: translate } = useLiveTranslation(); const [agentKey, setAgentKey] = React.useState(""); const [llmProfileId, setLlmProfileId] = React.useState(null); const [input, setInput] = React.useState(""); @@ -395,7 +425,7 @@ function DraftChat({ await api.sendConversationMessage(c.id, msg); onStarted(c); } catch (e) { - toast.error("发送失败:" + (e as Error).message); + toast.error(translate("overview.sendFailedPrefix") + localizedError(e, translate)); setSending(false); } } @@ -413,7 +443,7 @@ function DraftChat({ const r = await api.chatUpload("session", `conv-${c.id}`, files); onStarted(c, { input, attachments: r.attachments }); } catch (e) { - toast.error("上传失败:" + (e as Error).message); + toast.error(translate("overview.uploadFailedPrefix") + localizedError(e, translate)); setUploading(false); } } @@ -421,7 +451,7 @@ function DraftChat({ const agentPicker = ( changeAgentFilter(value === "all" ? null : value.slice(6))} disabled={bulkDeleting} > - + - + - 全部 Agent + {translate("overview.allAgents")} {agentFilterOptions.map((agent) => ( - {agent.name}({agent.count}) + {agentDisplayName(agent, translate)} ({agent.count}) ))} @@ -1389,11 +1478,13 @@ export default function ChatPage() { toggleAllConversations(checked === true)} - aria-label="选择当前筛选的全部对话" + aria-label={translate("overview.selectFilteredConversations")} disabled={filteredConversations.length === 0 || bulkDeleting} /> - {selectedConversationCount > 0 ? `已选 ${selectedConversationCount} 个` : conversationCountLabel} + {selectedConversationCount > 0 + ? translate("overview.selectedConversations", { count: selectedConversationCount }) + : conversationCountLabel} {selectedConversationCount > 0 && ( )}
) : ( @@ -1423,7 +1514,7 @@ export default function ChatPage() { disabled={filteredConversations.length === 0} > - 多选 + {translate("overview.selectMultiple")}
))} @@ -1436,7 +1527,9 @@ export default function ChatPage() {
{filteredConversations.length === 0 && (

- {agentFilter === null ? "暂无对话" : "该 Agent 暂无对话"} + {agentFilter === null + ? translate("overview.emptyConversations") + : translate("overview.emptyAgentConversations")}

)} {pinnedConversations.map((c) => ( @@ -1502,7 +1595,7 @@ export default function ChatPage() { className="mt-1 w-full" onClick={() => setVisibleConversationCount((count) => count + CONVERSATION_LIST_PAGE)} > - 加载更多 + {translate("overview.loadMore")} )}
@@ -1523,7 +1616,7 @@ export default function ChatPage() { /> ) : sourceRequested ? (
- {convsLoaded ? "对话已被删除" : "正在加载对应对话…"} + {convsLoaded ? translate("overview.conversationDeleted") : translate("overview.loadingConversation")}
) : ( - 删除选中的 {selectedConversationCount} 个对话? - 对话消息和执行记录将一并删除,此操作不可撤销。 + + {translate("overview.deleteSelected", { count: selectedConversationCount })} + + {translate("overview.deleteConversationHelp")} - 取消 + {translate("overview.cancel")} {bulkDeleting && } - {bulkDeleting ? "删除中" : "确认删除"} + {bulkDeleting ? translate("overview.deleting") : translate("overview.confirmDelete")} diff --git a/web/src/app/(main)/dashboard/page.tsx b/web/src/app/(main)/dashboard/page.tsx index 37a20ce..4d4908d 100644 --- a/web/src/app/(main)/dashboard/page.tsx +++ b/web/src/app/(main)/dashboard/page.tsx @@ -4,6 +4,7 @@ import * as React from "react"; import Link from "next/link"; +import type { TFunction } from "i18next"; import { ActivityIcon, ArrowUpRightIcon, @@ -24,6 +25,7 @@ import { Badge } from "@/components/ui/badge"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { type ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart"; import { Progress } from "@/components/ui/progress"; +import { useLiveTranslation } from "@/i18n/use-live-translation"; import { api } from "@/lib/api"; import type { Activity, @@ -36,35 +38,29 @@ import type { Settings, SkillItem, Stats, + SystemMetrics, Task, TokenTotal, Tool, TrafficExchange, UsageStats, - SystemMetrics, } from "@/lib/types"; import { cn } from "@/lib/utils"; // ── chart constants ─────────────────────────────────────────────────────────── -const dailyTrendConfig = { - input: { label: "输入", color: "hsl(217 91% 60%)" }, - output: { label: "输出", color: "hsl(263 70% 60%)" }, - cacheRead: { label: "缓存读", color: "hsl(160 60% 45%)" }, -} satisfies ChartConfig; - // ── helpers ────────────────────────────────────────────────────────────────── -function fmtRel(ts?: string | number): string { +function fmtRel(ts: string | number | undefined, translate: TFunction): string { if (!ts) return "—"; const ms = Date.now() - (typeof ts === "number" ? ts * 1000 : Date.parse(ts as string)); const s = Math.floor(ms / 1000); - if (s < 60) return `${s}s 前`; + if (s < 60) return translate("overview.secondsAgo", { count: s }); const m = Math.floor(s / 60); - if (m < 60) return `${m}m 前`; + if (m < 60) return translate("overview.minutesAgo", { count: m }); const h = Math.floor(m / 60); - if (h < 24) return `${h}h 前`; - return `${Math.floor(h / 24)}d 前`; + if (h < 24) return translate("overview.hoursAgo", { count: h }); + return translate("overview.daysAgo", { count: Math.floor(h / 24) }); } function fmtTokens(n: number): string { @@ -73,15 +69,6 @@ function fmtTokens(n: number): string { return String(n); } -const ASSET_TYPE_LABELS: Record = { - root_domain: "根域名", - ip: "IP", - subdomain: "子域名", - app: "应用", - service: "服务", - endpoint: "端点", -}; - const ASSET_COLORS: Record = { root_domain: "bg-blue-500", ip: "bg-indigo-400", @@ -106,22 +93,34 @@ function statusBg(code: number): string { } function SystemResourceStatus({ metrics }: { metrics: SystemMetrics | null }) { + const { t: translate } = useLiveTranslation(); if (!metrics) return null; if (!metrics.supported) { - return
资源状态:暂不支持
; + return ( +
+ {translate("overview.resourceUnsupported")} +
+ ); } const items = [ [CpuIcon, "CPU", metrics.cpu.percent], - [MemoryStickIcon, "内存", metrics.memory.percent], - [HardDriveIcon, "磁盘", metrics.data_disk.percent], + [MemoryStickIcon, translate("overview.memory"), metrics.memory.percent], + [HardDriveIcon, translate("overview.disk"), metrics.data_disk.percent], ] as const; return ( -
- 容器资源 +
+ {translate("overview.containerResources")} {items.map(([Icon, label, value]) => (
- + + {value.toFixed(1)}%
= { + root_domain: translate("overview.asset.root_domain"), + ip: "IP", + subdomain: translate("overview.asset.subdomain"), + app: translate("overview.asset.app"), + service: translate("overview.asset.service"), + endpoint: translate("overview.asset.endpoint"), + }; // data state const [tasks, setTasks] = React.useState([]); const [findings, setFindings] = React.useState([]); @@ -194,7 +207,10 @@ export default function DashboardPage() { }; void load(); const timer = setInterval(load, 5000); - return () => { alive = false; clearInterval(timer); }; + return () => { + alive = false; + clearInterval(timer); + }; }, []); // The task list is the most expensive dashboard source. Poll it independently @@ -545,10 +561,10 @@ export default function DashboardPage() { {/* ── Header ── */}
-
-

总览

-

系统全局状态 · 实时刷新

-
+
+

{translate("overview.title")}

+

{translate("overview.subtitle")}

+
@@ -559,7 +575,8 @@ export default function DashboardPage() {
- 活跃任务 + + {translate("overview.activeTasks")}
{tasksByStatus.running ?? 0} @@ -567,10 +584,22 @@ export default function DashboardPage() {
- {(tasksByStatus.running ?? 0) > 0 && 探索 {tasksByStatus.running}} - {(tasksByStatus.paused ?? 0) > 0 && 暂停 {tasksByStatus.paused}} - {(tasksByStatus.done ?? 0) > 0 && 完成 {tasksByStatus.done}} - {tasks.length === 0 && 暂无任务} + {(tasksByStatus.running ?? 0) > 0 && ( + + {translate("overview.exploring")} {tasksByStatus.running} + + )} + {(tasksByStatus.paused ?? 0) > 0 && ( + + {translate("overview.paused")} {tasksByStatus.paused} + + )} + {(tasksByStatus.done ?? 0) > 0 && ( + + {translate("overview.done")} {tasksByStatus.done} + + )} + {tasks.length === 0 && {translate("overview.emptyTasks")}}
@@ -578,15 +607,24 @@ export default function DashboardPage() {
- 确认发现 + + {translate("overview.confirmedFindings")}
{findings.length}
- 严重 {findingsBySev.critical} - 高危 {findingsBySev.high} - 中危 {findingsBySev.medium} - 低危 {findingsBySev.low} + + {translate("overview.critical")} {findingsBySev.critical} + + + {translate("overview.high")} {findingsBySev.high} + + + {translate("overview.medium")} {findingsBySev.medium} + + + {translate("overview.low")} {findingsBySev.low} +
@@ -594,18 +632,22 @@ export default function DashboardPage() {
- 资产节点 + + {translate("overview.assetNodes")}
{totalAssets}
- 跨任务共享 + + {translate("overview.sharedAcrossTasks")} +
{/* 流量交互 */}
- 流量交互 + + {translate("overview.trafficExchanges")}
{traffic.length}
@@ -613,10 +655,10 @@ export default function DashboardPage() { {settings?.traffic_capture ? ( <> - 录制中 + {translate("overview.recording")} ) : ( - 捕获未开启 + {translate("overview.captureDisabled")} )}
@@ -625,15 +667,19 @@ export default function DashboardPage() {
- Token 用量 + + {translate("overview.tokenUsage")}
{fmtTokens(displayedTokens.input + displayedTokens.output) || "—"}
- 入 {fmtTokens(displayedTokens.input)}(含缓存 {fmtTokens(displayedTokens.cacheRead)})· 出{" "} - {fmtTokens(displayedTokens.output)} + {translate("overview.tokenBreakdown", { + input: fmtTokens(displayedTokens.input), + cache: fmtTokens(displayedTokens.cacheRead), + output: fmtTokens(displayedTokens.output), + })}
@@ -644,13 +690,13 @@ export default function DashboardPage() {
- LLM Token 消耗 + {translate("overview.llmTokens")} {/* 数据源开关:旧版=activity 统计(含历史任务),新版=llm_usage 计量账本(更准,仅覆盖启用后) */}
{( [ - { v: "old", label: "旧版" }, - { v: "new", label: "新版" }, + { v: "old", label: translate("overview.legacyMetrics") }, + { v: "new", label: translate("overview.ledgerMetrics") }, ] as const ).map(({ v, label }) => ( {llmProfiles.map((p) => { const key = Number(p.id); @@ -718,7 +762,7 @@ export default function DashboardPage() { : "bg-emerald-500/20 text-emerald-400", )} > - 默认 + {translate("overview.default")} )} @@ -733,11 +777,13 @@ export default function DashboardPage() {
{/* Total */}
-
合计 (输入+输出)
+
{translate("overview.totalTokens")}
{fmtTokens(displayedTokens.input + displayedTokens.output) || "—"}
-
{displayedTokens.taskCount} 个任务
+
+ {translate("overview.taskCount", { count: displayedTokens.taskCount })} +
{/* Per-type bars */} @@ -747,19 +793,19 @@ export default function DashboardPage() { const total = displayedTokens.input + displayedTokens.output; return [ { - label: "输入(未命中)", + label: translate("overview.uncachedInput"), value: displayedTokens.input - displayedTokens.cacheRead, barColor: dailyTrendConfig.input.color!, text: "text-blue-400", }, { - label: "缓存命中", + label: translate("overview.cacheHit"), value: displayedTokens.cacheRead, barColor: dailyTrendConfig.cacheRead.color!, text: "text-emerald-400", }, { - label: "输出", + label: translate("overview.output"), value: displayedTokens.output, barColor: dailyTrendConfig.output.color!, text: "text-violet-400", @@ -791,7 +837,7 @@ export default function DashboardPage() { const hitPct = denominator > 0 ? Math.round((displayedTokens.cacheRead / denominator) * 100) : 0; return (
- 缓存命中率 + {translate("overview.cacheHitRate")} 50 ? "text-emerald-400" : "text-amber-400")} > @@ -819,11 +865,11 @@ export default function DashboardPage() {
{( [ - { days: 7, label: "7天" }, - { days: 30, label: "30天" }, - { days: 90, label: "3月" }, - { days: 180, label: "6月" }, - { days: 365, label: "一年" }, + { days: 7, label: translate("overview.sevenDays") }, + { days: 30, label: translate("overview.thirtyDays") }, + { days: 90, label: translate("overview.threeMonths") }, + { days: 180, label: translate("overview.sixMonths") }, + { days: 365, label: translate("overview.oneYear") }, ] as const ).map(({ days, label }) => (
) : ( @@ -903,17 +949,18 @@ export default function DashboardPage() {
- 活动流 + {translate("overview.activity")}
- {activity.filter((a) => a.kind !== "usage").length} 条事件 + {translate("overview.eventCount", { count: activity.filter((a) => a.kind !== "usage").length })} - 查看任务 + {translate("overview.viewTasks")} +
@@ -932,7 +979,9 @@ export default function DashboardPage() {
{recentActivity.length === 0 ? ( -
暂无活动记录
+
+ {translate("overview.emptyActivity")} +
) : ( recentActivity.map((a) => (
@@ -952,7 +1001,9 @@ export default function DashboardPage() { {a.kind} -
{fmtRel(a.ts)}
+
+ {fmtRel(a.ts, translate)} +
)) @@ -965,19 +1016,22 @@ export default function DashboardPage() {
- 发现 + {translate("overview.findings")}
- 全部 + {translate("overview.all")} +
{recentFindings.length === 0 ? ( -
暂无发现
+
+ {translate("overview.emptyFindings")} +
) : ( recentFindings.map((f) => (
@@ -997,7 +1051,9 @@ export default function DashboardPage() { {f.summary}
- {fmtRel(f.ts)} + + {fmtRel(f.ts, translate)} +
)) )} @@ -1010,22 +1066,32 @@ export default function DashboardPage() {
- 任务 + {translate("overview.tasks")}
- {tasks.length} 个任务 + + {translate("overview.taskCount", { count: tasks.length })} + - 全部 + {translate("overview.all")} +
- {["任务", "状态", "引擎", "目标进度", "在途", "最近活动"].map((h) => ( + {[ + translate("overview.tasks"), + translate("overview.status"), + translate("overview.engine"), + translate("overview.goalProgress"), + translate("overview.inFlight"), + translate("overview.lastActivity"), + ].map((h) => ( ) : ( @@ -1083,7 +1149,7 @@ export default function DashboardPage() { )} ); @@ -1097,12 +1163,12 @@ export default function DashboardPage() {
{/* 资产分布 */} - - 资产分布 + + {translate("overview.assetDistribution")} {assetByType.length === 0 ? ( -
暂无资产数据
+
{translate("overview.emptyAssets")}
) : (
{assetByType.map(([type, count]) => ( @@ -1122,18 +1188,20 @@ export default function DashboardPage() {
)} -
共 {totalAssets} 节点
+
+ {translate("overview.nodeTotal", { count: totalAssets })} +
{/* 流量状态码 */} - - 流量状态码 + + {translate("overview.trafficStatusCodes")} {/* bar chart */} {trafficByCodes.length === 0 ? ( -
暂无流量数据
+
{translate("overview.emptyTraffic")}
) : ( <>
@@ -1150,7 +1218,7 @@ export default function DashboardPage() {
-
最近请求
+
{translate("overview.recentRequests")}
{recentTraffic.map((e) => (
@@ -1179,11 +1247,11 @@ export default function DashboardPage() { {/* 系统状态 & 待审批 */} - 系统状态 + {translate("overview.systemStatus")}
-
LLM 配置
+
{translate("overview.llmProfiles")}
- {stats?.llm_configured ? "已配置" : "未配置"} + {stats?.llm_configured ? translate("overview.configured") : translate("overview.notConfigured")}
-
流量捕获
+
{translate("overview.trafficCapture")}
- {settings?.traffic_capture ? "开启" : "关闭"} + {settings?.traffic_capture ? translate("overview.enabled") : translate("overview.disabled")}
{activeProfile && (
-
激活模型
+
{translate("overview.activeModel")}
{activeProfile.model}
)} @@ -1223,7 +1291,9 @@ export default function DashboardPage() { {/* pending approvals */} {pendingCount > 0 && (
-
待审批 ({pendingCount})
+
+ {translate("overview.pendingApprovals", { count: pendingCount })} +
{pending.slice(0, 3).map((p) => ( - 还有 {pendingCount - 3} 条… + {translate("overview.moreApprovals", { count: pendingCount - 3 })} )}
@@ -1253,7 +1323,7 @@ export default function DashboardPage() { {pendingCount === 0 && (
- 无待审批拦截 + {translate("overview.noPendingApprovals")}
)} diff --git a/web/src/app/(main)/function/assets/page.tsx b/web/src/app/(main)/function/assets/page.tsx index 9073c95..a926dc9 100644 --- a/web/src/app/(main)/function/assets/page.tsx +++ b/web/src/app/(main)/function/assets/page.tsx @@ -1,5 +1,4 @@ "use client"; - import * as React from "react"; import { useSearchParams } from "next/navigation"; @@ -61,6 +60,7 @@ import { } from "@/components/ui/sheet"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { type UITranslate, useUITranslation } from "@/i18n/use-ui-translation"; import { api } from "@/lib/api"; import { parseCompanyScopeText } from "@/lib/company-scope"; import type { Asset, Company, CompanyScopeRule } from "@/lib/types"; @@ -100,17 +100,19 @@ function statusTone(code: number) { const PAGE_SIZES = [25, 50, 100, 200]; -const TABS: { key: string; label: string; icon: LucideIcon }[] = [ - { key: "company", label: "企业", icon: BuildingIcon }, - { key: "root_domain", label: "根域名", icon: GlobeIcon }, +const TABS = (ui: UITranslate): { key: string; label: string; icon: LucideIcon }[] => [ + { key: "company", label: ui("screen.409d0719010a46"), icon: BuildingIcon }, + { key: "root_domain", label: ui("screen.bc208f697acff9"), icon: GlobeIcon }, { key: "ip", label: "IP", icon: NetworkIcon }, - { key: "subdomain", label: "子域名", icon: GlobeIcon }, - { key: "app", label: "应用", icon: SmartphoneIcon }, - { key: "service", label: "服务", icon: LayoutTemplateIcon }, - { key: "endpoint", label: "接口", icon: LinkIcon }, + { key: "subdomain", label: ui("screen.4b4788120c34db"), icon: GlobeIcon }, + { key: "app", label: ui("screen.63c73c4730f447"), icon: SmartphoneIcon }, + { key: "service", label: ui("screen.ec309ab207ef7f"), icon: LayoutTemplateIcon }, + { key: "endpoint", label: ui("screen.c80d519245a7e8"), icon: LinkIcon }, ]; function AssetsContent() { + const { ui, liveUI } = useUITranslation(); + const searchParams = useSearchParams(); const requestedAsset = searchParams.get("asset") ?? ""; const [assetTarget, setAssetTarget] = React.useState<{ id: number; type: string }>(); @@ -126,7 +128,7 @@ function AssetsContent() { if (!alive) return; const asset = r.assets[0]; if (!asset) { - setAssetTargetError(`资产 #${requestedAsset} 不存在或已删除`); + setAssetTargetError(liveUI("screen.b125c86819e4a9", { v0: requestedAsset })); return; } setAssetTarget({ id: asset.id, type: asset.type }); @@ -140,7 +142,7 @@ function AssetsContent() { return () => { alive = false; }; - }, [requestedAsset]); + }, [requestedAsset, liveUI]); const [intelligenceAsset, setIntelligenceAsset] = React.useState(null); const [rows, setRows] = React.useState([]); const [total, setTotal] = React.useState(0); @@ -226,11 +228,11 @@ function AssetsContent() { setDeleting(true); try { const res = await api.deleteAssets(deleteIds); - toast.success(`已删除 ${res.deleted} 条资产`); + toast.success(ui("screen.1761c4798e77c2", { v0: res.deleted })); setSelected(new Set()); refresh(); } catch (e) { - toast.error("删除失败:" + String((e as Error)?.message ?? e)); + toast.error(ui("screen.0580fcee37e020") + String((e as Error)?.message ?? e)); } finally { setDeleting(false); setDeleteOpen(false); @@ -244,12 +246,12 @@ function AssetsContent() { const res = await api.deleteCompany(companyDeleteTarget.id, companyDeleteAssets); const msg = companyDeleteAssets && res.assets_deleted > 0 - ? `已删除企业,同时删除 ${res.assets_deleted} 条资产` - : "已删除企业"; + ? ui("screen.7ec8de519162d6", { v0: res.assets_deleted }) + : ui("screen.525e545e0d0e2c"); toast.success(msg); refresh(); } catch (e) { - toast.error("删除失败:" + String((e as Error)?.message ?? e)); + toast.error(ui("screen.0580fcee37e020") + String((e as Error)?.message ?? e)); } finally { setCompanyDeleting(false); setCompanyDeleteTarget(null); @@ -361,8 +363,11 @@ function AssetsContent() { > - 资产 #{intelligenceAsset} 的关联情报 - 按来源任务查看凭据、线索和敏感数据。 + + {ui("screen.3fce7d9a221714")} + {intelligenceAsset} {ui("screen.15ec2ed14acbcf")} + + {ui("screen.7040a4dc910e39")} {intelligenceAsset !== null ? (
-

资产

+

{ui("screen.5f45bb826b168f")}

- 共 {totalAssets} 项资产 + {ui("screen.76e547a8fa5463")} + {totalAssets} {ui("screen.a5670ed30b06da")} {tab !== "company" ? ( ) : null} {selected.size > 0 && ( )}
@@ -408,7 +415,7 @@ function AssetsContent() {
- {TABS.map((t) => ( + {TABS(ui).map((t) => ( {t.label} @@ -425,10 +432,10 @@ function AssetsContent() {
- 暂无任务 + {translate("overview.emptyTasks")}
- {fmtRel(t.last_activity_unix ?? t.created_unix)} + {fmtRel(t.last_activity_unix ?? t.created_unix, translate)}
- 企业 - 资产数 - 资产范围 - 操作 + {ui("screen.409d0719010a46")} + {ui("screen.ae4728f0882849")} + {ui("screen.c3f497207f2e9c")} + {ui("screen.ed31fbb483ee1b")} @@ -451,7 +458,7 @@ function AssetsContent() { ))} ) : ( - 未设置范围 + {ui("screen.8d66a21a659363")} )} @@ -466,7 +473,7 @@ function AssetsContent() { setCompanyDeleteTarget(c); setCompanyDeleteAssets(false); }} - aria-label={`删除企业 ${c.name}`} + aria-label={ui("screen.c36a45833ff362", { v0: c.name })} > @@ -477,7 +484,7 @@ function AssetsContent() { {companies.length === 0 && ( - 还没有企业。点击右上角「新增企业」并填写资产范围,系统会自动认领命中的资产。 + {ui("screen.1b38865e529a92")} )} @@ -491,7 +498,7 @@ function AssetsContent() { {searchBox} {a.domain} {a.icp || "—"} - {companyName(a.company_id) || 未归属} + {companyName(a.company_id) || ( + {ui("screen.96181a00e02657")} + )} @@ -532,7 +541,7 @@ function AssetsContent() { {searchBox} openDelete([a.id])} - aria-label={`删除资产 ${a.ip || a.id}`} + aria-label={ui("screen.2bf7036e5e76dc", { v0: a.ip || a.id })} > @@ -579,7 +588,14 @@ function AssetsContent() { {searchBox} openDelete([a.id])} - aria-label={`删除资产 ${a.domain || a.id}`} + aria-label={ui("screen.2bf7036e5e76dc", { v0: a.domain || a.id })} > @@ -621,7 +637,14 @@ function AssetsContent() { {searchBox} openDelete([a.id])} - aria-label={`删除资产 ${a.app_name || a.id}`} + aria-label={ui("screen.2bf7036e5e76dc", { v0: a.app_name || a.id })} > @@ -661,7 +684,18 @@ function AssetsContent() { {searchBox} { const item = authItem as Record; - const label = item.type || item.username || "认证"; + const label = item.type || item.username || ui("screen.d2d648bd1c94b7"); return ( @@ -742,7 +776,7 @@ function AssetsContent() { size="icon" className="size-7 text-muted-foreground hover:text-destructive" onClick={() => openDelete([a.id])} - aria-label={`删除资产 ${a.url || a.id}`} + aria-label={ui("screen.2bf7036e5e76dc", { v0: a.url || a.id })} > @@ -757,7 +791,7 @@ function AssetsContent() { {searchBox} openDelete([a.id])} - aria-label={`删除资产 ${a.url || a.id}`} + aria-label={ui("screen.2bf7036e5e76dc", { v0: a.url || a.id })} > @@ -808,14 +842,14 @@ function AssetsContent() { - 确认删除 + {ui("screen.a3ea3c17b401bd")} - 将永久删除 {deleteIds.length}{" "} - 条资产记录,此操作不可撤销。 + {ui("screen.cf89e2d397024a")} + {deleteIds.length} {ui("screen.49a6675fda9172")} - 取消 + {ui("screen.2cd0f3be8738a8")} { e.preventDefault(); @@ -824,7 +858,7 @@ function AssetsContent() { disabled={deleting} className="bg-destructive text-destructive-foreground hover:bg-destructive/90" > - {deleting ? "删除中…" : "确认删除"} + {deleting ? ui("screen.5e8e7af57abbcb") : ui("screen.a3ea3c17b401bd")} @@ -841,10 +875,13 @@ function AssetsContent() { > - 删除企业 · {companyDeleteTarget?.name} + + {ui("screen.06a9a4ee83c2ec")} + {companyDeleteTarget?.name} +
-

此操作将永久删除该企业及其资产范围配置,不可撤销。

+

{ui("screen.3f76f4b32468a9")}

- 取消 + {ui("screen.2cd0f3be8738a8")} { e.preventDefault(); @@ -872,7 +909,7 @@ function AssetsContent() { disabled={companyDeleting} className="bg-destructive text-destructive-foreground hover:bg-destructive/90" > - {companyDeleting ? "删除中…" : "确认删除"} + {companyDeleting ? ui("screen.5e8e7af57abbcb") : ui("screen.a3ea3c17b401bd")}
@@ -906,6 +943,8 @@ function AssetCard({ onToggleAll?: (ids: number[]) => void; children: React.ReactNode; }) { + const { ui } = useUITranslation(); + const childRows = React.Children.toArray(children); const pageCount = Math.max(1, Math.ceil(total / size)); const start = total === 0 ? 0 : page * size + 1; @@ -941,7 +980,7 @@ function AssetCard({ ) : ( - {loaded ? "暂无数据。" : "加载中…"} + {loaded ? ui("screen.ef3763ae265043") : ui("screen.4927a53bcc886a")} )} @@ -958,7 +997,7 @@ function AssetCard({ {PAGE_SIZES.map((n) => ( - {n} / 页 + {n} {ui("screen.785e3cd53901f4")} ))} @@ -1044,6 +1083,8 @@ function savedScopeText(company: Company): string { // 新增企业使用与任务、LLM 编辑一致的右侧抽屉。 function CompanyDialog({ onSaved }: { onSaved: () => void }) { + const { ui } = useUITranslation(); + const [open, setOpen] = React.useState(false); const [name, setName] = React.useState(""); const [scopeText, setScopeText] = React.useState(""); @@ -1058,11 +1099,11 @@ function CompanyDialog({ onSaved }: { onSaved: () => void }) { const submit = async () => { if (!name.trim()) { - toast.error("请填写企业名称"); + toast.error(ui("screen.04c47542433fac")); return; } if (parsedScope.errors.length > 0) { - toast.error("请修正无效的资产范围"); + toast.error(ui("screen.031baaffb78d07")); return; } setBusy(true); @@ -1070,14 +1111,14 @@ function CompanyDialog({ onSaved }: { onSaved: () => void }) { const res = await api.createCompany(name.trim(), parsedScope.rules); const added = res.scope_added ?? 0; const invalid = res.scope_invalid ?? 0; - if (invalid > 0) toast.warning(`已创建企业,添加 ${added} 条范围;${invalid} 行无效`); - else toast.success(`已创建企业,添加 ${added} 条范围`); + if (invalid > 0) toast.warning(ui("screen.2802702c973642", { v0: added, v1: invalid })); + else toast.success(ui("screen.f18ce3be548c06", { v0: added })); setOpen(false); onSaved(); } catch (e) { const msg = String((e as Error)?.message ?? e); - if (/:\s*409$/.test(msg)) toast.error("企业已存在,请换个名称"); - else toast.error(`保存失败:${msg}`); + if (/:\s*409$/.test(msg)) toast.error(ui("screen.55d0e12adaef50")); + else toast.error(ui("screen.20e1b0f2590bc7", { v0: msg })); } finally { setBusy(false); } @@ -1087,21 +1128,21 @@ function CompanyDialog({ onSaved }: { onSaved: () => void }) { - 新增企业 - 配置企业及其资产范围。关键词只作为 Agent 提示,不会自动归属资产。 + {ui("screen.926e2875424e77")} + {ui("screen.087da716915bc9")}
- 企业名称 + {ui("screen.69211530fe250a")} setName(e.target.value)} /> @@ -1111,10 +1152,10 @@ function CompanyDialog({ onSaved }: { onSaved: () => void }) {
@@ -1124,6 +1165,8 @@ function CompanyDialog({ onSaved }: { onSaved: () => void }) { // 编辑(覆盖)资产范围弹窗 function EditScopeDialog({ company, onSaved }: { company: Company; onSaved: () => void }) { + const { ui } = useUITranslation(); + const [open, setOpen] = React.useState(false); const [scopeText, setScopeText] = React.useState(""); const [reason, setReason] = React.useState(""); @@ -1142,20 +1185,20 @@ function EditScopeDialog({ company, onSaved }: { company: Company; onSaved: () = const submit = async () => { if (parsedScope.errors.length > 0) { - toast.error("请修正无效的资产范围"); + toast.error(ui("screen.031baaffb78d07")); return; } setBusy(true); try { const res = await api.updateCompanyScope(company.id, parsedScope.rules, reason); const errCount = res.invalid ?? 0; - if (errCount > 0) toast.warning(`已保存;${errCount} 行无效`); - else toast.success(`范围已更新,共 ${res.added} 条`); + if (errCount > 0) toast.warning(ui("screen.da721b3eee9ce1", { v0: errCount })); + else toast.success(ui("screen.123e089ef2fa0a", { v0: res.added })); showScopeWarnings(res.warnings); setOpen(false); onSaved(); } catch (e) { - toast.error(`保存失败:${String((e as Error)?.message ?? e)}`); + toast.error(ui("screen.20e1b0f2590bc7", { v0: String((e as Error)?.message ?? e) })); } finally { setBusy(false); } @@ -1165,15 +1208,16 @@ function EditScopeDialog({ company, onSaved }: { company: Company; onSaved: () = - 编辑资产范围 · {company.name} - - 编辑后将替换全部现有范围。ICP 精确匹配资产,企业关键词仅作为 Agent 提示。 - + + {ui("screen.db6fafa66f2df7")} + {company.name} + + {ui("screen.514634cb134223")} - 归属依据(可选) + {ui("screen.6f74197f3bdddf")} setReason(e.target.value)} /> @@ -1194,10 +1238,10 @@ function EditScopeDialog({ company, onSaved }: { company: Company; onSaved: () = @@ -1207,6 +1251,8 @@ function EditScopeDialog({ company, onSaved }: { company: Company; onSaved: () = // 追加资产范围弹窗 function AppendScopeDialog({ company, onSaved }: { company: Company; onSaved: () => void }) { + const { ui } = useUITranslation(); + const [open, setOpen] = React.useState(false); const [scopeText, setScopeText] = React.useState(""); const [reason, setReason] = React.useState(""); @@ -1221,24 +1267,24 @@ function AppendScopeDialog({ company, onSaved }: { company: Company; onSaved: () const submit = async () => { if (parsedScope.rules.length === 0) { - toast.error("请填写要追加的范围"); + toast.error(ui("screen.e71265f94553c7")); return; } if (parsedScope.errors.length > 0) { - toast.error("请修正无效的资产范围"); + toast.error(ui("screen.031baaffb78d07")); return; } setBusy(true); try { const res = await api.addCompanyScope(company.id, parsedScope.rules, reason); const errCount = res.invalid ?? 0; - if (errCount > 0) toast.warning(`已保存;${errCount} 行无效`); - else toast.success(`已追加 ${res.added} 条范围`); + if (errCount > 0) toast.warning(ui("screen.da721b3eee9ce1", { v0: errCount })); + else toast.success(ui("screen.5a718e3167fc5a", { v0: res.added })); showScopeWarnings(res.warnings); setOpen(false); onSaved(); } catch (e) { - toast.error(`保存失败:${String((e as Error)?.message ?? e)}`); + toast.error(ui("screen.20e1b0f2590bc7", { v0: String((e as Error)?.message ?? e) })); } finally { setBusy(false); } @@ -1248,13 +1294,16 @@ function AppendScopeDialog({ company, onSaved }: { company: Company; onSaved: () - 追加资产范围 · {company.name} - 新范围会追加到现有范围。ICP 精确匹配资产,企业关键词仅作为 Agent 提示。 + + {ui("screen.3653428c258c3e")} + {company.name} + + {ui("screen.98412bfb06851c")} - 归属依据(可选) + {ui("screen.6f74197f3bdddf")} setReason(e.target.value)} /> @@ -1275,10 +1324,10 @@ function AppendScopeDialog({ company, onSaved }: { company: Company; onSaved: () diff --git a/web/src/app/(main)/function/commands/page.tsx b/web/src/app/(main)/function/commands/page.tsx index 6a5954e..ebac211 100644 --- a/web/src/app/(main)/function/commands/page.tsx +++ b/web/src/app/(main)/function/commands/page.tsx @@ -1,5 +1,4 @@ "use client"; - import * as React from "react"; import { BarChart3Icon, ChevronLeftIcon, ChevronRightIcon, Loader2Icon, SearchIcon, TerminalIcon } from "lucide-react"; @@ -12,12 +11,13 @@ import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { type UITranslate, useUITranslation } from "@/i18n/use-ui-translation"; import { api } from "@/lib/api"; import type { CommandRecord, ToolStat } from "@/lib/types"; import { cn } from "@/lib/utils"; -function fmtTime(ts: string) { - return new Date(ts).toLocaleString("zh-CN", { +function fmtTime(ts: string, ui: UITranslate) { + return new Date(ts).toLocaleString(ui("screen.91610255157661"), { month: "2-digit", day: "2-digit", hour: "2-digit", @@ -50,6 +50,8 @@ const PAGE_SIZES = [25, 50, 100]; const CMD_MAX_LEN = 80; export default function CommandsPage() { + const { ui } = useUITranslation(); + const [page, setPage] = React.useState(0); const [size, setSize] = React.useState(50); const [query, setQuery] = React.useState(""); @@ -130,7 +132,7 @@ export default function CommandsPage() {
-

工具执行

+

{ui("screen.a548992cfa3335")}

{total}
@@ -140,14 +142,14 @@ export default function CommandsPage() {
setQuery(e.target.value)} className="h-8 pl-8" />
setTaskFilter(e.target.value.replace(/\D/g, ""))} @@ -159,7 +161,7 @@ export default function CommandsPage() { {PAGE_SIZES.map((n) => ( - {n} / 页 + {n} {ui("screen.785e3cd53901f4")} ))} @@ -167,7 +169,7 @@ export default function CommandsPage() {
@@ -205,12 +207,12 @@ export default function CommandsPage() {
- 时间 - 任务 + {ui("screen.8b6ff498515bcc")} + {ui("screen.5253040db8643c")} Worker - 工具 - 输入 - 状态 + {ui("screen.5ca6730d441522")} + {ui("screen.2087c777c06fef")} + {ui("screen.6320b4a8722a85")} @@ -223,7 +225,7 @@ export default function CommandsPage() { ) : commands.length === 0 ? ( - 暂无工具执行记录 + {ui("screen.005250ab54771a")} ) : ( @@ -234,7 +236,7 @@ export default function CommandsPage() { onClick={() => setSelected(cmd)} > - {fmtTime(cmd.created_at)} + {fmtTime(cmd.created_at, ui)} #{cmd.exploration_id} @@ -255,11 +257,11 @@ export default function CommandsPage() { {cmd.is_error ? ( - 失败 + {ui("screen.28384d7afd2e4f")} ) : ( - 成功 + {ui("screen.053461ce86d265")} )} @@ -276,17 +278,17 @@ export default function CommandsPage() { - 工具调用统计 + {ui("screen.aa254510842ebb")} - {taskFilter || queryQ ? "当前筛选条件下的全部记录" : "全部工具执行记录"} + {taskFilter || queryQ ? ui("screen.15f06f8780aa69") : ui("screen.4c1795faba173d")} {stats.length > 0 && ( <> {" · "} - {stats.length} 个工具 ·{" "} - {statsTotal} 次调用 + {stats.length} {ui("screen.3863aaa034a4a4")}{" "} + {statsTotal} {ui("screen.d8762c5105febe")} {statsErrors > 0 && ( <> - {" · 失败 "} + {ui("screen.3f52690b5a30b1")} {statsErrors} )} @@ -300,7 +302,7 @@ export default function CommandsPage() { ) : stats.length === 0 ? ( -
暂无统计数据
+
{ui("screen.1b8e57ace988ae")}
) : (
{stats.map((s) => ( @@ -309,7 +311,10 @@ export default function CommandsPage() {
{s.tool} {s.errors > 0 && ( - 失败 {s.errors} + + {ui("screen.28384d7afd2e4f")} + {s.errors} + )}
@@ -337,8 +342,8 @@ export default function CommandsPage() { {selected && ( <> - 工具执行详情 - {fmtTime(selected.created_at)} + {ui("screen.023a1c95731a3f")} + {fmtTime(selected.created_at, ui)}
#{selected.exploration_id} @@ -351,18 +356,20 @@ export default function CommandsPage() { {selected.is_error ? ( - 失败 + {ui("screen.28384d7afd2e4f")} ) : ( - 成功 + {ui("screen.053461ce86d265")} )}
-
输入 Input
+
+ {ui("screen.b719f4314b43d3")} +
                       {toolInput(selected.command)}
@@ -370,7 +377,9 @@ export default function CommandsPage() {
                   
-
输出 Output
+
+ {ui("screen.586a708e3d70eb")} +
-                      {selected.output || "(空)"}
+                      {selected.output || ui("screen.fa8bc5e925f9f7")}
                     
diff --git a/web/src/app/(main)/function/findings/_components/asset-tree.tsx b/web/src/app/(main)/function/findings/_components/asset-tree.tsx index a948077..d387a1b 100644 --- a/web/src/app/(main)/function/findings/_components/asset-tree.tsx +++ b/web/src/app/(main)/function/findings/_components/asset-tree.tsx @@ -1,5 +1,4 @@ "use client"; - import * as React from "react"; import { @@ -18,6 +17,7 @@ import { import { Button } from "@/components/ui/button"; import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group"; +import { type UITranslate, useUITranslation } from "@/i18n/use-ui-translation"; import type { FindingAssetKind, FindingAssetNode } from "@/lib/types"; import { cn } from "@/lib/utils"; @@ -33,16 +33,16 @@ const KIND_ICON: Record = { none: CircleDashedIcon, }; -const KIND_LABEL: Record = { - company: "企业", - root_domain: "根域名", - subdomain: "子域名", +const KIND_LABEL = (ui: UITranslate): Record => ({ + company: ui("screen.409d0719010a46"), + root_domain: ui("screen.bc208f697acff9"), + subdomain: ui("screen.4b4788120c34db"), ip: "IP", - app: "应用", - service: "服务", - endpoint: "接口", - none: "未关联", -}; + app: ui("screen.63c73c4730f447"), + service: ui("screen.ec309ab207ef7f"), + endpoint: ui("screen.c80d519245a7e8"), + none: ui("screen.6b75a35393124b"), +}); // TreeNode 是节点数组组装出来的树。后端已按「同父下发现多的在前」排好序, // 这里只需按数组顺序挂载。 @@ -192,6 +192,8 @@ export function AssetTree({ findingTotal, onRefresh, }: AssetTreeProps) { + const { ui } = useUITranslation(); + const [keyword, setKeyword] = React.useState(""); const [expanded, setExpanded] = React.useState>(() => new Set()); // 记住用户手动折叠过的节点,免得「默认展开顶层」在每次刷新后又把它们撑开。 @@ -233,9 +235,9 @@ export function AssetTree({ [isExpanded], ); - let emptyHint = "当前筛选下没有关联到资产的发现。"; - if (loading) emptyHint = "加载中…"; - else if (searching) emptyHint = "没有匹配的资产。"; + let emptyHint = ui("screen.e62adc3d64b53c"); + if (loading) emptyHint = ui("screen.4927a53bcc886a"); + else if (searching) emptyHint = ui("screen.19e42884b04f66"); const rows: React.ReactNode[] = []; const pushRows = (list: TreeNode[]) => { @@ -264,8 +266,8 @@ export function AssetTree({ type="search" value={keyword} onChange={(event) => setKeyword(event.target.value)} - placeholder="过滤资产" - aria-label="过滤资产" + placeholder={ui("screen.4c705950098156")} + aria-label={ui("screen.4c705950098156")} />
@@ -328,6 +331,8 @@ function AssetTreeRow({ onToggle: () => void; onSelect: () => void; }) { + const { ui } = useUITranslation(); + const Icon = KIND_ICON[node.kind] ?? GlobeIcon; const hasChildren = node.children.length > 0; return ( @@ -343,7 +348,7 @@ function AssetTreeRow({ type="button" onClick={onToggle} className="flex size-5 shrink-0 items-center justify-center rounded text-muted-foreground hover:text-foreground" - aria-label={open ? "折叠" : "展开"} + aria-label={open ? ui("screen.84a07f292f1f78") : ui("screen.00bd3960fea8ea")} aria-expanded={open} > @@ -355,23 +360,23 @@ function AssetTreeRow({ type="button" onClick={onSelect} className="flex min-w-0 flex-1 items-center gap-1.5 py-1 text-left" - title={`${KIND_LABEL[node.kind] ?? node.kind} · ${node.label}`} + title={`${KIND_LABEL(ui)[node.kind] ?? node.kind} · ${node.label}`} >
@@ -299,7 +310,7 @@ export function FindingsTable({ {FINDING_STATUSES.map((st) => ( - {statusMeta("finding", st).label} + {statusMeta("finding", st, t).label} ))} @@ -323,27 +334,27 @@ export function FindingsTable({ — )} - {fmtTime(f.ts)} + {fmtTime(f.ts, ui)} e.stopPropagation()}>
{retest ? ( ) : null} {!retest && f.finding_id && !f.inherited ? ( - ) : null} {f.finding_id && f.task_id && ( )} {f.finding_id && ( @@ -353,25 +364,27 @@ export function FindingsTable({ size="icon" variant="ghost" className="size-7 text-muted-foreground hover:text-destructive" - aria-label="删除漏洞" + aria-label={ui("screen.623b2dfaaa3ba0")} > - 确认删除该漏洞? + {ui("screen.fa4ee33b1be3d3")} - 「 + {ui("punctuation.b269e4a091")} {f.name || f.vulnclass || f.summary || `#${f.finding_id}`} - 」将被永久删除, 同时从发现列表、任务发现 Tab 与探索图中移除,此操作不可撤销。 + {ui("screen.10177194107fe4")} - 取消 - onDelete(f)}>删除 + {ui("screen.2cd0f3be8738a8")} + onDelete(f)}> + {ui("screen.2f9daa828907b9")} + @@ -389,23 +402,23 @@ export function FindingsTable({ {f.finding_id && edit && (
- + onEditChange((s) => (s ? { ...s, name: e.target.value } : s))} - placeholder="可读标题,留空回退类别" + placeholder={ui("screen.e4ca1c94d239bd")} />
- + onEditChange((s) => (s ? { ...s, vulnclass: e.target.value } : s))} - placeholder="如 SQL Injection" + placeholder={ui("screen.18f8e077cc27a6")} />
- +
)}
- 证据 + {ui("screen.d883c636d3f891")} {f.vulnclass && ( - · 类型: + {ui("screen.e9b150245e3845")} {f.vulnclass} )} {f.param_id && {f.param_id}} {f.assets && f.assets.length > 0 && ( - · 资产: + {ui("screen.a85936ba84b226")} {f.assets.map((a) => ( {a.label} @@ -458,12 +471,12 @@ export function FindingsTable({
- 详细报告 + {ui("screen.566e5ab4ab0638")} {reports[rowKey]?.status === "done" && reports[rowKey]?.text.trim() && ( @@ -472,11 +485,11 @@ export function FindingsTable({ {(() => { const rep = reports[rowKey]; if (!rep || rep.status === "loading") - return

加载中…

; + return

{ui("screen.4927a53bcc886a")}

; if (rep.status === "error") - return

报告加载失败。

; + return

{ui("screen.bf456ab5176d85")}

; if (!rep.text.trim()) - return

暂无详细报告。

; + return

{ui("screen.6283d9ab225878")}

; return ( // break-words 会继承到段落/列表,pre 另加 // whitespace-pre-wrap 让代码块也换行——否则长代码行/长 URL @@ -498,7 +511,7 @@ export function FindingsTable({ {(caseRows ? caseRows.length : items.length) === 0 && ( - 没有匹配的发现。 + {ui("screen.839ae9d1758f4a")} )} @@ -544,6 +557,8 @@ function FindingFolderTableRows({ row: FindingCaseListRow; shared: Omit; }) { + const { ui } = useUITranslation(); + const [open, setOpen] = React.useState(false); const group = row.case; if (!group) return null; @@ -562,7 +577,7 @@ function FindingFolderTableRows({ onClick={() => setOpen((v) => !v)} aria-expanded={open} aria-controls={`finding-table-members-${group.id}`} - aria-label={`${open ? "收起" : "展开"}${group.title}`} + aria-label={`${open ? ui("screen.afd4b783536b82") : ui("screen.00bd3960fea8ea")}${group.title}`} > @@ -586,7 +601,8 @@ function FindingFolderTableRows({ {group.title} - {group.count} 条上报 · {group.report_version !== group.version ? "统一报告待更新" : "统一报告已生成"} + {group.count} {ui("screen.0ebb9fd0cad94e")} + {group.report_version !== group.version ? ui("screen.99c01379e8e143") : ui("screen.632e3cdf7f317c")}
@@ -595,7 +611,7 @@ function FindingFolderTableRows({ - + — @@ -614,18 +630,21 @@ function FindingFolderTableRows({ )} - {row.last_found_at ? fmtTime(row.last_found_at) : "—"} + {row.last_found_at ? fmtTime(row.last_found_at, ui) : "—"} {open ? ( -
+
v + 1); - toast.success("已移出,原始记录保留"); + toast.success(ui("screen.05de947f40ed2d")); } catch (e) { toast.error((e as Error).message); } finally { @@ -104,7 +107,7 @@ function CaseDetail() {

{c.title}

@@ -116,43 +119,48 @@ function CaseDetail() { ) : null} {!c.active ? ( - 此文件夹已解散或并入其他组,原始记录仍可从漏洞列表查看。 + {ui("screen.8da7ac0026e848")} ) : null} {readOnly ? ( - 继承任务的漏洞组,只读。 + {ui("screen.cae190b24fd138")} ) : ( )} - 统一报告 - 原始上报({c.count}) - 归并历史 + {ui("screen.37707567ad4c9b")} + + {ui("screen.6c69677e262868")} + {c.count} + {ui("punctuation.f2e44c6c95")} + + {ui("screen.300b7381491fc0")} - 完整复现与修复报告 - 集中查看所有成员的复现步骤、证据与修复建议。 + {ui("screen.e3ef802bb18ea1")} + {ui("screen.52f0424c627be3")} {c.report_version !== c.version ? ( - 成员或证据已变化,统一报告待更新。 + {ui("screen.5a4461e8cf1ca1")} ) : null} {!readOnly && c.active ? ( ) : null} {c.report ? ( ) : ( -

统一报告尚未生成,原始报告可在“原始上报”中查看。

+

{ui("screen.6d6ba94a0fca2a")}

)}
@@ -160,27 +168,27 @@ function CaseDetail() { - 原始报告和证据完整保留 - 各条原始上报保留各自的等级、报告和证据。 + {ui("screen.e30211571e67b5")} + {ui("screen.08bf5edecaeda0")} {!readOnly && selected.size > 0 ? ( - 移出归并组? - - 原报告和证据保留,Agent 不会自动把这些关系再次归并。 - + {ui("screen.43eee10403f01c")} + {ui("screen.f133ded17c223f")} - 取消 - 移出 + {ui("screen.2cd0f3be8738a8")} + {ui("screen.95861c4b5cd644")} @@ -198,25 +206,30 @@ function CaseDetail() { - 判断依据与操作记录 + {ui("screen.a44627c5d22f36")} {c.reason} {data.events.map((e) => (

- {({ reporter: "报告 Agent", human: "人工", system: "系统" } as Record)[e.actor] ?? - e.actor}{" "} + {( + { + reporter: ui("screen.2181e612bd0b2b"), + human: ui("screen.f21f45e7986472"), + system: ui("screen.5b50d7c4b5950d"), + } as Record + )[e.actor] ?? e.actor}{" "} ·{" "} {( { - merge: "归并", - detach: "移出成员", - member_deleted: "成员移出或删除", - report_updated: "更新统一报告", + merge: ui("screen.c63a1da7b7ad44"), + detach: ui("screen.71bc4e9f153b8e"), + member_deleted: ui("screen.27c02ea4653866"), + report_updated: ui("screen.43a4e2bb711282"), } as Record )[e.action] ?? e.action}{" "} - · {new Date(e.created_at).toLocaleString("zh-CN")} + · {new Date(e.created_at).toLocaleString(ui("screen.91610255157661"))}

{e.reason} diff --git a/web/src/app/(main)/function/findings/detail/lineage.tsx b/web/src/app/(main)/function/findings/detail/lineage.tsx index 2bb1b8c..cf803b2 100644 --- a/web/src/app/(main)/function/findings/detail/lineage.tsx +++ b/web/src/app/(main)/function/findings/detail/lineage.tsx @@ -1,8 +1,8 @@ "use client"; - import * as React from "react"; import { ExplorationGraph } from "@/components/exploration-graph"; +import { useUITranslation } from "@/i18n/use-ui-translation"; import { api } from "@/lib/api"; import type { Edge, TaskNode } from "@/lib/types"; @@ -10,6 +10,8 @@ import type { Edge, TaskNode } from "@/lib/types"; // node down to this finding's node — the same 攻击链路图 canvas as the task graph, // scoped to just this finding's lineage. export function FindingLineageView({ findingId }: { findingId: string }) { + const { ui } = useUITranslation(); + const [nodes, setNodes] = React.useState([]); const [edges, setEdges] = React.useState([]); const [loaded, setLoaded] = React.useState(false); @@ -33,11 +35,7 @@ export function FindingLineageView({ findingId }: { findingId: string }) { }, [findingId]); if (loaded && nodes.length === 0) { - return ( -

- 无链路可展示(该漏洞未关联探索节点,或所属任务已删除)。 -

- ); + return

{ui("screen.081f5367ee5cc5")}

; } return ( @@ -45,7 +43,7 @@ export function FindingLineageView({ findingId }: { findingId: string }) { nodes={nodes} edges={edges} className="h-[68vh]" - emptyHint={loaded ? "无链路" : "加载中…"} + emptyHint={loaded ? ui("screen.8a14e9e5a50155") : ui("screen.4927a53bcc886a")} /> ); } diff --git a/web/src/app/(main)/function/findings/detail/page.tsx b/web/src/app/(main)/function/findings/detail/page.tsx index f8db407..35d959c 100644 --- a/web/src/app/(main)/function/findings/detail/page.tsx +++ b/web/src/app/(main)/function/findings/detail/page.tsx @@ -1,11 +1,11 @@ "use client"; - import * as React from "react"; import Link from "next/link"; import { useSearchParams } from "next/navigation"; import { ArrowLeftIcon, ArrowUpRightIcon, BotIcon, FileTextIcon, Loader2Icon, ShieldAlertIcon } from "lucide-react"; +import { useTranslation } from "react-i18next"; import { toast } from "sonner"; import { CopyButton } from "@/components/copy-button"; @@ -23,6 +23,7 @@ import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger } from "@ import { Separator } from "@/components/ui/separator"; import { SidebarTrigger } from "@/components/ui/sidebar"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { type UITranslate, useUITranslation } from "@/i18n/use-ui-translation"; import { api } from "@/lib/api"; import { statusMeta } from "@/lib/status"; import type { Finding, FindingStatus, Severity } from "@/lib/types"; @@ -42,8 +43,8 @@ const FINDING_STATUSES: FindingStatus[] = [ "risk_accepted", ]; -function fmtTime(ts: string) { - return new Date(ts).toLocaleString("zh-CN"); +function fmtTime(ts: string, ui: UITranslate) { + return new Date(ts).toLocaleString(ui("screen.91610255157661")); } function usesStructuredReproduction(report: string) { @@ -63,6 +64,9 @@ function FieldRow({ label, children }: { label: string; children: React.ReactNod } function FindingDetailInner() { + const { ui, liveUI } = useUITranslation(); + + const { t } = useTranslation(); const searchParams = useSearchParams(); const id = searchParams.get("id") ?? ""; const contextTaskId = searchParams.get("context_task") ?? ""; @@ -73,7 +77,7 @@ function FindingDetailInner() { const [reportGenerating, setReportGenerating] = React.useState(false); const [autoChatOpen, setAutoChatOpen] = React.useState(false); const [reportChatOpen, setReportChatOpen] = React.useState(false); - const title = finding?.name || finding?.vulnclass || "未分类"; + const title = finding?.name || finding?.vulnclass || ui("screen.54b89f90127458"); const load = React.useCallback(() => { if (!id) { @@ -98,13 +102,13 @@ function FindingDetailInner() { try { const updated = await api.setFindingSeverity(id, next); setFinding(updated); - toast.success(`严重等级已改为「${statusMeta("severity", next).label}」`); + toast.success(liveUI("screen.2ef0f1dd23335c", { v0: statusMeta("severity", next, t).label })); } catch (e) { setFinding((cur) => (cur ? { ...cur, severity: prev } : cur)); - toast.error("更新失败:" + (e as Error).message); + toast.error(liveUI("screen.4f2e8a8d4cb328") + (e as Error).message); } }, - [finding, id], + [finding, id, t, liveUI], ); const changeStatus = React.useCallback( @@ -115,13 +119,13 @@ function FindingDetailInner() { try { const updated = await api.setFindingStatus(id, next); setFinding(updated); - toast.success(`处理状态已改为「${statusMeta("finding", next).label}」`); + toast.success(liveUI("screen.96d69e2bc13dcd", { v0: statusMeta("finding", next, t).label })); } catch (e) { setFinding((cur) => (cur ? { ...cur, status: prev } : cur)); - toast.error("更新失败:" + (e as Error).message); + toast.error(liveUI("screen.4f2e8a8d4cb328") + (e as Error).message); } }, - [finding, id], + [finding, id, t, liveUI], ); const generateReport = React.useCallback(async () => { @@ -129,26 +133,28 @@ function FindingDetailInner() { setReportGenerating(true); try { await api.generateFindingReport(id); - toast.success("已提交生成请求,报告 Agent 将补全步骤与详细报告"); + toast.success(liveUI("screen.111a2943460953")); // The reporter runs asynchronously. Refresh once after it has had a // chance to persist the report, while keeping the button available if // the run is delayed or interrupted. window.setTimeout(() => void load(), 2000); } catch (e) { - toast.error("提交生成请求失败:" + (e as Error).message); + toast.error(liveUI("screen.e89e7c308d04f7") + (e as Error).message); } finally { setReportGenerating(false); } - }, [finding, id, load, reportGenerating]); + }, [finding, id, load, reportGenerating, liveUI]); if (!finding) { return (
-

{loaded ? `未找到发现 ${id}` : "加载中…"}

+

+ {loaded ? ui("screen.ffa4db03812c67", { v0: id }) : ui("screen.4927a53bcc886a")} +

{loaded && ( )} @@ -161,268 +167,288 @@ function FindingDetailInner() { return ( <> - {/* Sticky header */} -
-
- - - - {finding.case_id ? ( - - ) : null} -

- {title} -

- #{finding.id} - - - - - {!finding.inherited ? ( - + ) : null} +

+ {title} +

+ + #{finding.id} + + + + + - ) : null} - {finding.inherited && finding.source_task_id && ( - 来源任务 #{finding.source_task_id} · 只读 - )} -
- - 概览 - 链路图 - -
- - {/* Tab content */} -
- {/* 概览:左(摘要 + 证据)/ 右(状态区) */} - -
- {/* 左栏 */} -
- - - 摘要 - - -

{finding.summary || "(无摘要)"}

-
-
- {structuredReproduction ? ( - - ) : ( - <> - - - 证据 / PoC - - - {finding.evidence ? ( -
-                          {finding.evidence}
-                        
- ) : ( -

(无证据)

- )} -
-
- - - - 详细报告 -
- {finding.report && } - {!finding.inherited ? ( - - ) : null} -
-
- - {finding.report_stale ? ( - - 流量证据已变更,详细报告待更新。 - - ) : null} - {finding.report ? ( - - ) : ( -

暂无详细报告。

- )} -
-
- - )} -
- - {/* 右栏:状态区 */} -
- - - 状态 - - - {/* 漏洞 ID */} - - - #{finding.id} - - - - {/* 严重等级 */} - - {finding.inherited ? ( - - ) : ( - - )} - - - {/* 处理状态 */} - - {finding.inherited ? ( - - ) : ( - - )} - - - {/* 漏洞类型 */} - - {finding.vulnclass ? ( - {finding.vulnclass} - ) : ( - — - )} - - - {/* 涉及资产 */} - - {finding.assets && finding.assets.length > 0 ? ( -
- {finding.assets.map((a) => ( - - {a.label} - - ))} -
- ) : ( - — - )} -
- - {/* 所属任务 */} - - {finding.task_id ? ( - - {finding.task_description || `#${finding.task_id}`} - - - ) : ( - —(任务已删除) - )} - - - {/* 发现时间 */} - - {fmtTime(finding.ts)} - -
-
- -
+ {!finding.inherited ? ( + + ) : null} + {finding.inherited && finding.source_task_id && ( + + {ui("screen.88b75b1fbea969")} + {finding.source_task_id} {ui("screen.8feecd4f35005e")} + + )}
-
+ + {ui("screen.fea405f9b01d14")} + {ui("screen.c292d0de066e85")} + + - {/* 链路图:从任务初始节点回溯到本漏洞节点的攻击链路 */} - - - -
+ {/* Tab content */} +
+ {/* 概览:左(摘要 + 证据)/ 右(状态区) */} + +
+ {/* 左栏 */} +
+ + + {ui("screen.21c04b2eeeb431")} + + +

+ {finding.summary || ui("screen.e0cea5c3049cb8")} +

+
+
+ {structuredReproduction ? ( + + ) : ( + <> + + + {ui("screen.c13ed981c63391")} + + + {finding.evidence ? ( +
+                            {finding.evidence}
+                          
+ ) : ( +

{ui("screen.2a4a67ca9bce1b")}

+ )} +
+
+ + + + {ui("screen.566e5ab4ab0638")} +
+ {finding.report && ( + + )} + {!finding.inherited ? ( + + ) : null} +
+
+ + {finding.report_stale ? ( + + {ui("screen.2ba06b4abdd2d0")} + + ) : null} + {finding.report ? ( + + ) : ( +

{ui("screen.6283d9ab225878")}

+ )} +
+
+ + )} +
+ + {/* 右栏:状态区 */} +
+ + + {ui("screen.6320b4a8722a85")} + + + {/* 漏洞 ID */} + + + #{finding.id} + + + + {/* 严重等级 */} + + {finding.inherited ? ( + + ) : ( + + )} + + + {/* 处理状态 */} + + {finding.inherited ? ( + + ) : ( + + )} + + + {/* 漏洞类型 */} + + {finding.vulnclass ? ( + {finding.vulnclass} + ) : ( + — + )} + + + {/* 涉及资产 */} + + {finding.assets && finding.assets.length > 0 ? ( +
+ {finding.assets.map((a) => ( + + {a.label} + + ))} +
+ ) : ( + — + )} +
+ + {/* 所属任务 */} + + {finding.task_id ? ( + + {finding.task_description || `#${finding.task_id}`} + + + ) : ( + {ui("screen.06ae67d1f3260f")} + )} + + + {/* 发现时间 */} + + {fmtTime(finding.ts, ui)} + +
+
+ +
+
+
+ + {/* 链路图:从任务初始节点回溯到本漏洞节点的攻击链路 */} + + + +
- + ("cases"); const [severity, setSeverity] = React.useState<"all" | Severity>("all"); const [status, setStatus] = React.useState<"all" | FindingStatus>("all"); @@ -195,7 +199,7 @@ export default function FindingsPage() { setActiveRetests(Object.fromEntries(rows.map((item) => [item.finding_id, item]))); failed = false; } catch (error) { - if (!disposed && !failed) toast.error(`加载复测状态失败:${(error as Error).message}`); + if (!disposed && !failed) toast.error(liveUI("screen.34d011b1c81f43", { v0: (error as Error).message })); failed = true; } finally { if (!disposed) timer = setTimeout(() => void refreshRetests(), 3000); @@ -206,7 +210,7 @@ export default function FindingsPage() { disposed = true; clearTimeout(timer); }; - }, []); + }, [liveUI]); React.useEffect(() => { const raw = getLocalStorageValue(FINDING_LIST_PREFERENCE_KEY); @@ -328,9 +332,9 @@ export default function FindingsPage() { includeOriginals, }); setExportOpen(false); - toast.success("已开始下载导出文件"); + toast.success(ui("screen.f8ac8adc09dfbb")); } catch (e) { - toast.error(`导出失败:${(e as Error).message}`); + toast.error(ui("screen.d264572f9d3110", { v0: (e as Error).message })); } finally { setExporting(false); } @@ -422,9 +426,9 @@ export default function FindingsPage() { } catch (e) { if (request !== assetTreeRequest.current || activeFilterFingerprint.current !== requestFilter) return; setAssetTree((current) => ({ ...current, loading: false })); - toast.error(`资产树加载失败:${(e as Error).message}`); + toast.error(liveUI("screen.4dfcf1b3cb8efc", { v0: (e as Error).message })); } - }, [dateRange, filterFingerprint, severity, status, vulnclass, task, query, sort]); + }, [dateRange, filterFingerprint, severity, status, vulnclass, task, query, sort, liveUI]); const refreshGroups = React.useCallback(async () => { const requestFilter = filterFingerprint; @@ -683,7 +687,7 @@ export default function FindingsPage() { setFindings((cur) => cur.map((x) => (isSameFinding(x, f) ? { ...x, status: next } : x))); try { await api.setFindingStatus(f.finding_id, next); - toast.success(`已标记为「${statusMeta("finding", next).label}」`); + toast.success(liveUI("screen.caef7e7d3c7ddb", { v0: statusMeta("finding", next, t).label })); // refresh stat cards (pending count) and drop the row if it no longer matches the status filter void refreshStats(); if (status !== "all" && next !== status) { @@ -694,10 +698,10 @@ export default function FindingsPage() { refreshAfterMutation(f); } catch (e) { setFindings((cur) => cur.map((x) => (isSameFinding(x, f) ? { ...x, status: prev } : x))); - toast.error(`更新失败:${(e as Error).message}`); + toast.error(liveUI("screen.4cdda2a76dca47", { v0: (e as Error).message })); } }, - [refreshAfterMutation, refreshStats, setFindings, status], + [refreshAfterMutation, refreshStats, setFindings, status, t, liveUI], ); // 行内展开的详细报告缓存按全局稳定行键存。report 是大段 Markdown,列表查询不带它, @@ -749,16 +753,16 @@ export default function FindingsPage() { : x, ), ); - toast.success("已保存"); + toast.success(liveUI("screen.1bd91a7d0c53c9")); void refreshStats(); refreshAfterMutation(f); } catch (e) { - toast.error(`保存失败:${(e as Error).message}`); + toast.error(liveUI("screen.20e1b0f2590bc7", { v0: (e as Error).message })); } finally { setSaving(false); } }, - [edit, refreshAfterMutation, refreshStats, setFindings], + [edit, refreshAfterMutation, refreshStats, setFindings, liveUI], ); // deleteFinding 删除一个漏洞(需二次确认):删成功后从列表移除、收起行、刷新统计。 @@ -777,14 +781,14 @@ export default function FindingsPage() { setFlat((cur) => ({ ...cur, total: Math.max(0, cur.total - 1) })); const rowKey = findingRowKey(f); setExpanded((cur) => (cur === rowKey ? null : cur)); - toast.success("已删除漏洞"); + toast.success(liveUI("screen.0fe9181fee53be")); void refreshStats(); refreshAfterMutation(f, true); } catch (e) { - toast.error(`删除失败:${(e as Error).message}`); + toast.error(liveUI("screen.77dcc9d08e857c", { v0: (e as Error).message })); } }, - [refreshAfterMutation, refreshStats, setFindings], + [refreshAfterMutation, refreshStats, setFindings, liveUI], ); const openDeepen = React.useCallback((f: Finding) => { @@ -799,26 +803,26 @@ export default function FindingsPage() { const result = await api.deepenFinding(deepenFinding.finding_id, deepenDescription.trim()); toast.success( result.queued - ? `深入意图 #${result.intent_id} 已进入任务队列` - : `已创建高优先级 Worker 意图 #${result.intent_id}`, + ? ui("screen.44edee80661c59", { v0: result.intent_id }) + : ui("screen.d16e422fd48cba", { v0: result.intent_id }), ); refreshAfterMutation(deepenFinding); setDeepenFinding(null); setDeepenDescription(""); } catch (error) { - toast.error(`提交失败:${(error as Error).message}`); + toast.error(ui("screen.fd68c8cd2c3e99", { v0: (error as Error).message })); } finally { setDeepening(false); } } const statCards = [ - { label: "独立漏洞", value: stats.distinct?.total ?? stats.total, icon: BugIcon }, - { label: "上报记录", value: stats.total, tone: "text-amber-500", icon: ClockIcon }, - { label: "严重", value: stats.critical, tone: "text-rose-600", icon: ShieldAlertIcon }, - { label: "高危", value: stats.high, tone: "text-red-500", icon: TriangleAlertIcon }, - { label: "中危", value: stats.medium, tone: "text-amber-500", icon: TriangleAlertIcon }, - { label: "低危", value: stats.low, tone: "text-slate-500", icon: InfoIcon }, + { label: ui("screen.a8819d5b21f49d"), value: stats.distinct?.total ?? stats.total, icon: BugIcon }, + { label: ui("screen.bfec0e30c3ac5a"), value: stats.total, tone: "text-amber-500", icon: ClockIcon }, + { label: ui("screen.73eb0e14e307cd"), value: stats.critical, tone: "text-rose-600", icon: ShieldAlertIcon }, + { label: ui("screen.4aa71c5705662f"), value: stats.high, tone: "text-red-500", icon: TriangleAlertIcon }, + { label: ui("screen.36a7c77b623b5a"), value: stats.medium, tone: "text-amber-500", icon: TriangleAlertIcon }, + { label: ui("screen.27a7f42a0afb52"), value: stats.low, tone: "text-slate-500", icon: InfoIcon }, ]; // 导出弹窗里「当前筛选」的条数:两个视图的筛选一致,只是统计口径来源不同。 @@ -862,7 +866,7 @@ export default function FindingsPage() { items={flat.items} caseRows={flat.caseRows} presentation={view === "asset" ? "asset" : "records"} - selectAllLabel="选择当前页全部" + selectAllLabel={ui("screen.bc039dd5faffdd")} {...rowProps} />
-

发现

-

跨任务漏洞汇总

+

{ui("screen.10493d06b90f6b")}

+

{ui("screen.ca66d0605ee54c")}

setView(v as FindingView)}> - 按漏洞 - 原始上报 - 按任务分组 - 按资产 + {ui("screen.f8524eae6e2203")} + {ui("screen.69c04c96fe6017")} + {ui("screen.db5e08278bfd13")} + {ui("screen.ee7a3d75fc91e4")}
@@ -922,8 +926,8 @@ export default function FindingsPage() { type="search" value={search} onChange={(event) => setSearch(event.target.value)} - placeholder="检索漏洞内容" - aria-label="检索漏洞内容" + placeholder={ui("screen.3e04372feb7849")} + aria-label={ui("screen.3e04372feb7849")} />
@@ -1071,7 +1087,7 @@ export default function FindingsPage() { className={cn("hover:text-foreground", assetScope === null && "font-medium text-foreground")} onClick={() => setAssetScope(null)} > - 全部资产 + {ui("screen.036f4b70eb7f43")} {assetPath.map((node) => ( @@ -1089,7 +1105,10 @@ export default function FindingsPage() { ))} - 共 {flat.total} 条 + + {ui("screen.76e547a8fa5463")} + {flat.total} {ui("screen.f004f1d84cf962")} +
{flatListCard}
@@ -1137,13 +1156,13 @@ export default function FindingsPage() {
{group.task_id === null - ? "未关联 / 任务已删除" + ? ui("screen.dde6973aeb2ebd") : group.task_name - ? `${group.task_name}(任务 #${group.task_id})` - : `任务 #${group.task_id}`} + ? ui("screen.e618ab1e086a8c", { v0: group.task_name, v1: group.task_id }) + : ui("screen.f4fc8dc74e621b", { v0: group.task_id })} - {group.task_description || "来源任务不可用"} + {group.task_description || ui("screen.bec39379e8824b")}
@@ -1160,13 +1179,13 @@ export default function FindingsPage() { ); })} - {fmtTime(group.last_found_at)} + {fmtTime(group.last_found_at, ui)} {group.task_id !== null && (