feat: add reviewed follow-up audio patient enrichment

This commit is contained in:
2026-09-29 15:44:30 +08:00
parent 3cdc812f2b
commit 70be2fc70f
46 changed files with 7384 additions and 246 deletions
@@ -0,0 +1,112 @@
import type { FollowupCapabilities, FollowupField, FollowupFields, FollowupReviewItem, FollowupValue } from '@/api/followupAudio'
export const FOLLOWUP_CHUNK_BYTES = 2097152
export const followupKindLabels = { diagnosis: '诊单资料', blood: '血糖 / 血压', diet: '饮食', exercise: '运动', tracking_note: '跟踪备注' }
export const followupStatusLabels: Record<string, string> = {
queued: '等待处理', running: '正在分析', review: '待人工审核', applied: '已采纳',
failed: '处理失败', needs_reconciliation: '结果待核对', expired: '已过保留期'
}
export const cloneReviewItems = (items: FollowupReviewItem[]): FollowupReviewItem[] => JSON.parse(JSON.stringify(items))
export function fieldText(value: FollowupValue | undefined, field: FollowupField): string {
if (value === undefined || value === null || value === '' || (Array.isArray(value) && !value.length)) return '未记录'
const display = (part: string | number | boolean) => field.options?.find(option => String(option.value) === String(part))?.label || String(part)
return Array.isArray(value) ? value.map(display).join('、') : display(value)
}
export function hasEditableReviewFields(item: FollowupReviewItem, fields: FollowupFields): boolean {
return Object.keys(item.values).some(key => fields[item.kind]?.some(field => field.key === key && !field.readonly))
}
export function reviewFieldIssue(item: FollowupReviewItem, fields: FollowupFields): string {
if (!item.selected) return ''
for (const key of Object.keys(item.values)) {
const field = fields[item.kind]?.find(candidate => candidate.key === key)
if (!field || field.readonly) return `${field?.label || key} 当前不可编辑,请先取消采纳该字段`
}
return ''
}
export function reviewIssue(item: FollowupReviewItem, canDaily: boolean): string {
if (!item.selected) return ''
if (item.needs_review) return '请明确核对不确定、冲突或重复内容'
if (item.kind !== 'diagnosis' && !canDaily) return '没有日常记录编辑权限'
if (!Object.keys(item.values).length) return '至少填写一个有效字段'
if (item.kind !== 'diagnosis' && !validDate(item.record_date)) return '请核对并填写有效的记录日期'
if (item.record_time && !/^([01]\d|2[0-3]):[0-5]\d(?::[0-5]\d)?$/.test(item.record_time)) return '请填写有效的记录时间'
return ''
}
/** Pending is independent of selection so filtering never hides unresolved items. */
export function reviewPending(item: FollowupReviewItem, fields: FollowupFields, canDaily: boolean): boolean {
return item.needs_review || !!reviewFieldIssue({ ...item, selected: true }, fields) || !!reviewIssue({ ...item, selected: true }, canDaily)
}
export function reviewCounts(items: FollowupReviewItem[], fields: FollowupFields, canDaily: boolean) {
return {
all: items.length,
diagnosis: items.filter(item => item.kind === 'diagnosis').length,
daily: items.filter(item => item.kind !== 'diagnosis').length,
pending: items.filter(item => reviewPending(item, fields, canDaily)).length,
selected: items.filter(item => item.selected).length
}
}
export function changedReviewTarget(items: FollowupReviewItem[], snapshot: FollowupReviewItem[]): boolean {
const previous = new Map(snapshot.map(item => [item.id, item]))
return items.some(item => {
const original = previous.get(item.id)
return item.selected && original && (item.target_id !== original.target_id || item.record_date !== original.record_date)
})
}
/** The API accepts legacy seconds, while the review editor exposes minute precision. */
export function reviewTimeValue(value: string | null): string | null {
return value && /^([01]\d|2[0-3]):[0-5]\d(?::[0-5]\d)?$/.test(value) ? value.slice(0, 5) : null
}
export function validDate(value: string | null): boolean {
if (!value || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return false
const [year, month, day] = value.split('-').map(Number)
const date = new Date(Date.UTC(year, month - 1, day))
return date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day
}
/** A stale snapshot refresh never discards user values or reselects an item for them. */
export function rebaseReviewItems(local: FollowupReviewItem[], fresh: FollowupReviewItem[]): FollowupReviewItem[] {
const previous = new Map(local.map(item => [item.id, item]))
return cloneReviewItems(fresh).map(item => {
const edit = previous.get(item.id)
return edit ? { ...item, values: { ...edit.values }, record_date: edit.record_date, record_time: edit.record_time,
time_period: edit.time_period, target_id: edit.target_id, selected: false, needs_review: true } : item
})
}
export function uploadIssue(file: Pick<File, 'size' | 'name'> | null, recordedAt: string, model: string, caps: FollowupCapabilities | null, viewOnly: boolean): string {
if (viewOnly || !caps?.enabled || !caps.audio_verified || !caps.can_upload) return '当前不可上传录音'
if (!file || !file.size) return '请选择非空录音文件'
if (file.size > caps.limits.max_bytes) return '录音文件超过大小限制'
if (!/\.(mp3|wav|m4a|amr)$/i.test(file.name)) return '请选择支持的录音格式'
if (!/^\d{4}-\d{2}-\d{2} ([01]\d|2[0-3]):[0-5]\d:[0-5]\d$/.test(recordedAt) || !validDate(recordedAt.slice(0, 10))) return '请填写实际录音时间(北京时间)'
if (!caps.models.some(option => option.value === model)) return '请选择可用的分析模型'
return ''
}
/** All async operations capture one generation; invalidate on patient switch, hide or dispose. */
export function createFollowupScope() {
let generation = 0
let controller = new AbortController()
return {
capture: () => ({ generation, signal: controller.signal }),
current: (ticket: { generation: number; signal: AbortSignal }) => ticket.generation === generation && !ticket.signal.aborted,
invalidate() { controller.abort(); generation++; controller = new AbortController() }
}
}
export async function uploadChunks(
file: Blob,
chunkBytes: number,
send: (index: number, chunk: Blob) => Promise<{ received: boolean }>,
isCurrent: () => boolean,
progress: (percent: number) => void
): Promise<boolean> {
if (!Number.isSafeInteger(chunkBytes) || chunkBytes < 65536 || chunkBytes > FOLLOWUP_CHUNK_BYTES) throw new Error('服务端分片配置不匹配,请联系管理员')
for (let start = 0, index = 0; start < file.size; start += chunkBytes, index++) {
if (!isCurrent()) return false
const result = await send(index, file.slice(start, start + chunkBytes))
if (!isCurrent()) return false
if (!result.received) throw new Error('录音分片未被服务端确认,上传已停止')
progress(Math.round(Math.min(start + chunkBytes, file.size) / file.size * 100))
}
return isCurrent()
}
export function followupError(error: unknown): string {
return error instanceof Error ? error.message : '请求失败;当前审核内容已保留,请刷新任务状态后确认'
}