release: bump version to 0.73.0
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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. |
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
62
docs/plans/admin-console-i18n-plan.md
Normal file
62
docs/plans/admin-console-i18n-plan.md
Normal 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`:当前实现上下文。
|
||||||
@@ -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:
|
||||||
|
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|
||||||
文件:
|
文件:
|
||||||
|
|
||||||
|
|||||||
@@ -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 与双语文档 |
|
||||||
|
|||||||
@@ -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=="],
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
159
frontend/src/i18n/LegacyI18nBridge.tsx
Normal file
159
frontend/src/i18n/LegacyI18nBridge.tsx
Normal 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
|
||||||
|
}
|
||||||
27
frontend/src/i18n/index.ts
Normal file
27
frontend/src/i18n/index.ts
Normal 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
|
||||||
589
frontend/src/i18n/legacy-ui.ts
Normal file
589
frontend/src/i18n/legacy-ui.ts
Normal 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',
|
||||||
|
}
|
||||||
68
frontend/src/i18n/locale.ts
Normal file
68
frontend/src/i18n/locale.ts
Normal 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 }
|
||||||
|
}
|
||||||
430
frontend/src/i18n/resources.ts
Normal file
430
frontend/src/i18n/resources.ts
Normal 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
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 = [
|
||||||
|
|||||||
60
rules.md
60
rules.md
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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')
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user