agent-exec transport 4a9e9a1c45e7ac50517ea954d95026bb/16e6bae28677
This commit is contained in:
@@ -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}
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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 } : {}),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
@@ -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')
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user