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(/ - {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 ?
- - - - - {!valid ?

定时时间需为 HH:MM(00:00~23:59),储存天数 1~365,每月归档 0~24。

: null} - - - - {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,并可读回主机上生效的计划与最近一次结果。 +

+
+ +
+ {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 ? ( +
+ + + + + {!valid ? ( +

定时时间需为 HH:MM(00:00~23:59),储存天数 1~365,每月归档 0~24。

+ ) : null} + + + + {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 ? : null} - {lastSuccess && !hideTrigger ?

✅ 钉钉会话已刷新

: null} - {failure && !open ?

{failure}

: null} - {open ?
-
-

刷新钉钉扫码会话

-

- {generating ? '正在生成二维码,请稍候…' : session ? (stateLabel[session.state] ?? session.state) : '二维码生成失败,请重试'} + return ( +

+ {!hideTrigger ? ( + + ) : 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) - ? - : } + ) : 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) ? ( + + ) : ( + + )} +
+
-
-
: 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
-

文档工作台

{title}

{mode === 'monitor' ? '看每份文件处理到哪一步。解析完成不代表已上线。' : mode === 'attention' ? '只列出超限、失败或来源不可访问的文件。内容审核请进入发布管理。' : '先选来源和目录,再看文件、对内/对外和处理状态。'}

- {error ?
{error}(旧数据仅供参考)
: null} - {mode==='monitor'&&!unavailable?
来源处理概览直接看各来源完成、处理中和异常数量
{progress.map(item=>{const source=sources.find(value=>value.id===item.connection);return })}
: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} -
- - - -
-
{!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=> - - - - )} -
文件名对内/对外处理状态大小状态更新时间
{sourceNames[item.source_type]} / {item.directory_path || '目录待补齐'}{item.visibility==='external'?'对外':'对内'}{visibilityOrigin(item)}{stageLabels[documentStage(item)]}{bytes(item.size_bytes)}
: null} -
第 {shownPage+1} / {Math.max(1,Math.ceil(filtered.length/40))} 页
- {mode === 'monitor' ?

每15秒自动刷新。当前接口未提供页数或逐页百分比,因此只显示真实阶段。时间为状态更新时间,不是源文件修改时间。

: null} -
-
- {selected ?

{selected.title || '文件名待补齐'}

-
来源位置
{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=>
)}
-

原文与版本差异尚未接通,请在源系统核对内容;不要仅凭“解析完成”批准发布。

- {mode==='directory'?void mutateScope(decision,false)} onClear={decision=>void mutateScope(decision,true)} />:null} - {selected.can_retry && 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 ( +
+
+
+

文档工作台

+

{title}

+

+ {mode === 'monitor' + ? '看每份文件处理到哪一步。解析完成不代表已上线。' + : mode === 'attention' + ? '只列出超限、失败或来源不可访问的文件。内容审核请进入发布管理。' + : '先选来源和目录,再看文件、对内/对外和处理状态。'} +

+
+ +
+ {error ? ( +
+ {error}(旧数据仅供参考) +
+ ) : null} + {mode === 'monitor' && !unavailable ? ( +
+
+ 来源处理概览 + 直接看各来源完成、处理中和异常数量 +
+
+ {progress.map((item) => { + const source = sources.find((value) => value.id === item.connection) + return ( + + ) + })} +
+
+ ) : 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} +
+ + + +
+
+ {!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) => ( + + + + + + + + ))} + +
文件名对内/对外处理状态大小状态更新时间
+ + + {sourceNames[item.source_type]} / {item.directory_path || '目录待补齐'} + + + + {item.visibility === 'external' ? '对外' : '对内'} + + {visibilityOrigin(item)} + + + {stageLabels[documentStage(item)]} + + {bytes(item.size_bytes)} + +
+
+ ) : null} +
+ + 第 {shownPage + 1} / {Math.max(1, Math.ceil(filtered.length / 40))} 页 + +
+ + +
+
+ {mode === 'monitor' ? ( +

+ 每15秒自动刷新。当前接口未提供页数或逐页百分比,因此只显示真实阶段。时间为状态更新时间,不是源文件修改时间。 +

+ ) : null} +
+
+ {selected ? ( +
+
+

{selected.title || '文件名待补齐'}

+ +
+
+
+
来源位置
+
+ {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) => ( +
+
+ ))} +
+

原文与版本差异尚未接通,请在源系统核对内容;不要仅凭“解析完成”批准发布。

+ {mode === 'directory' ? ( + void mutateScope(decision, false)} + onClear={(decision) => void mutateScope(decision, true)} + /> + ) : null} + {selected.can_retry && 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} · 选择要入库的文件格式,未勾选的一律不取件
-
- - + return ( +
+
+
+ 文件格式筛选 + {label} · 选择要入库的文件格式,未勾选的一律不取件 +
+
+ + +
-
-
- {FORMAT_GROUPS.map((group) =>
- {group.label} -
{group.extensions.map((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="手动补充格式" /> - - {hasOwnRule ? : null} -
- {mode === 'allow' ? '白名单:无扩展名的文件不受限制;目录级规则就近覆盖上级。' : '黑名单:仅排除勾选格式,其余可入库。'} - {message ?

{message}

: null} - {error ?

{error}

: null} -
+
+ {FORMAT_GROUPS.map((group) => ( +
+ {group.label} +
+ {group.extensions.map((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="手动补充格式" + /> + + {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]} : ( - - )}{user.state === 'active' ? '启用' : '已停用'}{user.last_login_at ? new Date(user.last_login_at).toLocaleString('zh-CN', { hour12: false }) : '从未登录'}
- {!self ? : null} - {!self ? : 当前登录人} -
-
- - - - - -
- 停用不会删除任何业务记录,只禁止登录并立即撤销其在线会话。 -
+ 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]} + ) : ( + + )} + + + {user.state === 'active' ? '启用' : '已停用'} + + + {user.last_login_at + ? new Date(user.last_login_at).toLocaleString('zh-CN', { hour12: false }) + : '从未登录'} + +
+ {!self ? ( + + ) : null} + {!self ? ( + + ) : ( + 当前登录人 + )} +
+
+
+
+ + + + + +
+ 停用不会删除任何业务记录,只禁止登录并立即撤销其在线会话。 +
+ ) } 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.trim())} placeholder={`${placeholder}(输入生产知识库 ID)`} aria-label={placeholder} /> - ) + const editorFor = (value: string, onChange: (next: string) => void, placeholder: string) => + options.length > 0 ? ( + + ) : ( + onChange(event.target.value.trim())} + placeholder={`${placeholder}(输入生产知识库 ID)`} + aria-label={placeholder} + /> + ) - return
-

生产知识库绑定

发布链路的“源库 → 目标库”:新发布的对内 / 对外内容按这里的绑定写入生产 WeKnora 的对应知识库。留空表示未绑定,按同 ID 语义发布。

- {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 ?
- - -

保存时会向生产端核对所选的库是否真实存在;对不上会拒收,生产端不可达则只提示。

- {sameSelection ?

对内与对外必须绑定不同的生产知识库

: null} - - {message ?

{message}

: null} -
: null} - {!isAdmin ?

仅管理员可修改发布目标。

: null} -
-} \ No newline at end of file + return ( +
+
+
+

生产知识库绑定

+

+ 发布链路的“源库 → 目标库”:新发布的对内 / 对外内容按这里的绑定写入生产 WeKnora + 的对应知识库。留空表示未绑定,按同 ID 语义发布。 +

+
+ +
+ {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 ? ( +
+ + +

保存时会向生产端核对所选的库是否真实存在;对不上会拒收,生产端不可达则只提示。

+ {sameSelection ?

对内与对外必须绑定不同的生产知识库

: null} + + {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. 1. 选择文档
  2. 2. 检查数据闭包
  3. 3. 锁定清单
  4. 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 })}
-
- -
-
+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. 1. 选择文档
  2. +
  3. 2. 检查数据闭包
  4. +
  5. 3. 锁定清单
  6. +
  7. 4. 生产负责人授权
  8. +
+
+
+
+
+

测试环境候选文档

+ 解析失败或核心数据缺失的文件不可加入 +
+ +
+
+ {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 ( + + ) + })} +
+
+ +
+
+ ) +} +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.trim())} placeholder={`${placeholder}(输入知识库 ID)`} aria-label={placeholder} /> - ) + const editorFor = (value: string, onChange: (next: string) => void, placeholder: string) => + options.length > 0 ? ( + + ) : ( + onChange(event.target.value.trim())} + placeholder={`${placeholder}(输入知识库 ID)`} + aria-label={placeholder} + /> + ) - return
-

测试知识库绑定

“来源与目录”里的对内 / 对外标记按这里的绑定分发到对应的测试 WeKnora 知识库;保存后立即生效,无需重启。发布写入的生产知识库见「生产知识库绑定」。

- {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 ?
- - -

留空 = 取消 UI 绑定,回退到部署默认值;对内与对外必须是不同的库。

- {sameSelection ?

对内与对外必须绑定不同的知识库

: null} - - {message ?

{message}

: null} -
: null} - {!isAdmin ?

仅管理员可修改测试知识库绑定。

: null} -
+ return ( +
+
+
+

测试知识库绑定

+

+ “来源与目录”里的对内 / 对外标记按这里的绑定分发到对应的测试 WeKnora + 知识库;保存后立即生效,无需重启。发布写入的生产知识库见「生产知识库绑定」。 +

+
+ +
+ {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 ? ( +
+ + +

留空 = 取消 UI 绑定,回退到部署默认值;对内与对外必须是不同的库。

+ {sameSelection ?

对内与对外必须绑定不同的知识库

: null} + + {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}

:

当前继承上级规则或来源原始标记。

} -
- - -
- {error?

{error}

:null} -
{currentRule?: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} +

+ ) : ( +

当前继承上级规则或来源原始标记。

+ )} +
+ + +
+ {error ? ( +

+ {error} +

+ ) : null} +
+ + {currentRule ? ( + + ) : 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} -
    {snapshots.map(snapshot=>
  • -
    {stamp(snapshot.observed_at)}{snapshot.is_head?当前:null}{snapshot.node_count} 个节点 · {snapshot.file_count} 份文件 · 入库 {stamp(snapshot.created_at)}
    - -
  • )}
- {loading?
正在读取历史快照目录…
:null} - {active&&page?
{active.source_name}只读历史快照
- - {page.items.map(node=> - - - - - - )}
名称类型大小更新时间内容Hash
{node.node_type==='folder'?:<>📄 {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} +
    + {snapshots.map((snapshot) => ( +
  • +
    + {stamp(snapshot.observed_at)} + {snapshot.is_head ? 当前 : null} + + {snapshot.node_count} 个节点 · {snapshot.file_count} 份文件 · 入库 {stamp(snapshot.created_at)} + +
    + +
  • + ))} +
+ {loading ?
正在读取历史快照目录…
: null} + {active && page ? ( +
+
+ {active.source_name} + 只读历史快照 +
+ + + + + + + + + + + + {page.items.map((node) => ( + + + + + + + + ))} + +
名称类型大小更新时间内容Hash
+ {node.node_type === 'folder' ? ( + + ) : ( + <>📄 {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
-

来源与目录

各个知识源头的资料总览与标注

- {error?
{error}
:null} - {refreshJob&&refreshActive&&refreshHidden?: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} -
- -
- {selectedSource&&selectedSource.snapshot_status==='scanning'?
-
首次扫描中 · {selectedSource.scan_progress??0}%{selectedSource.name}
- -

{selectedSource.scan_message||'正在从源头扫描目录…'}

- 已发现约 {selectedSource.file_count} 份文件(文件夹 {selectedSource.folder_count} 个)。扫完这一页会自动出现目录;现在可以先去做别的。 -
:selectedSource&&page?<> -
- - -
-
{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 ( +
+
+
+

来源与目录

+

各个知识源头的资料总览与标注

+
+ +
+ {error ? ( +
+ {error} +
+ ) : null} + {refreshJob && refreshActive && refreshHidden ? ( + + ) : 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} +
+ +
+ {selectedSource && selectedSource.snapshot_status === 'scanning' ? ( +
+
+ 首次扫描中 · {selectedSource.scan_progress ?? 0}% + {selectedSource.name} +
+ +

{selectedSource.scan_message || '正在从源头扫描目录…'}

+ + 已发现约 {selectedSource.file_count} 份文件(文件夹 {selectedSource.folder_count}{' '} + 个)。扫完这一页会自动出现目录;现在可以先去做别的。 + +
+ ) : selectedSource && page ? ( + <> +
+ + +
+
+
+ {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?
-
- -
已选 {selectedCount} 项
+ {visibleItems.length ? ( +
+
+ +
+ + +
+ + 已选 {selectedCount} 项 +
+
+
+ {routingMessage ?

{routingMessage}

: null} +
+ ) : null} + {visibleItems.length ? ( +
+ + + + + + + + + + + + + {visibleItems.map((node) => { + const visibility = appliedPolicies[node.node_id] || node.visibility + return ( + + + + + + + + + ) + })} + +
名称类型处理策略文件数量导入与解析更新时间
+ {node.node_type === 'folder' ? ( +
+ toggleNode(node.node_id)} + /> + +
+ ) : ( + + )} +
{node.node_type === 'folder' ? '文件夹' : '文件'} +
+ {scope(visibility)} +
+
{node.node_type === 'folder' ? `${node.subtree_file_count} 份` : '—'} + {progress(node)} + {node.active_count ? 处理中 {node.active_count} : null} + {node.failed_count ? ( + 失败 {node.failed_count} + ) : null} + + {node.modified_at + ? new Date(node.modified_at).toLocaleString('zh-CN', { hour12: false }) + : '—'} +
+
+ ) : ( +
当前目录为空。
+ )} + {page.total > page.limit ? ( +
+ + 第 {Math.floor(page.offset / page.limit) + 1} / {Math.max(1, Math.ceil(page.total / page.limit))} 页 + +
+ + +
+
+ ) : null} +
+ 快照就绪 + 快照时间:{new Date(selectedSource.observed_at).toLocaleString('zh-CN', { hour12: false })} + 上次刷新耗时 {duration(selectedSource.last_refresh_duration_ms)} + {history ? ( + + ) : null} +
+ {history && history.open ? ( + + ) : null} + + ) : loading ? ( +
正在读取第一层目录…
+ ) : !error ? ( +
请选择一个内容来源。
+ ) : null} +
- {routingMessage?

{routingMessage}

:null} -
:null} - {visibleItems.length?
{visibleItems.map(node=>{const visibility=appliedPolicies[node.node_id]||node.visibility;return })}
名称类型处理策略文件数量导入与解析更新时间
{node.node_type==='folder'?
toggleNode(node.node_id)} />
:}
{node.node_type==='folder'?'文件夹':'文件'}
{scope(visibility)}
{node.node_type==='folder'?`${node.subtree_file_count} 份`:'—'}{progress(node)}{node.active_count?处理中 {node.active_count}:null}{node.failed_count?失败 {node.failed_count}:null}{node.modified_at?new Date(node.modified_at).toLocaleString('zh-CN',{hour12:false}):'—'}
:
当前目录为空。
} - {page.total>page.limit?
第 {Math.floor(page.offset/page.limit)+1} / {Math.max(1,Math.ceil(page.total/page.limit))} 页
:null} -
快照就绪快照时间:{new Date(selectedSource.observed_at).toLocaleString('zh-CN',{hour12:false})}上次刷新耗时 {duration(selectedSource.last_refresh_duration_ms)}{history?: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
-

连接配置

来源链接

配置钉钉、PP和AList的入口、认证方式及目录验证。

- {error?
{error}
:null} -
本地模拟配置使用真实来源名称和快照数量,但不连接登录服务、不保存真实Key。
-
- -
{selected?<>

{selected.name}

{typeLabel[selected.source_type]} · 本地只读快照
模拟验证待执行
-
-
当前快照文件 {selected.file_count}目录 {selected.folder_count}不下载文件内容,只读取目录和元数据不执行上传、移动、重命名或删除
- - {validated?
网络连接模拟通过
登录或Token模拟通过
根目录模拟通过
读取第一层目录模拟通过
只读边界模拟通过
以上只是本地交互模拟;迁移到测试服务器后才执行真实验证。
:null} -

真实模式下,新凭据先验证,成功后才替换旧凭据;网页永远不能读取已保存Key。

- :loading?
正在加载来源…
: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 ( +
+
+
+

连接配置

+

来源链接

+

配置钉钉、PP和AList的入口、认证方式及目录验证。

+
+ +
+ {error ? ( +
+ {error} +
+ ) : null} +
+ 本地模拟配置 + 使用真实来源名称和快照数量,但不连接登录服务、不保存真实Key。 +
+
+ +
+ {selected ? ( + <> +
+
+

{selected.name}

+ {typeLabel[selected.source_type]} · 本地只读快照 +
+ 模拟验证待执行 +
+
+ + + + +
+
+ 当前快照 + 文件 {selected.file_count} + 目录 {selected.folder_count} + 不下载文件内容,只读取目录和元数据 + 不执行上传、移动、重命名或删除 +
+ + {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() {

知识治理平台

登录后所有操作将记录到你名下

- - - {error ?

{error}

: null} - + + + {error ? ( +

+ {error} +

+ ) : null} +
) @@ -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() {

首次登录需要设置新密码

账号 {identity?.username} 的初始密码只能完成这一次登录

- - - - {error ?

{error}

: null} - - + + + + {error ? ( +

+ {error} +

+ ) : null} + +
) @@ -205,14 +270,31 @@ export function TopBarUser() { const name = identity.display_name || identity.username return (
- {menuOpen ? (
setMenuOpen(false)}> - +
) : 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})}
+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} + + + ))} +
+ ) }