87f97a2f9b
build / build (push) Successful in 1m7s
详情使用共享主表字典,稀疏修改所选字段并保留原始问答及旧复核审计。 月周统计提供文本和下拉双入口;每租户每周一份持久草稿,按技能六节双语模板真实预填。 加入幂等自动保存、冲突保留、未知响应重试、切周及离开保护和schema58非破坏降级保护。 验证:完整make verify、隔离PostgreSQL全mirror竞态回归、真实恢复库浏览器验收通过。 浏览器实测字段精确读回、月周双输入、模板预填、MD/CSV、自动保存、冲突、失败重试与API重启恢复。 未连接或改写DobotAI生产源库,未运行付费模型;提交时尚未部署。
230 lines
7.0 KiB
TypeScript
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()
|
|
}
|
|
}
|