Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bb185507d9 | |||
|
|
a54fcdbeed | ||
|
|
1dd2921674 | ||
|
|
d30f7d08c5 | ||
|
|
5bdb55f3f1 | ||
|
|
fbecf30513 | ||
|
|
19d5ac0fee | ||
|
|
3265d22af5 | ||
|
|
899e3bce43 | ||
|
|
53dc28e781 | ||
|
|
b7212d48d9 | ||
|
|
adc5aabcfc | ||
|
|
61baee00f6 | ||
|
|
620190819b | ||
|
|
f67d6bde60 | ||
|
|
36672e4c53 | ||
|
|
506402ce16 | ||
|
|
9d135bf2e1 | ||
|
|
49a9c33836 |
@@ -1,139 +0,0 @@
|
||||
---
|
||||
description: 审查当前工作区未提交代码中的垃圾代码,并在不影响逻辑的前提下自动清理
|
||||
argument-hint: 可选:指定要检查的文件或目录(默认检查所有未提交修改)
|
||||
allowed-tools: ["Read", "Edit", "Bash", "Grep", "Glob"]
|
||||
---
|
||||
|
||||
# /cleanup — 垃圾代码审查与清理
|
||||
|
||||
分析当前工作区(git diff)中的未提交代码,找出并修复常见垃圾代码,**不得改变任何运行逻辑**。
|
||||
|
||||
## 检查范围
|
||||
|
||||
若 `$ARGUMENTS` 非空,则只检查指定文件/目录;否则检查所有未提交修改(`git diff HEAD`)。
|
||||
|
||||
## 节省上下文规则
|
||||
|
||||
优先用确定性的 CLI 检查缩小范围,不要一上来把完整文件或大 diff 读入上下文:
|
||||
|
||||
```bash
|
||||
git diff --name-only HEAD
|
||||
git diff --unified=0 HEAD -- <path>
|
||||
git diff --check
|
||||
rg -n "TODO|FIXME|console\.log|debugger|print\(" <changed-paths>
|
||||
```
|
||||
|
||||
只有 focused diff 不足以安全判断或修改时,才读取完整文件。
|
||||
|
||||
## 审查清单
|
||||
|
||||
按优先级检查以下问题(只报告在本次 diff 中**新增或修改**的代码里存在的问题):
|
||||
|
||||
### 1. 重复逻辑 (Duplicate Logic)
|
||||
- 完全相同或高度相似的代码块在多处出现
|
||||
- 同一函数/方法被多个地方各自实现,已有公共版本未被复用
|
||||
- 相同的 DOM 查询、正则、模板字符串在同一文件重复
|
||||
|
||||
### 2. Magic Numbers / Magic Strings
|
||||
- 裸数字直接参与计算(如偏移量、时间、尺寸、阈值),没有命名常量
|
||||
- 硬编码字符串(如 id 名、状态值、URL 片段)散落在逻辑中
|
||||
- 例外:`0`, `1`, `-1`, `100`, `""` 等语义明确的惯用值不算
|
||||
|
||||
### 3. 命名问题
|
||||
- 含义不明的缩写变量(如 `or_`, `tmp2`, `x2`)
|
||||
- 命名与实际用途不符
|
||||
- 同一概念在不同地方用不同名字表达
|
||||
|
||||
### 4. 死代码 / 无效代码
|
||||
- 注释掉的旧代码块(3行以上)
|
||||
- 声明后从未使用的变量/参数/导入
|
||||
- 永远不会执行的条件分支
|
||||
|
||||
### 5. 代码风格问题
|
||||
- 尾部空白字符(trailing whitespace)
|
||||
- 同一文件内风格不一致(如混用单双引号、缩进不统一)
|
||||
- 空行使用不一致(连续多个空行等)
|
||||
|
||||
### 6. 其他常见问题
|
||||
- 私有辅助函数应被 export 但没有,导致调用方重复实现
|
||||
- 类型/接口重复定义
|
||||
- 过于冗长的条件表达式可以简化(不改逻辑)
|
||||
|
||||
## 执行步骤
|
||||
|
||||
### Step 1 — 获取待检查文件列表
|
||||
|
||||
```bash
|
||||
# 无参数时:获取所有未提交修改
|
||||
git diff HEAD --name-only
|
||||
|
||||
# 有参数时:用 $ARGUMENTS 过滤
|
||||
```
|
||||
|
||||
### Step 2 — 逐文件阅读并分析
|
||||
|
||||
先从 focused diff 开始:
|
||||
|
||||
```bash
|
||||
git diff --unified=0 HEAD -- <file>
|
||||
```
|
||||
|
||||
用 `rg`、`git diff --check`、编译器或 linter 输出确认确定性问题。只有需要上下文时才用 Read 读取完整文件。对照审查清单,记录每个问题:文件名、行号、问题类型、建议修复方式。
|
||||
|
||||
### Step 3 — 报告问题清单
|
||||
|
||||
在修改前,先以列表形式输出所有发现的问题:
|
||||
|
||||
```
|
||||
发现 N 个问题:
|
||||
|
||||
[文件] js/foo.js
|
||||
· L34, L78: 重复逻辑 — 两处都实现了相同的 DOM 查询,可提取到 getPanel()
|
||||
· L91: Magic number — 硬编码 14 作为偏移量,应命名为 TOOLTIP_OFFSET
|
||||
|
||||
[文件] js/bar.js
|
||||
· L12: 命名问题 — 变量 `or_` 语义不明,应命名为 outerR/outerG/outerB
|
||||
...
|
||||
```
|
||||
|
||||
如果没有发现问题,直接输出"未发现垃圾代码,当前代码质量良好。"并停止。
|
||||
|
||||
### Step 4 — 执行修复
|
||||
|
||||
对每个问题,使用 Edit 工具进行**最小化修改**:
|
||||
|
||||
- **重复逻辑**:提取为共享常量/函数,更新所有调用点
|
||||
- **Magic number**:在文件顶部或逻辑附近声明 `const NAME = value`,替换所有引用
|
||||
- **命名问题**:重命名变量,更新所有使用处
|
||||
- **死代码**:直接删除
|
||||
- **尾部空白/风格**:修正
|
||||
- **未 export 的函数**:添加 `export`,在调用方改为导入(不重复实现)
|
||||
|
||||
**修复原则:**
|
||||
- 只改在审查清单中发现的问题,不做额外优化
|
||||
- 每次 Edit 只修改确实有问题的行,保持 diff 最小
|
||||
- 改完后用 `grep` 验证旧的坏代码已消失
|
||||
- 优先做精确补丁;只有仓库已有对应格式化流程时,才运行格式化工具
|
||||
|
||||
### Step 5 — 输出总结
|
||||
|
||||
```
|
||||
清理完成:
|
||||
|
||||
修复了 N 个问题:
|
||||
✓ earth.js — 提取重复 vertexShader 为 ATMOS_VERTEX_SHADER 常量
|
||||
✓ main.js — 提取 TOOLTIP_CURSOR_OFFSET = 14(4处引用)
|
||||
✓ controls.js — export updateLayerButtonState,移除 main.js 中的重复实现
|
||||
...
|
||||
|
||||
未修改的问题(需人工确认):
|
||||
! foo.js L45 — 注释代码块较长,建议手动确认是否可删除
|
||||
```
|
||||
|
||||
## 约束
|
||||
|
||||
- **禁止**改变函数签名、接口定义、导出 API(除非问题正是私有函数应被 export)
|
||||
- **禁止**添加新功能、新抽象、新参数
|
||||
- **禁止**修改注释内容(只删除注释掉的死代码)
|
||||
- **禁止**修改测试文件逻辑
|
||||
- 如果一个 Magic number 的语义不完全确定,**跳过**,在总结中标记为"需人工确认"
|
||||
@@ -1,104 +0,0 @@
|
||||
---
|
||||
description: Create or update repository documentation from current code changes
|
||||
argument-hint: Optional: topic to document, or leave empty to infer from git diff
|
||||
allowed-tools: ["Read", "Edit", "Write", "Bash", "Glob", "Grep"]
|
||||
---
|
||||
|
||||
# /docs — Documentation Workflow
|
||||
|
||||
## Goal
|
||||
|
||||
Create or update documentation that explains why a change exists, how it behaves, and what maintainers need to know. Keep this command generic. Repository-specific coverage rules live in the repository and must be loaded separately.
|
||||
|
||||
## Repository Rules
|
||||
|
||||
Before deciding scope, check whether the repository has a documentation rules file:
|
||||
|
||||
```bash
|
||||
test -f docs/documentation-coverage-rules.md && sed -n '1,240p' docs/documentation-coverage-rules.md
|
||||
```
|
||||
|
||||
If it exists, apply it as the project-specific coverage checklist. If it does not exist, continue with the generic workflow below.
|
||||
|
||||
## Workflow
|
||||
|
||||
### Step 1 — Understand The Change
|
||||
|
||||
```bash
|
||||
git diff HEAD --stat
|
||||
git diff HEAD --name-only
|
||||
git log --oneline -10
|
||||
rg --files docs
|
||||
```
|
||||
|
||||
If `$ARGUMENTS` specifies a topic, focus on that topic. Otherwise infer the documentation topic from the changed files. Do not read the full repository diff by default; inspect focused files only:
|
||||
|
||||
```bash
|
||||
git diff HEAD -- <path>
|
||||
rg -n "class |def |function |export |router|@router|interface |type " <path>
|
||||
```
|
||||
|
||||
### Step 2 — Decide Scope
|
||||
|
||||
- Prefer updating an existing relevant document over creating a duplicate.
|
||||
- Use one document for one coherent topic.
|
||||
- Split documents only when the change crosses meaningful domains.
|
||||
- Keep filenames lowercase and hyphenated.
|
||||
- Apply the repository-specific rules file before writing.
|
||||
|
||||
#### Document Audience Routing (Planet)
|
||||
|
||||
In this repository, classify the action's performer before picking a target file:
|
||||
|
||||
- Browser/UI end user → `docs/technical/{zh,en}/manual.md` or `quickstart.md`.
|
||||
- Shell / Docker / log paths / `planet.sh` / SMTP fallbacks / port forwarding → `docs/technical/{zh,en}/ops-runbook.md` (or an existing `ops-*.md`).
|
||||
- Second-party developers → existing `*-context.md` / `backend-*.md` / `earth-*.md` files.
|
||||
|
||||
Never put shell commands, log paths, or Docker operations into `manual.md` / `quickstart.md`. Never put UI button labels or screenshots into `ops-*.md`. When the same action has both a UI and a CLI path, write each in its own home and cross-link them with one sentence.
|
||||
|
||||
For ambiguous or large documentation changes, briefly state the intended doc plan before editing. For clear small changes, proceed directly.
|
||||
|
||||
### Step 3 — Write
|
||||
|
||||
Explain:
|
||||
|
||||
- Background/problem: what was wrong or missing before.
|
||||
- Core design decisions and rationale.
|
||||
- Operational or user-facing impact.
|
||||
- Relevant code paths, only when useful for future maintainers.
|
||||
|
||||
Style:
|
||||
|
||||
- Follow the repository’s existing language and heading conventions.
|
||||
- Use fenced code blocks with language tags.
|
||||
- Prefer tables for comparisons or parameter lists.
|
||||
- Keep snippets concise and relevant.
|
||||
- For UI labels, chart labels, feature names, datasource names, and other terms that may become mixed Chinese/English copy, check `docs/technical/{zh,en}/naming-glossary.md` and use the documented display name. If a confusing term is missing, update the glossary in both languages as part of the docs change.
|
||||
|
||||
### Step 4 — Verify
|
||||
|
||||
- Read the completed docs once for clarity and stale statements.
|
||||
- Verify referenced paths exist with `test -e` or `rg --files`.
|
||||
- Run applicable checks from `docs/documentation-coverage-rules.md`.
|
||||
- Check Markdown links use readable user-facing titles unless repository rules allow otherwise.
|
||||
|
||||
### Step 5 — Report
|
||||
|
||||
Summarize changed docs and verification:
|
||||
|
||||
```md
|
||||
Updated:
|
||||
- path/to/doc.md — what changed
|
||||
|
||||
Verified:
|
||||
- checks that passed
|
||||
- checks that could not be run, if any
|
||||
```
|
||||
|
||||
## Hard Constraints
|
||||
|
||||
- Do not leave placeholder docs.
|
||||
- Do not duplicate bilingual files byte-for-byte.
|
||||
- Do not reference PR numbers, issue numbers, or the current conversation unless explicitly requested.
|
||||
- Do not write changelog-style lists without the reasoning and tradeoffs behind the change.
|
||||
- Keep docs maintainable and concise.
|
||||
@@ -1,93 +0,0 @@
|
||||
---
|
||||
description: 用 goal-driven 方法推动一个复杂任务持续执行,直到明确成功标准被满足
|
||||
argument-hint: 建议填写任务目标;若同时给出成功标准更好
|
||||
allowed-tools: ["Read", "Edit", "Bash", "Grep", "Glob"]
|
||||
---
|
||||
|
||||
# /goal-driven — 目标驱动执行模式
|
||||
|
||||
使用 `lidangzzz/goal-driven` 的核心思想来推进复杂任务:先固定目标与成功标准,再持续执行和反复验收,直到标准真正满足。
|
||||
|
||||
适用场景:
|
||||
|
||||
- 长周期实现任务
|
||||
- 高复杂度工程任务
|
||||
- 可被明确验收的研究、实现、迁移、验证类工作
|
||||
|
||||
不适用场景:
|
||||
|
||||
- 纯脑暴
|
||||
- 无法定义成功标准的模糊任务
|
||||
- 很小的一次性修改
|
||||
|
||||
## 输入要求
|
||||
|
||||
若 `$ARGUMENTS` 只包含目标,没有成功标准,先补全一版可执行的成功标准再开始。
|
||||
|
||||
启动时先输出:
|
||||
|
||||
```md
|
||||
Goal
|
||||
- ...
|
||||
|
||||
Criteria for success
|
||||
- ...
|
||||
|
||||
Plan
|
||||
1. ...
|
||||
2. ...
|
||||
3. ...
|
||||
|
||||
Verification
|
||||
- ...
|
||||
```
|
||||
|
||||
## 执行规则
|
||||
|
||||
1. 先把任务固化为两个核心块:
|
||||
- `Goal`
|
||||
- `Criteria for success`
|
||||
|
||||
2. 成功标准必须尽量客观,可验证,可落地。
|
||||
优先写成:
|
||||
- 需要交付什么
|
||||
- 需要通过哪些测试或验证
|
||||
- 如何判断结果真的完成
|
||||
|
||||
3. 进入持续执行循环:
|
||||
- 完成一个阶段
|
||||
- 检查当前结果是否满足成功标准
|
||||
- 若未满足,明确剩余差距并继续推进
|
||||
|
||||
4. 任何“完成了”“差不多了”“已实现”之类的结论,都必须经过验证,不能直接接受。
|
||||
|
||||
5. 如果验证失败:
|
||||
- 明确指出哪条成功标准没满足
|
||||
- 继续工作,不要把阶段性进展误判为完成
|
||||
|
||||
6. 只有在以下情况之一才能停止:
|
||||
- 成功标准已满足
|
||||
- 用户明确要求停止
|
||||
|
||||
## 执行风格
|
||||
|
||||
- 重证据,轻口头判断
|
||||
- 优先使用确定性工具证据:`rg`、`git diff --stat`、`git diff -- <path>`、测试、构建、lint、`curl`、数据库查询等能直接证明成功标准的方式
|
||||
- 不把大段命令输出粘进回复;保留在工具调用里,回复只总结关键证据
|
||||
- 重验收,轻自我感觉
|
||||
- 优先用测试、日志、产物、对比结果来证明完成
|
||||
- 对长期任务保持“未达标就继续”的节奏
|
||||
|
||||
## 简版模板
|
||||
|
||||
```md
|
||||
Goal: [[[[[在此填写最终目标]]]]]
|
||||
|
||||
Criteria for success: [[[[[在此填写成功标准]]]]]
|
||||
|
||||
循环执行:
|
||||
1. 推进任务
|
||||
2. 检查是否满足成功标准
|
||||
3. 若未满足,继续工作
|
||||
4. 直到满足标准或用户明确停止
|
||||
```
|
||||
@@ -1,160 +0,0 @@
|
||||
---
|
||||
description: 发版工作流:根据变更类型决定版本号,更新所有版本文件和 changelog,运行验证,commit 并 push
|
||||
argument-hint: 可选:feature | bugfix | 或直接描述本次发布内容
|
||||
allowed-tools: ["Read", "Edit", "Bash", "Glob", "Grep"]
|
||||
---
|
||||
|
||||
# /release — Planet 发版工作流
|
||||
|
||||
## 版本号规则
|
||||
|
||||
| 变更类型 | 版本跳动 | 适用场景 |
|
||||
|---------|---------|---------|
|
||||
| `feature` | `+0.1.0` | 纯新功能,无 bugfix |
|
||||
| `improvement` | `+0.0.1` | UI 调整、小功能增强、bugfix 混合,或以 UI/体验改进为主的迭代 |
|
||||
| `bugfix` | `+0.0.1` | 纯 bug 修复,无新功能 |
|
||||
| `docs` / `maintenance` / `refactor` | 默认不发版,除非用户明确要求 |
|
||||
|
||||
意图混合时以用户明确描述为准;bugfix + 小 feature 混合默认判定为 `improvement`(`+0.0.1`)。
|
||||
|
||||
## 必须同步更新的文件
|
||||
|
||||
使用 `git rev-parse --show-toplevel` 获取仓库根目录,以下路径均相对于根目录:
|
||||
|
||||
- `VERSION`
|
||||
- `frontend/package.json`(`"version"` 字段)
|
||||
- `pyproject.toml`(`version =` 字段)
|
||||
- `uv.lock`(**不要手动编辑**,通过 `uv lock` 重新生成)
|
||||
- `docs/CHANGELOG.md`
|
||||
- `docs/version-history.md`
|
||||
|
||||
## 节省上下文规则
|
||||
|
||||
发版判断应以确定性 CLI 证据为主,优先使用紧凑命令和定点读取:
|
||||
|
||||
```bash
|
||||
git status --short
|
||||
git diff --stat HEAD
|
||||
git diff --name-only HEAD
|
||||
rg -n "version|^## |^Released:|当前开发版本|current" VERSION frontend/package.json pyproject.toml docs/CHANGELOG.md docs/version-history.md
|
||||
```
|
||||
|
||||
除非需要判断某个代码变更是否属于本次发版,否则不要读取完整 diff。
|
||||
|
||||
## 执行步骤
|
||||
|
||||
### Step 1 — 环境检查
|
||||
|
||||
```bash
|
||||
git branch --show-current # 确认在 dev 分支
|
||||
git status --short # 检查是否有无关的未暂存修改
|
||||
cat VERSION # 读取当前版本
|
||||
```
|
||||
|
||||
若当前**不在 `dev` 分支**,停下来告知用户,不要继续。
|
||||
|
||||
若存在无关的未暂存修改,列出并询问用户是否一并提交,或先 stash。
|
||||
|
||||
### Step 2 — 确定发版类型与新版本号
|
||||
|
||||
- 若 `$ARGUMENTS` 提供了明确类型(`feature` / `bugfix`),直接使用
|
||||
- 否则根据 `git diff --stat HEAD`、`git diff --name-only HEAD`、必要的 focused diff 和 `git log` 推断
|
||||
- 计算新版本号(例:`0.26.2` → bugfix → `0.26.3`)
|
||||
- **先输出发版计划供用户确认**:
|
||||
|
||||
```
|
||||
发版计划:
|
||||
类型:bugfix
|
||||
版本:0.26.2 → 0.26.3
|
||||
分支:dev
|
||||
将更新:VERSION, frontend/package.json, pyproject.toml, uv.lock, CHANGELOG.md, version-history.md
|
||||
```
|
||||
|
||||
### Step 3 — 更新版本号文件
|
||||
|
||||
按顺序更新(每步用 Edit 工具,精确替换,不要重写整个文件):
|
||||
|
||||
1. `VERSION` — 直接替换全部内容为新版本号
|
||||
2. `frontend/package.json` — 替换 `"version": "x.x.x"` 行
|
||||
3. `pyproject.toml` — 替换 `version = "x.x.x"` 行
|
||||
4. 运行 `uv lock` 重新生成 `uv.lock`(在仓库根目录下执行)
|
||||
|
||||
### Step 4 — 更新 CHANGELOG.md
|
||||
|
||||
在文件顶部插入新条目,格式:
|
||||
|
||||
```markdown
|
||||
## [x.x.x] — YYYY-MM-DD
|
||||
|
||||
### ✨ Features / 🐛 Fixes / 🔧 Improvements
|
||||
- ...(只列高信号条目,最多 5 条)
|
||||
- ...
|
||||
|
||||
---
|
||||
```
|
||||
|
||||
日期使用 `date +%Y-%m-%d` 获取今天的日期。
|
||||
|
||||
### Step 5 — 更新 docs/version-history.md
|
||||
|
||||
- 更新文件头部的"当前开发版本"字段
|
||||
- 在时间线表格顶部插入新行:`| vx.x.x | YYYY-MM-DD | 一句话摘要 |`
|
||||
|
||||
### Step 6 — 验证
|
||||
|
||||
针对本次变更范围做最小验证:
|
||||
|
||||
- Python 文件有修改:先用 `git diff --name-only HEAD -- '*.py'` 列出,再运行 `python3 -m py_compile <changed_files>`
|
||||
- Frontend 文件有修改:先用 `git diff --name-only HEAD -- frontend` 判断范围,再运行项目标准检查(若无则跳过并说明)
|
||||
- 版本号一致性检查:用 grep 确认 VERSION、package.json、pyproject.toml 中的版本号完全一致
|
||||
|
||||
```bash
|
||||
cat VERSION
|
||||
rg -n "\"version\":|^version =|version = " frontend/package.json pyproject.toml uv.lock
|
||||
```
|
||||
|
||||
### Step 7 — 提交前预览
|
||||
|
||||
展示将要提交的文件列表:
|
||||
|
||||
```bash
|
||||
git diff --stat HEAD
|
||||
```
|
||||
|
||||
再次确认所有必须文件都在变更列表中,**不包含**非预期文件(如调试文件、.env 等)。
|
||||
|
||||
### Step 8 — Commit & Push(用户确认后)
|
||||
|
||||
```bash
|
||||
git add VERSION frontend/package.json pyproject.toml uv.lock docs/CHANGELOG.md docs/version-history.md
|
||||
# 若有代码变更也一并 stage
|
||||
git add <code_files>
|
||||
|
||||
git commit -m "release: bump version to x.x.x"
|
||||
git tag vx.x.x
|
||||
git push origin dev
|
||||
git push origin vx.x.x
|
||||
```
|
||||
|
||||
commit message 固定格式:`release: bump version to x.x.x`
|
||||
|
||||
### Step 9 — 完成确认
|
||||
|
||||
输出摘要:
|
||||
|
||||
```
|
||||
✓ 版本号已更新:0.26.2 → 0.26.3
|
||||
✓ CHANGELOG 已更新
|
||||
✓ version-history 已更新
|
||||
✓ uv.lock 已重新生成
|
||||
✓ 验证通过
|
||||
✓ commit: release: bump version to 0.26.3
|
||||
✓ tag: v0.26.3
|
||||
✓ 已 push 到 origin/dev
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
- `uv.lock` 只能通过 `uv lock` 生成,绝不手动编辑
|
||||
- 发版 commit 只包含版本文件 + 本次功能代码,不混入无关改动
|
||||
- 若环境中 `uv` 不可用,说明原因并跳过 lockfile 更新,提醒用户手动运行
|
||||
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 |
5
.gitignore
vendored
@@ -25,7 +25,10 @@ __pycache__/
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
downloads/*
|
||||
!downloads/usbipd-win/
|
||||
downloads/usbipd-win/*
|
||||
!downloads/usbipd-win/usbipd-win-5.3.0.msi
|
||||
eggs/
|
||||
.eggs/
|
||||
/lib/
|
||||
|
||||
180
AGENTS.md
Normal file
@@ -0,0 +1,180 @@
|
||||
# AGENTS.md
|
||||
|
||||
**Planet agent harness. Defines behavior for coding agents working in this repository.**
|
||||
|
||||
---
|
||||
|
||||
## Harness Compatibility
|
||||
|
||||
This file is the single authoritative agent guide for the Planet repository.
|
||||
The older lowercase `agents.md` entry has been merged here so coding agents and
|
||||
harness tools use one source of truth.
|
||||
|
||||
### Source Of Truth
|
||||
|
||||
- `rules.md` is the mandatory repository rule source. Always load `core`,
|
||||
`security`, and `workflow`; load only task-relevant modules after that.
|
||||
- `AGENTS.md` defines the local agent operating mode and evidence gates.
|
||||
- `project_context.md` is background, not a rule source. Prefer newer
|
||||
implementation docs when it disagrees with current code.
|
||||
- `.codex/skills/` is the active specialized workflow layer for cleanup, docs,
|
||||
goal-driven work, and release.
|
||||
- Do not duplicate long workflow text across harness files. Durable constraints
|
||||
belong in `rules.md`; task procedures belong in skills or scripts.
|
||||
|
||||
Read these files before changing code:
|
||||
|
||||
1. `rules.md`
|
||||
2. `AGENTS.md`
|
||||
3. `project_context.md`
|
||||
4. `README.md`
|
||||
5. `docs/HARNESS.md`
|
||||
6. `CODEMAP.md`
|
||||
|
||||
For documentation work, also read `docs/documentation-coverage-rules.md`.
|
||||
|
||||
### Start Safely
|
||||
|
||||
Before broad edits:
|
||||
|
||||
```bash
|
||||
git status --short
|
||||
scripts/harness/doctor.sh
|
||||
```
|
||||
|
||||
Use focused context commands before reading large files:
|
||||
|
||||
```bash
|
||||
rg -n "<symbol-or-term>" <path>
|
||||
git diff --stat HEAD
|
||||
git diff --name-only HEAD
|
||||
git diff --unified=0 HEAD -- <path>
|
||||
```
|
||||
|
||||
Preserve user changes already present in the worktree.
|
||||
|
||||
### Validation
|
||||
|
||||
Fast local harness validation:
|
||||
|
||||
```bash
|
||||
scripts/harness/quick-check.sh
|
||||
```
|
||||
|
||||
Full local validation:
|
||||
|
||||
```bash
|
||||
scripts/harness/validate.sh
|
||||
```
|
||||
|
||||
`validate.sh` includes quick checks, frontend Bun build, and frontend smoke
|
||||
unless disabled by its documented environment flags. Docker image smoke builds
|
||||
are intentionally opt-in:
|
||||
|
||||
```bash
|
||||
PLANET_HARNESS_DOCKER_SMOKE=1 scripts/harness/validate.sh
|
||||
```
|
||||
|
||||
Harness scripts resolve `bun`, `uv`, and optional delivery tools from the
|
||||
current non-interactive environment first. If a tool is missing there, they ask
|
||||
the user's login interactive shell instead of assuming a specific dotfile.
|
||||
|
||||
### High-Risk Areas
|
||||
|
||||
- `planet.sh` owns local lifecycle, ports, WSL/LAN behavior, and destructive
|
||||
`destroy` cleanup.
|
||||
- Frontend package management is Bun-only. Do not use npm, pnpm, or yarn.
|
||||
- Frontend changes must satisfy `scripts/harness/frontend-rules-check.sh`; use
|
||||
rendered smoke evidence for public pages, auth guards, authenticated admin
|
||||
route/section availability, safe navigation/search/tab interactions, mobile
|
||||
layout, and 125% / 150% zoom, not only a build.
|
||||
- Admin or Docs layout changes must load `rules.md` `uiux` and preserve the
|
||||
one-screen (`一屏` / `首屏`) height chain: route roots use `height: 100%`,
|
||||
intermediate wrappers keep `min-height: 0`, and only the intended child owns
|
||||
scrolling.
|
||||
- `aiprovider` is a protocol/provider adapter; keep business prompts and product
|
||||
workflows in the backend.
|
||||
- Earth rendering depends on layer order, depth behavior, picking, and
|
||||
performance-sensitive Three.js code.
|
||||
- Secrets belong in environment files or configured settings stores, never in
|
||||
committed files.
|
||||
- Backend service code must use structured logging instead of `print()` or
|
||||
debugger calls; `scripts/harness/backend-rules-check.sh` enforces this.
|
||||
|
||||
### Conflict Policy
|
||||
|
||||
Existing project rules and workflows win. If new harness guidance conflicts with
|
||||
`rules.md`, `AGENTS.md`, current docs, scripts, or CI, keep the existing
|
||||
behavior and document the compatibility note in `docs/harness-audit.md` or
|
||||
`docs/HARNESS.md`.
|
||||
|
||||
---
|
||||
|
||||
## Operating Mode
|
||||
|
||||
- Default to acting directly when the user gives a clear task.
|
||||
- Ask before acting only when the missing decision is risky, cannot be
|
||||
discovered from repository context, and no conservative assumption is safe.
|
||||
- Read relevant files before editing.
|
||||
- Prefer focused CLI evidence: `rg`, `git diff --stat`, `git diff --name-only`,
|
||||
focused file reads, tests, builds, linters, and harness scripts.
|
||||
- Keep changes scoped to the requested area. Do not mix cleanup, feature work,
|
||||
release work, and documentation unless the task requires it.
|
||||
|
||||
---
|
||||
|
||||
## Evidence Gates
|
||||
|
||||
- Visual inputs are blocking evidence. If the user provides a screenshot, image,
|
||||
mock, browser capture, or visual reference, obtain evidence from the artifact
|
||||
before interpreting intent or editing code.
|
||||
- Path resolution is part of the task. If the path cannot be opened, first try
|
||||
reasonable local equivalents such as WSL/Windows path conversion,
|
||||
workspace-relative lookup, absolute paths, and attached-file locations.
|
||||
- Never guess from prompt text, filenames, previous context, logs, OCR, or
|
||||
memory when a visual artifact was provided but cannot be accessed.
|
||||
- OCR is acceptable evidence for text-only visual questions or non-multimodal
|
||||
environments; state that OCR was used as the fallback. Layout, color, spacing,
|
||||
pixel, and rendering issues need real visual inspection or a clear limitation
|
||||
note.
|
||||
- If a visual artifact still cannot be inspected, say so and pause that
|
||||
visual-dependent part of the work.
|
||||
- Claims of completion need evidence: a relevant test, build, lint, screenshot,
|
||||
diff, direct file check, or harness result.
|
||||
- For UI and rendering changes, verify the rendered result when local tooling
|
||||
allows it.
|
||||
|
||||
---
|
||||
|
||||
## Communication
|
||||
|
||||
- Match the user's language. Use Chinese for Chinese requests unless the user
|
||||
asks otherwise.
|
||||
- Keep updates short and specific: what is being inspected, edited, or verified.
|
||||
- Final responses should summarize changed files and verification, with blockers
|
||||
stated plainly.
|
||||
- Use file references with line numbers when explaining code or review findings.
|
||||
|
||||
---
|
||||
|
||||
## Quality Bar
|
||||
|
||||
- Prefer existing project patterns over new abstractions.
|
||||
- Remove stale branches, mocks, compatibility paths, and duplicated helpers once
|
||||
a stable path exists.
|
||||
- Centralize prompts, constants, defaults, and shared request/response handling.
|
||||
- Do not add secrets, generated runtime output, or local environment files.
|
||||
- Frontend commands use Bun only. Do not use `npm`, `pnpm`, or `yarn`.
|
||||
- Run the smallest relevant verification for the changed scope and report
|
||||
anything skipped.
|
||||
|
||||
---
|
||||
|
||||
## Prohibited
|
||||
|
||||
- Do not skip visual evidence handling when a visual artifact was provided.
|
||||
- Do not preserve obsolete harness files just because they already exist.
|
||||
- Do not invent behavior not present in code, docs, or verified external
|
||||
sources.
|
||||
- Do not rewrite unrelated files during cleanup.
|
||||
- Do not mark a task complete without checking concrete success criteria.
|
||||
109
CODEMAP.md
Normal file
@@ -0,0 +1,109 @@
|
||||
# Code Map
|
||||
|
||||
This map gives agents and maintainers a quick orientation without replacing the
|
||||
deeper architecture docs. Current implementation docs under `docs/technical/`
|
||||
are the source of detail for specific subsystems.
|
||||
|
||||
## Top-Level Areas
|
||||
|
||||
| Path | Role | Notes |
|
||||
| --- | --- | --- |
|
||||
| `backend/` | FastAPI backend, auth, APIs, data collectors, AI task orchestration, persistence | Tests live in `backend/tests/`; run backend tests from `backend/` with the root uv project. |
|
||||
| `frontend/` | React admin console, Docs UI, Web Earth shell, Vite build | Use Bun only. Public Earth assets live under `frontend/public/earth/`. |
|
||||
| `aiprovider/` | Model provider/protocol adapter service | Keep it free of product-specific prompts and workflows. |
|
||||
| `motion_agent/` | Motion capture protocol service used by `planet.sh` | Often dry-runs when cameras are unavailable, especially in WSL. |
|
||||
| `scripts/` | Utility scripts and harness wrappers | Harness commands live in `scripts/harness/`. |
|
||||
| `docs/` | Plans, technical docs, changelog, harness docs | Public technical docs are explicitly registered by the frontend Docs catalog. |
|
||||
| `deploy/helm/planet/` | Helm chart for staging/deployment smoke paths | CI runs helm lint/template when delivery checks are available. |
|
||||
| `.gitea/workflows/` | CI, release image build, staging deploy workflows | This repository uses Gitea workflow files, not `.github/workflows/`. |
|
||||
| `planet.sh` | Main local lifecycle script | Owns init/start/restart/stop/health/log/createuser/destroy. |
|
||||
|
||||
## Runtime Entry Points
|
||||
|
||||
| Runtime | Entry Point | Validation |
|
||||
| --- | --- | --- |
|
||||
| Local full stack | `./planet.sh start` | `./planet.sh health` |
|
||||
| Backend API | `backend/app/main.py` | `cd backend && uv run --frozen --group dev --project .. python -m pytest -q` |
|
||||
| Frontend app | `frontend/src/main.tsx` and `frontend/vite.config.mts` | `cd frontend && bun run build` |
|
||||
| AI Provider | `aiprovider/main.py` | `curl http://localhost:8010/health` after startup |
|
||||
| Motion Agent | `python -m motion_agent` via `planet.sh` | `./planet.sh health` or dry-run startup |
|
||||
| Docs UI | `frontend/src/pages/Docs/` | Docs catalog metadata plus frontend build |
|
||||
|
||||
## Ownership Boundaries
|
||||
|
||||
- Backend owns business state, auth, evidence collection, prompt selection, AI
|
||||
task orchestration, and database persistence.
|
||||
- `aiprovider` owns provider identity, request adapter style, model gateway
|
||||
retries, and health/status endpoints only.
|
||||
- Frontend owns operator workflows, Docs presentation, Web Earth orchestration,
|
||||
and client-side state that mirrors backend truth.
|
||||
- Web Earth rendering changes must preserve documented layer order, altitude
|
||||
offsets, picking behavior, legend semantics, and performance constraints.
|
||||
- `planet.sh` owns local environment bootstrap and service lifecycle. Prefer
|
||||
wrapping it from harness scripts instead of duplicating its internals.
|
||||
- Harness scripts source `scripts/harness/lib.sh` so agent shells that cannot
|
||||
see `bun` or `uv` in non-interactive `PATH` can still resolve the user's login
|
||||
interactive command path without hardcoding `.zshrc`.
|
||||
|
||||
## Validation Commands
|
||||
|
||||
```bash
|
||||
scripts/harness/doctor.sh
|
||||
scripts/harness/security-check.sh
|
||||
scripts/harness/backend-rules-check.sh
|
||||
scripts/harness/frontend-rules-check.sh
|
||||
scripts/harness/docs-consistency-check.sh
|
||||
scripts/harness/quick-check.sh
|
||||
scripts/harness/validate.sh
|
||||
./planet.sh health
|
||||
```
|
||||
|
||||
CI-equivalent local checks:
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
uv run --frozen --group dev --project .. python -m pytest -s tests/test_api.py tests/test_realtime_sources.py -q
|
||||
|
||||
cd frontend
|
||||
bun install --frozen-lockfile
|
||||
bun run build
|
||||
PLANET_FRONTEND_SMOKE_URL=http://127.0.0.1:4173 bun ../scripts/harness/frontend-smoke.mjs
|
||||
```
|
||||
|
||||
The frontend smoke covers public routes, unauthenticated admin guards,
|
||||
login-error handling, the Earth iframe entry, and authenticated `super_admin`
|
||||
admin route/section rendering with mocked API data. Authenticated admin checks
|
||||
run on desktop, mobile, and 125% / 150% zoom; desktop and mobile passes also
|
||||
check for accidental global horizontal overflow. A second smoke layer exercises
|
||||
safe desktop/mobile navigation, admin search, section tab switching, dialog
|
||||
opening, and non-destructive shortcut links.
|
||||
|
||||
Optional delivery smoke, when Docker and Helm are available:
|
||||
|
||||
```bash
|
||||
PLANET_HARNESS_DOCKER_SMOKE=1 scripts/harness/validate.sh
|
||||
```
|
||||
|
||||
## Deeper Docs
|
||||
|
||||
| Topic | Start Here |
|
||||
| --- | --- |
|
||||
| Data products and flows | `docs/technical/zh/platform-data-flows.md` and `docs/technical/en/platform-data-flows.md` |
|
||||
| Operations and local lifecycle | `docs/technical/zh/ops-runbook.md` and `docs/technical/en/ops-runbook.md` |
|
||||
| `planet.sh` startup behavior | `docs/technical/zh/ops-planet-sh-startup.md` and `docs/technical/en/ops-planet-sh-startup.md` |
|
||||
| AI Provider | `docs/technical/zh/agents-aiprovider.md` and `docs/technical/en/agents-aiprovider.md` |
|
||||
| Admin frontend | `docs/technical/zh/frontend-admin-frontend-context.md` and `docs/technical/en/frontend-admin-frontend-context.md` |
|
||||
| Earth frontend | `docs/technical/zh/earth-frontend-context.md` and `docs/technical/en/earth-frontend-context.md` |
|
||||
| Earth render order | `docs/technical/zh/earth-render-layer-order.md` and `docs/technical/en/earth-render-layer-order.md` |
|
||||
| Documentation rules | `docs/documentation-coverage-rules.md` |
|
||||
| Harness workflow | `docs/HARNESS.md` |
|
||||
|
||||
## Known Sharp Edges
|
||||
|
||||
- `project_context.md` is static background for agents. It now labels future
|
||||
stack directions separately, but current code and technical docs still win
|
||||
when details diverge.
|
||||
- README now describes Web Earth, React admin, FastAPI, and `aiprovider` as the
|
||||
active local development shape.
|
||||
- Local `destroy` is intentionally destructive for Planet-owned Docker and build
|
||||
state. Never run it as a validation shortcut.
|
||||
@@ -83,7 +83,7 @@
|
||||
| 组件 | 用途 |
|
||||
|------|------|
|
||||
| React 18 | UI 框架 |
|
||||
| Ant Design Pro | 管理后台组件 |
|
||||
| Tactile UI / Radix primitives / lucide-react | 管理后台组件、基础交互与图标 |
|
||||
| Axios | HTTP 客户端 |
|
||||
| Socket.io-client | WebSocket 客户端 |
|
||||
| ECharts | 统计图表 |
|
||||
@@ -168,10 +168,12 @@
|
||||
|
||||
## 快速启动
|
||||
|
||||
入口需要先具备 `zsh`、`curl` 和可访问的软件源。Ubuntu / Ubuntu WSL 上,`init` 会自动检测并补装 Docker Engine、Compose v2 和 Buildx,启动 Docker 服务并配置当前用户的访问权限;需要系统权限时会提示输入 sudo 密码。其他系统请先准备可用的 Docker 环境。
|
||||
|
||||
```bash
|
||||
# 新机器或空项目首次初始化
|
||||
./planet.sh init
|
||||
# 会自动安装/检查 uv、bun,同步 Python/前端依赖
|
||||
# 会先准备 Docker / Compose / Buildx,再安装/检查 uv、bun 并同步 Python/前端依赖
|
||||
# 会在缺少时生成 backend/.env、aiprovider/.env、frontend/.env.local
|
||||
# 会启动 PostgreSQL/Redis,并创建表、默认数据源和本地默认用户
|
||||
|
||||
|
||||
1
TODO.md
@@ -4,6 +4,7 @@ This file is the active backlog only. Completed history belongs in `docs/CHANGEL
|
||||
|
||||
## Earth
|
||||
|
||||
- [ ] Motion Agent v2 hardening: tune the implemented MediaPipe gesture recognizer across camera placements, exercise the UE command/control client, run reconnect and dual-camera soak tests, and continue the v3 calibrated 3D roadmap described in [Motion Agent v2 Control Protocol And 3D Calibration Roadmap](/home/ray/dev/linkong/planet/docs/plans/motion-agent-v2-control-protocol-plan.md).
|
||||
- [ ] Earth AI command entry: merge natural-language and speech-triggered LLM commands into the existing Earth search panel as described in [Agent Runtime, Earth LLM Command, And Speech Entry Plan](/home/ray/dev/linkong/planet/docs/plans/agents-earth-command-runtime-plan.md).
|
||||
- [ ] Earth action executor: implement safe visualization actions for layer toggles, batch highlights, filters, focus, result panels, and clear-highlight behavior.
|
||||
- [ ] Earth entity matching: support stable entity ids and batch matching for Beidou satellites, mainland China compute centers, BGP, news, vessels, and cables.
|
||||
|
||||
231
agents.md
@@ -1,231 +0,0 @@
|
||||
# agents.md
|
||||
|
||||
**AI Agent 角色设定。定义 AI 如何行为、沟通和工作。**
|
||||
|
||||
---
|
||||
|
||||
## Identity
|
||||
|
||||
You are **opencode**, an AI coding assistant specialized in enterprise-level systems.
|
||||
|
||||
You are working on the **智能星球计划 (Intelligent Planet Plan)** - a situational awareness system for data-centric competition featuring:
|
||||
- Python FastAPI backend
|
||||
- React Admin dashboard
|
||||
- Unreal Engine 5 3D visualization
|
||||
- Multi-source data collection
|
||||
- Polarized 3D large display (4K, 120Hz)
|
||||
|
||||
---
|
||||
|
||||
## Communication Style
|
||||
|
||||
### Tone
|
||||
- **Professional but concise**
|
||||
- Technical accuracy with clarity
|
||||
- No unnecessary verbosity
|
||||
- Use code comments sparingly (explain **why**, not **what**)
|
||||
|
||||
### When Responding
|
||||
1. **Answer directly** - 1-3 sentences for simple questions
|
||||
2. **Use code blocks** for all code snippets
|
||||
3. **Include file:line_number** references when discussing code
|
||||
4. **Never** start with "I am an AI assistant" or similar phrases
|
||||
5. **Never** add unnecessary preambles/postambles
|
||||
|
||||
### Examples
|
||||
|
||||
**Good:**
|
||||
```
|
||||
GPU clusters are stored in `backend/app/services/collectors/top500.py:45`.
|
||||
```
|
||||
|
||||
**Bad:**
|
||||
```
|
||||
Based on the information you provided, I can see that the GPU clusters are stored in the top500.py file at line 45. Let me explain more about this...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Operational Mode
|
||||
|
||||
### Plan Mode (default for complex tasks)
|
||||
- Analyze requirements
|
||||
- Propose architecture
|
||||
- Confirm with user before execution
|
||||
- **DO NOT** write code until approved
|
||||
|
||||
### Build Mode (after user approval)
|
||||
- Execute the approved plan
|
||||
- Write code, run commands
|
||||
- Verify results
|
||||
- Report completion concisely
|
||||
|
||||
### Read-Only Mode
|
||||
- Analyze code
|
||||
- Explain functionality
|
||||
- Answer questions
|
||||
- **DO NOT** modify files
|
||||
|
||||
---
|
||||
|
||||
## Decision Framework
|
||||
|
||||
### When to Ask Before Acting
|
||||
- Unclear requirements
|
||||
- Multiple implementation approaches
|
||||
- Architecture changes
|
||||
- Dependency additions
|
||||
- Anything that could break existing functionality
|
||||
|
||||
### When to Act Directly
|
||||
- Clear, approved requirements
|
||||
- Routine tasks (linting, formatting, running tests)
|
||||
- Following established patterns
|
||||
- Fixing obvious bugs
|
||||
|
||||
### When to Refuse
|
||||
- Malicious code requests
|
||||
- Security violations (secrets, credentials)
|
||||
- Anything that violates `rules.md`
|
||||
|
||||
---
|
||||
|
||||
## Working Principles
|
||||
|
||||
### 1. First Understand, Then Act
|
||||
- Read relevant files before editing
|
||||
- Understand existing patterns and conventions
|
||||
- Follow the code style in the codebase
|
||||
- Match the project's technology choices
|
||||
|
||||
### 2. Incremental Progress
|
||||
- Break large tasks into smaller PRs
|
||||
- Complete one feature before starting the next
|
||||
- Run tests after each significant change
|
||||
- Commit frequently with clear messages
|
||||
|
||||
### 3. Quality First
|
||||
- Write tests for new functionality
|
||||
- Run linters before committing
|
||||
- Fix warnings, don't ignore them
|
||||
- Document non-obvious decisions
|
||||
|
||||
### 4. Communication Clarity
|
||||
- Use precise technical language
|
||||
- Show relevant code, not explanations
|
||||
- Report errors with context
|
||||
- Confirm understanding of requirements
|
||||
|
||||
---
|
||||
|
||||
## Code Review Checklist
|
||||
|
||||
Before marking a task complete:
|
||||
|
||||
- [ ] Code follows `rules.md` style guidelines
|
||||
- [ ] Type hints are correct and complete
|
||||
- [ ] Error handling is proper (no silent failures)
|
||||
- [ ] Tests pass locally
|
||||
- [ ] Linting passes
|
||||
- [ ] No TODO comments left behind
|
||||
- [ ] Documentation updated if needed
|
||||
- [ ] Commit message is clear
|
||||
|
||||
---
|
||||
|
||||
## Common Workflows
|
||||
|
||||
### Feature Development
|
||||
```
|
||||
1. Understand requirements
|
||||
2. Check existing patterns in codebase
|
||||
3. Design solution (brief mental model)
|
||||
4. Write code following rules.md
|
||||
5. Write/run tests
|
||||
6. Lint and format
|
||||
7. Commit with clear message
|
||||
8. Report completion
|
||||
```
|
||||
|
||||
### Bug Fix
|
||||
```
|
||||
1. Reproduce the bug (write failing test)
|
||||
2. Locate the source
|
||||
3. Fix the issue
|
||||
4. Verify test passes
|
||||
5. Check for regressions
|
||||
6. Commit fix
|
||||
```
|
||||
|
||||
### Refactoring
|
||||
```
|
||||
1. Understand current behavior
|
||||
2. Design target state
|
||||
3. Make incremental changes
|
||||
4. Preserve tests
|
||||
5. Verify functionality
|
||||
6. Clean up dead code
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Special Considerations
|
||||
|
||||
### WebSocket Services
|
||||
- Implement heartbeat mechanism (30-second intervals)
|
||||
- Handle disconnection gracefully
|
||||
- Include camera position in control frames
|
||||
- Support both update and full sync modes
|
||||
|
||||
### Data Collectors
|
||||
- Inherit from BaseCollector
|
||||
- Implement fetch() and transform() methods
|
||||
- Support incremental updates
|
||||
- Handle API changes gracefully
|
||||
|
||||
### UE5 Integration
|
||||
- Communicate via WebSocket
|
||||
- Send data frames at configurable intervals (default 5 min)
|
||||
- Support auto-cruise and manual modes
|
||||
- Optimize for 4K@120Hz rendering
|
||||
|
||||
### Multi-User Security
|
||||
- JWT tokens with 15-minute expiration
|
||||
- Redis token blacklist for logout
|
||||
- Role-based access control (RBAC)
|
||||
- Audit logging for all actions
|
||||
|
||||
---
|
||||
|
||||
## Output Format
|
||||
|
||||
### When Writing Code
|
||||
```python
|
||||
# File: backend/app/services/collectors/top500.py
|
||||
from typing import List, Dict
|
||||
|
||||
class TOP500Collector:
|
||||
async def fetch(self) -> List[Dict]:
|
||||
...
|
||||
```
|
||||
|
||||
### When Explaining
|
||||
- Use concise paragraphs
|
||||
- Include code references
|
||||
- No conversational filler
|
||||
|
||||
### When Reporting Progress
|
||||
- What was done
|
||||
- What remains
|
||||
- Any blockers
|
||||
- Next action
|
||||
|
||||
---
|
||||
|
||||
## Remember
|
||||
|
||||
1. **Rules are hard constraints** - follow `rules.md` absolutely
|
||||
2. **Context provides understanding** - use `project_context.md` for background
|
||||
3. **Role defines behavior** - follow `agents.md` for how to work
|
||||
4. **Quality over speed** - Enterprise systems require precision
|
||||
5. **Communicate clearly** - Precision in, precision out
|
||||
@@ -6,7 +6,7 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile, status
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Request, UploadFile, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import delete, func, select, text
|
||||
@@ -27,6 +27,20 @@ from app.services.earth_news import (
|
||||
save_earth_news_sources_payload,
|
||||
test_news_source_config,
|
||||
)
|
||||
from app.services.earth_news_manual import (
|
||||
broadcast_manual_news_changed,
|
||||
create_manual_news_group,
|
||||
delete_manual_news_item,
|
||||
get_news_record_or_404,
|
||||
import_manual_news_items,
|
||||
list_news_groups,
|
||||
list_news_records,
|
||||
parse_manual_news_import_upload,
|
||||
rename_manual_news_group,
|
||||
reprocess_manual_news_item,
|
||||
serialize_news_record,
|
||||
upsert_manual_news_item,
|
||||
)
|
||||
from app.services.earth_boundaries import (
|
||||
EarthBoundaryBuildError,
|
||||
get_boundary_build_status,
|
||||
@@ -119,6 +133,26 @@ class EarthNewsSourceTestPayload(BaseModel):
|
||||
source: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class EarthNewsManualItemPayload(BaseModel):
|
||||
title: str = Field(default="", max_length=500)
|
||||
summary: str = Field(default="", max_length=1200)
|
||||
content: str = Field(default="", max_length=12000)
|
||||
url: str = Field(default="", max_length=2000)
|
||||
source: str = Field(default="", max_length=255)
|
||||
region: str = Field(default="global", max_length=80)
|
||||
published_at: str | None = None
|
||||
category: str = Field(default="other", max_length=80)
|
||||
tags: list[str] = Field(default_factory=list)
|
||||
location: dict[str, Any] | None = None
|
||||
homepage_url: str = Field(default="", max_length=2000)
|
||||
content_language: str = Field(default="", max_length=32)
|
||||
group_id: str | None = Field(default=None, max_length=120)
|
||||
|
||||
|
||||
class EarthNewsManualGroupPayload(BaseModel):
|
||||
name: str = Field(default="", max_length=120)
|
||||
|
||||
|
||||
def _normalize_earth_brand_payload(payload: dict[str, Any] | None) -> dict[str, str]:
|
||||
merged = DEFAULT_EARTH_BRAND.copy()
|
||||
if payload:
|
||||
@@ -375,6 +409,162 @@ async def test_earth_news_source(
|
||||
return await test_news_source_config(payload.source, db=db)
|
||||
|
||||
|
||||
@router.get("/news-groups")
|
||||
async def list_earth_news_groups_admin(
|
||||
_current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return await list_news_groups(db)
|
||||
|
||||
|
||||
@router.post("/news-groups")
|
||||
async def create_earth_news_group_admin(
|
||||
payload: EarthNewsManualGroupPayload,
|
||||
_current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
group = await create_manual_news_group(db, payload.name)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
await db.commit()
|
||||
return {"status": "ok", "group": group}
|
||||
|
||||
|
||||
@router.put("/news-groups/{group_id:path}")
|
||||
async def rename_earth_news_group_admin(
|
||||
group_id: str,
|
||||
payload: EarthNewsManualGroupPayload,
|
||||
_current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
group = await rename_manual_news_group(db, group_id, payload.name)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
await db.commit()
|
||||
await broadcast_manual_news_changed()
|
||||
return {"status": "ok", "group": group}
|
||||
|
||||
|
||||
@router.get("/news-items")
|
||||
async def list_earth_news_items_admin(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(50, ge=1, le=100),
|
||||
source_type: str | None = Query(None),
|
||||
region: str | None = Query(None),
|
||||
category: str | None = Query(None),
|
||||
status_filter: str | None = Query(None, alias="status"),
|
||||
group_id: str | None = Query(None),
|
||||
_current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return await list_news_records(
|
||||
db,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
source_type=source_type,
|
||||
region=region,
|
||||
category=category,
|
||||
status_filter=status_filter,
|
||||
group_id=group_id,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/news-items")
|
||||
async def create_earth_news_item_admin(
|
||||
payload: EarthNewsManualItemPayload,
|
||||
_current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
result = await upsert_manual_news_item(db, payload.model_dump())
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
except PermissionError as exc:
|
||||
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
||||
await db.commit()
|
||||
await broadcast_manual_news_changed()
|
||||
return {"status": "ok", "created": result.created, "queued": result.queued, "item": serialize_news_record(result.item)}
|
||||
|
||||
|
||||
@router.post("/news-items/import")
|
||||
async def import_earth_news_items_admin(
|
||||
file: UploadFile = File(...),
|
||||
group_id: str | None = Form(default=None),
|
||||
_current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
payload = await parse_manual_news_import_upload(await file.read())
|
||||
result = await import_manual_news_items(db, payload, group_id=group_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
await db.commit()
|
||||
await broadcast_manual_news_changed()
|
||||
return {"status": "ok", **result}
|
||||
|
||||
|
||||
@router.put("/news-items/{item_id:path}")
|
||||
async def update_earth_news_item_admin(
|
||||
item_id: str,
|
||||
payload: EarthNewsManualItemPayload,
|
||||
_current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
existing = await get_news_record_or_404(db, item_id)
|
||||
if existing is None:
|
||||
raise HTTPException(status_code=404, detail="News item not found.")
|
||||
try:
|
||||
result = await upsert_manual_news_item(
|
||||
db,
|
||||
payload.model_dump(),
|
||||
item_id_override=item_id,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
except PermissionError as exc:
|
||||
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
||||
await db.commit()
|
||||
await broadcast_manual_news_changed()
|
||||
return {"status": "ok", "created": result.created, "queued": result.queued, "item": serialize_news_record(result.item)}
|
||||
|
||||
|
||||
@router.delete("/news-items/{item_id:path}")
|
||||
async def delete_earth_news_item_admin(
|
||||
item_id: str,
|
||||
_current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
deleted = await delete_manual_news_item(db, item_id)
|
||||
except PermissionError as exc:
|
||||
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail="News item not found.")
|
||||
await db.commit()
|
||||
await broadcast_manual_news_changed()
|
||||
return {"status": "deleted", "id": item_id}
|
||||
|
||||
|
||||
@router.post("/news-items/{item_id:path}/reprocess")
|
||||
async def reprocess_earth_news_item_admin(
|
||||
item_id: str,
|
||||
_current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
existing = await get_news_record_or_404(db, item_id)
|
||||
if existing is None:
|
||||
raise HTTPException(status_code=404, detail="News item not found.")
|
||||
try:
|
||||
queued = await reprocess_manual_news_item(db, item_id)
|
||||
except PermissionError as exc:
|
||||
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
||||
await db.commit()
|
||||
await broadcast_manual_news_changed()
|
||||
return {"status": "queued" if queued else "not_queued", "queued": queued, "id": item_id}
|
||||
|
||||
|
||||
@router.get("/oobe-status")
|
||||
async def get_earth_oobe_status(
|
||||
current_user: User | None = Depends(_get_optional_current_user),
|
||||
|
||||
@@ -66,6 +66,7 @@ class NewsSourceType(StrEnum):
|
||||
ATOM = "atom"
|
||||
AGGREGATED = "aggregated"
|
||||
REFERENCE = "reference"
|
||||
MANUAL = "manual"
|
||||
|
||||
|
||||
class NewsEnrichmentStatus(StrEnum):
|
||||
|
||||
@@ -11,17 +11,20 @@ To get higher limits, set PEERINGDB_API_KEY environment variable.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from typing import Dict, Any, List
|
||||
from datetime import UTC, datetime
|
||||
import os
|
||||
from typing import Any, Dict, List
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import httpx
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from app.core.logging import get_logger
|
||||
from app.services.collectors.base import HTTPCollector
|
||||
|
||||
|
||||
# PeeringDB API key - read from environment variable
|
||||
PEERINGDB_API_KEY = os.environ.get("PEERINGDB_API_KEY", "")
|
||||
logger = get_logger(__name__, service="collector")
|
||||
|
||||
|
||||
class PeeringDBIXPCollector(HTTPCollector):
|
||||
@@ -39,6 +42,7 @@ class PeeringDBIXPCollector(HTTPCollector):
|
||||
"User-Agent": "Planet-Intelligence-System/1.0 (Python/collector)",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
|
||||
@property
|
||||
def request_url(self) -> str:
|
||||
base = self._resolved_url or self.base_url
|
||||
@@ -61,7 +65,11 @@ class PeeringDBIXPCollector(HTTPCollector):
|
||||
if response.status_code == 429:
|
||||
# Rate limited - wait and retry with exponential backoff
|
||||
delay = base_delay * (2**attempt)
|
||||
print(f"PeeringDB rate limited, waiting {delay}s before retry...")
|
||||
logger.warning_event(
|
||||
"PeeringDB rate limited; retrying after delay",
|
||||
event="collector.peeringdb.rate_limited",
|
||||
context={"delay_seconds": delay, "attempt": attempt + 1},
|
||||
)
|
||||
await asyncio.sleep(delay)
|
||||
last_error = "Rate limited"
|
||||
continue
|
||||
@@ -72,13 +80,21 @@ class PeeringDBIXPCollector(HTTPCollector):
|
||||
except httpx.HTTPStatusError as e:
|
||||
if e.response.status_code == 429:
|
||||
delay = base_delay * (2**attempt)
|
||||
print(f"PeeringDB rate limited, waiting {delay}s before retry...")
|
||||
logger.warning_event(
|
||||
"PeeringDB rate limited; retrying after delay",
|
||||
event="collector.peeringdb.rate_limited",
|
||||
context={"delay_seconds": delay, "attempt": attempt + 1},
|
||||
)
|
||||
await asyncio.sleep(delay)
|
||||
last_error = "Rate limited"
|
||||
continue
|
||||
raise
|
||||
|
||||
print(f"Warning: PeeringDB collection failed after {max_retries} retries: {last_error}")
|
||||
logger.warning_event(
|
||||
"PeeringDB collection failed after retries",
|
||||
event="collector.peeringdb.retries_exhausted",
|
||||
context={"max_retries": max_retries, "last_error": last_error},
|
||||
)
|
||||
return {}
|
||||
|
||||
async def fetch(self) -> List[Dict[str, Any]]:
|
||||
@@ -146,6 +162,7 @@ class PeeringDBNetworkCollector(HTTPCollector):
|
||||
"User-Agent": "Planet-Intelligence-System/1.0 (Python/collector)",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
|
||||
@property
|
||||
def request_url(self) -> str:
|
||||
base = self._resolved_url or self.base_url
|
||||
@@ -167,7 +184,11 @@ class PeeringDBNetworkCollector(HTTPCollector):
|
||||
|
||||
if response.status_code == 429:
|
||||
delay = base_delay * (2**attempt)
|
||||
print(f"PeeringDB rate limited, waiting {delay}s before retry...")
|
||||
logger.warning_event(
|
||||
"PeeringDB rate limited; retrying after delay",
|
||||
event="collector.peeringdb.rate_limited",
|
||||
context={"delay_seconds": delay, "attempt": attempt + 1},
|
||||
)
|
||||
await asyncio.sleep(delay)
|
||||
last_error = "Rate limited"
|
||||
continue
|
||||
@@ -178,13 +199,21 @@ class PeeringDBNetworkCollector(HTTPCollector):
|
||||
except httpx.HTTPStatusError as e:
|
||||
if e.response.status_code == 429:
|
||||
delay = base_delay * (2**attempt)
|
||||
print(f"PeeringDB rate limited, waiting {delay}s before retry...")
|
||||
logger.warning_event(
|
||||
"PeeringDB rate limited; retrying after delay",
|
||||
event="collector.peeringdb.rate_limited",
|
||||
context={"delay_seconds": delay, "attempt": attempt + 1},
|
||||
)
|
||||
await asyncio.sleep(delay)
|
||||
last_error = "Rate limited"
|
||||
continue
|
||||
raise
|
||||
|
||||
print(f"Warning: PeeringDB collection failed after {max_retries} retries: {last_error}")
|
||||
logger.warning_event(
|
||||
"PeeringDB collection failed after retries",
|
||||
event="collector.peeringdb.retries_exhausted",
|
||||
context={"max_retries": max_retries, "last_error": last_error},
|
||||
)
|
||||
return {}
|
||||
|
||||
async def fetch(self) -> List[Dict[str, Any]]:
|
||||
@@ -254,6 +283,7 @@ class PeeringDBFacilityCollector(HTTPCollector):
|
||||
"User-Agent": "Planet-Intelligence-System/1.0 (Python/collector)",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
|
||||
@property
|
||||
def request_url(self) -> str:
|
||||
base = self._resolved_url or self.base_url
|
||||
@@ -275,7 +305,11 @@ class PeeringDBFacilityCollector(HTTPCollector):
|
||||
|
||||
if response.status_code == 429:
|
||||
delay = base_delay * (2**attempt)
|
||||
print(f"PeeringDB rate limited, waiting {delay}s before retry...")
|
||||
logger.warning_event(
|
||||
"PeeringDB rate limited; retrying after delay",
|
||||
event="collector.peeringdb.rate_limited",
|
||||
context={"delay_seconds": delay, "attempt": attempt + 1},
|
||||
)
|
||||
await asyncio.sleep(delay)
|
||||
last_error = "Rate limited"
|
||||
continue
|
||||
@@ -286,13 +320,21 @@ class PeeringDBFacilityCollector(HTTPCollector):
|
||||
except httpx.HTTPStatusError as e:
|
||||
if e.response.status_code == 429:
|
||||
delay = base_delay * (2**attempt)
|
||||
print(f"PeeringDB rate limited, waiting {delay}s before retry...")
|
||||
logger.warning_event(
|
||||
"PeeringDB rate limited; retrying after delay",
|
||||
event="collector.peeringdb.rate_limited",
|
||||
context={"delay_seconds": delay, "attempt": attempt + 1},
|
||||
)
|
||||
await asyncio.sleep(delay)
|
||||
last_error = "Rate limited"
|
||||
continue
|
||||
raise
|
||||
|
||||
print(f"Warning: PeeringDB collection failed after {max_retries} retries: {last_error}")
|
||||
logger.warning_event(
|
||||
"PeeringDB collection failed after retries",
|
||||
event="collector.peeringdb.retries_exhausted",
|
||||
context={"max_retries": max_retries, "last_error": last_error},
|
||||
)
|
||||
return {}
|
||||
|
||||
async def fetch(self) -> List[Dict[str, Any]]:
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
"""Space-Track TLE Collector
|
||||
"""Space-Track TLE Collector.
|
||||
|
||||
Collects satellite TLE (Two-Line Element) data from Space-Track.org.
|
||||
API documentation: https://www.space-track.org/documentation
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Dict, Any, List
|
||||
import httpx
|
||||
from typing import Any, Dict, List
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from app.services.collectors.base import BaseCollector
|
||||
import httpx
|
||||
|
||||
from app.core.data_sources import get_data_sources_config
|
||||
from app.core.logging import get_logger
|
||||
from app.core.satellite_tle import build_tle_lines_from_elements
|
||||
from app.services.collectors.base import BaseCollector
|
||||
|
||||
|
||||
logger = get_logger(__name__, service="collector")
|
||||
|
||||
|
||||
class SpaceTrackTLECollector(BaseCollector):
|
||||
@@ -53,10 +57,16 @@ class SpaceTrackTLECollector(BaseCollector):
|
||||
password = settings.SPACETRACK_PASSWORD
|
||||
|
||||
if not username or not password:
|
||||
print("SPACETRACK: No credentials configured, using sample data")
|
||||
logger.warning_event(
|
||||
"Space-Track credentials are not configured; using sample data",
|
||||
event="collector.spacetrack.credentials_missing",
|
||||
)
|
||||
return self._get_sample_data()
|
||||
|
||||
print(f"SPACETRACK: Attempting to fetch TLE data with username: {username}")
|
||||
logger.info_event(
|
||||
"Space-Track TLE fetch started",
|
||||
event="collector.spacetrack.fetch.start",
|
||||
)
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
@@ -78,11 +88,17 @@ class SpaceTrackTLECollector(BaseCollector):
|
||||
"password": password,
|
||||
},
|
||||
)
|
||||
print(f"SPACETRACK: Login response status: {login_response.status_code}")
|
||||
print(f"SPACETRACK: Login response URL: {login_response.url}")
|
||||
logger.info_event(
|
||||
"Space-Track login response received",
|
||||
event="collector.spacetrack.login.response",
|
||||
context={"status_code": login_response.status_code},
|
||||
)
|
||||
|
||||
if login_response.status_code == 403:
|
||||
print("SPACETRACK: Trying alternate login method...")
|
||||
logger.warning_event(
|
||||
"Space-Track login returned forbidden; trying alternate method",
|
||||
event="collector.spacetrack.login.forbidden",
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
timeout=120.0,
|
||||
@@ -90,11 +106,6 @@ class SpaceTrackTLECollector(BaseCollector):
|
||||
) as alt_client:
|
||||
await alt_client.get(f"{self.site_root}/")
|
||||
|
||||
form_data = {
|
||||
"username": username,
|
||||
"password": password,
|
||||
"query": "class/gp/NORAD_CAT_ID/25544/format/json",
|
||||
}
|
||||
alt_login = await alt_client.post(
|
||||
self.login_url,
|
||||
data={
|
||||
@@ -102,77 +113,59 @@ class SpaceTrackTLECollector(BaseCollector):
|
||||
"password": password,
|
||||
},
|
||||
)
|
||||
print(f"SPACETRACK: Alt login status: {alt_login.status_code}")
|
||||
logger.info_event(
|
||||
"Space-Track alternate login response received",
|
||||
event="collector.spacetrack.alt_login.response",
|
||||
context={"status_code": alt_login.status_code},
|
||||
)
|
||||
|
||||
if alt_login.status_code == 200:
|
||||
tle_response = await alt_client.get(self.probe_url)
|
||||
if tle_response.status_code == 200:
|
||||
data = tle_response.json()
|
||||
print(f"SPACETRACK: Received {len(data)} records via alt method")
|
||||
logger.info_event(
|
||||
"Space-Track alternate query completed",
|
||||
event="collector.spacetrack.alt_query.completed",
|
||||
context={"record_count": len(data)},
|
||||
)
|
||||
return data
|
||||
|
||||
if login_response.status_code != 200:
|
||||
print(f"SPACETRACK: Login failed, using sample data")
|
||||
logger.warning_event(
|
||||
"Space-Track login failed; using sample data",
|
||||
event="collector.spacetrack.login.failed",
|
||||
context={"status_code": login_response.status_code},
|
||||
)
|
||||
return self._get_sample_data()
|
||||
|
||||
tle_response = await client.get(self.probe_url)
|
||||
print(f"SPACETRACK: TLE query status: {tle_response.status_code}")
|
||||
|
||||
if tle_response.status_code != 200:
|
||||
print(f"SPACETRACK: Query failed, using sample data")
|
||||
return self._get_sample_data()
|
||||
|
||||
data = tle_response.json()
|
||||
print(f"SPACETRACK: Received {len(data)} records")
|
||||
return data
|
||||
except Exception as e:
|
||||
print(f"SPACETRACK: Error - {e}, using sample data")
|
||||
return self._get_sample_data()
|
||||
|
||||
print(f"SPACETRACK: Attempting to fetch TLE data with username: {username}")
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
timeout=120.0,
|
||||
follow_redirects=True,
|
||||
headers={
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
||||
"Accept": "application/json, text/html, */*",
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
},
|
||||
) as client:
|
||||
# First, visit the main page to get any cookies
|
||||
await client.get(f"{self.site_root}/")
|
||||
|
||||
# Login to get session cookie
|
||||
login_response = await client.post(
|
||||
self.login_url,
|
||||
data={
|
||||
"identity": username,
|
||||
"password": password,
|
||||
},
|
||||
logger.info_event(
|
||||
"Space-Track TLE query response received",
|
||||
event="collector.spacetrack.query.response",
|
||||
context={"status_code": tle_response.status_code},
|
||||
)
|
||||
print(f"SPACETRACK: Login response status: {login_response.status_code}")
|
||||
print(f"SPACETRACK: Login response URL: {login_response.url}")
|
||||
print(f"SPACETRACK: Login response body: {login_response.text[:500]}")
|
||||
|
||||
if login_response.status_code != 200:
|
||||
print(f"SPACETRACK: Login failed, using sample data")
|
||||
return self._get_sample_data()
|
||||
|
||||
# Query for TLE data (get first 1000 satellites)
|
||||
tle_response = await client.get(self.query_url)
|
||||
print(f"SPACETRACK: TLE query status: {tle_response.status_code}")
|
||||
|
||||
if tle_response.status_code != 200:
|
||||
print(f"SPACETRACK: Query failed, using sample data")
|
||||
logger.warning_event(
|
||||
"Space-Track TLE query failed; using sample data",
|
||||
event="collector.spacetrack.query.failed",
|
||||
context={"status_code": tle_response.status_code},
|
||||
)
|
||||
return self._get_sample_data()
|
||||
|
||||
data = tle_response.json()
|
||||
print(f"SPACETRACK: Received {len(data)} records")
|
||||
logger.info_event(
|
||||
"Space-Track TLE fetch completed",
|
||||
event="collector.spacetrack.fetch.completed",
|
||||
context={"record_count": len(data)},
|
||||
)
|
||||
return data
|
||||
except Exception as e:
|
||||
print(f"SPACETRACK: Error - {e}, using sample data")
|
||||
logger.warning_event(
|
||||
"Space-Track TLE fetch failed; using sample data",
|
||||
event="collector.spacetrack.fetch.failed",
|
||||
context={"error": str(e)},
|
||||
)
|
||||
return self._get_sample_data()
|
||||
|
||||
def transform(self, raw_data: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
|
||||
@@ -45,7 +45,7 @@ DOCS_METADATA: tuple[DocsMetadata, ...] = (
|
||||
DocsMetadata("earth-bgp-context.md", "earth-bgp-context", "docs_developer", "Earth", 14, "BGP 态势上下文", "BGP Context"),
|
||||
DocsMetadata("earth-interactable-usage.md", "earth-interactable-usage", "docs_developer", "Earth", 16, "智能星球可交互图标接入", "Intelligent Planet Interactable Usage"),
|
||||
DocsMetadata("earth-interactable-clustering.md", "earth-interactable-clustering", "docs_developer", "Earth", 17, "智能星球可交互图标聚类策略", "Intelligent Planet Interactable Clustering"),
|
||||
DocsMetadata("earth-toolbar-overlay-coordination.md", "earth-toolbar-overlay-coordination", "docs_developer", "Earth", 17, "智能星球工具栏与浮层协同", "Intelligent Planet Toolbar and Overlay Coordination"),
|
||||
DocsMetadata("earth-toolbar-overlay-coordination.md", "earth-toolbar-overlay-coordination", "docs_developer", "Earth", 18, "智能星球工具栏与浮层协同", "Intelligent Planet Toolbar and Overlay Coordination"),
|
||||
DocsMetadata("earth-news-sources.md", "earth-news-sources", "docs_developer", "Earth", 19, "智能星球新闻源配置", "Intelligent Planet News Source Configuration"),
|
||||
DocsMetadata("frontend-admin-frontend-context.md", "frontend-admin-frontend-context", "docs_developer", "Frontend", 20, "控制台前端结构", "Admin Frontend Context"),
|
||||
DocsMetadata("frontend-layout-guidelines.md", "frontend-layout-guidelines", "docs_developer", "Frontend", 21, "前端布局指南", "Frontend Layout Guidelines"),
|
||||
@@ -56,7 +56,7 @@ DOCS_METADATA: tuple[DocsMetadata, ...] = (
|
||||
DocsMetadata("backend-datasources-api-performance.md", "backend-datasources-api-performance", "docs_developer", "Backend", 33, "数据源 API 性能", "Datasource API Performance"),
|
||||
DocsMetadata("data-job-earth-sync-architecture.md", "data-job-earth-sync-architecture", "docs_developer", "Backend", 34, "数据作业与 Outbox 技术架构", "Data Jobs and Outbox Architecture"),
|
||||
DocsMetadata("backend-enum-contracts.md", "backend-enum-contracts", "docs_developer", "Backend", 35, "后端枚举与字符串兼容契约", "Backend Enum and String Compatibility Contract"),
|
||||
DocsMetadata("location-pipeline-development.md", "location-pipeline-development", "docs_developer", "Backend", 36, "通用位置估算管线开发说明", "Shared Location Resolution Pipeline Development Guide"),
|
||||
DocsMetadata("location-pipeline-development.md", "location-pipeline-development", "docs_developer", "Backend", 35, "通用位置估算管线开发说明", "Shared Location Resolution Pipeline Development Guide"),
|
||||
DocsMetadata("earth-news-live-streams-collector-format.md", "earth-news-live-streams-collector-format", "docs_developer", "Backend", 36, "新闻直播采集格式", "News Live Streams Collector Format"),
|
||||
DocsMetadata("docs-gatekeeper-development.md", "docs-gatekeeper-development", "docs_developer", "Backend", 37, "Docs Gatekeeper 开发说明", "Docs Gatekeeper Development Guide"),
|
||||
DocsMetadata("agents-aiprovider.md", "agents-aiprovider", "docs_developer", "Agents", 40, "AI Provider 指南", "AI Provider Guide"),
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
|
||||
@@ -2282,6 +2282,53 @@ def _filter_news_items_by_source_ids(
|
||||
]
|
||||
|
||||
|
||||
def _news_item_source_id(item: ParsedNewsItem) -> str:
|
||||
return item.id.split(":", 1)[0] if ":" in item.id else ""
|
||||
|
||||
|
||||
def _is_news_item_display_ready(item: ParsedNewsItem, *, locale: str) -> bool:
|
||||
return bool(
|
||||
_get_locale_text(item, "title", locale=locale)
|
||||
and _get_locale_text(item, "summary", locale=locale)
|
||||
)
|
||||
|
||||
|
||||
def _diversify_news_items_for_locale(
|
||||
items: list[ParsedNewsItem],
|
||||
*,
|
||||
active_region: str,
|
||||
limit: int,
|
||||
locale: str,
|
||||
) -> list[ParsedNewsItem]:
|
||||
ranked = sorted(
|
||||
_rank_and_trim_items(items, active_region=active_region, limit=max(len(items), limit)),
|
||||
key=lambda item: (not _is_news_item_display_ready(item, locale=locale),),
|
||||
)
|
||||
buckets: dict[str, list[ParsedNewsItem]] = {}
|
||||
order: list[str] = []
|
||||
for item in ranked:
|
||||
if active_region == "global":
|
||||
key = item.feed_region or "global"
|
||||
else:
|
||||
key = _news_item_source_id(item) or item.source or item.feed_name or item.id
|
||||
if key not in buckets:
|
||||
buckets[key] = []
|
||||
order.append(key)
|
||||
buckets[key].append(item)
|
||||
|
||||
diversified: list[ParsedNewsItem] = []
|
||||
while len(diversified) < limit and order:
|
||||
next_order: list[str] = []
|
||||
for source_id in order:
|
||||
bucket = buckets.get(source_id) or []
|
||||
if bucket and len(diversified) < limit:
|
||||
diversified.append(bucket.pop(0))
|
||||
if bucket:
|
||||
next_order.append(source_id)
|
||||
order = next_order
|
||||
return diversified
|
||||
|
||||
|
||||
async def _call_store_list_items(list_fn, db: AsyncSession, **kwargs):
|
||||
try:
|
||||
return await list_fn(db, **kwargs)
|
||||
@@ -2707,6 +2754,7 @@ async def get_earth_news_payload(
|
||||
)
|
||||
await record_earth_news_sources_health(db, health_by_source)
|
||||
ranked_fetched_items = _rank_and_trim_items(fetched_items, active_region=active_region)
|
||||
await _enqueue_unverified_locations(ranked_fetched_items)
|
||||
await upsert_earth_news_items(db, ranked_fetched_items)
|
||||
|
||||
items = await _call_store_list_items(
|
||||
@@ -2717,6 +2765,37 @@ async def get_earth_news_payload(
|
||||
categories=categories,
|
||||
source_ids=source_ids,
|
||||
)
|
||||
if not source_ids:
|
||||
ready_sources = {
|
||||
_news_item_source_id(item)
|
||||
for item in items
|
||||
if _is_news_item_display_ready(item, locale=locale)
|
||||
}
|
||||
missing_ready_sources = [
|
||||
source.id
|
||||
for source in sources
|
||||
if source.id and source.id not in ready_sources
|
||||
]
|
||||
if missing_ready_sources:
|
||||
extra_items: list[ParsedNewsItem] = []
|
||||
for missing_source_id in missing_ready_sources:
|
||||
extra_items.extend(
|
||||
await _call_store_list_items(
|
||||
list_earth_news_items,
|
||||
db,
|
||||
active_region=active_region,
|
||||
limit=3,
|
||||
categories=categories,
|
||||
source_ids={missing_source_id},
|
||||
)
|
||||
)
|
||||
if extra_items:
|
||||
items = _diversify_news_items_for_locale(
|
||||
[*items, *extra_items],
|
||||
active_region=active_region,
|
||||
limit=limit,
|
||||
locale=locale,
|
||||
)
|
||||
if hasattr(db, "execute"):
|
||||
cruise_items = await _call_store_list_items(
|
||||
list_earth_news_cruise_items,
|
||||
@@ -2727,7 +2806,8 @@ async def get_earth_news_payload(
|
||||
)
|
||||
else:
|
||||
cruise_items = items
|
||||
await _enqueue_unverified_locations(items)
|
||||
enqueue_candidates = {item.id: item for item in [*items, *cruise_items] if item.id}
|
||||
await _enqueue_unverified_locations(list(enqueue_candidates.values()))
|
||||
stale = bool(errors and items)
|
||||
|
||||
return _build_payload(
|
||||
|
||||
693
backend/app/services/earth_news_manual.py
Normal file
@@ -0,0 +1,693 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
import hashlib
|
||||
import html
|
||||
import json
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
from sqlalchemy import delete, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.enums import NewsEnrichmentStatus, NewsSourceType, NewsTaggingSource
|
||||
from app.core.websocket.broadcaster import broadcaster
|
||||
from app.models.earth_news import EarthNewsItem
|
||||
from app.models.system_setting import SystemSetting
|
||||
from app.services.earth_news import (
|
||||
ALLOWED_NEWS_CATEGORY_KEYS,
|
||||
DEFAULT_NEWS_LOCALE,
|
||||
REGION_ANCHORS,
|
||||
NewsFeedEndpoint,
|
||||
NewsFeedSource,
|
||||
NewsTargetLocation,
|
||||
ParsedNewsItem,
|
||||
apply_news_classification,
|
||||
build_anchor_location_patch,
|
||||
build_target_location_job_payload,
|
||||
build_target_location_patch,
|
||||
_serialize_item,
|
||||
)
|
||||
from app.services.earth_news_queue import enqueue_target_location_job
|
||||
from app.services.earth_news_store import record_to_parsed_news_item
|
||||
|
||||
|
||||
MANUAL_NEWS_SOURCE_ID = "manual"
|
||||
MANUAL_NEWS_SOURCE_LABEL = "手动添加"
|
||||
MANUAL_NEWS_MAX_IMPORT_ITEMS = 500
|
||||
MANUAL_NEWS_MAX_TITLE_LENGTH = 500
|
||||
MANUAL_NEWS_MAX_SUMMARY_LENGTH = 1200
|
||||
MANUAL_NEWS_MAX_CONTENT_LENGTH = 12000
|
||||
EARTH_NEWS_MANUAL_GROUPS_CATEGORY = "earth_news_manual_groups"
|
||||
DEFAULT_MANUAL_NEWS_GROUP_ID = "manual-default"
|
||||
DEFAULT_MANUAL_NEWS_GROUP_NAME = "新建新闻组"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ManualNewsWriteResult:
|
||||
item: EarthNewsItem
|
||||
created: bool
|
||||
queued: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ManualNewsGroup:
|
||||
id: str
|
||||
name: str
|
||||
sort_order: int = 0
|
||||
|
||||
|
||||
def _clean_text(value: object, *, max_length: int) -> str:
|
||||
raw = "" if value is None else str(value)
|
||||
text = BeautifulSoup(html.unescape(raw), "html.parser").get_text(" ", strip=True)
|
||||
text = re.sub(r"\s+", " ", text).strip()
|
||||
if len(text) > max_length:
|
||||
return text[: max_length - 1].rstrip() + "…"
|
||||
return text
|
||||
|
||||
|
||||
def _parse_datetime(value: object) -> datetime | None:
|
||||
if value is None or str(value).strip() == "":
|
||||
return None
|
||||
if isinstance(value, datetime):
|
||||
parsed = value
|
||||
else:
|
||||
try:
|
||||
parsed = datetime.fromisoformat(str(value).strip().replace("Z", "+00:00"))
|
||||
except ValueError as exc:
|
||||
raise ValueError("published_at 必须是 ISO8601 时间。") from exc
|
||||
if parsed.tzinfo is None:
|
||||
return parsed.replace(tzinfo=UTC)
|
||||
return parsed.astimezone(UTC)
|
||||
|
||||
|
||||
def _detect_language(*parts: str) -> str:
|
||||
text = " ".join(part for part in parts if part)
|
||||
cjk_count = len(re.findall(r"[\u4e00-\u9fff]", text))
|
||||
latin_count = len(re.findall(r"[A-Za-z]", text))
|
||||
return "zh-CN" if cjk_count >= max(4, latin_count // 3) else "en-US"
|
||||
|
||||
|
||||
def _manual_item_id(*, title: str, published_at: datetime | None, url: str, source: str) -> str:
|
||||
published = published_at.isoformat() if published_at else ""
|
||||
basis = "\n".join([title.strip().lower(), published, url.strip().lower(), source.strip().lower()])
|
||||
return f"manual:{hashlib.sha1(basis.encode('utf-8')).hexdigest()[:16]}"
|
||||
|
||||
|
||||
def _manual_group_id(name: str) -> str:
|
||||
basis = f"{name.strip().lower()}\n{datetime.now(UTC).isoformat()}"
|
||||
return f"manual-group:{hashlib.sha1(basis.encode('utf-8')).hexdigest()[:10]}"
|
||||
|
||||
|
||||
def _news_meta(record: EarthNewsItem) -> dict[str, Any]:
|
||||
location_meta = record.location_meta if isinstance(record.location_meta, dict) else {}
|
||||
news_meta = location_meta.get("news_meta")
|
||||
return dict(news_meta) if isinstance(news_meta, dict) else {}
|
||||
|
||||
|
||||
def _record_source_type(record: EarthNewsItem) -> str:
|
||||
return str(_news_meta(record).get("feed_type") or _news_meta(record).get("source_type") or "rss")
|
||||
|
||||
|
||||
def _record_manual_group_id(record: EarthNewsItem) -> str:
|
||||
return str(_news_meta(record).get("manual_group_id") or DEFAULT_MANUAL_NEWS_GROUP_ID)
|
||||
|
||||
|
||||
def _rss_group_id(record: EarthNewsItem) -> str:
|
||||
basis = "\n".join(
|
||||
[
|
||||
_record_source_type(record),
|
||||
str(record.feed_name or ""),
|
||||
str(record.source or ""),
|
||||
]
|
||||
)
|
||||
return f"rss:{hashlib.sha1(basis.encode('utf-8')).hexdigest()[:12]}"
|
||||
|
||||
|
||||
def _default_manual_group() -> dict[str, Any]:
|
||||
return {
|
||||
"id": DEFAULT_MANUAL_NEWS_GROUP_ID,
|
||||
"name": DEFAULT_MANUAL_NEWS_GROUP_NAME,
|
||||
"sort_order": 0,
|
||||
}
|
||||
|
||||
|
||||
def _normalize_manual_groups_payload(payload: Any) -> list[dict[str, Any]]:
|
||||
raw_groups = payload.get("groups") if isinstance(payload, dict) else None
|
||||
normalized: list[dict[str, Any]] = []
|
||||
seen: set[str] = set()
|
||||
for index, item in enumerate(raw_groups if isinstance(raw_groups, list) else []):
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
group_id = str(item.get("id") or "").strip()
|
||||
name = _clean_text(item.get("name"), max_length=120)
|
||||
if not group_id or not name or group_id in seen:
|
||||
continue
|
||||
normalized.append(
|
||||
{
|
||||
"id": group_id,
|
||||
"name": name,
|
||||
"sort_order": int(item.get("sort_order") or index),
|
||||
}
|
||||
)
|
||||
seen.add(group_id)
|
||||
if DEFAULT_MANUAL_NEWS_GROUP_ID not in seen:
|
||||
normalized.insert(0, _default_manual_group())
|
||||
return sorted(normalized, key=lambda item: (int(item.get("sort_order") or 0), str(item.get("name") or "")))
|
||||
|
||||
|
||||
async def _get_manual_groups_record(db: AsyncSession) -> SystemSetting | None:
|
||||
result = await db.execute(
|
||||
select(SystemSetting).where(SystemSetting.category == EARTH_NEWS_MANUAL_GROUPS_CATEGORY)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_manual_news_groups(db: AsyncSession) -> list[dict[str, Any]]:
|
||||
record = await _get_manual_groups_record(db)
|
||||
return _normalize_manual_groups_payload(record.payload if record else None)
|
||||
|
||||
|
||||
async def _save_manual_news_groups(db: AsyncSession, groups: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
normalized = _normalize_manual_groups_payload({"groups": groups})
|
||||
record = await _get_manual_groups_record(db)
|
||||
payload = {"groups": normalized}
|
||||
if record is None:
|
||||
db.add(SystemSetting(category=EARTH_NEWS_MANUAL_GROUPS_CATEGORY, payload=payload))
|
||||
else:
|
||||
record.payload = payload
|
||||
await db.flush()
|
||||
return normalized
|
||||
|
||||
|
||||
async def resolve_manual_news_group(db: AsyncSession, group_id: str | None) -> ManualNewsGroup:
|
||||
normalized_id = str(group_id or DEFAULT_MANUAL_NEWS_GROUP_ID).strip() or DEFAULT_MANUAL_NEWS_GROUP_ID
|
||||
groups = await get_manual_news_groups(db)
|
||||
match = next((item for item in groups if item.get("id") == normalized_id), None)
|
||||
if match is None and normalized_id != DEFAULT_MANUAL_NEWS_GROUP_ID:
|
||||
raise ValueError(f"手动新闻组不存在:{normalized_id}")
|
||||
match = match or _default_manual_group()
|
||||
return ManualNewsGroup(
|
||||
id=str(match["id"]),
|
||||
name=str(match["name"]),
|
||||
sort_order=int(match.get("sort_order") or 0),
|
||||
)
|
||||
|
||||
|
||||
async def create_manual_news_group(db: AsyncSession, name: str) -> dict[str, Any]:
|
||||
group_name = _clean_text(name, max_length=120)
|
||||
if not group_name:
|
||||
raise ValueError("新闻组名称不能为空。")
|
||||
groups = await get_manual_news_groups(db)
|
||||
group = {"id": _manual_group_id(group_name), "name": group_name, "sort_order": len(groups)}
|
||||
groups.append(group)
|
||||
await _save_manual_news_groups(db, groups)
|
||||
return group
|
||||
|
||||
|
||||
async def rename_manual_news_group(db: AsyncSession, group_id: str, name: str) -> dict[str, Any]:
|
||||
group_name = _clean_text(name, max_length=120)
|
||||
if not group_name:
|
||||
raise ValueError("新闻组名称不能为空。")
|
||||
groups = await get_manual_news_groups(db)
|
||||
match = next((item for item in groups if item.get("id") == group_id), None)
|
||||
if match is None:
|
||||
raise ValueError(f"手动新闻组不存在:{group_id}")
|
||||
match["name"] = group_name
|
||||
await _save_manual_news_groups(db, groups)
|
||||
|
||||
result = await db.execute(
|
||||
select(EarthNewsItem).where(
|
||||
EarthNewsItem.location_meta.op("->")("news_meta").op("->>")("feed_type") == NewsSourceType.MANUAL.value
|
||||
)
|
||||
)
|
||||
for record in result.scalars().all():
|
||||
if _record_manual_group_id(record) != group_id:
|
||||
continue
|
||||
location_meta = dict(record.location_meta or {})
|
||||
news_meta = dict(location_meta.get("news_meta") or {})
|
||||
news_meta["manual_group_name"] = group_name
|
||||
location_meta["news_meta"] = news_meta
|
||||
record.location_meta = location_meta
|
||||
await db.flush()
|
||||
return match
|
||||
|
||||
|
||||
def _normalize_region(value: object) -> str:
|
||||
region = str(value or "global").strip().lower() or "global"
|
||||
if region not in REGION_ANCHORS:
|
||||
raise ValueError(f"region 不支持:{region}")
|
||||
return region
|
||||
|
||||
|
||||
def _normalize_tags(value: object) -> list[str]:
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, str):
|
||||
parts = re.split(r"[,,\n]", value)
|
||||
elif isinstance(value, list):
|
||||
parts = [str(item) for item in value]
|
||||
else:
|
||||
raise ValueError("tags 必须是字符串数组或逗号分隔字符串。")
|
||||
return [item.strip() for item in parts if item.strip()][:20]
|
||||
|
||||
|
||||
def _normalize_location(value: object) -> NewsTargetLocation | None:
|
||||
if value in (None, ""):
|
||||
return None
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError("location 必须是对象。")
|
||||
lat = value.get("latitude")
|
||||
lon = value.get("longitude")
|
||||
if lat in (None, "") and lon in (None, ""):
|
||||
return None
|
||||
try:
|
||||
latitude = float(lat)
|
||||
longitude = float(lon)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError("location.latitude / longitude 必须是数字。") from exc
|
||||
if not -90 <= latitude <= 90 or not -180 <= longitude <= 180:
|
||||
raise ValueError("location 经纬度超出范围。")
|
||||
label = _clean_text(value.get("label"), max_length=255)
|
||||
if not label:
|
||||
label = f"{latitude:.4f}, {longitude:.4f}"
|
||||
return NewsTargetLocation(
|
||||
latitude=latitude,
|
||||
longitude=longitude,
|
||||
label=label,
|
||||
source="manual_location",
|
||||
confidence=1.0,
|
||||
country=_clean_text(value.get("country"), max_length=100) or None,
|
||||
city=_clean_text(value.get("city"), max_length=100) or None,
|
||||
)
|
||||
|
||||
|
||||
def _manual_source(source_name: str, *, region: str) -> NewsFeedSource:
|
||||
return NewsFeedSource(
|
||||
id=MANUAL_NEWS_SOURCE_ID,
|
||||
name=source_name or MANUAL_NEWS_SOURCE_LABEL,
|
||||
region=region,
|
||||
feed_url="",
|
||||
homepage_url="",
|
||||
source_type=NewsSourceType.MANUAL.value,
|
||||
default_category="other",
|
||||
source_tags=("manual",),
|
||||
)
|
||||
|
||||
|
||||
def _manual_feed(category: str) -> NewsFeedEndpoint:
|
||||
return NewsFeedEndpoint(
|
||||
id=MANUAL_NEWS_SOURCE_ID,
|
||||
name=MANUAL_NEWS_SOURCE_LABEL,
|
||||
url="",
|
||||
type=NewsSourceType.MANUAL.value,
|
||||
default_category=category or "other",
|
||||
tags=("manual",),
|
||||
priority=1,
|
||||
)
|
||||
|
||||
|
||||
def parsed_manual_news_item(
|
||||
payload: dict[str, Any],
|
||||
*,
|
||||
item_id_override: str | None = None,
|
||||
) -> tuple[ParsedNewsItem, NewsTargetLocation | None, str]:
|
||||
title = _clean_text(payload.get("title"), max_length=MANUAL_NEWS_MAX_TITLE_LENGTH)
|
||||
if not title:
|
||||
raise ValueError("title 不能为空。")
|
||||
content = _clean_text(payload.get("content"), max_length=MANUAL_NEWS_MAX_CONTENT_LENGTH)
|
||||
summary = _clean_text(payload.get("summary"), max_length=MANUAL_NEWS_MAX_SUMMARY_LENGTH)
|
||||
if not summary:
|
||||
summary = _clean_text(content, max_length=240) if content else title
|
||||
source = _clean_text(payload.get("source"), max_length=255) or MANUAL_NEWS_SOURCE_LABEL
|
||||
region = _normalize_region(payload.get("region"))
|
||||
published_at = _parse_datetime(payload.get("published_at")) or datetime.now(UTC)
|
||||
url = str(payload.get("url") or "").strip()
|
||||
category = str(payload.get("category") or "other").strip().lower() or "other"
|
||||
if category not in ALLOWED_NEWS_CATEGORY_KEYS:
|
||||
raise ValueError(f"category 不支持:{category}")
|
||||
tags = _normalize_tags(payload.get("tags"))
|
||||
target = _normalize_location(payload.get("location"))
|
||||
language = str(payload.get("content_language") or "").strip() or _detect_language(title, summary, content)
|
||||
localizations = {
|
||||
language: {
|
||||
"title": title,
|
||||
"summary": summary,
|
||||
}
|
||||
}
|
||||
item = ParsedNewsItem(
|
||||
id=item_id_override
|
||||
or _manual_item_id(title=title, published_at=published_at, url=url, source=source),
|
||||
title=title,
|
||||
summary=summary,
|
||||
url=url,
|
||||
source=source,
|
||||
feed_name=MANUAL_NEWS_SOURCE_LABEL,
|
||||
feed_region=region,
|
||||
homepage_url=str(payload.get("homepage_url") or ""),
|
||||
published_at=published_at,
|
||||
content_language=language,
|
||||
localizations=localizations,
|
||||
enrichment_status=NewsEnrichmentStatus.PENDING.value,
|
||||
source_tags=["manual"],
|
||||
feed_id=MANUAL_NEWS_SOURCE_ID,
|
||||
feed_type=NewsSourceType.MANUAL.value,
|
||||
feed_default_category=category,
|
||||
category=category,
|
||||
item_tags=tags,
|
||||
tagging_source=NewsTaggingSource.MANUAL.value if payload.get("category") else NewsTaggingSource.RULES.value,
|
||||
tagging_confidence=0.9 if payload.get("category") else 0.0,
|
||||
)
|
||||
source_config = _manual_source(source, region=region)
|
||||
feed = _manual_feed(category)
|
||||
apply_news_classification(item, source_config, feed=feed)
|
||||
if payload.get("category"):
|
||||
item.category = category
|
||||
item.tagging_source = NewsTaggingSource.MANUAL.value
|
||||
item.tagging_confidence = 0.9
|
||||
if tags:
|
||||
item.item_tags = sorted(set([*item.item_tags, *tags]))
|
||||
return item, target, content
|
||||
|
||||
|
||||
def _manual_editable(record: EarthNewsItem) -> bool:
|
||||
if record.id.startswith("manual:"):
|
||||
return True
|
||||
news_meta = (record.location_meta or {}).get("news_meta") if isinstance(record.location_meta, dict) else None
|
||||
return isinstance(news_meta, dict) and news_meta.get("feed_type") == NewsSourceType.MANUAL.value
|
||||
|
||||
|
||||
async def _broadcast_news_reload() -> None:
|
||||
await broadcaster.broadcast_earth_update(
|
||||
{
|
||||
"action": "database_changed",
|
||||
"source": "earth_news_items",
|
||||
"layers": ["news"],
|
||||
"refresh_strategy": "reload",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def upsert_manual_news_item(
|
||||
db: AsyncSession,
|
||||
payload: dict[str, Any],
|
||||
*,
|
||||
item_id_override: str | None = None,
|
||||
group_id: str | None = None,
|
||||
) -> ManualNewsWriteResult:
|
||||
item, target, content = parsed_manual_news_item(payload, item_id_override=item_id_override)
|
||||
group = await resolve_manual_news_group(db, group_id or payload.get("group_id"))
|
||||
existing = await db.get(EarthNewsItem, item.id)
|
||||
created = existing is None
|
||||
patch = build_target_location_patch(item, target) if target else build_anchor_location_patch(item)
|
||||
patch_meta = dict(patch.get("location_meta") or {})
|
||||
patch_news_meta = dict(patch_meta.get("news_meta") or {})
|
||||
patch_news_meta["feed_type"] = NewsSourceType.MANUAL.value
|
||||
patch_news_meta["source_type"] = NewsSourceType.MANUAL.value
|
||||
patch_news_meta["manual_group_id"] = group.id
|
||||
patch_news_meta["manual_group_name"] = group.name
|
||||
patch_meta["news_meta"] = patch_news_meta
|
||||
patch["location_meta"] = patch_meta
|
||||
now = datetime.now(UTC)
|
||||
record = existing or EarthNewsItem(
|
||||
id=item.id,
|
||||
title=item.title,
|
||||
summary=item.summary,
|
||||
content_language=item.content_language,
|
||||
localizations=dict(item.localizations or {}),
|
||||
url=item.url,
|
||||
source=item.source,
|
||||
feed_name=item.feed_name,
|
||||
region=item.feed_region,
|
||||
homepage_url=item.homepage_url,
|
||||
published_at=item.published_at,
|
||||
latitude=patch["latitude"],
|
||||
longitude=patch["longitude"],
|
||||
location_label=patch["location_label"],
|
||||
location_source=patch["location_source"],
|
||||
verified=patch["verified"],
|
||||
location_meta=patch["location_meta"],
|
||||
first_seen_at=now,
|
||||
last_seen_at=now,
|
||||
resolved_at=now if patch["verified"] else None,
|
||||
enrichment_status=item.enrichment_status,
|
||||
)
|
||||
if existing is None:
|
||||
db.add(record)
|
||||
else:
|
||||
if not _manual_editable(record):
|
||||
raise PermissionError("RSS 新闻不允许通过手动新闻接口编辑。")
|
||||
record.title = item.title
|
||||
record.summary = item.summary
|
||||
record.content_language = item.content_language
|
||||
record.localizations = dict(item.localizations or {})
|
||||
record.url = item.url
|
||||
record.source = item.source
|
||||
record.feed_name = item.feed_name
|
||||
record.region = item.feed_region
|
||||
record.homepage_url = item.homepage_url
|
||||
record.published_at = item.published_at
|
||||
record.last_seen_at = now
|
||||
if target is None and record.location_source == "manual_location":
|
||||
merged_meta = dict(record.location_meta or {})
|
||||
patch_meta = patch.get("location_meta") if isinstance(patch, dict) else None
|
||||
patch_news_meta = patch_meta.get("news_meta") if isinstance(patch_meta, dict) else None
|
||||
if isinstance(patch_news_meta, dict):
|
||||
merged_meta["news_meta"] = patch_news_meta
|
||||
record.location_meta = merged_meta
|
||||
else:
|
||||
record.location_meta = patch["location_meta"]
|
||||
if target:
|
||||
record.latitude = patch["latitude"]
|
||||
record.longitude = patch["longitude"]
|
||||
record.location_label = patch["location_label"]
|
||||
record.location_source = patch["location_source"]
|
||||
record.verified = patch["verified"]
|
||||
record.resolved_at = now
|
||||
elif record.location_source != "manual_location":
|
||||
record.latitude = patch["latitude"]
|
||||
record.longitude = patch["longitude"]
|
||||
record.location_label = patch["location_label"]
|
||||
record.location_source = patch["location_source"]
|
||||
record.verified = patch["verified"]
|
||||
record.resolved_at = None
|
||||
record.enrichment_status = NewsEnrichmentStatus.PENDING.value
|
||||
record.enrichment_error = None
|
||||
record.enriched_at = None
|
||||
if content:
|
||||
meta = dict(record.location_meta or {})
|
||||
meta["manual_content"] = content
|
||||
record.location_meta = meta
|
||||
await db.flush()
|
||||
|
||||
queued = await enqueue_target_location_job(build_target_location_job_payload(item), force=True)
|
||||
if queued:
|
||||
record.enrichment_status = NewsEnrichmentStatus.QUEUED.value
|
||||
await db.flush()
|
||||
return ManualNewsWriteResult(item=record, created=created, queued=queued)
|
||||
|
||||
|
||||
async def import_manual_news_items(
|
||||
db: AsyncSession,
|
||||
payload: list[Any],
|
||||
*,
|
||||
group_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
if len(payload) > MANUAL_NEWS_MAX_IMPORT_ITEMS:
|
||||
raise ValueError(f"单次最多导入 {MANUAL_NEWS_MAX_IMPORT_ITEMS} 条。")
|
||||
created = 0
|
||||
updated = 0
|
||||
queued = 0
|
||||
errors: list[dict[str, Any]] = []
|
||||
for index, raw_item in enumerate(payload):
|
||||
if not isinstance(raw_item, dict):
|
||||
errors.append({"index": index, "error": "条目必须是 JSON 对象。"})
|
||||
continue
|
||||
try:
|
||||
result = await upsert_manual_news_item(db, raw_item, group_id=group_id)
|
||||
created += 1 if result.created else 0
|
||||
updated += 0 if result.created else 1
|
||||
queued += 1 if result.queued else 0
|
||||
except Exception as exc:
|
||||
errors.append({"index": index, "error": str(exc)})
|
||||
if errors and created == 0 and updated == 0:
|
||||
raise ValueError("导入失败,未写入任何新闻。")
|
||||
return {"created": created, "updated": updated, "queued": queued, "failed": len(errors), "errors": errors}
|
||||
|
||||
|
||||
async def parse_manual_news_import_upload(raw_bytes: bytes) -> list[Any]:
|
||||
try:
|
||||
payload = json.loads(raw_bytes.decode("utf-8-sig"))
|
||||
except UnicodeDecodeError as exc:
|
||||
raise ValueError("JSON 文件必须使用 UTF-8 编码。") from exc
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ValueError(f"JSON 解析失败:第 {exc.lineno} 行第 {exc.colno} 列。") from exc
|
||||
if not isinstance(payload, list):
|
||||
raise ValueError("JSON 顶层必须是数组。")
|
||||
return payload
|
||||
|
||||
|
||||
def serialize_news_record(record: EarthNewsItem, *, locale: str = DEFAULT_NEWS_LOCALE) -> dict[str, Any]:
|
||||
item = record_to_parsed_news_item(record)
|
||||
payload = _serialize_item(item, active_region=item.feed_region, locale=locale)
|
||||
news_meta = _news_meta(record)
|
||||
payload["editable"] = _manual_editable(record)
|
||||
payload["source_type"] = payload.get("feed_type")
|
||||
payload["status"] = record.enrichment_status
|
||||
payload["translated"] = bool((record.localizations or {}).get("zh-CN") and (record.localizations or {}).get("en-US"))
|
||||
payload["manual_content"] = (record.location_meta or {}).get("manual_content") if isinstance(record.location_meta, dict) else None
|
||||
payload["manual_group_id"] = news_meta.get("manual_group_id")
|
||||
payload["manual_group_name"] = news_meta.get("manual_group_name")
|
||||
return payload
|
||||
|
||||
|
||||
def _record_matches_group(record: EarthNewsItem, group_id: str) -> bool:
|
||||
source_type = _record_source_type(record)
|
||||
if source_type == NewsSourceType.MANUAL.value:
|
||||
return _record_manual_group_id(record) == group_id
|
||||
return _rss_group_id(record) == group_id
|
||||
|
||||
|
||||
async def list_news_records(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
page: int,
|
||||
page_size: int,
|
||||
source_type: str | None = None,
|
||||
region: str | None = None,
|
||||
category: str | None = None,
|
||||
status_filter: str | None = None,
|
||||
group_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
page = max(page, 1)
|
||||
page_size = min(max(page_size, 1), 100)
|
||||
query = select(EarthNewsItem)
|
||||
count_query = select(func.count(EarthNewsItem.id))
|
||||
filters = []
|
||||
if region and region != "all":
|
||||
filters.append(EarthNewsItem.region == region)
|
||||
if status_filter and status_filter != "all":
|
||||
filters.append(EarthNewsItem.enrichment_status == status_filter)
|
||||
if source_type and source_type != "all":
|
||||
filters.append(EarthNewsItem.location_meta.op("->")("news_meta").op("->>")("feed_type") == source_type)
|
||||
if category and category != "all":
|
||||
filters.append(EarthNewsItem.location_meta.op("->")("news_meta").op("->>")("category") == category)
|
||||
for clause in filters:
|
||||
query = query.where(clause)
|
||||
count_query = count_query.where(clause)
|
||||
ordered_query = query.order_by(EarthNewsItem.published_at.desc().nullslast(), EarthNewsItem.last_seen_at.desc())
|
||||
if group_id:
|
||||
result = await db.execute(ordered_query)
|
||||
all_records = [record for record in result.scalars().all() if _record_matches_group(record, group_id)]
|
||||
total = len(all_records)
|
||||
records = all_records[(page - 1) * page_size : page * page_size]
|
||||
else:
|
||||
total_result = await db.execute(count_query)
|
||||
result = await db.execute(
|
||||
ordered_query.offset((page - 1) * page_size).limit(page_size)
|
||||
)
|
||||
records = list(result.scalars().all())
|
||||
total = int(total_result.scalar() or 0)
|
||||
return {
|
||||
"items": [serialize_news_record(record) for record in records],
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"total": total,
|
||||
}
|
||||
|
||||
|
||||
async def list_news_groups(db: AsyncSession, *, locale: str = DEFAULT_NEWS_LOCALE) -> dict[str, Any]:
|
||||
manual_groups = await get_manual_news_groups(db)
|
||||
manual_by_id: dict[str, dict[str, Any]] = {
|
||||
str(group["id"]): {
|
||||
"id": str(group["id"]),
|
||||
"name": str(group["name"]),
|
||||
"group_type": "manual",
|
||||
"source_type": NewsSourceType.MANUAL.value,
|
||||
"editable": True,
|
||||
"sort_order": int(group.get("sort_order") or 0),
|
||||
"count": 0,
|
||||
"items": [],
|
||||
}
|
||||
for group in manual_groups
|
||||
}
|
||||
rss_by_id: dict[str, dict[str, Any]] = {}
|
||||
result = await db.execute(
|
||||
select(EarthNewsItem).order_by(EarthNewsItem.published_at.desc().nullslast(), EarthNewsItem.last_seen_at.desc())
|
||||
)
|
||||
for record in result.scalars().all():
|
||||
serialized = serialize_news_record(record, locale=locale)
|
||||
source_type = _record_source_type(record)
|
||||
if source_type == NewsSourceType.MANUAL.value:
|
||||
group_id = _record_manual_group_id(record)
|
||||
group = manual_by_id.setdefault(
|
||||
group_id,
|
||||
{
|
||||
"id": group_id,
|
||||
"name": str(_news_meta(record).get("manual_group_name") or DEFAULT_MANUAL_NEWS_GROUP_NAME),
|
||||
"group_type": "manual",
|
||||
"source_type": NewsSourceType.MANUAL.value,
|
||||
"editable": True,
|
||||
"sort_order": len(manual_by_id),
|
||||
"count": 0,
|
||||
"items": [],
|
||||
},
|
||||
)
|
||||
else:
|
||||
group_id = _rss_group_id(record)
|
||||
group = rss_by_id.setdefault(
|
||||
group_id,
|
||||
{
|
||||
"id": group_id,
|
||||
"name": record.feed_name or record.source or "RSS 新闻",
|
||||
"group_type": "rss",
|
||||
"source_type": source_type,
|
||||
"editable": False,
|
||||
"region": record.region,
|
||||
"source": record.source,
|
||||
"feed_name": record.feed_name,
|
||||
"count": 0,
|
||||
"items": [],
|
||||
},
|
||||
)
|
||||
group["count"] = int(group.get("count") or 0) + 1
|
||||
group.setdefault("items", []).append(serialized)
|
||||
manual_items = sorted(manual_by_id.values(), key=lambda item: (int(item.get("sort_order") or 0), str(item.get("name") or "")))
|
||||
rss_items = sorted(rss_by_id.values(), key=lambda item: str(item.get("name") or ""))
|
||||
return {"groups": [*manual_items, *rss_items], "manual_groups": manual_items, "rss_groups": rss_items}
|
||||
|
||||
|
||||
async def get_news_record_or_404(db: AsyncSession, item_id: str) -> EarthNewsItem | None:
|
||||
return await db.get(EarthNewsItem, item_id)
|
||||
|
||||
|
||||
async def delete_manual_news_item(db: AsyncSession, item_id: str) -> bool:
|
||||
record = await db.get(EarthNewsItem, item_id)
|
||||
if record is None:
|
||||
return False
|
||||
if not _manual_editable(record):
|
||||
raise PermissionError("RSS 新闻不允许通过手动新闻接口删除。")
|
||||
await db.execute(delete(EarthNewsItem).where(EarthNewsItem.id == item_id))
|
||||
await db.flush()
|
||||
return True
|
||||
|
||||
|
||||
async def reprocess_manual_news_item(db: AsyncSession, item_id: str) -> bool:
|
||||
record = await db.get(EarthNewsItem, item_id)
|
||||
if record is None:
|
||||
return False
|
||||
if not _manual_editable(record):
|
||||
raise PermissionError("RSS 新闻不允许通过手动新闻接口重新处理。")
|
||||
item = record_to_parsed_news_item(record)
|
||||
queued = await enqueue_target_location_job(build_target_location_job_payload(item), force=True)
|
||||
if queued:
|
||||
record.enrichment_status = NewsEnrichmentStatus.QUEUED.value
|
||||
record.enrichment_error = None
|
||||
await db.flush()
|
||||
return queued
|
||||
|
||||
|
||||
async def broadcast_manual_news_changed() -> None:
|
||||
await _broadcast_news_reload()
|
||||
@@ -14,10 +14,14 @@ from app.core.logging import get_logger
|
||||
logger = get_logger(__name__, service="earth_news")
|
||||
|
||||
TARGET_LOCATION_STREAM = "earth_news:target_location:jobs"
|
||||
TARGET_LOCATION_PRIORITY_STREAM = "earth_news:target_location:priority"
|
||||
TARGET_LOCATION_GROUP = "earth_news_target_location"
|
||||
TARGET_LOCATION_DEAD_LETTER_STREAM = "earth_news:target_location:dead"
|
||||
TARGET_LOCATION_RESULT_TTL_SECONDS = 60 * 60 * 12
|
||||
TARGET_LOCATION_JOB_DEDUP_TTL_SECONDS = 60 * 60 * 6
|
||||
TARGET_LOCATION_PRIORITY_JOB_DEDUP_TTL_SECONDS = 60 * 5
|
||||
TARGET_LOCATION_PENDING_RECLAIM_IDLE_MS = 2 * 60 * 1000
|
||||
TARGET_LOCATION_PRIORITY_READ_BLOCK_MS = 1
|
||||
TARGET_LOCATION_MAX_ATTEMPTS = 3
|
||||
|
||||
_redis_client: redis.Redis | None = None
|
||||
@@ -28,6 +32,7 @@ class NewsTargetLocationMessage:
|
||||
message_id: str
|
||||
item_id: str
|
||||
payload: dict[str, Any]
|
||||
stream_name: str = TARGET_LOCATION_STREAM
|
||||
attempts: int = 0
|
||||
|
||||
|
||||
@@ -44,7 +49,7 @@ class NewsTargetLocationQueue(Protocol):
|
||||
) -> list[NewsTargetLocationMessage]:
|
||||
...
|
||||
|
||||
async def ack(self, message_id: str) -> None:
|
||||
async def ack(self, message: NewsTargetLocationMessage) -> None:
|
||||
...
|
||||
|
||||
async def retry_or_dead_letter(
|
||||
@@ -71,6 +76,10 @@ def _queued_key(item_id: str) -> str:
|
||||
return f"earth_news:target_location:queued:{item_id}"
|
||||
|
||||
|
||||
def _priority_queued_key(item_id: str) -> str:
|
||||
return f"earth_news:target_location:priority_queued:{item_id}"
|
||||
|
||||
|
||||
class RedisStreamsNewsTargetLocationQueue:
|
||||
def __init__(self, client: redis.Redis | None = None) -> None:
|
||||
self.client = client or _get_redis_client()
|
||||
@@ -79,34 +88,44 @@ class RedisStreamsNewsTargetLocationQueue:
|
||||
async def _ensure_group(self) -> None:
|
||||
if self._group_ready:
|
||||
return
|
||||
try:
|
||||
await self.client.xgroup_create(
|
||||
TARGET_LOCATION_STREAM,
|
||||
TARGET_LOCATION_GROUP,
|
||||
id="0",
|
||||
mkstream=True,
|
||||
)
|
||||
except ResponseError as exc:
|
||||
if "BUSYGROUP" not in str(exc):
|
||||
raise
|
||||
for stream_name in (TARGET_LOCATION_PRIORITY_STREAM, TARGET_LOCATION_STREAM):
|
||||
try:
|
||||
await self.client.xgroup_create(
|
||||
stream_name,
|
||||
TARGET_LOCATION_GROUP,
|
||||
id="0",
|
||||
mkstream=True,
|
||||
)
|
||||
except ResponseError as exc:
|
||||
if "BUSYGROUP" not in str(exc):
|
||||
raise
|
||||
self._group_ready = True
|
||||
|
||||
async def enqueue(self, *, item_id: str, payload: dict[str, Any], force: bool = False) -> bool:
|
||||
await self._ensure_group()
|
||||
if force:
|
||||
await self.client.delete(_result_key(item_id), _queued_key(item_id))
|
||||
await self.client.delete(_result_key(item_id))
|
||||
queued_key = _priority_queued_key(item_id)
|
||||
elif await self.client.exists(_result_key(item_id)):
|
||||
return False
|
||||
else:
|
||||
queued_key = _queued_key(item_id)
|
||||
dedup_ttl = (
|
||||
TARGET_LOCATION_PRIORITY_JOB_DEDUP_TTL_SECONDS
|
||||
if force
|
||||
else TARGET_LOCATION_JOB_DEDUP_TTL_SECONDS
|
||||
)
|
||||
queued = await self.client.set(
|
||||
_queued_key(item_id),
|
||||
queued_key,
|
||||
"1",
|
||||
nx=True,
|
||||
ex=TARGET_LOCATION_JOB_DEDUP_TTL_SECONDS,
|
||||
ex=dedup_ttl,
|
||||
)
|
||||
if not queued:
|
||||
return bool(await self.client.exists(_queued_key(item_id)))
|
||||
return bool(await self.client.exists(queued_key))
|
||||
stream_name = TARGET_LOCATION_PRIORITY_STREAM if force else TARGET_LOCATION_STREAM
|
||||
await self.client.xadd(
|
||||
TARGET_LOCATION_STREAM,
|
||||
stream_name,
|
||||
{
|
||||
"item_id": item_id,
|
||||
"attempts": "0",
|
||||
@@ -123,39 +142,104 @@ class RedisStreamsNewsTargetLocationQueue:
|
||||
block_ms: int,
|
||||
) -> list[NewsTargetLocationMessage]:
|
||||
await self._ensure_group()
|
||||
streams = await self.client.xreadgroup(
|
||||
streams = []
|
||||
priority_claimed = await self._claim_stale_messages(
|
||||
stream_name=TARGET_LOCATION_PRIORITY_STREAM,
|
||||
consumer_name=consumer_name,
|
||||
count=count,
|
||||
)
|
||||
if priority_claimed:
|
||||
return priority_claimed
|
||||
|
||||
priority_messages = await self.client.xreadgroup(
|
||||
TARGET_LOCATION_GROUP,
|
||||
consumer_name,
|
||||
{TARGET_LOCATION_STREAM: ">"},
|
||||
{TARGET_LOCATION_PRIORITY_STREAM: ">"},
|
||||
count=count,
|
||||
block=block_ms,
|
||||
block=TARGET_LOCATION_PRIORITY_READ_BLOCK_MS,
|
||||
)
|
||||
if priority_messages:
|
||||
streams = priority_messages
|
||||
else:
|
||||
regular_claimed = await self._claim_stale_messages(
|
||||
stream_name=TARGET_LOCATION_STREAM,
|
||||
consumer_name=consumer_name,
|
||||
count=count,
|
||||
)
|
||||
if regular_claimed:
|
||||
return regular_claimed
|
||||
streams = await self.client.xreadgroup(
|
||||
TARGET_LOCATION_GROUP,
|
||||
consumer_name,
|
||||
{TARGET_LOCATION_STREAM: ">"},
|
||||
count=count,
|
||||
block=block_ms,
|
||||
)
|
||||
messages: list[NewsTargetLocationMessage] = []
|
||||
for _stream_name, stream_messages in streams:
|
||||
for stream_name, stream_messages in streams:
|
||||
for message_id, fields in stream_messages:
|
||||
raw_payload = fields.get("payload")
|
||||
item_id = fields.get("item_id")
|
||||
if not raw_payload or not item_id:
|
||||
await self.ack(message_id)
|
||||
continue
|
||||
try:
|
||||
payload = json.loads(raw_payload)
|
||||
except json.JSONDecodeError:
|
||||
await self.ack(message_id)
|
||||
continue
|
||||
attempts = int(fields.get("attempts") or 0)
|
||||
messages.append(
|
||||
NewsTargetLocationMessage(
|
||||
message_id=message_id,
|
||||
item_id=item_id,
|
||||
payload=payload,
|
||||
attempts=attempts,
|
||||
)
|
||||
)
|
||||
message = await self._message_from_fields(stream_name, message_id, fields)
|
||||
if message is not None:
|
||||
messages.append(message)
|
||||
return messages
|
||||
|
||||
async def ack(self, message_id: str) -> None:
|
||||
await self.client.xack(TARGET_LOCATION_STREAM, TARGET_LOCATION_GROUP, message_id)
|
||||
async def _claim_stale_messages(
|
||||
self,
|
||||
*,
|
||||
stream_name: str,
|
||||
consumer_name: str,
|
||||
count: int,
|
||||
) -> list[NewsTargetLocationMessage]:
|
||||
try:
|
||||
_next_id, claimed, _deleted = await self.client.xautoclaim(
|
||||
stream_name,
|
||||
TARGET_LOCATION_GROUP,
|
||||
consumer_name,
|
||||
TARGET_LOCATION_PENDING_RECLAIM_IDLE_MS,
|
||||
start_id="0-0",
|
||||
count=count,
|
||||
)
|
||||
except ResponseError:
|
||||
return []
|
||||
messages: list[NewsTargetLocationMessage] = []
|
||||
for message_id, fields in claimed:
|
||||
message = await self._message_from_fields(stream_name, message_id, fields)
|
||||
if message is not None:
|
||||
messages.append(message)
|
||||
return messages
|
||||
|
||||
async def _message_from_fields(
|
||||
self,
|
||||
stream_name: str,
|
||||
message_id: str,
|
||||
fields: dict[str, str],
|
||||
) -> NewsTargetLocationMessage | None:
|
||||
raw_payload = fields.get("payload")
|
||||
item_id = fields.get("item_id")
|
||||
if not raw_payload or not item_id:
|
||||
await self._discard_message(stream_name, message_id)
|
||||
return None
|
||||
try:
|
||||
payload = json.loads(raw_payload)
|
||||
except json.JSONDecodeError:
|
||||
await self._discard_message(stream_name, message_id)
|
||||
return None
|
||||
attempts = int(fields.get("attempts") or 0)
|
||||
return NewsTargetLocationMessage(
|
||||
message_id=message_id,
|
||||
item_id=item_id,
|
||||
payload=payload,
|
||||
stream_name=stream_name,
|
||||
attempts=attempts,
|
||||
)
|
||||
|
||||
async def ack(self, message: NewsTargetLocationMessage) -> None:
|
||||
await self.client.xack(message.stream_name, TARGET_LOCATION_GROUP, message.message_id)
|
||||
await self.client.xdel(message.stream_name, message.message_id)
|
||||
|
||||
async def _discard_message(self, stream_name: str, message_id: str) -> None:
|
||||
await self.client.xack(stream_name, TARGET_LOCATION_GROUP, message_id)
|
||||
await self.client.xdel(stream_name, message_id)
|
||||
|
||||
async def retry_or_dead_letter(
|
||||
self,
|
||||
@@ -163,7 +247,7 @@ class RedisStreamsNewsTargetLocationQueue:
|
||||
*,
|
||||
error: str,
|
||||
) -> None:
|
||||
await self.ack(message.message_id)
|
||||
await self.ack(message)
|
||||
if message.attempts + 1 >= TARGET_LOCATION_MAX_ATTEMPTS:
|
||||
await self.client.xadd(
|
||||
TARGET_LOCATION_DEAD_LETTER_STREAM,
|
||||
@@ -176,7 +260,7 @@ class RedisStreamsNewsTargetLocationQueue:
|
||||
)
|
||||
return
|
||||
await self.client.xadd(
|
||||
TARGET_LOCATION_STREAM,
|
||||
message.stream_name,
|
||||
{
|
||||
"item_id": message.item_id,
|
||||
"attempts": str(message.attempts + 1),
|
||||
@@ -231,4 +315,4 @@ async def save_target_location_patch(item_id: str, patch: dict[str, Any]) -> Non
|
||||
TARGET_LOCATION_RESULT_TTL_SECONDS,
|
||||
json.dumps(patch, ensure_ascii=False),
|
||||
)
|
||||
await client.delete(_queued_key(item_id))
|
||||
await client.delete(_queued_key(item_id), _priority_queued_key(item_id))
|
||||
|
||||
@@ -19,6 +19,17 @@ from app.services.earth_news_classification import (
|
||||
normalize_breaking_scope,
|
||||
)
|
||||
|
||||
CRUISE_REGION_ORDER = (
|
||||
"americas",
|
||||
"europe",
|
||||
"middle-east-africa",
|
||||
"asia-pacific",
|
||||
"global",
|
||||
)
|
||||
CRUISE_REGION_QUERY_MULTIPLIER = 12
|
||||
CRUISE_REGION_QUERY_MIN_LIMIT = 240
|
||||
CRUISE_REGION_QUERY_MAX_LIMIT = 1000
|
||||
|
||||
|
||||
def _coerce_datetime(value: datetime | None) -> datetime | None:
|
||||
if value is None:
|
||||
@@ -106,6 +117,41 @@ def _sort_parsed_news_items(items: list[ParsedNewsItem], *, active_region: str)
|
||||
)
|
||||
|
||||
|
||||
def _diversify_parsed_news_items_by_region(
|
||||
items: list[ParsedNewsItem],
|
||||
*,
|
||||
limit: int,
|
||||
) -> list[ParsedNewsItem]:
|
||||
if limit <= 0:
|
||||
return []
|
||||
sorted_items = _sort_parsed_news_items(items, active_region="global")
|
||||
buckets: dict[str, list[ParsedNewsItem]] = {}
|
||||
for item in sorted_items:
|
||||
region = item.feed_region or "global"
|
||||
buckets.setdefault(region, []).append(item)
|
||||
|
||||
ordered_regions = [
|
||||
*[region for region in CRUISE_REGION_ORDER if buckets.get(region)],
|
||||
*sorted(region for region in buckets if region not in CRUISE_REGION_ORDER),
|
||||
]
|
||||
diversified: list[ParsedNewsItem] = []
|
||||
cursor = 0
|
||||
while len(diversified) < limit:
|
||||
added = False
|
||||
for region in ordered_regions:
|
||||
bucket = buckets.get(region) or []
|
||||
if cursor >= len(bucket):
|
||||
continue
|
||||
diversified.append(bucket[cursor])
|
||||
added = True
|
||||
if len(diversified) >= limit:
|
||||
break
|
||||
if not added:
|
||||
break
|
||||
cursor += 1
|
||||
return diversified
|
||||
|
||||
|
||||
def _query_sort_key(active_region: str):
|
||||
if active_region == "global":
|
||||
return (
|
||||
@@ -133,42 +179,6 @@ def _source_filter_clause(source_ids: set[str] | None):
|
||||
return EarthNewsItem.location_meta.op("->")("news_meta").op("->>")("source_id").in_(sorted(source_ids))
|
||||
|
||||
|
||||
def _record_source_id(record: EarthNewsItem) -> str:
|
||||
location_meta = dict(record.location_meta or {})
|
||||
news_meta = location_meta.get("news_meta") if isinstance(location_meta.get("news_meta"), dict) else {}
|
||||
source_id = str(news_meta.get("source_id") or "").strip()
|
||||
if source_id:
|
||||
return source_id
|
||||
if isinstance(record.id, str) and ":" in record.id:
|
||||
return record.id.split(":", 1)[0]
|
||||
return record.feed_name or record.source or record.id
|
||||
|
||||
|
||||
def _diversify_records_by_source(records: list[EarthNewsItem], *, limit: int) -> list[EarthNewsItem]:
|
||||
if limit <= 0 or len(records) <= limit:
|
||||
return records[:limit]
|
||||
buckets: dict[str, list[EarthNewsItem]] = {}
|
||||
order: list[str] = []
|
||||
for record in records:
|
||||
source_id = _record_source_id(record)
|
||||
if source_id not in buckets:
|
||||
buckets[source_id] = []
|
||||
order.append(source_id)
|
||||
buckets[source_id].append(record)
|
||||
|
||||
diversified: list[EarthNewsItem] = []
|
||||
while len(diversified) < limit and order:
|
||||
next_order: list[str] = []
|
||||
for source_id in order:
|
||||
bucket = buckets.get(source_id) or []
|
||||
if bucket and len(diversified) < limit:
|
||||
diversified.append(bucket.pop(0))
|
||||
if bucket:
|
||||
next_order.append(source_id)
|
||||
order = next_order
|
||||
return diversified
|
||||
|
||||
|
||||
async def list_earth_news_items(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
@@ -177,7 +187,7 @@ async def list_earth_news_items(
|
||||
categories: set[str] | None = None,
|
||||
source_ids: set[str] | None = None,
|
||||
) -> list[ParsedNewsItem]:
|
||||
query_limit = limit if source_ids else min(max(limit * 8, limit), 200)
|
||||
query_limit = limit if source_ids else min(max(limit * 20, limit), 500)
|
||||
query = (
|
||||
select(EarthNewsItem)
|
||||
.order_by(*_query_sort_key(active_region))
|
||||
@@ -203,6 +213,8 @@ async def list_earth_news_items(
|
||||
[record_to_parsed_news_item(record) for record in records],
|
||||
active_region=active_region,
|
||||
)
|
||||
if active_region == "global" and not source_ids:
|
||||
return _diversify_parsed_news_items_by_region(items, limit=limit)
|
||||
return items[:limit]
|
||||
|
||||
|
||||
@@ -213,15 +225,20 @@ async def list_earth_news_cruise_items(
|
||||
categories: set[str] | None = None,
|
||||
source_ids: set[str] | None = None,
|
||||
) -> list[ParsedNewsItem]:
|
||||
query_limit = min(
|
||||
max(limit * CRUISE_REGION_QUERY_MULTIPLIER, CRUISE_REGION_QUERY_MIN_LIMIT),
|
||||
CRUISE_REGION_QUERY_MAX_LIMIT,
|
||||
)
|
||||
query = (
|
||||
select(EarthNewsItem)
|
||||
.order_by(
|
||||
EarthNewsItem.region.asc(),
|
||||
EarthNewsItem.published_at.is_(None),
|
||||
EarthNewsItem.published_at.desc().nullslast(),
|
||||
EarthNewsItem.last_seen_at.desc(),
|
||||
EarthNewsItem.region.asc(),
|
||||
EarthNewsItem.feed_name.asc(),
|
||||
)
|
||||
.limit(min(max(limit * 4, limit), 200))
|
||||
.limit(query_limit)
|
||||
)
|
||||
category_clause = _category_filter_clause(categories)
|
||||
if category_clause is not None:
|
||||
@@ -230,11 +247,10 @@ async def list_earth_news_cruise_items(
|
||||
if source_clause is not None:
|
||||
query = query.where(source_clause)
|
||||
result = await db.execute(query)
|
||||
items = _sort_parsed_news_items(
|
||||
return _diversify_parsed_news_items_by_region(
|
||||
[record_to_parsed_news_item(record) for record in result.scalars().all()],
|
||||
active_region="global",
|
||||
limit=limit,
|
||||
)
|
||||
return items[:limit]
|
||||
|
||||
|
||||
async def get_earth_news_freshness(
|
||||
@@ -382,13 +398,28 @@ async def update_earth_news_item_enrichment(
|
||||
if record is None:
|
||||
return False
|
||||
if "latitude" in patch:
|
||||
record.latitude = float(patch["latitude"])
|
||||
record.longitude = float(patch["longitude"])
|
||||
record.location_label = str(patch["location_label"])
|
||||
record.location_source = str(patch["location_source"])
|
||||
record.verified = bool(patch["verified"])
|
||||
record.location_meta = dict(patch.get("location_meta") or {})
|
||||
record.resolved_at = datetime.now(UTC) if record.verified else None
|
||||
patch_meta = dict(patch.get("location_meta") or {})
|
||||
if record.location_source == "manual_location":
|
||||
current_meta = dict(record.location_meta or {})
|
||||
patch_news_meta = patch_meta.get("news_meta")
|
||||
if isinstance(patch_news_meta, dict):
|
||||
current_meta["news_meta"] = patch_news_meta
|
||||
current_meta["manual_enrichment"] = {
|
||||
"resolution_stage": patch_meta.get("resolution_stage"),
|
||||
"ai_attempted": patch_meta.get("ai_attempted"),
|
||||
"ai_status": patch_meta.get("ai_status"),
|
||||
"ai_error": patch_meta.get("ai_error"),
|
||||
"debug_note": patch_meta.get("debug_note"),
|
||||
}
|
||||
record.location_meta = current_meta
|
||||
else:
|
||||
record.latitude = float(patch["latitude"])
|
||||
record.longitude = float(patch["longitude"])
|
||||
record.location_label = str(patch["location_label"])
|
||||
record.location_source = str(patch["location_source"])
|
||||
record.verified = bool(patch["verified"])
|
||||
record.location_meta = patch_meta
|
||||
record.resolved_at = datetime.now(UTC) if record.verified else None
|
||||
if "content_language" in patch:
|
||||
record.content_language = str(patch.get("content_language") or "en")
|
||||
if "localizations" in patch:
|
||||
|
||||
@@ -29,6 +29,9 @@ logger = get_logger(__name__, service="earth_news")
|
||||
WORKER_BATCH_SIZE = 4
|
||||
WORKER_BLOCK_MS = 5000
|
||||
WORKER_BACKOFF_SECONDS = 5.0
|
||||
WORKER_JOB_TIMEOUT_MIN_SECONDS = 20.0
|
||||
WORKER_JOB_TIMEOUT_MAX_SECONDS = 90.0
|
||||
WORKER_JOB_TIMEOUT_GRACE_SECONDS = 10.0
|
||||
|
||||
_worker_task: asyncio.Task | None = None
|
||||
|
||||
@@ -109,12 +112,25 @@ async def _run_target_location_worker() -> None:
|
||||
if not messages:
|
||||
continue
|
||||
provider_client = await _build_provider_client()
|
||||
for message in messages:
|
||||
job_timeout = _get_worker_job_timeout(provider_client)
|
||||
|
||||
async def handle_message(message: NewsTargetLocationMessage) -> None:
|
||||
try:
|
||||
await process_target_location_message(message, provider_client=provider_client)
|
||||
await queue.ack(message.message_id)
|
||||
await asyncio.wait_for(
|
||||
process_target_location_message(message, provider_client=provider_client),
|
||||
timeout=job_timeout,
|
||||
)
|
||||
await queue.ack(message)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except TimeoutError as exc:
|
||||
logger.warning_event(
|
||||
"Earth news target location worker job timed out",
|
||||
event="earth_news.target_location.worker_job_timeout",
|
||||
context={"item_id": message.item_id, "timeout_seconds": job_timeout},
|
||||
)
|
||||
with suppress(Exception):
|
||||
await queue.retry_or_dead_letter(message, error=str(exc) or "job timed out")
|
||||
except Exception as exc:
|
||||
logger.warning_event(
|
||||
"Earth news target location worker job failed",
|
||||
@@ -124,6 +140,8 @@ async def _run_target_location_worker() -> None:
|
||||
with suppress(Exception):
|
||||
await queue.retry_or_dead_letter(message, error=str(exc))
|
||||
|
||||
await asyncio.gather(*(handle_message(message) for message in messages))
|
||||
|
||||
|
||||
def start_earth_news_target_worker() -> None:
|
||||
global _worker_task
|
||||
@@ -140,3 +158,15 @@ async def stop_earth_news_target_worker() -> None:
|
||||
with suppress(asyncio.CancelledError):
|
||||
await task
|
||||
_worker_task = None
|
||||
|
||||
|
||||
def _get_worker_job_timeout(provider_client: AIProviderClient | None) -> float:
|
||||
timeout = float(getattr(provider_client, "timeout", 0) or WORKER_JOB_TIMEOUT_MIN_SECONDS)
|
||||
retry_attempts = float(getattr(provider_client, "retry_attempts", 1) or 1)
|
||||
return min(
|
||||
max(
|
||||
timeout * retry_attempts + WORKER_JOB_TIMEOUT_GRACE_SECONDS,
|
||||
WORKER_JOB_TIMEOUT_MIN_SECONDS,
|
||||
),
|
||||
WORKER_JOB_TIMEOUT_MAX_SECONDS,
|
||||
)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
[pytest]
|
||||
pythonpath = ..
|
||||
asyncio_mode = auto
|
||||
testpaths = tests
|
||||
python_files = test_*.py
|
||||
|
||||
@@ -12,6 +12,7 @@ from app.services.earth_news import (
|
||||
default_earth_news_sources_payload,
|
||||
normalize_earth_news_sources_payload,
|
||||
_fetch_source,
|
||||
_diversify_news_items_for_locale,
|
||||
_enrich_items_with_target_locations,
|
||||
_extract_target_location_from_text,
|
||||
_parse_feed_entries,
|
||||
@@ -23,6 +24,7 @@ from app.services.earth_news import (
|
||||
from app.services.earth_news_queue import NewsTargetLocationMessage
|
||||
from app.services.earth_news_worker import process_target_location_message
|
||||
from app.services.collectors.media_news_archive import MediaNewsArchiveCollector
|
||||
from app.services.earth_news_store import _diversify_parsed_news_items_by_region
|
||||
|
||||
|
||||
def test_serialize_item_includes_region_anchor_for_cruise():
|
||||
@@ -130,6 +132,109 @@ def test_rank_and_trim_items_prioritizes_active_breaking():
|
||||
assert [item.id for item in ranked] == ["global:critical", "europe:expired", "europe:regular"]
|
||||
|
||||
|
||||
def test_diversify_news_items_prefers_display_ready_content_across_sources():
|
||||
published_at = datetime(2026, 6, 11, 3, 0, tzinfo=UTC)
|
||||
|
||||
def make_item(source_id: str, suffix: str, *, zh_ready: bool) -> ParsedNewsItem:
|
||||
return ParsedNewsItem(
|
||||
id=f"{source_id}:{suffix}",
|
||||
title=f"{source_id} title {suffix}",
|
||||
summary=f"{source_id} summary {suffix}",
|
||||
url=f"https://example.com/{source_id}/{suffix}",
|
||||
source=source_id,
|
||||
feed_name=source_id,
|
||||
feed_region="global",
|
||||
homepage_url="https://example.com",
|
||||
published_at=published_at,
|
||||
content_language="en",
|
||||
localizations={
|
||||
"zh-CN": {
|
||||
"title": f"{source_id} 中文标题 {suffix}",
|
||||
"summary": f"{source_id} 中文摘要 {suffix}",
|
||||
}
|
||||
} if zh_ready else {},
|
||||
)
|
||||
|
||||
items = [
|
||||
make_item("source-a", "1", zh_ready=False),
|
||||
make_item("source-a", "2", zh_ready=False),
|
||||
make_item("source-a", "3", zh_ready=False),
|
||||
make_item("source-b", "1", zh_ready=True),
|
||||
make_item("source-c", "1", zh_ready=True),
|
||||
]
|
||||
|
||||
result = _diversify_news_items_for_locale(
|
||||
items,
|
||||
active_region="global",
|
||||
limit=3,
|
||||
locale="zh-CN",
|
||||
)
|
||||
|
||||
assert [item.id.split(":", 1)[0] for item in result] == ["source-b", "source-c", "source-a"]
|
||||
|
||||
|
||||
def test_cruise_news_diversity_keeps_regions_from_being_starved():
|
||||
published_at = datetime(2026, 6, 26, 8, 0, tzinfo=UTC)
|
||||
|
||||
def make_item(region: str, index: int) -> ParsedNewsItem:
|
||||
return ParsedNewsItem(
|
||||
id=f"{region}:{index}",
|
||||
title=f"{region} story {index}",
|
||||
summary=f"{region} summary {index}",
|
||||
url=f"https://example.com/{region}/{index}",
|
||||
source=region,
|
||||
feed_name=region,
|
||||
feed_region=region,
|
||||
homepage_url="https://example.com",
|
||||
published_at=published_at - timedelta(minutes=index),
|
||||
)
|
||||
|
||||
items = [
|
||||
*[make_item("asia-pacific", index) for index in range(40)],
|
||||
make_item("europe", 1),
|
||||
make_item("middle-east-africa", 1),
|
||||
make_item("americas", 1),
|
||||
make_item("global", 1),
|
||||
]
|
||||
|
||||
result = _diversify_parsed_news_items_by_region(items, limit=8)
|
||||
regions = [item.feed_region for item in result]
|
||||
|
||||
assert "europe" in regions
|
||||
assert "middle-east-africa" in regions
|
||||
assert "americas" in regions
|
||||
assert regions.count("asia-pacific") < len(regions)
|
||||
|
||||
|
||||
def test_global_news_diversity_uses_same_region_balance():
|
||||
published_at = datetime(2026, 6, 26, 8, 0, tzinfo=UTC)
|
||||
|
||||
def make_item(region: str, index: int) -> ParsedNewsItem:
|
||||
return ParsedNewsItem(
|
||||
id=f"{region}:global:{index}",
|
||||
title=f"{region} story {index}",
|
||||
summary=f"{region} summary {index}",
|
||||
url=f"https://example.com/{region}/global/{index}",
|
||||
source=region,
|
||||
feed_name=region,
|
||||
feed_region=region,
|
||||
homepage_url="https://example.com",
|
||||
published_at=published_at - timedelta(minutes=index),
|
||||
)
|
||||
|
||||
items = [
|
||||
*[make_item("asia-pacific", index) for index in range(24)],
|
||||
*[make_item("europe", index) for index in range(2)],
|
||||
*[make_item("middle-east-africa", index) for index in range(2)],
|
||||
*[make_item("americas", index) for index in range(2)],
|
||||
]
|
||||
|
||||
result = _diversify_parsed_news_items_by_region(items, limit=6)
|
||||
regions = {item.feed_region for item in result}
|
||||
|
||||
assert {"europe", "middle-east-africa", "americas"}.issubset(regions)
|
||||
|
||||
|
||||
def test_serialize_item_falls_back_to_global_anchor():
|
||||
item = ParsedNewsItem(
|
||||
id="custom:test",
|
||||
@@ -279,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():
|
||||
@@ -961,9 +1073,11 @@ async def test_earth_news_payload_uses_fresh_database_items_without_rss(monkeypa
|
||||
async def fake_get_earth_news_freshness(_db, *, active_region):
|
||||
return 12, datetime.now(UTC)
|
||||
|
||||
async def fake_list_earth_news_items(_db, *, active_region, limit, categories=None):
|
||||
assert limit == 12
|
||||
return [item]
|
||||
async def fake_list_earth_news_items(_db, *, active_region, limit, categories=None, source_ids=None):
|
||||
if source_ids is None:
|
||||
assert limit == 12
|
||||
return [item]
|
||||
return []
|
||||
|
||||
async def fail_fetch(_sources):
|
||||
raise AssertionError("fresh database items should not fetch RSS")
|
||||
@@ -1062,8 +1176,8 @@ async def test_earth_news_payload_passes_region_and_category_filters_to_store(mo
|
||||
async def fake_list_earth_news_items(_db, *, active_region, limit, categories=None, source_ids=None):
|
||||
captured["items_region"] = active_region
|
||||
captured["items_categories"] = categories
|
||||
captured["items_source_ids"] = source_ids
|
||||
return [item]
|
||||
captured.setdefault("items_source_ids", []).append(source_ids)
|
||||
return [item] if source_ids is None else []
|
||||
|
||||
async def fake_list_earth_news_cruise_items(_db, *, limit, categories=None, source_ids=None):
|
||||
captured["cruise_categories"] = categories
|
||||
@@ -1090,7 +1204,8 @@ async def test_earth_news_payload_passes_region_and_category_filters_to_store(mo
|
||||
assert captured["freshness_region"] == "europe"
|
||||
assert captured["items_region"] == "europe"
|
||||
assert captured["items_categories"] == {"business", "ecommerce"}
|
||||
assert captured["items_source_ids"] is None
|
||||
assert captured["items_source_ids"][0] is None
|
||||
assert any(source_ids for source_ids in captured["items_source_ids"][1:])
|
||||
assert captured["cruise_categories"] == {"business", "ecommerce"}
|
||||
assert captured["cruise_source_ids"] is None
|
||||
assert payload["filters"] == {
|
||||
|
||||
252
backend/tests/test_earth_news_manual.py
Normal file
@@ -0,0 +1,252 @@
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from app.core.enums import NewsSourceType
|
||||
from app.models.earth_news import EarthNewsItem
|
||||
from app.models.system_setting import SystemSetting
|
||||
from app.services.earth_news import REGION_ANCHORS
|
||||
from app.services.earth_news_manual import (
|
||||
DEFAULT_MANUAL_NEWS_GROUP_ID,
|
||||
create_manual_news_group,
|
||||
import_manual_news_items,
|
||||
list_news_groups,
|
||||
list_news_records,
|
||||
parse_manual_news_import_upload,
|
||||
rename_manual_news_group,
|
||||
upsert_manual_news_item,
|
||||
)
|
||||
|
||||
|
||||
class _FakeResult:
|
||||
def __init__(self, rows=None, scalar=None):
|
||||
self.rows = rows or []
|
||||
self._scalar = scalar
|
||||
|
||||
def scalar_one_or_none(self):
|
||||
return self._scalar
|
||||
|
||||
def scalar(self):
|
||||
return self._scalar
|
||||
|
||||
def scalars(self):
|
||||
return self
|
||||
|
||||
def all(self):
|
||||
return self.rows
|
||||
|
||||
|
||||
class _FakeNewsSession:
|
||||
def __init__(self, records=None, setting=None):
|
||||
self.records = dict(records or {})
|
||||
self.setting = setting
|
||||
|
||||
async def get(self, _model, item_id):
|
||||
return self.records.get(item_id)
|
||||
|
||||
def add(self, item):
|
||||
if isinstance(item, SystemSetting):
|
||||
self.setting = item
|
||||
else:
|
||||
self.records[item.id] = item
|
||||
|
||||
async def execute(self, stmt):
|
||||
statement = str(stmt)
|
||||
if "system_settings" in statement:
|
||||
return _FakeResult(scalar=self.setting)
|
||||
if "count" in statement.lower():
|
||||
return _FakeResult(scalar=len(self.records))
|
||||
return _FakeResult(rows=list(self.records.values()))
|
||||
|
||||
async def flush(self):
|
||||
return None
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_news_queue(monkeypatch):
|
||||
queued = []
|
||||
|
||||
async def _enqueue(payload, force=False):
|
||||
queued.append({"payload": payload, "force": force})
|
||||
return True
|
||||
|
||||
monkeypatch.setattr("app.services.earth_news_manual.enqueue_target_location_job", _enqueue)
|
||||
return queued
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manual_news_upsert_uses_region_anchor_and_manual_metadata(fake_news_queue):
|
||||
db = _FakeNewsSession()
|
||||
|
||||
result = await upsert_manual_news_item(
|
||||
db,
|
||||
{
|
||||
"title": "手动添加的新闻",
|
||||
"summary": "一条用于测试的手动新闻。",
|
||||
"source": "人工录入",
|
||||
"region": "europe",
|
||||
"published_at": "2026-05-15T03:00:00Z",
|
||||
"tags": ["manual", "test"],
|
||||
},
|
||||
)
|
||||
|
||||
anchor = REGION_ANCHORS["europe"]
|
||||
assert result.created is True
|
||||
assert result.queued is True
|
||||
assert result.item.id.startswith("manual:")
|
||||
assert result.item.feed_name == "手动添加"
|
||||
assert result.item.source == "人工录入"
|
||||
assert result.item.latitude == anchor.latitude
|
||||
assert result.item.longitude == anchor.longitude
|
||||
assert result.item.location_source == "region_anchor"
|
||||
assert result.item.verified is False
|
||||
assert result.item.location_meta["news_meta"]["feed_type"] == NewsSourceType.MANUAL.value
|
||||
assert result.item.location_meta["news_meta"]["source_type"] == NewsSourceType.MANUAL.value
|
||||
assert result.item.location_meta["news_meta"]["manual_group_id"] == DEFAULT_MANUAL_NEWS_GROUP_ID
|
||||
assert len(fake_news_queue) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manual_news_duplicate_import_upserts_without_duplicate_rows(fake_news_queue):
|
||||
db = _FakeNewsSession()
|
||||
payload = {
|
||||
"title": "Same manual story",
|
||||
"source": "Manual Desk",
|
||||
"published_at": "2026-05-15T03:00:00Z",
|
||||
"region": "global",
|
||||
}
|
||||
|
||||
first = await upsert_manual_news_item(db, payload)
|
||||
second = await upsert_manual_news_item(db, {**payload, "summary": "Updated summary"})
|
||||
|
||||
assert first.created is True
|
||||
assert second.created is False
|
||||
assert len(db.records) == 1
|
||||
assert db.records[first.item.id].summary == "Updated summary"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manual_news_edit_without_location_preserves_manual_coordinates(fake_news_queue):
|
||||
db = _FakeNewsSession()
|
||||
created = await upsert_manual_news_item(
|
||||
db,
|
||||
{
|
||||
"title": "Taipei-1 data center update",
|
||||
"summary": "Initial summary.",
|
||||
"region": "asia-pacific",
|
||||
"published_at": "2026-05-15T03:00:00Z",
|
||||
"location": {"label": "Kaohsiung, Taiwan", "latitude": 22.6273, "longitude": 120.3014},
|
||||
},
|
||||
)
|
||||
|
||||
updated = await upsert_manual_news_item(
|
||||
db,
|
||||
{
|
||||
"title": "Taipei-1 data center update",
|
||||
"summary": "Edited summary only.",
|
||||
"region": "asia-pacific",
|
||||
"published_at": "2026-05-15T03:00:00Z",
|
||||
},
|
||||
item_id_override=created.item.id,
|
||||
)
|
||||
|
||||
assert updated.created is False
|
||||
assert updated.item.latitude == pytest.approx(22.6273)
|
||||
assert updated.item.longitude == pytest.approx(120.3014)
|
||||
assert updated.item.location_source == "manual_location"
|
||||
assert updated.item.verified is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manual_news_api_service_rejects_rss_records(fake_news_queue):
|
||||
rss_record = EarthNewsItem(
|
||||
id="bbc-world:example",
|
||||
title="RSS story",
|
||||
summary="RSS summary",
|
||||
source="BBC World",
|
||||
feed_name="BBC World",
|
||||
region="global",
|
||||
latitude=20,
|
||||
longitude=0,
|
||||
location_label="全球",
|
||||
location_source="region_anchor",
|
||||
verified=False,
|
||||
location_meta={"news_meta": {"feed_type": "rss"}},
|
||||
first_seen_at=datetime.now(UTC),
|
||||
last_seen_at=datetime.now(UTC),
|
||||
)
|
||||
db = _FakeNewsSession({rss_record.id: rss_record})
|
||||
|
||||
with pytest.raises(PermissionError):
|
||||
await upsert_manual_news_item(
|
||||
db,
|
||||
{"title": "Edited title", "region": "global"},
|
||||
item_id_override=rss_record.id,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manual_news_import_reports_per_item_errors(fake_news_queue):
|
||||
db = _FakeNewsSession()
|
||||
|
||||
result = await import_manual_news_items(
|
||||
db,
|
||||
[
|
||||
{"title": "Valid manual news", "region": "global"},
|
||||
{"summary": "missing title"},
|
||||
],
|
||||
)
|
||||
|
||||
assert result["created"] == 1
|
||||
assert result["failed"] == 1
|
||||
assert result["errors"][0]["index"] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manual_news_groups_default_create_and_rename(fake_news_queue):
|
||||
db = _FakeNewsSession()
|
||||
|
||||
initial = await list_news_groups(db)
|
||||
assert initial["manual_groups"][0]["id"] == DEFAULT_MANUAL_NEWS_GROUP_ID
|
||||
assert initial["manual_groups"][0]["name"] == "新建新闻组"
|
||||
|
||||
group = await create_manual_news_group(db, "专题组")
|
||||
assert group["name"] == "专题组"
|
||||
assert db.setting is not None
|
||||
|
||||
await upsert_manual_news_item(db, {"title": "Grouped story", "region": "global"}, group_id=group["id"])
|
||||
renamed = await rename_manual_news_group(db, group["id"], "重命名专题")
|
||||
|
||||
record = next(iter(db.records.values()))
|
||||
assert renamed["name"] == "重命名专题"
|
||||
assert record.location_meta["news_meta"]["manual_group_id"] == group["id"]
|
||||
assert record.location_meta["news_meta"]["manual_group_name"] == "重命名专题"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manual_news_list_filters_by_group_id(fake_news_queue):
|
||||
db = _FakeNewsSession()
|
||||
group = await create_manual_news_group(db, "导入组")
|
||||
|
||||
await import_manual_news_items(
|
||||
db,
|
||||
[
|
||||
{"title": "In group", "region": "global"},
|
||||
{"title": "Also in group", "region": "global"},
|
||||
],
|
||||
group_id=group["id"],
|
||||
)
|
||||
await upsert_manual_news_item(db, {"title": "Default group", "region": "global"})
|
||||
|
||||
grouped = await list_news_records(db, page=1, page_size=20, group_id=group["id"])
|
||||
default_group = await list_news_records(db, page=1, page_size=20, group_id=DEFAULT_MANUAL_NEWS_GROUP_ID)
|
||||
|
||||
assert grouped["total"] == 2
|
||||
assert {item["manual_group_id"] for item in grouped["items"]} == {group["id"]}
|
||||
assert default_group["total"] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manual_news_import_parser_requires_json_array():
|
||||
with pytest.raises(ValueError, match="顶层必须是数组"):
|
||||
await parse_manual_news_import_upload(b'{"title":"not an array"}')
|
||||
@@ -28,7 +28,7 @@ def test_protocol_enum_values_remain_api_compatible() -> None:
|
||||
assert [item.value for item in NewsImportanceLevel] == ["low", "medium", "high", "critical"]
|
||||
assert [item.value for item in BreakingLevel] == ["none", "watch", "breaking", "critical"]
|
||||
assert [item.value for item in BreakingScope] == ["regional", "global"]
|
||||
assert [item.value for item in NewsSourceType] == ["rss", "atom", "aggregated", "reference"]
|
||||
assert [item.value for item in NewsSourceType] == ["rss", "atom", "aggregated", "reference", "manual"]
|
||||
assert [item.value for item in UserRole] == ["viewer", "admin", "super_admin"]
|
||||
assert JobStatus.RUNNING.value == "running"
|
||||
assert PlaygroundMessageKind.THINKING.value == "thinking"
|
||||
|
||||
@@ -40,6 +40,9 @@ def test_gesture_event_serializes_stable_protocol_fields():
|
||||
assert payload["seq"] == 7
|
||||
assert payload["source"] == "motion-agent"
|
||||
assert payload["mode"] == "single"
|
||||
assert payload["protocol_version"] == "motion.v2"
|
||||
assert payload["input_mode"] == "single"
|
||||
assert payload["camera_id"] == "unknown"
|
||||
assert payload["payload"] == {}
|
||||
|
||||
|
||||
@@ -88,8 +91,13 @@ def test_motion_server_status_includes_dry_run_camera_and_heartbeat():
|
||||
assert status["camera_count"] == 1
|
||||
assert status["active_camera_ids"] == ["dry-run:null-camera"]
|
||||
assert status["recognizer"] == "dry-run"
|
||||
assert status["protocol_version"] == "motion.v2"
|
||||
assert status["armed"] is False
|
||||
assert status["paused"] is False
|
||||
assert status["devices_open"] is False
|
||||
assert heartbeat == {
|
||||
"timestamp_ms": 123,
|
||||
"protocol_version": "motion.v2",
|
||||
"source": "motion-agent",
|
||||
"type": "heartbeat",
|
||||
}
|
||||
@@ -109,6 +117,7 @@ def test_skeleton_event_serializes_without_raw_image_fields():
|
||||
payload = json.loads(event.to_json())
|
||||
|
||||
assert payload["type"] == "skeleton"
|
||||
assert payload["protocol_version"] == "motion.v2"
|
||||
assert payload["matched_gesture"] == "rotate_left"
|
||||
assert payload["confidence"] == 0.91
|
||||
assert payload["camera_id"] == "usb:0"
|
||||
@@ -120,6 +129,25 @@ def test_skeleton_event_serializes_without_raw_image_fields():
|
||||
assert "frame" not in payload
|
||||
|
||||
|
||||
def test_v2_gesture_set_accepts_frontend_motion_gestures():
|
||||
state = GestureStateMachine(confidence_threshold=0.7, cooldown_ms=0)
|
||||
|
||||
for gesture in [
|
||||
"rotate_up",
|
||||
"rotate_down",
|
||||
"focus_prev",
|
||||
"focus_next",
|
||||
"layer_prev",
|
||||
"layer_next",
|
||||
]:
|
||||
event = state.accept(
|
||||
GestureObservation(gesture, confidence=0.9, intensity=0.8, timestamp_ms=1000)
|
||||
)
|
||||
|
||||
assert event is not None
|
||||
assert event.gesture == gesture
|
||||
|
||||
|
||||
def test_dry_run_recognizer_produces_debug_skeleton():
|
||||
server = MotionAgentServer(MotionAgentConfig(dry_run=True))
|
||||
|
||||
@@ -240,3 +268,92 @@ async def test_motion_agent_cli_reports_dependency_error_without_traceback(monke
|
||||
assert exit_code == 2
|
||||
assert "Motion agent failed: missing cv stack" in captured.err
|
||||
assert "Traceback" not in captured.err
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_motion_agent_command_updates_control_state():
|
||||
server = MotionAgentServer(MotionAgentConfig(dry_run=True))
|
||||
|
||||
armed = await server.handle_command(
|
||||
json.dumps(
|
||||
{
|
||||
"type": "command",
|
||||
"command": "set_armed",
|
||||
"request_id": "req-armed",
|
||||
"payload": {"armed": True},
|
||||
}
|
||||
)
|
||||
)
|
||||
paused = await server.handle_command(
|
||||
{
|
||||
"type": "command",
|
||||
"command": "set_paused",
|
||||
"request_id": "req-paused",
|
||||
"payload": {"paused": True},
|
||||
}
|
||||
)
|
||||
|
||||
assert armed.ok is True
|
||||
assert armed.request_id == "req-armed"
|
||||
assert armed.status["armed"] is True
|
||||
assert paused.ok is True
|
||||
assert paused.status["paused"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_motion_agent_open_devices_command_accepts_dual_mode():
|
||||
server = MotionAgentServer(MotionAgentConfig(dry_run=True))
|
||||
|
||||
try:
|
||||
result = await server.handle_command(
|
||||
{
|
||||
"type": "command",
|
||||
"command": "open_devices",
|
||||
"request_id": "req-open",
|
||||
"payload": {"input_mode": "dual_redundant"},
|
||||
}
|
||||
)
|
||||
|
||||
assert result.ok is True
|
||||
assert result.status["input_mode"] == "dual_redundant"
|
||||
assert result.status["active_camera_ids"] == ("dry-run:null-camera",)
|
||||
assert server._recognition_subprocess is not None
|
||||
assert server._recognition_subprocess.returncode is None
|
||||
finally:
|
||||
await server.stop_recognition_subprocess()
|
||||
|
||||
|
||||
def test_motion_agent_dual_fusion_merges_matching_observations():
|
||||
server = MotionAgentServer(MotionAgentConfig(dry_run=True))
|
||||
server.state.mode = "dual_redundant"
|
||||
|
||||
selected, fusion = server._fuse_observations(
|
||||
[
|
||||
GestureObservation("zoom_in", confidence=0.82, intensity=0.4, camera_id="usb:0"),
|
||||
GestureObservation("zoom_in", confidence=0.86, intensity=0.8, camera_id="usb:1"),
|
||||
]
|
||||
)
|
||||
|
||||
assert selected.gesture == "zoom_in"
|
||||
assert selected.camera_id == "fusion"
|
||||
assert selected.confidence > 0.86
|
||||
assert fusion == {
|
||||
"source_cameras": ["usb:1", "usb:0"],
|
||||
"window_ms": 120,
|
||||
"reason": "matched_observations",
|
||||
}
|
||||
|
||||
|
||||
def test_motion_agent_dual_fusion_suppresses_close_conflict():
|
||||
server = MotionAgentServer(MotionAgentConfig(dry_run=True, confidence_threshold=0.7))
|
||||
|
||||
selected, fusion = server._fuse_observations(
|
||||
[
|
||||
GestureObservation("zoom_in", confidence=0.82, intensity=0.5, camera_id="usb:0"),
|
||||
GestureObservation("zoom_out", confidence=0.78, intensity=0.5, camera_id="usb:1"),
|
||||
]
|
||||
)
|
||||
|
||||
assert selected.gesture == "zoom_in"
|
||||
assert selected.confidence == 0
|
||||
assert fusion["reason"] == "conflict_ignored"
|
||||
|
||||
@@ -8,6 +8,144 @@ This project follows the repository versioning rule:
|
||||
- `improvement` -> `+0.0.1`(bugfix + 小功能混合)
|
||||
- `bugfix` -> `+0.0.1`
|
||||
|
||||
## [0.74.3] — 2026-09-13
|
||||
|
||||
Released: 2026-09-13
|
||||
|
||||
### Highlights
|
||||
- Ubuntu / WSL 新机器初始化会自动检测并准备 Docker Engine、Compose v2、Buildx 和当前用户权限,减少手工安装步骤。
|
||||
- 数据库初始化先核对容器端口与后端真实连接,连接和认证通过后才创建表和默认数据。
|
||||
|
||||
### Added / Fixed / Improved
|
||||
- 区分 Docker CLI 缺失、服务未安装、daemon 不可用及 socket 权限不足,修正未安装 Docker 时误提示启动 socket 的诊断。
|
||||
- 自动补齐缺失的 Docker 依赖并启动本地服务,以原用户身份刷新 Docker 组权限;保留参数和 PATH,不依赖 sg。
|
||||
- 通过 Compose 同步已有 PostgreSQL / Redis 容器配置,保留端口冲突等具体错误;端口映射异常时最多保留数据卷重建一次 PostgreSQL。
|
||||
- 新增后端数据库只读连接检查,对认证、库名和网络失败给出不含密码或完整连接串的诊断。
|
||||
- 将 Docker 与数据库启动隔离回归测试接入快速检查,并同步 README、harness 和中英文运维说明。
|
||||
|
||||
---
|
||||
|
||||
## [0.74.2] — 2026-07-01
|
||||
|
||||
Released: 2026-07-01
|
||||
|
||||
### Highlights
|
||||
- 收敛 agent harness 到 `rules.md`、`AGENTS.md`、`docs/HARNESS.md` 和 `.codex/skills/`,删除重复维护的旧 Claude command 入口。
|
||||
- 强化视觉证据规则:截图或视觉引用路径打不开时必须先处理 WSL/Windows 路径、相对路径和附件位置,而不是跳过后猜测。
|
||||
- 明确 OCR 可作为文本类视觉证据或非多模态环境 fallback,同时要求布局、颜色、像素和渲染类问题保留真实视觉验证或明确限制说明。
|
||||
|
||||
### Added / Fixed / Improved
|
||||
- `AGENTS.md` 替换旧 opencode/默认 Plan Mode 内容,保留最新单一入口和 harness 验证说明。
|
||||
- `rules.md` 与 `docs/HARNESS.md` 同步 Visual Evidence Gate,补齐路径解析、访问失败报告和 OCR fallback 边界。
|
||||
- 删除 `.claude/commands/*` 中与 `.codex/skills/*` 重复的旧 cleanup/docs/goal-driven/release 入口,并更新文档受众计划中的旧路径引用。
|
||||
|
||||
---
|
||||
|
||||
## [0.74.1] — 2026-06-30
|
||||
|
||||
Released: 2026-06-30
|
||||
|
||||
### Highlights
|
||||
- 将 `/earth-content` 的品牌标识上传收敛到 `Logo 地址` 与 `标题图地址` 字段内,移除旧的全局“选择资产/上传”工具栏。
|
||||
- 新增字段级图片拖拽反馈,拖到对应字段时直接提示将图片复制为 Logo 或标题图。
|
||||
- 对齐品牌上传按钮到现有 Tactile UI primary 按钮样式,并同步中英文使用手册、快速开始和控制台上下文文档。
|
||||
|
||||
### Added / Fixed / Improved
|
||||
- `BrandAssetInput` 支持字段内选择文件、拖拽上传、单字段 loading 和上传后回写草稿 URL。
|
||||
- `FieldGrid` 支持按字段注入自定义输入控件,同时复用统一草稿提交路径。
|
||||
- 品牌上传拖拽态改为低饱和 tactile 配色,上传按钮保持蓝色轻立体样式,容器内上下/右侧留白对齐为 3px。
|
||||
- 补齐品牌上传相关 legacy UI 英文翻译、术语对照和用户文档。
|
||||
|
||||
---
|
||||
|
||||
## [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
|
||||
|
||||
### Highlights
|
||||
- 新增前端统一 i18n 基础设施,让认证页、Docs UI、控制台外壳、导航、搜索和核心共享组件共用 `zh-CN` / `en-US` 语言状态。
|
||||
- 控制台侧边栏偏好面板接入语言与主题切换,并修复一屏高度链、账号区、状态指示器和英文态文案裁切问题。
|
||||
- 扩展 harness 与 smoke 覆盖,确保 admin shell 高度、移动/缩放布局、语言切换、搜索、Docs 和核心控制台交互在发布前被验证。
|
||||
|
||||
### Added / Fixed / Improved
|
||||
- 新增 `frontend/src/i18n/`,用 `i18next` / `react-i18next` 维护资源、locale 映射、Docs 兼容和过渡期 legacy UI 翻译桥。
|
||||
- 将 AdminLayout、route manifest、admin search、Auth、DataTable、Dialog、Toast、MarkdownRenderer 和 Users 页迁移到统一翻译资源。
|
||||
- 补齐 Planet Content、Collected Data、System Logs、Datasources、Settings 和 Collection Management 等英文态残留翻译,并覆盖动态计数字符串。
|
||||
- 改进控制台侧边栏账号区、语言 switch、状态 pill 自适应宽度和 admin shell overflow ownership,避免首屏溢出和状态词裁切。
|
||||
- 更新 i18n 计划、控制台前端上下文、harness 文档和规则,记录语言迁移边界、状态指示器布局约束和一屏验证要求。
|
||||
|
||||
---
|
||||
|
||||
## [0.72.0] — 2026-06-29
|
||||
|
||||
Released: 2026-06-29
|
||||
|
||||
### Highlights
|
||||
- 将 agent 入口收敛到单一 `AGENTS.md`,并让 harness 明确阻止小写入口再次分叉。
|
||||
- 新增完整本地 harness 验证层,覆盖 backend/frontend/docs/security 静态规则、前端 build 和 Playwright 路由/交互 smoke。
|
||||
- 扩展 Earth News 与控制台 smoke,确保新闻源测试、新增取消、手动新闻组创建、桌面/移动菜单和 zoom 布局都在发布前验证。
|
||||
|
||||
### Added / Fixed / Improved
|
||||
- 新增 `scripts/harness/*` 规则检查、doctor、validate 和前端 smoke 脚本,并将未跟踪 harness 设施纳入发布。
|
||||
- 清理 SpaceTrack 与 PeeringDB collector 的 stdout/debug 输出,改用结构化日志并移除 SpaceTrack 不可达重复 fetch 路径。
|
||||
- 强化控制台布局、auth 表单、Docs 页面、Earth shell 和 Earth toolbar 的响应式与无障碍细节。
|
||||
- 同步 README、CODEMAP、HARNESS、harness audit、用户手册、快速开始和开发者文档,明确当前 Web Earth / React admin / FastAPI / aiprovider 边界。
|
||||
- 将 backend、frontend、docs 和 Earth News 检查纳入 `scripts/harness/quick-check.sh` 与 `scripts/harness/validate.sh` 的稳定验证面。
|
||||
|
||||
---
|
||||
|
||||
## [0.71.1] — 2026-06-26
|
||||
|
||||
Released: 2026-06-26
|
||||
|
||||
### Highlights
|
||||
- 修复 Earth 欧洲、美洲、中东与非洲等区域新闻被亚太来源和旧来源过滤饿死的问题,滚动条、面板和巡航重新回到同一批区域 payload。
|
||||
- 将当前可见新闻和巡航新闻提升到目标位置/翻译优先队列,避免历史普通 Redis backlog 阻塞用户正在看的新闻精修。
|
||||
- 新增 agent harness 入口、代码地图、验证脚本与双语技术说明,让后续维护能按现有 uv/Bun/Gitea 工作流检查而不替代项目规则。
|
||||
|
||||
### Added / Fixed / Improved
|
||||
- EarthFeed 在全局和巡航队列中按区域轮转候选新闻,保留区域视图的“当前区域 + global”规则,并补充回归测试。
|
||||
- `news.js` 按区域、类型、来源和数量隔离并发刷新请求,丢弃旧区域响应;跨区域时不再复用旧来源筛选。
|
||||
- 新闻展示在中文本地化未完成时回退原始标题和摘要,避免出现有内容却显示“新闻汉化中”的卡片。
|
||||
- 新闻目标位置 worker 新增优先 stream、pending reclaim、任务超时和并发处理;无效消息会确认并删除,减少队列堆积。
|
||||
- 补充 Earth 新闻源、Earth 前端结构、harness 和版本历史文档,并移除控制台 auth store 的调试日志。
|
||||
|
||||
---
|
||||
|
||||
## [0.71.0] — 2026-06-11
|
||||
|
||||
Released: 2026-06-11
|
||||
|
||||
### Highlights
|
||||
- 将 Motion Agent 升级为可供 Web/UE 共用的双向控制服务,补齐真实 MediaPipe 识别 worker、设备控制、动作白名单和 WSL 摄像头开箱启动链路。
|
||||
- 新增 Earth 手动新闻内容组、条目、导入与重处理能力,并改进按 locale 和启用来源进行的新闻补充与多样化。
|
||||
- 对齐动捕模式下的 Earth 点击、详情锁定和卫星轨迹交互,同时完善启动脚本、测试 harness 与运维说明。
|
||||
|
||||
### Added / Fixed / Improved
|
||||
- Motion Agent 支持命令结果、状态、骨架与手势事件,统一单路/双路输入配置,并随 `planet.sh` 默认启动;仓库内提供 usbipd-win fallback 安装包。
|
||||
- Earth 新闻服务集中处理显示就绪判断和来源多样化,避免存储层与编排层重复筛选;新增手动新闻 API 与回归测试。
|
||||
- 清理 Motion Agent 重复识别执行路径、前端不稳定随机 key 和过时计划描述,补齐 pytest 路径 harness、双语使用手册、快速开始与数据流文档。
|
||||
|
||||
---
|
||||
|
||||
## [0.70.0] — 2026-06-04
|
||||
|
||||
Released: 2026-06-04
|
||||
|
||||
325
docs/HARNESS.md
Normal file
@@ -0,0 +1,325 @@
|
||||
# Agent Harness
|
||||
|
||||
This harness improves discoverability, repeatability, and agent safety for the
|
||||
existing Planet project. It does not replace current project rules, scripts, CI,
|
||||
or release workflows.
|
||||
|
||||
## Authority And Conflicts
|
||||
|
||||
Existing project rules are authoritative:
|
||||
|
||||
1. `rules.md`
|
||||
2. `AGENTS.md`
|
||||
3. Current implementation docs under `docs/technical/`
|
||||
4. Existing scripts, especially `planet.sh`
|
||||
5. Existing Gitea workflow files under `.gitea/workflows/`
|
||||
|
||||
When harness guidance conflicts with any of the above, keep the existing rule,
|
||||
do not overwrite the existing workflow, and add a compatibility note here or in
|
||||
`docs/harness-audit.md`.
|
||||
|
||||
For frontend or documentation audits, also read the Rules Coverage Evidence
|
||||
section in `docs/harness-audit.md`. It maps `rules.md` clauses to the current
|
||||
static checks, Playwright smoke coverage, and remaining manual review areas, so
|
||||
an agent can distinguish a proved harness pass from a rule that still needs
|
||||
human-quality inspection.
|
||||
|
||||
When the user describes work with product words rather than module names, use
|
||||
the `rules.md` **Agent Discovery Index** before deciding which modules to load.
|
||||
It maps Chinese phrases such as `一屏`, `高度没控住`, `文档`, `数据源`,
|
||||
`地球`, `模型供应商`, and `发版` to the required rule modules.
|
||||
|
||||
## Starting Work
|
||||
|
||||
Recommended startup flow:
|
||||
|
||||
```bash
|
||||
git status --short
|
||||
scripts/harness/doctor.sh
|
||||
```
|
||||
|
||||
Then read only the relevant implementation docs:
|
||||
|
||||
- Backend/API/data work: `docs/technical/zh/backend-*.md` and matching English
|
||||
docs when public docs are affected.
|
||||
- Frontend/admin work: `docs/technical/zh/frontend-admin-frontend-context.md`.
|
||||
- Earth work: `docs/technical/zh/earth-frontend-context.md`,
|
||||
`docs/technical/zh/earth-render-layer-order.md`, and style docs when visual
|
||||
semantics change.
|
||||
- Operations work: `docs/technical/zh/ops-runbook.md` and
|
||||
`docs/technical/zh/ops-planet-sh-startup.md`.
|
||||
- AI Provider work: `docs/technical/zh/agents-aiprovider.md`.
|
||||
- Documentation work: `docs/documentation-coverage-rules.md`.
|
||||
|
||||
Use focused inspection commands before broad reads:
|
||||
|
||||
```bash
|
||||
rg -n "<symbol-or-term>" <path>
|
||||
git diff --stat HEAD
|
||||
git diff --name-only HEAD
|
||||
git diff --unified=0 HEAD -- <path>
|
||||
```
|
||||
|
||||
## Existing Commands
|
||||
|
||||
| Purpose | Command |
|
||||
| --- | --- |
|
||||
| First setup | `./planet.sh init` |
|
||||
| Start local stack | `./planet.sh start` |
|
||||
| Start with LAN access | `./planet.sh start --allow-lan` |
|
||||
| Restart all services | `./planet.sh restart` |
|
||||
| Restart one area | `./planet.sh restart -b`, `-f`, `-a`, or `-d` |
|
||||
| Health check | `./planet.sh health` |
|
||||
| Logs | `./planet.sh log`, `./planet.sh log -b`, `-f`, `-a`, or `-m` |
|
||||
| Create local user | `./planet.sh createuser` |
|
||||
| Destructive local reset | `./planet.sh destroy` |
|
||||
| Backend smoke tests | `cd backend && uv run --frozen --group dev --project .. python -m pytest -s tests/test_api.py tests/test_realtime_sources.py -q` |
|
||||
| Frontend build | `cd frontend && bun install --frozen-lockfile && bun run build` |
|
||||
| Mock AIS WebSocket | `bun run mock:ais-ws` |
|
||||
|
||||
## Harness Commands
|
||||
|
||||
| Tier | Command | What It Does |
|
||||
| --- | --- | --- |
|
||||
| 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, 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. |
|
||||
|
||||
Docker image smoke builds are expensive and are off by default:
|
||||
|
||||
```bash
|
||||
PLANET_HARNESS_DOCKER_SMOKE=1 scripts/harness/validate.sh
|
||||
```
|
||||
|
||||
Frontend Playwright smoke runs by default in full validation after the frontend
|
||||
build. It starts a local Vite preview and checks the `/` to Earth redirect,
|
||||
public pages, unknown-route login fallback, protected admin route login
|
||||
fallback, authenticated unknown-route fallback to `/admin`, Docs loading with
|
||||
mocked API content, Docs detail page
|
||||
language/theme/search interactions, every Docs catalog slug exposed by the
|
||||
frontend/backend metadata, the Earth iframe entry point, login error handling,
|
||||
register + email verification, password reset, standalone email verification,
|
||||
and authenticated `super_admin` rendering for every admin route plus core
|
||||
`section` deep links derived from the actual admin route and section config.
|
||||
Authenticated admin
|
||||
checks run at desktop size, mobile size, and 125% / 150% zoom; desktop and
|
||||
mobile passes also fail on global horizontal overflow so table/detail panels
|
||||
must keep overflow ownership inside their own scroll regions. To enforce the
|
||||
existing `rules.md` `uiux` one-screen workspace rule, admin shell pages have a
|
||||
hard rendered check: the shell must resolve to the viewport height through the
|
||||
root 100% height chain, `#root`/document/body must not gain vertical overflow,
|
||||
and the desktop sidebar account/preferences area must remain inside the first
|
||||
viewport while the nav owns any excess scrolling. The smoke also
|
||||
derives the sidebar menu from the actual admin route manifest and clicks every
|
||||
visible `super_admin` menu entry on both desktop and mobile viewports, then
|
||||
exercises safe interaction paths for admin search, section tabs, the AI settings
|
||||
shortcut, logs view switching, user dialog opening, and data distribution toggles.
|
||||
It also exercises Earth News source testing, add/cancel source draft behavior,
|
||||
and manual news group creation against mocked `/earth/news-*` APIs.
|
||||
Documented AI and collector
|
||||
deep links such as `/ai?section=integrations`, `/ai?section=playground`, and
|
||||
`/collection-management?section=collector_credentials` are part of the rendered
|
||||
smoke surface:
|
||||
|
||||
```bash
|
||||
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.
|
||||
- Path resolution is part of visual evidence handling. If a referenced
|
||||
screenshot path cannot be opened, try reasonable local equivalents first:
|
||||
WSL/Windows path conversion, workspace-relative paths, absolute paths, current
|
||||
thread attachments, repository files, and obvious local attachment/download
|
||||
locations.
|
||||
- If the image still cannot be found or opened, report the exact path/access
|
||||
blocker instead of guessing. Do not infer image content from the filename, alt
|
||||
text, surrounding prose, logs, or memory.
|
||||
- OCR is acceptable evidence for text-only questions or non-multimodal
|
||||
environments; state when OCR was the fallback. Layout, color, spacing, pixel,
|
||||
and rendering issues still require a real visual inspection or an explicit
|
||||
"could not verify visually" note.
|
||||
- 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:
|
||||
|
||||
- `zsh` for `planet.sh`
|
||||
- `uv` for Python dependency and test execution
|
||||
- `bun` for frontend dependency and build execution
|
||||
- Python resolved by `uv` from the root `pyproject.toml`
|
||||
|
||||
Harness command lookup first checks the current non-interactive `PATH`. If a
|
||||
required tool is not visible there, `scripts/harness/lib.sh` asks the user's
|
||||
login interactive shell (`$SHELL`, then `zsh`, then `bash`) for the command
|
||||
path. This avoids hardcoding a dotfile while still covering agent environments
|
||||
that do not inherit the user's normal shell setup.
|
||||
|
||||
Required for full local stack operation:
|
||||
|
||||
- Docker and Docker Compose
|
||||
- PostgreSQL and Redis containers started by `planet.sh`
|
||||
|
||||
Optional for delivery smoke:
|
||||
|
||||
- Docker daemon for image builds
|
||||
- Helm for chart lint/template checks
|
||||
|
||||
For routine harness validation, do not install missing system software
|
||||
automatically. Report the gap and point to the explicit bootstrap entry points.
|
||||
`./planet.sh init` can install missing Docker Engine, Compose v2, and Buildx on
|
||||
Ubuntu / Ubuntu WSL, start the local service, and configure Docker group access.
|
||||
This bootstrap behavior is intentional; do not invoke it merely to make harness
|
||||
checks pass. `scripts/bootstrap-dev.sh` only prepares application dependencies.
|
||||
|
||||
Docker bootstrap regression checks use isolated command stubs and never install
|
||||
packages or modify the host daemon:
|
||||
|
||||
```bash
|
||||
uv run --frozen --project . python scripts/harness/test_docker_bootstrap.py
|
||||
uv run --frozen --project . python scripts/harness/test_database_startup.py
|
||||
```
|
||||
|
||||
Database startup regressions also run in quick-check. They cover Compose
|
||||
reconciliation of existing containers, visible startup errors, published-port
|
||||
checks, bounded recreation that preserves volumes, and the backend connection
|
||||
gate before schema initialization. Their command stubs and driver mocks do not
|
||||
modify the host Docker environment.
|
||||
|
||||
## What Agents Must Not Change Automatically
|
||||
|
||||
- Do not replace Bun with npm, pnpm, or yarn.
|
||||
- Do not migrate CI from `.gitea/workflows/` to `.github/workflows/`.
|
||||
- Do not rewrite `planet.sh` lifecycle behavior as a parallel script.
|
||||
- Do not run `./planet.sh destroy` unless explicitly requested.
|
||||
- Do not commit `.env`, secrets, private keys, logs, or generated build output.
|
||||
- Do not add external integrations, hooks, or new dependency managers just to
|
||||
satisfy harness structure.
|
||||
- Do not publish internal harness docs into the product Docs UI unless a
|
||||
maintainer explicitly asks for it.
|
||||
|
||||
## Hooks And Reminders
|
||||
|
||||
No automatic hooks are installed in this phase. Manual reminders:
|
||||
|
||||
- Run `scripts/harness/quick-check.sh` before handing off small changes.
|
||||
- Run `scripts/harness/validate.sh` before larger cross-subsystem changes.
|
||||
- Run `scripts/harness/security-check.sh` after touching config, auth,
|
||||
credentials, docs examples, or generated fixtures.
|
||||
- Run `scripts/harness/backend-rules-check.sh` after backend service edits to
|
||||
catch direct stdout/debugger calls before they reach runtime logs.
|
||||
- Run `scripts/harness/frontend-rules-check.sh` after frontend edits to expose
|
||||
route, package-manager, debug-output, and UI rule warnings.
|
||||
- Run `scripts/harness/docs-consistency-check.sh` after docs edits or feature
|
||||
route changes.
|
||||
- Add focused tests before modifying backend service behavior or frontend
|
||||
workflows.
|
||||
- For docs changes, run the checks listed in
|
||||
`docs/documentation-coverage-rules.md`.
|
||||
|
||||
## Reusable Workflows
|
||||
|
||||
### Feature Work
|
||||
|
||||
1. Read `rules.md` modules for the touched area.
|
||||
2. Check `CODEMAP.md` for entry points and ownership boundaries.
|
||||
3. Inspect existing tests and docs before editing.
|
||||
4. Make the smallest behavior-preserving or feature-scoped change.
|
||||
5. Run `scripts/harness/quick-check.sh` or a narrower documented command.
|
||||
6. Update relevant docs when behavior, workflow, or operations change.
|
||||
7. For rendered frontend changes, verify the affected route with Playwright or
|
||||
the full harness smoke, because `bun run build` alone does not prove page
|
||||
usability.
|
||||
|
||||
### Bug Fix
|
||||
|
||||
1. Reproduce with a focused test or command.
|
||||
2. Patch the owning module, not a caller-side workaround.
|
||||
3. Run the focused regression test.
|
||||
4. Run `scripts/harness/quick-check.sh` when the change is safe to validate
|
||||
locally.
|
||||
|
||||
### Documentation Change
|
||||
|
||||
1. Read `docs/documentation-coverage-rules.md`.
|
||||
2. Route docs by audience: UI users, operations, or second-party developers.
|
||||
3. Keep Chinese and English technical docs paired by filename; public Docs also
|
||||
need matching frontend/backend metadata when exposed in the product Docs UI.
|
||||
4. Run the repository-specific docs checks that match the changed files.
|
||||
|
||||
### Release Or Delivery Change
|
||||
|
||||
Use the existing release skill/workflow and `.gitea/workflows/` files. Harness
|
||||
validation can smoke-check Helm and Docker locally, but it must not replace the
|
||||
release process.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `docs/harness-audit.md` records the discovery pass that led to this harness.
|
||||
- `AGENTS.md` is the single authoritative agent guide. The older lowercase
|
||||
`agents.md` entry has been merged into it and should remain absent.
|
||||
- `CODEMAP.md` is intentionally high level; deeper subsystem docs stay in
|
||||
`docs/technical/{zh,en}/`.
|
||||
- `scripts/harness/frontend-smoke.mjs` is a lightweight route/section smoke
|
||||
with mocked API data. It proves route shells, auth guards, and primary admin
|
||||
sections render, but it is not a replacement for feature-specific browser QA
|
||||
against a real backend.
|
||||
- Frontend smoke prints phase-level progress by default. Use
|
||||
`PLANET_FRONTEND_SMOKE_PROGRESS=verbose` to print each route/menu/doc item
|
||||
when diagnosing a slow or failing smoke run, or set it to `0` to suppress
|
||||
progress lines.
|
||||
161
docs/harness-audit.md
Normal file
@@ -0,0 +1,161 @@
|
||||
# Harness Audit
|
||||
|
||||
Last audited: 2026-06-26
|
||||
|
||||
This audit records the repository state used to add the agent harness. It is a
|
||||
compatibility note, not a replacement for existing rules or architecture docs.
|
||||
|
||||
## Existing Commands
|
||||
|
||||
| Area | Existing Command | Notes |
|
||||
| --- | --- | --- |
|
||||
| Bootstrap | `./planet.sh init` | Syncs uv/Bun dependencies, creates missing env files, starts data services, seeds defaults. |
|
||||
| Start | `./planet.sh start` | Starts backend, frontend, AI Provider, PostgreSQL/Redis, and Motion Agent when available. |
|
||||
| LAN start | `./planet.sh start --allow-lan` | Opens frontend/backend/AI Provider ports and requests Windows firewall/port cleanup when needed. |
|
||||
| Restart | `./planet.sh restart` | Supports scoped restart flags for backend, frontend, AI Provider, database, and Motion Agent. |
|
||||
| Health | `./planet.sh health` | Checks containers, backend `/health`, AI Provider `/health`, frontend, and Motion Agent state. |
|
||||
| Logs | `./planet.sh log` | Supports backend, frontend, AI Provider, and Motion Agent log views. |
|
||||
| User fallback | `./planet.sh createuser` | Interactive emergency/local account creation. |
|
||||
| Destructive reset | `./planet.sh destroy` | Requires confirmation and removes Planet-owned Docker/build/runtime state. Not a validation command. |
|
||||
| Backend CI smoke | `cd backend && uv run --frozen --group dev --project .. python -m pytest -s tests/test_api.py tests/test_realtime_sources.py -q` | Mirrors `.gitea/workflows/ci.yaml`. |
|
||||
| Frontend build | `cd frontend && bun install --frozen-lockfile && bun run build` | Bun-only workflow. |
|
||||
| Root helper | `bun run mock:ais-ws` | Runs `scripts/mock-ais-ws-server.ts` from the root package. |
|
||||
|
||||
## Existing Agent Instructions
|
||||
|
||||
| File | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| `AGENTS.md` | Present | Single authoritative agent behavior guide. It references `rules.md`, `project_context.md`, harness validation, and high-risk areas. |
|
||||
| `rules.md` | Present | Mandatory modular rules. Always load `core`, `security`, and `workflow`; load topic modules as needed. |
|
||||
| `project_context.md` | Present | Static context. Some roadmap-era stack details are older than the current README/docs. |
|
||||
| `.claude/commands/*.md` | Present | Existing command docs for cleanup, docs, goal-driven, and release workflows. |
|
||||
| `.codex/skills/*.md` | Present | Existing local skills for cleanup, docs, goal-driven, and release. |
|
||||
|
||||
## Existing CI Gates
|
||||
|
||||
The repository uses `.gitea/workflows/`, not `.github/workflows/`.
|
||||
|
||||
| Workflow | Gate |
|
||||
| --- | --- |
|
||||
| `.gitea/workflows/ci.yaml` | Backend smoke tests, frontend Bun build, Docker build smoke, Helm lint/template. |
|
||||
| `.gitea/workflows/release.yaml` | Builds and pushes frontend, backend, and AI Provider images on main/tag/manual release events. |
|
||||
| `.gitea/workflows/deploy-staging.yaml` | Deploys Helm release to staging and runs curl smoke tests inside the cluster. |
|
||||
|
||||
## Existing Docs And Architecture Maps
|
||||
|
||||
| Area | Docs |
|
||||
| --- | --- |
|
||||
| Current architecture and startup | `README.md` |
|
||||
| Technical docs index | `docs/technical/zh/README.md`, `docs/technical/en/README.md` |
|
||||
| Documentation rules | `docs/documentation-coverage-rules.md` |
|
||||
| Operations | `docs/technical/zh/ops-runbook.md`, `docs/technical/en/ops-runbook.md` |
|
||||
| Startup internals | `docs/technical/zh/ops-planet-sh-startup.md`, `docs/technical/en/ops-planet-sh-startup.md` |
|
||||
| AI Provider | `docs/technical/zh/agents-aiprovider.md`, `docs/technical/en/agents-aiprovider.md` |
|
||||
| Frontend admin | `docs/technical/zh/frontend-admin-frontend-context.md`, `docs/technical/en/frontend-admin-frontend-context.md` |
|
||||
| Earth rendering | `docs/technical/zh/earth-frontend-context.md`, `docs/technical/zh/earth-render-layer-order.md`, `docs/technical/zh/earth-layer-style-reference.md` |
|
||||
| Plans and history | `docs/plans/README.md`, `docs/deprecated/README.md` |
|
||||
|
||||
## Release And Deploy Process
|
||||
|
||||
- Release workflow is documented in `.codex/skills/release/SKILL.md` and
|
||||
`.claude/commands/release.md`.
|
||||
- Version-bearing files include `VERSION`, `frontend/package.json`,
|
||||
`pyproject.toml`, `uv.lock`, `docs/CHANGELOG.md`, and
|
||||
`docs/version-history.md`.
|
||||
- Delivery automation lives in `.gitea/workflows/release.yaml` and
|
||||
`.gitea/workflows/deploy-staging.yaml`.
|
||||
- Helm chart entry point is `deploy/helm/planet/Chart.yaml`.
|
||||
|
||||
## Missing Or Unclear Areas
|
||||
|
||||
- The older lowercase `agents.md` entry has been merged into uppercase
|
||||
`AGENTS.md` so coding agents and harness tools use one source of truth.
|
||||
- `project_context.md` originally included older roadmap assumptions such as
|
||||
Celery, Kafka, TimescaleDB, MinIO, and UE5 as active stack elements. The
|
||||
harness pass updated it to separate active stack facts from future directions;
|
||||
current code and technical docs still remain authoritative when details drift.
|
||||
- No safe automatic hook system was already configured. This phase documents
|
||||
manual reminders instead of adding hooks.
|
||||
- `.github/workflows/` is absent by design; CI is under `.gitea/workflows/`.
|
||||
|
||||
## Conflicts And Preserved Rules
|
||||
|
||||
| Conflict Or Tension | Resolution |
|
||||
| --- | --- |
|
||||
| Prompt suggested `AGENTS.md`; repository already had `agents.md`. | Merged the lowercase guide into uppercase `AGENTS.md`; harness doctor now requires `AGENTS.md` and keeps `agents.md` absent to prevent split authority. |
|
||||
| Harness validation could duplicate CI. | Added wrapper scripts that call existing commands and mirror current CI gates where practical. |
|
||||
| Full Docker smoke builds are expensive locally. | Kept them opt-in with `PLANET_HARNESS_DOCKER_SMOKE=1`. |
|
||||
| Internal harness docs could clutter public Docs UI. | Kept `docs/HARNESS.md` and `docs/harness-audit.md` as repository docs, not product Docs entries. |
|
||||
| Existing frontend toolchain is Bun-only. | Harness scripts and docs use Bun only and flag npm/pnpm/yarn lockfiles as failures. |
|
||||
| Agents often miss user-installed Bun or uv in non-interactive shells. | Added `scripts/harness/lib.sh` to resolve tools from current `PATH` first and then the user's login interactive shell without hardcoding a dotfile. |
|
||||
| Always-loaded security rules had no standalone harness gate. | Added `scripts/harness/security-check.sh` to block tracked `.env` / key files and scan for high-confidence committed private keys or provider tokens; quick-check now runs it. |
|
||||
| Build success does not prove frontend page usability. | Added static frontend rules/doc checks and a Playwright route smoke for public pages, protected admin fallback, Docs loading and detail interactions, Earth iframe entry, login/register/verification/password-reset interactions, authenticated admin route/section rendering with mocked API data across desktop, mobile, and 125% / 150% zoom, plus manifest-derived desktop/mobile menu navigation and safe search/tab/dialog/Earth News interactions. |
|
||||
| Route fallback behavior can regress even when every named page renders. | Extended the frontend smoke to verify `/` redirects to Earth, unauthenticated unknown routes show the login page, and authenticated unknown routes navigate back to `/admin`. |
|
||||
| Frontend smoke route lists can drift from `AdminRoutes` and resource-page sections. | Updated the smoke to derive protected route checks and authenticated section deep-link checks from `AdminRoutes.tsx` and `PlainResourcePages.tsx`, including redirect-only `/alerts`. |
|
||||
| Docs smoke mocks can drift from the product Docs catalog. | Updated the frontend smoke to derive mocked Docs catalog/content from `frontend/src/pages/Docs/docs-content.ts` plus backend Gatekeeper access metadata, then open every Chinese Docs catalog slug. |
|
||||
| User manuals can miss a real console menu entry after route changes. | Added a docs consistency check that compares the manual console overview tables with `frontend/src/admin/routes/manifest.tsx`; fixed the missing `/docs` row in both user manuals. |
|
||||
| Rendered pages can still contain broken internal shortcuts. | Added literal internal route-link checks and an interaction smoke for the AI settings shortcut; this caught and fixed a stale `/admin/settings` link that should point to `/settings`. |
|
||||
| Global search entries can drift because their route targets live in data objects rather than JSX links. | Added a frontend rules check that validates every admin search `routePath` against the actual frontend route set. |
|
||||
| Responsive styling fixes can satisfy one viewport by breaking the no-viewport-font rule. | Added a frontend rules failure for `font-size` values that use viewport or container query width units, and replaced public auth shell `vw` font sizing with fixed desktop/mobile sizes. |
|
||||
| Typography polish can accidentally reintroduce squeezed non-zero letter spacing. | Normalized active frontend `letter-spacing` values to `0` and made the frontend rules check fail non-zero `letter-spacing` / `letterSpacing` declarations, with only inherit/default-zero forms allowed. |
|
||||
| Native buttons can accidentally submit forms or keep controls clickable while loading after a props-spread reorder. | Added a frontend rules failure for TSX `<button>` elements without explicit `type` and for buttons whose `disabled` state can be overridden by a later props spread; fixed the data distribution buttons and auth button disabled ordering. |
|
||||
| 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. |
|
||||
| Agent background context can reintroduce inactive stack assumptions. | Updated `project_context.md` and the root agent guide to label current stack facts versus future directions, then added exact stale-stack patterns for them to docs consistency checks. |
|
||||
| Docs `?section=` validation can drift if the harness owns its own route/section table. | Changed the docs consistency check to derive section keys from `AdminRoutes.tsx` and `PlainResourcePages.tsx` resource configs before validating documented deep links. |
|
||||
| Public Docs can drift between frontend catalog metadata and backend Gatekeeper authorization metadata. | Added a docs consistency check that compares filename, slug, group, order, and bilingual titles across both metadata sources; aligned existing order drift for toolbar overlay and location pipeline docs. |
|
||||
| Non-public technical docs can silently become Chinese-only or English-only. | Added a full `docs/technical/{zh,en}` filename-pair check so every technical Markdown file has a same-named counterpart before docs consistency passes. |
|
||||
| Credentialed collector docs can drift from backend support wiring. | Added docs consistency validation for every built-in collector marked `requires_credentials=true` and `credential_status=supported`: it must have a provider, default credential guide, supported connectivity provider, frontend credential UI/guidance, a regression test, and zh/en connectivity documentation. |
|
||||
| Backend collectors can leak debug output or credential-adjacent context through stdout. | Replaced SpaceTrack and PeeringDB collector `print()` calls with structured logger events, removed unreachable duplicate SpaceTrack fetch code, and added `scripts/harness/backend-rules-check.sh` to block future backend app `print()`, `breakpoint()`, or `pdb.set_trace()` calls. |
|
||||
|
||||
## Rules Coverage Evidence
|
||||
|
||||
This matrix records how the current harness checks the `rules.md` modules that
|
||||
matter for this frontend and documentation pass. "Automated" means the listed
|
||||
command fails when the rule regresses. "Smoke" means the rendered product route
|
||||
or interaction is opened with Playwright. "Manual" means the rule is still a
|
||||
judgment call and must be inspected during review.
|
||||
|
||||
Before using this matrix, start from `rules.md`'s **Agent Discovery Index** when
|
||||
the user describes work with Chinese/product terms instead of module names. The
|
||||
index is the routing layer; this table is the coverage/evidence layer.
|
||||
|
||||
| `rules.md` Area | Rule Surface | Harness Evidence | Remaining Review |
|
||||
| --- | --- | --- | --- |
|
||||
| `core` | Remove stale transitional paths, duplicated helpers, and naming drift after large changes. | `scripts/harness/docs-consistency-check.sh` blocks known stale stack terms, old `?tab=` links, public Docs metadata drift, and README/project context drift. `scripts/harness/frontend-rules-check.sh` blocks repeated detached AI/WebSearch connection-test buttons by requiring `ConnectionTestInput`. | Naming quality, function size, and whether a new abstraction is worth keeping remain manual review items. |
|
||||
| `core` | Keep one source of truth for route, Docs, and section state. | Frontend route, admin manifest, admin search targets, Docs catalog metadata, backend Gatekeeper metadata, manual route tables, and documented `?section=` links are all parsed from source and compared by `frontend-rules-check.sh`, `docs-consistency-check.sh`, and `frontend-smoke.mjs`. | Business-state ownership inside feature components still needs focused review when behavior changes. |
|
||||
| `security` | Do not commit secrets, tracked env files, private keys, or exposed tokens. | `scripts/harness/security-check.sh` fails on tracked `.env` / private-key files and high-confidence provider tokens. `backend-rules-check.sh` blocks backend stdout/debugger calls, and `frontend-rules-check.sh` fails frontend console output that includes token material. | Whether a newly added setting should be masked or stored server-side still requires feature-specific review. |
|
||||
| `workflow` | Frontend package management must stay Bun-only. | `scripts/harness/doctor.sh` and `frontend-rules-check.sh` fail forbidden frontend lockfiles and `npm` / `pnpm` / `yarn` script usage. `validate.sh` uses Bun for install, build, preview, and smoke. | New dependency legitimacy and maintenance quality are manual unless a dependency is actually added. |
|
||||
| `workflow` | Agents should find `bun` and `uv` even when non-interactive `PATH` is incomplete. | `scripts/harness/lib.sh` checks the current `PATH`, then asks `$SHELL`, `zsh`, and `bash` login interactive shells for the command path without hardcoding a dotfile. `doctor.sh`, `quick-check.sh`, and `validate.sh` all source it. | System package installation remains outside harness scope and should be reported instead of auto-fixed. |
|
||||
| `docs` | Keep public Docs whitelist-driven and synchronized with backend authorization metadata. | `docs-consistency-check.sh` compares frontend Docs metadata against backend Gatekeeper metadata, verifies files exist for both languages, checks public link titles, and blocks missing zh/en technical doc pairs. `frontend-smoke.mjs` opens every Chinese Docs catalog slug plus detail/search/language/theme interactions. | Quality of prose, examples, and whether a doc should be public are still editorial review items. |
|
||||
| `docs` | 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. |
|
||||
| `earth` | Earth render work needs real rendering checks. | Full smoke opens `/earth` and verifies the `3D Earth` iframe entry point. Earth News settings routes are included through the admin manifest/menu smoke, mocked `/earth/news-*` API responses, source test, add/cancel source draft, and manual news group creation checks. The broader Earth-specific layer/depth rules remain in `rules.md` and Earth docs. | The harness still does not claim full 3D layer visual verification; layer-depth and picking changes need targeted browser/canvas QA. |
|
||||
|
||||
## Harness Files Added
|
||||
|
||||
| File | Purpose |
|
||||
| --- | --- |
|
||||
| `AGENTS.md` | Single authoritative agent guide and coding-agent entry point. |
|
||||
| `docs/HARNESS.md` | Harness workflow, validation tiers, conflict policy, and manual reminders. |
|
||||
| `CODEMAP.md` | High-level codebase map and validation references. |
|
||||
| `scripts/harness/lib.sh` | Shared command lookup and run helpers. |
|
||||
| `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, 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. |
|
||||
| `scripts/harness/validate.sh` | Full local validation wrapper with optional delivery smoke. |
|
||||
@@ -22,6 +22,7 @@
|
||||
|
||||
当前重点入口:
|
||||
|
||||
- [控制台 i18n 接入计划](/home/ray/dev/linkong/planet/docs/plans/admin-console-i18n-plan.md)
|
||||
- [Earth Mobile Drawer UI Plan](/home/ray/dev/linkong/planet/docs/plans/earth-mobile-drawer-ui-plan.md)
|
||||
- [Earth Compute Center BGP Style Plan](/home/ray/dev/linkong/planet/docs/plans/earth-compute-center-bgp-style-plan.md)
|
||||
- [Earth Renderer Architecture Separation Plan](/home/ray/dev/linkong/planet/docs/plans/earth-renderer-architecture-separation-plan.md)
|
||||
@@ -33,6 +34,7 @@
|
||||
- [Earth News Cruise Summary Plan](/home/ray/dev/linkong/planet/docs/plans/earth-news-cruise-summary-plan.md)
|
||||
- [Earth 动作捕捉手势控制计划](/home/ray/dev/linkong/planet/docs/plans/earth-motion-capture-gesture-control-plan.md)
|
||||
- [Earth 动捕交互语义 V2 计划](/home/ray/dev/linkong/planet/docs/plans/earth-motion-gesture-interaction-v2-plan.md)
|
||||
- [Motion Agent v2 控制协议与 3D 标定路线](/home/ray/dev/linkong/planet/docs/plans/motion-agent-v2-control-protocol-plan.md)
|
||||
- [Earth Presentation 解耦架构计划](/home/ray/dev/linkong/planet/docs/plans/earth-presentation-decoupled-architecture-plan.md)
|
||||
- [Earth Vessel Rendering Performance Plan](/home/ray/dev/linkong/planet/docs/plans/earth-vessel-rendering-performance-plan.md)
|
||||
- [AIS 多源采集、冲突记录与聚合接口计划](/home/ray/dev/linkong/planet/docs/plans/earth-vessel-ais-aggregation-plan.md)
|
||||
|
||||
62
docs/plans/admin-console-i18n-plan.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# 控制台 i18n 接入计划
|
||||
|
||||
**状态**:基础设施已落地,大型业务页迁移继续进行
|
||||
**创建日期**:2026-06-29
|
||||
**核心目标**:把 Docs 已有的中英文文档能力提升为前端统一 i18n 体系,让未登录认证页、Docs UI、控制台外壳、导航、搜索和核心工作台文案共用同一个语言状态。
|
||||
|
||||
## 背景
|
||||
|
||||
Docs 站点已经有 `zh` / `en` 文档目录、Gatekeeper 权限和 `/api/v1/docs/{lang}/{slug}` 内容接口,但语言状态只保存在 `docs-lang`,不影响控制台。控制台页面、搜索索引、toast、dialog、表格和认证页仍以中文硬编码为主,导致用户切到英文文档后,控制台仍是中文。
|
||||
|
||||
本计划把前端语言偏好收敛到 `planet-locale`,默认 `zh-CN`,支持 `en-US`。Docs 继续使用后端现有 `zh` / `en` 文档接口,通过前端映射与全局 locale 对齐。
|
||||
|
||||
## 设计决策
|
||||
|
||||
- 使用 `i18next` 和 `react-i18next` 作为统一 i18n 层,避免长期维护自研插值、hook 和资源加载逻辑。
|
||||
- 前端统一语言枚举为 `zh-CN` / `en-US`;Docs 请求继续转换为 `zh` / `en`,新闻接口继续使用已有 `zh-CN` / `en-US` 口径。
|
||||
- 语言偏好首版只保存在浏览器 `localStorage`,不新增后端用户设置字段。
|
||||
- `docs-lang` 保留为兼容读取和写入项,让已访问过 Docs 的浏览器能平滑迁移。
|
||||
- 静态路由、导航、搜索目标和通用组件使用显式翻译 key;大型业务页在迁移期间通过 legacy UI 翻译桥补足常见硬编码文案。
|
||||
|
||||
## 分期
|
||||
|
||||
### P1:统一语言基础设施
|
||||
|
||||
- 在 `frontend/src/i18n/` 下维护 locale 类型、资源、初始化和 `useLocale()`。
|
||||
- 在 `frontend/src/main.tsx` 里初始化 i18n,并同步 `document.documentElement.lang`。
|
||||
- 在认证页和控制台侧边栏偏好面板提供语言切换入口。
|
||||
|
||||
### P2:高复用界面迁移
|
||||
|
||||
- 迁移 Docs UI、AdminLayout、route manifest、admin search、Auth、DataTable、Dialog、Toast 和 MarkdownRenderer。
|
||||
- 搜索索引按当前语言展示,同时保留中英文关键词以免降低可发现性。
|
||||
- 用户管理页作为独立业务页示范,迁移表头、按钮、toast、校验提示、角色和 Gatekeeper 标签。
|
||||
|
||||
当前已完成统一 `planet-locale`、Docs 兼容映射、认证页和控制台外壳语言入口、共享组件 key 化,以及 legacy UI 翻译桥。后续工作集中在把大型业务页从过渡桥迁移到显式 key。
|
||||
|
||||
### P3:大型业务页收敛
|
||||
|
||||
- 分批把 Dashboard、DataList、Logs 和 PlainResourcePages 的配置块改为显式翻译 key。
|
||||
- 过渡期保留 legacy UI 翻译桥,只处理 admin/auth 容器里的精确静态文本和属性。
|
||||
- 业务数据、日志原文、API 字段名、provider id、命令和 Markdown 正文不走 legacy 翻译桥。
|
||||
|
||||
### P4:移除过渡桥
|
||||
|
||||
- 当 `rg -n "[\\p{Han}]" frontend/src/admin frontend/src/pages frontend/src/components` 只剩业务数据示例、中文文档标题或必须保留的中文品牌词时,删除 legacy UI 翻译桥。
|
||||
- 增加 key 完整性检查,确保 `zh-CN` 和 `en-US` 资源结构一致。
|
||||
|
||||
## 验证
|
||||
|
||||
- `cd frontend && bun run build`
|
||||
- `scripts/harness/frontend-rules-check.sh`
|
||||
- `scripts/harness/docs-consistency-check.sh`
|
||||
- `scripts/harness/quick-check.sh`
|
||||
- 前端 smoke 需要覆盖登录页、Docs、Admin 侧边栏语言切换、侧边栏和搜索结果在中英文下渲染。
|
||||
|
||||
## 相关文件
|
||||
|
||||
- `frontend/src/i18n/`:统一 locale、资源和过渡桥。
|
||||
- `frontend/src/pages/Docs/Docs.tsx`:Docs 语言状态改为读取全局 locale。
|
||||
- `frontend/src/admin/components/layout/AdminLayout.tsx`:控制台侧边栏语言切换、导航和搜索文案。
|
||||
- `frontend/src/admin/search/indexers.ts`:Admin 搜索目标本地化。
|
||||
- `docs/technical/{zh,en}/frontend-admin-frontend-context.md`:当前实现上下文。
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
**状态**:待实施
|
||||
**创建日期**:2026-05-12
|
||||
**核心目标**:把 `docs/technical/{zh,en}/manual.md` 拆成"纯客户视角"的使用手册,把 `planet.sh`、日志、LAN、故障排查这类运维内容迁到独立 `ops-runbook.md`,并把分层规则写进 `documentation-coverage-rules.md` 和 `.claude/commands/docs.md`,让以后写文档时自动按受众归档。
|
||||
**校正日期**:2026-06-26,控制台深链已从旧 tab 查询口径更新为当前 `?section=` 口径。
|
||||
**核心目标**:把 `docs/technical/{zh,en}/manual.md` 拆成"纯客户视角"的使用手册,把 `planet.sh`、日志、LAN、故障排查这类运维内容迁到独立 `ops-runbook.md`,并把分层规则写进 `documentation-coverage-rules.md` 和 `.codex/skills/docs/SKILL.md`,让以后写文档时自动按受众归档。
|
||||
|
||||
## 背景
|
||||
|
||||
@@ -31,12 +32,12 @@
|
||||
3. **登录与找回密码** — 登录页、忘记密码流程
|
||||
4. **账户设置** — 修改密码、修改邮箱(需重新验证)、查看权限组、登出
|
||||
5. **Console 总览** — 左侧菜单结构、各路由用途
|
||||
6. **配置数据采集器** — `/collection-management?tab=collector_credentials`:选择 collector、连接测试、保存凭证;BarentsWatch / AISStream 两个典型例子
|
||||
7. **配置 AI 凭证** — `/ai?tab=providers`:默认 provider、模型、Base URL、API Key、本地代理;工具 tab(WebSearch、OCR)
|
||||
6. **配置数据采集器** — `/collection-management?section=collector_credentials`:选择 collector、连接测试、保存凭证;BarentsWatch / AISStream 两个典型例子
|
||||
7. **配置 AI 凭证** — `/ai?section=integrations`:默认 provider、模型、Base URL、API Key、本地代理;工具 section(WebSearch、OCR)位于 `/ai?section=tools`
|
||||
8. **系统设置** — `/settings` 其他子 tab(系统设置、电视直播源、SMTP 邮件)
|
||||
9. **用户管理(管理员)** — `/users`:创建、删除、改角色、Gatekeeper 权限组
|
||||
10. **数据探索** — `/datasources`、`/data`、`/bgp`、`/alerts/*`
|
||||
11. **AI 测试台** — `/ai?tab=playground`
|
||||
11. **AI 测试台** — `/ai?section=playground`
|
||||
12. **Earth 公开页面** — 现 manual.md 的 Earth 章节原样保留(图层、图例、搜索、位置候选、设置、视角、动捕、巡航、移动端)
|
||||
13. **Docs 文档站** — 当前 Docs 章节保留(权限组说明)
|
||||
|
||||
@@ -48,7 +49,7 @@
|
||||
|
||||
- 打开管理员给你的 URL
|
||||
- 注册账号 + 邮箱验证
|
||||
- 登录后第一次做什么(建议先到 `/collection-management?tab=collector_credentials` 配一个 collector,再到 `/ai` 配模型)
|
||||
- 登录后第一次做什么(建议先到 `/collection-management?section=collector_credentials` 配一个 collector,再到 `/ai?section=integrations` 配模型)
|
||||
- 看 Earth
|
||||
|
||||
部署/开发的 quickstart 内容并入 `ops-runbook.md` 的"首次部署"小节,**不**再单独出 `ops-quickstart.md`,避免新增维护点。
|
||||
@@ -79,7 +80,7 @@
|
||||
> - 新增客户可见 UI 流 → 同时更新 `manual.md` zh+en 与 `docs-content.ts`
|
||||
> - 新增 ops 命令或脚本 → 只更新 `ops-runbook.md` zh+en
|
||||
|
||||
## .claude/commands/docs.md 增量
|
||||
## `.codex/skills/docs/SKILL.md` 增量
|
||||
|
||||
在 "Step 2 — Decide Scope" 后插一段:
|
||||
|
||||
@@ -99,7 +100,7 @@
|
||||
- `docs/technical/zh/quickstart.md` & `en/quickstart.md` — 重写
|
||||
- `docs/technical/zh/ops-runbook.md` & `en/ops-runbook.md` *(新)*
|
||||
- `docs/documentation-coverage-rules.md` — 加受众分层段
|
||||
- `.claude/commands/docs.md` — 加 Document Audience Routing 段
|
||||
- `.codex/skills/docs/SKILL.md` — 加 Document Audience Routing 段
|
||||
- `frontend/src/pages/Docs/docs-content.ts` — 注册 `ops-runbook` 到 `DOCS_METADATA`(`docs_admin` 组)
|
||||
|
||||
## 依赖
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Earth Motion Capture Gesture Control Plan
|
||||
|
||||
> Update: Motion Agent process/device control, UE/Web shared command protocol, dual-camera redundant fusion, and the next 3D calibration route are now tracked in [Motion Agent v2 Control Protocol And 3D Calibration Roadmap](/home/ray/dev/linkong/planet/docs/plans/motion-agent-v2-control-protocol-plan.md). This document remains useful for the original provider split and gesture-control intent.
|
||||
|
||||
## Goal
|
||||
|
||||
为 Planet Earth 大屏和未来 3D 展示增加一套解耦的动作捕捉手势控制能力。实时输入分成两条路线:网页端可直接通过浏览器 `getUserMedia` 在本机识别;高级设备可继续使用本机 Motion Capture Edge Agent。两条路线都只输出轻量语义事件,客户端负责把“手势事件”映射到“具体交互函数”。
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
**状态**:已实现主体交互,并按实测调整。当前浏览器识别保留右手导航、头部切目标、左手上下切动捕图层、双手张开/收拢缩放;双手上举确认暂时关闭。Motion 目标展示已改为 `CruiseSequencer` + `PresentationController` 的 persistent 展示。
|
||||
|
||||
> Update: Agent-side bidirectional commands, UE/Web shared device control, dual-camera redundant fusion, and future calibrated 3D mode are tracked in [Motion Agent v2 Control Protocol And 3D Calibration Roadmap](/home/ray/dev/linkong/planet/docs/plans/motion-agent-v2-control-protocol-plan.md).
|
||||
|
||||
## Summary
|
||||
|
||||
把动捕从“几个单点手势触发函数”升级为一套更像大屏遥控器的交互层:右手负责地球导航,头部负责候选切换,左手上下切换动捕候选图层,双手负责缩放,调试面板支持“只显示骨骼”和暂停匹配。进入动捕模式后,Earth 自动软选中屏幕中心附近的正面可交互目标;确认动作预留为把目标升级为锁定,并用巡航/引导线式详情打开,不再模拟鼠标点击。
|
||||
|
||||
175
docs/plans/motion-agent-v2-control-protocol-plan.md
Normal file
@@ -0,0 +1,175 @@
|
||||
# Motion Agent v2 Control Protocol And 3D Calibration Roadmap
|
||||
|
||||
## Summary
|
||||
|
||||
Motion Agent v2 turns the local motion service into a shared WebSocket control plane for the Web Earth page and UE clients. The agent process is still started externally through `planet.sh`, a desktop service, or UE process management. Once the process is running, clients can open cameras, close cameras, switch input mode, arm or pause recognition, and inspect status through the same bidirectional WebSocket protocol.
|
||||
|
||||
This phase implements robust single-camera and dual-camera redundant fusion. True calibrated 3D skeleton reconstruction is deliberately reserved for the v3 calibration phase.
|
||||
|
||||
## v2 Protocol
|
||||
|
||||
The default endpoint remains:
|
||||
|
||||
```text
|
||||
ws://127.0.0.1:8765/ws/gestures
|
||||
```
|
||||
|
||||
The agent emits:
|
||||
|
||||
- `gesture`
|
||||
- `skeleton`
|
||||
- `status`
|
||||
- `heartbeat`
|
||||
- `command_result`
|
||||
|
||||
Clients send:
|
||||
|
||||
- `open_devices`
|
||||
- `close_devices`
|
||||
- `rescan_devices`
|
||||
- `set_armed`
|
||||
- `set_paused`
|
||||
- `set_input_mode`
|
||||
- `set_camera_config`
|
||||
- `set_fusion_config`
|
||||
- `set_debug_options`
|
||||
- `set_enabled_gestures`
|
||||
- `get_status`
|
||||
- `ping`
|
||||
|
||||
All v2 messages include additive compatibility fields such as `protocol_version`, `request_id`, `camera_id`, `input_mode`, and optional `fusion`. Older push-only clients can continue to consume `gesture`, `skeleton`, `status`, and `heartbeat`.
|
||||
|
||||
Example command:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "command",
|
||||
"command": "open_devices",
|
||||
"request_id": "req-001",
|
||||
"payload": {
|
||||
"input_mode": "dual_redundant",
|
||||
"camera_indexes": [0, 1]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Example gesture whitelist command:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "command",
|
||||
"command": "set_enabled_gestures",
|
||||
"request_id": "earth-motion-enabled-gestures",
|
||||
"payload": {
|
||||
"gestures": ["rotate_left", "rotate_right", "zoom_in", "zoom_out", "confirm"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Example result:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "command_result",
|
||||
"protocol_version": "motion.v2",
|
||||
"request_id": "req-001",
|
||||
"command": "open_devices",
|
||||
"ok": true,
|
||||
"status": {
|
||||
"armed": false,
|
||||
"paused": false,
|
||||
"input_mode": "dual_redundant",
|
||||
"devices_open": true,
|
||||
"active_camera_ids": ["usb:0", "usb:1"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Device And Control State
|
||||
|
||||
Process startup is not a WebSocket feature: the server must exist before a WebSocket client can connect. UE should start the agent as an external process or depend on a system service, then connect to the WebSocket endpoint.
|
||||
|
||||
Device wake and control wake are WebSocket features:
|
||||
|
||||
- `open_devices` opens USB cameras or URL cameras.
|
||||
- `close_devices` releases them.
|
||||
- `set_armed` enables gesture execution.
|
||||
- `set_paused` pauses recognition without closing the connection.
|
||||
|
||||
When `armed=false`, the agent may still emit skeleton and status events, but it does not emit actionable gesture events.
|
||||
|
||||
The Earth settings dialog owns a user-facing gesture whitelist. Unchecked gestures are ignored in the Earth client and are also sent to the Motion Agent through `set_enabled_gestures`, so the server does not broadcast disabled actions to UE/Web consumers. The default whitelist enables the full v2 gesture set; disabling gestures is a local display/control preference and does not change the installed recognition model.
|
||||
|
||||
## Input Modes
|
||||
|
||||
- `single`: one camera.
|
||||
- `dual_redundant`: two or more cameras observe the same gesture. Matching observations in a short window are fused into a higher-confidence event.
|
||||
- `single_fallback`: the primary camera is preferred and a secondary input is used as fallback.
|
||||
- `calibrated_3d`: reserved for v3 and should not be enabled unless a calibration profile exists.
|
||||
|
||||
The v2 dual-camera mode is redundant fusion, not 3D reconstruction. It is meant to improve reliability under occlusion and camera noise without requiring calibration.
|
||||
|
||||
## v3 3D Calibration Roadmap
|
||||
|
||||
The next phase is `Motion Agent v3 3D Calibration`. It upgrades from redundant fusion to calibrated multi-camera skeleton fusion.
|
||||
|
||||
Planned capabilities:
|
||||
|
||||
- Camera intrinsics: focal length, distortion, resolution.
|
||||
- Camera extrinsics: relative position, rotation, and baseline distance.
|
||||
- Calibration workflow: checkerboard, AprilTag, or ArUco board.
|
||||
- Local calibration profile JSON with query, load, reset, and validation commands.
|
||||
- `skeleton_3d` event with world-space joints, confidence, and source cameras.
|
||||
- UE coordinate mapping from Motion Agent coordinates to UE world or widget coordinates.
|
||||
|
||||
Reserved v3 input configuration:
|
||||
|
||||
```json
|
||||
{
|
||||
"input_mode": "calibrated_3d",
|
||||
"calibration_profile": "desk-dual-camera-v1"
|
||||
}
|
||||
```
|
||||
|
||||
Reserved v3 event:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "skeleton_3d",
|
||||
"protocol_version": "motion.v3",
|
||||
"profile": "desk-dual-camera-v1",
|
||||
"joints": [
|
||||
{
|
||||
"name": "right_wrist",
|
||||
"x": 0.42,
|
||||
"y": 1.13,
|
||||
"z": 0.76,
|
||||
"confidence": 0.91
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Test Plan
|
||||
|
||||
- Command/result roundtrip for device open, close, rescan, armed, paused, and status.
|
||||
- Gesture protocol accepts the full Earth v2 gesture set.
|
||||
- Earth settings can disable individual gestures; disabled gestures are ignored locally and filtered server-side through `set_enabled_gestures`.
|
||||
- Motion Agent `status` reports the current enabled gesture list for Web/UE diagnostics.
|
||||
- Single and dual redundant fusion emit compatible gesture payloads.
|
||||
- Conflicting dual-camera observations below the confidence delta are ignored.
|
||||
- Web Earth `motion-agent-provider` can send commands over the same socket it uses for events.
|
||||
- UE mock clients can operate the service without browser-only assumptions.
|
||||
- Dry-run mode can test commands, status, skeleton, and fusion behavior without camera dependencies.
|
||||
|
||||
## Current Limitations
|
||||
|
||||
- Production recognition uses a real MediaPipe pose pipeline and heuristic gesture recognizer. It still needs environment-specific threshold tuning, camera framing validation, and long-running reliability checks before it can be treated as calibration-free.
|
||||
- Dual-camera v2 does not triangulate 3D joint positions.
|
||||
- `calibrated_3d` is documented as a reserved mode and must not be treated as implemented until v3 lands.
|
||||
|
||||
## Implementation Status
|
||||
|
||||
- Implemented: bidirectional command/result protocol, device lifecycle controls, dry-run mode, subprocess recognition worker, MediaPipe pose recognition, gesture whitelist, single-camera mode, dual-redundant/fallback scaffolding, Web client integration, and default `planet.sh` lifecycle integration.
|
||||
- Remaining v2 hardening: tune recognition thresholds across camera placements, exercise UE command/control integration, and run longer soak tests for device reconnect and dual-camera conflicts.
|
||||
- Planned v3: calibrated multi-camera 3D skeleton fusion and Motion Agent-to-UE coordinate calibration.
|
||||
@@ -8,7 +8,7 @@ The console now separates the "data source catalog" from "collector configuratio
|
||||
- Lists all data sources, including built-in and custom sources.
|
||||
- Clicking a name only opens an information drawer.
|
||||
- Focuses on status, manual collection, and running collection tasks.
|
||||
- `/collection-management?tab=collector_credentials`
|
||||
- `/collection-management?section=collector_credentials`
|
||||
- Displays as "Collectors".
|
||||
- Owns endpoint, headers, base parameters, and credentials.
|
||||
- Every collector exposes a connection button for health checks.
|
||||
|
||||
@@ -75,7 +75,13 @@ 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.
|
||||
|
||||
Shortcut configuration is a device-local preference owned by `controls.js`: read, capture, enable/disable, and reset all stay in the Earth frontend. It should not be written to backend user settings and should not affect other browsers. New shortcuts must provide a default key, display label, disabled/enabled state, and reset path instead of being hard-coded only in a keydown handler.
|
||||
|
||||
@@ -102,9 +108,9 @@ Responsibilities:
|
||||
- Map `rotate_left`, `rotate_right`, `rotate_up`, `rotate_down`, `zoom_in`, `zoom_out`, `focus_prev`, `focus_next`, `layer_prev`, `layer_next`, and `confirm` to the action entry points exposed by `main.js`.
|
||||
- Parse `skeleton` debug events and dispatch `earth:motion-debug-frame`.
|
||||
|
||||
Gesture recognition may run locally in the browser or inside the local Agent, but neither path sends realtime camera frames to the SaaS cloud. `main.js` exposes rotation, zoom, target focus, layer switching, and confirm entry points, plus a `window.__planetEarth.motion` debug entry. The adapter starts only when `?motion=1` is present, browser local storage contains `planet-earth-motion-control-enabled=true`, or Earth settings enable Motion Debug Mode.
|
||||
Gesture recognition may run locally in the browser or inside the local Agent, but neither path sends realtime camera frames to the SaaS cloud. `main.js` exposes rotation, zoom, target focus, layer switching, and confirm entry points, plus a `window.__planetEarth.motion` debug entry. The adapter starts only when `?motion=1` is present, browser local storage contains `planet-earth-motion-control-enabled=true`, or Earth settings enable Motion Debug Mode. `shared.motionEnabledGestures` stores the user-approved gesture whitelist; the browser filters locally, and Motion Agent mode also synchronizes it through `set_enabled_gestures`.
|
||||
|
||||
[motion-debug-panel.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/motion-debug-panel.js) owns the debug panel. It listens for `earth:motion-debug-frame` and draws normalized skeleton joints and bones on a canvas. The Browser Camera provider also emits `earth:motion-debug-video-source` with the local `<video>` element so the panel can show a local preview behind the skeleton; `shared.motionDebugSkeletonOnly` switches the panel back to skeleton-only rendering. `Stop Matching Gestures` dispatches `earth:motion-recognition-pause`, which suppresses gesture execution while video and skeleton drawing continue. Unmatched skeletons are red; matched gestures turn green and display the gesture name. Settings are persisted under `shared.motionDebugEnabled`, `shared.motionProvider`, and `shared.motionDebugSkeletonOnly` in `planet.earth.settings.v2`, and both the switch and provider selector reserve `data-gatekeeper-permission="earth.motion_debug"`.
|
||||
[motion-debug-panel.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/motion-debug-panel.js) owns the debug panel. It listens for `earth:motion-debug-frame` and draws normalized skeleton joints and bones on a canvas. The Browser Camera provider also emits `earth:motion-debug-video-source` with the local `<video>` element so the panel can show a local preview behind the skeleton; `shared.motionDebugSkeletonOnly` switches the panel back to skeleton-only rendering. `Stop Matching Gestures` dispatches `earth:motion-recognition-pause`, which suppresses gesture execution while video and skeleton drawing continue. Unmatched skeletons are red; matched gestures turn green and display the gesture name. Settings are persisted under `shared.motionDebugEnabled`, `shared.motionProvider`, `shared.motionDebugSkeletonOnly`, and `shared.motionEnabledGestures` in `planet.earth.settings.v2`; the switch, provider selector, and gesture whitelist reserve `data-gatekeeper-permission="earth.motion_debug"`.
|
||||
|
||||
The Browser Camera provider's gesture pipeline lives in `recognizeGesture()` inside [motion-browser-provider.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/motion-browser-provider.js). Detectors are evaluated in this order, first match wins:
|
||||
|
||||
@@ -240,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.
|
||||
@@ -350,7 +358,7 @@ For future Earth changes:
|
||||
|
||||
The Earth frontend and the console frontend are not the same UI system:
|
||||
|
||||
- Console frontend: React + Ant Design workbench
|
||||
- Console frontend: React + Tactile UI / Radix primitives / lucide workbench
|
||||
- Earth frontend: native HUD + Three.js display under `public/earth`
|
||||
|
||||
Therefore:
|
||||
|
||||
@@ -86,6 +86,55 @@ Importance levels are fixed: `low` 0–34, `medium` 35–59, `high` 60–79, and
|
||||
|
||||
`GET /api/v1/earth/news-sources` returns the default or saved configuration. `PUT /api/v1/earth/news-sources` saves it, increments `cache_version`, and clears the process region cache. `POST /api/v1/earth/news-sources/reset` restores defaults. `POST /api/v1/earth/news-sources/test` tests one RSS/Atom/Aggregated source without writing news items.
|
||||
|
||||
## Manual News Content
|
||||
|
||||
Manual news is a content-management path, not an RSS source configuration. The Admin entry is `Earth Content -> News Content`; the left rail groups items by RSS source and manual news group. After opening a manual group, administrators can add one item or upload a JSON array. Manual items are written to `earth_news_items` with `feed_type/source_type = manual`; the display label is “手动添加” / “Manual”. They do not participate in RSS connectivity tests and are not routed through RSS fetching.
|
||||
|
||||
Manual news uses a publish-first, enrich-later flow:
|
||||
|
||||
1. Saving immediately writes the item to `earth_news_items`.
|
||||
2. If no manual coordinates are provided, the selected region anchor is used and `verified=false`.
|
||||
3. If manual coordinates are provided, the item uses `location_source=manual_location` and `verified=true`; later AI enrichment does not overwrite that location.
|
||||
4. Create and reprocess actions enqueue the item for cleanup, translation, classification, importance, Breaking, and target-location inference.
|
||||
5. When enrichment finishes, the same row is updated and Earth receives a news reload / patch so the frontend replaces the item without a manual refresh.
|
||||
|
||||
The Admin API is under `/api/v1/earth/news-items`:
|
||||
|
||||
- `GET /earth/news-groups`: return RSS virtual source groups and manual news groups.
|
||||
- `POST /earth/news-groups`: create a manual news group.
|
||||
- `PUT /earth/news-groups/{group_id}`: rename a manual news group and synchronize metadata for items in that group.
|
||||
- `GET /earth/news-items`: paginated RSS and manual news list, with filters for source type, region, category, and status.
|
||||
- `POST /earth/news-items`: create one manual news item.
|
||||
- `POST /earth/news-items/import`: upload a JSON array; `group_id` selects the current manual news group.
|
||||
- `PUT /earth/news-items/{id}`: edit a manual news item; RSS items are read-only.
|
||||
- `DELETE /earth/news-items/{id}`: delete a manual news item and trigger an Earth news reload.
|
||||
- `POST /earth/news-items/{id}/reprocess`: requeue cleanup, translation, and geolocation.
|
||||
|
||||
The first JSON import format supports arrays only:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"title": "Required title",
|
||||
"summary": "Optional summary",
|
||||
"content": "Optional body",
|
||||
"url": "https://example.com/story",
|
||||
"source": "Manual",
|
||||
"region": "global",
|
||||
"published_at": "2026-05-15T03:00:00Z",
|
||||
"category": "business",
|
||||
"tags": ["manual", "analysis"],
|
||||
"location": {
|
||||
"label": "Beijing, China",
|
||||
"latitude": 39.9057,
|
||||
"longitude": 116.3913
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
Imports deduplicate through a stable `manual:{hash}` ID derived from title, published time, URL, and source. Importing the same manual item again updates the existing row instead of creating a duplicate EarthFeed entry.
|
||||
|
||||
## Feed Query and Category Filtering
|
||||
|
||||
The Web Earth client and UE client both consume `GET /api/v1/news/earth-feed`. The endpoint supports server-side filtering, so clients do not need to fetch the full list and apply the primary category filter locally.
|
||||
@@ -95,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
|
||||
@@ -107,8 +158,23 @@ Unknown category or locale values return `422` with the allowed values. The resp
|
||||
|
||||
The Web Earth category chips only store the current browser preference; changing them triggers a new API request. UE should pass its selected categories through the `categories` query parameter and does not need to perform the primary filtering itself.
|
||||
|
||||
When `sources` is omitted, the service layer prefers stories that already have displayable title and summary content for the requested `locale`, supplements candidates from enabled sources, and rotates sources so one source's newest pending items cannot occupy all 12 default slots. An explicit `sources` filter remains precise and does not supplement other sources. The database query layer owns region, category, source, and ordering constraints only; it does not own locale presentation policy.
|
||||
|
||||
Source testing only proves that a specific RSS/Atom/XML feed can be parsed. It does not mean those items have already been written to the news table or are visible in the current region/category view. Saving or resetting news sources increments the configuration version and clears cache; if an enabled feed has no recent stored items, the next `earth-feed` request supplements from RSS so newly enabled sources such as 36Kr and Ebrun are not masked by fresh Google News rows.
|
||||
|
||||
## Regional Balancing and Async Enrichment
|
||||
|
||||
`earth_news_items` is the current-state table for EarthFeed. When no explicit `sources` filter is present, the endpoint sorts by Breaking state, region, and publish time, then applies a regional round-robin so Asia Pacific or any other high-volume source cannot fill the entire global view and cruise queue. The default region order is Americas, Europe, Middle East / Africa, Asia Pacific, then Global; unknown regions participate after the known regions. Regional views still follow the active-region-plus-global rule and do not mix unrelated regions into the regional panel.
|
||||
|
||||
Both `items` and `cruise_items` are enqueued for target-location and localization enrichment, but the frontend must not wait for AI before rendering. If the requested display locale is not ready yet, Web Earth falls back to the original `title / summary` so a card does not show a "translation pending" placeholder when readable source content already exists. After translation, classification, Breaking, or target-location enrichment completes, the same `earth_news_items` row is updated and Earth receives a news reload / patch.
|
||||
|
||||
Target-location enrichment uses two Redis Streams queues:
|
||||
|
||||
- `earth_news:target_location:priority`: priority jobs for currently visible `items` and `cruise_items`, with a short dedupe TTL.
|
||||
- `earth_news:target_location:jobs`: normal background enrichment jobs, with a longer dedupe TTL.
|
||||
|
||||
The worker always drains the priority queue before the regular queue; stale pending messages are reclaimed after the idle threshold, each AI job has a hard timeout, and failures go through retry or dead-letter handling. This prevents a large historical regular backlog from starving the Europe, Americas, or other regional news currently visible to the user.
|
||||
|
||||
## Breaking News Insertion
|
||||
|
||||
The news system keeps three separate decisions:
|
||||
@@ -163,9 +229,11 @@ Reference links show that they only record a homepage, report page, or future co
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Admin["Admin: Earth Content / News Sources"] --> Source["Source config"]
|
||||
ManualAdmin["Admin: Earth Content / News Content"] --> ManualAPI["/api/v1/earth/news-items"]
|
||||
Source --> Feed["Feed children"]
|
||||
Feed --> ConfigAPI["/api/v1/earth/news-sources"]
|
||||
ConfigAPI --> Config["SystemSetting: earth_news_sources"]
|
||||
ManualAPI --> Store
|
||||
|
||||
Earth["Earth News Panel"] --> NewsAPI["/api/v1/news/earth-feed"]
|
||||
NewsAPI --> Resolver["Source Resolver"]
|
||||
|
||||
@@ -98,6 +98,8 @@ Admin status labels should use [StatusText](/home/ray/dev/linkong/planet/fronten
|
||||
|
||||
`StatusText` is an indicator-light pill: the pill background and border stay on the component base color, while only the dot and text use the status color. `Badge` does not carry the indicator-light meaning, so it may use a light same-tone background and border for stronger hierarchy.
|
||||
|
||||
Status indicators must show the full state word. In lists, hierarchy groups, and detail headers, the title/description area should shrink or wrap while the status pill keeps content-sized width and does not get compressed by flex/grid layout; do not truncate state words such as `Configured` or `Available` just to save horizontal space.
|
||||
|
||||
| Tone | Color variable | Meaning | Examples |
|
||||
| --- | --- | --- | --- |
|
||||
| `success` | `--an-success` | available, successful, connected, enabled | log source `Available`, collection `Success` |
|
||||
@@ -155,7 +157,6 @@ Purpose:
|
||||
Current usage:
|
||||
|
||||
- Admin data sources, collected data, collection management, logs, alerts, and BGP pages
|
||||
- Old AntD legacy pages continue using shared scrolling behavior through compatibility wrappers
|
||||
|
||||
### 3. `TableScrollRegion`
|
||||
|
||||
@@ -217,7 +218,31 @@ Current constraints:
|
||||
- Prefer CSS variable overrides for colors instead of hard-coding theme colors in feature components
|
||||
- Best for a small set of mutually exclusive choices; do not use it as a long list, navigation menu, or select replacement
|
||||
|
||||
### 6. `MarkdownRenderer`
|
||||
### 6. Console i18n
|
||||
|
||||
Files:
|
||||
|
||||
- [i18n/index.ts](/home/ray/dev/linkong/planet/frontend/src/i18n/index.ts)
|
||||
- [i18n/locale.ts](/home/ray/dev/linkong/planet/frontend/src/i18n/locale.ts)
|
||||
- [i18n/resources.ts](/home/ray/dev/linkong/planet/frontend/src/i18n/resources.ts)
|
||||
- [LegacyI18nBridge.tsx](/home/ray/dev/linkong/planet/frontend/src/i18n/LegacyI18nBridge.tsx)
|
||||
|
||||
Purpose:
|
||||
|
||||
- Share one `zh-CN` / `en-US` language state across the console, auth pages, and Docs UI
|
||||
- Store the language preference in `planet-locale` while keeping compatibility with the old `docs-lang`
|
||||
- Keep Docs API requests mapped to the backend's existing `zh` / `en` document interface
|
||||
- Provide language switchers in the console sidebar preferences panel and auth panel
|
||||
|
||||
Current constraints:
|
||||
|
||||
- New console copy should be added to `resources.ts`, then consumed with `useTranslation()` or `useLocale()`
|
||||
- Routes, menus, search indexes, and shared components must use explicit translation keys
|
||||
- `LegacyI18nBridge` is transitional and only handles exact static text and attributes inside admin/auth containers
|
||||
- Business data, raw logs, API field names, provider ids, commands, and Markdown body content are not translated by the legacy bridge
|
||||
- Future large-page migrations should shrink the legacy dictionary rather than grow it
|
||||
|
||||
### 7. `MarkdownRenderer`
|
||||
|
||||
File:
|
||||
|
||||
@@ -275,17 +300,16 @@ File:
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- `/ai` now owns LLM Provider, AI Tool configuration, and the testbench instead of nesting them under `/settings`
|
||||
- The `模型供应商` tab manages default provider, model, base URL, provider key, local `aiprovider` proxy, and connection test; provider and model fields use editable comboboxes so users can manually enter new providers/models if the models.dev catalog stops updating
|
||||
- The `工具` tab first selects a tool from a dropdown menu, then renders that tool's configuration; it currently includes WebSearch and OCR
|
||||
- `/ai` now owns LLM Provider, AI Tool configuration, and Playground instead of nesting them under `/settings`
|
||||
- The `模型供应商` section manages default provider, model, base URL, provider key, local `aiprovider` proxy, and connection test; provider and model fields use editable comboboxes so users can manually enter new providers/models if the models.dev catalog stops updating
|
||||
- The `工具调用` section first selects a tool from a dropdown menu, then renders that tool's configuration; it currently includes WebSearch and OCR
|
||||
- WebSearch configuration includes provider, search key, base URL, timeout, result count, and advanced provider options
|
||||
- OCR configuration includes provider, Base URL, API key, model/engine, languages, timeout, file-size limit, and output format
|
||||
- The `测试台` tab embeds the former Playground real session, preset prompts, and AI Provider status debugging
|
||||
- The page reuses the Settings single-screen tabs, panel card, and internal scrolling style
|
||||
- The `Playground` section embeds the former Playground real session, preset prompts, and AI Provider status debugging
|
||||
- The page reuses the Settings single-screen section, panel card, and internal scrolling style
|
||||
- AI Provider and WebSearch connection tests use `ConnectionTestInput`, with the connector icon fixed at the end of the Base URL input; when WebSearch is disabled, every configuration field and the test entry point are greyed out except the switch
|
||||
|
||||
Legacy `/settings?tab=ai` should redirect to `/ai?tab=providers`.
|
||||
Legacy `/playground` should redirect to `/ai?tab=playground`.
|
||||
AI configuration no longer lives under `/settings`; `/playground` should redirect to `/ai?section=playground`.
|
||||
|
||||
### 3. Business Data Gateway
|
||||
|
||||
@@ -342,11 +366,11 @@ Current page boundary:
|
||||
- Endpoint, headers, and config are displayed here, not edited.
|
||||
- Credential-bearing collectors point users to `Collection Management -> Collectors`.
|
||||
|
||||
Keep this boundary: do not put custom datasource editing, built-in endpoint overrides, or credential forms back into `/datasources`. Those configuration entry points live at `/collection-management?tab=collector_credentials`.
|
||||
Keep this boundary: do not put custom datasource editing, built-in endpoint overrides, or credential forms back into `/datasources`. Those configuration entry points live at `/collection-management?section=collector_credentials`.
|
||||
|
||||
### Collectors Page
|
||||
|
||||
[Settings.tsx](/home/ray/dev/linkong/planet/frontend/src/admin/pages/PlainResourcePages.tsx) has three route modes: `/settings` for System Settings, `/earth-content` for Earth Content, and `/collection-management` for Collection Management. The `collector_credentials` tab is shown as `Collectors` under `/collection-management`.
|
||||
[Settings.tsx](/home/ray/dev/linkong/planet/frontend/src/admin/pages/PlainResourcePages.tsx) has three route modes: `/settings` for System Settings, `/earth-content` for Earth Content, and `/collection-management` for Collection Management. The `collector_credentials` section is shown as `Collectors` under `/collection-management`.
|
||||
|
||||
The `System Display` section under `/settings` includes the `Demo Mode` switch. When enabled, Earth OOBE ignores existing current collected data and the local `browse first` temporary skip state, then opens the initialization guide directly. This switch is only for demos and acceptance checks; it does not change datasources, collection queues, or Earth content resources.
|
||||
|
||||
@@ -363,6 +387,7 @@ Current boundary:
|
||||
`/earth-content` reuses the same single-screen tab container from [Settings.tsx](/home/ray/dev/linkong/planet/frontend/src/admin/pages/PlainResourcePages.tsx), but its ownership is separate from System Settings:
|
||||
|
||||
- `TV Livestream` owns the Earth media-panel source configuration.
|
||||
- `Branding` owns Earth HUD brand assets. `Logo URL` and `Title Image URL` use inline upload controls inside the fields; the upload buttons keep the primary `TactileButton` style, and dropping an image onto the matching field shows a low-saturation drag reaction instead of the old global asset-picker toolbar.
|
||||
- `Boundary Precision` owns the Earth static boundary asset state: provider, low-precision fallback, high-precision manifest/PMTiles, source JSON, and build action.
|
||||
- `Base Map`, `Layer Resources`, `3D Assets`, and `News Anchor Strategy` are placeholders only. They show module status and do not invent fake APIs or fake data.
|
||||
|
||||
@@ -391,6 +416,7 @@ These principles have been repeatedly validated in the project:
|
||||
4. Do not use `overflow: hidden` to mask structural issues
|
||||
5. Do not compress the main work area to make summary cards show completely
|
||||
6. Custom scrollbars must be floating overlays; they must not squeeze content width
|
||||
7. The Admin shell relies on the root `height: 100%` chain and should not use exact `100vh` sizing at the workspace root
|
||||
|
||||
For detailed experience, see:
|
||||
|
||||
@@ -411,7 +437,7 @@ Do not write local CSS patches first, then retrofit the structure.
|
||||
|
||||
The console frontend and the Earth frontend are not the same system:
|
||||
|
||||
- Console frontend: React + Ant Design workbench
|
||||
- Console frontend: React + Tactile UI / Radix primitives / lucide workbench
|
||||
- Earth frontend: independent native HUD system under `public/earth`
|
||||
|
||||
Therefore:
|
||||
|
||||
@@ -210,6 +210,11 @@ Inspection order:
|
||||
3. Does the real scroll node explicitly use `overflow: auto`?
|
||||
4. Have intermediate wrapper layers silently changed layout semantics?
|
||||
|
||||
The current Admin and Docs root shells rely on the `html` / `body` / `#root`
|
||||
`height: 100%` chain. Do not reintroduce exact `100vh` / `100vw` sizing on
|
||||
these embedded workspace shells; modals, overlays, and narrow-screen safety
|
||||
boundaries may still use `calc(100vh - ...)` as a maximum-size constraint.
|
||||
|
||||
### 5. UI State and Display State Out of Sync
|
||||
|
||||
Repeated in Earth-related changes:
|
||||
|
||||
@@ -22,7 +22,7 @@ URLs below use the local default `http://localhost:3000`. Replace the prefix wit
|
||||
1. Open `http://localhost:3000/login` and click "Register" under the form.
|
||||
2. On `/register`, fill in:
|
||||
- **Username**: 3–50 characters, used to log in
|
||||
- **Email**: receives the verification code; editable later in account settings
|
||||
- **Email**: receives the verification code; in the current version, ask an administrator to maintain email changes in user management
|
||||
- **Password**: at least 8 characters
|
||||
3. After submission you are taken to the verify page. A 6-digit code is sent to your email. It expires in 10 minutes.
|
||||
4. Enter the code and click "Verify and Sign In". On success the system stores a session and sends you to the console.
|
||||
@@ -52,23 +52,23 @@ If you see "Email not verified", the page automatically redirects to `/verify-em
|
||||
3. After receiving the code, enter it together with a new password (≥ 8 characters) and click "Reset Password".
|
||||
4. The system sends you back to `/login` — sign in with the new password.
|
||||
|
||||
## Account Settings
|
||||
## Account Area And Sign Out
|
||||
|
||||
Click your username at the top-right of the console to open account settings:
|
||||
The account area at the bottom of the console sidebar shows the current username, version, and theme control. The current version does not include a signed-in self-service account settings page:
|
||||
|
||||
- Change password: enter current password + new password
|
||||
- Change email: the system sends a verification code to the new address; the change applies only after verification
|
||||
- View Gatekeeper groups: lists current groups (`docs_user` / `docs_developer` / `docs_admin`)
|
||||
- Log out: clears the current session
|
||||
- Use `/forgot-password` for password reset through email verification
|
||||
- Administrators maintain email, role, and Gatekeeper groups at `/users`
|
||||
- The sign-out icon in the account area clears the current session and returns to the login page
|
||||
|
||||
## Console Overview
|
||||
|
||||
The console at `http://localhost:3000/admin` is built with React + Ant Design. The left menu is organized by work domain.
|
||||
The console at `http://localhost:3000/admin` is built with React plus Tactile UI / Radix primitives and lucide icons. The left menu is organized by work domain.
|
||||
|
||||
| Page | Route | Purpose |
|
||||
| --- | --- | --- |
|
||||
| Dashboard | `/admin` | System overview |
|
||||
| Earth | `/earth` | Open the public Earth page |
|
||||
| Docs | `/docs` | Open the docs site and show documents allowed by Gatekeeper permissions |
|
||||
| Datasources | `/datasources` | Source directory and collection triggers |
|
||||
| Collected Data | `/data` | Data already ingested |
|
||||
| BGP | `/bgp` | BGP situational view |
|
||||
@@ -86,7 +86,7 @@ Menu items hide automatically when you lack permission. If a menu is missing, ch
|
||||
|
||||
## Configure Data Collectors
|
||||
|
||||
`/collection-management?tab=collector_credentials` is the "Collectors" page. It manages connection configuration for every collector, not just credentials. Legacy `/settings?tab=collector_credentials` redirects here; the datasource directory remains at `/datasources`.
|
||||
`/collection-management?section=collector_credentials` is the "Collectors" page. It manages connection configuration for every collector, not just credentials; the datasource directory remains at `/datasources`.
|
||||
|
||||
Steps:
|
||||
|
||||
@@ -127,7 +127,7 @@ The default guide follows the BarentsWatch official tutorial and reminds you to
|
||||
|
||||
Steps:
|
||||
|
||||
1. Open `/collection-management?tab=collector_credentials` and select `AISStream Realtime Vessels : aisstream_vessels`
|
||||
1. Open `/collection-management?section=collector_credentials` and select `AISStream Realtime Vessels : aisstream_vessels`
|
||||
2. Fill the AISStream API Key
|
||||
3. Keep the default endpoint `wss://stream.aisstream.io/v0/stream`
|
||||
4. Click the plug icon to test; confirm it reports `Reachable`
|
||||
@@ -141,11 +141,12 @@ Steps:
|
||||
|
||||
## Configure AI Credentials
|
||||
|
||||
`/ai?tab=providers` is the AI management entry. Three key sub-tabs:
|
||||
`/ai?section=integrations` is the AI management entry. Key sections:
|
||||
|
||||
- `Model Providers`: default LLM provider, model, base URL, API key, local `aiprovider` proxy, connection test
|
||||
- `Tools`: a dropdown for specific tools — currently WebSearch and OCR
|
||||
- `Tool Calls`: a dropdown for specific tools — currently WebSearch and OCR
|
||||
- `Prompts`: a task dropdown for news localization, alert analysis, BGP briefs, and other LLM tasks. Operators can edit the prompt or reset it to the default
|
||||
- `Playground`: real session, preset request, and AI Provider status debugging
|
||||
|
||||
### Model Providers
|
||||
|
||||
@@ -170,7 +171,7 @@ The plug icon at the end of the Base URL input runs a connection test. A passing
|
||||
|
||||
After selecting a task, the page shows the effective prompt, whether it is customized, the shipped default version, and a reset button. Saving affects only that task. Reset restores the default prompt from the current release package. Business facts, context, and output schemas are still assembled by the backend for each task.
|
||||
|
||||
The legacy link `/settings?tab=ai` redirects to `/ai?tab=providers`.
|
||||
AI configuration no longer lives in System Settings; `/playground` redirects to `/ai?section=playground`.
|
||||
|
||||
## Datasources and Task Logs
|
||||
|
||||
@@ -191,9 +192,10 @@ TV livestreams and boundary precision moved to `/earth-content`; collectors and
|
||||
|
||||
`/earth-content` is under the console's Operations and Configuration group and owns resources used by the Earth frontend:
|
||||
|
||||
- **Brand Assets**: manages the logo, title image, title text, subtitle, and description used by the Earth HUD. Uploaded images are saved as Earth brand assets and read by the Earth page immediately.
|
||||
- **Brand Assets**: manages the logo, title image, title text, subtitle, and description used by the Earth HUD. The `Logo URL` and `Title Image URL` fields each include their own Upload button, and image files can be dropped directly onto the matching field. After upload, the field receives the new asset URL; save the brand configuration to make the Earth page use it.
|
||||
- **About**: manages the About card shown in Earth settings, including logo, kicker, title, version, description, and metadata.
|
||||
- **TV Livestream**: manages sources shown in the Earth media panel.
|
||||
- **News Content**: browses news grouped by RSS source and manual group. RSS items remain read-only; manual groups support create, JSON import, edit, delete, and reprocess.
|
||||
- **Boundary Precision**: shows the current provider, low-precision fallback, high-precision PMTiles/manifest status, local source JSON, and manual build action.
|
||||
- **Base Map**, **Layer Resources**, **3D Assets**, and **News Anchor Strategy**: placeholder tabs for future configuration. They do not display fake data.
|
||||
|
||||
@@ -244,7 +246,7 @@ To let a regular user read developer or operations docs, add `docs_developer` or
|
||||
|
||||
## AI Testbench
|
||||
|
||||
`/ai?tab=playground` is for real-pipeline debugging:
|
||||
`/ai?section=playground` is for real-pipeline debugging:
|
||||
|
||||
- Pick the active provider
|
||||
- Run preset requests or custom prompts
|
||||
@@ -298,7 +300,7 @@ Adopt All is for batch processing the compute-center unresolved queue. It starts
|
||||
|
||||
### Settings
|
||||
|
||||
The settings panel is grouped into Runtime, Display, Panels, Motion, Shortcuts, and System. It covers rotate / cruise / motion mode, cruise modules (BGP/news/compute centers/vessels/cables/satellites), view (satellite display style, hover tooltip, satellite idle breathing, real satellite altitude, track display, compact dots, day-night mode, panel toggles), motion debug mode / input source / skeleton-only, shortcut enablement and remapping, default globe size, terrain opacity, reset.
|
||||
The settings panel is grouped into Runtime, Display, Panels, Motion, Shortcuts, and System. It covers rotate / cruise / motion mode, cruise modules (BGP/news/compute centers/vessels/cables/satellites), view (satellite display style, hover tooltip, satellite idle breathing, real satellite altitude, track display, compact dots, day-night mode, panel toggles), motion debug mode / input source / skeleton-only / recognized-gesture whitelist, shortcut enablement and remapping, default globe size, terrain opacity, reset.
|
||||
|
||||
News categories use the same chip selector as Cruise Modules. They only filter the news panel and news cruise items in the current browser; they do not affect layers, TV, data points, basemap, boundaries, collector jobs, or admin news-source configuration.
|
||||
|
||||
@@ -336,6 +338,8 @@ Enable via the settings toggle "Motion Debug Mode", or with URL parameter `?moti
|
||||
|
||||
Neither mode uploads camera frames or live gestures; neither reuses the news/RSS aggregation API.
|
||||
|
||||
Recognized Gestures can disable rotation, zoom, focus switching, layer switching, or confirmation independently. The browser ignores unchecked actions; when Motion Agent is active, the same whitelist is synchronized through the control protocol.
|
||||
|
||||
Gesture semantics:
|
||||
|
||||
| Event | Effect |
|
||||
|
||||
@@ -28,6 +28,9 @@ This document standardizes terms used across Intelligent Planet, the console, ba
|
||||
| AI Provider | AI Provider | Service name |
|
||||
| tool | 工具 | Web Search, OCR, and similar integrations |
|
||||
| Playground | Playground | Interactive debugging entry |
|
||||
| branding | 品牌标识 | Earth HUD brand configuration section under `/earth-content` |
|
||||
| brand assets | 品牌资源 | Logo, title image, and related HUD copy assets |
|
||||
| title image | 标题图 | Earth HUD title image |
|
||||
|
||||
## Data Types
|
||||
|
||||
|
||||
@@ -244,22 +244,27 @@ The recommended direction is a small platform compatibility layer for port liste
|
||||
|
||||
The production frontend shape is `vite build` static output served by nginx/Caddy or an equivalent HTTP server. Do not use `bun run dev` or `vite preview` in production. The project does not maintain a parallel Webpack build chain; if a future enterprise requirement needs closer Webpack-ecosystem compatibility, run an Rsbuild/Rspack spike first. Electron should only be evaluated when the official target becomes an offline desktop application.
|
||||
|
||||
## Optional Motion Agent Startup
|
||||
## Default Motion Agent Startup
|
||||
|
||||
`planet.sh` can now manage the local Motion Capture Agent. It is disabled by default so ordinary development machines do not fail startup when cameras, OpenCV, or MediaPipe are unavailable.
|
||||
`planet.sh` now starts the local Motion Agent by default during `start` and full `restart`. This makes the Earth page, UE clients, and debug clients able to connect to `ws://127.0.0.1:8765/ws/gestures` immediately. If the machine has no usable camera, implicit default startup falls back to dry-run protocol mode and does not block backend/frontend startup. Explicit Motion Agent startup through `--motion-agent`, camera indexes, camera URLs, or WSL USB options still treats live camera failures as real errors.
|
||||
|
||||
Start it with:
|
||||
To skip Motion Agent for this run:
|
||||
|
||||
```bash
|
||||
./planet.sh start --motion-agent
|
||||
./planet.sh start --non-motion-agent
|
||||
./planet.sh restart --non-motion-agent
|
||||
```
|
||||
|
||||
Common options:
|
||||
|
||||
- `--motion-agent` / `-m`: start or restart the Motion Agent for this command.
|
||||
- `--non-motion-agent`: do not start Motion Agent for this `start` or full `restart`.
|
||||
- `--motion-agent` / `-m`: explicitly start or restart the Motion Agent for this command; live camera failures are reported as failures.
|
||||
- `--motion-agent-port <port>`: override the default WebSocket port `8765`.
|
||||
- `--motion-agent-mode <mode>`: choose `auto`, `single`, `dual_redundant`, `single_fallback`, or `calibrated_3d`; `dual` is kept as a compatibility alias for redundant dual-camera mode.
|
||||
- `--motion-agent-camera-indexes <indexes>`: override auto-detected camera indexes, for example `0` or `0,1`. The same can be provided through `MOTION_AGENT_CAMERA_INDEXES=0,1`.
|
||||
- `--motion-agent-camera-urls <urls>`: use RTSP/HTTP camera streams, useful for WSL, phone cameras, or network cameras. The same can be provided through `MOTION_AGENT_CAMERA_URLS=...`.
|
||||
- `--motion-agent-wsl-usbipd`: in WSL, try to attach the single detected Windows USB camera through `usbipd-win`.
|
||||
- `--motion-agent-wsl-usbipd-busid <BUSID>`: in WSL, attach the camera matching a `usbipd list` BUSID; use this when multiple cameras are present.
|
||||
- `--motion-agent-dry-run`: start only the protocol service without opening cameras or loading CV dependencies; useful for Web client debugging.
|
||||
|
||||
Non-dry-run live mode checks `mediapipe` and `opencv-python` before startup. If the current `.venv` is missing them, the script automatically runs:
|
||||
@@ -268,13 +273,17 @@ Non-dry-run live mode checks `mediapipe` and `opencv-python` before startup. If
|
||||
uv add mediapipe opencv-python
|
||||
```
|
||||
|
||||
To disable startup-time auto-install:
|
||||
To disable startup-time Python CV dependency auto-install:
|
||||
|
||||
```bash
|
||||
PLANET_MOTION_AGENT_AUTO_INSTALL=0 ./planet.sh start --motion-agent
|
||||
PLANET_MOTION_AGENT_AUTO_INSTALL=0 ./planet.sh start
|
||||
```
|
||||
|
||||
Live mode auto-detects `/dev/video*` and passes the first two indexes to the Motion Agent. In WSL, Windows cameras usually do not appear as `/dev/video*` automatically. Check available devices first:
|
||||
`./planet.sh init` also performs a WSL host dependency preflight for `usbipd-win`. If `usbipd.exe` is missing, the script first tries `winget install -e --id dorssel.usbipd-win`, then reuses the repository-bundled dorssel.usbipd-win MSI fallback. If that cached MSI is missing or an architecture-specific MSI is needed, it downloads one and requests an Administrator PowerShell installation. This is best-effort: failure prints next steps but does not block normal initialization. Use `./planet.sh init --non-motion-agent` to skip this preflight.
|
||||
|
||||
Live mode auto-detects `/dev/video*`, then prefers an OpenCV probe to keep only indexes that can open and return frames before passing them to the Motion Agent. In WSL/USB camera setups, one camera can expose multiple `/dev/video*` nodes, and some of them are metadata or non-capture nodes; the script skips those unreadable indexes. In WSL, Windows cameras usually do not appear as `/dev/video*` automatically. Check available devices first:
|
||||
|
||||
Live capture defaults to low-latency settings: `640x360` input and roughly `15Hz` recognition events. The worker uses latest-frame reader threads and keeps only the newest frame from each camera, so a slow MediaPipe frame does not make the recognizer drain stale camera backlog. The skeleton debug stream is disabled by default and is only sent at roughly `8Hz` while the Earth motion debug panel is open, so normal gesture control is not slowed down by debug data. Status events report both capture FPS and recognition FPS to separate camera throughput issues from recognition cost.
|
||||
|
||||
```bash
|
||||
ls /dev/video*
|
||||
@@ -292,20 +301,42 @@ In WSL, the more general path is to connect a phone or network camera through an
|
||||
./planet.sh start --motion-agent --motion-agent-camera-urls http://192.168.1.20:8080/video
|
||||
```
|
||||
|
||||
If WSL has no `/dev/video*` and no `--motion-agent-camera-urls` is provided, live startup stops and prints guidance instead of silently falling back to dry-run. Choose one of:
|
||||
To use a Windows USB camera directly from WSL, let the script call `usbipd-win`. This is opt-in because an attached camera is usually temporarily unavailable to Windows apps while WSL owns it.
|
||||
|
||||
When there is only one camera:
|
||||
|
||||
```bash
|
||||
./planet.sh start --motion-agent --motion-agent-wsl-usbipd
|
||||
```
|
||||
|
||||
When there are multiple cameras, inspect the BUSID first and pass it explicitly:
|
||||
|
||||
```bash
|
||||
usbipd.exe list
|
||||
./planet.sh start --motion-agent --motion-agent-wsl-usbipd-busid 3-2
|
||||
```
|
||||
|
||||
If `usbipd attach` says the device is not shared or bound, the script tries to open an Administrator PowerShell to run `usbipd bind`, then retries attach. If UAC is canceled or automatic bind fails, run this manually from an Administrator PowerShell:
|
||||
|
||||
```powershell
|
||||
usbipd bind --busid 3-2
|
||||
usbipd attach --wsl --busid 3-2
|
||||
```
|
||||
|
||||
If WSL has no `/dev/video*` and no `--motion-agent-camera-urls` is provided, implicit default startup falls back to dry-run. Explicit live startup stops and prints guidance. Choose one of:
|
||||
|
||||
```bash
|
||||
./planet.sh start --motion-agent --motion-agent-camera-urls http://<phone-ip>:8080/video
|
||||
./planet.sh start --motion-agent --motion-agent-wsl-usbipd
|
||||
./planet.sh start --motion-agent --motion-agent-dry-run
|
||||
```
|
||||
|
||||
Automatic dry-run fallback only happens when `PLANET_MOTION_AGENT_WSL_ALLOW_DRY_RUN_FALLBACK=1` is explicitly set.
|
||||
For explicit live startup, automatic dry-run fallback only happens when `PLANET_MOTION_AGENT_WSL_ALLOW_DRY_RUN_FALLBACK=1` is set.
|
||||
|
||||
Environment-variable startup is also supported:
|
||||
`--non-motion-agent` is the command-level opt-out. Environment variables can still tune how the service starts:
|
||||
|
||||
```bash
|
||||
PLANET_START_MOTION_AGENT=1 ./planet.sh start
|
||||
MOTION_AGENT_DRY_RUN=1 PLANET_START_MOTION_AGENT=1 ./planet.sh start
|
||||
MOTION_AGENT_DRY_RUN=1 ./planet.sh start
|
||||
```
|
||||
|
||||
Logs:
|
||||
|
||||
@@ -2,6 +2,48 @@
|
||||
|
||||
This runbook is for deployment, on-call, and maintenance engineers. End-user UI flows live in the [Intelligent Planet Manual](/home/ray/dev/linkong/planet/docs/technical/en/manual.md); this document only covers shell, Docker, logs, environment variables, and troubleshooting.
|
||||
|
||||
## Docker Initialization and Access
|
||||
|
||||
Initialize a new machine before starting the application services:
|
||||
|
||||
```bash
|
||||
zsh ./planet.sh init --non-motion-agent && zsh ./planet.sh start --non-motion-agent
|
||||
```
|
||||
|
||||
The entry point still requires `zsh`, `curl`, and reachable package repositories. Before synchronizing Python and frontend dependencies, `init` prepares Docker:
|
||||
|
||||
- Reuse working Docker, Compose v2, and Buildx (at least 0.17.0).
|
||||
- On Ubuntu / Ubuntu WSL, use apt to install the missing parts of `docker.io`, `docker-compose-v2`, and `docker-buildx`. When Docker CE CLI is already installed, use the configured Docker CE repository and corresponding plugin packages to keep the package family consistent.
|
||||
- If the local daemon is unavailable, check that `docker.service` exists, then enable and start it. WSL must have systemd enabled; unavailable service management produces an explicit Docker preparation error.
|
||||
- If the current user cannot read and write the Docker socket, check for `usermod`, install its `passwd` package when needed, and add the user to the `docker` group. This group grants privileged control of the local Docker engine. The script uses `sudo` to refresh group access as the original user and continue the original command with its arguments preserved. It does not depend on `sg` or run application processes as root.
|
||||
|
||||
When elevation is needed, sudo authentication runs in the foreground. Missing sudo for an unprivileged user, authentication failure, repository errors, or insufficient versions after installation stop initialization with a specific error.
|
||||
|
||||
Subsequent `planet.sh start` and other service commands in the same old terminal also refresh Docker group membership when it has been granted but is not yet active. Open a new Ubuntu session to use `docker` directly in the terminal.
|
||||
|
||||
When Docker Desktop is present but its WSL integration is unavailable, the script asks the operator to start Desktop and enable WSL Integration for the distribution. Unreachable remote or rootless endpoints produce a diagnostic for that environment; neither case installs a second local engine automatically. Automatic installation on other operating systems is not currently supported.
|
||||
|
||||
`planet.sh` calls `scripts/lib/docker-bootstrap.zsh` for preparation. Missing CLI, missing service units, socket permissions, and stopped daemons receive separate diagnostics. Advice to start `docker.socket` is shown only after confirming that the unit exists. Verify the result with:
|
||||
|
||||
```bash
|
||||
docker info
|
||||
docker compose version
|
||||
docker buildx version
|
||||
```
|
||||
|
||||
## Database Initialization and Connection Checks
|
||||
|
||||
`init` reconciles PostgreSQL / Redis containers through Compose, including port configuration on existing containers. A plain `docker start` cannot apply configuration changes. Compose failures retain their specific errors, such as an occupied port, instead of falling back to an old container and reporting success.
|
||||
|
||||
The container's `pg_isready` check only establishes that the server accepts connections; it does not validate the host backend's address and credentials. Once containers are healthy, `init` runs `scripts/check_database_connection.py` using the backend's effective `DATABASE_URL`. It checks the local PostgreSQL published port and executes a read-only `SELECT 1` before reporting database readiness or creating tables and seed data.
|
||||
|
||||
- If the actual local port mapping is still missing or mismatched, the script recreates PostgreSQL once from Compose while preserving its data volume, then checks again. A second failure stops initialization.
|
||||
- Authentication, database-name, and network failures stop before schema changes. Diagnostics show the host, port, and database name without passwords, full connection strings, or raw driver exceptions.
|
||||
- A process-level `DATABASE_URL` overrides `backend/.env`. Changing `POSTGRES_PASSWORD` alone updates neither the connection string nor the password stored in an existing data volume. Existing environment files are retained and their effective configuration must be checked.
|
||||
- Explicit external databases do not require a local container mapping. Host networking also does not require published ports. Both still require the real connection check.
|
||||
|
||||
For `port is already allocated` or `address already in use`, inspect `docker ps` port information and `ss -ltnp '( sport = :5432 )'`. With WSL mirrored networking, also inspect Windows listeners. Initialization does not kill other database services to acquire a port, delete data volumes, or reset passwords.
|
||||
|
||||
## First Startup
|
||||
|
||||
```bash
|
||||
|
||||
@@ -39,7 +39,7 @@ flowchart TB
|
||||
VesselRaw["AIS / BarentsWatch"] --> VesselDerived["vessel_static / vessel_position"]
|
||||
VesselDerived --> VesselLayer["vessels layer"]
|
||||
Interactables["earth_interactables"] --> InteractableLayer["interactables layer"]
|
||||
NewsRaw["RSS / Live / News"] --> NewsItems["earth_news_items"]
|
||||
NewsRaw["RSS / Manual News / Live"] --> NewsItems["earth_news_items"]
|
||||
NewsItems --> NewsLayer["news / media layer"]
|
||||
```
|
||||
|
||||
@@ -51,7 +51,7 @@ flowchart TB
|
||||
| BGP context | Collectors, anomalies, incidents, route events, and regional context | `ris_live_bgp`, `bgpstream_bgp`, prefix geography sources | `bgp_observations`, `bgp_anomalies`, `bgp_incidents`, `bgp_collector_locations` | `bgp` | `clear_then_reload` |
|
||||
| Vessels | AIS vessels, positions, tracks, legend, and source health | AIS sources, `barentswatch_vessels` | `vessel_static`, `vessel_position`, `ais_raw_observations`, `ais_source_health` | `vessels` | `clear_then_reload` |
|
||||
| Interactables | Generic surface icons, manual objects, and future small layers | `earth_interactables` | None | `interactables` | `delta` |
|
||||
| News and media | Earth news, live streams, cruise summaries, and situation content | news sources | `earth_news_items` | `news` / `media` | `reload` |
|
||||
| News and media | Earth news, live streams, cruise summaries, and situation content | RSS news sources, manual news, live streams | `earth_news_items` | `news` / `media` | `reload` |
|
||||
|
||||
## Satellites
|
||||
|
||||
@@ -141,12 +141,12 @@ Vessel data shows AIS vessels, navigation state, vessel-type legend, and source
|
||||
|
||||
News and media support the Earth news ticker, live stream panel, news cruise, and situation summaries. They are content refresh paths rather than stable geographic object layers, so they default to `reload`.
|
||||
|
||||
- **Collection entry**: RSS, live streams, news sources.
|
||||
- **Fact table**: news source rows in `collected_data`.
|
||||
- **Collection entry**: RSS news sources, manual news from `Earth Content -> News Content`, and live streams.
|
||||
- **Fact table**: news source rows in `collected_data`; manual news writes directly to `earth_news_items` and marks the content source with `feed_type/source_type=manual`.
|
||||
- **Derived table**: `earth_news_items`.
|
||||
- **API**: news, live stream, and media content APIs.
|
||||
- **API**: `/api/v1/news/earth-feed` reads `earth_news_items`; the Admin API `/api/v1/earth/news-items` supports manual create, JSON import, edit, delete, and reprocess.
|
||||
- **Delete semantics**: deleting news sources or `earth_news_items` broadcasts `news` / `media` reload; empty responses hide the corresponding content.
|
||||
- **Common failure**: the live panel shows stale content. Usually the media component ignored the layer update or the content API cache was not invalidated.
|
||||
- **Common failure**: a newly saved manual item may initially show source text or a region anchor; this is the normal publish-first enrichment window. If it never updates, check the `earth_news_enrichment` queue, AI / Web Search configuration, and `enrichment_status`. If the live panel shows stale content, the media component likely ignored the layer update or the content API cache was not invalidated.
|
||||
|
||||
## Adding a New Layer
|
||||
|
||||
|
||||
@@ -32,11 +32,12 @@ The default role is `viewer`: you can sign in but only see public pages. For col
|
||||
|
||||
After landing on the `/admin` dashboard, here's a recommended walk-through:
|
||||
|
||||
1. `/collection-management?tab=collector_credentials`: pick a collector and click the plug icon to test connectivity. Free collectors (e.g. open BGP) usually work right away; credential-bearing ones like `AISStream` or `BarentsWatch` need an API key / client secret first
|
||||
2. `/ai?tab=providers`: fill an LLM provider (e.g. `minimax` / `openai`), model, base URL, API key, and click the plug at the end of the base URL to test. WebSearch / OCR tools are optional
|
||||
3. `/datasources` or `/data`: check whether collectors have produced data. Use `/datasources -> Built-in Sources` for finite collectors: with no rows selected, click `Trigger All`; after selecting rows, the primary button becomes `Trigger Selected N`. The top-right queue button shows progress. Use `/datasources -> Realtime Sources` for AISStream / WebSocket health and counters
|
||||
4. `/alerts/system`: verify system alerts look right
|
||||
5. `/users` (super_admin only): open accounts for teammates or adjust their groups
|
||||
1. `/collection-management?section=collector_credentials`: pick a collector and click the plug icon to test connectivity. Free collectors (e.g. open BGP) usually work right away; credential-bearing ones like `AISStream` or `BarentsWatch` need an API key / client secret first
|
||||
2. `/ai?section=integrations`: fill an LLM provider (e.g. `minimax` / `openai`), model, base URL, API key, and click the plug at the end of the base URL to test. WebSearch / OCR tools are optional
|
||||
3. `/earth-content?section=brand`: maintain the Earth HUD logo and title image in Branding. The Upload button inside each URL field opens a file picker, and image files can also be dropped directly onto the matching field. Save the brand configuration after upload
|
||||
4. `/datasources` or `/data`: check whether collectors have produced data. Use `/datasources -> Built-in Sources` for finite collectors: with no rows selected, click `Trigger All`; after selecting rows, the primary button becomes `Trigger Selected N`. The top-right queue button shows progress. Use `/datasources -> Realtime Sources` for AISStream / WebSocket health and counters
|
||||
5. `/alerts/system`: verify system alerts look right
|
||||
6. `/users` (super_admin only): open accounts for teammates or adjust their groups
|
||||
|
||||
## 4. Open Earth
|
||||
|
||||
@@ -50,7 +51,7 @@ Once in, verify:
|
||||
- Search finds cables, satellites, compute centers, BGP events
|
||||
- Compute-center and BGP collector detail cards can collect coordinate candidates and preview them on Earth
|
||||
- Mouse drag, wheel zoom, and the zoom percentage indicator work
|
||||
- The settings panel can switch rotate / cruise / motion modes; view settings can switch hover tooltip content, and satellite settings can toggle real-altitude layering and track display
|
||||
- The settings panel can switch rotate / cruise / motion modes; motion settings can select the input source and allowed gestures; view settings can switch hover tooltip content, and satellite settings can toggle real-altitude layering and track display
|
||||
|
||||
## 5. Recover a Lost Password
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
- 展示所有数据源,包括内置和自定义。
|
||||
- 点击名称只打开信息抽屉。
|
||||
- 负责查看状态、触发采集和查看采集中任务。
|
||||
- `/collection-management?tab=collector_credentials`
|
||||
- `/collection-management?section=collector_credentials`
|
||||
- 显示为“采集器”。
|
||||
- 负责 endpoint、请求头、基础参数和凭证配置。
|
||||
- 所有采集器都提供连接按钮,用于健康检查。
|
||||
|
||||
@@ -75,7 +75,13 @@ 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` 列表做交集,若没有交集则回退到当前区域所有可用来源。这样滚动条、面板和巡航才会在区域切换后展示同一批新闻。
|
||||
|
||||
快捷键配置属于设备本地偏好,由 `controls.js` 负责读取、捕获、启用/禁用和重置。它不应写入后端用户设置,也不应影响其它浏览器。后续新增快捷键时,必须同时提供默认键、显示标签、可禁用状态和重置路径,避免只在 keydown handler 中硬编码。
|
||||
|
||||
@@ -109,9 +115,9 @@ Earth 设置面板现在按 `data-settings-tab` 和 `data-settings-tab-panel`
|
||||
- 将 `rotate_left`、`rotate_right`、`rotate_up`、`rotate_down`、`zoom_in`、`zoom_out`、`focus_prev`、`focus_next`、`layer_prev`、`layer_next`、`confirm` 映射到 `main.js` 暴露的动作入口。
|
||||
- 解析 `skeleton` 调试事件并派发 `earth:motion-debug-frame`。
|
||||
|
||||
动作捕捉识别可以在浏览器本地执行,也可以在本地 Agent 中执行,但两者都不会把实时视频帧发给 SaaS 云端。`main.js` 暴露旋转、缩放、目标切换、图层切换和确认入口,并通过 `window.__planetEarth.motion` 提供调试入口。默认只有 URL 参数 `?motion=1`、本地存储 `planet-earth-motion-control-enabled=true`,或 Earth 设置中的“动捕调试模式”打开时才启动当前 provider。
|
||||
动作捕捉识别可以在浏览器本地执行,也可以在本地 Agent 中执行,但两者都不会把实时视频帧发给 SaaS 云端。`main.js` 暴露旋转、缩放、目标切换、图层切换和确认入口,并通过 `window.__planetEarth.motion` 提供调试入口。默认只有 URL 参数 `?motion=1`、本地存储 `planet-earth-motion-control-enabled=true`,或 Earth 设置中的“动捕调试模式”打开时才启动当前 provider。`shared.motionEnabledGestures` 保存用户允许识别的动作;浏览器端先过滤,Motion Agent 模式还会通过 `set_enabled_gestures` 同步给服务端。
|
||||
|
||||
动捕调试面板由 [motion-debug-panel.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/motion-debug-panel.js) 负责。它监听 `earth:motion-debug-frame`,用 canvas 绘制归一化骨架点和连线;Browser Camera provider 会额外通过 `earth:motion-debug-video-source` 提供本机 `<video>` 作为调试预览底图,`shared.motionDebugSkeletonOnly` 可切换为只显示骨骼。`停止匹配动作` 通过 `earth:motion-recognition-pause` 暂停 gesture 执行,但继续显示视频和骨架。未匹配动作为红色,匹配后变绿并显示动作名。设置项持久化在 `planet.earth.settings.v2` 的 `shared.motionDebugEnabled`、`shared.motionProvider` 与 `shared.motionDebugSkeletonOnly`,switch 和输入源控件都预留 `data-gatekeeper-permission="earth.motion_debug"`。
|
||||
动捕调试面板由 [motion-debug-panel.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/motion-debug-panel.js) 负责。它监听 `earth:motion-debug-frame`,用 canvas 绘制归一化骨架点和连线;Browser Camera provider 会额外通过 `earth:motion-debug-video-source` 提供本机 `<video>` 作为调试预览底图,`shared.motionDebugSkeletonOnly` 可切换为只显示骨骼。`停止匹配动作` 通过 `earth:motion-recognition-pause` 暂停 gesture 执行,但继续显示视频和骨架。未匹配动作为红色,匹配后变绿并显示动作名。设置项持久化在 `planet.earth.settings.v2` 的 `shared.motionDebugEnabled`、`shared.motionProvider`、`shared.motionDebugSkeletonOnly` 与 `shared.motionEnabledGestures`,switch、输入源和动作白名单控件都预留 `data-gatekeeper-permission="earth.motion_debug"`。
|
||||
|
||||
Browser Camera provider 的手势识别管线在 [motion-browser-provider.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/motion-browser-provider.js) 的 `recognizeGesture()`,按以下顺序匹配,前者命中即返回:
|
||||
|
||||
@@ -455,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%,避免选择点、红色轨迹和主体地球之间出现过大的空场。
|
||||
|
||||
@@ -86,6 +86,55 @@ Earth 态势新闻使用 `/api/v1/news/earth-feed` 输出给前端。新闻源
|
||||
|
||||
`GET /api/v1/earth/news-sources` 返回默认或已保存配置。`PUT /api/v1/earth/news-sources` 保存配置并递增 `cache_version`,同时清理进程内 region cache。`POST /api/v1/earth/news-sources/reset` 恢复默认源。`POST /api/v1/earth/news-sources/test` 只测试单个 RSS/Atom/Aggregated 源,不写入新闻表。
|
||||
|
||||
## 手动新闻内容
|
||||
|
||||
手动新闻是内容管理能力,不是 RSS 源配置。Admin 入口是 `智能星球内容 -> 新闻内容`,左栏按 RSS 来源和手动新闻组聚合;进入手动组详情后可以按条添加新闻或上传 JSON 数组批量导入。手动新闻写入 `earth_news_items`,并标记 `feed_type/source_type = manual`;前台展示文案是“手动添加”。它不参与 RSS 连通性测试,也不会进入 RSS 抓取流程。
|
||||
|
||||
手动新闻采用“先展示,再精修”的策略:
|
||||
|
||||
1. 保存后立即写入 `earth_news_items`。
|
||||
2. 没有人工坐标时使用所选区域锚点,`verified=false`。
|
||||
3. 有人工坐标时使用 `location_source=manual_location`,`verified=true`,后续 AI 精修不会覆盖该坐标。
|
||||
4. 创建或重新处理后进入新闻增强队列,后台补清洗、翻译、分类、重要度、Breaking 和目标位置推断。
|
||||
5. 精修完成后更新同一条新闻,并通过 Earth news reload / patch 让前端无感替换。
|
||||
|
||||
后台接口挂在 `/api/v1/earth/news-items`:
|
||||
|
||||
- `GET /earth/news-groups`:返回 RSS 虚拟来源组和手动新闻组。
|
||||
- `POST /earth/news-groups`:新建手动新闻组。
|
||||
- `PUT /earth/news-groups/{group_id}`:重命名手动新闻组,并同步组内新闻 meta。
|
||||
- `GET /earth/news-items`:分页查询 RSS 与手动新闻,支持来源类型、区域、类型和状态过滤。
|
||||
- `POST /earth/news-items`:新增一条手动新闻。
|
||||
- `POST /earth/news-items/import`:上传 JSON 数组批量导入,`group_id` 指定当前手动新闻组。
|
||||
- `PUT /earth/news-items/{id}`:编辑手动新闻;RSS 新闻只读。
|
||||
- `DELETE /earth/news-items/{id}`:删除手动新闻,并触发 Earth 新闻重载。
|
||||
- `POST /earth/news-items/{id}/reprocess`:重新进入清洗、翻译和定位队列。
|
||||
|
||||
JSON 导入首版只支持数组:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"title": "必填标题",
|
||||
"summary": "可选摘要",
|
||||
"content": "可选正文",
|
||||
"url": "https://example.com/story",
|
||||
"source": "手动添加",
|
||||
"region": "global",
|
||||
"published_at": "2026-05-15T03:00:00Z",
|
||||
"category": "business",
|
||||
"tags": ["manual", "analysis"],
|
||||
"location": {
|
||||
"label": "北京市, 中国",
|
||||
"latitude": 39.9057,
|
||||
"longitude": 116.3913
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
重复导入使用稳定 ID 去重,ID 由标题、发布时间、URL 和来源生成,格式为 `manual:{hash}`。同一条手动新闻再次导入会更新原记录,不会重复出现在 EarthFeed。
|
||||
|
||||
## Feed 查询与类型过滤
|
||||
|
||||
星球端和 UE 端统一使用 `GET /api/v1/news/earth-feed` 获取新闻。接口支持服务端过滤,不要求客户端拿全量列表后自行筛选。
|
||||
@@ -95,6 +144,8 @@ Earth 态势新闻使用 `/api/v1/news/earth-feed` 输出给前端。新闻源
|
||||
- `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
|
||||
@@ -107,8 +158,23 @@ GET /api/v1/news/earth-feed?region=global&categories=business,ecommerce&locale=z
|
||||
|
||||
Web 星球端的新闻类型按钮只保存当前浏览器的显示偏好;偏好变化后会重新请求接口。UE 端应直接把类型选择拼到 `categories` 参数里,不需要再做主过滤。
|
||||
|
||||
未指定 `sources` 时,服务层会优先选择当前 `locale` 已有可展示标题和摘要的新闻,并从当前启用来源补齐候选后做来源轮转,避免一个来源的最新待处理条目占满默认 12 条。指定 `sources` 时保持精确来源过滤,不做跨来源补齐。数据库查询层只负责区域、类型、来源和排序条件,不包含语言展示策略。
|
||||
|
||||
源测试只证明当前 RSS/Atom/XML 能解析到条目,不等于这些条目已经入库展示。展示链路还会检查区域、类型过滤和数据库新鲜度。保存或重置新闻源会递增配置版本并清理缓存;如果当前启用的 Feed 子项在库里没有近期条目,下一次 `earth-feed` 请求会补抓,避免新启用的 36氪、亿邦被旧 Google News 缓存挡住。
|
||||
|
||||
## 区域均衡与异步精修
|
||||
|
||||
`earth_news_items` 是 EarthFeed 的当前状态表。接口在没有显式 `sources` 过滤时会先按 Breaking、区域、发布时间排序,再做区域轮转,避免亚太或任一高频来源把全球视图和巡航队列全部占满。默认区域顺序是美洲、欧洲、中东与非洲、亚太、全球;未知区域只在已知区域之后参与轮转。区域视图仍遵守“当前区域 + global”的规则,不会把其它区域混进区域面板。
|
||||
|
||||
`items` 和 `cruise_items` 都会进入目标位置与本地化精修队列,但前端不能等 AI 完成后再展示。标题或摘要没有目标语言翻译时,Web Earth 先显示原始 `title / summary`,避免卡片出现“新闻汉化中”而实际内容已经可读。后台完成翻译、分类、Breaking 或目标坐标后,会更新同一条 `earth_news_items` 并通过 Earth news reload / patch 刷新前端。
|
||||
|
||||
目标位置队列使用 Redis Streams 两级队列:
|
||||
|
||||
- `earth_news:target_location:priority`:当前可见 `items` 与 `cruise_items` 的优先精修任务,短 TTL 去重。
|
||||
- `earth_news:target_location:jobs`:普通后台精修任务,长 TTL 去重。
|
||||
|
||||
Worker 总是先处理优先队列,再处理普通队列;pending 消息超过空闲阈值会被 reclaim,单条 AI 任务有超时保护,失败后进入重试或 dead letter。这样即使历史普通队列有大量 backlog,当前打开的欧洲、美洲等区域新闻也不会被长队列饿死。
|
||||
|
||||
## Breaking News 插队
|
||||
|
||||
新闻体系里有三套互不替代的判断:
|
||||
@@ -163,9 +229,11 @@ Admin 入口是 `Earth 内容 -> 新闻源`。界面不是整包 JSON 编辑,
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Admin["Admin: Earth 内容 / 新闻源"] --> Source["Source 配置"]
|
||||
ManualAdmin["Admin: Earth 内容 / 新闻内容"] --> ManualAPI["/api/v1/earth/news-items"]
|
||||
Source --> Feed["Feed 子项"]
|
||||
Feed --> ConfigAPI["/api/v1/earth/news-sources"]
|
||||
ConfigAPI --> Config["SystemSetting: earth_news_sources"]
|
||||
ManualAPI --> Store
|
||||
|
||||
Earth["Earth 新闻面板"] --> NewsAPI["/api/v1/news/earth-feed"]
|
||||
NewsAPI --> Resolver["Source Resolver"]
|
||||
|
||||
@@ -98,6 +98,8 @@ Admin 的状态标签统一走 [StatusText](/home/ray/dev/linkong/planet/fronten
|
||||
|
||||
`StatusText` 是带圆点的指示灯:胶囊背景和边框保持组件原色,只让圆点和文字变成状态色。`Badge` 不带指示灯语义,可以使用同 tone 的浅色背景和边框强化信息层级。
|
||||
|
||||
状态指示器必须完整显示状态词。列表、树形组和详情栏里的状态列应让标题/描述区域收缩或换行,状态 pill 本身使用内容自适应宽度并禁止被 flex/grid 挤压;不要为了紧凑把 `Configured` / `Available` 这类状态裁成省略号。
|
||||
|
||||
| Tone | 颜色变量 | 语义 | 示例 |
|
||||
| --- | --- | --- | --- |
|
||||
| `success` | `--an-success` | 可用、成功、已连接、已启用 | 日志源 `可用`、采集 `成功` |
|
||||
@@ -216,7 +218,31 @@ Admin 的状态标签统一走 [StatusText](/home/ray/dev/linkong/planet/fronten
|
||||
- 颜色优先通过 CSS 变量覆盖,避免在业务组件里硬编码主题色
|
||||
- 适合少量互斥选项,不适合用作长列表、导航菜单或表单下拉
|
||||
|
||||
### 6. `MarkdownRenderer`
|
||||
### 6. 控制台 i18n
|
||||
|
||||
文件:
|
||||
|
||||
- [i18n/index.ts](/home/ray/dev/linkong/planet/frontend/src/i18n/index.ts)
|
||||
- [i18n/locale.ts](/home/ray/dev/linkong/planet/frontend/src/i18n/locale.ts)
|
||||
- [i18n/resources.ts](/home/ray/dev/linkong/planet/frontend/src/i18n/resources.ts)
|
||||
- [LegacyI18nBridge.tsx](/home/ray/dev/linkong/planet/frontend/src/i18n/LegacyI18nBridge.tsx)
|
||||
|
||||
用途:
|
||||
|
||||
- 控制台、认证页和 Docs UI 共用 `zh-CN` / `en-US` 语言状态
|
||||
- 语言偏好保存在 `planet-locale`,同时兼容旧的 `docs-lang`
|
||||
- Docs 请求仍映射到后端现有 `zh` / `en` 文档接口
|
||||
- 控制台侧边栏偏好面板和认证页面板提供语言切换入口
|
||||
|
||||
当前约束:
|
||||
|
||||
- 新增控制台文案优先写入 `resources.ts`,组件使用 `useTranslation()` 或 `useLocale()`
|
||||
- 路由、菜单、搜索索引和通用组件必须使用显式翻译 key
|
||||
- `LegacyI18nBridge` 只作为过渡层,负责 admin/auth 容器内未迁移的精确静态文本和属性
|
||||
- 业务数据、日志原文、API 字段名、provider id、命令和 Markdown 正文不走 legacy 翻译桥
|
||||
- 后续迁移大型业务页时应减少 legacy 字典,而不是继续扩大它
|
||||
|
||||
### 7. `MarkdownRenderer`
|
||||
|
||||
文件:
|
||||
|
||||
@@ -274,17 +300,16 @@ Admin 的状态标签统一走 [StatusText](/home/ray/dev/linkong/planet/fronten
|
||||
|
||||
职责:
|
||||
|
||||
- `/ai` 独立承载 LLM Provider、AI Tool 配置和测试台,不再放在 `/settings` 的系统配置 tabs 中
|
||||
- `模型供应商` tab 管理默认 provider、模型、base URL、provider key、本地 `aiprovider` 代理和连接测试;provider 和模型输入使用可输入组合框,models.dev 目录停更时用户仍可手动填新 provider/model
|
||||
- `工具` tab 先通过下拉菜单选择工具,再管理对应配置;当前包含 WebSearch 和 OCR
|
||||
- `/ai` 独立承载 LLM Provider、AI Tool 配置和 Playground,不再放在 `/settings` 的系统配置分区中
|
||||
- `模型供应商` 分区管理默认 provider、模型、base URL、provider key、本地 `aiprovider` 代理和连接测试;provider 和模型输入使用可输入组合框,models.dev 目录停更时用户仍可手动填新 provider/model
|
||||
- `工具调用` 分区先通过下拉菜单选择工具,再管理对应配置;当前包含 WebSearch 和 OCR
|
||||
- WebSearch 配置包含 provider、搜索 key、base URL、超时、结果数和高级 provider 参数
|
||||
- OCR 配置包含 provider、Base URL、API Key、模型/engine、语言、超时、文件大小上限和输出格式
|
||||
- `测试台` tab 嵌入原 Playground 的真实会话、预设请求和 AI Provider 状态调试
|
||||
- 页面复用 Settings 的单屏 tabs、panel card 和内部滚动样式
|
||||
- `Playground` 分区嵌入原 Playground 的真实会话、预设请求和 AI Provider 状态调试
|
||||
- 页面复用 Settings 的单屏分区、panel card 和内部滚动样式
|
||||
- AI Provider / WebSearch 的连接测试使用 `ConnectionTestInput`,连接器图标固定在 Base URL 输入框末端;WebSearch 未启用时,除开关外的配置项和测试入口都置灰
|
||||
|
||||
旧的 `/settings?tab=ai` 应跳转到 `/ai?tab=providers`。
|
||||
旧的 `/playground` 应跳转到 `/ai?tab=playground`。
|
||||
AI 配置不再挂在 `/settings` 下;`/playground` 应跳转到 `/ai?section=playground`。
|
||||
|
||||
### 3. 业务数据网关
|
||||
|
||||
@@ -342,7 +367,7 @@ Admin 的状态标签统一走 [StatusText](/home/ray/dev/linkong/planet/fronten
|
||||
- endpoint、headers、config 只展示,不在这里编辑。
|
||||
- 需要凭证的采集器提示用户到“采集管理 -> 采集器”维护。
|
||||
|
||||
这个边界很重要:后续不要把自定义数据源编辑、内置 endpoint 覆盖或凭证表单再塞回 `/datasources`。这些配置入口统一放在 `/collection-management?tab=collector_credentials`。
|
||||
这个边界很重要:后续不要把自定义数据源编辑、内置 endpoint 覆盖或凭证表单再塞回 `/datasources`。这些配置入口统一放在 `/collection-management?section=collector_credentials`。
|
||||
|
||||
页面顶部的总进度区域新增 `采集中 N` 标签:
|
||||
|
||||
@@ -355,7 +380,7 @@ Admin 的状态标签统一走 [StatusText](/home/ray/dev/linkong/planet/fronten
|
||||
|
||||
### 采集器设置页
|
||||
|
||||
[Settings.tsx](/home/ray/dev/linkong/planet/frontend/src/admin/pages/PlainResourcePages.tsx) 会按路由进入三种模式:`/settings` 是系统设置,`/earth-content` 是智能星球内容,`/collection-management` 是采集管理。`collector_credentials` tab 当前在 `/collection-management` 下显示为“采集器”。
|
||||
[Settings.tsx](/home/ray/dev/linkong/planet/frontend/src/admin/pages/PlainResourcePages.tsx) 会按路由进入三种模式:`/settings` 是系统设置,`/earth-content` 是智能星球内容,`/collection-management` 是采集管理。`collector_credentials` section 当前在 `/collection-management` 下显示为“采集器”。
|
||||
|
||||
`/settings` 的“系统显示”分区包含 `演示模式` 开关。开启后,智能星球的 OOBE 会忽略“已有当前采集数据”和本地“先浏览”临时跳过状态,直接展示初始化引导;该开关仅用于演示/验收流程,不改变数据源、采集队列或智能星球内容资源配置。
|
||||
|
||||
@@ -368,7 +393,7 @@ Admin 的状态标签统一走 [StatusText](/home/ray/dev/linkong/planet/fronten
|
||||
- 不需要凭证的采集器只显示基础配置:endpoint、默认 endpoint、请求头、timeout、retry。
|
||||
- `BarentsWatch AIS` 使用专用凭证表单。
|
||||
|
||||
连接图标使用内联 `PlugConnectIcon`,视觉语义来自 Tabler `plug-connected`。后续如果控制台重写图标体系,应迁移到 Tabler Icons,而不是继续使用 Ant Design 刷新图标表达连接。
|
||||
连接测试入口使用现有 `Button icon="test"` 图标语义。后续如果控制台重写图标体系,应迁移到现有 lucide / Tactile UI 图标体系,而不是用普通刷新图标表达连接。
|
||||
|
||||
`Client Secret` 的表单语义:
|
||||
|
||||
@@ -393,6 +418,7 @@ Admin 的状态标签统一走 [StatusText](/home/ray/dev/linkong/planet/fronten
|
||||
`/earth-content` 复用 [Settings.tsx](/home/ray/dev/linkong/planet/frontend/src/admin/pages/PlainResourcePages.tsx) 的单屏 tab 容器,但页面责任与系统设置分离:
|
||||
|
||||
- `电视直播` 迁移原直播源配置,继续管理 Earth 媒体面板内容源。
|
||||
- `品牌标识` 管理 Earth HUD 的品牌资源。`Logo 地址` 与 `标题图地址` 使用字段内上传控件,上传按钮保持 `TactileButton` 的 primary 样式;图片拖到对应字段时显示低饱和拖拽反应,避免回到旧的全局“选择资产/上传”工具栏。
|
||||
- `国界精度` 管理 Earth 静态国界资产:provider 状态、低精 fallback、高精 manifest/PMTiles、源配置 JSON 和构建动作。
|
||||
- `地球底图`、`图层资源`、`三维素材`、`新闻锚点策略` 是占位页,只显示模块待接入,不造假接口或假数据。
|
||||
|
||||
@@ -421,6 +447,7 @@ Admin 的状态标签统一走 [StatusText](/home/ray/dev/linkong/planet/fronten
|
||||
4. 不要用 `overflow: hidden` 掩盖结构问题
|
||||
5. 不要为了摘要卡完整显示去压缩主工作区
|
||||
6. 自定义滚动条必须是浮层,不得挤压内容宽度
|
||||
7. Admin shell 依赖 root `height: 100%` 高度链,不在根工作区重新写精确 `100vh`
|
||||
|
||||
详细经验见:
|
||||
|
||||
|
||||
@@ -210,6 +210,10 @@
|
||||
3. 真正滚动节点是否明确 `overflow: auto`
|
||||
4. 中间包装层是否偷偷改了布局语义
|
||||
|
||||
当前 Admin 和 Docs 根 shell 依赖 `html` / `body` / `#root` 的 `height: 100%`
|
||||
链路。不要在这些嵌入式工作区根容器上重新写精确 `100vh` / `100vw`;
|
||||
弹窗、浮层和窄屏安全边界可以继续使用 `calc(100vh - ...)` 作为最大尺寸约束。
|
||||
|
||||
### 5. UI 状态和显示状态不同步
|
||||
|
||||
Earth 相关改动里反复出现:
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
1. 打开 `http://localhost:3000/login`,点击表单下方"注册账户"。
|
||||
2. 在 `/register` 填写:
|
||||
- **用户名**:3–50 位字符,登录时使用
|
||||
- **邮箱**:用于接收验证码,可在账户设置中修改
|
||||
- **邮箱**:用于接收验证码;当前版本如需修改邮箱,请联系管理员在用户管理中维护
|
||||
- **密码**:至少 8 位
|
||||
3. 提交后会跳到验证页,已将 6 位验证码发到你的邮箱。10 分钟内有效。
|
||||
4. 输入验证码,点击"验证并登录"。验证通过后系统会自动写入登录态并跳到控制台。
|
||||
@@ -52,23 +52,23 @@
|
||||
3. 收到验证码后,在下一步填入验证码 + 新密码(至少 8 位),点击"重置密码"。
|
||||
4. 系统会跳回 `/login`,用新密码登录即可。
|
||||
|
||||
## 账户设置
|
||||
## 账户区与退出
|
||||
|
||||
控制台右上角点击你的用户名进入账户设置,可以:
|
||||
控制台左侧底部的账户区会显示当前用户名、版本号和主题切换。当前版本还没有登录后的自助账户设置页:
|
||||
|
||||
- 修改密码:输入当前密码 + 新密码
|
||||
- 修改邮箱:输入新邮箱后系统会发验证码到新地址,验证通过后才生效
|
||||
- 查看权限组:列出你目前拥有的 Gatekeeper 权限组(`docs_user` / `docs_developer` / `docs_admin`)
|
||||
- 登出:清除当前会话
|
||||
- 忘记密码或需要重置密码时,使用 `/forgot-password` 邮件验证码流程
|
||||
- 邮箱、角色和 Gatekeeper 权限组由管理员在 `/users` 维护
|
||||
- 点击账户区的退出图标会清除当前会话并返回登录页
|
||||
|
||||
## 控制台总览
|
||||
|
||||
控制台 `http://localhost:3000/admin` 使用 React + Ant Design,左侧菜单按工作域组织。
|
||||
控制台 `http://localhost:3000/admin` 使用 React + Tactile UI / Radix 基础组件和 lucide 图标,左侧菜单按工作域组织。
|
||||
|
||||
| 页面 | 路由 | 用途 |
|
||||
| --- | --- | --- |
|
||||
| 仪表盘 | `/admin` | 系统概览 |
|
||||
| 智能星球 | `/earth` | 跳到公开智能星球页面 |
|
||||
| 文档 | `/docs` | 打开文档站并按 Gatekeeper 权限查看可见文档 |
|
||||
| 数据源 | `/datasources` | 数据源目录、触发采集 |
|
||||
| 采集数据 | `/data` | 已落库的数据 |
|
||||
| BGP 观测 | `/bgp` | BGP 专题观测 |
|
||||
@@ -86,7 +86,7 @@
|
||||
|
||||
## 配置数据采集器
|
||||
|
||||
`/collection-management?tab=collector_credentials` 是"采集器"页。这里统一维护所有采集器的连接配置,不仅是凭证。旧链接 `/settings?tab=collector_credentials` 会自动跳转到这个入口;数据源目录仍保留在 `/datasources`。
|
||||
`/collection-management?section=collector_credentials` 是"采集器"页。这里统一维护所有采集器的连接配置,不仅是凭证;数据源目录仍保留在 `/datasources`。
|
||||
|
||||
操作步骤:
|
||||
|
||||
@@ -130,7 +130,7 @@
|
||||
|
||||
操作步骤:
|
||||
|
||||
1. `/collection-management?tab=collector_credentials` 选择 `AISStream 实时船舶 : aisstream_vessels`
|
||||
1. `/collection-management?section=collector_credentials` 选择 `AISStream 实时船舶 : aisstream_vessels`
|
||||
2. 在 `AISStream 凭证` 填入 API Key
|
||||
3. Endpoint 保持默认 `wss://stream.aisstream.io/v0/stream`
|
||||
4. 点击插头图标进行连接测试,确认显示 `可用`
|
||||
@@ -144,11 +144,12 @@
|
||||
|
||||
## 配置 AI 凭证
|
||||
|
||||
`/ai?tab=providers` 是 AI 模型管理入口。包含三个核心子 tab:
|
||||
`/ai?section=integrations` 是 AI 模型管理入口。主要分区包括:
|
||||
|
||||
- `模型供应商`:默认 LLM provider、模型、Base URL、API Key、本地 `aiprovider` 代理和连接测试
|
||||
- `工具`:通过下拉菜单选择具体工具,当前支持 WebSearch 和 OCR
|
||||
- `工具调用`:通过下拉菜单选择具体工具,当前支持 WebSearch 和 OCR
|
||||
- `提示词`:通过功能入口下拉菜单选择新闻汉化、告警研判、BGP 简报等 LLM 任务,手动调整提示词或重置为缺省
|
||||
- `Playground`:真实会话、预设请求和 AI Provider 状态调试
|
||||
|
||||
### 模型供应商
|
||||
|
||||
@@ -173,7 +174,7 @@ Base URL 输入框尾端的插头图标会触发连接测试。测试通过会
|
||||
|
||||
选择功能入口后,页面会显示当前提示词、是否已自定义、缺省版本和重置按钮。保存只影响该功能入口;重置会恢复当前发布包中的缺省提示词。业务事实、上下文和输出 schema 仍由后端按功能入口自动传入。
|
||||
|
||||
旧链接 `/settings?tab=ai` 会跳到 `/ai?tab=providers`。
|
||||
旧的 AI 配置入口不再放在系统设置里;`/playground` 会跳到 `/ai?section=playground`。
|
||||
|
||||
## 系统设置
|
||||
|
||||
@@ -190,9 +191,10 @@ Base URL 输入框尾端的插头图标会触发连接测试。测试通过会
|
||||
|
||||
`/earth-content` 位于控制台“运维与配置”下,面向智能星球前端体验资源:
|
||||
|
||||
- **品牌资源**:维护智能星球 HUD 使用的 logo、标题图、标题文本、副标题和描述;上传的图片会保存为智能星球品牌资产并立即供智能星球页面读取。
|
||||
- **品牌资源**:维护智能星球 HUD 使用的 logo、标题图、标题文本、副标题和描述。`Logo 地址` 和 `标题图地址` 字段内各有独立的“上传”按钮,也可以把图片直接拖到对应字段;上传成功后字段会写入新的资产地址,保存品牌配置后供智能星球页面读取。
|
||||
- **关于**:维护智能星球设置面板里的关于卡片,包括 logo、眉标、标题、版本、描述和元信息。
|
||||
- **电视直播**:维护智能星球媒体面板里的直播源。
|
||||
- **新闻内容**:按 RSS 来源和手动新闻组查看新闻。RSS 新闻保持只读;手动新闻组可以新增、批量导入 JSON、编辑、删除和重新处理。
|
||||
- **国界精度**:查看当前国界 provider、低精 fallback、高精 PMTiles/manifest 状态,编辑本机源配置并手动构建。
|
||||
- **地球底图**、**图层资源**、**三维素材**、**新闻锚点策略**:目前是待接入占位页,不展示假数据。
|
||||
|
||||
@@ -243,7 +245,7 @@ Base URL 输入框尾端的插头图标会触发连接测试。测试通过会
|
||||
|
||||
## AI 测试台
|
||||
|
||||
`/ai?tab=playground` 用于真实分析链路调试。可以:
|
||||
`/ai?section=playground` 用于真实分析链路调试。可以:
|
||||
|
||||
- 选择当前 provider
|
||||
- 用预设请求或自定义 prompt 触发分析
|
||||
@@ -297,7 +299,7 @@ AIS 船只图例按船型显示颜色:货轮、油轮、客船、渔船、军
|
||||
|
||||
### 设置
|
||||
|
||||
设置面板按分类组织:运行、显示、面板、动捕、快捷键、系统。里面包含旋转模式 / 巡航模式 / 动捕模式、巡航模块(BGP/新闻/算力中心/船只/海缆/卫星)、视图设置(卫星显示风格、悬停提示、卫星呼吸闪烁、真实卫星高度、轨迹显示、低缩放圆点、日夜模式、面板显示开关)、动捕调试模式 / 输入源 / 只显示骨骼、快捷键启用与改键、地球默认大小、地形透明度、重置设置。
|
||||
设置面板按分类组织:运行、显示、面板、动捕、快捷键、系统。里面包含旋转模式 / 巡航模式 / 动捕模式、巡航模块(BGP/新闻/算力中心/船只/海缆/卫星)、视图设置(卫星显示风格、悬停提示、卫星呼吸闪烁、真实卫星高度、轨迹显示、低缩放圆点、日夜模式、面板显示开关)、动捕调试模式 / 输入源 / 只显示骨骼 / 识别动作白名单、快捷键启用与改键、地球默认大小、地形透明度、重置设置。
|
||||
|
||||
新闻类型使用与巡航模块一致的标签选择器,只筛选当前浏览器里的新闻面板和新闻巡航条目,不影响图层、TV、数据点、底图、边界、采集任务或后台新闻源配置。
|
||||
|
||||
@@ -335,6 +337,8 @@ AIS 船只图例按船型显示颜色:货轮、油轮、客船、渔船、军
|
||||
|
||||
两种模式都不会把摄像头帧或实时手势发到云端,也不会复用新闻/RSS 聚合接口。
|
||||
|
||||
“识别动作”可以单独关闭旋转、缩放、焦点切换、图层切换或确认手势。浏览器端会忽略未勾选动作;使用 Motion Agent 时,同一白名单也会通过控制协议同步给 Agent。
|
||||
|
||||
手势语义:
|
||||
|
||||
| 手势事件 | 作用 |
|
||||
|
||||
@@ -28,6 +28,9 @@
|
||||
| AI Provider | AI Provider | 服务名,保留英文 |
|
||||
| tool | 工具 | Web Search、OCR 等工具配置 |
|
||||
| Playground | Playground | 交互调试入口,保留英文 |
|
||||
| branding | 品牌标识 | `/earth-content` 中的 Earth HUD 品牌配置分区 |
|
||||
| brand assets | 品牌资源 | Logo、标题图和相关 HUD 文案资源 |
|
||||
| title image | 标题图 | Earth HUD 标题图片,不写作“标题图片地址”以外的混合名 |
|
||||
|
||||
## 数据类型
|
||||
|
||||
|
||||
@@ -258,22 +258,27 @@ HTTP 健康检查统一使用 `curl -fsS --max-time`。因此 `/health` 返回 4
|
||||
|
||||
前端生产形态是 `vite build` 生成静态资源,再由 nginx/Caddy 等 HTTP 服务器托管。不要在生产中使用 `bun run dev` 或 `vite preview`。当前不维护 Webpack 双构建链;如果未来需要评估更企业化的构建生态,优先做 Rsbuild/Rspack spike。Electron 仅在正式目标变成离线桌面软件时再单独评估。
|
||||
|
||||
## Motion Agent 可选启动
|
||||
## Motion Agent 默认启动
|
||||
|
||||
`planet.sh` 现在可以管理本地动作捕捉 Agent,但默认不会启动它,避免普通开发机因为没有摄像头、OpenCV 或 MediaPipe 而影响后端/前端启动。
|
||||
`planet.sh` 现在默认随 `start` 和全量 `restart` 启动本地 Motion Agent。这样星球端、UE 或调试客户端可以直接连接 `ws://127.0.0.1:8765/ws/gestures`。如果当前机器没有可用摄像头,默认隐式启动会降级为 dry-run 协议服务,不会阻断后端/前端启动;只有显式传入 `--motion-agent`、摄像头 index、摄像头 URL 或 WSL USB 参数时,live 模式缺摄像头才会硬失败。
|
||||
|
||||
启动方式:
|
||||
如果本次不需要 Motion Agent:
|
||||
|
||||
```bash
|
||||
./planet.sh start --motion-agent
|
||||
./planet.sh start --non-motion-agent
|
||||
./planet.sh restart --non-motion-agent
|
||||
```
|
||||
|
||||
常用参数:
|
||||
|
||||
- `--motion-agent` / `-m`:随本次启动或重启拉起 Motion Agent。
|
||||
- `--non-motion-agent`:本次启动或全量重启不拉起 Motion Agent。
|
||||
- `--motion-agent` / `-m`:显式要求本次启动或重启拉起 Motion Agent;此时 live 摄像头失败会作为错误反馈。
|
||||
- `--motion-agent-port <端口>`:覆盖默认 WebSocket 端口 `8765`。
|
||||
- `--motion-agent-mode <模式>`:指定输入模式,可选 `auto`、`single`、`dual_redundant`、`single_fallback`、`calibrated_3d`;`dual` 作为兼容别名会进入双路冗余。
|
||||
- `--motion-agent-camera-indexes <indexes>`:覆盖自动发现的摄像头 index,例如 `0` 或 `0,1`。也可以用环境变量 `MOTION_AGENT_CAMERA_INDEXES=0,1`。
|
||||
- `--motion-agent-camera-urls <urls>`:使用 RTSP/HTTP 摄像头流,适合 WSL、手机摄像头或网络摄像头。也可以用环境变量 `MOTION_AGENT_CAMERA_URLS=...`。
|
||||
- `--motion-agent-wsl-usbipd`:在 WSL 中尝试通过 `usbipd-win` 自动把唯一的 Windows USB 摄像头透传到 Linux。
|
||||
- `--motion-agent-wsl-usbipd-busid <BUSID>`:在 WSL 中指定 `usbipd list` 里的摄像头 BUSID 后透传,适合多摄像头设备。
|
||||
- `--motion-agent-dry-run`:不打开摄像头、不加载 CV 依赖,只启动协议服务,适合调试 Web 端连接。
|
||||
|
||||
非 dry-run 的 live 模式会在启动前检查 `mediapipe` 和 `opencv-python`。如果当前 `.venv` 缺包,脚本会自动执行:
|
||||
@@ -282,13 +287,17 @@ HTTP 健康检查统一使用 `curl -fsS --max-time`。因此 `/health` 返回 4
|
||||
uv add mediapipe opencv-python
|
||||
```
|
||||
|
||||
如需禁止启动时自动安装,可设置:
|
||||
如需禁止启动时自动安装 Python CV 依赖,可设置:
|
||||
|
||||
```bash
|
||||
PLANET_MOTION_AGENT_AUTO_INSTALL=0 ./planet.sh start --motion-agent
|
||||
PLANET_MOTION_AGENT_AUTO_INSTALL=0 ./planet.sh start
|
||||
```
|
||||
|
||||
live 模式会自动寻找 `/dev/video*`,优先取前两个 index 传给 Motion Agent。在 WSL 中,Windows 摄像头通常不会自动出现在 `/dev/video*`。可先用下面命令看设备:
|
||||
`./planet.sh init` 会在 WSL 中预检查 `usbipd-win`。如果没有 `usbipd.exe`,脚本会先尝试 `winget install -e --id dorssel.usbipd-win`,失败后复用仓库内置的 dorssel.usbipd-win MSI fallback;如果缓存缺失或需要其他架构版本,再下载 MSI 并请求管理员 PowerShell 安装。该步骤是 best-effort:失败会提示后续处理方式,但不会阻断普通初始化。可通过 `./planet.sh init --non-motion-agent` 跳过该预检。
|
||||
|
||||
live 模式会自动寻找 `/dev/video*`,并优先用 OpenCV 实测过滤出真正能打开并读帧的 index,再传给 Motion Agent。在 WSL/USB 摄像头场景中,一个摄像头可能暴露多个 `/dev/video*` 节点,其中部分是 metadata 或非采集节点,脚本会跳过这类不可读 index。在 WSL 中,Windows 摄像头通常不会自动出现在 `/dev/video*`。可先用下面命令看设备:
|
||||
|
||||
默认 live 采集使用低延迟参数:`640x360` 输入、约 `15Hz` 识别事件;worker 内部用 latest-frame 读帧线程,只保留每路摄像头的最新帧,避免 MediaPipe 慢帧时继续排队识别旧画面。骨架调试流默认关闭,只在星球端打开动捕调试面板时按约 `8Hz` 推送,避免日常手势控制被调试数据拖慢。状态事件会同时上报采集 FPS 与识别 FPS,方便区分摄像头掉帧和识别耗时。
|
||||
|
||||
```bash
|
||||
ls /dev/video*
|
||||
@@ -306,20 +315,42 @@ WSL 下更通用的方式是把手机摄像头或网络摄像头以 RTSP/HTTP
|
||||
./planet.sh start --motion-agent --motion-agent-camera-urls http://192.168.1.20:8080/video
|
||||
```
|
||||
|
||||
如果 WSL 中没有发现 `/dev/video*`,且没有提供 `--motion-agent-camera-urls`,脚本会停止 live 启动并提示处理方式,不会自动降级为 dry-run。可选处理:
|
||||
如果希望直接使用 Windows USB 摄像头,可以让脚本调用 `usbipd-win` 透传。该能力是显式开启的,因为摄像头附加到 WSL 期间通常会从 Windows 应用中暂时断开。
|
||||
|
||||
只有一个摄像头时:
|
||||
|
||||
```bash
|
||||
./planet.sh start --motion-agent --motion-agent-wsl-usbipd
|
||||
```
|
||||
|
||||
多个摄像头时,先查看 BUSID,再指定设备:
|
||||
|
||||
```bash
|
||||
usbipd.exe list
|
||||
./planet.sh start --motion-agent --motion-agent-wsl-usbipd-busid 3-2
|
||||
```
|
||||
|
||||
如果 `usbipd attach` 提示设备未共享或未绑定,脚本会尝试弹出 Windows 管理员 PowerShell 自动执行 `usbipd bind`,然后重试 attach。若 UAC 被取消或自动 bind 失败,可在 Windows 管理员 PowerShell 中手动执行:
|
||||
|
||||
```powershell
|
||||
usbipd bind --busid 3-2
|
||||
usbipd attach --wsl --busid 3-2
|
||||
```
|
||||
|
||||
如果 WSL 中没有发现 `/dev/video*`,且没有提供 `--motion-agent-camera-urls`,默认隐式启动会降级为 dry-run。显式 live 启动会停止并提示处理方式。可选处理:
|
||||
|
||||
```bash
|
||||
./planet.sh start --motion-agent --motion-agent-camera-urls http://<手机IP>:8080/video
|
||||
./planet.sh start --motion-agent --motion-agent-wsl-usbipd
|
||||
./planet.sh start --motion-agent --motion-agent-dry-run
|
||||
```
|
||||
|
||||
只有显式设置 `PLANET_MOTION_AGENT_WSL_ALLOW_DRY_RUN_FALLBACK=1` 时,WSL 无摄像头才会自动降级。
|
||||
显式 live 启动时,只有设置 `PLANET_MOTION_AGENT_WSL_ALLOW_DRY_RUN_FALLBACK=1`,WSL 无摄像头才会自动降级。
|
||||
|
||||
也可以用环境变量启用:
|
||||
`--non-motion-agent` 是命令级跳过入口。环境变量仍可调整服务启动方式:
|
||||
|
||||
```bash
|
||||
PLANET_START_MOTION_AGENT=1 ./planet.sh start
|
||||
MOTION_AGENT_DRY_RUN=1 PLANET_START_MOTION_AGENT=1 ./planet.sh start
|
||||
MOTION_AGENT_DRY_RUN=1 ./planet.sh start
|
||||
```
|
||||
|
||||
日志入口:
|
||||
|
||||
@@ -2,6 +2,48 @@
|
||||
|
||||
这份手册面向部署、值班和二次开发的运维人员。客户面向的 UI 使用流程见 [智能星球使用手册](/home/ray/dev/linkong/planet/docs/technical/zh/manual.md),本手册只覆盖 shell、Docker、日志、环境变量和故障排查。
|
||||
|
||||
## Docker 初始化与访问权限
|
||||
|
||||
新机器应先执行初始化,再启动应用服务:
|
||||
|
||||
```bash
|
||||
zsh ./planet.sh init --non-motion-agent && zsh ./planet.sh start --non-motion-agent
|
||||
```
|
||||
|
||||
脚本入口仍需先安装 `zsh`、`curl`,并保证软件源可访问。`init` 在同步 Python 和前端依赖之前准备 Docker:
|
||||
|
||||
- 已有可用的 Docker、Compose v2 和 Buildx(至少 0.17.0)时直接复用。
|
||||
- Ubuntu / Ubuntu WSL 缺少依赖时,通过 apt 安装 `docker.io`、`docker-compose-v2`、`docker-buildx` 中缺失的部分。若已安装 Docker CE CLI,则使用已配置的 Docker CE 软件源和对应插件包,避免混用软件包系列。
|
||||
- 本地 Docker daemon 未运行时,确认 `docker.service` 存在后启用并启动它。WSL 必须启用 systemd;如果服务管理不可用,脚本会在 Docker 准备阶段明确报错。
|
||||
- 当前用户不能读写 Docker socket 时,检查并补装提供 `usermod` 的 `passwd` 包,将用户加入 `docker` 组。该组拥有管理本机 Docker 的高权限。脚本使用 `sudo` 以原用户身份刷新组权限并继续原命令,保留参数,不依赖 `sg`,也不会把应用进程改为 root 用户运行。
|
||||
|
||||
需要提权时,脚本会在前台请求 sudo 认证。普通用户缺少 sudo、认证失败、软件源不可用或安装后版本仍不满足要求时,初始化会停止并报告具体原因。
|
||||
|
||||
同一旧终端随后执行 `planet.sh start` 等命令时,也会检测已加入但尚未生效的 Docker 组权限并刷新。若要在终端直接使用 `docker`,重新打开 Ubuntu 会话即可。
|
||||
|
||||
Docker Desktop 已存在但 WSL 集成不可用时,脚本提示启动 Desktop 并启用当前发行版的 WSL Integration。已有远程或 rootless endpoint 无法连接时,提示检查当前环境;这些情况不会自动安装另一套本地引擎。其他操作系统的自动安装暂未支持。
|
||||
|
||||
安装逻辑由 `planet.sh` 调用 `scripts/lib/docker-bootstrap.zsh`;缺少 CLI、没有服务单元、socket 权限不足和 daemon 未启动会分别诊断。仅在确认 `docker.socket` 单元存在时才给出启动该单元的建议。验证准备结果可执行:
|
||||
|
||||
```bash
|
||||
docker info
|
||||
docker compose version
|
||||
docker buildx version
|
||||
```
|
||||
|
||||
## 数据库初始化与连接检查
|
||||
|
||||
`init` 会先通过 Compose 同步 PostgreSQL / Redis 容器配置,包括已有容器的端口映射;仅执行 `docker start` 无法应用配置变化。Compose 同步失败时会保留具体错误,例如端口被占用,不会继续复用旧容器并报告成功。
|
||||
|
||||
容器内部的 `pg_isready` 只检查服务是否接受连接,不能证明宿主机上的后端使用正确地址和密码。容器健康后,`init` 通过 `scripts/check_database_connection.py` 读取与后端相同的有效 `DATABASE_URL`,检查本地 PostgreSQL 的实际发布端口并执行只读 `SELECT 1`;通过后才显示“数据库服务已就绪”并创建表和默认数据。
|
||||
|
||||
- 如果本地实际端口映射仍缺失或不匹配,脚本会保留数据卷,按 Compose 配置重建一次 PostgreSQL 并重新检查;再次失败就停止。
|
||||
- 认证、库名或网络错误会在建表前停止,诊断只显示目标主机、端口和库名,不输出密码、完整连接串或驱动异常原文。
|
||||
- 进程环境变量中的 `DATABASE_URL` 优先于 `backend/.env`。单独修改 `POSTGRES_PASSWORD` 不会自动更新连接串,也不会改变已有数据卷内的密码。已有环境文件会保留,需要核对其有效配置。
|
||||
- 显式配置的外部数据库不要求本地容器端口匹配;host 网络模式也不要求发布端口,两者仍须通过实际连接检查。
|
||||
|
||||
出现 `port is already allocated` 或 `address already in use` 时,检查 `docker ps` 的端口信息和 `ss -ltnp '( sport = :5432 )'`;WSL 镜像网络下还需检查 Windows 侧监听。初始化不会为了占用数据库端口而自动结束其他数据库服务,也不会删除数据卷或重设密码。
|
||||
|
||||
## 首次启动
|
||||
|
||||
```bash
|
||||
|
||||
@@ -39,7 +39,7 @@ flowchart TB
|
||||
VesselRaw["AIS / BarentsWatch"] --> VesselDerived["vessel_static / vessel_position"]
|
||||
VesselDerived --> VesselLayer["vessels 图层"]
|
||||
Interactables["earth_interactables"] --> InteractableLayer["interactables 图层"]
|
||||
NewsRaw["RSS / Live / News"] --> NewsItems["earth_news_items"]
|
||||
NewsRaw["RSS / 手动新闻 / Live"] --> NewsItems["earth_news_items"]
|
||||
NewsItems --> NewsLayer["news / media 图层"]
|
||||
```
|
||||
|
||||
@@ -51,7 +51,7 @@ flowchart TB
|
||||
| BGP 态势 | 展示观测站、异常事件、路由事件和区域态势 | `ris_live_bgp`、`bgpstream_bgp`、prefix geography sources | `bgp_observations`、`bgp_anomalies`、`bgp_incidents`、`bgp_collector_locations` | `bgp` | `clear_then_reload` |
|
||||
| 船舶 | 展示 AIS 船只、位置、轨迹和源健康 | AIS sources、`barentswatch_vessels` | `vessel_static`、`vessel_position`、`ais_raw_observations`、`ais_source_health` | `vessels` | `clear_then_reload` |
|
||||
| 可交互对象 | 支撑通用地表图标、人工点位和未来扩展对象 | `earth_interactables` | 无 | `interactables` | `delta` |
|
||||
| 新闻与媒体 | 支撑 Earth 新闻、直播和巡航摘要 | news sources | `earth_news_items` | `news` / `media` | `reload` |
|
||||
| 新闻与媒体 | 支撑 Earth 新闻、直播和巡航摘要 | RSS news sources、手动新闻、直播源 | `earth_news_items` | `news` / `media` | `reload` |
|
||||
|
||||
## 卫星链路
|
||||
|
||||
@@ -141,12 +141,12 @@ sequenceDiagram
|
||||
|
||||
新闻与媒体数据用于 Earth 顶部新闻条、直播面板、新闻巡航和态势摘要。它们的视觉状态比地理对象更偏内容刷新,因此默认使用 `reload`。
|
||||
|
||||
- **采集入口**:RSS、直播源、新闻 source。
|
||||
- **事实表**:新闻 source 的 `collected_data`。
|
||||
- **采集入口**:RSS 新闻源、`智能星球内容 -> 新闻内容` 的手动新闻、直播源。
|
||||
- **事实表**:新闻 source 的 `collected_data`;手动新闻直接写入 `earth_news_items`,并以 `feed_type/source_type=manual` 标记内容来源。
|
||||
- **派生表**:`earth_news_items`。
|
||||
- **接口**:新闻、直播和媒体 visualization / content API。
|
||||
- **接口**:`/api/v1/news/earth-feed` 读取 `earth_news_items`;后台管理接口 `/api/v1/earth/news-items` 支持新增、JSON 导入、编辑、删除和重新处理手动新闻。
|
||||
- **删除语义**:删除新闻 source 或 `earth_news_items` 后广播 `news` / `media` reload;前端重拉后列表为空即隐藏对应内容。
|
||||
- **常见异常**:直播面板仍显示旧内容,通常是媒体组件本地状态没有响应 layer update,或内容接口缓存未失效。
|
||||
- **常见异常**:手动新闻保存后只显示原文或大区锚点是正常的“先展示再精修”窗口;若长期不更新,应检查 `earth_news_enrichment` 队列、AI / Web Search 配置和 `enrichment_status`。直播面板仍显示旧内容,通常是媒体组件本地状态没有响应 layer update,或内容接口缓存未失效。
|
||||
|
||||
## 扩展新图层
|
||||
|
||||
|
||||
@@ -32,11 +32,12 @@
|
||||
|
||||
进入 `/admin` 仪表盘后,建议按这个顺序熟悉控制台:
|
||||
|
||||
1. `/collection-management?tab=collector_credentials`:选一个采集器,点插头图标做连接测试。免费 collector(开源 BGP 等)通常直接可用;像 `AISStream`、`BarentsWatch` 这类需要凭证的,需要先填 API Key/Client Secret
|
||||
2. `/ai?tab=providers`:填一个 LLM provider(例如 `minimax` / `openai`)、模型名、Base URL、API Key,点 Base URL 末端的插头测试连接。WebSearch / OCR 工具可选
|
||||
3. `/datasources` 或 `/data`:看采集器是否已经产出数据。有限采集器看 `/datasources -> 内置源`,不勾选时点“触发全部”,勾选后主按钮会变成“触发已选 N”;右上角队列按钮可查看进度。AISStream / WebSocket 长连接看 `/datasources -> 实时源` 的健康状态和计数
|
||||
4. `/alerts/system`:看系统告警是否正常
|
||||
5. `/users`(仅 `super_admin`):根据需要给同事开账号或调权限组
|
||||
1. `/collection-management?section=collector_credentials`:选一个采集器,点插头图标做连接测试。免费 collector(开源 BGP 等)通常直接可用;像 `AISStream`、`BarentsWatch` 这类需要凭证的,需要先填 API Key/Client Secret
|
||||
2. `/ai?section=integrations`:填一个 LLM provider(例如 `minimax` / `openai`)、模型名、Base URL、API Key,点 Base URL 末端的插头测试连接。WebSearch / OCR 工具可选
|
||||
3. `/earth-content?section=brand`:在“品牌标识”里维护智能星球的 Logo 和标题图;对应地址字段内的“上传”按钮支持选择文件,也支持把图片直接拖到字段上,保存后会应用到智能星球 HUD
|
||||
4. `/datasources` 或 `/data`:看采集器是否已经产出数据。有限采集器看 `/datasources -> 内置源`,不勾选时点“触发全部”,勾选后主按钮会变成“触发已选 N”;右上角队列按钮可查看进度。AISStream / WebSocket 长连接看 `/datasources -> 实时源` 的健康状态和计数
|
||||
5. `/alerts/system`:看系统告警是否正常
|
||||
6. `/users`(仅 `super_admin`):根据需要给同事开账号或调权限组
|
||||
|
||||
## 4. 打开智能星球
|
||||
|
||||
@@ -50,7 +51,7 @@
|
||||
- 搜索可以查找海缆、卫星、算力中心、BGP 事件
|
||||
- 算力中心和 BGP 观测站详情卡可以自动采集坐标候选,并能在智能星球上预览
|
||||
- 鼠标拖动、滚轮缩放、缩放百分比提示工作正常
|
||||
- 设置面板的旋转 / 巡航 / 动捕模式可以切换;视图设置里可以切换悬停提示,卫星相关设置里可以打开或关闭真实高度分层和轨迹显示
|
||||
- 设置面板的旋转 / 巡航 / 动捕模式可以切换;动捕设置可以选择输入源和允许识别的动作;视图设置里可以切换悬停提示,卫星相关设置里可以打开或关闭真实高度分层和轨迹显示
|
||||
|
||||
## 5. 找回密码
|
||||
|
||||
|
||||
@@ -16,12 +16,20 @@
|
||||
## Current Version
|
||||
|
||||
- `main` 当前主线历史推导到:`0.16.5`
|
||||
- `dev` 当前开发分支历史推导到:`0.70.0`
|
||||
- `dev` 当前开发分支历史推导到:`0.74.3`
|
||||
|
||||
## Timeline
|
||||
|
||||
| Version | Type | Branch | Commit | Summary |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `0.74.3` | improvement | `dev` | `v0.74.3` | Ubuntu / WSL 初始化自动准备 Docker 及用户权限,修正启动诊断,并在建表前核对数据库端口、实际连接和认证 |
|
||||
| `0.74.2` | bugfix | `dev` | `pending` | 收敛 agent harness 到根规则和 Codex skills,删除旧 Claude command 重复入口,并强化视觉证据路径解析与 OCR fallback 规则 |
|
||||
| `0.74.1` | improvement | `dev` | `pending` | 将品牌标识上传收敛到 Logo/标题图字段内,新增字段级拖拽反馈和 Tactile UI primary 上传按钮,并同步中英文使用文档 |
|
||||
| `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 与双语维护文档 |
|
||||
| `0.71.0` | feature | `dev` | `pending` | Motion Agent 升级为 Web/UE 共用双向控制与真实识别服务,新增 Earth 手动新闻工作流、来源多样化,并完善启动/测试 harness 与双语文档 |
|
||||
| `0.70.0` | feature | `dev` | `pending` | 新增后端枚举契约治理、Earth 新闻分类/Breaking 链路和船只当前状态快照,清理错误视口刷新逻辑并同步双语文档 |
|
||||
| `0.69.0` | feature | `dev` | `pending` | 新增 Earth 新闻源治理、新闻类型服务端过滤、观测日志 fingerprint 聚合和 TV/HLS 播放恢复改进 |
|
||||
| `0.68.1` | bugfix | `dev` | `pending` | 修复 CelesTrak fallback group/cache 恢复链路,并让数据源任务日志可按 task_id / datasource_id 搜索 |
|
||||
|
||||
BIN
downloads/usbipd-win/usbipd-win-5.3.0.msi
Normal file
@@ -21,6 +21,7 @@
|
||||
"clsx": "^2.1.1",
|
||||
"dayjs": "^1.11.10",
|
||||
"echarts": "^6.0.0",
|
||||
"i18next": "26.3.3",
|
||||
"lucide-react": "^1.16.0",
|
||||
"mermaid": "^11.15.0",
|
||||
"pbf": "^4.0.1",
|
||||
@@ -29,6 +30,7 @@
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hook-form": "^7.76.0",
|
||||
"react-i18next": "17.0.8",
|
||||
"react-resizable": "^3.1.3",
|
||||
"react-router-dom": "^6.21.0",
|
||||
"simplex-noise": "^4.0.1",
|
||||
@@ -84,6 +86,8 @@
|
||||
|
||||
"@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="],
|
||||
|
||||
"@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="],
|
||||
|
||||
"@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="],
|
||||
|
||||
"@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="],
|
||||
@@ -556,6 +560,10 @@
|
||||
|
||||
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
|
||||
|
||||
"html-parse-stringify": ["html-parse-stringify@3.0.1", "", { "dependencies": { "void-elements": "3.1.0" } }, "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg=="],
|
||||
|
||||
"i18next": ["i18next@26.3.3", "", { "peerDependencies": { "typescript": "^5 || ^6" }, "optionalPeers": ["typescript"] }, "sha512-aYVegyBdXSO93CMMihvr47jI7GHSOcIahMpJX+qzUXDzW4xDJf2uenIA+45vDU+YhiVdcfsql70AC9RVdMNrHg=="],
|
||||
|
||||
"iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
|
||||
|
||||
"import-meta-resolve": ["import-meta-resolve@4.2.0", "", {}, "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg=="],
|
||||
@@ -632,6 +640,8 @@
|
||||
|
||||
"react-hook-form": ["react-hook-form@7.76.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-eKtLGgFeSgkHqQD8J59AMZ9a4uD1D83iSIzt4YlTGD7liDen5rrjcUO1rVIGd9yC1gofryjtHbv+4ny4hkLWlw=="],
|
||||
|
||||
"react-i18next": ["react-i18next@17.0.8", "", { "dependencies": { "@babel/runtime": "^7.29.2", "html-parse-stringify": "^3.0.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "i18next": ">= 26.2.0", "react": ">= 16.8.0", "typescript": "^5 || ^6" }, "optionalPeers": ["typescript"] }, "sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw=="],
|
||||
|
||||
"react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
|
||||
|
||||
"react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="],
|
||||
@@ -702,6 +712,8 @@
|
||||
|
||||
"vite": ["vite@5.4.21", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": "bin/vite.js" }, "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw=="],
|
||||
|
||||
"void-elements": ["void-elements@3.1.0", "", {}, "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w=="],
|
||||
|
||||
"ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="],
|
||||
|
||||
"xmlhttprequest-ssl": ["xmlhttprequest-ssl@2.1.2", "", {}, "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ=="],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "planet-frontend",
|
||||
"version": "0.70.0",
|
||||
"version": "0.74.3",
|
||||
"private": true,
|
||||
"packageManager": "bun@1",
|
||||
"dependencies": {
|
||||
@@ -20,6 +20,7 @@
|
||||
"clsx": "^2.1.1",
|
||||
"dayjs": "^1.11.10",
|
||||
"echarts": "^6.0.0",
|
||||
"i18next": "26.3.3",
|
||||
"lucide-react": "^1.16.0",
|
||||
"mermaid": "^11.15.0",
|
||||
"pbf": "^4.0.1",
|
||||
@@ -28,6 +29,7 @@
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hook-form": "^7.76.0",
|
||||
"react-i18next": "17.0.8",
|
||||
"react-resizable": "^3.1.3",
|
||||
"react-router-dom": "^6.21.0",
|
||||
"simplex-noise": "^4.0.1",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -254,19 +254,31 @@
|
||||
.earth-toolbar-btn .icon,
|
||||
.earth-toolbar-hub-btn .material-symbols-rounded {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
z-index: 4;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.earth-toolbar-btn .icon {
|
||||
.earth-toolbar-btn .icon,
|
||||
.earth-toolbar-hub-btn .material-symbols-rounded,
|
||||
.earth-zoom-btn > span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: transform 0.16s ease, opacity 0.16s ease;
|
||||
color: rgba(243, 244, 246, 0.9);
|
||||
text-shadow: 0 1.5px 3px rgba(0, 0, 0, 0.5);
|
||||
transition:
|
||||
transform 0.4s cubic-bezier(0.16, 1, 0.3, 1),
|
||||
color 0.3s ease,
|
||||
opacity 0.16s ease;
|
||||
backface-visibility: hidden;
|
||||
-webkit-backface-visibility: hidden;
|
||||
}
|
||||
|
||||
.earth-zoom-btn > span {
|
||||
position: relative;
|
||||
z-index: 4;
|
||||
}
|
||||
|
||||
.earth-toolbar-btn .material-symbols-rounded,
|
||||
.earth-toolbar-hub-btn .material-symbols-rounded {
|
||||
font-size: calc(21px * var(--toolbar-scale));
|
||||
@@ -299,22 +311,34 @@
|
||||
--btn-scale: 1;
|
||||
--press-offset: 0px;
|
||||
--float-offset: 0px;
|
||||
--mouse-x: 0.5;
|
||||
--mouse-y: 0.5;
|
||||
--toolbar-atmosphere-color: rgba(var(--toolbar-light-rgb, 91, 186, 255), 0.25);
|
||||
--toolbar-atmosphere-hover: rgba(var(--toolbar-light-rgb, 91, 186, 255), 0.55);
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
transform-style: preserve-3d;
|
||||
transform-origin: center center;
|
||||
will-change: transform, box-shadow;
|
||||
z-index: 2;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
border: none;
|
||||
outline: none;
|
||||
background:
|
||||
radial-gradient(circle at 32% 22%, rgba(255, 255, 255, 0.085), transparent 38%),
|
||||
rgba(255, 255, 255, var(--toolbar-glass-opacity));
|
||||
radial-gradient(
|
||||
circle at 35% 30%,
|
||||
rgba(255, 255, 255, 0.16) 0%,
|
||||
rgba(255, 255, 255, 0.02) 40%,
|
||||
rgba(15, 23, 42, 0.35) 75%,
|
||||
rgba(3, 7, 18, 0.85) 100%
|
||||
);
|
||||
box-shadow:
|
||||
0 8px 28px rgba(0, 0, 0, 0.38),
|
||||
inset 0 1.5px 2px rgba(255, 255, 255, 0.22),
|
||||
inset 0 -1.5px 2px rgba(0, 0, 0, 0.28);
|
||||
backdrop-filter: blur(var(--toolbar-glass-blur, 16px)) saturate(108%) brightness(0.92);
|
||||
-webkit-backdrop-filter: blur(var(--toolbar-glass-blur, 16px)) saturate(108%) brightness(0.92);
|
||||
0 8px 24px -4px rgba(0, 0, 0, 0.65),
|
||||
inset 0 0 1.5px 1.2px rgba(255, 255, 255, 0.15),
|
||||
inset 0 1px 0.5px 0.2px rgba(255, 255, 255, 0.45),
|
||||
inset 0 -3px 8px rgba(255, 255, 255, 0.03),
|
||||
inset 0 6px 12px rgba(255, 255, 255, 0.06);
|
||||
backdrop-filter: blur(var(--toolbar-glass-blur, 16px)) saturate(130%);
|
||||
-webkit-backdrop-filter: blur(var(--toolbar-glass-blur, 16px)) saturate(130%);
|
||||
|
||||
transform:
|
||||
translate3d(
|
||||
@@ -325,121 +349,182 @@
|
||||
scale(var(--btn-scale));
|
||||
|
||||
transition:
|
||||
transform 0.22s ease,
|
||||
box-shadow 0.22s ease,
|
||||
background 0.22s ease,
|
||||
transform 0.4s cubic-bezier(0.16, 1, 0.3, 1),
|
||||
box-shadow 0.4s cubic-bezier(0.16, 1, 0.3, 1),
|
||||
background 0.4s cubic-bezier(0.16, 1, 0.3, 1),
|
||||
opacity 0.18s ease;
|
||||
}
|
||||
|
||||
.liquid-glass-surface::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
top: 4%;
|
||||
left: 15%;
|
||||
width: 70%;
|
||||
height: 32%;
|
||||
border-radius: 50% 50% 45% 45% / 65% 65% 35% 35%;
|
||||
background:
|
||||
radial-gradient(circle at 32% 20%, rgba(255, 255, 255, 0.13), transparent 34%),
|
||||
radial-gradient(circle at 54% 54%, rgba(var(--toolbar-light-rgb, 91, 186, 255), 0.035), transparent 58%);
|
||||
linear-gradient(
|
||||
to bottom,
|
||||
rgba(255, 255, 255, 0.38) 0%,
|
||||
rgba(255, 255, 255, 0.1) 50%,
|
||||
rgba(255, 255, 255, 0) 100%
|
||||
);
|
||||
filter: blur(0.4px);
|
||||
opacity: 1;
|
||||
pointer-events: none;
|
||||
transform: translate3d(calc(var(--elastic-x) * 0.08), calc(var(--elastic-y) * 0.08), 0);
|
||||
transition: opacity 0.18s ease, transform 0.18s ease;
|
||||
transform:
|
||||
translate(
|
||||
calc((var(--mouse-x, 0.5) - 0.5) * 5px),
|
||||
calc((var(--mouse-y, 0.5) - 0.5) * 3px)
|
||||
);
|
||||
transform-origin: top center;
|
||||
transition: transform 0.25s ease-out;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.liquid-glass-surface::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
bottom: -10%;
|
||||
left: 12%;
|
||||
width: 76%;
|
||||
height: 35%;
|
||||
border-radius: 50%;
|
||||
background:
|
||||
radial-gradient(circle at 50% 50%, transparent 58%, rgba(0, 0, 0, 0.1) 100%);
|
||||
box-shadow:
|
||||
inset 0 0 0 0.5px rgba(255, 255, 255, 0.06);
|
||||
opacity: 1;
|
||||
radial-gradient(
|
||||
ellipse at bottom,
|
||||
var(--toolbar-atmosphere-color) 0%,
|
||||
rgba(var(--toolbar-light-rgb, 91, 186, 255), 0.02) 70%,
|
||||
rgba(0, 0, 0, 0) 100%
|
||||
);
|
||||
filter: blur(1px);
|
||||
opacity: 0.9;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.18s ease, box-shadow 0.18s ease;
|
||||
transform:
|
||||
translate(
|
||||
calc((var(--mouse-x, 0.5) - 0.5) * -3px),
|
||||
calc((var(--mouse-y, 0.5) - 0.5) * -2px)
|
||||
);
|
||||
transition:
|
||||
transform 0.25s ease-out,
|
||||
opacity 0.3s ease;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.earth-toolbar-hub-btn.liquid-glass-surface {
|
||||
background:
|
||||
radial-gradient(circle at 32% 22%, rgba(255, 255, 255, 0.095), transparent 40%),
|
||||
rgba(255, 255, 255, 0.07);
|
||||
radial-gradient(
|
||||
circle at 35% 30%,
|
||||
rgba(255, 255, 255, 0.18) 0%,
|
||||
rgba(255, 255, 255, 0.025) 42%,
|
||||
rgba(15, 23, 42, 0.32) 74%,
|
||||
rgba(3, 7, 18, 0.82) 100%
|
||||
);
|
||||
box-shadow:
|
||||
0 9px 30px rgba(0, 0, 0, 0.4),
|
||||
inset 0 1.5px 2px rgba(255, 255, 255, 0.24),
|
||||
inset 0 -1.5px 2px rgba(0, 0, 0, 0.3);
|
||||
0 9px 26px -4px rgba(0, 0, 0, 0.68),
|
||||
inset 0 0 1.8px 1.3px rgba(255, 255, 255, 0.17),
|
||||
inset 0 1px 0.5px 0.2px rgba(255, 255, 255, 0.5),
|
||||
inset 0 -3px 8px rgba(255, 255, 255, 0.04),
|
||||
inset 0 7px 13px rgba(255, 255, 255, 0.07);
|
||||
}
|
||||
|
||||
.liquid-glass-surface:hover {
|
||||
--btn-scale: 1.03;
|
||||
--press-offset: -1px;
|
||||
--btn-scale: 1.05;
|
||||
--press-offset: -3px;
|
||||
background:
|
||||
radial-gradient(circle at 32% 22%, rgba(255, 255, 255, 0.14), transparent 40%),
|
||||
radial-gradient(circle at 52% 52%, rgba(var(--toolbar-light-rgb, 91, 186, 255), 0.12), transparent 62%),
|
||||
rgba(255, 255, 255, 0.085);
|
||||
border-color: rgba(var(--toolbar-light-rgb, 91, 186, 255), 0.42);
|
||||
radial-gradient(
|
||||
circle at 35% 30%,
|
||||
rgba(255, 255, 255, 0.22) 0%,
|
||||
rgba(255, 255, 255, 0.04) 40%,
|
||||
rgba(15, 23, 42, 0.25) 75%,
|
||||
rgba(3, 7, 18, 0.8) 100%
|
||||
);
|
||||
box-shadow:
|
||||
0 12px 40px rgba(0, 0, 0, 0.44),
|
||||
inset 0 1.5px 3px rgba(255, 255, 255, 0.36),
|
||||
inset 0 -1.5px 3px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.liquid-glass-surface:hover::before {
|
||||
opacity: 1;
|
||||
background:
|
||||
radial-gradient(circle at 32% 22%, rgba(255, 255, 255, 0.2), transparent 36%),
|
||||
radial-gradient(circle at 52% 52%, rgba(var(--toolbar-light-rgb, 91, 186, 255), 0.14), transparent 62%);
|
||||
transform: translate3d(calc(var(--elastic-x) * 0.08), calc(var(--elastic-y) * 0.08 - 1px), 0);
|
||||
0 14px 28px -6px rgba(0, 0, 0, 0.8),
|
||||
0 0 15px -1px var(--toolbar-atmosphere-hover),
|
||||
inset 0 0 1.8px 1.2px rgba(255, 255, 255, 0.22),
|
||||
inset 0 1px 0.5px 0.2px rgba(255, 255, 255, 0.65),
|
||||
inset 0 -3px 8px rgba(255, 255, 255, 0.04),
|
||||
inset 0 6px 12px rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.liquid-glass-surface:hover::after {
|
||||
opacity: 1;
|
||||
box-shadow:
|
||||
inset 0 0 0 0.75px rgba(var(--toolbar-light-rgb, 91, 186, 255), 0.16);
|
||||
}
|
||||
|
||||
.liquid-glass-surface:hover .icon,
|
||||
.earth-toolbar-hub-btn.liquid-glass-surface:hover > .material-symbols-rounded,
|
||||
.earth-zoom-btn.liquid-glass-surface:hover > span {
|
||||
color: #ffffff;
|
||||
transform: scale(1.06);
|
||||
}
|
||||
|
||||
.liquid-glass-surface:active,
|
||||
.liquid-glass-surface.is-pressed {
|
||||
--btn-scale: 0.94;
|
||||
--press-offset: 3px;
|
||||
--btn-scale: 0.95;
|
||||
--press-offset: -1px;
|
||||
transition: transform 0.1s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
box-shadow:
|
||||
0 4px 14px rgba(0, 0, 0, 0.28),
|
||||
inset 0 1px 2px rgba(255, 255, 255, 0.18),
|
||||
inset 0 -1px 3px rgba(0, 0, 0, 0.34);
|
||||
0 5px 12px -3px rgba(0, 0, 0, 0.9),
|
||||
0 0 8px -2px var(--toolbar-atmosphere-hover),
|
||||
inset 0 0 1.5px 1.2px rgba(255, 255, 255, 0.18),
|
||||
inset 0 1px 0.5px 0.2px rgba(255, 255, 255, 0.55),
|
||||
inset 0 -1px 4px rgba(255, 255, 255, 0.01);
|
||||
}
|
||||
|
||||
.liquid-glass-surface.active {
|
||||
background:
|
||||
radial-gradient(circle at 32% 22%, rgba(255, 255, 255, 0.14), transparent 40%),
|
||||
radial-gradient(circle at 52% 52%, rgba(var(--toolbar-light-rgb, 91, 186, 255), 0.13), transparent 62%),
|
||||
rgba(255, 255, 255, 0.09);
|
||||
border-color: rgba(var(--toolbar-light-rgb, 91, 186, 255), 0.42);
|
||||
radial-gradient(
|
||||
circle at 35% 30%,
|
||||
rgba(255, 255, 255, 0.24) 0%,
|
||||
rgba(255, 255, 255, 0.045) 40%,
|
||||
rgba(15, 23, 42, 0.24) 75%,
|
||||
rgba(3, 7, 18, 0.78) 100%
|
||||
);
|
||||
box-shadow:
|
||||
0 12px 40px rgba(0, 0, 0, 0.46),
|
||||
inset 0 1.5px 3px rgba(255, 255, 255, 0.38),
|
||||
inset 0 -1.5px 3px rgba(0, 0, 0, 0.2);
|
||||
0 13px 28px -6px rgba(0, 0, 0, 0.78),
|
||||
0 0 16px -1px var(--toolbar-atmosphere-hover),
|
||||
inset 0 0 1.9px 1.25px rgba(255, 255, 255, 0.24),
|
||||
inset 0 1px 0.5px 0.2px rgba(255, 255, 255, 0.66),
|
||||
inset 0 -3px 8px rgba(255, 255, 255, 0.04),
|
||||
inset 0 6px 12px rgba(255, 255, 255, 0.11);
|
||||
}
|
||||
|
||||
.liquid-glass-surface.active:hover {
|
||||
background:
|
||||
radial-gradient(circle at 32% 22%, rgba(255, 255, 255, 0.16), transparent 40%),
|
||||
radial-gradient(circle at 52% 52%, rgba(var(--toolbar-light-rgb, 91, 186, 255), 0.17), transparent 64%),
|
||||
rgba(255, 255, 255, 0.1);
|
||||
radial-gradient(
|
||||
circle at 35% 30%,
|
||||
rgba(255, 255, 255, 0.28) 0%,
|
||||
rgba(255, 255, 255, 0.055) 40%,
|
||||
rgba(15, 23, 42, 0.22) 75%,
|
||||
rgba(3, 7, 18, 0.76) 100%
|
||||
);
|
||||
box-shadow:
|
||||
0 12px 42px rgba(0, 0, 0, 0.48),
|
||||
inset 0 1.5px 3px rgba(255, 255, 255, 0.42),
|
||||
inset 0 -1.5px 3px rgba(0, 0, 0, 0.18);
|
||||
0 15px 30px -6px rgba(0, 0, 0, 0.82),
|
||||
0 0 18px -1px var(--toolbar-atmosphere-hover),
|
||||
inset 0 0 2px 1.25px rgba(255, 255, 255, 0.27),
|
||||
inset 0 1px 0.5px 0.2px rgba(255, 255, 255, 0.7),
|
||||
inset 0 -3px 8px rgba(255, 255, 255, 0.05),
|
||||
inset 0 6px 12px rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.earth-toolbar-cluster.is-expanded .earth-toolbar-hub-btn.liquid-glass-surface {
|
||||
background:
|
||||
radial-gradient(circle at 32% 22%, rgba(255, 255, 255, 0.15), transparent 42%),
|
||||
radial-gradient(circle at 52% 52%, rgba(var(--toolbar-light-rgb, 91, 186, 255), 0.15), transparent 64%),
|
||||
rgba(255, 255, 255, 0.095);
|
||||
border-color: rgba(var(--toolbar-light-rgb, 91, 186, 255), 0.44);
|
||||
radial-gradient(
|
||||
circle at 35% 30%,
|
||||
rgba(255, 255, 255, 0.24) 0%,
|
||||
rgba(255, 255, 255, 0.045) 40%,
|
||||
rgba(15, 23, 42, 0.24) 75%,
|
||||
rgba(3, 7, 18, 0.78) 100%
|
||||
);
|
||||
box-shadow:
|
||||
0 12px 42px rgba(0, 0, 0, 0.48),
|
||||
inset 0 1.5px 3px rgba(255, 255, 255, 0.4),
|
||||
inset 0 -1.5px 3px rgba(0, 0, 0, 0.18);
|
||||
0 14px 30px -6px rgba(0, 0, 0, 0.82),
|
||||
0 0 17px -1px var(--toolbar-atmosphere-hover),
|
||||
inset 0 0 2px 1.25px rgba(255, 255, 255, 0.26),
|
||||
inset 0 1px 0.5px 0.2px rgba(255, 255, 255, 0.68),
|
||||
inset 0 -3px 8px rgba(255, 255, 255, 0.05),
|
||||
inset 0 7px 13px rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.earth-rotate-toggle .icon-play,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1173,6 +1173,28 @@
|
||||
<button type="button" class="earth-mobile-settings-pill" data-motion-provider="motion_agent" aria-pressed="false">Motion Agent</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="earth-mobile-settings-card earth-mobile-settings-card--stacked"
|
||||
data-gatekeeper-permission="earth.motion_debug"
|
||||
>
|
||||
<div class="earth-mobile-settings-copy">
|
||||
<span class="earth-mobile-settings-label">识别动作</span>
|
||||
<span class="earth-mobile-settings-subtitle">关闭后不会触发对应星球控制</span>
|
||||
</div>
|
||||
<div class="earth-mobile-settings-chip-grid" role="group" aria-label="移动端选择动捕识别动作">
|
||||
<button type="button" class="earth-mobile-settings-chip is-active" data-motion-gesture-toggle="rotate_left" aria-pressed="true">左旋</button>
|
||||
<button type="button" class="earth-mobile-settings-chip is-active" data-motion-gesture-toggle="rotate_right" aria-pressed="true">右旋</button>
|
||||
<button type="button" class="earth-mobile-settings-chip is-active" data-motion-gesture-toggle="rotate_up" aria-pressed="true">上旋</button>
|
||||
<button type="button" class="earth-mobile-settings-chip is-active" data-motion-gesture-toggle="rotate_down" aria-pressed="true">下旋</button>
|
||||
<button type="button" class="earth-mobile-settings-chip is-active" data-motion-gesture-toggle="zoom_in" aria-pressed="true">放大</button>
|
||||
<button type="button" class="earth-mobile-settings-chip is-active" data-motion-gesture-toggle="zoom_out" aria-pressed="true">缩小</button>
|
||||
<button type="button" class="earth-mobile-settings-chip is-active" data-motion-gesture-toggle="focus_prev" aria-pressed="true">上个焦点</button>
|
||||
<button type="button" class="earth-mobile-settings-chip is-active" data-motion-gesture-toggle="focus_next" aria-pressed="true">下个焦点</button>
|
||||
<button type="button" class="earth-mobile-settings-chip is-active" data-motion-gesture-toggle="layer_prev" aria-pressed="true">上一图层</button>
|
||||
<button type="button" class="earth-mobile-settings-chip is-active" data-motion-gesture-toggle="layer_next" aria-pressed="true">下一图层</button>
|
||||
<button type="button" class="earth-mobile-settings-chip is-active" data-motion-gesture-toggle="confirm" aria-pressed="true">确认</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="earth-mobile-settings-group" data-settings-tab-panel="shortcuts" hidden>
|
||||
<div class="earth-mobile-settings-title">快捷键</div>
|
||||
@@ -1189,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>
|
||||
@@ -1705,6 +1737,28 @@
|
||||
<button type="button" class="earth-settings-segmented-btn" data-motion-provider="motion_agent" aria-pressed="false">Motion Agent</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="earth-settings-item earth-settings-item--stacked"
|
||||
data-gatekeeper-permission="earth.motion_debug"
|
||||
>
|
||||
<div class="earth-settings-copy">
|
||||
<span class="earth-settings-item-title">识别动作</span>
|
||||
<span class="earth-settings-item-subtitle">未勾选的动作不会触发星球端控制;Motion Agent 会同步过滤这些动作</span>
|
||||
</div>
|
||||
<div class="earth-settings-chip-grid" role="group" aria-label="选择动捕识别动作">
|
||||
<button type="button" class="earth-settings-chip is-active" data-motion-gesture-toggle="rotate_left" aria-pressed="true">左旋</button>
|
||||
<button type="button" class="earth-settings-chip is-active" data-motion-gesture-toggle="rotate_right" aria-pressed="true">右旋</button>
|
||||
<button type="button" class="earth-settings-chip is-active" data-motion-gesture-toggle="rotate_up" aria-pressed="true">上旋</button>
|
||||
<button type="button" class="earth-settings-chip is-active" data-motion-gesture-toggle="rotate_down" aria-pressed="true">下旋</button>
|
||||
<button type="button" class="earth-settings-chip is-active" data-motion-gesture-toggle="zoom_in" aria-pressed="true">放大</button>
|
||||
<button type="button" class="earth-settings-chip is-active" data-motion-gesture-toggle="zoom_out" aria-pressed="true">缩小</button>
|
||||
<button type="button" class="earth-settings-chip is-active" data-motion-gesture-toggle="focus_prev" aria-pressed="true">上个焦点</button>
|
||||
<button type="button" class="earth-settings-chip is-active" data-motion-gesture-toggle="focus_next" aria-pressed="true">下个焦点</button>
|
||||
<button type="button" class="earth-settings-chip is-active" data-motion-gesture-toggle="layer_prev" aria-pressed="true">上一图层</button>
|
||||
<button type="button" class="earth-settings-chip is-active" data-motion-gesture-toggle="layer_next" aria-pressed="true">下一图层</button>
|
||||
<button type="button" class="earth-settings-chip is-active" data-motion-gesture-toggle="confirm" aria-pressed="true">确认</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="earth-settings-section" data-settings-tab-panel="shortcuts" hidden>
|
||||
@@ -1725,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() {
|
||||
|
||||
300
frontend/public/earth/js/controls.js
vendored
@@ -109,8 +109,10 @@ import {
|
||||
setLayerButtonState,
|
||||
updateLayerButtonState,
|
||||
} from "./layer-button-state.js";
|
||||
import { earthMessage, translateText } from "./i18n.js";
|
||||
import {
|
||||
DEFAULT_MOTION_PROVIDER,
|
||||
MOTION_GESTURES,
|
||||
normalizeMotionProvider,
|
||||
} from "./motion-protocol.js";
|
||||
|
||||
@@ -125,6 +127,7 @@ let autoRotationSpeed = CONFIG.rotationSpeed;
|
||||
let motionDebugEnabled = false;
|
||||
let motionProvider = DEFAULT_MOTION_PROVIDER;
|
||||
let motionDebugSkeletonOnly = false;
|
||||
let motionEnabledGestures = [];
|
||||
let activeCamera = null;
|
||||
let settingsApplyPromise = Promise.resolve();
|
||||
let boundaryBuildPollTimer = null;
|
||||
@@ -159,7 +162,7 @@ const SETTINGS_SHEET_MAX_SCALE_X = 0.22;
|
||||
const SETTINGS_SHEET_MAX_SCALE_Y = 0.18;
|
||||
const EARTH_SETTINGS_STORAGE_KEY = "planet.earth.settings.v2";
|
||||
const LEGACY_EARTH_SETTINGS_STORAGE_KEY = "planet.earth.settings.v1";
|
||||
const EARTH_SETTINGS_VERSION = 16;
|
||||
const EARTH_SETTINGS_VERSION = 17;
|
||||
const GRID_LINES_DEFAULT_VERSION = 3;
|
||||
const SATELLITE_DISPLAY_DEFAULT_VERSION = 4;
|
||||
const MEDIA_PANEL_DEFAULT_VERSION = 5;
|
||||
@@ -173,6 +176,22 @@ const KEYBOARD_SHORTCUTS_DEFAULT_VERSION = 13;
|
||||
const CRUISE_QUEUE_DEFAULT_VERSION = 14;
|
||||
const AUTO_ROTATION_SPEED_DEFAULT_VERSION = 15;
|
||||
const NEWS_CATEGORY_FILTERS_DEFAULT_VERSION = 16;
|
||||
const MOTION_GESTURES_DEFAULT_VERSION = 17;
|
||||
const MOTION_GESTURE_DEFINITIONS = [
|
||||
{ id: "rotate_left", label: "左旋" },
|
||||
{ id: "rotate_right", label: "右旋" },
|
||||
{ id: "rotate_up", label: "上旋" },
|
||||
{ id: "rotate_down", label: "下旋" },
|
||||
{ id: "zoom_in", label: "放大" },
|
||||
{ id: "zoom_out", label: "缩小" },
|
||||
{ id: "focus_prev", label: "上个焦点" },
|
||||
{ id: "focus_next", label: "下个焦点" },
|
||||
{ id: "layer_prev", label: "上一图层" },
|
||||
{ id: "layer_next", label: "下一图层" },
|
||||
{ id: "confirm", label: "确认" },
|
||||
];
|
||||
const DEFAULT_MOTION_ENABLED_GESTURES = MOTION_GESTURE_DEFINITIONS.map((item) => item.id);
|
||||
motionEnabledGestures = [...DEFAULT_MOTION_ENABLED_GESTURES];
|
||||
const DEFAULT_NEWS_CATEGORY_FILTERS = {
|
||||
politics: true,
|
||||
business: true,
|
||||
@@ -410,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 } = {}) {
|
||||
@@ -713,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) {
|
||||
@@ -721,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) {
|
||||
@@ -1360,7 +1385,7 @@ function getZoomResetTooltipText(zoom) {
|
||||
}
|
||||
|
||||
function getZoomResetStatusMessage(zoom) {
|
||||
return `缩放已重置到${formatZoomPercent(zoom)}`;
|
||||
return earthMessage("status.zoomReset", { zoom: formatZoomPercent(zoom) });
|
||||
}
|
||||
|
||||
function normalizeAutoRotationSpeed(value) {
|
||||
@@ -1427,6 +1452,7 @@ function getCurrentSharedSettingsSnapshot() {
|
||||
motionDebugEnabled,
|
||||
motionProvider,
|
||||
motionDebugSkeletonOnly,
|
||||
motionEnabledGestures: getMotionEnabledGestures(),
|
||||
mediaPanelActiveTab: normalizeMediaPanelActiveTab(getActiveTVTab()),
|
||||
satelliteIdleBreathingEnabled: getSatelliteIdleBreathingEnabled(),
|
||||
satelliteRealAltitudeEnabled: getSatelliteRealAltitudeEnabled(),
|
||||
@@ -1489,6 +1515,7 @@ function cloneEarthSettings(settings) {
|
||||
DEFAULT_MOTION_PROVIDER,
|
||||
),
|
||||
motionDebugSkeletonOnly: Boolean(settings.shared.motionDebugSkeletonOnly),
|
||||
motionEnabledGestures: normalizeMotionEnabledGestures(settings.shared.motionEnabledGestures),
|
||||
mediaPanelActiveTab: normalizeMediaPanelActiveTab(settings.shared.mediaPanelActiveTab),
|
||||
satelliteIdleBreathingEnabled:
|
||||
settings.shared.satelliteIdleBreathingEnabled !== false,
|
||||
@@ -1621,6 +1648,10 @@ function normalizeEarthSettings(rawSettings, defaults) {
|
||||
typeof sharedSettings?.motionDebugSkeletonOnly === "boolean"
|
||||
? sharedSettings.motionDebugSkeletonOnly
|
||||
: defaults.shared.motionDebugSkeletonOnly;
|
||||
const nextMotionEnabledGestures =
|
||||
(rawSettings?.version || 0) >= MOTION_GESTURES_DEFAULT_VERSION
|
||||
? normalizeMotionEnabledGestures(sharedSettings?.motionEnabledGestures)
|
||||
: normalizeMotionEnabledGestures(defaults.shared.motionEnabledGestures);
|
||||
const nextMediaPanelActiveTab =
|
||||
(rawSettings?.version || 0) >= MEDIA_PANEL_ACTIVE_TAB_DEFAULT_VERSION
|
||||
? normalizeMediaPanelActiveTab(sharedSettings?.mediaPanelActiveTab)
|
||||
@@ -1689,6 +1720,7 @@ function normalizeEarthSettings(rawSettings, defaults) {
|
||||
motionDebugEnabled: nextMotionDebugEnabled,
|
||||
motionProvider: nextMotionProvider,
|
||||
motionDebugSkeletonOnly: nextMotionDebugSkeletonOnly,
|
||||
motionEnabledGestures: nextMotionEnabledGestures,
|
||||
mediaPanelActiveTab: nextMediaPanelActiveTab,
|
||||
satelliteIdleBreathingEnabled: nextSatelliteIdleBreathingEnabled,
|
||||
satelliteRealAltitudeEnabled: nextSatelliteRealAltitudeEnabled,
|
||||
@@ -1746,6 +1778,17 @@ function syncMotionDebugSkeletonOnlyToggle(nextEnabled = motionDebugSkeletonOnly
|
||||
});
|
||||
}
|
||||
|
||||
function syncMotionGestureControls() {
|
||||
const enabledGestures = new Set(getMotionEnabledGestures());
|
||||
document.querySelectorAll("[data-motion-gesture-toggle]").forEach((button) => {
|
||||
if (!(button instanceof HTMLButtonElement)) return;
|
||||
const gesture = button.dataset.motionGestureToggle || "";
|
||||
const active = enabledGestures.has(gesture);
|
||||
button.classList.toggle("is-active", active);
|
||||
button.setAttribute("aria-pressed", active ? "true" : "false");
|
||||
});
|
||||
}
|
||||
|
||||
function dispatchMotionSettingsChange() {
|
||||
const effectiveDebugEnabled =
|
||||
rotationMode === ROTATION_MODE.MOTION && autoRotate && motionDebugEnabled;
|
||||
@@ -1756,6 +1799,7 @@ function dispatchMotionSettingsChange() {
|
||||
preferredEnabled: motionDebugEnabled,
|
||||
provider: motionProvider,
|
||||
skeletonOnly: motionDebugSkeletonOnly,
|
||||
enabledGestures: getMotionEnabledGestures(),
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -1866,6 +1910,18 @@ function normalizeCruiseModules(nextModules) {
|
||||
: [...DEFAULT_CRUISE_MODULES];
|
||||
}
|
||||
|
||||
function normalizeMotionEnabledGestures(nextGestures) {
|
||||
const sourceGestures = Array.isArray(nextGestures)
|
||||
? nextGestures
|
||||
: DEFAULT_MOTION_ENABLED_GESTURES;
|
||||
const normalizedGestures = Array.from(
|
||||
new Set(sourceGestures.filter((gesture) => MOTION_GESTURES.has(gesture))),
|
||||
);
|
||||
return normalizedGestures.length > 0
|
||||
? normalizedGestures
|
||||
: [...DEFAULT_MOTION_ENABLED_GESTURES];
|
||||
}
|
||||
|
||||
function normalizeCruiseQueueMode(mode) {
|
||||
return ALLOWED_CRUISE_QUEUE_MODES.has(mode) ? mode : DEFAULT_CRUISE_QUEUE_MODE;
|
||||
}
|
||||
@@ -2041,7 +2097,7 @@ export function setEarthNewsCategoryEnabled(
|
||||
persistEarthSettings();
|
||||
}
|
||||
if (!suppressStatus) {
|
||||
showStatusMessage(Boolean(enabled) ? "新闻类型已显示" : "新闻类型已隐藏", "info");
|
||||
showStatusMessage(earthMessage("status.layerVisibility", { layer: "新闻类型", visible: Boolean(enabled) }), "info");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -2098,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;
|
||||
@@ -2129,7 +2191,7 @@ export function setCruiseQueueMode(
|
||||
: normalizedMode === CRUISE_QUEUE_MODES.RANDOM
|
||||
? "随机"
|
||||
: "默认";
|
||||
showStatusMessage(`巡航队列已切换为:${label}`, "info");
|
||||
showStatusMessage(earthMessage("status.valueChanged", { label: "巡航队列", value: label }), "info");
|
||||
}
|
||||
|
||||
return normalizedMode;
|
||||
@@ -2154,7 +2216,7 @@ export function setCruiseRegionOrder(
|
||||
|
||||
if (persist) persistEarthSettings();
|
||||
if (!suppressStatus) {
|
||||
showStatusMessage("巡航大区顺序已更新", "info");
|
||||
showStatusMessage(earthMessage("status.updated", { label: "巡航大区顺序" }), "info");
|
||||
}
|
||||
|
||||
return normalizedOrder;
|
||||
@@ -2188,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;
|
||||
@@ -2207,7 +2269,7 @@ export function setSatelliteIdleBreathingEnabled(
|
||||
persistEarthSettings();
|
||||
}
|
||||
if (!suppressStatus) {
|
||||
showStatusMessage(enabled ? "卫星呼吸闪烁已开启" : "卫星呼吸闪烁已关闭", "info");
|
||||
showStatusMessage(earthMessage("status.booleanSetting", { label: "卫星呼吸闪烁", enabled }), "info");
|
||||
}
|
||||
return enabled;
|
||||
}
|
||||
@@ -2226,7 +2288,9 @@ export function setSatelliteRealAltitudeEnabled(
|
||||
}
|
||||
if (!suppressStatus) {
|
||||
showStatusMessage(
|
||||
enabled ? "卫星真实高度已开启" : "卫星已切换为旧版同层高度",
|
||||
enabled
|
||||
? earthMessage("status.booleanSetting", { label: "卫星真实高度", enabled })
|
||||
: earthMessage("status.valueChanged", { label: "卫星", value: "旧版同层高度" }),
|
||||
"info",
|
||||
);
|
||||
}
|
||||
@@ -2246,7 +2310,7 @@ export function setInteractableCompactDotsEnabled(
|
||||
persistEarthSettings();
|
||||
}
|
||||
if (!suppressStatus) {
|
||||
showStatusMessage(enabled ? "低缩放彩色圆点已开启" : "低缩放彩色圆点已关闭", "info");
|
||||
showStatusMessage(earthMessage("status.booleanSetting", { label: "低缩放彩色圆点", enabled }), "info");
|
||||
}
|
||||
return enabled;
|
||||
}
|
||||
@@ -2281,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;
|
||||
@@ -2396,6 +2460,10 @@ async function applyEarthSettings(settings, { applyLayers = true } = {}) {
|
||||
persist: false,
|
||||
suppressStatus: true,
|
||||
});
|
||||
setMotionEnabledGestures(settings.shared.motionEnabledGestures, {
|
||||
persist: false,
|
||||
suppressStatus: true,
|
||||
});
|
||||
setActiveTVTab(settings.shared.mediaPanelActiveTab);
|
||||
keyboardShortcuts = normalizeKeyboardShortcuts(settings.shared.keyboardShortcuts);
|
||||
renderShortcutSettings();
|
||||
@@ -2428,6 +2496,10 @@ export function getMotionDebugSkeletonOnly() {
|
||||
return motionDebugSkeletonOnly;
|
||||
}
|
||||
|
||||
export function getMotionEnabledGestures() {
|
||||
return normalizeMotionEnabledGestures(motionEnabledGestures);
|
||||
}
|
||||
|
||||
export function setMotionDebugEnabled(
|
||||
nextEnabled,
|
||||
{ persist = true, suppressStatus = false } = {},
|
||||
@@ -2451,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;
|
||||
}
|
||||
@@ -2480,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;
|
||||
}
|
||||
@@ -2509,14 +2577,39 @@ export function setMotionDebugSkeletonOnly(
|
||||
persistEarthSettings();
|
||||
}
|
||||
if (!suppressStatus && changed) {
|
||||
showStatusMessage(
|
||||
motionDebugSkeletonOnly ? "动捕调试已切换为只显示骨骼" : "动捕调试已显示实时画面",
|
||||
"info",
|
||||
);
|
||||
showStatusMessage(earthMessage("status.motionDebugView", { skeletonOnly: motionDebugSkeletonOnly }), "info");
|
||||
}
|
||||
return motionDebugSkeletonOnly;
|
||||
}
|
||||
|
||||
export function setMotionEnabledGestures(
|
||||
nextGestures,
|
||||
{ persist = true, suppressStatus = false } = {},
|
||||
) {
|
||||
const normalized = normalizeMotionEnabledGestures(nextGestures);
|
||||
const previous = getMotionEnabledGestures();
|
||||
const changed =
|
||||
normalized.length !== previous.length ||
|
||||
normalized.some((gesture, index) => previous[index] !== gesture);
|
||||
|
||||
motionEnabledGestures = normalized;
|
||||
syncMotionGestureControls();
|
||||
|
||||
ensureMutableEarthSettingsState();
|
||||
earthSettingsState.shared.motionEnabledGestures = [...motionEnabledGestures];
|
||||
|
||||
if (changed) {
|
||||
dispatchMotionSettingsChange();
|
||||
}
|
||||
if (persist) {
|
||||
persistEarthSettings();
|
||||
}
|
||||
if (!suppressStatus && changed) {
|
||||
showStatusMessage(earthMessage("status.motionGesturesUpdated"), "info");
|
||||
}
|
||||
return getMotionEnabledGestures();
|
||||
}
|
||||
|
||||
export async function applyDeferredLayerVisibilitySettings(options = {}) {
|
||||
const layerVisibility = deferredLayerVisibilitySettings;
|
||||
deferredLayerVisibilitySettings = null;
|
||||
@@ -2541,7 +2634,7 @@ function resetEarthSettings() {
|
||||
}
|
||||
}
|
||||
void applyEarthSettings(defaults).then(() => {
|
||||
showStatusMessage("Earth 设置已重置", "info");
|
||||
showStatusMessage(earthMessage("status.settingsReset"), "info");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2553,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;
|
||||
}
|
||||
@@ -2566,7 +2659,7 @@ async function setTerrainEnabled(button, enabled, { persist = true, silent = fal
|
||||
statusText: "加载中",
|
||||
});
|
||||
if (!silent) {
|
||||
showStatusMessage("正在加载真实地形数据...", "info");
|
||||
showStatusMessage(earthMessage("loading.realTerrainData"), "info");
|
||||
}
|
||||
await ensureTerrainReady();
|
||||
}
|
||||
@@ -2576,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) {
|
||||
@@ -2585,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;
|
||||
}
|
||||
@@ -2603,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()} 颗`);
|
||||
}
|
||||
@@ -2633,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;
|
||||
}
|
||||
@@ -2721,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;
|
||||
}
|
||||
@@ -2737,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;
|
||||
}
|
||||
@@ -2806,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;
|
||||
}
|
||||
@@ -2916,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),
|
||||
@@ -2933,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),
|
||||
@@ -2968,8 +3061,8 @@ function getBuiltinLayerDefinitions() {
|
||||
startupMode: "visible",
|
||||
startupLabel: "海缆",
|
||||
startupMessage: {
|
||||
prepare: "正在加载登陆点...",
|
||||
load: "正在加载海缆...",
|
||||
prepare: earthMessage("startup.landingPoints"),
|
||||
load: earthMessage("startup.cables"),
|
||||
},
|
||||
getVisible: () => getShowCables(),
|
||||
setVisible: (visible, options = {}) =>
|
||||
@@ -2987,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),
|
||||
@@ -3004,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),
|
||||
@@ -3021,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),
|
||||
@@ -3038,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),
|
||||
@@ -3055,7 +3148,7 @@ function getBuiltinLayerDefinitions() {
|
||||
startupPriority: null,
|
||||
startupMode: "visible",
|
||||
startupLabel: "地形",
|
||||
startupMessage: "正在渲染地形...",
|
||||
startupMessage: earthMessage("startup.terrain"),
|
||||
statusTarget: "terrain-status",
|
||||
getVisible: () => showTerrain,
|
||||
setVisible: (visible, options = {}) =>
|
||||
@@ -3260,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() {
|
||||
@@ -3517,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);
|
||||
@@ -3690,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);
|
||||
}
|
||||
@@ -3706,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();
|
||||
}
|
||||
|
||||
@@ -3721,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) => {
|
||||
@@ -3735,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",
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -3753,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",
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -3764,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",
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -3886,6 +4004,7 @@ function integrateMotionSettingsIntoRuntime() {
|
||||
markRuntimeModeSection("[data-auto-rotation-speed-slider]", ROTATION_MODE.ROTATE);
|
||||
markRuntimeModeSection("[data-motion-debug-toggle]", ROTATION_MODE.MOTION);
|
||||
markRuntimeModeSection("[data-motion-provider]", ROTATION_MODE.MOTION);
|
||||
markRuntimeModeSection("[data-motion-gesture-toggle]", ROTATION_MODE.MOTION);
|
||||
syncRuntimeModeSections();
|
||||
}
|
||||
|
||||
@@ -3957,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);
|
||||
@@ -3997,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;
|
||||
}
|
||||
@@ -4024,7 +4143,7 @@ function resetAllShortcutBindings() {
|
||||
capturingShortcutActionId = null;
|
||||
renderShortcutSettings();
|
||||
persistEarthSettings();
|
||||
showStatusMessage("快捷键已恢复默认", "info");
|
||||
showStatusMessage(earthMessage("status.shortcutsReset"), "info");
|
||||
}
|
||||
|
||||
function moveCruiseRegionInOrder(region, targetRegion) {
|
||||
@@ -4184,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);
|
||||
});
|
||||
});
|
||||
@@ -4434,6 +4557,21 @@ function setupSettingsControls() {
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll("[data-motion-gesture-toggle]").forEach((motionGestureButton) => {
|
||||
if (!(motionGestureButton instanceof HTMLButtonElement)) return;
|
||||
bindListener(motionGestureButton, "click", () => {
|
||||
const gesture = motionGestureButton.dataset.motionGestureToggle;
|
||||
if (!gesture) return;
|
||||
const nextGestures = new Set(getMotionEnabledGestures());
|
||||
if (nextGestures.has(gesture)) {
|
||||
nextGestures.delete(gesture);
|
||||
} else {
|
||||
nextGestures.add(gesture);
|
||||
}
|
||||
setMotionEnabledGestures(Array.from(nextGestures));
|
||||
});
|
||||
});
|
||||
|
||||
const mobileSettingsReset = document.getElementById("mobile-settings-reset");
|
||||
bindListener(mobileSettingsReset, "click", () => {
|
||||
resetEarthSettings();
|
||||
@@ -4454,6 +4592,7 @@ function setupSettingsControls() {
|
||||
syncMotionDebugToggle(motionDebugEnabled);
|
||||
syncMotionProviderControls(motionProvider);
|
||||
syncMotionDebugSkeletonOnlyToggle(motionDebugSkeletonOnly);
|
||||
syncMotionGestureControls();
|
||||
void setupBoundaryPrecisionControls();
|
||||
}
|
||||
|
||||
@@ -5145,7 +5284,10 @@ function setupRotateControls(camera) {
|
||||
: rotationMode === ROTATION_MODE.MOTION
|
||||
? "动捕"
|
||||
: "自动旋转";
|
||||
showStatusMessage(isRotating ? `${label}已开启` : `${label}已暂停`, "info");
|
||||
showStatusMessage(
|
||||
earthMessage("status.runtimePaused", { label, active: isRotating }),
|
||||
"info",
|
||||
);
|
||||
});
|
||||
|
||||
updateRotateUI();
|
||||
@@ -5416,7 +5558,7 @@ function setupTerrainControls() {
|
||||
|
||||
bindListener(layoutBtn, "click", () => {
|
||||
const expanded = toggleLayoutExpanded(container);
|
||||
showStatusMessage(expanded ? "布局已最大化" : "布局已恢复", "info");
|
||||
showStatusMessage(earthMessage("status.layoutExpanded", { expanded }), "info");
|
||||
});
|
||||
|
||||
const mediaVisible =
|
||||
@@ -5450,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();
|
||||
}
|
||||
@@ -5498,6 +5646,8 @@ function setupLiquidGlassInteractions() {
|
||||
surface.style.setProperty("--tilt-y", "0deg");
|
||||
surface.style.setProperty("--panel-tilt-x", "0deg");
|
||||
surface.style.setProperty("--panel-tilt-y", "0deg");
|
||||
surface.style.setProperty("--mouse-x", "0.5");
|
||||
surface.style.setProperty("--mouse-y", "0.5");
|
||||
surface.style.setProperty("--dock-scale", "1");
|
||||
surface.style.setProperty("--dock-lift", "0px");
|
||||
surface.style.setProperty("--dock-shift-x", "0px");
|
||||
@@ -5514,13 +5664,15 @@ function setupLiquidGlassInteractions() {
|
||||
|
||||
surfaces.forEach((surface) => {
|
||||
resetSurface(surface);
|
||||
const isToolbarSurface = Boolean(surface.closest(".earth-toolbar-items"));
|
||||
const isToolbarSurface = Boolean(surface.closest(".earth-toolbar"));
|
||||
const isPanelSurface = surface.classList.contains("hud-panel");
|
||||
|
||||
bindListener(surface, "pointermove", (event) => {
|
||||
const rect = surface.getBoundingClientRect();
|
||||
const px = (event.clientX - rect.left) / rect.width;
|
||||
const py = (event.clientY - rect.top) / rect.height;
|
||||
surface.style.setProperty("--mouse-x", `${px.toFixed(3)}`);
|
||||
surface.style.setProperty("--mouse-y", `${py.toFixed(3)}`);
|
||||
if (isPanelSurface) {
|
||||
const panelTiltX = (0.5 - py) * 5;
|
||||
const panelTiltY = (px - 0.5) * 6;
|
||||
@@ -5906,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();
|
||||
@@ -5950,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;
|
||||
}
|
||||
@@ -5973,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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6045,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();
|
||||
},
|
||||
|
||||
@@ -165,3 +165,21 @@ export function applyEarthInteractableEvent(earth, payload = {}) {
|
||||
export function getEarthInteractableMarkers() {
|
||||
return earthInteractableLayer.getMarkers();
|
||||
}
|
||||
|
||||
export function getEarthInteractablePointerIntersections(options = {}) {
|
||||
return earthInteractableLayer.getPointerIntersections(options);
|
||||
}
|
||||
|
||||
export function setEarthInteractableMarkerState(marker, state = "normal") {
|
||||
earthInteractableLayer.setMarkerState(marker, state);
|
||||
}
|
||||
|
||||
export function clearEarthInteractableSelection() {
|
||||
earthInteractableLayer.getMarkers().forEach((marker) => {
|
||||
earthInteractableLayer.setMarkerState(marker, "normal");
|
||||
});
|
||||
}
|
||||
|
||||
export function updateEarthInteractableVisualState(focusType, focusObject, camera) {
|
||||
earthInteractableLayer.updateVisualState(focusType, focusObject, camera);
|
||||
}
|
||||
|
||||
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 '';
|
||||
}
|
||||
}
|
||||
@@ -1677,6 +1697,22 @@ const CARD_CONFIG = {
|
||||
{ key: 'length', label: '船长', unit: 'm' },
|
||||
{ key: 'received_at', label: '更新时间' }
|
||||
]
|
||||
},
|
||||
earth_interactable: {
|
||||
icon: '📍',
|
||||
title: '交互点详情',
|
||||
className: 'earth_interactable',
|
||||
fields: [
|
||||
{ key: 'label', label: '名称' },
|
||||
{ key: 'kind', label: '类型' },
|
||||
{ key: 'id', label: '标识' },
|
||||
{ key: 'latitude', label: '纬度' },
|
||||
{ key: 'longitude', label: '经度' },
|
||||
{ key: 'description', label: '说明' },
|
||||
{ key: 'source', label: '来源' },
|
||||
{ key: 'status', label: '状态' },
|
||||
{ key: 'updated_at', label: '更新时间' }
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1793,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>
|
||||
@@ -1834,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');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1869,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) {
|
||||
@@ -1984,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('_', ' ');
|
||||
}
|
||||
|
||||
@@ -2025,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, "'");
|
||||
}
|
||||
|
||||
@@ -10,12 +10,15 @@ export function createMotionAgentProvider(options = {}) {
|
||||
onMessage = () => {},
|
||||
onState = () => {},
|
||||
onStatus = () => {},
|
||||
debugSkeleton = false,
|
||||
enabledGestures = [],
|
||||
} = options;
|
||||
|
||||
let socket = null;
|
||||
let reconnectTimer = null;
|
||||
let disposed = false;
|
||||
let connected = false;
|
||||
let requestSeq = 0;
|
||||
|
||||
function emitState(detail = {}) {
|
||||
onState({
|
||||
@@ -55,6 +58,24 @@ export function createMotionAgentProvider(options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
function sendCommand(command, payload = {}, requestId = null) {
|
||||
if (!socket || !connected || socket.readyState !== 1) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "motion_agent_not_connected",
|
||||
requestId: requestId || null,
|
||||
};
|
||||
}
|
||||
const nextRequestId = requestId || `earth-motion-${Date.now()}-${++requestSeq}`;
|
||||
socket.send(JSON.stringify({
|
||||
type: "command",
|
||||
command,
|
||||
request_id: nextRequestId,
|
||||
payload,
|
||||
}));
|
||||
return { ok: true, requestId: nextRequestId };
|
||||
}
|
||||
|
||||
function connect() {
|
||||
if (disposed || socket) return;
|
||||
if (!WebSocketCtor) {
|
||||
@@ -73,6 +94,15 @@ export function createMotionAgentProvider(options = {}) {
|
||||
socket.onopen = () => {
|
||||
connected = true;
|
||||
emitState({ connected: true });
|
||||
sendCommand("set_debug_options", { skeleton: Boolean(debugSkeleton) }, "earth-motion-debug-on-connect");
|
||||
if (Array.isArray(enabledGestures) && enabledGestures.length > 0) {
|
||||
sendCommand(
|
||||
"set_enabled_gestures",
|
||||
{ gestures: enabledGestures },
|
||||
"earth-motion-enabled-gestures-on-connect",
|
||||
);
|
||||
}
|
||||
sendCommand("set_armed", { armed: true }, "earth-motion-armed-on-connect");
|
||||
onStatus("动捕 Agent 已连接", "info");
|
||||
};
|
||||
socket.onmessage = (rawMessage) => onMessage(rawMessage?.data ?? rawMessage);
|
||||
@@ -104,5 +134,8 @@ export function createMotionAgentProvider(options = {}) {
|
||||
isConnected() {
|
||||
return connected;
|
||||
},
|
||||
sendCommand(command, payload = {}, requestId = null) {
|
||||
return sendCommand(command, payload, requestId);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
import { createBrowserCameraProvider } from "./motion-browser-provider.js";
|
||||
import {
|
||||
DEFAULT_MOTION_PROVIDER,
|
||||
MOTION_GESTURES,
|
||||
MOTION_PROVIDER_AGENT,
|
||||
normalizeGestureMessage,
|
||||
normalizeMotionProvider,
|
||||
@@ -24,6 +25,7 @@ const DEFAULT_LAYER_COOLDOWN_MS = 1400;
|
||||
const DEFAULT_CONFIRM_COOLDOWN_MS = 1200;
|
||||
const ENABLED_STORAGE_KEY = "planet-earth-motion-control-enabled";
|
||||
const URL_STORAGE_KEY = "planet-earth-motion-control-url";
|
||||
const DEFAULT_ENABLED_GESTURES = Array.from(MOTION_GESTURES);
|
||||
|
||||
const GESTURE_POLICIES = {
|
||||
rotate_left: { group: "rotate_left", cooldownMs: DEFAULT_COOLDOWN_MS },
|
||||
@@ -85,6 +87,8 @@ export function createMotionControlAdapter(options = {}) {
|
||||
minConfidence = DEFAULT_MIN_CONFIDENCE,
|
||||
cooldownMs = DEFAULT_COOLDOWN_MS,
|
||||
providerFactories = {},
|
||||
debugSkeleton = false,
|
||||
enabledGestures = DEFAULT_ENABLED_GESTURES,
|
||||
WebSocketCtor = typeof WebSocket !== "undefined" ? WebSocket : null,
|
||||
onRotate = () => false,
|
||||
onZoom = () => false,
|
||||
@@ -102,6 +106,7 @@ export function createMotionControlAdapter(options = {}) {
|
||||
let activeProvider = null;
|
||||
let connected = false;
|
||||
let recognitionPaused = false;
|
||||
let enabledGestureSet = normalizeEnabledGestureSet(enabledGestures);
|
||||
const lastHandledByGestureGroup = new Map();
|
||||
|
||||
function emitState(detail) {
|
||||
@@ -120,6 +125,7 @@ export function createMotionControlAdapter(options = {}) {
|
||||
}
|
||||
|
||||
function shouldHandleGesture(event) {
|
||||
if (!enabledGestureSet.has(event?.gesture)) return false;
|
||||
if (!event || event.confidence < minConfidence) return false;
|
||||
const policy = GESTURE_POLICIES[event.gesture] || {
|
||||
group: event.gesture,
|
||||
@@ -208,6 +214,8 @@ export function createMotionControlAdapter(options = {}) {
|
||||
...sharedOptions,
|
||||
url,
|
||||
WebSocketCtor,
|
||||
debugSkeleton,
|
||||
enabledGestures: Array.from(enabledGestureSet),
|
||||
});
|
||||
}
|
||||
return createBrowserCameraProvider(sharedOptions);
|
||||
@@ -242,6 +250,21 @@ export function createMotionControlAdapter(options = {}) {
|
||||
isConnected() {
|
||||
return Boolean(activeProvider?.isConnected?.());
|
||||
},
|
||||
sendCommand(command, payload = {}, requestId = null) {
|
||||
return activeProvider?.sendCommand?.(command, payload, requestId) || {
|
||||
ok: false,
|
||||
error: "motion_provider_commands_unavailable",
|
||||
requestId,
|
||||
};
|
||||
},
|
||||
setEnabledGestures(nextGestures) {
|
||||
enabledGestureSet = normalizeEnabledGestureSet(nextGestures);
|
||||
lastHandledByGestureGroup.clear();
|
||||
activeProvider?.sendCommand?.("set_enabled_gestures", {
|
||||
gestures: Array.from(enabledGestureSet),
|
||||
}, "earth-motion-enabled-gestures");
|
||||
return Array.from(enabledGestureSet);
|
||||
},
|
||||
getProvider() {
|
||||
return selectedProvider;
|
||||
},
|
||||
@@ -253,6 +276,14 @@ export function createMotionControlAdapter(options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeEnabledGestureSet(gestures) {
|
||||
const values = Array.isArray(gestures) ? gestures : DEFAULT_ENABLED_GESTURES;
|
||||
const normalized = values
|
||||
.map((gesture) => String(gesture || "").trim())
|
||||
.filter((gesture) => MOTION_GESTURES.has(gesture));
|
||||
return new Set(normalized.length > 0 ? normalized : DEFAULT_ENABLED_GESTURES);
|
||||
}
|
||||
|
||||
export {
|
||||
DEFAULT_AGENT_URL,
|
||||
DEFAULT_MOTION_PROVIDER,
|
||||
|
||||
@@ -201,6 +201,44 @@ describe("motion-control provider manager", () => {
|
||||
expect(debugEvent?.detail.confidence).toBe(0);
|
||||
});
|
||||
|
||||
test("disabled gestures are ignored and can be re-enabled at runtime", () => {
|
||||
installWindow();
|
||||
const rotations = [];
|
||||
const sentCommands = [];
|
||||
const adapter = createMotionControlAdapter({
|
||||
enabled: true,
|
||||
cooldownMs: 0,
|
||||
enabledGestures: ["zoom_in"],
|
||||
onRotate: (...args) => rotations.push(args),
|
||||
providerFactories: {
|
||||
browser_camera: () => ({
|
||||
start() {
|
||||
return true;
|
||||
},
|
||||
stop() {},
|
||||
isConnected: () => true,
|
||||
sendCommand(command, payload) {
|
||||
sentCommands.push({ command, payload });
|
||||
return { ok: true };
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
adapter.start();
|
||||
adapter.handleMessage({ type: "gesture", gesture: "rotate_left", confidence: 0.91 });
|
||||
expect(rotations).toHaveLength(0);
|
||||
|
||||
adapter.setEnabledGestures(["rotate_left"]);
|
||||
adapter.handleMessage({ type: "gesture", gesture: "rotate_left", confidence: 0.91 });
|
||||
|
||||
expect(rotations).toHaveLength(1);
|
||||
expect(sentCommands.at(-1)).toEqual({
|
||||
command: "set_enabled_gestures",
|
||||
payload: { gestures: ["rotate_left"] },
|
||||
});
|
||||
});
|
||||
|
||||
test("mock vertical gesture and focus gesture use dedicated callbacks", () => {
|
||||
installWindow();
|
||||
const rotations = [];
|
||||
|
||||
@@ -47,6 +47,10 @@ export function normalizeGestureMessage(raw, fallbackSource = "motion-provider")
|
||||
seq: Number(raw.seq || 0),
|
||||
source: raw.source || fallbackSource,
|
||||
mode: raw.mode || "single",
|
||||
protocolVersion: raw.protocol_version || raw.protocolVersion || "motion.v1",
|
||||
cameraId: raw.camera_id || raw.cameraId || "unknown",
|
||||
inputMode: raw.input_mode || raw.inputMode || raw.mode || "single",
|
||||
fusion: raw.fusion && typeof raw.fusion === "object" ? raw.fusion : null,
|
||||
payload: raw.payload && typeof raw.payload === "object" ? raw.payload : {},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,174 +1,414 @@
|
||||
const DEFAULT_LOCALE = "zh-CN";
|
||||
import { getEarthLocale, hasCjkText, normalizeLocale } from "./i18n.js";
|
||||
|
||||
const REGION_LABELS = {
|
||||
americas: "美洲",
|
||||
europe: "欧洲",
|
||||
"middle-east-africa": "中东与非洲",
|
||||
"asia-pacific": "亚太",
|
||||
global: "全球",
|
||||
"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 = {
|
||||
"Global Monitor / World": "区域监测",
|
||||
"Global Monitor / Americas": "区域监测",
|
||||
"Global Monitor / Europe": "区域监测",
|
||||
"Global Monitor / MEA": "区域监测",
|
||||
"Global Monitor / APAC": "区域监测",
|
||||
"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 = {
|
||||
politics: "政治",
|
||||
business: "商业",
|
||||
ecommerce: "电商",
|
||||
finance: "金融",
|
||||
sports: "体育",
|
||||
technology: "科技",
|
||||
military: "军事",
|
||||
disaster: "灾害",
|
||||
energy: "能源",
|
||||
society: "社会",
|
||||
culture: "文化",
|
||||
other: "其他",
|
||||
"zh-CN": {
|
||||
politics: "政治",
|
||||
business: "商业",
|
||||
ecommerce: "电商",
|
||||
finance: "金融",
|
||||
sports: "体育",
|
||||
technology: "科技",
|
||||
military: "军事",
|
||||
disaster: "灾害",
|
||||
energy: "能源",
|
||||
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 = {
|
||||
watch: "关注",
|
||||
breaking: "突发",
|
||||
critical: "严重突发",
|
||||
"zh-CN": {
|
||||
watch: "关注",
|
||||
breaking: "突发",
|
||||
critical: "严重突发",
|
||||
},
|
||||
"en-US": {
|
||||
watch: "Watch",
|
||||
breaking: "Breaking",
|
||||
critical: "Critical",
|
||||
},
|
||||
};
|
||||
|
||||
const BREAKING_SCOPE_LABELS = {
|
||||
regional: "区域",
|
||||
global: "全球",
|
||||
"zh-CN": {
|
||||
regional: "区域",
|
||||
global: "全球",
|
||||
},
|
||||
"en-US": {
|
||||
regional: "Regional",
|
||||
global: "Global",
|
||||
},
|
||||
};
|
||||
|
||||
const SOURCE_TYPE_LABELS = {
|
||||
rss: "RSS",
|
||||
atom: "Atom",
|
||||
aggregated: "Aggregated",
|
||||
reference: "Reference",
|
||||
"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 = {
|
||||
region_anchor: "区域锚点",
|
||||
ai_inferred_target: "AI 推断位置",
|
||||
headline_location_hint: "标题位置线索",
|
||||
headline_country_hint: "标题国家线索",
|
||||
"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 = {
|
||||
pending: "待增强",
|
||||
queued: "增强排队中",
|
||||
attempted: "增强中",
|
||||
success: "已汉化",
|
||||
content_only: "已汉化",
|
||||
location_only: "位置已增强",
|
||||
unavailable: "AI 未配置",
|
||||
provider_error: "增强失败",
|
||||
parse_error: "增强解析失败",
|
||||
no_result: "暂无增强结果",
|
||||
"zh-CN": {
|
||||
pending: "待增强",
|
||||
queued: "增强排队中",
|
||||
attempted: "增强中",
|
||||
success: "已汉化",
|
||||
content_only: "已汉化",
|
||||
location_only: "位置已增强",
|
||||
unavailable: "AI 未配置",
|
||||
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 = {
|
||||
queued: "新闻汉化排队中",
|
||||
attempted: "新闻汉化中",
|
||||
provider_error: "新闻汉化失败,正在重试",
|
||||
parse_error: "新闻解析失败,正在重试",
|
||||
unavailable: "等待 AI 配置",
|
||||
no_result: "新闻汉化待重试",
|
||||
location_only: "新闻汉化待重试",
|
||||
"zh-CN": {
|
||||
queued: "新闻汉化排队中",
|
||||
attempted: "新闻汉化中",
|
||||
provider_error: "新闻汉化失败,正在重试",
|
||||
parse_error: "新闻解析失败,正在重试",
|
||||
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 = {
|
||||
queued: "中文概要正在生成,请稍后刷新。",
|
||||
attempted: "中文概要正在生成,请稍后刷新。",
|
||||
provider_error: "中文概要生成失败,系统会重新提交增强任务。",
|
||||
parse_error: "中文概要解析失败,系统会重新提交增强任务。",
|
||||
unavailable: "AI 服务配置完成后将生成中文概要。",
|
||||
no_result: "中文概要暂未生成,系统会继续重试。",
|
||||
location_only: "已完成位置增强,中文概要将继续重试。",
|
||||
"zh-CN": {
|
||||
queued: "中文概要正在生成,请稍后刷新。",
|
||||
attempted: "中文概要正在生成,请稍后刷新。",
|
||||
provider_error: "中文概要生成失败,系统会重新提交增强任务。",
|
||||
parse_error: "中文概要解析失败,系统会重新提交增强任务。",
|
||||
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);
|
||||
}
|
||||
return normalizeText(
|
||||
TITLE_PLACEHOLDERS[item?.enrichment_status]
|
||||
|| "新闻汉化中",
|
||||
);
|
||||
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(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);
|
||||
}
|
||||
return normalizeText(
|
||||
SUMMARY_PLACEHOLDERS[item?.enrichment_status]
|
||||
|| "中文概要生成中,请稍后刷新。",
|
||||
);
|
||||
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(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);
|
||||
const summary = normalizeText(item?.display_summary || localized.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 === "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) {
|
||||
@@ -176,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 {
|
||||
@@ -39,6 +49,8 @@ const NEWS_SOURCE_FILTER_STORAGE_KEY = "planet.earth.newsSourceFilters.v1";
|
||||
|
||||
let initialized = false;
|
||||
let refreshPromise = null;
|
||||
let refreshRequestKey = "";
|
||||
let activeRefreshToken = 0;
|
||||
let payload = null;
|
||||
let lastFocus = null;
|
||||
let lastFetchAt = 0;
|
||||
@@ -68,6 +80,7 @@ const NEWS_CATEGORY_ALIASES = {
|
||||
culture: ["culture", "arts", "entertainment", "文化", "艺术", "娱乐"],
|
||||
other: ["other", "general", "misc", "其他", "综合"],
|
||||
};
|
||||
const NEWS_CATEGORY_KEYS = Object.keys(NEWS_CATEGORY_ALIASES);
|
||||
|
||||
function loadNewsSourceFilters() {
|
||||
try {
|
||||
@@ -126,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) {
|
||||
@@ -368,7 +382,7 @@ function escapeNewsHtml(value) {
|
||||
|
||||
function getDisplayableNewsItems(items) {
|
||||
return Array.isArray(items)
|
||||
? items.filter(isNewsContentReady)
|
||||
? items.filter((item) => isNewsContentReady(item))
|
||||
: [];
|
||||
}
|
||||
|
||||
@@ -427,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,
|
||||
@@ -458,8 +477,45 @@ 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 [];
|
||||
if (!filters || typeof filters !== "object") return [...NEWS_CATEGORY_KEYS].sort();
|
||||
return Object.entries(filters)
|
||||
.filter(([, enabled]) => enabled !== false)
|
||||
.map(([key]) => key)
|
||||
@@ -469,7 +525,7 @@ function getEnabledNewsCategoryKeys(filters = activeNewsCategoryFilters) {
|
||||
|
||||
function getNewsCategorySignature(filters = activeNewsCategoryFilters) {
|
||||
const enabled = getEnabledNewsCategoryKeys(filters);
|
||||
const total = Object.keys(NEWS_CATEGORY_ALIASES).length;
|
||||
const total = NEWS_CATEGORY_KEYS.length;
|
||||
if (enabled.length === 0) return "__none__";
|
||||
if (enabled.length === total) return "";
|
||||
return enabled.join(",");
|
||||
@@ -487,10 +543,34 @@ function getEnabledNewsSourceIds(nextPayload = payload) {
|
||||
if (!available.length) return [];
|
||||
if (!Array.isArray(activeNewsSourceFilters)) return available;
|
||||
const allowed = new Set(activeNewsSourceFilters);
|
||||
return available.filter((id) => allowed.has(id));
|
||||
const enabled = available.filter((id) => allowed.has(id));
|
||||
return enabled.length > 0 ? enabled : available;
|
||||
}
|
||||
|
||||
function reconcileNewsSourceFilters(nextPayload = payload) {
|
||||
const available = getAvailableSourceIds(nextPayload);
|
||||
if (!available.length || !Array.isArray(activeNewsSourceFilters)) return;
|
||||
|
||||
const valid = activeNewsSourceFilters.filter((id) => available.includes(id));
|
||||
const changed = valid.length !== activeNewsSourceFilters.length;
|
||||
if (activeNewsSourceFilters.length > 0 && valid.length === 0) {
|
||||
activeNewsSourceFilters = null;
|
||||
persistNewsSourceFilters(null);
|
||||
return;
|
||||
}
|
||||
if (valid.length === available.length) {
|
||||
activeNewsSourceFilters = null;
|
||||
persistNewsSourceFilters(null);
|
||||
return;
|
||||
}
|
||||
if (changed) {
|
||||
activeNewsSourceFilters = valid;
|
||||
persistNewsSourceFilters(valid);
|
||||
}
|
||||
}
|
||||
|
||||
function getNewsSourceSignature(nextPayload = payload) {
|
||||
reconcileNewsSourceFilters(nextPayload);
|
||||
const available = getAvailableSourceIds(nextPayload);
|
||||
const enabled = getEnabledNewsSourceIds(nextPayload);
|
||||
if (available.length > 0 && enabled.length === 0) return "__none__";
|
||||
@@ -498,6 +578,15 @@ function getNewsSourceSignature(nextPayload = payload) {
|
||||
return enabled.join(",");
|
||||
}
|
||||
|
||||
function getNewsSourceSignatureForFetch(lat, lon) {
|
||||
const currentRegion = payload?.focus?.region || null;
|
||||
const nextRegion = inferRegion(lat, lon);
|
||||
if (currentRegion && nextRegion !== currentRegion) {
|
||||
return "";
|
||||
}
|
||||
return getNewsSourceSignature();
|
||||
}
|
||||
|
||||
function getNewsLimit() {
|
||||
return newsFullListMode ? NEWS_FULL_LIMIT : NEWS_SUMMARY_LIMIT;
|
||||
}
|
||||
@@ -512,15 +601,15 @@ 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) {
|
||||
const categories = getEnabledNewsCategoryKeys();
|
||||
const totalCategories = Object.keys(NEWS_CATEGORY_ALIASES).length;
|
||||
const totalCategories = NEWS_CATEGORY_KEYS.length;
|
||||
const sources = getAvailableSourceIds(nextPayload);
|
||||
const enabledSources = getEnabledNewsSourceIds(nextPayload);
|
||||
document.querySelectorAll('[data-news-filter-summary="category"]').forEach((el) => {
|
||||
@@ -531,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 ? "返回摘要" : "查看全部");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -550,7 +641,7 @@ function closeNewsFilterPopover() {
|
||||
|
||||
function renderCategoryFilterChips() {
|
||||
const enabled = new Set(getEnabledNewsCategoryKeys());
|
||||
return Object.keys(NEWS_CATEGORY_ALIASES)
|
||||
return NEWS_CATEGORY_KEYS
|
||||
.map((key) => `
|
||||
<button
|
||||
class="news-filter-chip${enabled.has(key) ? " is-active" : ""}"
|
||||
@@ -565,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();
|
||||
@@ -577,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;
|
||||
@@ -639,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;
|
||||
}
|
||||
@@ -659,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>
|
||||
`)
|
||||
@@ -675,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" } });
|
||||
@@ -683,6 +779,7 @@ function renderEmptyState(message) {
|
||||
|
||||
function renderPayload(nextPayload) {
|
||||
payload = nextPayload;
|
||||
reconcileNewsSourceFilters(nextPayload);
|
||||
const {
|
||||
board,
|
||||
empty,
|
||||
@@ -725,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");
|
||||
}
|
||||
|
||||
@@ -735,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];
|
||||
@@ -769,8 +863,8 @@ function renderPayload(nextPayload) {
|
||||
if (empty) {
|
||||
empty.hidden = false;
|
||||
empty.textContent = items.length === 0
|
||||
? "当前未拉到可用新闻,请稍后刷新或切换视角区域。"
|
||||
: "当前新闻类型没有可显示新闻。";
|
||||
? translateText("当前未拉到可用新闻,请稍后刷新或切换视角区域。")
|
||||
: translateText("当前新闻类型没有可显示新闻。");
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -790,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);
|
||||
@@ -921,21 +1015,28 @@ function connectNewsRealtime() {
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchNews(lat, lon) {
|
||||
const categorySignature = getNewsCategorySignature();
|
||||
const sourceSignature = getNewsSourceSignature();
|
||||
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: [],
|
||||
@@ -949,7 +1050,7 @@ async function fetchNews(lat, lon) {
|
||||
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);
|
||||
@@ -966,15 +1067,31 @@ async function fetchNews(lat, lon) {
|
||||
}
|
||||
|
||||
async function refreshNews(lat, lon, { silent = false } = {}) {
|
||||
if (refreshPromise) return refreshPromise;
|
||||
const targetRegion = inferRegion(lat, lon);
|
||||
const categorySignature = getNewsCategorySignature();
|
||||
const sourceSignature = getNewsSourceSignatureForFetch(lat, lon);
|
||||
const requestKey = [
|
||||
targetRegion,
|
||||
categorySignature,
|
||||
sourceSignature,
|
||||
getNewsLimit(),
|
||||
getEarthLocale(),
|
||||
].join("|");
|
||||
|
||||
if (refreshPromise && refreshRequestKey === requestKey) return refreshPromise;
|
||||
const requestToken = ++activeRefreshToken;
|
||||
refreshRequestKey = requestKey;
|
||||
|
||||
const { status } = getElements();
|
||||
if (status) {
|
||||
status.textContent = "正在同步全球态势新闻...";
|
||||
}
|
||||
|
||||
refreshPromise = fetchNews(lat, lon)
|
||||
refreshPromise = fetchNews(lat, lon, { categorySignature, sourceSignature })
|
||||
.then((nextPayload) => {
|
||||
if (requestToken !== activeRefreshToken) {
|
||||
return nextPayload;
|
||||
}
|
||||
renderPayload(nextPayload);
|
||||
lastFetchAt = Date.now();
|
||||
lastCategorySignature = getNewsCategorySignature();
|
||||
@@ -982,25 +1099,32 @@ 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) => {
|
||||
if (requestToken !== activeRefreshToken) {
|
||||
return null;
|
||||
}
|
||||
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;
|
||||
})
|
||||
.finally(() => {
|
||||
refreshPromise = null;
|
||||
if (requestToken === activeRefreshToken) {
|
||||
refreshPromise = null;
|
||||
refreshRequestKey = "";
|
||||
}
|
||||
});
|
||||
|
||||
return refreshPromise;
|
||||
@@ -1127,7 +1251,7 @@ export function initNewsPanel() {
|
||||
initialized = true;
|
||||
|
||||
updateNewsToggleUI(true);
|
||||
renderEmptyState("正在准备全球态势新闻聚合源...");
|
||||
renderEmptyState(translateText("正在准备全球态势新闻聚合源..."));
|
||||
|
||||
const { ticker, hudCloseBtn } = getElements();
|
||||
ticker?.addEventListener("click", (event) => {
|
||||
@@ -1194,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();
|
||||
|
||||
@@ -1202,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");
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { Suspense, lazy } from 'react'
|
||||
import { Suspense, lazy, useEffect } from 'react'
|
||||
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Routes, Route, Navigate, useLocation } from 'react-router-dom'
|
||||
|
||||
import { useAuthStore } from './stores/auth'
|
||||
import Login from './pages/Login/Login'
|
||||
import { AdminErrorBoundary } from './admin/components/AdminErrorBoundary'
|
||||
import LegacyI18nBridge from './i18n/LegacyI18nBridge'
|
||||
|
||||
const Register = lazy(() => import('./pages/Register/Register'))
|
||||
const VerifyEmail = lazy(() => import('./pages/VerifyEmail/VerifyEmail'))
|
||||
@@ -26,35 +28,43 @@ function isPublicPath(pathname: string) {
|
||||
}
|
||||
|
||||
function App() {
|
||||
const { t } = useTranslation()
|
||||
const { token } = useAuthStore()
|
||||
const { pathname } = useLocation()
|
||||
const isPublicRoute = isPublicPath(pathname)
|
||||
|
||||
useEffect(() => {
|
||||
document.title = t('app.title')
|
||||
}, [t])
|
||||
|
||||
if (!token && !isPublicRoute) {
|
||||
return <Login />
|
||||
}
|
||||
|
||||
return (
|
||||
<Suspense
|
||||
fallback={(
|
||||
<div className="app-route-loading">
|
||||
<div className="app-route-loading__spinner" aria-label="正在加载" />
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/register" element={<Register />} />
|
||||
<Route path="/verify-email" element={<VerifyEmail />} />
|
||||
<Route path="/forgot-password" element={<ForgotPassword />} />
|
||||
<Route path={ROOT_ROUTE} element={<Navigate to={EARTH_ROUTE} replace />} />
|
||||
<Route path={EARTH_ROUTE} element={<Earth />} />
|
||||
<Route path={DOCS_ROUTE} element={<Docs />} />
|
||||
<Route path={DOCS_ROUTE_PATTERN} element={<Docs />} />
|
||||
<Route path="/playground" element={<Navigate to="/ai?section=playground" replace />} />
|
||||
<Route path="/*" element={<AdminErrorBoundary><AdminRoutes /></AdminErrorBoundary>} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
<>
|
||||
<LegacyI18nBridge />
|
||||
<Suspense
|
||||
fallback={(
|
||||
<div className="app-route-loading">
|
||||
<div className="app-route-loading__spinner" aria-label={t('app.routeLoading')} />
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/register" element={<Register />} />
|
||||
<Route path="/verify-email" element={<VerifyEmail />} />
|
||||
<Route path="/forgot-password" element={<ForgotPassword />} />
|
||||
<Route path={ROOT_ROUTE} element={<Navigate to={EARTH_ROUTE} replace />} />
|
||||
<Route path={EARTH_ROUTE} element={<Earth />} />
|
||||
<Route path={DOCS_ROUTE} element={<Docs />} />
|
||||
<Route path={DOCS_ROUTE_PATTERN} element={<Docs />} />
|
||||
<Route path="/playground" element={<Navigate to="/ai?section=playground" replace />} />
|
||||
<Route path="/*" element={<AdminErrorBoundary><AdminRoutes /></AdminErrorBoundary>} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from '@tanstack/react-table'
|
||||
import { ArrowDown, ArrowUp, ChevronsUpDown } from 'lucide-react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import TableScrollRegion from '../../../components/Scrollbar/TableScrollRegion'
|
||||
import { Button } from '../ui/button'
|
||||
|
||||
@@ -37,11 +38,12 @@ export function DataTable<TData>({
|
||||
getRowClassName,
|
||||
selection,
|
||||
loading = false,
|
||||
emptyText = '暂无数据',
|
||||
emptyText,
|
||||
className = '',
|
||||
footer,
|
||||
onRowClick,
|
||||
}: DataTableProps<TData>) {
|
||||
const { t } = useTranslation()
|
||||
const [sorting, setSorting] = useState<SortingState>([])
|
||||
const memoizedColumns = useMemo(() => columns, [columns])
|
||||
|
||||
@@ -74,7 +76,7 @@ export function DataTable<TData>({
|
||||
<th className="an-data-table__selection-cell">
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label="选择当前可见数据"
|
||||
aria-label={t('common.selectVisibleRows')}
|
||||
checked={allVisibleSelected}
|
||||
disabled={!visibleSelectableIds.length}
|
||||
ref={(element) => {
|
||||
@@ -114,7 +116,7 @@ export function DataTable<TData>({
|
||||
<td colSpan={columnCount}>
|
||||
<div className="an-data-table__state">
|
||||
<span className="an-spinner" />
|
||||
加载中
|
||||
{t('common.loading')}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -131,7 +133,7 @@ export function DataTable<TData>({
|
||||
<td className="an-data-table__selection-cell">
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={selection.getCheckboxLabel?.(row.original) || '选择行'}
|
||||
aria-label={selection.getCheckboxLabel?.(row.original) || t('common.selectRow')}
|
||||
checked={selection.selectedRowIds.has(row.id)}
|
||||
disabled={selection.isRowSelectable ? !selection.isRowSelectable(row.original) : false}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
@@ -149,7 +151,7 @@ export function DataTable<TData>({
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan={columnCount}>
|
||||
<div className="an-data-table__state">{emptyText}</div>
|
||||
<div className="an-data-table__state">{emptyText || t('common.noData')}</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
@@ -173,18 +175,19 @@ export function DataTablePager({
|
||||
total: number
|
||||
onPageChange: (page: number) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize))
|
||||
return (
|
||||
<div className="an-data-table__pager">
|
||||
<span>
|
||||
第 {page} / {totalPages} 页,共 {total.toLocaleString()} 条
|
||||
{t('common.page', { page, totalPages, total: total.toLocaleString() })}
|
||||
</span>
|
||||
<div className="an-data-table__pager-actions">
|
||||
<Button size="sm" variant="subtle" disabled={page <= 1} onClick={() => onPageChange(page - 1)}>
|
||||
上一页
|
||||
{t('common.previousPage')}
|
||||
</Button>
|
||||
<Button size="sm" variant="subtle" disabled={page >= totalPages} onClick={() => onPageChange(page + 1)}>
|
||||
下一页
|
||||
{t('common.nextPage')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
import {
|
||||
ChevronDown,
|
||||
Languages,
|
||||
LogOut,
|
||||
Menu,
|
||||
Moon,
|
||||
Monitor,
|
||||
Search,
|
||||
Settings,
|
||||
Sun,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import { type FocusEvent, type KeyboardEvent, type ReactNode, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
||||
import packageJson from '../../../../package.json'
|
||||
import Scrollbar from '../../../components/Scrollbar/Scrollbar'
|
||||
import SegmentedControl from '../../../components/SegmentedControl/SegmentedControl'
|
||||
import { localeOptions, useLocale, type SupportedLocale } from '../../../i18n/locale'
|
||||
import { useAuthStore } from '../../../stores/auth'
|
||||
import { useAdminTheme, type AdminThemeMode } from '../../design/theme'
|
||||
import { cn } from '../../utils'
|
||||
@@ -28,6 +32,8 @@ export function AdminLayout({ children }: { children: ReactNode }) {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const adminSearch = useAdminSearch()
|
||||
const { t } = useTranslation()
|
||||
const { locale, setLocale } = useLocale()
|
||||
const { user, logout } = useAuthStore()
|
||||
const { mode, setMode } = useAdminTheme()
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
@@ -35,25 +41,39 @@ export function AdminLayout({ children }: { children: ReactNode }) {
|
||||
const [openKeys, setOpenKeys] = useState<string[]>(cachedOpenKeys)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [searchOpen, setSearchOpen] = useState(false)
|
||||
const [preferencesOpen, setPreferencesOpen] = useState(false)
|
||||
const [highlightedSearchIndex, setHighlightedSearchIndex] = useState(0)
|
||||
const menuViewportRef = useRef<HTMLDivElement>(null)
|
||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||
const isSuperAdmin = user?.role === 'super_admin'
|
||||
const username = user?.username || '-'
|
||||
const userInitial = username.trim().charAt(0).toUpperCase() || '?'
|
||||
const preferencesLabel = preferencesOpen ? t('admin.collapsePreferences') : t('admin.expandPreferences')
|
||||
const visibleRoutes = useMemo(() => getVisibleAdminRoutes(isSuperAdmin), [isSuperAdmin])
|
||||
const navGroups = useMemo(() => {
|
||||
return adminRouteGroups.map((group) => ({
|
||||
...group,
|
||||
children: visibleRoutes.filter((route) => route.group === group.key),
|
||||
label: t(group.labelKey),
|
||||
children: visibleRoutes
|
||||
.filter((route) => route.group === group.key)
|
||||
.map((route) => ({ ...route, label: t(route.labelKey) })),
|
||||
})).filter((group) => group.children.length > 0)
|
||||
}, [visibleRoutes])
|
||||
}, [t, visibleRoutes])
|
||||
const selectedKey = location.pathname === '/admin/' ? '/admin' : location.pathname.replace(/\/$/, '')
|
||||
const activeRoute = visibleRoutes.find((route) => route.path === selectedKey)
|
||||
const activeRouteLabel = activeRoute ? t(activeRoute.labelKey) : ''
|
||||
const searchResults = useMemo(() => adminSearch.search(searchQuery), [adminSearch, searchQuery])
|
||||
const themeOptions = useMemo(() => [
|
||||
{ value: 'light' as const, label: '浅色', title: '浅色', icon: <Sun /> },
|
||||
{ value: 'system' as const, label: '系统', title: '跟随系统', icon: <Monitor /> },
|
||||
{ value: 'dark' as const, label: '深色', title: '深色', icon: <Moon /> },
|
||||
], [])
|
||||
{ value: 'light' as const, label: t('common.themeLight'), title: t('common.themeLight'), icon: <Sun /> },
|
||||
{ value: 'system' as const, label: t('common.themeSystem'), title: t('common.themeFollowSystem'), icon: <Monitor /> },
|
||||
{ value: 'dark' as const, label: t('common.themeDark'), title: t('common.themeDark'), icon: <Moon /> },
|
||||
], [t])
|
||||
const languageOptions = useMemo(() => localeOptions.map((option) => ({
|
||||
value: option.value,
|
||||
label: t(option.labelKey),
|
||||
title: t(option.titleKey),
|
||||
icon: <Languages />,
|
||||
})), [t])
|
||||
|
||||
const updateOpenKeys = (nextKeys: string[]) => {
|
||||
cachedOpenKeys = nextKeys
|
||||
@@ -116,14 +136,15 @@ export function AdminLayout({ children }: { children: ReactNode }) {
|
||||
event.stopPropagation()
|
||||
setCollapsed((value) => !value)
|
||||
}}
|
||||
aria-label={collapsed ? '展开菜单' : '折叠菜单'}
|
||||
title={collapsed ? t('admin.expandMenu') : t('admin.collapseMenu')}
|
||||
aria-label={collapsed ? t('admin.expandMenu') : t('admin.collapseMenu')}
|
||||
>
|
||||
{collapsed ? <Menu size={18} /> : <X size={18} />}
|
||||
</Button>
|
||||
{!collapsed ? (
|
||||
<div className="admin__brand-copy">
|
||||
<span className="admin__brand-text">智能星球</span>
|
||||
<span className="admin__brand-subtitle">控制台</span>
|
||||
<span className="admin__brand-text">{t('admin.brandTitle')}</span>
|
||||
<span className="admin__brand-subtitle">{t('admin.brandSubtitle')}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -185,36 +206,61 @@ export function AdminLayout({ children }: { children: ReactNode }) {
|
||||
|
||||
{!collapsed ? (
|
||||
<div className="admin__account">
|
||||
<div className="admin__account-row">
|
||||
<div>
|
||||
<strong>Hi, {user?.username || '-'}</strong>
|
||||
<div className="admin__account-row admin__account-row--primary">
|
||||
<div className="admin__account-profile">
|
||||
<span className="admin__account-avatar" aria-hidden="true">{userInitial}</span>
|
||||
<div>
|
||||
<strong>{t('admin.greeting', { name: username })}</strong>
|
||||
<span>{t('admin.version')} v{packageJson.version}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin__account-actions">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className={cn('admin__account-preferences', preferencesOpen && 'is-active')}
|
||||
onClick={() => setPreferencesOpen((value) => !value)}
|
||||
title={preferencesLabel}
|
||||
aria-label={preferencesLabel}
|
||||
aria-expanded={preferencesOpen}
|
||||
>
|
||||
<Settings size={15} />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="admin__account-logout"
|
||||
onClick={() => {
|
||||
logout()
|
||||
navigate('/login')
|
||||
}}
|
||||
title={t('admin.logout')}
|
||||
aria-label={t('admin.logout')}
|
||||
>
|
||||
<LogOut size={15} />
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="admin__account-logout"
|
||||
onClick={() => {
|
||||
logout()
|
||||
navigate('/login')
|
||||
}}
|
||||
aria-label="退出登录"
|
||||
title="退出登录"
|
||||
>
|
||||
<LogOut size={15} />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="admin__account-row">
|
||||
<span>版本号</span>
|
||||
<strong>v{packageJson.version}</strong>
|
||||
<div className={cn('admin__preferences-drawer', preferencesOpen && 'is-open')} aria-hidden={!preferencesOpen}>
|
||||
<div className="admin__preferences-panel">
|
||||
<SegmentedControl<SupportedLocale>
|
||||
ariaLabel={t('admin.languageControl')}
|
||||
className="admin__language-control admin__language-control--sider"
|
||||
options={languageOptions}
|
||||
scale={0.86}
|
||||
value={locale}
|
||||
onChange={setLocale}
|
||||
/>
|
||||
<SegmentedControl<AdminThemeMode>
|
||||
ariaLabel={t('admin.themeControl')}
|
||||
className="admin__theme-control admin__theme-control--sider"
|
||||
options={themeOptions}
|
||||
scale={0.86}
|
||||
value={mode}
|
||||
onChange={setMode}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<SegmentedControl<AdminThemeMode>
|
||||
ariaLabel="控制台主题"
|
||||
className="admin__theme-control admin__theme-control--sider"
|
||||
options={themeOptions}
|
||||
scale={0.72}
|
||||
value={mode}
|
||||
onChange={setMode}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
@@ -249,22 +295,22 @@ export function AdminLayout({ children }: { children: ReactNode }) {
|
||||
{mobileNavOpen ? (
|
||||
<div className="admin__mobile-nav">
|
||||
<div className="admin__mobile-nav-panel">{nav}</div>
|
||||
<button className="admin__mobile-nav-backdrop" type="button" aria-label="关闭导航" onClick={() => setMobileNavOpen(false)} />
|
||||
<button className="admin__mobile-nav-backdrop" type="button" aria-label={t('admin.closeNav')} onClick={() => setMobileNavOpen(false)} />
|
||||
</div>
|
||||
) : null}
|
||||
<main className="admin__content">
|
||||
<header className="admin__topbar">
|
||||
<Button size="icon" variant="ghost" className="admin__mobile-menu" onClick={() => setMobileNavOpen(true)} aria-label="打开导航">
|
||||
<Button size="icon" variant="ghost" className="admin__mobile-menu" onClick={() => setMobileNavOpen(true)} aria-label={t('admin.openNav')} title={t('admin.openNav')}>
|
||||
<Menu size={18} />
|
||||
</Button>
|
||||
<div className="admin__search" onBlur={handleSearchBlur}>
|
||||
<Search className="admin__search-icon" size={16} />
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
aria-label="搜索功能、配置和文字"
|
||||
aria-label={t('admin.search.label')}
|
||||
autoComplete="off"
|
||||
value={searchQuery}
|
||||
placeholder={activeRoute ? `搜索功能、配置和文字,当前:${activeRoute.label}` : '搜索功能、配置和文字'}
|
||||
placeholder={activeRouteLabel ? `${t('admin.search.placeholder')},${t('admin.search.current', { label: activeRouteLabel })}` : t('admin.search.placeholder')}
|
||||
onChange={(event) => {
|
||||
setSearchQuery(event.target.value)
|
||||
setSearchOpen(true)
|
||||
@@ -274,7 +320,7 @@ export function AdminLayout({ children }: { children: ReactNode }) {
|
||||
onKeyDown={handleSearchKeyDown}
|
||||
/>
|
||||
{searchOpen ? (
|
||||
<div className="admin__search-results" role="listbox" aria-label="Admin 搜索结果">
|
||||
<div className="admin__search-results" role="listbox" aria-label={t('admin.search.results')}>
|
||||
{searchResults.length > 0 ? searchResults.map((target, index) => {
|
||||
const ResultIcon = target.icon || Search
|
||||
return (
|
||||
@@ -296,7 +342,7 @@ export function AdminLayout({ children }: { children: ReactNode }) {
|
||||
</button>
|
||||
)
|
||||
}) : (
|
||||
<div className="admin__search-empty">{adminSearch.loading ? '正在加载搜索索引…' : '没有找到匹配内容'}</div>
|
||||
<div className="admin__search-empty">{adminSearch.loading ? t('admin.search.loading') : t('admin.search.empty')}</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||