release: bump version to 0.64.0
This commit is contained in:
@@ -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.")
|
||||||
|
|||||||
@@ -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 内容新增“关于”配置接口和后台 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
|
## [0.63.1] — 2026-05-21
|
||||||
|
|
||||||
Released: 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.
|
`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.
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 或品牌配置。
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -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、态势告警
|
||||||
|
|||||||
@@ -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 会显示初始化引导,提示登录控制台并触发采集。这个引导由后端状态决定,不会因为清空浏览器缓存而误判。
|
||||||
|
|
||||||
进入后建议确认:
|
进入后建议确认:
|
||||||
|
|
||||||
- 地球正常显示,右侧图层面板可以打开/关闭
|
- 地球正常显示,右侧图层面板可以打开/关闭
|
||||||
|
|||||||
@@ -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 和移动端详情 |
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
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,
|
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({
|
||||||
|
|||||||
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
|
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: '国界精度',
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 = [
|
||||||
|
|||||||
Reference in New Issue
Block a user