Adopt the server duty draft when a save result is unknown

Why: a lost write response was keeping the old duty version, so the next save replayed it, and a busy save could stay locked if another request advanced the sequence.
Evidence: SELF vitest duty-draft 6 passed, tsc passed, knip 43, Playwright duty flows 3 passed.
Impact: an unknown save reloads server roles, confirmation resets, and busy clears even if a later read is discarded.
Refs: docs/proposals/2026-10-15-rdm-duty-matrix-rework.md
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-15 09:06:24 +00:00
co-authored by Cursor
parent 6093beb27e
commit 985fc0f931
3 changed files with 39 additions and 14 deletions
+24 -12
View File
@@ -69,10 +69,11 @@ export function RdmDutiesSection({ project }: { project: Project }) {
const [legacy, setLegacy] = useState<LegacyOut['findings']>([])
const [legacyOpen, setLegacyOpen] = useState(false)
const seq = useRef(0)
const op = useRef(0)
const docsRef = useRef(docs)
docsRef.current = docs
const load = async (cursor: string, request = ++seq.current) => {
const load = async (cursor: string, request = ++seq.current, replaceDirty = false) => {
const seenMembers: DutyMember[] = []
const seenCells: MatrixOut['cells'] = []
let pageDocs: MatrixDoc[] = []
@@ -96,7 +97,7 @@ export function RdmDutiesSection({ project }: { project: Project }) {
setMembers(seenMembers)
setCanAssign(assignable)
setNextDoc(nextDocument)
setDocs(current => absorbServerDocs(current, pageDocs, seenCells))
setDocs(current => absorbServerDocs(current, pageDocs, seenCells, replaceDirty))
} catch (error) {
if (request !== seq.current) return
setErr(String(error))
@@ -118,6 +119,7 @@ export function RdmDutiesSection({ project }: { project: Project }) {
const onCell = (id: string, owner: string, duty: CellDuty) => {
setPreview([])
setConfirm(false)
setDocs(current => editCell(current, id, owner, duty))
}
@@ -136,7 +138,9 @@ export function RdmDutiesSection({ project }: { project: Project }) {
return
}
const request = ++seq.current
const ticket = ++op.current
setBusy(true); setErr(''); setNotice('')
let sent = false
try {
const looked = await api.rdmProjectCommand<PreviewOut>(project, 'rdm.duty.preview', dutyPreviewPayload(projectId, payload.docs))
if (request !== seq.current) return
@@ -149,18 +153,25 @@ export function RdmDutiesSection({ project }: { project: Project }) {
setErr('在途审签将失效。确认后再保存。')
return
}
sent = true
await api.rdmProjectCommand(project, 'rdm.review.assign.doc.batch', dutyPreviewPayload(projectId, savePayload(docsRef.current, confirm).docs))
if (request !== seq.current) return
setNotice('已保存文档职责')
setPreview([])
setConfirm(false)
setDocs(current => current.map(doc => ({ ...doc, dirty: false })))
await load(docCursor, request)
} catch (error) {
if (request !== seq.current) return
if (!sent) {
setErr(String(error))
return
}
setErr(`保存结果未确认:${error}`)
await load(docCursor, request)
setConfirm(false)
await load(docCursor, request, true)
} finally {
if (request === seq.current) setBusy(false)
if (ticket === op.current) setBusy(false)
}
}
@@ -177,6 +188,7 @@ export function RdmDutiesSection({ project }: { project: Project }) {
const applyIdentical = async () => {
const request = ++seq.current
const ticket = ++op.current
setBusy(true)
try {
await api.rdmProjectCommand(project, 'rdm.duty.legacy.apply', dutyLegacyPayload(projectId))
@@ -188,7 +200,7 @@ export function RdmDutiesSection({ project }: { project: Project }) {
if (request !== seq.current) return
setErr(String(error))
} finally {
if (request === seq.current) setBusy(false)
if (ticket === op.current) setBusy(false)
}
}
@@ -205,10 +217,10 @@ export function RdmDutiesSection({ project }: { project: Project }) {
<button className="ad-mini" role="tab" aria-selected={view === 'matrix'} onClick={() => setView('matrix')}>矩阵</button>
</div>
<div className="ad-row">
<input aria-label="筛选文档名称或编号" value={query} placeholder="名称或编号" onChange={event => { setDocCursor(''); setQuery(event.target.value) }} />
<input aria-label="筛选类型" value={typeKey} placeholder="类型" onChange={event => { setDocCursor(''); setTypeKey(event.target.value) }} />
<input aria-label="筛选阶段" value={stage} placeholder="阶段" onChange={event => { setDocCursor(''); setStage(event.target.value) }} />
<select aria-label="筛选当前职责" value={assignment} onChange={event => { setDocCursor(''); setAssignment(event.target.value) }}>
<input aria-label="筛选文档名称或编号" value={query} placeholder="名称或编号" disabled={busy} onChange={event => { setDocCursor(''); setQuery(event.target.value) }} />
<input aria-label="筛选类型" value={typeKey} placeholder="类型" disabled={busy} onChange={event => { setDocCursor(''); setTypeKey(event.target.value) }} />
<input aria-label="筛选阶段" value={stage} placeholder="阶段" disabled={busy} onChange={event => { setDocCursor(''); setStage(event.target.value) }} />
<select aria-label="筛选当前职责" value={assignment} disabled={busy} onChange={event => { setDocCursor(''); setAssignment(event.target.value) }}>
<option value="">全部</option>
<option value="assigned">已分配</option>
<option value="unassigned">未分配</option>
@@ -218,13 +230,13 @@ export function RdmDutiesSection({ project }: { project: Project }) {
{view === 'document' && <DutyByDocumentView docId={docId} docs={visibleDocs} members={members} disabled={disabled} onDoc={setDocId} onCell={onCell} />}
{view === 'matrix' && <DutyMatrixView docs={visibleDocs} members={members} disabled={disabled} narrow={narrow} onCell={onCell} />}
<div className="ad-row">
{docCursor && <button className="ad-mini" onClick={() => setDocCursor('')}>文档首页</button>}
{nextDoc && <button className="ad-mini" onClick={() => setDocCursor(nextDoc)}>下一页文档</button>}
{docCursor && <button className="ad-mini" disabled={busy} onClick={() => setDocCursor('')}>文档首页</button>}
{nextDoc && <button className="ad-mini" disabled={busy} onClick={() => setDocCursor(nextDoc)}>下一页文档</button>}
<Btn variant="primary" icon="check" disabled={disabled || dirtyDocs(docs).length === 0} onClick={() => { void save() }}>保存职责</Btn>
</div>
<DutyChangePreview impacts={preview} confirm={confirm} onConfirm={setConfirm} />
<Collapsible id="duty-legacy" open={legacyOpen} onToggle={setLegacyOpen} trigger="旧章节职责">
<button className="ad-mini" onClick={() => { void scanLegacy() }}>扫描</button>
<button className="ad-mini" disabled={busy} onClick={() => { void scanLegacy() }}>扫描</button>
<button className="ad-mini" disabled={disabled} onClick={() => { void applyIdentical() }}>归并无歧义覆盖</button>
{legacy.length === 0 ? <p className="ad-hint">尚未扫描,或没有章节级覆盖。</p> : (
<ul>
+3 -2
View File
@@ -132,6 +132,7 @@ export function absorbServerDocs(
dutyVersion: number; activeRound?: number; legacySectionOverrides?: number
}[],
cells: { docId: string; owner: string; duty: string }[],
replaceDirty = false,
): DutyDocDraft[] {
const byId = new Map(current.map(doc => [doc.docId, doc]))
const pageIds = new Set(docs.map(doc => doc.docId))
@@ -139,7 +140,7 @@ export function absorbServerDocs(
for (const doc of docs) {
const previous = byId.get(doc.docId)
const serverRoles = rolesFromCells(doc.docId, cells)
const keepDraft = previous?.dirty === true
const keepDraft = !replaceDirty && previous?.dirty === true
next.push({
docId: doc.docId,
title: doc.title,
@@ -156,7 +157,7 @@ export function absorbServerDocs(
})
}
for (const doc of current) {
if (doc.dirty && !pageIds.has(doc.docId)) next.push(doc)
if (!replaceDirty && doc.dirty && !pageIds.has(doc.docId)) next.push(doc)
}
return next
}