291 lines
12 KiB
TypeScript
291 lines
12 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',
|
|
'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 })
|
|
|
|
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 })
|
|
|
|
// ---- 项目成员页:候选检索 + 成员表检索(R2 契约:服务端先过滤后分页) ----
|
|
|
|
export type MemberMatchMode = 'fuzzy' | 'exact'
|
|
export interface RdmProfRole { roleId: string; name: string }
|
|
/** rdm.account.list 单条候选。 */
|
|
export interface RdmAccount {
|
|
owner: string; name?: string; valid: boolean; joined?: boolean
|
|
projectRole?: string; profRole?: RdmProfRole | null
|
|
}
|
|
/** 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 结构兼容(纯逻辑层不依赖组件库)。 */
|
|
export interface PersonPickItem {
|
|
id: string; title: string; subtitle?: string; disabled?: boolean; disabledReason?: string
|
|
}
|
|
|
|
export const MEMBER_PAGE_LIMIT = 50
|
|
export const JOINED_REASON = '已加入项目'
|
|
export const MATCH_MODE_LABELS: Record<MemberMatchMode, string> = {
|
|
fuzzy: '模糊匹配',
|
|
exact: '精确账号或姓名',
|
|
}
|
|
|
|
export const roleOptionsPayload = (projectId: string) => ({ projectId, purpose: 'members' as const })
|
|
|
|
/** 空串筛选不携带(缺省=不过滤);match 恒携带,服务端据此选择匹配语义。 */
|
|
export function accountListParams(projectId: string, opts: {
|
|
query?: string; cursor?: string; limit: number; roleId?: string; match?: MemberMatchMode
|
|
}) {
|
|
const query = opts.query?.trim() ?? ''
|
|
return {
|
|
projectId,
|
|
limit: opts.limit,
|
|
match: opts.match ?? 'fuzzy',
|
|
...(query ? { query } : {}),
|
|
...(opts.cursor ? { cursor: opts.cursor } : {}),
|
|
...(opts.roleId ? { roleId: opts.roleId } : {}),
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
/** 候选映射:无效账号不提供;已加入(服务端 joined 或已在成员表)可见但不可选。 */
|
|
export function accountsToPickItems(accounts: RdmAccount[] | undefined, joinedOwners: Iterable<string> = []): PersonPickItem[] {
|
|
const joined = new Set(joinedOwners)
|
|
return (accounts ?? []).filter(a => a.valid).map(a => {
|
|
const isJoined = a.joined === true || joined.has(a.owner)
|
|
return {
|
|
id: a.owner,
|
|
title: a.name?.trim() || a.owner,
|
|
subtitle: a.owner,
|
|
...(isJoined ? { disabled: true, disabledReason: JOINED_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
|
|
}
|