Files
knowledge-governance/web/src/qaWeeklyDrafts.ts
T
姚章浩 87f97a2f9b
build / build (push) Successful in 1m7s
feat(qaops): 完成详情下拉与每周自动保存草稿
详情使用共享主表字典,稀疏修改所选字段并保留原始问答及旧复核审计。
月周统计提供文本和下拉双入口;每租户每周一份持久草稿,按技能六节双语模板真实预填。
加入幂等自动保存、冲突保留、未知响应重试、切周及离开保护和schema58非破坏降级保护。

验证:完整make verify、隔离PostgreSQL全mirror竞态回归、真实恢复库浏览器验收通过。
浏览器实测字段精确读回、月周双输入、模板预填、MD/CSV、自动保存、冲突、失败重试与API重启恢复。
未连接或改写DobotAI生产源库,未运行付费模型;提交时尚未部署。
2026-10-20 05:44:02 +08:00

230 lines
7.0 KiB
TypeScript

import { newOperationID } from './operationKey'
export interface WeeklyDocument {
week: string
revision: number
markdown: string
seed: Record<string, unknown>
updated_at: string | null
}
export interface WeeklyWriteRequest {
base_revision: number
nonce: string
markdown: string
}
export interface WeeklyPrefillRequest {
base_revision: number
nonce: string
}
export interface WeeklyTransport {
read: (week: string) => Promise<WeeklyDocument>
write: (week: string, q: WeeklyWriteRequest) => Promise<WeeklyDocument>
prefill: (week: string, q: WeeklyPrefillRequest) => Promise<WeeklyDocument>
}
export interface WeeklyEditorState {
week: string
document: WeeklyDocument | null
text: string
loading: boolean
busy: boolean
saving: boolean
error: string
errorKind: '' | 'load' | 'save' | 'conflict' | 'prefill'
}
const errorText = (e: unknown) => (e instanceof Error ? e.message : '草稿操作失败,请保留文字并重试')
const conflict = (e: unknown) => !!e && typeof e === 'object' && 'status' in e && e.status === 409
export class WeeklyDraftEditorModel {
private snapshot: WeeklyEditorState
private listeners = new Set<() => void>()
private timer: ReturnType<typeof setTimeout> | null = null
private pending: WeeklyWriteRequest | null = null
private prefillPending: WeeklyPrefillRequest | null = null
get uncertainPrefill() {
return !!this.prefillPending
}
private flight: Promise<boolean> | null = null
private generation = 0
private disposed = false
constructor(
week: string,
private transport: WeeklyTransport
) {
this.snapshot = {
week,
document: null,
text: '',
loading: false,
busy: false,
saving: false,
error: '',
errorKind: '',
}
}
get state() {
return this.snapshot
}
get dirty() {
return !!this.state.document && (this.state.text !== this.state.document.markdown || !!this.pending)
}
getSnapshot = () => this.snapshot
subscribe = (listener: () => void) => {
this.listeners.add(listener)
return () => {
this.listeners.delete(listener)
}
}
private update(change: Partial<WeeklyEditorState>) {
this.snapshot = { ...this.snapshot, ...change }
for (const listener of this.listeners) listener()
}
private stopTimer() {
if (this.timer !== null) {
clearTimeout(this.timer)
this.timer = null
}
}
private schedule() {
this.stopTimer()
if (this.disposed || !this.dirty || this.state.error || this.state.loading || this.state.busy || this.state.saving)
return
this.timer = setTimeout(() => {
this.timer = null
void this.saveOne()
}, 800)
}
async initialize() {
const generation = ++this.generation
this.disposed = false
this.update({ loading: true, error: '', errorKind: '' })
try {
const document = await this.transport.read(this.state.week)
if (generation !== this.generation) return
this.pending = null
this.update({ document, text: document.markdown })
} catch (e) {
if (generation === this.generation) this.update({ error: errorText(e), errorKind: 'load' })
} finally {
if (generation === this.generation) this.update({ loading: false })
}
}
setText(text: string) {
if (this.state.busy || this.state.loading || this.prefillPending || !this.state.document) return
this.update({ text })
this.schedule()
}
private async saveOne(): Promise<boolean> {
if (this.flight) return this.flight
if (this.state.errorKind === 'conflict') return false
const document = this.state.document
if (!document || !this.dirty) return true
if (!this.pending) {
try {
this.pending = { base_revision: document.revision, nonce: newOperationID(), markdown: this.state.text }
} catch (e) {
this.update({ error: errorText(e), errorKind: 'save' })
return false
}
}
const request = this.pending,
week = this.state.week
this.flight = Promise.resolve().then(async () => {
this.update({ saving: true, error: '', errorKind: '' })
try {
const fresh = await this.transport.write(week, request)
if (fresh.week !== week || fresh.markdown !== request.markdown || fresh.revision !== request.base_revision + 1)
throw Error('保存版本或正文不匹配,请重新读取核对')
this.pending = null
this.update({ document: fresh })
return true
} catch (e) {
this.update({ error: errorText(e), errorKind: conflict(e) ? 'conflict' : 'save' })
return false
} finally {
this.update({ saving: false })
this.flight = null
this.schedule()
}
})
return this.flight
}
async prefill() {
if (this.state.busy || this.state.loading || !this.state.document) return false
this.update({ busy: true })
this.stopTimer()
try {
if (!this.prefillPending) {
if (!(await this.flush())) return false
this.prefillPending = { base_revision: this.state.document!.revision, nonce: newOperationID() }
}
const request = this.prefillPending,
week = this.state.week
this.update({ error: '', errorKind: '' })
const document = await this.transport.prefill(week, request)
if (document.week !== week || document.revision !== request.base_revision + 1)
throw Error('预填版本无效,尚未确认保存')
this.prefillPending = null
this.pending = null
this.update({ document, text: document.markdown })
return true
} catch (e) {
if (conflict(e)) this.prefillPending = null
this.update({ error: errorText(e), errorKind: conflict(e) ? 'conflict' : 'prefill' })
return false
} finally {
this.update({ busy: false })
}
}
async beforeLeave() {
if (this.state.busy) return false
this.update({ busy: true })
try {
return await this.flush()
} finally {
this.update({ busy: false })
}
}
async switchWeek(week: string) {
if (week === this.state.week) return true
if (this.state.busy) return false
this.update({ busy: true })
try {
if (!(await this.flush())) return false
this.stopTimer()
this.pending = null
this.prefillPending = null
this.update({ week, document: null, text: '' })
await this.initialize()
return !this.state.error
} finally {
this.update({ busy: false })
}
}
async reload() {
if (this.state.busy) return false
this.update({ busy: true })
try {
this.stopTimer()
if (this.flight) await this.flight
this.pending = null
this.prefillPending = null
await this.initialize()
return !this.state.error
} finally {
this.update({ busy: false })
}
}
async flush() {
this.stopTimer()
if (this.prefillPending || this.state.errorKind === 'conflict') return false
do {
if (!(await this.saveOne())) return false
} while (this.dirty)
return true
}
dispose() {
this.disposed = true
this.generation++
this.stopTimer()
this.listeners.clear()
}
}