Why: Fresh documents had no first-submit action, reading active duties opened confirmation dialogs, and delayed detail or status queries could overwrite another selection and send its document ID with the wrong duty draft. Evidence: SELF real-browser regressions observed the missing action, render-time dialogs, detail/status races, and a follow-up query after panel unmount. Five focused Playwright scenarios passed after the fix, together with the full npm test, npm run build, and git diff --check commands. Receipts: /tmp/project-member-duties-browser-green-20261013.json and /tmp/project-member-duties-full-build-green-20261013.json. Impact: Offer first submission independently of an existing review round. Ask for invalidation only on a dirty save click. Guard queries and continuations with project lifetime, selection, and request sequences; keep the existing Picker, canonical project routing, controls, and UI tokens. Refs: Dev project-member-access Task 4; independent synchronized Frontend P2 review
742 lines
39 KiB
TypeScript
742 lines
39 KiB
TypeScript
// U6/KTD5:RDM 管理与项目页面组件。系统管理「RDM 角色权限」+ 项目设置「成员 /
|
||
// 文档职责 · 整文审签」。全部数据经 api.rdmCommand(命令总线),载荷构造与能力
|
||
// 门控集中在 src/rdmManagement.ts;分区可见性由 AdminZone/ProjectSettingsModal
|
||
// 按 store 的 rdm*Available 门控(命令面不齐不渲染,普通基座零暴露)。样式只复用
|
||
// 既有 ad-*/lg-*/settings-* 类(design-guard 棘轮:不新增裸色值/字号)。
|
||
import { useEffect, useState, useRef, useCallback } from 'react'
|
||
import { api } from '../api'
|
||
import type { Project } from '../contracts'
|
||
import { projectWorkError } from '../projectmeta'
|
||
import { useStore } from '../store'
|
||
import { Btn, Collapsible, EmptyState, Icon, Picker } from '@ui'
|
||
import {
|
||
ROLE_ACTIONS,
|
||
roleCreatePayload, roleActionPayload, roleAssignPayload, roleDisablePayload,
|
||
memberAddPayload, memberRemovePayload, memberSetRolePayload,
|
||
assignDocDutiesPayload, docSubmitPayload, docApprovePayload, docRejectPayload,
|
||
docFinalPayload, docStatusPayload, permCheckPayload, describeSource,
|
||
rolesListPlaceholder, describeRoleListError,
|
||
} from '../rdmManagement'
|
||
|
||
// ---- 系统管理:RDM 角色权限(仅管理员 + rdmRolesAvailable) ----
|
||
|
||
interface RoleView {
|
||
roleId: string
|
||
name: string
|
||
disabled: boolean
|
||
version: number
|
||
actions: Record<string, [boolean, boolean]> | null
|
||
accounts: string[] | null
|
||
}
|
||
|
||
export function RdmRolesPanel() {
|
||
const me = useStore(s => s.me)
|
||
const [roles, setRoles] = useState<RoleView[] | null>(null)
|
||
const [err, setErr] = useState('')
|
||
const [notice, setNotice] = useState('')
|
||
const [busy, setBusy] = useState(false)
|
||
const [newName, setNewName] = useState('')
|
||
// 动作编辑器
|
||
const [editRole, setEditRole] = useState('')
|
||
const [editAction, setEditAction] = useState<string>(ROLE_ACTIONS[0])
|
||
const [editDef, setEditDef] = useState(false)
|
||
const [editCap, setEditCap] = useState(true)
|
||
// 绑定
|
||
const [assignOwner, setAssignOwner] = useState('')
|
||
|
||
const refresh = async () => {
|
||
try {
|
||
const out = await api.rdmCommand<{ roles: RoleView[] }>('rdm.role.list')
|
||
setErr(''); setRoles(out.roles ?? [])
|
||
} catch (e) { setRoles(null); setErr(describeRoleListError(String(e))) }
|
||
}
|
||
useEffect(() => { refresh() }, [])
|
||
|
||
const run = async (label: string, fn: () => Promise<unknown>) => {
|
||
setErr(''); setNotice(''); setBusy(true)
|
||
try { await fn(); setNotice(label); await refresh() }
|
||
catch (e) { setErr(describeRoleListError(String(e))) } finally { setBusy(false) }
|
||
}
|
||
|
||
if (!me?.isAdmin) return <EmptyState icon="shield">仅平台管理员可管理专业角色</EmptyState>
|
||
|
||
// 占位行单一出口:加载中仅在无错误时成立(拒绝后永远转圈是误导——见
|
||
// rolesListPlaceholder 注释)。
|
||
const placeholder = rolesListPlaceholder(roles, err)
|
||
|
||
const sel = roles?.find(r => r.roleId === editRole)
|
||
const editCurrent = sel?.actions?.[editAction]
|
||
|
||
return (
|
||
<div>
|
||
<div className="ad-create">
|
||
<div className="ad-row">
|
||
<input className="lg-input" placeholder="新角色名(如:编制工程师)" value={newName}
|
||
onChange={e => setNewName(e.target.value)} />
|
||
<button className="lg-submit ad-create-btn" disabled={busy || !newName.trim()}
|
||
onClick={() => run(`已创建角色 ${newName.trim()}`, async () => {
|
||
await api.rdmCommand('rdm.role.create', roleCreatePayload(newName.trim()))
|
||
setNewName('')
|
||
})}>创建角色</button>
|
||
</div>
|
||
<p className="ad-hint">新角色动作未配置=全部拒绝(fail-closed),创建后经下方编辑器逐动作配置默认与上限。</p>
|
||
</div>
|
||
|
||
<table className="ad-table">
|
||
<thead><tr><th>角色</th><th>状态</th><th>版本</th><th>动作配置</th><th>绑定账号</th><th></th></tr></thead>
|
||
<tbody>
|
||
{(roles ?? []).map(r => (
|
||
<tr key={r.roleId}>
|
||
<td><span className="ad-mono">{r.name}</span><br /><span className="ad-dim">{r.roleId}</span></td>
|
||
<td>{r.disabled ? <span className="ad-chip ad-chip-warn">已停用</span> : <span className="ad-chip">生效</span>}</td>
|
||
<td className="ad-dim">v{r.version}</td>
|
||
<td>{Object.keys(r.actions ?? {}).length} 项</td>
|
||
<td className="ad-mono">{(r.accounts ?? []).join('、') || '—'}</td>
|
||
<td><button className="ad-mini" disabled={busy}
|
||
onClick={() => run(r.disabled ? `已启用 ${r.name}` : `已停用 ${r.name}(绑定账号回落内置默认)`,
|
||
() => api.rdmCommand('rdm.role.disable', roleDisablePayload(r.roleId, !r.disabled)))}>
|
||
{r.disabled ? '启用' : '停用'}</button></td>
|
||
</tr>
|
||
))}
|
||
{roles !== null && roles.length === 0 && <tr><td colSpan={6} className="ad-dim">暂无专业角色</td></tr>}
|
||
{placeholder && <tr><td colSpan={6} className="ad-dim">{placeholder}</td></tr>}
|
||
</tbody>
|
||
</table>
|
||
|
||
<h4 className="settings-group-title" style={{ marginTop: 18 }}>动作配置(默认 ∩ 上限)</h4>
|
||
<div className="ad-row">
|
||
<select className="lg-input" value={editRole} onChange={e => setEditRole(e.target.value)}>
|
||
<option value="">选择角色…</option>
|
||
{(roles ?? []).map(r => <option key={r.roleId} value={r.roleId}>{r.name}</option>)}
|
||
</select>
|
||
<select className="lg-input" value={editAction}
|
||
onChange={e => {
|
||
setEditAction(e.target.value)
|
||
const cur = sel?.actions?.[e.target.value]
|
||
setEditDef(!!cur?.[0]); setEditCap(cur ? cur[1] : true)
|
||
}}>
|
||
{ROLE_ACTIONS.map(a => <option key={a} value={a}>{a}</option>)}
|
||
</select>
|
||
<label className="ad-check"><input type="checkbox" checked={editDef}
|
||
onChange={e => setEditDef(e.target.checked)} />默认允许</label>
|
||
<label className="ad-check"><input type="checkbox" checked={editCap}
|
||
onChange={e => setEditCap(e.target.checked)} />上限允许</label>
|
||
<button className="lg-submit ad-create-btn" disabled={busy || !editRole}
|
||
onClick={() => run(`已配置 ${editRole} · ${editAction}`,
|
||
() => api.rdmCommand('rdm.role.action', roleActionPayload(editRole, editAction, editDef, editCap)))}>保存动作</button>
|
||
</div>
|
||
{sel && editCurrent && <p className="ad-hint">当前值:默认={String(editCurrent[0])},上限={String(editCurrent[1])}</p>}
|
||
|
||
<h4 className="settings-group-title" style={{ marginTop: 18 }}>绑定账号(每账号一个生效角色)</h4>
|
||
<div className="ad-row">
|
||
<input className="lg-input lg-mono" placeholder="账号(owner)" value={assignOwner}
|
||
onChange={e => setAssignOwner(e.target.value)} />
|
||
<select className="lg-input" value={editRole} onChange={e => setEditRole(e.target.value)}>
|
||
<option value="">选择角色…</option>
|
||
{(roles ?? []).filter(r => !r.disabled).map(r => <option key={r.roleId} value={r.roleId}>{r.name}</option>)}
|
||
</select>
|
||
<button className="lg-submit ad-create-btn" disabled={busy || !assignOwner.trim() || !editRole}
|
||
onClick={() => run(`已把 ${assignOwner.trim()} 绑定到 ${editRole}`,
|
||
() => api.rdmCommand('rdm.role.assign', roleAssignPayload(assignOwner.trim(), editRole)))}>绑定</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>
|
||
)
|
||
}
|
||
|
||
// ---- 项目设置:成员(P7:可搜索人员选择器 + 专业角色/项目身份两列 + 显式身份调整)----
|
||
|
||
interface MemberRow {
|
||
owner: string; role: string; addedBy: string; addedAt: string
|
||
profRole?: { roleId: string; name: string } | null
|
||
}
|
||
|
||
export function RdmMembersSection({ 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 [rows, setRows] = useState<MemberRow[] | null>(null)
|
||
const [err, setErr] = useState('')
|
||
const [notice, setNotice] = useState('')
|
||
const [busy, setBusy] = useState(false)
|
||
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 cmd<{ 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 }
|
||
}, [project])
|
||
|
||
const refresh = async () => {
|
||
try {
|
||
const out = await cmd<{ members: MemberRow[] }>('rdm.project.member.list', { projectId })
|
||
setRows(out.members ?? [])
|
||
} catch (e) { setErr(String(e)) }
|
||
}
|
||
useEffect(() => { setPicked([]); refresh() }, [project])
|
||
|
||
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 pickedId = picked[0] ?? ''
|
||
|
||
return (
|
||
<div>
|
||
{writeError && <p className="ad-hint">{writeError}</p>}
|
||
<div className="ad-create">
|
||
<div className="ad-row">
|
||
<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">项目成员</option>
|
||
<option value="owner">项目负责人</option>
|
||
</select>
|
||
<button className="lg-submit ad-create-btn" disabled={busy || !!writeError || !pickedId}
|
||
onClick={() => run(`已添加 ${pickedId}`, async () => {
|
||
await cmd('rdm.project.member.add', memberAddPayload(projectId, pickedId, role))
|
||
setPicked([])
|
||
})}>添加成员</button>
|
||
</div>
|
||
<p className="ad-hint">候选来自系统账号目录(姓名为空时显示账号);「已加入」标记避免重复添加造成隐式变更——调整身份请在成员表中操作。最后一位负责人不可移除/降级。</p>
|
||
</div>
|
||
<table className="ad-table">
|
||
<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><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 || !!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={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>}
|
||
{err && <div className="lg-err"><Icon name="alert" size={14} />{err}</div>}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ---- 项目设置:文档职责(P7-B:文档清单+选中职责详情;方案 §4)----
|
||
|
||
interface DocSubmissionView {
|
||
round: number
|
||
state: string
|
||
digest: string
|
||
createdBy: string
|
||
createdAt: string
|
||
duties: { author: string; reviewers: string[]; approver: string }
|
||
reviews: Record<string, boolean>
|
||
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({ project }: { project: Project }) {
|
||
const projectId = project.id
|
||
const writeError = projectWorkError(project)
|
||
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 cmd = <T,>(id: string, params: Record<string, unknown>) => api.rdmProjectCommand<T>(project, id, params)
|
||
const memberPage = useMemberPickerPage(members)
|
||
const requests = useRef({ project, epoch: 0, selected: '', selection: 0, catalog: 0, status: 0, action: 0 })
|
||
requests.current.project = project
|
||
const currentProject = (epoch: number) => requests.current.project === project && requests.current.epoch === epoch
|
||
const currentDocument = (epoch: number, selection: number, id: string) =>
|
||
currentProject(epoch) && requests.current.selection === selection && requests.current.selected === id
|
||
|
||
const refreshCatalog = async (epoch = requests.current.epoch) => {
|
||
const request = ++requests.current.catalog
|
||
try {
|
||
const out = await cmd<{ docs: DutyCatalogDoc[] }>('rdm.duty.catalog', { projectId })
|
||
if (!currentProject(epoch) || request !== requests.current.catalog) return
|
||
setDocs(out.docs ?? [])
|
||
} catch (e) {
|
||
if (!currentProject(epoch) || request !== requests.current.catalog) return
|
||
setErr(String(e)); setDocs([])
|
||
}
|
||
}
|
||
const refreshStatus = async (id: string, epoch: number, selection: number) => {
|
||
if (!id || !currentDocument(epoch, selection, id)) return
|
||
const request = ++requests.current.status
|
||
try {
|
||
const out = await cmd<{ submission: DocSubmissionView | null }>('rdm.review.doc.status', docStatusPayload(projectId, id))
|
||
if (!currentDocument(epoch, selection, id) || request !== requests.current.status) return
|
||
setSub(out.submission)
|
||
} catch (e) {
|
||
if (!currentDocument(epoch, selection, id) || request !== requests.current.status) return
|
||
setSub(undefined); setErr(`整文状态读取失败:${e}`)
|
||
}
|
||
}
|
||
/** 选中文档:读当前职责(duty.get)并回填选择器(方案:当前职责与冻结名单分列)。 */
|
||
const selectDoc = async (id: string, keepNotice = false) => {
|
||
const epoch = requests.current.epoch
|
||
const selection = ++requests.current.selection
|
||
requests.current.selected = id
|
||
requests.current.status++
|
||
setErr(''); if (!keepNotice) setNotice('')
|
||
setDocId(id); setDetail(undefined); setSub(undefined); setBatchSel(new Set())
|
||
setAuthor([]); setReviewers([]); setApprover([])
|
||
if (!id) return
|
||
try {
|
||
const out = await cmd<DutyGetOut>('rdm.duty.get', { projectId, docId: id })
|
||
if (!currentDocument(epoch, selection, id)) return
|
||
if (out.docId !== id) throw new Error('文档职责与当前选择不一致,请重新选择文档')
|
||
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) {
|
||
if (!currentDocument(epoch, selection, id)) return
|
||
setDetail(null); setErr(String(e))
|
||
}
|
||
await refreshStatus(id, epoch, selection)
|
||
}
|
||
useEffect(() => {
|
||
const epoch = ++requests.current.epoch
|
||
requests.current.selected = ''; requests.current.selection++; requests.current.status++
|
||
requests.current.action++
|
||
setDocId(''); setDetail(undefined); setSub(undefined); setBatchSel(new Set())
|
||
setDocs(null); setMembers(null); setAuthor([]); setReviewers([]); setApprover([])
|
||
setBatchAuthor([]); setBatchReviewers([]); setBatchApprover([])
|
||
setErr(''); setNotice(''); setBusy(false)
|
||
void refreshCatalog(epoch)
|
||
cmd<{ members: { owner: string; role: string }[] }>('rdm.project.member.list', { projectId })
|
||
.then(out => { if (currentProject(epoch)) setMembers(out.members ?? []) })
|
||
.catch(() => { if (currentProject(epoch)) setMembers([]) })
|
||
return () => {
|
||
requests.current.epoch++; requests.current.selection++; requests.current.status++
|
||
requests.current.catalog++; requests.current.action++; requests.current.selected = ''
|
||
}
|
||
}, [project])
|
||
|
||
const run = async (label: string, fn: () => Promise<unknown>) => {
|
||
const epoch = requests.current.epoch
|
||
const selection = requests.current.selection
|
||
const selected = requests.current.selected
|
||
const action = ++requests.current.action
|
||
const currentAction = () => currentProject(epoch) && action === requests.current.action
|
||
setErr(''); setNotice(''); setBusy(true)
|
||
try {
|
||
await fn()
|
||
if (!currentAction() || !currentDocument(epoch, selection, selected)) return
|
||
setNotice(label)
|
||
await refreshCatalog(epoch)
|
||
if (currentAction() && currentDocument(epoch, selection, selected) && selected) await selectDoc(selected, true)
|
||
} catch (e) {
|
||
if (currentAction() && currentDocument(epoch, selection, selected)) setErr(String(e))
|
||
} finally { if (currentAction()) 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 || !!writeError || !docId || detail == null || !author[0] || !approver[0] || reviewers.length === 0
|
||
|
||
const save = () => {
|
||
if (saveDisabled || !dirty || requests.current.selected !== docId || detail?.docId !== docId) return
|
||
if (detail.active && !window.confirm(
|
||
`文档「${detail.title}」在途第 ${detail.active.round} 轮审签将因职责变更失效,需重新提交。确认变更?`)) return
|
||
return 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>
|
||
<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>
|
||
)}
|
||
|
||
{detail && docId && (
|
||
<div className="ad-row" style={{ marginTop: 8 }}>
|
||
<button className="lg-submit ad-create-btn" disabled={busy || !!writeError || !detail.assigned || sub === undefined}
|
||
onClick={() => run('整文已提交审核(全文锁定)',
|
||
() => cmd('rdm.review.doc.submit', docSubmitPayload(projectId, docId)))}>整文提交(编制人)</button>
|
||
</div>
|
||
)}
|
||
{sub && (
|
||
<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="ad-row" style={{ marginTop: 8 }}>
|
||
<button className="ad-mini" disabled={busy || !!writeError || sub.state !== 'submitted'}
|
||
onClick={() => run('已通过整文审核',
|
||
() => cmd('rdm.review.doc.approve', docApprovePayload(projectId, docId, '通过')))}>审核通过</button>
|
||
<button className="ad-mini" disabled={busy || !!writeError || sub.state !== 'submitted'}
|
||
onClick={() => run('已驳回整文(解锁回编写态)',
|
||
() => cmd('rdm.review.doc.reject', docRejectPayload(projectId, docId, '退回修改')))}>驳回</button>
|
||
<button className="lg-submit ad-create-btn" disabled={busy || !!writeError || sub.state !== 'submitted'}
|
||
onClick={() => run('整文终批完成(各章节已基线)',
|
||
() => cmd('rdm.review.doc.final', docFinalPayload(projectId, docId)))}>终批(批准人)</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
{sub === null && docId && <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 || !!writeError || !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>}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ---- 项目设置:项目权限(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>
|
||
)
|
||
}
|