476 lines
23 KiB
TypeScript
476 lines
23 KiB
TypeScript
// RDM 项目设置组件:成员 / 文档职责。全部数据经 api.rdmCommand(命令总线),载荷构造与能力
|
||
// 门控集中在 src/rdmManagement.ts;分区可见性由 ProjectSettingsModal 按 store 的
|
||
// rdm*Available 门控(命令面不齐不渲染,普通基座零暴露)。样式只复用
|
||
// 既有 ad-*/lg-*/settings-* 类(design-guard 棘轮:不新增裸色值/字号)。
|
||
import { useEffect, useState, useRef, useCallback, useMemo } from 'react'
|
||
import { api } from '../api'
|
||
import type { Project } from '../contracts'
|
||
import { projectWorkError } from '../projectmeta'
|
||
import { useStore } from '../store'
|
||
import { Btn, Collapsible, Icon, Picker } from '@ui'
|
||
import {
|
||
memberAddPayload, memberRemovePayload, memberSetRolePayload,
|
||
permCheckPayload, describeSource,
|
||
type MemberMatchMode, type PersonPickItem, type RdmAccountPage, type RdmMemberPage, type RdmProfRole,
|
||
type SearchFieldsChoice,
|
||
MATCH_MODE_LABELS, SEARCH_FIELDS, accountCandidatePager, accountSearchKey, appendMemberPage, memberListParams,
|
||
personLabel, roleOptionsPayload,
|
||
} from '../rdmManagement'
|
||
|
||
// ---- 项目设置:成员(P7:可搜索人员选择器 + 专业角色/项目身份两列 + 显式身份调整)----
|
||
|
||
interface MemberRow {
|
||
owner: string; name?: string; role: string; addedBy: string; addedAt: string
|
||
profRole?: { roleId: string; name: string } | null
|
||
}
|
||
|
||
export function RdmMembersSection({
|
||
project,
|
||
initialCanManage,
|
||
initialRows,
|
||
}: {
|
||
project: Project
|
||
initialCanManage?: boolean
|
||
initialRows?: MemberRow[]
|
||
}) {
|
||
const projectId = project.id
|
||
const writeError = projectWorkError(project)
|
||
const cmd = <T,>(id: string, params: Record<string, unknown> = {}) => api.rdmProjectCommand<T>(project, id, params)
|
||
const [canManage, setCanManage] = useState(initialCanManage ?? false)
|
||
const [rows, setRows] = useState<MemberRow[] | null>(initialRows ?? null)
|
||
const [memberNext, setMemberNext] = useState('')
|
||
const [memberTotal, setMemberTotal] = useState<number | null>(null)
|
||
const [directoryOk, setDirectoryOk] = useState(true)
|
||
const [loadingMore, setLoadingMore] = useState(false)
|
||
const [memberQuery, setMemberQuery] = useState('')
|
||
const [memberRoleId, setMemberRoleId] = useState('')
|
||
// null=未加载或 rdm.role.options 失败:两处专业角色筛选均不渲染(不给误导选项)。
|
||
const [roleOptions, setRoleOptions] = useState<RdmProfRole[] | null>(null)
|
||
const [err, setErr] = useState('')
|
||
const [notice, setNotice] = useState('')
|
||
const [busy, setBusy] = useState(false)
|
||
const [picked, setPicked] = useState<string[]>([])
|
||
const [pickedItems, setPickedItems] = useState<PersonPickItem[]>([])
|
||
const [candRoleId, setCandRoleId] = useState('')
|
||
const [candMatch, setCandMatch] = useState<MemberMatchMode>('fuzzy')
|
||
const [candFields, setCandFields] = useState<SearchFieldsChoice>('both')
|
||
const [role, setRole] = useState<'member' | 'owner'>('member')
|
||
// 成员表经 ref 参与取数(已加入标记),保持 fetchPage 身份稳定(Picker 依赖)。
|
||
const rowsRef = useRef<MemberRow[] | null>(null)
|
||
rowsRef.current = rows
|
||
const knownPicks = useRef(new Map<string, PersonPickItem>())
|
||
// 成员表请求代次:切项目/切筛选推进代次,迟到响应丢弃。
|
||
const memberGen = useRef(0)
|
||
|
||
useEffect(() => {
|
||
setCanManage(false)
|
||
setPicked([]); setPickedItems([]); knownPicks.current.clear()
|
||
setCandRoleId(''); setCandMatch('fuzzy'); setCandFields('both'); setMemberQuery(''); setMemberRoleId('')
|
||
setRoleOptions(null)
|
||
}, [project])
|
||
|
||
useEffect(() => {
|
||
if (!canManage) {
|
||
setRoleOptions(null)
|
||
return
|
||
}
|
||
let live = true
|
||
cmd<{ roles?: RdmProfRole[] }>('rdm.role.options', roleOptionsPayload(projectId))
|
||
.then(out => { if (live) setRoleOptions(out.roles ?? []) })
|
||
.catch(() => { if (live) setRoleOptions(null) })
|
||
return () => { live = false }
|
||
}, [project, canManage])
|
||
|
||
const candOptions = useMemo(() => ({ mode: candMatch, searchFields: SEARCH_FIELDS[candFields].fields, roleId: candRoleId }),
|
||
[candMatch, candFields, candRoleId])
|
||
|
||
/** 人员选择器取数适配:rdm.account.list 服务端按 query/mode/searchFields/roleId 先过滤后分页。 */
|
||
const fetchPage = useMemo(() => {
|
||
const page = accountCandidatePager(params => cmd<RdmAccountPage>('rdm.account.list', params), projectId,
|
||
{ ...candOptions, joinedOwners: () => (rowsRef.current ?? []).map(m => m.owner) })
|
||
return async (query: string, cursor: string, limit: number) => {
|
||
const out = await page(query, cursor, limit)
|
||
for (const it of out.items) knownPicks.current.set(it.id, it)
|
||
return out
|
||
}
|
||
}, [project, candOptions])
|
||
|
||
/** cursor 空=重载首页(clear 时先清空表格);非空=追加下一页。 */
|
||
const loadMembers = useCallback(async (cursor: string, clear: boolean) => {
|
||
const gen = cursor ? memberGen.current : ++memberGen.current
|
||
if (cursor) setLoadingMore(true)
|
||
else {
|
||
setLoadingMore(false)
|
||
if (clear) { setErr(''); setRows(null); setMemberNext(''); setMemberTotal(null) }
|
||
}
|
||
try {
|
||
const out = await cmd<RdmMemberPage>('rdm.project.member.list',
|
||
memberListParams(projectId, { query: memberQuery, roleId: memberRoleId, cursor }))
|
||
if (gen !== memberGen.current) return
|
||
if (!cursor) {
|
||
setCanManage(Boolean(out.canManage))
|
||
}
|
||
setRows(prev => appendMemberPage(cursor ? prev : null, out.members))
|
||
setMemberNext(out.next || '')
|
||
setMemberTotal(typeof out.total === 'number' ? out.total : null)
|
||
setDirectoryOk(out.accountDirectoryAvailable !== false)
|
||
} catch (e) {
|
||
if (gen === memberGen.current) setErr(String(e))
|
||
} finally {
|
||
if (cursor && gen === memberGen.current) setLoadingMore(false)
|
||
}
|
||
}, [project, memberQuery, memberRoleId])
|
||
|
||
useEffect(() => {
|
||
const timer = setTimeout(() => { void loadMembers('', true) }, memberQuery.trim() ? 250 : 0)
|
||
return () => { clearTimeout(timer); ++memberGen.current }
|
||
}, [loadMembers])
|
||
|
||
const refresh = () => loadMembers('', false)
|
||
|
||
const run = async (label: string, fn: () => Promise<unknown>) => {
|
||
setErr(''); setNotice(''); setBusy(true)
|
||
try { await fn(); setNotice(label); await refresh() }
|
||
catch (e) { setErr(String(e)) } finally { setBusy(false) }
|
||
}
|
||
|
||
const onPick = (ids: string[]) => {
|
||
setPicked(ids)
|
||
setPickedItems(ids.flatMap(id => {
|
||
const it = knownPicks.current.get(id)
|
||
return it ? [it] : []
|
||
}))
|
||
}
|
||
|
||
const pickedId = picked[0] ?? ''
|
||
const pickedItem = pickedItems.find(it => it.id === pickedId)
|
||
const pickedValid = !!pickedItem && !pickedItem.disabled && !(rows ?? []).some(m => m.owner === pickedItem.owner)
|
||
const pickerDisabled = busy || !!writeError
|
||
const filtering = memberQuery.trim() !== '' || memberRoleId !== ''
|
||
|
||
const roleFilter = (value: string, onChange: (v: string) => void, label: string) =>
|
||
roleOptions && roleOptions.length > 0 ? (
|
||
<select className="lg-input" aria-label={label} value={value} onChange={e => onChange(e.target.value)}>
|
||
<option value="">全部专业角色</option>
|
||
{roleOptions.map(r => <option key={r.roleId} value={r.roleId}>{r.name}</option>)}
|
||
</select>
|
||
) : null
|
||
|
||
return (
|
||
<div>
|
||
{writeError && <p className="ad-hint">{writeError}</p>}
|
||
{canManage ? (
|
||
<div className="ad-create">
|
||
<div className="ad-row">
|
||
{roleFilter(candRoleId, setCandRoleId, '候选专业角色筛选')}
|
||
<select className="lg-input" aria-label="匹配方式" value={candMatch}
|
||
onChange={e => setCandMatch(e.target.value as MemberMatchMode)}>
|
||
{(Object.keys(MATCH_MODE_LABELS) as MemberMatchMode[]).map(m =>
|
||
<option key={m} value={m}>{MATCH_MODE_LABELS[m]}</option>)}
|
||
</select>
|
||
<select className="lg-input" aria-label="检索字段" value={candFields}
|
||
onChange={e => setCandFields(e.target.value as SearchFieldsChoice)}>
|
||
{(Object.keys(SEARCH_FIELDS) as SearchFieldsChoice[]).map(f =>
|
||
<option key={f} value={f}>{SEARCH_FIELDS[f].label}</option>)}
|
||
</select>
|
||
</div>
|
||
<div className="ad-row">
|
||
<Picker
|
||
aria-label="选择人员"
|
||
selected={picked}
|
||
onSelect={onPick}
|
||
selectedItems={pickedItems}
|
||
fetchPage={fetchPage}
|
||
reloadKey={accountSearchKey(projectId, candOptions)}
|
||
disabled={pickerDisabled}
|
||
placeholder={candMatch === 'fuzzy' ? '输入至少两个字符搜索账号或姓名' : '输入完整账号或姓名精确搜索'}
|
||
emptyText="输入后显示匹配账号"
|
||
/>
|
||
<select className="lg-input" value={role} onChange={e => setRole(e.target.value as 'member' | 'owner')}>
|
||
<option value="member">项目成员</option>
|
||
<option value="owner">项目负责人</option>
|
||
</select>
|
||
<button className="lg-submit ad-create-btn" disabled={pickerDisabled || !pickedValid}
|
||
onClick={() => pickedItem && run(`已添加 ${personLabel({ owner: pickedItem.owner, name: pickedItem.title })}`, async () => {
|
||
await cmd('rdm.project.member.add', memberAddPayload(projectId, pickedItem.owner, role, pickedItem.id))
|
||
setPicked([]); setPickedItems([])
|
||
})}>添加成员</button>
|
||
</div>
|
||
<p className="ad-hint">候选来自系统账号目录(输入后检索,姓名为空时显示账号);无效账号与已加入项目的账号显示为不可选——调整身份请在成员表中操作。最后一位负责人不可移除/降级。</p>
|
||
</div>
|
||
) : (
|
||
<p className="ad-hint">仅项目创建者可管理成员;你可以查看成员列表。</p>
|
||
)}
|
||
<div className="ad-row">
|
||
<input className="lg-input" aria-label="搜索成员" placeholder="搜索成员账号或姓名" value={memberQuery}
|
||
onChange={e => setMemberQuery(e.target.value)} />
|
||
{roleFilter(memberRoleId, setMemberRoleId, '成员专业角色筛选')}
|
||
{rows !== null && <span className="ad-dim">共 {memberTotal ?? rows.length} 名成员匹配</span>}
|
||
</div>
|
||
{!directoryOk && <p className="ad-hint">账号目录暂不可用:成员姓名可能缺失,仅按账号显示。</p>}
|
||
<table className="ad-table">
|
||
<thead>
|
||
<tr>
|
||
<th>成员</th>
|
||
<th>专业角色</th>
|
||
<th>项目身份</th>
|
||
<th>添加人</th>
|
||
<th>添加时间</th>
|
||
{canManage && <th></th>}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{(rows ?? []).map(m => (
|
||
<tr key={m.owner}>
|
||
<td className="ad-mono">{personLabel(m)}</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>
|
||
{canManage && (
|
||
<td>
|
||
<button className="ad-mini" disabled={busy || !!writeError}
|
||
title={m.role === 'owner' ? '降为项目成员(setRole)' : '升为项目负责人(setRole)'}
|
||
onClick={() => run(`已调整 ${m.owner} 的项目身份`, () =>
|
||
cmd('rdm.project.member.set-role', memberSetRolePayload(projectId, m.owner, m.role === 'owner' ? 'member' : 'owner')))}>
|
||
{m.role === 'owner' ? '降为成员' : '升为负责人'}
|
||
</button>{' '}
|
||
<button className="ad-mini" disabled={busy || !!writeError}
|
||
onClick={() => run(`已移除 ${m.owner}`,
|
||
() => cmd('rdm.project.member.remove', memberRemovePayload(projectId, m.owner)))}>移除</button>
|
||
</td>
|
||
)}
|
||
</tr>
|
||
))}
|
||
{rows !== null && rows.length === 0 && <tr><td colSpan={canManage ? 6 : 5} className="ad-dim">{filtering ? '没有匹配的成员' : '暂无成员'}</td></tr>}
|
||
{rows === null && <tr><td colSpan={canManage ? 6 : 5} className="ad-dim">{err ? '成员清单未加载(见下方提示)' : '加载中…'}</td></tr>}
|
||
</tbody>
|
||
</table>
|
||
{memberNext && rows !== null && (
|
||
<div className="ad-row">
|
||
<button className="ad-mini" disabled={loadingMore}
|
||
onClick={() => loadMembers(memberNext, false)}>{loadingMore ? '加载中…' : '加载更多成员'}</button>
|
||
</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>
|
||
)
|
||
}
|
||
|
||
export { RdmDutiesSection } from './duties/RdmDutiesSection'
|
||
|
||
// ---- 项目设置:项目权限(P7-C:单成员详情标签化 + 多人矩阵 + 三态覆盖;
|
||
// 与实际判定同源——UI 不重算,全部读服务端解释) ----
|
||
|
||
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({ project }: { project: Project }) {
|
||
const projectId = project.id
|
||
const writeError = projectWorkError(project)
|
||
const cmd = <T,>(id: string, params: Record<string, unknown> = {}) => api.rdmProjectCommand<T>(project, id, params)
|
||
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)
|
||
|
||
const loadMembers = () =>
|
||
cmd<{ 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)))
|
||
useEffect(() => { loadMembers() }, [project, me?.owner]) // eslint-disable-line react-hooks/exhaustive-deps
|
||
|
||
const query = async (m: string) => {
|
||
if (!m) return
|
||
setErr(''); setBusy(true); setMatrix(null)
|
||
try {
|
||
setMatrix(await cmd<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 cmd<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') => {
|
||
if (writeError) { setErr(writeError); return }
|
||
setErr(''); setNotice(''); setBusy(true)
|
||
try {
|
||
if (mode === 'default') {
|
||
await cmd('rdm.perm.unset', { projectId, member: m, action })
|
||
setNotice(`已恢复默认(撤销 ${action} 的项目覆盖)`)
|
||
} else {
|
||
await cmd('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 = !writeError && !!matrix && matrix.role !== undefined
|
||
|
||
return (
|
||
<div>
|
||
{writeError && <p className="ad-hint">{writeError}</p>}
|
||
<div className="ad-create">
|
||
<div className="ad-row">
|
||
<select className="lg-input" value={view} onChange={e => setView(e.target.value as 'member' | 'matrix')}>
|
||
<option value="member">按成员查看(默认自省)</option>
|
||
<option value="matrix">权限矩阵(成员 × 动作,须管理权)</option>
|
||
</select>
|
||
{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>
|
||
</div>
|
||
|
||
{view === 'member' && matrix && (
|
||
<>
|
||
<div className="ad-row">
|
||
<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>
|
||
<ActionDetailTable actions={matrix.actions ?? []}
|
||
canManage={canManage}
|
||
onOverride={(action, mode) => override(matrix.member, action, mode)} />
|
||
</>
|
||
)}
|
||
{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={!writeError}
|
||
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>
|
||
)
|
||
}
|