Files
zyt/admin/src/views/tcm/diagnosis/components/followupAudioState.ts
T

197 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { FollowupAudioChannel, FollowupCapabilities, FollowupChannelRoles, FollowupField, FollowupFields, FollowupReviewItem, FollowupTaskDetail, FollowupTaskSummary, FollowupTranscriptSegment, 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: '已过保留期'
}
const followupStageLabels: Record<string, string> = { transcribing: '正在转写', extracting: '正在提炼', validating: '正在校验', review: '待人工审核' }
export function followupTaskStatus(task: Pick<FollowupTaskSummary, 'status' | 'stage'>): string {
return (task.status === 'running' && followupStageLabels[task.stage]) || followupStatusLabels[task.status] || task.status
}
export function followupProgressText(task: FollowupTaskSummary): string {
const progress = task.pipeline_progress
const valid = progress && Number.isSafeInteger(progress.transcribed) && Number.isSafeInteger(progress.total)
&& progress.total > 0 && progress.transcribed >= 0 && progress.transcribed <= progress.total
return `${followupTaskStatus(task)}${valid ? ` · 转写片段 ${progress.transcribed} / ${progress.total}` : ''};人工审核前不会写入业务记录。`
}
export function retainedTranscriptNotice(task: FollowupTaskDetail): string {
if (!task.transcript_available || !task.transcript.trim()) return ''
if (task.status === 'failed') return '处理失败,已完成片段的转写仍可查看。保留转写不代表提炼成功,不能直接写入病历。'
if (task.status === 'needs_reconciliation') return '结果待核对,已完成片段的转写仍可查看。请先核对上游结果,不要重复发送。'
return ''
}
export function evidencePlaybackLabel(quote: FollowupReviewItem['evidence'][number]): string {
if (typeof quote.start_ms !== 'number' || !Number.isFinite(quote.start_ms) || quote.start_ms < 0) return ''
if (quote.channel !== undefined && quote.channel !== 0 && quote.channel !== 1) return ''
if (quote.position_type === 'segment') {
if (!quote.segment_id || typeof quote.end_ms !== 'number' || !Number.isFinite(quote.end_ms) || quote.end_ms <= quote.start_ms) return ''
return `回听对话 ${audioTimestamp(quote.start_ms)}–${audioTimestamp(quote.end_ms)}`
}
return `播放 ${audioTimestamp(quote.start_ms)}`
}
export function audioTimestamp(ms: number): string { const seconds = Math.max(0, Math.floor(ms / 1000)); return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}` }
export function normalizedChannelRoles(value: unknown): FollowupChannelRoles { return value === 'left_service' || value === 'right_service' ? value : 'unconfirmed' }
export function hasStereoTranscript(segments: FollowupTranscriptSegment[] = []): boolean { return segments.some(segment => segment.channel === 0) && segments.some(segment => segment.channel === 1) }
export function followupChannelLabel(channel?: FollowupAudioChannel, roles: FollowupChannelRoles = 'unconfirmed'): string {
if (channel !== 0 && channel !== 1) return '未标注声道'
const side = channel === 0 ? '左声道' : '右声道'
const confirmed = normalizedChannelRoles(roles)
if (confirmed === 'unconfirmed') return `${side} · 角色待确认`
const service = confirmed === 'left_service' ? channel === 0 : channel === 1
return `${side} · ${service ? '客服端' : '患者/家属端'}(人工标注)`
}
/** Preserve coarse ASR windows; never invent speaker turn order or word-level timestamps. */
export function dialogueWindows(segments: FollowupTranscriptSegment[]) {
const windows = new Map<string, { id: string; start_ms: number; end_ms: number; segments: FollowupTranscriptSegment[] }>()
for (const segment of segments) {
const id = `${segment.start_ms}:${segment.end_ms}`
if (!windows.has(id)) windows.set(id, { id, start_ms: segment.start_ms, end_ms: segment.end_ms, segments: [] })
windows.get(id)!.segments.push(segment)
}
return [...windows.values()].sort((a, b) => a.start_ms - b.start_ms || a.end_ms - b.end_ms)
.map(window => ({ ...window, segments: [...window.segments].sort((a, b) => (a.channel ?? -1) - (b.channel ?? -1)) }))
}
export function dialoguePlaybackStart(milliseconds: number): number {
return Number.isFinite(milliseconds) ? Math.max(0, milliseconds - 3000) : 0
}
export function channelPlaybackLabel(channel: FollowupAudioChannel, roles: FollowupChannelRoles): string {
if (roles === 'unconfirmed') return channel === 0 ? '只听左声道' : '只听右声道'
const service = roles === 'left_service' ? channel === 0 : channel === 1
return service ? '只听客服' : '只听患者/家属'
}
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))
}
/** Prevent multiple selected writes to the same diagnosis field; never silently pick a last value. */
export function reviewBatchIssue(items: FollowupReviewItem[], fields: FollowupFields): string {
const touched = new Set<string>()
for (const item of items.filter(item => item.selected)) {
if (item.kind === 'diagnosis') {
for (const key of Object.keys(item.values)) {
if (touched.has(key)) {
const label = fields.diagnosis?.find(field => field.key === key)?.label || key
return `${label}存在多条选中建议,请合并内容到一项或仅保留一项后再确认写入`
}
touched.add(key)
}
}
}
return ''
}
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
})
}
/** Missing preview readiness/scope fails closed; normal deployments retain their full-verification rules. */
export function followupFeatureEnabled(caps: FollowupCapabilities | null): boolean {
return !!caps?.enabled && (!caps.preview_only || caps.test_scope_allowed === true)
}
export function followupAudioReady(caps: FollowupCapabilities | null): boolean {
return !!caps && (caps.preview_only ? caps.preview_ready === true && caps.test_scope_allowed === true : caps.audio_verified || (caps.manual_review_enabled === true && caps.manual_review_ready === true))
}
export function followupCanReview(caps: FollowupCapabilities | null, previewTask = false): boolean {
return followupFeatureEnabled(caps) && !!caps && (caps.preview_only || previewTask ? caps.can_review === true : caps.can_apply)
}
export function followupCanApply(caps: FollowupCapabilities | null, previewTask = false): boolean {
return followupFeatureEnabled(caps) && followupAudioReady(caps) && !!caps && !caps.preview_only && !previewTask && caps.can_apply
}
export function uploadIssue(file: Pick<File, 'size' | 'name'> | null, recordedAt: string, model: string, caps: FollowupCapabilities | null, viewOnly: boolean): string {
if (viewOnly || !caps || !followupFeatureEnabled(caps) || !followupAudioReady(caps) || !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 : '请求失败;当前审核内容已保留,请刷新任务状态后确认'
}