diff --git a/CHANGELOG.md b/CHANGELOG.md
index 37a32a7..ea48e2b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,23 @@
# Changelog
+## 开发快照(0.1.269:发布页维护文案与版本记录页加载失败)
+
+### Fixed
+
+- 发布管理:作业失败但维护窗口已释放时,不再显示"目标保持维护、入口未开、收尾未完成"的 held 诊断;
+ 改为如实记录"作业失败(维护窗口已释放)"。只有仍被持有的失败作业才给 held 处置文案(此前仅按 failed 渲染)。
+- 版本记录:同步进行中的 run(`finished_at` 为空)不再让整页 500。部署重启会自动触发一次同步,
+ 原先该行的空时间戳扫描失败 → `Get /api/v1/version-history` 返回 500。
+- 版本记录页:请求失败时不再声称"当前运行版本未知"或"版本记录未接线",并显示服务端返回的
+ `error`/`code`(例如 `version_history_unavailable`),把"未配置"和"后端故障"分开。
+
+### Tests
+
+- `internal/platform/postgres/version_history_sync_state_integration_test.go`:进行中 run(NULL finished_at)
+ 读取同步状态与分页都必须成功。
+- `web/src/releaseTargetsMaintenance.test.tsx`:已释放的失败作业不出现 held 文案;仍持有时保留。
+- `web/src/VersionHistory.test.tsx`:加载失败时不出现"未接线/未知",并显示服务端原因。
+
## 开发快照(隔离验收现场保留,仅测试侧)
### Changed
diff --git a/internal/platform/postgres/version_history.go b/internal/platform/postgres/version_history.go
index 511c530..9f838e2 100644
--- a/internal/platform/postgres/version_history.go
+++ b/internal/platform/postgres/version_history.go
@@ -171,11 +171,14 @@ WHERE repo_key = $1 AND status = 'completed' ORDER BY id DESC LIMIT 1`, repoKey)
func (s *Store) LoadVersionHistorySyncState(ctx context.Context, repoKey string) (governance.VersionHistorySyncRun, error) {
var run governance.VersionHistorySyncRun
var expected *int
+ // finished_at 在"进行中"的 run 上是 NULL(列可空)。用指针接再判空:
+ // 直接扫进 time.Time 会让整页 500,而部署重启本身就会触发一次同步。
+ var finishedAt *time.Time
err := s.db.QueryRow(ctx, `
SELECT id, job_id, status, source_head_sha, started_at, finished_at, complete,
fetched_count, expected_count, error_code, triggered_by
FROM version_history_sync_runs WHERE repo_key = $1 ORDER BY id DESC LIMIT 1`, repoKey).
- Scan(&run.ID, &run.JobID, &run.Status, &run.SourceHeadSHA, &run.StartedAt, &run.FinishedAt,
+ Scan(&run.ID, &run.JobID, &run.Status, &run.SourceHeadSHA, &run.StartedAt, &finishedAt,
&run.Complete, &run.FetchedCount, &expected, &run.ErrorCode, &run.TriggeredBy)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return governance.VersionHistorySyncRun{}, fmt.Errorf("查询同步状态:%w", err)
@@ -183,6 +186,9 @@ FROM version_history_sync_runs WHERE repo_key = $1 ORDER BY id DESC LIMIT 1`, re
if errors.Is(err, pgx.ErrNoRows) {
return governance.VersionHistorySyncRun{}, nil
}
+ if finishedAt != nil {
+ run.FinishedAt = *finishedAt
+ }
run.ExpectedCount = expected
if snapshot, err := s.ActiveVersionHistorySnapshot(ctx, repoKey); err != nil {
return governance.VersionHistorySyncRun{}, err
diff --git a/internal/platform/postgres/version_history_sync_state_integration_test.go b/internal/platform/postgres/version_history_sync_state_integration_test.go
new file mode 100644
index 0000000..23dd2f1
--- /dev/null
+++ b/internal/platform/postgres/version_history_sync_state_integration_test.go
@@ -0,0 +1,80 @@
+package postgres
+
+import (
+ "context"
+ "os"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/jackc/pgx/v5/pgxpool"
+)
+
+// 真实缺陷:同步进行中的 run(finished_at 为 NULL)会让 LoadVersionHistorySyncState
+// 扫描失败,整个版本记录页 500。部署重启会自动触发一次同步,所以页面第一次打开就撞上。
+func TestVersionHistorySyncStateToleratesRunningRunWithoutFinishTime(t *testing.T) {
+ dsn := os.Getenv("KG_VERSION_HISTORY_TEST_DSN")
+ if dsn == "" {
+ t.Skip("explicit disposable version-history DB required")
+ }
+ if !strings.Contains(dsn, "127.0.0.1") || !strings.Contains(dsn, "/kg_version_history_test_") {
+ t.Fatal("refusing shared database")
+ }
+ ctx := context.Background()
+ pool, err := pgxpool.New(ctx, dsn)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer pool.Close()
+ if err = ApplyMigrations(ctx, pool); err != nil {
+ t.Fatal(err)
+ }
+ repo := "fixture/repo"
+ if _, err = pool.Exec(ctx, `DELETE FROM version_history_sync_runs WHERE repo_key = $1`, repo); err != nil {
+ t.Fatal(err)
+ }
+ // 一条已完成的历史 run(提供 last_success_at 与快照)
+ if _, err = pool.Exec(ctx, `
+INSERT INTO version_history_sync_runs
+ (repo_key, job_id, status, source_head_sha, started_at, finished_at, last_success_at,
+ complete, fetched_count, expected_count, error_code, triggered_by)
+VALUES ($1,'job-1','completed',$2, now() - interval '2 hours', now() - interval '2 hours',
+ now() - interval '2 hours', true, 10, 10, '', 'fixture')`, repo, strings.Repeat("a", 40)); err != nil {
+ t.Fatal(err)
+ }
+ // 一条正在跑的 run:finished_at 必须是 NULL(这正是线上触发 500 的行)
+ if _, err = pool.Exec(ctx, `
+INSERT INTO version_history_sync_runs
+ (repo_key, job_id, status, source_head_sha, started_at, finished_at,
+ complete, fetched_count, expected_count, error_code, triggered_by)
+VALUES ($1,'job-2','running',$2, now(), NULL, false, 3, 12, '', 'fixture')`, repo, strings.Repeat("b", 40)); err != nil {
+ t.Fatal(err)
+ }
+
+ store := NewStore(pool)
+ state, err := store.LoadVersionHistorySyncState(ctx, repo)
+ if err != nil {
+ t.Fatalf("同步进行中读取同步状态不应失败:%v", err)
+ }
+ if state.Status != "running" {
+ t.Fatalf("应读到最新(进行中)的 run,实际 status=%q", state.Status)
+ }
+ if !state.FinishedAt.IsZero() {
+ t.Fatalf("进行中的 run 不应有结束时间,实际 %v", state.FinishedAt)
+ }
+ if state.LastSuccessAt.IsZero() {
+ t.Fatal("应保留上一次成功时间")
+ }
+ if !state.LastSuccessAt.Before(time.Now()) {
+ t.Fatalf("上次成功时间异常:%v", state.LastSuccessAt)
+ }
+
+ // 列表读取同样不能因为这一行而失败
+ items, _, _, snapshot, err := store.LoadVersionHistoryPage(ctx, repo, "all", "", 5)
+ if err != nil {
+ t.Fatalf("进行中读取分页也不应失败:%v", err)
+ }
+ if len(items) != 0 || snapshot == 0 {
+ t.Fatalf("空快照应返回空页与快照号,实际 items=%d snapshot=%d", len(items), snapshot)
+ }
+}
\ No newline at end of file
diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx
index fdade6a..dc638bd 100644
--- a/web/src/App.test.tsx
+++ b/web/src/App.test.tsx
@@ -1,11 +1,26 @@
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it, vi } from 'vitest'
-import App, { JobDashboard, KnowledgeInventory, ModulePage, ReleaseManagement, SourceConnections, WorkspaceShell, reviewGateLabel } from './App'
+import App, {
+ JobDashboard,
+ KnowledgeInventory,
+ ModulePage,
+ ReleaseManagement,
+ SourceConnections,
+ WorkspaceShell,
+ reviewGateLabel,
+} from './App'
import { AuthContext } from './auth'
import type { SourceAssetPage } from './assets'
import type { Job } from './jobs'
-import type { ReleaseBatchDetail, ReleaseBatchPage, ReleaseExecutionDetail, ReleaseExecutionPage, ReleasePlan, ReviewCandidatePage } from './reviewRelease'
+import type {
+ ReleaseBatchDetail,
+ ReleaseBatchPage,
+ ReleaseExecutionDetail,
+ ReleaseExecutionPage,
+ ReleasePlan,
+ ReviewCandidatePage,
+} from './reviewRelease'
import type { SourceConnectionPage, SourceScanDetail, SourceScanPage } from './sourceConnections'
const baseJob: Job = {
@@ -25,12 +40,9 @@ const baseJob: Job = {
describe('WorkspaceShell', () => {
it('renders the approved modules in the left navigation', () => {
const html = renderToStaticMarkup(
-
+
工作区内容
- ,
+
)
expect(html).toContain('aria-label="功能导航"')
@@ -45,12 +57,9 @@ describe('WorkspaceShell', () => {
it('omits the system open-page tab strip while retaining navigation and the current location', () => {
const html = renderToStaticMarkup(
-
+
工作区内容
- ,
+
)
expect(html).not.toContain('aria-label="已打开页面"')
@@ -66,12 +75,9 @@ describe('WorkspaceShell', () => {
it('does not render obsolete module-tab close controls', () => {
const html = renderToStaticMarkup(
-
+
工作区内容
- ,
+
)
expect(html).not.toContain('aria-label="关闭解析监控标签"')
@@ -80,12 +86,9 @@ describe('WorkspaceShell', () => {
it('renders a collapsed mobile navigation control', () => {
const html = renderToStaticMarkup(
-
+
工作区内容
- ,
+
)
expect(html).toContain('aria-label="切换功能导航"')
@@ -115,7 +118,7 @@ describe('ModulePage', () => {
loading={false}
error={null}
onRefresh={vi.fn()}
- />,
+ />
)
expect(html).toContain('治理工作台')
@@ -141,12 +144,23 @@ describe('ModulePage', () => {
error={null}
onRefresh={vi.fn()}
publicationPage={{
- items: [{
- id: 'pub-1', title: 'Safe title', ingest_state: 'failed', processing_state: 'failed',
- retrieval_state: 'unpublished', target_scope: 'internal', change_set_id: 'cs-1', change_type: 'added', can_start: false,
- retry_count: 2, last_error: 'remote_parse_failed',
- can_retry: true, updated_at: '2026-09-04T05:00:00Z',
- }],
+ items: [
+ {
+ id: 'pub-1',
+ title: 'Safe title',
+ ingest_state: 'failed',
+ processing_state: 'failed',
+ retrieval_state: 'unpublished',
+ target_scope: 'internal',
+ change_set_id: 'cs-1',
+ change_type: 'added',
+ can_start: false,
+ retry_count: 2,
+ last_error: 'remote_parse_failed',
+ can_retry: true,
+ updated_at: '2026-09-04T05:00:00Z',
+ },
+ ],
total: 1,
limit: 50,
offset: 0,
@@ -158,7 +172,7 @@ describe('ModulePage', () => {
onPublicationPageChange={vi.fn()}
onPublicationRefresh={vi.fn()}
onRetry={vi.fn()}
- />,
+ />
)
expect(html).toContain('
解析监控 ')
@@ -177,14 +191,19 @@ describe('ModulePage', () => {
loading={false}
error={null}
onRefresh={vi.fn()}
- assetPage={{ items: [], summary: { total: 613, completed: 530, content_alias: 29, over_limit: 54 }, limit: 50, offset: 0 }}
+ assetPage={{
+ items: [],
+ summary: { total: 613, completed: 530, content_alias: 29, over_limit: 54 },
+ limit: 50,
+ offset: 0,
+ }}
assetLoading={false}
assetError={null}
assetState=""
onAssetStateChange={vi.fn()}
onAssetPageChange={vi.fn()}
onAssetRefresh={vi.fn()}
- />,
+ />
)
expect(html).toContain('统一治理清单')
@@ -203,15 +222,39 @@ describe('ModulePage', () => {
connectionPage={{
items: [
{
- id: 'conn-pp', source_type: 'pp', name: 'PP历史公开知识', enabled: false,
- schedule_configured: false, health_status: 'healthy', item_count: 613, updated_at: '2026-09-06T01:00:00Z',
+ id: 'conn-pp',
+ source_type: 'pp',
+ name: 'PP历史公开知识',
+ enabled: false,
+ schedule_configured: false,
+ health_status: 'healthy',
+ item_count: 613,
+ updated_at: '2026-09-06T01:00:00Z',
last_scan: {
- id: 'scan-1', state: 'completed', observed_items: 613, unchanged_items: 559,
- added_items: 0, updated_items: 0, over_limit_items: 54, missing_items: 0,
- planned_uploads: 0, error_code: '', created_at: '2026-09-06T00:59:00Z', completed_at: '2026-09-06T01:00:00Z',
+ id: 'scan-1',
+ state: 'completed',
+ observed_items: 613,
+ unchanged_items: 559,
+ added_items: 0,
+ updated_items: 0,
+ over_limit_items: 54,
+ missing_items: 0,
+ planned_uploads: 0,
+ error_code: '',
+ created_at: '2026-09-06T00:59:00Z',
+ completed_at: '2026-09-06T01:00:00Z',
},
},
- { id: 'conn-alist', source_type: 'alist', name: 'AList只读源', enabled: true, schedule_configured: true, health_status: 'healthy', item_count: 42, updated_at: '2026-09-06T01:00:00Z' },
+ {
+ id: 'conn-alist',
+ source_type: 'alist',
+ name: 'AList只读源',
+ enabled: true,
+ schedule_configured: true,
+ health_status: 'healthy',
+ item_count: 42,
+ updated_at: '2026-09-06T01:00:00Z',
+ },
],
summary: { total: 2, enabled: 1, disabled: 1, healthy: 1, degraded: 0, unhealthy: 0, unknown: 1 },
}}
@@ -220,12 +263,23 @@ describe('ModulePage', () => {
onConnectionRefresh={vi.fn()}
scanningConnectionId={null}
onTriggerSourceScan={vi.fn()}
- />,
+ />
)
expect(html).toContain('来源连接 ')
expect(html).not.toContain('conn-pp')
- for (const text of ['来源连接', 'PP历史公开知识', 'AList只读源', '613', '已配置', '健康', '最近扫描', '未变化 559', '超过上限 54', '检查快照变化']) {
+ for (const text of [
+ '来源连接',
+ 'PP历史公开知识',
+ 'AList只读源',
+ '613',
+ '已配置',
+ '健康',
+ '最近扫描',
+ '未变化 559',
+ '超过上限 54',
+ '检查快照变化',
+ ]) {
expect(html).toContain(text)
}
expect(html).toContain('class="source-connections-table"')
@@ -236,21 +290,50 @@ describe('ModulePage', () => {
describe('KnowledgeInventory', () => {
it('renders real source summary and safe paginated asset fields', () => {
const page: SourceAssetPage = {
- items: [{
- id: 'si-safe', source_type: 'pp', connection_name: 'PP历史公开知识', size_bytes: 52428801,
- visibility: 'external', source_change: 'unchanged', content_state: 'over_limit', legacy_status: 'over_limit',
- has_publication: false, processing_state: '', retrieval_state: '', updated_at: '2026-09-04T00:00:00Z',
- }, {
- id: 'si-manual', source_type: 'manual' as never, connection_name: '本地验收', size_bytes: 128,
- visibility: 'internal', source_change: 'added', content_state: 'verified', legacy_status: '',
- has_publication: true, processing_state: 'pending', retrieval_state: 'unpublished', updated_at: '2026-09-05T00:00:00Z',
- }],
+ items: [
+ {
+ id: 'si-safe',
+ source_type: 'pp',
+ connection_name: 'PP历史公开知识',
+ size_bytes: 52428801,
+ visibility: 'external',
+ source_change: 'unchanged',
+ content_state: 'over_limit',
+ legacy_status: 'over_limit',
+ has_publication: false,
+ processing_state: '',
+ retrieval_state: '',
+ updated_at: '2026-09-04T00:00:00Z',
+ },
+ {
+ id: 'si-manual',
+ source_type: 'manual' as never,
+ connection_name: '本地验收',
+ size_bytes: 128,
+ visibility: 'internal',
+ source_change: 'added',
+ content_state: 'verified',
+ legacy_status: '',
+ has_publication: true,
+ processing_state: 'pending',
+ retrieval_state: 'unpublished',
+ updated_at: '2026-09-05T00:00:00Z',
+ },
+ ],
summary: { total: 613, completed: 530, content_alias: 29, over_limit: 54 },
limit: 50,
offset: 0,
}
const html = renderToStaticMarkup(
- ,
+
)
for (const text of ['知识资产', '613', '530', '29', '54', 'PP', '手工验收', '50.0 MiB', '超过旧同步上限']) {
@@ -265,21 +348,63 @@ describe('SourceConnections scan workbench', () => {
it('renders scan history and selected safe change records', () => {
const now = '2026-09-06T06:00:00Z'
const page: SourceConnectionPage = {
- items: [{ id: 'conn-pp', source_type: 'pp', name: 'PP历史公开知识', enabled: false, schedule_configured: false, health_status: 'healthy', item_count: 613, updated_at: now }],
+ items: [
+ {
+ id: 'conn-pp',
+ source_type: 'pp',
+ name: 'PP历史公开知识',
+ enabled: false,
+ schedule_configured: false,
+ health_status: 'healthy',
+ item_count: 613,
+ updated_at: now,
+ },
+ ],
summary: { total: 1, enabled: 0, disabled: 1, healthy: 1, degraded: 0, unhealthy: 0, unknown: 0 },
}
const scanPage: SourceScanPage = {
- items: [{ id: 'scan-1', state: 'completed', observed_items: 613, unchanged_items: 558, added_items: 0, updated_items: 1, over_limit_items: 54, missing_items: 0, planned_uploads: 1, error_code: '', created_at: now, completed_at: now }],
- total: 1, limit: 20, offset: 0,
+ items: [
+ {
+ id: 'scan-1',
+ state: 'completed',
+ observed_items: 613,
+ unchanged_items: 558,
+ added_items: 0,
+ updated_items: 1,
+ over_limit_items: 54,
+ missing_items: 0,
+ planned_uploads: 1,
+ error_code: '',
+ created_at: now,
+ completed_at: now,
+ },
+ ],
+ total: 1,
+ limit: 20,
+ offset: 0,
}
const detail: SourceScanDetail = {
- ...scanPage.items[0], changes: [{ id: 'chg-1', source_item_id: 'si-safe', change_type: 'updated', observed_size_bytes: 2048, created_at: now }],
+ ...scanPage.items[0],
+ changes: [
+ { id: 'chg-1', source_item_id: 'si-safe', change_type: 'updated', observed_size_bytes: 2048, created_at: now },
+ ],
}
- const html = renderToStaticMarkup( )
+ const html = renderToStaticMarkup(
+
+ )
for (const text of ['扫描历史与变化记录', 'scan-1', '已选择', '变化记录', '内容更新', 'si-safe', '2.0 KiB']) {
expect(html).toContain(text)
}
@@ -289,13 +414,39 @@ describe('SourceConnections scan workbench', () => {
it('states explicitly when a selected scan has no changes', () => {
const now = '2026-09-06T06:00:00Z'
- const scan = { id: 'scan-1', state: 'completed' as const, observed_items: 613, unchanged_items: 559, added_items: 0, updated_items: 0, over_limit_items: 54, missing_items: 0, planned_uploads: 0, error_code: '', created_at: now, completed_at: now }
- const html = renderToStaticMarkup( )
+ const scan = {
+ id: 'scan-1',
+ state: 'completed' as const,
+ observed_items: 613,
+ unchanged_items: 559,
+ added_items: 0,
+ updated_items: 0,
+ over_limit_items: 54,
+ missing_items: 0,
+ planned_uploads: 0,
+ error_code: '',
+ created_at: now,
+ completed_at: now,
+ }
+ const html = renderToStaticMarkup(
+
+ )
expect(html).toContain('本次扫描未发现新增、更新或不可用变化')
})
})
@@ -312,7 +463,7 @@ describe('JobDashboard', () => {
loading={false}
error={null}
onRefresh={vi.fn()}
- />,
+ />
)
expect(html).toContain('同步任务')
@@ -329,33 +480,235 @@ describe('ReleaseManagement', () => {
const now = '2026-09-06T10:00:00Z'
const priorNow = '2026-09-06T09:00:00Z'
const reviewPage: ReviewCandidatePage = {
- items: [{
- publication_id: 'tp-safe', title: 'Synthetic', source_type: 'manual', current_visibility: 'internal',
- knowledge_exists: 'passed', chunks_present: 'passed', retrieval_hit: 'manual_required',
- gate_status: 'ready', gate_code: 'ready', review_state: 'pending', decided_visibility: '', decision_code: '',
- reviewer_ref: '', reviewed_at: null, updated_at: now,
- }], total: 1, limit: 20, offset: 0,
+ items: [
+ {
+ publication_id: 'tp-safe',
+ title: 'Synthetic',
+ source_type: 'manual',
+ current_visibility: 'internal',
+ knowledge_exists: 'passed',
+ chunks_present: 'passed',
+ retrieval_hit: 'manual_required',
+ gate_status: 'ready',
+ gate_code: 'ready',
+ review_state: 'pending',
+ decided_visibility: '',
+ decision_code: '',
+ reviewer_ref: '',
+ reviewed_at: null,
+ updated_at: now,
+ },
+ ],
+ total: 1,
+ limit: 20,
+ offset: 0,
}
const batchPage: ReleaseBatchPage = {
- items: [{ id: 'rb-safe', name: 'Synthetic batch', state: 'draft', policy_version: 'release-gate-v1', created_by: 'engineer-1', sealed_by: '', item_count: 1, created_at: now, sealed_at: null }],
- total: 1, limit: 20, offset: 0,
+ items: [
+ {
+ id: 'rb-safe',
+ name: 'Synthetic batch',
+ state: 'draft',
+ policy_version: 'release-gate-v1',
+ created_by: 'engineer-1',
+ sealed_by: '',
+ item_count: 1,
+ created_at: now,
+ sealed_at: null,
+ },
+ ],
+ total: 1,
+ limit: 20,
+ offset: 0,
+ }
+ const batchDetail: ReleaseBatchDetail = {
+ ...batchPage.items[0],
+ items: [{ publication_id: 'tp-safe', title: 'Synthetic', decided_visibility: 'internal', action: 'delete' }],
}
- const batchDetail: ReleaseBatchDetail = { ...batchPage.items[0], items: [{ publication_id: 'tp-safe', title: 'Synthetic', decided_visibility: 'internal', action: 'delete' }] }
const executionPage: ReleaseExecutionPage = {
- items: [{ id: 'rx-auto', release_batch_id: 'rb-safe', operation: 'rollback', prior_execution_id: 'rx-safe', state: 'running', target_environment: 'production', authorized_by: 'system:auto-rollback', job_id: 'job-auto', item_count: 1, batch_size: 50, current_batch: 1, total_batches: 1, completed_item_count: 0, failed_item_count: 0, error_code: '', created_at: now, started_at: now, completed_at: null }, { id: 'rx-safe', release_batch_id: 'rb-safe', operation: 'publish', prior_execution_id: '', state: 'failed', target_environment: 'production', authorized_by: 'owner', job_id: 'job-safe', item_count: 2, batch_size: 50, current_batch: 1, total_batches: 1, completed_item_count: 1, failed_item_count: 1, error_code: 'release_item_failed', created_at: priorNow, started_at: priorNow, completed_at: priorNow }],
- total: 2, limit: 20, offset: 0,
+ items: [
+ {
+ id: 'rx-auto',
+ release_batch_id: 'rb-safe',
+ operation: 'rollback',
+ prior_execution_id: 'rx-safe',
+ state: 'running',
+ target_environment: 'production',
+ authorized_by: 'system:auto-rollback',
+ job_id: 'job-auto',
+ item_count: 1,
+ batch_size: 50,
+ current_batch: 1,
+ total_batches: 1,
+ completed_item_count: 0,
+ failed_item_count: 0,
+ error_code: '',
+ created_at: now,
+ started_at: now,
+ completed_at: null,
+ },
+ {
+ id: 'rx-safe',
+ release_batch_id: 'rb-safe',
+ operation: 'publish',
+ prior_execution_id: '',
+ state: 'failed',
+ target_environment: 'production',
+ authorized_by: 'owner',
+ job_id: 'job-safe',
+ item_count: 2,
+ batch_size: 50,
+ current_batch: 1,
+ total_batches: 1,
+ completed_item_count: 1,
+ failed_item_count: 1,
+ error_code: 'release_item_failed',
+ created_at: priorNow,
+ started_at: priorNow,
+ completed_at: priorNow,
+ },
+ ],
+ total: 2,
+ limit: 20,
+ offset: 0,
}
- const executionDetail: ReleaseExecutionDetail = { ...executionPage.items[0], items: [{ publication_id: 'tp-safe', title: 'Synthetic', action: 'delete', state: 'verified', previous_production_revision: 'prod-v0', production_revision: 'absent', error_code: '', created_at: now, completed_at: now, target_knowledge_base_id: '0b217e8a-2a65-40a3-ab8f-34ac5c7a6f05' }, { publication_id: 'tp-restored', title: 'Restored', action: 'delete', state: 'rolled_back', previous_production_revision: 'prod-v0', production_revision: 'prod-v0', error_code: '', created_at: now, completed_at: now, target_knowledge_base_id: '0b217e8a-2a65-40a3-ab8f-34ac5c7a6f05' }] }
- const releasePlan: ReleasePlan = { items: [{ publication_id: 'tp-safe', title: 'B.pdf', target_scope: 'external', action: 'add', test_state: 'ready', production_state: 'absent', can_release: true, blocker: '' }, { publication_id: 'tp-delete', title: 'C.pdf', target_scope: 'internal', action: 'delete', test_state: 'not_required', production_state: 'present', can_release: true, blocker: '' }, { publication_id: '', title: 'D.pdf', target_scope: 'internal', action: 'blocked', test_state: 'ready', production_state: 'present', can_release: false, blocker: 'source_unavailable' }], summary: { add: 1, update: 0, delete: 1, blocked: 1, unchanged: 11284 } }
- const html = renderToStaticMarkup( )
- for (const text of ['来源与目录 → 测试知识库 → 生产知识库','发布计划','新增 1','更新 0','删除 1','未变化 11284','B.pdf','C.pdf','D.pdf','将从生产检索中移除','我确认所选删除项会停止生产检索','待发布(0 + 下架 1)','已选 1 项(删除待确认)','为什么按钮不能点:请确认所选删除项会停止生产检索;至少 1 项动作通过确认;请填写批次名称','至少 1 项动作通过确认','来源当前不可用,保留生产知识','正在执行自动回滚','批次 1 / 1','每批最多 50 项','Snapshot #20260906-090000Z','待你审核', '需人工确认', '批准', '驳回', '创建并锁定清单', '锁定清单(不会上线)', 'Synthetic batch', '删除生产知识', '已从生产检索中移除', '已恢复生产知识', '仍可检查调整', 'Release Worker 默认关闭', '锁定清单 ≠ 上线', '生产确认、发布验证与回滚', '生产操作权限:已阻断', '生产请求门禁已开启', '快照恢复验证未通过', '生产发布已阻断']) {
+ const executionDetail: ReleaseExecutionDetail = {
+ ...executionPage.items[0],
+ items: [
+ {
+ publication_id: 'tp-safe',
+ title: 'Synthetic',
+ action: 'delete',
+ state: 'verified',
+ previous_production_revision: 'prod-v0',
+ production_revision: 'absent',
+ error_code: '',
+ created_at: now,
+ completed_at: now,
+ target_knowledge_base_id: '0b217e8a-2a65-40a3-ab8f-34ac5c7a6f05',
+ },
+ {
+ publication_id: 'tp-restored',
+ title: 'Restored',
+ action: 'delete',
+ state: 'rolled_back',
+ previous_production_revision: 'prod-v0',
+ production_revision: 'prod-v0',
+ error_code: '',
+ created_at: now,
+ completed_at: now,
+ target_knowledge_base_id: '0b217e8a-2a65-40a3-ab8f-34ac5c7a6f05',
+ },
+ ],
+ }
+ const releasePlan: ReleasePlan = {
+ items: [
+ {
+ publication_id: 'tp-safe',
+ title: 'B.pdf',
+ target_scope: 'external',
+ action: 'add',
+ test_state: 'ready',
+ production_state: 'absent',
+ can_release: true,
+ blocker: '',
+ },
+ {
+ publication_id: 'tp-delete',
+ title: 'C.pdf',
+ target_scope: 'internal',
+ action: 'delete',
+ test_state: 'not_required',
+ production_state: 'present',
+ can_release: true,
+ blocker: '',
+ },
+ {
+ publication_id: '',
+ title: 'D.pdf',
+ target_scope: 'internal',
+ action: 'blocked',
+ test_state: 'ready',
+ production_state: 'present',
+ can_release: false,
+ blocker: 'source_unavailable',
+ },
+ ],
+ summary: { add: 1, update: 0, delete: 1, blocked: 1, unchanged: 11284 },
+ }
+ const html = renderToStaticMarkup(
+
+ )
+ for (const text of [
+ '来源与目录 → 测试知识库 → 生产知识库',
+ '发布计划',
+ '新增 1',
+ '更新 0',
+ '删除 1',
+ '未变化 11284',
+ 'B.pdf',
+ 'C.pdf',
+ 'D.pdf',
+ '将从生产检索中移除',
+ '我确认所选删除项会停止生产检索',
+ '待发布(0 + 下架 1)',
+ '已选 1 项(删除待确认)',
+ '为什么按钮不能点:请确认所选删除项会停止生产检索;至少 1 项动作通过确认;请填写批次名称',
+ '至少 1 项动作通过确认',
+ '来源当前不可用,保留生产知识',
+ '正在执行自动回滚',
+ '批次 1 / 1',
+ '每批最多 50 项',
+ 'Snapshot #20260906-090000Z',
+ '待你审核',
+ '需人工确认',
+ '批准',
+ '驳回',
+ '创建并锁定清单',
+ '锁定清单(不会上线)',
+ 'Synthetic batch',
+ '删除生产知识',
+ '已从生产检索中移除',
+ '已恢复生产知识',
+ '仍可检查调整',
+ 'Release Worker 默认关闭',
+ '锁定清单 ≠ 上线',
+ '生产确认、发布验证与回滚',
+ '生产操作权限:已阻断',
+ '生产请求门禁已开启',
+ '快照恢复验证未通过',
+ '生产发布已阻断',
+ ]) {
expect(html).toContain(text)
}
// 需求(2026-10-10):发布界面要简单——不再显示步骤脚手架/流程编号/引导卡。
@@ -394,7 +747,16 @@ describe('ReleaseManagement', () => {
expect(html).toContain('至少 1 项动作通过确认 ')
expect(reviewGateLabel({ review_state: 'approved', gate_status: 'ready' })).toBe('测试已通过')
expect(reviewGateLabel({ review_state: 'pending', gate_status: 'ready' })).toBe('可以审核')
- for (const technicalValue of ['tp-safe', 'rb-safe', 'rx-safe', 'job-safe', 'release-gate-v1', 'prod-v0', 'prod-v1', '>verified<']) {
+ for (const technicalValue of [
+ 'tp-safe',
+ 'rb-safe',
+ 'rx-safe',
+ 'job-safe',
+ 'release-gate-v1',
+ 'prod-v0',
+ 'prod-v1',
+ '>verified<',
+ ]) {
expect(html).not.toContain(technicalValue)
}
expect(html).not.toContain('工程师标识')
@@ -404,17 +766,45 @@ describe('ReleaseManagement', () => {
describe('release plan pagination (R3)', () => {
const baseProps = {
reviewPage: { items: [], total: 0, limit: 20, offset: 0 } as any,
- reviewLoading: false, reviewError: null, reviewingId: null,
+ reviewLoading: false,
+ reviewError: null,
+ reviewingId: null,
batchPage: { items: [], total: 0, limit: 20, offset: 0 } as any,
- batchDetail: null, batchLoading: false, batchError: null, mutatingBatchId: null,
- onReview: vi.fn(), onReviewRefresh: vi.fn(), onCreateBatch: vi.fn(), onSealBatch: vi.fn(), onSelectBatch: vi.fn(), onBatchRefresh: vi.fn(),
- releaseCapabilities: { production_release_requests_enabled: false, production_backup_restore_verified: false, test_auto_review_policy: '' },
+ batchDetail: null,
+ batchLoading: false,
+ batchError: null,
+ mutatingBatchId: null,
+ onReview: vi.fn(),
+ onReviewRefresh: vi.fn(),
+ onCreateBatch: vi.fn(),
+ onSealBatch: vi.fn(),
+ onSelectBatch: vi.fn(),
+ onBatchRefresh: vi.fn(),
+ releaseCapabilities: {
+ production_release_requests_enabled: false,
+ production_backup_restore_verified: false,
+ test_auto_review_policy: '',
+ },
executionPage: { items: [], total: 0, limit: 20, offset: 0 } as any,
- executionDetail: null, executionLoading: false, executionError: null, onSelectExecution: vi.fn(), onExecutionRefresh: vi.fn(),
- selectedDeleteIDs: [] as string[], onToggleDelete: vi.fn(),
+ executionDetail: null,
+ executionLoading: false,
+ executionError: null,
+ onSelectExecution: vi.fn(),
+ onExecutionRefresh: vi.fn(),
+ selectedDeleteIDs: [] as string[],
+ onToggleDelete: vi.fn(),
}
const planWith = (count: number): ReleasePlan => ({
- items: Array.from({ length: count }, (_, i) => ({ publication_id: `tp-${i}`, title: `文档${i}.pdf`, target_scope: 'internal' as const, action: 'add' as const, test_state: 'ready', production_state: 'absent', can_release: true, blocker: '' })),
+ items: Array.from({ length: count }, (_, i) => ({
+ publication_id: `tp-${i}`,
+ title: `文档${i}.pdf`,
+ target_scope: 'internal' as const,
+ action: 'add' as const,
+ test_state: 'ready',
+ production_state: 'absent',
+ can_release: true,
+ blocker: '',
+ })),
summary: { add: count, update: 0, delete: 0, blocked: 0, unchanged: 0 },
})
it('renders at most one page (25 rows) instead of the full 618-row plan', () => {
@@ -448,51 +838,216 @@ describe('release plan pagination (R3)', () => {
describe('R4 rollback levels in the execution timeline', () => {
const baseProps = {
reviewPage: { items: [], total: 0, limit: 20, offset: 0 } as any,
- reviewLoading: false, reviewError: null, reviewingId: null,
+ reviewLoading: false,
+ reviewError: null,
+ reviewingId: null,
batchPage: { items: [], total: 0, limit: 20, offset: 0 } as any,
- batchDetail: null, batchLoading: false, batchError: null, mutatingBatchId: null,
- onReview: vi.fn(), onReviewRefresh: vi.fn(), onCreateBatch: vi.fn(), onSealBatch: vi.fn(), onSelectBatch: vi.fn(), onBatchRefresh: vi.fn(),
- releaseCapabilities: { production_release_requests_enabled: true, production_backup_restore_verified: false, test_auto_review_policy: '' },
+ batchDetail: null,
+ batchLoading: false,
+ batchError: null,
+ mutatingBatchId: null,
+ onReview: vi.fn(),
+ onReviewRefresh: vi.fn(),
+ onCreateBatch: vi.fn(),
+ onSealBatch: vi.fn(),
+ onSelectBatch: vi.fn(),
+ onBatchRefresh: vi.fn(),
+ releaseCapabilities: {
+ production_release_requests_enabled: true,
+ production_backup_restore_verified: false,
+ test_auto_review_policy: '',
+ },
releasePlan: { items: [], summary: { add: 0, update: 0, delete: 0, blocked: 0, unchanged: 0 } } as ReleasePlan,
- executionDetail: null, executionLoading: false, executionError: null, onSelectExecution: vi.fn(), onExecutionRefresh: vi.fn(),
- selectedDeleteIDs: [] as string[], onToggleDelete: vi.fn(),
+ executionDetail: null,
+ executionLoading: false,
+ executionError: null,
+ onSelectExecution: vi.fn(),
+ onExecutionRefresh: vi.fn(),
+ selectedDeleteIDs: [] as string[],
+ onToggleDelete: vi.fn(),
}
- const exec = (over: Record) => ({
- id: 'rx-1', release_batch_id: 'rb-1', operation: 'rollback', prior_execution_id: 'rx-0',
- state: 'rolling_back', target_environment: 'production', authorized_by: 'system:auto-rollback', job_id: 'job-1',
- item_count: 2, batch_size: 50, current_batch: 1, total_batches: 1, completed_item_count: 1, failed_item_count: 0,
- error_code: '', failure_level: '', restore_trigger: 'auto_rollback_after_failure',
- created_at: '2026-09-12T09:00:00Z', started_at: '2026-09-12T09:01:00Z', completed_at: null, ...over,
- }) as any
+ const exec = (over: Record) =>
+ ({
+ id: 'rx-1',
+ release_batch_id: 'rb-1',
+ operation: 'rollback',
+ prior_execution_id: 'rx-0',
+ state: 'rolling_back',
+ target_environment: 'production',
+ authorized_by: 'system:auto-rollback',
+ job_id: 'job-1',
+ item_count: 2,
+ batch_size: 50,
+ current_batch: 1,
+ total_batches: 1,
+ completed_item_count: 1,
+ failed_item_count: 0,
+ error_code: '',
+ failure_level: '',
+ restore_trigger: 'auto_rollback_after_failure',
+ created_at: '2026-09-12T09:00:00Z',
+ started_at: '2026-09-12T09:01:00Z',
+ completed_at: null,
+ ...over,
+ }) as any
it('labels the compensating stage and the escalation after a failed rollback', () => {
- const html = renderToStaticMarkup( )
+ const html = renderToStaticMarkup(
+
+ )
expect(html).toContain('正在回滚')
expect(html).toContain('正在执行自动回滚')
- const failed = renderToStaticMarkup( )
+ const failed = renderToStaticMarkup(
+
+ )
expect(failed).toContain('回滚失败')
expect(failed).toContain('整体恢复')
- const done = renderToStaticMarkup( )
+ const done = renderToStaticMarkup(
+
+ )
expect(done).toContain('已回滚')
expect(done).toContain('已恢复')
- const graded = renderToStaticMarkup( )
+ const graded = renderToStaticMarkup(
+
+ )
expect(graded).toContain('本批后续已停止')
})
})
// 生产动作(授权发布 / 重排 / 回滚):页面按钮只对管理员出现,且必须逐字输入固定确认语句。
describe('ReleaseManagement production actions', () => {
- const authValue = (role: 'admin' | 'engineer') => ({ identity: { id: `${role}-1`, username: role, display_name: role, role, must_change_password: false }, ready: true, login: async () => null, logout: async () => {}, changePassword: async () => null, signedOut: 0, notifySignedOut: () => {} })
+ const authValue = (role: 'admin' | 'engineer') => ({
+ identity: { id: `${role}-1`, username: role, display_name: role, role, must_change_password: false },
+ ready: true,
+ login: async () => null,
+ logout: async () => {},
+ changePassword: async () => null,
+ signedOut: 0,
+ notifySignedOut: () => {},
+ })
const now = '2026-10-08T08:00:00Z'
- const batchPage: ReleaseBatchPage = { items: [{ id: 'rb-sealed', name: '单文档首发', state: 'sealed', policy_version: 'release-gate-v1', created_by: 'admin', sealed_by: 'admin', item_count: 1, created_at: now, sealed_at: now }], total: 1, limit: 20, offset: 0 }
- const executionPage: ReleaseExecutionPage = { items: [{ id: 'rx-done', release_batch_id: 'rb-sealed', operation: 'publish', prior_execution_id: '', state: 'completed', target_environment: 'production', authorized_by: 'admin', job_id: 'job-1', item_count: 1, batch_size: 50, current_batch: 1, total_batches: 1, completed_item_count: 1, failed_item_count: 0, error_code: '', created_at: now, started_at: now, completed_at: now }, { id: 'rx-bad', release_batch_id: 'rb-sealed', operation: 'publish', prior_execution_id: '', state: 'failed', target_environment: 'production', authorized_by: 'admin', job_id: 'job-2', item_count: 1, batch_size: 50, current_batch: 1, total_batches: 1, completed_item_count: 0, failed_item_count: 1, error_code: 'release_item_failed', created_at: now, started_at: now, completed_at: now }], total: 2, limit: 20, offset: 0 }
- const base = (extra: Record = {}) =>
+ const batchPage: ReleaseBatchPage = {
+ items: [
+ {
+ id: 'rb-sealed',
+ name: '单文档首发',
+ state: 'sealed',
+ policy_version: 'release-gate-v1',
+ created_by: 'admin',
+ sealed_by: 'admin',
+ item_count: 1,
+ created_at: now,
+ sealed_at: now,
+ },
+ ],
+ total: 1,
+ limit: 20,
+ offset: 0,
+ }
+ const executionPage: ReleaseExecutionPage = {
+ items: [
+ {
+ id: 'rx-done',
+ release_batch_id: 'rb-sealed',
+ operation: 'publish',
+ prior_execution_id: '',
+ state: 'completed',
+ target_environment: 'production',
+ authorized_by: 'admin',
+ job_id: 'job-1',
+ item_count: 1,
+ batch_size: 50,
+ current_batch: 1,
+ total_batches: 1,
+ completed_item_count: 1,
+ failed_item_count: 0,
+ error_code: '',
+ created_at: now,
+ started_at: now,
+ completed_at: now,
+ },
+ {
+ id: 'rx-bad',
+ release_batch_id: 'rb-sealed',
+ operation: 'publish',
+ prior_execution_id: '',
+ state: 'failed',
+ target_environment: 'production',
+ authorized_by: 'admin',
+ job_id: 'job-2',
+ item_count: 1,
+ batch_size: 50,
+ current_batch: 1,
+ total_batches: 1,
+ completed_item_count: 0,
+ failed_item_count: 1,
+ error_code: 'release_item_failed',
+ created_at: now,
+ started_at: now,
+ completed_at: now,
+ },
+ ],
+ total: 2,
+ limit: 20,
+ offset: 0,
+ }
+ const base = (extra: Record = {}) => (
+
+ )
it('offers 授权发布 / 重排 / 回滚 to admins only', () => {
const admin = renderToStaticMarkup({base()} )
@@ -500,7 +1055,9 @@ describe('ReleaseManagement production actions', () => {
expect(admin).toContain('重排')
expect(admin).toContain('回滚')
expect(admin).toContain('生产操作可执行(待确认)')
- const engineer = renderToStaticMarkup({base()} )
+ const engineer = renderToStaticMarkup(
+ {base()}
+ )
expect(engineer).not.toContain('授权发布')
expect(engineer).not.toContain('回滚 ')
expect(engineer).not.toContain('重排 ')
@@ -508,15 +1065,49 @@ describe('ReleaseManagement production actions', () => {
it('requires the exact confirmation phrase before the action can be submitted', () => {
const pending = { kind: 'authorize' as const, id: 'rb-sealed', label: '单文档首发' }
- const wrong = renderToStaticMarkup({base({ pendingReleaseAction: pending, releaseActionPhrase: 'publish', onStartReleaseAction: vi.fn(), onReleaseActionPhrase: vi.fn(), onSubmitReleaseAction: vi.fn(), onCancelReleaseAction: vi.fn() })} )
+ const wrong = renderToStaticMarkup(
+
+ {base({
+ pendingReleaseAction: pending,
+ releaseActionPhrase: 'publish',
+ onStartReleaseAction: vi.fn(),
+ onReleaseActionPhrase: vi.fn(),
+ onSubmitReleaseAction: vi.fn(),
+ onCancelReleaseAction: vi.fn(),
+ })}
+
+ )
expect(wrong).toContain('publish-to-production')
expect(wrong).toMatch(/]*>确认执行<\/button>/)
- const right = renderToStaticMarkup({base({ pendingReleaseAction: pending, releaseActionPhrase: 'publish-to-production', onStartReleaseAction: vi.fn(), onReleaseActionPhrase: vi.fn(), onSubmitReleaseAction: vi.fn(), onCancelReleaseAction: vi.fn() })} )
+ const right = renderToStaticMarkup(
+
+ {base({
+ pendingReleaseAction: pending,
+ releaseActionPhrase: 'publish-to-production',
+ onStartReleaseAction: vi.fn(),
+ onReleaseActionPhrase: vi.fn(),
+ onSubmitReleaseAction: vi.fn(),
+ onCancelReleaseAction: vi.fn(),
+ })}
+
+ )
expect(right).toMatch(/确认执行<\/button>/)
})
it('surfaces the server reason when the action is refused', () => {
- const html = renderToStaticMarkup({base({ pendingReleaseAction: null, releaseActionError: 'production release authorization required', releaseActionMessage: null, onStartReleaseAction: vi.fn(), onReleaseActionPhrase: vi.fn(), onSubmitReleaseAction: vi.fn(), onCancelReleaseAction: vi.fn() })} )
+ const html = renderToStaticMarkup(
+
+ {base({
+ pendingReleaseAction: null,
+ releaseActionError: 'production release authorization required',
+ releaseActionMessage: null,
+ onStartReleaseAction: vi.fn(),
+ onReleaseActionPhrase: vi.fn(),
+ onSubmitReleaseAction: vi.fn(),
+ onCancelReleaseAction: vi.fn(),
+ })}
+
+ )
expect(html).toContain('production release authorization required')
})
})
diff --git a/web/src/App.tsx b/web/src/App.tsx
index bfaeeaf..ae91167 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -413,47 +413,47 @@ export function ModulePage({
文档级发布(旧流程,折叠保留):单篇文档发布成功不等于整库发布成功
diff --git a/web/src/BackupScheduleSection.test.tsx b/web/src/BackupScheduleSection.test.tsx
index 9c92c71..a694399 100644
--- a/web/src/BackupScheduleSection.test.tsx
+++ b/web/src/BackupScheduleSection.test.tsx
@@ -1,45 +1,101 @@
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it, vi } from 'vitest'
-import { BackupScheduleView, backupDaysValid, backupMonthsValid, backupTimeValid, formatBackupBytes, runMoment } from './BackupScheduleSection'
+import {
+ BackupScheduleView,
+ backupDaysValid,
+ backupMonthsValid,
+ backupTimeValid,
+ formatBackupBytes,
+ runMoment,
+} from './BackupScheduleSection'
import type { BackupSchedule } from './backupSchedule'
const base: BackupSchedule = {
- enabled: true, schedule_time: '03:30', retention_days: 7, retention_months: 3,
- updated_by: 'yaozhanghao', updated_at: '2026-10-09T12:00:00Z',
- last_applied_at: '2026-10-09T12:00:00Z', last_applied_summary: '每天 03:30 · 保留 7 天 · 每月归档 3 份',
- host_configured: true, host_target: 'weknora-test', host_reachable: true, host_error: '',
- host_schedule_enabled: true, host_schedule_time: '03:30', host_keep_days: 7, host_keep_months: 3,
- host_cron_lines: ['30 03 * * * root /bin/bash /opt/weknora-test/logical-backup.sh >> /srv/kb-backups/logs/logical-backup.log 2>&1'],
- host_cron_service: 'active', host_script_present: true,
- last_run_stamp: '20261009T052909Z', last_run_created_at: '2026-10-09T05:29:09Z', last_run_duration_seconds: 33,
- last_run_total_bytes: 778_502_144, last_run_databases: ['weknora_test', 'dobotai_prod'],
- last_run_failed_databases: [], last_run_error: '', in_sync: true,
- applied: true, apply_error: '', running: false, running_started_at: '', run_last_error: '',
+ enabled: true,
+ schedule_time: '03:30',
+ retention_days: 7,
+ retention_months: 3,
+ updated_by: 'yaozhanghao',
+ updated_at: '2026-10-09T12:00:00Z',
+ last_applied_at: '2026-10-09T12:00:00Z',
+ last_applied_summary: '每天 03:30 · 保留 7 天 · 每月归档 3 份',
+ host_configured: true,
+ host_target: 'weknora-test',
+ host_reachable: true,
+ host_error: '',
+ host_schedule_enabled: true,
+ host_schedule_time: '03:30',
+ host_keep_days: 7,
+ host_keep_months: 3,
+ host_cron_lines: [
+ '30 03 * * * root /bin/bash /opt/weknora-test/logical-backup.sh >> /srv/kb-backups/logs/logical-backup.log 2>&1',
+ ],
+ host_cron_service: 'active',
+ host_script_present: true,
+ last_run_stamp: '20261009T052909Z',
+ last_run_created_at: '2026-10-09T05:29:09Z',
+ last_run_duration_seconds: 33,
+ last_run_total_bytes: 778_502_144,
+ last_run_databases: ['weknora_test', 'dobotai_prod'],
+ last_run_failed_databases: [],
+ last_run_error: '',
+ in_sync: true,
+ applied: true,
+ apply_error: '',
+ running: false,
+ running_started_at: '',
+ run_last_error: '',
}
const view = (overrides: Partial = {}, extra: Record = {}) => {
const schedule = { ...base, ...overrides }
- return renderToStaticMarkup( )
+ return renderToStaticMarkup(
+
+ )
}
describe('BackupScheduleView', () => {
it('管理员看到定时时间/储存天数与下发按钮,并显示主机上生效的计划与最近一次结果', () => {
const html = view()
- for (const text of ['存储与备份', '目标库所在主机', '定时时间', '储存天数', '每月归档保留',
- '保存并下发到主机', '重新下发当前设置', '立即备份一次', 'weknora-test · 已连接',
- '每天 03:30 · 保留 7 天 · 每月 3 份', '已一致', '20261009T052909Z', '33 秒', '742.4 MiB',
- 'weknora_test、dobotai_prod']) expect(html).toContain(text)
+ for (const text of [
+ '存储与备份',
+ '目标库所在主机',
+ '定时时间',
+ '储存天数',
+ '每月归档保留',
+ '保存并下发到主机',
+ '重新下发当前设置',
+ '立即备份一次',
+ 'weknora-test · 已连接',
+ '每天 03:30 · 保留 7 天 · 每月 3 份',
+ '已一致',
+ '20261009T052909Z',
+ '33 秒',
+ '742.4 MiB',
+ 'weknora_test、dobotai_prod',
+ ])
+ expect(html).toContain(text)
expect(html).toContain('type="time"')
expect(html).toContain('value="03:30"')
})
@@ -52,7 +108,12 @@ describe('BackupScheduleView', () => {
})
it('未接线:显式说明缺少主机凭据,下发与立即备份不可点', () => {
- const html = view({ host_configured: false, host_reachable: false, host_error: '目标主机凭据未挂载(BACKUP_HOST_SSH_CONFIG / BACKUP_HOST_TARGET)', in_sync: false })
+ const html = view({
+ host_configured: false,
+ host_reachable: false,
+ host_error: '目标主机凭据未挂载(BACKUP_HOST_SSH_CONFIG / BACKUP_HOST_TARGET)',
+ in_sync: false,
+ })
expect(html).toContain('未接线(缺少主机凭据)')
expect(html).toContain('待下发')
expect(html).toContain('目标主机凭据未挂载')
@@ -100,4 +161,4 @@ describe('BackupScheduleView', () => {
expect(formatBackupBytes(1024 * 1024)).toBe('1.0 MiB')
expect(formatBackupBytes(2 * 1024 * 1024 * 1024)).toBe('2.00 GiB')
})
-})
\ No newline at end of file
+})
diff --git a/web/src/BackupScheduleSection.tsx b/web/src/BackupScheduleSection.tsx
index d51c410..029efef 100644
--- a/web/src/BackupScheduleSection.tsx
+++ b/web/src/BackupScheduleSection.tsx
@@ -1,7 +1,13 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useOptionalAuth } from './auth'
-import { applyBackupSchedule, fetchBackupSchedule, fetchBackupScheduleHost, runBackupNow, saveBackupSchedule } from './backupSchedule'
+import {
+ applyBackupSchedule,
+ fetchBackupSchedule,
+ fetchBackupScheduleHost,
+ runBackupNow,
+ saveBackupSchedule,
+} from './backupSchedule'
import type { BackupSchedule } from './backupSchedule'
export const formatBackupBytes = (bytes: number): string => {
@@ -52,60 +58,183 @@ export interface BackupScheduleViewProps {
// 纯视图:所有状态由 props 驱动,便于在测试里直接渲染各种状态(管理员/只读、已一致/待下发、在途)。
export function BackupScheduleView(props: BackupScheduleViewProps) {
const {
- schedule, isAdmin, loading, probing, busy, error, message,
- enabledDraft, timeDraft, daysDraft, monthsDraft,
- onEnabledChange, onTimeChange, onDaysChange, onMonthsChange, onSave, onApply, onRunNow, onReload,
+ schedule,
+ isAdmin,
+ loading,
+ probing,
+ busy,
+ error,
+ message,
+ enabledDraft,
+ timeDraft,
+ daysDraft,
+ monthsDraft,
+ onEnabledChange,
+ onTimeChange,
+ onDaysChange,
+ onMonthsChange,
+ onSave,
+ onApply,
+ onRunNow,
+ onReload,
} = props
const days = Number(daysDraft)
const months = Number(monthsDraft)
const valid = backupTimeValid(timeDraft) && backupDaysValid(days) && backupMonthsValid(months)
- const dirty = enabledDraft !== schedule.enabled || timeDraft !== schedule.schedule_time ||
- days !== schedule.retention_days || months !== schedule.retention_months
+ const dirty =
+ enabledDraft !== schedule.enabled ||
+ timeDraft !== schedule.schedule_time ||
+ days !== schedule.retention_days ||
+ months !== schedule.retention_months
const hostLine = !schedule.host_configured
? '未接线(缺少主机凭据)'
: schedule.host_reachable
? `${schedule.host_target} · 已连接`
: `${schedule.host_target} · 连接失败`
- return
-
-
存储与备份
-
整库逻辑备份(pg_dump 目录格式)在目标库所在主机 上本地执行:这里设置定时时间与储存天数,保存即下发到主机 cron,并可读回主机上生效的计划与最近一次结果。
-
{loading || probing ? '读取中…' : '重新读取'}
- {error ? {error}
: null}
-
-
- 目标主机 {hostLine}
- 主机 cron {schedule.host_cron_service ? `服务 ${schedule.host_cron_service}` : '—'}{schedule.host_script_present ? '' : ' · 备份脚本缺失'}
- 最近一次备份 {schedule.last_run_stamp || '—'}{runMoment(schedule) ? ` · ${runMoment(schedule)}` : ''}
- 备份耗时 / 体积 {schedule.last_run_stamp ? `${schedule.last_run_duration_seconds} 秒 · ${formatBackupBytes(schedule.last_run_total_bytes)}` : '—'}
-
-
- 覆盖的库 {schedule.last_run_databases.length ? schedule.last_run_databases.join('、') : '—'}
- 失败的库 {schedule.last_run_failed_databases.length ? schedule.last_run_failed_databases.join('、') : '无'}
- 主机上生效的计划 {schedule.host_schedule_enabled ? `每天 ${schedule.host_schedule_time} · 保留 ${schedule.host_keep_days} 天 · 每月 ${schedule.host_keep_months} 份` : schedule.host_configured ? '未启用定时' : '—'}
- 与设置是否一致 {schedule.in_sync ? '已一致' : '待下发'}
-
-
- {schedule.host_error ? 主机探测:{schedule.host_error}
: null}
- {schedule.last_run_error ? 最近一次结果读取:{schedule.last_run_error}
: null}
- {schedule.host_cron_lines.length ? 主机 cron 条目:{schedule.host_cron_lines.join(' ; ')}
: null}
- {schedule.last_applied_at ? 最后下发:{formatBackupMoment(schedule.last_applied_at)}{schedule.last_applied_summary ? ` · ${schedule.last_applied_summary}` : ''}
: null}
- 留档分层:PG 行级留档(发布前,含 ES 文档)+ ES 快照仓 + OSS 版本控制 + 本节的整库逻辑备份 + RDS 托管备份(控制台配置)。详见 docs/PRODUCTION_BACKUP_RECOVERY.md。
- {isAdmin ?
-
定时时间 onTimeChange(event.target.value)} aria-label="定时时间" />
-
储存天数 onDaysChange(event.target.value)} aria-label="储存天数" />
-
每月归档保留 onMonthsChange(event.target.value)} aria-label="每月归档保留" />
-
onEnabledChange(event.target.checked)} /> 启用定时整库备份
- {!valid ?
定时时间需为 HH:MM(00:00~23:59),储存天数 1~365,每月归档 0~24。
: null}
-
{busy === 'save' ? '正在保存并下发…' : '保存并下发到主机'}
-
{busy === 'apply' ? '正在下发…' : '重新下发当前设置'}
-
{schedule.running ? '备份进行中…' : '立即备份一次'}
- {message ?
{message}
: null}
- {schedule.running ?
正在备份({formatBackupMoment(schedule.running_started_at)} 开始),完成后自动刷新。
: null}
- {schedule.run_last_error && !schedule.running ?
上一次立即备份失败:{schedule.run_last_error}
: null}
-
: 定时备份设置仅管理员可修改;当前设置:{schedule.enabled ? `每天 ${schedule.schedule_time} · 保留 ${schedule.retention_days} 天` : '未启用'}。
}
-
+ return (
+
+
+
+
存储与备份
+
+ 整库逻辑备份(pg_dump 目录格式)在目标库所在主机
+ 上本地执行:这里设置定时时间与储存天数,保存即下发到主机 cron,并可读回主机上生效的计划与最近一次结果。
+
+
+
+ {loading || probing ? '读取中…' : '重新读取'}
+
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+
+ 目标主机
+ {hostLine}
+ 主机 cron
+
+ {schedule.host_cron_service ? `服务 ${schedule.host_cron_service}` : '—'}
+ {schedule.host_script_present ? '' : ' · 备份脚本缺失'}
+
+ 最近一次备份
+
+ {schedule.last_run_stamp || '—'}
+ {runMoment(schedule) ? ` · ${runMoment(schedule)}` : ''}
+
+ 备份耗时 / 体积
+
+ {schedule.last_run_stamp
+ ? `${schedule.last_run_duration_seconds} 秒 · ${formatBackupBytes(schedule.last_run_total_bytes)}`
+ : '—'}
+
+
+
+ 覆盖的库
+ {schedule.last_run_databases.length ? schedule.last_run_databases.join('、') : '—'}
+ 失败的库
+ {schedule.last_run_failed_databases.length ? schedule.last_run_failed_databases.join('、') : '无'}
+ 主机上生效的计划
+
+ {schedule.host_schedule_enabled
+ ? `每天 ${schedule.host_schedule_time} · 保留 ${schedule.host_keep_days} 天 · 每月 ${schedule.host_keep_months} 份`
+ : schedule.host_configured
+ ? '未启用定时'
+ : '—'}
+
+ 与设置是否一致
+ {schedule.in_sync ? '已一致' : '待下发'}
+
+
+ {schedule.host_error ? 主机探测:{schedule.host_error}
: null}
+ {schedule.last_run_error ? 最近一次结果读取:{schedule.last_run_error}
: null}
+ {schedule.host_cron_lines.length ? (
+
+ 主机 cron 条目:{schedule.host_cron_lines.join(' ; ')}
+
+ ) : null}
+ {schedule.last_applied_at ? (
+
+ 最后下发:{formatBackupMoment(schedule.last_applied_at)}
+ {schedule.last_applied_summary ? ` · ${schedule.last_applied_summary}` : ''}
+
+ ) : null}
+
+ 留档分层:PG 行级留档(发布前,含 ES 文档)+ ES 快照仓 + OSS 版本控制 + 本节的整库逻辑备份 + RDS
+ 托管备份(控制台配置)。详见 docs/PRODUCTION_BACKUP_RECOVERY.md。
+
+ {isAdmin ? (
+
+
+ 定时时间{' '}
+ onTimeChange(event.target.value)}
+ aria-label="定时时间"
+ />
+
+
+ 储存天数{' '}
+ onDaysChange(event.target.value)}
+ aria-label="储存天数"
+ />
+
+
+ 每月归档保留{' '}
+ onMonthsChange(event.target.value)}
+ aria-label="每月归档保留"
+ />
+
+
+ onEnabledChange(event.target.checked)} />{' '}
+ 启用定时整库备份
+
+ {!valid ? (
+
定时时间需为 HH:MM(00:00~23:59),储存天数 1~365,每月归档 0~24。
+ ) : null}
+
+ {busy === 'save' ? '正在保存并下发…' : '保存并下发到主机'}
+
+
+ {busy === 'apply' ? '正在下发…' : '重新下发当前设置'}
+
+
+ {schedule.running ? '备份进行中…' : '立即备份一次'}
+
+ {message ? (
+
+ {message}
+
+ ) : null}
+ {schedule.running ? (
+
正在备份({formatBackupMoment(schedule.running_started_at)} 开始),完成后自动刷新。
+ ) : null}
+ {schedule.run_last_error && !schedule.running ? (
+
上一次立即备份失败:{schedule.run_last_error}
+ ) : null}
+
+ ) : (
+
+ 定时备份设置仅管理员可修改;当前设置:
+ {schedule.enabled ? `每天 ${schedule.schedule_time} · 保留 ${schedule.retention_days} 天` : '未启用'}。
+
+ )}
+
+ )
}
// 容器:读取 + 下发 + 在途轮询。真正执行备份的是目标库所在主机(同 VPC 直连 RDS)。
@@ -133,7 +262,8 @@ export function BackupScheduleSection() {
}, [])
const load = useCallback(async () => {
- setLoading(true); setError(null)
+ setLoading(true)
+ setError(null)
try {
adopt(await fetchBackupSchedule())
setProbing(true)
@@ -141,83 +271,145 @@ export function BackupScheduleSection() {
adopt(await fetchBackupScheduleHost())
} catch (cause) {
setError(cause instanceof Error ? cause.message : '读取目标主机上的备份状态失败')
- } finally { setProbing(false) }
+ } finally {
+ setProbing(false)
+ }
} catch (cause) {
setError(cause instanceof Error ? cause.message : '读取备份计划失败')
- } finally { setLoading(false) }
+ } finally {
+ setLoading(false)
+ }
}, [adopt])
- useEffect(() => { void load() }, [load])
+ useEffect(() => {
+ void load()
+ }, [load])
// 立即备份约 30~60 秒:在途时轮询主机状态,直到 running 转假并出现新的最近一次结果。
useEffect(() => {
if (!schedule?.running) {
- if (polling.current !== null) { window.clearInterval(polling.current); polling.current = null }
+ if (polling.current !== null) {
+ window.clearInterval(polling.current)
+ polling.current = null
+ }
return
}
if (polling.current !== null) return
polling.current = window.setInterval(() => {
- void fetchBackupScheduleHost().then(adopt).catch(() => undefined)
+ void fetchBackupScheduleHost()
+ .then(adopt)
+ .catch(() => undefined)
}, 5000)
return () => {
- if (polling.current !== null) { window.clearInterval(polling.current); polling.current = null }
+ if (polling.current !== null) {
+ window.clearInterval(polling.current)
+ polling.current = null
+ }
}
}, [schedule?.running, adopt])
const save = async () => {
- setBusy('save'); setError(null); setMessage('')
+ setBusy('save')
+ setError(null)
+ setMessage('')
try {
const next = await saveBackupSchedule({
- enabled: enabledDraft, schedule_time: timeDraft,
- retention_days: Number(daysDraft), retention_months: Number(monthsDraft), apply: true,
+ enabled: enabledDraft,
+ schedule_time: timeDraft,
+ retention_days: Number(daysDraft),
+ retention_months: Number(monthsDraft),
+ apply: true,
})
adopt(next)
if (!next.host_configured) {
setMessage('计划已保存。目标主机凭据未挂载,因此还不能下发到主机上的 cron。')
} else if (next.applied) {
- setMessage(`计划已保存并下发:每天 ${next.schedule_time} 执行,保留 ${next.retention_days} 天(每月另留 ${next.retention_months} 份)。`)
+ setMessage(
+ `计划已保存并下发:每天 ${next.schedule_time} 执行,保留 ${next.retention_days} 天(每月另留 ${next.retention_months} 份)。`
+ )
} else {
setMessage(`计划已保存,但下发失败:${next.apply_error}`)
}
- } catch (cause) { setError(cause instanceof Error ? cause.message : '保存备份计划失败') }
- finally { setBusy(null) }
+ } catch (cause) {
+ setError(cause instanceof Error ? cause.message : '保存备份计划失败')
+ } finally {
+ setBusy(null)
+ }
}
const apply = async () => {
- setBusy('apply'); setError(null); setMessage('')
+ setBusy('apply')
+ setError(null)
+ setMessage('')
try {
const next = await applyBackupSchedule()
adopt(next)
setMessage(next.applied ? '已按当前设置重新下发到目标主机。' : `下发失败:${next.apply_error}`)
- } catch (cause) { setError(cause instanceof Error ? cause.message : '下发备份计划失败') }
- finally { setBusy(null) }
+ } catch (cause) {
+ setError(cause instanceof Error ? cause.message : '下发备份计划失败')
+ } finally {
+ setBusy(null)
+ }
}
const runNow = async () => {
- setBusy('run'); setError(null); setMessage('')
+ setBusy('run')
+ setError(null)
+ setMessage('')
try {
const start = await runBackupNow()
- setMessage(start.started ? '已在目标主机上开始备份(整库 pg_dump,约 30~60 秒),下方会自动刷新结果。' : '已有一个备份任务在执行中。')
+ setMessage(
+ start.started
+ ? '已在目标主机上开始备份(整库 pg_dump,约 30~60 秒),下方会自动刷新结果。'
+ : '已有一个备份任务在执行中。'
+ )
adopt(await fetchBackupScheduleHost())
- } catch (cause) { setError(cause instanceof Error ? cause.message : '触发备份失败') }
- finally { setBusy(null) }
+ } catch (cause) {
+ setError(cause instanceof Error ? cause.message : '触发备份失败')
+ } finally {
+ setBusy(null)
+ }
}
if (!schedule) {
- return
-
- {error ? {error}
: null}
- {loading ? '正在读取备份计划…' : '备份计划暂不可用。'}
-
+ return (
+
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+ {loading ? '正在读取备份计划…' : '备份计划暂不可用。'}
+
+ )
}
- return void save()} onApply={() => void apply()} onRunNow={() => void runNow()}
- onReload={() => void load()}
- />
-}
\ No newline at end of file
+ return (
+ void save()}
+ onApply={() => void apply()}
+ onRunNow={() => void runNow()}
+ onReload={() => void load()}
+ />
+ )
+}
diff --git a/web/src/DingtalkSessionControl.test.tsx b/web/src/DingtalkSessionControl.test.tsx
index 42a181c..9ee6821 100644
--- a/web/src/DingtalkSessionControl.test.tsx
+++ b/web/src/DingtalkSessionControl.test.tsx
@@ -1,12 +1,12 @@
import { renderToStaticMarkup } from 'react-dom/server'
-import { describe,expect,it,vi } from 'vitest'
+import { describe, expect, it, vi } from 'vitest'
import { DingtalkSessionControl } from './DingtalkSessionControl'
-describe('DingtalkSessionControl',()=>{
- it('renders the admin scan button without firing any request',()=>{
- const fetchMock=vi.fn()
- vi.stubGlobal('fetch',fetchMock)
- const markup=renderToStaticMarkup( )
+describe('DingtalkSessionControl', () => {
+ it('renders the admin scan button without firing any request', () => {
+ const fetchMock = vi.fn()
+ vi.stubGlobal('fetch', fetchMock)
+ const markup = renderToStaticMarkup( )
expect(markup).toContain('扫码刷新会话')
expect(markup).not.toContain('modal-backdrop')
expect(fetchMock).not.toHaveBeenCalled()
diff --git a/web/src/DingtalkSessionControl.tsx b/web/src/DingtalkSessionControl.tsx
index 5429388..0e2d467 100644
--- a/web/src/DingtalkSessionControl.tsx
+++ b/web/src/DingtalkSessionControl.tsx
@@ -18,7 +18,14 @@ interface DingtalkSessionControlProps {
onClose?: () => void
}
-export function DingtalkSessionControl({ autoStart = false, hideTrigger = false, space, onSuccess, onClose, triggerLabel = '扫码刷新会话' }: DingtalkSessionControlProps = {}) {
+export function DingtalkSessionControl({
+ autoStart = false,
+ hideTrigger = false,
+ space,
+ onSuccess,
+ onClose,
+ triggerLabel = '扫码刷新会话',
+}: DingtalkSessionControlProps = {}) {
const [session, setSession] = useState(null)
const [failure, setFailure] = useState(null)
const [starting, setStarting] = useState(false)
@@ -28,29 +35,43 @@ export function DingtalkSessionControl({ autoStart = false, hideTrigger = false,
const autoStartedRef = useRef(false)
const successHandledRef = useRef(false)
- useEffect(() => () => { if (idRef.current) void cancelDingtalkSession(idRef.current) }, [])
+ useEffect(
+ () => () => {
+ if (idRef.current) void cancelDingtalkSession(idRef.current)
+ },
+ []
+ )
useEffect(() => {
if (!session || finished.has(session.state)) return
const timer = setInterval(() => {
- fetchDingtalkSession(session.id).then(setSession).catch(() => undefined)
+ fetchDingtalkSession(session.id)
+ .then(setSession)
+ .catch(() => undefined)
}, 1500)
return () => clearInterval(timer)
}, [session?.id, session?.state])
- const close = useCallback((cancel = true) => {
- if (cancel && idRef.current) void cancelDingtalkSession(idRef.current)
- idRef.current = null
- setSession(null)
- setStarting(false)
- setOpen(false)
- onClose?.()
- }, [onClose])
+ const close = useCallback(
+ (cancel = true) => {
+ if (cancel && idRef.current) void cancelDingtalkSession(idRef.current)
+ idRef.current = null
+ setSession(null)
+ setStarting(false)
+ setOpen(false)
+ onClose?.()
+ },
+ [onClose]
+ )
const start = useCallback(async () => {
// Show the dialog immediately. Launching Chromium and rendering the QR can
// take several seconds and must never look like an ignored click.
- setOpen(true); setStarting(true); setFailure(null); setSession(null); setLastSuccess(false)
+ setOpen(true)
+ setStarting(true)
+ setFailure(null)
+ setSession(null)
+ setLastSuccess(false)
successHandledRef.current = false
try {
const fresh = await startDingtalkSession(space)
@@ -81,35 +102,82 @@ export function DingtalkSessionControl({ autoStart = false, hideTrigger = false,
}, [session?.state, onSuccess, close])
const stateLabel: Record = {
- starting: '正在生成二维码,请稍候…', qr: '请用钉钉 App 扫码登录', scanned: '已扫码,正在验证并继续…',
- reusing: '检测到有效浏览器会话,正在验证…', success: '✅ 钉钉扫码会话已刷新,扫描任务可继续使用',
- expired: '二维码已过期,请重新发起', failed: '会话验证失败,请重试或联系管理员', closed: '会话已关闭',
+ starting: '正在生成二维码,请稍候…',
+ qr: '请用钉钉 App 扫码登录',
+ scanned: '已扫码,正在验证并继续…',
+ reusing: '检测到有效浏览器会话,正在验证…',
+ success: '✅ 钉钉扫码会话已刷新,扫描任务可继续使用',
+ expired: '二维码已过期,请重新发起',
+ failed: '会话验证失败,请重试或联系管理员',
+ closed: '会话已关闭',
}
const generating = open && !failure && (starting || !session || session.state === 'starting')
- return
- {!hideTrigger ?
void start()}>
- {session && !finished.has(session.state) ? '扫码进行中…' : triggerLabel}
- : null}
- {lastSuccess && !hideTrigger ?
✅ 钉钉会话已刷新
: null}
- {failure && !open ?
{failure}
: null}
- {open ?
-
-
刷新钉钉扫码会话
-
- {generating ? '正在生成二维码,请稍候…' : session ? (stateLabel[session.state] ?? session.state) : '二维码生成失败,请重试'}
+ return (
+
+ {!hideTrigger ? (
+
void start()}
+ >
+ {session && !finished.has(session.state) ? '扫码进行中…' : triggerLabel}
+
+ ) : null}
+ {lastSuccess && !hideTrigger ? (
+
+ ✅ 钉钉会话已刷新
- {generating ?
: null}
- {session?.qr_png && session.state === 'qr' ?
: null}
- {session?.error ?
{session.error}
: null}
- {failure ?
{failure}
: null}
-
- {session && finished.has(session.state)
- ?
close(false)}>关闭
- :
close(true)}>取消扫码 }
+ ) : null}
+ {failure && !open ? (
+
+ {failure}
+
+ ) : null}
+ {open ? (
+
+
+
刷新钉钉扫码会话
+
+ {generating
+ ? '正在生成二维码,请稍候…'
+ : session
+ ? (stateLabel[session.state] ?? session.state)
+ : '二维码生成失败,请重试'}
+
+ {generating ? (
+
+
+
+ ) : null}
+ {session?.qr_png && session.state === 'qr' ? (
+
+ ) : null}
+ {session?.error ? (
+
+ {session.error}
+
+ ) : null}
+ {failure ? (
+
+ {failure}
+
+ ) : null}
+
+ {session && finished.has(session.state) ? (
+ close(false)}>
+ 关闭
+
+ ) : (
+ close(true)}>
+ 取消扫码
+
+ )}
+
+
-
-
: null}
-
+ ) : null}
+
+ )
}
diff --git a/web/src/DocumentWorkbench.test.tsx b/web/src/DocumentWorkbench.test.tsx
index caa456e..5816acf 100644
--- a/web/src/DocumentWorkbench.test.tsx
+++ b/web/src/DocumentWorkbench.test.tsx
@@ -4,30 +4,80 @@ import { DocumentWorkbenchView } from './DocumentWorkbench'
import { document } from './documentFixture'
describe('document engineer workbench', () => {
- it('shows real file names, source categories and scope without technical identifiers', () => {
- const manual={...document,id:'manual',title:'P3 验收文档',source_type:'manual' as const,connection_id:'manual-1'}
- const html = renderToStaticMarkup(
)
- for (const text of ['来源与目录','手册.pdf','使用指南','对外','解析完成','PP分类','未接通目录','文件名','显示 1 份验收文档']) expect(html).toContain(text)
- expect(html).not.toContain('P3 验收文档')
- expect(html).not.toContain('
a')
- expect(html).not.toContain('只走这 8 步')
- })
- it('shows monitoring and no fabricated page progress or production availability', () => {
- const html = renderToStaticMarkup(
)
- expect(html).toContain('解析监控')
- expect(html).toContain('来源处理概览')
- expect(html).toContain('已就绪 1 / 2')
- expect(html).toContain('独立解析')
- expect(html).toContain('内容复用')
- expect(html).toContain('处理中 1')
- expect(html).toContain('解析完成不代表已上线')
- expect(html).not.toContain('100%')
- expect(html).not.toContain('生产可用')
- })
- it('does not display zero as a successful load when the request failed', () => {
- const html = renderToStaticMarkup(
)
- expect(html).toContain('加载失败')
- expect(html).not.toContain('aria-label="当前目录统计"')
- expect(html).not.toContain('没有符合条件的文件')
- })
+ it('shows real file names, source categories and scope without technical identifiers', () => {
+ const manual = {
+ ...document,
+ id: 'manual',
+ title: 'P3 验收文档',
+ source_type: 'manual' as const,
+ connection_id: 'manual-1',
+ }
+ const html = renderToStaticMarkup(
+
+ )
+ for (const text of [
+ '来源与目录',
+ '手册.pdf',
+ '使用指南',
+ '对外',
+ '解析完成',
+ 'PP分类',
+ '未接通目录',
+ '文件名',
+ '显示 1 份验收文档',
+ ])
+ expect(html).toContain(text)
+ expect(html).not.toContain('P3 验收文档')
+ expect(html).not.toContain('
a')
+ expect(html).not.toContain('只走这 8 步')
+ })
+ it('shows monitoring and no fabricated page progress or production availability', () => {
+ const html = renderToStaticMarkup(
+
+ )
+ expect(html).toContain('解析监控')
+ expect(html).toContain('来源处理概览')
+ expect(html).toContain('已就绪 1 / 2')
+ expect(html).toContain('独立解析')
+ expect(html).toContain('内容复用')
+ expect(html).toContain('处理中 1')
+ expect(html).toContain('解析完成不代表已上线')
+ expect(html).not.toContain('100%')
+ expect(html).not.toContain('生产可用')
+ })
+ it('does not display zero as a successful load when the request failed', () => {
+ const html = renderToStaticMarkup(
+
+ )
+ expect(html).toContain('加载失败')
+ expect(html).not.toContain('aria-label="当前目录统计"')
+ expect(html).not.toContain('没有符合条件的文件')
+ })
})
diff --git a/web/src/DocumentWorkbench.tsx b/web/src/DocumentWorkbench.tsx
index f6afc34..a1d6f2e 100644
--- a/web/src/DocumentWorkbench.tsx
+++ b/web/src/DocumentWorkbench.tsx
@@ -6,155 +6,583 @@ import { clearScopeRule, fetchScopeRules, setScopeRule } from './scopeRules'
import type { ClearScopeRuleDecision, ScopeRule, ScopeRuleDecision } from './scopeRules'
import { ScopeRuleEditor } from './ScopeRuleEditor'
import { WorkspaceContextTabsSetter } from './workspaceContext'
-import { connectionKey, directoryGroups, documentPipeline, documentProblem, documentStage, fetchDocumentCatalog, filterDocuments, operationalDocuments, sourceProgress, stageLabels, visibilityOrigin } from './documentWorkbench'
+import {
+ connectionKey,
+ directoryGroups,
+ documentPipeline,
+ documentProblem,
+ documentStage,
+ fetchDocumentCatalog,
+ filterDocuments,
+ operationalDocuments,
+ sourceProgress,
+ stageLabels,
+ visibilityOrigin,
+} from './documentWorkbench'
import type { DocumentStage } from './documentWorkbench'
import './documentWorkbench.css'
type Mode = 'directory' | 'monitor' | 'attention'
-const sourceNames: Record
= {pp:'PP公开文档', alist:'AList文件源', dingtalk:'钉钉知识库', manual:'手工验收文档'}
-const activeStages: DocumentStage[] = ['downloading','uploading','parsing','indexing']
+const sourceNames: Record = {
+ pp: 'PP公开文档',
+ alist: 'AList文件源',
+ dingtalk: '钉钉知识库',
+ manual: '手工验收文档',
+}
+const activeStages: DocumentStage[] = ['downloading', 'uploading', 'parsing', 'indexing']
interface ViewProps {
- items: SourceAsset[]
- connections: SourceConnectionPage['items']
- mode: Mode
- loading: boolean
- error: string | null
- onRefresh: () => void
- onRetry: (id:string) => void
- retryingId: string | null
+ items: SourceAsset[]
+ connections: SourceConnectionPage['items']
+ mode: Mode
+ loading: boolean
+ error: string | null
+ onRefresh: () => void
+ onRetry: (id: string) => void
+ retryingId: string | null
}
-const time = (value:string) => new Date(value).toLocaleString('zh-CN', {hour12:false})
-const bytes = (value:number) => value >= 1048576 ? `${(value/1048576).toFixed(1)} MB` : `${(value/1024).toFixed(1)} KB`
+const time = (value: string) => new Date(value).toLocaleString('zh-CN', { hour12: false })
+const bytes = (value: number) =>
+ value >= 1048576 ? `${(value / 1048576).toFixed(1)} MB` : `${(value / 1024).toFixed(1)} KB`
-export function DocumentWorkbenchView({items, connections, mode, loading, error, onRefresh, onRetry, retryingId}: ViewProps) {
- const [connection, setConnection] = useState('')
- const [directory, setDirectory] = useState(null)
- const [query, setQuery] = useState('')
- const [visibility, setVisibility] = useState('')
- const [stage, setStage] = useState('')
- const [page, setPage] = useState(0)
- const [selectedID, setSelectedID] = useState(null)
- const [showTestDocuments,setShowTestDocuments]=useState(false)
- const [scopeRules,setScopeRules]=useState([])
- const [scopeLoading,setScopeLoading]=useState(false)
- const [scopeError,setScopeError]=useState(null)
- const setWorkspaceContextTabs=useContext(WorkspaceContextTabsSetter)
- const visibleItems=useMemo(()=>operationalDocuments(items,showTestDocuments),[items,showTestDocuments])
- const groups = useMemo(()=>directoryGroups(visibleItems),[visibleItems])
- const sources = useMemo(()=> {
- const map = new Map(connections.filter(c=>showTestDocuments||c.source_type!=='manual').map(c=>[c.id, {id:c.id,type:c.source_type,name:c.name}]))
- for (const item of visibleItems) if (!map.has(connectionKey(item))) map.set(connectionKey(item), {id:connectionKey(item),type:item.source_type,name:item.connection_name})
- return [...map.values()]
- },[visibleItems,connections,showTestDocuments])
- const scoped = filterDocuments(visibleItems,{connection,directory,query:'',visibility:'',stage:''})
- const filtered = filterDocuments(visibleItems,{connection,directory,query,visibility,stage}).filter(item => mode !== 'attention' || ['blocked','failed'].includes(documentStage(item)))
- const shownPage = Math.min(page,Math.max(0,Math.ceil(filtered.length/40)-1))
- const selected = filtered.find(item=>item.id===selectedID)
- const reset = () => {setPage(0);setSelectedID(null)}
- const selectFolder = (id:string,path:string|null) => {setConnection(id);setDirectory(path);reset()}
- const title = mode === 'monitor' ? '解析监控' : mode === 'attention' ? '待处理文件' : '来源与目录'
- const unavailable = items.length === 0 && Boolean(loading || error)
- const progress=sourceProgress(visibleItems)
- const manualCount=items.filter(item=>item.source_type==='manual').length
- const directDirectoryRule=scopeRules.find(rule=>rule.target_type==='directory'&&rule.connection_id===connection&&rule.directory_path===(directory??''))
- const selectedItemRule=scopeRules.find(rule=>rule.target_type==='item'&&rule.source_item_id===selectedID)
- const selectedSourceLabel=sourceNames[sources.find(item=>item.id===connection)?.type||'']||'当前来源'
- useEffect(()=>{
- if(!connection){setScopeRules([]);setScopeError(null);return}
- const controller=new AbortController()
- setScopeLoading(true)
- void fetchScopeRules(connection,controller.signal).then(setScopeRules).catch(cause=>{if(!controller.signal.aborted)setScopeError(cause instanceof Error?cause.message:'加载范围规则失败')}).finally(()=>{if(!controller.signal.aborted)setScopeLoading(false)})
- return()=>controller.abort()
- },[connection])
- useEffect(()=>{
- if(!connection){setWorkspaceContextTabs([]);return}
- const tabs=[{key:'source',label:selectedSourceLabel,onActivate:()=>{setDirectory(null);setSelectedID(null);setPage(0)}}]
- if(directory!==null)tabs.push({key:'directory',label:directory||'分类待补齐',onActivate:()=>setSelectedID(null)})
- if(selected)tabs.push({key:'file',label:selected.title||'当前文件',onActivate:()=>setSelectedID(selected.id)})
- setWorkspaceContextTabs(tabs)
- return()=>setWorkspaceContextTabs([])
- },[connection,directory,selected,selectedSourceLabel,setWorkspaceContextTabs])
- const mutateScope=async(decision:ScopeRuleDecision|ClearScopeRuleDecision,clear:boolean)=>{
- if(scopeLoading)return
- setScopeLoading(true);setScopeError(null)
- try{if(clear)await clearScopeRule(decision as ClearScopeRuleDecision);else await setScopeRule(decision as ScopeRuleDecision);setScopeRules(await fetchScopeRules(connection));onRefresh()}catch(cause){setScopeError(cause instanceof Error?cause.message:'保存范围规则失败')}finally{setScopeLoading(false)}
- }
- return
-
- {error ? {error}(旧数据仅供参考)
: null}
- {mode==='monitor'&&!unavailable?来源处理概览 直接看各来源完成、处理中和异常数量
{progress.map(item=>{const source=sources.find(value=>value.id===item.connection);return selectFolder(item.connection,null)}>{sourceNames[source?.type||'']||source?.name||'当前来源'} 已就绪 {item.completed+item.reused} / {item.total} 独立解析 {item.completed} · 内容复用 {item.reused} 处理中 {item.active} · 需处理 {item.attention} · 等待 {item.waiting} })}
:null}
-
-
-
-
- {!unavailable ?
- 文件 {scoped.length}
- {mode==='monitor'?<>独立解析 {scoped.filter(x=>documentStage(x)==='completed').length} 内容复用 {scoped.filter(x=>documentStage(x)==='reused').length} 处理中 {scoped.filter(x=>activeStages.includes(documentStage(x))).length} 等待 {scoped.filter(x=>documentStage(x)==='waiting').length} 需处理(含超限) {scoped.filter(x=>['blocked','failed'].includes(documentStage(x))).length} >:<>对内 {scoped.filter(x=>x.visibility==='internal').length} 对外 {scoped.filter(x=>x.visibility==='external').length} 处理中 {scoped.filter(x=>activeStages.includes(documentStage(x))).length} 已解析 {scoped.filter(x=>documentStage(x)==='completed').length} 需处理 {scoped.filter(x=>['blocked','failed'].includes(documentStage(x))).length} >}
-
: 文件数量尚未核实
}
- {mode==='directory'&&connection&&directory!==''?void mutateScope(decision,false)} onClear={decision=>void mutateScope(decision,true)} />:mode==='directory'&&connection&&directory===''?“分类待补齐”不是可靠目录,不能单独设置范围。请先补齐来源目录,或返回来源级设置。
:null}
-
- 找文件 {setQuery(e.target.value);reset()}} />
- 对内/对外{setVisibility(e.target.value);reset()}}>全部范围 对内 对外
- 处理状态{setStage(e.target.value);reset()}}>全部状态 {Object.entries(stageLabels).map(([value,label])=>{label} )}
-
- {!connection?'全部来源':sourceNames[sources.find(s=>s.id===connection)?.type || ''] || '当前来源'} / {directory === null?'全部文件':directory || '目录待补齐'} {!unavailable ? 筛选结果 {filtered.length} 份 : null}
- {loading && !items.length ? 正在核对完整文档清单…
: null}
- {!loading && !error && !filtered.length ? 没有符合条件的文件。
: null}
- {filtered.length ? 文件名 对内/对外 处理状态 大小 状态更新时间
- {filtered.slice(shownPage*40,(shownPage+1)*40).map(item=>
- setSelectedID(item.id)}>{item.title || '文件名待补齐'} {sourceNames[item.source_type]} / {item.directory_path || '目录待补齐'}
- {item.visibility==='external'?'对外':'对内'} {visibilityOrigin(item)}
- {stageLabels[documentStage(item)]} {bytes(item.size_bytes)} {time(item.updated_at)}
- )}
-
: null}
- 第 {shownPage+1} / {Math.max(1,Math.ceil(filtered.length/40))} 页 setPage(shownPage-1)}>上一页 =filtered.length} onClick={()=>setPage(shownPage+1)}>下一页
- {mode === 'monitor' ? 每15秒自动刷新。当前接口未提供页数或逐页百分比,因此只显示真实阶段。时间为状态更新时间,不是源文件修改时间。
: null}
-
-
- {selected ?
{selected.title || '文件名待补齐'} setSelectedID(null)}>关闭详情
-
来源位置 {sourceNames[selected.source_type]} / {selected.directory_path || '目录待补齐'} 对内/对外 {selected.visibility==='external'?'对外':'对内'} · {visibilityOrigin(selected)} 当前进度 {stageLabels[documentStage(selected)]}
- {documentProblem(selected) ?
{documentProblem(selected)}
: null}
-
文件大小 {bytes(selected.size_bytes)} 状态更新时间 {time(selected.updated_at)} 来源变化 {selected.source_change==='unchanged'?'无变化':'需要核对'} 检索状态 {selected.retrieval_state==='enabled'?'测试环境已启用':'未证明已启用'}
-
{documentPipeline(selected).map(step=>
{step.label} {step.detail}
)}
-
原文与版本差异尚未接通,请在源系统核对内容;不要仅凭“解析完成”批准发布。
- {mode==='directory'?
void mutateScope(decision,false)} onClear={decision=>void mutateScope(decision,true)} />:null}
- {selected.can_retry && selected.publication_id ? onRetry(selected.publication_id!)}>{retryingId===selected.publication_id?'提交中…':'重试此文件'} : null}
- {selected.last_error ? 错误详情 {selected.last_error} : null}
- : null}
-
+export function DocumentWorkbenchView({
+ items,
+ connections,
+ mode,
+ loading,
+ error,
+ onRefresh,
+ onRetry,
+ retryingId,
+}: ViewProps) {
+ const [connection, setConnection] = useState('')
+ const [directory, setDirectory] = useState(null)
+ const [query, setQuery] = useState('')
+ const [visibility, setVisibility] = useState('')
+ const [stage, setStage] = useState('')
+ const [page, setPage] = useState(0)
+ const [selectedID, setSelectedID] = useState(null)
+ const [showTestDocuments, setShowTestDocuments] = useState(false)
+ const [scopeRules, setScopeRules] = useState([])
+ const [scopeLoading, setScopeLoading] = useState(false)
+ const [scopeError, setScopeError] = useState(null)
+ const setWorkspaceContextTabs = useContext(WorkspaceContextTabsSetter)
+ const visibleItems = useMemo(() => operationalDocuments(items, showTestDocuments), [items, showTestDocuments])
+ const groups = useMemo(() => directoryGroups(visibleItems), [visibleItems])
+ const sources = useMemo(() => {
+ const map = new Map(
+ connections
+ .filter((c) => showTestDocuments || c.source_type !== 'manual')
+ .map((c) => [c.id, { id: c.id, type: c.source_type, name: c.name }])
+ )
+ for (const item of visibleItems)
+ if (!map.has(connectionKey(item)))
+ map.set(connectionKey(item), { id: connectionKey(item), type: item.source_type, name: item.connection_name })
+ return [...map.values()]
+ }, [visibleItems, connections, showTestDocuments])
+ const scoped = filterDocuments(visibleItems, { connection, directory, query: '', visibility: '', stage: '' })
+ const filtered = filterDocuments(visibleItems, { connection, directory, query, visibility, stage }).filter(
+ (item) => mode !== 'attention' || ['blocked', 'failed'].includes(documentStage(item))
+ )
+ const shownPage = Math.min(page, Math.max(0, Math.ceil(filtered.length / 40) - 1))
+ const selected = filtered.find((item) => item.id === selectedID)
+ const reset = () => {
+ setPage(0)
+ setSelectedID(null)
+ }
+ const selectFolder = (id: string, path: string | null) => {
+ setConnection(id)
+ setDirectory(path)
+ reset()
+ }
+ const title = mode === 'monitor' ? '解析监控' : mode === 'attention' ? '待处理文件' : '来源与目录'
+ const unavailable = items.length === 0 && Boolean(loading || error)
+ const progress = sourceProgress(visibleItems)
+ const manualCount = items.filter((item) => item.source_type === 'manual').length
+ const directDirectoryRule = scopeRules.find(
+ (rule) =>
+ rule.target_type === 'directory' && rule.connection_id === connection && rule.directory_path === (directory ?? '')
+ )
+ const selectedItemRule = scopeRules.find((rule) => rule.target_type === 'item' && rule.source_item_id === selectedID)
+ const selectedSourceLabel = sourceNames[sources.find((item) => item.id === connection)?.type || ''] || '当前来源'
+ useEffect(() => {
+ if (!connection) {
+ setScopeRules([])
+ setScopeError(null)
+ return
+ }
+ const controller = new AbortController()
+ setScopeLoading(true)
+ void fetchScopeRules(connection, controller.signal)
+ .then(setScopeRules)
+ .catch((cause) => {
+ if (!controller.signal.aborted) setScopeError(cause instanceof Error ? cause.message : '加载范围规则失败')
+ })
+ .finally(() => {
+ if (!controller.signal.aborted) setScopeLoading(false)
+ })
+ return () => controller.abort()
+ }, [connection])
+ useEffect(() => {
+ if (!connection) {
+ setWorkspaceContextTabs([])
+ return
+ }
+ const tabs = [
+ {
+ key: 'source',
+ label: selectedSourceLabel,
+ onActivate: () => {
+ setDirectory(null)
+ setSelectedID(null)
+ setPage(0)
+ },
+ },
+ ]
+ if (directory !== null)
+ tabs.push({ key: 'directory', label: directory || '分类待补齐', onActivate: () => setSelectedID(null) })
+ if (selected)
+ tabs.push({ key: 'file', label: selected.title || '当前文件', onActivate: () => setSelectedID(selected.id) })
+ setWorkspaceContextTabs(tabs)
+ return () => setWorkspaceContextTabs([])
+ }, [connection, directory, selected, selectedSourceLabel, setWorkspaceContextTabs])
+ const mutateScope = async (decision: ScopeRuleDecision | ClearScopeRuleDecision, clear: boolean) => {
+ if (scopeLoading) return
+ setScopeLoading(true)
+ setScopeError(null)
+ try {
+ if (clear) await clearScopeRule(decision as ClearScopeRuleDecision)
+ else await setScopeRule(decision as ScopeRuleDecision)
+ setScopeRules(await fetchScopeRules(connection))
+ onRefresh()
+ } catch (cause) {
+ setScopeError(cause instanceof Error ? cause.message : '保存范围规则失败')
+ } finally {
+ setScopeLoading(false)
+ }
+ }
+ return (
+
+
+ {error ? (
+
+ {error}(旧数据仅供参考)
+
+ ) : null}
+ {mode === 'monitor' && !unavailable ? (
+
+
+ 来源处理概览
+ 直接看各来源完成、处理中和异常数量
+
+
+ {progress.map((item) => {
+ const source = sources.find((value) => value.id === item.connection)
+ return (
+ selectFolder(item.connection, null)}
+ >
+ {sourceNames[source?.type || ''] || source?.name || '当前来源'}
+
+ 已就绪 {item.completed + item.reused} / {item.total}
+
+
+ 独立解析 {item.completed} · 内容复用 {item.reused}
+
+
+ 处理中 {item.active} · 需处理 {item.attention} · 等待 {item.waiting}
+
+
+ )
+ })}
+
+
+ ) : null}
+
+
+
+ {!unavailable ? (
+
+
+ 文件 {scoped.length}
+
+ {mode === 'monitor' ? (
+ <>
+
+ 独立解析 {scoped.filter((x) => documentStage(x) === 'completed').length}
+
+
+ 内容复用 {scoped.filter((x) => documentStage(x) === 'reused').length}
+
+
+ 处理中 {scoped.filter((x) => activeStages.includes(documentStage(x))).length}
+
+
+ 等待 {scoped.filter((x) => documentStage(x) === 'waiting').length}
+
+
+ 需处理(含超限){' '}
+ {scoped.filter((x) => ['blocked', 'failed'].includes(documentStage(x))).length}
+
+ >
+ ) : (
+ <>
+
+ 对内 {scoped.filter((x) => x.visibility === 'internal').length}
+
+
+ 对外 {scoped.filter((x) => x.visibility === 'external').length}
+
+
+ 处理中 {scoped.filter((x) => activeStages.includes(documentStage(x))).length}
+
+
+ 已解析 {scoped.filter((x) => documentStage(x) === 'completed').length}
+
+
+ 需处理 {scoped.filter((x) => ['blocked', 'failed'].includes(documentStage(x))).length}
+
+ >
+ )}
+
+ ) : (
+ 文件数量尚未核实
+ )}
+ {mode === 'directory' && connection && directory !== '' ? (
+ void mutateScope(decision, false)}
+ onClear={(decision) => void mutateScope(decision, true)}
+ />
+ ) : mode === 'directory' && connection && directory === '' ? (
+
+ “分类待补齐”不是可靠目录,不能单独设置范围。请先补齐来源目录,或返回来源级设置。
+
+ ) : null}
+
+
+ 找文件
+ {
+ setQuery(e.target.value)
+ reset()
+ }}
+ />
+
+
+ 对内/对外
+ {
+ setVisibility(e.target.value)
+ reset()
+ }}
+ >
+ 全部范围
+ 对内
+ 对外
+
+
+
+ 处理状态
+ {
+ setStage(e.target.value)
+ reset()
+ }}
+ >
+ 全部状态
+ {Object.entries(stageLabels).map(([value, label]) => (
+
+ {label}
+
+ ))}
+
+
+
+
+ {!connection ? '全部来源' : sourceNames[sources.find((s) => s.id === connection)?.type || ''] || '当前来源'}{' '}
+ / {directory === null ? '全部文件' : directory || '目录待补齐'}{' '}
+ {!unavailable ? 筛选结果 {filtered.length} 份 : null}
+
+ {loading && !items.length ? 正在核对完整文档清单…
: null}
+ {!loading && !error && !filtered.length ? 没有符合条件的文件。
: null}
+ {filtered.length ? (
+
+
+
+
+ 文件名
+ 对内/对外
+ 处理状态
+ 大小
+ 状态更新时间
+
+
+
+ {filtered.slice(shownPage * 40, (shownPage + 1) * 40).map((item) => (
+
+
+ setSelectedID(item.id)}>
+ {item.title || '文件名待补齐'}
+
+
+ {sourceNames[item.source_type]} / {item.directory_path || '目录待补齐'}
+
+
+
+
+ {item.visibility === 'external' ? '对外' : '对内'}
+
+ {visibilityOrigin(item)}
+
+
+
+ {stageLabels[documentStage(item)]}
+
+
+ {bytes(item.size_bytes)}
+
+ {time(item.updated_at)}
+
+
+ ))}
+
+
+
+ ) : null}
+
+
+ 第 {shownPage + 1} / {Math.max(1, Math.ceil(filtered.length / 40))} 页
+
+
+ setPage(shownPage - 1)}>
+ 上一页
+
+ = filtered.length} onClick={() => setPage(shownPage + 1)}>
+ 下一页
+
+
+
+ {mode === 'monitor' ? (
+
+ 每15秒自动刷新。当前接口未提供页数或逐页百分比,因此只显示真实阶段。时间为状态更新时间,不是源文件修改时间。
+
+ ) : null}
+
+
+ {selected ? (
+
+
+
{selected.title || '文件名待补齐'}
+ setSelectedID(null)}>关闭详情
+
+
+
+ 来源位置
+
+ {sourceNames[selected.source_type]} / {selected.directory_path || '目录待补齐'}
+
+ 对内/对外
+
+ {selected.visibility === 'external' ? '对外' : '对内'} · {visibilityOrigin(selected)}
+
+ 当前进度
+ {stageLabels[documentStage(selected)]}
+
+ {documentProblem(selected) ?
{documentProblem(selected)}
: null}
+
+
+ 文件大小 {bytes(selected.size_bytes)}
+
+
+ 状态更新时间 {time(selected.updated_at)}
+
+
+ 来源变化 {selected.source_change === 'unchanged' ? '无变化' : '需要核对'}
+
+
+ 检索状态 {selected.retrieval_state === 'enabled' ? '测试环境已启用' : '未证明已启用'}
+
+
+
+ {documentPipeline(selected).map((step) => (
+
+
+
+ {step.label}
+ {step.detail}
+
+
+ ))}
+
+
原文与版本差异尚未接通,请在源系统核对内容;不要仅凭“解析完成”批准发布。
+ {mode === 'directory' ? (
+
void mutateScope(decision, false)}
+ onClear={(decision) => void mutateScope(decision, true)}
+ />
+ ) : null}
+ {selected.can_retry && selected.publication_id ? (
+ onRetry(selected.publication_id!)}>
+ {retryingId === selected.publication_id ? '提交中…' : '重试此文件'}
+
+ ) : null}
+ {selected.last_error ? (
+
+ 错误详情
+ {selected.last_error}
+
+ ) : null}
+
+
+ ) : null}
+
+ )
}
-export function DocumentWorkbench({mode,connections}: {mode:Mode;connections:SourceConnectionPage['items']}) {
- const [items,setItems] = useState([])
- const [loading,setLoading] = useState(true)
- const [error,setError] = useState(null)
- const [revision,setRevision] = useState(0)
- const [retryingId,setRetryingId] = useState(null)
- useEffect(()=>{
- const controller = new AbortController()
- setLoading(true)
- void fetchDocumentCatalog(controller.signal).then(data=>{if (!controller.signal.aborted) {setItems(data);setError(null)}}).catch(cause=>{if (!controller.signal.aborted) setError(cause instanceof Error?cause.message:'加载文件失败')}).finally(()=>{if (!controller.signal.aborted) setLoading(false)})
- return ()=>controller.abort()
- },[revision])
- useEffect(()=>{
- if (mode!=='monitor' || loading) return
- const timer = window.setTimeout(()=>setRevision(r=>r+1),15000)
- return ()=>window.clearTimeout(timer)
- },[mode,loading,revision])
- const retry = async(id:string)=>{
- if (retryingId) return
- if (!window.confirm('仅重试这份失败文件,不影响已成功文件或生产。继续吗?')) return
- setRetryingId(id)
- try {await retryPublication(id);setRevision(r=>r+1)} catch(cause) {setError(cause instanceof Error?cause.message:'重试失败')} finally {setRetryingId(null)}
- }
- return setRevision(r=>r+1)} onRetry={id=>void retry(id)} retryingId={retryingId} />
+export function DocumentWorkbench({ mode, connections }: { mode: Mode; connections: SourceConnectionPage['items'] }) {
+ const [items, setItems] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState(null)
+ const [revision, setRevision] = useState(0)
+ const [retryingId, setRetryingId] = useState(null)
+ useEffect(() => {
+ const controller = new AbortController()
+ setLoading(true)
+ void fetchDocumentCatalog(controller.signal)
+ .then((data) => {
+ if (!controller.signal.aborted) {
+ setItems(data)
+ setError(null)
+ }
+ })
+ .catch((cause) => {
+ if (!controller.signal.aborted) setError(cause instanceof Error ? cause.message : '加载文件失败')
+ })
+ .finally(() => {
+ if (!controller.signal.aborted) setLoading(false)
+ })
+ return () => controller.abort()
+ }, [revision])
+ useEffect(() => {
+ if (mode !== 'monitor' || loading) return
+ const timer = window.setTimeout(() => setRevision((r) => r + 1), 15000)
+ return () => window.clearTimeout(timer)
+ }, [mode, loading, revision])
+ const retry = async (id: string) => {
+ if (retryingId) return
+ if (!window.confirm('仅重试这份失败文件,不影响已成功文件或生产。继续吗?')) return
+ setRetryingId(id)
+ try {
+ await retryPublication(id)
+ setRevision((r) => r + 1)
+ } catch (cause) {
+ setError(cause instanceof Error ? cause.message : '重试失败')
+ } finally {
+ setRetryingId(null)
+ }
+ }
+ return (
+ setRevision((r) => r + 1)}
+ onRetry={(id) => void retry(id)}
+ retryingId={retryingId}
+ />
+ )
}
diff --git a/web/src/FilenameFilterEditor.test.tsx b/web/src/FilenameFilterEditor.test.tsx
index 2039e22..897f240 100644
--- a/web/src/FilenameFilterEditor.test.tsx
+++ b/web/src/FilenameFilterEditor.test.tsx
@@ -5,20 +5,22 @@ import { FilenameFilterEditorView } from './FilenameFilterEditor'
describe('FilenameFilterEditorView', () => {
it('renders format groups with the allow-mode preselection', () => {
- const html = renderToStaticMarkup( undefined}
- options={['pdf', 'zip', 'mp4']}
- selected={['pdf']}
- onToggle={() => undefined}
- busy={false}
- hasOwnRule={false}
- message="已保留 .pdf"
- error={null}
- onSave={() => undefined}
- onClear={() => undefined}
- />)
+ const html = renderToStaticMarkup(
+ undefined}
+ options={['pdf', 'zip', 'mp4']}
+ selected={['pdf']}
+ onToggle={() => undefined}
+ busy={false}
+ hasOwnRule={false}
+ message="已保留 .pdf"
+ error={null}
+ onSave={() => undefined}
+ onClear={() => undefined}
+ />
+ )
expect(html).toContain('文件格式筛选')
expect(html).toContain('勾选保留')
expect(html).toContain('勾选排除')
diff --git a/web/src/FilenameFilterEditor.tsx b/web/src/FilenameFilterEditor.tsx
index 888b587..d971da8 100644
--- a/web/src/FilenameFilterEditor.tsx
+++ b/web/src/FilenameFilterEditor.tsx
@@ -1,6 +1,13 @@
import { useEffect, useMemo, useState } from 'react'
-import { clearFilenameFilter, DEFAULT_ALLOWED_EXTENSIONS, DEFAULT_EXCLUDED_EXTENSIONS, fetchFilenameFilters, parseExtensionDraft, saveFilenameFilter } from './filenameFilters'
+import {
+ clearFilenameFilter,
+ DEFAULT_ALLOWED_EXTENSIONS,
+ DEFAULT_EXCLUDED_EXTENSIONS,
+ fetchFilenameFilters,
+ parseExtensionDraft,
+ saveFilenameFilter,
+} from './filenameFilters'
import type { FilenameFilterRule } from './filenameFilters'
import './systemSettings.css'
@@ -40,41 +47,129 @@ function groupOf(extension: string): string {
return DEFAULT_EXCLUDED_EXTENSIONS.includes(extension) ? '排除项' : '其他'
}
-export function FilenameFilterEditorView({ label, mode, onModeChange, options, selected, onToggle, busy, hasOwnRule, message, error, onSave, onClear }: ViewProps) {
+export function FilenameFilterEditorView({
+ label,
+ mode,
+ onModeChange,
+ options,
+ selected,
+ onToggle,
+ busy,
+ hasOwnRule,
+ message,
+ error,
+ onSave,
+ onClear,
+}: ViewProps) {
const selectedSet = useMemo(() => new Set(selected), [selected])
const unknown = selected.filter((extension) => !options.includes(extension))
- return
- 文件格式筛选 {label} · 选择要入库的文件格式,未勾选的一律不取件
-
-
- {FORMAT_GROUPS.map((group) =>
-
{group.label}
-
{group.extensions.map((extension) => onToggle(extension)}>.{extension} )}
-
)}
-
- {unknown.length ?
规则含未知格式 {unknown.map((extension) => .{extension})}
: null}
-
- {
- if (event.key !== 'Enter') return
- event.preventDefault()
- const target = event.currentTarget
- for (const extension of parseExtensionDraft(target.value)) onToggle(extension)
- target.value = ''
- }} aria-label="手动补充格式" />
- {busy ? '保存中…' : mode === 'allow' ? '保存保留格式' : '保存排除格式'}
- {hasOwnRule ? 恢复继承上级规则 : null}
-
-
{mode === 'allow' ? '白名单:无扩展名的文件不受限制;目录级规则就近覆盖上级。' : '黑名单:仅排除勾选格式,其余可入库。'}
- {message ?
{message}
: null}
- {error ?
{error}
: null}
-
+
+ {FORMAT_GROUPS.map((group) => (
+
+
{group.label}
+
+ {group.extensions.map((extension) => (
+ onToggle(extension)}
+ >
+ .{extension}
+
+ ))}
+
+
+ ))}
+
+ {unknown.length ? (
+
+ 规则含未知格式
+ {unknown.map((extension) => (
+ .{extension}
+ ))}
+
+ ) : null}
+
+ {
+ if (event.key !== 'Enter') return
+ event.preventDefault()
+ const target = event.currentTarget
+ for (const extension of parseExtensionDraft(target.value)) onToggle(extension)
+ target.value = ''
+ }}
+ aria-label="手动补充格式"
+ />
+
+ {busy ? '保存中…' : mode === 'allow' ? '保存保留格式' : '保存排除格式'}
+
+ {hasOwnRule ? (
+
+ 恢复继承上级规则
+
+ ) : null}
+
+
+ {mode === 'allow'
+ ? '白名单:无扩展名的文件不受限制;目录级规则就近覆盖上级。'
+ : '黑名单:仅排除勾选格式,其余可入库。'}
+
+ {message ? (
+
+ {message}
+
+ ) : null}
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+ )
}
-export function FilenameFilterEditor({ connectionID, directoryPath = '', label, onChanged }: { connectionID: string; directoryPath?: string; label: string; onChanged?: () => void }) {
+export function FilenameFilterEditor({
+ connectionID,
+ directoryPath = '',
+ label,
+ onChanged,
+}: {
+ connectionID: string
+ directoryPath?: string
+ label: string
+ onChanged?: () => void
+}) {
const [rules, setRules] = useState
([])
const [mode, setMode] = useState('allow')
const [selected, setSelected] = useState(DEFAULT_ALLOWED_EXTENSIONS)
@@ -104,41 +199,82 @@ export function FilenameFilterEditor({ connectionID, directoryPath = '', label,
setSelected(current.excluded_extensions)
} else if (!touched) {
setMode('allow')
- setSelected(directoryPath === '' ? DEFAULT_ALLOWED_EXTENSIONS : [...new Set([...(inheritedHas('allow') ? inheritedValues('allow') : DEFAULT_ALLOWED_EXTENSIONS)])])
+ setSelected(
+ directoryPath === ''
+ ? DEFAULT_ALLOWED_EXTENSIONS
+ : [...new Set([...(inheritedHas('allow') ? inheritedValues('allow') : DEFAULT_ALLOWED_EXTENSIONS)])]
+ )
}
}
const inheritedHas = (want: string) => [...inherited].some((value) => value.endsWith(`:${want}`))
- const inheritedValues = (want: string) => [...inherited].filter((value) => value.endsWith(`:${want}`)).map((value) => value.split(':')[0])
+ const inheritedValues = (want: string) =>
+ [...inherited].filter((value) => value.endsWith(`:${want}`)).map((value) => value.split(':')[0])
useEffect(() => {
const controller = new AbortController()
- void load(controller.signal).catch((cause) => { if (!controller.signal.aborted) setError(cause instanceof Error ? cause.message : '加载文件格式筛选失败') })
+ void load(controller.signal).catch((cause) => {
+ if (!controller.signal.aborted) setError(cause instanceof Error ? cause.message : '加载文件格式筛选失败')
+ })
return () => controller.abort()
}, [connectionID, directoryPath])
const toggle = (extension: string) => {
setTouched(true)
- setSelected((previous) => previous.includes(extension) ? previous.filter((value) => value !== extension) : [...previous, extension].sort())
+ setSelected((previous) =>
+ previous.includes(extension) ? previous.filter((value) => value !== extension) : [...previous, extension].sort()
+ )
}
const save = async () => {
- setBusy(true); setError(null); setMessage(null)
+ setBusy(true)
+ setError(null)
+ setMessage(null)
try {
await saveFilenameFilter(connectionID, directoryPath, mode, selected)
await load()
- setMessage(mode === 'allow' ? `已保留 ${selected.map((value) => `.${value}`).join('、')};其余格式不再取件入库。` : `已排除 ${selected.map((value) => `.${value}`).join('、')}。`)
+ setMessage(
+ mode === 'allow'
+ ? `已保留 ${selected.map((value) => `.${value}`).join('、')};其余格式不再取件入库。`
+ : `已排除 ${selected.map((value) => `.${value}`).join('、')}。`
+ )
onChanged?.()
- } catch (cause) { setError(cause instanceof Error ? cause.message : '保存文件格式筛选失败') }
- finally { setBusy(false) }
+ } catch (cause) {
+ setError(cause instanceof Error ? cause.message : '保存文件格式筛选失败')
+ } finally {
+ setBusy(false)
+ }
}
const clear = async () => {
- setBusy(true); setError(null); setMessage(null)
+ setBusy(true)
+ setError(null)
+ setMessage(null)
try {
await clearFilenameFilter(connectionID, directoryPath)
await load()
setMessage('已移除本级规则,恢复继承上级。')
onChanged?.()
- } catch (cause) { setError(cause instanceof Error ? cause.message : '清除文件格式筛选失败') }
- finally { setBusy(false) }
+ } catch (cause) {
+ setError(cause instanceof Error ? cause.message : '清除文件格式筛选失败')
+ } finally {
+ setBusy(false)
+ }
}
- return { setTouched(true); setMode(next) }} options={options} selected={selected} onToggle={toggle} busy={busy} hasOwnRule={Boolean(direct)} message={message} error={error} onSave={() => void save()} onClear={() => void clear()} />
+ return (
+ {
+ setTouched(true)
+ setMode(next)
+ }}
+ options={options}
+ selected={selected}
+ onToggle={toggle}
+ busy={busy}
+ hasOwnRule={Boolean(direct)}
+ message={message}
+ error={error}
+ onSave={() => void save()}
+ onClear={() => void clear()}
+ />
+ )
}
diff --git a/web/src/MembersSection.tsx b/web/src/MembersSection.tsx
index ba32ec2..3eb480a 100644
--- a/web/src/MembersSection.tsx
+++ b/web/src/MembersSection.tsx
@@ -1,5 +1,11 @@
import { useCallback, useEffect, useState } from 'react'
-import { createManagedUser, fetchManagedUsers, resetManagedUserPassword, setManagedUserRole, setManagedUserState } from './members'
+import {
+ createManagedUser,
+ fetchManagedUsers,
+ resetManagedUserPassword,
+ setManagedUserRole,
+ setManagedUserState,
+} from './members'
import type { ManagedUser } from './members'
import { roleLabels, useOptionalAuth } from './auth'
import { PasswordInput } from './PasswordInput'
@@ -17,66 +23,221 @@ export function MembersSection() {
const [busy, setBusy] = useState(null)
const [error, setError] = useState(null)
const [message, setMessage] = useState(null)
- const [form, setForm] = useState({ username: '', display_name: '', role: 'engineer' as ManagedUser['role'], password: '' })
+ const [form, setForm] = useState({
+ username: '',
+ display_name: '',
+ role: 'engineer' as ManagedUser['role'],
+ password: '',
+ })
const load = useCallback(async () => {
- setLoading(true); setError(null)
- try { setUsers(await fetchManagedUsers()) }
- catch (cause) { setError(friendly(cause, '加载成员失败')) }
- finally { setLoading(false) }
+ setLoading(true)
+ setError(null)
+ try {
+ setUsers(await fetchManagedUsers())
+ } catch (cause) {
+ setError(friendly(cause, '加载成员失败'))
+ } finally {
+ setLoading(false)
+ }
}, [])
- useEffect(() => { void load() }, [load])
+ useEffect(() => {
+ void load()
+ }, [load])
const run = async (key: string, action: () => Promise, success: string) => {
- setBusy(key); setError(null); setMessage(null)
- try { await action(); setMessage(success); await load() }
- catch (cause) { setError(friendly(cause, '操作失败')) }
- finally { setBusy(null) }
+ setBusy(key)
+ setError(null)
+ setMessage(null)
+ try {
+ await action()
+ setMessage(success)
+ await load()
+ } catch (cause) {
+ setError(friendly(cause, '操作失败'))
+ } finally {
+ setBusy(null)
+ }
}
- const create = () => run('create', async () => {
- await createManagedUser(form)
- setForm({ username: '', display_name: '', role: 'engineer', password: '' })
- }, `成员 ${form.username} 已创建,初始密码只用于首次登录`)
+ const create = () =>
+ run(
+ 'create',
+ async () => {
+ await createManagedUser(form)
+ setForm({ username: '', display_name: '', role: 'engineer', password: '' })
+ },
+ `成员 ${form.username} 已创建,初始密码只用于首次登录`
+ )
const formValid = /^[a-z0-9][a-z0-9._-]{1,63}$/.test(form.username.trim()) && form.password.length >= 8
- return
- 成员与角色 登录后的一切操作自动记录在成员名下;角色决定谁能审核、封批和行使生产授权。
- {loading ? 加载成员…
: null}
- {error ? {error}
: null}
- {message ? {message}
: null}
- 成员 角色 状态 最近登录 操作
- {users.map((user) => {
- const self = user.id === identity?.id
- return
- {user.display_name || user.username} {user.username}{user.must_change_password ? ' · 待设置新密码' : ''}
- {self ? {memberRoleLabels[user.role]} : (
- void run(user.id, () => setManagedUserRole(user.id, event.target.value as ManagedUser['role']), '角色已更新,该成员需要重新登录')}>
- 观察者 知识工程师 管理员
-
- )}
- {user.state === 'active' ? '启用' : '已停用'}
- {user.last_login_at ? new Date(user.last_login_at).toLocaleString('zh-CN', { hour12: false }) : '从未登录'}
-
- {!self ? {
- const temporary = window.prompt(`为 ${user.username} 生成一个临时密码(至少 8 位,对方首次登录必须更换)`)
- if (temporary) void run(user.id, () => resetManagedUserPassword(user.id, temporary), `${user.username} 的临时密码已生效`)
- }}>重置密码 : null}
- {!self ? void run(user.id, () => setManagedUserState(user.id, user.state === 'active' ? 'disabled' : 'active'), user.state === 'active' ? '账号已停用,其在线会话立即失效' : '账号已重新启用')}>
- {user.state === 'active' ? '停用' : '启用'}
- : 当前登录人 }
-
-
- })}
-
-
-
用户名 setForm((current) => ({ ...current, username: event.target.value.toLowerCase() }))} />
-
姓名 setForm((current) => ({ ...current, display_name: event.target.value }))} />
-
角色 setForm((current) => ({ ...current, role: event.target.value as ManagedUser['role'] }))}>观察者 知识工程师 管理员
-
初始密码(≥8 位,首登强制修改) setForm((current) => ({ ...current, password: event.target.value }))} />
-
void create()}>新增成员
-
- 停用不会删除任何业务记录,只禁止登录并立即撤销其在线会话。
-
+ return (
+
+
+
+
成员与角色
+
登录后的一切操作自动记录在成员名下;角色决定谁能审核、封批和行使生产授权。
+
+
+ {loading ? 加载成员…
: null}
+ {error ? (
+
+ {error}
+
+ ) : null}
+ {message ? (
+
+ {message}
+
+ ) : null}
+
+
+
+
+ 成员
+ 角色
+ 状态
+ 最近登录
+ 操作
+
+
+
+ {users.map((user) => {
+ const self = user.id === identity?.id
+ return (
+
+
+ {user.display_name || user.username}
+
+ {user.username}
+ {user.must_change_password ? ' · 待设置新密码' : ''}
+
+
+
+ {self ? (
+ {memberRoleLabels[user.role]}
+ ) : (
+
+ void run(
+ user.id,
+ () => setManagedUserRole(user.id, event.target.value as ManagedUser['role']),
+ '角色已更新,该成员需要重新登录'
+ )
+ }
+ >
+ 观察者
+ 知识工程师
+ 管理员
+
+ )}
+
+
+
+ {user.state === 'active' ? '启用' : '已停用'}
+
+
+
+ {user.last_login_at
+ ? new Date(user.last_login_at).toLocaleString('zh-CN', { hour12: false })
+ : '从未登录'}
+
+
+
+ {!self ? (
+ {
+ const temporary = window.prompt(
+ `为 ${user.username} 生成一个临时密码(至少 8 位,对方首次登录必须更换)`
+ )
+ if (temporary)
+ void run(
+ user.id,
+ () => resetManagedUserPassword(user.id, temporary),
+ `${user.username} 的临时密码已生效`
+ )
+ }}
+ >
+ 重置密码
+
+ ) : null}
+ {!self ? (
+
+ void run(
+ user.id,
+ () => setManagedUserState(user.id, user.state === 'active' ? 'disabled' : 'active'),
+ user.state === 'active' ? '账号已停用,其在线会话立即失效' : '账号已重新启用'
+ )
+ }
+ >
+ {user.state === 'active' ? '停用' : '启用'}
+
+ ) : (
+ 当前登录人
+ )}
+
+
+
+ )
+ })}
+
+
+
+
+
+ 用户名
+ setForm((current) => ({ ...current, username: event.target.value.toLowerCase() }))}
+ />
+
+
+ 姓名
+ setForm((current) => ({ ...current, display_name: event.target.value }))}
+ />
+
+
+ 角色
+
+ setForm((current) => ({ ...current, role: event.target.value as ManagedUser['role'] }))
+ }
+ >
+ 观察者
+ 知识工程师
+ 管理员
+
+
+
+ 初始密码(≥8 位,首登强制修改)
+ setForm((current) => ({ ...current, password: event.target.value }))}
+ />
+
+
void create()}>
+ 新增成员
+
+
+ 停用不会删除任何业务记录,只禁止登录并立即撤销其在线会话。
+
+ )
}
diff --git a/web/src/PasswordInput.test.tsx b/web/src/PasswordInput.test.tsx
index 2eaff9f..fb78d4f 100644
--- a/web/src/PasswordInput.test.tsx
+++ b/web/src/PasswordInput.test.tsx
@@ -4,14 +4,18 @@ import { PasswordInput } from './PasswordInput'
describe('PasswordInput', () => {
it('masks the field by default and offers a visibility toggle', () => {
- const markup = renderToStaticMarkup( undefined} />)
+ const markup = renderToStaticMarkup(
+ undefined} />
+ )
expect(markup).toContain('type="password"')
expect(markup).toContain('显示密码')
expect(markup).toContain('aria-pressed="false"')
})
it('renders revealed when initiallyRevealed is set', () => {
- const markup = renderToStaticMarkup( undefined} />)
+ const markup = renderToStaticMarkup(
+ undefined} />
+ )
expect(markup).toContain('type="text"')
expect(markup).toContain('隐藏密码')
expect(markup).toContain('aria-pressed="true"')
diff --git a/web/src/ProductionBindingsSection.tsx b/web/src/ProductionBindingsSection.tsx
index 79b8f5d..abcfef6 100644
--- a/web/src/ProductionBindingsSection.tsx
+++ b/web/src/ProductionBindingsSection.tsx
@@ -20,68 +20,135 @@ export function ProductionBindingsSection() {
const [message, setMessage] = useState('')
const load = useCallback(async () => {
- setLoading(true); setError(null)
+ setLoading(true)
+ setError(null)
try {
const next = await fetchProductionBindings()
setBindings(next)
setInternalDraft(next.internal_kb_id)
setExternalDraft(next.external_kb_id)
- try { setOptions(await fetchProductionBindingOptions()) } catch { setOptions([]) }
- } catch (cause) { setError(cause instanceof Error ? cause.message : '读取生产知识库绑定失败') }
- finally { setLoading(false) }
+ try {
+ setOptions(await fetchProductionBindingOptions())
+ } catch {
+ setOptions([])
+ }
+ } catch (cause) {
+ setError(cause instanceof Error ? cause.message : '读取生产知识库绑定失败')
+ } finally {
+ setLoading(false)
+ }
}, [])
- useEffect(() => { void load() }, [load])
+ useEffect(() => {
+ void load()
+ }, [load])
- const dirty = bindings !== null && (internalDraft !== bindings.internal_kb_id || externalDraft !== bindings.external_kb_id)
+ const dirty =
+ bindings !== null && (internalDraft !== bindings.internal_kb_id || externalDraft !== bindings.external_kb_id)
const sameSelection = internalDraft !== '' && internalDraft === externalDraft
const save = async () => {
- setSaving(true); setError(null); setMessage('')
+ setSaving(true)
+ setError(null)
+ setMessage('')
try {
const next = await saveProductionBindings({ internal_kb_id: internalDraft, external_kb_id: externalDraft })
setBindings(next)
const where = `对内 ${next.internal_kb_name || next.internal_kb_id.slice(0, 8) || '未绑定'} · 对外 ${next.external_kb_name || next.external_kb_id.slice(0, 8) || '未绑定'}`
- setMessage(next.production_connected
- ? `发布目标已更新:${where}。之后新发布的“对内/对外”内容会写入这两个生产库(已发布过的条目仍回到原目标)。`
- : `发布目标已保存:${where};但当前核对不了生产端(${next.production_message}),下次发布前请确认。`)
- } catch (cause) { setError(cause instanceof Error ? cause.message : '保存生产知识库绑定失败') }
- finally { setSaving(false) }
+ setMessage(
+ next.production_connected
+ ? `发布目标已更新:${where}。之后新发布的“对内/对外”内容会写入这两个生产库(已发布过的条目仍回到原目标)。`
+ : `发布目标已保存:${where};但当前核对不了生产端(${next.production_message}),下次发布前请确认。`
+ )
+ } catch (cause) {
+ setError(cause instanceof Error ? cause.message : '保存生产知识库绑定失败')
+ } finally {
+ setSaving(false)
+ }
}
const optionLabel = (base: KnowledgeBaseOption) => `${base.name}(${base.knowledge_count} 文档)`
const renderSide = (label: string, boundID: string, boundName: string) => (
{label}
- {boundID ? (boundName || `${boundID.slice(0, 8)}…`) : '未绑定 · 按同 ID 语义发布(目标端须存在同 ID 库)'}
+
+ {boundID ? boundName || `${boundID.slice(0, 8)}…` : '未绑定 · 按同 ID 语义发布(目标端须存在同 ID 库)'}
+
{boundID ? 'UI 绑定 · 发布写入此库' : '未绑定:沿用测试库 ID'}
)
- const editorFor = (value: string, onChange: (next: string) => void, placeholder: string) => (
- options.length > 0
- ? onChange(event.target.value)} aria-label={placeholder}>
- {placeholder}
- {options.map((base) => {optionLabel(base)} )}
-
- : onChange(event.target.value.trim())} placeholder={`${placeholder}(输入生产知识库 ID)`} aria-label={placeholder} />
- )
+ const editorFor = (value: string, onChange: (next: string) => void, placeholder: string) =>
+ options.length > 0 ? (
+ onChange(event.target.value)} aria-label={placeholder}>
+ {placeholder}
+ {options.map((base) => (
+
+ {optionLabel(base)}
+
+ ))}
+
+ ) : (
+ onChange(event.target.value.trim())}
+ placeholder={`${placeholder}(输入生产知识库 ID)`}
+ aria-label={placeholder}
+ />
+ )
- return
- 生产知识库绑定 发布链路的“源库 → 目标库”:新发布的对内 / 对外内容按这里的绑定写入生产 WeKnora 的对应知识库。留空表示未绑定,按同 ID 语义发布。
void load()} disabled={loading}>{loading ? '读取中…' : '重新读取'}
- {error ? {error}
: null}
- {bindings ?
- {renderSide('对内生产知识库', bindings.internal_kb_id, bindings.internal_kb_name)}
- {renderSide('对外生产知识库', bindings.external_kb_id, bindings.external_kb_name)}
-
: null}
- {bindings && !bindings.production_connected ? 生产端核对不可用:{bindings.production_message}。{bindings.production_base_url ? `(生产地址:${bindings.production_base_url})` : ''}
: null}
- {bindings && bindings.production_connected && bindings.production_base_url ? 生产端:{bindings.production_base_url}{bindings.updated_by ? ` · 最近修改:${bindings.updated_by}` : ''}
: null}
- {isAdmin && bindings ?
-
对内目标库 {editorFor(internalDraft, setInternalDraft, '选择对内生产知识库')}
-
对外目标库 {editorFor(externalDraft, setExternalDraft, '选择对外生产知识库')}
-
保存时会向生产端核对所选的库是否真实存在;对不上会拒收,生产端不可达则只提示。
- {sameSelection ?
对内与对外必须绑定不同的生产知识库
: null}
-
void save()} disabled={saving || !dirty || sameSelection}>{saving ? '正在核对并保存…' : '保存发布目标'}
- {message ?
{message}
: null}
-
: null}
- {!isAdmin ? 仅管理员可修改发布目标。
: null}
-
-}
\ No newline at end of file
+ return (
+
+
+
+
生产知识库绑定
+
+ 发布链路的“源库 → 目标库”:新发布的对内 / 对外内容按这里的绑定写入生产 WeKnora
+ 的对应知识库。留空表示未绑定,按同 ID 语义发布。
+
+
+
void load()} disabled={loading}>
+ {loading ? '读取中…' : '重新读取'}
+
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+ {bindings ? (
+
+ {renderSide('对内生产知识库', bindings.internal_kb_id, bindings.internal_kb_name)}
+ {renderSide('对外生产知识库', bindings.external_kb_id, bindings.external_kb_name)}
+
+ ) : null}
+ {bindings && !bindings.production_connected ? (
+
+ 生产端核对不可用:{bindings.production_message}。
+ {bindings.production_base_url ? `(生产地址:${bindings.production_base_url})` : ''}
+
+ ) : null}
+ {bindings && bindings.production_connected && bindings.production_base_url ? (
+
+ 生产端:{bindings.production_base_url}
+ {bindings.updated_by ? ` · 最近修改:${bindings.updated_by}` : ''}
+
+ ) : null}
+ {isAdmin && bindings ? (
+
+
对内目标库 {editorFor(internalDraft, setInternalDraft, '选择对内生产知识库')}
+
对外目标库 {editorFor(externalDraft, setExternalDraft, '选择对外生产知识库')}
+
保存时会向生产端核对所选的库是否真实存在;对不上会拒收,生产端不可达则只提示。
+ {sameSelection ?
对内与对外必须绑定不同的生产知识库
: null}
+
void save()} disabled={saving || !dirty || sameSelection}>
+ {saving ? '正在核对并保存…' : '保存发布目标'}
+
+ {message ? (
+
+ {message}
+
+ ) : null}
+
+ ) : null}
+ {!isAdmin ? 仅管理员可修改发布目标。
: null}
+
+ )
+}
diff --git a/web/src/ReleasePlanner.test.tsx b/web/src/ReleasePlanner.test.tsx
index 87c3ff6..9d378ab 100644
--- a/web/src/ReleasePlanner.test.tsx
+++ b/web/src/ReleasePlanner.test.tsx
@@ -1,20 +1,62 @@
import { renderToStaticMarkup } from 'react-dom/server'
-import { describe,expect,it,vi } from 'vitest'
+import { describe, expect, it, vi } from 'vitest'
import { ReleasePlannerView } from './ReleasePlanner'
import { simulatedReleaseCandidates } from './releasePlanner'
-describe('ReleasePlannerView',()=>{
- it('shows a four-step document release plan with explicit closure and production gates',()=>{
- const html=renderToStaticMarkup( )
- for(const text of ['本地模拟发布准备','1. 选择文档','2. 检查数据闭包','3. 锁定清单','4. 生产负责人授权','[模拟] CR5用户手册.pdf','切片 186','向量 186','Wiki 完整','图谱 完整','闭包完整','文档级:','闭包可独立验证','整知识库:','Wiki或图谱共享','推荐方式','文档级同步','生产发布已关闭','生产备份到OSS:尚未配置'])expect(html).toContain(text)
- expect(html).not.toContain('>授权生产发布')
- })
- it('forces whole knowledge-base mode for shared graph data and blocks failed parsing',()=>{
- const whole=renderToStaticMarkup( )
- expect(whole).toContain('整知识库同步')
- expect(whole).toContain('图谱 共享,需整库')
- const failed=renderToStaticMarkup( )
- expect(failed).toContain('当前清单不能同步')
- expect(failed).toContain('闭包不完整')
- })
+describe('ReleasePlannerView', () => {
+ it('shows a four-step document release plan with explicit closure and production gates', () => {
+ const html = renderToStaticMarkup(
+
+ )
+ for (const text of [
+ '本地模拟发布准备',
+ '1. 选择文档',
+ '2. 检查数据闭包',
+ '3. 锁定清单',
+ '4. 生产负责人授权',
+ '[模拟] CR5用户手册.pdf',
+ '切片 186',
+ '向量 186',
+ 'Wiki 完整',
+ '图谱 完整',
+ '闭包完整',
+ '文档级:',
+ '闭包可独立验证',
+ '整知识库:',
+ 'Wiki或图谱共享',
+ '推荐方式',
+ '文档级同步',
+ '生产发布已关闭',
+ '生产备份到OSS:尚未配置',
+ ])
+ expect(html).toContain(text)
+ expect(html).not.toContain('>授权生产发布')
+ })
+ it('forces whole knowledge-base mode for shared graph data and blocks failed parsing', () => {
+ const whole = renderToStaticMarkup(
+
+ )
+ expect(whole).toContain('整知识库同步')
+ expect(whole).toContain('图谱 共享,需整库')
+ const failed = renderToStaticMarkup(
+
+ )
+ expect(failed).toContain('当前清单不能同步')
+ expect(failed).toContain('闭包不完整')
+ })
})
diff --git a/web/src/ReleasePlanner.tsx b/web/src/ReleasePlanner.tsx
index d7fe553..2e55329 100644
--- a/web/src/ReleasePlanner.tsx
+++ b/web/src/ReleasePlanner.tsx
@@ -1,26 +1,183 @@
import { useState } from 'react'
-import { evaluateReleaseSelection,simulatedReleaseCandidates } from './releasePlanner'
-import type { ClosureState,ReleaseCandidate } from './releasePlanner'
+import { evaluateReleaseSelection, simulatedReleaseCandidates } from './releasePlanner'
+import type { ClosureState, ReleaseCandidate } from './releasePlanner'
import './releasePlanner.css'
-const closure=(value:ClosureState)=>value==='ready'?'完整':value==='shared'?'共享,需整库':value==='missing'?'缺失':'不适用'
-const modeLabel={none:'尚未选择',document:'文档级同步',knowledge_base:'整知识库同步',blocked:'当前清单不能同步'} as const
-interface Props{candidates:ReleaseCandidate[];selectedIDs:string[];onToggle:(id:string)=>void;onClear:()=>void;onGenerate?:()=>void;generated?:boolean}
-export function ReleasePlannerView({candidates,selectedIDs,onToggle,onClear,onGenerate=()=>undefined,generated=false}:Props){
- const selected=candidates.filter(item=>selectedIDs.includes(item.id));const plan=evaluateReleaseSelection(selected);const totals=selected.reduce((sum,item)=>({chunks:sum.chunks+item.chunks,vectors:sum.vectors+item.vectors,assets:sum.assets+item.assets}),{chunks:0,vectors:0,assets:0})
- return
-
- 本地模拟发布准备 候选文档和闭包数量均为模拟,用于确认工作流程;不会连接生产。
- 1. 选择文档 2. 检查数据闭包 3. 锁定清单 4. 生产负责人授权
-
-
测试环境候选文档 解析失败或核心数据缺失的文件不可加入 清空选择
- {candidates.map(item=>{const blocked=item.parse_status!=='completed'||item.chunks<1||item.vectors!==item.chunks||item.assets<1||item.wiki==='missing'||item.graph==='missing';const shared=item.wiki==='shared'||item.graph==='shared';return
onToggle(item.id)} />{item.title} {item.knowledge_base_name} · {item.visibility==='external'?'对外':'对内'} · {item.parse_status}
切片 {item.chunks||'缺失'} 向量 {item.vectors||'缺失'} 文件 {item.assets||'缺失'} Wiki {closure(item.wiki)} 图谱 {closure(item.graph)}
{blocked?'闭包不完整':shared?'共享需整库':'闭包完整'} })}
-
-
本次同步清单 文档级: 闭包可独立验证整知识库: Wiki或图谱共享阻断: 解析失败或数据缺失
推荐方式 {modeLabel[plan.mode]} {plan.reason}
已选文档 {selected.length} 切片 {totals.chunks} 向量 {totals.vectors} 文件资源 {totals.assets} {plan.mode==='knowledge_base'?检测到共享Wiki或图谱关系,将同步整个“{selected[0]?.knowledge_base_name}”,不会覆盖整个WeKnora数据库。
:null}生产备份到OSS:尚未配置 生产授权:关闭 Release Worker:关闭
{generated?'模拟清单已生成':'生成模拟清单'} 模拟清单不会锁定真实数据,也不会创建生产任务。
-
-
+const closure = (value: ClosureState) =>
+ value === 'ready' ? '完整' : value === 'shared' ? '共享,需整库' : value === 'missing' ? '缺失' : '不适用'
+const modeLabel = {
+ none: '尚未选择',
+ document: '文档级同步',
+ knowledge_base: '整知识库同步',
+ blocked: '当前清单不能同步',
+} as const
+interface Props {
+ candidates: ReleaseCandidate[]
+ selectedIDs: string[]
+ onToggle: (id: string) => void
+ onClear: () => void
+ onGenerate?: () => void
+ generated?: boolean
}
-export function ReleasePlanner(){
- const [selectedIDs,setSelectedIDs]=useState([]);const [generated,setGenerated]=useState(false)
- const toggle=(id:string)=>{setGenerated(false);setSelectedIDs(current=>current.includes(id)?current.filter(value=>value!==id):[...current,id])}
- return {setSelectedIDs([]);setGenerated(false)}} onGenerate={()=>setGenerated(true)} generated={generated} />
+export function ReleasePlannerView({
+ candidates,
+ selectedIDs,
+ onToggle,
+ onClear,
+ onGenerate = () => undefined,
+ generated = false,
+}: Props) {
+ const selected = candidates.filter((item) => selectedIDs.includes(item.id))
+ const plan = evaluateReleaseSelection(selected)
+ const totals = selected.reduce(
+ (sum, item) => ({
+ chunks: sum.chunks + item.chunks,
+ vectors: sum.vectors + item.vectors,
+ assets: sum.assets + item.assets,
+ }),
+ { chunks: 0, vectors: 0, assets: 0 }
+ )
+ return (
+
+
+
+ 本地模拟发布准备
+ 候选文档和闭包数量均为模拟,用于确认工作流程;不会连接生产。
+
+
+ 1. 选择文档
+ 2. 检查数据闭包
+ 3. 锁定清单
+ 4. 生产负责人授权
+
+
+
+
+
+
测试环境候选文档
+ 解析失败或核心数据缺失的文件不可加入
+
+
+ 清空选择
+
+
+
+ {candidates.map((item) => {
+ const blocked =
+ item.parse_status !== 'completed' ||
+ item.chunks < 1 ||
+ item.vectors !== item.chunks ||
+ item.assets < 1 ||
+ item.wiki === 'missing' ||
+ item.graph === 'missing'
+ const shared = item.wiki === 'shared' || item.graph === 'shared'
+ return (
+
+ onToggle(item.id)}
+ />
+
+ {item.title}
+
+ {item.knowledge_base_name} · {item.visibility === 'external' ? '对外' : '对内'} ·{' '}
+ {item.parse_status}
+
+
+
+ 切片 {item.chunks || '缺失'}
+
+ 向量 {item.vectors || '缺失'}
+
+ 文件 {item.assets || '缺失'}
+ Wiki {closure(item.wiki)}
+ 图谱 {closure(item.graph)}
+
+
+ {blocked ? '闭包不完整' : shared ? '共享需整库' : '闭包完整'}
+
+
+ )
+ })}
+
+
+
+ 本次同步清单
+
+
+ 文档级: 闭包可独立验证
+
+
+ 整知识库: Wiki或图谱共享
+
+
+ 阻断: 解析失败或数据缺失
+
+
+
+ 推荐方式
+ {modeLabel[plan.mode]}
+ {plan.reason}
+
+
+ 已选文档
+ {selected.length}
+ 切片
+ {totals.chunks}
+ 向量
+ {totals.vectors}
+ 文件资源
+ {totals.assets}
+
+ {plan.mode === 'knowledge_base' ? (
+
+ 检测到共享Wiki或图谱关系,将同步整个“{selected[0]?.knowledge_base_name}”,不会覆盖整个WeKnora数据库。
+
+ ) : null}
+
+ 生产备份到OSS:尚未配置
+ 生产授权:关闭
+ Release Worker:关闭
+
+
+ {generated ? '模拟清单已生成' : '生成模拟清单'}
+
+ 模拟清单不会锁定真实数据,也不会创建生产任务。
+
+
+
+ )
+}
+export function ReleasePlanner() {
+ const [selectedIDs, setSelectedIDs] = useState([])
+ const [generated, setGenerated] = useState(false)
+ const toggle = (id: string) => {
+ setGenerated(false)
+ setSelectedIDs((current) => (current.includes(id) ? current.filter((value) => value !== id) : [...current, id]))
+ }
+ return (
+ {
+ setSelectedIDs([])
+ setGenerated(false)
+ }}
+ onGenerate={() => setGenerated(true)}
+ generated={generated}
+ />
+ )
}
diff --git a/web/src/ReleaseTargets.tsx b/web/src/ReleaseTargets.tsx
index 3859bbb..6b32525 100644
--- a/web/src/ReleaseTargets.tsx
+++ b/web/src/ReleaseTargets.tsx
@@ -304,6 +304,8 @@ export interface MaintenanceSummary {
releasedAt: string
progress: string
recoveryError: string
+ // 作业失败但维护窗口已释放:只保留失败记录,不能再说"目标保持维护"。
+ releasedFailure: string
recoveryNote: string
canRecover: boolean
}
@@ -352,7 +354,9 @@ export function maintenanceSummary(
acquiredAt: record?.acquired_at ? stamp(record.acquired_at) : '—',
releasedAt: record?.released_at ? stamp(record.released_at) : '',
progress: job ? `${stageLabel[job.stage] ?? job.stage ?? '—'} · ${jobStateText(job)}` : '尚无作业进度',
- recoveryError: failed ? job?.error || '作业失败但没有留下错误明细' : '',
+ // 只有仍然 held 的失败才配 held 处置说明;窗口已释放时那段文案是假的。
+ recoveryError: failed && held ? job?.error || '作业失败但没有留下错误明细' : '',
+ releasedFailure: failed && !held ? job?.error || '作业失败但没有留下错误明细' : '',
recoveryNote: job?.recovery || '',
canRecover: held && (failed || !job || job.state === 'cancelled'),
}
@@ -439,6 +443,14 @@ export function MaintenanceCard({
进度:{summary.progress}
{summary.releasedAt ? ` · 窗口已于 ${summary.releasedAt} 释放` : ''}
+ {summary.releasedFailure ? (
+
+
作业失败(维护窗口已释放)
+
{summary.releasedFailure}
+ {summary.recoveryNote ?
处理记录:{summary.recoveryNote}
: null}
+
窗口已释放、入口{summary.ingress}:这里是历史失败记录,不再按 held 处置,也不提供强制成功。
+
+ ) : null}
{summary.recoveryError ? (
恢复错误
diff --git a/web/src/RoutingBindingsSection.tsx b/web/src/RoutingBindingsSection.tsx
index 3b5322d..cf14e65 100644
--- a/web/src/RoutingBindingsSection.tsx
+++ b/web/src/RoutingBindingsSection.tsx
@@ -17,67 +17,150 @@ export function RoutingBindingsSection() {
const [message, setMessage] = useState('')
const load = useCallback(async () => {
- setLoading(true); setError(null)
+ setLoading(true)
+ setError(null)
try {
const next = await fetchRoutingBindings()
setBindings(next)
setInternalDraft(next.internal_bound_by_ui ? next.internal_kb_id : '')
setExternalDraft(next.external_bound_by_ui ? next.external_kb_id : '')
- try { setOptions(await fetchRoutingBindingOptions()) } catch { setOptions([]) }
- } catch (cause) { setError(cause instanceof Error ? cause.message : '读取测试知识库绑定失败') }
- finally { setLoading(false) }
+ try {
+ setOptions(await fetchRoutingBindingOptions())
+ } catch {
+ setOptions([])
+ }
+ } catch (cause) {
+ setError(cause instanceof Error ? cause.message : '读取测试知识库绑定失败')
+ } finally {
+ setLoading(false)
+ }
}, [])
- useEffect(() => { void load() }, [load])
+ useEffect(() => {
+ void load()
+ }, [load])
- const dirty = bindings !== null && (internalDraft !== (bindings.internal_bound_by_ui ? bindings.internal_kb_id : '') || externalDraft !== (bindings.external_bound_by_ui ? bindings.external_kb_id : ''))
+ const dirty =
+ bindings !== null &&
+ (internalDraft !== (bindings.internal_bound_by_ui ? bindings.internal_kb_id : '') ||
+ externalDraft !== (bindings.external_bound_by_ui ? bindings.external_kb_id : ''))
const sameSelection = internalDraft !== '' && internalDraft === externalDraft
const save = async () => {
- setSaving(true); setError(null); setMessage('')
+ setSaving(true)
+ setError(null)
+ setMessage('')
try {
const next = await saveRoutingBindings({ internal_kb_id: internalDraft, external_kb_id: externalDraft })
setBindings(next)
- const replan = next.replanned || next.retired_duplicates
- ? `;同时把 ${next.replanned} 项还没上传的待入库重新指向新库${next.retired_duplicates ? `,${next.retired_duplicates} 项因新库里已有同篇而被退休` : ''}`
- : ''
- setMessage(`绑定已生效:对内 ${next.internal_kb_name || next.internal_kb_id.slice(0, 8) || '未绑定'} · 对外 ${next.external_kb_name || next.external_kb_id.slice(0, 8) || '未绑定'}。此后“来源与目录”的标记按新绑定路由${replan}。`)
- } catch (cause) { setError(cause instanceof Error ? cause.message : '保存测试知识库绑定失败') }
- finally { setSaving(false) }
+ const replan =
+ next.replanned || next.retired_duplicates
+ ? `;同时把 ${next.replanned} 项还没上传的待入库重新指向新库${next.retired_duplicates ? `,${next.retired_duplicates} 项因新库里已有同篇而被退休` : ''}`
+ : ''
+ setMessage(
+ `绑定已生效:对内 ${next.internal_kb_name || next.internal_kb_id.slice(0, 8) || '未绑定'} · 对外 ${next.external_kb_name || next.external_kb_id.slice(0, 8) || '未绑定'}。此后“来源与目录”的标记按新绑定路由${replan}。`
+ )
+ } catch (cause) {
+ setError(cause instanceof Error ? cause.message : '保存测试知识库绑定失败')
+ } finally {
+ setSaving(false)
+ }
}
- const optionLabel = (base: KnowledgeBaseOption) => `${base.name}(${base.knowledge_count} 文档 · ${base.id.slice(0, 8)}…)`
+ const optionLabel = (base: KnowledgeBaseOption) =>
+ `${base.name}(${base.knowledge_count} 文档 · ${base.id.slice(0, 8)}…)`
const renderSide = (label: string, boundID: string, boundName: string, fromEnv: boolean, boundByUI: boolean) => (
{label}
- {boundID ? (boundName || `${boundID.slice(0, 8)}…`) : '未绑定 · 该方向的标记会被拒绝'}
- {boundID ? (boundByUI ? (boundName ? `UI 绑定 · ${boundID.slice(0, 8)}…` : 'UI 绑定') : fromEnv ? '来自部署默认值(env)' : '') : '请绑定一个测试知识库'}
+ {boundID ? boundName || `${boundID.slice(0, 8)}…` : '未绑定 · 该方向的标记会被拒绝'}
+
+ {boundID
+ ? boundByUI
+ ? boundName
+ ? `UI 绑定 · ${boundID.slice(0, 8)}…`
+ : 'UI 绑定'
+ : fromEnv
+ ? '来自部署默认值(env)'
+ : ''
+ : '请绑定一个测试知识库'}
+
)
- const editorFor = (value: string, onChange: (next: string) => void, placeholder: string) => (
- options.length > 0
- ?
onChange(event.target.value)} aria-label={placeholder}>
- {placeholder}
- {options.map((base) => {optionLabel(base)} )}
-
- :
onChange(event.target.value.trim())} placeholder={`${placeholder}(输入知识库 ID)`} aria-label={placeholder} />
- )
+ const editorFor = (value: string, onChange: (next: string) => void, placeholder: string) =>
+ options.length > 0 ? (
+
onChange(event.target.value)} aria-label={placeholder}>
+ {placeholder}
+ {options.map((base) => (
+
+ {optionLabel(base)}
+
+ ))}
+
+ ) : (
+
onChange(event.target.value.trim())}
+ placeholder={`${placeholder}(输入知识库 ID)`}
+ aria-label={placeholder}
+ />
+ )
- return
- 测试知识库绑定 “来源与目录”里的对内 / 对外标记按这里的绑定分发到对应的测试 WeKnora 知识库;保存后立即生效,无需重启。发布写入的生产知识库见「生产知识库绑定」。
void load()} disabled={loading}>{loading ? '读取中…' : '重新读取'}
- {error ? {error}
: null}
- {bindings ?
- {renderSide('对内知识库', bindings.internal_kb_id, bindings.internal_kb_name, bindings.internal_env_fallback, bindings.internal_bound_by_ui)}
- {renderSide('对外知识库', bindings.external_kb_id, bindings.external_kb_name, bindings.external_env_fallback, bindings.external_bound_by_ui)}
-
: null}
- {bindings && !bindings.weknora_connected ? 当前读不到 WeKnora 的库列表,只能按 ID 填写;连接恢复后可改为下拉选择。
: null}
- {isAdmin && bindings ?
-
对内绑定 {editorFor(internalDraft, setInternalDraft, '选择对内知识库')}
-
对外绑定 {editorFor(externalDraft, setExternalDraft, '选择对外知识库')}
-
留空 = 取消 UI 绑定,回退到部署默认值;对内与对外必须是不同的库。
- {sameSelection ?
对内与对外必须绑定不同的知识库
: null}
-
void save()} disabled={saving || !dirty || sameSelection}>{saving ? '正在校验并保存…' : '保存绑定'}
- {message ?
{message}
: null}
-
: null}
- {!isAdmin ? 仅管理员可修改测试知识库绑定。
: null}
-
+ return (
+
+
+
+
测试知识库绑定
+
+ “来源与目录”里的对内 / 对外标记按这里的绑定分发到对应的测试 WeKnora
+ 知识库;保存后立即生效,无需重启。发布写入的生产知识库见「生产知识库绑定」。
+
+
+
void load()} disabled={loading}>
+ {loading ? '读取中…' : '重新读取'}
+
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+ {bindings ? (
+
+ {renderSide(
+ '对内知识库',
+ bindings.internal_kb_id,
+ bindings.internal_kb_name,
+ bindings.internal_env_fallback,
+ bindings.internal_bound_by_ui
+ )}
+ {renderSide(
+ '对外知识库',
+ bindings.external_kb_id,
+ bindings.external_kb_name,
+ bindings.external_env_fallback,
+ bindings.external_bound_by_ui
+ )}
+
+ ) : null}
+ {bindings && !bindings.weknora_connected ? (
+ 当前读不到 WeKnora 的库列表,只能按 ID 填写;连接恢复后可改为下拉选择。
+ ) : null}
+ {isAdmin && bindings ? (
+
+
对内绑定 {editorFor(internalDraft, setInternalDraft, '选择对内知识库')}
+
对外绑定 {editorFor(externalDraft, setExternalDraft, '选择对外知识库')}
+
留空 = 取消 UI 绑定,回退到部署默认值;对内与对外必须是不同的库。
+ {sameSelection ?
对内与对外必须绑定不同的知识库
: null}
+
void save()} disabled={saving || !dirty || sameSelection}>
+ {saving ? '正在校验并保存…' : '保存绑定'}
+
+ {message ? (
+
+ {message}
+
+ ) : null}
+
+ ) : null}
+ {!isAdmin ? 仅管理员可修改测试知识库绑定。
: null}
+
+ )
}
diff --git a/web/src/ScopeRuleEditor.test.tsx b/web/src/ScopeRuleEditor.test.tsx
index 091e102..5437304 100644
--- a/web/src/ScopeRuleEditor.test.tsx
+++ b/web/src/ScopeRuleEditor.test.tsx
@@ -2,18 +2,48 @@ import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it, vi } from 'vitest'
import { ScopeRuleEditor } from './ScopeRuleEditor'
-describe('ScopeRuleEditor',()=>{
- it('explains directory inheritance and keeps production publication separate',()=>{
- const html=renderToStaticMarkup(
)
- for(const text of ['此目录及子目录','当前设置:对内','由 engineer 设置','移除本级设置,恢复继承','不会自动发布到生产']) expect(html).toContain(text)
- expect(html).not.toContain('操作人
{
- const html=renderToStaticMarkup(
)
- expect(html).toContain('整个来源')
- expect(html).toContain('当前没有本级设置')
- expect(html).toContain('拟修改为')
- expect(html).toContain('选择新范围')
- expect(html).not.toContain('移除本级设置')
- })
+describe('ScopeRuleEditor', () => {
+ it('explains directory inheritance and keeps production publication separate', () => {
+ const html = renderToStaticMarkup(
+
+ )
+ for (const text of [
+ '此目录及子目录',
+ '当前设置:对内',
+ '由 engineer 设置',
+ '移除本级设置,恢复继承',
+ '不会自动发布到生产',
+ ])
+ expect(html).toContain(text)
+ expect(html).not.toContain('操作人
{
+ const html = renderToStaticMarkup(
+
+ )
+ expect(html).toContain('整个来源')
+ expect(html).toContain('当前没有本级设置')
+ expect(html).toContain('拟修改为')
+ expect(html).toContain('选择新范围')
+ expect(html).not.toContain('移除本级设置')
+ })
})
diff --git a/web/src/ScopeRuleEditor.tsx b/web/src/ScopeRuleEditor.tsx
index 9b66843..4cb9226 100644
--- a/web/src/ScopeRuleEditor.tsx
+++ b/web/src/ScopeRuleEditor.tsx
@@ -2,30 +2,78 @@ import { useState } from 'react'
import type { ClearScopeRuleDecision, ScopeRule, ScopeRuleDecision, ScopeRuleTarget, Visibility } from './scopeRules'
interface Props {
- target: ScopeRuleTarget
- currentRule?: ScopeRule
- saving: boolean
- error?: string|null
- onSave: (decision:ScopeRuleDecision)=>void
- onClear: (decision:ClearScopeRuleDecision)=>void
+ target: ScopeRuleTarget
+ currentRule?: ScopeRule
+ saving: boolean
+ error?: string | null
+ onSave: (decision: ScopeRuleDecision) => void
+ onClear: (decision: ClearScopeRuleDecision) => void
}
-export function ScopeRuleEditor({target,currentRule,saving,error,onSave,onClear}:Props){
- const [visibility,setVisibility]=useState
(currentRule?.visibility||'')
- const [reason,setReason]=useState('')
- const reasonReady=reason.trim()!==''&&!saving
- const saveReady=reasonReady&&visibility!==''
- const label=target.target_type==='item'?'仅此文件':target.directory_path?'此目录及子目录':'整个来源'
- const base={...target,reason:reason.trim()}
- return
- 对内/对外设置 {label}
{currentRule?
当前设置:{currentRule.visibility==='external'?'对外':'对内'} :
当前没有本级设置 }
- {currentRule?由 {currentRule.updated_by} 设置 · 原因:{currentRule.reason}
:当前继承上级规则或来源原始标记。
}
-
- 拟修改为setVisibility(event.target.value as Visibility|'')}>选择新范围 对内 对外
- 判断原因 setReason(event.target.value)} />
-
- {error?{error}
:null}
- onSave({...base,visibility:visibility as Visibility})}>{saving?'保存中…':'保存范围'} {currentRule?onClear(base)}>移除本级设置,恢复继承 :null}
- 这里只更新治理规则并记录审计,操作自动记在当前登录人名下;不会修改源文件,也不会自动发布到生产。
-
+export function ScopeRuleEditor({ target, currentRule, saving, error, onSave, onClear }: Props) {
+ const [visibility, setVisibility] = useState(currentRule?.visibility || '')
+ const [reason, setReason] = useState('')
+ const reasonReady = reason.trim() !== '' && !saving
+ const saveReady = reasonReady && visibility !== ''
+ const label = target.target_type === 'item' ? '仅此文件' : target.directory_path ? '此目录及子目录' : '整个来源'
+ const base = { ...target, reason: reason.trim() }
+ return (
+
+
+
+ 对内/对外设置
+ {label}
+
+ {currentRule ? (
+
+ 当前设置:{currentRule.visibility === 'external' ? '对外' : '对内'}
+
+ ) : (
+
当前没有本级设置
+ )}
+
+ {currentRule ? (
+
+ 由 {currentRule.updated_by} 设置 · 原因:{currentRule.reason}
+
+ ) : (
+ 当前继承上级规则或来源原始标记。
+ )}
+
+
+ 拟修改为
+ setVisibility(event.target.value as Visibility | '')}>
+ 选择新范围
+ 对内
+ 对外
+
+
+
+ 判断原因
+ setReason(event.target.value)}
+ />
+
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+ onSave({ ...base, visibility: visibility as Visibility })}>
+ {saving ? '保存中…' : '保存范围'}
+
+ {currentRule ? (
+ onClear(base)}>
+ 移除本级设置,恢复继承
+
+ ) : null}
+
+ 这里只更新治理规则并记录审计,操作自动记在当前登录人名下;不会修改源文件,也不会自动发布到生产。
+
+ )
}
diff --git a/web/src/SnapshotHistory.test.tsx b/web/src/SnapshotHistory.test.tsx
index 5693d02..8b52025 100644
--- a/web/src/SnapshotHistory.test.tsx
+++ b/web/src/SnapshotHistory.test.tsx
@@ -1,34 +1,115 @@
import { renderToStaticMarkup } from 'react-dom/server'
-import { describe,expect,it,vi } from 'vitest'
+import { describe, expect, it, vi } from 'vitest'
import { SnapshotHistoryView } from './SnapshotHistory'
import type { CatalogSnapshot } from './sourceCatalog'
-const head:CatalogSnapshot={snapshot_id:'ss-new',connection_id:'conn-1',root_node_id:'root',source_name:'国内工业',node_count:2,folder_count:1,file_count:1,observed_at:'2026-09-11T00:00:00Z',created_at:'2026-09-11T00:00:05Z',is_head:true}
-const older:CatalogSnapshot={...head,snapshot_id:'ss-old',observed_at:'2026-09-10T00:00:00Z',is_head:false}
-const fileNode={node_id:'file-old',parent_node_id:'root',node_type:'file' as const,name:'手册.pdf',direct_child_count:0,subtree_file_count:1,subtree_bytes:20,size_bytes:20,modified_at:'2026-09-09T00:00:00Z',content_sha256:'ab'.repeat(32),visibility:'unknown' as const,imported_count:0,parsed_count:0,active_count:0,failed_count:0,routable_count:0}
-const page={current:{node_id:'root',node_type:'folder' as const,name:'国内工业',direct_child_count:1,subtree_file_count:1,subtree_bytes:20,visibility:'unknown' as const,imported_count:0,parsed_count:0,active_count:0,failed_count:0,routable_count:0},breadcrumbs:[{node_id:'root',name:'国内工业'}],items:[fileNode],total:1,limit:50,offset:0}
+const head: CatalogSnapshot = {
+ snapshot_id: 'ss-new',
+ connection_id: 'conn-1',
+ root_node_id: 'root',
+ source_name: '国内工业',
+ node_count: 2,
+ folder_count: 1,
+ file_count: 1,
+ observed_at: '2026-09-11T00:00:00Z',
+ created_at: '2026-09-11T00:00:05Z',
+ is_head: true,
+}
+const older: CatalogSnapshot = { ...head, snapshot_id: 'ss-old', observed_at: '2026-09-10T00:00:00Z', is_head: false }
+const fileNode = {
+ node_id: 'file-old',
+ parent_node_id: 'root',
+ node_type: 'file' as const,
+ name: '手册.pdf',
+ direct_child_count: 0,
+ subtree_file_count: 1,
+ subtree_bytes: 20,
+ size_bytes: 20,
+ modified_at: '2026-09-09T00:00:00Z',
+ content_sha256: 'ab'.repeat(32),
+ visibility: 'unknown' as const,
+ imported_count: 0,
+ parsed_count: 0,
+ active_count: 0,
+ failed_count: 0,
+ routable_count: 0,
+}
+const page = {
+ current: {
+ node_id: 'root',
+ node_type: 'folder' as const,
+ name: '国内工业',
+ direct_child_count: 1,
+ subtree_file_count: 1,
+ subtree_bytes: 20,
+ visibility: 'unknown' as const,
+ imported_count: 0,
+ parsed_count: 0,
+ active_count: 0,
+ failed_count: 0,
+ routable_count: 0,
+ },
+ breadcrumbs: [{ node_id: 'root', name: '国内工业' }],
+ items: [fileNode],
+ total: 1,
+ limit: 50,
+ offset: 0,
+}
-describe('SnapshotHistoryView',()=>{
- it('lists snapshot history, flags head and opens a historical snapshot read-only',()=>{
- const html=renderToStaticMarkup( )
- expect(html).toContain('历史快照')
- expect(html).toContain('当前')
- expect(html).toContain('只读')
- expect(html).toContain('手册.pdf')
- expect(html).toContain('内容Hash')
- expect(html).toContain('abababababab')
- // hash is displayable but never turns into an interactive policy checkbox/button
- expect(html).not.toContain('checkbox')
- expect(html).not.toContain('policy-button')
- })
- it('shows 内容Hash未获取 instead of faking a hash',()=>{
- const missing={...page,items:[{...fileNode,content_sha256:undefined}]}
- const html=renderToStaticMarkup( )
- expect(html).toContain('内容Hash未获取')
- })
- it('lists snapshots without an active page and offers opening each one',()=>{
- const html=renderToStaticMarkup( )
- expect((html.match(/查看该快照/g)||[]).length).toBe(2)
- expect(html).toContain('份文件')
- })
+describe('SnapshotHistoryView', () => {
+ it('lists snapshot history, flags head and opens a historical snapshot read-only', () => {
+ const html = renderToStaticMarkup(
+
+ )
+ expect(html).toContain('历史快照')
+ expect(html).toContain('当前')
+ expect(html).toContain('只读')
+ expect(html).toContain('手册.pdf')
+ expect(html).toContain('内容Hash')
+ expect(html).toContain('abababababab')
+ // hash is displayable but never turns into an interactive policy checkbox/button
+ expect(html).not.toContain('checkbox')
+ expect(html).not.toContain('policy-button')
+ })
+ it('shows 内容Hash未获取 instead of faking a hash', () => {
+ const missing = { ...page, items: [{ ...fileNode, content_sha256: undefined }] }
+ const html = renderToStaticMarkup(
+
+ )
+ expect(html).toContain('内容Hash未获取')
+ })
+ it('lists snapshots without an active page and offers opening each one', () => {
+ const html = renderToStaticMarkup(
+
+ )
+ expect((html.match(/查看该快照/g) || []).length).toBe(2)
+ expect(html).toContain('份文件')
+ })
})
diff --git a/web/src/SnapshotHistory.tsx b/web/src/SnapshotHistory.tsx
index 4ff1513..71e9ef1 100644
--- a/web/src/SnapshotHistory.tsx
+++ b/web/src/SnapshotHistory.tsx
@@ -1,32 +1,107 @@
import type { CatalogNodePage, CatalogSnapshot } from './sourceCatalog'
import './sourceCatalogBrowser.css'
-const stamp=(value:string)=>new Date(value).toLocaleString('zh-CN',{hour12:false})
-const shortHash=(value:string)=>`${value.slice(0,12)}…${value.slice(-8)}`
+const stamp = (value: string) => new Date(value).toLocaleString('zh-CN', { hour12: false })
+const shortHash = (value: string) => `${value.slice(0, 12)}…${value.slice(-8)}`
-interface Props{snapshots:CatalogSnapshot[];active:CatalogSnapshot|null;page:CatalogNodePage|null;loading:boolean;error:string|null
- onClose:()=>void;onOpenSnapshot:(snapshot:CatalogSnapshot)=>void;onOpenFolder:(snapshot:CatalogSnapshot,nodeID:string)=>void}
-
-export function SnapshotHistoryView({snapshots,active,page,loading,error,onClose,onOpenSnapshot,onOpenFolder}:Props){
- return
-
- {error?{error}
:null}
-
- {loading?正在读取历史快照目录…
:null}
- {active&&page?{active.source_name} 只读历史快照
-
名称 类型 大小 更新时间 内容Hash
- {page.items.map(node=>
- {node.node_type==='folder'?onOpenFolder(active,node.node_id)}>📁 {node.name} :<>📄 {node.name}>}
- {node.node_type==='folder'?'文件夹':'文件'}
- {typeof node.size_bytes==='number'?`${node.size_bytes} B`:'—'}
- {node.modified_at?stamp(node.modified_at):'—'}
- {node.content_sha256?shortHash(node.content_sha256):'内容Hash未获取'}
- )}
- {page.total>page.limit?
共 {page.total} 项 · 每页 {page.limit} 项
:null}
-
:null}
- {!loading&&active&&!page&&!error?该快照暂无可见内容。
:null}
-
+interface Props {
+ snapshots: CatalogSnapshot[]
+ active: CatalogSnapshot | null
+ page: CatalogNodePage | null
+ loading: boolean
+ error: string | null
+ onClose: () => void
+ onOpenSnapshot: (snapshot: CatalogSnapshot) => void
+ onOpenFolder: (snapshot: CatalogSnapshot, nodeID: string) => void
+}
+
+export function SnapshotHistoryView({
+ snapshots,
+ active,
+ page,
+ loading,
+ error,
+ onClose,
+ onOpenSnapshot,
+ onOpenFolder,
+}: Props) {
+ return (
+
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+ {loading ? 正在读取历史快照目录…
: null}
+ {active && page ? (
+
+
+ {active.source_name}
+ 只读历史快照
+
+
+
+
+ 名称
+ 类型
+ 大小
+ 更新时间
+ 内容Hash
+
+
+
+ {page.items.map((node) => (
+
+
+ {node.node_type === 'folder' ? (
+ onOpenFolder(active, node.node_id)}>
+ 📁 {node.name}
+
+ ) : (
+ <>📄 {node.name}>
+ )}
+
+ {node.node_type === 'folder' ? '文件夹' : '文件'}
+ {typeof node.size_bytes === 'number' ? `${node.size_bytes} B` : '—'}
+ {node.modified_at ? stamp(node.modified_at) : '—'}
+ {node.content_sha256 ? shortHash(node.content_sha256) : '内容Hash未获取'}
+
+ ))}
+
+
+ {page.total > page.limit ? (
+
+
+ 共 {page.total} 项 · 每页 {page.limit} 项
+
+
+ ) : null}
+
+ ) : null}
+ {!loading && active && !page && !error ? 该快照暂无可见内容。
: null}
+
+ )
}
diff --git a/web/src/SourceCatalogBrowser.test.tsx b/web/src/SourceCatalogBrowser.test.tsx
index c5d7610..e6d76be 100644
--- a/web/src/SourceCatalogBrowser.test.tsx
+++ b/web/src/SourceCatalogBrowser.test.tsx
@@ -1,141 +1,535 @@
import { renderToStaticMarkup } from 'react-dom/server'
-import { describe,expect,it,vi } from 'vitest'
+import { describe, expect, it, vi } from 'vitest'
import { SourceCatalogBrowserView } from './SourceCatalogBrowser'
-import type { CatalogNode,CatalogNodePage,CatalogSource } from './sourceCatalog'
+import type { CatalogNode, CatalogNodePage, CatalogSource } from './sourceCatalog'
import type { Job } from './jobs'
-const source:CatalogSource={connection_id:'conn-1',source_type:'dingtalk',name:'公司级技术支持知识库',root_node_id:'root-1',folder_count:1080,file_count:3468,observed_at:'2026-09-08T00:00:00Z',snapshot_only:true,snapshot_status:'ready',last_refresh_duration_ms:12000}
-const node=(name:string,type:'folder'|'file',id:string)=>({node_id:id,parent_node_id:'root-1',node_type:type,name,direct_child_count:type==='folder'?2:0,subtree_file_count:type==='folder'?10:1,subtree_bytes:1024,size_bytes:type==='file'?1024:undefined,visibility:type==='folder'?'internal' as const:'unknown' as const,imported_count:type==='folder'?8:0,parsed_count:type==='folder'?7:0,active_count:1,failed_count:0,routable_count:type==='file'?1:8})
-const page:CatalogNodePage={current:{...node('公司级技术支持知识库','folder','root-1'),parent_node_id:undefined},breadcrumbs:[{node_id:'root-1',name:'公司级技术支持知识库'}],items:[node('01|产品总览与定位','folder','folder-1'),node('根目录说明.pdf','file','file-1')],total:2,limit:50,offset:0}
-
-describe('SourceCatalogBrowserView',()=>{
-
- it('shows a source that is still doing its first scan, with progress',()=>{
- const scanning:CatalogSource={...source,connection_id:'conn-scan',name:'公司级营销资料管理',root_node_id:'',folder_count:1153,file_count:1428,snapshot_status:'scanning',scan_progress:36,scan_message:'正在扫描目录:已完成 470 个文件夹,待处理 683 个',last_refresh_duration_ms:0}
- const html=renderToStaticMarkup( )
- // 新来源不必等快照落地就出现在列表里,并且带百分比。
- for(const text of ['公司级营销资料管理','首次扫描中 36%','首次扫描中 · 36%','正在扫描目录:已完成 470 个文件夹,待处理 683 个','已发现约 1428 份文件'])expect(html).toContain(text)
- // 还没有目录:不渲染空目录、也不提供标注/格式筛选入口。
- expect(html).not.toContain('当前目录为空。')
- expect(html).not.toContain('禁止导入')
- })
-
- it('uses a Windows-style path bar instead of a directory tree',()=>{
- const html=renderToStaticMarkup( )
- expect(html).toContain('aria-label="当前目录路径"')
- expect(html).toContain('aria-label="目录面包屑"')
- expect(html).toContain('aria-label="返回上一级"')
- expect(html).not.toContain('role="tree"')
- expect(html).not.toContain('目录树')
- for(const text of ['禁止导入即生效','大小 ','大小未提供','总大小','已知合计'])expect(html).not.toContain(text)
- expect(html).toContain('')
- expect(html).toContain('文件格式筛选 ')
- expect(html).not.toMatch(/]*class="catalog-format-filter"[^>]*open/)
- })
-
- it('looks like a folder browser and shows only direct children',()=>{
- const html=renderToStaticMarkup( )
- for(const text of ['各个知识源头的资料总览与标注','公司级技术支持知识库','01|产品总览与定位','文件夹','已解析 7 / 已导入 8','本地只读快照','含所有子目录文件'])expect(html).toContain(text)
- expect(html).not.toContain('真实目录')
- expect(html).toContain('根目录说明.pdf')
- expect(html).not.toContain('显示根目录文件')
- expect(html).toContain('aria-label="当前目录路径"')
- expect(html).not.toContain('第 1 / 1 页')
- expect(html).not.toContain('PP分类 · 不代表完整原始目录')
- expect(html).toContain('快照时间')
- expect(html).toContain('快照就绪')
- expect(html).toContain('上次刷新耗时 12秒')
- expect(html).toContain('实时刷新目录')
- expect(html).not.toContain('aria-label="处理策略01|产品总览与定位"')
- expect(html).not.toContain('递归作用于全部子目录')
- })
- it('shows current directory, discovered file counts and the estimate marker during refresh',()=>{
- const job:Job={id:'job-scan',type:'source.catalog.refresh',payload:{connection_id:'conn-1',stage:'scanning',progress:43,message:'正在扫描目录:已完成 125 个文件夹,待处理 55 个',processed:125,total:180,current_directory:'产品资料/CR系列',scanned_files:2438,estimated_total_files:5662,total_is_estimate:true},state:'running',priority:20,available_at:'2026-09-09T00:00:00Z',attempt:1,max_attempts:3,idempotency_key:'safe',created_at:'2026-09-09T00:00:00Z',updated_at:'2026-09-09T00:00:00Z'}
- const html=renderToStaticMarkup( )
- expect(html).toContain('当前目录:产品资料/CR系列')
- expect(html).toContain('已发现 2438 份文件')
- expect(html).toContain('(估算)')
- })
- it('hides the running refresh to a compact pill that expands back on click without blocking browsing',()=>{
- const job:Job={id:'job-hide',type:'source.catalog.refresh',payload:{connection_id:'conn-1',stage:'scanning',progress:60,message:'正在扫描目录:已完成 1000 个文件夹',processed:1000,total:1339,current_directory:'产品资料/CR系列',scanned_files:2438},state:'running',priority:20,available_at:'2026-09-09T00:00:00Z',attempt:1,max_attempts:3,idempotency_key:'safe',created_at:'2026-09-09T00:00:00Z',updated_at:'2026-09-09T00:00:00Z'}
- const common={sources:[source],selectedSource:source,page,loading:false,error:null,refreshJob:job,onSelectSource:vi.fn(),onOpenFolder:vi.fn(),onNavigateTo:vi.fn(),onBack:vi.fn(),onPageChange:vi.fn(),onRefresh:vi.fn()}
- const full=renderToStaticMarkup( )
- expect(full).toContain('隐藏到后台')
- expect(full).toContain('实时快照进度')
- expect(full).toContain('产品资料/CR系列')
- const hidden=renderToStaticMarkup( )
- expect(hidden).not.toContain('实时快照进度')
- expect(hidden).toContain('catalog-refresh-pill')
- expect(hidden).toContain('60%')
- expect(hidden).toContain('公司级技术支持知识库') // pill still names the source
- // browsing and annotation stay interactive in hidden mode (only a duplicate refresh is blocked)
- expect(hidden).toContain('aria-label="选择01|产品总览与定位"')
- expect(hidden).toContain('aria-label="选择根目录说明.pdf"')
- expect(hidden).toContain('全选当前页')
- expect(hidden).toContain('实时刷新中…')
- })
- it('announces failure safely and keeps old snapshot usable',()=>{
- const job:Job={id:'job-fail',type:'source.catalog.refresh',payload:{connection_id:'conn-1',stage:'failed',progress:0,message:'AList API Token 未配置,请联系管理员更新来源凭据;旧快照仍然保留',processed:0,total:0},state:'failed',priority:20,available_at:'2026-09-09T00:00:00Z',attempt:3,max_attempts:3,idempotency_key:'safe',created_at:'2026-09-09T00:00:00Z',updated_at:'2026-09-09T00:00:00Z'}
- const html=renderToStaticMarkup( )
- expect(html).toContain('实时刷新失败')
- expect(html).toContain('刷新失败;旧快照仍然保留,目录浏览与标注不受影响。')
- expect(html).toContain('快照就绪')
- expect(html).toContain('01|产品总览与定位')
- })
- it('keeps the progress bar free of directory details when the scanner reports none',()=>{
- const job:Job={id:'job-plain',type:'source.catalog.refresh',payload:{connection_id:'conn-1',stage:'building',progress:80,message:'正在校验目录结构',processed:10,total:10},state:'running',priority:20,available_at:'2026-09-09T00:00:00Z',attempt:1,max_attempts:3,idempotency_key:'safe',created_at:'2026-09-09T00:00:00Z',updated_at:'2026-09-09T00:00:00Z'}
- const html=renderToStaticMarkup( )
- expect(html).toContain('正在校验目录结构')
- expect(html).not.toContain('刷新明细')
- })
- it('uses durable background refresh progress and the current stage message',()=>{
- const job:Job={id:'job-refresh',type:'source.catalog.refresh',payload:{connection_id:'conn-1',stage:'scanning',progress:50,message:'正在扫描目录:已完成 50 个',processed:50,total:100},state:'running',priority:20,available_at:'2026-09-09T00:00:00Z',attempt:1,max_attempts:3,idempotency_key:'safe',created_at:'2026-09-09T00:00:00Z',updated_at:'2026-09-09T00:00:00Z'}
- const html=renderToStaticMarkup( )
- expect(html).toContain('实时刷新中')
- expect(html).toContain('正在扫描目录:已完成 50 个')
- expect(html).toContain('value="50"')
- })
- it('uses clickable breadcrumbs and an explicit back action below the source root',()=>{
- const nested={...page,current:{...page.current,node_id:'folder-1',parent_node_id:'root-1',name:'01|产品总览与定位'},breadcrumbs:[...page.breadcrumbs,{node_id:'folder-1',name:'01|产品总览与定位'}]}
- const html=renderToStaticMarkup( )
- expect(html).toContain('aria-label="返回上一级"')
- expect(html).toContain('公司级技术支持知识库')
- expect(html).toContain('01|产品总览与定位')
- })
- it('shows policy as a read-only table state and offers top-level policy buttons',()=>{
- const nested={...page,current:{...page.current,node_id:'folder-1',parent_node_id:'root-1',name:'01|产品总览与定位'},breadcrumbs:[...page.breadcrumbs,{node_id:'folder-1',name:'01|产品总览与定位'}],items:[node('可入队.pdf','file','file-ready'),node('已知目录','folder','folder-known'),{...node('大小未知目录','folder','folder-unknown'),subtree_bytes:0},{...node('只有元数据.pdf','file','file-meta'),size_bytes:undefined,routable_count:0}],total:4}
- const html=renderToStaticMarkup( )
- for(const text of ['名称','处理策略','全选当前页','对内','对外','禁止导入','aria-label="选择可入队.pdf"'])expect(html).toContain(text)
- for(const text of ['class="catalog-policy-actions"','class="internal-policy-button"','class="external-policy-button"','class="blocked-policy-button"','class="selected-count"'])expect(html).toContain(text)
- for(const text of ['操作人','判断原因','名称与处理策略','当前策略','选择策略','批量处理策略','应用策略','对内 + 对外','{
- const nested={...page,current:{...page.current,node_id:'folder-1'},breadcrumbs:[...page.breadcrumbs,{node_id:'folder-1',name:'01|产品总览与定位'}],items:[node('可入队.pdf','file','file-ready'),node('已知目录','folder','folder-known')],total:2}
- const html=renderToStaticMarkup( )
- // exactly three top-level policy buttons: 对内 / 对外 / 禁止导入 (docs/SOURCE_SCOPE_POLICY.md rules 4-6)
- expect((html.match(/class="[a-z-]+-policy-button"/g)||[]).length).toBe(3)
- // rule 2: only current-page select-all exists
- expect(html).toContain('全选当前页')
- expect(html).not.toContain('递归全选')
- // rule 1: both file and folder rows expose selection checkboxes
- expect(html).toContain('aria-label="选择可入队.pdf"')
- expect(html).toContain('aria-label="选择已知目录"')
- // rules 5-6: no batch entry for historical 对内 + 对外 or for unset state
- expect(html).not.toContain('对内 + 对外')
- expect(html).not.toContain('批量未选择')
- })
- it('lets an engineer select a directory without opening it',()=>{
- const html=renderToStaticMarkup( )
- expect(html).toContain('aria-label="选择01|产品总览与定位"')
- })
- it('does not claim an empty source loaded when the request failed',()=>{
- const html=renderToStaticMarkup( )
- expect(html).toContain('加载失败')
- expect(html).not.toContain('尚无来源目录')
- })
+const source: CatalogSource = {
+ connection_id: 'conn-1',
+ source_type: 'dingtalk',
+ name: '公司级技术支持知识库',
+ root_node_id: 'root-1',
+ folder_count: 1080,
+ file_count: 3468,
+ observed_at: '2026-09-08T00:00:00Z',
+ snapshot_only: true,
+ snapshot_status: 'ready',
+ last_refresh_duration_ms: 12000,
+}
+const node = (name: string, type: 'folder' | 'file', id: string) => ({
+ node_id: id,
+ parent_node_id: 'root-1',
+ node_type: type,
+ name,
+ direct_child_count: type === 'folder' ? 2 : 0,
+ subtree_file_count: type === 'folder' ? 10 : 1,
+ subtree_bytes: 1024,
+ size_bytes: type === 'file' ? 1024 : undefined,
+ visibility: type === 'folder' ? ('internal' as const) : ('unknown' as const),
+ imported_count: type === 'folder' ? 8 : 0,
+ parsed_count: type === 'folder' ? 7 : 0,
+ active_count: 1,
+ failed_count: 0,
+ routable_count: type === 'file' ? 1 : 8,
+})
+const page: CatalogNodePage = {
+ current: { ...node('公司级技术支持知识库', 'folder', 'root-1'), parent_node_id: undefined },
+ breadcrumbs: [{ node_id: 'root-1', name: '公司级技术支持知识库' }],
+ items: [node('01|产品总览与定位', 'folder', 'folder-1'), node('根目录说明.pdf', 'file', 'file-1')],
+ total: 2,
+ limit: 50,
+ offset: 0,
+}
+
+describe('SourceCatalogBrowserView', () => {
+ it('shows a source that is still doing its first scan, with progress', () => {
+ const scanning: CatalogSource = {
+ ...source,
+ connection_id: 'conn-scan',
+ name: '公司级营销资料管理',
+ root_node_id: '',
+ folder_count: 1153,
+ file_count: 1428,
+ snapshot_status: 'scanning',
+ scan_progress: 36,
+ scan_message: '正在扫描目录:已完成 470 个文件夹,待处理 683 个',
+ last_refresh_duration_ms: 0,
+ }
+ const html = renderToStaticMarkup(
+
+ )
+ // 新来源不必等快照落地就出现在列表里,并且带百分比。
+ for (const text of [
+ '公司级营销资料管理',
+ '首次扫描中 36%',
+ '首次扫描中 · 36%',
+ '正在扫描目录:已完成 470 个文件夹,待处理 683 个',
+ '已发现约 1428 份文件',
+ ])
+ expect(html).toContain(text)
+ // 还没有目录:不渲染空目录、也不提供标注/格式筛选入口。
+ expect(html).not.toContain('当前目录为空。')
+ expect(html).not.toContain('禁止导入')
+ })
+
+ it('uses a Windows-style path bar instead of a directory tree', () => {
+ const html = renderToStaticMarkup(
+
+ )
+ expect(html).toContain('aria-label="当前目录路径"')
+ expect(html).toContain('aria-label="目录面包屑"')
+ expect(html).toContain('aria-label="返回上一级"')
+ expect(html).not.toContain('role="tree"')
+ expect(html).not.toContain('目录树')
+ for (const text of ['禁止导入即生效', '大小 ', '大小未提供', '总大小', '已知合计'])
+ expect(html).not.toContain(text)
+ expect(html).toContain('')
+ expect(html).toContain('文件格式筛选 ')
+ expect(html).not.toMatch(/]*class="catalog-format-filter"[^>]*open/)
+ })
+
+ it('looks like a folder browser and shows only direct children', () => {
+ const html = renderToStaticMarkup(
+
+ )
+ for (const text of [
+ '各个知识源头的资料总览与标注',
+ '公司级技术支持知识库',
+ '01|产品总览与定位',
+ '文件夹',
+ '已解析 7 / 已导入 8',
+ '本地只读快照',
+ '含所有子目录文件',
+ ])
+ expect(html).toContain(text)
+ expect(html).not.toContain('真实目录')
+ expect(html).toContain('根目录说明.pdf')
+ expect(html).not.toContain('显示根目录文件')
+ expect(html).toContain('aria-label="当前目录路径"')
+ expect(html).not.toContain('第 1 / 1 页')
+ expect(html).not.toContain('PP分类 · 不代表完整原始目录')
+ expect(html).toContain('快照时间')
+ expect(html).toContain('快照就绪')
+ expect(html).toContain('上次刷新耗时 12秒')
+ expect(html).toContain('实时刷新目录')
+ expect(html).not.toContain('aria-label="处理策略01|产品总览与定位"')
+ expect(html).not.toContain('递归作用于全部子目录')
+ })
+ it('shows current directory, discovered file counts and the estimate marker during refresh', () => {
+ const job: Job = {
+ id: 'job-scan',
+ type: 'source.catalog.refresh',
+ payload: {
+ connection_id: 'conn-1',
+ stage: 'scanning',
+ progress: 43,
+ message: '正在扫描目录:已完成 125 个文件夹,待处理 55 个',
+ processed: 125,
+ total: 180,
+ current_directory: '产品资料/CR系列',
+ scanned_files: 2438,
+ estimated_total_files: 5662,
+ total_is_estimate: true,
+ },
+ state: 'running',
+ priority: 20,
+ available_at: '2026-09-09T00:00:00Z',
+ attempt: 1,
+ max_attempts: 3,
+ idempotency_key: 'safe',
+ created_at: '2026-09-09T00:00:00Z',
+ updated_at: '2026-09-09T00:00:00Z',
+ }
+ const html = renderToStaticMarkup(
+
+ )
+ expect(html).toContain('当前目录:产品资料/CR系列')
+ expect(html).toContain('已发现 2438 份文件')
+ expect(html).toContain('(估算)')
+ })
+ it('hides the running refresh to a compact pill that expands back on click without blocking browsing', () => {
+ const job: Job = {
+ id: 'job-hide',
+ type: 'source.catalog.refresh',
+ payload: {
+ connection_id: 'conn-1',
+ stage: 'scanning',
+ progress: 60,
+ message: '正在扫描目录:已完成 1000 个文件夹',
+ processed: 1000,
+ total: 1339,
+ current_directory: '产品资料/CR系列',
+ scanned_files: 2438,
+ },
+ state: 'running',
+ priority: 20,
+ available_at: '2026-09-09T00:00:00Z',
+ attempt: 1,
+ max_attempts: 3,
+ idempotency_key: 'safe',
+ created_at: '2026-09-09T00:00:00Z',
+ updated_at: '2026-09-09T00:00:00Z',
+ }
+ const common = {
+ sources: [source],
+ selectedSource: source,
+ page,
+ loading: false,
+ error: null,
+ refreshJob: job,
+ onSelectSource: vi.fn(),
+ onOpenFolder: vi.fn(),
+ onNavigateTo: vi.fn(),
+ onBack: vi.fn(),
+ onPageChange: vi.fn(),
+ onRefresh: vi.fn(),
+ }
+ const full = renderToStaticMarkup(
+
+ )
+ expect(full).toContain('隐藏到后台')
+ expect(full).toContain('实时快照进度')
+ expect(full).toContain('产品资料/CR系列')
+ const hidden = renderToStaticMarkup(
+
+ )
+ expect(hidden).not.toContain('实时快照进度')
+ expect(hidden).toContain('catalog-refresh-pill')
+ expect(hidden).toContain('60%')
+ expect(hidden).toContain('公司级技术支持知识库') // pill still names the source
+ // browsing and annotation stay interactive in hidden mode (only a duplicate refresh is blocked)
+ expect(hidden).toContain('aria-label="选择01|产品总览与定位"')
+ expect(hidden).toContain('aria-label="选择根目录说明.pdf"')
+ expect(hidden).toContain('全选当前页')
+ expect(hidden).toContain('实时刷新中…')
+ })
+ it('announces failure safely and keeps old snapshot usable', () => {
+ const job: Job = {
+ id: 'job-fail',
+ type: 'source.catalog.refresh',
+ payload: {
+ connection_id: 'conn-1',
+ stage: 'failed',
+ progress: 0,
+ message: 'AList API Token 未配置,请联系管理员更新来源凭据;旧快照仍然保留',
+ processed: 0,
+ total: 0,
+ },
+ state: 'failed',
+ priority: 20,
+ available_at: '2026-09-09T00:00:00Z',
+ attempt: 3,
+ max_attempts: 3,
+ idempotency_key: 'safe',
+ created_at: '2026-09-09T00:00:00Z',
+ updated_at: '2026-09-09T00:00:00Z',
+ }
+ const html = renderToStaticMarkup(
+
+ )
+ expect(html).toContain('实时刷新失败')
+ expect(html).toContain('刷新失败;旧快照仍然保留,目录浏览与标注不受影响。')
+ expect(html).toContain('快照就绪')
+ expect(html).toContain('01|产品总览与定位')
+ })
+ it('keeps the progress bar free of directory details when the scanner reports none', () => {
+ const job: Job = {
+ id: 'job-plain',
+ type: 'source.catalog.refresh',
+ payload: {
+ connection_id: 'conn-1',
+ stage: 'building',
+ progress: 80,
+ message: '正在校验目录结构',
+ processed: 10,
+ total: 10,
+ },
+ state: 'running',
+ priority: 20,
+ available_at: '2026-09-09T00:00:00Z',
+ attempt: 1,
+ max_attempts: 3,
+ idempotency_key: 'safe',
+ created_at: '2026-09-09T00:00:00Z',
+ updated_at: '2026-09-09T00:00:00Z',
+ }
+ const html = renderToStaticMarkup(
+
+ )
+ expect(html).toContain('正在校验目录结构')
+ expect(html).not.toContain('刷新明细')
+ })
+ it('uses durable background refresh progress and the current stage message', () => {
+ const job: Job = {
+ id: 'job-refresh',
+ type: 'source.catalog.refresh',
+ payload: {
+ connection_id: 'conn-1',
+ stage: 'scanning',
+ progress: 50,
+ message: '正在扫描目录:已完成 50 个',
+ processed: 50,
+ total: 100,
+ },
+ state: 'running',
+ priority: 20,
+ available_at: '2026-09-09T00:00:00Z',
+ attempt: 1,
+ max_attempts: 3,
+ idempotency_key: 'safe',
+ created_at: '2026-09-09T00:00:00Z',
+ updated_at: '2026-09-09T00:00:00Z',
+ }
+ const html = renderToStaticMarkup(
+
+ )
+ expect(html).toContain('实时刷新中')
+ expect(html).toContain('正在扫描目录:已完成 50 个')
+ expect(html).toContain('value="50"')
+ })
+ it('uses clickable breadcrumbs and an explicit back action below the source root', () => {
+ const nested = {
+ ...page,
+ current: { ...page.current, node_id: 'folder-1', parent_node_id: 'root-1', name: '01|产品总览与定位' },
+ breadcrumbs: [...page.breadcrumbs, { node_id: 'folder-1', name: '01|产品总览与定位' }],
+ }
+ const html = renderToStaticMarkup(
+
+ )
+ expect(html).toContain('aria-label="返回上一级"')
+ expect(html).toContain('公司级技术支持知识库')
+ expect(html).toContain('01|产品总览与定位')
+ })
+ it('shows policy as a read-only table state and offers top-level policy buttons', () => {
+ const nested = {
+ ...page,
+ current: { ...page.current, node_id: 'folder-1', parent_node_id: 'root-1', name: '01|产品总览与定位' },
+ breadcrumbs: [...page.breadcrumbs, { node_id: 'folder-1', name: '01|产品总览与定位' }],
+ items: [
+ node('可入队.pdf', 'file', 'file-ready'),
+ node('已知目录', 'folder', 'folder-known'),
+ { ...node('大小未知目录', 'folder', 'folder-unknown'), subtree_bytes: 0 },
+ { ...node('只有元数据.pdf', 'file', 'file-meta'), size_bytes: undefined, routable_count: 0 },
+ ],
+ total: 4,
+ }
+ const html = renderToStaticMarkup(
+
+ )
+ for (const text of ['名称', '处理策略', '全选当前页', '对内', '对外', '禁止导入', 'aria-label="选择可入队.pdf"'])
+ expect(html).toContain(text)
+ for (const text of [
+ 'class="catalog-policy-actions"',
+ 'class="internal-policy-button"',
+ 'class="external-policy-button"',
+ 'class="blocked-policy-button"',
+ 'class="selected-count"',
+ ])
+ expect(html).toContain(text)
+ for (const text of [
+ '操作人',
+ '判断原因',
+ '名称与处理策略',
+ '当前策略',
+ '选择策略',
+ '批量处理策略',
+ '应用策略',
+ '对内 + 对外',
+ ' {
+ const nested = {
+ ...page,
+ current: { ...page.current, node_id: 'folder-1' },
+ breadcrumbs: [...page.breadcrumbs, { node_id: 'folder-1', name: '01|产品总览与定位' }],
+ items: [node('可入队.pdf', 'file', 'file-ready'), node('已知目录', 'folder', 'folder-known')],
+ total: 2,
+ }
+ const html = renderToStaticMarkup(
+
+ )
+ // exactly three top-level policy buttons: 对内 / 对外 / 禁止导入 (docs/SOURCE_SCOPE_POLICY.md rules 4-6)
+ expect((html.match(/class="[a-z-]+-policy-button"/g) || []).length).toBe(3)
+ // rule 2: only current-page select-all exists
+ expect(html).toContain('全选当前页')
+ expect(html).not.toContain('递归全选')
+ // rule 1: both file and folder rows expose selection checkboxes
+ expect(html).toContain('aria-label="选择可入队.pdf"')
+ expect(html).toContain('aria-label="选择已知目录"')
+ // rules 5-6: no batch entry for historical 对内 + 对外 or for unset state
+ expect(html).not.toContain('对内 + 对外')
+ expect(html).not.toContain('批量未选择')
+ })
+ it('lets an engineer select a directory without opening it', () => {
+ const html = renderToStaticMarkup(
+
+ )
+ expect(html).toContain('aria-label="选择01|产品总览与定位"')
+ })
+ it('does not claim an empty source loaded when the request failed', () => {
+ const html = renderToStaticMarkup(
+
+ )
+ expect(html).toContain('加载失败')
+ expect(html).not.toContain('尚无来源目录')
+ })
})
diff --git a/web/src/SourceCatalogBrowser.tsx b/web/src/SourceCatalogBrowser.tsx
index 5eec0fd..e6961f5 100644
--- a/web/src/SourceCatalogBrowser.tsx
+++ b/web/src/SourceCatalogBrowser.tsx
@@ -1,6 +1,20 @@
-import { useContext,useEffect,useState } from 'react'
-import { fetchCatalogNodes,fetchCatalogRoutingCapabilities,fetchCatalogSnapshotNodes,fetchCatalogSnapshots,fetchCatalogSources,routeCatalogFiles } from './sourceCatalog'
-import type { CatalogNode,CatalogNodePage,CatalogPolicy,CatalogRoutingCapabilities,CatalogSnapshot,CatalogSource } from './sourceCatalog'
+import { useContext, useEffect, useState } from 'react'
+import {
+ fetchCatalogNodes,
+ fetchCatalogRoutingCapabilities,
+ fetchCatalogSnapshotNodes,
+ fetchCatalogSnapshots,
+ fetchCatalogSources,
+ routeCatalogFiles,
+} from './sourceCatalog'
+import type {
+ CatalogNode,
+ CatalogNodePage,
+ CatalogPolicy,
+ CatalogRoutingCapabilities,
+ CatalogSnapshot,
+ CatalogSource,
+} from './sourceCatalog'
import { SnapshotHistoryView } from './SnapshotHistory'
import { triggerCatalogRefresh } from './sourceCatalog'
import { fetchJobs } from './jobs'
@@ -9,113 +23,760 @@ import { WorkspaceContextTabsSetter } from './workspaceContext'
import { FilenameFilterEditor } from './FilenameFilterEditor'
import './sourceCatalogBrowser.css'
-const sourceLabels:Record={dingtalk:'钉钉知识库',pp:'PP资料下载中心',alist:'AList文件源'}
-const duration=(milliseconds:number)=>milliseconds<=0?'未记录':milliseconds<1000?`${milliseconds}毫秒`:milliseconds<60000?`${Math.round(milliseconds/1000)}秒`:`${Math.floor(milliseconds/60000)}分${Math.round(milliseconds%60000/1000)}秒`
-const scope=(value:string)=>value==='internal'?'对内':value==='external'?'对外':value==='both'?'对内 + 对外':value==='blocked'?'禁止导入':value==='mixed'?'混合':'未选择'
-const progress=(node:CatalogNode)=>node.imported_count?`已解析 ${node.parsed_count} / 已导入 ${node.imported_count}`:'尚未导入'
+const sourceLabels: Record = { dingtalk: '钉钉知识库', pp: 'PP资料下载中心', alist: 'AList文件源' }
+const duration = (milliseconds: number) =>
+ milliseconds <= 0
+ ? '未记录'
+ : milliseconds < 1000
+ ? `${milliseconds}毫秒`
+ : milliseconds < 60000
+ ? `${Math.round(milliseconds / 1000)}秒`
+ : `${Math.floor(milliseconds / 60000)}分${Math.round((milliseconds % 60000) / 1000)}秒`
+const scope = (value: string) =>
+ value === 'internal'
+ ? '对内'
+ : value === 'external'
+ ? '对外'
+ : value === 'both'
+ ? '对内 + 对外'
+ : value === 'blocked'
+ ? '禁止导入'
+ : value === 'mixed'
+ ? '混合'
+ : '未选择'
+const progress = (node: CatalogNode) =>
+ node.imported_count ? `已解析 ${node.parsed_count} / 已导入 ${node.imported_count}` : '尚未导入'
-interface ViewProps{
- sources:CatalogSource[];selectedSource:CatalogSource|null;page:CatalogNodePage|null;loading:boolean;error:string|null
- routingCapabilities?:CatalogRoutingCapabilities;routingMessage?:string;routingLoading?:boolean
- refreshJob?:Job|null
- onSelectSource:(source:CatalogSource)=>void;onOpenFolder:(node:CatalogNode)=>void;onNavigateTo:(nodeID:string)=>void;onBack:()=>void;onPageChange:(offset:number)=>void;onRefresh:()=>void;onRoute?:(nodeIDs:string[],policy:CatalogPolicy)=>Promise
- history?:{open:boolean;snapshots:CatalogSnapshot[];active:CatalogSnapshot|null;page:CatalogNodePage|null;loading:boolean;error:string|null;onOpen:()=>void;onClose:()=>void;onOpenSnapshot:(snapshot:CatalogSnapshot)=>void;onOpenFolder:(snapshot:CatalogSnapshot,nodeID:string)=>void}
- refreshHidden?:boolean;onToggleRefreshHidden?:()=>void;onFiltersChanged?:()=>void
+interface ViewProps {
+ sources: CatalogSource[]
+ selectedSource: CatalogSource | null
+ page: CatalogNodePage | null
+ loading: boolean
+ error: string | null
+ routingCapabilities?: CatalogRoutingCapabilities
+ routingMessage?: string
+ routingLoading?: boolean
+ refreshJob?: Job | null
+ onSelectSource: (source: CatalogSource) => void
+ onOpenFolder: (node: CatalogNode) => void
+ onNavigateTo: (nodeID: string) => void
+ onBack: () => void
+ onPageChange: (offset: number) => void
+ onRefresh: () => void
+ onRoute?: (nodeIDs: string[], policy: CatalogPolicy) => Promise
+ history?: {
+ open: boolean
+ snapshots: CatalogSnapshot[]
+ active: CatalogSnapshot | null
+ page: CatalogNodePage | null
+ loading: boolean
+ error: string | null
+ onOpen: () => void
+ onClose: () => void
+ onOpenSnapshot: (snapshot: CatalogSnapshot) => void
+ onOpenFolder: (snapshot: CatalogSnapshot, nodeID: string) => void
+ }
+ refreshHidden?: boolean
+ onToggleRefreshHidden?: () => void
+ onFiltersChanged?: () => void
}
-export function SourceCatalogBrowserView({sources,selectedSource,page,loading,error,routingCapabilities={internal_enabled:false,external_enabled:false},routingMessage='',routingLoading=false,refreshJob=null,onSelectSource,onOpenFolder,onNavigateTo,onBack,onPageChange,onRefresh,onRoute=async()=>false,history,refreshHidden=false,onToggleRefreshHidden,onFiltersChanged}:ViewProps){
- const [selectedIDs,setSelectedIDs]=useState([])
- const [appliedPolicies,setAppliedPolicies]=useState>({})
- const atSourceRoot=Boolean(page&&page.breadcrumbs.length===1)
- const visibleItems=page?.items||[]
- useEffect(()=>{setSelectedIDs([]);setAppliedPolicies({})},[selectedSource?.connection_id,page?.current.node_id])
- useEffect(()=>{if(routingMessage)setSelectedIDs([])},[routingMessage])
- const selectableNodes=visibleItems
- const selectedNodeIDs=selectedIDs
- const selectedCount=selectedIDs.length
- const routeReady=!routingLoading
- const policyEnabled=(policy:CatalogPolicy)=>policy==='blocked'||policy==='both'?(policy==='blocked'||routingCapabilities.internal_enabled&&routingCapabilities.external_enabled):policy==='internal'?routingCapabilities.internal_enabled:routingCapabilities.external_enabled
- const toggleNode=(id:string)=>setSelectedIDs(current=>current.includes(id)?current.filter(value=>value!==id):[...current,id])
- const applyPolicy=async(policy:CatalogPolicy)=>{if(!selectedNodeIDs.length)return;const changed=await onRoute(selectedNodeIDs,policy);if(changed)setAppliedPolicies(current=>({...current,...Object.fromEntries(selectedNodeIDs.map(id=>[id,policy]))}))}
- const refreshPayload=refreshJob?.payload&&typeof refreshJob.payload==='object'?refreshJob.payload as Record:{}
- const refreshProgress=typeof refreshPayload.progress==='number'?refreshPayload.progress:0
- const refreshMessage=typeof refreshPayload.message==='string'?refreshPayload.message:'等待后台刷新'
- const refreshDirectory=typeof refreshPayload.current_directory==='string'&&refreshPayload.current_directory?refreshPayload.current_directory:''
- const refreshFiles=typeof refreshPayload.scanned_files==='number'&&refreshPayload.scanned_files>0?refreshPayload.scanned_files:0
- const refreshEstimate=refreshPayload.total_is_estimate===true
- const stageLabels:Record={queued:'排队中',connecting:'连接来源',scanning:'扫描目录',building:'构建快照',activating:'写入快照',completed:'完成',failed:'失败'}
- const refreshStageLabel=typeof refreshPayload.stage==='string'?stageLabels[refreshPayload.stage]||refreshPayload.stage:'排队中'
- const pillSource=typeof refreshPayload.source_name==='string'?refreshPayload.source_name:''
- const refreshPillSource=pillSource||selectedSource?.name||''
- const refreshActive=refreshJob?.state==='pending'||refreshJob?.state==='running'||refreshJob?.state==='retry'
- return
- {refreshActive?'实时刷新中…':'实时刷新目录'}
- {error?{error}
:null}
- {refreshJob&&refreshActive&&refreshHidden? 后台刷新中 · {refreshPillSource} · {refreshProgress}%{refreshEstimate?'(估算)':''} :null}
- {refreshJob&&!(refreshActive&&refreshHidden)?<>{refreshActive?'实时刷新中':refreshJob.state==='completed'?'实时快照已完成':'实时刷新失败'} {selectedSource?selectedSource.name+' · ':''}{refreshStageLabel} {refreshMessage}
{refreshProgress}%{refreshEstimate?'(估算)':''} · 后台运行,可以切换页面 {refreshActive&&onToggleRefreshHidden?隐藏到后台 :null} {refreshActive&&(refreshDirectory||refreshFiles)?{refreshDirectory?当前目录:{refreshDirectory} :null}{refreshFiles?已发现 {refreshFiles} 份文件 :null}
:null}{refreshJob.state==='failed'?刷新失败;旧快照仍然保留,目录浏览与标注不受影响。
:null}>:null}
-
-
- 内容来源
- {sources.map(source=>{const scanning=source.snapshot_status==='scanning';const percent=source.scan_progress??0;return onSelectSource(source)}>{source.name} {sourceLabels[source.source_type]} · {scanning?`首次扫描中 ${percent}%`:'本地只读快照'} {scanning?`${percent}%`:source.file_count} })}
- {!loading&&!error&&!sources.length?尚无来源目录。
:null}
-
-
- {selectedSource&&selectedSource.snapshot_status==='scanning'?
- 首次扫描中 · {selectedSource.scan_progress??0}% {selectedSource.name}
-
- {selectedSource.scan_message||'正在从源头扫描目录…'}
- 已发现约 {selectedSource.file_count} 份文件(文件夹 {selectedSource.folder_count} 个)。扫完这一页会自动出现目录;现在可以先去做别的。
- :selectedSource&&page?<>
-
- ←
- {page.breadcrumbs.map((crumb,index)=>{index>0?/ :null}onNavigateTo(crumb.node_id)}>{crumb.name} )}
-
- {page.current.name} {selectedSource.snapshot_only?'本地只读快照,不代表实时连接':'实时目录'}
{atSourceRoot?`第一层目录 ${page.items.filter(node=>node.node_type==='folder').length}`:`当前子项 ${page.total}`} 含所有子目录文件 {page.current.subtree_file_count}
-
- 文件格式筛选
- item.name).join('/')} label={atSourceRoot?'整个来源':'当前目录'} onChanged={onFiltersChanged} />
-
+export function SourceCatalogBrowserView({
+ sources,
+ selectedSource,
+ page,
+ loading,
+ error,
+ routingCapabilities = { internal_enabled: false, external_enabled: false },
+ routingMessage = '',
+ routingLoading = false,
+ refreshJob = null,
+ onSelectSource,
+ onOpenFolder,
+ onNavigateTo,
+ onBack,
+ onPageChange,
+ onRefresh,
+ onRoute = async () => false,
+ history,
+ refreshHidden = false,
+ onToggleRefreshHidden,
+ onFiltersChanged,
+}: ViewProps) {
+ const [selectedIDs, setSelectedIDs] = useState([])
+ const [appliedPolicies, setAppliedPolicies] = useState>({})
+ const atSourceRoot = Boolean(page && page.breadcrumbs.length === 1)
+ const visibleItems = page?.items || []
+ useEffect(() => {
+ setSelectedIDs([])
+ setAppliedPolicies({})
+ }, [selectedSource?.connection_id, page?.current.node_id])
+ useEffect(() => {
+ if (routingMessage) setSelectedIDs([])
+ }, [routingMessage])
+ const selectableNodes = visibleItems
+ const selectedNodeIDs = selectedIDs
+ const selectedCount = selectedIDs.length
+ const routeReady = !routingLoading
+ const policyEnabled = (policy: CatalogPolicy) =>
+ policy === 'blocked' || policy === 'both'
+ ? policy === 'blocked' || (routingCapabilities.internal_enabled && routingCapabilities.external_enabled)
+ : policy === 'internal'
+ ? routingCapabilities.internal_enabled
+ : routingCapabilities.external_enabled
+ const toggleNode = (id: string) =>
+ setSelectedIDs((current) => (current.includes(id) ? current.filter((value) => value !== id) : [...current, id]))
+ const applyPolicy = async (policy: CatalogPolicy) => {
+ if (!selectedNodeIDs.length) return
+ const changed = await onRoute(selectedNodeIDs, policy)
+ if (changed)
+ setAppliedPolicies((current) => ({
+ ...current,
+ ...Object.fromEntries(selectedNodeIDs.map((id) => [id, policy])),
+ }))
+ }
+ const refreshPayload =
+ refreshJob?.payload && typeof refreshJob.payload === 'object' ? (refreshJob.payload as Record) : {}
+ const refreshProgress = typeof refreshPayload.progress === 'number' ? refreshPayload.progress : 0
+ const refreshMessage = typeof refreshPayload.message === 'string' ? refreshPayload.message : '等待后台刷新'
+ const refreshDirectory =
+ typeof refreshPayload.current_directory === 'string' && refreshPayload.current_directory
+ ? refreshPayload.current_directory
+ : ''
+ const refreshFiles =
+ typeof refreshPayload.scanned_files === 'number' && refreshPayload.scanned_files > 0
+ ? refreshPayload.scanned_files
+ : 0
+ const refreshEstimate = refreshPayload.total_is_estimate === true
+ const stageLabels: Record = {
+ queued: '排队中',
+ connecting: '连接来源',
+ scanning: '扫描目录',
+ building: '构建快照',
+ activating: '写入快照',
+ completed: '完成',
+ failed: '失败',
+ }
+ const refreshStageLabel =
+ typeof refreshPayload.stage === 'string' ? stageLabels[refreshPayload.stage] || refreshPayload.stage : '排队中'
+ const pillSource = typeof refreshPayload.source_name === 'string' ? refreshPayload.source_name : ''
+ const refreshPillSource = pillSource || selectedSource?.name || ''
+ const refreshActive =
+ refreshJob?.state === 'pending' || refreshJob?.state === 'running' || refreshJob?.state === 'retry'
+ return (
+
+
+
+
来源与目录
+
各个知识源头的资料总览与标注
+
+
+ {refreshActive ? '实时刷新中…' : '实时刷新目录'}
+
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+ {refreshJob && refreshActive && refreshHidden ? (
+
+
+ 后台刷新中 · {refreshPillSource} · {refreshProgress}%{refreshEstimate ? '(估算)' : ''}
+
+ ) : null}
+ {refreshJob && !(refreshActive && refreshHidden) ? (
+ <>
+
+
+
+ {refreshActive ? '实时刷新中' : refreshJob.state === 'completed' ? '实时快照已完成' : '实时刷新失败'}
+
+
+ {selectedSource ? selectedSource.name + ' · ' : ''}
+ {refreshStageLabel}
+
+ {refreshMessage}
+
+
+
+ {refreshProgress}%{refreshEstimate ? '(估算)' : ''} · 后台运行,可以切换页面
+
+ {refreshActive && onToggleRefreshHidden ? (
+
+ 隐藏到后台
+
+ ) : null}
+
+ {refreshActive && (refreshDirectory || refreshFiles) ? (
+
+ {refreshDirectory ? 当前目录:{refreshDirectory} : null}
+ {refreshFiles ? 已发现 {refreshFiles} 份文件 : null}
+
+ ) : null}
+ {refreshJob.state === 'failed' ? (
+
+ 刷新失败;旧快照仍然保留,目录浏览与标注不受影响。
+
+ ) : null}
+ >
+ ) : null}
+
+
+ 内容来源
+ {sources.map((source) => {
+ const scanning = source.snapshot_status === 'scanning'
+ const percent = source.scan_progress ?? 0
+ return (
+ onSelectSource(source)}
+ >
+
+ {source.name}
+
+ {sourceLabels[source.source_type]} · {scanning ? `首次扫描中 ${percent}%` : '本地只读快照'}
+
+
+ {scanning ? `${percent}%` : source.file_count}
+
+ )
+ })}
+ {!loading && !error && !sources.length ? 尚无来源目录。
: null}
+
+
+ {selectedSource && selectedSource.snapshot_status === 'scanning' ? (
+
+
+ 首次扫描中 · {selectedSource.scan_progress ?? 0}%
+ {selectedSource.name}
+
+
+ {selectedSource.scan_message || '正在从源头扫描目录…'}
+
+ 已发现约 {selectedSource.file_count} 份文件(文件夹 {selectedSource.folder_count}{' '}
+ 个)。扫完这一页会自动出现目录;现在可以先去做别的。
+
+
+ ) : selectedSource && page ? (
+ <>
+
+
+ ←
+
+
+ {page.breadcrumbs.map((crumb, index) => (
+
+ {index > 0 ? / : null}
+ onNavigateTo(crumb.node_id)}
+ >
+ {crumb.name}
+
+
+ ))}
+
+
+
+
+ {page.current.name}
+ {selectedSource.snapshot_only ? '本地只读快照,不代表实时连接' : '实时目录'}
+
+
+ {atSourceRoot
+ ? `第一层目录 ${page.items.filter((node) => node.node_type === 'folder').length}`
+ : `当前子项 ${page.total}`}
+
+
含所有子目录文件 {page.current.subtree_file_count}
+
+
+ 文件格式筛选
+ item.name)
+ .join('/')}
+ label={atSourceRoot ? '整个来源' : '当前目录'}
+ onChanged={onFiltersChanged}
+ />
+
- {visibleItems.length?
-
-
0&&selectableNodes.every(node=>selectedIDs.includes(node.node_id))} disabled={!selectableNodes.length} onChange={event=>setSelectedIDs(event.target.checked?selectableNodes.map(node=>node.node_id):[])} />全选当前页
-
void applyPolicy('internal')}>对内 void applyPolicy('external')}>对外 void applyPolicy('blocked')}>禁止导入 已选 {selectedCount} 项
+ {visibleItems.length ? (
+
+
+
+ 0 &&
+ selectableNodes.every((node) => selectedIDs.includes(node.node_id))
+ }
+ disabled={!selectableNodes.length}
+ onChange={(event) =>
+ setSelectedIDs(event.target.checked ? selectableNodes.map((node) => node.node_id) : [])
+ }
+ />
+ 全选当前页
+
+
+
void applyPolicy('internal')}
+ >
+ 对内
+
+
void applyPolicy('external')}
+ >
+ 对外
+
+
+ void applyPolicy('blocked')}
+ >
+ 禁止导入
+
+ 已选 {selectedCount} 项
+
+
+
+ {routingMessage ? {routingMessage}
: null}
+
+ ) : null}
+ {visibleItems.length ? (
+
+ ) : (
+
当前目录为空。
+ )}
+ {page.total > page.limit ? (
+
+
+ 第 {Math.floor(page.offset / page.limit) + 1} / {Math.max(1, Math.ceil(page.total / page.limit))} 页
+
+
+ onPageChange(Math.max(0, page.offset - page.limit))}
+ >
+ 上一页
+
+ = page.total || loading}
+ onClick={() => onPageChange(page.offset + page.limit)}
+ >
+ 下一页
+
+
+
+ ) : null}
+
+ 快照就绪
+ 快照时间:{new Date(selectedSource.observed_at).toLocaleString('zh-CN', { hour12: false })}
+ 上次刷新耗时 {duration(selectedSource.last_refresh_duration_ms)}
+ {history ? (
+
+ {history.open ? '返回当前目录' : '快照历史'}
+
+ ) : null}
+
+ {history && history.open ? (
+
+ ) : null}
+ >
+ ) : loading ? (
+
正在读取第一层目录…
+ ) : !error ? (
+
请选择一个内容来源。
+ ) : null}
+
- {routingMessage?{routingMessage}
:null}
- :null}
- {visibleItems.length?:当前目录为空。
}
- {page.total>page.limit?第 {Math.floor(page.offset/page.limit)+1} / {Math.max(1,Math.ceil(page.total/page.limit))} 页 onPageChange(Math.max(0,page.offset-page.limit))}>上一页 =page.total||loading} onClick={()=>onPageChange(page.offset+page.limit)}>下一页
:null}
- 快照就绪 快照时间:{new Date(selectedSource.observed_at).toLocaleString('zh-CN',{hour12:false})} 上次刷新耗时 {duration(selectedSource.last_refresh_duration_ms)} {history?{history.open?'返回当前目录':'快照历史'} :null}
- {history&&history.open? :null}
- >:loading?正在读取第一层目录…
:!error?请选择一个内容来源。
:null}
-
-
-
+
+ )
}
-export function SourceCatalogBrowser(){
- const [sources,setSources]=useState([]);const [selected,setSelected]=useState(null);const [parentID,setParentID]=useState('');const [page,setPage]=useState(null);const [offset,setOffset]=useState(0);const [revision,setRevision]=useState(0);const [loading,setLoading]=useState(true);const [error,setError]=useState(null);const setContextTabs=useContext(WorkspaceContextTabsSetter)
- const [routingCapabilities,setRoutingCapabilities]=useState({internal_enabled:false,external_enabled:false});const [routingMessage,setRoutingMessage]=useState('');const [routingLoading,setRoutingLoading]=useState(false)
- const [refreshJob,setRefreshJob]=useState(null)
- const [historySnapshots,setHistorySnapshots]=useState([]);const [historyActive,setHistoryActive]=useState(null);const [historyPage,setHistoryPage]=useState(null);const [historyLoading,setHistoryLoading]=useState(false);const [historyError,setHistoryError]=useState(null);const [historyOpen,setHistoryOpen]=useState(false)
- const [refreshHidden,setRefreshHidden]=useState(false)
+export function SourceCatalogBrowser() {
+ const [sources, setSources] = useState([])
+ const [selected, setSelected] = useState(null)
+ const [parentID, setParentID] = useState('')
+ const [page, setPage] = useState(null)
+ const [offset, setOffset] = useState(0)
+ const [revision, setRevision] = useState(0)
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState(null)
+ const setContextTabs = useContext(WorkspaceContextTabsSetter)
+ const [routingCapabilities, setRoutingCapabilities] = useState({
+ internal_enabled: false,
+ external_enabled: false,
+ })
+ const [routingMessage, setRoutingMessage] = useState('')
+ const [routingLoading, setRoutingLoading] = useState(false)
+ const [refreshJob, setRefreshJob] = useState(null)
+ const [historySnapshots, setHistorySnapshots] = useState([])
+ const [historyActive, setHistoryActive] = useState(null)
+ const [historyPage, setHistoryPage] = useState(null)
+ const [historyLoading, setHistoryLoading] = useState(false)
+ const [historyError, setHistoryError] = useState(null)
+ const [historyOpen, setHistoryOpen] = useState(false)
+ const [refreshHidden, setRefreshHidden] = useState(false)
- const openHistory=async()=>{if(!selected||historyLoading)return;if(historyOpen){closeHistory();return}setHistoryOpen(true);setHistoryLoading(true);setHistoryError(null);try{const result=await fetchCatalogSnapshots(selected.connection_id,20,0);setHistorySnapshots(result.items);const head=result.items.find(item=>item.is_head)||result.items[0]||null;setHistoryActive(head);if(head)setHistoryPage(await fetchCatalogSnapshotNodes(selected.connection_id,head.snapshot_id,head.root_node_id));else setHistoryPage(null)}catch(cause){setHistoryError(cause instanceof Error?cause.message:'加载快照历史失败')}finally{setHistoryLoading(false)}}
- const openHistorySnapshot=async(snapshot:CatalogSnapshot)=>{if(!selected||historyLoading)return;setHistoryLoading(true);setHistoryError(null);try{setHistoryActive(snapshot);setHistoryPage(await fetchCatalogSnapshotNodes(selected.connection_id,snapshot.snapshot_id,snapshot.root_node_id))}catch(cause){setHistoryError(cause instanceof Error?cause.message:'加载历史快照目录失败')}finally{setHistoryLoading(false)}}
- const openHistoryFolder=async(snapshot:CatalogSnapshot,nodeID:string)=>{if(!selected||historyLoading)return;setHistoryLoading(true);setHistoryError(null);try{setHistoryPage(await fetchCatalogSnapshotNodes(selected.connection_id,snapshot.snapshot_id,nodeID))}catch(cause){setHistoryError(cause instanceof Error?cause.message:'加载历史快照目录失败')}finally{setHistoryLoading(false)}}
- const closeHistory=()=>{setHistoryOpen(false);setHistoryActive(null);setHistoryPage(null);setHistoryError(null)}
+ const openHistory = async () => {
+ if (!selected || historyLoading) return
+ if (historyOpen) {
+ closeHistory()
+ return
+ }
+ setHistoryOpen(true)
+ setHistoryLoading(true)
+ setHistoryError(null)
+ try {
+ const result = await fetchCatalogSnapshots(selected.connection_id, 20, 0)
+ setHistorySnapshots(result.items)
+ const head = result.items.find((item) => item.is_head) || result.items[0] || null
+ setHistoryActive(head)
+ if (head)
+ setHistoryPage(await fetchCatalogSnapshotNodes(selected.connection_id, head.snapshot_id, head.root_node_id))
+ else setHistoryPage(null)
+ } catch (cause) {
+ setHistoryError(cause instanceof Error ? cause.message : '加载快照历史失败')
+ } finally {
+ setHistoryLoading(false)
+ }
+ }
+ const openHistorySnapshot = async (snapshot: CatalogSnapshot) => {
+ if (!selected || historyLoading) return
+ setHistoryLoading(true)
+ setHistoryError(null)
+ try {
+ setHistoryActive(snapshot)
+ setHistoryPage(
+ await fetchCatalogSnapshotNodes(selected.connection_id, snapshot.snapshot_id, snapshot.root_node_id)
+ )
+ } catch (cause) {
+ setHistoryError(cause instanceof Error ? cause.message : '加载历史快照目录失败')
+ } finally {
+ setHistoryLoading(false)
+ }
+ }
+ const openHistoryFolder = async (snapshot: CatalogSnapshot, nodeID: string) => {
+ if (!selected || historyLoading) return
+ setHistoryLoading(true)
+ setHistoryError(null)
+ try {
+ setHistoryPage(await fetchCatalogSnapshotNodes(selected.connection_id, snapshot.snapshot_id, nodeID))
+ } catch (cause) {
+ setHistoryError(cause instanceof Error ? cause.message : '加载历史快照目录失败')
+ } finally {
+ setHistoryLoading(false)
+ }
+ }
+ const closeHistory = () => {
+ setHistoryOpen(false)
+ setHistoryActive(null)
+ setHistoryPage(null)
+ setHistoryError(null)
+ }
- useEffect(()=>{const controller=new AbortController();setLoading(true);void fetchCatalogSources(controller.signal).then(result=>{if(controller.signal.aborted)return;setSources(result);setSelected(current=>current&&result.find(source=>source.connection_id===current.connection_id)||result[0]||null);setParentID(current=>current||result[0]?.root_node_id||'');setError(null)}).catch(cause=>{if(!controller.signal.aborted)setError(cause instanceof Error?cause.message:'加载来源目录失败')}).finally(()=>{if(!controller.signal.aborted)setLoading(false)});return()=>controller.abort()},[revision])
- useEffect(()=>{if(!selected||!parentID||selected.snapshot_status==='scanning')return;const controller=new AbortController();setLoading(true);void fetchCatalogNodes(selected.connection_id,parentID,50,offset,controller.signal).then(result=>{if(!controller.signal.aborted){setPage(result);setError(null)}}).catch(cause=>{if(!controller.signal.aborted)setError(cause instanceof Error?cause.message:'加载目录失败')}).finally(()=>{if(!controller.signal.aborted)setLoading(false)});return()=>controller.abort()},[selected,parentID,offset,revision])
+ useEffect(() => {
+ const controller = new AbortController()
+ setLoading(true)
+ void fetchCatalogSources(controller.signal)
+ .then((result) => {
+ if (controller.signal.aborted) return
+ setSources(result)
+ setSelected(
+ (current) =>
+ (current && result.find((source) => source.connection_id === current.connection_id)) || result[0] || null
+ )
+ setParentID((current) => current || result[0]?.root_node_id || '')
+ setError(null)
+ })
+ .catch((cause) => {
+ if (!controller.signal.aborted) setError(cause instanceof Error ? cause.message : '加载来源目录失败')
+ })
+ .finally(() => {
+ if (!controller.signal.aborted) setLoading(false)
+ })
+ return () => controller.abort()
+ }, [revision])
+ useEffect(() => {
+ if (!selected || !parentID || selected.snapshot_status === 'scanning') return
+ const controller = new AbortController()
+ setLoading(true)
+ void fetchCatalogNodes(selected.connection_id, parentID, 50, offset, controller.signal)
+ .then((result) => {
+ if (!controller.signal.aborted) {
+ setPage(result)
+ setError(null)
+ }
+ })
+ .catch((cause) => {
+ if (!controller.signal.aborted) setError(cause instanceof Error ? cause.message : '加载目录失败')
+ })
+ .finally(() => {
+ if (!controller.signal.aborted) setLoading(false)
+ })
+ return () => controller.abort()
+ }, [selected, parentID, offset, revision])
- // 有来源正在首次扫描时自动刷新列表:百分比会自己往前走,扫完那一行自动变成正式来源。
- useEffect(()=>{if(!sources.some(source=>source.snapshot_status==='scanning'))return;const timer=window.setTimeout(()=>setRevision(value=>value+1),3000);return()=>window.clearTimeout(timer)},[sources])
- useEffect(()=>{const controller=new AbortController();void fetchCatalogRoutingCapabilities(controller.signal).then(setRoutingCapabilities).catch(()=>setRoutingCapabilities({internal_enabled:false,external_enabled:false}));return()=>controller.abort()},[revision])
- useEffect(()=>{if(!selected)return;const controller=new AbortController();void fetchJobs(controller.signal).then(items=>{if(controller.signal.aborted)return;const match=items.find(job=>job.type==='source.catalog.refresh'&&job.payload&&typeof job.payload==='object'&&(job.payload as Record).connection_id===selected.connection_id);setRefreshJob(match||null)}).catch(()=>undefined);return()=>controller.abort()},[selected])
- useEffect(()=>{if(!refreshJob||!['pending','running','retry'].includes(refreshJob.state))return;const timer=window.setTimeout(()=>void fetchJobs().then(items=>{const current=items.find(job=>job.id===refreshJob.id);if(!current)return;setRefreshJob(previous=>{if(previous?.state!=='completed'&¤t.state==='completed')setRevision(value=>value+1);return current})}).catch(()=>undefined),750);return()=>window.clearTimeout(timer)},[refreshJob])
- useEffect(()=>{if(!selected||!page){setContextTabs([]);return}setContextTabs(page.breadcrumbs.map(item=>({key:item.node_id,label:item.name,onActivate:()=>{setParentID(item.node_id);setOffset(0)}})));return()=>setContextTabs([])},[selected,page,setContextTabs])
- const selectSource=(source:CatalogSource)=>{if(selected?.connection_id===source.connection_id&&parentID===source.root_node_id&&offset===0)return;setSelected(source);setParentID(source.root_node_id);setOffset(0);setPage(null);setRefreshJob(null);closeHistory()}
- const back=()=>{if(!page||page.breadcrumbs.length<2)return;setParentID(page.breadcrumbs[page.breadcrumbs.length-2].node_id);setOffset(0)}
- const route=async(nodeIDs:string[],policy:CatalogPolicy):Promise=>{if(!selected||routingLoading)return false;setRoutingLoading(true);setRoutingMessage('');try{const result=await routeCatalogFiles({connection_id:selected.connection_id,node_ids:nodeIDs,policy,reason:`目录页策略:${scope(policy)}`});setRoutingMessage(`策略已保存 ${result.selected_files} 份;已记入意图 ${result.intents_recorded} 份;排队从源头取件 ${result.fetch_queued} 份;列入待入库 ${result.planned_publications} 份;标记禁止 ${result.blocked_items} 份;目录外无文件可标注 ${result.unbound_files} 份。到“解析监控 → 待入库”点开始处理即可。`);setRevision(value=>value+1);return true}catch(cause){setRoutingMessage(cause instanceof Error?cause.message:'文件处理失败');return false}finally{setRoutingLoading(false)}}
- const refresh=async()=>{if(!selected||refreshJob&&['pending','running','retry'].includes(refreshJob.state))return;setError(null);try{setRefreshJob(await triggerCatalogRefresh(selected.connection_id))}catch(cause){setError(cause instanceof Error?cause.message:'启动实时刷新失败')}}
- return {setRoutingMessage('');selectSource(source)}} onOpenFolder={node=>{setRoutingMessage('');setParentID(node.node_id);setOffset(0)}} onNavigateTo={nodeID=>{setRoutingMessage('');setParentID(nodeID);setOffset(0)}} onBack={back} onPageChange={setOffset} onRefresh={()=>void refresh()} onRoute={(nodeIDs,visibility)=>route(nodeIDs,visibility)} history={{open:historyOpen,snapshots:historySnapshots,active:historyActive,page:historyPage,loading:historyLoading,error:historyError,onOpen:()=>void openHistory(),onClose:closeHistory,onOpenSnapshot:snapshot=>void openHistorySnapshot(snapshot),onOpenFolder:(snapshot,nodeID)=>void openHistoryFolder(snapshot,nodeID)}} refreshHidden={refreshHidden} onToggleRefreshHidden={()=>setRefreshHidden(value=>!value)} onFiltersChanged={()=>setRevision(value=>value+1)} />
+ // 有来源正在首次扫描时自动刷新列表:百分比会自己往前走,扫完那一行自动变成正式来源。
+ useEffect(() => {
+ if (!sources.some((source) => source.snapshot_status === 'scanning')) return
+ const timer = window.setTimeout(() => setRevision((value) => value + 1), 3000)
+ return () => window.clearTimeout(timer)
+ }, [sources])
+ useEffect(() => {
+ const controller = new AbortController()
+ void fetchCatalogRoutingCapabilities(controller.signal)
+ .then(setRoutingCapabilities)
+ .catch(() => setRoutingCapabilities({ internal_enabled: false, external_enabled: false }))
+ return () => controller.abort()
+ }, [revision])
+ useEffect(() => {
+ if (!selected) return
+ const controller = new AbortController()
+ void fetchJobs(controller.signal)
+ .then((items) => {
+ if (controller.signal.aborted) return
+ const match = items.find(
+ (job) =>
+ job.type === 'source.catalog.refresh' &&
+ job.payload &&
+ typeof job.payload === 'object' &&
+ (job.payload as Record).connection_id === selected.connection_id
+ )
+ setRefreshJob(match || null)
+ })
+ .catch(() => undefined)
+ return () => controller.abort()
+ }, [selected])
+ useEffect(() => {
+ if (!refreshJob || !['pending', 'running', 'retry'].includes(refreshJob.state)) return
+ const timer = window.setTimeout(
+ () =>
+ void fetchJobs()
+ .then((items) => {
+ const current = items.find((job) => job.id === refreshJob.id)
+ if (!current) return
+ setRefreshJob((previous) => {
+ if (previous?.state !== 'completed' && current.state === 'completed') setRevision((value) => value + 1)
+ return current
+ })
+ })
+ .catch(() => undefined),
+ 750
+ )
+ return () => window.clearTimeout(timer)
+ }, [refreshJob])
+ useEffect(() => {
+ if (!selected || !page) {
+ setContextTabs([])
+ return
+ }
+ setContextTabs(
+ page.breadcrumbs.map((item) => ({
+ key: item.node_id,
+ label: item.name,
+ onActivate: () => {
+ setParentID(item.node_id)
+ setOffset(0)
+ },
+ }))
+ )
+ return () => setContextTabs([])
+ }, [selected, page, setContextTabs])
+ const selectSource = (source: CatalogSource) => {
+ if (selected?.connection_id === source.connection_id && parentID === source.root_node_id && offset === 0) return
+ setSelected(source)
+ setParentID(source.root_node_id)
+ setOffset(0)
+ setPage(null)
+ setRefreshJob(null)
+ closeHistory()
+ }
+ const back = () => {
+ if (!page || page.breadcrumbs.length < 2) return
+ setParentID(page.breadcrumbs[page.breadcrumbs.length - 2].node_id)
+ setOffset(0)
+ }
+ const route = async (nodeIDs: string[], policy: CatalogPolicy): Promise => {
+ if (!selected || routingLoading) return false
+ setRoutingLoading(true)
+ setRoutingMessage('')
+ try {
+ const result = await routeCatalogFiles({
+ connection_id: selected.connection_id,
+ node_ids: nodeIDs,
+ policy,
+ reason: `目录页策略:${scope(policy)}`,
+ })
+ setRoutingMessage(
+ `策略已保存 ${result.selected_files} 份;已记入意图 ${result.intents_recorded} 份;排队从源头取件 ${result.fetch_queued} 份;列入待入库 ${result.planned_publications} 份;标记禁止 ${result.blocked_items} 份;目录外无文件可标注 ${result.unbound_files} 份。到“解析监控 → 待入库”点开始处理即可。`
+ )
+ setRevision((value) => value + 1)
+ return true
+ } catch (cause) {
+ setRoutingMessage(cause instanceof Error ? cause.message : '文件处理失败')
+ return false
+ } finally {
+ setRoutingLoading(false)
+ }
+ }
+ const refresh = async () => {
+ if (!selected || (refreshJob && ['pending', 'running', 'retry'].includes(refreshJob.state))) return
+ setError(null)
+ try {
+ setRefreshJob(await triggerCatalogRefresh(selected.connection_id))
+ } catch (cause) {
+ setError(cause instanceof Error ? cause.message : '启动实时刷新失败')
+ }
+ }
+ return (
+ {
+ setRoutingMessage('')
+ selectSource(source)
+ }}
+ onOpenFolder={(node) => {
+ setRoutingMessage('')
+ setParentID(node.node_id)
+ setOffset(0)
+ }}
+ onNavigateTo={(nodeID) => {
+ setRoutingMessage('')
+ setParentID(nodeID)
+ setOffset(0)
+ }}
+ onBack={back}
+ onPageChange={setOffset}
+ onRefresh={() => void refresh()}
+ onRoute={(nodeIDs, visibility) => route(nodeIDs, visibility)}
+ history={{
+ open: historyOpen,
+ snapshots: historySnapshots,
+ active: historyActive,
+ page: historyPage,
+ loading: historyLoading,
+ error: historyError,
+ onOpen: () => void openHistory(),
+ onClose: closeHistory,
+ onOpenSnapshot: (snapshot) => void openHistorySnapshot(snapshot),
+ onOpenFolder: (snapshot, nodeID) => void openHistoryFolder(snapshot, nodeID),
+ }}
+ refreshHidden={refreshHidden}
+ onToggleRefreshHidden={() => setRefreshHidden((value) => !value)}
+ onFiltersChanged={() => setRevision((value) => value + 1)}
+ />
+ )
}
diff --git a/web/src/SourceLinkManager.test.tsx b/web/src/SourceLinkManager.test.tsx
index d52f411..0ae0143 100644
--- a/web/src/SourceLinkManager.test.tsx
+++ b/web/src/SourceLinkManager.test.tsx
@@ -1,26 +1,114 @@
import { renderToStaticMarkup } from 'react-dom/server'
-import { describe,expect,it,vi } from 'vitest'
+import { describe, expect, it, vi } from 'vitest'
import { SourceLinkManagerView } from './SourceLinkManager'
import type { CatalogSource } from './sourceCatalog'
-const sources:CatalogSource[]=[
- {connection_id:'ding',source_type:'dingtalk',name:'公司级技术支持知识库',root_node_id:'r1',folder_count:8,file_count:3468,observed_at:'2026-09-08T00:00:00Z',snapshot_only:true,snapshot_status:'ready',last_refresh_duration_ms:0},
- {connection_id:'pp',source_type:'pp',name:'PP资料下载中心',root_node_id:'r2',folder_count:64,file_count:613,observed_at:'2026-09-08T00:00:00Z',snapshot_only:true,snapshot_status:'ready',last_refresh_duration_ms:0},
- {connection_id:'alist',source_type:'alist',name:'国内工业',root_node_id:'r3',folder_count:1688,file_count:4880,observed_at:'2026-09-08T00:00:00Z',snapshot_only:true,snapshot_status:'ready',last_refresh_duration_ms:0},
+const sources: CatalogSource[] = [
+ {
+ connection_id: 'ding',
+ source_type: 'dingtalk',
+ name: '公司级技术支持知识库',
+ root_node_id: 'r1',
+ folder_count: 8,
+ file_count: 3468,
+ observed_at: '2026-09-08T00:00:00Z',
+ snapshot_only: true,
+ snapshot_status: 'ready',
+ last_refresh_duration_ms: 0,
+ },
+ {
+ connection_id: 'pp',
+ source_type: 'pp',
+ name: 'PP资料下载中心',
+ root_node_id: 'r2',
+ folder_count: 64,
+ file_count: 613,
+ observed_at: '2026-09-08T00:00:00Z',
+ snapshot_only: true,
+ snapshot_status: 'ready',
+ last_refresh_duration_ms: 0,
+ },
+ {
+ connection_id: 'alist',
+ source_type: 'alist',
+ name: '国内工业',
+ root_node_id: 'r3',
+ folder_count: 1688,
+ file_count: 4880,
+ observed_at: '2026-09-08T00:00:00Z',
+ snapshot_only: true,
+ snapshot_status: 'ready',
+ last_refresh_duration_ms: 0,
+ },
]
-describe('SourceLinkManagerView',()=>{
- it('shows understandable URL credential and login fields without accepting real secrets locally',()=>{
- const html=renderToStaticMarkup( )
- for(const text of ['来源链接','本地模拟配置','公司级技术支持知识库','PP资料下载中心','国内工业','知识库链接','认证方式','专用浏览器扫码登录','Key或Token','本地模拟阶段不接收真实Key','模拟登录并验证目录','不保存真实Key'])expect(html).toContain(text)
- expect(html).not.toContain('secret_ref')
- expect(html).not.toContain('base_url')
- })
- it('separates an AList site address from its starting directory and labels simulated status honestly',()=>{
- const html=renderToStaticMarkup( )
- for(const text of ['AList站点地址','起始目录','/国内工业','模拟验证待执行','不下载文件内容,只读取目录和元数据'])expect(html).toContain(text)
- expect(html).not.toContain('实时连接待验证')
- })
- it('does not show a successful zero-source state after errors',()=>{
- const html=renderToStaticMarkup( )
- expect(html).toContain('加载失败');expect(html).not.toContain('尚无来源链接')
- })
+describe('SourceLinkManagerView', () => {
+ it('shows understandable URL credential and login fields without accepting real secrets locally', () => {
+ const html = renderToStaticMarkup(
+
+ )
+ for (const text of [
+ '来源链接',
+ '本地模拟配置',
+ '公司级技术支持知识库',
+ 'PP资料下载中心',
+ '国内工业',
+ '知识库链接',
+ '认证方式',
+ '专用浏览器扫码登录',
+ 'Key或Token',
+ '本地模拟阶段不接收真实Key',
+ '模拟登录并验证目录',
+ '不保存真实Key',
+ ])
+ expect(html).toContain(text)
+ expect(html).not.toContain('secret_ref')
+ expect(html).not.toContain('base_url')
+ })
+ it('separates an AList site address from its starting directory and labels simulated status honestly', () => {
+ const html = renderToStaticMarkup(
+
+ )
+ for (const text of [
+ 'AList站点地址',
+ '起始目录',
+ '/国内工业',
+ '模拟验证待执行',
+ '不下载文件内容,只读取目录和元数据',
+ ])
+ expect(html).toContain(text)
+ expect(html).not.toContain('实时连接待验证')
+ })
+ it('does not show a successful zero-source state after errors', () => {
+ const html = renderToStaticMarkup(
+
+ )
+ expect(html).toContain('加载失败')
+ expect(html).not.toContain('尚无来源链接')
+ })
})
diff --git a/web/src/SourceLinkManager.tsx b/web/src/SourceLinkManager.tsx
index 50b8493..16d91d9 100644
--- a/web/src/SourceLinkManager.tsx
+++ b/web/src/SourceLinkManager.tsx
@@ -1,35 +1,209 @@
-import { useContext,useEffect,useState } from 'react'
+import { useContext, useEffect, useState } from 'react'
import { fetchCatalogSources } from './sourceCatalog'
import type { CatalogSource } from './sourceCatalog'
import { WorkspaceContextTabsSetter } from './workspaceContext'
import './sourceLinkManager.css'
-const typeLabel={dingtalk:'钉钉知识库',pp:'PP资料下载中心',alist:'AList文件源'}
-const authLabel={dingtalk:'专用浏览器扫码登录',pp:'公开访问,无需Key',alist:'只读Token'}
-const actionLabel={dingtalk:'模拟登录并验证目录',pp:'模拟验证公开目录',alist:'模拟验证只读Token'}
-const placeholder={dingtalk:'粘贴钉钉知识库网页链接(模拟)',pp:'粘贴PP资料下载中心链接(模拟)',alist:'例如:https://alist.example.com(模拟)'}
-const linkLabel={dingtalk:'知识库链接',pp:'资料中心地址',alist:'AList站点地址'}
-interface Props{sources:CatalogSource[];selected:CatalogSource|null;validated:boolean;loading:boolean;error:string|null;onSelect:(source:CatalogSource)=>void;onValidate:()=>void;onRefresh:()=>void}
-export function SourceLinkManagerView({sources,selected,validated,loading,error,onSelect,onValidate,onRefresh}:Props){
- const [link,setLink]=useState('')
- return
-
- {error?{error}
:null}
- 本地模拟配置 使用真实来源名称和快照数量,但不连接登录服务、不保存真实Key。
-
-
内容来源 {sources.map(source=>{setLink('');onSelect(source)}}>{source.name} {typeLabel[source.source_type]} {source.file_count}份 )}{!loading&&!error&&!sources.length?尚无来源链接。
:null}
-
-
-
+const typeLabel = { dingtalk: '钉钉知识库', pp: 'PP资料下载中心', alist: 'AList文件源' }
+const authLabel = { dingtalk: '专用浏览器扫码登录', pp: '公开访问,无需Key', alist: '只读Token' }
+const actionLabel = { dingtalk: '模拟登录并验证目录', pp: '模拟验证公开目录', alist: '模拟验证只读Token' }
+const placeholder = {
+ dingtalk: '粘贴钉钉知识库网页链接(模拟)',
+ pp: '粘贴PP资料下载中心链接(模拟)',
+ alist: '例如:https://alist.example.com(模拟)',
}
-export function SourceLinkManager(){
- const [sources,setSources]=useState([]);const [selected,setSelected]=useState(null);const [validated,setValidated]=useState(false);const [loading,setLoading]=useState(true);const [error,setError]=useState(null);const [revision,setRevision]=useState(0);const setContextTabs=useContext(WorkspaceContextTabsSetter)
- useEffect(()=>{const controller=new AbortController();setLoading(true);void fetchCatalogSources(controller.signal).then(result=>{if(!controller.signal.aborted){setSources(result);setSelected(current=>current&&result.find(item=>item.connection_id===current.connection_id)||result[0]||null);setError(null)}}).catch(cause=>{if(!controller.signal.aborted)setError(cause instanceof Error?cause.message:'加载来源链接失败')}).finally(()=>{if(!controller.signal.aborted)setLoading(false)});return()=>controller.abort()},[revision])
- useEffect(()=>{setContextTabs(selected?[{key:selected.connection_id,label:selected.name,onActivate:()=>undefined}]:[]);return()=>setContextTabs([])},[selected,setContextTabs])
- return {setSelected(source);setValidated(false)}} onValidate={()=>setValidated(true)} onRefresh={()=>{setValidated(false);setRevision(value=>value+1)}} />
+const linkLabel = { dingtalk: '知识库链接', pp: '资料中心地址', alist: 'AList站点地址' }
+interface Props {
+ sources: CatalogSource[]
+ selected: CatalogSource | null
+ validated: boolean
+ loading: boolean
+ error: string | null
+ onSelect: (source: CatalogSource) => void
+ onValidate: () => void
+ onRefresh: () => void
+}
+export function SourceLinkManagerView({
+ sources,
+ selected,
+ validated,
+ loading,
+ error,
+ onSelect,
+ onValidate,
+ onRefresh,
+}: Props) {
+ const [link, setLink] = useState('')
+ return (
+
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+ 本地模拟配置
+ 使用真实来源名称和快照数量,但不连接登录服务、不保存真实Key。
+
+
+
+ 内容来源
+ {sources.map((source) => (
+ {
+ setLink('')
+ onSelect(source)
+ }}
+ >
+
+ {source.name}
+ {typeLabel[source.source_type]}
+
+ {source.file_count}份
+
+ ))}
+ {!loading && !error && !sources.length ? 尚无来源链接。
: null}
+
+
+ {selected ? (
+ <>
+
+
+
{selected.name}
+ {typeLabel[selected.source_type]} · 本地只读快照
+
+
模拟验证待执行
+
+
+
+ {linkLabel[selected.source_type]}
+ setLink(event.target.value)}
+ placeholder={placeholder[selected.source_type]}
+ />
+
+
+ 认证方式
+
+
+
+ Key或Token
+
+
+
+ 起始目录
+
+
+
+
+ 当前快照
+ 文件 {selected.file_count}
+ 目录 {selected.folder_count}
+ 不下载文件内容,只读取目录和元数据
+ 不执行上传、移动、重命名或删除
+
+
+ {actionLabel[selected.source_type]}
+
+ {validated ? (
+
+
+ 网络连接
+ 模拟通过
+
+
+ 登录或Token
+ 模拟通过
+
+
+ 根目录
+ 模拟通过
+
+
+ 读取第一层目录
+ 模拟通过
+
+
+ 只读边界
+ 模拟通过
+
+
以上只是本地交互模拟;迁移到测试服务器后才执行真实验证。
+
+ ) : null}
+
+ 真实模式下,新凭据先验证,成功后才替换旧凭据;网页永远不能读取已保存Key。
+
+ >
+ ) : loading ? (
+ 正在加载来源…
+ ) : null}
+
+
+
+ )
+}
+export function SourceLinkManager() {
+ const [sources, setSources] = useState([])
+ const [selected, setSelected] = useState(null)
+ const [validated, setValidated] = useState(false)
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState(null)
+ const [revision, setRevision] = useState(0)
+ const setContextTabs = useContext(WorkspaceContextTabsSetter)
+ useEffect(() => {
+ const controller = new AbortController()
+ setLoading(true)
+ void fetchCatalogSources(controller.signal)
+ .then((result) => {
+ if (!controller.signal.aborted) {
+ setSources(result)
+ setSelected(
+ (current) =>
+ (current && result.find((item) => item.connection_id === current.connection_id)) || result[0] || null
+ )
+ setError(null)
+ }
+ })
+ .catch((cause) => {
+ if (!controller.signal.aborted) setError(cause instanceof Error ? cause.message : '加载来源链接失败')
+ })
+ .finally(() => {
+ if (!controller.signal.aborted) setLoading(false)
+ })
+ return () => controller.abort()
+ }, [revision])
+ useEffect(() => {
+ setContextTabs(selected ? [{ key: selected.connection_id, label: selected.name, onActivate: () => undefined }] : [])
+ return () => setContextTabs([])
+ }, [selected, setContextTabs])
+ return (
+ {
+ setSelected(source)
+ setValidated(false)
+ }}
+ onValidate={() => setValidated(true)}
+ onRefresh={() => {
+ setValidated(false)
+ setRevision((value) => value + 1)
+ }}
+ />
+ )
}
diff --git a/web/src/VersionHistory.test.tsx b/web/src/VersionHistory.test.tsx
index f3e9d44..52f2155 100644
--- a/web/src/VersionHistory.test.tsx
+++ b/web/src/VersionHistory.test.tsx
@@ -176,6 +176,20 @@ describe('VersionHistoryView', () => {
expect(html).toContain('已匹配到下面的提交')
})
+ it('加载失败时不说"未接线",并把服务端原因显示出来', () => {
+ // 真实案例:部署后第一次打开版本页,API 500(快照同步进行中)⇒ page=null,
+ // 页面却同时显示"当前运行版本未知"和"版本记录未接线",把故障说成未配置。
+ const html = render({
+ page: null,
+ error: '加载版本记录失败(HTTP 500):读取版本记录失败(version_history_unavailable)',
+ })
+ expect(html).toContain('加载版本记录失败')
+ expect(html).toContain('version_history_unavailable')
+ expect(html).not.toContain('版本记录未接线')
+ expect(html).not.toContain('未配置只读的仓库来源')
+ expect(html).not.toContain('当前运行版本未知')
+ })
+
it('never labels a commit when the running identity is unknown', () => {
const html = render({
page: {
diff --git a/web/src/VersionHistory.tsx b/web/src/VersionHistory.tsx
index 40bf86f..428d1af 100644
--- a/web/src/VersionHistory.tsx
+++ b/web/src/VersionHistory.tsx
@@ -146,7 +146,13 @@ export function VersionHistoryView(props: ViewProps) {
- {page ? runningText(page.current_running) : '当前运行版本未知'}
+
+ {page
+ ? runningText(page.current_running)
+ : props.error
+ ? '当前运行标记不可用:版本记录加载失败(原因见下方),不是"未配置"'
+ : '正在读取当前运行版本…'}
+
@@ -154,7 +160,7 @@ export function VersionHistoryView(props: ViewProps) {
记录来自仓库,只读展示,不执行发布或部署。
- {page ? syncText(page.sync) : '正在读取同步状态…'}
+ {page ? syncText(page.sync) : props.error ? '同步状态不可用:请求失败' : '正在读取同步状态…'}
{page?.sync.status === 'running' || page?.sync.status === 'pending' ? (
同步中
) : null}
@@ -184,9 +190,11 @@ export function VersionHistoryView(props: ViewProps) {
{items.length === 0 && !props.loading ? (
- {page?.sync.available
- ? '暂无记录(先同步一次,或该筛选下没有条目)'
- : '版本记录未接线:等待配置只读的仓库来源'}
+ {props.error
+ ? '列表加载失败:这不是"未接线",请按上方错误码排查后重试'
+ : page?.sync.available
+ ? '暂无记录(先同步一次,或该筛选下没有条目)'
+ : '版本记录未接线:等待配置只读的仓库来源'}
) : null}
diff --git a/web/src/assets.test.ts b/web/src/assets.test.ts
index e807e0c..a317053 100644
--- a/web/src/assets.test.ts
+++ b/web/src/assets.test.ts
@@ -7,12 +7,22 @@ afterEach(() => vi.unstubAllGlobals())
describe('parseSourceAssetsResponse', () => {
it('parses the read-only source inventory envelope', () => {
const result = parseSourceAssetsResponse({
- data: [{
- id: 'si-safe', source_type: 'pp', connection_name: 'PP历史公开知识', size_bytes: 52428801,
- visibility: 'external', source_change: 'unchanged', content_state: 'over_limit',
- legacy_status: 'over_limit', has_publication: false, processing_state: '', retrieval_state: '',
- updated_at: '2026-09-04T00:00:00Z',
- }],
+ data: [
+ {
+ id: 'si-safe',
+ source_type: 'pp',
+ connection_name: 'PP历史公开知识',
+ size_bytes: 52428801,
+ visibility: 'external',
+ source_change: 'unchanged',
+ content_state: 'over_limit',
+ legacy_status: 'over_limit',
+ has_publication: false,
+ processing_state: '',
+ retrieval_state: '',
+ updated_at: '2026-09-04T00:00:00Z',
+ },
+ ],
summary: { total: 613, completed: 530, content_alias: 29, over_limit: 54 },
limit: 50,
offset: 0,
@@ -27,7 +37,12 @@ describe('fetchSourceAssets', () => {
it('requests the selected state and server-side page', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
- json: async () => ({ data: [], summary: { total: 613, completed: 530, content_alias: 29, over_limit: 54 }, limit: 50, offset: 50 }),
+ json: async () => ({
+ data: [],
+ summary: { total: 613, completed: 530, content_alias: 29, over_limit: 54 },
+ limit: 50,
+ offset: 50,
+ }),
})
vi.stubGlobal('fetch', fetchMock)
@@ -35,7 +50,7 @@ describe('fetchSourceAssets', () => {
expect(fetchMock).toHaveBeenCalledWith(
'/api/v1/source-items?state=over_limit&limit=50&offset=50',
- expect.objectContaining({ headers: { Accept: 'application/json' } }),
+ expect.objectContaining({ headers: { Accept: 'application/json' } })
)
})
})
diff --git a/web/src/assets.ts b/web/src/assets.ts
index 89431ab..0231cc7 100644
--- a/web/src/assets.ts
+++ b/web/src/assets.ts
@@ -46,11 +46,17 @@ export interface SourceAssetQuery {
export function parseSourceAssetsResponse(value: unknown): SourceAssetPage {
if (
- typeof value !== 'object' || value === null ||
- !('data' in value) || !Array.isArray(value.data) ||
- !('summary' in value) || typeof value.summary !== 'object' || value.summary === null ||
- !('limit' in value) || typeof value.limit !== 'number' ||
- !('offset' in value) || typeof value.offset !== 'number'
+ typeof value !== 'object' ||
+ value === null ||
+ !('data' in value) ||
+ !Array.isArray(value.data) ||
+ !('summary' in value) ||
+ typeof value.summary !== 'object' ||
+ value.summary === null ||
+ !('limit' in value) ||
+ typeof value.limit !== 'number' ||
+ !('offset' in value) ||
+ typeof value.offset !== 'number'
) {
throw new Error('invalid source assets response')
}
diff --git a/web/src/auth.tsx b/web/src/auth.tsx
index 85dddc2..091d6a1 100644
--- a/web/src/auth.tsx
+++ b/web/src/auth.tsx
@@ -102,7 +102,9 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const login = useCallback(async (username: string, password: string) => {
const response = await fetch('/api/v1/auth/login', {
- method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' },
+ method: 'POST',
+ credentials: 'same-origin',
+ headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
})
if (!response.ok) return readError(response, '登录失败')
@@ -116,19 +118,25 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setIdentity(null)
}, [])
- const changePassword = useCallback(async (currentPassword: string, newPassword: string) => {
- const response = await fetch('/api/v1/auth/change-password', {
- method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ current_password: currentPassword, new_password: newPassword }),
- })
- if (!response.ok) return readError(response, '修改密码失败')
- await probe()
- return null
- }, [probe])
+ const changePassword = useCallback(
+ async (currentPassword: string, newPassword: string) => {
+ const response = await fetch('/api/v1/auth/change-password', {
+ method: 'POST',
+ credentials: 'same-origin',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ current_password: currentPassword, new_password: newPassword }),
+ })
+ if (!response.ok) return readError(response, '修改密码失败')
+ await probe()
+ return null
+ },
+ [probe]
+ )
const value = useMemo(
() => ({ identity, ready, login, logout, changePassword, signedOut, notifySignedOut: bumpSignedOut }),
- [identity, ready, login, logout, changePassword, signedOut, bumpSignedOut])
+ [identity, ready, login, logout, changePassword, signedOut, bumpSignedOut]
+ )
return {children}
}
@@ -153,10 +161,31 @@ export function LoginScreen() {
)
@@ -172,13 +201,23 @@ export function MustChangePasswordScreen() {
const submit = async (event: React.FormEvent) => {
event.preventDefault()
- if (next.length < 8) { setError('新密码至少 8 位'); return }
- if (next !== confirm) { setError('两次输入的新密码不一致'); return }
+ if (next.length < 8) {
+ setError('新密码至少 8 位')
+ return
+ }
+ if (next !== confirm) {
+ setError('两次输入的新密码不一致')
+ return
+ }
setBusy(true)
const failure = await changePassword(current, next)
setBusy(false)
setError(failure ?? '')
- if (!failure) { setCurrent(''); setNext(''); setConfirm('') }
+ if (!failure) {
+ setCurrent('')
+ setNext('')
+ setConfirm('')
+ }
}
return (
@@ -186,12 +225,38 @@ export function MustChangePasswordScreen() {
)
@@ -205,14 +270,31 @@ export function TopBarUser() {
const name = identity.display_name || identity.username
return (
-
setMenuOpen((value) => !value)}>
- {name.slice(0, 1)}
+ setMenuOpen((value) => !value)}
+ >
+
+ {name.slice(0, 1)}
+
{name}
{roleLabels[identity.role]}
{menuOpen ? (
setMenuOpen(false)}>
- { setMenuOpen(false); void logout() }}>退出登录
+ {
+ setMenuOpen(false)
+ void logout()
+ }}
+ >
+ 退出登录
+
) : null}
diff --git a/web/src/authMembers.test.ts b/web/src/authMembers.test.ts
index 8790984..275b708 100644
--- a/web/src/authMembers.test.ts
+++ b/web/src/authMembers.test.ts
@@ -10,7 +10,18 @@ describe('role labels', () => {
describe('members API', () => {
it('parses the managed user roster', async () => {
- const payload = { data: [{ id: 'usr-1', username: 'boss', display_name: '老板', role: 'admin', state: 'active', must_change_password: false }] }
+ const payload = {
+ data: [
+ {
+ id: 'usr-1',
+ username: 'boss',
+ display_name: '老板',
+ role: 'admin',
+ state: 'active',
+ must_change_password: false,
+ },
+ ],
+ }
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify(payload), { status: 200 }))
vi.stubGlobal('fetch', fetchMock)
const users = await fetchManagedUsers()
@@ -20,17 +31,33 @@ describe('members API', () => {
vi.unstubAllGlobals()
})
it('rejects a role the server does not know', async () => {
- vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({ data: [{ id: 'u', username: 'x', role: 'superuser', state: 'active' }] }))))
+ vi.stubGlobal(
+ 'fetch',
+ vi
+ .fn()
+ .mockResolvedValue(
+ new Response(JSON.stringify({ data: [{ id: 'u', username: 'x', role: 'superuser', state: 'active' }] }))
+ )
+ )
await expect(fetchManagedUsers()).rejects.toThrow('成员响应无效')
vi.unstubAllGlobals()
})
it('surfaces server errors for state changes', async () => {
- vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({ error: 'cannot disable the last active admin' }), { status: 409 })))
+ vi.stubGlobal(
+ 'fetch',
+ vi
+ .fn()
+ .mockResolvedValue(
+ new Response(JSON.stringify({ error: 'cannot disable the last active admin' }), { status: 409 })
+ )
+ )
await expect(setManagedUserState('usr-1', 'disabled')).rejects.toThrow('cannot disable the last active admin')
vi.unstubAllGlobals()
})
it('resets passwords without sending any actor field', async () => {
- const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ data: { reset: true } }), { status: 200 }))
+ const fetchMock = vi
+ .fn()
+ .mockResolvedValue(new Response(JSON.stringify({ data: { reset: true } }), { status: 200 }))
vi.stubGlobal('fetch', fetchMock)
await resetManagedUserPassword('usr-2', 'temp-pass-1')
const body = JSON.parse(String(fetchMock.mock.calls[0][1].body))
diff --git a/web/src/backupSchedule.test.ts b/web/src/backupSchedule.test.ts
index f129dc4..a6f1e4f 100644
--- a/web/src/backupSchedule.test.ts
+++ b/web/src/backupSchedule.test.ts
@@ -3,24 +3,53 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import { fetchBackupSchedule, fetchBackupScheduleHost, runBackupNow, saveBackupSchedule } from './backupSchedule'
const payload = {
- enabled: true, schedule_time: '03:30', retention_days: 7, retention_months: 3,
- updated_by: 'yaozhanghao', updated_at: '2026-10-09T12:00:00Z', last_applied_at: '', last_applied_summary: '',
- host_configured: true, host_target: 'weknora-test', host_reachable: true, host_error: '',
- host_schedule_enabled: true, host_schedule_time: '03:30', host_keep_days: 7, host_keep_months: 3,
- host_cron_lines: ['30 03 * * * root …'], host_cron_service: 'active', host_script_present: true,
- last_run_stamp: '20261009T052909Z', last_run_created_at: '2026-10-09T05:29:09Z',
- last_run_duration_seconds: 33, last_run_total_bytes: 778502144,
- last_run_databases: ['weknora_test'], last_run_failed_databases: [], last_run_error: '',
- in_sync: true, applied: true, apply_error: '', running: false, running_started_at: '', run_last_error: '',
+ enabled: true,
+ schedule_time: '03:30',
+ retention_days: 7,
+ retention_months: 3,
+ updated_by: 'yaozhanghao',
+ updated_at: '2026-10-09T12:00:00Z',
+ last_applied_at: '',
+ last_applied_summary: '',
+ host_configured: true,
+ host_target: 'weknora-test',
+ host_reachable: true,
+ host_error: '',
+ host_schedule_enabled: true,
+ host_schedule_time: '03:30',
+ host_keep_days: 7,
+ host_keep_months: 3,
+ host_cron_lines: ['30 03 * * * root …'],
+ host_cron_service: 'active',
+ host_script_present: true,
+ last_run_stamp: '20261009T052909Z',
+ last_run_created_at: '2026-10-09T05:29:09Z',
+ last_run_duration_seconds: 33,
+ last_run_total_bytes: 778502144,
+ last_run_databases: ['weknora_test'],
+ last_run_failed_databases: [],
+ last_run_error: '',
+ in_sync: true,
+ applied: true,
+ apply_error: '',
+ running: false,
+ running_started_at: '',
+ run_last_error: '',
}
-const jsonResponse = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } })
+const jsonResponse = (body: unknown, status = 200) =>
+ new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } })
-afterEach(() => { vi.unstubAllGlobals() })
+afterEach(() => {
+ vi.unstubAllGlobals()
+})
describe('backupSchedule 客户端', () => {
it('解析计划三要素与主机回执', async () => {
- vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ data: payload })))
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async () => jsonResponse({ data: payload }))
+ )
const schedule = await fetchBackupSchedule()
expect(schedule.schedule_time).toBe('03:30')
expect(schedule.retention_days).toBe(7)
@@ -31,18 +60,28 @@ describe('backupSchedule 客户端', () => {
it('缺计划字段就报错,绝不把默认值伪装成真实设置', async () => {
const broken = { ...payload } as Record
delete broken.retention_days
- vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ data: broken })))
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async () => jsonResponse({ data: broken }))
+ )
await expect(fetchBackupScheduleHost()).rejects.toThrow('存储与备份响应缺少计划字段')
})
it('保存失败时把后端的中文原因透出来', async () => {
- vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ error: '储存天数需在 1~365 之间,收到 0' }, 400)))
- await expect(saveBackupSchedule({ enabled: true, schedule_time: '03:30', retention_days: 0, retention_months: 3 }))
- .rejects.toThrow('储存天数需在 1~365 之间,收到 0')
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async () => jsonResponse({ error: '储存天数需在 1~365 之间,收到 0' }, 400))
+ )
+ await expect(
+ saveBackupSchedule({ enabled: true, schedule_time: '03:30', retention_days: 0, retention_months: 3 })
+ ).rejects.toThrow('储存天数需在 1~365 之间,收到 0')
})
it('立即备份返回 202 时识别为已启动', async () => {
- vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ data: { started: true, running: true } }, 202)))
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async () => jsonResponse({ data: { started: true, running: true } }, 202))
+ )
await expect(runBackupNow()).resolves.toEqual({ started: true, running: true })
})
})
diff --git a/web/src/backupSchedule.ts b/web/src/backupSchedule.ts
index 3ed9f2a..72b2b6d 100644
--- a/web/src/backupSchedule.ts
+++ b/web/src/backupSchedule.ts
@@ -52,7 +52,11 @@ const asStringList = (value: unknown): string[] =>
// 严格解析:计划三要素(时间/天数/月数)必须是正确类型,缺一个就整块报错,
// 而不是让界面显示成"默认 03:30 / 7 天"这种假状态。
function scheduleProjection(item: Record): BackupSchedule {
- if (typeof item.schedule_time !== 'string' || typeof item.retention_days !== 'number' || typeof item.retention_months !== 'number') {
+ if (
+ typeof item.schedule_time !== 'string' ||
+ typeof item.retention_days !== 'number' ||
+ typeof item.retention_months !== 'number'
+ ) {
throw new Error('存储与备份响应缺少计划字段(schedule_time / retention_days / retention_months)')
}
return {
@@ -94,7 +98,7 @@ function scheduleProjection(item: Record): BackupSchedule {
async function readSchedule(path: string, failure: string, signal?: AbortSignal): Promise {
const response = await fetch(path, { signal, headers: { Accept: 'application/json' } })
if (!response.ok) throw new Error(`${failure}(HTTP ${response.status})`)
- const value = await response.json() as unknown
+ const value = (await response.json()) as unknown
if (!record(value) || !record(value.data)) throw new Error('存储与备份响应无效')
return scheduleProjection(value.data)
}
@@ -108,21 +112,30 @@ export function fetchBackupScheduleHost(signal?: AbortSignal): Promise {
+async function writeSchedule(
+ path: string,
+ method: string,
+ body: unknown,
+ failure: string,
+ signal?: AbortSignal
+): Promise {
const response = await fetch(path, {
- method, signal,
+ method,
+ signal,
headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'Idempotency-Key': newOperationID() },
body: body === undefined ? undefined : JSON.stringify(body),
})
if (!response.ok) {
let reason = `${failure}(HTTP ${response.status})`
try {
- const payload = await response.json() as unknown
+ const payload = (await response.json()) as unknown
if (record(payload) && typeof payload.error === 'string') reason = payload.error
- } catch { /* keep the status text */ }
+ } catch {
+ /* keep the status text */
+ }
throw new Error(reason)
}
- const value = await response.json() as unknown
+ const value = (await response.json()) as unknown
if (!record(value) || !record(value.data)) throw new Error('存储与备份响应无效')
return scheduleProjection(value.data)
}
@@ -146,18 +159,21 @@ export function applyBackupSchedule(signal?: AbortSignal): Promise {
const response = await fetch('/api/v1/backup-schedule/run', {
- method: 'POST', signal,
+ method: 'POST',
+ signal,
headers: { Accept: 'application/json', 'Idempotency-Key': newOperationID() },
})
if (!response.ok) {
let reason = `触发备份失败(HTTP ${response.status})`
try {
- const payload = await response.json() as unknown
+ const payload = (await response.json()) as unknown
if (record(payload) && typeof payload.error === 'string') reason = payload.error
- } catch { /* keep the status text */ }
+ } catch {
+ /* keep the status text */
+ }
throw new Error(reason)
}
- const value = await response.json() as unknown
+ const value = (await response.json()) as unknown
if (!record(value) || !record(value.data)) throw new Error('触发备份响应无效')
return { started: value.data.started === true, running: value.data.running === true }
-}
\ No newline at end of file
+}
diff --git a/web/src/dingtalkSources.test.ts b/web/src/dingtalkSources.test.ts
index d9ec2b1..20e4e41 100644
--- a/web/src/dingtalkSources.test.ts
+++ b/web/src/dingtalkSources.test.ts
@@ -5,9 +5,27 @@ import { discoveryCandidates, driveSpaceURL, fetchAvailableDingtalkSources } fro
const payload = {
data: {
knowledge_bases: [
- { name: 'ATOM知识库', space_id: '9JOGOQneRgkneX4Q', space_type: 'orgWikiSpace', url: 'https://alidocs.dingtalk.com/i/spaces/9JOGOQneRgkneX4Q/overview', description: '' },
- { name: '视觉文档', space_id: '9JOGO9EEOMLMWX4Q', space_type: 'orgWikiSpace', url: '', description: '2d/2.5D/3D 视觉' },
- { name: '我的文档', space_id: '9JOGOxeo90ybnz4Q', space_type: 'myWikiSpace', url: 'https://alidocs.dingtalk.com/i/spaces/9JOGOxeo90ybnz4Q/overview', description: '' },
+ {
+ name: 'ATOM知识库',
+ space_id: '9JOGOQneRgkneX4Q',
+ space_type: 'orgWikiSpace',
+ url: 'https://alidocs.dingtalk.com/i/spaces/9JOGOQneRgkneX4Q/overview',
+ description: '',
+ },
+ {
+ name: '视觉文档',
+ space_id: '9JOGO9EEOMLMWX4Q',
+ space_type: 'orgWikiSpace',
+ url: '',
+ description: '2d/2.5D/3D 视觉',
+ },
+ {
+ name: '我的文档',
+ space_id: '9JOGOxeo90ybnz4Q',
+ space_type: 'myWikiSpace',
+ url: 'https://alidocs.dingtalk.com/i/spaces/9JOGOxeo90ybnz4Q/overview',
+ description: '',
+ },
],
drive_spaces: [
{ name: '我的文件', space_id: '28122692916', space_type: 'mySpace', root_folder_id: '65Y1OQX0' },
@@ -25,7 +43,10 @@ afterEach(() => {
describe('fetchAvailableDingtalkSources', () => {
it('parses knowledge bases and drive spaces', async () => {
- vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify(payload), { status: 200 })))
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async () => new Response(JSON.stringify(payload), { status: 200 }))
+ )
const list = await fetchAvailableDingtalkSources()
expect(list.knowledge_bases).toHaveLength(3)
expect(list.knowledge_bases[1].url).toBe('https://alidocs.dingtalk.com/i/spaces/9JOGO9EEOMLMWX4Q/overview')
@@ -36,16 +57,36 @@ describe('fetchAvailableDingtalkSources', () => {
})
it('explains environment problems instead of pretending the list is empty', async () => {
- vi.stubGlobal('fetch', vi.fn(async () => new Response('{"error":"dingtalk_discovery_unavailable"}', { status: 503 })))
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async () => new Response('{"error":"dingtalk_discovery_unavailable"}', { status: 503 }))
+ )
await expect(fetchAvailableDingtalkSources()).rejects.toThrow(/暂时列不出来/)
- vi.stubGlobal('fetch', vi.fn(async () => new Response('{}', { status: 501 })))
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async () => new Response('{}', { status: 501 }))
+ )
await expect(fetchAvailableDingtalkSources()).rejects.toThrow(/后端未接线/)
- vi.stubGlobal('fetch', vi.fn(async () => new Response('{}', { status: 403 })))
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async () => new Response('{}', { status: 403 }))
+ )
await expect(fetchAvailableDingtalkSources()).rejects.toThrow(/需要管理员身份/)
})
it('carries the drive notice so the page can explain a missing 钉盘 list', async () => {
- vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ data: { knowledge_bases: [], drive_spaces: [], drive_notice: 'list orgSpace: permission denied' } }), { status: 200 })))
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(
+ async () =>
+ new Response(
+ JSON.stringify({
+ data: { knowledge_bases: [], drive_spaces: [], drive_notice: 'list orgSpace: permission denied' },
+ }),
+ { status: 200 }
+ )
+ )
+ )
const list = await fetchAvailableDingtalkSources()
expect(list.drive_notice).toContain('permission denied')
})
@@ -55,7 +96,11 @@ describe('discoveryCandidates', () => {
it('flattens both groups and marks the ones already registered as sources', () => {
const candidates = discoveryCandidates(payload.data as never, ['ATOM知识库', ' (空名字) '])
expect(candidates.map((item) => item.key)).toEqual([
- 'kb:9JOGOQneRgkneX4Q', 'kb:9JOGO9EEOMLMWX4Q', 'kb:9JOGOxeo90ybnz4Q', 'drive:28122692916', 'drive:102571304',
+ 'kb:9JOGOQneRgkneX4Q',
+ 'kb:9JOGO9EEOMLMWX4Q',
+ 'kb:9JOGOxeo90ybnz4Q',
+ 'drive:28122692916',
+ 'drive:102571304',
])
expect(candidates[0].alreadyRegistered).toBe(true)
expect(candidates[1].alreadyRegistered).toBe(false)
@@ -69,4 +114,4 @@ describe('discoveryCandidates', () => {
it('returns nothing for an empty list', () => {
expect(discoveryCandidates(null, ['X'])).toEqual([])
})
-})
\ No newline at end of file
+})
diff --git a/web/src/dingtalkSources.ts b/web/src/dingtalkSources.ts
index 255bd1b..2359f6a 100644
--- a/web/src/dingtalkSources.ts
+++ b/web/src/dingtalkSources.ts
@@ -39,12 +39,13 @@ export async function fetchAvailableDingtalkSources(signal?: AbortSignal): Promi
const response = await fetch('/api/v1/dingtalk-spaces/available', { headers: { Accept: 'application/json' }, signal })
if (!response.ok) {
// 503 是环境问题(dws 未登录/权限不足),501 是后端没接线:都要说清楚"不是没有来源"。
- if (response.status === 503) throw new Error('暂时列不出来:dws 未登录或缺少租户目录读权限。先刷新钉钉会话,或让管理员检查 dws 凭据。')
+ if (response.status === 503)
+ throw new Error('暂时列不出来:dws 未登录或缺少租户目录读权限。先刷新钉钉会话,或让管理员检查 dws 凭据。')
if (response.status === 501) throw new Error('后端未接线:数据源发现接口不可用。')
if (response.status === 401 || response.status === 403) throw new Error('需要管理员身份才能列举数据源。')
throw new Error(`列举数据源失败(HTTP ${response.status})`)
}
- const payload = await response.json() as unknown
+ const payload = (await response.json()) as unknown
const data = isRecord(payload) && isRecord(payload.data) ? payload.data : {}
const knowledgeBases = Array.isArray(data.knowledge_bases) ? data.knowledge_bases : []
const driveSpaces = Array.isArray(data.drive_spaces) ? data.drive_spaces : []
@@ -55,14 +56,33 @@ export async function fetchAvailableDingtalkSources(signal?: AbortSignal): Promi
const spaceID = typeof item.space_id === 'string' ? item.space_id : ''
const url = typeof item.url === 'string' && item.url ? item.url : driveSpaceURL(spaceID)
const spaceType = typeof item.space_type === 'string' ? item.space_type : ''
- return name && spaceID ? [{ name, space_id: spaceID, space_type: spaceType, url, description: typeof item.description === 'string' ? item.description : '' }] : []
+ return name && spaceID
+ ? [
+ {
+ name,
+ space_id: spaceID,
+ space_type: spaceType,
+ url,
+ description: typeof item.description === 'string' ? item.description : '',
+ },
+ ]
+ : []
}),
drive_spaces: driveSpaces.flatMap((item) => {
if (!isRecord(item)) return []
const name = typeof item.name === 'string' ? item.name : ''
const spaceID = typeof item.space_id === 'string' ? item.space_id : ''
const spaceType = typeof item.space_type === 'string' ? item.space_type : ''
- return name && spaceID ? [{ name, space_id: spaceID, space_type: spaceType, root_folder_id: typeof item.root_folder_id === 'string' ? item.root_folder_id : '' }] : []
+ return name && spaceID
+ ? [
+ {
+ name,
+ space_id: spaceID,
+ space_type: spaceType,
+ root_folder_id: typeof item.root_folder_id === 'string' ? item.root_folder_id : '',
+ },
+ ]
+ : []
}),
knowledge_notice: typeof data.knowledge_notice === 'string' ? data.knowledge_notice : '',
drive_notice: typeof data.drive_notice === 'string' ? data.drive_notice : '',
@@ -81,7 +101,10 @@ export interface DiscoveryCandidate {
// 把发现结果拍平成可勾选列表:已登记(按空间名比对来源连接名)的条目打标,页面据此禁用勾选,
// 避免重复登记。
-export function discoveryCandidates(list: AvailableDingtalkSources | null, registeredNames: string[]): DiscoveryCandidate[] {
+export function discoveryCandidates(
+ list: AvailableDingtalkSources | null,
+ registeredNames: string[]
+): DiscoveryCandidate[] {
const registered = new Set(registeredNames.map((name) => name.trim()).filter(Boolean))
const knowledgeBases = (list?.knowledge_bases ?? []).map((item): DiscoveryCandidate => ({
key: `kb:${item.space_id}`,
diff --git a/web/src/documentFixture.ts b/web/src/documentFixture.ts
index a807af4..8ac0563 100644
--- a/web/src/documentFixture.ts
+++ b/web/src/documentFixture.ts
@@ -1,6 +1,23 @@
import type { SourceAsset } from './assets'
export const document: SourceAsset = {
- id: 'a', connection_id: 'pp-1', connection_name: 'PP', source_type: 'pp', title: '手册.pdf', directory_path: '使用指南', visibility_origin: 'pp_public_tag', visibility: 'external', size_bytes: 1024,
- content_state: 'verified', source_change: 'unchanged', legacy_status: 'completed', has_publication: true, ingest_state: 'uploaded', processing_state: 'completed', retrieval_state: 'unpublished', last_error: '', publication_id: 'p-a', updated_at: '2026-09-06T01:00:00Z',
+ id: 'a',
+ connection_id: 'pp-1',
+ connection_name: 'PP',
+ source_type: 'pp',
+ title: '手册.pdf',
+ directory_path: '使用指南',
+ visibility_origin: 'pp_public_tag',
+ visibility: 'external',
+ size_bytes: 1024,
+ content_state: 'verified',
+ source_change: 'unchanged',
+ legacy_status: 'completed',
+ has_publication: true,
+ ingest_state: 'uploaded',
+ processing_state: 'completed',
+ retrieval_state: 'unpublished',
+ last_error: '',
+ publication_id: 'p-a',
+ updated_at: '2026-09-06T01:00:00Z',
}
diff --git a/web/src/documentWorkbench.css b/web/src/documentWorkbench.css
index 7d05c26..6979d71 100644
--- a/web/src/documentWorkbench.css
+++ b/web/src/documentWorkbench.css
@@ -1,96 +1,488 @@
-.document-workbench { max-width: 1800px; }
-.document-layout { display: grid; grid-template-columns: 250px minmax(0, 1fr); gap: 18px; align-items: start; }
-.directory-panel, .document-content { min-width: 0; background: white; border: 1px solid var(--border-base); border-radius: 14px; }
-.directory-panel { padding: 10px; max-height: calc(100vh - 220px); overflow-y: auto; }
-.directory-panel .folder { display: flex; width: 100%; align-items: center; justify-content: space-between; gap: 10px; border: 0; border-radius: 8px; padding: 11px 10px; background: transparent; color: var(--ink-base); text-align: left; box-shadow: none; }
-.directory-panel .folder:hover { background: var(--c-run-bg); }
-.directory-panel .folder.active { background: var(--c-run-bg); color: var(--c-run); }
-.directory-panel .folder span { overflow-wrap: anywhere; }
-.directory-panel .folder.child { padding-left: 23px; font-size: 12px; font-weight: 500; }
-.directory-panel .folder b { flex: 0 0 auto; font-size: 12px; }
-.directory-note { display: block; padding: 8px 12px; color: var(--ink-mute); font-size: 12px; line-height: 1.6; }
-.source-unconnected { margin: 8px 0; padding: 12px; border-top: 1px solid var(--bg-soft); color: var(--ink-mute); }
-.source-unconnected small { display: block; margin-top: 6px; }
-.test-document-toggle { display: flex; align-items: center; gap: 8px; margin: 12px 8px 4px; padding-top: 12px; border-top: 1px solid var(--border-base); color: var(--ink-mute); font-size: 12px; }
-.source-progress { margin-bottom: 16px; padding: 16px 18px; border: 1px solid var(--border-base); border-radius: 14px; background: var(--bg-panel); }
-.section-title { display: flex; justify-content: space-between; gap: 16px; margin-bottom: 12px; color: var(--ink-base); }
-.section-title span { color: var(--ink-mute); font-size: 12px; }
-.source-progress-grid { display: grid; grid-template-columns: repeat(auto-fit,minmax(230px,1fr)); gap: 10px; }
-.source-progress-card { display: grid; gap: 6px; padding: 13px 15px; border: 1px solid var(--border-base); border-radius: 10px; background: var(--bg-soft); color: var(--ink-base); text-align: left; box-shadow: none; }
-.source-progress-card.active { border-color: var(--c-run); background: var(--c-run-bg); }
-.source-progress-card span { font-weight: 700; }
-.source-progress-card small { color: var(--ink-mute); }
+.document-workbench {
+ max-width: 1800px;
+}
+.document-layout {
+ display: grid;
+ grid-template-columns: 250px minmax(0, 1fr);
+ gap: 18px;
+ align-items: start;
+}
+.directory-panel,
+.document-content {
+ min-width: 0;
+ background: white;
+ border: 1px solid var(--border-base);
+ border-radius: 14px;
+}
+.directory-panel {
+ padding: 10px;
+ max-height: calc(100vh - 220px);
+ overflow-y: auto;
+}
+.directory-panel .folder {
+ display: flex;
+ width: 100%;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+ border: 0;
+ border-radius: 8px;
+ padding: 11px 10px;
+ background: transparent;
+ color: var(--ink-base);
+ text-align: left;
+ box-shadow: none;
+}
+.directory-panel .folder:hover {
+ background: var(--c-run-bg);
+}
+.directory-panel .folder.active {
+ background: var(--c-run-bg);
+ color: var(--c-run);
+}
+.directory-panel .folder span {
+ overflow-wrap: anywhere;
+}
+.directory-panel .folder.child {
+ padding-left: 23px;
+ font-size: 12px;
+ font-weight: 500;
+}
+.directory-panel .folder b {
+ flex: 0 0 auto;
+ font-size: 12px;
+}
+.directory-note {
+ display: block;
+ padding: 8px 12px;
+ color: var(--ink-mute);
+ font-size: 12px;
+ line-height: 1.6;
+}
+.source-unconnected {
+ margin: 8px 0;
+ padding: 12px;
+ border-top: 1px solid var(--bg-soft);
+ color: var(--ink-mute);
+}
+.source-unconnected small {
+ display: block;
+ margin-top: 6px;
+}
+.test-document-toggle {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin: 12px 8px 4px;
+ padding-top: 12px;
+ border-top: 1px solid var(--border-base);
+ color: var(--ink-mute);
+ font-size: 12px;
+}
+.source-progress {
+ margin-bottom: 16px;
+ padding: 16px 18px;
+ border: 1px solid var(--border-base);
+ border-radius: 14px;
+ background: var(--bg-panel);
+}
+.section-title {
+ display: flex;
+ justify-content: space-between;
+ gap: 16px;
+ margin-bottom: 12px;
+ color: var(--ink-base);
+}
+.section-title span {
+ color: var(--ink-mute);
+ font-size: 12px;
+}
+.source-progress-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
+ gap: 10px;
+}
+.source-progress-card {
+ display: grid;
+ gap: 6px;
+ padding: 13px 15px;
+ border: 1px solid var(--border-base);
+ border-radius: 10px;
+ background: var(--bg-soft);
+ color: var(--ink-base);
+ text-align: left;
+ box-shadow: none;
+}
+.source-progress-card.active {
+ border-color: var(--c-run);
+ background: var(--c-run-bg);
+}
+.source-progress-card span {
+ font-weight: 700;
+}
+.source-progress-card small {
+ color: var(--ink-mute);
+}
-.document-counts { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); padding: 18px; border-bottom: 1px solid var(--border-base); gap: 10px; }
-.document-counts span { font-size: 12px; color: var(--ink-mute); }
-.document-counts b { display: block; margin-top: 5px; font-size: 24px; color: var(--ink-strong); }
-.document-filters { display: flex; flex-wrap: wrap; gap: 12px; padding: 16px 18px; align-items: end; }
-.document-filters label { display: grid; gap: 6px; font-size: 12px; color: var(--ink-base); }
-.document-filters label:first-child { flex: 1; min-width: 140px; }
-.document-filters input, .document-filters select { width: 100%; min-width: 0; border: 1px solid var(--border-strong); border-radius: 8px; padding: 10px; background: white; color: var(--ink-strong); }
-.scope-editor { margin: 14px 18px 2px; padding: 13px 14px; border: 1px solid var(--border-strong); border-radius: 10px; background: var(--bg-soft); }
-.scope-editor-heading { display: flex; justify-content: space-between; gap: 12px; align-items: center; }
-.scope-editor-heading > div { display: grid; gap: 3px; }
-.scope-editor-heading span, .scope-rule-meta, .scope-editor small { color: var(--ink-mute); font-size: 11px; }
-.inherited-badge { padding: 5px 7px; border-radius: 6px; background: var(--bg-soft); color: var(--ink-mute); font-weight: 650; }
-.scope-rule-meta { margin: 7px 0 10px; }
-.scope-editor-fields { display: grid; grid-template-columns: 120px 160px minmax(180px,1fr); gap: 9px; }
-.scope-editor-fields label { display: grid; gap: 4px; color: var(--ink-base); font-size: 11px; }
-.scope-editor-fields input, .scope-editor-fields select { min-width: 0; padding: 8px; border: 1px solid var(--border-strong); border-radius: 7px; background: var(--bg-panel); }
-.scope-editor-actions { display: flex; gap: 8px; align-items: center; margin: 10px 0 7px; }
-.scope-editor-actions button { padding: 8px 12px; }
-.scope-editor-actions .secondary { background: var(--bg-panel); color: var(--ink-base); border-color: var(--ink-faint); box-shadow: none; }
-.scope-error, .scope-unavailable { margin: 10px 18px; padding: 10px 12px; border-radius: 8px; background: var(--c-warn-bg); color: var(--c-warn); font-size: 12px; }
-.document-location { padding: 0 18px 14px; font-size: 13px; font-weight: 650; color: var(--ink-base); }
-.document-location span { float: right; color: var(--ink-mute); font-weight: 400; }
-.document-table { table-layout: fixed; width: 100%; }
-.document-content .table-wrap { max-height: max(300px, calc(100vh - 470px)); overflow: auto; }
-.document-table thead th { position: sticky; top: 0; z-index: 1; background: var(--bg-soft); }
-.document-table th:nth-child(1) { width: 37%; }
-.document-table th:nth-child(2) { width: 16%; }
-.document-table th:nth-child(3) { width: 19%; }
-.document-table th:nth-child(4) { width: 10%; }
-.document-table th:nth-child(5) { width: 18%; }
-.document-table th, .document-table td { padding: 13px 12px; white-space: normal; overflow-wrap: anywhere; font-size: 12px; }
-.document-table small { display: block; margin-top: 7px; color: var(--ink-mute); font-size: 11px; }
-.document-workbench .file-link { padding: 0; border: 0; background: transparent; color: var(--c-run); text-align: left; font-size: 13px; box-shadow: none; white-space: normal; overflow-wrap: anywhere; line-height: 1.55; }
-.document-workbench .file-link:hover { text-decoration: underline; }
-.scope-badge, .document-stage { display: inline-block; border-radius: 6px; padding: 5px 7px; font-size: 12px; font-weight: 650; background: var(--border-soft); color: var(--ink-base); }
-.scope-badge.external { color: var(--c-done); background: var(--c-done-bg); }
-.scope-badge.internal { color: var(--ink-base); background: var(--c-run-bg); }
-.document-stage.completed, .document-stage.reused { color: var(--c-done); background: var(--c-done-bg); }
-.document-stage.blocked, .document-stage.failed { color: var(--c-warn); background: var(--c-warn-bg); }
-.document-stage.parsing, .document-stage.indexing, .document-stage.uploading, .document-stage.downloading { color: var(--c-run); background: var(--c-run-bg); }
-.document-inspector { position: fixed; top: 80px; right: 20px; bottom: 20px; z-index: 50; width: min(480px, calc(100vw - 40px)); overflow-y: auto; background: white; border: 2px solid var(--c-run-border); box-shadow: 0 12px 48px #12345240; margin: 0; }
-.document-inspector h2 { overflow-wrap: anywhere; font-size: 17px; }
-.inspector-body { padding: 0 22px 20px; font-size: 13px; line-height: 1.7; color: var(--ink-base); }
-.inspector-body dl { display: grid; grid-template-columns: 100px minmax(0,1fr); gap: 8px; }
-.inspector-body dd { margin: 0; overflow-wrap: anywhere; }
-.file-facts { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 8px; margin: 14px 0; }
-.file-facts span { display: grid; padding: 9px; border-radius: 8px; background: var(--bg-soft); color: var(--ink-mute); font-size: 11px; }
-.file-facts b { margin-top: 3px; color: var(--ink-base); font-size: 12px; }
-.document-pipeline { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap: 5px; margin: 14px 0; }
-.pipeline-step { display: grid; grid-template-columns: 9px minmax(0,1fr); gap: 6px; align-items: start; padding: 8px 5px; border-radius: 7px; background: var(--bg-soft); }
-.pipeline-step > span { width: 8px; height: 8px; margin-top: 5px; border-radius: 50%; background: var(--ink-faint); }
-.pipeline-step div { display: grid; min-width: 0; }
-.pipeline-step strong { font-size: 10px; color: var(--ink-base); }
-.pipeline-step small { color: var(--ink-mute); font-size: 9px; overflow-wrap: anywhere; }
-.pipeline-step.done > span { background: var(--c-done); }
-.pipeline-step.active > span { background: var(--c-run); }
-.pipeline-step.problem > span { background: var(--c-warn); }
-.document-inspector .scope-editor { margin: 16px 0; }
-.document-workbench button:disabled { cursor: not-allowed; color: var(--ink-faint); background: var(--bg-soft); border-color: var(--border-base); }
+.document-counts {
+ display: grid;
+ grid-template-columns: repeat(6, minmax(0, 1fr));
+ padding: 18px;
+ border-bottom: 1px solid var(--border-base);
+ gap: 10px;
+}
+.document-counts span {
+ font-size: 12px;
+ color: var(--ink-mute);
+}
+.document-counts b {
+ display: block;
+ margin-top: 5px;
+ font-size: 24px;
+ color: var(--ink-strong);
+}
+.document-filters {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 12px;
+ padding: 16px 18px;
+ align-items: end;
+}
+.document-filters label {
+ display: grid;
+ gap: 6px;
+ font-size: 12px;
+ color: var(--ink-base);
+}
+.document-filters label:first-child {
+ flex: 1;
+ min-width: 140px;
+}
+.document-filters input,
+.document-filters select {
+ width: 100%;
+ min-width: 0;
+ border: 1px solid var(--border-strong);
+ border-radius: 8px;
+ padding: 10px;
+ background: white;
+ color: var(--ink-strong);
+}
+.scope-editor {
+ margin: 14px 18px 2px;
+ padding: 13px 14px;
+ border: 1px solid var(--border-strong);
+ border-radius: 10px;
+ background: var(--bg-soft);
+}
+.scope-editor-heading {
+ display: flex;
+ justify-content: space-between;
+ gap: 12px;
+ align-items: center;
+}
+.scope-editor-heading > div {
+ display: grid;
+ gap: 3px;
+}
+.scope-editor-heading span,
+.scope-rule-meta,
+.scope-editor small {
+ color: var(--ink-mute);
+ font-size: 11px;
+}
+.inherited-badge {
+ padding: 5px 7px;
+ border-radius: 6px;
+ background: var(--bg-soft);
+ color: var(--ink-mute);
+ font-weight: 650;
+}
+.scope-rule-meta {
+ margin: 7px 0 10px;
+}
+.scope-editor-fields {
+ display: grid;
+ grid-template-columns: 120px 160px minmax(180px, 1fr);
+ gap: 9px;
+}
+.scope-editor-fields label {
+ display: grid;
+ gap: 4px;
+ color: var(--ink-base);
+ font-size: 11px;
+}
+.scope-editor-fields input,
+.scope-editor-fields select {
+ min-width: 0;
+ padding: 8px;
+ border: 1px solid var(--border-strong);
+ border-radius: 7px;
+ background: var(--bg-panel);
+}
+.scope-editor-actions {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+ margin: 10px 0 7px;
+}
+.scope-editor-actions button {
+ padding: 8px 12px;
+}
+.scope-editor-actions .secondary {
+ background: var(--bg-panel);
+ color: var(--ink-base);
+ border-color: var(--ink-faint);
+ box-shadow: none;
+}
+.scope-error,
+.scope-unavailable {
+ margin: 10px 18px;
+ padding: 10px 12px;
+ border-radius: 8px;
+ background: var(--c-warn-bg);
+ color: var(--c-warn);
+ font-size: 12px;
+}
+.document-location {
+ padding: 0 18px 14px;
+ font-size: 13px;
+ font-weight: 650;
+ color: var(--ink-base);
+}
+.document-location span {
+ float: right;
+ color: var(--ink-mute);
+ font-weight: 400;
+}
+.document-table {
+ table-layout: fixed;
+ width: 100%;
+}
+.document-content .table-wrap {
+ max-height: max(300px, calc(100vh - 470px));
+ overflow: auto;
+}
+.document-table thead th {
+ position: sticky;
+ top: 0;
+ z-index: 1;
+ background: var(--bg-soft);
+}
+.document-table th:nth-child(1) {
+ width: 37%;
+}
+.document-table th:nth-child(2) {
+ width: 16%;
+}
+.document-table th:nth-child(3) {
+ width: 19%;
+}
+.document-table th:nth-child(4) {
+ width: 10%;
+}
+.document-table th:nth-child(5) {
+ width: 18%;
+}
+.document-table th,
+.document-table td {
+ padding: 13px 12px;
+ white-space: normal;
+ overflow-wrap: anywhere;
+ font-size: 12px;
+}
+.document-table small {
+ display: block;
+ margin-top: 7px;
+ color: var(--ink-mute);
+ font-size: 11px;
+}
+.document-workbench .file-link {
+ padding: 0;
+ border: 0;
+ background: transparent;
+ color: var(--c-run);
+ text-align: left;
+ font-size: 13px;
+ box-shadow: none;
+ white-space: normal;
+ overflow-wrap: anywhere;
+ line-height: 1.55;
+}
+.document-workbench .file-link:hover {
+ text-decoration: underline;
+}
+.scope-badge,
+.document-stage {
+ display: inline-block;
+ border-radius: 6px;
+ padding: 5px 7px;
+ font-size: 12px;
+ font-weight: 650;
+ background: var(--border-soft);
+ color: var(--ink-base);
+}
+.scope-badge.external {
+ color: var(--c-done);
+ background: var(--c-done-bg);
+}
+.scope-badge.internal {
+ color: var(--ink-base);
+ background: var(--c-run-bg);
+}
+.document-stage.completed,
+.document-stage.reused {
+ color: var(--c-done);
+ background: var(--c-done-bg);
+}
+.document-stage.blocked,
+.document-stage.failed {
+ color: var(--c-warn);
+ background: var(--c-warn-bg);
+}
+.document-stage.parsing,
+.document-stage.indexing,
+.document-stage.uploading,
+.document-stage.downloading {
+ color: var(--c-run);
+ background: var(--c-run-bg);
+}
+.document-inspector {
+ position: fixed;
+ top: 80px;
+ right: 20px;
+ bottom: 20px;
+ z-index: 50;
+ width: min(480px, calc(100vw - 40px));
+ overflow-y: auto;
+ background: white;
+ border: 2px solid var(--c-run-border);
+ box-shadow: 0 12px 48px #12345240;
+ margin: 0;
+}
+.document-inspector h2 {
+ overflow-wrap: anywhere;
+ font-size: 17px;
+}
+.inspector-body {
+ padding: 0 22px 20px;
+ font-size: 13px;
+ line-height: 1.7;
+ color: var(--ink-base);
+}
+.inspector-body dl {
+ display: grid;
+ grid-template-columns: 100px minmax(0, 1fr);
+ gap: 8px;
+}
+.inspector-body dd {
+ margin: 0;
+ overflow-wrap: anywhere;
+}
+.file-facts {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 8px;
+ margin: 14px 0;
+}
+.file-facts span {
+ display: grid;
+ padding: 9px;
+ border-radius: 8px;
+ background: var(--bg-soft);
+ color: var(--ink-mute);
+ font-size: 11px;
+}
+.file-facts b {
+ margin-top: 3px;
+ color: var(--ink-base);
+ font-size: 12px;
+}
+.document-pipeline {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 5px;
+ margin: 14px 0;
+}
+.pipeline-step {
+ display: grid;
+ grid-template-columns: 9px minmax(0, 1fr);
+ gap: 6px;
+ align-items: start;
+ padding: 8px 5px;
+ border-radius: 7px;
+ background: var(--bg-soft);
+}
+.pipeline-step > span {
+ width: 8px;
+ height: 8px;
+ margin-top: 5px;
+ border-radius: 50%;
+ background: var(--ink-faint);
+}
+.pipeline-step div {
+ display: grid;
+ min-width: 0;
+}
+.pipeline-step strong {
+ font-size: 10px;
+ color: var(--ink-base);
+}
+.pipeline-step small {
+ color: var(--ink-mute);
+ font-size: 9px;
+ overflow-wrap: anywhere;
+}
+.pipeline-step.done > span {
+ background: var(--c-done);
+}
+.pipeline-step.active > span {
+ background: var(--c-run);
+}
+.pipeline-step.problem > span {
+ background: var(--c-warn);
+}
+.document-inspector .scope-editor {
+ margin: 16px 0;
+}
+.document-workbench button:disabled {
+ cursor: not-allowed;
+ color: var(--ink-faint);
+ background: var(--bg-soft);
+ border-color: var(--border-base);
+}
@media (max-width: 1150px) {
- .document-layout { grid-template-columns: 205px minmax(0,1fr); }
- .document-counts { grid-template-columns: repeat(3,minmax(0,1fr)); }
- .document-table th, .document-table td { padding: 10px 7px; }
- .scope-editor-fields { grid-template-columns: 1fr 1fr; }
- .scope-reason { grid-column: 1/-1; }
+ .document-layout {
+ grid-template-columns: 205px minmax(0, 1fr);
+ }
+ .document-counts {
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ }
+ .document-table th,
+ .document-table td {
+ padding: 10px 7px;
+ }
+ .scope-editor-fields {
+ grid-template-columns: 1fr 1fr;
+ }
+ .scope-reason {
+ grid-column: 1/-1;
+ }
}
@media (max-width: 860px) {
- .document-layout { grid-template-columns: 1fr; }
- .directory-panel { max-height: 260px; }
- .document-table { min-width: 650px; }
- .document-counts { grid-template-columns: repeat(3,minmax(0,1fr)); }
- .section-title { display: grid; }
+ .document-layout {
+ grid-template-columns: 1fr;
+ }
+ .directory-panel {
+ max-height: 260px;
+ }
+ .document-table {
+ min-width: 650px;
+ }
+ .document-counts {
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ }
+ .section-title {
+ display: grid;
+ }
}
diff --git a/web/src/documentWorkbench.test.ts b/web/src/documentWorkbench.test.ts
index e1cc1fa..0a9cf8d 100644
--- a/web/src/documentWorkbench.test.ts
+++ b/web/src/documentWorkbench.test.ts
@@ -1,65 +1,105 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
-import { documentPipeline, documentStage, directoryGroups, filterDocuments, fetchDocumentCatalog, operationalDocuments, sourceProgress, visibilityOrigin } from './documentWorkbench'
+import {
+ documentPipeline,
+ documentStage,
+ directoryGroups,
+ filterDocuments,
+ fetchDocumentCatalog,
+ operationalDocuments,
+ sourceProgress,
+ visibilityOrigin,
+} from './documentWorkbench'
import { document } from './documentFixture'
afterEach(() => vi.unstubAllGlobals())
describe('document workbench', () => {
- it('groups by actual connection and directory, never merges independent sources', () => {
- const groups = directoryGroups([document, {...document, id:'b',connection_id:'pp-2'}])
- expect(groups).toHaveLength(2)
- expect(groups[0].count).toBe(1)
- expect(groups[0].path).toBe('使用指南')
- })
- it('maps observed stages and never treats missing publication as failure or published', () => {
- expect(documentStage(document)).toBe('completed')
- expect(documentStage({...document, has_publication:false, legacy_status:''})).toBe('waiting')
- expect(documentStage({...document, content_state:'over_limit',has_publication:false})).toBe('blocked')
- expect(documentStage({...document, legacy_status:'content_alias',has_publication:false})).toBe('reused')
- expect(documentStage({...document, processing_state:'stalled'})).toBe('failed')
- expect(documentStage({...document, processing_state:'finalizing'})).toBe('indexing')
- })
- it('builds a factual pipeline without inventing page progress or production status',()=>{
- expect(documentPipeline(document)).toEqual([
- {label:'取得文件',state:'done',detail:'文件已校验'},
- {label:'上传测试库',state:'done',detail:'上传完成'},
- {label:'解析与索引',state:'done',detail:'解析完成'},
- {label:'测试检索',state:'waiting',detail:'未证明已启用'},
- ])
- expect(documentPipeline({...document,content_state:'over_limit',has_publication:false})[0]).toEqual({label:'取得文件',state:'problem',detail:'文件超限'})
- })
- it('shows whether scope comes from a file override, directory inheritance or source metadata',()=>{
- expect(visibilityOrigin({...document,visibility_origin:'file_override'})).toBe('文件单独设置')
- expect(visibilityOrigin({...document,visibility_origin:'directory_rule',scope_rule_path:'产品'})).toBe('继承目录设置:产品')
- expect(visibilityOrigin({...document,visibility_origin:'configured_scope'})).toBe('来源范围设置')
- })
- it('filters source directory, scope, stage and filename together', () => {
- expect(filterDocuments([document, {...document,id:'b',visibility:'internal'}], {connection:'pp-1',directory:'使用指南',query:'手册',visibility:'external',stage:'completed'}).map(x=>x.id)).toEqual(['a'])
- })
- it('hides isolated acceptance fixtures unless the engineer asks to see them',()=>{
- const manual={...document,id:'manual',source_type:'manual' as const,connection_id:'manual-1'}
- expect(operationalDocuments([document,manual],false).map(x=>x.id)).toEqual(['a'])
- expect(operationalDocuments([document,manual],true)).toHaveLength(2)
- })
- it('aggregates truthful source-level processing stages without fabricated percentages',()=>{
- const progress=sourceProgress([
- document,
- {...document,id:'active',processing_state:'processing'},
- {...document,id:'blocked',content_state:'over_limit',has_publication:false},
- {...document,id:'waiting',has_publication:false,legacy_status:''},
- {...document,id:'reused',legacy_status:'content_alias',has_publication:false},
- ])
- expect(progress).toEqual([{connection:'pp-1',total:5,completed:1,active:1,attention:1,waiting:1,reused:1}])
- })
- it('collects every page before claiming a complete directory tree', async () => {
- const fetch = vi.fn().mockResolvedValueOnce(new Response(JSON.stringify({data:[document],summary:{total:2},limit:1,offset:0}))).mockResolvedValueOnce(new Response(JSON.stringify({data:[{...document,id:'b'}],summary:{total:2},limit:1,offset:1})))
- vi.stubGlobal('fetch',fetch)
- expect(await fetchDocumentCatalog()).toHaveLength(2)
- expect(fetch).toHaveBeenCalledTimes(2)
- })
- it('rejects incomplete or duplicate pages rather than reporting wrong totals', async () => {
- vi.stubGlobal('fetch',vi.fn()
- .mockResolvedValueOnce(new Response(JSON.stringify({data:[document],summary:{total:2},limit:1,offset:0})))
- .mockResolvedValueOnce(new Response(JSON.stringify({data:[document],summary:{total:2},limit:1,offset:1}))))
- await expect(fetchDocumentCatalog()).rejects.toThrow('文档分页发生变化')
- })
+ it('groups by actual connection and directory, never merges independent sources', () => {
+ const groups = directoryGroups([document, { ...document, id: 'b', connection_id: 'pp-2' }])
+ expect(groups).toHaveLength(2)
+ expect(groups[0].count).toBe(1)
+ expect(groups[0].path).toBe('使用指南')
+ })
+ it('maps observed stages and never treats missing publication as failure or published', () => {
+ expect(documentStage(document)).toBe('completed')
+ expect(documentStage({ ...document, has_publication: false, legacy_status: '' })).toBe('waiting')
+ expect(documentStage({ ...document, content_state: 'over_limit', has_publication: false })).toBe('blocked')
+ expect(documentStage({ ...document, legacy_status: 'content_alias', has_publication: false })).toBe('reused')
+ expect(documentStage({ ...document, processing_state: 'stalled' })).toBe('failed')
+ expect(documentStage({ ...document, processing_state: 'finalizing' })).toBe('indexing')
+ })
+ it('builds a factual pipeline without inventing page progress or production status', () => {
+ expect(documentPipeline(document)).toEqual([
+ { label: '取得文件', state: 'done', detail: '文件已校验' },
+ { label: '上传测试库', state: 'done', detail: '上传完成' },
+ { label: '解析与索引', state: 'done', detail: '解析完成' },
+ { label: '测试检索', state: 'waiting', detail: '未证明已启用' },
+ ])
+ expect(documentPipeline({ ...document, content_state: 'over_limit', has_publication: false })[0]).toEqual({
+ label: '取得文件',
+ state: 'problem',
+ detail: '文件超限',
+ })
+ })
+ it('shows whether scope comes from a file override, directory inheritance or source metadata', () => {
+ expect(visibilityOrigin({ ...document, visibility_origin: 'file_override' })).toBe('文件单独设置')
+ expect(visibilityOrigin({ ...document, visibility_origin: 'directory_rule', scope_rule_path: '产品' })).toBe(
+ '继承目录设置:产品'
+ )
+ expect(visibilityOrigin({ ...document, visibility_origin: 'configured_scope' })).toBe('来源范围设置')
+ })
+ it('filters source directory, scope, stage and filename together', () => {
+ expect(
+ filterDocuments([document, { ...document, id: 'b', visibility: 'internal' }], {
+ connection: 'pp-1',
+ directory: '使用指南',
+ query: '手册',
+ visibility: 'external',
+ stage: 'completed',
+ }).map((x) => x.id)
+ ).toEqual(['a'])
+ })
+ it('hides isolated acceptance fixtures unless the engineer asks to see them', () => {
+ const manual = { ...document, id: 'manual', source_type: 'manual' as const, connection_id: 'manual-1' }
+ expect(operationalDocuments([document, manual], false).map((x) => x.id)).toEqual(['a'])
+ expect(operationalDocuments([document, manual], true)).toHaveLength(2)
+ })
+ it('aggregates truthful source-level processing stages without fabricated percentages', () => {
+ const progress = sourceProgress([
+ document,
+ { ...document, id: 'active', processing_state: 'processing' },
+ { ...document, id: 'blocked', content_state: 'over_limit', has_publication: false },
+ { ...document, id: 'waiting', has_publication: false, legacy_status: '' },
+ { ...document, id: 'reused', legacy_status: 'content_alias', has_publication: false },
+ ])
+ expect(progress).toEqual([
+ { connection: 'pp-1', total: 5, completed: 1, active: 1, attention: 1, waiting: 1, reused: 1 },
+ ])
+ })
+ it('collects every page before claiming a complete directory tree', async () => {
+ const fetch = vi
+ .fn()
+ .mockResolvedValueOnce(
+ new Response(JSON.stringify({ data: [document], summary: { total: 2 }, limit: 1, offset: 0 }))
+ )
+ .mockResolvedValueOnce(
+ new Response(JSON.stringify({ data: [{ ...document, id: 'b' }], summary: { total: 2 }, limit: 1, offset: 1 }))
+ )
+ vi.stubGlobal('fetch', fetch)
+ expect(await fetchDocumentCatalog()).toHaveLength(2)
+ expect(fetch).toHaveBeenCalledTimes(2)
+ })
+ it('rejects incomplete or duplicate pages rather than reporting wrong totals', async () => {
+ vi.stubGlobal(
+ 'fetch',
+ vi
+ .fn()
+ .mockResolvedValueOnce(
+ new Response(JSON.stringify({ data: [document], summary: { total: 2 }, limit: 1, offset: 0 }))
+ )
+ .mockResolvedValueOnce(
+ new Response(JSON.stringify({ data: [document], summary: { total: 2 }, limit: 1, offset: 1 }))
+ )
+ )
+ await expect(fetchDocumentCatalog()).rejects.toThrow('文档分页发生变化')
+ })
})
diff --git a/web/src/documentWorkbench.ts b/web/src/documentWorkbench.ts
index 485baec..8dacbec 100644
--- a/web/src/documentWorkbench.ts
+++ b/web/src/documentWorkbench.ts
@@ -1,128 +1,203 @@
import { fetchSourceAssets } from './assets'
import type { SourceAsset } from './assets'
-export type DocumentStage = 'waiting' | 'downloading' | 'uploading' | 'parsing' | 'indexing' | 'completed' | 'reused' | 'blocked' | 'failed'
+export type DocumentStage =
+ 'waiting' | 'downloading' | 'uploading' | 'parsing' | 'indexing' | 'completed' | 'reused' | 'blocked' | 'failed'
export const stageLabels: Record = {
- waiting: '等待处理', downloading: '下载中', uploading: '上传中', parsing: '解析中', indexing: '建立索引', completed: '解析完成', reused: '内容重复,复用已有文档', blocked: '文件超限', failed: '需要处理',
+ waiting: '等待处理',
+ downloading: '下载中',
+ uploading: '上传中',
+ parsing: '解析中',
+ indexing: '建立索引',
+ completed: '解析完成',
+ reused: '内容重复,复用已有文档',
+ blocked: '文件超限',
+ failed: '需要处理',
}
-export type PipelineState='waiting'|'active'|'done'|'problem'
-export interface PipelineStep {label:string;state:PipelineState;detail:string}
-export function documentPipeline(item:SourceAsset):PipelineStep[]{
- let source:PipelineStep={label:'取得文件',state:'waiting',detail:'等待取得文件'}
- if(item.content_state==='verified')source={label:'取得文件',state:'done',detail:'文件已校验'}
- else if(item.content_state==='downloading')source={label:'取得文件',state:'active',detail:'下载中'}
- else if(item.content_state==='over_limit')source={label:'取得文件',state:'problem',detail:'文件超限'}
- else if(['invalid','failed'].includes(item.content_state))source={label:'取得文件',state:'problem',detail:'文件校验失败'}
- let upload:PipelineStep={label:'上传测试库',state:'waiting',detail:'等待上传'}
- if(item.legacy_status==='content_alias')upload={label:'上传测试库',state:'done',detail:'复用已有内容'}
- else if(item.ingest_state==='uploaded')upload={label:'上传测试库',state:'done',detail:'上传完成'}
- else if(item.ingest_state==='uploading')upload={label:'上传测试库',state:'active',detail:'上传中'}
- else if(item.ingest_state==='failed')upload={label:'上传测试库',state:'problem',detail:'上传失败'}
- let processing:PipelineStep={label:'解析与索引',state:'waiting',detail:'等待解析'}
- if(item.legacy_status==='content_alias')processing={label:'解析与索引',state:'done',detail:'复用已有解析结果'}
- else if(item.processing_state==='completed')processing={label:'解析与索引',state:'done',detail:'解析完成'}
- else if(item.processing_state==='processing')processing={label:'解析与索引',state:'active',detail:'解析中'}
- else if(item.processing_state==='finalizing')processing={label:'解析与索引',state:'active',detail:'建立索引中'}
- else if(['failed','stalled'].includes(item.processing_state))processing={label:'解析与索引',state:'problem',detail:item.processing_state==='stalled'?'处理停滞':'解析失败'}
- let retrieval:PipelineStep={label:'测试检索',state:'waiting',detail:'未证明已启用'}
- if(item.retrieval_state==='enabled')retrieval={label:'测试检索',state:'done',detail:'测试环境已启用'}
- else if(['enabling','disabling'].includes(item.retrieval_state))retrieval={label:'测试检索',state:'active',detail:'状态变更中'}
- else if(item.retrieval_state==='failed')retrieval={label:'测试检索',state:'problem',detail:'检索状态失败'}
- return [source,upload,processing,retrieval]
+export type PipelineState = 'waiting' | 'active' | 'done' | 'problem'
+export interface PipelineStep {
+ label: string
+ state: PipelineState
+ detail: string
+}
+export function documentPipeline(item: SourceAsset): PipelineStep[] {
+ let source: PipelineStep = { label: '取得文件', state: 'waiting', detail: '等待取得文件' }
+ if (item.content_state === 'verified') source = { label: '取得文件', state: 'done', detail: '文件已校验' }
+ else if (item.content_state === 'downloading') source = { label: '取得文件', state: 'active', detail: '下载中' }
+ else if (item.content_state === 'over_limit') source = { label: '取得文件', state: 'problem', detail: '文件超限' }
+ else if (['invalid', 'failed'].includes(item.content_state))
+ source = { label: '取得文件', state: 'problem', detail: '文件校验失败' }
+ let upload: PipelineStep = { label: '上传测试库', state: 'waiting', detail: '等待上传' }
+ if (item.legacy_status === 'content_alias') upload = { label: '上传测试库', state: 'done', detail: '复用已有内容' }
+ else if (item.ingest_state === 'uploaded') upload = { label: '上传测试库', state: 'done', detail: '上传完成' }
+ else if (item.ingest_state === 'uploading') upload = { label: '上传测试库', state: 'active', detail: '上传中' }
+ else if (item.ingest_state === 'failed') upload = { label: '上传测试库', state: 'problem', detail: '上传失败' }
+ let processing: PipelineStep = { label: '解析与索引', state: 'waiting', detail: '等待解析' }
+ if (item.legacy_status === 'content_alias')
+ processing = { label: '解析与索引', state: 'done', detail: '复用已有解析结果' }
+ else if (item.processing_state === 'completed')
+ processing = { label: '解析与索引', state: 'done', detail: '解析完成' }
+ else if (item.processing_state === 'processing')
+ processing = { label: '解析与索引', state: 'active', detail: '解析中' }
+ else if (item.processing_state === 'finalizing')
+ processing = { label: '解析与索引', state: 'active', detail: '建立索引中' }
+ else if (['failed', 'stalled'].includes(item.processing_state))
+ processing = {
+ label: '解析与索引',
+ state: 'problem',
+ detail: item.processing_state === 'stalled' ? '处理停滞' : '解析失败',
+ }
+ let retrieval: PipelineStep = { label: '测试检索', state: 'waiting', detail: '未证明已启用' }
+ if (item.retrieval_state === 'enabled') retrieval = { label: '测试检索', state: 'done', detail: '测试环境已启用' }
+ else if (['enabling', 'disabling'].includes(item.retrieval_state))
+ retrieval = { label: '测试检索', state: 'active', detail: '状态变更中' }
+ else if (item.retrieval_state === 'failed')
+ retrieval = { label: '测试检索', state: 'problem', detail: '检索状态失败' }
+ return [source, upload, processing, retrieval]
}
export function documentStage(item: SourceAsset): DocumentStage {
- if (item.source_change === 'unavailable' || item.source_change === 'deleted') return 'failed'
- if (item.content_state === 'over_limit') return 'blocked'
- if (['invalid','failed'].includes(item.content_state) || item.ingest_state === 'failed' || ['failed','stalled'].includes(item.processing_state)) return 'failed'
- if (item.content_state === 'downloading') return 'downloading'
- if (item.legacy_status === 'content_alias' && !item.has_publication) return 'reused'
- if (!item.has_publication) return 'waiting'
- if (item.ingest_state === 'uploading') return 'uploading'
- switch (item.processing_state) {
- case 'completed': return 'completed'
- case 'processing': return 'parsing'
- case 'finalizing': return 'indexing'
- default: return 'waiting'
- }
+ if (item.source_change === 'unavailable' || item.source_change === 'deleted') return 'failed'
+ if (item.content_state === 'over_limit') return 'blocked'
+ if (
+ ['invalid', 'failed'].includes(item.content_state) ||
+ item.ingest_state === 'failed' ||
+ ['failed', 'stalled'].includes(item.processing_state)
+ )
+ return 'failed'
+ if (item.content_state === 'downloading') return 'downloading'
+ if (item.legacy_status === 'content_alias' && !item.has_publication) return 'reused'
+ if (!item.has_publication) return 'waiting'
+ if (item.ingest_state === 'uploading') return 'uploading'
+ switch (item.processing_state) {
+ case 'completed':
+ return 'completed'
+ case 'processing':
+ return 'parsing'
+ case 'finalizing':
+ return 'indexing'
+ default:
+ return 'waiting'
+ }
}
export function documentProblem(item: SourceAsset): string {
- if (item.source_change === 'unavailable' || item.source_change === 'deleted') return '来源文件缺失或暂不可访问,请核对来源权限和文件位置。'
- if (documentStage(item) === 'blocked') return '超过当前导入大小上限,尚未上传;请拆分文件或由管理员调整限制。'
- if (item.processing_state === 'stalled') return '长时间未收到处理进展,请检查解析服务。'
- if (documentStage(item) === 'failed') return '处理未完成,请查看错误详情;符合重试条件时可重新处理。'
- if (documentStage(item) === 'reused') return '文件内容完全相同,未重复上传。'
- return ''
+ if (item.source_change === 'unavailable' || item.source_change === 'deleted')
+ return '来源文件缺失或暂不可访问,请核对来源权限和文件位置。'
+ if (documentStage(item) === 'blocked') return '超过当前导入大小上限,尚未上传;请拆分文件或由管理员调整限制。'
+ if (item.processing_state === 'stalled') return '长时间未收到处理进展,请检查解析服务。'
+ if (documentStage(item) === 'failed') return '处理未完成,请查看错误详情;符合重试条件时可重新处理。'
+ if (documentStage(item) === 'reused') return '文件内容完全相同,未重复上传。'
+ return ''
}
export function visibilityOrigin(item: SourceAsset): string {
- switch (item.visibility_origin) {
- case 'pp_public_tag': return '来自PP公开标记'
- case 'manual': return '人工指定'
- case 'configured_scope': return '来源范围设置'
- case 'file_override': return '文件单独设置'
- case 'directory_rule': return item.scope_rule_path ? `继承目录设置:${item.scope_rule_path}` : '继承来源范围设置'
- default: return '设置来源待核对'
- }
+ switch (item.visibility_origin) {
+ case 'pp_public_tag':
+ return '来自PP公开标记'
+ case 'manual':
+ return '人工指定'
+ case 'configured_scope':
+ return '来源范围设置'
+ case 'file_override':
+ return '文件单独设置'
+ case 'directory_rule':
+ return item.scope_rule_path ? `继承目录设置:${item.scope_rule_path}` : '继承来源范围设置'
+ default:
+ return '设置来源待核对'
+ }
}
export function connectionKey(item: SourceAsset): string {
- return item.connection_id || `${item.source_type}:${item.connection_name}`
+ return item.connection_id || `${item.source_type}:${item.connection_name}`
}
export function directoryGroups(items: SourceAsset[]) {
- const groups = new Map()
- for (const item of items) {
- const connection = connectionKey(item)
- const path = item.directory_path || ''
- // JSON tuple avoids cross-source collisions, even when labels contain separators.
- const key = JSON.stringify([connection, path])
- const group = groups.get(key) || {key, connection, path, count:0}
- group.count++
- groups.set(key, group)
- }
- return [...groups.values()].sort((a,b) => a.path.localeCompare(b.path,'zh-CN') || a.connection.localeCompare(b.connection))
+ const groups = new Map()
+ for (const item of items) {
+ const connection = connectionKey(item)
+ const path = item.directory_path || ''
+ // JSON tuple avoids cross-source collisions, even when labels contain separators.
+ const key = JSON.stringify([connection, path])
+ const group = groups.get(key) || { key, connection, path, count: 0 }
+ group.count++
+ groups.set(key, group)
+ }
+ return [...groups.values()].sort(
+ (a, b) => a.path.localeCompare(b.path, 'zh-CN') || a.connection.localeCompare(b.connection)
+ )
}
-export function operationalDocuments(items:SourceAsset[],includeTestDocuments:boolean) {
- return includeTestDocuments ? items : items.filter(item=>item.source_type!=='manual')
+export function operationalDocuments(items: SourceAsset[], includeTestDocuments: boolean) {
+ return includeTestDocuments ? items : items.filter((item) => item.source_type !== 'manual')
}
-export interface SourceProgress {connection:string;total:number;completed:number;active:number;attention:number;waiting:number;reused:number}
-export function sourceProgress(items:SourceAsset[]):SourceProgress[] {
- const result=new Map()
- for(const item of items){
- const connection=connectionKey(item)
- const progress=result.get(connection)||{connection,total:0,completed:0,active:0,attention:0,waiting:0,reused:0}
- const stage=documentStage(item)
- progress.total++
- if(stage==='completed') progress.completed++
- if(['downloading','uploading','parsing','indexing'].includes(stage)) progress.active++
- if(['blocked','failed'].includes(stage)) progress.attention++
- if(stage==='waiting') progress.waiting++
- if(stage==='reused') progress.reused++
- result.set(connection,progress)
- }
- return [...result.values()].sort((a,b)=>a.connection.localeCompare(b.connection))
+export interface SourceProgress {
+ connection: string
+ total: number
+ completed: number
+ active: number
+ attention: number
+ waiting: number
+ reused: number
+}
+export function sourceProgress(items: SourceAsset[]): SourceProgress[] {
+ const result = new Map()
+ for (const item of items) {
+ const connection = connectionKey(item)
+ const progress = result.get(connection) || {
+ connection,
+ total: 0,
+ completed: 0,
+ active: 0,
+ attention: 0,
+ waiting: 0,
+ reused: 0,
+ }
+ const stage = documentStage(item)
+ progress.total++
+ if (stage === 'completed') progress.completed++
+ if (['downloading', 'uploading', 'parsing', 'indexing'].includes(stage)) progress.active++
+ if (['blocked', 'failed'].includes(stage)) progress.attention++
+ if (stage === 'waiting') progress.waiting++
+ if (stage === 'reused') progress.reused++
+ result.set(connection, progress)
+ }
+ return [...result.values()].sort((a, b) => a.connection.localeCompare(b.connection))
+}
+export interface DocumentFilter {
+ connection: string
+ directory: string | null
+ query: string
+ visibility: string
+ stage: string
}
-export interface DocumentFilter { connection:string; directory:string|null; query:string; visibility:string; stage:string }
export function filterDocuments(items: SourceAsset[], filter: DocumentFilter) {
- const query = filter.query.trim().toLocaleLowerCase()
- return items.filter(item => (!filter.connection || connectionKey(item) === filter.connection)
- && (filter.directory === null || (item.directory_path || '') === filter.directory)
- && (!filter.visibility || item.visibility === filter.visibility)
- && (!filter.stage || documentStage(item) === filter.stage)
- && (!query || `${item.title || ''} ${item.directory_path || ''}`.toLocaleLowerCase().includes(query)))
+ const query = filter.query.trim().toLocaleLowerCase()
+ return items.filter(
+ (item) =>
+ (!filter.connection || connectionKey(item) === filter.connection) &&
+ (filter.directory === null || (item.directory_path || '') === filter.directory) &&
+ (!filter.visibility || item.visibility === filter.visibility) &&
+ (!filter.stage || documentStage(item) === filter.stage) &&
+ (!query || `${item.title || ''} ${item.directory_path || ''}`.toLocaleLowerCase().includes(query))
+ )
}
export async function fetchDocumentCatalog(signal?: AbortSignal): Promise {
- const items: SourceAsset[] = []
- const ids = new Set()
- let expected: number | undefined
- do {
- const page = await fetchSourceAssets({state:'',limit:100,offset:items.length},signal)
- if (expected === undefined) expected = page.summary.total
- if (!Number.isInteger(expected) || expected < 0 || expected > 10000 || page.summary.total !== expected || page.offset !== items.length) throw new Error('文档清单已变化或过大,请刷新后重试。')
- for (const item of page.items) {
- if (!item.id || ids.has(item.id)) throw new Error('文档分页发生变化,请刷新后重试。')
- ids.add(item.id)
- items.push(item)
- }
- if (!page.items.length && items.length !== expected) throw new Error('文档清单未加载完整,请重试。')
- } while (items.length < expected)
- if (items.length !== expected) throw new Error('文档数量核验失败,请重试。')
- return items
+ const items: SourceAsset[] = []
+ const ids = new Set()
+ let expected: number | undefined
+ do {
+ const page = await fetchSourceAssets({ state: '', limit: 100, offset: items.length }, signal)
+ if (expected === undefined) expected = page.summary.total
+ if (
+ !Number.isInteger(expected) ||
+ expected < 0 ||
+ expected > 10000 ||
+ page.summary.total !== expected ||
+ page.offset !== items.length
+ )
+ throw new Error('文档清单已变化或过大,请刷新后重试。')
+ for (const item of page.items) {
+ if (!item.id || ids.has(item.id)) throw new Error('文档分页发生变化,请刷新后重试。')
+ ids.add(item.id)
+ items.push(item)
+ }
+ if (!page.items.length && items.length !== expected) throw new Error('文档清单未加载完整,请重试。')
+ } while (items.length < expected)
+ if (items.length !== expected) throw new Error('文档数量核验失败,请重试。')
+ return items
}
diff --git a/web/src/filenameFilters.ts b/web/src/filenameFilters.ts
index f152016..75e5f7f 100644
--- a/web/src/filenameFilters.ts
+++ b/web/src/filenameFilters.ts
@@ -15,30 +15,44 @@ export async function fetchFilenameFilters(connectionID: string, signal?: AbortS
const query = new URLSearchParams({ connection_id: connectionID })
const response = await fetch(`/api/v1/filename-filters?${query}`, { headers: { Accept: 'application/json' }, signal })
if (!response.ok) throw new Error(`加载文件名筛选失败(HTTP ${response.status})`)
- const envelope = await response.json() as unknown
+ const envelope = (await response.json()) as unknown
if (!record(envelope) || !Array.isArray(envelope.data)) throw new Error('文件名筛选响应无效')
return envelope.data.map((value) => {
- if (!record(value) || typeof value.connection_id !== 'string' || typeof value.directory_path !== 'string' ||
- !Array.isArray(value.excluded_extensions) || !value.excluded_extensions.every((item) => typeof item === 'string') ||
- typeof value.updated_by !== 'string' || typeof value.updated_at !== 'string') throw new Error('文件名筛选条目无效')
+ if (
+ !record(value) ||
+ typeof value.connection_id !== 'string' ||
+ typeof value.directory_path !== 'string' ||
+ !Array.isArray(value.excluded_extensions) ||
+ !value.excluded_extensions.every((item) => typeof item === 'string') ||
+ typeof value.updated_by !== 'string' ||
+ typeof value.updated_at !== 'string'
+ )
+ throw new Error('文件名筛选条目无效')
return value as unknown as FilenameFilterRule
})
}
-export async function saveFilenameFilter(connectionID: string, directoryPath: string, mode: 'allow' | 'exclude', extensions: string[]): Promise {
+export async function saveFilenameFilter(
+ connectionID: string,
+ directoryPath: string,
+ mode: 'allow' | 'exclude',
+ extensions: string[]
+): Promise {
const response = await fetch('/api/v1/filename-filters', {
- method: 'PUT', headers: { Accept: 'application/json', 'Content-Type': 'application/json', 'Idempotency-Key': newOperationID() },
+ method: 'PUT',
+ headers: { Accept: 'application/json', 'Content-Type': 'application/json', 'Idempotency-Key': newOperationID() },
body: JSON.stringify({ connection_id: connectionID, directory_path: directoryPath, mode, extensions }),
})
if (!response.ok) throw new Error(`保存文件名筛选失败(HTTP ${response.status})`)
- const envelope = await response.json() as { data?: FilenameFilterRule }
+ const envelope = (await response.json()) as { data?: FilenameFilterRule }
if (!envelope.data) throw new Error('文件名筛选响应无效')
return envelope.data
}
export async function clearFilenameFilter(connectionID: string, directoryPath: string): Promise {
const response = await fetch('/api/v1/filename-filters', {
- method: 'DELETE', headers: { Accept: 'application/json', 'Content-Type': 'application/json', 'Idempotency-Key': newOperationID() },
+ method: 'DELETE',
+ headers: { Accept: 'application/json', 'Content-Type': 'application/json', 'Idempotency-Key': newOperationID() },
body: JSON.stringify({ connection_id: connectionID, directory_path: directoryPath }),
})
if (!response.ok) throw new Error(`清除文件名筛选失败(HTTP ${response.status})`)
@@ -48,24 +62,115 @@ export async function clearFilenameFilter(connectionID: string, directoryPath: s
// binaries have no RAG value for the support knowledge bases. Kept for the
// legacy "exclude" mode editor prefill.
export const DEFAULT_EXCLUDED_EXTENSIONS = [
- "zip", "rar", "7z", "gz", "tgz", "tar", "xz", "iso", "crdownload",
- "mp4", "avi", "mkv", "mov", "wmv", "m4v", "mp3", "wav", "flac",
- "exe", "dll", "so", "msi", "apk", "bin", "pyc", "pyd", "pyi", "whl", "dtb", "u-boot", "imx",
- "stp", "step", "stl", "x_t", "x_b", "jt", "sldprt", "sldasm", "slddrw", "dwg", "dxf", "igs", "cat", "obj", "p2m", "p2s", "sol", "dwf",
- "psd", "ai", "otf", "ttf",
- "dlink", "atomproject", "amind", "appt", "rdk", "vcmx", "swf", "dav",
+ 'zip',
+ 'rar',
+ '7z',
+ 'gz',
+ 'tgz',
+ 'tar',
+ 'xz',
+ 'iso',
+ 'crdownload',
+ 'mp4',
+ 'avi',
+ 'mkv',
+ 'mov',
+ 'wmv',
+ 'm4v',
+ 'mp3',
+ 'wav',
+ 'flac',
+ 'exe',
+ 'dll',
+ 'so',
+ 'msi',
+ 'apk',
+ 'bin',
+ 'pyc',
+ 'pyd',
+ 'pyi',
+ 'whl',
+ 'dtb',
+ 'u-boot',
+ 'imx',
+ 'stp',
+ 'step',
+ 'stl',
+ 'x_t',
+ 'x_b',
+ 'jt',
+ 'sldprt',
+ 'sldasm',
+ 'slddrw',
+ 'dwg',
+ 'dxf',
+ 'igs',
+ 'cat',
+ 'obj',
+ 'p2m',
+ 'p2s',
+ 'sol',
+ 'dwf',
+ 'psd',
+ 'ai',
+ 'otf',
+ 'ttf',
+ 'dlink',
+ 'atomproject',
+ 'amind',
+ 'appt',
+ 'rdk',
+ 'vcmx',
+ 'swf',
+ 'dav',
].sort()
// Whitelist defaults for the "keep selected" mode: documents, text/scripts
// and images that a RAG knowledge base can actually parse and cite.
export const DEFAULT_ALLOWED_EXTENSIONS = [
- "adoc", "able", "axls",
- "pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "csv",
- "txt", "md", "json", "xml", "html", "yaml", "conf", "config", "ini", "log",
- "lua", "py", "sh", "bat", "qml",
- "png", "jpg", "jpeg", "gif", "bmp", "webp", "heic", "tif",
+ 'adoc',
+ 'able',
+ 'axls',
+ 'pdf',
+ 'doc',
+ 'docx',
+ 'xls',
+ 'xlsx',
+ 'ppt',
+ 'pptx',
+ 'csv',
+ 'txt',
+ 'md',
+ 'json',
+ 'xml',
+ 'html',
+ 'yaml',
+ 'conf',
+ 'config',
+ 'ini',
+ 'log',
+ 'lua',
+ 'py',
+ 'sh',
+ 'bat',
+ 'qml',
+ 'png',
+ 'jpg',
+ 'jpeg',
+ 'gif',
+ 'bmp',
+ 'webp',
+ 'heic',
+ 'tif',
].sort()
export function parseExtensionDraft(value: string): string[] {
- return [...new Set(value.split(/[,,\s]+/).map((item) => item.trim().toLowerCase().replace(/^\./, '')).filter(Boolean))].sort()
+ return [
+ ...new Set(
+ value
+ .split(/[,,\s]+/)
+ .map((item) => item.trim().toLowerCase().replace(/^\./, ''))
+ .filter(Boolean)
+ ),
+ ].sort()
}
diff --git a/web/src/main.tsx b/web/src/main.tsx
index 7ee5c6a..1972d2e 100644
--- a/web/src/main.tsx
+++ b/web/src/main.tsx
@@ -13,7 +13,11 @@ if (!root) {
function Gate() {
const { identity, ready } = useAuth()
if (!ready) {
- return
+ return (
+
+ )
}
if (!identity) return
if (identity.must_change_password) return
@@ -25,5 +29,5 @@ createRoot(root).render(
- ,
+
)
diff --git a/web/src/members.ts b/web/src/members.ts
index e8a51c3..7f31a8c 100644
--- a/web/src/members.ts
+++ b/web/src/members.ts
@@ -12,17 +12,26 @@ export interface ManagedUser {
export const managedUserRoles = ['viewer', 'engineer', 'admin'] as const
-const record = (value: unknown): value is Record => Boolean(value) && typeof value === 'object' && !Array.isArray(value)
+const record = (value: unknown): value is Record =>
+ Boolean(value) && typeof value === 'object' && !Array.isArray(value)
function parseUser(raw: unknown): ManagedUser {
- if (!record(raw) || typeof raw.id !== 'string' || typeof raw.username !== 'string' || typeof raw.role !== 'string' ||
- !managedUserRoles.includes(raw.role as ManagedUser['role']) || typeof raw.state !== 'string') {
+ if (
+ !record(raw) ||
+ typeof raw.id !== 'string' ||
+ typeof raw.username !== 'string' ||
+ typeof raw.role !== 'string' ||
+ !managedUserRoles.includes(raw.role as ManagedUser['role']) ||
+ typeof raw.state !== 'string'
+ ) {
throw new Error('成员响应无效')
}
return {
- id: raw.id, username: raw.username,
+ id: raw.id,
+ username: raw.username,
display_name: typeof raw.display_name === 'string' ? raw.display_name : '',
- role: raw.role as ManagedUser['role'], state: raw.state === 'disabled' ? 'disabled' : 'active',
+ role: raw.role as ManagedUser['role'],
+ state: raw.state === 'disabled' ? 'disabled' : 'active',
must_change_password: raw.must_change_password === true,
last_login_at: typeof raw.last_login_at === 'string' ? raw.last_login_at : null,
}
@@ -32,7 +41,12 @@ async function call(url: string, init: RequestInit, message: string): Promise null)) as { error?: string } | null
@@ -47,24 +61,41 @@ export async function fetchManagedUsers(signal?: AbortSignal): Promise {
+export async function createManagedUser(input: {
+ username: string
+ display_name: string
+ role: ManagedUser['role']
+ password: string
+}): Promise {
const value = await call('/api/v1/users', { method: 'POST', body: JSON.stringify(input) }, '创建成员失败')
if (!record(value) || !record(value.data)) throw new Error('创建成员响应无效')
return parseUser(value.data)
}
export async function setManagedUserRole(userID: string, role: ManagedUser['role']): Promise {
- const value = await call(`/api/v1/users/${encodeURIComponent(userID)}/role`, { method: 'POST', body: JSON.stringify({ role }) }, '调整角色失败')
+ const value = await call(
+ `/api/v1/users/${encodeURIComponent(userID)}/role`,
+ { method: 'POST', body: JSON.stringify({ role }) },
+ '调整角色失败'
+ )
if (!record(value) || !record(value.data)) throw new Error('角色更新响应无效')
return parseUser(value.data)
}
export async function setManagedUserState(userID: string, state: ManagedUser['state']): Promise {
- const value = await call(`/api/v1/users/${encodeURIComponent(userID)}/state`, { method: 'POST', body: JSON.stringify({ state }) }, '更新账号状态失败')
+ const value = await call(
+ `/api/v1/users/${encodeURIComponent(userID)}/state`,
+ { method: 'POST', body: JSON.stringify({ state }) },
+ '更新账号状态失败'
+ )
if (!record(value) || !record(value.data)) throw new Error('账号状态响应无效')
return parseUser(value.data)
}
export async function resetManagedUserPassword(userID: string, temporaryPassword: string): Promise {
- await call(`/api/v1/users/${encodeURIComponent(userID)}/reset-password`, { method: 'POST', body: JSON.stringify({ temporary_password: temporaryPassword }) }, '重置密码失败')
+ await call(
+ `/api/v1/users/${encodeURIComponent(userID)}/reset-password`,
+ { method: 'POST', body: JSON.stringify({ temporary_password: temporaryPassword }) },
+ '重置密码失败'
+ )
}
diff --git a/web/src/monitor.test.ts b/web/src/monitor.test.ts
index d774192..b621a6f 100644
--- a/web/src/monitor.test.ts
+++ b/web/src/monitor.test.ts
@@ -1,34 +1,134 @@
-import { afterEach,describe,expect,it,vi } from 'vitest'
-import { fetchMonitorFiles,fetchMonitorOverview,fetchRunMetrics } from './monitor'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { fetchMonitorFiles, fetchMonitorOverview, fetchRunMetrics } from './monitor'
-afterEach(()=>vi.unstubAllGlobals())
-const overview={mode:'simulation',simulated:true,observed_at:'2026-09-08T12:00:00Z',host:{cpu_percent:42,memory_used_bytes:19971597926,memory_total_bytes:34359738368,simulated:true},services:[{key:'mineru',name:'MinerU',state:'healthy',detail:'模拟',latency_ms:24,observed_at:'2026-09-08T12:00:00Z',simulated:true}],knowledge_bases:[{id:'kb-sim',name:'公司级技术支持知识库',visibility:'mixed',imported:5750,completed:4530,pending:450,processing:50,finalizing:714,failed:6,cancelled:0,internal_imported:4200,internal_completed:3300,external_imported:1550,external_completed:1230}]}
-describe('monitor API',()=>{
- it('requires the backend to label simulation mode explicitly',async()=>{
- vi.stubGlobal('fetch',vi.fn().mockResolvedValue(new Response(JSON.stringify(overview))))
- expect((await fetchMonitorOverview()).simulated).toBe(true)
- })
- it('filters files by real WeKnora status contract',async()=>{
- const fetchMock=vi.fn().mockResolvedValue(new Response(JSON.stringify({items:[{name:'[模拟] 文件.pdf',directory:'02 产品资料',visibility:'internal',status:'finalizing',stage_detail:'Wiki生成中',elapsed_seconds:120,error_code:''}],total:1,limit:20,offset:0,sample_only:true})))
- vi.stubGlobal('fetch',fetchMock)
- const page=await fetchMonitorFiles('kb-sim','finalizing',20,0)
- expect(page.sample_only).toBe(true)
- expect(fetchMock.mock.calls[0][0]).toBe('/api/v1/monitor/knowledge-bases/kb-sim/files?status=finalizing&limit=20&offset=0')
- })
- it('keeps insufficient metric samples null and requires a source label',async()=>{
- const payload={data:{observed_at:'2026-09-12T08:00:00Z',active_workers:2,worker_source:'pg_lease',
- queue:{pending:1,running:4,retry:1,failed:4,oldest_pending_age_seconds:10800},
- short_window:{window_seconds:3600,jobs_started:9,jobs_completed:6,jobs_failed:2,success_rate:75,failure_rate:25,duration_avg_seconds:411.4,duration_p50_seconds:30,duration_p95_seconds:1800},
- long_window:{window_seconds:86400,jobs_started:10,jobs_completed:0,jobs_failed:0,success_rate:null,failure_rate:null,duration_avg_seconds:null,duration_p50_seconds:null,duration_p95_seconds:null}}}
- vi.stubGlobal('fetch',vi.fn().mockResolvedValue(new Response(JSON.stringify(payload))))
- const metrics=await fetchRunMetrics()
- expect(metrics.worker_source).toBe('pg_lease')
- expect(metrics.long_window.success_rate).toBeNull()
- expect(metrics.long_window.duration_p95_seconds).toBeNull()
- expect(metrics.short_window.success_rate).toBe(75)
- })
- it('rejects metrics without a worker source or with a fake zero rate',async()=>{
- vi.stubGlobal('fetch',vi.fn().mockResolvedValue(new Response(JSON.stringify({data:{observed_at:'',queue:{pending:0},short_window:{window_seconds:3600},long_window:{window_seconds:86400}}}))))
- await expect(fetchRunMetrics()).rejects.toThrow()
- })
+afterEach(() => vi.unstubAllGlobals())
+const overview = {
+ mode: 'simulation',
+ simulated: true,
+ observed_at: '2026-09-08T12:00:00Z',
+ host: { cpu_percent: 42, memory_used_bytes: 19971597926, memory_total_bytes: 34359738368, simulated: true },
+ services: [
+ {
+ key: 'mineru',
+ name: 'MinerU',
+ state: 'healthy',
+ detail: '模拟',
+ latency_ms: 24,
+ observed_at: '2026-09-08T12:00:00Z',
+ simulated: true,
+ },
+ ],
+ knowledge_bases: [
+ {
+ id: 'kb-sim',
+ name: '公司级技术支持知识库',
+ visibility: 'mixed',
+ imported: 5750,
+ completed: 4530,
+ pending: 450,
+ processing: 50,
+ finalizing: 714,
+ failed: 6,
+ cancelled: 0,
+ internal_imported: 4200,
+ internal_completed: 3300,
+ external_imported: 1550,
+ external_completed: 1230,
+ },
+ ],
+}
+describe('monitor API', () => {
+ it('requires the backend to label simulation mode explicitly', async () => {
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify(overview))))
+ expect((await fetchMonitorOverview()).simulated).toBe(true)
+ })
+ it('filters files by real WeKnora status contract', async () => {
+ const fetchMock = vi
+ .fn()
+ .mockResolvedValue(
+ new Response(
+ JSON.stringify({
+ items: [
+ {
+ name: '[模拟] 文件.pdf',
+ directory: '02 产品资料',
+ visibility: 'internal',
+ status: 'finalizing',
+ stage_detail: 'Wiki生成中',
+ elapsed_seconds: 120,
+ error_code: '',
+ },
+ ],
+ total: 1,
+ limit: 20,
+ offset: 0,
+ sample_only: true,
+ })
+ )
+ )
+ vi.stubGlobal('fetch', fetchMock)
+ const page = await fetchMonitorFiles('kb-sim', 'finalizing', 20, 0)
+ expect(page.sample_only).toBe(true)
+ expect(fetchMock.mock.calls[0][0]).toBe(
+ '/api/v1/monitor/knowledge-bases/kb-sim/files?status=finalizing&limit=20&offset=0'
+ )
+ })
+ it('keeps insufficient metric samples null and requires a source label', async () => {
+ const payload = {
+ data: {
+ observed_at: '2026-09-12T08:00:00Z',
+ active_workers: 2,
+ worker_source: 'pg_lease',
+ queue: { pending: 1, running: 4, retry: 1, failed: 4, oldest_pending_age_seconds: 10800 },
+ short_window: {
+ window_seconds: 3600,
+ jobs_started: 9,
+ jobs_completed: 6,
+ jobs_failed: 2,
+ success_rate: 75,
+ failure_rate: 25,
+ duration_avg_seconds: 411.4,
+ duration_p50_seconds: 30,
+ duration_p95_seconds: 1800,
+ },
+ long_window: {
+ window_seconds: 86400,
+ jobs_started: 10,
+ jobs_completed: 0,
+ jobs_failed: 0,
+ success_rate: null,
+ failure_rate: null,
+ duration_avg_seconds: null,
+ duration_p50_seconds: null,
+ duration_p95_seconds: null,
+ },
+ },
+ }
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify(payload))))
+ const metrics = await fetchRunMetrics()
+ expect(metrics.worker_source).toBe('pg_lease')
+ expect(metrics.long_window.success_rate).toBeNull()
+ expect(metrics.long_window.duration_p95_seconds).toBeNull()
+ expect(metrics.short_window.success_rate).toBe(75)
+ })
+ it('rejects metrics without a worker source or with a fake zero rate', async () => {
+ vi.stubGlobal(
+ 'fetch',
+ vi
+ .fn()
+ .mockResolvedValue(
+ new Response(
+ JSON.stringify({
+ data: {
+ observed_at: '',
+ queue: { pending: 0 },
+ short_window: { window_seconds: 3600 },
+ long_window: { window_seconds: 86400 },
+ },
+ })
+ )
+ )
+ )
+ await expect(fetchRunMetrics()).rejects.toThrow()
+ })
})
diff --git a/web/src/monitor.ts b/web/src/monitor.ts
index ccc86eb..34e6c39 100644
--- a/web/src/monitor.ts
+++ b/web/src/monitor.ts
@@ -1,39 +1,189 @@
-export type MonitorStatus='completed'|'pending'|'processing'|'finalizing'|'failed'|'cancelled'
-export interface MonitorHost{cpu_percent:number;memory_used_bytes:number;memory_total_bytes:number;disk_used_bytes:number;disk_total_bytes:number;gpu_state:string;gpu_detail:string;simulated:boolean}
-export interface MonitorService{key:string;name:string;state:string;detail:string;latency_ms:number;observed_at:string;simulated:boolean}
-export interface KnowledgeProgress{id:string;name:string;visibility:string;imported:number;completed:number;pending:number;processing:number;finalizing:number;failed:number;cancelled:number;planned:number;needs_removal:number;stale_planned:number;internal_imported:number;internal_completed:number;external_imported:number;external_completed:number}
-export interface ServiceHealth{key:string;name:string;liveness:string;readiness:string;reason:string;latency_ms:number;observed_at:string;last_success_at:string|null;consecutive_failures:number;configured:boolean;applicable:boolean}
-export interface IngestionPipeline{fetch_pending:number;fetch_failed:number;fetch_missing:number;concurrency:number}
-export interface MonitorOverview{mode:string;simulated:boolean;observed_at:string;host:MonitorHost;services:MonitorService[];knowledge_bases:KnowledgeProgress[];pipeline?:IngestionPipeline;health:ServiceHealth[]}
-export interface MonitorFile{name:string;directory:string;visibility:string;status:MonitorStatus;stage_detail:string;elapsed_seconds:number;error_code:string}
-export interface MonitorFilePage{items:MonitorFile[];total:number;limit:number;offset:number;sample_only:boolean}
-const record=(value:unknown):value is Record=>typeof value==='object'&&value!==null
-export async function fetchMonitorOverview(signal?:AbortSignal):Promise{
- const response=await fetch('/api/v1/monitor/overview',{headers:{Accept:'application/json'},signal});if(!response.ok)throw new Error(`加载解析监控失败(HTTP ${response.status})`)
- const value=await response.json() as unknown;if(!record(value)||typeof value.mode!=='string'||typeof value.simulated!=='boolean'||!record(value.host)||!Array.isArray(value.services)||!Array.isArray(value.knowledge_bases))throw new Error('解析监控响应无效')
- const health=Array.isArray(value.health)?(value.health as unknown[]).filter(record):[]
- return {...value,health} as unknown as MonitorOverview
+export type MonitorStatus = 'completed' | 'pending' | 'processing' | 'finalizing' | 'failed' | 'cancelled'
+export interface MonitorHost {
+ cpu_percent: number
+ memory_used_bytes: number
+ memory_total_bytes: number
+ disk_used_bytes: number
+ disk_total_bytes: number
+ gpu_state: string
+ gpu_detail: string
+ simulated: boolean
}
-export async function fetchMonitorFiles(knowledgeBaseID:string,status:MonitorStatus|'',limit=20,offset=0,signal?:AbortSignal):Promise{
- const query=new URLSearchParams();if(status)query.set('status',status);query.set('limit',String(limit));query.set('offset',String(offset))
- const response=await fetch(`/api/v1/monitor/knowledge-bases/${encodeURIComponent(knowledgeBaseID)}/files?${query}`,{headers:{Accept:'application/json'},signal});if(!response.ok)throw new Error(`加载文档阶段失败(HTTP ${response.status})`)
- const value=await response.json() as unknown;if(!record(value)||!Array.isArray(value.items)||typeof value.total!=='number'||typeof value.sample_only!=='boolean')throw new Error('文档阶段响应无效')
- return value as unknown as MonitorFilePage
+export interface MonitorService {
+ key: string
+ name: string
+ state: string
+ detail: string
+ latency_ms: number
+ observed_at: string
+ simulated: boolean
+}
+export interface KnowledgeProgress {
+ id: string
+ name: string
+ visibility: string
+ imported: number
+ completed: number
+ pending: number
+ processing: number
+ finalizing: number
+ failed: number
+ cancelled: number
+ planned: number
+ needs_removal: number
+ stale_planned: number
+ internal_imported: number
+ internal_completed: number
+ external_imported: number
+ external_completed: number
+}
+export interface ServiceHealth {
+ key: string
+ name: string
+ liveness: string
+ readiness: string
+ reason: string
+ latency_ms: number
+ observed_at: string
+ last_success_at: string | null
+ consecutive_failures: number
+ configured: boolean
+ applicable: boolean
+}
+export interface IngestionPipeline {
+ fetch_pending: number
+ fetch_failed: number
+ fetch_missing: number
+ concurrency: number
+}
+export interface MonitorOverview {
+ mode: string
+ simulated: boolean
+ observed_at: string
+ host: MonitorHost
+ services: MonitorService[]
+ knowledge_bases: KnowledgeProgress[]
+ pipeline?: IngestionPipeline
+ health: ServiceHealth[]
+}
+export interface MonitorFile {
+ name: string
+ directory: string
+ visibility: string
+ status: MonitorStatus
+ stage_detail: string
+ elapsed_seconds: number
+ error_code: string
+}
+export interface MonitorFilePage {
+ items: MonitorFile[]
+ total: number
+ limit: number
+ offset: number
+ sample_only: boolean
+}
+const record = (value: unknown): value is Record => typeof value === 'object' && value !== null
+export async function fetchMonitorOverview(signal?: AbortSignal): Promise {
+ const response = await fetch('/api/v1/monitor/overview', { headers: { Accept: 'application/json' }, signal })
+ if (!response.ok) throw new Error(`加载解析监控失败(HTTP ${response.status})`)
+ const value = (await response.json()) as unknown
+ if (
+ !record(value) ||
+ typeof value.mode !== 'string' ||
+ typeof value.simulated !== 'boolean' ||
+ !record(value.host) ||
+ !Array.isArray(value.services) ||
+ !Array.isArray(value.knowledge_bases)
+ )
+ throw new Error('解析监控响应无效')
+ const health = Array.isArray(value.health) ? (value.health as unknown[]).filter(record) : []
+ return { ...value, health } as unknown as MonitorOverview
+}
+export async function fetchMonitorFiles(
+ knowledgeBaseID: string,
+ status: MonitorStatus | '',
+ limit = 20,
+ offset = 0,
+ signal?: AbortSignal
+): Promise {
+ const query = new URLSearchParams()
+ if (status) query.set('status', status)
+ query.set('limit', String(limit))
+ query.set('offset', String(offset))
+ const response = await fetch(
+ `/api/v1/monitor/knowledge-bases/${encodeURIComponent(knowledgeBaseID)}/files?${query}`,
+ { headers: { Accept: 'application/json' }, signal }
+ )
+ if (!response.ok) throw new Error(`加载文档阶段失败(HTTP ${response.status})`)
+ const value = (await response.json()) as unknown
+ if (
+ !record(value) ||
+ !Array.isArray(value.items) ||
+ typeof value.total !== 'number' ||
+ typeof value.sample_only !== 'boolean'
+ )
+ throw new Error('文档阶段响应无效')
+ return value as unknown as MonitorFilePage
}
-export interface JobWindowMetrics{window_seconds:number;jobs_started:number;jobs_completed:number;jobs_failed:number;success_rate:number|null;failure_rate:number|null;duration_avg_seconds:number|null;duration_p50_seconds:number|null;duration_p95_seconds:number|null}
-export interface RunMetrics{observed_at:string;active_workers:number;worker_source:'pg_lease'|'simulation';queue:{pending:number;running:number;retry:number;failed:number;oldest_pending_age_seconds:number|null};short_window:JobWindowMetrics;long_window:JobWindowMetrics}
-const finiteOrNull=(value:unknown)=>typeof value==='number'&&Number.isFinite(value)?value:null
-const numberOr=(value:unknown,fallback=0)=>typeof value==='number'&&Number.isFinite(value)?value:fallback
-const windowMetrics=(value:unknown):JobWindowMetrics=>{if(!record(value))throw new Error('运行指标窗口响应无效')
- return{window_seconds:numberOr(value.window_seconds),jobs_started:numberOr(value.jobs_started),jobs_completed:numberOr(value.jobs_completed),jobs_failed:numberOr(value.jobs_failed),success_rate:finiteOrNull(value.success_rate),failure_rate:finiteOrNull(value.failure_rate),duration_avg_seconds:finiteOrNull(value.duration_avg_seconds),duration_p50_seconds:finiteOrNull(value.duration_p50_seconds),duration_p95_seconds:finiteOrNull(value.duration_p95_seconds)}}
-export async function fetchRunMetrics(signal?:AbortSignal):Promise{
- const response=await fetch('/api/v1/monitor/metrics',{headers:{Accept:'application/json'},signal});if(!response.ok)throw new Error(`加载运行指标失败(HTTP ${response.status})`)
- const value=await response.json() as unknown;if(!record(value)||!record(value.data))throw new Error('运行指标响应无效')
- const data=value.data as Record
- if(data.worker_source!=='pg_lease'&&data.worker_source!=='simulation')throw new Error('运行指标缺少数据来源标记')
- if(!record(data.queue)||!record(data.short_window)||!record(data.long_window))throw new Error('运行指标字段不完整')
- return{observed_at:typeof data.observed_at==='string'?data.observed_at:'',active_workers:numberOr(data.active_workers),worker_source:data.worker_source as RunMetrics['worker_source'],
- queue:{pending:numberOr(data.queue.pending),running:numberOr(data.queue.running),retry:numberOr(data.queue.retry),failed:numberOr(data.queue.failed),oldest_pending_age_seconds:finiteOrNull(data.queue.oldest_pending_age_seconds)},
- short_window:windowMetrics(data.short_window),long_window:windowMetrics(data.long_window)}
+export interface JobWindowMetrics {
+ window_seconds: number
+ jobs_started: number
+ jobs_completed: number
+ jobs_failed: number
+ success_rate: number | null
+ failure_rate: number | null
+ duration_avg_seconds: number | null
+ duration_p50_seconds: number | null
+ duration_p95_seconds: number | null
+}
+export interface RunMetrics {
+ observed_at: string
+ active_workers: number
+ worker_source: 'pg_lease' | 'simulation'
+ queue: { pending: number; running: number; retry: number; failed: number; oldest_pending_age_seconds: number | null }
+ short_window: JobWindowMetrics
+ long_window: JobWindowMetrics
+}
+const finiteOrNull = (value: unknown) => (typeof value === 'number' && Number.isFinite(value) ? value : null)
+const numberOr = (value: unknown, fallback = 0) =>
+ typeof value === 'number' && Number.isFinite(value) ? value : fallback
+const windowMetrics = (value: unknown): JobWindowMetrics => {
+ if (!record(value)) throw new Error('运行指标窗口响应无效')
+ return {
+ window_seconds: numberOr(value.window_seconds),
+ jobs_started: numberOr(value.jobs_started),
+ jobs_completed: numberOr(value.jobs_completed),
+ jobs_failed: numberOr(value.jobs_failed),
+ success_rate: finiteOrNull(value.success_rate),
+ failure_rate: finiteOrNull(value.failure_rate),
+ duration_avg_seconds: finiteOrNull(value.duration_avg_seconds),
+ duration_p50_seconds: finiteOrNull(value.duration_p50_seconds),
+ duration_p95_seconds: finiteOrNull(value.duration_p95_seconds),
+ }
+}
+export async function fetchRunMetrics(signal?: AbortSignal): Promise {
+ const response = await fetch('/api/v1/monitor/metrics', { headers: { Accept: 'application/json' }, signal })
+ if (!response.ok) throw new Error(`加载运行指标失败(HTTP ${response.status})`)
+ const value = (await response.json()) as unknown
+ if (!record(value) || !record(value.data)) throw new Error('运行指标响应无效')
+ const data = value.data as Record
+ if (data.worker_source !== 'pg_lease' && data.worker_source !== 'simulation')
+ throw new Error('运行指标缺少数据来源标记')
+ if (!record(data.queue) || !record(data.short_window) || !record(data.long_window))
+ throw new Error('运行指标字段不完整')
+ return {
+ observed_at: typeof data.observed_at === 'string' ? data.observed_at : '',
+ active_workers: numberOr(data.active_workers),
+ worker_source: data.worker_source as RunMetrics['worker_source'],
+ queue: {
+ pending: numberOr(data.queue.pending),
+ running: numberOr(data.queue.running),
+ retry: numberOr(data.queue.retry),
+ failed: numberOr(data.queue.failed),
+ oldest_pending_age_seconds: finiteOrNull(data.queue.oldest_pending_age_seconds),
+ },
+ short_window: windowMetrics(data.short_window),
+ long_window: windowMetrics(data.long_window),
+ }
}
diff --git a/web/src/operationKey.test.ts b/web/src/operationKey.test.ts
index 02e8df1..5c20e53 100644
--- a/web/src/operationKey.test.ts
+++ b/web/src/operationKey.test.ts
@@ -1,31 +1,46 @@
-import { afterEach,describe,expect,it } from 'vitest'
+import { afterEach, describe, expect, it } from 'vitest'
import { newOperationID } from './operationKey'
-const uuidPattern=/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
-const originalCrypto=globalThis.crypto
+const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
+const originalCrypto = globalThis.crypto
-afterEach(()=>{
- Object.defineProperty(globalThis,'crypto',{value:originalCrypto,configurable:true,writable:true})
+afterEach(() => {
+ Object.defineProperty(globalThis, 'crypto', { value: originalCrypto, configurable: true, writable: true })
})
-describe('newOperationID',()=>{
- it('uses crypto.randomUUID when available (secure context)',()=>{
- expect(newOperationID()).toMatch(uuidPattern)
- })
- it('falls back to getRandomValues over plain-HTTP LAN (insecure context)',()=>{
- const randomUUID=globalThis.crypto?.randomUUID
- Object.defineProperty(globalThis,'crypto',{configurable:true,value:{randomUUID:undefined,getRandomValues:(buffer:Uint8Array)=>{const source=new Uint8Array([0x6f,0x1a,0x2b,0x3c,0x4d,0x5e,0x6f,0x70,0x81,0x92,0xa3,0xb4,0xc5,0xd6,0xe7,0xf8]);buffer.set(source);return buffer}}})
- try{
- const id=newOperationID()
- // version nibble forced to 4 and variant bits to 10x: 0x6f->0x4f, 0x81->0x81
- expect(id).toBe('6f1a2b3c-4d5e-4f70-8192-a3b4c5d6e7f8')
- expect(id).toMatch(uuidPattern)
- }finally{
- void randomUUID
- }
- })
- it('refuses to fabricate an identifier when no CSPRNG exists',()=>{
- Object.defineProperty(globalThis,'crypto',{configurable:true,value:{randomUUID:undefined,getRandomValues:undefined}})
- expect(()=>newOperationID()).toThrow('当前环境无法安全生成操作标识')
- })
+describe('newOperationID', () => {
+ it('uses crypto.randomUUID when available (secure context)', () => {
+ expect(newOperationID()).toMatch(uuidPattern)
+ })
+ it('falls back to getRandomValues over plain-HTTP LAN (insecure context)', () => {
+ const randomUUID = globalThis.crypto?.randomUUID
+ Object.defineProperty(globalThis, 'crypto', {
+ configurable: true,
+ value: {
+ randomUUID: undefined,
+ getRandomValues: (buffer: Uint8Array) => {
+ const source = new Uint8Array([
+ 0x6f, 0x1a, 0x2b, 0x3c, 0x4d, 0x5e, 0x6f, 0x70, 0x81, 0x92, 0xa3, 0xb4, 0xc5, 0xd6, 0xe7, 0xf8,
+ ])
+ buffer.set(source)
+ return buffer
+ },
+ },
+ })
+ try {
+ const id = newOperationID()
+ // version nibble forced to 4 and variant bits to 10x: 0x6f->0x4f, 0x81->0x81
+ expect(id).toBe('6f1a2b3c-4d5e-4f70-8192-a3b4c5d6e7f8')
+ expect(id).toMatch(uuidPattern)
+ } finally {
+ void randomUUID
+ }
+ })
+ it('refuses to fabricate an identifier when no CSPRNG exists', () => {
+ Object.defineProperty(globalThis, 'crypto', {
+ configurable: true,
+ value: { randomUUID: undefined, getRandomValues: undefined },
+ })
+ expect(() => newOperationID()).toThrow('当前环境无法安全生成操作标识')
+ })
})
diff --git a/web/src/operationKey.ts b/web/src/operationKey.ts
index 021c259..cc871a9 100644
--- a/web/src/operationKey.ts
+++ b/web/src/operationKey.ts
@@ -1,13 +1,13 @@
-export function newOperationID():string{
- const cryptoApi=globalThis.crypto
- if(typeof cryptoApi?.randomUUID==='function')return cryptoApi.randomUUID()
- if(typeof cryptoApi?.getRandomValues==='function'){
- const bytes=new Uint8Array(16)
- cryptoApi.getRandomValues(bytes)
- bytes[6]=(bytes[6]&0x0f)|0x40
- bytes[8]=(bytes[8]&0x3f)|0x80
- const hex=Array.from(bytes,value=>value.toString(16).padStart(2,'0')).join('')
- return `${hex.slice(0,8)}-${hex.slice(8,12)}-${hex.slice(12,16)}-${hex.slice(16,20)}-${hex.slice(20)}`
- }
- throw new Error('当前环境无法安全生成操作标识')
+export function newOperationID(): string {
+ const cryptoApi = globalThis.crypto
+ if (typeof cryptoApi?.randomUUID === 'function') return cryptoApi.randomUUID()
+ if (typeof cryptoApi?.getRandomValues === 'function') {
+ const bytes = new Uint8Array(16)
+ cryptoApi.getRandomValues(bytes)
+ bytes[6] = (bytes[6] & 0x0f) | 0x40
+ bytes[8] = (bytes[8] & 0x3f) | 0x80
+ const hex = Array.from(bytes, (value) => value.toString(16).padStart(2, '0')).join('')
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
+ }
+ throw new Error('当前环境无法安全生成操作标识')
}
diff --git a/web/src/productionBindings.test.ts b/web/src/productionBindings.test.ts
index 4c1ffb2..7c62524 100644
--- a/web/src/productionBindings.test.ts
+++ b/web/src/productionBindings.test.ts
@@ -3,14 +3,24 @@ import { describe, expect, it, vi } from 'vitest'
import { fetchProductionBindingOptions, fetchProductionBindings, saveProductionBindings } from './productionBindings'
const binding = {
- internal_kb_id: '0b217e8a-2a65-40a3-ab8f-34ac5c7a6f05', external_kb_id: '',
- internal_kb_name: '对内知识库-wiki', external_kb_name: '', updated_by: 'admin',
- production_base_url: 'https://weknora-test.dobot.cn/', production_connected: true, production_message: '',
+ internal_kb_id: '0b217e8a-2a65-40a3-ab8f-34ac5c7a6f05',
+ external_kb_id: '',
+ internal_kb_name: '对内知识库-wiki',
+ external_kb_name: '',
+ updated_by: 'admin',
+ production_base_url: 'https://weknora-test.dobot.cn/',
+ production_connected: true,
+ production_message: '',
}
describe('production bindings API', () => {
it('reads the 源库 → 目标库 projection without leaking extra fields', async () => {
- vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({ data: { ...binding, api_key: 'drop' } }), { status: 200 })))
+ vi.stubGlobal(
+ 'fetch',
+ vi
+ .fn()
+ .mockResolvedValue(new Response(JSON.stringify({ data: { ...binding, api_key: 'drop' } }), { status: 200 }))
+ )
const result = await fetchProductionBindings()
expect(result.internal_kb_name).toBe('对内知识库-wiki')
expect(result.production_connected).toBe(true)
@@ -18,16 +28,36 @@ describe('production bindings API', () => {
vi.unstubAllGlobals()
})
it('lists production knowledge base options for the picker', async () => {
- vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({ data: [{ id: 'kb-1', name: '对内知识库-wiki', knowledge_count: 2069 }, { id: '', name: '坏行' }] }), { status: 200 })))
- expect(await fetchProductionBindingOptions()).toEqual([{ id: 'kb-1', name: '对内知识库-wiki', knowledge_count: 2069 }])
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn().mockResolvedValue(
+ new Response(
+ JSON.stringify({
+ data: [
+ { id: 'kb-1', name: '对内知识库-wiki', knowledge_count: 2069 },
+ { id: '', name: '坏行' },
+ ],
+ }),
+ { status: 200 }
+ )
+ )
+ )
+ expect(await fetchProductionBindingOptions()).toEqual([
+ { id: 'kb-1', name: '对内知识库-wiki', knowledge_count: 2069 },
+ ])
vi.unstubAllGlobals()
})
it('saves with an idempotency key and surfaces a rejected base', async () => {
- const fetchMock = vi.fn()
+ const fetchMock = vi
+ .fn()
.mockResolvedValueOnce(new Response(JSON.stringify({ data: binding }), { status: 200 }))
- .mockResolvedValueOnce(new Response(JSON.stringify({ error: '对内绑定的生产知识库在该 WeKnora 实例中不存在' }), { status: 400 }))
+ .mockResolvedValueOnce(
+ new Response(JSON.stringify({ error: '对内绑定的生产知识库在该 WeKnora 实例中不存在' }), { status: 400 })
+ )
vi.stubGlobal('fetch', fetchMock)
- await expect(saveProductionBindings({ internal_kb_id: 'kb-1', external_kb_id: '' })).resolves.toMatchObject({ internal_kb_name: '对内知识库-wiki' })
+ await expect(saveProductionBindings({ internal_kb_id: 'kb-1', external_kb_id: '' })).resolves.toMatchObject({
+ internal_kb_name: '对内知识库-wiki',
+ })
const [, init] = fetchMock.mock.calls[0]
expect(init.method).toBe('PUT')
expect(init.headers['Idempotency-Key']).toBeTruthy()
@@ -35,10 +65,26 @@ describe('production bindings API', () => {
vi.unstubAllGlobals()
})
it('reports an unverified save honestly instead of pretending it was checked', async () => {
- vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({ data: { ...binding, production_connected: false, production_message: '生产 WeKnora 不可用:dial tcp: timeout' } }), { status: 200 })))
+ vi.stubGlobal(
+ 'fetch',
+ vi
+ .fn()
+ .mockResolvedValue(
+ new Response(
+ JSON.stringify({
+ data: {
+ ...binding,
+ production_connected: false,
+ production_message: '生产 WeKnora 不可用:dial tcp: timeout',
+ },
+ }),
+ { status: 200 }
+ )
+ )
+ )
const result = await saveProductionBindings({ internal_kb_id: 'kb-1', external_kb_id: '' })
expect(result.production_connected).toBe(false)
expect(result.production_message).toContain('不可用')
vi.unstubAllGlobals()
})
-})
\ No newline at end of file
+})
diff --git a/web/src/productionBindings.ts b/web/src/productionBindings.ts
index 5702fa6..395ecb0 100644
--- a/web/src/productionBindings.ts
+++ b/web/src/productionBindings.ts
@@ -13,7 +13,8 @@ export interface ProductionBindings {
production_message: string
}
-const record = (value: unknown): value is Record => Boolean(value) && typeof value === 'object' && !Array.isArray(value)
+const record = (value: unknown): value is Record =>
+ Boolean(value) && typeof value === 'object' && !Array.isArray(value)
function projection(item: Record): ProductionBindings {
return {
@@ -31,35 +32,50 @@ function projection(item: Record): ProductionBindings {
export async function fetchProductionBindings(signal?: AbortSignal): Promise {
const response = await fetch('/api/v1/production-kb-bindings', { signal, headers: { Accept: 'application/json' } })
if (!response.ok) throw new Error(`读取生产知识库绑定失败(HTTP ${response.status})`)
- const value = await response.json() as unknown
+ const value = (await response.json()) as unknown
if (!record(value) || !record(value.data)) throw new Error('生产知识库绑定响应无效')
return projection(value.data)
}
export async function fetchProductionBindingOptions(signal?: AbortSignal): Promise {
- const response = await fetch('/api/v1/production-kb-bindings/options', { signal, headers: { Accept: 'application/json' } })
+ const response = await fetch('/api/v1/production-kb-bindings/options', {
+ signal,
+ headers: { Accept: 'application/json' },
+ })
if (!response.ok) throw new Error(`读取生产知识库列表失败(HTTP ${response.status})`)
- const value = await response.json() as unknown
+ const value = (await response.json()) as unknown
if (!record(value) || !Array.isArray(value.data)) throw new Error('生产知识库列表响应无效')
- return value.data.filter(record).map((item) => ({
- id: typeof item.id === 'string' ? item.id : '',
- name: typeof item.name === 'string' ? item.name : '',
- knowledge_count: typeof item.knowledge_count === 'number' ? item.knowledge_count : 0,
- })).filter((item) => item.id)
+ return value.data
+ .filter(record)
+ .map((item) => ({
+ id: typeof item.id === 'string' ? item.id : '',
+ name: typeof item.name === 'string' ? item.name : '',
+ knowledge_count: typeof item.knowledge_count === 'number' ? item.knowledge_count : 0,
+ }))
+ .filter((item) => item.id)
}
-export async function saveProductionBindings(input: { internal_kb_id: string; external_kb_id: string }, signal?: AbortSignal): Promise {
+export async function saveProductionBindings(
+ input: { internal_kb_id: string; external_kb_id: string },
+ signal?: AbortSignal
+): Promise {
const response = await fetch('/api/v1/production-kb-bindings', {
- method: 'PUT', signal,
+ method: 'PUT',
+ signal,
headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'Idempotency-Key': newOperationID() },
body: JSON.stringify(input),
})
if (!response.ok) {
let reason = `保存生产知识库绑定失败(HTTP ${response.status})`
- try { const body = await response.json() as unknown; if (record(body) && typeof body.error === 'string') reason = body.error } catch { /* keep status text */ }
+ try {
+ const body = (await response.json()) as unknown
+ if (record(body) && typeof body.error === 'string') reason = body.error
+ } catch {
+ /* keep status text */
+ }
throw new Error(reason)
}
- const value = await response.json() as unknown
+ const value = (await response.json()) as unknown
if (!record(value) || !record(value.data)) throw new Error('生产知识库绑定响应无效')
return projection(value.data)
-}
\ No newline at end of file
+}
diff --git a/web/src/releasePlanner.css b/web/src/releasePlanner.css
index a65bfee..4c12685 100644
--- a/web/src/releasePlanner.css
+++ b/web/src/releasePlanner.css
@@ -1,2 +1,262 @@
-.release-planner{max-width:1700px}.production-off{border:1px solid var(--c-fail-border);border-radius:8px;padding:7px 10px;background:var(--c-fail-bg);color:var(--c-fail);font-size:12px;font-weight:700}.release-four-steps{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));margin:0 0 16px;padding:0;list-style:none}.release-four-steps li{border-top:3px solid var(--border-base);padding:9px;background:var(--bg-panel);color:var(--ink-mute);font-size:11px;text-align:center}.release-four-steps li.active{border-color:var(--c-run);background:var(--c-run-bg);color:var(--c-run);font-weight:700}.release-four-steps li.danger{border-color:var(--c-fail-border);background:var(--c-fail-bg);color:var(--c-fail)}.release-plan-layout{display:grid;grid-template-columns:minmax(0,1fr) 350px;gap:18px;align-items:start}.release-candidates,.release-plan-summary{border:1px solid var(--border-base);border-radius:14px;background:var(--bg-panel)}.release-plan-heading{display:flex;align-items:center;justify-content:space-between;gap:12px;border-bottom:1px solid var(--border-base);padding:14px 16px}.release-plan-heading>div{display:grid;gap:3px}.release-plan-heading h2,.release-plan-summary h2{margin:0;color:var(--ink-base);font-size:15px}.release-plan-heading span{color:var(--ink-mute);font-size:10px}.release-plan-heading button{padding:7px 10px}.release-candidate-list{display:grid}.release-candidate{display:grid;grid-template-columns:20px minmax(190px,1fr) minmax(330px,1.4fr) auto;gap:12px;align-items:center;border-bottom:1px solid var(--border-soft);padding:14px 16px;color:var(--ink-base)}.release-candidate.blocked{background:var(--c-fail-bg);color:var(--c-fail)}.candidate-title{display:grid;gap:5px}.candidate-title strong{overflow-wrap:anywhere;color:var(--ink-base);font-size:12px}.candidate-title small{color:var(--ink-mute);font-size:10px}.closure-grid{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:5px}.closure-grid span{border-radius:6px;padding:6px 5px;background:var(--bg-soft);color:var(--ink-mute);font-size:9px;text-align:center;white-space:nowrap}.closure-grid .ready{background:var(--c-done-bg);color:var(--c-done)}.closure-grid .shared{background:var(--c-warn-bg);color:var(--c-warn)}.closure-grid .missing{background:var(--c-fail-bg);color:var(--c-fail)}.blocked-reason{color:var(--c-fail);font-size:10px}.release-plan-summary{display:grid;gap:13px;padding:17px;position:sticky;top:80px}
-.closure-result{border-radius:6px;padding:6px 8px;font-size:9px;white-space:nowrap}.closure-result.ready{background:var(--c-done-bg);color:var(--c-done)}.closure-result.shared{background:var(--c-warn-bg);color:var(--c-warn)}.closure-result.missing{background:var(--c-fail-bg);color:var(--c-fail)}.release-decision-key{display:grid;gap:6px;border:1px solid var(--border-base);border-radius:8px;padding:9px;background:var(--bg-soft);color:var(--ink-mute);font-size:9px}.release-decision-key b{color:var(--ink-base)}.release-mode{display:grid;gap:5px;border-radius:10px;padding:13px;background:#f3f7fa}.release-mode span,.release-mode small{color:var(--ink-mute);font-size:10px;line-height:1.5}.release-mode strong{color:var(--ink-base)}.release-mode.document{background:var(--c-done-bg)}.release-mode.knowledge_base{background:var(--c-warn-bg)}.release-mode.blocked{background:var(--c-fail-bg)}.release-plan-summary dl{display:grid;grid-template-columns:1fr auto;gap:8px;margin:0;color:var(--ink-mute);font-size:11px}.release-plan-summary dd{margin:0;color:#1c4765;font-weight:700}.kb-warning{margin:0;border-radius:8px;padding:10px;background:var(--c-warn-bg);color:var(--c-warn);font-size:10px;line-height:1.6}.release-gates{display:grid;gap:6px;border-top:1px solid var(--border-base);padding-top:12px}.release-gates span{border-radius:6px;padding:7px;background:var(--c-fail-bg);color:var(--c-fail);font-size:10px}.release-plan-summary>small{color:var(--ink-mute);font-size:9px;line-height:1.5}@media(max-width:1100px){.release-plan-layout{grid-template-columns:1fr}.release-plan-summary{position:static}.release-candidate{grid-template-columns:20px minmax(180px,1fr)}.closure-grid{grid-column:2/-1}.blocked-reason{grid-column:2}}@media(max-width:700px){.release-four-steps{grid-template-columns:1fr 1fr}.closure-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}
\ No newline at end of file
+.release-planner {
+ max-width: 1700px;
+}
+.production-off {
+ border: 1px solid var(--c-fail-border);
+ border-radius: 8px;
+ padding: 7px 10px;
+ background: var(--c-fail-bg);
+ color: var(--c-fail);
+ font-size: 12px;
+ font-weight: 700;
+}
+.release-four-steps {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ margin: 0 0 16px;
+ padding: 0;
+ list-style: none;
+}
+.release-four-steps li {
+ border-top: 3px solid var(--border-base);
+ padding: 9px;
+ background: var(--bg-panel);
+ color: var(--ink-mute);
+ font-size: 11px;
+ text-align: center;
+}
+.release-four-steps li.active {
+ border-color: var(--c-run);
+ background: var(--c-run-bg);
+ color: var(--c-run);
+ font-weight: 700;
+}
+.release-four-steps li.danger {
+ border-color: var(--c-fail-border);
+ background: var(--c-fail-bg);
+ color: var(--c-fail);
+}
+.release-plan-layout {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) 350px;
+ gap: 18px;
+ align-items: start;
+}
+.release-candidates,
+.release-plan-summary {
+ border: 1px solid var(--border-base);
+ border-radius: 14px;
+ background: var(--bg-panel);
+}
+.release-plan-heading {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ border-bottom: 1px solid var(--border-base);
+ padding: 14px 16px;
+}
+.release-plan-heading > div {
+ display: grid;
+ gap: 3px;
+}
+.release-plan-heading h2,
+.release-plan-summary h2 {
+ margin: 0;
+ color: var(--ink-base);
+ font-size: 15px;
+}
+.release-plan-heading span {
+ color: var(--ink-mute);
+ font-size: 10px;
+}
+.release-plan-heading button {
+ padding: 7px 10px;
+}
+.release-candidate-list {
+ display: grid;
+}
+.release-candidate {
+ display: grid;
+ grid-template-columns: 20px minmax(190px, 1fr) minmax(330px, 1.4fr) auto;
+ gap: 12px;
+ align-items: center;
+ border-bottom: 1px solid var(--border-soft);
+ padding: 14px 16px;
+ color: var(--ink-base);
+}
+.release-candidate.blocked {
+ background: var(--c-fail-bg);
+ color: var(--c-fail);
+}
+.candidate-title {
+ display: grid;
+ gap: 5px;
+}
+.candidate-title strong {
+ overflow-wrap: anywhere;
+ color: var(--ink-base);
+ font-size: 12px;
+}
+.candidate-title small {
+ color: var(--ink-mute);
+ font-size: 10px;
+}
+.closure-grid {
+ display: grid;
+ grid-template-columns: repeat(5, minmax(0, 1fr));
+ gap: 5px;
+}
+.closure-grid span {
+ border-radius: 6px;
+ padding: 6px 5px;
+ background: var(--bg-soft);
+ color: var(--ink-mute);
+ font-size: 9px;
+ text-align: center;
+ white-space: nowrap;
+}
+.closure-grid .ready {
+ background: var(--c-done-bg);
+ color: var(--c-done);
+}
+.closure-grid .shared {
+ background: var(--c-warn-bg);
+ color: var(--c-warn);
+}
+.closure-grid .missing {
+ background: var(--c-fail-bg);
+ color: var(--c-fail);
+}
+.blocked-reason {
+ color: var(--c-fail);
+ font-size: 10px;
+}
+.release-plan-summary {
+ display: grid;
+ gap: 13px;
+ padding: 17px;
+ position: sticky;
+ top: 80px;
+}
+.closure-result {
+ border-radius: 6px;
+ padding: 6px 8px;
+ font-size: 9px;
+ white-space: nowrap;
+}
+.closure-result.ready {
+ background: var(--c-done-bg);
+ color: var(--c-done);
+}
+.closure-result.shared {
+ background: var(--c-warn-bg);
+ color: var(--c-warn);
+}
+.closure-result.missing {
+ background: var(--c-fail-bg);
+ color: var(--c-fail);
+}
+.release-decision-key {
+ display: grid;
+ gap: 6px;
+ border: 1px solid var(--border-base);
+ border-radius: 8px;
+ padding: 9px;
+ background: var(--bg-soft);
+ color: var(--ink-mute);
+ font-size: 9px;
+}
+.release-decision-key b {
+ color: var(--ink-base);
+}
+.release-mode {
+ display: grid;
+ gap: 5px;
+ border-radius: 10px;
+ padding: 13px;
+ background: #f3f7fa;
+}
+.release-mode span,
+.release-mode small {
+ color: var(--ink-mute);
+ font-size: 10px;
+ line-height: 1.5;
+}
+.release-mode strong {
+ color: var(--ink-base);
+}
+.release-mode.document {
+ background: var(--c-done-bg);
+}
+.release-mode.knowledge_base {
+ background: var(--c-warn-bg);
+}
+.release-mode.blocked {
+ background: var(--c-fail-bg);
+}
+.release-plan-summary dl {
+ display: grid;
+ grid-template-columns: 1fr auto;
+ gap: 8px;
+ margin: 0;
+ color: var(--ink-mute);
+ font-size: 11px;
+}
+.release-plan-summary dd {
+ margin: 0;
+ color: #1c4765;
+ font-weight: 700;
+}
+.kb-warning {
+ margin: 0;
+ border-radius: 8px;
+ padding: 10px;
+ background: var(--c-warn-bg);
+ color: var(--c-warn);
+ font-size: 10px;
+ line-height: 1.6;
+}
+.release-gates {
+ display: grid;
+ gap: 6px;
+ border-top: 1px solid var(--border-base);
+ padding-top: 12px;
+}
+.release-gates span {
+ border-radius: 6px;
+ padding: 7px;
+ background: var(--c-fail-bg);
+ color: var(--c-fail);
+ font-size: 10px;
+}
+.release-plan-summary > small {
+ color: var(--ink-mute);
+ font-size: 9px;
+ line-height: 1.5;
+}
+@media (max-width: 1100px) {
+ .release-plan-layout {
+ grid-template-columns: 1fr;
+ }
+ .release-plan-summary {
+ position: static;
+ }
+ .release-candidate {
+ grid-template-columns: 20px minmax(180px, 1fr);
+ }
+ .closure-grid {
+ grid-column: 2/-1;
+ }
+ .blocked-reason {
+ grid-column: 2;
+ }
+}
+@media (max-width: 700px) {
+ .release-four-steps {
+ grid-template-columns: 1fr 1fr;
+ }
+ .closure-grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+}
diff --git a/web/src/releasePlanner.test.ts b/web/src/releasePlanner.test.ts
index 6fe6f86..b0a6533 100644
--- a/web/src/releasePlanner.test.ts
+++ b/web/src/releasePlanner.test.ts
@@ -1,18 +1,39 @@
-import { describe,expect,it } from 'vitest'
+import { describe, expect, it } from 'vitest'
import { evaluateReleaseSelection } from './releasePlanner'
import type { ReleaseCandidate } from './releasePlanner'
-const ready:ReleaseCandidate={id:'a',title:'A.pdf',knowledge_base_id:'kb-1',knowledge_base_name:'公司级技术支持知识库',visibility:'internal',parse_status:'completed',chunks:100,vectors:100,assets:2,wiki:'ready',graph:'ready'}
-describe('release planner',()=>{
- it('uses document release only when every selected closure is independently complete',()=>{
- expect(evaluateReleaseSelection([ready])).toMatchObject({mode:'document',blocked:false})
- })
- it('escalates shared wiki or graph data to one whole knowledge-base release',()=>{
- expect(evaluateReleaseSelection([{...ready,id:'b',graph:'shared'}])).toMatchObject({mode:'knowledge_base',blocked:false,knowledge_base_id:'kb-1'})
- })
- it('blocks failed parsing, missing core data, or cross-KB fallback',()=>{
- expect(evaluateReleaseSelection([{...ready,parse_status:'failed'}]).blocked).toBe(true)
- expect(evaluateReleaseSelection([{...ready,vectors:0}]).blocked).toBe(true)
- expect(evaluateReleaseSelection([{...ready,graph:'shared'},{...ready,id:'c',knowledge_base_id:'kb-2'}]).blocked).toBe(true)
- })
+const ready: ReleaseCandidate = {
+ id: 'a',
+ title: 'A.pdf',
+ knowledge_base_id: 'kb-1',
+ knowledge_base_name: '公司级技术支持知识库',
+ visibility: 'internal',
+ parse_status: 'completed',
+ chunks: 100,
+ vectors: 100,
+ assets: 2,
+ wiki: 'ready',
+ graph: 'ready',
+}
+describe('release planner', () => {
+ it('uses document release only when every selected closure is independently complete', () => {
+ expect(evaluateReleaseSelection([ready])).toMatchObject({ mode: 'document', blocked: false })
+ })
+ it('escalates shared wiki or graph data to one whole knowledge-base release', () => {
+ expect(evaluateReleaseSelection([{ ...ready, id: 'b', graph: 'shared' }])).toMatchObject({
+ mode: 'knowledge_base',
+ blocked: false,
+ knowledge_base_id: 'kb-1',
+ })
+ })
+ it('blocks failed parsing, missing core data, or cross-KB fallback', () => {
+ expect(evaluateReleaseSelection([{ ...ready, parse_status: 'failed' }]).blocked).toBe(true)
+ expect(evaluateReleaseSelection([{ ...ready, vectors: 0 }]).blocked).toBe(true)
+ expect(
+ evaluateReleaseSelection([
+ { ...ready, graph: 'shared' },
+ { ...ready, id: 'c', knowledge_base_id: 'kb-2' },
+ ]).blocked
+ ).toBe(true)
+ })
})
diff --git a/web/src/releasePlanner.ts b/web/src/releasePlanner.ts
index e59e75a..5553797 100644
--- a/web/src/releasePlanner.ts
+++ b/web/src/releasePlanner.ts
@@ -1,16 +1,89 @@
-export type ClosureState='ready'|'shared'|'missing'|'not_required'
-export interface ReleaseCandidate{id:string;title:string;knowledge_base_id:string;knowledge_base_name:string;visibility:'internal'|'external';parse_status:string;chunks:number;vectors:number;assets:number;wiki:ClosureState;graph:ClosureState}
-export interface ReleasePlan{mode:'none'|'document'|'knowledge_base'|'blocked';blocked:boolean;reason:string;knowledge_base_id?:string}
-export function evaluateReleaseSelection(items:ReleaseCandidate[]):ReleasePlan{
- if(!items.length)return{mode:'none',blocked:true,reason:'请先选择测试文档'}
- if(items.some(item=>item.parse_status!=='completed'))return{mode:'blocked',blocked:true,reason:'存在尚未完成解析的文档'}
- if(items.some(item=>item.chunks<1||item.vectors!==item.chunks||item.assets<1||item.wiki==='missing'||item.graph==='missing'))return{mode:'blocked',blocked:true,reason:'切片、向量、文件、Wiki或图谱闭包不完整'}
- const needsKB=items.some(item=>item.wiki==='shared'||item.graph==='shared')
- if(needsKB){const ids=new Set(items.map(item=>item.knowledge_base_id));if(ids.size!==1)return{mode:'blocked',blocked:true,reason:'整知识库发布不能跨知识库合并'};return{mode:'knowledge_base',blocked:false,reason:'Wiki或图谱包含共享数据,必须按整个知识库同步',knowledge_base_id:items[0].knowledge_base_id}}
- return{mode:'document',blocked:false,reason:'所选文档的切片、向量、文件、Wiki和图谱闭包可以独立验证'}
+export type ClosureState = 'ready' | 'shared' | 'missing' | 'not_required'
+export interface ReleaseCandidate {
+ id: string
+ title: string
+ knowledge_base_id: string
+ knowledge_base_name: string
+ visibility: 'internal' | 'external'
+ parse_status: string
+ chunks: number
+ vectors: number
+ assets: number
+ wiki: ClosureState
+ graph: ClosureState
}
-export const simulatedReleaseCandidates:ReleaseCandidate[]=[
- {id:'sim-cr5',title:'[模拟] CR5用户手册.pdf',knowledge_base_id:'simulation-company-support',knowledge_base_name:'公司级技术支持知识库',visibility:'internal',parse_status:'completed',chunks:186,vectors:186,assets:8,wiki:'ready',graph:'ready'},
- {id:'sim-params',title:'[模拟] 产品参数总表.xlsx',knowledge_base_id:'simulation-company-support',knowledge_base_name:'公司级技术支持知识库',visibility:'external',parse_status:'completed',chunks:94,vectors:94,assets:2,wiki:'ready',graph:'shared'},
- {id:'sim-failed',title:'[模拟] CRA-IP68产品介绍.pdf',knowledge_base_id:'simulation-company-support',knowledge_base_name:'公司级技术支持知识库',visibility:'external',parse_status:'failed',chunks:0,vectors:0,assets:1,wiki:'missing',graph:'missing'},
+export interface ReleasePlan {
+ mode: 'none' | 'document' | 'knowledge_base' | 'blocked'
+ blocked: boolean
+ reason: string
+ knowledge_base_id?: string
+}
+export function evaluateReleaseSelection(items: ReleaseCandidate[]): ReleasePlan {
+ if (!items.length) return { mode: 'none', blocked: true, reason: '请先选择测试文档' }
+ if (items.some((item) => item.parse_status !== 'completed'))
+ return { mode: 'blocked', blocked: true, reason: '存在尚未完成解析的文档' }
+ if (
+ items.some(
+ (item) =>
+ item.chunks < 1 ||
+ item.vectors !== item.chunks ||
+ item.assets < 1 ||
+ item.wiki === 'missing' ||
+ item.graph === 'missing'
+ )
+ )
+ return { mode: 'blocked', blocked: true, reason: '切片、向量、文件、Wiki或图谱闭包不完整' }
+ const needsKB = items.some((item) => item.wiki === 'shared' || item.graph === 'shared')
+ if (needsKB) {
+ const ids = new Set(items.map((item) => item.knowledge_base_id))
+ if (ids.size !== 1) return { mode: 'blocked', blocked: true, reason: '整知识库发布不能跨知识库合并' }
+ return {
+ mode: 'knowledge_base',
+ blocked: false,
+ reason: 'Wiki或图谱包含共享数据,必须按整个知识库同步',
+ knowledge_base_id: items[0].knowledge_base_id,
+ }
+ }
+ return { mode: 'document', blocked: false, reason: '所选文档的切片、向量、文件、Wiki和图谱闭包可以独立验证' }
+}
+export const simulatedReleaseCandidates: ReleaseCandidate[] = [
+ {
+ id: 'sim-cr5',
+ title: '[模拟] CR5用户手册.pdf',
+ knowledge_base_id: 'simulation-company-support',
+ knowledge_base_name: '公司级技术支持知识库',
+ visibility: 'internal',
+ parse_status: 'completed',
+ chunks: 186,
+ vectors: 186,
+ assets: 8,
+ wiki: 'ready',
+ graph: 'ready',
+ },
+ {
+ id: 'sim-params',
+ title: '[模拟] 产品参数总表.xlsx',
+ knowledge_base_id: 'simulation-company-support',
+ knowledge_base_name: '公司级技术支持知识库',
+ visibility: 'external',
+ parse_status: 'completed',
+ chunks: 94,
+ vectors: 94,
+ assets: 2,
+ wiki: 'ready',
+ graph: 'shared',
+ },
+ {
+ id: 'sim-failed',
+ title: '[模拟] CRA-IP68产品介绍.pdf',
+ knowledge_base_id: 'simulation-company-support',
+ knowledge_base_name: '公司级技术支持知识库',
+ visibility: 'external',
+ parse_status: 'failed',
+ chunks: 0,
+ vectors: 0,
+ assets: 1,
+ wiki: 'missing',
+ graph: 'missing',
+ },
]
diff --git a/web/src/releaseTargetsMaintenance.test.tsx b/web/src/releaseTargetsMaintenance.test.tsx
index 92ceafc..b7ad40f 100644
--- a/web/src/releaseTargetsMaintenance.test.tsx
+++ b/web/src/releaseTargetsMaintenance.test.tsx
@@ -73,6 +73,40 @@ describe('维护级别/进度/恢复错误', () => {
expect(html).not.toContain('按预备份恢复')
})
+ it('窗口已释放的失败作业不再说"目标保持维护、入口未开"', () => {
+ // 真实案例:libsync-internal-1793399248431728863 失败后窗口已 released、入口已开,
+ // 页面却照样渲染 held 诊断文案(只按 failed 渲染),与维护状态自相矛盾。
+ const released: LibraryMaintenanceView = {
+ held: false,
+ instance_maintenance: {
+ ...heldInstance.instance_maintenance!,
+ released_at: '2026-11-01T03:40:00Z',
+ state: 'released',
+ ingress_state: 'open',
+ },
+ }
+ const summary = maintenanceSummary(released, job)
+ expect(summary.held).toBe(false)
+ expect(summary.recoveryError).toBe('')
+ expect(summary.releasedFailure).toContain('写入生产 chunks 失败')
+
+ const html = renderToStaticMarkup(
+ undefined} />
+ )
+ expect(html).not.toContain('目标保持维护')
+ expect(html).not.toContain('收尾没有完成')
+ expect(html).toContain('窗口已释放')
+ expect(html).toContain('写入生产 chunks 失败')
+ })
+
+ it('仍在持有的失败作业保留 held 诊断文案', () => {
+ const html = renderToStaticMarkup(
+ undefined} />
+ )
+ expect(html).toContain('目标保持维护')
+ expect(html).toContain('收尾没有完成')
+ })
+
it('已释放的窗口不再提供恢复入口', () => {
const released: LibraryMaintenanceView = {
held: false,
diff --git a/web/src/reviewRelease.test.ts b/web/src/reviewRelease.test.ts
index 3ad851b..9738441 100644
--- a/web/src/reviewRelease.test.ts
+++ b/web/src/reviewRelease.test.ts
@@ -30,58 +30,168 @@ import {
afterEach(() => vi.unstubAllGlobals())
const candidate = {
- publication_id: 'tp-safe', title: 'Synthetic', source_type: 'manual', current_visibility: 'internal',
- knowledge_exists: 'passed', chunks_present: 'passed', retrieval_hit: 'manual_required',
- gate_status: 'ready', gate_code: 'ready', review_state: 'pending', decided_visibility: '',
- decision_code: '', reviewer_ref: '', reviewed_at: null, updated_at: '2026-09-06T10:00:00Z',
- acceptance_run_id: 'must-drop', knowledge_id: 'must-drop',
+ publication_id: 'tp-safe',
+ title: 'Synthetic',
+ source_type: 'manual',
+ current_visibility: 'internal',
+ knowledge_exists: 'passed',
+ chunks_present: 'passed',
+ retrieval_hit: 'manual_required',
+ gate_status: 'ready',
+ gate_code: 'ready',
+ review_state: 'pending',
+ decided_visibility: '',
+ decision_code: '',
+ reviewer_ref: '',
+ reviewed_at: null,
+ updated_at: '2026-09-06T10:00:00Z',
+ acceptance_run_id: 'must-drop',
+ knowledge_id: 'must-drop',
}
const batch = {
- id: 'rb-safe', name: 'Batch', state: 'draft', policy_version: 'release-gate-v1',
- created_by: 'engineer-1', sealed_by: '', item_count: 1,
- created_at: '2026-09-06T10:00:00Z', sealed_at: null, acceptance_run_id: 'must-drop',
+ id: 'rb-safe',
+ name: 'Batch',
+ state: 'draft',
+ policy_version: 'release-gate-v1',
+ created_by: 'engineer-1',
+ sealed_by: '',
+ item_count: 1,
+ created_at: '2026-09-06T10:00:00Z',
+ sealed_at: null,
+ acceptance_run_id: 'must-drop',
}
describe('review and batch response parsing', () => {
it('strictly rebuilds the source-truth release plan', () => {
- const plan = parseReleasePlanResponse({ data: { items: [{ publication_id: 'tp-safe', title: 'B.pdf', target_scope: 'external', action: 'add', test_state: 'ready', production_state: 'absent', can_release: true, blocker: '', production_revision: 'must-drop' }], summary: { add: 1, update: 0, delete: 0, blocked: 0, unchanged: 10 } } })
+ const plan = parseReleasePlanResponse({
+ data: {
+ items: [
+ {
+ publication_id: 'tp-safe',
+ title: 'B.pdf',
+ target_scope: 'external',
+ action: 'add',
+ test_state: 'ready',
+ production_state: 'absent',
+ can_release: true,
+ blocker: '',
+ production_revision: 'must-drop',
+ },
+ ],
+ summary: { add: 1, update: 0, delete: 0, blocked: 0, unchanged: 10 },
+ },
+ })
expect(plan.summary).toEqual({ add: 1, update: 0, delete: 0, blocked: 0, unchanged: 10 })
- expect(plan.items[0]).toEqual({ publication_id: 'tp-safe', title: 'B.pdf', target_scope: 'external', action: 'add', test_state: 'ready', production_state: 'absent', can_release: true, blocker: '' })
+ expect(plan.items[0]).toEqual({
+ publication_id: 'tp-safe',
+ title: 'B.pdf',
+ target_scope: 'external',
+ action: 'add',
+ test_state: 'ready',
+ production_state: 'absent',
+ can_release: true,
+ blocker: '',
+ })
expect(plan.items[0]).not.toHaveProperty('production_revision')
})
it('strictly rebuilds safe review candidates', () => {
const page = parseReviewCandidatesResponse({ data: [candidate], total: 1, limit: 20, offset: 0 })
- expect(page).toEqual({ items: [{
- publication_id: 'tp-safe', title: 'Synthetic', source_type: 'manual', current_visibility: 'internal',
- knowledge_exists: 'passed', chunks_present: 'passed', retrieval_hit: 'manual_required',
- gate_status: 'ready', gate_code: 'ready', review_state: 'pending', decided_visibility: '',
- decision_code: '', reviewer_ref: '', reviewed_at: null, updated_at: '2026-09-06T10:00:00Z',
- }], total: 1, limit: 20, offset: 0 })
+ expect(page).toEqual({
+ items: [
+ {
+ publication_id: 'tp-safe',
+ title: 'Synthetic',
+ source_type: 'manual',
+ current_visibility: 'internal',
+ knowledge_exists: 'passed',
+ chunks_present: 'passed',
+ retrieval_hit: 'manual_required',
+ gate_status: 'ready',
+ gate_code: 'ready',
+ review_state: 'pending',
+ decided_visibility: '',
+ decision_code: '',
+ reviewer_ref: '',
+ reviewed_at: null,
+ updated_at: '2026-09-06T10:00:00Z',
+ },
+ ],
+ total: 1,
+ limit: 20,
+ offset: 0,
+ })
expect(page.items[0]).not.toHaveProperty('acceptance_run_id')
expect(page.items[0]).not.toHaveProperty('knowledge_id')
})
it('strictly rebuilds safe batch list and detail', () => {
expect(parseReleaseBatchesResponse({ data: [batch], total: 1, limit: 20, offset: 0 }).items[0]).toEqual({
- id: 'rb-safe', name: 'Batch', state: 'draft', policy_version: 'release-gate-v1', created_by: 'engineer-1',
- sealed_by: '', item_count: 1, created_at: '2026-09-06T10:00:00Z', sealed_at: null,
+ id: 'rb-safe',
+ name: 'Batch',
+ state: 'draft',
+ policy_version: 'release-gate-v1',
+ created_by: 'engineer-1',
+ sealed_by: '',
+ item_count: 1,
+ created_at: '2026-09-06T10:00:00Z',
+ sealed_at: null,
})
- expect(parseReleaseBatchDetailResponse({ data: { ...batch, items: [{ publication_id: 'tp-safe', title: 'Synthetic', decided_visibility: 'internal', action: 'delete', publication_updated_at: 'must-drop' }] } }).items).toEqual([
- { publication_id: 'tp-safe', title: 'Synthetic', decided_visibility: 'internal', action: 'delete' },
- ])
+ expect(
+ parseReleaseBatchDetailResponse({
+ data: {
+ ...batch,
+ items: [
+ {
+ publication_id: 'tp-safe',
+ title: 'Synthetic',
+ decided_visibility: 'internal',
+ action: 'delete',
+ publication_updated_at: 'must-drop',
+ },
+ ],
+ },
+ }).items
+ ).toEqual([{ publication_id: 'tp-safe', title: 'Synthetic', decided_visibility: 'internal', action: 'delete' }])
})
})
describe('review and batch requests', () => {
it('uses fixed scoped endpoints and explicit JSON fields', async () => {
- const fetchMock = vi.fn()
- .mockResolvedValueOnce(new Response(JSON.stringify({ data: [candidate], total: 1, limit: 20, offset: 0 }), { status: 200 }))
- .mockResolvedValueOnce(new Response(JSON.stringify({ data: { id: 'rv-safe', publication_id: 'tp-safe', decision: 'approved', decided_visibility: 'internal', reviewer_ref: 'engineer-1', decision_code: 'approved_for_release', created_at: '2026-09-06T10:01:00Z' } }), { status: 201 }))
+ const fetchMock = vi
+ .fn()
+ .mockResolvedValueOnce(
+ new Response(JSON.stringify({ data: [candidate], total: 1, limit: 20, offset: 0 }), { status: 200 })
+ )
+ .mockResolvedValueOnce(
+ new Response(
+ JSON.stringify({
+ data: {
+ id: 'rv-safe',
+ publication_id: 'tp-safe',
+ decision: 'approved',
+ decided_visibility: 'internal',
+ reviewer_ref: 'engineer-1',
+ decision_code: 'approved_for_release',
+ created_at: '2026-09-06T10:01:00Z',
+ },
+ }),
+ { status: 201 }
+ )
+ )
.mockResolvedValueOnce(new Response(JSON.stringify({ data: batch }), { status: 201 }))
- .mockResolvedValueOnce(new Response(JSON.stringify({ data: { ...batch, state: 'sealed', sealed_by: 'engineer-2', sealed_at: '2026-09-06T10:02:00Z' } }), { status: 200 }))
- .mockResolvedValueOnce(new Response(JSON.stringify({ data: [batch], total: 1, limit: 20, offset: 0 }), { status: 200 }))
+ .mockResolvedValueOnce(
+ new Response(
+ JSON.stringify({
+ data: { ...batch, state: 'sealed', sealed_by: 'engineer-2', sealed_at: '2026-09-06T10:02:00Z' },
+ }),
+ { status: 200 }
+ )
+ )
+ .mockResolvedValueOnce(
+ new Response(JSON.stringify({ data: [batch], total: 1, limit: 20, offset: 0 }), { status: 200 })
+ )
vi.stubGlobal('fetch', fetchMock)
await fetchReviewCandidates('pending')
@@ -94,9 +204,19 @@ describe('review and batch requests', () => {
expect(fetchMock.mock.calls[1][0]).toBe('/api/v1/review-candidates/tp-safe/decision')
// actor fields are server-stamped from the session; bodies must not carry them
// P0: the browser must never name the actor again; the server stamps it.
- expect(JSON.parse((fetchMock.mock.calls[1][1] as RequestInit).body as string)).toEqual({ decision: 'approved', decided_visibility: 'internal', decision_code: 'approved_for_release' })
+ expect(JSON.parse((fetchMock.mock.calls[1][1] as RequestInit).body as string)).toEqual({
+ decision: 'approved',
+ decided_visibility: 'internal',
+ decision_code: 'approved_for_release',
+ })
expect(fetchMock.mock.calls[2][0]).toBe('/api/v1/release-batches')
- expect(JSON.parse((fetchMock.mock.calls[2][1] as RequestInit).body as string)).toEqual({ name: 'Batch', publication_ids: ['tp-safe'], delete_publication_ids: ['tp-delete'], release_change_set_id: '', seal: false })
+ expect(JSON.parse((fetchMock.mock.calls[2][1] as RequestInit).body as string)).toEqual({
+ name: 'Batch',
+ publication_ids: ['tp-safe'],
+ delete_publication_ids: ['tp-delete'],
+ release_change_set_id: '',
+ seal: false,
+ })
expect(fetchMock.mock.calls[3][0]).toBe('/api/v1/release-batches/rb-safe/seal')
expect(fetchMock.mock.calls[4][0]).toBe('/api/v1/release-batches?state=draft&limit=20&offset=0')
})
@@ -104,11 +224,27 @@ describe('review and batch requests', () => {
describe('release execution audit parsing', () => {
const execution = {
- id: 'rx-safe', release_batch_id: 'rb-safe', operation: 'publish', prior_execution_id: '', state: 'completed',
- target_environment: 'production', authorized_by: 'owner', job_id: 'job-safe', item_count: 105, batch_size: 50, current_batch: 2, total_batches: 3,
- completed_item_count: 1, failed_item_count: 0, error_code: '', created_at: '2026-09-06T10:00:00Z',
- started_at: '2026-09-06T10:01:00Z', completed_at: '2026-09-06T10:02:00Z',
- authorization_code: 'must-drop', manifest_sha256: 'must-drop', knowledge_id: 'must-drop',
+ id: 'rx-safe',
+ release_batch_id: 'rb-safe',
+ operation: 'publish',
+ prior_execution_id: '',
+ state: 'completed',
+ target_environment: 'production',
+ authorized_by: 'owner',
+ job_id: 'job-safe',
+ item_count: 105,
+ batch_size: 50,
+ current_batch: 2,
+ total_batches: 3,
+ completed_item_count: 1,
+ failed_item_count: 0,
+ error_code: '',
+ created_at: '2026-09-06T10:00:00Z',
+ started_at: '2026-09-06T10:01:00Z',
+ completed_at: '2026-09-06T10:02:00Z',
+ authorization_code: 'must-drop',
+ manifest_sha256: 'must-drop',
+ knowledge_id: 'must-drop',
}
it('strictly rebuilds execution and capability responses', () => {
@@ -116,22 +252,81 @@ describe('release execution audit parsing', () => {
expect(page.items[0]).toMatchObject({ batch_size: 50, current_batch: 2, total_batches: 3 })
expect(page.items[0]).not.toHaveProperty('authorization_code')
expect(page.items[0]).not.toHaveProperty('manifest_sha256')
- const detail = parseReleaseExecutionDetailResponse({ data: { ...execution, items: [{ publication_id: 'tp-safe', title: 'Synthetic', action: 'delete', state: 'verified', previous_production_revision: 'prod-v0', production_revision: 'absent', error_code: '', created_at: '2026-09-06T10:00:00Z', completed_at: '2026-09-06T10:02:00Z', target_knowledge_base_id: 'kb-target-1', knowledge_id: 'must-drop' }] } })
- expect(detail.items[0]).toEqual({ publication_id: 'tp-safe', title: 'Synthetic', action: 'delete', state: 'verified', previous_production_revision: 'prod-v0', production_revision: 'absent', error_code: '', created_at: '2026-09-06T10:00:00Z', completed_at: '2026-09-06T10:02:00Z', target_knowledge_base_id: 'kb-target-1' })
- expect(parseReleaseCapabilitiesResponse({ data: { production_release_requests_enabled: true, production_backup_restore_verified: false, secret: 'drop' } })).toEqual({ production_release_requests_enabled: true, production_backup_restore_verified: false, test_auto_review_policy: '' })
+ const detail = parseReleaseExecutionDetailResponse({
+ data: {
+ ...execution,
+ items: [
+ {
+ publication_id: 'tp-safe',
+ title: 'Synthetic',
+ action: 'delete',
+ state: 'verified',
+ previous_production_revision: 'prod-v0',
+ production_revision: 'absent',
+ error_code: '',
+ created_at: '2026-09-06T10:00:00Z',
+ completed_at: '2026-09-06T10:02:00Z',
+ target_knowledge_base_id: 'kb-target-1',
+ knowledge_id: 'must-drop',
+ },
+ ],
+ },
+ })
+ expect(detail.items[0]).toEqual({
+ publication_id: 'tp-safe',
+ title: 'Synthetic',
+ action: 'delete',
+ state: 'verified',
+ previous_production_revision: 'prod-v0',
+ production_revision: 'absent',
+ error_code: '',
+ created_at: '2026-09-06T10:00:00Z',
+ completed_at: '2026-09-06T10:02:00Z',
+ target_knowledge_base_id: 'kb-target-1',
+ })
+ expect(
+ parseReleaseCapabilitiesResponse({
+ data: { production_release_requests_enabled: true, production_backup_restore_verified: false, secret: 'drop' },
+ })
+ ).toEqual({
+ production_release_requests_enabled: true,
+ production_backup_restore_verified: false,
+ test_auto_review_policy: '',
+ })
})
it('rejects a release item execution the API forgot to describe', () => {
// 线上 DTO 曾漏 action:一条明细不合法就让整个明细区报 invalid release item execution。
- const missingAction = { publication_id: 'tp-safe', title: 'Synthetic', state: 'verified', previous_production_revision: 'prod-v0', production_revision: 'absent', error_code: '', created_at: '2026-09-06T10:00:00Z', completed_at: null }
- expect(() => parseReleaseExecutionDetailResponse({ data: { ...execution, items: [missingAction] } })).toThrow('invalid release item execution')
+ const missingAction = {
+ publication_id: 'tp-safe',
+ title: 'Synthetic',
+ state: 'verified',
+ previous_production_revision: 'prod-v0',
+ production_revision: 'absent',
+ error_code: '',
+ created_at: '2026-09-06T10:00:00Z',
+ completed_at: null,
+ }
+ expect(() => parseReleaseExecutionDetailResponse({ data: { ...execution, items: [missingAction] } })).toThrow(
+ 'invalid release item execution'
+ )
})
it('fetches only read-only execution audit endpoints', async () => {
- const fetchMock = vi.fn()
- .mockResolvedValueOnce(new Response(JSON.stringify({ data: [execution], total: 1, limit: 20, offset: 0 }), { status: 200 }))
+ const fetchMock = vi
+ .fn()
+ .mockResolvedValueOnce(
+ new Response(JSON.stringify({ data: [execution], total: 1, limit: 20, offset: 0 }), { status: 200 })
+ )
.mockResolvedValueOnce(new Response(JSON.stringify({ data: { ...execution, items: [] } }), { status: 200 }))
- .mockResolvedValueOnce(new Response(JSON.stringify({ data: { production_release_requests_enabled: false, production_backup_restore_verified: false } }), { status: 200 }))
+ .mockResolvedValueOnce(
+ new Response(
+ JSON.stringify({
+ data: { production_release_requests_enabled: false, production_backup_restore_verified: false },
+ }),
+ { status: 200 }
+ )
+ )
vi.stubGlobal('fetch', fetchMock)
await fetchReleaseExecutions('publish', 'completed')
await fetchReleaseExecution('rx-safe')
@@ -145,30 +340,82 @@ describe('release execution audit parsing', () => {
})
it('fetches the source-truth release plan from its fixed endpoint', async () => {
- const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ data: { items: [], summary: { add: 0, update: 0, delete: 0, blocked: 0, unchanged: 0 } } }), { status: 200 }))
+ const fetchMock = vi
+ .fn()
+ .mockResolvedValue(
+ new Response(
+ JSON.stringify({ data: { items: [], summary: { add: 0, update: 0, delete: 0, blocked: 0, unchanged: 0 } } }),
+ { status: 200 }
+ )
+ )
vi.stubGlobal('fetch', fetchMock)
await fetchReleasePlan()
- expect(fetchMock).toHaveBeenCalledWith('/api/v1/release-plan', expect.objectContaining({ headers: { Accept: 'application/json' } }))
+ expect(fetchMock).toHaveBeenCalledWith(
+ '/api/v1/release-plan',
+ expect.objectContaining({ headers: { Accept: 'application/json' } })
+ )
})
})
describe('release change set (R1)', () => {
- const changeSetPayload = { id: 'rcs-1', number: 1, name: '9月发布', state: 'planned', source_snapshot_ids: ['snap-a'], source_change_set_ids: [], acceptance_cutoff: '2026-09-11T21:00:00Z', production_baseline_ref: '', item_count: 1, add_count: 1, update_count: 0, delete_count: 0, blocked_count: 0, manifest_sha256: 'a'.repeat(64), created_by: 'engineer-1', created_at: '2026-09-12T07:00:00Z', confirmed_by: '', confirmed_at: null }
+ const changeSetPayload = {
+ id: 'rcs-1',
+ number: 1,
+ name: '9月发布',
+ state: 'planned',
+ source_snapshot_ids: ['snap-a'],
+ source_change_set_ids: [],
+ acceptance_cutoff: '2026-09-11T21:00:00Z',
+ production_baseline_ref: '',
+ item_count: 1,
+ add_count: 1,
+ update_count: 0,
+ delete_count: 0,
+ blocked_count: 0,
+ manifest_sha256: 'a'.repeat(64),
+ created_by: 'engineer-1',
+ created_at: '2026-09-12T07:00:00Z',
+ confirmed_by: '',
+ confirmed_at: null,
+ }
it('generates with idempotency and rejects a manifest-less envelope', async () => {
- const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ data: changeSetPayload, operation: 'created' }), { status: 201 }))
+ const fetchMock = vi
+ .fn()
+ .mockResolvedValue(
+ new Response(JSON.stringify({ data: changeSetPayload, operation: 'created' }), { status: 201 })
+ )
vi.stubGlobal('fetch', fetchMock)
const set = await generateReleaseChangeSet('9月发布')
expect(set.id).toBe('rcs-1')
expect(set.manifest_sha256).toBe('a'.repeat(64))
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]
expect(init.headers).toMatchObject({ 'Idempotency-Key': expect.any(String) })
- expect((init.body as string)).toContain('"name":"9月发布"')
- const bad = vi.fn().mockResolvedValue(new Response(JSON.stringify({ data: { ...changeSetPayload, manifest_sha256: '' } }), { status: 201 }))
+ expect(init.body as string).toContain('"name":"9月发布"')
+ const bad = vi
+ .fn()
+ .mockResolvedValue(
+ new Response(JSON.stringify({ data: { ...changeSetPayload, manifest_sha256: '' } }), { status: 201 })
+ )
vi.stubGlobal('fetch', bad)
await expect(generateReleaseChangeSet('x')).rejects.toThrow('发布计划集响应缺少标识或清单摘要')
})
it('confirms via scoped endpoint and loads latest detail tolerating 404', async () => {
- const confirmMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ data: { ...changeSetPayload, state: 'confirmed', confirmed_by: 'boss', confirmed_at: '2026-09-12T08:00:00Z' }, operation: 'created' }), { status: 200 }))
+ const confirmMock = vi
+ .fn()
+ .mockResolvedValue(
+ new Response(
+ JSON.stringify({
+ data: {
+ ...changeSetPayload,
+ state: 'confirmed',
+ confirmed_by: 'boss',
+ confirmed_at: '2026-09-12T08:00:00Z',
+ },
+ operation: 'created',
+ }),
+ { status: 200 }
+ )
+ )
vi.stubGlobal('fetch', confirmMock)
const confirmed = await confirmReleaseChangeSet('rcs-1')
expect(confirmed.state).toBe('confirmed')
@@ -176,7 +423,40 @@ describe('release change set (R1)', () => {
const latest404 = vi.fn().mockResolvedValue(new Response('{}', { status: 404 }))
vi.stubGlobal('fetch', latest404)
expect(await fetchLatestReleaseChangeSet()).toBeNull()
- const detailMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ data: { ...changeSetPayload, items: [{ seq: 1, publication_id: 'tp-keep', title: '手册', target_scope: 'internal', action: 'add', test_state: 'ready', production_state: 'absent', can_release: true, blocker: '' }, { seq: 2, publication_id: 'x', title: '', target_scope: 'bogus', action: 'nope', test_state: '', production_state: '', can_release: false, blocker: '' }] } }), { status: 200 }))
+ const detailMock = vi.fn().mockResolvedValue(
+ new Response(
+ JSON.stringify({
+ data: {
+ ...changeSetPayload,
+ items: [
+ {
+ seq: 1,
+ publication_id: 'tp-keep',
+ title: '手册',
+ target_scope: 'internal',
+ action: 'add',
+ test_state: 'ready',
+ production_state: 'absent',
+ can_release: true,
+ blocker: '',
+ },
+ {
+ seq: 2,
+ publication_id: 'x',
+ title: '',
+ target_scope: 'bogus',
+ action: 'nope',
+ test_state: '',
+ production_state: '',
+ can_release: false,
+ blocker: '',
+ },
+ ],
+ },
+ }),
+ { status: 200 }
+ )
+ )
vi.stubGlobal('fetch', detailMock)
const detail = await fetchLatestReleaseChangeSet()
expect(detail?.items.length).toBe(1)
@@ -185,7 +465,46 @@ describe('release change set (R1)', () => {
})
describe('release audit chain (R6)', () => {
- const chain = { execution_id:'rx-1', operation:'publish', state:'completed', failure_level:'', release_batch_id:'rb-1', release_batch_name:'9月批次', created_by:'e1', sealed_by:'e2', authorized_by:'own', release_change_set_id:'rcs-1', release_change_set_name:'9月发布', release_change_set_state:'confirmed', release_change_set_created_by:'e1', release_change_set_confirmed_by:'boss', release_change_set_confirmed_at:'2026-09-12T08:00:00Z', manifest_sha256:'a'.repeat(64), snapshots:[{id:'snap-1',source_name:'PP',node_count:66,file_count:621,observed_at:'2026-09-11T00:00:00Z'}], catalog_change_sets:[{id:'cs-1',state:'applied',add_count:2,update_count:1,move_count:0,delete_count:0}], prior_execution_id:'', rollback_execution_id:'', items:[{publication_id:'tp-1',title:'手册.pdf',action:'upsert',item_state:'verified',review_id:'rev-1',acceptance_run_id:'run-1',source_item_id:'si-1',source_item_version_id:'siv-1',production_revision:'v1',error_code:''}] }
+ const chain = {
+ execution_id: 'rx-1',
+ operation: 'publish',
+ state: 'completed',
+ failure_level: '',
+ release_batch_id: 'rb-1',
+ release_batch_name: '9月批次',
+ created_by: 'e1',
+ sealed_by: 'e2',
+ authorized_by: 'own',
+ release_change_set_id: 'rcs-1',
+ release_change_set_name: '9月发布',
+ release_change_set_state: 'confirmed',
+ release_change_set_created_by: 'e1',
+ release_change_set_confirmed_by: 'boss',
+ release_change_set_confirmed_at: '2026-09-12T08:00:00Z',
+ manifest_sha256: 'a'.repeat(64),
+ snapshots: [
+ { id: 'snap-1', source_name: 'PP', node_count: 66, file_count: 621, observed_at: '2026-09-11T00:00:00Z' },
+ ],
+ catalog_change_sets: [
+ { id: 'cs-1', state: 'applied', add_count: 2, update_count: 1, move_count: 0, delete_count: 0 },
+ ],
+ prior_execution_id: '',
+ rollback_execution_id: '',
+ items: [
+ {
+ publication_id: 'tp-1',
+ title: '手册.pdf',
+ action: 'upsert',
+ item_state: 'verified',
+ review_id: 'rev-1',
+ acceptance_run_id: 'run-1',
+ source_item_id: 'si-1',
+ source_item_version_id: 'siv-1',
+ production_revision: 'v1',
+ error_code: '',
+ },
+ ],
+ }
it('loads the chain and returns null on 404', async () => {
const ok = vi.fn().mockResolvedValue(new Response(JSON.stringify({ data: chain }), { status: 200 }))
vi.stubGlobal('fetch', ok)
@@ -206,9 +525,23 @@ describe('release audit chain (R6)', () => {
// 生产动作:三条路径 + 服务端原因回传(页面通道 = 管理员会话 + 固定确认语句)。
describe('production actions', () => {
it('routes each action to its endpoint with the fixed phrase and an idempotency key', async () => {
- const fetchMock = vi.fn().mockImplementation(() => Promise.resolve(new Response(JSON.stringify({ data: { id: 'rx-1', state: 'pending', operation: 'publish', item_count: 1 } }), { status: 202 })))
+ const fetchMock = vi
+ .fn()
+ .mockImplementation(() =>
+ Promise.resolve(
+ new Response(
+ JSON.stringify({ data: { id: 'rx-1', state: 'pending', operation: 'publish', item_count: 1 } }),
+ { status: 202 }
+ )
+ )
+ )
vi.stubGlobal('fetch', fetchMock)
- await expect(runProductionAction('authorize', 'rb-1', 'publish-to-production')).resolves.toEqual({ id: 'rx-1', state: 'pending', operation: 'publish', item_count: 1 })
+ await expect(runProductionAction('authorize', 'rb-1', 'publish-to-production')).resolves.toEqual({
+ id: 'rx-1',
+ state: 'pending',
+ operation: 'publish',
+ item_count: 1,
+ })
await runProductionAction('retry', 'rx-1', 'retry-production-release')
await runProductionAction('rollback', 'rx-1', 'rollback-production-release')
const urls = fetchMock.mock.calls.map((call) => call[0])
@@ -221,9 +554,23 @@ describe('production actions', () => {
vi.unstubAllGlobals()
})
it('surfaces the server reason and keeps the phrases fixed', async () => {
- vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({ error: 'production release authorization required' }), { status: 401 })))
- await expect(runProductionAction('authorize', 'rb-1', 'publish-to-production')).rejects.toThrow('production release authorization required')
- expect(productionConfirmationPhrases).toEqual({ authorize: 'publish-to-production', retry: 'retry-production-release', rollback: 'rollback-production-release', publish_now: 'publish-to-production' })
+ vi.stubGlobal(
+ 'fetch',
+ vi
+ .fn()
+ .mockResolvedValue(
+ new Response(JSON.stringify({ error: 'production release authorization required' }), { status: 401 })
+ )
+ )
+ await expect(runProductionAction('authorize', 'rb-1', 'publish-to-production')).rejects.toThrow(
+ 'production release authorization required'
+ )
+ expect(productionConfirmationPhrases).toEqual({
+ authorize: 'publish-to-production',
+ retry: 'retry-production-release',
+ rollback: 'rollback-production-release',
+ publish_now: 'publish-to-production',
+ })
vi.unstubAllGlobals()
})
})
@@ -236,11 +583,15 @@ describe('建批失败时把服务端原因带出来', () => {
json: async () => ({ error: 'release change set must be confirmed and cover every requested publication' }),
})
vi.stubGlobal('fetch', fetchMock)
- await expect(createReleaseBatch('验收12', ['tp-x'], [], 'rcs-x')).rejects.toThrow('请先点「生成新版本」再「确认冻结(此后不可更改)」')
+ await expect(createReleaseBatch('验收12', ['tp-x'], [], 'rcs-x')).rejects.toThrow(
+ '请先点「生成新版本」再「确认冻结(此后不可更改)」'
+ )
})
it('未知原因原样带出,不再只给状态码', async () => {
- const fetchMock = vi.fn().mockResolvedValue({ ok: false, status: 500, json: async () => ({ error: 'could not create release batch' }) })
+ const fetchMock = vi
+ .fn()
+ .mockResolvedValue({ ok: false, status: 500, json: async () => ({ error: 'could not create release batch' }) })
vi.stubGlobal('fetch', fetchMock)
await expect(createReleaseBatch('验收12', ['tp-x'], [], '')).rejects.toThrow('could not create release batch')
})
@@ -248,31 +599,59 @@ describe('建批失败时把服务端原因带出来', () => {
describe('审核策略的只读展示', () => {
it('服务端返回策略时解析出来', () => {
- const parsed = parseReleaseCapabilitiesResponse({ data: { production_release_requests_enabled: true, production_backup_restore_verified: true, test_auto_review_policy: 'internal' } })
+ const parsed = parseReleaseCapabilitiesResponse({
+ data: {
+ production_release_requests_enabled: true,
+ production_backup_restore_verified: true,
+ test_auto_review_policy: 'internal',
+ },
+ })
expect(parsed.test_auto_review_policy).toBe('internal')
})
it('旧服务端不返回策略时不报错', () => {
- const parsed = parseReleaseCapabilitiesResponse({ data: { production_release_requests_enabled: false, production_backup_restore_verified: false } })
+ const parsed = parseReleaseCapabilitiesResponse({
+ data: { production_release_requests_enabled: false, production_backup_restore_verified: false },
+ })
expect(parsed.test_auto_review_policy).toBe('')
})
})
describe('批量审核与建批即锁定', () => {
it('批量审核发送选中的条目与同一决定', async () => {
- const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ data: { selected: 2, decided: 2, skipped: 0, skipped_ids: [], reviews: [] } }) })
+ const fetchMock = vi
+ .fn()
+ .mockResolvedValue({
+ ok: true,
+ json: async () => ({ data: { selected: 2, decided: 2, skipped: 0, skipped_ids: [], reviews: [] } }),
+ })
vi.stubGlobal('fetch', fetchMock)
const result = await reviewPublicationsBatch(['tp-a', 'tp-b'], 'approved', 'internal', 'approved_for_release')
expect(result).toEqual({ selected: 2, decided: 2, skipped: 0, skipped_ids: [] })
const [url, init] = fetchMock.mock.calls[0]
expect(url).toBe('/api/v1/review-candidates/batch-decision')
- expect(JSON.parse(String(init.body))).toEqual({ publication_ids: ['tp-a', 'tp-b'], decision: 'approved', decided_visibility: 'internal', decision_code: 'approved_for_release' })
+ expect(JSON.parse(String(init.body))).toEqual({
+ publication_ids: ['tp-a', 'tp-b'],
+ decision: 'approved',
+ decided_visibility: 'internal',
+ decision_code: 'approved_for_release',
+ })
})
it('如实回报被跳过的条目', async () => {
- const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ data: { selected: 3, decided: 2, skipped: 1, skipped_ids: ['tp-c'], reviews: [] } }) })
+ const fetchMock = vi
+ .fn()
+ .mockResolvedValue({
+ ok: true,
+ json: async () => ({ data: { selected: 3, decided: 2, skipped: 1, skipped_ids: ['tp-c'], reviews: [] } }),
+ })
vi.stubGlobal('fetch', fetchMock)
- const result = await reviewPublicationsBatch(['tp-a', 'tp-b', 'tp-c'], 'approved', 'internal', 'approved_for_release')
+ const result = await reviewPublicationsBatch(
+ ['tp-a', 'tp-b', 'tp-c'],
+ 'approved',
+ 'internal',
+ 'approved_for_release'
+ )
expect(result.skipped).toBe(1)
expect(result.skipped_ids).toEqual(['tp-c'])
})
@@ -290,18 +669,38 @@ describe('批量审核与建批即锁定', () => {
describe('发布到生产(建批+锁定+授权合成一个动作)', () => {
it('提交条目与固定确认语句,回传批次与执行', async () => {
- const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ data: { batch: { id: 'rb-now', name: '立即发布 · 2026/10/10' }, execution: { id: 'rx-now' } } }) })
+ const fetchMock = vi
+ .fn()
+ .mockResolvedValue({
+ ok: true,
+ json: async () => ({
+ data: { batch: { id: 'rb-now', name: '立即发布 · 2026/10/10' }, execution: { id: 'rx-now' } },
+ }),
+ })
vi.stubGlobal('fetch', fetchMock)
const result = await runPublishNow(['tp-a', 'tp-b'], [], '立即发布 · 2026/10/10', 'publish-to-production')
expect(result).toEqual({ batch_id: 'rb-now', batch_name: '立即发布 · 2026/10/10', execution_id: 'rx-now' })
const [url, init] = fetchMock.mock.calls[0]
expect(url).toBe('/api/v1/release-batches/publish-now')
- expect(JSON.parse(String(init.body))).toEqual({ publication_ids: ['tp-a', 'tp-b'], delete_publication_ids: [], name: '立即发布 · 2026/10/10', confirmation: 'publish-to-production' })
+ expect(JSON.parse(String(init.body))).toEqual({
+ publication_ids: ['tp-a', 'tp-b'],
+ delete_publication_ids: [],
+ name: '立即发布 · 2026/10/10',
+ confirmation: 'publish-to-production',
+ })
})
it('服务端拒绝时把原因带出来', async () => {
- const fetchMock = vi.fn().mockResolvedValue({ ok: false, status: 412, json: async () => ({ error: 'production backup restore verification is required' }) })
+ const fetchMock = vi
+ .fn()
+ .mockResolvedValue({
+ ok: false,
+ status: 412,
+ json: async () => ({ error: 'production backup restore verification is required' }),
+ })
vi.stubGlobal('fetch', fetchMock)
- await expect(runPublishNow(['tp-a'], [], 'x', 'publish-to-production')).rejects.toThrow('production backup restore verification is required')
+ await expect(runPublishNow(['tp-a'], [], 'x', 'publish-to-production')).rejects.toThrow(
+ 'production backup restore verification is required'
+ )
})
})
diff --git a/web/src/reviewRelease.ts b/web/src/reviewRelease.ts
index 1ddf298..192806f 100644
--- a/web/src/reviewRelease.ts
+++ b/web/src/reviewRelease.ts
@@ -5,7 +5,8 @@ export type AcceptanceState = 'pending' | 'passed' | 'failed' | 'manual_required
export type ReviewGateStatus = 'ready' | 'blocked'
export type ReleaseBatchState = 'draft' | 'sealed' | 'cancelled'
export type ReleaseOperation = 'publish' | 'rollback'
-export type ReleaseExecutionState = 'pending' | 'running' | 'completed' | 'failed' | 'rolling_back' | 'rolled_back' | 'rollback_failed'
+export type ReleaseExecutionState =
+ 'pending' | 'running' | 'completed' | 'failed' | 'rolling_back' | 'rolled_back' | 'rollback_failed'
export interface ReviewCandidate {
publication_id: string
@@ -150,8 +151,23 @@ const reviewStates = new Set(['pending', 'approved', 'rejected'])
const gateStatuses = new Set(['ready', 'blocked'])
const batchStates = new Set(['draft', 'sealed', 'cancelled'])
const releaseOperations = new Set(['publish', 'rollback'])
-const releaseExecutionStates = new Set(['pending', 'running', 'completed', 'failed', 'rolling_back', 'rolled_back', 'rollback_failed'])
-const releaseItemStates = new Set(['pending', 'staged', 'activated', 'verified', 'failed', 'rolled_back'])
+const releaseExecutionStates = new Set([
+ 'pending',
+ 'running',
+ 'completed',
+ 'failed',
+ 'rolling_back',
+ 'rolled_back',
+ 'rollback_failed',
+])
+const releaseItemStates = new Set([
+ 'pending',
+ 'staged',
+ 'activated',
+ 'verified',
+ 'failed',
+ 'rolled_back',
+])
const releasePlanActions = new Set(['add', 'update', 'delete', 'blocked'])
const releasePlanTestStates = new Set(['ready', 'not_ready', 'not_required'])
@@ -181,12 +197,22 @@ export function parseReleaseBatchDetailResponse(value: unknown): ReleaseBatchDet
}
const batch = parseReleaseBatch(value.data)
const items = value.data.items.map((raw): ReleaseBatchItem => {
- if (!isRecord(raw) || typeof raw.publication_id !== 'string' || typeof raw.title !== 'string' ||
- typeof raw.decided_visibility !== 'string' || !visibilities.has(raw.decided_visibility as Visibility) ||
- (raw.action !== 'upsert' && raw.action !== 'delete')) {
+ if (
+ !isRecord(raw) ||
+ typeof raw.publication_id !== 'string' ||
+ typeof raw.title !== 'string' ||
+ typeof raw.decided_visibility !== 'string' ||
+ !visibilities.has(raw.decided_visibility as Visibility) ||
+ (raw.action !== 'upsert' && raw.action !== 'delete')
+ ) {
throw new Error('invalid release batch item')
}
- return { publication_id: raw.publication_id, title: raw.title, decided_visibility: raw.decided_visibility as Visibility, action: raw.action }
+ return {
+ publication_id: raw.publication_id,
+ title: raw.title,
+ decided_visibility: raw.decided_visibility as Visibility,
+ action: raw.action,
+ }
})
return { ...batch, items }
}
@@ -211,7 +237,12 @@ export function parseReleaseExecutionDetailResponse(value: unknown): ReleaseExec
}
export function parseReleaseCapabilitiesResponse(value: unknown): ReleaseCapabilities {
- if (!isRecord(value) || !isRecord(value.data) || typeof value.data.production_release_requests_enabled !== 'boolean' || typeof value.data.production_backup_restore_verified !== 'boolean') {
+ if (
+ !isRecord(value) ||
+ !isRecord(value.data) ||
+ typeof value.data.production_release_requests_enabled !== 'boolean' ||
+ typeof value.data.production_backup_restore_verified !== 'boolean'
+ ) {
throw new Error('invalid release capabilities response')
}
// 审核策略是只读展示项:旧服务端不返回时不报错(空串 = 界面不显示这一行)。
@@ -228,15 +259,30 @@ export function parseReleasePlanResponse(value: unknown): ReleasePlan {
throw new Error('invalid release plan response')
}
const summary = value.data.summary
- if (!isCount(summary.add) || !isCount(summary.update) || !isCount(summary.delete) || !isCount(summary.blocked) || !isCount(summary.unchanged)) {
+ if (
+ !isCount(summary.add) ||
+ !isCount(summary.update) ||
+ !isCount(summary.delete) ||
+ !isCount(summary.blocked) ||
+ !isCount(summary.unchanged)
+ ) {
throw new Error('invalid release plan response')
}
const items = value.data.items.map((raw): ReleasePlanItem => {
- if (!isRecord(raw) || typeof raw.publication_id !== 'string' || typeof raw.title !== 'string' ||
- typeof raw.target_scope !== 'string' || !visibilities.has(raw.target_scope as Visibility) ||
- typeof raw.action !== 'string' || !releasePlanActions.has(raw.action as ReleasePlanItem['action']) ||
- typeof raw.test_state !== 'string' || !releasePlanTestStates.has(raw.test_state as ReleasePlanItem['test_state']) ||
- (raw.production_state !== 'present' && raw.production_state !== 'absent') || typeof raw.can_release !== 'boolean' || typeof raw.blocker !== 'string') {
+ if (
+ !isRecord(raw) ||
+ typeof raw.publication_id !== 'string' ||
+ typeof raw.title !== 'string' ||
+ typeof raw.target_scope !== 'string' ||
+ !visibilities.has(raw.target_scope as Visibility) ||
+ typeof raw.action !== 'string' ||
+ !releasePlanActions.has(raw.action as ReleasePlanItem['action']) ||
+ typeof raw.test_state !== 'string' ||
+ !releasePlanTestStates.has(raw.test_state as ReleasePlanItem['test_state']) ||
+ (raw.production_state !== 'present' && raw.production_state !== 'absent') ||
+ typeof raw.can_release !== 'boolean' ||
+ typeof raw.blocker !== 'string'
+ ) {
throw new Error('invalid release plan item')
}
return {
@@ -250,10 +296,24 @@ export function parseReleasePlanResponse(value: unknown): ReleasePlan {
blocker: raw.blocker,
}
})
- return { items, summary: { add: summary.add, update: summary.update, delete: summary.delete, blocked: summary.blocked, unchanged: summary.unchanged } }
+ return {
+ items,
+ summary: {
+ add: summary.add,
+ update: summary.update,
+ delete: summary.delete,
+ blocked: summary.blocked,
+ unchanged: summary.unchanged,
+ },
+ }
}
-export async function fetchReviewCandidates(reviewState: ReviewState | '' = '', limit = 20, offset = 0, signal?: AbortSignal): Promise {
+export async function fetchReviewCandidates(
+ reviewState: ReviewState | '' = '',
+ limit = 20,
+ offset = 0,
+ signal?: AbortSignal
+): Promise {
const params = new URLSearchParams({ review_state: reviewState, limit: String(limit), offset: String(offset) })
return parseReviewCandidatesResponse(await getJSON(`/api/v1/review-candidates?${params}`, signal, '加载审核队列失败'))
}
@@ -269,35 +329,93 @@ export interface ReviewBatchResult {
* 一次对多条候选做同一个审核决定。服务端逐条复用单条审核的写入路径,
* 并回报"哪几条当时不可审核",界面据此如实说明,而不是含糊地当作全成功。
*/
-export async function reviewPublicationsBatch(publicationIDs: string[], decision: 'approved' | 'rejected', visibility: Visibility, decisionCode: string): Promise {
- const value = await sendJSON('/api/v1/review-candidates/batch-decision', {
- publication_ids: publicationIDs, decision, decided_visibility: visibility, decision_code: decisionCode,
- }, '批量审核失败')
+export async function reviewPublicationsBatch(
+ publicationIDs: string[],
+ decision: 'approved' | 'rejected',
+ visibility: Visibility,
+ decisionCode: string
+): Promise {
+ const value = await sendJSON(
+ '/api/v1/review-candidates/batch-decision',
+ {
+ publication_ids: publicationIDs,
+ decision,
+ decided_visibility: visibility,
+ decision_code: decisionCode,
+ },
+ '批量审核失败'
+ )
if (!isRecord(value) || !isRecord(value.data)) throw new Error('invalid review batch response')
const data = value.data
- if (!isCount(data.selected) || !isCount(data.decided) || !isCount(data.skipped)) throw new Error('invalid review batch response')
- const skipped = Array.isArray(data.skipped_ids) ? data.skipped_ids.filter((item): item is string => typeof item === 'string') : []
+ if (!isCount(data.selected) || !isCount(data.decided) || !isCount(data.skipped))
+ throw new Error('invalid review batch response')
+ const skipped = Array.isArray(data.skipped_ids)
+ ? data.skipped_ids.filter((item): item is string => typeof item === 'string')
+ : []
return { selected: data.selected, decided: data.decided, skipped: data.skipped, skipped_ids: skipped }
}
-export async function reviewPublication(publicationID: string, decision: 'approved' | 'rejected', visibility: Visibility, decisionCode: string): Promise {
- const value = await sendJSON(`/api/v1/review-candidates/${encodeURIComponent(publicationID)}/decision`, {
- decision, decided_visibility: visibility, decision_code: decisionCode,
- }, '提交审核决定失败')
+export async function reviewPublication(
+ publicationID: string,
+ decision: 'approved' | 'rejected',
+ visibility: Visibility,
+ decisionCode: string
+): Promise {
+ const value = await sendJSON(
+ `/api/v1/review-candidates/${encodeURIComponent(publicationID)}/decision`,
+ {
+ decision,
+ decided_visibility: visibility,
+ decision_code: decisionCode,
+ },
+ '提交审核决定失败'
+ )
if (!isRecord(value) || !isRecord(value.data)) throw new Error('invalid publication review response')
const raw = value.data
- if (typeof raw.id !== 'string' || typeof raw.publication_id !== 'string' ||
- (raw.decision !== 'approved' && raw.decision !== 'rejected') || typeof raw.decided_visibility !== 'string' ||
- !visibilities.has(raw.decided_visibility as Visibility) || typeof raw.reviewer_ref !== 'string' ||
- typeof raw.decision_code !== 'string' || typeof raw.created_at !== 'string') {
+ if (
+ typeof raw.id !== 'string' ||
+ typeof raw.publication_id !== 'string' ||
+ (raw.decision !== 'approved' && raw.decision !== 'rejected') ||
+ typeof raw.decided_visibility !== 'string' ||
+ !visibilities.has(raw.decided_visibility as Visibility) ||
+ typeof raw.reviewer_ref !== 'string' ||
+ typeof raw.decision_code !== 'string' ||
+ typeof raw.created_at !== 'string'
+ ) {
throw new Error('invalid publication review response')
}
- return { id: raw.id, publication_id: raw.publication_id, decision: raw.decision, decided_visibility: raw.decided_visibility as Visibility, reviewer_ref: raw.reviewer_ref, decision_code: raw.decision_code, created_at: raw.created_at }
+ return {
+ id: raw.id,
+ publication_id: raw.publication_id,
+ decision: raw.decision,
+ decided_visibility: raw.decided_visibility as Visibility,
+ reviewer_ref: raw.reviewer_ref,
+ decision_code: raw.decision_code,
+ created_at: raw.created_at,
+ }
}
-export async function createReleaseBatch(name: string, publicationIDs: string[], deletePublicationIDs: string[] = [], releaseChangeSetID = '', seal = false): Promise {
+export async function createReleaseBatch(
+ name: string,
+ publicationIDs: string[],
+ deletePublicationIDs: string[] = [],
+ releaseChangeSetID = '',
+ seal = false
+): Promise {
// seal=true:建批即锁定(P0-3)。清单在冻结时已不可变,草稿态只是多按一次。
- return parseBatchEnvelope(await sendJSON('/api/v1/release-batches', { name, publication_ids: publicationIDs, delete_publication_ids: deletePublicationIDs, release_change_set_id: releaseChangeSetID, seal }, '创建封批失败'))
+ return parseBatchEnvelope(
+ await sendJSON(
+ '/api/v1/release-batches',
+ {
+ name,
+ publication_ids: publicationIDs,
+ delete_publication_ids: deletePublicationIDs,
+ release_change_set_id: releaseChangeSetID,
+ seal,
+ },
+ '创建封批失败'
+ )
+ )
}
export interface ReleaseChangeSetItem {
@@ -338,23 +456,39 @@ export interface ReleaseChangeSetDetail extends ReleaseChangeSet {
function parseChangeSetEnvelope(value: unknown): ReleaseChangeSet {
if (!isRecord(value) || !isRecord(value.data)) throw new Error('发布计划集响应无效')
const data = value.data
- const text = (key: string) => typeof data[key] === 'string' ? data[key] as string : ''
- const number = (key: string) => typeof data[key] === 'number' && Number.isFinite(data[key]) ? data[key] as number : 0
+ const text = (key: string) => (typeof data[key] === 'string' ? (data[key] as string) : '')
+ const number = (key: string) =>
+ typeof data[key] === 'number' && Number.isFinite(data[key]) ? (data[key] as number) : 0
const id = text('id')
const manifest = text('manifest_sha256')
if (!id || !/^[0-9a-f]{64}$/.test(manifest)) throw new Error('发布计划集响应缺少标识或清单摘要')
- const snapshots = Array.isArray(data.source_snapshot_ids) ? data.source_snapshot_ids.filter((item): item is string => typeof item === 'string') : []
- const catalogSets = Array.isArray(data.source_change_set_ids) ? data.source_change_set_ids.filter((item): item is string => typeof item === 'string') : []
+ const snapshots = Array.isArray(data.source_snapshot_ids)
+ ? data.source_snapshot_ids.filter((item): item is string => typeof item === 'string')
+ : []
+ const catalogSets = Array.isArray(data.source_change_set_ids)
+ ? data.source_change_set_ids.filter((item): item is string => typeof item === 'string')
+ : []
const confirmedRaw = data.confirmed_at
return {
- id, number: number('number'), name: text('name'),
- state: data.state === 'confirmed' || data.state === 'consumed' || data.state === 'cancelled' ? data.state : 'planned',
- source_snapshot_ids: snapshots, source_change_set_ids: catalogSets,
- acceptance_cutoff: text('acceptance_cutoff'), production_baseline_ref: text('production_baseline_ref'),
- item_count: number('item_count'), add_count: number('add_count'), update_count: number('update_count'),
- delete_count: number('delete_count'), blocked_count: number('blocked_count'),
- manifest_sha256: manifest, created_by: text('created_by'), created_at: text('created_at'),
- confirmed_by: text('confirmed_by'), confirmed_at: typeof confirmedRaw === 'string' && confirmedRaw ? confirmedRaw : null,
+ id,
+ number: number('number'),
+ name: text('name'),
+ state:
+ data.state === 'confirmed' || data.state === 'consumed' || data.state === 'cancelled' ? data.state : 'planned',
+ source_snapshot_ids: snapshots,
+ source_change_set_ids: catalogSets,
+ acceptance_cutoff: text('acceptance_cutoff'),
+ production_baseline_ref: text('production_baseline_ref'),
+ item_count: number('item_count'),
+ add_count: number('add_count'),
+ update_count: number('update_count'),
+ delete_count: number('delete_count'),
+ blocked_count: number('blocked_count'),
+ manifest_sha256: manifest,
+ created_by: text('created_by'),
+ created_at: text('created_at'),
+ confirmed_by: text('confirmed_by'),
+ confirmed_at: typeof confirmedRaw === 'string' && confirmedRaw ? confirmedRaw : null,
}
}
@@ -364,14 +498,19 @@ export async function generateReleaseChangeSet(name: string): Promise {
- return parseChangeSetEnvelope(await sendJSON(`/api/v1/release-change-sets/${encodeURIComponent(changeSetID)}/confirm`, {}, '确认发布计划集失败'))
+ return parseChangeSetEnvelope(
+ await sendJSON(`/api/v1/release-change-sets/${encodeURIComponent(changeSetID)}/confirm`, {}, '确认发布计划集失败')
+ )
}
export async function fetchLatestReleaseChangeSet(signal?: AbortSignal): Promise {
- const response = await fetch('/api/v1/release-change-sets/latest', { headers: { Accept: 'application/json' }, signal })
+ const response = await fetch('/api/v1/release-change-sets/latest', {
+ headers: { Accept: 'application/json' },
+ signal,
+ })
if (response.status === 404) return null
if (!response.ok) throw new Error(`加载发布计划集失败(HTTP ${response.status})`)
- const value = await response.json() as unknown
+ const value = (await response.json()) as unknown
const base = parseChangeSetEnvelope(value)
const data = (value as Record).data as Record
const rawItems = Array.isArray(data.items) ? data.items : []
@@ -380,36 +519,73 @@ export async function fetchLatestReleaseChangeSet(signal?: AbortSignal): Promise
if (!isRecord(raw)) continue
const action = raw.action
const scope = raw.target_scope
- if (typeof raw.publication_id !== 'string' || (action !== 'add' && action !== 'update' && action !== 'delete' && action !== 'blocked' && action !== 'unchanged') || (scope !== 'internal' && scope !== 'external')) continue
+ if (
+ typeof raw.publication_id !== 'string' ||
+ (action !== 'add' &&
+ action !== 'update' &&
+ action !== 'delete' &&
+ action !== 'blocked' &&
+ action !== 'unchanged') ||
+ (scope !== 'internal' && scope !== 'external')
+ )
+ continue
items.push({
- seq: typeof raw.seq === 'number' ? raw.seq : 0, publication_id: raw.publication_id, title: typeof raw.title === 'string' ? raw.title : '',
- target_scope: scope, action, test_state: typeof raw.test_state === 'string' ? raw.test_state : '', production_state: typeof raw.production_state === 'string' ? raw.production_state : '',
- can_release: raw.can_release === true, blocker: typeof raw.blocker === 'string' ? raw.blocker : '',
+ seq: typeof raw.seq === 'number' ? raw.seq : 0,
+ publication_id: raw.publication_id,
+ title: typeof raw.title === 'string' ? raw.title : '',
+ target_scope: scope,
+ action,
+ test_state: typeof raw.test_state === 'string' ? raw.test_state : '',
+ production_state: typeof raw.production_state === 'string' ? raw.production_state : '',
+ can_release: raw.can_release === true,
+ blocker: typeof raw.blocker === 'string' ? raw.blocker : '',
})
}
return { ...base, items }
}
export async function sealReleaseBatch(batchID: string): Promise {
- return parseBatchEnvelope(await sendJSON(`/api/v1/release-batches/${encodeURIComponent(batchID)}/seal`, {}, '封批失败'))
+ return parseBatchEnvelope(
+ await sendJSON(`/api/v1/release-batches/${encodeURIComponent(batchID)}/seal`, {}, '封批失败')
+ )
}
-export async function fetchReleaseBatches(state: ReleaseBatchState | '' = '', limit = 20, offset = 0, signal?: AbortSignal): Promise {
+export async function fetchReleaseBatches(
+ state: ReleaseBatchState | '' = '',
+ limit = 20,
+ offset = 0,
+ signal?: AbortSignal
+): Promise {
const params = new URLSearchParams({ state, limit: String(limit), offset: String(offset) })
return parseReleaseBatchesResponse(await getJSON(`/api/v1/release-batches?${params}`, signal, '加载封批列表失败'))
}
export async function fetchReleaseBatch(batchID: string, signal?: AbortSignal): Promise {
- return parseReleaseBatchDetailResponse(await getJSON(`/api/v1/release-batches/${encodeURIComponent(batchID)}`, signal, '加载封批详情失败'))
+ return parseReleaseBatchDetailResponse(
+ await getJSON(`/api/v1/release-batches/${encodeURIComponent(batchID)}`, signal, '加载封批详情失败')
+ )
}
-export async function fetchReleaseExecutions(operation: ReleaseOperation | '' = '', state: ReleaseExecutionState | '' = '', limit = 20, offset = 0, signal?: AbortSignal): Promise {
+export async function fetchReleaseExecutions(
+ operation: ReleaseOperation | '' = '',
+ state: ReleaseExecutionState | '' = '',
+ limit = 20,
+ offset = 0,
+ signal?: AbortSignal
+): Promise {
const params = new URLSearchParams({ operation, state, limit: String(limit), offset: String(offset) })
- return parseReleaseExecutionsResponse(await getJSON(`/api/v1/release-executions?${params}`, signal, '加载生产发布审计失败'))
+ return parseReleaseExecutionsResponse(
+ await getJSON(`/api/v1/release-executions?${params}`, signal, '加载生产发布审计失败')
+ )
}
-export async function fetchReleaseExecution(executionID: string, signal?: AbortSignal): Promise {
- return parseReleaseExecutionDetailResponse(await getJSON(`/api/v1/release-executions/${encodeURIComponent(executionID)}`, signal, '加载生产发布详情失败'))
+export async function fetchReleaseExecution(
+ executionID: string,
+ signal?: AbortSignal
+): Promise {
+ return parseReleaseExecutionDetailResponse(
+ await getJSON(`/api/v1/release-executions/${encodeURIComponent(executionID)}`, signal, '加载生产发布详情失败')
+ )
}
export async function fetchReleaseCapabilities(signal?: AbortSignal): Promise {
@@ -421,46 +597,97 @@ export async function fetchReleasePlan(signal?: AbortSignal): Promise = {
}
async function failureMessage(response: Response, message: string): Promise {
- const body = await response.json().catch(() => null) as unknown
+ const body = (await response.json().catch(() => null)) as unknown
const reason = isRecord(body) && typeof body.error === 'string' ? body.error : ''
if (!reason) return `${message}(HTTP ${response.status})`
return `${message}(HTTP ${response.status})${releaseFailureHints[reason] ?? `:${reason}`}`
@@ -531,7 +797,11 @@ async function getJSON(url: string, signal: AbortSignal | undefined, message: st
}
async function sendJSON(url: string, body: unknown, message: string): Promise {
- const response = await fetch(url, { method: 'POST', headers: { Accept: 'application/json', 'Content-Type': 'application/json', 'Idempotency-Key': newOperationID() }, body: JSON.stringify(body) })
+ const response = await fetch(url, {
+ method: 'POST',
+ headers: { Accept: 'application/json', 'Content-Type': 'application/json', 'Idempotency-Key': newOperationID() },
+ body: JSON.stringify(body),
+ })
if (!response.ok) throw new Error(await failureMessage(response, message))
return response.json()
}
@@ -544,38 +814,122 @@ function isCount(value: unknown): value is number {
return typeof value === 'number' && Number.isInteger(value) && value >= 0
}
-const auditText = (row: Record, key: string): string => typeof row[key] === 'string' ? row[key] as string : ''
-const auditNumber = (row: Record, key: string): number => typeof row[key] === 'number' && Number.isFinite(row[key]) ? row[key] as number : 0
-export interface AuditSnapshot { id: string; source_name: string; node_count: number; file_count: number; observed_at: string }
-export interface AuditCatalogSet { id: string; state: string; add_count: number; update_count: number; move_count: number; delete_count: number }
-export interface AuditChainItem { publication_id: string; title: string; action: string; item_state: string; review_id: string; acceptance_run_id: string; source_item_id: string; source_item_version_id: string; production_revision: string; error_code: string }
-export interface ReleaseAuditChain {
- execution_id: string; operation: string; state: string; failure_level: string
- release_batch_id: string; release_batch_name: string; created_by: string; sealed_by: string; authorized_by: string
- release_change_set_id: string; release_change_set_name: string; release_change_set_state: string
- release_change_set_created_by: string; release_change_set_confirmed_by: string; release_change_set_confirmed_at: string | null
- manifest_sha256: string; snapshots: AuditSnapshot[]; catalog_change_sets: AuditCatalogSet[]
- prior_execution_id: string; rollback_execution_id: string; items: AuditChainItem[]
+const auditText = (row: Record, key: string): string =>
+ typeof row[key] === 'string' ? (row[key] as string) : ''
+const auditNumber = (row: Record, key: string): number =>
+ typeof row[key] === 'number' && Number.isFinite(row[key]) ? (row[key] as number) : 0
+export interface AuditSnapshot {
+ id: string
+ source_name: string
+ node_count: number
+ file_count: number
+ observed_at: string
}
-export async function fetchReleaseAuditChain(executionID: string, signal?: AbortSignal): Promise {
- const response = await fetch(`/api/v1/release-executions/${encodeURIComponent(executionID)}/audit-chain`, { headers: { Accept: 'application/json' }, signal })
+export interface AuditCatalogSet {
+ id: string
+ state: string
+ add_count: number
+ update_count: number
+ move_count: number
+ delete_count: number
+}
+export interface AuditChainItem {
+ publication_id: string
+ title: string
+ action: string
+ item_state: string
+ review_id: string
+ acceptance_run_id: string
+ source_item_id: string
+ source_item_version_id: string
+ production_revision: string
+ error_code: string
+}
+export interface ReleaseAuditChain {
+ execution_id: string
+ operation: string
+ state: string
+ failure_level: string
+ release_batch_id: string
+ release_batch_name: string
+ created_by: string
+ sealed_by: string
+ authorized_by: string
+ release_change_set_id: string
+ release_change_set_name: string
+ release_change_set_state: string
+ release_change_set_created_by: string
+ release_change_set_confirmed_by: string
+ release_change_set_confirmed_at: string | null
+ manifest_sha256: string
+ snapshots: AuditSnapshot[]
+ catalog_change_sets: AuditCatalogSet[]
+ prior_execution_id: string
+ rollback_execution_id: string
+ items: AuditChainItem[]
+}
+export async function fetchReleaseAuditChain(
+ executionID: string,
+ signal?: AbortSignal
+): Promise {
+ const response = await fetch(`/api/v1/release-executions/${encodeURIComponent(executionID)}/audit-chain`, {
+ headers: { Accept: 'application/json' },
+ signal,
+ })
if (response.status === 404) return null
if (!response.ok) throw new Error(`加载发布审计链失败(HTTP ${response.status})`)
- const value = await response.json() as unknown
+ const value = (await response.json()) as unknown
if (!isRecord(value) || !isRecord(value.data)) throw new Error('发布审计链响应无效')
const data = value.data
- const rows = (key: string): Record[] => Array.isArray(data[key]) ? (data[key] as unknown[]).filter(isRecord) : []
+ const rows = (key: string): Record[] =>
+ Array.isArray(data[key]) ? (data[key] as unknown[]).filter(isRecord) : []
return {
- execution_id: auditText(data, 'execution_id'), operation: auditText(data, 'operation'), state: auditText(data, 'state'), failure_level: auditText(data, 'failure_level'),
- release_batch_id: auditText(data, 'release_batch_id'), release_batch_name: auditText(data, 'release_batch_name'), created_by: auditText(data, 'created_by'), sealed_by: auditText(data, 'sealed_by'), authorized_by: auditText(data, 'authorized_by'),
- release_change_set_id: auditText(data, 'release_change_set_id'), release_change_set_name: auditText(data, 'release_change_set_name'), release_change_set_state: auditText(data, 'release_change_set_state'),
- release_change_set_created_by: auditText(data, 'release_change_set_created_by'), release_change_set_confirmed_by: auditText(data, 'release_change_set_confirmed_by'),
- release_change_set_confirmed_at: typeof data.release_change_set_confirmed_at === 'string' ? data.release_change_set_confirmed_at : null,
+ execution_id: auditText(data, 'execution_id'),
+ operation: auditText(data, 'operation'),
+ state: auditText(data, 'state'),
+ failure_level: auditText(data, 'failure_level'),
+ release_batch_id: auditText(data, 'release_batch_id'),
+ release_batch_name: auditText(data, 'release_batch_name'),
+ created_by: auditText(data, 'created_by'),
+ sealed_by: auditText(data, 'sealed_by'),
+ authorized_by: auditText(data, 'authorized_by'),
+ release_change_set_id: auditText(data, 'release_change_set_id'),
+ release_change_set_name: auditText(data, 'release_change_set_name'),
+ release_change_set_state: auditText(data, 'release_change_set_state'),
+ release_change_set_created_by: auditText(data, 'release_change_set_created_by'),
+ release_change_set_confirmed_by: auditText(data, 'release_change_set_confirmed_by'),
+ release_change_set_confirmed_at:
+ typeof data.release_change_set_confirmed_at === 'string' ? data.release_change_set_confirmed_at : null,
manifest_sha256: auditText(data, 'manifest_sha256'),
- snapshots: rows('snapshots').map((row) => ({ id: auditText(row, 'id'), source_name: auditText(row, 'source_name'), node_count: auditNumber(row, 'node_count'), file_count: auditNumber(row, 'file_count'), observed_at: auditText(row, 'observed_at') })),
- catalog_change_sets: rows('catalog_change_sets').map((row) => ({ id: auditText(row, 'id'), state: auditText(row, 'state'), add_count: auditNumber(row, 'add_count'), update_count: auditNumber(row, 'update_count'), move_count: auditNumber(row, 'move_count'), delete_count: auditNumber(row, 'delete_count') })),
- prior_execution_id: auditText(data, 'prior_execution_id'), rollback_execution_id: auditText(data, 'rollback_execution_id'),
- items: rows('items').map((row) => ({ publication_id: auditText(row, 'publication_id'), title: auditText(row, 'title'), action: auditText(row, 'action'), item_state: auditText(row, 'item_state'), review_id: auditText(row, 'review_id'), acceptance_run_id: auditText(row, 'acceptance_run_id'), source_item_id: auditText(row, 'source_item_id'), source_item_version_id: auditText(row, 'source_item_version_id'), production_revision: auditText(row, 'production_revision'), error_code: auditText(row, 'error_code') })),
+ snapshots: rows('snapshots').map((row) => ({
+ id: auditText(row, 'id'),
+ source_name: auditText(row, 'source_name'),
+ node_count: auditNumber(row, 'node_count'),
+ file_count: auditNumber(row, 'file_count'),
+ observed_at: auditText(row, 'observed_at'),
+ })),
+ catalog_change_sets: rows('catalog_change_sets').map((row) => ({
+ id: auditText(row, 'id'),
+ state: auditText(row, 'state'),
+ add_count: auditNumber(row, 'add_count'),
+ update_count: auditNumber(row, 'update_count'),
+ move_count: auditNumber(row, 'move_count'),
+ delete_count: auditNumber(row, 'delete_count'),
+ })),
+ prior_execution_id: auditText(data, 'prior_execution_id'),
+ rollback_execution_id: auditText(data, 'rollback_execution_id'),
+ items: rows('items').map((row) => ({
+ publication_id: auditText(row, 'publication_id'),
+ title: auditText(row, 'title'),
+ action: auditText(row, 'action'),
+ item_state: auditText(row, 'item_state'),
+ review_id: auditText(row, 'review_id'),
+ acceptance_run_id: auditText(row, 'acceptance_run_id'),
+ source_item_id: auditText(row, 'source_item_id'),
+ source_item_version_id: auditText(row, 'source_item_version_id'),
+ production_revision: auditText(row, 'production_revision'),
+ error_code: auditText(row, 'error_code'),
+ })),
}
}
@@ -616,15 +970,28 @@ export interface PublishNowResult {
* 服务端不放松任何约束:请求门禁、备份恢复验证门禁、管理员身份、固定确认语句、
* 发布前快照硬门禁全部照旧;这里只是把操作员的两步点击合成一次带确认的提交。
*/
-export async function runPublishNow(publicationIDs: string[], deletePublicationIDs: string[], name: string, confirmation: string): Promise {
+export async function runPublishNow(
+ publicationIDs: string[],
+ deletePublicationIDs: string[],
+ name: string,
+ confirmation: string
+): Promise {
const response = await fetch('/api/v1/release-batches/publish-now', {
method: 'POST',
headers: { Accept: 'application/json', 'Content-Type': 'application/json', 'Idempotency-Key': newOperationID() },
- body: JSON.stringify({ publication_ids: publicationIDs, delete_publication_ids: deletePublicationIDs, name, confirmation }),
+ body: JSON.stringify({
+ publication_ids: publicationIDs,
+ delete_publication_ids: deletePublicationIDs,
+ name,
+ confirmation,
+ }),
})
const payload: unknown = await response.json().catch(() => null)
if (!response.ok) {
- const reason = isRecord(payload) && typeof payload.error === 'string' ? payload.error : `发布到生产失败(HTTP ${response.status})`
+ const reason =
+ isRecord(payload) && typeof payload.error === 'string'
+ ? payload.error
+ : `发布到生产失败(HTTP ${response.status})`
throw new Error(reason)
}
if (!isRecord(payload) || !isRecord(payload.data)) throw new Error('发布到生产响应无效')
@@ -633,10 +1000,18 @@ export async function runPublishNow(publicationIDs: string[], deletePublicationI
if (!isRecord(batch) || !isRecord(execution) || typeof batch.id !== 'string' || typeof execution.id !== 'string') {
throw new Error('发布到生产响应无效')
}
- return { batch_id: batch.id, batch_name: typeof batch.name === 'string' ? batch.name : '', execution_id: execution.id }
+ return {
+ batch_id: batch.id,
+ batch_name: typeof batch.name === 'string' ? batch.name : '',
+ execution_id: execution.id,
+ }
}
-export async function runProductionAction(kind: ConfirmedActionKind, id: string, confirmation: string): Promise {
+export async function runProductionAction(
+ kind: ConfirmedActionKind,
+ id: string,
+ confirmation: string
+): Promise {
const response = await fetch(productionActionPaths[kind](id), {
method: 'POST',
headers: { Accept: 'application/json', 'Content-Type': 'application/json', 'Idempotency-Key': newOperationID() },
@@ -644,7 +1019,8 @@ export async function runProductionAction(kind: ConfirmedActionKind, id: string,
})
const payload: unknown = await response.json().catch(() => null)
if (!response.ok) {
- const reason = isRecord(payload) && typeof payload.error === 'string' ? payload.error : `生产操作失败(HTTP ${response.status})`
+ const reason =
+ isRecord(payload) && typeof payload.error === 'string' ? payload.error : `生产操作失败(HTTP ${response.status})`
throw new Error(reason)
}
if (!isRecord(payload) || !isRecord(payload.data)) throw new Error('生产操作响应无效')
diff --git a/web/src/routingBindings.test.ts b/web/src/routingBindings.test.ts
index b689c29..2366641 100644
--- a/web/src/routingBindings.test.ts
+++ b/web/src/routingBindings.test.ts
@@ -3,14 +3,24 @@ import { describe, expect, it, vi } from 'vitest'
import { fetchRoutingBindingOptions, fetchRoutingBindings, saveRoutingBindings } from './routingBindings'
const binding = {
- internal_kb_id: '1b52c140-2e18-4676-afc5-4c7d8d190a8c', external_kb_id: 'fd7db6aa-763a-46f1-84d2-ecc7c952a5ea',
- internal_env_fallback: true, external_env_fallback: false, internal_bound_by_ui: false, external_bound_by_ui: true,
- internal_kb_name: '', external_kb_name: 'governance-external-local', updated_by: 'yao', weknora_connected: true,
+ internal_kb_id: '1b52c140-2e18-4676-afc5-4c7d8d190a8c',
+ external_kb_id: 'fd7db6aa-763a-46f1-84d2-ecc7c952a5ea',
+ internal_env_fallback: true,
+ external_env_fallback: false,
+ internal_bound_by_ui: false,
+ external_bound_by_ui: true,
+ internal_kb_name: '',
+ external_kb_name: 'governance-external-local',
+ updated_by: 'yao',
+ weknora_connected: true,
}
describe('routing bindings API', () => {
it('reads the active binding projection', async () => {
- vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({ data: { ...binding, secret: 'drop' } }), { status: 200 })))
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn().mockResolvedValue(new Response(JSON.stringify({ data: { ...binding, secret: 'drop' } }), { status: 200 }))
+ )
const result = await fetchRoutingBindings()
expect(result.internal_env_fallback).toBe(true)
expect(result.external_bound_by_ui).toBe(true)
@@ -18,20 +28,38 @@ describe('routing bindings API', () => {
vi.unstubAllGlobals()
})
it('lists knowledge base options for the picker', async () => {
- vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({ data: [{ id: 'kb-1', name: '对内', knowledge_count: 4 }, { id: '', name: '坏行' }] }), { status: 200 })))
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn().mockResolvedValue(
+ new Response(
+ JSON.stringify({
+ data: [
+ { id: 'kb-1', name: '对内', knowledge_count: 4 },
+ { id: '', name: '坏行' },
+ ],
+ }),
+ { status: 200 }
+ )
+ )
+ )
expect(await fetchRoutingBindingOptions()).toEqual([{ id: 'kb-1', name: '对内', knowledge_count: 4 }])
vi.unstubAllGlobals()
})
it('saves bindings with an idempotency key and surfaces the server reason', async () => {
- const fetchMock = vi.fn()
+ const fetchMock = vi
+ .fn()
.mockResolvedValueOnce(new Response(JSON.stringify({ data: binding }), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify({ error: '对内与对外必须绑定不同的知识库' }), { status: 400 }))
vi.stubGlobal('fetch', fetchMock)
- await expect(saveRoutingBindings({ internal_kb_id: 'kb-1', external_kb_id: '' })).resolves.toMatchObject({ internal_bound_by_ui: false })
+ await expect(saveRoutingBindings({ internal_kb_id: 'kb-1', external_kb_id: '' })).resolves.toMatchObject({
+ internal_bound_by_ui: false,
+ })
const [, init] = fetchMock.mock.calls[0]
expect(init.method).toBe('PUT')
expect(init.headers['Idempotency-Key']).toBeTruthy()
- await expect(saveRoutingBindings({ internal_kb_id: 'kb-1', external_kb_id: 'kb-1' })).rejects.toThrow('对内与对外必须绑定不同的知识库')
+ await expect(saveRoutingBindings({ internal_kb_id: 'kb-1', external_kb_id: 'kb-1' })).rejects.toThrow(
+ '对内与对外必须绑定不同的知识库'
+ )
vi.unstubAllGlobals()
})
})
diff --git a/web/src/routingBindings.ts b/web/src/routingBindings.ts
index 7424f5b..4af5e55 100644
--- a/web/src/routingBindings.ts
+++ b/web/src/routingBindings.ts
@@ -22,7 +22,8 @@ export interface KnowledgeBaseOption {
knowledge_count: number
}
-const record = (value: unknown): value is Record => Boolean(value) && typeof value === 'object' && !Array.isArray(value)
+const record = (value: unknown): value is Record =>
+ Boolean(value) && typeof value === 'object' && !Array.isArray(value)
function bindingProjection(item: Record): RoutingBindings {
return {
@@ -44,7 +45,7 @@ function bindingProjection(item: Record): RoutingBindings {
export async function fetchRoutingBindings(signal?: AbortSignal): Promise {
const response = await fetch('/api/v1/routing-bindings', { signal, headers: { Accept: 'application/json' } })
if (!response.ok) throw new Error(`读取测试知识库绑定失败(HTTP ${response.status})`)
- const value = await response.json() as unknown
+ const value = (await response.json()) as unknown
if (!record(value) || !record(value.data)) throw new Error('测试知识库绑定响应无效')
return bindingProjection(value.data)
}
@@ -52,27 +53,39 @@ export async function fetchRoutingBindings(signal?: AbortSignal): Promise {
const response = await fetch('/api/v1/routing-bindings/options', { signal, headers: { Accept: 'application/json' } })
if (!response.ok) throw new Error(`读取可绑定知识库失败(HTTP ${response.status})`)
- const value = await response.json() as unknown
+ const value = (await response.json()) as unknown
if (!record(value) || !Array.isArray(value.data)) throw new Error('知识库列表响应无效')
- return value.data.filter(record).map((item) => ({
- id: typeof item.id === 'string' ? item.id : '',
- name: typeof item.name === 'string' ? item.name : '',
- knowledge_count: typeof item.knowledge_count === 'number' ? item.knowledge_count : 0,
- })).filter((item) => item.id)
+ return value.data
+ .filter(record)
+ .map((item) => ({
+ id: typeof item.id === 'string' ? item.id : '',
+ name: typeof item.name === 'string' ? item.name : '',
+ knowledge_count: typeof item.knowledge_count === 'number' ? item.knowledge_count : 0,
+ }))
+ .filter((item) => item.id)
}
-export async function saveRoutingBindings(input: { internal_kb_id: string; external_kb_id: string }, signal?: AbortSignal): Promise {
+export async function saveRoutingBindings(
+ input: { internal_kb_id: string; external_kb_id: string },
+ signal?: AbortSignal
+): Promise {
const response = await fetch('/api/v1/routing-bindings', {
- method: 'PUT', signal,
+ method: 'PUT',
+ signal,
headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'Idempotency-Key': newOperationID() },
body: JSON.stringify(input),
})
if (!response.ok) {
let reason = `保存测试知识库绑定失败(HTTP ${response.status})`
- try { const body = await response.json() as unknown; if (record(body) && typeof body.error === 'string') reason = body.error } catch { /* keep status text */ }
+ try {
+ const body = (await response.json()) as unknown
+ if (record(body) && typeof body.error === 'string') reason = body.error
+ } catch {
+ /* keep status text */
+ }
throw new Error(reason)
}
- const value = await response.json() as unknown
+ const value = (await response.json()) as unknown
if (!record(value) || !record(value.data)) throw new Error('测试知识库绑定响应无效')
return bindingProjection(value.data)
}
diff --git a/web/src/scopeRules.test.ts b/web/src/scopeRules.test.ts
index 475e71c..c4c4769 100644
--- a/web/src/scopeRules.test.ts
+++ b/web/src/scopeRules.test.ts
@@ -1,25 +1,85 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { clearScopeRule, fetchScopeRules, setScopeRule } from './scopeRules'
-afterEach(()=>vi.unstubAllGlobals())
+afterEach(() => vi.unstubAllGlobals())
-describe('scope rule API',()=>{
- it('lists rules for exactly one configured connection',async()=>{
- const fetchMock=vi.fn().mockResolvedValue(new Response(JSON.stringify({data:[{target_type:'directory',connection_id:'conn-1',directory_path:'产品',visibility:'internal',updated_by:'engineer',reason:'内部资料',updated_at:'2026-09-08T00:00:00Z'}],summary:{total:1}})))
- vi.stubGlobal('fetch',fetchMock)
- const rules=await fetchScopeRules('conn-1')
- expect(rules).toHaveLength(1)
- expect(fetchMock).toHaveBeenCalledWith('/api/v1/scope-rules?connection_id=conn-1',expect.objectContaining({headers:{Accept:'application/json'}}))
- })
- it('sets and clears rules with explicit audited payloads',async()=>{
- const fetchMock=vi.fn().mockImplementation(()=>Promise.resolve(new Response(JSON.stringify({data:{target_type:'directory',connection_id:'conn-1',directory_path:'产品',visibility:'internal'}}))))
- vi.stubGlobal('fetch',fetchMock)
- const target={target_type:'directory' as const,connection_id:'conn-1',directory_path:'产品',source_item_id:''}
- await setScopeRule({...target,visibility:'internal',reason:'内部资料'})
- await clearScopeRule({...target,reason:'恢复来源设置'})
- expect(fetchMock.mock.calls[0][1]).toMatchObject({method:'PUT',headers:{Accept:'application/json','Content-Type':'application/json','Idempotency-Key':expect.any(String)}})
- expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({...target,visibility:'internal',reason:'内部资料'})
- expect(fetchMock.mock.calls[1][1]).toMatchObject({method:'DELETE',headers:{Accept:'application/json','Content-Type':'application/json','Idempotency-Key':expect.any(String)}})
- expect(JSON.parse(fetchMock.mock.calls[1][1].body)).not.toHaveProperty('visibility')
- })
+describe('scope rule API', () => {
+ it('lists rules for exactly one configured connection', async () => {
+ const fetchMock = vi
+ .fn()
+ .mockResolvedValue(
+ new Response(
+ JSON.stringify({
+ data: [
+ {
+ target_type: 'directory',
+ connection_id: 'conn-1',
+ directory_path: '产品',
+ visibility: 'internal',
+ updated_by: 'engineer',
+ reason: '内部资料',
+ updated_at: '2026-09-08T00:00:00Z',
+ },
+ ],
+ summary: { total: 1 },
+ })
+ )
+ )
+ vi.stubGlobal('fetch', fetchMock)
+ const rules = await fetchScopeRules('conn-1')
+ expect(rules).toHaveLength(1)
+ expect(fetchMock).toHaveBeenCalledWith(
+ '/api/v1/scope-rules?connection_id=conn-1',
+ expect.objectContaining({ headers: { Accept: 'application/json' } })
+ )
+ })
+ it('sets and clears rules with explicit audited payloads', async () => {
+ const fetchMock = vi
+ .fn()
+ .mockImplementation(() =>
+ Promise.resolve(
+ new Response(
+ JSON.stringify({
+ data: {
+ target_type: 'directory',
+ connection_id: 'conn-1',
+ directory_path: '产品',
+ visibility: 'internal',
+ },
+ })
+ )
+ )
+ )
+ vi.stubGlobal('fetch', fetchMock)
+ const target = {
+ target_type: 'directory' as const,
+ connection_id: 'conn-1',
+ directory_path: '产品',
+ source_item_id: '',
+ }
+ await setScopeRule({ ...target, visibility: 'internal', reason: '内部资料' })
+ await clearScopeRule({ ...target, reason: '恢复来源设置' })
+ expect(fetchMock.mock.calls[0][1]).toMatchObject({
+ method: 'PUT',
+ headers: {
+ Accept: 'application/json',
+ 'Content-Type': 'application/json',
+ 'Idempotency-Key': expect.any(String),
+ },
+ })
+ expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({
+ ...target,
+ visibility: 'internal',
+ reason: '内部资料',
+ })
+ expect(fetchMock.mock.calls[1][1]).toMatchObject({
+ method: 'DELETE',
+ headers: {
+ Accept: 'application/json',
+ 'Content-Type': 'application/json',
+ 'Idempotency-Key': expect.any(String),
+ },
+ })
+ expect(JSON.parse(fetchMock.mock.calls[1][1].body)).not.toHaveProperty('visibility')
+ })
})
diff --git a/web/src/scopeRules.ts b/web/src/scopeRules.ts
index 476e24c..1a357d7 100644
--- a/web/src/scopeRules.ts
+++ b/web/src/scopeRules.ts
@@ -3,49 +3,61 @@ export type ScopeTargetType = 'directory' | 'item'
export type Visibility = 'internal' | 'external'
export interface ScopeRule {
- target_type: ScopeTargetType
- connection_id?: string
- directory_path?: string
- source_item_id?: string
- visibility: Visibility
- updated_by: string
- reason: string
- updated_at: string
+ target_type: ScopeTargetType
+ connection_id?: string
+ directory_path?: string
+ source_item_id?: string
+ visibility: Visibility
+ updated_by: string
+ reason: string
+ updated_at: string
}
export interface ScopeRuleTarget {
- target_type: ScopeTargetType
- connection_id: string
- directory_path: string
- source_item_id: string
+ target_type: ScopeTargetType
+ connection_id: string
+ directory_path: string
+ source_item_id: string
}
export interface ScopeRuleDecision extends ScopeRuleTarget {
- visibility: Visibility
- reason: string
+ visibility: Visibility
+ reason: string
}
export interface ClearScopeRuleDecision extends ScopeRuleTarget {
- reason: string
+ reason: string
}
-
-
-export async function fetchScopeRules(connectionID:string,signal?:AbortSignal):Promise {
- const response=await fetch(`/api/v1/scope-rules?${new URLSearchParams({connection_id:connectionID})}`,{headers:{Accept:'application/json'},signal})
- if(!response.ok) throw new Error(`加载范围规则失败(HTTP ${response.status})`)
- const value=await response.json() as {data?:unknown}
- if(!Array.isArray(value.data)) throw new Error('范围规则响应无效')
- return value.data as ScopeRule[]
+export async function fetchScopeRules(connectionID: string, signal?: AbortSignal): Promise {
+ const response = await fetch(`/api/v1/scope-rules?${new URLSearchParams({ connection_id: connectionID })}`, {
+ headers: { Accept: 'application/json' },
+ signal,
+ })
+ if (!response.ok) throw new Error(`加载范围规则失败(HTTP ${response.status})`)
+ const value = (await response.json()) as { data?: unknown }
+ if (!Array.isArray(value.data)) throw new Error('范围规则响应无效')
+ return value.data as ScopeRule[]
}
-async function mutateScopeRule(method:'PUT'|'DELETE',body:ScopeRuleDecision|ClearScopeRuleDecision):Promise {
- const response=await fetch('/api/v1/scope-rules',{method,headers:{Accept:'application/json','Content-Type':'application/json','Idempotency-Key':newOperationID()},body:JSON.stringify(body)})
- if(!response.ok) throw new Error(`${method==='PUT'?'保存':'清除'}范围规则失败(HTTP ${response.status})`)
- const value=await response.json() as {data?:ScopeRule}
- if(!value.data || typeof value.data!=='object') throw new Error('范围规则响应无效')
- return value.data
+async function mutateScopeRule(
+ method: 'PUT' | 'DELETE',
+ body: ScopeRuleDecision | ClearScopeRuleDecision
+): Promise {
+ const response = await fetch('/api/v1/scope-rules', {
+ method,
+ headers: { Accept: 'application/json', 'Content-Type': 'application/json', 'Idempotency-Key': newOperationID() },
+ body: JSON.stringify(body),
+ })
+ if (!response.ok) throw new Error(`${method === 'PUT' ? '保存' : '清除'}范围规则失败(HTTP ${response.status})`)
+ const value = (await response.json()) as { data?: ScopeRule }
+ if (!value.data || typeof value.data !== 'object') throw new Error('范围规则响应无效')
+ return value.data
}
-export function setScopeRule(decision:ScopeRuleDecision){return mutateScopeRule('PUT',decision)}
-export function clearScopeRule(decision:ClearScopeRuleDecision){return mutateScopeRule('DELETE',decision)}
+export function setScopeRule(decision: ScopeRuleDecision) {
+ return mutateScopeRule('PUT', decision)
+}
+export function clearScopeRule(decision: ClearScopeRuleDecision) {
+ return mutateScopeRule('DELETE', decision)
+}
diff --git a/web/src/sourceCatalog.test.ts b/web/src/sourceCatalog.test.ts
index 0e48a78..d6c9302 100644
--- a/web/src/sourceCatalog.test.ts
+++ b/web/src/sourceCatalog.test.ts
@@ -1,94 +1,354 @@
-import { afterEach,describe,expect,it,vi } from 'vitest'
-import { fetchAllCatalogFolders,fetchCatalogNodes,fetchCatalogRoutingCapabilities,fetchCatalogSources,fetchLatestCatalogChangeSet,routeCatalogFiles,triggerCatalogRefresh } from './sourceCatalog'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import {
+ fetchAllCatalogFolders,
+ fetchCatalogNodes,
+ fetchCatalogRoutingCapabilities,
+ fetchCatalogSources,
+ fetchLatestCatalogChangeSet,
+ routeCatalogFiles,
+ triggerCatalogRefresh,
+} from './sourceCatalog'
-afterEach(()=>vi.unstubAllGlobals())
+afterEach(() => vi.unstubAllGlobals())
-describe('source catalog API',()=>{
- it('keeps a source that is still scanning, with its progress',async()=>{
- const fetchMock=vi.fn().mockResolvedValue(new Response(JSON.stringify({data:[
- {connection_id:'conn-scan',source_type:'dingtalk',name:'公司级营销资料管理',root_node_id:'',folder_count:1153,file_count:1428,observed_at:'2026-10-10T08:51:44Z',snapshot_only:true,snapshot_status:'scanning',last_refresh_duration_ms:0,scan_progress:36,scan_message:'正在扫描目录:已完成 470 个文件夹,待处理 683 个'},
- {connection_id:'conn-1',source_type:'dingtalk',name:'公司级技术支持知识库',root_node_id:'root-1',folder_count:8,file_count:3468,observed_at:'2026-09-08T00:00:00Z',snapshot_only:true,snapshot_status:'ready',last_refresh_duration_ms:12000}
- ],summary:{total:2}})))
- vi.stubGlobal('fetch',fetchMock)
- const sources=await fetchCatalogSources()
- expect(sources).toHaveLength(2)
- expect(sources[0].snapshot_status).toBe('scanning')
- expect(sources[0].scan_progress).toBe(36)
- expect(sources[0].scan_message).toContain('470 个文件夹')
- expect(sources[1].snapshot_status).toBe('ready')
- expect(sources[1].scan_progress).toBeUndefined()
- })
+describe('source catalog API', () => {
+ it('keeps a source that is still scanning, with its progress', async () => {
+ const fetchMock = vi.fn().mockResolvedValue(
+ new Response(
+ JSON.stringify({
+ data: [
+ {
+ connection_id: 'conn-scan',
+ source_type: 'dingtalk',
+ name: '公司级营销资料管理',
+ root_node_id: '',
+ folder_count: 1153,
+ file_count: 1428,
+ observed_at: '2026-10-10T08:51:44Z',
+ snapshot_only: true,
+ snapshot_status: 'scanning',
+ last_refresh_duration_ms: 0,
+ scan_progress: 36,
+ scan_message: '正在扫描目录:已完成 470 个文件夹,待处理 683 个',
+ },
+ {
+ connection_id: 'conn-1',
+ source_type: 'dingtalk',
+ name: '公司级技术支持知识库',
+ root_node_id: 'root-1',
+ folder_count: 8,
+ file_count: 3468,
+ observed_at: '2026-09-08T00:00:00Z',
+ snapshot_only: true,
+ snapshot_status: 'ready',
+ last_refresh_duration_ms: 12000,
+ },
+ ],
+ summary: { total: 2 },
+ })
+ )
+ )
+ vi.stubGlobal('fetch', fetchMock)
+ const sources = await fetchCatalogSources()
+ expect(sources).toHaveLength(2)
+ expect(sources[0].snapshot_status).toBe('scanning')
+ expect(sources[0].scan_progress).toBe(36)
+ expect(sources[0].scan_message).toContain('470 个文件夹')
+ expect(sources[1].snapshot_status).toBe('ready')
+ expect(sources[1].scan_progress).toBeUndefined()
+ })
- it('rejects a scanning source without progress instead of rendering a fake 0%',async()=>{
- const fetchMock=vi.fn().mockResolvedValue(new Response(JSON.stringify({data:[{connection_id:'conn-scan',source_type:'dingtalk',name:'X',root_node_id:'',folder_count:0,file_count:0,observed_at:'2026-10-10T08:51:44Z',snapshot_only:true,snapshot_status:'scanning',last_refresh_duration_ms:0}],summary:{total:1}})))
- vi.stubGlobal('fetch',fetchMock)
- await expect(fetchCatalogSources()).rejects.toThrow(/来源目录响应无效/)
- })
- it('loads safe source roots',async()=>{
- const fetchMock=vi.fn().mockResolvedValue(new Response(JSON.stringify({data:[{connection_id:'conn-1',source_type:'dingtalk',name:'公司级技术支持知识库',root_node_id:'root-1',folder_count:8,file_count:3468,observed_at:'2026-09-08T00:00:00Z',snapshot_only:true,snapshot_status:'ready',last_refresh_duration_ms:12000,secret:'drop'}],summary:{total:1}})))
- vi.stubGlobal('fetch',fetchMock)
- const sources=await fetchCatalogSources()
- expect(sources[0].name).toBe('公司级技术支持知识库')
- expect(sources[0].snapshot_status).toBe('ready')
- expect(sources[0].last_refresh_duration_ms).toBe(12000)
- expect(sources[0]).not.toHaveProperty('secret')
- expect(fetchMock).toHaveBeenCalledWith('/api/v1/source-catalog/sources',expect.objectContaining({headers:{Accept:'application/json'}}))
- })
- it('loads every page for a complete deduplicated folder tree level',async()=>{
- const folderA={node_id:'folder-a',parent_node_id:'root',node_type:'folder',name:'A',direct_child_count:1,subtree_file_count:1,subtree_bytes:1,visibility:'unknown',imported_count:0,parsed_count:0,active_count:0,failed_count:0,routable_count:0}
- const file={...folderA,node_id:'file-a',node_type:'file',name:'a.pdf'}
- const folderB={...folderA,node_id:'folder-b',name:'B'}
- const response=(items:unknown[],offset:number)=>new Response(JSON.stringify({current:{...folderA,node_id:'root',name:'Root'},breadcrumbs:[{node_id:'root',name:'Root'}],items,total:3,limit:2,offset}))
- const fetchMock=vi.fn().mockResolvedValueOnce(response([folderA,file],0)).mockResolvedValueOnce(response([folderB,folderA],2))
- vi.stubGlobal('fetch',fetchMock)
- await expect(fetchAllCatalogFolders('conn-1','root')).resolves.toEqual([folderA,folderB])
- expect(fetchMock.mock.calls.map(call=>call[0])).toEqual(['/api/v1/source-catalog/nodes?connection_id=conn-1&parent_id=root&limit=100&offset=0','/api/v1/source-catalog/nodes?connection_id=conn-1&parent_id=root&limit=100&offset=2'])
- })
- it('requests only one parent page at a time',async()=>{
- const response={current:{node_id:'root-1',node_type:'folder',name:'公司级技术支持知识库',direct_child_count:1,subtree_file_count:10,subtree_bytes:20,visibility:'unknown',imported_count:0,parsed_count:0,active_count:0,failed_count:0,routable_count:0},breadcrumbs:[{node_id:'root-1',name:'公司级技术支持知识库'}],items:[{node_id:'folder-1',parent_node_id:'root-1',node_type:'folder',name:'01|产品资料',direct_child_count:2,subtree_file_count:10,subtree_bytes:20,visibility:'internal',imported_count:8,parsed_count:7,active_count:1,failed_count:0,routable_count:8}],total:1,limit:50,offset:0}
- const fetchMock=vi.fn().mockResolvedValue(new Response(JSON.stringify(response)))
- vi.stubGlobal('fetch',fetchMock)
- const page=await fetchCatalogNodes('conn-1','root-1',50,0)
- expect(page.items).toHaveLength(1)
- expect(fetchMock.mock.calls[0][0]).toBe('/api/v1/source-catalog/nodes?connection_id=conn-1&parent_id=root-1&limit=50&offset=0')
- expect(JSON.stringify(page)).not.toContain('relative_path')
- })
- it('routes selected files without exposing target knowledge-base IDs to the browser',async()=>{
- const fetchMock=vi.fn()
- .mockResolvedValueOnce(new Response(JSON.stringify({data:{internal_enabled:true,external_enabled:true}})))
- .mockResolvedValueOnce(new Response(JSON.stringify({data:{selected_files:2,intents_recorded:2,fetch_queued:1,planned_publications:4,blocked_items:0,unbound_files:0}})))
- vi.stubGlobal('fetch',fetchMock)
- expect((await fetchCatalogRoutingCapabilities()).internal_enabled).toBe(true)
- const result=await routeCatalogFiles({connection_id:'conn-1',node_ids:['a','b'],policy:'both',reason:'批量标注'})
- expect(result.planned_publications).toBe(4)
- expect(result.unbound_files).toBe(0)
- const payload=JSON.parse(fetchMock.mock.calls[1][1].body)
- expect(payload.policy).toBe('both')
- expect(payload).not.toHaveProperty('visibility')
- expect(payload).not.toHaveProperty('target_knowledge_base_id')
- expect(fetchMock.mock.calls[1][1].headers).toMatchObject({Accept:'application/json','Content-Type':'application/json','Idempotency-Key':expect.any(String)})
- expect(fetchMock.mock.calls[1][1].headers['Idempotency-Key']).not.toBe('')
- })
- it('reports blocked items without planning anything',async()=>{
- const fetchMock=vi.fn().mockResolvedValue(new Response(JSON.stringify({data:{selected_files:2,intents_recorded:2,fetch_queued:0,planned_publications:0,blocked_items:2,unbound_files:0}})))
- vi.stubGlobal('fetch',fetchMock)
- const result=await routeCatalogFiles({connection_id:'conn-1',node_ids:['a','b'],policy:'blocked',reason:'禁止导入'})
- expect(result.blocked_items).toBe(2)
- expect(result.planned_publications).toBe(0)
- })
- it('starts a durable background refresh job with no source credential payload',async()=>{
- const job={id:'job-refresh',type:'source.catalog.refresh',payload:{connection_id:'conn-1',stage:'queued',progress:0,message:'等待后台刷新'},state:'pending',priority:20,available_at:'2026-09-09T00:00:00Z',attempt:0,max_attempts:3,idempotency_key:'safe',created_at:'2026-09-09T00:00:00Z',updated_at:'2026-09-09T00:00:00Z'}
- const fetchMock=vi.fn().mockResolvedValue(new Response(JSON.stringify({data:job})));vi.stubGlobal('fetch',fetchMock)
- const result=await triggerCatalogRefresh('conn-1')
- expect(result.id).toBe('job-refresh');expect(fetchMock.mock.calls[0][1].method).toBe('POST');expect(fetchMock.mock.calls[0][1].headers).toMatchObject({Accept:'application/json','Idempotency-Key':expect.any(String)});expect(fetchMock.mock.calls[0][1].headers['Idempotency-Key']).not.toBe('');expect(JSON.stringify(result.payload)).not.toContain('token')
- })
- it('loads the latest persisted snapshot change set without internal hashes',async()=>{
- const fetchMock=vi.fn().mockResolvedValue(new Response(JSON.stringify({data:{id:'cs-1',connection_id:'conn-1',source_name:'国内工业',state:'planned',add_count:2,update_count:1,move_count:0,move_scope_count:1,delete_count:1,no_change_count:10,created_at:'2026-09-10T00:00:00Z',items:[{action:'delete',title:'旧手册.pdf',old_path:'旧目录/旧手册.pdf',reason_code:'source_missing',seq:7}],total:1,limit:20,offset:0,source_locator_hash:'drop'}})))
- vi.stubGlobal('fetch',fetchMock)
- const result=await fetchLatestCatalogChangeSet('conn-1','delete',20,0)
- expect(result.add_count).toBe(2)
- expect(result.items[0]).toEqual({action:'delete',title:'旧手册.pdf',old_path:'旧目录/旧手册.pdf',new_path:'',reason_code:'source_missing',seq:7})
- expect(result).not.toHaveProperty('source_locator_hash')
- expect(fetchMock.mock.calls[0][0]).toBe('/api/v1/source-catalog/change-sets/latest?connection_id=conn-1&action=delete&limit=20&offset=0')
- })
+ it('rejects a scanning source without progress instead of rendering a fake 0%', async () => {
+ const fetchMock = vi
+ .fn()
+ .mockResolvedValue(
+ new Response(
+ JSON.stringify({
+ data: [
+ {
+ connection_id: 'conn-scan',
+ source_type: 'dingtalk',
+ name: 'X',
+ root_node_id: '',
+ folder_count: 0,
+ file_count: 0,
+ observed_at: '2026-10-10T08:51:44Z',
+ snapshot_only: true,
+ snapshot_status: 'scanning',
+ last_refresh_duration_ms: 0,
+ },
+ ],
+ summary: { total: 1 },
+ })
+ )
+ )
+ vi.stubGlobal('fetch', fetchMock)
+ await expect(fetchCatalogSources()).rejects.toThrow(/来源目录响应无效/)
+ })
+ it('loads safe source roots', async () => {
+ const fetchMock = vi
+ .fn()
+ .mockResolvedValue(
+ new Response(
+ JSON.stringify({
+ data: [
+ {
+ connection_id: 'conn-1',
+ source_type: 'dingtalk',
+ name: '公司级技术支持知识库',
+ root_node_id: 'root-1',
+ folder_count: 8,
+ file_count: 3468,
+ observed_at: '2026-09-08T00:00:00Z',
+ snapshot_only: true,
+ snapshot_status: 'ready',
+ last_refresh_duration_ms: 12000,
+ secret: 'drop',
+ },
+ ],
+ summary: { total: 1 },
+ })
+ )
+ )
+ vi.stubGlobal('fetch', fetchMock)
+ const sources = await fetchCatalogSources()
+ expect(sources[0].name).toBe('公司级技术支持知识库')
+ expect(sources[0].snapshot_status).toBe('ready')
+ expect(sources[0].last_refresh_duration_ms).toBe(12000)
+ expect(sources[0]).not.toHaveProperty('secret')
+ expect(fetchMock).toHaveBeenCalledWith(
+ '/api/v1/source-catalog/sources',
+ expect.objectContaining({ headers: { Accept: 'application/json' } })
+ )
+ })
+ it('loads every page for a complete deduplicated folder tree level', async () => {
+ const folderA = {
+ node_id: 'folder-a',
+ parent_node_id: 'root',
+ node_type: 'folder',
+ name: 'A',
+ direct_child_count: 1,
+ subtree_file_count: 1,
+ subtree_bytes: 1,
+ visibility: 'unknown',
+ imported_count: 0,
+ parsed_count: 0,
+ active_count: 0,
+ failed_count: 0,
+ routable_count: 0,
+ }
+ const file = { ...folderA, node_id: 'file-a', node_type: 'file', name: 'a.pdf' }
+ const folderB = { ...folderA, node_id: 'folder-b', name: 'B' }
+ const response = (items: unknown[], offset: number) =>
+ new Response(
+ JSON.stringify({
+ current: { ...folderA, node_id: 'root', name: 'Root' },
+ breadcrumbs: [{ node_id: 'root', name: 'Root' }],
+ items,
+ total: 3,
+ limit: 2,
+ offset,
+ })
+ )
+ const fetchMock = vi
+ .fn()
+ .mockResolvedValueOnce(response([folderA, file], 0))
+ .mockResolvedValueOnce(response([folderB, folderA], 2))
+ vi.stubGlobal('fetch', fetchMock)
+ await expect(fetchAllCatalogFolders('conn-1', 'root')).resolves.toEqual([folderA, folderB])
+ expect(fetchMock.mock.calls.map((call) => call[0])).toEqual([
+ '/api/v1/source-catalog/nodes?connection_id=conn-1&parent_id=root&limit=100&offset=0',
+ '/api/v1/source-catalog/nodes?connection_id=conn-1&parent_id=root&limit=100&offset=2',
+ ])
+ })
+ it('requests only one parent page at a time', async () => {
+ const response = {
+ current: {
+ node_id: 'root-1',
+ node_type: 'folder',
+ name: '公司级技术支持知识库',
+ direct_child_count: 1,
+ subtree_file_count: 10,
+ subtree_bytes: 20,
+ visibility: 'unknown',
+ imported_count: 0,
+ parsed_count: 0,
+ active_count: 0,
+ failed_count: 0,
+ routable_count: 0,
+ },
+ breadcrumbs: [{ node_id: 'root-1', name: '公司级技术支持知识库' }],
+ items: [
+ {
+ node_id: 'folder-1',
+ parent_node_id: 'root-1',
+ node_type: 'folder',
+ name: '01|产品资料',
+ direct_child_count: 2,
+ subtree_file_count: 10,
+ subtree_bytes: 20,
+ visibility: 'internal',
+ imported_count: 8,
+ parsed_count: 7,
+ active_count: 1,
+ failed_count: 0,
+ routable_count: 8,
+ },
+ ],
+ total: 1,
+ limit: 50,
+ offset: 0,
+ }
+ const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify(response)))
+ vi.stubGlobal('fetch', fetchMock)
+ const page = await fetchCatalogNodes('conn-1', 'root-1', 50, 0)
+ expect(page.items).toHaveLength(1)
+ expect(fetchMock.mock.calls[0][0]).toBe(
+ '/api/v1/source-catalog/nodes?connection_id=conn-1&parent_id=root-1&limit=50&offset=0'
+ )
+ expect(JSON.stringify(page)).not.toContain('relative_path')
+ })
+ it('routes selected files without exposing target knowledge-base IDs to the browser', async () => {
+ const fetchMock = vi
+ .fn()
+ .mockResolvedValueOnce(new Response(JSON.stringify({ data: { internal_enabled: true, external_enabled: true } })))
+ .mockResolvedValueOnce(
+ new Response(
+ JSON.stringify({
+ data: {
+ selected_files: 2,
+ intents_recorded: 2,
+ fetch_queued: 1,
+ planned_publications: 4,
+ blocked_items: 0,
+ unbound_files: 0,
+ },
+ })
+ )
+ )
+ vi.stubGlobal('fetch', fetchMock)
+ expect((await fetchCatalogRoutingCapabilities()).internal_enabled).toBe(true)
+ const result = await routeCatalogFiles({
+ connection_id: 'conn-1',
+ node_ids: ['a', 'b'],
+ policy: 'both',
+ reason: '批量标注',
+ })
+ expect(result.planned_publications).toBe(4)
+ expect(result.unbound_files).toBe(0)
+ const payload = JSON.parse(fetchMock.mock.calls[1][1].body)
+ expect(payload.policy).toBe('both')
+ expect(payload).not.toHaveProperty('visibility')
+ expect(payload).not.toHaveProperty('target_knowledge_base_id')
+ expect(fetchMock.mock.calls[1][1].headers).toMatchObject({
+ Accept: 'application/json',
+ 'Content-Type': 'application/json',
+ 'Idempotency-Key': expect.any(String),
+ })
+ expect(fetchMock.mock.calls[1][1].headers['Idempotency-Key']).not.toBe('')
+ })
+ it('reports blocked items without planning anything', async () => {
+ const fetchMock = vi
+ .fn()
+ .mockResolvedValue(
+ new Response(
+ JSON.stringify({
+ data: {
+ selected_files: 2,
+ intents_recorded: 2,
+ fetch_queued: 0,
+ planned_publications: 0,
+ blocked_items: 2,
+ unbound_files: 0,
+ },
+ })
+ )
+ )
+ vi.stubGlobal('fetch', fetchMock)
+ const result = await routeCatalogFiles({
+ connection_id: 'conn-1',
+ node_ids: ['a', 'b'],
+ policy: 'blocked',
+ reason: '禁止导入',
+ })
+ expect(result.blocked_items).toBe(2)
+ expect(result.planned_publications).toBe(0)
+ })
+ it('starts a durable background refresh job with no source credential payload', async () => {
+ const job = {
+ id: 'job-refresh',
+ type: 'source.catalog.refresh',
+ payload: { connection_id: 'conn-1', stage: 'queued', progress: 0, message: '等待后台刷新' },
+ state: 'pending',
+ priority: 20,
+ available_at: '2026-09-09T00:00:00Z',
+ attempt: 0,
+ max_attempts: 3,
+ idempotency_key: 'safe',
+ created_at: '2026-09-09T00:00:00Z',
+ updated_at: '2026-09-09T00:00:00Z',
+ }
+ const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ data: job })))
+ vi.stubGlobal('fetch', fetchMock)
+ const result = await triggerCatalogRefresh('conn-1')
+ expect(result.id).toBe('job-refresh')
+ expect(fetchMock.mock.calls[0][1].method).toBe('POST')
+ expect(fetchMock.mock.calls[0][1].headers).toMatchObject({
+ Accept: 'application/json',
+ 'Idempotency-Key': expect.any(String),
+ })
+ expect(fetchMock.mock.calls[0][1].headers['Idempotency-Key']).not.toBe('')
+ expect(JSON.stringify(result.payload)).not.toContain('token')
+ })
+ it('loads the latest persisted snapshot change set without internal hashes', async () => {
+ const fetchMock = vi
+ .fn()
+ .mockResolvedValue(
+ new Response(
+ JSON.stringify({
+ data: {
+ id: 'cs-1',
+ connection_id: 'conn-1',
+ source_name: '国内工业',
+ state: 'planned',
+ add_count: 2,
+ update_count: 1,
+ move_count: 0,
+ move_scope_count: 1,
+ delete_count: 1,
+ no_change_count: 10,
+ created_at: '2026-09-10T00:00:00Z',
+ items: [
+ {
+ action: 'delete',
+ title: '旧手册.pdf',
+ old_path: '旧目录/旧手册.pdf',
+ reason_code: 'source_missing',
+ seq: 7,
+ },
+ ],
+ total: 1,
+ limit: 20,
+ offset: 0,
+ source_locator_hash: 'drop',
+ },
+ })
+ )
+ )
+ vi.stubGlobal('fetch', fetchMock)
+ const result = await fetchLatestCatalogChangeSet('conn-1', 'delete', 20, 0)
+ expect(result.add_count).toBe(2)
+ expect(result.items[0]).toEqual({
+ action: 'delete',
+ title: '旧手册.pdf',
+ old_path: '旧目录/旧手册.pdf',
+ new_path: '',
+ reason_code: 'source_missing',
+ seq: 7,
+ })
+ expect(result).not.toHaveProperty('source_locator_hash')
+ expect(fetchMock.mock.calls[0][0]).toBe(
+ '/api/v1/source-catalog/change-sets/latest?connection_id=conn-1&action=delete&limit=20&offset=0'
+ )
+ })
})
diff --git a/web/src/sourceCatalog.ts b/web/src/sourceCatalog.ts
index 5f3c00a..c648e2f 100644
--- a/web/src/sourceCatalog.ts
+++ b/web/src/sourceCatalog.ts
@@ -1,115 +1,537 @@
import type { Job } from './jobs'
import { newOperationID } from './operationKey'
-export type CatalogSourceType='dingtalk'|'pp'|'alist'
-export type CatalogPolicy='internal'|'external'|'both'|'blocked'
-export interface CatalogSource {connection_id:string;source_type:CatalogSourceType;name:string;root_node_id:string;folder_count:number;file_count:number;observed_at:string;snapshot_only:boolean;snapshot_status:'ready'|'scanning';last_refresh_duration_ms:number;scan_progress?:number;scan_message?:string}
-export interface CatalogBreadcrumb {node_id:string;name:string}
-export interface CatalogNode {node_id:string;parent_node_id?:string;node_type:'folder'|'file';name:string;direct_child_count:number;subtree_file_count:number;subtree_bytes:number;size_bytes?:number;modified_at?:string;content_sha256?:string;visibility:'internal'|'external'|'both'|'blocked'|'mixed'|'unknown';imported_count:number;parsed_count:number;active_count:number;failed_count:number;routable_count:number}
-export interface CatalogSnapshot {snapshot_id:string;connection_id:string;root_node_id:string;source_name:string;node_count:number;folder_count:number;file_count:number;observed_at:string;created_at:string;is_head:boolean}
-export interface CatalogSnapshotPage {items:CatalogSnapshot[];total:number;limit:number;offset:number}
-export interface CatalogNodePage {current:CatalogNode;breadcrumbs:CatalogBreadcrumb[];items:CatalogNode[];total:number;limit:number;offset:number}
-const sourceTypes=new Set(['dingtalk','pp','alist']);const nodeTypes=new Set(['folder','file']);const visibilities=new Set(['internal','external','both','blocked','mixed','unknown'])
-const record=(value:unknown):value is Record=>typeof value==='object'&&value!==null
-const count=(value:unknown):value is number=>typeof value==='number'&&Number.isInteger(value)&&value>=0
-function parseNode(value:unknown):CatalogNode{
- if(!record(value)||typeof value.node_id!=='string'||typeof value.node_type!=='string'||!nodeTypes.has(value.node_type)||typeof value.name!=='string'||!count(value.direct_child_count)||!count(value.subtree_file_count)||!count(value.subtree_bytes)||typeof value.visibility!=='string'||!visibilities.has(value.visibility)||!count(value.imported_count)||!count(value.parsed_count)||!count(value.active_count)||!count(value.failed_count)||!count(value.routable_count)||value.content_sha256!==undefined&&typeof value.content_sha256!=='string')throw new Error('目录节点响应无效')
- return value as unknown as CatalogNode
+export type CatalogSourceType = 'dingtalk' | 'pp' | 'alist'
+export type CatalogPolicy = 'internal' | 'external' | 'both' | 'blocked'
+export interface CatalogSource {
+ connection_id: string
+ source_type: CatalogSourceType
+ name: string
+ root_node_id: string
+ folder_count: number
+ file_count: number
+ observed_at: string
+ snapshot_only: boolean
+ snapshot_status: 'ready' | 'scanning'
+ last_refresh_duration_ms: number
+ scan_progress?: number
+ scan_message?: string
}
-export async function fetchCatalogSources(signal?:AbortSignal):Promise{
- const response=await fetch('/api/v1/source-catalog/sources',{headers:{Accept:'application/json'},signal})
- if(!response.ok)throw new Error(`加载来源目录失败(HTTP ${response.status})`)
- const value=await response.json() as unknown
- if(!record(value)||!Array.isArray(value.data))throw new Error('来源目录响应无效')
- return value.data.map(raw=>{
- if(!record(raw)||typeof raw.connection_id!=='string'||typeof raw.source_type!=='string'||!sourceTypes.has(raw.source_type)||typeof raw.name!=='string'||typeof raw.root_node_id!=='string'||!count(raw.folder_count)||!count(raw.file_count)||typeof raw.observed_at!=='string'||raw.snapshot_only!==true||(raw.snapshot_status!=='ready'&&raw.snapshot_status!=='scanning')||!count(raw.last_refresh_duration_ms))throw new Error('来源目录响应无效')
- // scanning = 已登记但首次扫描还没落地:带进度,正好让新来源立刻出现在列表里。
- const scanning=raw.snapshot_status==='scanning'
- if(scanning&&!count(raw.scan_progress))throw new Error('来源目录响应无效')
- return {connection_id:raw.connection_id,source_type:raw.source_type as CatalogSourceType,name:raw.name,root_node_id:raw.root_node_id,folder_count:raw.folder_count,file_count:raw.file_count,observed_at:raw.observed_at,snapshot_only:true,snapshot_status:raw.snapshot_status as 'ready'|'scanning',last_refresh_duration_ms:raw.last_refresh_duration_ms,scan_progress:scanning?raw.scan_progress as number:undefined,scan_message:typeof raw.scan_message==='string'?raw.scan_message:undefined}
- })
+export interface CatalogBreadcrumb {
+ node_id: string
+ name: string
}
-export async function fetchCatalogSnapshots(connectionID:string,limit=20,offset=0,signal?:AbortSignal):Promise{
- const query=new URLSearchParams({connection_id:connectionID,limit:String(limit),offset:String(offset)})
- const response=await fetch(`/api/v1/source-catalog/snapshots?${query}`,{headers:{Accept:'application/json'},signal})
- if(!response.ok)throw new Error(`加载快照历史失败(HTTP ${response.status})`)
- const value=await response.json() as unknown
- if(!record(value)||!Array.isArray(value.items)||!count(value.total)||!count(value.limit)||!count(value.offset))throw new Error('快照历史响应无效')
- const items=value.items.map(raw=>{if(!record(raw)||typeof raw.snapshot_id!=='string'||typeof raw.connection_id!=='string'||typeof raw.root_node_id!=='string'||typeof raw.source_name!=='string'||!count(raw.node_count)||!count(raw.folder_count)||!count(raw.file_count)||typeof raw.observed_at!=='string'||typeof raw.created_at!=='string'||typeof raw.is_head!=='boolean')throw new Error('快照历史条目无效');return raw as unknown as CatalogSnapshot})
- return {items,total:value.total,limit:value.limit,offset:value.offset}
+export interface CatalogNode {
+ node_id: string
+ parent_node_id?: string
+ node_type: 'folder' | 'file'
+ name: string
+ direct_child_count: number
+ subtree_file_count: number
+ subtree_bytes: number
+ size_bytes?: number
+ modified_at?: string
+ content_sha256?: string
+ visibility: 'internal' | 'external' | 'both' | 'blocked' | 'mixed' | 'unknown'
+ imported_count: number
+ parsed_count: number
+ active_count: number
+ failed_count: number
+ routable_count: number
}
-export async function fetchCatalogSnapshotNodes(connectionID:string,snapshotID:string,parentID:string,limit=50,offset=0,signal?:AbortSignal):Promise{
- const query=new URLSearchParams({connection_id:connectionID,parent_id:parentID,limit:String(limit),offset:String(offset)})
- const response=await fetch(`/api/v1/source-catalog/snapshots/${encodeURIComponent(snapshotID)}/nodes?${query}`,{headers:{Accept:'application/json'},signal})
- if(!response.ok)throw new Error(`加载历史快照目录失败(HTTP ${response.status})`)
- const value=await response.json() as unknown
- if(!record(value)||!Array.isArray(value.items)||!count(value.total)||!count(value.limit)||!count(value.offset))throw new Error('历史快照目录响应无效')
- return {current:parseNode(value.current),breadcrumbs:Array.isArray(value.breadcrumbs)?value.breadcrumbs.map(raw=>{if(!record(raw)||typeof raw.node_id!=='string'||typeof raw.name!=='string')throw new Error('目录路径响应无效');return raw as unknown as CatalogBreadcrumb}):[],items:value.items.map(parseNode),total:value.total,limit:value.limit,offset:value.offset}
+export interface CatalogSnapshot {
+ snapshot_id: string
+ connection_id: string
+ root_node_id: string
+ source_name: string
+ node_count: number
+ folder_count: number
+ file_count: number
+ observed_at: string
+ created_at: string
+ is_head: boolean
}
-export interface ConfirmChangeSetResult{change_set_id:string;confirmed_items:number;uploads_created:number;uploads_existing:number;removals_requested:number;skipped_no_artifact:number;skipped_no_target:number;no_change_ignored:number}
-export async function confirmCatalogChangeSet(changeSetID:string,itemSeqs:number[],reason:string):Promise<{result:ConfirmChangeSetResult;operation:string}>{
- const response=await fetch(`/api/v1/source-catalog/change-sets/${encodeURIComponent(changeSetID)}/confirm`,{method:'POST',headers:{Accept:'application/json','Content-Type':'application/json','Idempotency-Key':newOperationID()},body:JSON.stringify({item_seqs:itemSeqs,reason})})
- if(!response.ok)throw new Error(response.status===409?'操作标识与另一请求冲突,请刷新后重试':`确认快照差异失败(HTTP ${response.status})`)
- const value=await response.json() as unknown;if(!record(value)||!record(value.data)||!count(value.data.confirmed_items)||!count(value.data.uploads_created)||!count(value.data.uploads_existing)||!count(value.data.removals_requested)||!count(value.data.skipped_no_artifact)||!count(value.data.skipped_no_target)||!count(value.data.no_change_ignored)||typeof value.operation!=='string')throw new Error('确认快照差异响应无效')
- return {result:value.data as unknown as ConfirmChangeSetResult,operation:value.operation}
+export interface CatalogSnapshotPage {
+ items: CatalogSnapshot[]
+ total: number
+ limit: number
+ offset: number
}
-export async function fetchCatalogNodes(connectionID:string,parentID:string,limit=50,offset=0,signal?:AbortSignal):Promise{
- const query=new URLSearchParams({connection_id:connectionID,parent_id:parentID,limit:String(limit),offset:String(offset)})
- const response=await fetch(`/api/v1/source-catalog/nodes?${query}`,{headers:{Accept:'application/json'},signal})
- if(!response.ok)throw new Error(response.status===404?'目录已更新,请返回来源首页':`加载目录失败(HTTP ${response.status})`)
- const value=await response.json() as unknown
- if(!record(value)||!Array.isArray(value.breadcrumbs)||!Array.isArray(value.items)||!count(value.total)||!count(value.limit)||!count(value.offset))throw new Error('目录列表响应无效')
- const breadcrumbs=value.breadcrumbs.map(raw=>{if(!record(raw)||typeof raw.node_id!=='string'||typeof raw.name!=='string')throw new Error('目录路径响应无效');return raw as unknown as CatalogBreadcrumb})
- return {current:parseNode(value.current),breadcrumbs,items:value.items.map(parseNode),total:value.total,limit:value.limit,offset:value.offset}
+export interface CatalogNodePage {
+ current: CatalogNode
+ breadcrumbs: CatalogBreadcrumb[]
+ items: CatalogNode[]
+ total: number
+ limit: number
+ offset: number
}
-
-export async function fetchAllCatalogFolders(connectionID:string,parentID:string,signal?:AbortSignal):Promise{
- const folders:CatalogNode[]=[]
- const seen=new Set()
- let offset=0
- while(true){
- const page=await fetchCatalogNodes(connectionID,parentID,100,offset,signal)
- for(const node of page.items){
- if(node.node_type==='folder'&&!seen.has(node.node_id)){seen.add(node.node_id);folders.push(node)}
+const sourceTypes = new Set(['dingtalk', 'pp', 'alist'])
+const nodeTypes = new Set(['folder', 'file'])
+const visibilities = new Set(['internal', 'external', 'both', 'blocked', 'mixed', 'unknown'])
+const record = (value: unknown): value is Record => typeof value === 'object' && value !== null
+const count = (value: unknown): value is number => typeof value === 'number' && Number.isInteger(value) && value >= 0
+function parseNode(value: unknown): CatalogNode {
+ if (
+ !record(value) ||
+ typeof value.node_id !== 'string' ||
+ typeof value.node_type !== 'string' ||
+ !nodeTypes.has(value.node_type) ||
+ typeof value.name !== 'string' ||
+ !count(value.direct_child_count) ||
+ !count(value.subtree_file_count) ||
+ !count(value.subtree_bytes) ||
+ typeof value.visibility !== 'string' ||
+ !visibilities.has(value.visibility) ||
+ !count(value.imported_count) ||
+ !count(value.parsed_count) ||
+ !count(value.active_count) ||
+ !count(value.failed_count) ||
+ !count(value.routable_count) ||
+ (value.content_sha256 !== undefined && typeof value.content_sha256 !== 'string')
+ )
+ throw new Error('目录节点响应无效')
+ return value as unknown as CatalogNode
+}
+export async function fetchCatalogSources(signal?: AbortSignal): Promise {
+ const response = await fetch('/api/v1/source-catalog/sources', { headers: { Accept: 'application/json' }, signal })
+ if (!response.ok) throw new Error(`加载来源目录失败(HTTP ${response.status})`)
+ const value = (await response.json()) as unknown
+ if (!record(value) || !Array.isArray(value.data)) throw new Error('来源目录响应无效')
+ return value.data.map((raw) => {
+ if (
+ !record(raw) ||
+ typeof raw.connection_id !== 'string' ||
+ typeof raw.source_type !== 'string' ||
+ !sourceTypes.has(raw.source_type) ||
+ typeof raw.name !== 'string' ||
+ typeof raw.root_node_id !== 'string' ||
+ !count(raw.folder_count) ||
+ !count(raw.file_count) ||
+ typeof raw.observed_at !== 'string' ||
+ raw.snapshot_only !== true ||
+ (raw.snapshot_status !== 'ready' && raw.snapshot_status !== 'scanning') ||
+ !count(raw.last_refresh_duration_ms)
+ )
+ throw new Error('来源目录响应无效')
+ // scanning = 已登记但首次扫描还没落地:带进度,正好让新来源立刻出现在列表里。
+ const scanning = raw.snapshot_status === 'scanning'
+ if (scanning && !count(raw.scan_progress)) throw new Error('来源目录响应无效')
+ return {
+ connection_id: raw.connection_id,
+ source_type: raw.source_type as CatalogSourceType,
+ name: raw.name,
+ root_node_id: raw.root_node_id,
+ folder_count: raw.folder_count,
+ file_count: raw.file_count,
+ observed_at: raw.observed_at,
+ snapshot_only: true,
+ snapshot_status: raw.snapshot_status as 'ready' | 'scanning',
+ last_refresh_duration_ms: raw.last_refresh_duration_ms,
+ scan_progress: scanning ? (raw.scan_progress as number) : undefined,
+ scan_message: typeof raw.scan_message === 'string' ? raw.scan_message : undefined,
+ }
+ })
+}
+export async function fetchCatalogSnapshots(
+ connectionID: string,
+ limit = 20,
+ offset = 0,
+ signal?: AbortSignal
+): Promise {
+ const query = new URLSearchParams({ connection_id: connectionID, limit: String(limit), offset: String(offset) })
+ const response = await fetch(`/api/v1/source-catalog/snapshots?${query}`, {
+ headers: { Accept: 'application/json' },
+ signal,
+ })
+ if (!response.ok) throw new Error(`加载快照历史失败(HTTP ${response.status})`)
+ const value = (await response.json()) as unknown
+ if (
+ !record(value) ||
+ !Array.isArray(value.items) ||
+ !count(value.total) ||
+ !count(value.limit) ||
+ !count(value.offset)
+ )
+ throw new Error('快照历史响应无效')
+ const items = value.items.map((raw) => {
+ if (
+ !record(raw) ||
+ typeof raw.snapshot_id !== 'string' ||
+ typeof raw.connection_id !== 'string' ||
+ typeof raw.root_node_id !== 'string' ||
+ typeof raw.source_name !== 'string' ||
+ !count(raw.node_count) ||
+ !count(raw.folder_count) ||
+ !count(raw.file_count) ||
+ typeof raw.observed_at !== 'string' ||
+ typeof raw.created_at !== 'string' ||
+ typeof raw.is_head !== 'boolean'
+ )
+ throw new Error('快照历史条目无效')
+ return raw as unknown as CatalogSnapshot
+ })
+ return { items, total: value.total, limit: value.limit, offset: value.offset }
+}
+export async function fetchCatalogSnapshotNodes(
+ connectionID: string,
+ snapshotID: string,
+ parentID: string,
+ limit = 50,
+ offset = 0,
+ signal?: AbortSignal
+): Promise {
+ const query = new URLSearchParams({
+ connection_id: connectionID,
+ parent_id: parentID,
+ limit: String(limit),
+ offset: String(offset),
+ })
+ const response = await fetch(`/api/v1/source-catalog/snapshots/${encodeURIComponent(snapshotID)}/nodes?${query}`, {
+ headers: { Accept: 'application/json' },
+ signal,
+ })
+ if (!response.ok) throw new Error(`加载历史快照目录失败(HTTP ${response.status})`)
+ const value = (await response.json()) as unknown
+ if (
+ !record(value) ||
+ !Array.isArray(value.items) ||
+ !count(value.total) ||
+ !count(value.limit) ||
+ !count(value.offset)
+ )
+ throw new Error('历史快照目录响应无效')
+ return {
+ current: parseNode(value.current),
+ breadcrumbs: Array.isArray(value.breadcrumbs)
+ ? value.breadcrumbs.map((raw) => {
+ if (!record(raw) || typeof raw.node_id !== 'string' || typeof raw.name !== 'string')
+ throw new Error('目录路径响应无效')
+ return raw as unknown as CatalogBreadcrumb
+ })
+ : [],
+ items: value.items.map(parseNode),
+ total: value.total,
+ limit: value.limit,
+ offset: value.offset,
+ }
+}
+export interface ConfirmChangeSetResult {
+ change_set_id: string
+ confirmed_items: number
+ uploads_created: number
+ uploads_existing: number
+ removals_requested: number
+ skipped_no_artifact: number
+ skipped_no_target: number
+ no_change_ignored: number
+}
+export async function confirmCatalogChangeSet(
+ changeSetID: string,
+ itemSeqs: number[],
+ reason: string
+): Promise<{ result: ConfirmChangeSetResult; operation: string }> {
+ const response = await fetch(`/api/v1/source-catalog/change-sets/${encodeURIComponent(changeSetID)}/confirm`, {
+ method: 'POST',
+ headers: { Accept: 'application/json', 'Content-Type': 'application/json', 'Idempotency-Key': newOperationID() },
+ body: JSON.stringify({ item_seqs: itemSeqs, reason }),
+ })
+ if (!response.ok)
+ throw new Error(
+ response.status === 409 ? '操作标识与另一请求冲突,请刷新后重试' : `确认快照差异失败(HTTP ${response.status})`
+ )
+ const value = (await response.json()) as unknown
+ if (
+ !record(value) ||
+ !record(value.data) ||
+ !count(value.data.confirmed_items) ||
+ !count(value.data.uploads_created) ||
+ !count(value.data.uploads_existing) ||
+ !count(value.data.removals_requested) ||
+ !count(value.data.skipped_no_artifact) ||
+ !count(value.data.skipped_no_target) ||
+ !count(value.data.no_change_ignored) ||
+ typeof value.operation !== 'string'
+ )
+ throw new Error('确认快照差异响应无效')
+ return { result: value.data as unknown as ConfirmChangeSetResult, operation: value.operation }
+}
+export async function fetchCatalogNodes(
+ connectionID: string,
+ parentID: string,
+ limit = 50,
+ offset = 0,
+ signal?: AbortSignal
+): Promise {
+ const query = new URLSearchParams({
+ connection_id: connectionID,
+ parent_id: parentID,
+ limit: String(limit),
+ offset: String(offset),
+ })
+ const response = await fetch(`/api/v1/source-catalog/nodes?${query}`, {
+ headers: { Accept: 'application/json' },
+ signal,
+ })
+ if (!response.ok)
+ throw new Error(response.status === 404 ? '目录已更新,请返回来源首页' : `加载目录失败(HTTP ${response.status})`)
+ const value = (await response.json()) as unknown
+ if (
+ !record(value) ||
+ !Array.isArray(value.breadcrumbs) ||
+ !Array.isArray(value.items) ||
+ !count(value.total) ||
+ !count(value.limit) ||
+ !count(value.offset)
+ )
+ throw new Error('目录列表响应无效')
+ const breadcrumbs = value.breadcrumbs.map((raw) => {
+ if (!record(raw) || typeof raw.node_id !== 'string' || typeof raw.name !== 'string')
+ throw new Error('目录路径响应无效')
+ return raw as unknown as CatalogBreadcrumb
+ })
+ return {
+ current: parseNode(value.current),
+ breadcrumbs,
+ items: value.items.map(parseNode),
+ total: value.total,
+ limit: value.limit,
+ offset: value.offset,
}
- const next=page.offset+page.limit
- if(next>=page.total)return folders
- if(page.limit<=0||next<=offset)throw new Error('目录分页游标无效')
- offset=next
- }
}
-export interface CatalogRoutingCapabilities{internal_enabled:boolean;external_enabled:boolean}
-export interface CatalogRouteInput{connection_id:string;node_ids:string[];policy:CatalogPolicy;reason:string}
-export interface CatalogRouteResult{selected_files:number;intents_recorded:number;fetch_queued:number;planned_publications:number;blocked_items:number;unbound_files:number}
-
-export async function fetchCatalogRoutingCapabilities(signal?:AbortSignal):Promise{
- const response=await fetch('/api/v1/source-catalog/routing-capabilities',{headers:{Accept:'application/json'},signal});if(!response.ok)throw new Error(`加载分流配置失败(HTTP ${response.status})`)
- const value=await response.json() as unknown;if(!record(value)||!record(value.data)||typeof value.data.internal_enabled!=='boolean'||typeof value.data.external_enabled!=='boolean')throw new Error('分流配置响应无效')
- return value.data as unknown as CatalogRoutingCapabilities
-}
-export async function routeCatalogFiles(input:CatalogRouteInput):Promise{
- const response=await fetch('/api/v1/source-catalog/route',{method:'POST',headers:{Accept:'application/json','Content-Type':'application/json','Idempotency-Key':newOperationID()},body:JSON.stringify(input)});if(!response.ok)throw new Error(response.status===503?'对内/对外知识库尚未配置':`处理策略保存失败(HTTP ${response.status})`)
- const value=await response.json() as unknown;if(!record(value)||!record(value.data)||!count(value.data.selected_files)||!count(value.data.intents_recorded)||!count(value.data.fetch_queued)||!count(value.data.planned_publications)||!count(value.data.blocked_items)||!count(value.data.unbound_files))throw new Error('文件处理响应无效')
- return value.data as unknown as CatalogRouteResult
-}
-export async function triggerCatalogRefresh(connectionID:string):Promise{
- const response=await fetch(`/api/v1/source-catalog/sources/${encodeURIComponent(connectionID)}/refresh`,{method:'POST',headers:{Accept:'application/json','Idempotency-Key':newOperationID()}});if(!response.ok)throw new Error(`启动实时刷新失败(HTTP ${response.status})`)
- const value=await response.json() as unknown;if(!record(value)||!record(value.data)||typeof value.data.id!=='string'||value.data.type!=='source.catalog.refresh')throw new Error('实时刷新任务响应无效')
- return value.data as unknown as Job
+export async function fetchAllCatalogFolders(
+ connectionID: string,
+ parentID: string,
+ signal?: AbortSignal
+): Promise {
+ const folders: CatalogNode[] = []
+ const seen = new Set()
+ let offset = 0
+ while (true) {
+ const page = await fetchCatalogNodes(connectionID, parentID, 100, offset, signal)
+ for (const node of page.items) {
+ if (node.node_type === 'folder' && !seen.has(node.node_id)) {
+ seen.add(node.node_id)
+ folders.push(node)
+ }
+ }
+ const next = page.offset + page.limit
+ if (next >= page.total) return folders
+ if (page.limit <= 0 || next <= offset) throw new Error('目录分页游标无效')
+ offset = next
+ }
}
-export type CatalogChangeAction='add'|'update'|'move'|'move_scope'|'delete'|'no_change'
-export interface CatalogChangeItem{action:CatalogChangeAction;title:string;old_path:string;new_path:string;old_size_bytes?:number;new_size_bytes?:number;old_modified_at?:string;new_modified_at?:string;old_content_sha256?:string;new_content_sha256?:string;reason_code:string;seq:number;old_scope?:string;new_scope?:string;old_version_id?:string;new_version_id?:string}
-export interface CatalogChangeSetDetail{id:string;connection_id:string;source_name:string;state:'planned'|'confirmed'|'applying'|'applied'|'cancelled'|'failed';add_count:number;update_count:number;move_count:number;move_scope_count:number;delete_count:number;no_change_count:number;created_at:string;items:CatalogChangeItem[];total:number;limit:number;offset:number}
-const changeActions=new Set(['add','update','move','move_scope','delete','no_change'])
-export async function fetchLatestCatalogChangeSet(connectionID:string,action:CatalogChangeAction|'',limit=20,offset=0,signal?:AbortSignal):Promise{
- const query=new URLSearchParams({connection_id:connectionID});if(action)query.set('action',action);query.set('limit',String(limit));query.set('offset',String(offset))
- const response=await fetch(`/api/v1/source-catalog/change-sets/latest?${query}`,{headers:{Accept:'application/json'},signal})
- if(!response.ok)throw new Error(response.status===404?'当前来源尚无快照差异':`加载快照差异失败(HTTP ${response.status})`)
- const envelope=await response.json() as unknown;if(!record(envelope)||!record(envelope.data))throw new Error('快照差异响应无效')
- const raw=envelope.data
- if(typeof raw.id!=='string'||typeof raw.connection_id!=='string'||typeof raw.source_name!=='string'||typeof raw.state!=='string'||!['planned','confirmed','applying','applied','cancelled','failed'].includes(raw.state)||!count(raw.add_count)||!count(raw.update_count)||!count(raw.move_count)||!count(raw.move_scope_count)||!count(raw.delete_count)||!count(raw.no_change_count)||typeof raw.created_at!=='string'||!Array.isArray(raw.items)||!count(raw.total)||!count(raw.limit)||!count(raw.offset))throw new Error('快照差异响应无效')
- const items=raw.items.map(item=>{if(!record(item)||typeof item.action!=='string'||!changeActions.has(item.action as CatalogChangeAction)||typeof item.title!=='string'||item.old_path!==undefined&&typeof item.old_path!=='string'||item.new_path!==undefined&&typeof item.new_path!=='string'||item.old_content_sha256!==undefined&&typeof item.old_content_sha256!=='string'||item.new_content_sha256!==undefined&&typeof item.new_content_sha256!=='string'||item.old_scope!==undefined&&typeof item.old_scope!=='string'||item.new_scope!==undefined&&typeof item.new_scope!=='string'||item.old_version_id!==undefined&&typeof item.old_version_id!=='string'||item.new_version_id!==undefined&&typeof item.new_version_id!=='string'||typeof item.reason_code!=='string'||!count(item.seq))throw new Error('快照差异条目无效');return {action:item.action as CatalogChangeAction,title:item.title,old_path:typeof item.old_path==='string'?item.old_path:'',new_path:typeof item.new_path==='string'?item.new_path:'',...(typeof item.old_size_bytes==='number'?{old_size_bytes:item.old_size_bytes}:{}),...(typeof item.new_size_bytes==='number'?{new_size_bytes:item.new_size_bytes}:{}),...(typeof item.old_modified_at==='string'?{old_modified_at:item.old_modified_at}:{}),...(typeof item.new_modified_at==='string'?{new_modified_at:item.new_modified_at}:{}),...(typeof item.old_content_sha256==='string'&&item.old_content_sha256?{old_content_sha256:item.old_content_sha256}:{}),...(typeof item.new_content_sha256==='string'&&item.new_content_sha256?{new_content_sha256:item.new_content_sha256}:{}),...(typeof item.old_scope==='string'&&item.old_scope?{old_scope:item.old_scope}:{}),...(typeof item.new_scope==='string'&&item.new_scope?{new_scope:item.new_scope}:{}),...(typeof item.old_version_id==='string'&&item.old_version_id?{old_version_id:item.old_version_id}:{}),...(typeof item.new_version_id==='string'&&item.new_version_id?{new_version_id:item.new_version_id}:{}),reason_code:item.reason_code,seq:item.seq}})
- return {id:raw.id,connection_id:raw.connection_id,source_name:raw.source_name,state:raw.state as CatalogChangeSetDetail['state'],add_count:raw.add_count,update_count:raw.update_count,move_count:raw.move_count,move_scope_count:raw.move_scope_count,delete_count:raw.delete_count,no_change_count:raw.no_change_count,created_at:raw.created_at,items,total:raw.total,limit:raw.limit,offset:raw.offset}
+export interface CatalogRoutingCapabilities {
+ internal_enabled: boolean
+ external_enabled: boolean
+}
+export interface CatalogRouteInput {
+ connection_id: string
+ node_ids: string[]
+ policy: CatalogPolicy
+ reason: string
+}
+export interface CatalogRouteResult {
+ selected_files: number
+ intents_recorded: number
+ fetch_queued: number
+ planned_publications: number
+ blocked_items: number
+ unbound_files: number
+}
+
+export async function fetchCatalogRoutingCapabilities(signal?: AbortSignal): Promise {
+ const response = await fetch('/api/v1/source-catalog/routing-capabilities', {
+ headers: { Accept: 'application/json' },
+ signal,
+ })
+ if (!response.ok) throw new Error(`加载分流配置失败(HTTP ${response.status})`)
+ const value = (await response.json()) as unknown
+ if (
+ !record(value) ||
+ !record(value.data) ||
+ typeof value.data.internal_enabled !== 'boolean' ||
+ typeof value.data.external_enabled !== 'boolean'
+ )
+ throw new Error('分流配置响应无效')
+ return value.data as unknown as CatalogRoutingCapabilities
+}
+export async function routeCatalogFiles(input: CatalogRouteInput): Promise {
+ const response = await fetch('/api/v1/source-catalog/route', {
+ method: 'POST',
+ headers: { Accept: 'application/json', 'Content-Type': 'application/json', 'Idempotency-Key': newOperationID() },
+ body: JSON.stringify(input),
+ })
+ if (!response.ok)
+ throw new Error(response.status === 503 ? '对内/对外知识库尚未配置' : `处理策略保存失败(HTTP ${response.status})`)
+ const value = (await response.json()) as unknown
+ if (
+ !record(value) ||
+ !record(value.data) ||
+ !count(value.data.selected_files) ||
+ !count(value.data.intents_recorded) ||
+ !count(value.data.fetch_queued) ||
+ !count(value.data.planned_publications) ||
+ !count(value.data.blocked_items) ||
+ !count(value.data.unbound_files)
+ )
+ throw new Error('文件处理响应无效')
+ return value.data as unknown as CatalogRouteResult
+}
+export async function triggerCatalogRefresh(connectionID: string): Promise {
+ const response = await fetch(`/api/v1/source-catalog/sources/${encodeURIComponent(connectionID)}/refresh`, {
+ method: 'POST',
+ headers: { Accept: 'application/json', 'Idempotency-Key': newOperationID() },
+ })
+ if (!response.ok) throw new Error(`启动实时刷新失败(HTTP ${response.status})`)
+ const value = (await response.json()) as unknown
+ if (
+ !record(value) ||
+ !record(value.data) ||
+ typeof value.data.id !== 'string' ||
+ value.data.type !== 'source.catalog.refresh'
+ )
+ throw new Error('实时刷新任务响应无效')
+ return value.data as unknown as Job
+}
+
+export type CatalogChangeAction = 'add' | 'update' | 'move' | 'move_scope' | 'delete' | 'no_change'
+export interface CatalogChangeItem {
+ action: CatalogChangeAction
+ title: string
+ old_path: string
+ new_path: string
+ old_size_bytes?: number
+ new_size_bytes?: number
+ old_modified_at?: string
+ new_modified_at?: string
+ old_content_sha256?: string
+ new_content_sha256?: string
+ reason_code: string
+ seq: number
+ old_scope?: string
+ new_scope?: string
+ old_version_id?: string
+ new_version_id?: string
+}
+export interface CatalogChangeSetDetail {
+ id: string
+ connection_id: string
+ source_name: string
+ state: 'planned' | 'confirmed' | 'applying' | 'applied' | 'cancelled' | 'failed'
+ add_count: number
+ update_count: number
+ move_count: number
+ move_scope_count: number
+ delete_count: number
+ no_change_count: number
+ created_at: string
+ items: CatalogChangeItem[]
+ total: number
+ limit: number
+ offset: number
+}
+const changeActions = new Set(['add', 'update', 'move', 'move_scope', 'delete', 'no_change'])
+export async function fetchLatestCatalogChangeSet(
+ connectionID: string,
+ action: CatalogChangeAction | '',
+ limit = 20,
+ offset = 0,
+ signal?: AbortSignal
+): Promise {
+ const query = new URLSearchParams({ connection_id: connectionID })
+ if (action) query.set('action', action)
+ query.set('limit', String(limit))
+ query.set('offset', String(offset))
+ const response = await fetch(`/api/v1/source-catalog/change-sets/latest?${query}`, {
+ headers: { Accept: 'application/json' },
+ signal,
+ })
+ if (!response.ok)
+ throw new Error(response.status === 404 ? '当前来源尚无快照差异' : `加载快照差异失败(HTTP ${response.status})`)
+ const envelope = (await response.json()) as unknown
+ if (!record(envelope) || !record(envelope.data)) throw new Error('快照差异响应无效')
+ const raw = envelope.data
+ if (
+ typeof raw.id !== 'string' ||
+ typeof raw.connection_id !== 'string' ||
+ typeof raw.source_name !== 'string' ||
+ typeof raw.state !== 'string' ||
+ !['planned', 'confirmed', 'applying', 'applied', 'cancelled', 'failed'].includes(raw.state) ||
+ !count(raw.add_count) ||
+ !count(raw.update_count) ||
+ !count(raw.move_count) ||
+ !count(raw.move_scope_count) ||
+ !count(raw.delete_count) ||
+ !count(raw.no_change_count) ||
+ typeof raw.created_at !== 'string' ||
+ !Array.isArray(raw.items) ||
+ !count(raw.total) ||
+ !count(raw.limit) ||
+ !count(raw.offset)
+ )
+ throw new Error('快照差异响应无效')
+ const items = raw.items.map((item) => {
+ if (
+ !record(item) ||
+ typeof item.action !== 'string' ||
+ !changeActions.has(item.action as CatalogChangeAction) ||
+ typeof item.title !== 'string' ||
+ (item.old_path !== undefined && typeof item.old_path !== 'string') ||
+ (item.new_path !== undefined && typeof item.new_path !== 'string') ||
+ (item.old_content_sha256 !== undefined && typeof item.old_content_sha256 !== 'string') ||
+ (item.new_content_sha256 !== undefined && typeof item.new_content_sha256 !== 'string') ||
+ (item.old_scope !== undefined && typeof item.old_scope !== 'string') ||
+ (item.new_scope !== undefined && typeof item.new_scope !== 'string') ||
+ (item.old_version_id !== undefined && typeof item.old_version_id !== 'string') ||
+ (item.new_version_id !== undefined && typeof item.new_version_id !== 'string') ||
+ typeof item.reason_code !== 'string' ||
+ !count(item.seq)
+ )
+ throw new Error('快照差异条目无效')
+ return {
+ action: item.action as CatalogChangeAction,
+ title: item.title,
+ old_path: typeof item.old_path === 'string' ? item.old_path : '',
+ new_path: typeof item.new_path === 'string' ? item.new_path : '',
+ ...(typeof item.old_size_bytes === 'number' ? { old_size_bytes: item.old_size_bytes } : {}),
+ ...(typeof item.new_size_bytes === 'number' ? { new_size_bytes: item.new_size_bytes } : {}),
+ ...(typeof item.old_modified_at === 'string' ? { old_modified_at: item.old_modified_at } : {}),
+ ...(typeof item.new_modified_at === 'string' ? { new_modified_at: item.new_modified_at } : {}),
+ ...(typeof item.old_content_sha256 === 'string' && item.old_content_sha256
+ ? { old_content_sha256: item.old_content_sha256 }
+ : {}),
+ ...(typeof item.new_content_sha256 === 'string' && item.new_content_sha256
+ ? { new_content_sha256: item.new_content_sha256 }
+ : {}),
+ ...(typeof item.old_scope === 'string' && item.old_scope ? { old_scope: item.old_scope } : {}),
+ ...(typeof item.new_scope === 'string' && item.new_scope ? { new_scope: item.new_scope } : {}),
+ ...(typeof item.old_version_id === 'string' && item.old_version_id
+ ? { old_version_id: item.old_version_id }
+ : {}),
+ ...(typeof item.new_version_id === 'string' && item.new_version_id
+ ? { new_version_id: item.new_version_id }
+ : {}),
+ reason_code: item.reason_code,
+ seq: item.seq,
+ }
+ })
+ return {
+ id: raw.id,
+ connection_id: raw.connection_id,
+ source_name: raw.source_name,
+ state: raw.state as CatalogChangeSetDetail['state'],
+ add_count: raw.add_count,
+ update_count: raw.update_count,
+ move_count: raw.move_count,
+ move_scope_count: raw.move_scope_count,
+ delete_count: raw.delete_count,
+ no_change_count: raw.no_change_count,
+ created_at: raw.created_at,
+ items,
+ total: raw.total,
+ limit: raw.limit,
+ offset: raw.offset,
+ }
}
diff --git a/web/src/sourceCatalogBrowser.css b/web/src/sourceCatalogBrowser.css
index 25fd460..54a6bc4 100644
--- a/web/src/sourceCatalogBrowser.css
+++ b/web/src/sourceCatalogBrowser.css
@@ -1,42 +1,835 @@
-.catalog-format-filter{border-bottom:1px solid var(--border-base)}.catalog-format-filter>summary{cursor:pointer;padding:12px 16px;color:var(--ink-mute);font-size:12px}.catalog-format-filter>summary:focus-visible{outline:2px solid var(--c-run);outline-offset:-2px}
-.catalog-browser{max-width:1800px}.catalog-layout{display:grid;grid-template-columns:285px minmax(0,1fr);gap:18px;align-items:start}.catalog-sources,.catalog-content{min-width:0;border:1px solid var(--border-base);border-radius:14px;background:var(--bg-panel)}.catalog-sources{max-height:calc(100vh - 220px);overflow:auto;padding:12px}.catalog-sources h2{margin:4px 8px 12px;color:var(--ink-base);font-size:13px}.catalog-source{display:flex;width:100%;align-items:center;justify-content:space-between;gap:10px;margin:4px 0;border:1px solid transparent;border-radius:9px;padding:11px;background:transparent;box-shadow:none;color:var(--ink-base);text-align:left}.catalog-source:hover{background:var(--bg-soft)}.catalog-source.active{border-color:var(--c-run-border);background:var(--c-run-bg);color:var(--c-run)}.catalog-source>span{display:grid;min-width:0;gap:5px}.catalog-source b{overflow-wrap:anywhere;font-size:12px}.catalog-source small{color:var(--ink-mute);font-size:10px}.catalog-source strong{flex:0 0 auto;font-size:12px}.catalog-directory-tree{margin-top:14px;border-top:1px solid var(--border-base);padding-top:12px}.catalog-directory-tree h3{margin:0 8px 8px;color:var(--ink-base);font-size:11px}.catalog-directory-tree ul{display:grid;gap:2px;margin:0;padding:0;list-style:none}.catalog-directory-tree ul[role="group"]{margin-left:16px;border-left:1px solid var(--border-base);padding-left:5px}.catalog-tree-row{display:grid;grid-template-columns:24px minmax(0,1fr);align-items:center;gap:2px}.catalog-tree-toggle,.catalog-tree-name{border:0;background:transparent;box-shadow:none;color:var(--ink-mute)}.catalog-tree-toggle{display:grid;width:24px;height:24px;place-items:center;padding:0;border-radius:5px;font-weight:800}.catalog-tree-toggle:hover{background:#e9f4fb}.catalog-tree-name{overflow:hidden;padding:5px 4px;text-align:left;text-overflow:ellipsis;white-space:nowrap}.catalog-tree-name:hover{color:var(--c-run);text-decoration:underline}.catalog-breadcrumbs{display:flex;min-height:48px;align-items:center;gap:5px;overflow-x:auto;border-bottom:1px solid var(--border-base);padding:8px 16px;white-space:nowrap}.catalog-breadcrumbs span{display:flex;align-items:center;gap:5px}.catalog-breadcrumbs i{color:var(--ink-faint);font-style:normal}.catalog-breadcrumbs button{max-width:320px;overflow:hidden;border:0;padding:6px 8px;background:transparent;box-shadow:none;color:var(--c-run);text-overflow:ellipsis;white-space:nowrap}.catalog-breadcrumbs span:last-child button{border-radius:6px;background:#eaf4fc;color:var(--c-run);font-weight:700}.catalog-summary{display:flex;align-items:center;gap:20px;border-bottom:1px solid var(--border-base);padding:14px 18px;color:var(--ink-mute);font-size:12px}.catalog-summary>div{display:grid;min-width:220px;margin-right:auto;gap:4px}.catalog-summary strong{color:var(--ink-strong);font-size:16px}.catalog-summary small{color:var(--ink-mute);font-size:10px}.catalog-table-wrap{max-height:calc(100vh - 390px);overflow:auto}
-.root-file-notice{display:flex;align-items:center;justify-content:space-between;gap:12px;border-bottom:1px solid var(--border-base);padding:9px 18px;background:var(--c-warn-bg);color:var(--c-warn);font-size:11px}.root-file-notice button{flex:0 0 auto;border:1px solid var(--c-warn-border);padding:6px 9px;background:var(--bg-panel);box-shadow:none;color:var(--c-warn)}.catalog-table .scope-badge{white-space:nowrap}
-.catalog-back-row{border-bottom:1px solid var(--border-base);padding:8px 16px}.catalog-back-row button{border:0;padding:6px 8px;background:transparent;box-shadow:none;color:var(--c-run)}.snapshot-timestamp{padding:10px 16px;color:var(--ink-mute);font-size:10px;text-align:right}
-.catalog-routing{display:grid;gap:9px;border-bottom:1px solid var(--border-base);padding:11px 16px;background:var(--bg-soft)}.routing-fields,.routing-actions{display:flex;flex-wrap:wrap;align-items:end;gap:9px}.routing-fields label{display:grid;gap:4px;color:var(--ink-mute);font-size:9px}.routing-fields input{border:1px solid var(--border-strong);border-radius:6px;padding:7px}.routing-check{display:flex!important;align-items:center;gap:5px;padding-bottom:7px}.routing-actions span{margin-right:auto;color:var(--ink-mute);font-size:10px}.routing-actions button{padding:7px 9px}.catalog-routing p{margin:0;border-radius:6px;padding:7px;background:var(--c-done-bg);color:var(--c-done);font-size:10px}.catalog-file-routing{display:grid;gap:6px}.catalog-file-routing .catalog-file{align-items:center}.catalog-file-routing select{width:max-content;max-width:100%;border:1px solid var(--border-strong);border-radius:6px;padding:5px;background:var(--bg-panel);color:var(--ink-base);font-size:9px}.catalog-file-routing small{color:var(--c-warn);font-size:9px}
-.catalog-refresh-progress{display:grid;grid-template-columns:minmax(220px,1fr) minmax(180px,2fr) auto;align-items:center;gap:12px;margin-bottom:14px;border:1px solid var(--c-run-border);border-radius:10px;padding:10px 14px;background:var(--c-run-bg)}.catalog-refresh-progress>div{display:grid;gap:3px}.catalog-refresh-progress strong{color:var(--c-run);font-size:11px}.catalog-refresh-progress span,.catalog-refresh-progress small{color:var(--ink-mute);font-size:9px}.catalog-refresh-progress progress{width:100%;height:12px}.catalog-refresh-progress.completed{border-color:var(--c-done-border);background:var(--c-done-bg)}.catalog-refresh-progress.failed{border-color:var(--c-fail-border);background:var(--c-fail-bg)}@media(max-width:700px){.catalog-refresh-progress{grid-template-columns:1fr}.routing-fields{align-items:stretch;flex-direction:column}}
-.routing-actions button:disabled,.catalog-file-routing select:disabled{cursor:not-allowed;border-color:var(--border-base);background:var(--bg-soft);color:var(--ink-faint);box-shadow:none}.snapshot-timestamp{color:var(--ink-base);font-size:11px;font-weight:600}.catalog-table tbody tr{scroll-margin-top:42px}.catalog-table{width:100%;table-layout:fixed}.catalog-table thead th{position:sticky;z-index:2;top:0;background:var(--bg-soft)}.catalog-table th,.catalog-table td{padding:12px 10px;white-space:normal;overflow-wrap:anywhere;font-size:11px}.catalog-table th:nth-child(1){width:32%}.catalog-table th:nth-child(2){width:8%}.catalog-table th:nth-child(3){width:15%}.catalog-table th:nth-child(4){width:9%}.catalog-table th:nth-child(5){width:16%}.catalog-table th:nth-child(6){width:20%}.catalog-name{display:flex;align-items:flex-start;gap:7px;border:0;padding:0;background:transparent;box-shadow:none;color:var(--c-run);text-align:left;line-height:1.5}.catalog-name:hover{text-decoration:underline}.catalog-file{display:flex;align-items:flex-start;gap:7px;color:var(--ink-base);line-height:1.5}.catalog-table td>span:not(.scope-badge){display:block}.catalog-table td small{display:block;margin-top:4px;color:var(--ink-mute)}.problem-text{color:var(--c-warn)!important}.scope-badge.mixed{background:var(--c-run-bg);color:var(--c-run)}.scope-badge.unknown{background:var(--bg-soft);color:var(--ink-mute)}@media(max-width:1100px){.catalog-layout{grid-template-columns:220px minmax(0,1fr)}.catalog-summary{flex-wrap:wrap}.catalog-summary>div{width:100%;margin:0}.catalog-table{min-width:800px}}@media(max-width:800px){.catalog-layout{grid-template-columns:1fr}.catalog-sources{max-height:240px}.catalog-table-wrap{max-height:520px}}
-.routing-actions label{display:flex;align-items:center;gap:7px;color:var(--ink-mute);font-size:10px;font-weight:700}.routing-actions select,.catalog-folder-routing select{border:1px solid var(--border-strong);border-radius:7px;padding:7px 9px;background:var(--bg-panel);color:var(--ink-base);font:inherit}.blocked-policy-note{color:var(--c-warn);font-size:10px;line-height:1.5}.catalog-folder-routing{display:grid;gap:7px}.catalog-folder-routing select{width:max-content;max-width:100%;font-size:9px}.catalog-folder-routing small{color:var(--ink-mute)!important;font-size:9px}.scope-badge.both{background:var(--c-run-bg);color:var(--c-run)}.scope-badge.blocked{background:var(--c-fail-bg);color:var(--c-fail)}
-.catalog-pathbar{display:flex;align-items:center;gap:10px;min-height:48px;border-bottom:1px solid var(--border-base);padding:8px 16px;background:var(--bg-panel)}.catalog-back-button{display:grid;flex:0 0 auto;width:30px;height:30px;place-items:center;border:1px solid var(--border-strong);border-radius:7px;padding:0;background:var(--bg-panel);color:var(--c-run);box-shadow:none;font-size:18px}.catalog-back-button:hover:not(:disabled){background:var(--c-run-bg)}.catalog-pathbar nav{display:flex;min-width:0;align-items:center;gap:4px;overflow-x:auto;white-space:nowrap}.catalog-pathbar nav span{display:flex;min-width:0;align-items:center;gap:4px}.catalog-pathbar nav i{color:var(--ink-faint);font-style:normal}.catalog-pathbar nav button{overflow:hidden;border:0;border-radius:6px;padding:6px 7px;background:transparent;color:var(--c-run);box-shadow:none;text-overflow:ellipsis;white-space:nowrap}.catalog-pathbar nav button:hover:not(:disabled){background:var(--c-run-bg)}.catalog-pathbar nav button.current{color:var(--ink-base);font-weight:700}.catalog-pathbar nav button:disabled{cursor:default;background:transparent;color:var(--ink-base)}
-.catalog-policy-cell{display:grid;gap:6px;justify-items:start}.catalog-policy-cell select{max-width:100%;border:1px solid var(--border-strong);border-radius:7px;padding:6px 8px;background:var(--bg-panel);color:var(--ink-base);font-size:10px}.catalog-policy-cell small{min-height:14px;color:var(--ink-mute);font-size:9px}.catalog-table th:nth-child(1){width:32%}.catalog-table th:nth-child(2){width:8%}.catalog-table th:nth-child(3){width:15%}.catalog-table th:nth-child(4){width:9%}.catalog-table th:nth-child(5){width:16%}.catalog-table th:nth-child(6){width:20%}
-.routing-actions .blocked-policy-button{border-color:var(--c-fail-border);background:var(--c-fail-bg);color:var(--c-fail)}.routing-actions .blocked-policy-button:hover:not(:disabled){background:var(--c-fail-bg)}
-.catalog-node-control{display:flex;align-items:flex-start;gap:7px}.catalog-node-control input{margin-top:4px;flex:0 0 auto}
-.routing-actions .internal-policy-button{border-color:var(--c-done);background:var(--c-done);color:var(--bg-panel)}.routing-actions .internal-policy-button:hover:not(:disabled){background:var(--c-done)}.routing-actions .external-policy-button{border-color:var(--c-warn);background:var(--c-warn);color:var(--bg-panel)}.routing-actions .external-policy-button:hover:not(:disabled){background:var(--c-warn)}.routing-actions .blocked-policy-button{border-color:var(--c-fail);background:var(--c-fail);color:var(--bg-panel)}.routing-actions .blocked-policy-button:hover:not(:disabled){background:var(--c-fail)}
-.catalog-policy-cell .scope-badge.internal{background:var(--c-done-bg);color:var(--c-done)}.catalog-policy-cell .scope-badge.external{background:var(--c-warn-bg);color:var(--c-warn)}.catalog-policy-cell .scope-badge.blocked{background:var(--c-fail-bg);color:var(--c-fail)}
-.catalog-policy-actions{display:flex;align-items:flex-start;gap:8px;margin-left:auto}.blocked-policy-stack{display:grid;gap:4px}.blocked-policy-stack .selected-count{margin:0;color:var(--ink-mute);font-size:10px;line-height:1.2;text-align:center;white-space:nowrap}
-.snapshot-history{margin:12px 0;border:1px solid #d8e3f0;border-radius:10px;padding:14px 16px;background:var(--bg-soft)}
-.snapshot-history header{display:flex;align-items:center;gap:12px;flex-wrap:wrap}
-.snapshot-history header h2{margin:0;font-size:16px}
-.snapshot-history header p{margin:0;flex:1;color:var(--ink-mute);font-size:13px}
-.snapshot-history-list{list-style:none;margin:10px 0 0;padding:0;display:flex;flex-direction:column;gap:6px}
-.snapshot-history-list li{display:flex;align-items:center;gap:12px;padding:8px 10px;border:1px solid var(--border-base);border-radius:8px;background:var(--bg-panel)}
-.snapshot-history-list li>div{flex:1;display:flex;align-items:center;gap:8px;flex-wrap:wrap}
-.snapshot-history-list small{width:100%;color:var(--ink-mute)}
-.snapshot-history-table{margin-top:12px}
-.catalog-history-button{margin-left:auto}
-.catalog-refresh-detail{display:flex;gap:16px;flex-wrap:wrap;margin-top:6px;color:var(--ink-base);font-size:11px}
-.catalog-refresh-pill{display:flex;align-items:center;gap:8px;width:max-content;margin:8px 0;padding:7px 12px;border:1px solid var(--c-run-border);border-radius:999px;background:#eef6fc;color:var(--ink-base);font:inherit;font-size:11px;font-weight:700;box-shadow:none}
-.catalog-refresh-pill:hover{background:var(--c-run-bg)}
-.pill-dot{width:8px;height:8px;border-radius:50%;background:var(--c-run);display:inline-block}
-.pill-dot.retry{background:var(--c-warn)}
-.catalog-refresh-hide{margin-left:auto;border:0;background:transparent;color:#4c7d9e;font:inherit;font-size:11px;text-decoration:underline;padding:0 2px}
-.catalog-refresh-failed-note{margin:6px 0 0;color:var(--c-fail);font-size:11px}
+.catalog-format-filter {
+ border-bottom: 1px solid var(--border-base);
+}
+.catalog-format-filter > summary {
+ cursor: pointer;
+ padding: 12px 16px;
+ color: var(--ink-mute);
+ font-size: 12px;
+}
+.catalog-format-filter > summary:focus-visible {
+ outline: 2px solid var(--c-run);
+ outline-offset: -2px;
+}
+.catalog-browser {
+ max-width: 1800px;
+}
+.catalog-layout {
+ display: grid;
+ grid-template-columns: 285px minmax(0, 1fr);
+ gap: 18px;
+ align-items: start;
+}
+.catalog-sources,
+.catalog-content {
+ min-width: 0;
+ border: 1px solid var(--border-base);
+ border-radius: 14px;
+ background: var(--bg-panel);
+}
+.catalog-sources {
+ max-height: calc(100vh - 220px);
+ overflow: auto;
+ padding: 12px;
+}
+.catalog-sources h2 {
+ margin: 4px 8px 12px;
+ color: var(--ink-base);
+ font-size: 13px;
+}
+.catalog-source {
+ display: flex;
+ width: 100%;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+ margin: 4px 0;
+ border: 1px solid transparent;
+ border-radius: 9px;
+ padding: 11px;
+ background: transparent;
+ box-shadow: none;
+ color: var(--ink-base);
+ text-align: left;
+}
+.catalog-source:hover {
+ background: var(--bg-soft);
+}
+.catalog-source.active {
+ border-color: var(--c-run-border);
+ background: var(--c-run-bg);
+ color: var(--c-run);
+}
+.catalog-source > span {
+ display: grid;
+ min-width: 0;
+ gap: 5px;
+}
+.catalog-source b {
+ overflow-wrap: anywhere;
+ font-size: 12px;
+}
+.catalog-source small {
+ color: var(--ink-mute);
+ font-size: 10px;
+}
+.catalog-source strong {
+ flex: 0 0 auto;
+ font-size: 12px;
+}
+.catalog-directory-tree {
+ margin-top: 14px;
+ border-top: 1px solid var(--border-base);
+ padding-top: 12px;
+}
+.catalog-directory-tree h3 {
+ margin: 0 8px 8px;
+ color: var(--ink-base);
+ font-size: 11px;
+}
+.catalog-directory-tree ul {
+ display: grid;
+ gap: 2px;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+.catalog-directory-tree ul[role='group'] {
+ margin-left: 16px;
+ border-left: 1px solid var(--border-base);
+ padding-left: 5px;
+}
+.catalog-tree-row {
+ display: grid;
+ grid-template-columns: 24px minmax(0, 1fr);
+ align-items: center;
+ gap: 2px;
+}
+.catalog-tree-toggle,
+.catalog-tree-name {
+ border: 0;
+ background: transparent;
+ box-shadow: none;
+ color: var(--ink-mute);
+}
+.catalog-tree-toggle {
+ display: grid;
+ width: 24px;
+ height: 24px;
+ place-items: center;
+ padding: 0;
+ border-radius: 5px;
+ font-weight: 800;
+}
+.catalog-tree-toggle:hover {
+ background: #e9f4fb;
+}
+.catalog-tree-name {
+ overflow: hidden;
+ padding: 5px 4px;
+ text-align: left;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.catalog-tree-name:hover {
+ color: var(--c-run);
+ text-decoration: underline;
+}
+.catalog-breadcrumbs {
+ display: flex;
+ min-height: 48px;
+ align-items: center;
+ gap: 5px;
+ overflow-x: auto;
+ border-bottom: 1px solid var(--border-base);
+ padding: 8px 16px;
+ white-space: nowrap;
+}
+.catalog-breadcrumbs span {
+ display: flex;
+ align-items: center;
+ gap: 5px;
+}
+.catalog-breadcrumbs i {
+ color: var(--ink-faint);
+ font-style: normal;
+}
+.catalog-breadcrumbs button {
+ max-width: 320px;
+ overflow: hidden;
+ border: 0;
+ padding: 6px 8px;
+ background: transparent;
+ box-shadow: none;
+ color: var(--c-run);
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.catalog-breadcrumbs span:last-child button {
+ border-radius: 6px;
+ background: #eaf4fc;
+ color: var(--c-run);
+ font-weight: 700;
+}
+.catalog-summary {
+ display: flex;
+ align-items: center;
+ gap: 20px;
+ border-bottom: 1px solid var(--border-base);
+ padding: 14px 18px;
+ color: var(--ink-mute);
+ font-size: 12px;
+}
+.catalog-summary > div {
+ display: grid;
+ min-width: 220px;
+ margin-right: auto;
+ gap: 4px;
+}
+.catalog-summary strong {
+ color: var(--ink-strong);
+ font-size: 16px;
+}
+.catalog-summary small {
+ color: var(--ink-mute);
+ font-size: 10px;
+}
+.catalog-table-wrap {
+ max-height: calc(100vh - 390px);
+ overflow: auto;
+}
+.root-file-notice {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ border-bottom: 1px solid var(--border-base);
+ padding: 9px 18px;
+ background: var(--c-warn-bg);
+ color: var(--c-warn);
+ font-size: 11px;
+}
+.root-file-notice button {
+ flex: 0 0 auto;
+ border: 1px solid var(--c-warn-border);
+ padding: 6px 9px;
+ background: var(--bg-panel);
+ box-shadow: none;
+ color: var(--c-warn);
+}
+.catalog-table .scope-badge {
+ white-space: nowrap;
+}
+.catalog-back-row {
+ border-bottom: 1px solid var(--border-base);
+ padding: 8px 16px;
+}
+.catalog-back-row button {
+ border: 0;
+ padding: 6px 8px;
+ background: transparent;
+ box-shadow: none;
+ color: var(--c-run);
+}
+.snapshot-timestamp {
+ padding: 10px 16px;
+ color: var(--ink-mute);
+ font-size: 10px;
+ text-align: right;
+}
+.catalog-routing {
+ display: grid;
+ gap: 9px;
+ border-bottom: 1px solid var(--border-base);
+ padding: 11px 16px;
+ background: var(--bg-soft);
+}
+.routing-fields,
+.routing-actions {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: end;
+ gap: 9px;
+}
+.routing-fields label {
+ display: grid;
+ gap: 4px;
+ color: var(--ink-mute);
+ font-size: 9px;
+}
+.routing-fields input {
+ border: 1px solid var(--border-strong);
+ border-radius: 6px;
+ padding: 7px;
+}
+.routing-check {
+ display: flex !important;
+ align-items: center;
+ gap: 5px;
+ padding-bottom: 7px;
+}
+.routing-actions span {
+ margin-right: auto;
+ color: var(--ink-mute);
+ font-size: 10px;
+}
+.routing-actions button {
+ padding: 7px 9px;
+}
+.catalog-routing p {
+ margin: 0;
+ border-radius: 6px;
+ padding: 7px;
+ background: var(--c-done-bg);
+ color: var(--c-done);
+ font-size: 10px;
+}
+.catalog-file-routing {
+ display: grid;
+ gap: 6px;
+}
+.catalog-file-routing .catalog-file {
+ align-items: center;
+}
+.catalog-file-routing select {
+ width: max-content;
+ max-width: 100%;
+ border: 1px solid var(--border-strong);
+ border-radius: 6px;
+ padding: 5px;
+ background: var(--bg-panel);
+ color: var(--ink-base);
+ font-size: 9px;
+}
+.catalog-file-routing small {
+ color: var(--c-warn);
+ font-size: 9px;
+}
+.catalog-refresh-progress {
+ display: grid;
+ grid-template-columns: minmax(220px, 1fr) minmax(180px, 2fr) auto;
+ align-items: center;
+ gap: 12px;
+ margin-bottom: 14px;
+ border: 1px solid var(--c-run-border);
+ border-radius: 10px;
+ padding: 10px 14px;
+ background: var(--c-run-bg);
+}
+.catalog-refresh-progress > div {
+ display: grid;
+ gap: 3px;
+}
+.catalog-refresh-progress strong {
+ color: var(--c-run);
+ font-size: 11px;
+}
+.catalog-refresh-progress span,
+.catalog-refresh-progress small {
+ color: var(--ink-mute);
+ font-size: 9px;
+}
+.catalog-refresh-progress progress {
+ width: 100%;
+ height: 12px;
+}
+.catalog-refresh-progress.completed {
+ border-color: var(--c-done-border);
+ background: var(--c-done-bg);
+}
+.catalog-refresh-progress.failed {
+ border-color: var(--c-fail-border);
+ background: var(--c-fail-bg);
+}
+@media (max-width: 700px) {
+ .catalog-refresh-progress {
+ grid-template-columns: 1fr;
+ }
+ .routing-fields {
+ align-items: stretch;
+ flex-direction: column;
+ }
+}
+.routing-actions button:disabled,
+.catalog-file-routing select:disabled {
+ cursor: not-allowed;
+ border-color: var(--border-base);
+ background: var(--bg-soft);
+ color: var(--ink-faint);
+ box-shadow: none;
+}
+.snapshot-timestamp {
+ color: var(--ink-base);
+ font-size: 11px;
+ font-weight: 600;
+}
+.catalog-table tbody tr {
+ scroll-margin-top: 42px;
+}
+.catalog-table {
+ width: 100%;
+ table-layout: fixed;
+}
+.catalog-table thead th {
+ position: sticky;
+ z-index: 2;
+ top: 0;
+ background: var(--bg-soft);
+}
+.catalog-table th,
+.catalog-table td {
+ padding: 12px 10px;
+ white-space: normal;
+ overflow-wrap: anywhere;
+ font-size: 11px;
+}
+.catalog-table th:nth-child(1) {
+ width: 32%;
+}
+.catalog-table th:nth-child(2) {
+ width: 8%;
+}
+.catalog-table th:nth-child(3) {
+ width: 15%;
+}
+.catalog-table th:nth-child(4) {
+ width: 9%;
+}
+.catalog-table th:nth-child(5) {
+ width: 16%;
+}
+.catalog-table th:nth-child(6) {
+ width: 20%;
+}
+.catalog-name {
+ display: flex;
+ align-items: flex-start;
+ gap: 7px;
+ border: 0;
+ padding: 0;
+ background: transparent;
+ box-shadow: none;
+ color: var(--c-run);
+ text-align: left;
+ line-height: 1.5;
+}
+.catalog-name:hover {
+ text-decoration: underline;
+}
+.catalog-file {
+ display: flex;
+ align-items: flex-start;
+ gap: 7px;
+ color: var(--ink-base);
+ line-height: 1.5;
+}
+.catalog-table td > span:not(.scope-badge) {
+ display: block;
+}
+.catalog-table td small {
+ display: block;
+ margin-top: 4px;
+ color: var(--ink-mute);
+}
+.problem-text {
+ color: var(--c-warn) !important;
+}
+.scope-badge.mixed {
+ background: var(--c-run-bg);
+ color: var(--c-run);
+}
+.scope-badge.unknown {
+ background: var(--bg-soft);
+ color: var(--ink-mute);
+}
+@media (max-width: 1100px) {
+ .catalog-layout {
+ grid-template-columns: 220px minmax(0, 1fr);
+ }
+ .catalog-summary {
+ flex-wrap: wrap;
+ }
+ .catalog-summary > div {
+ width: 100%;
+ margin: 0;
+ }
+ .catalog-table {
+ min-width: 800px;
+ }
+}
+@media (max-width: 800px) {
+ .catalog-layout {
+ grid-template-columns: 1fr;
+ }
+ .catalog-sources {
+ max-height: 240px;
+ }
+ .catalog-table-wrap {
+ max-height: 520px;
+ }
+}
+.routing-actions label {
+ display: flex;
+ align-items: center;
+ gap: 7px;
+ color: var(--ink-mute);
+ font-size: 10px;
+ font-weight: 700;
+}
+.routing-actions select,
+.catalog-folder-routing select {
+ border: 1px solid var(--border-strong);
+ border-radius: 7px;
+ padding: 7px 9px;
+ background: var(--bg-panel);
+ color: var(--ink-base);
+ font: inherit;
+}
+.blocked-policy-note {
+ color: var(--c-warn);
+ font-size: 10px;
+ line-height: 1.5;
+}
+.catalog-folder-routing {
+ display: grid;
+ gap: 7px;
+}
+.catalog-folder-routing select {
+ width: max-content;
+ max-width: 100%;
+ font-size: 9px;
+}
+.catalog-folder-routing small {
+ color: var(--ink-mute) !important;
+ font-size: 9px;
+}
+.scope-badge.both {
+ background: var(--c-run-bg);
+ color: var(--c-run);
+}
+.scope-badge.blocked {
+ background: var(--c-fail-bg);
+ color: var(--c-fail);
+}
+.catalog-pathbar {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ min-height: 48px;
+ border-bottom: 1px solid var(--border-base);
+ padding: 8px 16px;
+ background: var(--bg-panel);
+}
+.catalog-back-button {
+ display: grid;
+ flex: 0 0 auto;
+ width: 30px;
+ height: 30px;
+ place-items: center;
+ border: 1px solid var(--border-strong);
+ border-radius: 7px;
+ padding: 0;
+ background: var(--bg-panel);
+ color: var(--c-run);
+ box-shadow: none;
+ font-size: 18px;
+}
+.catalog-back-button:hover:not(:disabled) {
+ background: var(--c-run-bg);
+}
+.catalog-pathbar nav {
+ display: flex;
+ min-width: 0;
+ align-items: center;
+ gap: 4px;
+ overflow-x: auto;
+ white-space: nowrap;
+}
+.catalog-pathbar nav span {
+ display: flex;
+ min-width: 0;
+ align-items: center;
+ gap: 4px;
+}
+.catalog-pathbar nav i {
+ color: var(--ink-faint);
+ font-style: normal;
+}
+.catalog-pathbar nav button {
+ overflow: hidden;
+ border: 0;
+ border-radius: 6px;
+ padding: 6px 7px;
+ background: transparent;
+ color: var(--c-run);
+ box-shadow: none;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.catalog-pathbar nav button:hover:not(:disabled) {
+ background: var(--c-run-bg);
+}
+.catalog-pathbar nav button.current {
+ color: var(--ink-base);
+ font-weight: 700;
+}
+.catalog-pathbar nav button:disabled {
+ cursor: default;
+ background: transparent;
+ color: var(--ink-base);
+}
+.catalog-policy-cell {
+ display: grid;
+ gap: 6px;
+ justify-items: start;
+}
+.catalog-policy-cell select {
+ max-width: 100%;
+ border: 1px solid var(--border-strong);
+ border-radius: 7px;
+ padding: 6px 8px;
+ background: var(--bg-panel);
+ color: var(--ink-base);
+ font-size: 10px;
+}
+.catalog-policy-cell small {
+ min-height: 14px;
+ color: var(--ink-mute);
+ font-size: 9px;
+}
+.catalog-table th:nth-child(1) {
+ width: 32%;
+}
+.catalog-table th:nth-child(2) {
+ width: 8%;
+}
+.catalog-table th:nth-child(3) {
+ width: 15%;
+}
+.catalog-table th:nth-child(4) {
+ width: 9%;
+}
+.catalog-table th:nth-child(5) {
+ width: 16%;
+}
+.catalog-table th:nth-child(6) {
+ width: 20%;
+}
+.routing-actions .blocked-policy-button {
+ border-color: var(--c-fail-border);
+ background: var(--c-fail-bg);
+ color: var(--c-fail);
+}
+.routing-actions .blocked-policy-button:hover:not(:disabled) {
+ background: var(--c-fail-bg);
+}
+.catalog-node-control {
+ display: flex;
+ align-items: flex-start;
+ gap: 7px;
+}
+.catalog-node-control input {
+ margin-top: 4px;
+ flex: 0 0 auto;
+}
+.routing-actions .internal-policy-button {
+ border-color: var(--c-done);
+ background: var(--c-done);
+ color: var(--bg-panel);
+}
+.routing-actions .internal-policy-button:hover:not(:disabled) {
+ background: var(--c-done);
+}
+.routing-actions .external-policy-button {
+ border-color: var(--c-warn);
+ background: var(--c-warn);
+ color: var(--bg-panel);
+}
+.routing-actions .external-policy-button:hover:not(:disabled) {
+ background: var(--c-warn);
+}
+.routing-actions .blocked-policy-button {
+ border-color: var(--c-fail);
+ background: var(--c-fail);
+ color: var(--bg-panel);
+}
+.routing-actions .blocked-policy-button:hover:not(:disabled) {
+ background: var(--c-fail);
+}
+.catalog-policy-cell .scope-badge.internal {
+ background: var(--c-done-bg);
+ color: var(--c-done);
+}
+.catalog-policy-cell .scope-badge.external {
+ background: var(--c-warn-bg);
+ color: var(--c-warn);
+}
+.catalog-policy-cell .scope-badge.blocked {
+ background: var(--c-fail-bg);
+ color: var(--c-fail);
+}
+.catalog-policy-actions {
+ display: flex;
+ align-items: flex-start;
+ gap: 8px;
+ margin-left: auto;
+}
+.blocked-policy-stack {
+ display: grid;
+ gap: 4px;
+}
+.blocked-policy-stack .selected-count {
+ margin: 0;
+ color: var(--ink-mute);
+ font-size: 10px;
+ line-height: 1.2;
+ text-align: center;
+ white-space: nowrap;
+}
+.snapshot-history {
+ margin: 12px 0;
+ border: 1px solid #d8e3f0;
+ border-radius: 10px;
+ padding: 14px 16px;
+ background: var(--bg-soft);
+}
+.snapshot-history header {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ flex-wrap: wrap;
+}
+.snapshot-history header h2 {
+ margin: 0;
+ font-size: 16px;
+}
+.snapshot-history header p {
+ margin: 0;
+ flex: 1;
+ color: var(--ink-mute);
+ font-size: 13px;
+}
+.snapshot-history-list {
+ list-style: none;
+ margin: 10px 0 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+.snapshot-history-list li {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 8px 10px;
+ border: 1px solid var(--border-base);
+ border-radius: 8px;
+ background: var(--bg-panel);
+}
+.snapshot-history-list li > div {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex-wrap: wrap;
+}
+.snapshot-history-list small {
+ width: 100%;
+ color: var(--ink-mute);
+}
+.snapshot-history-table {
+ margin-top: 12px;
+}
+.catalog-history-button {
+ margin-left: auto;
+}
+.catalog-refresh-detail {
+ display: flex;
+ gap: 16px;
+ flex-wrap: wrap;
+ margin-top: 6px;
+ color: var(--ink-base);
+ font-size: 11px;
+}
+.catalog-refresh-pill {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ width: max-content;
+ margin: 8px 0;
+ padding: 7px 12px;
+ border: 1px solid var(--c-run-border);
+ border-radius: 999px;
+ background: #eef6fc;
+ color: var(--ink-base);
+ font: inherit;
+ font-size: 11px;
+ font-weight: 700;
+ box-shadow: none;
+}
+.catalog-refresh-pill:hover {
+ background: var(--c-run-bg);
+}
+.pill-dot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background: var(--c-run);
+ display: inline-block;
+}
+.pill-dot.retry {
+ background: var(--c-warn);
+}
+.catalog-refresh-hide {
+ margin-left: auto;
+ border: 0;
+ background: transparent;
+ color: #4c7d9e;
+ font: inherit;
+ font-size: 11px;
+ text-decoration: underline;
+ padding: 0 2px;
+}
+.catalog-refresh-failed-note {
+ margin: 6px 0 0;
+ color: var(--c-fail);
+ font-size: 11px;
+}
/* 首次扫描中的来源:还没快照,但立刻出现在列表里并显示进度。 */
-.catalog-source.scanning strong { color: #b45309; }
-.catalog-scanning-state { display: flex; flex-direction: column; gap: 10px; padding: 18px; border: 1px solid var(--border, #e3e8ec); border-radius: 10px; background: var(--card, #fff); }
-.catalog-scanning-state > div { display: flex; align-items: baseline; gap: 10px; }
-.catalog-scanning-state strong { font-size: 15px; }
-.catalog-scanning-state span { color: var(--muted-foreground, #5b6773); font-size: 13px; }
-.catalog-scanning-state progress { width: 100%; height: 10px; }
-.catalog-scanning-state p { margin: 0; font-size: 13px; }
-.catalog-scanning-state small { color: var(--muted-foreground, #5b6773); font-size: 12px; }
+.catalog-source.scanning strong {
+ color: #b45309;
+}
+.catalog-scanning-state {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ padding: 18px;
+ border: 1px solid var(--border, #e3e8ec);
+ border-radius: 10px;
+ background: var(--card, #fff);
+}
+.catalog-scanning-state > div {
+ display: flex;
+ align-items: baseline;
+ gap: 10px;
+}
+.catalog-scanning-state strong {
+ font-size: 15px;
+}
+.catalog-scanning-state span {
+ color: var(--muted-foreground, #5b6773);
+ font-size: 13px;
+}
+.catalog-scanning-state progress {
+ width: 100%;
+ height: 10px;
+}
+.catalog-scanning-state p {
+ margin: 0;
+ font-size: 13px;
+}
+.catalog-scanning-state small {
+ color: var(--muted-foreground, #5b6773);
+ font-size: 12px;
+}
diff --git a/web/src/sourceLinkManager.css b/web/src/sourceLinkManager.css
index cf7a074..77102cb 100644
--- a/web/src/sourceLinkManager.css
+++ b/web/src/sourceLinkManager.css
@@ -1 +1,178 @@
-.source-link-manager{max-width:1500px}.source-link-layout{display:grid;grid-template-columns:300px minmax(0,1fr);gap:18px}.source-link-list,.source-link-editor{min-width:0;border:1px solid var(--border-base);border-radius:14px;background:var(--bg-panel)}.source-link-list{max-height:calc(100vh - 260px);overflow:auto;padding:12px}.source-link-list h2{margin:5px 8px 12px;color:var(--ink-base);font-size:13px}.source-link-list button{display:flex;width:100%;align-items:center;justify-content:space-between;gap:10px;border:1px solid transparent;border-radius:9px;padding:11px;background:transparent;box-shadow:none;color:var(--ink-base);text-align:left}.source-link-list button.active{border-color:var(--c-run-border);background:var(--c-run-bg);color:var(--c-run)}.source-link-list button>span{display:grid;gap:4px}.source-link-list small{color:var(--ink-mute);font-size:10px}.source-link-list strong{font-size:10px}.source-link-editor{padding-bottom:18px}.source-link-title{display:flex;align-items:center;justify-content:space-between;gap:12px;border-bottom:1px solid var(--border-base);padding:15px 18px}.source-link-title>div{display:grid;gap:4px}.source-link-title h2{margin:0;color:var(--ink-base);font-size:16px}.source-link-title span{color:var(--ink-mute);font-size:10px}.connection-unverified{border-radius:6px;padding:5px 7px;background:var(--c-warn-bg)!important;color:var(--c-warn)!important;font-weight:700}.source-link-fields{display:grid;grid-template-columns:1fr 1fr;gap:12px;padding:18px}.source-link-fields label{display:grid;gap:6px;color:var(--ink-base);font-size:11px}.source-link-fields input{min-width:0;border:1px solid var(--border-strong);border-radius:8px;padding:10px;background:var(--bg-panel);color:var(--ink-base)}.source-link-fields input:read-only,.source-link-fields input:disabled{background:var(--bg-soft);color:var(--ink-mute)}.source-link-boundary{display:flex;flex-wrap:wrap;gap:8px;margin:0 18px 14px}.source-link-boundary>*{border-radius:6px;padding:6px 8px;background:var(--border-soft);color:var(--ink-mute);font-size:10px}.validate-source-link{margin:0 18px}.validation-results{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:7px;margin:16px 18px;border:1px solid var(--c-done-border);border-radius:10px;padding:12px;background:var(--c-done-bg)}.validation-results div{display:grid;gap:3px}.validation-results span{color:var(--ink-mute);font-size:9px}.validation-results b{color:var(--c-done);font-size:10px}.validation-results small{grid-column:1/-1;color:var(--c-warn);font-size:9px}.credential-note{margin:15px 18px 0;border-top:1px solid var(--border-base);padding-top:13px;color:var(--ink-mute);font-size:10px;line-height:1.6}@media(max-width:900px){.source-link-layout{grid-template-columns:1fr}.source-link-list{max-height:260px}}@media(max-width:650px){.source-link-fields{grid-template-columns:1fr}.validation-results{grid-template-columns:1fr 1fr}}
\ No newline at end of file
+.source-link-manager {
+ max-width: 1500px;
+}
+.source-link-layout {
+ display: grid;
+ grid-template-columns: 300px minmax(0, 1fr);
+ gap: 18px;
+}
+.source-link-list,
+.source-link-editor {
+ min-width: 0;
+ border: 1px solid var(--border-base);
+ border-radius: 14px;
+ background: var(--bg-panel);
+}
+.source-link-list {
+ max-height: calc(100vh - 260px);
+ overflow: auto;
+ padding: 12px;
+}
+.source-link-list h2 {
+ margin: 5px 8px 12px;
+ color: var(--ink-base);
+ font-size: 13px;
+}
+.source-link-list button {
+ display: flex;
+ width: 100%;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+ border: 1px solid transparent;
+ border-radius: 9px;
+ padding: 11px;
+ background: transparent;
+ box-shadow: none;
+ color: var(--ink-base);
+ text-align: left;
+}
+.source-link-list button.active {
+ border-color: var(--c-run-border);
+ background: var(--c-run-bg);
+ color: var(--c-run);
+}
+.source-link-list button > span {
+ display: grid;
+ gap: 4px;
+}
+.source-link-list small {
+ color: var(--ink-mute);
+ font-size: 10px;
+}
+.source-link-list strong {
+ font-size: 10px;
+}
+.source-link-editor {
+ padding-bottom: 18px;
+}
+.source-link-title {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ border-bottom: 1px solid var(--border-base);
+ padding: 15px 18px;
+}
+.source-link-title > div {
+ display: grid;
+ gap: 4px;
+}
+.source-link-title h2 {
+ margin: 0;
+ color: var(--ink-base);
+ font-size: 16px;
+}
+.source-link-title span {
+ color: var(--ink-mute);
+ font-size: 10px;
+}
+.connection-unverified {
+ border-radius: 6px;
+ padding: 5px 7px;
+ background: var(--c-warn-bg) !important;
+ color: var(--c-warn) !important;
+ font-weight: 700;
+}
+.source-link-fields {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 12px;
+ padding: 18px;
+}
+.source-link-fields label {
+ display: grid;
+ gap: 6px;
+ color: var(--ink-base);
+ font-size: 11px;
+}
+.source-link-fields input {
+ min-width: 0;
+ border: 1px solid var(--border-strong);
+ border-radius: 8px;
+ padding: 10px;
+ background: var(--bg-panel);
+ color: var(--ink-base);
+}
+.source-link-fields input:read-only,
+.source-link-fields input:disabled {
+ background: var(--bg-soft);
+ color: var(--ink-mute);
+}
+.source-link-boundary {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ margin: 0 18px 14px;
+}
+.source-link-boundary > * {
+ border-radius: 6px;
+ padding: 6px 8px;
+ background: var(--border-soft);
+ color: var(--ink-mute);
+ font-size: 10px;
+}
+.validate-source-link {
+ margin: 0 18px;
+}
+.validation-results {
+ display: grid;
+ grid-template-columns: repeat(5, minmax(0, 1fr));
+ gap: 7px;
+ margin: 16px 18px;
+ border: 1px solid var(--c-done-border);
+ border-radius: 10px;
+ padding: 12px;
+ background: var(--c-done-bg);
+}
+.validation-results div {
+ display: grid;
+ gap: 3px;
+}
+.validation-results span {
+ color: var(--ink-mute);
+ font-size: 9px;
+}
+.validation-results b {
+ color: var(--c-done);
+ font-size: 10px;
+}
+.validation-results small {
+ grid-column: 1/-1;
+ color: var(--c-warn);
+ font-size: 9px;
+}
+.credential-note {
+ margin: 15px 18px 0;
+ border-top: 1px solid var(--border-base);
+ padding-top: 13px;
+ color: var(--ink-mute);
+ font-size: 10px;
+ line-height: 1.6;
+}
+@media (max-width: 900px) {
+ .source-link-layout {
+ grid-template-columns: 1fr;
+ }
+ .source-link-list {
+ max-height: 260px;
+ }
+}
+@media (max-width: 650px) {
+ .source-link-fields {
+ grid-template-columns: 1fr;
+ }
+ .validation-results {
+ grid-template-columns: 1fr 1fr;
+ }
+}
diff --git a/web/src/styles.css b/web/src/styles.css
index 98f4ad9..9e44b2a 100644
--- a/web/src/styles.css
+++ b/web/src/styles.css
@@ -56,8 +56,8 @@
--side-ink-faint: #5b7789;
/* 字体 */
- --font-base: Inter, "Noto Sans SC", "PingFang SC", system-ui, sans-serif;
- --font-mono: "IBM Plex Mono", "SF Mono", Menlo, Consolas, monospace;
+ --font-base: Inter, 'Noto Sans SC', 'PingFang SC', system-ui, sans-serif;
+ --font-mono: 'IBM Plex Mono', 'SF Mono', Menlo, Consolas, monospace;
/* 圆角(A 风格:小圆角) */
--r-sm: 3px;
@@ -69,7 +69,8 @@
box-sizing: border-box;
}
-html, body {
+html,
+body {
margin: 0;
padding: 0;
}
@@ -101,7 +102,10 @@ button {
font-weight: 500;
cursor: pointer;
box-shadow: none;
- transition: background 120ms ease, border-color 120ms ease, color 120ms ease;
+ transition:
+ background 120ms ease,
+ border-color 120ms ease,
+ color 120ms ease;
}
button:hover:not(:disabled) {
@@ -115,7 +119,7 @@ button:disabled {
}
button.primary,
-button[type="submit"],
+button[type='submit'],
.primary-link {
background: var(--side-bg);
border-color: var(--side-bg);
@@ -227,7 +231,9 @@ code {
font-size: 16px;
min-height: 44px;
text-align: left;
- transition: background 120ms ease, color 120ms ease;
+ transition:
+ background 120ms ease,
+ color 120ms ease;
}
.nav-item:hover {
@@ -332,8 +338,15 @@ code {
font-size: 11px;
}
-.workspace-context-tabs span { display: flex; align-items: center; gap: 4px; }
-.workspace-context-tabs i { color: var(--ink-faint); font-style: normal; }
+.workspace-context-tabs span {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+}
+.workspace-context-tabs i {
+ color: var(--ink-faint);
+ font-style: normal;
+}
.workspace-context-tabs button {
max-width: 180px;
overflow: hidden;
@@ -397,7 +410,9 @@ code {
text-transform: uppercase;
}
-h1, h2, p {
+h1,
+h2,
+p {
margin-top: 0;
}
@@ -453,11 +468,21 @@ h1 {
font-variant-numeric: tabular-nums;
}
-.tone-blue { border-top: 2px solid var(--c-run); }
-.tone-cyan { border-top: 2px solid var(--c-cyan); }
-.tone-amber { border-top: 2px solid var(--c-warn); }
-.tone-red { border-top: 2px solid var(--c-fail); }
-.tone-green { border-top: 2px solid var(--c-done); }
+.tone-blue {
+ border-top: 2px solid var(--c-run);
+}
+.tone-cyan {
+ border-top: 2px solid var(--c-cyan);
+}
+.tone-amber {
+ border-top: 2px solid var(--c-warn);
+}
+.tone-red {
+ border-top: 2px solid var(--c-fail);
+}
+.tone-green {
+ border-top: 2px solid var(--c-done);
+}
/* ========== Panel(A:细边 + 6px 圆角 + 无阴影) ========== */
.panel {
@@ -513,7 +538,8 @@ table {
font-size: 12.5px;
}
-th, td {
+th,
+td {
padding: 10px 14px;
border-bottom: 1px solid var(--border-soft);
text-align: left;
@@ -534,21 +560,39 @@ tbody tr:hover {
}
/* ========== 来源连接表 ========== */
-.source-connections-table { table-layout: fixed; }
+.source-connections-table {
+ table-layout: fixed;
+}
.source-connections-table th,
.source-connections-table td {
padding: 10px 8px;
white-space: normal;
overflow-wrap: anywhere;
}
-.source-connections-table th:nth-child(1) { width: 22%; }
-.source-connections-table th:nth-child(2) { width: 7%; }
-.source-connections-table th:nth-child(3) { width: 8%; }
-.source-connections-table th:nth-child(4) { width: 8%; }
-.source-connections-table th:nth-child(5) { width: 8%; }
-.source-connections-table th:nth-child(6) { width: 7%; }
-.source-connections-table th:nth-child(7) { width: 30%; }
-.source-connections-table th:nth-child(8) { width: 10%; }
+.source-connections-table th:nth-child(1) {
+ width: 22%;
+}
+.source-connections-table th:nth-child(2) {
+ width: 7%;
+}
+.source-connections-table th:nth-child(3) {
+ width: 8%;
+}
+.source-connections-table th:nth-child(4) {
+ width: 8%;
+}
+.source-connections-table th:nth-child(5) {
+ width: 8%;
+}
+.source-connections-table th:nth-child(6) {
+ width: 7%;
+}
+.source-connections-table th:nth-child(7) {
+ width: 30%;
+}
+.source-connections-table th:nth-child(8) {
+ width: 10%;
+}
.source-connection-name,
.source-scan-summary {
@@ -579,21 +623,37 @@ tbody tr:hover {
padding: 14px 16px 16px;
}
-.scan-audit-grid > * { min-width: 0; }
+.scan-audit-grid > * {
+ min-width: 0;
+}
-.source-scan-history-table { table-layout: fixed; }
+.source-scan-history-table {
+ table-layout: fixed;
+}
.source-scan-history-table th,
.source-scan-history-table td {
padding: 9px 8px;
white-space: normal;
overflow-wrap: anywhere;
}
-.source-scan-history-table th:nth-child(1) { width: 25%; }
-.source-scan-history-table th:nth-child(2) { width: 12%; }
-.source-scan-history-table th:nth-child(3) { width: 9%; }
-.source-scan-history-table th:nth-child(4) { width: 27%; }
-.source-scan-history-table th:nth-child(5) { width: 12%; }
-.source-scan-history-table th:nth-child(6) { width: 15%; }
+.source-scan-history-table th:nth-child(1) {
+ width: 25%;
+}
+.source-scan-history-table th:nth-child(2) {
+ width: 12%;
+}
+.source-scan-history-table th:nth-child(3) {
+ width: 9%;
+}
+.source-scan-history-table th:nth-child(4) {
+ width: 27%;
+}
+.source-scan-history-table th:nth-child(5) {
+ width: 12%;
+}
+.source-scan-history-table th:nth-child(6) {
+ width: 15%;
+}
.source-scan-history-table code,
.source-scan-history-table time {
@@ -640,7 +700,9 @@ tbody tr:hover {
}
/* ========== Release page ========== */
-.release-page .panel { margin-top: 14px; }
+.release-page .panel {
+ margin-top: 14px;
+}
.module-tabs {
display: flex;
@@ -685,12 +747,23 @@ tbody tr:hover {
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 1px;
}
-.monitor-summary-grid .summary-card { padding: 10px 12px; }
-.monitor-summary-grid .summary-card span { margin-bottom: 4px; font-size: 11px; }
-.monitor-summary-grid .summary-card strong { font-size: 20px; }
+.monitor-summary-grid .summary-card {
+ padding: 10px 12px;
+}
+.monitor-summary-grid .summary-card span {
+ margin-bottom: 4px;
+ font-size: 11px;
+}
+.monitor-summary-grid .summary-card strong {
+ font-size: 20px;
+}
-.parsing-monitor-panel { margin-top: 0; }
-.parsing-monitor-panel .panel-heading { padding: 10px 14px; }
+.parsing-monitor-panel {
+ margin-top: 0;
+}
+.parsing-monitor-panel .panel-heading {
+ padding: 10px 14px;
+}
.release-snapshot-callout {
display: flex;
@@ -705,14 +778,33 @@ tbody tr:hover {
font-size: 12px;
}
-.release-snapshot-callout > span { font-size: 14px; line-height: 1.25; }
-.release-snapshot-callout div { display: grid; gap: 2px; }
-.release-snapshot-callout strong { font-size: 12px; color: var(--ink-strong); }
-.release-snapshot-callout small { color: var(--ink-mute); font-size: 11px; line-height: 1.4; }
+.release-snapshot-callout > span {
+ font-size: 14px;
+ line-height: 1.25;
+}
+.release-snapshot-callout div {
+ display: grid;
+ gap: 2px;
+}
+.release-snapshot-callout strong {
+ font-size: 12px;
+ color: var(--ink-strong);
+}
+.release-snapshot-callout small {
+ color: var(--ink-mute);
+ font-size: 11px;
+ line-height: 1.4;
+}
-.release-page .hero + .module-tabs { margin-top: -4px; }
-.release-page .summary-grid { margin-top: 14px; }
-.release-page .summary-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); }
+.release-page .hero + .module-tabs {
+ margin-top: -4px;
+}
+.release-page .summary-grid {
+ margin-top: 14px;
+}
+.release-page .summary-grid {
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+}
.release-page button:disabled {
border-color: var(--border-base);
@@ -1003,14 +1095,18 @@ tbody tr:hover {
background: var(--c-done-bg);
}
-.batch-requirements .met::before { content: '✓'; }
+.batch-requirements .met::before {
+ content: '✓';
+}
.batch-requirements .unmet {
color: var(--c-warn);
background: var(--c-warn-bg);
}
-.batch-requirements .unmet::before { content: '○'; }
+.batch-requirements .unmet::before {
+ content: '○';
+}
.acceptance-summary > strong,
.acceptance-summary > span {
@@ -1078,7 +1174,9 @@ tbody tr:hover {
font-size: 12px;
}
-.button-reason strong { margin-right: 4px; }
+.button-reason strong {
+ margin-right: 4px;
+}
.release-actions,
.row-actions {
@@ -1122,7 +1220,9 @@ tbody tr:hover {
font-size: 12px;
}
-.review-table { table-layout: fixed; }
+.review-table {
+ table-layout: fixed;
+}
.review-table th,
.review-table td {
padding: 8px 8px;
@@ -1130,13 +1230,27 @@ tbody tr:hover {
overflow-wrap: anywhere;
vertical-align: top;
}
-.review-table th:nth-child(1) { width: 5%; }
-.review-table th:nth-child(2) { width: 20%; }
-.review-table th:nth-child(3) { width: 19%; }
-.review-table th:nth-child(4) { width: 10%; }
-.review-table th:nth-child(5) { width: 13%; }
-.review-table th:nth-child(6) { width: 13%; }
-.review-table th:nth-child(7) { width: 20%; }
+.review-table th:nth-child(1) {
+ width: 5%;
+}
+.review-table th:nth-child(2) {
+ width: 20%;
+}
+.review-table th:nth-child(3) {
+ width: 19%;
+}
+.review-table th:nth-child(4) {
+ width: 10%;
+}
+.review-table th:nth-child(5) {
+ width: 13%;
+}
+.review-table th:nth-child(6) {
+ width: 13%;
+}
+.review-table th:nth-child(7) {
+ width: 20%;
+}
.review-table td > strong,
.review-table td > code,
@@ -1166,7 +1280,9 @@ tbody tr:hover {
padding: 12px 16px;
}
-.batch-create-bar label { margin-right: auto; }
+.batch-create-bar label {
+ margin-right: auto;
+}
.batch-grid {
display: grid;
@@ -1175,7 +1291,9 @@ tbody tr:hover {
padding: 12px 16px 16px;
}
-.batch-grid > * { min-width: 0; }
+.batch-grid > * {
+ min-width: 0;
+}
.batch-grid th,
.batch-grid td {
@@ -1235,13 +1353,41 @@ tbody tr:hover {
letter-spacing: 0.02em;
}
-.status-pending { color: var(--ink-mute); background: var(--bg-softer); border-color: var(--border-base); }
-.status-running { color: var(--c-cyan); background: var(--c-cyan-bg); border-color: #b5e5ee; }
-.status-retry { color: var(--c-warn); background: var(--c-warn-bg); border-color: var(--c-warn-border); }
-.status-completed { color: var(--c-done); background: var(--c-done-bg); border-color: var(--c-done-border); }
-.status-failed { color: var(--c-fail); background: var(--c-fail-bg); border-color: var(--c-fail-border); }
-.status-over_limit { color: var(--c-warn); background: var(--c-warn-bg); border-color: var(--c-warn-border); }
-.status-content_alias { color: var(--c-cyan); background: var(--c-cyan-bg); border-color: #b5e5ee; }
+.status-pending {
+ color: var(--ink-mute);
+ background: var(--bg-softer);
+ border-color: var(--border-base);
+}
+.status-running {
+ color: var(--c-cyan);
+ background: var(--c-cyan-bg);
+ border-color: #b5e5ee;
+}
+.status-retry {
+ color: var(--c-warn);
+ background: var(--c-warn-bg);
+ border-color: var(--c-warn-border);
+}
+.status-completed {
+ color: var(--c-done);
+ background: var(--c-done-bg);
+ border-color: var(--c-done-border);
+}
+.status-failed {
+ color: var(--c-fail);
+ background: var(--c-fail-bg);
+ border-color: var(--c-fail-border);
+}
+.status-over_limit {
+ color: var(--c-warn);
+ background: var(--c-warn-bg);
+ border-color: var(--c-warn-border);
+}
+.status-content_alias {
+ color: var(--c-cyan);
+ background: var(--c-cyan-bg);
+ border-color: #b5e5ee;
+}
/* ========== Error / Empty / Banner ========== */
.error-cell {
@@ -1296,7 +1442,9 @@ tbody tr:hover {
}
/* ========== Release Plan ========== */
-.release-plan-panel { margin-bottom: 14px; }
+.release-plan-panel {
+ margin-bottom: 14px;
+}
.release-plan-filters {
display: flex;
@@ -1323,8 +1471,13 @@ tbody tr:hover {
background: var(--bg-panel);
}
-.release-plan-filters .filter-check { cursor: pointer; }
-.release-plan-filters .filter-count { margin-left: auto; color: var(--ink-faint); }
+.release-plan-filters .filter-check {
+ cursor: pointer;
+}
+.release-plan-filters .filter-count {
+ margin-left: auto;
+ color: var(--ink-faint);
+}
.release-plan-pager {
display: flex;
@@ -1440,9 +1593,13 @@ tbody tr:hover {
}
/* ========== Dashboard ========== */
-.dashboard-recent { margin-top: 0; }
+.dashboard-recent {
+ margin-top: 0;
+}
-.recent-list { display: grid; }
+.recent-list {
+ display: grid;
+}
.recent-row {
display: grid;
@@ -1453,8 +1610,12 @@ tbody tr:hover {
border-bottom: 1px solid var(--border-soft);
}
-.recent-row:last-child { border-bottom: 0; }
-.recent-row div { min-width: 0; }
+.recent-row:last-child {
+ border-bottom: 0;
+}
+.recent-row div {
+ min-width: 0;
+}
.recent-row strong,
.recent-row code {
@@ -1519,7 +1680,9 @@ tbody tr:hover {
}
/* ========== Asset ========== */
-.asset-summary { grid-template-columns: repeat(4, minmax(0, 1fr)); }
+.asset-summary {
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+}
.asset-toolbar label {
display: flex;
@@ -1604,7 +1767,9 @@ tbody tr:hover {
font-family: var(--font-mono);
}
-.release-changeset-items { margin: 4px 0; }
+.release-changeset-items {
+ margin: 4px 0;
+}
.release-changeset-items summary {
cursor: pointer;
@@ -1759,12 +1924,24 @@ tbody tr:hover {
/* ========== Responsive ========== */
@media (max-width: 860px) {
- .summary-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
- .asset-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); }
- .scan-audit-grid { grid-template-columns: 1fr; }
- .batch-grid { grid-template-columns: 1fr; }
- .journey-steps { grid-template-columns: repeat(2, minmax(0, 1fr)); }
- .review-decision-guide { grid-template-columns: 1fr; }
+ .summary-grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+ .asset-summary {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+ .scan-audit-grid {
+ grid-template-columns: 1fr;
+ }
+ .batch-grid {
+ grid-template-columns: 1fr;
+ }
+ .journey-steps {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+ .review-decision-guide {
+ grid-template-columns: 1fr;
+ }
}
@media (max-width: 760px) {
@@ -1773,7 +1950,9 @@ tbody tr:hover {
transform: translateX(-105%);
transition: transform 180ms ease;
}
- .sidebar.open { transform: translateX(0); }
+ .sidebar.open {
+ transform: translateX(0);
+ }
.sidebar-backdrop {
position: fixed;
z-index: 35;
@@ -1789,8 +1968,12 @@ tbody tr:hover {
width: 100%;
margin-left: 0;
}
- .workspace-topbar { align-items: center; }
- .workspace-context-tabs { display: none; }
+ .workspace-topbar {
+ align-items: center;
+ }
+ .workspace-context-tabs {
+ display: none;
+ }
.mobile-nav-toggle {
display: grid;
width: 32px;
@@ -1816,8 +1999,12 @@ tbody tr:hover {
align-items: stretch;
flex-direction: column;
}
- .summary-grid { grid-template-columns: 1fr; }
- .asset-summary { grid-template-columns: 1fr; }
+ .summary-grid {
+ grid-template-columns: 1fr;
+ }
+ .asset-summary {
+ grid-template-columns: 1fr;
+ }
.asset-toolbar label,
.pagination,
.batch-create-bar {
@@ -1831,13 +2018,28 @@ tbody tr:hover {
align-items: flex-start;
flex-direction: column;
}
- .journey-heading { display: flex; }
- .journey-heading p { text-align: left; }
- .journey-steps { grid-template-columns: 1fr; }
- .current-guidance { display: flex; }
- .guidance-step { max-width: none; }
- .recent-row { grid-template-columns: minmax(0, 1fr) auto; }
- .recent-row time { grid-column: 1 / -1; text-align: left; }
+ .journey-heading {
+ display: flex;
+ }
+ .journey-heading p {
+ text-align: left;
+ }
+ .journey-steps {
+ grid-template-columns: 1fr;
+ }
+ .current-guidance {
+ display: flex;
+ }
+ .guidance-step {
+ max-width: none;
+ }
+ .recent-row {
+ grid-template-columns: minmax(0, 1fr) auto;
+ }
+ .recent-row time {
+ grid-column: 1 / -1;
+ text-align: left;
+ }
}
/* ── 登录 / 强制改密 / 顶栏身份(A · Data-console) ─────────────── */
@@ -1856,13 +2058,28 @@ tbody tr:hover {
border: 1px solid var(--border-base);
border-radius: 10px;
padding: 28px 26px 24px;
- box-shadow: 0 18px 46px rgba(15, 23, 32, 0.10);
+ box-shadow: 0 18px 46px rgba(15, 23, 32, 0.1);
display: grid;
gap: 14px;
}
-.login-card h1 { margin: 0; font-size: 20px; color: var(--ink-strong); letter-spacing: 0.01em; }
-.login-subtitle { margin: 0; color: var(--ink-mute); font-size: 13px; }
-.login-card label { display: grid; gap: 6px; font-size: 13px; color: var(--ink-base); font-weight: 600; }
+.login-card h1 {
+ margin: 0;
+ font-size: 20px;
+ color: var(--ink-strong);
+ letter-spacing: 0.01em;
+}
+.login-subtitle {
+ margin: 0;
+ color: var(--ink-mute);
+ font-size: 13px;
+}
+.login-card label {
+ display: grid;
+ gap: 6px;
+ font-size: 13px;
+ color: var(--ink-base);
+ font-weight: 600;
+}
.login-card input {
padding: 10px 12px;
border: 1px solid var(--border-strong);
@@ -1871,12 +2088,25 @@ tbody tr:hover {
background: var(--bg-panel);
color: var(--ink-strong);
}
-.login-card input:focus { outline: 2px solid var(--c-run); outline-offset: 1px; border-color: transparent; }
-.login-card button[type="submit"] { width: 100%; margin-top: 4px; }
+.login-card input:focus {
+ outline: 2px solid var(--c-run);
+ outline-offset: 1px;
+ border-color: transparent;
+}
+.login-card button[type='submit'] {
+ width: 100%;
+ margin-top: 4px;
+}
/* Password visibility toggle: button sits inside the field's right edge. */
-.password-field { position: relative; display: block; }
-.password-field input { width: 100%; padding-right: 56px; }
+.password-field {
+ position: relative;
+ display: block;
+}
+.password-field input {
+ width: 100%;
+ padding-right: 56px;
+}
.password-toggle {
position: absolute;
top: 50%;
@@ -1892,34 +2122,128 @@ tbody tr:hover {
line-height: 20px;
cursor: pointer;
}
-.password-toggle:hover { border-color: var(--c-run, #2f6feb); color: var(--c-run, #2f6feb); }
-.login-error { margin: 0; font-size: 13px; color: var(--c-fail); background: var(--c-fail-bg); border: 1px solid var(--c-fail-border); border-radius: 6px; padding: 8px 10px; }
-.login-secondary { background: transparent; border: none; color: var(--ink-mute); font-size: 12.5px; cursor: pointer; text-decoration: underline; }
+.password-toggle:hover {
+ border-color: var(--c-run, #2f6feb);
+ color: var(--c-run, #2f6feb);
+}
+.login-error {
+ margin: 0;
+ font-size: 13px;
+ color: var(--c-fail);
+ background: var(--c-fail-bg);
+ border: 1px solid var(--c-fail-border);
+ border-radius: 6px;
+ padding: 8px 10px;
+}
+.login-secondary {
+ background: transparent;
+ border: none;
+ color: var(--ink-mute);
+ font-size: 12.5px;
+ cursor: pointer;
+ text-decoration: underline;
+}
-.topbar-user { position: relative; margin-left: auto; }
+.topbar-user {
+ position: relative;
+ margin-left: auto;
+}
.topbar-user-button {
- display: inline-flex; align-items: center; gap: 8px;
- background: var(--bg-soft); border: 1px solid var(--border-base); border-radius: 999px;
- padding: 4px 12px 4px 4px; cursor: pointer; color: var(--ink-base); font: inherit;
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ background: var(--bg-soft);
+ border: 1px solid var(--border-base);
+ border-radius: 999px;
+ padding: 4px 12px 4px 4px;
+ cursor: pointer;
+ color: var(--ink-base);
+ font: inherit;
+}
+.topbar-user-button:hover {
+ background: var(--bg-hover);
}
-.topbar-user-button:hover { background: var(--bg-hover); }
.topbar-user-avatar {
- width: 26px; height: 26px; border-radius: 50%;
- background: var(--side-bg); color: var(--side-accent);
- display: grid; place-items: center; font-size: 13px; font-weight: 700;
+ width: 26px;
+ height: 26px;
+ border-radius: 50%;
+ background: var(--side-bg);
+ color: var(--side-accent);
+ display: grid;
+ place-items: center;
+ font-size: 13px;
+ font-weight: 700;
+}
+.topbar-user-name {
+ font-size: 13px;
+ font-weight: 600;
+}
+.role-badge {
+ font-size: 11px;
+ padding: 1px 8px;
+ border-radius: 999px;
+ border: 1px solid var(--border-base);
+ background: var(--bg-panel);
+ color: var(--ink-mute);
+}
+.role-badge.role-admin {
+ color: var(--c-run);
+ border-color: var(--c-run-border);
+ background: var(--c-run-bg);
+}
+.role-badge.role-engineer {
+ color: var(--c-done);
+ border-color: var(--c-done-border);
+ background: var(--c-done-bg);
}
-.topbar-user-name { font-size: 13px; font-weight: 600; }
-.role-badge { font-size: 11px; padding: 1px 8px; border-radius: 999px; border: 1px solid var(--border-base); background: var(--bg-panel); color: var(--ink-mute); }
-.role-badge.role-admin { color: var(--c-run); border-color: var(--c-run-border); background: var(--c-run-bg); }
-.role-badge.role-engineer { color: var(--c-done); border-color: var(--c-done-border); background: var(--c-done-bg); }
.topbar-user-menu {
- position: absolute; right: 0; top: calc(100% + 6px); z-index: 30;
- background: var(--bg-panel); border: 1px solid var(--border-base); border-radius: 8px;
- box-shadow: 0 12px 30px rgba(15, 23, 32, 0.12); min-width: 140px; padding: 4px;
+ position: absolute;
+ right: 0;
+ top: calc(100% + 6px);
+ z-index: 30;
+ background: var(--bg-panel);
+ border: 1px solid var(--border-base);
+ border-radius: 8px;
+ box-shadow: 0 12px 30px rgba(15, 23, 32, 0.12);
+ min-width: 140px;
+ padding: 4px;
+}
+.topbar-user-menu button {
+ width: 100%;
+ text-align: left;
+ background: transparent;
+ border: none;
+ padding: 8px 10px;
+ border-radius: 6px;
+ cursor: pointer;
+ font: inherit;
+ color: var(--ink-base);
+}
+.topbar-user-menu button:hover {
+ background: var(--bg-hover);
}
-.topbar-user-menu button { width: 100%; text-align: left; background: transparent; border: none; padding: 8px 10px; border-radius: 6px; cursor: pointer; font: inherit; color: var(--ink-base); }
-.topbar-user-menu button:hover { background: var(--bg-hover); }
-.member-create-form { display: flex; flex-wrap: wrap; gap: 12px; align-items: flex-end; margin-top: 14px; padding-top: 14px; border-top: 1px solid var(--border-soft); }
-.member-create-form label { display: grid; gap: 5px; font-size: 12.5px; color: var(--ink-base); font-weight: 600; }
-.member-create-form input, .member-create-form select { padding: 8px 10px; border: 1px solid var(--border-strong); border-radius: 6px; font: inherit; background: var(--bg-panel); }
+.member-create-form {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 12px;
+ align-items: flex-end;
+ margin-top: 14px;
+ padding-top: 14px;
+ border-top: 1px solid var(--border-soft);
+}
+.member-create-form label {
+ display: grid;
+ gap: 5px;
+ font-size: 12.5px;
+ color: var(--ink-base);
+ font-weight: 600;
+}
+.member-create-form input,
+.member-create-form select {
+ padding: 8px 10px;
+ border: 1px solid var(--border-strong);
+ border-radius: 6px;
+ font: inherit;
+ background: var(--bg-panel);
+}
diff --git a/web/src/testPublications.test.ts b/web/src/testPublications.test.ts
index b04688a..282cbba 100644
--- a/web/src/testPublications.test.ts
+++ b/web/src/testPublications.test.ts
@@ -1,19 +1,40 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
-import { fetchAcceptanceResults, fetchTestPublications, parseTestPublicationsResponse, retryPublication, retryPublicationsBatch, startTestPublications, fetchJobFailures, fetchRemovalRequiredPublications, startPublicationRemoval } from './testPublications'
+import {
+ fetchAcceptanceResults,
+ fetchTestPublications,
+ parseTestPublicationsResponse,
+ retryPublication,
+ retryPublicationsBatch,
+ startTestPublications,
+ fetchJobFailures,
+ fetchRemovalRequiredPublications,
+ startPublicationRemoval,
+} from './testPublications'
afterEach(() => vi.unstubAllGlobals())
describe('parseTestPublicationsResponse', () => {
it('parses the safe publication projection envelope', () => {
const result = parseTestPublicationsResponse({
- data: [{
- id: 'pub-safe', title: 'Safe title', ingest_state: 'failed',
- processing_state: 'failed', retrieval_state: 'unpublished',
- target_scope: 'external', change_set_id: 'cs-1', change_type: 'updated', can_start: false,
- retry_count: 2, last_error: 'remote_parse_failed', can_retry: true,
- updated_at: '2026-09-04T05:00:00Z', test_kb_id: 'must-drop',
- }],
+ data: [
+ {
+ id: 'pub-safe',
+ title: 'Safe title',
+ ingest_state: 'failed',
+ processing_state: 'failed',
+ retrieval_state: 'unpublished',
+ target_scope: 'external',
+ change_set_id: 'cs-1',
+ change_type: 'updated',
+ can_start: false,
+ retry_count: 2,
+ last_error: 'remote_parse_failed',
+ can_retry: true,
+ updated_at: '2026-09-04T05:00:00Z',
+ test_kb_id: 'must-drop',
+ },
+ ],
total: 1,
limit: 25,
offset: 0,
@@ -40,7 +61,7 @@ describe('fetchTestPublications', () => {
expect(fetchMock).toHaveBeenCalledWith(
'/api/v1/test-publications?processing_state=failed&limit=25&offset=0',
- expect.objectContaining({ headers: { Accept: 'application/json' } }),
+ expect.objectContaining({ headers: { Accept: 'application/json' } })
)
})
})
@@ -49,7 +70,9 @@ describe('fetchAcceptanceResults', () => {
it('reads the acceptance record list', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
- json: async () => ({ data: [{ check_type: 'chunks_present', state: 'passed', observed_at: '2026-09-04T05:05:00Z' }] }),
+ json: async () => ({
+ data: [{ check_type: 'chunks_present', state: 'passed', observed_at: '2026-09-04T05:05:00Z' }],
+ }),
})
vi.stubGlobal('fetch', fetchMock)
@@ -59,7 +82,7 @@ describe('fetchAcceptanceResults', () => {
expect(records[0].check_type).toBe('chunks_present')
expect(fetchMock).toHaveBeenCalledWith(
'/api/v1/test-publications/pub-safe/acceptance',
- expect.objectContaining({ headers: { Accept: 'application/json' } }),
+ expect.objectContaining({ headers: { Accept: 'application/json' } })
)
})
})
@@ -73,7 +96,7 @@ describe('retryPublication', () => {
expect(fetchMock).toHaveBeenCalledWith(
'/api/v1/test-publications/pub-safe/retry',
- expect.objectContaining({ method: 'POST' }),
+ expect.objectContaining({ method: 'POST' })
)
})
@@ -91,7 +114,9 @@ describe('retryPublication', () => {
describe('需下架条目的移除入口', () => {
it('只取被禁止但仍存活在测试库的条目', async () => {
- const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ data: [], total: 0, limit: 50, offset: 0 }) })
+ const fetchMock = vi
+ .fn()
+ .mockResolvedValue({ ok: true, json: async () => ({ data: [], total: 0, limit: 50, offset: 0 }) })
vi.stubGlobal('fetch', fetchMock)
await fetchRemovalRequiredPublications()
@@ -102,7 +127,13 @@ describe('需下架条目的移除入口', () => {
})
it('带幂等键触发下架并回传任务号', async () => {
- const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ publication_id: 'tp-1', job_id: 'job-remove-1', state: 'disabling' }), { status: 202 }))
+ const fetchMock = vi
+ .fn()
+ .mockResolvedValue(
+ new Response(JSON.stringify({ publication_id: 'tp-1', job_id: 'job-remove-1', state: 'disabling' }), {
+ status: 202,
+ })
+ )
vi.stubGlobal('fetch', fetchMock)
await expect(startPublicationRemoval('tp-1')).resolves.toEqual({ job_id: 'job-remove-1' })
@@ -113,7 +144,9 @@ describe('需下架条目的移除入口', () => {
})
it('把服务端拒绝原因原样抛给界面', async () => {
- const fetchMock = vi.fn().mockResolvedValue({ status: 409, json: async () => ({ error: '该知识的检索已经移除,无需重复下架' }) })
+ const fetchMock = vi
+ .fn()
+ .mockResolvedValue({ status: 409, json: async () => ({ error: '该知识的检索已经移除,无需重复下架' }) })
vi.stubGlobal('fetch', fetchMock)
await expect(startPublicationRemoval('tp-1')).rejects.toThrow('该知识的检索已经移除,无需重复下架')
@@ -122,30 +155,58 @@ describe('需下架条目的移除入口', () => {
describe('startTestPublications', () => {
it('starts only the explicitly confirmed processing plans', async () => {
- const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ data: { selected: 2, started: 1, already_started: 1, unavailable: 0 } }), { status: 202 }))
+ const fetchMock = vi
+ .fn()
+ .mockResolvedValue(
+ new Response(JSON.stringify({ data: { selected: 2, started: 1, already_started: 1, unavailable: 0 } }), {
+ status: 202,
+ })
+ )
vi.stubGlobal('fetch', fetchMock)
- await expect(startTestPublications(['pub-a', 'pub-b'], '已核对差异')).resolves.toEqual({ selected: 2, started: 1, already_started: 1, unavailable: 0 })
- expect(fetchMock).toHaveBeenCalledWith('/api/v1/test-publications/start', expect.objectContaining({
- method: 'POST',
- headers: expect.objectContaining({ 'Idempotency-Key': expect.any(String) }),
- body: JSON.stringify({ publication_ids: ['pub-a', 'pub-b'], reason: '已核对差异' }),
- }))
+ await expect(startTestPublications(['pub-a', 'pub-b'], '已核对差异')).resolves.toEqual({
+ selected: 2,
+ started: 1,
+ already_started: 1,
+ unavailable: 0,
+ })
+ expect(fetchMock).toHaveBeenCalledWith(
+ '/api/v1/test-publications/start',
+ expect.objectContaining({
+ method: 'POST',
+ headers: expect.objectContaining({ 'Idempotency-Key': expect.any(String) }),
+ body: JSON.stringify({ publication_ids: ['pub-a', 'pub-b'], reason: '已核对差异' }),
+ })
+ )
})
})
describe('retryPublicationsBatch', () => {
it('sends one server-side batch request and parses per-item outcomes', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
- json: async () => ({ data: { batch_key: 'op-1', total: 2, started: 1, already_running: 1, not_retryable: 0,
- outcomes: [{ publication_id: 'tp-1', result: 'started' }, { publication_id: 'tp-2', result: 'already_running' }] }, operation: 'created' }),
+ json: async () => ({
+ data: {
+ batch_key: 'op-1',
+ total: 2,
+ started: 1,
+ already_running: 1,
+ not_retryable: 0,
+ outcomes: [
+ { publication_id: 'tp-1', result: 'started' },
+ { publication_id: 'tp-2', result: 'already_running' },
+ ],
+ },
+ operation: 'created',
+ }),
})
vi.stubGlobal('fetch', fetchMock)
const result = await retryPublicationsBatch(['tp-1', 'tp-2'], '解析服务恢复')
- expect(fetchMock).toHaveBeenCalledWith('/api/v1/test-publications/retry-batch',
- expect.objectContaining({ method: 'POST' }))
+ expect(fetchMock).toHaveBeenCalledWith(
+ '/api/v1/test-publications/retry-batch',
+ expect.objectContaining({ method: 'POST' })
+ )
const call = fetchMock.mock.calls[0] as [string, RequestInit]
expect(JSON.parse(String(call[1].body))).toEqual({ publication_ids: ['tp-1', 'tp-2'], reason: '解析服务恢复' })
expect(result.started).toBe(1)
@@ -162,13 +223,34 @@ describe('fetchJobFailures', () => {
it('reads the open dead-letter projection', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
- json: async () => ({ data: { items: [{ id: 'jf-1', job_id: 'job-1', job_type: 'test.upload', error_code: 'remote_permanent', attempt: 5, max_attempts_at_failure: 5, state: 'open', created_at: '2026-09-11T20:00:00Z' }], total: 1, limit: 50, offset: 0 } }),
+ json: async () => ({
+ data: {
+ items: [
+ {
+ id: 'jf-1',
+ job_id: 'job-1',
+ job_type: 'test.upload',
+ error_code: 'remote_permanent',
+ attempt: 5,
+ max_attempts_at_failure: 5,
+ state: 'open',
+ created_at: '2026-09-11T20:00:00Z',
+ },
+ ],
+ total: 1,
+ limit: 50,
+ offset: 0,
+ },
+ }),
})
vi.stubGlobal('fetch', fetchMock)
const items = await fetchJobFailures()
- expect(fetchMock).toHaveBeenCalledWith('/api/v1/job-failures?state=open&limit=50', expect.objectContaining({ headers: { Accept: 'application/json' } }))
+ expect(fetchMock).toHaveBeenCalledWith(
+ '/api/v1/job-failures?state=open&limit=50',
+ expect.objectContaining({ headers: { Accept: 'application/json' } })
+ )
expect(items[0].error_code).toBe('remote_permanent')
})
})
diff --git a/web/src/testPublications.ts b/web/src/testPublications.ts
index 8ae3f8e..0fae5ce 100644
--- a/web/src/testPublications.ts
+++ b/web/src/testPublications.ts
@@ -1,6 +1,7 @@
import { newOperationID } from './operationKey'
export type IngestState = 'pending' | 'uploading' | 'uploaded' | 'failed'
-export type ProcessingState = 'planned' | 'pending' | 'processing' | 'finalizing' | 'completed' | 'failed' | 'stalled' | 'cancelled'
+export type ProcessingState =
+ 'planned' | 'pending' | 'processing' | 'finalizing' | 'completed' | 'failed' | 'stalled' | 'cancelled'
export interface TestPublication {
id: string
@@ -35,7 +36,7 @@ export interface AcceptanceRecord {
export interface TestPublicationQuery {
/** removal: 'required' 只取「被禁止导入但仍存活在测试库」的条目(解析监控 → 需下架)。 */
- removal?: 'required'
+ removal?: 'required'
// A single state, or several at once (joined into a comma list server-side).
processing_state: ProcessingState | '' | ProcessingState[]
target_scope?: 'internal' | 'external' | ''
@@ -53,16 +54,36 @@ export interface StartTestPublicationsResult {
const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null
const isCount = (value: unknown): value is number => typeof value === 'number' && Number.isInteger(value) && value >= 0
const ingestStates = new Set(['pending', 'uploading', 'uploaded', 'failed'])
-const processingStates = new Set(['planned', 'pending', 'processing', 'finalizing', 'completed', 'failed', 'stalled', 'cancelled'])
+const processingStates = new Set([
+ 'planned',
+ 'pending',
+ 'processing',
+ 'finalizing',
+ 'completed',
+ 'failed',
+ 'stalled',
+ 'cancelled',
+])
function parseTestPublication(value: unknown): TestPublication {
- if (!isRecord(value) || typeof value.id !== 'string' || typeof value.title !== 'string' ||
- typeof value.ingest_state !== 'string' || !ingestStates.has(value.ingest_state as IngestState) ||
- typeof value.processing_state !== 'string' || !processingStates.has(value.processing_state as ProcessingState) ||
- typeof value.retrieval_state !== 'string' || (value.target_scope !== 'internal' && value.target_scope !== 'external') ||
+ if (
+ !isRecord(value) ||
+ typeof value.id !== 'string' ||
+ typeof value.title !== 'string' ||
+ typeof value.ingest_state !== 'string' ||
+ !ingestStates.has(value.ingest_state as IngestState) ||
+ typeof value.processing_state !== 'string' ||
+ !processingStates.has(value.processing_state as ProcessingState) ||
+ typeof value.retrieval_state !== 'string' ||
+ (value.target_scope !== 'internal' && value.target_scope !== 'external') ||
typeof value.change_set_id !== 'string' ||
- (value.change_type !== 'added' && value.change_type !== 'updated') || typeof value.can_start !== 'boolean' ||
- !isCount(value.retry_count) || typeof value.last_error !== 'string' || typeof value.can_retry !== 'boolean' || typeof value.updated_at !== 'string') {
+ (value.change_type !== 'added' && value.change_type !== 'updated') ||
+ typeof value.can_start !== 'boolean' ||
+ !isCount(value.retry_count) ||
+ typeof value.last_error !== 'string' ||
+ typeof value.can_retry !== 'boolean' ||
+ typeof value.updated_at !== 'string'
+ ) {
throw new Error('invalid test publication response')
}
return {
@@ -83,7 +104,13 @@ function parseTestPublication(value: unknown): TestPublication {
}
export function parseTestPublicationsResponse(value: unknown): TestPublicationPage {
- if (!isRecord(value) || !Array.isArray(value.data) || !isCount(value.total) || !isCount(value.limit) || !isCount(value.offset)) {
+ if (
+ !isRecord(value) ||
+ !Array.isArray(value.data) ||
+ !isCount(value.total) ||
+ !isCount(value.limit) ||
+ !isCount(value.offset)
+ ) {
throw new Error('invalid test publications response')
}
return {
@@ -94,14 +121,24 @@ export function parseTestPublicationsResponse(value: unknown): TestPublicationPa
}
}
-export async function fetchTestPublications(query: TestPublicationQuery, signal?: AbortSignal): Promise {
+export async function fetchTestPublications(
+ query: TestPublicationQuery,
+ signal?: AbortSignal
+): Promise {
const parameters = new URLSearchParams()
- if (query.processing_state) parameters.set('processing_state', Array.isArray(query.processing_state) ? query.processing_state.join(',') : query.processing_state)
+ if (query.processing_state)
+ parameters.set(
+ 'processing_state',
+ Array.isArray(query.processing_state) ? query.processing_state.join(',') : query.processing_state
+ )
if (query.target_scope) parameters.set('target_scope', query.target_scope)
if (query.removal) parameters.set('removal', query.removal)
parameters.set('limit', String(query.limit))
parameters.set('offset', String(query.offset))
- const response = await fetch(`/api/v1/test-publications?${parameters.toString()}`, { headers: { Accept: 'application/json' }, signal })
+ const response = await fetch(`/api/v1/test-publications?${parameters.toString()}`, {
+ headers: { Accept: 'application/json' },
+ signal,
+ })
if (!response.ok) throw new Error(`加载测试发布失败(HTTP ${response.status})`)
return parseTestPublicationsResponse(await response.json())
}
@@ -127,28 +164,37 @@ export async function startPublicationRemoval(publicationId: string): Promise<{
const body = (await response.json().catch(() => null)) as { error?: string } | null
throw new Error(body?.error ?? `触发下架失败(HTTP ${response.status})`)
}
- const value = await response.json() as unknown
+ const value = (await response.json()) as unknown
if (!isRecord(value) || typeof value.job_id !== 'string') throw new Error('下架响应无效')
return { job_id: value.job_id }
}
export async function fetchAcceptanceResults(publicationId: string, signal?: AbortSignal): Promise {
- const response = await fetch(`/api/v1/test-publications/${encodeURIComponent(publicationId)}/acceptance`, { headers: { Accept: 'application/json' }, signal })
+ const response = await fetch(`/api/v1/test-publications/${encodeURIComponent(publicationId)}/acceptance`, {
+ headers: { Accept: 'application/json' },
+ signal,
+ })
if (!response.ok) throw new Error(`加载验收结果失败(HTTP ${response.status})`)
- const value = await response.json() as unknown
+ const value = (await response.json()) as unknown
if (!isRecord(value) || !Array.isArray(value.data)) throw new Error('invalid acceptance response')
return value.data as AcceptanceRecord[]
}
export async function retryPublication(publicationId: string): Promise {
- const response = await fetch(`/api/v1/test-publications/${encodeURIComponent(publicationId)}/retry`, { method: 'POST', headers: { Accept: 'application/json', 'Idempotency-Key': newOperationID() } })
+ const response = await fetch(`/api/v1/test-publications/${encodeURIComponent(publicationId)}/retry`, {
+ method: 'POST',
+ headers: { Accept: 'application/json', 'Idempotency-Key': newOperationID() },
+ })
if (!response.ok) {
const body = (await response.json().catch(() => null)) as { error?: string } | null
throw new Error(body?.error ?? `重试失败(HTTP ${response.status})`)
}
}
-export async function startTestPublications(publicationIds: string[], reason: string): Promise {
+export async function startTestPublications(
+ publicationIds: string[],
+ reason: string
+): Promise {
const response = await fetch('/api/v1/test-publications/start', {
method: 'POST',
headers: { Accept: 'application/json', 'Content-Type': 'application/json', 'Idempotency-Key': newOperationID() },
@@ -158,8 +204,15 @@ export async function startTestPublications(publicationIds: string[], reason: st
const body = (await response.json().catch(() => null)) as { error?: string } | null
throw new Error(body?.error ?? `开始处理失败(HTTP ${response.status})`)
}
- const value = await response.json() as unknown
- if (!isRecord(value) || !isRecord(value.data) || !isCount(value.data.selected) || !isCount(value.data.started) || !isCount(value.data.already_started) || !isCount(value.data.unavailable)) {
+ const value = (await response.json()) as unknown
+ if (
+ !isRecord(value) ||
+ !isRecord(value.data) ||
+ !isCount(value.data.selected) ||
+ !isCount(value.data.started) ||
+ !isCount(value.data.already_started) ||
+ !isCount(value.data.unavailable)
+ ) {
throw new Error('invalid processing start response')
}
return {
@@ -169,10 +222,24 @@ export async function startTestPublications(publicationIds: string[], reason: st
unavailable: value.data.unavailable,
}
}
-export interface RetryOutcome { publication_id: string; result: 'started' | 'already_running' | 'not_retryable'; detail?: string }
-export interface RetryPublicationsBatchResult { batch_key: string; total: number; started: number; already_running: number; not_retryable: number; outcomes: RetryOutcome[] }
+export interface RetryOutcome {
+ publication_id: string
+ result: 'started' | 'already_running' | 'not_retryable'
+ detail?: string
+}
+export interface RetryPublicationsBatchResult {
+ batch_key: string
+ total: number
+ started: number
+ already_running: number
+ not_retryable: number
+ outcomes: RetryOutcome[]
+}
-export async function retryPublicationsBatch(publicationIds: string[], reason: string): Promise {
+export async function retryPublicationsBatch(
+ publicationIds: string[],
+ reason: string
+): Promise {
const response = await fetch('/api/v1/test-publications/retry-batch', {
method: 'POST',
headers: { Accept: 'application/json', 'Content-Type': 'application/json', 'Idempotency-Key': newOperationID() },
@@ -182,8 +249,9 @@ export async function retryPublicationsBatch(publicationIds: string[], reason: s
const body = (await response.json().catch(() => null)) as { error?: string } | null
throw new Error(body?.error ?? `批量重试失败(HTTP ${response.status})`)
}
- const value = await response.json() as unknown
- if (!isRecord(value) || !isRecord(value.data) || !Array.isArray(value.data.outcomes)) throw new Error('批量重试响应无效')
+ const value = (await response.json()) as unknown
+ if (!isRecord(value) || !isRecord(value.data) || !Array.isArray(value.data.outcomes))
+ throw new Error('批量重试响应无效')
const data = value.data as Record
const outcomes = Array.isArray(data.outcomes) ? data.outcomes : []
return {
@@ -194,15 +262,37 @@ export async function retryPublicationsBatch(publicationIds: string[], reason: s
not_retryable: isCount(data.not_retryable) ? data.not_retryable : 0,
outcomes: outcomes.filter(isRecord).map((item) => ({
publication_id: typeof item.publication_id === 'string' ? item.publication_id : '',
- result: item.result === 'started' || item.result === 'already_running' || item.result === 'not_retryable' ? item.result : 'not_retryable',
+ result:
+ item.result === 'started' || item.result === 'already_running' || item.result === 'not_retryable'
+ ? item.result
+ : 'not_retryable',
detail: typeof item.detail === 'string' ? item.detail : undefined,
})),
}
}
-export interface JobFailure { id: string; job_id: string; job_type: string; publication_id?: string; source_item_id?: string; error_code: string; attempt: number; max_attempts_at_failure: number; change_set_id?: string; action?: string; target_kb_id?: string; state: 'open' | 'requeued' | 'abandoned'; created_at: string; doc_name?: string; last_error?: string }
+export interface JobFailure {
+ id: string
+ job_id: string
+ job_type: string
+ publication_id?: string
+ source_item_id?: string
+ error_code: string
+ attempt: number
+ max_attempts_at_failure: number
+ change_set_id?: string
+ action?: string
+ target_kb_id?: string
+ state: 'open' | 'requeued' | 'abandoned'
+ created_at: string
+ doc_name?: string
+ last_error?: string
+}
-export async function cancelPublicationsBatch(publicationIds: string[], reason: string): Promise {
+export async function cancelPublicationsBatch(
+ publicationIds: string[],
+ reason: string
+): Promise {
const response = await fetch('/api/v1/test-publications/cancel-batch', {
method: 'POST',
headers: { Accept: 'application/json', 'Content-Type': 'application/json', 'Idempotency-Key': newOperationID() },
@@ -212,8 +302,9 @@ export async function cancelPublicationsBatch(publicationIds: string[], reason:
const body = (await response.json().catch(() => null)) as { error?: string } | null
throw new Error(body?.error ?? `批量取消失败(HTTP ${response.status})`)
}
- const value = await response.json() as unknown
- if (!isRecord(value) || !isRecord(value.data) || !Array.isArray(value.data.outcomes)) throw new Error('批量取消响应无效')
+ const value = (await response.json()) as unknown
+ if (!isRecord(value) || !isRecord(value.data) || !Array.isArray(value.data.outcomes))
+ throw new Error('批量取消响应无效')
const data = value.data as Record
const outcomes = Array.isArray(data.outcomes) ? data.outcomes : []
return {
@@ -224,21 +315,34 @@ export async function cancelPublicationsBatch(publicationIds: string[], reason:
not_retryable: isCount(data.not_retryable) ? data.not_retryable : 0,
outcomes: outcomes.filter(isRecord).map((item) => ({
publication_id: typeof item.publication_id === 'string' ? item.publication_id : '',
- result: item.result === 'started' || item.result === 'already_running' || item.result === 'not_retryable' ? item.result : 'not_retryable',
+ result:
+ item.result === 'started' || item.result === 'already_running' || item.result === 'not_retryable'
+ ? item.result
+ : 'not_retryable',
detail: typeof item.detail === 'string' ? item.detail : undefined,
})),
}
}
export async function fetchJobFailures(signal?: AbortSignal): Promise {
- const response = await fetch('/api/v1/job-failures?state=open&limit=50', { headers: { Accept: 'application/json' }, signal })
+ const response = await fetch('/api/v1/job-failures?state=open&limit=50', {
+ headers: { Accept: 'application/json' },
+ signal,
+ })
if (!response.ok) throw new Error(`加载失败队列失败(HTTP ${response.status})`)
- const value = await response.json() as unknown
+ const value = (await response.json()) as unknown
if (!isRecord(value) || !isRecord(value.data) || !Array.isArray(value.data.items)) throw new Error('失败队列响应无效')
return value.data.items as JobFailure[]
}
-export interface RequeuedArtifactFetch { failure_id: string; job_id: string; version_id: string; round: number; doc_name?: string; already_queued: boolean }
+export interface RequeuedArtifactFetch {
+ failure_id: string
+ job_id: string
+ version_id: string
+ round: number
+ doc_name?: string
+ already_queued: boolean
+}
// 源头取件死信重排:取件任务的幂等键绑在版本上,失败那次已占住它,所以重新标注
// 同一文件不会再入队;这里让后端按新轮次重新排队(新键),并复位意图状态。
@@ -252,7 +356,7 @@ export async function requeueJobFailure(failureId: string, reason: string): Prom
const body = (await response.json().catch(() => null)) as { error?: string } | null
throw new Error(body?.error ?? `重新取件失败(HTTP ${response.status})`)
}
- const value = await response.json() as unknown
+ const value = (await response.json()) as unknown
if (!isRecord(value) || !isRecord(value.data)) throw new Error('重新取件响应无效')
const data = value.data as Record
return {
diff --git a/web/src/uiSettings.test.ts b/web/src/uiSettings.test.ts
index 8443d58..d0d5346 100644
--- a/web/src/uiSettings.test.ts
+++ b/web/src/uiSettings.test.ts
@@ -1,69 +1,166 @@
import { describe, expect, it, vi } from 'vitest'
-import { activateUISettings, createUISettingsCandidate, fetchActiveUISettings, parseServiceTargetVerification, parseUISettingsResponse, settingsRefreshDelays, UISettingsError } from './uiSettings'
+import {
+ activateUISettings,
+ createUISettingsCandidate,
+ fetchActiveUISettings,
+ parseServiceTargetVerification,
+ parseUISettingsResponse,
+ settingsRefreshDelays,
+ UISettingsError,
+} from './uiSettings'
const candidateInput = {
- document_refresh_seconds: 15, resource_refresh_seconds: 30, stalled_after_seconds: 1800,
- finalizing_after_seconds: 7200, ingestion_concurrency: 2,
- test_weknora_base_url: '', production_weknora_base_url: '', production_tenant_id: '', production_knowledge_base_id: '',
+ document_refresh_seconds: 15,
+ resource_refresh_seconds: 30,
+ stalled_after_seconds: 1800,
+ finalizing_after_seconds: 7200,
+ ingestion_concurrency: 2,
+ test_weknora_base_url: '',
+ production_weknora_base_url: '',
+ production_tenant_id: '',
+ production_knowledge_base_id: '',
} as const
const active = {
- id: 'settings-default', state: 'active', document_refresh_seconds: 15,
- resource_refresh_seconds: 30, stalled_after_seconds: 1800,
- finalizing_after_seconds: 7200, ingestion_concurrency: 2, production_weknora_base_url: '',
- test_weknora_base_url: '', production_tenant_id: '', production_knowledge_base_id: '',
+ id: 'settings-default',
+ state: 'active',
+ document_refresh_seconds: 15,
+ resource_refresh_seconds: 30,
+ stalled_after_seconds: 1800,
+ finalizing_after_seconds: 7200,
+ ingestion_concurrency: 2,
+ production_weknora_base_url: '',
+ test_weknora_base_url: '',
+ production_tenant_id: '',
+ production_knowledge_base_id: '',
updated_by: 'system:migration',
- created_at: '2026-09-10T08:00:00Z', activated_at: '2026-09-10T08:00:00Z',
- retired_at: null, error_code: '',
+ created_at: '2026-09-10T08:00:00Z',
+ activated_at: '2026-09-10T08:00:00Z',
+ retired_at: null,
+ error_code: '',
}
describe('UI settings API', () => {
it('strictly rebuilds the safe revision projection', () => {
expect(parseUISettingsResponse({ data: { ...active, secret: 'drop' } })).toEqual(active)
- expect(() => parseUISettingsResponse({ data: { ...active, document_refresh_seconds: 31 } })).toThrow('invalid UI settings response')
+ expect(() => parseUISettingsResponse({ data: { ...active, document_refresh_seconds: 31 } })).toThrow(
+ 'invalid UI settings response'
+ )
})
it('accepts only the four service fields with their own rules', () => {
- const parsed = parseUISettingsResponse({ data: { ...active, test_weknora_base_url: 'https://weknora.test.example:8443/kb', production_tenant_id: '10000', production_knowledge_base_id: 'kb-abc_123' } })
+ const parsed = parseUISettingsResponse({
+ data: {
+ ...active,
+ test_weknora_base_url: 'https://weknora.test.example:8443/kb',
+ production_tenant_id: '10000',
+ production_knowledge_base_id: 'kb-abc_123',
+ },
+ })
expect(parsed.test_weknora_base_url).toBe('https://weknora.test.example:8443/kb')
expect(parsed.production_tenant_id).toBe('10000')
expect(parsed.production_knowledge_base_id).toBe('kb-abc_123')
- for (const bad of ['weknora.test.example', 'https://user:pass@weknora.test.example', 'https://weknora.test.example?token=abc'])
- expect(() => parseUISettingsResponse({ data: { ...active, test_weknora_base_url: bad } })).toThrow('invalid UI settings response')
+ for (const bad of [
+ 'weknora.test.example',
+ 'https://user:pass@weknora.test.example',
+ 'https://weknora.test.example?token=abc',
+ ])
+ expect(() => parseUISettingsResponse({ data: { ...active, test_weknora_base_url: bad } })).toThrow(
+ 'invalid UI settings response'
+ )
for (const bad of ['abc', '10 000', '-1', '0', '10000000000000000000000', '10000.5'])
- expect(() => parseUISettingsResponse({ data: { ...active, production_tenant_id: bad } })).toThrow('invalid UI settings response')
+ expect(() => parseUISettingsResponse({ data: { ...active, production_tenant_id: bad } })).toThrow(
+ 'invalid UI settings response'
+ )
for (const bad of ['kb/abc', 'kb abc', 'kb-abc?x', 'https://weknora.prod.example/kb'])
- expect(() => parseUISettingsResponse({ data: { ...active, production_knowledge_base_id: bad } })).toThrow('invalid UI settings response')
+ expect(() => parseUISettingsResponse({ data: { ...active, production_knowledge_base_id: bad } })).toThrow(
+ 'invalid UI settings response'
+ )
})
it('keeps a web-configured production endpoint only when it is a bare http(s) origin', () => {
- expect(parseUISettingsResponse({ data: { ...active, production_weknora_base_url: 'https://weknora.prod.example:8443/kb' } }).production_weknora_base_url).toBe('https://weknora.prod.example:8443/kb')
- for (const bad of ['weknora.prod.example', 'https://user:pass@weknora.prod.example', 'https://weknora.prod.example?token=abc', 'https://weknora.prod.example#frag', ' https://weknora.prod.example'])
- expect(() => parseUISettingsResponse({ data: { ...active, production_weknora_base_url: bad } })).toThrow('invalid UI settings response')
+ expect(
+ parseUISettingsResponse({
+ data: { ...active, production_weknora_base_url: 'https://weknora.prod.example:8443/kb' },
+ }).production_weknora_base_url
+ ).toBe('https://weknora.prod.example:8443/kb')
+ for (const bad of [
+ 'weknora.prod.example',
+ 'https://user:pass@weknora.prod.example',
+ 'https://weknora.prod.example?token=abc',
+ 'https://weknora.prod.example#frag',
+ ' https://weknora.prod.example',
+ ])
+ expect(() => parseUISettingsResponse({ data: { ...active, production_weknora_base_url: bad } })).toThrow(
+ 'invalid UI settings response'
+ )
})
it('converts the active revision into independent monitor delays', () => {
- expect(settingsRefreshDelays({ ...active, document_refresh_seconds: 30, resource_refresh_seconds: 60 })).toEqual({ documentMs: 30000, resourceMs: 60000 })
+ expect(settingsRefreshDelays({ ...active, document_refresh_seconds: 30, resource_refresh_seconds: 60 })).toEqual({
+ documentMs: 30000,
+ resourceMs: 60000,
+ })
})
it('creates a validated candidate then activates it through fixed endpoints', async () => {
- const candidate = { ...active, id: 'uis-candidate', state: 'validated', updated_by: 'engineer-1', activated_at: null }
- const fetchMock = vi.fn()
+ const candidate = {
+ ...active,
+ id: 'uis-candidate',
+ state: 'validated',
+ updated_by: 'engineer-1',
+ activated_at: null,
+ }
+ const fetchMock = vi
+ .fn()
.mockResolvedValueOnce(new Response(JSON.stringify({ data: active }), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify({ data: candidate }), { status: 201 }))
- .mockResolvedValueOnce(new Response(JSON.stringify({ data: { ...candidate, state: 'active', activated_at: '2026-09-10T08:01:00Z' } }), { status: 200 }))
+ .mockResolvedValueOnce(
+ new Response(
+ JSON.stringify({ data: { ...candidate, state: 'active', activated_at: '2026-09-10T08:01:00Z' } }),
+ { status: 200 }
+ )
+ )
vi.stubGlobal('fetch', fetchMock)
await fetchActiveUISettings()
- await createUISettingsCandidate({ document_refresh_seconds: 30, resource_refresh_seconds: 60, stalled_after_seconds: 3600, finalizing_after_seconds: 14400, ingestion_concurrency: 3, test_weknora_base_url: '', production_weknora_base_url: '', production_tenant_id: '', production_knowledge_base_id: '' })
+ await createUISettingsCandidate({
+ document_refresh_seconds: 30,
+ resource_refresh_seconds: 60,
+ stalled_after_seconds: 3600,
+ finalizing_after_seconds: 14400,
+ ingestion_concurrency: 3,
+ test_weknora_base_url: '',
+ production_weknora_base_url: '',
+ production_tenant_id: '',
+ production_knowledge_base_id: '',
+ })
await activateUISettings('uis-candidate')
expect(fetchMock.mock.calls[0][0]).toBe('/api/v1/ui-settings')
expect(fetchMock.mock.calls[1][0]).toBe('/api/v1/ui-settings/candidates')
- expect(fetchMock.mock.calls[1][1]).toMatchObject({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ document_refresh_seconds: 30, resource_refresh_seconds: 60, stalled_after_seconds: 3600, finalizing_after_seconds: 14400, ingestion_concurrency: 3, test_weknora_base_url: '', production_weknora_base_url: '', production_tenant_id: '', production_knowledge_base_id: '' }) })
+ expect(fetchMock.mock.calls[1][1]).toMatchObject({
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ document_refresh_seconds: 30,
+ resource_refresh_seconds: 60,
+ stalled_after_seconds: 3600,
+ finalizing_after_seconds: 14400,
+ ingestion_concurrency: 3,
+ test_weknora_base_url: '',
+ production_weknora_base_url: '',
+ production_tenant_id: '',
+ production_knowledge_base_id: '',
+ }),
+ })
expect(fetchMock.mock.calls[2][0]).toBe('/api/v1/ui-settings/uis-candidate/activate')
- expect(fetchMock.mock.calls[2][1]).toMatchObject({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' })
+ expect(fetchMock.mock.calls[2][1]).toMatchObject({
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: '{}',
+ })
vi.unstubAllGlobals()
})
@@ -71,13 +168,23 @@ describe('UI settings API', () => {
it('returns the real verification block produced while saving', async () => {
const candidate = { ...active, id: 'uis-candidate', state: 'validated', activated_at: null }
const verification = {
- status: 'verified', message: '已核验:生产地址上存在知识库「生产知识库」,租户 10000 一致', blocked: false,
- knowledge_base_name: '生产知识库', observed_tenant_id: '10000',
+ status: 'verified',
+ message: '已核验:生产地址上存在知识库「生产知识库」,租户 10000 一致',
+ blocked: false,
+ knowledge_base_name: '生产知识库',
+ observed_tenant_id: '10000',
test_endpoint: { status: 'reachable', message: '测试地址可达(可列出 7 个知识库)' },
production_endpoint: { status: 'reachable', message: '生产地址可达(可列出 7 个知识库)' },
checked_at: '2026-09-18T09:00:00Z',
}
- vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({ data: candidate, operation: 'created', verification }), { status: 201 })))
+ vi.stubGlobal(
+ 'fetch',
+ vi
+ .fn()
+ .mockResolvedValue(
+ new Response(JSON.stringify({ data: candidate, operation: 'created', verification }), { status: 201 })
+ )
+ )
const saved = await createUISettingsCandidate(candidateInput)
expect(saved.settings.id).toBe('uis-candidate')
expect(saved.verification).toEqual(verification)
@@ -87,13 +194,27 @@ describe('UI settings API', () => {
// 被拒绝时保留服务器那句话,并带上核验详情,而不是退化成 "request failed (400)"。
it('keeps a refusal sentence and its detail instead of a status code', async () => {
const verification = {
- status: 'missing', message: '未通过:生产地址上找不到知识库 ID「kb-prod-1」;该地址现有 1 个知识库:生产知识库(kb-prod-1)', blocked: true,
- knowledge_base_name: '', observed_tenant_id: '',
+ status: 'missing',
+ message: '未通过:生产地址上找不到知识库 ID「kb-prod-1」;该地址现有 1 个知识库:生产知识库(kb-prod-1)',
+ blocked: true,
+ knowledge_base_name: '',
+ observed_tenant_id: '',
test_endpoint: { status: 'reachable', message: '测试地址可达' },
production_endpoint: { status: 'reachable', message: '生产地址可达' },
checked_at: '2026-09-18T09:00:00Z',
}
- vi.stubGlobal('fetch', vi.fn().mockImplementation(() => Promise.resolve(new Response(JSON.stringify({ error: '未通过:生产地址上找不到知识库 ID「kb-prod-1」', verification }), { status: 400 }))))
+ vi.stubGlobal(
+ 'fetch',
+ vi
+ .fn()
+ .mockImplementation(() =>
+ Promise.resolve(
+ new Response(JSON.stringify({ error: '未通过:生产地址上找不到知识库 ID「kb-prod-1」', verification }), {
+ status: 400,
+ })
+ )
+ )
+ )
await expect(createUISettingsCandidate(candidateInput)).rejects.toThrow('找不到知识库 ID')
const failure = await createUISettingsCandidate(candidateInput).catch((cause: unknown) => cause)
expect(failure).toBeInstanceOf(UISettingsError)
@@ -106,9 +227,17 @@ describe('UI settings API', () => {
it('treats a missing or malformed verification block as no verification', () => {
expect(parseServiceTargetVerification(undefined)).toBeNull()
expect(parseServiceTargetVerification({ status: 'verified' })).toBeNull()
- expect(parseServiceTargetVerification({ status: 'verified', message: 'x', blocked: false, test_endpoint: 'nope' })).toEqual({
- status: 'verified', message: 'x', blocked: false, knowledge_base_name: '', observed_tenant_id: '',
- test_endpoint: { status: '', message: '' }, production_endpoint: { status: '', message: '' }, checked_at: '',
+ expect(
+ parseServiceTargetVerification({ status: 'verified', message: 'x', blocked: false, test_endpoint: 'nope' })
+ ).toEqual({
+ status: 'verified',
+ message: 'x',
+ blocked: false,
+ knowledge_base_name: '',
+ observed_tenant_id: '',
+ test_endpoint: { status: '', message: '' },
+ production_endpoint: { status: '', message: '' },
+ checked_at: '',
})
})
})
diff --git a/web/src/uiSettings.ts b/web/src/uiSettings.ts
index ee726b3..b2fc49d 100644
--- a/web/src/uiSettings.ts
+++ b/web/src/uiSettings.ts
@@ -98,7 +98,9 @@ export function validKnowledgeBaseID(value: string): boolean {
return !/[\x00\r\n\t /?#\\]/.test(value)
}
-export function settingsRefreshDelays(settings: Pick) {
+export function settingsRefreshDelays(
+ settings: Pick
+) {
return { documentMs: settings.document_refresh_seconds * 1000, resourceMs: settings.resource_refresh_seconds * 1000 }
}
@@ -107,25 +109,43 @@ const documentRefresh = new Set([15, 30, 60])
const resourceRefresh = new Set([30, 60])
const stalledAfter = new Set([1800, 3600])
const finalizingAfter = new Set([7200, 14400])
-const isRecord = (value: unknown): value is Record => Boolean(value) && typeof value === 'object' && !Array.isArray(value)
+const isRecord = (value: unknown): value is Record =>
+ Boolean(value) && typeof value === 'object' && !Array.isArray(value)
export function parseUISettingsResponse(value: unknown): UISettings {
if (!isRecord(value) || !isRecord(value.data)) throw new Error('invalid UI settings response')
const item = value.data
- if (typeof item.id !== 'string' || !/^[A-Za-z0-9._:-]{2,36}$/.test(item.id) ||
- typeof item.state !== 'string' || !states.has(item.state) ||
- typeof item.document_refresh_seconds !== 'number' || !documentRefresh.has(item.document_refresh_seconds) ||
- typeof item.resource_refresh_seconds !== 'number' || !resourceRefresh.has(item.resource_refresh_seconds) ||
- typeof item.stalled_after_seconds !== 'number' || !stalledAfter.has(item.stalled_after_seconds) ||
- typeof item.finalizing_after_seconds !== 'number' || !finalizingAfter.has(item.finalizing_after_seconds) ||
- typeof item.ingestion_concurrency !== 'number' || !Number.isInteger(item.ingestion_concurrency) || item.ingestion_concurrency < 1 || item.ingestion_concurrency > 16 ||
- typeof item.test_weknora_base_url !== 'string' || !validServiceEndpoint(item.test_weknora_base_url) ||
- typeof item.production_weknora_base_url !== 'string' || !validServiceEndpoint(item.production_weknora_base_url) ||
- typeof item.production_tenant_id !== 'string' || !validProductionTenantID(item.production_tenant_id) ||
- typeof item.production_knowledge_base_id !== 'string' || !validKnowledgeBaseID(item.production_knowledge_base_id) ||
- typeof item.updated_by !== 'string' || typeof item.created_at !== 'string' ||
- (item.activated_at !== null && typeof item.activated_at !== 'string') ||
- (item.retired_at !== null && typeof item.retired_at !== 'string') || typeof item.error_code !== 'string') {
+ if (
+ typeof item.id !== 'string' ||
+ !/^[A-Za-z0-9._:-]{2,36}$/.test(item.id) ||
+ typeof item.state !== 'string' ||
+ !states.has(item.state) ||
+ typeof item.document_refresh_seconds !== 'number' ||
+ !documentRefresh.has(item.document_refresh_seconds) ||
+ typeof item.resource_refresh_seconds !== 'number' ||
+ !resourceRefresh.has(item.resource_refresh_seconds) ||
+ typeof item.stalled_after_seconds !== 'number' ||
+ !stalledAfter.has(item.stalled_after_seconds) ||
+ typeof item.finalizing_after_seconds !== 'number' ||
+ !finalizingAfter.has(item.finalizing_after_seconds) ||
+ typeof item.ingestion_concurrency !== 'number' ||
+ !Number.isInteger(item.ingestion_concurrency) ||
+ item.ingestion_concurrency < 1 ||
+ item.ingestion_concurrency > 16 ||
+ typeof item.test_weknora_base_url !== 'string' ||
+ !validServiceEndpoint(item.test_weknora_base_url) ||
+ typeof item.production_weknora_base_url !== 'string' ||
+ !validServiceEndpoint(item.production_weknora_base_url) ||
+ typeof item.production_tenant_id !== 'string' ||
+ !validProductionTenantID(item.production_tenant_id) ||
+ typeof item.production_knowledge_base_id !== 'string' ||
+ !validKnowledgeBaseID(item.production_knowledge_base_id) ||
+ typeof item.updated_by !== 'string' ||
+ typeof item.created_at !== 'string' ||
+ (item.activated_at !== null && typeof item.activated_at !== 'string') ||
+ (item.retired_at !== null && typeof item.retired_at !== 'string') ||
+ typeof item.error_code !== 'string'
+ ) {
throw new Error('invalid UI settings response')
}
return {
@@ -149,13 +169,20 @@ export function parseUISettingsResponse(value: unknown): UISettings {
}
function parseEndpointStatus(value: unknown): ServiceEndpointStatus {
- if (!isRecord(value) || typeof value.status !== 'string' || typeof value.message !== 'string') return { status: '', message: '' }
+ if (!isRecord(value) || typeof value.status !== 'string' || typeof value.message !== 'string')
+ return { status: '', message: '' }
return { status: value.status, message: value.message }
}
// 核验块是诚实声明,解析失败按"没有核验"处理,绝不编造"已核验"。
export function parseServiceTargetVerification(value: unknown): ServiceTargetVerification | null {
- if (!isRecord(value) || typeof value.status !== 'string' || typeof value.message !== 'string' || typeof value.blocked !== 'boolean') return null
+ if (
+ !isRecord(value) ||
+ typeof value.status !== 'string' ||
+ typeof value.message !== 'string' ||
+ typeof value.blocked !== 'boolean'
+ )
+ return null
return {
status: value.status,
message: value.message,
@@ -176,7 +203,10 @@ async function readPayload(response: Response): Promise>
// 拒绝保存时服务器给的就是要展示的那句话,别再退化成状态码。
const failure: unknown = await response.json().catch(() => null)
const record = isRecord(failure) ? failure : {}
- const message = typeof record.error === 'string' && record.error.trim() !== '' ? record.error : `UI settings request failed (${response.status})`
+ const message =
+ typeof record.error === 'string' && record.error.trim() !== ''
+ ? record.error
+ : `UI settings request failed (${response.status})`
throw new UISettingsError(message, parseServiceTargetVerification(record.verification))
}
@@ -186,7 +216,10 @@ async function readSettings(response: Response): Promise {
async function readSettingsSave(response: Response): Promise {
const payload = await readPayload(response)
- return { settings: parseUISettingsResponse(payload), verification: parseServiceTargetVerification(payload.verification) }
+ return {
+ settings: parseUISettingsResponse(payload),
+ verification: parseServiceTargetVerification(payload.verification),
+ }
}
export async function fetchActiveUISettings(signal?: AbortSignal): Promise {
@@ -194,14 +227,30 @@ export async function fetchActiveUISettings(signal?: AbortSignal): Promise {
- return readSettingsSave(await fetch('/api/v1/ui-settings/candidates', {
- method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Governance-Action': 'ui-settings', 'Idempotency-Key': newOperationID() }, body: JSON.stringify(input),
- }))
+ return readSettingsSave(
+ await fetch('/api/v1/ui-settings/candidates', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-Governance-Action': 'ui-settings',
+ 'Idempotency-Key': newOperationID(),
+ },
+ body: JSON.stringify(input),
+ })
+ )
}
export async function activateUISettings(revisionID: string): Promise {
if (!/^[A-Za-z0-9._:-]{2,36}$/.test(revisionID)) throw new Error('invalid UI settings revision')
- return readSettingsSave(await fetch(`/api/v1/ui-settings/${encodeURIComponent(revisionID)}/activate`, {
- method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Governance-Action': 'ui-settings', 'Idempotency-Key': newOperationID() }, body: '{}',
- }))
-}
\ No newline at end of file
+ return readSettingsSave(
+ await fetch(`/api/v1/ui-settings/${encodeURIComponent(revisionID)}/activate`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-Governance-Action': 'ui-settings',
+ 'Idempotency-Key': newOperationID(),
+ },
+ body: '{}',
+ })
+ )
+}
diff --git a/web/src/versionHistory.ts b/web/src/versionHistory.ts
index 4a38fc4..d7058ec 100644
--- a/web/src/versionHistory.ts
+++ b/web/src/versionHistory.ts
@@ -133,7 +133,18 @@ export async function fetchVersionHistory(
if (response.status === 409 || code === 'cursor_expired') {
throw new Error('这次翻页依据的记录快照已被清理,请刷新页面重新加载。')
}
- throw new Error(`加载版本记录失败(HTTP ${response.status})`)
+ // 把服务端的 code/message 带出来:否则页面只能报状态码,用户无法区分
+ // "未配置"、"同步进行中导致 500" 和真正的后端故障。
+ let detail = ''
+ try {
+ const body = await response.json()
+ const message = record(body) ? text(body.error) : ''
+ const code = record(body) ? text(body.code) : ''
+ if (message || code) detail = `:${message || '无错误描述'}${code ? `(${code})` : ''}`
+ } catch {
+ detail = ''
+ }
+ throw new Error(`加载版本记录失败(HTTP ${response.status})${detail}`)
}
if (!record(value) || !Array.isArray(value.items) || !record(value.sync) || !record(value.current_running)) {
throw new Error('版本记录响应无效')
diff --git a/web/src/weknoraMonitor.css b/web/src/weknoraMonitor.css
index 241df89..b93b676 100644
--- a/web/src/weknoraMonitor.css
+++ b/web/src/weknoraMonitor.css
@@ -1,68 +1,992 @@
-.weknora-monitor{max-width:1800px}.simulation-banner{display:flex;align-items:center;gap:14px;margin-bottom:14px;border:1px solid var(--c-warn);border-radius:10px;padding:10px 14px;background:var(--c-warn-bg);color:var(--c-warn)}.simulation-banner strong{padding:4px 7px;border-radius:5px;background:var(--c-warn);color:var(--c-warn);font-size:12px}.simulation-banner span{font-size:12px}.monitor-health{display:grid;grid-template-columns:repeat(6,minmax(0,1fr));gap:10px;margin-bottom:16px}.monitor-health article{display:grid;gap:5px;border:1px solid var(--border-base);border-radius:11px;padding:13px;background:var(--bg-panel)}.monitor-health span{color:var(--ink-mute);font-size:11px}.monitor-health strong{color:var(--ink-strong);font-size:17px}.monitor-health small{color:var(--ink-mute);font-size:9px;line-height:1.5}.monitor-health .service-healthy{box-shadow:inset 0 3px var(--c-done)}.monitor-health .service-degraded,.monitor-health .service-unhealthy{box-shadow:inset 0 3px var(--c-warn)}.monitor-kb-layout{display:grid;grid-template-columns:285px minmax(0,1fr);gap:18px}.monitor-kb-list,.monitor-kb-detail{min-width:0;border:1px solid var(--border-base);border-radius:14px;background:var(--bg-panel)}.monitor-kb-list{padding:12px}.monitor-kb-list h2{margin:5px 8px 12px;color:var(--ink-base);font-size:13px}.monitor-kb-list button{display:flex;width:100%;align-items:center;justify-content:space-between;gap:10px;border:1px solid transparent;border-radius:9px;padding:12px;background:transparent;box-shadow:none;color:var(--ink-base);text-align:left}.monitor-kb-list button.active{border-color:var(--c-run-border);background:var(--c-run-bg);color:var(--c-run)}.monitor-kb-list button>span{display:grid;gap:4px}.monitor-kb-list small{color:var(--ink-mute);font-size:10px}.monitor-kb-list strong{font-size:10px}.monitor-kb-heading{display:flex;align-items:center;justify-content:space-between;gap:15px;border-bottom:1px solid var(--border-base);padding:15px 18px}.monitor-kb-heading>div{display:flex;align-items:center;gap:12px}.monitor-kb-heading h2{margin:0;color:var(--ink-strong);font-size:16px}.monitor-kb-heading span{color:var(--ink-mute);font-size:11px}.monitor-stage-tabs{display:flex;flex-wrap:wrap;gap:7px;padding:13px 18px}.monitor-stage-tabs button{border:1px solid var(--border-base);border-radius:8px;padding:7px 9px;background:var(--bg-soft);box-shadow:none;color:var(--ink-base);font-size:11px}.monitor-stage-tabs button.active{border-color:var(--c-run);background:var(--c-run-bg);color:var(--c-run)}.finalizing-note,.sample-note{margin:0 18px 10px;border-radius:7px;padding:8px 10px;background:var(--bg-soft);color:var(--ink-mute);font-size:10px;line-height:1.6}.sample-note{background:var(--c-warn-bg);color:var(--c-warn)}.monitor-file-wrap{max-height:calc(100vh - 540px);overflow:auto}.monitor-file-table{width:100%;table-layout:fixed}.monitor-file-table thead th{position:sticky;z-index:2;top:0;background:var(--bg-soft)}.monitor-file-table th,.monitor-file-table td{padding:12px 10px;white-space:normal;overflow-wrap:anywhere;font-size:11px}.monitor-file-table th:nth-child(1){width:22%}.monitor-file-table th:nth-child(2){width:20%}.monitor-file-table th:nth-child(3){width:11%}.monitor-file-table th:nth-child(4){width:11%}.monitor-file-table th:nth-child(5){width:9%}.monitor-file-table th:nth-child(6){width:27%}.monitor-file-table small{display:block;margin-top:4px;color:var(--c-warn)}.monitor-status{display:inline-block;border-radius:6px;padding:4px 6px;background:var(--bg-soft);color:var(--ink-mute);white-space:nowrap}.monitor-status.completed{background:var(--c-done-bg);color:var(--c-done)}.monitor-status.finalizing,.monitor-status.processing{background:var(--c-run-bg);color:var(--c-run)}.monitor-status.failed{background:var(--c-warn-bg);color:var(--c-warn)}@media(max-width:1200px){.monitor-health{grid-template-columns:repeat(3,minmax(0,1fr))}.monitor-kb-layout{grid-template-columns:230px minmax(0,1fr)}}@media(max-width:800px){.monitor-health{grid-template-columns:repeat(2,minmax(0,1fr))}.monitor-kb-layout{grid-template-columns:1fr}.monitor-file-table{min-width:800px}.monitor-kb-heading{align-items:flex-start;flex-direction:column}.monitor-kb-heading>div{flex-wrap:wrap}}
-.difference-panel{margin-bottom:18px;overflow:hidden;border:1px solid var(--c-run-border);border-radius:14px;background:var(--bg-panel)}.difference-heading{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:14px 16px;background:var(--c-run-bg)}.difference-heading h2{margin:0 0 4px;color:var(--ink-base);font-size:15px}.difference-heading p{margin:0;color:var(--ink-mute);font-size:10px}.difference-counts{display:flex;gap:7px}.difference-counts span{border-radius:7px;padding:7px 9px;background:var(--c-run-bg);color:var(--c-run);font-size:10px;font-weight:750;white-space:nowrap}.difference-panel details{border-top:1px solid var(--border-base)}.difference-panel summary{cursor:pointer;padding:10px 16px;color:var(--ink-base);font-size:10px;font-weight:750}.difference-table-wrap{max-height:260px;overflow:auto}.difference-table{width:100%;table-layout:fixed}.difference-table th,.difference-table td{padding:9px 12px;white-space:normal;overflow-wrap:anywhere;font-size:10px}.difference-confirmation{display:grid;grid-template-columns:minmax(150px,.6fr) minmax(280px,1.4fr) auto;align-items:end;gap:9px;border-top:1px solid var(--border-base);padding:12px 16px}.difference-confirmation label{display:grid;gap:4px;color:var(--ink-mute);font-size:9px}.difference-confirmation input{border:1px solid var(--border-strong);border-radius:7px;padding:8px;color:var(--ink-base)}.difference-confirmation button{padding:8px 11px}.difference-message{margin:0;border-top:1px solid var(--border-base);padding:9px 16px;background:var(--c-done-bg);color:var(--c-done);font-size:10px}@media(max-width:760px){.difference-heading{align-items:flex-start;flex-direction:column}.difference-counts{flex-wrap:wrap}.difference-confirmation{grid-template-columns:1fr}}
-.persisted-change-panel{margin-bottom:18px;overflow:hidden;border:1px solid var(--c-run-border);border-radius:12px;background:var(--bg-panel)}.persisted-change-heading{display:flex;align-items:center;justify-content:space-between;gap:14px;border-bottom:1px solid var(--border-base);padding:13px 16px;background:var(--bg-soft)}.persisted-change-heading h2{margin:0 0 4px;color:var(--ink-base);font-size:15px}.persisted-change-heading p{margin:0;color:var(--ink-mute);font-size:10px}.persisted-change-heading>span{border-radius:6px;padding:6px 8px;background:var(--c-run-bg);color:var(--c-run);font-size:10px;font-weight:750}.persisted-change-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;padding:12px}.persisted-change-grid>article{min-width:0;border:1px solid var(--border-base);border-radius:9px;padding:11px;background:var(--bg-panel)}.persisted-change-grid>article>div:first-child{display:flex;align-items:center;justify-content:space-between;gap:8px}.persisted-change-grid strong{color:var(--ink-base);font-size:11px}.persisted-change-grid small{color:var(--ink-mute);font-size:9px}.persisted-change-counts{display:flex;flex-wrap:wrap;gap:5px;margin-top:8px}.persisted-change-counts span{border-radius:5px;padding:5px 7px;background:var(--border-soft);color:var(--ink-mute);font-size:9px}.persisted-change-counts span:nth-child(1){background:var(--c-done-bg);color:var(--c-done)}.persisted-change-counts span:nth-child(2),.persisted-change-counts span:nth-child(3){background:var(--c-run-bg);color:var(--c-run)}.persisted-change-counts span:nth-child(4){background:var(--c-fail-bg);color:var(--c-fail)}.persisted-change-grid details{margin-top:8px;border-top:1px solid var(--border-base)}.persisted-change-grid summary{cursor:pointer;padding-top:8px;color:var(--c-run);font-size:9px;font-weight:750}.persisted-change-grid details p{display:grid;grid-template-columns:auto minmax(100px,.7fr) minmax(160px,1.3fr);gap:7px;margin:7px 0 0;align-items:center}.persisted-change-grid details b{border-radius:4px;padding:3px 5px;background:var(--border-soft);color:var(--ink-mute);font-size:8px}.persisted-change-grid details span,.persisted-change-grid details small{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}@media(max-width:900px){.persisted-change-grid{grid-template-columns:1fr}}@media(max-width:600px){.persisted-change-heading{align-items:flex-start;flex-direction:column}.persisted-change-grid details p{grid-template-columns:auto 1fr}.persisted-change-grid details small{grid-column:1/-1}}
-.processing-queue-panel{margin-top:18px;overflow:hidden;border:1px solid var(--border-base);border-radius:14px;background:var(--bg-panel)}.queue-heading{display:flex;align-items:center;justify-content:space-between;gap:16px;border-bottom:1px solid var(--border-base);padding:14px 16px}.queue-heading h2{margin:0 0 4px;color:var(--ink-base);font-size:15px}.queue-heading p{margin:0;color:var(--ink-mute);font-size:10px}.queue-heading>div:last-child{display:flex;align-items:end;gap:8px}.queue-heading label{display:grid;gap:4px;color:var(--ink-mute);font-size:9px}.queue-heading select{border:1px solid var(--border-strong);border-radius:7px;padding:7px;background:var(--bg-panel);color:var(--ink-base)}.queue-heading button{padding:8px 10px;box-shadow:none}.pipeline-strip{overflow-x:auto;border-bottom:1px solid var(--border-base);padding:10px 16px;background:var(--bg-soft);color:var(--ink-base);font-size:10px;font-weight:750;white-space:nowrap}.queue-table-wrap{max-height:420px;overflow:auto}.processing-queue-table{min-width:940px;table-layout:fixed}.processing-queue-table th,.processing-queue-table td{padding:10px 8px;white-space:normal;overflow-wrap:anywhere;font-size:10px}.processing-queue-table th:nth-child(1){width:18%}.processing-queue-table th:nth-child(2){width:13%}.processing-queue-table th:nth-child(3){width:10%}.processing-queue-table th:nth-child(4){width:12%}.processing-queue-table th:nth-child(5){width:17%}.processing-queue-table th:nth-child(6){width:6%}.processing-queue-table th:nth-child(7){width:14%}.processing-queue-table th:nth-child(8){width:10%}.processing-queue-table .queue-problem{background:var(--c-fail-bg)}.processing-queue-table button{padding:6px 8px;box-shadow:none;font-size:9px}.monitor-status.stalled{background:var(--c-warn-bg);color:var(--c-warn)}@media(max-width:700px){.queue-heading{align-items:stretch;flex-direction:column}.queue-heading>div:last-child{align-items:stretch;flex-direction:column}}.changeset-selection{display:flex;flex-direction:column;gap:6px;margin-top:8px}
-.changeset-choice{display:flex;align-items:center;gap:8px;font-size:12px;color:var(--ink-base)}
-.changeset-choice b{flex:0 0 78px;font-weight:700;color:var(--ink-base)}
-.changeset-choice span{overflow-wrap:anywhere}
-.changeset-choice small{color:var(--ink-faint)}
-.changeset-confirm{display:flex;align-items:center;gap:10px;margin-top:10px}
-.changeset-confirm label{display:flex;align-items:center;gap:6px;font-size:11px;color:var(--ink-mute);font-weight:700}
-.changeset-confirm input{border:1px solid var(--border-strong);border-radius:7px;padding:6px 8px;background:var(--bg-panel);color:var(--ink-base);font:inherit}
-.queue-batch-controls{display:inline-flex;align-items:center;gap:8px;margin-left:10px}
-.queue-batch-controls input{width:130px}
-.queue-batch-message{margin:6px 0;color:var(--c-done)}
-.dlq-panel{margin-top:14px}
-.dlq-list{list-style:none;margin:8px 0 0;padding:0;display:flex;flex-direction:column;gap:6px}
-.dlq-list li{display:flex;align-items:center;gap:10px;flex-wrap:wrap;padding:8px 10px;border:1px solid var(--border-strong);border-radius:8px;background:var(--bg-page)}
-.dlq-list li small{flex-basis:100%;margin-left:2px;color:var(--ink-mute)}
-.dlq-list small{margin-left:auto;color:var(--c-dead)}
-.run-metrics{margin-top:14px}
-.run-metrics-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:8px;margin-top:8px}
-.run-metrics-grid article{border:1px solid var(--border-strong);border-radius:8px;padding:8px 10px;background:var(--bg-panel);display:flex;flex-direction:column;gap:2px}
-.run-metrics-grid span{font-size:12px;color:var(--c-dead)}
-.run-metrics-grid strong{font-size:16px}
-.run-metrics-grid small{font-size:12px;color:var(--c-dead)}
+.weknora-monitor {
+ max-width: 1800px;
+}
+.simulation-banner {
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ margin-bottom: 14px;
+ border: 1px solid var(--c-warn);
+ border-radius: 10px;
+ padding: 10px 14px;
+ background: var(--c-warn-bg);
+ color: var(--c-warn);
+}
+.simulation-banner strong {
+ padding: 4px 7px;
+ border-radius: 5px;
+ background: var(--c-warn);
+ color: var(--c-warn);
+ font-size: 12px;
+}
+.simulation-banner span {
+ font-size: 12px;
+}
+.monitor-health {
+ display: grid;
+ grid-template-columns: repeat(6, minmax(0, 1fr));
+ gap: 10px;
+ margin-bottom: 16px;
+}
+.monitor-health article {
+ display: grid;
+ gap: 5px;
+ border: 1px solid var(--border-base);
+ border-radius: 11px;
+ padding: 13px;
+ background: var(--bg-panel);
+}
+.monitor-health span {
+ color: var(--ink-mute);
+ font-size: 11px;
+}
+.monitor-health strong {
+ color: var(--ink-strong);
+ font-size: 17px;
+}
+.monitor-health small {
+ color: var(--ink-mute);
+ font-size: 9px;
+ line-height: 1.5;
+}
+.monitor-health .service-healthy {
+ box-shadow: inset 0 3px var(--c-done);
+}
+.monitor-health .service-degraded,
+.monitor-health .service-unhealthy {
+ box-shadow: inset 0 3px var(--c-warn);
+}
+.monitor-kb-layout {
+ display: grid;
+ grid-template-columns: 285px minmax(0, 1fr);
+ gap: 18px;
+}
+.monitor-kb-list,
+.monitor-kb-detail {
+ min-width: 0;
+ border: 1px solid var(--border-base);
+ border-radius: 14px;
+ background: var(--bg-panel);
+}
+.monitor-kb-list {
+ padding: 12px;
+}
+.monitor-kb-list h2 {
+ margin: 5px 8px 12px;
+ color: var(--ink-base);
+ font-size: 13px;
+}
+.monitor-kb-list button {
+ display: flex;
+ width: 100%;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+ border: 1px solid transparent;
+ border-radius: 9px;
+ padding: 12px;
+ background: transparent;
+ box-shadow: none;
+ color: var(--ink-base);
+ text-align: left;
+}
+.monitor-kb-list button.active {
+ border-color: var(--c-run-border);
+ background: var(--c-run-bg);
+ color: var(--c-run);
+}
+.monitor-kb-list button > span {
+ display: grid;
+ gap: 4px;
+}
+.monitor-kb-list small {
+ color: var(--ink-mute);
+ font-size: 10px;
+}
+.monitor-kb-list strong {
+ font-size: 10px;
+}
+.monitor-kb-heading {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 15px;
+ border-bottom: 1px solid var(--border-base);
+ padding: 15px 18px;
+}
+.monitor-kb-heading > div {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+.monitor-kb-heading h2 {
+ margin: 0;
+ color: var(--ink-strong);
+ font-size: 16px;
+}
+.monitor-kb-heading span {
+ color: var(--ink-mute);
+ font-size: 11px;
+}
+.monitor-stage-tabs {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 7px;
+ padding: 13px 18px;
+}
+.monitor-stage-tabs button {
+ border: 1px solid var(--border-base);
+ border-radius: 8px;
+ padding: 7px 9px;
+ background: var(--bg-soft);
+ box-shadow: none;
+ color: var(--ink-base);
+ font-size: 11px;
+}
+.monitor-stage-tabs button.active {
+ border-color: var(--c-run);
+ background: var(--c-run-bg);
+ color: var(--c-run);
+}
+.finalizing-note,
+.sample-note {
+ margin: 0 18px 10px;
+ border-radius: 7px;
+ padding: 8px 10px;
+ background: var(--bg-soft);
+ color: var(--ink-mute);
+ font-size: 10px;
+ line-height: 1.6;
+}
+.sample-note {
+ background: var(--c-warn-bg);
+ color: var(--c-warn);
+}
+.monitor-file-wrap {
+ max-height: calc(100vh - 540px);
+ overflow: auto;
+}
+.monitor-file-table {
+ width: 100%;
+ table-layout: fixed;
+}
+.monitor-file-table thead th {
+ position: sticky;
+ z-index: 2;
+ top: 0;
+ background: var(--bg-soft);
+}
+.monitor-file-table th,
+.monitor-file-table td {
+ padding: 12px 10px;
+ white-space: normal;
+ overflow-wrap: anywhere;
+ font-size: 11px;
+}
+.monitor-file-table th:nth-child(1) {
+ width: 22%;
+}
+.monitor-file-table th:nth-child(2) {
+ width: 20%;
+}
+.monitor-file-table th:nth-child(3) {
+ width: 11%;
+}
+.monitor-file-table th:nth-child(4) {
+ width: 11%;
+}
+.monitor-file-table th:nth-child(5) {
+ width: 9%;
+}
+.monitor-file-table th:nth-child(6) {
+ width: 27%;
+}
+.monitor-file-table small {
+ display: block;
+ margin-top: 4px;
+ color: var(--c-warn);
+}
+.monitor-status {
+ display: inline-block;
+ border-radius: 6px;
+ padding: 4px 6px;
+ background: var(--bg-soft);
+ color: var(--ink-mute);
+ white-space: nowrap;
+}
+.monitor-status.completed {
+ background: var(--c-done-bg);
+ color: var(--c-done);
+}
+.monitor-status.finalizing,
+.monitor-status.processing {
+ background: var(--c-run-bg);
+ color: var(--c-run);
+}
+.monitor-status.failed {
+ background: var(--c-warn-bg);
+ color: var(--c-warn);
+}
+@media (max-width: 1200px) {
+ .monitor-health {
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ }
+ .monitor-kb-layout {
+ grid-template-columns: 230px minmax(0, 1fr);
+ }
+}
+@media (max-width: 800px) {
+ .monitor-health {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+ .monitor-kb-layout {
+ grid-template-columns: 1fr;
+ }
+ .monitor-file-table {
+ min-width: 800px;
+ }
+ .monitor-kb-heading {
+ align-items: flex-start;
+ flex-direction: column;
+ }
+ .monitor-kb-heading > div {
+ flex-wrap: wrap;
+ }
+}
+.difference-panel {
+ margin-bottom: 18px;
+ overflow: hidden;
+ border: 1px solid var(--c-run-border);
+ border-radius: 14px;
+ background: var(--bg-panel);
+}
+.difference-heading {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+ padding: 14px 16px;
+ background: var(--c-run-bg);
+}
+.difference-heading h2 {
+ margin: 0 0 4px;
+ color: var(--ink-base);
+ font-size: 15px;
+}
+.difference-heading p {
+ margin: 0;
+ color: var(--ink-mute);
+ font-size: 10px;
+}
+.difference-counts {
+ display: flex;
+ gap: 7px;
+}
+.difference-counts span {
+ border-radius: 7px;
+ padding: 7px 9px;
+ background: var(--c-run-bg);
+ color: var(--c-run);
+ font-size: 10px;
+ font-weight: 750;
+ white-space: nowrap;
+}
+.difference-panel details {
+ border-top: 1px solid var(--border-base);
+}
+.difference-panel summary {
+ cursor: pointer;
+ padding: 10px 16px;
+ color: var(--ink-base);
+ font-size: 10px;
+ font-weight: 750;
+}
+.difference-table-wrap {
+ max-height: 260px;
+ overflow: auto;
+}
+.difference-table {
+ width: 100%;
+ table-layout: fixed;
+}
+.difference-table th,
+.difference-table td {
+ padding: 9px 12px;
+ white-space: normal;
+ overflow-wrap: anywhere;
+ font-size: 10px;
+}
+.difference-confirmation {
+ display: grid;
+ grid-template-columns: minmax(150px, 0.6fr) minmax(280px, 1.4fr) auto;
+ align-items: end;
+ gap: 9px;
+ border-top: 1px solid var(--border-base);
+ padding: 12px 16px;
+}
+.difference-confirmation label {
+ display: grid;
+ gap: 4px;
+ color: var(--ink-mute);
+ font-size: 9px;
+}
+.difference-confirmation input {
+ border: 1px solid var(--border-strong);
+ border-radius: 7px;
+ padding: 8px;
+ color: var(--ink-base);
+}
+.difference-confirmation button {
+ padding: 8px 11px;
+}
+.difference-message {
+ margin: 0;
+ border-top: 1px solid var(--border-base);
+ padding: 9px 16px;
+ background: var(--c-done-bg);
+ color: var(--c-done);
+ font-size: 10px;
+}
+@media (max-width: 760px) {
+ .difference-heading {
+ align-items: flex-start;
+ flex-direction: column;
+ }
+ .difference-counts {
+ flex-wrap: wrap;
+ }
+ .difference-confirmation {
+ grid-template-columns: 1fr;
+ }
+}
+.persisted-change-panel {
+ margin-bottom: 18px;
+ overflow: hidden;
+ border: 1px solid var(--c-run-border);
+ border-radius: 12px;
+ background: var(--bg-panel);
+}
+.persisted-change-heading {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 14px;
+ border-bottom: 1px solid var(--border-base);
+ padding: 13px 16px;
+ background: var(--bg-soft);
+}
+.persisted-change-heading h2 {
+ margin: 0 0 4px;
+ color: var(--ink-base);
+ font-size: 15px;
+}
+.persisted-change-heading p {
+ margin: 0;
+ color: var(--ink-mute);
+ font-size: 10px;
+}
+.persisted-change-heading > span {
+ border-radius: 6px;
+ padding: 6px 8px;
+ background: var(--c-run-bg);
+ color: var(--c-run);
+ font-size: 10px;
+ font-weight: 750;
+}
+.persisted-change-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 10px;
+ padding: 12px;
+}
+.persisted-change-grid > article {
+ min-width: 0;
+ border: 1px solid var(--border-base);
+ border-radius: 9px;
+ padding: 11px;
+ background: var(--bg-panel);
+}
+.persisted-change-grid > article > div:first-child {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+}
+.persisted-change-grid strong {
+ color: var(--ink-base);
+ font-size: 11px;
+}
+.persisted-change-grid small {
+ color: var(--ink-mute);
+ font-size: 9px;
+}
+.persisted-change-counts {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 5px;
+ margin-top: 8px;
+}
+.persisted-change-counts span {
+ border-radius: 5px;
+ padding: 5px 7px;
+ background: var(--border-soft);
+ color: var(--ink-mute);
+ font-size: 9px;
+}
+.persisted-change-counts span:nth-child(1) {
+ background: var(--c-done-bg);
+ color: var(--c-done);
+}
+.persisted-change-counts span:nth-child(2),
+.persisted-change-counts span:nth-child(3) {
+ background: var(--c-run-bg);
+ color: var(--c-run);
+}
+.persisted-change-counts span:nth-child(4) {
+ background: var(--c-fail-bg);
+ color: var(--c-fail);
+}
+.persisted-change-grid details {
+ margin-top: 8px;
+ border-top: 1px solid var(--border-base);
+}
+.persisted-change-grid summary {
+ cursor: pointer;
+ padding-top: 8px;
+ color: var(--c-run);
+ font-size: 9px;
+ font-weight: 750;
+}
+.persisted-change-grid details p {
+ display: grid;
+ grid-template-columns: auto minmax(100px, 0.7fr) minmax(160px, 1.3fr);
+ gap: 7px;
+ margin: 7px 0 0;
+ align-items: center;
+}
+.persisted-change-grid details b {
+ border-radius: 4px;
+ padding: 3px 5px;
+ background: var(--border-soft);
+ color: var(--ink-mute);
+ font-size: 8px;
+}
+.persisted-change-grid details span,
+.persisted-change-grid details small {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+@media (max-width: 900px) {
+ .persisted-change-grid {
+ grid-template-columns: 1fr;
+ }
+}
+@media (max-width: 600px) {
+ .persisted-change-heading {
+ align-items: flex-start;
+ flex-direction: column;
+ }
+ .persisted-change-grid details p {
+ grid-template-columns: auto 1fr;
+ }
+ .persisted-change-grid details small {
+ grid-column: 1/-1;
+ }
+}
+.processing-queue-panel {
+ margin-top: 18px;
+ overflow: hidden;
+ border: 1px solid var(--border-base);
+ border-radius: 14px;
+ background: var(--bg-panel);
+}
+.queue-heading {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+ border-bottom: 1px solid var(--border-base);
+ padding: 14px 16px;
+}
+.queue-heading h2 {
+ margin: 0 0 4px;
+ color: var(--ink-base);
+ font-size: 15px;
+}
+.queue-heading p {
+ margin: 0;
+ color: var(--ink-mute);
+ font-size: 10px;
+}
+.queue-heading > div:last-child {
+ display: flex;
+ align-items: end;
+ gap: 8px;
+}
+.queue-heading label {
+ display: grid;
+ gap: 4px;
+ color: var(--ink-mute);
+ font-size: 9px;
+}
+.queue-heading select {
+ border: 1px solid var(--border-strong);
+ border-radius: 7px;
+ padding: 7px;
+ background: var(--bg-panel);
+ color: var(--ink-base);
+}
+.queue-heading button {
+ padding: 8px 10px;
+ box-shadow: none;
+}
+.pipeline-strip {
+ overflow-x: auto;
+ border-bottom: 1px solid var(--border-base);
+ padding: 10px 16px;
+ background: var(--bg-soft);
+ color: var(--ink-base);
+ font-size: 10px;
+ font-weight: 750;
+ white-space: nowrap;
+}
+.queue-table-wrap {
+ max-height: 420px;
+ overflow: auto;
+}
+.processing-queue-table {
+ min-width: 940px;
+ table-layout: fixed;
+}
+.processing-queue-table th,
+.processing-queue-table td {
+ padding: 10px 8px;
+ white-space: normal;
+ overflow-wrap: anywhere;
+ font-size: 10px;
+}
+.processing-queue-table th:nth-child(1) {
+ width: 18%;
+}
+.processing-queue-table th:nth-child(2) {
+ width: 13%;
+}
+.processing-queue-table th:nth-child(3) {
+ width: 10%;
+}
+.processing-queue-table th:nth-child(4) {
+ width: 12%;
+}
+.processing-queue-table th:nth-child(5) {
+ width: 17%;
+}
+.processing-queue-table th:nth-child(6) {
+ width: 6%;
+}
+.processing-queue-table th:nth-child(7) {
+ width: 14%;
+}
+.processing-queue-table th:nth-child(8) {
+ width: 10%;
+}
+.processing-queue-table .queue-problem {
+ background: var(--c-fail-bg);
+}
+.processing-queue-table button {
+ padding: 6px 8px;
+ box-shadow: none;
+ font-size: 9px;
+}
+.monitor-status.stalled {
+ background: var(--c-warn-bg);
+ color: var(--c-warn);
+}
+@media (max-width: 700px) {
+ .queue-heading {
+ align-items: stretch;
+ flex-direction: column;
+ }
+ .queue-heading > div:last-child {
+ align-items: stretch;
+ flex-direction: column;
+ }
+}
+.changeset-selection {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ margin-top: 8px;
+}
+.changeset-choice {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 12px;
+ color: var(--ink-base);
+}
+.changeset-choice b {
+ flex: 0 0 78px;
+ font-weight: 700;
+ color: var(--ink-base);
+}
+.changeset-choice span {
+ overflow-wrap: anywhere;
+}
+.changeset-choice small {
+ color: var(--ink-faint);
+}
+.changeset-confirm {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ margin-top: 10px;
+}
+.changeset-confirm label {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 11px;
+ color: var(--ink-mute);
+ font-weight: 700;
+}
+.changeset-confirm input {
+ border: 1px solid var(--border-strong);
+ border-radius: 7px;
+ padding: 6px 8px;
+ background: var(--bg-panel);
+ color: var(--ink-base);
+ font: inherit;
+}
+.queue-batch-controls {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ margin-left: 10px;
+}
+.queue-batch-controls input {
+ width: 130px;
+}
+.queue-batch-message {
+ margin: 6px 0;
+ color: var(--c-done);
+}
+.dlq-panel {
+ margin-top: 14px;
+}
+.dlq-list {
+ list-style: none;
+ margin: 8px 0 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+.dlq-list li {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ flex-wrap: wrap;
+ padding: 8px 10px;
+ border: 1px solid var(--border-strong);
+ border-radius: 8px;
+ background: var(--bg-page);
+}
+.dlq-list li small {
+ flex-basis: 100%;
+ margin-left: 2px;
+ color: var(--ink-mute);
+}
+.dlq-list small {
+ margin-left: auto;
+ color: var(--c-dead);
+}
+.run-metrics {
+ margin-top: 14px;
+}
+.run-metrics-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
+ gap: 8px;
+ margin-top: 8px;
+}
+.run-metrics-grid article {
+ border: 1px solid var(--border-strong);
+ border-radius: 8px;
+ padding: 8px 10px;
+ background: var(--bg-panel);
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+.run-metrics-grid span {
+ font-size: 12px;
+ color: var(--c-dead);
+}
+.run-metrics-grid strong {
+ font-size: 16px;
+}
+.run-metrics-grid small {
+ font-size: 12px;
+ color: var(--c-dead);
+}
/* P7 direction B: task-inbox layout */
-.inbox-layout .hero.compact{display:flex;align-items:center;gap:14px}
-.monitor-meta{color:var(--c-dead);font-size:12px;margin-left:auto}
-.inbox-grid{display:grid;grid-template-columns:230px minmax(0,1fr);gap:14px;align-items:start;margin-top:12px}
-.inbox-nav{background:var(--bg-panel);border:1px solid var(--border-strong);border-radius:8px;position:sticky;top:14px;overflow:hidden}
-.inbox-nav h2{font-size:13px;margin:0;padding:10px 14px;border-bottom:1px solid var(--border-strong);color:var(--c-dead);font-weight:600}
-.inbox-nav button{display:flex;justify-content:space-between;align-items:center;width:100%;padding:9px 14px;border:none;border-bottom:1px solid var(--bg-soft);background:none;color:var(--ink-strong);font:inherit;font-size:13px;cursor:pointer;text-align:left}
-.inbox-nav button.active{background:var(--c-run-bg);border-left:3px solid var(--ink-strong);padding-left:11px;font-weight:600}
-.inbox-nav .lane-count{font-style:normal;font-size:12px;padding:0 8px;border-radius:999px;background:var(--c-dead-bg);color:var(--c-dead)}
-.inbox-nav button.blocked .lane-count{background:var(--c-fail-bg);color:var(--c-fail)}
-.inbox-nav button.okall .lane-count{background:var(--c-done-bg);color:var(--c-done)}
-.inbox-work{display:flex;flex-direction:column;gap:14px;min-width:0}
-.inbox-section{background:var(--bg-panel);border:1px solid var(--border-strong);border-radius:8px;padding-bottom:4px}
-.section-head{display:flex;align-items:center;gap:10px;padding:10px 14px;border-bottom:1px solid var(--border-strong);flex-wrap:wrap}
-.section-head h2{font-size:14px;margin:0}
-.section-head h3{font-size:13px;margin:0}
-.section-head .spacer{flex:1}
-.inbox-task{display:grid;grid-template-columns:28px minmax(0,1fr) auto;gap:12px;padding:12px 14px;border-bottom:1px solid var(--bg-soft);align-items:start}
-.inbox-task:last-child{border-bottom:none}
-.inbox-task h4{margin:0;font-size:14px;display:flex;align-items:center;gap:6px;flex-wrap:wrap}
-.inbox-task .why{font-size:12px;color:var(--c-dead);margin-top:3px}
-.task-chain{margin-top:8px;display:flex;gap:4px;align-items:center;flex-wrap:wrap;font-size:11px;color:var(--c-dead)}
-.chain-step{padding:2px 8px;border-radius:4px;border:1px solid var(--border-strong);background:var(--bg-page);white-space:nowrap}
-.chain-step.done{background:var(--c-done-bg);border-color:var(--c-done-border);color:var(--c-done)}
-.chain-step.bad{background:var(--c-fail-bg);border-color:var(--c-fail-border);color:var(--c-fail);font-weight:600}
-.chain-step.active{color:var(--c-run);border-color:var(--c-run-border);background:var(--c-run-bg)}
-.errline{margin-top:6px;font-size:12px;color:var(--c-fail)}
-.task-badge{font-size:12px;padding:1px 8px;border-radius:4px;border:1px solid var(--border-strong);white-space:nowrap}
-.task-badge.add{color:var(--c-done)}.task-badge.update{color:var(--c-run)}
-.inbox-task-actions{display:flex;flex-direction:column;gap:6px;align-items:flex-end}
-.inbox-task-actions .primary{background:var(--ink-strong);color:var(--bg-panel);border-color:var(--ink-strong)}
-button.danger{background:var(--c-fail);color:#fff;border-color:var(--c-fail)}
-button.danger:hover:not(:disabled){background:#991b1b;border-color:#991b1b}
-button.danger:disabled{background:var(--c-fail-border);color:#fff;border-color:var(--c-fail-border)}
-.queue-more{display:flex;align-items:center;gap:12px;justify-content:center;padding:12px 0;font-size:13px;color:var(--c-dead)}
-.operator-input{font:inherit;padding:5px 8px;border:1px solid var(--border-strong);border-radius:6px;width:120px}
-.tiny{font-size:12px;color:var(--c-dead)}
-.monitor-strip{display:flex;overflow-x:auto;background:var(--bg-panel);border:1px solid var(--border-strong);border-radius:8px;font-size:12px}
-.monitor-strip div{padding:8px 16px;border-right:1px solid var(--border-strong);white-space:nowrap}
-.monitor-strip div:last-child{border-right:none}
-.monitor-strip .bad{color:var(--c-fail)}.monitor-strip .good{color:var(--c-done)}
-.dlq-list button{margin-left:auto;padding:2px 10px;border:1px solid var(--border-strong);border-radius:6px;background:var(--bg-panel);cursor:pointer;font-size:12px}
-@media (max-width: 900px){.inbox-grid{grid-template-columns:1fr}.inbox-nav{position:static}}
+.inbox-layout .hero.compact {
+ display: flex;
+ align-items: center;
+ gap: 14px;
+}
+.monitor-meta {
+ color: var(--c-dead);
+ font-size: 12px;
+ margin-left: auto;
+}
+.inbox-grid {
+ display: grid;
+ grid-template-columns: 230px minmax(0, 1fr);
+ gap: 14px;
+ align-items: start;
+ margin-top: 12px;
+}
+.inbox-nav {
+ background: var(--bg-panel);
+ border: 1px solid var(--border-strong);
+ border-radius: 8px;
+ position: sticky;
+ top: 14px;
+ overflow: hidden;
+}
+.inbox-nav h2 {
+ font-size: 13px;
+ margin: 0;
+ padding: 10px 14px;
+ border-bottom: 1px solid var(--border-strong);
+ color: var(--c-dead);
+ font-weight: 600;
+}
+.inbox-nav button {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ width: 100%;
+ padding: 9px 14px;
+ border: none;
+ border-bottom: 1px solid var(--bg-soft);
+ background: none;
+ color: var(--ink-strong);
+ font: inherit;
+ font-size: 13px;
+ cursor: pointer;
+ text-align: left;
+}
+.inbox-nav button.active {
+ background: var(--c-run-bg);
+ border-left: 3px solid var(--ink-strong);
+ padding-left: 11px;
+ font-weight: 600;
+}
+.inbox-nav .lane-count {
+ font-style: normal;
+ font-size: 12px;
+ padding: 0 8px;
+ border-radius: 999px;
+ background: var(--c-dead-bg);
+ color: var(--c-dead);
+}
+.inbox-nav button.blocked .lane-count {
+ background: var(--c-fail-bg);
+ color: var(--c-fail);
+}
+.inbox-nav button.okall .lane-count {
+ background: var(--c-done-bg);
+ color: var(--c-done);
+}
+.inbox-work {
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+ min-width: 0;
+}
+.inbox-section {
+ background: var(--bg-panel);
+ border: 1px solid var(--border-strong);
+ border-radius: 8px;
+ padding-bottom: 4px;
+}
+.section-head {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 10px 14px;
+ border-bottom: 1px solid var(--border-strong);
+ flex-wrap: wrap;
+}
+.section-head h2 {
+ font-size: 14px;
+ margin: 0;
+}
+.section-head h3 {
+ font-size: 13px;
+ margin: 0;
+}
+.section-head .spacer {
+ flex: 1;
+}
+.inbox-task {
+ display: grid;
+ grid-template-columns: 28px minmax(0, 1fr) auto;
+ gap: 12px;
+ padding: 12px 14px;
+ border-bottom: 1px solid var(--bg-soft);
+ align-items: start;
+}
+.inbox-task:last-child {
+ border-bottom: none;
+}
+.inbox-task h4 {
+ margin: 0;
+ font-size: 14px;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ flex-wrap: wrap;
+}
+.inbox-task .why {
+ font-size: 12px;
+ color: var(--c-dead);
+ margin-top: 3px;
+}
+.task-chain {
+ margin-top: 8px;
+ display: flex;
+ gap: 4px;
+ align-items: center;
+ flex-wrap: wrap;
+ font-size: 11px;
+ color: var(--c-dead);
+}
+.chain-step {
+ padding: 2px 8px;
+ border-radius: 4px;
+ border: 1px solid var(--border-strong);
+ background: var(--bg-page);
+ white-space: nowrap;
+}
+.chain-step.done {
+ background: var(--c-done-bg);
+ border-color: var(--c-done-border);
+ color: var(--c-done);
+}
+.chain-step.bad {
+ background: var(--c-fail-bg);
+ border-color: var(--c-fail-border);
+ color: var(--c-fail);
+ font-weight: 600;
+}
+.chain-step.active {
+ color: var(--c-run);
+ border-color: var(--c-run-border);
+ background: var(--c-run-bg);
+}
+.errline {
+ margin-top: 6px;
+ font-size: 12px;
+ color: var(--c-fail);
+}
+.task-badge {
+ font-size: 12px;
+ padding: 1px 8px;
+ border-radius: 4px;
+ border: 1px solid var(--border-strong);
+ white-space: nowrap;
+}
+.task-badge.add {
+ color: var(--c-done);
+}
+.task-badge.update {
+ color: var(--c-run);
+}
+.inbox-task-actions {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ align-items: flex-end;
+}
+.inbox-task-actions .primary {
+ background: var(--ink-strong);
+ color: var(--bg-panel);
+ border-color: var(--ink-strong);
+}
+button.danger {
+ background: var(--c-fail);
+ color: #fff;
+ border-color: var(--c-fail);
+}
+button.danger:hover:not(:disabled) {
+ background: #991b1b;
+ border-color: #991b1b;
+}
+button.danger:disabled {
+ background: var(--c-fail-border);
+ color: #fff;
+ border-color: var(--c-fail-border);
+}
+.queue-more {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ justify-content: center;
+ padding: 12px 0;
+ font-size: 13px;
+ color: var(--c-dead);
+}
+.operator-input {
+ font: inherit;
+ padding: 5px 8px;
+ border: 1px solid var(--border-strong);
+ border-radius: 6px;
+ width: 120px;
+}
+.tiny {
+ font-size: 12px;
+ color: var(--c-dead);
+}
+.monitor-strip {
+ display: flex;
+ overflow-x: auto;
+ background: var(--bg-panel);
+ border: 1px solid var(--border-strong);
+ border-radius: 8px;
+ font-size: 12px;
+}
+.monitor-strip div {
+ padding: 8px 16px;
+ border-right: 1px solid var(--border-strong);
+ white-space: nowrap;
+}
+.monitor-strip div:last-child {
+ border-right: none;
+}
+.monitor-strip .bad {
+ color: var(--c-fail);
+}
+.monitor-strip .good {
+ color: var(--c-done);
+}
+.dlq-list button {
+ margin-left: auto;
+ padding: 2px 10px;
+ border: 1px solid var(--border-strong);
+ border-radius: 6px;
+ background: var(--bg-panel);
+ cursor: pointer;
+ font-size: 12px;
+}
+@media (max-width: 900px) {
+ .inbox-grid {
+ grid-template-columns: 1fr;
+ }
+ .inbox-nav {
+ position: static;
+ }
+}
diff --git a/web/src/workspace.test.ts b/web/src/workspace.test.ts
index 9408482..55791ed 100644
--- a/web/src/workspace.test.ts
+++ b/web/src/workspace.test.ts
@@ -14,19 +14,13 @@ describe('openModule', () => {
describe('closeModule', () => {
it('activates the nearest tab on the left after closing the active tab', () => {
- const result = closeModule(
- { openTabs: ['dashboard', 'releases', 'tasks'], activeTab: 'tasks' },
- 'tasks',
- )
+ const result = closeModule({ openTabs: ['dashboard', 'releases', 'tasks'], activeTab: 'tasks' }, 'tasks')
expect(result).toEqual({ openTabs: ['dashboard', 'releases'], activeTab: 'releases' })
})
it('keeps the dashboard tab pinned', () => {
- const result = closeModule(
- { openTabs: ['dashboard', 'tasks'], activeTab: 'dashboard' },
- 'dashboard',
- )
+ const result = closeModule({ openTabs: ['dashboard', 'tasks'], activeTab: 'dashboard' }, 'dashboard')
expect(result).toEqual({ openTabs: ['dashboard', 'tasks'], activeTab: 'dashboard' })
})
@@ -35,7 +29,7 @@ describe('closeModule', () => {
describe('restoreWorkspace', () => {
it('restores open tabs and the active tab from saved state', () => {
const result = restoreWorkspace(
- JSON.stringify({ openTabs: ['dashboard', 'tasks', 'releases'], activeTab: 'tasks' }),
+ JSON.stringify({ openTabs: ['dashboard', 'tasks', 'releases'], activeTab: 'tasks' })
)
expect(result).toEqual({ openTabs: ['dashboard', 'tasks', 'releases'], activeTab: 'tasks' })
@@ -43,7 +37,7 @@ describe('restoreWorkspace', () => {
it('drops superseded source-link and pending-file tabs from saved state', () => {
const result = restoreWorkspace(
- JSON.stringify({ openTabs: ['dashboard', 'sources', 'knowledge', 'settings'], activeTab: 'knowledge' }),
+ JSON.stringify({ openTabs: ['dashboard', 'sources', 'knowledge', 'settings'], activeTab: 'knowledge' })
)
expect(result).toEqual({ openTabs: ['dashboard', 'settings'], activeTab: 'dashboard' })
diff --git a/web/src/workspace.ts b/web/src/workspace.ts
index ff6f0bd..66f4205 100644
--- a/web/src/workspace.ts
+++ b/web/src/workspace.ts
@@ -1,12 +1,4 @@
-export type ModuleId =
- | 'dashboard'
- | 'sources'
- | 'knowledge'
- | 'tasks'
- | 'releases'
- | 'settings'
- | 'versions'
- | 'qaops'
+export type ModuleId = 'dashboard' | 'sources' | 'knowledge' | 'tasks' | 'releases' | 'settings' | 'versions' | 'qaops'
export interface WorkspaceState {
openTabs: ModuleId[]
diff --git a/web/src/workspaceContext.test.tsx b/web/src/workspaceContext.test.tsx
index 8eb5da5..192286b 100644
--- a/web/src/workspaceContext.test.tsx
+++ b/web/src/workspaceContext.test.tsx
@@ -2,14 +2,18 @@ import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it, vi } from 'vitest'
import { WorkspaceContextTabsBar } from './workspaceContext'
-describe('WorkspaceContextTabsBar',()=>{
- it('renders clickable source directory and file context after module tabs',()=>{
- const html=renderToStaticMarkup( )
- expect(html).toContain('aria-label="当前来源目录和文件"')
- for(const label of ['PP公开文档','产品介绍','手册.pdf']) expect(html).toContain(label)
- })
+describe('WorkspaceContextTabsBar', () => {
+ it('renders clickable source directory and file context after module tabs', () => {
+ const html = renderToStaticMarkup(
+
+ )
+ expect(html).toContain('aria-label="当前来源目录和文件"')
+ for (const label of ['PP公开文档', '产品介绍', '手册.pdf']) expect(html).toContain(label)
+ })
})
diff --git a/web/src/workspaceContext.tsx b/web/src/workspaceContext.tsx
index 71dded4..4959af3 100644
--- a/web/src/workspaceContext.tsx
+++ b/web/src/workspaceContext.tsx
@@ -1,10 +1,27 @@
import { createContext } from 'react'
import type { Dispatch, SetStateAction } from 'react'
-export interface WorkspaceContextTab {key:string;label:string;onActivate:()=>void}
-export const WorkspaceContextTabsSetter=createContext>>(()=>undefined)
-
-export function WorkspaceContextTabsBar({tabs}:{tabs:WorkspaceContextTab[]}){
- if(!tabs.length)return null
- return {tabs.map((tab,index)=>{index?/ :null}{tab.label} )}
+export interface WorkspaceContextTab {
+ key: string
+ label: string
+ onActivate: () => void
+}
+export const WorkspaceContextTabsSetter = createContext>>(
+ () => undefined
+)
+
+export function WorkspaceContextTabsBar({ tabs }: { tabs: WorkspaceContextTab[] }) {
+ if (!tabs.length) return null
+ return (
+
+ {tabs.map((tab, index) => (
+
+ {index ? / : null}
+
+ {tab.label}
+
+
+ ))}
+
+ )
}