Compare commits
59
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7194d54d5 | ||
|
|
f752a7bcec | ||
|
|
e3332badca | ||
|
|
3a5fbe6e42 | ||
|
|
fca832e3b0 | ||
|
|
81b984c752 | ||
|
|
c4ebcf5404 | ||
|
|
11464242c0 | ||
|
|
809f28d48f | ||
|
|
336ec94763 | ||
|
|
0ae27749cf | ||
|
|
531b683a92 | ||
|
|
0f52634380 | ||
|
|
1edd872c55 | ||
|
|
a45a4d8652 | ||
|
|
378ec24bbd | ||
|
|
cc233fec90 | ||
|
|
8db7093a9d | ||
|
|
034bd5f724 | ||
|
|
83a062b7f8 | ||
|
|
fdaf89de9d | ||
|
|
986ebdbb61 | ||
|
|
e058adcf72 | ||
|
|
d01a3ebd81 | ||
|
|
fac99939fa | ||
|
|
c1225bb880 | ||
|
|
57fabf1a95 | ||
|
|
360d6406e4 | ||
|
|
25e76c5e01 | ||
|
|
41f6ce8760 | ||
|
|
0f860c0743 | ||
|
|
fdedaa1123 | ||
|
|
4ab507b1b6 | ||
|
|
01b853fd0e | ||
|
|
7acfef0a1f | ||
|
|
c22120d7fe | ||
|
|
15d8a4093b | ||
|
|
d2d81e119c | ||
|
|
fb846bed14 | ||
|
|
566fec9f56 | ||
|
|
98751916da | ||
|
|
ed9845f8e4 | ||
|
|
a2ebc7fb84 | ||
|
|
367aeefff4 | ||
|
|
65d3b8ef02 | ||
|
|
ed7e3126e7 | ||
|
|
aaced8796b | ||
|
|
d0d9876b18 | ||
|
|
2c56054861 | ||
|
|
c2878c04ea | ||
|
|
b80153dbf8 | ||
|
|
39dc8c456c | ||
|
|
482ba885e1 | ||
|
|
09310cb753 | ||
|
|
fcf40ff75f | ||
|
|
dc18d6c747 | ||
|
|
68f6c8638b | ||
|
|
df4ca1e470 | ||
|
|
8108655e61 |
@@ -6,3 +6,6 @@ dist/
|
||||
|
||||
# mimosa 扫描钩子的本地缓存(scripts/hooks 写入,不入库)
|
||||
.mimosa
|
||||
# Playwright 运行工件(e2e/ 用例输出;详工件在 Dev .tmp/e2e/<ts>/frontend/)
|
||||
test-results/
|
||||
playwright-report/
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
// 增量 3 验收(2026-10-12 诊断报告):改密弹窗表单语义——<form onSubmit> 承载提交
|
||||
//(Enter 可提交)、autocomplete 三段标注(username/current-password/new-password)、
|
||||
// 只读用户名关联、错误文案复用 humanChangePasswordError(401 不再一刀切)。
|
||||
// 驱动:scripts/e2e.sh 第 4 阶段(E2E_PLAIN_BASE=1)——表单语义与命令面无关,
|
||||
// 任意 auth-on 服务可验,普通基座段顺路覆盖;RDM 组合主跑(第 3 阶段)本文件自跳过。
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
|
||||
const OWNER = process.env.E2E_OWNER || 'e2e-auditor'
|
||||
const PASSWORD = process.env.E2E_PASSWORD || 'E2eAudit#2026'
|
||||
const PASSWORD2 = process.env.E2E_PASSWORD2 || 'E2eAudit#2026b'
|
||||
|
||||
test.skip(!(process.env.PLAYWRIGHT_BASE_URL && process.env.E2E_PLAIN_BASE),
|
||||
'set PLAYWRIGHT_BASE_URL + E2E_PLAIN_BASE (scripts/e2e.sh phase 4 drives this suite)')
|
||||
|
||||
async function login(page: Page) {
|
||||
await page.goto('/')
|
||||
await page.getByPlaceholder('由管理员分配').fill(OWNER)
|
||||
await page.getByPlaceholder('初始密码或常用密码').fill(PASSWORD)
|
||||
await page.getByRole('button', { name: '登录', exact: true }).click()
|
||||
const gate = page.getByText('修改密码')
|
||||
const gated = await gate.waitFor({ state: 'visible', timeout: 5_000 }).then(() => true).catch(() => false)
|
||||
if (gated) {
|
||||
const pwInputs = page.locator('.lg-card input[type="password"]')
|
||||
await pwInputs.nth(0).fill(PASSWORD)
|
||||
await pwInputs.nth(1).fill(PASSWORD2)
|
||||
await pwInputs.nth(2).fill(PASSWORD2)
|
||||
await page.getByRole('button', { name: '设置新密码并进入' }).click()
|
||||
} else {
|
||||
await page.getByPlaceholder('初始密码或常用密码').fill(PASSWORD2)
|
||||
await page.getByRole('button', { name: '登录', exact: true }).click()
|
||||
}
|
||||
await expect(page.locator('.tsb-tool', { hasText: '代理' })).toBeVisible({ timeout: 15_000 })
|
||||
}
|
||||
|
||||
test('改密弹窗:form 提交、autocomplete/用户名关联、错误分类文案', async ({ page }) => {
|
||||
await login(page)
|
||||
await page.locator('.tsb-user').click()
|
||||
await page.getByRole('button', { name: '修改密码…' }).click()
|
||||
const dlg = page.getByRole('dialog', { name: '修改密码' })
|
||||
await expect(dlg).toBeVisible()
|
||||
|
||||
// 用户名只读关联 + 三段 autocomplete。
|
||||
const username = dlg.locator('input[autocomplete="username"]')
|
||||
await expect(username).toHaveValue(OWNER)
|
||||
// 本仓 @playwright/test 无 toBeReadOnly:以 readonly 属性断言只读关联。
|
||||
await expect(username).toHaveAttribute('readonly', '')
|
||||
await expect(dlg.locator('input[autocomplete="current-password"]')).toHaveCount(1)
|
||||
await expect(dlg.locator('input[autocomplete="new-password"]')).toHaveCount(2)
|
||||
|
||||
// 表单语义:填错当前密码后按 Enter(不点按钮)提交——证明提交走 <form onSubmit>。
|
||||
await dlg.locator('input[autocomplete="current-password"]').fill('definitely-wrong-pass')
|
||||
await dlg.locator('input[autocomplete="new-password"]').first().fill('ANewPass#2026x')
|
||||
await dlg.locator('input[autocomplete="new-password"]').nth(1).fill('ANewPass#2026x')
|
||||
await dlg.locator('input[autocomplete="current-password"]').press('Enter')
|
||||
// 后端 401 "current password incorrect" → humanChangePasswordError 的分类文案。
|
||||
await expect(dlg.getByText('当前密码不正确')).toBeVisible({ timeout: 10_000 })
|
||||
})
|
||||
@@ -1,10 +1,12 @@
|
||||
// 双入口对拍的浏览器侧说明。CI 门禁在 backend command_pairs_test.go:
|
||||
// 新内置命令未写入 testdata/command-pairs.json 时 go test 失败。
|
||||
// 安装 Playwright 后可用 npx playwright test e2e/command-pairs.spec.ts 做人工抽查。
|
||||
// 人工抽查:PLAYWRIGHT_INCLUDE_COMMAND_PAIRS=1 npx playwright test e2e/command-pairs.playwright.ts
|
||||
// (scripts/e2e.sh 驱动的验收跑 auth-on 种子环境,本用例面向免登录演示环境,默认跳过。)
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test('command menu and chat share builtin.help', async ({ page }) => {
|
||||
test.skip(!process.env.PLAYWRIGHT_BASE_URL, 'set PLAYWRIGHT_BASE_URL to run')
|
||||
test.skip(!process.env.PLAYWRIGHT_BASE_URL || !process.env.PLAYWRIGHT_INCLUDE_COMMAND_PAIRS,
|
||||
'set PLAYWRIGHT_BASE_URL and PLAYWRIGHT_INCLUDE_COMMAND_PAIRS to run')
|
||||
await page.goto(process.env.PLAYWRIGHT_BASE_URL!)
|
||||
await expect(page.getByText('帮助')).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
// 增量 2 验收(2026-10-12 诊断报告):普通基座(无 rdm.inspect.* 命令面)不展示
|
||||
// 「追溯审计」入口,且不发出任何 rdm.* 命令请求——消除「入口可达、进入即 400」。
|
||||
// 驱动:scripts/e2e.sh 第 4 阶段——RDM 组合验收跑完后,同一数据目录换基座 CLI
|
||||
//(agent-system serve,同源同 pin)服务,同一 preview 指向它,只跑本文件。
|
||||
// 环境契约与 rdm-audit.playwright.ts 一致(e2e.sh 注入)。
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
|
||||
const OWNER = process.env.E2E_OWNER || 'e2e-auditor'
|
||||
const PASSWORD = process.env.E2E_PASSWORD || 'E2eAudit#2026'
|
||||
const PASSWORD2 = process.env.E2E_PASSWORD2 || 'E2eAudit#2026b'
|
||||
|
||||
// 仅第 4 阶段(普通基座)启用:RDM 组合主跑(第 3 阶段)扫全目录时本文件自跳过,
|
||||
// 否则「入口隐藏」断言会跑在入口本就可见的 RDM 组合上。
|
||||
test.skip(!(process.env.PLAYWRIGHT_BASE_URL && process.env.E2E_PLAIN_BASE),
|
||||
'set PLAYWRIGHT_BASE_URL + E2E_PLAIN_BASE (scripts/e2e.sh phase 4 drives this suite)')
|
||||
|
||||
// 与 rdm-audit spec 同一账号:若数据库仍是初始密码则先过强制改密门。
|
||||
async function login(page: Page) {
|
||||
await page.goto('/')
|
||||
await page.getByPlaceholder('由管理员分配').fill(OWNER)
|
||||
await page.getByPlaceholder('初始密码或常用密码').fill(PASSWORD)
|
||||
await page.getByRole('button', { name: '登录', exact: true }).click()
|
||||
const gate = page.getByText('修改密码')
|
||||
const gated = await gate.waitFor({ state: 'visible', timeout: 5_000 }).then(() => true).catch(() => false)
|
||||
if (gated) {
|
||||
const pwInputs = page.locator('.lg-card input[type="password"]')
|
||||
await pwInputs.nth(0).fill(PASSWORD)
|
||||
await pwInputs.nth(1).fill(PASSWORD2)
|
||||
await pwInputs.nth(2).fill(PASSWORD2)
|
||||
await page.getByRole('button', { name: '设置新密码并进入' }).click()
|
||||
} else {
|
||||
// 已改密(RDM 阶段完成):改用新密码重登。
|
||||
await page.getByPlaceholder('初始密码或常用密码').fill(PASSWORD2)
|
||||
await page.getByRole('button', { name: '登录', exact: true }).click()
|
||||
}
|
||||
}
|
||||
|
||||
test('普通基座:命令面无 rdm.inspect.* → 审计入口隐藏、无 rdm 请求', async ({ page }) => {
|
||||
const rdmRequests: string[] = []
|
||||
page.on('request', r => {
|
||||
if (r.url().includes('/api/commands/rdm.')) rdmRequests.push(r.url())
|
||||
})
|
||||
await login(page)
|
||||
// 登录成功标志:侧栏常驻「代理」与「文件」体系工具(均为无条件内置)。
|
||||
await expect(page.locator('.tsb-tool', { hasText: '代理' })).toBeVisible({ timeout: 15_000 })
|
||||
await expect(page.locator('.tsb-tool', { hasText: '文件' })).toBeVisible()
|
||||
// 能力探测(GET /api/commands)返回普通基座清单后,审计入口保持隐藏。
|
||||
await expect(page.locator('.tsb-tool', { hasText: '追溯审计' })).toHaveCount(0)
|
||||
await page.waitForTimeout(500)
|
||||
await expect(page.locator('.tsb-tool', { hasText: '追溯审计' })).toHaveCount(0)
|
||||
// 入口不存在则不应有任何 rdm.* 命令请求(原缺陷路径是进入即 400)。
|
||||
expect(rdmRequests).toEqual([])
|
||||
})
|
||||
@@ -0,0 +1,126 @@
|
||||
// U4 运行验收:真实浏览器走通项目追溯审计工作区(RdmAuditWorkspace 四入口)。
|
||||
// 驱动方:Dev 仓 scripts/e2e.sh——构建 RDM 二进制 + frontend dist,起 serve(auth on)
|
||||
// 与 vite preview,并经 /api/commands 造种子(主项目已 ingest、隔离项目零数据)。
|
||||
// 环境契约(e2e.sh 注入;缺省值与 e2e.sh 保持一致):
|
||||
// PLAYWRIGHT_BASE_URL 前端入口(默认 http://127.0.0.1:4173)
|
||||
// E2E_OWNER/E2E_PASSWORD 登录账号(域管理员+基座管理员)
|
||||
// E2E_PROJECT_MAIN 已 ingest 的主项目名
|
||||
// E2E_PROJECT_ISOLATED 零数据隔离项目名
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
|
||||
const OWNER = process.env.E2E_OWNER || 'e2e-auditor'
|
||||
const PASSWORD = process.env.E2E_PASSWORD || 'E2eAudit#2026'
|
||||
const PASSWORD2 = process.env.E2E_PASSWORD2 || 'E2eAudit#2026b'
|
||||
const MAIN = process.env.E2E_PROJECT_MAIN || '审计演示项目'
|
||||
const ISOLATED = process.env.E2E_PROJECT_ISOLATED || '隔离检查项目'
|
||||
|
||||
test.skip(!process.env.PLAYWRIGHT_BASE_URL, 'set PLAYWRIGHT_BASE_URL (scripts/e2e.sh drives this suite)')
|
||||
|
||||
async function login(page: Page) {
|
||||
await page.goto('/')
|
||||
await expect(page.getByPlaceholder('由管理员分配')).toBeVisible()
|
||||
await page.getByPlaceholder('由管理员分配').fill(OWNER)
|
||||
// 首个用例用初始密码(触发强制改密门),后续用例用改后的新密码。
|
||||
await page.getByPlaceholder('初始密码或常用密码').fill(PASSWORD)
|
||||
await page.getByRole('button', { name: '登录', exact: true }).click()
|
||||
const gate = page.getByText('修改密码')
|
||||
const gated = await gate.waitFor({ state: 'visible', timeout: 5_000 }).then(() => true).catch(() => false)
|
||||
if (gated) {
|
||||
const pwInputs = page.locator('.lg-card input[type="password"]')
|
||||
await pwInputs.nth(0).fill(PASSWORD)
|
||||
await pwInputs.nth(1).fill(PASSWORD2)
|
||||
await pwInputs.nth(2).fill(PASSWORD2)
|
||||
await page.getByRole('button', { name: '设置新密码并进入' }).click()
|
||||
} else {
|
||||
// 已改密:改用新密码重登。
|
||||
await page.getByPlaceholder('初始密码或常用密码').fill(PASSWORD2)
|
||||
await page.getByRole('button', { name: '登录', exact: true }).click()
|
||||
}
|
||||
// 登录成功后侧栏体系工具出现「追溯审计」入口。
|
||||
await expect(page.locator('.tsb-tool', { hasText: '追溯审计' })).toBeVisible({ timeout: 15_000 })
|
||||
}
|
||||
|
||||
async function openAudit(page: Page) {
|
||||
// 展开态侧栏底部的体系工具条(tsb-tool,name=标题)。
|
||||
await page.locator('.tsb-tool', { hasText: '追溯审计' }).click()
|
||||
await expect(page.getByText('追溯审计').first()).toBeVisible()
|
||||
await expect(page.locator('.rdm-audit-project')).toBeVisible()
|
||||
}
|
||||
|
||||
test.describe.serial('U4 项目追溯审计工作区', () => {
|
||||
// 卡片按 label 精确定位(「章节」与「空缺章节」等子串互不干扰)。
|
||||
const card = (page: Page, label: string) =>
|
||||
page.locator('.rdm-audit-card').filter({ has: page.locator('.rdm-audit-card-label', { hasText: new RegExp(`^${label}$`) }) })
|
||||
|
||||
test('登录并打开工作区,概览计数与覆盖标注可读', async ({ page }) => {
|
||||
await login(page)
|
||||
await openAudit(page)
|
||||
// 概览:统计卡片 + 水位标注(数据完整性提示,不冒充实时)。
|
||||
await expect(page.locator('.rdm-audit-card')).toHaveCount(9)
|
||||
await expect(page.getByText(/截至水位 \d+/)).toBeVisible()
|
||||
// 主项目应有切片与章节(种子 structure 装载 + ingest 保证)。
|
||||
await expect(card(page, '切片').locator('.rdm-audit-card-num')).toHaveText(/[1-9]/)
|
||||
await expect(card(page, '章节')).toContainText(/[1-9]/)
|
||||
})
|
||||
|
||||
test('三次主要点击内完成取证:章节来源依据与最近修改原因', async ({ page }) => {
|
||||
await login(page)
|
||||
await openAudit(page)
|
||||
// 路径①:资料与文档 → 展开文档(点击1)→ 选章节(点击2)→ 详情含最近事件(修改原因)。
|
||||
// docs 树是层级结构:第一层是文档,章节在展开后的第二层。
|
||||
await page.getByRole('tab', { name: '资料与文档' }).click()
|
||||
await page.locator('.rdm-audit-tree-node').first().click()
|
||||
const sectionNode = page.locator('.rdm-audit-tree-children .rdm-audit-tree-node')
|
||||
.filter({ has: page.locator('.rdm-audit-tree-kind', { hasText: 'section' }) }).first()
|
||||
await expect(sectionNode).toBeVisible()
|
||||
await sectionNode.click()
|
||||
await expect(page.locator('.rdm-audit-detail')).toBeVisible()
|
||||
await expect(page.getByText('最近事件')).toBeVisible()
|
||||
// 路径②:关联追溯(点击1)→ 选章节(点击2)→ 双向分列或明确的空态
|
||||
//(种子未确认追溯边时呈现「没有已知关联」,同为可理解状态)。
|
||||
await page.getByRole('tab', { name: '关联追溯' }).click()
|
||||
await expect(page.locator('.rdm-audit-pick button')).not.toHaveCount(0)
|
||||
await page.locator('.rdm-audit-pick button').first().click()
|
||||
await expect(page.getByText('← 返回')).toBeVisible()
|
||||
await expect(
|
||||
page.getByText(/来源依据(\d+)/).or(page.getByText('没有已知关联')),
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('操作记录按操作归组,历史模式可见水位', async ({ page }) => {
|
||||
await login(page)
|
||||
await openAudit(page)
|
||||
await page.getByRole('tab', { name: '操作记录' }).click()
|
||||
// 事件以单行或操作组呈现;种子项目必有 ingest 事件。
|
||||
await expect(
|
||||
page.locator('.rdm-audit-event').or(page.locator('.rdm-audit-opgroup')),
|
||||
).not.toHaveCount(0)
|
||||
// 历史模式:返回当前切换明确,无对象时输入提示可理解。
|
||||
await page.getByRole('button', { name: '历史模式' }).click()
|
||||
await expect(page.getByText(/截至 seq \d+/)).toBeVisible()
|
||||
await page.getByRole('button', { name: '← 返回当前' }).click()
|
||||
await expect(page.getByRole('button', { name: '历史模式' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('项目切换无串页:隔离项目显示空态,切回主项目数据一致', async ({ page }) => {
|
||||
await login(page)
|
||||
await openAudit(page)
|
||||
// 切到隔离项目:概览计数应为 0 且无残留「加载中…」。
|
||||
await page.locator('.rdm-audit-project').selectOption({ label: ISOLATED })
|
||||
await expect(page.getByText('加载中…')).toHaveCount(0, { timeout: 10_000 })
|
||||
await expect(card(page, '切片').locator('.rdm-audit-card-num')).toHaveText('0')
|
||||
// 空态:文档树与时间线给出可理解提示。
|
||||
await page.getByRole('tab', { name: '资料与文档' }).click()
|
||||
await expect(page.getByText('暂无文档')).toBeVisible()
|
||||
await page.getByRole('tab', { name: '操作记录' }).click()
|
||||
await expect(page.getByText('暂无事件')).toBeVisible()
|
||||
// 立即切回主项目:标题与请求一致,数据回到非零(过期响应被丢弃,无串页)。
|
||||
await page.getByRole('tab', { name: '概览' }).click()
|
||||
await page.locator('.rdm-audit-project').selectOption({ label: MAIN })
|
||||
await expect(page.getByText('加载中…')).toHaveCount(0, { timeout: 10_000 })
|
||||
await expect(card(page, '切片').locator('.rdm-audit-card-num')).toHaveText(/[1-9]/)
|
||||
await expect(page.locator('.rdm-audit-project')).toHaveValue(
|
||||
await page.locator('.rdm-audit-project option', { hasText: MAIN }).getAttribute('value') ?? '',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,60 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
import type { Conversation, SurfaceMeta, SurfaceSnapshot } from '../src/contracts'
|
||||
|
||||
test.skip(!process.env.PLAYWRIGHT_BASE_URL, 'requires a served frontend bundle')
|
||||
|
||||
const conversation: Conversation = {
|
||||
id: 'surface-reload', projectId: 'surface-project', title: '页面恢复回归',
|
||||
createdAt: '2026-10-12T00:00:00Z', updatedAt: '2026-10-12T00:00:00Z',
|
||||
}
|
||||
const snapshots: SurfaceSnapshot[] = ['page-1', 'page-2'].map((surfaceId, i) => ({
|
||||
identity: { surfaceId, owner: 'fixture', pluginId: 'generative-ui', conversationId: conversation.id },
|
||||
compat: { schemaVersion: 1, catalogVersion: 'ui-v1' }, revision: 1, activationEpoch: 1,
|
||||
spec: { title: `恢复页面${i + 1}`, nodes: [{ id: 'body', type: 'markdown', markdown: `恢复正文${i + 1}` }] },
|
||||
}))
|
||||
const metadata: SurfaceMeta[] = snapshots.map(s => ({
|
||||
surfaceId: s.identity.surfaceId, title: s.spec.title, pluginId: s.identity.pluginId,
|
||||
revision: s.revision, conversationId: conversation.id, updatedAt: conversation.updatedAt,
|
||||
}))
|
||||
|
||||
test('saved-page reload keeps the app and restores both full snapshots', async ({ page }) => {
|
||||
const errors: string[] = []
|
||||
page.on('pageerror', e => errors.push(e.message))
|
||||
await page.addInitScript(({ id }) => {
|
||||
localStorage.setItem('as.conv', id)
|
||||
localStorage.setItem('as.ui', JSON.stringify({ rightTab: 'files', filesView: 'preview', rightCollapsed: false }))
|
||||
}, { id: conversation.id })
|
||||
|
||||
// Replay the two distinct server contracts through the real app. Intercept all
|
||||
// API requests so this regression can run against a live bundle without writes.
|
||||
await page.route('**/api/**', async route => {
|
||||
const request = route.request()
|
||||
const path = new URL(request.url()).pathname
|
||||
if (request.method() !== 'GET') return route.fulfill({ status: 405, body: 'read-only regression' })
|
||||
if (path === '/api/events') return route.fulfill({ contentType: 'text/event-stream', body: ': connected\n\n' })
|
||||
let body: unknown = []
|
||||
if (path === '/api/health') body = { status: 'ok' }
|
||||
else if (path === '/api/me') body = { owner: 'fixture', name: 'fixture', auth: true, authVia: 'session' }
|
||||
else if (path === '/api/projects') body = [{ id: conversation.projectId, name: '恢复项目', createdAt: '', updatedAt: '' }]
|
||||
else if (path === '/api/conversations' || path === `/api/projects/${conversation.projectId}/conversations`) body = [conversation]
|
||||
else if (path === `/api/conversations/${conversation.id}`) body = { conversation, messages: [], runs: [], activeHitl: [], lastSeq: 0 }
|
||||
else if (path === `/api/conversations/${conversation.id}/surfaces` || path === `/api/projects/${conversation.projectId}/surfaces`) body = metadata
|
||||
else if (path.startsWith('/api/surfaces/')) {
|
||||
body = snapshots.find(s => s.identity.surfaceId === path.split('/').at(-1))
|
||||
} else if (path.startsWith('/api/settings')) body = { schema: [], defaults: {}, user: {}, project: {} }
|
||||
else if (path.endsWith('/plan') || path.endsWith('/todos')) body = null
|
||||
await route.fulfill({ contentType: 'application/json', body: JSON.stringify(body) })
|
||||
})
|
||||
|
||||
await page.goto('/')
|
||||
await expect(page.locator('.preview-pane .surface-view-title')).toHaveText('恢复页面1')
|
||||
await expect(page.locator('.preview-pane')).toContainText('恢复正文1')
|
||||
await page.reload()
|
||||
await expect(page.locator('.preview-pane .surface-view-title')).toHaveText('恢复页面1')
|
||||
await page.getByRole('button', { name: '返回文件列表' }).click()
|
||||
await expect(page.locator('[data-surface-row]')).toHaveCount(2)
|
||||
await page.locator('[data-surface-row="page-2"]').click()
|
||||
await expect(page.locator('.preview-pane .surface-view-title')).toHaveText('恢复页面2')
|
||||
await expect(page.locator('.preview-pane')).toContainText('恢复正文2')
|
||||
expect(errors).toEqual([])
|
||||
})
|
||||
@@ -0,0 +1,55 @@
|
||||
// U7 浏览器点验(补录):R1–R8 用户可见流程——在实际 8788 入口(React 内嵌组合
|
||||
// 产物)以已激活账号登录,点验体系工具入口、审计工作区、项目设置三页签(成员/
|
||||
// 文档职责·整文审签/项目权限矩阵)与系统管理 RDM 角色分区。
|
||||
// 驱动:PLAYWRIGHT_BASE_URL=http://127.0.0.1:8788 + 已激活账号(无 must-change 门)。
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
|
||||
const OWNER = process.env.E2E_OWNER || 'u7-creator'
|
||||
const PASSWORD = process.env.E2E_PASSWORD || 'U7-Final-creator!7'
|
||||
|
||||
test.skip(!process.env.PLAYWRIGHT_BASE_URL, 'set PLAYWRIGHT_BASE_URL (drives against the live 8788 entry)')
|
||||
|
||||
async function login(page: Page) {
|
||||
await page.goto('/')
|
||||
await expect(page.getByPlaceholder('由管理员分配')).toBeVisible({ timeout: 15_000 })
|
||||
await page.getByPlaceholder('由管理员分配').fill(OWNER)
|
||||
await page.getByPlaceholder('初始密码或常用密码').fill(PASSWORD)
|
||||
await page.getByRole('button', { name: '登录', exact: true }).click()
|
||||
}
|
||||
|
||||
test('U7 浏览器点验:登录后工作台与审计入口(R1)', async ({ page }) => {
|
||||
await login(page)
|
||||
// 工作台主体出现(侧栏/会话区之一可见即视为进入)。
|
||||
await expect(page.locator('body')).toContainText(/会话|代理|项目|工作台/, { timeout: 15_000 })
|
||||
})
|
||||
|
||||
test('U7 浏览器点验:追溯审计工作区可进入(R1/R7)', async ({ page }) => {
|
||||
await login(page)
|
||||
const audit = page.locator('.tsb-tool', { hasText: '追溯审计' })
|
||||
await expect(audit).toBeVisible({ timeout: 15_000 })
|
||||
await audit.click()
|
||||
await expect(page.locator('body')).toContainText(/概览|覆盖|追溯/, { timeout: 15_000 })
|
||||
})
|
||||
|
||||
test('U7 浏览器点验:项目设置三页签(R2/R4/R5)', async ({ page }) => {
|
||||
await login(page)
|
||||
// 侧栏「项目设置」按钮(currentProject 就绪后出现;最多等 15s)。
|
||||
const btn = page.locator('.tsb-tool', { hasText: '项目设置' })
|
||||
await expect(btn).toBeVisible({ timeout: 15_000 })
|
||||
await btn.click()
|
||||
await page.waitForTimeout(1000)
|
||||
const body = await page.locator('body').innerText().catch(() => '')
|
||||
const tabs = ['成员', '文档职责', '项目权限'].filter(t => body.includes(t))
|
||||
test.info().annotations.push({ type: 'note', description: `project-settings tabs visible: ${tabs.join(',')}` })
|
||||
expect(tabs.length).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
test('U7 浏览器点验:系统管理 RDM 角色分区(R3)', async ({ page }) => {
|
||||
await login(page)
|
||||
await page.waitForTimeout(1200)
|
||||
const admin = page.locator('button[title*="系统管理"], button:has-text("系统管理")').first()
|
||||
const visible = await admin.isVisible().catch(() => false)
|
||||
test.info().annotations.push({ type: 'note', description: `admin-zone entry visible=${visible} (u7-creator 非 admin 时无入口=预期)` })
|
||||
// 创建者非管理员:无系统管理入口本身即门控正确的证据。
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
@@ -0,0 +1,19 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
const OWNER = process.env.E2E_OWNER || 'u7-creator'
|
||||
const PASSWORD = process.env.E2E_PASSWORD || 'U7-Final-creator!7'
|
||||
test.skip(!process.env.PLAYWRIGHT_BASE_URL)
|
||||
test('u7 工作台+审计截图', async ({ page }) => {
|
||||
test.setTimeout(60_000)
|
||||
await page.goto('/')
|
||||
await expect(page.getByPlaceholder('由管理员分配')).toBeVisible({ timeout: 15_000 })
|
||||
await page.getByPlaceholder('由管理员分配').fill(OWNER)
|
||||
await page.getByPlaceholder('初始密码或常用密码').fill(PASSWORD)
|
||||
await page.getByRole('button', { name: '登录', exact: true }).click()
|
||||
await page.waitForTimeout(1200)
|
||||
await page.screenshot({ path: 'test-results/u7-01-workbench.png' })
|
||||
const audit = page.locator('.tsb-tool', { hasText: '追溯审计' })
|
||||
await expect(audit).toBeVisible({ timeout: 15_000 })
|
||||
await audit.click()
|
||||
await page.waitForTimeout(1500)
|
||||
await page.screenshot({ path: 'test-results/u7-02-audit.png' })
|
||||
})
|
||||
@@ -0,0 +1,17 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
const OWNER = process.env.E2E_OWNER || 'u7-creator'
|
||||
const PASSWORD = process.env.E2E_PASSWORD || 'U7-Final-creator!7'
|
||||
test.skip(!process.env.PLAYWRIGHT_BASE_URL)
|
||||
test('u7 项目设置截图', async ({ page }) => {
|
||||
test.setTimeout(60_000)
|
||||
await page.goto('/')
|
||||
await expect(page.getByPlaceholder('由管理员分配')).toBeVisible({ timeout: 15_000 })
|
||||
await page.getByPlaceholder('由管理员分配').fill(OWNER)
|
||||
await page.getByPlaceholder('初始密码或常用密码').fill(PASSWORD)
|
||||
await page.getByRole('button', { name: '登录', exact: true }).click()
|
||||
const settings = page.locator('.tsb-tool', { hasText: '项目设置' })
|
||||
await expect(settings).toBeVisible({ timeout: 20_000 })
|
||||
await settings.click()
|
||||
await page.waitForTimeout(1500)
|
||||
await page.screenshot({ path: 'test-results/u7-03-project-settings.png' })
|
||||
})
|
||||
Generated
+46
@@ -19,6 +19,7 @@
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.50.1",
|
||||
"@types/node": "^22.20.4",
|
||||
"@types/react": "^19.2.0",
|
||||
"@types/react-dom": "^19.2.0",
|
||||
@@ -1212,6 +1213,22 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.64.0",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.0.tgz",
|
||||
"integrity": "sha512-oTN9FNYuN6bqQMwiho6jJKLCG2/QOYCixGXuCjRmzOq23eODeukuY2XiAVoVpAECbuezMt1MHQjAiQxK8F+Y+g==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.64.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-beta.27",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
||||
@@ -2749,6 +2766,35 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.64.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.0.tgz",
|
||||
"integrity": "sha512-kVzTnYFYEyQ9RGzBjNCd2+yEqWmVbxl3AHPynv3qQ6JTjv4a+kwRcnqQwIpuP3NnrnIrE/Q6wWAIAvMfIE2/Yg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.64.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.64.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.0.tgz",
|
||||
"integrity": "sha512-T9r+MZkTECl2+oUcZ26YgZzTrYidTsIhYYkJix+6iDOymOb1FB2RLLSOxRGnZGv5DStt+aduIVbt+E8WygXYpg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.28",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
|
||||
|
||||
+3
-1
@@ -7,7 +7,8 @@
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"test": "vitest run",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"e2e": "playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"docx-preview": "^0.4.1",
|
||||
@@ -21,6 +22,7 @@
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.50.1",
|
||||
"@types/node": "^22.20.4",
|
||||
"@types/react": "^19.2.0",
|
||||
"@types/react-dom": "^19.2.0",
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
// U4 运行验收(Playwright):真实浏览器走通追溯审计工作区四入口。
|
||||
// 由 Dev 仓 scripts/e2e.sh 驱动(起 RDM 二进制 + preview 静态前端 + 种子数据);
|
||||
// PLAYWRIGHT_BASE_URL 未设置时整组跳过(CI 单测档不拉浏览器)。
|
||||
import { defineConfig } from '@playwright/test'
|
||||
|
||||
const base = process.env.PLAYWRIGHT_BASE_URL || ''
|
||||
|
||||
export default defineConfig({
|
||||
testDir: 'e2e',
|
||||
testMatch: /.*(spec|playwright)\.ts$/,
|
||||
timeout: 30_000,
|
||||
retries: 0,
|
||||
workers: 1,
|
||||
reporter: [['list']],
|
||||
use: {
|
||||
baseURL: base,
|
||||
locale: 'zh-CN',
|
||||
trace: 'retain-on-failure',
|
||||
},
|
||||
})
|
||||
+10
-1
@@ -52,8 +52,13 @@ export function App() {
|
||||
const r = await auth.fetch('/api/health')
|
||||
if (r.status === 401) { setAuthState('login'); return }
|
||||
// 已持有有效会话 cookie(或密钥)时直接放行;会话携带 must_change 时先进改密门。
|
||||
// 改密接口只认会话登录:API key 凭证进改密门是死路(提交必 401),
|
||||
// key 持有者清 key 回登录页,改走密码登录完成改密。
|
||||
const me = await api.me().catch(() => null)
|
||||
if (me?.mustChangePassword) { setMustChangeOwner(me.owner); setAuthState('mustchange'); return }
|
||||
if (me?.mustChangePassword) {
|
||||
if (me.authVia === 'key') { auth.clear(); setAuthState('login'); return }
|
||||
setMustChangeOwner(me.owner); setAuthState('mustchange'); return
|
||||
}
|
||||
setAuthState('ok')
|
||||
} catch {
|
||||
setAuthState('ok') // 网络异常不是认证问题;放行交给业务层报错
|
||||
@@ -63,6 +68,10 @@ export function App() {
|
||||
|
||||
useEffect(() => {
|
||||
if (authState !== 'ok') return
|
||||
// me 回填进 store:管理入口(me?.isAdmin)与侧栏身份都消费它;此前只有
|
||||
// 改密门 onDone 调 refreshMe,登录与刷新回态后 me 恒为 null,管理员菜单
|
||||
// 在任何一次 F5 后永久消失(2026-10-12「用户管理页面不可用」根因)。
|
||||
store.refreshMe()
|
||||
store.refreshProjects()
|
||||
store.refreshProfiles()
|
||||
store.refreshPlugins()
|
||||
|
||||
+32
-4
@@ -4,11 +4,16 @@ import { auth } from './auth'
|
||||
import type {
|
||||
Project, Conversation, Message, Run, Artifact, HITLCard, HITLResponse,
|
||||
AgentProfile, SettingDesc, StreamEvent, StreamEventType, PlanSnapshot, TodoList, UploadInfo, PromptModule,
|
||||
ActivityCard,
|
||||
ActivityCard, SurfaceMeta, SurfaceSnapshot,
|
||||
} from './contracts'
|
||||
|
||||
const BASE = ''
|
||||
|
||||
/** 管理区声明分区(WP3-I3/KTD4,backend 专属端点类型):编译插件经
|
||||
* Registry.AddAdminZone 声明;入口不做前端权限合并,区内数据能力由组件
|
||||
* 自身命令面 fail-closed。 */
|
||||
export interface AdminZoneSection { id: string; title: string; icon?: string; desc?: string; order?: number }
|
||||
|
||||
async function json<T>(res: Response): Promise<T> {
|
||||
if (!res.ok) {
|
||||
const text = await res.text()
|
||||
@@ -30,7 +35,14 @@ export const api = {
|
||||
setPermissionMode: (id: string, permissionMode: string) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ permissionMode }) }).then(r => { if (!r.ok) throw new Error(`${r.status}: ${r.statusText}`) }),
|
||||
checkpoints: (id: string, messageId: string) => auth.fetch(`${BASE}/api/conversations/${id}/checkpoints?messageId=${encodeURIComponent(messageId)}`).then(r => json<{ restored?: string[]; skipped?: string[] }>(r)),
|
||||
rewindFiles: (id: string, messageId: string) => auth.fetch(`${BASE}/api/conversations/${id}/rewind`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ messageId }) }).then(r => json<{ restored?: string[]; skipped?: string[] }>(r)),
|
||||
me: () => auth.fetch(`${BASE}/api/me`).then(r => json<{ owner: string; name: string; auth: boolean; isAdmin?: boolean; mustChangePassword?: boolean }>(r)),
|
||||
/** 会话内页面目录:扁平 SurfaceMeta(无 identity/spec),不可直接写入 surfaces 投影。 */
|
||||
surfaceList: (convId: string) => auth.fetch(`${BASE}/api/conversations/${convId}/surfaces`).then(r => json<SurfaceMeta[]>(r)),
|
||||
submitSurfaceAction: (id: string, body: { actionId: string; revision: number; requestId: string; data: Record<string, unknown> }) =>
|
||||
auth.fetch(`${BASE}/api/surfaces/${id}/actions`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }).then(r => json<any>(r)),
|
||||
projectSurfaces: (projectId: string) => auth.fetch(`${BASE}/api/projects/${projectId}/surfaces`).then(r => json<SurfaceMeta[]>(r)),
|
||||
/** 单页权威快照(含 identity + spec),供列表水合与预览渲染。 */
|
||||
surfaceGet: (id: string) => auth.fetch(`${BASE}/api/surfaces/${id}`).then(r => json<SurfaceSnapshot>(r)),
|
||||
me: () => auth.fetch(`${BASE}/api/me`).then(r => json<{ owner: string; name: string; auth: boolean; authVia?: string; isAdmin?: boolean; mustChangePassword?: boolean }>(r)),
|
||||
// ---- 认证与会话(管理员发放制,R2)----
|
||||
login: (owner: string, password: string) => auth.fetch(`${BASE}/api/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, password }) }).then(async r => {
|
||||
if (!r.ok) throw new Error(`${r.status}: ${await r.text()}`)
|
||||
@@ -108,8 +120,9 @@ export const api = {
|
||||
|
||||
listTools: () => auth.fetch(`${BASE}/api/tools`).then(r => r.json()),
|
||||
listPlugins: () => auth.fetch(`${BASE}/api/plugins`).then(r => r.json()),
|
||||
adminZoneSections: () => auth.fetch(`${BASE}/api/admin-zone/sections`).then(r => r.json()) as Promise<AdminZoneSection[]>,
|
||||
reloadPlugins: () => auth.fetch(`${BASE}/api/plugins/reload`, { method: 'POST' }).then(r => r.json()),
|
||||
listCommands: () => auth.fetch(`${BASE}/api/commands`).then(r => r.json()) as Promise<{ name?: string; id?: string; title?: string; description?: string; danger?: string }[]>,
|
||||
listCommands: () => auth.fetch(`${BASE}/api/commands`).then(r => r.json()) as Promise<{ name?: string; id?: string; title?: string; description?: string; danger?: string; enabled?: boolean }[]>,
|
||||
setSessionNote: (id: string, sessionNote: string) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionNote }) }).then(r => { if (!r.ok) throw new Error(`${r.status}`) }),
|
||||
excludeMessage: (id: string, excluded: boolean) => auth.fetch(`${BASE}/api/messages/${id}/exclude`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ excluded }) }).then(r => { if (!r.ok) throw new Error(`${r.status}`) }),
|
||||
selectVariant: (id: string) => auth.fetch(`${BASE}/api/messages/${id}/variant`, { method: 'POST' }).then(r => { if (!r.ok) throw new Error(`${r.status}`) }),
|
||||
@@ -125,6 +138,21 @@ export const api = {
|
||||
savePromptCommand: (body: { name: string; title: string; template: string }) => auth.fetch(`${BASE}/api/prompt-commands`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }).then(r => json(r)),
|
||||
deletePromptCommand: (id: string) => auth.fetch(`${BASE}/api/prompt-commands/${id}`, { method: 'DELETE' }),
|
||||
listWorkspaces: () => auth.fetch(`${BASE}/api/workspaces`).then(r => r.json()) as Promise<{ id: string; title: string; icon?: string; shortcut?: string; badgeTool?: string; body?: any[]; refreshTool?: string }[]>,
|
||||
|
||||
// ---- U4:RDM 命令总线封装(POST /api/commands/{id},同 RDM web 的 command 路由) ----
|
||||
// receipt 解包:rejected 抛错;confirm-card 抛提示(审计面只读命令不应触发)。
|
||||
rdmCommand: <T = any>(id: string, params: Record<string, unknown> = {}): Promise<T> =>
|
||||
auth.fetch(`${BASE}/api/commands/${encodeURIComponent(id)}`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ params }),
|
||||
}).then(r => json<{ receipt?: { state?: string; error?: string; message?: string }; card?: unknown; payload?: T } | T>(r))
|
||||
.then(out => {
|
||||
const envelope = out as { receipt?: { state?: string; error?: string; message?: string }; card?: unknown; payload?: T }
|
||||
if (envelope?.receipt?.state === 'rejected') throw new Error(envelope.receipt.error || envelope.receipt.message || '命令被拒绝')
|
||||
if (envelope?.card) throw new Error('高危命令已生成确认卡——请在对话中确认执行')
|
||||
if (envelope && envelope.payload !== undefined) return envelope.payload
|
||||
return out as T
|
||||
}),
|
||||
}
|
||||
|
||||
// ---- SSE 客户端:Last-Event-ID 游标续传;断线自动重连 ----
|
||||
@@ -160,7 +188,7 @@ const streamEventListeners: Record<StreamEventType, true> = {
|
||||
'card.created': true,
|
||||
'card.updated': true,
|
||||
'card.status': true,
|
||||
'card.resolved': true,
|
||||
'card.resolved': true, 'surface.updated': true,
|
||||
}
|
||||
|
||||
export function connectEvents(conversationId: string, since: number, handlers: SSEHandlers, projectId?: string): { close: () => void } {
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { groupArtifactsByName } from './artifactGrouping'
|
||||
import type { Artifact } from './contracts'
|
||||
|
||||
// 2026-10-11 实测事故:一次 run 内 register_artifact 三次 → 文件面板三份同名
|
||||
// 转写报告并列。修复语义(用户裁定):存储保留版本、界面按名称分组去重。
|
||||
|
||||
function art(id: string, name: string, updatedAt: string, size = 40000): Artifact {
|
||||
return { id, projectId: 'p1', name, kind: 'document', size, createdAt: updatedAt, updatedAt }
|
||||
}
|
||||
|
||||
describe('groupArtifactsByName', () => {
|
||||
it('同名 3 行归一组:latest 为最新,versions 新→旧', () => {
|
||||
const g = groupArtifactsByName([
|
||||
art('a1', '报告.docx', '2026-10-11T15:22:20Z'),
|
||||
art('a2', '报告.docx', '2026-10-11T15:24:06Z'),
|
||||
art('a3', '报告.docx', '2026-10-11T15:23:38Z'),
|
||||
art('b1', '其他.md', '2026-10-11T15:20:00Z', 100),
|
||||
])
|
||||
expect(g).toHaveLength(2)
|
||||
const grp = g.find(x => x.latest.name === '报告.docx')!
|
||||
expect(grp.latest.id).toBe('a2') // 最新 15:24:06
|
||||
expect(grp.versions).toHaveLength(3)
|
||||
expect(grp.versions.map(v => v.id)).toEqual(['a2', 'a3', 'a1'])
|
||||
expect(g.find(x => x.latest.name === '其他.md')!.versions).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('不同名互不影响;空列表返回空', () => {
|
||||
const g = groupArtifactsByName([art('x', 'a.md', 'T1'), art('y', 'b.md', 'T2')])
|
||||
expect(g).toHaveLength(2)
|
||||
expect(groupArtifactsByName([])).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
// 同名工件版本分组(2026-10-11 实测事故的修复语义,用户裁定:存储保留版本、
|
||||
// 界面去重):组内按 updated_at 新→旧,latest 为面板显示行,其余为历史版本。
|
||||
import type { Artifact } from './contracts'
|
||||
|
||||
export interface ArtifactGroup {
|
||||
latest: Artifact
|
||||
versions: Artifact[]
|
||||
}
|
||||
|
||||
export function groupArtifactsByName(list: Artifact[]): ArtifactGroup[] {
|
||||
const byName = new Map<string, Artifact[]>()
|
||||
for (const a of list) {
|
||||
const arr = byName.get(a.name) ?? []
|
||||
arr.push(a)
|
||||
byName.set(a.name, arr)
|
||||
}
|
||||
const out: ArtifactGroup[] = []
|
||||
for (const versions of byName.values()) {
|
||||
versions.sort((x, y) => (x.updatedAt < y.updatedAt ? 1 : x.updatedAt > y.updatedAt ? -1 : 0))
|
||||
out.push({ latest: versions[0], versions })
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -22,6 +22,9 @@ export const auth = {
|
||||
apiKey(): string { return storage.getItem(KEY) ?? '' },
|
||||
setKey(key: string) { storage.setItem(KEY, key.trim()) },
|
||||
clear() { storage.removeItem(KEY) },
|
||||
// 密码登录成功后切换到 HttpOnly 会话 cookie:必须清掉残留 Bearer——服务端
|
||||
// 认证中间件 Bearer 优先于会话 cookie,失效残留 key 会把有效会话也判成 401。
|
||||
beginSession() { this.clear() },
|
||||
// 带凭证的 fetch:未设置密钥时与原生 fetch 等价(嵌入式档无认证)。
|
||||
fetch(input: string, init?: RequestInit): Promise<Response> {
|
||||
const key = this.apiKey()
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState } from 'react'
|
||||
import { api } from '../api'
|
||||
import { store, useStore } from '../store'
|
||||
import { Icon } from '@ui'
|
||||
import { humanChangePasswordError } from './LoginPage'
|
||||
|
||||
function Shell({ title, onClose, children }: { title: string; onClose: () => void; children: React.ReactNode }) {
|
||||
return (
|
||||
@@ -20,7 +21,10 @@ function Shell({ title, onClose, children }: { title: string; onClose: () => voi
|
||||
)
|
||||
}
|
||||
|
||||
/** 主动改密(账户菜单入口):当前密码 + 新密码 + 确认。 */
|
||||
/** 主动改密(账户菜单入口):用户名(只读关联)+ 当前密码 + 新密码 + 确认。
|
||||
* 表单语义(2026-10-12 增量 3):<form onSubmit> 承载提交(Enter 可用)、autocomplete
|
||||
* 三段标注(username/current-password/new-password)、label 与 input 关联;
|
||||
* 错误文案复用 humanChangePasswordError——401 不再一刀切成「当前密码不正确」。 */
|
||||
export function ChangePasswordModal({ onClose }: { onClose: () => void }) {
|
||||
const me = useStore(s => s.me)
|
||||
const [current, setCurrent] = useState('')
|
||||
@@ -41,23 +45,28 @@ export function ChangePasswordModal({ onClose }: { onClose: () => void }) {
|
||||
store.toast('密码已更新')
|
||||
setTimeout(onClose, 900)
|
||||
} catch (e) {
|
||||
setErr(String(e).includes('401') ? '当前密码不正确' : String(e))
|
||||
setErr(humanChangePasswordError(String(e)))
|
||||
} finally { setBusy(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<Shell title="修改密码" onClose={onClose}>
|
||||
{ok ? <div className="ad-notice">密码已更新,其他设备的会话已注销。</div> : (
|
||||
<div className="ad-changepw">
|
||||
<label className="lg-label">当前密码({me?.owner ?? ''})</label>
|
||||
<input className="lg-input" type="password" value={current} onChange={e => setCurrent(e.target.value)} autoFocus />
|
||||
<label className="lg-label">新密码(≥8 位)</label>
|
||||
<input className="lg-input" type="password" value={next} onChange={e => setNext(e.target.value)} />
|
||||
<label className="lg-label">确认新密码</label>
|
||||
<input className="lg-input" type="password" value={confirm} onChange={e => setConfirm(e.target.value)} />
|
||||
<form className="ad-changepw" onSubmit={e => { e.preventDefault(); submit() }}>
|
||||
<label className="lg-label" htmlFor="changepw-username">用户名</label>
|
||||
<input id="changepw-username" className="lg-input" value={me?.owner ?? ''} readOnly autoComplete="username" />
|
||||
<label className="lg-label" htmlFor="changepw-current">当前密码</label>
|
||||
<input id="changepw-current" className="lg-input" type="password" autoComplete="current-password"
|
||||
value={current} onChange={e => setCurrent(e.target.value)} autoFocus />
|
||||
<label className="lg-label" htmlFor="changepw-next">新密码(≥8 位)</label>
|
||||
<input id="changepw-next" className="lg-input" type="password" autoComplete="new-password"
|
||||
value={next} onChange={e => setNext(e.target.value)} />
|
||||
<label className="lg-label" htmlFor="changepw-confirm">确认新密码</label>
|
||||
<input id="changepw-confirm" className="lg-input" type="password" autoComplete="new-password"
|
||||
value={confirm} onChange={e => setConfirm(e.target.value)} />
|
||||
{err && <div className="lg-err"><Icon name="alert" size={14} />{err}</div>}
|
||||
<button className="lg-submit" disabled={busy || !current || !next || !confirm} onClick={submit}>{busy ? '提交中…' : '更新密码'}</button>
|
||||
</div>
|
||||
<button className="lg-submit" type="submit" disabled={busy || !current || !next || !confirm}>{busy ? '提交中…' : '更新密码'}</button>
|
||||
</form>
|
||||
)}
|
||||
</Shell>
|
||||
)
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
// 系统管理(重设计方案 阶段②):管理员专属的全屏独立视图(带返回,非弹窗)。
|
||||
// 系统管理(重设计方案 阶段②):全屏独立视图(带返回,非弹窗)。
|
||||
// 用户决策:系统管理 = 全屏独立视图;普通用户界面不暴露任何系统能力。
|
||||
// 分区注册表:新增管理页只改 SECTIONS,不改 AdminZone 本体(声明式注册)。
|
||||
// WP3-I3/KTD4:编译插件可经 Registry.AddAdminZone 声明展示分区(服务端
|
||||
// /api/admin-zone/sections 下发);声明分区对全部认证主体开放,区内数据
|
||||
// 能力由分区组件自身的命令面 fail-closed——前端只消费、不合并角色。
|
||||
import { useState } from 'react'
|
||||
import { store, useStore } from '../store'
|
||||
import { Icon, type IconName } from '@ui'
|
||||
import { EmptyState, Icon, type IconName } from '@ui'
|
||||
import { UsersPanel, ProfilesSection, PluginsSection, PluginWorkspacesPanel } from './adminSections'
|
||||
import { RdmAuditWorkspace } from './RdmAuditWorkspace'
|
||||
import { RdmRolesPanel } from './adminRdmSections'
|
||||
import type { AdminZoneSection } from '../api'
|
||||
|
||||
interface AdminSectionDef {
|
||||
key: string
|
||||
@@ -22,17 +28,55 @@ function ProfilesPanelWrapper() {
|
||||
|
||||
const SECTIONS: AdminSectionDef[] = [
|
||||
{ key: 'users', label: '用户管理', icon: 'users', desc: '建号、发放初始密码、重置密码(管理员发放制)', render: () => <UsersPanel /> },
|
||||
// U6/KTD5:RDM 专业角色管理——命令面齐备(rdmRolesAvailable)才显示;普通基座零暴露。
|
||||
{ key: 'rdm-roles', label: 'RDM 角色权限', icon: 'shield', desc: '专业角色:动作默认/上限、账号绑定、停用(仅管理员)', render: () => <RdmRolesPanel /> },
|
||||
{ key: 'profiles', label: '智能体画像', icon: 'bot', desc: '系统级画像:系统提示、工具允许/确认/拒绝清单', render: () => <ProfilesPanelWrapper /> },
|
||||
{ key: 'plugins', label: '插件管理', icon: 'puzzle', desc: '已装载插件清单与热重载(装卸不影响核心)', render: () => <PluginsSection /> },
|
||||
{ key: 'workspaces', label: '插件工作区', icon: 'grid', desc: '插件声明面板与服务端声明工作区', render: () => <PluginWorkspacesPanel /> },
|
||||
]
|
||||
|
||||
/** 声明分区 → 内置分区组件(按声明 ID 映射;AdminZone 不硬编码领域逻辑)。 */
|
||||
const DECLARED_COMPONENTS: Record<string, () => React.ReactNode> = {
|
||||
'rdm.audit': () => <RdmAuditWorkspace />,
|
||||
}
|
||||
|
||||
/** icon 走基座 IconName 词表;声明携带未知图标时回退通用图标(不渲染空白)。 */
|
||||
const ICONS: IconName[] = ['plus', 'search', 'panel-left', 'inbox', 'settings', 'sun', 'moon', 'pin', 'archive', 'undo',
|
||||
'chevron-right', 'chevron-down', 'folder', 'message', 'package', 'bot', 'puzzle', 'grid', 'arrow-up', 'paperclip',
|
||||
'more', 'logout', 'key', 'user', 'users', 'shield', 'x', 'check', 'alert', 'send', 'sparkles', 'clock', 'list',
|
||||
'file', 'image', 'download', 'trash', 'eye', 'maximize', 'minimize']
|
||||
function declaredIcon(name?: string): IconName {
|
||||
return (ICONS as string[]).includes(name ?? '') ? (name as IconName) : 'grid'
|
||||
}
|
||||
|
||||
function DeclaredSection({ section }: { section: AdminZoneSection }) {
|
||||
const render = DECLARED_COMPONENTS[section.id]
|
||||
if (!render) {
|
||||
return <EmptyState icon="puzzle">分区「{section.title}」由内置插件声明,但当前前端版本未携带其组件<br />(部署前端与二进制版本不匹配时会出现;升级前端即可)</EmptyState>
|
||||
}
|
||||
return <>{render()}</>
|
||||
}
|
||||
|
||||
export function AdminZone() {
|
||||
const me = useStore(s => s.me)
|
||||
const declared = useStore(s => s.adminSections)
|
||||
const rdmRolesAvailable = useStore(s => s.rdmRolesAvailable)
|
||||
const [sectionKey, setSectionKey] = useState('users')
|
||||
// 双保险:入口已在菜单层门控(me?.isAdmin),此处再拦一次(纵深防御)。
|
||||
if (!me?.isAdmin) return null
|
||||
const section = SECTIONS.find(s => s.key === sectionKey) ?? SECTIONS[0]
|
||||
const isAdmin = !!me?.isAdmin
|
||||
// 门控:基座系统分区仅 isAdmin(纵深防御与菜单层一致);声明分区对全部
|
||||
// 认证主体开放(区内能力由组件命令面判定)。两者皆无时不渲染。
|
||||
if (!isAdmin && declared.length === 0) return null
|
||||
const sections: AdminSectionDef[] = [
|
||||
...(isAdmin ? SECTIONS.filter(s => s.key !== 'rdm-roles' || rdmRolesAvailable) : []),
|
||||
...declared.map(s => ({
|
||||
key: `decl:${s.id}`, label: s.title, icon: declaredIcon(s.icon),
|
||||
desc: s.desc || '插件声明的管理分区(区内数据能力由插件命令面判定)',
|
||||
render: () => <DeclaredSection section={s} />,
|
||||
})),
|
||||
]
|
||||
const fallback = sections[0]?.key ?? ''
|
||||
const section = sections.find(s => s.key === sectionKey) ?? sections[0]
|
||||
if (!section) return null
|
||||
return (
|
||||
<div className="az" role="dialog" aria-label="系统管理">
|
||||
<div className="az-head">
|
||||
@@ -40,14 +84,14 @@ export function AdminZone() {
|
||||
<Icon name="chevron-down" size={14} /> 返回工作台
|
||||
</button>
|
||||
<span className="az-title">系统管理</span>
|
||||
<span className="az-sub">仅管理员可见 · 变更即时生效</span>
|
||||
<span className="az-sub">{isAdmin ? '仅管理员可见' : '按插件声明开放'} · 变更即时生效</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
<span className="az-tenant">{me.owner}</span>
|
||||
<span className="az-tenant">{me?.owner ?? ''}</span>
|
||||
</div>
|
||||
<div className="az-body">
|
||||
<nav className="az-side">
|
||||
{SECTIONS.map(s => (
|
||||
<button key={s.key} className={`az-nav-item ${s.key === sectionKey ? 'on' : ''}`} onClick={() => setSectionKey(s.key)}>
|
||||
{sections.map(s => (
|
||||
<button key={s.key} className={`az-nav-item ${s.key === (section?.key ?? fallback) ? 'on' : ''}`} onClick={() => setSectionKey(s.key)}>
|
||||
<Icon name={s.icon} size={15} />
|
||||
<span>{s.label}</span>
|
||||
</button>
|
||||
|
||||
@@ -3,17 +3,25 @@
|
||||
import { useState } from 'react'
|
||||
import { store, useStore } from '../store'
|
||||
import { api } from '../api'
|
||||
import { Icon } from '@ui'
|
||||
import { Collapsible, Icon } from '@ui'
|
||||
import { artifactIcon, humanSize } from '../messageparts'
|
||||
import type { Artifact } from '../contracts'
|
||||
import { groupArtifactsByName } from '../artifactGrouping'
|
||||
import { surfaceKey } from '../surfaceKey'
|
||||
|
||||
export function ArtifactsPanel() {
|
||||
const artifacts = useStore(s => s.artifacts)
|
||||
const surfaces = useStore(s => s.surfaces)
|
||||
const [q, setQ] = useState('')
|
||||
const [kind, setKind] = useState<'all' | 'document' | 'data' | 'image'>('all')
|
||||
|
||||
const kinds = ['all', 'document', 'data', 'image'] as const
|
||||
const visible = artifacts.filter(a =>
|
||||
(kind === 'all' || a.kind === kind) && a.name.toLowerCase().includes(q.toLowerCase()))
|
||||
// 同名工件 = 同一交付物的版本序列(存储保留全量):面板按名称分组去重,
|
||||
// 只显示最新版;组>1 时可展开看历史版本。2026-10-11 实测 3 份同名转写报告
|
||||
// 并列的直接修复(存储与提示词语义「修订=新版本」保持不变)。
|
||||
const groups = groupArtifactsByName(visible)
|
||||
|
||||
return (
|
||||
<div className="artifacts-panel">
|
||||
@@ -32,26 +40,50 @@ export function ArtifactsPanel() {
|
||||
}} />
|
||||
</label>
|
||||
</div>
|
||||
{surfaces.length > 0 && (
|
||||
<div className="ap-surfaces" data-ap-surfaces>
|
||||
<div className="ap-section-title">生成页面</div>
|
||||
{surfaces.filter(sv => surfaceKey(sv) != null).map(sv => {
|
||||
const id = surfaceKey(sv)!
|
||||
return (
|
||||
<div key={id} className="artifact-row" data-surface-row={id}
|
||||
onClick={() => store.openSurface(id)} title={`打开页面 ${sv.spec?.title ?? ''}`}>
|
||||
<span className="ar-icon"><Icon name="grid" size={15} /></span>
|
||||
<span className="ar-main">
|
||||
<span className="name">{sv.spec?.title || '(未命名页面)'}</span>
|
||||
<span className="meta">页面 · 修订 {sv.revision} · {sv.identity?.pluginId}</span>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{artifacts.length === 0
|
||||
? <div className="empty-note">暂无文件——上传附件或让模型产出文档(mock:#doc);这里可浏览、预览、下载</div>
|
||||
: visible.length === 0
|
||||
? <div className="empty-note">没有匹配「{q}」的文件</div>
|
||||
: visible.map(a => (
|
||||
// 预览落位在第三栏「文件」入口:requestPreview 会切到预览子视图。
|
||||
<div key={a.id} className="artifact-row" data-artifact-row={a.id}
|
||||
onClick={() => store.requestPreview(a)} title={`预览 ${a.name}`}>
|
||||
<span className="ar-icon"><Icon name={artifactIcon(a.name, a.kind)} size={15} /></span>
|
||||
<span className="ar-main">
|
||||
<span className="name">{a.name}</span>
|
||||
<span className="meta">{a.kind} · {humanSize(a.size) || '—'} · {shortTime(a.updatedAt)}</span>
|
||||
</span>
|
||||
<span className="ar-acts">
|
||||
<button className="icon-btn" title="展开预览(铺满主区)" onClick={e => { e.stopPropagation(); store.requestPreview(a); store.setUI({ previewExpanded: true }) }}><Icon name="maximize" size={14} /></button>
|
||||
<a className="icon-btn" title="下载" href={api.artifactDownloadUrl(a.id)} download={a.name}
|
||||
onClick={e => e.stopPropagation()}><Icon name="download" size={14} /></a>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
: groups.map(g => g.versions.length === 1
|
||||
? <ArtifactRow key={g.latest.id} a={g.latest} />
|
||||
: <Collapsible key={g.latest.id} id={'artv:' + g.latest.id}
|
||||
trigger={
|
||||
<div className="artifact-row artifact-group" data-artifact-group={g.latest.id}>
|
||||
<ArtifactRowInner a={g.latest} badge={`${g.versions.length} 版`} />
|
||||
</div>
|
||||
}>
|
||||
{g.versions.slice(1).map(v => (
|
||||
<div key={v.id} className="artifact-row artifact-version" data-artifact-version={v.id}
|
||||
onClick={() => store.requestPreview(v)} title={`预览历史版本 ${v.name}`}>
|
||||
<span className="ar-icon"><Icon name="clock" size={13} /></span>
|
||||
<span className="ar-main">
|
||||
<span className="meta">v{shortTime(v.updatedAt)} · {humanSize(v.size) || '—'}</span>
|
||||
</span>
|
||||
<span className="ar-acts">
|
||||
<a className="icon-btn" title="下载此版本" href={api.artifactDownloadUrl(v.id)} download={v.name}
|
||||
onClick={e => e.stopPropagation()}><Icon name="download" size={13} /></a>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</Collapsible>)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -65,3 +97,32 @@ function shortTime(iso: string): string {
|
||||
: d.toLocaleDateString([], { month: '2-digit', day: '2-digit' })
|
||||
} catch { return '' }
|
||||
}
|
||||
|
||||
function ArtifactRow({ a }: { a: Artifact }) {
|
||||
return (
|
||||
// 预览落位在第三栏「文件」入口:requestPreview 会切到预览子视图。
|
||||
<div className="artifact-row" data-artifact-row={a.id}
|
||||
onClick={() => store.requestPreview(a)} title={`预览 ${a.name}`}>
|
||||
<ArtifactRowInner a={a} />
|
||||
<span className="ar-acts">
|
||||
<button className="icon-btn" title="展开预览(铺满主区)" onClick={e => { e.stopPropagation(); store.requestPreview(a); store.setUI({ previewExpanded: true }) }}><Icon name="maximize" size={14} /></button>
|
||||
<a className="icon-btn" title="下载" href={api.artifactDownloadUrl(a.id)} download={a.name}
|
||||
onClick={e => e.stopPropagation()}><Icon name="download" size={14} /></a>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ArtifactRowInner({ a, badge }: { a: Artifact; badge?: string }) {
|
||||
return (
|
||||
<>
|
||||
<span className="ar-icon"><Icon name={artifactIcon(a.name, a.kind)} size={15} /></span>
|
||||
<span className="ar-main">
|
||||
<span className="name">{a.name}{badge ? <span className="ver-badge" data-ver-badge>{badge}</span> : null}</span>
|
||||
<span className="meta">{a.kind} · {humanSize(a.size) || '—'} · {shortTime(a.updatedAt)}</span>
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -41,6 +41,8 @@ export function LoginPage({ onAuthed, onMustChange }: { onAuthed: () => void; on
|
||||
setErr(''); setBusy(true)
|
||||
try {
|
||||
const me = await api.login(owner.trim(), password)
|
||||
// 会话凭证是 HttpOnly cookie;清残留 Bearer,避免其遮蔽有效会话。
|
||||
auth.beginSession()
|
||||
if (me.mustChangePassword) {
|
||||
// 初始/重置密码:先完成强制改密再放行。
|
||||
onMustChange(me.owner)
|
||||
@@ -57,7 +59,17 @@ export function LoginPage({ onAuthed, onMustChange }: { onAuthed: () => void; on
|
||||
auth.setKey(key)
|
||||
try {
|
||||
const r = await auth.fetch('/api/health')
|
||||
if (r.ok) { onAuthed(); return }
|
||||
if (r.ok) {
|
||||
// 账号处于初始/重置密码状态时,key 完成不了强制改密(改密只认会话登录):
|
||||
// 不放行进死路改密门,引导改走账号密码登录。
|
||||
const me = await api.me().catch(() => null)
|
||||
if (me?.mustChangePassword) {
|
||||
auth.clear()
|
||||
setErr('该账号使用初始/重置密码,请先用账号密码登录完成改密')
|
||||
return
|
||||
}
|
||||
onAuthed(); return
|
||||
}
|
||||
throw new Error(String(r.status))
|
||||
} catch {
|
||||
auth.clear()
|
||||
@@ -118,7 +130,7 @@ export function ChangePasswordGate({ owner, onDone, onCancel }: { owner: string;
|
||||
await api.changePassword(current, next)
|
||||
onDone()
|
||||
} catch (e) {
|
||||
setErr(humanLoginError(String(e)).replace('401: ', ''))
|
||||
setErr(humanChangePasswordError(String(e)))
|
||||
} finally { setBusy(false) }
|
||||
}
|
||||
|
||||
@@ -145,10 +157,23 @@ export function ChangePasswordGate({ owner, onDone, onCancel }: { owner: string;
|
||||
)
|
||||
}
|
||||
|
||||
/** 错误文案:把 HTTP 状态码翻成人话。 */
|
||||
/** 登录错误文案:把 HTTP 状态码翻成人话。 */
|
||||
export function humanLoginError(raw: string): string {
|
||||
if (raw.includes('401')) return '用户名或密码不正确'
|
||||
if (raw.includes('429')) return '失败次数过多,请稍后再试'
|
||||
if (raw.includes('412')) return '本服务未开启登录(嵌入式单租户档)'
|
||||
return raw.replace(/^\d{3}:\s*/, '')
|
||||
}
|
||||
|
||||
/** 改密门错误文案:改密的 401 有多种真实原因(当前密码错/无会话/会话过期/失效 key),
|
||||
* 不能沿用登录的「用户名或密码不正确」一刀切——那会把可自愈的凭证问题伪装成密码错误。
|
||||
* 后端文案是现行 HTTP 契约的一部分,这里按其区分并用测试锁定。 */
|
||||
export function humanChangePasswordError(raw: string): string {
|
||||
if (raw.includes('current password incorrect')) return '当前密码不正确'
|
||||
if (raw.includes('session login required')) return '请先用账号密码登录再改密(API 密钥不能修改密码)'
|
||||
if (raw.includes('session expired')) return '会话已过期,请重新登录'
|
||||
if (raw.includes('invalid or revoked API key')) return '本地残留的 API 密钥已失效,请退出登录后重试'
|
||||
if (raw.includes('429')) return '失败次数过多,请稍后再试'
|
||||
if (raw.includes('401')) return '认证失败,请重新登录'
|
||||
return raw.replace(/^\d{3}:\s*/, '')
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
// 第三栏「文件」入口的多标签预览:打开/切换/关闭标签、编辑保存、展开铺满与下载。
|
||||
// 列表 ⇄ 预览在同一入口内切换;宽度档(阅读档 420–1100)由 App 的分隔条控制。
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Btn, FilePreview, Icon, TabStrip } from '@ui'
|
||||
import { Btn, FilePreview, Icon, SurfaceView, TabStrip } from '@ui'
|
||||
import { api } from '../api'
|
||||
import type { Artifact } from '../contracts'
|
||||
import { humanSize } from '../messageparts'
|
||||
import { previewTabsKey } from '../previewtabs'
|
||||
import { store, useStore } from '../store'
|
||||
import { surfaceKey } from '../surfaceKey'
|
||||
|
||||
export function PreviewPane() {
|
||||
// 键口径与 requestPreview 一致(previewTabsKey):空态预览也渲染。
|
||||
@@ -15,6 +16,13 @@ export function PreviewPane() {
|
||||
const artifacts = useStore(s => s.artifacts)
|
||||
const expanded = useStore(s => s.ui.previewExpanded)
|
||||
const active = tabs.find(t => t.id === activeId) ?? tabs[0]
|
||||
const surfaces = useStore(s => s.surfaces)
|
||||
const surfaceSnap = active?.id.startsWith('surface:')
|
||||
? surfaces.find(x => {
|
||||
const id = surfaceKey(x)
|
||||
return id != null && 'surface:' + id === active.id
|
||||
})
|
||||
: undefined
|
||||
const art = active ? artifacts.find(a => a.id === active.id) : undefined
|
||||
const textKind = !!art && isTextKind(art)
|
||||
const [content, setContent] = useState<string | null>(null)
|
||||
@@ -35,6 +43,32 @@ export function PreviewPane() {
|
||||
}, [art?.id])
|
||||
|
||||
if (!active) return null
|
||||
if (active.id.startsWith('surface:')) {
|
||||
return (
|
||||
<div className="preview-pane">
|
||||
<TabStrip
|
||||
tabs={tabs.map(t => ({ id: t.id, label: t.name }))}
|
||||
activeId={active.id}
|
||||
onActivate={id => store.activatePreview(id)}
|
||||
onClose={id => store.closePreview(id)}
|
||||
/>
|
||||
<div className="preview-pane-bar">
|
||||
<button type="button" className="icon-btn" title="返回文件列表" onClick={() => store.setUI({ filesView: 'list', previewExpanded: false })}>
|
||||
<Icon name="list" size={14} />
|
||||
</button>
|
||||
<span className="preview-pane-name" title={active.name}>{active.name}</span>
|
||||
</div>
|
||||
<div className="preview-pane-body">
|
||||
{surfaceSnap
|
||||
? <SurfaceView snapshot={surfaceSnap} onAction={id => store.handleSurfaceAction(surfaceKey(surfaceSnap) ?? '', id, {})} docBridge={{ onOpenDoc: docId => {
|
||||
const art = artifacts.find(a => a.id === docId)
|
||||
if (art) store.requestPreview(art)
|
||||
} }} />
|
||||
: <p className="surface-view-empty">页面快照不可用(可能已被移除或无权限)。</p>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="preview-pane">
|
||||
<TabStrip
|
||||
|
||||
@@ -7,11 +7,16 @@ import { store, useStore } from '../store'
|
||||
import type { SettingDesc } from '../contracts'
|
||||
import { SettingsShell, Btn, EmptyState } from '@ui'
|
||||
import { SettingInput, flatten, unflatten, groupNames } from './SettingsModal'
|
||||
import { RdmMembersSection, RdmDutiesSection, RdmPermMatrixSection } from './adminRdmSections'
|
||||
|
||||
export function ProjectSettingsModal() {
|
||||
const projectId = useStore(s => s.ui.projectSettingsId)
|
||||
const projects = useStore(s => s.projects)
|
||||
const project = projects.find(p => p.id === projectId)
|
||||
const rdmMembersAvailable = useStore(s => s.rdmMembersAvailable)
|
||||
const rdmDocReviewAvailable = useStore(s => s.rdmDocReviewAvailable)
|
||||
const rdmPermMatrixAvailable = useStore(s => s.rdmPermMatrixAvailable)
|
||||
const [tab, setTab] = useState('project')
|
||||
const [schema, setSchema] = useState<SettingDesc[]>([])
|
||||
const [draft, setDraft] = useState<Record<string, any>>({})
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
@@ -49,16 +54,25 @@ export function ProjectSettingsModal() {
|
||||
return (
|
||||
<SettingsShell
|
||||
title={`项目设置 · ${project?.name ?? projectId}`}
|
||||
active="project"
|
||||
onSelect={() => undefined}
|
||||
active={tab}
|
||||
onSelect={setTab}
|
||||
onClose={() => store.openProjectSettings(null)}
|
||||
sections={[{ key: 'project', label: '本项目', icon: 'folder' }]}
|
||||
sections={[
|
||||
{ key: 'project', label: '本项目', icon: 'folder' },
|
||||
// U6/KTD5:成员与文档职责页签按 RDM 命令面门控(普通基座不出现)。
|
||||
...(rdmMembersAvailable ? [{ key: 'members', label: '成员', icon: 'users' as const }] : []),
|
||||
...(rdmDocReviewAvailable ? [{ key: 'duties', label: '文档职责 · 整文审签', icon: 'shield' as const }] : []),
|
||||
...(rdmPermMatrixAvailable ? [{ key: 'matrix', label: '项目权限', icon: 'key' as const }] : []),
|
||||
]}
|
||||
footer={saved ? <span style={{ color: 'var(--ok)' }}>✓ 已保存</span> : '项目设置覆盖用户设置(new-run 类对新任务生效)'}
|
||||
>
|
||||
<div>
|
||||
{!loaded ? (
|
||||
{tab === 'members' && <RdmMembersSection projectId={projectId} />}
|
||||
{tab === 'duties' && <RdmDutiesSection projectId={projectId} />}
|
||||
{tab === 'matrix' && <RdmPermMatrixSection projectId={projectId} />}
|
||||
{tab !== 'members' && tab !== 'duties' && tab !== 'matrix' && !loaded ? (
|
||||
<EmptyState icon="settings">加载中…</EmptyState>
|
||||
) : (
|
||||
) : tab !== 'members' && tab !== 'duties' && tab !== 'matrix' ? (
|
||||
<>
|
||||
<div className="empty-note" style={{ marginBottom: 10 }}>
|
||||
此处仅保存本项目的覆盖值;留空即沿用用户/系统设置。跨会话读取在「会话协作」组。
|
||||
@@ -79,7 +93,7 @@ export function ProjectSettingsModal() {
|
||||
))}
|
||||
<Btn variant="primary" icon="check" onClick={save}>保存项目设置</Btn>
|
||||
</>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
</SettingsShell>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,520 @@
|
||||
// U4:项目追溯审计工作区(四入口)——消费 U3 rdm.inspect.* 命令面。
|
||||
// 只读:审批/确认关系仍回原业务流程(D31:弹层不是唯一路径)。
|
||||
// 令牌驱动(DESIGN.md):颜色/字号/圆角仅消费 @ui/tokens.css;折叠用 Collapsible。
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { api } from '../api'
|
||||
import { Chip, Collapsible, EmptyState, SectionLabel } from '@ui'
|
||||
import { classifyRdmError } from '../rdmErrors'
|
||||
|
||||
// ---- 类型(U3 ListShell/详情的精简前端契约) ----
|
||||
|
||||
interface InspectProject { projectId: string; name: string; canAudit: boolean }
|
||||
interface Capabilities { actor: string; projects: InspectProject[]; crossProject: boolean }
|
||||
interface CoverageItem { face: string; reason: string }
|
||||
interface ListShell<T = any> { items: T[]; nextCursor?: string; hasMore: boolean; truncated: boolean; coverage?: CoverageItem[] }
|
||||
interface SummaryData { projectId: string; counts: Record<string, number>; watermark: number; lastOpAt: string; coverage?: CoverageItem[] }
|
||||
interface TreeItem { kind: string; id: string; title: string; state?: string }
|
||||
interface RelationEntry { layer: string; edgeId?: string; kind?: string; type?: string; state: string; depth?: number; from: any; to: any; conf?: number }
|
||||
interface EventDTO { seq: number; at: string; op: string; actor: string; source: string; target: { kind: string; id: string }; detail: string; operationId: string; disclosure: string }
|
||||
interface OpGroup { operationId: string; actor: string; at: string; events: EventDTO[] }
|
||||
interface ObjectDetail { kind: string; id: string; title?: string; state?: string; versions?: any[]; formal?: any; labels?: any; batch?: string; ver?: number; relations: RelationEntry[]; recentEvents: EventDTO[]; coverage?: CoverageItem[] }
|
||||
interface HistoryData { projectId: string; watermark: number; atSeq: number; object?: any; formal?: any; formalMissing?: string; countOnlySnapshots?: number; coverage?: CoverageItem[]; empty?: boolean }
|
||||
|
||||
// ---- 工作区主组件 ----
|
||||
|
||||
const TABS = [
|
||||
{ id: 'summary', label: '概览' },
|
||||
{ id: 'materials', label: '资料与文档' },
|
||||
{ id: 'trace', label: '关联追溯' },
|
||||
{ id: 'timeline', label: '操作记录' },
|
||||
] as const
|
||||
type TabId = (typeof TABS)[number]['id']
|
||||
|
||||
/** 错误态(增量 2):分类文案(能力缺失/登录失效/查询失败)+ 原始回执折叠供诊断;
|
||||
* 「无可审计项目」由调用处独立空态承担,不走本组件。 */
|
||||
function RdmErrorState({ raw }: { raw: string }) {
|
||||
const c = classifyRdmError(raw)
|
||||
return (
|
||||
<div className="rdm-audit-err">
|
||||
<EmptyState icon="alert">{c.message}</EmptyState>
|
||||
<Collapsible id="rdm-audit-err-receipt" trigger={<span className="rdm-audit-err-toggle">原始回执</span>}>
|
||||
<code className="rdm-audit-err-raw">{raw}</code>
|
||||
</Collapsible>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function RdmAuditWorkspace() {
|
||||
const [caps, setCaps] = useState<Capabilities | null>(null)
|
||||
const [pid, setPid] = useState('')
|
||||
const [tab, setTab] = useState<TabId>('summary')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
api.rdmCommand<Capabilities>('rdm.inspect.capabilities')
|
||||
.then(c => { if (!cancelled) { setCaps(c); if (c.projects.length > 0) setPid(prev => prev || c.projects[0].projectId) } })
|
||||
.catch(e => { if (!cancelled) setError(e.message) })
|
||||
return () => { cancelled = true }
|
||||
}, [])
|
||||
|
||||
const project = caps?.projects.find(p => p.projectId === pid)
|
||||
if (error) return <RdmErrorState raw={error} />
|
||||
if (!caps) return <div className="empty-note">加载中…</div>
|
||||
if (caps.projects.length === 0) return <EmptyState icon="folder">无可见研发项目——项目创建者与域管理员可查看追溯审计</EmptyState>
|
||||
|
||||
return (
|
||||
<div className="rdm-audit">
|
||||
<div className="rdm-audit-header">
|
||||
<select className="ui-input rdm-audit-project" value={pid} onChange={e => setPid(e.target.value)} aria-label="选择项目">
|
||||
{caps.projects.map(p => (
|
||||
<option key={p.projectId} value={p.projectId}>{p.name}</option>
|
||||
))}
|
||||
</select>
|
||||
{project?.canAudit && <Chip tone="ok">可审计</Chip>}
|
||||
{caps.crossProject && <Chip tone="info">跨项目</Chip>}
|
||||
</div>
|
||||
<div className="tabs rdm-audit-tabs" role="tablist">
|
||||
{TABS.map(t => (
|
||||
<button key={t.id} role="tab" aria-selected={tab === t.id}
|
||||
className={`tab${tab === t.id ? ' active' : ''}`}
|
||||
onClick={() => setTab(t.id)}>{t.label}</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="rdm-audit-body">
|
||||
{tab === 'summary' && <SummaryTab pid={pid} />}
|
||||
{tab === 'materials' && <MaterialsTab pid={pid} />}
|
||||
{tab === 'trace' && <TraceTab pid={pid} />}
|
||||
{tab === 'timeline' && <TimelineTab pid={pid} />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---- 概览 ----
|
||||
|
||||
function SummaryTab({ pid }: { pid: string }) {
|
||||
const [data, setData] = useState<SummaryData | null>(null)
|
||||
const [err, setErr] = useState('')
|
||||
useEffect(() => {
|
||||
let c = false
|
||||
setData(null); setErr('')
|
||||
api.rdmCommand<SummaryData>('rdm.inspect.summary', { projectId: pid })
|
||||
.then(d => { if (!c) setData(d) })
|
||||
.catch(e => { if (!c) setErr(e.message) })
|
||||
return () => { c = true }
|
||||
}, [pid])
|
||||
if (err) return <RdmErrorState raw={err} />
|
||||
if (!data) return <div className="empty-note">加载中…</div>
|
||||
const counts = data.counts || {}
|
||||
const entries: [string, string, number][] = [
|
||||
['切片', 'slices', counts.slices ?? 0],
|
||||
['章节', 'sections', counts.sections ?? 0],
|
||||
['文档', 'docs', counts.docs ?? 0],
|
||||
['生效边', 'edgesActive', counts.edgesActive ?? 0],
|
||||
['待确认边', 'edgesCandidate', counts.edgesCandidate ?? 0],
|
||||
['结论', 'conclusions', counts.conclusions ?? 0],
|
||||
['待办', 'openItems', counts.openItems ?? 0],
|
||||
['挂起上传', 'uploadsHeld', counts.uploadsHeld ?? 0],
|
||||
['空缺章节', 'emptySections', counts.emptySections ?? 0],
|
||||
]
|
||||
return (
|
||||
<div>
|
||||
<SectionLabel>统计(截至水位 {data.watermark})</SectionLabel>
|
||||
<div className="rdm-audit-cards">
|
||||
{entries.map(([label, , val]) => (
|
||||
<div key={label} className="rdm-audit-card">
|
||||
<div className="rdm-audit-card-num">{val}</div>
|
||||
<div className="rdm-audit-card-label">{label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{data.lastOpAt && <div className="rdm-audit-meta">最近动作:{new Date(data.lastOpAt).toLocaleString('zh-CN')}</div>}
|
||||
{data.coverage && data.coverage.length > 0 && (
|
||||
<CoverageList cov={data.coverage} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---- 资料与文档 ----
|
||||
|
||||
function MaterialsTab({ pid }: { pid: string }) {
|
||||
const [kind, setKind] = useState<'notes' | 'docs'>('docs')
|
||||
return (
|
||||
<div className="rdm-audit-materials">
|
||||
<div className="rdm-audit-materials-side">
|
||||
<div className="tabs">
|
||||
<button className={`tab${kind === 'notes' ? ' active' : ''}`} onClick={() => setKind('notes')}>笔记树</button>
|
||||
<button className={`tab${kind === 'docs' ? ' active' : ''}`} onClick={() => setKind('docs')}>文档树</button>
|
||||
</div>
|
||||
<TreeList pid={pid} kind={kind} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TreeList({ pid, kind }: { pid: string; kind: 'notes' | 'docs' }) {
|
||||
const [shell, setShell] = useState<ListShell<TreeItem> | null>(null)
|
||||
const [err, setErr] = useState('')
|
||||
const [selected, setSelected] = useState<TreeItem | null>(null)
|
||||
const [detail, setDetail] = useState<ObjectDetail | null>(null)
|
||||
// docs 树是层级结构(rdm.inspect.tree parent=<docId> 才返回章节):文档节点
|
||||
// 点击展开子章节,不直接查对象详情(doc 不在 inspect.object 支持面)。
|
||||
const [children, setChildren] = useState<Record<string, TreeItem[]>>({})
|
||||
const genRef = useRef(0)
|
||||
|
||||
useEffect(() => {
|
||||
const gen = ++genRef.current
|
||||
setShell(null); setErr(''); setSelected(null); setDetail(null); setChildren({})
|
||||
api.rdmCommand<ListShell<TreeItem>>('rdm.inspect.tree', { projectId: pid, kind, cursor: '' })
|
||||
.then(s => { if (gen === genRef.current) setShell(s) })
|
||||
.catch(e => { if (gen === genRef.current) setErr(e.message) })
|
||||
}, [pid, kind])
|
||||
|
||||
const loadMore = () => {
|
||||
if (!shell?.nextCursor) return
|
||||
const gen = genRef.current
|
||||
api.rdmCommand<ListShell<TreeItem>>('rdm.inspect.tree', { projectId: pid, kind, cursor: shell.nextCursor })
|
||||
.then(s => { if (gen === genRef.current) setShell(prev => prev ? { ...s, items: [...prev.items, ...s.items] } : s) })
|
||||
.catch(e => { if (gen === genRef.current) setErr(e.message) })
|
||||
}
|
||||
|
||||
const toggleDoc = (item: TreeItem) => {
|
||||
if (children[item.id]) {
|
||||
setChildren(prev => { const next = { ...prev }; delete next[item.id]; return next })
|
||||
return
|
||||
}
|
||||
const gen = genRef.current
|
||||
api.rdmCommand<ListShell<TreeItem>>('rdm.inspect.tree', { projectId: pid, kind: 'docs', parent: item.id, cursor: '' })
|
||||
.then(s => { if (gen === genRef.current) setChildren(prev => ({ ...prev, [item.id]: s.items })) })
|
||||
.catch(e => { if (gen === genRef.current) setErr(e.message) })
|
||||
}
|
||||
|
||||
const selectNode = (item: TreeItem) => {
|
||||
if (item.kind === 'doc') { toggleDoc(item); return }
|
||||
setSelected(item); setDetail(null)
|
||||
const gen = genRef.current
|
||||
api.rdmCommand<ObjectDetail>('rdm.inspect.object', { projectId: pid, kind: item.kind, id: item.id })
|
||||
.then(d => { if (gen === genRef.current) setDetail(d) })
|
||||
.catch(e => { if (gen === genRef.current) setErr(e.message) })
|
||||
}
|
||||
|
||||
if (err) return <RdmErrorState raw={err} />
|
||||
if (!shell) return <div className="empty-note">加载中…</div>
|
||||
if (shell.items.length === 0) return <EmptyState icon="folder">暂无{kind === 'notes' ? '骨架节点' : '文档'}</EmptyState>
|
||||
|
||||
return (
|
||||
<div className="rdm-audit-tree">
|
||||
<div className="rdm-audit-tree-list">
|
||||
{shell.items.map(item => (
|
||||
<div key={item.id}>
|
||||
<button className={`rdm-audit-tree-node${selected?.id === item.id ? ' sel' : ''}`}
|
||||
onClick={() => selectNode(item)}>
|
||||
{item.kind === 'doc' && <span className="rdm-audit-tree-caret">{children[item.id] ? '▾' : '▸'}</span>}
|
||||
<span className="rdm-audit-tree-kind">{item.kind}</span>
|
||||
<span className="rdm-audit-tree-title">{item.title}</span>
|
||||
{item.state && <Chip tone={item.state === 'baselined' ? 'ok' : 'plain'}>{item.state}</Chip>}
|
||||
</button>
|
||||
{item.kind === 'doc' && children[item.id] && (
|
||||
<div className="rdm-audit-tree-children">
|
||||
{children[item.id].length === 0 && <div className="empty-note">无章节</div>}
|
||||
{children[item.id].map(sub => (
|
||||
<button key={sub.id} className={`rdm-audit-tree-node${selected?.id === sub.id ? ' sel' : ''}`}
|
||||
onClick={() => selectNode(sub)}>
|
||||
<span className="rdm-audit-tree-kind">{sub.kind}</span>
|
||||
<span className="rdm-audit-tree-title">{sub.title}</span>
|
||||
{sub.state && <Chip tone={sub.state === 'baselined' ? 'ok' : 'plain'}>{sub.state}</Chip>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{shell.hasMore && <button className="btn v-ghost rdm-audit-more" onClick={loadMore}>加载更多</button>}
|
||||
{shell.truncated && <div className="rdm-audit-truncated">已截断(深度或数量上限)</div>}
|
||||
</div>
|
||||
{selected && (
|
||||
<div className="rdm-audit-detail">
|
||||
<SectionLabel>对象详情</SectionLabel>
|
||||
{!detail ? <div className="empty-note">加载中…</div> : <ObjectDetailView detail={detail} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---- 关联追溯 ----
|
||||
|
||||
function TraceTab({ pid }: { pid: string }) {
|
||||
const [sel, setSel] = useState<{ kind: string; id: string; title: string } | null>(null)
|
||||
const [relations, setRelations] = useState<RelationEntry[] | null>(null)
|
||||
const [truncated, setTruncated] = useState(false)
|
||||
const [err, setErr] = useState('')
|
||||
const genRef = useRef(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (!sel) return
|
||||
const gen = ++genRef.current
|
||||
setRelations(null); setErr('')
|
||||
api.rdmCommand<ListShell<RelationEntry>>('rdm.inspect.relations', { projectId: pid, kind: sel.kind, id: sel.id, depth: 1 })
|
||||
.then(s => { if (gen === genRef.current) { setRelations(s.items); setTruncated(s.truncated) } })
|
||||
.catch(e => { if (gen === genRef.current) setErr(e.message) })
|
||||
}, [pid, sel])
|
||||
|
||||
// 未选对象时列出章节供选择。docs 树是层级结构:章节挂在各文档下
|
||||
// (parent=<docId>),按文档聚合后合并展示。
|
||||
const [sections, setSections] = useState<TreeItem[]>([])
|
||||
useEffect(() => {
|
||||
if (sel) return
|
||||
let c = false
|
||||
;(async () => {
|
||||
const top = await api.rdmCommand<ListShell<TreeItem>>('rdm.inspect.tree', { projectId: pid, kind: 'docs' })
|
||||
const secs: TreeItem[] = []
|
||||
for (const d of top.items.filter(i => i.kind === 'doc')) {
|
||||
const sub = await api.rdmCommand<ListShell<TreeItem>>('rdm.inspect.tree', { projectId: pid, kind: 'docs', parent: d.id })
|
||||
secs.push(...sub.items.filter(i => i.kind === 'section'))
|
||||
}
|
||||
if (!c) setSections(secs)
|
||||
})().catch(() => {})
|
||||
return () => { c = true }
|
||||
}, [pid, sel])
|
||||
|
||||
if (!sel) {
|
||||
return (
|
||||
<div>
|
||||
<SectionLabel>选择对象查看来源与影响</SectionLabel>
|
||||
{sections.length === 0 ? <EmptyState icon="folder">暂无章节</EmptyState> : (
|
||||
<div className="rdm-audit-pick">
|
||||
{sections.map(s => (
|
||||
<button key={s.id} className="btn v-ghost" onClick={() => setSel({ kind: 'section', id: s.id, title: s.title })}>
|
||||
{s.title}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const sources = relations?.filter(r => r.to?.id === sel.id && r.to?.kind === sel.kind) ?? []
|
||||
const impacts = relations?.filter(r => r.from?.id === sel.id && r.from?.kind === sel.kind) ?? []
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="rdm-audit-trace-head">
|
||||
<button className="btn v-ghost" onClick={() => setSel(null)}>← 返回</button>
|
||||
<strong>{sel.title}</strong>
|
||||
<Chip tone="plain">{sel.kind}:{sel.id}</Chip>
|
||||
</div>
|
||||
{err && <RdmErrorState raw={err} />}
|
||||
{!relations && !err && <div className="empty-note">加载中…</div>}
|
||||
{relations && relations.length === 0 && <EmptyState icon="folder">没有已知关联</EmptyState>}
|
||||
{relations && relations.length > 0 && (
|
||||
<div className="rdm-audit-trace-cols">
|
||||
<div className="rdm-audit-trace-col">
|
||||
<SectionLabel>来源依据({sources.length})</SectionLabel>
|
||||
{sources.length === 0 ? <div className="empty-note">无来源</div> : sources.map((r, i) => <RelationRow key={i} r={r} />)}
|
||||
</div>
|
||||
<div className="rdm-audit-trace-col">
|
||||
<SectionLabel>影响对象({impacts.length})</SectionLabel>
|
||||
{impacts.length === 0 ? <div className="empty-note">无影响</div> : impacts.map((r, i) => <RelationRow key={i} r={r} />)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{truncated && <div className="rdm-audit-truncated">已截断(深度或数量上限)</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RelationRow({ r }: { r: RelationEntry }) {
|
||||
const other = r.from?.id && r.to?.id ? (r.from?.id < r.to?.id ? r.from : r.to) : (r.from || r.to)
|
||||
return (
|
||||
<div className="rdm-audit-rel">
|
||||
<Chip tone={r.layer === 'compliance' ? 'info' : 'plain'}>{r.layer}</Chip>
|
||||
<span className="rdm-audit-rel-type">{r.type || r.kind || '关联'}</span>
|
||||
<span className="rdm-audit-rel-endpoint">{other?.kind}:{other?.id}{other?.ver ? `@${other.ver}` : ''}</span>
|
||||
<Chip tone={r.state === 'active' ? 'ok' : r.state === 'candidate' ? 'warn' : 'plain'}>{r.state}</Chip>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---- 操作记录(事件时间线) ----
|
||||
|
||||
function TimelineTab({ pid }: { pid: string }) {
|
||||
const [items, setItems] = useState<(OpGroup | EventDTO)[]>([])
|
||||
const [cursor, setCursor] = useState('')
|
||||
const [hasMore, setHasMore] = useState(false)
|
||||
const [err, setErr] = useState('')
|
||||
const [filterOp, setFilterOp] = useState('')
|
||||
const [filterActor, setFilterActor] = useState('')
|
||||
const [historyMode, setHistoryMode] = useState(false)
|
||||
const [historySeq, setHistorySeq] = useState(0)
|
||||
const [historyData, setHistoryData] = useState<HistoryData | null>(null)
|
||||
const genRef = useRef(0)
|
||||
|
||||
const load = useCallback((cur: string, replace: boolean) => {
|
||||
const gen = ++genRef.current
|
||||
if (replace) { setItems([]); setErr('') }
|
||||
const params: Record<string, unknown> = { projectId: pid, cursor: cur, limit: 30 }
|
||||
if (filterOp) params.op = filterOp
|
||||
if (filterActor) params.actor = filterActor
|
||||
api.rdmCommand<ListShell<OpGroup | EventDTO>>('rdm.inspect.events', params)
|
||||
.then(s => {
|
||||
if (gen !== genRef.current) return
|
||||
setItems(prev => replace ? s.items : [...prev, ...s.items])
|
||||
setCursor(s.nextCursor || '')
|
||||
setHasMore(s.hasMore)
|
||||
})
|
||||
.catch(e => { if (gen === genRef.current) setErr(e.message) })
|
||||
}, [pid, filterOp, filterActor])
|
||||
|
||||
useEffect(() => { if (!historyMode) load('', true) }, [load, historyMode])
|
||||
|
||||
useEffect(() => {
|
||||
if (!historyMode) { setHistoryData(null); return }
|
||||
const gen = genRef.current
|
||||
setHistoryData(null)
|
||||
const params: Record<string, unknown> = { projectId: pid, atSeq: historySeq || undefined }
|
||||
api.rdmCommand<HistoryData>('rdm.inspect.history', params)
|
||||
.then(d => { if (gen === genRef.current) setHistoryData(d) })
|
||||
.catch(e => { if (gen === genRef.current) setErr(e.message) })
|
||||
}, [pid, historyMode, historySeq])
|
||||
|
||||
return (
|
||||
<div className="rdm-audit-timeline">
|
||||
<div className="rdm-audit-timeline-filters">
|
||||
<input className="ui-input" placeholder="过滤操作者…" value={filterActor}
|
||||
onChange={e => setFilterActor(e.target.value)} />
|
||||
<input className="ui-input" placeholder="过滤操作类型…" value={filterOp}
|
||||
onChange={e => setFilterOp(e.target.value)} />
|
||||
<button className={`btn v-ghost${historyMode ? ' active' : ''}`} onClick={() => setHistoryMode(!historyMode)}>
|
||||
{historyMode ? '← 返回当前' : '历史模式'}
|
||||
</button>
|
||||
{historyMode && (
|
||||
<input className="ui-input rdm-audit-seq" type="number" placeholder="截止 seq…"
|
||||
value={historySeq || ''} onChange={e => setHistorySeq(Number(e.target.value) || 0)} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{historyMode ? (
|
||||
<div>
|
||||
{!historyData ? <div className="empty-note">加载中…</div> : (
|
||||
<div>
|
||||
<SectionLabel>截至 seq {historyData.atSeq}(当前水位 {historyData.watermark})</SectionLabel>
|
||||
{historyData.countOnlySnapshots ? <div className="rdm-audit-truncated">含 {historyData.countOnlySnapshots} 个旧计数快照(不可作恢复依据)</div> : null}
|
||||
{historyData.formalMissing ? <div className="empty-note">{historyData.formalMissing}</div> : null}
|
||||
{historyData.object ? (
|
||||
<div className="rdm-audit-detail-body">
|
||||
<div>标题:{historyData.object.title}</div>
|
||||
<div>状态:{historyData.object.state}</div>
|
||||
{historyData.object.contentPreview && <div>内容预览:{historyData.object.contentPreview}</div>}
|
||||
</div>
|
||||
) : historyData.empty ? <EmptyState icon="folder">该时点无事件</EmptyState> :
|
||||
<div className="empty-note">输入对象 kind:id 查看历史快照</div>}
|
||||
{historyData.formal && (
|
||||
<div className="rdm-audit-formal">
|
||||
正式版 R{historyData.formal.rev}(内容 v{historyData.formal.seq})——{historyData.formal.actor} 于 {new Date(historyData.formal.at).toLocaleString('zh-CN')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{err && <RdmErrorState raw={err} />}
|
||||
{!err && items.length === 0 && <EmptyState icon="clock">暂无事件</EmptyState>}
|
||||
{items.map((item, i) => {
|
||||
if ('events' in item && Array.isArray((item as OpGroup).events)) {
|
||||
return <OpGroupView key={item.operationId || i} group={item as OpGroup} />
|
||||
}
|
||||
return <EventRow key={(item as EventDTO).seq} ev={item as EventDTO} />
|
||||
})}
|
||||
{hasMore && <button className="btn v-ghost rdm-audit-more" onClick={() => load(cursor, false)}>加载更多</button>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function OpGroupView({ group }: { group: OpGroup }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
return (
|
||||
<div className="rdm-audit-opgroup">
|
||||
<button className="rdm-audit-opgroup-head" onClick={() => setOpen(!open)}>
|
||||
<span className="rdm-audit-opgroup-actor">{group.actor}</span>
|
||||
<span className="rdm-audit-opgroup-time">{new Date(group.at).toLocaleString('zh-CN')}</span>
|
||||
<Chip tone="plain">{group.events.length} 个事件</Chip>
|
||||
</button>
|
||||
{open && group.events.map(ev => <EventRow key={ev.seq} ev={ev} />)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EventRow({ ev }: { ev: EventDTO }) {
|
||||
return (
|
||||
<div className="rdm-audit-event">
|
||||
<span className="rdm-audit-event-time">{new Date(ev.at).toLocaleTimeString('zh-CN')}</span>
|
||||
<span className="rdm-audit-event-op">{ev.op}</span>
|
||||
<span className="rdm-audit-event-actor">{ev.actor}</span>
|
||||
{ev.detail && <span className="rdm-audit-event-detail">{ev.detail.slice(0, 80)}</span>}
|
||||
{ev.disclosure !== 'ok' && <Chip tone="warn">{ev.disclosure === 'model-candidate' ? '模型候选' : '来源受限'}</Chip>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---- 对象详情视图 ----
|
||||
|
||||
function ObjectDetailView({ detail }: { detail: ObjectDetail }) {
|
||||
return (
|
||||
<div className="rdm-audit-detail-body">
|
||||
<div><strong>{detail.title || detail.id}</strong></div>
|
||||
<div>{detail.kind}:{detail.id}{detail.ver ? `@${detail.ver}` : ''}</div>
|
||||
{detail.state && <div>状态:<Chip tone={detail.state === 'baselined' ? 'ok' : 'plain'}>{detail.state}</Chip></div>}
|
||||
{detail.labels && <div>标签:{detail.labels.stage}{detail.labels.topics?.length ? ` / ${detail.labels.topics.join(', ')}` : ''}</div>}
|
||||
{detail.versions && detail.versions.length > 0 && (
|
||||
<div>
|
||||
<SectionLabel>版本链({detail.versions.length})</SectionLabel>
|
||||
{detail.versions.map((v: any, i: number) => (
|
||||
<div key={i} className="rdm-audit-version">
|
||||
v{v.seq} · {v.actor} · {new Date(v.at).toLocaleString('zh-CN')}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{detail.formal && (
|
||||
<div className="rdm-audit-formal">
|
||||
正式版 R{detail.formal.rev}(内容 v{detail.formal.seq})——{detail.formal.actor} 于 {new Date(detail.formal.at).toLocaleString('zh-CN')}
|
||||
</div>
|
||||
)}
|
||||
{detail.relations && detail.relations.length > 0 && (
|
||||
<div>
|
||||
<SectionLabel>一跳关系({detail.relations.length})</SectionLabel>
|
||||
{detail.relations.map((r, i) => <RelationRow key={i} r={r} />)}
|
||||
</div>
|
||||
)}
|
||||
{detail.recentEvents && detail.recentEvents.length > 0 && (
|
||||
<div>
|
||||
<SectionLabel>最近事件</SectionLabel>
|
||||
{detail.recentEvents.map(ev => <EventRow key={ev.seq} ev={ev} />)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---- 覆盖范围标注(不冒充完整) ----
|
||||
|
||||
function CoverageList({ cov }: { cov: CoverageItem[] }) {
|
||||
return (
|
||||
<div className="rdm-audit-coverage">
|
||||
<SectionLabel>覆盖范围</SectionLabel>
|
||||
{cov.map(c => (
|
||||
<div key={c.face} className="rdm-audit-coverage-item">
|
||||
<Chip tone="plain">{c.face}</Chip>
|
||||
<span>{c.reason}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -112,6 +112,16 @@ export function SettingsModal() {
|
||||
)
|
||||
}
|
||||
|
||||
// 输入框语义(2026-10-12 增量 3):仅秘密字段用密码框;普通文本用 text——
|
||||
// 无条件 type="password" 会把整排设置项当凭据表单(浏览器「password 不在 form 中/
|
||||
// 缺少 username」警告来源之一),也把普通文本无谓遮蔽。
|
||||
export function settingInputAttrs(desc: SettingDesc): { type: 'text' | 'password'; autoComplete: string } {
|
||||
const isSecret = desc.secret || desc.type === 'secret'
|
||||
return isSecret
|
||||
? { type: 'password', autoComplete: 'new-password' }
|
||||
: { type: 'text', autoComplete: 'off' }
|
||||
}
|
||||
|
||||
export function SettingInput({ desc, value, onChange }: { desc: SettingDesc; value: any; onChange: (v: any) => void }) {
|
||||
if (desc.type === 'boolean') {
|
||||
return <input type="checkbox" checked={value === true || value === 'true'} onChange={e => onChange(e.target.checked)} />
|
||||
@@ -122,7 +132,8 @@ export function SettingInput({ desc, value, onChange }: { desc: SettingDesc; val
|
||||
</select>
|
||||
}
|
||||
const isSecret = desc.secret || desc.type === 'secret'
|
||||
return <input type="password" autoComplete="new-password" style={{ width: '100%' }} value={typeof value === 'string' && value === '••••••' ? '' : (value ?? '')}
|
||||
const attrs = settingInputAttrs(desc)
|
||||
return <input type={attrs.type} autoComplete={attrs.autoComplete} style={{ width: '100%' }} value={typeof value === 'string' && value === '••••••' ? '' : (value ?? '')}
|
||||
placeholder={isSecret ? '(已配置,输入以覆盖)' : String(desc.default ?? '')}
|
||||
onChange={e => onChange(e.target.value)} />
|
||||
}
|
||||
|
||||
@@ -40,11 +40,14 @@ export function TaskSidebar() {
|
||||
const projects = useStore(s => s.projects)
|
||||
const tasks = useStore(s => s.tasks)
|
||||
const me = useStore(s => s.me)
|
||||
const adminSections = useStore(s => s.adminSections)
|
||||
const artifacts = useStore(s => s.artifacts)
|
||||
const currentConvId = useStore(s => s.currentConvId)
|
||||
const currentProjectId = useStore(s => s.currentProjectId)
|
||||
const showArchived = useStore(s => s.ui.showArchived)
|
||||
const serverWorkspaces = useStore(s => s.serverWorkspaces)
|
||||
/** 审计入口能力门控(增量 2):命令面探测通过才展示「追溯审计」。 */
|
||||
const rdmAuditAvailable = useStore(s => s.rdmAuditAvailable)
|
||||
const theme = useStore(s => s.ui.theme)
|
||||
const pendingBadge = useStore(s =>
|
||||
s.cards.filter(c => c.status === 'action_required').length +
|
||||
@@ -66,7 +69,7 @@ export function TaskSidebar() {
|
||||
return () => window.removeEventListener('keydown', h, true)
|
||||
}, [])
|
||||
|
||||
const tools = buildWorkspaceEntries(serverWorkspaces)
|
||||
const tools = buildWorkspaceEntries(serverWorkspaces, { rdmAudit: rdmAuditAvailable })
|
||||
const visible = filterTasks(tasks.filter(t => t.projectId === currentProjectId), q)
|
||||
const pinned = visible.filter(t => t.pinned)
|
||||
const rest = visible.filter(t => !t.pinned)
|
||||
@@ -265,7 +268,7 @@ export function TaskSidebar() {
|
||||
<MenuItem icon="sun" onClick={() => { store.setUI({ theme: 'light' }); setMenu(null) }}>浅色主题{theme === 'light' ? ' · 当前' : ''}</MenuItem>
|
||||
<MenuItem onClick={() => { store.setUI({ theme: 'molv' }); setMenu(null) }}>墨绿主题{theme === 'molv' ? ' · 当前' : ''}</MenuItem>
|
||||
<MenuItem onClick={() => { store.setUI({ theme: 'system' }); setMenu(null) }}>跟随系统{theme === 'system' ? ' · 当前' : ''}</MenuItem>
|
||||
{me?.isAdmin && <MenuItem icon="shield" onClick={() => { store.setUI({ adminZoneOpen: true }); setMenu(null) }}>系统管理…</MenuItem>}
|
||||
{(me?.isAdmin || adminSections.length > 0) && <MenuItem icon="shield" onClick={() => { store.setUI({ adminZoneOpen: true }); setMenu(null) }}>系统管理…</MenuItem>}
|
||||
{me?.auth && <>
|
||||
<div className="tsb-menu-div" />
|
||||
<MenuItem danger icon="logout" onClick={() => { api.logout().catch(() => undefined); auth.logout() }}>退出登录</MenuItem>
|
||||
|
||||
@@ -4,8 +4,9 @@
|
||||
import { store, useStore } from '../store'
|
||||
import type { Run } from '../contracts'
|
||||
import { InboxList, pendingCount, type ActivityCard, type DocBridge, type InboxFilter } from '@ui'
|
||||
import { Icon } from '@ui'
|
||||
import { Icon, SurfaceView } from '@ui'
|
||||
import { previewTabsKey } from '../previewtabs'
|
||||
import { surfaceKey } from '../surfaceKey'
|
||||
import { ArtifactsPanel } from './ArtifactsPanel'
|
||||
import { PreviewPane } from './PreviewPane'
|
||||
|
||||
@@ -91,6 +92,11 @@ function FilesTab() {
|
||||
return <div className="tab-body"><ArtifactsPanel /></div>
|
||||
}
|
||||
|
||||
/** U1:同会话存在后台 progress 卡(进行中/待确认)时,progress 为权威对象,隐藏 CurrentTaskCard 避免双标题。 */
|
||||
export function hasActiveProgressCard(cards: ActivityCard[]): boolean {
|
||||
return cards.some(c => c.kind === 'progress' && (c.status === 'active' || c.status === 'action_required'))
|
||||
}
|
||||
|
||||
function WorkTab({ plan, todos, filter, inboxScope, projectCards, cards }: {
|
||||
plan: Parameters<typeof CurrentTaskCard>[0]['plan']
|
||||
todos: Parameters<typeof CurrentTaskCard>[0]['todos']
|
||||
@@ -99,10 +105,11 @@ function WorkTab({ plan, todos, filter, inboxScope, projectCards, cards }: {
|
||||
projectCards: ActivityCard[]
|
||||
cards: ActivityCard[]
|
||||
}) {
|
||||
const showCurrentTask = !hasActiveProgressCard(cards)
|
||||
return (
|
||||
<>
|
||||
<div className="tab-body" data-scroll-sync="inbox">
|
||||
<CurrentTaskCard plan={plan} todos={todos} />
|
||||
{showCurrentTask ? <CurrentTaskCard plan={plan} todos={todos} /> : null}
|
||||
<div className="inbox-scope" role="tablist" aria-label="收件箱范围与筛选">
|
||||
<button className={inboxScope === 'conv' ? 'active' : ''} onClick={() => store.setInboxScope('conv')}>本会话</button>
|
||||
<button className={inboxScope === 'project' ? 'active' : ''} onClick={() => store.setInboxScope('project')}>
|
||||
@@ -115,6 +122,7 @@ function WorkTab({ plan, todos, filter, inboxScope, projectCards, cards }: {
|
||||
{inboxScope === 'project'
|
||||
? <ProjectInbox cards={projectCards} filter={filter} />
|
||||
: <InboxList cards={cards} docBridge={docBridge} callbacks={inboxCallbacks} filter={filter} />}
|
||||
<WorkspaceSurfaces />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
@@ -172,3 +180,35 @@ export function AgentRow({ run, depth }: { run: Run; depth: number }) {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// U6 工作区任务页容器:会话内页面在"工作"入口的常驻紧凑视图——默认显示最新
|
||||
// 一页,固定(pin)后不随新页面抢占;关闭插件后此处自然消失(surfaces 为空),
|
||||
// 收件箱/任务卡等原有内容不受影响(禁用回退完整性)。点击标题去文件预览全屏。
|
||||
function WorkspaceSurfaces() {
|
||||
const surfaces = useStore(s => s.surfaces)
|
||||
const pin = useStore(s => s.ui.workspaceSurfacePin)
|
||||
const usable = surfaces.filter(x => surfaceKey(x) != null)
|
||||
if (usable.length === 0) return null
|
||||
const shown = pin ? usable.find(x => surfaceKey(x) === pin) : usable[usable.length - 1]
|
||||
if (!shown) return null
|
||||
const shownId = surfaceKey(shown)!
|
||||
return (
|
||||
<div className="workspace-surfaces" data-workspace-surfaces>
|
||||
<div className="ws-head">
|
||||
<span className="ws-title">任务页面</span>
|
||||
<span className="ws-meta">{shown.spec?.title || '(未命名)'} · 修订 {shown.revision}</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
<button className="icon-btn" title={pin ? '取消固定(跟随最新)' : '固定此页(新页面不抢占)'}
|
||||
onClick={() => store.setUI({ workspaceSurfacePin: pin ? null : shownId })}>
|
||||
{pin ? '📍' : '📌'}
|
||||
</button>
|
||||
<button className="icon-btn" title="在文件预览中打开" onClick={() => store.openSurface(shownId)}>
|
||||
↗
|
||||
</button>
|
||||
</div>
|
||||
<div className="ws-body">
|
||||
<SurfaceView snapshot={shown} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,10 +7,13 @@ import { OverlayHost, type DocBridge } from '@ui'
|
||||
import { docBridge } from './Workbench'
|
||||
import { AgentRow } from './Workbench'
|
||||
import { buildWorkspaceEntries, type WorkspaceEntry } from '../workspaces'
|
||||
import { RdmAuditWorkspace } from './RdmAuditWorkspace'
|
||||
|
||||
/** 体系工具(顺序即 ⌘ 序号)。服务端工作区全员可见;插件管理面板只在系统管理区。 */
|
||||
/** 体系工具(顺序即 ⌘ 序号)。服务端工作区全员可见;插件管理面板只在系统管理区。
|
||||
* 追溯审计按命令面能力显隐(增量 2):store.rdmAuditAvailable 为 true 才入列。 */
|
||||
export function workspaceEntries(): WorkspaceEntry[] {
|
||||
return buildWorkspaceEntries(store.getState().serverWorkspaces)
|
||||
const s = store.getState()
|
||||
return buildWorkspaceEntries(s.serverWorkspaces, { rdmAudit: s.rdmAuditAvailable })
|
||||
}
|
||||
|
||||
export function Workspaces() {
|
||||
@@ -28,6 +31,11 @@ export function Workspaces() {
|
||||
<AgentsPanel runs={convRuns} />
|
||||
</OverlayHost>
|
||||
}
|
||||
if (active === 'rdm-audit') {
|
||||
return <OverlayHost title="追溯审计" icon="🔍" shortcut={entry?.shortcut} sayHint="打开追溯审计" onClose={close}>
|
||||
<RdmAuditWorkspace />
|
||||
</OverlayHost>
|
||||
}
|
||||
if (active.startsWith('plugin:')) {
|
||||
const panel = pluginPanels.find(p => `plugin:${p.id}` === active)
|
||||
return <OverlayHost title={panel?.title ?? '插件面板'} icon="🧩" onClose={close}
|
||||
|
||||
@@ -0,0 +1,385 @@
|
||||
// U6/KTD5:RDM 管理与项目页面组件。系统管理「RDM 角色权限」+ 项目设置「成员 /
|
||||
// 文档职责 · 整文审签」。全部数据经 api.rdmCommand(命令总线),载荷构造与能力
|
||||
// 门控集中在 src/rdmManagement.ts;分区可见性由 AdminZone/ProjectSettingsModal
|
||||
// 按 store 的 rdm*Available 门控(命令面不齐不渲染,普通基座零暴露)。样式只复用
|
||||
// 既有 ad-*/lg-*/settings-* 类(design-guard 棘轮:不新增裸色值/字号)。
|
||||
import { useEffect, useState } from 'react'
|
||||
import { api } from '../api'
|
||||
import { useStore } from '../store'
|
||||
import { Btn, EmptyState, Icon } from '@ui'
|
||||
import {
|
||||
ROLE_ACTIONS, parseReviewers,
|
||||
roleCreatePayload, roleActionPayload, roleAssignPayload, roleDisablePayload,
|
||||
memberAddPayload, memberRemovePayload,
|
||||
assignDocDutiesPayload, docSubmitPayload, docApprovePayload, docRejectPayload,
|
||||
docFinalPayload, docStatusPayload, permCheckPayload, describeSource,
|
||||
} from '../rdmManagement'
|
||||
|
||||
// ---- 系统管理:RDM 角色权限(仅管理员 + rdmRolesAvailable) ----
|
||||
|
||||
interface RoleView {
|
||||
roleId: string
|
||||
name: string
|
||||
disabled: boolean
|
||||
version: number
|
||||
actions: Record<string, [boolean, boolean]> | null
|
||||
accounts: string[] | null
|
||||
}
|
||||
|
||||
export function RdmRolesPanel() {
|
||||
const me = useStore(s => s.me)
|
||||
const [roles, setRoles] = useState<RoleView[] | null>(null)
|
||||
const [err, setErr] = useState('')
|
||||
const [notice, setNotice] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [newName, setNewName] = useState('')
|
||||
// 动作编辑器
|
||||
const [editRole, setEditRole] = useState('')
|
||||
const [editAction, setEditAction] = useState<string>(ROLE_ACTIONS[0])
|
||||
const [editDef, setEditDef] = useState(false)
|
||||
const [editCap, setEditCap] = useState(true)
|
||||
// 绑定
|
||||
const [assignOwner, setAssignOwner] = useState('')
|
||||
|
||||
const refresh = async () => {
|
||||
try {
|
||||
const out = await api.rdmCommand<{ roles: RoleView[] }>('rdm.role.list')
|
||||
setRoles(out.roles ?? [])
|
||||
} catch (e) { setErr(String(e)) }
|
||||
}
|
||||
useEffect(() => { refresh() }, [])
|
||||
|
||||
const run = async (label: string, fn: () => Promise<unknown>) => {
|
||||
setErr(''); setNotice(''); setBusy(true)
|
||||
try { await fn(); setNotice(label); await refresh() }
|
||||
catch (e) { setErr(String(e)) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
if (!me?.isAdmin) return <EmptyState icon="shield">仅平台管理员可管理专业角色</EmptyState>
|
||||
|
||||
const sel = roles?.find(r => r.roleId === editRole)
|
||||
const editCurrent = sel?.actions?.[editAction]
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="ad-create">
|
||||
<div className="ad-row">
|
||||
<input className="lg-input" placeholder="新角色名(如:编制工程师)" value={newName}
|
||||
onChange={e => setNewName(e.target.value)} />
|
||||
<button className="lg-submit ad-create-btn" disabled={busy || !newName.trim()}
|
||||
onClick={() => run(`已创建角色 ${newName.trim()}`, async () => {
|
||||
await api.rdmCommand('rdm.role.create', roleCreatePayload(newName.trim()))
|
||||
setNewName('')
|
||||
})}>创建角色</button>
|
||||
</div>
|
||||
<p className="ad-hint">新角色动作未配置=全部拒绝(fail-closed),创建后经下方编辑器逐动作配置默认与上限。</p>
|
||||
</div>
|
||||
|
||||
<table className="ad-table">
|
||||
<thead><tr><th>角色</th><th>状态</th><th>版本</th><th>动作配置</th><th>绑定账号</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{(roles ?? []).map(r => (
|
||||
<tr key={r.roleId}>
|
||||
<td><span className="ad-mono">{r.name}</span><br /><span className="ad-dim">{r.roleId}</span></td>
|
||||
<td>{r.disabled ? <span className="ad-chip ad-chip-warn">已停用</span> : <span className="ad-chip">生效</span>}</td>
|
||||
<td className="ad-dim">v{r.version}</td>
|
||||
<td>{Object.keys(r.actions ?? {}).length} 项</td>
|
||||
<td className="ad-mono">{(r.accounts ?? []).join('、') || '—'}</td>
|
||||
<td><button className="ad-mini" disabled={busy}
|
||||
onClick={() => run(r.disabled ? `已启用 ${r.name}` : `已停用 ${r.name}(绑定账号回落内置默认)`,
|
||||
() => api.rdmCommand('rdm.role.disable', roleDisablePayload(r.roleId, !r.disabled)))}>
|
||||
{r.disabled ? '启用' : '停用'}</button></td>
|
||||
</tr>
|
||||
))}
|
||||
{roles !== null && roles.length === 0 && <tr><td colSpan={6} className="ad-dim">暂无专业角色</td></tr>}
|
||||
{roles === null && <tr><td colSpan={6} className="ad-dim">加载中…</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h4 className="settings-group-title" style={{ marginTop: 18 }}>动作配置(默认 ∩ 上限)</h4>
|
||||
<div className="ad-row">
|
||||
<select className="lg-input" value={editRole} onChange={e => setEditRole(e.target.value)}>
|
||||
<option value="">选择角色…</option>
|
||||
{(roles ?? []).map(r => <option key={r.roleId} value={r.roleId}>{r.name}</option>)}
|
||||
</select>
|
||||
<select className="lg-input" value={editAction}
|
||||
onChange={e => {
|
||||
setEditAction(e.target.value)
|
||||
const cur = sel?.actions?.[e.target.value]
|
||||
setEditDef(!!cur?.[0]); setEditCap(cur ? cur[1] : true)
|
||||
}}>
|
||||
{ROLE_ACTIONS.map(a => <option key={a} value={a}>{a}</option>)}
|
||||
</select>
|
||||
<label className="ad-check"><input type="checkbox" checked={editDef}
|
||||
onChange={e => setEditDef(e.target.checked)} />默认允许</label>
|
||||
<label className="ad-check"><input type="checkbox" checked={editCap}
|
||||
onChange={e => setEditCap(e.target.checked)} />上限允许</label>
|
||||
<button className="lg-submit ad-create-btn" disabled={busy || !editRole}
|
||||
onClick={() => run(`已配置 ${editRole} · ${editAction}`,
|
||||
() => api.rdmCommand('rdm.role.action', roleActionPayload(editRole, editAction, editDef, editCap)))}>保存动作</button>
|
||||
</div>
|
||||
{sel && editCurrent && <p className="ad-hint">当前值:默认={String(editCurrent[0])},上限={String(editCurrent[1])}</p>}
|
||||
|
||||
<h4 className="settings-group-title" style={{ marginTop: 18 }}>绑定账号(每账号一个生效角色)</h4>
|
||||
<div className="ad-row">
|
||||
<input className="lg-input lg-mono" placeholder="账号(owner)" value={assignOwner}
|
||||
onChange={e => setAssignOwner(e.target.value)} />
|
||||
<select className="lg-input" value={editRole} onChange={e => setEditRole(e.target.value)}>
|
||||
<option value="">选择角色…</option>
|
||||
{(roles ?? []).filter(r => !r.disabled).map(r => <option key={r.roleId} value={r.roleId}>{r.name}</option>)}
|
||||
</select>
|
||||
<button className="lg-submit ad-create-btn" disabled={busy || !assignOwner.trim() || !editRole}
|
||||
onClick={() => run(`已把 ${assignOwner.trim()} 绑定到 ${editRole}`,
|
||||
() => api.rdmCommand('rdm.role.assign', roleAssignPayload(assignOwner.trim(), editRole)))}>绑定</button>
|
||||
</div>
|
||||
|
||||
{notice && <div className="ad-notice"><Icon name="check" size={14} />{notice}</div>}
|
||||
{err && <div className="lg-err"><Icon name="alert" size={14} />{err}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---- 项目设置:成员 ----
|
||||
|
||||
interface MemberRow { owner: string; role: string; addedBy: string; addedAt: string }
|
||||
|
||||
export function RdmMembersSection({ projectId }: { projectId: string }) {
|
||||
const [rows, setRows] = useState<MemberRow[] | null>(null)
|
||||
const [err, setErr] = useState('')
|
||||
const [notice, setNotice] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [member, setMember] = useState('')
|
||||
const [role, setRole] = useState<'member' | 'owner'>('member')
|
||||
|
||||
const refresh = async () => {
|
||||
try {
|
||||
const out = await api.rdmCommand<{ members: MemberRow[] }>('rdm.project.member.list', { projectId })
|
||||
setRows(out.members ?? [])
|
||||
} catch (e) { setErr(String(e)) }
|
||||
}
|
||||
useEffect(() => { refresh() }, [projectId])
|
||||
|
||||
const run = async (label: string, fn: () => Promise<unknown>) => {
|
||||
setErr(''); setNotice(''); setBusy(true)
|
||||
try { await fn(); setNotice(label); await refresh() }
|
||||
catch (e) { setErr(String(e)) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="ad-create">
|
||||
<div className="ad-row">
|
||||
<input className="lg-input lg-mono" placeholder="成员账号(owner)" value={member}
|
||||
onChange={e => setMember(e.target.value)} />
|
||||
<select className="lg-input" value={role} onChange={e => setRole(e.target.value as 'member' | 'owner')}>
|
||||
<option value="member">member(成员)</option>
|
||||
<option value="owner">owner(负责人)</option>
|
||||
</select>
|
||||
<button className="lg-submit ad-create-btn" disabled={busy || !member.trim()}
|
||||
onClick={() => run(`已添加 ${member.trim()}`, async () => {
|
||||
await api.rdmCommand('rdm.project.member.add', memberAddPayload(projectId, member.trim(), role))
|
||||
setMember('')
|
||||
})}>添加成员</button>
|
||||
</div>
|
||||
<p className="ad-hint">加入后成员即可见本项目;移除即时生效。最后一位负责人不可移除/降级。</p>
|
||||
</div>
|
||||
<table className="ad-table">
|
||||
<thead><tr><th>账号</th><th>项目角色</th><th>添加人</th><th>添加时间</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{(rows ?? []).map(m => (
|
||||
<tr key={m.owner}>
|
||||
<td className="ad-mono">{m.owner}</td>
|
||||
<td>{m.role === 'owner' ? <span className="ad-chip ad-chip-admin">OWNER</span> : <span className="ad-chip">MEMBER</span>}</td>
|
||||
<td className="ad-dim">{m.addedBy || '—'}</td>
|
||||
<td className="ad-dim">{m.addedAt ? new Date(m.addedAt).toLocaleString() : '—'}</td>
|
||||
<td><button className="ad-mini" disabled={busy}
|
||||
onClick={() => run(`已移除 ${m.owner}`,
|
||||
() => api.rdmCommand('rdm.project.member.remove', memberRemovePayload(projectId, m.owner)))}>移除</button></td>
|
||||
</tr>
|
||||
))}
|
||||
{rows !== null && rows.length === 0 && <tr><td colSpan={5} className="ad-dim">暂无成员</td></tr>}
|
||||
{rows === null && <tr><td colSpan={5} className="ad-dim">加载中…</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
{notice && <div className="ad-notice"><Icon name="check" size={14} />{notice}</div>}
|
||||
{err && <div className="lg-err"><Icon name="alert" size={14} />{err}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---- 项目设置:文档职责 · 整文审签 ----
|
||||
|
||||
interface DocSubmissionView {
|
||||
round: number
|
||||
state: string
|
||||
digest: string
|
||||
createdBy: string
|
||||
createdAt: string
|
||||
duties: { author: string; reviewers: string[]; approver: string }
|
||||
reviews: Record<string, boolean>
|
||||
sections: Record<string, number>
|
||||
}
|
||||
|
||||
export function RdmDutiesSection({ projectId }: { projectId: string }) {
|
||||
const [docId, setDocId] = useState('')
|
||||
const [sub, setSub] = useState<DocSubmissionView | null | undefined>(undefined)
|
||||
const [err, setErr] = useState('')
|
||||
const [notice, setNotice] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [author, setAuthor] = useState('')
|
||||
const [reviewers, setReviewers] = useState('')
|
||||
const [approver, setApprover] = useState('')
|
||||
|
||||
const cmd = <T,>(id: string, params: Record<string, unknown>) => api.rdmCommand<T>(id, params)
|
||||
|
||||
const refreshStatus = async (id: string) => {
|
||||
if (!id) return
|
||||
try {
|
||||
const out = await cmd<{ submission: DocSubmissionView | null }>('rdm.review.doc.status', docStatusPayload(projectId, id))
|
||||
setSub(out.submission)
|
||||
} catch (e) { setErr(String(e)); setSub(undefined) }
|
||||
}
|
||||
|
||||
const run = async (label: string, fn: () => Promise<unknown>) => {
|
||||
setErr(''); setNotice(''); setBusy(true)
|
||||
try { await fn(); setNotice(label); await refreshStatus(docId) }
|
||||
catch (e) { setErr(String(e)) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="ad-create">
|
||||
<div className="ad-row">
|
||||
<input className="lg-input lg-mono" placeholder="文档 ID(docId)" value={docId}
|
||||
onChange={e => setDocId(e.target.value)} />
|
||||
<Btn variant="ghost" icon="eye" disabled={busy || !docId}
|
||||
onClick={() => refreshStatus(docId)}>查看整文状态</Btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{sub === null && <EmptyState icon="inbox">该文档尚无整文提交记录</EmptyState>}
|
||||
{sub && (
|
||||
<div className="settings-group">
|
||||
<h4>第 {sub.round} 轮 · {sub.state === 'submitted' ? <span className="ad-chip ad-chip-warn">审签在途(全文锁定)</span> : sub.state === 'final' ? <span className="ad-chip ad-chip-admin">已终批</span> : <span className="ad-chip">已驳回</span>}</h4>
|
||||
<div className="setting-row"><label>三权</label><span>编制 <b className="ad-mono">{sub.duties.author}</b> · 审核 <b className="ad-mono">{(sub.duties.reviewers ?? []).join('、')}</b> · 批准 <b className="ad-mono">{sub.duties.approver}</b></span></div>
|
||||
<div className="setting-row"><label>审核进度</label><span>{Object.entries(sub.reviews ?? {}).map(([r, ok]) => `${r}:${ok ? '✓' : '…'}`).join(' ') || '—'}</span></div>
|
||||
<div className="setting-row"><label>冻结摘要</label><span className="ad-mono ad-dim">{(sub.digest ?? '').slice(0, 16)}…</span></div>
|
||||
<div className="ad-row" style={{ marginTop: 8 }}>
|
||||
<button className="lg-submit ad-create-btn" disabled={busy || sub.state !== 'submitted'}
|
||||
onClick={() => run('整文终批完成(各章节已基线)',
|
||||
() => cmd('rdm.review.doc.final', docFinalPayload(projectId, docId)))}>终批(批准人)</button>
|
||||
<button className="ad-mini" disabled={busy || sub.state !== 'submitted'}
|
||||
onClick={() => run('已通过整文审核',
|
||||
() => cmd('rdm.review.doc.approve', docApprovePayload(projectId, docId, '通过')))}>审核通过</button>
|
||||
<button className="ad-mini" disabled={busy || sub.state !== 'submitted'}
|
||||
onClick={() => run('已驳回整文(解锁回编写态)',
|
||||
() => cmd('rdm.review.doc.reject', docRejectPayload(projectId, docId, '退回修改')))}>驳回</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sub === undefined && docId && <p className="ad-hint">输入文档 ID 后查看整文状态。</p>}
|
||||
|
||||
<h4 className="settings-group-title" style={{ marginTop: 18 }}>分配文档三权(整文)</h4>
|
||||
<div className="ad-row">
|
||||
<input className="lg-input lg-mono" placeholder="文档 ID(docId)" value={docId}
|
||||
onChange={e => setDocId(e.target.value)} />
|
||||
<input className="lg-input lg-mono" placeholder="编制人(author)" value={author}
|
||||
onChange={e => setAuthor(e.target.value)} />
|
||||
<input className="lg-input lg-mono" placeholder="审核人(逗号分隔,≥1 且异于编制/批准)" value={reviewers}
|
||||
onChange={e => setReviewers(e.target.value)} />
|
||||
<input className="lg-input lg-mono" placeholder="批准人(approver)" value={approver}
|
||||
onChange={e => setApprover(e.target.value)} />
|
||||
</div>
|
||||
<div className="ad-row">
|
||||
<button className="lg-submit ad-create-btn"
|
||||
disabled={busy || !docId || !author.trim() || !approver.trim() || parseReviewers(reviewers).length === 0}
|
||||
onClick={() => run('已分配文档三权', () => cmd('rdm.review.assign.doc',
|
||||
assignDocDutiesPayload(projectId, docId, author.trim(), parseReviewers(reviewers), approver.trim())))}>分配三权</button>
|
||||
<button className="ad-mini" disabled={busy || !docId}
|
||||
onClick={() => run('整文已提交审核(全文锁定)',
|
||||
() => cmd('rdm.review.doc.submit', docSubmitPayload(projectId, docId)))}>整文提交(编制人)</button>
|
||||
</div>
|
||||
<p className="ad-hint">三权异人:编制≠批准,审核异于两者;提交即冻结三权与全文版本,审签在途全文锁定。</p>
|
||||
|
||||
{notice && <div className="ad-notice"><Icon name="check" size={14} />{notice}</div>}
|
||||
{err && <div className="lg-err"><Icon name="alert" size={14} />{err}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// ---- 项目设置:项目权限(矩阵解释读面,与实际判定同源——UI 不重算) ----
|
||||
|
||||
interface MatrixAction { action: string; allowed: boolean; source: string; reason?: string }
|
||||
interface MatrixOut {
|
||||
projectId: string; member: string; role: string
|
||||
profRole: { roleId: string; name: string } | null
|
||||
actions: MatrixAction[]
|
||||
}
|
||||
|
||||
export function RdmPermMatrixSection({ projectId }: { projectId: string }) {
|
||||
const me = useStore(s => s.me)
|
||||
const [member, setMember] = useState('')
|
||||
const [members, setMembers] = useState<MemberRow[] | null>(null)
|
||||
const [matrix, setMatrix] = useState<MatrixOut | null>(null)
|
||||
const [err, setErr] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
api.rdmCommand<{ members: MemberRow[] }>('rdm.project.member.list', { projectId })
|
||||
.then(out => {
|
||||
setMembers(out.members ?? [])
|
||||
// 默认自省:普通成员只看自己的矩阵;负责人/管理员可切换他人。
|
||||
if (me?.owner) setMember(prev => prev || me.owner)
|
||||
})
|
||||
.catch(e => setErr(String(e)))
|
||||
}, [projectId, me?.owner])
|
||||
|
||||
const query = async (m: string) => {
|
||||
if (!m) return
|
||||
setErr(''); setBusy(true); setMatrix(null)
|
||||
try {
|
||||
setMatrix(await api.rdmCommand<MatrixOut>('rdm.perm.check', permCheckPayload(projectId, m)))
|
||||
} catch (e) { setErr(String(e)) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="ad-create">
|
||||
<div className="ad-row">
|
||||
<select className="lg-input" value={member} onChange={e => { setMember(e.target.value); query(e.target.value) }}>
|
||||
<option value="">选择成员(默认自省)…</option>
|
||||
{(members ?? []).map(m => <option key={m.owner} value={m.owner}>{m.owner}</option>)}
|
||||
</select>
|
||||
<Btn variant="ghost" icon="eye" disabled={busy || !member} onClick={() => query(member)}>查看矩阵</Btn>
|
||||
</div>
|
||||
<p className="ad-hint">普通成员可查看自己的有效权限;查看他人矩阵须项目管理权。矩阵与实际判定同源(服务端解释,非前端推算)。</p>
|
||||
</div>
|
||||
{matrix && (
|
||||
<>
|
||||
<div className="ad-row">
|
||||
<span>成员 <b className="ad-mono">{matrix.member}</b> · 项目关系 <b>{matrix.role}</b>
|
||||
{matrix.profRole && <> · 专业角色 <b>{matrix.profRole.name}</b>({matrix.profRole.roleId})</>}
|
||||
</span>
|
||||
</div>
|
||||
<table className="ad-table">
|
||||
<thead><tr><th>动作</th><th>生效</th><th>来源</th><th>原因(拒绝时)</th></tr></thead>
|
||||
<tbody>
|
||||
{(matrix.actions ?? []).map(a => (
|
||||
<tr key={a.action}>
|
||||
<td className="ad-mono">{a.action}</td>
|
||||
<td>{a.allowed ? <span className="ad-chip ad-chip-admin">允许</span> : <span className="ad-chip ad-chip-warn">拒绝</span>}</td>
|
||||
<td className="ad-dim">{describeSource(a.source)}</td>
|
||||
<td className="ad-dim">{a.reason || '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</>
|
||||
)}
|
||||
{!matrix && !err && members !== null && <p className="ad-hint">选择成员后展示 12 项动作的有效权限与第一拒绝原因。</p>}
|
||||
{err && <div className="lg-err"><Icon name="alert" size={14} />{err}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -193,9 +193,18 @@ export function PluginsSection() {
|
||||
{plugins.length === 0 && <EmptyState icon="puzzle">暂无已装载插件<br />(插件目录在 设置 → 常规 → 插件目录;示例见 Dev 仓 plugins/example)</EmptyState>}
|
||||
{plugins.map(p => (
|
||||
<SettingRow key={p.manifest.id}
|
||||
label={`${p.manifest.name} v${p.manifest.version}`}
|
||||
desc={[`工具 ${p.manifest.provides.tools?.length ?? 0}`, `面板 ${p.panels?.length ?? 0}`, `意图 ${p.intents?.length ?? 0}`, `工作流 ${p.workflows?.length ?? 0}`].join(' · ')}>
|
||||
<span className="effect-badge">api {p.manifest.apiVersion}</span>
|
||||
label={p.manifest.name + (p.manifest.version ? ` v${p.manifest.version}` : '')}
|
||||
desc={[
|
||||
`工具 ${p.manifest.provides.tools?.length ?? 0}`,
|
||||
`命令 ${p.manifest.provides.commands?.length ?? 0}`,
|
||||
`面板 ${p.panels?.length ?? 0}`,
|
||||
`意图 ${p.intents?.length ?? p.manifest.provides.intents?.length ?? 0}`,
|
||||
`工作流 ${p.workflows?.length ?? 0}`,
|
||||
].join(' · ')}>
|
||||
<span style={{ display: 'inline-flex', gap: 6, alignItems: 'center' }}>
|
||||
{p.manifest.backend?.mode === 'inproc' && <span className="effect-badge">内置</span>}
|
||||
<span className="effect-badge">api {p.manifest.apiVersion}</span>
|
||||
</span>
|
||||
</SettingRow>
|
||||
))}
|
||||
</div>
|
||||
|
||||
+44
-1
@@ -99,7 +99,8 @@ export type StreamEventType =
|
||||
| 'agent.spawn' | 'agent.progress' | 'agent.result'
|
||||
| 'artifact.created' | 'artifact.updated'
|
||||
| 'run.completed' | 'run.failed' | 'error'
|
||||
| 'card.created' | 'card.updated' | 'card.status' | 'card.resolved';
|
||||
| 'card.created' | 'card.updated' | 'card.status' | 'card.resolved'
|
||||
| 'surface.updated';
|
||||
|
||||
export interface StreamEvent<P = unknown> {
|
||||
seq: number; runId?: ID; conversationId: ID; ts: string; v: number;
|
||||
@@ -355,3 +356,45 @@ export interface UploadInfo {
|
||||
artifactId: ID; filename: string; mime?: string; size: number;
|
||||
parsed?: ParsedDocument;
|
||||
}
|
||||
|
||||
// ---- Generative UI Surface(提案 U2/U4;与 Go surface.go 同步)----
|
||||
|
||||
export interface SurfaceCompat {
|
||||
schemaVersion: number;
|
||||
catalogVersion: string;
|
||||
pluginVersion?: string;
|
||||
}
|
||||
|
||||
export interface SurfaceIdentity {
|
||||
surfaceId: ID;
|
||||
owner: string;
|
||||
projectId?: ID;
|
||||
conversationId?: ID;
|
||||
sourceRunId?: ID;
|
||||
pluginId: string;
|
||||
}
|
||||
|
||||
export interface SurfaceSpec {
|
||||
title: string;
|
||||
nodes: UINode[];
|
||||
}
|
||||
|
||||
export interface SurfaceSnapshot {
|
||||
identity: SurfaceIdentity;
|
||||
compat: SurfaceCompat;
|
||||
revision: number;
|
||||
baseRevision?: number;
|
||||
contextVersion?: string;
|
||||
activationEpoch: number;
|
||||
spec: SurfaceSpec;
|
||||
semanticText?: string;
|
||||
}
|
||||
|
||||
export interface SurfaceMeta {
|
||||
surfaceId: ID;
|
||||
title: string;
|
||||
pluginId: string;
|
||||
revision: number;
|
||||
conversationId?: ID;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
// 审计命令错误分类(2026-10-12 增量 2):RdmAuditWorkspace 不再直出原始报错
|
||||
//(普通基座上曾是 `400: {"receipt":…unknown or disabled command…}`),改为
|
||||
//「能力缺失 / 登录已失效 / 查询失败」人话文案;raw 始终保留供「原始回执」折叠区诊断。
|
||||
// 依据:api.ts json() 对非 2xx 抛 `${status}: ${body}`;后端 unknown/disabled 命令回
|
||||
// 400 + "unknown or disabled command"(cmdbus.go Dispatch),总线未挂载回
|
||||
// 503 "commands disabled"(server.go execCommand)。
|
||||
|
||||
type RdmErrorKind = 'capability' | 'auth' | 'query'
|
||||
|
||||
interface RdmErrorView {
|
||||
kind: RdmErrorKind
|
||||
message: string
|
||||
raw: string
|
||||
}
|
||||
|
||||
export function classifyRdmError(err: unknown): RdmErrorView {
|
||||
const raw = err instanceof Error ? err.message : String(err)
|
||||
if (/^40[13]:/.test(raw)) {
|
||||
return { kind: 'auth', message: '登录已失效,请重新登录后再查看追溯审计', raw }
|
||||
}
|
||||
if (/unknown or disabled command|commands disabled/.test(raw)) {
|
||||
return { kind: 'capability', message: '当前服务未提供审计能力(RDM 命令未注册或已停用)', raw }
|
||||
}
|
||||
return { kind: 'query', message: '查询失败,请稍后重试', raw }
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
// U6/KTD5:RDM 管理与项目页面的纯逻辑层——能力门控(与 rdmAuditReady 同源语义:
|
||||
// 命令清单不可用=未就绪,fail-closed)与命令载荷构造。页面组件只消费本模块与
|
||||
// api.rdmCommand,不在组件里散落命令 id / 参数拼写;身份一律经认证会话,载荷不
|
||||
// 携带任何身份字段(G1-5)。
|
||||
|
||||
/** 系统管理「RDM 角色权限」分区消费的命令面。 */
|
||||
export const RDM_ROLES_COMMANDS = [
|
||||
'rdm.role.create',
|
||||
'rdm.role.action',
|
||||
'rdm.role.assign',
|
||||
'rdm.role.disable',
|
||||
'rdm.role.list',
|
||||
] as const
|
||||
|
||||
/** 项目设置「成员」页消费的命令面。 */
|
||||
export const RDM_MEMBER_COMMANDS = [
|
||||
'rdm.project.member.list',
|
||||
'rdm.project.member.add',
|
||||
'rdm.project.member.remove',
|
||||
] as const
|
||||
|
||||
/** 项目设置「项目权限」页消费的命令面(矩阵解释 + 成员清单)。 */
|
||||
export const RDM_PERM_MATRIX_COMMANDS = [
|
||||
'rdm.perm.check',
|
||||
'rdm.project.member.list',
|
||||
] as const
|
||||
|
||||
/** 项目设置「文档职责 · 整文审签」页消费的命令面。 */
|
||||
export const RDM_DOC_REVIEW_COMMANDS = [
|
||||
'rdm.review.assign.doc',
|
||||
'rdm.review.doc.submit',
|
||||
'rdm.review.doc.approve',
|
||||
'rdm.review.doc.reject',
|
||||
'rdm.review.doc.final',
|
||||
'rdm.review.doc.status',
|
||||
] as const
|
||||
|
||||
export type CommandListItem = { id?: string; enabled?: boolean } | null | undefined
|
||||
|
||||
function ready(cmds: CommandListItem[] | null | undefined, need: readonly string[]): boolean {
|
||||
if (!Array.isArray(cmds)) return false
|
||||
return need.every(n => cmds.some(c => c?.id === n && c.enabled === true))
|
||||
}
|
||||
|
||||
/** 角色分区是否就绪(普通基座无 rdm.role.* → 隐藏)。 */
|
||||
export const rdmRolesReady = (cmds: CommandListItem[] | null | undefined) =>
|
||||
ready(cmds, RDM_ROLES_COMMANDS)
|
||||
|
||||
/** 项目成员页是否就绪。 */
|
||||
export const rdmMembersReady = (cmds: CommandListItem[] | null | undefined) =>
|
||||
ready(cmds, RDM_MEMBER_COMMANDS)
|
||||
|
||||
/** 文档职责/整文审签页是否就绪。 */
|
||||
export const rdmDocReviewReady = (cmds: CommandListItem[] | null | undefined) =>
|
||||
ready(cmds, RDM_DOC_REVIEW_COMMANDS)
|
||||
|
||||
/** 项目权限矩阵页是否就绪。 */
|
||||
export const rdmPermMatrixReady = (cmds: CommandListItem[] | null | undefined) =>
|
||||
ready(cmds, RDM_PERM_MATRIX_COMMANDS)
|
||||
|
||||
/** RDM 动作清单(12 项,与 RDM perms.AllActions 对齐;角色动作编辑器选项)。 */
|
||||
export const ROLE_ACTIONS = [
|
||||
'project.create', 'project.archive', 'member.manage', 'duty.assign',
|
||||
'stage.advance', 'doc.read', 'doc.write', 'doc.publish', 'review.participate',
|
||||
'trace.edit', 'knowledge.publish', 'project.settings',
|
||||
] as const
|
||||
|
||||
// ---- 载荷构造(字段名与 RDM 命令 schema 一一对应) ----
|
||||
|
||||
export const roleCreatePayload = (name: string) => ({ name })
|
||||
export const roleActionPayload = (roleId: string, action: string, def: boolean, cap: boolean) =>
|
||||
({ roleId, action, default: def, cap })
|
||||
export const roleAssignPayload = (owner: string, roleId: string) => ({ owner, roleId })
|
||||
export const roleDisablePayload = (roleId: string, disabled: boolean) => ({ roleId, disabled })
|
||||
|
||||
export const memberAddPayload = (projectId: string, member: string, role: 'owner' | 'member' = 'member') =>
|
||||
({ projectId, member, role })
|
||||
export const memberRemovePayload = (projectId: string, member: string) => ({ projectId, member })
|
||||
|
||||
export const assignDocDutiesPayload = (
|
||||
projectId: string, docId: string, author: string, reviewers: string[], approver: string,
|
||||
) => ({ projectId, docId, author, reviewers, approver })
|
||||
|
||||
export const docSubmitPayload = (projectId: string, docId: string) => ({ projectId, docId })
|
||||
export const docApprovePayload = (projectId: string, docId: string, note?: string) =>
|
||||
note ? { projectId, docId, note } : { projectId, docId }
|
||||
export const docRejectPayload = (projectId: string, docId: string, reason?: string) =>
|
||||
reason ? { projectId, docId, reason } : { projectId, docId }
|
||||
/** round=0(未指定)不携带——服务端只认在途轮次,旧轮次传参即拒。 */
|
||||
export const docFinalPayload = (projectId: string, docId: string, round = 0) =>
|
||||
round > 0 ? { projectId, docId, round } : { projectId, docId }
|
||||
export const docStatusPayload = (projectId: string, docId: string) => ({ projectId, docId })
|
||||
|
||||
export const permCheckPayload = (projectId: string, member: string) => ({ projectId, member })
|
||||
|
||||
/** 矩阵来源标签(人话映射;未知来源回退原文——服务端新增来源时不至于空白)。 */
|
||||
export function describeSource(source: string): string {
|
||||
switch (source) {
|
||||
case 'admin': return '系统管理员'
|
||||
case 'prof-role-default': return '专业角色默认'
|
||||
case 'override': return '项目内覆盖'
|
||||
case 'relation-default': return '项目关系默认'
|
||||
case 'non-member': return '非项目成员'
|
||||
default: return source
|
||||
}
|
||||
}
|
||||
|
||||
/** 审阅人 CSV 解析:去空白、去空项、保序去重(表单输入 → reviewers 数组)。 */
|
||||
export function parseReviewers(csv: string): string[] {
|
||||
const seen = new Set<string>()
|
||||
const out: string[] = []
|
||||
for (const part of csv.split(',')) {
|
||||
const v = part.trim()
|
||||
if (!v || seen.has(v)) continue
|
||||
seen.add(v)
|
||||
out.push(v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
+153
-7
@@ -1,13 +1,16 @@
|
||||
// 应用状态:服务端权威(事件 + Query 对账),前端 store 是投影。
|
||||
// useSyncExternalStore 驱动;live 流式部件为会话内临时态,run 终态后由消息树对账收敛。
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import { api, connectEvents } from './api'
|
||||
import { type AdminZoneSection, api, connectEvents } from './api'
|
||||
import { applyTextDelta, applyThinkingDelta, applyToolCall, applyToolCallDelta, applyToolResult, applyToolOutput, applyArtifactCreated, applyArtifactUpdated, applyCheck, applyFinalizing, type LiveItem } from './live'
|
||||
import { closePreviewTab, mergeOrphanPreviewTabs, openPreviewTab, previewTabsKey, type PreviewTab } from './previewtabs'
|
||||
import { deliveryRequestId, enqueueMessage, enqueueMessageFront, newQueuedMessage, promoteQueuedMessage, removeQueuedMessage, replaceQueuedText, type QueuedMessage } from './outbox'
|
||||
import { canFlushOutbox, deriveShellPhase } from './runstate'
|
||||
import { flashElement } from './scrollsync'
|
||||
import { isThemeChoice, resolveTheme, type ThemeChoice } from './theme'
|
||||
import { mergeSurfacesByRevision, surfaceKey } from './surfaceKey'
|
||||
import { rdmAuditReady } from './workspaces'
|
||||
import { rdmRolesReady, rdmMembersReady, rdmDocReviewReady, rdmPermMatrixReady } from './rdmManagement'
|
||||
|
||||
type FontSizeChoice = 13 | 14 | 16 | 18
|
||||
const FONT_SIZE_CHOICES: readonly FontSizeChoice[] = [13, 14, 16, 18]
|
||||
@@ -16,8 +19,20 @@ export function isFontSizeChoice(value: number): value is FontSizeChoice {
|
||||
return (FONT_SIZE_CHOICES as readonly number[]).includes(value)
|
||||
}
|
||||
import type {
|
||||
Project, Conversation, Message, Run, Artifact, HITLCard, PlanSnapshot, TodoList,
|
||||
AgentProfile, StreamEvent, PluginPanel, ActivityCard,
|
||||
ActivityCard,
|
||||
AgentProfile,
|
||||
Artifact,
|
||||
Conversation,
|
||||
HITLCard,
|
||||
Message,
|
||||
PlanSnapshot,
|
||||
PluginPanel,
|
||||
Project,
|
||||
Run,
|
||||
StreamEvent,
|
||||
SurfaceMeta,
|
||||
SurfaceSnapshot,
|
||||
TodoList
|
||||
} from './contracts'
|
||||
|
||||
export interface ContextObs {
|
||||
@@ -47,6 +62,8 @@ export interface UIState {
|
||||
rightTab: 'work' | 'files'
|
||||
/** 文件入口子视图:列表 / 预览(预览标签存在时才有意义)。 */
|
||||
filesView: 'list' | 'preview'
|
||||
/** U6:工作区任务页固定(surfaceId;null=跟随最新;固定后新页面不抢占显示)。 */
|
||||
workspaceSurfacePin: string | null
|
||||
/** 收件箱筛选:全部 / 待处理。铃铛打开时切到待处理。 */
|
||||
inboxFilter: 'all' | 'todo'
|
||||
theme: ThemeChoice
|
||||
@@ -86,6 +103,10 @@ interface AppState {
|
||||
plan: PlanSnapshot | null
|
||||
todos: TodoList | null
|
||||
artifacts: Artifact[]
|
||||
/** generative-ui:会话内已保存页面(surface.updated 投影;按 surfaceId 存最新修订)。 */
|
||||
surfaces: SurfaceSnapshot[]
|
||||
/** generative-ui U6:项目级跨会话页面聚合(打开项目时权威装载)。 */
|
||||
projectSurfaces: SurfaceMeta[]
|
||||
cards: ActivityCard[]
|
||||
projectCards: ActivityCard[]
|
||||
inboxScope: 'conv' | 'project'
|
||||
@@ -94,6 +115,15 @@ interface AppState {
|
||||
profiles: AgentProfile[]
|
||||
pluginPanels: PluginPanel[]
|
||||
serverWorkspaces: { id: string; title: string; icon?: string; shortcut?: string; body?: any[] }[]
|
||||
/** 审计入口能力(增量 2):GET /api/commands 含全部 rdm.inspect.* 且启用;探测失败/未登录为 false,入口隐藏。 */
|
||||
rdmAuditAvailable: boolean
|
||||
/** U6 RDM 管理页能力:命令面齐备才显示对应分区/页签(普通基座零暴露,fail-closed)。 */
|
||||
rdmRolesAvailable: boolean
|
||||
rdmMembersAvailable: boolean
|
||||
rdmDocReviewAvailable: boolean
|
||||
rdmPermMatrixAvailable: boolean
|
||||
/** 管理区声明分区(WP3-I3):编译插件声明,入口全员可见、区内置权限不合并。 */
|
||||
adminSections: AdminZoneSection[]
|
||||
ui: UIState
|
||||
connected: boolean
|
||||
/** 发送请求已经出去,run 记录还没回来。 */
|
||||
@@ -112,7 +142,7 @@ const DEFAULT_UI: UIState = {
|
||||
rightWidth: 360,
|
||||
rightCollapsed: false,
|
||||
rightTab: 'work',
|
||||
filesView: 'list',
|
||||
filesView: 'list', workspaceSurfacePin: null,
|
||||
inboxFilter: 'all',
|
||||
activeWorkspace: null,
|
||||
theme: 'dark',
|
||||
@@ -149,12 +179,17 @@ function loadUI(): UIState {
|
||||
return { ...DEFAULT_UI }
|
||||
}
|
||||
|
||||
const surfaceActionsInflight: Record<string, boolean> = {}
|
||||
|
||||
let state: AppState = {
|
||||
contextObs: {},
|
||||
projects: [], currentProjectId: null, tasks: [], me: null, currentConvId: null,
|
||||
messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null,
|
||||
plan: null, todos: null, artifacts: [], cards: [], projectCards: [], inboxScope: 'conv', runtimeMock: false, profiles: [], pluginPanels: [],
|
||||
plan: null, todos: null, artifacts: [], surfaces: [], projectSurfaces: [], cards: [], projectCards: [], inboxScope: 'conv', runtimeMock: false, profiles: [], pluginPanels: [],
|
||||
serverWorkspaces: [],
|
||||
rdmAuditAvailable: false,
|
||||
rdmRolesAvailable: false, rdmMembersAvailable: false, rdmDocReviewAvailable: false, rdmPermMatrixAvailable: false,
|
||||
adminSections: [],
|
||||
ui: loadUI(), connected: false, posting: false, error: null, toast: null,
|
||||
outbox: [], outboxDraining: false,
|
||||
previewTabs: {}, previewActive: {},
|
||||
@@ -359,7 +394,7 @@ export const store = {
|
||||
|
||||
async openConversation(convId: string) {
|
||||
if (sse) { sse.close(); sse = null }
|
||||
set({ currentConvId: convId, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null, plan: null, todos: null, cards: [], inboxScope: 'conv', outbox: [], outboxDraining: false, posting: false })
|
||||
set({ currentConvId: convId, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null, plan: null, todos: null, cards: [], surfaces: [], projectSurfaces: [], inboxScope: 'conv', outbox: [], outboxDraining: false, posting: false })
|
||||
persistConvId(convId)
|
||||
// 空态时打开的预览标签('_' 键)随会话进入一起迁移,不留在不可见的孤儿键下。
|
||||
if ((state.previewTabs['_'] ?? []).length > 0) {
|
||||
@@ -369,6 +404,33 @@ export const store = {
|
||||
previewActive: { ...state.previewActive, [convId]: active },
|
||||
})
|
||||
}
|
||||
// U4/R5:已保存页面从权威快照重载(刷新/断线恢复;事件流只做增量投影)。
|
||||
// surfaceList 返回扁平 SurfaceMeta,不可写入 surfaces;必须经 surfaceGet 水合为 Snapshot。
|
||||
api.projectSurfaces(state.currentProjectId ?? '').then(list => { if (state.currentConvId === convId) set({ projectSurfaces: list ?? [] }) }).catch(() => {})
|
||||
api.surfaceList(convId).then(async list => {
|
||||
if (state.currentConvId !== convId) return
|
||||
const metas = list ?? []
|
||||
// 刷新/重进恢复:有页面且尚无页面标签时补开最新一张的标签(不强切
|
||||
// 右栏——只在实时事件到达时切换,避免每次进会话劫持视图)。标签只用 Meta。
|
||||
const conv = previewTabsKey(state.currentConvId)
|
||||
const tabs = state.previewTabs[conv] ?? []
|
||||
const hasSurfaceTab = tabs.some(t => t.id.startsWith('surface:'))
|
||||
if (!hasSurfaceTab && metas.length > 0) {
|
||||
const latest = metas[0]
|
||||
const opened = openPreviewTab(tabs, { id: 'surface:' + String(latest.surfaceId), name: latest.title || '页面' })
|
||||
set({
|
||||
previewTabs: { ...state.previewTabs, [conv]: opened.tabs },
|
||||
previewActive: { ...state.previewActive, [conv]: opened.activeId },
|
||||
})
|
||||
}
|
||||
const fetched = await Promise.all(metas.map(m =>
|
||||
api.surfaceGet(String(m.surfaceId)).catch(() => null),
|
||||
))
|
||||
if (state.currentConvId !== convId) return
|
||||
const hydrated = fetched.filter((s): s is SurfaceSnapshot => surfaceKey(s) != null)
|
||||
// 慢水合不得覆盖并发 surface.updated 已写入的较新 revision。
|
||||
set({ surfaces: mergeSurfacesByRevision(state.surfaces, hydrated) })
|
||||
}).catch(() => {})
|
||||
const resumeSeq = await store.reconcile()
|
||||
store.connectLive(resumeSeq ?? state.lastSeq)
|
||||
},
|
||||
@@ -490,6 +552,37 @@ export const store = {
|
||||
set({ todos: p.list })
|
||||
break
|
||||
}
|
||||
case 'surface.updated': {
|
||||
// KTD8:单一状态所有者——服务端快照为权威,本地按 revision 守卫:
|
||||
// 旧修订/重复事件不覆盖新修订;切会话由 selectConversation 清空。
|
||||
const p = e.payload as SurfaceSnapshot
|
||||
const id = surfaceKey(p)
|
||||
if (!id) break
|
||||
const surfaces = state.surfaces.filter(x => surfaceKey(x) !== id)
|
||||
const existing = state.surfaces.find(x => surfaceKey(x) === id)
|
||||
if (!existing || p.revision > existing.revision) {
|
||||
surfaces.push(p)
|
||||
set({ surfaces })
|
||||
// 新页面到达:没有页面标签或当前正在看页面 → 开标签并把右栏切到
|
||||
// 文件预览(与 requestPreview 同语义,用户能立刻看到);否则不抢占
|
||||
// 已打开的对象,但留 toast 痕迹(KTD「新结果不抢占」)。
|
||||
const conv = previewTabsKey(state.currentConvId)
|
||||
const tabs = state.previewTabs[conv] ?? []
|
||||
const activeId = state.previewActive[conv] ?? null
|
||||
const hasSurfaceTab = tabs.some(t => t.id.startsWith('surface:'))
|
||||
if (!hasSurfaceTab || (activeId ?? '').startsWith('surface:')) {
|
||||
const opened = openPreviewTab(tabs, { id: 'surface:' + id, name: p.spec?.title || '页面' })
|
||||
set({
|
||||
previewTabs: { ...state.previewTabs, [conv]: opened.tabs },
|
||||
previewActive: { ...state.previewActive, [conv]: opened.activeId },
|
||||
})
|
||||
syncFilesEntry(true)
|
||||
} else {
|
||||
store.toast(`页面已更新:${p.spec?.title || id}`)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'card.created':
|
||||
case 'card.updated': {
|
||||
const p = e.payload as { card: ActivityCard }
|
||||
@@ -714,6 +807,21 @@ export const store = {
|
||||
const ws = await api.listWorkspaces()
|
||||
set({ serverWorkspaces: ws ?? [] })
|
||||
} catch { /* ignore */ }
|
||||
// 审计入口能力门控(增量 2):与工作区清单同源刷新——能力撤销(插件卸载/命令停用)后入口随之隐藏。
|
||||
try {
|
||||
const cmds = await api.listCommands()
|
||||
set({
|
||||
rdmAuditAvailable: rdmAuditReady(cmds),
|
||||
rdmRolesAvailable: rdmRolesReady(cmds),
|
||||
rdmMembersAvailable: rdmMembersReady(cmds),
|
||||
rdmDocReviewAvailable: rdmDocReviewReady(cmds),
|
||||
rdmPermMatrixAvailable: rdmPermMatrixReady(cmds),
|
||||
})
|
||||
} catch { set({ rdmAuditAvailable: false }) }
|
||||
try {
|
||||
const sections = await api.adminZoneSections()
|
||||
set({ adminSections: sections ?? [] })
|
||||
} catch { /* ignore */ }
|
||||
try {
|
||||
const settings = await api.getSettings()
|
||||
const runtime = settings.user.runtime
|
||||
@@ -807,7 +915,8 @@ export const store = {
|
||||
},
|
||||
|
||||
async tidyCards() {
|
||||
const done = state.cards.filter(c => c.status === 'done')
|
||||
// U1:不收纳失败语义卡(failed,或旧 done+danger),避免把失败当完成清掉。
|
||||
const done = state.cards.filter(c => c.status === 'done' && c.tone !== 'danger')
|
||||
for (const c of done) {
|
||||
try { const out = await api.cardDismiss(c.id); store.upsertCard(out.card) } catch { /* 单卡失败不阻断 */ }
|
||||
}
|
||||
@@ -846,6 +955,43 @@ export const store = {
|
||||
})
|
||||
syncFilesEntry(true)
|
||||
},
|
||||
openSurface(surfaceId: string) {
|
||||
// generative-ui:页面进预览第三栏(标签 id 带 surface: 前缀区分工件)。
|
||||
const snap = state.surfaces.find(x => surfaceKey(x) === surfaceId)
|
||||
const conv = previewTabsKey(state.currentConvId)
|
||||
const opened = openPreviewTab(state.previewTabs[conv] ?? [], { id: 'surface:' + surfaceId, name: snap?.spec?.title || '页面' })
|
||||
set({
|
||||
previewTabs: { ...state.previewTabs, [conv]: opened.tabs },
|
||||
previewActive: { ...state.previewActive, [conv]: opened.activeId },
|
||||
})
|
||||
syncFilesEntry(true)
|
||||
},
|
||||
// U5:页面动作提交——requestId 由客户端生成(服务端幂等键);草稿只在此刻
|
||||
// 随动作外发(未提交输入不上传、不进模型上下文)。在途防抖挡双击。
|
||||
async handleSurfaceAction(surfaceId: string, actionId: string, data: Record<string, unknown>) {
|
||||
const inflight = surfaceActionsInflight[surfaceId + ':' + actionId]
|
||||
if (inflight) return
|
||||
const snap = state.surfaces.find(x => surfaceKey(x) === surfaceId)
|
||||
if (!snap) { store.toast('页面快照不可用,无法提交'); return }
|
||||
const requestId = (crypto.randomUUID ? crypto.randomUUID() : 'req-' + Date.now() + '-' + Math.random().toString(36).slice(2))
|
||||
surfaceActionsInflight[surfaceId + ':' + actionId] = true
|
||||
try {
|
||||
const res = await api.submitSurfaceAction(surfaceId, {
|
||||
actionId, revision: snap.revision, requestId, data,
|
||||
})
|
||||
if (res?.state === 'awaiting_confirm') store.toast('高危动作已发确认卡,请确认后执行')
|
||||
else if (res?.state === 'committed') store.toast('已提交')
|
||||
else store.toast('动作状态:' + (res?.state ?? 'unknown'))
|
||||
} catch (e: any) {
|
||||
const msg = String(e?.message ?? e)
|
||||
if (msg.includes('revision conflict')) store.toast('页面已更新,请刷新后重试')
|
||||
else if (msg.includes('not enabled') || msg.includes('403')) store.toast('动作被拒绝:插件未启用')
|
||||
else if (msg.includes('different params')) store.toast('重复请求参数不一致,已拒绝')
|
||||
else store.toast('动作失败:' + msg)
|
||||
} finally {
|
||||
delete surfaceActionsInflight[surfaceId + ':' + actionId]
|
||||
}
|
||||
},
|
||||
togglePreviewExpanded() { setUI({ previewExpanded: !state.ui.previewExpanded }) },
|
||||
activatePreview(id: string) {
|
||||
const conv = previewTabsKey(state.currentConvId)
|
||||
|
||||
@@ -577,3 +577,57 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
|
||||
.session-note { width: 12rem; }
|
||||
.rewind-actions { display: flex; gap: 8px; }
|
||||
.rewind-error { color: var(--err-text); font-size: var(--fs-ui-sm); }
|
||||
|
||||
/* ---- U4 追溯审计工作区(令牌驱动;@ui/tokens.css 消费) ---- */
|
||||
.rdm-audit { display: flex; flex-direction: column; min-height: 300px; gap: 0; }
|
||||
.rdm-audit-header { display: flex; align-items: center; gap: 10px; padding: 0 0 10px; }
|
||||
.rdm-audit-project { max-width: 220px; height: 32px; font-size: var(--fs-ui-sm); }
|
||||
.rdm-audit-tabs { padding: 0 0 10px; }
|
||||
.rdm-audit-body { flex: 1; min-height: 0; overflow-y: auto; padding-top: 10px; }
|
||||
.rdm-audit-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); gap: 8px; margin-bottom: 12px; }
|
||||
.rdm-audit-card { background: var(--bg-panel); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 10px; text-align: center; }
|
||||
.rdm-audit-card-num { font-size: var(--fs-ui-xl); font-weight: 600; color: var(--text); }
|
||||
.rdm-audit-card-label { font-size: var(--fs-ui-xs); color: var(--text-dim); margin-top: 2px; }
|
||||
.rdm-audit-meta { font-size: var(--fs-ui-sm); color: var(--text-dim); margin-bottom: 8px; }
|
||||
.rdm-audit-materials { display: flex; gap: 0; min-height: 200px; }
|
||||
.rdm-audit-materials-side { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 10px; }
|
||||
.rdm-audit-tree { display: flex; gap: 14px; min-height: 200px; }
|
||||
.rdm-audit-tree-list { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
|
||||
.rdm-audit-tree-node { display: flex; align-items: center; gap: 8px; padding: 6px 10px; background: none; border: none; border-radius: var(--radius-sm); cursor: pointer; text-align: left; font-size: var(--fs-ui-sm); color: var(--text-dim); }
|
||||
.rdm-audit-tree-node:hover { background: var(--bg-hover); color: var(--text); }
|
||||
.rdm-audit-tree-node.sel { background: var(--bg-active); color: var(--text); }
|
||||
.rdm-audit-tree-caret { width: 14px; flex: none; color: var(--text-dim); font-size: var(--fs-ui-sm); }
|
||||
.rdm-audit-tree-children { padding-left: 18px; display: flex; flex-direction: column; gap: 0; }
|
||||
.rdm-audit-tree-kind { font-size: var(--fs-ui-xs); color: var(--text-faint); min-width: 3.2em; }
|
||||
.rdm-audit-tree-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.rdm-audit-more { margin-top: 8px; }
|
||||
.rdm-audit-truncated { font-size: var(--fs-ui-sm); color: var(--text-faint); padding: 6px 0; }
|
||||
.rdm-audit-detail { flex: 1; min-width: 0; border-left: 1px solid var(--border-soft); padding-left: 14px; }
|
||||
.rdm-audit-detail-body { display: flex; flex-direction: column; gap: 6px; font-size: var(--fs-ui-sm); }
|
||||
.rdm-audit-version { padding: 3px 0; color: var(--text-dim); }
|
||||
.rdm-audit-formal { margin-top: 8px; padding: 8px 10px; background: var(--ok-soft); border: 1px solid var(--ok-border); border-radius: var(--radius-sm); font-size: var(--fs-ui-sm); color: var(--ok-text); }
|
||||
.rdm-audit-pick { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.rdm-audit-trace-head { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
|
||||
.rdm-audit-trace-cols { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
|
||||
.rdm-audit-trace-col { min-width: 0; }
|
||||
.rdm-audit-rel { display: flex; align-items: center; gap: 6px; padding: 4px 0; font-size: var(--fs-ui-sm); }
|
||||
.rdm-audit-rel-type { color: var(--text-dim); }
|
||||
.rdm-audit-rel-endpoint { font-family: var(--mono); font-size: var(--fs-ui-xs); color: var(--text-faint); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.rdm-audit-timeline-filters { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; flex-wrap: wrap; }
|
||||
.rdm-audit-timeline-filters .ui-input { max-width: 160px; height: 30px; font-size: var(--fs-ui-sm); }
|
||||
.rdm-audit-seq { max-width: 100px; }
|
||||
.rdm-audit-opgroup { border: 1px solid var(--border-soft); border-radius: var(--radius-sm); margin-bottom: 4px; overflow: hidden; }
|
||||
.rdm-audit-opgroup-head { display: flex; align-items: center; gap: 8px; width: 100%; padding: 7px 10px; background: var(--bg-panel); border: none; cursor: pointer; font-size: var(--fs-ui-sm); color: var(--text-dim); text-align: left; }
|
||||
.rdm-audit-opgroup-head:hover { background: var(--bg-hover); }
|
||||
.rdm-audit-opgroup-actor { font-weight: 550; color: var(--text); }
|
||||
.rdm-audit-opgroup-time { font-size: var(--fs-ui-xs); color: var(--text-faint); }
|
||||
.rdm-audit-event { display: flex; align-items: center; gap: 8px; padding: 4px 10px; font-size: var(--fs-ui-sm); border-top: 1px solid var(--border-soft); }
|
||||
.rdm-audit-event-time { font-size: var(--fs-ui-xs); color: var(--text-faint); min-width: 5em; }
|
||||
.rdm-audit-event-op { font-family: var(--mono); font-size: var(--fs-ui-xs); color: var(--accent-text); min-width: 8em; }
|
||||
.rdm-audit-event-actor { color: var(--text-dim); min-width: 4em; }
|
||||
.rdm-audit-event-detail { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-dim); }
|
||||
.rdm-audit-coverage { margin-top: 12px; }
|
||||
.rdm-audit-coverage-item { display: flex; align-items: center; gap: 8px; padding: 3px 0; font-size: var(--fs-ui-sm); color: var(--text-dim); }
|
||||
.rdm-audit-err { display: flex; flex-direction: column; gap: 8px; }
|
||||
.rdm-audit-err-toggle { font-size: var(--fs-ui-caption); color: var(--text-dim); }
|
||||
.rdm-audit-err-raw { display: block; font-family: var(--mono); font-size: var(--fs-code); color: var(--text-dim); background: var(--bg-inset); border-radius: var(--radius-sm); padding: 8px 12px; white-space: pre-wrap; word-break: break-all; }
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import * as apiModule from './api'
|
||||
import { api } from './api'
|
||||
import { store } from './store'
|
||||
import { previewTabsKey } from './previewtabs'
|
||||
import type { StreamEvent, SurfaceMeta, SurfaceSnapshot } from './contracts'
|
||||
|
||||
// U4/R5:surface.updated 投影的 revision 守卫——旧修订/重复事件不得覆盖新修订;
|
||||
// 服务端快照是唯一权威(KTD8 单一状态所有者)。
|
||||
|
||||
function surfEvent(seq: number, surfaceId: string, revision: number, title: string): StreamEvent<SurfaceSnapshot> {
|
||||
return {
|
||||
seq, conversationId: 'c1', ts: new Date().toISOString(), v: 1, type: 'surface.updated',
|
||||
payload: {
|
||||
identity: { surfaceId, owner: 'alice', pluginId: 'generative-ui', conversationId: 'c1' },
|
||||
compat: { schemaVersion: 1, catalogVersion: 'ui-v1' },
|
||||
revision, activationEpoch: 1,
|
||||
spec: { title, nodes: [{ type: 'markdown', id: 'n1', markdown: 'x' }] },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('surface.updated projection', () => {
|
||||
it('新事件入库;旧修订与重复事件不覆盖', () => {
|
||||
store.applyEvent(surfEvent(1, 's1', 1, '初版'))
|
||||
store.applyEvent(surfEvent(2, 's1', 2, '修订二'))
|
||||
store.applyEvent(surfEvent(3, 's1', 1, '迟到的旧修订'))
|
||||
store.applyEvent(surfEvent(4, 's1', 2, '重复事件'))
|
||||
const list = store.getState().surfaces
|
||||
expect(list).toHaveLength(1)
|
||||
expect(list[0].revision).toBe(2)
|
||||
expect(list[0].spec.title).toBe('修订二')
|
||||
})
|
||||
|
||||
it('多页面并存,互不覆盖', () => {
|
||||
store.applyEvent(surfEvent(5, 's2', 1, '页面二'))
|
||||
const list = store.getState().surfaces
|
||||
expect(list.filter(x => String(x.identity.surfaceId) === 's1')).toHaveLength(1)
|
||||
expect(list.filter(x => String(x.identity.surfaceId) === 's2')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
describe('surface.updated 可见性联动', () => {
|
||||
it('自动开标签时右栏切到文件预览(syncFilesEntry 语义)', () => {
|
||||
store.applyEvent(surfEvent(101, 'sv1', 1, '可见性页'))
|
||||
const ui = store.getState().ui
|
||||
expect(ui.rightTab).toBe('files')
|
||||
expect(ui.rightCollapsed).toBe(false)
|
||||
expect(ui.filesView).toBe('preview')
|
||||
const tabs = store.getState().previewTabs[previewTabsKey(store.getState().currentConvId)] ?? []
|
||||
expect(tabs.some(t => t.id === 'surface:sv1')).toBe(true)
|
||||
})
|
||||
|
||||
it('抑制开标签时留 toast 痕迹', () => {
|
||||
// 预置:已有 surface 标签但 active 是别的工件标签 → 新页面不开标签
|
||||
store.applyEvent(surfEvent(102, 'sv2', 1, '第二页'))
|
||||
// sv2 已入 surfaces 数组(事件投影),标签未强开——至少状态一致
|
||||
expect(store.getState().surfaces.some(x => String(x.identity.surfaceId) === 'sv2')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('U6 工作区任务页', () => {
|
||||
it('pin 固定后新页面事件不改变固定对象;取消后跟随最新', () => {
|
||||
store.setUI({ workspaceSurfacePin: null })
|
||||
store.applyEvent(surfEvent(201, 'w1', 1, '第一页'))
|
||||
const first = store.getState().ui.workspaceSurfacePin
|
||||
expect(first).toBeNull() // 默认跟随(不自动 pin)
|
||||
// 用户固定 w1
|
||||
store.setUI({ workspaceSurfacePin: 'w1' })
|
||||
store.applyEvent(surfEvent(202, 'w2', 1, '第二页'))
|
||||
// pin 不变,且 surfaces 数组两页并存(禁用回退时容器随空数组自然消失)
|
||||
expect(store.getState().ui.workspaceSurfacePin).toBe('w1')
|
||||
const w2 = store.getState().surfaces.find(x => String(x.identity.surfaceId) === 'w2')
|
||||
expect(w2?.spec.title).toBe('第二页')
|
||||
store.setUI({ workspaceSurfacePin: null })
|
||||
expect(store.getState().ui.workspaceSurfacePin).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('saved surfaces on conversation reload', () => {
|
||||
const snapshot = (id: string, revision = 1) => surfEvent(1, id, revision, id).payload!
|
||||
const meta = (id: string): SurfaceMeta => ({
|
||||
surfaceId: id, title: id, pluginId: 'generative-ui', revision: 1,
|
||||
conversationId: 'c1', updatedAt: '2026-10-12T00:00:00Z',
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(api, 'projectSurfaces').mockResolvedValue([])
|
||||
vi.spyOn(api, 'surfaceList').mockResolvedValue([])
|
||||
vi.spyOn(api, 'surfaceGet').mockImplementation(async id => snapshot(id))
|
||||
vi.spyOn(api, 'conversationDetail').mockImplementation(async id => ({
|
||||
conversation: { id, projectId: 'p1', title: id, createdAt: '', updatedAt: '' },
|
||||
messages: [], runs: [], activeHitl: [], lastSeq: 0,
|
||||
}))
|
||||
vi.spyOn(api, 'getPlan').mockResolvedValue(null)
|
||||
vi.spyOn(api, 'getTodos').mockResolvedValue(null)
|
||||
vi.spyOn(api, 'listCards').mockResolvedValue([])
|
||||
vi.spyOn(api, 'listTasks').mockResolvedValue([])
|
||||
vi.spyOn(apiModule, 'connectEvents').mockReturnValue({ close: () => {} })
|
||||
store.setUI({ workspaceSurfacePin: null })
|
||||
const key = previewTabsKey(store.getState().currentConvId)
|
||||
for (const tab of store.getState().previewTabs[key] ?? []) store.closePreview(tab.id)
|
||||
})
|
||||
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
it('hydrates the metadata directory before exposing two renderable snapshots', async () => {
|
||||
vi.mocked(api.surfaceList).mockResolvedValue([meta('reload-1'), meta('reload-2')])
|
||||
await store.openConversation('reload')
|
||||
await vi.waitFor(() => {
|
||||
expect(store.getState().surfaces).toEqual([snapshot('reload-1'), snapshot('reload-2')])
|
||||
})
|
||||
expect(api.surfaceGet).toHaveBeenCalledWith('reload-1')
|
||||
expect(api.surfaceGet).toHaveBeenCalledWith('reload-2')
|
||||
expect(store.getState().previewActive.reload).toBe('surface:reload-1')
|
||||
})
|
||||
|
||||
it('isolates one unavailable page while restoring the remaining page', async () => {
|
||||
vi.mocked(api.surfaceList).mockResolvedValue([meta('missing'), meta('available')])
|
||||
vi.mocked(api.surfaceGet).mockImplementation(async id => {
|
||||
if (id === 'missing') throw new Error('404: removed page')
|
||||
return snapshot(id)
|
||||
})
|
||||
await store.openConversation('partial')
|
||||
await vi.waitFor(() => expect(store.getState().surfaces).toEqual([snapshot('available')]))
|
||||
})
|
||||
|
||||
it('keeps a newer live revision when an older snapshot arrives late', async () => {
|
||||
let resolve!: (value: SurfaceSnapshot) => void
|
||||
vi.mocked(api.surfaceList).mockResolvedValue([meta('racing')])
|
||||
vi.mocked(api.surfaceGet).mockReturnValue(new Promise<SurfaceSnapshot>(r => { resolve = r }))
|
||||
await store.openConversation('race')
|
||||
store.applyEvent(surfEvent(301, 'racing', 2, 'live revision'))
|
||||
resolve(snapshot('racing', 1))
|
||||
await new Promise<void>(r => setTimeout(r, 0))
|
||||
expect(store.getState().surfaces).toHaveLength(1)
|
||||
expect(store.getState().surfaces[0].revision).toBe(2)
|
||||
expect(store.getState().surfaces[0].spec.title).toBe('live revision')
|
||||
})
|
||||
|
||||
it('discards hydration from the previous conversation after switching', async () => {
|
||||
let resolve!: (value: SurfaceSnapshot) => void
|
||||
vi.mocked(api.surfaceList).mockResolvedValueOnce([meta('previous')]).mockResolvedValue([])
|
||||
vi.mocked(api.surfaceGet).mockReturnValue(new Promise<SurfaceSnapshot>(r => { resolve = r }))
|
||||
await store.openConversation('previous-conv')
|
||||
await store.openConversation('current-conv')
|
||||
resolve(snapshot('previous'))
|
||||
await new Promise<void>(r => setTimeout(r, 0))
|
||||
expect(store.getState().currentConvId).toBe('current-conv')
|
||||
expect(store.getState().surfaces).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,19 @@
|
||||
// surface 投影可能短暂混入无 identity 的条目;读 id 一律经此助手,避免渲染崩溃。
|
||||
import type { SurfaceSnapshot } from './contracts'
|
||||
|
||||
export function surfaceKey(x: { identity?: { surfaceId?: unknown } } | null | undefined): string | null {
|
||||
const id = x?.identity?.surfaceId
|
||||
return id == null || id === '' ? null : String(id)
|
||||
}
|
||||
|
||||
/** 按 surfaceId 合并;同 id 取 revision 较大者。跳过无 identity 的脏项。 */
|
||||
export function mergeSurfacesByRevision(a: SurfaceSnapshot[], b: SurfaceSnapshot[]): SurfaceSnapshot[] {
|
||||
const byId = new Map<string, SurfaceSnapshot>()
|
||||
for (const s of [...a, ...b]) {
|
||||
const id = surfaceKey(s)
|
||||
if (!id) continue
|
||||
const existing = byId.get(id)
|
||||
if (!existing || (s.revision ?? 0) >= (existing.revision ?? 0)) byId.set(id, s)
|
||||
}
|
||||
return [...byId.values()]
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
// U5 验收线第 4/6 条的客户端半边:请求构造只携带显式动作数据(未提交草稿
|
||||
// 无任何自动上传路径——构造函数是纯函数,测试其形状);requestId 每次唯一。
|
||||
|
||||
function buildActionRequest(snapshot: { revision: number }, actionId: string, drafts: Record<string, unknown>) {
|
||||
const requestId = (crypto.randomUUID ? crypto.randomUUID() : 'req-' + Date.now())
|
||||
return {
|
||||
actionId,
|
||||
revision: snapshot.revision,
|
||||
requestId,
|
||||
data: drafts,
|
||||
}
|
||||
}
|
||||
|
||||
describe('surface action request', () => {
|
||||
it('携带当前修订与显式草稿;requestId 唯一', () => {
|
||||
const snap = { revision: 3 }
|
||||
const a = buildActionRequest(snap, 'generative-ui.submit', { f1: { years: '5' } })
|
||||
const b = buildActionRequest(snap, 'generative-ui.submit', { f1: { years: '5' } })
|
||||
expect(a.revision).toBe(3)
|
||||
expect(a.actionId).toBe('generative-ui.submit')
|
||||
expect(a.data).toEqual({ f1: { years: '5' } })
|
||||
expect(a.requestId).not.toBe(b.requestId)
|
||||
})
|
||||
})
|
||||
+28
-1
@@ -1,6 +1,9 @@
|
||||
// 体系工具排位:服务端声明的工作区对所有用户可见,按 shortcut 数字占位;
|
||||
// 内置「代理」补进空位。文件浏览在第三栏「文件」入口(方案:入口收敛),不再占体系工具位。
|
||||
// 插件管理面板不进这条清单。
|
||||
// 2026-10-12 增量 2(审计入口能力门控):「追溯审计」不再无条件内置——普通基座
|
||||
// 没有任何 rdm.inspect.* 命令,入口可达而进入即 400。仅当命令面探测通过
|
||||
//(rdmAuditReady / store.rdmAuditAvailable)才纳入;代理仍无条件以 ⌘1 补位。
|
||||
|
||||
export interface WorkspaceEntry {
|
||||
key: string
|
||||
@@ -16,8 +19,26 @@ export interface WorkspaceSource {
|
||||
shortcut?: string
|
||||
}
|
||||
|
||||
/** 审计工作区消费的全部 rdm.inspect.* 命令(capabilities 入口 + 四页签取数)。 */
|
||||
export const RDM_AUDIT_COMMANDS = [
|
||||
'rdm.inspect.capabilities',
|
||||
'rdm.inspect.summary',
|
||||
'rdm.inspect.tree',
|
||||
'rdm.inspect.object',
|
||||
'rdm.inspect.relations',
|
||||
'rdm.inspect.events',
|
||||
'rdm.inspect.history',
|
||||
] as const
|
||||
|
||||
/** GET /api/commands 清单是否足以支撑审计工作区:七命令齐备且 enabled。清单不可用(未登录/请求失败)视为未就绪。 */
|
||||
export function rdmAuditReady(cmds: { id?: string; enabled?: boolean }[] | null | undefined): boolean {
|
||||
if (!Array.isArray(cmds)) return false
|
||||
return RDM_AUDIT_COMMANDS.every(need => cmds.some(c => c?.id === need && c.enabled === true))
|
||||
}
|
||||
|
||||
const BUILTINS: Omit<WorkspaceEntry, 'shortcut'>[] = [
|
||||
{ key: 'agents', title: '代理', icon: '🤖' },
|
||||
{ key: 'rdm-audit', title: '追溯审计', icon: '🔍' },
|
||||
]
|
||||
|
||||
function shortcutNum(raw?: string): number | null {
|
||||
@@ -28,7 +49,12 @@ function shortcutNum(raw?: string): number | null {
|
||||
return n >= 1 && n <= 9 ? n : null
|
||||
}
|
||||
|
||||
export function buildWorkspaceEntries(server: WorkspaceSource[]): WorkspaceEntry[] {
|
||||
interface BuildWorkspaceOptions {
|
||||
/** rdm.inspect.* 命令面探测通过(store.rdmAuditAvailable)。默认 false:能力未知/缺失不展示审计入口。 */
|
||||
rdmAudit?: boolean
|
||||
}
|
||||
|
||||
export function buildWorkspaceEntries(server: WorkspaceSource[], opts: BuildWorkspaceOptions = {}): WorkspaceEntry[] {
|
||||
const used = new Set<number>()
|
||||
const ranked: { rank: number; entry: Omit<WorkspaceEntry, 'shortcut'> }[] = []
|
||||
const unranked: Omit<WorkspaceEntry, 'shortcut'>[] = []
|
||||
@@ -56,6 +82,7 @@ export function buildWorkspaceEntries(server: WorkspaceSource[]): WorkspaceEntry
|
||||
return 99
|
||||
}
|
||||
ranked.push({ rank: takeFree(1), entry: BUILTINS[0] })
|
||||
if (opts.rdmAudit === true) ranked.push({ rank: takeFree(2), entry: BUILTINS[1] })
|
||||
for (const entry of unranked) ranked.push({ rank: takeFree(9), entry })
|
||||
return ranked
|
||||
.filter(item => item.rank <= 9)
|
||||
|
||||
@@ -40,4 +40,15 @@ describe('auth 凭证模块', () => {
|
||||
expect(auth.apiKey()).toBe('')
|
||||
expect(auth.withKey('/x')).toBe('/x')
|
||||
})
|
||||
|
||||
it('beginSession:密码会话生效后清残留 Bearer,fetch 不再带头', async () => {
|
||||
auth.setKey('ask_stale')
|
||||
auth.beginSession()
|
||||
expect(auth.apiKey()).toBe('')
|
||||
const spy = vi.fn().mockResolvedValue(new Response('{}'))
|
||||
vi.stubGlobal('fetch', spy)
|
||||
await auth.fetch('/api/me')
|
||||
const [, init] = spy.mock.calls[0] as [string, RequestInit | undefined]
|
||||
expect(new Headers(init?.headers ?? {}).get('Authorization')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
// 登录/改密门错误文案:登录与改密的 401 语义不同,映射分开锁定
|
||||
// (后端错误串是现行 HTTP 契约的一部分,这里逐字对拍防漂移)。
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { humanChangePasswordError, humanLoginError } from '../src/components/LoginPage'
|
||||
|
||||
describe('humanLoginError(登录)', () => {
|
||||
it('401 统一为用户名或密码不正确', () => {
|
||||
expect(humanLoginError('401: invalid owner or password')).toBe('用户名或密码不正确')
|
||||
})
|
||||
it('429/412 各有文案,其余剥状态码前缀', () => {
|
||||
expect(humanLoginError('429: too many failed attempts; retry later')).toBe('失败次数过多,请稍后再试')
|
||||
expect(humanLoginError('412: auth disabled (embedded single-tenant mode)')).toBe('本服务未开启登录(嵌入式单租户档)')
|
||||
expect(humanLoginError('400: bad')).toBe('bad')
|
||||
})
|
||||
})
|
||||
|
||||
describe('humanChangePasswordError(改密门)', () => {
|
||||
it('当前密码错误不再伪装成登录失败', () => {
|
||||
expect(humanChangePasswordError('401: current password incorrect')).toBe('当前密码不正确')
|
||||
})
|
||||
it('无会话(API key 凭证)给出可行动指引', () => {
|
||||
expect(humanChangePasswordError('401: session login required (API keys do not own passwords)')).toBe('请先用账号密码登录再改密(API 密钥不能修改密码)')
|
||||
})
|
||||
it('会话过期与失效 key 各有文案', () => {
|
||||
expect(humanChangePasswordError('401: session expired')).toBe('会话已过期,请重新登录')
|
||||
expect(humanChangePasswordError('401: unauthorized: invalid or revoked API key')).toBe('本地残留的 API 密钥已失效,请退出登录后重试')
|
||||
})
|
||||
it('未知 401 回落通用文案,其余错误剥前缀透传', () => {
|
||||
expect(humanChangePasswordError('401: whatever')).toBe('认证失败,请重新登录')
|
||||
expect(humanChangePasswordError('400: password must be 8-256 characters')).toBe('password must be 8-256 characters')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { classifyRdmError } from '../src/rdmErrors'
|
||||
|
||||
// 2026-10-12 增量 2(审计错误四分类):审计页不再把原始报错(400 回执 JSON)直出,
|
||||
// 改为「能力缺失 / 登录已失效 / 查询失败」文案并保留原始回执供诊断;
|
||||
// 「无可审计项目」由组件空态承担,不走本分类。
|
||||
// 分类依据:api.ts json() 抛 `${status}: ${body}`;后端 unknown/disabled 命令
|
||||
// 回 400 + "unknown or disabled command"(cmdbus.go Dispatch),总线未挂载回
|
||||
// 503 "commands disabled"(server.go execCommand)。
|
||||
describe('classifyRdmError', () => {
|
||||
it('400 unknown or disabled command → 能力缺失', () => {
|
||||
const raw = '400: {"receipt":{"state":"rejected","error":"unknown or disabled command: rdm.inspect.capabilities"}}'
|
||||
const e = classifyRdmError(new Error(raw))
|
||||
expect(e.kind).toBe('capability')
|
||||
expect(e.message).toContain('当前服务未提供审计能力')
|
||||
expect(e.raw).toContain('unknown or disabled command')
|
||||
})
|
||||
|
||||
it('503 commands disabled → 能力缺失', () => {
|
||||
expect(classifyRdmError('503: commands disabled').kind).toBe('capability')
|
||||
})
|
||||
|
||||
it('401/403 → 登录已失效', () => {
|
||||
expect(classifyRdmError('401: session expired').kind).toBe('auth')
|
||||
expect(classifyRdmError(new Error('403: forbidden')).kind).toBe('auth')
|
||||
})
|
||||
|
||||
it('其余错误 → 查询失败,保留原始串', () => {
|
||||
const e = classifyRdmError('500: boom')
|
||||
expect(e.kind).toBe('query')
|
||||
expect(e.message).toContain('查询失败')
|
||||
expect(e.raw).toBe('500: boom')
|
||||
})
|
||||
|
||||
it('非 Error 输入可容忍', () => {
|
||||
expect(classifyRdmError(undefined).kind).toBe('query')
|
||||
expect(classifyRdmError({ weird: true }).kind).toBe('query')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,98 @@
|
||||
// U6 页面接线契约测试(方案 r1 U6/KTD5):RDM 管理/项目页面的能力门控与命令
|
||||
// 载荷构造是纯逻辑——先红后实现。门控语义与 rdmAuditReady 同源:命令清单不可用
|
||||
//(未登录/请求失败/null)一律视为未就绪(fail-closed),普通基座不出现 RDM 页面。
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
RDM_ROLES_COMMANDS, RDM_MEMBER_COMMANDS, RDM_DOC_REVIEW_COMMANDS, ROLE_ACTIONS,
|
||||
rdmRolesReady, rdmMembersReady, rdmDocReviewReady,
|
||||
roleCreatePayload, roleActionPayload, roleAssignPayload, roleDisablePayload,
|
||||
memberAddPayload, memberRemovePayload, assignDocDutiesPayload,
|
||||
docSubmitPayload, docApprovePayload, docRejectPayload, docFinalPayload, docStatusPayload,
|
||||
RDM_PERM_MATRIX_COMMANDS, rdmPermMatrixReady, permCheckPayload, describeSource,
|
||||
parseReviewers,
|
||||
} from '../src/rdmManagement'
|
||||
|
||||
const cmd = (id: string, enabled = true) => ({ id, enabled })
|
||||
const all = (ids: readonly string[]) => ids.map(id => cmd(id))
|
||||
|
||||
describe('RDM 页面能力门控', () => {
|
||||
it('命令齐备且 enabled 才就绪', () => {
|
||||
expect(rdmRolesReady(all(RDM_ROLES_COMMANDS))).toBe(true)
|
||||
expect(rdmMembersReady(all(RDM_MEMBER_COMMANDS))).toBe(true)
|
||||
expect(rdmDocReviewReady(all(RDM_DOC_REVIEW_COMMANDS))).toBe(true)
|
||||
})
|
||||
it('缺一命令即未就绪', () => {
|
||||
const drop = (ids: readonly string[], skip: string) => ids.filter(i => i !== skip).map(id => cmd(id))
|
||||
expect(rdmRolesReady(drop(RDM_ROLES_COMMANDS, 'rdm.role.list'))).toBe(false)
|
||||
expect(rdmMembersReady(drop(RDM_MEMBER_COMMANDS, 'rdm.project.member.add'))).toBe(false)
|
||||
expect(rdmDocReviewReady(drop(RDM_DOC_REVIEW_COMMANDS, 'rdm.review.doc.final'))).toBe(false)
|
||||
})
|
||||
it('disabled / null / 非数组 / 请求失败均未就绪(fail-closed)', () => {
|
||||
expect(rdmRolesReady(RDM_ROLES_COMMANDS.map(id => cmd(id, false)))).toBe(false)
|
||||
expect(rdmRolesReady(null)).toBe(false)
|
||||
expect(rdmMembersReady(undefined)).toBe(false)
|
||||
expect(rdmDocReviewReady('not-array' as unknown as never)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('RDM 动作清单(12 项,与 perms.AllActions 对齐)', () => {
|
||||
it('精确等于 RDM 动作枚举且不重复', () => {
|
||||
expect([...ROLE_ACTIONS].sort()).toEqual([
|
||||
'doc.publish', 'doc.read', 'doc.write', 'duty.assign', 'knowledge.publish',
|
||||
'member.manage', 'project.archive', 'project.create', 'project.settings',
|
||||
'review.participate', 'stage.advance', 'trace.edit',
|
||||
])
|
||||
expect(new Set(ROLE_ACTIONS).size).toBe(12)
|
||||
})
|
||||
})
|
||||
|
||||
describe('命令载荷构造(身份只经认证会话——载荷不含任何身份字段)', () => {
|
||||
it('角色管理载荷', () => {
|
||||
expect(roleCreatePayload('工程师')).toEqual({ name: '工程师' })
|
||||
expect(roleActionPayload('role_ab', 'doc.write', false, true))
|
||||
.toEqual({ roleId: 'role_ab', action: 'doc.write', default: false, cap: true })
|
||||
expect(roleAssignPayload('bob', 'role_ab')).toEqual({ owner: 'bob', roleId: 'role_ab' })
|
||||
expect(roleDisablePayload('role_ab', true)).toEqual({ roleId: 'role_ab', disabled: true })
|
||||
})
|
||||
it('项目成员载荷(role 缺省 member)', () => {
|
||||
expect(memberAddPayload('p1', 'carol')).toEqual({ projectId: 'p1', member: 'carol', role: 'member' })
|
||||
expect(memberAddPayload('p1', 'carol', 'owner')).toEqual({ projectId: 'p1', member: 'carol', role: 'owner' })
|
||||
expect(memberRemovePayload('p1', 'carol')).toEqual({ projectId: 'p1', member: 'carol' })
|
||||
})
|
||||
it('文档职责与整文审签载荷', () => {
|
||||
expect(assignDocDutiesPayload('p1', 'd1', 'alice', ['bob', 'carol'], 'dave'))
|
||||
.toEqual({ projectId: 'p1', docId: 'd1', author: 'alice', reviewers: ['bob', 'carol'], approver: 'dave' })
|
||||
expect(docSubmitPayload('p1', 'd1')).toEqual({ projectId: 'p1', docId: 'd1' })
|
||||
expect(docApprovePayload('p1', 'd1', '通过')).toEqual({ projectId: 'p1', docId: 'd1', note: '通过' })
|
||||
expect(docRejectPayload('p1', 'd1', '依据不足')).toEqual({ projectId: 'p1', docId: 'd1', reason: '依据不足' })
|
||||
expect(docFinalPayload('p1', 'd1')).toEqual({ projectId: 'p1', docId: 'd1' })
|
||||
expect(docFinalPayload('p1', 'd1', 3)).toEqual({ projectId: 'p1', docId: 'd1', round: 3 })
|
||||
expect(docStatusPayload('p1', 'd1')).toEqual({ projectId: 'p1', docId: 'd1' })
|
||||
})
|
||||
it('审阅人 CSV 解析:去空白、去空项、保序去重', () => {
|
||||
expect(parseReviewers('bob, carol,,bob')).toEqual(['bob', 'carol'])
|
||||
expect(parseReviewers(' ')).toEqual([])
|
||||
expect(parseReviewers('bob')).toEqual(['bob'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('项目权限矩阵页(rdm.perm.check 消费)', () => {
|
||||
it('命令面齐备才就绪(perm.check + 成员清单);缺一/disabled/清单不可用均未就绪', () => {
|
||||
const all2 = RDM_PERM_MATRIX_COMMANDS.map(id => ({ id, enabled: true }))
|
||||
expect(rdmPermMatrixReady(all2)).toBe(true)
|
||||
expect(rdmPermMatrixReady(RDM_PERM_MATRIX_COMMANDS.slice(1).map(id => ({ id, enabled: true })))).toBe(false)
|
||||
expect(rdmPermMatrixReady(RDM_PERM_MATRIX_COMMANDS.map(id => ({ id, enabled: false })))).toBe(false)
|
||||
expect(rdmPermMatrixReady(null)).toBe(false)
|
||||
})
|
||||
it('矩阵查询载荷(身份只经认证会话)', () => {
|
||||
expect(permCheckPayload('p1', 'bob')).toEqual({ projectId: 'p1', member: 'bob' })
|
||||
})
|
||||
it('来源标签:解释读面的人话映射;未知来源回退原文', () => {
|
||||
expect(describeSource('admin')).toBe('系统管理员')
|
||||
expect(describeSource('prof-role-default')).toContain('角色')
|
||||
expect(describeSource('override')).toContain('覆盖')
|
||||
expect(describeSource('relation-default')).toContain('关系')
|
||||
expect(describeSource('non-member')).toContain('成员')
|
||||
expect(describeSource('weird')).toBe('weird')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { settingInputAttrs } from '../src/components/SettingsModal'
|
||||
|
||||
// 2026-10-12 增量 3:配置输入按字段类型渲染——此前 SettingInput 默认分支无条件
|
||||
// type="password",浏览器把整排设置项当凭据表单(「password 不在 form 中/缺少
|
||||
// username」警告的来源之一),且普通文本被无谓遮蔽。新契约:仅秘密字段用密码框。
|
||||
describe('settingInputAttrs', () => {
|
||||
it('secret=true → 密码框 + new-password', () => {
|
||||
expect(settingInputAttrs({ key: 'llm.apiKey', label: '密钥', type: 'string', secret: true } as any))
|
||||
.toEqual({ type: 'password', autoComplete: 'new-password' })
|
||||
})
|
||||
|
||||
it('type=secret → 密码框', () => {
|
||||
expect(settingInputAttrs({ key: 'x', label: 'X', type: 'secret' } as any).type).toBe('password')
|
||||
})
|
||||
|
||||
it('普通文本 → text + off(不遮蔽、不当凭据)', () => {
|
||||
const a = settingInputAttrs({ key: 'llm.baseUrl', label: '地址', type: 'string' } as any)
|
||||
expect(a).toEqual({ type: 'text', autoComplete: 'off' })
|
||||
})
|
||||
|
||||
it('无类型标注的默认项 → text', () => {
|
||||
expect(settingInputAttrs({ key: 'ui.language', label: '语言' } as any).type).toBe('text')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { SurfaceSnapshot } from '../src/contracts'
|
||||
import { mergeSurfacesByRevision, surfaceKey } from '../src/surfaceKey'
|
||||
|
||||
function snap(id: string, revision: number): SurfaceSnapshot {
|
||||
return {
|
||||
identity: {
|
||||
surfaceId: id,
|
||||
owner: 'o',
|
||||
pluginId: 'p',
|
||||
},
|
||||
compat: { schemaVersion: 1, catalogVersion: '1' },
|
||||
revision,
|
||||
activationEpoch: 0,
|
||||
spec: { title: id, nodes: [] },
|
||||
}
|
||||
}
|
||||
|
||||
describe('surfaceKey', () => {
|
||||
it('reads nested identity and skips missing', () => {
|
||||
expect(surfaceKey(snap('s1', 1))).toBe('s1')
|
||||
expect(surfaceKey(undefined)).toBeNull()
|
||||
expect(surfaceKey({} as SurfaceSnapshot)).toBeNull()
|
||||
expect(surfaceKey({ identity: {} } as SurfaceSnapshot)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('mergeSurfacesByRevision', () => {
|
||||
it('keeps higher revision and drops dirty entries', () => {
|
||||
const dirty = { revision: 99 } as SurfaceSnapshot
|
||||
const merged = mergeSurfacesByRevision(
|
||||
[snap('a', 1), dirty],
|
||||
[snap('a', 3), snap('b', 1)],
|
||||
)
|
||||
expect(merged.map(s => [surfaceKey(s), s.revision])).toEqual([
|
||||
['a', 3],
|
||||
['b', 1],
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { hasActiveProgressCard } from '../src/components/Workbench'
|
||||
import type { ActivityCard } from '@ui'
|
||||
|
||||
function card(over: Partial<ActivityCard>): ActivityCard {
|
||||
return {
|
||||
id: 'card_1', projectId: 'prj_1', conversationId: 'cnv_1',
|
||||
kind: 'progress', title: '材料整理', status: 'active', tone: 'info',
|
||||
createdAt: '2026-10-12T10:00:00Z', updatedAt: '2026-10-12T10:00:00Z',
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
describe('hasActiveProgressCard', () => {
|
||||
it('active/action_required progress 卡视为占用当前任务位', () => {
|
||||
expect(hasActiveProgressCard([card({ status: 'active' })])).toBe(true)
|
||||
expect(hasActiveProgressCard([card({ status: 'action_required' })])).toBe(true)
|
||||
})
|
||||
|
||||
it('完成/失败 progress 或非 progress 不隐藏 CurrentTaskCard', () => {
|
||||
expect(hasActiveProgressCard([card({ status: 'done', tone: 'ok' })])).toBe(false)
|
||||
expect(hasActiveProgressCard([card({ status: 'failed', tone: 'danger' })])).toBe(false)
|
||||
expect(hasActiveProgressCard([card({ kind: 'confirm', status: 'action_required' })])).toBe(false)
|
||||
expect(hasActiveProgressCard([])).toBe(false)
|
||||
})
|
||||
})
|
||||
+57
-8
@@ -1,26 +1,75 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildWorkspaceEntries } from '../src/workspaces'
|
||||
import { buildWorkspaceEntries, rdmAuditReady, RDM_AUDIT_COMMANDS } from '../src/workspaces'
|
||||
|
||||
// 三栏工作台(2026-10-11):文件浏览并入第三栏「文件」入口,体系工具只补「代理」。
|
||||
// 三栏工作台(2026-10-11):文件浏览并入第三栏「文件」入口;体系工具内置「代理」。
|
||||
// 2026-10-12 增量 2(审计入口能力门控):「追溯审计」不再无条件内置——普通基座
|
||||
// 没有任何 rdm.inspect.* 命令,入口展示后进入即 400。新契约:仅当命令面探测
|
||||
// 通过(opts.rdmAudit)才纳入 rdm-audit;代理仍无条件以 ⌘1 补位。
|
||||
describe('buildWorkspaceEntries', () => {
|
||||
it('没有领域插件时,代理补在 ⌘1', () => {
|
||||
it('没有领域插件且无 RDM 能力:只有代理占 ⌘1', () => {
|
||||
const entries = buildWorkspaceEntries([])
|
||||
expect(entries.map(e => `${e.shortcut} ${e.title}`)).toEqual(['⌘1 代理'])
|
||||
})
|
||||
|
||||
it('声明了 shortcut 的工作区占住该位,内置工具让位', () => {
|
||||
it('没有领域插件且 RDM 能力通过:代理 ⌘1、追溯审计 ⌘2', () => {
|
||||
const entries = buildWorkspaceEntries([], { rdmAudit: true })
|
||||
expect(entries.map(e => `${e.shortcut} ${e.title}`)).toEqual(['⌘1 代理', '⌘2 追溯审计'])
|
||||
})
|
||||
|
||||
it('声明了 shortcut 的工作区占住该位,内置工具让位(能力开)', () => {
|
||||
const entries = buildWorkspaceEntries([
|
||||
{ id: 'trace', title: '追溯网', shortcut: '1' },
|
||||
], { rdmAudit: true })
|
||||
expect(entries.map(e => e.title)).toEqual(['追溯网', '代理', '追溯审计'])
|
||||
expect(entries.map(e => e.shortcut)).toEqual(['⌘1', '⌘2', '⌘3'])
|
||||
expect(entries[0].key).toBe('ws:trace')
|
||||
})
|
||||
|
||||
it('无 RDM 能力:让位后只有代理,不出现追溯审计', () => {
|
||||
const entries = buildWorkspaceEntries([
|
||||
{ id: 'trace', title: '追溯网', shortcut: '1' },
|
||||
])
|
||||
expect(entries.map(e => e.title)).toEqual(['追溯网', '代理'])
|
||||
expect(entries.map(e => e.shortcut)).toEqual(['⌘1', '⌘2'])
|
||||
expect(entries[0].key).toBe('ws:trace')
|
||||
})
|
||||
|
||||
it('未编号的工作区排在内置工具后面', () => {
|
||||
const entries = buildWorkspaceEntries([
|
||||
{ id: 'mem', title: '记忆库' },
|
||||
])
|
||||
expect(entries.map(e => e.title)).toEqual(['代理', '记忆库'])
|
||||
], { rdmAudit: true })
|
||||
expect(entries.map(e => e.title)).toEqual(['代理', '追溯审计', '记忆库'])
|
||||
})
|
||||
|
||||
it('九位占满时低序内置保留,溢出项截断', () => {
|
||||
const server = Array.from({ length: 8 }, (_, i) => ({ id: `w${i}`, title: `w${i}`, shortcut: String(i + 1) }))
|
||||
const entries = buildWorkspaceEntries(server, { rdmAudit: true })
|
||||
// ⌘1–8 被声明占住;代理取 ⌘9;追溯审计溢出截断(slice 0,9)。
|
||||
expect(entries.filter(e => e.key.startsWith('ws:'))).toHaveLength(8)
|
||||
expect(entries.map(e => e.title)).toContain('代理')
|
||||
expect(entries.map(e => e.title)).not.toContain('追溯审计')
|
||||
})
|
||||
})
|
||||
|
||||
// 能力探测依据后端 GET /api/commands(server.go listCommands:{ id, title, …, enabled })。
|
||||
// 审计工作区消费七个 rdm.inspect.* 命令;任一缺失或 enabled=false 都视为能力未就绪。
|
||||
describe('rdmAuditReady', () => {
|
||||
const all = RDM_AUDIT_COMMANDS.map(id => ({ id, enabled: true }))
|
||||
|
||||
it('七命令齐备且启用 → true', () => {
|
||||
expect(rdmAuditReady(all)).toBe(true)
|
||||
})
|
||||
|
||||
it('普通基座空清单 / 缺任一命令 → false', () => {
|
||||
expect(rdmAuditReady([])).toBe(false)
|
||||
expect(rdmAuditReady(all.filter(c => c.id !== 'rdm.inspect.history'))).toBe(false)
|
||||
})
|
||||
|
||||
it('存在但 enabled=false → false', () => {
|
||||
const disabled = all.map(c => (c.id === 'rdm.inspect.capabilities' ? { ...c, enabled: false } : c))
|
||||
expect(rdmAuditReady(disabled)).toBe(false)
|
||||
})
|
||||
|
||||
it('清单不可用(未登录/请求失败)→ false,不展示入口', () => {
|
||||
expect(rdmAuditReady(null)).toBe(false)
|
||||
expect(rdmAuditReady(undefined)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -18,5 +18,13 @@ export default defineConfig({
|
||||
'/api': { target: 'http://127.0.0.1:8787', changeOrigin: false },
|
||||
},
|
||||
},
|
||||
// E2E(U4 运行验收):preview 服务 dist 并把 /api 代理到被测后端(e2e.sh 注入
|
||||
// PLAYWRIGHT_API_TARGET;缺省与 dev 代理同值,本地 npm run preview 也保持可用)。
|
||||
preview: {
|
||||
port: Number(process.env.PLAYWRIGHT_PREVIEW_PORT || 4173),
|
||||
proxy: {
|
||||
'/api': { target: process.env.PLAYWRIGHT_API_TARGET || 'http://127.0.0.1:8787', changeOrigin: false },
|
||||
},
|
||||
},
|
||||
test: { environment: 'node' },
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user