From 5a8722ff07d198541999965f30c0aea365ab765f Mon Sep 17 00:00:00 2001 From: ryan Date: Sun, 16 Aug 2026 16:49:16 +0800 Subject: [PATCH] =?UTF-8?q?feat(frontend):=20=E6=8E=A7=E5=88=B6=E5=8F=B0?= =?UTF-8?q?=20next-intl=20=E4=B8=AD=E8=8B=B1=E5=8F=8C=E8=AF=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 接入无 URL 前缀的 zh-CN/en,顶栏与外观设置可切换语言;选择写入 cookie 后刷新生效。 --- AGENTS.md | 7 + docs/changelog/index.md | 3 + docs/design/index.md | 1 + .../specs/2026-07-24-frontend-i18n-design.md | 253 + .../components/access-log-detail-dialog.tsx | 47 +- .../components/access-log-filters.tsx | 39 +- .../components/access-log-utils.ts | 80 +- .../access-logs/components/cleanup-dialog.tsx | 27 +- .../access-logs/components/detail-tab.tsx | 35 +- .../components/ip-analysis-panel.tsx | 102 +- .../components/ip-detail-dialog.tsx | 6 +- .../(main)/access-logs/components/ip-tab.tsx | 82 +- .../access-logs/components/overview-tab.tsx | 43 +- .../components/overview-toolbar.tsx | 43 +- frontend/app/(main)/access-logs/page.tsx | 29 +- .../database/components/cache-manager.tsx | 81 +- .../admin/database/components/sql-console.tsx | 52 +- .../database/components/table-browser.tsx | 34 +- .../app/(main)/admin/database/page-client.tsx | 60 +- frontend/app/(main)/admin/demo/page.tsx | 60 +- .../(main)/admin/demo/tabs/controls-tab.tsx | 46 +- .../(main)/admin/demo/tabs/dashboard-tab.tsx | 40 +- .../app/(main)/admin/demo/tabs/table-tab.tsx | 60 +- .../admin/files/components/file-list.tsx | 111 +- .../admin/files/components/file-stats.tsx | 165 +- .../files/components/storage-config-tab.tsx | 82 +- frontend/app/(main)/admin/files/page.tsx | 12 +- .../logs/components/access-analytics.tsx | 77 +- .../admin/logs/components/access-logs.tsx | 93 +- .../(main)/admin/logs/components/app-logs.tsx | 22 +- .../app/(main)/admin/logs/page-client.tsx | 12 +- .../admin/push/components/events-tab.tsx | 168 +- .../admin/push/components/histories-tab.tsx | 84 +- .../admin/push/components/settings-tab.tsx | 127 +- frontend/app/(main)/admin/push/page.tsx | 10 +- .../components/openflare-ops-utils.ts | 67 +- .../settings/components/openflare-ops.tsx | 173 +- .../settings/components/system-status.tsx | 94 +- .../components/uptimekuma-site-modal.tsx | 37 +- .../app/(main)/admin/settings/page-client.tsx | 20 +- frontend/app/(main)/admin/system/page.tsx | 45 +- .../tasks/components/task-executions.tsx | 132 +- .../admin/tasks/components/task-manager.tsx | 93 +- .../admin/tasks/components/task-schedules.tsx | 98 +- frontend/app/(main)/admin/tasks/page.tsx | 12 +- .../users/components/create-user-modal.tsx | 50 +- .../users/components/edit-user-modal.tsx | 51 +- .../users/components/user-detail-sheet.tsx | 44 +- .../users/components/user-filter-bar.tsx | 30 +- frontend/app/(main)/admin/users/page.tsx | 34 +- .../components/log-detail-sheet.tsx | 36 +- .../app/(main)/apply-logs/page-client.tsx | 118 +- frontend/app/(main)/certificates/page.tsx | 111 +- .../cloudflare/components/group-dialog.tsx | 33 +- .../components/member-add-dialog.tsx | 44 +- .../components/sync-tasks-panel.tsx | 145 +- .../cloudflare/groups/[id]/page-client.tsx | 74 +- .../(main)/cloudflare/groups/[id]/page.tsx | 6 +- frontend/app/(main)/cloudflare/page.tsx | 65 +- .../app/(main)/cloudflare/settings/page.tsx | 50 +- .../dashboard/capacity-trend-chart.tsx | 19 +- .../dashboard/distribution-rank-charts.tsx | 37 +- .../dashboard/node-health-table.tsx | 50 +- .../components/dashboard/node-rank-panel.tsx | 13 +- .../components/dashboard/stat-cards.tsx | 34 +- .../traffic-capacity-trend-chart.tsx | 18 +- .../dashboard/traffic-trend-chart.tsx | 25 +- .../components/dashboard/world-stage-map.tsx | 35 +- .../components/dashboard/world-stage.tsx | 69 +- .../components/cleanup-dialog.tsx | 31 +- .../components/diff-dialog.tsx | 72 +- .../components/preview-sheet.tsx | 32 +- .../components/version-snapshot-sheet.tsx | 37 +- frontend/app/(main)/config-versions/page.tsx | 115 +- frontend/app/(main)/dns-accounts/page.tsx | 41 +- frontend/app/(main)/files/page.tsx | 4 +- frontend/app/(main)/ip-groups/page.tsx | 52 +- .../nodes/components/agent-update-dialog.tsx | 74 +- .../nodes/components/disk-io-trend-chart.tsx | 21 +- .../nodes/components/distribution-list.tsx | 10 +- .../nodes/components/edge-node-detail.tsx | 140 +- .../nodes/components/install-command.tsx | 88 +- .../nodes/components/network-trend-chart.tsx | 21 +- .../nodes/components/node-detail-shell.tsx | 23 +- .../nodes/components/node-editor-dialog.tsx | 143 +- .../nodes/components/node-observability.tsx | 283 +- .../nodes/components/node-type-filter.tsx | 33 +- .../app/(main)/nodes/components/node-utils.ts | 82 +- .../(main)/nodes/components/nodes-table.tsx | 60 +- .../nodes/components/relay-node-detail.tsx | 155 +- .../nodes/components/tunnel-node-detail.tsx | 143 +- .../app/(main)/nodes/detail/page-client.tsx | 18 +- frontend/app/(main)/nodes/page-client.tsx | 40 +- .../components/origin-editor-dialog.tsx | 60 +- .../app/(main)/origins/detail/page-client.tsx | 62 +- frontend/app/(main)/origins/page.tsx | 48 +- frontend/app/(main)/page.tsx | 22 +- .../components/deployment-upload-dialog.tsx | 40 +- .../components/project-editor-dialog.tsx | 21 +- .../(main)/pages/components/project-form.tsx | 115 +- .../pages/components/project-list-item.tsx | 16 +- .../detail/components/danger-zone-card.tsx | 28 +- .../components/deployment-files-panel.tsx | 10 +- .../detail/components/deployment-history.tsx | 121 +- .../detail/components/pages-source-card.tsx | 94 +- .../components/pages-source-details.tsx | 70 +- .../detail/components/pages-source-dialog.tsx | 62 +- .../components/pages-source-github-fields.tsx | 25 +- .../components/project-settings-card.tsx | 18 +- frontend/app/(main)/pages/detail/page.tsx | 26 +- frontend/app/(main)/pages/page.tsx | 22 +- .../components/performance-utils.ts | 50 +- frontend/app/(main)/performance/page.tsx | 61 +- .../(main)/proxy-routes/components/helpers.ts | 183 +- .../components/proxy-route-create-sheet.tsx | 209 +- .../components/zone-domain-selector.tsx | 39 +- .../detail/components/auth-section.tsx | 78 +- .../detail/components/cache-section.tsx | 149 +- .../detail/components/domain-section.tsx | 69 +- .../detail/components/limits-section.tsx | 132 +- .../detail/components/proxy-section.tsx | 224 +- .../detail/components/route-header.tsx | 40 +- .../detail/components/section-shell.tsx | 5 +- .../detail/components/waf-section.tsx | 42 +- .../detail/hooks/use-route-section-save.ts | 9 +- .../proxy-routes/detail/page-client.tsx | 17 +- .../app/(main)/proxy-routes/page-client.tsx | 64 +- .../rate-limits/components/analysis-tab.tsx | 35 +- .../rate-limits/components/config-tab.tsx | 55 +- .../components/rate-pressure-chart.tsx | 20 +- frontend/app/(main)/rate-limits/page.tsx | 25 +- .../responses/components/error-page-tab.tsx | 44 +- .../responses/components/offline-page-tab.tsx | 43 +- .../components/scope-domain-dialog.tsx | 37 +- .../(main)/responses/error-page/edit/page.tsx | 48 +- .../responses/error-page/preview/page.tsx | 25 +- .../(main)/responses/offline/edit/page.tsx | 50 +- .../(main)/responses/offline/preview/page.tsx | 25 +- frontend/app/(main)/responses/page.tsx | 26 +- frontend/app/(main)/settings/page.tsx | 68 +- .../waf/components/create-rule-dialog.tsx | 17 +- frontend/app/(main)/waf/components/helpers.ts | 48 +- .../(main)/waf/components/ip-group-dialog.tsx | 165 +- .../waf/components/ip-group-test-dialog.tsx | 30 +- .../waf/components/ip-group-view-dialog.tsx | 64 +- .../(main)/waf/components/ip-groups-table.tsx | 57 +- .../waf/components/rule-groups-table.tsx | 34 +- frontend/app/(main)/waf/page.tsx | 39 +- .../components/node-properties.test.tsx | 60 +- .../editor/components/node-properties.tsx | 191 +- .../editor/components/rule-flow-canvas.tsx | 6 +- .../components/unsaved-changes.test.tsx | 27 +- .../editor/components/unsaved-changes.tsx | 13 +- .../app/(main)/waf/rules/editor/page.test.tsx | 15 +- frontend/app/(main)/waf/rules/editor/page.tsx | 48 +- .../[zoneId]/components/zone-certificates.tsx | 28 +- .../components/zone-domains-table.tsx | 43 +- .../components/zone-editor-dialog.tsx | 41 +- .../[zoneId]/components/zone-overview.tsx | 55 +- .../(main)/websites/[zoneId]/page-client.tsx | 55 +- .../app/(main)/websites/[zoneId]/page.tsx | 4 +- .../components/certificate-apply-dialog.tsx | 75 +- .../components/certificate-detail-dialog.tsx | 52 +- .../components/certificate-editor-dialog.tsx | 38 +- .../components/certificate-import-dialog.tsx | 61 +- .../components/dns-account-create-dialog.tsx | 43 +- .../quick-create-zone-domain-dialog.tsx | 93 +- .../components/resolve-zone-domain-input.ts | 22 +- .../app/(main)/websites/components/schemas.ts | 62 +- .../websites/components/website-utils.ts | 27 +- frontend/app/(main)/websites/page.tsx | 32 +- frontend/app/layout.tsx | 43 +- frontend/components/auth/cap-widget.tsx | 20 +- frontend/components/auth/login-form.tsx | 62 +- frontend/components/auth/login-page.tsx | 26 +- frontend/components/auth/otp-form.tsx | 20 +- frontend/components/auth/register-form.tsx | 79 +- frontend/components/auth/require-auth.tsx | 4 +- .../components/common/language-switcher.tsx | 78 + .../common/settings/access-token.tsx | 125 +- .../components/common/settings/appearance.tsx | 61 +- .../common/settings/auth-source-modal.tsx | 34 +- .../components/common/settings/info-tab.tsx | 62 +- .../common/settings/notifications.tsx | 19 +- .../common/settings/operation-tab.tsx | 58 +- .../components/common/settings/other-tab.tsx | 127 +- .../components/common/settings/profile.tsx | 183 +- .../common/settings/security-tab.tsx | 143 +- .../components/common/settings/system-tab.tsx | 105 +- .../components/common/settings/templates.tsx | 71 +- .../components/common/user/file-manager.tsx | 52 +- frontend/components/layout/header.tsx | 33 +- .../layout/openflare-sidebar-menu.tsx | 44 +- frontend/components/layout/search-dialog.tsx | 80 +- frontend/components/layout/sidebar.tsx | 194 +- .../components/providers/intl-provider.tsx | 24 + frontend/i18n/client.ts | 72 + frontend/i18n/config.ts | 67 + frontend/i18n/format.ts | 39 + frontend/i18n/request.ts | 44 + frontend/lib/navigation/openflare-nav.ts | 57 +- frontend/lib/utils/search-data.ts | 138 +- frontend/messages/en.json | 4404 +++++++++++++++++ frontend/messages/fragments/admin.en.json | 141 + frontend/messages/fragments/admin.zh-CN.json | 141 + frontend/messages/fragments/layout.en.json | 44 + frontend/messages/fragments/layout.zh-CN.json | 44 + frontend/messages/fragments/ops.en.json | 673 +++ frontend/messages/fragments/ops.zh-CN.json | 673 +++ frontend/messages/fragments/security.en.json | 1080 ++++ .../messages/fragments/security.zh-CN.json | 1080 ++++ frontend/messages/fragments/websites.en.json | 582 +++ .../messages/fragments/websites.zh-CN.json | 582 +++ frontend/messages/zh-CN.json | 4404 +++++++++++++++++ frontend/next.config.ts | 5 +- frontend/package.json | 4 +- frontend/pnpm-lock.yaml | 399 ++ frontend/scripts/check-i18n-keys.mjs | 36 + frontend/scripts/merge-i18n-fragments.mjs | 43 + 219 files changed, 21437 insertions(+), 5212 deletions(-) create mode 100644 docs/superpowers/specs/2026-07-24-frontend-i18n-design.md create mode 100644 frontend/components/common/language-switcher.tsx create mode 100644 frontend/components/providers/intl-provider.tsx create mode 100644 frontend/i18n/client.ts create mode 100644 frontend/i18n/config.ts create mode 100644 frontend/i18n/format.ts create mode 100644 frontend/i18n/request.ts create mode 100644 frontend/messages/en.json create mode 100644 frontend/messages/fragments/admin.en.json create mode 100644 frontend/messages/fragments/admin.zh-CN.json create mode 100644 frontend/messages/fragments/layout.en.json create mode 100644 frontend/messages/fragments/layout.zh-CN.json create mode 100644 frontend/messages/fragments/ops.en.json create mode 100644 frontend/messages/fragments/ops.zh-CN.json create mode 100644 frontend/messages/fragments/security.en.json create mode 100644 frontend/messages/fragments/security.zh-CN.json create mode 100644 frontend/messages/fragments/websites.en.json create mode 100644 frontend/messages/fragments/websites.zh-CN.json create mode 100644 frontend/messages/zh-CN.json create mode 100644 frontend/scripts/check-i18n-keys.mjs create mode 100644 frontend/scripts/merge-i18n-fragments.mjs diff --git a/AGENTS.md b/AGENTS.md index a47a1db1..6ed1b088 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -228,3 +228,10 @@ frontend/lib/services// - 继承 `BaseService`,定义 `basePath`,有类型静态方法;在 `frontend/lib/services/index.ts` 注册。 - 回调/`mutationFn`/`queryFn` **禁止**直接传静态方法引用(丢 `this`);用箭头:`(p) => XxxService.create(p)`。 + +### 国际化 (i18n) + +- 使用 `next-intl`(无 URL locale 前缀 / provider 模式),兼容 `NEXT_STANDALONE_EXPORT`。 +- 语言:`zh-CN`、`en`;默认 `zh-CN`。优先级:cookie `NEXT_LOCALE` → 浏览器语言 → 默认。 +- 文案放在 `frontend/messages/{locale}.json`。组件内用户可见文案必须 `useTranslations` / `getTranslations`,禁止新增中英硬编码 UI 字符串(后端 `error_msg`、日志除外)。 +- 新增或修改文案必须同步 `zh-CN.json` 与 `en.json`。设计见 `docs/superpowers/specs/2026-07-24-frontend-i18n-design.md`。 diff --git a/docs/changelog/index.md b/docs/changelog/index.md index 0b6b9960..b5ceb0f1 100644 --- a/docs/changelog/index.md +++ b/docs/changelog/index.md @@ -10,6 +10,9 @@ sidebar: false ## [Unreleased] +### 新增 +- 控制台接入中英双语(next-intl,无 URL 语言前缀):默认中文,可在顶栏或「外观设置」切换;选择写入 cookie 后刷新生效。 + ### 🛠 修复 - 修复自定义 Webhook 推送在企业微信/钉钉返回 HTTP 200 但 `errcode` 非零时仍记为成功的问题;任务日志会记录上游响应体。 - 修复 OpenTelemetry Resource 绑定 semconv schema 版本导致 SDK 升级后可能无法启动的问题。 diff --git a/docs/design/index.md b/docs/design/index.md index 767fe6fb..365d7f98 100644 --- a/docs/design/index.md +++ b/docs/design/index.md @@ -33,6 +33,7 @@ OpenFlare 适合需要统一管理多台 OpenResty 代理节点的团队,具 | **TLS 证书自动续期** | 将证书显式绑定到 Zone 域名,并通过 ACME 协议向 Let's Encrypt 申请/续期证书 | [Zone 与域名资源设计](./zone-design.md) | | **多节点监控与观测** | 访问日志为业务流量唯一真相;Agent 只上报明细与主机读数,Server 统一聚合;与 Zone/看板对账 | [观测数据传输模型](./observability-transport-model.md) / [边缘可观测与业务流量统计](./observability-design.md) / [上报协议与表结构](./observability-data-model.md) / [系统架构](./architecture.md) | | **日志存储** | 访问日志与可观测时序走可切换日志主库(随业务主库或 ClickHouse);关闭 ClickHouse 后仍可写可查 | [日志存储解耦](./logstore.md) | +| **控制台双语** | 无 URL 前缀的 zh-CN / en,cookie `NEXT_LOCALE` 优先,兼容静态导出 | [前端 i18n 设计](../superpowers/specs/2026-07-24-frontend-i18n-design.md) | --- diff --git a/docs/superpowers/specs/2026-07-24-frontend-i18n-design.md b/docs/superpowers/specs/2026-07-24-frontend-i18n-design.md new file mode 100644 index 00000000..bc6a65bd --- /dev/null +++ b/docs/superpowers/specs/2026-07-24-frontend-i18n-design.md @@ -0,0 +1,253 @@ +# Frontend i18n Design + +Date: 2026-07-24 +Status: Implemented in OpenFlare (ported from Wavelet 1625cfb, extended to product console) +Scope: Frontend UI only + +## 1. Goals + +Add bilingual UI support for Wavelet frontend: + +- Languages: `zh-CN` and `en` +- Default locale: `zh-CN` +- Locale resolution: explicit user choice → browser language → default +- Phase 1: infrastructure + core paths only (layout / auth / settings) +- Must remain compatible with `NEXT_STANDALONE_EXPORT` static export + +### Non-goals (Phase 1) + +- Backend API error / message localization +- Email / push notification localization +- URL locale prefixes (`/en/...`, `/zh-CN/...`) and SEO hreflang +- Full translation of all admin business pages + +## 2. Context + +Current state: + +- Root layout hardcodes `lang='zh-CN'` +- UI copy is mostly Chinese string literals across many TSX files +- Date formatting often hardcodes `zh-CN` / `date-fns/locale` `zhCN` +- No i18n library is installed +- Frontend supports both normal Next rewrites mode and static export embed mode + +## 3. Approach + +Use **next-intl in non-routing / provider mode**. + +Why this approach: + +- Mature App Router integration and clear `useTranslations` API +- ICU message format ready when needed +- Avoids locale-prefixed routing, which conflicts with static-export simplicity and current route structure +- Cookie + browser detection matches product preference without SEO path requirements + +Rejected alternatives: + +- Fully custom Context + JSON: lower dependency cost, but reimplements interpolation/plurals/type safety poorly +- `i18next` + `react-i18next`: powerful, but heavier and less natural for this Next App Router setup + +## 4. Architecture + +``` +RootLayout + html lang={locale} + ThemeProvider + CustomThemeProvider + AppQueryProvider + NextIntlClientProvider(locale, messages) + existing User / Notification / Bell providers + pages + components +``` + +### Key files + +| Path | Responsibility | +| --- | --- | +| `frontend/i18n/config.ts` | Supported locales, default locale, cookie name, normalize helpers | +| `frontend/i18n/request.ts` | `getRequestConfig` for server-side locale/messages resolution when not in export mode | +| `frontend/i18n/client.ts` | Client helpers to read/write locale preference | +| `frontend/messages/zh-CN.json` | Chinese messages | +| `frontend/messages/en.json` | English messages | +| `frontend/components/common/language-switcher.tsx` (or under `layout/`) | Language switch UI | +| `frontend/lib/i18n-format.ts` (optional location under `i18n/`) | Locale-aware date/number formatting helpers | + +### Runtime flow + +1. Resolve locale: cookie `NEXT_LOCALE` → browser languages → `zh-CN` +2. Load `messages/{locale}.json` +3. Provide locale + messages through `NextIntlClientProvider` +4. Components call `useTranslations('')` +5. Language switcher writes cookie and refreshes locale/messages +6. Update `document.documentElement.lang` + +## 5. Locale Resolution + +Supported locales: `zh-CN`, `en` + +Normalization: + +- `zh`, `zh-CN`, `zh-Hans*` → `zh-CN` +- `en`, `en-US`, `en-GB`, other `en-*` → `en` +- anything else → `zh-CN` + +Priority: + +1. User explicit choice stored in cookie `NEXT_LOCALE` +2. Browser language (`Accept-Language` on server, `navigator.languages` on client) +3. Default `zh-CN` + +Invalid cookie values are normalized to a supported locale and may be rewritten to a valid value. + +## 6. Static Export Compatibility + +Constraints: + +- No locale-segment routes +- No middleware-based locale rewriting required for correctness +- `build:embed` (`NEXT_STANDALONE_EXPORT=true`) must continue to work + +Behavior: + +- **Normal SSR/dev**: resolve locale on server when possible to reduce first-paint language flash +- **Static export**: ship both message catalogs; resolve on client from cookie/browser; accept a brief default-language flash similar to theme hydration, using existing `suppressHydrationWarning` patterns where needed + +## 7. Message Organization + +Single catalog files with nested namespaces: + +```json +{ + "common": { + "save": "保存", + "cancel": "取消", + "loading": "加载中..." + }, + "layout": { + "nav": { + "home": "首页", + "myFiles": "我的文件" + }, + "userMenu": { + "settings": "设置", + "logout": "退出登录" + } + }, + "auth": { + "login": { + "title": "登录", + "submit": "登录" + } + }, + "settings": { + "appearance": { + "language": "语言", + "languageDesc": "选择界面显示语言" + } + } +} +``` + +Conventions: + +- Keys use camelCase and hierarchical grouping +- Prefer complete phrases as values; avoid assembling sentences in components +- Use ICU only when needed (`{name}`, plural forms) +- Backend `error_msg` values are shown as-is in Phase 1 +- Frontend-owned toast / validation copy is translated + +Both locale files must keep the same key tree. A key-alignment check script is recommended. + +## 8. Language Switcher UX + +Placement: + +- Header toolbar near theme controls +- Appearance settings page as an explicit preference row + +UI labels for language options use native names and do not themselves translate: + +- `中文` +- `English` + +On change: + +1. Persist `NEXT_LOCALE` +2. Apply new locale/messages (via refresh or controlled provider update) +3. Sync `document.documentElement.lang` +4. Preserve unrelated UI state where practical (theme, auth session, sidebar collapse) + +## 9. Phase 1 Migration Scope + +### In scope + +- Install and wire `next-intl` +- Message catalogs for core namespaces +- Locale resolution + persistence +- `LanguageSwitcher` +- Translate: + - layout shell: sidebar nav/user menu, header accessible labels / titles + - auth: login / register / OTP labels, buttons, validation messages + - settings: appearance (including language preference), profile, security, notifications, access-token visible copy +- Replace date/number hardcoding only where touched by the above paths +- Ensure `html lang` reflects active locale + +### Out of scope + +- Remaining admin pages and deep business modules +- Backend localization +- Route prefixing / SEO alternate links + +Unmigrated pages may remain Chinese hard-coded; mixed-language UI is acceptable during incremental rollout. + +## 10. Formatting Helpers + +Introduce locale-aware helpers for dates/numbers used by migrated surfaces, e.g.: + +- `formatDateTime(value, locale)` +- `formatNumber(value, locale)` + +`date-fns` locale objects should follow active locale (`zhCN` / `enUS`) when a migrated component uses them. + +## 11. Error Handling & Fallbacks + +| Case | Behavior | +| --- | --- | +| Missing message key | Dev warning; do not crash; show key or fallback language value | +| Unsupported cookie locale | Normalize to supported locale / default | +| Partial migration | Keep hard-coded Chinese on unmigrated screens | +| Backend error strings | Display raw `error_msg` | + +## 12. Testing & Acceptance + +Manual: + +1. No cookie + browser Chinese → Chinese UI +2. No cookie + browser English → English UI +3. Manual switch to English survives refresh +4. Manual switch back to Chinese survives refresh +5. Core paths (layout/auth/settings) have no major residual hard-coded Chinese UI copy +6. `pnpm build` and `pnpm build:embed` both succeed +7. Language switch does not break theme, session, or sidebar state + +Automated (recommended): + +- Unit tests for `normalizeLocale` / resolution priority +- Script or test asserting `zh-CN.json` and `en.json` key parity + +## 13. Rollout Plan (high level) + +1. Add i18n infrastructure and empty/core message files +2. Mount provider and language switcher +3. Migrate layout shell copy +4. Migrate auth copy +5. Migrate settings copy + appearance language control +6. Verify SSR and static-export builds +7. Document how later pages should adopt `useTranslations` + +## 14. Open Implementation Notes + +- Prefer cookie name `NEXT_LOCALE` unless an existing project cookie convention conflicts during implementation +- Prefer minimal surface-area integration with next-intl; avoid introducing locale-based routing APIs that break static export +- Keep `internal/util` and backend packages untouched +- After implementation, follow repo frontend conventions and existing provider composition style diff --git a/frontend/app/(main)/access-logs/components/access-log-detail-dialog.tsx b/frontend/app/(main)/access-logs/components/access-log-detail-dialog.tsx index 7b95d01c..cfc98d28 100644 --- a/frontend/app/(main)/access-logs/components/access-log-detail-dialog.tsx +++ b/frontend/app/(main)/access-logs/components/access-log-detail-dialog.tsx @@ -1,5 +1,6 @@ 'use client'; +import { useTranslations } from 'next-intl'; import { useState } from 'react'; import { Badge } from '@/components/ui/badge'; @@ -60,6 +61,7 @@ export function AccessLogDetailDialog({ item: AccessLogItem | null; onOpenChange: (open: boolean) => void; }) { + const t = useTranslations('accessLogs'); // Keep last selected item while the dialog closes to avoid empty-state flash. const [displayItem, setDisplayItem] = useState(item); if (item && item !== displayItem) { @@ -71,21 +73,23 @@ export function AccessLogDetailDialog({ - 访问日志详情 - - 本条请求的全部业务字段。IP 访问分析请前往「IP 明细」。 - + {t('detail.title')} + {t('detail.description')} {activeItem ? (
- + - - + + {activeItem.status_code} @@ -117,12 +127,13 @@ export function AccessLogDetailDialog({ } /> {cacheOutcomeLabel( resolveCacheOutcome(activeItem.cache_status), + (key) => t(key), )} @@ -132,19 +143,19 @@ export function AccessLogDetailDialog({ } /> void; }) { + const t = useTranslations('accessLogs.filters'); const [open, setOpen] = useState(false); const current = value ? new Date(value) : undefined; @@ -121,7 +123,7 @@ function DateTimePicker({ {current ? ( format(current, 'yyyy-MM-dd HH:mm') ) : ( - 选择时间 + {t('pickTime')} )} @@ -153,12 +155,13 @@ export function AccessLogFilters({ onSearch, onReset, }: AccessLogFiltersProps) { + const t = useTranslations('accessLogs.filters'); const [moreOpen, setMoreOpen] = useState(false); return (
- + @@ -167,22 +170,22 @@ export function AccessLogFilters({ onKeyDown={(e) => { if (e.key === 'Enter') onSearch(); }} - placeholder='按 IP 搜索' + placeholder={t('remoteAddrPlaceholder')} className='h-9 text-xs' /> - + onDraftChange({ ...draft, host: e.target.value })} onKeyDown={(e) => { if (e.key === 'Enter') onSearch(); }} - placeholder='按域名搜索' + placeholder={t('hostPlaceholder')} className='h-9 text-xs' /> - + @@ -194,7 +197,7 @@ export function AccessLogFilters({ onKeyDown={(e) => { if (e.key === 'Enter') onSearch(); }} - placeholder='输入状态码,如 404' + placeholder={t('statusCodePlaceholder')} className='h-9 text-xs' /> @@ -207,7 +210,7 @@ export function AccessLogFilters({ size='sm' className='-ml-1 h-8 gap-1 px-1 text-xs text-muted-foreground' > - 更多筛选 + {t('more')}
- +
{ if (e.key === 'Enter') onSearch(); }} - placeholder='按 node_id 搜索' + placeholder={t('nodeIdPlaceholder')} className='pl-8 h-9 text-xs' />
- + @@ -242,11 +245,11 @@ export function AccessLogFilters({ onKeyDown={(e) => { if (e.key === 'Enter') onSearch(); }} - placeholder='按路径搜索' + placeholder={t('pathPlaceholder')} className='h-9 text-xs' /> - +
-

每页条数

+

+ {t('pageSize')} +

@@ -77,17 +78,17 @@ export function CleanupDialog({ {CLEANUP_PRESETS.map((days) => ( - 保留最近 {days} 天 + {t('keepDays', { days })} ))} - 自定义天数 + {t('custom')}
{mode === 'custom' ? (
- + - 取消 + + {tCommon('cancel')} + diff --git a/frontend/app/(main)/access-logs/components/detail-tab.tsx b/frontend/app/(main)/access-logs/components/detail-tab.tsx index 0399dd22..c22512aa 100644 --- a/frontend/app/(main)/access-logs/components/detail-tab.tsx +++ b/frontend/app/(main)/access-logs/components/detail-tab.tsx @@ -2,6 +2,7 @@ import { useState } from 'react'; import { Eye } from 'lucide-react'; +import { useTranslations } from 'next-intl'; import { EmptyStateWithBorder } from '@/components/layout/empty'; import { ErrorInline } from '@/components/layout/error'; @@ -65,9 +66,12 @@ function PaginationBar({ onPrev: () => void; onNext: () => void; }) { + const t = useTranslations('accessLogs.detail'); return (
-

当前第 {page + 1} 页

+

+ {t('page', { page: page + 1 })} +

@@ -113,6 +117,7 @@ export function DetailTab({ onNextPage: () => void; isFetching: boolean; }) { + const t = useTranslations('accessLogs'); const [selected, setSelected] = useState(null); const [detailOpen, setDetailOpen] = useState(false); @@ -120,7 +125,7 @@ export function DetailTab({ <>
-

日志明细

+

{t('detail.listTitle')}

- + {addableGroups.map((group) => ( @@ -323,7 +332,7 @@ function AddToIPGroupPanel({ @@ -361,6 +370,8 @@ export function IpAnalysisPanel({ */ initialHours?: number; }) { + const t = useTranslations('accessLogs'); + const trendChartConfig = useTrendChartConfig(); const [ipGroupOpen, setIpGroupOpen] = useState(false); const [rangeHours, setRangeHours] = useState(() => clampAnalysisHours(initialHours), @@ -368,8 +379,8 @@ export function IpAnalysisPanel({ const bucketMinutes = resolveBucketMinutes(rangeHours); const rangeHint = isOverviewPreset(rangeHours) - ? formatOverviewRangeHint(rangeHours) - : `近 ${rangeHours} 小时`; + ? formatOverviewRangeHint(rangeHours, (key, values) => t(key, values)) + : t('analysis.recentHours', { hours: rangeHours }); const trendQuery = useQuery({ queryKey: [ @@ -411,7 +422,7 @@ export function IpAnalysisPanel({ const isFetchingIP = trendQuery.isFetching || analysisQuery.isFetching; if (!ip) { - return ; + return ; } return ( @@ -423,7 +434,8 @@ export function IpAnalysisPanel({ variant='outline' onClick={() => setIpGroupOpen(true)} > - 将 IP 加入到 IP 组 + + {t('analysis.addToGroup')} - {option.label} + {t(option.labelKey)} ))}
{isLoadingIP ? ( - + ) : (
{analysisQuery.isError ? ( @@ -456,34 +468,34 @@ export function IpAnalysisPanel({ message={ analysisQuery.error instanceof Error ? analysisQuery.error.message - : '加载 IP 分析失败' + : t('analysis.loadFailed') } onRetry={() => void analysisQuery.refetch()} /> ) : analysis ? (
@@ -492,9 +504,13 @@ export function IpAnalysisPanel({
-

IP 请求趋势

+

{t('analysis.trend')}

- {ip} · {rangeHint} · {bucketMinutes} 分钟桶 + {t('analysis.trendMeta', { + ip, + range: rangeHint, + minutes: bucketMinutes, + })}

@@ -515,13 +531,13 @@ export function IpAnalysisPanel({ message={ trendQuery.error instanceof Error ? trendQuery.error.message - : '加载趋势失败' + : t('analysis.trendFailed') } onRetry={() => void trendQuery.refetch()} /> ) : trendChartData.every((point) => point.requests === 0) ? ( ) : ( - IP 详情 + {t('dialogTitle')} {ip || '—'} {displayRegion ? ( · {displayRegion} ) : null} - 。查看该 IP 的访问趋势、分布与 WAF IP 组操作。 + {t('dialogDesc')} string, ): string | null { if (!sinceLocal.trim() || !untilLocal.trim()) { - return '请填写开始与结束时间'; + return t ? t('ip.needRange') : 'needRange'; } const sinceMs = new Date(sinceLocal).getTime(); const untilMs = new Date(untilLocal).getTime(); if (Number.isNaN(sinceMs) || Number.isNaN(untilMs)) { - return '时间格式无效'; + return t ? t('ip.invalidTime') : 'invalidTime'; } if (untilMs <= sinceMs) { - return '结束时间必须晚于开始时间'; + return t ? t('ip.endAfterStart') : 'endAfterStart'; } if (untilMs - sinceMs > MAX_CUSTOM_RANGE_MS) { - return '时间范围不能超过 30 天'; + return t ? t('ip.maxRange') : 'maxRange'; } return null; } @@ -128,11 +130,12 @@ function PaginationBar({ onPrev: () => void; onNext: () => void; }) { + const t = useTranslations('accessLogs.ip'); return (

- 当前第 {page + 1} 页 - {typeof totalIp === 'number' ? ` · 共 ${totalIp} 个 IP` : ''} + {t('page', { page: page + 1 })} + {typeof totalIp === 'number' ? t('total', { count: totalIp }) : ''}

@@ -201,6 +204,7 @@ export function IpTab({ onNextPage: () => void; isFetching: boolean; }) { + const t = useTranslations('accessLogs'); const [selected, setSelected] = useState(null); const [detailOpen, setDetailOpen] = useState(false); const [draftSince, setDraftSince] = useState(customSince); @@ -232,13 +236,15 @@ export function IpTab({ const rangeHint = timeMode === 'custom' && customSince && customUntil - ? '自定义区间' + ? t('ip.customRange') : timeMode === 'custom' - ? '自定义(未应用)' - : formatOverviewRangeHint(hours); + ? t('ip.customPending') + : formatOverviewRangeHint(hours, (key, values) => t(key, values)); const handleApplyCustom = () => { - const message = validateCustomTimeRange(draftSince, draftUntil); + const message = validateCustomTimeRange(draftSince, draftUntil, (key) => + t(key), + ); if (message) { setCustomError(message); return; @@ -253,11 +259,11 @@ export function IpTab({
-

时间范围

+

{t('ip.timeRange')}

- 当前:{rangeHint} + {t('ip.current', { range: rangeHint })} {data?.total_ip != null - ? ` · ${formatCompactNumber(data.total_ip)} 个 IP` + ? t('ip.total', { count: data.total_ip }) : ''}

@@ -281,7 +287,7 @@ export function IpTab({ value={String(option.value)} className='px-2.5 text-xs' > - {option.label} + {t(option.labelKey)} ))} @@ -291,7 +297,7 @@ export function IpTab({ className='h-8 text-xs' onClick={() => onTimeModeChange('custom')} > - 自定义 + {t('ip.custom')}
@@ -300,7 +306,7 @@ export function IpTab({
-

开始

+

{t('ip.start')}

-

结束

+

{t('ip.end')}

- 应用 + {t('ip.apply')}
{customError ? (

{customError}

) : (

- 修改时间后点击「应用」再查询;详情分析窗口与列表时长对齐。 + {t('ip.applyHint')}

)}
@@ -343,7 +349,7 @@ export function IpTab({
-

排序

+

{t('ip.sort')}

-

每页

+

{t('ip.pageSize')}

setQuery(event.target.value)} - placeholder='搜索 Zone 或域名' + placeholder={t('searchPlaceholder')} className='h-8 text-xs' />
{zonesQuery.isLoading ? (
- 加载域名… + {t('loadingHosts')}
) : zonesQuery.isError ? (
-

加载域名失败

+

{t('loadHostsFailed')}

) : filteredZones.length === 0 ? (

- {zones.length === 0 ? '暂无已登记域名' : '没有匹配的域名'} + {zones.length === 0 ? t('noRegistered') : t('noMatch')}

) : ( filteredZones.map((zone) => { @@ -247,7 +253,7 @@ export function OverviewHostFilter({ onCheckedChange={(checked) => toggleZone(zone.domains, checked) } - aria-label={`选择 Zone ${zone.zoneName}`} + aria-label={t('selectZone', { name: zone.zoneName })} />
- 概览 - IP 明细 - 日志明细 + {t('tabOverview')} + {t('tabIps')} + {t('tabList')} @@ -343,12 +345,13 @@ function AccessLogsPageContent() { } export default function AccessLogsPage() { + const t = useTranslations('accessLogs'); return ( } > diff --git a/frontend/app/(main)/admin/database/components/cache-manager.tsx b/frontend/app/(main)/admin/database/components/cache-manager.tsx index ec09a178..f8fc1429 100644 --- a/frontend/app/(main)/admin/database/components/cache-manager.tsx +++ b/frontend/app/(main)/admin/database/components/cache-manager.tsx @@ -2,6 +2,7 @@ import * as React from 'react'; import { useCallback, useEffect, useState } from 'react'; +import { useTranslations } from 'next-intl'; import { toast } from 'sonner'; import { HardDrive, RefreshCw, Trash2 } from 'lucide-react'; @@ -52,6 +53,7 @@ interface CacheManagerProps { } export function CacheManager({ refreshTrigger }: CacheManagerProps) { + const t = useTranslations('admin.database.cache'); const [cacheStatus, setCacheStatus] = useState(null); const [loadingCache, setLoadingCache] = useState(true); const [savingConfig, setSavingConfig] = useState(false); @@ -73,13 +75,13 @@ export function CacheManager({ refreshTrigger }: CacheManagerProps) { setTtlMinutes(data.ttl_minutes.toString()); setLruEnabled(data.lru_enabled); } catch (err) { - toast.error('获取磁盘缓存状态失败', { - description: err instanceof Error ? err.message : '未知错误', + toast.error(t('fetchCacheStatusFailed'), { + description: err instanceof Error ? err.message : t('unknownError'), }); } finally { setLoadingCache(false); } - }, []); + }, [t]); // 保存配置 const handleSaveConfig = async (e: React.FormEvent) => { @@ -87,14 +89,14 @@ export function CacheManager({ refreshTrigger }: CacheManagerProps) { const size = parseInt(maxSizeMB, 10); const ttl = parseInt(ttlMinutes, 10); if (isNaN(size) || size < 1) { - toast.error('保存失败', { - description: '最大容量限制必须是大于等于 1 的整数', + toast.error(t('saveFailed'), { + description: t('maxSizeMustBePositive'), }); return; } if (isNaN(ttl) || ttl < 0) { - toast.error('保存失败', { - description: '默认过期时间必须是大于等于 0 的整数', + toast.error(t('saveFailed'), { + description: t('ttlMustBeNonNegative'), }); return; } @@ -105,11 +107,13 @@ export function CacheManager({ refreshTrigger }: CacheManagerProps) { ttl_minutes: ttl, lru_enabled: lruEnabled, }); - toast.success('保存成功', { description: '磁盘缓存策略已热更新' }); + toast.success(t('saveSuccess'), { + description: t('cachePolicyHotUpdated'), + }); await fetchCacheStatus(true); } catch (err) { - toast.error('保存配置失败', { - description: err instanceof Error ? err.message : '未知错误', + toast.error(t('saveConfigFailed'), { + description: err instanceof Error ? err.message : t('unknownError'), }); } finally { setSavingConfig(false); @@ -121,12 +125,12 @@ export function CacheManager({ refreshTrigger }: CacheManagerProps) { setClearingCache(true); try { await services.adminCache.clearCache(); - toast.success('清空成功', { description: '缓存数据已全部清除' }); + toast.success(t('clearSuccess'), { description: t('cacheDataCleared') }); setShowClearConfirm(false); await fetchCacheStatus(true); } catch (err) { - toast.error('清空缓存失败', { - description: err instanceof Error ? err.message : '未知错误', + toast.error(t('clearCacheFailed'), { + description: err instanceof Error ? err.message : t('unknownError'), }); } finally { setClearingCache(false); @@ -149,10 +153,12 @@ export function CacheManager({ refreshTrigger }: CacheManagerProps) {
- 缓存管理 + + {t('cacheManagement')} +
- 管理和监控系统级磁盘缓存的资源占用、生命周期及淘汰策略 + {t('cacheManagementDesc')}
@@ -166,7 +172,7 @@ export function CacheManager({ refreshTrigger }: CacheManagerProps) { - 刷新状态 + {t('refreshStatus')} @@ -181,13 +187,13 @@ export function CacheManager({ refreshTrigger }: CacheManagerProps) { {/* 左边:状态区 (2/5 cols) */}

- 运行状态 + {t('runtimeStatus')}

{/* 已占空间 */}

- 已用空间 + {t('usedSpace')}

{formatBytes(cacheStatus.total_size)} @@ -196,12 +202,12 @@ export function CacheManager({ refreshTrigger }: CacheManagerProps) { {/* Key数量 */}

- 缓存键数量 + {t('cacheKeysCount')}

{formatNumber(cacheStatus.keys_count)}{' '} - 个文件 + {t('files')}

@@ -210,7 +216,7 @@ export function CacheManager({ refreshTrigger }: CacheManagerProps) { {/* 存储路径 */}

- 缓存基准目录 + {t('cacheBaseDirectory')}

setShowClearConfirm(true)} > - 立即清空缓存 + {t('clearCacheNow')}
{/* 右边:配置区 (3/5 cols) */}

- 策略配置 + {t('policyConfig')}

setMaxSizeMB(e.target.value)} className='h-8 text-xs bg-background/50 border-border/40' - placeholder='例如 100' + placeholder={t('maxSizePlaceholder')} required />

- 当总大小超出该值时,自动触发淘汰算法 + {t('maxSizeHint')}

setTtlMinutes(e.target.value)} className='h-8 text-xs bg-background/50 border-border/40' - placeholder='例如 60,0 表示永不过期' + placeholder={t('ttlPlaceholder')} required />

- 缓存项的最长存活时间,超期后将失效被清理 + {t('ttlHint')}

@@ -281,10 +287,10 @@ export function CacheManager({ refreshTrigger }: CacheManagerProps) { htmlFor='lruEnabled' className='text-xs font-semibold block cursor-pointer' > - 启用 LRU 淘汰机制 + {t('enableLru')} - 在到达最大容量限制时,自动移除最久未被访问的缓存项。关闭该功能仅清理过期项。 + {t('enableLruDesc')}
- {savingConfig ? '正在保存...' : '保存设置'} + {savingConfig ? t('saving') : t('saveSettings')}
@@ -309,7 +315,7 @@ export function CacheManager({ refreshTrigger }: CacheManagerProps) { ) : (
- 未加载到缓存状态信息 + {t('noCacheStatus')}
)} @@ -320,11 +326,10 @@ export function CacheManager({ refreshTrigger }: CacheManagerProps) { - 确认清空所有缓存? + {t('confirmClearAllCache')} - 该操作将彻底清空磁盘目录下的全部缓存文件(包含临时解压、处理后的图片及各种块文件),重置键数量统计为 - 0。此操作不可撤销,且可能导致用户拉取资源时出现一过性的响应变慢。 + {t('confirmClearDesc')} @@ -335,7 +340,7 @@ export function CacheManager({ refreshTrigger }: CacheManagerProps) { onClick={() => setShowClearConfirm(false)} disabled={clearingCache} > - 取消 + {t('cancel')} diff --git a/frontend/app/(main)/admin/database/components/sql-console.tsx b/frontend/app/(main)/admin/database/components/sql-console.tsx index 97e6be68..ce45490e 100644 --- a/frontend/app/(main)/admin/database/components/sql-console.tsx +++ b/frontend/app/(main)/admin/database/components/sql-console.tsx @@ -6,6 +6,7 @@ import { useTheme } from 'next-themes'; import CodeMirror from '@uiw/react-codemirror'; import { sql } from '@codemirror/lang-sql'; import { toast } from 'sonner'; +import { useTranslations } from 'next-intl'; import { ArrowLeft, Play, RefreshCw, Terminal, Trash2 } from 'lucide-react'; import { Button } from '@/components/ui/button'; @@ -49,6 +50,7 @@ interface SQLConsoleProps { } export function SQLConsole({ dbType, onClose }: SQLConsoleProps) { + const t = useTranslations('admin.database.sql'); // SQL 控制台状态 const [sqlQuery, setSqlQuery] = useState(''); const [executingSQL, setExecutingSQL] = useState(false); @@ -97,10 +99,10 @@ export function SQLConsole({ dbType, onClose }: SQLConsoleProps) { try { const result = await DbManageService.executeSQL(sqlQuery); setSqlResult(result); - toast.success('SQL 执行成功'); + toast.success(t('sqlExecSuccess')); } catch (err) { - setSqlError(err instanceof Error ? err.message : '未知执行错误'); - toast.error('SQL 执行失败'); + setSqlError(err instanceof Error ? err.message : t('unknownExecError')); + toast.error(t('sqlExecFailed')); } finally { setExecutingSQL(false); } @@ -134,7 +136,7 @@ export function SQLConsole({ dbType, onClose }: SQLConsoleProps) {

- SQL 查询终端 + {t('sqlQueryTerminal')}

@@ -153,34 +155,34 @@ export function SQLConsole({ dbType, onClose }: SQLConsoleProps) {
- SQL 编辑器 + {t('sqlEditor')}
{/* 快速模板 */}
- 快速模板: + {t('quickTemplates')} @@ -193,7 +195,7 @@ export function SQLConsole({ dbType, onClose }: SQLConsoleProps) { size='icon' className='h-7 w-7 text-muted-foreground hover:text-foreground' onClick={() => setSqlQuery('')} - title='清空编辑器' + title={t('clearEditor')} > @@ -204,7 +206,7 @@ export function SQLConsole({ dbType, onClose }: SQLConsoleProps) { disabled={executingSQL || !sqlQuery.trim()} > - {executingSQL ? '运行中...' : '运行'} + {executingSQL ? t('running') : t('run')}
@@ -236,7 +238,7 @@ export function SQLConsole({ dbType, onClose }: SQLConsoleProps) {
@@ -245,10 +247,10 @@ export function SQLConsole({ dbType, onClose }: SQLConsoleProps) {
{/* 结果栏工具提示 */}
- 执行输出 + {t('executionOutput')} {sqlResult && ( - 类型: {sqlResult.type.toUpperCase()} | 耗时:{' '} + {t('type')}: {sqlResult.type.toUpperCase()} | {t('duration')}:{' '} {sqlResult.execution_time_ms} ms )} @@ -260,14 +262,14 @@ export function SQLConsole({ dbType, onClose }: SQLConsoleProps) {
- 正在在数据库执行查询,请稍候... + {t('executingQuery')}
)} {sqlError && (
-

SQL 执行报错 (Error):

+

{t('execErrorTitle')}

{sqlError}
)} @@ -319,7 +321,7 @@ export function SQLConsole({ dbType, onClose }: SQLConsoleProps) { colSpan={sqlResult.columns.length} className='text-center py-10 text-muted-foreground' > - 查询结果为空 + {t('queryResultEmpty')} )} @@ -331,11 +333,13 @@ export function SQLConsole({ dbType, onClose }: SQLConsoleProps) { {sqlResult.type === 'exec' && (

- SQL 执行成功 + {t('sqlExecSuccessTitle')}

- 受影响行数: {sqlResult.affected_rows} 行,耗时{' '} - {sqlResult.execution_time_ms} 毫秒。 + {t('affectedRows', { + rows: sqlResult.affected_rows, + ms: sqlResult.execution_time_ms, + })}

)} @@ -346,7 +350,7 @@ export function SQLConsole({ dbType, onClose }: SQLConsoleProps) {
- 编辑器就绪,请在上方编写 SQL 并运行查看结果 + {t('editorReady')}
)} diff --git a/frontend/app/(main)/admin/database/components/table-browser.tsx b/frontend/app/(main)/admin/database/components/table-browser.tsx index fe63a2d9..2c41531f 100644 --- a/frontend/app/(main)/admin/database/components/table-browser.tsx +++ b/frontend/app/(main)/admin/database/components/table-browser.tsx @@ -2,6 +2,7 @@ import * as React from 'react'; import { useCallback, useEffect, useState } from 'react'; +import { useTranslations } from 'next-intl'; import { toast } from 'sonner'; import { Layers, RefreshCw } from 'lucide-react'; @@ -67,6 +68,7 @@ export function TableBrowser({ loadingTables, refreshTrigger, }: TableBrowserProps) { + const t = useTranslations('admin.database.tableBrowser'); const [selectedTable, setSelectedTable] = useState(''); const [tableData, setTableData] = useState(null); const [page, setPage] = useState(1); @@ -86,14 +88,14 @@ export function TableBrowser({ }); setTableData(data); } catch (err) { - toast.error(`获取数据表 ${tableName} 数据失败`, { - description: err instanceof Error ? err.message : '未知错误', + toast.error(t('fetchTableFailed', { table: tableName }), { + description: err instanceof Error ? err.message : t('unknownError'), }); } finally { setLoadingData(false); } }, - [], + [t], ); // 默认选择第一张表 @@ -125,9 +127,11 @@ export function TableBrowser({
- 数据表浏览器 + + {t('tableBrowser')} + - 浏览数据库中的物理数据表详情及内容 + {t('tableBrowserDesc')}
@@ -137,7 +141,7 @@ export function TableBrowser({ ) : ( setFormData((prev) => ({ ...prev, name: e.target.value })) } - placeholder='输入要创建的任务描述...' + placeholder={t('taskNamePlaceholder')} className='h-8 text-xs shadow-none bg-background focus-visible:ring-1' />

- 任务名称将用于后台执行记录的可读性显示 + {t('taskNameHint')}

{/* 下拉框 */}
@@ -107,10 +109,10 @@ export function ControlsTab() { htmlFor='allow-notify' className='text-xs font-medium text-foreground cursor-pointer select-none leading-none' > - 允许触发异步通知推送 + {t('allowNotify')}

- 勾选后,在当前后台任务顺利执行结束时,会关联派发多渠道通知推送。 + {t('allowNotifyDesc')}

@@ -118,32 +120,32 @@ export function ControlsTab() { {/* 按钮变体 */}
- 按钮样式对齐: + {t('buttonStyles')}
@@ -155,13 +157,13 @@ export function ControlsTab() { - 表单设计规范 + {t('formDesignSpec')}

- 1. 统一控件高度与投影: + {t('spec.unifiedHeight')}

输入框 ( @@ -188,7 +190,7 @@ export function ControlsTab() {

- 2. Checkbox 与文本的对齐: + {t('spec.checkboxAlignment')}

Checkbox 应在左侧垂直偏上对齐,使用容器{' '} @@ -203,7 +205,7 @@ export function ControlsTab() {

- 3. 按钮使用策略: + {t('spec.buttonStrategy')}

普通主表单及新增/保存按钮,一律使用默认 Primary 按钮或次要{' '} diff --git a/frontend/app/(main)/admin/demo/tabs/dashboard-tab.tsx b/frontend/app/(main)/admin/demo/tabs/dashboard-tab.tsx index fcc81455..18dc78cf 100644 --- a/frontend/app/(main)/admin/demo/tabs/dashboard-tab.tsx +++ b/frontend/app/(main)/admin/demo/tabs/dashboard-tab.tsx @@ -4,11 +4,13 @@ 'use client'; import * as React from 'react'; +import { useTranslations } from 'next-intl'; import { Code, Cpu, Database, HardDrive } from 'lucide-react'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Progress } from '@/components/ui/progress'; export function DashboardTab() { + const t = useTranslations('admin.demo.dashboard'); return (

@@ -16,7 +18,7 @@ export function DashboardTab() { - 容器 CPU 使用率 + {t('cpuUsage')} @@ -25,7 +27,7 @@ export function DashboardTab() {

- 容器运行正常,负载平稳 + {t('cpuStatus')}

@@ -34,7 +36,7 @@ export function DashboardTab() { - 存储已用容量 + {t('storageUsage')} @@ -45,7 +47,7 @@ export function DashboardTab() {

- 数据已同步远端 S3 存储桶,支持自动过期 + {t('storageStatus')}

@@ -54,7 +56,7 @@ export function DashboardTab() { - 物理数据库 + {t('database')} @@ -67,16 +69,16 @@ export function DashboardTab() {
- 活跃连接:{' '} + {t('activeConnections')}{' '} 12
- 总表数量:{' '} + {t('totalTables')}{' '} 34

- 链接池自动维护中,最大连接限制为 100 + {t('dbStatus')}

@@ -87,45 +89,45 @@ export function DashboardTab() { - 指标卡片 & 仪表盘设计规范 + {t('dashboardDesignSpec')}
  • - 指标卡片统一采用{' '} + {t('spec.dashedBorder')} border-dashed shadow-none {' '} - 作为边框和投影。 + {t('spec.dashedBorder2')}
  • - 头部使用{' '} + {t('spec.headerLayout')} flex flex-row items-center justify-between pb-2 {' '} - 布局,左侧为标题小字,右侧为直接呈现的 Lucide 图标。 + {t('spec.headerLayout2')}
  • - 数值强调使用且仅使用{' '} + {t('spec.valueStyle')} text-2xl font-semibold tracking-tight {' '} - 展示。 + {t('spec.valueStyle2')}
  • - 进度条使用{' '} + {t('spec.progressBar')} h-1.5 {' '} - 细条高度,不喧宾夺主。 + {t('spec.progressBar2')}
  • - 底部的次要说明信息必须统一使用{' '} + {t('spec.secondaryText')} text-[10px] text-muted-foreground {' '} - 等辅助类。 + {t('spec.secondaryText2')}
diff --git a/frontend/app/(main)/admin/demo/tabs/table-tab.tsx b/frontend/app/(main)/admin/demo/tabs/table-tab.tsx
index f5a60c16..29f71b56 100644
--- a/frontend/app/(main)/admin/demo/tabs/table-tab.tsx
+++ b/frontend/app/(main)/admin/demo/tabs/table-tab.tsx
@@ -4,6 +4,7 @@
 'use client';
 
 import * as React from 'react';
+import { useTranslations } from 'next-intl';
 import { Code, Edit, ListFilter, Plus, Search, Trash2 } from 'lucide-react';
 import { Button } from '@/components/ui/button';
 import { Input } from '@/components/ui/input';
@@ -19,27 +20,28 @@ import { Badge } from '@/components/ui/badge';
 import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
 
 export function TableTab() {
+  const t = useTranslations('admin.demo.table');
   const mockTableData = [
     {
       id: 'TX-1002',
-      name: '用户信息批量同步导出',
-      type: '数据同步',
+      name: t('mockData.syncExport'),
+      type: t('mockType.dataSync'),
       status: 'success',
       duration: '1.2s',
       time: '2026-06-15 14:22',
     },
     {
       id: 'TX-1003',
-      name: '系统数据库每周自动备份归档',
-      type: '数据库运维',
+      name: t('mockData.dbBackup'),
+      type: t('mockType.dbOps'),
       status: 'running',
-      duration: '在执行中',
+      duration: t('mockDuration.running'),
       time: '2026-06-15 15:00',
     },
     {
       id: 'TX-1004',
-      name: '远端S3过期缓存对象自动清理任务',
-      type: '磁盘清理',
+      name: t('mockData.s3Cleanup'),
+      type: t('mockType.diskCleanup'),
       status: 'failed',
       duration: '0.4s',
       time: '2026-06-15 10:05',
@@ -54,7 +56,7 @@ export function TableTab() {
           
@@ -64,12 +66,12 @@ export function TableTab() { className='h-8 border-dashed text-xs shadow-none' > - 过滤 + {t('filter')}
@@ -79,25 +81,25 @@ export function TableTab() { - 任务编号 + {t('columns.taskId')} - 执行任务名称 + {t('columns.taskName')} - 任务类型 + {t('columns.taskType')} - 状态 + {t('columns.status')} - 执行耗时 + {t('columns.duration')} - 触发时间 + {t('columns.triggerTime')} - 操作 + {t('columns.actions')} @@ -123,7 +125,7 @@ export function TableTab() { className='text-[10px] bg-emerald-500/10 border-emerald-500/20 text-emerald-600 rounded-full py-0 px-2 font-medium' > - 成功 + {t('status.success')} )} {row.status === 'running' && ( @@ -132,7 +134,7 @@ export function TableTab() { className='text-[10px] bg-blue-500/10 border-blue-500/20 text-blue-600 rounded-full py-0 px-2 font-medium' > - 运行中 + {t('status.running')} )} {row.status === 'failed' && ( @@ -141,7 +143,7 @@ export function TableTab() { className='text-[10px] bg-destructive/10 border-destructive/20 text-destructive rounded-full py-0 px-2 font-medium' > - 失败 + {t('status.failed')} )} @@ -180,13 +182,13 @@ export function TableTab() { - 表格设计规范与代码模板 + {t('tableDesignSpec')}
  • - 表格容器必须统一使用带有虚线边框的容器,即类名: + {t('spec.container')} border border-dashed shadow-none rounded-lg overflow-hidden bg-background @@ -194,40 +196,40 @@ export function TableTab() { 。
  • - 表头背景设为{' '} + {t('spec.headerBg')} bg-muted/40 - 。表头表项字号统一为小字粗体{' '} + {t('spec.headerStyle')} text-xs font-semibold 。
  • - 单元格行与列之间使用虚线分割( + {t('spec.cellDivider')} border-dashed - )。表格行悬浮交互采用温和过渡色{' '} + {t('spec.rowHover')} hover:bg-muted/10 transition-colors 。
  • - 编码、主键或唯一 ID 类字段一律采用等宽字体呈现: + {t('spec.monoFont')} font-mono text-xs 。
  • - 状态标签(Badge)尽量圆角化( + {t('spec.badgeStyle')} rounded-full py-0 px-2 - ),并以浅背景配以各自对应的图标圆点,以实现现代、高对比度的视觉质感。 + {t('spec.badgeStyle2')}
diff --git a/frontend/app/(main)/admin/files/components/file-list.tsx b/frontend/app/(main)/admin/files/components/file-list.tsx
index eea4f8a3..9311be6d 100644
--- a/frontend/app/(main)/admin/files/components/file-list.tsx
+++ b/frontend/app/(main)/admin/files/components/file-list.tsx
@@ -1,6 +1,7 @@
 'use client';
 
 import * as React from 'react';
+import { useTranslations } from 'next-intl';
 import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
 import { AnimatePresence, motion } from 'motion/react';
 import {
@@ -81,6 +82,7 @@ function formatDate(dateStr: string) {
 }
 
 export function FileList() {
+  const t = useTranslations('admin.files');
   const queryClient = useQueryClient();
   const [keyword, setKeyword] = React.useState('');
   const [debouncedKeyword, setDebouncedKeyword] = React.useState('');
@@ -157,10 +159,10 @@ export function FileList() {
     onSuccess: () => {
       void queryClient.invalidateQueries({ queryKey: ['files', 'my'] });
       void queryClient.invalidateQueries({ queryKey: ['files', 'stats'] });
-      toast.success('文件已删除');
+      toast.success(t('list.deleteSuccess'));
       setDeleteTarget(null);
     },
-    onError: (err: Error) => toast.error(err.message || '删除失败'),
+    onError: (err: Error) => toast.error(err.message || t('list.deleteFailed')),
   });
 
   // 批量 ZIP 下载
@@ -173,9 +175,9 @@ export function FileList() {
       a.download = 'batch_download.zip';
       a.click();
       URL.revokeObjectURL(url);
-      toast.success('批量下载已开始');
+      toast.success(t('list.batchDownloadSuccess'));
     },
-    onError: () => toast.error('批量下载失败'),
+    onError: () => toast.error(t('list.batchDownloadFailed')),
   });
 
   const toggleSelect = (id: string) => {
@@ -209,7 +211,7 @@ export function FileList() {
         
setKeyword(e.target.value)} @@ -232,7 +234,9 @@ export function FileList() { selectedIds.size === files.length ? clearSelection : selectAll } > - {selectedIds.size === files.length ? '取消全选' : '全选本页'} + {selectedIds.size === files.length + ? t('list.deselectAll') + : t('list.selectAll')} )} @@ -247,7 +251,7 @@ export function FileList() { className='flex items-center gap-2' > - 已选 {selectedIds.size} 个 + {t('list.selectedCount', { count: selectedIds.size })}
@@ -292,7 +296,7 @@ export function FileList() {

- {debouncedKeyword ? '没有匹配的文件' : '您还没有上传任何文件'} + {debouncedKeyword ? t('list.noMatch') : t('list.noFiles')}

) : ( @@ -309,18 +313,28 @@ export function FileList() { onCheckedChange={handleSelectAll} /> - 预览 - ID - 文件名 - - 路径 + + {t('list.colPreview')} + + ID + {t('list.colName')} + + {t('list.colPath')} + + + {t('list.colType')} + + + {t('list.colMime')} + + + {t('list.colSize')} + + + {t('list.colUploadedAt')} - 业务类别 - MIME类型 - 大小 - 上传时间 - 操作 + {t('list.colActions')} @@ -396,7 +410,7 @@ export function FileList() { size='icon' variant='ghost' className='h-7 w-7 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground' - title='查看详情' + title={t('list.viewDetail')} onClick={() => setDetailTarget(file)} > @@ -405,7 +419,7 @@ export function FileList() { size='icon' variant='ghost' className='h-7 w-7 rounded-md hover:bg-muted' - title='下载' + title={t('list.download')} onClick={() => handleDownload(file)} > @@ -414,7 +428,7 @@ export function FileList() { size='icon' variant='ghost' className='h-7 w-7 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive' - title='删除' + title={t('list.delete')} onClick={() => setDeleteTarget(file)} > @@ -439,7 +453,7 @@ export function FileList() { disabled={page <= 1} onClick={() => setPage((p) => p - 1)} > - 上一页 + {t('list.prevPage')} {page} / {totalPages} @@ -451,7 +465,7 @@ export function FileList() { disabled={page >= totalPages} onClick={() => setPage((p) => p + 1)} > - 下一页 + {t('list.nextPage')}
)} @@ -463,9 +477,9 @@ export function FileList() { > - 文件详情 + {t('list.detailTitle')} - 查看文件的完整元数据与配置属性。 + {t('list.detailDesc')} @@ -485,7 +499,9 @@ export function FileList() {
{getFileIcon(detailTarget.mime_type, 'size-14')} - {detailTarget.extension} 文件 + {t('list.extensionFile', { + ext: detailTarget.extension, + })}
)} @@ -495,7 +511,7 @@ export function FileList() {
- 文件 ID + {t('list.detailId')} {detailTarget.id} @@ -504,7 +520,7 @@ export function FileList() {
- 文件名 + {t('list.detailName')} {detailTarget.file_name} @@ -513,7 +529,7 @@ export function FileList() {
- 存储路径 + {t('list.detailPath')} {detailTarget.file_path} @@ -522,17 +538,18 @@ export function FileList() {
- 大小 + {t('list.detailSize')} {formatFileSize(detailTarget.file_size)} ( - {detailTarget.file_size.toLocaleString()} 字节) + {detailTarget.file_size.toLocaleString()} {t('list.bytes')} + )
- MIME 类型 + {t('list.detailMime')} {detailTarget.mime_type} @@ -541,7 +558,7 @@ export function FileList() {
- 文件后缀 + {t('list.detailExtension')} {detailTarget.extension} @@ -559,7 +576,7 @@ export function FileList() {
- 业务类型 + {t('list.detailBizType')} - 存储驱动 + {t('list.detailDriver')} {storageDriverQuery.data ?? 'local'} @@ -582,7 +599,7 @@ export function FileList() {
- 上传者 ID + {t('list.detailUploaderId')} {detailTarget.user_id} @@ -591,7 +608,7 @@ export function FileList() {
- 上传时间 + {t('list.detailUploadedAt')} {formatDate(detailTarget.created_at)} @@ -602,7 +619,7 @@ export function FileList() { Object.keys(detailTarget.metadata).length > 0 && (
- 额外元数据 + {t('list.detailMetadata')}
                         {JSON.stringify(detailTarget.metadata, null, 2)}
@@ -622,18 +639,16 @@ export function FileList() {
       >
         
           
-            确认删除文件
+            {t('list.deleteTitle')}
             
-              确定要删除文件{' '}
-              
-                「{deleteTarget?.file_name}」
-              {' '}
-              吗?此操作不可撤销。
+              {t('list.deleteConfirm', {
+                name: deleteTarget?.file_name ?? '',
+              })}
             
           
           
             
-              取消
+              {t('list.cancel')}
             
             
@@ -645,7 +660,7 @@ export function FileList() {
               {deleteMutation.isPending && (
                 
               )}
-              确认删除
+              {t('list.confirmDelete')}
             
           
         
diff --git a/frontend/app/(main)/admin/files/components/file-stats.tsx b/frontend/app/(main)/admin/files/components/file-stats.tsx
index 7c51c8b0..a03dc0b1 100644
--- a/frontend/app/(main)/admin/files/components/file-stats.tsx
+++ b/frontend/app/(main)/admin/files/components/file-stats.tsx
@@ -1,6 +1,7 @@
 'use client';
 
 import * as React from 'react';
+import { useTranslations } from 'next-intl';
 import { useQuery } from '@tanstack/react-query';
 import {
   Database,
@@ -49,42 +50,47 @@ const categoryMap: Record = {
   其他: 'others',
 };
 
-const statsChartConfig = {
-  images: {
-    label: '图片',
-    color: 'hsl(var(--chart-1))',
-  },
-  videos: {
-    label: '视频',
-    color: 'hsl(var(--chart-2))',
-  },
-  audio: {
-    label: '音频',
-    color: 'hsl(var(--chart-3))',
-  },
-  documents: {
-    label: '文档',
-    color: 'hsl(var(--chart-4))',
-  },
-  archives: {
-    label: '压缩包',
-    color: 'hsl(var(--chart-5))',
-  },
-  others: {
-    label: '其他',
-    color: 'hsl(var(--chart-6))',
-  },
-  count: {
-    label: '新增文件数',
-    color: 'hsl(var(--primary))',
-  },
-  size: {
-    label: '新增大小',
-    color: 'hsl(var(--chart-2))',
-  },
-} satisfies ChartConfig;
+function useStatsChartConfig() {
+  const t = useTranslations('admin.files');
+  return {
+    images: {
+      label: t('stats.catImages'),
+      color: 'hsl(var(--chart-1))',
+    },
+    videos: {
+      label: t('stats.catVideos'),
+      color: 'hsl(var(--chart-2))',
+    },
+    audio: {
+      label: t('stats.catAudio'),
+      color: 'hsl(var(--chart-3))',
+    },
+    documents: {
+      label: t('stats.catDocuments'),
+      color: 'hsl(var(--chart-4))',
+    },
+    archives: {
+      label: t('stats.catArchives'),
+      color: 'hsl(var(--chart-5))',
+    },
+    others: {
+      label: t('stats.catOthers'),
+      color: 'hsl(var(--chart-6))',
+    },
+    count: {
+      label: t('stats.chartLabelCount'),
+      color: 'hsl(var(--primary))',
+    },
+    size: {
+      label: t('stats.chartLabelSize'),
+      color: 'hsl(var(--chart-2))',
+    },
+  } satisfies ChartConfig;
+}
 
 export function FileStats() {
+  const t = useTranslations('admin.files');
+  const statsChartConfig = useStatsChartConfig();
   const [trendMetric, setTrendMetric] = React.useState<'count' | 'size'>(
     'count',
   );
@@ -137,11 +143,16 @@ export function FileStats() {
 
   const maxStats = React.useMemo(() => {
     if (!stats?.categories || stats.categories.length === 0) {
-      return { maxCountName: '无', maxCount: 0, maxSizeName: '无', maxSize: 0 };
+      return {
+        maxCountName: t('stats.none'),
+        maxCount: 0,
+        maxSizeName: t('stats.none'),
+        maxSize: 0,
+      };
     }
-    let maxCountName = '无';
+    let maxCountName = t('stats.none');
     let maxCount = 0;
-    let maxSizeName = '无';
+    let maxSizeName = t('stats.none');
     let maxSize = 0;
 
     stats.categories.forEach((cat) => {
@@ -156,7 +167,7 @@ export function FileStats() {
     });
 
     return { maxCountName, maxCount, maxSizeName, maxSize };
-  }, [stats?.categories]);
+  }, [stats?.categories, t]);
 
   const trendSummary = React.useMemo(() => {
     if (!stats?.trend) return { count: 0, size: 0 };
@@ -182,9 +193,9 @@ export function FileStats() {
       
-

暂无上传数据

+

{t('stats.noData')}

- 上传文件后即可在此查看容量与文件分布分析。 + {t('stats.noDataDesc')}

@@ -198,7 +209,7 @@ export function FileStats() { - 总文件数 + {t('stats.totalFiles')} @@ -206,11 +217,11 @@ export function FileStats() {
{stats.total_count.toLocaleString()}{' '} - 个 + {t('stats.countUnit')}

- 您上传的可用文件数量汇总 + {t('stats.totalFilesDesc')}

@@ -218,7 +229,7 @@ export function FileStats() { - 占用存储容量 + {t('stats.totalSize')} @@ -227,7 +238,7 @@ export function FileStats() { {formatFileSize(stats.total_size)}

- 文件实际在磁盘/S3中占用的总空间 + {t('stats.totalSizeDesc')}

@@ -235,7 +246,7 @@ export function FileStats() { - 近 7 天新增文件 + {t('stats.weeklyNewFiles')} @@ -243,11 +254,11 @@ export function FileStats() {
+{trendSummary.count.toLocaleString()}{' '} - 个 + {t('stats.countUnit')}

- 最近 7 天上传成功的文件总数 + {t('stats.weeklyNewFilesDesc')}

@@ -255,7 +266,7 @@ export function FileStats() { - 近 7 天新增大小 + {t('stats.weeklyNewSize')} @@ -264,7 +275,7 @@ export function FileStats() { +{formatFileSize(trendSummary.size)}

- 最近 7 天上传所消耗的存储带宽 + {t('stats.weeklyNewSizeDesc')}

@@ -275,10 +286,10 @@ export function FileStats() {
- 最近 7 天新增趋势 + {t('stats.trendTitle')} - 按日统计近一周期内的上传波动 + {t('stats.trendDesc')}
@@ -288,7 +299,7 @@ export function FileStats() { className='h-7 text-xs px-2.5 rounded-md' onClick={() => setTrendMetric('count')} > - 新增数量 (个) + {t('stats.trendCount')}
@@ -361,11 +372,13 @@ export function FileStats() { hideLabel formatter={(value) => { const label = - trendMetric === 'count' ? '新增文件数' : '新增大小'; + trendMetric === 'count' + ? t('stats.chartLabelCount') + : t('stats.chartLabelSize'); const formattedValue = trendMetric === 'size' ? formatFileSize(Number(value)) - : `${value} 个`; + : `${value} ${t('stats.countUnit')}`; return ( <>
@@ -419,16 +432,16 @@ export function FileStats() { - 文件格式数量分布 + {t('stats.countDistTitle')} - 各种格式类型的文件个数占比 + {t('stats.countDistDesc')} {categoryCountData.length === 0 ? (
- 暂无分类数据 + {t('stats.noCategoryData')}
) : (
@@ -475,7 +488,7 @@ export function FileStats() {
- {value} 个 + {value} {t('stats.countUnit')} ); @@ -498,15 +511,10 @@ export function FileStats() {

- 文件类型中数量最多的是{' '} - - 「{maxStats.maxCountName}」 - - ,累计上传了{' '} - - {maxStats.maxCount} - {' '} - 个文件。 + {t('stats.maxCountHint', { + name: maxStats.maxCountName, + count: maxStats.maxCount, + })}

@@ -516,16 +524,16 @@ export function FileStats() { - 文件占用容量分布 + {t('stats.sizeDistTitle')} - 各种格式类型的文件大小占比 + {t('stats.sizeDistDesc')} {categorySizeData.length === 0 ? (
- 暂无容量数据 + {t('stats.noSizeData')}
) : (
@@ -595,15 +603,10 @@ export function FileStats() {

- 文件类型中占用存储最大的是{' '} - - 「{maxStats.maxSizeName}」 - - ,共消耗了{' '} - - {formatFileSize(maxStats.maxSize)} - {' '} - 存储容量。 + {t('stats.maxSizeHint', { + name: maxStats.maxSizeName, + size: formatFileSize(maxStats.maxSize), + })}

diff --git a/frontend/app/(main)/admin/files/components/storage-config-tab.tsx b/frontend/app/(main)/admin/files/components/storage-config-tab.tsx index 3f2b68d7..11ae498f 100644 --- a/frontend/app/(main)/admin/files/components/storage-config-tab.tsx +++ b/frontend/app/(main)/admin/files/components/storage-config-tab.tsx @@ -1,6 +1,7 @@ 'use client'; import * as React from 'react'; +import { useTranslations } from 'next-intl'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { Database, Loader2, Play, Save } from 'lucide-react'; import { toast } from 'sonner'; @@ -44,13 +45,13 @@ import type { const storageConfigKey = 'storage_config'; const storageMigrationTaskType = 'storage_migration'; -const driverLabels: Record = { - local: '本地文件系统', - s3: 'AWS S3', - r2: 'Cloudflare R2', - minio: 'MinIO', - oss: '阿里云 OSS', - webdav: 'WebDAV', +const driverLabelKeys: Record = { + local: 'driverLocal', + s3: 'driverS3', + r2: 'driverR2', + minio: 'driverMinio', + oss: 'driverOss', + webdav: 'driverWebdav', }; const emptyObjectConfig: ObjectStorageConfig = { @@ -112,8 +113,8 @@ function parseMigrationPayload(payload: string): StorageConfig | undefined { } } -function driverLabel(driver?: StorageDriver): string { - return driver ? driverLabels[driver] : '未知'; +function driverLabelKey(driver?: StorageDriver): string { + return driver ? driverLabelKeys[driver] : 'driverUnknown'; } function latestMigration( @@ -136,6 +137,7 @@ function latestMigration( } export function StorageConfigTab() { + const t = useTranslations('admin.files'); const queryClient = useQueryClient(); const [config, setConfig] = React.useState(null); const query = useQuery({ @@ -186,13 +188,13 @@ export function StorageConfigTab() { }); }, onSuccess: () => { - toast.success('存储配置已保存'); + toast.success(t('storage.saveSuccess')); void queryClient.invalidateQueries({ queryKey: ['admin', 'storage-config'], }); }, onError: (error: Error) => { - toast.error(error.message || '保存存储配置失败'); + toast.error(error.message || t('storage.saveFailed')); void queryClient.invalidateQueries({ queryKey: ['admin', 'storage-config'], }); @@ -208,13 +210,13 @@ export function StorageConfigTab() { }); }, onSuccess: () => { - toast.success('存储迁移任务已下发'); + toast.success(t('storage.migrateSuccess')); void queryClient.invalidateQueries({ queryKey: ['admin', 'storage-config'], }); }, onError: (error: Error) => { - toast.error(error.message || '下发存储迁移任务失败'); + toast.error(error.message || t('storage.migrateFailed')); void queryClient.invalidateQueries({ queryKey: ['admin', 'storage-config'], }); @@ -224,12 +226,13 @@ export function StorageConfigTab() { mutationFn: (executionID: string) => services.adminTask.retryTaskExecution(executionID), onSuccess: () => { - toast.success('存储迁移任务已重新下发'); + toast.success(t('storage.retrySuccess')); void queryClient.invalidateQueries({ queryKey: ['admin', 'storage-config'], }); }, - onError: (error: Error) => toast.error(error.message || '运行存储迁移失败'), + onError: (error: Error) => + toast.error(error.message || t('storage.retryFailed')), }); if (query.isPending || !config) { @@ -269,7 +272,7 @@ export function StorageConfigTab() { - 存储维护模式 + {t('storage.maintenanceMode')} - {driverLabel(migration.source_driver)} →{' '} - {driverLabel(migration.target_driver)} - 。迁移期间文件只允许读取,禁止上传、删除和清理。 + {t('storage.maintenanceDesc', { + source: t(`storage.${driverLabelKey(migration.source_driver)}`), + target: t(`storage.${driverLabelKey(migration.target_driver)}`), + })} @@ -289,7 +293,9 @@ export function StorageConfigTab() { value={migration.state === 'succeeded' ? 100 : undefined} />

- 迁移进度请查看任务执行日志:{migration.task_id || '尚未下发'} + {t('storage.migrationProgress', { + taskId: migration.task_id || t('storage.notDispatched'), + })}

{migration.error && (

{migration.error}

@@ -310,7 +316,7 @@ export function StorageConfigTab() { ) : ( )} - 重试迁移 + {t('storage.retryMigration')}
@@ -324,10 +330,10 @@ export function StorageConfigTab() {
- 文件存储 + {t('storage.fileStorage')} - 默认使用本地存储。配置系统文件的存储媒介,切换存储类型且已有文件时,系统会自动进入维护模式并迁移文件。 + {t('storage.fileStorageDesc')}
@@ -337,7 +343,7 @@ export function StorageConfigTab() {
- 存储类型 + {t('storage.storageType')} setUsernameFilter(e.target.value)} className='h-8 text-xs' @@ -239,11 +239,11 @@ export function AccessLogs() {
setPathFilter(e.target.value)} className='h-8 text-xs' @@ -251,7 +251,7 @@ export function AccessLogs() {
@@ -295,26 +295,27 @@ export function AccessLogs() { {loading && logs.length === 0 ? ( ) : logs.length === 0 ? ( - + ) : (
- 请求方法 - 路径 - 用户 - IP - 状态 - 耗时 - 请求时间 - 详情 + {t('colMethod')} + {t('colPath')} + {t('colUser')} + {t('colIp')} + {t('colStatus')} + {t('colLatency')} + + {t('colRequestTime')} + + + {t('colDetail')} + @@ -334,7 +335,7 @@ export function AccessLogs() {
- {log.username || '未知用户'} + {log.username || t('unknownUser')} {log.nickname && ( @@ -381,7 +382,7 @@ export function AccessLogs() { {logs.length > 0 && (
- 共 {total} 条记录,当前第 {page}/{totalPages} 页 + {t('paginationInfo', { total, page, totalPages })}
@@ -412,9 +413,11 @@ export function AccessLogs() { - 访问日志详情 + + {t('accessLogDetail')} + - 访问 ID: {selectedLog?.id} + {t('accessId', { id: selectedLog?.id ?? '' })} @@ -424,7 +427,7 @@ export function AccessLogs() {
- 请求方法 + {t('method')}
{selectedLog.method} @@ -432,7 +435,7 @@ export function AccessLogs() {
- 响应状态 + {t('responseStatus')}
@@ -442,7 +445,7 @@ export function AccessLogs() {
- 耗时 + {t('latency')}
{formatLatency(selectedLog.latency)} @@ -450,18 +453,18 @@ export function AccessLogs() {
- IP 地址 + {t('ipAddress')}
{selectedLog.ip}
- 用户 + {t('user')}
{selectedLog.username - ? `${selectedLog.username} (${selectedLog.nickname || '无昵称'})` - : '未知/游客'} + ? `${selectedLog.username} (${selectedLog.nickname || t('noNickname')})` + : t('unknownGuest')} ID: {selectedLog.user_id} @@ -472,7 +475,7 @@ export function AccessLogs() { {/* Path */}
{selectedLog.path} @@ -482,7 +485,7 @@ export function AccessLogs() { {/* Request Time */}
{formatDateTime(selectedLog.created_at)} @@ -502,7 +505,7 @@ export function AccessLogs() { onClick={() => copyToClipboard(selectedLog.user_agent, 'User-Agent') } - title='复制 User-Agent' + title={t('copyUserAgent')} > @@ -528,7 +531,7 @@ export function AccessLogs() { 'Headers', ) } - title='复制 Headers' + title={t('copyHeaders')} > @@ -542,7 +545,7 @@ export function AccessLogs() {
)} diff --git a/frontend/app/(main)/admin/logs/components/app-logs.tsx b/frontend/app/(main)/admin/logs/components/app-logs.tsx index bd51880a..206dbecb 100644 --- a/frontend/app/(main)/admin/logs/components/app-logs.tsx +++ b/frontend/app/(main)/admin/logs/components/app-logs.tsx @@ -22,6 +22,7 @@ import { toast } from 'sonner'; import { ArrowDown, ChevronUp, Loader2 } from 'lucide-react'; import services from '@/lib/services'; +import { useTranslations } from 'next-intl'; import { ErrorInline } from '@/components/layout/error'; import { LoadingStateWithBorder } from '@/components/layout/loading'; import { Button } from '@/components/ui/button'; @@ -92,6 +93,7 @@ function parseLogLevel( } export function AppLogs() { + const t = useTranslations('admin.logs.appLogs'); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -176,9 +178,9 @@ export function AppLogs() { try { return await services.adminLog.getLogs(cursor); } catch (err) { - throw err instanceof Error ? err : new Error('获取日志失败'); + throw err instanceof Error ? err : new Error(t('fetchFailed')); } - }, []); + }, [t]); const loadHistory = useCallback( async (cursor: number = 0) => { @@ -209,16 +211,16 @@ export function AppLogs() { setNextCursor(data.next_cursor); } catch (err) { if (isInitial) { - setError(err instanceof Error ? err : new Error('获取日志失败')); + setError(err instanceof Error ? err : new Error(t('fetchFailed'))); } else { - toast.error('加载更早日志失败'); + toast.error(t('loadOlderFailed')); } } finally { if (isInitial) setLoading(false); else setLoadingMore(false); } }, - [fetchLogs], + [fetchLogs, t], ); // ---- WebSocket ------------------------------------------------------- @@ -300,12 +302,12 @@ export function AppLogs() { {loadingMore ? ( <> - 加载中... + {t('loading')} ) : ( <> - 加载更早日志 + {t('loadOlderLogs')} )} @@ -313,7 +315,9 @@ export function AppLogs() { )} {logs.length === 0 ? ( -
暂无日志
+
+ {t('noLogs')} +
) : (
- 回到最新 + {t('backToLatest')}
)} diff --git a/frontend/app/(main)/admin/logs/page-client.tsx b/frontend/app/(main)/admin/logs/page-client.tsx index 3f6a5a8f..5e65ffee 100644 --- a/frontend/app/(main)/admin/logs/page-client.tsx +++ b/frontend/app/(main)/admin/logs/page-client.tsx @@ -2,6 +2,7 @@ import dynamic from 'next/dynamic'; import { Activity, BarChart3, Terminal } from 'lucide-react'; +import { useTranslations } from 'next-intl'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; @@ -26,13 +27,16 @@ const AppLogs = dynamic( ); export function LogsPageClient() { + const t = useTranslations('admin.logs'); return (
{/* Header */}
-

系统日志

+

+ {t('pageTitle')} +

{/* Tabs Layout */} @@ -43,21 +47,21 @@ export function LogsPageClient() { className='px-0 pb-2 text-xs font-semibold flex items-center gap-1.5' > - 访问分析 + {t('accessAnalytics')} - 用户访问日志 + {t('userAccessLogs')} - 应用运行日志 + {t('appRuntimeLogs')} diff --git a/frontend/app/(main)/admin/push/components/events-tab.tsx b/frontend/app/(main)/admin/push/components/events-tab.tsx index a1b22cef..36ca8f84 100644 --- a/frontend/app/(main)/admin/push/components/events-tab.tsx +++ b/frontend/app/(main)/admin/push/components/events-tab.tsx @@ -4,6 +4,7 @@ 'use client'; import * as React from 'react'; +import { useTranslations } from 'next-intl'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; import { @@ -80,6 +81,7 @@ import type { import { PushService } from '@/lib/services/push'; export function EventsTab() { + const t = useTranslations('admin.push.events'); const queryClient = useQueryClient(); const [deleteTarget, setDeleteTarget] = React.useState( null, @@ -120,12 +122,12 @@ export function EventsTab() { mutationFn: ({ id, data }: { id: number; data: UpdatePushEventRequest }) => PushService.updateEvent(id, data), onSuccess: () => { - toast.success('事件更新成功'); + toast.success(t('eventUpdateSuccess')); queryClient.invalidateQueries({ queryKey: ['admin', 'push-events'] }); setEditEventOpen(false); }, onError: (err: unknown) => { - toast.error('事件更新失败: ' + (err as Error).message); + toast.error(t('eventUpdateFailed') + ': ' + (err as Error).message); }, }); @@ -135,14 +137,14 @@ export function EventsTab() { queryClient.invalidateQueries({ queryKey: ['admin', 'push-events'] }); }, onError: (err: unknown) => { - toast.error('操作失败: ' + (err as Error).message); + toast.error(t('operationFailed') + ': ' + (err as Error).message); }, }); const createEventMutation = useMutation({ mutationFn: (data: CreatePushEventRequest) => PushService.createEvent(data), onSuccess: () => { - toast.success('事件创建成功'); + toast.success(t('eventCreateSuccess')); queryClient.invalidateQueries({ queryKey: ['admin', 'push-events'] }); setCreateEventOpen(false); setNewEventKey(''); @@ -152,7 +154,7 @@ export function EventsTab() { setNewEventEnabled(true); }, onError: (err: unknown) => { - toast.error('事件创建失败: ' + (err as Error).message); + toast.error(t('eventCreateFailed') + ': ' + (err as Error).message); }, }); @@ -160,11 +162,11 @@ export function EventsTab() { mutationFn: (id: number) => PushService.deleteEvent(id), onSuccess: () => { setDeleteTarget(null); - toast.success('配置删除成功'); + toast.success(t('configDeleteSuccess')); queryClient.invalidateQueries({ queryKey: ['admin', 'push-events'] }); }, onError: (err: unknown) => { - toast.error('配置删除失败: ' + (err as Error).message); + toast.error(t('configDeleteFailed') + ': ' + (err as Error).message); }, }); @@ -212,7 +214,7 @@ export function EventsTab() { try { JSON.parse(eventTemplate); } catch { - toast.error('消息模板不是合法的 JSON 格式'); + toast.error(t('templateInvalidJson')); return; } @@ -260,8 +262,8 @@ export function EventsTab() { ); if (taskMeta) { const defaultTemplate = { - title: `任务完成: ${taskMeta.name}`, - content: `异步任务 {{task_name}} 已完成。状态: {{task_status}},耗时: {{task_duration}} ms。`, + title: t('taskCompleteTitle', { name: taskMeta.name }), + content: t('taskCompleteContent'), level: 'INFO', }; setNewEventTemplate(JSON.stringify(defaultTemplate, null, 2)); @@ -272,11 +274,11 @@ export function EventsTab() { const handleCreateEvent = () => { if (newEventType === 'builtin' && !newEventKey) { - toast.error('请选择系统事件'); + toast.error(t('selectSystemEvent')); return; } if (newEventType === 'task' && !newEventTaskType) { - toast.error('请选择异步任务'); + toast.error(t('selectAsyncTask')); return; } @@ -284,7 +286,7 @@ export function EventsTab() { try { JSON.parse(newEventTemplate); } catch { - toast.error('内容渲染模板不是合法的 JSON 格式'); + toast.error(t('templateInvalidJson')); return; } } @@ -309,11 +311,11 @@ export function EventsTab() {
{eventsQuery.isLoading ? ( - + ) : eventsQuery.isError ? (
) : (
@@ -336,19 +338,19 @@ export function EventsTab() { ID - 通知事件 + {t('colEvent')} - 关联渠道 + {t('colChannels')} - 推送目标 + {t('colTargets')} - 状态 + {t('colStatus')} - 操作 + {t('colActions')} @@ -376,7 +378,7 @@ export function EventsTab() { variant='outline' className='text-[8px] h-3.5 px-1 bg-blue-50/50 text-blue-600 border-blue-200' > - 任务 + {t('taskBadge')} )}
@@ -389,7 +391,7 @@ export function EventsTab() {
{(event.channels ?? []).length === 0 ? ( - 未指定渠道 + {t('noChannelSpecified')} ) : ( event.channels.map((ch) => ( @@ -398,7 +400,7 @@ export function EventsTab() { variant='secondary' className='text-[10px] py-0 px-1.5 h-4.5' > - {ch === 'email' ? '邮件' : ch} + {ch === 'email' ? t('emailChannel') : ch} )) )} @@ -453,7 +455,7 @@ export function EventsTab() { - 配置 + {t('configure')} @@ -472,7 +474,7 @@ export function EventsTab() { - 删除 + {t('delete')} @@ -489,15 +491,15 @@ export function EventsTab() { - 新增通知事件 + {t('addEventDialogTitle')} - 选择系统内置的事件并绑定推送渠道,创建后即可开始生效 + {t('addEventDialogDesc')}
- +
{newEventType === 'builtin' ? (
- + {builtInEventsQuery.isLoading ? (
- 加载系统事件中... + {t('loadingSystemEvents')}
) : availableBuiltInEvents.length === 0 ? (
- 所有内置事件都已配置,没有可新增的事件。 + {t('allBuiltinEventsConfigured')}
) : ( - + {(taskTypesQuery.data ?? []).map((taskMeta) => ( @@ -603,11 +611,11 @@ export function EventsTab() { {newEventType === 'builtin' && newEventKey && (
- 事件说明: + {t('eventDescription')} {availableBuiltInEvents.find((e) => e.key === newEventKey) - ?.description || '无描述信息'} + ?.description || t('noDescription')}
)} @@ -615,18 +623,20 @@ export function EventsTab() { {newEventType === 'task' && newEventTaskType && (
- 任务说明: + {t('taskDescription')} {(taskTypesQuery.data ?? []).find( (t) => t.asynq_task === newEventTaskType, - )?.description || '无描述信息'} + )?.description || t('noDescription')}
)}
- + @@ -666,12 +676,12 @@ export function EventsTab() { } }} /> - {ch === 'email' ? '邮件推送' : ch} + {ch === 'email' ? t('emailPush') : ch} ))} {availableChannels.length === 0 && (
- 暂无可用渠道,请先在“通道管理与设置”中创建或启用。 + {t('noAvailableChannels')}
)}
@@ -681,11 +691,11 @@ export function EventsTab() {
setNewEventTargets(e.target.value)} className='text-xs h-9' @@ -695,12 +705,12 @@ export function EventsTab() {
{newEventType === 'task' - ? '支持变量:{{task_name}}, {{task_status}}, {{task_duration}}, {{user.username}}' - : '支持变量:{{user.username}}, {{ip}}, {{time}}'} + ? t('taskTemplateVars') + : t('eventTemplateVars')}