391 lines
16 KiB
TypeScript
391 lines
16 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 })
|
||
|
||
/** 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 '非项目成员'
|
||
case 'creator': return '本人项目创建者自动具备(仅本人创建的项目)'
|
||
case 'role': 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
|
||
}
|