From 37e92e75720c9bfb56b19b589344f8b94a598daf Mon Sep 17 00:00:00 2001 From: linkong Date: Thu, 21 May 2026 03:46:02 +0800 Subject: [PATCH] release: bump version to 0.64.0 --- VERSION | 2 +- backend/app/api/v1/earth.py | 244 +++++++++-- docs/CHANGELOG.md | 17 + docs/technical/en/earth-frontend-context.md | 5 + .../en/frontend-admin-frontend-context.md | 20 + docs/technical/en/manual.md | 7 +- docs/technical/en/quickstart.md | 4 +- docs/technical/zh/earth-frontend-context.md | 5 + .../zh/frontend-admin-frontend-context.md | 20 + docs/technical/zh/manual.md | 7 +- docs/technical/zh/quickstart.md | 4 +- docs/version-history.md | 3 +- frontend/package.json | 2 +- frontend/public/earth/css/hud.css | 310 ++++++++++++++ frontend/public/earth/js/about.js | 96 +++++ frontend/public/earth/js/main.js | 4 + frontend/public/earth/js/oobe.js | 126 ++++++ .../admin-next/pages/PlainResourcePages.tsx | 391 +++++++++++++++++- frontend/src/admin-next/styles.css | 212 ++++++++++ pyproject.toml | 2 +- uv.lock | 2 +- 21 files changed, 1418 insertions(+), 65 deletions(-) create mode 100644 frontend/public/earth/js/about.js create mode 100644 frontend/public/earth/js/oobe.js diff --git a/VERSION b/VERSION index 630f2e0c..d4f16f06 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.63.1 +0.64.0 diff --git a/backend/app/api/v1/earth.py b/backend/app/api/v1/earth.py index 85fe81db..fb8571c7 100644 --- a/backend/app/api/v1/earth.py +++ b/backend/app/api/v1/earth.py @@ -9,13 +9,17 @@ from uuid import uuid4 from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile, status from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from pydantic import BaseModel, Field -from sqlalchemy import delete, select, text +from sqlalchemy import delete, func, select, text from sqlalchemy.ext.asyncio import AsyncSession from app.core.security import decode_token, get_current_user, redis_client from app.db.session import get_db +from app.models.collected_data import CollectedData +from app.models.datasource import DataSource +from app.models.datasource_config import DataSourceConfig from app.models.system_setting import SystemSetting from app.models.user import User +from app.services.tv_streams import get_tv_settings_payload from app.services.earth_boundaries import ( EarthBoundaryBuildError, get_boundary_build_status, @@ -31,6 +35,7 @@ REPO_ROOT = Path(__file__).resolve().parents[4] EARTH_BRAND_ASSET_DIR = REPO_ROOT / "data" / "earth-brand" EARTH_BRAND_ASSET_URL_PREFIX = "/earth-brand-assets" EARTH_BRAND_CATEGORY = "earth_brand" +EARTH_ABOUT_CATEGORY = "earth_about" MAX_EARTH_BRAND_ASSET_BYTES = 3 * 1024 * 1024 ALLOWED_EARTH_BRAND_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".svg"} @@ -44,6 +49,19 @@ DEFAULT_EARTH_BRAND = { "title_alt": "智能星球计划", } +DEFAULT_EARTH_ABOUT = { + "logo_src": "/earth/assets/brand/lim-logo.png", + "kicker": "About", + "title": "智能星球计划", + "version": "v0.64.0", + "description": "面向临空场景下的智能媒体研究、全球态势感知与多源开放数据巡航,提供可视化观测、事件聚合与交互式探索能力。", + "meta": [ + {"label": "出品方", "value": "浙江大学临空智能媒体研究院"}, + {"label": "策划人", "value": "黄柳青"}, + {"label": "产品兼开发者", "value": "钱坤、张鸽、齐鹏"}, + ], +} + class EarthBoundaryConfigPayload(BaseModel): config: dict[str, Any] = Field(default_factory=dict) @@ -59,6 +77,20 @@ class EarthBrandPayload(BaseModel): title_alt: str = Field(default=DEFAULT_EARTH_BRAND["title_alt"], max_length=200) +class EarthAboutMetaItem(BaseModel): + label: str = Field(default="", max_length=80) + value: str = Field(default="", max_length=240) + + +class EarthAboutPayload(BaseModel): + logo_src: str = Field(default=DEFAULT_EARTH_ABOUT["logo_src"], max_length=1000) + kicker: str = Field(default=DEFAULT_EARTH_ABOUT["kicker"], max_length=80) + title: str = Field(default=DEFAULT_EARTH_ABOUT["title"], max_length=160) + version: str = Field(default=DEFAULT_EARTH_ABOUT["version"], max_length=80) + description: str = Field(default=DEFAULT_EARTH_ABOUT["description"], max_length=800) + meta: list[EarthAboutMetaItem] = Field(default_factory=list) + + def _normalize_earth_brand_payload(payload: dict[str, Any] | None) -> dict[str, str]: merged = DEFAULT_EARTH_BRAND.copy() if payload: @@ -76,6 +108,40 @@ def _normalize_earth_brand_payload(payload: dict[str, Any] | None) -> dict[str, return merged +def _normalize_earth_about_payload(payload: dict[str, Any] | None) -> dict[str, Any]: + merged: dict[str, Any] = { + key: value + for key, value in DEFAULT_EARTH_ABOUT.items() + if key != "meta" + } + raw_meta = DEFAULT_EARTH_ABOUT["meta"] + if payload: + for key in ("logo_src", "kicker", "title", "version", "description"): + value = payload.get(key) + if value is not None: + merged[key] = str(value).strip() + raw_meta = payload.get("meta") if isinstance(payload.get("meta"), list) else raw_meta + + for key, default_value in DEFAULT_EARTH_ABOUT.items(): + if key == "meta": + continue + if not merged.get(key): + merged[key] = default_value + + normalized_meta: list[dict[str, str]] = [] + for item in raw_meta: + if not isinstance(item, dict): + continue + label = str(item.get("label") or "").strip() + value = str(item.get("value") or "").strip() + if label or value: + normalized_meta.append({"label": label, "value": value}) + if not normalized_meta: + normalized_meta = [dict(item) for item in DEFAULT_EARTH_ABOUT["meta"]] + merged["meta"] = normalized_meta + return merged + + async def _get_earth_brand_record(db: AsyncSession) -> SystemSetting | None: result = await db.execute( select(SystemSetting).where(SystemSetting.category == EARTH_BRAND_CATEGORY) @@ -91,6 +157,56 @@ async def _get_earth_brand_payload(db: AsyncSession) -> dict[str, Any]: } +async def _get_earth_about_record(db: AsyncSession) -> SystemSetting | None: + result = await db.execute( + select(SystemSetting).where(SystemSetting.category == EARTH_ABOUT_CATEGORY) + ) + return result.scalar_one_or_none() + + +async def _get_earth_about_payload(db: AsyncSession) -> dict[str, Any]: + record = await _get_earth_about_record(db) + return { + "about": _normalize_earth_about_payload(record.payload if record else None), + "is_default": record is None, + } + + +async def _get_optional_current_user( + credentials: HTTPAuthorizationCredentials | None = Depends(optional_bearer), + db: AsyncSession = Depends(get_db), +) -> User | None: + if credentials is None: + return None + token = credentials.credentials + if redis_client.sismember("blacklisted_tokens", token): + return None + payload = decode_token(token) + if payload is None or payload.get("type") != "access": + return None + user_id = payload.get("sub") + if user_id is None: + return None + result = await db.execute( + text( + "SELECT id, username, email, password_hash, role, is_active, gatekeeper_groups FROM users WHERE id = :id" + ), + {"id": int(user_id)}, + ) + row = result.fetchone() + if row is None or not row[5]: + return None + user = User() + user.id = row[0] + user.username = row[1] + user.email = row[2] + user.password_hash = row[3] + user.role = row[4] + user.is_active = row[5] + user.gatekeeper_groups = row[6] or [] + return user + + @router.get("/brand") async def get_earth_brand(db: AsyncSession = Depends(get_db)): return await _get_earth_brand_payload(db) @@ -159,46 +275,100 @@ async def upload_earth_brand_asset( return {"url": asset_url, "filename": safe_name, "content_type": file.content_type} +@router.get("/about") +async def get_earth_about(db: AsyncSession = Depends(get_db)): + return await _get_earth_about_payload(db) + + +@router.put("/about") +async def update_earth_about( + payload: EarthAboutPayload, + _current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + normalized = _normalize_earth_about_payload(payload.model_dump()) + record = await _get_earth_about_record(db) + if record is None: + record = SystemSetting(category=EARTH_ABOUT_CATEGORY, payload=normalized) + db.add(record) + else: + record.payload = normalized + await db.commit() + await db.refresh(record) + return {"status": "updated", "about": _normalize_earth_about_payload(record.payload), "is_default": False} + + +@router.delete("/about") +async def reset_earth_about( + _current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + await db.execute(delete(SystemSetting).where(SystemSetting.category == EARTH_ABOUT_CATEGORY)) + await db.commit() + return {"status": "reset", "about": _normalize_earth_about_payload(None), "is_default": True} + + +@router.get("/oobe-status") +async def get_earth_oobe_status( + current_user: User | None = Depends(_get_optional_current_user), + db: AsyncSession = Depends(get_db), +): + current_count_result = await db.execute( + select(func.count(CollectedData.id)).where(CollectedData.is_current.is_(True)) + ) + current_record_count = int(current_count_result.scalar() or 0) + + datasource_count_result = await db.execute(select(func.count(DataSource.id))) + datasource_count = int(datasource_count_result.scalar() or 0) + active_datasource_count_result = await db.execute( + select(func.count(DataSource.id)).where(DataSource.is_active.is_(True)) + ) + active_datasource_count = int(active_datasource_count_result.scalar() or 0) + config_result = await db.execute(select(func.count(DataSourceConfig.id))) + custom_config_count = int(config_result.scalar() or 0) + + tv_payload = await get_tv_settings_payload(db) + tv_sources = tv_payload.get("sources") if isinstance(tv_payload, dict) else [] + tv_source_count = len(tv_sources) if isinstance(tv_sources, list) else 0 + + boundary_status = get_boundary_status() + has_core_layers = bool(boundary_status.get("ready") or boundary_status.get("available") or boundary_status.get("status") in {"ready", "built", "ok"}) + has_collected_data = current_record_count > 0 + ready = has_collected_data + + suggestions: list[str] = [] + if not current_user: + suggestions.append("登录控制台") + if not has_collected_data: + suggestions.append("触发数据源采集") + if not custom_config_count: + suggestions.append("确认采集器配置") + if not has_core_layers: + suggestions.append("构建或启用 Earth 图层") + + return { + "ready": ready, + "authenticated": current_user is not None, + "needs_login": current_user is None and not ready, + "has_collected_data": has_collected_data, + "has_tv_sources": tv_source_count > 0, + "has_core_layers": has_core_layers, + "current_record_count": current_record_count, + "datasource_count": datasource_count, + "active_datasource_count": active_datasource_count, + "custom_config_count": custom_config_count, + "tv_source_count": tv_source_count, + "suggestions": suggestions, + "login_url": "/login?next=/datasources", + "datasources_url": "/datasources", + "collection_url": "/collection-management", + } + + @router.get("/boundaries/status") async def get_earth_boundary_status(): return get_boundary_status() - -async def _get_optional_current_user( - credentials: HTTPAuthorizationCredentials | None = Depends(optional_bearer), - db: AsyncSession = Depends(get_db), -) -> User | None: - if credentials is None: - return None - token = credentials.credentials - if redis_client.sismember("blacklisted_tokens", token): - return None - payload = decode_token(token) - if payload is None or payload.get("type") != "access": - return None - user_id = payload.get("sub") - if user_id is None: - return None - result = await db.execute( - text( - "SELECT id, username, email, password_hash, role, is_active, gatekeeper_groups FROM users WHERE id = :id" - ), - {"id": int(user_id)}, - ) - row = result.fetchone() - if row is None or not row[5]: - return None - user = User() - user.id = row[0] - user.username = row[1] - user.email = row[2] - user.password_hash = row[3] - user.role = row[4] - user.is_active = row[5] - user.gatekeeper_groups = row[6] or [] - return user - - def _is_loopback_request(request: Request) -> bool: host = request.client.host if request.client else "" return host in {"127.0.0.1", "::1", "localhost"} or host.startswith("127.") diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 6daded50..a0d3b85a 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -8,6 +8,23 @@ This project follows the repository versioning rule: - `improvement` -> `+0.0.1`(bugfix + 小功能混合) - `bugfix` -> `+0.0.1` +## [0.64.0] — 2026-05-21 + +Released: 2026-05-21 + +### Highlights +- 新增 Earth 首次初始化 OOBE,由后端真实采集状态决定是否显示,避免 localStorage 清空后误弹,并提供桌面毛玻璃引导与移动端 bottom sheet。 +- 数据源页新增下载列表式采集队列,把单源、批量和触发全部的任务进度统一展示,并支持失败重试与跳转详情。 +- Earth 内容新增“关于”配置接口和后台 tab,Earth 设置页 About 卡片改为运行时读取配置并带默认 fallback。 + +### Added / Fixed / Improved +- 新增 `/api/v1/earth/oobe-status`、`/api/v1/earth/about` GET/PUT/DELETE,并让 Earth 前端加载 `about.js` 与 `oobe.js`。 +- Admin Next 数据源队列优先消费 `datasource_tasks` WebSocket,断线时轮询 `/datasources/{id}/task-status`,刷新后只恢复后端仍在运行的真实任务。 +- Admin Next 深色主题滑块补齐 Docs 同款 dark token,侧栏主题控件在 dark 模式下不再保持浅色底座。 +- 用户手册、快速开始、Earth 前端上下文和 Admin 前端上下文同步记录 OOBE、采集队列、About 配置与主题滑块行为。 + +--- + ## [0.63.1] — 2026-05-21 Released: 2026-05-21 diff --git a/docs/technical/en/earth-frontend-context.md b/docs/technical/en/earth-frontend-context.md index 64a4d39c..7a760b87 100644 --- a/docs/technical/en/earth-frontend-context.md +++ b/docs/technical/en/earth-frontend-context.md @@ -151,9 +151,14 @@ Each module is responsible for its own: `brand.js` manages Earth HUD brand resources. Static assets provide the default brand; runtime overrides come from `/api/v1/earth/brand`, and uploaded images are served from `/earth-brand-assets/...`. The frontend must treat logo/title images and text fallback separately: if an image fails, show the text title; if text fields are empty, rely on backend defaults so the HUD brand area never renders blank. The console Earth Content page owns saving and resetting brand configuration; the Earth frontend only consumes it. +`about.js` manages the About card inside Earth settings. Frontend defaults remain as a fallback, while runtime content is loaded from `/api/v1/earth/about`. If the request fails or fields are missing, the renderer must fall back per field so the settings page never renders an empty card. Admin Next exposes an Earth Content `About` tab; saving uses `PUT /api/v1/earth/about`, and restoring defaults uses `DELETE /api/v1/earth/about`. + +`oobe.js` manages the first-run Earth initialization guide. OOBE visibility must be driven by `/api/v1/earth/oobe-status` and its `ready` field, not by `localStorage`. `localStorage` may only store a short-lived "skip on this browser" flag; if the backend reports `ready: true`, logout, cleared browser storage, or a different browser must not show OOBE again. Desktop uses a dark starfield scrim and glass startup panel, while mobile uses a bottom sheet and respects `prefers-reduced-motion`. + The Admin Next Earth Content page must preserve runtime semantics: - `Brand`: brand preview should use the same dark starfield background, size, spacing, logo/title rendering, and text fallback as the Earth HUD top-left brand block, not a generic form preview. +- `About`: configures the About card in Earth settings, including logo, kicker, title, version, description, and metadata items. Earth runtime reads `/earth/about` and falls back to defaults on failure. - `Boundary Precision`: build boundary, refresh status, and restore defaults belong inside this section, not in the global page toolbar. - `TV`: the list distinguishes built-in, collected, and custom sources. Card state represents enabled, disabled, draft, or error. Built-in sources cannot be deleted; collected and custom sources can. A new live source only enters draft state after the plus button is clicked; save persists it into the list, while cancel destroys the draft. - `Basemap`, `Layer Resources`, `3D Models`, and `News Anchor Strategy`: if backend capability is not available yet, the console should show an explicit pending state instead of mixing those items into TV or brand configuration. diff --git a/docs/technical/en/frontend-admin-frontend-context.md b/docs/technical/en/frontend-admin-frontend-context.md index 7c0f47ea..c5068dc4 100644 --- a/docs/technical/en/frontend-admin-frontend-context.md +++ b/docs/technical/en/frontend-admin-frontend-context.md @@ -110,6 +110,26 @@ Multi-tab pages are currently coordinated by [PlainResourcePages.tsx](/home/ray/ This keeps Earth, AI, collection management, and other multi-section pages from flooding backend APIs on cold start while preserving a fast cached tab-switching experience. Full health checks should use backend health endpoints or explicit refresh flows rather than relying on page initialization to touch every business endpoint. +## Datasource Collection Queue + +The Admin Next datasource page routes single-source trigger, batch trigger, and trigger-all into a browser-download-list style collection queue: + +- Queue state is managed by [PlainResourcePages.tsx](/home/ray/dev/linkong/planet/frontend/src/admin-next/pages/PlainResourcePages.tsx). It is a current-session visibility layer and does not fake task history in `localStorage`. +- Progress first consumes the `/ws` `datasource_tasks` channel. If the socket is unavailable or stale, the page polls `/api/v1/datasources/{id}/task-status`. +- Trigger responses immediately insert `triggered`, `skipped`, and `failed` items. After a refresh, the queue restores only real backend rows that are still `running`, `pending`, or `queued`. +- The page shows a compact progress bar and counts above the table. The "view queue" action opens a bottom panel grouped by running, failed, completed, and skipped. +- Queue rows can jump to the datasource detail panel, and failed rows can retry. The detail panel's task summary only reports the selected source's latest task; it does not save configuration. + +This queue is a user-perception layer. Backend task status remains the only source of truth for running, completion, failure, and skipped decisions. + +## Admin Next Theme Slider + +The Admin Next sidebar theme switcher still reuses shared [SegmentedControl.tsx](/home/ray/dev/linkong/planet/frontend/src/components/SegmentedControl/SegmentedControl.tsx), while [styles.css](/home/ray/dev/linkong/planet/frontend/src/admin-next/styles.css) overrides the segment variables by `data-theme`: + +- Light mode uses `--d-segment-bg: #eef3f9`, a white slider, and a light external shadow. +- Dark mode uses the same dark base, `#202938` slider, and dark external shadow semantics as Docs. +- Product code only hides the text label and keeps icon + tooltip behavior; it should not recreate the private slider DOM. + ## Current Shared Components ### 1. `Scrollbar` diff --git a/docs/technical/en/manual.md b/docs/technical/en/manual.md index 6f3e7c09..1f0da049 100644 --- a/docs/technical/en/manual.md +++ b/docs/technical/en/manual.md @@ -188,19 +188,22 @@ TV livestreams and boundary precision moved to `/earth-content`; collectors and `/earth-content` is under the console's Operations and Configuration group and owns resources used by the Earth frontend: - **Brand Assets**: manages the logo, title image, title text, subtitle, and description used by the Earth HUD. Uploaded images are saved as Earth brand assets and read by the Earth page immediately. +- **About**: manages the About card shown in Earth settings, including logo, kicker, title, version, description, and metadata. - **TV Livestream**: manages sources shown in the Earth media panel. - **Boundary Precision**: shows the current provider, low-precision fallback, high-precision PMTiles/manifest status, local source JSON, and manual build action. - **Base Map**, **Layer Resources**, **3D Assets**, and **News Anchor Strategy**: placeholder tabs for future configuration. They do not display fake data. The Earth page settings gear also includes Boundary Precision. Switching to High Precision starts a local background download/build, like a game update package, when no high-precision asset exists yet. Progress is shown as a percentage, and the result applies automatically after success without a page reload. Switching back to Low Precision only changes the local display preference. +If the backend decides Earth is not initialized yet, the first visit to `/earth` shows a glassy startup guide that points the user to sign in and collect data. This is based on backend data state; once the system has collected data, clearing browser storage does not make the guide reappear. + ### Collection Management `/collection-management` is also under Operations and Configuration and owns the collection lifecycle: - **Collectors**: endpoint, headers, credentials, timeout, retry, and connection checks. - **Collection Scheduling**: the existing scheduling configuration. -- **Collection History / Snapshots**: a placeholder for future collection task, snapshot, and collected-data browsing. +- **Collection History / Snapshots**: groups snapshots by datasource and lets the detail panel switch versions through a Time Capsule selector. ### SMTP Email Settings @@ -230,7 +233,7 @@ To let a regular user read developer or operations docs, add `docs_developer` or ## Data Exploration -- `/datasources`: source directory. The `Collection Tasks` tab is for one-shot, scheduled, and finite collectors; it can be filtered by product domain, layer/module, enabled state, last run status, whether collected records exist, and search text. Selecting rows triggers only those sources; with no selected rows, `Collect current filter` triggers the filtered scope. The `Realtime Streams` tab is for AISStream / WebSocket long connections and shows connection health, stored totals, time-window counters, and Start / Stop / Reconnect actions. Clicking a name opens an info drawer showing endpoint, headers, base config, and built-in flag; endpoint/credentials editing happens at `/collection-management -> Collectors`. The `Collecting N` tag under the overall progress can be clicked to expand the current collection task list +- `/datasources`: source directory. The `Built-in Sources` tab can be filtered by product domain, layer/module, enabled state, last run status, whether collected records exist, and search text. Selecting rows triggers only those sources; `Trigger All` enters a browser-download-list style collection queue. The queue bar shows total progress plus running, completed, failed, and skipped counts; `View Queue` opens the bottom panel, failed rows can retry, and completed rows can jump to detail. `Realtime Sources` is for AISStream / WebSocket long connections and shows connection health, stored totals, time-window counters, and Start / Stop / Reconnect actions. Endpoint/credential/header editing happens at `/collection-management -> Collectors`. - `/data`: collected data table — used to verify "did data arrive", "is the freshness right", "does a source emit valid records" - `/bgp`: BGP detail page with list + detail + analysis; complements the BGP layer on Earth - `/alerts/system`, `/alerts/bgp`, `/alerts/situational`: system, BGP, and situational alerts diff --git a/docs/technical/en/quickstart.md b/docs/technical/en/quickstart.md index 8318c02d..527f1663 100644 --- a/docs/technical/en/quickstart.md +++ b/docs/technical/en/quickstart.md @@ -34,7 +34,7 @@ After landing on the `/admin` dashboard, here's a recommended walk-through: 1. `/collection-management?tab=collector_credentials`: pick a collector and click the plug icon to test connectivity. Free collectors (e.g. open BGP) usually work right away; credential-bearing ones like `AISStream` or `BarentsWatch` need an API key / client secret first 2. `/ai?tab=providers`: fill an LLM provider (e.g. `minimax` / `openai`), model, base URL, API key, and click the plug at the end of the base URL to test. WebSearch / OCR tools are optional -3. `/datasources` or `/data`: check whether collectors have produced data. Use `/datasources -> Collection Tasks` for finite collectors, and `/datasources -> Realtime Streams` for AISStream / WebSocket health and counters +3. `/datasources` or `/data`: check whether collectors have produced data. Use `/datasources -> Built-in Sources` for finite collectors and open the collection queue after triggering; use `/datasources -> Realtime Sources` for AISStream / WebSocket health and counters 4. `/alerts/system`: verify system alerts look right 5. `/users` (super_admin only): open accounts for teammates or adjust their groups @@ -42,6 +42,8 @@ After landing on the `/admin` dashboard, here's a recommended walk-through: Visit `/earth`. This is a public page — no login required. +If the system has no collected data yet, Earth shows an initialization guide that points you to sign in and trigger collection. This guide is backend-state driven, so clearing browser storage does not make it appear once the system is ready. + Once in, verify: - The globe renders, and the right-side layer panel can toggle layers diff --git a/docs/technical/zh/earth-frontend-context.md b/docs/technical/zh/earth-frontend-context.md index 82d2aee5..9fa9c3e0 100644 --- a/docs/technical/zh/earth-frontend-context.md +++ b/docs/technical/zh/earth-frontend-context.md @@ -162,9 +162,14 @@ Earth 地表是多层近似同心球,不是单一 mesh。`earth.js` 的基座 `brand.js` 管理 Earth HUD 品牌资源。默认品牌来自静态资源,运行时覆盖值来自 `/api/v1/earth/brand`,上传的图片通过 `/earth-brand-assets/...` 读取。前端必须把 logo/title 图片和文本 fallback 分开处理:图片加载失败时显示文本标题,文本字段为空时使用后端默认值,避免 HUD 品牌区空白。控制台的 Earth 内容页负责保存和重置品牌配置,Earth 前端只消费结果。 +`about.js` 管理 Earth 设置里的“关于”卡片。默认内容仍保留在前端作为兜底,运行时优先读取 `/api/v1/earth/about`。接口失败或字段缺失时必须回退默认值,避免设置页出现空白。Admin Next 的 Earth 内容页提供“关于”tab,保存走 `PUT /api/v1/earth/about`,恢复默认走 `DELETE /api/v1/earth/about`。 + +`oobe.js` 管理 Earth 首次初始化引导。是否显示 OOBE 必须由 `/api/v1/earth/oobe-status` 的 `ready` 字段决定,不能依赖 `localStorage` 判断系统是否初始化。`localStorage` 只允许记录“本浏览器暂时跳过”的短时状态;如果后端已经认为 `ready: true`,退出登录、清空本地缓存或换浏览器都不应再次弹出 OOBE。桌面端使用深色星空遮罩和毛玻璃启动面板,移动端改为底部 sheet,并尊重 `prefers-reduced-motion`。 + Admin Next 的 Earth 内容页必须按运行时语义组织这些配置: - `品牌标识`:品牌预览应使用与 Earth HUD 左上角一致的深色星空背景、尺寸、间距、logo/title 渲染和文本 fallback,而不是普通表单预览。 +- `关于`:配置 Earth 设置里的 About 卡片,包括 logo、眉标、标题、版本、描述和元信息条目;Earth 运行时从 `/earth/about` 读取,失败时回退默认内容。 - `国界精度`:构建边界、刷新状态和恢复默认属于这个分区内部动作,不应放在页面全局工具栏。 - `电视直播`:列表同时区分内置源、采集源和自定义源;卡片状态表达启用、停用、新建或错误。内置源不能删除,采集源和自定义源可以删除。新增直播源在点击加号后才进入草稿状态,保存后固化到列表,取消则销毁草稿。 - `底图资源`、`图层资源`、`3D 模型`、`新闻锚点策略`:如果后端能力未接入,控制台应明确显示待接入空态,不应混入 TV 或品牌配置。 diff --git a/docs/technical/zh/frontend-admin-frontend-context.md b/docs/technical/zh/frontend-admin-frontend-context.md index 1a4e7602..0082e8c9 100644 --- a/docs/technical/zh/frontend-admin-frontend-context.md +++ b/docs/technical/zh/frontend-admin-frontend-context.md @@ -110,6 +110,26 @@ legacy 职责: 这样做是为了降低 Earth、AI、采集管理等多分区页面的冷启动压力,同时保留 tab 数量、状态和用户切换后的缓存体验。需要全量健康巡检时应走后端健康接口或显式刷新流程,不要依赖页面初始化时顺手拉所有业务接口。 +## 数据源采集队列 + +Admin Next 的数据源页把单源触发、批量触发和触发全部统一接入浏览器下载列表式采集队列: + +- 队列状态由 [PlainResourcePages.tsx](/home/ray/dev/linkong/planet/frontend/src/admin-next/pages/PlainResourcePages.tsx) 管理,只保存当前会话中的可见任务,不用 `localStorage` 伪造历史。 +- 任务进度优先消费 `/ws` 的 `datasource_tasks` channel;如果 WebSocket 未连接或没有及时返回,则轮询 `/api/v1/datasources/{id}/task-status`。 +- 触发接口返回的 `triggered`、`skipped`、`failed` 会立即进入队列;刷新页面后只根据后端当前仍在 `running/pending/queued` 的数据源恢复队列。 +- 页面上方只显示紧凑进度条和数量摘要;点击“查看队列”展开底部面板,按运行中、失败、完成、跳过分组。 +- 队列项可以跳转到对应数据源详情,失败项可以重试。详情页内的“采集任务”摘要只展示当前数据源最近任务,不承担保存配置职责。 + +这个队列是用户感知层,不替代后端调度状态。后端仍然是任务是否运行、完成、失败或跳过的唯一事实来源。 + +## Admin Next 主题滑块 + +Admin Next 侧栏底部主题切换继续复用共享 [SegmentedControl.tsx](/home/ray/dev/linkong/planet/frontend/src/components/SegmentedControl/SegmentedControl.tsx),但主题变量在 [styles.css](/home/ray/dev/linkong/planet/frontend/src/admin-next/styles.css) 内跟随 `data-theme` 覆盖: + +- light 下使用 `--d-segment-bg: #eef3f9`、白色 slider 和轻投影。 +- dark 下使用与 Docs 一致的深色底座、`#202938` slider 和深色外投影。 +- 业务侧只隐藏文字 label 并保留 icon + tooltip,不重写 slider DOM。 + ## 当前共享组件 ### 1. `Scrollbar` diff --git a/docs/technical/zh/manual.md b/docs/technical/zh/manual.md index b436f7d3..2106ead9 100644 --- a/docs/technical/zh/manual.md +++ b/docs/technical/zh/manual.md @@ -191,19 +191,22 @@ Base URL 输入框尾端的插头图标会触发连接测试。测试通过会 `/earth-content` 位于控制台“运维与配置”下,面向 Earth 前端体验资源: - **品牌资源**:维护 Earth HUD 使用的 logo、标题图、标题文本、副标题和描述;上传的图片会保存为 Earth 品牌资产并立即供 Earth 页面读取。 +- **关于**:维护 Earth 设置面板里的关于卡片,包括 logo、眉标、标题、版本、描述和元信息。 - **电视直播**:维护 Earth 媒体面板里的直播源。 - **国界精度**:查看当前国界 provider、低精 fallback、高精 PMTiles/manifest 状态,编辑本机源配置并手动构建。 - **地球底图**、**图层资源**、**三维素材**、**新闻锚点策略**:目前是待接入占位页,不展示假数据。 Earth 页面工具栏齿轮中也有“国界精度”。切到“高精”时,如果本机尚未构建高精资产,会像游戏更新包一样启动后台下载/构建并显示百分比;构建成功后自动应用,无需刷新。切回“低精”只切换本机显示偏好,不重新下载。 +如果后端判断 Earth 尚未初始化,首次进入 `/earth` 会出现毛玻璃引导,提示登录控制台并采集数据。这个判断来自后端真实数据状态;如果系统已经有已采集数据,清空浏览器缓存也不会重新弹出。 + ### 采集管理 `/collection-management` 位于控制台“运维与配置”下,面向采集生命周期: - **采集器**:维护 endpoint、请求头、凭证、timeout、retry,并运行连接检查。 - **采集调度**:维护原有调度相关设置。 -- **采集历史 / 快照**:当前是待接入占位页,后续承载 collection task、snapshot、collected data 浏览能力。 +- **采集历史 / 快照**:按数据源聚合历史快照,详情页可用 Time Capsule 下拉切换不同版本。 ### SMTP 邮件设置 @@ -233,7 +236,7 @@ Earth 页面工具栏齿轮中也有“国界精度”。切到“高精”时 ## 数据探索 -- `/datasources`:数据源目录。`采集任务` tab 面向一次性/定时采集器,可以按产品域、层级、启用状态、最近执行状态、是否已有采集数据和关键词筛选;勾选多行后可批量采集选中项,未勾选时“一键采集”触发当前筛选范围。`实时流` tab 面向 AISStream / WebSocket 长连接,展示连接健康、累计入库、时间窗统计和启动 / 停止 / 重连操作。点击名称打开信息抽屉查看 endpoint、请求头、基础配置和是否内置;接口、凭证、请求头的编辑统一在 `/collection-management` 的"采集器"。总体进度下方的 `采集中 N` 标签可点击,展开当前采集任务列表 +- `/datasources`:数据源目录。`内置源` 支持按产品域、层级、启用状态、最近执行状态、是否已有采集数据和关键词筛选;勾选多行后可批量触发,`触发全部` 会进入类似浏览器下载列表的采集队列。队列上方显示总进度、运行中、完成、失败和跳过数量,`查看队列` 可展开底部面板,失败项可重试,完成项可跳到详情。`实时源` 面向 AISStream / WebSocket 长连接,展示连接健康、累计入库、时间窗统计和启动 / 停止 / 重连操作。接口、凭证、请求头的编辑统一在 `/collection-management` 的"采集器"。 - `/data`:采集后数据表,适合排查"数据是否已经进入系统"、"更新时间是否符合预期"、"某个数据源是否产出有效记录" - `/bgp`:BGP 专题页面,列表 + 详情 + 研判,与 Earth 的 BGP 图层互补 - `/alerts/system`、`/alerts/bgp`、`/alerts/situational`:系统、BGP、态势告警 diff --git a/docs/technical/zh/quickstart.md b/docs/technical/zh/quickstart.md index ab2f9443..cb33664b 100644 --- a/docs/technical/zh/quickstart.md +++ b/docs/technical/zh/quickstart.md @@ -34,7 +34,7 @@ 1. `/collection-management?tab=collector_credentials`:选一个采集器,点插头图标做连接测试。免费 collector(开源 BGP 等)通常直接可用;像 `AISStream`、`BarentsWatch` 这类需要凭证的,需要先填 API Key/Client Secret 2. `/ai?tab=providers`:填一个 LLM provider(例如 `minimax` / `openai`)、模型名、Base URL、API Key,点 Base URL 末端的插头测试连接。WebSearch / OCR 工具可选 -3. `/datasources` 或 `/data`:看采集器是否已经产出数据。有限采集器看 `/datasources -> 采集任务`,AISStream / WebSocket 长连接看 `/datasources -> 实时流` 的健康状态和计数 +3. `/datasources` 或 `/data`:看采集器是否已经产出数据。有限采集器看 `/datasources -> 内置源`,触发后可展开采集队列查看进度;AISStream / WebSocket 长连接看 `/datasources -> 实时源` 的健康状态和计数 4. `/alerts/system`:看系统告警是否正常 5. `/users`(仅 `super_admin`):根据需要给同事开账号或调权限组 @@ -42,6 +42,8 @@ 访问 `/earth`,公开页面,不需要登录。 +如果系统还没有已采集数据,Earth 会显示初始化引导,提示登录控制台并触发采集。这个引导由后端状态决定,不会因为清空浏览器缓存而误判。 + 进入后建议确认: - 地球正常显示,右侧图层面板可以打开/关闭 diff --git a/docs/version-history.md b/docs/version-history.md index 60631dbe..3ff859e8 100644 --- a/docs/version-history.md +++ b/docs/version-history.md @@ -16,12 +16,13 @@ ## Current Version - `main` 当前主线历史推导到:`0.16.5` -- `dev` 当前开发分支历史推导到:`0.63.1` +- `dev` 当前开发分支历史推导到:`0.64.0` ## Timeline | Version | Type | Branch | Commit | Summary | | --- | --- | --- | --- | --- | +| `0.64.0` | feature | `dev` | `pending` | Earth 新增后端状态驱动 OOBE 与 About 配置,Admin Next 数据源页新增采集队列,补齐深色主题滑块和用户/技术文档 | | `0.63.1` | bugfix | `dev` | `pending` | 补上被 `lib/` ignore 规则漏提交的 Admin Next utility module,修复新设备初始化后 AdminNextRoutes 动态导入 500 | | `0.63.0` | feature | `dev` | `pending` | `planet.sh` 新增空项目 `init` 和破坏性 `destroy` 入口,补齐服务运行中初始化保护、状态日志时间戳和 README 快速启动说明 | | `0.62.0` | feature | `dev` | `pending` | Admin Next 转正并迁移旧 AntD 到 `/legacy/admin/*`,补齐采集/AI Provider/Earth/日志/BGP/告警工作台,抽出 Tactile UI 组件库并优化懒加载、Markdown 和移动端详情 | diff --git a/frontend/package.json b/frontend/package.json index bb844840..77dc80ff 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "planet-frontend", - "version": "0.63.1", + "version": "0.64.0", "private": true, "packageManager": "bun@1", "dependencies": { diff --git a/frontend/public/earth/css/hud.css b/frontend/public/earth/css/hud.css index 063649b1..4d320d99 100644 --- a/frontend/public/earth/css/hud.css +++ b/frontend/public/earth/css/hud.css @@ -1441,6 +1441,11 @@ gap: 8px; } +.earth-about-meta-list { + display: grid; + gap: 8px; +} + .earth-about-meta { display: flex; flex-direction: column; @@ -1460,6 +1465,311 @@ line-height: 1.45; } +.earth-oobe { + position: fixed; + inset: 0; + z-index: 12000; + display: grid; + place-items: start center; + padding-top: min(16vh, 150px); + color: rgba(235, 245, 255, 0.96); + pointer-events: auto; +} + +.earth-oobe__scrim { + position: absolute; + inset: 0; + background: + radial-gradient(circle at 50% 28%, rgba(73, 136, 220, 0.18), transparent 38%), + rgba(2, 7, 16, 0.58); + animation: earth-oobe-fade-in 180ms ease-out both; +} + +.earth-oobe__panel { + position: relative; + z-index: 1; + width: min(620px, calc(100vw - 36px)); + overflow: hidden; + border: 1px solid rgba(132, 176, 236, 0.32); + border-radius: 22px; + background: + linear-gradient(135deg, rgba(11, 24, 45, 0.88), rgba(7, 14, 27, 0.76)), + rgba(8, 18, 34, 0.7); + box-shadow: + 0 28px 80px rgba(0, 0, 0, 0.42), + inset 0 1px 0 rgba(255, 255, 255, 0.14); + backdrop-filter: blur(22px) saturate(1.28); + padding: 28px; + transform-origin: 50% 40%; + animation: earth-oobe-panel-in 520ms cubic-bezier(0.19, 1, 0.22, 1) both; +} + +.earth-oobe__panel::before { + content: ""; + position: absolute; + inset: 0; + pointer-events: none; + background: + linear-gradient(90deg, transparent, rgba(125, 194, 255, 0.16), transparent), + radial-gradient(circle at 88% 0%, rgba(84, 142, 255, 0.2), transparent 30%); + opacity: 0.82; +} + +.earth-oobe__scan { + position: absolute; + left: -35%; + top: 0; + width: 34%; + height: 2px; + background: linear-gradient(90deg, transparent, rgba(135, 211, 255, 0.9), transparent); + box-shadow: 0 0 22px rgba(86, 176, 255, 0.65); + animation: earth-oobe-scan 2.8s ease-in-out infinite; +} + +.earth-oobe__close { + position: absolute; + right: 16px; + top: 14px; + width: 32px; + height: 32px; + border: 1px solid rgba(151, 190, 238, 0.28); + border-radius: 10px; + background: rgba(12, 24, 42, 0.58); + color: rgba(226, 240, 255, 0.86); + box-shadow: 0 8px 22px rgba(0, 0, 0, 0.28); + cursor: pointer; +} + +.earth-oobe__eyebrow, +.earth-oobe h2, +.earth-oobe__subtitle, +.earth-oobe__steps, +.earth-oobe__stats, +.earth-oobe__actions { + position: relative; + z-index: 1; +} + +.earth-oobe__eyebrow { + color: rgba(125, 204, 255, 0.82); + font-size: 0.72rem; + font-weight: 900; + letter-spacing: 0.18em; + text-transform: uppercase; +} + +.earth-oobe h2 { + margin: 10px 0; + color: #f7fbff; + font-size: clamp(1.7rem, 3vw, 2.4rem); + line-height: 1.05; +} + +.earth-oobe__subtitle { + max-width: 520px; + margin: 0; + color: rgba(190, 211, 235, 0.88); + line-height: 1.7; +} + +.earth-oobe__steps { + display: grid; + gap: 10px; + margin: 22px 0; + padding: 0; + list-style: none; +} + +.earth-oobe__step { + display: flex; + align-items: center; + gap: 10px; + min-height: 28px; + color: rgba(222, 235, 250, 0.9); + animation: earth-oobe-step-in 360ms ease-out both; + animation-delay: calc(180ms + var(--step-index, 0) * 80ms); +} + +.earth-oobe__step-dot { + width: 10px; + height: 10px; + border-radius: 999px; + background: #4ca3ff; + box-shadow: 0 0 18px rgba(76, 163, 255, 0.72); + animation: earth-oobe-dot-pulse 2.4s ease-in-out infinite; +} + +.earth-oobe__step--done .earth-oobe__step-dot { + background: #36d399; + box-shadow: 0 0 18px rgba(54, 211, 153, 0.68); +} + +.earth-oobe__step--warn .earth-oobe__step-dot { + background: #f6b73c; + box-shadow: 0 0 18px rgba(246, 183, 60, 0.62); +} + +.earth-oobe__stats { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; +} + +.earth-oobe__stats span { + border: 1px solid rgba(142, 178, 225, 0.22); + border-radius: 12px; + background: rgba(7, 15, 29, 0.46); + padding: 10px 12px; + color: rgba(180, 203, 229, 0.82); + font-size: 0.82rem; +} + +.earth-oobe__stats strong { + display: block; + color: #ffffff; + font-size: 1.28rem; + line-height: 1.1; +} + +.earth-oobe__actions { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-top: 22px; +} + +.earth-oobe__primary, +.earth-oobe__secondary, +.earth-oobe__ghost { + min-height: 38px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 12px; + padding: 0 16px; + font: inherit; + font-weight: 850; + text-decoration: none; + cursor: pointer; +} + +.earth-oobe__primary { + border: 1px solid rgba(88, 164, 255, 0.8); + background: linear-gradient(180deg, #4e9eff, #1f67e8); + color: #ffffff; + box-shadow: 0 12px 28px rgba(30, 103, 232, 0.34); +} + +.earth-oobe__secondary, +.earth-oobe__ghost { + border: 1px solid rgba(151, 190, 238, 0.28); + background: rgba(12, 24, 42, 0.52); + color: rgba(226, 240, 255, 0.9); +} + +.earth-oobe--closing { + pointer-events: none; + animation: earth-oobe-fade-out 220ms ease-in both; +} + +@keyframes earth-oobe-fade-in { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes earth-oobe-fade-out { + from { opacity: 1; } + to { opacity: 0; } +} + +@keyframes earth-oobe-panel-in { + from { + opacity: 0; + transform: translateY(26px) scale(0.96); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +@keyframes earth-oobe-step-in { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes earth-oobe-scan { + 0%, 34% { transform: translateX(0); opacity: 0; } + 42% { opacity: 1; } + 72%, 100% { transform: translateX(420%); opacity: 0; } +} + +@keyframes earth-oobe-dot-pulse { + 0%, 100% { transform: scale(1); opacity: 0.84; } + 50% { transform: scale(1.18); opacity: 1; } +} + +@media (max-width: 820px) { + .earth-oobe { + place-items: end center; + padding: 0 12px 14px; + } + + .earth-oobe__panel { + width: min(100%, 520px); + max-height: 72vh; + overflow: auto; + border-radius: 22px 22px 18px 18px; + padding: 24px 20px; + animation-name: earth-oobe-sheet-in; + } + + .earth-oobe__panel::after { + content: ""; + position: absolute; + top: 9px; + left: 50%; + width: 42px; + height: 4px; + border-radius: 999px; + background: rgba(202, 222, 247, 0.32); + transform: translateX(-50%); + } + + .earth-oobe__stats, + .earth-oobe__actions { + grid-template-columns: 1fr; + display: grid; + } +} + +@keyframes earth-oobe-sheet-in { + from { + opacity: 0; + transform: translateY(34px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@media (prefers-reduced-motion: reduce) { + .earth-oobe, + .earth-oobe__panel, + .earth-oobe__scan, + .earth-oobe__step, + .earth-oobe__step-dot { + animation: none !important; + } +} + .earth-mobile-settings-card--stacked { flex-direction: column; align-items: stretch; diff --git a/frontend/public/earth/js/about.js b/frontend/public/earth/js/about.js new file mode 100644 index 00000000..f2d4f3aa --- /dev/null +++ b/frontend/public/earth/js/about.js @@ -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, "'"); +} + +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) => ` +
+ ${escapeHtml(item.label)} + ${escapeHtml(item.value)} +
+ `, + ) + .join(""); + + return ` +
+ +
+
+ ${escapeHtml(about.kicker)} + ${escapeHtml(about.title)} + ${escapeHtml(about.version)} +
+

${escapeHtml(about.description)}

+
+ ${metaMarkup} +
+ `.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); + } +} diff --git a/frontend/public/earth/js/main.js b/frontend/public/earth/js/main.js index f488ac13..fb0fd28a 100644 --- a/frontend/public/earth/js/main.js +++ b/frontend/public/earth/js/main.js @@ -261,6 +261,8 @@ import { setLegendItems, } from "./legend.js"; import { fetchEarthBrandConfig, mountBrand } from "./brand.js"; +import { initEarthAbout } from "./about.js"; +import { initEarthOobe } from "./oobe.js"; import { initTVPanel } from "./tv.js"; import { initNewsPanel, refreshEarthNews, updateNewsViewFocus } from "./news.js"; import { initSearchPanel } from "./search.js"; @@ -3737,6 +3739,8 @@ export function init() { console.warn("Earth brand config unavailable, using defaults.", error); }); initTVPanel(); + initEarthAbout(); + initEarthOobe(); initNewsPanel(); connectEarthUpdatesRealtime(); initSearchPanel({ diff --git a/frontend/public/earth/js/oobe.js b/frontend/public/earth/js/oobe.js new file mode 100644 index 00000000..8c120bfb --- /dev/null +++ b/frontend/public/earth/js/oobe.js @@ -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, "'"); +} + +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 ` +
  • + + ${step.label} +
  • + `; + }) + .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 = ` +
    +
    + + +
    SYSTEM INITIALIZATION
    +

    欢迎使用智能星球计划

    +

    ${subtitle}

    + +
    + ${Number(status.current_record_count || 0)} 当前记录 + ${Number(status.tv_source_count || 0)} 直播源 + ${Number(status.active_datasource_count || 0)} 活跃数据源 +
    +
    + ${primaryText} + ${authenticated ? `进入采集管理` : ""} + +
    +
    + `; + + 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); + } +} diff --git a/frontend/src/admin-next/pages/PlainResourcePages.tsx b/frontend/src/admin-next/pages/PlainResourcePages.tsx index 2b5e3eeb..5b1be1e4 100644 --- a/frontend/src/admin-next/pages/PlainResourcePages.tsx +++ b/frontend/src/admin-next/pages/PlainResourcePages.tsx @@ -98,6 +98,27 @@ type DatasourceFilters = { dataStatus: string } +type CollectionQueueStatus = 'queued' | 'running' | 'success' | 'failed' | 'skipped' | 'cancelled' + +type CollectionQueueItem = { + key: string + sourceId: string + source?: string + name: string + taskId?: number | string | null + status: CollectionQueueStatus + phase?: string + phaseMessage?: string + progress?: number | null + recordsProcessed?: number | null + totalRecords?: number | null + reason?: string + error?: string + createdAt: number + updatedAt: number + completedAt?: number +} + const DEFAULT_DATASOURCE_FILTERS: DatasourceFilters = { product: '', module: '', @@ -237,6 +258,61 @@ function datasourceStatus(record: AnyRecord) { return text(record.last_status || record.status, record.is_active === false ? 'disabled' : 'idle') } +function queueStatusFromTask(statusValue: unknown, isRunning?: unknown): CollectionQueueStatus { + const status = text(statusValue, '').toLowerCase() + if (status === 'success' || status === 'completed') return 'success' + if (status === 'failed' || status === 'error') return 'failed' + if (status === 'cancelled' || status === 'canceled') return 'cancelled' + if (status === 'skipped') return 'skipped' + if (status === 'queued' || status === 'pending') return 'queued' + if (isRunning === true || status === 'running' || status === 'collecting') return 'running' + return 'queued' +} + +function queueItemKey(item: AnyRecord) { + const taskId = text(item.task_id || item.taskId, '') + if (taskId) return `task:${taskId}` + const sourceId = text(item.datasource_id || item.source_id || item.id || item.sourceId, '') + if (sourceId) return `source:${sourceId}` + const source = text(item.collector_name || item.source, '') + return source ? `source-name:${source}` : `queue:${Date.now()}` +} + +function queueProgress(item: CollectionQueueItem) { + if (typeof item.progress === 'number') return Math.max(0, Math.min(100, Math.round(item.progress))) + if (item.status === 'success' || item.status === 'failed' || item.status === 'cancelled' || item.status === 'skipped') return 100 + return 0 +} + +function queueStatusLabel(status: CollectionQueueStatus) { + const labels: Record = { + queued: '排队中', + running: '运行中', + success: '已完成', + failed: '失败', + skipped: '跳过', + cancelled: '已取消', + } + return labels[status] +} + +function queueReasonLabel(reason = '') { + const labels: Record = { + disabled: '已停用', + already_running: '已有任务运行', + within_frequency_window: '未到采集间隔', + trigger_failed: '触发失败', + } + return labels[reason] || reason || '-' +} + +function formatDuration(startedAt: number, endedAt = Date.now()) { + const seconds = Math.max(0, Math.round((endedAt - startedAt) / 1000)) + const minutes = Math.floor(seconds / 60) + const rest = seconds % 60 + return `${String(minutes).padStart(2, '0')}:${String(rest).padStart(2, '0')}` +} + function datasourceTableRow(row: AnyRecord) { return { ...row, @@ -540,6 +616,8 @@ const fieldLabels: Record = { key: '键', label: '标签', title: '标题', + kicker: '眉标', + version: '版本', default_source_id: '默认频道', auto_fallback: '自动回退', id: '标识', @@ -653,6 +731,10 @@ const fieldLabels: Record = { use_ssl: '使用 SSL', from_email: '发件邮箱', from_name: '发件人名称', + logo_alt: 'Logo 替代文本', + meta: '信息条目', + credits: '出品信息', + links: '链接', } function fieldLabel(key: string) { @@ -1941,6 +2023,8 @@ function ModuleConsole({ config }: { config: ModuleConfig }) { const [loading, setLoading] = useState(false) const [actionLoading, setActionLoading] = useState(false) const [rowActionLoading, setRowActionLoading] = useState>({}) + const [collectionQueue, setCollectionQueue] = useState([]) + const [collectionQueueOpen, setCollectionQueueOpen] = useState(false) const pendingDatasourceTasksRef = useRef>({}) const completedDatasourceTasksRef = useRef>(new Set()) const datasourcePollTimersRef = useRef>({}) @@ -2157,6 +2241,77 @@ function ModuleConsole({ config }: { config: ModuleConfig }) { return Boolean(sourceId && rowId === sourceId) || Boolean(source && rowSource === source) }, []) + const upsertCollectionQueueItem = useCallback((item: CollectionQueueItem) => { + setCollectionQueue((current) => { + const index = current.findIndex((existing) => ( + existing.key === item.key + || (item.taskId && existing.taskId === item.taskId) + || (item.sourceId && existing.sourceId === item.sourceId) + || (item.source && existing.source === item.source) + )) + if (index < 0) return [item, ...current] + const next = [...current] + next[index] = { ...next[index], ...item, key: next[index].key, createdAt: next[index].createdAt || item.createdAt } + return next + }) + }, []) + + const patchCollectionQueueFromTask = useCallback((payload: AnyRecord) => { + const sourceId = text(payload.datasource_id || payload.source_id || payload.id, '') + const source = text(payload.collector_name || payload.source, '') + const taskId = payload.task_id as number | string | null | undefined + const status = queueStatusFromTask(payload.status || payload.phase, payload.is_running) + setCollectionQueue((current) => current.map((item) => { + const matched = (taskId && item.taskId === taskId) || (sourceId && item.sourceId === sourceId) || (source && item.source === source) + if (!matched) return item + const terminal = ['success', 'failed', 'cancelled', 'skipped'].includes(status) + return { + ...item, + taskId: taskId ?? item.taskId, + sourceId: sourceId || item.sourceId, + source: source || item.source, + status, + phase: text(payload.phase, item.phase || ''), + phaseMessage: text(payload.phase_message, item.phaseMessage || ''), + progress: typeof payload.progress === 'number' ? payload.progress : item.progress, + recordsProcessed: typeof payload.records_processed === 'number' ? payload.records_processed : item.recordsProcessed, + totalRecords: typeof payload.total_records === 'number' ? payload.total_records : item.totalRecords, + error: text(payload.error_message, item.error || ''), + updatedAt: Date.now(), + completedAt: terminal ? Date.now() : item.completedAt, + } + })) + }, []) + + const addBatchQueueResult = useCallback((payload: AnyRecord) => { + const now = Date.now() + const toItems = (items: unknown, status: CollectionQueueStatus): CollectionQueueItem[] => ( + Array.isArray(items) ? items.filter(isObjectRecord).map((item) => ({ + key: queueItemKey(item), + sourceId: text(item.id || item.source_id || item.datasource_id, ''), + source: text(item.source || item.collector_name, ''), + name: text(item.name || item.source || item.collector_name, '数据源'), + taskId: item.task_id as number | string | null | undefined, + status, + phase: status === 'queued' ? 'queued' : undefined, + phaseMessage: status === 'queued' ? '等待任务创建' : queueReasonLabel(text(item.reason, '')), + progress: status === 'queued' ? 0 : 100, + reason: text(item.reason, ''), + error: text(item.error || item.message, ''), + createdAt: now, + updatedAt: now, + completedAt: status === 'queued' || status === 'running' ? undefined : now, + })) : [] + ) + const nextItems = [ + ...toItems(payload.triggered, 'queued'), + ...toItems(payload.skipped, 'skipped'), + ...toItems(payload.failed, 'failed'), + ] + nextItems.forEach(upsertCollectionQueueItem) + if (nextItems.length) setCollectionQueueOpen(true) + }, [upsertCollectionQueueItem]) + const updateDatasourceRow = useCallback((row: AnyRecord, options: { removeIfFilteredOut?: boolean } = {}) => { if (config !== configs.datasources) return const normalized = normalizeDatasourceTableRecord(row) @@ -2180,8 +2335,8 @@ function ModuleConsole({ config }: { config: ModuleConfig }) { }) }, [config, isSameDatasourceRow, normalizeDatasourceTableRecord]) - const mergeDatasourceTaskUpdate = useCallback((payload: AnyRecord) => { - if (config !== configs.datasources) return + const mergeDatasourceTaskUpdate = useCallback((payload: AnyRecord) => { + if (config !== configs.datasources) return const sourceId = text(payload.datasource_id || payload.source_id, '') const source = text(payload.collector_name || payload.source, '') if (!sourceId && !source) return @@ -2216,11 +2371,12 @@ function ModuleConsole({ config }: { config: ModuleConfig }) { }), } })) - setSelected((current) => { - if (!current || !isSameDatasourceRow(current, sourceId, source)) return current - return normalizeDatasourceTableRecord({ ...current, ...rowPatch, id: current.id, source: current.source }) - }) - }, [config, isSameDatasourceRow, normalizeDatasourceTableRecord]) + setSelected((current) => { + if (!current || !isSameDatasourceRow(current, sourceId, source)) return current + return normalizeDatasourceTableRecord({ ...current, ...rowPatch, id: current.id, source: current.source }) + }) + patchCollectionQueueFromTask(payload) + }, [config, isSameDatasourceRow, normalizeDatasourceTableRecord, patchCollectionQueueFromTask]) const finalizeDatasourceTask = useCallback(async (payload: AnyRecord) => { const sourceId = text(payload.datasource_id || payload.source_id, '') @@ -2305,6 +2461,16 @@ function ModuleConsole({ config }: { config: ModuleConfig }) { const activeState = useMemo(() => states.find((state) => state.section.key === activeSection?.key), [activeSection?.key, states]) const rows = activeState?.rows ?? [] const summary = sectionSummary(states) + const collectionQueueSummary = useMemo(() => { + const running = collectionQueue.filter((item) => item.status === 'queued' || item.status === 'running').length + const completed = collectionQueue.filter((item) => item.status === 'success').length + const failed = collectionQueue.filter((item) => item.status === 'failed').length + const skipped = collectionQueue.filter((item) => item.status === 'skipped' || item.status === 'cancelled').length + const terminal = completed + failed + skipped + const total = collectionQueue.length + const progress = total ? Math.round((terminal / total) * 100) : 0 + return { total, running, completed, failed, skipped, progress } + }, [collectionQueue]) const isPlaygroundSection = config === configs.ai && activeSection?.key === 'playground' const isHierarchySection = config.viewMode === 'management' && !isPlaygroundSection const searchIntent = useMemo(() => { @@ -2486,6 +2652,12 @@ function ModuleConsole({ config }: { config: ModuleConfig }) { source_ids: sourceIds, force: batchTriggerForce, }) + addBatchQueueResult(response.data) + arrayAt(response.data, 'triggered').forEach((item) => { + const sourceId = text(item.id || item.source_id || item.datasource_id, '') + const record = rows.find((row) => text(row.id, '') === sourceId || text(row.source, '') === text(item.source, '')) + if (record) scheduleDatasourceTaskPoll(record, item.task_id as number | string | null | undefined) + }) replaceSelectedWithPayload('trigger-batch', '批量触发结果', [{ ...response.data, __title: '批量触发结果', @@ -2502,6 +2674,25 @@ function ModuleConsole({ config }: { config: ModuleConfig }) { } } + const triggerAllDatasources = async () => { + setActionLoading(true) + try { + toast({ title: '正在提交采集队列', description: '触发全部会按后端调度规则跳过未到采集间隔的源。' }) + const response = await axios.post(apiPath('/datasources/trigger-all')) + addBatchQueueResult(response.data) + arrayAt(response.data, 'triggered').forEach((item) => { + const sourceId = text(item.id || item.source_id || item.datasource_id, '') + const record = rows.find((row) => text(row.id, '') === sourceId || text(row.source, '') === text(item.source, '')) + if (record) scheduleDatasourceTaskPoll(record, item.task_id as number | string | null | undefined) + }) + toast({ title: '采集队列已提交', description: `${arrayAt(response.data, 'triggered').length} 个任务已进入队列。`, tone: 'success' }) + } catch (error) { + toast({ title: '触发全部失败', description: actionErrorMessage(error), tone: 'error' }) + } finally { + setActionLoading(false) + } + } + const scheduleDatasourceTaskPoll = (record: TableRecord, taskId?: number | string | null) => { const id = pick(record, ['id', 'source_id', 'key', 'name'], '') if (!id) return @@ -2535,6 +2726,33 @@ function ModuleConsole({ config }: { config: ModuleConfig }) { datasourcePollTimersRef.current[pollKey] = window.setTimeout(poll, datasourceSocket.connected ? 3000 : 900) } + useEffect(() => { + if (config !== configs.datasources) return + const builtinState = states.find((state) => state.section.key === 'builtin') + if (!builtinState?.rows.length) return + builtinState.rows.forEach((row) => { + const status = datasourceStatus(row) + if (!['running', 'pending', 'queued'].includes(status)) return + const sourceId = text(row.id || row.source_id, '') + const source = text(row.source || row.collector_name, '') + const taskId = row.task_id as number | string | null | undefined + upsertCollectionQueueItem({ + key: queueItemKey({ id: sourceId, source, task_id: taskId }), + sourceId, + source, + name: recordTitle(row), + taskId, + status: status === 'running' ? 'running' : 'queued', + phase: status, + phaseMessage: text(row.phase_message || row.last_status, '后端任务仍在运行'), + progress: typeof row.progress === 'number' ? row.progress : 0, + createdAt: Date.now(), + updatedAt: Date.now(), + }) + scheduleDatasourceTaskPoll(row, taskId) + }) + }, [config, states, upsertCollectionQueueItem]) + const refreshDatasourceRow = async (record: AnyRecord, options: { removeIfFilteredOut?: boolean } = {}) => { const id = pick(record, ['id', 'source_id', 'key', 'name'], '') if (!id) return @@ -2632,13 +2850,27 @@ function ModuleConsole({ config }: { config: ModuleConfig }) { } const taskId = response.data?.task_id const pendingKey = text(taskId, id) - pendingDatasourceTasksRef.current[pendingKey] = { - sourceId: id, - source: text(record.source || response.data?.collector_name, ''), - name: recordTitle(record), - taskId, - } - completedDatasourceTasksRef.current.delete(pendingKey) + pendingDatasourceTasksRef.current[pendingKey] = { + sourceId: id, + source: text(record.source || response.data?.collector_name, ''), + name: recordTitle(record), + taskId, + } + upsertCollectionQueueItem({ + key: queueItemKey({ task_id: taskId, id, source: record.source }), + sourceId: id, + source: text(record.source || response.data?.collector_name, ''), + name: recordTitle(record), + taskId, + status: 'queued', + phase: 'queued', + phaseMessage: '任务已提交', + progress: 0, + createdAt: Date.now(), + updatedAt: Date.now(), + }) + setCollectionQueueOpen(true) + completedDatasourceTasksRef.current.delete(pendingKey) updateDatasourceRow({ ...record, id: record.id || Number(id) || id, @@ -2659,8 +2891,8 @@ function ModuleConsole({ config }: { config: ModuleConfig }) { __status: '已提交', __metric: response.data?.task_id || '-', }]) - toast({ title: force ? '已强制重新触发' : '任务已触发', tone: 'success' }) - scheduleDatasourceTaskPoll(record, taskId) + toast({ title: force ? '已强制重新触发' : '任务已触发', tone: 'success' }) + scheduleDatasourceTaskPoll(record, taskId) } catch (error) { toast({ title: '触发采集失败', description: actionErrorMessage(error), tone: 'error' }) } finally { @@ -3957,6 +4189,10 @@ function ModuleConsole({ config }: { config: ModuleConfig }) { await saveHierarchySettings('/earth/brand', payload, '保存品牌配置') return } + if (activeSection.key === 'about') { + await saveHierarchySettings('/earth/about', payload, '保存关于配置') + return + } if (activeGroup.key.includes('boundaries')) { await saveHierarchySettings('/earth/boundaries/config', { config: payload }, '保存边界配置') return @@ -4185,6 +4421,15 @@ function ModuleConsole({ config }: { config: ModuleConfig }) { })} loading={actionLoading}> ) : null} + {config === configs.earthContent && activeSection.key === 'about' ? ( + + ) : null} {config === configs.earthContent && activeSection.key === 'earth_assets' ? ( ) : null} @@ -4636,7 +4881,7 @@ function ModuleConsole({ config }: { config: ModuleConfig }) { const actions: ReactNode[] = [] if (config === configs.datasources) { actions.push( - , + {collectionQueueSummary.running === 0 ? ( + + ) : null} + + + + {collectionQueueOpen ? ( +
    + {groups.map((group) => ( +
    +

    {group.title}{group.items.length}

    + {group.items.length ? ( +
    + {group.items.map((item) => ( +
    +
    + {item.name} +

    {item.phaseMessage || item.error || queueStatusLabel(item.status)}{item.taskId ? ` · task ${item.taskId}` : ''}{item.completedAt ? ` · ${formatDuration(item.createdAt, item.completedAt)}` : ''}

    +
    + {queueProgress(item)}% +
    + + {item.status === 'failed' ? ( +
    +
    + ))} +
    + ) :

    暂无{group.title}任务

    } +
    + ))} +
    + ) : null} + + ) + } + const moduleActions = renderModuleActions() const credentialGuideProviderName = credentialGuide ? text(credentialGuide.provider, 'barentswatch') : '' const credentialGuideMarkdown = credentialGuide @@ -4790,6 +5144,7 @@ function ModuleConsole({ config }: { config: ModuleConfig }) { {renderDatasourceFilters()} + {renderCollectionQueue()} {isPlaygroundSection ? ( @@ -4841,6 +5196,7 @@ function ModuleConsole({ config }: { config: ModuleConfig }) { {recordStatus(selected)} {renderRecordActions()} + {renderDatasourceTaskSummary()} {renderStructuredEditor() || }
    @@ -5112,6 +5468,7 @@ const configs = { viewMode: 'management', sections: [ { key: 'brand', label: '品牌标识', url: '/earth/brand', map: (payload) => singleRow(payload, 'brand', { __title: '品牌配置', __module: '品牌' }).map((row) => ({ ...row, __title: '品牌配置', __module: '品牌', __status: '已读取' })) }, + { key: 'about', label: '关于', url: '/earth/about', map: (payload) => singleRow(payload, 'about', { __title: '关于配置', __module: '关于' }).map((row) => ({ ...row, __title: '关于配置', __module: '关于', __status: '已读取' })) }, { key: 'earth_assets', label: '国界精度', diff --git a/frontend/src/admin-next/styles.css b/frontend/src/admin-next/styles.css index e060fedb..b1439414 100644 --- a/frontend/src/admin-next/styles.css +++ b/frontend/src/admin-next/styles.css @@ -29,6 +29,12 @@ --tui-primary-hover: var(--an-accent-hover); --tui-primary-active: var(--an-accent-hover); --tui-danger: var(--an-danger); + --d-segment-bg: #eef3f9; + --d-segment-slider: #ffffff; + --d-segment-shadow: 0 2px 8px rgba(15, 23, 42, 0.12); + --d-lang-btn: #4a5568; + --d-nav-hover: #0d4f9f; + --d-nav-active: #0b5fc1; color: var(--an-text); } @@ -49,6 +55,12 @@ --an-info: #38bdf8; --an-row-hover: #1f2b3e; --an-shadow: none; + --d-segment-bg: rgba(0, 0, 0, 0.28); + --d-segment-slider: #202938; + --d-segment-shadow: 0 2px 8px rgba(0, 0, 0, 0.45); + --d-lang-btn: #8a9bb8; + --d-nav-hover: #93c5fd; + --d-nav-active: #5ba5ff; } .an-dialog, @@ -301,6 +313,192 @@ body:has(.admin-next-theme-root[data-theme='dark']) .an-toast { justify-content: center; } +.an-collection-queue { + display: grid; + gap: 8px; + padding: 10px 12px; + border: 1px solid var(--an-border); + border-radius: 8px; + background: var(--an-surface); + box-shadow: var(--an-shadow); +} + +.an-collection-queue__bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.an-collection-queue__summary, +.an-collection-queue__actions, +.an-collection-queue__item-actions { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.an-collection-queue__summary { + flex-wrap: wrap; + color: var(--an-muted); + font-size: 12px; +} + +.an-collection-queue__summary strong { + color: var(--an-text); + font-size: 14px; +} + +.an-collection-queue__track { + height: 6px; + overflow: hidden; + border-radius: 999px; + background: var(--an-soft); +} + +.an-collection-queue__track span { + display: block; + height: 100%; + border-radius: inherit; + background: linear-gradient(90deg, var(--an-info), var(--an-accent)); + transition: width 180ms ease; +} + +.an-collection-queue__panel { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; + max-height: 280px; + overflow: auto; + padding-top: 4px; +} + +.an-collection-queue__group { + display: grid; + align-content: start; + gap: 8px; + min-width: 0; + padding: 8px; + border: 1px solid var(--an-border); + border-radius: 8px; + background: var(--an-surface-alt); +} + +.an-collection-queue__group h3 { + display: flex; + align-items: center; + justify-content: space-between; + margin: 0; + font-size: 13px; +} + +.an-collection-queue__group h3 span { + color: var(--an-muted); + font-size: 12px; +} + +.an-collection-queue__items { + display: grid; + gap: 6px; +} + +.an-collection-queue__item { + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto; + align-items: center; + gap: 8px; + padding: 8px; + border: 1px solid var(--an-border); + border-radius: 7px; + background: var(--an-surface); +} + +.an-collection-queue__item strong, +.an-collection-queue__item p { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.an-collection-queue__item strong { + display: block; + font-size: 12px; +} + +.an-collection-queue__item p, +.an-collection-queue__empty { + margin: 0; + color: var(--an-muted); + font-size: 12px; +} + +.an-collection-queue__item > span { + min-width: 38px; + color: var(--an-muted); + font-size: 12px; + text-align: right; +} + +.an-collection-queue__item.is-running, +.an-collection-queue__item.is-queued { + border-color: color-mix(in srgb, var(--an-info) 35%, var(--an-border)); +} + +.an-collection-queue__item.is-success { + border-color: color-mix(in srgb, var(--an-success) 35%, var(--an-border)); +} + +.an-collection-queue__item.is-failed { + border-color: color-mix(in srgb, var(--an-danger) 35%, var(--an-border)); +} + +.an-task-summary { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 10px; + padding: 10px; + border: 1px solid var(--an-border); + border-radius: 8px; + background: var(--an-surface-alt); +} + +.an-task-summary p { + margin: 3px 0 0; + color: var(--an-muted); + font-size: 12px; +} + +.an-task-summary dl { + grid-column: 1 / -1; + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + margin: 0; + border: 1px solid var(--an-border); + border-radius: 7px; + overflow: hidden; +} + +.an-task-summary dt, +.an-task-summary dd { + margin: 0; + padding: 7px 9px; + border-bottom: 1px solid var(--an-border); + font-size: 12px; +} + +.an-task-summary dt { + color: var(--an-muted); + background: var(--an-surface); +} + +.an-task-summary dd { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .admin-next__content { min-width: 0; height: 100vh; @@ -3572,6 +3770,20 @@ body:has(.admin-next-theme-root[data-theme='dark']) .an-toast { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .an-collection-queue__bar { + align-items: stretch; + flex-direction: column; + } + + .an-collection-queue__panel { + grid-template-columns: 1fr; + max-height: 46vh; + } + + .an-task-summary dl { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .an-logs-filters { grid-template-columns: repeat(2, minmax(0, 1fr)); } diff --git a/pyproject.toml b/pyproject.toml index ff9de0c2..b66294ae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "planet" -version = "0.63.1" +version = "0.64.0" description = "智能星球计划 - 态势感知系统" requires-python = ">=3.14" dependencies = [ diff --git a/uv.lock b/uv.lock index a23d4d9b..0d60695c 100644 --- a/uv.lock +++ b/uv.lock @@ -757,7 +757,7 @@ wheels = [ [[package]] name = "planet" -version = "0.63.1" +version = "0.64.0" source = { virtual = "." } dependencies = [ { name = "aiofiles" },