Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
57fabf1a95 | ||
|
|
360d6406e4 | ||
|
|
25e76c5e01 | ||
|
|
41f6ce8760 | ||
|
|
0f860c0743 | ||
|
|
fdedaa1123 | ||
|
|
4ab507b1b6 | ||
|
|
01b853fd0e | ||
|
|
7acfef0a1f | ||
|
|
c22120d7fe | ||
|
|
15d8a4093b | ||
|
|
d2d81e119c | ||
|
|
fb846bed14 | ||
|
|
566fec9f56 | ||
|
|
98751916da | ||
|
|
ed9845f8e4 |
@@ -6,3 +6,6 @@ dist/
|
|||||||
|
|
||||||
# mimosa 扫描钩子的本地缓存(scripts/hooks 写入,不入库)
|
# mimosa 扫描钩子的本地缓存(scripts/hooks 写入,不入库)
|
||||||
.mimosa
|
.mimosa
|
||||||
|
# Playwright 运行工件(e2e/ 用例输出;详工件在 Dev .tmp/e2e/<ts>/frontend/)
|
||||||
|
test-results/
|
||||||
|
playwright-report/
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
// 双入口对拍的浏览器侧说明。CI 门禁在 backend command_pairs_test.go:
|
// 双入口对拍的浏览器侧说明。CI 门禁在 backend command_pairs_test.go:
|
||||||
// 新内置命令未写入 testdata/command-pairs.json 时 go test 失败。
|
// 新内置命令未写入 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'
|
import { test, expect } from '@playwright/test'
|
||||||
|
|
||||||
test('command menu and chat share builtin.help', async ({ page }) => {
|
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 page.goto(process.env.PLAYWRIGHT_BASE_URL!)
|
||||||
await expect(page.getByText('帮助')).toBeVisible()
|
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"
|
"xlsx": "^0.18.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.50.1",
|
||||||
"@types/node": "^22.20.4",
|
"@types/node": "^22.20.4",
|
||||||
"@types/react": "^19.2.0",
|
"@types/react": "^19.2.0",
|
||||||
"@types/react-dom": "^19.2.0",
|
"@types/react-dom": "^19.2.0",
|
||||||
@@ -1212,6 +1213,22 @@
|
|||||||
"win32"
|
"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": {
|
"node_modules/@rolldown/pluginutils": {
|
||||||
"version": "1.0.0-beta.27",
|
"version": "1.0.0-beta.27",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
||||||
@@ -2749,6 +2766,35 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"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": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.28",
|
"version": "8.5.28",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
|
||||||
|
|||||||
+3
-1
@@ -7,7 +7,8 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"e2e": "playwright test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"docx-preview": "^0.4.1",
|
"docx-preview": "^0.4.1",
|
||||||
@@ -21,6 +22,7 @@
|
|||||||
"xlsx": "^0.18.5"
|
"xlsx": "^0.18.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.50.1",
|
||||||
"@types/node": "^22.20.4",
|
"@types/node": "^22.20.4",
|
||||||
"@types/react": "^19.2.0",
|
"@types/react": "^19.2.0",
|
||||||
"@types/react-dom": "^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',
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -68,6 +68,10 @@ export function App() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (authState !== 'ok') return
|
if (authState !== 'ok') return
|
||||||
|
// me 回填进 store:管理入口(me?.isAdmin)与侧栏身份都消费它;此前只有
|
||||||
|
// 改密门 onDone 调 refreshMe,登录与刷新回态后 me 恒为 null,管理员菜单
|
||||||
|
// 在任何一次 F5 后永久消失(2026-10-12「用户管理页面不可用」根因)。
|
||||||
|
store.refreshMe()
|
||||||
store.refreshProjects()
|
store.refreshProjects()
|
||||||
store.refreshProfiles()
|
store.refreshProfiles()
|
||||||
store.refreshPlugins()
|
store.refreshPlugins()
|
||||||
|
|||||||
+3
-1
@@ -1,7 +1,7 @@
|
|||||||
// API 客户端(API-first:UI 只是第一个客户端)与 SSE 流客户端(游标重放 + 终态对账)。
|
// API 客户端(API-first:UI 只是第一个客户端)与 SSE 流客户端(游标重放 + 终态对账)。
|
||||||
// 认证:服务端开启 API key 时经 auth.fetch/withKey 注入凭证(嵌入式档无认证则透传)。
|
// 认证:服务端开启 API key 时经 auth.fetch/withKey 注入凭证(嵌入式档无认证则透传)。
|
||||||
import { auth } from './auth'
|
import { auth } from './auth'
|
||||||
import type {
|
import type { AdminZoneSection,
|
||||||
Project, Conversation, Message, Run, Artifact, HITLCard, HITLResponse,
|
Project, Conversation, Message, Run, Artifact, HITLCard, HITLResponse,
|
||||||
AgentProfile, SettingDesc, StreamEvent, StreamEventType, PlanSnapshot, TodoList, UploadInfo, PromptModule,
|
AgentProfile, SettingDesc, StreamEvent, StreamEventType, PlanSnapshot, TodoList, UploadInfo, PromptModule,
|
||||||
ActivityCard,
|
ActivityCard,
|
||||||
@@ -33,6 +33,7 @@ export const api = {
|
|||||||
surfaceList: (convId: string) => auth.fetch(`${BASE}/api/conversations/${convId}/surfaces`).then(r => json<any[]>(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> }) =>
|
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)),
|
auth.fetch(`${BASE}/api/surfaces/${id}/actions`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }).then(r => json<any>(r)),
|
||||||
|
projectSurfaces: (projectId: string) => auth.fetch(`${BASE}/api/projects/${projectId}/surfaces`).then(r => json<any[]>(r)),
|
||||||
surfaceGet: (id: string) => auth.fetch(`${BASE}/api/surfaces/${id}`).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)),
|
me: () => auth.fetch(`${BASE}/api/me`).then(r => json<{ owner: string; name: string; auth: boolean; authVia?: string; isAdmin?: boolean; mustChangePassword?: boolean }>(r)),
|
||||||
// ---- 认证与会话(管理员发放制,R2)----
|
// ---- 认证与会话(管理员发放制,R2)----
|
||||||
@@ -112,6 +113,7 @@ export const api = {
|
|||||||
|
|
||||||
listTools: () => auth.fetch(`${BASE}/api/tools`).then(r => r.json()),
|
listTools: () => auth.fetch(`${BASE}/api/tools`).then(r => r.json()),
|
||||||
listPlugins: () => auth.fetch(`${BASE}/api/plugins`).then(r => r.json()),
|
listPlugins: () => auth.fetch(`${BASE}/api/plugins`).then(r => r.json()),
|
||||||
|
adminZoneSections: () => auth.fetch(`${BASE}/api/admin-zone/sections`).then(r => r.json()) as Promise<AdminZoneSection[]>,
|
||||||
reloadPlugins: () => auth.fetch(`${BASE}/api/plugins/reload`, { method: 'POST' }).then(r => r.json()),
|
reloadPlugins: () => auth.fetch(`${BASE}/api/plugins/reload`, { method: 'POST' }).then(r => r.json()),
|
||||||
listCommands: () => auth.fetch(`${BASE}/api/commands`).then(r => r.json()) as Promise<{ name?: string; id?: string; title?: string; description?: string; danger?: string }[]>,
|
listCommands: () => auth.fetch(`${BASE}/api/commands`).then(r => r.json()) as Promise<{ name?: string; id?: string; title?: string; description?: string; danger?: string }[]>,
|
||||||
setSessionNote: (id: string, sessionNote: string) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionNote }) }).then(r => { if (!r.ok) throw new Error(`${r.status}`) }),
|
setSessionNote: (id: string, sessionNote: string) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionNote }) }).then(r => { if (!r.ok) throw new Error(`${r.status}`) }),
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
// 系统管理(重设计方案 阶段②):管理员专属的全屏独立视图(带返回,非弹窗)。
|
// 系统管理(重设计方案 阶段②):全屏独立视图(带返回,非弹窗)。
|
||||||
// 用户决策:系统管理 = 全屏独立视图;普通用户界面不暴露任何系统能力。
|
// 用户决策:系统管理 = 全屏独立视图;普通用户界面不暴露任何系统能力。
|
||||||
// 分区注册表:新增管理页只改 SECTIONS,不改 AdminZone 本体(声明式注册)。
|
// 分区注册表:新增管理页只改 SECTIONS,不改 AdminZone 本体(声明式注册)。
|
||||||
|
// WP3-I3/KTD4:编译插件可经 Registry.AddAdminZone 声明展示分区(服务端
|
||||||
|
// /api/admin-zone/sections 下发);声明分区对全部认证主体开放,区内数据
|
||||||
|
// 能力由分区组件自身的命令面 fail-closed——前端只消费、不合并角色。
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { store, useStore } from '../store'
|
import { store, useStore } from '../store'
|
||||||
import { Icon, type IconName } from '@ui'
|
import { EmptyState, Icon, type IconName } from '@ui'
|
||||||
import { UsersPanel, ProfilesSection, PluginsSection, PluginWorkspacesPanel } from './adminSections'
|
import { UsersPanel, ProfilesSection, PluginsSection, PluginWorkspacesPanel } from './adminSections'
|
||||||
|
import { RdmAuditWorkspace } from './RdmAuditWorkspace'
|
||||||
|
import type { AdminZoneSection } from '../contracts'
|
||||||
|
|
||||||
interface AdminSectionDef {
|
interface AdminSectionDef {
|
||||||
key: string
|
key: string
|
||||||
@@ -27,12 +32,47 @@ const SECTIONS: AdminSectionDef[] = [
|
|||||||
{ key: 'workspaces', label: '插件工作区', icon: 'grid', desc: '插件声明面板与服务端声明工作区', render: () => <PluginWorkspacesPanel /> },
|
{ key: 'workspaces', label: '插件工作区', icon: 'grid', desc: '插件声明面板与服务端声明工作区', render: () => <PluginWorkspacesPanel /> },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
/** 声明分区 → 内置分区组件(按声明 ID 映射;AdminZone 不硬编码领域逻辑)。 */
|
||||||
|
const DECLARED_COMPONENTS: Record<string, () => React.ReactNode> = {
|
||||||
|
'rdm.audit': () => <RdmAuditWorkspace />,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** icon 走基座 IconName 词表;声明携带未知图标时回退通用图标(不渲染空白)。 */
|
||||||
|
const ICONS: IconName[] = ['plus', 'search', 'panel-left', 'inbox', 'settings', 'sun', 'moon', 'pin', 'archive', 'undo',
|
||||||
|
'chevron-right', 'chevron-down', 'folder', 'message', 'package', 'bot', 'puzzle', 'grid', 'arrow-up', 'paperclip',
|
||||||
|
'more', 'logout', 'key', 'user', 'users', 'shield', 'x', 'check', 'alert', 'send', 'sparkles', 'clock', 'list',
|
||||||
|
'file', 'image', 'download', 'trash', 'eye', 'maximize', 'minimize']
|
||||||
|
function declaredIcon(name?: string): IconName {
|
||||||
|
return (ICONS as string[]).includes(name ?? '') ? (name as IconName) : 'grid'
|
||||||
|
}
|
||||||
|
|
||||||
|
function DeclaredSection({ section }: { section: AdminZoneSection }) {
|
||||||
|
const render = DECLARED_COMPONENTS[section.id]
|
||||||
|
if (!render) {
|
||||||
|
return <EmptyState icon="puzzle">分区「{section.title}」由内置插件声明,但当前前端版本未携带其组件<br />(部署前端与二进制版本不匹配时会出现;升级前端即可)</EmptyState>
|
||||||
|
}
|
||||||
|
return <>{render()}</>
|
||||||
|
}
|
||||||
|
|
||||||
export function AdminZone() {
|
export function AdminZone() {
|
||||||
const me = useStore(s => s.me)
|
const me = useStore(s => s.me)
|
||||||
|
const declared = useStore(s => s.adminSections)
|
||||||
const [sectionKey, setSectionKey] = useState('users')
|
const [sectionKey, setSectionKey] = useState('users')
|
||||||
// 双保险:入口已在菜单层门控(me?.isAdmin),此处再拦一次(纵深防御)。
|
const isAdmin = !!me?.isAdmin
|
||||||
if (!me?.isAdmin) return null
|
// 门控:基座系统分区仅 isAdmin(纵深防御与菜单层一致);声明分区对全部
|
||||||
const section = SECTIONS.find(s => s.key === sectionKey) ?? SECTIONS[0]
|
// 认证主体开放(区内能力由组件命令面判定)。两者皆无时不渲染。
|
||||||
|
if (!isAdmin && declared.length === 0) return null
|
||||||
|
const sections: AdminSectionDef[] = [
|
||||||
|
...(isAdmin ? SECTIONS : []),
|
||||||
|
...declared.map(s => ({
|
||||||
|
key: `decl:${s.id}`, label: s.title, icon: declaredIcon(s.icon),
|
||||||
|
desc: s.desc || '插件声明的管理分区(区内数据能力由插件命令面判定)',
|
||||||
|
render: () => <DeclaredSection section={s} />,
|
||||||
|
})),
|
||||||
|
]
|
||||||
|
const fallback = sections[0]?.key ?? ''
|
||||||
|
const section = sections.find(s => s.key === sectionKey) ?? sections[0]
|
||||||
|
if (!section) return null
|
||||||
return (
|
return (
|
||||||
<div className="az" role="dialog" aria-label="系统管理">
|
<div className="az" role="dialog" aria-label="系统管理">
|
||||||
<div className="az-head">
|
<div className="az-head">
|
||||||
@@ -40,14 +80,14 @@ export function AdminZone() {
|
|||||||
<Icon name="chevron-down" size={14} /> 返回工作台
|
<Icon name="chevron-down" size={14} /> 返回工作台
|
||||||
</button>
|
</button>
|
||||||
<span className="az-title">系统管理</span>
|
<span className="az-title">系统管理</span>
|
||||||
<span className="az-sub">仅管理员可见 · 变更即时生效</span>
|
<span className="az-sub">{isAdmin ? '仅管理员可见' : '按插件声明开放'} · 变更即时生效</span>
|
||||||
<span style={{ flex: 1 }} />
|
<span style={{ flex: 1 }} />
|
||||||
<span className="az-tenant">{me.owner}</span>
|
<span className="az-tenant">{me?.owner ?? ''}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="az-body">
|
<div className="az-body">
|
||||||
<nav className="az-side">
|
<nav className="az-side">
|
||||||
{SECTIONS.map(s => (
|
{sections.map(s => (
|
||||||
<button key={s.key} className={`az-nav-item ${s.key === sectionKey ? 'on' : ''}`} onClick={() => setSectionKey(s.key)}>
|
<button key={s.key} className={`az-nav-item ${s.key === (section?.key ?? fallback) ? 'on' : ''}`} onClick={() => setSectionKey(s.key)}>
|
||||||
<Icon name={s.icon} size={15} />
|
<Icon name={s.icon} size={15} />
|
||||||
<span>{s.label}</span>
|
<span>{s.label}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -144,11 +144,14 @@ function TreeList({ pid, kind }: { pid: string; kind: 'notes' | 'docs' }) {
|
|||||||
const [err, setErr] = useState('')
|
const [err, setErr] = useState('')
|
||||||
const [selected, setSelected] = useState<TreeItem | null>(null)
|
const [selected, setSelected] = useState<TreeItem | null>(null)
|
||||||
const [detail, setDetail] = useState<ObjectDetail | 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)
|
const genRef = useRef(0)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const gen = ++genRef.current
|
const gen = ++genRef.current
|
||||||
setShell(null); setErr(''); setSelected(null); setDetail(null)
|
setShell(null); setErr(''); setSelected(null); setDetail(null); setChildren({})
|
||||||
api.rdmCommand<ListShell<TreeItem>>('rdm.inspect.tree', { projectId: pid, kind, cursor: '' })
|
api.rdmCommand<ListShell<TreeItem>>('rdm.inspect.tree', { projectId: pid, kind, cursor: '' })
|
||||||
.then(s => { if (gen === genRef.current) setShell(s) })
|
.then(s => { if (gen === genRef.current) setShell(s) })
|
||||||
.catch(e => { if (gen === genRef.current) setErr(e.message) })
|
.catch(e => { if (gen === genRef.current) setErr(e.message) })
|
||||||
@@ -162,7 +165,19 @@ function TreeList({ pid, kind }: { pid: string; kind: 'notes' | 'docs' }) {
|
|||||||
.catch(e => { if (gen === genRef.current) setErr(e.message) })
|
.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) => {
|
const selectNode = (item: TreeItem) => {
|
||||||
|
if (item.kind === 'doc') { toggleDoc(item); return }
|
||||||
setSelected(item); setDetail(null)
|
setSelected(item); setDetail(null)
|
||||||
const gen = genRef.current
|
const gen = genRef.current
|
||||||
api.rdmCommand<ObjectDetail>('rdm.inspect.object', { projectId: pid, kind: item.kind, id: item.id })
|
api.rdmCommand<ObjectDetail>('rdm.inspect.object', { projectId: pid, kind: item.kind, id: item.id })
|
||||||
@@ -178,12 +193,28 @@ function TreeList({ pid, kind }: { pid: string; kind: 'notes' | 'docs' }) {
|
|||||||
<div className="rdm-audit-tree">
|
<div className="rdm-audit-tree">
|
||||||
<div className="rdm-audit-tree-list">
|
<div className="rdm-audit-tree-list">
|
||||||
{shell.items.map(item => (
|
{shell.items.map(item => (
|
||||||
<button key={item.id} className={`rdm-audit-tree-node${selected?.id === item.id ? ' sel' : ''}`}
|
<div key={item.id}>
|
||||||
onClick={() => selectNode(item)}>
|
<button className={`rdm-audit-tree-node${selected?.id === item.id ? ' sel' : ''}`}
|
||||||
<span className="rdm-audit-tree-kind">{item.kind}</span>
|
onClick={() => selectNode(item)}>
|
||||||
<span className="rdm-audit-tree-title">{item.title}</span>
|
{item.kind === 'doc' && <span className="rdm-audit-tree-caret">{children[item.id] ? '▾' : '▸'}</span>}
|
||||||
{item.state && <Chip tone={item.state === 'baselined' ? 'ok' : 'plain'}>{item.state}</Chip>}
|
<span className="rdm-audit-tree-kind">{item.kind}</span>
|
||||||
</button>
|
<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.hasMore && <button className="btn v-ghost rdm-audit-more" onClick={loadMore}>加载更多</button>}
|
||||||
{shell.truncated && <div className="rdm-audit-truncated">已截断(深度或数量上限)</div>}
|
{shell.truncated && <div className="rdm-audit-truncated">已截断(深度或数量上限)</div>}
|
||||||
@@ -216,14 +247,21 @@ function TraceTab({ pid }: { pid: string }) {
|
|||||||
.catch(e => { if (gen === genRef.current) setErr(e.message) })
|
.catch(e => { if (gen === genRef.current) setErr(e.message) })
|
||||||
}, [pid, sel])
|
}, [pid, sel])
|
||||||
|
|
||||||
// 未选对象时列出章节供选择
|
// 未选对象时列出章节供选择。docs 树是层级结构:章节挂在各文档下
|
||||||
|
// (parent=<docId>),按文档聚合后合并展示。
|
||||||
const [sections, setSections] = useState<TreeItem[]>([])
|
const [sections, setSections] = useState<TreeItem[]>([])
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (sel) return
|
if (sel) return
|
||||||
let c = false
|
let c = false
|
||||||
api.rdmCommand<ListShell<TreeItem>>('rdm.inspect.tree', { projectId: pid, kind: 'docs' })
|
;(async () => {
|
||||||
.then(s => { if (!c) setSections(s.items.filter(i => i.kind === 'section')) })
|
const top = await api.rdmCommand<ListShell<TreeItem>>('rdm.inspect.tree', { projectId: pid, kind: 'docs' })
|
||||||
.catch(() => {})
|
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 }
|
return () => { c = true }
|
||||||
}, [pid, sel])
|
}, [pid, sel])
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ export function TaskSidebar() {
|
|||||||
const projects = useStore(s => s.projects)
|
const projects = useStore(s => s.projects)
|
||||||
const tasks = useStore(s => s.tasks)
|
const tasks = useStore(s => s.tasks)
|
||||||
const me = useStore(s => s.me)
|
const me = useStore(s => s.me)
|
||||||
|
const adminSections = useStore(s => s.adminSections)
|
||||||
const artifacts = useStore(s => s.artifacts)
|
const artifacts = useStore(s => s.artifacts)
|
||||||
const currentConvId = useStore(s => s.currentConvId)
|
const currentConvId = useStore(s => s.currentConvId)
|
||||||
const currentProjectId = useStore(s => s.currentProjectId)
|
const currentProjectId = useStore(s => s.currentProjectId)
|
||||||
@@ -265,7 +266,7 @@ export function TaskSidebar() {
|
|||||||
<MenuItem icon="sun" onClick={() => { store.setUI({ theme: 'light' }); setMenu(null) }}>浅色主题{theme === 'light' ? ' · 当前' : ''}</MenuItem>
|
<MenuItem icon="sun" onClick={() => { store.setUI({ theme: 'light' }); setMenu(null) }}>浅色主题{theme === 'light' ? ' · 当前' : ''}</MenuItem>
|
||||||
<MenuItem onClick={() => { store.setUI({ theme: 'molv' }); setMenu(null) }}>墨绿主题{theme === 'molv' ? ' · 当前' : ''}</MenuItem>
|
<MenuItem onClick={() => { store.setUI({ theme: 'molv' }); setMenu(null) }}>墨绿主题{theme === 'molv' ? ' · 当前' : ''}</MenuItem>
|
||||||
<MenuItem onClick={() => { store.setUI({ theme: 'system' }); setMenu(null) }}>跟随系统{theme === 'system' ? ' · 当前' : ''}</MenuItem>
|
<MenuItem onClick={() => { store.setUI({ theme: 'system' }); setMenu(null) }}>跟随系统{theme === 'system' ? ' · 当前' : ''}</MenuItem>
|
||||||
{me?.isAdmin && <MenuItem icon="shield" onClick={() => { store.setUI({ adminZoneOpen: true }); setMenu(null) }}>系统管理…</MenuItem>}
|
{(me?.isAdmin || adminSections.length > 0) && <MenuItem icon="shield" onClick={() => { store.setUI({ adminZoneOpen: true }); setMenu(null) }}>系统管理…</MenuItem>}
|
||||||
{me?.auth && <>
|
{me?.auth && <>
|
||||||
<div className="tsb-menu-div" />
|
<div className="tsb-menu-div" />
|
||||||
<MenuItem danger icon="logout" onClick={() => { api.logout().catch(() => undefined); auth.logout() }}>退出登录</MenuItem>
|
<MenuItem danger icon="logout" onClick={() => { api.logout().catch(() => undefined); auth.logout() }}>退出登录</MenuItem>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
import { store, useStore } from '../store'
|
import { store, useStore } from '../store'
|
||||||
import type { Run } from '../contracts'
|
import type { Run } from '../contracts'
|
||||||
import { InboxList, pendingCount, type ActivityCard, type DocBridge, type InboxFilter } from '@ui'
|
import { InboxList, pendingCount, type ActivityCard, type DocBridge, type InboxFilter } from '@ui'
|
||||||
import { Icon } from '@ui'
|
import { Icon, SurfaceView } from '@ui'
|
||||||
import { previewTabsKey } from '../previewtabs'
|
import { previewTabsKey } from '../previewtabs'
|
||||||
import { ArtifactsPanel } from './ArtifactsPanel'
|
import { ArtifactsPanel } from './ArtifactsPanel'
|
||||||
import { PreviewPane } from './PreviewPane'
|
import { PreviewPane } from './PreviewPane'
|
||||||
@@ -115,6 +115,7 @@ function WorkTab({ plan, todos, filter, inboxScope, projectCards, cards }: {
|
|||||||
{inboxScope === 'project'
|
{inboxScope === 'project'
|
||||||
? <ProjectInbox cards={projectCards} filter={filter} />
|
? <ProjectInbox cards={projectCards} filter={filter} />
|
||||||
: <InboxList cards={cards} docBridge={docBridge} callbacks={inboxCallbacks} filter={filter} />}
|
: <InboxList cards={cards} docBridge={docBridge} callbacks={inboxCallbacks} filter={filter} />}
|
||||||
|
<WorkspaceSurfaces />
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
@@ -172,3 +173,33 @@ export function AgentRow({ run, depth }: { run: Run; depth: number }) {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// U6 工作区任务页容器:会话内页面在"工作"入口的常驻紧凑视图——默认显示最新
|
||||||
|
// 一页,固定(pin)后不随新页面抢占;关闭插件后此处自然消失(surfaces 为空),
|
||||||
|
// 收件箱/任务卡等原有内容不受影响(禁用回退完整性)。点击标题去文件预览全屏。
|
||||||
|
function WorkspaceSurfaces() {
|
||||||
|
const surfaces = useStore(s => s.surfaces)
|
||||||
|
const pin = useStore(s => s.ui.workspaceSurfacePin)
|
||||||
|
if (surfaces.length === 0) return null
|
||||||
|
const shown = pin ? surfaces.find(x => String(x.identity.surfaceId) === pin) : surfaces[surfaces.length - 1]
|
||||||
|
if (!shown) return null
|
||||||
|
return (
|
||||||
|
<div className="workspace-surfaces" data-workspace-surfaces>
|
||||||
|
<div className="ws-head">
|
||||||
|
<span className="ws-title">任务页面</span>
|
||||||
|
<span className="ws-meta">{shown.spec?.title || '(未命名)'} · 修订 {shown.revision}</span>
|
||||||
|
<span style={{ flex: 1 }} />
|
||||||
|
<button className="icon-btn" title={pin ? '取消固定(跟随最新)' : '固定此页(新页面不抢占)'}
|
||||||
|
onClick={() => store.setUI({ workspaceSurfacePin: pin ? null : String(shown.identity.surfaceId) })}>
|
||||||
|
{pin ? '📍' : '📌'}
|
||||||
|
</button>
|
||||||
|
<button className="icon-btn" title="在文件预览中打开" onClick={() => store.openSurface(String(shown.identity.surfaceId))}>
|
||||||
|
↗
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="ws-body">
|
||||||
|
<SurfaceView snapshot={shown} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -193,9 +193,18 @@ export function PluginsSection() {
|
|||||||
{plugins.length === 0 && <EmptyState icon="puzzle">暂无已装载插件<br />(插件目录在 设置 → 常规 → 插件目录;示例见 Dev 仓 plugins/example)</EmptyState>}
|
{plugins.length === 0 && <EmptyState icon="puzzle">暂无已装载插件<br />(插件目录在 设置 → 常规 → 插件目录;示例见 Dev 仓 plugins/example)</EmptyState>}
|
||||||
{plugins.map(p => (
|
{plugins.map(p => (
|
||||||
<SettingRow key={p.manifest.id}
|
<SettingRow key={p.manifest.id}
|
||||||
label={`${p.manifest.name} v${p.manifest.version}`}
|
label={p.manifest.name + (p.manifest.version ? ` v${p.manifest.version}` : '')}
|
||||||
desc={[`工具 ${p.manifest.provides.tools?.length ?? 0}`, `面板 ${p.panels?.length ?? 0}`, `意图 ${p.intents?.length ?? 0}`, `工作流 ${p.workflows?.length ?? 0}`].join(' · ')}>
|
desc={[
|
||||||
<span className="effect-badge">api {p.manifest.apiVersion}</span>
|
`工具 ${p.manifest.provides.tools?.length ?? 0}`,
|
||||||
|
`命令 ${p.manifest.provides.commands?.length ?? 0}`,
|
||||||
|
`面板 ${p.panels?.length ?? 0}`,
|
||||||
|
`意图 ${p.intents?.length ?? p.manifest.provides.intents?.length ?? 0}`,
|
||||||
|
`工作流 ${p.workflows?.length ?? 0}`,
|
||||||
|
].join(' · ')}>
|
||||||
|
<span style={{ display: 'inline-flex', gap: 6, alignItems: 'center' }}>
|
||||||
|
{p.manifest.backend?.mode === 'inproc' && <span className="effect-badge">内置</span>}
|
||||||
|
<span className="effect-badge">api {p.manifest.apiVersion}</span>
|
||||||
|
</span>
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -288,6 +288,13 @@ export interface PluginManifest {
|
|||||||
backend?: { mode?: 'inproc' | 'sidecar'; command?: string; args?: string[]; schemaVersion?: number };
|
backend?: { mode?: 'inproc' | 'sidecar'; command?: string; args?: string[]; schemaVersion?: number };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- 管理区声明分区(WP3-I3/KTD4)----
|
||||||
|
/** 编译插件经 Registry.AddAdminZone 声明的管理区展示分区;入口不做前端权限合并,
|
||||||
|
* 分区内数据能力由组件自身的命令面 fail-closed。 */
|
||||||
|
export interface AdminZoneSection {
|
||||||
|
id: string; title: string; icon?: string; desc?: string; order?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PluginPanel {
|
export interface PluginPanel {
|
||||||
id: string; title: string; icon?: string; body: UINode[]; refreshTool?: string;
|
id: string; title: string; icon?: string; body: UINode[]; refreshTool?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-4
@@ -15,7 +15,7 @@ const FONT_SIZE_CHOICES: readonly FontSizeChoice[] = [13, 14, 16, 18]
|
|||||||
export function isFontSizeChoice(value: number): value is FontSizeChoice {
|
export function isFontSizeChoice(value: number): value is FontSizeChoice {
|
||||||
return (FONT_SIZE_CHOICES as readonly number[]).includes(value)
|
return (FONT_SIZE_CHOICES as readonly number[]).includes(value)
|
||||||
}
|
}
|
||||||
import type {
|
import type { AdminZoneSection,
|
||||||
ActivityCard,
|
ActivityCard,
|
||||||
AgentProfile,
|
AgentProfile,
|
||||||
Artifact,
|
Artifact,
|
||||||
@@ -27,6 +27,7 @@ import type {
|
|||||||
Project,
|
Project,
|
||||||
Run,
|
Run,
|
||||||
StreamEvent,
|
StreamEvent,
|
||||||
|
SurfaceMeta,
|
||||||
SurfaceSnapshot,
|
SurfaceSnapshot,
|
||||||
TodoList
|
TodoList
|
||||||
} from './contracts'
|
} from './contracts'
|
||||||
@@ -58,6 +59,8 @@ export interface UIState {
|
|||||||
rightTab: 'work' | 'files'
|
rightTab: 'work' | 'files'
|
||||||
/** 文件入口子视图:列表 / 预览(预览标签存在时才有意义)。 */
|
/** 文件入口子视图:列表 / 预览(预览标签存在时才有意义)。 */
|
||||||
filesView: 'list' | 'preview'
|
filesView: 'list' | 'preview'
|
||||||
|
/** U6:工作区任务页固定(surfaceId;null=跟随最新;固定后新页面不抢占显示)。 */
|
||||||
|
workspaceSurfacePin: string | null
|
||||||
/** 收件箱筛选:全部 / 待处理。铃铛打开时切到待处理。 */
|
/** 收件箱筛选:全部 / 待处理。铃铛打开时切到待处理。 */
|
||||||
inboxFilter: 'all' | 'todo'
|
inboxFilter: 'all' | 'todo'
|
||||||
theme: ThemeChoice
|
theme: ThemeChoice
|
||||||
@@ -99,6 +102,8 @@ interface AppState {
|
|||||||
artifacts: Artifact[]
|
artifacts: Artifact[]
|
||||||
/** generative-ui:会话内已保存页面(surface.updated 投影;按 surfaceId 存最新修订)。 */
|
/** generative-ui:会话内已保存页面(surface.updated 投影;按 surfaceId 存最新修订)。 */
|
||||||
surfaces: SurfaceSnapshot[]
|
surfaces: SurfaceSnapshot[]
|
||||||
|
/** generative-ui U6:项目级跨会话页面聚合(打开项目时权威装载)。 */
|
||||||
|
projectSurfaces: SurfaceMeta[]
|
||||||
cards: ActivityCard[]
|
cards: ActivityCard[]
|
||||||
projectCards: ActivityCard[]
|
projectCards: ActivityCard[]
|
||||||
inboxScope: 'conv' | 'project'
|
inboxScope: 'conv' | 'project'
|
||||||
@@ -107,6 +112,8 @@ interface AppState {
|
|||||||
profiles: AgentProfile[]
|
profiles: AgentProfile[]
|
||||||
pluginPanels: PluginPanel[]
|
pluginPanels: PluginPanel[]
|
||||||
serverWorkspaces: { id: string; title: string; icon?: string; shortcut?: string; body?: any[] }[]
|
serverWorkspaces: { id: string; title: string; icon?: string; shortcut?: string; body?: any[] }[]
|
||||||
|
/** 管理区声明分区(WP3-I3):编译插件声明,入口全员可见、区内置权限不合并。 */
|
||||||
|
adminSections: AdminZoneSection[]
|
||||||
ui: UIState
|
ui: UIState
|
||||||
connected: boolean
|
connected: boolean
|
||||||
/** 发送请求已经出去,run 记录还没回来。 */
|
/** 发送请求已经出去,run 记录还没回来。 */
|
||||||
@@ -125,7 +132,7 @@ const DEFAULT_UI: UIState = {
|
|||||||
rightWidth: 360,
|
rightWidth: 360,
|
||||||
rightCollapsed: false,
|
rightCollapsed: false,
|
||||||
rightTab: 'work',
|
rightTab: 'work',
|
||||||
filesView: 'list',
|
filesView: 'list', workspaceSurfacePin: null,
|
||||||
inboxFilter: 'all',
|
inboxFilter: 'all',
|
||||||
activeWorkspace: null,
|
activeWorkspace: null,
|
||||||
theme: 'dark',
|
theme: 'dark',
|
||||||
@@ -168,8 +175,9 @@ let state: AppState = {
|
|||||||
contextObs: {},
|
contextObs: {},
|
||||||
projects: [], currentProjectId: null, tasks: [], me: null, currentConvId: null,
|
projects: [], currentProjectId: null, tasks: [], me: null, currentConvId: null,
|
||||||
messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null,
|
messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null,
|
||||||
plan: null, todos: null, artifacts: [], surfaces: [], cards: [], projectCards: [], inboxScope: 'conv', runtimeMock: false, profiles: [], pluginPanels: [],
|
plan: null, todos: null, artifacts: [], surfaces: [], projectSurfaces: [], cards: [], projectCards: [], inboxScope: 'conv', runtimeMock: false, profiles: [], pluginPanels: [],
|
||||||
serverWorkspaces: [],
|
serverWorkspaces: [],
|
||||||
|
adminSections: [],
|
||||||
ui: loadUI(), connected: false, posting: false, error: null, toast: null,
|
ui: loadUI(), connected: false, posting: false, error: null, toast: null,
|
||||||
outbox: [], outboxDraining: false,
|
outbox: [], outboxDraining: false,
|
||||||
previewTabs: {}, previewActive: {},
|
previewTabs: {}, previewActive: {},
|
||||||
@@ -374,7 +382,7 @@ export const store = {
|
|||||||
|
|
||||||
async openConversation(convId: string) {
|
async openConversation(convId: string) {
|
||||||
if (sse) { sse.close(); sse = null }
|
if (sse) { sse.close(); sse = null }
|
||||||
set({ currentConvId: convId, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null, plan: null, todos: null, cards: [], surfaces: [], inboxScope: 'conv', outbox: [], outboxDraining: false, posting: false })
|
set({ currentConvId: convId, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null, plan: null, todos: null, cards: [], surfaces: [], projectSurfaces: [], inboxScope: 'conv', outbox: [], outboxDraining: false, posting: false })
|
||||||
persistConvId(convId)
|
persistConvId(convId)
|
||||||
// 空态时打开的预览标签('_' 键)随会话进入一起迁移,不留在不可见的孤儿键下。
|
// 空态时打开的预览标签('_' 键)随会话进入一起迁移,不留在不可见的孤儿键下。
|
||||||
if ((state.previewTabs['_'] ?? []).length > 0) {
|
if ((state.previewTabs['_'] ?? []).length > 0) {
|
||||||
@@ -385,6 +393,7 @@ export const store = {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
// U4/R5:已保存页面从权威快照重载(刷新/断线恢复;事件流只做增量投影)。
|
// U4/R5:已保存页面从权威快照重载(刷新/断线恢复;事件流只做增量投影)。
|
||||||
|
api.projectSurfaces(state.currentProjectId ?? '').then(list => { if (state.currentConvId === convId) set({ projectSurfaces: list ?? [] }) }).catch(() => {})
|
||||||
api.surfaceList(convId).then(list => {
|
api.surfaceList(convId).then(list => {
|
||||||
if (state.currentConvId !== convId) return
|
if (state.currentConvId !== convId) return
|
||||||
set({ surfaces: list ?? [] })
|
set({ surfaces: list ?? [] })
|
||||||
@@ -778,6 +787,10 @@ export const store = {
|
|||||||
const ws = await api.listWorkspaces()
|
const ws = await api.listWorkspaces()
|
||||||
set({ serverWorkspaces: ws ?? [] })
|
set({ serverWorkspaces: ws ?? [] })
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
|
try {
|
||||||
|
const sections = await api.adminZoneSections()
|
||||||
|
set({ adminSections: sections ?? [] })
|
||||||
|
} catch { /* ignore */ }
|
||||||
try {
|
try {
|
||||||
const settings = await api.getSettings()
|
const settings = await api.getSettings()
|
||||||
const runtime = settings.user.runtime
|
const runtime = settings.user.runtime
|
||||||
|
|||||||
@@ -596,6 +596,8 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
|
|||||||
.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 { 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:hover { background: var(--bg-hover); color: var(--text); }
|
||||||
.rdm-audit-tree-node.sel { background: var(--bg-active); 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-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-tree-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.rdm-audit-more { margin-top: 8px; }
|
.rdm-audit-more { margin-top: 8px; }
|
||||||
|
|||||||
@@ -57,3 +57,21 @@ describe('surface.updated 可见性联动', () => {
|
|||||||
expect(store.getState().surfaces.some(x => String(x.identity.surfaceId) === 'sv2')).toBe(true)
|
expect(store.getState().surfaces.some(x => String(x.identity.surfaceId) === 'sv2')).toBe(true)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('U6 工作区任务页', () => {
|
||||||
|
it('pin 固定后新页面事件不改变固定对象;取消后跟随最新', () => {
|
||||||
|
store.setUI({ workspaceSurfacePin: null })
|
||||||
|
store.applyEvent(surfEvent(201, 'w1', 1, '第一页'))
|
||||||
|
const first = store.getState().ui.workspaceSurfacePin
|
||||||
|
expect(first).toBeNull() // 默认跟随(不自动 pin)
|
||||||
|
// 用户固定 w1
|
||||||
|
store.setUI({ workspaceSurfacePin: 'w1' })
|
||||||
|
store.applyEvent(surfEvent(202, 'w2', 1, '第二页'))
|
||||||
|
// pin 不变,且 surfaces 数组两页并存(禁用回退时容器随空数组自然消失)
|
||||||
|
expect(store.getState().ui.workspaceSurfacePin).toBe('w1')
|
||||||
|
const w2 = store.getState().surfaces.find(x => String(x.identity.surfaceId) === 'w2')
|
||||||
|
expect(w2?.spec.title).toBe('第二页')
|
||||||
|
store.setUI({ workspaceSurfacePin: null })
|
||||||
|
expect(store.getState().ui.workspaceSurfacePin).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ export function buildWorkspaceEntries(server: WorkspaceSource[]): WorkspaceEntry
|
|||||||
return 99
|
return 99
|
||||||
}
|
}
|
||||||
ranked.push({ rank: takeFree(1), entry: BUILTINS[0] })
|
ranked.push({ rank: takeFree(1), entry: BUILTINS[0] })
|
||||||
|
ranked.push({ rank: takeFree(2), entry: BUILTINS[1] })
|
||||||
for (const entry of unranked) ranked.push({ rank: takeFree(9), entry })
|
for (const entry of unranked) ranked.push({ rank: takeFree(9), entry })
|
||||||
return ranked
|
return ranked
|
||||||
.filter(item => item.rank <= 9)
|
.filter(item => item.rank <= 9)
|
||||||
|
|||||||
+16
-6
@@ -1,19 +1,20 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { buildWorkspaceEntries } from '../src/workspaces'
|
import { buildWorkspaceEntries } from '../src/workspaces'
|
||||||
|
|
||||||
// 三栏工作台(2026-10-11):文件浏览并入第三栏「文件」入口,体系工具只补「代理」。
|
// 三栏工作台(2026-10-11):文件浏览并入第三栏「文件」入口;体系工具内置
|
||||||
|
// 「代理」与「追溯审计」(U4 修复:第二个 builtin 此前未入列,入口不可达)。
|
||||||
describe('buildWorkspaceEntries', () => {
|
describe('buildWorkspaceEntries', () => {
|
||||||
it('没有领域插件时,代理补在 ⌘1', () => {
|
it('没有领域插件时,代理补 ⌘1、追溯审计补 ⌘2', () => {
|
||||||
const entries = buildWorkspaceEntries([])
|
const entries = buildWorkspaceEntries([])
|
||||||
expect(entries.map(e => `${e.shortcut} ${e.title}`)).toEqual(['⌘1 代理'])
|
expect(entries.map(e => `${e.shortcut} ${e.title}`)).toEqual(['⌘1 代理', '⌘2 追溯审计'])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('声明了 shortcut 的工作区占住该位,内置工具让位', () => {
|
it('声明了 shortcut 的工作区占住该位,内置工具让位', () => {
|
||||||
const entries = buildWorkspaceEntries([
|
const entries = buildWorkspaceEntries([
|
||||||
{ id: 'trace', title: '追溯网', shortcut: '1' },
|
{ 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'])
|
expect(entries.map(e => e.shortcut)).toEqual(['⌘1', '⌘2', '⌘3'])
|
||||||
expect(entries[0].key).toBe('ws:trace')
|
expect(entries[0].key).toBe('ws:trace')
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -21,6 +22,15 @@ describe('buildWorkspaceEntries', () => {
|
|||||||
const entries = buildWorkspaceEntries([
|
const entries = buildWorkspaceEntries([
|
||||||
{ id: 'mem', title: '记忆库' },
|
{ 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 },
|
'/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' },
|
test: { environment: 'node' },
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user