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 = { queued: '等待处理', running: '正在分析', review: '待人工审核', applied: '已采纳', failed: '处理失败', needs_reconciliation: '结果待核对', expired: '已过保留期' } const followupStageLabels: Record = { transcribing: '正在转写', extracting: '正在提炼', validating: '正在校验', review: '待人工审核' } export function followupTaskStatus(task: Pick): 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() 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() 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 | 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 { 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 : '请求失败;当前审核内容已保留,请刷新任务状态后确认' }