import request from '@/utils/request' export type FollowupKind = 'diagnosis' | 'blood' | 'diet' | 'exercise' | 'tracking_note' export type FollowupValue = string | number | boolean | null | Array export type FollowupValues = Record export interface FollowupField { key: string label: string type: 'text' | 'number' | 'select' | 'multiselect' | 'date' options?: Array<{ label: string; value: string | number }> readonly?: boolean } export type FollowupFields = Record export type FollowupAudioChannel = 0 | 1 export type FollowupChannelRoles = 'unconfirmed' | 'left_service' | 'right_service' export interface FollowupTranscriptSegment { id: string start_ms: number end_ms: number text: string channel?: FollowupAudioChannel } export interface FollowupCapabilities { enabled: boolean audio_verified: boolean preview_only?: boolean preview_ready?: boolean can_review?: boolean test_scope_allowed?: boolean can_upload: boolean can_apply: boolean can_daily: boolean models: Array<{ value: string; label: string }> limits: { max_bytes: number; max_seconds: number; chunk_bytes: number } fields: FollowupFields } export interface FollowupReviewItem { id: string kind: FollowupKind values: FollowupValues record_date: string | null record_time: string | null time_period: string | null time_estimated: boolean time_text: string date_text: string evidence: Array<{ text: string; start_ms?: number; end_ms?: number; segment_id?: string; position_type?: 'segment'; channel?: FollowupAudioChannel }> needs_review: boolean selected: boolean target_id: number | null current_values: FollowupValues expected_hash: string conflict: boolean possible_duplicate: boolean candidates?: Array<{ id: number values: FollowupValues record_time: string | null time_estimated: boolean time_period: string | null delete_time?: number | null }> } export interface FollowupTaskSummary { id: number diagnosis_id: number file_name: string recorded_at: string status: string stage: string error_code: string error_message: string version: number created_at: number | string expires_at: number | string can_retry: boolean audio_available: boolean preview_only?: boolean transcript_available?: boolean pipeline_progress?: { transcribed: number; total: number } } export interface FollowupTaskDetail extends FollowupTaskSummary { summary: string transcript: string transcript_segments?: FollowupTranscriptSegment[] channel_roles?: FollowupChannelRoles uncertainties: unknown[] items: FollowupReviewItem[] fields?: FollowupFields applied_items: unknown[] review_refreshed?: boolean } interface Envelope { code: number; msg?: string; data: T } export class FollowupAudioError extends Error { constructor(message: string, public readonly code = '') { super(message); this.name = 'FollowupAudioError' } } const options = { isTransformResponse: false, ignoreCancelToken: true, isOpenRetry: false, retryCount: 0 } const base = '/tcm.followupAudio/' function unwrap(response: Envelope): T { if (response.code !== 1) { const data = response.data const code = data && typeof data === 'object' && 'code' in data ? String(data.code) : '' throw new FollowupAudioError(response.msg || '回访录音请求失败,请刷新任务状态后确认', code) } return response.data } async function get(name: string, params: Record, signal?: AbortSignal): Promise { return unwrap(await request.get>({ url: base + name, params, signal }, options)) } async function post(name: string, data: unknown, signal?: AbortSignal): Promise { return unwrap(await request.post>({ url: base + name, data, signal, timeout: 120000 }, options)) } export const followupAudioCapabilities = (diagnosisId: number, signal?: AbortSignal) => get('capabilities', { diagnosis_id: diagnosisId }, signal) export const followupAudioLists = (diagnosisId: number, signal?: AbortSignal) => get<{ items: FollowupTaskSummary[] }>('lists', { diagnosis_id: diagnosisId }, signal) export const followupAudioDetail = (id: number, signal?: AbortSignal) => get('detail', { id }, signal) export const followupAudioUploadSession = (data: { diagnosis_id: number; file_name: string; total_bytes: number }, signal?: AbortSignal) => post<{ upload_id: string; chunk_bytes: number }>('uploadSession', data, signal) export async function followupAudioUploadChunk(uploadId: string, index: number, file: Blob, signal?: AbortSignal) { const data = new FormData() data.append('upload_id', uploadId) data.append('index', String(index)) data.append('file', file, `chunk-${index}`) return unwrap(await request.post>({ url: base + 'uploadChunk', data, signal, timeout: 120000, headers: { 'Content-Type': 'multipart/form-data' } }, options)) } export const followupAudioUploadComplete = (uploadId: string, signal?: AbortSignal) => post<{ upload_id: string; duration_seconds: number; sha256: string }>('uploadComplete', { upload_id: uploadId }, signal) export const followupAudioCreate = (data: { diagnosis_id: number; upload_id: string; recorded_at: string; model_key: string }, signal?: AbortSignal) => post<{ task_id: number; status: string; reused?: boolean; reuse_message?: string }>('create', data, signal) export const followupAudioSaveDraft = (data: { id: number; version: number; items: FollowupReviewItem[]; channel_roles?: FollowupChannelRoles }, signal?: AbortSignal) => post('saveDraft', data, signal) export const followupAudioApply = (data: { id: number; version: number; items: FollowupReviewItem[] }, signal?: AbortSignal) => post<{ id: number; status: 'applied'; applied_items: unknown[] }>('apply', data, signal) export const followupAudioRetry = (id: number, signal?: AbortSignal) => post<{ id: number; status: string }>('retry', { id }, signal) /** The existing request interceptor puts the token in a header, never in the audio URL. */ export async function followupAudioBlob(id: number, signal?: AbortSignal): Promise { const blob = await request.get({ url: base + 'audio', params: { id }, responseType: 'blob', signal, timeout: 120000 }, options) if (blob.type.includes('json')) { const response: Envelope = JSON.parse(await blob.text()) unwrap(response) throw new FollowupAudioError('录音响应不是音频文件') } return blob }