agent-exec transport 4a9e9a1c45e7ac50517ea954d95026bb/16e6bae28677

This commit is contained in:
agent-exec-transport
2026-10-18 16:51:49 +00:00
parent d47c49350b
commit 72c941c772
8 changed files with 382 additions and 76 deletions
+31 -18
View File
@@ -2,7 +2,7 @@
// 门控集中在 src/rdmManagement.ts;分区可见性由 ProjectSettingsModal 按 store 的 // 门控集中在 src/rdmManagement.ts;分区可见性由 ProjectSettingsModal 按 store 的
// rdm*Available 门控(命令面不齐不渲染,普通基座零暴露)。样式只复用 // rdm*Available 门控(命令面不齐不渲染,普通基座零暴露)。样式只复用
// 既有 ad-*/lg-*/settings-* 类(design-guard 棘轮:不新增裸色值/字号)。 // 既有 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 { api } from '../api'
import type { Project } from '../contracts' import type { Project } from '../contracts'
import { projectWorkError } from '../projectmeta' import { projectWorkError } from '../projectmeta'
@@ -16,8 +16,9 @@ import {
// ---- 项目设置:成员(P7:可搜索人员选择器 + 专业角色/项目身份两列 + 显式身份调整)---- // ---- 项目设置:成员(P7:可搜索人员选择器 + 专业角色/项目身份两列 + 显式身份调整)----
import { import {
type MemberMatchMode, type PersonPickItem, type RdmAccount, type RdmMemberPage, type RdmProfRole, type MemberMatchMode, type PersonPickItem, type RdmAccountPage, type RdmMemberPage, type RdmProfRole,
MATCH_MODE_LABELS, accountListParams, accountsToPickItems, appendMemberPage, memberListParams, type SearchFieldsChoice,
MATCH_MODE_LABELS, SEARCH_FIELDS, accountCandidatePager, accountSearchKey, appendMemberPage, memberListParams,
personLabel, roleOptionsPayload, personLabel, roleOptionsPayload,
} from '../rdmManagement' } from '../rdmManagement'
@@ -46,6 +47,7 @@ export function RdmMembersSection({ project }: { project: Project }) {
const [pickedItems, setPickedItems] = useState<PersonPickItem[]>([]) const [pickedItems, setPickedItems] = useState<PersonPickItem[]>([])
const [candRoleId, setCandRoleId] = useState('') const [candRoleId, setCandRoleId] = useState('')
const [candMatch, setCandMatch] = useState<MemberMatchMode>('fuzzy') const [candMatch, setCandMatch] = useState<MemberMatchMode>('fuzzy')
const [candFields, setCandFields] = useState<SearchFieldsChoice>('both')
const [role, setRole] = useState<'member' | 'owner'>('member') const [role, setRole] = useState<'member' | 'owner'>('member')
// 成员表经 ref 参与取数(已加入标记),保持 fetchPage 身份稳定(Picker 依赖)。 // 成员表经 ref 参与取数(已加入标记),保持 fetchPage 身份稳定(Picker 依赖)。
const rowsRef = useRef<MemberRow[] | null>(null) const rowsRef = useRef<MemberRow[] | null>(null)
@@ -56,7 +58,7 @@ export function RdmMembersSection({ project }: { project: Project }) {
useEffect(() => { useEffect(() => {
setPicked([]); setPickedItems([]); knownPicks.current.clear() setPicked([]); setPickedItems([]); knownPicks.current.clear()
setCandRoleId(''); setCandMatch('fuzzy'); setMemberQuery(''); setMemberRoleId('') setCandRoleId(''); setCandMatch('fuzzy'); setCandFields('both'); setMemberQuery(''); setMemberRoleId('')
setRoleOptions(null) setRoleOptions(null)
let live = true let live = true
cmd<{ roles?: RdmProfRole[] }>('rdm.role.options', roleOptionsPayload(projectId)) cmd<{ roles?: RdmProfRole[] }>('rdm.role.options', roleOptionsPayload(projectId))
@@ -65,14 +67,19 @@ export function RdmMembersSection({ project }: { project: Project }) {
return () => { live = false } return () => { live = false }
}, [project]) }, [project])
/** 人员选择器取数适配:rdm.account.list 服务端按 query/roleId/match 先过滤后分页。 */ const candOptions = useMemo(() => ({ mode: candMatch, searchFields: SEARCH_FIELDS[candFields].fields, roleId: candRoleId }),
const fetchPage = useCallback(async (query: string, cursor: string, limit: number) => { [candMatch, candFields, candRoleId])
const out = await cmd<{ accounts?: RdmAccount[]; next?: string }>('rdm.account.list',
accountListParams(projectId, { query, cursor, limit, roleId: candRoleId, match: candMatch })) /** 人员选择器取数适配:rdm.account.list 服务端按 query/mode/searchFields/roleId 先过滤后分页。 */
const items = accountsToPickItems(out.accounts, (rowsRef.current ?? []).map(m => m.owner)) const fetchPage = useMemo(() => {
for (const it of items) knownPicks.current.set(it.id, it) const page = accountCandidatePager(params => cmd<RdmAccountPage>('rdm.account.list', params), projectId,
return { items, nextCursor: out.next || undefined } { ...candOptions, joinedOwners: () => (rowsRef.current ?? []).map(m => m.owner) })
}, [project, candRoleId, candMatch]) 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 时先清空表格);非空=追加下一页。 */ /** cursor 空=重载首页(clear 时先清空表格);非空=追加下一页。 */
const loadMembers = useCallback(async (cursor: string, clear: boolean) => { const loadMembers = useCallback(async (cursor: string, clear: boolean) => {
@@ -120,7 +127,7 @@ export function RdmMembersSection({ project }: { project: Project }) {
const pickedId = picked[0] ?? '' const pickedId = picked[0] ?? ''
const pickedItem = pickedItems.find(it => it.id === pickedId) 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 pickerDisabled = busy || !!writeError
const filtering = memberQuery.trim() !== '' || memberRoleId !== '' const filtering = memberQuery.trim() !== '' || memberRoleId !== ''
@@ -143,6 +150,11 @@ export function RdmMembersSection({ project }: { project: Project }) {
{(Object.keys(MATCH_MODE_LABELS) as MemberMatchMode[]).map(m => {(Object.keys(MATCH_MODE_LABELS) as MemberMatchMode[]).map(m =>
<option key={m} value={m}>{MATCH_MODE_LABELS[m]}</option>)} <option key={m} value={m}>{MATCH_MODE_LABELS[m]}</option>)}
</select> </select>
<select className="lg-input" aria-label="检索字段" value={candFields}
onChange={e => setCandFields(e.target.value as SearchFieldsChoice)}>
{(Object.keys(SEARCH_FIELDS) as SearchFieldsChoice[]).map(f =>
<option key={f} value={f}>{SEARCH_FIELDS[f].label}</option>)}
</select>
</div> </div>
<div className="ad-row"> <div className="ad-row">
<Picker <Picker
@@ -151,21 +163,22 @@ export function RdmMembersSection({ project }: { project: Project }) {
onSelect={onPick} onSelect={onPick}
selectedItems={pickedItems} selectedItems={pickedItems}
fetchPage={fetchPage} fetchPage={fetchPage}
reloadKey={`${projectId}|${candRoleId}|${candMatch}`} reloadKey={accountSearchKey(projectId, candOptions)}
disabled={pickerDisabled} disabled={pickerDisabled}
placeholder="输入账号或姓名,或浏览候选…" placeholder={candMatch === 'fuzzy' ? '输入至少两个字符搜索账号或姓名' : '输入完整账号或姓名精确搜索'}
emptyText="输入后显示匹配账号"
/> />
<select className="lg-input" value={role} onChange={e => setRole(e.target.value as 'member' | 'owner')}> <select className="lg-input" value={role} onChange={e => setRole(e.target.value as 'member' | 'owner')}>
<option value="member">项目成员</option> <option value="member">项目成员</option>
<option value="owner">项目负责人</option> <option value="owner">项目负责人</option>
</select> </select>
<button className="lg-submit ad-create-btn" disabled={pickerDisabled || !pickedValid} <button className="lg-submit ad-create-btn" disabled={pickerDisabled || !pickedValid}
onClick={() => run(`已添加 ${pickedItem ? personLabel({ owner: pickedId, name: pickedItem.title }) : pickedId}`, async () => { onClick={() => pickedItem && run(`已添加 ${personLabel({ owner: pickedItem.owner, name: pickedItem.title })}`, async () => {
await cmd('rdm.project.member.add', memberAddPayload(projectId, pickedId, role)) await cmd('rdm.project.member.add', memberAddPayload(projectId, pickedItem.owner, role, pickedItem.id))
setPicked([]); setPickedItems([]) setPicked([]); setPickedItems([])
})}>添加成员</button> })}>添加成员</button>
</div> </div>
<p className="ad-hint">候选来自系统账号目录(姓名为空时显示账号),无效账号不提供;已加入项目的账号显示为不可选——调整身份请在成员表中操作。最后一位负责人不可移除/降级。</p> <p className="ad-hint">候选来自系统账号目录(输入后检索,姓名为空时显示账号);无效账号与已加入项目的账号显示为不可选——调整身份请在成员表中操作。最后一位负责人不可移除/降级。</p>
</div> </div>
<div className="ad-row"> <div className="ad-row">
<input className="lg-input" aria-label="搜索成员" placeholder="搜索成员账号或姓名" value={memberQuery} <input className="lg-input" aria-label="搜索成员" placeholder="搜索成员账号或姓名" value={memberQuery}
+32 -5
View File
@@ -1,14 +1,17 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { api } from '../../api' import { api } from '../../api'
import type { Project } from '../../contracts' import type { Project } from '../../contracts'
import { projectWorkError } from '../../projectmeta' import { projectWorkError } from '../../projectmeta'
import { Btn, Collapsible, EmptyState, Icon } from '@ui' import { Btn, Collapsible, EmptyState, Icon } from '@ui'
import { dutyMatrixPayload, dutyPreviewPayload, dutyLegacyPayload } from '../../rdmManagement' import {
SEARCH_FIELDS, accountCandidatePager, accountSearchKey, dutyMatrixPayload, dutyPreviewPayload, dutyLegacyPayload,
type DutyAccountIds, type MemberMatchMode, type PersonPickItem, type RdmAccountPage, type SearchFieldsChoice,
} from '../../rdmManagement'
import { import {
absorbServerDocs, dirtyDocs, editCell, savePayload, absorbServerDocs, dirtyDocs, editCell, savePayload,
type CellDuty, type DutyDocDraft, type DutyMember, type CellDuty, type DutyDocDraft, type DutyMember,
} from '../../duties/draft' } from '../../duties/draft'
import { DutyByDocumentView, DutyByMemberView, DutyChangePreview, DutyMatrixView } from '../../duties/views' import { DutyByDocumentView, DutyByMemberView, DutyCandidateSearch, DutyChangePreview, DutyMatrixView } from '../../duties/views'
type ViewName = 'member' | 'document' | 'matrix' type ViewName = 'member' | 'document' | 'matrix'
@@ -68,6 +71,9 @@ export function RdmDutiesSection({ project }: { project: Project }) {
const [confirm, setConfirm] = useState(false) const [confirm, setConfirm] = useState(false)
const [legacy, setLegacy] = useState<LegacyOut['findings']>([]) const [legacy, setLegacy] = useState<LegacyOut['findings']>([])
const [legacyOpen, setLegacyOpen] = useState(false) const [legacyOpen, setLegacyOpen] = useState(false)
const [candMode, setCandMode] = useState<MemberMatchMode>('fuzzy')
const [candFields, setCandFields] = useState<SearchFieldsChoice>('both')
const [accountIds, setAccountIds] = useState<DutyAccountIds>({})
const seq = useRef(0) const seq = useRef(0)
const op = useRef(0) const op = useRef(0)
const docsRef = useRef(docs) const docsRef = useRef(docs)
@@ -117,12 +123,25 @@ export function RdmDutiesSection({ project }: { project: Project }) {
return () => { seq.current += 1 } return () => { seq.current += 1 }
}, [project, query, stage, typeKey, assignment, docCursor]) }, [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<RdmAccountPage>(project, 'rdm.account.list', params), projectId, candOptions,
), [project, candOptions])
const onCell = (id: string, owner: string, duty: CellDuty) => { const onCell = (id: string, owner: string, duty: CellDuty) => {
setPreview([]) setPreview([])
setConfirm(false) setConfirm(false)
setDocs(current => editCell(current, id, owner, duty)) setDocs(current => editCell(current, id, owner, duty))
} }
const onCandidate = (id: string, candidate: PersonPickItem, duty: Exclude<CellDuty, 'none'>) => {
setAccountIds(current => ({ ...current, [candidate.owner]: candidate.id }))
onCell(id, candidate.owner, duty)
}
const visibleDocs = docs.filter(doc => { const visibleDocs = docs.filter(doc => {
const text = `${doc.title} ${doc.code} ${doc.docId}`.toLowerCase() const text = `${doc.title} ${doc.code} ${doc.docId}`.toLowerCase()
if (query && !text.includes(query.trim().toLowerCase())) return false if (query && !text.includes(query.trim().toLowerCase())) return false
@@ -132,7 +151,7 @@ export function RdmDutiesSection({ project }: { project: Project }) {
}) })
const save = async () => { const save = async () => {
const payload = savePayload(docsRef.current, confirm) const payload = savePayload(docsRef.current, confirm, accountIds)
if (payload.issues.length > 0) { if (payload.issues.length > 0) {
setErr(payload.issues.join(';')) setErr(payload.issues.join(';'))
return return
@@ -154,7 +173,7 @@ export function RdmDutiesSection({ project }: { project: Project }) {
return return
} }
sent = true 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 if (request !== seq.current) return
setNotice('已保存文档职责') setNotice('已保存文档职责')
setPreview([]) setPreview([])
@@ -228,6 +247,14 @@ export function RdmDutiesSection({ project }: { project: Project }) {
</div> </div>
{view === 'member' && <DutyByMemberView member={member} members={members} docs={visibleDocs} disabled={disabled} onMember={setMember} onCell={onCell} />} {view === 'member' && <DutyByMemberView member={member} members={members} docs={visibleDocs} disabled={disabled} onMember={setMember} onCell={onCell} />}
{view === 'document' && <DutyByDocumentView docId={docId} docs={visibleDocs} members={members} disabled={disabled} onDoc={setDocId} onCell={onCell} />} {view === 'document' && <DutyByDocumentView docId={docId} docs={visibleDocs} members={members} disabled={disabled} onDoc={setDocId} onCell={onCell} />}
{view === 'document' && (() => {
const doc = docs.find(item => item.docId === docId)
return doc ? (
<DutyCandidateSearch doc={doc} mode={candMode} fields={candFields} fetchPage={fetchCandidates}
reloadKey={accountSearchKey(projectId, candOptions)} disabled={disabled}
onMode={setCandMode} onFields={setCandFields} onAssign={(candidate, duty) => onCandidate(doc.docId, candidate, duty)} />
) : null
})()}
{view === 'matrix' && <DutyMatrixView docs={visibleDocs} members={members} disabled={disabled} narrow={narrow} onCell={onCell} />} {view === 'matrix' && <DutyMatrixView docs={visibleDocs} members={members} disabled={disabled} narrow={narrow} onCell={onCell} />}
<div className="ad-row"> <div className="ad-row">
{docCursor && <button className="ad-mini" disabled={busy} onClick={() => setDocCursor('')}>文档首页</button>} {docCursor && <button className="ad-mini" disabled={busy} onClick={() => setDocCursor('')}>文档首页</button>}
+19 -9
View File
@@ -1,4 +1,6 @@
// 文档职责草稿的唯一存储。三个视图只投影这份数据;单元格值由三权反推。 // 文档职责草稿的唯一存储。三个视图只投影这份数据;单元格值由三权反推。
import { dutyAccountIds, type DutyAccountIds } from '../rdmManagement'
export type CellDuty = 'none' | 'author' | 'reviewer' | 'approver' export type CellDuty = 'none' | 'author' | 'reviewer' | 'approver'
interface DutyRoles { interface DutyRoles {
@@ -35,6 +37,7 @@ interface DutySaveItem {
approver: string approver: string
expectedDutyVersion: number expectedDutyVersion: number
confirmInvalidation: boolean confirmInvalidation: boolean
accountIds?: DutyAccountIds
} }
export function cellDuty(roles: DutyRoles, owner: string): CellDuty { export function cellDuty(roles: DutyRoles, owner: string): CellDuty {
@@ -174,7 +177,10 @@ export function dirtyDocs(docs: DutyDocDraft[]): DutyDocDraft[] {
return docs.filter(doc => doc.dirty) 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 dirty = dirtyDocs(docs)
const issues: string[] = [] const issues: string[] = []
if (dirty.length === 0) issues.push('没有未保存的职责') 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}`) for (const issue of saveIssues(doc)) issues.push(`${doc.title}:${issue}`)
} }
return { return {
docs: dirty.slice(0, 20).map(doc => ({ docs: dirty.slice(0, 20).map(doc => {
docId: doc.docId, const ids = dutyAccountIds(doc, accountIds)
author: doc.author, return {
reviewers: doc.reviewers, docId: doc.docId,
approver: doc.approver, author: doc.author,
expectedDutyVersion: doc.dutyVersion, reviewers: doc.reviewers,
confirmInvalidation: confirmInvalidation && doc.activeRound > 0, approver: doc.approver,
})), expectedDutyVersion: doc.dutyVersion,
confirmInvalidation: confirmInvalidation && doc.activeRound > 0,
...(ids ? { accountIds: ids } : {}),
}
}),
issues, issues,
} }
} }
+67
View File
@@ -1,5 +1,11 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { Picker } from '@ui'
import type { CellDuty, DutyDocDraft, DutyMember } from './draft' import type { CellDuty, DutyDocDraft, DutyMember } from './draft'
import { cellDuty, completeness, replacementNote, saveIssues } 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 }[] = [ const DUTY_OPTIONS: { value: CellDuty; label: string }[] = [
{ value: 'none', label: '无职责' }, { value: 'none', label: '无职责' },
@@ -120,6 +126,67 @@ export function DutyByDocumentView({
) )
} }
type AssignedDuty = Exclude<CellDuty, 'none'>
/** 文档职责候选检索: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<PersonPickItem | null>(null)
const [duty, setDuty] = useState<AssignedDuty>('reviewer')
const known = useRef(new Map<string, PersonPickItem>())
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 (
<div className="settings-group">
<h4>搜索候选人</h4>
<div className="ad-row">
<select className="lg-input" aria-label="候选匹配方式" value={mode} onChange={event => onMode(event.target.value as MemberMatchMode)}>
{(Object.keys(MATCH_MODE_LABELS) as MemberMatchMode[]).map(m => <option key={m} value={m}>{MATCH_MODE_LABELS[m]}</option>)}
</select>
<select className="lg-input" aria-label="候选检索字段" value={fields} onChange={event => onFields(event.target.value as SearchFieldsChoice)}>
{(Object.keys(SEARCH_FIELDS) as SearchFieldsChoice[]).map(f => <option key={f} value={f}>{SEARCH_FIELDS[f].label}</option>)}
</select>
</div>
<div className="ad-row">
<Picker
aria-label="职责候选人"
selected={picked ? [picked.id] : []}
selectedItems={picked ? [picked] : []}
onSelect={ids => setPicked(known.current.get(ids[0] ?? '') ?? null)}
fetchPage={fetchKnown}
reloadKey={reloadKey}
disabled={disabled}
placeholder={mode === 'fuzzy' ? '输入至少两个字符搜索账号或姓名' : '输入完整账号或姓名精确搜索'}
emptyText="输入后显示当前有效成员"
/>
<select className="lg-input" aria-label="候选人职责" value={duty} onChange={event => setDuty(event.target.value as AssignedDuty)}>
{DUTY_OPTIONS.filter(option => option.value !== 'none').map(option => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
<button className="ad-mini" disabled={disabled || !picked || picked.disabled}
onClick={() => { if (picked) { onAssign(picked, duty); setPicked(null) } }}>
{picked ? `指派到 ${doc.title}` : '指派'}
</button>
</div>
{picked && <p className="ad-hint">{picked.title} · {picked.owner}{replacementNote(doc, picked.owner, duty) ? `(${replacementNote(doc, picked.owner, duty)})` : ''}</p>}
</div>
)
}
export function DutyMatrixView({ export function DutyMatrixView({
docs, members, disabled, narrow, onCell, docs, members, disabled, narrow, onCell,
}: { }: {
+104 -22
View File
@@ -102,19 +102,39 @@ export const roleActionPayload = (roleId: string, action: string, def: boolean,
export const roleAssignPayload = (owner: string, roleId: string) => ({ owner, roleId }) export const roleAssignPayload = (owner: string, roleId: string) => ({ owner, roleId })
export const roleDisablePayload = (roleId: string, disabled: boolean) => ({ roleId, disabled }) export const roleDisablePayload = (roleId: string, disabled: boolean) => ({ roleId, disabled })
export const memberAddPayload = (projectId: string, member: string, role: 'owner' | 'member' = 'member') => /** userId 为候选检索选中的稳定账号;服务端按 member 精确查找后比对,不一致即拒。 */
({ projectId, member, role }) 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 memberRemovePayload = (projectId: string, member: string) => ({ projectId, member })
export const memberSetRolePayload = (projectId: string, member: string, role: 'owner' | 'member') => ({ projectId, member, role }) export const memberSetRolePayload = (projectId: string, member: string, role: 'owner' | 'member') => ({ projectId, member, role })
/** 职责账号身份:owner → 候选检索选中的 userId。 */
export type DutyAccountIds = Record<string, string>
/** 只保留本条职责实际涉及的人;为空不携带。 */
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 = ( export const assignDocDutiesPayload = (
projectId: string, docId: string, author: string, reviewers: string[], approver: string, projectId: string, docId: string, author: string, reviewers: string[], approver: string,
extra?: { expectedDutyVersion?: number; confirmInvalidation?: boolean }, extra?: { expectedDutyVersion?: number; confirmInvalidation?: boolean; accountIds?: DutyAccountIds },
) => ({ ) => {
projectId, docId, author, reviewers, approver, const accountIds = dutyAccountIds({ author, reviewers, approver }, extra?.accountIds)
...(extra?.expectedDutyVersion !== undefined ? { expectedDutyVersion: extra.expectedDutyVersion } : {}), return {
...(extra?.confirmInvalidation !== undefined ? { confirmInvalidation: extra.confirmInvalidation } : {}), projectId, docId, author, reviewers, approver,
}) ...(extra?.expectedDutyVersion !== undefined ? { expectedDutyVersion: extra.expectedDutyVersion } : {}),
...(extra?.confirmInvalidation !== undefined ? { confirmInvalidation: extra.confirmInvalidation } : {}),
...(accountIds ? { accountIds } : {}),
}
}
export const dutyMatrixPayload = ( export const dutyMatrixPayload = (
projectId: string, projectId: string,
@@ -136,7 +156,10 @@ export const dutyMatrixPayload = (
export const dutyPreviewPayload = ( export const dutyPreviewPayload = (
projectId: string, 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 }) ) => ({ projectId, docs })
export const dutyLegacyPayload = (projectId: string) => ({ projectId }) export const dutyLegacyPayload = (projectId: string) => ({ projectId })
@@ -172,12 +195,16 @@ export const commandRequestBody = (params: Record<string, unknown>, source?: Com
// ---- 项目成员页:候选检索 + 成员表检索(R2 契约:服务端先过滤后分页) ---- // ---- 项目成员页:候选检索 + 成员表检索(R2 契约:服务端先过滤后分页) ----
export type MemberMatchMode = 'fuzzy' | 'exact' export type MemberMatchMode = 'fuzzy' | 'exact'
type AccountSearchField = 'owner' | 'name'
export interface RdmProfRole { roleId: string; name: string } export interface RdmProfRole { roleId: string; name: string }
/** rdm.account.list 单条候选。 */ /** rdm.account.list 单条候选。 */
export interface RdmAccount { 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 projectRole?: string; profRole?: RdmProfRole | null
} }
export interface RdmAccountPage {
accounts?: RdmAccount[]; next?: string; directoryVersion?: string; count?: number
}
/** rdm.project.member.list 单条成员。 */ /** rdm.project.member.list 单条成员。 */
export interface RdmMemberRow { export interface RdmMemberRow {
owner: string; name?: string; role: string; addedBy: string; addedAt: string owner: string; name?: string; role: string; addedBy: string; addedAt: string
@@ -186,32 +213,78 @@ export interface RdmMemberRow {
export interface RdmMemberPage { export interface RdmMemberPage {
members: RdmMemberRow[]; next?: string; total?: number; accountDirectoryAvailable?: boolean members: RdmMemberRow[]; next?: string; total?: number; accountDirectoryAvailable?: boolean
} }
/** 与 @ui PickItem 结构兼容(纯逻辑层不依赖组件库)。 */ /** 与 @ui PickItem 结构兼容(纯逻辑层不依赖组件库);id 为稳定 userId,owner 为提交用账号。 */
export interface PersonPickItem { 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 export const MEMBER_PAGE_LIMIT = 50
const ACCOUNT_PAGE_LIMIT = 50
export const JOINED_REASON = '已加入项目' export const JOINED_REASON = '已加入项目'
export const INVALID_REASON = '账号无效'
export const NOT_MEMBER_REASON = '非当前项目成员'
export const MATCH_MODE_LABELS: Record<MemberMatchMode, string> = { export const MATCH_MODE_LABELS: Record<MemberMatchMode, string> = {
fuzzy: '模糊匹配', fuzzy: '模糊匹配',
exact: '精确账号或姓名', exact: '精确账号或姓名',
} }
export type SearchFieldsChoice = 'both' | 'owner' | 'name'
export const SEARCH_FIELDS: Record<SearchFieldsChoice, { label: string; fields: AccountSearchField[] }> = {
both: { label: '账号和姓名', fields: ['owner', 'name'] },
owner: { label: '仅账号', fields: ['owner'] },
name: { label: '仅姓名', fields: ['name'] },
}
export const roleOptionsPayload = (projectId: string) => ({ projectId, purpose: 'members' as const }) export const roleOptionsPayload = (projectId: string) => ({ projectId, purpose: 'members' as const })
/** 空串筛选不携带(缺省=不过滤);match 恒携带,服务端据此选择匹配语义。 */ interface AccountSearchOptions {
export function accountListParams(projectId: string, opts: { mode?: MemberMatchMode
query?: string; cursor?: string; limit: number; roleId?: string; match?: 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() ?? '' const query = opts.query?.trim() ?? ''
return { return {
projectId, projectId,
limit: opts.limit, limit: Math.min(Math.max(1, opts.limit), ACCOUNT_PAGE_LIMIT),
match: opts.match ?? 'fuzzy', mode: opts.mode ?? 'fuzzy',
...(opts.searchFields && opts.searchFields.length > 0 ? { searchFields: [...opts.searchFields] } : {}),
...(query ? { query } : {}), ...(query ? { query } : {}),
...(opts.cursor ? { cursor: opts.cursor } : {}), ...(opts.cursor ? { cursor: opts.cursor } : {}),
...(opts.roleId ? { roleId: opts.roleId } : {}), ...(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<typeof accountListParams>) => Promise<RdmAccountPage>,
projectId: string,
opts: AccountSearchOptions & { joinedOwners?: () => Iterable<string> },
) {
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 return name && name !== p.owner ? `${name} · ${p.owner}` : p.owner
} }
/** 候选映射:无效账号不提供;已加入(服务端 joined 或已在成员表)可见但不可选。 */ /**
export function accountsToPickItems(accounts: RdmAccount[] | undefined, joinedOwners: Iterable<string> = []): PersonPickItem[] { * 候选映射(id=userId;无 userId 的行无法稳定提交,不提供)。无效账号可见但不可选;
* member:已加入(服务端 joined 或已在成员表)不可选;duty:只有当前成员可选。
*/
export function accountsToPickItems(
accounts: RdmAccount[] | undefined, joinedOwners: Iterable<string> = [], purpose: 'member' | 'duty' = 'member',
): PersonPickItem[] {
const joined = new Set(joinedOwners) 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 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 { return {
id: a.owner, id: a.userId,
owner: a.owner,
title: a.name?.trim() || a.owner, title: a.name?.trim() || a.owner,
subtitle: a.owner, subtitle: a.owner,
...(isJoined ? { disabled: true, disabledReason: JOINED_REASON } : {}), ...(reason ? { disabled: true, disabledReason: reason } : {}),
} }
}) })
} }
+11
View File
@@ -69,5 +69,16 @@ describe('文档职责草稿', () => {
docId: 'd1', author: 'alice', reviewers: ['bob'], approver: 'carol', docId: 'd1', author: 'alice', reviewers: ['bob'], approver: 'carol',
expectedDutyVersion: 3, confirmInvalidation: true, 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')
}) })
}) })
+15
View File
@@ -59,6 +59,21 @@ describe('命令载荷构造(身份只经认证会话——载荷不含任何
expect(memberAddPayload('p1', 'carol', 'owner')).toEqual({ projectId: 'p1', member: 'carol', role: 'owner' }) expect(memberAddPayload('p1', 'carol', 'owner')).toEqual({ projectId: 'p1', member: 'carol', role: 'owner' })
expect(memberRemovePayload('p1', 'carol')).toEqual({ projectId: 'p1', member: 'carol' }) 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('文档职责与整文审签载荷', () => { it('文档职责与整文审签载荷', () => {
expect(assignDocDutiesPayload('p1', 'd1', 'alice', ['bob', 'carol'], 'dave')) expect(assignDocDutiesPayload('p1', 'd1', 'alice', ['bob', 'carol'], 'dave'))
.toEqual({ projectId: 'p1', docId: 'd1', author: 'alice', reviewers: ['bob', 'carol'], approver: 'dave' }) .toEqual({ projectId: 'p1', docId: 'd1', author: 'alice', reviewers: ['bob', 'carol'], approver: 'dave' })
+103 -22
View File
@@ -3,28 +3,104 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import type { Project } from '../src/contracts' import type { Project } from '../src/contracts'
import { import {
JOINED_REASON, MATCH_MODE_LABELS, MEMBER_PAGE_LIMIT, RDM_MEMBER_COMMANDS, INVALID_REASON, JOINED_REASON, MATCH_MODE_LABELS, MEMBER_PAGE_LIMIT, NOT_MEMBER_REASON, RDM_MEMBER_COMMANDS,
accountListParams, accountsToPickItems, appendMemberPage, memberListParams, SEARCH_FIELDS, accountCandidatePager, accountListParams, accountSearchKey, accountsToPickItems, appendMemberPage,
personLabel, rdmMembersReady, rdmProjectCommandError, roleOptionsPayload, memberListParams, personLabel, rdmMembersReady, rdmProjectCommandError, roleOptionsPayload,
type RdmMemberRow, type RdmAccountPage, type RdmMemberRow,
} from '../src/rdmManagement' } from '../src/rdmManagement'
const row = (owner: string, name?: string): RdmMemberRow => const row = (owner: string, name?: string): RdmMemberRow =>
({ owner, name, role: 'member', addedBy: 'admin', addedAt: '' }) ({ owner, name, role: 'member', addedBy: 'admin', addedAt: '' })
describe('accountListParams', () => { describe('accountListParams', () => {
it('passes roleId, match and cursor to rdm.account.list', () => { it('passes mode, searchFields, roleId and cursor to rdm.account.list', () => {
expect(accountListParams('p1', { query: ' zhang ', cursor: 'c2', limit: 20, roleId: 'r-eng', match: 'exact' })) expect(accountListParams('p1', {
.toEqual({ projectId: 'p1', query: 'zhang', cursor: 'c2', limit: 20, roleId: 'r-eng', match: 'exact' }) 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', () => { it('passes docId for document duty candidates', () => {
expect(accountListParams('p1', { query: ' ', cursor: '', limit: 20, roleId: '' })) expect(accountListParams('p1', { query: 'li', limit: 20, docId: 'd1', searchFields: ['owner', 'name'] }))
.toEqual({ projectId: 'p1', limit: 20, match: 'fuzzy' }) .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(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<string, unknown>[] = []
const run = async (params: Record<string, unknown>) => { 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', () => { describe('accountsToPickItems', () => {
it('drops invalid accounts', () => { it('keys candidates by stable userId and drops rows without one', () => {
const items = accountsToPickItems([ const items = accountsToPickItems([
{ owner: 'a', name: '甲', valid: true, joined: false }, { userId: 'u-a', owner: 'a', name: '甲', valid: true, joined: false },
{ owner: 'gone', name: '离职', valid: false, 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', () => { it('disables joined candidates with the 已加入项目 reason instead of a badge', () => {
const items = accountsToPickItems([ const items = accountsToPickItems([
{ owner: 'a', name: '甲', valid: true, joined: true }, { userId: 'u-a', owner: 'a', name: '甲', valid: true, joined: true },
{ owner: 'b', valid: true, joined: false }, { userId: 'u-b', owner: 'b', valid: true, joined: false },
{ owner: 'c', name: '丙', valid: true }, { userId: 'u-c', owner: 'c', name: '丙', valid: true },
], ['b']) ], ['b'])
expect(items).toEqual([ expect(items).toEqual([
{ id: 'a', title: '甲', subtitle: 'a', disabled: true, disabledReason: JOINED_REASON }, { id: 'u-a', owner: 'a', title: '甲', subtitle: 'a', disabled: true, disabledReason: JOINED_REASON },
{ id: 'b', title: 'b', subtitle: 'b', disabled: true, disabledReason: '已加入项目' }, { id: 'u-b', owner: 'b', title: 'b', subtitle: 'b', disabled: true, disabledReason: '已加入项目' },
{ id: 'c', title: '丙', subtitle: 'c' }, { id: 'u-c', owner: 'c', title: '丙', subtitle: 'c' },
]) ])
expect(items.some(it => 'badge' in it)).toBe(false) expect(items.some(it => 'badge' in it)).toBe(false)
}) })
it('keeps name as title and account as subtitle so the selection shows both', () => { 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.title).toBe('张三')
expect(it.subtitle).toBe('zhangsan') expect(it.subtitle).toBe('zhangsan')
}) })