release: bump version to 0.74.0
BIN
.codex/screenshots/earth-i18n-current-page.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
.codex/screenshots/earth-i18n-hd-texture-status.png
Normal file
|
After Width: | Height: | Size: 4.4 KiB |
BIN
.codex/screenshots/i18n-admin-data-sidebar.png
Normal file
|
After Width: | Height: | Size: 109 KiB |
BIN
.codex/screenshots/i18n-ai-tool-calls.png
Normal file
|
After Width: | Height: | Size: 124 KiB |
BIN
.codex/screenshots/i18n-earth-brand-config.png
Normal file
|
After Width: | Height: | Size: 170 KiB |
BIN
.codex/screenshots/i18n-earth-hud-brand.png
Normal file
|
After Width: | Height: | Size: 772 KiB |
BIN
.codex/screenshots/i18n-settings-notifications.png
Normal file
|
After Width: | Height: | Size: 95 KiB |
BIN
.codex/screenshots/i18n-settings-system.png
Normal file
|
After Width: | Height: | Size: 96 KiB |
BIN
.codex/screenshots/sidebar-left-align.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
@@ -45,6 +45,11 @@ git diff --name-only HEAD
|
||||
git diff --unified=0 HEAD -- <path>
|
||||
```
|
||||
|
||||
When the user provides screenshots or images, inspect the actual image before
|
||||
making visual claims. If the referenced path is missing, search alternate
|
||||
attachment/worktree/local locations or ask for the file; never guess the image
|
||||
content from text, filenames, or memory.
|
||||
|
||||
Preserve user changes already present in the worktree.
|
||||
|
||||
### Validation
|
||||
|
||||
@@ -865,9 +865,9 @@ def _get_locale_text(
|
||||
if isinstance(fallback, dict):
|
||||
value = _coerce_str(fallback.get(key))
|
||||
if value:
|
||||
if locale == "en-US" and _contains_cjk_text(value):
|
||||
return ""
|
||||
return value
|
||||
if locale == "en-US" and _is_chinese_language(item.content_language):
|
||||
return item.title if key == "title" else item.summary
|
||||
return ""
|
||||
|
||||
|
||||
|
||||
@@ -384,7 +384,14 @@ def test_parse_chinese_rss_marks_source_language_and_keeps_zh_localization():
|
||||
assert items[0].content_language == "zh-CN"
|
||||
assert items[0].localizations["zh-CN"]["title"] == "中国电商平台发布季度增长数据"
|
||||
assert payload_zh["display_title"] == "中国电商平台发布季度增长数据"
|
||||
assert payload_en["display_title"] == "中国电商平台发布季度增长数据"
|
||||
assert payload_en["display_title"] == ""
|
||||
|
||||
items[0].localizations["en-US"] = {
|
||||
"title": "Chinese e-commerce platform reports quarterly growth",
|
||||
"summary": "The platform said cross-border orders rose year over year.",
|
||||
}
|
||||
payload_en_ready = _serialize_item(items[0], active_region="global", locale="en-US")
|
||||
assert payload_en_ready["display_title"] == "Chinese e-commerce platform reports quarterly growth"
|
||||
|
||||
|
||||
def test_default_news_sources_include_business_and_ecommerce_sources():
|
||||
|
||||
@@ -8,6 +8,24 @@ This project follows the repository versioning rule:
|
||||
- `improvement` -> `+0.0.1`(bugfix + 小功能混合)
|
||||
- `bugfix` -> `+0.0.1`
|
||||
|
||||
## [0.74.0] — 2026-06-30
|
||||
|
||||
Released: 2026-06-30
|
||||
|
||||
### Highlights
|
||||
- 扩展统一 i18n 到 Web Earth、控制台、认证页和公开 Docs 的更多动态入口,减少英文界面中文残留。
|
||||
- 强化 Earth HUD 的通知胶囊、品牌栏、语言 switch、图例、tooltip、详情卡、新闻和 TV 文案展示,避免英文态裁切或错位。
|
||||
- 将容易遗漏的 i18n 入口和视觉回归加入 harness,让 smoke 覆盖通知位置、内容宽度、语言切换状态和动态文案。
|
||||
|
||||
### Added / Fixed / Improved
|
||||
- 新增 Earth runtime i18n 入口,统一高清材质、启动状态、错误提示和图层状态文案来源。
|
||||
- 补齐国家名、属性名、卫星 legend、详情页 tooltip、新闻/TV 默认文案和 API 错误提示的英文翻译与回退。
|
||||
- 更新控制台与 Earth 布局规则,保留 brand 尺寸语义,同时让标题、副标题和通知胶囊按内容完整显示。
|
||||
- 扩展 frontend smoke 与 harness 文档,固化一屏高度链、i18n 动态入口、胶囊/tag overflow 和截图证据要求。
|
||||
- 更新 Earth 新闻本地化服务与测试,确保英文界面新闻内容不再回退中文 UI 文案。
|
||||
|
||||
---
|
||||
|
||||
## [0.73.0] — 2026-06-29
|
||||
|
||||
Released: 2026-06-29
|
||||
|
||||
@@ -84,7 +84,7 @@ git diff --unified=0 HEAD -- <path>
|
||||
| Doctor | `scripts/harness/doctor.sh` | Checks required files, required tools, optional delivery tools, and forbidden frontend lockfiles. |
|
||||
| Security | `scripts/harness/security-check.sh` | Checks that environment/private-key files are not tracked and scans for high-confidence committed secret tokens. |
|
||||
| Backend Rules | `scripts/harness/backend-rules-check.sh` | Checks backend app Python for direct `print()`, `breakpoint()`, and `pdb.set_trace()` debug calls so service code uses structured logging. |
|
||||
| Frontend Rules | `scripts/harness/frontend-rules-check.sh` | Checks Bun-only scripts, admin route manifest coherence, literal internal route links, admin search route targets, frontend debug output, native button safety, icon-button accessibility, no nested Cards, no AntD/Space layout primitives, ConnectionTestInput usage, admin/docs shell height-chain sizing, viewport-scaled font sizes, zero letter spacing, and high-signal UI rule warnings. |
|
||||
| Frontend Rules | `scripts/harness/frontend-rules-check.sh` | Checks Bun-only scripts, admin route manifest coherence, literal internal route links, admin search route targets, frontend debug output, native button safety, icon-button accessibility, no nested Cards, no AntD/Space layout primitives, ConnectionTestInput usage, admin/docs shell height-chain sizing, same-category style owner warnings, viewport-scaled font sizes, zero letter spacing, and high-signal UI rule warnings. |
|
||||
| Docs Consistency | `scripts/harness/docs-consistency-check.sh` | Checks frontend Docs metadata against backend Gatekeeper metadata, public Docs registration, full technical-doc bilingual file pairs, public doc links, readable link titles, language-scoped technical links, README/project-context admin stack drift, supported credential collector contracts, manual console route coverage against the actual admin manifest, documented UI route drift, documented `?section=` deep-link validity against the actual admin section config in technical docs and active plan docs, and the harness rules-coverage notes. |
|
||||
| Quick | `scripts/harness/quick-check.sh` | Runs doctor, whitespace diff check, shell syntax checks, security scan, backend/frontend/doc consistency checks, and CI backend smoke tests. |
|
||||
| Full | `scripts/harness/validate.sh` | Runs quick check, frontend Bun install/build, Playwright route smoke, optional Helm checks, and opt-in Docker image smoke builds. |
|
||||
@@ -130,6 +130,64 @@ PLANET_HARNESS_FRONTEND_SMOKE=0 scripts/harness/validate.sh
|
||||
PLANET_HARNESS_FRONTEND_SMOKE_PORT=4174 scripts/harness/validate.sh
|
||||
```
|
||||
|
||||
### Visual Evidence And Style Consistency
|
||||
|
||||
- Treat user-provided screenshots and images as primary visual evidence. If a
|
||||
screenshot contradicts written text, inspect the image first and explicitly
|
||||
call out the mismatch before deciding what to change.
|
||||
- If a referenced screenshot path cannot be opened, do not infer image content
|
||||
from the filename, alt text, surrounding prose, or memory. Search the current
|
||||
thread attachments, repository, and obvious local attachment/download
|
||||
locations; if the image still cannot be found, ask for the file or report the
|
||||
blocker instead of guessing.
|
||||
- Same-category UI surfaces must use one visual system per product area. Badges,
|
||||
chips, pills, tags, status labels, small buttons, cards, panels, and toolbar
|
||||
controls should reuse the shared component, shared token, or established CSS
|
||||
owner for that area instead of introducing a page-local lookalike.
|
||||
- `scripts/harness/frontend-rules-check.sh` warns when semantic
|
||||
`badge` / `chip` / `pill` / `tag` / `status` selectors appear outside the
|
||||
approved React and Earth CSS owner files. A warning means the reviewer should
|
||||
either move the style into the shared owner or document why this is a genuinely
|
||||
new visual family.
|
||||
|
||||
### Earth I18n Harness Rules
|
||||
|
||||
Earth i18n work must validate rendered behavior, not only static text lookup.
|
||||
Agents often miss dynamic strings that are created after initial page load, so
|
||||
the smoke treats these as first-class i18n surfaces:
|
||||
|
||||
- **Visible text and attributes**: translated checks must include `innerText`
|
||||
plus `title`, `aria-label`, `placeholder`, and `alt`. Tooltips and icon-only
|
||||
buttons are user-facing copy, not implementation details.
|
||||
- **Dynamic detail cards**: info cards opened from Earth markers, cruise cards,
|
||||
BGP markers, compute centers, vessels, and news must render field labels,
|
||||
status values, source tags, action buttons, and disabled/tooltips in the active
|
||||
language.
|
||||
- **English content safety**: English mode must not fall back to Chinese news
|
||||
titles, summaries, feed names, measure words, or generic status labels. If no
|
||||
English localization exists, hide the item or use a neutral English fallback.
|
||||
- **Brand assets**: locale switching must update both text and image assets.
|
||||
The default Earth HUD brand uses `title-zh.png` for Chinese and `title-en.png`
|
||||
for English while keeping the same top-left layout and logo position.
|
||||
- **Controls and state**: switch/segmented-control visuals must follow the real
|
||||
checked/pressed state after both direct clicks and programmatic panel changes.
|
||||
A control is not valid if the state changes but the thumb, active pill, or
|
||||
`aria-*` state stays stale.
|
||||
- **Runtime copy entrypoints**: dynamic status, loading, startup, and error copy
|
||||
must enter through `earthMessage(...)` plus the centralized
|
||||
`EARTH_MESSAGE_TEMPLATES` map. Do not hide direct strings behind
|
||||
`showStatusMessage`, `queueStatusMessage`, `showGestureStatusMessage`,
|
||||
`showError`, `setLoadingMessage`, `resolveStartupMessage`, `startupMessage`,
|
||||
or `earth:status` events.
|
||||
- **Capsules and tags**: pills, tags, chips, badges, and small buttons must not
|
||||
overflow their panel. Prefer a slightly wider owning panel for important
|
||||
status information; otherwise use `min-width: 0`, wrapping, or ellipsis with a
|
||||
translated tooltip.
|
||||
|
||||
Current frontend smoke explicitly covers the Earth English locale flow: brand
|
||||
image swap, settings language controls, panel switch visual sync, English news
|
||||
filtering, English detail-card text and tooltips, and TV default/source labels.
|
||||
|
||||
## Environment Requirements
|
||||
|
||||
Required for normal development:
|
||||
|
||||
@@ -102,6 +102,8 @@ The repository uses `.gitea/workflows/`, not `.github/workflows/`.
|
||||
| Admin/docs shell layouts can reintroduce brittle viewport sizing after a responsive fix. | Changed the admin and Docs route shells to use the existing `html/body/#root` 100% height chain, and added a frontend rules failure for exact `100vh` / `100vw` shell sizing in those CSS files. |
|
||||
| Compact workspaces can drift back into card-in-card layouts or implicit AntD `Space` wrappers. | Added frontend rules failures for nested `Card` components, AntD imports, and `<Space>` layout primitives in active frontend source. |
|
||||
| Connection-test controls can drift back into detached toolbar buttons. | Added a shared `ConnectionTestInput` suffix pattern for AI Provider and WebSearch Base URL fields, disabled WebSearch configuration/test controls when the tool is off, and made the frontend rules check fail detached AI/WebSearch connection-test buttons. |
|
||||
| Same-category UI styles can fragment into page-local lookalikes. | Added same-category style owner warnings for semantic `badge`, `chip`, `pill`, `tag`, and `status` CSS selectors outside the approved shared React and Earth CSS owner files. |
|
||||
| Visual fixes can go wrong when agents guess from missing screenshot paths. | Documented screenshots and images as primary visual evidence: if the path is not available, agents must search alternate attachment/local locations or report the blocker instead of inferring image content. |
|
||||
| Public docs can reference stale admin section URLs. | Added docs consistency validation for documented `?section=` links and rendered smoke coverage for documented AI / collector deep links. |
|
||||
| Active plan docs can preserve old admin deep-link assumptions after the technical docs are corrected. | Extended docs consistency checks to active `docs/plans/*.md` files for stale admin tab-query terms and actual `?section=` validity; corrected the docs audience split plan to current section routes. |
|
||||
| Top-level README can drift from the actual frontend stack while technical docs stay current. | Updated README from Ant Design Pro to Tactile UI / Radix primitives / lucide-react and added README stale admin-stack terms to docs consistency checks. |
|
||||
@@ -135,6 +137,7 @@ index is the routing layer; this table is the coverage/evidence layer.
|
||||
| `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` 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` | Same-category visual surfaces should use one style system per product area. | `frontend-rules-check.sh` warns when semantic `badge`, `chip`, `pill`, `tag`, or `status` selectors appear outside approved shared React and Earth CSS owner files. `docs/HARNESS.md` also makes screenshots/images primary evidence for visual fixes and forbids guessing when an image path is unavailable. | Final visual cohesion across screenshots still needs human/Playwright review, especially for page-specific cards, panels, and toolbar controls that static selector checks cannot classify perfectly. |
|
||||
| `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` | Responsive adaptations must preserve the primary action path. | `frontend-smoke.mjs` clicks every visible admin menu entry on desktop and mobile, opens protected routes unauthenticated and authenticated, verifies root/unknown route fallback, and exercises core auth flows. | Deep feature workflows beyond smoke data, such as destructive or long-running actions, require targeted tests before behavior changes. |
|
||||
@@ -151,7 +154,7 @@ index is the routing layer; this table is the coverage/evidence layer.
|
||||
| `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/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 shell one-screen height-chain declarations, 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, same-category style owner warnings, 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/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. |
|
||||
|
||||
@@ -75,7 +75,11 @@ This is currently the most critical UI control entry point for the Earth fronten
|
||||
|
||||
Earth settings are now grouped by `data-settings-tab` and `data-settings-tab-panel`. Desktop and mobile share the same category semantics: Runtime, Display, Panels, Motion, Shortcuts, and System. When adding a setting, first choose its category, then add the DOM, persistence field, and restore logic; do not keep growing one long undifferentiated panel.
|
||||
|
||||
The news category selector in Display reuses the same chip-selector pattern as Cruise Modules. It only filters news categories for the current browser on the Earth frontend. It does not toggle layers, basemap, boundaries, TV, data points, BGP, vessels, satellites, or compute centers; those remain owned by the layer panel, media panel, and admin configuration. `controls.js` persists only `shared.newsCategoryFilters` and broadcasts `earth:news-category-filters-change`; `news.js` sends the selected categories to `/api/v1/news/earth-feed?categories=...&locale=zh-CN`, so Web and UE clients share the same backend category filtering path.
|
||||
Earth runs inside an independent iframe / static application, so it cannot directly reuse React Admin's `react-i18next` context. `public/earth` uses its own [i18n.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/i18n.js) runtime to read and write the global `planet-locale`, while keeping the legacy `docs-lang` in sync. This keeps Docs, the console, and Earth on the same language preference. The language switch belongs in the Settings `System` tab, and desktop/mobile both use the same `data-earth-locale` buttons; do not put language selection into layer, display, or runtime-mode settings.
|
||||
|
||||
The news category selector in Display reuses the same chip-selector pattern as Cruise Modules. It only filters news categories for the current browser on the Earth frontend. It does not toggle layers, basemap, boundaries, TV, data points, BGP, vessels, satellites, or compute centers; those remain owned by the layer panel, media panel, and admin configuration. `controls.js` persists only `shared.newsCategoryFilters` and broadcasts `earth:news-category-filters-change`; `news.js` sends the selected categories and current locale to `/api/v1/news/earth-feed?categories=...&locale=...`, so Web and UE clients share the same backend category filtering path.
|
||||
|
||||
In English mode, Earth news must render only English title/summary text. Chinese source items without `en-US` localization are filtered from the visible cards/ticker/cruise until the backend enrichment finishes, and source/feed labels fall back to English-safe names instead of rendering Chinese labels.
|
||||
|
||||
The news panel, ticker, and news cruise must consume `items` / `cruise_items` from the same `/api/v1/news/earth-feed` response instead of keeping separate regional caches. `news.js` builds a refresh request key from region, category, source, and limit; only concurrent requests with the same key reuse the promise, and stale responses from an older region are dropped by token. Source filtering is also region-scoped: when the user moves from Asia Pacific to Europe or another region, source IDs saved for the old region must not be appended to the next fetch. After the new payload arrives, the saved source list is intersected with the available `sources`; if the intersection is empty, the current region falls back to all available sources. This keeps the ticker, panel, and cruise cards aligned after region switches.
|
||||
|
||||
@@ -242,6 +246,8 @@ Earth settings are stored in `localStorage`. The key is typically a namespaced s
|
||||
|
||||
Settings that affect visual layers and surface interaction (terrain opacity, day/night mode, satellite display style, satellite idle breathing, real satellite altitude, track display, hover tooltip mode, etc.) are read during initialization and applied immediately.
|
||||
|
||||
Language preference is the exception: Earth language is not a private `planet.earth.settings.v2` field. It shares `planet-locale` with Docs/Admin. When language changes, `i18n.js` updates `document.documentElement.lang`, translates static and dynamically inserted DOM, synchronizes language button state, and passes the current locale to news requests. The news module refreshes after a language change so an English UI does not reuse a Chinese news payload.
|
||||
|
||||
The surface hover tooltip preference is persisted by `controls.js` as `shared.surfaceHoverInfoMode`, while `main.js` composes the actual tooltip in the globe-surface hover branch. `Country` shows country details only when a country polygon is hit and stays silent over ocean; `Position` shows latitude, longitude, and sampled terrain elevation and clears country-boundary hover; `Full` shows country + position on land and position over ocean.
|
||||
|
||||
The real satellite altitude preference is persisted by `controls.js`, while the rendering state lives in `satellites.js`. When enabled, the real radius from SGP4 is compressed logarithmically into the current Earth visual radius range. When disabled, satellite dots, trails, and predicted orbits all return to the legacy same-sphere display. Toggling this setting must refresh satellite positions and clear trail buffers so a trail never mixes both height models. `maxRealAltitudeOffset = 25` is a visual cap tuned for the current camera and `earthRadius = 100`: GEO / MEO remain clearly higher than LEO, but the highest orbits stay within about 25% beyond the globe radius so selection targets, red trails, and the globe do not feel disconnected.
|
||||
|
||||
@@ -144,6 +144,8 @@ The Web Earth client and UE client both consume `GET /api/v1/news/earth-feed`. T
|
||||
- `categories`: comma-separated news category keys, for example `business,ecommerce`. Omit it when all categories are selected.
|
||||
- `locale`: display locale, currently `zh-CN` or `en-US`, defaulting to `zh-CN`. Chinese RSS items are stored as Chinese source content and enriched with `en-US`; English RSS items are enriched with `zh-CN`.
|
||||
|
||||
For `locale=en-US`, the API must not fall back to Chinese source title/summary. If a Chinese source item has not yet received an `en-US` localization, `display_title` and `display_summary` stay empty so the Earth client can show an English pending/empty state instead of mixing languages.
|
||||
|
||||
Examples:
|
||||
|
||||
```http
|
||||
|
||||
@@ -75,7 +75,11 @@ Earth 收到 `/ws` 的 `earth_updates` 时只把它当作刷新提示,真实
|
||||
|
||||
Earth 设置面板现在按 `data-settings-tab` 和 `data-settings-tab-panel` 分类组织。桌面端和移动端使用同一组分类语义:运行、显示、面板、动捕、快捷键、系统。新增设置项时应先判断它属于哪个分类,再补 DOM、持久化字段和恢复逻辑;不要把所有控件继续堆到一个长面板里。
|
||||
|
||||
`显示` 分类里的新闻类型选择复用巡航模块的 chip 选择器形态,只控制星球端当前浏览器的新闻分类显示。它不会打开或关闭图层、底图、边界、TV、数据点、BGP、船舶、卫星或算力中心;这些仍由图层面板、媒体面板和控制台配置各自负责。`controls.js` 只持久化 `shared.newsCategoryFilters` 并广播 `earth:news-category-filters-change`,`news.js` 会把选中的类型拼到 `/api/v1/news/earth-feed?categories=...&locale=zh-CN`,让 Web 和 UE 走同一套后端类型过滤。
|
||||
Earth 运行在独立 iframe / 静态应用里,不能直接复用 React Admin 的 `react-i18next` 上下文。`public/earth` 自己通过 [i18n.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/i18n.js) 读取并写回全局 `planet-locale`,同时同步旧的 `docs-lang`,这样 Docs、控制台和 Earth 的语言偏好保持一致。语言切换控件属于设置里的 `系统` tab,桌面和移动端都使用同一组 `data-earth-locale` 按钮;不要把语言选择塞进图层、显示或运行模式设置里。
|
||||
|
||||
`显示` 分类里的新闻类型选择复用巡航模块的 chip 选择器形态,只控制星球端当前浏览器的新闻分类显示。它不会打开或关闭图层、底图、边界、TV、数据点、BGP、船舶、卫星或算力中心;这些仍由图层面板、媒体面板和控制台配置各自负责。`controls.js` 只持久化 `shared.newsCategoryFilters` 并广播 `earth:news-category-filters-change`,`news.js` 会把选中的类型和当前 locale 拼到 `/api/v1/news/earth-feed?categories=...&locale=...`,让 Web 和 UE 走同一套后端类型过滤。
|
||||
|
||||
英文模式下,Earth 新闻只能渲染英文标题和摘要。中文来源新闻如果还没有 `en-US` 本地化,会先从可见卡片、滚动条和巡航中隐藏,直到后端增强完成;来源和 feed 标签也必须回退到英文安全名称,避免英文界面混入中文标签。
|
||||
|
||||
新闻面板、滚动条和新闻巡航必须消费同一次 `/api/v1/news/earth-feed` 响应里的 `items` / `cruise_items`,不能各自缓存区域状态。`news.js` 的刷新请求以区域、类型、来源和数量生成 request key;只有 key 相同的并发请求才复用 promise,旧区域请求返回时会被 token 丢弃。来源过滤也必须按区域作用域处理:当用户从亚太切到欧洲等其它区域时,旧区域保存的来源 ID 不允许继续拼到下一次 fetch 里;拿到新 payload 后再与 `sources` 列表做交集,若没有交集则回退到当前区域所有可用来源。这样滚动条、面板和巡航才会在区域切换后展示同一批新闻。
|
||||
|
||||
@@ -457,6 +461,8 @@ Earth 设置面板当前由 [controls.js](/home/ray/dev/linkong/planet/frontend/
|
||||
|
||||
也就是说,Earth 设置不是一次性 UI 状态了,而是本地设备级偏好。后续如果再加入新的设置项,应优先接入同一条持久化链,而不是各自散着写 `localStorage`。
|
||||
|
||||
语言偏好例外:Earth 语言不是 `planet.earth.settings.v2` 的私有字段,而是与 Docs/Admin 共用 `planet-locale`。切换语言时,`i18n.js` 会更新 `document.documentElement.lang`、翻译静态和动态插入的 DOM、同步语言按钮状态,并向新闻请求传递当前 locale;新闻模块在语言变化后会重新刷新,避免英文界面复用中文新闻 payload。
|
||||
|
||||
地表 hover 提示由 `controls.js` 持久化为 `shared.surfaceHoverInfoMode`,实际 tooltip 在 `main.js` 的地表 hover 分支组合。`国家` 模式只在命中国家时显示国家信息,海洋区域不显示地表 tooltip;`位置` 模式只显示纬度、经度和地形采样海拔,并清除国家边界 hover;`完整` 模式在陆地显示国家 + 位置,在海洋显示位置。
|
||||
|
||||
卫星真实高度开关由 `controls.js` 持久化,实际渲染状态在 `satellites.js`。开启时,SGP4 得到的真实半径会按对数压缩到当前 Earth 视觉半径范围;关闭时,卫星点、轨迹和预测轨道都回到旧版同层球面。切换时必须刷新卫星位置并清理轨迹缓存,避免同一条轨迹混入两个高度模型。`maxRealAltitudeOffset = 25` 是当前相机和 `earthRadius = 100` 下的视觉上限:它让 GEO / MEO 比 LEO 明显更高,但把最高轨道控制在地球半径外约 25%,避免选择点、红色轨迹和主体地球之间出现过大的空场。
|
||||
|
||||
@@ -144,6 +144,8 @@ JSON 导入首版只支持数组:
|
||||
- `categories`:逗号分隔的新闻类型 key,例如 `business,ecommerce`。全选时可以不传。
|
||||
- `locale`:展示语言,支持 `zh-CN` 和 `en-US`,默认 `zh-CN`。中文 RSS 会以中文原文入库,并由后台补 `en-US`;英文 RSS 则由后台补 `zh-CN`。
|
||||
|
||||
当 `locale=en-US` 时,接口不能回退展示中文原文标题或摘要。中文来源新闻尚未生成 `en-US` 本地化时,`display_title` 和 `display_summary` 保持为空,由 Earth 前端展示英文待处理或空态,避免英文界面混入中文新闻内容。
|
||||
|
||||
示例:
|
||||
|
||||
```http
|
||||
|
||||
@@ -16,12 +16,13 @@
|
||||
## Current Version
|
||||
|
||||
- `main` 当前主线历史推导到:`0.16.5`
|
||||
- `dev` 当前开发分支历史推导到:`0.73.0`
|
||||
- `dev` 当前开发分支历史推导到:`0.74.0`
|
||||
|
||||
## Timeline
|
||||
|
||||
| Version | Type | Branch | Commit | Summary |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `0.74.0` | feature | `dev` | `pending` | 扩展统一 i18n 到 Web Earth 动态入口、控制台/API 错误和公开页面,修复 Earth 通知胶囊、语言 switch、品牌栏、legend、tooltip、新闻/TV 英文态裁切与中文残留,并加入 harness 回归覆盖 |
|
||||
| `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.71.1` | bugfix | `dev` | `pending` | 修复 Earth 新闻区域切换、滚动条/面板/巡航一致性和新闻精修队列饿死问题,并补充 agent harness 与双语维护文档 |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "planet-frontend",
|
||||
"version": "0.73.0",
|
||||
"version": "0.74.0",
|
||||
"private": true,
|
||||
"packageManager": "bun@1",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1157,15 +1157,18 @@
|
||||
|
||||
.earth-mobile-tv-overview-tags {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-top: 1px;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.earth-mobile-tv-overview-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
min-height: 20px;
|
||||
padding: 0 6px;
|
||||
border-radius: 999px;
|
||||
@@ -1173,8 +1176,9 @@
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: var(--hud-text);
|
||||
font-size: 0.62rem;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
line-height: 1.15;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.earth-mobile-tv-overview-tag--status {
|
||||
@@ -1817,11 +1821,15 @@
|
||||
.earth-mobile-settings-pill {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: var(--hud-text-soft);
|
||||
padding: 10px 14px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.earth-mobile-settings-pill.is-active {
|
||||
@@ -1835,6 +1843,9 @@
|
||||
}
|
||||
|
||||
.earth-mobile-settings-chip {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
border: 1px solid rgba(212, 227, 244, 0.12);
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
@@ -1844,6 +1855,9 @@
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.18s ease,
|
||||
@@ -1900,11 +1914,13 @@
|
||||
transition: transform 0.18s ease;
|
||||
}
|
||||
|
||||
.earth-mobile-settings-switch input:checked + .earth-mobile-settings-switch-track {
|
||||
.earth-mobile-settings-switch input:checked + .earth-mobile-settings-switch-track,
|
||||
.earth-mobile-settings-switch.is-checked .earth-mobile-settings-switch-track {
|
||||
background: rgba(122, 180, 255, 0.34);
|
||||
}
|
||||
|
||||
.earth-mobile-settings-switch input:checked + .earth-mobile-settings-switch-track::after {
|
||||
.earth-mobile-settings-switch input:checked + .earth-mobile-settings-switch-track::after,
|
||||
.earth-mobile-settings-switch.is-checked .earth-mobile-settings-switch-track::after {
|
||||
transform: translate(16px, -50%);
|
||||
}
|
||||
|
||||
@@ -2145,10 +2161,10 @@ label.is-disabled.earth-mobile-settings-card {
|
||||
.earth-status-message,
|
||||
.earth-error-message {
|
||||
position: absolute;
|
||||
top: calc(var(--hud-offset) + calc(2px * var(--hud-scale)));
|
||||
top: calc(var(--hud-offset) + calc(44px * var(--hud-scale)));
|
||||
left: min(
|
||||
calc(var(--hud-offset) + calc(340px * var(--hud-scale)) + calc(12px * var(--hud-scale))),
|
||||
calc(100vw - min(calc(440px * var(--hud-scale)), 74vw) - var(--hud-offset))
|
||||
calc(100vw - min(calc(620px * var(--hud-scale)), 82vw) - var(--hud-offset))
|
||||
);
|
||||
transform: translateY(-18px);
|
||||
display: none;
|
||||
@@ -2172,8 +2188,8 @@ label.is-disabled.earth-mobile-settings-card {
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
text-align: left;
|
||||
min-width: min(calc(160px * var(--hud-scale)), 58vw);
|
||||
max-width: min(calc(440px * var(--hud-scale)), 74vw);
|
||||
min-width: 0;
|
||||
max-width: min(calc(620px * var(--hud-scale)), 82vw);
|
||||
color: var(--hud-text);
|
||||
opacity: 0;
|
||||
transition:
|
||||
@@ -2236,6 +2252,7 @@ label.is-disabled.earth-mobile-settings-card {
|
||||
align-items: center;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* Loading: three-dot sequential pulse */
|
||||
@@ -2869,6 +2886,7 @@ label.is-disabled.earth-mobile-settings-card {
|
||||
.earth-settings-segmented-btn {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--hud-text-soft);
|
||||
@@ -2878,6 +2896,9 @@ label.is-disabled.earth-mobile-settings-card {
|
||||
font-size: calc(0.7rem * var(--hud-scale));
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
color 0.18s ease,
|
||||
@@ -2979,6 +3000,9 @@ label.is-disabled.earth-mobile-settings-card {
|
||||
}
|
||||
|
||||
.earth-settings-chip {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
border: 1px solid rgba(212, 227, 244, 0.1);
|
||||
border-radius: 999px;
|
||||
background:
|
||||
@@ -2990,6 +3014,9 @@ label.is-disabled.earth-mobile-settings-card {
|
||||
font-size: calc(0.7rem * var(--hud-scale));
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.18s ease,
|
||||
@@ -3253,12 +3280,18 @@ label.is-disabled.earth-mobile-settings-card {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: calc(38px * var(--hud-scale));
|
||||
height: calc(22px * var(--hud-scale));
|
||||
flex: 0 0 auto;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.earth-settings-switch input {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.earth-settings-switch-track {
|
||||
@@ -3285,12 +3318,14 @@ label.is-disabled.earth-mobile-settings-card {
|
||||
transition: transform 0.18s ease;
|
||||
}
|
||||
|
||||
.earth-settings-switch input:checked + .earth-settings-switch-track {
|
||||
.earth-settings-switch input:checked + .earth-settings-switch-track,
|
||||
.earth-settings-switch.is-checked .earth-settings-switch-track {
|
||||
background: linear-gradient(180deg, rgba(143, 185, 255, 0.72), rgba(104, 147, 221, 0.78));
|
||||
border-color: rgba(223, 236, 252, 0.28);
|
||||
}
|
||||
|
||||
.earth-settings-switch input:checked + .earth-settings-switch-track::after {
|
||||
.earth-settings-switch input:checked + .earth-settings-switch-track::after,
|
||||
.earth-settings-switch.is-checked .earth-settings-switch-track::after {
|
||||
transform: translate(calc(16px * var(--hud-scale)), -50%);
|
||||
}
|
||||
|
||||
|
||||
@@ -145,6 +145,22 @@
|
||||
.hud-panel-brand .earth-brand--en .earth-brand__subtitle,
|
||||
.hud-panel-brand .earth-brand--en .earth-brand__description {
|
||||
font-family: "Roboto Condensed", "Arial Narrow", "Trebuchet MS", "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
|
||||
overflow: visible;
|
||||
text-overflow: clip;
|
||||
white-space: normal;
|
||||
word-break: normal;
|
||||
overflow-wrap: normal;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.hud-panel-brand .earth-brand--en .earth-brand__subtitle {
|
||||
font-size: calc(0.58rem * var(--hud-scale) * var(--brand-scale));
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.hud-panel-brand .earth-brand--en .earth-brand__description {
|
||||
font-size: calc(0.5rem * var(--hud-scale) * var(--brand-scale));
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
/* ── Info detail panel (floating, positioned near click by JS) ── */
|
||||
@@ -152,7 +168,7 @@
|
||||
.hud-panel-info {
|
||||
position: absolute;
|
||||
z-index: 50;
|
||||
width: min(calc(300px * var(--hud-scale)), calc(100vw - 32px));
|
||||
width: min(calc(340px * var(--hud-scale)), calc(100vw - 32px));
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
@@ -349,12 +365,17 @@
|
||||
}
|
||||
|
||||
.info-card-label {
|
||||
min-width: 0;
|
||||
max-width: 42%;
|
||||
color: var(--hud-text-soft);
|
||||
font-size: calc(0.68rem * var(--hud-scale));
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
transition: color 0.18s ease;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
@@ -365,17 +386,35 @@
|
||||
}
|
||||
|
||||
.info-card-value {
|
||||
min-width: 0;
|
||||
color: var(--hud-text);
|
||||
font-weight: 600;
|
||||
font-size: calc(0.82rem * var(--hud-scale));
|
||||
line-height: 1.45;
|
||||
text-align: right;
|
||||
max-width: calc(180px * var(--hud-scale));
|
||||
max-width: calc(220px * var(--hud-scale));
|
||||
word-break: break-word;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.info-card-source-tag {
|
||||
display: inline-flex;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
margin-left: calc(4px * var(--hud-scale));
|
||||
padding: 0 calc(5px * var(--hud-scale));
|
||||
border: 1px solid rgba(214, 229, 245, 0.14);
|
||||
border-radius: 999px;
|
||||
color: var(--hud-text-soft);
|
||||
font-size: calc(0.64rem * var(--hud-scale));
|
||||
line-height: 1.35;
|
||||
vertical-align: middle;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Type-specific header accent colors */
|
||||
.info-card.cable .info-card-header {
|
||||
background: rgba(255, 200, 0, 0.12);
|
||||
@@ -681,15 +720,25 @@
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.info-card-compute-candidate-precision {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
max-width: 44%;
|
||||
color: #cfe1ff;
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.info-card-compute-candidate-preview {
|
||||
position: relative;
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
max-width: calc(96px * var(--hud-scale));
|
||||
background: transparent;
|
||||
color: #c9dcff;
|
||||
border: 1px solid rgba(214, 229, 245, 0.18);
|
||||
@@ -697,6 +746,9 @@
|
||||
cursor: pointer;
|
||||
padding: 2px 6px;
|
||||
font-size: calc(0.68rem * var(--hud-scale));
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.info-card-compute-candidate-preview:hover {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
/* Lives inside .earth-left-column — narrower than brand panel intentionally */
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
width: calc(260px * var(--hud-scale));
|
||||
width: calc(276px * var(--hud-scale));
|
||||
z-index: 10;
|
||||
overflow: hidden;
|
||||
margin-top: calc(12px * var(--hud-scale));
|
||||
@@ -184,7 +184,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(8px * var(--hud-scale));
|
||||
padding: calc(9px * var(--hud-scale)) calc(10px * var(--hud-scale));
|
||||
padding: calc(9px * var(--hud-scale)) calc(12px * var(--hud-scale));
|
||||
border-bottom: 1px solid var(--hud-line);
|
||||
transition: background 0.14s ease;
|
||||
min-height: calc(56px * var(--hud-scale));
|
||||
@@ -241,6 +241,9 @@
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
line-height: 1.2;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Toggle switch ────────────────────────────────────────────── */
|
||||
@@ -329,12 +332,13 @@
|
||||
appearance: none;
|
||||
position: absolute;
|
||||
top: calc(4px * var(--hud-scale));
|
||||
left: calc(19px * var(--hud-scale));
|
||||
left: calc(21px * var(--hud-scale));
|
||||
z-index: 2;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: calc(16px * var(--hud-scale));
|
||||
max-width: calc(38px * var(--hud-scale));
|
||||
height: calc(16px * var(--hud-scale));
|
||||
padding: 0 calc(4px * var(--hud-scale));
|
||||
border: 1px solid rgba(255, 226, 186, 0.62);
|
||||
@@ -348,6 +352,9 @@
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
filter 0.16s ease,
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
left: var(--hud-offset);
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
width: min(calc(200px * var(--hud-scale)), calc(100vw - 32px));
|
||||
width: min(calc(280px * var(--hud-scale)), calc(100vw - 32px));
|
||||
z-index: 10;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -51,14 +51,17 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
padding: calc(3px * var(--hud-scale)) calc(7px * var(--hud-scale));
|
||||
border-radius: calc(4px * var(--hud-scale));
|
||||
border: 1px solid rgba(120, 180, 255, 0.2);
|
||||
background: rgba(120, 180, 255, 0.12);
|
||||
color: var(--hud-accent-strong);
|
||||
font-size: calc(0.68rem * var(--hud-scale));
|
||||
letter-spacing: 0.08em;
|
||||
letter-spacing: 0.02em;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* ── Bar action buttons ───────────────────────────────────────── */
|
||||
@@ -92,6 +95,7 @@
|
||||
.legend-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
padding: calc(4px * var(--hud-scale)) 0;
|
||||
overflow-y: auto;
|
||||
max-height: calc(220px * var(--hud-scale));
|
||||
@@ -110,6 +114,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(8px * var(--hud-scale));
|
||||
min-width: 0;
|
||||
padding: calc(5px * var(--hud-scale)) calc(10px * var(--hud-scale));
|
||||
}
|
||||
|
||||
@@ -135,6 +140,9 @@
|
||||
}
|
||||
|
||||
.legend-label {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
color: var(--hud-text);
|
||||
font-size: calc(0.78rem * var(--hud-scale));
|
||||
font-weight: 400;
|
||||
@@ -142,6 +150,7 @@
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
/* ── Layout-expanded ──────────────────────────────────────────── */
|
||||
@@ -156,7 +165,7 @@
|
||||
position: fixed;
|
||||
left: 8px;
|
||||
bottom: calc(84px + var(--safe-bottom));
|
||||
width: min(172px, calc(100vw - 16px));
|
||||
width: min(208px, calc(100vw - 16px));
|
||||
z-index: 205;
|
||||
}
|
||||
|
||||
|
||||
@@ -306,6 +306,8 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: calc(6px * var(--hud-scale));
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
min-height: calc(30px * var(--hud-scale));
|
||||
border: 1px solid rgba(201, 225, 247, 0.1);
|
||||
border-radius: calc(12px * var(--hud-scale));
|
||||
@@ -330,6 +332,11 @@
|
||||
}
|
||||
|
||||
.news-filter-pill strong {
|
||||
min-width: 0;
|
||||
max-width: calc(160px * var(--hud-scale));
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--hud-accent-strong);
|
||||
font-size: calc(0.7rem * var(--hud-scale));
|
||||
font-weight: 700;
|
||||
@@ -391,6 +398,8 @@
|
||||
}
|
||||
|
||||
.news-filter-chip {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
border: 1px solid rgba(201, 225, 247, 0.12);
|
||||
border-radius: calc(14px * var(--hud-scale));
|
||||
padding: calc(7px * var(--hud-scale)) calc(10px * var(--hud-scale));
|
||||
@@ -398,6 +407,10 @@
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
font: inherit;
|
||||
font-size: calc(0.74rem * var(--hud-scale));
|
||||
line-height: 1.25;
|
||||
text-align: center;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: normal;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -539,6 +552,7 @@
|
||||
|
||||
.news-story-meta {
|
||||
justify-content: space-between;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.news-story-tags {
|
||||
@@ -549,6 +563,7 @@
|
||||
.news-story-time,
|
||||
.news-story-origin,
|
||||
.news-story-tag {
|
||||
min-width: 0;
|
||||
color: var(--hud-text-soft);
|
||||
font-size: calc(0.66rem * var(--hud-scale));
|
||||
}
|
||||
@@ -563,6 +578,7 @@
|
||||
|
||||
.news-story-origin {
|
||||
color: rgba(188, 212, 238, 0.56);
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@@ -582,9 +598,13 @@
|
||||
}
|
||||
|
||||
.news-story-tag {
|
||||
max-width: 100%;
|
||||
border-radius: 999px;
|
||||
padding: calc(3px * var(--hud-scale)) calc(7px * var(--hud-scale));
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
line-height: 1.25;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.news-story-tag--breaking {
|
||||
|
||||
@@ -154,7 +154,9 @@
|
||||
}
|
||||
|
||||
.tv-panel-tag {
|
||||
flex: 0 0 auto;
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
border: 1px solid rgba(137, 179, 217, 0.22);
|
||||
border-radius: calc(999px * var(--hud-scale));
|
||||
background: rgba(108, 153, 192, 0.12);
|
||||
@@ -164,6 +166,8 @@
|
||||
font-weight: 700;
|
||||
line-height: 1.35;
|
||||
letter-spacing: 0.04em;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.tv-panel-tag--status {
|
||||
|
||||
@@ -1211,6 +1211,16 @@
|
||||
</div>
|
||||
<div class="earth-mobile-settings-group" data-settings-tab-panel="system" hidden>
|
||||
<div class="earth-mobile-settings-title">系统</div>
|
||||
<div class="earth-mobile-settings-card earth-mobile-settings-card--stacked">
|
||||
<div class="earth-mobile-settings-copy">
|
||||
<span class="earth-mobile-settings-label">星球语言</span>
|
||||
<span class="earth-mobile-settings-subtitle">同步 Docs 和控制台语言偏好</span>
|
||||
</div>
|
||||
<div class="earth-mobile-settings-segmented" role="group" aria-label="星球语言">
|
||||
<button type="button" class="earth-mobile-settings-pill is-active" data-earth-locale="zh-CN" aria-pressed="true">中文</button>
|
||||
<button type="button" class="earth-mobile-settings-pill" data-earth-locale="en-US" aria-pressed="false">English</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="earth-mobile-settings-actions">
|
||||
<button id="mobile-settings-reset" class="earth-mobile-action-btn earth-mobile-action-btn--ghost" type="button">重置设置</button>
|
||||
<a class="earth-mobile-action-btn" href="/admin" target="_blank" rel="noreferrer noopener">打开控制台</a>
|
||||
@@ -1769,6 +1779,16 @@
|
||||
<section class="earth-settings-section" data-settings-tab-panel="system" hidden>
|
||||
<div class="earth-settings-section-title">系统</div>
|
||||
<div class="earth-settings-list">
|
||||
<div class="earth-settings-item earth-settings-item--stacked">
|
||||
<div class="earth-settings-copy">
|
||||
<span class="earth-settings-item-title">星球语言</span>
|
||||
<span class="earth-settings-item-subtitle">同步 Docs 和控制台语言偏好</span>
|
||||
</div>
|
||||
<div class="earth-settings-segmented" role="group" aria-label="星球语言">
|
||||
<button type="button" class="earth-settings-segmented-btn is-active" data-earth-locale="zh-CN" aria-pressed="true">中文</button>
|
||||
<button type="button" class="earth-settings-segmented-btn" data-earth-locale="en-US" aria-pressed="false">English</button>
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
class="earth-settings-item earth-settings-link"
|
||||
href="/admin"
|
||||
|
||||
@@ -17,11 +17,25 @@ const BRANDS = {
|
||||
logoSrc: "/earth/assets/brand/earth-logo.png",
|
||||
titleSrc: "/earth/assets/brand/title-en.png",
|
||||
titleText: "Intelligent Planet Program",
|
||||
subtitle: "Physical-Universe Holography",
|
||||
description: "Satellites · Cables · Compute Infra",
|
||||
subtitle: "Reality Layer Situational Awareness System",
|
||||
description: "Satellites · Subsea Cables · Compute Infrastructure",
|
||||
},
|
||||
};
|
||||
|
||||
const DEFAULT_BRAND_TITLE_BY_VARIANT = {
|
||||
zh: BRANDS.zh.titleSrc,
|
||||
en: BRANDS.en.titleSrc,
|
||||
};
|
||||
|
||||
const LOCALIZED_FIELD_NAMES = {
|
||||
ariaLabel: ["aria_label", "ariaLabel"],
|
||||
titleAlt: ["title_alt", "titleAlt"],
|
||||
titleSrc: ["title_src", "titleSrc"],
|
||||
titleText: ["title_text", "titleText"],
|
||||
subtitle: ["subtitle"],
|
||||
description: ["description"],
|
||||
};
|
||||
|
||||
export function getDefaultBrandConfig(variant = DEFAULT_BRAND_LANGUAGE) {
|
||||
return BRANDS[variant] ?? BRANDS[DEFAULT_BRAND_LANGUAGE];
|
||||
}
|
||||
@@ -35,18 +49,65 @@ function escapeHtml(value = "") {
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function hasCjkText(value = "") {
|
||||
return /[\u3400-\u9fff]/.test(String(value ?? ""));
|
||||
}
|
||||
|
||||
function readConfigValue(config, keys = []) {
|
||||
for (const key of keys) {
|
||||
if (config[key] !== undefined && config[key] !== null && config[key] !== "") {
|
||||
return config[key];
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function readLocalizedConfigValue(config, fieldName, variant, fallback) {
|
||||
const keys = LOCALIZED_FIELD_NAMES[fieldName] || [fieldName];
|
||||
const localeSuffix = variant === "en" ? "en" : "zh";
|
||||
const localeKeys = keys.flatMap((key) => [
|
||||
`${key}_${localeSuffix}`,
|
||||
`${key}${localeSuffix.charAt(0).toUpperCase()}${localeSuffix.slice(1)}`,
|
||||
]);
|
||||
const localized = readConfigValue(config, localeKeys);
|
||||
if (localized !== undefined) return localized;
|
||||
const generic = readConfigValue(config, keys);
|
||||
return generic ?? fallback;
|
||||
}
|
||||
|
||||
function normalizeBrandConfig(config = {}, variant = DEFAULT_BRAND_LANGUAGE) {
|
||||
const defaults = getDefaultBrandConfig(variant);
|
||||
const sourceTitleSrc = readLocalizedConfigValue(config, "titleSrc", variant, undefined);
|
||||
const normalized = {
|
||||
...defaults,
|
||||
...config,
|
||||
ariaLabel: config.aria_label ?? config.ariaLabel ?? defaults.ariaLabel,
|
||||
titleAlt: config.title_alt ?? config.titleAlt ?? defaults.titleAlt,
|
||||
ariaLabel: readLocalizedConfigValue(config, "ariaLabel", variant, defaults.ariaLabel),
|
||||
titleAlt: readLocalizedConfigValue(config, "titleAlt", variant, defaults.titleAlt),
|
||||
logoSrc: config.logo_src ?? config.logoSrc ?? defaults.logoSrc,
|
||||
titleSrc: config.title_src ?? config.titleSrc ?? defaults.titleSrc,
|
||||
titleText: config.title_text ?? config.titleText ?? defaults.titleText,
|
||||
titleSrc: sourceTitleSrc ?? defaults.titleSrc,
|
||||
titleText: readLocalizedConfigValue(config, "titleText", variant, defaults.titleText),
|
||||
subtitle: readLocalizedConfigValue(config, "subtitle", variant, defaults.subtitle),
|
||||
description: readLocalizedConfigValue(config, "description", variant, defaults.description),
|
||||
};
|
||||
|
||||
if (
|
||||
variant === "en" &&
|
||||
(
|
||||
!sourceTitleSrc ||
|
||||
sourceTitleSrc === DEFAULT_BRAND_TITLE_BY_VARIANT.zh ||
|
||||
hasCjkText(normalized.titleText) ||
|
||||
hasCjkText(normalized.titleAlt) ||
|
||||
hasCjkText(normalized.ariaLabel)
|
||||
)
|
||||
) {
|
||||
normalized.titleSrc = DEFAULT_BRAND_TITLE_BY_VARIANT.en;
|
||||
normalized.titleAlt = defaults.titleAlt;
|
||||
normalized.titleText = defaults.titleText;
|
||||
normalized.ariaLabel = defaults.ariaLabel;
|
||||
normalized.subtitle = defaults.subtitle;
|
||||
normalized.description = defaults.description;
|
||||
}
|
||||
|
||||
if (!normalized.titleText) normalized.titleText = defaults.titleText;
|
||||
if (!normalized.ariaLabel) normalized.ariaLabel = normalized.titleText;
|
||||
if (!normalized.titleAlt) normalized.titleAlt = normalized.titleText;
|
||||
|
||||
@@ -13,6 +13,7 @@ import { getSurfaceMarkerCameraScale, latLonToVector3 } from "./utils.js";
|
||||
import { setEarthStatValue, updateEarthStats, showStatusMessage } from "./ui.js";
|
||||
import { showInfoCard } from "./info-card.js";
|
||||
import { setLegendItems, setLegendMode } from "./legend.js";
|
||||
import { earthMessage } from "./i18n.js";
|
||||
|
||||
export let cableLines = [];
|
||||
export let landingPoints = [];
|
||||
@@ -324,7 +325,7 @@ export function clearCableData(earthObj = null) {
|
||||
export async function loadGeoJSONFromPath(scene, earthObj, options = {}) {
|
||||
const { silent = false } = options;
|
||||
if (!silent) {
|
||||
showStatusMessage("正在加载电缆数据...", "warning");
|
||||
showStatusMessage(earthMessage("loading.cableData"), "warning");
|
||||
}
|
||||
|
||||
const response = await fetch(PATHS.cablesApi, { cache: "no-store" });
|
||||
@@ -423,7 +424,7 @@ export async function loadGeoJSONFromPath(scene, earthObj, options = {}) {
|
||||
});
|
||||
|
||||
if (!silent) {
|
||||
showStatusMessage(`成功加载 ${cableLines.length} 条电缆`, "success");
|
||||
showStatusMessage(earthMessage("status.loadedCables", { count: cableLines.length }), "success");
|
||||
}
|
||||
return cableLines.length;
|
||||
}
|
||||
@@ -512,7 +513,7 @@ export async function loadLandingPoints(scene, earthObj, options = {}) {
|
||||
setEarthStatValue("landing-point-count", `${validCount}个`);
|
||||
|
||||
if (!silent) {
|
||||
showStatusMessage(`成功加载 ${validCount} 个登陆点`, "success");
|
||||
showStatusMessage(earthMessage("status.loadedLandingPoints", { count: validCount }), "success");
|
||||
}
|
||||
return validCount;
|
||||
}
|
||||
@@ -532,7 +533,7 @@ export function handleCableClick(cable) {
|
||||
rfs: data.rfs,
|
||||
});
|
||||
|
||||
showStatusMessage(`已锁定: ${data.name}`, "info");
|
||||
showStatusMessage(earthMessage("status.locked", { name: data.name }), "info");
|
||||
}
|
||||
|
||||
export function clearCableSelection() {
|
||||
|
||||
192
frontend/public/earth/js/controls.js
vendored
@@ -109,6 +109,7 @@ import {
|
||||
setLayerButtonState,
|
||||
updateLayerButtonState,
|
||||
} from "./layer-button-state.js";
|
||||
import { earthMessage, translateText } from "./i18n.js";
|
||||
import {
|
||||
DEFAULT_MOTION_PROVIDER,
|
||||
MOTION_GESTURES,
|
||||
@@ -428,12 +429,12 @@ function getShortcutForAction(actionId) {
|
||||
|
||||
function getAutoRotateShortcutStatusMessage(isActive) {
|
||||
if (rotationMode === ROTATION_MODE.CRUISE) {
|
||||
return isActive ? "巡航已恢复" : "巡航已暂停";
|
||||
return earthMessage("status.runtimePaused", { label: "巡航", active: isActive });
|
||||
}
|
||||
if (rotationMode === ROTATION_MODE.MOTION) {
|
||||
return isActive ? "动捕已恢复" : "动捕已暂停";
|
||||
return earthMessage("status.runtimePaused", { label: "动捕", active: isActive });
|
||||
}
|
||||
return isActive ? "旋转已恢复" : "旋转已暂停";
|
||||
return earthMessage("status.runtimePaused", { label: "旋转", active: isActive });
|
||||
}
|
||||
|
||||
function getShortcutOwnerByBinding(binding, { excludeActionId = null } = {}) {
|
||||
@@ -731,7 +732,7 @@ function toggleLayoutExpandedFromShortcut() {
|
||||
const container = document.getElementById("container");
|
||||
if (!(container instanceof HTMLElement)) return;
|
||||
const expanded = toggleLayoutExpanded(container);
|
||||
showStatusMessage(expanded ? "布局已最大化" : "布局已恢复", "info");
|
||||
showStatusMessage(earthMessage("status.layoutExpanded", { expanded }), "info");
|
||||
}
|
||||
|
||||
async function toggleLayerFromShortcut(layerId) {
|
||||
@@ -739,11 +740,17 @@ async function toggleLayerFromShortcut(layerId) {
|
||||
if (!definition) return;
|
||||
const button = getLayerButton(layerId);
|
||||
if (button?.disabled || button?.classList.contains("is-disabled")) {
|
||||
showStatusMessage(`${definition.label}当前不可用`, "warning");
|
||||
showStatusMessage(earthMessage("status.layerUnavailable", { layer: definition.label }), "warning");
|
||||
return;
|
||||
}
|
||||
await definition.setVisible(!definition.getVisible());
|
||||
showStatusMessage(`${definition.label}${definition.getVisible() ? "已显示" : "已隐藏"}`, "info");
|
||||
showStatusMessage(
|
||||
earthMessage("status.layerVisibility", {
|
||||
layer: definition.label,
|
||||
visible: definition.getVisible(),
|
||||
}),
|
||||
"info",
|
||||
);
|
||||
}
|
||||
|
||||
function executeKeyboardShortcut(actionId, event = null) {
|
||||
@@ -1378,7 +1385,7 @@ function getZoomResetTooltipText(zoom) {
|
||||
}
|
||||
|
||||
function getZoomResetStatusMessage(zoom) {
|
||||
return `缩放已重置到${formatZoomPercent(zoom)}`;
|
||||
return earthMessage("status.zoomReset", { zoom: formatZoomPercent(zoom) });
|
||||
}
|
||||
|
||||
function normalizeAutoRotationSpeed(value) {
|
||||
@@ -2090,7 +2097,7 @@ export function setEarthNewsCategoryEnabled(
|
||||
persistEarthSettings();
|
||||
}
|
||||
if (!suppressStatus) {
|
||||
showStatusMessage(Boolean(enabled) ? "新闻类型已显示" : "新闻类型已隐藏", "info");
|
||||
showStatusMessage(earthMessage("status.layerVisibility", { layer: "新闻类型", visible: Boolean(enabled) }), "info");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -2147,7 +2154,13 @@ export function setCruiseModules(nextModules, { persist = true, suppressStatus =
|
||||
|
||||
if (!suppressStatus) {
|
||||
const labels = normalizedModules.map((moduleId) => CRUISE_MODULE_LABELS[moduleId] || moduleId);
|
||||
showStatusMessage(`巡航模块已切换为:${labels.join(" + ")}`, "info");
|
||||
showStatusMessage(
|
||||
earthMessage("status.modulesChanged", {
|
||||
label: "巡航模块",
|
||||
value: labels.map((label) => translateText(label)).join(" + "),
|
||||
}),
|
||||
"info",
|
||||
);
|
||||
}
|
||||
|
||||
return normalizedModules;
|
||||
@@ -2178,7 +2191,7 @@ export function setCruiseQueueMode(
|
||||
: normalizedMode === CRUISE_QUEUE_MODES.RANDOM
|
||||
? "随机"
|
||||
: "默认";
|
||||
showStatusMessage(`巡航队列已切换为:${label}`, "info");
|
||||
showStatusMessage(earthMessage("status.valueChanged", { label: "巡航队列", value: label }), "info");
|
||||
}
|
||||
|
||||
return normalizedMode;
|
||||
@@ -2203,7 +2216,7 @@ export function setCruiseRegionOrder(
|
||||
|
||||
if (persist) persistEarthSettings();
|
||||
if (!suppressStatus) {
|
||||
showStatusMessage("巡航大区顺序已更新", "info");
|
||||
showStatusMessage(earthMessage("status.updated", { label: "巡航大区顺序" }), "info");
|
||||
}
|
||||
|
||||
return normalizedOrder;
|
||||
@@ -2237,7 +2250,7 @@ export function setSatelliteDisplayStyle(
|
||||
normalizedStyle === SATELLITE_DISPLAY_STYLES.GROUND_FOOTPRINT
|
||||
? "真实地表覆盖"
|
||||
: "自身发光";
|
||||
showStatusMessage(`卫星显示风格已切换为:${nextLabel}`, "info");
|
||||
showStatusMessage(earthMessage("status.valueChanged", { label: "卫星显示风格", value: nextLabel }), "info");
|
||||
}
|
||||
|
||||
return normalizedStyle;
|
||||
@@ -2256,7 +2269,7 @@ export function setSatelliteIdleBreathingEnabled(
|
||||
persistEarthSettings();
|
||||
}
|
||||
if (!suppressStatus) {
|
||||
showStatusMessage(enabled ? "卫星呼吸闪烁已开启" : "卫星呼吸闪烁已关闭", "info");
|
||||
showStatusMessage(earthMessage("status.booleanSetting", { label: "卫星呼吸闪烁", enabled }), "info");
|
||||
}
|
||||
return enabled;
|
||||
}
|
||||
@@ -2275,7 +2288,9 @@ export function setSatelliteRealAltitudeEnabled(
|
||||
}
|
||||
if (!suppressStatus) {
|
||||
showStatusMessage(
|
||||
enabled ? "卫星真实高度已开启" : "卫星已切换为旧版同层高度",
|
||||
enabled
|
||||
? earthMessage("status.booleanSetting", { label: "卫星真实高度", enabled })
|
||||
: earthMessage("status.valueChanged", { label: "卫星", value: "旧版同层高度" }),
|
||||
"info",
|
||||
);
|
||||
}
|
||||
@@ -2295,7 +2310,7 @@ export function setInteractableCompactDotsEnabled(
|
||||
persistEarthSettings();
|
||||
}
|
||||
if (!suppressStatus) {
|
||||
showStatusMessage(enabled ? "低缩放彩色圆点已开启" : "低缩放彩色圆点已关闭", "info");
|
||||
showStatusMessage(earthMessage("status.booleanSetting", { label: "低缩放彩色圆点", enabled }), "info");
|
||||
}
|
||||
return enabled;
|
||||
}
|
||||
@@ -2330,7 +2345,7 @@ export function setSurfaceHoverInfoMode(
|
||||
: normalizedMode === SURFACE_HOVER_INFO_MODES.POSITION
|
||||
? "位置"
|
||||
: "完整";
|
||||
showStatusMessage(`悬停提示已切换为:${label}`, "info");
|
||||
showStatusMessage(earthMessage("status.valueChanged", { label: "悬停提示", value: label }), "info");
|
||||
}
|
||||
|
||||
return normalizedMode;
|
||||
@@ -2508,12 +2523,13 @@ export function setMotionDebugEnabled(
|
||||
persistEarthSettings();
|
||||
}
|
||||
if (!suppressStatus && changed) {
|
||||
const message = motionDebugEnabled
|
||||
? rotationMode === ROTATION_MODE.MOTION
|
||||
? "动捕调试模式已开启"
|
||||
: "动捕调试模式将在下次进入动捕时开启"
|
||||
: "动捕调试模式已关闭";
|
||||
showStatusMessage(message, "info");
|
||||
showStatusMessage(
|
||||
earthMessage("status.motionDebugMode", {
|
||||
enabled: motionDebugEnabled,
|
||||
pending: rotationMode !== ROTATION_MODE.MOTION,
|
||||
}),
|
||||
"info",
|
||||
);
|
||||
}
|
||||
return motionDebugEnabled;
|
||||
}
|
||||
@@ -2537,12 +2553,7 @@ export function setMotionProvider(
|
||||
persistEarthSettings();
|
||||
}
|
||||
if (!suppressStatus && changed) {
|
||||
showStatusMessage(
|
||||
motionProvider === "motion_agent"
|
||||
? "动捕输入源已切换为 Motion Agent"
|
||||
: "动捕输入源已切换为浏览器摄像头",
|
||||
"info",
|
||||
);
|
||||
showStatusMessage(earthMessage("status.motionProvider", { provider: motionProvider }), "info");
|
||||
}
|
||||
return motionProvider;
|
||||
}
|
||||
@@ -2566,10 +2577,7 @@ export function setMotionDebugSkeletonOnly(
|
||||
persistEarthSettings();
|
||||
}
|
||||
if (!suppressStatus && changed) {
|
||||
showStatusMessage(
|
||||
motionDebugSkeletonOnly ? "动捕调试已切换为只显示骨骼" : "动捕调试已显示实时画面",
|
||||
"info",
|
||||
);
|
||||
showStatusMessage(earthMessage("status.motionDebugView", { skeletonOnly: motionDebugSkeletonOnly }), "info");
|
||||
}
|
||||
return motionDebugSkeletonOnly;
|
||||
}
|
||||
@@ -2597,7 +2605,7 @@ export function setMotionEnabledGestures(
|
||||
persistEarthSettings();
|
||||
}
|
||||
if (!suppressStatus && changed) {
|
||||
showStatusMessage("动捕识别动作已更新", "info");
|
||||
showStatusMessage(earthMessage("status.motionGesturesUpdated"), "info");
|
||||
}
|
||||
return getMotionEnabledGestures();
|
||||
}
|
||||
@@ -2626,7 +2634,7 @@ function resetEarthSettings() {
|
||||
}
|
||||
}
|
||||
void applyEarthSettings(defaults).then(() => {
|
||||
showStatusMessage("Earth 设置已重置", "info");
|
||||
showStatusMessage(earthMessage("status.settingsReset"), "info");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2638,7 +2646,7 @@ async function setTerrainEnabled(button, enabled, { persist = true, silent = fal
|
||||
syncMobileLayerCards();
|
||||
if (persist) persistEarthSettings();
|
||||
if (!silent) {
|
||||
showStatusMessage("地形已隐藏", "info");
|
||||
showStatusMessage(earthMessage("status.layerVisibility", { layer: "真实地形", visible: false }), "info");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -2651,7 +2659,7 @@ async function setTerrainEnabled(button, enabled, { persist = true, silent = fal
|
||||
statusText: "加载中",
|
||||
});
|
||||
if (!silent) {
|
||||
showStatusMessage("正在加载真实地形数据...", "info");
|
||||
showStatusMessage(earthMessage("loading.realTerrainData"), "info");
|
||||
}
|
||||
await ensureTerrainReady();
|
||||
}
|
||||
@@ -2661,7 +2669,7 @@ async function setTerrainEnabled(button, enabled, { persist = true, silent = fal
|
||||
syncMobileLayerCards();
|
||||
if (persist) persistEarthSettings();
|
||||
if (!silent) {
|
||||
showStatusMessage("真实地形已显示", "success");
|
||||
showStatusMessage(earthMessage("status.layerVisibility", { layer: "真实地形", visible: true }), "success");
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
@@ -2670,7 +2678,7 @@ async function setTerrainEnabled(button, enabled, { persist = true, silent = fal
|
||||
syncMobileLayerCards();
|
||||
if (persist) persistEarthSettings();
|
||||
if (!silent) {
|
||||
showStatusMessage("真实地形暂时不可用", "error");
|
||||
showStatusMessage(earthMessage("status.terrainUnavailable"), "error");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -2688,7 +2696,7 @@ async function setSatellitesLayerEnabled(button, enabled, { persist = true, sile
|
||||
}
|
||||
await setSatellitesEnabled(enabled, { suppressStatus: silent, suppressLoadingUi: silent });
|
||||
if (!enabled && !silent) {
|
||||
showStatusMessage("卫星已隐藏", "info");
|
||||
showStatusMessage(earthMessage("status.layerVisibility", { layer: "卫星", visible: false }), "info");
|
||||
} else if (enabled) {
|
||||
setEarthStatValue("satellite-count", `${getSatelliteCount()} 颗`);
|
||||
}
|
||||
@@ -2718,7 +2726,7 @@ function setGridLinesLayerEnabled(button, enabled, { persist = true, silent = fa
|
||||
syncMobileLayerCards();
|
||||
if (persist) persistEarthSettings();
|
||||
if (!silent) {
|
||||
showStatusMessage(enabled ? "经纬线已显示" : "经纬线已隐藏", "info");
|
||||
showStatusMessage(earthMessage("status.layerVisibility", { layer: "经纬线", visible: enabled }), "info");
|
||||
}
|
||||
return enabled;
|
||||
}
|
||||
@@ -2806,7 +2814,7 @@ function setBGPLayerEnabled(button, enabled, { persist = true, silent = false }
|
||||
syncMobileLayerCards();
|
||||
if (persist) persistEarthSettings();
|
||||
if (!silent) {
|
||||
showStatusMessage(enabled ? "BGP观测已显示" : "BGP观测已隐藏", "info");
|
||||
showStatusMessage(earthMessage("status.layerVisibility", { layer: "BGP观测", visible: enabled }), "info");
|
||||
}
|
||||
return enabled;
|
||||
}
|
||||
@@ -2822,7 +2830,7 @@ function setComputeCentersLayerEnabled(button, enabled, { persist = true, silent
|
||||
syncMobileLayerCards();
|
||||
if (persist) persistEarthSettings();
|
||||
if (!silent) {
|
||||
showStatusMessage(enabled ? "算力中心已显示" : "算力中心已隐藏", "info");
|
||||
showStatusMessage(earthMessage("status.layerVisibility", { layer: "算力中心", visible: enabled }), "info");
|
||||
}
|
||||
return enabled;
|
||||
}
|
||||
@@ -2891,7 +2899,7 @@ function setTrailsDisplayEnabled(enabled, { persist = true, silent = false } = {
|
||||
syncMobileLayerCards();
|
||||
if (persist) persistEarthSettings();
|
||||
if (!silent) {
|
||||
showStatusMessage(enabled ? "轨迹已显示" : "轨迹已隐藏", "info");
|
||||
showStatusMessage(earthMessage("status.layerVisibility", { layer: "轨迹", visible: enabled }), "info");
|
||||
}
|
||||
return enabled;
|
||||
}
|
||||
@@ -3001,7 +3009,7 @@ function getBuiltinLayerDefinitions() {
|
||||
startupMode: "preload",
|
||||
startupAlwaysLoad: true,
|
||||
startupLabel: "海陆基座",
|
||||
startupMessage: "正在加载海陆基座...",
|
||||
startupMessage: earthMessage("startup.landOceanBase"),
|
||||
getVisible: () => getShowCountryBoundaries(),
|
||||
setVisible: (visible, options = {}) =>
|
||||
setCountryBoundariesLayerEnabled(getLayerButton("countryBoundaries"), visible, options),
|
||||
@@ -3018,7 +3026,7 @@ function getBuiltinLayerDefinitions() {
|
||||
startupPriority: 30,
|
||||
startupMode: "visible",
|
||||
startupLabel: "高清材质",
|
||||
startupMessage: "正在启用高清材质...",
|
||||
startupMessage: earthMessage("startup.hdTexture"),
|
||||
getVisible: () => getHighResTextureEnabled(),
|
||||
setVisible: (visible, options = {}) =>
|
||||
setHighResTextureLayerEnabled(getLayerButton("earthHighResTexture"), visible, options),
|
||||
@@ -3053,8 +3061,8 @@ function getBuiltinLayerDefinitions() {
|
||||
startupMode: "visible",
|
||||
startupLabel: "海缆",
|
||||
startupMessage: {
|
||||
prepare: "正在加载登陆点...",
|
||||
load: "正在加载海缆...",
|
||||
prepare: earthMessage("startup.landingPoints"),
|
||||
load: earthMessage("startup.cables"),
|
||||
},
|
||||
getVisible: () => getShowCables(),
|
||||
setVisible: (visible, options = {}) =>
|
||||
@@ -3072,7 +3080,7 @@ function getBuiltinLayerDefinitions() {
|
||||
startupPriority: 60,
|
||||
startupMode: "preload",
|
||||
startupLabel: "算力中心",
|
||||
startupMessage: "正在加载算力中心...",
|
||||
startupMessage: earthMessage("startup.computeCenters"),
|
||||
getVisible: () => getShowComputeCenters(),
|
||||
setVisible: (visible, options = {}) =>
|
||||
setComputeCentersLayerEnabled(getLayerButton("computeCenters"), visible, options),
|
||||
@@ -3089,7 +3097,7 @@ function getBuiltinLayerDefinitions() {
|
||||
startupPriority: 70,
|
||||
startupMode: "preload",
|
||||
startupLabel: "BGP态势",
|
||||
startupMessage: "正在加载BGP态势...",
|
||||
startupMessage: earthMessage("startup.bgp"),
|
||||
getVisible: () => getShowBGP(),
|
||||
setVisible: (visible, options = {}) =>
|
||||
setBGPLayerEnabled(getLayerButton("bgp"), visible, options),
|
||||
@@ -3106,7 +3114,7 @@ function getBuiltinLayerDefinitions() {
|
||||
startupPriority: 65,
|
||||
startupMode: "visible",
|
||||
startupLabel: "船只",
|
||||
startupMessage: "正在加载船只...",
|
||||
startupMessage: earthMessage("startup.vessels"),
|
||||
getVisible: () => getVesselsEnabled(),
|
||||
setVisible: (visible, options = {}) =>
|
||||
setVesselsLayerEnabled(getLayerButton("vessels"), visible, options),
|
||||
@@ -3123,7 +3131,7 @@ function getBuiltinLayerDefinitions() {
|
||||
startupPriority: 80,
|
||||
startupMode: "visible",
|
||||
startupLabel: "卫星",
|
||||
startupMessage: "正在加载卫星...",
|
||||
startupMessage: earthMessage("startup.satellites"),
|
||||
getVisible: () => getSatellitesEnabled(),
|
||||
setVisible: (visible, options = {}) =>
|
||||
setSatellitesLayerEnabled(getLayerButton("satellites"), visible, options),
|
||||
@@ -3140,7 +3148,7 @@ function getBuiltinLayerDefinitions() {
|
||||
startupPriority: null,
|
||||
startupMode: "visible",
|
||||
startupLabel: "地形",
|
||||
startupMessage: "正在渲染地形...",
|
||||
startupMessage: earthMessage("startup.terrain"),
|
||||
statusTarget: "terrain-status",
|
||||
getVisible: () => showTerrain,
|
||||
setVisible: (visible, options = {}) =>
|
||||
@@ -3345,7 +3353,10 @@ export function showZoomStatusCapsule({ force = false, zoom = null } = {}) {
|
||||
const currentZoom = Number.isFinite(Number(zoom))
|
||||
? clampEarthZoomLevel(zoom)
|
||||
: syncZoomLevelFromCamera(activeCamera);
|
||||
showGestureStatusMessage(`缩放 ${Math.round(currentZoom * 100)}%`, "info");
|
||||
showGestureStatusMessage(
|
||||
earthMessage("status.zoomPercent", { percent: Math.round(currentZoom * 100) }),
|
||||
"info",
|
||||
);
|
||||
}
|
||||
|
||||
function cancelSettingsSheetAnimation() {
|
||||
@@ -3602,10 +3613,17 @@ function syncSettingsToggle(panelId, visible) {
|
||||
inputs.forEach((input) => {
|
||||
if (input instanceof HTMLInputElement) {
|
||||
input.checked = visible;
|
||||
syncSettingsSwitchVisual(input, visible);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function syncSettingsSwitchVisual(input, visible = input?.checked === true) {
|
||||
if (!(input instanceof HTMLInputElement)) return;
|
||||
const switchShell = input.closest(".earth-settings-switch, .earth-mobile-settings-switch");
|
||||
switchShell?.classList.toggle("is-checked", Boolean(visible));
|
||||
}
|
||||
|
||||
function syncAllHudPanelToggles() {
|
||||
HUD_PANEL_IDS.forEach((panelId) => {
|
||||
const panel = document.getElementById(panelId);
|
||||
@@ -3775,12 +3793,15 @@ function startBoundaryBuildPolling() {
|
||||
setHighPrecisionBoundariesEnabled(true);
|
||||
await reloadCountryBoundaries({ suppressStatus: true });
|
||||
await refreshBoundaryPrecisionStatus().catch(() => {});
|
||||
showStatusMessage("高精国界已下载并应用", "info");
|
||||
showStatusMessage(earthMessage("status.boundaryDownloaded"), "info");
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
stopBoundaryBuildPolling();
|
||||
showStatusMessage(`高清国界进度读取失败:${error.message || error}`, "warning");
|
||||
showStatusMessage(
|
||||
earthMessage("status.failure", { label: "高清国界进度读取失败", error: error.message || error }),
|
||||
"warning",
|
||||
);
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
@@ -3791,7 +3812,7 @@ async function startBoundaryPrecisionBuild() {
|
||||
});
|
||||
boundaryBuildAttemptedThisSession = true;
|
||||
await fetchBoundaryPrecisionJson("/api/v1/earth/boundaries/build", { method: "POST", body: "{}" });
|
||||
showStatusMessage("高精国界构建已启动", "info");
|
||||
showStatusMessage(earthMessage("status.boundaryBuildStarted"), "info");
|
||||
startBoundaryBuildPolling();
|
||||
}
|
||||
|
||||
@@ -3806,7 +3827,10 @@ async function setupBoundaryPrecisionControls() {
|
||||
}
|
||||
} catch (error) {
|
||||
renderBoundaryPrecisionStatus({});
|
||||
showStatusMessage(`高清国界状态读取失败:${error.message || error}`, "warning");
|
||||
showStatusMessage(
|
||||
earthMessage("status.failure", { label: "高清国界状态读取失败", error: error.message || error }),
|
||||
"warning",
|
||||
);
|
||||
}
|
||||
|
||||
els.buildButtons.forEach((buildButton) => {
|
||||
@@ -3820,14 +3844,17 @@ async function setupBoundaryPrecisionControls() {
|
||||
if (getHighPrecisionBoundariesEnabled()) return;
|
||||
setHighPrecisionBoundariesEnabled(true);
|
||||
await reloadCountryBoundaries({ suppressStatus: true });
|
||||
showStatusMessage("已切换到高精国界", "info");
|
||||
showStatusMessage(earthMessage("status.boundaryPrecision", { high: true }), "info");
|
||||
await refreshBoundaryPrecisionStatus().catch(() => {});
|
||||
return;
|
||||
}
|
||||
await startBoundaryPrecisionBuild();
|
||||
} catch (error) {
|
||||
await refreshBoundaryPrecisionStatus().catch(() => {});
|
||||
showStatusMessage(`高精国界切换失败:${error.message || error}`, "warning");
|
||||
showStatusMessage(
|
||||
earthMessage("status.failure", { label: "高精国界切换失败", error: error.message || error }),
|
||||
"warning",
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -3838,7 +3865,10 @@ async function setupBoundaryPrecisionControls() {
|
||||
await startBoundaryPrecisionBuild();
|
||||
} catch (error) {
|
||||
await refreshBoundaryPrecisionStatus().catch(() => {});
|
||||
showStatusMessage(`高精国界重建启动失败:${error.message || error}`, "warning");
|
||||
showStatusMessage(
|
||||
earthMessage("status.failure", { label: "高精国界重建启动失败", error: error.message || error }),
|
||||
"warning",
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -3849,10 +3879,13 @@ async function setupBoundaryPrecisionControls() {
|
||||
if (!getHighPrecisionBoundariesEnabled()) return;
|
||||
setHighPrecisionBoundariesEnabled(false);
|
||||
await reloadCountryBoundaries({ suppressStatus: true });
|
||||
showStatusMessage("已切换到低精国界", "info");
|
||||
showStatusMessage(earthMessage("status.boundaryPrecision", { high: false }), "info");
|
||||
await refreshBoundaryPrecisionStatus().catch(() => {});
|
||||
} catch (error) {
|
||||
showStatusMessage(`低精国界切换失败:${error.message || error}`, "warning");
|
||||
showStatusMessage(
|
||||
earthMessage("status.failure", { label: "低精国界切换失败", error: error.message || error }),
|
||||
"warning",
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -4043,7 +4076,7 @@ function setShortcutBinding(actionId, binding, { persist = true } = {}) {
|
||||
if (!normalizedBinding) return false;
|
||||
const owner = getShortcutOwnerByBinding(normalizedBinding, { excludeActionId: actionId });
|
||||
if (owner) {
|
||||
showStatusMessage(`快捷键已被「${owner.label}」使用`, "warning");
|
||||
showStatusMessage(earthMessage("status.shortcutConflict", { owner: owner.label }), "warning");
|
||||
return false;
|
||||
}
|
||||
const nextShortcuts = normalizeKeyboardShortcuts(keyboardShortcuts);
|
||||
@@ -4083,7 +4116,7 @@ function setShortcutEnabled(actionId, enabled, { persist = true } = {}) {
|
||||
if (enabled) {
|
||||
const owner = getShortcutOwnerByBinding(currentShortcut.binding, { excludeActionId: actionId });
|
||||
if (owner) {
|
||||
showStatusMessage(`快捷键已被「${owner.label}」使用`, "warning");
|
||||
showStatusMessage(earthMessage("status.shortcutConflict", { owner: owner.label }), "warning");
|
||||
renderShortcutSettings();
|
||||
return false;
|
||||
}
|
||||
@@ -4110,7 +4143,7 @@ function resetAllShortcutBindings() {
|
||||
capturingShortcutActionId = null;
|
||||
renderShortcutSettings();
|
||||
persistEarthSettings();
|
||||
showStatusMessage("快捷键已恢复默认", "info");
|
||||
showStatusMessage(earthMessage("status.shortcutsReset"), "info");
|
||||
}
|
||||
|
||||
function moveCruiseRegionInOrder(region, targetRegion) {
|
||||
@@ -4270,11 +4303,15 @@ function setupSettingsControls() {
|
||||
|
||||
const toggleInputs = document.querySelectorAll("[data-settings-panel]");
|
||||
toggleInputs.forEach((input) => {
|
||||
if (input instanceof HTMLInputElement) {
|
||||
syncSettingsSwitchVisual(input);
|
||||
}
|
||||
bindListener(input, "change", (event) => {
|
||||
const target = event.currentTarget;
|
||||
if (!(target instanceof HTMLInputElement)) return;
|
||||
const panelId = target.dataset.settingsPanel;
|
||||
if (!panelId) return;
|
||||
syncSettingsSwitchVisual(target, target.checked);
|
||||
setHudPanelVisibility(panelId, target.checked);
|
||||
});
|
||||
});
|
||||
@@ -5247,7 +5284,10 @@ function setupRotateControls(camera) {
|
||||
: rotationMode === ROTATION_MODE.MOTION
|
||||
? "动捕"
|
||||
: "自动旋转";
|
||||
showStatusMessage(isRotating ? `${label}已开启` : `${label}已暂停`, "info");
|
||||
showStatusMessage(
|
||||
earthMessage("status.runtimePaused", { label, active: isRotating }),
|
||||
"info",
|
||||
);
|
||||
});
|
||||
|
||||
updateRotateUI();
|
||||
@@ -5518,7 +5558,7 @@ function setupTerrainControls() {
|
||||
|
||||
bindListener(layoutBtn, "click", () => {
|
||||
const expanded = toggleLayoutExpanded(container);
|
||||
showStatusMessage(expanded ? "布局已最大化" : "布局已恢复", "info");
|
||||
showStatusMessage(earthMessage("status.layoutExpanded", { expanded }), "info");
|
||||
});
|
||||
|
||||
const mediaVisible =
|
||||
@@ -5552,7 +5592,13 @@ function setupKeyboardControls() {
|
||||
if (!nextBinding) return;
|
||||
if (setShortcutBinding(capturingShortcutActionId, nextBinding)) {
|
||||
const definition = KEYBOARD_SHORTCUT_DEFINITION_BY_ID.get(capturingShortcutActionId);
|
||||
showStatusMessage(`${definition?.label || "快捷键"}已设置为 ${getShortcutDisplayLabel(nextBinding)}`, "info");
|
||||
showStatusMessage(
|
||||
earthMessage("status.shortcutSet", {
|
||||
label: definition?.label || "快捷键",
|
||||
binding: getShortcutDisplayLabel(nextBinding),
|
||||
}),
|
||||
"info",
|
||||
);
|
||||
capturingShortcutActionId = null;
|
||||
syncShortcutCaptureUi();
|
||||
}
|
||||
@@ -6012,9 +6058,9 @@ function updateRotateUI() {
|
||||
? "动捕"
|
||||
: "自动旋转";
|
||||
if (tooltip) {
|
||||
tooltip.textContent = autoRotate ? `暂停${activeLabel}` : `开始${activeLabel}`;
|
||||
tooltip.textContent = translateText(autoRotate ? `暂停${activeLabel}` : `开始${activeLabel}`);
|
||||
}
|
||||
btn.title = `${getRotationModeLabel()} · ${activeLabel}`;
|
||||
btn.title = translateText(`${getRotationModeLabel()} · ${activeLabel}`);
|
||||
}
|
||||
|
||||
syncRotationModeButtons();
|
||||
@@ -6056,7 +6102,7 @@ export function setAutoRotationSpeed(value, { persist = true, suppressStatus = f
|
||||
persistEarthSettings();
|
||||
}
|
||||
if (changed && !suppressStatus) {
|
||||
showStatusMessage(`旋转转速已设为 ${formatAutoRotationSpeed(normalizedSpeed)}`, "info");
|
||||
showStatusMessage(earthMessage("status.rotateSpeed", { speed: formatAutoRotationSpeed(normalizedSpeed) }), "info");
|
||||
}
|
||||
return normalizedSpeed;
|
||||
}
|
||||
@@ -6079,7 +6125,7 @@ export function setRotationMode(nextMode, { persist = true, suppressStatus = fal
|
||||
persistEarthSettings();
|
||||
}
|
||||
if (changed && !suppressStatus) {
|
||||
showStatusMessage(`已切换到${getRotationModeLabel(normalizedMode)}`, "info");
|
||||
showStatusMessage(earthMessage("status.switchedTo", { label: getRotationModeLabel(normalizedMode) }), "info");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6151,7 +6197,7 @@ export function focusEarthView(camera, options = {}) {
|
||||
earthObj.rotation.y = nextRotation.y;
|
||||
earthObj.rotation.z = nextRotation.z;
|
||||
if (!suppressStatus) {
|
||||
showStatusMessage("视角已重置", "info");
|
||||
showStatusMessage(earthMessage("status.viewReset"), "info");
|
||||
}
|
||||
resolve();
|
||||
},
|
||||
|
||||
1641
frontend/public/earth/js/i18n.js
Normal file
@@ -1,5 +1,12 @@
|
||||
// info-card.js - Unified info card module
|
||||
import { showStatusMessage } from './ui.js';
|
||||
import {
|
||||
earthMessage,
|
||||
getEarthLocale,
|
||||
hasCjkText,
|
||||
localizeCountryName,
|
||||
translateText,
|
||||
} from './i18n.js';
|
||||
import {
|
||||
getNewsDisplaySummary,
|
||||
getNewsDisplayTitle,
|
||||
@@ -85,8 +92,8 @@ function clearLocationCollectState(contextOrKey) {
|
||||
}
|
||||
|
||||
function getWebSearchDisabledTitle(capability = computeCenterLocationCapability) {
|
||||
if (!capability) return '正在检查 WebSearch 状态,稍候即可定位。';
|
||||
return capability.reason || 'WebSearch 未开启,无法进行事实核查定位。';
|
||||
if (!capability) return infoText('正在检查 WebSearch 状态,稍候即可定位。');
|
||||
return infoText(capability.reason || 'WebSearch 未开启,无法进行事实核查定位。');
|
||||
}
|
||||
|
||||
function isComputeCenterLocationBlocked() {
|
||||
@@ -134,7 +141,7 @@ function setLocationButtonLoading(button, loading, label) {
|
||||
button.classList.toggle('is-loading', Boolean(loading));
|
||||
button.toggleAttribute('aria-busy', Boolean(loading));
|
||||
if (label) {
|
||||
button.dataset.loadingLabel = label;
|
||||
button.dataset.loadingLabel = infoText(label);
|
||||
}
|
||||
updateComputeCenterLocationCapabilityDom();
|
||||
}
|
||||
@@ -188,14 +195,27 @@ function formatInfoCardValue(field, rawValue) {
|
||||
if (IDENTIFIER_FIELD_KEYS.has(field.key)) {
|
||||
value = String(value);
|
||||
} else if (typeof value === 'number') {
|
||||
value = value.toLocaleString();
|
||||
value = value.toLocaleString(getEarthLocale());
|
||||
}
|
||||
if (field.key === 'country') {
|
||||
value = localizeCountryName(value) || value;
|
||||
}
|
||||
if (field.unit && value !== '-') {
|
||||
value = value + ' ' + field.unit;
|
||||
value = value + ' ' + translateText(field.unit);
|
||||
} else if (typeof value === 'string') {
|
||||
value = translateText(value);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function infoText(value, fallback = '') {
|
||||
const translated = translateText(value);
|
||||
if (getEarthLocale() === 'en-US' && hasCjkText(translated) && fallback) {
|
||||
return fallback;
|
||||
}
|
||||
return translated;
|
||||
}
|
||||
|
||||
function escapeInfoCardHtml(value) {
|
||||
return String(value ?? '').replace(/[&<>"']/g, (char) => ({
|
||||
'&': '&',
|
||||
@@ -299,18 +319,18 @@ function renderNewsCardContent(content, data) {
|
||||
const metaHtml = metaItems.length
|
||||
? `<div class="info-card-news-meta-grid">${metaItems.map(([label, value]) => `
|
||||
<div class="info-card-news-meta-item">
|
||||
<span>${escapeInfoCardHtml(label)}</span>
|
||||
<span>${escapeInfoCardHtml(infoText(label))}</span>
|
||||
<strong>${escapeInfoCardHtml(value)}</strong>
|
||||
</div>
|
||||
`).join('')}</div>`
|
||||
: '';
|
||||
content.innerHTML = `
|
||||
<div class="info-card-news-layout">
|
||||
<div class="info-card-news-kicker">新闻信号</div>
|
||||
<div class="info-card-news-kicker">${escapeInfoCardHtml(infoText('新闻信号'))}</div>
|
||||
<div class="info-card-news-title">${escapeInfoCardHtml(title)}</div>
|
||||
${metaHtml}
|
||||
<div class="info-card-news-summary-shell">
|
||||
<div class="info-card-news-summary-label">概要</div>
|
||||
<div class="info-card-news-summary-label">${escapeInfoCardHtml(infoText('概要'))}</div>
|
||||
<div class="info-card-news-summary" data-news-summary></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -332,18 +352,18 @@ function renderMobileNewsCardContent(content, data) {
|
||||
const metaHtml = metaItems.length
|
||||
? `<div class="info-card-news-meta-grid info-card-news-meta-grid--mobile">${metaItems.map(([label, value]) => `
|
||||
<div class="info-card-news-meta-item">
|
||||
<span>${escapeInfoCardHtml(label)}</span>
|
||||
<span>${escapeInfoCardHtml(infoText(label))}</span>
|
||||
<strong>${escapeInfoCardHtml(value)}</strong>
|
||||
</div>
|
||||
`).join('')}</div>`
|
||||
: '';
|
||||
content.innerHTML = `
|
||||
<div class="earth-mobile-news-detail">
|
||||
<div class="earth-mobile-news-detail-kicker">新闻信号</div>
|
||||
<div class="earth-mobile-news-detail-kicker">${escapeInfoCardHtml(infoText('新闻信号'))}</div>
|
||||
<div class="earth-mobile-news-detail-title">${escapeInfoCardHtml(title)}</div>
|
||||
${metaHtml}
|
||||
<div class="earth-mobile-news-detail-summary-shell">
|
||||
<div class="earth-mobile-news-detail-summary-label">概要</div>
|
||||
<div class="earth-mobile-news-detail-summary-label">${escapeInfoCardHtml(infoText('概要'))}</div>
|
||||
<div class="earth-mobile-news-detail-summary" data-news-summary></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -371,7 +391,7 @@ function renderMobileDetailContent(type, config, data) {
|
||||
const value = formatInfoCardValue(field, data[field.key]);
|
||||
html += `
|
||||
<div class="earth-mobile-detail-row">
|
||||
<span class="earth-mobile-detail-row-label">${field.label}</span>
|
||||
<span class="earth-mobile-detail-row-label">${escapeInfoCardHtml(infoText(field.label))}</span>
|
||||
<span class="earth-mobile-detail-row-value">${value}</span>
|
||||
</div>
|
||||
`;
|
||||
@@ -428,7 +448,7 @@ function renderDefaultCardContent(content, config, data) {
|
||||
const sourceLabel = getFieldSourceLabel(data, field.key);
|
||||
html += `
|
||||
<div class="info-card-property">
|
||||
<span class="info-card-label">${field.label}</span>
|
||||
<span class="info-card-label">${escapeInfoCardHtml(infoText(field.label))}</span>
|
||||
<span class="info-card-value">${value}${sourceLabel}</span>
|
||||
</div>
|
||||
`;
|
||||
@@ -519,8 +539,8 @@ function buildLocationCollectContext(config, data) {
|
||||
|
||||
function renderLocationCollectSection(context) {
|
||||
const buttonLabel = context.needsConfirmation
|
||||
? '重新自动采集坐标'
|
||||
: '自动采集坐标候选';
|
||||
? infoText('重新自动采集坐标')
|
||||
: infoText('自动采集坐标候选');
|
||||
const cacheKey = getLocationCollectCacheKey(context);
|
||||
const cached = getLocationCollectState(cacheKey);
|
||||
ensureComputeCenterLocationCapability();
|
||||
@@ -559,7 +579,7 @@ function hydrateLocationCollectRoot(root, state) {
|
||||
const statusEl = root.querySelector('[data-collect-status], [data-unresolved-status]');
|
||||
const candidatesEl = root.querySelector('[data-collect-candidates], [data-unresolved-candidates]');
|
||||
const button = root.querySelector('[data-collect-action="run"], [data-unresolved-collect]');
|
||||
if (statusEl) statusEl.textContent = state?.statusText || '';
|
||||
if (statusEl) statusEl.textContent = infoText(state?.statusText || '');
|
||||
if (candidatesEl) candidatesEl.innerHTML = renderCachedCollectCandidates(state);
|
||||
if (button instanceof HTMLButtonElement) {
|
||||
button.classList.toggle('is-loading', state?.loading === true);
|
||||
@@ -654,15 +674,15 @@ function ensureCandidateActionBindings(rootOrChild, context) {
|
||||
if (typeof actionContext.save !== 'function') return;
|
||||
const statusEl = root.querySelector('[data-collect-status], [data-unresolved-status]');
|
||||
button.disabled = true;
|
||||
if (statusEl) statusEl.textContent = '正在保存所选坐标...';
|
||||
if (statusEl) statusEl.textContent = infoText('正在保存所选坐标...');
|
||||
try {
|
||||
const saveResult = await actionContext.save(candidate);
|
||||
setLocationCollectState(actionContext, {
|
||||
loading: false,
|
||||
statusText: '坐标已保存',
|
||||
statusText: infoText('坐标已保存'),
|
||||
candidates: [],
|
||||
});
|
||||
if (statusEl) statusEl.textContent = '坐标已保存';
|
||||
if (statusEl) statusEl.textContent = infoText('坐标已保存');
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('earth:compute-center-location-saved', {
|
||||
detail: {
|
||||
@@ -685,7 +705,7 @@ function ensureCandidateActionBindings(rootOrChild, context) {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('save compute-center location failed', error);
|
||||
if (statusEl) statusEl.textContent = `保存失败:${error?.message || error}`;
|
||||
if (statusEl) statusEl.textContent = infoText(`保存失败:${error?.message || error}`);
|
||||
button.disabled = false;
|
||||
}
|
||||
});
|
||||
@@ -695,14 +715,14 @@ function formatLocationCollectFailure(result) {
|
||||
const regularReason = result?.failure_reason || '常规来源没有可用坐标候选';
|
||||
const llmReason = result?.llm_failure_reason;
|
||||
if (llmReason) {
|
||||
return `常规来源无结果;LLM 兜底未生成可用候选:${llmReason}`;
|
||||
return infoText(`常规来源无结果;LLM 兜底未生成可用候选:${llmReason}`);
|
||||
}
|
||||
const attempted = Array.isArray(result?.attempted_queries) ? result.attempted_queries : [];
|
||||
const attemptedLlm = attempted.some((query) => String(query || '').startsWith('llm_factcheck:'));
|
||||
if (attemptedLlm) {
|
||||
return `常规来源无结果;LLM 兜底已尝试但没有返回可用候选。${regularReason}`;
|
||||
return infoText(`常规来源无结果;LLM 兜底已尝试但没有返回可用候选。${regularReason}`);
|
||||
}
|
||||
return regularReason;
|
||||
return infoText(regularReason);
|
||||
}
|
||||
|
||||
function bindLocationCollectControls(content, context) {
|
||||
@@ -728,7 +748,7 @@ function bindLocationCollectControls(content, context) {
|
||||
setLocationButtonLoading(button, true, '正在定位');
|
||||
setLocationCollectState(context, {
|
||||
loading: true,
|
||||
statusText: '正在采集坐标候选...',
|
||||
statusText: infoText('正在采集坐标候选...'),
|
||||
candidates: [],
|
||||
});
|
||||
try {
|
||||
@@ -736,7 +756,7 @@ function bindLocationCollectControls(content, context) {
|
||||
if (!result?.success) {
|
||||
setLocationCollectState(context, {
|
||||
loading: false,
|
||||
statusText: `未能采集到坐标:${formatLocationCollectFailure(result)}`,
|
||||
statusText: infoText(`未能采集到坐标:${formatLocationCollectFailure(result)}`),
|
||||
candidates: [],
|
||||
result,
|
||||
});
|
||||
@@ -745,7 +765,7 @@ function bindLocationCollectControls(content, context) {
|
||||
const candidates = Array.isArray(result.candidates) ? result.candidates : [];
|
||||
setLocationCollectState(context, {
|
||||
loading: false,
|
||||
statusText: `共找到 ${candidates.length} 个候选位置`,
|
||||
statusText: infoText(`共找到 ${candidates.length} 个候选位置`),
|
||||
candidates,
|
||||
result,
|
||||
});
|
||||
@@ -753,7 +773,7 @@ function bindLocationCollectControls(content, context) {
|
||||
console.error('collect-location failed', error);
|
||||
setLocationCollectState(context, {
|
||||
loading: false,
|
||||
statusText: `采集失败:${error?.message || error}`,
|
||||
statusText: infoText(`采集失败:${error?.message || error}`),
|
||||
candidates: [],
|
||||
});
|
||||
} finally {
|
||||
@@ -773,22 +793,22 @@ function renderCollectCandidateRow(candidate, isBest, index) {
|
||||
? `${Math.round(Number(candidate.confidence) * 100)}%`
|
||||
: '-';
|
||||
const safeIndex = Number.isFinite(Number(index)) ? Number(index) : 0;
|
||||
const name = escapeInfoCardHtml(candidate.matched_location_name || candidate.display_name || '候选');
|
||||
const name = escapeInfoCardHtml(candidate.matched_location_name || candidate.display_name || infoText('候选'));
|
||||
const sourceLabel = escapeInfoCardHtml(candidate.source || '');
|
||||
return `
|
||||
<div class="info-card-compute-candidate ${isBest ? 'is-best' : ''}">
|
||||
<div class="info-card-compute-candidate-line">
|
||||
<span class="info-card-compute-candidate-name">${name}</span>
|
||||
<span class="info-card-compute-candidate-precision">${escapeInfoCardHtml(precisionLabel)}</span>
|
||||
<span class="info-card-compute-candidate-precision">${escapeInfoCardHtml(infoText(precisionLabel))}</span>
|
||||
</div>
|
||||
<div class="info-card-compute-candidate-line">
|
||||
<span class="info-card-compute-candidate-source">${sourceLabel}</span>
|
||||
<span class="info-card-compute-candidate-confidence">置信 ${escapeInfoCardHtml(confidence)}</span>
|
||||
<span class="info-card-compute-candidate-confidence">${escapeInfoCardHtml(infoText(`置信 ${confidence}`))}</span>
|
||||
</div>
|
||||
<div class="info-card-compute-candidate-line">
|
||||
<span class="info-card-compute-candidate-coords">${Number(candidate.latitude).toFixed(4)}, ${Number(candidate.longitude).toFixed(4)}</span>
|
||||
<button type="button" class="info-card-compute-candidate-preview" data-preview-candidate data-candidate-index="${safeIndex}">预览</button>
|
||||
<button type="button" class="info-card-compute-candidate-preview" data-save-candidate data-candidate-index="${safeIndex}">保存</button>
|
||||
<button type="button" class="info-card-compute-candidate-preview" data-preview-candidate data-candidate-index="${safeIndex}">${escapeInfoCardHtml(infoText('预览'))}</button>
|
||||
<button type="button" class="info-card-compute-candidate-preview" data-save-candidate data-candidate-index="${safeIndex}">${escapeInfoCardHtml(infoText('保存'))}</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -803,7 +823,7 @@ function getUnresolvedComputeCenterContext(item) {
|
||||
entityId: item?.source_id || item?.id || '',
|
||||
sourceId: item?.source_id || item?.id || '',
|
||||
recordId: item?.id || item?.record_id || '',
|
||||
name: item?.name || item?.title || '未命名算力中心',
|
||||
name: item?.name || item?.title || infoText('未命名算力中心'),
|
||||
site_type: item?.site_type || metadata.site_type || '',
|
||||
operator: item?.operator || item?.vendor || metadata.operator || '',
|
||||
site: item?.site || metadata.site || metadata.organization || '',
|
||||
@@ -821,7 +841,7 @@ function renderComputeCenterUnresolvedContent(content, data) {
|
||||
if (!items.length) {
|
||||
content.innerHTML = `
|
||||
<div class="info-card-unresolved-empty">
|
||||
当前没有待定位算力中心
|
||||
${escapeInfoCardHtml(infoText('当前没有待定位算力中心'))}
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
@@ -835,7 +855,7 @@ function renderComputeCenterUnresolvedContent(content, data) {
|
||||
const cached = getLocationCollectState(cacheKey);
|
||||
const meta = [context.site || context.operator, context.city, context.country]
|
||||
.filter(Boolean)
|
||||
.join(' · ') || '缺少可用地址字段';
|
||||
.join(' · ') || infoText('缺少可用地址字段');
|
||||
return `
|
||||
<div class="info-card-unresolved-item" data-unresolved-item data-collect-cache-key="${escapeInfoCardHtml(cacheKey)}">
|
||||
<div class="info-card-unresolved-main">
|
||||
@@ -847,10 +867,10 @@ function renderComputeCenterUnresolvedContent(content, data) {
|
||||
<button type="button" class="info-card-compute-candidate-preview"
|
||||
data-unresolved-collect
|
||||
data-requires-web-search="true"
|
||||
data-ready-title="采集坐标候选"
|
||||
title="${escapeInfoCardHtml(blocked ? disabledTitle : '采集坐标候选')}"
|
||||
data-ready-title="${escapeInfoCardHtml(infoText('采集坐标候选'))}"
|
||||
title="${escapeInfoCardHtml(blocked ? disabledTitle : infoText('采集坐标候选'))}"
|
||||
${blocked ? 'disabled aria-disabled="true"' : ''}
|
||||
data-context-json="${contextJson}">采集</button>
|
||||
data-context-json="${contextJson}">${escapeInfoCardHtml(getEarthLocale() === 'en-US' ? 'Collect' : '采集')}</button>
|
||||
</div>
|
||||
<div class="info-card-compute-collect-status" data-unresolved-status>${escapeInfoCardHtml(cached?.statusText || '')}</div>
|
||||
<div class="info-card-compute-collect-candidates" data-unresolved-candidates>
|
||||
@@ -863,14 +883,14 @@ function renderComputeCenterUnresolvedContent(content, data) {
|
||||
|
||||
content.innerHTML = `
|
||||
<div class="info-card-unresolved-summary">
|
||||
<span data-unresolved-summary-text>${items.length} 个算力中心没有可信坐标</span>
|
||||
<span data-unresolved-summary-text>${escapeInfoCardHtml(infoText(`${items.length} 个算力中心没有可信坐标`))}</span>
|
||||
<button type="button" class="info-card-compute-candidate-preview info-card-unresolved-adopt"
|
||||
data-unresolved-adopt-all
|
||||
data-requires-web-search="true"
|
||||
data-ready-title="一键定位并采用最高置信候选"
|
||||
title="${escapeInfoCardHtml(blocked ? disabledTitle : '一键定位并采用最高置信候选')}"
|
||||
data-ready-title="${escapeInfoCardHtml(infoText('一键定位并采用最高置信候选'))}"
|
||||
title="${escapeInfoCardHtml(blocked ? disabledTitle : infoText('一键定位并采用最高置信候选'))}"
|
||||
${blocked ? 'disabled aria-disabled="true"' : ''}>
|
||||
一键定位
|
||||
${escapeInfoCardHtml(infoText('一键定位'))}
|
||||
</button>
|
||||
</div>
|
||||
<div class="info-card-compute-collect-status" data-unresolved-batch-status>${escapeInfoCardHtml(computeCenterUnresolvedBatchState.statusText || '')}</div>
|
||||
@@ -888,8 +908,8 @@ function updateUnresolvedSummary(content) {
|
||||
const summaryText = content.querySelector('[data-unresolved-summary-text]');
|
||||
if (summaryText) {
|
||||
summaryText.textContent = remainingCount > 0
|
||||
? `${remainingCount} 个算力中心没有可信坐标`
|
||||
: '当前没有待定位算力中心';
|
||||
? infoText(`${remainingCount} 个算力中心没有可信坐标`)
|
||||
: infoText('当前没有待定位算力中心');
|
||||
}
|
||||
const adoptAllButton = content.querySelector('[data-unresolved-adopt-all]');
|
||||
if (adoptAllButton instanceof HTMLButtonElement) {
|
||||
@@ -948,7 +968,7 @@ async function collectUnresolvedComputeCenterCandidates(context, options = {}) {
|
||||
async function saveBestUnresolvedComputeCenterCandidate(context, progressLabel = '') {
|
||||
setLocationCollectState(context, {
|
||||
loading: true,
|
||||
statusText: progressLabel || '正在一键定位并采用最高置信候选...',
|
||||
statusText: infoText(progressLabel || '正在一键定位并采用最高置信候选...'),
|
||||
candidates: [],
|
||||
});
|
||||
const { mod, result, candidates } = await collectUnresolvedComputeCenterCandidates(
|
||||
@@ -958,7 +978,7 @@ async function saveBestUnresolvedComputeCenterCandidate(context, progressLabel =
|
||||
if (!result?.success) {
|
||||
setLocationCollectState(context, {
|
||||
loading: false,
|
||||
statusText: `未找到可采用候选:${formatLocationCollectFailure(result)}`,
|
||||
statusText: infoText(`未找到可采用候选:${formatLocationCollectFailure(result)}`),
|
||||
candidates: [],
|
||||
result,
|
||||
});
|
||||
@@ -966,7 +986,7 @@ async function saveBestUnresolvedComputeCenterCandidate(context, progressLabel =
|
||||
}
|
||||
setLocationCollectState(context, {
|
||||
loading: true,
|
||||
statusText: `找到 ${candidates.length} 个候选,正在保存最高置信位置...`,
|
||||
statusText: infoText(`找到 ${candidates.length} 个候选,正在保存最高置信位置...`),
|
||||
candidates,
|
||||
result,
|
||||
});
|
||||
@@ -974,7 +994,7 @@ async function saveBestUnresolvedComputeCenterCandidate(context, progressLabel =
|
||||
if (!bestCandidate) {
|
||||
setLocationCollectState(context, {
|
||||
loading: false,
|
||||
statusText: '未找到包含有效经纬度的候选',
|
||||
statusText: infoText('未找到包含有效经纬度的候选'),
|
||||
candidates,
|
||||
result,
|
||||
});
|
||||
@@ -983,7 +1003,7 @@ async function saveBestUnresolvedComputeCenterCandidate(context, progressLabel =
|
||||
const saveResult = await mod.saveComputeCenterLocation(context.sourceId, bestCandidate, context);
|
||||
setLocationCollectState(context, {
|
||||
loading: false,
|
||||
statusText: '坐标已保存,等待图层刷新',
|
||||
statusText: infoText('坐标已保存,等待图层刷新'),
|
||||
candidates,
|
||||
result,
|
||||
savedCandidate: bestCandidate,
|
||||
@@ -1052,7 +1072,7 @@ function bindComputeCenterUnresolvedControls(content) {
|
||||
setLocationButtonLoading(button, true, '正在定位');
|
||||
setLocationCollectState(context, {
|
||||
loading: true,
|
||||
statusText: '正在采集坐标候选...',
|
||||
statusText: infoText('正在采集坐标候选...'),
|
||||
candidates: [],
|
||||
});
|
||||
try {
|
||||
@@ -1060,7 +1080,7 @@ function bindComputeCenterUnresolvedControls(content) {
|
||||
if (!result?.success) {
|
||||
setLocationCollectState(context, {
|
||||
loading: false,
|
||||
statusText: `未能采集到坐标:${formatLocationCollectFailure(result)}`,
|
||||
statusText: infoText(`未能采集到坐标:${formatLocationCollectFailure(result)}`),
|
||||
candidates: [],
|
||||
result,
|
||||
});
|
||||
@@ -1068,7 +1088,7 @@ function bindComputeCenterUnresolvedControls(content) {
|
||||
}
|
||||
setLocationCollectState(context, {
|
||||
loading: false,
|
||||
statusText: `共找到 ${candidates.length} 个候选位置`,
|
||||
statusText: infoText(`共找到 ${candidates.length} 个候选位置`),
|
||||
candidates,
|
||||
result,
|
||||
});
|
||||
@@ -1084,7 +1104,7 @@ function bindComputeCenterUnresolvedControls(content) {
|
||||
console.error('collect unresolved compute-center location failed', error);
|
||||
setLocationCollectState(context, {
|
||||
loading: false,
|
||||
statusText: `采集失败:${error?.message || error}`,
|
||||
statusText: infoText(`采集失败:${error?.message || error}`),
|
||||
candidates: [],
|
||||
});
|
||||
} finally {
|
||||
@@ -1127,7 +1147,7 @@ function bindComputeCenterUnresolvedControls(content) {
|
||||
processed: 0,
|
||||
saved: 0,
|
||||
missed: 0,
|
||||
statusText: `一键定位进行中 0/${pendingItems.length}...`,
|
||||
statusText: infoText(`一键定位进行中 0/${pendingItems.length}...`),
|
||||
});
|
||||
|
||||
let savedCount = 0;
|
||||
@@ -1136,7 +1156,7 @@ function bindComputeCenterUnresolvedControls(content) {
|
||||
for (const [index, { itemRoot, context }] of pendingItems.entries()) {
|
||||
const itemStatusEl = itemRoot.querySelector('[data-unresolved-status]');
|
||||
itemRoot.classList.add('is-locating');
|
||||
const progressText = `正在定位并采用最高置信候选 ${index + 1}/${pendingItems.length}...`;
|
||||
const progressText = infoText(`正在定位并采用最高置信候选 ${index + 1}/${pendingItems.length}...`);
|
||||
setUnresolvedBatchState({
|
||||
statusText: progressText,
|
||||
processed: index,
|
||||
@@ -1147,7 +1167,7 @@ function bindComputeCenterUnresolvedControls(content) {
|
||||
try {
|
||||
const saveOutcome = await saveBestUnresolvedComputeCenterCandidate(context, progressText);
|
||||
if (!saveOutcome.saved) {
|
||||
if (itemStatusEl) itemStatusEl.textContent = getLocationCollectState(context)?.statusText || '未找到可采用候选';
|
||||
if (itemStatusEl) itemStatusEl.textContent = getLocationCollectState(context)?.statusText || infoText('未找到可采用候选');
|
||||
missedCount += 1;
|
||||
continue;
|
||||
}
|
||||
@@ -1157,11 +1177,11 @@ function bindComputeCenterUnresolvedControls(content) {
|
||||
console.error('adopt unresolved compute-center location failed', error);
|
||||
setLocationCollectState(context, {
|
||||
loading: false,
|
||||
statusText: `一键定位失败:${error?.message || error}`,
|
||||
statusText: infoText(`一键定位失败:${error?.message || error}`),
|
||||
candidates: [],
|
||||
});
|
||||
if (itemStatusEl) {
|
||||
itemStatusEl.textContent = `一键采用失败:${error?.message || error}`;
|
||||
itemStatusEl.textContent = infoText(`一键采用失败:${error?.message || error}`);
|
||||
}
|
||||
missedCount += 1;
|
||||
} finally {
|
||||
@@ -1170,14 +1190,14 @@ function bindComputeCenterUnresolvedControls(content) {
|
||||
processed: index + 1,
|
||||
saved: savedCount,
|
||||
missed: missedCount,
|
||||
statusText: `一键定位进行中 ${index + 1}/${pendingItems.length},已保存 ${savedCount} 个${missedCount ? `,失败 ${missedCount} 个` : ''}`,
|
||||
statusText: infoText(`一键定位进行中 ${index + 1}/${pendingItems.length},已保存 ${savedCount} 个${missedCount ? `,失败 ${missedCount} 个` : ''}`),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const finalStatus = savedCount > 0
|
||||
? `已定位并采用 ${savedCount} 个最高置信候选${missedCount ? `,${missedCount} 个仍需手动处理` : ''}`
|
||||
: `${missedCount} 个都没有可自动采用的候选,需要手动处理`;
|
||||
? infoText(`已定位并采用 ${savedCount} 个最高置信候选${missedCount ? `,${missedCount} 个仍需手动处理` : ''}`)
|
||||
: infoText(`${missedCount} 个都没有可自动采用的候选,需要手动处理`);
|
||||
if (statusEl) statusEl.textContent = finalStatus;
|
||||
setUnresolvedBatchState({
|
||||
running: false,
|
||||
@@ -1213,7 +1233,7 @@ function getFieldSourceLabel(data, fieldKey) {
|
||||
if (!sources || typeof sources !== 'object') return '';
|
||||
const source = sources[fieldKey];
|
||||
if (!source) return '';
|
||||
return ` <span class="info-card-source-tag" title="字段来源">${source}</span>`;
|
||||
return ` <span class="info-card-source-tag" title="${escapeInfoCardHtml(infoText('字段来源'))}">${escapeInfoCardHtml(infoText(source))}</span>`;
|
||||
}
|
||||
|
||||
function renderVesselEnrichmentSection(enrichment) {
|
||||
@@ -1223,8 +1243,8 @@ function renderVesselEnrichmentSection(enrichment) {
|
||||
if (!profile && !media) {
|
||||
return `
|
||||
<div class="info-card-enrichment info-card-enrichment--empty">
|
||||
<div class="info-card-enrichment-title">船舶资料</div>
|
||||
<div class="info-card-enrichment-status">资料缓存中</div>
|
||||
<div class="info-card-enrichment-title">${escapeInfoCardHtml(infoText('船舶资料'))}</div>
|
||||
<div class="info-card-enrichment-status">${escapeInfoCardHtml(infoText('资料缓存中'))}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -1244,11 +1264,11 @@ function renderVesselEnrichmentSection(enrichment) {
|
||||
inner += renderEnrichmentMeta('媒体', media);
|
||||
}
|
||||
if (!inner) {
|
||||
inner = '<div class="info-card-enrichment-status">资料缓存中</div>';
|
||||
inner = `<div class="info-card-enrichment-status">${escapeInfoCardHtml(infoText('资料缓存中'))}</div>`;
|
||||
}
|
||||
return `
|
||||
<div class="info-card-enrichment">
|
||||
<div class="info-card-enrichment-title">船舶资料</div>
|
||||
<div class="info-card-enrichment-title">${escapeInfoCardHtml(infoText('船舶资料'))}</div>
|
||||
${inner}
|
||||
</div>
|
||||
`;
|
||||
@@ -1261,8 +1281,8 @@ function renderEnrichmentPayloadRows(payload) {
|
||||
if (typeof value === 'object') continue;
|
||||
rows += `
|
||||
<div class="info-card-property">
|
||||
<span class="info-card-label">${key}</span>
|
||||
<span class="info-card-value">${String(value)}</span>
|
||||
<span class="info-card-label">${escapeInfoCardHtml(infoText(key))}</span>
|
||||
<span class="info-card-value">${escapeInfoCardHtml(infoText(String(value)))}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -1271,45 +1291,45 @@ function renderEnrichmentPayloadRows(payload) {
|
||||
|
||||
function renderEnrichmentMeta(label, record) {
|
||||
const parts = [];
|
||||
if (record.source) parts.push(`来源 ${record.source}`);
|
||||
if (record.fetched_at) parts.push(`更新 ${record.fetched_at}`);
|
||||
if (record.source) parts.push(infoText(`来源 ${record.source}`));
|
||||
if (record.fetched_at) parts.push(infoText(`更新 ${record.fetched_at}`));
|
||||
if (record.confidence !== null && record.confidence !== undefined) {
|
||||
parts.push(`置信 ${Number(record.confidence).toFixed(2)}`);
|
||||
parts.push(infoText(`置信 ${Number(record.confidence).toFixed(2)}`));
|
||||
}
|
||||
if (!parts.length) return '';
|
||||
return `<div class="info-card-enrichment-meta">${label}:${parts.join(' · ')}</div>`;
|
||||
return `<div class="info-card-enrichment-meta">${escapeInfoCardHtml(infoText(label))}: ${escapeInfoCardHtml(parts.join(' · '))}</div>`;
|
||||
}
|
||||
|
||||
// ── Mobile popup ─────────────────────────────────────────────
|
||||
|
||||
function getMobilePopupTitle(type, data) {
|
||||
switch (type) {
|
||||
case 'cable': return data.name || '海缆';
|
||||
case 'landing_point': return data.name || '登陆点';
|
||||
case 'satellite': return data.name || '卫星';
|
||||
case 'bgp': return data.anomaly_type || 'BGP事件';
|
||||
case 'cable': return data.name || infoText('海缆');
|
||||
case 'landing_point': return data.name || infoText('登陆点');
|
||||
case 'satellite': return data.name || infoText('卫星');
|
||||
case 'bgp': return data.anomaly_type || infoText('BGP事件');
|
||||
case 'news': return getNewsCardTitle(data);
|
||||
case 'bgp_collector': return data.collector || 'BGP观测站';
|
||||
case 'compute_center_unresolved': return '待定位算力中心';
|
||||
case 'supercomputer': return data.name || '超算';
|
||||
case 'gpu_cluster': return data.name || 'GPU集群';
|
||||
case 'vessel': return data.name || '船只';
|
||||
default: return '详情';
|
||||
case 'bgp_collector': return data.collector || infoText('BGP观测站');
|
||||
case 'compute_center_unresolved': return infoText('待定位算力中心');
|
||||
case 'supercomputer': return data.name || infoText('超算');
|
||||
case 'gpu_cluster': return data.name || infoText('GPU集群');
|
||||
case 'vessel': return data.name || infoText('船只');
|
||||
default: return infoText('详情');
|
||||
}
|
||||
}
|
||||
|
||||
function getMobilePopupSubtitle(type, data) {
|
||||
switch (type) {
|
||||
case 'cable': return data.owner || data.status || '海缆';
|
||||
case 'landing_point': return data.country || '登陆点';
|
||||
case 'satellite': return data.norad_id ? `NORAD ${data.norad_id}` : '卫星';
|
||||
case 'bgp': return data.severity || 'BGP路由异常';
|
||||
case 'news': return getNewsCardSummaryPreview(data, 30) || '态势新闻';
|
||||
case 'bgp_collector': return data.location || 'BGP观测站';
|
||||
case 'compute_center_unresolved': return `${data?.totalCount || 0} 个待定位`;
|
||||
case 'supercomputer': return data.country || '超级计算机';
|
||||
case 'gpu_cluster': return data.country || 'GPU集群';
|
||||
case 'vessel': return data.vessel_type || 'AIS 船只';
|
||||
case 'cable': return infoText(data.owner || data.status || '海缆');
|
||||
case 'landing_point': return localizeCountryName(data.country) || infoText('登陆点');
|
||||
case 'satellite': return data.norad_id ? `NORAD ${data.norad_id}` : infoText('卫星');
|
||||
case 'bgp': return infoText(data.severity || 'BGP路由异常');
|
||||
case 'news': return getNewsCardSummaryPreview(data, 30) || infoText('态势新闻');
|
||||
case 'bgp_collector': return data.location || infoText('BGP观测站');
|
||||
case 'compute_center_unresolved': return infoText(`${data?.totalCount || 0} 个待定位`);
|
||||
case 'supercomputer': return localizeCountryName(data.country) || infoText('超级计算机');
|
||||
case 'gpu_cluster': return localizeCountryName(data.country) || infoText('GPU集群');
|
||||
case 'vessel': return data.vessel_type || infoText('AIS 船只');
|
||||
default: return '';
|
||||
}
|
||||
}
|
||||
@@ -1809,8 +1829,8 @@ function mountCard() {
|
||||
<div id="info-card" class="info-card">
|
||||
<div class="info-card-header hud-panel-drag-handle">
|
||||
<span class="info-card-icon" id="info-card-icon">🛰️</span>
|
||||
<h3 id="info-card-title">详情</h3>
|
||||
<button class="info-card-close hud-panel-close" type="button" aria-label="关闭详情">
|
||||
<h3 id="info-card-title">${escapeInfoCardHtml(infoText('详情'))}</h3>
|
||||
<button class="info-card-close hud-panel-close" type="button" aria-label="${escapeInfoCardHtml(infoText('关闭详情'))}">
|
||||
<span class="material-symbols-rounded">close</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -1850,16 +1870,19 @@ function mountCard() {
|
||||
const value = valueEl?.textContent?.trim();
|
||||
|
||||
if (!value || value === '-') {
|
||||
showStatusMessage('无可复制内容', 'warning');
|
||||
showStatusMessage(earthMessage("status.copyEmpty"), 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
showStatusMessage(`已复制${label.textContent}:${value}`, 'success');
|
||||
showStatusMessage(
|
||||
earthMessage("status.copyValue", { label: label.textContent, value }),
|
||||
'success',
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Copy failed:', error);
|
||||
showStatusMessage('复制失败', 'error');
|
||||
showStatusMessage(earthMessage("status.copyFailed"), 'error');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1885,7 +1908,7 @@ function positionPanel(panel, x, y, options = {}) {
|
||||
const scale = parseFloat(
|
||||
getComputedStyle(document.documentElement).getPropertyValue('--hud-scale')
|
||||
) || 1;
|
||||
const estW = Math.min(300 * scale, vpW - 32);
|
||||
const estW = Math.min(340 * scale, vpW - 32);
|
||||
const estH = Math.min(420 * scale, vpH * 0.7);
|
||||
|
||||
if (options.absolute === true) {
|
||||
@@ -2000,11 +2023,11 @@ export function showInfoCard(type, data, options = {}) {
|
||||
if (title) {
|
||||
title.textContent = type === 'news'
|
||||
? getNewsCardTitle(data)
|
||||
: config.title;
|
||||
: infoText(config.title);
|
||||
}
|
||||
if (typeLabel) {
|
||||
typeLabel.textContent = type === 'news'
|
||||
? '新闻信号'
|
||||
? infoText('新闻信号')
|
||||
: type.replaceAll('_', ' ');
|
||||
}
|
||||
|
||||
@@ -2041,7 +2064,7 @@ export function showInfoCard(type, data, options = {}) {
|
||||
icon.textContent = config.icon;
|
||||
title.textContent = type === 'news'
|
||||
? getNewsCardTitle(data)
|
||||
: config.title;
|
||||
: infoText(config.title);
|
||||
|
||||
if (type === 'news') {
|
||||
renderNewsCardContent(content, data);
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { translateText } from "./i18n.js";
|
||||
|
||||
export function setButtonTooltip(button, text) {
|
||||
const translatedText = translateText(text);
|
||||
if (button instanceof HTMLElement) {
|
||||
button.title = text;
|
||||
button.title = translatedText;
|
||||
}
|
||||
const tooltip = button?.querySelector(".earth-toolbar-tooltip");
|
||||
if (tooltip) {
|
||||
tooltip.textContent = text;
|
||||
tooltip.textContent = translatedText;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
loadCountryBoundaries,
|
||||
toggleCountryBoundaries,
|
||||
} from "./country-boundaries.js";
|
||||
import { earthMessage } from "./i18n.js";
|
||||
|
||||
/**
|
||||
* Layer startup task registry.
|
||||
@@ -101,7 +102,7 @@ function registerVesselStartupTask() {
|
||||
if (!context.getShowVessels()) return;
|
||||
|
||||
context.setLoadingMessage(
|
||||
resolveStartupMessage(layer, "load", "正在加载船只..."),
|
||||
resolveStartupMessage(layer, "load", earthMessage("startup.vessels")),
|
||||
);
|
||||
await context.yieldFrame(12);
|
||||
try {
|
||||
@@ -125,7 +126,7 @@ function registerCableStartupTask() {
|
||||
if (!context.isCablesEnabled()) return;
|
||||
|
||||
context.setLoadingMessage(
|
||||
resolveStartupMessage(layer, "prepare", "正在加载登陆点..."),
|
||||
resolveStartupMessage(layer, "prepare", earthMessage("startup.landingPoints")),
|
||||
);
|
||||
await context.yieldFrame(12);
|
||||
try {
|
||||
@@ -137,7 +138,7 @@ function registerCableStartupTask() {
|
||||
await context.yieldFrame(16);
|
||||
|
||||
context.setLoadingMessage(
|
||||
resolveStartupMessage(layer, "load", "正在加载海缆..."),
|
||||
resolveStartupMessage(layer, "load", earthMessage("startup.cables")),
|
||||
);
|
||||
await context.yieldFrame(12);
|
||||
try {
|
||||
@@ -161,7 +162,7 @@ function registerSatelliteStartupTask() {
|
||||
if (!context.isSatellitesEnabled()) return;
|
||||
|
||||
context.setLoadingMessage(
|
||||
resolveStartupMessage(layer, "load", "正在加载卫星..."),
|
||||
resolveStartupMessage(layer, "load", earthMessage("startup.satellites")),
|
||||
);
|
||||
await context.yieldFrame(12);
|
||||
try {
|
||||
@@ -200,7 +201,7 @@ function registerSatelliteStartupTask() {
|
||||
function registerBGPStartupTask() {
|
||||
registerLayerStartupTask("bgp", (context) => async (layer) => {
|
||||
context.setLoadingMessage(
|
||||
resolveStartupMessage(layer, "load", "正在加载BGP态势..."),
|
||||
resolveStartupMessage(layer, "load", earthMessage("startup.bgp")),
|
||||
);
|
||||
await context.yieldFrame(12);
|
||||
try {
|
||||
@@ -223,7 +224,7 @@ function registerEarthTextureStartupTask() {
|
||||
if (!context.isEarthTextureVisible()) return;
|
||||
|
||||
context.setLoadingMessage(
|
||||
resolveStartupMessage(layer, "load", "正在加载地球纹理..."),
|
||||
resolveStartupMessage(layer, "load", earthMessage("startup.hdTexture")),
|
||||
);
|
||||
await context.yieldFrame(12);
|
||||
try {
|
||||
@@ -241,7 +242,7 @@ function registerCloudStartupTask() {
|
||||
if (!context.isCloudsEnabled()) return;
|
||||
|
||||
context.setLoadingMessage(
|
||||
resolveStartupMessage(layer, "load", "正在加载大气云图..."),
|
||||
resolveStartupMessage(layer, "load", earthMessage("startup.clouds")),
|
||||
);
|
||||
await context.yieldFrame(12);
|
||||
try {
|
||||
@@ -257,7 +258,7 @@ function registerCloudStartupTask() {
|
||||
function registerCountryBoundaryStartupTask() {
|
||||
registerLayerStartupTask("countryBoundaries", (context) => async (layer) => {
|
||||
context.setLoadingMessage(
|
||||
resolveStartupMessage(layer, "load", "正在加载海陆基座..."),
|
||||
resolveStartupMessage(layer, "load", earthMessage("startup.landOceanBase")),
|
||||
);
|
||||
await context.yieldFrame(12);
|
||||
try {
|
||||
@@ -283,7 +284,7 @@ function registerCountryBoundaryStartupTask() {
|
||||
function registerComputeCenterStartupTask() {
|
||||
registerLayerStartupTask("computeCenters", (context) => async (layer) => {
|
||||
context.setLoadingMessage(
|
||||
resolveStartupMessage(layer, "load", "正在加载算力中心..."),
|
||||
resolveStartupMessage(layer, "load", earthMessage("startup.computeCenters")),
|
||||
);
|
||||
await context.yieldFrame(12);
|
||||
try {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { createHUDPanel } from "./hud-panels.js";
|
||||
import { getEarthLocale, translateText } from "./i18n.js";
|
||||
|
||||
const LEGEND_MODES = {
|
||||
cables: { title: "海缆" },
|
||||
satellites: { title: "卫星" },
|
||||
cables: { title: "海缆", compactTitleEn: "Cables" },
|
||||
satellites: { title: "卫星", compactTitleEn: "Orbits" },
|
||||
countryBoundaries: { title: "国界" },
|
||||
computeCenters: { title: "算力" },
|
||||
vessels: { title: "船只" },
|
||||
@@ -11,6 +12,7 @@ const LEGEND_MODES = {
|
||||
|
||||
let currentLegendMode = "cables";
|
||||
let legendPanel = null;
|
||||
let legendLocaleListenerBound = false;
|
||||
let legendItemsByMode = {
|
||||
cables: [],
|
||||
satellites: [],
|
||||
@@ -40,6 +42,14 @@ export function initLegend() {
|
||||
});
|
||||
}
|
||||
|
||||
if (!legendLocaleListenerBound) {
|
||||
legendLocaleListenerBound = true;
|
||||
window.addEventListener("earth:locale-change", () => {
|
||||
syncCurrentLabel(currentLegendMode);
|
||||
renderLegend(currentLegendMode);
|
||||
});
|
||||
}
|
||||
|
||||
syncCurrentLabel(currentLegendMode);
|
||||
renderLegend(currentLegendMode);
|
||||
}
|
||||
@@ -68,24 +78,38 @@ export function setLegendItems(mode, items) {
|
||||
}
|
||||
|
||||
function syncCurrentLabel(mode) {
|
||||
const nextLabel = LEGEND_MODES[mode]?.title || LEGEND_MODES.cables.title;
|
||||
const nextLabel = getLegendModeTitle(mode);
|
||||
[document.getElementById("legend-current-label"), document.getElementById("mobile-situation-legend-mode")]
|
||||
.forEach((labelEl) => {
|
||||
if (labelEl) {
|
||||
labelEl.textContent = nextLabel;
|
||||
const translatedLabel = getEarthLocale() === "en-US" ? nextLabel : translateText(nextLabel);
|
||||
labelEl.textContent = translatedLabel;
|
||||
labelEl.dataset.i18nOriginalTitle = translatedLabel;
|
||||
labelEl.title = translatedLabel;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function getLegendModeTitle(mode) {
|
||||
const definition = LEGEND_MODES[mode] || LEGEND_MODES.cables;
|
||||
if (getEarthLocale() === "en-US" && definition.compactTitleEn) {
|
||||
return definition.compactTitleEn;
|
||||
}
|
||||
return definition.title;
|
||||
}
|
||||
|
||||
function renderLegend(mode) {
|
||||
const items = legendItemsByMode[mode] || [];
|
||||
const html = items
|
||||
.map(
|
||||
(item) => `
|
||||
(item) => {
|
||||
const label = escapeLegendHtml(translateText(item.label));
|
||||
return `
|
||||
<div class="legend-item">
|
||||
<span class="legend-dot legend-dot--${item.shape || "dot"}" style="background:${item.color}; color:${item.color}"></span>
|
||||
<span class="legend-label">${item.label}</span>
|
||||
</div>`,
|
||||
<span class="legend-label" title="${label}">${label}</span>
|
||||
</div>`;
|
||||
},
|
||||
)
|
||||
.join("");
|
||||
|
||||
@@ -99,3 +123,12 @@ function renderLegend(mode) {
|
||||
mobileList.innerHTML = html;
|
||||
}
|
||||
}
|
||||
|
||||
function escapeLegendHtml(value) {
|
||||
return String(value ?? "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import * as THREE from "three";
|
||||
|
||||
import {
|
||||
earthMessage,
|
||||
getEarthLocale,
|
||||
initEarthI18n,
|
||||
localizeCountryName,
|
||||
onEarthLocaleChange,
|
||||
translateText,
|
||||
} from "./i18n.js";
|
||||
import {
|
||||
CONFIG,
|
||||
CRUISE_MODULES,
|
||||
@@ -304,6 +312,12 @@ import {
|
||||
setMotionDebugPanelVisible,
|
||||
} from "./motion-debug-panel.js";
|
||||
|
||||
initEarthI18n();
|
||||
window.addEventListener("earth:status", (event) => {
|
||||
const message = event.detail?.message;
|
||||
if (message) showStatusMessage(message, event.detail?.type || "info");
|
||||
});
|
||||
|
||||
const EARTH_RADIUS_KM = 6371;
|
||||
const EARTH_GRAVITATIONAL_PARAMETER_KM3_S2 = 398600.4418;
|
||||
const SECONDS_PER_DAY = 86400;
|
||||
@@ -343,6 +357,7 @@ let earthTexture = null;
|
||||
let animationFrameId = null;
|
||||
let initialized = false;
|
||||
let destroyed = false;
|
||||
let runtimeBrandConfig = null;
|
||||
let isDataLoading = false;
|
||||
let currentLoadToken = 0;
|
||||
let cablesEnabled = true;
|
||||
@@ -357,6 +372,21 @@ let calloutConnector = null;
|
||||
let cruiseBGPAdapter = null;
|
||||
let cruiseNewsAdapter = null;
|
||||
let cruiseSequencer = null;
|
||||
|
||||
function getEarthBrandLanguage() {
|
||||
return getEarthLocale() === "en-US" ? "en" : (HUD_CONFIG.brandLanguage || "zh");
|
||||
}
|
||||
|
||||
function getLocalizedBrandConfig(config = null) {
|
||||
if (config && typeof config === "object") {
|
||||
return { ...config, variant: config.variant || getEarthBrandLanguage() };
|
||||
}
|
||||
return getEarthBrandLanguage();
|
||||
}
|
||||
|
||||
function remountEarthBrand() {
|
||||
mountBrand(document.getElementById("brand-root"), getLocalizedBrandConfig(runtimeBrandConfig));
|
||||
}
|
||||
let cruiseRandomQueueSignature = "";
|
||||
let cruiseRandomQueueItems = [];
|
||||
let earthUpdatesSocket = null;
|
||||
@@ -419,6 +449,21 @@ const VESSEL_POINTER_RADIUS_PX = 22;
|
||||
const INTERACTABLE_POINTER_RADIUS_PX = 24;
|
||||
const MOTION_FOCUS_RADIUS_PX = 180;
|
||||
const MOTION_FOCUS_REFRESH_MS = 350;
|
||||
|
||||
function isEnglishEarthLocale() {
|
||||
return getEarthLocale() === "en-US";
|
||||
}
|
||||
|
||||
function formatUnresolvedComputeTitle(count) {
|
||||
return isEnglishEarthLocale()
|
||||
? `${count} compute centers pending location`
|
||||
: `${count} 个算力中心待定位`;
|
||||
}
|
||||
|
||||
function formatUnresolvedComputeSuffix(count) {
|
||||
if (count <= 0) return "";
|
||||
return isEnglishEarthLocale() ? ` (${count} pending location)` : `(${count} 个待定位)`;
|
||||
}
|
||||
const MOTION_MARKER_ANCHOR_SIZE_PX = 24;
|
||||
const MOTION_SATELLITE_ANCHOR_SIZE_PX = 18;
|
||||
const MOTION_CABLE_ANCHOR_SIZE_PX = 14;
|
||||
@@ -881,7 +926,9 @@ function getPrimaryClusterHit(...intersectionGroups) {
|
||||
|
||||
function getClusterBriefHtml(hit) {
|
||||
const count = Number(hit?.clusterCount || hit?.clusterMarkers?.length || 0);
|
||||
return `<strong>共 ${count} 个对象</strong><br><span>放大后可查看单个图标</span>`;
|
||||
const title = isEnglishEarthLocale() ? `${count} objects` : `共 ${count} 个对象`;
|
||||
const hint = isEnglishEarthLocale() ? "Zoom in to inspect individual markers" : "放大后可查看单个图标";
|
||||
return `<strong>${title}</strong><br><span>${hint}</span>`;
|
||||
}
|
||||
|
||||
function getPrimaryBGPHoverTarget(bgpAnomalyIntersects, bgpCollectorIntersects) {
|
||||
@@ -1336,7 +1383,7 @@ async function applyMotionSharedCruiseFocus(direction = "next") {
|
||||
|
||||
const presented = await sequencer.presentSpecificItem(targetItem, { interrupt: true });
|
||||
if (presented) {
|
||||
showStatusMessage("动捕: 已切换巡航目标", "info");
|
||||
showStatusMessage(earthMessage("status.motionCruiseTargetSwitched"), "info");
|
||||
}
|
||||
return Boolean(presented);
|
||||
}
|
||||
@@ -1465,7 +1512,10 @@ export async function applyMotionFocus(direction = "next") {
|
||||
refreshMotionFocusCandidates({ force: true });
|
||||
if (motionFocusCandidates.length === 0) {
|
||||
const layer = getCurrentMotionFocusLayer();
|
||||
showStatusMessage(layer ? `动捕: ${layer.label}当前视野没有可选目标` : "动捕: 当前没有可用图层", "info");
|
||||
showStatusMessage(
|
||||
earthMessage("status.motionNoTarget", { layer: layer?.label || "" }),
|
||||
"info",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
const delta = direction === "prev" ? -1 : 1;
|
||||
@@ -1473,7 +1523,10 @@ export async function applyMotionFocus(direction = "next") {
|
||||
const candidate = motionFocusCandidates[motionFocusIndex];
|
||||
applyMotionFocusVisual(candidate);
|
||||
await presentMotionCandidate(candidate);
|
||||
showStatusMessage(`动捕: 已切换到${getMotionCandidateLabel(candidate)}`, "info");
|
||||
showStatusMessage(
|
||||
earthMessage("status.motionSwitchedTo", { label: getMotionCandidateLabel(candidate) }),
|
||||
"info",
|
||||
);
|
||||
return true;
|
||||
} finally {
|
||||
releaseGate();
|
||||
@@ -1486,7 +1539,7 @@ export async function applyMotionLayerSwitch(direction = "next") {
|
||||
try {
|
||||
const layers = getVisibleMotionLayerDefinitions();
|
||||
if (layers.length === 0) {
|
||||
showStatusMessage("动捕: 当前没有可切换的可见图层", "info");
|
||||
showStatusMessage(earthMessage("status.motionNoVisibleLayer"), "info");
|
||||
return false;
|
||||
}
|
||||
const currentIndex = layers.findIndex((layer) => layer.id === motionFocusLayerId);
|
||||
@@ -1505,7 +1558,10 @@ export async function applyMotionLayerSwitch(direction = "next") {
|
||||
if (candidate) {
|
||||
await presentMotionCandidate(candidate);
|
||||
}
|
||||
showStatusMessage(`动捕: 已切换到${nextLayer.label}图层`, "info");
|
||||
showStatusMessage(
|
||||
earthMessage("status.motionSwitchedTo", { label: nextLayer.label, layer: true }),
|
||||
"info",
|
||||
);
|
||||
return true;
|
||||
} finally {
|
||||
releaseGate();
|
||||
@@ -1673,7 +1729,10 @@ function confirmMotionCandidate(candidate, { showMotionStatus = true } = {}) {
|
||||
});
|
||||
window.dispatchEvent(new CustomEvent("earth:open-details-tab"));
|
||||
if (showMotionStatus) {
|
||||
showStatusMessage(`动捕: 已确认${getMotionCandidateLabel(candidate)}`, "info");
|
||||
showStatusMessage(
|
||||
earthMessage("status.motionConfirmed", { label: getMotionCandidateLabel(candidate) }),
|
||||
"info",
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -1693,7 +1752,7 @@ function showCableInfo(cable, coords) {
|
||||
function getCableBriefHtml(cable) {
|
||||
const name = cable.userData.name || "未知海缆";
|
||||
const status = cable.userData.status || "";
|
||||
return `<strong>${name}</strong>${status ? `<br>${status}` : ""}`;
|
||||
return `<strong>${translateText(name)}</strong>${status ? `<br>${translateText(status)}` : ""}`;
|
||||
}
|
||||
|
||||
function showSatelliteInfo(props, coords) {
|
||||
@@ -1822,7 +1881,7 @@ function showVesselInfo(marker, coords) {
|
||||
status: formatVesselStatus(marker.userData?.nav_status),
|
||||
length: marker.userData?.length ?? "-",
|
||||
received_at: marker.userData?.received_at
|
||||
? new Date(marker.userData.received_at).toLocaleString("zh-CN", { hour12: false })
|
||||
? new Date(marker.userData.received_at).toLocaleString(getEarthLocale(), { hour12: false })
|
||||
: "-",
|
||||
}, coords);
|
||||
}
|
||||
@@ -1846,31 +1905,31 @@ function getEarthInteractableBriefHtml(marker) {
|
||||
const ud = marker?.userData || {};
|
||||
const name = ud.label || ud.name || "交互点";
|
||||
const kind = ud.kind || "数据点";
|
||||
return `<strong>${name}</strong><br>${kind}`;
|
||||
return `<strong>${translateText(name)}</strong><br>${translateText(kind)}`;
|
||||
}
|
||||
|
||||
function getVesselBriefHtml(marker) {
|
||||
const name = marker.userData?.name || `MMSI ${marker.userData?.mmsi}`;
|
||||
const speed = marker.userData?.sog ?? "-";
|
||||
const vesselType = marker.userData?.vessel_type_display || marker.userData?.vessel_type_name || "Vessel";
|
||||
return `<strong>${name}</strong><br>${vesselType} · ${speed} kn`;
|
||||
return `<strong>${translateText(name)}</strong><br>${translateText(vesselType)} · ${speed} kn`;
|
||||
}
|
||||
|
||||
function getComputeCenterBriefHtml(marker) {
|
||||
const name = marker.userData?.name || "算力中心";
|
||||
const type = formatComputeCenterTypeLabel(marker.userData?.site_type);
|
||||
const location = [marker.userData?.city, marker.userData?.country]
|
||||
const location = [marker.userData?.city, localizeCountryName(marker.userData?.country)]
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
const precision = marker.userData?.is_estimated ? " · 估算位置" : "";
|
||||
return `<strong>${name}</strong><br>${type}${location ? ` · ${location}` : ""}${precision}`;
|
||||
const precision = marker.userData?.is_estimated ? ` · ${translateText("估算位置")}` : "";
|
||||
return `<strong>${translateText(name)}</strong><br>${translateText(type)}${location ? ` · ${location}` : ""}${precision}`;
|
||||
}
|
||||
|
||||
function getCountryBoundaryBriefHtml(country) {
|
||||
const name = country?.nameZh || country?.name || "未知国家";
|
||||
const name = localizeCountryName(country) || translateText("未知国家");
|
||||
const code = country?.isoA3 || country?.isoA2 || "-";
|
||||
const continent = country?.continent || "-";
|
||||
return `<strong>${name}</strong><br>ISO: ${code}<br>大洲: ${continent}`;
|
||||
const continent = translateText(country?.continent || "-");
|
||||
return `<strong>${name}</strong><br>ISO: ${code}<br>${translateText("大洲")}: ${continent}`;
|
||||
}
|
||||
|
||||
function getSurfacePositionBriefHtml(coords) {
|
||||
@@ -1880,7 +1939,7 @@ function getSurfacePositionBriefHtml(coords) {
|
||||
? `${(elevMeters / 1000).toFixed(2)} km`
|
||||
: `${Math.round(elevMeters)} m`
|
||||
: "—";
|
||||
return `纬度: ${coords.lat}°<br>经度: ${coords.lon}°<br>海拔: ${elevText}`;
|
||||
return `${translateText("纬度")}: ${coords.lat}°<br>${translateText("经度")}: ${coords.lon}°<br>${translateText("海拔")}: ${elevText}`;
|
||||
}
|
||||
|
||||
function showBGPInfo(marker, coords) {
|
||||
@@ -1920,7 +1979,9 @@ function showBGPInfo(marker, coords) {
|
||||
),
|
||||
prefix:
|
||||
Array.isArray(marker.userData.prefixes) && marker.userData.prefixes.length > 1
|
||||
? `${marker.userData.prefixes[0]} 等${marker.userData.prefixes.length}个`
|
||||
? isEnglishEarthLocale()
|
||||
? `${marker.userData.prefixes[0]} + ${marker.userData.prefixes.length - 1}`
|
||||
: `${marker.userData.prefixes[0]} 等${marker.userData.prefixes.length}个`
|
||||
: marker.userData.prefix,
|
||||
as_path_display:
|
||||
Array.isArray(marker.userData.as_path) && marker.userData.as_path.length > 0
|
||||
@@ -1932,7 +1993,9 @@ function showBGPInfo(marker, coords) {
|
||||
: marker.userData.origin_asn,
|
||||
new_origin_asn:
|
||||
Array.isArray(marker.userData.affected_asns) && marker.userData.affected_asns.length > 3
|
||||
? `共${marker.userData.affected_asns.length}个ASN`
|
||||
? isEnglishEarthLocale()
|
||||
? `${marker.userData.affected_asns.length} ASNs`
|
||||
: `共${marker.userData.affected_asns.length}个ASN`
|
||||
: marker.userData.new_origin_asn,
|
||||
confidence: formatBGPConfidence(marker.userData.confidence),
|
||||
collector: marker.userData.collector,
|
||||
@@ -1941,7 +2004,9 @@ function showBGPInfo(marker, coords) {
|
||||
related_cables: relatedCables,
|
||||
related_satellites:
|
||||
marker.userData.related_satellite_count > 0
|
||||
? `${marker.userData.related_satellite_count}颗事件附近卫星`
|
||||
? isEnglishEarthLocale()
|
||||
? `${marker.userData.related_satellite_count} nearby event satellites`
|
||||
: `${marker.userData.related_satellite_count}颗事件附近卫星`
|
||||
: "-",
|
||||
location:
|
||||
marker.userData.location ||
|
||||
@@ -1983,7 +2048,8 @@ function showBGPCollectorInfo(marker, coords) {
|
||||
function getBGPCollectorBriefHtml(marker) {
|
||||
const name = marker.userData.collector || "观测站";
|
||||
const count = marker.userData.anomaly_count ?? 0;
|
||||
return `<strong>${name}</strong><br>${count} 条事件`;
|
||||
const eventText = isEnglishEarthLocale() ? `${count} events` : `${count} 条事件`;
|
||||
return `<strong>${translateText(name)}</strong><br>${eventText}`;
|
||||
}
|
||||
|
||||
function getSearchCardCoords() {
|
||||
@@ -2157,7 +2223,10 @@ async function focusSearchLandingPoint(point) {
|
||||
});
|
||||
applyLandingPointVisualState(relatedCableNames, relatedCableNames.length === 0, camera);
|
||||
showLandingPointInfo(point, getSearchCardCoords());
|
||||
showStatusMessage(`已定位登陆点:${point.userData?.name || "未知登陆点"}`, "info");
|
||||
showStatusMessage(
|
||||
earthMessage("status.located", { target: "登陆点", name: point.userData?.name || "未知登陆点" }),
|
||||
"info",
|
||||
);
|
||||
}
|
||||
|
||||
async function focusSearchSatellite(index) {
|
||||
@@ -2191,7 +2260,13 @@ async function focusSearchSatellite(index) {
|
||||
}
|
||||
}
|
||||
showSatelliteInfo(sat.properties, getSearchCardCoords());
|
||||
showStatusMessage(`已定位卫星:${sat.properties.name || sat.properties.norad_cat_id || "未知卫星"}`, "info");
|
||||
showStatusMessage(
|
||||
earthMessage("status.located", {
|
||||
target: "卫星",
|
||||
name: sat.properties.name || sat.properties.norad_cat_id || "未知卫星",
|
||||
}),
|
||||
"info",
|
||||
);
|
||||
}
|
||||
|
||||
async function focusSearchBGPMarker(marker) {
|
||||
@@ -2215,7 +2290,13 @@ async function focusSearchBGPMarker(marker) {
|
||||
showBGPEventOverlay(marker, earth);
|
||||
applyBGPEventSatelliteHighlights(marker);
|
||||
showBGPInfo(marker, getSearchCardCoords());
|
||||
showStatusMessage(`已定位 BGP 事件:${marker.userData?.collector || "未知观测站"}`, "info");
|
||||
showStatusMessage(
|
||||
earthMessage("status.located", {
|
||||
target: "BGP 事件",
|
||||
name: marker.userData?.collector || "未知观测站",
|
||||
}),
|
||||
"info",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2225,7 +2306,13 @@ async function focusSearchBGPMarker(marker) {
|
||||
lockedObjectType = "bgp_collector";
|
||||
showBGPCollectorCoverageOverlay(marker, earth);
|
||||
showBGPCollectorInfo(marker, getSearchCardCoords());
|
||||
showStatusMessage(`已定位观测站:${marker.userData?.collector || "未知观测站"}`, "info");
|
||||
showStatusMessage(
|
||||
earthMessage("status.located", {
|
||||
target: "观测站",
|
||||
name: marker.userData?.collector || "未知观测站",
|
||||
}),
|
||||
"info",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2247,7 +2334,10 @@ async function focusSearchComputeCenter(marker) {
|
||||
lockedObjectType = "compute_center";
|
||||
showComputeCenterInfo(marker, getSearchCardCoords());
|
||||
showStatusMessage(
|
||||
`已定位算力中心:${marker.userData?.name || "未知节点"}`,
|
||||
earthMessage("status.located", {
|
||||
target: "算力中心",
|
||||
name: marker.userData?.name || "未知节点",
|
||||
}),
|
||||
"info",
|
||||
);
|
||||
}
|
||||
@@ -2310,7 +2400,7 @@ async function spawnComputeCenterAfterLocationSave(detail = {}) {
|
||||
lockedObject = result.marker;
|
||||
lockedObjectType = "compute_center";
|
||||
}
|
||||
showStatusMessage("算力中心坐标已保存", "success");
|
||||
showStatusMessage(earthMessage("status.computeCoordinatesSaved"), "success");
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -2336,7 +2426,10 @@ async function focusSearchVessel(marker) {
|
||||
console.warn("船只轨迹加载失败:", error);
|
||||
});
|
||||
showStatusMessage(
|
||||
`已定位船只:${marker.userData?.name || marker.userData?.mmsi || "未知船只"}`,
|
||||
earthMessage("status.located", {
|
||||
target: "船只",
|
||||
name: marker.userData?.name || marker.userData?.mmsi || "未知船只",
|
||||
}),
|
||||
"info",
|
||||
);
|
||||
}
|
||||
@@ -2645,16 +2738,16 @@ async function loadEarthStatsSummary({
|
||||
function updateComputeCenterHud(computeCenterResult) {
|
||||
const computeBtn = document.getElementById("toggle-compute-centers");
|
||||
const unresolvedCount = Number(computeCenterResult?.unresolvedCount) || 0;
|
||||
const unresolvedTooltip =
|
||||
unresolvedCount > 0 ? `(${unresolvedCount} 个待定位)` : "";
|
||||
const unresolvedTooltip = formatUnresolvedComputeSuffix(unresolvedCount);
|
||||
|
||||
if (computeBtn) {
|
||||
const tooltip = getShowComputeCenters()
|
||||
? `隐藏算力中心${unresolvedTooltip}`
|
||||
: `显示算力中心${unresolvedTooltip}`;
|
||||
setLayerButtonState(computeBtn, {
|
||||
active: getShowComputeCenters(),
|
||||
loading: false,
|
||||
tooltip: getShowComputeCenters()
|
||||
? `隐藏算力中心${unresolvedTooltip}`
|
||||
: `显示算力中心${unresolvedTooltip}`,
|
||||
tooltip: translateText(tooltip),
|
||||
});
|
||||
updateComputeCenterUnresolvedBadge(computeBtn, unresolvedCount);
|
||||
}
|
||||
@@ -2690,8 +2783,12 @@ function updateComputeCenterUnresolvedBadge(computeBtn, unresolvedCount) {
|
||||
|
||||
computeBtn.dataset.unresolvedCount = String(count);
|
||||
badge.textContent = count > 99 ? "99+" : String(count);
|
||||
badge.title = `${count} 个算力中心待定位`;
|
||||
badge.setAttribute("aria-label", `${count} 个算力中心待定位,点击查看`);
|
||||
const title = formatUnresolvedComputeTitle(count);
|
||||
badge.title = title;
|
||||
badge.setAttribute(
|
||||
"aria-label",
|
||||
isEnglishEarthLocale() ? `${title}, click to view` : `${title},点击查看`,
|
||||
);
|
||||
}
|
||||
|
||||
function syncComputeCenterUnresolvedCount(unresolvedCount) {
|
||||
@@ -2773,12 +2870,12 @@ function prepareToolInfoCard(type) {
|
||||
|
||||
if (hadCruisePresentation || restoreCruise) {
|
||||
stopCruiseMode({ preserveCard: false });
|
||||
showStatusMessage("已暂停巡航,正在打开候选列表", "info");
|
||||
showStatusMessage(earthMessage("status.cruisePausedOpenCandidates"), "info");
|
||||
} else if (hadMotionPresentation) {
|
||||
presentationController?.dismiss?.("tool_card_open");
|
||||
motionCruiseSequencer?.stop?.();
|
||||
motionSharedCruiseSequencer?.stop?.();
|
||||
showStatusMessage("已暂停动捕目标展示,正在打开候选列表", "info");
|
||||
showStatusMessage(earthMessage("status.motionPausedOpenCandidates"), "info");
|
||||
}
|
||||
|
||||
activeToolInfoCard = {
|
||||
@@ -2809,7 +2906,7 @@ function resumeCruiseAfterToolInfoCard(context) {
|
||||
return;
|
||||
}
|
||||
|
||||
showStatusMessage("候选列表已关闭,巡航已恢复", "info");
|
||||
showStatusMessage(earthMessage("status.candidatesClosedCruiseResumed"), "info");
|
||||
ensureCruisePolling();
|
||||
syncCruiseModuleKnownEventIds()
|
||||
.catch((error) => {
|
||||
@@ -3522,12 +3619,12 @@ function handleCruiseQueueSettingsChange() {
|
||||
|
||||
function handleCruiseNextCardShortcut() {
|
||||
if (!isCruiseModeActive()) {
|
||||
showStatusMessage("切换到巡航模式后可切换卡片", "info");
|
||||
showStatusMessage(earthMessage("status.cruiseModeRequired"), "info");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!getAutoRotate()) {
|
||||
showStatusMessage("巡航已暂停,按空格恢复", "info");
|
||||
showStatusMessage(earthMessage("status.cruisePausedSpace"), "info");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3770,12 +3867,7 @@ function getSatellitePointerIntersections(event) {
|
||||
|
||||
function buildLoadErrorMessage(errors) {
|
||||
if (errors.length === 0) return "";
|
||||
return errors
|
||||
.map(
|
||||
({ label, reason }) =>
|
||||
`${label}加载失败: ${reason?.message || String(reason)}`,
|
||||
)
|
||||
.join(";");
|
||||
return earthMessage("status.loadFailedList", { items: errors });
|
||||
}
|
||||
|
||||
function updateSatelliteToggleUi(enabled, satelliteCount = getSatelliteCount()) {
|
||||
@@ -4128,15 +4220,30 @@ export function init() {
|
||||
destroyed = false;
|
||||
initialized = true;
|
||||
updateHudScale();
|
||||
const brandRoot = document.getElementById("brand-root");
|
||||
mountBrand(brandRoot, HUD_CONFIG.brandLanguage);
|
||||
remountEarthBrand();
|
||||
fetchEarthBrandConfig()
|
||||
.then((brandConfig) => {
|
||||
mountBrand(brandRoot, brandConfig);
|
||||
runtimeBrandConfig = brandConfig;
|
||||
remountEarthBrand();
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn("Earth brand config unavailable, using defaults.", error);
|
||||
});
|
||||
cleanupFns.push(onEarthLocaleChange(() => {
|
||||
remountEarthBrand();
|
||||
updateCableToggleUi(getShowCables());
|
||||
updateSatelliteToggleUi(getShowSatellites());
|
||||
updateComputeCenterHud({
|
||||
totalCount: getComputeCenterCount(),
|
||||
unresolvedCount: getUnresolvedComputeCenters().length,
|
||||
});
|
||||
updateVesselToggleUi(getShowVessels());
|
||||
updateBGPHud({
|
||||
totalCount: getBGPCount(),
|
||||
collectorCount: getBGPCollectorCount(),
|
||||
});
|
||||
updateStatsSummary();
|
||||
}));
|
||||
initTVPanel();
|
||||
initEarthAbout();
|
||||
initEarthOobe();
|
||||
@@ -4280,7 +4387,7 @@ export function applyMotionRotate(axisOrDirection, directionOrIntensity = 1, may
|
||||
up: "向上旋转",
|
||||
down: "向下旋转",
|
||||
}[direction] || "旋转";
|
||||
showStatusMessage(`动捕: ${label}`, "info");
|
||||
showStatusMessage(earthMessage("status.motionPrefix", { text: label }), "info");
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -4309,10 +4416,10 @@ export function applyMotionConfirm() {
|
||||
}
|
||||
if (lockedObject || lockedSatellite) {
|
||||
window.dispatchEvent(new CustomEvent("earth:open-details-tab"));
|
||||
showStatusMessage("动捕: 已确认当前目标", "info");
|
||||
showStatusMessage(earthMessage("status.motionConfirmCurrent"), "info");
|
||||
return true;
|
||||
}
|
||||
showStatusMessage("动捕: 请先选择目标", "info");
|
||||
showStatusMessage(earthMessage("status.motionSelectTargetFirst"), "info");
|
||||
return false;
|
||||
} finally {
|
||||
releaseGate();
|
||||
@@ -4783,7 +4890,7 @@ async function loadData() {
|
||||
clearSatelliteData();
|
||||
clearCountryBoundaryHover();
|
||||
|
||||
setLoadingMessage("正在初始化...");
|
||||
setLoadingMessage(earthMessage("loading.initializing"));
|
||||
setLoading(true);
|
||||
await yieldFrame(18);
|
||||
if (loadToken !== currentLoadToken) { isDataLoading = false; return; }
|
||||
@@ -4860,7 +4967,7 @@ async function loadData() {
|
||||
const terrainMessage = resolveStartupMessage(
|
||||
terrainLayer,
|
||||
"load",
|
||||
"正在渲染地形...",
|
||||
earthMessage("startup.terrain"),
|
||||
);
|
||||
setLoadingMessage(terrainMessage);
|
||||
await yieldFrame(24);
|
||||
@@ -4891,7 +4998,7 @@ async function loadData() {
|
||||
queueStatusMessage(errorMessage, "error");
|
||||
} else {
|
||||
hideError();
|
||||
queueStatusMessage("数据已加载", "success");
|
||||
queueStatusMessage(earthMessage("status.dataLoaded"), "success");
|
||||
}
|
||||
|
||||
applyDeferredLayerVisibilitySettings()
|
||||
@@ -4935,13 +5042,13 @@ export async function setCablesEnabled(
|
||||
clearSelectionAndInfo();
|
||||
disableCables();
|
||||
if (!suppressStatus) {
|
||||
showStatusMessage("线缆已隐藏", "info");
|
||||
showStatusMessage(earthMessage("status.layerVisibility", { layer: "线缆", visible: false }), "info");
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!suppressLoadingUi) {
|
||||
setLoadingMessage("正在加载线缆数据...");
|
||||
setLoadingMessage(earthMessage("loading.cableData"));
|
||||
setLoading(true);
|
||||
hideError();
|
||||
}
|
||||
@@ -4949,19 +5056,20 @@ export async function setCablesEnabled(
|
||||
try {
|
||||
const cableCount = await ensureCablesEnabled();
|
||||
if (!suppressStatus) {
|
||||
showStatusMessage("线缆已显示", "info");
|
||||
showStatusMessage(earthMessage("status.layerVisibility", { layer: "线缆", visible: true }), "info");
|
||||
}
|
||||
return cableCount;
|
||||
} catch (error) {
|
||||
cablesEnabled = false;
|
||||
clearCableData(getEarth());
|
||||
updateCableToggleUi(false);
|
||||
const message = `线缆加载失败: ${error?.message || String(error)}`;
|
||||
const reason = error?.message || String(error);
|
||||
const message = earthMessage("status.layerLoadFailed", { layer: "线缆", error: reason });
|
||||
void reportEarthClientLog({
|
||||
level: "error",
|
||||
category: "layer-toggle",
|
||||
module: "cables",
|
||||
message,
|
||||
message: `线缆加载失败: ${reason}`,
|
||||
detail: error,
|
||||
});
|
||||
if (!suppressLoadingUi) {
|
||||
@@ -4986,7 +5094,7 @@ export async function setCountryBoundariesEnabled(
|
||||
toggleCountryBoundaries(false);
|
||||
clearCountryBoundaryHover();
|
||||
if (!suppressStatus) {
|
||||
showStatusMessage("国界已隐藏", "info");
|
||||
showStatusMessage(earthMessage("status.layerVisibility", { layer: "国界", visible: false }), "info");
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
@@ -5002,18 +5110,19 @@ export async function setCountryBoundariesEnabled(
|
||||
setLegendItems("countryBoundaries", getCountryBoundaryLegendItems());
|
||||
refreshLegend();
|
||||
if (!suppressStatus) {
|
||||
showStatusMessage("国界已显示", "info");
|
||||
showStatusMessage(earthMessage("status.layerVisibility", { layer: "国界", visible: true }), "info");
|
||||
}
|
||||
return countryCount;
|
||||
} catch (error) {
|
||||
toggleCountryBoundaries(false);
|
||||
clearCountryBoundaryHover();
|
||||
const message = `国界加载失败: ${error?.message || String(error)}`;
|
||||
const reason = error?.message || String(error);
|
||||
const message = earthMessage("status.layerLoadFailed", { layer: "国界", error: reason });
|
||||
void reportEarthClientLog({
|
||||
level: "error",
|
||||
category: "layer-toggle",
|
||||
module: "country-boundaries",
|
||||
message,
|
||||
message: `国界加载失败: ${reason}`,
|
||||
detail: error,
|
||||
});
|
||||
if (!suppressStatus) {
|
||||
@@ -5082,7 +5191,7 @@ export async function setHighResTextureEnabled(enabled, { suppressStatus = false
|
||||
}
|
||||
}
|
||||
if (!suppressStatus) {
|
||||
showStatusMessage(enabled ? "高清材质已启用" : "高清材质已隐藏", "info");
|
||||
showStatusMessage(earthMessage("status.layerEnabled", { layer: "高清材质", enabled }), "info");
|
||||
}
|
||||
return enabled;
|
||||
}
|
||||
@@ -5103,7 +5212,7 @@ export async function setAtmosphereCloudsEnabled(enabled, { suppressStatus = fal
|
||||
}
|
||||
}
|
||||
if (!suppressStatus) {
|
||||
showStatusMessage(enabled ? "大气云图已显示" : "大气云图已隐藏", "info");
|
||||
showStatusMessage(earthMessage("status.layerVisibility", { layer: "大气云图", visible: enabled }), "info");
|
||||
}
|
||||
return enabled;
|
||||
}
|
||||
@@ -5128,7 +5237,7 @@ export async function setSatellitesEnabled(
|
||||
}
|
||||
|
||||
if (!suppressLoadingUi) {
|
||||
setLoadingMessage("正在加载卫星数据...");
|
||||
setLoadingMessage(earthMessage("loading.satelliteData"));
|
||||
setLoading(true);
|
||||
hideError();
|
||||
}
|
||||
@@ -5136,19 +5245,20 @@ export async function setSatellitesEnabled(
|
||||
try {
|
||||
const satelliteCount = await ensureSatellitesEnabled();
|
||||
if (!suppressStatus) {
|
||||
showStatusMessage("卫星已显示", "info");
|
||||
showStatusMessage(earthMessage("status.layerVisibility", { layer: "卫星", visible: true }), "info");
|
||||
}
|
||||
return satelliteCount;
|
||||
} catch (error) {
|
||||
satellitesEnabled = false;
|
||||
resetSatelliteState();
|
||||
updateSatelliteToggleUi(false, 0);
|
||||
const message = `卫星加载失败: ${error?.message || String(error)}`;
|
||||
const reason = error?.message || String(error);
|
||||
const message = earthMessage("status.layerLoadFailed", { layer: "卫星", error: reason });
|
||||
void reportEarthClientLog({
|
||||
level: "error",
|
||||
category: "layer-toggle",
|
||||
module: "satellites",
|
||||
message,
|
||||
message: `卫星加载失败: ${reason}`,
|
||||
detail: error,
|
||||
});
|
||||
if (!suppressLoadingUi) {
|
||||
@@ -5178,13 +5288,13 @@ export async function setVesselsEnabled(
|
||||
clearSelectionAndInfo();
|
||||
disableVessels();
|
||||
if (!suppressStatus) {
|
||||
showStatusMessage("船只已隐藏", "info");
|
||||
showStatusMessage(earthMessage("status.layerVisibility", { layer: "船只", visible: false }), "info");
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!suppressLoadingUi) {
|
||||
setLoadingMessage("正在加载船只数据...");
|
||||
setLoadingMessage(earthMessage("loading.vesselData"));
|
||||
setLoading(true);
|
||||
hideError();
|
||||
}
|
||||
@@ -5192,19 +5302,20 @@ export async function setVesselsEnabled(
|
||||
try {
|
||||
const vesselCount = await ensureVesselsEnabled();
|
||||
if (!suppressStatus) {
|
||||
showStatusMessage("船只已显示", "info");
|
||||
showStatusMessage(earthMessage("status.layerVisibility", { layer: "船只", visible: true }), "info");
|
||||
}
|
||||
return vesselCount;
|
||||
} catch (error) {
|
||||
vesselsEnabled = false;
|
||||
clearVesselData(getEarth());
|
||||
updateVesselToggleUi(false, 0);
|
||||
const message = `船只加载失败: ${error?.message || String(error)}`;
|
||||
const reason = error?.message || String(error);
|
||||
const message = earthMessage("status.layerLoadFailed", { layer: "船只", error: reason });
|
||||
void reportEarthClientLog({
|
||||
level: "error",
|
||||
category: "layer-toggle",
|
||||
module: "vessels",
|
||||
message,
|
||||
message: `船只加载失败: ${reason}`,
|
||||
detail: error,
|
||||
});
|
||||
if (!suppressLoadingUi) {
|
||||
@@ -5258,13 +5369,13 @@ function setupEventListeners() {
|
||||
// for the user-visible confirmation.
|
||||
return refreshComputeCentersAfterLocationSave()
|
||||
.then(() => {
|
||||
showStatusMessage("算力中心坐标已保存", "success");
|
||||
showStatusMessage(earthMessage("status.computeCoordinatesSaved"), "success");
|
||||
})
|
||||
.catch((error) => {
|
||||
// Swallow refresh failure: the save itself succeeded, so we
|
||||
// must not surface this as a save failure.
|
||||
console.warn("后台校准算力中心图层失败:", error);
|
||||
showStatusMessage("坐标已保存,地图稍后同步", "info");
|
||||
showStatusMessage(earthMessage("status.coordinatesSavedLater"), "info");
|
||||
});
|
||||
}
|
||||
// Optimistic marker is on screen; reconcile in the background.
|
||||
@@ -5275,10 +5386,10 @@ function setupEventListeners() {
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn("即时生成算力中心交互物件失败,改用后台刷新:", error);
|
||||
showStatusMessage("坐标已保存,正在同步地图...", "info");
|
||||
showStatusMessage(earthMessage("status.coordinatesSavedSyncing"), "info");
|
||||
refreshComputeCentersAfterLocationSave()
|
||||
.then(() => {
|
||||
showStatusMessage("算力中心坐标已保存", "success");
|
||||
showStatusMessage(earthMessage("status.computeCoordinatesSaved"), "success");
|
||||
})
|
||||
.catch((refreshError) => {
|
||||
console.warn("后台校准算力中心图层失败:", refreshError);
|
||||
@@ -5945,7 +6056,11 @@ function onClick(event) {
|
||||
const incidentSummary = getBGPInfrastructureSummary(clickedMarker);
|
||||
showBGPInfo(clickedMarker, { x: event.clientX, y: event.clientY });
|
||||
showStatusMessage(
|
||||
`已选择BGP事件: ${clickedMarker.userData.collector} · ${incidentSummary.regionCount}个区域 / ${incidentSummary.cableCount}条相关海缆`,
|
||||
earthMessage("status.bgpSelected", {
|
||||
collector: clickedMarker.userData.collector,
|
||||
regionCount: incidentSummary.regionCount,
|
||||
cableCount: incidentSummary.cableCount,
|
||||
}),
|
||||
"info",
|
||||
);
|
||||
return;
|
||||
@@ -5969,7 +6084,7 @@ function onClick(event) {
|
||||
clickedMarker.userData.related_satellite_count = 0;
|
||||
showBGPCollectorInfo(clickedMarker, { x: event.clientX, y: event.clientY });
|
||||
showStatusMessage(
|
||||
`已选择观测站: ${clickedMarker.userData.collector}`,
|
||||
earthMessage("status.selected", { target: "观测站", name: clickedMarker.userData.collector }),
|
||||
"info",
|
||||
);
|
||||
return;
|
||||
@@ -5986,7 +6101,10 @@ function onClick(event) {
|
||||
setAutoRotate(false);
|
||||
showComputeCenterInfo(clickedMarker, { x: event.clientX, y: event.clientY });
|
||||
showStatusMessage(
|
||||
`已选择算力中心: ${clickedMarker.userData?.name || "未知节点"}`,
|
||||
earthMessage("status.selected", {
|
||||
target: "算力中心",
|
||||
name: clickedMarker.userData?.name || "未知节点",
|
||||
}),
|
||||
"info",
|
||||
);
|
||||
return;
|
||||
@@ -6006,7 +6124,10 @@ function onClick(event) {
|
||||
console.warn("船只轨迹加载失败:", error);
|
||||
});
|
||||
showStatusMessage(
|
||||
`已选择船只: ${clickedMarker.userData?.name || clickedMarker.userData?.mmsi}`,
|
||||
earthMessage("status.selected", {
|
||||
target: "船只",
|
||||
name: clickedMarker.userData?.name || clickedMarker.userData?.mmsi,
|
||||
}),
|
||||
"info",
|
||||
);
|
||||
return;
|
||||
@@ -6021,7 +6142,10 @@ function onClick(event) {
|
||||
distancePxSq: 0,
|
||||
}, { showMotionStatus: false })) {
|
||||
showStatusMessage(
|
||||
`已选择交互点: ${clickedMarker.userData?.label || clickedMarker.userData?.name || clickedMarker.userData?.id || "未知目标"}`,
|
||||
earthMessage("status.selected", {
|
||||
target: "交互点",
|
||||
name: clickedMarker.userData?.label || clickedMarker.userData?.name || clickedMarker.userData?.id || "未知目标",
|
||||
}),
|
||||
"info",
|
||||
);
|
||||
}
|
||||
@@ -6079,7 +6203,7 @@ function onClick(event) {
|
||||
screen: { x: event.clientX, y: event.clientY },
|
||||
distancePxSq: 0,
|
||||
}, { showMotionStatus: false });
|
||||
showStatusMessage("已选择: " + sat.properties.name, "info");
|
||||
showStatusMessage(earthMessage("status.selected", { name: sat.properties.name }), "info");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import * as THREE from "three";
|
||||
import { CONFIG, CONNECTOR_CONFIG, CRUISE_CONFIG } from "./constants.js";
|
||||
import { showInfoCard, hideInfoCard } from "./info-card.js";
|
||||
import { latLonToVector3 } from "./utils.js";
|
||||
import { formatLocaleDateTime, getEarthLocale, hasCjkText } from "./i18n.js";
|
||||
import {
|
||||
createConnectorPath,
|
||||
resolveConnectorAnchor,
|
||||
@@ -20,8 +21,9 @@ import {
|
||||
getNewsFetchChannelLabel,
|
||||
getNewsFeedLabel,
|
||||
getNewsLocationSourceLabel,
|
||||
getNewsRegionLabel,
|
||||
getNewsRegionDisplayLabel,
|
||||
getNewsSourceTypeLabel,
|
||||
getNewsSourceNameLabel,
|
||||
} from "./news-locale.js";
|
||||
|
||||
const CRUISE_PRESENTATION_HIDE_MS = 220;
|
||||
@@ -38,10 +40,10 @@ function getItemTimestamp(item) {
|
||||
}
|
||||
|
||||
function formatPublishedAt(rawValue) {
|
||||
if (!rawValue) return "刚刚同步";
|
||||
if (!rawValue) return getEarthLocale() === "en-US" ? "Just synced" : "刚刚同步";
|
||||
const parsed = new Date(rawValue);
|
||||
if (Number.isNaN(parsed.getTime())) return "刚刚同步";
|
||||
return parsed.toLocaleString("zh-CN", {
|
||||
if (Number.isNaN(parsed.getTime())) return getEarthLocale() === "en-US" ? "Just synced" : "刚刚同步";
|
||||
return formatLocaleDateTime(parsed, {
|
||||
hour12: false,
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
@@ -94,6 +96,10 @@ function mapNewsItemToCruiseEvent(item) {
|
||||
|
||||
const rawFeedName = item.feed_name || "";
|
||||
const sourceType = item.source_type || (String(rawFeedName).startsWith("Global Monitor /") ? "aggregated" : "rss");
|
||||
const regionLabel = getNewsRegionDisplayLabel(item.region, item.display_region);
|
||||
const locationLabel = getEarthLocale() === "en-US" && hasCjkText(item.location_label)
|
||||
? regionLabel
|
||||
: item.location_label || regionLabel;
|
||||
|
||||
return {
|
||||
id: `news:${item.id}`,
|
||||
@@ -101,21 +107,21 @@ function mapNewsItemToCruiseEvent(item) {
|
||||
type: "news",
|
||||
title: getNewsDisplayTitle(item),
|
||||
summary: getNewsDisplaySummary(item),
|
||||
source: item.source || "",
|
||||
source: getNewsSourceNameLabel({ id: item.source_id, name: item.source || "" }),
|
||||
feedName: getNewsFeedLabel(rawFeedName),
|
||||
rawFeedName,
|
||||
feedSourceTypeLabel: getNewsSourceTypeLabel(sourceType),
|
||||
fetchChannelLabel: getNewsFetchChannelLabel(rawFeedName, sourceType),
|
||||
categoryLabel: getNewsCategoryLabel(item.category),
|
||||
region: item.region || "global",
|
||||
regionLabel: item.display_region || getNewsRegionLabel(item.region),
|
||||
regionLabel,
|
||||
url: item.url || "",
|
||||
publishedAt: item.published_at || null,
|
||||
publishedAtDisplay: formatPublishedAt(item.published_at),
|
||||
latitude,
|
||||
longitude,
|
||||
locationLabel: item.location_label || item.display_region || getNewsRegionLabel(item.region),
|
||||
sourceLocationLabel: item.location_label || item.display_region || getNewsRegionLabel(item.region),
|
||||
locationLabel,
|
||||
sourceLocationLabel: locationLabel,
|
||||
targetLocationConfidence: item.location_meta?.target?.confidence ?? null,
|
||||
targetLocationSource: item.location_source || "",
|
||||
targetLocationSourceLabel: getNewsLocationSourceLabel(item.location_source),
|
||||
|
||||
@@ -1,22 +1,41 @@
|
||||
const DEFAULT_LOCALE = "zh-CN";
|
||||
import { getEarthLocale, hasCjkText, normalizeLocale } from "./i18n.js";
|
||||
|
||||
const REGION_LABELS = {
|
||||
"zh-CN": {
|
||||
americas: "美洲",
|
||||
europe: "欧洲",
|
||||
"middle-east-africa": "中东与非洲",
|
||||
"asia-pacific": "亚太",
|
||||
global: "全球",
|
||||
},
|
||||
"en-US": {
|
||||
americas: "Americas",
|
||||
europe: "Europe",
|
||||
"middle-east-africa": "Middle East & Africa",
|
||||
"asia-pacific": "Asia Pacific",
|
||||
global: "Global",
|
||||
},
|
||||
};
|
||||
|
||||
const FEED_LABELS = {
|
||||
"zh-CN": {
|
||||
"Global Monitor / World": "区域监测",
|
||||
"Global Monitor / Americas": "区域监测",
|
||||
"Global Monitor / Europe": "区域监测",
|
||||
"Global Monitor / MEA": "区域监测",
|
||||
"Global Monitor / APAC": "区域监测",
|
||||
},
|
||||
"en-US": {
|
||||
"Global Monitor / World": "Regional Monitor",
|
||||
"Global Monitor / Americas": "Regional Monitor",
|
||||
"Global Monitor / Europe": "Regional Monitor",
|
||||
"Global Monitor / MEA": "Regional Monitor",
|
||||
"Global Monitor / APAC": "Regional Monitor",
|
||||
},
|
||||
};
|
||||
|
||||
const CATEGORY_LABELS = {
|
||||
"zh-CN": {
|
||||
politics: "政治",
|
||||
business: "商业",
|
||||
ecommerce: "电商",
|
||||
@@ -29,35 +48,81 @@ const CATEGORY_LABELS = {
|
||||
society: "社会",
|
||||
culture: "文化",
|
||||
other: "其他",
|
||||
},
|
||||
"en-US": {
|
||||
politics: "Politics",
|
||||
business: "Business",
|
||||
ecommerce: "E-commerce",
|
||||
finance: "Finance",
|
||||
sports: "Sports",
|
||||
technology: "Technology",
|
||||
military: "Military",
|
||||
disaster: "Disaster",
|
||||
energy: "Energy",
|
||||
society: "Society",
|
||||
culture: "Culture",
|
||||
other: "Other",
|
||||
},
|
||||
};
|
||||
|
||||
const BREAKING_LEVEL_LABELS = {
|
||||
"zh-CN": {
|
||||
watch: "关注",
|
||||
breaking: "突发",
|
||||
critical: "严重突发",
|
||||
},
|
||||
"en-US": {
|
||||
watch: "Watch",
|
||||
breaking: "Breaking",
|
||||
critical: "Critical",
|
||||
},
|
||||
};
|
||||
|
||||
const BREAKING_SCOPE_LABELS = {
|
||||
"zh-CN": {
|
||||
regional: "区域",
|
||||
global: "全球",
|
||||
},
|
||||
"en-US": {
|
||||
regional: "Regional",
|
||||
global: "Global",
|
||||
},
|
||||
};
|
||||
|
||||
const SOURCE_TYPE_LABELS = {
|
||||
"zh-CN": {
|
||||
rss: "RSS",
|
||||
atom: "Atom",
|
||||
aggregated: "Aggregated",
|
||||
manual: "手动添加",
|
||||
reference: "Reference",
|
||||
},
|
||||
"en-US": {
|
||||
rss: "RSS",
|
||||
atom: "Atom",
|
||||
aggregated: "Aggregated",
|
||||
manual: "Manual",
|
||||
reference: "Reference",
|
||||
},
|
||||
};
|
||||
|
||||
const LOCATION_SOURCE_LABELS = {
|
||||
"zh-CN": {
|
||||
region_anchor: "区域锚点",
|
||||
ai_inferred_target: "AI 推断位置",
|
||||
headline_location_hint: "标题位置线索",
|
||||
headline_country_hint: "标题国家线索",
|
||||
},
|
||||
"en-US": {
|
||||
region_anchor: "Region Anchor",
|
||||
ai_inferred_target: "AI-inferred Location",
|
||||
headline_location_hint: "Headline Location Hint",
|
||||
headline_country_hint: "Headline Country Hint",
|
||||
},
|
||||
};
|
||||
|
||||
const ENRICHMENT_STATUS_LABELS = {
|
||||
"zh-CN": {
|
||||
pending: "待增强",
|
||||
queued: "增强排队中",
|
||||
attempted: "增强中",
|
||||
@@ -68,9 +133,59 @@ const ENRICHMENT_STATUS_LABELS = {
|
||||
provider_error: "增强失败",
|
||||
parse_error: "增强解析失败",
|
||||
no_result: "暂无增强结果",
|
||||
},
|
||||
"en-US": {
|
||||
pending: "Pending",
|
||||
queued: "Queued",
|
||||
attempted: "Enhancing",
|
||||
success: "Localized",
|
||||
content_only: "Localized",
|
||||
location_only: "Location Enhanced",
|
||||
unavailable: "AI Unconfigured",
|
||||
provider_error: "Enhancement Failed",
|
||||
parse_error: "Parse Failed",
|
||||
no_result: "No Enhancement",
|
||||
},
|
||||
};
|
||||
|
||||
const SOURCE_NAME_LABELS = {
|
||||
"en-US": {
|
||||
"36氪": "36Kr",
|
||||
"亿邦动力": "Ebrun",
|
||||
"商务数据中心": "MOFCOM Data Center",
|
||||
"商务部电商动态": "MOFCOM E-Commerce",
|
||||
"国家统计局数据发布": "National Bureau of Statistics",
|
||||
"电商物流指数": "China E-Commerce Logistics Index",
|
||||
},
|
||||
};
|
||||
|
||||
const SOURCE_ID_LABELS = {
|
||||
"en-US": {
|
||||
"36kr": "36Kr",
|
||||
ebrun: "Ebrun",
|
||||
"mofcom-data": "MOFCOM Data Center",
|
||||
"mofcom-ecommerce": "MOFCOM E-Commerce",
|
||||
"stats-china-online-retail": "National Bureau of Statistics",
|
||||
"china-ecommerce-logistics-index": "China E-Commerce Logistics Index",
|
||||
},
|
||||
};
|
||||
|
||||
const FEED_NAME_LABELS = {
|
||||
"en-US": {
|
||||
"综合资讯": "General",
|
||||
"文章资讯": "Articles",
|
||||
"最新快讯": "Newsflash",
|
||||
"动态内容": "Updates",
|
||||
"零售": "Retail",
|
||||
"服务": "Services",
|
||||
"数据": "Data",
|
||||
"政策": "Policy",
|
||||
"数据发布": "Data Releases",
|
||||
},
|
||||
};
|
||||
|
||||
const TITLE_PLACEHOLDERS = {
|
||||
"zh-CN": {
|
||||
queued: "新闻汉化排队中",
|
||||
attempted: "新闻汉化中",
|
||||
provider_error: "新闻汉化失败,正在重试",
|
||||
@@ -78,9 +193,20 @@ const TITLE_PLACEHOLDERS = {
|
||||
unavailable: "等待 AI 配置",
|
||||
no_result: "新闻汉化待重试",
|
||||
location_only: "新闻汉化待重试",
|
||||
},
|
||||
"en-US": {
|
||||
queued: "English translation queued",
|
||||
attempted: "English translation in progress",
|
||||
provider_error: "English translation retrying",
|
||||
parse_error: "English translation retrying",
|
||||
unavailable: "Waiting for AI configuration",
|
||||
no_result: "English translation pending",
|
||||
location_only: "English translation pending",
|
||||
},
|
||||
};
|
||||
|
||||
const SUMMARY_PLACEHOLDERS = {
|
||||
"zh-CN": {
|
||||
queued: "中文概要正在生成,请稍后刷新。",
|
||||
attempted: "中文概要正在生成,请稍后刷新。",
|
||||
provider_error: "中文概要生成失败,系统会重新提交增强任务。",
|
||||
@@ -88,95 +214,201 @@ const SUMMARY_PLACEHOLDERS = {
|
||||
unavailable: "AI 服务配置完成后将生成中文概要。",
|
||||
no_result: "中文概要暂未生成,系统会继续重试。",
|
||||
location_only: "已完成位置增强,中文概要将继续重试。",
|
||||
},
|
||||
"en-US": {
|
||||
queued: "English summary is being generated. Refresh shortly.",
|
||||
attempted: "English summary is being generated. Refresh shortly.",
|
||||
provider_error: "English summary generation failed and will be retried.",
|
||||
parse_error: "English summary parsing failed and will be retried.",
|
||||
unavailable: "English summary will be generated after AI is configured.",
|
||||
no_result: "English summary is pending and will be retried.",
|
||||
location_only: "Location is ready; English summary is still pending.",
|
||||
},
|
||||
};
|
||||
|
||||
function normalizeText(value) {
|
||||
return String(value ?? "").replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function getLocalization(item, locale = DEFAULT_LOCALE) {
|
||||
function getLocale(locale = getEarthLocale()) {
|
||||
return normalizeLocale(locale);
|
||||
}
|
||||
|
||||
function getLabels(table, locale = getEarthLocale()) {
|
||||
const normalizedLocale = getLocale(locale);
|
||||
return table[normalizedLocale] || table["zh-CN"] || {};
|
||||
}
|
||||
|
||||
function isEnglishLocale(locale = getEarthLocale()) {
|
||||
return getLocale(locale) === "en-US";
|
||||
}
|
||||
|
||||
function isEnglishContent(item) {
|
||||
const language = String(item?.content_language || "").toLowerCase();
|
||||
return language === "en" || language.startsWith("en-") || language.startsWith("en_");
|
||||
}
|
||||
|
||||
function getPlaceholder(table, status, locale = getEarthLocale(), fallbackKey = "no_result") {
|
||||
const labels = getLabels(table, locale);
|
||||
return labels[status] || labels[fallbackKey] || "";
|
||||
}
|
||||
|
||||
function safeEnglishText(value) {
|
||||
const text = normalizeText(value);
|
||||
return text && !hasCjkText(text) ? text : "";
|
||||
}
|
||||
|
||||
function sourceIdFallback(id) {
|
||||
const value = normalizeText(id);
|
||||
if (!value) return "";
|
||||
return value
|
||||
.replace(/[-_]+/g, " ")
|
||||
.replace(/\b\w/g, (letter) => letter.toUpperCase());
|
||||
}
|
||||
|
||||
function getLocalization(item, locale = getEarthLocale()) {
|
||||
const localizations = item?.localizations;
|
||||
const localized = localizations && typeof localizations === "object"
|
||||
? localizations[locale]
|
||||
? localizations[getLocale(locale)]
|
||||
: null;
|
||||
return localized && typeof localized === "object" ? localized : {};
|
||||
}
|
||||
|
||||
export function getNewsDisplayTitle(item, locale = DEFAULT_LOCALE) {
|
||||
export function getNewsDisplayTitle(item, locale = getEarthLocale()) {
|
||||
const normalizedLocale = getLocale(locale);
|
||||
const localized = getLocalization(item, locale).title;
|
||||
if (localized || item?.display_title) {
|
||||
if (normalizedLocale === "zh-CN" && (item?.display_title || localized)) {
|
||||
return normalizeText(item?.display_title || localized);
|
||||
}
|
||||
if (normalizedLocale === "en-US") {
|
||||
return safeEnglishText(localized)
|
||||
|| safeEnglishText(item?.display_title)
|
||||
|| (isEnglishContent(item) ? safeEnglishText(item?.title) : "")
|
||||
|| getPlaceholder(TITLE_PLACEHOLDERS, item?.enrichment_status, locale);
|
||||
}
|
||||
if (localized) {
|
||||
return normalizeText(localized);
|
||||
}
|
||||
if (item?.title) {
|
||||
return normalizeText(item.title);
|
||||
}
|
||||
return normalizeText(
|
||||
TITLE_PLACEHOLDERS[item?.enrichment_status]
|
||||
|| "新闻汉化中",
|
||||
);
|
||||
return normalizeText(getPlaceholder(TITLE_PLACEHOLDERS, item?.enrichment_status, locale, "attempted"));
|
||||
}
|
||||
|
||||
export function getNewsDisplaySummary(item, locale = DEFAULT_LOCALE) {
|
||||
export function getNewsDisplaySummary(item, locale = getEarthLocale()) {
|
||||
const normalizedLocale = getLocale(locale);
|
||||
const localized = getLocalization(item, locale).summary;
|
||||
if (localized || item?.display_summary) {
|
||||
if (normalizedLocale === "zh-CN" && (item?.display_summary || localized)) {
|
||||
return normalizeText(item?.display_summary || localized);
|
||||
}
|
||||
if (normalizedLocale === "en-US") {
|
||||
return safeEnglishText(localized)
|
||||
|| safeEnglishText(item?.display_summary)
|
||||
|| (isEnglishContent(item) ? safeEnglishText(item?.summary) : "")
|
||||
|| getPlaceholder(SUMMARY_PLACEHOLDERS, item?.enrichment_status, locale);
|
||||
}
|
||||
if (localized) {
|
||||
return normalizeText(localized);
|
||||
}
|
||||
if (item?.summary) {
|
||||
return normalizeText(item.summary);
|
||||
}
|
||||
return normalizeText(
|
||||
SUMMARY_PLACEHOLDERS[item?.enrichment_status]
|
||||
|| "中文概要生成中,请稍后刷新。",
|
||||
);
|
||||
return normalizeText(getPlaceholder(SUMMARY_PLACEHOLDERS, item?.enrichment_status, locale, "attempted"));
|
||||
}
|
||||
|
||||
export function isNewsContentReady(item, locale = DEFAULT_LOCALE) {
|
||||
export function isNewsContentReady(item, locale = getEarthLocale()) {
|
||||
const localized = getLocalization(item, locale);
|
||||
const title = normalizeText(item?.display_title || localized.title || item?.title);
|
||||
const summary = normalizeText(item?.display_summary || localized.summary || item?.summary);
|
||||
const normalizedLocale = getLocale(locale);
|
||||
if (normalizedLocale === "en-US") {
|
||||
const title = safeEnglishText(localized.title)
|
||||
|| safeEnglishText(item?.display_title)
|
||||
|| (isEnglishContent(item) ? safeEnglishText(item?.title) : "");
|
||||
const summary = safeEnglishText(localized.summary)
|
||||
|| safeEnglishText(item?.display_summary)
|
||||
|| (isEnglishContent(item) ? safeEnglishText(item?.summary) : "");
|
||||
return Boolean(title && summary);
|
||||
}
|
||||
const title = normalizeText(
|
||||
item?.display_title || localized.title || item?.title,
|
||||
);
|
||||
const summary = normalizeText(
|
||||
item?.display_summary || localized.summary || item?.summary,
|
||||
);
|
||||
return Boolean(title && summary);
|
||||
}
|
||||
|
||||
export function getNewsRegionLabel(region, fallback = "") {
|
||||
return REGION_LABELS[region] || fallback || region || REGION_LABELS.global;
|
||||
export function getNewsRegionLabel(region, fallback = "", locale = getEarthLocale()) {
|
||||
const labels = getLabels(REGION_LABELS, locale);
|
||||
return labels[region] || fallback || region || labels.global;
|
||||
}
|
||||
|
||||
export function getNewsFeedLabel(feedName) {
|
||||
return FEED_LABELS[feedName] || feedName || "聚合源";
|
||||
export function getNewsFeedLabel(feedName, locale = getEarthLocale()) {
|
||||
const normalizedLocale = getLocale(locale);
|
||||
const mappedFeed = getLabels(FEED_NAME_LABELS, locale)[feedName];
|
||||
if (mappedFeed) return mappedFeed;
|
||||
if (normalizedLocale === "en-US" && hasCjkText(feedName)) return "News Feed";
|
||||
return getLabels(FEED_LABELS, locale)[feedName]
|
||||
|| feedName
|
||||
|| (normalizedLocale === "en-US" ? "Aggregated Source" : "聚合源");
|
||||
}
|
||||
|
||||
export function getNewsCategoryLabel(category) {
|
||||
return CATEGORY_LABELS[category] || category || CATEGORY_LABELS.other;
|
||||
export function getNewsCategoryLabel(category, locale = getEarthLocale()) {
|
||||
const labels = getLabels(CATEGORY_LABELS, locale);
|
||||
return labels[category] || category || labels.other;
|
||||
}
|
||||
|
||||
export function getNewsBreakingLabel(level, scope = "regional") {
|
||||
export function getNewsBreakingLabel(level, scope = "regional", locale = getEarthLocale()) {
|
||||
const normalizedLevel = normalizeText(level).toLowerCase();
|
||||
if (!normalizedLevel || normalizedLevel === "none") return "";
|
||||
const levelLabel = BREAKING_LEVEL_LABELS[normalizedLevel] || level;
|
||||
const scopeLabel = BREAKING_SCOPE_LABELS[normalizeText(scope).toLowerCase()] || BREAKING_SCOPE_LABELS.regional;
|
||||
return `${scopeLabel}${levelLabel}`;
|
||||
const levelLabels = getLabels(BREAKING_LEVEL_LABELS, locale);
|
||||
const scopeLabels = getLabels(BREAKING_SCOPE_LABELS, locale);
|
||||
const levelLabel = levelLabels[normalizedLevel] || level;
|
||||
const scopeLabel = scopeLabels[normalizeText(scope).toLowerCase()] || scopeLabels.regional;
|
||||
return getLocale(locale) === "en-US" ? `${scopeLabel} ${levelLabel}` : `${scopeLabel}${levelLabel}`;
|
||||
}
|
||||
|
||||
export function getNewsSourceTypeLabel(sourceType) {
|
||||
export function getNewsSourceTypeLabel(sourceType, locale = getEarthLocale()) {
|
||||
const normalized = normalizeText(sourceType).toLowerCase();
|
||||
return SOURCE_TYPE_LABELS[normalized] || sourceType || "RSS";
|
||||
return getLabels(SOURCE_TYPE_LABELS, locale)[normalized] || sourceType || "RSS";
|
||||
}
|
||||
|
||||
export function getNewsSourceNameLabel(sourceOrName, locale = getEarthLocale()) {
|
||||
const normalizedLocale = getLocale(locale);
|
||||
const source = sourceOrName && typeof sourceOrName === "object" ? sourceOrName : null;
|
||||
const name = normalizeText(source ? source.name : sourceOrName);
|
||||
if (normalizedLocale !== "en-US") return name;
|
||||
const id = normalizeText(source?.id || source?.source_id);
|
||||
return getLabels(SOURCE_ID_LABELS, locale)[id]
|
||||
|| getLabels(SOURCE_NAME_LABELS, locale)[name]
|
||||
|| safeEnglishText(name)
|
||||
|| sourceIdFallback(id)
|
||||
|| "News Source";
|
||||
}
|
||||
|
||||
export function getNewsRegionDisplayLabel(region, fallback = "", locale = getEarthLocale()) {
|
||||
return getNewsRegionLabel(region, isEnglishLocale(locale) ? "" : fallback, locale);
|
||||
}
|
||||
|
||||
export function isRegionalMonitorFeed(feedName) {
|
||||
return Object.prototype.hasOwnProperty.call(FEED_LABELS, feedName);
|
||||
return Object.values(FEED_LABELS).some((labels) =>
|
||||
Object.prototype.hasOwnProperty.call(labels, feedName),
|
||||
);
|
||||
}
|
||||
|
||||
export function getNewsFetchChannelLabel(feedName, sourceType = "") {
|
||||
const normalized = normalizeText(sourceType).toLowerCase();
|
||||
if (isRegionalMonitorFeed(feedName) || normalized === "aggregated") return "区域监测";
|
||||
if (normalized === "manual") return "手动添加";
|
||||
if (normalized === "atom") return "单源 Atom";
|
||||
if (normalized === "reference") return "配置保留";
|
||||
return "单源 RSS";
|
||||
const english = getEarthLocale() === "en-US";
|
||||
if (isRegionalMonitorFeed(feedName) || normalized === "aggregated") return english ? "Regional Monitor" : "区域监测";
|
||||
if (normalized === "manual") return english ? "Manual" : "手动添加";
|
||||
if (normalized === "atom") return english ? "Single-source Atom" : "单源 Atom";
|
||||
if (normalized === "reference") return english ? "Reserved" : "配置保留";
|
||||
return english ? "Single-source RSS" : "单源 RSS";
|
||||
}
|
||||
|
||||
export function getNewsLocationSourceLabel(source) {
|
||||
return LOCATION_SOURCE_LABELS[source] || source || "位置来源";
|
||||
export function getNewsLocationSourceLabel(source, locale = getEarthLocale()) {
|
||||
return getLabels(LOCATION_SOURCE_LABELS, locale)[source]
|
||||
|| source
|
||||
|| (getLocale(locale) === "en-US" ? "Location Source" : "位置来源");
|
||||
}
|
||||
|
||||
export function getNewsEnrichmentStatusLabel(statusOrItem) {
|
||||
@@ -184,9 +416,11 @@ export function getNewsEnrichmentStatusLabel(statusOrItem) {
|
||||
const status = item ? item.enrichment_status : statusOrItem;
|
||||
const language = String(item?.content_language || "").toLowerCase();
|
||||
if (language.startsWith("zh") && status !== "success" && status !== "content_only") {
|
||||
if (status === "queued" || status === "attempted") return "英文补译中";
|
||||
if (status === "provider_error" || status === "parse_error" || status === "no_result") return "中文原文";
|
||||
return "中文原文";
|
||||
if (status === "queued" || status === "attempted") return getEarthLocale() === "en-US" ? "English Translation Pending" : "英文补译中";
|
||||
if (status === "provider_error" || status === "parse_error" || status === "no_result") return getEarthLocale() === "en-US" ? "Chinese Original" : "中文原文";
|
||||
return getEarthLocale() === "en-US" ? "Chinese Original" : "中文原文";
|
||||
}
|
||||
return ENRICHMENT_STATUS_LABELS[status] || status || "增强状态";
|
||||
return getLabels(ENRICHMENT_STATUS_LABELS)[status]
|
||||
|| status
|
||||
|| (getEarthLocale() === "en-US" ? "Enhancement Status" : "增强状态");
|
||||
}
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { showStatusMessage } from "./ui.js";
|
||||
import {
|
||||
applyEarthI18n,
|
||||
earthMessage,
|
||||
getEarthLocale,
|
||||
onEarthLocaleChange,
|
||||
translateText,
|
||||
} from "./i18n.js";
|
||||
import {
|
||||
getNewsDisplaySummary,
|
||||
getNewsDisplayTitle,
|
||||
@@ -6,8 +13,11 @@ import {
|
||||
getNewsBreakingLabel,
|
||||
getNewsEnrichmentStatusLabel,
|
||||
getNewsFetchChannelLabel,
|
||||
getNewsFeedLabel,
|
||||
getNewsRegionLabel,
|
||||
getNewsRegionDisplayLabel,
|
||||
getNewsSourceTypeLabel,
|
||||
getNewsSourceNameLabel,
|
||||
isNewsContentReady,
|
||||
} from "./news-locale.js";
|
||||
import {
|
||||
@@ -129,17 +139,18 @@ function formatCoord(value, positiveLabel, negativeLabel) {
|
||||
}
|
||||
|
||||
function formatRelativeTime(raw) {
|
||||
if (!raw) return "刚刚同步";
|
||||
const english = getEarthLocale() === "en-US";
|
||||
if (!raw) return english ? "Just synced" : "刚刚同步";
|
||||
const date = new Date(raw);
|
||||
if (Number.isNaN(date.getTime())) return "刚刚同步";
|
||||
if (Number.isNaN(date.getTime())) return english ? "Just synced" : "刚刚同步";
|
||||
|
||||
const diff = Date.now() - date.getTime();
|
||||
const minutes = Math.max(1, Math.round(diff / 60000));
|
||||
if (minutes < 60) return `${minutes} 分钟前`;
|
||||
if (minutes < 60) return english ? `${minutes}m ago` : `${minutes} 分钟前`;
|
||||
const hours = Math.round(minutes / 60);
|
||||
if (hours < 24) return `${hours} 小时前`;
|
||||
if (hours < 24) return english ? `${hours}h ago` : `${hours} 小时前`;
|
||||
const days = Math.round(hours / 24);
|
||||
return `${days} 天前`;
|
||||
return english ? `${days}d ago` : `${days} 天前`;
|
||||
}
|
||||
|
||||
export function updateNewsToggleUI(visible) {
|
||||
@@ -371,7 +382,7 @@ function escapeNewsHtml(value) {
|
||||
|
||||
function getDisplayableNewsItems(items) {
|
||||
return Array.isArray(items)
|
||||
? items.filter(isNewsContentReady)
|
||||
? items.filter((item) => isNewsContentReady(item))
|
||||
: [];
|
||||
}
|
||||
|
||||
@@ -430,25 +441,30 @@ function normalizeNewsSourceType(value) {
|
||||
|
||||
function getNewsSourceDescriptor(item, sourcesByName, sourcesById) {
|
||||
const feedName = String(item?.feed_name || "").trim();
|
||||
const sourceName = String(item?.source || feedName || "NEWS").trim();
|
||||
const rawSourceName = String(item?.source || feedName || "NEWS").trim();
|
||||
const sourceId = String(item?.source_id || "").trim();
|
||||
const sourceConfig = sourcesById.get(sourceId) || sourcesByName.get(feedName) || null;
|
||||
const sourceType = normalizeNewsSourceType(item?.source_type || sourceConfig?.source_type)
|
||||
|| (feedName.startsWith("Global Monitor /") ? "aggregated" : "rss");
|
||||
const sourceTypeLabel = getNewsSourceTypeLabel(sourceType);
|
||||
const channelLabel = getNewsFetchChannelLabel(feedName, sourceType);
|
||||
const sourceGroupName = String(sourceConfig?.name || "").trim();
|
||||
const sourceName = getNewsSourceNameLabel(
|
||||
sourceConfig || { id: sourceId, name: rawSourceName },
|
||||
);
|
||||
const feedLabel = getNewsFetchChannelLabel(feedName, sourceType);
|
||||
const sourceGroupName = sourceConfig ? getNewsSourceNameLabel(sourceConfig) : "";
|
||||
const originLabel = sourceGroupName
|
||||
? `${sourceGroupName} · ${channelLabel}`
|
||||
: feedName && feedName !== sourceName
|
||||
? `${feedName} · ${sourceTypeLabel}`
|
||||
? `${feedLabel} · ${sourceTypeLabel}`
|
||||
: `${channelLabel} · ${sourceTypeLabel}`;
|
||||
const english = getEarthLocale() === "en-US";
|
||||
const tooltip = [
|
||||
`媒体来源:${sourceName}`,
|
||||
sourceGroupName ? `来源组:${sourceGroupName}` : "",
|
||||
feedName ? `RSS 来源:${feedName}` : "",
|
||||
`源类型:${sourceTypeLabel}`,
|
||||
`抓取通道:${channelLabel}`,
|
||||
`${english ? "Media source" : "媒体来源"}: ${sourceName}`,
|
||||
sourceGroupName ? `${english ? "Source group" : "来源组"}: ${sourceGroupName}` : "",
|
||||
feedName ? `${english ? "RSS feed" : "RSS 来源"}: ${getNewsFeedNameForTooltip(feedName)}` : "",
|
||||
`${english ? "Source type" : "源类型"}: ${sourceTypeLabel}`,
|
||||
`${english ? "Fetch channel" : "抓取通道"}: ${channelLabel}`,
|
||||
].filter(Boolean).join("\n");
|
||||
return {
|
||||
sourceName,
|
||||
@@ -461,6 +477,43 @@ function getNewsSourceDescriptor(item, sourcesByName, sourcesById) {
|
||||
};
|
||||
}
|
||||
|
||||
function getNewsFeedNameForTooltip(feedName) {
|
||||
return getEarthLocale() === "en-US"
|
||||
? getNewsFeedLabel(feedName)
|
||||
: feedName;
|
||||
}
|
||||
|
||||
function getFocusRegionText(focus = {}) {
|
||||
return getNewsRegionDisplayLabel(focus.region, focus.display_region);
|
||||
}
|
||||
|
||||
function getFocusLabelText(focus = {}) {
|
||||
if (getEarthLocale() === "en-US") {
|
||||
return getNewsRegionDisplayLabel(focus.region, focus.label);
|
||||
}
|
||||
return focus.label || "全球焦点";
|
||||
}
|
||||
|
||||
function formatNewsSourceCount(enabledCount, totalCount) {
|
||||
if (getEarthLocale() === "en-US") return `${enabledCount || totalCount} / ${totalCount} sources`;
|
||||
return `${enabledCount || totalCount} / ${totalCount} 路来源`;
|
||||
}
|
||||
|
||||
function formatNewsStatus({ displayCount, totalCount, stale, hasErrors }) {
|
||||
if (getEarthLocale() === "en-US") {
|
||||
if (displayCount !== totalCount) return `Showing ${displayCount} / ${totalCount} stories`;
|
||||
if (stale) return `Showing the latest available news cache, ${totalCount} stories`;
|
||||
return hasErrors
|
||||
? `Aggregated ${totalCount} stories; some sources are unavailable`
|
||||
: `Aggregated ${totalCount} situation stories`;
|
||||
}
|
||||
if (displayCount !== totalCount) return `展示 ${displayCount} / ${totalCount} 条态势新闻`;
|
||||
if (stale) return `当前显示最近一次可用新闻缓存,共 ${totalCount} 条`;
|
||||
return hasErrors
|
||||
? `已聚合 ${totalCount} 条,部分源不可用`
|
||||
: `已聚合 ${totalCount} 条态势新闻`;
|
||||
}
|
||||
|
||||
function getEnabledNewsCategoryKeys(filters = activeNewsCategoryFilters) {
|
||||
if (!filters || typeof filters !== "object") return [...NEWS_CATEGORY_KEYS].sort();
|
||||
return Object.entries(filters)
|
||||
@@ -548,10 +601,10 @@ function setNewsSourceFilters(enabledIds, { persist = true } = {}) {
|
||||
}
|
||||
|
||||
function summarizeSelection(enabledCount, totalCount) {
|
||||
if (totalCount <= 0) return "暂无";
|
||||
if (enabledCount <= 0) return "未选";
|
||||
if (enabledCount === totalCount) return "全部";
|
||||
return `${enabledCount} 项`;
|
||||
if (totalCount <= 0) return translateText("暂无");
|
||||
if (enabledCount <= 0) return translateText("未选");
|
||||
if (enabledCount === totalCount) return translateText("全部");
|
||||
return translateText(`${enabledCount} 项`);
|
||||
}
|
||||
|
||||
function syncFilterSummaries(nextPayload = payload) {
|
||||
@@ -567,10 +620,12 @@ function syncFilterSummaries(nextPayload = payload) {
|
||||
});
|
||||
document.querySelectorAll('[data-news-filter-summary="limit"]').forEach((el) => {
|
||||
const total = Array.isArray(nextPayload?.items) ? nextPayload.items.length : 0;
|
||||
el.textContent = newsFullListMode ? "全部" : `${Math.min(NEWS_SUMMARY_LIMIT, total || NEWS_SUMMARY_LIMIT)} 条`;
|
||||
el.textContent = newsFullListMode
|
||||
? translateText("全部")
|
||||
: translateText(`${Math.min(NEWS_SUMMARY_LIMIT, total || NEWS_SUMMARY_LIMIT)} 条`);
|
||||
});
|
||||
document.querySelectorAll("[data-news-view-mode-label]").forEach((el) => {
|
||||
el.textContent = newsFullListMode ? "返回摘要" : "查看全部";
|
||||
el.textContent = translateText(newsFullListMode ? "返回摘要" : "查看全部");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -601,7 +656,9 @@ function renderCategoryFilterChips() {
|
||||
function renderSourceFilterChips() {
|
||||
const sources = Array.isArray(payload?.sources) ? payload.sources : [];
|
||||
const enabled = new Set(getEnabledNewsSourceIds());
|
||||
if (!sources.length) return `<span class="news-filter-popover__hint">暂无可筛选来源。</span>`;
|
||||
if (!sources.length) {
|
||||
return `<span class="news-filter-popover__hint">${escapeNewsHtml(translateText("暂无可筛选来源。"))}</span>`;
|
||||
}
|
||||
return sources
|
||||
.map((source) => {
|
||||
const id = String(source?.id || "").trim();
|
||||
@@ -613,15 +670,15 @@ function renderSourceFilterChips() {
|
||||
type="button"
|
||||
data-news-source-toggle="${escapeNewsHtml(id)}"
|
||||
aria-pressed="${active ? "true" : "false"}"
|
||||
>${escapeNewsHtml(source?.name || id)}</button>
|
||||
>${escapeNewsHtml(getNewsSourceNameLabel(source || { id }))}</button>
|
||||
`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
function renderFilterPopover(kind) {
|
||||
const title = kind === "source" ? "新闻来源" : "新闻类型";
|
||||
const hint = kind === "source" ? "按大来源筛选,不影响后台抓取。" : "按新闻内容分类筛选。";
|
||||
const title = translateText(kind === "source" ? "新闻来源" : "新闻类型");
|
||||
const hint = translateText(kind === "source" ? "按大来源筛选,不影响后台抓取。" : "按新闻内容分类筛选。");
|
||||
const content = kind === "source" ? renderSourceFilterChips() : renderCategoryFilterChips();
|
||||
|
||||
activeFilterPopover = kind;
|
||||
@@ -675,19 +732,19 @@ function renderTicker(nextPayload) {
|
||||
const focus = nextPayload?.focus || {};
|
||||
|
||||
if (tickerRegion instanceof HTMLElement) {
|
||||
tickerRegion.textContent = focus.display_region || getNewsRegionLabel(focus.region);
|
||||
tickerRegion.textContent = getFocusRegionText(focus);
|
||||
tickerRegion.style.color = focus.accent || "";
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
tickerTrack.textContent = "正在准备全球态势新闻...";
|
||||
tickerTrack.textContent = translateText("正在准备全球态势新闻...");
|
||||
tickerTrack.style.removeProperty("--news-ticker-duration");
|
||||
return;
|
||||
}
|
||||
|
||||
const visibleItems = getDisplayableNewsItems(items).slice(0, 6);
|
||||
if (visibleItems.length === 0) {
|
||||
tickerTrack.textContent = "当前新闻类型没有可显示新闻...";
|
||||
tickerTrack.textContent = translateText("当前新闻类型没有可显示新闻...");
|
||||
tickerTrack.style.removeProperty("--news-ticker-duration");
|
||||
return;
|
||||
}
|
||||
@@ -695,7 +752,10 @@ function renderTicker(nextPayload) {
|
||||
tickerTrack.innerHTML = tickerItems
|
||||
.map((item) => `
|
||||
<span class="earth-news-ticker__item" data-news-id="${escapeTickerText(item.id || "")}">
|
||||
<span class="earth-news-ticker__source">${escapeTickerText(item.source || item.feed_name || "NEWS")}</span>
|
||||
<span class="earth-news-ticker__source">${escapeTickerText(getNewsSourceNameLabel({
|
||||
id: item.source_id,
|
||||
name: item.source || item.feed_name || "NEWS",
|
||||
}))}</span>
|
||||
<span>${escapeTickerText(getNewsDisplaySummary(item))}</span>
|
||||
</span>
|
||||
`)
|
||||
@@ -711,7 +771,7 @@ function renderEmptyState(message) {
|
||||
empty.textContent = message;
|
||||
}
|
||||
if (status) {
|
||||
status.textContent = "等待聚合新闻源";
|
||||
status.textContent = translateText("等待聚合新闻源");
|
||||
}
|
||||
if (openBtn) openBtn.disabled = true;
|
||||
renderTicker({ items: [], focus: payload?.focus || { region: "global" } });
|
||||
@@ -762,7 +822,7 @@ function renderPayload(nextPayload) {
|
||||
}
|
||||
|
||||
if (regionChip) {
|
||||
regionChip.textContent = focus.display_region || getNewsRegionLabel(focus.region);
|
||||
regionChip.textContent = getFocusRegionText(focus);
|
||||
regionChip.style.setProperty("--news-accent", focus.accent || "#d6e6ff");
|
||||
}
|
||||
|
||||
@@ -772,25 +832,22 @@ function renderPayload(nextPayload) {
|
||||
return;
|
||||
}
|
||||
|
||||
focusLabel.textContent = focus.label || "全球焦点";
|
||||
focusLabel.textContent = getFocusLabelText(focus);
|
||||
|
||||
if (typeof focus.lat === "number" && typeof focus.lon === "number") {
|
||||
focusCoords.textContent = `${formatCoord(focus.lat, "N", "S")} · ${formatCoord(focus.lon, "E", "W")}`;
|
||||
} else {
|
||||
focusCoords.textContent = "跟随当前视角自动聚焦";
|
||||
focusCoords.textContent = translateText("跟随当前视角自动聚焦");
|
||||
}
|
||||
|
||||
const enabledSourceCount = getEnabledNewsSourceIds(nextPayload).length;
|
||||
sourceCount.textContent = `${enabledSourceCount || sources.length} / ${sources.length} 路来源`;
|
||||
if (displayItems.length !== items.length) {
|
||||
status.textContent = `展示 ${displayItems.length} / ${items.length} 条态势新闻`;
|
||||
} else if (nextPayload?.stale) {
|
||||
status.textContent = `当前显示最近一次可用新闻缓存,共 ${items.length} 条`;
|
||||
} else {
|
||||
status.textContent = nextPayload?.errors?.length
|
||||
? `已聚合 ${items.length} 条,部分源不可用`
|
||||
: `已聚合 ${items.length} 条态势新闻`;
|
||||
}
|
||||
sourceCount.textContent = formatNewsSourceCount(enabledSourceCount, sources.length);
|
||||
status.textContent = formatNewsStatus({
|
||||
displayCount: displayItems.length,
|
||||
totalCount: items.length,
|
||||
stale: Boolean(nextPayload?.stale),
|
||||
hasErrors: Boolean(nextPayload?.errors?.length),
|
||||
});
|
||||
|
||||
if (feedAnchor) {
|
||||
const matchedSource = sources.find((source) => source.region === focus.region) || sources[0];
|
||||
@@ -806,8 +863,8 @@ function renderPayload(nextPayload) {
|
||||
if (empty) {
|
||||
empty.hidden = false;
|
||||
empty.textContent = items.length === 0
|
||||
? "当前未拉到可用新闻,请稍后刷新或切换视角区域。"
|
||||
: "当前新闻类型没有可显示新闻。";
|
||||
? translateText("当前未拉到可用新闻,请稍后刷新或切换视角区域。")
|
||||
: translateText("当前新闻类型没有可显示新闻。");
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -827,7 +884,7 @@ function renderPayload(nextPayload) {
|
||||
const title = getNewsDisplayTitle(item);
|
||||
const summaryText = getNewsDisplaySummary(item);
|
||||
const leadText = summaryText || title;
|
||||
const regionLabel = item.display_region || getNewsRegionLabel(item.region);
|
||||
const regionLabel = getNewsRegionDisplayLabel(item.region, item.display_region);
|
||||
const categoryLabel = getNewsCategoryLabel(item.category);
|
||||
const statusLabel = getNewsEnrichmentStatusLabel(item);
|
||||
const breakingLabel = getNewsBreakingLabel(breakingLevel, breakingScope);
|
||||
@@ -962,17 +1019,24 @@ async function fetchNews(lat, lon, context = {}) {
|
||||
const categorySignature = context.categorySignature ?? getNewsCategorySignature();
|
||||
const sourceSignature = context.sourceSignature ?? getNewsSourceSignatureForFetch(lat, lon);
|
||||
if (categorySignature === "__none__" || sourceSignature === "__none__") {
|
||||
const locale = getEarthLocale();
|
||||
return {
|
||||
...(payload || {}),
|
||||
generated_at: new Date().toISOString(),
|
||||
focus: payload?.focus || { lat, lon, region: "global", label: "全球焦点", display_region: "全球" },
|
||||
focus: payload?.focus || {
|
||||
lat,
|
||||
lon,
|
||||
region: "global",
|
||||
label: translateText("全球焦点"),
|
||||
display_region: getNewsRegionLabel("global"),
|
||||
},
|
||||
sources: payload?.sources || [],
|
||||
filters: {
|
||||
region: payload?.focus?.region || "global",
|
||||
categories: categorySignature === "__none__" ? [] : getEnabledNewsCategoryKeys(),
|
||||
sources: sourceSignature === "__none__" ? [] : getEnabledNewsSourceIds(),
|
||||
limit: getNewsLimit(),
|
||||
locale: "zh-CN",
|
||||
locale,
|
||||
},
|
||||
items: [],
|
||||
cruise_items: [],
|
||||
@@ -986,7 +1050,7 @@ async function fetchNews(lat, lon, context = {}) {
|
||||
if (categorySignature) url.searchParams.set("categories", categorySignature);
|
||||
if (sourceSignature) url.searchParams.set("sources", sourceSignature);
|
||||
url.searchParams.set("limit", String(getNewsLimit()));
|
||||
url.searchParams.set("locale", "zh-CN");
|
||||
url.searchParams.set("locale", getEarthLocale());
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = window.setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
||||
@@ -1011,6 +1075,7 @@ async function refreshNews(lat, lon, { silent = false } = {}) {
|
||||
categorySignature,
|
||||
sourceSignature,
|
||||
getNewsLimit(),
|
||||
getEarthLocale(),
|
||||
].join("|");
|
||||
|
||||
if (refreshPromise && refreshRequestKey === requestKey) return refreshPromise;
|
||||
@@ -1034,9 +1099,10 @@ async function refreshNews(lat, lon, { silent = false } = {}) {
|
||||
if (Array.isArray(nextPayload?.items) && nextPayload.items.length === 0) {
|
||||
const { status } = getElements();
|
||||
if (status) {
|
||||
status.textContent = "当前区域暂无可用新闻,已完成一次聚合尝试";
|
||||
status.textContent = translateText("当前区域暂无可用新闻,已完成一次聚合尝试");
|
||||
}
|
||||
}
|
||||
applyEarthI18n();
|
||||
return nextPayload;
|
||||
})
|
||||
.catch((error) => {
|
||||
@@ -1045,12 +1111,12 @@ async function refreshNews(lat, lon, { silent = false } = {}) {
|
||||
}
|
||||
console.error("加载 Earth RSS 新闻失败:", error);
|
||||
const message = error?.name === "AbortError"
|
||||
? "新闻聚合请求超时,请稍后重试"
|
||||
: `新闻聚合暂时不可用: ${error?.message || "未知错误"}`;
|
||||
? translateText("新闻聚合请求超时,请稍后重试")
|
||||
: `${translateText("新闻聚合暂时不可用")}: ${error?.message || (getEarthLocale() === "en-US" ? "Unknown error" : "未知错误")}`;
|
||||
if (!payload) {
|
||||
renderEmptyState(message);
|
||||
} else if (!silent) {
|
||||
showStatusMessage("态势新闻同步失败", "error");
|
||||
showStatusMessage(earthMessage("status.newsSyncFailed"), "error");
|
||||
}
|
||||
throw error;
|
||||
})
|
||||
@@ -1185,7 +1251,7 @@ export function initNewsPanel() {
|
||||
initialized = true;
|
||||
|
||||
updateNewsToggleUI(true);
|
||||
renderEmptyState("正在准备全球态势新闻聚合源...");
|
||||
renderEmptyState(translateText("正在准备全球态势新闻聚合源..."));
|
||||
|
||||
const { ticker, hudCloseBtn } = getElements();
|
||||
ticker?.addEventListener("click", (event) => {
|
||||
@@ -1252,6 +1318,13 @@ export function initNewsPanel() {
|
||||
lastFetchAt = 0;
|
||||
refreshNews(lastFocus?.lat, lastFocus?.lon, { silent: true }).catch(() => {});
|
||||
});
|
||||
onEarthLocaleChange(() => {
|
||||
syncFilterSummaries();
|
||||
if (activeFilterPopover) renderFilterPopover(activeFilterPopover);
|
||||
lastFetchAt = 0;
|
||||
refreshRequestKey = "";
|
||||
refreshNews(lastFocus?.lat, lastFocus?.lon, { silent: true }).catch(() => {});
|
||||
});
|
||||
setupNewsHudResize();
|
||||
connectNewsRealtime();
|
||||
|
||||
@@ -1260,9 +1333,9 @@ export function initNewsPanel() {
|
||||
refreshBtn?.addEventListener("click", async () => {
|
||||
try {
|
||||
await refreshNews(lastFocus?.lat, lastFocus?.lon);
|
||||
showStatusMessage("态势新闻已刷新", "info");
|
||||
showStatusMessage(earthMessage("status.newsRefresh", { ok: true }), "info");
|
||||
} catch {
|
||||
showStatusMessage("态势新闻刷新失败", "error");
|
||||
showStatusMessage(earthMessage("status.newsRefresh", { ok: false }), "error");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import Hls from "hls.js";
|
||||
import { showStatusMessage } from "./ui.js";
|
||||
import { createHUDPanel } from "./hud-panels.js";
|
||||
import {
|
||||
earthMessage,
|
||||
formatLocaleDateTime,
|
||||
getEarthLocale,
|
||||
hasCjkText,
|
||||
onEarthLocaleChange,
|
||||
translateText,
|
||||
} from "./i18n.js";
|
||||
|
||||
// Naming convention:
|
||||
// - #media-panel is the outer HUD shell, responsible for drag/resize/show-hide
|
||||
@@ -63,6 +71,59 @@ const HLS_SOURCE_FAILURE_DETAILS = new Set([
|
||||
"fragLoadTimeOut",
|
||||
]);
|
||||
|
||||
const TV_SOURCE_TYPE_LABELS = {
|
||||
hls: "HLS",
|
||||
video: "Video",
|
||||
iframe: "Web",
|
||||
external: "External",
|
||||
youtube: "YouTube",
|
||||
};
|
||||
|
||||
function isEnglishLocale() {
|
||||
return getEarthLocale() === "en-US";
|
||||
}
|
||||
|
||||
function titleCaseIdentifier(value) {
|
||||
return String(value || "")
|
||||
.replace(/[-_]+/g, " ")
|
||||
.replace(/\b\w/g, (letter) => letter.toUpperCase())
|
||||
.trim();
|
||||
}
|
||||
|
||||
function safeTVText(value, fallback = "") {
|
||||
const text = String(value ?? "").trim();
|
||||
if (!text) return fallback;
|
||||
if (isEnglishLocale() && hasCjkText(text)) return fallback;
|
||||
return text;
|
||||
}
|
||||
|
||||
function getTVSourceName(source) {
|
||||
if (!source) return translateText("暂无可用频道");
|
||||
return safeTVText(
|
||||
source.name,
|
||||
titleCaseIdentifier(source.id) || "Live Channel",
|
||||
);
|
||||
}
|
||||
|
||||
function getTVSourceTypeLabel(sourceType) {
|
||||
const normalized = String(sourceType || "").trim().toLowerCase();
|
||||
return TV_SOURCE_TYPE_LABELS[normalized] || safeTVText(sourceType, translateText("频道"));
|
||||
}
|
||||
|
||||
function getTVSourceField(value, fallback = "") {
|
||||
return safeTVText(value, fallback);
|
||||
}
|
||||
|
||||
function getTVCollectorLabel(value) {
|
||||
const collector = getTVSourceField(value, "Collector");
|
||||
return `${translateText("采集")}: ${collector}`;
|
||||
}
|
||||
|
||||
function getTVNotes(source) {
|
||||
if (!source?.notes) return translateText("支持后台配置默认源与采集器补充源。");
|
||||
return safeTVText(source.notes, translateText("配置于控制台"));
|
||||
}
|
||||
|
||||
function isVideoActuallyPlaying(video) {
|
||||
return (
|
||||
video instanceof HTMLVideoElement
|
||||
@@ -157,9 +218,9 @@ function syncMetaToggleState(collapsed) {
|
||||
desktopToggle.setAttribute("aria-expanded", collapsed ? "false" : "true");
|
||||
desktopToggle.setAttribute(
|
||||
"aria-label",
|
||||
collapsed ? "展开新闻直播内容" : "折叠新闻直播内容",
|
||||
translateText(collapsed ? "展开新闻直播内容" : "折叠新闻直播内容"),
|
||||
);
|
||||
desktopToggle.title = collapsed ? "展开新闻直播内容" : "折叠新闻直播内容";
|
||||
desktopToggle.title = translateText(collapsed ? "展开新闻直播内容" : "折叠新闻直播内容");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -328,11 +389,11 @@ function updateToggleButton(visible) {
|
||||
icon.textContent = "live_tv";
|
||||
}
|
||||
const title = visible ? "关闭 Live 新闻" : "打开 Live 新闻";
|
||||
toggleBtn.title = title;
|
||||
toggleBtn.setAttribute("aria-label", title);
|
||||
toggleBtn.title = translateText(title);
|
||||
toggleBtn.setAttribute("aria-label", translateText(title));
|
||||
const tooltip = toggleBtn.querySelector(".earth-toolbar-tooltip");
|
||||
if (tooltip) {
|
||||
tooltip.textContent = title;
|
||||
tooltip.textContent = translateText(title);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -628,30 +689,30 @@ function syncMobileOverviewSummary(source) {
|
||||
const summary = document.getElementById("mobile-tv-overview-summary");
|
||||
const tags = document.getElementById("mobile-tv-overview-tags");
|
||||
if (headline instanceof HTMLElement) {
|
||||
headline.textContent = source?.name || "暂无可用频道";
|
||||
headline.textContent = getTVSourceName(source);
|
||||
}
|
||||
if (summary instanceof HTMLElement) {
|
||||
if (!source) {
|
||||
summary.textContent = "点击查看当前频道来源、目录和补充说明";
|
||||
summary.textContent = translateText("点击查看当前频道来源、目录和补充说明");
|
||||
} else {
|
||||
const parts = [
|
||||
source.provider,
|
||||
source.region,
|
||||
source.language,
|
||||
getTVSourceField(source.provider),
|
||||
getTVSourceField(source.region),
|
||||
getTVSourceField(source.language),
|
||||
].filter(Boolean);
|
||||
summary.textContent = parts.length
|
||||
? parts.join(" · ")
|
||||
: "点击查看完整频道信息";
|
||||
: translateText("点击查看完整频道信息");
|
||||
}
|
||||
}
|
||||
if (tags instanceof HTMLElement) {
|
||||
const tagValues = source
|
||||
? [
|
||||
{ label: source.source_type || "频道", kind: "status" },
|
||||
source.collector_source ? { label: `采集:${source.collector_source}`, kind: "" } : { label: "内置源", kind: "" },
|
||||
source.region ? { label: source.region, kind: "" } : null,
|
||||
{ label: getTVSourceTypeLabel(source.source_type), kind: "status" },
|
||||
source.collector_source ? { label: getTVCollectorLabel(source.collector_source), kind: "" } : { label: translateText("内置源"), kind: "" },
|
||||
source.region ? { label: getTVSourceField(source.region, "Global"), kind: "" } : null,
|
||||
].filter(Boolean).slice(0, 3)
|
||||
: [{ label: "待加载", kind: "status" }];
|
||||
: [{ label: translateText("待加载"), kind: "status" }];
|
||||
tags.replaceChildren(
|
||||
...tagValues.map(({ label, kind }) => {
|
||||
const chip = document.createElement("span");
|
||||
@@ -727,7 +788,7 @@ function getCurrentSource() {
|
||||
function setPanelMessage(message) {
|
||||
const { status } = getElements();
|
||||
if (status) {
|
||||
status.textContent = message || TV_STATUS_MESSAGE.idle;
|
||||
status.textContent = translateText(message || TV_STATUS_MESSAGE.idle);
|
||||
if (status.id === "tv-source-status") {
|
||||
const normalized = message || TV_STATUS_MESSAGE.idle;
|
||||
status.classList.toggle(
|
||||
@@ -762,7 +823,7 @@ function resetVideo(video) {
|
||||
function showEmptyState(empty, message) {
|
||||
if (!(empty instanceof HTMLElement)) return;
|
||||
empty.hidden = false;
|
||||
empty.textContent = message;
|
||||
empty.textContent = translateText(message);
|
||||
}
|
||||
|
||||
function hideEmptyState(empty) {
|
||||
@@ -797,11 +858,11 @@ function renderSourceOptions() {
|
||||
const fragment = document.createDocumentFragment();
|
||||
|
||||
sources.forEach((source) => {
|
||||
const defaultMark = source.id === tvPayload?.default_source_id ? " · 默认" : "";
|
||||
const defaultMark = source.id === tvPayload?.default_source_id ? ` · ${translateText("默认")}` : "";
|
||||
const failMark = failedSourceIds.has(source.id) ? " ⚠" : "";
|
||||
const option = document.createElement("option");
|
||||
option.value = source.id;
|
||||
option.textContent = `${source.name}${defaultMark}${failMark}`;
|
||||
option.textContent = `${getTVSourceName(source)}${defaultMark}${failMark}`;
|
||||
fragment.appendChild(option);
|
||||
});
|
||||
|
||||
@@ -820,27 +881,37 @@ function renderSource(source) {
|
||||
const isExternalOnly = Boolean(source) && !embeddedUrl && !videoUrl && Boolean(externalUrl);
|
||||
|
||||
if (title) {
|
||||
title.textContent = source?.name || "暂无可用频道";
|
||||
title.textContent = getTVSourceName(source);
|
||||
}
|
||||
if (origin instanceof HTMLElement) {
|
||||
origin.textContent = source?.collector_source ? "采集" : source ? "内置" : "待加载";
|
||||
origin.title = source?.collector_source ? `采集源:${source.collector_source}` : source ? "内置源" : "待加载";
|
||||
origin.textContent = source?.collector_source ? translateText("采集") : source ? translateText("内置") : translateText("待加载");
|
||||
origin.title = source?.collector_source ? `${translateText("采集源")}: ${getTVSourceField(source.collector_source, "Collector")}` : source ? translateText("内置源") : translateText("待加载");
|
||||
}
|
||||
if (meta) {
|
||||
meta.textContent = source
|
||||
? `${source.provider} · ${source.region} · ${source.language} · ${source.source_type}`
|
||||
: "当前未配置可播放新闻直播源";
|
||||
const metaParts = source
|
||||
? [
|
||||
getTVSourceField(source.provider),
|
||||
getTVSourceField(source.region),
|
||||
getTVSourceField(source.language),
|
||||
getTVSourceTypeLabel(source.source_type),
|
||||
].filter(Boolean)
|
||||
: [];
|
||||
meta.textContent = metaParts.length
|
||||
? metaParts.join(" · ")
|
||||
: translateText("当前未配置可播放新闻直播源");
|
||||
}
|
||||
if (catalog) {
|
||||
const sourceCount = tvPayload?.source_count || tvPayload?.sources?.length || 0;
|
||||
const latestUpdatedAt = tvPayload?.latest_updated_at || tvPayload?.generated_at || "";
|
||||
const latestLabel = latestUpdatedAt
|
||||
? `最近同步 ${new Date(latestUpdatedAt).toLocaleString("zh-CN", { hour12: false })}`
|
||||
: "尚未同步";
|
||||
catalog.textContent = `共 ${sourceCount} 个频道 · ${latestLabel}`;
|
||||
? `${translateText("最近同步")} ${formatLocaleDateTime(latestUpdatedAt)}`
|
||||
: translateText("尚未同步");
|
||||
catalog.textContent = isEnglishLocale()
|
||||
? `${sourceCount} channels · ${latestLabel}`
|
||||
: `共 ${sourceCount} 个频道 · ${latestLabel}`;
|
||||
}
|
||||
if (notes) {
|
||||
notes.textContent = source?.notes || "支持后台配置默认源与采集器补充源。";
|
||||
notes.textContent = getTVNotes(source);
|
||||
}
|
||||
syncMobileOverviewSummary(source);
|
||||
|
||||
@@ -954,8 +1025,8 @@ export function initTVPanel() {
|
||||
collapseBtn: metaToggle,
|
||||
bodyCollapsedClass: "is-collapsed",
|
||||
preferredDirection: "up",
|
||||
expandLabel: "展开新闻直播信息",
|
||||
collapseLabel: "折叠新闻直播信息",
|
||||
expandLabel: translateText("展开新闻直播信息"),
|
||||
collapseLabel: translateText("折叠新闻直播信息"),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -965,6 +1036,12 @@ export function initTVPanel() {
|
||||
|
||||
updateToggleButton(mediaPanel?.isVisible() ?? !panel?.classList.contains("hud-panel-hidden"));
|
||||
syncSettingsToggle(mediaPanel?.isVisible() ?? !panel?.classList.contains("hud-panel-hidden"));
|
||||
onEarthLocaleChange(() => {
|
||||
syncMetaToggleState(isMetaCollapsed());
|
||||
updateToggleButton(mediaPanel?.isVisible() ?? !panel?.classList.contains("hud-panel-hidden"));
|
||||
if (tvPayload) renderPanel();
|
||||
else renderSource(null);
|
||||
});
|
||||
|
||||
toggleBtn?.addEventListener("click", async (event) => {
|
||||
event.preventDefault();
|
||||
@@ -973,12 +1050,12 @@ export function initTVPanel() {
|
||||
if (!currentlyVisible) {
|
||||
setPanelVisible(true);
|
||||
await ensureTVPanelReady();
|
||||
showStatusMessage("Live 新闻窗口已打开", "info");
|
||||
showStatusMessage(earthMessage("status.newsPanel", { open: true }), "info");
|
||||
return;
|
||||
}
|
||||
|
||||
setPanelVisible(false);
|
||||
showStatusMessage("Live 新闻窗口已关闭", "info");
|
||||
showStatusMessage(earthMessage("status.newsPanel", { open: false }), "info");
|
||||
});
|
||||
|
||||
[select, document.getElementById("mobile-tv-source-select"), document.getElementById("tv-source-select")]
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
// ui.js - UI update functions
|
||||
|
||||
import {
|
||||
applyEarthI18n,
|
||||
earthMessage,
|
||||
formatEarthMessage,
|
||||
getEarthLocale,
|
||||
onEarthLocaleChange,
|
||||
translateText,
|
||||
} from "./i18n.js";
|
||||
|
||||
let statusTimeoutId = null;
|
||||
let statusHideTimeoutId = null;
|
||||
const STATUS_BASE_CLASS = "earth-status-message";
|
||||
@@ -27,14 +36,30 @@ function getEarthStatTargets(statKey) {
|
||||
);
|
||||
}
|
||||
|
||||
function formatEarthStatValue(value) {
|
||||
return translateText(value);
|
||||
}
|
||||
|
||||
function syncEarthStatElement(element) {
|
||||
if (!(element instanceof HTMLElement)) return;
|
||||
const sourceValue = element.dataset.earthStatSourceValue;
|
||||
if (sourceValue === undefined) return;
|
||||
element.textContent = formatEarthStatValue(sourceValue);
|
||||
}
|
||||
|
||||
export function setEarthStatValue(statKey, value) {
|
||||
getEarthStatTargets(statKey).forEach((element) => {
|
||||
if (element instanceof HTMLElement) {
|
||||
element.textContent = value;
|
||||
element.dataset.earthStatSourceValue = String(value ?? "");
|
||||
syncEarthStatElement(element);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onEarthLocaleChange(() => {
|
||||
document.querySelectorAll("[data-earth-stat-source-value]").forEach(syncEarthStatElement);
|
||||
});
|
||||
|
||||
function setElementDisplay(element, visible, displayValue = "block") {
|
||||
if (!element) return;
|
||||
element.style.display = visible ? displayValue : "none";
|
||||
@@ -82,7 +107,7 @@ function buildStatusContent(statusEl, message, type) {
|
||||
|
||||
const text = document.createElement("span");
|
||||
text.className = "earth-status-text";
|
||||
text.textContent = message;
|
||||
text.textContent = formatEarthMessage(message);
|
||||
|
||||
statusEl.appendChild(indicator);
|
||||
statusEl.appendChild(text);
|
||||
@@ -92,23 +117,20 @@ function getStatusSidePlacement(statusEl) {
|
||||
if (!(statusEl instanceof HTMLElement)) return false;
|
||||
if (document.querySelector(".layout-mode-mobile")) return false;
|
||||
|
||||
const ticker = document.getElementById("desktop-news-ticker");
|
||||
const brand = document.getElementById("brand-panel");
|
||||
if (!(ticker instanceof HTMLElement) || !(brand instanceof HTMLElement)) return false;
|
||||
if (ticker.classList.contains("is-hidden") || ticker.offsetParent === null) return false;
|
||||
if (!(brand instanceof HTMLElement)) return false;
|
||||
|
||||
const tickerRect = ticker.getBoundingClientRect();
|
||||
const brandRect = brand.getBoundingClientRect();
|
||||
const statusWidth = Math.ceil(statusEl.getBoundingClientRect().width || statusEl.scrollWidth || 0);
|
||||
if (!statusWidth || !tickerRect.width || !brandRect.width) return false;
|
||||
if (!statusWidth || !brandRect.width) return false;
|
||||
|
||||
const rootStyle = getComputedStyle(document.documentElement);
|
||||
const hudScale = Number.parseFloat(rootStyle.getPropertyValue("--hud-scale")) || 1;
|
||||
const requiredGap = Math.max(10, Math.round(12 * hudScale));
|
||||
const availableWidth = tickerRect.left - brandRect.right - requiredGap * 2;
|
||||
const left = Math.round(brandRect.right + requiredGap);
|
||||
const availableWidth = window.innerWidth - left - requiredGap;
|
||||
return {
|
||||
shouldStack: availableWidth < statusWidth,
|
||||
left: Math.round(brandRect.right + requiredGap),
|
||||
left,
|
||||
maxWidth: Math.max(160, Math.floor(availableWidth)),
|
||||
};
|
||||
}
|
||||
@@ -122,9 +144,8 @@ function syncStatusPlacement(statusEl) {
|
||||
return;
|
||||
}
|
||||
const placement = getStatusSidePlacement(statusEl);
|
||||
const shouldStack = !placement || placement.shouldStack;
|
||||
statusEl.classList.toggle(STATUS_TICKER_STACK_CLASS, shouldStack);
|
||||
if (!placement || shouldStack) {
|
||||
statusEl.classList.remove(STATUS_TICKER_STACK_CLASS);
|
||||
if (!placement) {
|
||||
statusEl.style.left = "";
|
||||
statusEl.style.maxWidth = "";
|
||||
return;
|
||||
@@ -134,10 +155,12 @@ function syncStatusPlacement(statusEl) {
|
||||
}
|
||||
|
||||
function syncVisibleStatusPlacement() {
|
||||
const statusEl = getElement("status-message");
|
||||
if (statusEl?.classList.contains("visible")) {
|
||||
syncStatusPlacement(statusEl);
|
||||
["status-message", "error-message"].forEach((id) => {
|
||||
const el = getElement(id);
|
||||
if (el?.classList.contains("visible")) {
|
||||
syncStatusPlacement(el);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function buildPersistentErrorContent(errorEl, message) {
|
||||
@@ -237,7 +260,9 @@ export function updateCoordinatesDisplay(lat, lon, alt = 0) {
|
||||
if (longitudeEl) longitudeEl.textContent = lon.toFixed(2) + "°";
|
||||
if (latitudeEl) latitudeEl.textContent = lat.toFixed(2) + "°";
|
||||
if (mouseCoordsEl) {
|
||||
mouseCoordsEl.textContent = `鼠标: ${lat.toFixed(2)}°, ${lon.toFixed(2)}°`;
|
||||
mouseCoordsEl.textContent = getEarthLocale() === "en-US"
|
||||
? `Mouse: ${lat.toFixed(2)}°, ${lon.toFixed(2)}°`
|
||||
: `鼠标: ${lat.toFixed(2)}°, ${lon.toFixed(2)}°`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -253,7 +278,11 @@ export function updateZoomDisplay(zoomLevel, distance) {
|
||||
const label = `${percent}%`;
|
||||
zoomValueEl.textContent = label;
|
||||
}
|
||||
if (zoomLevelEl) zoomLevelEl.textContent = "缩放: " + percent + "%";
|
||||
if (zoomLevelEl) {
|
||||
zoomLevelEl.textContent = getEarthLocale() === "en-US"
|
||||
? `Zoom: ${percent}%`
|
||||
: `缩放: ${percent}%`;
|
||||
}
|
||||
if (slider) slider.value = zoomLevel;
|
||||
if (cameraDistanceEl) cameraDistanceEl.textContent = distance + " km";
|
||||
}
|
||||
@@ -276,7 +305,14 @@ export function updateEarthStats(stats) {
|
||||
setEarthStatValue("bgp-collector-count", String(stats.bgpCollectorCount || 0));
|
||||
}
|
||||
if (has("bgpStatusSummary")) setEarthStatValue("bgp-status-summary", stats.bgpStatusSummary || "-");
|
||||
if (has("terrainOn")) setEarthStatValue("terrain-status", stats.terrainOn ? "开启" : "关闭");
|
||||
if (has("terrainOn")) {
|
||||
setEarthStatValue(
|
||||
"terrain-status",
|
||||
getEarthLocale() === "en-US"
|
||||
? (stats.terrainOn ? "On" : "Off")
|
||||
: (stats.terrainOn ? "开启" : "关闭"),
|
||||
);
|
||||
}
|
||||
if (has("textureQuality")) setEarthStatValue("texture-quality", stats.textureQuality || "8K 卫星图");
|
||||
}
|
||||
|
||||
@@ -292,7 +328,7 @@ export function setLoading(loading) {
|
||||
clearLoadingWidthLock(statusEl);
|
||||
buildStatusContent(
|
||||
statusEl,
|
||||
pendingLoadingMessage || "正在加载...",
|
||||
pendingLoadingMessage || earthMessage("loading.default"),
|
||||
"loading",
|
||||
);
|
||||
pendingLoadingMessage = "";
|
||||
@@ -331,7 +367,7 @@ export function setLoadingMessage(title) {
|
||||
}
|
||||
const textEl = statusEl.querySelector(".earth-status-text");
|
||||
if (textEl) {
|
||||
textEl.textContent = title;
|
||||
textEl.textContent = formatEarthMessage(title);
|
||||
requestAnimationFrame(() => {
|
||||
updateLoadingWidthLock(statusEl);
|
||||
syncStatusPlacement(statusEl);
|
||||
@@ -344,6 +380,7 @@ export function showTooltip(x, y, content) {
|
||||
const tooltip = getElement("tooltip");
|
||||
if (!tooltip) return;
|
||||
tooltip.innerHTML = content;
|
||||
applyEarthI18n(tooltip);
|
||||
tooltip.style.left = x + "px";
|
||||
tooltip.style.top = y + "px";
|
||||
setElementDisplay(tooltip, true);
|
||||
@@ -364,6 +401,7 @@ export function showError(message) {
|
||||
buildPersistentErrorContent(errorEl, message);
|
||||
errorEl.className = `${STATUS_BASE_CLASS} earth-error-message error`;
|
||||
setElementDisplay(errorEl, true, "inline-flex");
|
||||
syncStatusPlacement(errorEl);
|
||||
errorEl.offsetHeight;
|
||||
errorEl.classList.add("visible");
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { useEffect, useMemo, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import Scrollbar from '../../components/Scrollbar/Scrollbar'
|
||||
import { useWebSocket } from '../../hooks/useWebSocket'
|
||||
import { describeApiError, describeApiValue } from '../../i18n/api-errors'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import { formatDateTimeZhCN } from '../../utils/datetime'
|
||||
import { AdminLayout } from '../components/layout/AdminLayout'
|
||||
@@ -148,14 +149,14 @@ function DashboardContent() {
|
||||
setRestartTaskId(res.data.task_id)
|
||||
setRestartStartedAt(Date.now())
|
||||
setRestartStage('waiting_for_shutdown')
|
||||
setRestartMessage(res.data.message || '已发送重启指令,正在等待服务进入重启流程。')
|
||||
setRestartMessage(describeApiValue(res.data.message, '已发送重启指令,正在等待服务进入重启流程。'))
|
||||
setRestartLogs((current) => [...current, `任务已创建: ${res.data.task_id}`])
|
||||
} catch (restartError: unknown) {
|
||||
const err = restartError as { response?: { data?: { detail?: string } } }
|
||||
const restartFailureMessage = describeApiError(restartError, '提交重启任务失败')
|
||||
setRestartStage('failed')
|
||||
setRestartMessage(err.response?.data?.detail || '提交重启任务失败')
|
||||
setRestartMessage(restartFailureMessage)
|
||||
setRestartLogs((current) => [...current, '提交重启任务失败'])
|
||||
toast({ tone: 'error', title: '提交失败', description: err.response?.data?.detail || '提交重启任务失败' })
|
||||
toast({ tone: 'error', title: '提交失败', description: restartFailureMessage })
|
||||
} finally {
|
||||
setRestartSubmitting(false)
|
||||
}
|
||||
@@ -191,7 +192,7 @@ function DashboardContent() {
|
||||
try {
|
||||
const taskRes = await axios.get<RestartTask>(`/api/v1/system/restart-tasks/${restartTaskId}`, { timeout: 1500 })
|
||||
const task = taskRes.data
|
||||
if (!cancelled && task?.message) setRestartMessage(task.message)
|
||||
if (!cancelled && task?.message) setRestartMessage(describeApiValue(task.message, '重启任务正在执行'))
|
||||
if (!cancelled && restartAction !== 'restart-system') {
|
||||
const logsRes = await axios.get<RestartTaskLogs>(`/api/v1/system/restart-tasks/${restartTaskId}/logs`, { timeout: 1500 })
|
||||
if (logsRes.data.lines.length > 0) setRestartLogs(logsRes.data.lines.slice(-8))
|
||||
@@ -204,7 +205,7 @@ function DashboardContent() {
|
||||
}
|
||||
if (!cancelled && (task.status === 'failed' || task.status === 'timeout')) {
|
||||
setRestartStage(task.status === 'timeout' ? 'timeout' : 'failed')
|
||||
setRestartMessage(task.message || '重启任务失败')
|
||||
setRestartMessage(describeApiValue(task.message, '重启任务失败'))
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import Scrollbar from '../../components/Scrollbar/Scrollbar'
|
||||
import { useWebSocket } from '../../hooks/useWebSocket'
|
||||
import { describeApiError } from '../../i18n/api-errors'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import { AdminLayout } from '../components/layout/AdminLayout'
|
||||
import { Badge } from '../components/ui/badge'
|
||||
@@ -168,9 +169,7 @@ function statusLabel(status: string) {
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown, fallback: string) {
|
||||
if (!axios.isAxiosError(error)) return fallback
|
||||
const detail = error.response?.data?.detail
|
||||
return typeof detail === 'string' ? detail : fallback
|
||||
return describeApiError(error, fallback)
|
||||
}
|
||||
|
||||
function formatDateTime(value?: string | null) {
|
||||
|
||||
@@ -32,6 +32,9 @@ import { Link, useLocation, useNavigate } from 'react-router-dom'
|
||||
import MarkdownRenderer from '../../components/MarkdownRenderer/MarkdownRenderer'
|
||||
import Scrollbar from '../../components/Scrollbar/Scrollbar'
|
||||
import { useWebSocket } from '../../hooks/useWebSocket'
|
||||
import { describeApiError, localizeApiMessage } from '../../i18n/api-errors'
|
||||
import { legacyUiTextEnUS } from '../../i18n/legacy-ui'
|
||||
import { useLocale, type SupportedLocale } from '../../i18n/locale'
|
||||
import { AdminLayout } from '../components/layout/AdminLayout'
|
||||
import { DataTable } from '../components/data-table/DataTable'
|
||||
import { Button, type ButtonProps } from '../components/ui/button'
|
||||
@@ -215,6 +218,55 @@ function text(value: unknown, fallback = '-') {
|
||||
return fallback
|
||||
}
|
||||
|
||||
const legacyUiTextZhCN = Object.fromEntries(
|
||||
Object.entries(legacyUiTextEnUS).map(([source, target]) => [target, source]),
|
||||
)
|
||||
|
||||
const editableValueTextEnUS: Record<string, string> = {
|
||||
'智能星球': 'Intelligent Planet',
|
||||
'智能星球计划': 'Intelligent Planet Program',
|
||||
'现实层宇宙全息感知系统': 'Reality Layer Situational Awareness System',
|
||||
'卫星 · 海底光缆 · 算力基础设施': 'Satellites · Subsea Cables · Compute Infrastructure',
|
||||
'智能星球计划品牌标识': 'Intelligent Planet Program brand banner',
|
||||
'/earth/assets/brand/title-zh.png': '/earth/assets/brand/title-en.png',
|
||||
}
|
||||
|
||||
const editableValueTextZhCN = Object.fromEntries(
|
||||
Object.entries(editableValueTextEnUS).map(([source, target]) => [target, source]),
|
||||
)
|
||||
|
||||
function localizeAdminText(value: string, locale: SupportedLocale) {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return value
|
||||
const dictionary = locale === 'en-US' ? legacyUiTextEnUS : legacyUiTextZhCN
|
||||
const translated = dictionary[trimmed]
|
||||
if (translated) return value.replace(trimmed, translated)
|
||||
if (trimmed.length > 240) return value
|
||||
const entries = Object.entries(dictionary)
|
||||
.filter(([source]) => source && trimmed.includes(source))
|
||||
.sort(([left], [right]) => right.length - left.length)
|
||||
if (!entries.length) return value
|
||||
return entries.reduce((next, [source, target]) => next.split(source).join(target), value)
|
||||
}
|
||||
|
||||
function localizeEditableValue(_key: string, value: unknown, locale: SupportedLocale) {
|
||||
if (typeof value !== 'string') return value
|
||||
const dictionary = locale === 'en-US' ? editableValueTextEnUS : editableValueTextZhCN
|
||||
return dictionary[value] || localizeAdminText(value, locale)
|
||||
}
|
||||
|
||||
function localizeDisplayValue(value: unknown, locale: SupportedLocale) {
|
||||
if (value === null || value === undefined) return value
|
||||
return localizeAdminText(text(localizeEditableValue('', value, locale), ''), locale)
|
||||
}
|
||||
|
||||
function localizeSearchParts(parts: unknown[], locale: SupportedLocale) {
|
||||
return parts
|
||||
.map((part) => text(localizeDisplayValue(part, locale), ''))
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
function pick(record: AnyRecord, keys: string[], fallback = '-') {
|
||||
for (const key of keys) {
|
||||
const value = record[key]
|
||||
@@ -546,6 +598,7 @@ function EditableRecordForm({
|
||||
onDraftChange: (value: string) => void
|
||||
hiddenKeys?: string[]
|
||||
}) {
|
||||
const { locale } = useLocale()
|
||||
const parsed = parseJsonDraft(draft, {})
|
||||
if (!isObjectRecord(parsed)) {
|
||||
return (
|
||||
@@ -574,14 +627,14 @@ function EditableRecordForm({
|
||||
checked={value}
|
||||
onChange={(event) => onDraftChange(setDraftField(draft, key, event.target.checked))}
|
||||
/>
|
||||
{fieldLabelElement(key)}
|
||||
{fieldLabelElement(key, locale)}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
return (
|
||||
<label key={key} className="an-field" htmlFor={inputId}>
|
||||
{fieldLabelElement(key)}
|
||||
{fieldLabelElement(key, locale)}
|
||||
<input
|
||||
id={inputId}
|
||||
className="an-input"
|
||||
@@ -593,13 +646,14 @@ function EditableRecordForm({
|
||||
)
|
||||
}
|
||||
if (typeof value === 'string' || value === null || value === undefined) {
|
||||
const displayValue = localizeEditableValue(key, text(value, ''), locale)
|
||||
return (
|
||||
<label key={key} className="an-field" htmlFor={inputId}>
|
||||
{fieldLabelElement(key)}
|
||||
{fieldLabelElement(key, locale)}
|
||||
<input
|
||||
id={inputId}
|
||||
className="an-input"
|
||||
value={text(value, '')}
|
||||
value={text(displayValue, '')}
|
||||
onChange={(event) => onDraftChange(setDraftField(draft, key, event.target.value))}
|
||||
/>
|
||||
</label>
|
||||
@@ -607,7 +661,7 @@ function EditableRecordForm({
|
||||
}
|
||||
return (
|
||||
<label key={key} className="an-field an-field--wide" htmlFor={inputId}>
|
||||
{fieldLabelElement(key)}
|
||||
{fieldLabelElement(key, locale)}
|
||||
<Textarea
|
||||
id={inputId}
|
||||
className="an-json-editor an-json-editor--compact"
|
||||
@@ -636,6 +690,7 @@ function sectionSummary(states: SectionState[]) {
|
||||
}
|
||||
|
||||
function DetailFields({ record }: { record: AnyRecord }) {
|
||||
const { locale } = useLocale()
|
||||
const entries = Object.entries(cleanRecord(record))
|
||||
.filter(([key]) => !MARKDOWN_CONTENT_KEYS.has(key))
|
||||
.filter(([, value]) => value === null || ['string', 'number', 'boolean'].includes(typeof value))
|
||||
@@ -646,8 +701,8 @@ function DetailFields({ record }: { record: AnyRecord }) {
|
||||
<dl className="an-detail-list">
|
||||
{entries.map(([key, value]) => (
|
||||
<div key={key}>
|
||||
<dt>{fieldLabel(key)}</dt>
|
||||
<dd>{semanticLabel(value)}</dd>
|
||||
<dt>{fieldLabel(key, locale)}</dt>
|
||||
<dd>{localizeDisplayValue(semanticLabel(value), locale)}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
@@ -674,30 +729,30 @@ function DetailMarkdownDocument({ record }: { record: AnyRecord }) {
|
||||
)
|
||||
}
|
||||
|
||||
function defaultColumns(onSelect: (record: TableRecord) => void): Array<ColumnDef<TableRecord>> {
|
||||
function defaultColumns(onSelect: (record: TableRecord) => void, locale: SupportedLocale): Array<ColumnDef<TableRecord>> {
|
||||
return [
|
||||
{
|
||||
id: 'name',
|
||||
header: '名称',
|
||||
header: localizeAdminText('名称', locale),
|
||||
size: 260,
|
||||
cell: ({ row }) => (
|
||||
<button type="button" className="an-table-link" onClick={() => onSelect(row.original)}>
|
||||
{recordTitle(row.original)}
|
||||
{localizeDisplayValue(recordTitle(row.original), locale)}
|
||||
</button>
|
||||
),
|
||||
},
|
||||
{ id: 'module', header: '模块', size: 150, cell: ({ row }) => row.original.__module },
|
||||
{ id: 'module', header: localizeAdminText('模块', locale), size: 150, cell: ({ row }) => localizeDisplayValue(row.original.__module, locale) },
|
||||
{
|
||||
id: 'status',
|
||||
header: '状态',
|
||||
header: localizeAdminText('状态', locale),
|
||||
size: 130,
|
||||
cell: ({ row }) => {
|
||||
const status = recordDisplayStatus(row.original)
|
||||
return <StatusText tone={statusTone(status)}>{status}</StatusText>
|
||||
return <StatusText tone={statusTone(status)}>{localizeDisplayValue(status, locale)}</StatusText>
|
||||
},
|
||||
},
|
||||
{ id: 'metric', header: '指标', size: 220, cell: ({ row }) => <span className="an-muted-text">{semanticLabel(recordMetric(row.original))}</span> },
|
||||
{ id: 'updated', header: '更新时间', size: 180, cell: ({ row }) => row.original.__time },
|
||||
{ id: 'metric', header: localizeAdminText('指标', locale), size: 220, cell: ({ row }) => <span className="an-muted-text">{localizeDisplayValue(semanticLabel(recordMetric(row.original)), locale)}</span> },
|
||||
{ id: 'updated', header: localizeAdminText('更新时间', locale), size: 180, cell: ({ row }) => localizeDisplayValue(row.original.__time, locale) },
|
||||
]
|
||||
}
|
||||
|
||||
@@ -722,13 +777,14 @@ function ModuleTable({
|
||||
}
|
||||
loading?: boolean
|
||||
}) {
|
||||
const tableColumns = useMemo(() => columns || defaultColumns(onSelect), [columns, onSelect])
|
||||
const { locale } = useLocale()
|
||||
const tableColumns = useMemo(() => columns || defaultColumns(onSelect, locale), [columns, locale, onSelect])
|
||||
return (
|
||||
<DataTable
|
||||
className="an-resource-table"
|
||||
columns={tableColumns}
|
||||
data={rows}
|
||||
emptyText="当前模块暂无数据"
|
||||
emptyText={localizeAdminText('当前模块暂无数据', locale)}
|
||||
getRowClassName={(row) => row.__rowId === selected?.__rowId ? 'is-selected' : undefined}
|
||||
getRowId={(row) => row.__rowId}
|
||||
loading={loading}
|
||||
@@ -749,11 +805,13 @@ function SectionTabs({
|
||||
activeKey: string
|
||||
onChange: (key: string) => void
|
||||
}) {
|
||||
const { locale } = useLocale()
|
||||
return (
|
||||
<div className="an-section-tabs" role="tablist" aria-label="模块分区">
|
||||
<div className="an-section-tabs" role="tablist" aria-label={localizeAdminText('模块分区', locale)}>
|
||||
{sections.map((section) => {
|
||||
const state = states.find((item) => item.section.key === section.key)
|
||||
const active = activeKey === section.key
|
||||
const label = localizeAdminText(section.label, locale)
|
||||
return (
|
||||
<button
|
||||
key={section.key}
|
||||
@@ -762,11 +820,11 @@ function SectionTabs({
|
||||
aria-selected={active}
|
||||
className={active ? 'an-section-tab is-active' : 'an-section-tab'}
|
||||
data-admin-search-target={`section:${section.key}`}
|
||||
data-admin-search-text={section.label}
|
||||
data-admin-search-text={label}
|
||||
onClick={() => onChange(section.key)}
|
||||
>
|
||||
<span className={state ? state.ok ? 'an-section-tab__dot an-section-tab__dot--success' : 'an-section-tab__dot an-section-tab__dot--danger' : 'an-section-tab__dot an-section-tab__dot--neutral'} />
|
||||
<span>{section.label}</span>
|
||||
<span>{label}</span>
|
||||
<strong>{state ? state.rows.length : '-'}</strong>
|
||||
</button>
|
||||
)
|
||||
@@ -1002,16 +1060,17 @@ const fieldHelp: Record<string, string> = {
|
||||
demo_mode: '开启后访问智能星球会直接显示 OOBE 引导,不再要求首次采集条件,也会忽略本机“先浏览”临时跳过。',
|
||||
}
|
||||
|
||||
function fieldLabel(key: string) {
|
||||
return fieldLabels[key] || key.replace(/_/g, ' ')
|
||||
function fieldLabel(key: string, locale?: SupportedLocale) {
|
||||
const label = fieldLabels[key] || key.replace(/_/g, ' ')
|
||||
return locale ? localizeAdminText(label, locale) : label
|
||||
}
|
||||
|
||||
function fieldLabelElement(key: string) {
|
||||
function fieldLabelElement(key: string, locale?: SupportedLocale) {
|
||||
const help = fieldHelp[key]
|
||||
if (!help) return fieldLabel(key)
|
||||
if (!help) return fieldLabel(key, locale)
|
||||
return (
|
||||
<span className="an-field-label-help" title={help}>
|
||||
<span>{fieldLabel(key)}</span>
|
||||
<span className="an-field-label-help" title={locale ? localizeAdminText(help, locale) : help}>
|
||||
<span>{fieldLabel(key, locale)}</span>
|
||||
<CircleHelp size={13} aria-hidden="true" />
|
||||
</span>
|
||||
)
|
||||
@@ -1890,24 +1949,27 @@ function makeNewMappingGroup(index: number, datasourceConfigId = ''): HierarchyG
|
||||
}
|
||||
|
||||
function EarthBrandPreview({ record }: { record: AnyRecord }) {
|
||||
const { locale } = useLocale()
|
||||
const logoSrc = text(record.logo_src, '/earth/assets/brand/earth-logo.png')
|
||||
const titleSrc = text(record.title_src, '')
|
||||
const titleText = text(record.title_text, '智能星球计划')
|
||||
const titleSrc = text(localizeEditableValue('title_src', text(record.title_src, ''), locale), '')
|
||||
const titleText = text(localizeEditableValue('title_text', text(record.title_text, '智能星球计划'), locale), '智能星球计划')
|
||||
const titleAlt = text(record.title_alt, titleText)
|
||||
const subtitle = text(record.subtitle, '现实层宇宙全息感知系统')
|
||||
const description = text(record.description, '卫星 · 海底光缆 · 算力基础设施')
|
||||
const ariaLabel = text(record.aria_label, '智能星球计划品牌标识')
|
||||
const localizedTitleAlt = text(localizeEditableValue('title_alt', titleAlt, locale), titleText)
|
||||
const subtitle = text(localizeEditableValue('subtitle', text(record.subtitle, '现实层宇宙全息感知系统'), locale), '')
|
||||
const description = text(localizeEditableValue('description', text(record.description, '卫星 · 海底光缆 · 算力基础设施'), locale), '')
|
||||
const ariaLabel = text(localizeEditableValue('aria_label', text(record.aria_label, '智能星球计划品牌标识'), locale), titleText)
|
||||
const brandVariant = locale === 'en-US' ? 'en' : 'zh'
|
||||
|
||||
return (
|
||||
<div className="an-earth-brand-preview" aria-label="Earth 左上角品牌实际渲染预览">
|
||||
<div className="an-earth-brand-preview" aria-label={localizeAdminText('Earth 左上角品牌实际渲染预览', locale)}>
|
||||
<div className="an-earth-brand-preview__space">
|
||||
<div className="earth-left-column">
|
||||
<div className="hud-panel hud-panel-brand">
|
||||
<div className="earth-brand earth-brand--zh" aria-label={ariaLabel}>
|
||||
<div className={`earth-brand earth-brand--${brandVariant}`} aria-label={ariaLabel}>
|
||||
<img className="earth-brand__logo" src={logoSrc} alt="" aria-hidden="true" />
|
||||
<div className="earth-brand__copy">
|
||||
{titleSrc ? (
|
||||
<img className="earth-brand__title" src={titleSrc} alt={titleAlt} />
|
||||
<img className="earth-brand__title" src={titleSrc} alt={localizedTitleAlt} />
|
||||
) : (
|
||||
<div className="earth-brand__title-text">{titleText}</div>
|
||||
)}
|
||||
@@ -2094,11 +2156,7 @@ function maskSecretPreview(value: string, fallbackPrefix = '') {
|
||||
}
|
||||
|
||||
function actionErrorMessage(error: unknown, fallback = '接口请求失败') {
|
||||
if (axios.isAxiosError(error)) {
|
||||
const detail = error.response?.data?.detail || error.response?.data?.message
|
||||
return describeResponseValue(detail, error.message || fallback)
|
||||
}
|
||||
return error instanceof Error ? error.message : fallback
|
||||
return describeApiError(error, fallback)
|
||||
}
|
||||
|
||||
function describeResponseValue(value: unknown, fallback = '接口请求失败') {
|
||||
@@ -2113,18 +2171,18 @@ function describeResponseValue(value: unknown, fallback = '接口请求失败')
|
||||
}
|
||||
|
||||
function connectionResult(data: unknown): { ok: boolean; message: string } {
|
||||
if (!isObjectRecord(data)) return { ok: true, message: '当前配置可以连通。' }
|
||||
if (!isObjectRecord(data)) return { ok: true, message: localizeApiMessage('', '当前配置可以连通。') }
|
||||
const explicit = data.success ?? data.connected ?? data.ok ?? data.valid
|
||||
const detail = describeResponseValue(data.message || data.detail || data.error || data.status, '')
|
||||
const lower = detail.toLowerCase()
|
||||
const hasFailureText = /(failed|fail|error|invalid|unauthorized|forbidden|auth|api key|apikey|not configured|missing|失败|错误|无效|未配置|鉴权|认证)/.test(lower)
|
||||
if (explicit === false || hasFailureText) {
|
||||
return { ok: false, message: detail || '当前配置连通性检查失败。' }
|
||||
return { ok: false, message: localizeApiMessage(detail, '当前配置连通性检查失败。') }
|
||||
}
|
||||
if (explicit === true) {
|
||||
return { ok: true, message: detail || '当前配置可以连通。' }
|
||||
return { ok: true, message: localizeApiMessage(detail, '当前配置可以连通。') }
|
||||
}
|
||||
return { ok: !hasFailureText, message: detail || '当前配置可以连通。' }
|
||||
return { ok: !hasFailureText, message: localizeApiMessage(detail, '当前配置可以连通。') }
|
||||
}
|
||||
|
||||
function isSecretDraftUnchanged(nextSecret: unknown, savedPreview?: unknown, revealedSecret?: unknown) {
|
||||
@@ -2230,14 +2288,19 @@ function FieldGrid({
|
||||
fields: FieldConfig[]
|
||||
searchGroupKey?: string
|
||||
}) {
|
||||
const { locale } = useLocale()
|
||||
const current = draftRecord(draft, record)
|
||||
return (
|
||||
<div className="an-field-grid">
|
||||
{fields.map((field) => {
|
||||
const value = current[field.key]
|
||||
const displayValue = localizeEditableValue(field.key, value, locale)
|
||||
const label = localizeAdminText(field.label, locale)
|
||||
const help = field.help ? localizeAdminText(field.help, locale) : ''
|
||||
const placeholder = field.placeholder ? localizeAdminText(field.placeholder, locale) : undefined
|
||||
const className = field.wide ? 'an-field an-field--wide' : 'an-field'
|
||||
const searchTarget = searchGroupKey ? `${searchGroupKey}:field:${field.key}` : `field:${field.key}`
|
||||
const searchText = [field.label, field.key, text(value, '')].filter(Boolean).join(' ')
|
||||
const searchText = [label, field.key, text(displayValue, '')].filter(Boolean).join(' ')
|
||||
if (field.type === 'boolean') {
|
||||
return (
|
||||
<label key={field.key} className="an-checkbox-row an-field--wide" data-admin-search-target={searchTarget} data-admin-search-field={field.key} data-admin-search-text={searchText}>
|
||||
@@ -2247,34 +2310,38 @@ function FieldGrid({
|
||||
disabled={field.disabled}
|
||||
onChange={(event) => onDraftChange(setNestedDraftField(draft, record, field.key, event.target.checked))}
|
||||
/>
|
||||
{field.label}
|
||||
{field.help ? <small>{field.help}</small> : null}
|
||||
{label}
|
||||
{help ? <small>{help}</small> : null}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
if (field.type === 'select') {
|
||||
return (
|
||||
<label key={field.key} className={className} data-admin-search-target={searchTarget} data-admin-search-field={field.key} data-admin-search-text={searchText}>
|
||||
<span>{field.label}</span>
|
||||
<span>{label}</span>
|
||||
<select
|
||||
className="an-input"
|
||||
value={text(value, '')}
|
||||
disabled={field.disabled}
|
||||
onChange={(event) => onDraftChange(setNestedDraftField(draft, record, field.key, event.target.value))}
|
||||
>
|
||||
{(field.options || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||
{(field.options || []).map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{localizeAdminText(option.label, locale)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{field.help ? <small>{field.help}</small> : null}
|
||||
{help ? <small>{help}</small> : null}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
if (field.type === 'textarea') {
|
||||
return (
|
||||
<label key={field.key} className={className} data-admin-search-target={searchTarget} data-admin-search-field={field.key} data-admin-search-text={searchText}>
|
||||
<span>{field.label}</span>
|
||||
<span>{label}</span>
|
||||
<Textarea
|
||||
value={isObjectRecord(value) || Array.isArray(value) ? formatRaw(value) : text(value, '')}
|
||||
placeholder={field.placeholder}
|
||||
value={isObjectRecord(value) || Array.isArray(value) ? formatRaw(value) : text(displayValue, '')}
|
||||
placeholder={placeholder}
|
||||
disabled={field.disabled}
|
||||
onChange={(event) => {
|
||||
const nextValue = event.target.value
|
||||
@@ -2286,7 +2353,7 @@ function FieldGrid({
|
||||
}}
|
||||
spellCheck={false}
|
||||
/>
|
||||
{field.help ? <small>{field.help}</small> : null}
|
||||
{help ? <small>{help}</small> : null}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
@@ -2296,47 +2363,63 @@ function FieldGrid({
|
||||
const displayValue = field.secretVisible && isMaskedSecretDraft(secretValue)
|
||||
? `${secretValue} 已保存密钥不回传明文,输入新值可替换`
|
||||
: secretValue
|
||||
const savedSecretSuffix = localizeAdminText('已保存密钥不回传明文,输入新值可替换', locale)
|
||||
return (
|
||||
<label key={field.key} className={className} data-admin-search-target={searchTarget} data-admin-search-field={field.key} data-admin-search-text={searchText}>
|
||||
<span>{field.label}</span>
|
||||
<span>{label}</span>
|
||||
<div className="an-secret-input">
|
||||
<input
|
||||
className="an-input an-secret-input__control"
|
||||
type={secretInputType}
|
||||
value={displayValue}
|
||||
placeholder={field.placeholder}
|
||||
value={field.secretVisible && isMaskedSecretDraft(secretValue) ? `${secretValue} ${savedSecretSuffix}` : displayValue}
|
||||
placeholder={placeholder}
|
||||
disabled={field.disabled}
|
||||
autoComplete="new-password"
|
||||
onChange={(event) => {
|
||||
const nextValue = event.target.value
|
||||
const suffix = ' 已保存密钥不回传明文,输入新值可替换'
|
||||
onDraftChange(setNestedDraftField(draft, record, field.key, nextValue.endsWith(suffix) ? secretValue : nextValue))
|
||||
const sourceSuffix = ' 已保存密钥不回传明文,输入新值可替换'
|
||||
const localizedSuffix = ` ${savedSecretSuffix}`
|
||||
onDraftChange(setNestedDraftField(
|
||||
draft,
|
||||
record,
|
||||
field.key,
|
||||
nextValue.endsWith(sourceSuffix) || nextValue.endsWith(localizedSuffix) ? secretValue : nextValue,
|
||||
))
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="an-secret-input__toggle"
|
||||
title={field.secretVisible ? `隐藏${field.label}` : `显示${field.label}`}
|
||||
aria-label={field.secretVisible ? `隐藏${field.label}` : `显示${field.label}`}
|
||||
title={field.secretVisible ? localizeAdminText(`隐藏${field.label}`, locale) : localizeAdminText(`显示${field.label}`, locale)}
|
||||
aria-label={field.secretVisible ? localizeAdminText(`隐藏${field.label}`, locale) : localizeAdminText(`显示${field.label}`, locale)}
|
||||
onClick={() => field.onToggleSecret?.(!field.secretVisible)}
|
||||
>
|
||||
{field.secretVisible ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
{field.help ? <small>{field.help}</small> : null}
|
||||
{help ? <small>{help}</small> : null}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
const inputAction = field.inputAction
|
||||
? {
|
||||
...field.inputAction,
|
||||
ariaLabel: field.inputAction.ariaLabel
|
||||
? localizeAdminText(field.inputAction.ariaLabel, locale)
|
||||
: undefined,
|
||||
title: localizeAdminText(field.inputAction.title, locale),
|
||||
}
|
||||
: null
|
||||
return (
|
||||
<label key={field.key} className={className} data-admin-search-target={searchTarget} data-admin-search-field={field.key} data-admin-search-text={searchText}>
|
||||
<span>{field.label}</span>
|
||||
{field.inputAction ? (
|
||||
<ConnectionTestInput action={{ ...field.inputAction, disabled: field.disabled || field.inputAction.disabled }}>
|
||||
<span>{label}</span>
|
||||
{inputAction ? (
|
||||
<ConnectionTestInput action={{ ...inputAction, disabled: field.disabled || inputAction.disabled }}>
|
||||
<input
|
||||
className="an-input an-connection-test-input__control"
|
||||
type={field.type === 'number' ? 'number' : 'text'}
|
||||
value={text(value, '')}
|
||||
placeholder={field.placeholder}
|
||||
value={text(displayValue, '')}
|
||||
placeholder={placeholder}
|
||||
disabled={field.disabled}
|
||||
onChange={(event) => onDraftChange(setNestedDraftField(
|
||||
draft,
|
||||
@@ -2350,8 +2433,8 @@ function FieldGrid({
|
||||
<input
|
||||
className="an-input"
|
||||
type={field.type === 'number' ? 'number' : 'text'}
|
||||
value={text(value, '')}
|
||||
placeholder={field.placeholder}
|
||||
value={text(displayValue, '')}
|
||||
placeholder={placeholder}
|
||||
disabled={field.disabled}
|
||||
onChange={(event) => onDraftChange(setNestedDraftField(
|
||||
draft,
|
||||
@@ -2361,7 +2444,7 @@ function FieldGrid({
|
||||
))}
|
||||
/>
|
||||
)}
|
||||
{field.help ? <small>{field.help}</small> : null}
|
||||
{help ? <small>{help}</small> : null}
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
@@ -2384,6 +2467,7 @@ function GroupList({
|
||||
header?: ReactNode
|
||||
renderGroupControl?: (group: HierarchyGroup) => ReactNode
|
||||
}) {
|
||||
const { locale } = useLocale()
|
||||
return (
|
||||
<Panel className="an-hierarchy-list">
|
||||
{header ? <div className="an-hierarchy-list__header">{header}</div> : null}
|
||||
@@ -2391,35 +2475,43 @@ function GroupList({
|
||||
<div className="an-hierarchy-list__items">
|
||||
{groups.map((group) => {
|
||||
const nested = group.children || []
|
||||
const groupLabel = localizeDisplayValue(group.label, locale)
|
||||
const groupDescription = group.description ? localizeDisplayValue(group.description, locale) : ''
|
||||
const groupStatus = group.status ? localizeDisplayValue(group.status, locale) : ''
|
||||
if (nested.length) {
|
||||
return (
|
||||
<div key={group.key} className="an-hierarchy-group-set">
|
||||
<div className="an-hierarchy-group-set__title">
|
||||
<span>
|
||||
<strong>{group.label}</strong>
|
||||
{group.description ? <small>{group.description}</small> : null}
|
||||
<strong>{groupLabel}</strong>
|
||||
{groupDescription ? <small>{groupDescription}</small> : null}
|
||||
</span>
|
||||
{renderGroupControl?.(group)}
|
||||
</div>
|
||||
{nested.map((child) => (
|
||||
{nested.map((child) => {
|
||||
const childLabel = localizeDisplayValue(child.label, locale)
|
||||
const childDescription = child.description ? localizeDisplayValue(child.description, locale) : ''
|
||||
const childStatus = child.status ? localizeDisplayValue(child.status, locale) : ''
|
||||
return (
|
||||
<button
|
||||
key={child.key}
|
||||
type="button"
|
||||
className={activeKey === child.key ? 'an-hierarchy-group is-active is-child' : 'an-hierarchy-group is-child'}
|
||||
data-admin-search-target={child.key}
|
||||
data-admin-search-text={[child.label, child.description, child.status].filter(Boolean).join(' ')}
|
||||
data-admin-search-text={localizeSearchParts([child.label, child.description, child.status], locale)}
|
||||
onClick={() => onSelect(child)}
|
||||
>
|
||||
<span>
|
||||
<strong>{child.label}</strong>
|
||||
{child.description ? <small>{child.description}</small> : null}
|
||||
<strong>{childLabel}</strong>
|
||||
{childDescription ? <small>{childDescription}</small> : null}
|
||||
</span>
|
||||
<span className="an-hierarchy-group__meta">
|
||||
{child.status ? <StatusText tone={statusTone(child.status)}>{child.status}</StatusText> : null}
|
||||
{child.status ? <StatusText tone={statusTone(child.status)}>{childStatus}</StatusText> : null}
|
||||
{typeof child.count === 'number' ? <em>{child.count}</em> : null}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2429,15 +2521,15 @@ function GroupList({
|
||||
type="button"
|
||||
className={activeKey === group.key ? 'an-hierarchy-group is-active' : 'an-hierarchy-group'}
|
||||
data-admin-search-target={group.key}
|
||||
data-admin-search-text={[group.label, group.description, group.status].filter(Boolean).join(' ')}
|
||||
data-admin-search-text={localizeSearchParts([group.label, group.description, group.status], locale)}
|
||||
onClick={() => onSelect(group)}
|
||||
>
|
||||
<span>
|
||||
<strong>{group.label}</strong>
|
||||
{group.description ? <small>{group.description}</small> : null}
|
||||
<strong>{groupLabel}</strong>
|
||||
{groupDescription ? <small>{groupDescription}</small> : null}
|
||||
</span>
|
||||
<span className="an-hierarchy-group__meta">
|
||||
{group.status ? <StatusText tone={statusTone(group.status)}>{group.status}</StatusText> : null}
|
||||
{group.status ? <StatusText tone={statusTone(group.status)}>{groupStatus}</StatusText> : null}
|
||||
{typeof group.count === 'number' ? <em>{group.count}</em> : null}
|
||||
</span>
|
||||
</button>
|
||||
@@ -2482,6 +2574,7 @@ const PLAYGROUND_PRESETS = [
|
||||
|
||||
function PlaygroundLite() {
|
||||
const { toast } = useToast()
|
||||
const { locale } = useLocale()
|
||||
const [messages, setMessages] = useState<PlaygroundApiMessage[]>([])
|
||||
const [selectedPresetKey, setSelectedPresetKey] = useState(PLAYGROUND_PRESETS[0].key)
|
||||
const [title, setTitle] = useState(PLAYGROUND_PRESETS[0].title)
|
||||
@@ -2630,20 +2723,20 @@ function PlaygroundLite() {
|
||||
<label className="an-field">
|
||||
<span>预设</span>
|
||||
<select className="an-input" value={selectedPresetKey} onChange={(event) => applyPreset(event.target.value)}>
|
||||
{PLAYGROUND_PRESETS.map((preset) => <option key={preset.key} value={preset.key}>{preset.label}</option>)}
|
||||
{PLAYGROUND_PRESETS.map((preset) => <option key={preset.key} value={preset.key}>{localizeAdminText(preset.label, locale)}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="an-field">
|
||||
<span>标题</span>
|
||||
<input className="an-input" value={title} onChange={(event) => setTitle(event.target.value)} />
|
||||
<input className="an-input" value={text(localizeDisplayValue(title, locale), '')} onChange={(event) => setTitle(event.target.value)} />
|
||||
</label>
|
||||
<label className="an-field">
|
||||
<span>目标</span>
|
||||
<Textarea value={objective} onChange={(event) => setObjective(event.target.value)} />
|
||||
<Textarea value={text(localizeDisplayValue(objective, locale), '')} onChange={(event) => setObjective(event.target.value)} />
|
||||
</label>
|
||||
<label className="an-field">
|
||||
<span>约束</span>
|
||||
<Textarea value={constraints} onChange={(event) => setConstraints(event.target.value)} />
|
||||
<Textarea value={text(localizeDisplayValue(constraints, locale), '')} onChange={(event) => setConstraints(event.target.value)} />
|
||||
</label>
|
||||
</div>
|
||||
</Panel>
|
||||
@@ -2704,9 +2797,9 @@ function PlaygroundLite() {
|
||||
)) : <EmptyState title="暂无会话" description="发送一条消息开始测试 AI 链路。" />}
|
||||
</Scrollbar>
|
||||
<div className="an-playground-composer">
|
||||
<Textarea value={inputValue} onChange={(event) => setInputValue(event.target.value)} placeholder="输入要发送给 AI 的内容" />
|
||||
<Textarea value={text(localizeDisplayValue(inputValue, locale), '')} onChange={(event) => setInputValue(event.target.value)} placeholder={localizeAdminText('输入要发送给 AI 的内容', locale)} />
|
||||
<Button variant="primary" onClick={() => void sendMessage()} loading={sending || Boolean(activeAssistant)}>
|
||||
<Send size={15} />发送
|
||||
<Send size={15} />{localizeAdminText('发送', locale)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2717,6 +2810,7 @@ function PlaygroundLite() {
|
||||
|
||||
function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
const { toast } = useToast()
|
||||
const { locale } = useLocale()
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const [states, setStates] = useState<SectionState[]>([])
|
||||
@@ -5026,13 +5120,13 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
<GroupList groups={groups} activeKey={activeGroup?.key || ''} onSelect={selectHierarchyGroup} renderGroupControl={activeSection.key === 'tools' ? renderToolSwitch : undefined} />
|
||||
<Panel className="an-hierarchy-detail">
|
||||
<div className="an-mobile-detail-bar">
|
||||
<Button size="icon" variant="subtle" title="返回列表" aria-label="返回列表" onClick={() => setMobileHierarchyDetailOpen(false)}><ArrowLeft size={15} /></Button>
|
||||
<strong>{activeGroup?.label || activeSection.label}</strong>
|
||||
<Button size="icon" variant="subtle" title={localizeAdminText('返回列表', locale)} aria-label={localizeAdminText('返回列表', locale)} onClick={() => setMobileHierarchyDetailOpen(false)}><ArrowLeft size={15} /></Button>
|
||||
<strong>{localizeDisplayValue(activeGroup?.label || activeSection.label, locale)}</strong>
|
||||
</div>
|
||||
<div className="an-panel-heading">
|
||||
<div>
|
||||
<h2 data-admin-search-target={activeGroup?.key} data-admin-search-text={[activeGroup?.label, activeGroup?.description].filter(Boolean).join(' ')}>{activeGroup?.label || activeSection.label}</h2>
|
||||
<p>{activeGroup?.description || '选择左侧父级后编辑它的子配置。'}</p>
|
||||
<h2 data-admin-search-target={activeGroup?.key} data-admin-search-text={localizeSearchParts([activeGroup?.label, activeGroup?.description], locale)}>{localizeDisplayValue(activeGroup?.label || activeSection.label, locale)}</h2>
|
||||
<p>{localizeDisplayValue(activeGroup?.description || '选择左侧父级后编辑它的子配置。', locale)}</p>
|
||||
</div>
|
||||
<div className="an-toolbar">
|
||||
{activeSection.key === 'integrations' && activeGroup ? (
|
||||
@@ -5064,7 +5158,7 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
run: () => requestAction('重置 Prompt', 'post', `/settings/ai-prompts/${encodeURIComponent(text(record.key, ''))}/reset`),
|
||||
})}><RefreshCw size={15} /></Button>
|
||||
) : null}
|
||||
<Button variant="primary" onClick={() => void saveCurrent()} loading={actionLoading}><Save size={15} />保存</Button>
|
||||
<Button variant="primary" onClick={() => void saveCurrent()} loading={actionLoading}><Save size={15} />{localizeAdminText('保存', locale)}</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="an-panel-body">
|
||||
@@ -5074,12 +5168,12 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
<FieldGrid record={activeGroup.record} draft={hierarchyDraft} onDraftChange={setHierarchyDraft} fields={fields} searchGroupKey={activeGroup.key} />
|
||||
{activeSection.key === 'integrations' && Array.isArray(record.models) && record.models.length ? (
|
||||
<div className="an-sub-list">
|
||||
<strong>可选模型</strong>
|
||||
<strong>{localizeAdminText('可选模型', locale)}</strong>
|
||||
<div>{(record.models as unknown[]).map((model) => <button key={String(model)} type="button" data-admin-search-target={`${activeGroup.key}:model:${String(model)}`} data-admin-search-text={String(model)} onClick={() => setHierarchyDraft(setNestedDraftField(hierarchyDraft, activeGroup.record, 'model', String(model)))}>{String(model)}</button>)}</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : <EmptyState title="暂无分组" description="当前分区没有可配置项。" />}
|
||||
) : <EmptyState title={localizeAdminText('暂无分组', locale)} description={localizeAdminText('当前分区没有可配置项。', locale)} />}
|
||||
</Scrollbar>
|
||||
</div>
|
||||
</Panel>
|
||||
@@ -5361,7 +5455,7 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
</label>
|
||||
<label className="an-field">
|
||||
<span>Feed 名称</span>
|
||||
<input className="an-input" value={text(feed.name, '')} onChange={(event) => setHierarchyDraft(updateNewsFeedDraft(hierarchyDraft, activeGroup.record, index, 'name', event.target.value))} />
|
||||
<input className="an-input" value={text(localizeDisplayValue(feed.name, locale), '')} onChange={(event) => setHierarchyDraft(updateNewsFeedDraft(hierarchyDraft, activeGroup.record, index, 'name', event.target.value))} />
|
||||
</label>
|
||||
<label className="an-field an-field--wide">
|
||||
<span>Feed 地址</span>
|
||||
@@ -5370,13 +5464,13 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
<label className="an-field">
|
||||
<span>Feed 类型</span>
|
||||
<select className="an-input" value={text(feed.type, 'rss')} onChange={(event) => setHierarchyDraft(updateNewsFeedDraft(hierarchyDraft, activeGroup.record, index, 'type', event.target.value))}>
|
||||
{NEWS_SOURCE_TYPE_OPTIONS.filter((option) => option.value !== 'reference').map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||
{NEWS_SOURCE_TYPE_OPTIONS.filter((option) => option.value !== 'reference').map((option) => <option key={option.value} value={option.value}>{localizeAdminText(option.label, locale)}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="an-field">
|
||||
<span>默认新闻类型</span>
|
||||
<select className="an-input" value={text(feed.default_category, text(currentSource.default_category, 'business'))} onChange={(event) => setHierarchyDraft(updateNewsFeedDraft(hierarchyDraft, activeGroup.record, index, 'default_category', event.target.value))}>
|
||||
{categories.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||
{categories.map((option) => <option key={option.value} value={option.value}>{localizeAdminText(option.label, locale)}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="an-field">
|
||||
@@ -5846,13 +5940,13 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
<GroupList groups={groups} activeKey={activeGroup?.key || ''} onSelect={selectHierarchyGroup} footer={hierarchyFooter} />
|
||||
<Panel className="an-hierarchy-detail">
|
||||
<div className="an-mobile-detail-bar">
|
||||
<Button size="icon" variant="subtle" title="返回列表" aria-label="返回列表" onClick={() => setMobileHierarchyDetailOpen(false)}><ArrowLeft size={15} /></Button>
|
||||
<strong>{activeGroup?.label || activeSection.label}</strong>
|
||||
<Button size="icon" variant="subtle" title={localizeAdminText('返回列表', locale)} aria-label={localizeAdminText('返回列表', locale)} onClick={() => setMobileHierarchyDetailOpen(false)}><ArrowLeft size={15} /></Button>
|
||||
<strong>{localizeDisplayValue(activeGroup?.label || activeSection.label, locale)}</strong>
|
||||
</div>
|
||||
<div className="an-panel-heading">
|
||||
<div>
|
||||
<h2 data-admin-search-target={activeGroup?.key} data-admin-search-text={[activeGroup?.label, activeGroup?.description].filter(Boolean).join(' ')}>{activeGroup?.label || activeSection.label}</h2>
|
||||
<p>{activeGroup?.description || '选择左侧父级后编辑它的子配置。'}</p>
|
||||
<h2 data-admin-search-target={activeGroup?.key} data-admin-search-text={localizeSearchParts([activeGroup?.label, activeGroup?.description], locale)}>{localizeDisplayValue(activeGroup?.label || activeSection.label, locale)}</h2>
|
||||
<p>{localizeDisplayValue(activeGroup?.description || '选择左侧父级后编辑它的子配置。', locale)}</p>
|
||||
</div>
|
||||
<div className="an-toolbar">
|
||||
{config === configs.earthContent && activeSection.key === 'brand' ? (
|
||||
@@ -6076,7 +6170,7 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
<section className="an-field-cluster">
|
||||
<div className="an-field-cluster__heading">
|
||||
<h3>Time Capsule</h3>
|
||||
<p>{snapshotTimeline.length} 个历史快照,选择后查看该版本详情。</p>
|
||||
<p>{localizeAdminText(`${snapshotTimeline.length} 个历史快照,选择后查看该版本详情。`, locale)}</p>
|
||||
</div>
|
||||
<div className="an-time-capsule__controls">
|
||||
<label className="an-field an-field--wide">
|
||||
@@ -6858,16 +6952,16 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
return (
|
||||
<AdminLayout>
|
||||
<PageFrame
|
||||
title={config.title}
|
||||
description={config.description}
|
||||
title={localizeAdminText(config.title, locale)}
|
||||
description={localizeAdminText(config.description, locale)}
|
||||
className="an-resource-page"
|
||||
actions={(
|
||||
<>
|
||||
<Button size="icon" variant="subtle" onClick={() => void load()} loading={loading} title="刷新" aria-label="刷新"><RefreshCw size={15} /></Button>
|
||||
<Button size="icon" variant="subtle" onClick={() => void load()} loading={loading} title={localizeAdminText('刷新', locale)} aria-label={localizeAdminText('刷新', locale)}><RefreshCw size={15} /></Button>
|
||||
{renderCollectionQueueAction()}
|
||||
{moduleActions}
|
||||
{config.actions.map((action) => (
|
||||
<Button key={action.label} asChild size="icon" variant="subtle" title={action.label} aria-label={action.label}>
|
||||
<Button key={action.label} asChild size="icon" variant="subtle" title={localizeAdminText(action.label, locale)} aria-label={localizeAdminText(action.label, locale)}>
|
||||
<Link to={action.to}>{action.icon}</Link>
|
||||
</Button>
|
||||
))}
|
||||
@@ -6876,9 +6970,9 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
>
|
||||
<div className="an-page-grid">
|
||||
<SummaryStrip>
|
||||
<StatCell label="接口在线" value={`${summary.online}/${states.length || 1}`} hint="已加载分区" />
|
||||
<StatCell label="记录数" value={summary.rows} hint="已加载分区汇总" />
|
||||
<StatCell label="异常接口" value={summary.failing} hint="失败时页面仍可操作" />
|
||||
<StatCell label={localizeAdminText('接口在线', locale)} value={`${summary.online}/${states.length || 1}`} hint={localizeAdminText('已加载分区', locale)} />
|
||||
<StatCell label={localizeAdminText('记录数', locale)} value={summary.rows} hint={localizeAdminText('已加载分区汇总', locale)} />
|
||||
<StatCell label={localizeAdminText('异常接口', locale)} value={summary.failing} hint={localizeAdminText('失败时页面仍可操作', locale)} />
|
||||
</SummaryStrip>
|
||||
|
||||
<SectionTabs sections={config.sections} states={states} activeKey={activeSection?.key || ''} onChange={handleSectionChange} />
|
||||
@@ -6894,10 +6988,10 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
<Panel className="an-resource-main">
|
||||
<div className="an-panel-heading">
|
||||
<div>
|
||||
<h2>{activeSection?.label || config.listTitle}</h2>
|
||||
<p>{config.listDescription}</p>
|
||||
<h2>{localizeAdminText(activeSection?.label || config.listTitle, locale)}</h2>
|
||||
<p>{localizeAdminText(config.listDescription, locale)}</p>
|
||||
</div>
|
||||
<StatusText tone={loading ? 'running' : activeState?.ok === false ? 'danger' : 'success'}>{loading ? '同步中' : activeState?.ok === false ? '接口失败' : '已就绪'}</StatusText>
|
||||
<StatusText tone={loading ? 'running' : activeState?.ok === false ? 'danger' : 'success'}>{localizeAdminText(loading ? '同步中' : activeState?.ok === false ? '接口失败' : '已就绪', locale)}</StatusText>
|
||||
</div>
|
||||
<div className="an-panel-body">
|
||||
{loading || rows.length > 0 ? (
|
||||
@@ -6911,11 +7005,11 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
selectedRowIds: datasourceSelectedRowIds,
|
||||
onToggleAllVisible: toggleAllVisibleDatasourceSelection,
|
||||
onToggleRow: toggleDatasourceSelection,
|
||||
getCheckboxLabel: (row) => `选择${recordTitle(row)}`,
|
||||
getCheckboxLabel: (row) => localizeAdminText(`选择${recordTitle(row)}`, locale),
|
||||
} : undefined}
|
||||
/>
|
||||
) : (
|
||||
<EmptyState title={loading ? '正在加载数据' : '当前分区暂无记录'} description="切换上方分区可精准查看不同配置和接口。" />
|
||||
<EmptyState title={localizeAdminText(loading ? '正在加载数据' : '当前分区暂无记录', locale)} description={localizeAdminText('切换上方分区可精准查看不同配置和接口。', locale)} />
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
@@ -6930,20 +7024,20 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
|
||||
<div className="an-resource-side">
|
||||
<div className="an-mobile-detail-bar">
|
||||
<Button size="icon" variant="subtle" title="返回列表" aria-label="返回列表" onClick={() => setMobileResourceDetailOpen(false)}><ArrowLeft size={15} /></Button>
|
||||
<strong>{selected ? recordTitle(selected) : config.detailTitle}</strong>
|
||||
<Button size="icon" variant="subtle" title={localizeAdminText('返回列表', locale)} aria-label={localizeAdminText('返回列表', locale)} onClick={() => setMobileResourceDetailOpen(false)}><ArrowLeft size={15} /></Button>
|
||||
<strong>{selected ? localizeDisplayValue(recordTitle(selected), locale) : localizeAdminText(config.detailTitle, locale)}</strong>
|
||||
</div>
|
||||
<DetailPanel title={config.detailTitle} description={selected ? selected.__endpointLabel : '未选择记录'}>
|
||||
<DetailPanel title={localizeAdminText(config.detailTitle, locale)} description={localizeDisplayValue(selected ? selected.__endpointLabel : '未选择记录', locale)}>
|
||||
{selected ? (
|
||||
<div className="an-detail-content">
|
||||
<header className="an-detail-title">
|
||||
<div className="an-detail-title__main">
|
||||
{selectedHistory.length ? (
|
||||
<Button size="icon" variant="subtle" icon="back" title="返回上一级详情" aria-label="返回上一级详情" onClick={goBackResourceDetail} />
|
||||
<Button size="icon" variant="subtle" icon="back" title={localizeAdminText('返回上一级详情', locale)} aria-label={localizeAdminText('返回上一级详情', locale)} onClick={goBackResourceDetail} />
|
||||
) : null}
|
||||
<h3>{recordTitle(selected)}</h3>
|
||||
<h3>{localizeDisplayValue(recordTitle(selected), locale)}</h3>
|
||||
</div>
|
||||
<StatusText tone={statusTone(recordDisplayStatus(selected))}>{recordDisplayStatus(selected)}</StatusText>
|
||||
<StatusText tone={statusTone(recordDisplayStatus(selected))}>{localizeDisplayValue(recordDisplayStatus(selected), locale)}</StatusText>
|
||||
</header>
|
||||
{renderRecordActions()}
|
||||
{renderDatasourceTaskSummary()}
|
||||
@@ -6951,8 +7045,8 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
{renderStructuredEditor() || <DetailFields record={selected} />}
|
||||
<div className="an-code-section">
|
||||
<div className="an-code-section__header">
|
||||
<strong>{selected.__endpointKey === 'logSnapshot' ? '日志正文' : '原始数据'}</strong>
|
||||
<Button size="icon" variant="subtle" onClick={copySelected} title="复制原始数据" aria-label="复制原始数据"><Copy size={14} /></Button>
|
||||
<strong>{localizeAdminText(selected.__endpointKey === 'logSnapshot' ? '日志正文' : '原始数据', locale)}</strong>
|
||||
<Button size="icon" variant="subtle" onClick={copySelected} title={localizeAdminText('复制原始数据', locale)} aria-label={localizeAdminText('复制原始数据', locale)}><Copy size={14} /></Button>
|
||||
</div>
|
||||
<Scrollbar className="an-code-scroll">
|
||||
<pre className="an-json-view">{selected.__endpointKey === 'logSnapshot' && Array.isArray(selected.lines) ? (selected.lines as unknown[]).join('\n') : formatRaw(cleanRecord(selected))}</pre>
|
||||
@@ -6960,7 +7054,7 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState title="选择一条记录" description="详情会在右侧完整滚动显示,不会挤压主表区域。" />
|
||||
<EmptyState title={localizeAdminText('选择一条记录', locale)} description={localizeAdminText('详情会在右侧完整滚动显示,不会挤压主表区域。', locale)} />
|
||||
)}
|
||||
</DetailPanel>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useEffect, useMemo, useState } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { z } from 'zod'
|
||||
import { describeApiError } from '../../i18n/api-errors'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import { DataTable } from '../components/data-table/DataTable'
|
||||
import { AdminLayout } from '../components/layout/AdminLayout'
|
||||
@@ -134,8 +135,7 @@ export default function Users() {
|
||||
setModalVisible(false)
|
||||
void fetchUsers()
|
||||
} catch (error: unknown) {
|
||||
const err = error as { response?: { data?: { detail?: string } } }
|
||||
toast({ tone: 'error', title: t('common.operationFailed'), description: err.response?.data?.detail || t('users.retryLater') })
|
||||
toast({ tone: 'error', title: t('common.operationFailed'), description: describeApiError(error, t('users.retryLater')) })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,8 +147,7 @@ export default function Users() {
|
||||
setDeleteTarget(null)
|
||||
void fetchUsers()
|
||||
} catch (error: unknown) {
|
||||
const err = error as { response?: { data?: { detail?: string } } }
|
||||
toast({ tone: 'error', title: t('users.deleteFailed'), description: err.response?.data?.detail || t('users.retryLater') })
|
||||
toast({ tone: 'error', title: t('users.deleteFailed'), description: describeApiError(error, t('users.retryLater')) })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -123,7 +123,7 @@ body:has(.admin-theme-root[data-theme='dark']) .an-toast {
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: 236px minmax(0, 1fr);
|
||||
grid-template-columns: 264px minmax(0, 1fr);
|
||||
background: var(--an-bg);
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -151,19 +151,33 @@ body:has(.admin-theme-root[data-theme='dark']) .an-toast {
|
||||
height: 58px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 10px;
|
||||
padding: 0 14px;
|
||||
border-bottom: 1px solid var(--an-border);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.admin__brand-copy {
|
||||
display: grid;
|
||||
justify-items: start;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.admin__brand-text {
|
||||
font-weight: 700;
|
||||
font-size: 16px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin__brand-subtitle {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin__brand-subtitle,
|
||||
@@ -183,19 +197,29 @@ body:has(.admin-theme-root[data-theme='dark']) .an-toast {
|
||||
}
|
||||
|
||||
.admin__nav {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 10px;
|
||||
display: grid;
|
||||
justify-items: stretch;
|
||||
gap: 6px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.admin__nav-group {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
justify-items: stretch;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.admin__nav-group-button,
|
||||
.admin__nav-link {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--an-text);
|
||||
@@ -204,11 +228,28 @@ body:has(.admin-theme-root[data-theme='dark']) .an-toast {
|
||||
padding: 0 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 10px;
|
||||
text-align: left;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.admin__nav-group-button svg,
|
||||
.admin__nav-link svg {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.admin__nav-group-button span,
|
||||
.admin__nav-link span {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-align: left;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin__nav-group-button:hover,
|
||||
.admin__nav-link:hover,
|
||||
.admin__nav-link.is-active {
|
||||
@@ -220,7 +261,11 @@ body:has(.admin-theme-root[data-theme='dark']) .an-toast {
|
||||
}
|
||||
|
||||
.admin__nav-children {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
justify-items: stretch;
|
||||
gap: 3px;
|
||||
padding-left: 22px;
|
||||
}
|
||||
@@ -236,6 +281,7 @@ body:has(.admin-theme-root[data-theme='dark']) .an-toast {
|
||||
|
||||
.admin__nav-chevron {
|
||||
margin-left: auto;
|
||||
flex: 0 0 auto;
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
@@ -250,14 +296,16 @@ body:has(.admin-theme-root[data-theme='dark']) .an-toast {
|
||||
background: color-mix(in srgb, var(--an-bg) 42%, var(--an-surface));
|
||||
display: grid;
|
||||
gap: 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.admin__account-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
justify-content: flex-start;
|
||||
gap: 12px;
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.admin__account-row--primary {
|
||||
@@ -274,19 +322,23 @@ body:has(.admin-theme-root[data-theme='dark']) .an-toast {
|
||||
display: flex !important;
|
||||
grid-template-columns: none;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
margin-right: 6px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.admin__account-row .admin__account-profile {
|
||||
gap: 18px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.admin__account-profile > div {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
justify-items: start;
|
||||
gap: 1px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.admin__account-avatar {
|
||||
@@ -314,6 +366,12 @@ body:has(.admin-theme-root[data-theme='dark']) .an-toast {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin__account-profile span:not(.admin__account-avatar) {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin__account-actions {
|
||||
display: flex !important;
|
||||
grid-template-columns: none;
|
||||
@@ -321,6 +379,7 @@ body:has(.admin-theme-root[data-theme='dark']) .an-toast {
|
||||
justify-content: flex-end;
|
||||
flex: 0 0 auto;
|
||||
gap: 4px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.admin__account-logout,
|
||||
@@ -390,7 +449,9 @@ body:has(.admin-theme-root[data-theme='dark']) .an-toast {
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--an-bg) 62%, var(--an-surface));
|
||||
display: grid;
|
||||
justify-items: stretch;
|
||||
gap: 8px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.admin__theme-control--sider {
|
||||
@@ -438,6 +499,7 @@ body:has(.admin-theme-root[data-theme='dark']) .an-toast {
|
||||
font-size: calc(10px * var(--segmented-control-scale, 1));
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.admin__logout {
|
||||
@@ -2210,6 +2272,31 @@ body:has(.admin-theme-root[data-theme='dark']) .an-toast {
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.an-earth-brand-preview .hud-panel-brand .earth-brand--en {
|
||||
--brand-copy-width: 172px;
|
||||
}
|
||||
|
||||
.an-earth-brand-preview .hud-panel-brand .earth-brand--en .earth-brand__subtitle,
|
||||
.an-earth-brand-preview .hud-panel-brand .earth-brand--en .earth-brand__description {
|
||||
font-family: "Roboto Condensed", "Arial Narrow", "Trebuchet MS", "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
|
||||
overflow: visible;
|
||||
text-overflow: clip;
|
||||
white-space: normal;
|
||||
word-break: normal;
|
||||
overflow-wrap: normal;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.an-earth-brand-preview .hud-panel-brand .earth-brand--en .earth-brand__subtitle {
|
||||
font-size: calc(0.58rem * var(--hud-scale) * var(--brand-scale));
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.an-earth-brand-preview .hud-panel-brand .earth-brand--en .earth-brand__description {
|
||||
font-size: calc(0.5rem * var(--hud-scale) * var(--brand-scale));
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.an-tv-earth-preview {
|
||||
--hud-scale: 0.82;
|
||||
--hud-gap-xs: calc(6px * var(--hud-scale));
|
||||
@@ -4006,12 +4093,16 @@ body:has(.admin-theme-root[data-theme='dark']) .an-toast {
|
||||
}
|
||||
|
||||
.an-segmented button {
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--an-muted);
|
||||
border-radius: 4px;
|
||||
padding: 0 10px;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.an-segmented button.is-active {
|
||||
|
||||
@@ -41,6 +41,7 @@ const legacyTextPatternsEnUS: LegacyTextPattern[] = [
|
||||
{ match: /^已导出\s+(.+)$/, replace: (match) => `Exported ${match[1]}` },
|
||||
{ match: /^(.+)\s+采集失败$/, replace: (match) => `${match[1]} collection failed` },
|
||||
{ match: /^(.+)\s+采集已取消$/, replace: (match) => `${match[1]} collection cancelled` },
|
||||
{ match: /^选择(.+)$/, replace: (match) => `Select ${match[1]}` },
|
||||
]
|
||||
|
||||
const legacyTextPatternsZhCN: LegacyTextPattern[] = [
|
||||
@@ -69,6 +70,7 @@ const legacyTextPatternsZhCN: LegacyTextPattern[] = [
|
||||
{ match: /^Exported\s+(.+)$/, replace: (match) => `已导出 ${match[1]}` },
|
||||
{ match: /^(.+)\s+collection failed$/, replace: (match) => `${match[1]} 采集失败` },
|
||||
{ match: /^(.+)\s+collection cancelled$/, replace: (match) => `${match[1]} 采集已取消` },
|
||||
{ match: /^Select\s+(.+)$/, replace: (match) => `选择${match[1]}` },
|
||||
]
|
||||
|
||||
function preserveOuterWhitespace(source: string, replacement: string) {
|
||||
@@ -94,7 +96,14 @@ function translateText(value: string, locale: string) {
|
||||
const dictionary = normalizeLocale(locale) === 'en-US' ? legacyUiTextEnUS : reverseLegacyUiText
|
||||
const replacement = dictionary[text]
|
||||
if (replacement) return preserveOuterWhitespace(value, replacement)
|
||||
return translatePatternText(value, locale)
|
||||
const patternTranslated = translatePatternText(value, locale)
|
||||
if (patternTranslated !== value) return patternTranslated
|
||||
if (text.length > 240) return value
|
||||
const entries = Object.entries(dictionary)
|
||||
.filter(([source]) => source && text.includes(source))
|
||||
.sort(([left], [right]) => right.length - left.length)
|
||||
if (!entries.length) return value
|
||||
return entries.reduce((next, [source, target]) => next.split(source).join(target), value)
|
||||
}
|
||||
|
||||
function translateElementAttributes(element: Element, locale: string) {
|
||||
|
||||
93
frontend/src/i18n/api-errors.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import i18n from './index'
|
||||
import { legacyUiTextEnUS } from './legacy-ui'
|
||||
import { normalizeLocale } from './locale'
|
||||
|
||||
type ApiErrorPayload = {
|
||||
detail?: unknown
|
||||
error?: unknown
|
||||
message?: unknown
|
||||
}
|
||||
|
||||
type ApiErrorLike = {
|
||||
message?: unknown
|
||||
response?: {
|
||||
data?: ApiErrorPayload
|
||||
}
|
||||
}
|
||||
|
||||
const cjkPattern = /[\u3400-\u9fff]/
|
||||
const genericEnglishFallback = 'Operation failed'
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value && typeof value === 'object' && !Array.isArray(value))
|
||||
}
|
||||
|
||||
function currentLocale() {
|
||||
return normalizeLocale(i18n.resolvedLanguage || i18n.language)
|
||||
}
|
||||
|
||||
export function hasCjkText(value: string) {
|
||||
return cjkPattern.test(value)
|
||||
}
|
||||
|
||||
function englishFallbackFor(fallback: string) {
|
||||
const normalized = fallback.trim()
|
||||
if (!normalized) return genericEnglishFallback
|
||||
if (!hasCjkText(normalized)) return normalized
|
||||
return legacyUiTextEnUS[normalized] || genericEnglishFallback
|
||||
}
|
||||
|
||||
function stringFromApiValue(value: unknown): string {
|
||||
if (value === null || value === undefined || value === '') return ''
|
||||
if (typeof value === 'string') return value
|
||||
if (typeof value === 'number' || typeof value === 'boolean') return String(value)
|
||||
if (Array.isArray(value)) {
|
||||
const firstMessage = value
|
||||
.map((item) => stringFromApiValue(item))
|
||||
.find(Boolean)
|
||||
return firstMessage || ''
|
||||
}
|
||||
if (isRecord(value)) {
|
||||
const directMessage = stringFromApiValue(value.message || value.detail || value.error)
|
||||
if (directMessage) return directMessage
|
||||
const code = stringFromApiValue(value.code)
|
||||
if (code) return code
|
||||
try {
|
||||
return JSON.stringify(value)
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
export function localizeApiMessage(message: string, fallback = genericEnglishFallback) {
|
||||
const normalized = message.trim()
|
||||
if (currentLocale() !== 'en-US') return normalized || fallback
|
||||
const englishFallback = englishFallbackFor(fallback)
|
||||
if (!normalized) return englishFallback
|
||||
const exactLegacy = legacyUiTextEnUS[normalized]
|
||||
if (exactLegacy) return exactLegacy
|
||||
if (hasCjkText(normalized)) return englishFallback
|
||||
return normalized
|
||||
}
|
||||
|
||||
export function describeApiValue(value: unknown, fallback = genericEnglishFallback) {
|
||||
return localizeApiMessage(stringFromApiValue(value), fallback)
|
||||
}
|
||||
|
||||
export function describeApiError(error: unknown, fallback = genericEnglishFallback) {
|
||||
if (isRecord(error)) {
|
||||
const apiError = error as ApiErrorLike
|
||||
const payload = apiError.response?.data
|
||||
if (payload) {
|
||||
const responseValue = payload.detail ?? payload.message ?? payload.error
|
||||
const responseMessage = describeApiValue(responseValue, fallback)
|
||||
if (responseMessage) return responseMessage
|
||||
}
|
||||
const errorMessage = stringFromApiValue(apiError.message)
|
||||
if (errorMessage) return localizeApiMessage(errorMessage, fallback)
|
||||
}
|
||||
if (error instanceof Error) return localizeApiMessage(error.message, fallback)
|
||||
return localizeApiMessage('', fallback)
|
||||
}
|
||||
@@ -18,7 +18,6 @@ export const legacyUiTextEnUS: Record<string, string> = {
|
||||
'BGP观测': 'BGP Observatory',
|
||||
'Feed 信息页': 'Feed info page',
|
||||
'Feed 地址': 'Feed URL',
|
||||
'Feed 子项': 'Feed entries',
|
||||
'Feed 标签': 'Feed tags',
|
||||
'Feed 类型': 'Feed type',
|
||||
'Feed 名称': 'Feed name',
|
||||
@@ -46,6 +45,7 @@ export const legacyUiTextEnUS: Record<string, string> = {
|
||||
'供应商': 'Provider',
|
||||
'供应商状态': 'Provider status',
|
||||
'供应商配置': 'Provider configuration',
|
||||
'事件': 'Event',
|
||||
'保存': 'Save',
|
||||
'保存并重试': 'Save and retry',
|
||||
'保存后会进入清洗、翻译、分类和定位队列。': 'After saving, the item enters the cleaning, translation, classification, and geocoding queue.',
|
||||
@@ -74,10 +74,15 @@ export const legacyUiTextEnUS: Record<string, string> = {
|
||||
'其他': 'Other',
|
||||
'刷新': 'Refresh',
|
||||
'刷新当前 Provider 的模型配置': 'Refresh current provider model configuration',
|
||||
'刷新间隔(秒)': 'Refresh interval (seconds)',
|
||||
'刷新模型': 'Refresh models',
|
||||
'刷新模型列表': 'Refresh model list',
|
||||
'刷新线程': 'Refresh thread',
|
||||
'分类': 'Category',
|
||||
'副标题': 'Subtitle',
|
||||
'标题图地址': 'Title image URL',
|
||||
'标题图替代文本': 'Title image alt text',
|
||||
'标题文字': 'Title text',
|
||||
'删除 Feed 子项': 'Delete feed entry',
|
||||
'删除': 'Delete',
|
||||
'删除失败': 'Delete failed',
|
||||
@@ -99,6 +104,9 @@ export const legacyUiTextEnUS: Record<string, string> = {
|
||||
'启动实时源': 'Start realtime source',
|
||||
'启动边界构建': 'Start boundary build',
|
||||
'启用': 'Enabled',
|
||||
'启用 WebSearch': 'Enable WebSearch',
|
||||
'启用 OCR': 'Enable OCR',
|
||||
'启用邮件通知': 'Enable email notifications',
|
||||
'启用抓取': 'Enable fetching',
|
||||
'启用映射': 'Enable mapping',
|
||||
'启用筛选': 'Active filters',
|
||||
@@ -106,6 +114,7 @@ export const legacyUiTextEnUS: Record<string, string> = {
|
||||
'告警记录': 'Alert records',
|
||||
'告警统计': 'Alert stats',
|
||||
'名称': 'Name',
|
||||
'认证方式': 'Auth method',
|
||||
'后台账号': 'Console account',
|
||||
'回到首页': 'Back to overview',
|
||||
'国界精度': 'Boundary accuracy',
|
||||
@@ -123,6 +132,7 @@ export const legacyUiTextEnUS: Record<string, string> = {
|
||||
'已加载分区汇总': 'Loaded section total',
|
||||
'工具调用': 'Tool calls',
|
||||
'已保存': 'Saved',
|
||||
'已保存密钥不回传明文,输入新值可替换': 'Saved secrets are not returned; enter a new value to replace it',
|
||||
'已取消': 'Cancelled',
|
||||
'已处理': 'Resolved',
|
||||
'已提交': 'Submitted',
|
||||
@@ -134,6 +144,7 @@ export const legacyUiTextEnUS: Record<string, string> = {
|
||||
'已停用': 'Disabled',
|
||||
'已就绪': 'Ready',
|
||||
'已跳过': 'Skipped',
|
||||
'有效': 'Valid',
|
||||
'已有新闻': 'Existing news',
|
||||
'已有任务运行': 'Task already running',
|
||||
'已有账号,去登录': 'Already have an account? Log in',
|
||||
@@ -161,7 +172,9 @@ export const legacyUiTextEnUS: Record<string, string> = {
|
||||
'接口在线': 'Endpoints online',
|
||||
'接口失败': 'Endpoint failed',
|
||||
'接口请求失败': 'Endpoint request failed',
|
||||
'接口路径': 'Endpoint path',
|
||||
'提示': 'Info',
|
||||
'操作失败': 'Operation failed',
|
||||
'控制台不混入其他配置或假数据。': 'The console does not mix in unrelated configuration or mock data.',
|
||||
'控制台发生错误,请刷新页面重试。': 'The console encountered an error. Please refresh and try again.',
|
||||
'控制台渲染错误': 'Console render error',
|
||||
@@ -172,8 +185,8 @@ export const legacyUiTextEnUS: Record<string, string> = {
|
||||
'提示词': 'Prompts',
|
||||
'搜索日志正文': 'Search log text',
|
||||
'搜索名称、描述、元数据等': 'Search name, description, metadata, and more',
|
||||
'搜索': 'Search',
|
||||
'搜索供应商': 'Search provider',
|
||||
'搜索': 'Search',
|
||||
'搜索深度': 'Search depth',
|
||||
'搜索用户、邮箱、角色': 'Search users, email, or role',
|
||||
'数据源': 'Datasources',
|
||||
@@ -196,6 +209,7 @@ export const legacyUiTextEnUS: Record<string, string> = {
|
||||
'采集时间': 'Collected at',
|
||||
'采集已取消': 'Collection cancelled',
|
||||
'采集失败': 'Collection failed',
|
||||
'采集成功': 'Collection succeeded',
|
||||
'采集完成': 'Collection complete',
|
||||
'采集中': 'Collecting',
|
||||
'采集管理': 'Collection Management',
|
||||
@@ -256,6 +270,8 @@ export const legacyUiTextEnUS: Record<string, string> = {
|
||||
'显示 LLM API Key / Service Token': 'Show LLM API Key / Service Token',
|
||||
'显示': 'Display',
|
||||
'智能星球': 'Intelligent Planet',
|
||||
'智能星球计划': 'Intelligent Planet Program',
|
||||
'智能星球计划品牌标识': 'Intelligent Planet Program brand banner',
|
||||
'智能星球内容配置': 'Intelligent Planet content configuration',
|
||||
'智能星球配置详情': 'Intelligent Planet configuration details',
|
||||
'智能星球内容': 'Planet Content',
|
||||
@@ -301,9 +317,10 @@ export const legacyUiTextEnUS: Record<string, string> = {
|
||||
'源属性': 'Source attributes',
|
||||
'源详情': 'Source details',
|
||||
'源健康': 'Source health',
|
||||
'来源': 'Source',
|
||||
'区域': 'Region',
|
||||
'按数据源': 'By datasource',
|
||||
'按类型': 'By type',
|
||||
'按数据源': 'Source',
|
||||
'按类型': 'Type',
|
||||
'排序': 'Sort order',
|
||||
'单条添加': 'Add one item',
|
||||
'单源配置': 'Single-source configuration',
|
||||
@@ -328,8 +345,10 @@ export const legacyUiTextEnUS: Record<string, string> = {
|
||||
'结果': 'Results',
|
||||
'系统告警列表': 'System alert list',
|
||||
'系统告警': 'System Alerts',
|
||||
'系统数据库日志': 'System database logs',
|
||||
'系统日志': 'System Logs',
|
||||
'系统显示': 'System display',
|
||||
'系统名称': 'System name',
|
||||
'系统设置': 'System Settings',
|
||||
'系统总览与实时态势': 'System overview and realtime status',
|
||||
'设置': 'Settings',
|
||||
@@ -343,6 +362,7 @@ export const legacyUiTextEnUS: Record<string, string> = {
|
||||
'选择左侧父级后编辑它的子配置。': 'Select a parent item on the left to edit its child configuration.',
|
||||
'选择日志源后读取快照。': 'Select a log source to read its snapshot.',
|
||||
'选择新闻直播源': 'Select news stream source',
|
||||
'层级': 'Level',
|
||||
'纬度': 'Latitude',
|
||||
'经度': 'Longitude',
|
||||
'统计': 'Stats',
|
||||
@@ -354,6 +374,7 @@ export const legacyUiTextEnUS: Record<string, string> = {
|
||||
'网络': 'Network',
|
||||
'自定义': 'Custom',
|
||||
'自定义源': 'Custom sources',
|
||||
'演示模式': 'Demo mode',
|
||||
'自治系统统计': 'Autonomous system stats',
|
||||
'自动回退': 'Auto fallback',
|
||||
'英文标题': 'English title',
|
||||
@@ -362,6 +383,7 @@ export const legacyUiTextEnUS: Record<string, string> = {
|
||||
'英文分类': 'English category',
|
||||
'草稿': 'Draft',
|
||||
'警告': 'Warning',
|
||||
'警告告警通知': 'Warning alerts',
|
||||
'设备统计': 'Device stats',
|
||||
'触发全部': 'Trigger all',
|
||||
'触发采集': 'Trigger collection',
|
||||
@@ -375,6 +397,7 @@ export const legacyUiTextEnUS: Record<string, string> = {
|
||||
'请稍后重试': 'Please try again later',
|
||||
'连接失败': 'Connection failed',
|
||||
'连接中': 'Connecting',
|
||||
'正在连接': 'Connecting',
|
||||
'连接测试': 'Connection test',
|
||||
'连通性': 'Connectivity',
|
||||
'连通正常': 'Connectivity normal',
|
||||
@@ -390,6 +413,7 @@ export const legacyUiTextEnUS: Record<string, string> = {
|
||||
'返回上一级详情': 'Back to parent details',
|
||||
'返回列表': 'Back to list',
|
||||
'通知策略': 'Notification policy',
|
||||
'通知邮箱': 'Notification email',
|
||||
'配置错误': 'Configuration error',
|
||||
'配置源': 'Configuration source',
|
||||
'重要度与健康策略': 'Importance and health policy',
|
||||
@@ -435,11 +459,17 @@ export const legacyUiTextEnUS: Record<string, string> = {
|
||||
'Docker 不可用': 'Docker unavailable',
|
||||
'GPU 集群': 'GPU clusters',
|
||||
'HTTP 失败': 'HTTP failed',
|
||||
'Anthropic 版本': 'Anthropic version',
|
||||
'Earth 左上角品牌实际渲染预览': 'Rendered preview of the top-left Earth brand',
|
||||
'LLM 基础地址': 'LLM base URL',
|
||||
'Logo 替代文本': 'Logo alt text',
|
||||
'当前分区没有可用后端能力,控制台不混入其他配置或假数据。': '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',
|
||||
'当前配置可以连通。': 'Current configuration can connect.',
|
||||
'当前配置连通性检查失败。': 'Configuration connectivity check failed.',
|
||||
'当前采集源没有可查看的历史版本。': 'This collection source has no historical versions.',
|
||||
'待定位': 'Pending location',
|
||||
'后端能力未提供': 'Backend capability unavailable',
|
||||
@@ -454,6 +484,11 @@ export const legacyUiTextEnUS: Record<string, string> = {
|
||||
'只重启前端开发服务,页面会短暂不可用,恢复后自动刷新。': 'Restart only the frontend dev service. The page will be briefly unavailable and refresh after recovery.',
|
||||
'失败时页面仍可操作': 'Page remains usable when requests fail',
|
||||
'打开': 'Open',
|
||||
'观测台': 'Observatory',
|
||||
'概览': 'Overview',
|
||||
'概览摘要': 'Overview summary',
|
||||
'告警详情': 'Alert details',
|
||||
'查看智能星球图层缓存': 'View Intelligent Planet layer cache',
|
||||
'描述来源属性,不是媒体来源名;多个标签用逗号分隔,例如 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.',
|
||||
@@ -462,6 +497,7 @@ export const legacyUiTextEnUS: Record<string, string> = {
|
||||
'管理系统显示、通知策略、安全策略和 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',
|
||||
'严重告警通知': '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.',
|
||||
@@ -476,6 +512,39 @@ export const legacyUiTextEnUS: Record<string, string> = {
|
||||
'仅展示数据源相关接口,不混入其他设置对象。': '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',
|
||||
'触发全部数据源': 'Trigger all datasources',
|
||||
'数据源任务,暂无任务': 'Datasource tasks, no tasks',
|
||||
'生成 BGP AI 简报': 'Generate BGP AI brief',
|
||||
'生成系统告警 AI 简报': 'Generate system alert AI brief',
|
||||
'生成态势告警 AI 简报': 'Generate situational alert AI brief',
|
||||
'生成简报': 'Generate brief',
|
||||
'模块': 'Module',
|
||||
'模块分区': 'Module sections',
|
||||
'指标': 'Metric',
|
||||
'支持 png, jpg, jpeg, webp, svg': 'Supports png, jpg, jpeg, webp, svg',
|
||||
'协议适配': 'Protocol adapter',
|
||||
'代理 Token': 'Proxy token',
|
||||
'代理地址': 'Proxy URL',
|
||||
'设为默认': 'Set as default',
|
||||
'输入新的 LLM API Key': 'Enter new LLM API key',
|
||||
'输入新的代理 Token': 'Enter new proxy token',
|
||||
'显示LLM API Key': 'Show LLM API key',
|
||||
'隐藏LLM API Key': 'Hide LLM API key',
|
||||
'显示代理 Token': 'Show proxy token',
|
||||
'隐藏代理 Token': 'Hide proxy token',
|
||||
'显示API Key': 'Show API key',
|
||||
'隐藏API Key': 'Hide API key',
|
||||
'最大输出 Tokens': 'Max output tokens',
|
||||
'超时(秒)': 'Timeout (seconds)',
|
||||
'拖动调整详情宽度': 'Drag to resize details',
|
||||
'异常': 'Anomaly',
|
||||
'播放类型': 'Playback type',
|
||||
'上次状态': 'Last status',
|
||||
'上次执行': 'Last run',
|
||||
'需要凭证': 'Requires credentials',
|
||||
'凭证提供方': 'Credential provider',
|
||||
'凭证教程': 'Credential guide',
|
||||
'接口地址': 'Endpoint URL',
|
||||
'输入要发送给 AI 的内容': 'Enter content to send to AI',
|
||||
'发送': 'Send',
|
||||
'会话': 'Conversation',
|
||||
@@ -485,6 +554,22 @@ export const legacyUiTextEnUS: Record<string, string> = {
|
||||
'目标': 'Objective',
|
||||
'约束': 'Constraints',
|
||||
'描述': 'Description',
|
||||
'每日摘要': 'Daily digest',
|
||||
'Logo 地址': 'Logo URL',
|
||||
'API 基础地址': 'API base URL',
|
||||
'Firecrawl 抓取路径': 'Firecrawl scrape path',
|
||||
'Firecrawl 搜索路径': 'Firecrawl search path',
|
||||
'SearXNG 分类': 'SearXNG categories',
|
||||
'SerpAPI 引擎': 'SerpAPI engine',
|
||||
'OCR 基础地址': 'OCR base URL',
|
||||
'OCR 供应商': 'OCR provider',
|
||||
'包含答案': 'Include answer',
|
||||
'包含原始内容': 'Include raw content',
|
||||
'包含正文': 'Include text',
|
||||
'输入新的 OCR API Key': 'Enter new OCR API key',
|
||||
'输入新的 WebSearch API Key': 'Enter new WebSearch API key',
|
||||
'每个搜索 provider 有独立 API 与高级参数': 'Each search provider has its own API and advanced parameters',
|
||||
'OCR provider、模型、语言和文件限制': 'OCR provider, model, language, and file limits',
|
||||
'优先级': 'Priority',
|
||||
'边界状态': 'Boundary status',
|
||||
'边界构建': 'Boundary build',
|
||||
@@ -518,6 +603,7 @@ export const legacyUiTextEnUS: Record<string, string> = {
|
||||
'重启 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.',
|
||||
'重启任务正在执行': 'Restart task is running',
|
||||
'服务已恢复,正在刷新页面。': 'Service recovered. Refreshing the page.',
|
||||
'前端已恢复,正在刷新页面。': 'Frontend recovered. Refreshing the page.',
|
||||
'前端正在重启,正在等待页面入口恢复访问。': 'Frontend is restarting. Waiting for the page entry to recover.',
|
||||
@@ -530,6 +616,7 @@ export const legacyUiTextEnUS: Record<string, string> = {
|
||||
'导出 JSON': 'Export JSON',
|
||||
'导出 CSV': 'Export CSV',
|
||||
'数据详情': 'Data details',
|
||||
'数据保留天数': 'Data retention days',
|
||||
'按级别/日期/搜索条件读取快照': '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.',
|
||||
@@ -564,8 +651,6 @@ export const legacyUiTextEnUS: Record<string, string> = {
|
||||
'条结果': 'results',
|
||||
'筛选': 'Filters',
|
||||
'共': 'Total',
|
||||
'智能星球计划': 'Intelligent Planet Plan',
|
||||
'智能星球计划品牌标识': 'Intelligent Planet Plan branding',
|
||||
'现实层宇宙全息感知系统': 'Reality-layer holographic awareness system',
|
||||
'卫星 · 海底光缆 · 算力基础设施': 'Satellites · Submarine cables · Computing infrastructure',
|
||||
'选择/拖入资产': 'Select / drop asset',
|
||||
@@ -586,4 +671,172 @@ export const legacyUiTextEnUS: Record<string, string> = {
|
||||
'卫星': 'Satellite',
|
||||
'算力': 'Compute',
|
||||
'媒体': 'Media',
|
||||
'参考': 'Reference',
|
||||
'参考链接': 'Reference link',
|
||||
'参考链接不参与抓取': 'Reference links are not fetched',
|
||||
'亚太': 'Asia-Pacific',
|
||||
'中国': 'China',
|
||||
'中东与非洲': 'Middle East and Africa',
|
||||
'全球': 'Global',
|
||||
'欧洲': 'Europe',
|
||||
'美国': 'United States',
|
||||
'美洲': 'Americas',
|
||||
'36氪': '36Kr',
|
||||
'亿邦动力': 'Ebrun',
|
||||
'商务数据中心': 'MOFCOM Data Center',
|
||||
'商务部电商动态': 'MOFCOM E-Commerce',
|
||||
'国家统计局数据发布': 'National Bureau of Statistics',
|
||||
'电商物流指数': 'China E-Commerce Logistics Index',
|
||||
'综合资讯': 'General',
|
||||
'文章资讯': 'Articles',
|
||||
'最新快讯': 'Newsflash',
|
||||
'动态内容': 'Updates',
|
||||
'零售': 'Retail',
|
||||
'服务': 'Services',
|
||||
'商业': 'Business',
|
||||
'政治': 'Politics',
|
||||
'金融': 'Finance',
|
||||
'科技': 'Technology',
|
||||
'无条目': 'No entries',
|
||||
'格式错误': 'Format error',
|
||||
'超时': 'Timeout',
|
||||
'尚未测试当前源。': 'This source has not been tested yet.',
|
||||
'来源官网、栏目页或报告页,不作为抓取入口。': 'Official site, section page, or report page. It is not used as the fetch entry.',
|
||||
'默认类型': 'Default category',
|
||||
'重要度权重': 'Importance weight',
|
||||
'抓取间隔(分钟)': 'Fetch interval (minutes)',
|
||||
'失败阈值': 'Failure threshold',
|
||||
'熔断冷却(分钟)': 'Circuit-breaker cooldown (minutes)',
|
||||
'熔断开关': 'Circuit breaker',
|
||||
'组名 / 来源名': 'Group / source name',
|
||||
'组类型': 'Group type',
|
||||
'新闻数量': 'News count',
|
||||
'内容来源': 'Content source',
|
||||
'原文链接': 'Original URL',
|
||||
'缺省区域': 'Default region',
|
||||
'Feed 子项': 'Feed entries',
|
||||
'每个子项都是一个真实 RSS/Atom/Aggregated 抓取入口,可单独启停和设置默认新闻类型。': 'Each entry is a real RSS/Atom/Aggregated fetch entry and can be enabled, disabled, and assigned its own default category.',
|
||||
'添加一个真实 RSS/Atom/Aggregated 地址后才能抓取。': 'Add a real RSS/Atom/Aggregated URL before fetching.',
|
||||
'启用 Feed': 'Enable feed',
|
||||
'停用 Feed': 'Disable feed',
|
||||
'用于新闻排序、抓取频率、超时和熔断控制。': 'Used for news ranking, fetch frequency, timeout, and circuit-breaker control.',
|
||||
'频道身份、状态和排序。': 'Channel identity, status, and sort order.',
|
||||
'播放地址、封面、YouTube 信息和其他高级字段。': 'Playback URL, cover image, YouTube metadata, and other advanced fields.',
|
||||
'配置表单': 'Configuration form',
|
||||
'常用字段直接编辑;复杂对象保留结构化子字段,不再把整条记录只丢进 JSON。': 'Common fields are edited directly; complex objects keep structured child fields instead of sending the whole record into JSON only.',
|
||||
'查看完整 JSON': 'View full JSON',
|
||||
'原始数据': 'Raw data',
|
||||
'日志正文': 'Log text',
|
||||
'正在加载数据': 'Loading data',
|
||||
'同步中': 'Syncing',
|
||||
'就绪': 'Ready',
|
||||
'进度': 'Progress',
|
||||
'取消任务': 'Cancel task',
|
||||
'清空已结束队列项': 'Clear completed queue items',
|
||||
'暂无数据源任务': 'No datasource tasks',
|
||||
'进行': 'Running',
|
||||
'跳过': 'Skipped',
|
||||
'可选模型': 'Available models',
|
||||
'快照版本': 'Snapshot version',
|
||||
'快照摘要': 'Snapshot summary',
|
||||
'采集任务': 'Collection task',
|
||||
'当前没有运行中的任务。': 'No task is currently running.',
|
||||
'没有可用 WebSearch 证据,已保留默认教程。': 'No WebSearch evidence is available, so the default guide was kept.',
|
||||
'首版只支持顶层为数组的 JSON 文件;取消不会清空当前新闻组详情。': 'The first version only supports JSON files whose top-level value is an array. Canceling does not clear the current news group details.',
|
||||
'选择文件': 'Select file',
|
||||
'实时启停': 'Start / stop realtime source',
|
||||
'采集位置': 'Collect location',
|
||||
'拉取详情': 'Fetch details',
|
||||
'预览': 'Preview',
|
||||
'预览映射': 'Preview mapping',
|
||||
'到采集管理编辑': 'Edit in Collection Management',
|
||||
'测试启用源': 'Test enabled sources',
|
||||
'重置默认新闻源': 'Reset default news sources',
|
||||
'重置新闻源': 'Reset news sources',
|
||||
'删除数据': 'Delete data',
|
||||
'清理数据源缓存': 'Clear datasource cache',
|
||||
'停止删除': 'Stop deletion',
|
||||
'停止已选': 'Stop selected',
|
||||
'触发已选': 'Trigger selected',
|
||||
'已取消新增直播源': 'New stream source cancelled',
|
||||
'已取消新增新闻源': 'New news source cancelled',
|
||||
'已取消新增配置': 'New configuration cancelled',
|
||||
'已恢复当前项': 'Current item restored',
|
||||
'表单已恢复到加载时状态。': 'The form has been restored to its loaded state.',
|
||||
'当前分区未找到该数据源': 'Datasource not found in the current section',
|
||||
'可切回内置源或刷新后再查看。': 'Switch back to built-in sources or refresh before viewing it again.',
|
||||
'无法重试': 'Cannot retry',
|
||||
'当前列表中没有找到对应数据源。': 'The matching datasource was not found in the current list.',
|
||||
'读取凭证失败': 'Failed to read credentials',
|
||||
'JSON 格式错误': 'Invalid JSON',
|
||||
'请修正高级编辑内容后再保存。': 'Fix the advanced editor content before saving.',
|
||||
'请修正映射内容后再保存。': 'Fix the mapping content before saving.',
|
||||
'请修正映射内容后再预览。': 'Fix the mapping content before previewing.',
|
||||
'新闻源配置不完整': 'News source configuration is incomplete',
|
||||
'参考链接不能启用抓取,请改为 RSS/Atom/Aggregated 或关闭启用。': 'Reference links cannot be fetched. Use RSS/Atom/Aggregated or disable fetching.',
|
||||
'RSS/Atom/Aggregated 新闻源需要至少一个 Feed 子项。': 'RSS/Atom/Aggregated news sources need at least one feed entry.',
|
||||
'新闻标题不能为空。': 'News title is required.',
|
||||
'坐标必须是数字。': 'Coordinates must be numeric.',
|
||||
'坐标超出范围。': 'Coordinates are out of range.',
|
||||
'新闻源缺少已启用的 Feed 子项。': 'The news source has no enabled feed entries.',
|
||||
'OCR 配置': 'OCR configuration',
|
||||
'个入口': 'entries',
|
||||
'条消息': 'messages',
|
||||
'发送一条消息开始测试 AI 链路。': 'Send a message to test the AI link.',
|
||||
'数据源健康': 'Datasource health',
|
||||
'链路探测': 'Link smoke test',
|
||||
'AI 链路探测': 'AI link smoke test',
|
||||
'BGP 告警态势简报': 'BGP alert situational brief',
|
||||
'总结当前告警的主要风险、优先级与建议动作。': 'Summarize the main risks, priorities, and recommended actions for the current alerts.',
|
||||
'结论要简洁\n优先给出操作建议': 'Keep conclusions concise\nPrioritize operational recommendations',
|
||||
'出现新的高危告警\n部分观测站最近 24h 事件增多\n请先给出风险摘要,再列出建议动作。': 'New high-risk alerts appeared\nSome observatories have more events in the last 24h\nStart with a risk summary, then list recommended actions.',
|
||||
'采集器健康检查说明': 'Collector health check guide',
|
||||
'判断当前采集器失败是否属于上游接口失效、限流、结构变更或临时波动。': 'Determine whether the current collector failure is caused by upstream outage, rate limiting, schema drift, or temporary fluctuation.',
|
||||
'区分事实与推断\n先给排障优先级': 'Separate facts from inference\nPrioritize troubleshooting first',
|
||||
'最近 3 次任务失败\n部分数据源响应时间抬升\n个别接口返回结构不稳定\n请帮我先做排障优先级排序。': 'The last 3 tasks failed\nSome datasources have slower responses\nA few endpoints return unstable structures\nPlease prioritize troubleshooting.',
|
||||
'验证 backend -> aiprovider -> model provider 调用链路是否正常。': 'Verify whether the backend -> aiprovider -> model provider call chain works.',
|
||||
'输出简洁\n包含一段明确结论': 'Keep output concise\nInclude a clear conclusion',
|
||||
'当前从 Playground 发起测试,希望确认 provider 配置和返回结构正常,请直接给我链路结论。': 'This Playground test should confirm provider configuration and response structure. Give the link conclusion directly.',
|
||||
'无障碍标签': 'Accessibility label',
|
||||
'版本': 'Version',
|
||||
'版本号来自当前系统版本,只读展示,不会随关于信息提交。': 'The version comes from the current system version. It is read-only and is not submitted with About information.',
|
||||
'边界': 'Boundary',
|
||||
'构建': 'Build',
|
||||
'内置': 'Built-in',
|
||||
'Live 新闻': 'Live News',
|
||||
'直播加载中': 'Live stream loading',
|
||||
'个频道': 'channels',
|
||||
'尚未同步': 'Not synced yet',
|
||||
'提供方': 'Provider',
|
||||
'地区': 'Region',
|
||||
'语言': 'Language',
|
||||
'可回退': 'Fallback',
|
||||
'备注': 'Notes',
|
||||
'播放流地址': 'Stream URL',
|
||||
'嵌入地址': 'Embed URL',
|
||||
'封面地址': 'Poster URL',
|
||||
'YouTube 视频 ID': 'YouTube video ID',
|
||||
'YouTube 频道': 'YouTube channel',
|
||||
'Earth TV 实际渲染预览': 'Rendered Earth TV preview',
|
||||
'商业新闻': 'Business news',
|
||||
'连通不稳定': 'Unstable connectivity',
|
||||
'官方数据': 'Official data',
|
||||
'来源类型': 'Source type',
|
||||
'条新闻。': 'news items.',
|
||||
'单条标题只在这里展示,左侧保持来源/组聚合。': 'Only item titles are shown here; the left side keeps source/group aggregation.',
|
||||
'可以单条添加或上传 JSON 数组导入。': 'You can add items one by one or upload a JSON array.',
|
||||
'是': 'Yes',
|
||||
'否': 'No',
|
||||
'采集间隔(分钟)': 'Collection interval (minutes)',
|
||||
'无时间': 'No timestamp',
|
||||
'自动刷新': 'Auto refresh',
|
||||
'会话超时(分钟)': 'Session timeout (minutes)',
|
||||
'密码策略': 'Password policy',
|
||||
'主机': 'Host',
|
||||
'端口': 'Port',
|
||||
'用户名': 'Username',
|
||||
'使用 TLS': 'Use TLS',
|
||||
'发件邮箱': 'Sender email',
|
||||
'发件人名称': 'Sender name',
|
||||
'个历史快照,选择后查看该版本详情。': 'historical snapshots. Select one to view that version.',
|
||||
}
|
||||
|
||||
@@ -141,6 +141,7 @@ export const zhCN = {
|
||||
},
|
||||
},
|
||||
docs: {
|
||||
brandMark: '智',
|
||||
brandTitle: '智能星球文档',
|
||||
brandSubtitle: '开发者和用户手册',
|
||||
documentUnavailable: '文档不可用',
|
||||
@@ -354,6 +355,7 @@ export const enUS = {
|
||||
},
|
||||
},
|
||||
docs: {
|
||||
brandMark: 'IP',
|
||||
brandTitle: 'Intelligent Planet Docs',
|
||||
brandSubtitle: 'Developer & User Guide',
|
||||
documentUnavailable: 'Document unavailable',
|
||||
|
||||
@@ -308,7 +308,7 @@ export default function Docs() {
|
||||
<main className="docs-page" data-theme={effectiveTheme}>
|
||||
<aside className="docs-sidebar" aria-label="Documentation navigation">
|
||||
<Link className="docs-brand" to="/docs">
|
||||
<span className="docs-brand__mark">智</span>
|
||||
<span className="docs-brand__mark">{t('docs.brandMark')}</span>
|
||||
<span>
|
||||
<span className="docs-brand__title">
|
||||
{t('docs.brandTitle')}
|
||||
|
||||
@@ -2,23 +2,10 @@ import axios from 'axios'
|
||||
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 { describeApiError } from '../../i18n/api-errors'
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || '/api/v1'
|
||||
|
||||
interface ErrorBody {
|
||||
response?: {
|
||||
data?: { detail?: string | { code?: string; message?: string; retry_after_seconds?: number } }
|
||||
}
|
||||
}
|
||||
|
||||
function extractDetail(error: unknown, fallback: string): string {
|
||||
const err = error as ErrorBody
|
||||
const detail = err.response?.data?.detail
|
||||
if (typeof detail === 'string') return detail
|
||||
if (detail && typeof detail === 'object') return detail.message || detail.code || fallback
|
||||
return fallback
|
||||
}
|
||||
|
||||
function ForgotPassword() {
|
||||
const { t } = useTranslation()
|
||||
const [step, setStep] = useState<'request' | 'reset'>('request')
|
||||
@@ -45,7 +32,7 @@ function ForgotPassword() {
|
||||
setCooldown(60)
|
||||
setFeedback({ tone: 'success', text: t('auth.recoveryCodeSent') })
|
||||
} catch (error) {
|
||||
setFeedback({ tone: 'error', text: extractDetail(error, t('common.operationFailed')) })
|
||||
setFeedback({ tone: 'error', text: describeApiError(error, t('common.operationFailed')) })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -62,7 +49,7 @@ function ForgotPassword() {
|
||||
setNewPassword('')
|
||||
setFeedback({ tone: 'success', text: t('auth.passwordResetSuccess') })
|
||||
} catch (error) {
|
||||
setFeedback({ tone: 'error', text: extractDetail(error, t('common.operationFailed')) })
|
||||
setFeedback({ tone: 'error', text: describeApiError(error, t('common.operationFailed')) })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -75,7 +62,7 @@ function ForgotPassword() {
|
||||
setCooldown(60)
|
||||
setFeedback({ tone: 'success', text: t('auth.resendSuccess') })
|
||||
} catch (error) {
|
||||
setFeedback({ tone: 'error', text: extractDetail(error, t('common.operationFailed')) })
|
||||
setFeedback({ tone: 'error', text: describeApiError(error, t('common.operationFailed')) })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, type FormEvent } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { AuthButton, AuthField, AuthForm, AuthInput, AuthLinks, AuthNotice, AuthShell } from '../Auth/AuthShell'
|
||||
import { describeApiValue } from '../../i18n/api-errors'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
|
||||
interface LoginError {
|
||||
@@ -36,8 +37,7 @@ function Login() {
|
||||
navigate(`/verify-email${email}`)
|
||||
return
|
||||
}
|
||||
const fallback = typeof detail === 'string' ? detail : detail?.message
|
||||
setFeedback({ tone: 'error', text: fallback || t('auth.loginFailed') })
|
||||
setFeedback({ tone: 'error', text: describeApiValue(detail, t('auth.loginFailed')) })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useEffect, useState, type FormEvent } from 'react'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { AuthButton, AuthField, AuthForm, AuthInput, AuthLinks, AuthNotice, AuthShell } from '../Auth/AuthShell'
|
||||
import { describeApiError } from '../../i18n/api-errors'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || '/api/v1'
|
||||
@@ -16,14 +17,6 @@ interface ErrorBody {
|
||||
}
|
||||
}
|
||||
|
||||
function extractDetail(error: unknown, fallback: string): string {
|
||||
const err = error as ErrorBody
|
||||
const detail = err.response?.data?.detail
|
||||
if (typeof detail === 'string') return detail
|
||||
if (detail && typeof detail === 'object') return detail.message || detail.code || fallback
|
||||
return fallback
|
||||
}
|
||||
|
||||
function Register() {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
@@ -53,7 +46,7 @@ function Register() {
|
||||
setCooldown(RESEND_COOLDOWN_SECONDS)
|
||||
setFeedback({ tone: 'success', text: t('auth.verificationSent') })
|
||||
} catch (error) {
|
||||
setFeedback({ tone: 'error', text: extractDetail(error, t('common.operationFailed')) })
|
||||
setFeedback({ tone: 'error', text: describeApiError(error, t('common.operationFailed')) })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -70,7 +63,7 @@ function Register() {
|
||||
axios.defaults.headers.common.Authorization = `Bearer ${access_token}`
|
||||
navigate('/admin', { replace: true })
|
||||
} catch (error) {
|
||||
setFeedback({ tone: 'error', text: extractDetail(error, t('common.operationFailed')) })
|
||||
setFeedback({ tone: 'error', text: describeApiError(error, t('common.operationFailed')) })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -88,7 +81,7 @@ function Register() {
|
||||
const err = error as ErrorBody
|
||||
const detail = err.response?.data?.detail
|
||||
if (detail && typeof detail === 'object' && detail.retry_after_seconds) setCooldown(detail.retry_after_seconds)
|
||||
setFeedback({ tone: 'error', text: extractDetail(error, t('common.operationFailed')) })
|
||||
setFeedback({ tone: 'error', text: describeApiError(error, t('common.operationFailed')) })
|
||||
} finally {
|
||||
setResending(false)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useEffect, useState, type FormEvent } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { AuthButton, AuthField, AuthForm, AuthInput, AuthLinks, AuthNotice, AuthShell, BackToLogin } from '../Auth/AuthShell'
|
||||
import { describeApiError } from '../../i18n/api-errors'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || '/api/v1'
|
||||
@@ -13,14 +14,6 @@ interface ErrorBody {
|
||||
}
|
||||
}
|
||||
|
||||
function extractDetail(error: unknown, fallback: string): string {
|
||||
const err = error as ErrorBody
|
||||
const detail = err.response?.data?.detail
|
||||
if (typeof detail === 'string') return detail
|
||||
if (detail && typeof detail === 'object') return detail.message || detail.code || fallback
|
||||
return fallback
|
||||
}
|
||||
|
||||
function VerifyEmail() {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
@@ -49,7 +42,7 @@ function VerifyEmail() {
|
||||
axios.defaults.headers.common.Authorization = `Bearer ${access_token}`
|
||||
navigate('/admin', { replace: true })
|
||||
} catch (error) {
|
||||
setFeedback({ tone: 'error', text: extractDetail(error, t('common.operationFailed')) })
|
||||
setFeedback({ tone: 'error', text: describeApiError(error, t('common.operationFailed')) })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -67,7 +60,7 @@ function VerifyEmail() {
|
||||
const err = error as ErrorBody
|
||||
const detail = err.response?.data?.detail
|
||||
if (detail && typeof detail === 'object' && detail.retry_after_seconds) setCooldown(detail.retry_after_seconds)
|
||||
setFeedback({ tone: 'error', text: extractDetail(error, t('common.operationFailed')) })
|
||||
setFeedback({ tone: 'error', text: describeApiError(error, t('common.operationFailed')) })
|
||||
} finally {
|
||||
setResending(false)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "planet"
|
||||
version = "0.73.0"
|
||||
version = "0.74.0"
|
||||
description = "智能星球计划 - 态势感知系统"
|
||||
requires-python = ">=3.14"
|
||||
dependencies = [
|
||||
|
||||
@@ -142,6 +142,12 @@ def iter_frontend_src_files() -> list[Path]:
|
||||
]
|
||||
|
||||
|
||||
def iter_style_family_css_files() -> list[Path]:
|
||||
files = sorted((root / "frontend/src").rglob("*.css"))
|
||||
files.extend(sorted((root / "frontend/public/earth/css").glob("*.css")))
|
||||
return files
|
||||
|
||||
|
||||
def collect_inline_style_context(lines: list[str], start_index: int) -> str:
|
||||
block = []
|
||||
for line in lines[start_index:start_index + 8]:
|
||||
@@ -255,6 +261,48 @@ 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
|
||||
|
||||
|
||||
STYLE_FAMILY_OWNER_FILES = {
|
||||
"frontend/src/admin/styles.css",
|
||||
"frontend/src/components/tactile-ui/styles.css",
|
||||
"frontend/src/pages/Docs/Docs.css",
|
||||
"frontend/public/earth/css/base.css",
|
||||
"frontend/public/earth/css/coordinates-display.css",
|
||||
"frontend/public/earth/css/earth-stats.css",
|
||||
"frontend/public/earth/css/hud.css",
|
||||
"frontend/public/earth/css/info-panel.css",
|
||||
"frontend/public/earth/css/layer-panel.css",
|
||||
"frontend/public/earth/css/legend.css",
|
||||
"frontend/public/earth/css/news-panel.css",
|
||||
"frontend/public/earth/css/toolbar.css",
|
||||
"frontend/public/earth/css/tv-panel.css",
|
||||
}
|
||||
|
||||
STYLE_FAMILY_PATTERN = re.compile(r"\b(?:badge|chip|pill|tag|status)\b", re.I)
|
||||
|
||||
|
||||
def style_family_selector(selector: str) -> str | None:
|
||||
for class_name in re.findall(r"\.([A-Za-z][A-Za-z0-9_-]*)", selector):
|
||||
if STYLE_FAMILY_PATTERN.search(class_name):
|
||||
return class_name
|
||||
return None
|
||||
|
||||
|
||||
def check_same_category_style_ownership() -> None:
|
||||
for path in iter_style_family_css_files():
|
||||
rel = str(path.relative_to(root))
|
||||
if rel in STYLE_FAMILY_OWNER_FILES:
|
||||
continue
|
||||
text = path.read_text(encoding="utf-8")
|
||||
for line_no, selector, _body in iter_css_blocks(text):
|
||||
class_name = style_family_selector(selector)
|
||||
if class_name is None:
|
||||
continue
|
||||
warn(
|
||||
f"{rel}:{line_no}: same-category {class_name!r} styles should reuse "
|
||||
"an existing shared style owner instead of introducing a page-local visual variant"
|
||||
)
|
||||
|
||||
|
||||
def check_admin_shell_height_chain() -> None:
|
||||
text = read_text("frontend/src/admin/styles.css")
|
||||
required: dict[str, dict[str, str]] = {
|
||||
@@ -324,6 +372,160 @@ def check_debug_output() -> None:
|
||||
fail(f"{rel}:{line_no}: console output must not include token material")
|
||||
|
||||
|
||||
EARTH_RUNTIME_COPY_FILES = (
|
||||
"frontend/public/earth/js/main.js",
|
||||
"frontend/public/earth/js/controls.js",
|
||||
"frontend/public/earth/js/cables.js",
|
||||
"frontend/public/earth/js/news.js",
|
||||
"frontend/public/earth/js/tv.js",
|
||||
"frontend/public/earth/js/info-card.js",
|
||||
"frontend/public/earth/js/layer-startup-tasks.js",
|
||||
"frontend/public/earth/js/i18n.js",
|
||||
)
|
||||
|
||||
EARTH_RUNTIME_COPY_ENTRYPOINTS = (
|
||||
"showStatusMessage",
|
||||
"queueStatusMessage",
|
||||
"showGestureStatusMessage",
|
||||
"showError",
|
||||
"setLoadingMessage",
|
||||
"resolveStartupMessage",
|
||||
)
|
||||
|
||||
CJK_PATTERN = re.compile(r"[\u3400-\u9fff]")
|
||||
|
||||
|
||||
def collect_balanced_js(text: str, start_index: int, opener: str, closer: str) -> str:
|
||||
depth = 0
|
||||
quote: str | None = None
|
||||
template_depth = 0
|
||||
cursor = start_index
|
||||
while cursor < len(text):
|
||||
char = text[cursor]
|
||||
if quote:
|
||||
if char == "\\":
|
||||
cursor += 2
|
||||
continue
|
||||
if quote == "`" and char == "$" and cursor + 1 < len(text) and text[cursor + 1] == "{":
|
||||
template_depth += 1
|
||||
cursor += 2
|
||||
continue
|
||||
if template_depth > 0 and char == "}":
|
||||
template_depth -= 1
|
||||
cursor += 1
|
||||
continue
|
||||
if char == quote and template_depth == 0:
|
||||
quote = None
|
||||
cursor += 1
|
||||
continue
|
||||
|
||||
if char in ("'", '"', "`"):
|
||||
quote = char
|
||||
elif char == opener:
|
||||
depth += 1
|
||||
elif char == closer:
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return text[start_index:cursor + 1]
|
||||
cursor += 1
|
||||
return text[start_index:]
|
||||
|
||||
|
||||
def collect_js_call(text: str, start_index: int) -> str:
|
||||
open_index = text.find("(", start_index)
|
||||
if open_index == -1:
|
||||
return text[start_index:]
|
||||
return collect_balanced_js(text, open_index, "(", ")")
|
||||
|
||||
|
||||
def collect_js_value(text: str, start_index: int) -> str:
|
||||
cursor = start_index
|
||||
while cursor < len(text) and text[cursor].isspace():
|
||||
cursor += 1
|
||||
if cursor >= len(text):
|
||||
return ""
|
||||
char = text[cursor]
|
||||
if char == "{":
|
||||
return collect_balanced_js(text, cursor, "{", "}")
|
||||
if char == "[":
|
||||
return collect_balanced_js(text, cursor, "[", "]")
|
||||
if text.startswith("earthMessage", cursor):
|
||||
open_index = text.find("(", cursor)
|
||||
if open_index == -1:
|
||||
return text[cursor:]
|
||||
return text[cursor:open_index] + collect_balanced_js(text, open_index, "(", ")")
|
||||
if char in ("'", '"', "`"):
|
||||
quote = char
|
||||
cursor += 1
|
||||
while cursor < len(text):
|
||||
if text[cursor] == "\\":
|
||||
cursor += 2
|
||||
continue
|
||||
if text[cursor] == quote:
|
||||
return text[start_index:cursor + 1]
|
||||
cursor += 1
|
||||
return text[start_index:]
|
||||
|
||||
end_candidates = [
|
||||
index for index in (text.find(",", cursor), text.find("\n", cursor)) if index != -1
|
||||
]
|
||||
end_index = min(end_candidates) if end_candidates else len(text)
|
||||
return text[start_index:end_index]
|
||||
|
||||
|
||||
def check_earth_runtime_copy_entrypoints() -> None:
|
||||
i18n_text = read_text("frontend/public/earth/js/i18n.js")
|
||||
for token in ("EARTH_MESSAGE_TEMPLATES", "export function earthMessage", "export function formatEarthMessage"):
|
||||
if token not in i18n_text:
|
||||
fail(f"frontend/public/earth/js/i18n.js: missing centralized Earth runtime copy token {token}")
|
||||
|
||||
direct_literal_pattern = re.compile(r"^\(\s*['\"`]")
|
||||
for file_path in EARTH_RUNTIME_COPY_FILES:
|
||||
text = read_text(file_path)
|
||||
for entrypoint in EARTH_RUNTIME_COPY_ENTRYPOINTS:
|
||||
pattern = re.compile(rf"\b{re.escape(entrypoint)}\s*\(")
|
||||
for match in pattern.finditer(text):
|
||||
call = collect_js_call(text, match.start())
|
||||
if direct_literal_pattern.search(call):
|
||||
fail(
|
||||
f"{file_path}:{jsx_line_number(text, match.start())}: "
|
||||
f"Earth runtime copy entrypoint {entrypoint} must use earthMessage(...), not a direct string literal"
|
||||
)
|
||||
if CJK_PATTERN.search(call) and "earthMessage(" not in call:
|
||||
fail(
|
||||
f"{file_path}:{jsx_line_number(text, match.start())}: "
|
||||
f"Earth runtime copy entrypoint {entrypoint} contains CJK without earthMessage(...)"
|
||||
)
|
||||
|
||||
for match in re.finditer(r"\bstartupMessage\s*:\s*", text):
|
||||
value = collect_js_value(text, match.end()).strip()
|
||||
if value in {'""', "''", "``"}:
|
||||
continue
|
||||
if "earthMessage(" not in value:
|
||||
fail(
|
||||
f"{file_path}:{jsx_line_number(text, match.start())}: "
|
||||
"Earth layer startupMessage must use earthMessage(...) so startup copy has one i18n entrypoint"
|
||||
)
|
||||
elif CJK_PATTERN.search(value) and "earthMessage(" not in value:
|
||||
fail(
|
||||
f"{file_path}:{jsx_line_number(text, match.start())}: "
|
||||
"Earth layer startupMessage contains CJK outside the centralized runtime copy map"
|
||||
)
|
||||
|
||||
for match in re.finditer(r"new\s+CustomEvent\(\s*['\"]earth:status['\"]", text):
|
||||
call = collect_js_call(text, match.start())
|
||||
if re.search(r"\bmessage\s*:\s*['\"`]", call):
|
||||
fail(
|
||||
f"{file_path}:{jsx_line_number(text, match.start())}: "
|
||||
"earth:status event messages must use earthMessage(...) instead of direct strings"
|
||||
)
|
||||
if CJK_PATTERN.search(call) and "earthMessage(" not in call:
|
||||
fail(
|
||||
f"{file_path}:{jsx_line_number(text, match.start())}: "
|
||||
"earth:status event contains CJK without earthMessage(...)"
|
||||
)
|
||||
|
||||
|
||||
def jsx_line_number(text: str, index: int) -> int:
|
||||
return text.count("\n", 0, index) + 1
|
||||
|
||||
@@ -512,12 +714,14 @@ def main() -> None:
|
||||
check_literal_internal_links()
|
||||
check_admin_search_route_targets()
|
||||
check_debug_output()
|
||||
check_earth_runtime_copy_entrypoints()
|
||||
check_native_button_safety()
|
||||
check_icon_button_accessibility()
|
||||
check_no_nested_cards()
|
||||
check_no_antd_layout_primitives()
|
||||
check_connection_test_input_pattern()
|
||||
check_admin_shell_height_chain()
|
||||
check_same_category_style_ownership()
|
||||
check_uiux_static_warnings()
|
||||
|
||||
for message in warnings:
|
||||
|
||||
@@ -22,6 +22,15 @@ const publicRoutes = [
|
||||
{ path: '/docs/manual', text: '智能星球使用手册' },
|
||||
{ path: '/docs/quickstart', text: '快速开始' },
|
||||
]
|
||||
const publicEnglishI18nRoutes = [
|
||||
'/login',
|
||||
'/register',
|
||||
'/verify-email',
|
||||
'/forgot-password',
|
||||
'/docs',
|
||||
'/docs/manual',
|
||||
'/docs/quickstart',
|
||||
]
|
||||
|
||||
const desktopViewport = { width: 1440, height: 900 }
|
||||
const mobileViewport = { width: 390, height: 844 }
|
||||
@@ -39,9 +48,13 @@ const interactionChecks = [
|
||||
'logs raw tab',
|
||||
'users add dialog',
|
||||
'data distribution toggle',
|
||||
'earth i18n settings',
|
||||
'earth news source test',
|
||||
'earth news source draft cancel',
|
||||
'earth news group create',
|
||||
'public english i18n sweep',
|
||||
'english login backend error i18n',
|
||||
'admin english i18n sweep',
|
||||
'authenticated unknown route fallback',
|
||||
]
|
||||
const authInteractionChecks = [
|
||||
@@ -49,6 +62,22 @@ const authInteractionChecks = [
|
||||
'forgot password reset',
|
||||
'standalone verify email',
|
||||
]
|
||||
const adminEnglishI18nRoutes = [
|
||||
'/admin',
|
||||
'/data',
|
||||
'/datasources',
|
||||
'/bgp',
|
||||
'/alerts/system',
|
||||
'/alerts/bgp',
|
||||
'/alerts/situational',
|
||||
'/ai',
|
||||
'/earth-content',
|
||||
'/collection-management',
|
||||
'/logs',
|
||||
'/users',
|
||||
'/settings',
|
||||
]
|
||||
const adminEnglishAllowedCjkText = ['中文']
|
||||
|
||||
function readRepoText(relativePath) {
|
||||
return readFileSync(new URL(`../../${relativePath}`, import.meta.url), 'utf8')
|
||||
@@ -381,6 +410,26 @@ async function clickFirstVisible(locator, label) {
|
||||
await item.click()
|
||||
}
|
||||
|
||||
async function ensureAdminPreferencesOpen(page, label) {
|
||||
const drawer = page.locator('.admin__preferences-drawer.is-open').first()
|
||||
if (await drawer.isVisible().catch(() => false)) return
|
||||
|
||||
const toggle = await firstVisible(page.locator('.admin__account-preferences'), label)
|
||||
await toggle.click()
|
||||
await page.locator('.admin__preferences-drawer.is-open').waitFor({ state: 'visible', timeout: 5000 })
|
||||
}
|
||||
|
||||
async function getEarthFrame(page, label) {
|
||||
const iframe = page.locator('iframe[title="3D Earth"]')
|
||||
await iframe.waitFor({ state: 'visible', timeout: 10000 })
|
||||
const handle = await iframe.elementHandle()
|
||||
const frame = await handle?.contentFrame()
|
||||
if (!frame) {
|
||||
throw new Error(`${label}: Earth iframe did not expose a content frame`)
|
||||
}
|
||||
return frame
|
||||
}
|
||||
|
||||
async function firstVisible(locator, label, timeoutMs = 10000) {
|
||||
const item = await firstVisibleOrNull(locator, timeoutMs)
|
||||
if (item) return item
|
||||
@@ -448,7 +497,13 @@ async function expectUrl(page, route, predicate) {
|
||||
}
|
||||
|
||||
async function checkNoFrameworkOverlay(page, route) {
|
||||
const bodyText = await page.locator('body').innerText({ timeout: 5000 }).catch(() => '')
|
||||
let bodyText = await page.locator('body').innerText({ timeout: 5000 }).catch(() => '')
|
||||
if (!bodyText.trim() && !(await page.locator('iframe').count())) {
|
||||
await page.waitForFunction(() => (
|
||||
Boolean(document.body?.innerText?.trim()) || document.querySelectorAll('iframe').length > 0
|
||||
), null, { timeout: 3000 }).catch(() => {})
|
||||
bodyText = await page.locator('body').innerText({ timeout: 5000 }).catch(() => '')
|
||||
}
|
||||
const overlayNeedles = [
|
||||
'Internal server error',
|
||||
'Failed to resolve import',
|
||||
@@ -487,6 +542,70 @@ async function checkNoGlobalHorizontalOverflow(page, route) {
|
||||
}
|
||||
}
|
||||
|
||||
async function checkNoUnexpectedEnglishCjk(page, route) {
|
||||
const result = await page.evaluate((allowed) => {
|
||||
const allowedText = new Set(allowed)
|
||||
const cjk = /[\u3400-\u9fff]/
|
||||
const ignoredSelector = [
|
||||
'script',
|
||||
'style',
|
||||
'code',
|
||||
'pre',
|
||||
'svg',
|
||||
'textarea',
|
||||
'.an-json-viewer',
|
||||
'.an-log-output',
|
||||
'.an-detail-json',
|
||||
'.an-data-json',
|
||||
].join(', ')
|
||||
const root = document.querySelector('.admin-theme-root') || document.body
|
||||
const textNodes = []
|
||||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
|
||||
acceptNode(node) {
|
||||
const parent = node.parentElement
|
||||
if (!parent || parent.closest(ignoredSelector)) return NodeFilter.FILTER_REJECT
|
||||
const text = (node.textContent || '').replace(/\s+/g, ' ').trim()
|
||||
if (!text || allowedText.has(text) || !cjk.test(text)) return NodeFilter.FILTER_REJECT
|
||||
return NodeFilter.FILTER_ACCEPT
|
||||
},
|
||||
})
|
||||
while (walker.nextNode()) {
|
||||
const node = walker.currentNode
|
||||
const parent = node.parentElement
|
||||
textNodes.push({
|
||||
text: (node.textContent || '').replace(/\s+/g, ' ').trim(),
|
||||
tag: parent?.tagName || '',
|
||||
className: typeof parent?.className === 'string' ? parent.className : '',
|
||||
})
|
||||
}
|
||||
|
||||
const attributes = []
|
||||
root.querySelectorAll('*').forEach((element) => {
|
||||
if (element.closest(ignoredSelector)) return
|
||||
for (const attributeName of ['aria-label', 'placeholder', 'title']) {
|
||||
const value = element.getAttribute(attributeName)
|
||||
if (value && !allowedText.has(value) && cjk.test(value)) {
|
||||
attributes.push({
|
||||
attr: attributeName,
|
||||
value,
|
||||
tag: element.tagName,
|
||||
className: typeof element.className === 'string' ? element.className : '',
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
textNodes: textNodes.slice(0, 20),
|
||||
attributes: attributes.slice(0, 20),
|
||||
}
|
||||
}, adminEnglishAllowedCjkText)
|
||||
|
||||
if (result.textNodes.length || result.attributes.length) {
|
||||
throw new Error(`${route}: English UI contains unexpected Chinese text ${JSON.stringify(result)}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function checkAdminShellOneScreen(page, route, options = {}) {
|
||||
const { expectAccountVisible = false, expectPreferencesVisible = false } = options
|
||||
const layout = await page.evaluate(() => {
|
||||
@@ -785,7 +904,81 @@ function apiPayloadFor(requestUrl, method) {
|
||||
if (path === '/earth/boundaries/status') return jsonResponse({ status: 'ready', provider: 'low', updated_at: now })
|
||||
if (path === '/earth/boundaries/build/status') return jsonResponse({ status: 'idle', progress: 0 })
|
||||
if (path === '/settings/tv') return jsonResponse({ tv: { default_source_id: 'smoke-tv', sources: [{ id: 'smoke-tv', name: 'Smoke TV', source_type: 'hls', stream_url: 'https://example.invalid/live.m3u8', is_enabled: true, sort_order: 10 }] } })
|
||||
if (path === '/tv/streams') return jsonResponse({ sources: [] })
|
||||
if (path === '/tv/streams') return jsonResponse({
|
||||
generated_at: now,
|
||||
latest_updated_at: now,
|
||||
source_count: 1,
|
||||
default_source_id: 'cctv-news',
|
||||
selected_source: { id: 'cctv-news' },
|
||||
sources: [{
|
||||
id: 'cctv-news',
|
||||
name: '央视新闻',
|
||||
provider: '中国媒体',
|
||||
region: '中国',
|
||||
language: '中文',
|
||||
source_type: 'external',
|
||||
homepage_url: 'https://example.invalid/live',
|
||||
notes: '默认直播源',
|
||||
is_enabled: true,
|
||||
sort_order: 10,
|
||||
}],
|
||||
})
|
||||
if (path === '/news/earth-feed') return jsonResponse({
|
||||
generated_at: now,
|
||||
focus: { lat: 0, lon: 0, region: 'global', label: 'Global Focus', display_region: 'Global' },
|
||||
filters: { locale: url.searchParams.get('locale') || 'zh-CN', categories: [], sources: [], limit: 12 },
|
||||
sources: [
|
||||
{ id: 'bbc-world', name: 'BBC World', region: 'global', homepage_url: 'https://www.bbc.com/news', source_type: 'rss', enabled: true },
|
||||
{ id: '36kr', name: '36氪', region: 'asia-pacific', homepage_url: 'https://www.36kr.com/', source_type: 'rss', enabled: true },
|
||||
],
|
||||
items: [
|
||||
{
|
||||
id: 'bbc-world:smoke-english',
|
||||
source_id: 'bbc-world',
|
||||
title: 'Global trade ministers agree on supply-chain safeguards',
|
||||
summary: 'Officials said the plan focuses on resilient logistics and energy infrastructure.',
|
||||
content_language: 'en',
|
||||
localizations: {},
|
||||
display_title: 'Global trade ministers agree on supply-chain safeguards',
|
||||
display_summary: 'Officials said the plan focuses on resilient logistics and energy infrastructure.',
|
||||
url: 'https://example.invalid/news',
|
||||
source: 'BBC World',
|
||||
feed_name: 'BBC World',
|
||||
source_type: 'rss',
|
||||
category: 'business',
|
||||
region: 'global',
|
||||
display_region: 'Global',
|
||||
published_at: now,
|
||||
latitude: 0,
|
||||
longitude: 0,
|
||||
enrichment_status: 'success',
|
||||
},
|
||||
{
|
||||
id: '36kr:smoke-chinese',
|
||||
source_id: '36kr',
|
||||
title: '中国电商平台发布季度增长数据',
|
||||
summary: '平台表示,跨境电商订单量同比增长。',
|
||||
content_language: 'zh-CN',
|
||||
localizations: { 'zh-CN': { title: '中国电商平台发布季度增长数据', summary: '平台表示,跨境电商订单量同比增长。' } },
|
||||
display_title: '',
|
||||
display_summary: '',
|
||||
url: 'https://example.invalid/zh-news',
|
||||
source: '36氪',
|
||||
feed_name: '综合资讯',
|
||||
source_type: 'rss',
|
||||
category: 'business',
|
||||
region: 'asia-pacific',
|
||||
display_region: '亚太',
|
||||
published_at: now,
|
||||
latitude: 31,
|
||||
longitude: 121,
|
||||
enrichment_status: 'queued',
|
||||
},
|
||||
],
|
||||
cruise_items: [],
|
||||
errors: [],
|
||||
stale: false,
|
||||
})
|
||||
if (path === '/earth/news-sources') return jsonResponse({
|
||||
is_default: true,
|
||||
sources: [
|
||||
@@ -964,7 +1157,7 @@ async function runAdminInteractionChecks(browser, failures, consoleErrors) {
|
||||
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 ensureAdminPreferencesOpen(page, 'admin preferences drawer open')
|
||||
await page.waitForTimeout(350)
|
||||
await checkAdminShellOneScreen(page, 'admin preferences drawer open', {
|
||||
expectAccountVisible: true,
|
||||
@@ -981,10 +1174,17 @@ async function runAdminInteractionChecks(browser, failures, consoleErrors) {
|
||||
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()
|
||||
smokeProgress('admin english i18n sweep')
|
||||
for (const route of adminEnglishI18nRoutes) {
|
||||
const label = `admin english i18n ${route}`
|
||||
smokeProgress(label, 'verbose')
|
||||
await page.goto(urlFor(route), { waitUntil: 'domcontentloaded' })
|
||||
await page.waitForTimeout(450)
|
||||
await checkNoFrameworkOverlay(page, label)
|
||||
await checkNoUnexpectedEnglishCjk(page, label)
|
||||
}
|
||||
await page.goto(urlFor('/settings?section=smtp'), { waitUntil: 'domcontentloaded' })
|
||||
await ensureAdminPreferencesOpen(page, 'admin preferences drawer reopen')
|
||||
await clickFirstVisible(
|
||||
page.getByRole('group', { name: 'Console language' }).getByRole('button', { name: '中文' }),
|
||||
'admin language switch chinese',
|
||||
@@ -1009,15 +1209,23 @@ async function runAdminInteractionChecks(browser, failures, consoleErrors) {
|
||||
await expectVisibleText(page, 'SMTP 邮件', 'section tab settings smtp')
|
||||
|
||||
await page.goto(urlFor('/ai?section=integrations'), { waitUntil: 'domcontentloaded' })
|
||||
await clickFirstVisible(page.getByRole('button', { name: '测试 AI Provider 连通性' }), 'connection test input ai provider')
|
||||
await expectVisibleText(page, 'LLM 基础地址', 'connection test input ai provider')
|
||||
await clickFirstVisible(page.locator('.an-connection-test-input button'), 'connection test input ai provider')
|
||||
await expectVisibleText(page, 'AI Provider连通性正常', 'connection test input ai provider')
|
||||
|
||||
await page.goto(urlFor('/ai?section=tools'), { waitUntil: 'domcontentloaded' })
|
||||
await clickFirstVisible(page.getByRole('button', { name: /tavily/i }), 'connection test input websearch provider')
|
||||
await clickFirstVisible(page.getByRole('switch', { name: '停用 WebSearch' }), 'connection test input websearch switch')
|
||||
await expectVisibleText(page, 'WebSearch 已在草稿中停用', 'connection test input websearch disabled')
|
||||
const webSearchEnabledInput = page.locator('[data-admin-search-field="enabled"] input[type="checkbox"]')
|
||||
await firstVisible(webSearchEnabledInput, 'connection test input websearch switch')
|
||||
if (await webSearchEnabledInput.first().isChecked()) {
|
||||
await webSearchEnabledInput.first().click()
|
||||
}
|
||||
await page.waitForFunction(() => {
|
||||
const input = document.querySelector('[data-admin-search-field="enabled"] input[type="checkbox"]')
|
||||
return input instanceof HTMLInputElement && !input.checked
|
||||
}, null, { timeout: 5000 })
|
||||
await expectFirstVisibleDisabled(page.getByLabel('API 基础地址'), 'connection test input websearch base url')
|
||||
await expectFirstVisibleDisabled(page.getByRole('button', { name: '测试 Web Search 连通性' }), 'connection test input websearch action')
|
||||
await expectFirstVisibleDisabled(page.locator('.an-connection-test-input button'), 'connection test input websearch action')
|
||||
|
||||
await page.goto(urlFor('/logs'), { waitUntil: 'domcontentloaded' })
|
||||
await clickFirstVisible(page.getByRole('tab', { name: /原始日志/ }), 'logs raw tab')
|
||||
@@ -1259,16 +1467,487 @@ async function main() {
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
smokeProgress('public english i18n sweep')
|
||||
await page.goto(urlFor('/login'), { waitUntil: 'domcontentloaded' })
|
||||
await page.evaluate(() => {
|
||||
localStorage.setItem('planet-locale', 'en-US')
|
||||
localStorage.setItem('docs-lang', 'en')
|
||||
})
|
||||
for (const route of publicEnglishI18nRoutes) {
|
||||
const label = `public english i18n ${route}`
|
||||
smokeProgress(label, 'verbose')
|
||||
await page.goto(urlFor(route), { waitUntil: 'domcontentloaded' })
|
||||
await page.waitForTimeout(450)
|
||||
await checkNoFrameworkOverlay(page, label)
|
||||
await checkNoUnexpectedEnglishCjk(page, label)
|
||||
}
|
||||
await page.evaluate(() => {
|
||||
localStorage.setItem('planet-locale', 'zh-CN')
|
||||
localStorage.setItem('docs-lang', 'zh')
|
||||
})
|
||||
} catch (error) {
|
||||
failures.push(error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
|
||||
try {
|
||||
smokeProgress('earth public route')
|
||||
await page.evaluate(() => {
|
||||
localStorage.setItem('planet-locale', 'en-US')
|
||||
localStorage.setItem('docs-lang', 'en')
|
||||
})
|
||||
await page.goto(urlFor('/earth'), { waitUntil: 'domcontentloaded' })
|
||||
await checkNoFrameworkOverlay(page, '/earth')
|
||||
await page.locator('iframe[title="3D Earth"]').waitFor({ state: 'visible', timeout: 10000 })
|
||||
const earthFrame = await getEarthFrame(page, '/earth')
|
||||
await earthFrame.waitForFunction(() => document.documentElement.lang === 'en-US', null, { timeout: 10000 })
|
||||
await earthFrame.waitForFunction(() => (
|
||||
document.querySelector('.earth-brand__title')?.getAttribute('src')?.includes('title-en.png')
|
||||
), null, { timeout: 10000 })
|
||||
const brandState = await earthFrame.evaluate(() => {
|
||||
const brand = document.querySelector('#brand-panel .earth-brand')
|
||||
const panel = document.getElementById('brand-panel')
|
||||
const title = document.querySelector('.earth-brand__title')
|
||||
const subtitle = document.querySelector('.earth-brand__subtitle')
|
||||
const description = document.querySelector('.earth-brand__description')
|
||||
if (
|
||||
!(brand instanceof HTMLElement)
|
||||
|| !(panel instanceof HTMLElement)
|
||||
|| !(title instanceof HTMLImageElement)
|
||||
|| !(subtitle instanceof HTMLElement)
|
||||
|| !(description instanceof HTMLElement)
|
||||
) return null
|
||||
const brandRect = brand.getBoundingClientRect()
|
||||
const panelRect = panel.getBoundingClientRect()
|
||||
const titleRect = title.getBoundingClientRect()
|
||||
const subtitleRect = subtitle.getBoundingClientRect()
|
||||
const descriptionRect = description.getBoundingClientRect()
|
||||
return {
|
||||
brandWidth: brandRect.width,
|
||||
panelWidth: panelRect.width,
|
||||
titleWidth: titleRect.width,
|
||||
titleRight: titleRect.right,
|
||||
brandRight: brandRect.right,
|
||||
panelRight: panelRect.right,
|
||||
subtitleText: subtitle.innerText,
|
||||
descriptionText: description.innerText,
|
||||
subtitleRight: subtitleRect.right,
|
||||
descriptionRight: descriptionRect.right,
|
||||
viewportWidth: window.innerWidth,
|
||||
}
|
||||
})
|
||||
if (
|
||||
!brandState
|
||||
|| brandState.brandWidth > 285
|
||||
|| brandState.panelWidth > 290
|
||||
|| brandState.titleWidth < 110
|
||||
|| brandState.titleRight > brandState.panelRight + 1
|
||||
|| brandState.brandRight > brandState.panelRight + 1
|
||||
|| brandState.subtitleRight > brandState.panelRight + 1
|
||||
|| brandState.descriptionRight > brandState.panelRight + 1
|
||||
|| !brandState.subtitleText
|
||||
|| !brandState.descriptionText
|
||||
|| /[\u4e00-\u9fff]/.test(`${brandState.subtitleText}\n${brandState.descriptionText}`)
|
||||
|| brandState.brandRight > brandState.viewportWidth - 8
|
||||
) {
|
||||
throw new Error(`/earth i18n settings: English brand text is clipped or oversized ${JSON.stringify(brandState)}`)
|
||||
}
|
||||
await earthFrame.evaluate(() => document.getElementById('settings-trigger')?.click())
|
||||
await earthFrame.waitForTimeout(350)
|
||||
await earthFrame.evaluate(() => (
|
||||
document.querySelector('.earth-settings-tabs [data-settings-tab="system"]')?.click()
|
||||
))
|
||||
await earthFrame.waitForFunction(() => (
|
||||
!document.querySelector('.earth-settings-sheet [data-settings-tab-panel="system"]')?.hidden
|
||||
), null, { timeout: 5000 })
|
||||
const systemText = await earthFrame.evaluate(() => (
|
||||
document.querySelector('.earth-settings-sheet [data-settings-tab-panel="system"]')?.innerText || ''
|
||||
))
|
||||
if (!systemText.includes('Earth Language')) {
|
||||
throw new Error('/earth i18n settings: expected English language control')
|
||||
}
|
||||
const localeSegmentInitial = await earthFrame.evaluate(() => {
|
||||
const segmented = document.querySelector('.earth-settings-sheet [data-settings-tab-panel="system"] .earth-settings-segmented')
|
||||
const english = segmented?.querySelector('[data-earth-locale="en-US"]')
|
||||
const chinese = segmented?.querySelector('[data-earth-locale="zh-CN"]')
|
||||
if (!(segmented instanceof HTMLElement) || !(english instanceof HTMLButtonElement) || !(chinese instanceof HTMLButtonElement)) return null
|
||||
return {
|
||||
englishActive: english.classList.contains('is-active'),
|
||||
chineseActive: chinese.classList.contains('is-active'),
|
||||
activeIndex: getComputedStyle(segmented).getPropertyValue('--active-index').trim(),
|
||||
itemCount: getComputedStyle(segmented).getPropertyValue('--item-count').trim(),
|
||||
}
|
||||
})
|
||||
if (
|
||||
!localeSegmentInitial?.englishActive
|
||||
|| localeSegmentInitial.chineseActive
|
||||
|| localeSegmentInitial.activeIndex !== '1'
|
||||
|| localeSegmentInitial.itemCount !== '2'
|
||||
) {
|
||||
throw new Error(`/earth i18n settings: English language segmented control is not visually active ${JSON.stringify(localeSegmentInitial)}`)
|
||||
}
|
||||
await earthFrame.evaluate(() => document.querySelector('.earth-settings-sheet [data-earth-locale="zh-CN"]')?.click())
|
||||
await earthFrame.waitForFunction(() => {
|
||||
const segmented = document.querySelector('.earth-settings-sheet [data-settings-tab-panel="system"] .earth-settings-segmented')
|
||||
const english = segmented?.querySelector('[data-earth-locale="en-US"]')
|
||||
const chinese = segmented?.querySelector('[data-earth-locale="zh-CN"]')
|
||||
return (
|
||||
document.documentElement.lang === 'zh-CN'
|
||||
&& segmented instanceof HTMLElement
|
||||
&& english instanceof HTMLButtonElement
|
||||
&& chinese instanceof HTMLButtonElement
|
||||
&& chinese.classList.contains('is-active')
|
||||
&& !english.classList.contains('is-active')
|
||||
&& getComputedStyle(segmented).getPropertyValue('--active-index').trim() === '0'
|
||||
)
|
||||
}, null, { timeout: 5000 })
|
||||
const zhSystemText = await earthFrame.evaluate(() => (
|
||||
document.querySelector('.earth-settings-sheet [data-settings-tab-panel="system"]')?.innerText || ''
|
||||
))
|
||||
if (!zhSystemText.includes('星球语言')) {
|
||||
throw new Error('/earth i18n settings: expected Chinese language control after switching locale')
|
||||
}
|
||||
await earthFrame.evaluate(() => document.querySelector('.earth-settings-sheet [data-earth-locale="en-US"]')?.click())
|
||||
await earthFrame.waitForFunction(() => {
|
||||
const segmented = document.querySelector('.earth-settings-sheet [data-settings-tab-panel="system"] .earth-settings-segmented')
|
||||
const english = segmented?.querySelector('[data-earth-locale="en-US"]')
|
||||
const chinese = segmented?.querySelector('[data-earth-locale="zh-CN"]')
|
||||
return (
|
||||
document.documentElement.lang === 'en-US'
|
||||
&& localStorage.getItem('planet-locale') === 'en-US'
|
||||
&& localStorage.getItem('docs-lang') === 'en'
|
||||
&& segmented instanceof HTMLElement
|
||||
&& english instanceof HTMLButtonElement
|
||||
&& chinese instanceof HTMLButtonElement
|
||||
&& english.classList.contains('is-active')
|
||||
&& !chinese.classList.contains('is-active')
|
||||
&& getComputedStyle(segmented).getPropertyValue('--active-index').trim() === '1'
|
||||
)
|
||||
}, null, { timeout: 5000 })
|
||||
const hdTextureStatus = await earthFrame.evaluate(async () => {
|
||||
const { earthMessage, formatEarthMessage } = await import('/earth/js/i18n.js?frontend_smoke_i18n')
|
||||
const ui = await import('/earth/js/ui.js?frontend_smoke_i18n')
|
||||
const message = earthMessage('startup.hdTexture')
|
||||
const translated = formatEarthMessage(message, 'en-US')
|
||||
ui.setLoading(true)
|
||||
ui.setLoadingMessage(message)
|
||||
return {
|
||||
translated,
|
||||
rendered: document.querySelector('#status-message .earth-status-text')?.textContent || '',
|
||||
}
|
||||
})
|
||||
if (
|
||||
/[\u4e00-\u9fff]/.test(`${hdTextureStatus.translated}\n${hdTextureStatus.rendered}`)
|
||||
|| !hdTextureStatus.translated.includes('Enabling HD texture')
|
||||
|| !hdTextureStatus.rendered.includes('Enabling HD texture')
|
||||
) {
|
||||
throw new Error(`/earth i18n settings: HD texture loading status is not translated: ${JSON.stringify(hdTextureStatus)}`)
|
||||
}
|
||||
const earthEnglishCjkAudit = await earthFrame.evaluate(() => {
|
||||
const cjk = /[\u4e00-\u9fff]/
|
||||
const allowed = new Set(['中文'])
|
||||
const skip = 'script,style,svg,canvas,video,iframe,.material-symbols-rounded'
|
||||
const isVisible = (element) => {
|
||||
if (!(element instanceof HTMLElement)) return false
|
||||
if (element.matches(skip) || element.closest(skip)) return false
|
||||
const style = getComputedStyle(element)
|
||||
if (style.visibility === 'hidden' || style.display === 'none' || Number(style.opacity) === 0) return false
|
||||
const rect = element.getBoundingClientRect()
|
||||
return rect.width > 0 && rect.height > 0
|
||||
}
|
||||
const textHits = []
|
||||
const attrHits = []
|
||||
document.querySelectorAll('body *').forEach((element) => {
|
||||
if (!isVisible(element)) return
|
||||
const ownText = Array.from(element.childNodes)
|
||||
.filter((node) => node.nodeType === Node.TEXT_NODE)
|
||||
.map((node) => node.textContent || '')
|
||||
.join(' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
if (ownText && cjk.test(ownText) && !allowed.has(ownText)) {
|
||||
textHits.push({
|
||||
selector: element.id ? `#${element.id}` : String(element.className || element.tagName.toLowerCase()),
|
||||
text: ownText,
|
||||
})
|
||||
}
|
||||
;['title', 'aria-label', 'placeholder', 'alt'].forEach((attr) => {
|
||||
const value = element.getAttribute(attr)
|
||||
if (value && cjk.test(value) && !allowed.has(value)) {
|
||||
attrHits.push({
|
||||
selector: element.id ? `#${element.id}` : String(element.className || element.tagName.toLowerCase()),
|
||||
attr,
|
||||
value,
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
return {
|
||||
textHits: textHits.slice(0, 20),
|
||||
attrHits: attrHits.slice(0, 20),
|
||||
}
|
||||
})
|
||||
if (earthEnglishCjkAudit.textHits.length || earthEnglishCjkAudit.attrHits.length) {
|
||||
throw new Error(`/earth i18n settings: English UI still has visible CJK ${JSON.stringify(earthEnglishCjkAudit)}`)
|
||||
}
|
||||
await earthFrame.evaluate(() => (
|
||||
document.querySelector('.earth-settings-tabs [data-settings-tab="panels"]')?.click()
|
||||
))
|
||||
await earthFrame.waitForFunction(() => (
|
||||
!document.querySelector('.earth-settings-sheet [data-settings-tab-panel="panels"]')?.hidden
|
||||
), null, { timeout: 5000 })
|
||||
const switchState = await earthFrame.evaluate(() => {
|
||||
const label = document.querySelector('label[for="toggle-view-legend"]')
|
||||
const input = document.getElementById('toggle-view-legend')
|
||||
if (!(label instanceof HTMLElement) || !(input instanceof HTMLInputElement)) return null
|
||||
const before = input.checked
|
||||
label.click()
|
||||
const shell = input.closest('.earth-settings-switch')
|
||||
return {
|
||||
before,
|
||||
checked: input.checked,
|
||||
visualChecked: shell?.classList.contains('is-checked') === true,
|
||||
}
|
||||
})
|
||||
if (
|
||||
!switchState
|
||||
|| switchState.checked === switchState.before
|
||||
|| switchState.visualChecked !== switchState.checked
|
||||
) {
|
||||
throw new Error('/earth i18n settings: system switch visual state did not follow checked state')
|
||||
}
|
||||
await earthFrame.evaluate(() => {
|
||||
const label = document.querySelector('label[for="toggle-view-legend"]')
|
||||
const input = document.getElementById('toggle-view-legend')
|
||||
if (input instanceof HTMLInputElement && !input.checked && label instanceof HTMLElement) {
|
||||
label.click()
|
||||
}
|
||||
})
|
||||
await earthFrame.waitForFunction(() => (
|
||||
!document.getElementById('legend')?.classList.contains('hud-panel-hidden')
|
||||
), null, { timeout: 5000 })
|
||||
const legendState = await earthFrame.evaluate(async () => {
|
||||
const legend = await import('/earth/js/legend.js')
|
||||
legend.setLegendItems('satellites', [
|
||||
{ color: '#ff3333', label: '赤道轨道(0-30°)' },
|
||||
{ color: '#ff9933', label: '低倾角轨道(30-60°)' },
|
||||
{ color: '#ffff33', label: '中倾角轨道(60-90°)' },
|
||||
{ color: '#33ff33', label: '高倾角轨道(90-120°)' },
|
||||
{ color: '#3333ff', label: '逆行轨道(120-180°)' },
|
||||
{ color: '#5eead4', label: '低轨' },
|
||||
{ color: '#93c5fd', label: '中轨' },
|
||||
{ color: '#c4b5fd', label: '高轨' },
|
||||
{ color: '#f9a8d4', label: '地球静止轨道' },
|
||||
{ color: '#fcd34d', label: '太阳同步轨道' },
|
||||
{ color: '#e5e7eb', label: '极轨' },
|
||||
])
|
||||
legend.setLegendMode('satellites')
|
||||
const panel = document.getElementById('legend')
|
||||
const panelRect = panel?.getBoundingClientRect()
|
||||
const currentLabel = document.getElementById('legend-current-label')
|
||||
const labels = Array.from(document.querySelectorAll('#legend-current-label, #legend .legend-label'))
|
||||
const mobileLabels = Array.from(document.querySelectorAll('#mobile-situation-legend-mode, #mobile-situation-legend-list .legend-label'))
|
||||
const text = labels.concat(mobileLabels)
|
||||
.map((element) => `${element.textContent || ''}\n${element.getAttribute('title') || ''}`)
|
||||
.join('\n')
|
||||
const overflows = labels
|
||||
.map((element) => {
|
||||
const rect = element.getBoundingClientRect()
|
||||
if (!panelRect) return null
|
||||
const overflow = rect.left < panelRect.left - 1 || rect.right > panelRect.right + 1
|
||||
return overflow ? {
|
||||
text: element.textContent,
|
||||
left: rect.left,
|
||||
right: rect.right,
|
||||
panelLeft: panelRect.left,
|
||||
panelRight: panelRect.right,
|
||||
} : null
|
||||
})
|
||||
.filter(Boolean)
|
||||
return {
|
||||
text,
|
||||
overflows,
|
||||
currentText: currentLabel?.textContent || '',
|
||||
currentTitle: currentLabel?.getAttribute('title') || '',
|
||||
}
|
||||
})
|
||||
if (/[\u4e00-\u9fff]/.test(legendState.text)) {
|
||||
throw new Error('/earth i18n settings: English satellite legend contains Chinese text')
|
||||
}
|
||||
if (!legendState.text.includes('Orbits')) {
|
||||
throw new Error('/earth i18n settings: English satellite legend should use compact Orbits label')
|
||||
}
|
||||
if (!legendState.text.includes('Low Incl.') || !legendState.text.includes('High Incl.') || !legendState.text.includes('Retrograde')) {
|
||||
throw new Error(`/earth i18n settings: English satellite legend missing translated orbit labels ${JSON.stringify(legendState)}`)
|
||||
}
|
||||
for (const compactOrbitLabel of ['LEO', 'MEO', 'HEO', 'GEO', 'SSO', 'Polar']) {
|
||||
if (!legendState.text.includes(compactOrbitLabel)) {
|
||||
throw new Error(`/earth i18n settings: English satellite legend missing compact orbit label ${compactOrbitLabel} ${JSON.stringify(legendState)}`)
|
||||
}
|
||||
}
|
||||
if (legendState.currentText !== legendState.currentTitle) {
|
||||
throw new Error(`/earth i18n settings: legend current title mismatch ${JSON.stringify(legendState)}`)
|
||||
}
|
||||
if (legendState.overflows.length > 0) {
|
||||
throw new Error(`/earth i18n settings: legend labels overflow panel ${JSON.stringify(legendState.overflows)}`)
|
||||
}
|
||||
const earthDynamicTranslations = await earthFrame.evaluate(async () => {
|
||||
const i18n = await import('/earth/js/i18n.js')
|
||||
return {
|
||||
satelliteShown: i18n.translateText('卫星已显示', 'en-US'),
|
||||
cruisePaused: i18n.translateText('巡航已暂停', 'en-US'),
|
||||
locatedCompute: i18n.translateText('已定位算力中心:Smoke Node', 'en-US'),
|
||||
loadingCables: i18n.translateText('正在加载线缆数据...', 'en-US'),
|
||||
}
|
||||
})
|
||||
if (
|
||||
earthDynamicTranslations.satelliteShown !== 'Satellites shown'
|
||||
|| earthDynamicTranslations.cruisePaused !== 'Cruise paused'
|
||||
|| earthDynamicTranslations.locatedCompute !== 'Located compute centers: Smoke Node'
|
||||
|| earthDynamicTranslations.loadingCables !== 'Loading cable data...'
|
||||
) {
|
||||
throw new Error(`/earth i18n settings: dynamic status translations regressed ${JSON.stringify(earthDynamicTranslations)}`)
|
||||
}
|
||||
const earthNotificationState = await earthFrame.evaluate(async () => {
|
||||
const ui = await import('/earth/js/ui.js')
|
||||
ui.setLoading(false)
|
||||
ui.showStatusMessage('成功加载 12 条电缆', 'success')
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 360))
|
||||
const status = document.getElementById('status-message')
|
||||
const brand = document.getElementById('brand-panel')
|
||||
const ticker = document.getElementById('desktop-news-ticker')
|
||||
const rect = status?.getBoundingClientRect()
|
||||
const brandRect = brand?.getBoundingClientRect()
|
||||
const tickerRect = ticker?.getBoundingClientRect()
|
||||
return {
|
||||
text: status?.innerText || '',
|
||||
visible: status?.classList.contains('visible') === true,
|
||||
left: rect?.left || 0,
|
||||
top: rect?.top || 0,
|
||||
brandRight: brandRect?.right || 0,
|
||||
tickerBottom: tickerRect?.bottom || 0,
|
||||
width: rect?.width || 0,
|
||||
maxWidth: status instanceof HTMLElement ? getComputedStyle(status).maxWidth : '',
|
||||
}
|
||||
})
|
||||
if (
|
||||
/[\u4e00-\u9fff]/.test(earthNotificationState.text)
|
||||
|| !earthNotificationState.text.includes('Loaded 12 cables')
|
||||
|| !earthNotificationState.visible
|
||||
|| earthNotificationState.left < earthNotificationState.brandRight + 8
|
||||
|| earthNotificationState.left > earthNotificationState.brandRight + 28
|
||||
|| earthNotificationState.top < earthNotificationState.tickerBottom + 2
|
||||
|| earthNotificationState.width < 120
|
||||
|| earthNotificationState.width > 240
|
||||
) {
|
||||
throw new Error(`/earth i18n settings: English notification text or width regressed ${JSON.stringify(earthNotificationState)}`)
|
||||
}
|
||||
const earthTooltipState = await earthFrame.evaluate(async () => {
|
||||
const i18n = await import('/earth/js/i18n.js')
|
||||
const ui = await import('/earth/js/ui.js')
|
||||
const countryName = i18n.localizeCountryName({ name: 'China', nameZh: '中国', continent: 'Asia' }, 'en-US')
|
||||
const content = [
|
||||
`<strong>${countryName}</strong>`,
|
||||
`ISO: CHN`,
|
||||
`${i18n.translateText('大洲', 'en-US')}: ${i18n.translateText('亚洲', 'en-US')}`,
|
||||
`${i18n.translateText('纬度', 'en-US')}: 31.2300°`,
|
||||
`${i18n.translateText('经度', 'en-US')}: 121.4700°`,
|
||||
`${i18n.translateText('海拔', 'en-US')}: 8 m`,
|
||||
`${i18n.translateText('country', 'en-US')}: ${i18n.localizeCountryName('中国', 'en-US')}`,
|
||||
].join('<br>')
|
||||
ui.showTooltip(180, 180, content)
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve))
|
||||
const tooltip = document.getElementById('tooltip')
|
||||
const rect = tooltip?.getBoundingClientRect()
|
||||
return {
|
||||
text: tooltip?.innerText || '',
|
||||
display: tooltip instanceof HTMLElement ? getComputedStyle(tooltip).display : '',
|
||||
width: rect?.width || 0,
|
||||
}
|
||||
})
|
||||
if (
|
||||
/[\u4e00-\u9fff]/.test(earthTooltipState.text)
|
||||
|| !earthTooltipState.text.includes('China')
|
||||
|| !earthTooltipState.text.includes('Continent: Asia')
|
||||
|| !earthTooltipState.text.includes('Latitude')
|
||||
|| !earthTooltipState.text.includes('Longitude')
|
||||
|| !earthTooltipState.text.includes('Altitude')
|
||||
|| !earthTooltipState.text.includes('Country: China')
|
||||
) {
|
||||
throw new Error(`/earth i18n settings: English tooltip translation regressed ${JSON.stringify(earthTooltipState)}`)
|
||||
}
|
||||
await earthFrame.evaluate(() => document.getElementById('settings-close')?.click())
|
||||
await earthFrame.waitForFunction(() => (
|
||||
document.getElementById('news-ticker-track')?.innerText.includes('Officials said')
|
||||
), null, { timeout: 10000 })
|
||||
await earthFrame.evaluate(() => document.getElementById('desktop-news-ticker')?.click())
|
||||
await earthFrame.waitForFunction(() => (
|
||||
!document.getElementById('news-hud-panel')?.classList.contains('hud-panel-hidden')
|
||||
), null, { timeout: 5000 })
|
||||
const newsHudText = await earthFrame.evaluate(() => (
|
||||
document.getElementById('news-hud-panel')?.innerText || ''
|
||||
))
|
||||
if (/[\u4e00-\u9fff]/.test(newsHudText)) {
|
||||
throw new Error('/earth i18n settings: English news panel contains Chinese text')
|
||||
}
|
||||
const detailCardText = await earthFrame.evaluate(async () => {
|
||||
const card = await import('/earth/js/info-card.js')
|
||||
card.showInfoCard('satellite', {
|
||||
name: 'SmokeSat',
|
||||
norad_id: '12345',
|
||||
constellation: 'Smoke',
|
||||
footprint_capability: '支持 Starlink 地表覆盖',
|
||||
current_display: '真实地表覆盖(Starlink)',
|
||||
footprint_model: 'Starlink 单星地表覆盖',
|
||||
inclination: '53.00',
|
||||
period: '90.0',
|
||||
perigee: '550',
|
||||
apogee: '560',
|
||||
field_sources: { current_display: 'smoke' },
|
||||
}, { x: 460, y: 180, absolute: true })
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve))
|
||||
const panel = document.getElementById('info-panel')
|
||||
const titleText = Array.from(panel?.querySelectorAll('[title]') || [])
|
||||
.map((element) => element.getAttribute('title') || '')
|
||||
.join('\\n')
|
||||
return [panel?.innerText || '', titleText].join('\\n')
|
||||
})
|
||||
if (/[\u4e00-\u9fff]/.test(detailCardText)) {
|
||||
throw new Error('/earth i18n settings: English detail card contains Chinese text')
|
||||
}
|
||||
await earthFrame.evaluate(async () => {
|
||||
const tv = await import('/earth/js/tv.js')
|
||||
await tv.ensureTVPanelReady()
|
||||
})
|
||||
await earthFrame.waitForFunction(() => (
|
||||
document.getElementById('tv-source-title')?.innerText.includes('Cctv News')
|
||||
), null, { timeout: 5000 })
|
||||
const tvText = await earthFrame.evaluate(() => [
|
||||
document.getElementById('tv-source-title')?.innerText || '',
|
||||
document.getElementById('tv-source-origin')?.innerText || '',
|
||||
document.getElementById('tv-source-status')?.innerText || '',
|
||||
document.getElementById('tv-source-catalog')?.innerText || '',
|
||||
document.getElementById('tv-source-notes')?.innerText || '',
|
||||
Array.from(document.querySelectorAll('#tv-source-select option')).map((option) => option.textContent || '').join(' '),
|
||||
].join('\n'))
|
||||
if (!tvText.includes('Default') || !tvText.includes('Built-in')) {
|
||||
throw new Error('/earth i18n settings: English TV panel is missing localized default/built-in labels')
|
||||
}
|
||||
if (/[\u4e00-\u9fff]/.test(tvText)) {
|
||||
throw new Error('/earth i18n settings: English TV panel contains Chinese text')
|
||||
}
|
||||
await page.evaluate(() => {
|
||||
localStorage.setItem('planet-locale', 'zh-CN')
|
||||
localStorage.setItem('docs-lang', 'zh')
|
||||
})
|
||||
} catch (error) {
|
||||
failures.push(error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
|
||||
smokeProgress('protected route guards')
|
||||
await page.evaluate(() => {
|
||||
localStorage.setItem('planet-locale', 'zh-CN')
|
||||
localStorage.setItem('docs-lang', 'zh')
|
||||
}).catch(() => {})
|
||||
for (const route of adminRoutes) {
|
||||
try {
|
||||
smokeProgress(`guard ${route}`, 'verbose')
|
||||
@@ -1292,6 +1971,28 @@ async function main() {
|
||||
failures.push(error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
|
||||
try {
|
||||
smokeProgress('english login backend error i18n')
|
||||
await page.goto(urlFor('/login'), { waitUntil: 'domcontentloaded' })
|
||||
await page.evaluate(() => {
|
||||
localStorage.setItem('planet-locale', 'en-US')
|
||||
localStorage.setItem('docs-lang', 'en')
|
||||
})
|
||||
await page.reload({ waitUntil: 'domcontentloaded' })
|
||||
await page.getByLabel('Username').fill('smoke-user')
|
||||
await page.getByLabel('Password').fill('wrong-password')
|
||||
await page.getByRole('button', { name: 'Log in' }).click()
|
||||
await expectVisibleText(page, 'Login failed. Check your account or password.', 'english login backend error i18n')
|
||||
await checkNoUnexpectedEnglishCjk(page, 'english login backend error i18n')
|
||||
await checkNoFrameworkOverlay(page, 'english login backend error i18n')
|
||||
await page.evaluate(() => {
|
||||
localStorage.setItem('planet-locale', 'zh-CN')
|
||||
localStorage.setItem('docs-lang', 'zh')
|
||||
})
|
||||
} catch (error) {
|
||||
failures.push(error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
|
||||
await runAuthenticatedAdminChecks(browser, failures, consoleErrors, {
|
||||
label: 'desktop',
|
||||
viewport: desktopViewport,
|
||||
|
||||