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) => ` +
+ `, + ) + .join(""); + + return ` +${escapeHtml(about.description)}
+ + `.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 ` +${subtitle}
+