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

This commit is contained in:
rayd1o
2026-05-17 02:50:42 +08:00
parent 9b913a3b83
commit 81970a1d05
43 changed files with 3378 additions and 463 deletions

203
README.md
View File

@@ -8,68 +8,54 @@
## 系统架构 ## 系统架构
当前仓库的核心形态是“Web Earth 可视化 + React 运维台 + FastAPI 数据与 AI 编排后端 + 独立模型适配层”。物理大屏与 UE 客户端仍是长期方向,但不再作为本地开发和当前发布的必需运行单元。
``` ```
┌───────────────────────────────────────────────────────────────────────── ┌─────────────────────────────────────────────────────────────────────┐
物理大屏展示层 浏览器展示与运维层
│ ┌─────────────────────────────────────────────────────────────────┐ │ ┌──────────────────────────────┐ ┌──────────────────────────────┐ │
│ │ 偏振片3D大屏 (2m×3m, 4K, 120Hz, 眼镜式) │ │ Web Earth │ │ React 运维台 │ │
│ │ ┌─────────────────────────────────────────────────────────┐ │ │ frontend/public/earth frontend/src │
│ │ │ 虚幻引擎 UE5 客户端 │ │ Three.js 地球 / HUD / 新闻 │ 数据源 / 告警 / AI 设置
│ │ │ ├── 3D地球渲染 (Cesium for UE) │ │ 国界精度 / 品牌内容配置 │ 提示词配置 / 用户与系统配置 │ │
│ │ ├── 算力点可视化 (GPU集群、智算中心) │ │ └──────────────────────────────┘ └──────────────────────────────┘
│ │ │ ├── 连接弧线 (光缆、路由、数据流向) │ │ │ └─────────────────────────────────────────────────────────────────────┘
│ │ ├── 粒子效果 (数据流动、告警提示) │ │ │ │ REST / WebSocket
│ └── 自动巡航相机 + 交互控制 │ │ │
│ │ └─────────────────────────────────────────────────────────┘ │ │ ─────────────────────────────────────────────────────────────────────┐
└─────────────────────────────────────────────────────────────────┘ FastAPI 业务与编排后端
─────────────────────────────────────────────────────────────────────────┘ │ ┌────────────────────┐ ┌────────────────────┐ ┌─────────────────┐ │
│ │ 数据 API 与认证 │ │ Earth 新闻增强 │ │ 告警与态势简报 │ │
│ WebSocket (实时推送) │ │ JWT / 权限 / 审计 │ │ 位置推断 / 本地化 │ │ BGP / 告警研判 │ │
│ 120Hz 心跳 / 数据帧同步 │ └────────────────────┘ └────────────────────┘ └─────────────────┘ │
┌────────────────────┐ ┌────────────────────┐ ┌─────────────────┐ │
┌─────────────────────────────────────────────────────────────────────────┐ │ │ 系统运行配置 │ │ 默认提示词注册表 │ │ 未来 Agent Runtime│ │
数据中台服务层 (FastAPI) │ system_settings │ │ 代码发布 + DB 覆盖 │ │ 工具/证据/工作流
─────────────────────────────────────────────────────────────────┐ ────────────────────┘ └────────────────────┘ └─────────────────
│ │ API Gateway (Redis 限流) │ │ └─────────────────────────────────────────────────────────────────────┘
│ └─────────────────────────────────────────────────────────────────┘ │ │ SQLAlchemy / Redis Stream │ 纯净 LLM 调用
│ │
┌───────────────────┬────────────────────────────────────────────┐ ┌──────────────────────────────┐ ┌──────────────────────────────┐
│ 数据采集服务核心业务服务 运维管理服务 PostgreSQL / Redis │ aiprovider
│ ┌─────────────┐ │ ┌─────────────────┐ │ ┌─────────────┐ │ 用户、配置、采集结果、新闻 │ │ provider + protocol adapter
│ │ 调度中心 │ │ │ WebSocket 服务 │ │ │ 用户管理 │ │ Stream、缓存、运行状态 │ │ OpenAI / MiniMax / Ollama 等
│ │ │ (Celery) │ │ │ (FastAPI) │ │ │ (JWT Auth) │ │ │ └──────────────────────────────┘ └──────────────────────────────┘
└─────────────┘ └─────────────────┘ │ └─────────────┘ │ │
┌─────────────┐ │ ┌─────────────────┐ │ ┌─────────────┐ │ │ │ 采集器 / 外部数据源
│ 采集器池 │ │ │ 数据查询 API │ │ │ 数据源配置 │ │ │
│ │ │ (10+源) │ │ │ (REST) │ │ │ 监控告警 │ │ │ ┌─────────────────────────────────────────────────────────────────────┐
│ └─────────────┘ │ └─────────────────┘ │ └─────────────┘ │ RSS 新闻、BGP 观测、公开数据源、后续 WebSearch/OCR/语音识别等工具
│ │ ┌─────────────┐ │ ┌─────────────────┐ │ ┌─────────────┐ │ │ ─────────────────────────────────────────────────────────────────────┘
│ │ │ 消息队列 │ │ │ 态势分析引擎 │ │ │ 系统配置 │ │ │
│ │ │ (Kafka) │ │ │ (计算/聚合) │ │ │ 日志审计 │ │ │
│ │ └─────────────┘ │ └─────────────────┘ │ └─────────────┘ │ │
│ └───────────────────┴──────────────────────────┴──────────────────┘ │
└─────────────────────────────────────────────────────────────────────────┘
│ 内部 API 调用
┌─────────────────────────────────────────────────────────────────────────┐
│ Web管理端 (React Admin) │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ 登录页 │ 仪表盘 │ 用户管理 │ 数据源配置 │ 任务监控 │ 系统配置 │ │
│ └─────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────┘
│ PostgreSQL / Redis
┌─────────────────────────────────────────────────────────────────────────┐
│ 数据存储层 │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ PostgreSQL │ │ TimescaleDB │ │ Redis │ │ MinIO │ │
│ │ (用户/配置) │ │ (时序数据) │ │ (缓存/会话) │ │ (文件存储) │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ │
└─────────────────────────────────────────────────────────────────────────┘
``` ```
架构边界:
- `backend` 负责业务语义、证据收集、提示词选择、AI 任务编排、权限和数据落库。
- `aiprovider` 只负责把纯净模型请求适配到不同供应商或协议,不内置具体业务提示词。
- 默认提示词随代码发布并保存在 `backend/app/ai_tasks/default_prompts.json`,运维台可在数据库中保存覆盖值,重置时回到当前代码版本的默认提示词。
- Earth 新闻保留英文原文,中文展示结果存入 `localizations`,前端默认展示 `zh-CN``display_title``display_summary` 和中文地域/状态文案。
- Earth LLM 指令、语音识别、多角色态势研判属于后续 Agent Runtime 方向,计划见 [docs/plans/agents-earth-command-runtime-plan.md](/home/ray/dev/linkong/planet/docs/plans/agents-earth-command-runtime-plan.md)。
## 四大核心要素 ## 四大核心要素
| 层级 | 要素 | 描述 | | 层级 | 要素 | 描述 |
@@ -87,11 +73,10 @@
|------|------|------| |------|------|------|
| FastAPI | 0.109+ | Web 框架 | | FastAPI | 0.109+ | Web 框架 |
| SQLAlchemy | 2.0+ | ORM | | SQLAlchemy | 2.0+ | ORM |
| Alembic | - | 数据库迁移 | | uv | - | Python 依赖与命令运行 |
| Celery | 5.3+ | 任务队列 | | Redis | 7.0+ | 缓存、Stream 与运行协调 |
| Redis | 7.0+ | 缓存/消息 |
| Kafka | 3.0+ | 事件流 |
| PyJWT | - | 认证 | | PyJWT | - | 认证 |
| APScheduler / 后台任务 | - | 采集、增强与运行时任务 |
### 前端 (React Admin) ### 前端 (React Admin)
@@ -102,6 +87,7 @@
| Axios | HTTP 客户端 | | Axios | HTTP 客户端 |
| Socket.io-client | WebSocket 客户端 | | Socket.io-client | WebSocket 客户端 |
| ECharts | 统计图表 | | ECharts | 统计图表 |
| Three.js | Earth 3D 地球渲染 |
| Bun | 前端包管理与脚本运行 | | Bun | 前端包管理与脚本运行 |
前端工程统一使用 Bun 前端工程统一使用 Bun
@@ -110,22 +96,16 @@
- 运行脚本使用 `bun run <script>` - 运行脚本使用 `bun run <script>`
- 不使用 `npm``pnpm``yarn` - 不使用 `npm``pnpm``yarn`
### 虚幻引擎客户端 ### 大屏与 3D 展示方向
| 组件 | 版本 | 用途 | 当前发布优先使用浏览器 Web Earth。UE5 / Cesium for Unreal / Niagara 可作为后续物理大屏方向接入,但不是本地开发闭环的必需组件。
|------|------|------|
| Unreal Engine 5 | 5.3+ | 3D 渲染引擎 |
| Cesium for Unreal | 1.5+ | 地理可视化 |
| Niagara | - | 粒子系统 |
### 数据库 ### 数据库
| 组件 | 用途 | | 组件 | 用途 |
|------|------| |------|------|
| PostgreSQL 15+ | 关系数据 | | PostgreSQL 15+ | 关系数据 |
| TimescaleDB | 时序数据扩展 | | Redis 7+ | 缓存、Stream、运行状态 |
| Redis 7+ | 缓存/会话 |
| MinIO | S3 兼容存储 |
### 部署 ### 部署
@@ -152,9 +132,9 @@
| P0 | Epoch AI | 每小时 | | P0 | Epoch AI | 每小时 |
| P0 | Hugging Face | 每 2 小时 | | P0 | Hugging Face | 每 2 小时 |
| P0 | GitHub | 每 4 小时 | | P0 | GitHub | 每 4 小时 |
| P0 每日 | | P0 | 海底光缆 / IXP / 卫星等基础设施数据 | 每日或按源刷新 |
| P0 | PeeringDB | 每 2 小时 | | P0 | PeeringDB | 每 2 小时 |
| P1 | Cloudflare Radar | | TeleGeography | 每小时 | | P1 | Cloudflare Radar / TeleGeography | 每小时 |
| P1 | CAIDA BGPStream | 每 15 分钟 | | P1 | CAIDA BGPStream | 每 15 分钟 |
## 项目结构 ## 项目结构
@@ -166,20 +146,18 @@
│ │ ├── core/ # 核心配置 │ │ ├── core/ # 核心配置
│ │ ├── models/ # 数据模型 │ │ ├── models/ # 数据模型
│ │ ├── schemas/ # Pydantic 模型 │ │ ├── schemas/ # Pydantic 模型
│ │ ├── services/ # 业务逻辑 │ │ ├── services/ # 业务逻辑与 AI 任务编排
│ │ └── tasks/ # Celery 任务 │ │ └── ai_tasks/ # 默认提示词与 AI 任务定义
│ └── tests/ │ └── tests/
├── aiprovider/ # 独立模型供应商适配层
├── frontend/ # React 管理后台 ├── frontend/ # React 管理后台
│ ├── src/ │ ├── src/
│ │ ├── components/ # 组件 │ │ ├── components/ # 组件
│ │ ├── pages/ # 页面 │ │ ├── pages/ # 页面
│ │ ├── services/ # API 服务 │ │ ├── services/ # API 服务
│ │ └── store/ # 状态管理 │ │ └── store/ # 状态管理
── tests/ ── public/earth/ # Web Earth 静态应用
├── unreal/ # UE5 大屏客户端 │ └── tests/ # 前端测试
│ ├── Content/
│ ├── Source/
│ └── Plugins/
├── data/ # 数据文件 ├── data/ # 数据文件
├── docs/ # 文档 ├── docs/ # 文档
├── scripts/ # 脚本 ├── scripts/ # 脚本
@@ -364,14 +342,23 @@ DATABASE_RETRY_INTERVAL=10 \
- `FRONTEND_HEALTH_CHECK_ATTEMPTS` / `FRONTEND_HEALTH_CHECK_INTERVAL`: 控制前端 HTTP 可访问检查等待次数与间隔,默认 `10` 次、`2` - `FRONTEND_HEALTH_CHECK_ATTEMPTS` / `FRONTEND_HEALTH_CHECK_INTERVAL`: 控制前端 HTTP 可访问检查等待次数与间隔,默认 `10` 次、`2`
- `AI_PROVIDER_HEALTH_CHECK_ATTEMPTS` / `AI_PROVIDER_HEALTH_CHECK_INTERVAL`: 控制 `aiprovider` HTTP 健康检查等待次数与间隔,默认 `10` 次、`2` - `AI_PROVIDER_HEALTH_CHECK_ATTEMPTS` / `AI_PROVIDER_HEALTH_CHECK_INTERVAL`: 控制 `aiprovider` HTTP 健康检查等待次数与间隔,默认 `10` 次、`2`
## AI 接口预留 ## AI 与智能体接口
项目现在采用“两层”设计: 项目现在采用“三段式”边界:
- 主后端暴露稳定业务接口: `GET /api/v1/ai/provider/status``POST /api/v1/ai/situational-awareness/analyze` - `backend`: 暴露业务接口,负责选择任务提示词、组织证据、调用工具、保存 AI 设置和结果。
- 独立 `aiprovider` 服务负责适配具体模型供应商 - `aiprovider`: 暴露模型网关接口,只负责 provider / protocol 适配,不写入 BGP、新闻、告警等业务提示词。
- 模型供应商: OpenAI 兼容、MiniMax、Anthropic、Ollama 或其他兼容网关。
这样前端和业务代码不直接依赖 OpenAI、本地模型网关或其他订阅服务后续切换部署方式只需要调整环境变量 这样前端和业务代码不直接依赖某个模型供应商,后续增加 Agent Runtime、Earth 一键 LLM 指令、语音识别或多角色态势研判时,也可以把业务工作流放在后端,而不是污染模型适配层
当前已落地的 AI 配置能力:
- 运维台 AI 设置可维护 provider、模型、协议、超时、token 等运行配置。
- 运维台 AI 设置中的“提示词”页可选择不同功能入口,手动覆盖提示词,并一键重置到默认值。
- 默认提示词随代码发布,位于 [backend/app/ai_tasks/default_prompts.json](/home/ray/dev/linkong/planet/backend/app/ai_tasks/default_prompts.json)。
- 覆盖值保存在数据库运行配置中,升级代码后可继续保留现场配置,也可重置到新版本默认提示词。
- 态势摘要、告警研判、新闻本地化等入口应使用各自任务提示词;调用 `aiprovider` 时只传递当前任务所需的 `prompt` / `system_prompt`
主后端建议配置: 主后端建议配置:
@@ -384,39 +371,33 @@ AI_PROVIDER_TIMEOUT_SECONDS=60
`aiprovider` 服务建议配置: `aiprovider` 服务建议配置:
```env ```env
AI_PROVIDER=openai_compatible AI_PROVIDER=minimax
AI_BASE_URL=https://api.openai.com/v1 AI_PROVIDER_API=anthropic-messages
AI_BASE_URL=https://api.minimaxi.com/anthropic
AI_API_KEY=your_api_key AI_API_KEY=your_api_key
AI_MODEL=gpt-4o-mini AI_MODEL=MiniMax-M2.7
AI_TIMEOUT_SECONDS=60 AI_TIMEOUT_SECONDS=60
AI_PROVIDER_SERVICE_TOKEN=change_me AI_PROVIDER_SERVICE_TOKEN=change_me
``` ```
OpenAI 兼容场景推荐使用: 推荐映射关系:
- `AI_PROVIDER=openai_compatible` - `vLLM` / `LM Studio` / `One API`: `AI_PROVIDER=openai` + `AI_PROVIDER_API=openai-completions`
- `MiniMax`: `AI_PROVIDER=minimax` + `AI_PROVIDER_API=anthropic-messages`
- Claude 兼容网关: `AI_PROVIDER=anthropic` + `AI_PROVIDER_API=anthropic-messages`
- `Ollama`: `AI_PROVIDER=ollama` + `AI_PROVIDER_API=ollama-generate`
Claude 兼容场景推荐使用: 比如 MiniMax 可以这样配置:
- `AI_PROVIDER=anthropic`
- `AI_PROVIDER=anthropic_compatible`
- `AI_PROVIDER=claude_compatible`
Ollama 原生场景推荐使用:
- `AI_PROVIDER=ollama`
比如 MiniMax 或其他 Claude 兼容网关,可以这样配置:
```env ```env
AI_PROVIDER=claude_compatible AI_PROVIDER=minimax
AI_BASE_URL=https://your-claude-compatible-endpoint.example.com AI_PROVIDER_API=anthropic-messages
AI_BASE_URL=https://api.minimaxi.com/anthropic
AI_API_KEY=your_api_key AI_API_KEY=your_api_key
AI_MODEL=your-claude-compatible-model AI_MODEL=MiniMax-M2.7
AI_TIMEOUT_SECONDS=60 AI_TIMEOUT_SECONDS=60
AI_MAX_TOKENS=1200 AI_MAX_TOKENS=1200
AI_ANTHROPIC_VERSION=2023-06-01 AI_ANTHROPIC_VERSION=2023-06-01
AI_PROVIDER_SERVICE_TOKEN=change_me
``` ```
如果你要本地直接起模型适配层,项目里已经补了模板: 如果你要本地直接起模型适配层,项目里已经补了模板:
@@ -424,12 +405,6 @@ AI_PROVIDER_SERVICE_TOKEN=change_me
- [aiprovider/.env.example](/home/ray/dev/linkong/planet/aiprovider/.env.example) - [aiprovider/.env.example](/home/ray/dev/linkong/planet/aiprovider/.env.example)
- [docker-compose.local-model.yml](/home/ray/dev/linkong/planet/docker-compose.local-model.yml) - [docker-compose.local-model.yml](/home/ray/dev/linkong/planet/docker-compose.local-model.yml)
推荐映射关系:
- `vLLM` / `LM Studio` / `One API`: `AI_PROVIDER=openai_compatible`
- `MiniMax` / Claude 兼容网关: `AI_PROVIDER=claude_compatible`
- `Ollama`: `AI_PROVIDER=ollama`
运行与调用补充: 运行与调用补充:
- `./planet.sh start` 默认会启动 `aiprovider` - `./planet.sh start` 默认会启动 `aiprovider`
@@ -439,11 +414,13 @@ AI_PROVIDER_SERVICE_TOKEN=change_me
详细文档: 详细文档:
- [docs/technical/agents-aiprovider.md](/home/ray/dev/linkong/planet/docs/technical/agents-aiprovider.md) - [docs/technical/zh/agents-aiprovider.md](/home/ray/dev/linkong/planet/docs/technical/zh/agents-aiprovider.md)
- [docs/technical/en/agents-aiprovider.md](/home/ray/dev/linkong/planet/docs/technical/en/agents-aiprovider.md)
- [aiprovider/README.md](/home/ray/dev/linkong/planet/aiprovider/README.md) - [aiprovider/README.md](/home/ray/dev/linkong/planet/aiprovider/README.md)
- [docs/technical/frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/technical/frontend-layout-guidelines.md) - [docs/technical/frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/technical/frontend-layout-guidelines.md)
- [docs/plans/frontend-ai-playground-development-plan.md](/home/ray/dev/linkong/planet/docs/plans/frontend-ai-playground-development-plan.md) - [docs/plans/frontend-ai-playground-development-plan.md](/home/ray/dev/linkong/planet/docs/plans/frontend-ai-playground-development-plan.md)
- [docs/plans/agents-situational-awareness-foundation-plan.md](/home/ray/dev/linkong/planet/docs/plans/agents-situational-awareness-foundation-plan.md) - [docs/plans/agents-situational-awareness-foundation-plan.md](/home/ray/dev/linkong/planet/docs/plans/agents-situational-awareness-foundation-plan.md)
- [docs/plans/agents-earth-command-runtime-plan.md](/home/ray/dev/linkong/planet/docs/plans/agents-earth-command-runtime-plan.md)
## 前端页面布局规范 ## 前端页面布局规范

View File

@@ -4,6 +4,9 @@ This file is the active backlog only. Completed history belongs in `docs/CHANGEL
## Earth ## Earth
- [ ] 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.
- [x] High-precision country boundary tile framework: implement the static vector tile builder, versioned seed output, frontend bbox tile loader, debounce, in-flight dedupe, and LRU cache described in [Earth High Precision Boundary Tiles Plan](/home/ray/dev/linkong/planet/docs/plans/earth-high-precision-boundary-tiles-plan.md). - [x] High-precision country boundary tile framework: implement the static vector tile builder, versioned seed output, frontend bbox tile loader, debounce, in-flight dedupe, and LRU cache described in [Earth High Precision Boundary Tiles Plan](/home/ray/dev/linkong/planet/docs/plans/earth-high-precision-boundary-tiles-plan.md).
- [x] Add the `pmtiles-mvt` frontend tile provider contract, MVT decoder dependencies, static PMTiles Nginx handling, collector artifact registration, production readiness check, and user operation docs for Earth boundaries. - [x] Add the `pmtiles-mvt` frontend tile provider contract, MVT decoder dependencies, static PMTiles Nginx handling, collector artifact registration, production readiness check, and user operation docs for Earth boundaries.
- [x] Split Earth boundary ingestion into standard source collectors (`earth_admin0_boundaries`, `earth_coastline`, `earth_claim_lines`) plus the downstream `earth_boundary_tiles` PMTiles builder. - [x] Split Earth boundary ingestion into standard source collectors (`earth_admin0_boundaries`, `earth_coastline`, `earth_claim_lines`) plus the downstream `earth_boundary_tiles` PMTiles builder.
@@ -32,6 +35,10 @@ This file is the active backlog only. Completed history belongs in `docs/CHANGEL
## AI Provider And Agents ## AI Provider And Agents
- [ ] Agent runtime foundation: add auditable agent runs, steps, evidence, proposals, and the Agent operations UI described in [Agent Runtime, Earth LLM Command, And Speech Entry Plan](/home/ray/dev/linkong/planet/docs/plans/agents-earth-command-runtime-plan.md).
- [ ] Agent tool protocol: add backend JSON tool-call fallback, optional provider-native tool compatibility, tool whitelist validation, and policy-gated proposal application.
- [ ] Speech/ASR integration for agents: add provider-neutral transcription settings and API, defaulting to Whisper-compatible API providers while keeping text commands usable when ASR is unavailable.
- [ ] Earth voice wake: add device-local configurable wake-word preferences, microphone fallback states, and post-wake instruction upload for Earth commands.
- [ ] AI provider compatibility center: move provider/model compatibility rules into a JSON/YAML config read by runtime, instead of continuing to scatter provider-specific branches through Python code. - [ ] AI provider compatibility center: move provider/model compatibility rules into a JSON/YAML config read by runtime, instead of continuing to scatter provider-specific branches through Python code.
- [ ] Provider compatibility coverage: add explicit config for OpenAI, Anthropic, MiniMax, Ollama, Moonshot, DeepSeek, Qwen, GLM, Gemini, OpenRouter, vLLM, LM Studio, and One API. - [ ] Provider compatibility coverage: add explicit config for OpenAI, Anthropic, MiniMax, Ollama, Moonshot, DeepSeek, Qwen, GLM, Gemini, OpenRouter, vLLM, LM Studio, and One API.
- [ ] Compatibility schema: cover adapter type, base URL pattern, auth header, thinking/reasoning defaults, stream path, tool-call capability, multimodal capability, and provider-specific request patches. - [ ] Compatibility schema: cover adapter type, base URL pattern, auth header, thinking/reasoning defaults, stream path, tool-call capability, multimodal capability, and provider-specific request patches.

View File

@@ -1 +1 @@
0.59.0 0.60.0

View File

@@ -3,9 +3,9 @@
"key": "earth.news.enrich", "key": "earth.news.enrich",
"label": "Earth 新闻汉化与定位", "label": "Earth 新闻汉化与定位",
"group": "Earth 新闻", "group": "Earth 新闻",
"version": "2026-05-16.1", "version": "2026-05-16.2",
"system_prompt": "", "system_prompt": "",
"prompt": "Return exactly one strict JSON object with a location object and a localizations object. Infer the most likely physical event location and produce a faithful Simplified Chinese title and summary based only on the supplied RSS headline, description, source, and date." "prompt": "Return exactly one strict JSON object with a location object and a localizations object. Infer the most likely physical event location and produce a faithful Simplified Chinese title plus a one-sentence newswire-style Chinese summary based only on the supplied RSS headline, description, source, and date. The summary should read like a concise breaking-news lead, not a label, slogan, or keyword headline."
}, },
{ {
"key": "alerts.brief", "key": "alerts.brief",
@@ -27,8 +27,8 @@
"key": "bgp.brief", "key": "bgp.brief",
"label": "BGP 态势简报", "label": "BGP 态势简报",
"group": "BGP", "group": "BGP",
"version": "2026-05-16.1", "version": "2026-05-16.2",
"system_prompt": "", "system_prompt": "你是 BGP 值班分析师。请直接输出面向值班人员的中文 Markdown 简报,只写最终研判内容;不要复述用户需求、提示词、写作计划、字段清单或“我将如何回答”。",
"prompt": "基于当前 BGP incidents、anomalies、原始观测事件、观测站覆盖与 prefix geography 证据,生成一份面向操作员的简明态势简报,突出区域热点、观测偏差、当前风险、证据和优先动作。" "prompt": "基于当前 BGP incidents、anomalies、原始观测事件、观测站覆盖与 prefix geography 证据,生成一份面向操作员的简明态势简报,突出区域热点、观测偏差、当前风险、证据和优先动作。"
}, },
{ {

View File

@@ -102,7 +102,12 @@ def build_search_rank_sql(search: Optional[str]) -> str:
""" """
def serialize_collected_row(row, source_name_map: dict[str, str] | None = None) -> dict: def serialize_collected_row(
row,
source_name_map: dict[str, str] | None = None,
*,
include_metadata: bool = True,
) -> dict:
metadata = row[7] metadata = row[7]
source = row[1] source = row[1]
return { return {
@@ -120,7 +125,7 @@ def serialize_collected_row(row, source_name_map: dict[str, str] | None = None)
"longitude": get_metadata_field(metadata, "longitude"), "longitude": get_metadata_field(metadata, "longitude"),
"value": get_metadata_field(metadata, "value"), "value": get_metadata_field(metadata, "value"),
"unit": get_metadata_field(metadata, "unit"), "unit": get_metadata_field(metadata, "unit"),
"metadata": metadata, "metadata": metadata if include_metadata else None,
"cores": get_metadata_field(metadata, "cores"), "cores": get_metadata_field(metadata, "cores"),
"rmax": get_metadata_field(metadata, "rmax"), "rmax": get_metadata_field(metadata, "rmax"),
"rpeak": get_metadata_field(metadata, "rpeak"), "rpeak": get_metadata_field(metadata, "rpeak"),
@@ -145,6 +150,7 @@ async def list_collected_data(
search: Optional[str] = Query(None, description="搜索名称"), search: Optional[str] = Query(None, description="搜索名称"),
page: int = Query(1, ge=1, description="页码"), page: int = Query(1, ge=1, description="页码"),
page_size: int = Query(20, ge=1, le=100, description="每页数量"), page_size: int = Query(20, ge=1, le=100, description="每页数量"),
include_metadata: bool = Query(True, description="是否返回完整 metadata 字段"),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
@@ -201,7 +207,7 @@ async def list_collected_data(
data = [] data = []
for row in rows: for row in rows:
data.append(serialize_collected_row(row[:11], source_name_map)) data.append(serialize_collected_row(row[:11], source_name_map, include_metadata=include_metadata))
return { return {
"total": total, "total": total,

View File

@@ -295,13 +295,14 @@ def _filter_datasources_in_memory(
datasources: list[DataSource], datasources: list[DataSource],
*, *,
running_tasks: dict[int, CollectionTask], running_tasks: dict[int, CollectionTask],
latest_tasks: dict[int, CollectionTask], latest_tasks: dict[int, CollectionTask] | None = None,
record_counts: dict[str, int], record_counts: dict[str, int],
product: Optional[str] = None, product: Optional[str] = None,
run_status: Optional[str] = None, run_status: Optional[str] = None,
collected: Optional[bool] = None, collected: Optional[bool] = None,
credential_status: Optional[str] = None, credential_status: Optional[str] = None,
) -> list[DataSource]: ) -> list[DataSource]:
latest_tasks = latest_tasks or {}
filtered: list[DataSource] = [] filtered: list[DataSource] = []
for datasource in datasources: for datasource in datasources:
record_count = record_counts.get(datasource.source, 0) record_count = record_counts.get(datasource.source, 0)

View File

@@ -2,16 +2,19 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path
from typing import Any from typing import Any
from uuid import uuid4
from fastapi import APIRouter, Depends, HTTPException, Request, status from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from sqlalchemy import text from sqlalchemy import delete, select, text
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.security import decode_token, get_current_user, redis_client from app.core.security import decode_token, get_current_user, redis_client
from app.db.session import get_db from app.db.session import get_db
from app.models.system_setting import SystemSetting
from app.models.user import User from app.models.user import User
from app.services.earth_boundaries import ( from app.services.earth_boundaries import (
EarthBoundaryBuildError, EarthBoundaryBuildError,
@@ -24,12 +27,138 @@ from app.services.earth_boundaries import (
router = APIRouter() router = APIRouter()
optional_bearer = HTTPBearer(auto_error=False) optional_bearer = HTTPBearer(auto_error=False)
REPO_ROOT = Path(__file__).resolve().parents[4]
EARTH_BRAND_ASSET_DIR = REPO_ROOT / "data" / "earth-brand"
EARTH_BRAND_ASSET_URL_PREFIX = "/earth-brand-assets"
EARTH_BRAND_CATEGORY = "earth_brand"
MAX_EARTH_BRAND_ASSET_BYTES = 3 * 1024 * 1024
ALLOWED_EARTH_BRAND_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".svg"}
DEFAULT_EARTH_BRAND = {
"logo_src": "/earth/assets/brand/earth-logo.png",
"title_src": "/earth/assets/brand/title-zh.png",
"title_text": "智能星球计划",
"subtitle": "现实层宇宙全息感知系统",
"description": "卫星 · 海底光缆 · 算力基础设施",
"aria_label": "智能星球计划品牌标识",
"title_alt": "智能星球计划",
}
class EarthBoundaryConfigPayload(BaseModel): class EarthBoundaryConfigPayload(BaseModel):
config: dict[str, Any] = Field(default_factory=dict) config: dict[str, Any] = Field(default_factory=dict)
class EarthBrandPayload(BaseModel):
logo_src: str = Field(default=DEFAULT_EARTH_BRAND["logo_src"], max_length=1000)
title_src: str = Field(default=DEFAULT_EARTH_BRAND["title_src"], max_length=1000)
title_text: str = Field(default=DEFAULT_EARTH_BRAND["title_text"], max_length=120)
subtitle: str = Field(default=DEFAULT_EARTH_BRAND["subtitle"], max_length=160)
description: str = Field(default=DEFAULT_EARTH_BRAND["description"], max_length=200)
aria_label: str = Field(default=DEFAULT_EARTH_BRAND["aria_label"], max_length=200)
title_alt: str = Field(default=DEFAULT_EARTH_BRAND["title_alt"], max_length=200)
def _normalize_earth_brand_payload(payload: dict[str, Any] | None) -> dict[str, str]:
merged = DEFAULT_EARTH_BRAND.copy()
if payload:
for key in DEFAULT_EARTH_BRAND:
value = payload.get(key)
if value is not None:
merged[key] = str(value).strip()
if not merged["title_text"]:
merged["title_text"] = DEFAULT_EARTH_BRAND["title_text"]
if not merged["aria_label"]:
merged["aria_label"] = merged["title_text"]
if not merged["title_alt"]:
merged["title_alt"] = merged["title_text"]
return merged
async def _get_earth_brand_record(db: AsyncSession) -> SystemSetting | None:
result = await db.execute(
select(SystemSetting).where(SystemSetting.category == EARTH_BRAND_CATEGORY)
)
return result.scalar_one_or_none()
async def _get_earth_brand_payload(db: AsyncSession) -> dict[str, Any]:
record = await _get_earth_brand_record(db)
return {
"brand": _normalize_earth_brand_payload(record.payload if record else None),
"is_default": record is None,
}
@router.get("/brand")
async def get_earth_brand(db: AsyncSession = Depends(get_db)):
return await _get_earth_brand_payload(db)
@router.put("/brand")
async def update_earth_brand(
payload: EarthBrandPayload,
_current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
normalized = _normalize_earth_brand_payload(payload.model_dump())
record = await _get_earth_brand_record(db)
if record is None:
record = SystemSetting(category=EARTH_BRAND_CATEGORY, payload=normalized)
db.add(record)
else:
record.payload = normalized
await db.commit()
await db.refresh(record)
return {"status": "updated", "brand": _normalize_earth_brand_payload(record.payload), "is_default": False}
@router.delete("/brand")
@router.post("/brand/reset")
async def reset_earth_brand(
_current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
await db.execute(delete(SystemSetting).where(SystemSetting.category == EARTH_BRAND_CATEGORY))
await db.commit()
return {"status": "reset", "brand": DEFAULT_EARTH_BRAND.copy(), "is_default": True}
@router.post("/brand/assets")
async def upload_earth_brand_asset(
file: UploadFile = File(...),
_current_user: User = Depends(get_current_user),
):
original_name = file.filename or ""
extension = Path(original_name).suffix.lower()
if extension not in ALLOWED_EARTH_BRAND_EXTENSIONS:
raise HTTPException(
status_code=400,
detail={
"code": "unsupported_file_type",
"message": "Only png, jpg, jpeg, webp, and svg brand assets are supported.",
},
)
content = await file.read(MAX_EARTH_BRAND_ASSET_BYTES + 1)
if len(content) > MAX_EARTH_BRAND_ASSET_BYTES:
raise HTTPException(
status_code=400,
detail={
"code": "file_too_large",
"message": "Brand asset must be 3 MB or smaller.",
},
)
EARTH_BRAND_ASSET_DIR.mkdir(parents=True, exist_ok=True)
safe_name = f"{uuid4().hex}{extension}"
destination = EARTH_BRAND_ASSET_DIR / safe_name
destination.write_bytes(content)
asset_url = f"{EARTH_BRAND_ASSET_URL_PREFIX}/{safe_name}"
return {"url": asset_url, "filename": safe_name, "content_type": file.content_type}
@router.get("/boundaries/status") @router.get("/boundaries/status")
async def get_earth_boundary_status(): async def get_earth_boundary_status():
return get_boundary_status() return get_boundary_status()

View File

@@ -1,8 +1,10 @@
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path
from uuid import uuid4 from uuid import uuid4
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from starlette.middleware.base import BaseHTTPMiddleware from starlette.middleware.base import BaseHTTPMiddleware
from app.api.main import api_router from app.api.main import api_router
@@ -88,6 +90,14 @@ app.add_middleware(WebSocketCORSMiddleware)
app.include_router(api_router, prefix="/api/v1") app.include_router(api_router, prefix="/api/v1")
app.include_router(websocket.router) app.include_router(websocket.router)
EARTH_BRAND_ASSET_DIR = Path(__file__).resolve().parents[2] / "data" / "earth-brand"
EARTH_BRAND_ASSET_DIR.mkdir(parents=True, exist_ok=True)
app.mount(
"/earth-brand-assets",
StaticFiles(directory=str(EARTH_BRAND_ASSET_DIR)),
name="earth-brand-assets",
)
@app.get("/health") @app.get("/health")
async def health_check(): async def health_check():

View File

@@ -254,6 +254,7 @@ async def build_bgp_brief_request(
system_prompt=prompt.system_prompt or None, system_prompt=prompt.system_prompt or None,
observations=observations_lines, observations=observations_lines,
constraints=[ constraints=[
"直接输出中文 Markdown 简报正文,不要输出英文写作计划、提示词复述、字段说明或元评论。",
"明确区分事实、推断与建议。", "明确区分事实、推断与建议。",
"优先指出需要立即关注的高严重度 incident 或异常模式。", "优先指出需要立即关注的高严重度 incident 或异常模式。",
"需要单独指出哪些区域结论来自 prefix geography / affected regions哪些可能受 collector coverage 偏差影响。", "需要单独指出哪些区域结论来自 prefix geography / affected regions哪些可能受 collector coverage 偏差影响。",

View File

@@ -661,7 +661,7 @@ async def _infer_news_enrichment(
"localizations": { "localizations": {
"zh-CN": { "zh-CN": {
"title": "faithful Simplified Chinese title", "title": "faithful Simplified Chinese title",
"summary": "1-2 sentence faithful Simplified Chinese summary", "summary": "one-sentence newswire-style Simplified Chinese lead summary",
} }
}, },
}, },
@@ -669,8 +669,9 @@ async def _infer_news_enrichment(
constraints=[ constraints=[
"Return only strict JSON. Do not wrap it in markdown.", "Return only strict JSON. Do not wrap it in markdown.",
"For localizations, do not add facts that are absent from the RSS headline, description, source, or date.", "For localizations, do not add facts that are absent from the RSS headline, description, source, or date.",
"If the RSS description is thin, write a conservative summary that says only what is supported.", "Write zh-CN summary as one concise newswire-style sentence, like a breaking-news lead.",
"Keep zh-CN summary concise, factual, and non-promotional.", "If the RSS description is thin, write a conservative one-sentence summary that says only what is supported.",
"Keep zh-CN summary factual, non-promotional, and avoid colon-heavy keyword labels.",
"Prefer the event location, not the newsroom or publisher headquarters.", "Prefer the event location, not the newsroom or publisher headquarters.",
"When a country visit or summit is the clear topic but the city is omitted, use the most likely host city only if it is broadly public knowledge.", "When a country visit or summit is the clear topic but the city is omitted, use the most likely host city only if it is broadly public knowledge.",
"Use null for unknown fields instead of inventing details.", "Use null for unknown fields instead of inventing details.",

View File

@@ -99,18 +99,24 @@ async def build_situational_alert_brief_request(
(str(item[0] or "未命名数据源"), item[1]) (str(item[0] or "未命名数据源"), item[1])
for item in alert_source_result.fetchall() for item in alert_source_result.fetchall()
] ]
total_alerts = total_alerts_result.scalar() or 0
active_alerts = active_alerts_result.scalar() or 0
total_incidents = total_incidents_result.scalar() or 0
active_incidents = active_incidents_result.scalar() or 0
total_anomalies = total_anomalies_result.scalar() or 0
active_anomalies = active_anomalies_result.scalar() or 0
facts = [ facts = [
( (
f"系统告警侧:总告警 {total_alerts_result.scalar() or 0}active {active_alerts_result.scalar() or 0} 条;" f"系统告警侧:总告警 {total_alerts}active {active_alerts} 条;"
f"活跃告警严重度分布为 {_format_pairs(active_alert_severities)}" f"活跃告警严重度分布为 {_format_pairs(active_alert_severities)}"
), ),
( (
f"BGP态势侧累计 incidents {total_incidents_result.scalar() or 0}active incidents {active_incidents_result.scalar() or 0} 条;" f"BGP态势侧累计 incidents {total_incidents}active incidents {active_incidents} 条;"
f"活跃 incidents 严重度分布为 {_format_pairs(active_bgp_severities)}" f"活跃 incidents 严重度分布为 {_format_pairs(active_bgp_severities)}"
), ),
( (
f"BGP异常侧累计 anomalies {total_anomalies_result.scalar() or 0}active anomalies {active_anomalies_result.scalar() or 0} 条;" f"BGP异常侧累计 anomalies {total_anomalies}active anomalies {active_anomalies} 条;"
f"活跃 anomaly 类型分布为 {_format_pairs(active_anomaly_types)}" f"活跃 anomaly 类型分布为 {_format_pairs(active_anomaly_types)}"
), ),
] ]
@@ -150,12 +156,12 @@ async def build_situational_alert_brief_request(
context = { context = {
"source": "situational-alerts", "source": "situational-alerts",
"active_system_alerts": active_alerts_result.scalar() or 0, "active_system_alerts": active_alerts,
"active_system_alert_severities": dict(active_alert_severities), "active_system_alert_severities": dict(active_alert_severities),
"top_system_alert_sources": dict(active_alert_sources), "top_system_alert_sources": dict(active_alert_sources),
"active_bgp_incidents": active_incidents_result.scalar() or 0, "active_bgp_incidents": active_incidents,
"active_bgp_incident_severities": dict(active_bgp_severities), "active_bgp_incident_severities": dict(active_bgp_severities),
"active_bgp_anomalies": active_anomalies_result.scalar() or 0, "active_bgp_anomalies": active_anomalies,
"active_bgp_anomaly_types": dict(active_anomaly_types), "active_bgp_anomaly_types": dict(active_anomaly_types),
"bgp_hot_regions": dict(bgp_region_counter.most_common(5)), "bgp_hot_regions": dict(bgp_region_counter.most_common(5)),
"latest_bgp_brief_id": latest_bgp_brief.id if latest_bgp_brief else None, "latest_bgp_brief_id": latest_bgp_brief.id if latest_bgp_brief else None,

View File

@@ -5,10 +5,12 @@ from datetime import datetime
from unittest.mock import patch, AsyncMock from unittest.mock import patch, AsyncMock
from httpx import AsyncClient, ASGITransport from httpx import AsyncClient, ASGITransport
from app.api.v1 import earth as earth_api
from app.main import app from app.main import app
from app.core.config import settings from app.core.config import settings
from app.core.security import create_access_token from app.core.security import create_access_token
from app.db.session import get_db from app.db.session import get_db
from app.models.system_setting import SystemSetting
from app.models.user import User from app.models.user import User
from app.schemas.ai import ( from app.schemas.ai import (
AIProviderStatusResponse, AIProviderStatusResponse,
@@ -61,6 +63,148 @@ async def test_root_endpoint():
assert data["version"] == settings.VERSION assert data["version"] == settings.VERSION
class _ScalarOneOrNoneResult:
def __init__(self, value=None):
self._value = value
def scalar_one_or_none(self):
return self._value
class _FakeEarthBrandSession:
def __init__(self, record=None):
self.record = record
self.added = None
self.deleted = False
self.committed = False
async def execute(self, statement):
if statement.__class__.__name__ == "Delete":
self.deleted = True
self.record = None
return _ScalarOneOrNoneResult(None)
return _ScalarOneOrNoneResult(self.record)
def add(self, record):
self.added = record
self.record = record
async def commit(self):
self.committed = True
async def refresh(self, _record):
return None
def _override_admin_user():
return User(id=1, username="testuser", email="test@example.com", role="admin", is_active=True)
@pytest.mark.asyncio
async def test_get_earth_brand_returns_static_defaults():
async def override_get_db():
yield _FakeEarthBrandSession()
app.dependency_overrides[get_db] = override_get_db
try:
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/api/v1/earth/brand")
assert response.status_code == 200
data = response.json()
assert data["is_default"] is True
assert data["brand"]["logo_src"] == "/earth/assets/brand/earth-logo.png"
assert data["brand"]["title_src"] == "/earth/assets/brand/title-zh.png"
finally:
app.dependency_overrides.clear()
@pytest.mark.asyncio
async def test_update_and_reset_earth_brand(auth_headers):
session = _FakeEarthBrandSession()
async def override_get_db():
yield session
app.dependency_overrides.update(
{
__import__("app.core.security", fromlist=["get_current_user"]).get_current_user: _override_admin_user,
get_db: override_get_db,
}
)
try:
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
update_response = await client.put(
"/api/v1/earth/brand",
headers=auth_headers,
json={
"logo_src": "/earth-brand-assets/custom.png",
"title_src": "",
"title_text": "Custom Earth",
"subtitle": "Custom subtitle",
"description": "Custom description",
"aria_label": "",
"title_alt": "",
},
)
reset_response = await client.delete("/api/v1/earth/brand", headers=auth_headers)
assert update_response.status_code == 200
updated = update_response.json()
assert updated["is_default"] is False
assert updated["brand"]["title_text"] == "Custom Earth"
assert updated["brand"]["aria_label"] == "Custom Earth"
assert isinstance(session.added, SystemSetting)
assert reset_response.status_code == 200
reset = reset_response.json()
assert reset["is_default"] is True
assert reset["brand"]["logo_src"] == "/earth/assets/brand/earth-logo.png"
finally:
app.dependency_overrides.clear()
@pytest.mark.asyncio
async def test_upload_earth_brand_asset_rejects_invalid_type(auth_headers):
app.dependency_overrides[
__import__("app.core.security", fromlist=["get_current_user"]).get_current_user
] = _override_admin_user
try:
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post(
"/api/v1/earth/brand/assets",
headers=auth_headers,
files={"file": ("brand.txt", b"nope", "text/plain")},
)
assert response.status_code == 400
assert response.json()["detail"]["code"] == "unsupported_file_type"
finally:
app.dependency_overrides.clear()
@pytest.mark.asyncio
async def test_upload_earth_brand_asset_saves_file(auth_headers, tmp_path, monkeypatch):
monkeypatch.setattr(earth_api, "EARTH_BRAND_ASSET_DIR", tmp_path)
app.dependency_overrides[
__import__("app.core.security", fromlist=["get_current_user"]).get_current_user
] = _override_admin_user
try:
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post(
"/api/v1/earth/brand/assets",
headers=auth_headers,
files={"file": ("brand.png", b"png-bytes", "image/png")},
)
assert response.status_code == 200
data = response.json()
assert data["url"].startswith("/earth-brand-assets/")
assert (tmp_path / data["filename"]).read_bytes() == b"png-bytes"
finally:
app.dependency_overrides.clear()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_dashboard_stats_without_auth(): async def test_dashboard_stats_without_auth():
"""Test dashboard stats requires authentication""" """Test dashboard stats requires authentication"""

View File

@@ -0,0 +1,71 @@
import pytest
from app.services.situational_alert_ai_brief import build_situational_alert_brief_request
class _SingleUseScalarResult:
def __init__(self, value=0, rows=None):
self.value = value
self.rows = rows or []
self.scalar_calls = 0
def scalar(self):
self.scalar_calls += 1
if self.scalar_calls > 1:
raise AssertionError("scalar result was consumed more than once")
return self.value
def fetchall(self):
return self.rows
def scalar_one_or_none(self):
return None
def scalars(self):
rows = self.rows
class _Scalars:
def all(self):
return rows
return _Scalars()
class _FakeBriefSession:
def __init__(self):
self._results = [
_SingleUseScalarResult(3),
_SingleUseScalarResult(2),
_SingleUseScalarResult(rows=[]),
_SingleUseScalarResult(rows=[]),
_SingleUseScalarResult(rows=[]),
_SingleUseScalarResult(4),
_SingleUseScalarResult(1),
_SingleUseScalarResult(rows=[]),
_SingleUseScalarResult(rows=[]),
_SingleUseScalarResult(5),
_SingleUseScalarResult(2),
_SingleUseScalarResult(rows=[]),
_SingleUseScalarResult(),
]
async def execute(self, _query):
return self._results.pop(0)
@pytest.mark.asyncio
async def test_situational_alert_brief_builder_reuses_counts_without_reconsuming_results(monkeypatch):
monkeypatch.setattr(
"app.services.situational_alert_ai_brief.get_latest_bgp_brief_record",
lambda: None,
)
request, facts, context = await build_situational_alert_brief_request(_FakeBriefSession())
assert request.title == "态势告警 AI 简报"
assert "总告警 3 条active 2 条" in facts[0]
assert "累计 incidents 4 条active incidents 1 条" in facts[1]
assert "累计 anomalies 5 条active anomalies 2 条" in facts[2]
assert context["active_system_alerts"] == 2
assert context["active_bgp_incidents"] == 1
assert context["active_bgp_anomalies"] == 2

View File

@@ -8,6 +8,23 @@ This project follows the repository versioning rule:
- `improvement` -> `+0.0.1`bugfix + 小功能混合) - `improvement` -> `+0.0.1`bugfix + 小功能混合)
- `bugfix` -> `+0.0.1` - `bugfix` -> `+0.0.1`
## [0.60.0] — 2026-05-17
Released: 2026-05-17
### Highlights
- 新增 Earth 内容与国界运行体验:品牌内容配置、国界精度滑块、高精国界首次应用按钮和高精重载构建入口共同收口。
- 完成新闻中文展示链路收敛:滚动新闻改用一句话摘要,新闻本地化状态避免未完成内容进入滚动展示,英文原文继续保留在数据层。
- 重构 AI Provider 与提示词边界:`aiprovider` 保持纯净模型适配,业务提示词集中到后端 AI task 默认注册表并支持运维台覆盖和重置。
### Added / Fixed / Improved
- 新增 AI 设置提示词 tab按功能入口选择、编辑、保存和重置默认提示词并修复态势告警 / BGP 简报展示生成内容而非 prompt。
- 更新 Earth 新闻、HUD、巡航、桌面详情和移动详情的中文显示策略并优化新闻摘要提示词为新闻式一句话概要。
- 补充 Agent Runtime、Earth LLM 指令、语音识别和多角色态势感知的详细计划,并同步 README 架构说明、用户手册、FAQ 与开发者文档。
- 补齐相关后端测试,覆盖 Earth 品牌配置、AI 简报接口和态势告警 AI 简报生成链路。
---
## [0.59.0] — 2026-05-16 ## [0.59.0] — 2026-05-16
Released: 2026-05-16 Released: 2026-05-16

View File

@@ -0,0 +1,810 @@
# Agent Runtime, Earth LLM Command, And Speech Entry Plan
## Summary
Build an auditable backend Agent Runtime and upgrade the existing Earth search panel into a combined search, AI command, and voice wake entry. The first version is a runtime foundation, not the full multi-role simulation product yet.
Typical user goals:
- In Earth, type "高亮所有北斗卫星" and have the system open the satellite layer and highlight matching Beidou satellites.
- Type "中国大陆的算力中心" and have the system open the compute-center layer, match mainland China compute centers, and highlight them.
- When microphone permission is granted, use a configurable wake word, then speak an Earth command.
- Save every Earth AI command as an agent run so operators can review the original input, speech transcription, tool steps, entity matches, final action plan, and frontend execution result.
Core boundaries:
- `aiprovider` remains the model gateway. It must not own business tools, database access, Earth actions, or agent policy.
- The backend owns agent orchestration, tools, evidence storage, permission policy, and proposal application.
- Earth v1 executes visualization actions only. It does not mutate business data.
- Speech recognition uses a provider-neutral ASR API first, defaulting to OpenAI/Whisper-compatible transcription APIs, with local `whisper.cpp` style providers as later adapters.
## Architecture
### Backend Agent Runtime
Recommended module shape:
```text
backend/app/models/
agent_run.py
agent_step.py
agent_evidence.py
agent_proposal.py
backend/app/schemas/
agents.py
speech.py
backend/app/services/agents/
runtime.py
orchestrator.py
tool_protocol.py
tool_registry.py
policy.py
proposals.py
earth_command.py
entity_query.py
speech.py
backend/app/api/v1/
agents.py
```
Database conventions should follow the current project style:
- Use SQLAlchemy models.
- Import new models from `init_db()`.
- Let `Base.metadata.create_all` create tables.
- Add required indexes with `CREATE INDEX IF NOT EXISTS`.
- Do not introduce Alembic for this feature.
### aiprovider Boundary
`aiprovider` should continue to provide model transport only:
- Keep the existing `/v1/analyze` endpoint.
- If schemas are extended, only pass through model/provider request fields and normalize responses.
- Do not add WebSearch, database queries, Earth entity lookup, or business action execution inside `aiprovider`.
- If a provider does not support native tool calling, the backend must use JSON tool-call fallback.
### Agent Tool Protocol
The first version should support two protocols:
- Default path: backend JSON tool-call loop.
- Optional path: provider-native tools when the configured provider supports them.
- Fallback path: if provider-native tools are unavailable or unstable, automatically use JSON tool-call.
Example JSON tool call:
```json
{
"type": "tool_call",
"tool": "earth.find_entities",
"arguments": {
"domain": "satellites",
"filters": {
"constellation": "beidou"
},
"limit": 500
}
}
```
Example final response:
```json
{
"type": "final",
"summary": "已找到并高亮北斗卫星。",
"result": {
"actions": []
}
}
```
Hard limits:
- One run may execute at most 8 tool steps by default.
- One tool call times out after 30 seconds by default.
- Only registered whitelist tools may run.
- Tool arguments must pass Pydantic validation.
- Illegal tools, invalid arguments, and denied actions must be recorded as step errors.
- LLM output may not directly write business state. Writes are either proposals or backend-executed policy-approved actions.
## Data Model
### AgentRun
Suggested fields:
- `id`: integer primary key.
- `public_id`: public string id.
- `run_type`: `earth_command`, `situational_awareness`, `config_proposal`, or `diagnostic`.
- `status`: `queued`, `running`, `waiting_approval`, `completed`, `failed`, or `stopped`.
- `title`: short display title.
- `objective`: text objective.
- `input`: JSONB original input, including text and audio metadata.
- `context`: JSONB run context.
- `result_markdown`: final human-readable output.
- `result_json`: structured output, including Earth action plans.
- `provider`: nullable provider id.
- `model`: nullable model id.
- `request_id`: nullable propagated request id.
- `created_by`: user id.
- `created_at`, `updated_at`, `completed_at`.
- `error`: nullable text error.
### AgentStep
Suggested fields:
- `id`.
- `run_id`.
- `step_index`.
- `step_type`: `llm`, `tool`, `policy`, `action`, or `transcription`.
- `status`: `pending`, `running`, `completed`, `failed`, or `skipped`.
- `name`: step name, for example `earth.find_entities`.
- `input`: JSONB.
- `output`: JSONB.
- `error`: nullable text.
- `started_at`, `completed_at`.
- `duration_ms`.
### AgentEvidence
Suggested fields:
- `id`.
- `run_id`.
- `step_id`: nullable.
- `evidence_type`: `internal_record`, `web_search`, `web_fetch`, `entity_match`, or `transcription`.
- `source`: source id.
- `title`: display title.
- `url`: nullable source URL.
- `content`: text evidence content.
- `content_hash`: nullable hash.
- `metadata`: JSONB.
- `retrieved_at`.
### AgentProposal
Suggested fields:
- `id`.
- `run_id`.
- `proposal_type`: `datasource_config`, `ai_prompt`, or `external_integration`.
- `status`: `pending`, `approved`, `applied`, `rejected`, or `failed`.
- `risk_level`: `low`, `medium`, or `high`.
- `target`: JSONB target descriptor.
- `before_payload`: JSONB.
- `after_payload`: JSONB.
- `rationale`: text.
- `policy_result`: JSONB.
- `applied_by`: nullable user id.
- `applied_at`: nullable timestamp.
- `error`: nullable text.
## Public APIs
### Agent Runs
Add:
```text
POST /api/v1/agents/runs
GET /api/v1/agents/runs
GET /api/v1/agents/runs/{run_id}
POST /api/v1/agents/runs/{run_id}/stop
POST /api/v1/agents/proposals/{proposal_id}/apply
```
### Earth Command
Add:
```text
POST /api/v1/agents/earth/command
```
Request shape:
```json
{
"input_text": "高亮所有北斗卫星",
"source": "text",
"transcription_id": null,
"client_context": {
"visible_layers": ["satellites"],
"locale": "zh-CN",
"viewport": {
"is_mobile": false
}
}
}
```
Response shape:
```json
{
"run_id": "agent_xxx",
"summary": "已找到并高亮北斗卫星。",
"actions": [
{
"type": "show_layer",
"layer": "satellites",
"enabled": true
},
{
"type": "highlight_entities",
"domain": "satellites",
"entity_ids": ["satellite:norad:12345"],
"style": {
"color": "#7dd3fc"
}
},
{
"type": "open_result_panel",
"title": "北斗卫星",
"items": []
}
],
"matched_entities": [],
"confidence": 0.86,
"missing_data": []
}
```
### Speech / ASR
Add:
```text
POST /api/v1/agents/speech/transcriptions
```
Request should use multipart form data:
- `file`: audio blob.
- `language`: default `zh`.
- `provider`: optional provider override.
- `source`: default `earth_command`.
Response shape:
```json
{
"text": "高亮所有北斗卫星",
"provider": "openai_compatible",
"model": "whisper-1",
"duration_ms": 1234,
"confidence": null,
"metadata": {}
}
```
## Earth Action Plan
### Allowed Action Types
The first version may only return:
```text
show_layer
highlight_entities
filter_entities
focus_view
open_result_panel
clear_highlight
```
### Allowed Domains
The first version supports:
```text
satellites
compute_centers
bgp
news
vessels
cables
```
### EarthAction Shape
Example:
```json
{
"type": "highlight_entities",
"domain": "satellites",
"entity_ids": ["satellite:norad:12345"],
"style": {
"color": "#7dd3fc",
"mode": "glow"
},
"reason": "用户要求高亮北斗卫星"
}
```
### Safety Rules
The backend must validate action plans before returning them:
- `type` must be in the allowed action list.
- `domain` must be in the allowed domain list.
- `entity_ids` must come from backend or current Earth candidate data. The model may not invent ids.
- One highlight action should include at most 500 entities by default. If more match, return a truncation note in `missing_data` or `summary`.
- `focus_view` must include valid coordinates, a valid region, or a matched entity.
- No action may return executable JavaScript, arbitrary CSS, or arbitrary URLs to fetch.
## Earth Entity Query
Add a backend entity query service used by both deterministic resolvers and LLM tools.
### Satellites
Sources:
- `/api/v1/visualization/geo/satellites`.
- Current TLE collected data.
- GeoJSON feature properties.
Filters:
- `constellation`: `beidou`, `gps`, `galileo`, `glonass`, `starlink`, `iridium`, `geo`, `leo`.
- Name contains.
- NORAD id.
- Country/operator when present in data.
- Orbital class when inferable from existing fields.
Beidou matching:
- Prefer `constellation == beidou`.
- Then match names containing `BEIDOU`, `BDS`, `BEIDOU-`, or `北斗`.
- Stable entity id format should be `satellite:norad:{norad_id}` when possible, with fallback `satellite:index:{index}`.
### Compute Centers
Sources:
- `/api/v1/visualization/geo/compute-centers`.
- Unified TOP500 and Epoch AI GPU GeoJSON properties.
Filters:
- Country/region.
- `site_type`: `supercomputer` or `gpu_cluster`.
- Source: `top500` or `epoch_ai_gpu`.
- Name contains.
- `needs_confirmation`.
- `location_precision`.
Mainland China matching:
- Match country values such as `China`, `中国`, or `People's Republic of China`.
- Exclude obvious non-mainland records when fields identify Hong Kong, Macau, or Taiwan.
- If records do not expose enough region detail to separate mainland China from Hong Kong, Macau, or Taiwan, return a `missing_data` note and conservatively match `country=China`.
### BGP, News, Vessels, And Cables
First-version basic support:
- BGP: severity, status, region, collector, prefix, ASN.
- News: region, source, localized title, localized summary.
- Vessels: vessel type, country/area, name, status.
- Cables: cable name, landing point, country/region.
## Earth Frontend Integration
### Search Panel Merge
Reuse the existing Earth search panel:
- Default behavior remains normal local search.
- Add an AI command state for natural-language commands.
- Add a "use AI" command button.
- Add a microphone button.
- Show running status, result summary, and clear-highlight action.
- Visually distinguish ordinary search results from AI action results.
Natural-language routing:
- If the user clicks the AI command button, always call the AI command endpoint.
- If input contains action words such as `高亮`, `显示`, `找出`, `聚焦`, `打开`, `筛选`, `隐藏`, or `清除`, suggest AI command mode.
- Short ordinary keywords continue to use local search.
### Earth Action Executor
Add:
```text
frontend/public/earth/js/earth-command-actions.js
```
Responsibilities:
- Receive backend `EarthActionPlan`.
- Toggle required layers.
- Apply highlights and filters.
- Focus view when requested.
- Open or update the result panel.
- Clear the previous AI command highlight.
Execution order:
1. Clear previous AI command highlights unless the plan is explicitly additive.
2. Show required layers.
3. Apply filters and highlights.
4. Focus the view if requested.
5. Open the result panel.
6. Record the execution summary in UI state.
### Satellite Highlight
Existing `highlightRelatedSatellites(indices, color)` can be reused, but Earth command support needs an entity-id-to-index map.
Add:
- Satellite id map built from current satellite data.
- Highlight by `entity_ids`.
- Clear AI command satellite highlights without clearing manual locked selection.
- Auto-enable the satellite layer before highlighting.
### Compute Center Highlight
Existing compute center markers support marker state, but need a dedicated batch AI highlight state.
Add:
- Batch set compute center markers to AI highlighted state.
- Keep AI highlight compatible with hover and locked states.
- Clear AI command compute highlights without clearing manual locked selection.
- Auto-enable the compute-center layer before highlighting.
### Result Panel
First version can show results inside the merged search panel:
- Title, for example `北斗卫星`.
- Count, for example `已高亮 32 个对象`.
- List with the first 20 entities.
- Actions: clear highlight, rerun, view Agent Run.
## Voice And Wake Word
### ASR Configuration
Add Speech/ASR under the AI settings tool tab.
Fields:
- `enabled`.
- `provider`.
- `base_url`.
- `api_key`.
- `model`.
- `language`.
- `timeout_seconds`.
- `max_audio_size_mb`.
First-version providers:
- `openai_whisper`.
- `openai_compatible`.
- `local_whisper`.
Recommended defaults:
- `provider`: `openai_compatible`.
- `model`: `whisper-1` or user-configured equivalent.
- `language`: `zh`.
Secret handling:
- Use the existing masked secret pattern.
- Do not allow agent proposals to write API keys.
- Support environment fallback such as `ASR_API_KEY` and `OPENAI_API_KEY`.
### Wake Word
The wake word is configurable.
Implementation rules:
- Treat wake word as a browser-local preference in v1.
- Store it in `localStorage`.
- Default suggestion: `小星球`.
- Provide a wake-word setting inside the Earth search/command panel.
- Do not listen until the user explicitly enables voice wake.
- Do not upload audio before wake.
- After wake, record one instruction audio segment and upload it to backend ASR.
Fallbacks:
- Browser does not support continuous local recognition: fall back to click-to-record.
- Microphone permission denied: show `麦克风权限未开启,仍可输入文字指令`.
- ASR not configured: show `语音识别未配置`; text commands remain available.
- User can disable wake listening and keep manual microphone recording.
### Wake Word Technical Choice
Do not require local Whisper in v1.
Recommended path:
- Use browser Web Speech API for local wake-word detection when available.
- Fall back to click-to-record when unavailable.
- Upload only the post-wake instruction audio to backend ASR.
- Add local `whisper.cpp` streaming wake-word or ASR provider later.
## Prompt Registry
Add default prompt keys:
```text
agents.runtime.system
agents.earth.command
agents.situational.assessment
agents.config.proposal
agents.roles.network
agents.roles.bgp
agents.roles.platform_ops
agents.roles.business_impact
```
`agents.earth.command` must require:
- Strict JSON output only.
- Use only provided candidate entities.
- Do not invent objects.
- Do not return arbitrary code.
- Do not modify business data.
- Return `clarification_needed` when intent is unclear.
- Return action plans matching the schema.
- Use Chinese summary for Chinese user input.
## Policy
### Tool Policy
- The LLM can request tools, but the backend decides whether to execute.
- Every tool must declare:
- name.
- description.
- input schema.
- output schema.
- permission.
- side-effect level.
- First-version side-effect levels:
- `read`: can run directly.
- `proposal`: can only generate proposals.
- `write`: can only execute during proposal apply.
### Proposal Policy
First version supports configuration proposal application with role-based automatic/manual gating:
- `super_admin` may enable automatic application for low-risk proposals.
- Normal admins must manually confirm.
- High-risk proposals always require manual confirmation.
- Proposals containing secret fields are rejected.
- Proposals failing schema validation are rejected.
- Before and after payloads must be saved.
- Failed applications must save errors.
Low-risk scope:
- Datasource endpoint/config non-secret fields.
- AI prompt overrides.
- External integration non-secret fields.
Out of scope for v1:
- Alert acknowledge/resolve.
- Data deletion.
- User permission changes.
- Authentication configuration changes.
- Database schema changes by agent.
- Mutation of original Earth collected data.
## Agent Operations UI
Add an `Agent` page under `运维与配置`.
Run list:
- Status.
- Type.
- Title.
- Creator.
- Model.
- Time.
- Duration.
Run detail:
- Input.
- Status timeline.
- LLM steps.
- Tool steps.
- Evidence.
- Final result.
- Proposals.
Proposal apply:
- Before/after diff.
- Risk level.
- Policy result.
- Apply button.
- Reject button.
Earth command run detail:
- Original text or speech transcription.
- Matched entities.
- Action plan.
- Link back to Earth or copy run id.
## Frontend Types
Add or extend:
```ts
interface AgentRun {}
interface AgentStep {}
interface AgentEvidence {}
interface AgentProposal {}
interface EarthCommandRequest {}
interface EarthActionPlan {}
interface EarthAction {}
interface SpeechTranscriptionResponse {}
```
Earth action executor API:
```js
executeEarthActionPlan(plan, context)
clearEarthCommandHighlights()
getEarthCommandExecutionState()
```
## Testing
### Backend Tests
Use `uv`.
Suggested files:
```text
backend/tests/test_agents_runtime.py
backend/tests/test_agent_tool_protocol.py
backend/tests/test_agent_policy.py
backend/tests/test_earth_agent_command.py
backend/tests/test_speech_transcription.py
```
Coverage:
- Create run.
- Persist ordered run steps.
- Parse valid JSON tool calls.
- Reject invalid JSON tool calls without executing tools.
- Reject unregistered tools.
- Reject invalid tool arguments.
- Pass through provider-native tools fields when configured.
- Fall back to JSON tool calls when native tools are unavailable.
- When WebSearch is not configured, record `missing_data` and do not fail Earth command.
- "高亮所有北斗卫星" returns a satellite highlight action.
- "中国大陆的算力中心" returns a compute-center highlight action.
- Mainland China matching excludes Hong Kong, Macau, and Taiwan when fields permit it.
- Field granularity limitations are returned in `missing_data`.
- ASR not configured returns a clear error.
- ASR provider success returns transcription.
- Normal admins cannot auto-apply proposals.
- `super_admin` can apply low-risk proposals.
- Secret-field proposals are rejected.
### Frontend Tests
Use `bun`.
Required build check:
```bash
cd frontend && bun run build
```
Suggested Earth JS tests:
- Action executor opens the satellite layer.
- Action executor highlights satellite entity ids.
- Action executor highlights compute-center entity ids.
- Clear highlight does not clear manual locked selection.
- Search panel ordinary search still uses local search.
- AI command button calls `/api/v1/agents/earth/command`.
- ASR missing configuration and microphone permission denial show clear UI feedback.
### Manual Acceptance
1. Type `高亮所有北斗卫星`.
- Satellite layer opens.
- Beidou satellites are highlighted.
- Panel shows count and summary.
- Agent run is reviewable.
2. Type `显示中国大陆的算力中心`.
- Compute-center layer opens.
- Mainland China related compute centers are highlighted.
- If data cannot separate mainland China from Hong Kong/Macau/Taiwan, the UI shows a missing-data note.
3. Click microphone.
- Permission denial is clear.
- Permission grant allows recording.
- Configured ASR transcribes and executes the command.
- If ASR is not configured, text input still works.
4. Enable voice wake.
- Wake word is configurable.
- Audio is not uploaded before wake.
- After wake, one instruction audio segment is uploaded.
- Wake listening can be disabled.
5. Open Agent operations UI.
- Earth command run is listed.
- Transcription step is visible when speech was used.
- Entity query step is visible.
- Final action plan is visible.
- User can return to Earth or copy the run id.
## Documentation
When implemented, update:
```text
docs/technical/zh/agents-aiprovider.md
docs/technical/en/agents-aiprovider.md
docs/technical/zh/manual.md
docs/technical/en/manual.md
docs/technical/zh/earth-frontend-context.md
docs/technical/en/earth-frontend-context.md
```
Document:
- `aiprovider` and backend agent boundaries.
- Earth AI command entry.
- Speech/ASR configuration.
- Wake-word privacy behavior.
- Agent run/evidence/proposal review.
- V1 capability boundary: Earth visualization actions only, no business data mutation.
## Implementation Order
1. Add backend Agent data models, `init_db()` imports, and indexes.
2. Add Agent schemas and run CRUD API.
3. Add tool registry, JSON tool-call parser, and policy skeleton.
4. Add Earth entity query service.
5. Add `agents.earth.command` prompt and backend command endpoint.
6. Add frontend Earth action executor.
7. Merge AI command entry into Earth search panel.
8. Add satellite and compute-center batch highlight support.
9. Add Speech/ASR settings and transcription API.
10. Add Earth microphone recording, wake-word local setting, and fallback behavior.
11. Add Agent operations UI.
12. Add proposal apply policy and low-risk configuration application.
13. Add tests and documentation updates.
## Assumptions
- Earth LLM v1 only executes visualization actions.
- Earth commands create agent runs but do not automatically create configuration proposals.
- Configuration proposals are applied only from the Agent operations UI.
- Wake word is a device-local preference in v1 and is stored in `localStorage`.
- ASR is API-first and Whisper-compatible by default; local Whisper is a later provider.
- Multi-role simulation only reserves schemas and prompt keys in v1.
- Palantir-style situational workflows should grow from the shared evidence, entity, action, and assessment model instead of a separate isolated system.

View File

@@ -107,6 +107,12 @@ The `ai-prompts` endpoints back the Prompts tab in AI settings. Shipped defaults
`aiprovider` is a pure model adapter and does not inject a global business system prompt. News localization, alert briefing, BGP briefing, location factcheck, datasource mapping, and credential guide generation each resolve their own effective prompt by task key. Alert-analysis system prompts are only sent by alert-related tasks and do not leak into other LLM calls. `aiprovider` is a pure model adapter and does not inject a global business system prompt. News localization, alert briefing, BGP briefing, location factcheck, datasource mapping, and credential guide generation each resolve their own effective prompt by task key. Alert-analysis system prompts are only sent by alert-related tasks and do not leak into other LLM calls.
### Agent And Tool Boundary
Agent workflows belong in the `backend`, not in `aiprovider`. Future Earth LLM commands, situational awareness, multi-role simulation, WebSearch, database queries, evidence storage, and configuration proposal application should be orchestrated by the backend Agent Runtime. `aiprovider` should receive model-ready requests from the backend and return normalized model responses.
If a provider supports native tool calling, `aiprovider` may pass through protocol fields and normalize response blocks, but tool whitelists, argument validation, permission policy, run records, and write approvals must stay in the backend. When a provider does not support native tools, the backend uses JSON tool-call fallback; business tools should not move into `aiprovider` for a provider-specific shortcut.
### AI provider internal API ### AI provider internal API
Internal-only endpoints: Internal-only endpoints:

View File

@@ -71,6 +71,10 @@ Responsibilities:
This is currently the most critical UI control entry point for the Earth frontend. This is currently the most critical UI control entry point for the Earth frontend.
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.
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.
### 4. UI and Status Messages ### 4. UI and Status Messages
- [ui.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/ui.js) - [ui.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/ui.js)
@@ -145,6 +149,8 @@ Each module is responsible for its own:
`tv.js` owns the live / aggregation-news tabs inside `media-panel`. Toolbar open and tab-switch actions write back through `earth:tv-visibility-change` and `earth:tv-tab-change`: panel visibility remains viewport-scoped at `views.<scope>.panelVisibility.media-panel`, while the active tab is stored at `shared.mediaPanelActiveTab`. Refreshing the page therefore restores the user's last live/news state. Temporary hides from `closeTransientMobileOverlays()` carry `persist:false` and do not overwrite the preference. `tv.js` owns the live / aggregation-news tabs inside `media-panel`. Toolbar open and tab-switch actions write back through `earth:tv-visibility-change` and `earth:tv-tab-change`: panel visibility remains viewport-scoped at `views.<scope>.panelVisibility.media-panel`, while the active tab is stored at `shared.mediaPanelActiveTab`. Refreshing the page therefore restores the user's last live/news state. Temporary hides from `closeTransientMobileOverlays()` carry `persist:false` and do not overwrite the preference.
`brand.js` manages Earth HUD brand resources. Static assets provide the default brand; runtime overrides come from `/api/v1/earth/brand`, and uploaded images are served from `/earth-brand-assets/...`. The frontend must treat logo/title images and text fallback separately: if an image fails, show the text title; if text fields are empty, rely on backend defaults so the HUD brand area never renders blank. The console Earth Content page owns saving and resetting brand configuration; the Earth frontend only consumes it.
The compute-center layer row has a notification badge for GeoJSON `unresolved` records. The badge means "no trustworthy coordinates, cannot render on the globe"; it is different from the `?` marker drawn on already positioned but unconfirmed compute centers. Clicking the badge opens a fixed info card beside the layer panel. Row-level `采集` fetches candidates only. Header-level `一键采用` processes the queue top-to-bottom, saves the highest-confidence valid candidate, removes successful rows, renumbers the list, and dispatches `earth:compute-center-unresolved-count-change` so the badge updates immediately. When the batch ends, `earth:compute-center-location-saved` refreshes the real layer. The compute-center layer row has a notification badge for GeoJSON `unresolved` records. The badge means "no trustworthy coordinates, cannot render on the globe"; it is different from the `?` marker drawn on already positioned but unconfirmed compute centers. Clicking the badge opens a fixed info card beside the layer panel. Row-level `采集` fetches candidates only. Header-level `一键采用` processes the queue top-to-bottom, saves the highest-confidence valid candidate, removes successful rows, renumbers the list, and dispatches `earth:compute-center-unresolved-count-change` so the badge updates immediately. When the batch ends, `earth:compute-center-location-saved` refreshes the real layer.
Location candidate state in the details card is cached in [info-card.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/info-card.js) by `entityType:entityId`. If the user closes the details card or unresolved queue and reopens the same compute center / BGP collector, previously collected candidates and status text are restored. Header-level `一键采用` prefers cached candidates, avoiding repeated online geocoding or LLM factcheck calls. After a location is saved, that entity's candidate list is cleared to a "refreshing layer" status so stale candidates do not keep misleading the user. Location candidate state in the details card is cached in [info-card.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/info-card.js) by `entityType:entityId`. If the user closes the details card or unresolved queue and reopens the same compute center / BGP collector, previously collected candidates and status text are restored. Header-level `一键采用` prefers cached candidates, avoiding repeated online geocoding or LLM factcheck calls. After a location is saved, that entity's candidate list is cleared to a "refreshing layer" status so stale candidates do not keep misleading the user.

View File

@@ -340,6 +340,12 @@ Collecting and saving are two separate actions. Candidates can be previewed on E
Compute-center saves write to `compute_center_locations` and refresh the layer. Records with no candidate stay in the unresolved list; Planet does not fabricate a location from a country center or hard-coded hint. Compute-center saves write to `compute_center_locations` and refresh the layer. Records with no candidate stay in the unresolved list; Planet does not fabricate a location from a country center or hard-coded hint.
### Why did the Earth logo or title not return to the default after I edited it?
Earth brand assets are managed from `Operations and Configuration -> Earth Content -> Brand Assets`. Uploaded images are stored as Earth brand asset URLs. If text fields such as title or ARIA label are cleared, Planet falls back to default text so the HUD never renders an empty brand.
Use `Reset Brand Assets` to restore the shipped logo, title image, and copy. Refreshing Earth does not delete the saved runtime brand configuration.
### Why are satellites no longer on one sphere? ### Why are satellites no longer on one sphere?
Earth enables "Real Satellite Altitude" by default. Satellite positions still come from TLE/SGP4, but altitude is compressed for display: LEO satellites stay close to the globe, while higher-orbit satellites render farther out without leaving the normal view. The maximum display offset is `25`, about one quarter of the current globe radius; this is a readability compromise that separates GEO / MEO / LEO without drawing real kilometers to scale. This setting also affects satellite trails and the predicted orbit shown after locking a satellite. Earth enables "Real Satellite Altitude" by default. Satellite positions still come from TLE/SGP4, but altitude is compressed for display: LEO satellites stay close to the globe, while higher-orbit satellites render farther out without leaving the normal view. The maximum display offset is `25`, about one quarter of the current globe radius; this is a readability compromise that separates GEO / MEO / LEO without drawing real kilometers to scale. This setting also affects satellite trails and the predicted orbit shown after locking a satellite.

View File

@@ -187,6 +187,7 @@ 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: `/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.
- **TV Livestream**: manages sources shown in the Earth media panel. - **TV Livestream**: manages sources shown in the Earth media panel.
- **Boundary Precision**: shows the current provider, low-precision fallback, high-precision PMTiles/manifest status, local source JSON, and manual build action. - **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. - **Base Map**, **Layer Resources**, **3D Assets**, and **News Anchor Strategy**: placeholder tabs for future configuration. They do not display fake data.
@@ -280,7 +281,7 @@ Candidates preview on Earth directly. Saving a compute-center candidate writes t
### Settings ### Settings
The settings panel 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, 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, shortcut enablement and remapping, default globe size, terrain opacity, reset.
"Real Satellite Altitude" is enabled by default: satellite positions use a compressed display height based on TLE/SGP4 orbital altitude. LEO satellites remain close to the globe, while high-orbit satellites render farther out without leaving the normal view. The high-orbit display height is capped at about one quarter of the globe radius, so GEO / MEO objects remain visually separated from LEO without spreading trails and selection targets too far apart. Turning it off restores the legacy same-sphere satellite display. "Track Display" controls satellite trail visibility; trails are unavailable while the satellite layer is hidden. "Real Satellite Altitude" is enabled by default: satellite positions use a compressed display height based on TLE/SGP4 orbital altitude. LEO satellites remain close to the globe, while high-orbit satellites render farther out without leaving the normal view. The high-orbit display height is capped at about one quarter of the globe radius, so GEO / MEO objects remain visually separated from LEO without spreading trails and selection targets too far apart. Turning it off restores the legacy same-sphere satellite display. "Track Display" controls satellite trail visibility; trails are unavailable while the satellite layer is hidden.
@@ -288,6 +289,8 @@ The settings panel covers: rotate / cruise / motion mode, cruise modules (BGP/ne
These settings live in browser local storage; switching browsers or clearing site data resets them. These settings live in browser local storage; switching browsers or clearing site data resets them.
Shortcuts also live in browser local storage. Use the Shortcuts category to disable an individual shortcut, capture a new key, or restore the default binding for the current browser only.
### View Controls ### View Controls
| Action | Effect | | Action | Effect |

View File

@@ -107,6 +107,12 @@ AI 配置页使用的接口:
`aiprovider` 是纯模型适配器,不注入通用业务 system prompt。新闻汉化、告警研判、BGP 简报、位置 factcheck、数据源映射和凭据教程等入口各自通过 task key 解析有效提示词。告警研判 prompt 只会在告警相关 task 中作为 system prompt 传入,不会污染其它 LLM 调用。 `aiprovider` 是纯模型适配器,不注入通用业务 system prompt。新闻汉化、告警研判、BGP 简报、位置 factcheck、数据源映射和凭据教程等入口各自通过 task key 解析有效提示词。告警研判 prompt 只会在告警相关 task 中作为 system prompt 传入,不会污染其它 LLM 调用。
### Agent 与工具边界
Agent 工作流属于 `backend`,不属于 `aiprovider`。后续 Earth LLM 指令、态势感知、多角色模拟、WebSearch、数据库查询、证据存储和配置提案应用都应由后端 Agent Runtime 编排;`aiprovider` 只接收后端整理好的模型请求并返回规范化响应。
如果某个 provider 支持原生 tool calling`aiprovider` 可以透传协议字段并规范化响应块但工具白名单、参数校验、权限策略、运行记录和写入审批仍必须留在后端。provider 不支持原生 tools 时,后端使用 JSON tool-call fallback不应为了某个模型厂商把业务工具下沉到 `aiprovider`
### AI Provider 内部 API ### AI Provider 内部 API
仅供内部调用的接口: 仅供内部调用的接口:

View File

@@ -71,6 +71,10 @@ React 路由入口:
这份文件是 Earth 前端当前最核心的 UI 控制入口。 这份文件是 Earth 前端当前最核心的 UI 控制入口。
Earth 设置面板现在按 `data-settings-tab``data-settings-tab-panel` 分类组织。桌面端和移动端使用同一组分类语义:运行、显示、面板、动捕、快捷键、系统。新增设置项时应先判断它属于哪个分类,再补 DOM、持久化字段和恢复逻辑不要把所有控件继续堆到一个长面板里。
快捷键配置属于设备本地偏好,由 `controls.js` 负责读取、捕获、启用/禁用和重置。它不应写入后端用户设置,也不应影响其它浏览器。后续新增快捷键时,必须同时提供默认键、显示标签、可禁用状态和重置路径,避免只在 keydown handler 中硬编码。
### 4. UI 与状态消息 ### 4. UI 与状态消息
- [ui.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/ui.js) - [ui.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/ui.js)
@@ -156,6 +160,8 @@ Earth 地表是多层近似同心球,不是单一 mesh。`earth.js` 的基座
`tv.js` 管理 `media-panel` 里的直播 / 态势新闻 tab。toolbar 打开或切换 TV/新闻时,会通过 `earth:tv-visibility-change``earth:tv-tab-change` 回写 Earth 设置:面板可见性仍按 desktop/mobile viewport 存在 `views.<scope>.panelVisibility.media-panel`,当前 tab 存在 `shared.mediaPanelActiveTab`,因此刷新页面后能恢复用户上次打开的直播或新闻状态。`closeTransientMobileOverlays()` 这类临时收起会带 `persist:false`,不会覆盖用户偏好。 `tv.js` 管理 `media-panel` 里的直播 / 态势新闻 tab。toolbar 打开或切换 TV/新闻时,会通过 `earth:tv-visibility-change``earth:tv-tab-change` 回写 Earth 设置:面板可见性仍按 desktop/mobile viewport 存在 `views.<scope>.panelVisibility.media-panel`,当前 tab 存在 `shared.mediaPanelActiveTab`,因此刷新页面后能恢复用户上次打开的直播或新闻状态。`closeTransientMobileOverlays()` 这类临时收起会带 `persist:false`,不会覆盖用户偏好。
`brand.js` 管理 Earth HUD 品牌资源。默认品牌来自静态资源,运行时覆盖值来自 `/api/v1/earth/brand`,上传的图片通过 `/earth-brand-assets/...` 读取。前端必须把 logo/title 图片和文本 fallback 分开处理:图片加载失败时显示文本标题,文本字段为空时使用后端默认值,避免 HUD 品牌区空白。控制台的 Earth 内容页负责保存和重置品牌配置Earth 前端只消费结果。
其中 Earth 启动加载链现在也拆成了两层: 其中 Earth 启动加载链现在也拆成了两层:
- `controls.js` - `controls.js`

View File

@@ -342,6 +342,12 @@ Docs 按 Gatekeeper 权限组控制可见性:
算力中心候选保存后会写入 `compute_center_locations`。没有可用候选的记录会保留在待定位列表中,系统不会用国家中心点或硬编码 hint 伪造位置。 算力中心候选保存后会写入 `compute_center_locations`。没有可用候选的记录会保留在待定位列表中,系统不会用国家中心点或硬编码 hint 伪造位置。
### Earth 品牌 logo 或标题改完后为什么没恢复默认?
Earth 品牌资源在控制台 `运维与配置 -> Earth 内容 -> 品牌资源` 中维护。上传图片后页面会使用返回的 Earth 品牌资产地址如果只是清空标题、ARIA 文案等文本字段,系统会回退到默认标题,避免出现空白品牌。
要恢复发布包自带的默认 logo、标题图和文案使用“重置品牌资源”。只刷新 Earth 页面不会删除已经保存的运行时品牌配置。
### 为什么卫星看起来不在同一个球面上? ### 为什么卫星看起来不在同一个球面上?
Earth 默认开启“真实卫星高度”。卫星位置仍来自 TLE/SGP4但高度会经过压缩映射低轨卫星靠近地球高轨卫星更远同时保持在当前视图可读范围内。最高显示偏移使用 `25`,约等于当前地球显示半径的四分之一;这是视觉上区分 GEO / MEO / LEO 和保持镜头可读性的折中,不是把真实公里数按比例直接画出来。这个设置也会影响卫星轨迹和锁定后的预测轨道。 Earth 默认开启“真实卫星高度”。卫星位置仍来自 TLE/SGP4但高度会经过压缩映射低轨卫星靠近地球高轨卫星更远同时保持在当前视图可读范围内。最高显示偏移使用 `25`,约等于当前地球显示半径的四分之一;这是视觉上区分 GEO / MEO / LEO 和保持镜头可读性的折中,不是把真实公里数按比例直接画出来。这个设置也会影响卫星轨迹和锁定后的预测轨道。

View File

@@ -190,6 +190,7 @@ Base URL 输入框尾端的插头图标会触发连接测试。测试通过会
`/earth-content` 位于控制台“运维与配置”下,面向 Earth 前端体验资源: `/earth-content` 位于控制台“运维与配置”下,面向 Earth 前端体验资源:
- **品牌资源**:维护 Earth HUD 使用的 logo、标题图、标题文本、副标题和描述上传的图片会保存为 Earth 品牌资产并立即供 Earth 页面读取。
- **电视直播**:维护 Earth 媒体面板里的直播源。 - **电视直播**:维护 Earth 媒体面板里的直播源。
- **国界精度**:查看当前国界 provider、低精 fallback、高精 PMTiles/manifest 状态,编辑本机源配置并手动构建。 - **国界精度**:查看当前国界 provider、低精 fallback、高精 PMTiles/manifest 状态,编辑本机源配置并手动构建。
- **地球底图**、**图层资源**、**三维素材**、**新闻锚点策略**:目前是待接入占位页,不展示假数据。 - **地球底图**、**图层资源**、**三维素材**、**新闻锚点策略**:目前是待接入占位页,不展示假数据。
@@ -283,7 +284,7 @@ AIS 船只图例按船型显示颜色:货轮、油轮、客船、渔船、军
### 设置 ### 设置
设置面板包含旋转模式 / 巡航模式 / 动捕模式、巡航模块BGP/新闻/算力中心/船只/海缆/卫星)、视图设置(卫星显示风格、悬停提示、卫星呼吸闪烁、真实卫星高度、轨迹显示、低缩放圆点、日夜模式、面板显示开关)、动捕调试模式 / 输入源 / 只显示骨骼、地球默认大小、地形透明度、重置设置。 设置面板按分类组织:运行、显示、面板、动捕、快捷键、系统。里面包含旋转模式 / 巡航模式 / 动捕模式、巡航模块BGP/新闻/算力中心/船只/海缆/卫星)、视图设置(卫星显示风格、悬停提示、卫星呼吸闪烁、真实卫星高度、轨迹显示、低缩放圆点、日夜模式、面板显示开关)、动捕调试模式 / 输入源 / 只显示骨骼、快捷键启用与改键、地球默认大小、地形透明度、重置设置。
“真实卫星高度”默认开启:卫星会按 TLE/SGP4 算出的真实轨道高度做压缩分层显示,低轨仍靠近地球,高轨会更远但不会脱离当前视图。高轨显示高度会被压到地球半径外约四分之一以内,这样 GEO / MEO 仍能和 LEO 分层,但不会把视线、轨迹和选择操作拉得过散;关闭后恢复旧版所有卫星位于同一显示球面的效果。“轨迹显示”控制卫星轨迹线显隐,卫星图层关闭时轨迹也不可见。 “真实卫星高度”默认开启:卫星会按 TLE/SGP4 算出的真实轨道高度做压缩分层显示,低轨仍靠近地球,高轨会更远但不会脱离当前视图。高轨显示高度会被压到地球半径外约四分之一以内,这样 GEO / MEO 仍能和 LEO 分层,但不会把视线、轨迹和选择操作拉得过散;关闭后恢复旧版所有卫星位于同一显示球面的效果。“轨迹显示”控制卫星轨迹线显隐,卫星图层关闭时轨迹也不可见。
@@ -291,6 +292,8 @@ AIS 船只图例按船型显示颜色:货轮、油轮、客船、渔船、军
这些设置保存在浏览器本地存储,换浏览器或清理站点数据后会恢复默认值。 这些设置保存在浏览器本地存储,换浏览器或清理站点数据后会恢复默认值。
快捷键也保存在浏览器本地。可以在设置里的“快捷键”分类中单独禁用、重设某个快捷键,或恢复默认键位;这只影响当前浏览器。
### 视角控制 ### 视角控制
| 操作 | 作用 | | 操作 | 作用 |

View File

@@ -16,12 +16,13 @@
## Current Version ## Current Version
- `main` 当前主线历史推导到:`0.16.5` - `main` 当前主线历史推导到:`0.16.5`
- `dev` 当前开发分支历史推导到:`0.59.0` - `dev` 当前开发分支历史推导到:`0.60.0`
## Timeline ## Timeline
| Version | Type | Branch | Commit | Summary | | Version | Type | Branch | Commit | Summary |
| --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- |
| `0.60.0` | feature | `dev` | `pending` | Earth 内容/国界运行体验、新闻中文摘要展示、AI Provider 纯净边界和提示词运维配置落地,并补充 Agent Runtime 与 Earth LLM 指令计划 |
| `0.59.0` | feature | `dev` | `pending` | Earth 国界迁移为静态资产并恢复低精 fallback新增工具栏高精构建进度、后台 Earth 内容/采集管理拆分、AI task prompt 管理和新闻锚点队列补丁链路 | | `0.59.0` | feature | `dev` | `pending` | Earth 国界迁移为静态资产并恢复低精 fallback新增工具栏高精构建进度、后台 Earth 内容/采集管理拆分、AI task prompt 管理和新闻锚点队列补丁链路 |
| `0.58.0` | feature | `dev` | `pending` | Earth 高精度国界切换到 PMTiles/MVT 和标准源采集器,移除旧低精度兜底,修复远距地表 z-fighting 雪花/黑块,并补齐新闻目标地点队列与文档 | | `0.58.0` | feature | `dev` | `pending` | Earth 高精度国界切换到 PMTiles/MVT 和标准源采集器,移除旧低精度兜底,修复远距地表 z-fighting 雪花/黑块,并补齐新闻目标地点队列与文档 |
| `0.57.0` | feature | `dev` | `pending` | WSL `--allow-lan` 新增临时 Windows relay保持 localhost 与局域网同用 3000/8000并自动处理旧 portproxy、防火墙授权和 Vite ESM 配置 | | `0.57.0` | feature | `dev` | `pending` | WSL `--allow-lan` 新增临时 Windows relay保持 localhost 与局域网同用 3000/8000并自动处理旧 portproxy、防火墙授权和 Vite ESM 配置 |

View File

@@ -1,6 +1,6 @@
{ {
"name": "planet-frontend", "name": "planet-frontend",
"version": "0.59.0", "version": "0.60.0",
"private": true, "private": true,
"packageManager": "bun@1", "packageManager": "bun@1",
"dependencies": { "dependencies": {

View File

@@ -1290,6 +1290,53 @@
gap: 10px; gap: 10px;
} }
.earth-mobile-settings-tabs {
display: flex;
gap: 8px;
overflow-x: auto;
padding-bottom: 2px;
scrollbar-width: none;
}
.earth-mobile-settings-tabs::-webkit-scrollbar {
display: none;
}
.earth-mobile-settings-tab {
flex: 0 0 auto;
min-width: 52px;
border: 1px solid rgba(212, 227, 244, 0.1);
border-radius: 999px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.04), transparent),
rgba(255, 255, 255, 0.025);
color: var(--hud-text-soft);
padding: 9px 14px;
font: inherit;
font-size: 0.8rem;
font-weight: 700;
letter-spacing: 0.02em;
cursor: pointer;
transition:
background 0.18s ease,
border-color 0.18s ease,
color 0.18s ease,
transform 0.18s ease;
}
.earth-mobile-settings-tab:hover {
color: var(--hud-text);
transform: translateY(-1px);
}
.earth-mobile-settings-tab.is-active {
color: var(--hud-title);
border-color: rgba(122, 180, 255, 0.28);
background:
radial-gradient(circle at 35% 22%, rgba(255, 255, 255, 0.18), transparent 58%),
linear-gradient(180deg, rgba(122, 180, 255, 0.18), rgba(82, 123, 186, 0.24));
}
.earth-mobile-settings-card { .earth-mobile-settings-card {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -2164,6 +2211,52 @@ label.is-disabled.earth-mobile-settings-card {
gap: var(--hud-gap-md); gap: var(--hud-gap-md);
} }
.earth-settings-tabs {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: calc(6px * var(--settings-scale));
padding: calc(2px * var(--settings-scale)) 0 calc(4px * var(--settings-scale));
}
.earth-settings-tab {
min-width: 0;
border: 1px solid rgba(212, 227, 244, 0.1);
border-radius: 999px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.04), transparent),
rgba(255, 255, 255, 0.025);
color: var(--hud-text-soft);
padding: calc(7px * var(--settings-scale)) calc(10px * var(--settings-scale));
font: inherit;
font-size: calc(0.68rem * var(--hud-scale));
font-weight: 700;
letter-spacing: 0.04em;
cursor: pointer;
transition:
background 0.18s ease,
border-color 0.18s ease,
color 0.18s ease,
transform 0.18s ease;
}
.earth-settings-tab:hover {
color: var(--hud-text);
transform: translateY(-1px);
}
.earth-settings-tab.is-active {
color: var(--hud-title);
border-color: rgba(122, 180, 255, 0.28);
background:
radial-gradient(circle at 35% 22%, rgba(255, 255, 255, 0.2), transparent 58%),
linear-gradient(180deg, rgba(121, 159, 207, 0.22), rgba(72, 101, 139, 0.28));
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.08),
0 8px 18px rgba(0, 0, 0, 0.16);
}
.earth-settings-kicker { .earth-settings-kicker {
color: var(--hud-text-soft); color: var(--hud-text-soft);
font-size: calc(0.72rem * var(--hud-scale)); font-size: calc(0.72rem * var(--hud-scale));
@@ -2193,6 +2286,8 @@ label.is-disabled.earth-mobile-settings-card {
} }
.earth-settings-content { .earth-settings-content {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto; overflow-y: auto;
padding-inline: 8px; padding-inline: 8px;
padding-right: 10px; padding-right: 10px;
@@ -2656,6 +2751,154 @@ label.is-disabled.earth-mobile-settings-card {
pointer-events: none; pointer-events: none;
} }
.earth-shortcut-list {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
}
.earth-shortcut-category {
margin-top: 4px;
color: var(--hud-text-soft);
font-size: calc(0.62rem * var(--hud-scale));
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.earth-shortcut-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto auto;
align-items: center;
gap: 10px;
padding: 9px 10px;
border: 1px solid rgba(212, 227, 244, 0.08);
background: rgba(255, 255, 255, 0.025);
}
.earth-shortcut-copy {
display: flex;
min-width: 0;
flex-direction: column;
gap: 3px;
}
.earth-shortcut-label {
color: var(--hud-text);
font-size: calc(0.72rem * var(--hud-scale));
font-weight: 700;
}
.earth-shortcut-alias {
color: var(--hud-text-muted);
font-size: calc(0.62rem * var(--hud-scale));
line-height: 1.3;
}
.earth-shortcut-key {
min-width: calc(68px * var(--hud-scale));
border: 1px solid rgba(122, 180, 255, 0.24);
border-radius: calc(6px * var(--hud-scale));
background:
radial-gradient(circle at 35% 22%, rgba(255, 255, 255, 0.16), transparent 58%),
linear-gradient(180deg, rgba(121, 159, 207, 0.16), rgba(72, 101, 139, 0.2));
color: var(--hud-title);
padding: calc(6px * var(--hud-scale)) calc(10px * var(--hud-scale));
font: inherit;
font-size: calc(0.68rem * var(--hud-scale));
font-weight: 800;
cursor: pointer;
}
.earth-shortcut-key.is-capturing {
border-color: rgba(110, 226, 188, 0.52);
color: #d8fff3;
}
.earth-shortcut-reset {
display: inline-flex;
align-items: center;
justify-content: center;
width: calc(28px * var(--hud-scale));
height: calc(28px * var(--hud-scale));
border: 1px solid rgba(212, 227, 244, 0.1);
border-radius: 999px;
background: rgba(255, 255, 255, 0.035);
color: var(--hud-text-soft);
cursor: pointer;
}
.earth-shortcut-enable {
position: relative;
display: inline-flex;
width: calc(34px * var(--hud-scale));
height: calc(20px * var(--hud-scale));
cursor: pointer;
}
.earth-shortcut-enable input {
position: absolute;
opacity: 0;
pointer-events: none;
}
.earth-shortcut-enable .earth-settings-switch-track {
width: 100%;
height: 100%;
}
.earth-shortcut-enable .earth-settings-switch-track::after {
width: calc(14px * var(--hud-scale));
height: calc(14px * var(--hud-scale));
}
.earth-shortcut-enable input: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-shortcut-enable input:checked + .earth-settings-switch-track::after {
transform: translate(calc(14px * var(--hud-scale)), -50%);
}
.earth-shortcut-reset .material-symbols-rounded {
font-size: calc(0.9rem * var(--hud-scale));
}
.earth-settings-shortcut-actions {
display: flex;
justify-content: flex-start;
}
.earth-mobile-settings-card .earth-shortcut-category {
font-size: 0.68rem;
}
.earth-mobile-settings-card .earth-shortcut-row {
grid-template-columns: minmax(0, 1fr) auto auto auto;
padding: 10px;
}
.earth-mobile-settings-card .earth-shortcut-label {
font-size: 0.82rem;
}
.earth-mobile-settings-card .earth-shortcut-alias {
font-size: 0.72rem;
}
.earth-mobile-settings-card .earth-shortcut-key {
min-width: 70px;
padding: 8px 10px;
font-size: 0.78rem;
}
.earth-mobile-settings-card .earth-shortcut-reset {
width: 32px;
height: 32px;
}
.hud-panel-motion-debug { .hud-panel-motion-debug {
position: absolute; position: absolute;
right: calc(24px * var(--hud-scale)); right: calc(24px * var(--hud-scale));

View File

@@ -92,6 +92,16 @@
object-fit: contain; object-fit: contain;
} }
.hud-panel-brand .earth-brand__title-text {
color: var(--hud-text);
font-size: calc(1rem * var(--hud-scale) * var(--brand-scale));
line-height: 1.18;
font-weight: 700;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.hud-panel-brand .earth-brand__meta { .hud-panel-brand .earth-brand__meta {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@@ -849,8 +849,16 @@
<span class="earth-mobile-page-kicker">Settings</span> <span class="earth-mobile-page-kicker">Settings</span>
<span class="earth-mobile-page-summary">仅保留移动端仍有意义的 Earth 配置</span> <span class="earth-mobile-page-summary">仅保留移动端仍有意义的 Earth 配置</span>
</div> </div>
<div class="earth-mobile-settings-group"> <div class="earth-mobile-settings-tabs" role="tablist" aria-label="移动端设置分类">
<div class="earth-mobile-settings-title">旋转</div> <button type="button" class="earth-mobile-settings-tab is-active" data-settings-tab="runtime" aria-selected="true">运行</button>
<button type="button" class="earth-mobile-settings-tab" data-settings-tab="display" aria-selected="false">显示</button>
<button type="button" class="earth-mobile-settings-tab" data-settings-tab="panels" aria-selected="false">面板</button>
<button type="button" class="earth-mobile-settings-tab" data-settings-tab="motion" aria-selected="false">动捕</button>
<button type="button" class="earth-mobile-settings-tab" data-settings-tab="shortcuts" aria-selected="false">快捷键</button>
<button type="button" class="earth-mobile-settings-tab" data-settings-tab="system" aria-selected="false">系统</button>
</div>
<div class="earth-mobile-settings-group" data-settings-tab-panel="runtime">
<div class="earth-mobile-settings-title">运行</div>
<div class="earth-mobile-settings-card earth-mobile-settings-card--stacked"> <div class="earth-mobile-settings-card earth-mobile-settings-card--stacked">
<div class="earth-mobile-settings-copy"> <div class="earth-mobile-settings-copy">
<span class="earth-mobile-settings-label">旋转模式</span> <span class="earth-mobile-settings-label">旋转模式</span>
@@ -877,8 +885,8 @@
</div> </div>
</div> </div>
</div> </div>
<div class="earth-mobile-settings-group"> <div class="earth-mobile-settings-group" data-settings-tab-panel="display" hidden>
<div class="earth-mobile-settings-title">视图</div> <div class="earth-mobile-settings-title">显示</div>
<div class="earth-mobile-settings-card earth-mobile-settings-card--stacked"> <div class="earth-mobile-settings-card earth-mobile-settings-card--stacked">
<div class="earth-mobile-settings-copy"> <div class="earth-mobile-settings-copy">
<span class="earth-mobile-settings-label">卫星显示风格</span> <span class="earth-mobile-settings-label">卫星显示风格</span>
@@ -950,10 +958,115 @@
<span class="earth-mobile-settings-switch-track"></span> <span class="earth-mobile-settings-switch-track"></span>
</span> </span>
</label> </label>
<label <div class="earth-mobile-settings-card earth-mobile-settings-card--stacked">
class="earth-mobile-settings-card" <div class="earth-mobile-settings-copy">
data-gatekeeper-permission="earth.motion_debug" <span class="earth-mobile-settings-label">地球默认大小</span>
> <span class="earth-mobile-settings-subtitle">用于重置视角、缩放重置和巡航视图</span>
</div>
<div class="earth-mobile-settings-slider-row">
<input
class="earth-mobile-settings-slider"
type="range"
min="0.5"
max="5"
step="0.01"
value="1"
data-default-earth-size-slider
aria-label="移动端调整地球默认大小"
>
<span class="earth-mobile-settings-slider-value" data-default-earth-size-value>100%</span>
</div>
</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">调高后会呈现更明显的绿色地形覆盖效果</span>
</div>
<div class="earth-mobile-settings-slider-row">
<input
class="earth-mobile-settings-slider"
type="range"
min="0.05"
max="1"
step="0.01"
value="0.62"
data-terrain-opacity-slider
aria-label="移动端调整地形透明度"
>
<span class="earth-mobile-settings-slider-value" data-terrain-opacity-value>62%</span>
</div>
</div>
<div class="earth-mobile-settings-card earth-mobile-settings-card--stacked">
<div class="earth-mobile-settings-copy">
<span class="earth-mobile-settings-label" data-boundary-precision-status>国界精度</span>
<span class="earth-mobile-settings-subtitle" data-boundary-precision-detail>正在读取高清国界状态...</span>
</div>
<div class="earth-boundary-progress" data-boundary-precision-progress-wrap hidden>
<div class="earth-boundary-progress__track">
<div class="earth-boundary-progress__bar" data-boundary-precision-progress-bar></div>
</div>
<span class="earth-boundary-progress__value" data-boundary-precision-progress-value>0%</span>
</div>
<div class="earth-settings-boundary-action-row">
<div class="earth-mobile-settings-segmented earth-mobile-settings-segmented--boundary" role="group" aria-label="移动端选择国界精度">
<button type="button" class="earth-mobile-settings-pill is-active" data-boundary-precision-disable aria-pressed="true">低精</button>
<button type="button" class="earth-mobile-settings-pill" data-boundary-precision-build aria-pressed="false">高精</button>
</div>
<button type="button" class="earth-settings-reload-action" data-boundary-precision-rebuild aria-label="重新获取并构建高清国界" title="重新获取并构建" hidden disabled>
<span class="material-symbols-rounded">refresh</span>
</button>
</div>
</div>
</div>
<div class="earth-mobile-settings-group" data-settings-tab-panel="panels" hidden>
<div class="earth-mobile-settings-title">面板</div>
<label class="earth-mobile-settings-card">
<div class="earth-mobile-settings-copy">
<span class="earth-mobile-settings-label">图层控制</span>
<span class="earth-mobile-settings-subtitle">控制图层控制面板显示</span>
</div>
<span class="earth-mobile-settings-switch">
<input type="checkbox" data-settings-panel="layer-toggles" checked>
<span class="earth-mobile-settings-switch-track"></span>
</span>
</label>
<label class="earth-mobile-settings-card">
<div class="earth-mobile-settings-copy">
<span class="earth-mobile-settings-label">图例</span>
<span class="earth-mobile-settings-subtitle">控制图例面板显示</span>
</div>
<span class="earth-mobile-settings-switch">
<input type="checkbox" data-settings-panel="legend">
<span class="earth-mobile-settings-switch-track"></span>
</span>
</label>
<label class="earth-mobile-settings-card">
<div class="earth-mobile-settings-copy">
<span class="earth-mobile-settings-label">全球态势</span>
<span class="earth-mobile-settings-subtitle">控制全球态势统计面板显示</span>
</div>
<span class="earth-mobile-settings-switch">
<input type="checkbox" data-settings-panel="earth-stats">
<span class="earth-mobile-settings-switch-track"></span>
</span>
</label>
<label class="earth-mobile-settings-card">
<div class="earth-mobile-settings-copy">
<span class="earth-mobile-settings-label">新闻直播</span>
<span class="earth-mobile-settings-subtitle">控制 Live 新闻面板显示</span>
</div>
<span class="earth-mobile-settings-switch">
<input type="checkbox" data-settings-panel="media-panel">
<span class="earth-mobile-settings-switch-track"></span>
</span>
</label>
</div>
<div class="earth-mobile-settings-group" data-settings-tab-panel="motion" hidden>
<div class="earth-mobile-settings-title">动捕</div>
<label
class="earth-mobile-settings-card"
data-gatekeeper-permission="earth.motion_debug"
>
<div class="earth-mobile-settings-copy"> <div class="earth-mobile-settings-copy">
<span class="earth-mobile-settings-label">动捕调试模式</span> <span class="earth-mobile-settings-label">动捕调试模式</span>
<span class="earth-mobile-settings-subtitle">显示摄像头识别到的骨架连线和匹配动作</span> <span class="earth-mobile-settings-subtitle">显示摄像头识别到的骨架连线和匹配动作</span>
@@ -976,49 +1089,21 @@
<button type="button" class="earth-mobile-settings-pill" data-motion-provider="motion_agent" aria-pressed="false">Motion Agent</button> <button type="button" class="earth-mobile-settings-pill" data-motion-provider="motion_agent" aria-pressed="false">Motion Agent</button>
</div> </div>
</div> </div>
</div>
<div class="earth-mobile-settings-group" data-settings-tab-panel="shortcuts" hidden>
<div class="earth-mobile-settings-title">快捷键</div>
<div class="earth-mobile-settings-card earth-mobile-settings-card--stacked"> <div class="earth-mobile-settings-card earth-mobile-settings-card--stacked">
<div class="earth-mobile-settings-copy"> <div class="earth-mobile-settings-copy">
<span class="earth-mobile-settings-label">地球默认大小</span> <span class="earth-mobile-settings-label">键盘控制</span>
<span class="earth-mobile-settings-subtitle">用于重置视角、缩放重置和巡航视图</span> <span class="earth-mobile-settings-subtitle">点击按键后按下新的快捷键Esc 取消录入</span>
</div> </div>
<div class="earth-mobile-settings-slider-row"> <div class="earth-shortcut-list" data-shortcut-list></div>
<input <div class="earth-mobile-settings-actions">
class="earth-mobile-settings-slider" <button class="earth-mobile-action-btn earth-mobile-action-btn--ghost" type="button" data-shortcut-reset-all>恢复默认快捷键</button>
type="range"
min="0.5"
max="5"
step="0.01"
value="1"
data-default-earth-size-slider
aria-label="移动端调整地球默认大小"
>
<span class="earth-mobile-settings-slider-value" data-default-earth-size-value>100%</span>
</div> </div>
</div> </div>
</div> </div>
<div class="earth-mobile-settings-group"> <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">调高后会呈现更明显的绿色地形覆盖效果</span>
</div>
<div class="earth-mobile-settings-slider-row">
<input
class="earth-mobile-settings-slider"
type="range"
min="0.05"
max="1"
step="0.01"
value="0.62"
data-terrain-opacity-slider
aria-label="移动端调整地形透明度"
>
<span class="earth-mobile-settings-slider-value" data-terrain-opacity-value>62%</span>
</div>
</div>
</div>
<div class="earth-mobile-settings-group">
<div class="earth-mobile-settings-title">系统</div> <div class="earth-mobile-settings-title">系统</div>
<div class="earth-mobile-settings-actions"> <div class="earth-mobile-settings-actions">
<button id="mobile-settings-reset" class="earth-mobile-action-btn earth-mobile-action-btn--ghost" type="button">重置设置</button> <button id="mobile-settings-reset" class="earth-mobile-action-btn earth-mobile-action-btn--ghost" type="button">重置设置</button>
@@ -1100,9 +1185,17 @@
<span class="material-symbols-rounded">close</span> <span class="material-symbols-rounded">close</span>
</button> </button>
</div> </div>
<div class="earth-settings-tabs" role="tablist" aria-label="设置分类">
<button type="button" class="earth-settings-tab is-active" data-settings-tab="runtime" aria-selected="true">运行</button>
<button type="button" class="earth-settings-tab" data-settings-tab="display" aria-selected="false">显示</button>
<button type="button" class="earth-settings-tab" data-settings-tab="panels" aria-selected="false">面板</button>
<button type="button" class="earth-settings-tab" data-settings-tab="motion" aria-selected="false">动捕</button>
<button type="button" class="earth-settings-tab" data-settings-tab="shortcuts" aria-selected="false">快捷键</button>
<button type="button" class="earth-settings-tab" data-settings-tab="system" aria-selected="false">系统</button>
</div>
<div class="earth-settings-content hud-panel__body"> <div class="earth-settings-content hud-panel__body">
<section class="earth-settings-section"> <section class="earth-settings-section" data-settings-tab-panel="runtime">
<div class="earth-settings-section-title">旋转</div> <div class="earth-settings-section-title">运行</div>
<div class="earth-settings-list"> <div class="earth-settings-list">
<div class="earth-settings-item earth-settings-item--stacked"> <div class="earth-settings-item earth-settings-item--stacked">
<div class="earth-settings-copy"> <div class="earth-settings-copy">
@@ -1194,8 +1287,8 @@
</div> </div>
</div> </div>
</section> </section>
<section class="earth-settings-section"> <section class="earth-settings-section" data-settings-tab-panel="display" hidden>
<div class="earth-settings-section-title">视图</div> <div class="earth-settings-section-title">显示</div>
<div class="earth-settings-list"> <div class="earth-settings-list">
<div class="earth-settings-item earth-settings-item--stacked"> <div class="earth-settings-item earth-settings-item--stacked">
<div class="earth-settings-copy"> <div class="earth-settings-copy">
@@ -1303,33 +1396,70 @@
<span class="earth-settings-switch-track"></span> <span class="earth-settings-switch-track"></span>
</span> </span>
</label> </label>
<label <div class="earth-settings-item earth-settings-item--stacked">
class="earth-settings-item"
for="toggle-motion-debug"
data-gatekeeper-permission="earth.motion_debug"
>
<div class="earth-settings-copy"> <div class="earth-settings-copy">
<span class="earth-settings-item-title">动捕调试模式</span> <span class="earth-settings-item-title">地球默认大小</span>
<span class="earth-settings-item-subtitle">打开骨架连线面板;未匹配为红线,匹配动作后变绿</span> <span class="earth-settings-item-subtitle">用于重置视角、缩放重置和巡航视图的默认缩放比例</span>
</div> </div>
<span class="earth-settings-switch"> <div class="earth-settings-slider-row">
<input id="toggle-motion-debug" type="checkbox" data-motion-debug-toggle> <input
<span class="earth-settings-switch-track"></span> id="default-earth-size-slider"
</span> class="earth-settings-slider"
</label> type="range"
<div min="0.5"
class="earth-settings-item earth-settings-item--stacked" max="5"
data-gatekeeper-permission="earth.motion_debug" step="0.01"
> value="1"
<div class="earth-settings-copy"> aria-label="调整地球默认大小"
<span class="earth-settings-item-title">动捕输入源</span> >
<span class="earth-settings-item-subtitle">浏览器摄像头适合网页/SaaSMotion Agent 适合双摄、RTSP/HTTP 和客户端</span> <span id="default-earth-size-value" class="earth-settings-slider-value">100%</span>
</div>
<div class="earth-settings-segmented" role="group" aria-label="选择动捕输入源">
<button type="button" class="earth-settings-segmented-btn is-active" data-motion-provider="browser_camera" aria-pressed="true">浏览器摄像头</button>
<button type="button" class="earth-settings-segmented-btn" data-motion-provider="motion_agent" aria-pressed="false">Motion Agent</button>
</div> </div>
</div> </div>
<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">调高后会呈现更明显的绿色地形覆盖效果</span>
</div>
<div class="earth-settings-slider-row">
<input
id="terrain-opacity-slider"
class="earth-settings-slider"
type="range"
min="0.05"
max="1"
step="0.01"
value="0.62"
aria-label="调整地形透明度"
>
<span id="terrain-opacity-value" class="earth-settings-slider-value">62%</span>
</div>
</div>
<div class="earth-settings-item earth-settings-item--stacked">
<div class="earth-settings-copy">
<span class="earth-settings-item-title" id="boundary-precision-status" data-boundary-precision-status>国界精度</span>
<span class="earth-settings-item-subtitle" id="boundary-precision-detail" data-boundary-precision-detail>正在读取高清国界状态...</span>
</div>
<div id="boundary-precision-progress-wrap" class="earth-boundary-progress" data-boundary-precision-progress-wrap hidden>
<div class="earth-boundary-progress__track">
<div id="boundary-precision-progress-bar" class="earth-boundary-progress__bar" data-boundary-precision-progress-bar></div>
</div>
<span id="boundary-precision-progress-value" class="earth-boundary-progress__value" data-boundary-precision-progress-value>0%</span>
</div>
<div class="earth-settings-boundary-action-row">
<div class="earth-settings-segmented earth-settings-segmented--boundary" role="group" aria-label="选择国界精度">
<button id="boundary-precision-disable" type="button" class="earth-settings-segmented-btn is-active" data-boundary-precision-disable aria-pressed="true">低精</button>
<button id="boundary-precision-build" type="button" class="earth-settings-segmented-btn" data-boundary-precision-build aria-pressed="false">高精</button>
</div>
<button id="boundary-precision-rebuild" type="button" class="earth-settings-reload-action" data-boundary-precision-rebuild aria-label="重新获取并构建高清国界" title="重新获取并构建" hidden disabled>
<span class="material-symbols-rounded">refresh</span>
</button>
</div>
</div>
</div>
</section>
<section class="earth-settings-section" data-settings-tab-panel="panels" hidden>
<div class="earth-settings-section-title">面板</div>
<div class="earth-settings-list">
<label class="earth-settings-item" for="toggle-view-layers"> <label class="earth-settings-item" for="toggle-view-layers">
<div class="earth-settings-copy"> <div class="earth-settings-copy">
<span class="earth-settings-item-title">图层控制</span> <span class="earth-settings-item-title">图层控制</span>
@@ -1372,81 +1502,54 @@
</label> </label>
</div> </div>
</section> </section>
<section class="earth-settings-section"> <section class="earth-settings-section" data-settings-tab-panel="motion" hidden>
<div class="earth-settings-section-title">视图</div> <div class="earth-settings-section-title">动捕</div>
<div class="earth-settings-list"> <div class="earth-settings-list">
<div class="earth-settings-item earth-settings-item--stacked"> <label
class="earth-settings-item"
for="toggle-motion-debug"
data-gatekeeper-permission="earth.motion_debug"
>
<div class="earth-settings-copy"> <div class="earth-settings-copy">
<span class="earth-settings-item-title">地球默认大小</span> <span class="earth-settings-item-title">动捕调试模式</span>
<span class="earth-settings-item-subtitle">用于重置视角、缩放重置和巡航视图的默认缩放比例</span> <span class="earth-settings-item-subtitle">打开骨架连线面板;未匹配为红线,匹配动作后变绿</span>
</div> </div>
<div class="earth-settings-slider-row"> <span class="earth-settings-switch">
<input <input id="toggle-motion-debug" type="checkbox" data-motion-debug-toggle>
id="default-earth-size-slider" <span class="earth-settings-switch-track"></span>
class="earth-settings-slider" </span>
type="range" </label>
min="0.5" <div
max="5" class="earth-settings-item earth-settings-item--stacked"
step="0.01" data-gatekeeper-permission="earth.motion_debug"
value="1" >
aria-label="调整地球默认大小" <div class="earth-settings-copy">
> <span class="earth-settings-item-title">动捕输入源</span>
<span id="default-earth-size-value" class="earth-settings-slider-value">100%</span> <span class="earth-settings-item-subtitle">浏览器摄像头适合网页/SaaSMotion Agent 适合双摄、RTSP/HTTP 和客户端</span>
</div>
<div class="earth-settings-segmented" role="group" aria-label="选择动捕输入源">
<button type="button" class="earth-settings-segmented-btn is-active" data-motion-provider="browser_camera" aria-pressed="true">浏览器摄像头</button>
<button type="button" class="earth-settings-segmented-btn" data-motion-provider="motion_agent" aria-pressed="false">Motion Agent</button>
</div> </div>
</div> </div>
</div> </div>
</section> </section>
<section class="earth-settings-section"> <section class="earth-settings-section" data-settings-tab-panel="shortcuts" hidden>
<div class="earth-settings-section-title">地形</div> <div class="earth-settings-section-title">快捷键</div>
<div class="earth-settings-list"> <div class="earth-settings-list">
<div class="earth-settings-item earth-settings-item--stacked"> <div class="earth-settings-item earth-settings-item--stacked">
<div class="earth-settings-copy"> <div class="earth-settings-copy">
<span class="earth-settings-item-title">地形透明度</span> <span class="earth-settings-item-title">键盘控制</span>
<span class="earth-settings-item-subtitle">调高后会呈现更明显的绿色地形覆盖效果</span> <span class="earth-settings-item-subtitle">点击按键后按下新的快捷键Esc 取消录入,冲突快捷键不会保存</span>
</div> </div>
<div class="earth-settings-slider-row"> <div class="earth-shortcut-list" data-shortcut-list></div>
<input <div class="earth-settings-shortcut-actions">
id="terrain-opacity-slider" <button class="earth-settings-reload-action earth-settings-reload-action--text" type="button" data-shortcut-reset-all>恢复默认</button>
class="earth-settings-slider"
type="range"
min="0.05"
max="1"
step="0.01"
value="0.62"
aria-label="调整地形透明度"
>
<span id="terrain-opacity-value" class="earth-settings-slider-value">62%</span>
</div> </div>
</div> </div>
</div> </div>
</section> </section>
<section class="earth-settings-section"> <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" id="boundary-precision-status">国界精度</span>
<span class="earth-settings-item-subtitle" id="boundary-precision-detail">正在读取高清国界状态...</span>
</div>
<div id="boundary-precision-progress-wrap" class="earth-boundary-progress" hidden>
<div class="earth-boundary-progress__track">
<div id="boundary-precision-progress-bar" class="earth-boundary-progress__bar"></div>
</div>
<span id="boundary-precision-progress-value" class="earth-boundary-progress__value">0%</span>
</div>
<div class="earth-settings-boundary-action-row">
<div class="earth-settings-segmented earth-settings-segmented--boundary" role="group" aria-label="选择国界精度">
<button id="boundary-precision-disable" type="button" class="earth-settings-segmented-btn is-active" aria-pressed="true">低精</button>
<button id="boundary-precision-build" type="button" class="earth-settings-segmented-btn" aria-pressed="false">高精</button>
</div>
<button id="boundary-precision-rebuild" type="button" class="earth-settings-reload-action" aria-label="重新获取并构建高清国界" title="重新获取并构建" hidden disabled>
<span class="material-symbols-rounded">refresh</span>
</button>
</div>
</div>
</div>
</section>
<section class="earth-settings-section">
<div class="earth-settings-section-title">系统</div> <div class="earth-settings-section-title">系统</div>
<div class="earth-settings-list"> <div class="earth-settings-list">
<a <a

View File

@@ -1,57 +1,110 @@
const DEFAULT_BRAND_LANGUAGE = "zh"; const DEFAULT_BRAND_LANGUAGE = "zh";
const EARTH_BRAND_API = "/api/v1/earth/brand";
const BRANDS = { const BRANDS = {
zh: { zh: {
ariaLabel: "智能星球计划品牌标识", ariaLabel: "智能星球计划品牌标识",
titleAlt: "智能星球计划", titleAlt: "智能星球计划",
titleSrc: "assets/brand/title-zh.png", logoSrc: "/earth/assets/brand/earth-logo.png",
titleSrc: "/earth/assets/brand/title-zh.png",
titleText: "智能星球计划",
subtitle: "现实层宇宙全息感知系统", subtitle: "现实层宇宙全息感知系统",
description: "卫星 · 海底光缆 · 算力基础设施", description: "卫星 · 海底光缆 · 算力基础设施",
}, },
en: { en: {
ariaLabel: "Intelligent Planet Program brand banner", ariaLabel: "Intelligent Planet Program brand banner",
titleAlt: "Intelligent Planet Program", titleAlt: "Intelligent Planet Program",
titleSrc: "assets/brand/title-en.png", logoSrc: "/earth/assets/brand/earth-logo.png",
titleSrc: "/earth/assets/brand/title-en.png",
titleText: "Intelligent Planet Program",
subtitle: "Physical-Universe Holography", subtitle: "Physical-Universe Holography",
description: "Satellites · Cables · Compute Infra", description: "Satellites · Cables · Compute Infra",
}, },
}; };
function getBrandConfig(variant = DEFAULT_BRAND_LANGUAGE) { export function getDefaultBrandConfig(variant = DEFAULT_BRAND_LANGUAGE) {
return BRANDS[variant] ?? BRANDS[DEFAULT_BRAND_LANGUAGE]; return BRANDS[variant] ?? BRANDS[DEFAULT_BRAND_LANGUAGE];
} }
export function renderBrand(variant = DEFAULT_BRAND_LANGUAGE) { function escapeHtml(value = "") {
const config = getBrandConfig(variant); return String(value)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function normalizeBrandConfig(config = {}, variant = DEFAULT_BRAND_LANGUAGE) {
const defaults = getDefaultBrandConfig(variant);
const normalized = {
...defaults,
...config,
ariaLabel: config.aria_label ?? config.ariaLabel ?? defaults.ariaLabel,
titleAlt: config.title_alt ?? config.titleAlt ?? 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,
};
if (!normalized.titleText) normalized.titleText = defaults.titleText;
if (!normalized.ariaLabel) normalized.ariaLabel = normalized.titleText;
if (!normalized.titleAlt) normalized.titleAlt = normalized.titleText;
return normalized;
}
export function renderBrand(configOrVariant = DEFAULT_BRAND_LANGUAGE) {
const variant =
typeof configOrVariant === "string"
? configOrVariant
: configOrVariant.variant ?? DEFAULT_BRAND_LANGUAGE;
const config =
typeof configOrVariant === "string"
? normalizeBrandConfig({}, variant)
: normalizeBrandConfig(configOrVariant, variant);
const titleMarkup = config.titleSrc
? `
<img
class="earth-brand__title"
src="${escapeHtml(config.titleSrc)}"
alt="${escapeHtml(config.titleAlt)}"
fetchpriority="high"
loading="eager"
>
`
: `<div class="earth-brand__title-text">${escapeHtml(config.titleText)}</div>`;
return ` return `
<div class="earth-brand earth-brand--${variant}" aria-label="${config.ariaLabel}"> <div class="earth-brand earth-brand--${escapeHtml(variant)}" aria-label="${escapeHtml(config.ariaLabel)}">
<img <img
class="earth-brand__logo" class="earth-brand__logo"
src="assets/brand/earth-logo.png" src="${escapeHtml(config.logoSrc)}"
alt="" alt=""
aria-hidden="true" aria-hidden="true"
fetchpriority="high" fetchpriority="high"
loading="eager" loading="eager"
> >
<div class="earth-brand__copy"> <div class="earth-brand__copy">
<img ${titleMarkup}
class="earth-brand__title"
src="${config.titleSrc}"
alt="${config.titleAlt}"
fetchpriority="high"
loading="eager"
>
<div class="earth-brand__meta"> <div class="earth-brand__meta">
<span class="earth-brand__subtitle">${config.subtitle}</span> <span class="earth-brand__subtitle">${escapeHtml(config.subtitle)}</span>
<span class="earth-brand__description">${config.description}</span> <span class="earth-brand__description">${escapeHtml(config.description)}</span>
</div> </div>
</div> </div>
</div> </div>
`.trim(); `.trim();
} }
export function mountBrand(target, variant = DEFAULT_BRAND_LANGUAGE) { export function mountBrand(target, configOrVariant = DEFAULT_BRAND_LANGUAGE) {
if (!target) return; if (!target) return;
target.innerHTML = renderBrand(variant); target.innerHTML = renderBrand(configOrVariant);
}
export async function fetchEarthBrandConfig() {
const response = await fetch(EARTH_BRAND_API, { cache: "no-store" });
if (!response.ok) {
throw new Error(`Earth brand config request failed: ${response.status}`);
}
const payload = await response.json();
return payload?.brand ?? getDefaultBrandConfig();
} }

View File

@@ -153,7 +153,7 @@ const SETTINGS_SHEET_MAX_SCALE_X = 0.22;
const SETTINGS_SHEET_MAX_SCALE_Y = 0.18; const SETTINGS_SHEET_MAX_SCALE_Y = 0.18;
const EARTH_SETTINGS_STORAGE_KEY = "planet.earth.settings.v2"; const EARTH_SETTINGS_STORAGE_KEY = "planet.earth.settings.v2";
const LEGACY_EARTH_SETTINGS_STORAGE_KEY = "planet.earth.settings.v1"; const LEGACY_EARTH_SETTINGS_STORAGE_KEY = "planet.earth.settings.v1";
const EARTH_SETTINGS_VERSION = 11; const EARTH_SETTINGS_VERSION = 13;
const GRID_LINES_DEFAULT_VERSION = 3; const GRID_LINES_DEFAULT_VERSION = 3;
const SATELLITE_DISPLAY_DEFAULT_VERSION = 4; const SATELLITE_DISPLAY_DEFAULT_VERSION = 4;
const MEDIA_PANEL_DEFAULT_VERSION = 5; const MEDIA_PANEL_DEFAULT_VERSION = 5;
@@ -163,8 +163,14 @@ const MOTION_DEBUG_SKELETON_ONLY_DEFAULT_VERSION = 8;
const MEDIA_PANEL_ACTIVE_TAB_DEFAULT_VERSION = 9; const MEDIA_PANEL_ACTIVE_TAB_DEFAULT_VERSION = 9;
const VISUAL_PREFERENCES_DEFAULT_VERSION = 10; const VISUAL_PREFERENCES_DEFAULT_VERSION = 10;
const SURFACE_HOVER_INFO_DEFAULT_VERSION = 11; const SURFACE_HOVER_INFO_DEFAULT_VERSION = 11;
const KEYBOARD_SHORTCUTS_DEFAULT_VERSION = 13;
const DEFAULT_EARTH_ZOOM_STEP = 0.01; const DEFAULT_EARTH_ZOOM_STEP = 0.01;
const ZOOM_STATUS_UPDATE_INTERVAL_MS = 90; const ZOOM_STATUS_UPDATE_INTERVAL_MS = 90;
const KEYBOARD_ROTATION_ACCELERATION = 3.2;
const KEYBOARD_ROTATION_MAX_SPEED = 2.8;
const KEYBOARD_ROTATION_FRICTION = 5.4;
const KEYBOARD_ROTATION_STOP_SPEED = 0.012;
const KEYBOARD_ZOOM_STEP = 0.1;
const TARGET_SWITCH_ZOOM_IN_PHASE = 0.28; const TARGET_SWITCH_ZOOM_IN_PHASE = 0.28;
const TARGET_SWITCH_ROTATE_PHASE = 0.5; const TARGET_SWITCH_ROTATE_PHASE = 0.5;
let settingsModalTimer = null; let settingsModalTimer = null;
@@ -186,6 +192,13 @@ let mobileDrawerOpen = false;
let mobileDrawerCard = "layers"; let mobileDrawerCard = "layers";
let mobileDrawerHintTimer = null; let mobileDrawerHintTimer = null;
let toolbarHubController = null; let toolbarHubController = null;
let keyboardShortcuts = {};
let capturingShortcutActionId = null;
let keyboardRotationFrameId = null;
let keyboardRotationLastFrameAt = 0;
let keyboardRotationOriginalAutoRotate = null;
const activeKeyboardRotationActions = new Set();
const keyboardRotationVelocity = { x: 0, y: 0 };
const ALLOWED_CRUISE_MODULES = new Set(Object.values(CRUISE_MODULES)); const ALLOWED_CRUISE_MODULES = new Set(Object.values(CRUISE_MODULES));
const ALLOWED_SATELLITE_DISPLAY_STYLES = new Set( const ALLOWED_SATELLITE_DISPLAY_STYLES = new Set(
Object.values(SATELLITE_DISPLAY_STYLES), Object.values(SATELLITE_DISPLAY_STYLES),
@@ -202,6 +215,36 @@ const CRUISE_MODULE_LABELS = {
[CRUISE_MODULES.SATELLITES]: "卫星", [CRUISE_MODULES.SATELLITES]: "卫星",
}; };
const KEYBOARD_SHORTCUT_DEFINITIONS = [
{ id: "rotateUp", label: "向上旋转", category: "视角控制", defaultBinding: "W", aliases: ["ArrowUp"] },
{ id: "rotateLeft", label: "向左旋转", category: "视角控制", defaultBinding: "A", aliases: ["ArrowLeft"] },
{ id: "rotateDown", label: "向下旋转", category: "视角控制", defaultBinding: "S", aliases: ["ArrowDown"] },
{ id: "rotateRight", label: "向右旋转", category: "视角控制", defaultBinding: "D", aliases: ["ArrowRight"] },
{ id: "zoomIn", label: "放大", category: "视角控制", defaultBinding: "Plus", aliases: ["="] },
{ id: "zoomOut", label: "缩小", category: "视角控制", defaultBinding: "-", aliases: ["_"] },
{ id: "closeFocus", label: "关闭当前焦点菜单", category: "工具", defaultBinding: "Escape" },
{ id: "openSearch", label: "打开搜索", category: "工具", defaultBinding: "F" },
{ id: "resetView", label: "重置视角", category: "工具", defaultBinding: "R" },
{ id: "toggleLayerPanel", label: "打开/关闭图层面板", category: "工具", defaultBinding: "L" },
{ id: "toggleLayoutExpanded", label: "最大化布局", category: "工具", defaultBinding: "M" },
{ id: "toggleMediaPanel", label: "打开/关闭新闻直播", category: "工具", defaultBinding: "Ctrl+M" },
{ id: "toggleAutoRotate", label: "暂停/恢复旋转", category: "工具", defaultBinding: "Space" },
{ id: "toggleLayer:cables", label: "切换海缆", category: "图层", defaultBinding: "Ctrl+1" },
{ id: "toggleLayer:satellites", label: "切换卫星", category: "图层", defaultBinding: "Ctrl+2" },
{ id: "toggleLayer:computeCenters", label: "切换算力中心", category: "图层", defaultBinding: "Ctrl+3" },
{ id: "toggleLayer:vessels", label: "切换船只", category: "图层", defaultBinding: "Ctrl+4" },
{ id: "toggleLayer:bgp", label: "切换 BGP观测", category: "图层", defaultBinding: "Ctrl+5" },
{ id: "toggleLayer:terrain", label: "切换地形", category: "图层", defaultBinding: "Ctrl+6" },
{ id: "toggleLayer:earthHighResTexture", label: "切换高清材质", category: "图层", defaultBinding: "Ctrl+7" },
{ id: "toggleLayer:atmosphereClouds", label: "切换大气云图", category: "图层", defaultBinding: "Ctrl+8" },
{ id: "toggleLayer:countryBoundaries", label: "切换国界线", category: "图层", defaultBinding: "Ctrl+9" },
{ id: "toggleLayer:gridLines", label: "切换经纬线", category: "图层", defaultBinding: "Ctrl+0" },
];
const KEYBOARD_SHORTCUT_DEFINITION_BY_ID = new Map(
KEYBOARD_SHORTCUT_DEFINITIONS.map((definition) => [definition.id, definition]),
);
function normalizeMediaPanelActiveTab(tab) { function normalizeMediaPanelActiveTab(tab) {
return tab === "news" ? "news" : "live"; return tab === "news" ? "news" : "live";
} }
@@ -212,6 +255,431 @@ function normalizeSurfaceHoverInfoMode(mode) {
: DEFAULT_SURFACE_HOVER_INFO_MODE; : DEFAULT_SURFACE_HOVER_INFO_MODE;
} }
function getDefaultKeyboardShortcuts() {
return Object.fromEntries(
KEYBOARD_SHORTCUT_DEFINITIONS.map((definition) => [
definition.id,
{
binding: definition.defaultBinding,
enabled: true,
},
]),
);
}
function normalizeShortcutBinding(binding) {
if (typeof binding !== "string") return "";
if (binding.trim() === "+") return "+";
const parts = binding
.split("+")
.map((part) => part.trim())
.filter(Boolean);
if (parts.length === 0) return "";
const key = parts[parts.length - 1];
const modifiers = new Set(
parts.slice(0, -1).map((part) => {
const normalized = part.toLowerCase();
if (normalized === "control") return "Ctrl";
if (normalized === "cmd" || normalized === "command") return "Meta";
return normalized.charAt(0).toUpperCase() + normalized.slice(1);
}),
);
const orderedModifiers = ["Ctrl", "Alt", "Shift", "Meta"].filter((modifier) =>
modifiers.has(modifier),
);
return [...orderedModifiers, normalizeShortcutKeyName(key)].join("+");
}
function normalizeShortcutKeyName(key) {
if (!key) return "";
if (key === " ") return "Space";
if (key === "+") return "Plus";
if (key === "_") return "-";
if (key.length === 1) return key.toUpperCase();
const lowered = key.toLowerCase();
if (lowered === "esc") return "Escape";
if (lowered === "spacebar") return "Space";
if (lowered.startsWith("arrow")) {
return `Arrow${lowered.slice(5, 6).toUpperCase()}${lowered.slice(6)}`;
}
return key.charAt(0).toUpperCase() + key.slice(1);
}
function normalizeKeyboardShortcuts(rawShortcuts = {}) {
const defaults = getDefaultKeyboardShortcuts();
const source =
rawShortcuts && typeof rawShortcuts === "object"
? rawShortcuts
: {};
return Object.fromEntries(
KEYBOARD_SHORTCUT_DEFINITIONS.map((definition) => {
const rawShortcut = source[definition.id] || {};
const binding = normalizeShortcutBinding(rawShortcut.binding);
return [
definition.id,
{
binding: binding || defaults[definition.id].binding,
enabled:
typeof rawShortcut.enabled === "boolean"
? rawShortcut.enabled
: defaults[definition.id].enabled,
},
];
}),
);
}
function getShortcutDisplayLabel(binding) {
const normalized = normalizeShortcutBinding(binding);
if (!normalized) return "未设置";
return normalized
.replaceAll("ArrowUp", "↑")
.replaceAll("ArrowDown", "↓")
.replaceAll("ArrowLeft", "←")
.replaceAll("ArrowRight", "→")
.replaceAll("Space", "空格")
.replaceAll("Escape", "Esc")
.replaceAll("Plus", "+")
.replaceAll("Ctrl", "Ctrl")
.replaceAll("Meta", "⌘");
}
function getShortcutAliasesLabel(definition) {
const aliases = Array.isArray(definition?.aliases) ? definition.aliases : [];
if (aliases.length === 0) return "";
return `备用:${aliases.map(getShortcutDisplayLabel).join(" / ")}`;
}
function getShortcutForAction(actionId) {
return keyboardShortcuts[actionId] || getDefaultKeyboardShortcuts()[actionId] || {
binding: "",
enabled: false,
};
}
function getShortcutOwnerByBinding(binding, { excludeActionId = null } = {}) {
const normalized = normalizeShortcutBinding(binding);
if (!normalized) return null;
for (const definition of KEYBOARD_SHORTCUT_DEFINITIONS) {
if (definition.id === excludeActionId) continue;
const shortcut = getShortcutForAction(definition.id);
if (!shortcut.enabled) continue;
const bindings = [
shortcut.binding,
...(Array.isArray(definition.aliases) ? definition.aliases : []),
].map(normalizeShortcutBinding);
if (bindings.includes(normalized)) {
return definition;
}
}
return null;
}
function getShortcutChordFromEvent(event) {
if (!(event instanceof KeyboardEvent)) return "";
let key = event.key;
if (!key || key === "Unidentified" || key === "Dead") return "";
if (key === " ") key = "Space";
const ignoreShift = key === "+" || key === "_";
if (key.length === 1) key = key.toUpperCase();
const modifiers = [];
if (event.ctrlKey) modifiers.push("Ctrl");
if (event.altKey) modifiers.push("Alt");
if (event.shiftKey && key.length !== 1 && !ignoreShift) modifiers.push("Shift");
if (event.metaKey) modifiers.push("Meta");
return [...modifiers, normalizeShortcutKeyName(key)].join("+");
}
function isEditableShortcutTarget(target) {
if (!(target instanceof Element)) return false;
if (target.closest("[data-shortcut-capture]")) return false;
return Boolean(
target.closest("input, textarea, select, [contenteditable='true'], [contenteditable='']"),
);
}
function isShortcutSuppressedBySettingsUi(target) {
if (!(target instanceof Element)) return false;
return Boolean(target.closest("#settings-modal, .earth-mobile-page--settings"));
}
function getShortcutDefinitionForChord(chord) {
const normalizedChord = normalizeShortcutBinding(chord);
if (!normalizedChord) return null;
for (const definition of KEYBOARD_SHORTCUT_DEFINITIONS) {
const shortcut = getShortcutForAction(definition.id);
if (!shortcut.enabled) continue;
const bindings = [
shortcut.binding,
...(Array.isArray(definition.aliases) ? definition.aliases : []),
].map(normalizeShortcutBinding);
if (bindings.includes(normalizedChord)) {
return definition;
}
}
return null;
}
function closeCurrentFocusOverlay() {
if (capturingShortcutActionId) {
capturingShortcutActionId = null;
syncShortcutCaptureUi();
return true;
}
if (isSearchPanelOpen()) {
closeSearchPanel();
return true;
}
if (isSettingsModalOpen()) {
closeSettingsModal();
return true;
}
if (isMobileLayout() && mobileDrawerOpen) {
setMobileDrawerState({ open: false });
return true;
}
if (isFloatingMenuVisible()) {
closeFloatingMenus();
return true;
}
if (toolbarHubController?.isOpen?.()) {
toolbarHubController.close();
return true;
}
clearLockedObjectAndInfo();
return true;
}
function isKeyboardRotationAction(actionId) {
return (
actionId === "rotateUp" ||
actionId === "rotateDown" ||
actionId === "rotateLeft" ||
actionId === "rotateRight"
);
}
function getKeyboardRotationDirection() {
const direction = { x: 0, y: 0 };
if (activeKeyboardRotationActions.has("rotateUp")) direction.x -= 1;
if (activeKeyboardRotationActions.has("rotateDown")) direction.x += 1;
if (activeKeyboardRotationActions.has("rotateLeft")) direction.y -= 1;
if (activeKeyboardRotationActions.has("rotateRight")) direction.y += 1;
const magnitude = Math.hypot(direction.x, direction.y);
if (magnitude > 1) {
direction.x /= magnitude;
direction.y /= magnitude;
}
return direction;
}
function clampKeyboardRotationVelocity() {
const speed = Math.hypot(keyboardRotationVelocity.x, keyboardRotationVelocity.y);
if (speed <= KEYBOARD_ROTATION_MAX_SPEED) return;
const scale = KEYBOARD_ROTATION_MAX_SPEED / speed;
keyboardRotationVelocity.x *= scale;
keyboardRotationVelocity.y *= scale;
}
function applyKeyboardRotationFrame(timestamp) {
keyboardRotationFrameId = null;
if (!earthObj) {
stopKeyboardRotationControl({ restoreAutoRotate: true, clearVelocity: true });
return;
}
const deltaSeconds = keyboardRotationLastFrameAt
? Math.min((timestamp - keyboardRotationLastFrameAt) / 1000, 0.05)
: 0.016;
keyboardRotationLastFrameAt = timestamp;
const direction = getKeyboardRotationDirection();
const hasInput = direction.x !== 0 || direction.y !== 0;
if (hasInput) {
keyboardRotationVelocity.x += direction.x * KEYBOARD_ROTATION_ACCELERATION * deltaSeconds;
keyboardRotationVelocity.y += direction.y * KEYBOARD_ROTATION_ACCELERATION * deltaSeconds;
clampKeyboardRotationVelocity();
} else {
const decay = Math.exp(-KEYBOARD_ROTATION_FRICTION * deltaSeconds);
keyboardRotationVelocity.x *= decay;
keyboardRotationVelocity.y *= decay;
}
const speed = Math.hypot(keyboardRotationVelocity.x, keyboardRotationVelocity.y);
if (!hasInput && speed < KEYBOARD_ROTATION_STOP_SPEED) {
keyboardRotationVelocity.x = 0;
keyboardRotationVelocity.y = 0;
restoreKeyboardRotationAutoRotate();
keyboardRotationLastFrameAt = 0;
return;
}
earthObj.rotation.x = THREE.MathUtils.clamp(
earthObj.rotation.x + keyboardRotationVelocity.x * deltaSeconds,
-Math.PI / 2,
Math.PI / 2,
);
earthObj.rotation.y += keyboardRotationVelocity.y * deltaSeconds;
keyboardRotationFrameId = window.requestAnimationFrame(applyKeyboardRotationFrame);
}
function ensureKeyboardRotationFrame() {
if (keyboardRotationFrameId !== null) return;
keyboardRotationFrameId = window.requestAnimationFrame(applyKeyboardRotationFrame);
}
function restoreKeyboardRotationAutoRotate() {
if (keyboardRotationOriginalAutoRotate === true && !autoRotate) {
setAutoRotate(true);
}
keyboardRotationOriginalAutoRotate = null;
}
function startKeyboardRotationControl(actionId) {
if (!earthObj || !isKeyboardRotationAction(actionId)) return;
if (keyboardRotationOriginalAutoRotate === null) {
keyboardRotationOriginalAutoRotate = autoRotate;
}
if (autoRotate) {
setAutoRotate(false);
}
activeKeyboardRotationActions.add(actionId);
clearLockedObject();
ensureKeyboardRotationFrame();
}
function stopKeyboardRotationControl({ actionId = null, restoreAutoRotate = false, clearVelocity = false } = {}) {
if (actionId) {
activeKeyboardRotationActions.delete(actionId);
} else {
activeKeyboardRotationActions.clear();
}
if (clearVelocity) {
keyboardRotationVelocity.x = 0;
keyboardRotationVelocity.y = 0;
}
if (restoreAutoRotate && activeKeyboardRotationActions.size === 0) {
restoreKeyboardRotationAutoRotate();
}
if (clearVelocity && keyboardRotationFrameId !== null) {
window.cancelAnimationFrame(keyboardRotationFrameId);
keyboardRotationFrameId = null;
keyboardRotationLastFrameAt = 0;
} else if (activeKeyboardRotationActions.size === 0 && Math.hypot(keyboardRotationVelocity.x, keyboardRotationVelocity.y) > 0) {
ensureKeyboardRotationFrame();
}
}
function applyKeyboardZoom(direction) {
setZoomLevel(zoomLevel + direction * KEYBOARD_ZOOM_STEP, activeCamera);
showZoomStatusCapsule({ force: true });
}
function openSearchFromShortcut() {
if (isMobileLayout()) {
setMobileDrawerState({ open: true, card: "search" });
return;
}
closeTransientMobileOverlays({ except: "search" });
openSearchPanel();
}
function toggleLayerPanelFromShortcut() {
if (isMobileLayout()) {
const nextOpen = !(mobileDrawerOpen && mobileDrawerCard === "layers");
setMobileDrawerState({ open: nextOpen, card: "layers" });
return;
}
const panel = document.getElementById("layer-toggles");
const currentlyVisible = !panel?.classList.contains("hud-panel-hidden");
setHudPanelVisibility("layer-toggles", !currentlyVisible);
}
function toggleMediaPanelFromShortcut() {
if (isMobileLayout()) {
const nextOpen = !(mobileDrawerOpen && (mobileDrawerCard === "tv" || mobileDrawerCard === "news"));
setMobileDrawerState({ open: nextOpen, card: "tv" });
return;
}
const nextVisible = !isTVPanelVisible();
setTVPanelVisible(nextVisible);
}
function toggleLayoutExpandedFromShortcut() {
const container = document.getElementById("container");
if (!(container instanceof HTMLElement)) return;
const expanded = toggleLayoutExpanded(container);
showStatusMessage(expanded ? "布局已最大化" : "布局已恢复", "info");
}
async function toggleLayerFromShortcut(layerId) {
const definition = getLayerDefinition(layerId);
if (!definition) return;
const button = getLayerButton(layerId);
if (button?.disabled || button?.classList.contains("is-disabled")) {
showStatusMessage(`${definition.label}当前不可用`, "warning");
return;
}
await definition.setVisible(!definition.getVisible());
showStatusMessage(`${definition.label}${definition.getVisible() ? "已显示" : "已隐藏"}`, "info");
}
function executeKeyboardShortcut(actionId) {
if (actionId === "closeFocus") {
closeCurrentFocusOverlay();
return;
}
if (isKeyboardRotationAction(actionId)) {
startKeyboardRotationControl(actionId);
return;
}
if (actionId === "zoomIn") {
applyKeyboardZoom(1);
return;
}
if (actionId === "zoomOut") {
applyKeyboardZoom(-1);
return;
}
if (actionId === "openSearch") {
openSearchFromShortcut();
return;
}
if (actionId === "resetView") {
resetView(activeCamera);
return;
}
if (actionId === "toggleLayerPanel") {
toggleLayerPanelFromShortcut();
return;
}
if (actionId === "toggleLayoutExpanded") {
toggleLayoutExpandedFromShortcut();
return;
}
if (actionId === "toggleMediaPanel") {
toggleMediaPanelFromShortcut();
return;
}
if (actionId === "toggleAutoRotate") {
const isRotating = toggleAutoRotate();
showStatusMessage(isRotating ? "旋转已恢复" : "旋转已暂停", "info");
return;
}
if (actionId.startsWith("toggleLayer:")) {
void toggleLayerFromShortcut(actionId.slice("toggleLayer:".length));
}
}
function detectLayoutMode() { function detectLayoutMode() {
const width = window.innerWidth; const width = window.innerWidth;
const height = window.innerHeight; const height = window.innerHeight;
@@ -819,6 +1287,7 @@ function getCurrentSharedSettingsSnapshot() {
satelliteRealAltitudeEnabled: getSatelliteRealAltitudeEnabled(), satelliteRealAltitudeEnabled: getSatelliteRealAltitudeEnabled(),
interactableCompactDotsEnabled: getInteractableCompactDotsEnabled(), interactableCompactDotsEnabled: getInteractableCompactDotsEnabled(),
surfaceHoverInfoMode: getSurfaceHoverInfoMode(), surfaceHoverInfoMode: getSurfaceHoverInfoMode(),
keyboardShortcuts: normalizeKeyboardShortcuts(keyboardShortcuts),
}; };
} }
@@ -879,6 +1348,7 @@ function cloneEarthSettings(settings) {
surfaceHoverInfoMode: normalizeSurfaceHoverInfoMode( surfaceHoverInfoMode: normalizeSurfaceHoverInfoMode(
settings.shared.surfaceHoverInfoMode, settings.shared.surfaceHoverInfoMode,
), ),
keyboardShortcuts: normalizeKeyboardShortcuts(settings.shared.keyboardShortcuts),
layerVisibility: { ...(settings.shared.layerVisibility || {}) }, layerVisibility: { ...(settings.shared.layerVisibility || {}) },
}, },
views: { views: {
@@ -1017,6 +1487,10 @@ function normalizeEarthSettings(rawSettings, defaults) {
(rawSettings?.version || 0) >= SURFACE_HOVER_INFO_DEFAULT_VERSION (rawSettings?.version || 0) >= SURFACE_HOVER_INFO_DEFAULT_VERSION
? normalizeSurfaceHoverInfoMode(sharedSettings?.surfaceHoverInfoMode) ? normalizeSurfaceHoverInfoMode(sharedSettings?.surfaceHoverInfoMode)
: defaults.shared.surfaceHoverInfoMode; : defaults.shared.surfaceHoverInfoMode;
const nextKeyboardShortcuts =
(rawSettings?.version || 0) >= KEYBOARD_SHORTCUTS_DEFAULT_VERSION
? normalizeKeyboardShortcuts(sharedSettings?.keyboardShortcuts)
: defaults.shared.keyboardShortcuts;
const nextTrailsEnabled = typeof sharedSettings?.trailsEnabled === "boolean" const nextTrailsEnabled = typeof sharedSettings?.trailsEnabled === "boolean"
? sharedSettings.trailsEnabled ? sharedSettings.trailsEnabled
: typeof inputLayerVisibility.trails === "boolean" : typeof inputLayerVisibility.trails === "boolean"
@@ -1046,6 +1520,7 @@ function normalizeEarthSettings(rawSettings, defaults) {
satelliteRealAltitudeEnabled: nextSatelliteRealAltitudeEnabled, satelliteRealAltitudeEnabled: nextSatelliteRealAltitudeEnabled,
interactableCompactDotsEnabled: nextInteractableCompactDotsEnabled, interactableCompactDotsEnabled: nextInteractableCompactDotsEnabled,
surfaceHoverInfoMode: nextSurfaceHoverInfoMode, surfaceHoverInfoMode: nextSurfaceHoverInfoMode,
keyboardShortcuts: nextKeyboardShortcuts,
}, },
views: { views: {
desktop: { desktop: {
@@ -1550,6 +2025,8 @@ async function applyEarthSettings(settings, { applyLayers = true } = {}) {
suppressStatus: true, suppressStatus: true,
}); });
setActiveTVTab(settings.shared.mediaPanelActiveTab); setActiveTVTab(settings.shared.mediaPanelActiveTab);
keyboardShortcuts = normalizeKeyboardShortcuts(settings.shared.keyboardShortcuts);
renderShortcutSettings();
if (!applyLayers) { if (!applyLayers) {
const layerVisibility = { ...(settings.shared.layerVisibility || {}) }; const layerVisibility = { ...(settings.shared.layerVisibility || {}) };
@@ -2659,12 +3136,14 @@ function applyCurrentViewportPanelVisibility({ persist = false } = {}) {
} }
function syncSettingsToggle(panelId, visible) { function syncSettingsToggle(panelId, visible) {
const input = document.querySelector( const inputs = document.querySelectorAll(
`[data-settings-panel="${panelId}"]`, `[data-settings-panel="${panelId}"]`,
); );
if (input instanceof HTMLInputElement) { inputs.forEach((input) => {
input.checked = visible; if (input instanceof HTMLInputElement) {
} input.checked = visible;
}
});
} }
function syncAllHudPanelToggles() { function syncAllHudPanelToggles() {
@@ -2717,14 +3196,14 @@ export function setDayNightInteractable(enabled) {
function getBoundaryPrecisionEls() { function getBoundaryPrecisionEls() {
return { return {
status: document.getElementById("boundary-precision-status"), statuses: Array.from(document.querySelectorAll("[data-boundary-precision-status]")),
detail: document.getElementById("boundary-precision-detail"), details: Array.from(document.querySelectorAll("[data-boundary-precision-detail]")),
progressWrap: document.getElementById("boundary-precision-progress-wrap"), progressWraps: Array.from(document.querySelectorAll("[data-boundary-precision-progress-wrap]")),
progressBar: document.getElementById("boundary-precision-progress-bar"), progressBars: Array.from(document.querySelectorAll("[data-boundary-precision-progress-bar]")),
progressValue: document.getElementById("boundary-precision-progress-value"), progressValues: Array.from(document.querySelectorAll("[data-boundary-precision-progress-value]")),
buildButton: document.getElementById("boundary-precision-build"), buildButtons: Array.from(document.querySelectorAll("[data-boundary-precision-build]")),
rebuildButton: document.getElementById("boundary-precision-rebuild"), rebuildButtons: Array.from(document.querySelectorAll("[data-boundary-precision-rebuild]")),
disableButton: document.getElementById("boundary-precision-disable"), disableButtons: Array.from(document.querySelectorAll("[data-boundary-precision-disable]")),
}; };
} }
@@ -2764,45 +3243,48 @@ function renderBoundaryPrecisionStatus(payload = {}) {
? `高清国界更新源未配置完整:${job.message || job.code}` ? `高清国界更新源未配置完整:${job.message || job.code}`
: `高清国界下载失败:${job.message || job.code || "请检查更新源"}`; : `高清国界下载失败:${job.message || job.code || "请检查更新源"}`;
if (els.status) { els.statuses.forEach((status) => {
els.status.textContent = "国界精度"; status.textContent = "国界精度";
} });
if (els.detail) { els.details.forEach((detail) => {
els.detail.textContent = running detail.textContent = running
? (job.message || "正在准备高清国界") ? (job.message || "正在准备高清国界")
: failed : failed
? failureMessage ? failureMessage
: highReady : highReady
? (enabled ? "当前使用高精国界;可重新获取并构建。" : "高精国界已就绪,切到高精会立即应用。") ? (enabled ? "当前使用高精国界;可重新获取并构建。" : "高精国界已就绪,切到高精会立即应用。")
: "当前使用低精国界;切到高精会下载并构建。"; : "当前使用低精国界;切到高精会下载并构建。";
} });
if (els.progressWrap) { els.progressWraps.forEach((progressWrap) => {
els.progressWrap.hidden = !running; progressWrap.hidden = !running;
} });
if (els.progressBar) { els.progressBars.forEach((progressBar) => {
els.progressBar.style.width = `${running || job.status === "succeeded" ? progress || 100 : progress}%`; progressBar.style.width = `${running || job.status === "succeeded" ? progress || 100 : progress}%`;
} });
if (els.progressValue) { els.progressValues.forEach((progressValue) => {
els.progressValue.textContent = job.status === "failed" progressValue.textContent = job.status === "failed"
? `失败:${job.message || job.code || "构建失败"}` ? `失败:${job.message || job.code || "构建失败"}`
: `${running || job.status === "succeeded" ? progress || 100 : progress}%`; : `${running || job.status === "succeeded" ? progress || 100 : progress}%`;
} });
if (els.buildButton instanceof HTMLButtonElement) { els.buildButtons.forEach((buildButton) => {
els.buildButton.disabled = running; if (!(buildButton instanceof HTMLButtonElement)) return;
els.buildButton.textContent = "高精"; buildButton.disabled = running;
els.buildButton.classList.toggle("is-active", enabled || running); buildButton.textContent = "高精";
els.buildButton.setAttribute("aria-pressed", enabled || running ? "true" : "false"); buildButton.classList.toggle("is-active", enabled || running);
} buildButton.setAttribute("aria-pressed", enabled || running ? "true" : "false");
if (els.rebuildButton instanceof HTMLButtonElement) { });
els.rebuildButtons.forEach((rebuildButton) => {
if (!(rebuildButton instanceof HTMLButtonElement)) return;
const shouldShowRebuild = highReady && enabled && !running; const shouldShowRebuild = highReady && enabled && !running;
els.rebuildButton.hidden = !shouldShowRebuild; rebuildButton.hidden = !shouldShowRebuild;
els.rebuildButton.disabled = !shouldShowRebuild; rebuildButton.disabled = !shouldShowRebuild;
} });
if (els.disableButton instanceof HTMLButtonElement) { els.disableButtons.forEach((disableButton) => {
els.disableButton.disabled = running; if (!(disableButton instanceof HTMLButtonElement)) return;
els.disableButton.classList.toggle("is-active", !enabled && !running); disableButton.disabled = running;
els.disableButton.setAttribute("aria-pressed", !enabled && !running ? "true" : "false"); disableButton.classList.toggle("is-active", !enabled && !running);
} disableButton.setAttribute("aria-pressed", !enabled && !running ? "true" : "false");
});
syncSegmentedControlSliders(); syncSegmentedControlSliders();
} }
@@ -2855,7 +3337,7 @@ async function startBoundaryPrecisionBuild() {
async function setupBoundaryPrecisionControls() { async function setupBoundaryPrecisionControls() {
const els = getBoundaryPrecisionEls(); const els = getBoundaryPrecisionEls();
if (!els.buildButton && !els.disableButton) return; if (els.buildButtons.length === 0 && els.disableButtons.length === 0) return;
try { try {
const payload = await refreshBoundaryPrecisionStatus(); const payload = await refreshBoundaryPrecisionStatus();
const jobStatus = payload.current_job?.status; const jobStatus = payload.current_job?.status;
@@ -2867,8 +3349,9 @@ async function setupBoundaryPrecisionControls() {
showStatusMessage(`高清国界状态读取失败:${error.message || error}`, "warning"); showStatusMessage(`高清国界状态读取失败:${error.message || error}`, "warning");
} }
if (els.buildButton instanceof HTMLButtonElement) { els.buildButtons.forEach((buildButton) => {
bindListener(els.buildButton, "click", async () => { if (!(buildButton instanceof HTMLButtonElement)) return;
bindListener(buildButton, "click", async () => {
try { try {
const statusPayload = await refreshBoundaryPrecisionStatus(); const statusPayload = await refreshBoundaryPrecisionStatus();
const job = statusPayload.job || statusPayload.current_job || {}; const job = statusPayload.job || statusPayload.current_job || {};
@@ -2887,9 +3370,10 @@ async function setupBoundaryPrecisionControls() {
showStatusMessage(`高精国界切换失败:${error.message || error}`, "warning"); showStatusMessage(`高精国界切换失败:${error.message || error}`, "warning");
} }
}); });
} });
if (els.rebuildButton instanceof HTMLButtonElement) { els.rebuildButtons.forEach((rebuildButton) => {
bindListener(els.rebuildButton, "click", async () => { if (!(rebuildButton instanceof HTMLButtonElement)) return;
bindListener(rebuildButton, "click", async () => {
try { try {
await startBoundaryPrecisionBuild(); await startBoundaryPrecisionBuild();
} catch (error) { } catch (error) {
@@ -2897,9 +3381,10 @@ async function setupBoundaryPrecisionControls() {
showStatusMessage(`高精国界重建启动失败:${error.message || error}`, "warning"); showStatusMessage(`高精国界重建启动失败:${error.message || error}`, "warning");
} }
}); });
} });
if (els.disableButton instanceof HTMLButtonElement) { els.disableButtons.forEach((disableButton) => {
bindListener(els.disableButton, "click", async () => { if (!(disableButton instanceof HTMLButtonElement)) return;
bindListener(disableButton, "click", async () => {
try { try {
if (!getHighPrecisionBoundariesEnabled()) return; if (!getHighPrecisionBoundariesEnabled()) return;
setHighPrecisionBoundariesEnabled(false); setHighPrecisionBoundariesEnabled(false);
@@ -2910,7 +3395,204 @@ async function setupBoundaryPrecisionControls() {
showStatusMessage(`低精国界切换失败:${error.message || error}`, "warning"); showStatusMessage(`低精国界切换失败:${error.message || error}`, "warning");
} }
}); });
});
}
function setSettingsTab(root, tabId) {
if (!(root instanceof HTMLElement)) return;
const nextTab = tabId || "runtime";
root.querySelectorAll("[data-settings-tab]").forEach((button) => {
if (!(button instanceof HTMLButtonElement)) return;
const active = button.dataset.settingsTab === nextTab;
button.classList.toggle("is-active", active);
button.setAttribute("aria-selected", active ? "true" : "false");
});
root.querySelectorAll("[data-settings-tab-panel]").forEach((panel) => {
if (!(panel instanceof HTMLElement)) return;
panel.hidden = panel.dataset.settingsTabPanel !== nextTab;
});
}
function setupSettingsTabs() {
const roots = document.querySelectorAll(".earth-settings-sheet, .earth-mobile-page--settings");
roots.forEach((root) => {
if (!(root instanceof HTMLElement)) return;
setSettingsTab(root, "runtime");
root.querySelectorAll("[data-settings-tab]").forEach((button) => {
if (!(button instanceof HTMLButtonElement)) return;
bindListener(button, "click", () => {
setSettingsTab(root, button.dataset.settingsTab || "runtime");
});
});
});
}
function renderShortcutSettings() {
const lists = document.querySelectorAll("[data-shortcut-list]");
if (lists.length === 0) return;
const groupedDefinitions = KEYBOARD_SHORTCUT_DEFINITIONS.reduce((groups, definition) => {
if (!groups.has(definition.category)) {
groups.set(definition.category, []);
}
groups.get(definition.category).push(definition);
return groups;
}, new Map());
lists.forEach((list) => {
if (!(list instanceof HTMLElement)) return;
list.innerHTML = "";
groupedDefinitions.forEach((definitions, category) => {
const heading = document.createElement("div");
heading.className = "earth-shortcut-category";
heading.textContent = category;
list.appendChild(heading);
definitions.forEach((definition) => {
const shortcut = getShortcutForAction(definition.id);
const row = document.createElement("div");
row.className = "earth-shortcut-row";
row.dataset.shortcutAction = definition.id;
row.innerHTML = `
<div class="earth-shortcut-copy">
<span class="earth-shortcut-label">${definition.label}</span>
<span class="earth-shortcut-alias">${getShortcutAliasesLabel(definition) || " "}</span>
</div>
<button class="earth-shortcut-key" type="button" data-shortcut-capture="${definition.id}">${getShortcutDisplayLabel(shortcut.binding)}</button>
<label class="earth-shortcut-enable" title="启用快捷键">
<input type="checkbox" data-shortcut-enabled="${definition.id}" ${shortcut.enabled ? "checked" : ""}>
<span class="earth-settings-switch-track"></span>
</label>
<button class="earth-shortcut-reset" type="button" data-shortcut-reset="${definition.id}" aria-label="重置${definition.label}" title="重置">
<span class="material-symbols-rounded">restart_alt</span>
</button>
`;
row.classList.toggle("is-disabled", !shortcut.enabled);
list.appendChild(row);
});
});
});
syncShortcutCaptureUi();
}
function syncShortcutCaptureUi() {
document.querySelectorAll("[data-shortcut-capture]").forEach((button) => {
if (!(button instanceof HTMLButtonElement)) return;
const actionId = button.dataset.shortcutCapture;
const isCapturing = Boolean(actionId && actionId === capturingShortcutActionId);
button.classList.toggle("is-capturing", isCapturing);
if (isCapturing) {
button.textContent = "按键...";
} else if (actionId) {
button.textContent = getShortcutDisplayLabel(getShortcutForAction(actionId).binding);
}
});
}
function setShortcutBinding(actionId, binding, { persist = true } = {}) {
if (!KEYBOARD_SHORTCUT_DEFINITION_BY_ID.has(actionId)) return false;
const normalizedBinding = normalizeShortcutBinding(binding);
if (!normalizedBinding) return false;
const owner = getShortcutOwnerByBinding(normalizedBinding, { excludeActionId: actionId });
if (owner) {
showStatusMessage(`快捷键已被「${owner.label}」使用`, "warning");
return false;
} }
const nextShortcuts = normalizeKeyboardShortcuts(keyboardShortcuts);
nextShortcuts[actionId] = {
...nextShortcuts[actionId],
binding: normalizedBinding,
enabled: true,
};
keyboardShortcuts = nextShortcuts;
if (earthSettingsState?.shared) {
earthSettingsState.shared.keyboardShortcuts = normalizeKeyboardShortcuts(keyboardShortcuts);
}
renderShortcutSettings();
if (persist) persistEarthSettings();
return true;
}
function resetShortcutBinding(actionId, { persist = true } = {}) {
const definition = KEYBOARD_SHORTCUT_DEFINITION_BY_ID.get(actionId);
if (!definition) return;
const nextShortcuts = normalizeKeyboardShortcuts(keyboardShortcuts);
nextShortcuts[actionId] = {
binding: definition.defaultBinding,
enabled: true,
};
keyboardShortcuts = nextShortcuts;
if (earthSettingsState?.shared) {
earthSettingsState.shared.keyboardShortcuts = normalizeKeyboardShortcuts(keyboardShortcuts);
}
renderShortcutSettings();
if (persist) persistEarthSettings();
}
function setShortcutEnabled(actionId, enabled, { persist = true } = {}) {
if (!KEYBOARD_SHORTCUT_DEFINITION_BY_ID.has(actionId)) return false;
const currentShortcut = getShortcutForAction(actionId);
if (enabled) {
const owner = getShortcutOwnerByBinding(currentShortcut.binding, { excludeActionId: actionId });
if (owner) {
showStatusMessage(`快捷键已被「${owner.label}」使用`, "warning");
renderShortcutSettings();
return false;
}
}
const nextShortcuts = normalizeKeyboardShortcuts(keyboardShortcuts);
nextShortcuts[actionId] = {
...nextShortcuts[actionId],
enabled: Boolean(enabled),
};
keyboardShortcuts = nextShortcuts;
if (earthSettingsState?.shared) {
earthSettingsState.shared.keyboardShortcuts = normalizeKeyboardShortcuts(keyboardShortcuts);
}
renderShortcutSettings();
if (persist) persistEarthSettings();
return true;
}
function resetAllShortcutBindings() {
keyboardShortcuts = getDefaultKeyboardShortcuts();
if (earthSettingsState?.shared) {
earthSettingsState.shared.keyboardShortcuts = normalizeKeyboardShortcuts(keyboardShortcuts);
}
capturingShortcutActionId = null;
renderShortcutSettings();
persistEarthSettings();
showStatusMessage("快捷键已恢复默认", "info");
}
function setupShortcutSettingsControls() {
document.querySelectorAll("[data-shortcut-list]").forEach((list) => {
bindListener(list, "click", (event) => {
const target = event.target instanceof Element ? event.target : null;
const captureButton = target?.closest("[data-shortcut-capture]");
const resetButton = target?.closest("[data-shortcut-reset]");
if (captureButton instanceof HTMLButtonElement) {
capturingShortcutActionId = captureButton.dataset.shortcutCapture || null;
syncShortcutCaptureUi();
} else if (resetButton instanceof HTMLButtonElement) {
const actionId = resetButton.dataset.shortcutReset;
if (actionId) resetShortcutBinding(actionId);
}
});
bindListener(list, "change", (event) => {
const target = event.target;
if (!(target instanceof HTMLInputElement)) return;
const actionId = target.dataset.shortcutEnabled;
if (!actionId) return;
setShortcutEnabled(actionId, target.checked);
});
});
document.querySelectorAll("[data-shortcut-reset-all]").forEach((button) => {
bindListener(button, "click", () => {
resetAllShortcutBindings();
});
});
renderShortcutSettings();
} }
function setupSettingsControls() { function setupSettingsControls() {
@@ -2936,6 +3618,9 @@ function setupSettingsControls() {
resetEarthSettings(); resetEarthSettings();
}); });
setupSettingsTabs();
setupShortcutSettingsControls();
bindListener(settingsModal, "click", (event) => { bindListener(settingsModal, "click", (event) => {
const sheet = event.target.closest(".earth-settings-sheet"); const sheet = event.target.closest(".earth-settings-sheet");
if (!sheet) { if (!sheet) {
@@ -4008,34 +4693,53 @@ function setupTerrainControls() {
function setupKeyboardControls() { function setupKeyboardControls() {
bindListener(document, "keydown", (event) => { bindListener(document, "keydown", (event) => {
if (event.key !== "Escape") return; if (capturingShortcutActionId) {
event.preventDefault();
if (isSearchPanelOpen()) { event.stopPropagation();
closeSearchPanel(); if (event.key === "Escape") {
capturingShortcutActionId = null;
syncShortcutCaptureUi();
return;
}
const nextBinding = getShortcutChordFromEvent(event);
if (!nextBinding) return;
if (setShortcutBinding(capturingShortcutActionId, nextBinding)) {
const definition = KEYBOARD_SHORTCUT_DEFINITION_BY_ID.get(capturingShortcutActionId);
showStatusMessage(`${definition?.label || "快捷键"}已设置为 ${getShortcutDisplayLabel(nextBinding)}`, "info");
capturingShortcutActionId = null;
syncShortcutCaptureUi();
}
return; return;
} }
if (isSettingsModalOpen()) { const chord = getShortcutChordFromEvent(event);
closeSettingsModal(); const definition = getShortcutDefinitionForChord(chord);
if (!definition) return;
if (isEditableShortcutTarget(event.target) && definition.id !== "closeFocus") return;
if (
definition.id !== "closeFocus" &&
(isShortcutSuppressedBySettingsUi(event.target) ||
isSettingsModalOpen() ||
(isMobileLayout() && mobileDrawerOpen && mobileDrawerCard === "settings"))
) {
return; return;
} }
if (isMobileLayout() && mobileDrawerOpen) { event.preventDefault();
setMobileDrawerState({ open: false }); event.stopPropagation();
return; executeKeyboardShortcut(definition.id);
} });
bindListener(document, "keyup", (event) => {
if (isFloatingMenuVisible()) { const chord = getShortcutChordFromEvent(event);
closeFloatingMenus(); const definition = getShortcutDefinitionForChord(chord);
return; if (!definition || !isKeyboardRotationAction(definition.id)) return;
} stopKeyboardRotationControl({ actionId: definition.id });
});
if (toolbarHubController?.isOpen?.()) { bindListener(window, "blur", () => {
toolbarHubController.close(); stopKeyboardRotationControl({ restoreAutoRotate: true, clearVelocity: true });
return; });
} cleanupFns.push(() => {
stopKeyboardRotationControl({ restoreAutoRotate: true, clearVelocity: true });
clearLockedObjectAndInfo();
}); });
} }

View File

@@ -255,7 +255,7 @@ import {
refreshLegend, refreshLegend,
setLegendItems, setLegendItems,
} from "./legend.js"; } from "./legend.js";
import { mountBrand } from "./brand.js"; import { fetchEarthBrandConfig, mountBrand } from "./brand.js";
import { initTVPanel } from "./tv.js"; import { initTVPanel } from "./tv.js";
import { initNewsPanel, updateNewsViewFocus } from "./news.js"; import { initNewsPanel, updateNewsViewFocus } from "./news.js";
import { initSearchPanel } from "./search.js"; import { initSearchPanel } from "./search.js";
@@ -3569,6 +3569,13 @@ export function init() {
updateHudScale(); updateHudScale();
const brandRoot = document.getElementById("brand-root"); const brandRoot = document.getElementById("brand-root");
mountBrand(brandRoot, HUD_CONFIG.brandLanguage); mountBrand(brandRoot, HUD_CONFIG.brandLanguage);
fetchEarthBrandConfig()
.then((brandConfig) => {
mountBrand(brandRoot, brandConfig);
})
.catch((error) => {
console.warn("Earth brand config unavailable, using defaults.", error);
});
initTVPanel(); initTVPanel();
initNewsPanel(); initNewsPanel();
initSearchPanel({ initSearchPanel({

View File

@@ -328,7 +328,7 @@ function renderTicker(nextPayload) {
.map((item) => ` .map((item) => `
<span class="earth-news-ticker__item" data-news-id="${escapeTickerText(item.id || "")}"> <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(item.source || item.feed_name || "NEWS")}</span>
<span>${escapeTickerText(getNewsDisplayTitle(item))}</span> <span>${escapeTickerText(getNewsDisplaySummary(item))}</span>
</span> </span>
`) `)
.join(""); .join("");
@@ -434,11 +434,12 @@ function renderPayload(nextPayload) {
: "news-story-card"; : "news-story-card";
const title = getNewsDisplayTitle(item); const title = getNewsDisplayTitle(item);
const summaryText = getNewsDisplaySummary(item); const summaryText = getNewsDisplaySummary(item);
const leadText = summaryText || title;
const regionLabel = item.display_region || getNewsRegionLabel(item.region); const regionLabel = item.display_region || getNewsRegionLabel(item.region);
const feedLabel = getNewsFeedLabel(item.feed_name); const feedLabel = getNewsFeedLabel(item.feed_name);
const statusLabel = getNewsEnrichmentStatusLabel(item.enrichment_status); const statusLabel = getNewsEnrichmentStatusLabel(item.enrichment_status);
const summary = summaryText const summary = title && title !== leadText
? `<div class="news-story-summary">${escapeNewsHtml(summaryText)}</div>` ? `<div class="news-story-summary">${escapeNewsHtml(title)}</div>`
: ""; : "";
return ` return `
<a class="${cardClass}" data-news-id="${item.id}" href="${item.url}" target="_blank" rel="noreferrer noopener"> <a class="${cardClass}" data-news-id="${item.id}" href="${item.url}" target="_blank" rel="noreferrer noopener">
@@ -446,7 +447,7 @@ function renderPayload(nextPayload) {
<span class="news-story-source">${escapeNewsHtml(item.source || "NEWS")}</span> <span class="news-story-source">${escapeNewsHtml(item.source || "NEWS")}</span>
<span class="news-story-time">${formatRelativeTime(item.published_at)}</span> <span class="news-story-time">${formatRelativeTime(item.published_at)}</span>
</div> </div>
<div class="news-story-title">${escapeNewsHtml(title)}</div> <div class="news-story-title">${escapeNewsHtml(leadText)}</div>
${summary} ${summary}
<div class="news-story-tags"> <div class="news-story-tags">
<span class="news-story-tag">${escapeNewsHtml(regionLabel)}</span> <span class="news-story-tag">${escapeNewsHtml(regionLabel)}</span>

View File

@@ -1,4 +1,4 @@
import { ReactNode, useMemo, useState } from 'react' import { ReactNode, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { Layout, Menu, Typography, Button, Space } from 'antd' import { Layout, Menu, Typography, Button, Space } from 'antd'
import { import {
AlertOutlined, AlertOutlined,
@@ -28,6 +28,7 @@ const { Sider, Content } = Layout
const { Text } = Typography const { Text } = Typography
const DEFAULT_OPEN_MENU_KEY = 'collection' const DEFAULT_OPEN_MENU_KEY = 'collection'
let cachedOpenKeys: string[] = [DEFAULT_OPEN_MENU_KEY] let cachedOpenKeys: string[] = [DEFAULT_OPEN_MENU_KEY]
let cachedMenuScrollTop = 0
interface AppLayoutProps { interface AppLayoutProps {
children: ReactNode children: ReactNode
@@ -39,6 +40,7 @@ function AppLayout({ children }: AppLayoutProps) {
const { user, logout } = useAuthStore() const { user, logout } = useAuthStore()
const [collapsed, setCollapsed] = useState(false) const [collapsed, setCollapsed] = useState(false)
const [openKeys, setOpenKeys] = useState<string[]>(cachedOpenKeys) const [openKeys, setOpenKeys] = useState<string[]>(cachedOpenKeys)
const menuViewportRef = useRef<HTMLDivElement>(null)
const showBanner = true const showBanner = true
const appVersion = `v${packageJson.version}` const appVersion = `v${packageJson.version}`
const isSuperAdmin = user?.role === 'super_admin' const isSuperAdmin = user?.role === 'super_admin'
@@ -105,6 +107,37 @@ function AppLayout({ children }: AppLayoutProps) {
setOpenKeys(nextKeys) setOpenKeys(nextKeys)
} }
useLayoutEffect(() => {
const viewport = menuViewportRef.current
if (!viewport) return
const restoreScroll = () => {
viewport.scrollTop = cachedMenuScrollTop
}
restoreScroll()
const frameId = window.requestAnimationFrame(restoreScroll)
return () => {
window.cancelAnimationFrame(frameId)
}
}, [collapsed, location.pathname, openKeys])
useLayoutEffect(() => {
const viewport = menuViewportRef.current
if (!viewport) return
const cacheScroll = () => {
cachedMenuScrollTop = viewport.scrollTop
}
viewport.addEventListener('scroll', cacheScroll, { passive: true })
return () => {
viewport.removeEventListener('scroll', cacheScroll)
}
}, [])
return ( return (
<Layout className="dashboard-layout"> <Layout className="dashboard-layout">
<Sider <Sider
@@ -139,7 +172,7 @@ function AppLayout({ children }: AppLayoutProps) {
<Text strong style={{ color: 'white', fontSize: 18 }}></Text> <Text strong style={{ color: 'white', fontSize: 18 }}></Text>
) : null} ) : null}
</div> </div>
<Scrollbar className="dashboard-sider-menu-scroll"> <Scrollbar className="dashboard-sider-menu-scroll" viewportRef={menuViewportRef}>
<Menu <Menu
theme="dark" theme="dark"
mode="inline" mode="inline"
@@ -150,6 +183,7 @@ function AppLayout({ children }: AppLayoutProps) {
updateOpenKeys(keys as string[]) updateOpenKeys(keys as string[])
}} }}
onClick={({ key }) => { onClick={({ key }) => {
cachedMenuScrollTop = menuViewportRef.current?.scrollTop ?? cachedMenuScrollTop
if (key !== selectedKey) { if (key !== selectedKey) {
navigate(key) navigate(key)
} }

View File

@@ -2743,6 +2743,17 @@ body {
margin-bottom: 12px; margin-bottom: 12px;
} }
.settings-tabs .ant-tabs-extra-content {
display: flex;
align-items: center;
}
.settings-tab-actions .ant-btn {
width: 32px;
height: 32px;
padding: 0;
}
.settings-tabs .ant-tabs-content-holder, .settings-tabs .ant-tabs-content-holder,
.settings-tabs .ant-tabs-content, .settings-tabs .ant-tabs-content,
.settings-tabs .ant-tabs-tabpane { .settings-tabs .ant-tabs-tabpane {
@@ -2801,6 +2812,159 @@ body {
min-width: 0; min-width: 0;
} }
.earth-brand-settings {
display: grid;
grid-template-columns: minmax(0, 1.05fr) minmax(280px, 0.95fr);
gap: 20px;
align-items: start;
}
.earth-brand-settings__form,
.earth-brand-settings__preview {
min-width: 0;
}
.earth-brand-settings__uploads {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.earth-brand-settings__preview {
position: sticky;
top: 0;
}
.earth-brand-settings__preview-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.earth-brand-preview-stage {
--hud-scale: 1;
--brand-scale: 0.88;
--brand-copy-width: 160px;
--hud-text: rgba(235, 245, 255, 0.96);
--hud-text-muted: rgba(214, 229, 247, 0.86);
--hud-text-soft: rgba(183, 204, 230, 0.68);
min-height: 190px;
padding: 26px 24px;
overflow: hidden;
background:
radial-gradient(circle at 22% 42%, rgba(69, 139, 255, 0.18), transparent 32%),
linear-gradient(135deg, #07111d 0%, #102033 52%, #17271e 100%);
}
.earth-brand-preview-stage .hud-panel-brand {
padding: calc(10px * var(--hud-scale)) calc(4px * var(--hud-scale)) calc(12px * var(--hud-scale)) 0;
display: flex;
align-items: center;
justify-content: flex-start;
min-height: calc(66px * var(--hud-scale));
background: transparent;
border: 0;
box-shadow: none;
isolation: isolate;
}
.earth-brand-preview-stage .earth-brand {
display: flex;
align-items: center;
gap: calc(10px * var(--hud-scale) * var(--brand-scale));
min-width: 0;
position: relative;
}
.earth-brand-preview-stage .earth-brand::after {
content: "";
position: absolute;
inset: calc(4px * var(--hud-scale)) calc(-10px * var(--hud-scale)) calc(6px * var(--hud-scale)) calc(-10px * var(--hud-scale));
background:
radial-gradient(circle at 18% 50%, rgba(145, 186, 255, 0.14), transparent 32%),
linear-gradient(90deg, rgba(145, 186, 255, 0.05), transparent 58%);
opacity: 0.75;
pointer-events: none;
filter: blur(14px);
z-index: -1;
}
.earth-brand-preview-stage .earth-brand__logo {
display: block;
flex: 0 0 auto;
width: calc(128px * var(--hud-scale) * var(--brand-scale));
height: calc(128px * var(--hud-scale) * var(--brand-scale));
object-fit: contain;
}
.earth-brand-preview-stage .earth-brand__copy {
display: flex;
flex: 0 0 auto;
flex-direction: column;
justify-content: center;
gap: calc(5px * var(--hud-scale) * var(--brand-scale));
width: calc(var(--brand-copy-width) * var(--hud-scale) * var(--brand-scale));
}
.earth-brand-preview-stage .earth-brand__title {
display: block;
width: min(100%, calc(var(--brand-copy-width) * var(--hud-scale) * var(--brand-scale)));
max-width: 100%;
height: auto;
min-height: calc(20px * var(--hud-scale) * var(--brand-scale));
object-fit: contain;
}
.earth-brand-preview-stage .earth-brand__title-text {
color: var(--hud-text);
font-size: calc(1rem * var(--hud-scale) * var(--brand-scale));
line-height: 1.18;
font-weight: 700;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.earth-brand-preview-stage .earth-brand__meta {
display: flex;
flex-direction: column;
gap: calc(2px * var(--hud-scale) * var(--brand-scale));
width: fit-content;
}
.earth-brand-preview-stage .earth-brand__subtitle {
color: var(--hud-text-muted);
font-size: calc(0.74rem * var(--hud-scale) * var(--brand-scale));
line-height: 1.3;
font-weight: 500;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.earth-brand-preview-stage .earth-brand__description {
color: var(--hud-text-soft);
font-size: calc(0.6rem * var(--hud-scale) * var(--brand-scale));
line-height: 1.3;
letter-spacing: 0.08em;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
@media (max-width: 980px) {
.earth-brand-settings,
.earth-brand-settings__uploads {
grid-template-columns: 1fr;
}
.earth-brand-settings__preview {
position: static;
}
}
.settings-pane .data-source-table-region .ant-table-container { .settings-pane .data-source-table-region .ant-table-container {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@@ -1,12 +1,12 @@
import { useEffect, useMemo, useState, type ReactNode } from 'react' import { useEffect, useMemo, useState, type ReactNode } from 'react'
import { import {
ApiOutlined, ApiOutlined,
CheckOutlined,
EyeInvisibleOutlined, EyeInvisibleOutlined,
EyeOutlined, EyeOutlined,
SaveOutlined, ReloadOutlined,
SyncOutlined, SyncOutlined,
ToolOutlined, ToolOutlined,
UndoOutlined,
} from '@ant-design/icons' } from '@ant-design/icons'
import { import {
Alert, Alert,
@@ -853,9 +853,6 @@ export default function AISettings() {
</Form.Item> </Form.Item>
</Card> </Card>
</Card> </Card>
<Button type="primary" htmlType="submit" loading={saving} style={{ marginTop: 16 }}>
AI
</Button>
{feedback ? <Alert showIcon type={feedback.type} message={feedback.message} style={{ marginTop: 12 }} /> : null} {feedback ? <Alert showIcon type={feedback.type} message={feedback.message} style={{ marginTop: 12 }} /> : null}
</Form> </Form>
</AISettingsPanel> </AISettingsPanel>
@@ -1062,9 +1059,6 @@ export default function AISettings() {
</> </>
) : null} ) : null}
</Card> </Card>
<Button type="primary" htmlType="submit" loading={saving} style={{ marginTop: 16 }}>
Tool
</Button>
{feedback ? <Alert showIcon type={feedback.type} message={feedback.message} style={{ marginTop: 12 }} /> : null} {feedback ? <Alert showIcon type={feedback.type} message={feedback.message} style={{ marginTop: 12 }} /> : null}
</Form> </Form>
</AISettingsPanel> </AISettingsPanel>
@@ -1136,19 +1130,6 @@ export default function AISettings() {
style={{ marginBottom: 12 }} style={{ marginBottom: 12 }}
/> />
<Space wrap>
<Button type="primary" htmlType="submit" icon={<SaveOutlined />} loading={savingPrompt}>
</Button>
<Button
icon={<UndoOutlined />}
loading={resettingPrompt}
disabled={!selectedPrompt.is_custom}
onClick={() => { void resetSelectedPrompt() }}
>
</Button>
</Space>
</> </>
) : ( ) : (
<Alert showIcon type="warning" message="暂无可配置提示词。" /> <Alert showIcon type="warning" message="暂无可配置提示词。" />
@@ -1167,6 +1148,48 @@ export default function AISettings() {
}, },
] ]
const renderTabActions = () => {
if (activeTab === 'playground') return null
if (activeTab === 'prompts') {
return (
<Space size={8} className="settings-tab-actions">
<Tooltip title="保存">
<Button
type="primary"
icon={<CheckOutlined />}
loading={savingPrompt}
disabled={!selectedPrompt}
onClick={() => promptForm.submit()}
aria-label="保存"
/>
</Tooltip>
<Tooltip title="重置">
<Button
icon={<ReloadOutlined />}
loading={resettingPrompt}
disabled={!selectedPrompt?.is_custom}
onClick={() => { void resetSelectedPrompt() }}
aria-label="重置"
/>
</Tooltip>
</Space>
)
}
return (
<Space size={8} className="settings-tab-actions">
<Tooltip title="保存">
<Button
type="primary"
icon={<CheckOutlined />}
loading={saving}
onClick={() => form.submit()}
aria-label="保存"
/>
</Tooltip>
</Space>
)
}
return ( return (
<AppLayout> <AppLayout>
<div className="page-shell settings-shell"> <div className="page-shell settings-shell">
@@ -1181,6 +1204,7 @@ export default function AISettings() {
className="settings-tabs" className="settings-tabs"
activeKey={activeTab} activeKey={activeTab}
onChange={(tabKey) => setSearchParams(tabKey === 'providers' ? {} : { tab: tabKey }, { replace: true })} onChange={(tabKey) => setSearchParams(tabKey === 'providers' ? {} : { tab: tabKey }, { replace: true })}
tabBarExtraContent={renderTabActions()}
items={tabItems} items={tabItems}
/> />
</div> </div>

View File

@@ -19,6 +19,7 @@ import {
} from 'antd' } from 'antd'
import AppLayout from '../../components/AppLayout/AppLayout' import AppLayout from '../../components/AppLayout/AppLayout'
import MarkdownRenderer from '../../components/MarkdownRenderer/MarkdownRenderer'
import Scrollbar from '../../components/Scrollbar/Scrollbar' import Scrollbar from '../../components/Scrollbar/Scrollbar'
import TableScrollRegion from '../../components/Scrollbar/TableScrollRegion' import TableScrollRegion from '../../components/Scrollbar/TableScrollRegion'
import type { BGPAnomaly, BGPBriefRecord, BGPIncident } from '../../services/situational-awareness' import type { BGPAnomaly, BGPBriefRecord, BGPIncident } from '../../services/situational-awareness'
@@ -252,7 +253,7 @@ export function BGPAlertsPanel() {
<Descriptions.Item label="模型">{brief.model || '-'}</Descriptions.Item> <Descriptions.Item label="模型">{brief.model || '-'}</Descriptions.Item>
<Descriptions.Item label="生成时间">{formatDateTimeZhCN(brief.generated_at)}</Descriptions.Item> <Descriptions.Item label="生成时间">{formatDateTimeZhCN(brief.generated_at)}</Descriptions.Item>
</Descriptions> </Descriptions>
<Typography.Paragraph className="alerts-brief-content">{brief.content_markdown}</Typography.Paragraph> <MarkdownRenderer markdown={brief.content_markdown} />
</Scrollbar> </Scrollbar>
) : ( ) : (
<Text type="secondary"> BGP </Text> <Text type="secondary"> BGP </Text>

View File

@@ -16,6 +16,7 @@ import {
import axios from 'axios' import axios from 'axios'
import AppLayout from '../../components/AppLayout/AppLayout' import AppLayout from '../../components/AppLayout/AppLayout'
import MarkdownRenderer from '../../components/MarkdownRenderer/MarkdownRenderer'
import Scrollbar from '../../components/Scrollbar/Scrollbar' import Scrollbar from '../../components/Scrollbar/Scrollbar'
import type { BGPSummarySnapshot } from '../../services/situational-awareness' import type { BGPSummarySnapshot } from '../../services/situational-awareness'
import { import {
@@ -28,6 +29,17 @@ const { Text } = Typography
const API_BASE_URL = (import.meta as any).env?.VITE_API_URL || '/api/v1' const API_BASE_URL = (import.meta as any).env?.VITE_API_URL || '/api/v1'
const gateway = getSituationalAwarenessGateway() const gateway = getSituationalAwarenessGateway()
function getAxiosErrorDetail(error: unknown, fallback: string): string {
if (!axios.isAxiosError(error)) return fallback
const detail = error.response?.data?.detail
if (typeof detail === 'string') return detail
if (Array.isArray(detail)) {
return detail.map((item) => item?.msg || item?.message || JSON.stringify(item)).join('')
}
if (detail && typeof detail === 'object') return JSON.stringify(detail)
return fallback
}
interface AlertStatsResponse { interface AlertStatsResponse {
critical: number critical: number
warning: number warning: number
@@ -84,8 +96,7 @@ export function SituationalAlertsPanel() {
setBriefResult(response.data) setBriefResult(response.data)
} catch (error) { } catch (error) {
console.error('Failed to generate situational alert brief:', error) console.error('Failed to generate situational alert brief:', error)
const detail = axios.isAxiosError(error) ? error.response?.data?.detail : null setBriefError(getAxiosErrorDetail(error, '生成态势告警 AI 简报失败'))
setBriefError(detail || '生成态势告警 AI 简报失败')
messageApi.error('生成态势告警 AI 简报失败') messageApi.error('生成态势告警 AI 简报失败')
} finally { } finally {
setBriefLoading(false) setBriefLoading(false)
@@ -191,7 +202,7 @@ export function SituationalAlertsPanel() {
</Space> </Space>
</Card> </Card>
<Card size="small" title="AI 简报"> <Card size="small" title="AI 简报">
<Typography.Paragraph className="alerts-brief-content">{briefResult.content}</Typography.Paragraph> <MarkdownRenderer markdown={briefResult.content} />
</Card> </Card>
</Space> </Space>
) : null} ) : null}

View File

@@ -1,4 +1,4 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties } from 'react' import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties } from 'react'
import { import {
Table, Tag, Space, Card, Select, Input, Button, Segmented, Table, Tag, Space, Card, Select, Input, Button, Segmented,
Modal, Spin, Empty, Tooltip, Typography, Grid Modal, Spin, Empty, Tooltip, Typography, Grid
@@ -17,6 +17,7 @@ import { formatDateTimeZhCN, formatDateZhCN, parseBackendDate } from '../../util
const { Title, Text } = Typography const { Title, Text } = Typography
const { useBreakpoint } = Grid const { useBreakpoint } = Grid
const DATA_LIST_SUMMARY_DELAY_MS = 250
interface CollectedData { interface CollectedData {
id: number id: number
@@ -321,6 +322,7 @@ function DataList() {
const [sourceFilter, setSourceFilter] = useState<string[]>([]) const [sourceFilter, setSourceFilter] = useState<string[]>([])
const [typeFilter, setTypeFilter] = useState<string[]>([]) const [typeFilter, setTypeFilter] = useState<string[]>([])
const [searchText, setSearchText] = useState('') const [searchText, setSearchText] = useState('')
const [submittedSearchText, setSubmittedSearchText] = useState('')
const [sources, setSources] = useState<SourceOption[]>([]) const [sources, setSources] = useState<SourceOption[]>([])
const [types, setTypes] = useState<string[]>([]) const [types, setTypes] = useState<string[]>([])
const [detailVisible, setDetailVisible] = useState(false) const [detailVisible, setDetailVisible] = useState(false)
@@ -398,16 +400,17 @@ function DataList() {
window.addEventListener('mouseup', onUp) window.addEventListener('mouseup', onUp)
} }
const fetchData = async () => { const fetchData = useCallback(async () => {
setLoading(true) setLoading(true)
try { try {
const params = new URLSearchParams({ const params = new URLSearchParams({
page: page.toString(), page: page.toString(),
page_size: pageSize.toString(), page_size: pageSize.toString(),
include_metadata: 'false',
}) })
if (sourceFilter.length > 0) params.append('source', sourceFilter.join(',')) if (sourceFilter.length > 0) params.append('source', sourceFilter.join(','))
if (typeFilter.length > 0) params.append('data_type', typeFilter.join(',')) if (typeFilter.length > 0) params.append('data_type', typeFilter.join(','))
if (searchText) params.append('search', searchText) if (submittedSearchText) params.append('search', submittedSearchText)
const res = await axios.get(`/api/v1/collected?${params}`) const res = await axios.get(`/api/v1/collected?${params}`)
setData(res.data.data) setData(res.data.data)
@@ -417,14 +420,14 @@ function DataList() {
} finally { } finally {
setLoading(false) setLoading(false)
} }
} }, [page, pageSize, submittedSearchText, sourceFilter, typeFilter])
const fetchSummary = async () => { const fetchSummary = useCallback(async () => {
try { try {
const params = new URLSearchParams() const params = new URLSearchParams()
if (sourceFilter.length > 0) params.append('source', sourceFilter.join(',')) if (sourceFilter.length > 0) params.append('source', sourceFilter.join(','))
if (typeFilter.length > 0) params.append('data_type', typeFilter.join(',')) if (typeFilter.length > 0) params.append('data_type', typeFilter.join(','))
if (searchText) params.append('search', searchText) if (submittedSearchText) params.append('search', submittedSearchText)
const query = params.toString() const query = params.toString()
const res = await axios.get(query ? `/api/v1/collected/summary?${query}` : '/api/v1/collected/summary') const res = await axios.get(query ? `/api/v1/collected/summary?${query}` : '/api/v1/collected/summary')
@@ -432,9 +435,9 @@ function DataList() {
} catch (error) { } catch (error) {
console.error('Failed to fetch summary:', error) console.error('Failed to fetch summary:', error)
} }
} }, [submittedSearchText, sourceFilter, typeFilter])
const fetchFilters = async () => { const fetchFilters = useCallback(async () => {
try { try {
const [sourcesRes, typesRes] = await Promise.all([ const [sourcesRes, typesRes] = await Promise.all([
axios.get('/api/v1/collected/sources'), axios.get('/api/v1/collected/sources'),
@@ -445,30 +448,34 @@ function DataList() {
} catch (error) { } catch (error) {
console.error('Failed to fetch filters:', error) console.error('Failed to fetch filters:', error)
} }
}
useEffect(() => {
fetchFilters()
}, []) }, [])
useEffect(() => { useEffect(() => {
fetchData() void fetchFilters()
fetchSummary() }, [fetchFilters])
}, [page, pageSize, sourceFilter, typeFilter])
useEffect(() => {
void fetchData()
}, [fetchData])
useEffect(() => {
const timer = window.setTimeout(() => {
void fetchSummary()
}, DATA_LIST_SUMMARY_DELAY_MS)
return () => window.clearTimeout(timer)
}, [fetchSummary])
const handleSearch = () => { const handleSearch = () => {
setSubmittedSearchText(searchText.trim())
setPage(1) setPage(1)
fetchData()
fetchSummary()
} }
const handleReset = () => { const handleReset = () => {
setSourceFilter([]) setSourceFilter([])
setTypeFilter([]) setTypeFilter([])
setSearchText('') setSearchText('')
setSubmittedSearchText('')
setPage(1) setPage(1)
setTimeout(fetchData, 0)
setTimeout(fetchSummary, 0)
} }
const handleViewDetail = async (id: number) => { const handleViewDetail = async (id: number) => {
@@ -589,8 +596,8 @@ function DataList() {
} }
const activeFilterCount = useMemo( const activeFilterCount = useMemo(
() => [sourceFilter.length > 0, typeFilter.length > 0, searchText.trim()].filter(Boolean).length, () => [sourceFilter.length > 0, typeFilter.length > 0, submittedSearchText].filter(Boolean).length,
[sourceFilter, typeFilter, searchText] [sourceFilter, typeFilter, submittedSearchText]
) )
const summaryKpis = useMemo( const summaryKpis = useMemo(

View File

@@ -40,6 +40,7 @@ const { Text } = Typography
const COLLECTION_REFRESH_DELAY_MS = 800 const COLLECTION_REFRESH_DELAY_MS = 800
const COLLECTION_STATUS_POLL_MS = 900 const COLLECTION_STATUS_POLL_MS = 900
const COLLECTION_STATUS_MAX_POLLS = 30 const COLLECTION_STATUS_MAX_POLLS = 30
const DATASOURCE_SEARCH_DEBOUNCE_MS = 350
interface BuiltInDataSource { interface BuiltInDataSource {
id: number id: number
@@ -309,6 +310,7 @@ function DataSources({ embedded = false }: { embedded?: boolean } = {}) {
const [activeFilter, setActiveFilter] = useState<ActiveFilter>('enabled') const [activeFilter, setActiveFilter] = useState<ActiveFilter>('enabled')
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all') const [statusFilter, setStatusFilter] = useState<StatusFilter>('all')
const [collectedFilter, setCollectedFilter] = useState<CollectedFilter>('all') const [collectedFilter, setCollectedFilter] = useState<CollectedFilter>('all')
const [searchInput, setSearchInput] = useState('')
const [searchQuery, setSearchQuery] = useState('') const [searchQuery, setSearchQuery] = useState('')
const [viewDrawerVisible, setViewDrawerVisible] = useState(false) const [viewDrawerVisible, setViewDrawerVisible] = useState(false)
const [runningTasksVisible, setRunningTasksVisible] = useState(false) const [runningTasksVisible, setRunningTasksVisible] = useState(false)
@@ -316,6 +318,8 @@ function DataSources({ embedded = false }: { embedded?: boolean } = {}) {
const [recordCount, setRecordCount] = useState<number | null>(null) const [recordCount, setRecordCount] = useState<number | null>(null)
const [tableHeight, setTableHeight] = useState(360) const [tableHeight, setTableHeight] = useState(360)
const tableRegionRef = useRef<HTMLDivElement | null>(null) const tableRegionRef = useRef<HTMLDivElement | null>(null)
const customOverridesLoadedRef = useRef(false)
const realtimeSourcesLoadedRef = useRef(false)
const taskBuiltInSources = useMemo( const taskBuiltInSources = useMemo(
() => builtInSources.filter((source) => !isRealtimeBuiltinSource(source)), () => builtInSources.filter((source) => !isRealtimeBuiltinSource(source)),
@@ -352,12 +356,8 @@ function DataSources({ embedded = false }: { embedded?: boolean } = {}) {
collected: collectedFilter === 'all' ? undefined : collectedFilter === 'collected', collected: collectedFilter === 'all' ? undefined : collectedFilter === 'collected',
q: searchQuery.trim() || undefined, q: searchQuery.trim() || undefined,
} }
const [builtinRes, customRes] = await Promise.all([ const builtinRes = await axios.get('/api/v1/datasources', { params })
axios.get('/api/v1/datasources', { params }),
axios.get('/api/v1/datasources/configs'),
])
setBuiltInSources(builtinRes.data.data || []) setBuiltInSources(builtinRes.data.data || [])
setCustomOverrides(customRes.data.data || [])
} catch (error) { } catch (error) {
console.error('Failed to fetch data:', error) console.error('Failed to fetch data:', error)
messageApi.error('获取数据源列表失败') messageApi.error('获取数据源列表失败')
@@ -371,6 +371,7 @@ function DataSources({ embedded = false }: { embedded?: boolean } = {}) {
try { try {
const response = await axios.get('/api/v1/realtime-sources') const response = await axios.get('/api/v1/realtime-sources')
setRealtimeSources(response.data.data || []) setRealtimeSources(response.data.data || [])
realtimeSourcesLoadedRef.current = true
} catch (error) { } catch (error) {
console.error('Failed to fetch realtime sources:', error) console.error('Failed to fetch realtime sources:', error)
messageApi.error('获取实时流状态失败') messageApi.error('获取实时流状态失败')
@@ -384,8 +385,17 @@ function DataSources({ embedded = false }: { embedded?: boolean } = {}) {
}, [fetchData]) }, [fetchData])
useEffect(() => { useEffect(() => {
void fetchRealtimeSources() if (activeTab === 'realtime' && !realtimeSourcesLoadedRef.current) {
}, [fetchRealtimeSources]) void fetchRealtimeSources()
}
}, [activeTab, fetchRealtimeSources])
useEffect(() => {
const timer = window.setTimeout(() => {
setSearchQuery(searchInput.trim())
}, DATASOURCE_SEARCH_DEBOUNCE_MS)
return () => window.clearTimeout(timer)
}, [searchInput])
useEffect(() => { useEffect(() => {
const visibleKeys = new Set(allSources.map((source) => source.key)) const visibleKeys = new Set(allSources.map((source) => source.key))
@@ -562,7 +572,14 @@ function DataSources({ embedded = false }: { embedded?: boolean } = {}) {
const handleViewSource = async (source: UnifiedDataSource) => { const handleViewSource = async (source: UnifiedDataSource) => {
try { try {
{ {
const override = customOverrides.find((item) => item.name === source.source) let overrides = customOverrides
if (!customOverridesLoadedRef.current) {
const customRes = await axios.get('/api/v1/datasources/configs')
overrides = customRes.data.data || []
setCustomOverrides(overrides)
customOverridesLoadedRef.current = true
}
const override = overrides.find((item) => item.name === source.source)
const [detailRes, statsRes, overrideDetail] = await Promise.all([ const [detailRes, statsRes, overrideDetail] = await Promise.all([
axios.get(`/api/v1/datasources/${source.id}`), axios.get(`/api/v1/datasources/${source.id}`),
axios.get(`/api/v1/datasources/${source.id}/stats`), axios.get(`/api/v1/datasources/${source.id}/stats`),
@@ -1006,8 +1023,9 @@ function DataSources({ embedded = false }: { embedded?: boolean } = {}) {
className="data-source-filter-toolbar__search" className="data-source-filter-toolbar__search"
allowClear allowClear
placeholder="搜索名称、标识或采集器" placeholder="搜索名称、标识或采集器"
value={searchQuery} value={searchInput}
onChange={(event) => setSearchQuery(event.target.value)} onChange={(event) => setSearchInput(event.target.value)}
onSearch={(value) => setSearchQuery(value.trim())}
/> />
</div> </div>
<div ref={tableRegionRef} className="table-scroll-region data-source-table-region"> <div ref={tableRegionRef} className="table-scroll-region data-source-table-region">

View File

@@ -4,6 +4,7 @@ import { TableActions, actionCellProps } from '../../components/TableActions/Tab
import { import {
ApiOutlined, ApiOutlined,
BookOutlined, BookOutlined,
CheckOutlined,
CheckCircleOutlined, CheckCircleOutlined,
DeleteOutlined, DeleteOutlined,
EditOutlined, EditOutlined,
@@ -15,6 +16,7 @@ import {
RobotOutlined, RobotOutlined,
StopOutlined, StopOutlined,
SyncOutlined, SyncOutlined,
UploadOutlined,
} from '@ant-design/icons' } from '@ant-design/icons'
import { import {
Alert, Alert,
@@ -35,6 +37,7 @@ import {
Tag, Tag,
Tooltip, Tooltip,
Typography, Typography,
Upload,
} from 'antd' } from 'antd'
import axios from 'axios' import axios from 'axios'
import AppLayout from '../../components/AppLayout/AppLayout' import AppLayout from '../../components/AppLayout/AppLayout'
@@ -48,9 +51,19 @@ import SmtpPanel from './SmtpPanel'
import DataSources from '../DataSources/DataSources' import DataSources from '../DataSources/DataSources'
const { Title, Text } = Typography const { Title, Text } = Typography
const { Dragger } = Upload
const ANTHROPIC_MESSAGES_MAX_TOKENS = 1200 const ANTHROPIC_MESSAGES_MAX_TOKENS = 1200
const DEFAULT_PROVIDER_MAX_TOKENS = 4096 const DEFAULT_PROVIDER_MAX_TOKENS = 4096
const CUSTOM_STREAM_STATUS_POLL_MS = 5000 const CUSTOM_STREAM_STATUS_POLL_MS = 5000
const DEFAULT_EARTH_BRAND_SETTINGS: EarthBrandSettings = {
logo_src: '/earth/assets/brand/earth-logo.png',
title_src: '/earth/assets/brand/title-zh.png',
title_text: '智能星球计划',
subtitle: '现实层宇宙全息感知系统',
description: '卫星 · 海底光缆 · 算力基础设施',
aria_label: '智能星球计划品牌标识',
title_alt: '智能星球计划',
}
interface SystemSettings { interface SystemSettings {
system_name: string system_name: string
@@ -160,6 +173,21 @@ interface EarthBoundaryStatus {
last_build?: Record<string, unknown> last_build?: Record<string, unknown>
} }
interface EarthBrandSettings {
logo_src: string
title_src: string
title_text: string
subtitle: string
description: string
aria_label: string
title_alt: string
}
interface EarthBrandResponse {
brand: EarthBrandSettings
is_default: boolean
}
interface SecretStatus { interface SecretStatus {
configured: boolean configured: boolean
preview: string preview: string
@@ -357,6 +385,14 @@ const formatBytes = (value: number | null | undefined) => {
return `${(value / 1024 / 1024).toFixed(1)} MB` return `${(value / 1024 / 1024).toFixed(1)} MB`
} }
const normalizeEarthBrandSettings = (value?: Partial<EarthBrandSettings> | null): EarthBrandSettings => ({
...DEFAULT_EARTH_BRAND_SETTINGS,
...(value || {}),
title_text: value?.title_text?.trim() || DEFAULT_EARTH_BRAND_SETTINGS.title_text,
aria_label: value?.aria_label?.trim() || value?.title_text?.trim() || DEFAULT_EARTH_BRAND_SETTINGS.aria_label,
title_alt: value?.title_alt?.trim() || value?.title_text?.trim() || DEFAULT_EARTH_BRAND_SETTINGS.title_alt,
})
function SettingsPanel({ function SettingsPanel({
loading, loading,
children, children,
@@ -392,8 +428,12 @@ function Settings() {
const [notificationSettings, setNotificationSettings] = useState<NotificationSettings | null>(null) const [notificationSettings, setNotificationSettings] = useState<NotificationSettings | null>(null)
const [securitySettings, setSecuritySettings] = useState<SecuritySettings | null>(null) const [securitySettings, setSecuritySettings] = useState<SecuritySettings | null>(null)
const [tvSettings, setTvSettings] = useState<TVSettings | null>(null) const [tvSettings, setTvSettings] = useState<TVSettings | null>(null)
const [earthBrandSettings, setEarthBrandSettings] = useState<EarthBrandSettings>(DEFAULT_EARTH_BRAND_SETTINGS)
const [earthBrandIsDefault, setEarthBrandIsDefault] = useState(true)
const [earthBoundaryStatus, setEarthBoundaryStatus] = useState<EarthBoundaryStatus | null>(null) const [earthBoundaryStatus, setEarthBoundaryStatus] = useState<EarthBoundaryStatus | null>(null)
const [earthBoundaryConfigText, setEarthBoundaryConfigText] = useState('') const [earthBoundaryConfigText, setEarthBoundaryConfigText] = useState('')
const [savingEarthBrand, setSavingEarthBrand] = useState(false)
const [uploadingEarthBrandField, setUploadingEarthBrandField] = useState<keyof EarthBrandSettings | null>(null)
const [savingEarthBoundaryConfig, setSavingEarthBoundaryConfig] = useState(false) const [savingEarthBoundaryConfig, setSavingEarthBoundaryConfig] = useState(false)
const [buildingEarthBoundary, setBuildingEarthBoundary] = useState(false) const [buildingEarthBoundary, setBuildingEarthBoundary] = useState(false)
const [integrations, setIntegrations] = useState<ExternalIntegrations | null>(null) const [integrations, setIntegrations] = useState<ExternalIntegrations | null>(null)
@@ -428,12 +468,15 @@ function Settings() {
const [systemForm] = Form.useForm<SystemSettings>() const [systemForm] = Form.useForm<SystemSettings>()
const [notificationForm] = Form.useForm<NotificationSettings>() const [notificationForm] = Form.useForm<NotificationSettings>()
const [securityForm] = Form.useForm<SecuritySettings>() const [securityForm] = Form.useForm<SecuritySettings>()
const [earthBrandForm] = Form.useForm<EarthBrandSettings>()
const [integrationForm] = Form.useForm() const [integrationForm] = Form.useForm()
const [collectorConfigForm] = Form.useForm() const [collectorConfigForm] = Form.useForm()
const [customSourceForm] = Form.useForm() const [customSourceForm] = Form.useForm()
const [tvEditForm] = Form.useForm<TVStreamSource>() const [tvEditForm] = Form.useForm<TVStreamSource>()
const selectedAiProvider = Form.useWatch(['ai_provider', 'provider'], integrationForm) const selectedAiProvider = Form.useWatch(['ai_provider', 'provider'], integrationForm)
const selectedWebSearchProvider = Form.useWatch(['web_search', 'provider'], integrationForm) const selectedWebSearchProvider = Form.useWatch(['web_search', 'provider'], integrationForm)
const earthBrandFormValues = Form.useWatch([], earthBrandForm) as Partial<EarthBrandSettings> | undefined
const earthBrandPreview = normalizeEarthBrandSettings(earthBrandFormValues || earthBrandSettings)
const webSearchEnabled = Form.useWatch(['web_search', 'enabled'], integrationForm) const webSearchEnabled = Form.useWatch(['web_search', 'enabled'], integrationForm)
const selectedAiProviderSecret = selectedAiProvider const selectedAiProviderSecret = selectedAiProvider
? integrations?.ai_provider.providers?.[selectedAiProvider]?.api_key || integrations?.ai_provider.api_key ? integrations?.ai_provider.providers?.[selectedAiProvider]?.api_key || integrations?.ai_provider.api_key
@@ -471,7 +514,7 @@ function Settings() {
const selectedAisRuntimeHealth = selectedCollector?.ais_health || null const selectedAisRuntimeHealth = selectedCollector?.ais_health || null
const settingsTabKeysByMode: Record<string, Set<string>> = { const settingsTabKeysByMode: Record<string, Set<string>> = {
settings: new Set(['display', 'notifications', 'security', 'smtp']), settings: new Set(['display', 'notifications', 'security', 'smtp']),
earth: new Set(['tv', 'earth_assets', 'basemap', 'layer_resources', 'models_3d', 'news_anchor_strategy']), earth: new Set(['tv', 'brand', 'earth_assets', 'basemap', 'layer_resources', 'models_3d', 'news_anchor_strategy']),
collection: new Set(['collector_credentials', 'collectors', 'collection_history']), collection: new Set(['collector_credentials', 'collectors', 'collection_history']),
} }
const defaultTabByMode: Record<string, string> = { const defaultTabByMode: Record<string, string> = {
@@ -504,6 +547,7 @@ function Settings() {
webSearchPresetsResponse, webSearchPresetsResponse,
collectorConfigsResponse, collectorConfigsResponse,
customConfigsResponse, customConfigsResponse,
earthBrandResponse,
earthBoundaryResponse, earthBoundaryResponse,
] = await Promise.all([ ] = await Promise.all([
axios.get('/api/v1/settings'), axios.get('/api/v1/settings'),
@@ -511,6 +555,7 @@ function Settings() {
axios.get('/api/v1/settings/integrations/web-search/presets'), axios.get('/api/v1/settings/integrations/web-search/presets'),
axios.get('/api/v1/datasources/configs/all'), axios.get('/api/v1/datasources/configs/all'),
axios.get('/api/v1/datasources/configs'), axios.get('/api/v1/datasources/configs'),
axios.get<EarthBrandResponse>('/api/v1/earth/brand'),
axios.get('/api/v1/earth/boundaries/status'), axios.get('/api/v1/earth/boundaries/status'),
]) ])
setSystemSettings(response.data.system) setSystemSettings(response.data.system)
@@ -518,6 +563,10 @@ function Settings() {
setSecuritySettings(response.data.security) setSecuritySettings(response.data.security)
setTvSettings(response.data.tv || null) setTvSettings(response.data.tv || null)
setIntegrations(response.data.integrations || null) setIntegrations(response.data.integrations || null)
const nextEarthBrand = normalizeEarthBrandSettings(earthBrandResponse.data.brand)
setEarthBrandSettings(nextEarthBrand)
setEarthBrandIsDefault(earthBrandResponse.data.is_default)
earthBrandForm.setFieldsValue(nextEarthBrand)
setEarthBoundaryStatus(earthBoundaryResponse.data) setEarthBoundaryStatus(earthBoundaryResponse.data)
setEarthBoundaryConfigText(JSON.stringify(earthBoundaryResponse.data.config || {}, null, 2)) setEarthBoundaryConfigText(JSON.stringify(earthBoundaryResponse.data.config || {}, null, 2))
setCollectors(response.data.collectors || []) setCollectors(response.data.collectors || [])
@@ -1601,6 +1650,94 @@ function Settings() {
} }
} }
const saveEarthBrandSettings = async () => {
try {
const values = await earthBrandForm.validateFields()
const payload = normalizeEarthBrandSettings(values)
setSavingEarthBrand(true)
const response = await axios.put<EarthBrandResponse>('/api/v1/earth/brand', payload)
const nextBrand = normalizeEarthBrandSettings(response.data.brand)
setEarthBrandSettings(nextBrand)
setEarthBrandIsDefault(response.data.is_default)
earthBrandForm.setFieldsValue(nextBrand)
message.success('Earth 品牌标识已保存')
} catch (error) {
if (error instanceof Error) {
message.error(error.message || 'Earth 品牌标识保存失败')
} else {
message.error('Earth 品牌标识保存失败')
}
console.error(error)
} finally {
setSavingEarthBrand(false)
}
}
const resetEarthBrandSettings = () => {
Modal.confirm({
title: '重置为缺省品牌标识?',
content: '这会清除当前自定义品牌配置Earth 页面将恢复使用内置静态资产。',
okText: '重置',
cancelText: '取消',
okButtonProps: { danger: true },
onOk: async () => {
try {
setSavingEarthBrand(true)
const response = await axios.delete<EarthBrandResponse>('/api/v1/earth/brand')
const nextBrand = normalizeEarthBrandSettings(response.data.brand)
setEarthBrandSettings(nextBrand)
setEarthBrandIsDefault(true)
earthBrandForm.setFieldsValue(nextBrand)
message.success('Earth 品牌标识已重置为缺省')
} catch (error) {
message.error('Earth 品牌标识重置失败')
console.error(error)
} finally {
setSavingEarthBrand(false)
}
},
})
}
const uploadEarthBrandAsset = async (field: 'logo_src' | 'title_src', file: File) => {
const formData = new FormData()
formData.append('file', file)
setUploadingEarthBrandField(field)
try {
const response = await axios.post<{ url: string }>('/api/v1/earth/brand/assets', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})
earthBrandForm.setFieldValue(field, response.data.url)
message.success('品牌图片已上传')
return response.data.url
} finally {
setUploadingEarthBrandField(null)
}
}
const createEarthBrandUploadProps = (field: 'logo_src' | 'title_src') => ({
name: 'file',
multiple: false,
showUploadList: false,
accept: '.png,.jpg,.jpeg,.webp,.svg,image/png,image/jpeg,image/webp,image/svg+xml',
customRequest: (options: {
file: string | Blob | File
onSuccess?: (response: unknown) => void
onError?: (error: Error) => void
}) => {
const file = options.file instanceof File
? options.file
: new File([options.file], 'earth-brand-asset')
uploadEarthBrandAsset(field, file)
.then((url) => options.onSuccess?.({ url }))
.catch((error: unknown) => {
const uploadError = error instanceof Error ? error : new Error('品牌图片上传失败')
message.error(uploadError.message)
options.onError?.(uploadError)
})
},
})
const saveEarthBoundaryConfig = async () => { const saveEarthBoundaryConfig = async () => {
try { try {
setSavingEarthBoundaryConfig(true) setSavingEarthBoundaryConfig(true)
@@ -1731,9 +1868,15 @@ function Settings() {
width: 92, width: 92,
fixed: 'right' as const, fixed: 'right' as const,
render: (_: unknown, record: CollectorSettings) => ( render: (_: unknown, record: CollectorSettings) => (
<Button type="primary" loading={savingCollectorId === record.id} onClick={() => saveCollector(record)}> <Tooltip title="保存">
<Button
</Button> type="primary"
icon={<CheckOutlined />}
loading={savingCollectorId === record.id}
onClick={() => saveCollector(record)}
aria-label="保存"
/>
</Tooltip>
), ),
}, },
] ]
@@ -1899,7 +2042,6 @@ function Settings() {
<Form.Item name="auto_refresh" label="自动刷新" valuePropName="checked"> <Form.Item name="auto_refresh" label="自动刷新" valuePropName="checked">
<Switch /> <Switch />
</Form.Item> </Form.Item>
<Button type="primary" htmlType="submit"></Button>
</Form> </Form>
</SettingsPanel> </SettingsPanel>
), ),
@@ -1926,7 +2068,6 @@ function Settings() {
<Form.Item name="daily_summary" label="每日摘要" valuePropName="checked"> <Form.Item name="daily_summary" label="每日摘要" valuePropName="checked">
<Switch /> <Switch />
</Form.Item> </Form.Item>
<Button type="primary" htmlType="submit"></Button>
</Form> </Form>
</SettingsPanel> </SettingsPanel>
), ),
@@ -1953,7 +2094,6 @@ function Settings() {
]} ]}
/> />
</Form.Item> </Form.Item>
<Button type="primary" htmlType="submit"></Button>
</Form> </Form>
</SettingsPanel> </SettingsPanel>
), ),
@@ -2075,6 +2215,99 @@ function Settings() {
</div> </div>
), ),
}, },
{
key: 'brand',
label: '品牌标识',
forceRender: true,
children: (
<SettingsPanel loading={loading}>
<div className="earth-brand-settings">
<div className="earth-brand-settings__form">
<Form form={earthBrandForm} layout="vertical">
<div className="earth-brand-settings__uploads">
<Form.Item label="图形 logo">
<Dragger
{...createEarthBrandUploadProps('logo_src')}
disabled={uploadingEarthBrandField !== null}
>
<p className="ant-upload-drag-icon"><UploadOutlined /></p>
<p className="ant-upload-text"> logo</p>
</Dragger>
</Form.Item>
<Form.Item label="文字 logo 图片">
<Dragger
{...createEarthBrandUploadProps('title_src')}
disabled={uploadingEarthBrandField !== null}
>
<p className="ant-upload-drag-icon"><UploadOutlined /></p>
<p className="ant-upload-text"> logo</p>
</Dragger>
</Form.Item>
</div>
<Form.Item name="logo_src" label="图形 logo 地址" rules={[{ required: true, message: '请填写图形 logo 地址' }]}>
<Input placeholder="/earth/assets/brand/earth-logo.png" />
</Form.Item>
<Form.Item name="title_src" label="文字 logo 图片地址">
<Input placeholder="/earth/assets/brand/title-zh.png" />
</Form.Item>
<Form.Item name="title_text" label="标题文本" rules={[{ required: true, message: '请填写标题文本' }]}>
<Input />
</Form.Item>
<Form.Item name="subtitle" label="副标题">
<Input />
</Form.Item>
<Form.Item name="description" label="说明文案">
<Input />
</Form.Item>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0 16px' }}>
<Form.Item name="aria_label" label="品牌区域无障碍文本">
<Input />
</Form.Item>
<Form.Item name="title_alt" label="文字 logo 替代文本">
<Input />
</Form.Item>
</div>
</Form>
</div>
<div className="earth-brand-settings__preview">
<div className="earth-brand-settings__preview-header">
<Text strong></Text>
<Tag color={earthBrandIsDefault ? 'default' : 'blue'}>
{earthBrandIsDefault ? '缺省' : '自定义'}
</Tag>
</div>
<div className="earth-brand-preview-stage">
<div className="hud-panel-brand">
<div className="earth-brand" aria-label={earthBrandPreview.aria_label}>
<img
className="earth-brand__logo"
src={earthBrandPreview.logo_src}
alt=""
aria-hidden="true"
/>
<div className="earth-brand__copy">
{earthBrandPreview.title_src ? (
<img
className="earth-brand__title"
src={earthBrandPreview.title_src}
alt={earthBrandPreview.title_alt}
/>
) : (
<div className="earth-brand__title-text">{earthBrandPreview.title_text}</div>
)}
<div className="earth-brand__meta">
<span className="earth-brand__subtitle">{earthBrandPreview.subtitle}</span>
<span className="earth-brand__description">{earthBrandPreview.description}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</SettingsPanel>
),
},
{ {
key: 'earth_assets', key: 'earth_assets',
label: '国界精度', label: '国界精度',
@@ -2130,13 +2363,6 @@ function Settings() {
spellCheck={false} spellCheck={false}
/> />
<Space style={{ marginTop: 12 }}> <Space style={{ marginTop: 12 }}>
<Button
type="primary"
loading={savingEarthBoundaryConfig}
onClick={() => { void saveEarthBoundaryConfig() }}
>
</Button>
<Button <Button
icon={<PlayCircleOutlined />} icon={<PlayCircleOutlined />}
loading={buildingEarthBoundary} loading={buildingEarthBoundary}
@@ -2145,12 +2371,6 @@ function Settings() {
> >
</Button> </Button>
<Button
icon={<ReloadOutlined />}
onClick={() => { void fetchSettings() }}
>
</Button>
</Space> </Space>
</Card> </Card>
</Space> </Space>
@@ -2426,14 +2646,6 @@ function Settings() {
) : null} ) : null}
</Card> </Card>
<Button
type="primary"
htmlType="submit"
loading={savingIntegrations}
style={{ marginTop: 16 }}
>
AI
</Button>
{aiProviderSaveFeedback ? ( {aiProviderSaveFeedback ? (
<Alert <Alert
showIcon showIcon
@@ -2753,13 +2965,6 @@ function Settings() {
</Card> </Card>
<Space> <Space>
<Button
type="primary"
loading={savingCollectorConfig || savingIntegrations}
onClick={() => { void saveSelectedCollectorSettings() }}
>
</Button>
{selectedCollector?.is_custom && selectedCollectorConfig?.source_type === 'websocket' ? ( {selectedCollector?.is_custom && selectedCollectorConfig?.source_type === 'websocket' ? (
<> <>
<Button <Button
@@ -2863,6 +3068,109 @@ function Settings() {
const visibleTabItems = tabItems.filter((item) => settingsTabKeysByMode[pageMode].has(String(item.key))) const visibleTabItems = tabItems.filter((item) => settingsTabKeysByMode[pageMode].has(String(item.key)))
const renderTabActions = () => {
if (activeSettingsTab === 'display') {
return (
<Space size={8} className="settings-tab-actions">
<Tooltip title="保存">
<Button type="primary" icon={<CheckOutlined />} onClick={() => systemForm.submit()} aria-label="保存" />
</Tooltip>
</Space>
)
}
if (activeSettingsTab === 'notifications') {
return (
<Space size={8} className="settings-tab-actions">
<Tooltip title="保存">
<Button type="primary" icon={<CheckOutlined />} onClick={() => notificationForm.submit()} aria-label="保存" />
</Tooltip>
</Space>
)
}
if (activeSettingsTab === 'security') {
return (
<Space size={8} className="settings-tab-actions">
<Tooltip title="保存">
<Button type="primary" icon={<CheckOutlined />} onClick={() => securityForm.submit()} aria-label="保存" />
</Tooltip>
</Space>
)
}
if (activeSettingsTab === 'earth_assets') {
return (
<Space size={8} className="settings-tab-actions">
<Tooltip title="保存">
<Button
type="primary"
icon={<CheckOutlined />}
loading={savingEarthBoundaryConfig}
onClick={() => { void saveEarthBoundaryConfig() }}
aria-label="保存"
/>
</Tooltip>
<Tooltip title="刷新">
<Button icon={<ReloadOutlined />} onClick={() => { void fetchSettings() }} aria-label="刷新" />
</Tooltip>
</Space>
)
}
if (activeSettingsTab === 'brand') {
return (
<Space size={8} className="settings-tab-actions">
<Tooltip title="保存">
<Button
type="primary"
icon={<CheckOutlined />}
loading={savingEarthBrand}
onClick={() => { void saveEarthBrandSettings() }}
aria-label="保存"
/>
</Tooltip>
<Tooltip title="重置为缺省">
<Button
icon={<ReloadOutlined />}
loading={savingEarthBrand}
onClick={resetEarthBrandSettings}
aria-label="重置为缺省"
/>
</Tooltip>
</Space>
)
}
if (activeSettingsTab === 'ai') {
return (
<Space size={8} className="settings-tab-actions">
<Tooltip title="保存">
<Button
type="primary"
icon={<CheckOutlined />}
loading={savingIntegrations}
onClick={() => integrationForm.submit()}
aria-label="保存"
/>
</Tooltip>
</Space>
)
}
if (activeSettingsTab === 'collector_credentials') {
return (
<Space size={8} className="settings-tab-actions">
<Tooltip title="保存">
<Button
type="primary"
icon={<CheckOutlined />}
loading={savingCollectorConfig || savingIntegrations}
disabled={!selectedCollector}
onClick={() => { void saveSelectedCollectorSettings() }}
aria-label="保存"
/>
</Tooltip>
</Space>
)
}
return null
}
return ( return (
<AppLayout> <AppLayout>
<div className="page-shell settings-shell"> <div className="page-shell settings-shell">
@@ -2878,6 +3186,7 @@ function Settings() {
className="settings-tabs" className="settings-tabs"
activeKey={activeSettingsTab} activeKey={activeSettingsTab}
onChange={updateSettingsTab} onChange={updateSettingsTab}
tabBarExtraContent={renderTabActions()}
items={visibleTabItems} items={visibleTabItems}
/> />
</div> </div>

View File

@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Alert, Button, Card, Form, Input, InputNumber, Modal, Space, Switch, Typography, message } from 'antd' import { CheckOutlined } from '@ant-design/icons'
import { Alert, Button, Card, Form, Input, InputNumber, Modal, Space, Switch, Tooltip, Typography, message } from 'antd'
import axios from 'axios' import axios from 'axios'
interface SecretStatus { interface SecretStatus {
@@ -180,7 +181,9 @@ export default function SmtpPanel() {
STARTTLS TLS 587 465 STARTTLS TLS 587 465
</Typography.Paragraph> </Typography.Paragraph>
<Space> <Space>
<Button type="primary" htmlType="submit" loading={saving}> SMTP </Button> <Tooltip title="保存">
<Button type="primary" htmlType="submit" icon={<CheckOutlined />} loading={saving} aria-label="保存" />
</Tooltip>
<Button onClick={() => setTestOpen(true)}></Button> <Button onClick={() => setTestOpen(true)}></Button>
</Space> </Space>
</Form> </Form>

View File

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

2
uv.lock generated
View File

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