Why: the settings page only needs the draft operations the views call. Extra aliases and unused helpers pushed the unused-export count over the baseline. Evidence: SELF node scripts/check-knip.mjs reported 43 <= 43, and duty-draft vitest passed. Impact: chapter-review command aliases are gone from the settings gate. The draft behavior is unchanged. Refs: modules/frontend/knip-baseline.json Co-authored-by: Cursor <cursoragent@cursor.com>
196 lines
8.4 KiB
TypeScript
196 lines
8.4 KiB
TypeScript
// 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',
|
|
])
|
|
|
|
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 })
|
|
|
|
export const memberAddPayload = (projectId: string, member: string, role: 'owner' | 'member' = 'member') =>
|
|
({ projectId, member, role })
|
|
export const memberRemovePayload = (projectId: string, member: string) => ({ projectId, member })
|
|
export const memberSetRolePayload = (projectId: string, member: string, role: 'owner' | 'member') => ({ projectId, member, role })
|
|
|
|
export const assignDocDutiesPayload = (
|
|
projectId: string, docId: string, author: string, reviewers: string[], approver: string,
|
|
extra?: { expectedDutyVersion?: number; confirmInvalidation?: boolean },
|
|
) => ({
|
|
projectId, docId, author, reviewers, approver,
|
|
...(extra?.expectedDutyVersion !== undefined ? { expectedDutyVersion: extra.expectedDutyVersion } : {}),
|
|
...(extra?.confirmInvalidation !== undefined ? { confirmInvalidation: extra.confirmInvalidation } : {}),
|
|
})
|
|
|
|
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 }[],
|
|
) => ({ 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 })
|
|
|
|
/** 矩阵来源标签(人话映射;未知来源回退原文——服务端新增来源时不至于空白)。 */
|
|
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
|
|
}
|