From 96ab2ebd3219fd5edd8b34fcdd38c90c37024015 Mon Sep 17 00:00:00 2001 From: agent-exec-transport Date: Sun, 18 Oct 2026 10:53:45 +0000 Subject: [PATCH] agent-exec transport ec7392fedc38af5f4b87509ddc658ec3/46e128ab5767 --- src/components/adminRdmSections.tsx | 152 ++++++++++++++++++++++------ src/rdmManagement.ts | 95 +++++++++++++++++ test/rdm-members-search.test.ts | 125 +++++++++++++++++++++++ 3 files changed, 339 insertions(+), 33 deletions(-) create mode 100644 test/rdm-members-search.test.ts diff --git a/src/components/adminRdmSections.tsx b/src/components/adminRdmSections.tsx index 6c42619..d81bcc5 100644 --- a/src/components/adminRdmSections.tsx +++ b/src/components/adminRdmSections.tsx @@ -147,8 +147,14 @@ export function RdmRolesPanel() { // ---- 项目设置:成员(P7:可搜索人员选择器 + 专业角色/项目身份两列 + 显式身份调整)---- +import { + type MemberMatchMode, type PersonPickItem, type RdmAccount, type RdmMemberPage, type RdmProfRole, + MATCH_MODE_LABELS, accountListParams, accountsToPickItems, appendMemberPage, memberListParams, + personLabel, roleOptionsPayload, +} from '../rdmManagement' + interface MemberRow { - owner: string; role: string; addedBy: string; addedAt: string + owner: string; name?: string; role: string; addedBy: string; addedAt: string profRole?: { roleId: string; name: string } | null } @@ -157,42 +163,78 @@ export function RdmMembersSection({ project }: { project: Project }) { const writeError = projectWorkError(project) const cmd = (id: string, params: Record = {}) => api.rdmProjectCommand(project, id, params) const [rows, setRows] = useState(null) + const [memberNext, setMemberNext] = useState('') + const [memberTotal, setMemberTotal] = useState(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(null) const [err, setErr] = useState('') const [notice, setNotice] = useState('') const [busy, setBusy] = useState(false) const [picked, setPicked] = useState([]) + const [pickedItems, setPickedItems] = useState([]) + const [candRoleId, setCandRoleId] = useState('') + const [candMatch, setCandMatch] = useState('fuzzy') const [role, setRole] = useState<'member' | 'owner'>('member') // 成员表经 ref 参与取数(已加入标记),保持 fetchPage 身份稳定(Picker 依赖)。 const rowsRef = useRef(null) rowsRef.current = rows + const knownPicks = useRef(new Map()) + // 成员表请求代次:切项目/切筛选推进代次,迟到响应丢弃。 + const memberGen = useRef(0) - /** 人员选择器取数适配(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 } + useEffect(() => { + setPicked([]); setPickedItems([]); knownPicks.current.clear() + setCandRoleId(''); setCandMatch('fuzzy'); setMemberQuery(''); setMemberRoleId('') + setRoleOptions(null) + 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]) - const refresh = async () => { + /** 人员选择器取数适配:rdm.account.list 服务端按 query/roleId/match 先过滤后分页。 */ + const fetchPage = useCallback(async (query: string, cursor: string, limit: number) => { + const out = await cmd<{ accounts?: RdmAccount[]; next?: string }>('rdm.account.list', + accountListParams(projectId, { query, cursor, limit, roleId: candRoleId, match: candMatch })) + const items = accountsToPickItems(out.accounts, (rowsRef.current ?? []).map(m => m.owner)) + for (const it of items) knownPicks.current.set(it.id, it) + return { items, nextCursor: out.next || undefined } + }, [project, candRoleId, candMatch]) + + /** 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<{ members: MemberRow[] }>('rdm.project.member.list', { projectId }) - setRows(out.members ?? []) - } catch (e) { setErr(String(e)) } - } - useEffect(() => { setPicked([]); refresh() }, [project]) + const out = await cmd('rdm.project.member.list', + memberListParams(projectId, { query: memberQuery, roleId: memberRoleId, cursor })) + if (gen !== memberGen.current) return + 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) => { setErr(''); setNotice(''); setBusy(true) @@ -200,38 +242,76 @@ export function RdmMembersSection({ project }: { project: Project }) { 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 = !!pickedId && !pickedItem?.disabled && !(rows ?? []).some(m => m.owner === pickedId) + const pickerDisabled = busy || !!writeError + const filtering = memberQuery.trim() !== '' || memberRoleId !== '' + + const roleFilter = (value: string, onChange: (v: string) => void, label: string) => + roleOptions && roleOptions.length > 0 ? ( + + ) : null return (
{writeError &&

{writeError}

}
+
+ {roleFilter(candRoleId, setCandRoleId, '候选专业角色筛选')} + +
-
-

候选来自系统账号目录(姓名为空时显示账号);「已加入」标记避免重复添加造成隐式变更——调整身份请在成员表中操作。最后一位负责人不可移除/降级。

+

候选来自系统账号目录(姓名为空时显示账号),无效账号不提供;已加入项目的账号显示为不可选——调整身份请在成员表中操作。最后一位负责人不可移除/降级。

+
+ setMemberQuery(e.target.value)} /> + {roleFilter(memberRoleId, setMemberRoleId, '成员专业角色筛选')} + {rows !== null && 共 {memberTotal ?? rows.length} 名成员匹配} +
+ {!directoryOk &&

账号目录暂不可用:成员姓名可能缺失,仅按账号显示。

} {(rows ?? []).map(m => ( - + @@ -249,10 +329,16 @@ export function RdmMembersSection({ project }: { project: Project }) { ))} - {rows !== null && rows.length === 0 && } - {rows === null && } + {rows !== null && rows.length === 0 && } + {rows === null && }
成员专业角色项目身份添加人添加时间
{m.owner}{m.profRole?.name ? · {m.profRole.name} : null}{personLabel(m)} {m.profRole?.name || '—'} {m.role === 'owner' ? 项目负责人 : 项目成员} {m.addedBy || '—'}
暂无成员
加载中…
{filtering ? '没有匹配的成员' : '暂无成员'}
{err ? '成员清单未加载(见下方提示)' : '加载中…'}
+ {memberNext && rows !== null && ( +
+ +
+ )} {notice &&
{notice}
} {err &&
{err}
}
diff --git a/src/rdmManagement.ts b/src/rdmManagement.ts index cdd0972..413d101 100644 --- a/src/rdmManagement.ts +++ b/src/rdmManagement.ts @@ -27,6 +27,7 @@ const PROJECT_READ_COMMANDS = new Set([ 'rdm.project.member.list', 'rdm.perm.check', 'rdm.perm.matrix', 'rdm.review.doc.status', 'rdm.duty.catalog', 'rdm.duty.get', 'rdm.duty.matrix', 'rdm.duty.preview', 'rdm.duty.legacy.scan', + 'rdm.role.options', ]) export function rdmProjectCommandError(project: Project, command: string): string | null { @@ -152,6 +153,100 @@ export const docStatusPayload = (projectId: string, docId: string) => ({ project export const permCheckPayload = (projectId: string, member: string) => ({ projectId, member }) +// ---- 项目成员页:候选检索 + 成员表检索(R2 契约:服务端先过滤后分页) ---- + +export type MemberMatchMode = 'fuzzy' | 'exact' +export interface RdmProfRole { roleId: string; name: string } +/** rdm.account.list 单条候选。 */ +export interface RdmAccount { + owner: string; name?: string; valid: boolean; joined?: boolean + projectRole?: string; profRole?: RdmProfRole | null +} +/** rdm.project.member.list 单条成员。 */ +export interface RdmMemberRow { + owner: string; name?: string; role: string; addedBy: string; addedAt: string + profRole?: RdmProfRole | null +} +export interface RdmMemberPage { + members: RdmMemberRow[]; next?: string; total?: number; accountDirectoryAvailable?: boolean +} +/** 与 @ui PickItem 结构兼容(纯逻辑层不依赖组件库)。 */ +export interface PersonPickItem { + id: string; title: string; subtitle?: string; disabled?: boolean; disabledReason?: string +} + +export const MEMBER_PAGE_LIMIT = 50 +export const JOINED_REASON = '已加入项目' +export const MATCH_MODE_LABELS: Record = { + fuzzy: '模糊匹配', + exact: '精确账号或姓名', +} + +export const roleOptionsPayload = (projectId: string) => ({ projectId, purpose: 'members' as const }) + +/** 空串筛选不携带(缺省=不过滤);match 恒携带,服务端据此选择匹配语义。 */ +export function accountListParams(projectId: string, opts: { + query?: string; cursor?: string; limit: number; roleId?: string; match?: MemberMatchMode +}) { + const query = opts.query?.trim() ?? '' + return { + projectId, + limit: opts.limit, + match: opts.match ?? 'fuzzy', + ...(query ? { query } : {}), + ...(opts.cursor ? { cursor: opts.cursor } : {}), + ...(opts.roleId ? { roleId: opts.roleId } : {}), + } +} + +export function memberListParams(projectId: string, opts: { + query?: string; roleId?: string; cursor?: string; limit?: number + match?: MemberMatchMode; projectRole?: 'owner' | 'member' +} = {}) { + const query = opts.query?.trim() ?? '' + return { + projectId, + limit: opts.limit ?? MEMBER_PAGE_LIMIT, + ...(query ? { query } : {}), + ...(opts.roleId ? { roleId: opts.roleId } : {}), + ...(opts.cursor ? { cursor: opts.cursor } : {}), + ...(opts.match ? { match: opts.match } : {}), + ...(opts.projectRole ? { projectRole: opts.projectRole } : {}), + } +} + +/** 人员标签:「姓名 · 账号」,姓名为空时只显示账号。 */ +export function personLabel(p: { owner: string; name?: string }): string { + const name = p.name?.trim() + return name && name !== p.owner ? `${name} · ${p.owner}` : p.owner +} + +/** 候选映射:无效账号不提供;已加入(服务端 joined 或已在成员表)可见但不可选。 */ +export function accountsToPickItems(accounts: RdmAccount[] | undefined, joinedOwners: Iterable = []): PersonPickItem[] { + const joined = new Set(joinedOwners) + return (accounts ?? []).filter(a => a.valid).map(a => { + const isJoined = a.joined === true || joined.has(a.owner) + return { + id: a.owner, + title: a.name?.trim() || a.owner, + subtitle: a.owner, + ...(isJoined ? { disabled: true, disabledReason: JOINED_REASON } : {}), + } + }) +} + +/** 成员表追加下一页:保序,已存在的账号不重复。 */ +export function appendMemberPage(prev: RdmMemberRow[] | null, page: RdmMemberRow[] | undefined): RdmMemberRow[] { + const out = [...(prev ?? [])] + const seen = new Set(out.map(m => m.owner)) + for (const m of page ?? []) { + if (seen.has(m.owner)) continue + seen.add(m.owner) + out.push(m) + } + return out +} + /** 矩阵来源标签(人话映射;未知来源回退原文——服务端新增来源时不至于空白)。 */ export function describeSource(source: string): string { switch (source) { diff --git a/test/rdm-members-search.test.ts b/test/rdm-members-search.test.ts new file mode 100644 index 0000000..50f4bba --- /dev/null +++ b/test/rdm-members-search.test.ts @@ -0,0 +1,125 @@ +// 项目成员页检索(R2 契约):服务端先过滤后分页,前端只拼参数、映射候选与追加分页。 +// node 环境无 DOM——决策逻辑全部落在 src/rdmManagement.ts 的纯函数上逐项断言。 +import { describe, expect, it } from 'vitest' +import type { Project } from '../src/contracts' +import { + JOINED_REASON, MATCH_MODE_LABELS, MEMBER_PAGE_LIMIT, RDM_MEMBER_COMMANDS, + accountListParams, accountsToPickItems, appendMemberPage, memberListParams, + personLabel, rdmMembersReady, rdmProjectCommandError, roleOptionsPayload, + type RdmMemberRow, +} from '../src/rdmManagement' + +const row = (owner: string, name?: string): RdmMemberRow => + ({ owner, name, role: 'member', addedBy: 'admin', addedAt: '' }) + +describe('accountListParams', () => { + it('passes roleId, match and cursor to rdm.account.list', () => { + expect(accountListParams('p1', { query: ' zhang ', cursor: 'c2', limit: 20, roleId: 'r-eng', match: 'exact' })) + .toEqual({ projectId: 'p1', query: 'zhang', cursor: 'c2', limit: 20, roleId: 'r-eng', match: 'exact' }) + }) + + it('omits empty filters and defaults to fuzzy matching', () => { + expect(accountListParams('p1', { query: ' ', cursor: '', limit: 20, roleId: '' })) + .toEqual({ projectId: 'p1', limit: 20, match: 'fuzzy' }) + }) + + it('labels both match modes in Chinese', () => { + expect(MATCH_MODE_LABELS).toEqual({ fuzzy: '模糊匹配', exact: '精确账号或姓名' }) + }) +}) + +describe('memberListParams', () => { + it('sends query, roleId and cursor with a page size of 50', () => { + expect(memberListParams('p1', { query: 'li', roleId: 'r-qa', cursor: 'n1' })) + .toEqual({ projectId: 'p1', query: 'li', roleId: 'r-qa', cursor: 'n1', limit: MEMBER_PAGE_LIMIT }) + expect(MEMBER_PAGE_LIMIT).toBe(50) + }) + + it('loads the unfiltered first page when no filter is set', () => { + expect(memberListParams('p1', { query: '', roleId: '', cursor: '' })).toEqual({ projectId: 'p1', limit: 50 }) + }) + + it('forwards the optional match and projectRole filters', () => { + expect(memberListParams('p1', { match: 'exact', projectRole: 'owner', limit: 10 })) + .toEqual({ projectId: 'p1', limit: 10, match: 'exact', projectRole: 'owner' }) + }) +}) + +describe('roleOptionsPayload', () => { + it('asks for member-purpose role options', () => { + expect(roleOptionsPayload('p1')).toEqual({ projectId: 'p1', purpose: 'members' }) + }) +}) + +describe('accountsToPickItems', () => { + it('drops invalid accounts', () => { + const items = accountsToPickItems([ + { owner: 'a', name: '甲', valid: true, joined: false }, + { owner: 'gone', name: '离职', valid: false, joined: false }, + ]) + expect(items.map(it => it.id)).toEqual(['a']) + }) + + it('disables joined candidates with the 已加入项目 reason instead of a badge', () => { + const items = accountsToPickItems([ + { owner: 'a', name: '甲', valid: true, joined: true }, + { owner: 'b', valid: true, joined: false }, + { owner: 'c', name: '丙', valid: true }, + ], ['b']) + expect(items).toEqual([ + { id: 'a', title: '甲', subtitle: 'a', disabled: true, disabledReason: JOINED_REASON }, + { id: 'b', title: 'b', subtitle: 'b', disabled: true, disabledReason: '已加入项目' }, + { id: 'c', title: '丙', subtitle: 'c' }, + ]) + expect(items.some(it => 'badge' in it)).toBe(false) + }) + + it('keeps name as title and account as subtitle so the selection shows both', () => { + const [it] = accountsToPickItems([{ owner: 'zhangsan', name: '张三', valid: true, joined: false }]) + expect(it.title).toBe('张三') + expect(it.subtitle).toBe('zhangsan') + }) + + it('tolerates a missing account list', () => { + expect(accountsToPickItems(undefined)).toEqual([]) + }) +}) + +describe('personLabel', () => { + it('renders name · account', () => { + expect(personLabel({ owner: 'zhangsan', name: '张三' })).toBe('张三 · zhangsan') + }) + + it('falls back to the account when there is no name', () => { + expect(personLabel({ owner: 'zhangsan' })).toBe('zhangsan') + expect(personLabel({ owner: 'zhangsan', name: ' ' })).toBe('zhangsan') + expect(personLabel({ owner: 'zhangsan', name: 'zhangsan' })).toBe('zhangsan') + }) +}) + +describe('appendMemberPage', () => { + it('appends the next page in order without duplicating accounts', () => { + const first = [row('a', '甲'), row('b')] + const got = appendMemberPage(first, [row('b', '乙-重复'), row('c', '丙')]) + expect(got.map(m => m.owner)).toEqual(['a', 'b', 'c']) + expect(got[1].name).toBeUndefined() + expect(first.map(m => m.owner)).toEqual(['a', 'b']) + }) + + it('starts a fresh list from null and dedupes within a page', () => { + expect(appendMemberPage(null, [row('a'), row('a')]).map(m => m.owner)).toEqual(['a']) + expect(appendMemberPage(null, undefined)).toEqual([]) + }) +}) + +describe('rdm.role.options command gating', () => { + it('is a project read command, so archived projects can still load role filters', () => { + const archived = { id: 'p1', settings: { 'rdm.state': 'archived', 'rdm.projectId': 'rdm-1' } } as unknown as Project + expect(rdmProjectCommandError(archived, 'rdm.role.options')).toBeNull() + }) + + it('does not gate member page readiness', () => { + expect(RDM_MEMBER_COMMANDS).not.toContain('rdm.role.options') + expect(rdmMembersReady(RDM_MEMBER_COMMANDS.map(id => ({ id, enabled: true })))).toBe(true) + }) +})