dev #11
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -8,6 +8,23 @@ 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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -112,12 +112,13 @@ This keeps Earth, AI, collection management, and other multi-section pages from
|
||||
|
||||
## Datasource Collection Queue
|
||||
|
||||
The Admin Next datasource page routes single-source trigger, batch trigger, and trigger-all into a browser-download-list style collection queue:
|
||||
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 page shows a compact progress bar and counts above the table. The "view queue" action opens a bottom panel grouped by running, failed, completed, and skipped.
|
||||
- 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.
|
||||
|
||||
@@ -233,7 +233,7 @@ To let a regular user read developer or operations docs, add `docs_developer` or
|
||||
|
||||
## Data Exploration
|
||||
|
||||
- `/datasources`: source directory. The `Built-in Sources` tab can be filtered by product domain, layer/module, enabled state, last run status, whether collected records exist, and search text. Selecting rows triggers only those sources; `Trigger All` enters a browser-download-list style collection queue. The queue bar shows total progress plus running, completed, failed, and skipped counts; `View Queue` opens the bottom panel, failed rows can retry, and completed rows can jump to detail. `Realtime Sources` is for AISStream / WebSocket long connections and shows connection health, stored totals, time-window counters, and Start / Stop / Reconnect actions. Endpoint/credential/header editing happens at `/collection-management -> Collectors`.
|
||||
- `/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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 -> Built-in Sources` for finite collectors and open the collection queue after triggering; use `/datasources -> Realtime Sources` 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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`。
|
||||
|
||||
|
||||
@@ -112,12 +112,13 @@ legacy 职责:
|
||||
|
||||
## 数据源采集队列
|
||||
|
||||
Admin Next 的数据源页把单源触发、批量触发和触发全部统一接入浏览器下载列表式采集队列:
|
||||
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` 图标,有任务时只显示纯圆环总进度。点击后打开浮层,按运行中、失败、完成、跳过分组。
|
||||
- 队列项可以跳转到对应数据源详情,失败项可以重试。详情页内的“采集任务”摘要只展示当前数据源最近任务,不承担保存配置职责。
|
||||
|
||||
这个队列是用户感知层,不替代后端调度状态。后端仍然是任务是否运行、完成、失败或跳过的唯一事实来源。
|
||||
|
||||
@@ -236,7 +236,7 @@ Earth 页面工具栏齿轮中也有“国界精度”。切到“高精”时
|
||||
|
||||
## 数据探索
|
||||
|
||||
- `/datasources`:数据源目录。`内置源` 支持按产品域、层级、启用状态、最近执行状态、是否已有采集数据和关键词筛选;勾选多行后可批量触发,`触发全部` 会进入类似浏览器下载列表的采集队列。队列上方显示总进度、运行中、完成、失败和跳过数量,`查看队列` 可展开底部面板,失败项可重试,完成项可跳到详情。`实时源` 面向 AISStream / WebSocket 长连接,展示连接健康、累计入库、时间窗统计和启动 / 停止 / 重连操作。接口、凭证、请求头的编辑统一在 `/collection-management` 的"采集器"。
|
||||
- `/datasources`:数据源目录。`内置源` 支持按产品域、层级、启用状态、最近执行状态、是否已有采集数据和关键词筛选;未勾选时主按钮显示“触发全部”,勾选多行后会变成“触发已选 N”,并只提交所选数据源。右上角队列按钮空态显示队列图标,有任务时显示纯圆环总进度;点击后打开队列浮层,按运行中、完成、失败和跳过分组,失败项可重试,完成项可跳到详情。`实时源` 面向 AISStream / WebSocket 长连接,展示连接健康、累计入库、时间窗统计和启动 / 停止 / 重连操作。接口、凭证、请求头的编辑统一在 `/collection-management` 的"采集器"。
|
||||
- `/data`:采集后数据表,适合排查"数据是否已经进入系统"、"更新时间是否符合预期"、"某个数据源是否产出有效记录"
|
||||
- `/bgp`:BGP 专题页面,列表 + 详情 + 研判,与 Earth 的 BGP 图层互补
|
||||
- `/alerts/system`、`/alerts/bgp`、`/alerts/situational`:系统、BGP、态势告警
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`):根据需要给同事开账号或调权限组
|
||||
|
||||
|
||||
@@ -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` 统一维护,业务页面通过语义名称调用,避免每个页面随意选择图标。
|
||||
|
||||
@@ -16,12 +16,13 @@
|
||||
## Current Version
|
||||
|
||||
- `main` 当前主线历史推导到:`0.16.5`
|
||||
- `dev` 当前开发分支历史推导到:`0.64.0`
|
||||
- `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 快速启动说明 |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "planet-frontend",
|
||||
"version": "0.64.0",
|
||||
"version": "0.65.0",
|
||||
"private": true,
|
||||
"packageManager": "bun@1",
|
||||
"dependencies": {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
FileText,
|
||||
Globe2,
|
||||
ImageUp,
|
||||
ListChecks,
|
||||
Radio,
|
||||
Redo2,
|
||||
RefreshCw,
|
||||
@@ -527,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])
|
||||
@@ -546,6 +555,7 @@ function ModuleTable({
|
||||
getRowId={(row) => row.__rowId}
|
||||
loading={loading}
|
||||
onRowClick={onSelect}
|
||||
selection={selection}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -2025,6 +2035,8 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
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>>({})
|
||||
@@ -2038,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)
|
||||
@@ -2095,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) {
|
||||
@@ -2105,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> = {
|
||||
@@ -2150,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> => {
|
||||
@@ -2460,6 +2489,42 @@ 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
|
||||
@@ -2485,6 +2550,7 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
|
||||
const handleSectionChange = (key: string) => {
|
||||
setActiveSectionKey(key)
|
||||
setDatasourceSelectedRowIds(new Set())
|
||||
setActiveGroupKey('')
|
||||
setHierarchyDraft('')
|
||||
setTvDraftGroup(null)
|
||||
@@ -2637,20 +2703,17 @@ 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) => {
|
||||
@@ -2665,15 +2728,23 @@ 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 {
|
||||
@@ -4880,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 triggerAllDatasources()} 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>,
|
||||
)
|
||||
}
|
||||
@@ -5035,8 +5111,8 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
void triggerDatasourceWithPrecheck(record)
|
||||
}
|
||||
|
||||
const renderCollectionQueue = () => {
|
||||
if (config !== configs.datasources || !collectionQueueSummary.total) return null
|
||||
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') },
|
||||
@@ -5055,9 +5131,6 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
<span>跳过 {collectionQueueSummary.skipped}</span>
|
||||
</div>
|
||||
<div className="an-collection-queue__actions">
|
||||
<Button size="sm" variant="subtle" onClick={() => setCollectionQueueOpen((open) => !open)}>
|
||||
{collectionQueueOpen ? '收起队列' : '查看队列'}
|
||||
</Button>
|
||||
{collectionQueueSummary.running === 0 ? (
|
||||
<Button size="icon" variant="subtle" title="清空已结束队列项" aria-label="清空已结束队列项" onClick={() => setCollectionQueue((items) => items.filter((item) => item.status === 'queued' || item.status === 'running'))}>
|
||||
<X size={14} />
|
||||
@@ -5068,7 +5141,7 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
<div className="an-collection-queue__track" aria-hidden="true">
|
||||
<span style={{ width: `${collectionQueueSummary.progress}%` }} />
|
||||
</div>
|
||||
{collectionQueueOpen ? (
|
||||
{collectionQueueSummary.total ? (
|
||||
<div className="an-collection-queue__panel">
|
||||
{groups.map((group) => (
|
||||
<section key={group.key} className="an-collection-queue__group">
|
||||
@@ -5097,6 +5170,43 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
</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>
|
||||
)
|
||||
@@ -5126,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}>
|
||||
@@ -5144,7 +5255,6 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
|
||||
<SectionTabs sections={config.sections} states={states} activeKey={activeSection?.key || ''} onChange={handleSectionChange} />
|
||||
{renderDatasourceFilters()}
|
||||
{renderCollectionQueue()}
|
||||
</div>
|
||||
|
||||
{isPlaygroundSection ? (
|
||||
@@ -5163,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="切换上方分区可精准查看不同配置和接口。" />
|
||||
)}
|
||||
@@ -5291,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) => {
|
||||
|
||||
@@ -365,6 +365,46 @@ body:has(.admin-next-theme-root[data-theme='dark']) .an-toast {
|
||||
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));
|
||||
@@ -433,6 +473,15 @@ body:has(.admin-next-theme-root[data-theme='dark']) .an-toast {
|
||||
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);
|
||||
@@ -711,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;
|
||||
@@ -2378,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 {
|
||||
@@ -2431,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;
|
||||
@@ -3775,6 +3856,14 @@ body:has(.admin-next-theme-root[data-theme='dark']) .an-toast {
|
||||
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;
|
||||
|
||||
@@ -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)',
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
25
planet.sh
25
planet.sh
@@ -3319,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
|
||||
@@ -3328,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" \
|
||||
@@ -3342,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() {
|
||||
@@ -3354,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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "planet"
|
||||
version = "0.64.0"
|
||||
version = "0.65.0"
|
||||
description = "智能星球计划 - 态势感知系统"
|
||||
requires-python = ">=3.14"
|
||||
dependencies = [
|
||||
|
||||
Reference in New Issue
Block a user