Files
Agent-System-Frontend/src/components/adminRdmSections.tsx
T
agent-exec-transport b496c4476d
governance / governance (pull_request) Successful in 22s
ci / test-and-build (pull_request) Successful in 43s
agent-exec transport 5cc70be566de95cb35d2d8bdfb05b392/b0fcba7575e5
2026-10-20 03:54:20 +00:00

476 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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>
)
}