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)"
>
@@ -90,6 +94,9 @@
{{ getCell(row.key, date).value }}
+
+ {{ getDayRecordCount(row.key, date) }}条
+
↑
自录
- —
+
+ 查看{{ getDayRecordCount(row.key, date) }}条
+ —
+
@@ -109,6 +119,7 @@
血糖趋势
+
按日展示最新有效血糖值;点击表格查看当天全部原始记录。
+
+ 每张卡片是一条独立记录;相同数值也不会合并。请在对应记录上点击编辑。
+
+
+
血糖血压 · {{ dayBloodRecords.length }}条
+ 新增一条
+
+
+
+
+ {{ recordTimeLabel(record) }} #{{ record.id }}
+ 编辑此条
+
+
+ 估计时间
+ 时段:{{ recordTimePeriodLabel(record) }}
+ 随访录音
+ 患者自录
+
+ 时间原文:{{ record.record_time_text }}
+
+
+ - {{ field.label }}
- {{ field.value }}
+
+
+
+
+
+
+
饮食 · {{ dayDietRecords.length }}条
+ 新增一条
+
+
+
+
+ {{ recordTimeLabel(record) }} #{{ record.id }}
+ 编辑此条
+
+
+
+ - {{ meal.label }}
+ -
+ {{ record[`${meal.key}_foods`] || record[meal.key] || '—' }}
+
+
+
+
+
+ - 备注
- {{ 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 @@
+
+ {{ fieldText(modelValue, field) }}
+ emit('update:modelValue', value ?? null)" />
+ emit('update:modelValue', value)">
+
+
+ emit('update:modelValue', value || null)" />
+ emit('update:modelValue', value)" />
+
+
+
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 @@
+
+
+
+
+
录音只生成建议,不会自动写入。逐项核对后选择采纳,再确认写入;日常记录按日期和时段分别保留。
+
仅当前可见标签页每 5 秒刷新;已编辑内容不会被覆盖。后台分析不受关闭页面影响,未知结果请先核对任务或账单,勿重复上传。
+
任务 #{{ detail.id }} · 版本 {{ detail.version }} · 保留至 {{ formatExpiry(detail.expires_at) }}
+
+
+
+
+
+
+
+
+
+
+ {{ row.file_name }}
+ {{ row.recorded_at }}
+
+
+
+ {{ followupStatusLabels[row.status] || row.status }}
+
+
+ 查看
+
+
+
+ 正在加载录音任务…
+
+
+
{{ detail.file_name }}
通话时间 {{ detail.recorded_at }}
+
{{ followupStatusLabels[detail.status] || detail.status }}
+
+ {{ detail.stage || '任务正在后台处理,可稍后回来查看。' }}
+
+
+
+ 重试已确认失败的任务
+
+
{{ detail.summary || '暂无回访摘要' }}
+
{{ detail.status === 'applied' ? '已写入' : '已选' }} {{ selectedCount }} 项待核实 {{ pendingCount }} 项
+
+
+
+
+
+
{{ audioUrl ? '重新加载录音' : '加载录音播放' }}
+
录音不可播放或已清理
+
+
+ {{ detail.transcript || '暂无转录' }}
+
+
+
+
已选 {{ selectedCount }} 项{{ dirty ? '有未保存修改' : '草稿已同步' }}
+
+ 保存草稿
+ 确认写入
+
+
{{ applyIssue }}
+
+ 本次审核已完成,所选项已写入诊单或日常记录,不能重复写入。
+
+
+
+
+
+ 支持 MP3 / M4A / WAV / AMR,最多 {{ Math.floor(capabilities.limits.max_bytes / 1024 / 1024) }} MB / {{ Math.floor(capabilities.limits.max_seconds / 60) }} 分钟。
+
+
+
+
+
+
+
+
+
+
+
+ {{ uploadStage }}
+ 分析仅生成建议,人工确认后才会写入。
+ 取消上传并分析
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
+ {{ applied ? '审核结果' : '提炼结果' }} · {{ visibleItems.length }} 项跨分类已选 {{ counts.selected }} 项
+ 日常记录仅供查看,当前账号没有编辑权限。
+
+
+
+ toggleSelection(row, !!value)" />
+
+
+
+ {{ field.label }}:{{ fieldText(row.values[field.key], field) }}
+ 暂无有效字段
+ {{ itemKindLabel(row) }} · 共 {{ itemFields(row).length }} 个字段 · 更新已有记录
+ {{ row.record_date || '日期待核实' }} · {{ reviewTimeValue(row.record_time) || row.time_period || '时间待核实' }}{{ row.time_estimated ? '(推定)' : '' }}
+
+
+
+ {{ row.kind === 'diagnosis' ? '—' : row.record_date || '待补充日期' }}{{ reviewTimeValue(row.record_time) || row.time_period || '未提及时间' }}{{ row.time_estimated ? '(推定)' : '' }}
+
+
+ {{ itemStatus(row).label }}
+
+
+ {{ applied || disabled ? '查看' : '核对' }}
+
+
+
+
+ 原文中还需补充的信息
+ - {{ uncertaintyText(value) }}
+
+
+
+ {{ followupKindLabels[editor.kind] }}{{ itemStatus(editor).label }}
+ {{ editorNotice }}
+
+
录音原话
+
{{ quote.text }}播放 {{ timestamp(quote.start_ms) }}
+
未提供原话证据,请人工核实。
+
时间原话:{{ editor.date_text || '未提及日期' }} · {{ editor.time_text || '未提及时间' }}
+
+ 与已有值冲突可能存在重复记录时间由时段推定
+
+ {{ editor.record_date || '未记录日期' }} · {{ reviewTimeValue(editor.record_time) || editor.time_period || '未记录时间' }}{{ editor.target_id ? `更新记录 #${editor.target_id}` : '新增独立记录' }}
+
+
+
+
+
+
+ 日期或目标已更改。请先保存本项,再保存草稿刷新目标当前值,之后重新核对并选择。
+
+
+
+
{{ field.label }}只读
+
{{ fieldText(editor.current_values[field.key], field) }}
+
setValue(field.key, value)" />移除
+
+ addField(value)">
+ confirmReview(!!value)">我已核对原话、冲突 / 重复及日期时间,确认本项内容
+ {{ editorIssue }}
+
+
+
+
+
+
+
+
+
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 @@