From 70be2fc70fa1455d114550c07e3df6e42269f031 Mon Sep 17 00:00:00 2001 From: long <452591453@qq.com> Date: Tue, 29 Sep 2026 15:44:30 +0800 Subject: [PATCH] feat: add reviewed follow-up audio patient enrichment --- admin/scripts/verify-daily-records.cjs | 206 ++++++ admin/src/api/followupAudio.ts | 123 ++++ .../tcm/diagnosis/components/DailyMatrix.vue | 522 ++++++++------ .../components/FollowupAudioField.vue | 26 + .../components/FollowupAudioPanel.vue | 412 +++++++++++ .../components/FollowupAudioReview.vue | 233 +++++++ .../components/followupAudioState.ts | 112 +++ admin/src/views/tcm/diagnosis/edit.vue | 109 ++- admin/tests/followup-audio.test.cjs | 655 ++++++++++++++++++ docs/plans/followup-audio-deployment.md | 89 +++ scripts/generate_followup_audio_fixtures.py | 78 +++ server/.env.followup-audio.example | 16 + .../tcm/FollowupAudioController.php | 161 +++++ .../http/middleware/AuthMiddleware.php | 12 + .../app/adminapi/logic/tcm/DiagnosisLogic.php | 16 +- .../adminapi/logic/tcm/FollowupAudioLogic.php | 159 +++++ .../adminapi/logic/tcm/TrackingNoteLogic.php | 98 ++- server/app/command/FollowupAudioCleanup.php | 36 + server/app/command/FollowupAudioProbe.php | 180 +++++ server/app/command/FollowupAudioWork.php | 56 ++ server/app/common/model/tcm/Diagnosis.php | 5 +- .../followupaudio/FollowupAudioAccess.php | 80 +++ .../followupaudio/FollowupAudioApply.php | 354 ++++++++++ .../followupaudio/FollowupAudioDify.php | 493 +++++++++++++ .../followupaudio/FollowupAudioException.php | 35 + .../followupaudio/FollowupAudioFields.php | 221 ++++++ .../followupaudio/FollowupAudioPolicy.php | 225 ++++++ .../followupaudio/FollowupAudioStore.php | 441 ++++++++++++ .../followupaudio/FollowupAudioStream.php | 39 ++ .../followupaudio/FollowupAudioUpload.php | 363 ++++++++++ .../followupaudio/FollowupAudioWorker.php | 52 ++ server/config/console.php | 3 + server/config/followup_audio.php | 30 + server/sql/2026_09_29_followup_audio.sql | 104 +++ server/tests/FollowupAudioAccessTest.php | 85 +++ .../tests/FollowupAudioCleanupCommandTest.php | 30 + server/tests/FollowupAudioCoreTest.php | 470 +++++++++++++ server/tests/FollowupAudioDifyTest.php | 154 ++++ .../FollowupAudioEndpointContractTest.php | 39 ++ .../FollowupAudioExtractionAcceptanceTest.php | 464 +++++++++++++ server/tests/FollowupAudioMediaTest.php | 171 +++++ .../tests/FollowupAudioProbeCommandTest.php | 80 +++ .../FollowupAudioTrackingNoteBehaviorTest.php | 143 ++++ server/tests/FollowupAudioUploadTest.php | 116 ++++ server/tests/FollowupAudioWorkerTest.php | 72 ++ .../fixtures/followup_audio/dify_router.php | 62 ++ 46 files changed, 7384 insertions(+), 246 deletions(-) create mode 100644 admin/scripts/verify-daily-records.cjs create mode 100644 admin/src/api/followupAudio.ts create mode 100644 admin/src/views/tcm/diagnosis/components/FollowupAudioField.vue create mode 100644 admin/src/views/tcm/diagnosis/components/FollowupAudioPanel.vue create mode 100644 admin/src/views/tcm/diagnosis/components/FollowupAudioReview.vue create mode 100644 admin/src/views/tcm/diagnosis/components/followupAudioState.ts create mode 100644 admin/tests/followup-audio.test.cjs create mode 100644 docs/plans/followup-audio-deployment.md create mode 100755 scripts/generate_followup_audio_fixtures.py create mode 100644 server/.env.followup-audio.example create mode 100644 server/app/adminapi/controller/tcm/FollowupAudioController.php create mode 100644 server/app/adminapi/logic/tcm/FollowupAudioLogic.php create mode 100644 server/app/command/FollowupAudioCleanup.php create mode 100644 server/app/command/FollowupAudioProbe.php create mode 100644 server/app/command/FollowupAudioWork.php create mode 100644 server/app/common/service/followupaudio/FollowupAudioAccess.php create mode 100644 server/app/common/service/followupaudio/FollowupAudioApply.php create mode 100644 server/app/common/service/followupaudio/FollowupAudioDify.php create mode 100644 server/app/common/service/followupaudio/FollowupAudioException.php create mode 100644 server/app/common/service/followupaudio/FollowupAudioFields.php create mode 100644 server/app/common/service/followupaudio/FollowupAudioPolicy.php create mode 100644 server/app/common/service/followupaudio/FollowupAudioStore.php create mode 100644 server/app/common/service/followupaudio/FollowupAudioStream.php create mode 100644 server/app/common/service/followupaudio/FollowupAudioUpload.php create mode 100644 server/app/common/service/followupaudio/FollowupAudioWorker.php create mode 100644 server/config/followup_audio.php create mode 100644 server/sql/2026_09_29_followup_audio.sql create mode 100644 server/tests/FollowupAudioAccessTest.php create mode 100644 server/tests/FollowupAudioCleanupCommandTest.php create mode 100644 server/tests/FollowupAudioCoreTest.php create mode 100644 server/tests/FollowupAudioDifyTest.php create mode 100644 server/tests/FollowupAudioEndpointContractTest.php create mode 100644 server/tests/FollowupAudioExtractionAcceptanceTest.php create mode 100644 server/tests/FollowupAudioMediaTest.php create mode 100644 server/tests/FollowupAudioProbeCommandTest.php create mode 100644 server/tests/FollowupAudioTrackingNoteBehaviorTest.php create mode 100644 server/tests/FollowupAudioUploadTest.php create mode 100644 server/tests/FollowupAudioWorkerTest.php create mode 100644 server/tests/fixtures/followup_audio/dify_router.php diff --git a/admin/scripts/verify-daily-records.cjs b/admin/scripts/verify-daily-records.cjs new file mode 100644 index 000000000..a4c84bd87 --- /dev/null +++ b/admin/scripts/verify-daily-records.cjs @@ -0,0 +1,206 @@ +const assert = require('node:assert/strict') +const fs = require('node:fs') +const path = require('node:path') +const vm = require('node:vm') +const ts = require('typescript') +const vue = require('vue') +const { parse, compileScript, compileTemplate, compileStyleAsync } = require('@vue/compiler-sfc') + +const sourceFlag = process.argv.indexOf('--source') +const filename = sourceFlag >= 0 ? path.resolve(process.argv[sourceFlag + 1]) : path.join(__dirname, '../src/views/tcm/diagnosis/components/DailyMatrix.vue') +const source = fs.readFileSync(filename, 'utf8') +const { descriptor, errors } = parse(source, { filename }) +assert.deepEqual(errors, [], 'actual SFC parses') +const script = compileScript(descriptor, { id: 'daily-record-test' }) +const template = compileTemplate({ source: descriptor.template.content, filename, id: 'daily-record-test', compilerOptions: { bindingMetadata: script.bindings } }) +assert.deepEqual(template.errors, [], 'actual template compiles') +const calls = [] +const pending = { window: null, notes: null, bloodEdit: null } +const api = new Proxy({}, { get(_target, name) { + return (...args) => { + calls.push({ name, args: JSON.parse(JSON.stringify(args)) }) + if (name === 'diagnosisTrackingWindow') return pending.window ? pending.window(...args) : Promise.resolve({}) + if (name === 'diagnosisTrackingNotes') return pending.notes ? pending.notes(...args) : Promise.resolve([]) + if (name === 'bloodRecordEdit' && pending.bloodEdit) return pending.bloodEdit(...args) + return Promise.resolve(true) + } +} }) +const thresholdSource = fs.readFileSync(path.join(__dirname, '../src/utils/blood-thresholds.ts'), 'utf8') +const thresholdExports = {} +vm.runInNewContext(ts.transpileModule(thresholdSource, { compilerOptions: { module: ts.ModuleKind.CommonJS } }).outputText, { exports: thresholdExports }) +const moduleObject = { exports: {} } +const lifecycle = { mounted: [], unmounted: [] } +const runtime = { ...vue, onMounted: fn => lifecycle.mounted.push(fn), onUnmounted: fn => lifecycle.unmounted.push(fn) } +vm.runInNewContext(ts.transpileModule(script.content, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText, { + exports: moduleObject.exports, module: moduleObject, console, Date, Map, window: { addEventListener() {}, removeEventListener() {} }, + require(name) { + if (name === 'vue') return runtime + if (name === '@/api/tcm') return api + if (name === '@/utils/perm') return { hasPermission: () => true } + if (name === '@/utils/blood-thresholds') return thresholdExports + if (name === '@/utils/feedback') return { default: { msgWarning: message => calls.push({ name: 'warning', message }) } } + if (name === 'vue-echarts' || name.endsWith('.vue')) return { default: {} } + throw new Error(`Unexpected import: ${name}`) + } +}, { filename }) +const props = vue.reactive({ diagnosisId: 7, patientId: 70, age: 50, readOnly: false, patientName: '合成测试' }) +let exposed +const scope = vue.effectScope() +const state = scope.run(() => moduleObject.exports.default.setup(props, { expose(value) { exposed = value } })) +const date = '2026-09-20' +const blood = [ + { id: 103, diagnosis_id: 7, record_date: date, record_time: '19:30', fasting_blood_sugar: 7.1, record_time_estimated: 1, record_time_period: 'evening', record_time_text: '昨晚', followup_audio_task_id: 80, remark: '晚间原始行' }, + { id: 102, diagnosis_id: 7, record_date: date, record_time: '08:30', fasting_blood_sugar: 7.1, source: 1, remark: '早间原始行' }, + { id: 101, diagnosis_id: 7, record_date: date, record_time: '07:00', postprandial_blood_sugar: 8.4, remark: '独立原始行' } +] +const diet = [ { id: 201, record_date: date, breakfast_foods: '鸡蛋', breakfast_images: ['fixture-a.png'] }, { id: 202, record_date: date, lunch_foods: '豆腐', lunch_images: ['fixture-b.png'] } ] +const exercise = [ { id: 301, record_date: date, exercise_type: '散步', duration: 20, intensity: 1, images: ['fixture-c.png'] }, { id: 302, record_date: date, exercise_type: '散步', duration: 20, intensity: 1 } ] +function fixtures() { + state.bloodRecords.value = structuredClone(blood) + state.dietRecords.value = structuredClone(diet) + state.exerciseRecords.value = structuredClone(exercise) +} +function rowCount(map) { const rows = map.value.get(date); return Array.isArray(rows) ? rows.length : rows ? 1 : 0 } +function observe() { + fixtures() + state.handleCellClick('fasting', date) + return { bloodRows: rowCount(state.bloodByDate), dietRows: rowCount(state.dietByDate), exerciseRows: rowCount(state.exerciseByDate), dayDetailOpen: !!state.dayDetailVisible?.value, implicitBloodEditId: state.bloodForm.value.id || null, fastingCell: state.getCell('fasting', date).value } +} +async function main() { + for (const style of descriptor.styles) { + const result = await compileStyleAsync({ source: style.content, filename, id: 'daily-record-test', scoped: style.scoped, preprocessLang: style.lang }) + assert.deepEqual(result.errors, [], 'actual styles compile') + } + const canonicalFlag = process.argv.indexOf('--canonical-fixture') + if (canonicalFlag >= 0) { + const fixture = JSON.parse(fs.readFileSync(process.argv[canonicalFlag + 1], 'utf8')) + assert.equal(fixture.synthetic, true) + assert.equal(fixture.source, 'real-disposable-mysql-apply') + for (const alias of ['breakfast', 'lunch', 'dinner']) assert.equal(fixture.diet_columns.includes(alias), false) + const actual = { ...fixture.diet_model, record_date: new Intl.DateTimeFormat('sv-SE', { timeZone: 'Asia/Shanghai' }).format(new Date(Number(fixture.diet_model.record_date) * 1000)) } + state.dietRecords.value = [actual] + state.openDietRecord(actual, actual.record_date) + for (const meal of ['breakfast', 'lunch', 'dinner']) { + assert.equal(state.dietForm.value[`${meal}_foods`], actual[`${meal}_foods`], 'real adopted meal opens in existing edit input') + assert.deepEqual(JSON.parse(JSON.stringify(state.dietForm.value[`${meal}_images`])), actual[`${meal}_images`]) + assert.equal(state.getCell(meal, actual.record_date).value, '✓') + } + state.dietForm.value.note = 'only synthetic note changed' + await state.submitDiet() + const payload = calls.findLast(x => x.name === 'dietRecordEdit').args[0] + for (const meal of ['breakfast', 'lunch', 'dinner']) { + assert.equal(payload[`${meal}_foods`], actual[`${meal}_foods`], 'submit preserves untouched canonical meals') + assert.deepEqual(payload[`${meal}_images`], actual[`${meal}_images`], 'submit preserves images') + assert.equal(meal in payload, false, 'no extraction alias leaks to existing edit API') + } + console.log('DAILY_RECORDS_CANONICAL_MYSQL PASS actual_model=1 edit_inputs=1 canonical_payload=1 preserved_images=1') + } + if (process.argv.includes('--observe')) { console.log(JSON.stringify(observe())); scope.stop(); return } + assert.deepEqual(observe(), { bloodRows: 3, dietRows: 2, exerciseRows: 2, dayDetailOpen: true, implicitBloodEditId: null, fastingCell: '7.1 / 7.1' }) + assert.equal(state.dayBloodRecords.value.length, 3, 'all same-day blood rows present') + assert.equal(state.dayDietRecords.value.length, 2, 'all diet rows present') + assert.equal(state.dayExerciseRecords.value.length, 2, 'identical exercise rows remain separate') + assert.equal(state.recordTimeLabel(blood[0]), '19:30') + assert.equal(state.recordTimePeriodLabel(blood[0]), '晚上') + assert.equal(state.recordTimeLabel(diet[0]), '未记录具体时间', 'do not invent times for old diet rows') + assert.match(descriptor.template.content, /v-for="record in dayBloodRecords"/) + assert.match(descriptor.template.content, /v-for="record in dayDietRecords"/) + assert.match(descriptor.template.content, /v-for="record in dayExerciseRecords"/) + assert.match(descriptor.template.content, /估计时间/) + assert.match(descriptor.template.content, /record\.record_time_text/) + + state.openBloodRecord(blood[0], date) + assert.equal(state.bloodForm.value.id, 103) + assert.equal(state.bloodForm.value.postprandial_blood_sugar, 0, 'no field from another row merged into edit') + state.bloodForm.value.remark = '只改备注' + await state.submitBlood() + let sent = calls.findLast(x => x.name === 'bloodRecordEdit').args[0] + assert.equal(sent.id, 103) + assert.equal(sent.record_time_estimated, 1, 'unmodified time retains estimate') + assert.equal(sent.record_time_text, '昨晚') + assert.equal(sent.followup_audio_task_id, 80, 'keep provenance') + state.openBloodRecord(blood[0], date) + state.bloodForm.value.record_time = '20:05' + await state.submitBlood() + sent = calls.findLast(x => x.name === 'bloodRecordEdit').args[0] + assert.equal(sent.record_time_estimated, 0, 'manual time correction clears estimate') + assert.equal(sent.record_time_period, '') + assert.equal(sent.record_time_text, '') + assert.equal(sent.followup_audio_task_id, 80) + state.openBloodRecord({ ...blood[0], record_time: '19:30:25' }, date) + state.bloodForm.value.record_time = '19:30' + await state.submitBlood() + sent = calls.findLast(x => x.name === 'bloodRecordEdit').args[0] + assert.equal(sent.record_time_estimated, 0, 'seconds correction must not retain estimated time') + assert.equal(state.recordTimeLabel({ record_time: '19:30:25' }), '19:30:25', 'precise original seconds remain visible') + state.openBloodRecord(undefined, date) + await state.submitBlood() + sent = calls.findLast(x => x.name === 'bloodRecordAdd').args[0] + assert.equal(sent.id, '') + assert.equal('record_time_estimated' in sent, false, 'legacy manual add does not require migration columns') + state.openDietRecord(diet[1], date) + assert.equal(state.dietForm.value.id, 202) + state.dietForm.value.lunch_images.push('fixture-new.png') + assert.equal(diet[1].lunch_images.length, 1, 'edit image arrays cloned') + await state.submitDiet() + assert.equal(calls.findLast(x => x.name === 'dietRecordEdit').args[0].id, 202) + state.openExerciseRecord(exercise[1], date) + await state.submitExercise() + assert.equal(calls.findLast(x => x.name === 'exerciseRecordEdit').args[0].id, 302) + fixtures() + props.readOnly = true + state.dayDetailVisible.value = false + state.handleCellClick('exercise', date) + assert.equal(state.dayDetailVisible.value, true, 'readonly viewers can inspect actual rows') + state.bloodDialogVisible.value = false + state.openBloodRecord(blood[0], date) + assert.equal(state.bloodDialogVisible.value, false, 'readonly cannot edit') + props.readOnly = false + + const windows = [] + pending.window = params => new Promise(resolve => windows.push({ params, resolve })) + const oldFetch = state.fetchTracking() + props.diagnosisId = 8 + await vue.nextTick() + assert.equal(state.dayDetailVisible.value, false, 'patient switch closes prior details') + assert.equal(state.bloodRecords.value.length, 0, 'patient switch immediately clears old rows') + assert.equal(windows.length, 2) + assert.equal(windows[0].params.id, 7) + assert.equal(windows[1].params.id, 8) + windows[1].resolve({ blood_records: [{ id: 888, record_date: date }] }) + await new Promise(resolve => setImmediate(resolve)) + windows[0].resolve({ blood_records: blood }) + await oldFetch + assert.equal(state.bloodRecords.value[0].id, 888, 'late old-patient fetch cannot overwrite new data') + assert.deepEqual(calls.filter(x => x.name === 'diagnosisTrackingNotes').slice(-2).map(x => x.args[0].diagnosis_id), [8, 7], 'both sequential requests use their own bound identity') + + let releaseEdit + pending.bloodEdit = () => new Promise(resolve => { releaseEdit = resolve }) + pending.window = null + state.openBloodRecord({ ...blood[0], diagnosis_id: 8 }, date) + const oldSubmit = state.submitBlood() + props.diagnosisId = 9 + await vue.nextTick() + state.bloodDialogVisible.value = true + releaseEdit(true) + await oldSubmit + assert.equal(state.bloodDialogVisible.value, true, 'late old-patient submit cannot close new dialog') + + // Single-flight guard prevents duplicate submissions of the same manual form. + state.bloodSubmitting.value = true + const callCount = calls.length + await state.submitBlood() + assert.equal(calls.length, callCount) + state.bloodSubmitting.value = false + const unmountedWindows = [] + pending.window = () => new Promise(resolve => unmountedWindows.push(resolve)) + const unmountedFetch = state.fetchTracking() + for (const unmount of lifecycle.unmounted) unmount() + unmountedWindows[0]({ blood_records: blood }) + await unmountedFetch + assert.notEqual(state.bloodRecords.value[0]?.id, 103, 'unmounted request cannot repopulate component') + scope.stop() + assert.equal(typeof exposed.refresh, 'function') + console.log('DailyMatrix actual SFC setup/template/style: 3 blood + 2 diet + 2 exercise rows, exact edit IDs, time metadata, readonly detail, stale isolation: PASS') +} +main().catch(error => { scope.stop(); console.error(error); process.exitCode = 1 }) diff --git a/admin/src/api/followupAudio.ts b/admin/src/api/followupAudio.ts new file mode 100644 index 000000000..4f3a9a1da --- /dev/null +++ b/admin/src/api/followupAudio.ts @@ -0,0 +1,123 @@ +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 interface FollowupCapabilities { + enabled: boolean + audio_verified: 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 }> + 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 +} +export interface FollowupTaskDetail extends FollowupTaskSummary { + summary: string + transcript: string + 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[] }, 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 +} diff --git a/admin/src/views/tcm/diagnosis/components/DailyMatrix.vue b/admin/src/views/tcm/diagnosis/components/DailyMatrix.vue index ded7fbba1..80f94b3f8 100644 --- a/admin/src/views/tcm/diagnosis/components/DailyMatrix.vue +++ b/admin/src/views/tcm/diagnosis/components/DailyMatrix.vue @@ -68,7 +68,11 @@ 'is-high': getCell(row.key, date).isHigh, 'is-patient-self': getCell(row.key, date).isPatientSelf && getCell(row.key, date).hasRecord }" + :role="isCellClickable(row.key, date) ? 'button' : undefined" + :tabindex="isCellClickable(row.key, date) ? 0 : undefined" @click="handleCellClick(row.key, date)" + @keydown.enter="handleCellClick(row.key, date)" + @keydown.space.prevent="handleCellClick(row.key, date)" > - + @@ -109,6 +119,7 @@
血糖趋势
+

按日展示最新有效血糖值;点击表格查看当天全部原始记录。

+ +

每张卡片是一条独立记录;相同数值也不会合并。请在对应记录上点击编辑。

+
+
+

血糖血压 · {{ dayBloodRecords.length }}条

+ 新增一条 +
+ +
+
+ {{ recordTimeLabel(record) }} #{{ record.id }} + 编辑此条 +
+
+ 估计时间 + 时段:{{ recordTimePeriodLabel(record) }} + 随访录音 + 患者自录 +
+

时间原文:{{ record.record_time_text }}

+
+ +
+
+
+
+
+

饮食 · {{ dayDietRecords.length }}条

+ 新增一条 +
+ +
+
+ {{ recordTimeLabel(record) }} #{{ record.id }} + 编辑此条 +
+
+ +
备注
{{ record.note || '—' }}
+
+
+
+
+
+

运动 · {{ dayExerciseRecords.length }}条

+ 新增一条 +
+ +
+
+ {{ recordTimeLabel(record) }} #{{ record.id }} + 编辑此条 +
+
+
运动
{{ record.exercise_type || '—' }}
+
时长
{{ record.duration ?? '—' }} 分钟
+
强度
{{ record.intensity_text || intensityLabel(record.intensity) }}
+
备注
{{ record.note || '—' }}
+
+
+ +
+
+
+
+ @@ -148,6 +237,12 @@ class="w-full" /> + +
+ 估计时间 · {{ recordTimePeriodLabel(bloodForm) }};原文:{{ bloodForm.record_time_text || '未提供' }}。 + 手动修改记录时间后会清除估计标记;只改数值则保留来源。 +
+
mmol/L @@ -353,6 +448,10 @@ interface BloodRecord { patient_id?: number record_date?: string record_time?: string + record_time_estimated?: number | string + record_time_period?: string + record_time_text?: string + followup_audio_task_id?: number | null record_date_ts?: number fasting_blood_sugar?: any postprandial_blood_sugar?: any @@ -364,12 +463,11 @@ interface BloodRecord { remark?: string /** 来源:0-医生录入 1-患者自录(小程序日常记录页) */ source?: number - /** 合并后该日是否含有「患者自录」记录 */ - has_patient_self?: boolean [key: string]: any } interface DietRecord { + record_time?: string id?: number | string diagnosis_id?: number patient_id?: number @@ -388,6 +486,7 @@ interface DietRecord { } interface ExerciseRecord { + record_time?: string id?: number | string diagnosis_id?: number patient_id?: number @@ -437,6 +536,9 @@ const bloodDialogVisible = ref(false) const dietDialogVisible = ref(false) const exerciseDialogVisible = ref(false) const trackingDialogVisible = ref(false) +const dayDetailVisible = ref(false) +const detailDate = ref('') +const originalBloodTime = ref('') const bloodSubmitting = ref(false) const dietSubmitting = ref(false) const exerciseSubmitting = ref(false) @@ -455,7 +557,7 @@ const bloodDialogTitle = computed(() => (bloodForm.value.id ? '编辑血糖血 const dietDialogTitle = computed(() => (dietForm.value.id ? '编辑饮食记录' : '新增饮食记录')) const exerciseDialogTitle = computed(() => (exerciseForm.value.id ? '编辑运动记录' : '新增运动记录')) const todayDate = computed(() => formatDate(new Date())) -const trackingCanSubmit = computed(() => !readOnly.value && trackingForm.value.note_date === todayDate.value) +const trackingCanSubmit = computed(() => canEditDailyRecord.value && trackingForm.value.note_date === todayDate.value) const trackingDialogTitle = computed(() => (trackingCanSubmit.value ? '新增/追加跟踪备注' : '查看跟踪备注')) const tableWrapRef = ref(null) const tableWrapWidth = ref(0) @@ -583,70 +685,63 @@ const hasPatientSelfRecord = computed(() => { return bloodRecords.value.some((r) => Number((r as any).source) === 1) }) -const bloodByDate = computed(() => { - const map = new Map() - for (const record of bloodRecords.value) { +// Group actual rows, never a synthetic record bearing the first row's id. +function groupRecordsByDate(records: T[]): Map { + const map = new Map() + for (const record of records) { const date = String(record.record_date || '') if (!date) continue - if (!map.has(date)) { - map.set(date, { - id: record.id, - diagnosis_id: record.diagnosis_id, - patient_id: record.patient_id, - record_date: date, - record_time: record.record_time || '', - record_date_ts: record.record_date_ts, - has_patient_self: false - }) - } - const merged = map.get(date)! - const fields: Array = [ - 'fasting_blood_sugar', - 'postprandial_blood_sugar', - 'other_blood_sugar', - 'systolic_pressure', - 'diastolic_pressure', - 'western_medicine', - 'insulin', - 'remark' - ] - for (const field of fields) { - if ( - (merged[field] === undefined || merged[field] === null || merged[field] === '') && - record[field] !== undefined && - record[field] !== null && - record[field] !== '' - ) { - ;(merged as any)[field] = record[field] - } - } - // 当天有任意 source=1 的记录则标记为患者自录 - if (Number(record.source) === 1) { - merged.has_patient_self = true - } + const rows = map.get(date) || [] + rows.push(record) + map.set(date, rows) } return map -}) +} -const dietByDate = computed(() => { - const map = new Map() - for (const record of dietRecords.value) { - const date = String(record.record_date || '') - if (!date || map.has(date)) continue - map.set(date, record) - } - return map -}) +const bloodByDate = computed(() => groupRecordsByDate(bloodRecords.value)) +const dietByDate = computed(() => groupRecordsByDate(dietRecords.value)) +const exerciseByDate = computed(() => groupRecordsByDate(exerciseRecords.value)) +const dayBloodRecords = computed(() => bloodByDate.value.get(detailDate.value) || []) +const dayDietRecords = computed(() => dietByDate.value.get(detailDate.value) || []) +const dayExerciseRecords = computed(() => exerciseByDate.value.get(detailDate.value) || []) +const mealFields = [{ key: 'breakfast', label: '早餐' }, { key: 'lunch', label: '午餐' }, { key: 'dinner', label: '晚餐' }] -const exerciseByDate = computed(() => { - const map = new Map() - for (const record of exerciseRecords.value) { - const date = String(record.record_date || '') - if (!date || map.has(date)) continue - map.set(date, record) +function recordTimeLabel(record: { record_time?: string }) { + return record.record_time ? String(record.record_time) : '未记录具体时间' +} + +function recordTimePeriodLabel(record: BloodRecord) { + const labels: Record = { + dawn: '凌晨', early_morning: '清晨', morning: '上午', noon: '中午', + afternoon: '下午', evening: '晚上', night: '夜间', bedtime: '睡前' } - return map -}) + const period = String(record.record_time_period || '') + return labels[period] || period || '未提供时段' +} + +function intensityLabel(value?: number) { + return ({ 1: '低强度', 2: '中强度', 3: '高强度' } as Record)[Number(value)] || '—' +} + +function bloodDetailFields(record: BloodRecord) { + const show = (value: unknown) => value == null || value === '' ? '—' : String(value) + return [ + { label: '空腹血糖', value: `${show(record.fasting_blood_sugar)} mmol/L` }, + { label: '餐后2h血糖', value: `${show(record.postprandial_blood_sugar)} mmol/L` }, + { label: '其他血糖', value: `${show(record.other_blood_sugar)} mmol/L` }, + { label: '血压', value: record.systolic_pressure || record.diastolic_pressure ? `${formatBp(record)} mmHg` : '—' }, + { label: '西药', value: show(record.western_medicine) }, + { label: '胰岛素', value: show(record.insulin) }, + { label: '备注', value: show(record.remark) } + ] +} + +function getDayRecordCount(metric: string, date: string) { + if (['breakfast', 'lunch', 'dinner'].includes(metric)) return dietByDate.value.get(date)?.length || 0 + if (metric === 'exercise') return exerciseByDate.value.get(date)?.length || 0 + if (metric === 'tracking') return 0 + return bloodByDate.value.get(date)?.length || 0 +} const trackingByDate = computed(() => { const map = new Map() @@ -663,9 +758,10 @@ const chartDates = computed(() => [...visibleDates.value].reverse()) const bloodTrendSeries = computed(() => { return chartDates.value.reduce( (acc, date) => { - const blood = bloodByDate.value.get(date) - acc.fasting.push(parseTrendNumber(blood?.fasting_blood_sugar)) - acc.postprandial.push(parseTrendNumber(blood?.postprandial_blood_sugar)) + const records = bloodByDate.value.get(date) || [] + const latestValue = (field: keyof BloodRecord) => records.map((record) => parseTrendNumber(record[field])).find((value) => value !== null) ?? null + acc.fasting.push(latestValue('fasting_blood_sugar')) + acc.postprandial.push(latestValue('postprandial_blood_sugar')) return acc }, { @@ -754,89 +850,49 @@ function parseTrendNumber(value: unknown): number | null { } function getCell(metric: string, date: string): { value: string; isHigh: boolean; hasRecord: boolean; isPatientSelf?: boolean } { - const blood = bloodByDate.value.get(date) - const diet = dietByDate.value.get(date) - const exercise = exerciseByDate.value.get(date) + const blood = bloodByDate.value.get(date) || [] + const diet = dietByDate.value.get(date) || [] + const exercise = exerciseByDate.value.get(date) || [] const tracking = trackingByDate.value.get(date) - const isPatientSelf = !!blood?.has_patient_self - switch (metric) { - case 'fasting': - return { - value: blood?.fasting_blood_sugar != null && blood.fasting_blood_sugar !== '' ? String(blood.fasting_blood_sugar) : '', - isHigh: isHighFastingBloodSugar(blood?.fasting_blood_sugar, props.age), - hasRecord: !!blood && blood.fasting_blood_sugar != null && blood.fasting_blood_sugar !== '', - isPatientSelf - } - case 'postprandial': - return { - value: blood?.postprandial_blood_sugar != null && blood.postprandial_blood_sugar !== '' ? String(blood.postprandial_blood_sugar) : '', - isHigh: isHighPostprandialBloodSugar(blood?.postprandial_blood_sugar, props.age), - hasRecord: !!blood && blood.postprandial_blood_sugar != null && blood.postprandial_blood_sugar !== '', - isPatientSelf - } - case 'other': - return { - value: blood?.other_blood_sugar != null && blood.other_blood_sugar !== '' ? String(blood.other_blood_sugar) : '', - isHigh: isHighOtherBloodSugar(blood?.other_blood_sugar, props.age), - hasRecord: !!blood && blood.other_blood_sugar != null && blood.other_blood_sugar !== '', - isPatientSelf - } - case 'bp': - return { - value: blood && (blood.systolic_pressure || blood.diastolic_pressure) ? formatBp(blood) : '', - isHigh: isHighBloodPressure(blood), - hasRecord: !!blood && !!(blood.systolic_pressure || blood.diastolic_pressure), - isPatientSelf - } - case 'western': - return { - value: blood?.western_medicine || '', - isHigh: false, - hasRecord: !!blood?.western_medicine - } - case 'insulin': - return { - value: blood?.insulin || '', - isHigh: false, - hasRecord: !!blood?.insulin - } - case 'breakfast': - return { - value: diet?.breakfast || diet?.breakfast_foods ? '✓' : '', - isHigh: false, - hasRecord: !!(diet?.breakfast || diet?.breakfast_foods) - } - case 'lunch': - return { - value: diet?.lunch || diet?.lunch_foods ? '✓' : '', - isHigh: false, - hasRecord: !!(diet?.lunch || diet?.lunch_foods) - } - case 'dinner': - return { - value: diet?.dinner || diet?.dinner_foods ? '✓' : '', - isHigh: false, - hasRecord: !!(diet?.dinner || diet?.dinner_foods) - } - case 'exercise': - return { - value: exercise?.duration ? `${exercise.duration}min` : '', - isHigh: false, - hasRecord: !!exercise?.id - } - case 'tracking': - return { - value: tracking?.content ? summarizeTracking(tracking.content) : '', - isHigh: false, - hasRecord: !!tracking?.content - } - default: - return { value: '', isHigh: false, hasRecord: false } + const bloodFields: Record = { + fasting: 'fasting_blood_sugar', postprandial: 'postprandial_blood_sugar', + other: 'other_blood_sugar', western: 'western_medicine', insulin: 'insulin' } + const field = bloodFields[metric] + if (field || metric === 'bp') { + const rows = blood.filter((record) => metric === 'bp' + ? !!(record.systolic_pressure || record.diastolic_pressure) + : record[field] != null && record[field] !== '') + const isHigh = (record: BloodRecord) => { + if (metric === 'fasting') return isHighFastingBloodSugar(record.fasting_blood_sugar, props.age) + if (metric === 'postprandial') return isHighPostprandialBloodSugar(record.postprandial_blood_sugar, props.age) + if (metric === 'other') return isHighOtherBloodSugar(record.other_blood_sugar, props.age) + return metric === 'bp' && isHighBloodPressure(record) + } + return { + value: rows.map((record) => metric === 'bp' ? formatBp(record) : String(record[field])).join(' / '), + isHigh: rows.some(isHigh), hasRecord: rows.length > 0, + isPatientSelf: rows.some((record) => Number(record.source) === 1) + } + } + if (['breakfast', 'lunch', 'dinner'].includes(metric)) { + const count = diet.filter((record) => record[metric] || record[`${metric}_foods`] || record[`${metric}_images`]?.length).length + return { value: count ? '✓' : '', isHigh: false, hasRecord: count > 0 } + } + if (metric === 'exercise') { + return { value: exercise.map((record) => record.duration ? `${record.duration}min` : record.exercise_type || '已记录').join(' / '), isHigh: false, hasRecord: exercise.length > 0 } + } + if (metric === 'tracking') { + return { value: tracking?.content ? summarizeTracking(tracking.content) : '', isHigh: false, hasRecord: !!tracking?.content } + } + return { value: '', isHigh: false, hasRecord: false } } async function fetchTracking() { - if (!diagnosisId.value) { + const seq = ++requestSeq + const requestDiagnosisId = diagnosisId.value + if (!requestDiagnosisId) { + loading.value = false bloodRecords.value = [] dietRecords.value = [] exerciseRecords.value = [] @@ -845,23 +901,23 @@ async function fetchTracking() { } const { start, end } = currentRange.value if (rangeMode.value === 'custom' && (!start || !end)) return - const seq = ++requestSeq + const requestDates = [...visibleDates.value] loading.value = true try { const data: any = await diagnosisTrackingWindow({ - id: diagnosisId.value, + id: requestDiagnosisId, start_date: start, end_date: end }) const noteData: any = await diagnosisTrackingNotes({ - diagnosis_id: diagnosisId.value + diagnosis_id: requestDiagnosisId }) - if (seq !== requestSeq) return + if (seq !== requestSeq || requestDiagnosisId !== diagnosisId.value) return bloodRecords.value = Array.isArray(data?.blood_records) ? data.blood_records : [] dietRecords.value = Array.isArray(data?.diet_records) ? data.diet_records : [] exerciseRecords.value = Array.isArray(data?.exercise_records) ? data.exercise_records : [] trackingNotes.value = Array.isArray(noteData) - ? noteData.filter((item: any) => visibleDates.value.includes(String(item?.note_date || ''))) + ? noteData.filter((item: any) => requestDates.includes(String(item?.note_date || ''))) : [] } catch { if (seq === requestSeq) { @@ -890,76 +946,83 @@ function handleCustomRangeChange() { } } +function recordTimeKey(value: unknown) { + const time = String(value || '') + return /^\d{2}:\d{2}$/.test(time) ? `${time}:00` : time +} + +function openBloodRecord(source: BloodRecord | undefined, date: string) { + if (!canEditDailyRecord.value) return + bloodForm.value = { ...createBloodForm(date), ...source } + originalBloodTime.value = recordTimeKey(source?.record_time) + bloodDialogVisible.value = true +} + +function openDietRecord(source: DietRecord | undefined, date: string) { + if (!canEditDailyRecord.value) return + dietForm.value = { + ...createDietForm(date), ...source, + breakfast_images: [...(source?.breakfast_images || [])], + lunch_images: [...(source?.lunch_images || [])], dinner_images: [...(source?.dinner_images || [])] + } + dietDialogVisible.value = true +} + +function openExerciseRecord(source: ExerciseRecord | undefined, date: string) { + if (!canEditDailyRecord.value) return + exerciseForm.value = { ...createExerciseForm(date), ...source, images: [...(source?.images || [])] } + exerciseDialogVisible.value = true +} + function openQuickAdd(kind: DialogKind) { if (!canEditDailyRecord.value) return const date = visibleDates.value[0] || formatDate(new Date()) - if (kind === 'blood') { - bloodForm.value = createBloodForm(date) - bloodDialogVisible.value = true - return - } - if (kind === 'diet') { - dietForm.value = createDietForm(date) - dietDialogVisible.value = true - return - } - if (kind === 'tracking') { - const today = todayDate.value - const source = trackingByDate.value.get(today) - trackingForm.value = { - note_date: today, - existing_content: String(source?.content || ''), - content: '' - } - trackingDialogVisible.value = true - return - } - exerciseForm.value = createExerciseForm(date) - exerciseDialogVisible.value = true + if (kind === 'blood') return openBloodRecord(undefined, date) + if (kind === 'diet') return openDietRecord(undefined, date) + if (kind === 'exercise') return openExerciseRecord(undefined, date) + handleCellClick('tracking', todayDate.value) } function handleCellClick(metric: string, date: string) { - if (!canEditDailyRecord.value) return - if (['fasting', 'postprandial', 'other', 'bp', 'western', 'insulin'].includes(metric)) { - const source = bloodByDate.value.get(date) - bloodForm.value = source ? { ...createBloodForm(date), ...source } : createBloodForm(date) - bloodDialogVisible.value = true - return - } - if (['breakfast', 'lunch', 'dinner'].includes(metric)) { - const source = dietByDate.value.get(date) - dietForm.value = source ? { ...createDietForm(date), ...source } : createDietForm(date) - dietDialogVisible.value = true - return - } + if (!isCellClickable(metric, date)) return if (metric === 'tracking') { const source = trackingByDate.value.get(date) - if (!source && date !== todayDate.value) return - trackingForm.value = { - note_date: date, - existing_content: String(source?.content || ''), - content: '' - } + trackingForm.value = { note_date: date, existing_content: String(source?.content || ''), content: '' } trackingDialogVisible.value = true return } - const source = exerciseByDate.value.get(date) - exerciseForm.value = source ? { ...createExerciseForm(date), ...source } : createExerciseForm(date) - exerciseDialogVisible.value = true + const count = (bloodByDate.value.get(date)?.length || 0) + (dietByDate.value.get(date)?.length || 0) + (exerciseByDate.value.get(date)?.length || 0) + if (count) { + detailDate.value = date + dayDetailVisible.value = true + return + } + if (['breakfast', 'lunch', 'dinner'].includes(metric)) return openDietRecord(undefined, date) + if (metric === 'exercise') return openExerciseRecord(undefined, date) + openBloodRecord(undefined, date) } async function submitBlood() { + if (!canEditDailyRecord.value || bloodSubmitting.value) return if (!bloodForm.value.record_date) { feedback.msgWarning('请选择记录日期') return } + const targetDiagnosisId = diagnosisId.value + const payload = { ...bloodForm.value } + if ('record_time_estimated' in payload && recordTimeKey(payload.record_time) !== originalBloodTime.value) { + payload.record_time_estimated = 0 + payload.record_time_period = '' + payload.record_time_text = '' + } bloodSubmitting.value = true try { - if (bloodForm.value.id) { - await bloodRecordEdit(bloodForm.value) + if (payload.id) { + await bloodRecordEdit(payload) } else { - await bloodRecordAdd(bloodForm.value) + await bloodRecordAdd(payload) } + if (targetDiagnosisId !== diagnosisId.value) return bloodDialogVisible.value = false await fetchTracking() } finally { @@ -968,17 +1031,21 @@ async function submitBlood() { } async function submitDiet() { + if (!canEditDailyRecord.value || dietSubmitting.value) return if (!dietForm.value.record_date) { feedback.msgWarning('请选择记录日期') return } + const targetDiagnosisId = diagnosisId.value + const payload = { ...dietForm.value } dietSubmitting.value = true try { - if (dietForm.value.id) { - await dietRecordEdit(dietForm.value) + if (payload.id) { + await dietRecordEdit(payload) } else { - await dietRecordAdd(dietForm.value) + await dietRecordAdd(payload) } + if (targetDiagnosisId !== diagnosisId.value) return dietDialogVisible.value = false await fetchTracking() } finally { @@ -987,6 +1054,7 @@ async function submitDiet() { } async function submitExercise() { + if (!canEditDailyRecord.value || exerciseSubmitting.value) return if (!exerciseForm.value.record_date) { feedback.msgWarning('请选择记录日期') return @@ -999,13 +1067,16 @@ async function submitExercise() { feedback.msgWarning('请输入运动时长') return } + const targetDiagnosisId = diagnosisId.value + const payload = { ...exerciseForm.value } exerciseSubmitting.value = true try { - if (exerciseForm.value.id) { - await exerciseRecordEdit(exerciseForm.value) + if (payload.id) { + await exerciseRecordEdit(payload) } else { - await exerciseRecordAdd(exerciseForm.value) + await exerciseRecordAdd(payload) } + if (targetDiagnosisId !== diagnosisId.value) return exerciseDialogVisible.value = false await fetchTracking() } finally { @@ -1014,6 +1085,7 @@ async function submitExercise() { } async function submitTracking() { + if (trackingSubmitting.value) return if (!trackingForm.value.note_date) { feedback.msgWarning('请选择记录日期') return @@ -1027,12 +1099,14 @@ async function submitTracking() { feedback.msgWarning('请输入跟踪备注') return } + const targetDiagnosisId = diagnosisId.value trackingSubmitting.value = true try { await diagnosisAddTrackingNote({ - diagnosis_id: diagnosisId.value, + diagnosis_id: targetDiagnosisId, tracking_content: content }) + if (targetDiagnosisId !== diagnosisId.value) return trackingDialogVisible.value = false await fetchTracking() } finally { @@ -1057,17 +1131,23 @@ function getTrackingPreview(content: string) { } function isCellClickable(metric: string, date: string) { - if (!canEditDailyRecord.value) return false - if (metric !== 'tracking') return true - return date === todayDate.value || !!getTrackingContent(date) + if (metric === 'tracking') return !!getTrackingContent(date) || (canEditDailyRecord.value && date === todayDate.value) + return canEditDailyRecord.value || getDayRecordCount(metric, date) > 0 } watch( () => props.diagnosisId, - (value) => { - if (value) { - fetchTracking() - } + () => { + dayDetailVisible.value = false + bloodDialogVisible.value = false + dietDialogVisible.value = false + exerciseDialogVisible.value = false + trackingDialogVisible.value = false + bloodRecords.value = [] + dietRecords.value = [] + exerciseRecords.value = [] + trackingNotes.value = [] + void fetchTracking() void nextTick(updateTableWrapWidth) } ) @@ -1081,6 +1161,7 @@ onMounted(() => { }) onUnmounted(() => { + requestSeq++ window.removeEventListener('resize', updateTableWrapWidth) }) @@ -1116,6 +1197,29 @@ defineExpose({ refresh: fetchTracking }) width: 100%; } + &__hint, &__time-evidence { + color: var(--el-text-color-secondary); + font-size: 12px; + line-height: 1.6; + margin: 0 0 12px; + overflow-wrap: anywhere; + } + + &__record-count { color: var(--el-color-primary); font-size: 11px; white-space: nowrap; } + &__detail-section + &__detail-section { margin-top: 24px; } + &__detail-heading { display: flex; justify-content: space-between; align-items: center; gap: 12px; } + &__detail-heading h3 { margin: 0 0 8px; } + &__detail-heading small { color: var(--el-text-color-secondary); font-weight: normal; } + &__detail-record { border: 1px solid var(--el-border-color); border-radius: 8px; padding: 12px; margin-bottom: 12px; } + &__detail-tags { display: flex; gap: 8px; flex-wrap: wrap; margin: 8px 0; } + &__detail-fields { + display: grid; grid-template-columns: 96px minmax(0, 1fr); gap: 8px; margin: 12px 0 0; + dt { color: var(--el-text-color-secondary); } + dd { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; } + } + &__detail-images { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; } + &__detail-images .el-image { width: 64px; height: 64px; border-radius: 4px; } + &__chart { margin-top: 16px; padding: 16px 18px; diff --git a/admin/src/views/tcm/diagnosis/components/FollowupAudioField.vue b/admin/src/views/tcm/diagnosis/components/FollowupAudioField.vue new file mode 100644 index 000000000..bb9bc24fd --- /dev/null +++ b/admin/src/views/tcm/diagnosis/components/FollowupAudioField.vue @@ -0,0 +1,26 @@ + + + diff --git a/admin/src/views/tcm/diagnosis/components/FollowupAudioPanel.vue b/admin/src/views/tcm/diagnosis/components/FollowupAudioPanel.vue new file mode 100644 index 000000000..4ae897adb --- /dev/null +++ b/admin/src/views/tcm/diagnosis/components/FollowupAudioPanel.vue @@ -0,0 +1,412 @@ + + + diff --git a/admin/src/views/tcm/diagnosis/components/FollowupAudioReview.vue b/admin/src/views/tcm/diagnosis/components/FollowupAudioReview.vue new file mode 100644 index 000000000..cc8ffa8b1 --- /dev/null +++ b/admin/src/views/tcm/diagnosis/components/FollowupAudioReview.vue @@ -0,0 +1,233 @@ + + + diff --git a/admin/src/views/tcm/diagnosis/components/followupAudioState.ts b/admin/src/views/tcm/diagnosis/components/followupAudioState.ts new file mode 100644 index 000000000..182598c77 --- /dev/null +++ b/admin/src/views/tcm/diagnosis/components/followupAudioState.ts @@ -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 = { + 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 | 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 { + 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 : '请求失败;当前审核内容已保留,请刷新任务状态后确认' +} diff --git a/admin/src/views/tcm/diagnosis/edit.vue b/admin/src/views/tcm/diagnosis/edit.vue index 3662ed5bf..0f7b45a51 100644 --- a/admin/src/views/tcm/diagnosis/edit.vue +++ b/admin/src/views/tcm/diagnosis/edit.vue @@ -52,7 +52,7 @@

- + + + + +
@@ -785,6 +801,7 @@