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

This commit is contained in:
linkong
2026-05-21 03:46:02 +08:00
parent a37d4b6289
commit 37e92e7572
21 changed files with 1418 additions and 65 deletions

View File

@@ -1 +1 @@
0.63.1 0.64.0

View File

@@ -9,13 +9,17 @@ from uuid import uuid4
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile, 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 delete, select, text from sqlalchemy import delete, func, 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.collected_data import CollectedData
from app.models.datasource import DataSource
from app.models.datasource_config import DataSourceConfig
from app.models.system_setting import SystemSetting from app.models.system_setting import SystemSetting
from app.models.user import User from app.models.user import User
from app.services.tv_streams import get_tv_settings_payload
from app.services.earth_boundaries import ( from app.services.earth_boundaries import (
EarthBoundaryBuildError, EarthBoundaryBuildError,
get_boundary_build_status, get_boundary_build_status,
@@ -31,6 +35,7 @@ REPO_ROOT = Path(__file__).resolve().parents[4]
EARTH_BRAND_ASSET_DIR = REPO_ROOT / "data" / "earth-brand" EARTH_BRAND_ASSET_DIR = REPO_ROOT / "data" / "earth-brand"
EARTH_BRAND_ASSET_URL_PREFIX = "/earth-brand-assets" EARTH_BRAND_ASSET_URL_PREFIX = "/earth-brand-assets"
EARTH_BRAND_CATEGORY = "earth_brand" EARTH_BRAND_CATEGORY = "earth_brand"
EARTH_ABOUT_CATEGORY = "earth_about"
MAX_EARTH_BRAND_ASSET_BYTES = 3 * 1024 * 1024 MAX_EARTH_BRAND_ASSET_BYTES = 3 * 1024 * 1024
ALLOWED_EARTH_BRAND_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".svg"} ALLOWED_EARTH_BRAND_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".svg"}
@@ -44,6 +49,19 @@ DEFAULT_EARTH_BRAND = {
"title_alt": "智能星球计划", "title_alt": "智能星球计划",
} }
DEFAULT_EARTH_ABOUT = {
"logo_src": "/earth/assets/brand/lim-logo.png",
"kicker": "About",
"title": "智能星球计划",
"version": "v0.64.0",
"description": "面向临空场景下的智能媒体研究、全球态势感知与多源开放数据巡航,提供可视化观测、事件聚合与交互式探索能力。",
"meta": [
{"label": "出品方", "value": "浙江大学临空智能媒体研究院"},
{"label": "策划人", "value": "黄柳青"},
{"label": "产品兼开发者", "value": "钱坤、张鸽、齐鹏"},
],
}
class EarthBoundaryConfigPayload(BaseModel): class EarthBoundaryConfigPayload(BaseModel):
config: dict[str, Any] = Field(default_factory=dict) config: dict[str, Any] = Field(default_factory=dict)
@@ -59,6 +77,20 @@ class EarthBrandPayload(BaseModel):
title_alt: str = Field(default=DEFAULT_EARTH_BRAND["title_alt"], max_length=200) title_alt: str = Field(default=DEFAULT_EARTH_BRAND["title_alt"], max_length=200)
class EarthAboutMetaItem(BaseModel):
label: str = Field(default="", max_length=80)
value: str = Field(default="", max_length=240)
class EarthAboutPayload(BaseModel):
logo_src: str = Field(default=DEFAULT_EARTH_ABOUT["logo_src"], max_length=1000)
kicker: str = Field(default=DEFAULT_EARTH_ABOUT["kicker"], max_length=80)
title: str = Field(default=DEFAULT_EARTH_ABOUT["title"], max_length=160)
version: str = Field(default=DEFAULT_EARTH_ABOUT["version"], max_length=80)
description: str = Field(default=DEFAULT_EARTH_ABOUT["description"], max_length=800)
meta: list[EarthAboutMetaItem] = Field(default_factory=list)
def _normalize_earth_brand_payload(payload: dict[str, Any] | None) -> dict[str, str]: def _normalize_earth_brand_payload(payload: dict[str, Any] | None) -> dict[str, str]:
merged = DEFAULT_EARTH_BRAND.copy() merged = DEFAULT_EARTH_BRAND.copy()
if payload: if payload:
@@ -76,6 +108,40 @@ def _normalize_earth_brand_payload(payload: dict[str, Any] | None) -> dict[str,
return merged return merged
def _normalize_earth_about_payload(payload: dict[str, Any] | None) -> dict[str, Any]:
merged: dict[str, Any] = {
key: value
for key, value in DEFAULT_EARTH_ABOUT.items()
if key != "meta"
}
raw_meta = DEFAULT_EARTH_ABOUT["meta"]
if payload:
for key in ("logo_src", "kicker", "title", "version", "description"):
value = payload.get(key)
if value is not None:
merged[key] = str(value).strip()
raw_meta = payload.get("meta") if isinstance(payload.get("meta"), list) else raw_meta
for key, default_value in DEFAULT_EARTH_ABOUT.items():
if key == "meta":
continue
if not merged.get(key):
merged[key] = default_value
normalized_meta: list[dict[str, str]] = []
for item in raw_meta:
if not isinstance(item, dict):
continue
label = str(item.get("label") or "").strip()
value = str(item.get("value") or "").strip()
if label or value:
normalized_meta.append({"label": label, "value": value})
if not normalized_meta:
normalized_meta = [dict(item) for item in DEFAULT_EARTH_ABOUT["meta"]]
merged["meta"] = normalized_meta
return merged
async def _get_earth_brand_record(db: AsyncSession) -> SystemSetting | None: async def _get_earth_brand_record(db: AsyncSession) -> SystemSetting | None:
result = await db.execute( result = await db.execute(
select(SystemSetting).where(SystemSetting.category == EARTH_BRAND_CATEGORY) select(SystemSetting).where(SystemSetting.category == EARTH_BRAND_CATEGORY)
@@ -91,6 +157,56 @@ async def _get_earth_brand_payload(db: AsyncSession) -> dict[str, Any]:
} }
async def _get_earth_about_record(db: AsyncSession) -> SystemSetting | None:
result = await db.execute(
select(SystemSetting).where(SystemSetting.category == EARTH_ABOUT_CATEGORY)
)
return result.scalar_one_or_none()
async def _get_earth_about_payload(db: AsyncSession) -> dict[str, Any]:
record = await _get_earth_about_record(db)
return {
"about": _normalize_earth_about_payload(record.payload if record else None),
"is_default": record is None,
}
async def _get_optional_current_user(
credentials: HTTPAuthorizationCredentials | None = Depends(optional_bearer),
db: AsyncSession = Depends(get_db),
) -> User | None:
if credentials is None:
return None
token = credentials.credentials
if redis_client.sismember("blacklisted_tokens", token):
return None
payload = decode_token(token)
if payload is None or payload.get("type") != "access":
return None
user_id = payload.get("sub")
if user_id is None:
return None
result = await db.execute(
text(
"SELECT id, username, email, password_hash, role, is_active, gatekeeper_groups FROM users WHERE id = :id"
),
{"id": int(user_id)},
)
row = result.fetchone()
if row is None or not row[5]:
return None
user = User()
user.id = row[0]
user.username = row[1]
user.email = row[2]
user.password_hash = row[3]
user.role = row[4]
user.is_active = row[5]
user.gatekeeper_groups = row[6] or []
return user
@router.get("/brand") @router.get("/brand")
async def get_earth_brand(db: AsyncSession = Depends(get_db)): async def get_earth_brand(db: AsyncSession = Depends(get_db)):
return await _get_earth_brand_payload(db) return await _get_earth_brand_payload(db)
@@ -159,46 +275,100 @@ async def upload_earth_brand_asset(
return {"url": asset_url, "filename": safe_name, "content_type": file.content_type} return {"url": asset_url, "filename": safe_name, "content_type": file.content_type}
@router.get("/about")
async def get_earth_about(db: AsyncSession = Depends(get_db)):
return await _get_earth_about_payload(db)
@router.put("/about")
async def update_earth_about(
payload: EarthAboutPayload,
_current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
normalized = _normalize_earth_about_payload(payload.model_dump())
record = await _get_earth_about_record(db)
if record is None:
record = SystemSetting(category=EARTH_ABOUT_CATEGORY, payload=normalized)
db.add(record)
else:
record.payload = normalized
await db.commit()
await db.refresh(record)
return {"status": "updated", "about": _normalize_earth_about_payload(record.payload), "is_default": False}
@router.delete("/about")
async def reset_earth_about(
_current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
await db.execute(delete(SystemSetting).where(SystemSetting.category == EARTH_ABOUT_CATEGORY))
await db.commit()
return {"status": "reset", "about": _normalize_earth_about_payload(None), "is_default": True}
@router.get("/oobe-status")
async def get_earth_oobe_status(
current_user: User | None = Depends(_get_optional_current_user),
db: AsyncSession = Depends(get_db),
):
current_count_result = await db.execute(
select(func.count(CollectedData.id)).where(CollectedData.is_current.is_(True))
)
current_record_count = int(current_count_result.scalar() or 0)
datasource_count_result = await db.execute(select(func.count(DataSource.id)))
datasource_count = int(datasource_count_result.scalar() or 0)
active_datasource_count_result = await db.execute(
select(func.count(DataSource.id)).where(DataSource.is_active.is_(True))
)
active_datasource_count = int(active_datasource_count_result.scalar() or 0)
config_result = await db.execute(select(func.count(DataSourceConfig.id)))
custom_config_count = int(config_result.scalar() or 0)
tv_payload = await get_tv_settings_payload(db)
tv_sources = tv_payload.get("sources") if isinstance(tv_payload, dict) else []
tv_source_count = len(tv_sources) if isinstance(tv_sources, list) else 0
boundary_status = get_boundary_status()
has_core_layers = bool(boundary_status.get("ready") or boundary_status.get("available") or boundary_status.get("status") in {"ready", "built", "ok"})
has_collected_data = current_record_count > 0
ready = has_collected_data
suggestions: list[str] = []
if not current_user:
suggestions.append("登录控制台")
if not has_collected_data:
suggestions.append("触发数据源采集")
if not custom_config_count:
suggestions.append("确认采集器配置")
if not has_core_layers:
suggestions.append("构建或启用 Earth 图层")
return {
"ready": ready,
"authenticated": current_user is not None,
"needs_login": current_user is None and not ready,
"has_collected_data": has_collected_data,
"has_tv_sources": tv_source_count > 0,
"has_core_layers": has_core_layers,
"current_record_count": current_record_count,
"datasource_count": datasource_count,
"active_datasource_count": active_datasource_count,
"custom_config_count": custom_config_count,
"tv_source_count": tv_source_count,
"suggestions": suggestions,
"login_url": "/login?next=/datasources",
"datasources_url": "/datasources",
"collection_url": "/collection-management",
}
@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()
async def _get_optional_current_user(
credentials: HTTPAuthorizationCredentials | None = Depends(optional_bearer),
db: AsyncSession = Depends(get_db),
) -> User | None:
if credentials is None:
return None
token = credentials.credentials
if redis_client.sismember("blacklisted_tokens", token):
return None
payload = decode_token(token)
if payload is None or payload.get("type") != "access":
return None
user_id = payload.get("sub")
if user_id is None:
return None
result = await db.execute(
text(
"SELECT id, username, email, password_hash, role, is_active, gatekeeper_groups FROM users WHERE id = :id"
),
{"id": int(user_id)},
)
row = result.fetchone()
if row is None or not row[5]:
return None
user = User()
user.id = row[0]
user.username = row[1]
user.email = row[2]
user.password_hash = row[3]
user.role = row[4]
user.is_active = row[5]
user.gatekeeper_groups = row[6] or []
return user
def _is_loopback_request(request: Request) -> bool: def _is_loopback_request(request: Request) -> bool:
host = request.client.host if request.client else "" host = request.client.host if request.client else ""
return host in {"127.0.0.1", "::1", "localhost"} or host.startswith("127.") return host in {"127.0.0.1", "::1", "localhost"} or host.startswith("127.")

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.64.0] — 2026-05-21
Released: 2026-05-21
### Highlights
- 新增 Earth 首次初始化 OOBE由后端真实采集状态决定是否显示避免 localStorage 清空后误弹,并提供桌面毛玻璃引导与移动端 bottom sheet。
- 数据源页新增下载列表式采集队列,把单源、批量和触发全部的任务进度统一展示,并支持失败重试与跳转详情。
- Earth 内容新增“关于”配置接口和后台 tabEarth 设置页 About 卡片改为运行时读取配置并带默认 fallback。
### Added / Fixed / Improved
- 新增 `/api/v1/earth/oobe-status``/api/v1/earth/about` GET/PUT/DELETE并让 Earth 前端加载 `about.js``oobe.js`
- Admin Next 数据源队列优先消费 `datasource_tasks` WebSocket断线时轮询 `/datasources/{id}/task-status`,刷新后只恢复后端仍在运行的真实任务。
- Admin Next 深色主题滑块补齐 Docs 同款 dark token侧栏主题控件在 dark 模式下不再保持浅色底座。
- 用户手册、快速开始、Earth 前端上下文和 Admin 前端上下文同步记录 OOBE、采集队列、About 配置与主题滑块行为。
---
## [0.63.1] — 2026-05-21 ## [0.63.1] — 2026-05-21
Released: 2026-05-21 Released: 2026-05-21

View File

@@ -151,9 +151,14 @@ Each module is responsible for its own:
`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. `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.
`about.js` manages the About card inside Earth settings. Frontend defaults remain as a fallback, while runtime content is loaded from `/api/v1/earth/about`. If the request fails or fields are missing, the renderer must fall back per field so the settings page never renders an empty card. Admin Next exposes an Earth Content `About` tab; saving uses `PUT /api/v1/earth/about`, and restoring defaults uses `DELETE /api/v1/earth/about`.
`oobe.js` manages the first-run Earth initialization guide. OOBE visibility must be driven by `/api/v1/earth/oobe-status` and its `ready` field, not by `localStorage`. `localStorage` may only store a short-lived "skip on this browser" flag; if the backend reports `ready: true`, logout, cleared browser storage, or a different browser must not show OOBE again. Desktop uses a dark starfield scrim and glass startup panel, while mobile uses a bottom sheet and respects `prefers-reduced-motion`.
The Admin Next Earth Content page must preserve runtime semantics: The Admin Next Earth Content page must preserve runtime semantics:
- `Brand`: brand preview should use the same dark starfield background, size, spacing, logo/title rendering, and text fallback as the Earth HUD top-left brand block, not a generic form preview. - `Brand`: brand preview should use the same dark starfield background, size, spacing, logo/title rendering, and text fallback as the Earth HUD top-left brand block, not a generic form preview.
- `About`: configures the About card in Earth settings, including logo, kicker, title, version, description, and metadata items. Earth runtime reads `/earth/about` and falls back to defaults on failure.
- `Boundary Precision`: build boundary, refresh status, and restore defaults belong inside this section, not in the global page toolbar. - `Boundary Precision`: build boundary, refresh status, and restore defaults belong inside this section, not in the global page toolbar.
- `TV`: the list distinguishes built-in, collected, and custom sources. Card state represents enabled, disabled, draft, or error. Built-in sources cannot be deleted; collected and custom sources can. A new live source only enters draft state after the plus button is clicked; save persists it into the list, while cancel destroys the draft. - `TV`: the list distinguishes built-in, collected, and custom sources. Card state represents enabled, disabled, draft, or error. Built-in sources cannot be deleted; collected and custom sources can. A new live source only enters draft state after the plus button is clicked; save persists it into the list, while cancel destroys the draft.
- `Basemap`, `Layer Resources`, `3D Models`, and `News Anchor Strategy`: if backend capability is not available yet, the console should show an explicit pending state instead of mixing those items into TV or brand configuration. - `Basemap`, `Layer Resources`, `3D Models`, and `News Anchor Strategy`: if backend capability is not available yet, the console should show an explicit pending state instead of mixing those items into TV or brand configuration.

View File

@@ -110,6 +110,26 @@ Multi-tab pages are currently coordinated by [PlainResourcePages.tsx](/home/ray/
This keeps Earth, AI, collection management, and other multi-section pages from flooding backend APIs on cold start while preserving a fast cached tab-switching experience. Full health checks should use backend health endpoints or explicit refresh flows rather than relying on page initialization to touch every business endpoint. This keeps Earth, AI, collection management, and other multi-section pages from flooding backend APIs on cold start while preserving a fast cached tab-switching experience. Full health checks should use backend health endpoints or explicit refresh flows rather than relying on page initialization to touch every business endpoint.
## Datasource Collection Queue
The Admin Next datasource page routes single-source trigger, batch trigger, and trigger-all into a browser-download-list style collection queue:
- Queue state is managed by [PlainResourcePages.tsx](/home/ray/dev/linkong/planet/frontend/src/admin-next/pages/PlainResourcePages.tsx). It is a current-session visibility layer and does not fake task history in `localStorage`.
- Progress first consumes the `/ws` `datasource_tasks` channel. If the socket is unavailable or stale, the page polls `/api/v1/datasources/{id}/task-status`.
- Trigger responses immediately insert `triggered`, `skipped`, and `failed` items. After a refresh, the queue restores only real backend rows that are still `running`, `pending`, or `queued`.
- The page shows a compact progress bar and counts above the table. The "view queue" action opens a bottom panel grouped by running, failed, completed, and skipped.
- Queue rows can jump to the datasource detail panel, and failed rows can retry. The detail panel's task summary only reports the selected source's latest task; it does not save configuration.
This queue is a user-perception layer. Backend task status remains the only source of truth for running, completion, failure, and skipped decisions.
## Admin Next Theme Slider
The Admin Next sidebar theme switcher still reuses shared [SegmentedControl.tsx](/home/ray/dev/linkong/planet/frontend/src/components/SegmentedControl/SegmentedControl.tsx), while [styles.css](/home/ray/dev/linkong/planet/frontend/src/admin-next/styles.css) overrides the segment variables by `data-theme`:
- Light mode uses `--d-segment-bg: #eef3f9`, a white slider, and a light external shadow.
- Dark mode uses the same dark base, `#202938` slider, and dark external shadow semantics as Docs.
- Product code only hides the text label and keeps icon + tooltip behavior; it should not recreate the private slider DOM.
## Current Shared Components ## Current Shared Components
### 1. `Scrollbar` ### 1. `Scrollbar`

View File

@@ -188,19 +188,22 @@ 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. - **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.
- **About**: manages the About card shown in Earth settings, including logo, kicker, title, version, description, and metadata.
- **TV Livestream**: manages sources shown in the Earth media panel. - **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.
The Earth page settings gear also includes Boundary Precision. Switching to High Precision starts a local background download/build, like a game update package, when no high-precision asset exists yet. Progress is shown as a percentage, and the result applies automatically after success without a page reload. Switching back to Low Precision only changes the local display preference. The Earth page settings gear also includes Boundary Precision. Switching to High Precision starts a local background download/build, like a game update package, when no high-precision asset exists yet. Progress is shown as a percentage, and the result applies automatically after success without a page reload. Switching back to Low Precision only changes the local display preference.
If the backend decides Earth is not initialized yet, the first visit to `/earth` shows a glassy startup guide that points the user to sign in and collect data. This is based on backend data state; once the system has collected data, clearing browser storage does not make the guide reappear.
### Collection Management ### Collection Management
`/collection-management` is also under Operations and Configuration and owns the collection lifecycle: `/collection-management` is also under Operations and Configuration and owns the collection lifecycle:
- **Collectors**: endpoint, headers, credentials, timeout, retry, and connection checks. - **Collectors**: endpoint, headers, credentials, timeout, retry, and connection checks.
- **Collection Scheduling**: the existing scheduling configuration. - **Collection Scheduling**: the existing scheduling configuration.
- **Collection History / Snapshots**: a placeholder for future collection task, snapshot, and collected-data browsing. - **Collection History / Snapshots**: groups snapshots by datasource and lets the detail panel switch versions through a Time Capsule selector.
### SMTP Email Settings ### SMTP Email Settings
@@ -230,7 +233,7 @@ To let a regular user read developer or operations docs, add `docs_developer` or
## Data Exploration ## Data Exploration
- `/datasources`: source directory. The `Collection Tasks` tab is for one-shot, scheduled, and finite collectors; it can be filtered by product domain, layer/module, enabled state, last run status, whether collected records exist, and search text. Selecting rows triggers only those sources; with no selected rows, `Collect current filter` triggers the filtered scope. The `Realtime Streams` tab is for AISStream / WebSocket long connections and shows connection health, stored totals, time-window counters, and Start / Stop / Reconnect actions. Clicking a name opens an info drawer showing endpoint, headers, base config, and built-in flag; endpoint/credentials editing happens at `/collection-management -> Collectors`. The `Collecting N` tag under the overall progress can be clicked to expand the current collection task list - `/datasources`: source directory. The `Built-in Sources` tab can be filtered by product domain, layer/module, enabled state, last run status, whether collected records exist, and search text. Selecting rows triggers only those sources; `Trigger All` enters a browser-download-list style collection queue. The queue bar shows total progress plus running, completed, failed, and skipped counts; `View Queue` opens the bottom panel, failed rows can retry, and completed rows can jump to detail. `Realtime Sources` is for AISStream / WebSocket long connections and shows connection health, stored totals, time-window counters, and Start / Stop / Reconnect actions. Endpoint/credential/header editing happens at `/collection-management -> Collectors`.
- `/data`: collected data table — used to verify "did data arrive", "is the freshness right", "does a source emit valid records" - `/data`: collected data table — used to verify "did data arrive", "is the freshness right", "does a source emit valid records"
- `/bgp`: BGP detail page with list + detail + analysis; complements the BGP layer on Earth - `/bgp`: BGP detail page with list + detail + analysis; complements the BGP layer on Earth
- `/alerts/system`, `/alerts/bgp`, `/alerts/situational`: system, BGP, and situational alerts - `/alerts/system`, `/alerts/bgp`, `/alerts/situational`: system, BGP, and situational alerts

View File

@@ -34,7 +34,7 @@ After landing on the `/admin` dashboard, here's a recommended walk-through:
1. `/collection-management?tab=collector_credentials`: pick a collector and click the plug icon to test connectivity. Free collectors (e.g. open BGP) usually work right away; credential-bearing ones like `AISStream` or `BarentsWatch` need an API key / client secret first 1. `/collection-management?tab=collector_credentials`: pick a collector and click the plug icon to test connectivity. Free collectors (e.g. open BGP) usually work right away; credential-bearing ones like `AISStream` or `BarentsWatch` need an API key / client secret first
2. `/ai?tab=providers`: fill an LLM provider (e.g. `minimax` / `openai`), model, base URL, API key, and click the plug at the end of the base URL to test. WebSearch / OCR tools are optional 2. `/ai?tab=providers`: fill an LLM provider (e.g. `minimax` / `openai`), model, base URL, API key, and click the plug at the end of the base URL to test. WebSearch / OCR tools are optional
3. `/datasources` or `/data`: check whether collectors have produced data. Use `/datasources -> Collection Tasks` for finite collectors, and `/datasources -> Realtime Streams` for AISStream / WebSocket health and counters 3. `/datasources` or `/data`: check whether collectors have produced data. Use `/datasources -> Built-in Sources` for finite collectors and open the collection queue after triggering; use `/datasources -> Realtime Sources` for AISStream / WebSocket health and counters
4. `/alerts/system`: verify system alerts look right 4. `/alerts/system`: verify system alerts look right
5. `/users` (super_admin only): open accounts for teammates or adjust their groups 5. `/users` (super_admin only): open accounts for teammates or adjust their groups
@@ -42,6 +42,8 @@ After landing on the `/admin` dashboard, here's a recommended walk-through:
Visit `/earth`. This is a public page — no login required. Visit `/earth`. This is a public page — no login required.
If the system has no collected data yet, Earth shows an initialization guide that points you to sign in and trigger collection. This guide is backend-state driven, so clearing browser storage does not make it appear once the system is ready.
Once in, verify: Once in, verify:
- The globe renders, and the right-side layer panel can toggle layers - The globe renders, and the right-side layer panel can toggle layers

View File

@@ -162,9 +162,14 @@ Earth 地表是多层近似同心球,不是单一 mesh。`earth.js` 的基座
`brand.js` 管理 Earth HUD 品牌资源。默认品牌来自静态资源,运行时覆盖值来自 `/api/v1/earth/brand`,上传的图片通过 `/earth-brand-assets/...` 读取。前端必须把 logo/title 图片和文本 fallback 分开处理:图片加载失败时显示文本标题,文本字段为空时使用后端默认值,避免 HUD 品牌区空白。控制台的 Earth 内容页负责保存和重置品牌配置Earth 前端只消费结果。 `brand.js` 管理 Earth HUD 品牌资源。默认品牌来自静态资源,运行时覆盖值来自 `/api/v1/earth/brand`,上传的图片通过 `/earth-brand-assets/...` 读取。前端必须把 logo/title 图片和文本 fallback 分开处理:图片加载失败时显示文本标题,文本字段为空时使用后端默认值,避免 HUD 品牌区空白。控制台的 Earth 内容页负责保存和重置品牌配置Earth 前端只消费结果。
`about.js` 管理 Earth 设置里的“关于”卡片。默认内容仍保留在前端作为兜底,运行时优先读取 `/api/v1/earth/about`。接口失败或字段缺失时必须回退默认值避免设置页出现空白。Admin Next 的 Earth 内容页提供“关于”tab保存走 `PUT /api/v1/earth/about`,恢复默认走 `DELETE /api/v1/earth/about`
`oobe.js` 管理 Earth 首次初始化引导。是否显示 OOBE 必须由 `/api/v1/earth/oobe-status``ready` 字段决定,不能依赖 `localStorage` 判断系统是否初始化。`localStorage` 只允许记录“本浏览器暂时跳过”的短时状态;如果后端已经认为 `ready: true`,退出登录、清空本地缓存或换浏览器都不应再次弹出 OOBE。桌面端使用深色星空遮罩和毛玻璃启动面板移动端改为底部 sheet并尊重 `prefers-reduced-motion`
Admin Next 的 Earth 内容页必须按运行时语义组织这些配置: Admin Next 的 Earth 内容页必须按运行时语义组织这些配置:
- `品牌标识`:品牌预览应使用与 Earth HUD 左上角一致的深色星空背景、尺寸、间距、logo/title 渲染和文本 fallback而不是普通表单预览。 - `品牌标识`:品牌预览应使用与 Earth HUD 左上角一致的深色星空背景、尺寸、间距、logo/title 渲染和文本 fallback而不是普通表单预览。
- `关于`:配置 Earth 设置里的 About 卡片,包括 logo、眉标、标题、版本、描述和元信息条目Earth 运行时从 `/earth/about` 读取,失败时回退默认内容。
- `国界精度`:构建边界、刷新状态和恢复默认属于这个分区内部动作,不应放在页面全局工具栏。 - `国界精度`:构建边界、刷新状态和恢复默认属于这个分区内部动作,不应放在页面全局工具栏。
- `电视直播`:列表同时区分内置源、采集源和自定义源;卡片状态表达启用、停用、新建或错误。内置源不能删除,采集源和自定义源可以删除。新增直播源在点击加号后才进入草稿状态,保存后固化到列表,取消则销毁草稿。 - `电视直播`:列表同时区分内置源、采集源和自定义源;卡片状态表达启用、停用、新建或错误。内置源不能删除,采集源和自定义源可以删除。新增直播源在点击加号后才进入草稿状态,保存后固化到列表,取消则销毁草稿。
- `底图资源``图层资源``3D 模型``新闻锚点策略`:如果后端能力未接入,控制台应明确显示待接入空态,不应混入 TV 或品牌配置。 - `底图资源``图层资源``3D 模型``新闻锚点策略`:如果后端能力未接入,控制台应明确显示待接入空态,不应混入 TV 或品牌配置。

View File

@@ -110,6 +110,26 @@ legacy 职责:
这样做是为了降低 Earth、AI、采集管理等多分区页面的冷启动压力同时保留 tab 数量、状态和用户切换后的缓存体验。需要全量健康巡检时应走后端健康接口或显式刷新流程,不要依赖页面初始化时顺手拉所有业务接口。 这样做是为了降低 Earth、AI、采集管理等多分区页面的冷启动压力同时保留 tab 数量、状态和用户切换后的缓存体验。需要全量健康巡检时应走后端健康接口或显式刷新流程,不要依赖页面初始化时顺手拉所有业务接口。
## 数据源采集队列
Admin Next 的数据源页把单源触发、批量触发和触发全部统一接入浏览器下载列表式采集队列:
- 队列状态由 [PlainResourcePages.tsx](/home/ray/dev/linkong/planet/frontend/src/admin-next/pages/PlainResourcePages.tsx) 管理,只保存当前会话中的可见任务,不用 `localStorage` 伪造历史。
- 任务进度优先消费 `/ws``datasource_tasks` channel如果 WebSocket 未连接或没有及时返回,则轮询 `/api/v1/datasources/{id}/task-status`
- 触发接口返回的 `triggered``skipped``failed` 会立即进入队列;刷新页面后只根据后端当前仍在 `running/pending/queued` 的数据源恢复队列。
- 页面上方只显示紧凑进度条和数量摘要;点击“查看队列”展开底部面板,按运行中、失败、完成、跳过分组。
- 队列项可以跳转到对应数据源详情,失败项可以重试。详情页内的“采集任务”摘要只展示当前数据源最近任务,不承担保存配置职责。
这个队列是用户感知层,不替代后端调度状态。后端仍然是任务是否运行、完成、失败或跳过的唯一事实来源。
## Admin Next 主题滑块
Admin Next 侧栏底部主题切换继续复用共享 [SegmentedControl.tsx](/home/ray/dev/linkong/planet/frontend/src/components/SegmentedControl/SegmentedControl.tsx),但主题变量在 [styles.css](/home/ray/dev/linkong/planet/frontend/src/admin-next/styles.css) 内跟随 `data-theme` 覆盖:
- light 下使用 `--d-segment-bg: #eef3f9`、白色 slider 和轻投影。
- dark 下使用与 Docs 一致的深色底座、`#202938` slider 和深色外投影。
- 业务侧只隐藏文字 label 并保留 icon + tooltip不重写 slider DOM。
## 当前共享组件 ## 当前共享组件
### 1. `Scrollbar` ### 1. `Scrollbar`

View File

@@ -191,19 +191,22 @@ Base URL 输入框尾端的插头图标会触发连接测试。测试通过会
`/earth-content` 位于控制台“运维与配置”下,面向 Earth 前端体验资源: `/earth-content` 位于控制台“运维与配置”下,面向 Earth 前端体验资源:
- **品牌资源**:维护 Earth HUD 使用的 logo、标题图、标题文本、副标题和描述上传的图片会保存为 Earth 品牌资产并立即供 Earth 页面读取。 - **品牌资源**:维护 Earth HUD 使用的 logo、标题图、标题文本、副标题和描述上传的图片会保存为 Earth 品牌资产并立即供 Earth 页面读取。
- **关于**:维护 Earth 设置面板里的关于卡片,包括 logo、眉标、标题、版本、描述和元信息。
- **电视直播**:维护 Earth 媒体面板里的直播源。 - **电视直播**:维护 Earth 媒体面板里的直播源。
- **国界精度**:查看当前国界 provider、低精 fallback、高精 PMTiles/manifest 状态,编辑本机源配置并手动构建。 - **国界精度**:查看当前国界 provider、低精 fallback、高精 PMTiles/manifest 状态,编辑本机源配置并手动构建。
- **地球底图**、**图层资源**、**三维素材**、**新闻锚点策略**:目前是待接入占位页,不展示假数据。 - **地球底图**、**图层资源**、**三维素材**、**新闻锚点策略**:目前是待接入占位页,不展示假数据。
Earth 页面工具栏齿轮中也有“国界精度”。切到“高精”时,如果本机尚未构建高精资产,会像游戏更新包一样启动后台下载/构建并显示百分比;构建成功后自动应用,无需刷新。切回“低精”只切换本机显示偏好,不重新下载。 Earth 页面工具栏齿轮中也有“国界精度”。切到“高精”时,如果本机尚未构建高精资产,会像游戏更新包一样启动后台下载/构建并显示百分比;构建成功后自动应用,无需刷新。切回“低精”只切换本机显示偏好,不重新下载。
如果后端判断 Earth 尚未初始化,首次进入 `/earth` 会出现毛玻璃引导,提示登录控制台并采集数据。这个判断来自后端真实数据状态;如果系统已经有已采集数据,清空浏览器缓存也不会重新弹出。
### 采集管理 ### 采集管理
`/collection-management` 位于控制台“运维与配置”下,面向采集生命周期: `/collection-management` 位于控制台“运维与配置”下,面向采集生命周期:
- **采集器**:维护 endpoint、请求头、凭证、timeout、retry并运行连接检查。 - **采集器**:维护 endpoint、请求头、凭证、timeout、retry并运行连接检查。
- **采集调度**:维护原有调度相关设置。 - **采集调度**:维护原有调度相关设置。
- **采集历史 / 快照**当前是待接入占位页,后续承载 collection task、snapshot、collected data 浏览能力 - **采集历史 / 快照**按数据源聚合历史快照,详情页可用 Time Capsule 下拉切换不同版本
### SMTP 邮件设置 ### SMTP 邮件设置
@@ -233,7 +236,7 @@ Earth 页面工具栏齿轮中也有“国界精度”。切到“高精”时
## 数据探索 ## 数据探索
- `/datasources`:数据源目录。`采集任务` tab 面向一次性/定时采集器,可以按产品域、层级、启用状态、最近执行状态、是否已有采集数据和关键词筛选;勾选多行后可批量采集选中项,未勾选时“一键采集”触发当前筛选范围`实时` tab 面向 AISStream / WebSocket 长连接,展示连接健康、累计入库、时间窗统计和启动 / 停止 / 重连操作。点击名称打开信息抽屉查看 endpoint、请求头、基础配置和是否内置接口、凭证、请求头的编辑统一在 `/collection-management` 的"采集器"。总体进度下方的 `采集中 N` 标签可点击,展开当前采集任务列表 - `/datasources`:数据源目录。`内置源` 支持按产品域、层级、启用状态、最近执行状态、是否已有采集数据和关键词筛选;勾选多行后可批量触发,`触发全部` 会进入类似浏览器下载列表的采集队列。队列上方显示总进度、运行中、完成、失败和跳过数量,`查看队列` 可展开底部面板,失败项可重试,完成项可跳到详情`实时` 面向 AISStream / WebSocket 长连接,展示连接健康、累计入库、时间窗统计和启动 / 停止 / 重连操作。接口、凭证、请求头的编辑统一在 `/collection-management` 的"采集器"。
- `/data`:采集后数据表,适合排查"数据是否已经进入系统"、"更新时间是否符合预期"、"某个数据源是否产出有效记录" - `/data`:采集后数据表,适合排查"数据是否已经进入系统"、"更新时间是否符合预期"、"某个数据源是否产出有效记录"
- `/bgp`BGP 专题页面,列表 + 详情 + 研判,与 Earth 的 BGP 图层互补 - `/bgp`BGP 专题页面,列表 + 详情 + 研判,与 Earth 的 BGP 图层互补
- `/alerts/system``/alerts/bgp``/alerts/situational`系统、BGP、态势告警 - `/alerts/system``/alerts/bgp``/alerts/situational`系统、BGP、态势告警

View File

@@ -34,7 +34,7 @@
1. `/collection-management?tab=collector_credentials`:选一个采集器,点插头图标做连接测试。免费 collector开源 BGP 等)通常直接可用;像 `AISStream``BarentsWatch` 这类需要凭证的,需要先填 API Key/Client Secret 1. `/collection-management?tab=collector_credentials`:选一个采集器,点插头图标做连接测试。免费 collector开源 BGP 等)通常直接可用;像 `AISStream``BarentsWatch` 这类需要凭证的,需要先填 API Key/Client Secret
2. `/ai?tab=providers`:填一个 LLM provider例如 `minimax` / `openai`、模型名、Base URL、API Key点 Base URL 末端的插头测试连接。WebSearch / OCR 工具可选 2. `/ai?tab=providers`:填一个 LLM provider例如 `minimax` / `openai`、模型名、Base URL、API Key点 Base URL 末端的插头测试连接。WebSearch / OCR 工具可选
3. `/datasources``/data`:看采集器是否已经产出数据。有限采集器看 `/datasources -> 采集任务`AISStream / WebSocket 长连接看 `/datasources -> 实时` 的健康状态和计数 3. `/datasources``/data`:看采集器是否已经产出数据。有限采集器看 `/datasources -> 内置源`,触发后可展开采集队列查看进度;AISStream / WebSocket 长连接看 `/datasources -> 实时` 的健康状态和计数
4. `/alerts/system`:看系统告警是否正常 4. `/alerts/system`:看系统告警是否正常
5. `/users`(仅 `super_admin`):根据需要给同事开账号或调权限组 5. `/users`(仅 `super_admin`):根据需要给同事开账号或调权限组
@@ -42,6 +42,8 @@
访问 `/earth`,公开页面,不需要登录。 访问 `/earth`,公开页面,不需要登录。
如果系统还没有已采集数据Earth 会显示初始化引导,提示登录控制台并触发采集。这个引导由后端状态决定,不会因为清空浏览器缓存而误判。
进入后建议确认: 进入后建议确认:
- 地球正常显示,右侧图层面板可以打开/关闭 - 地球正常显示,右侧图层面板可以打开/关闭

View File

@@ -16,12 +16,13 @@
## Current Version ## Current Version
- `main` 当前主线历史推导到:`0.16.5` - `main` 当前主线历史推导到:`0.16.5`
- `dev` 当前开发分支历史推导到:`0.63.1` - `dev` 当前开发分支历史推导到:`0.64.0`
## Timeline ## Timeline
| Version | Type | Branch | Commit | Summary | | Version | Type | Branch | Commit | Summary |
| --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- |
| `0.64.0` | feature | `dev` | `pending` | Earth 新增后端状态驱动 OOBE 与 About 配置Admin Next 数据源页新增采集队列,补齐深色主题滑块和用户/技术文档 |
| `0.63.1` | bugfix | `dev` | `pending` | 补上被 `lib/` ignore 规则漏提交的 Admin Next utility module修复新设备初始化后 AdminNextRoutes 动态导入 500 | | `0.63.1` | bugfix | `dev` | `pending` | 补上被 `lib/` ignore 规则漏提交的 Admin Next utility module修复新设备初始化后 AdminNextRoutes 动态导入 500 |
| `0.63.0` | feature | `dev` | `pending` | `planet.sh` 新增空项目 `init` 和破坏性 `destroy` 入口,补齐服务运行中初始化保护、状态日志时间戳和 README 快速启动说明 | | `0.63.0` | feature | `dev` | `pending` | `planet.sh` 新增空项目 `init` 和破坏性 `destroy` 入口,补齐服务运行中初始化保护、状态日志时间戳和 README 快速启动说明 |
| `0.62.0` | feature | `dev` | `pending` | Admin Next 转正并迁移旧 AntD 到 `/legacy/admin/*`,补齐采集/AI Provider/Earth/日志/BGP/告警工作台,抽出 Tactile UI 组件库并优化懒加载、Markdown 和移动端详情 | | `0.62.0` | feature | `dev` | `pending` | Admin Next 转正并迁移旧 AntD 到 `/legacy/admin/*`,补齐采集/AI Provider/Earth/日志/BGP/告警工作台,抽出 Tactile UI 组件库并优化懒加载、Markdown 和移动端详情 |

View File

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

View File

@@ -1441,6 +1441,11 @@
gap: 8px; gap: 8px;
} }
.earth-about-meta-list {
display: grid;
gap: 8px;
}
.earth-about-meta { .earth-about-meta {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -1460,6 +1465,311 @@
line-height: 1.45; line-height: 1.45;
} }
.earth-oobe {
position: fixed;
inset: 0;
z-index: 12000;
display: grid;
place-items: start center;
padding-top: min(16vh, 150px);
color: rgba(235, 245, 255, 0.96);
pointer-events: auto;
}
.earth-oobe__scrim {
position: absolute;
inset: 0;
background:
radial-gradient(circle at 50% 28%, rgba(73, 136, 220, 0.18), transparent 38%),
rgba(2, 7, 16, 0.58);
animation: earth-oobe-fade-in 180ms ease-out both;
}
.earth-oobe__panel {
position: relative;
z-index: 1;
width: min(620px, calc(100vw - 36px));
overflow: hidden;
border: 1px solid rgba(132, 176, 236, 0.32);
border-radius: 22px;
background:
linear-gradient(135deg, rgba(11, 24, 45, 0.88), rgba(7, 14, 27, 0.76)),
rgba(8, 18, 34, 0.7);
box-shadow:
0 28px 80px rgba(0, 0, 0, 0.42),
inset 0 1px 0 rgba(255, 255, 255, 0.14);
backdrop-filter: blur(22px) saturate(1.28);
padding: 28px;
transform-origin: 50% 40%;
animation: earth-oobe-panel-in 520ms cubic-bezier(0.19, 1, 0.22, 1) both;
}
.earth-oobe__panel::before {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
background:
linear-gradient(90deg, transparent, rgba(125, 194, 255, 0.16), transparent),
radial-gradient(circle at 88% 0%, rgba(84, 142, 255, 0.2), transparent 30%);
opacity: 0.82;
}
.earth-oobe__scan {
position: absolute;
left: -35%;
top: 0;
width: 34%;
height: 2px;
background: linear-gradient(90deg, transparent, rgba(135, 211, 255, 0.9), transparent);
box-shadow: 0 0 22px rgba(86, 176, 255, 0.65);
animation: earth-oobe-scan 2.8s ease-in-out infinite;
}
.earth-oobe__close {
position: absolute;
right: 16px;
top: 14px;
width: 32px;
height: 32px;
border: 1px solid rgba(151, 190, 238, 0.28);
border-radius: 10px;
background: rgba(12, 24, 42, 0.58);
color: rgba(226, 240, 255, 0.86);
box-shadow: 0 8px 22px rgba(0, 0, 0, 0.28);
cursor: pointer;
}
.earth-oobe__eyebrow,
.earth-oobe h2,
.earth-oobe__subtitle,
.earth-oobe__steps,
.earth-oobe__stats,
.earth-oobe__actions {
position: relative;
z-index: 1;
}
.earth-oobe__eyebrow {
color: rgba(125, 204, 255, 0.82);
font-size: 0.72rem;
font-weight: 900;
letter-spacing: 0.18em;
text-transform: uppercase;
}
.earth-oobe h2 {
margin: 10px 0;
color: #f7fbff;
font-size: clamp(1.7rem, 3vw, 2.4rem);
line-height: 1.05;
}
.earth-oobe__subtitle {
max-width: 520px;
margin: 0;
color: rgba(190, 211, 235, 0.88);
line-height: 1.7;
}
.earth-oobe__steps {
display: grid;
gap: 10px;
margin: 22px 0;
padding: 0;
list-style: none;
}
.earth-oobe__step {
display: flex;
align-items: center;
gap: 10px;
min-height: 28px;
color: rgba(222, 235, 250, 0.9);
animation: earth-oobe-step-in 360ms ease-out both;
animation-delay: calc(180ms + var(--step-index, 0) * 80ms);
}
.earth-oobe__step-dot {
width: 10px;
height: 10px;
border-radius: 999px;
background: #4ca3ff;
box-shadow: 0 0 18px rgba(76, 163, 255, 0.72);
animation: earth-oobe-dot-pulse 2.4s ease-in-out infinite;
}
.earth-oobe__step--done .earth-oobe__step-dot {
background: #36d399;
box-shadow: 0 0 18px rgba(54, 211, 153, 0.68);
}
.earth-oobe__step--warn .earth-oobe__step-dot {
background: #f6b73c;
box-shadow: 0 0 18px rgba(246, 183, 60, 0.62);
}
.earth-oobe__stats {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.earth-oobe__stats span {
border: 1px solid rgba(142, 178, 225, 0.22);
border-radius: 12px;
background: rgba(7, 15, 29, 0.46);
padding: 10px 12px;
color: rgba(180, 203, 229, 0.82);
font-size: 0.82rem;
}
.earth-oobe__stats strong {
display: block;
color: #ffffff;
font-size: 1.28rem;
line-height: 1.1;
}
.earth-oobe__actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 22px;
}
.earth-oobe__primary,
.earth-oobe__secondary,
.earth-oobe__ghost {
min-height: 38px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 12px;
padding: 0 16px;
font: inherit;
font-weight: 850;
text-decoration: none;
cursor: pointer;
}
.earth-oobe__primary {
border: 1px solid rgba(88, 164, 255, 0.8);
background: linear-gradient(180deg, #4e9eff, #1f67e8);
color: #ffffff;
box-shadow: 0 12px 28px rgba(30, 103, 232, 0.34);
}
.earth-oobe__secondary,
.earth-oobe__ghost {
border: 1px solid rgba(151, 190, 238, 0.28);
background: rgba(12, 24, 42, 0.52);
color: rgba(226, 240, 255, 0.9);
}
.earth-oobe--closing {
pointer-events: none;
animation: earth-oobe-fade-out 220ms ease-in both;
}
@keyframes earth-oobe-fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes earth-oobe-fade-out {
from { opacity: 1; }
to { opacity: 0; }
}
@keyframes earth-oobe-panel-in {
from {
opacity: 0;
transform: translateY(26px) scale(0.96);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes earth-oobe-step-in {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes earth-oobe-scan {
0%, 34% { transform: translateX(0); opacity: 0; }
42% { opacity: 1; }
72%, 100% { transform: translateX(420%); opacity: 0; }
}
@keyframes earth-oobe-dot-pulse {
0%, 100% { transform: scale(1); opacity: 0.84; }
50% { transform: scale(1.18); opacity: 1; }
}
@media (max-width: 820px) {
.earth-oobe {
place-items: end center;
padding: 0 12px 14px;
}
.earth-oobe__panel {
width: min(100%, 520px);
max-height: 72vh;
overflow: auto;
border-radius: 22px 22px 18px 18px;
padding: 24px 20px;
animation-name: earth-oobe-sheet-in;
}
.earth-oobe__panel::after {
content: "";
position: absolute;
top: 9px;
left: 50%;
width: 42px;
height: 4px;
border-radius: 999px;
background: rgba(202, 222, 247, 0.32);
transform: translateX(-50%);
}
.earth-oobe__stats,
.earth-oobe__actions {
grid-template-columns: 1fr;
display: grid;
}
}
@keyframes earth-oobe-sheet-in {
from {
opacity: 0;
transform: translateY(34px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
.earth-oobe,
.earth-oobe__panel,
.earth-oobe__scan,
.earth-oobe__step,
.earth-oobe__step-dot {
animation: none !important;
}
}
.earth-mobile-settings-card--stacked { .earth-mobile-settings-card--stacked {
flex-direction: column; flex-direction: column;
align-items: stretch; align-items: stretch;

View File

@@ -0,0 +1,96 @@
const EARTH_ABOUT_API = "/api/v1/earth/about";
const DEFAULT_ABOUT = {
logo_src: "./assets/brand/lim-logo.png",
kicker: "About",
title: "智能星球计划",
version: "v0.64.0",
description:
"面向临空场景下的智能媒体研究、全球态势感知与多源开放数据巡航,提供可视化观测、事件聚合与交互式探索能力。",
meta: [
{ label: "出品方", value: "浙江大学临空智能媒体研究院" },
{ label: "策划人", value: "黄柳青" },
{ label: "产品兼开发者", value: "钱坤、张鸽、齐鹏" },
],
};
function escapeHtml(value = "") {
return String(value)
.replace(/&/g, "&")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function escapeAttribute(value = "") {
return escapeHtml(value);
}
function normalizeAbout(payload) {
const about = payload && typeof payload === "object" ? payload : {};
const meta = Array.isArray(about.meta) ? about.meta : DEFAULT_ABOUT.meta;
return {
...DEFAULT_ABOUT,
...about,
logo_src: about.logo_src || DEFAULT_ABOUT.logo_src,
kicker: about.kicker || DEFAULT_ABOUT.kicker,
title: about.title || DEFAULT_ABOUT.title,
version: about.version || DEFAULT_ABOUT.version,
description: about.description || DEFAULT_ABOUT.description,
meta: meta
.map((item) => ({
label: String(item?.label || "").trim(),
value: String(item?.value || "").trim(),
}))
.filter((item) => item.label || item.value),
};
}
function renderAboutCard(about, isMobile = false) {
const metaClass = isMobile ? "earth-about-meta-list" : "earth-about-grid";
const metaMarkup = about.meta
.map(
(item) => `
<div class="earth-about-meta">
<span>${escapeHtml(item.label)}</span>
<strong>${escapeHtml(item.value)}</strong>
</div>
`,
)
.join("");
return `
<div class="earth-about-logo-frame">
<img class="earth-about-logo" src="${escapeAttribute(about.logo_src)}" alt="${escapeAttribute(about.title)} Logo">
</div>
<div class="earth-about-heading">
<span class="earth-about-kicker">${escapeHtml(about.kicker)}</span>
<strong>${escapeHtml(about.title)}</strong>
<span>${escapeHtml(about.version)}</span>
</div>
<p class="earth-about-copy">${escapeHtml(about.description)}</p>
<div class="${metaClass}">
${metaMarkup}
</div>
`.trim();
}
function applyAboutConfig(about) {
document.querySelectorAll(".earth-about-card").forEach((card) => {
const isMobile = card.classList.contains("earth-about-card--mobile");
card.innerHTML = renderAboutCard(about, isMobile);
});
}
export async function initEarthAbout() {
applyAboutConfig(normalizeAbout(DEFAULT_ABOUT));
try {
const response = await fetch(EARTH_ABOUT_API, { cache: "no-store" });
if (!response.ok) throw new Error(`Earth about request failed: ${response.status}`);
const payload = await response.json();
applyAboutConfig(normalizeAbout(payload?.about));
} catch (error) {
console.warn("Earth about config unavailable, using defaults.", error);
}
}

View File

@@ -261,6 +261,8 @@ import {
setLegendItems, setLegendItems,
} from "./legend.js"; } from "./legend.js";
import { fetchEarthBrandConfig, mountBrand } from "./brand.js"; import { fetchEarthBrandConfig, mountBrand } from "./brand.js";
import { initEarthAbout } from "./about.js";
import { initEarthOobe } from "./oobe.js";
import { initTVPanel } from "./tv.js"; import { initTVPanel } from "./tv.js";
import { initNewsPanel, refreshEarthNews, updateNewsViewFocus } from "./news.js"; import { initNewsPanel, refreshEarthNews, updateNewsViewFocus } from "./news.js";
import { initSearchPanel } from "./search.js"; import { initSearchPanel } from "./search.js";
@@ -3737,6 +3739,8 @@ export function init() {
console.warn("Earth brand config unavailable, using defaults.", error); console.warn("Earth brand config unavailable, using defaults.", error);
}); });
initTVPanel(); initTVPanel();
initEarthAbout();
initEarthOobe();
initNewsPanel(); initNewsPanel();
connectEarthUpdatesRealtime(); connectEarthUpdatesRealtime();
initSearchPanel({ initSearchPanel({

View File

@@ -0,0 +1,126 @@
const EARTH_OOBE_STATUS_API = "/api/v1/earth/oobe-status";
const EARTH_OOBE_SKIP_KEY = "planet-earth-oobe-skip-until-v1";
const EARTH_OOBE_SKIP_MS = 6 * 60 * 60 * 1000;
function getAuthToken() {
try {
const raw = window.localStorage?.getItem("auth-storage");
if (!raw) return "";
const parsed = JSON.parse(raw);
return parsed?.state?.token || parsed?.token || "";
} catch {
return "";
}
}
function isTemporarilySkipped() {
try {
const value = Number(window.localStorage?.getItem(EARTH_OOBE_SKIP_KEY) || 0);
return Number.isFinite(value) && value > Date.now();
} catch {
return false;
}
}
function markTemporarilySkipped() {
try {
window.localStorage?.setItem(EARTH_OOBE_SKIP_KEY, String(Date.now() + EARTH_OOBE_SKIP_MS));
} catch {
// Local storage is only a soft "remind later" hint.
}
}
function escapeAttribute(value = "") {
return String(value)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
async function fetchOobeStatus() {
const token = getAuthToken();
const headers = token ? { Authorization: `Bearer ${token}` } : {};
const response = await fetch(EARTH_OOBE_STATUS_API, { cache: "no-store", headers });
if (!response.ok) throw new Error(`Earth OOBE status request failed: ${response.status}`);
return response.json();
}
function statusSteps(status) {
return [
{ label: "后端服务在线", done: true },
{ label: "登录控制台", done: Boolean(status.authenticated), warn: !status.authenticated },
{ label: "配置或确认数据源", done: Number(status.datasource_count || 0) > 0 || Number(status.custom_config_count || 0) > 0 },
{ label: "触发首次采集", done: Boolean(status.has_collected_data), warn: !status.has_collected_data },
{ label: "回到 Earth 查看结果", done: Boolean(status.ready) },
];
}
function renderOobe(status) {
const authenticated = Boolean(status.authenticated);
const primaryHref = authenticated ? status.datasources_url || "/datasources" : status.login_url || "/login?next=/datasources";
const primaryText = authenticated ? "去采集数据" : "登录并采集数据";
const secondaryHref = status.collection_url || "/collection-management";
const subtitle = authenticated
? "当前还没有检测到可展示的数据,可以直接进入后台触发首次采集。"
: "登录控制台并完成首次采集后Earth 将显示实时数据层。";
const steps = statusSteps(status)
.map((step, index) => {
const state = step.done ? "done" : step.warn ? "warn" : "pending";
return `
<li class="earth-oobe__step earth-oobe__step--${state}" style="--step-index: ${index}">
<span class="earth-oobe__step-dot" aria-hidden="true"></span>
<span>${step.label}</span>
</li>
`;
})
.join("");
const root = document.createElement("div");
root.className = "earth-oobe";
root.setAttribute("role", "dialog");
root.setAttribute("aria-modal", "true");
root.setAttribute("aria-labelledby", "earth-oobe-title");
root.innerHTML = `
<div class="earth-oobe__scrim" data-oobe-close></div>
<section class="earth-oobe__panel">
<div class="earth-oobe__scan" aria-hidden="true"></div>
<button type="button" class="earth-oobe__close" aria-label="先浏览 Earth" data-oobe-close>×</button>
<div class="earth-oobe__eyebrow">SYSTEM INITIALIZATION</div>
<h2 id="earth-oobe-title">欢迎使用智能星球计划</h2>
<p class="earth-oobe__subtitle">${subtitle}</p>
<ul class="earth-oobe__steps">${steps}</ul>
<div class="earth-oobe__stats">
<span><strong>${Number(status.current_record_count || 0)}</strong> 当前记录</span>
<span><strong>${Number(status.tv_source_count || 0)}</strong> 直播源</span>
<span><strong>${Number(status.active_datasource_count || 0)}</strong> 活跃数据源</span>
</div>
<div class="earth-oobe__actions">
<a class="earth-oobe__primary" href="${escapeAttribute(primaryHref)}">${primaryText}</a>
${authenticated ? `<a class="earth-oobe__secondary" href="${escapeAttribute(secondaryHref)}">进入采集管理</a>` : ""}
<button type="button" class="earth-oobe__ghost" data-oobe-close>先浏览 Earth</button>
</div>
</section>
`;
root.querySelectorAll("[data-oobe-close]").forEach((element) => {
element.addEventListener("click", () => {
markTemporarilySkipped();
root.classList.add("earth-oobe--closing");
window.setTimeout(() => root.remove(), 220);
});
});
return root;
}
export async function initEarthOobe() {
try {
const status = await fetchOobeStatus();
if (status?.ready || isTemporarilySkipped()) return;
document.body.appendChild(renderOobe(status));
} catch (error) {
console.warn("Earth OOBE status unavailable.", error);
}
}

View File

@@ -98,6 +98,27 @@ type DatasourceFilters = {
dataStatus: string dataStatus: string
} }
type CollectionQueueStatus = 'queued' | 'running' | 'success' | 'failed' | 'skipped' | 'cancelled'
type CollectionQueueItem = {
key: string
sourceId: string
source?: string
name: string
taskId?: number | string | null
status: CollectionQueueStatus
phase?: string
phaseMessage?: string
progress?: number | null
recordsProcessed?: number | null
totalRecords?: number | null
reason?: string
error?: string
createdAt: number
updatedAt: number
completedAt?: number
}
const DEFAULT_DATASOURCE_FILTERS: DatasourceFilters = { const DEFAULT_DATASOURCE_FILTERS: DatasourceFilters = {
product: '', product: '',
module: '', module: '',
@@ -237,6 +258,61 @@ function datasourceStatus(record: AnyRecord) {
return text(record.last_status || record.status, record.is_active === false ? 'disabled' : 'idle') return text(record.last_status || record.status, record.is_active === false ? 'disabled' : 'idle')
} }
function queueStatusFromTask(statusValue: unknown, isRunning?: unknown): CollectionQueueStatus {
const status = text(statusValue, '').toLowerCase()
if (status === 'success' || status === 'completed') return 'success'
if (status === 'failed' || status === 'error') return 'failed'
if (status === 'cancelled' || status === 'canceled') return 'cancelled'
if (status === 'skipped') return 'skipped'
if (status === 'queued' || status === 'pending') return 'queued'
if (isRunning === true || status === 'running' || status === 'collecting') return 'running'
return 'queued'
}
function queueItemKey(item: AnyRecord) {
const taskId = text(item.task_id || item.taskId, '')
if (taskId) return `task:${taskId}`
const sourceId = text(item.datasource_id || item.source_id || item.id || item.sourceId, '')
if (sourceId) return `source:${sourceId}`
const source = text(item.collector_name || item.source, '')
return source ? `source-name:${source}` : `queue:${Date.now()}`
}
function queueProgress(item: CollectionQueueItem) {
if (typeof item.progress === 'number') return Math.max(0, Math.min(100, Math.round(item.progress)))
if (item.status === 'success' || item.status === 'failed' || item.status === 'cancelled' || item.status === 'skipped') return 100
return 0
}
function queueStatusLabel(status: CollectionQueueStatus) {
const labels: Record<CollectionQueueStatus, string> = {
queued: '排队中',
running: '运行中',
success: '已完成',
failed: '失败',
skipped: '跳过',
cancelled: '已取消',
}
return labels[status]
}
function queueReasonLabel(reason = '') {
const labels: Record<string, string> = {
disabled: '已停用',
already_running: '已有任务运行',
within_frequency_window: '未到采集间隔',
trigger_failed: '触发失败',
}
return labels[reason] || reason || '-'
}
function formatDuration(startedAt: number, endedAt = Date.now()) {
const seconds = Math.max(0, Math.round((endedAt - startedAt) / 1000))
const minutes = Math.floor(seconds / 60)
const rest = seconds % 60
return `${String(minutes).padStart(2, '0')}:${String(rest).padStart(2, '0')}`
}
function datasourceTableRow(row: AnyRecord) { function datasourceTableRow(row: AnyRecord) {
return { return {
...row, ...row,
@@ -540,6 +616,8 @@ const fieldLabels: Record<string, string> = {
key: '键', key: '键',
label: '标签', label: '标签',
title: '标题', title: '标题',
kicker: '眉标',
version: '版本',
default_source_id: '默认频道', default_source_id: '默认频道',
auto_fallback: '自动回退', auto_fallback: '自动回退',
id: '标识', id: '标识',
@@ -653,6 +731,10 @@ const fieldLabels: Record<string, string> = {
use_ssl: '使用 SSL', use_ssl: '使用 SSL',
from_email: '发件邮箱', from_email: '发件邮箱',
from_name: '发件人名称', from_name: '发件人名称',
logo_alt: 'Logo 替代文本',
meta: '信息条目',
credits: '出品信息',
links: '链接',
} }
function fieldLabel(key: string) { function fieldLabel(key: string) {
@@ -1941,6 +2023,8 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [actionLoading, setActionLoading] = useState(false) const [actionLoading, setActionLoading] = useState(false)
const [rowActionLoading, setRowActionLoading] = useState<Record<string, boolean>>({}) const [rowActionLoading, setRowActionLoading] = useState<Record<string, boolean>>({})
const [collectionQueue, setCollectionQueue] = useState<CollectionQueueItem[]>([])
const [collectionQueueOpen, setCollectionQueueOpen] = useState(false)
const pendingDatasourceTasksRef = useRef<Record<string, { sourceId: string; source?: string; name: string; taskId?: number | string | null; completed?: boolean }>>({}) const pendingDatasourceTasksRef = useRef<Record<string, { sourceId: string; source?: string; name: string; taskId?: number | string | null; completed?: boolean }>>({})
const completedDatasourceTasksRef = useRef<Set<string>>(new Set()) const completedDatasourceTasksRef = useRef<Set<string>>(new Set())
const datasourcePollTimersRef = useRef<Record<string, number>>({}) const datasourcePollTimersRef = useRef<Record<string, number>>({})
@@ -2157,6 +2241,77 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
return Boolean(sourceId && rowId === sourceId) || Boolean(source && rowSource === source) return Boolean(sourceId && rowId === sourceId) || Boolean(source && rowSource === source)
}, []) }, [])
const upsertCollectionQueueItem = useCallback((item: CollectionQueueItem) => {
setCollectionQueue((current) => {
const index = current.findIndex((existing) => (
existing.key === item.key
|| (item.taskId && existing.taskId === item.taskId)
|| (item.sourceId && existing.sourceId === item.sourceId)
|| (item.source && existing.source === item.source)
))
if (index < 0) return [item, ...current]
const next = [...current]
next[index] = { ...next[index], ...item, key: next[index].key, createdAt: next[index].createdAt || item.createdAt }
return next
})
}, [])
const patchCollectionQueueFromTask = useCallback((payload: AnyRecord) => {
const sourceId = text(payload.datasource_id || payload.source_id || payload.id, '')
const source = text(payload.collector_name || payload.source, '')
const taskId = payload.task_id as number | string | null | undefined
const status = queueStatusFromTask(payload.status || payload.phase, payload.is_running)
setCollectionQueue((current) => current.map((item) => {
const matched = (taskId && item.taskId === taskId) || (sourceId && item.sourceId === sourceId) || (source && item.source === source)
if (!matched) return item
const terminal = ['success', 'failed', 'cancelled', 'skipped'].includes(status)
return {
...item,
taskId: taskId ?? item.taskId,
sourceId: sourceId || item.sourceId,
source: source || item.source,
status,
phase: text(payload.phase, item.phase || ''),
phaseMessage: text(payload.phase_message, item.phaseMessage || ''),
progress: typeof payload.progress === 'number' ? payload.progress : item.progress,
recordsProcessed: typeof payload.records_processed === 'number' ? payload.records_processed : item.recordsProcessed,
totalRecords: typeof payload.total_records === 'number' ? payload.total_records : item.totalRecords,
error: text(payload.error_message, item.error || ''),
updatedAt: Date.now(),
completedAt: terminal ? Date.now() : item.completedAt,
}
}))
}, [])
const addBatchQueueResult = useCallback((payload: AnyRecord) => {
const now = Date.now()
const toItems = (items: unknown, status: CollectionQueueStatus): CollectionQueueItem[] => (
Array.isArray(items) ? items.filter(isObjectRecord).map((item) => ({
key: queueItemKey(item),
sourceId: text(item.id || item.source_id || item.datasource_id, ''),
source: text(item.source || item.collector_name, ''),
name: text(item.name || item.source || item.collector_name, '数据源'),
taskId: item.task_id as number | string | null | undefined,
status,
phase: status === 'queued' ? 'queued' : undefined,
phaseMessage: status === 'queued' ? '等待任务创建' : queueReasonLabel(text(item.reason, '')),
progress: status === 'queued' ? 0 : 100,
reason: text(item.reason, ''),
error: text(item.error || item.message, ''),
createdAt: now,
updatedAt: now,
completedAt: status === 'queued' || status === 'running' ? undefined : now,
})) : []
)
const nextItems = [
...toItems(payload.triggered, 'queued'),
...toItems(payload.skipped, 'skipped'),
...toItems(payload.failed, 'failed'),
]
nextItems.forEach(upsertCollectionQueueItem)
if (nextItems.length) setCollectionQueueOpen(true)
}, [upsertCollectionQueueItem])
const updateDatasourceRow = useCallback((row: AnyRecord, options: { removeIfFilteredOut?: boolean } = {}) => { const updateDatasourceRow = useCallback((row: AnyRecord, options: { removeIfFilteredOut?: boolean } = {}) => {
if (config !== configs.datasources) return if (config !== configs.datasources) return
const normalized = normalizeDatasourceTableRecord(row) const normalized = normalizeDatasourceTableRecord(row)
@@ -2180,8 +2335,8 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
}) })
}, [config, isSameDatasourceRow, normalizeDatasourceTableRecord]) }, [config, isSameDatasourceRow, normalizeDatasourceTableRecord])
const mergeDatasourceTaskUpdate = useCallback((payload: AnyRecord) => { const mergeDatasourceTaskUpdate = useCallback((payload: AnyRecord) => {
if (config !== configs.datasources) return if (config !== configs.datasources) return
const sourceId = text(payload.datasource_id || payload.source_id, '') const sourceId = text(payload.datasource_id || payload.source_id, '')
const source = text(payload.collector_name || payload.source, '') const source = text(payload.collector_name || payload.source, '')
if (!sourceId && !source) return if (!sourceId && !source) return
@@ -2216,11 +2371,12 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
}), }),
} }
})) }))
setSelected((current) => { setSelected((current) => {
if (!current || !isSameDatasourceRow(current, sourceId, source)) return current if (!current || !isSameDatasourceRow(current, sourceId, source)) return current
return normalizeDatasourceTableRecord({ ...current, ...rowPatch, id: current.id, source: current.source }) return normalizeDatasourceTableRecord({ ...current, ...rowPatch, id: current.id, source: current.source })
}) })
}, [config, isSameDatasourceRow, normalizeDatasourceTableRecord]) patchCollectionQueueFromTask(payload)
}, [config, isSameDatasourceRow, normalizeDatasourceTableRecord, patchCollectionQueueFromTask])
const finalizeDatasourceTask = useCallback(async (payload: AnyRecord) => { const finalizeDatasourceTask = useCallback(async (payload: AnyRecord) => {
const sourceId = text(payload.datasource_id || payload.source_id, '') const sourceId = text(payload.datasource_id || payload.source_id, '')
@@ -2305,6 +2461,16 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
const activeState = useMemo(() => states.find((state) => state.section.key === activeSection?.key), [activeSection?.key, states]) const activeState = useMemo(() => states.find((state) => state.section.key === activeSection?.key), [activeSection?.key, states])
const rows = activeState?.rows ?? [] const rows = activeState?.rows ?? []
const summary = sectionSummary(states) const summary = sectionSummary(states)
const collectionQueueSummary = useMemo(() => {
const running = collectionQueue.filter((item) => item.status === 'queued' || item.status === 'running').length
const completed = collectionQueue.filter((item) => item.status === 'success').length
const failed = collectionQueue.filter((item) => item.status === 'failed').length
const skipped = collectionQueue.filter((item) => item.status === 'skipped' || item.status === 'cancelled').length
const terminal = completed + failed + skipped
const total = collectionQueue.length
const progress = total ? Math.round((terminal / total) * 100) : 0
return { total, running, completed, failed, skipped, progress }
}, [collectionQueue])
const isPlaygroundSection = config === configs.ai && activeSection?.key === 'playground' const isPlaygroundSection = config === configs.ai && activeSection?.key === 'playground'
const isHierarchySection = config.viewMode === 'management' && !isPlaygroundSection const isHierarchySection = config.viewMode === 'management' && !isPlaygroundSection
const searchIntent = useMemo(() => { const searchIntent = useMemo(() => {
@@ -2486,6 +2652,12 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
source_ids: sourceIds, source_ids: sourceIds,
force: batchTriggerForce, force: batchTriggerForce,
}) })
addBatchQueueResult(response.data)
arrayAt(response.data, 'triggered').forEach((item) => {
const sourceId = text(item.id || item.source_id || item.datasource_id, '')
const record = rows.find((row) => text(row.id, '') === sourceId || text(row.source, '') === text(item.source, ''))
if (record) scheduleDatasourceTaskPoll(record, item.task_id as number | string | null | undefined)
})
replaceSelectedWithPayload('trigger-batch', '批量触发结果', [{ replaceSelectedWithPayload('trigger-batch', '批量触发结果', [{
...response.data, ...response.data,
__title: '批量触发结果', __title: '批量触发结果',
@@ -2502,6 +2674,25 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
} }
} }
const triggerAllDatasources = async () => {
setActionLoading(true)
try {
toast({ title: '正在提交采集队列', description: '触发全部会按后端调度规则跳过未到采集间隔的源。' })
const response = await axios.post(apiPath('/datasources/trigger-all'))
addBatchQueueResult(response.data)
arrayAt(response.data, 'triggered').forEach((item) => {
const sourceId = text(item.id || item.source_id || item.datasource_id, '')
const record = rows.find((row) => text(row.id, '') === sourceId || text(row.source, '') === text(item.source, ''))
if (record) scheduleDatasourceTaskPoll(record, item.task_id as number | string | null | undefined)
})
toast({ title: '采集队列已提交', description: `${arrayAt(response.data, 'triggered').length} 个任务已进入队列。`, tone: 'success' })
} catch (error) {
toast({ title: '触发全部失败', description: actionErrorMessage(error), tone: 'error' })
} finally {
setActionLoading(false)
}
}
const scheduleDatasourceTaskPoll = (record: TableRecord, taskId?: number | string | null) => { const scheduleDatasourceTaskPoll = (record: TableRecord, taskId?: number | string | null) => {
const id = pick(record, ['id', 'source_id', 'key', 'name'], '') const id = pick(record, ['id', 'source_id', 'key', 'name'], '')
if (!id) return if (!id) return
@@ -2535,6 +2726,33 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
datasourcePollTimersRef.current[pollKey] = window.setTimeout(poll, datasourceSocket.connected ? 3000 : 900) datasourcePollTimersRef.current[pollKey] = window.setTimeout(poll, datasourceSocket.connected ? 3000 : 900)
} }
useEffect(() => {
if (config !== configs.datasources) return
const builtinState = states.find((state) => state.section.key === 'builtin')
if (!builtinState?.rows.length) return
builtinState.rows.forEach((row) => {
const status = datasourceStatus(row)
if (!['running', 'pending', 'queued'].includes(status)) return
const sourceId = text(row.id || row.source_id, '')
const source = text(row.source || row.collector_name, '')
const taskId = row.task_id as number | string | null | undefined
upsertCollectionQueueItem({
key: queueItemKey({ id: sourceId, source, task_id: taskId }),
sourceId,
source,
name: recordTitle(row),
taskId,
status: status === 'running' ? 'running' : 'queued',
phase: status,
phaseMessage: text(row.phase_message || row.last_status, '后端任务仍在运行'),
progress: typeof row.progress === 'number' ? row.progress : 0,
createdAt: Date.now(),
updatedAt: Date.now(),
})
scheduleDatasourceTaskPoll(row, taskId)
})
}, [config, states, upsertCollectionQueueItem])
const refreshDatasourceRow = async (record: AnyRecord, options: { removeIfFilteredOut?: boolean } = {}) => { const refreshDatasourceRow = async (record: AnyRecord, options: { removeIfFilteredOut?: boolean } = {}) => {
const id = pick(record, ['id', 'source_id', 'key', 'name'], '') const id = pick(record, ['id', 'source_id', 'key', 'name'], '')
if (!id) return if (!id) return
@@ -2632,13 +2850,27 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
} }
const taskId = response.data?.task_id const taskId = response.data?.task_id
const pendingKey = text(taskId, id) const pendingKey = text(taskId, id)
pendingDatasourceTasksRef.current[pendingKey] = { pendingDatasourceTasksRef.current[pendingKey] = {
sourceId: id, sourceId: id,
source: text(record.source || response.data?.collector_name, ''), source: text(record.source || response.data?.collector_name, ''),
name: recordTitle(record), name: recordTitle(record),
taskId, taskId,
} }
completedDatasourceTasksRef.current.delete(pendingKey) upsertCollectionQueueItem({
key: queueItemKey({ task_id: taskId, id, source: record.source }),
sourceId: id,
source: text(record.source || response.data?.collector_name, ''),
name: recordTitle(record),
taskId,
status: 'queued',
phase: 'queued',
phaseMessage: '任务已提交',
progress: 0,
createdAt: Date.now(),
updatedAt: Date.now(),
})
setCollectionQueueOpen(true)
completedDatasourceTasksRef.current.delete(pendingKey)
updateDatasourceRow({ updateDatasourceRow({
...record, ...record,
id: record.id || Number(id) || id, id: record.id || Number(id) || id,
@@ -2659,8 +2891,8 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
__status: '已提交', __status: '已提交',
__metric: response.data?.task_id || '-', __metric: response.data?.task_id || '-',
}]) }])
toast({ title: force ? '已强制重新触发' : '任务已触发', tone: 'success' }) toast({ title: force ? '已强制重新触发' : '任务已触发', tone: 'success' })
scheduleDatasourceTaskPoll(record, taskId) scheduleDatasourceTaskPoll(record, taskId)
} catch (error) { } catch (error) {
toast({ title: '触发采集失败', description: actionErrorMessage(error), tone: 'error' }) toast({ title: '触发采集失败', description: actionErrorMessage(error), tone: 'error' })
} finally { } finally {
@@ -3957,6 +4189,10 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
await saveHierarchySettings('/earth/brand', payload, '保存品牌配置') await saveHierarchySettings('/earth/brand', payload, '保存品牌配置')
return return
} }
if (activeSection.key === 'about') {
await saveHierarchySettings('/earth/about', payload, '保存关于配置')
return
}
if (activeGroup.key.includes('boundaries')) { if (activeGroup.key.includes('boundaries')) {
await saveHierarchySettings('/earth/boundaries/config', { config: payload }, '保存边界配置') await saveHierarchySettings('/earth/boundaries/config', { config: payload }, '保存边界配置')
return return
@@ -4185,6 +4421,15 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
})} loading={actionLoading}><Trash2 size={15} /></Button> })} loading={actionLoading}><Trash2 size={15} /></Button>
</> </>
) : null} ) : null}
{config === configs.earthContent && activeSection.key === 'about' ? (
<Button size="icon" variant="subtle" title="恢复默认关于信息" aria-label="恢复默认关于信息" onClick={() => setConfirmAction({
title: '恢复默认关于信息',
description: '确认恢复 Earth 关于卡片的默认内容?',
danger: false,
confirmLabel: '恢复',
run: () => requestAction('恢复默认关于信息', 'delete', '/earth/about'),
})} loading={actionLoading}><RefreshCw size={15} /></Button>
) : null}
{config === configs.earthContent && activeSection.key === 'earth_assets' ? ( {config === configs.earthContent && activeSection.key === 'earth_assets' ? (
<Button variant="primary" icon="trigger" onClick={() => void requestAction('启动边界构建', 'post', '/earth/boundaries/build', undefined, { refresh: false, successDescription: '边界构建任务已提交,当前表单未保存。' })} loading={actionLoading}></Button> <Button variant="primary" icon="trigger" onClick={() => void requestAction('启动边界构建', 'post', '/earth/boundaries/build', undefined, { refresh: false, successDescription: '边界构建任务已提交,当前表单未保存。' })} loading={actionLoading}></Button>
) : null} ) : null}
@@ -4636,7 +4881,7 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
const actions: ReactNode[] = [] const actions: ReactNode[] = []
if (config === configs.datasources) { if (config === configs.datasources) {
actions.push( actions.push(
<Button key="trigger-all" variant="primary" icon="trigger" onClick={() => void requestAction('触发全部数据源', 'post', '/datasources/trigger-all', undefined, { refresh: false, successDescription: '批量触发任务已提交,不会修改配置。' })} loading={actionLoading} title="触发全部数据源"> <Button key="trigger-all" variant="primary" icon="trigger" onClick={() => void triggerAllDatasources()} loading={actionLoading} title="触发全部数据源">
</Button>, </Button>,
<Button key="trigger-batch" size="icon" variant="subtle" onClick={() => setBatchTriggerOpen(true)} loading={actionLoading} title="批量触发数据源" aria-label="批量触发数据源"> <Button key="trigger-batch" size="icon" variant="subtle" onClick={() => setBatchTriggerOpen(true)} loading={actionLoading} title="批量触发数据源" aria-label="批量触发数据源">
@@ -4748,6 +4993,115 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
) )
} }
const renderDatasourceTaskSummary = () => {
if (config !== configs.datasources || !selected || selected.__endpointKey !== 'builtin') return null
const sourceId = text(selected.id || selected.source_id, '')
const source = text(selected.source || selected.collector_name, '')
const queueItem = collectionQueue.find((item) => isSameDatasourceRow(selected, item.sourceId || '', item.source || ''))
const status = queueItem?.status || datasourceStatus(selected)
const taskId = queueItem?.taskId || selected.task_id
return (
<section className="an-task-summary">
<div>
<strong></strong>
<p>{queueItem?.phaseMessage || text(selected.last_status || selected.phase_message, '当前没有运行中的任务。')}</p>
</div>
<StatusText tone={statusTone(status)}>{queueStatusLabel(status as CollectionQueueStatus)}</StatusText>
<dl>
<dt></dt><dd>{source || sourceId || '-'}</dd>
<dt></dt><dd>{text(taskId, '-')}</dd>
<dt></dt><dd>{queueItem ? `${queueProgress(queueItem)}%` : '-'}</dd>
<dt></dt><dd>{queueItem?.updatedAt ? new Date(queueItem.updatedAt).toLocaleTimeString() : text(selected.last_run_at, '-')}</dd>
</dl>
</section>
)
}
const jumpToQueueRecord = (item: CollectionQueueItem) => {
const record = rows.find((row) => isSameDatasourceRow(row, item.sourceId || '', item.source || ''))
if (!record) {
toast({ title: '当前分区未找到该数据源', description: '可切回内置源或刷新后再查看。' })
return
}
openResourceDetail(record)
}
const retryQueueItem = (item: CollectionQueueItem) => {
const record = rows.find((row) => isSameDatasourceRow(row, item.sourceId || '', item.source || ''))
if (!record) {
toast({ title: '无法重试', description: '当前列表中没有找到对应数据源。', tone: 'error' })
return
}
void triggerDatasourceWithPrecheck(record)
}
const renderCollectionQueue = () => {
if (config !== configs.datasources || !collectionQueueSummary.total) return null
const groups: Array<{ key: string; title: string; items: CollectionQueueItem[] }> = [
{ key: 'running', title: '运行中', items: collectionQueue.filter((item) => item.status === 'queued' || item.status === 'running') },
{ key: 'failed', title: '失败', items: collectionQueue.filter((item) => item.status === 'failed') },
{ key: 'completed', title: '完成', items: collectionQueue.filter((item) => item.status === 'success') },
{ key: 'skipped', title: '跳过', items: collectionQueue.filter((item) => item.status === 'skipped' || item.status === 'cancelled') },
]
return (
<div className="an-collection-queue">
<div className="an-collection-queue__bar">
<div className="an-collection-queue__summary">
<strong></strong>
<span>{collectionQueueSummary.progress}%</span>
<span> {collectionQueueSummary.running}</span>
<span> {collectionQueueSummary.completed}</span>
<span> {collectionQueueSummary.failed}</span>
<span> {collectionQueueSummary.skipped}</span>
</div>
<div className="an-collection-queue__actions">
<Button size="sm" variant="subtle" onClick={() => setCollectionQueueOpen((open) => !open)}>
{collectionQueueOpen ? '收起队列' : '查看队列'}
</Button>
{collectionQueueSummary.running === 0 ? (
<Button size="icon" variant="subtle" title="清空已结束队列项" aria-label="清空已结束队列项" onClick={() => setCollectionQueue((items) => items.filter((item) => item.status === 'queued' || item.status === 'running'))}>
<X size={14} />
</Button>
) : null}
</div>
</div>
<div className="an-collection-queue__track" aria-hidden="true">
<span style={{ width: `${collectionQueueSummary.progress}%` }} />
</div>
{collectionQueueOpen ? (
<div className="an-collection-queue__panel">
{groups.map((group) => (
<section key={group.key} className="an-collection-queue__group">
<h3>{group.title}<span>{group.items.length}</span></h3>
{group.items.length ? (
<div className="an-collection-queue__items">
{group.items.map((item) => (
<article key={item.key} className={`an-collection-queue__item is-${item.status}`}>
<div>
<strong>{item.name}</strong>
<p>{item.phaseMessage || item.error || queueStatusLabel(item.status)}{item.taskId ? ` · task ${item.taskId}` : ''}{item.completedAt ? ` · ${formatDuration(item.createdAt, item.completedAt)}` : ''}</p>
</div>
<span>{queueProgress(item)}%</span>
<div className="an-collection-queue__item-actions">
<Button size="icon" variant="subtle" title="查看数据源" aria-label="查看数据源" onClick={() => jumpToQueueRecord(item)}>
<Eye size={14} />
</Button>
{item.status === 'failed' ? (
<Button size="icon" variant="subtle" icon="trigger" title="重试" aria-label="重试" onClick={() => retryQueueItem(item)} />
) : null}
</div>
</article>
))}
</div>
) : <p className="an-collection-queue__empty">{group.title}</p>}
</section>
))}
</div>
) : null}
</div>
)
}
const moduleActions = renderModuleActions() const moduleActions = renderModuleActions()
const credentialGuideProviderName = credentialGuide ? text(credentialGuide.provider, 'barentswatch') : '' const credentialGuideProviderName = credentialGuide ? text(credentialGuide.provider, 'barentswatch') : ''
const credentialGuideMarkdown = credentialGuide const credentialGuideMarkdown = credentialGuide
@@ -4790,6 +5144,7 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
<SectionTabs sections={config.sections} states={states} activeKey={activeSection?.key || ''} onChange={handleSectionChange} /> <SectionTabs sections={config.sections} states={states} activeKey={activeSection?.key || ''} onChange={handleSectionChange} />
{renderDatasourceFilters()} {renderDatasourceFilters()}
{renderCollectionQueue()}
</div> </div>
{isPlaygroundSection ? ( {isPlaygroundSection ? (
@@ -4841,6 +5196,7 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
<StatusText tone={statusTone(recordStatus(selected))}>{recordStatus(selected)}</StatusText> <StatusText tone={statusTone(recordStatus(selected))}>{recordStatus(selected)}</StatusText>
</header> </header>
{renderRecordActions()} {renderRecordActions()}
{renderDatasourceTaskSummary()}
<DetailMarkdownDocument record={selected} /> <DetailMarkdownDocument record={selected} />
{renderStructuredEditor() || <DetailFields record={selected} />} {renderStructuredEditor() || <DetailFields record={selected} />}
<div className="an-code-section"> <div className="an-code-section">
@@ -5112,6 +5468,7 @@ const configs = {
viewMode: 'management', viewMode: 'management',
sections: [ sections: [
{ key: 'brand', label: '品牌标识', url: '/earth/brand', map: (payload) => singleRow(payload, 'brand', { __title: '品牌配置', __module: '品牌' }).map((row) => ({ ...row, __title: '品牌配置', __module: '品牌', __status: '已读取' })) }, { key: 'brand', label: '品牌标识', url: '/earth/brand', map: (payload) => singleRow(payload, 'brand', { __title: '品牌配置', __module: '品牌' }).map((row) => ({ ...row, __title: '品牌配置', __module: '品牌', __status: '已读取' })) },
{ key: 'about', label: '关于', url: '/earth/about', map: (payload) => singleRow(payload, 'about', { __title: '关于配置', __module: '关于' }).map((row) => ({ ...row, __title: '关于配置', __module: '关于', __status: '已读取' })) },
{ {
key: 'earth_assets', key: 'earth_assets',
label: '国界精度', label: '国界精度',

View File

@@ -29,6 +29,12 @@
--tui-primary-hover: var(--an-accent-hover); --tui-primary-hover: var(--an-accent-hover);
--tui-primary-active: var(--an-accent-hover); --tui-primary-active: var(--an-accent-hover);
--tui-danger: var(--an-danger); --tui-danger: var(--an-danger);
--d-segment-bg: #eef3f9;
--d-segment-slider: #ffffff;
--d-segment-shadow: 0 2px 8px rgba(15, 23, 42, 0.12);
--d-lang-btn: #4a5568;
--d-nav-hover: #0d4f9f;
--d-nav-active: #0b5fc1;
color: var(--an-text); color: var(--an-text);
} }
@@ -49,6 +55,12 @@
--an-info: #38bdf8; --an-info: #38bdf8;
--an-row-hover: #1f2b3e; --an-row-hover: #1f2b3e;
--an-shadow: none; --an-shadow: none;
--d-segment-bg: rgba(0, 0, 0, 0.28);
--d-segment-slider: #202938;
--d-segment-shadow: 0 2px 8px rgba(0, 0, 0, 0.45);
--d-lang-btn: #8a9bb8;
--d-nav-hover: #93c5fd;
--d-nav-active: #5ba5ff;
} }
.an-dialog, .an-dialog,
@@ -301,6 +313,192 @@ body:has(.admin-next-theme-root[data-theme='dark']) .an-toast {
justify-content: center; justify-content: center;
} }
.an-collection-queue {
display: grid;
gap: 8px;
padding: 10px 12px;
border: 1px solid var(--an-border);
border-radius: 8px;
background: var(--an-surface);
box-shadow: var(--an-shadow);
}
.an-collection-queue__bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.an-collection-queue__summary,
.an-collection-queue__actions,
.an-collection-queue__item-actions {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.an-collection-queue__summary {
flex-wrap: wrap;
color: var(--an-muted);
font-size: 12px;
}
.an-collection-queue__summary strong {
color: var(--an-text);
font-size: 14px;
}
.an-collection-queue__track {
height: 6px;
overflow: hidden;
border-radius: 999px;
background: var(--an-soft);
}
.an-collection-queue__track span {
display: block;
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, var(--an-info), var(--an-accent));
transition: width 180ms ease;
}
.an-collection-queue__panel {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
max-height: 280px;
overflow: auto;
padding-top: 4px;
}
.an-collection-queue__group {
display: grid;
align-content: start;
gap: 8px;
min-width: 0;
padding: 8px;
border: 1px solid var(--an-border);
border-radius: 8px;
background: var(--an-surface-alt);
}
.an-collection-queue__group h3 {
display: flex;
align-items: center;
justify-content: space-between;
margin: 0;
font-size: 13px;
}
.an-collection-queue__group h3 span {
color: var(--an-muted);
font-size: 12px;
}
.an-collection-queue__items {
display: grid;
gap: 6px;
}
.an-collection-queue__item {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
align-items: center;
gap: 8px;
padding: 8px;
border: 1px solid var(--an-border);
border-radius: 7px;
background: var(--an-surface);
}
.an-collection-queue__item strong,
.an-collection-queue__item p {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.an-collection-queue__item strong {
display: block;
font-size: 12px;
}
.an-collection-queue__item p,
.an-collection-queue__empty {
margin: 0;
color: var(--an-muted);
font-size: 12px;
}
.an-collection-queue__item > span {
min-width: 38px;
color: var(--an-muted);
font-size: 12px;
text-align: right;
}
.an-collection-queue__item.is-running,
.an-collection-queue__item.is-queued {
border-color: color-mix(in srgb, var(--an-info) 35%, var(--an-border));
}
.an-collection-queue__item.is-success {
border-color: color-mix(in srgb, var(--an-success) 35%, var(--an-border));
}
.an-collection-queue__item.is-failed {
border-color: color-mix(in srgb, var(--an-danger) 35%, var(--an-border));
}
.an-task-summary {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 10px;
padding: 10px;
border: 1px solid var(--an-border);
border-radius: 8px;
background: var(--an-surface-alt);
}
.an-task-summary p {
margin: 3px 0 0;
color: var(--an-muted);
font-size: 12px;
}
.an-task-summary dl {
grid-column: 1 / -1;
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
margin: 0;
border: 1px solid var(--an-border);
border-radius: 7px;
overflow: hidden;
}
.an-task-summary dt,
.an-task-summary dd {
margin: 0;
padding: 7px 9px;
border-bottom: 1px solid var(--an-border);
font-size: 12px;
}
.an-task-summary dt {
color: var(--an-muted);
background: var(--an-surface);
}
.an-task-summary dd {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.admin-next__content { .admin-next__content {
min-width: 0; min-width: 0;
height: 100vh; height: 100vh;
@@ -3572,6 +3770,20 @@ body:has(.admin-next-theme-root[data-theme='dark']) .an-toast {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
.an-collection-queue__bar {
align-items: stretch;
flex-direction: column;
}
.an-collection-queue__panel {
grid-template-columns: 1fr;
max-height: 46vh;
}
.an-task-summary dl {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.an-logs-filters { .an-logs-filters {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }

View File

@@ -1,6 +1,6 @@
[project] [project]
name = "planet" name = "planet"
version = "0.63.1" version = "0.64.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.63.1" version = "0.64.0"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "aiofiles" }, { name = "aiofiles" },