Compare commits

...
Author SHA1 Message Date
weichen dfcea5c988 Wire members section to Picker with explicit role changes
Why: P7-A——成员添加从手填账号升级为可搜索人员选择器(@ui Picker:搜索/浏览/键盘/分页),仅提交稳定账号;候选「已加入」标记杜绝隐式身份变更;成员表拆分「专业角色」与「项目身份」(中文标签)两列;身份调整走 rdm.project.member.setRole 显式命令(升/降按钮)。
Evidence: tsc 通过;design-guard 6/6;npm run build 成功;Picker 行为由 UI #53 的 7 用例覆盖。
Impact: RdmMembersSection 重构(member.add/setRole/remove 三命令消费);rdmManagement 增 setRole payload;account.list 适配层 query 透传+旧基座首页本地过滤降级。
Refs: 提案 r1 §3/P7;UI #53;RDM #345
2026-10-13 09:34:52 +00:00
2 changed files with 67 additions and 24 deletions
+66 -24
View File
@@ -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, EmptyState, Icon, Picker} from '@ui'
import {
ROLE_ACTIONS, parseReviewers,
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>}
+1
View File
@@ -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,