feat: add reviewed follow-up audio patient enrichment
This commit is contained in:
@@ -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 : '请求失败;当前审核内容已保留,请刷新任务状态后确认'
|
||||
}
|
||||
Reference in New Issue
Block a user