Merge pull request 'Rebuild duties and permission pages on catalog, matrix and tri-state' (#81) from feature/rdm-increment-duties-page into main

This commit was merged in pull request #81.
This commit is contained in:
2026-10-13 09:56:49 +00:00
+318 -78
View File
@@ -6,9 +6,9 @@
import { useEffect, useState, useRef, useCallback } from 'react'
import { api } from '../api'
import { useStore } from '../store'
import { Btn, EmptyState, Icon, Picker} from '@ui'
import { Btn, Collapsible, EmptyState, Icon, Picker } from '@ui'
import {
ROLE_ACTIONS, parseReviewers,
ROLE_ACTIONS,
roleCreatePayload, roleActionPayload, roleAssignPayload, roleDisablePayload,
memberAddPayload, memberRemovePayload, memberSetRolePayload,
assignDocDutiesPayload, docSubmitPayload, docApprovePayload, docRejectPayload,
@@ -254,7 +254,7 @@ export function RdmMembersSection({ projectId }: { projectId: string }) {
)
}
// ---- 项目设置:文档职责 · 整文审签 ----
// ---- 项目设置:文档职责(P7-B:文档清单+选中职责详情;方案 §4)----
interface DocSubmissionView {
round: number
@@ -267,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>}
@@ -355,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
@@ -390,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>
)
}
}