Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f52634380 | ||
|
|
a45a4d8652 | ||
|
|
378ec24bbd | ||
|
|
cc233fec90 | ||
|
|
8db7093a9d | ||
|
|
034bd5f724 | ||
|
|
83a062b7f8 | ||
|
|
fdaf89de9d | ||
|
|
986ebdbb61 | ||
|
|
e058adcf72 | ||
|
|
d01a3ebd81 | ||
|
|
fac99939fa | ||
|
|
c1225bb880 | ||
|
|
57fabf1a95 | ||
|
|
360d6406e4 | ||
|
|
25e76c5e01 | ||
|
|
41f6ce8760 | ||
|
|
0f860c0743 |
@@ -0,0 +1,57 @@
|
||||
// 增量 3 验收(2026-10-12 诊断报告):改密弹窗表单语义——<form onSubmit> 承载提交
|
||||
//(Enter 可提交)、autocomplete 三段标注(username/current-password/new-password)、
|
||||
// 只读用户名关联、错误文案复用 humanChangePasswordError(401 不再一刀切)。
|
||||
// 驱动:scripts/e2e.sh 第 4 阶段(E2E_PLAIN_BASE=1)——表单语义与命令面无关,
|
||||
// 任意 auth-on 服务可验,普通基座段顺路覆盖;RDM 组合主跑(第 3 阶段)本文件自跳过。
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
|
||||
const OWNER = process.env.E2E_OWNER || 'e2e-auditor'
|
||||
const PASSWORD = process.env.E2E_PASSWORD || 'E2eAudit#2026'
|
||||
const PASSWORD2 = process.env.E2E_PASSWORD2 || 'E2eAudit#2026b'
|
||||
|
||||
test.skip(!(process.env.PLAYWRIGHT_BASE_URL && process.env.E2E_PLAIN_BASE),
|
||||
'set PLAYWRIGHT_BASE_URL + E2E_PLAIN_BASE (scripts/e2e.sh phase 4 drives this suite)')
|
||||
|
||||
async function login(page: Page) {
|
||||
await page.goto('/')
|
||||
await page.getByPlaceholder('由管理员分配').fill(OWNER)
|
||||
await page.getByPlaceholder('初始密码或常用密码').fill(PASSWORD)
|
||||
await page.getByRole('button', { name: '登录', exact: true }).click()
|
||||
const gate = page.getByText('修改密码')
|
||||
const gated = await gate.waitFor({ state: 'visible', timeout: 5_000 }).then(() => true).catch(() => false)
|
||||
if (gated) {
|
||||
const pwInputs = page.locator('.lg-card input[type="password"]')
|
||||
await pwInputs.nth(0).fill(PASSWORD)
|
||||
await pwInputs.nth(1).fill(PASSWORD2)
|
||||
await pwInputs.nth(2).fill(PASSWORD2)
|
||||
await page.getByRole('button', { name: '设置新密码并进入' }).click()
|
||||
} else {
|
||||
await page.getByPlaceholder('初始密码或常用密码').fill(PASSWORD2)
|
||||
await page.getByRole('button', { name: '登录', exact: true }).click()
|
||||
}
|
||||
await expect(page.locator('.tsb-tool', { hasText: '代理' })).toBeVisible({ timeout: 15_000 })
|
||||
}
|
||||
|
||||
test('改密弹窗:form 提交、autocomplete/用户名关联、错误分类文案', async ({ page }) => {
|
||||
await login(page)
|
||||
await page.locator('.tsb-user').click()
|
||||
await page.getByRole('button', { name: '修改密码…' }).click()
|
||||
const dlg = page.getByRole('dialog', { name: '修改密码' })
|
||||
await expect(dlg).toBeVisible()
|
||||
|
||||
// 用户名只读关联 + 三段 autocomplete。
|
||||
const username = dlg.locator('input[autocomplete="username"]')
|
||||
await expect(username).toHaveValue(OWNER)
|
||||
// 本仓 @playwright/test 无 toBeReadOnly:以 readonly 属性断言只读关联。
|
||||
await expect(username).toHaveAttribute('readonly', '')
|
||||
await expect(dlg.locator('input[autocomplete="current-password"]')).toHaveCount(1)
|
||||
await expect(dlg.locator('input[autocomplete="new-password"]')).toHaveCount(2)
|
||||
|
||||
// 表单语义:填错当前密码后按 Enter(不点按钮)提交——证明提交走 <form onSubmit>。
|
||||
await dlg.locator('input[autocomplete="current-password"]').fill('definitely-wrong-pass')
|
||||
await dlg.locator('input[autocomplete="new-password"]').first().fill('ANewPass#2026x')
|
||||
await dlg.locator('input[autocomplete="new-password"]').nth(1).fill('ANewPass#2026x')
|
||||
await dlg.locator('input[autocomplete="current-password"]').press('Enter')
|
||||
// 后端 401 "current password incorrect" → humanChangePasswordError 的分类文案。
|
||||
await expect(dlg.getByText('当前密码不正确')).toBeVisible({ timeout: 10_000 })
|
||||
})
|
||||
@@ -0,0 +1,53 @@
|
||||
// 增量 2 验收(2026-10-12 诊断报告):普通基座(无 rdm.inspect.* 命令面)不展示
|
||||
// 「追溯审计」入口,且不发出任何 rdm.* 命令请求——消除「入口可达、进入即 400」。
|
||||
// 驱动:scripts/e2e.sh 第 4 阶段——RDM 组合验收跑完后,同一数据目录换基座 CLI
|
||||
//(agent-system serve,同源同 pin)服务,同一 preview 指向它,只跑本文件。
|
||||
// 环境契约与 rdm-audit.playwright.ts 一致(e2e.sh 注入)。
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
|
||||
const OWNER = process.env.E2E_OWNER || 'e2e-auditor'
|
||||
const PASSWORD = process.env.E2E_PASSWORD || 'E2eAudit#2026'
|
||||
const PASSWORD2 = process.env.E2E_PASSWORD2 || 'E2eAudit#2026b'
|
||||
|
||||
// 仅第 4 阶段(普通基座)启用:RDM 组合主跑(第 3 阶段)扫全目录时本文件自跳过,
|
||||
// 否则「入口隐藏」断言会跑在入口本就可见的 RDM 组合上。
|
||||
test.skip(!(process.env.PLAYWRIGHT_BASE_URL && process.env.E2E_PLAIN_BASE),
|
||||
'set PLAYWRIGHT_BASE_URL + E2E_PLAIN_BASE (scripts/e2e.sh phase 4 drives this suite)')
|
||||
|
||||
// 与 rdm-audit spec 同一账号:若数据库仍是初始密码则先过强制改密门。
|
||||
async function login(page: Page) {
|
||||
await page.goto('/')
|
||||
await page.getByPlaceholder('由管理员分配').fill(OWNER)
|
||||
await page.getByPlaceholder('初始密码或常用密码').fill(PASSWORD)
|
||||
await page.getByRole('button', { name: '登录', exact: true }).click()
|
||||
const gate = page.getByText('修改密码')
|
||||
const gated = await gate.waitFor({ state: 'visible', timeout: 5_000 }).then(() => true).catch(() => false)
|
||||
if (gated) {
|
||||
const pwInputs = page.locator('.lg-card input[type="password"]')
|
||||
await pwInputs.nth(0).fill(PASSWORD)
|
||||
await pwInputs.nth(1).fill(PASSWORD2)
|
||||
await pwInputs.nth(2).fill(PASSWORD2)
|
||||
await page.getByRole('button', { name: '设置新密码并进入' }).click()
|
||||
} else {
|
||||
// 已改密(RDM 阶段完成):改用新密码重登。
|
||||
await page.getByPlaceholder('初始密码或常用密码').fill(PASSWORD2)
|
||||
await page.getByRole('button', { name: '登录', exact: true }).click()
|
||||
}
|
||||
}
|
||||
|
||||
test('普通基座:命令面无 rdm.inspect.* → 审计入口隐藏、无 rdm 请求', async ({ page }) => {
|
||||
const rdmRequests: string[] = []
|
||||
page.on('request', r => {
|
||||
if (r.url().includes('/api/commands/rdm.')) rdmRequests.push(r.url())
|
||||
})
|
||||
await login(page)
|
||||
// 登录成功标志:侧栏常驻「代理」与「文件」体系工具(均为无条件内置)。
|
||||
await expect(page.locator('.tsb-tool', { hasText: '代理' })).toBeVisible({ timeout: 15_000 })
|
||||
await expect(page.locator('.tsb-tool', { hasText: '文件' })).toBeVisible()
|
||||
// 能力探测(GET /api/commands)返回普通基座清单后,审计入口保持隐藏。
|
||||
await expect(page.locator('.tsb-tool', { hasText: '追溯审计' })).toHaveCount(0)
|
||||
await page.waitForTimeout(500)
|
||||
await expect(page.locator('.tsb-tool', { hasText: '追溯审计' })).toHaveCount(0)
|
||||
// 入口不存在则不应有任何 rdm.* 命令请求(原缺陷路径是进入即 400)。
|
||||
expect(rdmRequests).toEqual([])
|
||||
})
|
||||
+13
-4
@@ -4,11 +4,16 @@ import { auth } from './auth'
|
||||
import type {
|
||||
Project, Conversation, Message, Run, Artifact, HITLCard, HITLResponse,
|
||||
AgentProfile, SettingDesc, StreamEvent, StreamEventType, PlanSnapshot, TodoList, UploadInfo, PromptModule,
|
||||
ActivityCard,
|
||||
ActivityCard, SurfaceMeta, SurfaceSnapshot,
|
||||
} from './contracts'
|
||||
|
||||
const BASE = ''
|
||||
|
||||
/** 管理区声明分区(WP3-I3/KTD4,backend 专属端点类型):编译插件经
|
||||
* Registry.AddAdminZone 声明;入口不做前端权限合并,区内数据能力由组件
|
||||
* 自身命令面 fail-closed。 */
|
||||
export interface AdminZoneSection { id: string; title: string; icon?: string; desc?: string; order?: number }
|
||||
|
||||
async function json<T>(res: Response): Promise<T> {
|
||||
if (!res.ok) {
|
||||
const text = await res.text()
|
||||
@@ -30,10 +35,13 @@ 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)),
|
||||
surfaceList: (convId: string) => auth.fetch(`${BASE}/api/conversations/${convId}/surfaces`).then(r => json<any[]>(r)),
|
||||
/** 会话内页面目录:扁平 SurfaceMeta(无 identity/spec),不可直接写入 surfaces 投影。 */
|
||||
surfaceList: (convId: string) => auth.fetch(`${BASE}/api/conversations/${convId}/surfaces`).then(r => json<SurfaceMeta[]>(r)),
|
||||
submitSurfaceAction: (id: string, body: { actionId: string; revision: number; requestId: string; data: Record<string, unknown> }) =>
|
||||
auth.fetch(`${BASE}/api/surfaces/${id}/actions`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }).then(r => json<any>(r)),
|
||||
surfaceGet: (id: string) => auth.fetch(`${BASE}/api/surfaces/${id}`).then(r => json<any>(r)),
|
||||
projectSurfaces: (projectId: string) => auth.fetch(`${BASE}/api/projects/${projectId}/surfaces`).then(r => json<SurfaceMeta[]>(r)),
|
||||
/** 单页权威快照(含 identity + spec),供列表水合与预览渲染。 */
|
||||
surfaceGet: (id: string) => auth.fetch(`${BASE}/api/surfaces/${id}`).then(r => json<SurfaceSnapshot>(r)),
|
||||
me: () => auth.fetch(`${BASE}/api/me`).then(r => json<{ owner: string; name: string; auth: boolean; authVia?: string; isAdmin?: boolean; mustChangePassword?: boolean }>(r)),
|
||||
// ---- 认证与会话(管理员发放制,R2)----
|
||||
login: (owner: string, password: string) => auth.fetch(`${BASE}/api/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, password }) }).then(async r => {
|
||||
@@ -112,8 +120,9 @@ export const api = {
|
||||
|
||||
listTools: () => auth.fetch(`${BASE}/api/tools`).then(r => r.json()),
|
||||
listPlugins: () => auth.fetch(`${BASE}/api/plugins`).then(r => r.json()),
|
||||
adminZoneSections: () => auth.fetch(`${BASE}/api/admin-zone/sections`).then(r => r.json()) as Promise<AdminZoneSection[]>,
|
||||
reloadPlugins: () => auth.fetch(`${BASE}/api/plugins/reload`, { method: 'POST' }).then(r => r.json()),
|
||||
listCommands: () => auth.fetch(`${BASE}/api/commands`).then(r => r.json()) as Promise<{ name?: string; id?: string; title?: string; description?: string; danger?: string }[]>,
|
||||
listCommands: () => auth.fetch(`${BASE}/api/commands`).then(r => r.json()) as Promise<{ name?: string; id?: string; title?: string; description?: string; danger?: string; enabled?: boolean }[]>,
|
||||
setSessionNote: (id: string, sessionNote: string) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionNote }) }).then(r => { if (!r.ok) throw new Error(`${r.status}`) }),
|
||||
excludeMessage: (id: string, excluded: boolean) => auth.fetch(`${BASE}/api/messages/${id}/exclude`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ excluded }) }).then(r => { if (!r.ok) throw new Error(`${r.status}`) }),
|
||||
selectVariant: (id: string) => auth.fetch(`${BASE}/api/messages/${id}/variant`, { method: 'POST' }).then(r => { if (!r.ok) throw new Error(`${r.status}`) }),
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState } from 'react'
|
||||
import { api } from '../api'
|
||||
import { store, useStore } from '../store'
|
||||
import { Icon } from '@ui'
|
||||
import { humanChangePasswordError } from './LoginPage'
|
||||
|
||||
function Shell({ title, onClose, children }: { title: string; onClose: () => void; children: React.ReactNode }) {
|
||||
return (
|
||||
@@ -20,7 +21,10 @@ function Shell({ title, onClose, children }: { title: string; onClose: () => voi
|
||||
)
|
||||
}
|
||||
|
||||
/** 主动改密(账户菜单入口):当前密码 + 新密码 + 确认。 */
|
||||
/** 主动改密(账户菜单入口):用户名(只读关联)+ 当前密码 + 新密码 + 确认。
|
||||
* 表单语义(2026-10-12 增量 3):<form onSubmit> 承载提交(Enter 可用)、autocomplete
|
||||
* 三段标注(username/current-password/new-password)、label 与 input 关联;
|
||||
* 错误文案复用 humanChangePasswordError——401 不再一刀切成「当前密码不正确」。 */
|
||||
export function ChangePasswordModal({ onClose }: { onClose: () => void }) {
|
||||
const me = useStore(s => s.me)
|
||||
const [current, setCurrent] = useState('')
|
||||
@@ -41,23 +45,28 @@ export function ChangePasswordModal({ onClose }: { onClose: () => void }) {
|
||||
store.toast('密码已更新')
|
||||
setTimeout(onClose, 900)
|
||||
} catch (e) {
|
||||
setErr(String(e).includes('401') ? '当前密码不正确' : String(e))
|
||||
setErr(humanChangePasswordError(String(e)))
|
||||
} finally { setBusy(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<Shell title="修改密码" onClose={onClose}>
|
||||
{ok ? <div className="ad-notice">密码已更新,其他设备的会话已注销。</div> : (
|
||||
<div className="ad-changepw">
|
||||
<label className="lg-label">当前密码({me?.owner ?? ''})</label>
|
||||
<input className="lg-input" type="password" value={current} onChange={e => setCurrent(e.target.value)} autoFocus />
|
||||
<label className="lg-label">新密码(≥8 位)</label>
|
||||
<input className="lg-input" type="password" value={next} onChange={e => setNext(e.target.value)} />
|
||||
<label className="lg-label">确认新密码</label>
|
||||
<input className="lg-input" type="password" value={confirm} onChange={e => setConfirm(e.target.value)} />
|
||||
<form className="ad-changepw" onSubmit={e => { e.preventDefault(); submit() }}>
|
||||
<label className="lg-label" htmlFor="changepw-username">用户名</label>
|
||||
<input id="changepw-username" className="lg-input" value={me?.owner ?? ''} readOnly autoComplete="username" />
|
||||
<label className="lg-label" htmlFor="changepw-current">当前密码</label>
|
||||
<input id="changepw-current" className="lg-input" type="password" autoComplete="current-password"
|
||||
value={current} onChange={e => setCurrent(e.target.value)} autoFocus />
|
||||
<label className="lg-label" htmlFor="changepw-next">新密码(≥8 位)</label>
|
||||
<input id="changepw-next" className="lg-input" type="password" autoComplete="new-password"
|
||||
value={next} onChange={e => setNext(e.target.value)} />
|
||||
<label className="lg-label" htmlFor="changepw-confirm">确认新密码</label>
|
||||
<input id="changepw-confirm" className="lg-input" type="password" autoComplete="new-password"
|
||||
value={confirm} onChange={e => setConfirm(e.target.value)} />
|
||||
{err && <div className="lg-err"><Icon name="alert" size={14} />{err}</div>}
|
||||
<button className="lg-submit" disabled={busy || !current || !next || !confirm} onClick={submit}>{busy ? '提交中…' : '更新密码'}</button>
|
||||
</div>
|
||||
<button className="lg-submit" type="submit" disabled={busy || !current || !next || !confirm}>{busy ? '提交中…' : '更新密码'}</button>
|
||||
</form>
|
||||
)}
|
||||
</Shell>
|
||||
)
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
// 系统管理(重设计方案 阶段②):管理员专属的全屏独立视图(带返回,非弹窗)。
|
||||
// 系统管理(重设计方案 阶段②):全屏独立视图(带返回,非弹窗)。
|
||||
// 用户决策:系统管理 = 全屏独立视图;普通用户界面不暴露任何系统能力。
|
||||
// 分区注册表:新增管理页只改 SECTIONS,不改 AdminZone 本体(声明式注册)。
|
||||
// WP3-I3/KTD4:编译插件可经 Registry.AddAdminZone 声明展示分区(服务端
|
||||
// /api/admin-zone/sections 下发);声明分区对全部认证主体开放,区内数据
|
||||
// 能力由分区组件自身的命令面 fail-closed——前端只消费、不合并角色。
|
||||
import { useState } from 'react'
|
||||
import { store, useStore } from '../store'
|
||||
import { Icon, type IconName } from '@ui'
|
||||
import { EmptyState, Icon, type IconName } from '@ui'
|
||||
import { UsersPanel, ProfilesSection, PluginsSection, PluginWorkspacesPanel } from './adminSections'
|
||||
import { RdmAuditWorkspace } from './RdmAuditWorkspace'
|
||||
import type { AdminZoneSection } from '../api'
|
||||
|
||||
interface AdminSectionDef {
|
||||
key: string
|
||||
@@ -27,12 +32,47 @@ const SECTIONS: AdminSectionDef[] = [
|
||||
{ key: 'workspaces', label: '插件工作区', icon: 'grid', desc: '插件声明面板与服务端声明工作区', render: () => <PluginWorkspacesPanel /> },
|
||||
]
|
||||
|
||||
/** 声明分区 → 内置分区组件(按声明 ID 映射;AdminZone 不硬编码领域逻辑)。 */
|
||||
const DECLARED_COMPONENTS: Record<string, () => React.ReactNode> = {
|
||||
'rdm.audit': () => <RdmAuditWorkspace />,
|
||||
}
|
||||
|
||||
/** icon 走基座 IconName 词表;声明携带未知图标时回退通用图标(不渲染空白)。 */
|
||||
const ICONS: IconName[] = ['plus', 'search', 'panel-left', 'inbox', 'settings', 'sun', 'moon', 'pin', 'archive', 'undo',
|
||||
'chevron-right', 'chevron-down', 'folder', 'message', 'package', 'bot', 'puzzle', 'grid', 'arrow-up', 'paperclip',
|
||||
'more', 'logout', 'key', 'user', 'users', 'shield', 'x', 'check', 'alert', 'send', 'sparkles', 'clock', 'list',
|
||||
'file', 'image', 'download', 'trash', 'eye', 'maximize', 'minimize']
|
||||
function declaredIcon(name?: string): IconName {
|
||||
return (ICONS as string[]).includes(name ?? '') ? (name as IconName) : 'grid'
|
||||
}
|
||||
|
||||
function DeclaredSection({ section }: { section: AdminZoneSection }) {
|
||||
const render = DECLARED_COMPONENTS[section.id]
|
||||
if (!render) {
|
||||
return <EmptyState icon="puzzle">分区「{section.title}」由内置插件声明,但当前前端版本未携带其组件<br />(部署前端与二进制版本不匹配时会出现;升级前端即可)</EmptyState>
|
||||
}
|
||||
return <>{render()}</>
|
||||
}
|
||||
|
||||
export function AdminZone() {
|
||||
const me = useStore(s => s.me)
|
||||
const declared = useStore(s => s.adminSections)
|
||||
const [sectionKey, setSectionKey] = useState('users')
|
||||
// 双保险:入口已在菜单层门控(me?.isAdmin),此处再拦一次(纵深防御)。
|
||||
if (!me?.isAdmin) return null
|
||||
const section = SECTIONS.find(s => s.key === sectionKey) ?? SECTIONS[0]
|
||||
const isAdmin = !!me?.isAdmin
|
||||
// 门控:基座系统分区仅 isAdmin(纵深防御与菜单层一致);声明分区对全部
|
||||
// 认证主体开放(区内能力由组件命令面判定)。两者皆无时不渲染。
|
||||
if (!isAdmin && declared.length === 0) return null
|
||||
const sections: AdminSectionDef[] = [
|
||||
...(isAdmin ? SECTIONS : []),
|
||||
...declared.map(s => ({
|
||||
key: `decl:${s.id}`, label: s.title, icon: declaredIcon(s.icon),
|
||||
desc: s.desc || '插件声明的管理分区(区内数据能力由插件命令面判定)',
|
||||
render: () => <DeclaredSection section={s} />,
|
||||
})),
|
||||
]
|
||||
const fallback = sections[0]?.key ?? ''
|
||||
const section = sections.find(s => s.key === sectionKey) ?? sections[0]
|
||||
if (!section) return null
|
||||
return (
|
||||
<div className="az" role="dialog" aria-label="系统管理">
|
||||
<div className="az-head">
|
||||
@@ -40,14 +80,14 @@ export function AdminZone() {
|
||||
<Icon name="chevron-down" size={14} /> 返回工作台
|
||||
</button>
|
||||
<span className="az-title">系统管理</span>
|
||||
<span className="az-sub">仅管理员可见 · 变更即时生效</span>
|
||||
<span className="az-sub">{isAdmin ? '仅管理员可见' : '按插件声明开放'} · 变更即时生效</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
<span className="az-tenant">{me.owner}</span>
|
||||
<span className="az-tenant">{me?.owner ?? ''}</span>
|
||||
</div>
|
||||
<div className="az-body">
|
||||
<nav className="az-side">
|
||||
{SECTIONS.map(s => (
|
||||
<button key={s.key} className={`az-nav-item ${s.key === sectionKey ? 'on' : ''}`} onClick={() => setSectionKey(s.key)}>
|
||||
{sections.map(s => (
|
||||
<button key={s.key} className={`az-nav-item ${s.key === (section?.key ?? fallback) ? 'on' : ''}`} onClick={() => setSectionKey(s.key)}>
|
||||
<Icon name={s.icon} size={15} />
|
||||
<span>{s.label}</span>
|
||||
</button>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Collapsible, Icon } from '@ui'
|
||||
import { artifactIcon, humanSize } from '../messageparts'
|
||||
import type { Artifact } from '../contracts'
|
||||
import { groupArtifactsByName } from '../artifactGrouping'
|
||||
import { surfaceKey } from '../surfaceKey'
|
||||
|
||||
export function ArtifactsPanel() {
|
||||
const artifacts = useStore(s => s.artifacts)
|
||||
@@ -42,16 +43,19 @@ export function ArtifactsPanel() {
|
||||
{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 ?? ''}`}>
|
||||
{surfaces.filter(sv => surfaceKey(sv) != null).map(sv => {
|
||||
const id = surfaceKey(sv)!
|
||||
return (
|
||||
<div key={id} className="artifact-row" data-surface-row={id}
|
||||
onClick={() => store.openSurface(id)} title={`打开页面 ${sv.spec?.title ?? ''}`}>
|
||||
<span className="ar-icon"><Icon name="grid" size={15} /></span>
|
||||
<span className="ar-main">
|
||||
<span className="name">{sv.spec?.title || '(未命名页面)'}</span>
|
||||
<span className="meta">页面 · 修订 {sv.revision} · {sv.identity.pluginId}</span>
|
||||
<span className="meta">页面 · 修订 {sv.revision} · {sv.identity?.pluginId}</span>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{artifacts.length === 0
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { Artifact } from '../contracts'
|
||||
import { humanSize } from '../messageparts'
|
||||
import { previewTabsKey } from '../previewtabs'
|
||||
import { store, useStore } from '../store'
|
||||
import { surfaceKey } from '../surfaceKey'
|
||||
|
||||
export function PreviewPane() {
|
||||
// 键口径与 requestPreview 一致(previewTabsKey):空态预览也渲染。
|
||||
@@ -17,7 +18,10 @@ export function PreviewPane() {
|
||||
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)
|
||||
? surfaces.find(x => {
|
||||
const id = surfaceKey(x)
|
||||
return id != null && 'surface:' + id === active.id
|
||||
})
|
||||
: undefined
|
||||
const art = active ? artifacts.find(a => a.id === active.id) : undefined
|
||||
const textKind = !!art && isTextKind(art)
|
||||
@@ -56,7 +60,7 @@ export function PreviewPane() {
|
||||
</div>
|
||||
<div className="preview-pane-body">
|
||||
{surfaceSnap
|
||||
? <SurfaceView snapshot={surfaceSnap} onAction={id => store.handleSurfaceAction(String(surfaceSnap?.identity.surfaceId), id, {})} docBridge={{ onOpenDoc: docId => {
|
||||
? <SurfaceView snapshot={surfaceSnap} onAction={id => store.handleSurfaceAction(surfaceKey(surfaceSnap) ?? '', id, {})} docBridge={{ onOpenDoc: docId => {
|
||||
const art = artifacts.find(a => a.id === docId)
|
||||
if (art) store.requestPreview(art)
|
||||
} }} />
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
// 令牌驱动(DESIGN.md):颜色/字号/圆角仅消费 @ui/tokens.css;折叠用 Collapsible。
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { api } from '../api'
|
||||
import { Chip, EmptyState, SectionLabel } from '@ui'
|
||||
import { Chip, Collapsible, EmptyState, SectionLabel } from '@ui'
|
||||
import { classifyRdmError } from '../rdmErrors'
|
||||
|
||||
// ---- 类型(U3 ListShell/详情的精简前端契约) ----
|
||||
|
||||
@@ -29,6 +30,20 @@ const TABS = [
|
||||
] as const
|
||||
type TabId = (typeof TABS)[number]['id']
|
||||
|
||||
/** 错误态(增量 2):分类文案(能力缺失/登录失效/查询失败)+ 原始回执折叠供诊断;
|
||||
* 「无可审计项目」由调用处独立空态承担,不走本组件。 */
|
||||
function RdmErrorState({ raw }: { raw: string }) {
|
||||
const c = classifyRdmError(raw)
|
||||
return (
|
||||
<div className="rdm-audit-err">
|
||||
<EmptyState icon="alert">{c.message}</EmptyState>
|
||||
<Collapsible id="rdm-audit-err-receipt" trigger={<span className="rdm-audit-err-toggle">原始回执</span>}>
|
||||
<code className="rdm-audit-err-raw">{raw}</code>
|
||||
</Collapsible>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function RdmAuditWorkspace() {
|
||||
const [caps, setCaps] = useState<Capabilities | null>(null)
|
||||
const [pid, setPid] = useState('')
|
||||
@@ -44,7 +59,7 @@ export function RdmAuditWorkspace() {
|
||||
}, [])
|
||||
|
||||
const project = caps?.projects.find(p => p.projectId === pid)
|
||||
if (error) return <EmptyState icon="alert">{error}</EmptyState>
|
||||
if (error) return <RdmErrorState raw={error} />
|
||||
if (!caps) return <div className="empty-note">加载中…</div>
|
||||
if (caps.projects.length === 0) return <EmptyState icon="folder">无可见研发项目——项目创建者与域管理员可查看追溯审计</EmptyState>
|
||||
|
||||
@@ -89,7 +104,7 @@ function SummaryTab({ pid }: { pid: string }) {
|
||||
.catch(e => { if (!c) setErr(e.message) })
|
||||
return () => { c = true }
|
||||
}, [pid])
|
||||
if (err) return <EmptyState icon="alert">{err}</EmptyState>
|
||||
if (err) return <RdmErrorState raw={err} />
|
||||
if (!data) return <div className="empty-note">加载中…</div>
|
||||
const counts = data.counts || {}
|
||||
const entries: [string, string, number][] = [
|
||||
@@ -185,7 +200,7 @@ function TreeList({ pid, kind }: { pid: string; kind: 'notes' | 'docs' }) {
|
||||
.catch(e => { if (gen === genRef.current) setErr(e.message) })
|
||||
}
|
||||
|
||||
if (err) return <EmptyState icon="alert">{err}</EmptyState>
|
||||
if (err) return <RdmErrorState raw={err} />
|
||||
if (!shell) return <div className="empty-note">加载中…</div>
|
||||
if (shell.items.length === 0) return <EmptyState icon="folder">暂无{kind === 'notes' ? '骨架节点' : '文档'}</EmptyState>
|
||||
|
||||
@@ -292,7 +307,7 @@ function TraceTab({ pid }: { pid: string }) {
|
||||
<strong>{sel.title}</strong>
|
||||
<Chip tone="plain">{sel.kind}:{sel.id}</Chip>
|
||||
</div>
|
||||
{err && <EmptyState icon="alert">{err}</EmptyState>}
|
||||
{err && <RdmErrorState raw={err} />}
|
||||
{!relations && !err && <div className="empty-note">加载中…</div>}
|
||||
{relations && relations.length === 0 && <EmptyState icon="folder">没有已知关联</EmptyState>}
|
||||
{relations && relations.length > 0 && (
|
||||
@@ -407,7 +422,7 @@ function TimelineTab({ pid }: { pid: string }) {
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{err && <EmptyState icon="alert">{err}</EmptyState>}
|
||||
{err && <RdmErrorState raw={err} />}
|
||||
{!err && items.length === 0 && <EmptyState icon="clock">暂无事件</EmptyState>}
|
||||
{items.map((item, i) => {
|
||||
if ('events' in item && Array.isArray((item as OpGroup).events)) {
|
||||
|
||||
@@ -112,6 +112,16 @@ export function SettingsModal() {
|
||||
)
|
||||
}
|
||||
|
||||
// 输入框语义(2026-10-12 增量 3):仅秘密字段用密码框;普通文本用 text——
|
||||
// 无条件 type="password" 会把整排设置项当凭据表单(浏览器「password 不在 form 中/
|
||||
// 缺少 username」警告来源之一),也把普通文本无谓遮蔽。
|
||||
export function settingInputAttrs(desc: SettingDesc): { type: 'text' | 'password'; autoComplete: string } {
|
||||
const isSecret = desc.secret || desc.type === 'secret'
|
||||
return isSecret
|
||||
? { type: 'password', autoComplete: 'new-password' }
|
||||
: { type: 'text', autoComplete: 'off' }
|
||||
}
|
||||
|
||||
export function SettingInput({ desc, value, onChange }: { desc: SettingDesc; value: any; onChange: (v: any) => void }) {
|
||||
if (desc.type === 'boolean') {
|
||||
return <input type="checkbox" checked={value === true || value === 'true'} onChange={e => onChange(e.target.checked)} />
|
||||
@@ -122,7 +132,8 @@ export function SettingInput({ desc, value, onChange }: { desc: SettingDesc; val
|
||||
</select>
|
||||
}
|
||||
const isSecret = desc.secret || desc.type === 'secret'
|
||||
return <input type="password" autoComplete="new-password" style={{ width: '100%' }} value={typeof value === 'string' && value === '••••••' ? '' : (value ?? '')}
|
||||
const attrs = settingInputAttrs(desc)
|
||||
return <input type={attrs.type} autoComplete={attrs.autoComplete} style={{ width: '100%' }} value={typeof value === 'string' && value === '••••••' ? '' : (value ?? '')}
|
||||
placeholder={isSecret ? '(已配置,输入以覆盖)' : String(desc.default ?? '')}
|
||||
onChange={e => onChange(e.target.value)} />
|
||||
}
|
||||
|
||||
@@ -40,11 +40,14 @@ export function TaskSidebar() {
|
||||
const projects = useStore(s => s.projects)
|
||||
const tasks = useStore(s => s.tasks)
|
||||
const me = useStore(s => s.me)
|
||||
const adminSections = useStore(s => s.adminSections)
|
||||
const artifacts = useStore(s => s.artifacts)
|
||||
const currentConvId = useStore(s => s.currentConvId)
|
||||
const currentProjectId = useStore(s => s.currentProjectId)
|
||||
const showArchived = useStore(s => s.ui.showArchived)
|
||||
const serverWorkspaces = useStore(s => s.serverWorkspaces)
|
||||
/** 审计入口能力门控(增量 2):命令面探测通过才展示「追溯审计」。 */
|
||||
const rdmAuditAvailable = useStore(s => s.rdmAuditAvailable)
|
||||
const theme = useStore(s => s.ui.theme)
|
||||
const pendingBadge = useStore(s =>
|
||||
s.cards.filter(c => c.status === 'action_required').length +
|
||||
@@ -66,7 +69,7 @@ export function TaskSidebar() {
|
||||
return () => window.removeEventListener('keydown', h, true)
|
||||
}, [])
|
||||
|
||||
const tools = buildWorkspaceEntries(serverWorkspaces)
|
||||
const tools = buildWorkspaceEntries(serverWorkspaces, { rdmAudit: rdmAuditAvailable })
|
||||
const visible = filterTasks(tasks.filter(t => t.projectId === currentProjectId), q)
|
||||
const pinned = visible.filter(t => t.pinned)
|
||||
const rest = visible.filter(t => !t.pinned)
|
||||
@@ -265,7 +268,7 @@ export function TaskSidebar() {
|
||||
<MenuItem icon="sun" onClick={() => { store.setUI({ theme: 'light' }); setMenu(null) }}>浅色主题{theme === 'light' ? ' · 当前' : ''}</MenuItem>
|
||||
<MenuItem onClick={() => { store.setUI({ theme: 'molv' }); setMenu(null) }}>墨绿主题{theme === 'molv' ? ' · 当前' : ''}</MenuItem>
|
||||
<MenuItem onClick={() => { store.setUI({ theme: 'system' }); setMenu(null) }}>跟随系统{theme === 'system' ? ' · 当前' : ''}</MenuItem>
|
||||
{me?.isAdmin && <MenuItem icon="shield" onClick={() => { store.setUI({ adminZoneOpen: true }); setMenu(null) }}>系统管理…</MenuItem>}
|
||||
{(me?.isAdmin || adminSections.length > 0) && <MenuItem icon="shield" onClick={() => { store.setUI({ adminZoneOpen: true }); setMenu(null) }}>系统管理…</MenuItem>}
|
||||
{me?.auth && <>
|
||||
<div className="tsb-menu-div" />
|
||||
<MenuItem danger icon="logout" onClick={() => { api.logout().catch(() => undefined); auth.logout() }}>退出登录</MenuItem>
|
||||
|
||||
@@ -4,8 +4,9 @@
|
||||
import { store, useStore } from '../store'
|
||||
import type { Run } from '../contracts'
|
||||
import { InboxList, pendingCount, type ActivityCard, type DocBridge, type InboxFilter } from '@ui'
|
||||
import { Icon } from '@ui'
|
||||
import { Icon, SurfaceView } from '@ui'
|
||||
import { previewTabsKey } from '../previewtabs'
|
||||
import { surfaceKey } from '../surfaceKey'
|
||||
import { ArtifactsPanel } from './ArtifactsPanel'
|
||||
import { PreviewPane } from './PreviewPane'
|
||||
|
||||
@@ -115,6 +116,7 @@ function WorkTab({ plan, todos, filter, inboxScope, projectCards, cards }: {
|
||||
{inboxScope === 'project'
|
||||
? <ProjectInbox cards={projectCards} filter={filter} />
|
||||
: <InboxList cards={cards} docBridge={docBridge} callbacks={inboxCallbacks} filter={filter} />}
|
||||
<WorkspaceSurfaces />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
@@ -172,3 +174,35 @@ export function AgentRow({ run, depth }: { run: Run; depth: number }) {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// U6 工作区任务页容器:会话内页面在"工作"入口的常驻紧凑视图——默认显示最新
|
||||
// 一页,固定(pin)后不随新页面抢占;关闭插件后此处自然消失(surfaces 为空),
|
||||
// 收件箱/任务卡等原有内容不受影响(禁用回退完整性)。点击标题去文件预览全屏。
|
||||
function WorkspaceSurfaces() {
|
||||
const surfaces = useStore(s => s.surfaces)
|
||||
const pin = useStore(s => s.ui.workspaceSurfacePin)
|
||||
const usable = surfaces.filter(x => surfaceKey(x) != null)
|
||||
if (usable.length === 0) return null
|
||||
const shown = pin ? usable.find(x => surfaceKey(x) === pin) : usable[usable.length - 1]
|
||||
if (!shown) return null
|
||||
const shownId = surfaceKey(shown)!
|
||||
return (
|
||||
<div className="workspace-surfaces" data-workspace-surfaces>
|
||||
<div className="ws-head">
|
||||
<span className="ws-title">任务页面</span>
|
||||
<span className="ws-meta">{shown.spec?.title || '(未命名)'} · 修订 {shown.revision}</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
<button className="icon-btn" title={pin ? '取消固定(跟随最新)' : '固定此页(新页面不抢占)'}
|
||||
onClick={() => store.setUI({ workspaceSurfacePin: pin ? null : shownId })}>
|
||||
{pin ? '📍' : '📌'}
|
||||
</button>
|
||||
<button className="icon-btn" title="在文件预览中打开" onClick={() => store.openSurface(shownId)}>
|
||||
↗
|
||||
</button>
|
||||
</div>
|
||||
<div className="ws-body">
|
||||
<SurfaceView snapshot={shown} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,9 +9,11 @@ import { AgentRow } from './Workbench'
|
||||
import { buildWorkspaceEntries, type WorkspaceEntry } from '../workspaces'
|
||||
import { RdmAuditWorkspace } from './RdmAuditWorkspace'
|
||||
|
||||
/** 体系工具(顺序即 ⌘ 序号)。服务端工作区全员可见;插件管理面板只在系统管理区。 */
|
||||
/** 体系工具(顺序即 ⌘ 序号)。服务端工作区全员可见;插件管理面板只在系统管理区。
|
||||
* 追溯审计按命令面能力显隐(增量 2):store.rdmAuditAvailable 为 true 才入列。 */
|
||||
export function workspaceEntries(): WorkspaceEntry[] {
|
||||
return buildWorkspaceEntries(store.getState().serverWorkspaces)
|
||||
const s = store.getState()
|
||||
return buildWorkspaceEntries(s.serverWorkspaces, { rdmAudit: s.rdmAuditAvailable })
|
||||
}
|
||||
|
||||
export function Workspaces() {
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
// 审计命令错误分类(2026-10-12 增量 2):RdmAuditWorkspace 不再直出原始报错
|
||||
//(普通基座上曾是 `400: {"receipt":…unknown or disabled command…}`),改为
|
||||
//「能力缺失 / 登录已失效 / 查询失败」人话文案;raw 始终保留供「原始回执」折叠区诊断。
|
||||
// 依据:api.ts json() 对非 2xx 抛 `${status}: ${body}`;后端 unknown/disabled 命令回
|
||||
// 400 + "unknown or disabled command"(cmdbus.go Dispatch),总线未挂载回
|
||||
// 503 "commands disabled"(server.go execCommand)。
|
||||
|
||||
type RdmErrorKind = 'capability' | 'auth' | 'query'
|
||||
|
||||
interface RdmErrorView {
|
||||
kind: RdmErrorKind
|
||||
message: string
|
||||
raw: string
|
||||
}
|
||||
|
||||
export function classifyRdmError(err: unknown): RdmErrorView {
|
||||
const raw = err instanceof Error ? err.message : String(err)
|
||||
if (/^40[13]:/.test(raw)) {
|
||||
return { kind: 'auth', message: '登录已失效,请重新登录后再查看追溯审计', raw }
|
||||
}
|
||||
if (/unknown or disabled command|commands disabled/.test(raw)) {
|
||||
return { kind: 'capability', message: '当前服务未提供审计能力(RDM 命令未注册或已停用)', raw }
|
||||
}
|
||||
return { kind: 'query', message: '查询失败,请稍后重试', raw }
|
||||
}
|
||||
+46
-15
@@ -1,13 +1,15 @@
|
||||
// 应用状态:服务端权威(事件 + Query 对账),前端 store 是投影。
|
||||
// useSyncExternalStore 驱动;live 流式部件为会话内临时态,run 终态后由消息树对账收敛。
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import { api, connectEvents } from './api'
|
||||
import { type AdminZoneSection, api, connectEvents } from './api'
|
||||
import { applyTextDelta, applyThinkingDelta, applyToolCall, applyToolCallDelta, applyToolResult, applyToolOutput, applyArtifactCreated, applyArtifactUpdated, applyCheck, applyFinalizing, type LiveItem } from './live'
|
||||
import { closePreviewTab, mergeOrphanPreviewTabs, openPreviewTab, previewTabsKey, type PreviewTab } from './previewtabs'
|
||||
import { deliveryRequestId, enqueueMessage, enqueueMessageFront, newQueuedMessage, promoteQueuedMessage, removeQueuedMessage, replaceQueuedText, type QueuedMessage } from './outbox'
|
||||
import { canFlushOutbox, deriveShellPhase } from './runstate'
|
||||
import { flashElement } from './scrollsync'
|
||||
import { isThemeChoice, resolveTheme, type ThemeChoice } from './theme'
|
||||
import { mergeSurfacesByRevision, surfaceKey } from './surfaceKey'
|
||||
import { rdmAuditReady } from './workspaces'
|
||||
|
||||
type FontSizeChoice = 13 | 14 | 16 | 18
|
||||
const FONT_SIZE_CHOICES: readonly FontSizeChoice[] = [13, 14, 16, 18]
|
||||
@@ -27,6 +29,7 @@ import type {
|
||||
Project,
|
||||
Run,
|
||||
StreamEvent,
|
||||
SurfaceMeta,
|
||||
SurfaceSnapshot,
|
||||
TodoList
|
||||
} from './contracts'
|
||||
@@ -58,6 +61,8 @@ export interface UIState {
|
||||
rightTab: 'work' | 'files'
|
||||
/** 文件入口子视图:列表 / 预览(预览标签存在时才有意义)。 */
|
||||
filesView: 'list' | 'preview'
|
||||
/** U6:工作区任务页固定(surfaceId;null=跟随最新;固定后新页面不抢占显示)。 */
|
||||
workspaceSurfacePin: string | null
|
||||
/** 收件箱筛选:全部 / 待处理。铃铛打开时切到待处理。 */
|
||||
inboxFilter: 'all' | 'todo'
|
||||
theme: ThemeChoice
|
||||
@@ -99,6 +104,8 @@ interface AppState {
|
||||
artifacts: Artifact[]
|
||||
/** generative-ui:会话内已保存页面(surface.updated 投影;按 surfaceId 存最新修订)。 */
|
||||
surfaces: SurfaceSnapshot[]
|
||||
/** generative-ui U6:项目级跨会话页面聚合(打开项目时权威装载)。 */
|
||||
projectSurfaces: SurfaceMeta[]
|
||||
cards: ActivityCard[]
|
||||
projectCards: ActivityCard[]
|
||||
inboxScope: 'conv' | 'project'
|
||||
@@ -107,6 +114,10 @@ interface AppState {
|
||||
profiles: AgentProfile[]
|
||||
pluginPanels: PluginPanel[]
|
||||
serverWorkspaces: { id: string; title: string; icon?: string; shortcut?: string; body?: any[] }[]
|
||||
/** 审计入口能力(增量 2):GET /api/commands 含全部 rdm.inspect.* 且启用;探测失败/未登录为 false,入口隐藏。 */
|
||||
rdmAuditAvailable: boolean
|
||||
/** 管理区声明分区(WP3-I3):编译插件声明,入口全员可见、区内置权限不合并。 */
|
||||
adminSections: AdminZoneSection[]
|
||||
ui: UIState
|
||||
connected: boolean
|
||||
/** 发送请求已经出去,run 记录还没回来。 */
|
||||
@@ -125,7 +136,7 @@ const DEFAULT_UI: UIState = {
|
||||
rightWidth: 360,
|
||||
rightCollapsed: false,
|
||||
rightTab: 'work',
|
||||
filesView: 'list',
|
||||
filesView: 'list', workspaceSurfacePin: null,
|
||||
inboxFilter: 'all',
|
||||
activeWorkspace: null,
|
||||
theme: 'dark',
|
||||
@@ -168,8 +179,10 @@ let state: AppState = {
|
||||
contextObs: {},
|
||||
projects: [], currentProjectId: null, tasks: [], me: null, currentConvId: 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: [],
|
||||
rdmAuditAvailable: false,
|
||||
adminSections: [],
|
||||
ui: loadUI(), connected: false, posting: false, error: null, toast: null,
|
||||
outbox: [], outboxDraining: false,
|
||||
previewTabs: {}, previewActive: {},
|
||||
@@ -374,7 +387,7 @@ export const store = {
|
||||
|
||||
async openConversation(convId: string) {
|
||||
if (sse) { sse.close(); sse = null }
|
||||
set({ currentConvId: convId, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null, plan: null, todos: null, cards: [], 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)
|
||||
// 空态时打开的预览标签('_' 键)随会话进入一起迁移,不留在不可见的孤儿键下。
|
||||
if ((state.previewTabs['_'] ?? []).length > 0) {
|
||||
@@ -385,22 +398,31 @@ export const store = {
|
||||
})
|
||||
}
|
||||
// U4/R5:已保存页面从权威快照重载(刷新/断线恢复;事件流只做增量投影)。
|
||||
api.surfaceList(convId).then(list => {
|
||||
// surfaceList 返回扁平 SurfaceMeta,不可写入 surfaces;必须经 surfaceGet 水合为 Snapshot。
|
||||
api.projectSurfaces(state.currentProjectId ?? '').then(list => { if (state.currentConvId === convId) set({ projectSurfaces: list ?? [] }) }).catch(() => {})
|
||||
api.surfaceList(convId).then(async list => {
|
||||
if (state.currentConvId !== convId) return
|
||||
set({ surfaces: list ?? [] })
|
||||
const metas = list ?? []
|
||||
// 刷新/重进恢复:有页面且尚无页面标签时补开最新一张的标签(不强切
|
||||
// 右栏——只在实时事件到达时切换,避免每次进会话劫持视图)。
|
||||
// 右栏——只在实时事件到达时切换,避免每次进会话劫持视图)。标签只用 Meta。
|
||||
const conv = previewTabsKey(state.currentConvId)
|
||||
const tabs = state.previewTabs[conv] ?? []
|
||||
const hasSurfaceTab = tabs.some(t => t.id.startsWith('surface:'))
|
||||
if (!hasSurfaceTab && (list?.length ?? 0) > 0) {
|
||||
const latest = list[0]
|
||||
if (!hasSurfaceTab && metas.length > 0) {
|
||||
const latest = metas[0]
|
||||
const opened = openPreviewTab(tabs, { id: 'surface:' + String(latest.surfaceId), name: latest.title || '页面' })
|
||||
set({
|
||||
previewTabs: { ...state.previewTabs, [conv]: opened.tabs },
|
||||
previewActive: { ...state.previewActive, [conv]: opened.activeId },
|
||||
})
|
||||
}
|
||||
const fetched = await Promise.all(metas.map(m =>
|
||||
api.surfaceGet(String(m.surfaceId)).catch(() => null),
|
||||
))
|
||||
if (state.currentConvId !== convId) return
|
||||
const hydrated = fetched.filter((s): s is SurfaceSnapshot => surfaceKey(s) != null)
|
||||
// 慢水合不得覆盖并发 surface.updated 已写入的较新 revision。
|
||||
set({ surfaces: mergeSurfacesByRevision(state.surfaces, hydrated) })
|
||||
}).catch(() => {})
|
||||
const resumeSeq = await store.reconcile()
|
||||
store.connectLive(resumeSeq ?? state.lastSeq)
|
||||
@@ -527,10 +549,10 @@ export const store = {
|
||||
// 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)
|
||||
const id = surfaceKey(p)
|
||||
if (!id) break
|
||||
const surfaces = state.surfaces.filter(x => surfaceKey(x) !== id)
|
||||
const existing = state.surfaces.find(x => surfaceKey(x) === id)
|
||||
if (!existing || p.revision > existing.revision) {
|
||||
surfaces.push(p)
|
||||
set({ surfaces })
|
||||
@@ -778,6 +800,15 @@ export const store = {
|
||||
const ws = await api.listWorkspaces()
|
||||
set({ serverWorkspaces: ws ?? [] })
|
||||
} catch { /* ignore */ }
|
||||
// 审计入口能力门控(增量 2):与工作区清单同源刷新——能力撤销(插件卸载/命令停用)后入口随之隐藏。
|
||||
try {
|
||||
const cmds = await api.listCommands()
|
||||
set({ rdmAuditAvailable: rdmAuditReady(cmds) })
|
||||
} catch { set({ rdmAuditAvailable: false }) }
|
||||
try {
|
||||
const sections = await api.adminZoneSections()
|
||||
set({ adminSections: sections ?? [] })
|
||||
} catch { /* ignore */ }
|
||||
try {
|
||||
const settings = await api.getSettings()
|
||||
const runtime = settings.user.runtime
|
||||
@@ -912,7 +943,7 @@ export const store = {
|
||||
},
|
||||
openSurface(surfaceId: string) {
|
||||
// generative-ui:页面进预览第三栏(标签 id 带 surface: 前缀区分工件)。
|
||||
const snap = state.surfaces.find(x => String(x.identity.surfaceId) === surfaceId)
|
||||
const snap = state.surfaces.find(x => surfaceKey(x) === surfaceId)
|
||||
const conv = previewTabsKey(state.currentConvId)
|
||||
const opened = openPreviewTab(state.previewTabs[conv] ?? [], { id: 'surface:' + surfaceId, name: snap?.spec?.title || '页面' })
|
||||
set({
|
||||
@@ -926,7 +957,7 @@ export const store = {
|
||||
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)
|
||||
const snap = state.surfaces.find(x => surfaceKey(x) === surfaceId)
|
||||
if (!snap) { store.toast('页面快照不可用,无法提交'); return }
|
||||
const requestId = (crypto.randomUUID ? crypto.randomUUID() : 'req-' + Date.now() + '-' + Math.random().toString(36).slice(2))
|
||||
surfaceActionsInflight[surfaceId + ':' + actionId] = true
|
||||
|
||||
@@ -628,3 +628,6 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
|
||||
.rdm-audit-event-detail { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-dim); }
|
||||
.rdm-audit-coverage { margin-top: 12px; }
|
||||
.rdm-audit-coverage-item { display: flex; align-items: center; gap: 8px; padding: 3px 0; font-size: var(--fs-ui-sm); color: var(--text-dim); }
|
||||
.rdm-audit-err { display: flex; flex-direction: column; gap: 8px; }
|
||||
.rdm-audit-err-toggle { font-size: var(--fs-ui-caption); color: var(--text-dim); }
|
||||
.rdm-audit-err-raw { display: block; font-family: var(--mono); font-size: var(--fs-code); color: var(--text-dim); background: var(--bg-inset); border-radius: var(--radius-sm); padding: 8px 12px; white-space: pre-wrap; word-break: break-all; }
|
||||
|
||||
@@ -57,3 +57,21 @@ describe('surface.updated 可见性联动', () => {
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
// surface 投影可能短暂混入无 identity 的条目;读 id 一律经此助手,避免渲染崩溃。
|
||||
import type { SurfaceSnapshot } from './contracts'
|
||||
|
||||
export function surfaceKey(x: { identity?: { surfaceId?: unknown } } | null | undefined): string | null {
|
||||
const id = x?.identity?.surfaceId
|
||||
return id == null || id === '' ? null : String(id)
|
||||
}
|
||||
|
||||
/** 按 surfaceId 合并;同 id 取 revision 较大者。跳过无 identity 的脏项。 */
|
||||
export function mergeSurfacesByRevision(a: SurfaceSnapshot[], b: SurfaceSnapshot[]): SurfaceSnapshot[] {
|
||||
const byId = new Map<string, SurfaceSnapshot>()
|
||||
for (const s of [...a, ...b]) {
|
||||
const id = surfaceKey(s)
|
||||
if (!id) continue
|
||||
const existing = byId.get(id)
|
||||
if (!existing || (s.revision ?? 0) >= (existing.revision ?? 0)) byId.set(id, s)
|
||||
}
|
||||
return [...byId.values()]
|
||||
}
|
||||
+27
-2
@@ -1,6 +1,9 @@
|
||||
// 体系工具排位:服务端声明的工作区对所有用户可见,按 shortcut 数字占位;
|
||||
// 内置「代理」补进空位。文件浏览在第三栏「文件」入口(方案:入口收敛),不再占体系工具位。
|
||||
// 插件管理面板不进这条清单。
|
||||
// 2026-10-12 增量 2(审计入口能力门控):「追溯审计」不再无条件内置——普通基座
|
||||
// 没有任何 rdm.inspect.* 命令,入口可达而进入即 400。仅当命令面探测通过
|
||||
//(rdmAuditReady / store.rdmAuditAvailable)才纳入;代理仍无条件以 ⌘1 补位。
|
||||
|
||||
export interface WorkspaceEntry {
|
||||
key: string
|
||||
@@ -16,6 +19,23 @@ export interface WorkspaceSource {
|
||||
shortcut?: string
|
||||
}
|
||||
|
||||
/** 审计工作区消费的全部 rdm.inspect.* 命令(capabilities 入口 + 四页签取数)。 */
|
||||
export const RDM_AUDIT_COMMANDS = [
|
||||
'rdm.inspect.capabilities',
|
||||
'rdm.inspect.summary',
|
||||
'rdm.inspect.tree',
|
||||
'rdm.inspect.object',
|
||||
'rdm.inspect.relations',
|
||||
'rdm.inspect.events',
|
||||
'rdm.inspect.history',
|
||||
] as const
|
||||
|
||||
/** GET /api/commands 清单是否足以支撑审计工作区:七命令齐备且 enabled。清单不可用(未登录/请求失败)视为未就绪。 */
|
||||
export function rdmAuditReady(cmds: { id?: string; enabled?: boolean }[] | null | undefined): boolean {
|
||||
if (!Array.isArray(cmds)) return false
|
||||
return RDM_AUDIT_COMMANDS.every(need => cmds.some(c => c?.id === need && c.enabled === true))
|
||||
}
|
||||
|
||||
const BUILTINS: Omit<WorkspaceEntry, 'shortcut'>[] = [
|
||||
{ key: 'agents', title: '代理', icon: '🤖' },
|
||||
{ key: 'rdm-audit', title: '追溯审计', icon: '🔍' },
|
||||
@@ -29,7 +49,12 @@ function shortcutNum(raw?: string): number | null {
|
||||
return n >= 1 && n <= 9 ? n : null
|
||||
}
|
||||
|
||||
export function buildWorkspaceEntries(server: WorkspaceSource[]): WorkspaceEntry[] {
|
||||
interface BuildWorkspaceOptions {
|
||||
/** rdm.inspect.* 命令面探测通过(store.rdmAuditAvailable)。默认 false:能力未知/缺失不展示审计入口。 */
|
||||
rdmAudit?: boolean
|
||||
}
|
||||
|
||||
export function buildWorkspaceEntries(server: WorkspaceSource[], opts: BuildWorkspaceOptions = {}): WorkspaceEntry[] {
|
||||
const used = new Set<number>()
|
||||
const ranked: { rank: number; entry: Omit<WorkspaceEntry, 'shortcut'> }[] = []
|
||||
const unranked: Omit<WorkspaceEntry, 'shortcut'>[] = []
|
||||
@@ -57,7 +82,7 @@ export function buildWorkspaceEntries(server: WorkspaceSource[]): WorkspaceEntry
|
||||
return 99
|
||||
}
|
||||
ranked.push({ rank: takeFree(1), entry: BUILTINS[0] })
|
||||
ranked.push({ rank: takeFree(2), entry: BUILTINS[1] })
|
||||
if (opts.rdmAudit === true) ranked.push({ rank: takeFree(2), entry: BUILTINS[1] })
|
||||
for (const entry of unranked) ranked.push({ rank: takeFree(9), entry })
|
||||
return ranked
|
||||
.filter(item => item.rank <= 9)
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { classifyRdmError } from '../src/rdmErrors'
|
||||
|
||||
// 2026-10-12 增量 2(审计错误四分类):审计页不再把原始报错(400 回执 JSON)直出,
|
||||
// 改为「能力缺失 / 登录已失效 / 查询失败」文案并保留原始回执供诊断;
|
||||
// 「无可审计项目」由组件空态承担,不走本分类。
|
||||
// 分类依据:api.ts json() 抛 `${status}: ${body}`;后端 unknown/disabled 命令
|
||||
// 回 400 + "unknown or disabled command"(cmdbus.go Dispatch),总线未挂载回
|
||||
// 503 "commands disabled"(server.go execCommand)。
|
||||
describe('classifyRdmError', () => {
|
||||
it('400 unknown or disabled command → 能力缺失', () => {
|
||||
const raw = '400: {"receipt":{"state":"rejected","error":"unknown or disabled command: rdm.inspect.capabilities"}}'
|
||||
const e = classifyRdmError(new Error(raw))
|
||||
expect(e.kind).toBe('capability')
|
||||
expect(e.message).toContain('当前服务未提供审计能力')
|
||||
expect(e.raw).toContain('unknown or disabled command')
|
||||
})
|
||||
|
||||
it('503 commands disabled → 能力缺失', () => {
|
||||
expect(classifyRdmError('503: commands disabled').kind).toBe('capability')
|
||||
})
|
||||
|
||||
it('401/403 → 登录已失效', () => {
|
||||
expect(classifyRdmError('401: session expired').kind).toBe('auth')
|
||||
expect(classifyRdmError(new Error('403: forbidden')).kind).toBe('auth')
|
||||
})
|
||||
|
||||
it('其余错误 → 查询失败,保留原始串', () => {
|
||||
const e = classifyRdmError('500: boom')
|
||||
expect(e.kind).toBe('query')
|
||||
expect(e.message).toContain('查询失败')
|
||||
expect(e.raw).toBe('500: boom')
|
||||
})
|
||||
|
||||
it('非 Error 输入可容忍', () => {
|
||||
expect(classifyRdmError(undefined).kind).toBe('query')
|
||||
expect(classifyRdmError({ weird: true }).kind).toBe('query')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { settingInputAttrs } from '../src/components/SettingsModal'
|
||||
|
||||
// 2026-10-12 增量 3:配置输入按字段类型渲染——此前 SettingInput 默认分支无条件
|
||||
// type="password",浏览器把整排设置项当凭据表单(「password 不在 form 中/缺少
|
||||
// username」警告的来源之一),且普通文本被无谓遮蔽。新契约:仅秘密字段用密码框。
|
||||
describe('settingInputAttrs', () => {
|
||||
it('secret=true → 密码框 + new-password', () => {
|
||||
expect(settingInputAttrs({ key: 'llm.apiKey', label: '密钥', type: 'string', secret: true } as any))
|
||||
.toEqual({ type: 'password', autoComplete: 'new-password' })
|
||||
})
|
||||
|
||||
it('type=secret → 密码框', () => {
|
||||
expect(settingInputAttrs({ key: 'x', label: 'X', type: 'secret' } as any).type).toBe('password')
|
||||
})
|
||||
|
||||
it('普通文本 → text + off(不遮蔽、不当凭据)', () => {
|
||||
const a = settingInputAttrs({ key: 'llm.baseUrl', label: '地址', type: 'string' } as any)
|
||||
expect(a).toEqual({ type: 'text', autoComplete: 'off' })
|
||||
})
|
||||
|
||||
it('无类型标注的默认项 → text', () => {
|
||||
expect(settingInputAttrs({ key: 'ui.language', label: '语言' } as any).type).toBe('text')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { SurfaceSnapshot } from '../src/contracts'
|
||||
import { mergeSurfacesByRevision, surfaceKey } from '../src/surfaceKey'
|
||||
|
||||
function snap(id: string, revision: number): SurfaceSnapshot {
|
||||
return {
|
||||
identity: {
|
||||
surfaceId: id,
|
||||
owner: 'o',
|
||||
pluginId: 'p',
|
||||
},
|
||||
compat: { schemaVersion: 1, catalogVersion: '1' },
|
||||
revision,
|
||||
activationEpoch: 0,
|
||||
spec: { title: id, nodes: [] },
|
||||
}
|
||||
}
|
||||
|
||||
describe('surfaceKey', () => {
|
||||
it('reads nested identity and skips missing', () => {
|
||||
expect(surfaceKey(snap('s1', 1))).toBe('s1')
|
||||
expect(surfaceKey(undefined)).toBeNull()
|
||||
expect(surfaceKey({} as SurfaceSnapshot)).toBeNull()
|
||||
expect(surfaceKey({ identity: {} } as SurfaceSnapshot)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('mergeSurfacesByRevision', () => {
|
||||
it('keeps higher revision and drops dirty entries', () => {
|
||||
const dirty = { revision: 99 } as SurfaceSnapshot
|
||||
const merged = mergeSurfacesByRevision(
|
||||
[snap('a', 1), dirty],
|
||||
[snap('a', 3), snap('b', 1)],
|
||||
)
|
||||
expect(merged.map(s => [surfaceKey(s), s.revision])).toEqual([
|
||||
['a', 3],
|
||||
['b', 1],
|
||||
])
|
||||
})
|
||||
})
|
||||
+47
-8
@@ -1,36 +1,75 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildWorkspaceEntries } from '../src/workspaces'
|
||||
import { buildWorkspaceEntries, rdmAuditReady, RDM_AUDIT_COMMANDS } from '../src/workspaces'
|
||||
|
||||
// 三栏工作台(2026-10-11):文件浏览并入第三栏「文件」入口;体系工具内置
|
||||
// 「代理」与「追溯审计」(U4 修复:第二个 builtin 此前未入列,入口不可达)。
|
||||
// 三栏工作台(2026-10-11):文件浏览并入第三栏「文件」入口;体系工具内置「代理」。
|
||||
// 2026-10-12 增量 2(审计入口能力门控):「追溯审计」不再无条件内置——普通基座
|
||||
// 没有任何 rdm.inspect.* 命令,入口展示后进入即 400。新契约:仅当命令面探测
|
||||
// 通过(opts.rdmAudit)才纳入 rdm-audit;代理仍无条件以 ⌘1 补位。
|
||||
describe('buildWorkspaceEntries', () => {
|
||||
it('没有领域插件时,代理补 ⌘1、追溯审计补 ⌘2', () => {
|
||||
it('没有领域插件且无 RDM 能力:只有代理占 ⌘1', () => {
|
||||
const entries = buildWorkspaceEntries([])
|
||||
expect(entries.map(e => `${e.shortcut} ${e.title}`)).toEqual(['⌘1 代理'])
|
||||
})
|
||||
|
||||
it('没有领域插件且 RDM 能力通过:代理 ⌘1、追溯审计 ⌘2', () => {
|
||||
const entries = buildWorkspaceEntries([], { rdmAudit: true })
|
||||
expect(entries.map(e => `${e.shortcut} ${e.title}`)).toEqual(['⌘1 代理', '⌘2 追溯审计'])
|
||||
})
|
||||
|
||||
it('声明了 shortcut 的工作区占住该位,内置工具让位', () => {
|
||||
it('声明了 shortcut 的工作区占住该位,内置工具让位(能力开)', () => {
|
||||
const entries = buildWorkspaceEntries([
|
||||
{ id: 'trace', title: '追溯网', shortcut: '1' },
|
||||
])
|
||||
], { rdmAudit: true })
|
||||
expect(entries.map(e => e.title)).toEqual(['追溯网', '代理', '追溯审计'])
|
||||
expect(entries.map(e => e.shortcut)).toEqual(['⌘1', '⌘2', '⌘3'])
|
||||
expect(entries[0].key).toBe('ws:trace')
|
||||
})
|
||||
|
||||
it('无 RDM 能力:让位后只有代理,不出现追溯审计', () => {
|
||||
const entries = buildWorkspaceEntries([
|
||||
{ id: 'trace', title: '追溯网', shortcut: '1' },
|
||||
])
|
||||
expect(entries.map(e => e.title)).toEqual(['追溯网', '代理'])
|
||||
})
|
||||
|
||||
it('未编号的工作区排在内置工具后面', () => {
|
||||
const entries = buildWorkspaceEntries([
|
||||
{ id: 'mem', title: '记忆库' },
|
||||
])
|
||||
], { rdmAudit: true })
|
||||
expect(entries.map(e => e.title)).toEqual(['代理', '追溯审计', '记忆库'])
|
||||
})
|
||||
|
||||
it('九位占满时低序内置保留,溢出项截断', () => {
|
||||
const server = Array.from({ length: 8 }, (_, i) => ({ id: `w${i}`, title: `w${i}`, shortcut: String(i + 1) }))
|
||||
const entries = buildWorkspaceEntries(server)
|
||||
const entries = buildWorkspaceEntries(server, { rdmAudit: true })
|
||||
// ⌘1–8 被声明占住;代理取 ⌘9;追溯审计溢出截断(slice 0,9)。
|
||||
expect(entries.filter(e => e.key.startsWith('ws:'))).toHaveLength(8)
|
||||
expect(entries.map(e => e.title)).toContain('代理')
|
||||
expect(entries.map(e => e.title)).not.toContain('追溯审计')
|
||||
})
|
||||
})
|
||||
|
||||
// 能力探测依据后端 GET /api/commands(server.go listCommands:{ id, title, …, enabled })。
|
||||
// 审计工作区消费七个 rdm.inspect.* 命令;任一缺失或 enabled=false 都视为能力未就绪。
|
||||
describe('rdmAuditReady', () => {
|
||||
const all = RDM_AUDIT_COMMANDS.map(id => ({ id, enabled: true }))
|
||||
|
||||
it('七命令齐备且启用 → true', () => {
|
||||
expect(rdmAuditReady(all)).toBe(true)
|
||||
})
|
||||
|
||||
it('普通基座空清单 / 缺任一命令 → false', () => {
|
||||
expect(rdmAuditReady([])).toBe(false)
|
||||
expect(rdmAuditReady(all.filter(c => c.id !== 'rdm.inspect.history'))).toBe(false)
|
||||
})
|
||||
|
||||
it('存在但 enabled=false → false', () => {
|
||||
const disabled = all.map(c => (c.id === 'rdm.inspect.capabilities' ? { ...c, enabled: false } : c))
|
||||
expect(rdmAuditReady(disabled)).toBe(false)
|
||||
})
|
||||
|
||||
it('清单不可用(未登录/请求失败)→ false,不展示入口', () => {
|
||||
expect(rdmAuditReady(null)).toBe(false)
|
||||
expect(rdmAuditReady(undefined)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user