release: bump version to 0.73.0
Some checks failed
ci / backend (push) Has been cancelled
ci / frontend (push) Has been cancelled
ci / delivery (push) Has been cancelled
release / images (push) Has been cancelled

This commit is contained in:
linkong
2026-06-29 17:04:05 +08:00
parent 19d5ac0fee
commit fbecf30513
41 changed files with 2306 additions and 311 deletions

View File

@@ -81,6 +81,10 @@ the user's login interactive shell instead of assuming a specific dotfile.
rendered smoke evidence for public pages, auth guards, authenticated admin rendered smoke evidence for public pages, auth guards, authenticated admin
route/section availability, safe navigation/search/tab interactions, mobile route/section availability, safe navigation/search/tab interactions, mobile
layout, and 125% / 150% zoom, not only a build. layout, and 125% / 150% zoom, not only a build.
- Admin or Docs layout changes must load `rules.md` `uiux` and preserve the
one-screen (`一屏` / `首屏`) height chain: route roots use `height: 100%`,
intermediate wrappers keep `min-height: 0`, and only the intended child owns
scrolling.
- `aiprovider` is a protocol/provider adapter; keep business prompts and product - `aiprovider` is a protocol/provider adapter; keep business prompts and product
workflows in the backend. workflows in the backend.
- Earth rendering depends on layer order, depth behavior, picking, and - Earth rendering depends on layer order, depth behavior, picking, and

View File

@@ -1 +1 @@
0.72.0 0.73.0

View File

@@ -8,6 +8,24 @@ This project follows the repository versioning rule:
- `improvement` -> `+0.0.1`bugfix + 小功能混合) - `improvement` -> `+0.0.1`bugfix + 小功能混合)
- `bugfix` -> `+0.0.1` - `bugfix` -> `+0.0.1`
## [0.73.0] — 2026-06-29
Released: 2026-06-29
### Highlights
- 新增前端统一 i18n 基础设施让认证页、Docs UI、控制台外壳、导航、搜索和核心共享组件共用 `zh-CN` / `en-US` 语言状态。
- 控制台侧边栏偏好面板接入语言与主题切换,并修复一屏高度链、账号区、状态指示器和英文态文案裁切问题。
- 扩展 harness 与 smoke 覆盖,确保 admin shell 高度、移动/缩放布局、语言切换、搜索、Docs 和核心控制台交互在发布前被验证。
### Added / Fixed / Improved
- 新增 `frontend/src/i18n/`,用 `i18next` / `react-i18next` 维护资源、locale 映射、Docs 兼容和过渡期 legacy UI 翻译桥。
- 将 AdminLayout、route manifest、admin search、Auth、DataTable、Dialog、Toast、MarkdownRenderer 和 Users 页迁移到统一翻译资源。
- 补齐 Planet Content、Collected Data、System Logs、Datasources、Settings 和 Collection Management 等英文态残留翻译,并覆盖动态计数字符串。
- 改进控制台侧边栏账号区、语言 switch、状态 pill 自适应宽度和 admin shell overflow ownership避免首屏溢出和状态词裁切。
- 更新 i18n 计划、控制台前端上下文、harness 文档和规则,记录语言迁移边界、状态指示器布局约束和一屏验证要求。
---
## [0.72.0] — 2026-06-29 ## [0.72.0] — 2026-06-29
Released: 2026-06-29 Released: 2026-06-29

View File

@@ -24,6 +24,11 @@ static checks, Playwright smoke coverage, and remaining manual review areas, so
an agent can distinguish a proved harness pass from a rule that still needs an agent can distinguish a proved harness pass from a rule that still needs
human-quality inspection. human-quality inspection.
When the user describes work with product words rather than module names, use
the `rules.md` **Agent Discovery Index** before deciding which modules to load.
It maps Chinese phrases such as `一屏`, `高度没控住`, `文档`, `数据源`,
`地球`, `模型供应商`, and `发版` to the required rule modules.
## Starting Work ## Starting Work
Recommended startup flow: Recommended startup flow:
@@ -103,7 +108,12 @@ and authenticated `super_admin` rendering for every admin route plus core
Authenticated admin Authenticated admin
checks run at desktop size, mobile size, and 125% / 150% zoom; desktop and checks run at desktop size, mobile size, and 125% / 150% zoom; desktop and
mobile passes also fail on global horizontal overflow so table/detail panels mobile passes also fail on global horizontal overflow so table/detail panels
must keep overflow ownership inside their own scroll regions. The smoke also must keep overflow ownership inside their own scroll regions. To enforce the
existing `rules.md` `uiux` one-screen workspace rule, admin shell pages have a
hard rendered check: the shell must resolve to the viewport height through the
root 100% height chain, `#root`/document/body must not gain vertical overflow,
and the desktop sidebar account/preferences area must remain inside the first
viewport while the nav owns any excess scrolling. The smoke also
derives the sidebar menu from the actual admin route manifest and clicks every derives the sidebar menu from the actual admin route manifest and clicks every
visible `super_admin` menu entry on both desktop and mobile viewports, then visible `super_admin` menu entry on both desktop and mobile viewports, then
exercises safe interaction paths for admin search, section tabs, the AI settings exercises safe interaction paths for admin search, section tabs, the AI settings

View File

@@ -120,6 +120,10 @@ command fails when the rule regresses. "Smoke" means the rendered product route
or interaction is opened with Playwright. "Manual" means the rule is still a or interaction is opened with Playwright. "Manual" means the rule is still a
judgment call and must be inspected during review. judgment call and must be inspected during review.
Before using this matrix, start from `rules.md`'s **Agent Discovery Index** when
the user describes work with Chinese/product terms instead of module names. The
index is the routing layer; this table is the coverage/evidence layer.
| `rules.md` Area | Rule Surface | Harness Evidence | Remaining Review | | `rules.md` Area | Rule Surface | Harness Evidence | Remaining Review |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `core` | Remove stale transitional paths, duplicated helpers, and naming drift after large changes. | `scripts/harness/docs-consistency-check.sh` blocks known stale stack terms, old `?tab=` links, public Docs metadata drift, and README/project context drift. `scripts/harness/frontend-rules-check.sh` blocks repeated detached AI/WebSearch connection-test buttons by requiring `ConnectionTestInput`. | Naming quality, function size, and whether a new abstraction is worth keeping remain manual review items. | | `core` | Remove stale transitional paths, duplicated helpers, and naming drift after large changes. | `scripts/harness/docs-consistency-check.sh` blocks known stale stack terms, old `?tab=` links, public Docs metadata drift, and README/project context drift. `scripts/harness/frontend-rules-check.sh` blocks repeated detached AI/WebSearch connection-test buttons by requiring `ConnectionTestInput`. | Naming quality, function size, and whether a new abstraction is worth keeping remain manual review items. |
@@ -129,7 +133,7 @@ judgment call and must be inspected during review.
| `workflow` | Agents should find `bun` and `uv` even when non-interactive `PATH` is incomplete. | `scripts/harness/lib.sh` checks the current `PATH`, then asks `$SHELL`, `zsh`, and `bash` login interactive shells for the command path without hardcoding a dotfile. `doctor.sh`, `quick-check.sh`, and `validate.sh` all source it. | System package installation remains outside harness scope and should be reported instead of auto-fixed. | | `workflow` | Agents should find `bun` and `uv` even when non-interactive `PATH` is incomplete. | `scripts/harness/lib.sh` checks the current `PATH`, then asks `$SHELL`, `zsh`, and `bash` login interactive shells for the command path without hardcoding a dotfile. `doctor.sh`, `quick-check.sh`, and `validate.sh` all source it. | System package installation remains outside harness scope and should be reported instead of auto-fixed. |
| `docs` | Keep public Docs whitelist-driven and synchronized with backend authorization metadata. | `docs-consistency-check.sh` compares frontend Docs metadata against backend Gatekeeper metadata, verifies files exist for both languages, checks public link titles, and blocks missing zh/en technical doc pairs. `frontend-smoke.mjs` opens every Chinese Docs catalog slug plus detail/search/language/theme interactions. | Quality of prose, examples, and whether a doc should be public are still editorial review items. | | `docs` | Keep public Docs whitelist-driven and synchronized with backend authorization metadata. | `docs-consistency-check.sh` compares frontend Docs metadata against backend Gatekeeper metadata, verifies files exist for both languages, checks public link titles, and blocks missing zh/en technical doc pairs. `frontend-smoke.mjs` opens every Chinese Docs catalog slug plus detail/search/language/theme interactions. | Quality of prose, examples, and whether a doc should be public are still editorial review items. |
| `docs` | User manuals must match real console routes and deep links. | `docs-consistency-check.sh` compares manual console tables with `frontend/src/admin/routes/manifest.tsx` and validates documented `?section=` links from actual `AdminRoutes.tsx` plus `PlainResourcePages.tsx` section config. | Screenshots and UI-copy nuance are not exhaustively validated. | | `docs` | User manuals must match real console routes and deep links. | `docs-consistency-check.sh` compares manual console tables with `frontend/src/admin/routes/manifest.tsx` and validates documented `?section=` links from actual `AdminRoutes.tsx` plus `PlainResourcePages.tsx` section config. | Screenshots and UI-copy nuance are not exhaustively validated. |
| `uiux` | Admin pages are compact single-screen workspaces with explicit overflow ownership. | `frontend-rules-check.sh` warns on suspicious `overflow: hidden`, blocks exact `100vh` / `100vw` shell sizing in admin/Docs CSS, and `frontend-smoke.mjs` checks every admin route at desktop, mobile, and 125% / 150% zoom. Desktop/mobile smoke also fails global horizontal overflow. | Visual density, hierarchy, and whether a scroll owner feels ergonomic remain manual QA. | | `uiux` | Admin pages are compact single-screen workspaces with explicit overflow ownership. | `frontend-rules-check.sh` fails missing admin shell height-chain declarations (`.admin-theme-root`, `.admin`, `.admin__sider`, `.admin__nav-scroll`, `.admin__account`, `.admin__content`, `.admin__content-inner`), warns on suspicious `overflow: hidden`, and blocks exact `100vh` / `100vw` shell sizing in admin/Docs CSS. `frontend-smoke.mjs` checks every admin route at desktop/mobile and verifies `.admin` equals viewport height, `#root`/document/body have no vertical overflow, and desktop sidebar account/preferences stay in the first viewport. Zoom passes still cover 125% / 150% rendering. | Visual density, hierarchy, and whether a scroll owner feels ergonomic remain manual QA. |
| `uiux` | Controls use expected patterns and accessible icon buttons. | `frontend-rules-check.sh` blocks icon `Button` without `aria-label` and `title`, native `<button>` without explicit `type`, nested Cards, AntD imports, `<Space>`, and detached connection-test buttons. Smoke exercises search, tabs, dialogs, data toggles, and connection-test actions. | Native buttons with visible text are not treated as icon-only by static checks; semantics still need review when adding custom controls. | | `uiux` | Controls use expected patterns and accessible icon buttons. | `frontend-rules-check.sh` blocks icon `Button` without `aria-label` and `title`, native `<button>` without explicit `type`, nested Cards, AntD imports, `<Space>`, and detached connection-test buttons. Smoke exercises search, tabs, dialogs, data toggles, and connection-test actions. | Native buttons with visible text are not treated as icon-only by static checks; semantics still need review when adding custom controls. |
| `uiux` | Text should fit, avoid viewport-scaled font sizes, and keep letter spacing at zero. | `frontend-rules-check.sh` fails viewport/container-width font-size units and non-zero `letter-spacing` / `letterSpacing`. `frontend-smoke.mjs` checks rendered routes for global overflow across desktop/mobile. | Per-element text clipping without page-level overflow is not exhaustively detected and needs visual review for changed screens. | | `uiux` | Text should fit, avoid viewport-scaled font sizes, and keep letter spacing at zero. | `frontend-rules-check.sh` fails viewport/container-width font-size units and non-zero `letter-spacing` / `letterSpacing`. `frontend-smoke.mjs` checks rendered routes for global overflow across desktop/mobile. | Per-element text clipping without page-level overflow is not exhaustively detected and needs visual review for changed screens. |
| `frontend` | Keep shared behavior in reusable components and existing project patterns. | `frontend-rules-check.sh` enforces shared `ConnectionTestInput`, route/link/search consistency, no debug output, native button safety, Tactile/Radix/lucide direction instead of AntD/Space, and whitelist-driven public Docs. `bun x tsc --noEmit` and `bun run build` verify TypeScript/build health. | Broad casts, inline styles, and overflow issues are warnings when context may be legitimate; review changed lines before accepting them. | | `frontend` | Keep shared behavior in reusable components and existing project patterns. | `frontend-rules-check.sh` enforces shared `ConnectionTestInput`, route/link/search consistency, no debug output, native button safety, Tactile/Radix/lucide direction instead of AntD/Space, and whitelist-driven public Docs. `bun x tsc --noEmit` and `bun run build` verify TypeScript/build health. | Broad casts, inline styles, and overflow issues are warnings when context may be legitimate; review changed lines before accepting them. |
@@ -147,8 +151,8 @@ judgment call and must be inspected during review.
| `scripts/harness/doctor.sh` | Environment and repository-shape check. | | `scripts/harness/doctor.sh` | Environment and repository-shape check. |
| `scripts/harness/security-check.sh` | High-confidence secret and tracked environment/key file check. | | `scripts/harness/security-check.sh` | High-confidence secret and tracked environment/key file check. |
| `scripts/harness/backend-rules-check.sh` | Backend app debug-call guard for direct stdout/debugger usage. | | `scripts/harness/backend-rules-check.sh` | Backend app debug-call guard for direct stdout/debugger usage. |
| `scripts/harness/frontend-rules-check.sh` | Bun-only, route manifest, literal internal link, admin-search route target, debug-output, native-button safety, icon-button accessibility, Card nesting, AntD/Space avoidance, ConnectionTestInput, admin/docs shell viewport sizing, viewport-font, zero-letter-spacing, and UI rules static check. | | `scripts/harness/frontend-rules-check.sh` | Bun-only, route manifest, literal internal link, admin-search route target, debug-output, native-button safety, icon-button accessibility, Card nesting, AntD/Space avoidance, ConnectionTestInput, admin shell one-screen height-chain declarations, admin/docs shell viewport sizing, viewport-font, zero-letter-spacing, and UI rules static check. |
| `scripts/harness/docs-consistency-check.sh` | Frontend/backend Docs metadata alignment, public Docs metadata, full technical-doc bilingual pair, link-title, language-scoped technical link, supported credential collector contracts, manual console route coverage, documented route, admin-config-derived section deep-link consistency, and harness rules-coverage note check. | | `scripts/harness/docs-consistency-check.sh` | Frontend/backend Docs metadata alignment, public Docs metadata, full technical-doc bilingual pair, link-title, language-scoped technical link, supported credential collector contracts, manual console route coverage, documented route, admin-config-derived section deep-link consistency, and harness rules-coverage note check. |
| `scripts/harness/frontend-smoke.mjs` | Playwright route, Docs detail/language/theme/search interaction, public auth form interaction, desktop/mobile/zoom rendering, safe admin navigation/search/tab/dialog/Earth News interactions, and authenticated admin route/section smoke for the built frontend preview. | | `scripts/harness/frontend-smoke.mjs` | Playwright route, Docs detail/language/theme/search interaction, public auth form interaction, desktop/mobile/zoom rendering, admin shell one-screen/overflow checks, safe admin navigation/search/tab/dialog/Earth News interactions, and authenticated admin route/section smoke for the built frontend preview. |
| `scripts/harness/quick-check.sh` | Fast deterministic local validation. | | `scripts/harness/quick-check.sh` | Fast deterministic local validation. |
| `scripts/harness/validate.sh` | Full local validation wrapper with optional delivery smoke. | | `scripts/harness/validate.sh` | Full local validation wrapper with optional delivery smoke. |

View File

@@ -22,6 +22,7 @@
当前重点入口: 当前重点入口:
- [控制台 i18n 接入计划](/home/ray/dev/linkong/planet/docs/plans/admin-console-i18n-plan.md)
- [Earth Mobile Drawer UI Plan](/home/ray/dev/linkong/planet/docs/plans/earth-mobile-drawer-ui-plan.md) - [Earth Mobile Drawer UI Plan](/home/ray/dev/linkong/planet/docs/plans/earth-mobile-drawer-ui-plan.md)
- [Earth Compute Center BGP Style Plan](/home/ray/dev/linkong/planet/docs/plans/earth-compute-center-bgp-style-plan.md) - [Earth Compute Center BGP Style Plan](/home/ray/dev/linkong/planet/docs/plans/earth-compute-center-bgp-style-plan.md)
- [Earth Renderer Architecture Separation Plan](/home/ray/dev/linkong/planet/docs/plans/earth-renderer-architecture-separation-plan.md) - [Earth Renderer Architecture Separation Plan](/home/ray/dev/linkong/planet/docs/plans/earth-renderer-architecture-separation-plan.md)

View File

@@ -0,0 +1,62 @@
# 控制台 i18n 接入计划
**状态**:基础设施已落地,大型业务页迁移继续进行
**创建日期**2026-06-29
**核心目标**:把 Docs 已有的中英文文档能力提升为前端统一 i18n 体系让未登录认证页、Docs UI、控制台外壳、导航、搜索和核心工作台文案共用同一个语言状态。
## 背景
Docs 站点已经有 `zh` / `en` 文档目录、Gatekeeper 权限和 `/api/v1/docs/{lang}/{slug}` 内容接口,但语言状态只保存在 `docs-lang`不影响控制台。控制台页面、搜索索引、toast、dialog、表格和认证页仍以中文硬编码为主导致用户切到英文文档后控制台仍是中文。
本计划把前端语言偏好收敛到 `planet-locale`,默认 `zh-CN`,支持 `en-US`。Docs 继续使用后端现有 `zh` / `en` 文档接口,通过前端映射与全局 locale 对齐。
## 设计决策
- 使用 `i18next``react-i18next` 作为统一 i18n 层避免长期维护自研插值、hook 和资源加载逻辑。
- 前端统一语言枚举为 `zh-CN` / `en-US`Docs 请求继续转换为 `zh` / `en`,新闻接口继续使用已有 `zh-CN` / `en-US` 口径。
- 语言偏好首版只保存在浏览器 `localStorage`,不新增后端用户设置字段。
- `docs-lang` 保留为兼容读取和写入项,让已访问过 Docs 的浏览器能平滑迁移。
- 静态路由、导航、搜索目标和通用组件使用显式翻译 key大型业务页在迁移期间通过 legacy UI 翻译桥补足常见硬编码文案。
## 分期
### P1统一语言基础设施
-`frontend/src/i18n/` 下维护 locale 类型、资源、初始化和 `useLocale()`
-`frontend/src/main.tsx` 里初始化 i18n并同步 `document.documentElement.lang`
- 在认证页和控制台侧边栏偏好面板提供语言切换入口。
### P2高复用界面迁移
- 迁移 Docs UI、AdminLayout、route manifest、admin search、Auth、DataTable、Dialog、Toast 和 MarkdownRenderer。
- 搜索索引按当前语言展示,同时保留中英文关键词以免降低可发现性。
- 用户管理页作为独立业务页示范迁移表头、按钮、toast、校验提示、角色和 Gatekeeper 标签。
当前已完成统一 `planet-locale`、Docs 兼容映射、认证页和控制台外壳语言入口、共享组件 key 化,以及 legacy UI 翻译桥。后续工作集中在把大型业务页从过渡桥迁移到显式 key。
### P3大型业务页收敛
- 分批把 Dashboard、DataList、Logs 和 PlainResourcePages 的配置块改为显式翻译 key。
- 过渡期保留 legacy UI 翻译桥,只处理 admin/auth 容器里的精确静态文本和属性。
- 业务数据、日志原文、API 字段名、provider id、命令和 Markdown 正文不走 legacy 翻译桥。
### P4移除过渡桥
-`rg -n "[\\p{Han}]" frontend/src/admin frontend/src/pages frontend/src/components` 只剩业务数据示例、中文文档标题或必须保留的中文品牌词时,删除 legacy UI 翻译桥。
- 增加 key 完整性检查,确保 `zh-CN``en-US` 资源结构一致。
## 验证
- `cd frontend && bun run build`
- `scripts/harness/frontend-rules-check.sh`
- `scripts/harness/docs-consistency-check.sh`
- `scripts/harness/quick-check.sh`
- 前端 smoke 需要覆盖登录页、Docs、Admin 侧边栏语言切换、侧边栏和搜索结果在中英文下渲染。
## 相关文件
- `frontend/src/i18n/`:统一 locale、资源和过渡桥。
- `frontend/src/pages/Docs/Docs.tsx`Docs 语言状态改为读取全局 locale。
- `frontend/src/admin/components/layout/AdminLayout.tsx`:控制台侧边栏语言切换、导航和搜索文案。
- `frontend/src/admin/search/indexers.ts`Admin 搜索目标本地化。
- `docs/technical/{zh,en}/frontend-admin-frontend-context.md`:当前实现上下文。

View File

@@ -98,6 +98,8 @@ Admin status labels should use [StatusText](/home/ray/dev/linkong/planet/fronten
`StatusText` is an indicator-light pill: the pill background and border stay on the component base color, while only the dot and text use the status color. `Badge` does not carry the indicator-light meaning, so it may use a light same-tone background and border for stronger hierarchy. `StatusText` is an indicator-light pill: the pill background and border stay on the component base color, while only the dot and text use the status color. `Badge` does not carry the indicator-light meaning, so it may use a light same-tone background and border for stronger hierarchy.
Status indicators must show the full state word. In lists, hierarchy groups, and detail headers, the title/description area should shrink or wrap while the status pill keeps content-sized width and does not get compressed by flex/grid layout; do not truncate state words such as `Configured` or `Available` just to save horizontal space.
| Tone | Color variable | Meaning | Examples | | Tone | Color variable | Meaning | Examples |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `success` | `--an-success` | available, successful, connected, enabled | log source `Available`, collection `Success` | | `success` | `--an-success` | available, successful, connected, enabled | log source `Available`, collection `Success` |
@@ -216,7 +218,31 @@ Current constraints:
- Prefer CSS variable overrides for colors instead of hard-coding theme colors in feature components - Prefer CSS variable overrides for colors instead of hard-coding theme colors in feature components
- Best for a small set of mutually exclusive choices; do not use it as a long list, navigation menu, or select replacement - Best for a small set of mutually exclusive choices; do not use it as a long list, navigation menu, or select replacement
### 6. `MarkdownRenderer` ### 6. Console i18n
Files:
- [i18n/index.ts](/home/ray/dev/linkong/planet/frontend/src/i18n/index.ts)
- [i18n/locale.ts](/home/ray/dev/linkong/planet/frontend/src/i18n/locale.ts)
- [i18n/resources.ts](/home/ray/dev/linkong/planet/frontend/src/i18n/resources.ts)
- [LegacyI18nBridge.tsx](/home/ray/dev/linkong/planet/frontend/src/i18n/LegacyI18nBridge.tsx)
Purpose:
- Share one `zh-CN` / `en-US` language state across the console, auth pages, and Docs UI
- Store the language preference in `planet-locale` while keeping compatibility with the old `docs-lang`
- Keep Docs API requests mapped to the backend's existing `zh` / `en` document interface
- Provide language switchers in the console sidebar preferences panel and auth panel
Current constraints:
- New console copy should be added to `resources.ts`, then consumed with `useTranslation()` or `useLocale()`
- Routes, menus, search indexes, and shared components must use explicit translation keys
- `LegacyI18nBridge` is transitional and only handles exact static text and attributes inside admin/auth containers
- Business data, raw logs, API field names, provider ids, commands, and Markdown body content are not translated by the legacy bridge
- Future large-page migrations should shrink the legacy dictionary rather than grow it
### 7. `MarkdownRenderer`
File: File:

View File

@@ -98,6 +98,8 @@ Admin 的状态标签统一走 [StatusText](/home/ray/dev/linkong/planet/fronten
`StatusText` 是带圆点的指示灯:胶囊背景和边框保持组件原色,只让圆点和文字变成状态色。`Badge` 不带指示灯语义,可以使用同 tone 的浅色背景和边框强化信息层级。 `StatusText` 是带圆点的指示灯:胶囊背景和边框保持组件原色,只让圆点和文字变成状态色。`Badge` 不带指示灯语义,可以使用同 tone 的浅色背景和边框强化信息层级。
状态指示器必须完整显示状态词。列表、树形组和详情栏里的状态列应让标题/描述区域收缩或换行,状态 pill 本身使用内容自适应宽度并禁止被 flex/grid 挤压;不要为了紧凑把 `Configured` / `Available` 这类状态裁成省略号。
| Tone | 颜色变量 | 语义 | 示例 | | Tone | 颜色变量 | 语义 | 示例 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `success` | `--an-success` | 可用、成功、已连接、已启用 | 日志源 `可用`、采集 `成功` | | `success` | `--an-success` | 可用、成功、已连接、已启用 | 日志源 `可用`、采集 `成功` |
@@ -216,7 +218,31 @@ Admin 的状态标签统一走 [StatusText](/home/ray/dev/linkong/planet/fronten
- 颜色优先通过 CSS 变量覆盖,避免在业务组件里硬编码主题色 - 颜色优先通过 CSS 变量覆盖,避免在业务组件里硬编码主题色
- 适合少量互斥选项,不适合用作长列表、导航菜单或表单下拉 - 适合少量互斥选项,不适合用作长列表、导航菜单或表单下拉
### 6. `MarkdownRenderer` ### 6. 控制台 i18n
文件:
- [i18n/index.ts](/home/ray/dev/linkong/planet/frontend/src/i18n/index.ts)
- [i18n/locale.ts](/home/ray/dev/linkong/planet/frontend/src/i18n/locale.ts)
- [i18n/resources.ts](/home/ray/dev/linkong/planet/frontend/src/i18n/resources.ts)
- [LegacyI18nBridge.tsx](/home/ray/dev/linkong/planet/frontend/src/i18n/LegacyI18nBridge.tsx)
用途:
- 控制台、认证页和 Docs UI 共用 `zh-CN` / `en-US` 语言状态
- 语言偏好保存在 `planet-locale`,同时兼容旧的 `docs-lang`
- Docs 请求仍映射到后端现有 `zh` / `en` 文档接口
- 控制台侧边栏偏好面板和认证页面板提供语言切换入口
当前约束:
- 新增控制台文案优先写入 `resources.ts`,组件使用 `useTranslation()``useLocale()`
- 路由、菜单、搜索索引和通用组件必须使用显式翻译 key
- `LegacyI18nBridge` 只作为过渡层,负责 admin/auth 容器内未迁移的精确静态文本和属性
- 业务数据、日志原文、API 字段名、provider id、命令和 Markdown 正文不走 legacy 翻译桥
- 后续迁移大型业务页时应减少 legacy 字典,而不是继续扩大它
### 7. `MarkdownRenderer`
文件: 文件:

View File

@@ -16,12 +16,13 @@
## Current Version ## Current Version
- `main` 当前主线历史推导到:`0.16.5` - `main` 当前主线历史推导到:`0.16.5`
- `dev` 当前开发分支历史推导到:`0.72.0` - `dev` 当前开发分支历史推导到:`0.73.0`
## Timeline ## Timeline
| Version | Type | Branch | Commit | Summary | | Version | Type | Branch | Commit | Summary |
| --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- |
| `0.73.0` | feature | `dev` | `pending` | 新增前端统一 i18n、控制台语言/主题偏好入口、英文态 legacy 过渡翻译和 admin 一屏/状态指示器布局验证 |
| `0.72.0` | feature | `dev` | `pending` | 新增完整 agent harness、单一 AGENTS 入口、Earth News smoke 覆盖和 collector 结构化日志清理,并同步控制台/Earth/Docs 响应式维护文档 | | `0.72.0` | feature | `dev` | `pending` | 新增完整 agent harness、单一 AGENTS 入口、Earth News smoke 覆盖和 collector 结构化日志清理,并同步控制台/Earth/Docs 响应式维护文档 |
| `0.71.1` | bugfix | `dev` | `pending` | 修复 Earth 新闻区域切换、滚动条/面板/巡航一致性和新闻精修队列饿死问题,并补充 agent harness 与双语维护文档 | | `0.71.1` | bugfix | `dev` | `pending` | 修复 Earth 新闻区域切换、滚动条/面板/巡航一致性和新闻精修队列饿死问题,并补充 agent harness 与双语维护文档 |
| `0.71.0` | feature | `dev` | `pending` | Motion Agent 升级为 Web/UE 共用双向控制与真实识别服务,新增 Earth 手动新闻工作流、来源多样化,并完善启动/测试 harness 与双语文档 | | `0.71.0` | feature | `dev` | `pending` | Motion Agent 升级为 Web/UE 共用双向控制与真实识别服务,新增 Earth 手动新闻工作流、来源多样化,并完善启动/测试 harness 与双语文档 |

View File

@@ -21,6 +21,7 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dayjs": "^1.11.10", "dayjs": "^1.11.10",
"echarts": "^6.0.0", "echarts": "^6.0.0",
"i18next": "26.3.3",
"lucide-react": "^1.16.0", "lucide-react": "^1.16.0",
"mermaid": "^11.15.0", "mermaid": "^11.15.0",
"pbf": "^4.0.1", "pbf": "^4.0.1",
@@ -29,6 +30,7 @@
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-hook-form": "^7.76.0", "react-hook-form": "^7.76.0",
"react-i18next": "17.0.8",
"react-resizable": "^3.1.3", "react-resizable": "^3.1.3",
"react-router-dom": "^6.21.0", "react-router-dom": "^6.21.0",
"simplex-noise": "^4.0.1", "simplex-noise": "^4.0.1",
@@ -84,6 +86,8 @@
"@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="], "@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="],
"@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="],
"@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], "@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="],
"@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], "@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="],
@@ -556,6 +560,10 @@
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
"html-parse-stringify": ["html-parse-stringify@3.0.1", "", { "dependencies": { "void-elements": "3.1.0" } }, "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg=="],
"i18next": ["i18next@26.3.3", "", { "peerDependencies": { "typescript": "^5 || ^6" }, "optionalPeers": ["typescript"] }, "sha512-aYVegyBdXSO93CMMihvr47jI7GHSOcIahMpJX+qzUXDzW4xDJf2uenIA+45vDU+YhiVdcfsql70AC9RVdMNrHg=="],
"iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], "iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
"import-meta-resolve": ["import-meta-resolve@4.2.0", "", {}, "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg=="], "import-meta-resolve": ["import-meta-resolve@4.2.0", "", {}, "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg=="],
@@ -632,6 +640,8 @@
"react-hook-form": ["react-hook-form@7.76.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-eKtLGgFeSgkHqQD8J59AMZ9a4uD1D83iSIzt4YlTGD7liDen5rrjcUO1rVIGd9yC1gofryjtHbv+4ny4hkLWlw=="], "react-hook-form": ["react-hook-form@7.76.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-eKtLGgFeSgkHqQD8J59AMZ9a4uD1D83iSIzt4YlTGD7liDen5rrjcUO1rVIGd9yC1gofryjtHbv+4ny4hkLWlw=="],
"react-i18next": ["react-i18next@17.0.8", "", { "dependencies": { "@babel/runtime": "^7.29.2", "html-parse-stringify": "^3.0.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "i18next": ">= 26.2.0", "react": ">= 16.8.0", "typescript": "^5 || ^6" }, "optionalPeers": ["typescript"] }, "sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw=="],
"react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], "react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
"react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="], "react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="],
@@ -702,6 +712,8 @@
"vite": ["vite@5.4.21", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": "bin/vite.js" }, "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw=="], "vite": ["vite@5.4.21", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": "bin/vite.js" }, "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw=="],
"void-elements": ["void-elements@3.1.0", "", {}, "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w=="],
"ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="], "ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="],
"xmlhttprequest-ssl": ["xmlhttprequest-ssl@2.1.2", "", {}, "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ=="], "xmlhttprequest-ssl": ["xmlhttprequest-ssl@2.1.2", "", {}, "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ=="],

View File

@@ -1,6 +1,6 @@
{ {
"name": "planet-frontend", "name": "planet-frontend",
"version": "0.72.0", "version": "0.73.0",
"private": true, "private": true,
"packageManager": "bun@1", "packageManager": "bun@1",
"dependencies": { "dependencies": {
@@ -20,6 +20,7 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dayjs": "^1.11.10", "dayjs": "^1.11.10",
"echarts": "^6.0.0", "echarts": "^6.0.0",
"i18next": "26.3.3",
"lucide-react": "^1.16.0", "lucide-react": "^1.16.0",
"mermaid": "^11.15.0", "mermaid": "^11.15.0",
"pbf": "^4.0.1", "pbf": "^4.0.1",
@@ -28,6 +29,7 @@
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-hook-form": "^7.76.0", "react-hook-form": "^7.76.0",
"react-i18next": "17.0.8",
"react-resizable": "^3.1.3", "react-resizable": "^3.1.3",
"react-router-dom": "^6.21.0", "react-router-dom": "^6.21.0",
"simplex-noise": "^4.0.1", "simplex-noise": "^4.0.1",

View File

@@ -1,10 +1,12 @@
import { Suspense, lazy } from 'react' import { Suspense, lazy, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { Routes, Route, Navigate, useLocation } from 'react-router-dom' import { Routes, Route, Navigate, useLocation } from 'react-router-dom'
import { useAuthStore } from './stores/auth' import { useAuthStore } from './stores/auth'
import Login from './pages/Login/Login' import Login from './pages/Login/Login'
import { AdminErrorBoundary } from './admin/components/AdminErrorBoundary' import { AdminErrorBoundary } from './admin/components/AdminErrorBoundary'
import LegacyI18nBridge from './i18n/LegacyI18nBridge'
const Register = lazy(() => import('./pages/Register/Register')) const Register = lazy(() => import('./pages/Register/Register'))
const VerifyEmail = lazy(() => import('./pages/VerifyEmail/VerifyEmail')) const VerifyEmail = lazy(() => import('./pages/VerifyEmail/VerifyEmail'))
@@ -26,35 +28,43 @@ function isPublicPath(pathname: string) {
} }
function App() { function App() {
const { t } = useTranslation()
const { token } = useAuthStore() const { token } = useAuthStore()
const { pathname } = useLocation() const { pathname } = useLocation()
const isPublicRoute = isPublicPath(pathname) const isPublicRoute = isPublicPath(pathname)
useEffect(() => {
document.title = t('app.title')
}, [t])
if (!token && !isPublicRoute) { if (!token && !isPublicRoute) {
return <Login /> return <Login />
} }
return ( return (
<Suspense <>
fallback={( <LegacyI18nBridge />
<div className="app-route-loading"> <Suspense
<div className="app-route-loading__spinner" aria-label="正在加载" /> fallback={(
</div> <div className="app-route-loading">
)} <div className="app-route-loading__spinner" aria-label={t('app.routeLoading')} />
> </div>
<Routes> )}
<Route path="/login" element={<Login />} /> >
<Route path="/register" element={<Register />} /> <Routes>
<Route path="/verify-email" element={<VerifyEmail />} /> <Route path="/login" element={<Login />} />
<Route path="/forgot-password" element={<ForgotPassword />} /> <Route path="/register" element={<Register />} />
<Route path={ROOT_ROUTE} element={<Navigate to={EARTH_ROUTE} replace />} /> <Route path="/verify-email" element={<VerifyEmail />} />
<Route path={EARTH_ROUTE} element={<Earth />} /> <Route path="/forgot-password" element={<ForgotPassword />} />
<Route path={DOCS_ROUTE} element={<Docs />} /> <Route path={ROOT_ROUTE} element={<Navigate to={EARTH_ROUTE} replace />} />
<Route path={DOCS_ROUTE_PATTERN} element={<Docs />} /> <Route path={EARTH_ROUTE} element={<Earth />} />
<Route path="/playground" element={<Navigate to="/ai?section=playground" replace />} /> <Route path={DOCS_ROUTE} element={<Docs />} />
<Route path="/*" element={<AdminErrorBoundary><AdminRoutes /></AdminErrorBoundary>} /> <Route path={DOCS_ROUTE_PATTERN} element={<Docs />} />
</Routes> <Route path="/playground" element={<Navigate to="/ai?section=playground" replace />} />
</Suspense> <Route path="/*" element={<AdminErrorBoundary><AdminRoutes /></AdminErrorBoundary>} />
</Routes>
</Suspense>
</>
) )
} }

View File

@@ -8,6 +8,7 @@ import {
} from '@tanstack/react-table' } from '@tanstack/react-table'
import { ArrowDown, ArrowUp, ChevronsUpDown } from 'lucide-react' import { ArrowDown, ArrowUp, ChevronsUpDown } from 'lucide-react'
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import TableScrollRegion from '../../../components/Scrollbar/TableScrollRegion' import TableScrollRegion from '../../../components/Scrollbar/TableScrollRegion'
import { Button } from '../ui/button' import { Button } from '../ui/button'
@@ -37,11 +38,12 @@ export function DataTable<TData>({
getRowClassName, getRowClassName,
selection, selection,
loading = false, loading = false,
emptyText = '暂无数据', emptyText,
className = '', className = '',
footer, footer,
onRowClick, onRowClick,
}: DataTableProps<TData>) { }: DataTableProps<TData>) {
const { t } = useTranslation()
const [sorting, setSorting] = useState<SortingState>([]) const [sorting, setSorting] = useState<SortingState>([])
const memoizedColumns = useMemo(() => columns, [columns]) const memoizedColumns = useMemo(() => columns, [columns])
@@ -74,7 +76,7 @@ export function DataTable<TData>({
<th className="an-data-table__selection-cell"> <th className="an-data-table__selection-cell">
<input <input
type="checkbox" type="checkbox"
aria-label="选择当前可见数据" aria-label={t('common.selectVisibleRows')}
checked={allVisibleSelected} checked={allVisibleSelected}
disabled={!visibleSelectableIds.length} disabled={!visibleSelectableIds.length}
ref={(element) => { ref={(element) => {
@@ -114,7 +116,7 @@ export function DataTable<TData>({
<td colSpan={columnCount}> <td colSpan={columnCount}>
<div className="an-data-table__state"> <div className="an-data-table__state">
<span className="an-spinner" /> <span className="an-spinner" />
{t('common.loading')}
</div> </div>
</td> </td>
</tr> </tr>
@@ -131,7 +133,7 @@ export function DataTable<TData>({
<td className="an-data-table__selection-cell"> <td className="an-data-table__selection-cell">
<input <input
type="checkbox" type="checkbox"
aria-label={selection.getCheckboxLabel?.(row.original) || '选择行'} aria-label={selection.getCheckboxLabel?.(row.original) || t('common.selectRow')}
checked={selection.selectedRowIds.has(row.id)} checked={selection.selectedRowIds.has(row.id)}
disabled={selection.isRowSelectable ? !selection.isRowSelectable(row.original) : false} disabled={selection.isRowSelectable ? !selection.isRowSelectable(row.original) : false}
onClick={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}
@@ -149,7 +151,7 @@ export function DataTable<TData>({
) : ( ) : (
<tr> <tr>
<td colSpan={columnCount}> <td colSpan={columnCount}>
<div className="an-data-table__state">{emptyText}</div> <div className="an-data-table__state">{emptyText || t('common.noData')}</div>
</td> </td>
</tr> </tr>
)} )}
@@ -173,18 +175,19 @@ export function DataTablePager({
total: number total: number
onPageChange: (page: number) => void onPageChange: (page: number) => void
}) { }) {
const { t } = useTranslation()
const totalPages = Math.max(1, Math.ceil(total / pageSize)) const totalPages = Math.max(1, Math.ceil(total / pageSize))
return ( return (
<div className="an-data-table__pager"> <div className="an-data-table__pager">
<span> <span>
{page} / {totalPages} {total.toLocaleString()} {t('common.page', { page, totalPages, total: total.toLocaleString() })}
</span> </span>
<div className="an-data-table__pager-actions"> <div className="an-data-table__pager-actions">
<Button size="sm" variant="subtle" disabled={page <= 1} onClick={() => onPageChange(page - 1)}> <Button size="sm" variant="subtle" disabled={page <= 1} onClick={() => onPageChange(page - 1)}>
{t('common.previousPage')}
</Button> </Button>
<Button size="sm" variant="subtle" disabled={page >= totalPages} onClick={() => onPageChange(page + 1)}> <Button size="sm" variant="subtle" disabled={page >= totalPages} onClick={() => onPageChange(page + 1)}>
{t('common.nextPage')}
</Button> </Button>
</div> </div>
</div> </div>

View File

@@ -1,18 +1,22 @@
import { import {
ChevronDown, ChevronDown,
Languages,
LogOut, LogOut,
Menu, Menu,
Moon, Moon,
Monitor, Monitor,
Search, Search,
Settings,
Sun, Sun,
X, X,
} from 'lucide-react' } from 'lucide-react'
import { type FocusEvent, type KeyboardEvent, type ReactNode, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { type FocusEvent, type KeyboardEvent, type ReactNode, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Link, useLocation, useNavigate } from 'react-router-dom' import { Link, useLocation, useNavigate } from 'react-router-dom'
import packageJson from '../../../../package.json' import packageJson from '../../../../package.json'
import Scrollbar from '../../../components/Scrollbar/Scrollbar' import Scrollbar from '../../../components/Scrollbar/Scrollbar'
import SegmentedControl from '../../../components/SegmentedControl/SegmentedControl' import SegmentedControl from '../../../components/SegmentedControl/SegmentedControl'
import { localeOptions, useLocale, type SupportedLocale } from '../../../i18n/locale'
import { useAuthStore } from '../../../stores/auth' import { useAuthStore } from '../../../stores/auth'
import { useAdminTheme, type AdminThemeMode } from '../../design/theme' import { useAdminTheme, type AdminThemeMode } from '../../design/theme'
import { cn } from '../../utils' import { cn } from '../../utils'
@@ -28,6 +32,8 @@ export function AdminLayout({ children }: { children: ReactNode }) {
const location = useLocation() const location = useLocation()
const navigate = useNavigate() const navigate = useNavigate()
const adminSearch = useAdminSearch() const adminSearch = useAdminSearch()
const { t } = useTranslation()
const { locale, setLocale } = useLocale()
const { user, logout } = useAuthStore() const { user, logout } = useAuthStore()
const { mode, setMode } = useAdminTheme() const { mode, setMode } = useAdminTheme()
const [collapsed, setCollapsed] = useState(false) const [collapsed, setCollapsed] = useState(false)
@@ -35,25 +41,39 @@ export function AdminLayout({ children }: { children: ReactNode }) {
const [openKeys, setOpenKeys] = useState<string[]>(cachedOpenKeys) const [openKeys, setOpenKeys] = useState<string[]>(cachedOpenKeys)
const [searchQuery, setSearchQuery] = useState('') const [searchQuery, setSearchQuery] = useState('')
const [searchOpen, setSearchOpen] = useState(false) const [searchOpen, setSearchOpen] = useState(false)
const [preferencesOpen, setPreferencesOpen] = useState(false)
const [highlightedSearchIndex, setHighlightedSearchIndex] = useState(0) const [highlightedSearchIndex, setHighlightedSearchIndex] = useState(0)
const menuViewportRef = useRef<HTMLDivElement>(null) const menuViewportRef = useRef<HTMLDivElement>(null)
const searchInputRef = useRef<HTMLInputElement>(null) const searchInputRef = useRef<HTMLInputElement>(null)
const isSuperAdmin = user?.role === 'super_admin' const isSuperAdmin = user?.role === 'super_admin'
const username = user?.username || '-'
const userInitial = username.trim().charAt(0).toUpperCase() || '?'
const preferencesLabel = preferencesOpen ? t('admin.collapsePreferences') : t('admin.expandPreferences')
const visibleRoutes = useMemo(() => getVisibleAdminRoutes(isSuperAdmin), [isSuperAdmin]) const visibleRoutes = useMemo(() => getVisibleAdminRoutes(isSuperAdmin), [isSuperAdmin])
const navGroups = useMemo(() => { const navGroups = useMemo(() => {
return adminRouteGroups.map((group) => ({ return adminRouteGroups.map((group) => ({
...group, ...group,
children: visibleRoutes.filter((route) => route.group === group.key), label: t(group.labelKey),
children: visibleRoutes
.filter((route) => route.group === group.key)
.map((route) => ({ ...route, label: t(route.labelKey) })),
})).filter((group) => group.children.length > 0) })).filter((group) => group.children.length > 0)
}, [visibleRoutes]) }, [t, visibleRoutes])
const selectedKey = location.pathname === '/admin/' ? '/admin' : location.pathname.replace(/\/$/, '') const selectedKey = location.pathname === '/admin/' ? '/admin' : location.pathname.replace(/\/$/, '')
const activeRoute = visibleRoutes.find((route) => route.path === selectedKey) const activeRoute = visibleRoutes.find((route) => route.path === selectedKey)
const activeRouteLabel = activeRoute ? t(activeRoute.labelKey) : ''
const searchResults = useMemo(() => adminSearch.search(searchQuery), [adminSearch, searchQuery]) const searchResults = useMemo(() => adminSearch.search(searchQuery), [adminSearch, searchQuery])
const themeOptions = useMemo(() => [ const themeOptions = useMemo(() => [
{ value: 'light' as const, label: '浅色', title: '浅色', icon: <Sun /> }, { value: 'light' as const, label: t('common.themeLight'), title: t('common.themeLight'), icon: <Sun /> },
{ value: 'system' as const, label: '系统', title: '跟随系统', icon: <Monitor /> }, { value: 'system' as const, label: t('common.themeSystem'), title: t('common.themeFollowSystem'), icon: <Monitor /> },
{ value: 'dark' as const, label: '深色', title: '深色', icon: <Moon /> }, { value: 'dark' as const, label: t('common.themeDark'), title: t('common.themeDark'), icon: <Moon /> },
], []) ], [t])
const languageOptions = useMemo(() => localeOptions.map((option) => ({
value: option.value,
label: t(option.labelKey),
title: t(option.titleKey),
icon: <Languages />,
})), [t])
const updateOpenKeys = (nextKeys: string[]) => { const updateOpenKeys = (nextKeys: string[]) => {
cachedOpenKeys = nextKeys cachedOpenKeys = nextKeys
@@ -116,15 +136,15 @@ export function AdminLayout({ children }: { children: ReactNode }) {
event.stopPropagation() event.stopPropagation()
setCollapsed((value) => !value) setCollapsed((value) => !value)
}} }}
aria-label={collapsed ? '展开菜单' : '折叠菜单'} title={collapsed ? t('admin.expandMenu') : t('admin.collapseMenu')}
title={collapsed ? '展开菜单' : '折叠菜单'} aria-label={collapsed ? t('admin.expandMenu') : t('admin.collapseMenu')}
> >
{collapsed ? <Menu size={18} /> : <X size={18} />} {collapsed ? <Menu size={18} /> : <X size={18} />}
</Button> </Button>
{!collapsed ? ( {!collapsed ? (
<div className="admin__brand-copy"> <div className="admin__brand-copy">
<span className="admin__brand-text"></span> <span className="admin__brand-text">{t('admin.brandTitle')}</span>
<span className="admin__brand-subtitle"></span> <span className="admin__brand-subtitle">{t('admin.brandSubtitle')}</span>
</div> </div>
) : null} ) : null}
</div> </div>
@@ -186,36 +206,61 @@ export function AdminLayout({ children }: { children: ReactNode }) {
{!collapsed ? ( {!collapsed ? (
<div className="admin__account"> <div className="admin__account">
<div className="admin__account-row"> <div className="admin__account-row admin__account-row--primary">
<div> <div className="admin__account-profile">
<strong>Hi, {user?.username || '-'}</strong> <span className="admin__account-avatar" aria-hidden="true">{userInitial}</span>
<div>
<strong>{t('admin.greeting', { name: username })}</strong>
<span>{t('admin.version')} v{packageJson.version}</span>
</div>
</div>
<div className="admin__account-actions">
<Button
size="icon"
variant="ghost"
className={cn('admin__account-preferences', preferencesOpen && 'is-active')}
onClick={() => setPreferencesOpen((value) => !value)}
title={preferencesLabel}
aria-label={preferencesLabel}
aria-expanded={preferencesOpen}
>
<Settings size={15} />
</Button>
<Button
size="icon"
variant="ghost"
className="admin__account-logout"
onClick={() => {
logout()
navigate('/login')
}}
title={t('admin.logout')}
aria-label={t('admin.logout')}
>
<LogOut size={15} />
</Button>
</div> </div>
<Button
size="icon"
variant="ghost"
className="admin__account-logout"
onClick={() => {
logout()
navigate('/login')
}}
aria-label="退出登录"
title="退出登录"
>
<LogOut size={15} />
</Button>
</div> </div>
<div className="admin__account-row"> <div className={cn('admin__preferences-drawer', preferencesOpen && 'is-open')} aria-hidden={!preferencesOpen}>
<span></span> <div className="admin__preferences-panel">
<strong>v{packageJson.version}</strong> <SegmentedControl<SupportedLocale>
ariaLabel={t('admin.languageControl')}
className="admin__language-control admin__language-control--sider"
options={languageOptions}
scale={0.86}
value={locale}
onChange={setLocale}
/>
<SegmentedControl<AdminThemeMode>
ariaLabel={t('admin.themeControl')}
className="admin__theme-control admin__theme-control--sider"
options={themeOptions}
scale={0.86}
value={mode}
onChange={setMode}
/>
</div>
</div> </div>
<SegmentedControl<AdminThemeMode>
ariaLabel="控制台主题"
className="admin__theme-control admin__theme-control--sider"
options={themeOptions}
scale={0.72}
value={mode}
onChange={setMode}
/>
</div> </div>
) : null} ) : null}
</> </>
@@ -250,22 +295,22 @@ export function AdminLayout({ children }: { children: ReactNode }) {
{mobileNavOpen ? ( {mobileNavOpen ? (
<div className="admin__mobile-nav"> <div className="admin__mobile-nav">
<div className="admin__mobile-nav-panel">{nav}</div> <div className="admin__mobile-nav-panel">{nav}</div>
<button className="admin__mobile-nav-backdrop" type="button" aria-label="关闭导航" onClick={() => setMobileNavOpen(false)} /> <button className="admin__mobile-nav-backdrop" type="button" aria-label={t('admin.closeNav')} onClick={() => setMobileNavOpen(false)} />
</div> </div>
) : null} ) : null}
<main className="admin__content"> <main className="admin__content">
<header className="admin__topbar"> <header className="admin__topbar">
<Button size="icon" variant="ghost" className="admin__mobile-menu" onClick={() => setMobileNavOpen(true)} aria-label="打开导航" title="打开导航"> <Button size="icon" variant="ghost" className="admin__mobile-menu" onClick={() => setMobileNavOpen(true)} aria-label={t('admin.openNav')} title={t('admin.openNav')}>
<Menu size={18} /> <Menu size={18} />
</Button> </Button>
<div className="admin__search" onBlur={handleSearchBlur}> <div className="admin__search" onBlur={handleSearchBlur}>
<Search className="admin__search-icon" size={16} /> <Search className="admin__search-icon" size={16} />
<input <input
ref={searchInputRef} ref={searchInputRef}
aria-label="搜索功能、配置和文字" aria-label={t('admin.search.label')}
autoComplete="off" autoComplete="off"
value={searchQuery} value={searchQuery}
placeholder={activeRoute ? `搜索功能、配置和文字,当前:${activeRoute.label}` : '搜索功能、配置和文字'} placeholder={activeRouteLabel ? `${t('admin.search.placeholder')}${t('admin.search.current', { label: activeRouteLabel })}` : t('admin.search.placeholder')}
onChange={(event) => { onChange={(event) => {
setSearchQuery(event.target.value) setSearchQuery(event.target.value)
setSearchOpen(true) setSearchOpen(true)
@@ -275,7 +320,7 @@ export function AdminLayout({ children }: { children: ReactNode }) {
onKeyDown={handleSearchKeyDown} onKeyDown={handleSearchKeyDown}
/> />
{searchOpen ? ( {searchOpen ? (
<div className="admin__search-results" role="listbox" aria-label="Admin 搜索结果"> <div className="admin__search-results" role="listbox" aria-label={t('admin.search.results')}>
{searchResults.length > 0 ? searchResults.map((target, index) => { {searchResults.length > 0 ? searchResults.map((target, index) => {
const ResultIcon = target.icon || Search const ResultIcon = target.icon || Search
return ( return (
@@ -297,7 +342,7 @@ export function AdminLayout({ children }: { children: ReactNode }) {
</button> </button>
) )
}) : ( }) : (
<div className="admin__search-empty">{adminSearch.loading ? '正在加载搜索索引…' : '没有找到匹配内容'}</div> <div className="admin__search-empty">{adminSearch.loading ? t('admin.search.loading') : t('admin.search.empty')}</div>
)} )}
</div> </div>
) : null} ) : null}

View File

@@ -1,6 +1,7 @@
import * as DialogPrimitive from '@radix-ui/react-dialog' import * as DialogPrimitive from '@radix-ui/react-dialog'
import { X } from 'lucide-react' import { X } from 'lucide-react'
import { type ReactNode } from 'react' import { type ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
import Scrollbar from '../../../components/Scrollbar/Scrollbar' import Scrollbar from '../../../components/Scrollbar/Scrollbar'
import { Button } from './button' import { Button } from './button'
@@ -15,6 +16,8 @@ interface DialogProps {
} }
export function Dialog({ open, onOpenChange, title, description, children, footer, width }: DialogProps) { export function Dialog({ open, onOpenChange, title, description, children, footer, width }: DialogProps) {
const { t } = useTranslation()
return ( return (
<DialogPrimitive.Root open={open} onOpenChange={onOpenChange}> <DialogPrimitive.Root open={open} onOpenChange={onOpenChange}>
<DialogPrimitive.Portal> <DialogPrimitive.Portal>
@@ -30,7 +33,7 @@ export function Dialog({ open, onOpenChange, title, description, children, foote
) : null} ) : null}
</div> </div>
<DialogPrimitive.Close asChild> <DialogPrimitive.Close asChild>
<Button size="icon" variant="ghost" aria-label="关闭" title="关闭"> <Button size="icon" variant="ghost" aria-label={t('common.close')} title={t('common.close')}>
<X size={16} /> <X size={16} />
</Button> </Button>
</DialogPrimitive.Close> </DialogPrimitive.Close>
@@ -60,12 +63,16 @@ export function ConfirmDialog({
onOpenChange, onOpenChange,
title, title,
description, description,
confirmLabel = '确认', confirmLabel,
cancelLabel = '取消', cancelLabel,
danger = false, danger = false,
loading = false, loading = false,
onConfirm, onConfirm,
}: ConfirmDialogProps) { }: ConfirmDialogProps) {
const { t } = useTranslation()
const resolvedCancelLabel = cancelLabel || t('common.cancel')
const resolvedConfirmLabel = confirmLabel || t('common.confirm')
return ( return (
<Dialog <Dialog
open={open} open={open}
@@ -76,15 +83,15 @@ export function ConfirmDialog({
footer={( footer={(
<> <>
<Button variant="subtle" onClick={() => onOpenChange(false)} disabled={loading}> <Button variant="subtle" onClick={() => onOpenChange(false)} disabled={loading}>
{cancelLabel} {resolvedCancelLabel}
</Button> </Button>
<Button variant={danger ? 'danger' : 'primary'} onClick={onConfirm} loading={loading}> <Button variant={danger ? 'danger' : 'primary'} onClick={onConfirm} loading={loading}>
{confirmLabel} {resolvedConfirmLabel}
</Button> </Button>
</> </>
)} )}
> >
<span className="sr-only">{description || '请确认本次操作。'}</span> <span className="sr-only">{description || t('common.confirm')}</span>
</Dialog> </Dialog>
) )
} }

View File

@@ -1,6 +1,7 @@
import * as ToastPrimitive from '@radix-ui/react-toast' import * as ToastPrimitive from '@radix-ui/react-toast'
import { X } from 'lucide-react' import { X } from 'lucide-react'
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from 'react' import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
type ToastTone = 'default' | 'success' | 'error' type ToastTone = 'default' | 'success' | 'error'
@@ -18,6 +19,7 @@ interface ToastContextValue {
const ToastContext = createContext<ToastContextValue | null>(null) const ToastContext = createContext<ToastContextValue | null>(null)
export function ToastProvider({ children }: { children: ReactNode }) { export function ToastProvider({ children }: { children: ReactNode }) {
const { t } = useTranslation()
const [items, setItems] = useState<ToastItem[]>([]) const [items, setItems] = useState<ToastItem[]>([])
const toast = useCallback((item: Omit<ToastItem, 'id' | 'tone'> & { tone?: ToastTone }) => { const toast = useCallback((item: Omit<ToastItem, 'id' | 'tone'> & { tone?: ToastTone }) => {
@@ -46,7 +48,7 @@ export function ToastProvider({ children }: { children: ReactNode }) {
{item.description} {item.description}
</ToastPrimitive.Description> </ToastPrimitive.Description>
) : null} ) : null}
<ToastPrimitive.Close className="an-toast__close" aria-label="关闭通知" title="关闭通知"> <ToastPrimitive.Close className="an-toast__close" aria-label={t('common.close')} title={t('common.close')}>
<X size={14} /> <X size={14} />
</ToastPrimitive.Close> </ToastPrimitive.Close>
</ToastPrimitive.Root> </ToastPrimitive.Root>

View File

@@ -4,6 +4,7 @@ import axios from 'axios'
import { Edit, Plus, Search, Trash2, X } from 'lucide-react' import { Edit, Plus, Search, Trash2, X } from 'lucide-react'
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { useForm } from 'react-hook-form' import { useForm } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import { z } from 'zod' import { z } from 'zod'
import { useAuthStore } from '../../stores/auth' import { useAuthStore } from '../../stores/auth'
import { DataTable } from '../components/data-table/DataTable' import { DataTable } from '../components/data-table/DataTable'
@@ -25,28 +26,17 @@ interface User {
created_at: string created_at: string
} }
const userSchema = z.object({ interface UserFormValues {
username: z.string().min(1, '请输入用户名'), username: string
email: z.string().email('请输入有效邮箱'), email: string
password: z.string().optional(), password?: string
role: z.string().min(1, '请选择角色'), role: string
gatekeeper_groups: z.array(z.string()).optional(), gatekeeper_groups?: string[]
}) }
type UserFormValues = z.infer<typeof userSchema> const roleValues = ['super_admin', 'admin', 'operator', 'viewer'] as const
const roleOptions = [ const gatekeeperValues = ['docs_user', 'docs_developer', 'docs_admin'] as const
{ value: 'super_admin', label: '超级管理员' },
{ value: 'admin', label: '管理员' },
{ value: 'operator', label: '操作员' },
{ value: 'viewer', label: '只读用户' },
]
const gatekeeperOptions = [
{ value: 'docs_user', label: '文档:用户文档' },
{ value: 'docs_developer', label: '文档:开发文档' },
{ value: 'docs_admin', label: '文档:管理/运维文档' },
]
function roleTone(role: string) { function roleTone(role: string) {
if (role === 'super_admin') return 'red' if (role === 'super_admin') return 'red'
@@ -56,15 +46,8 @@ function roleTone(role: string) {
return 'default' return 'default'
} }
function roleLabel(role: string) {
return roleOptions.find((option) => option.value === role)?.label || role
}
function gatekeeperLabel(group: string) {
return gatekeeperOptions.find((option) => option.value === group)?.label || group
}
export default function Users() { export default function Users() {
const { t } = useTranslation()
const { user: currentUser } = useAuthStore() const { user: currentUser } = useAuthStore()
const { toast } = useToast() const { toast } = useToast()
const [users, setUsers] = useState<User[]>([]) const [users, setUsers] = useState<User[]>([])
@@ -74,6 +57,23 @@ export default function Users() {
const [deleteTarget, setDeleteTarget] = useState<User | null>(null) const [deleteTarget, setDeleteTarget] = useState<User | null>(null)
const [searchText, setSearchText] = useState('') const [searchText, setSearchText] = useState('')
const isSuperAdmin = currentUser?.role === 'super_admin' const isSuperAdmin = currentUser?.role === 'super_admin'
const userSchema = useMemo(() => z.object({
username: z.string().min(1, t('auth.username')),
email: z.string().email(t('auth.email')),
password: z.string().optional(),
role: z.string().min(1, t('users.role')),
gatekeeper_groups: z.array(z.string()).optional(),
}), [t])
const roleOptions = useMemo(() => roleValues.map((value) => ({
value,
label: t(`users.roles.${value}`),
})), [t])
const gatekeeperOptions = useMemo(() => gatekeeperValues.map((value) => ({
value,
label: t(`users.gatekeeper.${value}`),
})), [t])
const roleLabel = (role: string) => roleOptions.find((option) => option.value === role)?.label || role
const gatekeeperLabel = (group: string) => gatekeeperOptions.find((option) => option.value === group)?.label || group
const form = useForm<UserFormValues>({ const form = useForm<UserFormValues>({
resolver: zodResolver(userSchema), resolver: zodResolver(userSchema),
@@ -125,17 +125,17 @@ export default function Users() {
if (!isSuperAdmin) delete payload.gatekeeper_groups if (!isSuperAdmin) delete payload.gatekeeper_groups
if (editingUser) { if (editingUser) {
await axios.put(`/api/v1/users/${editingUser.id}`, payload) await axios.put(`/api/v1/users/${editingUser.id}`, payload)
toast({ tone: 'success', title: '更新成功' }) toast({ tone: 'success', title: t('users.updateSuccess') })
} else { } else {
const createPayload = { ...payload, password: values.password || '' } const createPayload = { ...payload, password: values.password || '' }
await axios.post('/api/v1/users', createPayload) await axios.post('/api/v1/users', createPayload)
toast({ tone: 'success', title: '创建成功' }) toast({ tone: 'success', title: t('users.createSuccess') })
} }
setModalVisible(false) setModalVisible(false)
void fetchUsers() void fetchUsers()
} catch (error: unknown) { } catch (error: unknown) {
const err = error as { response?: { data?: { detail?: string } } } const err = error as { response?: { data?: { detail?: string } } }
toast({ tone: 'error', title: '操作失败', description: err.response?.data?.detail || '请稍后重试' }) toast({ tone: 'error', title: t('common.operationFailed'), description: err.response?.data?.detail || t('users.retryLater') })
} }
} }
@@ -143,22 +143,22 @@ export default function Users() {
if (!deleteTarget) return if (!deleteTarget) return
try { try {
await axios.delete(`/api/v1/users/${deleteTarget.id}`) await axios.delete(`/api/v1/users/${deleteTarget.id}`)
toast({ tone: 'success', title: '删除成功' }) toast({ tone: 'success', title: t('users.deleteSuccess') })
setDeleteTarget(null) setDeleteTarget(null)
void fetchUsers() void fetchUsers()
} catch (error: unknown) { } catch (error: unknown) {
const err = error as { response?: { data?: { detail?: string } } } const err = error as { response?: { data?: { detail?: string } } }
toast({ tone: 'error', title: '删除失败', description: err.response?.data?.detail || '请稍后重试' }) toast({ tone: 'error', title: t('users.deleteFailed'), description: err.response?.data?.detail || t('users.retryLater') })
} }
} }
const columns = useMemo<Array<ColumnDef<User>>>(() => [ const columns = useMemo<Array<ColumnDef<User>>>(() => [
{ accessorKey: 'id', header: 'ID', size: 80 }, { accessorKey: 'id', header: 'ID', size: 80 },
{ accessorKey: 'username', header: '用户名', size: 180 }, { accessorKey: 'username', header: t('auth.username'), size: 180 },
{ accessorKey: 'email', header: '邮箱', size: 260 }, { accessorKey: 'email', header: t('auth.email'), size: 260 },
{ {
accessorKey: 'role', accessorKey: 'role',
header: '角色', header: t('users.role'),
size: 140, size: 140,
cell: ({ row }) => <Badge tone={roleTone(row.original.role)} title={row.original.role}>{roleLabel(row.original.role)}</Badge>, cell: ({ row }) => <Badge tone={roleTone(row.original.role)} title={row.original.role}>{roleLabel(row.original.role)}</Badge>,
}, },
@@ -175,30 +175,30 @@ export default function Users() {
<Badge key={group} tone={group === 'docs_admin' ? 'red' : group === 'docs_developer' ? 'blue' : 'green'}> <Badge key={group} tone={group === 'docs_admin' ? 'red' : group === 'docs_developer' ? 'blue' : 'green'}>
{gatekeeperLabel(group)} {gatekeeperLabel(group)}
</Badge> </Badge>
)) : <Badge tone="slate"></Badge>} )) : <Badge tone="slate">{t('users.unconfigured')}</Badge>}
</div> </div>
) )
}, },
}, },
{ {
accessorKey: 'is_active', accessorKey: 'is_active',
header: '状态', header: t('users.status'),
size: 120, size: 120,
cell: ({ row }) => <Badge tone={row.original.is_active ? 'green' : 'red'}>{row.original.is_active ? '活跃' : '禁用'}</Badge>, cell: ({ row }) => <Badge tone={row.original.is_active ? 'green' : 'red'}>{row.original.is_active ? t('users.active') : t('users.disabled')}</Badge>,
}, },
{ {
id: 'actions', id: 'actions',
header: '操作', header: t('users.actions'),
size: 148, size: 148,
enableSorting: false, enableSorting: false,
cell: ({ row }) => ( cell: ({ row }) => (
<div className="an-row-actions"> <div className="an-row-actions">
<Button size="sm" variant="ghost" onClick={() => handleEdit(row.original)}><Edit size={14} /></Button> <Button size="sm" variant="ghost" onClick={() => handleEdit(row.original)}><Edit size={14} />{t('users.edit')}</Button>
<Button size="sm" variant="ghost" className="is-danger" onClick={() => setDeleteTarget(row.original)}><Trash2 size={14} /></Button> <Button size="sm" variant="ghost" className="is-danger" onClick={() => setDeleteTarget(row.original)}><Trash2 size={14} />{t('common.delete')}</Button>
</div> </div>
), ),
}, },
], []) ], [gatekeeperOptions, roleOptions, t])
const filteredUsers = useMemo(() => { const filteredUsers = useMemo(() => {
const keyword = searchText.trim().toLowerCase() const keyword = searchText.trim().toLowerCase()
@@ -218,8 +218,8 @@ export default function Users() {
<div className="an-page"> <div className="an-page">
<div className="an-page__header"> <div className="an-page__header">
<div> <div>
<h1></h1> <h1>{t('admin.routes.users')}</h1>
<p></p> <p>{t('users.description')}</p>
</div> </div>
<div className="an-toolbar"> <div className="an-toolbar">
<div className="an-search-box"> <div className="an-search-box">
@@ -227,15 +227,15 @@ export default function Users() {
<Input <Input
value={searchText} value={searchText}
onChange={(event) => setSearchText(event.target.value)} onChange={(event) => setSearchText(event.target.value)}
placeholder="搜索用户、邮箱、角色" placeholder={t('users.searchPlaceholder')}
/> />
{searchText ? ( {searchText ? (
<Button size="icon" variant="ghost" onClick={() => setSearchText('')} aria-label="清空搜索" title="清空搜索"> <Button size="icon" variant="ghost" onClick={() => setSearchText('')} aria-label={t('users.clearSearch')} title={t('users.clearSearch')}>
<X size={14} /> <X size={14} />
</Button> </Button>
) : null} ) : null}
</div> </div>
<Button variant="primary" onClick={handleAdd}><Plus size={16} /></Button> <Button variant="primary" onClick={handleAdd}><Plus size={16} />{t('users.addUser')}</Button>
</div> </div>
</div> </div>
<div className="an-page__body"> <div className="an-page__body">
@@ -244,40 +244,40 @@ export default function Users() {
</div> </div>
<Dialog <Dialog
title={editingUser ? '编辑用户' : '添加用户'} title={editingUser ? t('users.editUser') : t('users.addUser')}
open={modalVisible} open={modalVisible}
onOpenChange={setModalVisible} onOpenChange={setModalVisible}
footer={( footer={(
<> <>
<Button variant="subtle" onClick={() => setModalVisible(false)}></Button> <Button variant="subtle" onClick={() => setModalVisible(false)}>{t('common.cancel')}</Button>
<Button variant="primary" loading={form.formState.isSubmitting} onClick={form.handleSubmit(handleSubmit)}></Button> <Button variant="primary" loading={form.formState.isSubmitting} onClick={form.handleSubmit(handleSubmit)}>{t('users.submit')}</Button>
</> </>
)} )}
> >
<form className="an-form" onSubmit={form.handleSubmit(handleSubmit)}> <form className="an-form" onSubmit={form.handleSubmit(handleSubmit)}>
<label className="an-field"> <label className="an-field">
<span></span> <span>{t('auth.username')}</span>
<Input {...form.register('username')} /> <Input {...form.register('username')} />
{form.formState.errors.username ? <em>{form.formState.errors.username.message}</em> : null} {form.formState.errors.username ? <em>{form.formState.errors.username.message}</em> : null}
</label> </label>
<label className="an-field"> <label className="an-field">
<span></span> <span>{t('auth.email')}</span>
<Input {...form.register('email')} /> <Input {...form.register('email')} />
{form.formState.errors.email ? <em>{form.formState.errors.email.message}</em> : null} {form.formState.errors.email ? <em>{form.formState.errors.email.message}</em> : null}
</label> </label>
{!editingUser ? ( {!editingUser ? (
<label className="an-field"> <label className="an-field">
<span></span> <span>{t('auth.password')}</span>
<Input type="password" {...form.register('password', { required: true, minLength: 8 })} /> <Input type="password" {...form.register('password', { required: true, minLength: 8 })} />
{form.formState.errors.password ? <em> 8 </em> : null} {form.formState.errors.password ? <em>{t('auth.passwordHint')}</em> : null}
</label> </label>
) : null} ) : null}
<label className="an-field"> <label className="an-field">
<span></span> <span>{t('users.role')}</span>
<Select value={form.watch('role')} onValueChange={(value) => form.setValue('role', value)} options={roleOptions} /> <Select value={form.watch('role')} onValueChange={(value) => form.setValue('role', value)} options={roleOptions} />
</label> </label>
<div className="an-field"> <div className="an-field">
<span>Gatekeeper </span> <span>{t('users.gatekeeperGroups')}</span>
<div className="an-checkbox-list" aria-disabled={!isSuperAdmin}> <div className="an-checkbox-list" aria-disabled={!isSuperAdmin}>
{gatekeeperOptions.map((option) => ( {gatekeeperOptions.map((option) => (
<label key={option.value}> <label key={option.value}>
@@ -301,14 +301,14 @@ export default function Users() {
</Dialog> </Dialog>
<ConfirmDialog <ConfirmDialog
title="确认删除" title={t('users.confirmDelete')}
open={Boolean(deleteTarget)} open={Boolean(deleteTarget)}
onOpenChange={(open) => { onOpenChange={(open) => {
if (!open) setDeleteTarget(null) if (!open) setDeleteTarget(null)
}} }}
description={`确定要删除用户 ${deleteTarget?.username || ''} 吗?`} description={t('users.confirmDeleteDescription', { username: deleteTarget?.username || '' })}
danger danger
confirmLabel="删除" confirmLabel={t('common.delete')}
onConfirm={() => void handleDelete()} onConfirm={() => void handleDelete()}
/> />
</AdminLayout> </AdminLayout>

View File

@@ -17,6 +17,7 @@ import {
export interface AdminRouteItem { export interface AdminRouteItem {
path: string path: string
label: string label: string
labelKey: string
group: string group: string
icon: LucideIcon icon: LucideIcon
keywords: string[] keywords: string[]
@@ -26,33 +27,34 @@ export interface AdminRouteItem {
export interface AdminRouteGroup { export interface AdminRouteGroup {
key: string key: string
label: string label: string
labelKey: string
icon: LucideIcon icon: LucideIcon
} }
export const adminRouteGroups: AdminRouteGroup[] = [ export const adminRouteGroups: AdminRouteGroup[] = [
{ key: 'overview', label: '总览', icon: CircleGauge }, { key: 'overview', label: '总览', labelKey: 'admin.groups.overview', icon: CircleGauge },
{ key: 'collection', label: '采集与数据', icon: HardDrive }, { key: 'collection', label: '采集与数据', labelKey: 'admin.groups.collection', icon: HardDrive },
{ key: 'observability', label: '专题观测', icon: AppWindow }, { key: 'observability', label: '专题观测', labelKey: 'admin.groups.observability', icon: AppWindow },
{ key: 'alerts', label: '告警与研判', icon: ShieldAlert }, { key: 'alerts', label: '告警与研判', labelKey: 'admin.groups.alerts', icon: ShieldAlert },
{ key: 'ops', label: '运维与配置', icon: Settings }, { key: 'ops', label: '运维与配置', labelKey: 'admin.groups.ops', icon: Settings },
] ]
export const adminRoutes: AdminRouteItem[] = [ export const adminRoutes: AdminRouteItem[] = [
{ path: '/admin', label: '仪表盘', group: 'overview', icon: CircleGauge, keywords: ['dashboard', '总览', '驾驶舱'] }, { path: '/admin', label: '仪表盘', labelKey: 'admin.routes.dashboard', group: 'overview', icon: CircleGauge, keywords: ['dashboard', '总览', '驾驶舱'] },
{ path: '/earth', label: '智能星球', group: 'overview', icon: Globe2, keywords: ['earth', '地球', '智能星球'] }, { path: '/earth', label: '智能星球', labelKey: 'admin.routes.earth', group: 'overview', icon: Globe2, keywords: ['earth', '地球', '智能星球'] },
{ path: '/docs', label: '文档', group: 'overview', icon: FileText, keywords: ['docs', '文档', 'manual', '使用手册'] }, { path: '/docs', label: '文档', labelKey: 'admin.routes.docs', group: 'overview', icon: FileText, keywords: ['docs', '文档', 'manual', '使用手册'] },
{ path: '/datasources', label: '数据源', group: 'collection', icon: Database, keywords: ['datasource', '采集', '目录'] }, { path: '/datasources', label: '数据源', labelKey: 'admin.routes.datasources', group: 'collection', icon: Database, keywords: ['datasource', '采集', '目录'] },
{ path: '/data', label: '采集数据', group: 'collection', icon: AppWindow, keywords: ['data', 'records', '采集数据'] }, { path: '/data', label: '采集数据', labelKey: 'admin.routes.data', group: 'collection', icon: AppWindow, keywords: ['data', 'records', '采集数据'] },
{ path: '/bgp', label: 'BGP观测', group: 'observability', icon: Network, keywords: ['bgp', '观测', '网络'] }, { path: '/bgp', label: 'BGP观测', labelKey: 'admin.routes.bgp', group: 'observability', icon: Network, keywords: ['bgp', '观测', '网络'] },
{ path: '/alerts/system', label: '系统告警', group: 'alerts', icon: AlertTriangle, keywords: ['alert', 'system', '告警'] }, { path: '/alerts/system', label: '系统告警', labelKey: 'admin.routes.systemAlerts', group: 'alerts', icon: AlertTriangle, keywords: ['alert', 'system', '告警'] },
{ path: '/alerts/bgp', label: 'BGP 告警', group: 'alerts', icon: Network, keywords: ['alert', 'bgp', '风险'] }, { path: '/alerts/bgp', label: 'BGP 告警', labelKey: 'admin.routes.bgpAlerts', group: 'alerts', icon: Network, keywords: ['alert', 'bgp', '风险'] },
{ path: '/alerts/situational', label: '态势告警', group: 'alerts', icon: Globe2, keywords: ['situational', '态势', '研判'] }, { path: '/alerts/situational', label: '态势告警', labelKey: 'admin.routes.situationalAlerts', group: 'alerts', icon: Globe2, keywords: ['situational', '态势', '研判'] },
{ path: '/ai', label: 'AI', group: 'ops', icon: Bot, keywords: ['ai', 'provider', 'playground', 'prompt'] }, { path: '/ai', label: 'AI', labelKey: 'admin.routes.ai', group: 'ops', icon: Bot, keywords: ['ai', 'provider', 'playground', 'prompt'] },
{ path: '/earth-content', label: '智能星球内容', group: 'ops', icon: Globe2, keywords: ['earth', '地球', '智能星球', 'tv', 'boundary', 'brand', 'news', 'rss', '新闻源'] }, { path: '/earth-content', label: '智能星球内容', labelKey: 'admin.routes.earthContent', group: 'ops', icon: Globe2, keywords: ['earth', '地球', '智能星球', 'tv', 'boundary', 'brand', 'news', 'rss', '新闻源'] },
{ path: '/collection-management', label: '采集管理', group: 'ops', icon: Database, keywords: ['collector', 'mapping', 'custom source'] }, { path: '/collection-management', label: '采集管理', labelKey: 'admin.routes.collectionManagement', group: 'ops', icon: Database, keywords: ['collector', 'mapping', 'custom source'] },
{ path: '/logs', label: '系统日志', group: 'ops', icon: FileText, keywords: ['log', '日志', 'tail'], superAdminOnly: true }, { path: '/logs', label: '系统日志', labelKey: 'admin.routes.logs', group: 'ops', icon: FileText, keywords: ['log', '日志', 'tail'], superAdminOnly: true },
{ path: '/users', label: '用户管理', group: 'ops', icon: Users, keywords: ['users', 'role', 'gatekeeper'] }, { path: '/users', label: '用户管理', labelKey: 'admin.routes.users', group: 'ops', icon: Users, keywords: ['users', 'role', 'gatekeeper'] },
{ path: '/settings', label: '系统设置', group: 'ops', icon: Settings, keywords: ['settings', 'smtp', 'security'] }, { path: '/settings', label: '系统设置', labelKey: 'admin.routes.settings', group: 'ops', icon: Settings, keywords: ['settings', 'smtp', 'security'] },
] ]
export function getVisibleAdminRoutes(isSuperAdmin: boolean) { export function getVisibleAdminRoutes(isSuperAdmin: boolean) {

View File

@@ -1,4 +1,5 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react' import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
import { useLocation, useNavigate } from 'react-router-dom' import { useLocation, useNavigate } from 'react-router-dom'
import { useAuthStore } from '../../stores/auth' import { useAuthStore } from '../../stores/auth'
import { buildDynamicAdminTargets, buildStaticAdminTargets, searchAdminTargets } from './indexers' import { buildDynamicAdminTargets, buildStaticAdminTargets, searchAdminTargets } from './indexers'
@@ -26,13 +27,14 @@ function targetSearchParams(target: AdminSearchTarget) {
export function AdminSearchProvider({ children }: { children: ReactNode }) { export function AdminSearchProvider({ children }: { children: ReactNode }) {
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation() const location = useLocation()
const { i18n } = useTranslation()
const { user } = useAuthStore() const { user } = useAuthStore()
const isSuperAdmin = user?.role === 'super_admin' const isSuperAdmin = user?.role === 'super_admin'
const [dynamicTargets, setDynamicTargets] = useState<AdminSearchTarget[]>([]) const [dynamicTargets, setDynamicTargets] = useState<AdminSearchTarget[]>([])
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const loadedRef = useRef(false) const loadedRef = useRef(false)
const loadingRef = useRef<Promise<void> | null>(null) const loadingRef = useRef<Promise<void> | null>(null)
const staticTargets = useMemo(() => buildStaticAdminTargets(isSuperAdmin), [isSuperAdmin]) const staticTargets = useMemo(() => buildStaticAdminTargets(isSuperAdmin), [i18n.language, isSuperAdmin])
const targets = useMemo(() => { const targets = useMemo(() => {
const byId = new Map<string, AdminSearchTarget>() const byId = new Map<string, AdminSearchTarget>()
staticTargets.forEach((target) => byId.set(target.id, target)) staticTargets.forEach((target) => byId.set(target.id, target))
@@ -44,7 +46,7 @@ export function AdminSearchProvider({ children }: { children: ReactNode }) {
loadedRef.current = false loadedRef.current = false
loadingRef.current = null loadingRef.current = null
setDynamicTargets([]) setDynamicTargets([])
}, [isSuperAdmin]) }, [i18n.language, isSuperAdmin])
const ensureDynamicIndex = useCallback(async (query: string) => { const ensureDynamicIndex = useCallback(async (query: string) => {
if (query.trim().length < 2 || loadedRef.current) return if (query.trim().length < 2 || loadedRef.current) return

View File

@@ -14,6 +14,7 @@ import {
ShieldAlert, ShieldAlert,
Users, Users,
} from 'lucide-react' } from 'lucide-react'
import i18n from '../../i18n'
import { adminRoutes, getVisibleAdminRoutes } from '../routes/manifest' import { adminRoutes, getVisibleAdminRoutes } from '../routes/manifest'
import type { AdminSearchTarget } from './types' import type { AdminSearchTarget } from './types'
@@ -50,11 +51,68 @@ function targetId(parts: Array<string | undefined>) {
return parts.filter(Boolean).join(':') return parts.filter(Boolean).join(':')
} }
const labelKeys: Record<string, string> = {
'AI': 'admin.routes.ai',
'BGP观测': 'admin.routes.bgp',
'BGP': 'admin.sections.bgpOverview',
'BGP 事故': 'admin.sections.alerts',
'BGP 告警': 'admin.routes.bgpAlerts',
'Playground': 'admin.sections.aiPlayground',
'SMTP 邮件': 'admin.sections.smtp',
'工具调用': 'admin.sections.aiTools',
'提示词': 'admin.sections.aiPrompts',
'日志': 'admin.routes.logs',
'日志源': 'admin.sections.logsSources',
'智能星球内容': 'admin.routes.earthContent',
'模型供应商': 'admin.sections.aiIntegrations',
'模型预设': 'admin.sections.aiIntegrations',
'电视直播': 'admin.sections.tv',
'系统告警': 'admin.routes.systemAlerts',
'系统显示': 'admin.sections.settingsSystem',
'系统设置': 'admin.routes.settings',
'采集历史': 'admin.sections.collectionHistory',
'采集历史 / 快照': 'admin.sections.collectionHistory',
'采集器': 'admin.sections.collectorCredentials',
'采集数据': 'admin.routes.data',
'采集管理': 'admin.routes.collectionManagement',
'采集调度': 'admin.sections.collectors',
'数据源': 'admin.routes.datasources',
'用户': 'admin.routes.users',
'用户管理': 'admin.routes.users',
'告警记录': 'admin.sections.alerts',
'国界精度': 'admin.sections.earthAssets',
'品牌标识': 'admin.sections.earthBrand',
'态势告警': 'admin.routes.situationalAlerts',
'通知策略': 'admin.sections.notifications',
'安全策略': 'admin.sections.security',
'新闻源': 'admin.sections.newsSources',
'页面': 'admin.search.pageContext',
}
function translateLabel(label: string | undefined): string | undefined {
if (!label) return label
const key = labelKeys[label]
return key ? i18n.t(key) : label
}
function makeTarget(target: Omit<AdminSearchTarget, 'id'> & { id?: string }): AdminSearchTarget { function makeTarget(target: Omit<AdminSearchTarget, 'id'> & { id?: string }): AdminSearchTarget {
const routeLabel = translateLabel(target.routeLabel) || target.routeLabel
const sectionLabel = translateLabel(target.sectionLabel) || target.sectionLabel
const label = translateLabel(target.label) || target.label
const contextLabel = translateLabel(target.contextLabel) || target.contextLabel
return { return {
...target, ...target,
contextLabel,
id: target.id || targetId([target.routePath, target.sectionKey, target.groupKey, target.fieldKey, target.label]), id: target.id || targetId([target.routePath, target.sectionKey, target.groupKey, target.fieldKey, target.label]),
label,
routeLabel,
sectionLabel,
terms: Array.from(new Set([ terms: Array.from(new Set([
label,
routeLabel,
sectionLabel,
contextLabel,
target.routeLabel, target.routeLabel,
target.sectionLabel, target.sectionLabel,
target.contextLabel, target.contextLabel,
@@ -153,9 +211,9 @@ export function buildStaticAdminTargets(isSuperAdmin: boolean): AdminSearchTarge
.filter((route) => visiblePaths.has(route.path)) .filter((route) => visiblePaths.has(route.path))
.map((route) => makeTarget({ .map((route) => makeTarget({
routePath: route.path, routePath: route.path,
routeLabel: route.label, routeLabel: i18n.t(route.labelKey),
label: route.label, label: i18n.t(route.labelKey),
contextLabel: '页面', contextLabel: i18n.t('admin.search.pageContext'),
terms: route.keywords, terms: route.keywords,
icon: route.icon, icon: route.icon,
})) }))

View File

@@ -1,4 +1,7 @@
.admin-theme-root { .admin-theme-root {
min-height: 0;
height: 100%;
overflow: hidden;
--an-page-padding: 16px; --an-page-padding: 16px;
--an-section-gap: 16px; --an-section-gap: 16px;
--an-panel-gap: 12px; --an-panel-gap: 12px;
@@ -128,7 +131,10 @@ body:has(.admin-theme-root[data-theme='dark']) .an-toast {
.admin__sider { .admin__sider {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: 0;
min-width: 0; min-width: 0;
height: 100%;
overflow: hidden;
background: var(--an-surface); background: var(--an-surface);
border-right: 1px solid var(--an-border); border-right: 1px solid var(--an-border);
} }
@@ -171,8 +177,9 @@ body:has(.admin-theme-root[data-theme='dark']) .an-toast {
} }
.admin__nav-scroll { .admin__nav-scroll {
flex: 1; flex: 1 1 auto;
min-height: 0; min-height: 0;
overflow: hidden;
} }
.admin__nav { .admin__nav {
@@ -237,10 +244,12 @@ body:has(.admin-theme-root[data-theme='dark']) .an-toast {
} }
.admin__account { .admin__account {
flex: 0 0 auto;
border-top: 1px solid var(--an-border); border-top: 1px solid var(--an-border);
padding: 12px; padding: 12px;
background: color-mix(in srgb, var(--an-bg) 42%, var(--an-surface));
display: grid; display: grid;
gap: 9px; gap: 0;
} }
.admin__account-row { .admin__account-row {
@@ -251,21 +260,93 @@ body:has(.admin-theme-root[data-theme='dark']) .an-toast {
font-size: 12px; font-size: 12px;
} }
.admin__account-row--primary {
min-height: 34px;
}
.admin__account-row > div { .admin__account-row > div {
min-width: 0; min-width: 0;
display: grid; display: grid;
gap: 2px; gap: 2px;
} }
.admin__account-profile {
display: flex !important;
grid-template-columns: none;
align-items: center;
min-width: 0;
flex: 1 1 auto;
margin-right: 6px;
}
.admin__account-row .admin__account-profile {
gap: 18px;
}
.admin__account-profile > div {
min-width: 0;
display: grid;
gap: 1px;
}
.admin__account-avatar {
flex: 0 0 30px;
width: 30px;
height: 30px;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--an-accent);
color: #ffffff;
font-size: 12px;
font-weight: 800;
box-shadow: 0 6px 16px color-mix(in srgb, var(--an-accent) 22%, transparent);
}
.admin__account-row .admin__account-avatar {
color: #ffffff;
}
.admin__account-row strong { .admin__account-row strong {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.admin__account-logout { .admin__account-actions {
display: flex !important;
grid-template-columns: none;
align-items: center;
justify-content: flex-end;
flex: 0 0 auto;
gap: 4px;
}
.admin__account-logout,
.admin__account-preferences {
width: 28px; width: 28px;
height: 28px; height: 28px;
}
.admin__account-preferences {
color: var(--an-muted);
}
.admin__account-preferences svg {
transition: color 0.18s ease, transform 0.28s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.admin__account-preferences.is-active {
color: var(--an-accent);
background: color-mix(in srgb, var(--an-accent) 10%, var(--an-surface));
}
.admin__account-preferences.is-active svg {
transform: rotate(90deg);
}
.admin__account-logout {
color: var(--an-danger); color: var(--an-danger);
} }
@@ -277,11 +358,46 @@ body:has(.admin-theme-root[data-theme='dark']) .an-toast {
width: 100%; width: 100%;
} }
.admin__preferences-drawer {
max-height: 0;
overflow: hidden;
opacity: 0;
visibility: hidden;
transform: translateY(-5px);
transition:
max-height 0.24s ease,
opacity 0.18s ease,
transform 0.24s ease,
visibility 0s linear 0.24s;
}
.admin__preferences-drawer.is-open {
max-height: 146px;
opacity: 1;
visibility: visible;
transform: translateY(0);
transition:
max-height 0.28s ease,
opacity 0.18s ease,
transform 0.28s ease,
visibility 0s;
}
.admin__preferences-panel {
margin-top: 12px;
padding: 12px 14px;
border-top: 1px solid color-mix(in srgb, var(--an-border) 78%, transparent);
border-radius: 8px;
background: color-mix(in srgb, var(--an-bg) 62%, var(--an-surface));
display: grid;
gap: 8px;
}
.admin__theme-control--sider { .admin__theme-control--sider {
--segmented-control-radius: 8px; --segmented-control-radius: 8px;
--segmented-control-slider-radius: 6px; --segmented-control-slider-radius: 6px;
--segmented-control-button-gap: 0; --segmented-control-button-gap: 0;
--segmented-control-icon-size: calc(17px * var(--segmented-control-scale, 1)); --segmented-control-icon-size: 13px;
} }
.admin__theme-control--sider .segmented-control__button { .admin__theme-control--sider .segmented-control__button {
@@ -309,6 +425,21 @@ body:has(.admin-theme-root[data-theme='dark']) .an-toast {
color: #64748b; color: #64748b;
} }
.admin__language-control--sider {
width: 100%;
--segmented-control-radius: 8px;
--segmented-control-slider-radius: 6px;
--segmented-control-button-gap: 0;
--segmented-control-font-size: calc(10px * var(--segmented-control-scale, 1));
--segmented-control-font-weight: 800;
}
.admin__language-control--sider .segmented-control__button {
font-size: calc(10px * var(--segmented-control-scale, 1));
font-weight: 800;
line-height: 1;
}
.admin__logout { .admin__logout {
justify-content: center; justify-content: center;
} }
@@ -597,9 +728,11 @@ body:has(.admin-theme-root[data-theme='dark']) .an-toast {
.admin__content { .admin__content {
min-width: 0; min-width: 0;
min-height: 0;
height: 100%; height: 100%;
display: grid; display: grid;
grid-template-rows: 48px minmax(0, 1fr); grid-template-rows: 48px minmax(0, 1fr);
overflow: hidden;
} }
.admin__topbar { .admin__topbar {
@@ -652,6 +785,25 @@ body:has(.admin-theme-root[data-theme='dark']) .an-toast {
color: var(--an-muted); color: var(--an-muted);
} }
.admin__language-control {
width: 112px;
--segmented-control-radius: 8px;
--segmented-control-slider-radius: 6px;
--segmented-control-button-gap: 0;
}
.admin__language-control .segmented-control__button {
flex-direction: row;
}
.admin__language-control .segmented-control__icon {
display: none;
}
.admin__language-control.admin__language-control--sider {
width: 100%;
}
.admin__search-results { .admin__search-results {
position: absolute; position: absolute;
top: calc(100% + 6px); top: calc(100% + 6px);
@@ -754,6 +906,7 @@ body:has(.admin-theme-root[data-theme='dark']) .an-toast {
.admin__content-inner { .admin__content-inner {
min-width: 0; min-width: 0;
min-height: 0; min-height: 0;
height: 100%;
overflow: hidden; overflow: hidden;
padding: var(--an-page-padding); padding: var(--an-page-padding);
} }
@@ -1633,7 +1786,7 @@ body:has(.admin-theme-root[data-theme='dark']) .an-toast {
color: var(--an-text); color: var(--an-text);
padding: 8px 10px; padding: 8px 10px;
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) 104px; grid-template-columns: minmax(0, 1fr) max-content;
align-items: start; align-items: start;
gap: 10px; gap: 10px;
text-align: left; text-align: left;
@@ -1683,19 +1836,22 @@ body:has(.admin-theme-root[data-theme='dark']) .an-toast {
} }
.an-hierarchy-group__meta { .an-hierarchy-group__meta {
width: 104px; width: max-content;
max-width: 104px; max-width: none;
min-width: max-content;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: flex-end; justify-content: flex-end;
gap: 5px; gap: 5px;
overflow: hidden; justify-self: end;
overflow: visible;
} }
.an-hierarchy-group__meta .an-status-pill { .an-hierarchy-group__meta .an-status-pill {
flex: 0 0 74px; flex: 0 0 auto;
width: 74px; width: auto;
max-width: 74px; min-width: max-content;
max-width: none;
} }
.an-hierarchy-group__meta em { .an-hierarchy-group__meta em {

View File

@@ -1,5 +1,6 @@
import { Check, Copy } from 'lucide-react' import { Check, Copy } from 'lucide-react'
import { memo, useEffect, useId, useRef, useState } from 'react' import { memo, useEffect, useId, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { PointerEvent as ReactPointerEvent, ReactNode, WheelEvent as ReactWheelEvent } from 'react' import type { PointerEvent as ReactPointerEvent, ReactNode, WheelEvent as ReactWheelEvent } from 'react'
import Scrollbar from '../Scrollbar/Scrollbar' import Scrollbar from '../Scrollbar/Scrollbar'
@@ -167,6 +168,7 @@ function isMermaidTextTarget(target: EventTarget | null): boolean {
} }
function MarkdownCodeBlock({ code, language }: { code: string; language?: string }) { function MarkdownCodeBlock({ code, language }: { code: string; language?: string }) {
const { t } = useTranslation()
const [copied, setCopied] = useState(false) const [copied, setCopied] = useState(false)
const label = language?.trim() || 'text' const label = language?.trim() || 'text'
const codeClassName = language const codeClassName = language
@@ -187,8 +189,8 @@ function MarkdownCodeBlock({ code, language }: { code: string; language?: string
type="button" type="button"
className="markdown-renderer__code-copy" className="markdown-renderer__code-copy"
onClick={handleCopy} onClick={handleCopy}
aria-label={copied ? '已复制代码' : '复制代码'} aria-label={copied ? t('markdown.copiedCode') : t('markdown.copyCode')}
title={copied ? '已复制' : '复制代码'} title={copied ? t('markdown.copied') : t('markdown.copyCode')}
> >
{copied ? <Check size={14} /> : <Copy size={14} />} {copied ? <Check size={14} /> : <Copy size={14} />}
</button> </button>
@@ -201,6 +203,7 @@ function MarkdownCodeBlock({ code, language }: { code: string; language?: string
} }
function MarkdownMermaidBlock({ code }: { code: string }) { function MarkdownMermaidBlock({ code }: { code: string }) {
const { t } = useTranslation()
const [copied, setCopied] = useState(false) const [copied, setCopied] = useState(false)
const [svg, setSvg] = useState('') const [svg, setSvg] = useState('')
const [error, setError] = useState('') const [error, setError] = useState('')
@@ -266,7 +269,7 @@ function MarkdownMermaidBlock({ code }: { code: string }) {
} catch (renderError) { } catch (renderError) {
if (!cancelled) { if (!cancelled) {
setSvg('') setSvg('')
setError(renderError instanceof Error ? renderError.message : 'Mermaid 渲染失败') setError(renderError instanceof Error ? renderError.message : t('markdown.mermaidRenderFailed'))
} }
} }
} }
@@ -276,7 +279,7 @@ function MarkdownMermaidBlock({ code }: { code: string }) {
return () => { return () => {
cancelled = true cancelled = true
} }
}, [blockId, code, themeMode]) }, [blockId, code, t, themeMode])
const handleCopy = async () => { const handleCopy = async () => {
await copyToClipboard(code) await copyToClipboard(code)
@@ -339,8 +342,8 @@ function MarkdownMermaidBlock({ code }: { code: string }) {
type="button" type="button"
className="markdown-renderer__code-copy" className="markdown-renderer__code-copy"
onClick={handleCopy} onClick={handleCopy}
aria-label={copied ? '已复制图表源码' : '复制图表源码'} aria-label={copied ? t('markdown.copiedChartSource') : t('markdown.copyChartSource')}
title={copied ? '已复制' : '复制图表源码'} title={copied ? t('markdown.copied') : t('markdown.copyChartSource')}
> >
{copied ? <Check size={14} /> : <Copy size={14} />} {copied ? <Check size={14} /> : <Copy size={14} />}
</button> </button>
@@ -361,8 +364,8 @@ function MarkdownMermaidBlock({ code }: { code: string }) {
openExpanded() openExpanded()
} }
}} }}
aria-label="放大查看 Mermaid 图表" aria-label={t('markdown.expandMermaid')}
title="点击放大查看" title={t('markdown.clickToExpand')}
> >
<span className="markdown-renderer__mermaid-canvas-inner" dangerouslySetInnerHTML={{ __html: svg }} /> <span className="markdown-renderer__mermaid-canvas-inner" dangerouslySetInnerHTML={{ __html: svg }} />
</div> </div>
@@ -381,15 +384,15 @@ function MarkdownMermaidBlock({ code }: { code: string }) {
className="markdown-renderer__mermaid-viewer" className="markdown-renderer__mermaid-viewer"
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-label="Mermaid 图表查看器" aria-label={t('markdown.mermaidViewer')}
onClick={closeExpanded} onClick={closeExpanded}
> >
<button <button
type="button" type="button"
className="markdown-renderer__mermaid-viewer-close" className="markdown-renderer__mermaid-viewer-close"
onClick={closeExpanded} onClick={closeExpanded}
aria-label="关闭 Mermaid 图表查看器" aria-label={t('markdown.closeMermaid')}
title="关闭" title={t('common.close')}
> >
× ×
</button> </button>
@@ -411,7 +414,7 @@ function MarkdownMermaidBlock({ code }: { code: string }) {
/> />
</div> </div>
<div className="markdown-renderer__mermaid-viewer-hint"> <div className="markdown-renderer__mermaid-viewer-hint">
· · {t('markdown.viewerHint')}
</div> </div>
</div> </div>
) : null} ) : null}

View File

@@ -0,0 +1,159 @@
import { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { legacyUiTextEnUS } from './legacy-ui'
import { normalizeLocale } from './locale'
const attributeNames = ['aria-label', 'placeholder', 'title']
const selector = '.admin-theme-root, .auth-shell'
const reverseLegacyUiText = Object.fromEntries(
Object.entries(legacyUiTextEnUS).map(([source, target]) => [target, source]),
)
type LegacyTextPattern = {
match: RegExp
replace: (match: RegExpMatchArray) => string
}
const legacyTextPatternsEnUS: LegacyTextPattern[] = [
{ match: /^结果\s+(.+)\s+条$/, replace: (match) => `Results ${match[1]}` },
{ match: /^筛选\s+(.+)\s+项$/, replace: (match) => `${match[1]} filters` },
{ match: /^共\s+(.+)\s+条结果$/, replace: (match) => `${match[1]} results` },
{ match: /^(.+)\s+条新闻。$/, replace: (match) => `${match[1]} news items.` },
{ match: /^(.+)\s+个历史快照,选择后查看该版本详情。$/, replace: (match) => `${match[1]} historical snapshots. Select one to view that version.` },
{ match: /^(.+)\s+字段$/, replace: (match) => `${match[1]} fields` },
{ match: /^(.+)\s+个源 \/ (.+)\s+个类型$/, replace: (match) => `${match[1]} sources / ${match[2]} categories` },
{ match: /^(.+)\s+个来源$/, replace: (match) => `${match[1]} sources` },
{ match: /^(.+)\s+个聚合项$/, replace: (match) => `${match[1]} aggregations` },
{ match: /^(.+)\s+条$/, replace: (match) => `${match[1]} items` },
{ match: /^(.+)\s+行$/, replace: (match) => `${match[1]} lines` },
{ match: /^(.+)\s+次$/, replace: (match) => `${match[1]} times` },
{ match: /^最后更新:\s*(.+)$/, replace: (match) => `Last updated: ${match[1]}` },
{ match: /^任务已创建:\s*(.+)$/, replace: (match) => `Task created: ${match[1]}` },
{ match: /^执行命令\s+(.+)$/, replace: (match) => `Command ${match[1]}` },
{ match: /^任务 ID\s+(.+)$/, replace: (match) => `Task ID ${match[1]}` },
{ match: /^触发已选\s+(.+)$/, replace: (match) => `Trigger selected ${match[1]}` },
{ match: /^新闻直播源\s+(.+)$/, replace: (match) => `News stream source ${match[1]}` },
{ match: /^新增新闻源\s+(.+)$/, replace: (match) => `New news source ${match[1]}` },
{ match: /^最终指标:(.+)$/, replace: (match) => `Final metric: ${match[1]}` },
{ match: /^指纹\s+(.+)$/, replace: (match) => `Fingerprint ${match[1]}` },
{ match: /^首次\s+(.+)\s+·\s+最近\s+(.+)$/, replace: (match) => `First ${match[1]} · Latest ${match[2]}` },
{ match: /^已导出\s+(.+)$/, replace: (match) => `Exported ${match[1]}` },
{ match: /^(.+)\s+采集失败$/, replace: (match) => `${match[1]} collection failed` },
{ match: /^(.+)\s+采集已取消$/, replace: (match) => `${match[1]} collection cancelled` },
]
const legacyTextPatternsZhCN: LegacyTextPattern[] = [
{ match: /^Results\s+(.+)$/, replace: (match) => `结果 ${match[1]}` },
{ match: /^(.+)\s+filters$/, replace: (match) => `筛选 ${match[1]}` },
{ match: /^(.+)\s+results$/, replace: (match) => `${match[1]} 条结果` },
{ match: /^(.+)\s+news items\.$/, replace: (match) => `${match[1]} 条新闻。` },
{ match: /^(.+)\s+historical snapshots\. Select one to view that version\.$/, replace: (match) => `${match[1]} 个历史快照,选择后查看该版本详情。` },
{ match: /^(.+)\s+fields$/, replace: (match) => `${match[1]} 字段` },
{ match: /^(.+)\s+sources \/ (.+)\s+categories$/, replace: (match) => `${match[1]} 个源 / ${match[2]} 个类型` },
{ match: /^(.+)\s+sources$/, replace: (match) => `${match[1]} 个来源` },
{ match: /^(.+)\s+aggregations$/, replace: (match) => `${match[1]} 个聚合项` },
{ match: /^(.+)\s+items$/, replace: (match) => `${match[1]}` },
{ match: /^(.+)\s+lines$/, replace: (match) => `${match[1]}` },
{ match: /^(.+)\s+times$/, replace: (match) => `${match[1]}` },
{ match: /^Last updated:\s*(.+)$/, replace: (match) => `最后更新: ${match[1]}` },
{ match: /^Task created:\s*(.+)$/, replace: (match) => `任务已创建: ${match[1]}` },
{ match: /^Command\s+(.+)$/, replace: (match) => `执行命令 ${match[1]}` },
{ match: /^Task ID\s+(.+)$/, replace: (match) => `任务 ID ${match[1]}` },
{ match: /^Trigger selected\s+(.+)$/, replace: (match) => `触发已选 ${match[1]}` },
{ match: /^News stream source\s+(.+)$/, replace: (match) => `新闻直播源 ${match[1]}` },
{ match: /^New news source\s+(.+)$/, replace: (match) => `新增新闻源 ${match[1]}` },
{ match: /^Final metric:\s*(.+)$/, replace: (match) => `最终指标:${match[1]}` },
{ match: /^Fingerprint\s+(.+)$/, replace: (match) => `指纹 ${match[1]}` },
{ match: /^First\s+(.+)\s+·\s+Latest\s+(.+)$/, replace: (match) => `首次 ${match[1]} · 最近 ${match[2]}` },
{ match: /^Exported\s+(.+)$/, replace: (match) => `已导出 ${match[1]}` },
{ match: /^(.+)\s+collection failed$/, replace: (match) => `${match[1]} 采集失败` },
{ match: /^(.+)\s+collection cancelled$/, replace: (match) => `${match[1]} 采集已取消` },
]
function preserveOuterWhitespace(source: string, replacement: string) {
const leading = source.match(/^\s*/)?.[0] || ''
const trailing = source.match(/\s*$/)?.[0] || ''
return `${leading}${replacement}${trailing}`
}
function translatePatternText(value: string, locale: string) {
const text = value.trim()
if (!text || text.length > 160) return value
const patterns = normalizeLocale(locale) === 'en-US' ? legacyTextPatternsEnUS : legacyTextPatternsZhCN
for (const pattern of patterns) {
const matched = text.match(pattern.match)
if (matched) return preserveOuterWhitespace(value, pattern.replace(matched))
}
return value
}
function translateText(value: string, locale: string) {
const text = value.trim()
if (!text) return value
const dictionary = normalizeLocale(locale) === 'en-US' ? legacyUiTextEnUS : reverseLegacyUiText
const replacement = dictionary[text]
if (replacement) return preserveOuterWhitespace(value, replacement)
return translatePatternText(value, locale)
}
function translateElementAttributes(element: Element, locale: string) {
attributeNames.forEach((attributeName) => {
const value = element.getAttribute(attributeName)
if (!value) return
const translated = translateText(value, locale)
if (translated !== value) element.setAttribute(attributeName, translated)
})
}
function translateNodeText(root: Element, locale: string) {
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT)
let node = walker.nextNode()
while (node) {
const value = node.textContent || ''
const translated = translateText(value, locale)
if (translated !== value) node.textContent = translated
node = walker.nextNode()
}
}
function translateRoot(root: Element, locale: string) {
translateElementAttributes(root, locale)
root.querySelectorAll('*').forEach((element) => translateElementAttributes(element, locale))
translateNodeText(root, locale)
}
export default function LegacyI18nBridge() {
const { i18n } = useTranslation()
const locale = normalizeLocale(i18n.resolvedLanguage || i18n.language)
useEffect(() => {
let frameId = 0
const translate = () => {
document.querySelectorAll(selector).forEach((root) => translateRoot(root, locale))
}
const scheduleTranslate = () => {
window.cancelAnimationFrame(frameId)
frameId = window.requestAnimationFrame(translate)
}
scheduleTranslate()
const observer = new MutationObserver(scheduleTranslate)
if (document.body) {
observer.observe(document.body, {
attributes: true,
attributeFilter: attributeNames,
characterData: true,
childList: true,
subtree: true,
})
}
return () => {
window.cancelAnimationFrame(frameId)
observer.disconnect()
}
}, [locale])
return null
}

View File

@@ -0,0 +1,27 @@
import i18n from 'i18next'
import { initReactI18next } from 'react-i18next'
import { readStoredLocale, syncDocumentLocale } from './locale'
import { resources } from './resources'
const initialLocale = readStoredLocale()
syncDocumentLocale(initialLocale)
void i18n
.use(initReactI18next)
.init({
fallbackLng: 'zh-CN',
interpolation: {
escapeValue: false,
},
lng: initialLocale,
resources,
returnEmptyString: false,
})
i18n.on('languageChanged', (locale) => {
syncDocumentLocale(locale === 'en-US' ? 'en-US' : 'zh-CN')
})
export default i18n

View File

@@ -0,0 +1,589 @@
export const legacyUiTextEnUS: Record<string, string> = {
'3D 模型': '3D models',
'AI 生成教程': 'AI-generated guide',
'AI 分区': 'AI sections',
'AI 配置详情': 'AI configuration details',
'AI 集成': 'AI integrations',
'AI 简报': 'AI brief',
'BGP 事故': 'BGP incident',
'BGP 事件与简报': 'BGP events and briefs',
'BGP 告警列表': 'BGP alert list',
'BGP 告警详情': 'BGP alert details',
'BGP 异常': 'BGP anomaly',
'BGP 概览': 'BGP overview',
'BGP 简报': 'BGP brief',
'BGP 详情': 'BGP details',
'BGP 观测': 'BGP Observatory',
'BGP 告警': 'BGP alert',
'BGP观测': 'BGP Observatory',
'Feed 信息页': 'Feed info page',
'Feed 地址': 'Feed URL',
'Feed 子项': 'Feed entries',
'Feed 标签': 'Feed tags',
'Feed 类型': 'Feed type',
'Feed 名称': 'Feed name',
'Feed ID': 'Feed ID',
'Gatekeeper 权限组': 'Gatekeeper groups',
'RSS 来源': 'RSS source',
'RSS 来源只读,新闻由抓取与增强链路维护。': 'RSS sources are read-only. News is maintained by the collection and enrichment pipeline.',
'RSS 订阅中心或 Feed 聚合页,只用于人工查看,不参与抓取。': 'RSS directory or feed aggregation page for manual review only. It is not fetched.',
'SMTP 邮件': 'SMTP email',
'System Prompt': 'System prompt',
'Time Capsule': 'Time Capsule',
'Web Search 预设': 'Web Search presets',
'不可用': 'Unavailable',
'事故': 'Incident',
'交互正常': 'Interactive',
'个来源': 'sources',
'个聚合项': 'aggregations',
'任务提示词': 'Task prompt',
'仪表盘': 'Dashboard',
'任务': 'Task',
'任务 ID': 'Task ID',
'任务已取消': 'Task cancelled',
'任务状态': 'Task status',
'今日任务': 'Tasks today',
'供应商': 'Provider',
'供应商状态': 'Provider status',
'供应商配置': 'Provider configuration',
'保存': 'Save',
'保存并重试': 'Save and retry',
'保存后会进入清洗、翻译、分类和定位队列。': 'After saving, the item enters the cleaning, translation, classification, and geocoding queue.',
'保存后才会固化到新闻源配置。': 'Changes are persisted to the news source configuration only after saving.',
'保存新闻': 'Save news item',
'修改邮箱': 'Change email',
'停止': 'Stop',
'停止采集': 'Stop collection',
'停止生成': 'Stop generation',
'停用': 'Disabled',
'关闭': 'Close',
'关于': 'About',
'关于配置': 'About configuration',
'内置源': 'Built-in sources',
'全部区域': 'All regions',
'全部国家/地区': 'All countries / regions',
'全部层级': 'All levels',
'全部级别': 'All levels',
'全部产品域': 'All product domains',
'全部执行状态': 'All execution statuses',
'全部数据源': 'All datasources',
'全部数据状态': 'All data statuses',
'全部源属性': 'All source attributes',
'全部状态': 'All statuses',
'全部类型': 'All types',
'其他': 'Other',
'刷新': 'Refresh',
'刷新当前 Provider 的模型配置': 'Refresh current provider model configuration',
'刷新模型': 'Refresh models',
'刷新模型列表': 'Refresh model list',
'刷新线程': 'Refresh thread',
'分类': 'Category',
'删除 Feed 子项': 'Delete feed entry',
'删除': 'Delete',
'删除失败': 'Delete failed',
'删除完成': 'Deletion complete',
'删除成功': 'Deleted',
'删除已取消': 'Deletion cancelled',
'删除新闻': 'Delete news item',
'删除新闻源': 'Delete news source',
'删除中': 'Deleting',
'删除直播源': 'Delete stream source',
'删除品牌配置': 'Delete brand configuration',
'前往登录': 'Go to login',
'加载中': 'Loading',
'加载日志内容失败': 'Failed to load log content',
'加载日志源失败': 'Failed to load log sources',
'加载重复日志详情失败': 'Failed to load duplicate log details',
'加载重复日志统计失败': 'Failed to load duplicate log stats',
'启动采集': 'Start collection',
'启动实时源': 'Start realtime source',
'启动边界构建': 'Start boundary build',
'启用': 'Enabled',
'启用抓取': 'Enable fetching',
'启用映射': 'Enable mapping',
'启用筛选': 'Active filters',
'告警': 'Alert',
'告警记录': 'Alert records',
'告警统计': 'Alert stats',
'名称': 'Name',
'后台账号': 'Console account',
'回到首页': 'Back to overview',
'国界精度': 'Boundary accuracy',
'国家': 'Country',
'地址': 'Address',
'基础字段': 'Basic fields',
'基础信息': 'Basic information',
'城市': 'City',
'字段': 'Fields',
'安全策略': 'Security policy',
'完成': 'Complete',
'密码': 'Password',
'导航': 'Navigation',
'已加载分区': 'Loaded section',
'已加载分区汇总': 'Loaded section total',
'工具调用': 'Tool calls',
'已保存': 'Saved',
'已取消': 'Cancelled',
'已处理': 'Resolved',
'已提交': 'Submitted',
'已读取': 'Loaded',
'已启用': 'Enabled',
'已定位': 'Located',
'已配置': 'Configured',
'已停止': 'Stopped',
'已停用': 'Disabled',
'已就绪': 'Ready',
'已跳过': 'Skipped',
'已有新闻': 'Existing news',
'已有任务运行': 'Task already running',
'已有账号,去登录': 'Already have an account? Log in',
'开始日期': 'Start date',
'底图资源': 'Basemap assets',
'开启后访问智能星球会直接显示 OOBE 引导,不再要求首次采集条件,也会忽略本机“先浏览”临时跳过。': 'When enabled, Intelligent Planet opens directly into the OOBE guide, skips first-collection requirements, and ignores local temporary browse-first skips.',
'态势告警': 'Situational alerts',
'态势告警列表': 'Situational alert list',
'态势详情': 'Situational details',
'态势统计': 'Situational stats',
'快速访问地球可视化页面': 'Quickly open the Earth visualization page',
'恢复当前表单': 'Restore current form',
'恢复默认关于信息': 'Restore default about information',
'恢复默认配置': 'Restore defaults',
'恢复超时,请手动检查服务状态。': 'Recovery timed out. Please check service status manually.',
'成功': 'Success',
'成功率': 'Success rate',
'手动新闻组': 'Manual news group',
'手动新闻': 'Manual news',
'打开智能星球内容': 'Open Intelligent Planet content',
'打开智能星球': 'Open Intelligent Planet',
'处理告警': 'Resolve alert',
'播放与扩展': 'Playback and extensions',
'接入在线': 'Endpoints online',
'接口在线': 'Endpoints online',
'接口失败': 'Endpoint failed',
'接口请求失败': 'Endpoint request failed',
'提示': 'Info',
'控制台不混入其他配置或假数据。': 'The console does not mix in unrelated configuration or mock data.',
'控制台发生错误,请刷新页面重试。': 'The console encountered an error. Please refresh and try again.',
'控制台渲染错误': 'Console render error',
'拖动调整数据概览宽度': 'Drag to resize data overview',
'提交': 'Submit',
'提交失败': 'Submission failed',
'提交重启任务失败': 'Failed to submit restart task',
'提示词': 'Prompts',
'搜索日志正文': 'Search log text',
'搜索名称、描述、元数据等': 'Search name, description, metadata, and more',
'搜索': 'Search',
'搜索供应商': 'Search provider',
'搜索深度': 'Search depth',
'搜索用户、邮箱、角色': 'Search users, email, or role',
'数据源': 'Datasources',
'数据源列表': 'Datasource list',
'数据源详情': 'Datasource details',
'数据源总数': 'Total datasources',
'数据源状态': 'Datasource status',
'数据概览': 'Data overview',
'数据列表': 'Data list',
'数据类型': 'Data type',
'数据集': 'Dataset',
'数据状态': 'Data status',
'采集数据': 'Collected data',
'采集配置列表': 'Collection configuration list',
'采集器': 'Collectors',
'采集器详情': 'Collector details',
'采集器配置': 'Collector configuration',
'采集快照': 'Collection snapshot',
'采集历史 / 快照': 'Collection history / snapshots',
'采集时间': 'Collected at',
'采集已取消': 'Collection cancelled',
'采集失败': 'Collection failed',
'采集完成': 'Collection complete',
'采集中': 'Collecting',
'采集管理': 'Collection Management',
'采集调度': 'Collection schedule',
'采样': 'Sample',
'采样数据': 'Sample data',
'重新处理': 'Reprocess',
'重新处理新闻': 'Reprocess news item',
'新增 Feed 子项': 'Add feed entry',
'新增新闻': 'Add news item',
'新建': 'Create',
'新增新闻源': 'Add news source',
'新增新闻组': 'Add news group',
'新增采集器配置': 'Add collector configuration',
'新增 Schema 映射': 'Add schema mapping',
'新增直播源': 'Add stream source',
'新闻内容': 'News content',
'新闻条目': 'News items',
'新闻源': 'News sources',
'新闻源 ID 不能为空。': 'News source ID is required.',
'新闻源名称不能为空。': 'News source name is required.',
'新闻源配置': 'News source configuration',
'新闻源详情': 'News source details',
'新闻源测试失败': 'News source test failed',
'新闻组': 'News group',
'新闻类型': 'News category',
'新闻直播源': 'News stream source',
'无权访问': 'Permission required',
'无权限': 'No permission',
'无效': 'Invalid',
'暂无 Feed 子项': 'No feed entries',
'暂无会话': 'No conversation',
'暂无分组': 'No groups',
'暂无快照': 'No snapshots',
'暂无数据': 'No data',
'暂无新闻': 'No news items',
'暂无发生明细': 'No occurrences',
'暂无日志': 'No logs',
'暂无日志内容': 'No log content',
'暂无重复日志': 'No duplicate logs',
'暂无上报': 'No reports',
'暂无摘要': 'No summary',
'日志': 'Logs',
'日志源': 'Log sources',
'日志源不可用': 'Log source unavailable',
'日志详情': 'Log details',
'日志视图': 'Log views',
'日志跟随连接失败,可暂停后使用手动刷新。': 'Log follow connection failed. Pause it and refresh manually.',
'日志已复制': 'Logs copied',
'明细': 'Details',
'是否启用': 'Enabled',
'实时同步中': 'Syncing live',
'实时连接': 'Live connection',
'旧密码': 'Old password',
'映射模板': 'Mapping templates',
'映射预览': 'Mapping preview',
'显示名称': 'Display name',
'显示 LLM API Key / Service Token': 'Show LLM API Key / Service Token',
'显示': 'Display',
'智能星球': 'Intelligent Planet',
'智能星球内容配置': 'Intelligent Planet content configuration',
'智能星球配置详情': 'Intelligent Planet configuration details',
'智能星球内容': 'Planet Content',
'未知': 'Unknown',
'未配置': 'Not configured',
'未启用': 'Not enabled',
'未测试': 'Untested',
'未选择记录': 'No record selected',
'查看日志': 'View logs',
'最近': 'Latest',
'最后更新:': 'Last updated:',
'最大 Token': 'Max tokens',
'最大并发任务数': 'Max concurrent tasks',
'最大登录尝试次数': 'Max login attempts',
'最大结果数': 'Max results',
'最大文件(MB)': 'Max file size (MB)',
'标签': 'Tags',
'标题': 'Title',
'模型': 'Model',
'模型供应商': 'Model providers',
'模型预设': 'Model presets',
'清理数据库数据': 'Clear database data',
'清理智能星球图层缓存': 'Clear planet layer cache',
'清理缓存': 'Clear cache',
'测试 Web Search 连通性': 'Test Web Search connectivity',
'测试 AI Provider 连通性': 'Test AI Provider connectivity',
'测试当前 Feed': 'Test current feed',
'测试当前新闻源': 'Test current news source',
'测试收件人': 'Test recipient',
'测试 SMTP': 'Test SMTP',
'状态': 'Status',
'活跃数据源': 'Active datasources',
'海底光缆': 'Submarine cable',
'海缆': 'Cable',
'海缆登陆关系': 'Cable landing relation',
'海缆系统': 'Cable system',
'后端已停止响应,正在等待服务恢复。': 'Backend stopped responding. Waiting for service recovery.',
'源 ID': 'Source ID',
'源名称': 'Source name',
'源属性标签': 'Source attribute tags',
'源类型': 'Source type',
'源配置': 'Source configuration',
'源属性': 'Source attributes',
'源详情': 'Source details',
'源健康': 'Source health',
'区域': 'Region',
'按数据源': 'By datasource',
'按类型': 'By type',
'排序': 'Sort order',
'单条添加': 'Add one item',
'单源配置': 'Single-source configuration',
'上传': 'Upload',
'上传 JSON': 'Upload JSON',
'用户管理': 'User Management',
'电商': 'E-commerce',
'电视直播': 'TV streams',
'直播源': 'Stream sources',
'直播源详情': 'Stream source details',
'目标 Schema': 'Target schema',
'直达': 'Open',
'确认': 'Confirm',
'确认删除': 'Confirm deletion',
'确认告警': 'Confirm alert',
'确认操作': 'Confirm action',
'禁用': 'Disabled',
'空': 'Empty',
'空闲': 'Idle',
'等待中': 'Pending',
'简报': 'Brief',
'结果': 'Results',
'系统告警列表': 'System alert list',
'系统告警': 'System Alerts',
'系统日志': 'System Logs',
'系统显示': 'System display',
'系统设置': 'System Settings',
'系统总览与实时态势': 'System overview and realtime status',
'设置': 'Settings',
'设置详情': 'Settings details',
'设置分区': 'Settings sections',
'记录数': 'Records',
'计算中心': 'Compute center',
'选择 JSON 文件': 'Select JSON file',
'选择一条记录': 'Select a record',
'选择一组重复日志': 'Select a duplicate log group',
'选择左侧父级后编辑它的子配置。': 'Select a parent item on the left to edit its child configuration.',
'选择日志源后读取快照。': 'Select a log source to read its snapshot.',
'选择新闻直播源': 'Select news stream source',
'纬度': 'Latitude',
'经度': 'Longitude',
'统计': 'Stats',
'组内可按条添加,也可以上传 JSON 数组批量导入。': 'You can add items one by one or upload a JSON array for bulk import.',
'编辑': 'Edit',
'编辑新闻': 'Edit news',
'缺失': 'Missing',
'免费': 'Free',
'网络': 'Network',
'自定义': 'Custom',
'自定义源': 'Custom sources',
'自治系统统计': 'Autonomous system stats',
'自动回退': 'Auto fallback',
'英文标题': 'English title',
'英文摘要': 'English summary',
'英文正文': 'English content',
'英文分类': 'English category',
'草稿': 'Draft',
'警告': 'Warning',
'设备统计': 'Device stats',
'触发全部': 'Trigger all',
'触发采集': 'Trigger collection',
'访问智能星球': 'Open Intelligent Planet',
'访问官网': 'Open website',
'详 情': 'Details',
'详情': 'Details',
'详情/统计': 'Details / stats',
'详情会在右侧完整显示,不会挤压主表区域。': 'Details appear in the right pane without compressing the main table.',
'调试': 'Debug',
'请稍后重试': 'Please try again later',
'连接失败': 'Connection failed',
'连接中': 'Connecting',
'连接测试': 'Connection test',
'连通性': 'Connectivity',
'连通正常': 'Connectivity normal',
'连通性失败': 'Connectivity failed',
'运行': 'Run',
'运行中': 'Running',
'运行状态': 'Runtime status',
'运维与配置': 'Operations and Settings',
'过滤': 'Filters',
'跟随中': 'Following',
'跟随日志': 'Follow logs',
'输入': 'Input',
'返回上一级详情': 'Back to parent details',
'返回列表': 'Back to list',
'通知策略': 'Notification policy',
'配置错误': 'Configuration error',
'配置源': 'Configuration source',
'重要度与健康策略': 'Importance and health policy',
'重启': 'Restart',
'重启 AI Provider': 'Restart AI Provider',
'重启后端': 'Restart backend',
'重启服务': 'Restart service',
'重启前端': 'Restart frontend',
'重启数据库': 'Restart database',
'重启动作': 'Restart action',
'重启任务失败': 'Restart task failed',
'重复日志详情': 'Duplicate log details',
'重复日志统计': 'Duplicate log stats',
'重复统计': 'Duplicate stats',
'重启实时源': 'Restart realtime source',
'重置': 'Reset',
'重置 Prompt': 'Reset prompt',
'重置为默认内容': 'Reset to default content',
'重置为默认教程': 'Reset to default guide',
'重置品牌配置': 'Reset brand configuration',
'重试': 'Retry',
'重试次数': 'Retries',
'错误': 'Error',
'覆盖类型': 'Covered types',
'覆盖数据源': 'Covered datasources',
'执行命令': 'Command',
'暂停日志跟随': 'Pause log follow',
'隐藏 LLM API Key / Service Token': 'Hide LLM API Key / Service Token',
'隐藏': 'Hide',
'首页地址': 'Homepage URL',
'主页地址': 'Homepage URL',
'默认新闻类型': 'Default news category',
'默认': 'Default',
'默认教程': 'Default guide',
'默认模型': 'Default model',
'默认频道': 'Default channel',
'高亮命中': 'Highlighted match',
'AIS 船舶': 'AIS vessels',
'BGP 更新': 'BGP updates',
'BGP 路由': 'BGP route',
'BGP 路由表': 'BGP RIB',
'BGP 事件': 'BGP event',
'Docker 不可用': 'Docker unavailable',
'GPU 集群': 'GPU clusters',
'HTTP 失败': 'HTTP failed',
'当前分区没有可用后端能力,控制台不混入其他配置或假数据。': 'This section has no backend capability yet; the console does not mix in unrelated configuration or fake data.',
'当前分区没有可配置项。': 'This section has no configurable items.',
'当前模块暂无数据': 'No data in this module',
'当前已是默认': 'Already default',
'当前已是默认频道': 'Already the default channel',
'当前采集源没有可查看的历史版本。': 'This collection source has no historical versions.',
'待定位': 'Pending location',
'后端能力未提供': 'Backend capability unavailable',
'只展示智能星球品牌、边界构建和电视内容配置。': 'Only Intelligent Planet branding, boundary build, and TV content configuration are shown.',
'只展示数据源相关接口,不混入其他设置对象。': 'Only datasource-related endpoints are shown; unrelated settings are not mixed in.',
'只展示系统设置分区AI 集成和采集器调度分别在对应模块管理。': 'Only system settings sections are shown. AI integrations and collector schedules are managed in their own modules.',
'只展示 BGP 事故、异常与简报。': 'Only BGP incidents, anomalies, and briefs are shown.',
'只记录官网、报告页或未来采集器线索,不参与 RSS/Atom 抓取;如需抓取,请改为 RSS、Atom 或 Aggregated。': 'Records official sites, reports, or future collector leads only. It does not participate in RSS/Atom fetching. Use RSS, Atom, or Aggregated to fetch.',
'只编辑当前新闻源;保存后才会固化到新闻源配置。': 'Only edits the current news source. Save to persist it into the news source configuration.',
'只重启 AI Provider 适配服务,前端页面通常保持在线。': 'Restart only the AI Provider adapter. The frontend usually stays online.',
'只重启后端服务,页面通常会短暂失联后自动恢复。': 'Restart only the backend service. The page may briefly disconnect and recover automatically.',
'只重启前端开发服务,页面会短暂不可用,恢复后自动刷新。': 'Restart only the frontend dev service. The page will be briefly unavailable and refresh after recovery.',
'失败时页面仍可操作': 'Page remains usable when requests fail',
'打开': 'Open',
'描述来源属性,不是媒体来源名;多个标签用逗号分隔,例如 business_news, ecommerce, china。': 'Describe source attributes, not media source names. Separate multiple tags with commas, for example business_news, ecommerce, china.',
'浏览采集结果、筛选数据源和查看原始元数据。': 'Browse collected results, filter datasources, and inspect raw metadata.',
'管理采集器、采集调度和采集历史 / 快照。': 'Manage collectors, collection schedules, and collection history / snapshots.',
'管理智能星球品牌、边界、电视内容和内容资产。': 'Manage Intelligent Planet branding, boundaries, TV content, and content assets.',
'管理模型供应商、工具调用、提示词和 Playground。': 'Manage model providers, tool calls, prompts, and Playground.',
'管理系统显示、通知策略、安全策略和 SMTP 邮件。': 'Manage system display, notification policy, security policy, and SMTP email.',
'统一查看内置源、自定义源、实时源与任务状态,保留触发、启停和连接状态入口。': 'View built-in, custom, and realtime sources plus task status in one place, with trigger, start/stop, and connectivity entries.',
'严重告警': 'Critical alerts',
'查看日志源、读取快照、复制原始输出,按控制台阅读方式组织。': 'View log sources, read snapshots, and copy raw output in a console-friendly layout.',
'查看日志源、按级别/日期/搜索条件读取快照,并复制原始输出。': 'View log sources, read snapshots by level, date, and search filters, then copy raw output.',
'显示系统告警记录和统计,不混入 BGP 概览以外的数据。': 'Shows system alert records and stats without mixing in data outside the BGP overview.',
'显示态势统计与告警记录。': 'Shows situational stats and alert records.',
'查看态势统计、严重度、AI 简报入口和处理状态。': 'View situational stats, severity, AI brief entry points, and handling status.',
'系统告警、确认处理、AI 摘要和处置状态集中到一张低噪声列表。': 'System alerts, acknowledgements, AI summaries, and resolution status are collected into one low-noise list.',
'聚合 BGP 事故、异常和 AI 简报,突出严重度、影响范围和事件链路。': 'Aggregates BGP incidents, anomalies, and AI briefs, highlighting severity, affected scope, and event chains.',
'查看采集器、事故、异常、事件与 AI 简报;这是信息观测页,采用列表加详情。': 'View collectors, incidents, anomalies, events, and AI briefs in an information page with list plus detail.',
'按 BGP 实体聚合展示,保留事件、异常、事故和简报语义。': 'Aggregates by BGP entity while preserving event, anomaly, incident, and brief semantics.',
'配置类页面采用分层结构:先选父级,再编辑子配置。': 'Configuration pages use a hierarchy: select a parent first, then edit child configuration.',
'仅展示系统设置分区AI 集成和采集器调度分别在对应模块管理。': 'Only system setting sections are shown; AI integrations and collector schedules are managed in their own modules.',
'仅展示数据源相关接口,不混入其他设置对象。': 'Only datasource endpoints are shown; unrelated settings objects are not mixed in.',
'仅展示智能星球品牌、边界构建和电视内容配置。': 'Only Intelligent Planet branding, boundary builds, and TV content configuration are shown.',
'读取快照': 'Read snapshot',
'输入要发送给 AI 的内容': 'Enter content to send to AI',
'发送': 'Send',
'会话': 'Conversation',
'会话写入后端,刷新后保留线程状态。': 'Conversation state is stored in the backend and persists after refresh.',
'Playground 设置': 'Playground settings',
'预设': 'Preset',
'目标': 'Objective',
'约束': 'Constraints',
'描述': 'Description',
'优先级': 'Priority',
'边界状态': 'Boundary status',
'边界构建': 'Boundary build',
'边界构建任务': 'Boundary build task',
'品牌': 'Brand',
'品牌标识': 'Branding',
'品牌配置': 'Brand configuration',
'异常接口': 'Failing endpoints',
'图层资源': 'Layer resources',
'采集源': 'Collected sources',
'实时源': 'Realtime sources',
'重复日志': 'Duplicate logs',
'原始日志': 'Raw logs',
'审计事件': 'Audit events',
'审计日志': 'Audit logs',
'审计来源': 'Audit sources',
'原始ID': 'Raw ID',
'原始元数据': 'Raw metadata',
'扩展字段': 'Extended fields',
'参考日期': 'Reference date',
'快捷入口': 'Quick links',
'行': 'lines',
'次': 'times',
'首次': 'First',
'指纹': 'Fingerprint',
'离线': 'Offline',
'等待创建': 'Waiting to create',
'等待操作': 'Waiting for action',
'将重启服务。': 'The service will restart.',
'完全重启': 'Full restart',
'重启 PostgreSQL 和 Redis 容器,前端页面保持在线。': 'Restart the PostgreSQL and Redis containers while the frontend stays online.',
'重启前后端和相关服务,页面会短暂不可用,恢复后自动刷新。': 'Restart frontend, backend, and related services. The page will be briefly unavailable and refresh after recovery.',
'已发送重启指令,正在等待服务进入重启流程。': 'Restart command sent. Waiting for services to enter the restart flow.',
'服务已恢复,正在刷新页面。': 'Service recovered. Refreshing the page.',
'前端已恢复,正在刷新页面。': 'Frontend recovered. Refreshing the page.',
'前端正在重启,正在等待页面入口恢复访问。': 'Frontend is restarting. Waiting for the page entry to recover.',
'获取数据失败': 'Failed to load data',
'最后更新': 'Last updated',
'总记录': 'Total records',
'筛选结果': 'Filtered results',
'清空': 'Clear',
'导出失败': 'Export failed',
'导出 JSON': 'Export JSON',
'导出 CSV': 'Export CSV',
'数据详情': 'Data details',
'按级别/日期/搜索条件读取快照': 'Read snapshots by level, date, and search filters',
'点击左侧聚合项查看每次发生时间。': 'Click an aggregation on the left to view each occurrence time.',
'管理员敏感操作和安全审计记录。': 'Sensitive admin operations and security audit records.',
'当前账号没有系统日志访问权限。': 'This account does not have system log access.',
'仅超级管理员可查看系统日志。': 'Only super admins can view system logs.',
'左侧展示按 fingerprint 聚合后的运行时错误。': 'The left side shows runtime errors grouped by fingerprint.',
'调整筛选条件或刷新日志源。': 'Adjust filters or refresh log sources.',
'复制日志': 'Copy logs',
'刷新日志': 'Refresh logs',
'刷新日志源': 'Refresh log sources',
'结束日期': 'End date',
'信息': 'Info',
'可用': 'Available',
'可读取': 'Readable',
'可编辑': 'Editable',
'只读': 'Read-only',
'暂无日志源': 'No log sources',
'登陆点': 'Landing point',
'算力中心': 'Compute center',
'互联网交换点': 'Internet exchange point',
'前缀地理位置': 'Prefix geography',
'卫星轨道根数': 'Satellite TLE',
'空间': 'Space',
'超算': 'Supercomputer',
'通用数据': 'Generic data',
'通用记录': 'Generic records',
'船舶': 'Vessel',
'设施': 'Facility',
'流量统计': 'Traffic stats',
'条': 'items',
'项': 'items',
'条结果': 'results',
'筛选': 'Filters',
'共': 'Total',
'智能星球计划': 'Intelligent Planet Plan',
'智能星球计划品牌标识': 'Intelligent Planet Plan branding',
'现实层宇宙全息感知系统': 'Reality-layer holographic awareness system',
'卫星 · 海底光缆 · 算力基础设施': 'Satellites · Submarine cables · Computing infrastructure',
'选择/拖入资产': 'Select / drop asset',
'元数据 / 原始字段': 'Metadata / raw fields',
'全部启用状态': 'All enabled states',
'失败': 'Failed',
'未执行': 'Not run',
'已采集': 'Collected',
'未采集': 'Not collected',
'当前分区暂无记录': 'No records in this section',
'切换上方分区可精准查看不同配置和接口。': 'Switch sections above to inspect different configurations and endpoints.',
'详情会在右侧完整滚动显示,不会挤压主表区域。': 'Details scroll fully on the right without compressing the main table.',
'连接、采样、运行和凭证配置': 'Connection, sampling, runtime, and credential configuration',
'采样 payload 到目标 Schema 的字段映射': 'Field mapping from sample payload to target schema',
'采集数据落库目标结构': 'Target schema for persisted collected data',
'标识': 'Identifier',
'更新时间': 'Updated at',
'卫星': 'Satellite',
'算力': 'Compute',
'媒体': 'Media',
}

View File

@@ -0,0 +1,68 @@
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
export type SupportedLocale = 'zh-CN' | 'en-US'
export type DocsLang = 'zh' | 'en'
export const defaultLocale: SupportedLocale = 'zh-CN'
export const localeStorageKey = 'planet-locale'
const legacyDocsLangStorageKey = 'docs-lang'
export const localeOptions: Array<{ value: SupportedLocale; labelKey: string; titleKey: string }> = [
{ value: 'zh-CN', labelKey: 'common.zh', titleKey: 'common.zh' },
{ value: 'en-US', labelKey: 'common.en', titleKey: 'common.en' },
]
export function normalizeLocale(value: string | null | undefined): SupportedLocale {
if (!value) return defaultLocale
const normalized = value.toLowerCase()
if (normalized === 'en' || normalized === 'en-us' || normalized.startsWith('en-')) return 'en-US'
if (normalized === 'zh' || normalized === 'zh-cn' || normalized.startsWith('zh-')) return 'zh-CN'
return defaultLocale
}
export function docsLangFromLocale(locale: SupportedLocale): DocsLang {
return locale === 'en-US' ? 'en' : 'zh'
}
export function localeFromDocsLang(lang: DocsLang): SupportedLocale {
return lang === 'en' ? 'en-US' : 'zh-CN'
}
export function readStoredLocale(): SupportedLocale {
if (typeof window === 'undefined') return defaultLocale
const storedLocale = window.localStorage.getItem(localeStorageKey)
if (storedLocale) return normalizeLocale(storedLocale)
const legacyDocsLang = window.localStorage.getItem(legacyDocsLangStorageKey)
if (legacyDocsLang === 'en' || legacyDocsLang === 'zh') {
return localeFromDocsLang(legacyDocsLang)
}
return defaultLocale
}
export function persistLocale(locale: SupportedLocale) {
if (typeof window === 'undefined') return
window.localStorage.setItem(localeStorageKey, locale)
window.localStorage.setItem(legacyDocsLangStorageKey, docsLangFromLocale(locale))
}
export function syncDocumentLocale(locale: SupportedLocale) {
if (typeof document === 'undefined') return
document.documentElement.lang = locale
}
export function useLocale() {
const { i18n } = useTranslation()
const locale = normalizeLocale(i18n.resolvedLanguage || i18n.language)
const docsLang = docsLangFromLocale(locale)
const setLocale = useCallback((nextLocale: SupportedLocale) => {
persistLocale(nextLocale)
syncDocumentLocale(nextLocale)
void i18n.changeLanguage(nextLocale)
}, [i18n])
return { docsLang, locale, setLocale }
}

View File

@@ -0,0 +1,430 @@
export const zhCN = {
app: {
title: '智能星球计划',
routeLoading: '正在加载',
},
common: {
cancel: '取消',
close: '关闭',
confirm: '确认',
delete: '删除',
language: '语言',
loading: '加载中',
noData: '暂无数据',
operationFailed: '操作失败',
page: '第 {{page}} / {{totalPages}} 页,共 {{total}} 条',
previousPage: '上一页',
nextPage: '下一页',
selectRow: '选择行',
selectVisibleRows: '选择当前可见数据',
theme: '主题',
themeLight: '浅色',
themeDark: '深色',
themeSystem: '系统',
themeFollowSystem: '跟随系统',
zh: '中文',
en: 'EN',
},
admin: {
brandTitle: '智能星球',
brandSubtitle: '控制台',
collapseMenu: '折叠菜单',
expandMenu: '展开菜单',
openNav: '打开导航',
closeNav: '关闭导航',
logout: '退出登录',
greeting: '您好,{{name}}',
version: '版本号',
themeControl: '控制台主题',
languageControl: '控制台语言',
expandPreferences: '展开偏好设置',
collapsePreferences: '收起偏好设置',
search: {
label: '搜索功能、配置和文字',
placeholder: '搜索功能、配置和文字',
current: '当前:{{label}}',
results: 'Admin 搜索结果',
loading: '正在加载搜索索引…',
empty: '没有找到匹配内容',
pageContext: '页面',
},
groups: {
overview: '总览',
collection: '采集与数据',
observability: '专题观测',
alerts: '告警与研判',
ops: '运维与配置',
},
routes: {
dashboard: '仪表盘',
earth: '智能星球',
docs: '文档',
datasources: '数据源',
data: '采集数据',
bgp: 'BGP观测',
systemAlerts: '系统告警',
bgpAlerts: 'BGP 告警',
situationalAlerts: '态势告警',
ai: 'AI',
earthContent: '智能星球内容',
collectionManagement: '采集管理',
logs: '系统日志',
users: '用户管理',
settings: '系统设置',
},
sections: {
alerts: '告警记录',
aiIntegrations: '模型供应商',
aiTools: '工具调用',
aiPrompts: '提示词',
aiPlayground: 'Playground',
bgpOverview: 'BGP',
collectionHistory: '采集历史 / 快照',
collectorCredentials: '采集器',
collectors: '采集调度',
earthAssets: '国界精度',
earthBrand: '品牌标识',
logsSources: '日志源',
newsSources: '新闻源',
notifications: '通知策略',
security: '安全策略',
settingsSystem: '系统显示',
smtp: 'SMTP 邮件',
tv: '电视直播',
},
},
auth: {
accountRecovery: '账号恢复',
alreadyHaveAccount: '已有账号,去登录',
backToLogin: '返回登录',
code: '验证码',
codeSent: '验证码已发送至 <strong>{{email}}</strong>10 分钟内有效。',
createAccount: '创建账号',
email: '邮箱',
emailVerification: '邮箱验证',
emailNotVerified: '邮箱未验证,请先完成邮箱验证。',
forgotPassword: '忘记密码?',
forgotPasswordDescription: '通过邮箱验证码重置后台账号密码。',
forgotPasswordTitle: '找回密码',
loginButton: '登录',
loginDescription: '使用你的后台账号进入运维工作台。',
loginFailed: '登录失败,请检查账号或密码。',
loginSuccess: '登录成功,正在进入控制台。',
loginTitle: '登录 Planet 控制台',
newPassword: '新密码',
password: '密码',
passwordHint: '至少 8 位',
passwordResetSuccess: '密码已重置,请用新密码登录。',
recoveryCodeSent: '若该邮箱已注册,验证码已发送。请到邮箱查收。',
register: '注册',
registerAccount: '注册账户',
registerDescription: '创建账号后需要完成邮箱验证,验证成功会自动进入控制台。',
resend: '重新发送验证码',
resendCountdown: '重发 ({{seconds}}s)',
resendSuccess: '验证码已重发。',
resetPassword: '重置密码',
sendCode: '发送验证码',
updateEmail: '修改邮箱',
username: '用户名',
usernameHint: '3-50 个字符',
verificationSent: '验证码已发送到邮箱。',
verifyAndLogin: '验证并登录',
verifyEmail: '验证邮箱',
verifyEmailDescription: '输入邮箱验证码后会自动登录并进入控制台。',
welcomeBack: '欢迎回来',
shell: {
product: 'Planet',
subtitle: 'Operations Console',
kicker: '现代控制台',
title: '把数据、告警、AI 和 Earth 运维放在同一个清爽工作台。',
description: '控制台默认进入现代化工作流,登录后直接使用 `/admin` 即可。',
},
},
docs: {
brandTitle: '智能星球文档',
brandSubtitle: '开发者和用户手册',
documentUnavailable: '文档不可用',
docs: '文档',
footerLanguage: 'Language',
footerTheme: 'Theme',
loading: '加载中...',
loginRequired: '需要登录',
loginRequiredDescription: '这份文档需要登录并具备对应 Gatekeeper 权限组后才能阅读。',
goToLogin: '前往登录',
forbidden: '无权访问',
forbiddenDescription: '当前账号没有阅读这份文档所需的 Gatekeeper 权限组。',
notFound: '文档未找到',
notFoundDescription: '请求的文档不存在,或当前语言没有对应内容。',
returnOverview: '返回文档首页',
searchLabel: '搜索文档',
searchPlaceholder: '搜索文档...',
searchEmpty: '未找到匹配文档',
toc: '本页目录',
tocEmpty: '暂无章节',
},
markdown: {
copyCode: '复制代码',
copied: '已复制',
copiedCode: '已复制代码',
copyChartSource: '复制图表源码',
copiedChartSource: '已复制图表源码',
expandMermaid: '放大查看 Mermaid 图表',
clickToExpand: '点击放大查看',
closeMermaid: '关闭 Mermaid 图表查看器',
mermaidViewer: 'Mermaid 图表查看器',
mermaidRenderFailed: 'Mermaid 渲染失败',
viewerHint: '拖拽移动 · 滚轮缩放 · 点击空白关闭',
},
users: {
actions: '操作',
active: '活跃',
addUser: '添加用户',
clearSearch: '清空搜索',
confirmDelete: '确认删除',
confirmDeleteDescription: '确定要删除用户 {{username}} 吗?',
createSuccess: '创建成功',
deleteFailed: '删除失败',
deleteSuccess: '删除成功',
description: '维护后台账号、角色与文档权限组。',
disabled: '禁用',
edit: '编辑',
editUser: '编辑用户',
gatekeeperGroups: 'Gatekeeper 权限组',
retryLater: '请稍后重试',
role: '角色',
searchPlaceholder: '搜索用户、邮箱、角色',
status: '状态',
submit: '提交',
unconfigured: '未配置',
updateSuccess: '更新成功',
roles: {
super_admin: '超级管理员',
admin: '管理员',
operator: '操作员',
viewer: '只读用户',
},
gatekeeper: {
docs_user: '文档:用户文档',
docs_developer: '文档:开发文档',
docs_admin: '文档:管理/运维文档',
},
},
}
export const enUS = {
app: {
title: 'Intelligent Planet Plan',
routeLoading: 'Loading',
},
common: {
cancel: 'Cancel',
close: 'Close',
confirm: 'Confirm',
delete: 'Delete',
language: 'Language',
loading: 'Loading',
noData: 'No data',
operationFailed: 'Operation failed',
page: 'Page {{page}} / {{totalPages}}, {{total}} total',
previousPage: 'Previous',
nextPage: 'Next',
selectRow: 'Select row',
selectVisibleRows: 'Select visible rows',
theme: 'Theme',
themeLight: 'Light',
themeDark: 'Dark',
themeSystem: 'System',
themeFollowSystem: 'Follow system',
zh: '中文',
en: 'EN',
},
admin: {
brandTitle: 'Intelligent Planet',
brandSubtitle: 'Console',
collapseMenu: 'Collapse menu',
expandMenu: 'Expand menu',
openNav: 'Open navigation',
closeNav: 'Close navigation',
logout: 'Log out',
greeting: 'Hi, {{name}}',
version: 'Version',
themeControl: 'Console theme',
languageControl: 'Console language',
expandPreferences: 'Expand preferences',
collapsePreferences: 'Collapse preferences',
search: {
label: 'Search features, settings, and text',
placeholder: 'Search features, settings, and text',
current: 'Current: {{label}}',
results: 'Admin search results',
loading: 'Loading search index...',
empty: 'No matching content',
pageContext: 'Page',
},
groups: {
overview: 'Overview',
collection: 'Collection and Data',
observability: 'Observability',
alerts: 'Alerts and Analysis',
ops: 'Operations and Settings',
},
routes: {
dashboard: 'Dashboard',
earth: 'Intelligent Planet',
docs: 'Docs',
datasources: 'Datasources',
data: 'Collected Data',
bgp: 'BGP Observatory',
systemAlerts: 'System Alerts',
bgpAlerts: 'BGP Alerts',
situationalAlerts: 'Situational Alerts',
ai: 'AI',
earthContent: 'Planet Content',
collectionManagement: 'Collection Management',
logs: 'System Logs',
users: 'User Management',
settings: 'System Settings',
},
sections: {
alerts: 'Alert Records',
aiIntegrations: 'Model Providers',
aiTools: 'Tool Calls',
aiPrompts: 'Prompts',
aiPlayground: 'Playground',
bgpOverview: 'BGP',
collectionHistory: 'Collection History / Snapshots',
collectorCredentials: 'Collectors',
collectors: 'Collection Schedule',
earthAssets: 'Boundary Accuracy',
earthBrand: 'Branding',
logsSources: 'Log Sources',
newsSources: 'News Sources',
notifications: 'Notification Policy',
security: 'Security Policy',
settingsSystem: 'System Display',
smtp: 'SMTP Email',
tv: 'TV Streams',
},
},
auth: {
accountRecovery: 'Account recovery',
alreadyHaveAccount: 'Already have an account? Log in',
backToLogin: 'Back to login',
code: 'Verification code',
codeSent: 'A 6-digit code was sent to <strong>{{email}}</strong>. It is valid for 10 minutes.',
createAccount: 'Create account',
email: 'Email',
emailVerification: 'Email verification',
emailNotVerified: 'Email is not verified. Please verify your email first.',
forgotPassword: 'Forgot password?',
forgotPasswordDescription: 'Reset your console password with an email verification code.',
forgotPasswordTitle: 'Reset password',
loginButton: 'Log in',
loginDescription: 'Use your admin account to enter the operations workspace.',
loginFailed: 'Login failed. Check your account or password.',
loginSuccess: 'Login succeeded. Opening the console.',
loginTitle: 'Log in to Planet Console',
newPassword: 'New password',
password: 'Password',
passwordHint: 'At least 8 characters',
passwordResetSuccess: 'Password reset. Log in with your new password.',
recoveryCodeSent: 'If this email is registered, a code has been sent. Please check your inbox.',
register: 'Register',
registerAccount: 'Register account',
registerDescription: 'Create an account, verify your email, then enter the console automatically.',
resend: 'Resend code',
resendCountdown: 'Resend ({{seconds}}s)',
resendSuccess: 'Verification code resent.',
resetPassword: 'Reset password',
sendCode: 'Send code',
updateEmail: 'Change email',
username: 'Username',
usernameHint: '3-50 characters',
verificationSent: 'Verification code sent to your email.',
verifyAndLogin: 'Verify and log in',
verifyEmail: 'Verify email',
verifyEmailDescription: 'Enter the email verification code to log in and open the console.',
welcomeBack: 'Welcome back',
shell: {
product: 'Planet',
subtitle: 'Operations Console',
kicker: 'Modern console',
title: 'Bring data, alerts, AI, and Earth operations into one focused workspace.',
description: 'The console opens the modern workflow by default. Use `/admin` after login.',
},
},
docs: {
brandTitle: 'Intelligent Planet Docs',
brandSubtitle: 'Developer & User Guide',
documentUnavailable: 'Document unavailable',
docs: 'Docs',
footerLanguage: 'Language',
footerTheme: 'Theme',
loading: 'Loading document...',
loginRequired: 'Login required',
loginRequiredDescription: 'This document requires login and the matching Gatekeeper permission group.',
goToLogin: 'Go to login',
forbidden: 'Permission required',
forbiddenDescription: 'Your account does not have the Gatekeeper permission group required for this document.',
notFound: 'Document not found',
notFoundDescription: 'The requested guide does not exist or is not available in the current language.',
returnOverview: 'Return to docs overview',
searchLabel: 'Search docs',
searchPlaceholder: 'Search guides, APIs, layers...',
searchEmpty: 'No matching docs',
toc: 'On this page',
tocEmpty: 'No sections',
},
markdown: {
copyCode: 'Copy code',
copied: 'Copied',
copiedCode: 'Code copied',
copyChartSource: 'Copy chart source',
copiedChartSource: 'Chart source copied',
expandMermaid: 'Expand Mermaid diagram',
clickToExpand: 'Click to expand',
closeMermaid: 'Close Mermaid diagram viewer',
mermaidViewer: 'Mermaid diagram viewer',
mermaidRenderFailed: 'Mermaid render failed',
viewerHint: 'Drag to pan · Scroll to zoom · Click blank space to close',
},
users: {
actions: 'Actions',
active: 'Active',
addUser: 'Add user',
clearSearch: 'Clear search',
confirmDelete: 'Confirm deletion',
confirmDeleteDescription: 'Delete user {{username}}?',
createSuccess: 'Created',
deleteFailed: 'Delete failed',
deleteSuccess: 'Deleted',
description: 'Maintain console accounts, roles, and Docs permission groups.',
disabled: 'Disabled',
edit: 'Edit',
editUser: 'Edit user',
gatekeeperGroups: 'Gatekeeper groups',
retryLater: 'Please try again later',
role: 'Role',
searchPlaceholder: 'Search users, email, or role',
status: 'Status',
submit: 'Submit',
unconfigured: 'Not configured',
updateSuccess: 'Updated',
roles: {
super_admin: 'Super admin',
admin: 'Admin',
operator: 'Operator',
viewer: 'Viewer',
},
gatekeeper: {
docs_user: 'Docs: user docs',
docs_developer: 'Docs: developer docs',
docs_admin: 'Docs: admin / ops docs',
},
},
}
export const resources = {
'zh-CN': { translation: zhCN },
'en-US': { translation: enUS },
} as const

View File

@@ -63,6 +63,7 @@ select {
} }
.auth-shell__panel { .auth-shell__panel {
position: relative;
min-width: 0; min-width: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -70,6 +71,13 @@ select {
padding: clamp(28px, 5vw, 56px); padding: clamp(28px, 5vw, 56px);
} }
.auth-shell__language {
position: absolute;
top: 34px;
right: 34px;
width: 118px;
}
.auth-shell__brand { .auth-shell__brand {
position: absolute; position: absolute;
top: 34px; top: 34px;

View File

@@ -3,6 +3,7 @@ import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom' import { BrowserRouter } from 'react-router-dom'
import App from './App' import App from './App'
import { registerAdminRuntimeErrorHandlers } from './admin/runtimeLogs' import { registerAdminRuntimeErrorHandlers } from './admin/runtimeLogs'
import './i18n'
import './index.css' import './index.css'
registerAdminRuntimeErrorHandlers() registerAdminRuntimeErrorHandlers()

View File

@@ -1,6 +1,9 @@
import { ArrowLeft, Loader2, Sparkles } from 'lucide-react' import { ArrowLeft, Loader2, Sparkles } from 'lucide-react'
import { type FormEvent, type ReactNode } from 'react' import { type FormEvent, type ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import SegmentedControl from '../../components/SegmentedControl/SegmentedControl'
import { localeOptions, useLocale, type SupportedLocale } from '../../i18n/locale'
interface AuthShellProps { interface AuthShellProps {
eyebrow: string eyebrow: string
@@ -11,14 +14,30 @@ interface AuthShellProps {
} }
export function AuthShell({ eyebrow, title, description, children, aside }: AuthShellProps) { export function AuthShell({ eyebrow, title, description, children, aside }: AuthShellProps) {
const { t } = useTranslation()
const { locale, setLocale } = useLocale()
const languageOptions = localeOptions.map((option) => ({
value: option.value,
label: t(option.labelKey),
title: t(option.titleKey),
}))
return ( return (
<main className="auth-shell"> <main className="auth-shell">
<section className="auth-shell__panel"> <section className="auth-shell__panel">
<SegmentedControl<SupportedLocale>
ariaLabel={t('common.language')}
className="auth-shell__language"
options={languageOptions}
scale={0.78}
value={locale}
onChange={setLocale}
/>
<div className="auth-shell__brand"> <div className="auth-shell__brand">
<span className="auth-shell__logo"><Sparkles size={20} /></span> <span className="auth-shell__logo"><Sparkles size={20} /></span>
<div> <div>
<strong>Planet</strong> <strong>{t('auth.shell.product')}</strong>
<span>Operations Console</span> <span>{t('auth.shell.subtitle')}</span>
</div> </div>
</div> </div>
<div className="auth-shell__heading"> <div className="auth-shell__heading">
@@ -31,9 +50,9 @@ export function AuthShell({ eyebrow, title, description, children, aside }: Auth
<aside className="auth-shell__aside"> <aside className="auth-shell__aside">
{aside || ( {aside || (
<> <>
<span className="auth-shell__aside-kicker"></span> <span className="auth-shell__aside-kicker">{t('auth.shell.kicker')}</span>
<h2>AI Earth </h2> <h2>{t('auth.shell.title')}</h2>
<p>使 `/admin` </p> <p>{t('auth.shell.description')}</p>
</> </>
)} )}
</aside> </aside>
@@ -95,10 +114,12 @@ export function AuthLinks({ children }: { children: ReactNode }) {
} }
export function BackToLogin() { export function BackToLogin() {
const { t } = useTranslation()
return ( return (
<Link className="auth-link auth-link--back" to="/login"> <Link className="auth-link auth-link--back" to="/login">
<ArrowLeft size={15} /> <ArrowLeft size={15} />
{t('auth.backToLogin')}
</Link> </Link>
) )
} }

View File

@@ -1,11 +1,13 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Link, useNavigate, useParams } from 'react-router-dom' import { Link, useNavigate, useParams } from 'react-router-dom'
import axios from 'axios' import axios from 'axios'
import MarkdownRenderer from '../../components/MarkdownRenderer/MarkdownRenderer' import MarkdownRenderer from '../../components/MarkdownRenderer/MarkdownRenderer'
import Scrollbar from '../../components/Scrollbar/Scrollbar' import Scrollbar from '../../components/Scrollbar/Scrollbar'
import SegmentedControl from '../../components/SegmentedControl/SegmentedControl' import SegmentedControl from '../../components/SegmentedControl/SegmentedControl'
import { localeFromDocsLang, useLocale } from '../../i18n/locale'
import { useAuthStore } from '../../stores/auth' import { useAuthStore } from '../../stores/auth'
import { import {
createHeadingIdResolver, createHeadingIdResolver,
@@ -46,11 +48,6 @@ function getHashFromHref(href: string): string {
return hashIndex >= 0 ? href.slice(hashIndex) : '' return hashIndex >= 0 ? href.slice(hashIndex) : ''
} }
function readStoredLang(): DocsLang {
const stored = localStorage.getItem('docs-lang')
return stored === 'en' ? 'en' : 'zh'
}
function readStoredThemeMode(): DocsThemeMode { function readStoredThemeMode(): DocsThemeMode {
const stored = localStorage.getItem('docs-theme') const stored = localStorage.getItem('docs-theme')
if (stored === 'system' || stored === 'light' || stored === 'dark') { if (stored === 'system' || stored === 'light' || stored === 'dark') {
@@ -69,9 +66,11 @@ function getSystemTheme(): 'light' | 'dark' {
export default function Docs() { export default function Docs() {
const { slug } = useParams() const { slug } = useParams()
const navigate = useNavigate() const navigate = useNavigate()
const { t } = useTranslation()
const { docsLang, setLocale } = useLocale()
const { token } = useAuthStore() const { token } = useAuthStore()
const lang = docsLang
const [lang, setLang] = useState<DocsLang>(readStoredLang)
const [themeMode, setThemeMode] = useState<DocsThemeMode>(readStoredThemeMode) const [themeMode, setThemeMode] = useState<DocsThemeMode>(readStoredThemeMode)
const [systemTheme, setSystemTheme] = useState<'light' | 'dark'>(getSystemTheme) const [systemTheme, setSystemTheme] = useState<'light' | 'dark'>(getSystemTheme)
const [catalogItems, setCatalogItems] = useState<DocsCatalogItem[]>([]) const [catalogItems, setCatalogItems] = useState<DocsCatalogItem[]>([])
@@ -94,14 +93,14 @@ export default function Docs() {
const groupedEntries = useMemo(() => groupDocsEntries(docsEntries), [docsEntries]) const groupedEntries = useMemo(() => groupDocsEntries(docsEntries), [docsEntries])
const effectiveTheme = themeMode === 'system' ? systemTheme : themeMode const effectiveTheme = themeMode === 'system' ? systemTheme : themeMode
const langOptions = useMemo(() => [ const langOptions = useMemo(() => [
{ value: 'zh' as const, label: '中文' }, { value: 'zh' as const, label: t('common.zh') },
{ value: 'en' as const, label: 'EN' }, { value: 'en' as const, label: t('common.en') },
], []) ], [t])
const themeOptions = useMemo(() => [ const themeOptions = useMemo(() => [
{ {
value: 'light' as const, value: 'light' as const,
label: lang === 'zh' ? '浅色' : 'Light', label: t('common.themeLight'),
title: lang === 'zh' ? '浅色' : 'Light', title: t('common.themeLight'),
icon: ( icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="4" /> <circle cx="12" cy="12" r="4" />
@@ -111,8 +110,8 @@ export default function Docs() {
}, },
{ {
value: 'system' as const, value: 'system' as const,
label: lang === 'zh' ? '系统' : 'System', label: t('common.themeSystem'),
title: lang === 'zh' ? '跟随系统' : 'Follow system', title: t('common.themeFollowSystem'),
icon: ( icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round">
<rect x="2" y="3" width="20" height="14" rx="2" ry="2" /> <rect x="2" y="3" width="20" height="14" rx="2" ry="2" />
@@ -122,20 +121,19 @@ export default function Docs() {
}, },
{ {
value: 'dark' as const, value: 'dark' as const,
label: lang === 'zh' ? '深色' : 'Dark', label: t('common.themeDark'),
title: lang === 'zh' ? '深色' : 'Dark', title: t('common.themeDark'),
icon: ( icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" /> <path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" />
</svg> </svg>
), ),
}, },
], [lang]) ], [t])
const handleLangChange = useCallback((newLang: DocsLang) => { const handleLangChange = useCallback((newLang: DocsLang) => {
setLang(newLang) setLocale(localeFromDocsLang(newLang))
localStorage.setItem('docs-lang', newLang) }, [setLocale])
}, [])
const handleThemeModeChange = useCallback((nextMode: DocsThemeMode) => { const handleThemeModeChange = useCallback((nextMode: DocsThemeMode) => {
setThemeMode(nextMode) setThemeMode(nextMode)
@@ -313,10 +311,10 @@ export default function Docs() {
<span className="docs-brand__mark"></span> <span className="docs-brand__mark"></span>
<span> <span>
<span className="docs-brand__title"> <span className="docs-brand__title">
{lang === 'zh' ? '智能星球文档' : 'Intelligent Planet Docs'} {t('docs.brandTitle')}
</span> </span>
<span className="docs-brand__subtitle"> <span className="docs-brand__subtitle">
{lang === 'zh' ? '开发者和用户手册' : 'Developer & User Guide'} {t('docs.brandSubtitle')}
</span> </span>
</span> </span>
</Link> </Link>
@@ -359,7 +357,7 @@ export default function Docs() {
<footer className="docs-sidebar-footer"> <footer className="docs-sidebar-footer">
<div className="docs-footer-row docs-footer-row--language"> <div className="docs-footer-row docs-footer-row--language">
<SegmentedControl <SegmentedControl
ariaLabel="Language" ariaLabel={t('docs.footerLanguage')}
className="docs-lang-toggle" className="docs-lang-toggle"
options={langOptions} options={langOptions}
scale={FOOTER_CONTROL_SCALE} scale={FOOTER_CONTROL_SCALE}
@@ -370,7 +368,7 @@ export default function Docs() {
<div className="docs-footer-row"> <div className="docs-footer-row">
<SegmentedControl <SegmentedControl
ariaLabel="Theme" ariaLabel={t('docs.footerTheme')}
className="docs-theme-toggle" className="docs-theme-toggle"
options={themeOptions} options={themeOptions}
scale={FOOTER_CONTROL_SCALE} scale={FOOTER_CONTROL_SCALE}
@@ -385,16 +383,16 @@ export default function Docs() {
<header className="docs-header"> <header className="docs-header">
<div> <div>
<p className="docs-header__eyebrow"> <p className="docs-header__eyebrow">
{activeHeaderEntry ? getDocsGroupLabel(activeHeaderEntry.group, lang) : lang === 'zh' ? '文档' : 'Docs'} {activeHeaderEntry ? getDocsGroupLabel(activeHeaderEntry.group, lang) : t('docs.docs')}
</p> </p>
<h1 className="docs-header__title"> <h1 className="docs-header__title">
{activeHeaderEntry?.title || (lang === 'zh' ? '文档不可用' : 'Document unavailable')} {activeHeaderEntry?.title || t('docs.documentUnavailable')}
</h1> </h1>
</div> </div>
<div className="docs-search" ref={searchRef}> <div className="docs-search" ref={searchRef}>
<label className="docs-search__label" htmlFor="docs-search-input"> <label className="docs-search__label" htmlFor="docs-search-input">
{lang === 'zh' ? '搜索文档' : 'Search docs'} {t('docs.searchLabel')}
</label> </label>
<input <input
id="docs-search-input" id="docs-search-input"
@@ -409,7 +407,7 @@ export default function Docs() {
setIsSearchOpen(true) setIsSearchOpen(true)
} }
}} }}
placeholder={lang === 'zh' ? '搜索文档...' : 'Search guides, APIs, layers...'} placeholder={t('docs.searchPlaceholder')}
type="search" type="search"
/> />
{shouldShowSearchResults && ( {shouldShowSearchResults && (
@@ -432,7 +430,7 @@ export default function Docs() {
)) ))
) : ( ) : (
<div className="docs-search__empty"> <div className="docs-search__empty">
{lang === 'zh' ? '未找到匹配文档' : 'No matching docs'} {t('docs.searchEmpty')}
</div> </div>
)} )}
</Scrollbar> </Scrollbar>
@@ -445,7 +443,7 @@ export default function Docs() {
<Scrollbar className="docs-article" viewportRef={articleRef}> <Scrollbar className="docs-article" viewportRef={articleRef}>
{isCatalogLoading || isLoading ? ( {isCatalogLoading || isLoading ? (
<div className="docs-state"> <div className="docs-state">
{lang === 'zh' ? '加载中...' : 'Loading document...'} {t('docs.loading')}
</div> </div>
) : docError === 'none' ? ( ) : docError === 'none' ? (
<MarkdownRenderer <MarkdownRenderer
@@ -458,33 +456,27 @@ export default function Docs() {
<div className="docs-not-found"> <div className="docs-not-found">
{docError === 'unauthenticated' ? ( {docError === 'unauthenticated' ? (
<> <>
<h2>{lang === 'zh' ? '需要登录' : 'Login required'}</h2> <h2>{t('docs.loginRequired')}</h2>
<p> <p>
{lang === 'zh' {t('docs.loginRequiredDescription')}
? '这份文档需要登录并具备对应 Gatekeeper 权限组后才能阅读。'
: 'This document requires login and the matching Gatekeeper permission group.'}
</p> </p>
<Link to="/admin">{lang === 'zh' ? '前往登录' : 'Go to login'}</Link> <Link to="/admin">{t('docs.goToLogin')}</Link>
</> </>
) : docError === 'forbidden' ? ( ) : docError === 'forbidden' ? (
<> <>
<h2>{lang === 'zh' ? '无权访问' : 'Permission required'}</h2> <h2>{t('docs.forbidden')}</h2>
<p> <p>
{lang === 'zh' {t('docs.forbiddenDescription')}
? '当前账号没有阅读这份文档所需的 Gatekeeper 权限组。'
: 'Your account does not have the Gatekeeper permission group required for this document.'}
</p> </p>
<Link to="/docs">{lang === 'zh' ? '返回文档首页' : 'Return to docs overview'}</Link> <Link to="/docs">{t('docs.returnOverview')}</Link>
</> </>
) : ( ) : (
<> <>
<h2>{lang === 'zh' ? '文档未找到' : 'Document not found'}</h2> <h2>{t('docs.notFound')}</h2>
<p> <p>
{lang === 'zh' {t('docs.notFoundDescription')}
? '请求的文档不存在,或当前语言没有对应内容。'
: 'The requested guide does not exist or is not available in the current language.'}
</p> </p>
<Link to="/docs">{lang === 'zh' ? '返回文档首页' : 'Return to docs overview'}</Link> <Link to="/docs">{t('docs.returnOverview')}</Link>
</> </>
)} )}
</div> </div>
@@ -494,7 +486,7 @@ export default function Docs() {
<aside className="docs-toc" aria-label="Document table of contents"> <aside className="docs-toc" aria-label="Document table of contents">
<Scrollbar className="docs-toc__inner"> <Scrollbar className="docs-toc__inner">
<h2 className="docs-toc__title"> <h2 className="docs-toc__title">
{lang === 'zh' ? '本页目录' : 'On this page'} {t('docs.toc')}
</h2> </h2>
{headings.length > 0 ? ( {headings.length > 0 ? (
<nav className="docs-toc__nav"> <nav className="docs-toc__nav">
@@ -515,7 +507,7 @@ export default function Docs() {
</nav> </nav>
) : ( ) : (
<p className="docs-toc__empty"> <p className="docs-toc__empty">
{lang === 'zh' ? '暂无章节' : 'No sections'} {t('docs.tocEmpty')}
</p> </p>
)} )}
</Scrollbar> </Scrollbar>

View File

@@ -1,5 +1,6 @@
import axios from 'axios' import axios from 'axios'
import { useEffect, useState, type FormEvent } from 'react' import { useEffect, useState, type FormEvent } from 'react'
import { Trans, useTranslation } from 'react-i18next'
import { AuthButton, AuthField, AuthForm, AuthInput, AuthLinks, AuthNotice, AuthShell, BackToLogin } from '../Auth/AuthShell' import { AuthButton, AuthField, AuthForm, AuthInput, AuthLinks, AuthNotice, AuthShell, BackToLogin } from '../Auth/AuthShell'
const API_URL = import.meta.env.VITE_API_URL || '/api/v1' const API_URL = import.meta.env.VITE_API_URL || '/api/v1'
@@ -10,15 +11,16 @@ interface ErrorBody {
} }
} }
function extractDetail(error: unknown): string { function extractDetail(error: unknown, fallback: string): string {
const err = error as ErrorBody const err = error as ErrorBody
const detail = err.response?.data?.detail const detail = err.response?.data?.detail
if (typeof detail === 'string') return detail if (typeof detail === 'string') return detail
if (detail && typeof detail === 'object') return detail.message || detail.code || '操作失败' if (detail && typeof detail === 'object') return detail.message || detail.code || fallback
return '操作失败' return fallback
} }
function ForgotPassword() { function ForgotPassword() {
const { t } = useTranslation()
const [step, setStep] = useState<'request' | 'reset'>('request') const [step, setStep] = useState<'request' | 'reset'>('request')
const [email, setEmail] = useState('') const [email, setEmail] = useState('')
const [code, setCode] = useState('') const [code, setCode] = useState('')
@@ -41,9 +43,9 @@ function ForgotPassword() {
await axios.post(`${API_URL}/auth/forgot-password`, { email }) await axios.post(`${API_URL}/auth/forgot-password`, { email })
setStep('reset') setStep('reset')
setCooldown(60) setCooldown(60)
setFeedback({ tone: 'success', text: '若该邮箱已注册,验证码已发送。请到邮箱查收。' }) setFeedback({ tone: 'success', text: t('auth.recoveryCodeSent') })
} catch (error) { } catch (error) {
setFeedback({ tone: 'error', text: extractDetail(error) }) setFeedback({ tone: 'error', text: extractDetail(error, t('common.operationFailed')) })
} finally { } finally {
setLoading(false) setLoading(false)
} }
@@ -58,9 +60,9 @@ function ForgotPassword() {
setStep('request') setStep('request')
setCode('') setCode('')
setNewPassword('') setNewPassword('')
setFeedback({ tone: 'success', text: '密码已重置,请用新密码登录。' }) setFeedback({ tone: 'success', text: t('auth.passwordResetSuccess') })
} catch (error) { } catch (error) {
setFeedback({ tone: 'error', text: extractDetail(error) }) setFeedback({ tone: 'error', text: extractDetail(error, t('common.operationFailed')) })
} finally { } finally {
setLoading(false) setLoading(false)
} }
@@ -71,38 +73,38 @@ function ForgotPassword() {
try { try {
await axios.post(`${API_URL}/auth/forgot-password`, { email }) await axios.post(`${API_URL}/auth/forgot-password`, { email })
setCooldown(60) setCooldown(60)
setFeedback({ tone: 'success', text: '验证码已重发。' }) setFeedback({ tone: 'success', text: t('auth.resendSuccess') })
} catch (error) { } catch (error) {
setFeedback({ tone: 'error', text: extractDetail(error) }) setFeedback({ tone: 'error', text: extractDetail(error, t('common.operationFailed')) })
} }
} }
return ( return (
<AuthShell eyebrow="Account recovery" title="找回密码" description="通过邮箱验证码重置后台账号密码。"> <AuthShell eyebrow={t('auth.accountRecovery')} title={t('auth.forgotPasswordTitle')} description={t('auth.forgotPasswordDescription')}>
{step === 'request' ? ( {step === 'request' ? (
<AuthForm onSubmit={onRequest}> <AuthForm onSubmit={onRequest}>
{feedback ? <AuthNotice tone={feedback.tone}>{feedback.text}</AuthNotice> : null} {feedback ? <AuthNotice tone={feedback.tone}>{feedback.text}</AuthNotice> : null}
<AuthField label="邮箱"> <AuthField label={t('auth.email')}>
<AuthInput value={email} onChange={(event) => setEmail(event.target.value)} type="email" autoComplete="email" required /> <AuthInput value={email} onChange={(event) => setEmail(event.target.value)} type="email" autoComplete="email" required />
</AuthField> </AuthField>
<AuthButton type="submit" loading={loading}></AuthButton> <AuthButton type="submit" loading={loading}>{t('auth.sendCode')}</AuthButton>
<AuthLinks><BackToLogin /></AuthLinks> <AuthLinks><BackToLogin /></AuthLinks>
</AuthForm> </AuthForm>
) : ( ) : (
<AuthForm onSubmit={onReset}> <AuthForm onSubmit={onReset}>
{feedback ? <AuthNotice tone={feedback.tone}>{feedback.text}</AuthNotice> : null} {feedback ? <AuthNotice tone={feedback.tone}>{feedback.text}</AuthNotice> : null}
<AuthNotice> <strong>{email}</strong>10 </AuthNotice> <AuthNotice><Trans i18nKey="auth.codeSent" values={{ email }} components={{ strong: <strong /> }} /></AuthNotice>
<AuthField label="验证码"> <AuthField label={t('auth.code')}>
<AuthInput value={code} onChange={(event) => setCode(event.target.value)} maxLength={6} inputMode="numeric" required /> <AuthInput value={code} onChange={(event) => setCode(event.target.value)} maxLength={6} inputMode="numeric" required />
</AuthField> </AuthField>
<AuthField label="新密码"> <AuthField label={t('auth.newPassword')}>
<AuthInput value={newPassword} onChange={(event) => setNewPassword(event.target.value)} type="password" minLength={8} autoComplete="new-password" required /> <AuthInput value={newPassword} onChange={(event) => setNewPassword(event.target.value)} type="password" minLength={8} autoComplete="new-password" required />
</AuthField> </AuthField>
<AuthButton type="submit" loading={loading}></AuthButton> <AuthButton type="submit" loading={loading}>{t('auth.resetPassword')}</AuthButton>
<AuthLinks> <AuthLinks>
<button className="auth-link auth-link--button" type="button" onClick={() => setStep('request')}></button> <button className="auth-link auth-link--button" type="button" onClick={() => setStep('request')}>{t('auth.updateEmail')}</button>
<button className="auth-link auth-link--button" type="button" disabled={cooldown > 0} onClick={onResend}> <button className="auth-link auth-link--button" type="button" disabled={cooldown > 0} onClick={onResend}>
{cooldown > 0 ? `重发 (${cooldown}s)` : '重新发送验证码'} {cooldown > 0 ? t('auth.resendCountdown', { seconds: cooldown }) : t('auth.resend')}
</button> </button>
</AuthLinks> </AuthLinks>
</AuthForm> </AuthForm>

View File

@@ -1,4 +1,5 @@
import { useState, type FormEvent } from 'react' import { useState, type FormEvent } from 'react'
import { useTranslation } from 'react-i18next'
import { Link, useNavigate } from 'react-router-dom' import { Link, useNavigate } from 'react-router-dom'
import { AuthButton, AuthField, AuthForm, AuthInput, AuthLinks, AuthNotice, AuthShell } from '../Auth/AuthShell' import { AuthButton, AuthField, AuthForm, AuthInput, AuthLinks, AuthNotice, AuthShell } from '../Auth/AuthShell'
import { useAuthStore } from '../../stores/auth' import { useAuthStore } from '../../stores/auth'
@@ -10,6 +11,7 @@ interface LoginError {
} }
function Login() { function Login() {
const { t } = useTranslation()
const [username, setUsername] = useState('') const [username, setUsername] = useState('')
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
@@ -23,38 +25,38 @@ function Login() {
setFeedback(null) setFeedback(null)
try { try {
await login(username.trim(), password) await login(username.trim(), password)
setFeedback({ tone: 'success', text: '登录成功,正在进入控制台。' }) setFeedback({ tone: 'success', text: t('auth.loginSuccess') })
navigate('/admin', { replace: true }) navigate('/admin', { replace: true })
} catch (error: unknown) { } catch (error: unknown) {
const err = error as LoginError const err = error as LoginError
const detail = err.response?.data?.detail const detail = err.response?.data?.detail
if (detail && typeof detail === 'object' && detail.code === 'EMAIL_NOT_VERIFIED') { if (detail && typeof detail === 'object' && detail.code === 'EMAIL_NOT_VERIFIED') {
setFeedback({ tone: 'warning', text: '邮箱未验证,请先完成邮箱验证。' }) setFeedback({ tone: 'warning', text: t('auth.emailNotVerified') })
const email = detail.email ? `?email=${encodeURIComponent(detail.email)}` : '' const email = detail.email ? `?email=${encodeURIComponent(detail.email)}` : ''
navigate(`/verify-email${email}`) navigate(`/verify-email${email}`)
return return
} }
const fallback = typeof detail === 'string' ? detail : detail?.message const fallback = typeof detail === 'string' ? detail : detail?.message
setFeedback({ tone: 'error', text: fallback || '登录失败,请检查账号或密码。' }) setFeedback({ tone: 'error', text: fallback || t('auth.loginFailed') })
} finally { } finally {
setLoading(false) setLoading(false)
} }
} }
return ( return (
<AuthShell eyebrow="Welcome back" title="登录 Planet 控制台" description="使用你的后台账号进入运维工作台。"> <AuthShell eyebrow={t('auth.welcomeBack')} title={t('auth.loginTitle')} description={t('auth.loginDescription')}>
<AuthForm onSubmit={onSubmit}> <AuthForm onSubmit={onSubmit}>
{feedback ? <AuthNotice tone={feedback.tone}>{feedback.text}</AuthNotice> : null} {feedback ? <AuthNotice tone={feedback.tone}>{feedback.text}</AuthNotice> : null}
<AuthField label="用户名"> <AuthField label={t('auth.username')}>
<AuthInput value={username} onChange={(event) => setUsername(event.target.value)} autoComplete="username" required autoFocus /> <AuthInput value={username} onChange={(event) => setUsername(event.target.value)} autoComplete="username" required autoFocus />
</AuthField> </AuthField>
<AuthField label="密码"> <AuthField label={t('auth.password')}>
<AuthInput value={password} onChange={(event) => setPassword(event.target.value)} type="password" autoComplete="current-password" required /> <AuthInput value={password} onChange={(event) => setPassword(event.target.value)} type="password" autoComplete="current-password" required />
</AuthField> </AuthField>
<AuthButton type="submit" loading={loading}></AuthButton> <AuthButton type="submit" loading={loading}>{t('auth.loginButton')}</AuthButton>
<AuthLinks> <AuthLinks>
<Link className="auth-link" to="/register"></Link> <Link className="auth-link" to="/register">{t('auth.registerAccount')}</Link>
<Link className="auth-link" to="/forgot-password"></Link> <Link className="auth-link" to="/forgot-password">{t('auth.forgotPassword')}</Link>
</AuthLinks> </AuthLinks>
</AuthForm> </AuthForm>
</AuthShell> </AuthShell>

View File

@@ -1,5 +1,6 @@
import axios from 'axios' import axios from 'axios'
import { useEffect, useState, type FormEvent } from 'react' import { useEffect, useState, type FormEvent } from 'react'
import { Trans, useTranslation } from 'react-i18next'
import { Link, useNavigate } from 'react-router-dom' import { Link, useNavigate } from 'react-router-dom'
import { AuthButton, AuthField, AuthForm, AuthInput, AuthLinks, AuthNotice, AuthShell } from '../Auth/AuthShell' import { AuthButton, AuthField, AuthForm, AuthInput, AuthLinks, AuthNotice, AuthShell } from '../Auth/AuthShell'
import { useAuthStore } from '../../stores/auth' import { useAuthStore } from '../../stores/auth'
@@ -15,15 +16,16 @@ interface ErrorBody {
} }
} }
function extractDetail(error: unknown): string { function extractDetail(error: unknown, fallback: string): string {
const err = error as ErrorBody const err = error as ErrorBody
const detail = err.response?.data?.detail const detail = err.response?.data?.detail
if (typeof detail === 'string') return detail if (typeof detail === 'string') return detail
if (detail && typeof detail === 'object') return detail.message || detail.code || '操作失败' if (detail && typeof detail === 'object') return detail.message || detail.code || fallback
return '操作失败' return fallback
} }
function Register() { function Register() {
const { t } = useTranslation()
const navigate = useNavigate() const navigate = useNavigate()
const [step, setStep] = useState<'register' | 'verify'>('register') const [step, setStep] = useState<'register' | 'verify'>('register')
const [username, setUsername] = useState('') const [username, setUsername] = useState('')
@@ -49,9 +51,9 @@ function Register() {
await axios.post(`${API_URL}/auth/register`, { username: username.trim(), email: email.trim(), password }) await axios.post(`${API_URL}/auth/register`, { username: username.trim(), email: email.trim(), password })
setStep('verify') setStep('verify')
setCooldown(RESEND_COOLDOWN_SECONDS) setCooldown(RESEND_COOLDOWN_SECONDS)
setFeedback({ tone: 'success', text: '验证码已发送到邮箱。' }) setFeedback({ tone: 'success', text: t('auth.verificationSent') })
} catch (error) { } catch (error) {
setFeedback({ tone: 'error', text: extractDetail(error) }) setFeedback({ tone: 'error', text: extractDetail(error, t('common.operationFailed')) })
} finally { } finally {
setLoading(false) setLoading(false)
} }
@@ -68,7 +70,7 @@ function Register() {
axios.defaults.headers.common.Authorization = `Bearer ${access_token}` axios.defaults.headers.common.Authorization = `Bearer ${access_token}`
navigate('/admin', { replace: true }) navigate('/admin', { replace: true })
} catch (error) { } catch (error) {
setFeedback({ tone: 'error', text: extractDetail(error) }) setFeedback({ tone: 'error', text: extractDetail(error, t('common.operationFailed')) })
} finally { } finally {
setLoading(false) setLoading(false)
} }
@@ -81,46 +83,46 @@ function Register() {
try { try {
await axios.post(`${API_URL}/auth/resend-code`, { email, purpose: 'register' }) await axios.post(`${API_URL}/auth/resend-code`, { email, purpose: 'register' })
setCooldown(RESEND_COOLDOWN_SECONDS) setCooldown(RESEND_COOLDOWN_SECONDS)
setFeedback({ tone: 'success', text: '验证码已重发。' }) setFeedback({ tone: 'success', text: t('auth.resendSuccess') })
} catch (error) { } catch (error) {
const err = error as ErrorBody const err = error as ErrorBody
const detail = err.response?.data?.detail const detail = err.response?.data?.detail
if (detail && typeof detail === 'object' && detail.retry_after_seconds) setCooldown(detail.retry_after_seconds) if (detail && typeof detail === 'object' && detail.retry_after_seconds) setCooldown(detail.retry_after_seconds)
setFeedback({ tone: 'error', text: extractDetail(error) }) setFeedback({ tone: 'error', text: extractDetail(error, t('common.operationFailed')) })
} finally { } finally {
setResending(false) setResending(false)
} }
} }
return ( return (
<AuthShell eyebrow="Create account" title={step === 'register' ? '注册账户' : '验证邮箱'} description="创建账号后需要完成邮箱验证,验证成功会自动进入控制台。"> <AuthShell eyebrow={t('auth.createAccount')} title={step === 'register' ? t('auth.registerAccount') : t('auth.verifyEmail')} description={t('auth.registerDescription')}>
{step === 'register' ? ( {step === 'register' ? (
<AuthForm onSubmit={onRegister}> <AuthForm onSubmit={onRegister}>
{feedback ? <AuthNotice tone={feedback.tone}>{feedback.text}</AuthNotice> : null} {feedback ? <AuthNotice tone={feedback.tone}>{feedback.text}</AuthNotice> : null}
<AuthField label="用户名" hint="3-50 个字符"> <AuthField label={t('auth.username')} hint={t('auth.usernameHint')}>
<AuthInput value={username} onChange={(event) => setUsername(event.target.value)} minLength={3} maxLength={50} required autoComplete="username" /> <AuthInput value={username} onChange={(event) => setUsername(event.target.value)} minLength={3} maxLength={50} required autoComplete="username" />
</AuthField> </AuthField>
<AuthField label="邮箱"> <AuthField label={t('auth.email')}>
<AuthInput value={email} onChange={(event) => setEmail(event.target.value)} type="email" required autoComplete="email" /> <AuthInput value={email} onChange={(event) => setEmail(event.target.value)} type="email" required autoComplete="email" />
</AuthField> </AuthField>
<AuthField label="密码" hint="至少 8 位"> <AuthField label={t('auth.password')} hint={t('auth.passwordHint')}>
<AuthInput value={password} onChange={(event) => setPassword(event.target.value)} type="password" minLength={8} required autoComplete="new-password" /> <AuthInput value={password} onChange={(event) => setPassword(event.target.value)} type="password" minLength={8} required autoComplete="new-password" />
</AuthField> </AuthField>
<AuthButton type="submit" loading={loading}></AuthButton> <AuthButton type="submit" loading={loading}>{t('auth.register')}</AuthButton>
<AuthLinks><Link className="auth-link" to="/login"></Link></AuthLinks> <AuthLinks><Link className="auth-link" to="/login">{t('auth.alreadyHaveAccount')}</Link></AuthLinks>
</AuthForm> </AuthForm>
) : ( ) : (
<AuthForm onSubmit={onVerify}> <AuthForm onSubmit={onVerify}>
{feedback ? <AuthNotice tone={feedback.tone}>{feedback.text}</AuthNotice> : null} {feedback ? <AuthNotice tone={feedback.tone}>{feedback.text}</AuthNotice> : null}
<AuthNotice> 6 <strong>{email}</strong>10 </AuthNotice> <AuthNotice><Trans i18nKey="auth.codeSent" values={{ email }} components={{ strong: <strong /> }} /></AuthNotice>
<AuthField label="验证码"> <AuthField label={t('auth.code')}>
<AuthInput value={code} onChange={(event) => setCode(event.target.value)} maxLength={6} inputMode="numeric" required /> <AuthInput value={code} onChange={(event) => setCode(event.target.value)} maxLength={6} inputMode="numeric" required />
</AuthField> </AuthField>
<AuthButton type="submit" loading={loading}></AuthButton> <AuthButton type="submit" loading={loading}>{t('auth.verifyAndLogin')}</AuthButton>
<AuthLinks> <AuthLinks>
<button className="auth-link auth-link--button" type="button" onClick={() => setStep('register')}></button> <button className="auth-link auth-link--button" type="button" onClick={() => setStep('register')}>{t('auth.updateEmail')}</button>
<button className="auth-link auth-link--button" type="button" disabled={cooldown > 0 || resending} onClick={onResend}> <button className="auth-link auth-link--button" type="button" disabled={cooldown > 0 || resending} onClick={onResend}>
{cooldown > 0 ? `重发 (${cooldown}s)` : '重新发送验证码'} {cooldown > 0 ? t('auth.resendCountdown', { seconds: cooldown }) : t('auth.resend')}
</button> </button>
</AuthLinks> </AuthLinks>
</AuthForm> </AuthForm>

View File

@@ -1,5 +1,6 @@
import axios from 'axios' import axios from 'axios'
import { useEffect, useState, type FormEvent } from 'react' import { useEffect, useState, type FormEvent } from 'react'
import { useTranslation } from 'react-i18next'
import { useNavigate, useSearchParams } from 'react-router-dom' import { useNavigate, useSearchParams } from 'react-router-dom'
import { AuthButton, AuthField, AuthForm, AuthInput, AuthLinks, AuthNotice, AuthShell, BackToLogin } from '../Auth/AuthShell' import { AuthButton, AuthField, AuthForm, AuthInput, AuthLinks, AuthNotice, AuthShell, BackToLogin } from '../Auth/AuthShell'
import { useAuthStore } from '../../stores/auth' import { useAuthStore } from '../../stores/auth'
@@ -12,15 +13,16 @@ interface ErrorBody {
} }
} }
function extractDetail(error: unknown): string { function extractDetail(error: unknown, fallback: string): string {
const err = error as ErrorBody const err = error as ErrorBody
const detail = err.response?.data?.detail const detail = err.response?.data?.detail
if (typeof detail === 'string') return detail if (typeof detail === 'string') return detail
if (detail && typeof detail === 'object') return detail.message || detail.code || '操作失败' if (detail && typeof detail === 'object') return detail.message || detail.code || fallback
return '操作失败' return fallback
} }
function VerifyEmail() { function VerifyEmail() {
const { t } = useTranslation()
const navigate = useNavigate() const navigate = useNavigate()
const [search] = useSearchParams() const [search] = useSearchParams()
const [email, setEmail] = useState(search.get('email') || '') const [email, setEmail] = useState(search.get('email') || '')
@@ -47,7 +49,7 @@ function VerifyEmail() {
axios.defaults.headers.common.Authorization = `Bearer ${access_token}` axios.defaults.headers.common.Authorization = `Bearer ${access_token}`
navigate('/admin', { replace: true }) navigate('/admin', { replace: true })
} catch (error) { } catch (error) {
setFeedback({ tone: 'error', text: extractDetail(error) }) setFeedback({ tone: 'error', text: extractDetail(error, t('common.operationFailed')) })
} finally { } finally {
setLoading(false) setLoading(false)
} }
@@ -60,32 +62,32 @@ function VerifyEmail() {
try { try {
await axios.post(`${API_URL}/auth/resend-code`, { email, purpose: 'register' }) await axios.post(`${API_URL}/auth/resend-code`, { email, purpose: 'register' })
setCooldown(60) setCooldown(60)
setFeedback({ tone: 'success', text: '验证码已重发。' }) setFeedback({ tone: 'success', text: t('auth.resendSuccess') })
} catch (error) { } catch (error) {
const err = error as ErrorBody const err = error as ErrorBody
const detail = err.response?.data?.detail const detail = err.response?.data?.detail
if (detail && typeof detail === 'object' && detail.retry_after_seconds) setCooldown(detail.retry_after_seconds) if (detail && typeof detail === 'object' && detail.retry_after_seconds) setCooldown(detail.retry_after_seconds)
setFeedback({ tone: 'error', text: extractDetail(error) }) setFeedback({ tone: 'error', text: extractDetail(error, t('common.operationFailed')) })
} finally { } finally {
setResending(false) setResending(false)
} }
} }
return ( return (
<AuthShell eyebrow="Email verification" title="验证邮箱" description="输入邮箱验证码后会自动登录并进入控制台。"> <AuthShell eyebrow={t('auth.emailVerification')} title={t('auth.verifyEmail')} description={t('auth.verifyEmailDescription')}>
<AuthForm onSubmit={onVerify}> <AuthForm onSubmit={onVerify}>
{feedback ? <AuthNotice tone={feedback.tone}>{feedback.text}</AuthNotice> : null} {feedback ? <AuthNotice tone={feedback.tone}>{feedback.text}</AuthNotice> : null}
<AuthField label="邮箱"> <AuthField label={t('auth.email')}>
<AuthInput value={email} onChange={(event) => setEmail(event.target.value)} type="email" autoComplete="email" required /> <AuthInput value={email} onChange={(event) => setEmail(event.target.value)} type="email" autoComplete="email" required />
</AuthField> </AuthField>
<AuthField label="验证码"> <AuthField label={t('auth.code')}>
<AuthInput value={code} onChange={(event) => setCode(event.target.value)} maxLength={6} inputMode="numeric" required /> <AuthInput value={code} onChange={(event) => setCode(event.target.value)} maxLength={6} inputMode="numeric" required />
</AuthField> </AuthField>
<AuthButton type="submit" loading={loading} disabled={!email}></AuthButton> <AuthButton type="submit" loading={loading} disabled={!email}>{t('auth.verifyAndLogin')}</AuthButton>
<AuthLinks> <AuthLinks>
<BackToLogin /> <BackToLogin />
<button className="auth-link auth-link--button" type="button" disabled={cooldown > 0 || resending || !email} onClick={onResend}> <button className="auth-link auth-link--button" type="button" disabled={cooldown > 0 || resending || !email} onClick={onResend}>
{cooldown > 0 ? `重发 (${cooldown}s)` : '重新发送验证码'} {cooldown > 0 ? t('auth.resendCountdown', { seconds: cooldown }) : t('auth.resend')}
</button> </button>
</AuthLinks> </AuthLinks>
</AuthForm> </AuthForm>

View File

@@ -1,6 +1,6 @@
[project] [project]
name = "planet" name = "planet"
version = "0.72.0" version = "0.73.0"
description = "智能星球计划 - 态势感知系统" description = "智能星球计划 - 态势感知系统"
requires-python = ">=3.14" requires-python = ">=3.14"
dependencies = [ dependencies = [

View File

@@ -31,6 +31,24 @@ Load selectively:
Do not load the entire file by default for small tasks. Use `rg -n "^## Module:" rules.md` to find module boundaries, then read only the needed block. Do not load the entire file by default for small tasks. Use `rg -n "^## Module:" rules.md` to find module boundaries, then read only the needed block.
### Agent Discovery Index
Use these shortcuts when the user describes work in product language instead of
module names. Matching one of these phrases means the related module is
relevant, even if the exact module name is not mentioned.
| User wording / touched surface | Load modules |
| --- | --- |
| `一屏`, `首屏`, `高度没控住`, page grows past viewport, scroll/overflow, spacing/breathing, responsive, accessibility | `uiux`, `frontend` |
| Admin console, 控制台, sidebar/menu/search/tabs/dialog/table, theme/language switch, i18n, auth pages, public Docs UI | `frontend`, plus `uiux` for rendered/layout changes |
| Docs, 文档, 中英文, public Docs, manual, quickstart, README, plan status, stale route/section links | `docs`, plus the implementation module for the changed behavior |
| API, FastAPI, database, SQLAlchemy, collector, datasource, scheduler, credential/connectivity, pagination, slow endpoint | `backend` |
| Earth/地球, 3D/canvas/Three.js, BGP/vessel/satellite/cable, terrain/clouds, marker/icon, hover/lock/tooltip, flicker/z-fighting | `earth`, plus `uiux` for visible controls |
| AI Provider, model provider, Playground, prompt, mapping, custom collector, base URL, service token, OCR/WebSearch/Tavily integration | `ai`, plus `security` for credentials |
| Version/changelog/history/tag/release, `发版`, `发布`, `打包`, `版本号`, commit/push release work | `release`, `workflow` |
| Secrets, `.env`, API key, token, password, credential masking, auth/JWT/logout, logs containing sensitive data | `security`, plus touched implementation module |
| Dependency/package-manager/tooling, Bun/uv, npm/pnpm/yarn, lockfiles, dirty worktree, generated output | `workflow` |
--- ---
## Module: core ## Module: core
@@ -174,6 +192,10 @@ rg -n "<pattern>" <path>
### Load When ### Load When
Writing, translating, linking, restructuring, or publishing docs. Writing, translating, linking, restructuring, or publishing docs.
Also load this module when the user mentions docs in Chinese or product terms:
`文档`, `中英文`, `双语`, `public Docs`, `手册`, `quickstart`,
`README`, `计划`, `plan`, `过期说明`, `路由文档`, `section 链接`,
or asks to make rules easier to discover.
### Must ### Must
@@ -230,6 +252,27 @@ PY
### Load When ### Load When
Changing layout, visual hierarchy, controls, interaction states, responsive behavior, or accessibility. Changing layout, visual hierarchy, controls, interaction states, responsive behavior, or accessibility.
Also load this module when the user mentions Chinese layout terms such as
`一屏`, `首屏`, `高度没控住`, `页面撑出`, `滚动`, `溢出`, `呼吸感`,
`间距`, or `响应式`.
### Hard Rule: Admin One-Screen Workspaces
Backend/admin pages are compact single-screen workspaces (`一屏` / `首屏`):
- The route shell must resolve to the viewport through the existing
`html`, `body`, `#root`, route-root, and page-shell `height: 100%` chain.
- Intermediate shell nodes such as theme providers must not break the height
chain; they need `height: 100%`, `min-height: 0`, and explicit overflow
ownership when they wrap the page shell.
- Header, summary/controls, main work area, and fixed sidebar account/actions
must remain inside the first viewport on common desktop sizes.
- Long menus, tables, detail panels, logs, Markdown, JSON, and forms scroll
inside their intended region; the document/body/root must not become the
scroll owner.
- A build is not enough for height-critical changes. Use rendered validation
on the affected admin routes, and include 125% / 150% zoom when the change
touches shell, sidebar, panel, table, or scroll ownership.
### Must ### Must
@@ -276,6 +319,10 @@ changed=$(git diff --name-only HEAD -- frontend/src)
### Load When ### Load When
Editing React, TypeScript, CSS, Vite, Bun, admin console, public Docs UI, or client-side services. Editing React, TypeScript, CSS, Vite, Bun, admin console, public Docs UI, or client-side services.
Also load this module when the user mentions `控制台`, `Admin`, `Docs 页面`,
`登录/注册/忘记密码`, `i18n`, `语言切换`, `主题切换`, `搜索`, `菜单`,
`Tab`, `弹窗`, `表格`, `Markdown`, frontend build, or any file under
`frontend/src`.
### Must ### Must
@@ -318,6 +365,9 @@ rg -n "npm|pnpm|yarn" frontend package.json
### Load When ### Load When
Editing FastAPI, SQLAlchemy, collectors, database models, migrations, services, API routes, or performance-sensitive code. Editing FastAPI, SQLAlchemy, collectors, database models, migrations, services, API routes, or performance-sensitive code.
Also load this module when the user mentions `后端`, `接口`, `API`, `数据库`,
`迁移`, `采集器`, `数据源`, `凭证`, `连通性`, `调度`, `分页`, `性能`,
`慢查询`, `国家/地区`, or files under `backend/`.
### Must ### Must
@@ -371,6 +421,10 @@ rg -n "normalize_country|COUNTRY_ENTRIES" backend/app
### Load When ### Load When
Editing `frontend/public/earth`, 3D Earth, canvas/Three.js rendering, BGP/vessel/satellite/cable layers, geographic boundaries, or Earth marker icons. Editing `frontend/public/earth`, 3D Earth, canvas/Three.js rendering, BGP/vessel/satellite/cable layers, geographic boundaries, or Earth marker icons.
Also load this module when the user mentions `地球`, `三维`, `图层`, `船只`,
`卫星`, `海缆`, `BGP`, `算力中心`, `云图`, `地形`, `marker`, `图标`,
`hover`, `locked`, `tooltip`, `闪烁`, `黑块`, `雪花`, `z-fighting`,
or files under `frontend/public/earth`.
### Must ### Must
@@ -444,6 +498,9 @@ ls frontend/public/earth/assets/icons/
### Load When ### Load When
Editing AI Provider, LLM gateway, AI Playground, prompt templates, model selection, custom collector mapping generation, or LLM-assisted data transformation. Editing AI Provider, LLM gateway, AI Playground, prompt templates, model selection, custom collector mapping generation, or LLM-assisted data transformation.
Also load this module when the user mentions `AI Provider`, `模型供应商`,
`Playground`, `提示词`, `prompt`, `mapping`, `自定义采集器`, `base_url`,
`service_token`, `OCR`, `WebSearch`, `Tavily`, or provider credentials.
### Must ### Must
@@ -472,6 +529,9 @@ git diff --check -- backend aiprovider frontend/src
### Load When ### Load When
The user asks to `发版`, bump version, release, commit/push release work, or update changelog/version history as part of a release. The user asks to `发版`, bump version, release, commit/push release work, or update changelog/version history as part of a release.
Also load this module when the user mentions `发布`, `打包`, `版本号`,
`CHANGELOG`, `version-history`, tag, release branch, or asks to commit/push a
release-oriented change.
### Must ### Must

View File

@@ -243,6 +243,76 @@ def overflow_hidden_has_explicit_owner(selector: str, body: str) -> bool:
) )
def css_block_for_selector(text: str, wanted_selector: str) -> tuple[int, str] | None:
for line_no, selector, body in iter_css_blocks(text):
selectors = [item.strip() for item in selector.split(",")]
if wanted_selector in selectors:
return line_no, body
return None
def css_has_declaration(body: str, prop: str, value_pattern: str) -> bool:
return re.search(rf"(^|[;\n])\s*{re.escape(prop)}\s*:\s*{value_pattern}\s*;", body) is not None
def check_admin_shell_height_chain() -> None:
text = read_text("frontend/src/admin/styles.css")
required: dict[str, dict[str, str]] = {
".admin-theme-root": {
"min-height": r"0",
"height": r"100%",
"overflow": r"hidden",
},
".admin": {
"min-height": r"0",
"height": r"100%",
"display": r"grid",
"overflow": r"hidden",
},
".admin__sider": {
"min-height": r"0",
"height": r"100%",
"display": r"flex",
"overflow": r"hidden",
},
".admin__nav-scroll": {
"flex": r"1\s+1\s+auto",
"min-height": r"0",
"overflow": r"hidden",
},
".admin__account": {
"flex": r"0\s+0\s+auto",
},
".admin__content": {
"min-height": r"0",
"height": r"100%",
"display": r"grid",
"overflow": r"hidden",
},
".admin__content-inner": {
"min-height": r"0",
"height": r"100%",
"overflow": r"hidden",
},
}
for selector, declarations in required.items():
block = css_block_for_selector(text, selector)
if block is None:
fail(f"frontend/src/admin/styles.css: admin shell one-screen rule requires {selector}")
continue
line_no, body = block
for prop, value_pattern in declarations.items():
if css_has_declaration(body, prop, value_pattern):
continue
friendly_value = re.sub(r"\\s\+", " ", value_pattern)
fail(
"frontend/src/admin/styles.css:"
f"{line_no}: admin shell one-screen rule requires {selector} "
f"to declare {prop}: {friendly_value}"
)
def check_debug_output() -> None: def check_debug_output() -> None:
for path in iter_frontend_src_files(): for path in iter_frontend_src_files():
text = path.read_text(encoding="utf-8") text = path.read_text(encoding="utf-8")
@@ -447,6 +517,7 @@ def main() -> None:
check_no_nested_cards() check_no_nested_cards()
check_no_antd_layout_primitives() check_no_antd_layout_primitives()
check_connection_test_input_pattern() check_connection_test_input_pattern()
check_admin_shell_height_chain()
check_uiux_static_warnings() check_uiux_static_warnings()
for message in warnings: for message in warnings:

View File

@@ -182,7 +182,7 @@ function loadAdminManifestNavigationEntries() {
} }
const entries = [] const entries = []
const routePattern = /\{\s*path:\s*'([^']+)',\s*label:\s*'([^']+)',\s*group:\s*'([^']+)'/g const routePattern = /\{\s*path:\s*'([^']+)',\s*label:\s*'([^']+)'.*?\bgroup:\s*'([^']+)'/gs
for (const match of routesBlock.matchAll(routePattern)) { for (const match of routesBlock.matchAll(routePattern)) {
const groupLabel = groupLabels.get(match[3]) const groupLabel = groupLabels.get(match[3])
if (!groupLabel) { if (!groupLabel) {
@@ -487,6 +487,82 @@ async function checkNoGlobalHorizontalOverflow(page, route) {
} }
} }
async function checkAdminShellOneScreen(page, route, options = {}) {
const { expectAccountVisible = false, expectPreferencesVisible = false } = options
const layout = await page.evaluate(() => {
const rectFor = (selector) => {
const element = document.querySelector(selector)
if (!element) return null
const rect = element.getBoundingClientRect()
return {
top: rect.top,
bottom: rect.bottom,
height: rect.height,
clientHeight: element.clientHeight,
scrollHeight: element.scrollHeight,
}
}
const root = rectFor('#root')
const admin = rectFor('.admin')
const account = rectFor('.admin__account')
const preferences = rectFor('.admin__preferences-panel')
return {
viewportHeight: window.innerHeight,
documentOverflowY: document.documentElement.scrollHeight - document.documentElement.clientHeight,
bodyOverflowY: document.body.scrollHeight - document.body.clientHeight,
rootOverflowY: root ? root.scrollHeight - root.clientHeight : 0,
root,
admin,
account,
preferences,
}
})
if (!layout.admin) return
const tolerance = 2
const failures = []
const adminHeightDelta = Math.abs(layout.admin.height - layout.viewportHeight)
if (adminHeightDelta > tolerance) {
failures.push(
`.admin height ${layout.admin.height.toFixed(2)}px does not match viewport ${layout.viewportHeight}px`,
)
}
if (layout.admin.bottom > layout.viewportHeight + tolerance || layout.admin.top < -tolerance) {
failures.push(
`.admin escapes viewport (top=${layout.admin.top.toFixed(2)}, bottom=${layout.admin.bottom.toFixed(2)})`,
)
}
if (layout.rootOverflowY > tolerance) {
failures.push(`#root vertical overflow ${layout.rootOverflowY}px`)
}
if (layout.documentOverflowY > tolerance || layout.bodyOverflowY > tolerance) {
failures.push(
`document/body vertical overflow document=${layout.documentOverflowY}px body=${layout.bodyOverflowY}px`,
)
}
if (expectAccountVisible && layout.account) {
if (layout.account.top < -tolerance || layout.account.bottom > layout.viewportHeight + tolerance) {
failures.push(
`.admin__account is not fully in the first viewport ` +
`(top=${layout.account.top.toFixed(2)}, bottom=${layout.account.bottom.toFixed(2)})`,
)
}
}
if (expectPreferencesVisible && layout.preferences) {
if (layout.preferences.top < -tolerance || layout.preferences.bottom > layout.viewportHeight + tolerance) {
failures.push(
`.admin__preferences-panel is not fully in the first viewport ` +
`(top=${layout.preferences.top.toFixed(2)}, bottom=${layout.preferences.bottom.toFixed(2)})`,
)
}
}
if (failures.length) {
throw new Error(`${route}: admin shell one-screen check failed: ${failures.join('; ')}`)
}
}
const now = '2026-06-26T00:00:00Z' const now = '2026-06-26T00:00:00Z'
const smokeAuthUser = { const smokeAuthUser = {
id: 1, id: 1,
@@ -823,9 +899,12 @@ async function runAuthenticatedAdminChecks(browser, failures, consoleErrors, opt
} }
await expectVisibleText(page, route.text, routeLabel) await expectVisibleText(page, route.text, routeLabel)
if (expectAccount) { if (expectAccount) {
await expectVisibleText(page, 'Hi, smoke-admin', routeLabel) await expectVisibleText(page, '您好,smoke-admin', routeLabel)
} }
await checkNoFrameworkOverlay(page, routeLabel) await checkNoFrameworkOverlay(page, routeLabel)
if (!zoom) {
await checkAdminShellOneScreen(page, routeLabel, { expectAccountVisible: expectAccount })
}
if (checkHorizontalOverflow) { if (checkHorizontalOverflow) {
await checkNoGlobalHorizontalOverflow(page, routeLabel) await checkNoGlobalHorizontalOverflow(page, routeLabel)
} }
@@ -884,6 +963,34 @@ async function runAdminInteractionChecks(browser, failures, consoleErrors) {
)) ))
await expectVisibleText(page, 'SMTP 邮件', 'global search smtp') await expectVisibleText(page, 'SMTP 邮件', 'global search smtp')
await page.goto(urlFor('/admin'), { waitUntil: 'domcontentloaded' })
await clickFirstVisible(page.getByRole('button', { name: '展开偏好设置' }), 'admin preferences drawer open')
await page.waitForTimeout(350)
await checkAdminShellOneScreen(page, 'admin preferences drawer open', {
expectAccountVisible: true,
expectPreferencesVisible: true,
})
await clickFirstVisible(
page.getByRole('group', { name: '控制台语言' }).getByRole('button', { name: 'EN' }),
'admin language switch english',
)
await expectVisibleText(page, 'Dashboard', 'admin language switch english')
await page.getByLabel('Search features, settings, and text').fill('SMTP')
await clickFirstVisible(page.getByRole('option', { name: /SMTP/i }), 'admin english global search smtp option')
await expectUrl(page, 'admin english global search smtp', (location) => (
location.pathname === '/settings' && location.search.includes('section=smtp')
))
await expectVisibleText(page, 'SMTP Email', 'admin english global search smtp')
const reopenEnglishPreferences = await firstVisibleOrNull(page.getByRole('button', { name: 'Expand preferences' }), 500)
if (reopenEnglishPreferences) {
await reopenEnglishPreferences.click()
}
await clickFirstVisible(
page.getByRole('group', { name: 'Console language' }).getByRole('button', { name: '中文' }),
'admin language switch chinese',
)
await expectVisibleText(page, 'SMTP 邮件', 'admin language switch chinese')
await page.goto(urlFor('/ai'), { waitUntil: 'domcontentloaded' }) await page.goto(urlFor('/ai'), { waitUntil: 'domcontentloaded' })
await clickFirstVisible(page.locator('a[aria-label="设置"][href="/settings"]'), 'ai settings shortcut') await clickFirstVisible(page.locator('a[aria-label="设置"][href="/settings"]'), 'ai settings shortcut')
await expectUrl(page, 'ai settings shortcut', (location) => location.pathname === '/settings') await expectUrl(page, 'ai settings shortcut', (location) => location.pathname === '/settings')
@@ -1079,7 +1186,7 @@ async function runAuthInteractionChecks(browser, failures, consoleErrors) {
await page.getByRole('button', { name: '验证并登录' }).click() await page.getByRole('button', { name: '验证并登录' }).click()
await expectUrl(page, 'register verify login', (location) => location.pathname === '/admin') await expectUrl(page, 'register verify login', (location) => location.pathname === '/admin')
await expectVisibleText(page, '仪表盘', 'register verify login') await expectVisibleText(page, '仪表盘', 'register verify login')
await expectVisibleText(page, 'Hi, smoke-auth', 'register verify login') await expectVisibleText(page, '您好,smoke-auth', 'register verify login')
}) })
await withAuthPage('forgot password reset', async (page) => { await withAuthPage('forgot password reset', async (page) => {
@@ -1106,7 +1213,7 @@ async function runAuthInteractionChecks(browser, failures, consoleErrors) {
await page.getByRole('button', { name: '验证并登录' }).click() await page.getByRole('button', { name: '验证并登录' }).click()
await expectUrl(page, 'standalone verify email', (location) => location.pathname === '/admin') await expectUrl(page, 'standalone verify email', (location) => location.pathname === '/admin')
await expectVisibleText(page, '仪表盘', 'standalone verify email') await expectVisibleText(page, '仪表盘', 'standalone verify email')
await expectVisibleText(page, 'Hi, smoke-auth', 'standalone verify email') await expectVisibleText(page, '您好,smoke-auth', 'standalone verify email')
}) })
} }

2
uv.lock generated
View File

@@ -757,7 +757,7 @@ wheels = [
[[package]] [[package]]
name = "planet" name = "planet"
version = "0.72.0" version = "0.73.0"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "aiofiles" }, { name = "aiofiles" },