diff --git a/src/components/adminRdmSections.tsx b/src/components/adminRdmSections.tsx index 9ae19b3..74f6b70 100644 --- a/src/components/adminRdmSections.tsx +++ b/src/components/adminRdmSections.tsx @@ -2,7 +2,7 @@ // 门控集中在 src/rdmManagement.ts;分区可见性由 ProjectSettingsModal 按 store 的 // rdm*Available 门控(命令面不齐不渲染,普通基座零暴露)。样式只复用 // 既有 ad-*/lg-*/settings-* 类(design-guard 棘轮:不新增裸色值/字号)。 -import { useEffect, useState, useRef, useCallback } from 'react' +import { useEffect, useState, useRef, useCallback, useMemo } from 'react' import { api } from '../api' import type { Project } from '../contracts' import { projectWorkError } from '../projectmeta' @@ -16,8 +16,9 @@ import { // ---- 项目设置:成员(P7:可搜索人员选择器 + 专业角色/项目身份两列 + 显式身份调整)---- import { - type MemberMatchMode, type PersonPickItem, type RdmAccount, type RdmMemberPage, type RdmProfRole, - MATCH_MODE_LABELS, accountListParams, accountsToPickItems, appendMemberPage, memberListParams, + type MemberMatchMode, type PersonPickItem, type RdmAccountPage, type RdmMemberPage, type RdmProfRole, + type SearchFieldsChoice, + MATCH_MODE_LABELS, SEARCH_FIELDS, accountCandidatePager, accountSearchKey, appendMemberPage, memberListParams, personLabel, roleOptionsPayload, } from '../rdmManagement' @@ -46,6 +47,7 @@ export function RdmMembersSection({ project }: { project: Project }) { const [pickedItems, setPickedItems] = useState([]) const [candRoleId, setCandRoleId] = useState('') const [candMatch, setCandMatch] = useState('fuzzy') + const [candFields, setCandFields] = useState('both') const [role, setRole] = useState<'member' | 'owner'>('member') // 成员表经 ref 参与取数(已加入标记),保持 fetchPage 身份稳定(Picker 依赖)。 const rowsRef = useRef(null) @@ -56,7 +58,7 @@ export function RdmMembersSection({ project }: { project: Project }) { useEffect(() => { setPicked([]); setPickedItems([]); knownPicks.current.clear() - setCandRoleId(''); setCandMatch('fuzzy'); setMemberQuery(''); setMemberRoleId('') + setCandRoleId(''); setCandMatch('fuzzy'); setCandFields('both'); setMemberQuery(''); setMemberRoleId('') setRoleOptions(null) let live = true cmd<{ roles?: RdmProfRole[] }>('rdm.role.options', roleOptionsPayload(projectId)) @@ -65,14 +67,19 @@ export function RdmMembersSection({ project }: { project: Project }) { return () => { live = false } }, [project]) - /** 人员选择器取数适配: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]) + const candOptions = useMemo(() => ({ mode: candMatch, searchFields: SEARCH_FIELDS[candFields].fields, roleId: candRoleId }), + [candMatch, candFields, candRoleId]) + + /** 人员选择器取数适配:rdm.account.list 服务端按 query/mode/searchFields/roleId 先过滤后分页。 */ + const fetchPage = useMemo(() => { + const page = accountCandidatePager(params => cmd('rdm.account.list', params), projectId, + { ...candOptions, joinedOwners: () => (rowsRef.current ?? []).map(m => m.owner) }) + return async (query: string, cursor: string, limit: number) => { + const out = await page(query, cursor, limit) + for (const it of out.items) knownPicks.current.set(it.id, it) + return out + } + }, [project, candOptions]) /** cursor 空=重载首页(clear 时先清空表格);非空=追加下一页。 */ const loadMembers = useCallback(async (cursor: string, clear: boolean) => { @@ -120,7 +127,7 @@ export function RdmMembersSection({ project }: { project: Project }) { const pickedId = picked[0] ?? '' const pickedItem = pickedItems.find(it => it.id === pickedId) - const pickedValid = !!pickedId && !pickedItem?.disabled && !(rows ?? []).some(m => m.owner === pickedId) + const pickedValid = !!pickedItem && !pickedItem.disabled && !(rows ?? []).some(m => m.owner === pickedItem.owner) const pickerDisabled = busy || !!writeError const filtering = memberQuery.trim() !== '' || memberRoleId !== '' @@ -143,6 +150,11 @@ export function RdmMembersSection({ project }: { project: Project }) { {(Object.keys(MATCH_MODE_LABELS) as MemberMatchMode[]).map(m => )} +
-

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

+

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

([]) const [legacyOpen, setLegacyOpen] = useState(false) + const [candMode, setCandMode] = useState('fuzzy') + const [candFields, setCandFields] = useState('both') + const [accountIds, setAccountIds] = useState({}) const seq = useRef(0) const op = useRef(0) const docsRef = useRef(docs) @@ -117,12 +123,25 @@ export function RdmDutiesSection({ project }: { project: Project }) { return () => { seq.current += 1 } }, [project, query, stage, typeKey, assignment, docCursor]) + useEffect(() => { setAccountIds({}) }, [project]) + + const candOptions = useMemo(() => ({ docId, mode: candMode, searchFields: SEARCH_FIELDS[candFields].fields }), + [docId, candMode, candFields]) + const fetchCandidates = useMemo(() => accountCandidatePager( + params => api.rdmProjectCommand(project, 'rdm.account.list', params), projectId, candOptions, + ), [project, candOptions]) + const onCell = (id: string, owner: string, duty: CellDuty) => { setPreview([]) setConfirm(false) setDocs(current => editCell(current, id, owner, duty)) } + const onCandidate = (id: string, candidate: PersonPickItem, duty: Exclude) => { + setAccountIds(current => ({ ...current, [candidate.owner]: candidate.id })) + onCell(id, candidate.owner, duty) + } + const visibleDocs = docs.filter(doc => { const text = `${doc.title} ${doc.code} ${doc.docId}`.toLowerCase() if (query && !text.includes(query.trim().toLowerCase())) return false @@ -132,7 +151,7 @@ export function RdmDutiesSection({ project }: { project: Project }) { }) const save = async () => { - const payload = savePayload(docsRef.current, confirm) + const payload = savePayload(docsRef.current, confirm, accountIds) if (payload.issues.length > 0) { setErr(payload.issues.join(';')) return @@ -154,7 +173,7 @@ export function RdmDutiesSection({ project }: { project: Project }) { return } sent = true - await api.rdmProjectCommand(project, 'rdm.review.assign.doc.batch', dutyPreviewPayload(projectId, savePayload(docsRef.current, confirm).docs)) + await api.rdmProjectCommand(project, 'rdm.review.assign.doc.batch', dutyPreviewPayload(projectId, savePayload(docsRef.current, confirm, accountIds).docs)) if (request !== seq.current) return setNotice('已保存文档职责') setPreview([]) @@ -228,6 +247,14 @@ export function RdmDutiesSection({ project }: { project: Project }) {
{view === 'member' && } {view === 'document' && } + {view === 'document' && (() => { + const doc = docs.find(item => item.docId === docId) + return doc ? ( + onCandidate(doc.docId, candidate, duty)} /> + ) : null + })()} {view === 'matrix' && }
{docCursor && } diff --git a/src/duties/draft.ts b/src/duties/draft.ts index eddf52b..20c1676 100644 --- a/src/duties/draft.ts +++ b/src/duties/draft.ts @@ -1,4 +1,6 @@ // 文档职责草稿的唯一存储。三个视图只投影这份数据;单元格值由三权反推。 +import { dutyAccountIds, type DutyAccountIds } from '../rdmManagement' + export type CellDuty = 'none' | 'author' | 'reviewer' | 'approver' interface DutyRoles { @@ -35,6 +37,7 @@ interface DutySaveItem { approver: string expectedDutyVersion: number confirmInvalidation: boolean + accountIds?: DutyAccountIds } export function cellDuty(roles: DutyRoles, owner: string): CellDuty { @@ -174,7 +177,10 @@ export function dirtyDocs(docs: DutyDocDraft[]): DutyDocDraft[] { return docs.filter(doc => doc.dirty) } -export function savePayload(docs: DutyDocDraft[], confirmInvalidation: boolean): { docs: DutySaveItem[]; issues: string[] } { +/** accountIds:候选检索选中的 owner → userId;每份文档只携带其职责涉及的人。 */ +export function savePayload( + docs: DutyDocDraft[], confirmInvalidation: boolean, accountIds?: DutyAccountIds, +): { docs: DutySaveItem[]; issues: string[] } { const dirty = dirtyDocs(docs) const issues: string[] = [] if (dirty.length === 0) issues.push('没有未保存的职责') @@ -183,14 +189,18 @@ export function savePayload(docs: DutyDocDraft[], confirmInvalidation: boolean): for (const issue of saveIssues(doc)) issues.push(`${doc.title}:${issue}`) } return { - docs: dirty.slice(0, 20).map(doc => ({ - docId: doc.docId, - author: doc.author, - reviewers: doc.reviewers, - approver: doc.approver, - expectedDutyVersion: doc.dutyVersion, - confirmInvalidation: confirmInvalidation && doc.activeRound > 0, - })), + docs: dirty.slice(0, 20).map(doc => { + const ids = dutyAccountIds(doc, accountIds) + return { + docId: doc.docId, + author: doc.author, + reviewers: doc.reviewers, + approver: doc.approver, + expectedDutyVersion: doc.dutyVersion, + confirmInvalidation: confirmInvalidation && doc.activeRound > 0, + ...(ids ? { accountIds: ids } : {}), + } + }), issues, } } diff --git a/src/duties/views.tsx b/src/duties/views.tsx index cde818c..9b42568 100644 --- a/src/duties/views.tsx +++ b/src/duties/views.tsx @@ -1,5 +1,11 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { Picker } from '@ui' import type { CellDuty, DutyDocDraft, DutyMember } from './draft' import { cellDuty, completeness, replacementNote, saveIssues } from './draft' +import { + MATCH_MODE_LABELS, SEARCH_FIELDS, + type MemberMatchMode, type PersonPickItem, type SearchFieldsChoice, +} from '../rdmManagement' const DUTY_OPTIONS: { value: CellDuty; label: string }[] = [ { value: 'none', label: '无职责' }, @@ -120,6 +126,67 @@ export function DutyByDocumentView({ ) } +type AssignedDuty = Exclude + +/** 文档职责候选检索:rdm.account.list 携带 docId,只有当前有效成员可选;选中后按 userId 指派。 */ +export function DutyCandidateSearch({ + doc, mode, fields, fetchPage, reloadKey, disabled, onMode, onFields, onAssign, +}: { + doc: DutyDocDraft + mode: MemberMatchMode + fields: SearchFieldsChoice + fetchPage: (query: string, cursor: string, limit: number) => Promise<{ items: PersonPickItem[]; nextCursor?: string }> + reloadKey: string + disabled: boolean + onMode: (mode: MemberMatchMode) => void + onFields: (fields: SearchFieldsChoice) => void + onAssign: (candidate: PersonPickItem, duty: AssignedDuty) => void +}) { + const [picked, setPicked] = useState(null) + const [duty, setDuty] = useState('reviewer') + const known = useRef(new Map()) + useEffect(() => { known.current.clear(); setPicked(null) }, [reloadKey]) + const fetchKnown = useCallback(async (query: string, cursor: string, limit: number) => { + const page = await fetchPage(query, cursor, limit) + for (const item of page.items) known.current.set(item.id, item) + return page + }, [fetchPage]) + return ( +
+

搜索候选人

+
+ + +
+
+ setPicked(known.current.get(ids[0] ?? '') ?? null)} + fetchPage={fetchKnown} + reloadKey={reloadKey} + disabled={disabled} + placeholder={mode === 'fuzzy' ? '输入至少两个字符搜索账号或姓名' : '输入完整账号或姓名精确搜索'} + emptyText="输入后显示当前有效成员" + /> + + +
+ {picked &&

{picked.title} · {picked.owner}{replacementNote(doc, picked.owner, duty) ? `(${replacementNote(doc, picked.owner, duty)})` : ''}

} +
+ ) +} + export function DutyMatrixView({ docs, members, disabled, narrow, onCell, }: { diff --git a/src/rdmManagement.ts b/src/rdmManagement.ts index 344f777..06ae571 100644 --- a/src/rdmManagement.ts +++ b/src/rdmManagement.ts @@ -102,19 +102,39 @@ export const roleActionPayload = (roleId: string, action: string, def: boolean, export const roleAssignPayload = (owner: string, roleId: string) => ({ owner, roleId }) export const roleDisablePayload = (roleId: string, disabled: boolean) => ({ roleId, disabled }) -export const memberAddPayload = (projectId: string, member: string, role: 'owner' | 'member' = 'member') => - ({ projectId, member, role }) +/** userId 为候选检索选中的稳定账号;服务端按 member 精确查找后比对,不一致即拒。 */ +export const memberAddPayload = (projectId: string, member: string, role: 'owner' | 'member' = 'member', userId?: string) => + ({ projectId, member, role, ...(userId ? { userId } : {}) }) export const memberRemovePayload = (projectId: string, member: string) => ({ projectId, member }) export const memberSetRolePayload = (projectId: string, member: string, role: 'owner' | 'member') => ({ projectId, member, role }) +/** 职责账号身份:owner → 候选检索选中的 userId。 */ +export type DutyAccountIds = Record + +/** 只保留本条职责实际涉及的人;为空不携带。 */ +export function dutyAccountIds( + roles: { author: string; reviewers: string[]; approver: string }, known: DutyAccountIds | undefined, +): DutyAccountIds | undefined { + if (!known) return undefined + const out: DutyAccountIds = {} + for (const owner of [roles.author, ...roles.reviewers, roles.approver]) { + if (owner && known[owner]) out[owner] = known[owner] + } + return Object.keys(out).length > 0 ? out : undefined +} + export const assignDocDutiesPayload = ( projectId: string, docId: string, author: string, reviewers: string[], approver: string, - extra?: { expectedDutyVersion?: number; confirmInvalidation?: boolean }, -) => ({ - projectId, docId, author, reviewers, approver, - ...(extra?.expectedDutyVersion !== undefined ? { expectedDutyVersion: extra.expectedDutyVersion } : {}), - ...(extra?.confirmInvalidation !== undefined ? { confirmInvalidation: extra.confirmInvalidation } : {}), -}) + extra?: { expectedDutyVersion?: number; confirmInvalidation?: boolean; accountIds?: DutyAccountIds }, +) => { + const accountIds = dutyAccountIds({ author, reviewers, approver }, extra?.accountIds) + return { + projectId, docId, author, reviewers, approver, + ...(extra?.expectedDutyVersion !== undefined ? { expectedDutyVersion: extra.expectedDutyVersion } : {}), + ...(extra?.confirmInvalidation !== undefined ? { confirmInvalidation: extra.confirmInvalidation } : {}), + ...(accountIds ? { accountIds } : {}), + } +} export const dutyMatrixPayload = ( projectId: string, @@ -136,7 +156,10 @@ export const dutyMatrixPayload = ( export const dutyPreviewPayload = ( projectId: string, - docs: { docId: string; author: string; reviewers: string[]; approver: string; expectedDutyVersion: number; confirmInvalidation: boolean }[], + docs: { + docId: string; author: string; reviewers: string[]; approver: string + expectedDutyVersion: number; confirmInvalidation: boolean; accountIds?: DutyAccountIds + }[], ) => ({ projectId, docs }) export const dutyLegacyPayload = (projectId: string) => ({ projectId }) @@ -172,12 +195,16 @@ export const commandRequestBody = (params: Record, source?: Com // ---- 项目成员页:候选检索 + 成员表检索(R2 契约:服务端先过滤后分页) ---- export type MemberMatchMode = 'fuzzy' | 'exact' +type AccountSearchField = 'owner' | 'name' export interface RdmProfRole { roleId: string; name: string } /** rdm.account.list 单条候选。 */ export interface RdmAccount { - owner: string; name?: string; valid: boolean; joined?: boolean + userId: string; owner: string; name?: string; valid: boolean; joined?: boolean projectRole?: string; profRole?: RdmProfRole | null } +export interface RdmAccountPage { + accounts?: RdmAccount[]; next?: string; directoryVersion?: string; count?: number +} /** rdm.project.member.list 单条成员。 */ export interface RdmMemberRow { owner: string; name?: string; role: string; addedBy: string; addedAt: string @@ -186,32 +213,78 @@ export interface RdmMemberRow { export interface RdmMemberPage { members: RdmMemberRow[]; next?: string; total?: number; accountDirectoryAvailable?: boolean } -/** 与 @ui PickItem 结构兼容(纯逻辑层不依赖组件库)。 */ +/** 与 @ui PickItem 结构兼容(纯逻辑层不依赖组件库);id 为稳定 userId,owner 为提交用账号。 */ export interface PersonPickItem { - id: string; title: string; subtitle?: string; disabled?: boolean; disabledReason?: string + id: string; owner: string; title: string; subtitle?: string; disabled?: boolean; disabledReason?: string } export const MEMBER_PAGE_LIMIT = 50 +const ACCOUNT_PAGE_LIMIT = 50 export const JOINED_REASON = '已加入项目' +export const INVALID_REASON = '账号无效' +export const NOT_MEMBER_REASON = '非当前项目成员' export const MATCH_MODE_LABELS: Record = { fuzzy: '模糊匹配', exact: '精确账号或姓名', } +export type SearchFieldsChoice = 'both' | 'owner' | 'name' +export const SEARCH_FIELDS: Record = { + both: { label: '账号和姓名', fields: ['owner', 'name'] }, + owner: { label: '仅账号', fields: ['owner'] }, + name: { label: '仅姓名', fields: ['name'] }, +} 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 +interface AccountSearchOptions { + mode?: MemberMatchMode + searchFields?: AccountSearchField[] + roleId?: string + /** 携带即按该文档的职责候选检索(仅当前有效成员)。 */ + docId?: string +} + +/** 只用 mode(match 是服务端旧别名,两者并存且不一致会被拒);空串筛选不携带。 */ +export function accountListParams(projectId: string, opts: AccountSearchOptions & { + query?: string; cursor?: string; limit: number }) { const query = opts.query?.trim() ?? '' return { projectId, - limit: opts.limit, - match: opts.match ?? 'fuzzy', + limit: Math.min(Math.max(1, opts.limit), ACCOUNT_PAGE_LIMIT), + mode: opts.mode ?? 'fuzzy', + ...(opts.searchFields && opts.searchFields.length > 0 ? { searchFields: [...opts.searchFields] } : {}), ...(query ? { query } : {}), ...(opts.cursor ? { cursor: opts.cursor } : {}), ...(opts.roleId ? { roleId: opts.roleId } : {}), + ...(opts.docId ? { docId: opts.docId } : {}), + } +} + +/** Picker reloadKey:项目/文档/匹配方式/检索字段/角色任一变化都清空查询与游标。 */ +export function accountSearchKey(projectId: string, opts: AccountSearchOptions): string { + return JSON.stringify([projectId, opts.docId ?? '', opts.mode ?? 'fuzzy', opts.searchFields ?? [], opts.roleId ?? '']) +} + +/** + * Picker 取数适配。空 query 不发请求(服务端空查询不枚举);cursor 只在签发它的 + * 同一检索条件(query + 选项)下沿用,条件变化即回到首页。 + */ +export function accountCandidatePager( + run: (params: ReturnType) => Promise, + projectId: string, + opts: AccountSearchOptions & { joinedOwners?: () => Iterable }, +) { + const base = accountSearchKey(projectId, opts) + let issuedFor = '' + return async (query: string, cursor: string, limit: number): Promise<{ items: PersonPickItem[]; nextCursor?: string }> => { + const q = query.trim() + if (!q) { issuedFor = ''; return { items: [] } } + const key = `${base}|${q}` + const out = await run(accountListParams(projectId, { ...opts, query: q, cursor: key === issuedFor ? cursor : '', limit })) + issuedFor = out.next ? key : '' + const items = accountsToPickItems(out.accounts, opts.joinedOwners?.() ?? [], opts.docId ? 'duty' : 'member') + return { items, nextCursor: out.next || undefined } } } @@ -237,16 +310,25 @@ export function personLabel(p: { owner: string; name?: string }): string { return name && name !== p.owner ? `${name} · ${p.owner}` : p.owner } -/** 候选映射:无效账号不提供;已加入(服务端 joined 或已在成员表)可见但不可选。 */ -export function accountsToPickItems(accounts: RdmAccount[] | undefined, joinedOwners: Iterable = []): PersonPickItem[] { +/** + * 候选映射(id=userId;无 userId 的行无法稳定提交,不提供)。无效账号可见但不可选; + * member:已加入(服务端 joined 或已在成员表)不可选;duty:只有当前成员可选。 + */ +export function accountsToPickItems( + accounts: RdmAccount[] | undefined, joinedOwners: Iterable = [], purpose: 'member' | 'duty' = 'member', +): PersonPickItem[] { const joined = new Set(joinedOwners) - return (accounts ?? []).filter(a => a.valid).map(a => { + return (accounts ?? []).filter(a => a.userId && a.owner).map(a => { const isJoined = a.joined === true || joined.has(a.owner) + const reason = !a.valid ? INVALID_REASON + : purpose === 'member' && isJoined ? JOINED_REASON + : purpose === 'duty' && !isJoined ? NOT_MEMBER_REASON : '' return { - id: a.owner, + id: a.userId, + owner: a.owner, title: a.name?.trim() || a.owner, subtitle: a.owner, - ...(isJoined ? { disabled: true, disabledReason: JOINED_REASON } : {}), + ...(reason ? { disabled: true, disabledReason: reason } : {}), } }) } diff --git a/test/duty-draft.test.ts b/test/duty-draft.test.ts index 7747784..0c00e78 100644 --- a/test/duty-draft.test.ts +++ b/test/duty-draft.test.ts @@ -69,5 +69,16 @@ describe('文档职责草稿', () => { docId: 'd1', author: 'alice', reviewers: ['bob'], approver: 'carol', expectedDutyVersion: 3, confirmInvalidation: true, }) + expect(one.docs[0]).not.toHaveProperty('accountIds') + }) + + it('候选检索选中的 userId 随预览/批量保存以 accountIds{owner:userId} 携带,只含本文档职责人', () => { + const known = { bob: 'u-bob', erin: 'u-erin' } + const out = savePayload([ + doc({ dirty: true }), + doc({ docId: 'd2', dirty: true, author: 'dave', reviewers: ['frank'], approver: 'gina' }), + ], false, known) + expect(out.docs[0].accountIds).toEqual({ bob: 'u-bob' }) + expect(out.docs[1]).not.toHaveProperty('accountIds') }) }) diff --git a/test/rdm-management.test.ts b/test/rdm-management.test.ts index 14cebfc..40462d8 100644 --- a/test/rdm-management.test.ts +++ b/test/rdm-management.test.ts @@ -59,6 +59,21 @@ describe('命令载荷构造(身份只经认证会话——载荷不含任何 expect(memberAddPayload('p1', 'carol', 'owner')).toEqual({ projectId: 'p1', member: 'carol', role: 'owner' }) expect(memberRemovePayload('p1', 'carol')).toEqual({ projectId: 'p1', member: 'carol' }) }) + it('成员添加携带候选检索选中的 userId(缺省不携带)', () => { + expect(memberAddPayload('p1', 'carol', 'member', 'u-carol')) + .toEqual({ projectId: 'p1', member: 'carol', role: 'member', userId: 'u-carol' }) + expect(memberAddPayload('p1', 'carol', 'owner', '')).not.toHaveProperty('userId') + }) + it('文档职责分配/预览携带 accountIds{owner:userId},只含本条职责涉及的人', () => { + const known = { alice: 'u-a', bob: 'u-b', erin: 'u-e' } + expect(assignDocDutiesPayload('p1', 'd1', 'alice', ['bob'], 'dave', { expectedDutyVersion: 2, accountIds: known })) + .toEqual({ projectId: 'p1', docId: 'd1', author: 'alice', reviewers: ['bob'], approver: 'dave', + expectedDutyVersion: 2, accountIds: { alice: 'u-a', bob: 'u-b' } }) + expect(assignDocDutiesPayload('p1', 'd1', 'dave', ['carol'], 'frank', { accountIds: known })).not.toHaveProperty('accountIds') + const doc = { docId: 'd1', author: 'alice', reviewers: ['bob'], approver: 'dave', expectedDutyVersion: 2, + confirmInvalidation: false, accountIds: { alice: 'u-a' } } + expect(dutyPreviewPayload('p1', [doc])).toEqual({ projectId: 'p1', docs: [doc] }) + }) it('文档职责与整文审签载荷', () => { expect(assignDocDutiesPayload('p1', 'd1', 'alice', ['bob', 'carol'], 'dave')) .toEqual({ projectId: 'p1', docId: 'd1', author: 'alice', reviewers: ['bob', 'carol'], approver: 'dave' }) diff --git a/test/rdm-members-search.test.ts b/test/rdm-members-search.test.ts index 50f4bba..a7a7448 100644 --- a/test/rdm-members-search.test.ts +++ b/test/rdm-members-search.test.ts @@ -3,28 +3,104 @@ 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, + INVALID_REASON, JOINED_REASON, MATCH_MODE_LABELS, MEMBER_PAGE_LIMIT, NOT_MEMBER_REASON, RDM_MEMBER_COMMANDS, + SEARCH_FIELDS, accountCandidatePager, accountListParams, accountSearchKey, accountsToPickItems, appendMemberPage, + memberListParams, personLabel, rdmMembersReady, rdmProjectCommandError, roleOptionsPayload, + type RdmAccountPage, 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('passes mode, searchFields, roleId and cursor to rdm.account.list', () => { + expect(accountListParams('p1', { + query: ' zhang ', cursor: 'c2', limit: 20, roleId: 'r-eng', mode: 'exact', searchFields: ['name'], + })).toEqual({ projectId: 'p1', query: 'zhang', cursor: 'c2', limit: 20, roleId: 'r-eng', mode: 'exact', searchFields: ['name'] }) }) - 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('passes docId for document duty candidates', () => { + expect(accountListParams('p1', { query: 'li', limit: 20, docId: 'd1', searchFields: ['owner', 'name'] })) + .toEqual({ projectId: 'p1', query: 'li', limit: 20, mode: 'fuzzy', searchFields: ['owner', 'name'], docId: 'd1' }) }) - it('labels both match modes in Chinese', () => { + it('never sends the legacy match alias next to mode', () => { + const params = accountListParams('p1', { query: 'li', limit: 20, mode: 'exact' }) + expect(params).not.toHaveProperty('match') + expect(params.mode).toBe('exact') + }) + + it('omits empty filters, defaults to fuzzy and caps the page at 50', () => { + expect(accountListParams('p1', { query: ' ', cursor: '', limit: 200, roleId: '', docId: '', searchFields: [] })) + .toEqual({ projectId: 'p1', limit: 50, mode: 'fuzzy' }) + }) + + it('labels both match modes and the search field choices in Chinese', () => { expect(MATCH_MODE_LABELS).toEqual({ fuzzy: '模糊匹配', exact: '精确账号或姓名' }) + expect(SEARCH_FIELDS.both.fields).toEqual(['owner', 'name']) + expect(SEARCH_FIELDS.owner.fields).toEqual(['owner']) + expect(SEARCH_FIELDS.name.fields).toEqual(['name']) + }) +}) + +describe('accountCandidatePager', () => { + const recorder = (pages: RdmAccountPage[]) => { + const calls: Record[] = [] + const run = async (params: Record) => { calls.push(params); return pages.shift() ?? {} } + return { calls, run } + } + const acct = (userId: string, owner: string) => ({ userId, owner, valid: true, joined: false }) + + it('does not query the directory for an empty search', async () => { + const { calls, run } = recorder([]) + const page = accountCandidatePager(run, 'p1', { mode: 'fuzzy' }) + expect(await page(' ', '', 20)).toEqual({ items: [] }) + expect(calls).toEqual([]) + }) + + it('pages with the opaque next cursor under the same query and options', async () => { + const { calls, run } = recorder([{ accounts: [acct('u1', 'a1')], next: 'n1' }, { accounts: [acct('u2', 'a2')] }]) + const page = accountCandidatePager(run, 'p1', { mode: 'exact', searchFields: ['owner'], docId: 'd1' }) + const first = await page('zh', '', 20) + expect(first.nextCursor).toBe('n1') + await page('zh', 'n1', 20) + expect(calls).toEqual([ + { projectId: 'p1', query: 'zh', limit: 20, mode: 'exact', searchFields: ['owner'], docId: 'd1' }, + { projectId: 'p1', query: 'zh', limit: 20, mode: 'exact', searchFields: ['owner'], docId: 'd1', cursor: 'n1' }, + ]) + }) + + it('resets the cursor when the query changes', async () => { + const { calls, run } = recorder([{ accounts: [], next: 'n1' }, { accounts: [] }]) + const page = accountCandidatePager(run, 'p1', { mode: 'fuzzy' }) + await page('zh', '', 20) + await page('zhang', 'n1', 20) + expect(calls[1]).not.toHaveProperty('cursor') + }) + + it('resets the cursor when mode or search fields change (new pager, new reload key)', async () => { + const { calls, run } = recorder([{ accounts: [], next: 'n1' }, { accounts: [] }, { accounts: [] }]) + await accountCandidatePager(run, 'p1', { mode: 'fuzzy', searchFields: ['owner', 'name'] })('zh', '', 20) + await accountCandidatePager(run, 'p1', { mode: 'exact', searchFields: ['owner', 'name'] })('zh', 'n1', 20) + await accountCandidatePager(run, 'p1', { mode: 'fuzzy', searchFields: ['name'] })('zh', 'n1', 20) + expect(calls[1]).not.toHaveProperty('cursor') + expect(calls[2]).not.toHaveProperty('cursor') + const key = (mode: 'fuzzy' | 'exact', fields: ('owner' | 'name')[], docId?: string) => + accountSearchKey('p1', { mode, searchFields: fields, docId }) + expect(new Set([key('fuzzy', ['owner', 'name']), key('exact', ['owner', 'name']), key('fuzzy', ['name']), + key('fuzzy', ['owner', 'name'], 'd1')]).size).toBe(4) + }) + + it('maps duty candidates so only current members are selectable', async () => { + const { run } = recorder([{ accounts: [ + { userId: 'u1', owner: 'm1', name: '成员', valid: true, joined: true }, + { userId: 'u2', owner: 'x1', valid: true, joined: false }, + ] }]) + const { items } = await accountCandidatePager(run, 'p1', { docId: 'd1' })('m', '', 20) + expect(items).toEqual([ + { id: 'u1', owner: 'm1', title: '成员', subtitle: 'm1' }, + { id: 'u2', owner: 'x1', title: 'x1', subtitle: 'x1', disabled: true, disabledReason: NOT_MEMBER_REASON }, + ]) }) }) @@ -52,30 +128,35 @@ describe('roleOptionsPayload', () => { }) describe('accountsToPickItems', () => { - it('drops invalid accounts', () => { + it('keys candidates by stable userId and drops rows without one', () => { const items = accountsToPickItems([ - { owner: 'a', name: '甲', valid: true, joined: false }, - { owner: 'gone', name: '离职', valid: false, joined: false }, + { userId: 'u-a', owner: 'a', name: '甲', valid: true, joined: false }, + { userId: '', owner: 'anon', valid: true, joined: false }, ]) - expect(items.map(it => it.id)).toEqual(['a']) + expect(items).toEqual([{ id: 'u-a', owner: 'a', title: '甲', subtitle: 'a' }]) + }) + + it('shows invalid accounts as not selectable', () => { + const [it] = accountsToPickItems([{ userId: 'u-g', owner: 'gone', name: '离职', valid: false, joined: false }]) + expect(it).toMatchObject({ id: 'u-g', disabled: true, disabledReason: INVALID_REASON }) }) 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 }, + { userId: 'u-a', owner: 'a', name: '甲', valid: true, joined: true }, + { userId: 'u-b', owner: 'b', valid: true, joined: false }, + { userId: 'u-c', 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' }, + { id: 'u-a', owner: 'a', title: '甲', subtitle: 'a', disabled: true, disabledReason: JOINED_REASON }, + { id: 'u-b', owner: 'b', title: 'b', subtitle: 'b', disabled: true, disabledReason: '已加入项目' }, + { id: 'u-c', owner: '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 }]) + const [it] = accountsToPickItems([{ userId: 'u-z', owner: 'zhangsan', name: '张三', valid: true, joined: false }]) expect(it.title).toBe('张三') expect(it.subtitle).toBe('zhangsan') })