Compare commits

...

3 Commits

Author SHA1 Message Date
rayd1o
65e6a96c0d release: bump version to 0.65.0
Some checks failed
ci / backend (push) Has been cancelled
ci / frontend (push) Has been cancelled
release / images (push) Has been cancelled
ci / delivery (push) Has been cancelled
ci / backend (pull_request) Has been cancelled
ci / frontend (pull_request) Has been cancelled
ci / delivery (pull_request) Has been cancelled
2026-05-21 05:41:49 +08:00
linkong
37e92e7572 release: bump version to 0.64.0
Some checks failed
ci / backend (push) Has been cancelled
ci / frontend (push) Has been cancelled
ci / delivery (push) Has been cancelled
release / images (push) Has been cancelled
2026-05-21 03:46:02 +08:00
linkong
a37d4b6289 fix: update default linkong password
Some checks failed
ci / backend (push) Has been cancelled
ci / frontend (push) Has been cancelled
ci / delivery (push) Has been cancelled
2026-05-21 02:20:45 +08:00
40 changed files with 1902 additions and 145 deletions

View File

@@ -1 +1 @@
0.63.1
0.65.0

View File

@@ -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.")

View File

@@ -150,7 +150,7 @@ DEFAULT_LOGIN_USERS = (
{
"username": "linkong",
"email": "linkong@planet.local",
"password": "12345678",
"password": "LK12345678",
"role": "super_admin",
},
)

View File

@@ -47,6 +47,8 @@ DOCS_METADATA: tuple[DocsMetadata, ...] = (
DocsMetadata("frontend-admin-frontend-context.md", "frontend-admin-frontend-context", "docs_developer", "Frontend", 20, "控制台前端结构", "Admin Frontend Context"),
DocsMetadata("frontend-layout-guidelines.md", "frontend-layout-guidelines", "docs_developer", "Frontend", 21, "前端布局指南", "Frontend Layout Guidelines"),
DocsMetadata("docs-gatekeeper-development.md", "docs-gatekeeper-development", "docs_developer", "Frontend", 22, "Docs Gatekeeper 开发说明", "Docs Gatekeeper Development Guide"),
DocsMetadata("naming-glossary.md", "naming-glossary", "docs_developer", "Frontend", 23, "命名与术语对照", "Naming Glossary"),
DocsMetadata("tactile-ui-components.md", "tactile-ui-components", "docs_developer", "Frontend", 24, "Tactile UI 组件库", "Tactile UI Components"),
DocsMetadata("backend-collectors.md", "backend-collectors", "docs_developer", "Backend", 30, "数据采集系统", "Data Collectors"),
DocsMetadata("backend-system-service-control.md", "backend-system-service-control", "docs_admin", "Backend", 31, "系统服务控制", "System Service Control"),
DocsMetadata("datasource-collector-settings-connectivity.md", "datasource-collector-settings-connectivity", "docs_developer", "Backend", 32, "数据源、采集器设置与连接验证", "Datasource Collector Settings and Connectivity"),

View File

@@ -18,7 +18,7 @@ async def create_admin():
if existing_user:
print("用户 linkong 已存在,更新密码...")
existing_user.set_password("12345678")
existing_user.set_password("LK12345678")
existing_user.role = "super_admin"
existing_user.email = "linkong@planet.local"
else:
@@ -26,7 +26,7 @@ async def create_admin():
user = User(
username="linkong",
email="linkong@planet.local",
password_hash=get_password_hash("12345678"),
password_hash=get_password_hash("LK12345678"),
role="super_admin",
is_active=True,
)

View File

@@ -19,7 +19,7 @@ DEFAULT_LOGIN_USERS = (
{
"username": "linkong",
"email": "linkong@planet.local",
"password": "12345678",
"password": "LK12345678",
"role": "super_admin",
},
)

View File

@@ -22,7 +22,7 @@ DEFAULT_LOGIN_USERS = (
{
"username": "linkong",
"email": "linkong@planet.local",
"password": "12345678",
"password": "LK12345678",
"role": "super_admin",
},
)

View File

@@ -52,6 +52,19 @@ async def test_public_catalog_only_for_anonymous_user():
}
@pytest.mark.asyncio
async def test_developer_catalog_includes_frontend_reference_docs():
response = await get_json(
"/api/v1/docs/catalog",
make_user(role="viewer", groups=["docs_developer"]),
)
assert response.status_code == 200
zh_slugs = {item["slug"] for item in response.json()["items"] if item["lang"] == "zh"}
assert "naming-glossary" in zh_slugs
assert "tactile-ui-components" in zh_slugs
@pytest.mark.asyncio
async def test_anonymous_can_read_public_doc():
response = await get_json("/api/v1/docs/zh/quickstart")
@@ -83,10 +96,16 @@ async def test_developer_group_can_read_developer_but_not_admin_doc():
user = make_user(role="viewer", groups=["docs_developer"])
developer_response = await get_json("/api/v1/docs/zh/backend-collectors", user)
tactile_response = await get_json("/api/v1/docs/zh/tactile-ui-components", user)
glossary_response = await get_json("/api/v1/docs/zh/naming-glossary", user)
admin_response = await get_json("/api/v1/docs/zh/backend-system-service-control", user)
assert developer_response.status_code == 200
assert developer_response.json()["access"] == "docs_developer"
assert tactile_response.status_code == 200
assert tactile_response.json()["access"] == "docs_developer"
assert glossary_response.status_code == 200
assert glossary_response.json()["access"] == "docs_developer"
assert admin_response.status_code == 403

View File

@@ -8,6 +8,40 @@ This project follows the repository versioning rule:
- `improvement` -> `+0.0.1`bugfix + 小功能混合)
- `bugfix` -> `+0.0.1`
## [0.65.0] — 2026-05-21
Released: 2026-05-21
### Highlights
- 收敛 Admin Next 数据源触发入口:主按钮在未勾选时触发全部,勾选内置源后切换为“触发已选 N”并移除手填 ID 的批量触发弹窗。
- 优化数据源采集队列入口:右上角按钮常驻,空态显示队列图标,有任务时显示纯圆环进度,队列改为浮层避免挤压表格。
- 强化 `planet.sh destroy` 清理语义,销毁时先硬重置运行中的本地 Postgres `public` schema避免残留采集数据让 OOBE 误判 ready。
### Added / Fixed / Improved
- Admin Next 表格新增可选选择列,仅在 `/datasources` 内置源分区启用,支持当前可见行全选并在筛选、切分区或刷新时清空选择。
- 数据源批量触发复用 `/datasources/trigger-batch``source_ids`,成功后写入现有采集队列并清空勾选。
- `destroy` 补充清理 `planet-aiprovider:latest` 镜像以及 Python/Vite 等本地编译缓存,同时保留源码和 `.env`
- Docs Gatekeeper 与 Tactile UI 文档/样式继续补齐覆盖本轮按钮、队列、OOBE 和销毁流程说明。
---
## [0.64.0] — 2026-05-21
Released: 2026-05-21
### Highlights
- 新增 Earth 首次初始化 OOBE由后端真实采集状态决定是否显示避免 localStorage 清空后误弹,并提供桌面毛玻璃引导与移动端 bottom sheet。
- 数据源页新增下载列表式采集队列,把单源、批量和触发全部的任务进度统一展示,并支持失败重试与跳转详情。
- Earth 内容新增“关于”配置接口和后台 tabEarth 设置页 About 卡片改为运行时读取配置并带默认 fallback。
### Added / Fixed / Improved
- 新增 `/api/v1/earth/oobe-status``/api/v1/earth/about` GET/PUT/DELETE并让 Earth 前端加载 `about.js``oobe.js`
- Admin Next 数据源队列优先消费 `datasource_tasks` WebSocket断线时轮询 `/datasources/{id}/task-status`,刷新后只恢复后端仍在运行的真实任务。
- Admin Next 深色主题滑块补齐 Docs 同款 dark token侧栏主题控件在 dark 模式下不再保持浅色底座。
- 用户手册、快速开始、Earth 前端上下文和 Admin 前端上下文同步记录 OOBE、采集队列、About 配置与主题滑块行为。
---
## [0.63.1] — 2026-05-21
Released: 2026-05-21

View File

@@ -57,7 +57,7 @@
抽自现 manual.md重新组织
1. 首次启动 — `./planet.sh start`、默认账号(`admin/admin123``linkong/12345678`,引用 `b15d097b` 引入的 `DEFAULT_LOGIN_USERS`
1. 首次启动 — `./planet.sh start`、默认账号(`admin/admin123``linkong/LK12345678`,引用 `b15d097b` 引入的 `DEFAULT_LOGIN_USERS`
2. 启停与按模块重启 — `start/stop/restart``-b -f -a -d`
3. 健康检查 — `./planet.sh health`
4. 日志 — `./planet.sh log``-f -b -a`,日志文件路径

View File

@@ -66,10 +66,10 @@ DocsMetadata(
)
```
When adding a public technical doc:
When adding a technical doc that should appear in the Docs page:
- Add both Chinese and English Markdown files.
- Add filename, slug, access, group, order, and titles to server `DOCS_METADATA`.
- Add filename, slug, access, group, order, and titles to server `DOCS_METADATA`. The backend catalog endpoint is authoritative; frontend metadata alone does not publish a document into `/docs` navigation.
- Add matching metadata to frontend [docs-content.ts](/home/ray/dev/linkong/planet/frontend/src/pages/Docs/docs-content.ts) so navigation titles and sorting stay aligned.
- Update `docs/technical/zh/README.md` and `docs/technical/en/README.md` when the document should be discoverable from the README.

View File

@@ -151,9 +151,14 @@ Each module is responsible for its own:
`brand.js` manages Earth HUD brand resources. Static assets provide the default brand; runtime overrides come from `/api/v1/earth/brand`, and uploaded images are served from `/earth-brand-assets/...`. The frontend must treat logo/title images and text fallback separately: if an image fails, show the text title; if text fields are empty, rely on backend defaults so the HUD brand area never renders blank. The console Earth Content page owns saving and resetting brand configuration; the Earth frontend only consumes it.
`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.

View File

@@ -110,6 +110,27 @@ 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, table-selected 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 `Built-in Sources` section uses the table selection column for selected-source triggering. With no rows selected, the primary button is `Trigger All`; after selection, the same button becomes `Trigger Selected N`, replacing the old manual-ID batch button.
- The expanded queue no longer lives in the page content flow, so trigger-all cannot squeeze the table and detail panel. The top-right actions area uses the existing `Button` styling; the empty state shows a `ListChecks` icon, and active queues show only a pure circular total-progress indicator. Clicking it opens a floating 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`

View File

@@ -188,19 +188,22 @@ TV livestreams and boundary precision moved to `/earth-content`; collectors and
`/earth-content` is under the console's Operations and Configuration group and owns resources used by the Earth frontend:
- **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. With no rows selected, the primary button shows `Trigger All`; after selecting rows, it becomes `Trigger Selected N` and submits only those sources. The top-right queue button shows a queue icon when empty and a pure circular total-progress indicator while tasks exist; it opens a floating panel grouped by running, completed, failed, and skipped. 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

View File

@@ -21,7 +21,7 @@ First startup seeds two default accounts (see `DEFAULT_LOGIN_USERS` in `backend/
| Username | Password | Role |
| --- | --- | --- |
| `admin` | `admin123` | `super_admin` |
| `linkong` | `12345678` | `super_admin` |
| `linkong` | `LK12345678` | `super_admin` |
Both seed accounts are created with `email_verified = TRUE` and can log into the console immediately. Any other account must either go through the public registration flow described in the Manual, or be created via `./planet.sh createuser`.
@@ -61,6 +61,22 @@ Per-module restart:
Per-module restart is preferred during development to avoid interrupting unrelated services.
## Destructive Reset
```bash
./planet.sh destroy
```
`destroy` returns a local development environment to a near-empty project state. It requires typing `Y` before it runs; source files and existing `.env` files are preserved.
Cleanup order and boundaries:
- If `planet_postgres` is running, the script first clears the `public` schema in `planet_db`. This prevents old `collected_data.is_current = true` rows from making Earth OOBE report `ready=true` if Docker volume removal later fails.
- Docker cleanup targets resources whose Compose project is `planet`, plus the explicit volumes `planet_postgres_data`, `planet_redis_data`, `postgres_data`, and `redis_data`; do not delete unlabeled volumes by a broad `planet_*` pattern, because another local project could own them.
- Local build state removes `.venv`, frontend `node_modules` / `dist`, Planet state/cache, and scattered Python / Vite cache directories.
After the reset, run `./planet.sh init` again to recreate tables and default seed data. Old collected records are not restored, and Earth OOBE is evaluated from the backend's real collection state on the next visit.
## Health Check
```bash

View File

@@ -34,7 +34,7 @@ After landing on the `/admin` dashboard, here's a recommended walk-through:
1. `/collection-management?tab=collector_credentials`: pick a collector and click the plug icon to test connectivity. Free collectors (e.g. open BGP) usually work right away; credential-bearing ones like `AISStream` or `BarentsWatch` need an API key / client secret first
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: with no rows selected, click `Trigger All`; after selecting rows, the primary button becomes `Trigger Selected N`. The top-right queue button shows progress. 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

View File

@@ -49,6 +49,22 @@ Core tokens are exposed as `--tui-*` CSS variables. Product themes should overri
Most controls also accept a `tactile` prop for local width, height, radius, background, border, and shadow overrides. Use local overrides for small special cases; use CSS variables for product-wide styling.
## Portals and Dark Theme
`TactileTooltip` and the Admin Next `Dialog`, `Select`, and toast controls that use Tactile UI may render through a portal attached to `document.body`. Those nodes are not descendants of `.admin-next-theme-root[data-theme='dark']`, so dark tokens cannot rely only on an ancestor selector inside the Admin Next root.
The Admin Next theme provider mirrors the active theme to `body[data-admin-next-theme]`. Shared styles need to support both selector paths:
```css
[data-theme='dark'] .tui-button,
body[data-admin-next-theme='dark'] .tui-button {
--tui-surface: #172033;
--tui-text: #e5edf8;
}
```
When adding a portal-based control, first check whether it renders into body. If it does, add a `body[data-admin-next-theme='dark']` branch in that component's style entry, or reuse the already covered `--tui-*` / `--an-*` tokens. Avoid hard-coding a one-off dark modal style, because the same contrast problem can reappear in dropdowns, tooltips, toasts, and confirmation dialogs.
## `TactileButton`
The button component covers regular buttons, icon buttons, strong-intent buttons, and link-like buttons.
@@ -74,6 +90,8 @@ Common props:
`variant="neutral"` defaults to a white tactile button. Colored buttons should still keep the same height and external shadow instead of relying on page-specific CSS overrides.
Colored button borders must not use the exact fill color. `primary`, `danger`, and future colored variants should use a lighter border from the same hue, such as `color-mix(in srgb, var(--tui-danger) 64%, white)`. The border still reads as part of the button color, but its visual weight is lower than the fill surface, so red or blue buttons do not look one outline larger than neutral buttons. Hover states should brighten rather than darken: mix a little white into the current fill color, and keep the hover border lighter than the hover fill.
## Icon Presets
Preset icons are maintained in `tactileIconPresets`. Feature pages should call icons by semantic name so actions remain consistent across the console.

View File

@@ -66,10 +66,10 @@ DocsMetadata(
)
```
新增公开文档时,需要同步:
新增可在 Docs 页面展示的技术文档时,需要同步:
- 新增中英文 Markdown 文件。
- 在服务端 `DOCS_METADATA` 添加 filename、slug、access、group、order、标题。
- 在服务端 `DOCS_METADATA` 添加 filename、slug、access、group、order、标题。后端目录接口以这里为准,只改前端 metadata 不会让文档出现在 `/docs` 导航中。
- 在前端 [docs-content.ts](/home/ray/dev/linkong/planet/frontend/src/pages/Docs/docs-content.ts) 添加同名 metadata保持导航标题和排序一致。
- 如果需要从 README 发现,更新 `docs/technical/zh/README.md``docs/technical/en/README.md`

View File

@@ -162,9 +162,14 @@ Earth 地表是多层近似同心球,不是单一 mesh。`earth.js` 的基座
`brand.js` 管理 Earth HUD 品牌资源。默认品牌来自静态资源,运行时覆盖值来自 `/api/v1/earth/brand`,上传的图片通过 `/earth-brand-assets/...` 读取。前端必须把 logo/title 图片和文本 fallback 分开处理:图片加载失败时显示文本标题,文本字段为空时使用后端默认值,避免 HUD 品牌区空白。控制台的 Earth 内容页负责保存和重置品牌配置Earth 前端只消费结果。
`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 或品牌配置。

View File

@@ -110,6 +110,27 @@ 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` 的数据源恢复队列。
- `内置源` 分区通过表格选择列收敛批量触发。没有勾选时主按钮是“触发全部”;勾选后同一个主按钮变成“触发已选 N”不再提供手填 ID 的独立批量按钮。
- 页面内容流不再承载展开队列,避免全量触发后挤压列表和详情面板。右上角 actions 区的队列按钮沿用现有 `Button` 样式;空态使用 `ListChecks` 图标,有任务时只显示纯圆环总进度。点击后打开浮层,按运行中、失败、完成、跳过分组。
- 队列项可以跳转到对应数据源详情,失败项可以重试。详情页内的“采集任务”摘要只展示当前数据源最近任务,不承担保存配置职责。
这个队列是用户感知层,不替代后端调度状态。后端仍然是任务是否运行、完成、失败或跳过的唯一事实来源。
## 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`

View File

@@ -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`:数据源目录。`内置源` 支持按产品域、层级、启用状态、最近执行状态、是否已有采集数据和关键词筛选;勾选时主按钮显示“触发全部”,勾选多行后会变成“触发已选 N”并只提交所选数据源。右上角队列按钮空态显示队列图标有任务时显示纯圆环总进度点击后打开队列浮层按运行中、完成、失败和跳过分组失败项可重试完成项可跳到详情`实时` 面向 AISStream / WebSocket 长连接,展示连接健康、累计入库、时间窗统计和启动 / 停止 / 重连操作。接口、凭证、请求头的编辑统一在 `/collection-management` 的"采集器"。
- `/data`:采集后数据表,适合排查"数据是否已经进入系统"、"更新时间是否符合预期"、"某个数据源是否产出有效记录"
- `/bgp`BGP 专题页面,列表 + 详情 + 研判,与 Earth 的 BGP 图层互补
- `/alerts/system``/alerts/bgp``/alerts/situational`系统、BGP、态势告警

View File

@@ -21,7 +21,7 @@
| 用户名 | 密码 | 角色 |
| --- | --- | --- |
| `admin` | `admin123` | `super_admin` |
| `linkong` | `12345678` | `super_admin` |
| `linkong` | `LK12345678` | `super_admin` |
两个默认账号 `email_verified``true`,可直接登录控制台。任何在该列表之外的账号都必须走公开注册 + 邮箱验证流程(见使用手册),或用 `./planet.sh createuser` 命令式创建。
@@ -61,6 +61,22 @@
按模块重启适合日常开发,能避免无关服务被打断。
## 破坏性重置
```bash
./planet.sh destroy
```
`destroy` 用于把本地开发环境退回到接近空项目的状态。执行前需要输入 `Y` 确认;源码和现有 `.env` 配置文件会保留。
清理顺序和边界:
- 如果 `planet_postgres` 正在运行,脚本会先清空 `planet_db``public` schema。这样即使后续 Docker volume 删除失败,旧的 `collected_data.is_current = true` 也不会让 Earth OOBE 继续显示 `ready=true`
- Docker 清理只针对 Compose project 为 `planet` 的资源,以及显式列出的 `planet_postgres_data``planet_redis_data``postgres_data``redis_data`;不要按 `planet_*` 模式删除没有 label 的 volume避免误删同机其他项目。
- 本地编译状态会删除 `.venv`、前端 `node_modules` / `dist`、Planet state/cache以及散落的 Python / Vite 缓存目录。
重置后重新执行 `./planet.sh init` 会重建表和默认数据,但不会恢复旧采集结果;首次进入 Earth 时 OOBE 会重新按后端真实采集状态判断。
## 健康检查
```bash

View File

@@ -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 -> 内置源`,不勾选时点“触发全部”,勾选后主按钮会变成“触发已选 N”右上角队列按钮可查看进度。AISStream / WebSocket 长连接看 `/datasources -> 实时` 的健康状态和计数
4. `/alerts/system`:看系统告警是否正常
5. `/users`(仅 `super_admin`):根据需要给同事开账号或调权限组
@@ -42,6 +42,8 @@
访问 `/earth`,公开页面,不需要登录。
如果系统还没有已采集数据Earth 会显示初始化引导,提示登录控制台并触发采集。这个引导由后端状态决定,不会因为清空浏览器缓存而误判。
进入后建议确认:
- 地球正常显示,右侧图层面板可以打开/关闭

View File

@@ -49,6 +49,22 @@ import '@planet/tactile-ui/styles.css'
组件还支持通过 `tactile` prop 传入局部尺寸、圆角、背景、边框和阴影参数。局部参数适合少量特殊按钮;全局视觉应优先走 CSS variables。
## Portal 与深色主题
`TactileTooltip` 和使用 Tactile UI 的 Admin Next `Dialog``Select`、Toast 都可能通过 portal 挂到 `document.body`。这类节点不在 `.admin-next-theme-root[data-theme='dark']` 下面,不能只依赖局部祖先选择器读取深色 token。
Admin Next 的主题 provider 会把当前主题同步到 `body[data-admin-next-theme]`。共享样式必须同时支持两类选择器:
```css
[data-theme='dark'] .tui-button,
body[data-admin-next-theme='dark'] .tui-button {
--tui-surface: #172033;
--tui-text: #e5edf8;
}
```
新增 portal 控件时,先确认它是否渲染到 body。如果是就要在组件自己的样式入口补 `body[data-admin-next-theme='dark']` 分支,或复用已经覆盖过的 `--tui-*` / `--an-*` token。不要在单个弹窗里手写固定深色因为同一问题会在下拉菜单、tooltip、toast 和确认弹窗里重复出现。
## `TactileButton`
按钮组件覆盖普通按钮、图标按钮、强意图按钮和链接式按钮。
@@ -74,6 +90,8 @@ import '@planet/tactile-ui/styles.css'
`variant="neutral"` 的默认按钮是白色触感按钮。彩色按钮仍应保留外部投影和统一高度,不应在业务 CSS 中手写新的阴影体系。
有色按钮的边框不能直接使用填充色本身。`primary``danger` 和后续新增的有色 variant 应使用同色系减淡边框,例如 `color-mix(in srgb, var(--tui-danger) 64%, white)`。这样边框仍然属于按钮色相,但视觉重量弱于填充面,避免红色/蓝色按钮看起来比默认按钮额外大一圈。hover 态应提亮而不是压暗,背景用当前色混入少量 white边框继续比背景更轻。
## 图标预设
预设图标由 `tactileIconPresets` 统一维护,业务页面通过语义名称调用,避免每个页面随意选择图标。

View File

@@ -16,12 +16,14 @@
## Current Version
- `main` 当前主线历史推导到:`0.16.5`
- `dev` 当前开发分支历史推导到:`0.63.1`
- `dev` 当前开发分支历史推导到:`0.65.0`
## Timeline
| Version | Type | Branch | Commit | Summary |
| --- | --- | --- | --- | --- |
| `0.65.0` | feature | `dev` | `pending` | Admin Next 数据源触发入口收敛为“触发全部/触发已选 N”队列改为右上角浮层按钮并强化 `planet.sh destroy` 的 OOBE 数据清理 |
| `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 和移动端详情 |

View File

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

View File

@@ -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;

View File

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

View File

@@ -261,6 +261,8 @@ import {
setLegendItems,
} 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({

View File

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

View File

@@ -16,6 +16,13 @@ interface DataTableProps<TData> {
data: TData[]
getRowId?: (row: TData, index: number) => string
getRowClassName?: (row: TData) => string | undefined
selection?: {
selectedRowIds: Set<string>
onToggleAllVisible: (rowIds: string[]) => void
onToggleRow: (rowId: string, row: TData) => void
getCheckboxLabel?: (row: TData) => string
isRowSelectable?: (row: TData) => boolean
}
loading?: boolean
emptyText?: string
className?: string
@@ -28,6 +35,7 @@ export function DataTable<TData>({
data,
getRowId,
getRowClassName,
selection,
loading = false,
emptyText = '暂无数据',
className = '',
@@ -46,6 +54,13 @@ export function DataTable<TData>({
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
})
const visibleSelectableRows = selection
? table.getRowModel().rows.filter((row) => selection.isRowSelectable?.(row.original) ?? true)
: []
const visibleSelectableIds = visibleSelectableRows.map((row) => row.id)
const allVisibleSelected = visibleSelectableIds.length > 0 && visibleSelectableIds.every((rowId) => selection?.selectedRowIds.has(rowId))
const someVisibleSelected = visibleSelectableIds.some((rowId) => selection?.selectedRowIds.has(rowId))
const columnCount = columns.length + (selection ? 1 : 0)
return (
<div className={`an-data-table ${className}`}>
@@ -55,6 +70,20 @@ export function DataTable<TData>({
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{selection ? (
<th className="an-data-table__selection-cell">
<input
type="checkbox"
aria-label="选择当前可见数据"
checked={allVisibleSelected}
disabled={!visibleSelectableIds.length}
ref={(element) => {
if (element) element.indeterminate = someVisibleSelected && !allVisibleSelected
}}
onChange={() => selection.onToggleAllVisible(visibleSelectableIds)}
/>
</th>
) : null}
{headerGroup.headers.map((header) => {
const sorted = header.column.getIsSorted()
const stickyEnd = header.column.id === 'actions' || header.column.id === 'action'
@@ -82,7 +111,7 @@ export function DataTable<TData>({
<tbody>
{loading ? (
<tr>
<td colSpan={columns.length}>
<td colSpan={columnCount}>
<div className="an-data-table__state">
<span className="an-spinner" />
@@ -97,6 +126,18 @@ export function DataTable<TData>({
onClick={onRowClick ? () => onRowClick(row.original) : undefined}
data-clickable={onRowClick ? 'true' : undefined}
>
{selection ? (
<td className="an-data-table__selection-cell">
<input
type="checkbox"
aria-label={selection.getCheckboxLabel?.(row.original) || '选择行'}
checked={selection.selectedRowIds.has(row.id)}
disabled={selection.isRowSelectable ? !selection.isRowSelectable(row.original) : false}
onClick={(event) => event.stopPropagation()}
onChange={() => selection.onToggleRow(row.id, row.original)}
/>
</td>
) : null}
{row.getVisibleCells().map((cell) => (
<td key={cell.id} data-sticky-end={cell.column.id === 'actions' || cell.column.id === 'action' || undefined}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
@@ -106,7 +147,7 @@ export function DataTable<TData>({
))
) : (
<tr>
<td colSpan={columns.length}>
<td colSpan={columnCount}>
<div className="an-data-table__state">{emptyText}</div>
</td>
</tr>

View File

@@ -42,6 +42,14 @@ export function AdminThemeProvider({ children }: { children: ReactNode }) {
return () => mediaQuery.removeEventListener('change', handleChange)
}, [])
useEffect(() => {
if (typeof document === 'undefined') return
document.body.dataset.adminNextTheme = theme
return () => {
delete document.body.dataset.adminNextTheme
}
}, [theme])
const value = useMemo(() => ({ mode, theme, setMode }), [mode, setMode, theme])
return (

View File

@@ -11,6 +11,7 @@ import {
FileText,
Globe2,
ImageUp,
ListChecks,
Radio,
Redo2,
RefreshCw,
@@ -98,6 +99,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 +259,61 @@ function datasourceStatus(record: AnyRecord) {
return text(record.last_status || record.status, record.is_active === false ? 'disabled' : 'idle')
}
function queueStatusFromTask(statusValue: unknown, isRunning?: unknown): CollectionQueueStatus {
const status = text(statusValue, '').toLowerCase()
if (status === 'success' || status === 'completed') return 'success'
if (status === 'failed' || status === 'error') return 'failed'
if (status === 'cancelled' || status === 'canceled') return 'cancelled'
if (status === 'skipped') return 'skipped'
if (status === 'queued' || status === 'pending') return 'queued'
if (isRunning === true || status === 'running' || status === 'collecting') return 'running'
return 'queued'
}
function queueItemKey(item: AnyRecord) {
const taskId = text(item.task_id || item.taskId, '')
if (taskId) return `task:${taskId}`
const sourceId = text(item.datasource_id || item.source_id || item.id || item.sourceId, '')
if (sourceId) return `source:${sourceId}`
const source = text(item.collector_name || item.source, '')
return source ? `source-name:${source}` : `queue:${Date.now()}`
}
function queueProgress(item: CollectionQueueItem) {
if (typeof item.progress === 'number') return Math.max(0, Math.min(100, Math.round(item.progress)))
if (item.status === 'success' || item.status === 'failed' || item.status === 'cancelled' || item.status === 'skipped') return 100
return 0
}
function queueStatusLabel(status: CollectionQueueStatus) {
const labels: Record<CollectionQueueStatus, string> = {
queued: '排队中',
running: '运行中',
success: '已完成',
failed: '失败',
skipped: '跳过',
cancelled: '已取消',
}
return labels[status]
}
function queueReasonLabel(reason = '') {
const labels: Record<string, string> = {
disabled: '已停用',
already_running: '已有任务运行',
within_frequency_window: '未到采集间隔',
trigger_failed: '触发失败',
}
return labels[reason] || reason || '-'
}
function formatDuration(startedAt: number, endedAt = Date.now()) {
const seconds = Math.max(0, Math.round((endedAt - startedAt) / 1000))
const minutes = Math.floor(seconds / 60)
const rest = seconds % 60
return `${String(minutes).padStart(2, '0')}:${String(rest).padStart(2, '0')}`
}
function datasourceTableRow(row: AnyRecord) {
return {
...row,
@@ -451,12 +528,20 @@ function ModuleTable({
selected,
onSelect,
columns,
selection,
loading,
}: {
rows: TableRecord[]
selected: TableRecord | null
onSelect: (record: TableRecord) => void
columns?: Array<ColumnDef<TableRecord>>
selection?: {
selectedRowIds: Set<string>
onToggleAllVisible: (rowIds: string[]) => void
onToggleRow: (rowId: string, row: TableRecord) => void
getCheckboxLabel?: (row: TableRecord) => string
isRowSelectable?: (row: TableRecord) => boolean
}
loading?: boolean
}) {
const tableColumns = useMemo(() => columns || defaultColumns(onSelect), [columns, onSelect])
@@ -470,6 +555,7 @@ function ModuleTable({
getRowId={(row) => row.__rowId}
loading={loading}
onRowClick={onSelect}
selection={selection}
/>
)
}
@@ -540,6 +626,8 @@ const fieldLabels: Record<string, string> = {
key: '键',
label: '标签',
title: '标题',
kicker: '眉标',
version: '版本',
default_source_id: '默认频道',
auto_fallback: '自动回退',
id: '标识',
@@ -653,6 +741,10 @@ const fieldLabels: Record<string, string> = {
use_ssl: '使用 SSL',
from_email: '发件邮箱',
from_name: '发件人名称',
logo_alt: 'Logo 替代文本',
meta: '信息条目',
credits: '出品信息',
links: '链接',
}
function fieldLabel(key: string) {
@@ -1941,6 +2033,10 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
const [loading, setLoading] = useState(false)
const [actionLoading, setActionLoading] = useState(false)
const [rowActionLoading, setRowActionLoading] = useState<Record<string, boolean>>({})
const [collectionQueue, setCollectionQueue] = useState<CollectionQueueItem[]>([])
const [collectionQueueOpen, setCollectionQueueOpen] = useState(false)
const collectionQueueRef = useRef<HTMLDivElement>(null)
const [datasourceSelectedRowIds, setDatasourceSelectedRowIds] = useState<Set<string>>(() => new Set())
const pendingDatasourceTasksRef = useRef<Record<string, { sourceId: string; source?: string; name: string; taskId?: number | string | null; completed?: boolean }>>({})
const completedDatasourceTasksRef = useRef<Set<string>>(new Set())
const datasourcePollTimersRef = useRef<Record<string, number>>({})
@@ -1954,11 +2050,8 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
const [visibleSecretFields, setVisibleSecretFields] = useState<Record<string, boolean>>({})
const [smtpTestEmail, setSmtpTestEmail] = useState('')
const [brandUploadFile, setBrandUploadFile] = useState<File | null>(null)
const [batchTriggerOpen, setBatchTriggerOpen] = useState(false)
const [resolveTarget, setResolveTarget] = useState<TableRecord | null>(null)
const [resolutionText, setResolutionText] = useState('已处理')
const [batchTriggerIds, setBatchTriggerIds] = useState('')
const [batchTriggerForce, setBatchTriggerForce] = useState(false)
const [credentialGuide, setCredentialGuide] = useState<AnyRecord | null>(null)
const [credentialGuideOpen, setCredentialGuideOpen] = useState(false)
const [credentialGuidePosition, setCredentialGuidePosition] = useState<{ x: number; y: number } | null>(null)
@@ -2011,6 +2104,23 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
window.addEventListener('pointerup', stopMove, { once: true })
}
useEffect(() => {
if (!collectionQueueOpen) return
const handlePointerDown = (event: MouseEvent) => {
if (collectionQueueRef.current?.contains(event.target as Node)) return
setCollectionQueueOpen(false)
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') setCollectionQueueOpen(false)
}
document.addEventListener('mousedown', handlePointerDown)
document.addEventListener('keydown', handleKeyDown)
return () => {
document.removeEventListener('mousedown', handlePointerDown)
document.removeEventListener('keydown', handleKeyDown)
}
}, [collectionQueueOpen])
useEffect(() => {
datasourceFiltersRef.current = datasourceFilters
if (config === configs.datasources) {
@@ -2021,19 +2131,19 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
useEffect(() => {
if (config !== configs.datasources) return
const next = datasourceFiltersFromSearch(location.search)
setDatasourceFilters((current) => (
current.product === next.product &&
const current = datasourceFiltersRef.current
const unchanged = current.product === next.product &&
current.module === next.module &&
current.isActive === next.isActive &&
current.runStatus === next.runStatus &&
current.dataStatus === next.dataStatus
? current
: next
))
if (!unchanged) setDatasourceSelectedRowIds(new Set())
setDatasourceFilters((filters) => unchanged ? filters : next)
}, [config, location.search])
const setDatasourceFilter = (key: keyof DatasourceFilters, value: string) => {
const next = { ...datasourceFiltersRef.current, [key]: value }
setDatasourceSelectedRowIds(new Set())
setDatasourceFilters(next)
const params = new URLSearchParams(location.search)
const queryKeyByFilter: Record<keyof DatasourceFilters, string> = {
@@ -2066,6 +2176,9 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
const load = useCallback(async (sectionKey = activeSectionKey) => {
const section = config.sections.find((item) => item.key === sectionKey) ?? config.sections[0]
if (!section) return
if (config === configs.datasources && section.key === 'builtin') {
setDatasourceSelectedRowIds(new Set())
}
setLoading(true)
try {
const result = await (async (): Promise<SectionState> => {
@@ -2157,6 +2270,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 +2364,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 +2400,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, '')
@@ -2304,7 +2489,53 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
const activeSection = useMemo(() => config.sections.find((section) => section.key === activeSectionKey) ?? config.sections[0], [activeSectionKey, config.sections])
const activeState = useMemo(() => states.find((state) => state.section.key === activeSection?.key), [activeSection?.key, states])
const rows = activeState?.rows ?? []
const isDatasourceBuiltinSection = config === configs.datasources && activeSection?.key === 'builtin'
const selectedDatasourceRows = useMemo(
() => isDatasourceBuiltinSection ? rows.filter((row) => datasourceSelectedRowIds.has(row.__rowId)) : [],
[datasourceSelectedRowIds, isDatasourceBuiltinSection, rows],
)
const selectedDatasourceIds = useMemo(
() => selectedDatasourceRows
.map((row) => Number(row.id))
.filter((id) => Number.isFinite(id) && id > 0),
[selectedDatasourceRows],
)
const toggleDatasourceSelection = useCallback((rowId: string) => {
setDatasourceSelectedRowIds((current) => {
const next = new Set(current)
if (next.has(rowId)) {
next.delete(rowId)
} else {
next.add(rowId)
}
return next
})
}, [])
const toggleAllVisibleDatasourceSelection = useCallback((rowIds: string[]) => {
setDatasourceSelectedRowIds((current) => {
const next = new Set(current)
const allSelected = rowIds.length > 0 && rowIds.every((rowId) => next.has(rowId))
rowIds.forEach((rowId) => {
if (allSelected) {
next.delete(rowId)
} else {
next.add(rowId)
}
})
return next
})
}, [])
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(() => {
@@ -2319,6 +2550,7 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
const handleSectionChange = (key: string) => {
setActiveSectionKey(key)
setDatasourceSelectedRowIds(new Set())
setActiveGroupKey('')
setHierarchyDraft('')
setTvDraftGroup(null)
@@ -2471,20 +2703,23 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
}
}
const submitBatchTrigger = async () => {
const sourceIds = batchTriggerIds
.split(/[\s,;]+/)
.map((item) => Number(item.trim()))
.filter((item) => Number.isFinite(item) && item > 0)
const triggerSelectedDatasources = async () => {
const sourceIds = selectedDatasourceIds
if (!sourceIds.length) {
toast({ title: '请输入数据源 ID', description: '可以用逗号、空格或换行分隔。', tone: 'error' })
toast({ title: '请先勾选数据源', description: '勾选内置源后,主触发按钮会只触发所选数据源。', tone: 'error' })
return
}
setActionLoading(true)
try {
const response = await axios.post(apiPath('/datasources/trigger-batch'), {
source_ids: sourceIds,
force: batchTriggerForce,
force: false,
})
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,
@@ -2493,10 +2728,37 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
__status: '已提交',
__metric: `${sourceIds.length} 个数据源`,
}])
setBatchTriggerOpen(false)
toast({ title: '批量触发已提交', tone: 'success' })
setDatasourceSelectedRowIds(new Set())
toast({ title: '已触发所选数据源', description: `${sourceIds.length} 个数据源已提交。`, tone: 'success' })
} catch (error) {
toast({ title: '批量触发失败', description: error instanceof Error ? error.message : '接口请求失败', tone: 'error' })
toast({ title: '触发已选失败', description: error instanceof Error ? error.message : '接口请求失败', tone: 'error' })
} finally {
setActionLoading(false)
}
}
const triggerDatasourcePrimaryAction = async () => {
if (selectedDatasourceIds.length) {
await triggerSelectedDatasources()
return
}
await triggerAllDatasources()
}
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)
}
@@ -2535,6 +2797,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 +2921,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 +2962,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 +4260,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 +4492,15 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
})} loading={actionLoading}><Trash2 size={15} /></Button>
</>
) : null}
{config === configs.earthContent && activeSection.key === 'about' ? (
<Button size="icon" variant="subtle" title="恢复默认关于信息" aria-label="恢复默认关于信息" onClick={() => setConfirmAction({
title: '恢复默认关于信息',
description: '确认恢复 Earth 关于卡片的默认内容?',
danger: false,
confirmLabel: '恢复',
run: () => requestAction('恢复默认关于信息', 'delete', '/earth/about'),
})} loading={actionLoading}><RefreshCw size={15} /></Button>
) : null}
{config === configs.earthContent && activeSection.key === 'earth_assets' ? (
<Button variant="primary" icon="trigger" onClick={() => void requestAction('启动边界构建', 'post', '/earth/boundaries/build', undefined, { refresh: false, successDescription: '边界构建任务已提交,当前表单未保存。' })} loading={actionLoading}></Button>
) : null}
@@ -4635,12 +4951,17 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
const renderModuleActions = () => {
const actions: ReactNode[] = []
if (config === configs.datasources) {
const selectedCount = selectedDatasourceIds.length
actions.push(
<Button key="trigger-all" variant="primary" icon="trigger" onClick={() => void requestAction('触发全部数据源', 'post', '/datasources/trigger-all', undefined, { refresh: false, successDescription: '批量触发任务已提交,不会修改配置。' })} loading={actionLoading} title="触发全部数据源">
</Button>,
<Button key="trigger-batch" size="icon" variant="subtle" onClick={() => setBatchTriggerOpen(true)} loading={actionLoading} title="批量触发数据源" aria-label="批量触发数据源">
<DatabaseZap size={15} />
<Button
key="trigger-primary"
variant="primary"
icon="trigger"
onClick={() => void triggerDatasourcePrimaryAction()}
loading={actionLoading}
title={selectedCount ? `触发已选 ${selectedCount} 个数据源` : '触发全部数据源'}
>
{selectedCount ? `触发已选 ${selectedCount}` : '触发全部'}
</Button>,
)
}
@@ -4748,6 +5069,149 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
)
}
const renderDatasourceTaskSummary = () => {
if (config !== configs.datasources || !selected || selected.__endpointKey !== 'builtin') return null
const sourceId = text(selected.id || selected.source_id, '')
const source = text(selected.source || selected.collector_name, '')
const queueItem = collectionQueue.find((item) => isSameDatasourceRow(selected, item.sourceId || '', item.source || ''))
const status = queueItem?.status || datasourceStatus(selected)
const taskId = queueItem?.taskId || selected.task_id
return (
<section className="an-task-summary">
<div>
<strong></strong>
<p>{queueItem?.phaseMessage || text(selected.last_status || selected.phase_message, '当前没有运行中的任务。')}</p>
</div>
<StatusText tone={statusTone(status)}>{queueStatusLabel(status as CollectionQueueStatus)}</StatusText>
<dl>
<dt></dt><dd>{source || sourceId || '-'}</dd>
<dt></dt><dd>{text(taskId, '-')}</dd>
<dt></dt><dd>{queueItem ? `${queueProgress(queueItem)}%` : '-'}</dd>
<dt></dt><dd>{queueItem?.updatedAt ? new Date(queueItem.updatedAt).toLocaleTimeString() : text(selected.last_run_at, '-')}</dd>
</dl>
</section>
)
}
const jumpToQueueRecord = (item: CollectionQueueItem) => {
const record = rows.find((row) => isSameDatasourceRow(row, item.sourceId || '', item.source || ''))
if (!record) {
toast({ title: '当前分区未找到该数据源', description: '可切回内置源或刷新后再查看。' })
return
}
openResourceDetail(record)
}
const retryQueueItem = (item: CollectionQueueItem) => {
const record = rows.find((row) => isSameDatasourceRow(row, item.sourceId || '', item.source || ''))
if (!record) {
toast({ title: '无法重试', description: '当前列表中没有找到对应数据源。', tone: 'error' })
return
}
void triggerDatasourceWithPrecheck(record)
}
const renderCollectionQueuePanel = () => {
if (config !== configs.datasources) return null
const groups: Array<{ key: string; title: string; items: CollectionQueueItem[] }> = [
{ key: 'running', title: '运行中', items: collectionQueue.filter((item) => item.status === 'queued' || item.status === 'running') },
{ key: 'failed', title: '失败', items: collectionQueue.filter((item) => item.status === 'failed') },
{ key: 'completed', title: '完成', items: collectionQueue.filter((item) => item.status === 'success') },
{ key: 'skipped', title: '跳过', items: collectionQueue.filter((item) => item.status === 'skipped' || item.status === 'cancelled') },
]
return (
<div className="an-collection-queue">
<div className="an-collection-queue__bar">
<div className="an-collection-queue__summary">
<strong></strong>
<span>{collectionQueueSummary.progress}%</span>
<span> {collectionQueueSummary.running}</span>
<span> {collectionQueueSummary.completed}</span>
<span> {collectionQueueSummary.failed}</span>
<span> {collectionQueueSummary.skipped}</span>
</div>
<div className="an-collection-queue__actions">
{collectionQueueSummary.running === 0 ? (
<Button size="icon" variant="subtle" title="清空已结束队列项" aria-label="清空已结束队列项" onClick={() => setCollectionQueue((items) => items.filter((item) => item.status === 'queued' || item.status === 'running'))}>
<X size={14} />
</Button>
) : null}
</div>
</div>
<div className="an-collection-queue__track" aria-hidden="true">
<span style={{ width: `${collectionQueueSummary.progress}%` }} />
</div>
{collectionQueueSummary.total ? (
<div className="an-collection-queue__panel">
{groups.map((group) => (
<section key={group.key} className="an-collection-queue__group">
<h3>{group.title}<span>{group.items.length}</span></h3>
{group.items.length ? (
<div className="an-collection-queue__items">
{group.items.map((item) => (
<article key={item.key} className={`an-collection-queue__item is-${item.status}`}>
<div>
<strong>{item.name}</strong>
<p>{item.phaseMessage || item.error || queueStatusLabel(item.status)}{item.taskId ? ` · task ${item.taskId}` : ''}{item.completedAt ? ` · ${formatDuration(item.createdAt, item.completedAt)}` : ''}</p>
</div>
<span>{queueProgress(item)}%</span>
<div className="an-collection-queue__item-actions">
<Button size="icon" variant="subtle" title="查看数据源" aria-label="查看数据源" onClick={() => jumpToQueueRecord(item)}>
<Eye size={14} />
</Button>
{item.status === 'failed' ? (
<Button size="icon" variant="subtle" icon="trigger" title="重试" aria-label="重试" onClick={() => retryQueueItem(item)} />
) : null}
</div>
</article>
))}
</div>
) : <p className="an-collection-queue__empty">{group.title}</p>}
</section>
))}
</div>
) : (
<p className="an-collection-queue__empty an-collection-queue__empty--panel"></p>
)}
</div>
)
}
const renderCollectionQueueAction = () => {
if (config !== configs.datasources) return null
const hasQueue = collectionQueueSummary.total > 0
const queueLabel = hasQueue
? `采集队列 ${collectionQueueSummary.progress}%,运行 ${collectionQueueSummary.running},完成 ${collectionQueueSummary.completed},失败 ${collectionQueueSummary.failed},跳过 ${collectionQueueSummary.skipped}`
: '采集队列,暂无采集任务'
return (
<div className="an-collection-queue-anchor" ref={collectionQueueRef}>
<Button
size="icon"
variant="subtle"
title={queueLabel}
aria-label={queueLabel}
aria-expanded={collectionQueueOpen}
onClick={() => setCollectionQueueOpen((open) => !open)}
>
{hasQueue ? (
<span
className="an-collection-queue-trigger an-collection-queue-trigger--progress"
style={{ '--queue-progress': `${collectionQueueSummary.progress}%` } as CSSProperties}
aria-hidden="true"
/>
) : (
<ListChecks size={15} />
)}
</Button>
{collectionQueueOpen ? (
<div className="an-collection-queue-popover">
{renderCollectionQueuePanel()}
</div>
) : null}
</div>
)
}
const moduleActions = renderModuleActions()
const credentialGuideProviderName = credentialGuide ? text(credentialGuide.provider, 'barentswatch') : ''
const credentialGuideMarkdown = credentialGuide
@@ -4772,6 +5236,7 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
actions={(
<>
<Button size="icon" variant="subtle" onClick={() => void load()} loading={loading} title="刷新" aria-label="刷新"><RefreshCw size={15} /></Button>
{renderCollectionQueueAction()}
{moduleActions}
{config.actions.map((action) => (
<Button key={action.label} asChild size="icon" variant="subtle" title={action.label} aria-label={action.label}>
@@ -4808,7 +5273,19 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
</div>
<div className="an-panel-body">
{loading || rows.length > 0 ? (
<ModuleTable rows={rows} selected={selected} onSelect={openResourceDetail} columns={config.columns} loading={loading} />
<ModuleTable
rows={rows}
selected={selected}
onSelect={openResourceDetail}
columns={config.columns}
loading={loading}
selection={isDatasourceBuiltinSection ? {
selectedRowIds: datasourceSelectedRowIds,
onToggleAllVisible: toggleAllVisibleDatasourceSelection,
onToggleRow: toggleDatasourceSelection,
getCheckboxLabel: (row) => `选择${recordTitle(row)}`,
} : undefined}
/>
) : (
<EmptyState title={loading ? '正在加载数据' : '当前分区暂无记录'} description="切换上方分区可精准查看不同配置和接口。" />
)}
@@ -4841,6 +5318,7 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
<StatusText tone={statusTone(recordStatus(selected))}>{recordStatus(selected)}</StatusText>
</header>
{renderRecordActions()}
{renderDatasourceTaskSummary()}
<DetailMarkdownDocument record={selected} />
{renderStructuredEditor() || <DetailFields record={selected} />}
<div className="an-code-section">
@@ -4935,31 +5413,6 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
</div>
) : null}
<Dialog
open={batchTriggerOpen}
onOpenChange={setBatchTriggerOpen}
title="批量触发数据源"
description="输入内置数据源 ID提交到 trigger-batch。"
width={560}
footer={(
<>
<Button variant="subtle" onClick={() => setBatchTriggerOpen(false)} disabled={actionLoading}></Button>
<Button variant="primary" onClick={submitBatchTrigger} loading={actionLoading}></Button>
</>
)}
>
<div className="an-form">
<label className="an-field">
<span> ID</span>
<Textarea value={batchTriggerIds} onChange={(event) => setBatchTriggerIds(event.target.value)} placeholder="例如1, 2, 3" />
</label>
<label className="an-checkbox-row">
<input type="checkbox" checked={batchTriggerForce} onChange={(event) => setBatchTriggerForce(event.target.checked)} />
</label>
</div>
</Dialog>
<Dialog
open={Boolean(resolveTarget)}
onOpenChange={(open) => {
@@ -5112,6 +5565,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: '国界精度',

View File

@@ -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,241 @@ 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-anchor {
position: relative;
display: inline-flex;
}
.an-collection-queue-trigger {
position: relative;
width: 20px;
height: 20px;
display: inline-grid;
place-items: center;
color: currentColor;
}
.an-collection-queue-trigger--progress::before {
content: "";
position: absolute;
inset: 0;
border-radius: 999px;
background: conic-gradient(var(--an-accent) var(--queue-progress, 0%), var(--an-soft) 0);
mask: radial-gradient(farthest-side, transparent calc(100% - 3px), #000 0);
-webkit-mask: radial-gradient(farthest-side, transparent calc(100% - 3px), #000 0);
}
.an-collection-queue-popover {
position: absolute;
top: calc(100% + 8px);
right: 0;
z-index: 40;
width: min(960px, calc(100vw - 32px));
}
.an-collection-queue-popover .an-collection-queue {
max-height: min(560px, calc(100vh - 96px));
}
.an-collection-queue-popover .an-collection-queue__panel {
max-height: min(430px, calc(100vh - 210px));
}
.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__empty--panel {
min-height: 96px;
display: grid;
place-items: center;
border: 1px dashed var(--an-border);
border-radius: 8px;
background: var(--an-surface-alt);
}
.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;
@@ -513,6 +760,20 @@ body:has(.admin-next-theme-root[data-theme='dark']) .an-toast {
gap: 8px;
}
.an-toolbar > .tui-button {
align-self: stretch;
height: 34px;
}
.an-toolbar .an-status-pill {
align-self: stretch;
height: 34px;
min-width: 0;
padding: 0 12px;
border-radius: 7px;
line-height: 1;
}
.an-inline-field {
height: 34px;
display: inline-flex;
@@ -2180,9 +2441,11 @@ body:has(.admin-next-theme-root[data-theme='dark']) .an-toast {
}
.an-toolbar .an-badge {
height: 32px;
padding: 0 10px;
border-radius: 6px;
align-self: stretch;
height: 34px;
padding: 0 12px;
border-radius: 7px;
line-height: 1;
}
.an-data-table {
@@ -2233,6 +2496,22 @@ body:has(.admin-next-theme-root[data-theme='dark']) .an-toast {
white-space: nowrap;
}
.an-data-table__selection-cell {
width: 42px;
min-width: 42px;
max-width: 42px;
padding: 0;
text-align: center;
}
.an-data-table__selection-cell input {
width: 15px;
height: 15px;
margin: 0;
accent-color: var(--an-accent);
cursor: pointer;
}
.an-data-table th {
position: sticky;
top: 0;
@@ -3572,6 +3851,28 @@ 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-popover {
position: fixed;
top: 92px;
right: 12px;
left: 12px;
width: auto;
}
.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));
}

View File

@@ -69,10 +69,10 @@ export function tactileButtonPreset(
return {
...base,
background: 'var(--tui-primary)',
backgroundHover: 'var(--tui-primary-hover)',
backgroundHover: 'color-mix(in srgb, var(--tui-primary) 86%, white)',
backgroundActive: 'var(--tui-primary-active)',
borderColor: 'var(--tui-primary)',
borderColorHover: 'var(--tui-primary-hover)',
borderColor: 'color-mix(in srgb, var(--tui-primary) 64%, white)',
borderColorHover: 'color-mix(in srgb, var(--tui-primary) 52%, white)',
color: '#fff',
darkBackground: 'var(--tui-primary)',
}
@@ -81,10 +81,10 @@ export function tactileButtonPreset(
return {
...base,
background: 'var(--tui-danger)',
backgroundHover: 'var(--tui-danger-hover)',
backgroundHover: 'color-mix(in srgb, var(--tui-danger) 86%, white)',
backgroundActive: 'var(--tui-danger-active)',
borderColor: 'var(--tui-danger)',
borderColorHover: 'var(--tui-danger-hover)',
borderColor: 'color-mix(in srgb, var(--tui-danger) 64%, white)',
borderColorHover: 'color-mix(in srgb, var(--tui-danger) 52%, white)',
color: '#fff',
darkBackground: 'var(--tui-danger)',
}

View File

@@ -37,7 +37,13 @@
[data-theme='dark'] .tui-control-group,
[data-theme='dark'] .tui-scrollbar,
[data-theme='dark'] .tui-table-scroll-region,
[data-theme='dark'] .tui-tooltip {
[data-theme='dark'] .tui-tooltip,
body[data-admin-next-theme='dark'] .tui-button,
body[data-admin-next-theme='dark'] .tui-switch,
body[data-admin-next-theme='dark'] .tui-control-group,
body[data-admin-next-theme='dark'] .tui-scrollbar,
body[data-admin-next-theme='dark'] .tui-table-scroll-region,
body[data-admin-next-theme='dark'] .tui-tooltip {
--tui-bg: #0f1724;
--tui-surface: #172033;
--tui-surface-soft: #202b3d;

View File

@@ -535,6 +535,18 @@ run_command_with_spinner() {
return "$exit_code"
}
run_command_quiet_unless_verbose() {
local log_file="$1"
shift
if [ "$VERBOSE" -eq 1 ]; then
"$@"
return $?
fi
"$@" > "$log_file" 2>&1
}
resolve_bun_from_shell_configs() {
local shell_name="$1"
shift
@@ -1272,6 +1284,8 @@ install_bun_if_needed() {
}
ensure_uv_backend_deps() {
local log_file="$PLANET_STATE_DIR/uv_sync.log"
set_wait_detail "检查后端 uv 环境"
if ! command -v uv >/dev/null 2>&1; then
@@ -1279,6 +1293,7 @@ ensure_uv_backend_deps() {
fi
cd "$SCRIPT_DIR"
: > "$log_file"
if [ ! -x "$SCRIPT_DIR/.venv/bin/python" ]; then
log_warn "未检测到 .venv正在执行 uv sync"
@@ -1287,7 +1302,8 @@ ensure_uv_backend_deps() {
"$DEPENDENCY_INSTALL_RETRY_INTERVAL" \
"uv 环境初始化失败,已重试 ${DEPENDENCY_INSTALL_MAX_RETRIES}" \
"uv sync" \
uv sync --group dev; then
run_command_quiet_unless_verbose "$log_file" uv sync --group dev; then
tail -20 "$log_file" 2>/dev/null || true
exit 1
fi
fi
@@ -1299,6 +1315,8 @@ ensure_uv_backend_deps() {
}
ensure_python_runtime() {
local log_file="$PLANET_STATE_DIR/uv_python_install.log"
ensure_local_runtime_bins_on_path
if ! command -v uv >/dev/null 2>&1; then
@@ -1306,6 +1324,7 @@ ensure_python_runtime() {
fi
cd "$SCRIPT_DIR"
: > "$log_file"
set_wait_detail "安装 Python 3.14 运行时"
if ! run_with_retry \
@@ -1313,12 +1332,15 @@ ensure_python_runtime() {
"$DEPENDENCY_INSTALL_RETRY_INTERVAL" \
"Python 3.14 运行时安装失败,已重试 ${DEPENDENCY_INSTALL_MAX_RETRIES}" \
"uv python install 3.14" \
uv python install 3.14; then
run_command_quiet_unless_verbose "$log_file" uv python install 3.14; then
tail -20 "$log_file" 2>/dev/null || true
exit 1
fi
}
sync_python_deps() {
local log_file="$PLANET_STATE_DIR/uv_sync.log"
ensure_local_runtime_bins_on_path
if ! command -v uv >/dev/null 2>&1; then
@@ -1326,6 +1348,7 @@ sync_python_deps() {
fi
cd "$SCRIPT_DIR"
: > "$log_file"
set_wait_detail "同步 Python 依赖"
if ! run_with_retry \
@@ -1333,7 +1356,8 @@ sync_python_deps() {
"$DEPENDENCY_INSTALL_RETRY_INTERVAL" \
"uv 环境初始化失败,已重试 ${DEPENDENCY_INSTALL_MAX_RETRIES}" \
"uv sync" \
uv sync --group dev; then
run_command_quiet_unless_verbose "$log_file" uv sync --group dev; then
tail -20 "$log_file" 2>/dev/null || true
exit 1
fi
@@ -1469,6 +1493,7 @@ resolve_frontend_runtime() {
ensure_frontend_deps() {
local owns_wait_session=0
local log_file="$PLANET_STATE_DIR/bun_install.log"
if [ "$WAIT_SESSION_ACTIVE" -eq 0 ]; then
start_wait_session "检查前端依赖"
@@ -1488,6 +1513,7 @@ ensure_frontend_deps() {
fi
cd "$SCRIPT_DIR/frontend"
: > "$log_file"
set_wait_detail "检查 Vite Bun 入口是否已安装"
if [ ! -f "$FRONTEND_VITE_ENTRY" ]; then
@@ -1498,7 +1524,8 @@ ensure_frontend_deps() {
"$DEPENDENCY_INSTALL_RETRY_INTERVAL" \
"前端依赖安装失败,已重试 ${DEPENDENCY_INSTALL_MAX_RETRIES}" \
"bun install" \
"$FRONTEND_RUNTIME_BIN" install; then
run_command_quiet_unless_verbose "$log_file" "$FRONTEND_RUNTIME_BIN" install; then
tail -20 "$log_file" 2>/dev/null || true
exit 1
fi
fi
@@ -2917,6 +2944,8 @@ ensure_planet_env_files() {
}
sync_frontend_deps() {
local log_file="$PLANET_STATE_DIR/bun_install.log"
set_wait_detail "解析前端运行时"
if ! resolve_frontend_runtime; then
install_bun_if_needed
@@ -2927,6 +2956,7 @@ sync_frontend_deps() {
fi
cd "$SCRIPT_DIR/frontend"
: > "$log_file"
set_wait_detail "同步前端依赖"
if ! run_with_retry \
@@ -2934,7 +2964,8 @@ sync_frontend_deps() {
"$DEPENDENCY_INSTALL_RETRY_INTERVAL" \
"前端依赖安装失败,已重试 ${DEPENDENCY_INSTALL_MAX_RETRIES}" \
"bun install" \
"$FRONTEND_RUNTIME_BIN" install; then
run_command_quiet_unless_verbose "$log_file" "$FRONTEND_RUNTIME_BIN" install; then
tail -20 "$log_file" 2>/dev/null || true
exit 1
fi
}
@@ -3041,7 +3072,7 @@ init() {
log_success "初始化完成"
log_note "默认本地登录用户:"
log_note " admin / admin123"
log_note " linkong / 12345678"
log_note " linkong / LK12345678"
log_note "下一步: ./planet.sh start"
}
@@ -3288,7 +3319,7 @@ remove_planet_docker_state() {
printf "%s\n" "$container_ids" | awk '/^[0-9a-f]{12,64}$/' | xargs -r docker rm -f >/dev/null 2>&1 || true
fi
docker image rm "$AI_PROVIDER_IMAGE_NAME" postgres:15 redis:7-alpine >/dev/null 2>&1 || true
docker image rm "$AI_PROVIDER_IMAGE_NAME" planet-aiprovider:latest postgres:15 redis:7-alpine >/dev/null 2>&1 || true
volume_names="$(docker volume ls -q --filter label=com.docker.compose.project=planet 2>/dev/null || true)"
if [ -n "$volume_names" ]; then
@@ -3297,6 +3328,19 @@ remove_planet_docker_state() {
docker volume rm -f planet_postgres_data planet_redis_data postgres_data redis_data >/dev/null 2>&1 || true
}
reset_planet_database_for_destroy() {
local postgres_container="planet_postgres"
docker ps --format '{{.Names}}' 2>/dev/null | grep -qx "$postgres_container" || return 0
docker exec "$postgres_container" psql -U postgres -d planet_db -v ON_ERROR_STOP=1 >/dev/null 2>&1 <<'SQL' || true
DROP SCHEMA IF EXISTS public CASCADE;
CREATE SCHEMA public;
GRANT ALL ON SCHEMA public TO postgres;
GRANT ALL ON SCHEMA public TO public;
SQL
}
remove_planet_build_state() {
rm -rf \
"$SCRIPT_DIR/.venv" \
@@ -3311,6 +3355,11 @@ remove_planet_build_state() {
"$SCRIPT_DIR/frontend/dist-ssr" \
"$PLANET_STATE_DIR" \
"$PLANET_CACHE_DIR"
find "$SCRIPT_DIR" \
\( -path "$SCRIPT_DIR/.git" -o -path "$SCRIPT_DIR/frontend/node_modules" -o -path "$SCRIPT_DIR/node_modules" \) -prune \
-o \( -type d \( -name __pycache__ -o -name .pytest_cache -o -name .ruff_cache -o -name .mypy_cache -o -name .vite \) -print \) \
| xargs -r rm -rf
}
destroy() {
@@ -3323,6 +3372,11 @@ destroy() {
stop_frontend_service
stop_motion_agent_service
start_wait_session "重置数据库 OOBE 状态"
reset_planet_database_for_destroy
stop_wait_session
log_success "数据库 OOBE 状态已重置"
start_wait_session "清理 Docker 状态"
remove_planet_docker_state
stop_wait_session

View File

@@ -1,6 +1,6 @@
[project]
name = "planet"
version = "0.63.1"
version = "0.65.0"
description = "智能星球计划 - 态势感知系统"
requires-python = ">=3.14"
dependencies = [

View File

@@ -23,7 +23,7 @@ DEFAULT_LOGIN_USERS = (
{
"username": "linkong",
"email": "linkong@planet.local",
"password": "12345678",
"password": "LK12345678",
"role": "super_admin",
},
)

2
uv.lock generated
View File

@@ -757,7 +757,7 @@ wheels = [
[[package]]
name = "planet"
version = "0.63.1"
version = "0.65.0"
source = { virtual = "." }
dependencies = [
{ name = "aiofiles" },