@@ -3,14 +3,14 @@
// 门控集中在 src/rdmManagement.ts;分区可见性由 AdminZone/ProjectSettingsModal
// 按 store 的 rdm*Available 门控(命令面不齐不渲染,普通基座零暴露)。样式只复用
// 既有 ad-*/lg-*/settings-* 类(design-guard 棘轮:不新增裸色值/字号)。
import { useEffect , useState } from 'react'
import { useEffect , useState , useRef , useCallback } from 'react'
import { api } from '../api'
import { useStore } from '../store'
import { Btn , EmptyState , Icon } from '@ui'
import { Btn , Collapsible , EmptyState , Icon , Picker } from '@ui'
import {
ROLE_ACTIONS , parseReviewers ,
ROLE_ACTIONS ,
roleCreatePayload , roleActionPayload , roleAssignPayload , roleDisablePayload ,
memberAddPayload , memberRemovePayload ,
memberAddPayload , memberRemovePayload , memberSetRolePayload ,
assignDocDutiesPayload , docSubmitPayload , docApprovePayload , docRejectPayload ,
docFinalPayload , docStatusPayload , permCheckPayload , describeSource ,
rolesListPlaceholder , describeRoleListError ,
@@ -144,17 +144,43 @@ export function RdmRolesPanel() {
)
}
// ---- 项目设置:成员 ----
// ---- 项目设置:成员(P7:可搜索人员选择器 + 专业角色/项目身份两列 + 显式身份调整) ----
interface MemberRow { owner : string ; role : string ; addedBy : string ; addedAt : string }
interface MemberRow {
owner : string ; role : string ; addedBy : string ; addedAt : string
profRole ? : { roleId : string ; name : string } | null
}
export function RdmMembersSection ( { projectId } : { projectId : string } ) {
const [ rows , setRows ] = useState < MemberRow [ ] | null > ( null )
const [ err , setErr ] = useState ( '' )
const [ notice , setNotice ] = useState ( '' )
const [ busy , setBusy ] = useState ( false )
const [ member , setMember ] = useState ( '' )
const [ picked , setPicked ] = useState < string [ ] > ( [ ] )
const [ role , setRole ] = useState < 'member' | 'owner' > ( 'member' )
// 成员表经 ref 参与取数(已加入标记),保持 fetchPage 身份稳定(Picker 依赖)。
const rowsRef = useRef < MemberRow [ ] | null > ( null )
rowsRef . current = rows
/** 人员选择器取数适配(P7-A):rdm.account.list 目录(member.manage 生效者)。
* query 透传后端(基座升级 AccountSearcher 后全量生效);旧基座忽略 query 时
* 对首页做本地包含匹配降级(翻页为浏览模式,不过滤)。 */
const fetchPage = useCallback ( async ( query : string , cursor : string , limit : number ) = > {
const out = await api . rdmCommand < { accounts ? : { owner : string ; name? : string ; valid : boolean } [ ] ; next? : string } > (
'rdm.account.list' , { projectId , cursor , limit , query } )
const joined = new Set ( ( rowsRef . current ? ? [ ] ) . map ( m = > m . owner ) )
let items = ( out . accounts ? ? [ ] ) . filter ( a = > a . valid ) . map ( a = > ( {
id : a.owner ,
title : a.name || a . owner ,
subtitle : a.owner ,
badge : joined.has ( a . owner ) ? '已加入' : undefined ,
} ) )
if ( query !== '' && cursor === '' ) {
const q = query . toLowerCase ( )
items = items . filter ( it = > it . id . toLowerCase ( ) . includes ( q ) || it . title . toLowerCase ( ) . includes ( q ) )
}
return { items , nextCursor : out.next || undefined }
} , [ projectId ] )
const refresh = async ( ) = > {
try {
@@ -162,7 +188,7 @@ export function RdmMembersSection({ projectId }: { projectId: string }) {
setRows ( out . members ? ? [ ] )
} catch ( e ) { setErr ( String ( e ) ) }
}
useEffect ( ( ) = > { refresh ( ) } , [ projectId ] )
useEffect ( ( ) = > { setPicked ( [ ] ) ; refresh ( ) } , [ projectId ] )
const run = async ( label : string , fn : ( ) = > Promise < unknown > ) = > {
setErr ( '' ) ; setNotice ( '' ) ; setBusy ( true )
@@ -170,40 +196,56 @@ export function RdmMembersSection({ projectId }: { projectId: string }) {
catch ( e ) { setErr ( String ( e ) ) } finally { setBusy ( false ) }
}
const pickedId = picked [ 0 ] ? ? ''
return (
< div >
< div className = "ad-create" >
< div className = "ad-row" >
< input className = "lg-input lg-mono" placeholder = "成员账号(owner) " value = { member }
onChange = { e = > setMember ( e . target . value ) } / >
< Picker
aria-label = "选择人员"
selected = { picked }
onSelect = { setPicked }
fetchPage = { fetchPage }
placeholder = "输入账号或姓名,或浏览候选…"
/ >
< select className = "lg-input" value = { role } onChange = { e = > setRole ( e . target . value as 'member' | 'owner' ) } >
< option value = "member" > member ( 成 员 ) < / option >
< option value = "owner" > owner ( 负 责 人 ) < / option >
< option value = "member" > 项 目 成 员 < / option >
< option value = "owner" > 项 目 负 责 人 < / option >
< / select >
< button className = "lg-submit ad-create-btn" disabled = { busy || ! member . trim ( ) }
onClick = { ( ) = > run ( ` 已添加 ${ member . trim ( ) } ` , async ( ) = > {
await api . rdmCommand ( 'rdm.project.member.add' , memberAddPayload ( projectId , member . trim ( ) , role ) )
setMember ( '' )
< button className = "lg-submit ad-create-btn" disabled = { busy || ! pickedId }
onClick = { ( ) = > run ( ` 已添加 ${ pickedId } ` , async ( ) = > {
await api . rdmCommand ( 'rdm.project.member.add' , memberAddPayload ( projectId , pickedId , role ) )
setPicked ( [ ] )
} ) } > 添 加 成 员 < / button >
< / div >
< p className = "ad-hint" > 加 入 后 成 员 即 可 见 本 项 目 ; 移 除 即 时 生 效 。 最 后 一 位 负 责 人 不 可 移 除 / 降 级 。 < / p >
< p className = "ad-hint" > 候 选 来 自 系 统 账 号 目 录 ( 姓 名 为 空 时 显 示 账 号 ) ; 「 已 加 入 」 标 记 避 免 重 复 添 加 造 成 隐 式 变 更 — — 调 整 身 份 请 在 成 员 表 中 操 作 。 最 后 一 位 负 责 人 不 可 移 除 / 降 级 。 < / p >
< / div >
< table className = "ad-table" >
< thead > < tr > < th > 账 号 < / th > < th > 项 目 角 色 < / th > < th > 添 加 人 < / th > < th > 添 加 时 间 < / th > < th > < / th > < / tr > < / thead >
< thead > < tr > < th > 成 员 < / th > < th > 专 业 角 色 < / th > < th > 项 目 身 份 < / th > < th > 添 加 人 < / th > < th > 添 加 时 间 < / th > < th > < / th > < / tr > < / thead >
< tbody >
{ ( rows ? ? [ ] ) . map ( m = > (
< tr key = { m . owner } >
< td className = "ad-mono" > { m . owner } < / td >
< td > { m . role === 'owner' ? < span className = "ad-chip ad-chip-admin" > OWNER < / span > : < span className = "ad-chip" > MEMBER < / span > } < / td >
< td > < span className = "ad-mono" > { m . owner } < / span > { m . profRole ? . name ? < span className = "ad-dim" > · { m . profRole . name } < / span > : null } < / td >
< td className = "ad-dim" > { m . profRole ? . name || '—' } < / td >
< td > { m . role === 'owner' ? < span className = "ad-chip ad-chip-admin" > 项 目 负 责 人 < / span > : < span className = "ad-chip" > 项 目 成 员 < / span > } < / td >
< td className = "ad-dim" > { m . addedBy || '—' } < / td >
< td className = "ad-dim" > { m . addedAt ? new Date ( m . addedAt ) . toLocaleString ( ) : '—' } < / td >
< td > < button className = "ad-mini" disabled = { busy }
onClick = { ( ) = > run ( ` 已移除 ${ m . owner } ` ,
( ) = > api . rdmCommand ( 'rdm.project.member.remove' , memberRemovePayload ( projectId , m . owner ) ) ) } > 移 除 < / button > < / td >
< td >
< button className = "ad-mini" disabled = { busy }
title = { m . role === 'owner' ? '降为项目成员(setRole) ' : '升为项目负责人(setRole) ' }
onClick = { ( ) = > run ( ` 已调整 ${ m . owner } 的项目身份 ` , ( ) = >
api . rdmCommand ( 'rdm.project.member.setRole' , memberSetRolePayload ( projectId , m . owner , m . role === 'owner' ? 'member' : 'owner' ) ) ) } >
{ m . role === 'owner' ? '降为成员' : '升为负责人' }
< / button > { ' ' }
< button className = "ad-mini" disabled = { busy }
onClick = { ( ) = > run ( ` 已移除 ${ m . owner } ` ,
( ) = > api . rdmCommand ( 'rdm.project.member.remove' , memberRemovePayload ( projectId , m . owner ) ) ) } > 移 除 < / button >
< / td >
< / tr >
) ) }
{ rows !== null && rows . length === 0 && < tr > < td colSpan = { 5 } className = "ad-dim" > 暂 无 成 员 < / td > < / tr > }
{ rows === null && < tr > < td colSpan = { 5 } className = "ad-dim" > 加 载 中 … < / td > < / tr > }
{ rows !== null && rows . length === 0 && < tr > < td colSpan = { 6 } className = "ad-dim" > 暂 无 成 员 < / td > < / tr > }
{ rows === null && < tr > < td colSpan = { 6 } className = "ad-dim" > 加 载 中 … < / td > < / tr > }
< / tbody >
< / table >
{ notice && < div className = "ad-notice" > < Icon name = "check" size = { 14 } / > { notice } < / div > }
@@ -212,7 +254,7 @@ export function RdmMembersSection({ projectId }: { projectId: string }) {
)
}
// ---- 项目设置:文档职责 · 整文审签 ----
// ---- 项目设置:文档职责(P7-B:文档清单+选中职责详情;方案 §4) ----
interface DocSubmissionView {
round : number
@@ -225,87 +267,194 @@ interface DocSubmissionView {
sections : Record < string , number >
}
interface DutyCatalogDoc {
docId : string ; typeKey : string ; title : string ; stage? : string ; sections : number
assigned : boolean ; author? : string ; reviewers? : string [ ] ; approver? : string ; activeRound? : number
}
interface DutyGetOut {
docId : string ; title : string ; assigned : boolean
roles : { author : string ; reviewers : string [ ] ; approver : string }
active ? : { round : number ; state : string ; createdBy : string } | null
sections : { sectionId : string ; title : string ; overridden : boolean } [ ]
}
/** 职责候选 = 当前项目成员(方案 §4:候选只能来自项目成员)。
* member.list 为小全量列表:本地 query 过滤 + 游标分页。 */
function useMemberPickerPage ( members : { owner : string ; role : string } [ ] | null ) {
return useCallback ( async ( query : string , cursor : string , limit : number ) = > {
const q = query . trim ( ) . toLowerCase ( )
const all = ( members ? ? [ ] )
. filter ( m = > q === '' || m . owner . toLowerCase ( ) . includes ( q ) )
. map ( m = > ( { id : m.owner , title : m.owner , subtitle : m.role === 'owner' ? '项目负责人' : '项目成员' } ) )
const start = cursor === '' ? 0 : all.findIndex ( x = > x . id === cursor ) + 1
const page = all . slice ( start , start + limit )
return {
items : page ,
nextCursor : start + limit < all . length ? page [ page . length - 1 ] ? . id : undefined ,
}
} , [ members ] )
}
export function RdmDutiesSection ( { projectId } : { projectId : string } ) {
const [ docs , setDocs ] = useState < DutyCatalogDoc [ ] | null > ( null )
const [ members , setMembers ] = useState < { owner : string ; role : string } [ ] | null > ( null )
const [ docId , setDocId ] = useState ( '' )
const [ detail , setDetail ] = useState < DutyGetOut | null | undefined > ( undefined )
const [ author , setAuthor ] = useState < string [ ] > ( [ ] )
const [ reviewers , setReviewers ] = useState < string [ ] > ( [ ] )
const [ approver , setApprover ] = useState < string [ ] > ( [ ] )
const [ sub , setSub ] = useState < DocSubmissionView | null | undefined > ( undefined )
const [ batchSel , setBatchSel ] = useState < Set < string > > ( new Set ( ) )
const [ batchAuthor , setBatchAuthor ] = useState < string [ ] > ( [ ] )
const [ batchReviewers , setBatchReviewers ] = useState < string [ ] > ( [ ] )
const [ batchApprover , setBatchApprover ] = useState < string [ ] > ( [ ] )
const [ err , setErr ] = useState ( '' )
const [ notice , setNotice ] = useState ( '' )
const [ busy , setBusy ] = useState ( false )
const [ author , setAuthor ] = useState ( '' )
const [ reviewers , setReviewers ] = useState ( '' )
const [ approver , setApprover ] = useState ( '' )
const cmd = < T , > ( id : string , params : Record < string , unknown > ) = > api . rdmCommand < T > ( id , params )
const memberPage = useMemberPickerPage ( members )
const refreshCatalog = async ( ) = > {
try {
const out = await cmd < { docs : DutyCatalogDoc [ ] } > ( 'rdm.duty.catalog' , { projectId } )
setDocs ( out . docs ? ? [ ] )
} catch ( e ) { setErr ( String ( e ) ) ; setDocs ( [ ] ) }
}
const refreshStatus = async ( id : string ) = > {
if ( ! id ) return
if ( ! id ) { setSub ( undefined ) ; return }
try {
const out = await cmd < { submission : DocSubmissionView | null } > ( 'rdm.review.doc.status' , docStatusPayload ( projectId , id ) )
setSub ( out . submission )
} catch ( e ) { setErr ( String ( e ) ) ; setSub ( undefined ) }
} catch ( e ) { setSub ( undefined ) }
}
/** 选中文档:读当前职责(duty.get)并回填选择器(方案:当前职责与冻结名单分列)。 */
const selectDoc = async ( id : string ) = > {
setErr ( '' ) ; setNotice ( '' ) ; setDocId ( id ) ; setDetail ( undefined ) ; setBatchSel ( new Set ( ) )
if ( ! id ) return
try {
const out = await cmd < DutyGetOut > ( 'rdm.duty.get' , { projectId , docId : id } )
setDetail ( out )
setAuthor ( out . assigned && out . roles . author ? [ out . roles . author ] : [ ] )
setReviewers ( out . assigned ? out . roles . reviewers ? ? [ ] : [ ] )
setApprover ( out . assigned && out . roles . approver ? [ out . roles . approver ] : [ ] )
} catch ( e ) { setErr ( String ( e ) ) }
await refreshStatus ( id )
}
useEffect ( ( ) = > {
setDocId ( '' ) ; setDetail ( undefined ) ; setSub ( undefined ) ; setBatchSel ( new Set ( ) )
refreshCatalog ( )
api . rdmCommand < { members : { owner : string ; role : string } [ ] } > ( 'rdm.project.member.list' , { projectId } )
. then ( out = > setMembers ( out . members ? ? [ ] ) ) . catch ( ( ) = > setMembers ( [ ] ) )
} , [ projectId ] )
const run = async ( label : string , fn : ( ) = > Promise < unknown > ) = > {
setErr ( '' ) ; setNotice ( '' ) ; setBusy ( true )
try { await fn ( ) ; setNotice ( label ) ; await refreshStatus ( docId ) }
try { await fn ( ) ; setNotice ( label ) ; await refreshCatalog ( ) ; if ( docId ) await selectDoc ( docId ) }
catch ( e ) { setErr ( String ( e ) ) } finally { setBusy ( false ) }
}
const dirty = detail != null && (
( author [ 0 ] ? ? '' ) !== ( detail . assigned ? detail . roles . author : '' )
|| JSON . stringify ( reviewers ) !== JSON . stringify ( detail . assigned ? detail . roles . reviewers ? ? [ ] : [ ] )
|| ( approver [ 0 ] ? ? '' ) !== ( detail . assigned ? detail . roles . approver : '' ) )
const saveDisabled = busy || ! docId || ! author [ 0 ] || ! approver [ 0 ] || reviewers . length === 0
|| ( detail !== null && detail !== undefined && detail . active != null && ! window . confirm (
` 文档「 ${ detail . title } 」在途第 ${ detail . active . round } 轮审签将因职责变更失效,需重新提交。确认变更? ` ) )
const save = ( ) = > run ( '已保存当前职责' , ( ) = >
cmd ( 'rdm.review.assign.doc' , assignDocDutiesPayload ( projectId , docId , author [ 0 ] , reviewers , approver [ 0 ] ) ) )
const batchApply = ( ) = > run ( ` 已批量分配 ${ batchSel . size } 份文档 ` , ( ) = >
cmd ( 'rdm.review.assign.doc.batch' , { projectId , docs : [ . . . batchSel ] . map ( d = > ( {
docId : d , author : batchAuthor [ 0 ] , reviewers : batchReviewers , approver : batchApprover [ 0 ] } ) ) } ) )
return (
< div >
< div className = "ad-create" >
< div className = "ad-row " >
< input className = "lg-input lg-mono" placeholder = "文档 ID( docId) " value = { docId }
onChange = { e = > setDocId ( e . target . value ) } / >
< Btn variant = "ghost" icon = "eye" disabled = { busy || ! docId }
onClick = { ( ) = > refreshStatus ( docId ) } > 查 看 整 文 状 态 < / Btn >
< h4 className = "settings-group-title" > 文 档 清 单 ( 职 责 分 配 对 象 为 整 份 文 档 ; 搜 索 / 浏 览 同 一 目 录 ) < / h4 >
< table className = "ad-table " >
< thead > < tr > < th > < / th > < th > 文 档 < / th > < th > 类 型 / 阶 段 < / th > < th > 当 前 职 责 < / th > < th > 状 态 < / th > < th > < / th > < / tr > < / thead >
< tbody >
{ ( docs ? ? [ ] ) . map ( d = > (
< tr key = { d . docId } className = { d . docId === docId ? 'ad-row-on' : '' } >
< td > < input type = "checkbox" checked = { batchSel . has ( d . docId ) }
onChange = { e = > { const n = new Set ( batchSel ) ; e . target . checked ? n . add ( d . docId ) : n . delete ( d . docId ) ; setBatchSel ( n ) } } / > < / td >
< td > < button className = "ad-mini" onClick = { ( ) = > selectDoc ( d . docId ) } > { d . title } < / button >
< span className = "ad-dim ad-mono" > · { d . docId } < / span > < / td >
< td className = "ad-dim" > { d . typeKey } { d . stage ? ` / ${ d . stage } ` : '' } < / td >
< td className = "ad-dim" > { d . assigned
? ` 编 ${ d . author } · 审 ${ ( d . reviewers ? ? [ ] ) . join ( '、' ) } · 批 ${ d . approver } ` : '未分配' } < / td >
< td > { d . activeRound ? < span className = "ad-chip ad-chip-warn" > 第 { d . activeRound } 轮 在 途 < / span > : d . assigned ? < span className = "ad-chip" > — < / span > : < span className = "ad-chip" > 未 分 配 < / span > } < / td >
< td className = "ad-dim" > { d . sections } 章 节 < / td >
< / tr >
) ) }
{ docs === null && < tr > < td colSpan = { 6 } className = "ad-dim" > 加 载 中 … < / td > < / tr > }
{ docs !== null && docs . length === 0 && < tr > < td colSpan = { 6 } className = "ad-dim" > 暂 无 文 档 < / td > < / tr > }
< / tbody >
< / table >
{ detail === null && docId && < EmptyState icon = "inbox" > 读 取 职 责 失 败 < / EmptyState > }
{ detail && (
< div className = "settings-group" style = { { marginTop : 14 } } >
< h4 > { detail . title } · 当 前 职 责 { detail . active ? < span className = "ad-chip ad-chip-warn" > 在 途 第 { detail . active . round } 轮 ( 变 更 将 失 效 需 重 提 交 ) < / span > : null } < / h4 >
< div className = "setting-row" > < label > 编 制 人 ( 单 选 ) < / label >
< div style = { { maxWidth : 320 } } > < Picker aria-label = "编制人" selected = { author } onSelect = { setAuthor } fetchPage = { memberPage } / > < / div > < / div >
< div className = "setting-row" > < label > 审 核 人 ( 多 选 ≥ 1 ) < / label >
< div style = { { maxWidth : 320 } } > < Picker aria-label = "审核人" multiple selected = { reviewers } onSelect = { setReviewers } fetchPage = { memberPage } / > < / div >
{ reviewers . length > 0 && < span className = "ad-dim" > 已 选 : { reviewers . join ( '、' ) } < / span > } < / div >
< div className = "setting-row" > < label > 批 准 人 ( 单 选 ) < / label >
< div style = { { maxWidth : 320 } } > < Picker aria-label = "批准人" selected = { approver } onSelect = { setApprover } fetchPage = { memberPage } / > < / div > < / div >
< div className = "ad-row" >
< Btn variant = "primary" icon = "check" disabled = { saveDisabled || ! dirty } onClick = { save } > 保 存 当 前 职 责 < / Btn >
{ detail . active && < span className = "ad-hint" > 保 存 将 使 在 途 第 { detail . active . round } 轮 失 效 ( 提 交 人 { detail . active . createdBy } ) < / span > }
< / div >
{ detail . sections . length > 0 && (
< p className = "ad-hint" > 章 节 覆 盖 : { detail . sections . filter ( x = > x . overridden ) . length } / { detail . sections . length } 章 节 单 独 指 派 ( 覆 盖 文 档 默 认 ) 。 < / p >
) }
< / div >
< / div >
) }
{ sub === null && < EmptyState icon = "inbox" > 该 文 档 尚 无 整 文 提 交 记 录 < / EmptyState > }
{ sub && (
< div className = "settings-group" >
< h4 > 第 { sub . round } 轮 · { sub . state === 'submitted' ? < span className = "ad-chip ad-chip-warn" > 审 签 在 途 ( 全 文 锁 定 ) < / span > : sub . state === 'final' ? < span className = "ad-chip ad-chip-admin" > 已 终 批 < / span > : < span className = "ad-chip" > 已 驳 回 < / span > } < / h4 >
< div className = "setting-row" > < label > 三 权 < / label > < span > 编 制 < b className = "ad-mono" > { sub . duties . author } < / b > · 审 核 < b className = "ad-mono" > { ( sub . duties . reviewers ? ? [ ] ) . join ( '、' ) } < / b > · 批 准 < b className = "ad-mono" > { sub . duties . approver } < / b > < / span > < / div >
< div className = "settings-group" style = { { marginTop : 14 } } >
< h4 > 整 文 审 签 · 第 { sub . round } 轮 · { sub . state === 'submitted' ? < span className = "ad-chip ad-chip-warn" > 在 途 ( 全 文 锁 定 ) < / span > : sub . state === 'final' ? < span className = "ad-chip ad-chip-admin" > 已 终 批 < / span > : < span className = "ad-chip" > 已 驳 回 < / span > } < / h4 >
< div className = "setting-row" > < label > 冻 结 名 单 < / label > < span > 编 < b className = "ad-mono" > { sub . duties . author } < / b > · 审 < b className = "ad-mono" > { ( sub . duties . reviewers ? ? [ ] ) . join ( '、' ) } < / b > · 批 < b className = "ad-mono" > { sub . duties . approver } < / b > < / span > < / div >
< div className = "setting-row" > < label > 审 核 进 度 < / label > < span > { Object . entries ( sub . reviews ? ? { } ) . map ( ( [ r , ok ] ) = > ` ${ r } : ${ ok ? '✓' : '…' } ` ) . join ( ' ' ) || '—' } < / span > < / div >
< div className = "setting-row" > < label > 冻 结 摘 要 < / label > < span className = "ad-mono ad-dim" > { ( sub . digest ? ? '' ) . slice ( 0 , 16 ) } … < / span > < / div >
< div className = "ad-row" style = { { marginTop : 8 } } >
< button className = "lg-submit ad-create-btn" disabled = { busy || sub . state !== 'submitted' }
onClick = { ( ) = > run ( '整文终批完成(各章节已基线 ) ' ,
( ) = > cmd ( 'rdm.review.doc.final ' , docFinalPayload ( projectId , docId ) ) ) } > 终 批 ( 批 准 人 ) < / button >
< button className = "lg-submit ad-create-btn" disabled = { busy || ! docId }
onClick = { ( ) = > run ( '整文已提交审核(全文锁定 ) ' ,
( ) = > cmd ( 'rdm.review.doc.submit ' , docSubmitPayload ( projectId , docId ) ) ) } > 整 文 提 交 ( 编 制 人 ) < / button >
< button className = "ad-mini" disabled = { busy || sub . state !== 'submitted' }
onClick = { ( ) = > run ( '已通过整文审核' ,
( ) = > cmd ( 'rdm.review.doc.approve' , docApprovePayload ( projectId , docId , '通过' ) ) ) } > 审 核 通 过 < / button >
< button className = "ad-mini" disabled = { busy || sub . state !== 'submitted' }
onClick = { ( ) = > run ( '已驳回整文(解锁回编写态)' ,
( ) = > cmd ( 'rdm.review.doc.reject' , docRejectPayload ( projectId , docId , '退回修改' ) ) ) } > 驳 回 < / button >
< button className = "lg-submit ad-create-btn" disabled = { busy || sub . state !== 'submitted' }
onClick = { ( ) = > run ( '整文终批完成(各章节已基线)' ,
( ) = > cmd ( 'rdm.review.doc.final' , docFinalPayload ( projectId , docId ) ) ) } > 终 批 ( 批 准 人 ) < / button >
< / div >
< / div >
) }
{ sub === null && docId && < p className = "ad-hint" > 该 文 档 尚 无 整 文 提 交 记 录 ( 当 前 职 责 仍 可 分 配 ) 。 < / p > }
{ sub === undefined && docId && < p className = "ad-hint" > 输 入 文 档 ID 后 查 看 整 文 状 态 。 < / p > }
< h4 className = "settings-group-title" style = { { marginTop : 18 } } > 分 配 文 档 三 权 ( 整 文 ) < / h4 >
< div className = "ad-row" >
< input className = "lg-input lg-mono" placeholder = "文档 ID( docId) " value = { docId }
onChange = { e = > setDocId ( e . target . value ) } / >
< input className = "lg-input lg-mono" placeholder = "编制人(author) " value = { author }
onChange = { e = > setAuthor ( e . target . value ) } / >
< input className = "lg-input lg-mono" placeholder = "审核人(逗号分隔,≥1 且异于编制/ 批准) " value = { reviewers }
onChange = { e = > setReviewers ( e . target . value ) } / >
< input className = "lg-input lg-mono " placeholder = "批准人(approver) " value = { approver }
onChange = { e = > setApprover ( e . target . value ) } / >
< / div >
< div className = "ad-row" >
< button className = "lg-submit ad-create-btn"
disabled = { busy || ! docId || ! author . trim ( ) || ! approver . trim ( ) || parseReviewers ( reviewers ) . length === 0 }
onClick = { ( ) = > run ( '已分配文档三权' , ( ) = > cmd ( 'rdm.review.assign.doc' ,
assignDocDutiesPayload ( projectId , docId , author . trim ( ) , parseReviewers ( reviewers ) , approver . trim ( ) ) ) ) } > 分 配 三 权 < / button >
< button className = "ad-mini" disabled = { busy || ! docId }
onClick = { ( ) = > run ( '整文已提交审核(全文锁定)' ,
( ) = > cmd ( 'rdm.review.doc.submit' , docSubmitPayload ( projectId , docId ) ) ) } > 整 文 提 交 ( 编 制 人 ) < / button >
< / div >
< p className = "ad-hint" > 三 权 异 人 : 编 制 ≠ 批 准 , 审 核 异 于 两 者 ; 提 交 即 冻 结 三 权 与 全 文 版 本 , 审 签 在 途 全 文 锁 定 。 < / p >
{ batchSel . size > 0 && (
< div className = "settings-group" style = { { marginTop : 14 } } >
< h4 > 批 量 分 配 ( 已 选 { batchSel . size } 份 ; 任 一 校 验 失 败 整 批 拒 绝 并 列 原 因 ) < / h4 >
< div className = "setting-row" > < label > 统 一 编 制 人 < / label >
< div style = { { maxWidth : 320 } } > < Picker aria-label = "批量编制人" selected = { batchAuthor } onSelect = { setBatchAuthor } fetchPage = { memberPage } / > < / div > < / div >
< div className = "setting-row" > < label > 统 一 审 核 人 < / label >
< div style = { { maxWidth : 320 } } > < Picker aria-label = "批量审核人" multiple selected = { batchReviewers } onSelect = { setBatchReviewers } fetchPage = { memberPage } / > < / div > < / div >
< div className = "setting-row" > < label > 统 一 批 准 人 < / label >
< div style = { { maxWidth : 320 } } > < Picker aria-label = "批量 批准人 " selected = { batchApprover } onSelect = { setBatchApprover } fetchPage = { memberPage } / > < / div > < / div >
< div className = "ad-row" >
< Btn variant = "primary " icon = "check " disabled = { busy || ! batchAuthor [ 0 ] || ! batchApprover [ 0 ] || batchReviewers . length === 0 }
onClick = { batchApply } > 批 量 分 配 { batchSel . size } 份 文 档 < / Btn >
< button className = "ad-mini" onClick = { ( ) = > setBatchSel ( new Set ( ) ) } > 清 空 选 择 < / button >
< / div >
< / div >
) }
{ notice && < div className = "ad-notice" > < Icon name = "check" size = { 14 } / > { notice } < / div > }
{ err && < div className = "lg-err" > < Icon name = "alert" size = { 14 } / > { err } < / div > }
@@ -313,33 +462,102 @@ export function RdmDutiesSection({ projectId }: { projectId: string }) {
)
}
// ---- 项目设置:项目权限(P7-C:单成员详情标签化 + 多人矩阵 + 三态覆盖;
// 与实际判定同源——UI 不重算,全部读服务端解释) ----
// ---- 项目设置:项目权限(矩阵解释读面,与实际判定同源——UI 不重算) ----
interface MatrixAction { action : string ; allowed : boolean ; source : string ; reason? : string }
interface MatrixAction {
action : string ; label? : string ; scope? : string
allowed : boolean ; source : string ; reason? : string
}
interface MatrixOut {
projectId : string ; member : string ; role : string
profRole : { roleId : string ; name : string } | null
actions : MatrixAction [ ]
}
interface MatrixRowOut {
member : string ; role : string
profRole : { roleId : string ; name : string } | null
actions : MatrixAction [ ]
}
interface MatrixBatchOut { projectId : string ; rows : MatrixRowOut [ ] }
function actionTitle ( a : MatrixAction ) { return a . label || a . action }
/** 动作行:中文标签为主,内部标识进 title 技术详情(方案 §5)。 */
function ActionDetailTable ( { actions , onOverride , canManage } : {
actions : MatrixAction [ ]
onOverride ? : ( action : string , mode : 'allow' | 'deny' | 'default' ) = > void
canManage? : boolean
} ) {
const project = actions . filter ( a = > a . scope !== 'system' )
const system = actions . filter ( a = > a . scope === 'system' )
const overrideOf = ( a : MatrixAction ) : 'allow' | 'deny' | 'default' = >
a . source . includes ( 'override' ) || a . source === 'project' ? ( a . allowed ? 'allow' : 'deny' ) : 'default'
return (
< >
< table className = "ad-table" >
< thead > < tr > < th > 动 作 < / th > < th > 生 效 < / th > < th > 来 源 < / th > < th > 原 因 ( 拒 绝 时 ) < / th > { canManage ? < th > 项 目 覆 盖 < / th > : null } < / tr > < / thead >
< tbody >
{ project . map ( a = > (
< tr key = { a . action } >
< td title = { ` 内部标识 ${ a . action } ` } > { actionTitle ( a ) } < / td >
< td > { a . allowed ? < span className = "ad-chip ad-chip-admin" > 允 许 < / span > : < span className = "ad-chip ad-chip-warn" > 拒 绝 < / span > } < / td >
< td className = "ad-dim" > { describeSource ( a . source ) } < / td >
< td className = "ad-dim" > { a . reason || '—' } < / td >
{ canManage ? (
< td >
< select className = "lg-input" value = { overrideOf ( a ) } style = { { minWidth : 120 } }
onChange = { e = > onOverride ? . ( a . action , e . target . value as 'allow' | 'deny' | 'default' ) } >
< option value = "default" > 沿 用 角 色 默 认 < / option >
< option value = "allow" > 项 目 内 允 许 < / option >
< option value = "deny" > 项 目 内 禁 止 < / option >
< / select >
< / td >
) : null }
< / tr >
) ) }
< / tbody >
< / table >
{ system . length > 0 && (
< Collapsible trigger = { ` 系统范围动作( ${ system . length } 项,不在项目内配置) ` } >
< table className = "ad-table" >
< thead > < tr > < th > 动 作 < / th > < th > 生 效 < / th > < th > 来 源 < / th > < th > 原 因 < / th > < / tr > < / thead >
< tbody >
{ system . map ( a = > (
< tr key = { a . action } >
< td title = { ` 内部标识 ${ a . action } ` } > { actionTitle ( a ) } < / td >
< td > { a . allowed ? < span className = "ad-chip ad-chip-admin" > 允 许 < / span > : < span className = "ad-chip ad-chip-warn" > 拒 绝 < / span > } < / td >
< td className = "ad-dim" > { describeSource ( a . source ) } < / td >
< td className = "ad-dim" > { a . reason || '—' } < / td >
< / tr >
) ) }
< / tbody >
< / table >
< / Collapsible >
) }
< / >
)
}
export function RdmPermMatrixSection ( { projectId } : { projectId : string } ) {
const me = useStore ( s = > s . me )
const [ view , setView ] = useState < 'member' | 'matrix' > ( 'member' )
const [ member , setMember ] = useState ( '' )
const [ members , setMembers ] = useState < MemberRow [ ] | null > ( null )
const [ matrix , setMatrix ] = useState < MatrixOut | null > ( null )
const [ batch , setBatch ] = useState < MatrixBatchOut | null > ( null )
const [ expand , setExpand ] = useState ( '' )
const [ err , setErr ] = useState ( '' )
const [ notice , setNotice ] = useState ( '' )
const [ busy , setBusy ] = useState ( false )
useEffect ( ( ) = > {
const loadMembers = ( ) = >
api . rdmCommand < { members : MemberRow [ ] } > ( 'rdm.project.member.list' , { projectId } )
. then ( out = > {
setMembers ( out . members ? ? [ ] )
// 默认自省:普通成员只看自己的矩阵;负责人/管理员可切换他人。
if ( me ? . owner ) setMember ( prev = > prev || me . owner )
} )
. catch ( e = > setErr ( String ( e ) ) )
} , [ projectId , me ? . owner ] )
} ) . catch ( e = > setErr ( String ( e ) ) )
useEffect ( ( ) = > { loadMembers ( ) } , [ projectId , me ? . owner ] ) // eslint-disable-line react-hooks/exhaustive-deps
const query = async ( m : string ) = > {
if ( ! m ) return
@@ -348,43 +566,107 @@ export function RdmPermMatrixSection({ projectId }: { projectId: string }) {
setMatrix ( await api . rdmCommand < MatrixOut > ( 'rdm.perm.check' , permCheckPayload ( projectId , m ) ) )
} catch ( e ) { setErr ( String ( e ) ) } finally { setBusy ( false ) }
}
const queryBatch = async ( ) = > {
setErr ( '' ) ; setBusy ( true ) ; setBatch ( null )
try {
setBatch ( await api . rdmCommand < MatrixBatchOut > ( 'rdm.perm.matrix' , { projectId } ) )
} catch ( e ) { setErr ( String ( e ) ) } finally { setBusy ( false ) }
}
useEffect ( ( ) = > { if ( view === 'matrix' ) queryBatch ( ) } , [ view ] ) // eslint-disable-line react-hooks/exhaustive-deps
/** 三态覆盖写回:default=实际撤销(perm.unset);allow/deny=perm.set。 */
const override = async ( m : string , action : string , mode : 'allow' | 'deny' | 'default' ) = > {
setErr ( '' ) ; setNotice ( '' ) ; setBusy ( true )
try {
if ( mode === 'default' ) {
await api . rdmCommand ( 'rdm.perm.unset' , { projectId , member : m , action } )
setNotice ( ` 已恢复默认(撤销 ${ action } 的项目覆盖) ` )
} else {
await api . rdmCommand ( 'rdm.perm.set' , { projectId , member : m , action , allow : mode === 'allow' } )
setNotice ( mode === 'allow' ? ` 已在项目内允许 ${ action } ` : ` 已在项目内禁止 ${ action } ` )
}
await query ( m )
} catch ( e ) { setErr ( String ( e ) ) } finally { setBusy ( false ) }
}
const canManage = ! ! matrix && matrix . role !== undefined
return (
< div >
< div className = "ad-create" >
< div className = "ad-row" >
< select className = "lg-input" value = { member } onChange = { e = > { setMember ( e . target . value ) ; query ( e . target . value ) } } >
< option value = "" > 选 择 成 员 ( 默 认 自 省 ) … < / option >
{ ( members ? ? [ ] ) . map ( m = > < option key = { m . owner } value = { m . owner } > { m . owner } < / option > ) }
< select className = "lg-input" value = { view } onChange = { e = > setView ( e . target . value as 'member' | 'matrix' ) } >
< option value = "member " > 按 成 员 查 看 ( 默 认 自 省 ) < / option >
< option value = "matrix" > 权 限 矩 阵 ( 成 员 × 动 作 , 须 管 理 权 ) < / option >
< / select >
< Btn variant = "ghost" icon = "eye" disabled = { busy || ! member } onClick = { ( ) = > query ( member ) } > 查 看 矩 阵 < / Btn >
{ view === 'member' && (
< >
< select className = "lg-input" value = { member } onChange = { e = > { setMember ( e . target . value ) ; query ( e . target . value ) } } >
< option value = "" > 选 择 成 员 … < / option >
{ ( members ? ? [ ] ) . map ( m = > < option key = { m . owner } value = { m . owner } > { m . owner } < / option > ) }
< / select >
< Btn variant = "ghost" icon = "eye" disabled = { busy || ! member } onClick = { ( ) = > query ( member ) } > 查 看 < / Btn >
< / >
) }
< / div >
< p className = "ad-hint" > 普 通 成 员 可 查 看 自 己 的 有 效 权 限 ; 查 看 他 人 矩 阵 须 项 目 管 理 权 。 矩 阵 与 实 际 判 定 同 源 ( 服 务 端 解 释 , 非 前 端 推 算 ) 。 < / p >
< p className = "ad-hint" > 普 通 成 员 可 查 看 自 己 的 有 效 权 限 ; 查 看 他 人 与 矩 阵 须 项 目 管 理 权 。 解 释 与 实 际 判 定 同 源 ( 服 务 端 , 非 前 端 推 算 ) ; 系 统 范 围 动 作 折 叠 为 技 术 详 情 。 < / p >
< / div >
{ matrix && (
{ view === 'member' && matrix && (
< >
< div className = "ad-row" >
< span > 成 员 < b className = "ad-mono" > { matrix . member } < / b > · 项 目 关 系 < b > { matrix . role } < / b >
{ matrix . profRole && < > · 专 业 角 色 < b > { matrix . profRole . name } < / b > ( { matrix . profRole . roleId } ) < / > }
< span > 成 员 < b className = "ad-mono" > { matrix . member } < / b > · 项 目 身 份 < b > { matrix . role === 'owner' ? '项目负责人' : matrix . role === 'member' ? '项目成员' : matrix . role } < / b >
{ matrix . profRole && < > · 专 业 角 色 < b > { matrix . profRole . name } < / b > < / > }
< / span >
< / div >
< table className = "ad-table" >
< thead > < tr > < th > 动 作 < / th > < th > 生 效 < / th > < th > 来 源 < / th > < th > 原 因 ( 拒 绝 时 ) < / th > < / tr > < / thead >
< tbody >
{ ( matrix . actions ? ? [ ] ) . map ( a = > (
< tr key = { a . action } >
< td className = "ad-mono" > { a . action } < / td >
< td > { a . allowed ? < span className = "ad-chip ad-chip-admin" > 允 许 < / span > : < span className = "ad-chip ad-chip-warn" > 拒 绝 < / span > } < / td >
< td className = "ad-dim" > { describeSource ( a . source ) } < / td >
< td className = "ad-dim" > { a . reason || '—' } < / td >
< / tr >
) ) }
< / tbody >
< / table >
< ActionDetailTable actions = { matrix . actions ? ? [ ] }
canManage = { canManage }
onOverride = { ( action , mode ) = > override ( matrix . member , action , mode ) } / >
< / >
) }
{ ! matrix && ! err && members !== null && < p className = "ad-hint" > 选 择 成 员 后 展 示 12 项 动 作 的 有 效 权 限 与 第 一 拒 绝 原 因 。 < / p > }
{ view === 'member' && ! matrix && ! err && members !== null && < p className = "ad-hint" > 选 择 成 员 后 展 示 各 动 作 的 有 效 权 限 、 来 源 与 第 一 拒 绝 原 因 。 < / p > }
{ view === 'matrix' && batch && (
< table className = "ad-table" style = { { overflowX : 'auto' , display : 'block' } } >
< thead > < tr >
< th > 成 员 < / th >
{ ( batch . rows [ 0 ] ? . actions ? ? [ ] ) . filter ( a = > a . scope !== 'system' ) . map ( a = > (
< th key = { a . action } title = { ` 内部标识 ${ a . action } ` } > { actionTitle ( a ) } < / th >
) ) }
< / tr > < / thead >
< tbody >
{ batch . rows . map ( row = > (
< tr key = { row . member } >
< td >
< button className = "ad-mini" onClick = { ( ) = > setExpand ( expand === row . member ? '' : row . member ) } >
{ row . member }
< / button >
< span className = "ad-dim" > · { row . role === 'owner' ? '负责人' : '成员' } < / span >
< / td >
{ row . actions . filter ( a = > a . scope !== 'system' ) . map ( a = > (
< td key = { a . action } title = { ` ${ a . label ? ? a . action } : ${ a . allowed ? '允许' : '拒绝' } ( ${ describeSource ( a . source ) } ${ a . reason ? '; ' + a . reason : '' } ) ` } >
{ a . allowed ? < span className = "ad-chip ad-chip-admin" > ✓ < / span > : < span className = "ad-chip ad-chip-warn" > ✗ < / span > }
< / td >
) ) }
< / tr >
) ) }
< / tbody >
< / table >
) }
{ view === 'matrix' && batch && expand && (
< div className = "settings-group" style = { { marginTop : 12 } } >
< h4 > { expand } · 单 成 员 详 情 与 三 态 覆 盖 < / h4 >
{ ( ( ) = > {
const row = batch . rows . find ( r = > r . member === expand )
if ( ! row ) return null
return < ActionDetailTable actions = { row . actions } canManage
onOverride = { ( action , mode ) = > override ( expand , action , mode ) } / >
} ) ( ) }
< / div >
) }
{ notice && < div className = "ad-notice" > < Icon name = "check" size = { 14 } / > { notice } < / div > }
{ err && < div className = "lg-err" > < Icon name = "alert" size = { 14 } / > { err } < / div > }
< / div >
)
}
}