Compare commits
38
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
6b4920b509 | ||
|
|
a26d7c5aee | ||
|
|
69b1487fb7 | ||
|
|
32e327a831 | ||
|
|
3ed10e1220 | ||
|
|
204ca67dc7 | ||
|
|
339a9ebc2d | ||
|
|
475fd3a8d6 | ||
|
|
84b73ab328 | ||
|
|
c71a77bb22 | ||
|
|
50c7244601 | ||
|
|
2464c16e6b | ||
|
|
0a0b55612c |
@@ -6,3 +6,6 @@ dist/
|
||||
|
||||
# mimosa 扫描钩子的本地缓存(scripts/hooks 写入,不入库)
|
||||
.mimosa
|
||||
# Playwright 运行工件(e2e/ 用例输出;详工件在 Dev .tmp/e2e/<ts>/frontend/)
|
||||
test-results/
|
||||
playwright-report/
|
||||
|
||||
@@ -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,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') ?? '',
|
||||
)
|
||||
})
|
||||
})
|
||||
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',
|
||||
},
|
||||
})
|
||||
+36
-33
@@ -8,7 +8,7 @@ import { Icon } from '@ui'
|
||||
import { store, useStore } from './store'
|
||||
import { TaskSidebar } from './components/TaskSidebar'
|
||||
import { Conversation } from './components/Conversation'
|
||||
import { PreviewPane } from './components/PreviewPane'
|
||||
import { previewTabsKey } from './previewtabs'
|
||||
import { Workbench } from './components/Workbench'
|
||||
import { Workspaces, workspaceShortcuts } from './components/Workspaces'
|
||||
import { AgentTracker } from './components/AgentTracker'
|
||||
@@ -17,7 +17,8 @@ import { ProjectSettingsModal } from './components/ProjectSettingsModal'
|
||||
import { TopBar } from './components/TopBar'
|
||||
import { workspaceEntries } from './components/Workspaces'
|
||||
|
||||
// 布局:左(项目会话)/ 中(对话)/ 预览区(有标签时)/ 右(工作台)。
|
||||
// 布局(三栏工作台):左(项目与会话)/ 中(对话)/ 第三栏(工作|文件双入口)。
|
||||
// 文件预览不再是独立第四栏——预览在第三栏「文件」入口内,展开态铺满主区。
|
||||
const LEFT_MIN = 220, LEFT_MAX = 420, LEFT_OVERSHOOT = 40
|
||||
const RIGHT_MIN = 280, RIGHT_MAX = 560
|
||||
const PREVIEW_MIN = 420, PREVIEW_MAX = 1100
|
||||
@@ -30,13 +31,16 @@ export function App() {
|
||||
}, [])
|
||||
|
||||
const ui = useStore(s => s.ui)
|
||||
const previewOpen = useStore(s => {
|
||||
const id = s.currentConvId
|
||||
return id ? (s.previewTabs[id]?.length ?? 0) > 0 : false
|
||||
})
|
||||
// 预览键与 requestPreview 的落点一致(previewTabsKey):空态(未选会话)点开
|
||||
// 工件也要能进文件入口,否则点击只剩工件弹层关闭、毫无反馈。
|
||||
const previewOpen = useStore(s => (s.previewTabs[previewTabsKey(s.currentConvId)]?.length ?? 0) > 0)
|
||||
const rightTab = useStore(s => s.ui.rightTab)
|
||||
const toast = useStore(s => s.toast)
|
||||
const trackerRunId = useStore(s => s.ui.trackerRunId)
|
||||
const previewExpanded = previewOpen && ui.previewExpanded
|
||||
// 文件入口 + 预览标签在场时,第三栏用「阅读档」宽度(420–1100),不受工作档上限约束。
|
||||
const filesWide = rightTab === 'files' && previewOpen
|
||||
const rightPanelWidth = filesWide ? ui.previewWidth : ui.rightWidth
|
||||
const previewExpanded = filesWide && ui.previewExpanded
|
||||
const pendingBadge = useStore(s => s.cards.filter(c => c.status === 'action_required').length)
|
||||
// authState:checking → login(登录)→ mustchange(首登强制改密)→ ok(放行)。
|
||||
const [authState, setAuthState] = useState<'checking' | 'login' | 'mustchange' | 'ok'>('checking')
|
||||
@@ -48,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') // 网络异常不是认证问题;放行交给业务层报错
|
||||
@@ -64,28 +73,31 @@ export function App() {
|
||||
store.refreshPlugins()
|
||||
}, [authState])
|
||||
|
||||
const drag = useRef<{ side: 'left' | 'right' | 'preview'; startX: number; startW: number; overshoot: boolean } | null>(null)
|
||||
const drag = useRef<{ side: 'left' | 'right'; startX: number; startW: number; overshoot: boolean; min: number; max: number; key: 'leftWidth' | 'rightWidth' | 'previewWidth' } | null>(null)
|
||||
|
||||
const onDividerDown = (side: 'left' | 'right' | 'preview') => (e: React.MouseEvent) => {
|
||||
const onDividerDown = (side: 'left' | 'right') => (e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
const startW = side === 'left' ? ui.leftWidth : side === 'preview' ? ui.previewWidth : ui.rightWidth
|
||||
drag.current = { side, startX: e.clientX, startW, overshoot: false }
|
||||
// 右侧分隔条按当前入口选宽度档:文件+预览在场走阅读档,否则工作档。
|
||||
const wide = side === 'right' && rightTab === 'files' && previewOpen
|
||||
const key = side === 'left' ? 'leftWidth' : wide ? 'previewWidth' : 'rightWidth'
|
||||
drag.current = {
|
||||
side, startX: e.clientX, startW: ui[key], overshoot: false,
|
||||
min: side === 'left' ? LEFT_MIN : wide ? PREVIEW_MIN : RIGHT_MIN,
|
||||
max: side === 'left' ? LEFT_MAX : wide ? PREVIEW_MAX : RIGHT_MAX,
|
||||
key,
|
||||
}
|
||||
const move = (ev: MouseEvent) => {
|
||||
const d = drag.current
|
||||
if (!d) return
|
||||
if (d.side === 'left') {
|
||||
const w = d.startW + (ev.clientX - d.startX)
|
||||
if (w < LEFT_MIN - LEFT_OVERSHOOT) { d.overshoot = true; return }
|
||||
if (w < d.min - LEFT_OVERSHOOT) { d.overshoot = true; return }
|
||||
d.overshoot = false
|
||||
store.setUI({ leftCollapsed: false, leftWidth: Math.min(LEFT_MAX, Math.max(LEFT_MIN, w)) })
|
||||
} else if (d.side === 'preview') {
|
||||
const w = d.startW - (ev.clientX - d.startX)
|
||||
if (w < PREVIEW_MIN) return
|
||||
store.setUI({ previewWidth: Math.min(PREVIEW_MAX, w) })
|
||||
store.setUI({ leftCollapsed: false, leftWidth: Math.min(d.max, Math.max(d.min, w)) })
|
||||
} else {
|
||||
const w = d.startW - (ev.clientX - d.startX)
|
||||
if (w < RIGHT_MIN) return
|
||||
store.setUI({ rightCollapsed: false, rightWidth: Math.min(RIGHT_MAX, w) })
|
||||
if (w < d.min) return
|
||||
store.setUI({ rightCollapsed: false, [d.key]: Math.min(d.max, w) } as Partial<typeof ui>)
|
||||
}
|
||||
}
|
||||
const up = () => {
|
||||
@@ -146,30 +158,21 @@ export function App() {
|
||||
<Conversation />
|
||||
</div>
|
||||
|
||||
{previewOpen && (
|
||||
<>
|
||||
{!previewExpanded && (
|
||||
<div className="divider" onMouseDown={onDividerDown('preview')} title="拖动调整预览区宽度" />
|
||||
)}
|
||||
<div className={`preview-slot mk-panel-grow${previewExpanded ? ' expanded' : ''}`} style={previewExpanded ? undefined : { width: ui.previewWidth }}>
|
||||
<PreviewPane />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!ui.rightCollapsed ? (
|
||||
<>
|
||||
<div className="divider" onMouseDown={onDividerDown('right')} onDoubleClick={store.toggleRight} title="拖动调整宽度;双击收起" />
|
||||
<div className="right-panel" style={{ width: ui.rightWidth }}>
|
||||
<div className={`right-panel mk-panel-grow${previewExpanded ? ' expanded' : ''}`}
|
||||
style={previewExpanded ? undefined : { width: rightPanelWidth }}>
|
||||
<Workbench />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="right-panel collapsed" style={{ width: 44 }}>
|
||||
<div className="icon-only">
|
||||
<button title="展开收件箱" onClick={store.toggleRight}>◧
|
||||
<button title="展开右栏" onClick={store.toggleRight}>◧
|
||||
{pendingBadge > 0 && <span className="rail-badge" data-testid="rail-badge">{pendingBadge}</span>}
|
||||
</button>
|
||||
<button title="文件(搜索、预览、编辑、下载)" onClick={() => store.openFiles()}><Icon name="folder" size={16} /></button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
+21
-2
@@ -30,7 +30,11 @@ 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)),
|
||||
surfaceList: (convId: string) => auth.fetch(`${BASE}/api/conversations/${convId}/surfaces`).then(r => json<any[]>(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)),
|
||||
surfaceGet: (id: string) => auth.fetch(`${BASE}/api/surfaces/${id}`).then(r => json<any>(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()}`)
|
||||
@@ -125,6 +129,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 +179,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()
|
||||
|
||||
@@ -78,7 +78,10 @@ export function AgentTracker() {
|
||||
|
||||
function UsageBadge({ usage }: { usage?: Usage | null }) {
|
||||
if (!usage) return null
|
||||
return <span className="effect-badge" title="token 用量与时长">
|
||||
{usage.totalTokens > 0 ? `${usage.totalTokens} tok · ` : ''}{(usage.durationMs / 1000).toFixed(1)}s{usage.toolCalls > 0 ? ` · ${usage.toolCalls} 工具` : ''}
|
||||
// N3 计费拆分:有上一请求数据时显示「+最近 · 累计(含缓存命中)」。
|
||||
const last = (usage.lastPromptTokens ?? 0) + (usage.lastCompletionTokens ?? 0)
|
||||
const cache = usage.cacheReadTokens ?? 0
|
||||
return <span className="effect-badge" title={cache > 0 ? `token 用量与时长(缓存命中 ${cache})` : 'token 用量与时长'}>
|
||||
{last > 0 ? `+${last} · ` : ''}{usage.totalTokens > 0 ? `累计 ${usage.totalTokens} tok · ` : ''}{(usage.durationMs / 1000).toFixed(1)}s{usage.toolCalls > 0 ? ` · ${usage.toolCalls} 工具` : ''}
|
||||
</span>
|
||||
}
|
||||
|
||||
@@ -1,19 +1,26 @@
|
||||
// 工件面板(工作区文件管理):搜索 + kind 过滤 + 上传;行内打开预览区或下载。
|
||||
// ⌘1 弹层与右栏「文件」页签共用这一份。删除仍是保留能力,系统默认不提供入口。
|
||||
// 文件列表(第三栏「文件」入口的主视图):搜索 + kind 过滤 + 上传;
|
||||
// 行内打开预览(同入口切到预览子视图)或下载。删除仍是保留能力,系统默认不提供入口。
|
||||
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'
|
||||
|
||||
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 +39,47 @@ export function ArtifactsPanel() {
|
||||
}} />
|
||||
</label>
|
||||
</div>
|
||||
{surfaces.length > 0 && (
|
||||
<div className="ap-surfaces" data-ap-surfaces>
|
||||
<div className="ap-section-title">生成页面</div>
|
||||
{surfaces.map(sv => (
|
||||
<div key={String(sv.identity.surfaceId)} className="artifact-row" data-surface-row={String(sv.identity.surfaceId)}
|
||||
onClick={() => store.openSurface(String(sv.identity.surfaceId))} 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 => (
|
||||
// 预览落在对话右侧预览区。工作区弹层盖住主区,先收起再打开。
|
||||
<div key={a.id} className="artifact-row" data-artifact-row={a.id}
|
||||
onClick={() => { store.closeWorkspace(); 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.closeWorkspace(); 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 +93,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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import { Icon, Avatar, BrandMark, Collapsible, getPersistedOpen, setPersistedOpen, SafeMarkdown } from '@ui'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { api } from '../api'
|
||||
import { store, useStore, activeRun, type LiveState, type ContextObs } from '../store'
|
||||
import { store, useStore, activeRun, isTerminal, type LiveState, type ContextObs } from '../store'
|
||||
import { scrollRatio, scrollTopForRatio } from '../scrollsync'
|
||||
import { liveToMessage, livePhaseLabel, showProcessInline } from '../live'
|
||||
import { deriveShellPhase, isShellBusy, showLiveBubble, type ShellPhase } from '../runstate'
|
||||
@@ -124,7 +124,11 @@ function DeliverableCard({ artifact, updates = 0 }: { artifact: { artifactId: st
|
||||
const name = artifact.name || art?.name || '文件'
|
||||
const icon = artifactIcon(name, artifact.kind ?? art?.kind)
|
||||
const size = humanSize(art?.size)
|
||||
const open = () => { if (art) store.requestPreview(art) }
|
||||
const open = () => {
|
||||
if (art) { store.requestPreview(art); return }
|
||||
// 工件已不在当前项目(被删/跨项目残留):给一句话反馈,不再无声无息。
|
||||
store.toast(`「${name}」已不在当前项目中,无法预览`)
|
||||
}
|
||||
return (
|
||||
<div className="deliverable-card mk-rise-in" data-artifact-chip={artifact.artifactId}>
|
||||
<button type="button" className="dc-open" title={`预览 ${name}`} onClick={open}>
|
||||
@@ -480,6 +484,8 @@ export function Conversation() {
|
||||
})
|
||||
const busy = isShellBusy(phase)
|
||||
const showStop = phase === 'running'
|
||||
// 慢通道在跑的后台子代理:前台回合结束后仍在执行,完成后经通知回到对话汇报。
|
||||
const bgRunning = runs.filter(r => r.kind === 'subagent' && !isTerminal(r.status)).length
|
||||
|
||||
useEffect(() => {
|
||||
if (!stickRef.current) return
|
||||
@@ -549,7 +555,6 @@ export function Conversation() {
|
||||
<SessionNoteField convId={currentConvId} note={conversation?.sessionNote ?? ''} />
|
||||
<span title={connected ? '事件流已连接' : '重连中'} className={`conn-dot ${connected ? 'on' : 'off'}`} />
|
||||
{!connected && <span className="conn-label">重连中</span>}
|
||||
{showStop && <button className="btn ghost" onClick={() => store.cancelActive()}>停止</button>}
|
||||
</div>
|
||||
|
||||
<div className="chat-body" ref={bodyRef}
|
||||
@@ -611,6 +616,19 @@ export function Conversation() {
|
||||
|
||||
<div className="input-bar">
|
||||
<OutboxPanel />
|
||||
{/* 轻量状态提示(方案:快慢通道连续体验):忙碌时可继续输入排队;
|
||||
后台任务在回合结束后仍在执行,完成后回到对话汇报。 */}
|
||||
{(busy || bgRunning > 0) && (
|
||||
<div className="input-status" data-testid="input-status">
|
||||
<span className="live-dot" />
|
||||
{busy
|
||||
? <span className="mk-sweep">助手工作中{bgRunning > 0 ? ` · ${bgRunning} 个后台任务` : ''}</span>
|
||||
: <span>{bgRunning} 个后台任务进行中</span>}
|
||||
<span className="input-status-note">{busy ? '输入仍可排队' : '完成后回到对话汇报'}</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
<button type="button" className="input-status-link" onClick={() => store.openInbox()}>在工作里查看</button>
|
||||
</div>
|
||||
)}
|
||||
{attachmentNames.length > 0 && (
|
||||
<div className="attachments">
|
||||
{attachmentNames.map((n, i) => <span className="attach-chip" key={i}><Icon name='paperclip' size={12} /> {n}
|
||||
|
||||
@@ -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,19 +1,24 @@
|
||||
// 对话右侧预览区:多标签、可关、展开与下载。宽度由 App 的分隔条控制;
|
||||
// 展开态铺满主区。文本类文件在这里编辑保存。
|
||||
// 第三栏「文件」入口的多标签预览:打开/切换/关闭标签、编辑保存、展开铺满与下载。
|
||||
// 列表 ⇄ 预览在同一入口内切换;宽度档(阅读档 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'
|
||||
|
||||
export function PreviewPane() {
|
||||
const convId = useStore(s => s.currentConvId)
|
||||
const tabs = useStore(s => (convId ? s.previewTabs[convId] : undefined) ?? [])
|
||||
const activeId = useStore(s => (convId ? s.previewActive[convId] : null) ?? null)
|
||||
// 键口径与 requestPreview 一致(previewTabsKey):空态预览也渲染。
|
||||
const tabs = useStore(s => s.previewTabs[previewTabsKey(s.currentConvId)] ?? [])
|
||||
const activeId = useStore(s => s.previewActive[previewTabsKey(s.currentConvId)] ?? null)
|
||||
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 => 'surface:' + String(x.identity.surfaceId) === 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)
|
||||
@@ -34,6 +39,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(String(surfaceSnap?.identity.surfaceId), 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
|
||||
@@ -43,6 +74,9 @@ export function PreviewPane() {
|
||||
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>
|
||||
{art && (
|
||||
<span className="preview-pane-meta">{art.kind}{humanSize(art.size) ? ` · ${humanSize(art.size)}` : ''}</span>
|
||||
|
||||
@@ -0,0 +1,505 @@
|
||||
// U4:项目追溯审计工作区(四入口)——消费 U3 rdm.inspect.* 命令面。
|
||||
// 只读:审批/确认关系仍回原业务流程(D31:弹层不是唯一路径)。
|
||||
// 令牌驱动(DESIGN.md):颜色/字号/圆角仅消费 @ui/tokens.css;折叠用 Collapsible。
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { api } from '../api'
|
||||
import { Chip, EmptyState, SectionLabel } from '@ui'
|
||||
|
||||
// ---- 类型(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']
|
||||
|
||||
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 <EmptyState icon="alert">{error}</EmptyState>
|
||||
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 <EmptyState icon="alert">{err}</EmptyState>
|
||||
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 <EmptyState icon="alert">{err}</EmptyState>
|
||||
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 && <EmptyState icon="alert">{err}</EmptyState>}
|
||||
{!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 && <EmptyState icon="alert">{err}</EmptyState>}
|
||||
{!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>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// 左栏:项目列表、当前项目的会话、体系工具、账户。
|
||||
// 左栏(找上下文):顶部切换项目,中间放会话与搜索,底部放文件、工作区和账户。
|
||||
// 创建、搜索、置顶与归档仍在会话段里;领域工具由服务端工作区声明,没有时装上说明行。
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { store, useStore } from '../store'
|
||||
@@ -40,6 +40,7 @@ export function TaskSidebar() {
|
||||
const projects = useStore(s => s.projects)
|
||||
const tasks = useStore(s => s.tasks)
|
||||
const me = useStore(s => s.me)
|
||||
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)
|
||||
@@ -50,7 +51,7 @@ export function TaskSidebar() {
|
||||
s.projectCards.filter(c => c.status === 'action_required').length)
|
||||
|
||||
const [q, setQ] = useState('')
|
||||
const [menu, setMenu] = useState<{ kind: 'new' | 'account' | `row:${string}`; anchor: HTMLElement } | null>(null)
|
||||
const [menu, setMenu] = useState<{ kind: 'new' | 'account' | 'project' | `row:${string}`; anchor: HTMLElement } | null>(null)
|
||||
const [creatingProject, setCreatingProject] = useState(false)
|
||||
const [newProjectName, setNewProjectName] = useState('')
|
||||
|
||||
@@ -70,6 +71,7 @@ export function TaskSidebar() {
|
||||
const pinned = visible.filter(t => t.pinned)
|
||||
const rest = visible.filter(t => !t.pinned)
|
||||
const currentProject = projects.find(p => p.id === currentProjectId)
|
||||
const stage = projectStage(currentProject?.settings)
|
||||
const rowTask = menu?.kind.startsWith('row:') ? tasks.find(t => t.id === menu.kind.slice(4)) : null
|
||||
const rowMenuAnchor = menu && menu.kind.startsWith('row:') ? menu.anchor : null
|
||||
|
||||
@@ -86,6 +88,14 @@ export function TaskSidebar() {
|
||||
</span>
|
||||
)
|
||||
|
||||
/** 切换项目:优先恢复该项目下正在看的会话,否则进第一个未归档会话。 */
|
||||
const switchProject = (pid: string) => {
|
||||
const next = tasks.find(t => t.projectId === pid && t.id === currentConvId)
|
||||
?? tasks.find(t => t.projectId === pid && !t.archived)
|
||||
if (next) store.openTask(next.id)
|
||||
else store.openProject(pid)
|
||||
}
|
||||
|
||||
const taskRow = (t: Conversation) => (
|
||||
<div key={t.id} className={`tsb-task ${t.id === currentConvId ? 'active' : ''} ${t.archived ? 'archived' : ''}`}
|
||||
onClick={() => store.openTask(t.id)}
|
||||
@@ -99,8 +109,15 @@ export function TaskSidebar() {
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 项目身份唯一落点:顶部切换项目(方案:项目导航置顶)。 */}
|
||||
<div className="tsb-brand">
|
||||
<span className="tsb-name">项目</span>
|
||||
<button className="tsb-projrow" title="切换项目"
|
||||
onClick={e => { const el = e.currentTarget; setMenu(m => m?.kind === 'project' ? null : { kind: 'project', anchor: el }) }}>
|
||||
<Icon name="folder" size={15} />
|
||||
<span className="tsb-projrow-name">{currentProject?.name ?? '选择项目'}</span>
|
||||
{stage && <span className="tsb-projrow-stage">{stage}</span>}
|
||||
<span className="tsb-user-chev"><Icon name="chevron-down" size={13} /></span>
|
||||
</button>
|
||||
<span style={{ flex: 1 }} />
|
||||
<button className="tsb-iconbtn" title="收起侧栏(⌘B)" onClick={store.toggleLeft}><Icon name="panel-left" /></button>
|
||||
</div>
|
||||
@@ -127,6 +144,7 @@ export function TaskSidebar() {
|
||||
onClick={() => { store.setUI({ showArchived: !showArchived }); store.refreshTasks(!showArchived) }}><Icon name="archive" size={15} /></button>
|
||||
</div>
|
||||
|
||||
{/* 中段(方案:会话与搜索)。项目切换在顶部,文件与工作区沉底。 */}
|
||||
<div className="tsb-scroll">
|
||||
{creatingProject && (
|
||||
<div className="tsb-newproject">
|
||||
@@ -143,25 +161,6 @@ export function TaskSidebar() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="tsb-seclabel">项目</div>
|
||||
{projects.map(p => {
|
||||
const stage = projectStage(p.settings)
|
||||
const count = tasks.filter(t => t.projectId === p.id && !t.archived).length
|
||||
return (
|
||||
<button key={p.id} className={`tsb-project ${p.id === currentProjectId ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
const next = tasks.find(t => t.projectId === p.id && t.id === currentConvId)
|
||||
?? tasks.find(t => t.projectId === p.id && !t.archived)
|
||||
if (next) store.openTask(next.id)
|
||||
else store.openProject(p.id)
|
||||
}}>
|
||||
<span className="tsb-project-name">{p.name}</span>
|
||||
<span className="tsb-project-meta">{stage ? `${stage} · ` : ''}{count} 个会话</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{projects.length === 0 && <div className="tsb-empty">还没有项目——从「新建任务」旁的菜单创建</div>}
|
||||
|
||||
{pinned.length > 0 && (
|
||||
<>
|
||||
<div className="tsb-seclabel">置顶</div>
|
||||
@@ -173,11 +172,15 @@ export function TaskSidebar() {
|
||||
<div className="tsb-empty">这个项目还没有会话——点击「新建任务」开始</div>
|
||||
)}
|
||||
{rest.map(t => taskRow(t))}
|
||||
</div>
|
||||
|
||||
<div className="tsb-seclabel">
|
||||
体系工具
|
||||
{pendingBadge > 0 && <span className="tsb-tool-badge" title="待处理">{pendingBadge}</span>}
|
||||
</div>
|
||||
{/* 底部(方案:文件、工作区、账户沉底)。 */}
|
||||
<div className="tsb-bottom">
|
||||
<button className="tsb-tool" title="文件搜索、预览、编辑与下载(第三栏「文件」入口)" onClick={() => store.openFiles()}>
|
||||
<span className="tsb-tool-icon"><Icon name="folder" size={14} /></span>
|
||||
<span className="tsb-tool-name">文件</span>
|
||||
{artifacts.length > 0 && <span className="tsb-group-cnt">{artifacts.length}</span>}
|
||||
</button>
|
||||
{tools.map(entry => (
|
||||
<button key={entry.key} className="tsb-tool" title={entry.shortcut} onClick={() => store.openWorkspace(entry.key)}>
|
||||
<span className="tsb-tool-icon">{entry.icon}</span>
|
||||
@@ -209,6 +212,22 @@ export function TaskSidebar() {
|
||||
</div>
|
||||
|
||||
{/* 弹出菜单们 */}
|
||||
<Menu anchor={menu?.kind === 'project' ? menu.anchor : null} onClose={() => setMenu(null)}>
|
||||
{projects.map(p => {
|
||||
const count = tasks.filter(t => t.projectId === p.id && !t.archived).length
|
||||
const pstage = projectStage(p.settings)
|
||||
return (
|
||||
<MenuItem key={p.id} icon="folder" onClick={() => { switchProject(p.id); setMenu(null) }}>
|
||||
{p.name}
|
||||
<kbd>{p.id === currentProjectId ? '当前' : pstage ? `${pstage} · ` : ''}{count} 会话</kbd>
|
||||
</MenuItem>
|
||||
)
|
||||
})}
|
||||
{projects.length === 0 && <div className="tsb-tool-note">还没有项目</div>}
|
||||
<div className="tsb-menu-div" />
|
||||
<MenuItem icon="plus" onClick={() => { setCreatingProject(true); setMenu(null) }}>新建项目…</MenuItem>
|
||||
</Menu>
|
||||
|
||||
<Menu anchor={menu?.kind === 'new' ? menu.anchor : null} onClose={() => setMenu(null)}>
|
||||
<MenuItem icon="plus" onClick={() => { store.createTask(); setMenu(null) }}>新建任务 · 当前项目<kbd>⌘N</kbd></MenuItem>
|
||||
<div className="tsb-menu-div" />
|
||||
|
||||
@@ -1,20 +1,16 @@
|
||||
// 顶栏:产品标识、当前项目与阶段徽章、待处理铃铛。不参与具体操作。
|
||||
// 顶栏:产品标识与待处理铃铛。项目身份在左栏顶部切换器集中显示一次(方案),
|
||||
// 阶段徽章随切换器;本栏不再重复。
|
||||
import { BrandMark, Icon, pendingCount } from '@ui'
|
||||
import { store, useStore } from '../store'
|
||||
import { projectStage } from '../projectmeta'
|
||||
|
||||
export function TopBar() {
|
||||
const project = useStore(s => s.projects.find(p => p.id === s.currentProjectId))
|
||||
const pending = useStore(s => pendingCount(s.cards) + pendingCount(s.projectCards))
|
||||
const stage = projectStage(project?.settings)
|
||||
return (
|
||||
<header className="topbar">
|
||||
<BrandMark size={22} />
|
||||
<span className="topbar-name">Agent-System</span>
|
||||
{project && <span className="topbar-project" title="当前项目">{project.name}</span>}
|
||||
{stage && <span className="topbar-stage" title="当前阶段">{stage}</span>}
|
||||
<span className="topbar-spacer" />
|
||||
<button className="topbar-bell" title={pending > 0 ? `待处理 ${pending},打开收件箱` : '收件箱'}
|
||||
<button className="topbar-bell" title={pending > 0 ? `待处理 ${pending},打开工作入口` : '工作入口'}
|
||||
onClick={() => store.focusPending()}>
|
||||
<Icon name="inbox" size={16} />
|
||||
{pending > 0 && <span className="rail-badge" data-testid="topbar-pending">{pending}</span>}
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
// 右栏:卡片流。全部 / 待处理筛选、收纳、本会话与项目范围。
|
||||
// 文件不占右栏页签——工件走体系工具(⌘)与预览区,一个对象一个入口。
|
||||
// 右栏(第三栏):「工作 / 文件」双入口。
|
||||
// 工作 = 当前任务卡 + 收件箱(待处理事项、后台任务卡、执行记录);
|
||||
// 文件 = 文件列表(搜索/上传)与多标签预览(预览、编辑、下载)——预览不再是独立第四栏。
|
||||
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 { previewTabsKey } from '../previewtabs'
|
||||
import { ArtifactsPanel } from './ArtifactsPanel'
|
||||
import { PreviewPane } from './PreviewPane'
|
||||
|
||||
// 工件解析/打开注入:渲染器纯展示,doc-ref 词汇经此桥接本仓状态(D29)。
|
||||
// 打开语义走 requestPreview 唯一入口(预览区)。
|
||||
// 打开语义走 requestPreview 唯一入口(第三栏「文件」入口)。
|
||||
export const docBridge: DocBridge = {
|
||||
resolveDoc: id => store.getState().artifacts.find(a => a.id === id),
|
||||
onOpenDoc: id => {
|
||||
@@ -47,6 +51,7 @@ function ProjectInbox({ cards, filter }: { cards: ActivityCard[]; filter: InboxF
|
||||
}
|
||||
|
||||
export function Workbench() {
|
||||
const rightTab = useStore(s => s.ui.rightTab)
|
||||
const cards = useStore(s => s.cards)
|
||||
const plan = useStore(s => s.plan)
|
||||
const todos = useStore(s => s.todos)
|
||||
@@ -55,24 +60,57 @@ export function Workbench() {
|
||||
const filter = useStore(s => s.ui.inboxFilter)
|
||||
const visible = inboxScope === 'project' ? projectCards : cards
|
||||
const pending = pendingCount(visible)
|
||||
// 离开文件入口时收回全屏展开,避免出现「工作」铺满主区的怪态。
|
||||
const switchTab = (tab: 'work' | 'files') =>
|
||||
store.setUI({ rightTab: tab, previewExpanded: tab === 'files' ? store.getState().ui.previewExpanded : false })
|
||||
return (
|
||||
<>
|
||||
<div className="tabs">
|
||||
<button className={`tab ${filter === 'all' ? 'active' : ''}`} onClick={() => store.setUI({ inboxFilter: 'all' })}
|
||||
title="全部卡片">全部</button>
|
||||
<button className={`tab ${filter === 'todo' ? 'active' : ''}`} onClick={() => store.setUI({ inboxFilter: 'todo' })}
|
||||
title="只看待处理">待处理{pending > 0 ? ` ${pending}` : ''}</button>
|
||||
<button className={`tab ${rightTab === 'work' ? 'active' : ''}`} onClick={() => switchTab('work')}
|
||||
title="待处理事项、后台任务与执行记录">工作{pending > 0 ? ` ${pending}` : ''}</button>
|
||||
<button className={`tab ${rightTab === 'files' ? 'active' : ''}`} onClick={() => switchTab('files')}
|
||||
title="文件搜索、预览、编辑与下载">文件</button>
|
||||
<span className="spacer" style={{ flex: 1 }} />
|
||||
<button className="tab tab-action" title="收纳已完成卡(done→关闭;已确认卡留痕不动)" onClick={() => store.tidyCards()}><Icon name="check" size={13} /> 收纳</button>
|
||||
<button className="tab" title="收起收件箱" onClick={store.toggleRight}><Icon name="chevron-right" size={14} /></button>
|
||||
{rightTab === 'work' && (
|
||||
<button className="tab tab-action" title="收纳已完成卡(done→关闭;已确认卡留痕不动)" onClick={() => store.tidyCards()}><Icon name="check" size={13} /> 收纳</button>
|
||||
)}
|
||||
<button className="tab" title="收起右栏" onClick={store.toggleRight}><Icon name="chevron-right" size={14} /></button>
|
||||
</div>
|
||||
{rightTab === 'files'
|
||||
? <FilesTab />
|
||||
: <WorkTab plan={plan} todos={todos} filter={filter} inboxScope={inboxScope} projectCards={projectCards} cards={cards} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** 文件入口:有预览标签且处于预览子视图时展示预览;否则展示文件列表(搜索/上传)。 */
|
||||
function FilesTab() {
|
||||
const previewOpen = useStore(s => (s.previewTabs[previewTabsKey(s.currentConvId)]?.length ?? 0) > 0)
|
||||
const filesView = useStore(s => s.ui.filesView)
|
||||
if (previewOpen && filesView === 'preview') return <PreviewPane />
|
||||
return <div className="tab-body"><ArtifactsPanel /></div>
|
||||
}
|
||||
|
||||
function WorkTab({ plan, todos, filter, inboxScope, projectCards, cards }: {
|
||||
plan: Parameters<typeof CurrentTaskCard>[0]['plan']
|
||||
todos: Parameters<typeof CurrentTaskCard>[0]['todos']
|
||||
filter: InboxFilter
|
||||
inboxScope: 'conv' | 'project'
|
||||
projectCards: ActivityCard[]
|
||||
cards: ActivityCard[]
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div className="tab-body" data-scroll-sync="inbox">
|
||||
<CurrentTaskCard plan={plan} todos={todos} />
|
||||
<div className="inbox-scope" role="tablist" aria-label="收件箱范围">
|
||||
<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')}>
|
||||
项目{pendingCount(projectCards) > 0 ? ` ${pendingCount(projectCards)}` : ''}
|
||||
</button>
|
||||
<span style={{ flex: 1 }} />
|
||||
<button className={filter === 'todo' ? 'active' : ''} title="只看待处理" onClick={() => store.setUI({ inboxFilter: 'todo' })}>待处理</button>
|
||||
<button className={filter === 'all' ? 'active' : ''} title="全部卡片" onClick={() => store.setUI({ inboxFilter: 'all' })}>全部</button>
|
||||
</div>
|
||||
{inboxScope === 'project'
|
||||
? <ProjectInbox cards={projectCards} filter={filter} />
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
// 工作区弹层(提案 T4/D25):深读经 OverlayHost 单层呈现。
|
||||
// 服务端声明的工作区(领域插件槽位)对所有用户可见;插件管理面板仍只在系统管理区。
|
||||
// 文件浏览已并入第三栏「文件」入口,本弹层只承载代理与领域工作区。
|
||||
import { store, useStore } from '../store'
|
||||
import type { Run } from '../contracts'
|
||||
import { OverlayHost, type DocBridge } from '@ui'
|
||||
import { docBridge } from './Workbench'
|
||||
import { AgentRow } from './Workbench'
|
||||
import { ArtifactsPanel } from './ArtifactsPanel'
|
||||
import { buildWorkspaceEntries, type WorkspaceEntry } from '../workspaces'
|
||||
import { RdmAuditWorkspace } from './RdmAuditWorkspace'
|
||||
|
||||
/** 体系工具(顺序即 ⌘ 序号)。服务端工作区全员可见;插件管理面板只在系统管理区。 */
|
||||
export function workspaceEntries(): WorkspaceEntry[] {
|
||||
@@ -15,7 +16,6 @@ export function workspaceEntries(): WorkspaceEntry[] {
|
||||
|
||||
export function Workspaces() {
|
||||
const active = useStore(s => s.ui.activeWorkspace)
|
||||
const artifacts = useStore(s => s.artifacts)
|
||||
const runs = useStore(s => s.runs)
|
||||
const pluginPanels = useStore(s => s.pluginPanels)
|
||||
const serverWorkspaces = useStore(s => s.serverWorkspaces)
|
||||
@@ -23,17 +23,17 @@ export function Workspaces() {
|
||||
const entry = workspaceEntries().find(e => e.key === active)
|
||||
const close = () => store.closeWorkspace()
|
||||
|
||||
if (active === 'artifacts') {
|
||||
return <OverlayHost title="工件" icon="📦" shortcut={entry?.shortcut ?? '⌘1'} sayHint="打开工件" badge={artifacts.length || undefined} onClose={close}>
|
||||
<ArtifactsPanel />
|
||||
</OverlayHost>
|
||||
}
|
||||
if (active === 'agents') {
|
||||
const convRuns = runs.filter(r => r.kind !== 'tool')
|
||||
return <OverlayHost title="代理" icon="🤖" shortcut={entry?.shortcut ?? '⌘2'} sayHint="打开代理" onClose={close}>
|
||||
<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}
|
||||
|
||||
+53
-1
@@ -60,6 +60,12 @@ export interface Usage {
|
||||
promptTokens: number; completionTokens: number; totalTokens: number;
|
||||
durationMs: number; toolCalls: number;
|
||||
model?: string;
|
||||
// N3 计费拆分(additive;旧记录缺省为 0):
|
||||
// Last* 是最近一次请求的用量,Cache* 是缓存命中累计。
|
||||
lastPromptTokens?: number;
|
||||
lastCompletionTokens?: number;
|
||||
cacheReadTokens?: number;
|
||||
cacheWriteTokens?: number;
|
||||
}
|
||||
|
||||
export interface Run {
|
||||
@@ -93,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;
|
||||
@@ -179,6 +186,9 @@ export type UINodeType =
|
||||
|
||||
export interface UINode {
|
||||
type: UINodeType;
|
||||
/** Surface 层节点级标注(Generative UI U2):整页唯一稳定 id 与事实来源标识,旧路径不填。 */
|
||||
id?: string;
|
||||
source?: string[];
|
||||
title?: string;
|
||||
items?: string[];
|
||||
kv?: [string, string][];
|
||||
@@ -346,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;
|
||||
}
|
||||
|
||||
@@ -25,3 +25,23 @@ export function closePreviewTab(
|
||||
const neighbor = next[Math.min(i, next.length - 1)] ?? null
|
||||
return { tabs: next, activeId: neighbor?.id ?? null }
|
||||
}
|
||||
|
||||
/** 预览标签的会话键。空态(未选会话)统一落在 '_':写入(requestPreview)与
|
||||
* 渲染(previewOpen/PreviewPane)共用同一口径,空态点开工件才能出现预览区。 */
|
||||
export function previewTabsKey(currentConvId: string | null | undefined): string {
|
||||
return currentConvId ?? '_'
|
||||
}
|
||||
|
||||
/** 进入会话时把空态打开的孤儿标签('_' 键)并入会话键下;按 id 去重,
|
||||
* 会话侧顺序在前,'_' 键清空。无孤儿或目标即 '_' 时原样返回。 */
|
||||
export function mergeOrphanPreviewTabs(
|
||||
tabsByConv: Record<string, PreviewTab[]>,
|
||||
convId: string,
|
||||
): Record<string, PreviewTab[]> {
|
||||
const orphan = tabsByConv['_'] ?? []
|
||||
if (orphan.length === 0 || convId === '_') return tabsByConv
|
||||
const own = tabsByConv[convId] ?? []
|
||||
const ids = new Set(own.map(t => t.id))
|
||||
const merged = [...own, ...orphan.filter(t => !ids.has(t.id))]
|
||||
return { ...tabsByConv, [convId]: merged, '_': [] }
|
||||
}
|
||||
|
||||
+164
-29
@@ -3,7 +3,7 @@
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import { api, connectEvents } from './api'
|
||||
import { applyTextDelta, applyThinkingDelta, applyToolCall, applyToolCallDelta, applyToolResult, applyToolOutput, applyArtifactCreated, applyArtifactUpdated, applyCheck, applyFinalizing, type LiveItem } from './live'
|
||||
import { closePreviewTab, openPreviewTab, type PreviewTab } from './previewtabs'
|
||||
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'
|
||||
@@ -16,8 +16,19 @@ 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,
|
||||
SurfaceSnapshot,
|
||||
TodoList
|
||||
} from './contracts'
|
||||
|
||||
export interface ContextObs {
|
||||
@@ -43,7 +54,10 @@ export interface UIState {
|
||||
leftCollapsed: boolean
|
||||
rightWidth: number
|
||||
rightCollapsed: boolean
|
||||
rightTab: string
|
||||
/** 第三栏双入口:工作(当前任务/收件箱)/ 文件(搜索、预览、编辑、下载)。 */
|
||||
rightTab: 'work' | 'files'
|
||||
/** 文件入口子视图:列表 / 预览(预览标签存在时才有意义)。 */
|
||||
filesView: 'list' | 'preview'
|
||||
/** 收件箱筛选:全部 / 待处理。铃铛打开时切到待处理。 */
|
||||
inboxFilter: 'all' | 'todo'
|
||||
theme: ThemeChoice
|
||||
@@ -83,6 +97,8 @@ interface AppState {
|
||||
plan: PlanSnapshot | null
|
||||
todos: TodoList | null
|
||||
artifacts: Artifact[]
|
||||
/** generative-ui:会话内已保存页面(surface.updated 投影;按 surfaceId 存最新修订)。 */
|
||||
surfaces: SurfaceSnapshot[]
|
||||
cards: ActivityCard[]
|
||||
projectCards: ActivityCard[]
|
||||
inboxScope: 'conv' | 'project'
|
||||
@@ -106,9 +122,10 @@ interface AppState {
|
||||
const DEFAULT_UI: UIState = {
|
||||
leftWidth: 260,
|
||||
leftCollapsed: false,
|
||||
rightWidth: 340,
|
||||
rightWidth: 360,
|
||||
rightCollapsed: false,
|
||||
rightTab: 'inbox',
|
||||
rightTab: 'work',
|
||||
filesView: 'list',
|
||||
inboxFilter: 'all',
|
||||
activeWorkspace: null,
|
||||
theme: 'dark',
|
||||
@@ -134,6 +151,9 @@ function loadUI(): UIState {
|
||||
if (!isThemeChoice(ui.theme)) ui.theme = 'dark'
|
||||
if (!isFontSizeChoice(ui.fontSize)) ui.fontSize = 14
|
||||
if (ui.inboxFilter !== 'all' && ui.inboxFilter !== 'todo') ui.inboxFilter = 'all'
|
||||
// 旧本地偏好里存的是 'inbox'——第三栏双入口上线时归一到 'work'。
|
||||
if (ui.rightTab !== 'work' && ui.rightTab !== 'files') ui.rightTab = 'work'
|
||||
if (ui.filesView !== 'list' && ui.filesView !== 'preview') ui.filesView = 'list'
|
||||
// 预览标签不进本地偏好。展开态若随刷新残留,会在没有标签时藏起对话。
|
||||
ui.previewExpanded = false
|
||||
return ui
|
||||
@@ -142,11 +162,13 @@ 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: [], cards: [], projectCards: [], inboxScope: 'conv', runtimeMock: false, profiles: [], pluginPanels: [],
|
||||
serverWorkspaces: [],
|
||||
ui: loadUI(), connected: false, posting: false, error: null, toast: null,
|
||||
outbox: [], outboxDraining: false,
|
||||
@@ -160,19 +182,14 @@ function set(patch: Partial<AppState>) {
|
||||
listeners.forEach(l => l())
|
||||
}
|
||||
|
||||
// 预览区打开前右栏是否收起。null = 当前没有因为预览而改过右栏。
|
||||
let previewRightWasCollapsed: boolean | null = null
|
||||
|
||||
function syncPreviewRail(hasTabs: boolean) {
|
||||
// 第三栏「文件」入口联动(方案:预览并入第三栏):开第一张标签自动切到文件入口的
|
||||
// 预览子视图并展开右栏;关掉最后一张回到文件列表。不再收起右栏——预览与工作同居第三栏。
|
||||
function syncFilesEntry(hasTabs: boolean) {
|
||||
if (hasTabs) {
|
||||
if (previewRightWasCollapsed === null) previewRightWasCollapsed = state.ui.rightCollapsed
|
||||
if (!state.ui.rightCollapsed) setUI({ rightCollapsed: true })
|
||||
setUI({ rightTab: 'files', rightCollapsed: false, filesView: 'preview' })
|
||||
return
|
||||
}
|
||||
if (previewRightWasCollapsed === null) return
|
||||
const restore = previewRightWasCollapsed
|
||||
previewRightWasCollapsed = null
|
||||
setUI({ rightCollapsed: restore })
|
||||
if (state.ui.filesView === 'preview') setUI({ filesView: 'list' })
|
||||
}
|
||||
|
||||
function setUI(patch: Partial<UIState>) {
|
||||
@@ -184,6 +201,17 @@ function setUI(patch: Partial<UIState>) {
|
||||
listeners.forEach(l => l())
|
||||
}
|
||||
|
||||
// 会话恢复(空态点工件无反应的高频前置):currentConvId 不持久化时刷新必回空态,
|
||||
// 空态下 requestPreview 的标签挂在 '_' 键、渲染判空键——点击只剩弹层关闭,没有任何反馈。
|
||||
const CONV_KEY = 'as.conv'
|
||||
let convRestoreDone = false
|
||||
function persistConvId(id: string | null) {
|
||||
try { id ? localStorage.setItem(CONV_KEY, id) : localStorage.removeItem(CONV_KEY) } catch { /* ignore */ }
|
||||
}
|
||||
function savedConvId(): string | null {
|
||||
try { return localStorage.getItem(CONV_KEY) } catch { return null }
|
||||
}
|
||||
|
||||
let systemQuery: MediaQueryList | null = null
|
||||
let systemHandler: (() => void) | null = null
|
||||
|
||||
@@ -252,7 +280,18 @@ export const store = {
|
||||
|
||||
// 跨项目任务列表(方案 A 侧栏数据源):归档视图切换时带 includeArchived 重取。
|
||||
async refreshTasks(includeArchived = state.ui.showArchived) {
|
||||
try { set({ tasks: await api.listTasks(includeArchived) }) } catch { /* 列表竞态,静默 */ }
|
||||
try {
|
||||
const tasks = await api.listTasks(includeArchived)
|
||||
set({ tasks })
|
||||
// 首次加载恢复上次会话(仅一次;此后用户主动切换/新建不再打扰)。
|
||||
if (!convRestoreDone) {
|
||||
convRestoreDone = true
|
||||
const saved = savedConvId()
|
||||
if (saved && !state.currentConvId && tasks.some(t => t.id === saved && !t.archived)) {
|
||||
store.openConversation(saved)
|
||||
}
|
||||
}
|
||||
} catch { /* 列表竞态,静默 */ }
|
||||
},
|
||||
|
||||
async refreshMe() {
|
||||
@@ -335,8 +374,34 @@ 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 })
|
||||
syncPreviewRail((state.previewTabs[convId] ?? []).length > 0)
|
||||
set({ currentConvId: convId, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null, plan: null, todos: null, cards: [], surfaces: [], inboxScope: 'conv', outbox: [], outboxDraining: false, posting: false })
|
||||
persistConvId(convId)
|
||||
// 空态时打开的预览标签('_' 键)随会话进入一起迁移,不留在不可见的孤儿键下。
|
||||
if ((state.previewTabs['_'] ?? []).length > 0) {
|
||||
const active = state.previewActive[convId] ?? state.previewActive['_'] ?? null
|
||||
set({
|
||||
previewTabs: mergeOrphanPreviewTabs(state.previewTabs, convId),
|
||||
previewActive: { ...state.previewActive, [convId]: active },
|
||||
})
|
||||
}
|
||||
// U4/R5:已保存页面从权威快照重载(刷新/断线恢复;事件流只做增量投影)。
|
||||
api.surfaceList(convId).then(list => {
|
||||
if (state.currentConvId !== convId) return
|
||||
set({ surfaces: list ?? [] })
|
||||
// 刷新/重进恢复:有页面且尚无页面标签时补开最新一张的标签(不强切
|
||||
// 右栏——只在实时事件到达时切换,避免每次进会话劫持视图)。
|
||||
const conv = previewTabsKey(state.currentConvId)
|
||||
const tabs = state.previewTabs[conv] ?? []
|
||||
const hasSurfaceTab = tabs.some(t => t.id.startsWith('surface:'))
|
||||
if (!hasSurfaceTab && (list?.length ?? 0) > 0) {
|
||||
const latest = list[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 },
|
||||
})
|
||||
}
|
||||
}).catch(() => {})
|
||||
const resumeSeq = await store.reconcile()
|
||||
store.connectLive(resumeSeq ?? state.lastSeq)
|
||||
},
|
||||
@@ -458,6 +523,37 @@ export const store = {
|
||||
set({ todos: p.list })
|
||||
break
|
||||
}
|
||||
case 'surface.updated': {
|
||||
// KTD8:单一状态所有者——服务端快照为权威,本地按 revision 守卫:
|
||||
// 旧修订/重复事件不覆盖新修订;切会话由 selectConversation 清空。
|
||||
const p = e.payload as SurfaceSnapshot
|
||||
if (!p?.identity?.surfaceId) break
|
||||
const id = String(p.identity.surfaceId)
|
||||
const surfaces = state.surfaces.filter(x => String(x.identity.surfaceId) !== id)
|
||||
const existing = state.surfaces.find(x => String(x.identity.surfaceId) === 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 }
|
||||
@@ -787,12 +883,14 @@ export const store = {
|
||||
const conv = state.cards.filter(c => c.status === 'action_required').length
|
||||
const proj = state.projectCards.filter(c => c.status === 'action_required').length
|
||||
if (conv === 0 && proj > 0) store.setInboxScope('project')
|
||||
setUI({ rightCollapsed: false, rightTab: 'inbox', inboxFilter: 'todo' })
|
||||
setUI({ rightCollapsed: false, rightTab: 'work', inboxFilter: 'todo' })
|
||||
},
|
||||
openInbox(cardId?: string) {
|
||||
setUI({ rightCollapsed: false, rightTab: 'inbox' })
|
||||
setUI({ rightCollapsed: false, rightTab: 'work' })
|
||||
if (cardId) setTimeout(() => flashLocate('.right-panel', 'data-card-id', cardId), 60)
|
||||
},
|
||||
/** 左栏/收起轨的「文件」入口:打开第三栏文件入口(列表或既有预览)。 */
|
||||
openFiles() { setUI({ rightTab: 'files', rightCollapsed: false }) },
|
||||
// 卡→消息互定位另一半(D32):从收件箱卡滚回产生它的对话消息。
|
||||
locateMessage(messageId?: string) {
|
||||
if (!messageId) return
|
||||
@@ -800,32 +898,69 @@ export const store = {
|
||||
},
|
||||
openTracker(runId: string) { setUI({ trackerRunId: runId }) },
|
||||
/**
|
||||
* 工件预览唯一入口:在对话右侧预览区打开或激活标签。
|
||||
* 打开第一张标签时收起右栏,关掉最后一张时恢复,并收回展开态。
|
||||
* 工件预览唯一入口:在第三栏「文件」入口打开或激活标签(多文件多标签)。
|
||||
* 打开第一张自动切到预览子视图;关掉最后一张回到文件列表,并收回展开态。
|
||||
*/
|
||||
requestPreview(a: Artifact) {
|
||||
const conv = state.currentConvId ?? '_'
|
||||
const conv = previewTabsKey(state.currentConvId)
|
||||
const opened = openPreviewTab(state.previewTabs[conv] ?? [], { id: a.id, name: a.name })
|
||||
set({
|
||||
previewTabs: { ...state.previewTabs, [conv]: opened.tabs },
|
||||
previewActive: { ...state.previewActive, [conv]: opened.activeId },
|
||||
})
|
||||
syncPreviewRail(true)
|
||||
syncFilesEntry(true)
|
||||
},
|
||||
openSurface(surfaceId: string) {
|
||||
// generative-ui:页面进预览第三栏(标签 id 带 surface: 前缀区分工件)。
|
||||
const snap = state.surfaces.find(x => String(x.identity.surfaceId) === 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 => String(x.identity.surfaceId) === 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 = state.currentConvId ?? '_'
|
||||
const conv = previewTabsKey(state.currentConvId)
|
||||
set({ previewActive: { ...state.previewActive, [conv]: id } })
|
||||
},
|
||||
closePreview(id: string) {
|
||||
const conv = state.currentConvId ?? '_'
|
||||
const conv = previewTabsKey(state.currentConvId)
|
||||
const next = closePreviewTab(state.previewTabs[conv] ?? [], state.previewActive[conv] ?? null, id)
|
||||
set({
|
||||
previewTabs: { ...state.previewTabs, [conv]: next.tabs },
|
||||
previewActive: { ...state.previewActive, [conv]: next.activeId },
|
||||
})
|
||||
if (next.tabs.length === 0) setUI({ previewExpanded: false })
|
||||
syncPreviewRail(next.tabs.length > 0)
|
||||
syncFilesEntry(next.tabs.length > 0)
|
||||
},
|
||||
openSettings(open: boolean) { setUI({ settingsOpen: open }) },
|
||||
openProjectSettings(id: string | null) { setUI({ projectSettingsId: id }) },
|
||||
|
||||
+75
-11
@@ -24,11 +24,6 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
|
||||
border: 1px solid var(--border-sidebar);
|
||||
}
|
||||
.topbar-name { font-weight: 650; letter-spacing: .01em; }
|
||||
.topbar-project { color: var(--text-sidebar-dim); font-size: var(--fs-ui-sm); max-width: 240px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.topbar-stage {
|
||||
font-size: var(--fs-ui-xs); padding: 2px 8px; border-radius: var(--radius-full);
|
||||
border: 1px solid var(--border-sidebar); color: var(--text-sidebar);
|
||||
}
|
||||
.topbar-spacer { flex: 1; }
|
||||
.topbar-bell {
|
||||
position: relative; width: 28px; height: 28px; display: inline-flex; align-items: center; justify-content: center;
|
||||
@@ -54,8 +49,14 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
|
||||
.divider:hover::before, .divider.dragging::before { background: var(--border-strong); }
|
||||
.divider::after { content: ''; position: absolute; inset: 0 -3px; } /* 拖拽热区加宽 */
|
||||
.main { flex: 1; min-width: 0; display: flex; flex-direction: column; background: var(--bg); border: 1px solid var(--border-soft); border-radius: var(--radius-lg); }
|
||||
/* 作者 display 声明会压过 UA 的 [hidden],此处显式恢复;展开预览靠它隐藏对话列 */
|
||||
.main[hidden] { display: none !important; }
|
||||
/* 第三栏(工作|文件):文件入口承载预览,展开态铺满主区 */
|
||||
.right-panel { background: var(--bg-panel); border: 1px solid var(--border-soft); border-radius: var(--radius-lg); display: flex; flex-direction: column; flex-shrink: 0; min-width: 0; }
|
||||
.right-panel.expanded { flex: 1 1 auto; width: auto; }
|
||||
.right-panel.collapsed { width: 44px !important; }
|
||||
/* 预览嵌进第三栏后去掉独立面板外壳(圆角/描边由右栏提供,纸面仍用 --bg) */
|
||||
.right-panel .preview-pane { flex: 1; min-height: 0; border: none; border-radius: 0; background: var(--bg); }
|
||||
|
||||
/* ---- 通用控件 ---- */
|
||||
.btn {
|
||||
@@ -194,9 +195,7 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
|
||||
.tl-check { color: var(--text-dim); font-size: var(--fs-ui-sm); margin: 4px 0; }
|
||||
.tl-tools .status-error { margin-left: 8px; color: var(--err-text); }
|
||||
|
||||
/* 对话右侧预览区 */
|
||||
.preview-slot { flex-shrink: 0; min-width: 0; display: flex; }
|
||||
.preview-slot.expanded { flex: 1 1 auto; width: auto; }
|
||||
/* 第三栏「文件」入口内的多标签预览(外壳样式见 .right-panel .preview-pane) */
|
||||
.preview-pane { flex: 1; min-width: 0; display: flex; flex-direction: column; background: var(--bg); border: 1px solid var(--border-soft); border-radius: var(--radius-lg); overflow: hidden; }
|
||||
.preview-pane-bar { display: flex; align-items: center; gap: 8px; padding: 6px 10px; border-bottom: 1px solid var(--border-soft); }
|
||||
.preview-pane-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--fs-ui-sm); color: var(--text-dim); }
|
||||
@@ -212,7 +211,9 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
|
||||
font-family: var(--mono); font-size: var(--fs-code); background: var(--bg); color: var(--text);
|
||||
}
|
||||
.docx-wrapper { background: var(--bg-inset) !important; padding: 12px; display: flex; flex-direction: column; align-items: flex-start !important; }
|
||||
.docx-wrapper > section.docx { background: var(--paper) !important; color: var(--paper-ink); box-shadow: var(--shadow-md); }
|
||||
/* 宽栏时纸张居中。auto margin 优先于 align-items;栏比纸窄时剩余空间为负,margin 归零,
|
||||
纸张仍贴左,previewfit 的 transform-origin: top left 不用改。 */
|
||||
.docx-wrapper > section.docx { background: var(--paper) !important; color: var(--paper-ink); box-shadow: var(--shadow-md); margin-inline: auto; }
|
||||
/* 过程段内联组(流式/关闭自动折叠时,按原位渲染的过程容器) */
|
||||
.part-group { margin: 2px 0; }
|
||||
|
||||
@@ -252,6 +253,11 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
|
||||
|
||||
/* 输入区:悬浮卡片式输入壳(16px 大圆角 = DESIGN.md 特批例外) */
|
||||
.input-bar { padding: 8px max(16px, 6%) 16px; background: var(--bg); border-radius: 0 0 var(--radius-lg) var(--radius-lg); }
|
||||
/* 轻量状态提示行(快慢通道):一行以内,弱色;后台任务在回合结束后仍显示 */
|
||||
.input-status { display: flex; align-items: center; gap: 8px; padding: 0 4px 6px; color: var(--text-faint); font-size: var(--fs-ui-sm); }
|
||||
.input-status-note { color: var(--text-faint); }
|
||||
.input-status-link { border: none; background: none; color: var(--accent-text); font-size: var(--fs-ui-sm); cursor: pointer; padding: 0; }
|
||||
.input-status-link:hover { text-decoration: underline; }
|
||||
.outbox-trigger { width: 100%; text-align: left; background: var(--bg-inset); color: var(--text-dim); border: 1px solid var(--border-soft); border-radius: var(--radius-xs); padding: 6px 10px; font-size: var(--fs-ui-sm); cursor: pointer; }
|
||||
.outbox-list { list-style: none; margin: 6px 0 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
|
||||
.outbox-item { display: flex; gap: 6px; align-items: center; background: var(--bg-inset); border-radius: var(--radius-xs); padding: 6px 8px; }
|
||||
@@ -341,8 +347,13 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
|
||||
/* ---- 品牌标(侧栏 / 登录页 / 头像共用的星芒渐变底;辅色为品牌令牌 --brand-2) ---- */
|
||||
.brand-mark { background: linear-gradient(135deg, var(--accent), var(--brand-2)); color: var(--text-on-accent); display: inline-flex; align-items: center; justify-content: center; flex: none; box-shadow: inset 0 1px 0 rgba(255,255,255,.22); }
|
||||
|
||||
/* ---- 左栏:任务中心(方案 A;Zai 复刻重造型) ---- */
|
||||
.tsb-brand { display: flex; align-items: center; gap: 9px; padding: 14px 14px 8px; }
|
||||
/* ---- 左栏:项目导航(方案 A;三栏工作台重排:切换置顶、会话居中、文件沉底) ---- */
|
||||
.tsb-brand { display: flex; align-items: center; gap: 6px; padding: 12px 12px 8px; }
|
||||
.tsb-projrow { flex: 1; min-width: 0; display: flex; align-items: center; gap: 7px; height: 32px; padding: 0 9px; border-radius: var(--radius); border: 1px solid var(--border); background: var(--bg-raised); color: var(--text); cursor: pointer; font-size: var(--fs-ui-caption); font-weight: 600; }
|
||||
.tsb-projrow:hover { border-color: var(--accent-sidebar); }
|
||||
.tsb-projrow-name { min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.tsb-projrow-stage { flex: none; font-size: var(--fs-ui-xs); font-weight: 450; color: var(--text-faint); border: 1px solid var(--border); border-radius: var(--radius-full); padding: 0 6px; }
|
||||
.tsb-projrow .tsb-user-chev { color: var(--text-faint); display: inline-flex; flex: none; }
|
||||
.tsb-logo { width: 24px; height: 24px; border-radius: var(--radius); background: linear-gradient(135deg, var(--accent), var(--brand-2)); color: var(--text-on-accent); font-size: var(--fs-ui-sm); font-weight: 700; display: flex; align-items: center; justify-content: center; flex: none; }
|
||||
.tsb-name { font-weight: 650; font-size: var(--fs-ui-caption); letter-spacing: -0.01em; }
|
||||
.tsb-iconbtn { width: 26px; height: 26px; border-radius: var(--radius-sm); background: none; border: none; color: var(--text-faint); cursor: pointer; font-size: var(--fs-ui-caption); display: inline-flex; align-items: center; justify-content: center; position: relative; }
|
||||
@@ -400,6 +411,8 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
|
||||
.tsb-row-act:hover { background: var(--bg-active); color: var(--text); }
|
||||
|
||||
.tsb-footer { display: flex; align-items: center; gap: 4px; padding: 10px 12px; border-top: 1px solid var(--border-soft); border-radius: 0 0 var(--radius-lg) var(--radius-lg); }
|
||||
/* 底部沉底区(方案:文件、工作区在账户上方) */
|
||||
.tsb-bottom { display: flex; flex-direction: column; gap: 1px; padding: 6px 10px 2px; border-top: 1px solid var(--border-soft); }
|
||||
.tsb-user { flex: 1; display: flex; align-items: center; gap: 9px; min-width: 0; height: 40px; padding: 0 7px; border: none; border-radius: var(--radius); background: none; cursor: pointer; text-align: left; transition: background .12s; }
|
||||
.tsb-user:hover { background: var(--bg-hover); }
|
||||
.tsb-avatar { width: 27px; height: 27px; border-radius: 50%; background: linear-gradient(135deg, var(--accent), var(--brand-2)); color: var(--text-on-accent); font-size: var(--fs-ui-sm); font-weight: 600; display: flex; align-items: center; justify-content: center; flex: none; box-shadow: inset 0 1px 0 rgba(255,255,255,.2); }
|
||||
@@ -564,3 +577,54 @@ 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); }
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { store } from './store'
|
||||
import { previewTabsKey } from './previewtabs'
|
||||
import type { StreamEvent, 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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
+3
-2
@@ -1,5 +1,6 @@
|
||||
// 体系工具排位:服务端声明的工作区对所有用户可见,按 shortcut 数字占位;
|
||||
// 内置「工件」「代理」补进空位。插件管理面板不进这条清单。
|
||||
// 内置「代理」补进空位。文件浏览在第三栏「文件」入口(方案:入口收敛),不再占体系工具位。
|
||||
// 插件管理面板不进这条清单。
|
||||
|
||||
export interface WorkspaceEntry {
|
||||
key: string
|
||||
@@ -16,8 +17,8 @@ export interface WorkspaceSource {
|
||||
}
|
||||
|
||||
const BUILTINS: Omit<WorkspaceEntry, 'shortcut'>[] = [
|
||||
{ key: 'artifacts', title: '工件', icon: '📦' },
|
||||
{ key: 'agents', title: '代理', icon: '🤖' },
|
||||
{ key: 'rdm-audit', title: '追溯审计', icon: '🔍' },
|
||||
]
|
||||
|
||||
function shortcutNum(raw?: string): number | null {
|
||||
|
||||
@@ -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,30 @@
|
||||
// 空态点工件无反应(2026-10-10 实测复现):requestPreview 写 '_' 键、渲染判
|
||||
// currentConvId 非空——键口径不一致导致空态点击只剩弹层关闭。这里测纯函数口径。
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mergeOrphanPreviewTabs, previewTabsKey } from '../src/previewtabs'
|
||||
|
||||
describe('previewTabsKey', () => {
|
||||
it('falls back to the empty-state key when no conversation is selected', () => {
|
||||
expect(previewTabsKey(null)).toBe('_')
|
||||
expect(previewTabsKey(undefined)).toBe('_')
|
||||
expect(previewTabsKey('cnv_1')).toBe('cnv_1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('mergeOrphanPreviewTabs', () => {
|
||||
it('merges empty-state tabs into the conversation key and clears the orphan key', () => {
|
||||
const got = mergeOrphanPreviewTabs({
|
||||
_: [{ id: 'a', name: 'A.docx' }, { id: 'b', name: 'B.md' }],
|
||||
cnv_1: [{ id: 'b', name: 'B.md' }],
|
||||
}, 'cnv_1')
|
||||
expect(got._).toEqual([])
|
||||
expect(got.cnv_1.map(t => t.id)).toEqual(['b', 'a'])
|
||||
})
|
||||
|
||||
it('returns the input untouched when there is no orphan or the target is the orphan key', () => {
|
||||
const none = { cnv_1: [{ id: 'a', name: 'A' }] }
|
||||
expect(mergeOrphanPreviewTabs(none, 'cnv_1')).toBe(none)
|
||||
const self = { _: [{ id: 'a', name: 'A' }] }
|
||||
expect(mergeOrphanPreviewTabs(self, '_')).toBe(self)
|
||||
})
|
||||
})
|
||||
+15
-4
@@ -1,17 +1,19 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildWorkspaceEntries } from '../src/workspaces'
|
||||
|
||||
// 三栏工作台(2026-10-11):文件浏览并入第三栏「文件」入口;体系工具内置
|
||||
// 「代理」与「追溯审计」(U4 修复:第二个 builtin 此前未入列,入口不可达)。
|
||||
describe('buildWorkspaceEntries', () => {
|
||||
it('没有领域插件时,工件和代理补在 ⌘1 ⌘2', () => {
|
||||
it('没有领域插件时,代理补 ⌘1、追溯审计补 ⌘2', () => {
|
||||
const entries = buildWorkspaceEntries([])
|
||||
expect(entries.map(e => `${e.shortcut} ${e.title}`)).toEqual(['⌘1 工件', '⌘2 代理'])
|
||||
expect(entries.map(e => `${e.shortcut} ${e.title}`)).toEqual(['⌘1 代理', '⌘2 追溯审计'])
|
||||
})
|
||||
|
||||
it('声明了 shortcut 的工作区占住该位,内置工具让位', () => {
|
||||
const entries = buildWorkspaceEntries([
|
||||
{ id: 'trace', title: '追溯网', shortcut: '1' },
|
||||
])
|
||||
expect(entries.map(e => e.title)).toEqual(['追溯网', '工件', '代理'])
|
||||
expect(entries.map(e => e.title)).toEqual(['追溯网', '代理', '追溯审计'])
|
||||
expect(entries.map(e => e.shortcut)).toEqual(['⌘1', '⌘2', '⌘3'])
|
||||
expect(entries[0].key).toBe('ws:trace')
|
||||
})
|
||||
@@ -20,6 +22,15 @@ describe('buildWorkspaceEntries', () => {
|
||||
const entries = buildWorkspaceEntries([
|
||||
{ id: 'mem', title: '记忆库' },
|
||||
])
|
||||
expect(entries.map(e => e.title)).toEqual(['工件', '代理', '记忆库'])
|
||||
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)
|
||||
// ⌘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('追溯审计')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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