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:
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user