Files
Agent-System-Frontend/src/rdmManagement.ts
T

389 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// U6/KTD5:RDM 管理与项目页面的纯逻辑层——能力门控(与 rdmAuditReady 同源语义:
// 命令清单不可用=未就绪,fail-closed)与命令载荷构造。页面组件只消费本模块与
// api.rdmCommand,不在组件里散落命令 id / 参数拼写;身份一律经认证会话,载荷不
// 携带任何身份字段(G1-5)。
import type { Project } from './contracts'
import { projectWorkError } from './projectmeta'
export function rdmCommandProjectId(project: Project): string {
const id = project.settings?.['rdm.projectId']
if (typeof id !== 'string' || !id.trim()) {
throw new Error('项目尚未绑定,请联系项目负责人完成项目接入')
}
return id.trim()
}
/** Historical project reads remain available; this does not grant read permission. */
export function rdmProjectReadError(project: Project | undefined): string | null {
const state = project?.settings?.['rdm.state']
if (project && (state === 'ready' || state === 'archived' || state === 'closed')) {
try { rdmCommandProjectId(project); return null }
catch (e) { return String(e).replace(/^Error:\s*/, '') }
}
return projectWorkError(project) ?? '项目尚未绑定,请联系项目负责人完成项目接入'
}
const PROJECT_READ_COMMANDS = new Set([
'rdm.project.member.list', 'rdm.perm.check', 'rdm.perm.matrix',
'rdm.review.doc.status', 'rdm.duty.catalog', 'rdm.duty.get',
'rdm.duty.matrix', 'rdm.duty.preview', 'rdm.duty.legacy.scan',
'rdm.role.options',
])
export function rdmProjectCommandError(project: Project, command: string): string | null {
return PROJECT_READ_COMMANDS.has(command) ? rdmProjectReadError(project) : projectWorkError(project)
}
/** 系统管理「RDM 角色权限」分区消费的命令面。 */
export const RDM_ROLES_COMMANDS = [
'rdm.role.create',
'rdm.role.action',
'rdm.role.assign',
'rdm.role.disable',
'rdm.role.list',
] as const
/** 项目设置「成员」页消费的命令面。 */
export const RDM_MEMBER_COMMANDS = [
'rdm.project.member.list',
'rdm.project.member.add',
'rdm.project.member.remove',
] as const
/** 项目设置「项目权限」页消费的命令面(矩阵解释 + 成员清单)。 */
export const RDM_PERM_MATRIX_COMMANDS = [
'rdm.perm.check',
'rdm.project.member.list',
] as const
/** 项目设置「文档职责」页消费的命令面。审签命令留在文档工作区,不作为本页门控。 */
export const RDM_DUTY_COMMANDS = [
'rdm.duty.matrix',
'rdm.duty.preview',
'rdm.review.assign.doc',
'rdm.review.assign.doc.batch',
] as const
export type CommandListItem = { id?: string; enabled?: boolean } | null | undefined
function ready(cmds: CommandListItem[] | null | undefined, need: readonly string[]): boolean {
if (!Array.isArray(cmds)) return false
return need.every(n => cmds.some(c => c?.id === n && c.enabled === true))
}
/** 角色分区是否就绪(普通基座无 rdm.role.* → 隐藏)。 */
export const rdmRolesReady = (cmds: CommandListItem[] | null | undefined) =>
ready(cmds, RDM_ROLES_COMMANDS)
/** 项目成员页是否就绪。 */
export const rdmMembersReady = (cmds: CommandListItem[] | null | undefined) =>
ready(cmds, RDM_MEMBER_COMMANDS)
/** 文档职责页是否就绪。命令未齐则 fail-closed。 */
export const rdmDutyReady = (cmds: CommandListItem[] | null | undefined) =>
ready(cmds, RDM_DUTY_COMMANDS)
/** 项目权限矩阵页是否就绪。 */
export const rdmPermMatrixReady = (cmds: CommandListItem[] | null | undefined) =>
ready(cmds, RDM_PERM_MATRIX_COMMANDS)
/** RDM 动作清单(12 项,与 RDM perms.AllActions 对齐;角色动作编辑器选项)。 */
export const ROLE_ACTIONS = [
'project.create', 'project.archive', 'member.manage', 'duty.assign',
'stage.advance', 'doc.read', 'doc.write', 'doc.publish', 'review.participate',
'trace.edit', 'knowledge.publish', 'project.settings',
] as const
// ---- 载荷构造(字段名与 RDM 命令 schema 一一对应) ----
export const roleCreatePayload = (name: string) => ({ name })
export const roleActionPayload = (roleId: string, action: string, def: boolean, cap: boolean) =>
({ roleId, action, default: def, cap })
export const roleAssignPayload = (owner: string, roleId: string) => ({ owner, roleId })
export const roleDisablePayload = (roleId: string, disabled: boolean) => ({ roleId, disabled })
/** userId 为候选检索选中的稳定账号;服务端按 member 精确查找后比对,不一致即拒。 */
export const memberAddPayload = (projectId: string, member: string, role: 'owner' | 'member' = 'member', userId?: string) =>
({ projectId, member, role, ...(userId ? { userId } : {}) })
export const memberRemovePayload = (projectId: string, member: string) => ({ projectId, member })
export const memberSetRolePayload = (projectId: string, member: string, role: 'owner' | 'member') => ({ projectId, member, role })
/** 职责账号身份:owner → 候选检索选中的 userId。 */
export type DutyAccountIds = Record<string, string>
/** 只保留本条职责实际涉及的人;为空不携带。 */
export function dutyAccountIds(
roles: { author: string; reviewers: string[]; approver: string }, known: DutyAccountIds | undefined,
): DutyAccountIds | undefined {
if (!known) return undefined
const out: DutyAccountIds = {}
for (const owner of [roles.author, ...roles.reviewers, roles.approver]) {
if (owner && known[owner]) out[owner] = known[owner]
}
return Object.keys(out).length > 0 ? out : undefined
}
export const assignDocDutiesPayload = (
projectId: string, docId: string, author: string, reviewers: string[], approver: string,
extra?: { expectedDutyVersion?: number; confirmInvalidation?: boolean; accountIds?: DutyAccountIds },
) => {
const accountIds = dutyAccountIds({ author, reviewers, approver }, extra?.accountIds)
return {
projectId, docId, author, reviewers, approver,
...(extra?.expectedDutyVersion !== undefined ? { expectedDutyVersion: extra.expectedDutyVersion } : {}),
...(extra?.confirmInvalidation !== undefined ? { confirmInvalidation: extra.confirmInvalidation } : {}),
...(accountIds ? { accountIds } : {}),
}
}
export const dutyMatrixPayload = (
projectId: string,
query: {
view: 'member' | 'document' | 'matrix'
memberOwner?: string
docId?: string
query?: string
stage?: string
typeKey?: string
assignment?: string
memberQuery?: string
memberCursor?: string
docCursor?: string
memberLimit?: number
docLimit?: number
},
) => ({ projectId, ...query })
export const dutyPreviewPayload = (
projectId: string,
docs: {
docId: string; author: string; reviewers: string[]; approver: string
expectedDutyVersion: number; confirmInvalidation: boolean; accountIds?: DutyAccountIds
}[],
) => ({ projectId, docs })
export const dutyLegacyPayload = (projectId: string) => ({ projectId })
export const docSubmitPayload = (projectId: string, docId: string) => ({ projectId, docId })
export const docApprovePayload = (projectId: string, docId: string, note?: string) =>
note ? { projectId, docId, note } : { projectId, docId }
export const docRejectPayload = (projectId: string, docId: string, reason?: string) =>
reason ? { projectId, docId, reason } : { projectId, docId }
/** round=0(未指定)不携带——服务端只认在途轮次,旧轮次传参即拒。 */
export const docFinalPayload = (projectId: string, docId: string, round = 0) =>
round > 0 ? { projectId, docId, round } : { projectId, docId }
export const docStatusPayload = (projectId: string, docId: string) => ({ projectId, docId })
export const permCheckPayload = (projectId: string, member: string) => ({ projectId, member })
/**
* 命令来源(基座 cmdbus.RequiresProjectWorkDecision):正式写命令必须带已提交的对话来源,
* 且位于请求体顶层(conversationId/anchorMessageId/entrance),不得放进 params。
* entrance 取服务端 CanonicalEntrance 集合;页面动作为 button。
*/
export interface CommandSource {
conversationId: string
anchorMessageId: string
entrance: 'button' | 'card' | 'slash' | 'chat' | 'api' | 'agent'
}
export const commandRequestBody = (params: Record<string, unknown>, source?: CommandSource) =>
source
? { params, conversationId: source.conversationId, anchorMessageId: source.anchorMessageId, entrance: source.entrance }
: { params }
// ---- 项目成员页:候选检索 + 成员表检索(R2 契约:服务端先过滤后分页) ----
export type MemberMatchMode = 'fuzzy' | 'exact'
type AccountSearchField = 'owner' | 'name'
export interface RdmProfRole { roleId: string; name: string }
/** rdm.account.list 单条候选。 */
export interface RdmAccount {
userId: string; owner: string; name?: string; valid: boolean; joined?: boolean
projectRole?: string; profRole?: RdmProfRole | null
}
export interface RdmAccountPage {
accounts?: RdmAccount[]; next?: string; directoryVersion?: string; count?: number
}
/** rdm.project.member.list 单条成员。 */
export interface RdmMemberRow {
owner: string; name?: string; role: string; addedBy: string; addedAt: string
profRole?: RdmProfRole | null
}
export interface RdmMemberPage {
members: RdmMemberRow[]; next?: string; total?: number; accountDirectoryAvailable?: boolean
}
/** 与 @ui PickItem 结构兼容(纯逻辑层不依赖组件库);id 为稳定 userId,owner 为提交用账号。 */
export interface PersonPickItem {
id: string; owner: string; title: string; subtitle?: string; disabled?: boolean; disabledReason?: string
}
export const MEMBER_PAGE_LIMIT = 50
const ACCOUNT_PAGE_LIMIT = 50
export const JOINED_REASON = '已加入项目'
export const INVALID_REASON = '账号无效'
export const NOT_MEMBER_REASON = '非当前项目成员'
export const MATCH_MODE_LABELS: Record<MemberMatchMode, string> = {
fuzzy: '模糊匹配',
exact: '精确账号或姓名',
}
export type SearchFieldsChoice = 'both' | 'owner' | 'name'
export const SEARCH_FIELDS: Record<SearchFieldsChoice, { label: string; fields: AccountSearchField[] }> = {
both: { label: '账号和姓名', fields: ['owner', 'name'] },
owner: { label: '仅账号', fields: ['owner'] },
name: { label: '仅姓名', fields: ['name'] },
}
export const roleOptionsPayload = (projectId: string) => ({ projectId, purpose: 'members' as const })
interface AccountSearchOptions {
mode?: MemberMatchMode
searchFields?: AccountSearchField[]
roleId?: string
/** 携带即按该文档的职责候选检索(仅当前有效成员)。 */
docId?: string
}
/** 只用 mode(match 是服务端旧别名,两者并存且不一致会被拒);空串筛选不携带。 */
export function accountListParams(projectId: string, opts: AccountSearchOptions & {
query?: string; cursor?: string; limit: number
}) {
const query = opts.query?.trim() ?? ''
return {
projectId,
limit: Math.min(Math.max(1, opts.limit), ACCOUNT_PAGE_LIMIT),
mode: opts.mode ?? 'fuzzy',
...(opts.searchFields && opts.searchFields.length > 0 ? { searchFields: [...opts.searchFields] } : {}),
...(query ? { query } : {}),
...(opts.cursor ? { cursor: opts.cursor } : {}),
...(opts.roleId ? { roleId: opts.roleId } : {}),
...(opts.docId ? { docId: opts.docId } : {}),
}
}
/** Picker reloadKey:项目/文档/匹配方式/检索字段/角色任一变化都清空查询与游标。 */
export function accountSearchKey(projectId: string, opts: AccountSearchOptions): string {
return JSON.stringify([projectId, opts.docId ?? '', opts.mode ?? 'fuzzy', opts.searchFields ?? [], opts.roleId ?? ''])
}
/**
* Picker 取数适配。空 query 不发请求(服务端空查询不枚举);cursor 只在签发它的
* 同一检索条件(query + 选项)下沿用,条件变化即回到首页。
*/
export function accountCandidatePager(
run: (params: ReturnType<typeof accountListParams>) => Promise<RdmAccountPage>,
projectId: string,
opts: AccountSearchOptions & { joinedOwners?: () => Iterable<string> },
) {
const base = accountSearchKey(projectId, opts)
let issuedFor = ''
return async (query: string, cursor: string, limit: number): Promise<{ items: PersonPickItem[]; nextCursor?: string }> => {
const q = query.trim()
if (!q) { issuedFor = ''; return { items: [] } }
const key = `${base}|${q}`
const out = await run(accountListParams(projectId, { ...opts, query: q, cursor: key === issuedFor ? cursor : '', limit }))
issuedFor = out.next ? key : ''
const items = accountsToPickItems(out.accounts, opts.joinedOwners?.() ?? [], opts.docId ? 'duty' : 'member')
return { items, nextCursor: out.next || undefined }
}
}
export function memberListParams(projectId: string, opts: {
query?: string; roleId?: string; cursor?: string; limit?: number
match?: MemberMatchMode; projectRole?: 'owner' | 'member'
} = {}) {
const query = opts.query?.trim() ?? ''
return {
projectId,
limit: opts.limit ?? MEMBER_PAGE_LIMIT,
...(query ? { query } : {}),
...(opts.roleId ? { roleId: opts.roleId } : {}),
...(opts.cursor ? { cursor: opts.cursor } : {}),
...(opts.match ? { match: opts.match } : {}),
...(opts.projectRole ? { projectRole: opts.projectRole } : {}),
}
}
/** 人员标签:「姓名 · 账号」,姓名为空时只显示账号。 */
export function personLabel(p: { owner: string; name?: string }): string {
const name = p.name?.trim()
return name && name !== p.owner ? `${name} · ${p.owner}` : p.owner
}
/**
* 候选映射(id=userId;无 userId 的行无法稳定提交,不提供)。无效账号可见但不可选;
* member:已加入(服务端 joined 或已在成员表)不可选;duty:只有当前成员可选。
*/
export function accountsToPickItems(
accounts: RdmAccount[] | undefined, joinedOwners: Iterable<string> = [], purpose: 'member' | 'duty' = 'member',
): PersonPickItem[] {
const joined = new Set(joinedOwners)
return (accounts ?? []).filter(a => a.userId && a.owner).map(a => {
const isJoined = a.joined === true || joined.has(a.owner)
const reason = !a.valid ? INVALID_REASON
: purpose === 'member' && isJoined ? JOINED_REASON
: purpose === 'duty' && !isJoined ? NOT_MEMBER_REASON : ''
return {
id: a.userId,
owner: a.owner,
title: a.name?.trim() || a.owner,
subtitle: a.owner,
...(reason ? { disabled: true, disabledReason: reason } : {}),
}
})
}
/** 成员表追加下一页:保序,已存在的账号不重复。 */
export function appendMemberPage(prev: RdmMemberRow[] | null, page: RdmMemberRow[] | undefined): RdmMemberRow[] {
const out = [...(prev ?? [])]
const seen = new Set(out.map(m => m.owner))
for (const m of page ?? []) {
if (seen.has(m.owner)) continue
seen.add(m.owner)
out.push(m)
}
return out
}
/** 矩阵来源标签(人话映射;未知来源回退原文——服务端新增来源时不至于空白)。 */
export function describeSource(source: string): string {
switch (source) {
case 'admin': return '系统管理员'
case 'prof-role-default': return '专业角色默认'
case 'override': return '项目内覆盖'
case 'relation-default': return '项目关系默认'
case 'non-member': return '非项目成员'
default: return source
}
}
/** 角色清单表格占位行文案:加载中仅在无错误时成立——rdm.role.list 被拒后
* 停留「加载中…」是 2026-10-13 8788 锁死事故的误导性表象(表格永远在转圈)。 */
export function rolesListPlaceholder(roles: unknown[] | null, err: string): string {
if (roles !== null) return roles.length === 0 ? '暂无专业角色' : ''
return err ? '角色清单未加载(见下方提示)' : '加载中…'
}
/** rdm.role.list 错误文案映射:域管理员权限拒绝给人话 + 恢复通道;其余错误
* 原样保留(服务端 reason 变化不至于空白)。台账单管理员制见 RDM G1-4/U1。 */
export function describeRoleListError(raw: string): string {
if (raw.includes('不是系统管理员')) {
return '当前账号不是 RDM 域管理员(台账单管理员制),无法读取角色清单;'
+ '请联系现任管理员经 rdm.perm.transfer 移交,或由部署侧受信通道恢复'
}
return raw
}
/** 审阅人 CSV 解析:去空白、去空项、保序去重(表单输入 → reviewers 数组)。 */
export function parseReviewers(csv: string): string[] {
const seen = new Set<string>()
const out: string[] = []
for (const part of csv.split(',')) {
const v = part.trim()
if (!v || seen.has(v)) continue
seen.add(v)
out.push(v)
}
return out
}