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