dev #11
@@ -9,13 +9,17 @@ from uuid import uuid4
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
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 app.core.security import decode_token, get_current_user, redis_client
|
||||
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.user import User
|
||||
from app.services.tv_streams import get_tv_settings_payload
|
||||
from app.services.earth_boundaries import (
|
||||
EarthBoundaryBuildError,
|
||||
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_URL_PREFIX = "/earth-brand-assets"
|
||||
EARTH_BRAND_CATEGORY = "earth_brand"
|
||||
EARTH_ABOUT_CATEGORY = "earth_about"
|
||||
MAX_EARTH_BRAND_ASSET_BYTES = 3 * 1024 * 1024
|
||||
ALLOWED_EARTH_BRAND_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".svg"}
|
||||
|
||||
@@ -44,6 +49,19 @@ DEFAULT_EARTH_BRAND = {
|
||||
"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):
|
||||
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)
|
||||
|
||||
|
||||
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]:
|
||||
merged = DEFAULT_EARTH_BRAND.copy()
|
||||
if payload:
|
||||
@@ -76,6 +108,40 @@ def _normalize_earth_brand_payload(payload: dict[str, Any] | None) -> dict[str,
|
||||
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:
|
||||
result = await db.execute(
|
||||
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")
|
||||
async def get_earth_brand(db: AsyncSession = Depends(get_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}
|
||||
|
||||
|
||||
@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")
|
||||
async def get_earth_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:
|
||||
host = request.client.host if request.client else ""
|
||||
return host in {"127.0.0.1", "::1", "localhost"} or host.startswith("127.")
|
||||
|
||||
@@ -8,6 +8,23 @@ This project follows the repository versioning rule:
|
||||
- `improvement` -> `+0.0.1`(bugfix + 小功能混合)
|
||||
- `bugfix` -> `+0.0.1`
|
||||
|
||||
## [0.64.0] — 2026-05-21
|
||||
|
||||
Released: 2026-05-21
|
||||
|
||||
### Highlights
|
||||
- 新增 Earth 首次初始化 OOBE,由后端真实采集状态决定是否显示,避免 localStorage 清空后误弹,并提供桌面毛玻璃引导与移动端 bottom sheet。
|
||||
- 数据源页新增下载列表式采集队列,把单源、批量和触发全部的任务进度统一展示,并支持失败重试与跳转详情。
|
||||
- Earth 内容新增“关于”配置接口和后台 tab,Earth 设置页 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
|
||||
|
||||
Released: 2026-05-21
|
||||
|
||||
@@ -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.
|
||||
|
||||
`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:
|
||||
|
||||
- `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.
|
||||
- `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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
## 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
|
||||
|
||||
### 1. `Scrollbar`
|
||||
|
||||
@@ -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:
|
||||
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
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` is also under Operations and Configuration and owns the collection lifecycle:
|
||||
|
||||
- **Collectors**: endpoint, headers, credentials, timeout, retry, and connection checks.
|
||||
- **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
|
||||
|
||||
@@ -230,7 +233,7 @@ To let a regular user read developer or operations docs, add `docs_developer` or
|
||||
|
||||
## 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"
|
||||
- `/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
|
||||
|
||||
@@ -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
|
||||
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
|
||||
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.
|
||||
|
||||
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:
|
||||
|
||||
- The globe renders, and the right-side layer panel can toggle layers
|
||||
|
||||
@@ -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 前端只消费结果。
|
||||
|
||||
`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 内容页必须按运行时语义组织这些配置:
|
||||
|
||||
- `品牌标识`:品牌预览应使用与 Earth HUD 左上角一致的深色星空背景、尺寸、间距、logo/title 渲染和文本 fallback,而不是普通表单预览。
|
||||
- `关于`:配置 Earth 设置里的 About 卡片,包括 logo、眉标、标题、版本、描述和元信息条目;Earth 运行时从 `/earth/about` 读取,失败时回退默认内容。
|
||||
- `国界精度`:构建边界、刷新状态和恢复默认属于这个分区内部动作,不应放在页面全局工具栏。
|
||||
- `电视直播`:列表同时区分内置源、采集源和自定义源;卡片状态表达启用、停用、新建或错误。内置源不能删除,采集源和自定义源可以删除。新增直播源在点击加号后才进入草稿状态,保存后固化到列表,取消则销毁草稿。
|
||||
- `底图资源`、`图层资源`、`3D 模型`、`新闻锚点策略`:如果后端能力未接入,控制台应明确显示待接入空态,不应混入 TV 或品牌配置。
|
||||
|
||||
@@ -110,6 +110,26 @@ legacy 职责:
|
||||
|
||||
这样做是为了降低 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`
|
||||
|
||||
@@ -191,19 +191,22 @@ Base URL 输入框尾端的插头图标会触发连接测试。测试通过会
|
||||
`/earth-content` 位于控制台“运维与配置”下,面向 Earth 前端体验资源:
|
||||
|
||||
- **品牌资源**:维护 Earth HUD 使用的 logo、标题图、标题文本、副标题和描述;上传的图片会保存为 Earth 品牌资产并立即供 Earth 页面读取。
|
||||
- **关于**:维护 Earth 设置面板里的关于卡片,包括 logo、眉标、标题、版本、描述和元信息。
|
||||
- **电视直播**:维护 Earth 媒体面板里的直播源。
|
||||
- **国界精度**:查看当前国界 provider、低精 fallback、高精 PMTiles/manifest 状态,编辑本机源配置并手动构建。
|
||||
- **地球底图**、**图层资源**、**三维素材**、**新闻锚点策略**:目前是待接入占位页,不展示假数据。
|
||||
|
||||
Earth 页面工具栏齿轮中也有“国界精度”。切到“高精”时,如果本机尚未构建高精资产,会像游戏更新包一样启动后台下载/构建并显示百分比;构建成功后自动应用,无需刷新。切回“低精”只切换本机显示偏好,不重新下载。
|
||||
|
||||
如果后端判断 Earth 尚未初始化,首次进入 `/earth` 会出现毛玻璃引导,提示登录控制台并采集数据。这个判断来自后端真实数据状态;如果系统已经有已采集数据,清空浏览器缓存也不会重新弹出。
|
||||
|
||||
### 采集管理
|
||||
|
||||
`/collection-management` 位于控制台“运维与配置”下,面向采集生命周期:
|
||||
|
||||
- **采集器**:维护 endpoint、请求头、凭证、timeout、retry,并运行连接检查。
|
||||
- **采集调度**:维护原有调度相关设置。
|
||||
- **采集历史 / 快照**:当前是待接入占位页,后续承载 collection task、snapshot、collected data 浏览能力。
|
||||
- **采集历史 / 快照**:按数据源聚合历史快照,详情页可用 Time Capsule 下拉切换不同版本。
|
||||
|
||||
### SMTP 邮件设置
|
||||
|
||||
@@ -233,7 +236,7 @@ Earth 页面工具栏齿轮中也有“国界精度”。切到“高精”时
|
||||
|
||||
## 数据探索
|
||||
|
||||
- `/datasources`:数据源目录。`采集任务` tab 面向一次性/定时采集器,可以按产品域、层级、启用状态、最近执行状态、是否已有采集数据和关键词筛选;勾选多行后可批量采集选中项,未勾选时“一键采集”触发当前筛选范围。`实时流` tab 面向 AISStream / WebSocket 长连接,展示连接健康、累计入库、时间窗统计和启动 / 停止 / 重连操作。点击名称打开信息抽屉查看 endpoint、请求头、基础配置和是否内置;接口、凭证、请求头的编辑统一在 `/collection-management` 的"采集器"。总体进度下方的 `采集中 N` 标签可点击,展开当前采集任务列表
|
||||
- `/datasources`:数据源目录。`内置源` 支持按产品域、层级、启用状态、最近执行状态、是否已有采集数据和关键词筛选;勾选多行后可批量触发,`触发全部` 会进入类似浏览器下载列表的采集队列。队列上方显示总进度、运行中、完成、失败和跳过数量,`查看队列` 可展开底部面板,失败项可重试,完成项可跳到详情。`实时源` 面向 AISStream / WebSocket 长连接,展示连接健康、累计入库、时间窗统计和启动 / 停止 / 重连操作。接口、凭证、请求头的编辑统一在 `/collection-management` 的"采集器"。
|
||||
- `/data`:采集后数据表,适合排查"数据是否已经进入系统"、"更新时间是否符合预期"、"某个数据源是否产出有效记录"
|
||||
- `/bgp`:BGP 专题页面,列表 + 详情 + 研判,与 Earth 的 BGP 图层互补
|
||||
- `/alerts/system`、`/alerts/bgp`、`/alerts/situational`:系统、BGP、态势告警
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
|
||||
1. `/collection-management?tab=collector_credentials`:选一个采集器,点插头图标做连接测试。免费 collector(开源 BGP 等)通常直接可用;像 `AISStream`、`BarentsWatch` 这类需要凭证的,需要先填 API Key/Client Secret
|
||||
2. `/ai?tab=providers`:填一个 LLM provider(例如 `minimax` / `openai`)、模型名、Base URL、API Key,点 Base URL 末端的插头测试连接。WebSearch / OCR 工具可选
|
||||
3. `/datasources` 或 `/data`:看采集器是否已经产出数据。有限采集器看 `/datasources -> 采集任务`,AISStream / WebSocket 长连接看 `/datasources -> 实时流` 的健康状态和计数
|
||||
3. `/datasources` 或 `/data`:看采集器是否已经产出数据。有限采集器看 `/datasources -> 内置源`,触发后可展开采集队列查看进度;AISStream / WebSocket 长连接看 `/datasources -> 实时源` 的健康状态和计数
|
||||
4. `/alerts/system`:看系统告警是否正常
|
||||
5. `/users`(仅 `super_admin`):根据需要给同事开账号或调权限组
|
||||
|
||||
@@ -42,6 +42,8 @@
|
||||
|
||||
访问 `/earth`,公开页面,不需要登录。
|
||||
|
||||
如果系统还没有已采集数据,Earth 会显示初始化引导,提示登录控制台并触发采集。这个引导由后端状态决定,不会因为清空浏览器缓存而误判。
|
||||
|
||||
进入后建议确认:
|
||||
|
||||
- 地球正常显示,右侧图层面板可以打开/关闭
|
||||
|
||||
@@ -16,12 +16,13 @@
|
||||
## Current Version
|
||||
|
||||
- `main` 当前主线历史推导到:`0.16.5`
|
||||
- `dev` 当前开发分支历史推导到:`0.63.1`
|
||||
- `dev` 当前开发分支历史推导到:`0.64.0`
|
||||
|
||||
## Timeline
|
||||
|
||||
| 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.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 和移动端详情 |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "planet-frontend",
|
||||
"version": "0.63.1",
|
||||
"version": "0.64.0",
|
||||
"private": true,
|
||||
"packageManager": "bun@1",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1441,6 +1441,11 @@
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.earth-about-meta-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.earth-about-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -1460,6 +1465,311 @@
|
||||
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 {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
|
||||
96
frontend/public/earth/js/about.js
Normal file
96
frontend/public/earth/js/about.js
Normal 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, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -261,6 +261,8 @@ import {
|
||||
setLegendItems,
|
||||
} from "./legend.js";
|
||||
import { fetchEarthBrandConfig, mountBrand } from "./brand.js";
|
||||
import { initEarthAbout } from "./about.js";
|
||||
import { initEarthOobe } from "./oobe.js";
|
||||
import { initTVPanel } from "./tv.js";
|
||||
import { initNewsPanel, refreshEarthNews, updateNewsViewFocus } from "./news.js";
|
||||
import { initSearchPanel } from "./search.js";
|
||||
@@ -3737,6 +3739,8 @@ export function init() {
|
||||
console.warn("Earth brand config unavailable, using defaults.", error);
|
||||
});
|
||||
initTVPanel();
|
||||
initEarthAbout();
|
||||
initEarthOobe();
|
||||
initNewsPanel();
|
||||
connectEarthUpdatesRealtime();
|
||||
initSearchPanel({
|
||||
|
||||
126
frontend/public/earth/js/oobe.js
Normal file
126
frontend/public/earth/js/oobe.js
Normal 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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -98,6 +98,27 @@ type DatasourceFilters = {
|
||||
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 = {
|
||||
product: '',
|
||||
module: '',
|
||||
@@ -237,6 +258,61 @@ function datasourceStatus(record: AnyRecord) {
|
||||
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) {
|
||||
return {
|
||||
...row,
|
||||
@@ -540,6 +616,8 @@ const fieldLabels: Record<string, string> = {
|
||||
key: '键',
|
||||
label: '标签',
|
||||
title: '标题',
|
||||
kicker: '眉标',
|
||||
version: '版本',
|
||||
default_source_id: '默认频道',
|
||||
auto_fallback: '自动回退',
|
||||
id: '标识',
|
||||
@@ -653,6 +731,10 @@ const fieldLabels: Record<string, string> = {
|
||||
use_ssl: '使用 SSL',
|
||||
from_email: '发件邮箱',
|
||||
from_name: '发件人名称',
|
||||
logo_alt: 'Logo 替代文本',
|
||||
meta: '信息条目',
|
||||
credits: '出品信息',
|
||||
links: '链接',
|
||||
}
|
||||
|
||||
function fieldLabel(key: string) {
|
||||
@@ -1941,6 +2023,8 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [actionLoading, setActionLoading] = useState(false)
|
||||
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 completedDatasourceTasksRef = useRef<Set<string>>(new Set())
|
||||
const datasourcePollTimersRef = useRef<Record<string, number>>({})
|
||||
@@ -2157,6 +2241,77 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
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 } = {}) => {
|
||||
if (config !== configs.datasources) return
|
||||
const normalized = normalizeDatasourceTableRecord(row)
|
||||
@@ -2180,8 +2335,8 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
})
|
||||
}, [config, isSameDatasourceRow, normalizeDatasourceTableRecord])
|
||||
|
||||
const mergeDatasourceTaskUpdate = useCallback((payload: AnyRecord) => {
|
||||
if (config !== configs.datasources) return
|
||||
const mergeDatasourceTaskUpdate = useCallback((payload: AnyRecord) => {
|
||||
if (config !== configs.datasources) return
|
||||
const sourceId = text(payload.datasource_id || payload.source_id, '')
|
||||
const source = text(payload.collector_name || payload.source, '')
|
||||
if (!sourceId && !source) return
|
||||
@@ -2216,11 +2371,12 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
}),
|
||||
}
|
||||
}))
|
||||
setSelected((current) => {
|
||||
if (!current || !isSameDatasourceRow(current, sourceId, source)) return current
|
||||
return normalizeDatasourceTableRecord({ ...current, ...rowPatch, id: current.id, source: current.source })
|
||||
})
|
||||
}, [config, isSameDatasourceRow, normalizeDatasourceTableRecord])
|
||||
setSelected((current) => {
|
||||
if (!current || !isSameDatasourceRow(current, sourceId, source)) return current
|
||||
return normalizeDatasourceTableRecord({ ...current, ...rowPatch, id: current.id, source: current.source })
|
||||
})
|
||||
patchCollectionQueueFromTask(payload)
|
||||
}, [config, isSameDatasourceRow, normalizeDatasourceTableRecord, patchCollectionQueueFromTask])
|
||||
|
||||
const finalizeDatasourceTask = useCallback(async (payload: AnyRecord) => {
|
||||
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 rows = activeState?.rows ?? []
|
||||
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 isHierarchySection = config.viewMode === 'management' && !isPlaygroundSection
|
||||
const searchIntent = useMemo(() => {
|
||||
@@ -2486,6 +2652,12 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
source_ids: sourceIds,
|
||||
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', '批量触发结果', [{
|
||||
...response.data,
|
||||
__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 id = pick(record, ['id', 'source_id', 'key', 'name'], '')
|
||||
if (!id) return
|
||||
@@ -2535,6 +2726,33 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
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 id = pick(record, ['id', 'source_id', 'key', 'name'], '')
|
||||
if (!id) return
|
||||
@@ -2632,13 +2850,27 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
}
|
||||
const taskId = response.data?.task_id
|
||||
const pendingKey = text(taskId, id)
|
||||
pendingDatasourceTasksRef.current[pendingKey] = {
|
||||
sourceId: id,
|
||||
source: text(record.source || response.data?.collector_name, ''),
|
||||
name: recordTitle(record),
|
||||
taskId,
|
||||
}
|
||||
completedDatasourceTasksRef.current.delete(pendingKey)
|
||||
pendingDatasourceTasksRef.current[pendingKey] = {
|
||||
sourceId: id,
|
||||
source: text(record.source || response.data?.collector_name, ''),
|
||||
name: recordTitle(record),
|
||||
taskId,
|
||||
}
|
||||
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({
|
||||
...record,
|
||||
id: record.id || Number(id) || id,
|
||||
@@ -2659,8 +2891,8 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
__status: '已提交',
|
||||
__metric: response.data?.task_id || '-',
|
||||
}])
|
||||
toast({ title: force ? '已强制重新触发' : '任务已触发', tone: 'success' })
|
||||
scheduleDatasourceTaskPoll(record, taskId)
|
||||
toast({ title: force ? '已强制重新触发' : '任务已触发', tone: 'success' })
|
||||
scheduleDatasourceTaskPoll(record, taskId)
|
||||
} catch (error) {
|
||||
toast({ title: '触发采集失败', description: actionErrorMessage(error), tone: 'error' })
|
||||
} finally {
|
||||
@@ -3957,6 +4189,10 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
await saveHierarchySettings('/earth/brand', payload, '保存品牌配置')
|
||||
return
|
||||
}
|
||||
if (activeSection.key === 'about') {
|
||||
await saveHierarchySettings('/earth/about', payload, '保存关于配置')
|
||||
return
|
||||
}
|
||||
if (activeGroup.key.includes('boundaries')) {
|
||||
await saveHierarchySettings('/earth/boundaries/config', { config: payload }, '保存边界配置')
|
||||
return
|
||||
@@ -4185,6 +4421,15 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
})} loading={actionLoading}><Trash2 size={15} /></Button>
|
||||
</>
|
||||
) : 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' ? (
|
||||
<Button variant="primary" icon="trigger" onClick={() => void requestAction('启动边界构建', 'post', '/earth/boundaries/build', undefined, { refresh: false, successDescription: '边界构建任务已提交,当前表单未保存。' })} loading={actionLoading}>构建</Button>
|
||||
) : null}
|
||||
@@ -4636,7 +4881,7 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
const actions: ReactNode[] = []
|
||||
if (config === configs.datasources) {
|
||||
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 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 credentialGuideProviderName = credentialGuide ? text(credentialGuide.provider, 'barentswatch') : ''
|
||||
const credentialGuideMarkdown = credentialGuide
|
||||
@@ -4790,6 +5144,7 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
|
||||
<SectionTabs sections={config.sections} states={states} activeKey={activeSection?.key || ''} onChange={handleSectionChange} />
|
||||
{renderDatasourceFilters()}
|
||||
{renderCollectionQueue()}
|
||||
</div>
|
||||
|
||||
{isPlaygroundSection ? (
|
||||
@@ -4841,6 +5196,7 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
<StatusText tone={statusTone(recordStatus(selected))}>{recordStatus(selected)}</StatusText>
|
||||
</header>
|
||||
{renderRecordActions()}
|
||||
{renderDatasourceTaskSummary()}
|
||||
<DetailMarkdownDocument record={selected} />
|
||||
{renderStructuredEditor() || <DetailFields record={selected} />}
|
||||
<div className="an-code-section">
|
||||
@@ -5112,6 +5468,7 @@ const configs = {
|
||||
viewMode: 'management',
|
||||
sections: [
|
||||
{ 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',
|
||||
label: '国界精度',
|
||||
|
||||
@@ -29,6 +29,12 @@
|
||||
--tui-primary-hover: var(--an-accent-hover);
|
||||
--tui-primary-active: var(--an-accent-hover);
|
||||
--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);
|
||||
}
|
||||
|
||||
@@ -49,6 +55,12 @@
|
||||
--an-info: #38bdf8;
|
||||
--an-row-hover: #1f2b3e;
|
||||
--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,
|
||||
@@ -301,6 +313,192 @@ body:has(.admin-next-theme-root[data-theme='dark']) .an-toast {
|
||||
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 {
|
||||
min-width: 0;
|
||||
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));
|
||||
}
|
||||
|
||||
.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 {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "planet"
|
||||
version = "0.63.1"
|
||||
version = "0.64.0"
|
||||
description = "智能星球计划 - 态势感知系统"
|
||||
requires-python = ">=3.14"
|
||||
dependencies = [
|
||||
|
||||
Reference in New Issue
Block a user