Compare commits

...
7 changed files with 838 additions and 42 deletions
+4 -4
View File
@@ -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 { commandRequestBody, rdmCommandProjectId, rdmProjectCommandError, type CommandSource } from './rdmManagement' import { ConfirmCardPending, commandRequestBody, rdmCommandProjectId, rdmProjectCommandError, type CommandSource } from './rdmManagement'
import type { import type {
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,
@@ -292,10 +292,10 @@ export const api = {
// ---- U4:RDM 命令总线封装(POST /api/commands/{id},同 RDM web 的 command 路由) ---- // ---- U4:RDM 命令总线封装(POST /api/commands/{id},同 RDM web 的 command 路由) ----
// receipt 解包:rejected 抛错;confirm-card 抛提示(审计面只读命令不应触发)。 // receipt 解包:rejected 抛错;confirm-card 抛提示(审计面只读命令不应触发)。
rdmProjectCommand: async <T = any>(project: Project, id: string, params: Record<string, unknown> = {}): Promise<T> => { rdmProjectCommand: async <T = any>(project: Project, id: string, params: Record<string, unknown> = {}, source?: CommandSource): Promise<T> => {
const error = rdmProjectCommandError(project, id) const error = rdmProjectCommandError(project, id)
if (error) throw new Error(error) if (error) throw new Error(error)
return api.rdmCommand<T>(id, { ...params, projectId: rdmCommandProjectId(project) }) return api.rdmCommand<T>(id, { ...params, projectId: rdmCommandProjectId(project) }, source)
}, },
rdmCommand: <T = any>(id: string, params: Record<string, unknown> = {}, source?: CommandSource): Promise<T> => rdmCommand: <T = any>(id: string, params: Record<string, unknown> = {}, source?: CommandSource): Promise<T> =>
auth.fetch(`${BASE}/api/commands/${encodeURIComponent(id)}`, { auth.fetch(`${BASE}/api/commands/${encodeURIComponent(id)}`, {
@@ -305,7 +305,7 @@ export const api = {
.then(out => { .then(out => {
const envelope = out as { receipt?: { state?: string; error?: string; message?: string }; card?: unknown; payload?: T } const envelope = out as { receipt?: { state?: string; error?: string; message?: string }; card?: unknown; payload?: T }
if (envelope?.receipt?.state === 'rejected') throw new Error(envelope.receipt.error || envelope.receipt.message || '命令被拒绝') if (envelope?.receipt?.state === 'rejected') throw new Error(envelope.receipt.error || envelope.receipt.message || '命令被拒绝')
if (envelope?.card) throw new Error('高危命令已生成确认卡——请在对话中确认执行') if (envelope?.card) throw new ConfirmCardPending(String((envelope.card as { id?: unknown }).id ?? ''))
if (envelope && envelope.payload !== undefined) return envelope.payload if (envelope && envelope.payload !== undefined) return envelope.payload
return out as T return out as T
}), }),
+26 -6
View File
@@ -9,9 +9,13 @@ import type { Conversation } from '../contracts'
import { relativeTime, filterTasks } from '../tasklist' import { relativeTime, filterTasks } from '../tasklist'
import { projectStage, projectStateLabel, projectWorkError } from '../projectmeta' import { projectStage, projectStateLabel, projectWorkError } from '../projectmeta'
import { buildWorkspaceEntries } from '../workspaces' import { buildWorkspaceEntries } from '../workspaces'
import { WORK_STATE_NOTICES, projectWorkClosed } from '../rdmManagement'
import { RdmTemplateDocsPanel } from './adminRdmSections'
const WORK_CLOSED_LABEL = '工作已关闭'
/** 小型弹出菜单:锚点下方渲染,点击外部关闭。 */ /** 小型弹出菜单:锚点下方渲染,点击外部关闭。 */
function Menu({ anchor, onClose, children }: { anchor: HTMLElement | null; onClose: () => void; children: React.ReactNode }) { function Menu({ anchor, onClose, className, children }: { anchor: HTMLElement | null; onClose: () => void; className?: string; children: React.ReactNode }) {
const ref = useRef<HTMLDivElement>(null) const ref = useRef<HTMLDivElement>(null)
useEffect(() => { useEffect(() => {
if (!anchor || !ref.current) return if (!anchor || !ref.current) return
@@ -27,7 +31,7 @@ function Menu({ anchor, onClose, children }: { anchor: HTMLElement | null; onClo
return () => window.removeEventListener('mousedown', h) return () => window.removeEventListener('mousedown', h)
}, [anchor]) }, [anchor])
if (!anchor) return null if (!anchor) return null
return <div ref={ref} className="tsb-menu" onClick={e => e.stopPropagation()}>{children}</div> return <div ref={ref} className={`tsb-menu${className ? ` ${className}` : ''}`} onClick={e => e.stopPropagation()}>{children}</div>
} }
function MenuItem({ onClick, danger, icon, children }: { onClick: () => void; danger?: boolean; icon?: Parameters<typeof Icon>[0]['name']; children: React.ReactNode }) { function MenuItem({ onClick, danger, icon, children }: { onClick: () => void; danger?: boolean; icon?: Parameters<typeof Icon>[0]['name']; children: React.ReactNode }) {
@@ -58,13 +62,14 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
/** 审计入口能力门控(增量 2):命令面探测通过才展示「追溯审计」。 */ /** 审计入口能力门控(增量 2):命令面探测通过才展示「追溯审计」。 */
const rdmAuditAvailable = useStore(s => s.rdmAuditAvailable) const rdmAuditAvailable = useStore(s => s.rdmAuditAvailable)
const rdmCockpitAvailable = useStore(s => s.rdmCockpitAvailable) const rdmCockpitAvailable = useStore(s => s.rdmCockpitAvailable)
const rdmDocTemplatesAvailable = useStore(s => s.rdmDocTemplatesAvailable)
const theme = useStore(s => s.ui.theme) const theme = useStore(s => s.ui.theme)
const pendingBadge = useStore(s => const pendingBadge = useStore(s =>
s.cards.filter(c => c.status === 'action_required').length + s.cards.filter(c => c.status === 'action_required').length +
s.projectCards.filter(c => c.status === 'action_required').length) s.projectCards.filter(c => c.status === 'action_required').length)
const [q, setQ] = useState('') const [q, setQ] = useState('')
const [menu, setMenu] = useState<{ kind: 'new' | 'account' | 'project' | `row:${string}`; anchor: HTMLElement } | null>(null) const [menu, setMenu] = useState<{ kind: 'new' | 'account' | 'project' | 'templates' | `row:${string}`; anchor: HTMLElement } | null>(null)
const [creatingProject, setCreatingProject] = useState(false) const [creatingProject, setCreatingProject] = useState(false)
const [newProjectName, setNewProjectName] = useState('') const [newProjectName, setNewProjectName] = useState('')
@@ -82,6 +87,7 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
const currentProject = projects.find(p => p.id === currentProjectId) const currentProject = projects.find(p => p.id === currentProjectId)
const stage = projectStateLabel(currentProject?.settings) ?? projectStage(currentProject?.settings) const stage = projectStateLabel(currentProject?.settings) ?? projectStage(currentProject?.settings)
const workError = currentProject ? projectWorkError(currentProject) : null const workError = currentProject ? projectWorkError(currentProject) : null
const workClosed = projectWorkClosed(currentProject)
const rowTask = menu?.kind.startsWith('row:') ? tasks.find(t => t.id === menu.kind.slice(4)) : null const rowTask = menu?.kind.startsWith('row:') ? tasks.find(t => t.id === menu.kind.slice(4)) : null
const rowMenuAnchor = menu && menu.kind.startsWith('row:') ? menu.anchor : null const rowMenuAnchor = menu && menu.kind.startsWith('row:') ? menu.anchor : null
@@ -131,6 +137,7 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
<Icon name="folder" size={15} /> <Icon name="folder" size={15} />
<span className="tsb-projrow-name">{currentProject?.name ?? '选择项目'}</span> <span className="tsb-projrow-name">{currentProject?.name ?? '选择项目'}</span>
{stage && <span className="tsb-projrow-stage">{stage}</span>} {stage && <span className="tsb-projrow-stage">{stage}</span>}
{workClosed && <span className="tsb-projrow-stage" title={WORK_STATE_NOTICES.closed}>{WORK_CLOSED_LABEL}</span>}
<span className="tsb-user-chev"><Icon name="chevron-down" size={13} /></span> <span className="tsb-user-chev"><Icon name="chevron-down" size={13} /></span>
</button> </button>
<button className="tsb-iconbtn" title="收起侧栏(⌘B)" onClick={onCollapse}><Icon name="panel-left" /></button> <button className="tsb-iconbtn" title="收起侧栏(⌘B)" onClick={onCollapse}><Icon name="panel-left" /></button>
@@ -139,7 +146,8 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
{/* 主入口创建会话;项目创建保留在次级菜单。 */} {/* 主入口创建会话;项目创建保留在次级菜单。 */}
<div className="tsb-actions"> <div className="tsb-actions">
<div className="tsb-newtask"> <div className="tsb-newtask">
<button className="tsb-newtask-main" title="在当前项目新建对话(⌘N)" onClick={() => createConversation()}> <button className="tsb-newtask-main" disabled={workClosed}
title={workClosed ? WORK_STATE_NOTICES.closed : '在当前项目新建对话(⌘N)'} onClick={() => createConversation()}>
<Icon name="plus" size={15} />新建对话 <Icon name="plus" size={15} />新建对话
</button> </button>
<button className="tsb-newtask-caret" title="更多创建选项" <button className="tsb-newtask-caret" title="更多创建选项"
@@ -185,7 +193,7 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
)} )}
{rest.length === 0 && pinned.length === 0 && ( {rest.length === 0 && pinned.length === 0 && (
<div className="tsb-empty">{workError ?? '从「新建对话」开始,描述你想完成的工作。'}</div> <div className="tsb-empty">{workError ?? (workClosed ? WORK_STATE_NOTICES.closed : '从「新建对话」开始,描述你想完成的工作。')}</div>
)} )}
{rest.map(t => taskRow(t))} {rest.map(t => taskRow(t))}
</div> </div>
@@ -197,6 +205,13 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
<span className="tsb-tool-name">文件</span> <span className="tsb-tool-name">文件</span>
{artifacts.length > 0 && <span className="tsb-group-cnt">{artifacts.length}</span>} {artifacts.length > 0 && <span className="tsb-group-cnt">{artifacts.length}</span>}
</button> </button>
{currentProject && rdmDocTemplatesAvailable && (
<button className="tsb-tool" title="浏览已发布文档模板,逐条显式选择后生成项目文档"
onClick={e => { const el = e.currentTarget; setMenu(m => m?.kind === 'templates' ? null : { kind: 'templates', anchor: el }) }}>
<span className="tsb-tool-icon"><Icon name="file" size={14} /></span>
<span className="tsb-tool-name">按模板生成文档</span>
</button>
)}
{tools.map(entry => ( {tools.map(entry => (
<button key={entry.key} className="tsb-tool" title={entry.shortcut} onClick={() => store.openWorkspace(entry.key)}> <button key={entry.key} className="tsb-tool" title={entry.shortcut} onClick={() => store.openWorkspace(entry.key)}>
<span className="tsb-tool-icon">{entry.icon}</span> <span className="tsb-tool-icon">{entry.icon}</span>
@@ -231,10 +246,11 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
{projects.map(p => { {projects.map(p => {
const count = tasks.filter(t => t.projectId === p.id && !t.archived).length const count = tasks.filter(t => t.projectId === p.id && !t.archived).length
const pstage = projectStateLabel(p.settings) ?? projectStage(p.settings) const pstage = projectStateLabel(p.settings) ?? projectStage(p.settings)
const pclosed = projectWorkClosed(p) ? `${WORK_CLOSED_LABEL} · ` : ''
return ( return (
<MenuItem key={p.id} icon="folder" onClick={() => { switchProject(p.id); setMenu(null) }}> <MenuItem key={p.id} icon="folder" onClick={() => { switchProject(p.id); setMenu(null) }}>
{p.name} {p.name}
<kbd>{p.id === currentProjectId ? '当前' : pstage ? `${pstage} · ` : ''}{count} 会话</kbd> <kbd>{pclosed}{p.id === currentProjectId ? '当前' : pstage ? `${pstage} · ` : ''}{count} 会话</kbd>
</MenuItem> </MenuItem>
) )
})} })}
@@ -243,6 +259,10 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
<MenuItem icon="plus" onClick={() => { setCreatingProject(true); setMenu(null) }}>新建项目…</MenuItem> <MenuItem icon="plus" onClick={() => { setCreatingProject(true); setMenu(null) }}>新建项目…</MenuItem>
</Menu> </Menu>
<Menu anchor={menu?.kind === 'templates' ? menu.anchor : null} onClose={() => setMenu(null)} className="tsb-tpl-pop">
{currentProject && <RdmTemplateDocsPanel key={currentProject.id} project={currentProject} />}
</Menu>
<Menu anchor={menu?.kind === 'new' ? menu.anchor : null} onClose={() => setMenu(null)}> <Menu anchor={menu?.kind === 'new' ? menu.anchor : null} onClose={() => setMenu(null)}>
<MenuItem icon="plus" onClick={() => { createConversation(); setMenu(null) }}>新建对话 · 当前项目<kbd>⌘N</kbd></MenuItem> <MenuItem icon="plus" onClick={() => { createConversation(); setMenu(null) }}>新建对话 · 当前项目<kbd>⌘N</kbd></MenuItem>
<div className="tsb-menu-div" /> <div className="tsb-menu-div" />
+283 -26
View File
@@ -6,13 +6,220 @@ import { useEffect, useState, useRef, useCallback, useMemo } from 'react'
import { api } from '../api' import { api } from '../api'
import type { Project } from '../contracts' import type { Project } from '../contracts'
import { projectWorkError } from '../projectmeta' import { projectWorkError } from '../projectmeta'
import { useStore } from '../store' import { store, useStore } from '../store'
import { Btn, Collapsible, Icon, Picker } from '@ui' import { Btn, Collapsible, Icon, Picker } from '@ui'
import { import {
memberAddPayload, memberRemovePayload, memberSetRolePayload, memberAddPayload, memberRemovePayload, memberSetRolePayload,
permCheckPayload, describeSource, permCheckPayload, describeSource,
ConfirmCardPending, WORK_STATE_BADGES, WORK_STATE_NOTICES, highDangerSource, isHighDangerCommand,
memberWorkControl, memberWorkPayload, memberWorkState, projectWorkControl, projectWorkPayload, projectWorkStatus,
projectWorkClosed, rdmProjectCommandError, commitTemplateInstantiate, templateKey, templateSelectable, templateStateLabel,
type CommandSource, type DocTemplateRow, type ProjectWorkStatus, type WorkControl, type WorkStateKind,
} from '../rdmManagement' } from '../rdmManagement'
// ---- U4 工作状态呈现:文字徽标(非颜色唯一)+ 项目卡 + 成员行控件 ----
export function WorkStateBadge({ state }: { state: WorkStateKind }) {
const badge = WORK_STATE_BADGES[state]
return <span className={badge.className} data-work-state={state}>{badge.label}</span>
}
const confirmHint = (control: WorkControl) =>
control.disabledReason || (isHighDangerCommand(control.command) ? '高危操作:先生成确认卡,确认后才执行' : undefined)
export function ProjectWorkCardView({ status, isCreator, busy, onRun }: {
status: ProjectWorkStatus; isCreator: boolean; busy: boolean; onRun: (control: WorkControl) => void
}) {
const control = projectWorkControl(status, isCreator)
const danger = isHighDangerCommand(control.command)
return (
<div className="ad-create pw-card" role="group" aria-label="项目工作状态">
<div className="ad-row">
<span>项目工作</span>
<WorkStateBadge state={status.state} />
<button className={`ad-mini pw-action${danger ? ' pw-danger' : ''}`} disabled={busy || !!control.disabledReason}
title={confirmHint(control)} onClick={() => onRun(control)}>
{control.label}{danger ? '(需确认)' : ''}
</button>
</div>
<p className="ad-hint">{status.state === 'closed' ? WORK_STATE_NOTICES.closed
: '关闭后全体成员的新建与继续工作被拒,已有会话与资料保留只读;关闭需在待处理中确认后执行。'}</p>
{control.disabledReason && <p className="ad-hint">{control.disabledReason}</p>}
</div>
)
}
interface MemberWorkRow { owner: string; state?: string; stateVersion?: number }
export function MemberWorkCell({ row, isCreator, creator, busy, onRun }: {
row: MemberWorkRow; isCreator: boolean; creator?: string; busy: boolean
onRun: (row: MemberWorkRow, control: WorkControl) => void
}) {
const state = memberWorkState(row)
const control = memberWorkControl(row, { isCreator, creator })
return (
<span className="ad-actions">
<WorkStateBadge state={state} />
{control && (
<button className={`ad-mini${isHighDangerCommand(control.command) ? ' pw-danger' : ''}`}
disabled={busy || !!control.disabledReason} title={confirmHint(control)}
onClick={() => onRun(row, control)}>{control.label}</button>
)}
</span>
)
}
/** 确认卡落在项目收件箱:关闭设置、切到该项目的项目范围待处理并定位卡片。 */
async function openConfirmCard(projectId: string, cardId: string) {
store.openProjectSettings(null)
if (store.getState().currentProjectId !== projectId) await store.openProject(projectId)
store.setInboxScope('project')
await store.loadProjectCards()
store.openInbox(cardId || undefined)
}
function ConfirmPendingNotice({ projectId, cardId }: { projectId: string; cardId: string }) {
return (
<div className="ad-notice" role="status">
<Icon name="alert" size={14} />
<span>已生成确认卡:确认后才会执行。</span>
<button className="ad-mini" onClick={() => { void openConfirmCard(projectId, cardId) }}>前往待处理确认</button>
</div>
)
}
// ---- U5 按模板生成文档:全部条目可见;仅 supported 条目可显式选择,逐条实例化 ----
export function TemplateListView({ templates, selectedKey, blockedReason, onChoose }: {
templates: DocTemplateRow[]; selectedKey: string; blockedReason: string | null
onChoose: (t: DocTemplateRow) => void
}) {
return (
<ul className="tpl-list" aria-label="已发布文档模板">
{templates.map(t => {
const key = templateKey(t)
const check = templateSelectable(t)
const chosen = key === selectedKey
return (
<li key={key} className={`tpl-item${chosen ? ' on' : ''}`} data-template={key} data-generation={t.generation}>
<div className="tpl-head">
<span className="tpl-title">{t.title || t.templateId}</span>
<span className={check.ok ? 'ad-chip ad-chip-admin' : 'ad-chip ad-chip-warn'}>{check.ok ? '可生成' : '不可生成'}</span>
<span className="ad-chip">{templateStateLabel(t.state)}</span>
</div>
<span className="ad-dim ad-mono">{key}{t.stage ? ` · ${t.stage}` : ''}</span>
{t.docTypes && t.docTypes.length > 0 && <span className="ad-dim">适用文档类型:{t.docTypes.join('、')}</span>}
{!check.ok && <p className="ad-hint">不可生成:{check.reason}</p>}
<span>
<button className="ad-mini" aria-pressed={chosen} disabled={!check.ok || !!blockedReason}
title={!check.ok ? check.reason : blockedReason ?? undefined} onClick={() => onChoose(t)}>
{chosen ? '已选择' : '选择此模板'}
</button>
</span>
</li>
)
})}
</ul>
)
}
const newOperationId = () =>
crypto.randomUUID ? crypto.randomUUID() : 'op-' + Date.now() + '-' + Math.random().toString(36).slice(2)
export function RdmTemplateDocsPanel({ project }: { project: Project }) {
const [templates, setTemplates] = useState<DocTemplateRow[] | null>(null)
const [loadErr, setLoadErr] = useState('')
const [selectedKey, setSelectedKey] = useState('')
const [docType, setDocType] = useState('')
const [title, setTitle] = useState('')
const [busy, setBusy] = useState(false)
const [err, setErr] = useState('')
const [notice, setNotice] = useState('')
// 清单与提交的代次:切项目即推进,迟到响应丢弃。
const gen = useRef(0)
// 同一输入的重试复用编号(服务端据此幂等返回同一条来源消息);输入一变即换新编号。
const operation = useRef<{ sig: string; id: string } | null>(null)
useEffect(() => {
const g = ++gen.current
setTemplates(null); setLoadErr(''); setSelectedKey(''); setErr(''); setNotice(''); setBusy(false)
api.rdmCommand<DocTemplateRow[]>('rdm.docs.templates', {})
.then(rows => { if (g === gen.current) setTemplates(Array.isArray(rows) ? rows : []) })
.catch(e => { if (g === gen.current) setLoadErr(String(e)) })
return () => { ++gen.current }
}, [project.id])
const blockedReason = projectWorkClosed(project) ? WORK_STATE_NOTICES.closed
: rdmProjectCommandError(project, 'rdm.docs.instantiate')
const chosen = (templates ?? []).find(t => templateKey(t) === selectedKey && templateSelectable(t).ok)
const choose = (t: DocTemplateRow) => {
setSelectedKey(templateKey(t))
setDocType(t.docTypes?.length === 1 ? t.docTypes[0] : '')
setTitle(''); setErr(''); setNotice('')
}
const submit = async () => {
if (!chosen || !docType || busy || blockedReason) return
const sig = JSON.stringify([project.id, templateKey(chosen), docType, title.trim()])
const op = operation.current?.sig === sig ? operation.current : { sig, id: newOperationId() }
operation.current = op
const g = gen.current
setBusy(true); setErr(''); setNotice('')
try {
const out = await commitTemplateInstantiate(api, {
host: project, operationId: op.id, template: chosen, docType, title,
}) as { docId?: string } | undefined
if (g !== gen.current) return
setNotice(`已按模板「${chosen.title || chosen.templateId}」(${templateKey(chosen)})生成文档${out?.docId ? ` ${out.docId}` : ''}`)
operation.current = null
setSelectedKey('')
} catch (e) {
if (g === gen.current) setErr(String(e).replace(/^Error:\s*/, ''))
} finally {
if (g === gen.current) setBusy(false)
}
}
return (
<div className="tpl-panel">
<div className="tpl-head">
<span className="tpl-title">按模板生成文档</span>
{templates && <span className="ad-dim">共 {templates.length} 个模板</span>}
</div>
<p className="ad-hint">逐条显式选择后生成;不可生成的模板保留可见并标明原因。</p>
{blockedReason && <p className="ad-hint">{blockedReason}</p>}
{templates === null && <p className="ad-hint">{loadErr ? '模板清单未加载(见下方提示)' : '加载中…'}</p>}
{templates && templates.length === 0 && <p className="ad-hint">暂无已发布的文档模板</p>}
{templates && templates.length > 0 && (
<TemplateListView templates={templates} selectedKey={selectedKey} blockedReason={blockedReason} onChoose={choose} />
)}
{chosen && (
<div className="ad-create tpl-form">
<span>已选:<b>{chosen.title || chosen.templateId}</b> <span className="ad-dim ad-mono">{templateKey(chosen)}</span></span>
<select className="lg-input" aria-label="文档类型" value={docType} onChange={e => setDocType(e.target.value)}>
{(chosen.docTypes?.length ?? 0) > 1 && <option value="">选择文档类型…</option>}
{(chosen.docTypes ?? []).map(d => <option key={d} value={d}>{d}</option>)}
</select>
<input className="lg-input" aria-label="文档标题" placeholder="文档标题(留空沿用模板标题)" value={title}
onChange={e => setTitle(e.target.value)} />
<Collapsible trigger="模板溯源">
<span className="ad-dim ad-mono">来源格式 {chosen.sourceFormat || '—'} · sha256 {chosen.sourceSha256 || '—'}</span>
</Collapsible>
<span className="ad-actions">
<button className="ad-mini" disabled={busy || !docType || !!blockedReason} onClick={() => { void submit() }}>
{busy ? <span className="mk-sweep">生成中</span> : '生成文档'}
</button>
<button className="ad-mini" disabled={busy} onClick={() => setSelectedKey('')}>取消选择</button>
</span>
</div>
)}
{notice && <div className="ad-notice"><Icon name="check" size={14} />{notice}</div>}
{(err || loadErr) && <div className="lg-err"><Icon name="alert" size={14} />{err || loadErr}</div>}
</div>
)
}
// ---- 项目设置:成员(P7:可搜索人员选择器 + 专业角色/项目身份两列 + 显式身份调整)---- // ---- 项目设置:成员(P7:可搜索人员选择器 + 专业角色/项目身份两列 + 显式身份调整)----
import { import {
@@ -25,12 +232,19 @@ import {
interface MemberRow { interface MemberRow {
owner: string; name?: string; role: string; addedBy: string; addedAt: string owner: string; name?: string; role: string; addedBy: string; addedAt: string
profRole?: { roleId: string; name: string } | null profRole?: { roleId: string; name: string } | null
state?: string; stateVersion?: number
} }
export function RdmMembersSection({ project }: { project: Project }) { export function RdmMembersSection({ project }: { project: Project }) {
const projectId = project.id const projectId = project.id
const writeError = projectWorkError(project) const writeError = projectWorkError(project)
const cmd = <T,>(id: string, params: Record<string, unknown> = {}) => api.rdmProjectCommand<T>(project, id, params) const cmd = <T,>(id: string, params: Record<string, unknown> = {}, source?: CommandSource) =>
api.rdmProjectCommand<T>(project, id, params, source)
const me = useStore(s => s.me?.owner)
const workStatesAvailable = useStore(s => s.rdmWorkStatesAvailable)
const workStatus = workStatesAvailable ? projectWorkStatus(project) : null
const isCreator = !!me && project.owner === me
const [pendingCard, setPendingCard] = useState<string | null>(null)
const [rows, setRows] = useState<MemberRow[] | null>(null) const [rows, setRows] = useState<MemberRow[] | null>(null)
const [memberNext, setMemberNext] = useState('') const [memberNext, setMemberNext] = useState('')
const [memberTotal, setMemberTotal] = useState<number | null>(null) const [memberTotal, setMemberTotal] = useState<number | null>(null)
@@ -112,11 +326,28 @@ export function RdmMembersSection({ project }: { project: Project }) {
const refresh = () => loadMembers('', false) const refresh = () => loadMembers('', false)
const run = async (label: string, fn: () => Promise<unknown>) => { const run = async (label: string, fn: () => Promise<unknown>) => {
setErr(''); setNotice(''); setBusy(true) setErr(''); setNotice(''); setPendingCard(null); setBusy(true)
try { await fn(); setNotice(label); await refresh() } try { await fn(); setNotice(label); await refresh() }
catch (e) { setErr(String(e)) } finally { setBusy(false) } catch (e) { setErr(String(e)) } finally { setBusy(false) }
} }
/** 版本取自各自读面(项目列表 / 成员清单);结束后两者各由其唯一所有者重取,陈旧拒绝后即得新版本。 */
const runWork = async (label: string, command: string, params: Record<string, unknown>) => {
setErr(''); setNotice(''); setPendingCard(null); setBusy(true)
try {
const source = isHighDangerCommand(command) ? await highDangerSource(api, project) : undefined
await cmd(command, params, source)
setNotice(label)
} catch (e) {
if (e instanceof ConfirmCardPending) setPendingCard(e.cardId)
else setErr(String(e))
} finally {
setBusy(false)
void store.refreshProjects()
await refresh()
}
}
const onPick = (ids: string[]) => { const onPick = (ids: string[]) => {
setPicked(ids) setPicked(ids)
setPickedItems(ids.flatMap(id => { setPickedItems(ids.flatMap(id => {
@@ -139,9 +370,22 @@ export function RdmMembersSection({ project }: { project: Project }) {
</select> </select>
) : null ) : null
const myState = workStatesAvailable ? memberWorkState((rows ?? []).find(m => m.owner === me) ?? {}) : 'active'
const runMemberWork = (m: { owner: string; stateVersion?: number }, control: WorkControl) => {
if (typeof m.stateVersion !== 'number') return
void runWork(control.command === 'rdm.project.member.suspend' ? `已暂停 ${m.owner}` : `已恢复 ${m.owner}`,
control.command, memberWorkPayload(projectId, m.owner, m.stateVersion))
}
return ( return (
<div> <div>
{writeError && <p className="ad-hint">{writeError}</p>} {writeError && <p className="ad-hint">{writeError}</p>}
{workStatus && (
<ProjectWorkCardView status={workStatus} isCreator={isCreator} busy={busy || !!writeError}
onRun={control => runWork(control.command === 'rdm.project.work.close' ? '已关闭项目工作' : '已重新开启项目工作',
control.command, projectWorkPayload(projectId, workStatus.version))} />
)}
{myState !== 'active' && <p className="ad-hint">你在本项目{WORK_STATE_NOTICES[myState]}</p>}
<div className="ad-create"> <div className="ad-create">
<div className="ad-row"> <div className="ad-row">
{roleFilter(candRoleId, setCandRoleId, '候选专业角色筛选')} {roleFilter(candRoleId, setCandRoleId, '候选专业角色筛选')}
@@ -188,30 +432,42 @@ export function RdmMembersSection({ project }: { project: Project }) {
</div> </div>
{!directoryOk && <p className="ad-hint">账号目录暂不可用:成员姓名可能缺失,仅按账号显示。</p>} {!directoryOk && <p className="ad-hint">账号目录暂不可用:成员姓名可能缺失,仅按账号显示。</p>}
<table className="ad-table"> <table className="ad-table">
<thead><tr><th>成员</th><th>专业角色</th><th>项目身份</th><th>添加人</th><th>添加时间</th><th></th></tr></thead> <thead><tr><th>成员</th><th>专业角色</th><th>项目身份</th>{workStatesAvailable && <th>状态</th>}<th>添加人</th><th>添加时间</th><th></th></tr></thead>
<tbody> <tbody>
{(rows ?? []).map(m => ( {(rows ?? []).map(m => {
<tr key={m.owner}> const state = memberWorkState(m)
<td className="ad-mono">{personLabel(m)}</td> return (
<td className="ad-dim">{m.profRole?.name || '—'}</td> <tr key={m.owner} title={state === 'active' ? undefined : WORK_STATE_NOTICES[state]}>
<td>{m.role === 'owner' ? <span className="ad-chip ad-chip-admin">项目负责人</span> : <span className="ad-chip">项目成员</span>}</td> <td className="ad-mono">{personLabel(m)}</td>
<td className="ad-dim">{m.addedBy || '—'}</td> <td className="ad-dim">{m.profRole?.name || '—'}</td>
<td className="ad-dim">{m.addedAt ? new Date(m.addedAt).toLocaleString() : '—'}</td> <td>{m.role === 'owner' ? <span className="ad-chip ad-chip-admin">项目负责人</span> : <span className="ad-chip">项目成员</span>}</td>
<td> {workStatesAvailable && (
<button className="ad-mini" disabled={busy || !!writeError} <td>
title={m.role === 'owner' ? '降为项目成员(setRole)' : '升为项目负责人(setRole)'} <MemberWorkCell row={m} isCreator={isCreator} creator={project.owner} busy={busy || !!writeError}
onClick={() => run(`已调整 ${m.owner} 的项目身份`, () => onRun={runMemberWork} />
cmd('rdm.project.member.set-role', memberSetRolePayload(projectId, m.owner, m.role === 'owner' ? 'member' : 'owner')))}> </td>
{m.role === 'owner' ? '降为成员' : '升为负责人'} )}
</button>{' '} <td className="ad-dim">{m.addedBy || '—'}</td>
<button className="ad-mini" disabled={busy || !!writeError} <td className="ad-dim">{m.addedAt ? new Date(m.addedAt).toLocaleString() : '—'}</td>
onClick={() => run(`已移除 ${m.owner}`, <td>
() => cmd('rdm.project.member.remove', memberRemovePayload(projectId, m.owner)))}>移除</button> {state === 'removed' ? <span className="ad-dim">仅历史只读</span> : <span className="ad-actions">
</td> <button className="ad-mini" disabled={busy || !!writeError || state === 'suspended'}
</tr> title={state === 'suspended' ? '已暂停:先恢复再调整项目身份'
))} : m.role === 'owner' ? '降为项目成员(setRole)' : '升为项目负责人(setRole)'}
{rows !== null && rows.length === 0 && <tr><td colSpan={6} className="ad-dim">{filtering ? '没有匹配的成员' : '暂无成员'}</td></tr>} onClick={() => run(`已调整 ${m.owner} 的项目身份`, () =>
{rows === null && <tr><td colSpan={6} className="ad-dim">{err ? '成员清单未加载(见下方提示)' : '加载中…'}</td></tr>} cmd('rdm.project.member.set-role', memberSetRolePayload(projectId, m.owner, m.role === 'owner' ? 'member' : 'owner')))}>
{m.role === 'owner' ? '降为成员' : '升为负责人'}
</button>
<button className="ad-mini" disabled={busy || !!writeError}
onClick={() => run(`已移除 ${m.owner}`,
() => cmd('rdm.project.member.remove', memberRemovePayload(projectId, m.owner)))}>移除</button>
</span>}
</td>
</tr>
)
})}
{rows !== null && rows.length === 0 && <tr><td colSpan={workStatesAvailable ? 7 : 6} className="ad-dim">{filtering ? '没有匹配的成员' : '暂无成员'}</td></tr>}
{rows === null && <tr><td colSpan={workStatesAvailable ? 7 : 6} className="ad-dim">{err ? '成员清单未加载(见下方提示)' : '加载中…'}</td></tr>}
</tbody> </tbody>
</table> </table>
{memberNext && rows !== null && ( {memberNext && rows !== null && (
@@ -221,6 +477,7 @@ export function RdmMembersSection({ project }: { project: Project }) {
</div> </div>
)} )}
{notice && <div className="ad-notice"><Icon name="check" size={14} />{notice}</div>} {notice && <div className="ad-notice"><Icon name="check" size={14} />{notice}</div>}
{pendingCard !== null && <ConfirmPendingNotice projectId={projectId} cardId={pendingCard} />}
{err && <div className="lg-err"><Icon name="alert" size={14} />{err}</div>} {err && <div className="lg-err"><Icon name="alert" size={14} />{err}</div>}
</div> </div>
) )
+217 -3
View File
@@ -2,7 +2,7 @@
// 命令清单不可用=未就绪,fail-closed)与命令载荷构造。页面组件只消费本模块与 // 命令清单不可用=未就绪,fail-closed)与命令载荷构造。页面组件只消费本模块与
// api.rdmCommand,不在组件里散落命令 id / 参数拼写;身份一律经认证会话,载荷不 // api.rdmCommand,不在组件里散落命令 id / 参数拼写;身份一律经认证会话,载荷不
// 携带任何身份字段(G1-5)。 // 携带任何身份字段(G1-5)。
import type { Project } from './contracts' import type { Conversation, Message, Project } from './contracts'
import { projectWorkError } from './projectmeta' import { projectWorkError } from './projectmeta'
export function rdmCommandProjectId(project: Project): string { export function rdmCommandProjectId(project: Project): string {
@@ -64,6 +64,20 @@ export const RDM_DUTY_COMMANDS = [
'rdm.review.assign.doc.batch', 'rdm.review.assign.doc.batch',
] as const ] as const
/** 项目卡与成员行的工作状态控制(U4):项目关闭/重开 + 成员暂停/恢复。 */
export const RDM_WORK_STATE_COMMANDS = [
'rdm.project.work.close',
'rdm.project.work.reopen',
'rdm.project.member.suspend',
'rdm.project.member.reactivate',
] as const
/** 「按模板生成文档」入口(U5):清单只读 + 显式单条实例化。 */
export const RDM_DOC_TEMPLATE_COMMANDS = [
'rdm.docs.templates',
'rdm.docs.instantiate',
] as const
export type CommandListItem = { id?: string; enabled?: boolean } | null | undefined export type CommandListItem = { id?: string; enabled?: boolean } | null | undefined
function ready(cmds: CommandListItem[] | null | undefined, need: readonly string[]): boolean { function ready(cmds: CommandListItem[] | null | undefined, need: readonly string[]): boolean {
@@ -87,6 +101,14 @@ export const rdmDutyReady = (cmds: CommandListItem[] | null | undefined) =>
export const rdmPermMatrixReady = (cmds: CommandListItem[] | null | undefined) => export const rdmPermMatrixReady = (cmds: CommandListItem[] | null | undefined) =>
ready(cmds, RDM_PERM_MATRIX_COMMANDS) ready(cmds, RDM_PERM_MATRIX_COMMANDS)
/** 工作状态控制是否就绪(四条命令齐备才渲染项目卡与成员行控件)。 */
export const rdmWorkStatesReady = (cmds: CommandListItem[] | null | undefined) =>
ready(cmds, RDM_WORK_STATE_COMMANDS)
/** 模板生成入口是否就绪。 */
export const rdmDocTemplatesReady = (cmds: CommandListItem[] | null | undefined) =>
ready(cmds, RDM_DOC_TEMPLATE_COMMANDS)
/** RDM 动作清单(12 项,与 RDM perms.AllActions 对齐;角色动作编辑器选项)。 */ /** RDM 动作清单(12 项,与 RDM perms.AllActions 对齐;角色动作编辑器选项)。 */
export const ROLE_ACTIONS = [ export const ROLE_ACTIONS = [
'project.create', 'project.archive', 'member.manage', 'duty.assign', 'project.create', 'project.archive', 'member.manage', 'duty.assign',
@@ -183,13 +205,18 @@ export const permCheckPayload = (projectId: string, member: string) => ({ projec
*/ */
export interface CommandSource { export interface CommandSource {
conversationId: string conversationId: string
anchorMessageId: string /** 高危命令只需对话上下文(基座在该对话落确认卡);正式写命令必须带已提交消息锚点。 */
anchorMessageId?: string
entrance: 'button' | 'card' | 'slash' | 'chat' | 'api' | 'agent' entrance: 'button' | 'card' | 'slash' | 'chat' | 'api' | 'agent'
} }
export const commandRequestBody = (params: Record<string, unknown>, source?: CommandSource) => export const commandRequestBody = (params: Record<string, unknown>, source?: CommandSource) =>
source source
? { params, conversationId: source.conversationId, anchorMessageId: source.anchorMessageId, entrance: source.entrance } ? {
params, conversationId: source.conversationId,
...(source.anchorMessageId ? { anchorMessageId: source.anchorMessageId } : {}),
entrance: source.entrance,
}
: { params } : { params }
// ---- 项目成员页:候选检索 + 成员表检索(R2 契约:服务端先过滤后分页) ---- // ---- 项目成员页:候选检索 + 成员表检索(R2 契约:服务端先过滤后分页) ----
@@ -386,3 +413,190 @@ export function parseReviewers(csv: string): string[] {
} }
return out return out
} }
// ---- U4 工作状态:项目卡(关闭/重新开启)与成员行(暂停/恢复) ----
export type WorkStateKind = 'active' | 'suspended' | 'removed' | 'closed'
/** 状态一律带文字徽标:配色只是辅助,不单独承载语义。 */
export const WORK_STATE_BADGES: Record<WorkStateKind, { label: string; className: string }> = {
active: { label: '正常', className: 'ad-chip ad-chip-admin' },
suspended: { label: '已暂停', className: 'ad-chip ad-chip-warn' },
removed: { label: '已移除', className: 'ad-chip' },
closed: { label: '已关闭', className: 'ad-chip ad-chip-warn' },
}
export const WORK_STATE_NOTICES: Record<Exclude<WorkStateKind, 'active'>, string> = {
closed: '项目工作已关闭:全体成员的新建与继续工作被拒,已有会话与资料保留只读;项目创建者可重新开启。',
suspended: '已暂停:新建与继续工作被拒,仅保留暂停前内容的只读;项目创建者可恢复。',
removed: '已移除:仅保留历史只读,不可自行恢复;如需恢复须由项目创建者重新添加。',
}
export interface ProjectWorkStatus { state: 'active' | 'closed'; version: number }
/** 项目列表读面携带的工作状态(rdm.workState / rdm.workVersion);缺任一视为未提供,不渲染控制。 */
export function projectWorkStatus(project: Project | undefined): ProjectWorkStatus | null {
const state = project?.settings?.['rdm.workState']
const version = project?.settings?.['rdm.workVersion']
if ((state !== 'active' && state !== 'closed') || typeof version !== 'number' || !Number.isFinite(version)) return null
return { state, version }
}
export const projectWorkClosed = (project: Project | undefined) => projectWorkStatus(project)?.state === 'closed'
/** 成员清单行的工作状态;旧服务端不带 state 时按 active 呈现。 */
export function memberWorkState(row: { state?: string }): Exclude<WorkStateKind, 'closed'> {
return row.state === 'suspended' || row.state === 'removed' ? row.state : 'active'
}
/** danger:high(RDM 命令声明):基座只在对话上下文中落确认卡,确认后才执行。 */
const HIGH_DANGER_WORK_COMMANDS = new Set(['rdm.project.work.close', 'rdm.project.member.suspend'])
export const isHighDangerCommand = (command: string) => HIGH_DANGER_WORK_COMMANDS.has(command)
export interface WorkControl { command: string; label: string; disabledReason: string }
export function projectWorkControl(status: ProjectWorkStatus, isCreator: boolean): WorkControl {
const closing = status.state === 'active'
return {
command: closing ? 'rdm.project.work.close' : 'rdm.project.work.reopen',
label: closing ? '关闭项目工作' : '重新开启',
disabledReason: isCreator ? '' : '仅项目创建者可关闭或重新开启项目工作',
}
}
/** 已移除成员没有可执行的工作控制(恢复须重新添加)。 */
export function memberWorkControl(
row: { owner: string; state?: string; stateVersion?: number },
ctx: { isCreator: boolean; creator?: string },
): WorkControl | null {
const state = memberWorkState(row)
if (state === 'removed') return null
const suspending = state === 'active'
const disabledReason = !ctx.isCreator ? '仅项目创建者可暂停或恢复成员'
: suspending && row.owner === ctx.creator ? '项目创建者不可暂停'
: typeof row.stateVersion !== 'number' ? '缺少成员状态版本,请刷新成员清单' : ''
return {
command: suspending ? 'rdm.project.member.suspend' : 'rdm.project.member.reactivate',
label: suspending ? '暂停' : '恢复',
disabledReason,
}
}
export const projectWorkPayload = (projectId: string, expectedVersion: number) => ({ projectId, expectedVersion })
export const memberWorkPayload = (projectId: string, member: string, expectedVersion: number) =>
({ projectId, member, expectedVersion })
export const CONFIRM_CARD_PENDING = '高危命令已生成确认卡——请在对话中确认执行'
/** 命令总线返回确认卡(未执行):携带卡片 id,便于跳到待处理确认。 */
export class ConfirmCardPending extends Error {
cardId: string
constructor(cardId: string) {
super(CONFIRM_CARD_PENDING)
this.name = 'ConfirmCardPending'
this.cardId = cardId
}
}
export interface RecordConversationPort {
listConversations: (projectId: string) => Promise<Conversation[]>
createConversation: (projectId: string, title?: string) => Promise<Conversation>
}
export const WORK_CONFIRM_RECORD_TITLE = '项目管理确认'
export const TEMPLATE_RECORD_TITLE = '文档生成记录'
async function recordConversation(port: RecordConversationPort, hostProjectId: string, title: string): Promise<Conversation> {
const list = await port.listConversations(hostProjectId)
return list.find(c => c.title === title && !c.archived) ?? await port.createConversation(hostProjectId, title)
}
/** 高危命令的对话上下文:只提供对话(确认卡落点),不发消息、不触发回合。 */
export async function highDangerSource(port: RecordConversationPort, host: Project): Promise<CommandSource> {
const conversation = await recordConversation(port, host.id, WORK_CONFIRM_RECORD_TITLE)
return { conversationId: conversation.id, entrance: 'button' }
}
// ---- U5 模板浏览:rdm.docs.templates 清单 + 显式单条 rdm.docs.instantiate ----
export interface DocTemplateRow {
templateId: string
version: string
title: string
docTypes?: string[] | null
stage?: string
state: string
sourceFormat: string
sourceSha256: string
generation: string
unsupportedReason?: string
}
const TEMPLATE_STATE_LABELS: Record<string, string> = {
published: '已发布',
'needs-review': '待复核',
retired: '已退役',
draft: '草稿',
}
export const templateStateLabel = (state: string) => TEMPLATE_STATE_LABELS[state] ?? state
export const templateKey = (t: Pick<DocTemplateRow, 'templateId' | 'version'>) => `${t.templateId}@${t.version}`
/** 与 rdm.docs.instantiate 同一判定:只有 generation=supported 且登记了文档类型的条目可实例化。 */
export function templateSelectable(t: DocTemplateRow): { ok: boolean; reason: string } {
if (t.generation !== 'supported') return { ok: false, reason: t.unsupportedReason?.trim() || '该模板暂不支持生成文档' }
if (!t.docTypes || t.docTypes.length === 0) return { ok: false, reason: '未登记适用文档类型,无法按类型生成' }
return { ok: true, reason: '' }
}
/** paramsSchema:projectId/docType/templateId/version 必填,title 可选;不可生成的条目直接拒绝。 */
export function instantiatePayload(projectId: string, t: DocTemplateRow, docType: string, title = '') {
const check = templateSelectable(t)
if (!check.ok) throw new Error(check.reason)
if (!t.docTypes?.includes(docType)) throw new Error(`文档类型 ${docType} 不在该模板的适用范围内`)
const trimmed = title.trim()
return { projectId, docType, templateId: t.templateId, version: t.version, ...(trimmed ? { title: trimmed } : {}) }
}
/** 来源消息正文(中文、首字符不为 “/”),记录模板身份与版本以便溯源。 */
export function instantiateSourceText(t: DocTemplateRow, docType: string, title: string): string {
return [
'文档生成:按模板实例化',
`模板:${t.title}(${templateKey(t)})`,
`文档类型:${docType}`,
`文档标题:${title.trim() || '沿用模板标题'}`,
'来源:按模板生成文档',
].join('\n')
}
export interface InstantiatePort extends RecordConversationPort {
sendMessage: (convId: string, text: string, attachmentIds: string[], requestId?: string) => Promise<{ message?: Message; command?: string }>
rdmCommand: (id: string, params: Record<string, unknown>, source?: CommandSource) => Promise<unknown>
}
interface InstantiateRequest {
host: Project
/** 同一用户动作的编号:重试复用,服务端据此幂等返回同一条来源消息。 */
operationId: string
template: DocTemplateRow
docType: string
title: string
}
/** 正式写命令须锚定已提交的对话消息:先记录来源,再分发;任一步失败都不分发。 */
export async function commitTemplateInstantiate(port: InstantiatePort, req: InstantiateRequest): Promise<unknown> {
const gate = rdmProjectCommandError(req.host, 'rdm.docs.instantiate')
if (gate) throw new Error(gate)
if (projectWorkClosed(req.host)) throw new Error(WORK_STATE_NOTICES.closed)
const params = instantiatePayload(rdmCommandProjectId(req.host), req.template, req.docType, req.title)
const conversation = await recordConversation(port, req.host.id, TEMPLATE_RECORD_TITLE)
const out = await port.sendMessage(conversation.id, instantiateSourceText(req.template, req.docType, req.title), [],
`instantiate:${req.operationId}`)
if (out.command) throw new Error('生成说明被识别为命令,未作为来源记录;文档未生成')
const msg = out.message
if (!msg?.id || msg.role !== 'user' || msg.conversationId !== conversation.id) {
throw new Error('服务端未返回已提交的对话消息;文档未生成')
}
return port.rdmCommand('rdm.docs.instantiate', params, {
conversationId: conversation.id, anchorMessageId: msg.id, entrance: 'button',
})
}
+12 -3
View File
@@ -12,7 +12,7 @@ import { flashElement } from './scrollsync'
import { isThemeChoice, resolveTheme, type ThemeChoice } from './theme' import { isThemeChoice, resolveTheme, type ThemeChoice } from './theme'
import { mergeSurfacesByRevision, surfaceKey } from './surfaceKey' import { mergeSurfacesByRevision, surfaceKey } from './surfaceKey'
import { cockpitReady, rdmAuditReady, rdmExportReady } from './workspaces' import { cockpitReady, rdmAuditReady, rdmExportReady } from './workspaces'
import { rdmRolesReady, rdmMembersReady, rdmDutyReady, rdmPermMatrixReady } from './rdmManagement' import { rdmRolesReady, rdmMembersReady, rdmDutyReady, rdmPermMatrixReady, rdmWorkStatesReady, rdmDocTemplatesReady } from './rdmManagement'
import { NO_RENDER_CAPABILITIES, renderCapabilities, type RenderCapabilities } from './documentRenderPresentation' import { NO_RENDER_CAPABILITIES, renderCapabilities, type RenderCapabilities } from './documentRenderPresentation'
type FontSizeChoice = 13 | 14 | 16 | 18 type FontSizeChoice = 13 | 14 | 16 | 18
@@ -134,6 +134,10 @@ interface AppState {
rdmMembersAvailable: boolean rdmMembersAvailable: boolean
rdmDocReviewAvailable: boolean rdmDocReviewAvailable: boolean
rdmPermMatrixAvailable: boolean rdmPermMatrixAvailable: boolean
/** 项目卡/成员行工作状态控制(rdm.project.work.* + member.suspend/reactivate 齐备)。 */
rdmWorkStatesAvailable: boolean
/** 「按模板生成文档」入口(rdm.docs.templates + rdm.docs.instantiate 齐备)。 */
rdmDocTemplatesAvailable: boolean
/** 统一渲染(Task 11):rdm.render.* 核心命令齐备才 ready;审阅/批注/导出各自门控。 */ /** 统一渲染(Task 11):rdm.render.* 核心命令齐备才 ready;审阅/批注/导出各自门控。 */
rdmRender: RenderCapabilities rdmRender: RenderCapabilities
/** 管理区声明分区(WP3-I3):编译插件声明,入口全员可见、区内置权限不合并。 */ /** 管理区声明分区(WP3-I3):编译插件声明,入口全员可见、区内置权限不合并。 */
@@ -207,6 +211,7 @@ let state: AppState = {
rdmCockpitAvailable: false, rdmCockpitAvailable: false,
rdmExportAvailable: false, rdmExportAvailable: false,
rdmRolesAvailable: false, rdmMembersAvailable: false, rdmDocReviewAvailable: false, rdmPermMatrixAvailable: false, rdmRolesAvailable: false, rdmMembersAvailable: false, rdmDocReviewAvailable: false, rdmPermMatrixAvailable: false,
rdmWorkStatesAvailable: false, rdmDocTemplatesAvailable: false,
rdmRender: NO_RENDER_CAPABILITIES, rdmRender: NO_RENDER_CAPABILITIES,
adminSections: [], adminSections: [],
ui: loadUI(), connected: false, posting: false, error: null, toast: null, ui: loadUI(), connected: false, posting: false, error: null, toast: null,
@@ -466,7 +471,8 @@ export const store = {
cards: [], projectCards: [], cardActionStates: {}, workRecord: null, workReference: null, inboxScope: 'conv', cards: [], projectCards: [], cardActionStates: {}, workRecord: null, workReference: null, inboxScope: 'conv',
runtimeMock: false, profiles: [], pluginPanels: [], serverWorkspaces: [], adminSections: [], runtimeMock: false, profiles: [], pluginPanels: [], serverWorkspaces: [], adminSections: [],
rdmAuditAvailable: false, rdmCockpitAvailable: false, rdmExportAvailable: false, rdmRolesAvailable: false, rdmMembersAvailable: false, rdmAuditAvailable: false, rdmCockpitAvailable: false, rdmExportAvailable: false, rdmRolesAvailable: false, rdmMembersAvailable: false,
rdmDocReviewAvailable: false, rdmPermMatrixAvailable: false, rdmRender: NO_RENDER_CAPABILITIES, rdmDocReviewAvailable: false, rdmPermMatrixAvailable: false, rdmWorkStatesAvailable: false, rdmDocTemplatesAvailable: false,
rdmRender: NO_RENDER_CAPABILITIES,
connected: false, posting: false, error: null, toast: null, outbox: [], outboxDraining: false, connected: false, posting: false, error: null, toast: null, outbox: [], outboxDraining: false,
previewTabs: {}, previewActive: {}, previewOpenIntent: 0 }) previewTabs: {}, previewActive: {}, previewOpenIntent: 0 })
setUI({ lastProjectId: null, projectSettingsId: null, adminZoneOpen: false, activeWorkspace: null, setUI({ lastProjectId: null, projectSettingsId: null, adminZoneOpen: false, activeWorkspace: null,
@@ -1236,12 +1242,15 @@ export const store = {
rdmMembersAvailable: rdmMembersReady(cmds), rdmMembersAvailable: rdmMembersReady(cmds),
rdmDocReviewAvailable: rdmDutyReady(cmds), rdmDocReviewAvailable: rdmDutyReady(cmds),
rdmPermMatrixAvailable: rdmPermMatrixReady(cmds), rdmPermMatrixAvailable: rdmPermMatrixReady(cmds),
rdmWorkStatesAvailable: rdmWorkStatesReady(cmds),
rdmDocTemplatesAvailable: rdmDocTemplatesReady(cmds),
rdmRender: renderCapabilities(cmds), rdmRender: renderCapabilities(cmds),
}) })
} catch { } catch {
if (epoch !== identityEpoch) return if (epoch !== identityEpoch) return
set({ rdmAuditAvailable: false, rdmCockpitAvailable: false, rdmExportAvailable: false, rdmRolesAvailable: false, rdmMembersAvailable: false, set({ rdmAuditAvailable: false, rdmCockpitAvailable: false, rdmExportAvailable: false, rdmRolesAvailable: false, rdmMembersAvailable: false,
rdmDocReviewAvailable: false, rdmPermMatrixAvailable: false, rdmRender: NO_RENDER_CAPABILITIES }) rdmDocReviewAvailable: false, rdmPermMatrixAvailable: false, rdmWorkStatesAvailable: false, rdmDocTemplatesAvailable: false,
rdmRender: NO_RENDER_CAPABILITIES })
} }
try { try {
const sections = await api.adminZoneSections() const sections = await api.adminZoneSections()
+19
View File
@@ -546,6 +546,25 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
.ad-chip-admin { background: var(--accent-soft); color: var(--accent-text); } .ad-chip-admin { background: var(--accent-soft); color: var(--accent-text); }
.ad-chip-super { background: var(--accent); color: var(--text-on-accent); } .ad-chip-super { background: var(--accent); color: var(--text-on-accent); }
.ad-chip-warn { background: var(--warn-soft); color: var(--warn-text); } .ad-chip-warn { background: var(--warn-soft); color: var(--warn-text); }
/* 工作状态(项目卡 / 成员行)与按模板生成文档;状态一律文字徽标,颜色只作辅助。 */
.ad-actions { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 4px; }
.pw-card .ad-row { flex-wrap: wrap; }
.pw-card .pw-action { margin-left: auto; }
.ad-mini.pw-danger { color: var(--err-text); }
.pw-card .ad-mini:disabled, .ad-actions .ad-mini:disabled, .tpl-panel .ad-mini:disabled { opacity: .55; cursor: not-allowed; }
.tsb-newtask-main:disabled { opacity: .55; cursor: not-allowed; }
.tsb-menu.tsb-tpl-pop { width: min(440px, calc(100vw - 16px)); max-height: 70vh; overflow-y: auto; padding: 10px; }
.tpl-panel { display: flex; flex-direction: column; gap: 8px; }
.tpl-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
.tpl-item { border: 1px solid var(--border); border-radius: var(--radius); padding: 8px 10px; display: flex; flex-direction: column; gap: 4px; background: var(--bg-panel); }
.tpl-item.on { border-color: var(--accent); }
.tpl-head { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
.tpl-title { flex: 1; min-width: 0; font-weight: 600; overflow-wrap: anywhere; }
.tpl-form .lg-input { height: 34px; }
@media (max-width: 640px) {
.pw-card .pw-action { margin-left: 0; }
.ad-table .ad-actions { flex-direction: column; align-items: flex-start; }
}
.ad-reset { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; border: 1px solid color-mix(in srgb, var(--warn) 40%, transparent); background: var(--warn-soft); border-radius: var(--radius-lg); padding: 12px; font-size: var(--fs-ui-sm); } .ad-reset { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; border: 1px solid color-mix(in srgb, var(--warn) 40%, transparent); background: var(--warn-soft); border-radius: var(--radius-lg); padding: 12px; font-size: var(--fs-ui-sm); }
.ad-reset .lg-input { flex: 1; min-width: 160px; height: 32px; } .ad-reset .lg-input { flex: 1; min-width: 160px; height: 32px; }
.ad-changepw { display: flex; flex-direction: column; gap: 2px; max-width: 340px; } .ad-changepw { display: flex; flex-direction: column; gap: 2px; max-width: 340px; }
+277
View File
@@ -0,0 +1,277 @@
// 计划 U6(项目访问生命周期)界面呈现:工作状态控制消费 U4 合同(rdm.project.work.close/reopen、
// rdm.project.member.suspend/reactivate),模板浏览消费 U5 合同(rdm.docs.templates/instantiate)。
// 锁定:命令面门控、载荷形状、文字徽标(非颜色唯一)、禁用逻辑、高危确认流与 unsupported 拒绝。
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createElement } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { api } from '../src/api'
import type { Conversation, Message, Project } from '../src/contracts'
import { MemberWorkCell, ProjectWorkCardView, TemplateListView, WorkStateBadge } from '../src/components/adminRdmSections'
import {
CONFIRM_CARD_PENDING, ConfirmCardPending, RDM_DOC_TEMPLATE_COMMANDS, RDM_WORK_STATE_COMMANDS,
TEMPLATE_RECORD_TITLE, WORK_CONFIRM_RECORD_TITLE, WORK_STATE_NOTICES,
commandRequestBody, commitTemplateInstantiate, highDangerSource, instantiatePayload, instantiateSourceText,
isHighDangerCommand, memberWorkControl, memberWorkPayload, memberWorkState, projectWorkClosed, projectWorkControl,
projectWorkPayload, projectWorkStatus, rdmDocTemplatesReady, rdmWorkStatesReady, templateSelectable,
type DocTemplateRow, type InstantiatePort,
} from '../src/rdmManagement'
const project = (settings: Record<string, unknown> = {}, owner = 'alice'): Project => ({
id: 'host-1', name: '宿主项目', owner, createdAt: '', updatedAt: '',
settings: { 'rdm.projectId': 'domain-7', 'rdm.state': 'ready', ...settings },
})
const active = project({ 'rdm.workState': 'active', 'rdm.workVersion': 3 })
const closed = project({ 'rdm.workState': 'closed', 'rdm.workVersion': 4 })
const conv = (id: string, over: Partial<Conversation> = {}): Conversation => ({
id, projectId: 'host-1', title: TEMPLATE_RECORD_TITLE, createdAt: '', updatedAt: '', ...over,
})
const userMsg = (id: string, conversationId: string): Message => ({ id, conversationId, role: 'user', parts: [], createdAt: '' })
const template = (over: Partial<DocTemplateRow> = {}): DocTemplateRow => ({
templateId: 'tpl-srs', version: '1.2.0', title: '软件需求规格说明', docTypes: ['SRS'], stage: 'design',
state: 'published', sourceFormat: 'docx', sourceSha256: 'abc123', generation: 'supported', ...over,
})
const unsupported = template({
templateId: 'tpl-risk', version: '0.9.0', title: '风险管理报告', generation: 'unsupported',
unsupportedReason: 'dhf: 模板包缺失签署区映射', state: 'needs-review',
})
const noop = () => undefined
const html = (el: Parameters<typeof renderToStaticMarkup>[0]) => renderToStaticMarkup(el)
describe('命令面门控(fail-closed)', () => {
const cmds = (ids: readonly string[], enabled = true) => ids.map(id => ({ id, enabled }))
it('工作状态四条命令齐备且 enabled 才就绪', () => {
expect(rdmWorkStatesReady(cmds(RDM_WORK_STATE_COMMANDS))).toBe(true)
expect(rdmWorkStatesReady(cmds(RDM_WORK_STATE_COMMANDS.slice(1)))).toBe(false)
expect(rdmWorkStatesReady(cmds(RDM_WORK_STATE_COMMANDS, false))).toBe(false)
expect(rdmWorkStatesReady(null)).toBe(false)
})
it('模板入口需要清单与实例化两条命令', () => {
expect(rdmDocTemplatesReady(cmds(RDM_DOC_TEMPLATE_COMMANDS))).toBe(true)
expect(rdmDocTemplatesReady(cmds(['rdm.docs.templates']))).toBe(false)
expect(rdmDocTemplatesReady(undefined)).toBe(false)
})
})
describe('工作状态读面与载荷', () => {
it('项目工作状态取自项目列表的 rdm.workState / rdm.workVersion;缺任一不渲染控制', () => {
expect(projectWorkStatus(active)).toEqual({ state: 'active', version: 3 })
expect(projectWorkStatus(closed)).toEqual({ state: 'closed', version: 4 })
expect(projectWorkStatus(project({ 'rdm.workState': 'closed' }))).toBeNull()
expect(projectWorkStatus(project({ 'rdm.workState': 'weird', 'rdm.workVersion': 1 }))).toBeNull()
expect(projectWorkStatus(undefined)).toBeNull()
expect(projectWorkClosed(closed)).toBe(true)
expect(projectWorkClosed(active)).toBe(false)
})
it('载荷按命令 paramsSchema:projectId + expectedVersion(成员命令另带 member),不含身份字段', () => {
expect(projectWorkPayload('host-1', 3)).toEqual({ projectId: 'host-1', expectedVersion: 3 })
expect(memberWorkPayload('host-1', 'bob', 7)).toEqual({ projectId: 'host-1', member: 'bob', expectedVersion: 7 })
})
it('成员状态:缺省 active;suspended/removed 原样保留', () => {
expect(memberWorkState({})).toBe('active')
expect(memberWorkState({ state: 'suspended' })).toBe('suspended')
expect(memberWorkState({ state: 'removed' })).toBe('removed')
})
it('只有 close 与 suspend 是高危命令', () => {
expect(RDM_WORK_STATE_COMMANDS.filter(isHighDangerCommand)).toEqual(['rdm.project.work.close', 'rdm.project.member.suspend'])
})
})
describe('控件禁用逻辑', () => {
it('项目卡:active→关闭,closed→重新开启;非创建者禁用并说明', () => {
expect(projectWorkControl({ state: 'active', version: 1 }, true)).toMatchObject({ command: 'rdm.project.work.close', disabledReason: '' })
expect(projectWorkControl({ state: 'closed', version: 2 }, true)).toMatchObject({ command: 'rdm.project.work.reopen', disabledReason: '' })
expect(projectWorkControl({ state: 'closed', version: 2 }, false).disabledReason).toContain('仅项目创建者')
})
it('成员行:active→暂停,suspended→恢复,removed 无控件;创建者自身不可暂停;缺版本禁用', () => {
const ctx = { isCreator: true, creator: 'alice' }
expect(memberWorkControl({ owner: 'bob', state: 'active', stateVersion: 1 }, ctx))
.toMatchObject({ command: 'rdm.project.member.suspend', disabledReason: '' })
expect(memberWorkControl({ owner: 'bob', state: 'suspended', stateVersion: 2 }, ctx))
.toMatchObject({ command: 'rdm.project.member.reactivate', disabledReason: '' })
expect(memberWorkControl({ owner: 'bob', state: 'removed', stateVersion: 3 }, ctx)).toBeNull()
expect(memberWorkControl({ owner: 'alice', state: 'active', stateVersion: 1 }, ctx)?.disabledReason).toContain('创建者不可暂停')
expect(memberWorkControl({ owner: 'bob', state: 'active' }, ctx)?.disabledReason).toContain('状态版本')
expect(memberWorkControl({ owner: 'bob', state: 'active', stateVersion: 1 }, { isCreator: false, creator: 'alice' })?.disabledReason)
.toContain('仅项目创建者')
})
})
describe('状态呈现(文字徽标,非颜色唯一)', () => {
it.each([
['active', '正常'], ['suspended', '已暂停'], ['removed', '已移除'], ['closed', '已关闭'],
] as const)('%s 渲染为文字徽标「%s」', (state, label) => {
const out = html(createElement(WorkStateBadge, { state }))
expect(out).toContain(`data-work-state="${state}"`)
expect(out).toContain(`>${label}<`)
})
it('closed 项目:只读说明 + 创建者「重新开启」保持可用', () => {
const out = html(createElement(ProjectWorkCardView, { status: { state: 'closed', version: 4 }, isCreator: true, busy: false, onRun: noop }))
expect(out).toContain('已关闭')
expect(out).toContain(WORK_STATE_NOTICES.closed)
expect(out).toContain('重新开启')
expect(out).not.toContain('disabled=""')
})
it('active 项目:关闭按钮标明需确认;非创建者禁用并显示原因', () => {
const creator = html(createElement(ProjectWorkCardView, { status: { state: 'active', version: 3 }, isCreator: true, busy: false, onRun: noop }))
expect(creator).toContain('关闭项目工作(需确认)')
expect(creator).toContain('正常')
const other = html(createElement(ProjectWorkCardView, { status: { state: 'active', version: 3 }, isCreator: false, busy: false, onRun: noop }))
expect(other).toContain('disabled=""')
expect(other).toContain('仅项目创建者可关闭或重新开启项目工作')
})
it('成员行:suspended 显示「已暂停」+ 恢复;removed 只读无控件;busy 时禁用', () => {
const suspended = html(createElement(MemberWorkCell, { row: { owner: 'bob', state: 'suspended', stateVersion: 2 }, isCreator: true, creator: 'alice', busy: false, onRun: noop }))
expect(suspended).toContain('已暂停')
expect(suspended).toContain('>恢复<')
const removed = html(createElement(MemberWorkCell, { row: { owner: 'bob', state: 'removed', stateVersion: 3 }, isCreator: true, creator: 'alice', busy: false, onRun: noop }))
expect(removed).toContain('已移除')
expect(removed).not.toContain('<button')
const busy = html(createElement(MemberWorkCell, { row: { owner: 'bob', state: 'active', stateVersion: 1 }, isCreator: true, creator: 'alice', busy: true, onRun: noop }))
expect(busy).toContain('>暂停<')
expect(busy).toContain('disabled=""')
})
})
describe('命令总线管道(经既有 rdmProjectCommand)', () => {
let requests: { path: string; body: any }[]
let reply: () => Response
beforeEach(() => {
requests = []
reply = () => new Response(JSON.stringify({ payload: { ok: true } }))
vi.stubGlobal('fetch', async (path: string, init?: RequestInit) => {
requests.push({ path, body: init?.body ? JSON.parse(String(init.body)) : undefined })
return reply()
})
})
afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks() })
it('非高危命令只发 params,projectId 换成已绑定的研发项目', async () => {
await api.rdmProjectCommand(closed, 'rdm.project.work.reopen', projectWorkPayload(closed.id, 4))
expect(requests).toEqual([{ path: '/api/commands/rdm.project.work.reopen', body: { params: { projectId: 'domain-7', expectedVersion: 4 } } }])
})
it('高危命令携带对话上下文(无锚点);返回确认卡时抛出带卡片 id 的 ConfirmCardPending', async () => {
reply = () => new Response(JSON.stringify({ receipt: { state: 'accepted' }, card: { id: 'card-9' } }))
const run = api.rdmProjectCommand(active, 'rdm.project.member.suspend', memberWorkPayload(active.id, 'bob', 2),
{ conversationId: 'c-confirm', entrance: 'button' })
await expect(run).rejects.toBeInstanceOf(ConfirmCardPending)
await expect(run).rejects.toMatchObject({ cardId: 'card-9', message: CONFIRM_CARD_PENDING })
expect(requests[0].body).toEqual({
params: { projectId: 'domain-7', member: 'bob', expectedVersion: 2 }, conversationId: 'c-confirm', entrance: 'button',
})
})
it('陈旧版本被拒(4xx)按既有错误管道原样抛出', async () => {
reply = () => new Response(JSON.stringify({ receipt: { state: 'rejected', error: '工作状态已被并发修改' } }), { status: 400 })
await expect(api.rdmProjectCommand(active, 'rdm.project.work.close', projectWorkPayload(active.id, 2), { conversationId: 'c', entrance: 'button' }))
.rejects.toThrow(/400: .*并发修改/)
})
it('请求体:锚点缺省不携带,正式写命令的锚点保留', () => {
expect(commandRequestBody({ a: 1 }, { conversationId: 'c', entrance: 'button' })).toEqual({ params: { a: 1 }, conversationId: 'c', entrance: 'button' })
expect(commandRequestBody({ a: 1 }, { conversationId: 'c', anchorMessageId: 'm', entrance: 'button' }))
.toEqual({ params: { a: 1 }, conversationId: 'c', anchorMessageId: 'm', entrance: 'button' })
})
it('高危确认上下文:复用未归档的确认记录对话,否则新建;不发送消息', async () => {
const port = {
listConversations: vi.fn(async () => [conv('c-arch', { title: WORK_CONFIRM_RECORD_TITLE, archived: true })]),
createConversation: vi.fn(async (_p: string, title?: string) => conv('c-new', { title: title ?? '' })),
}
expect(await highDangerSource(port, active)).toEqual({ conversationId: 'c-new', entrance: 'button' })
expect(port.createConversation).toHaveBeenCalledWith('host-1', WORK_CONFIRM_RECORD_TITLE)
port.listConversations.mockResolvedValueOnce([conv('c-live', { title: WORK_CONFIRM_RECORD_TITLE })])
expect(await highDangerSource(port, active)).toEqual({ conversationId: 'c-live', entrance: 'button' })
})
})
describe('模板浏览与实例化(U5 合同)', () => {
it('列表:全部条目可见;unsupported 禁用选择并显示 unsupportedReason', () => {
const out = html(createElement(TemplateListView, { templates: [template(), unsupported], selectedKey: '', blockedReason: null, onChoose: noop }))
expect(out).toContain('软件需求规格说明')
expect(out).toContain('风险管理报告')
expect(out).toContain('tpl-srs@1.2.0')
expect(out).toContain('tpl-risk@0.9.0')
expect(out).toContain('不可生成:dhf: 模板包缺失签署区映射')
expect(out).toContain('待复核')
expect(out.match(/disabled=""/g)).toHaveLength(1)
})
it('项目工作已关闭时全部选择禁用', () => {
const out = html(createElement(TemplateListView, { templates: [template(), unsupported], selectedKey: '', blockedReason: WORK_STATE_NOTICES.closed, onChoose: noop }))
expect(out.match(/disabled=""/g)).toHaveLength(2)
})
it('可选判定与 instantiate 同源:generation≠supported 或缺文档类型即不可选', () => {
expect(templateSelectable(template())).toEqual({ ok: true, reason: '' })
expect(templateSelectable(unsupported)).toEqual({ ok: false, reason: 'dhf: 模板包缺失签署区映射' })
expect(templateSelectable(template({ generation: 'unsupported', unsupportedReason: '' })).ok).toBe(false)
expect(templateSelectable(template({ docTypes: [] })).ok).toBe(false)
expect(templateSelectable(template({ docTypes: null })).ok).toBe(false)
})
it('载荷按 paramsSchema 携带 templateId/version 溯源;title 为空不携带', () => {
expect(instantiatePayload('domain-7', template(), 'SRS', ' ')).toEqual({ projectId: 'domain-7', docType: 'SRS', templateId: 'tpl-srs', version: '1.2.0' })
expect(instantiatePayload('domain-7', template(), 'SRS', ' 需求 v1 ')).toEqual({
projectId: 'domain-7', docType: 'SRS', templateId: 'tpl-srs', version: '1.2.0', title: '需求 v1',
})
expect(() => instantiatePayload('domain-7', unsupported, 'SRS')).toThrow('签署区映射')
expect(() => instantiatePayload('domain-7', template(), 'SDD')).toThrow('不在该模板的适用范围内')
})
it('来源正文含模板身份与版本,首字符不为 “/”', () => {
const text = instantiateSourceText(template(), 'SRS', '')
expect(text).toContain('模板:软件需求规格说明(tpl-srs@1.2.0)')
expect(text).toContain('文档标题:沿用模板标题')
expect(text.startsWith('/')).toBe(false)
})
function harness(over: Partial<InstantiatePort> = {}) {
const calls: string[] = []
const port: InstantiatePort = {
listConversations: vi.fn(async () => { calls.push('list'); return [conv('c-1')] }),
createConversation: vi.fn(async (_p: string, title?: string) => { calls.push('create'); return conv('c-new', { title: title ?? '' }) }),
sendMessage: vi.fn(async (convId: string) => { calls.push('send'); return { message: userMsg('m-1', convId) } }),
rdmCommand: vi.fn(async () => { calls.push('command'); return { docId: 'doc-1' } }),
...over,
}
return { port, calls }
}
it('显式选择后:记录来源消息再以其为锚点分发 rdm.docs.instantiate', async () => {
const { port, calls } = harness()
const out = await commitTemplateInstantiate(port, { host: active, operationId: 'op-1', template: template(), docType: 'SRS', title: '' })
expect(out).toEqual({ docId: 'doc-1' })
expect(calls).toEqual(['list', 'send', 'command'])
expect(port.sendMessage).toHaveBeenCalledWith('c-1', expect.stringContaining('tpl-srs@1.2.0'), [], 'instantiate:op-1')
expect(port.rdmCommand).toHaveBeenCalledWith('rdm.docs.instantiate',
{ projectId: 'domain-7', docType: 'SRS', templateId: 'tpl-srs', version: '1.2.0' },
{ conversationId: 'c-1', anchorMessageId: 'm-1', entrance: 'button' })
})
it('unsupported 条目、工作已关闭、未绑定项目:一律拒绝且不记录、不分发', async () => {
for (const [host, tpl, pattern] of [
[active, unsupported, /签署区映射/],
[closed, template(), /项目工作已关闭/],
[project({ 'rdm.state': 'provisioning' }), template(), /正在准备/],
] as const) {
const { port, calls } = harness()
await expect(commitTemplateInstantiate(port, { host, operationId: 'op', template: tpl, docType: 'SRS', title: '' })).rejects.toThrow(pattern)
expect(calls).toEqual([])
}
})
it('来源消息未提交或被识别为命令:不分发', async () => {
const asCommand = harness({ sendMessage: vi.fn(async () => ({ command: '/x' })) })
await expect(commitTemplateInstantiate(asCommand.port, { host: active, operationId: 'op', template: template(), docType: 'SRS', title: '' }))
.rejects.toThrow('识别为命令')
expect(asCommand.port.rdmCommand).not.toHaveBeenCalled()
const wrongConv = harness({ sendMessage: vi.fn(async () => ({ message: userMsg('m-2', 'other') })) })
await expect(commitTemplateInstantiate(wrongConv.port, { host: active, operationId: 'op', template: template(), docType: 'SRS', title: '' }))
.rejects.toThrow('未返回已提交的对话消息')
expect(wrongConv.port.rdmCommand).not.toHaveBeenCalled()
})
})