feat(followup-audio): normalize extraction to record field contracts

This commit is contained in:
2026-10-12 14:34:31 +08:00
parent 9e3aa9fc61
commit 86041042b9
9 changed files with 449 additions and 21 deletions
@@ -205,8 +205,8 @@
</header>
<dl class="daily-matrix__detail-fields">
<dt>运动</dt><dd>{{ record.exercise_type || '—' }}</dd>
<dt>时长</dt><dd>{{ record.duration ?? '—' }} 分钟</dd>
<dt>强度</dt><dd>{{ record.intensity_text || intensityLabel(record.intensity) }}</dd>
<dt>时长</dt><dd>{{ record.duration == null ? '未记录' : `${record.duration} 分钟` }}</dd>
<dt>强度</dt><dd>{{ intensityLabel(record.intensity) }}</dd>
<dt>备注</dt><dd>{{ record.note || '—' }}</dd>
</dl>
<div v-if="record.images?.length" class="daily-matrix__detail-images">
@@ -343,11 +343,13 @@
<el-option label="其他" value="其他" />
</el-select>
</el-form-item>
<el-form-item label="运动时长(分钟)" required>
<el-input-number v-model="exerciseForm.duration" :min="0" :max="300" />
<el-form-item label="运动时长(分钟)" :required="!exerciseForm.id">
<el-input-number v-model="exerciseForm.duration" :min="0" :max="300" placeholder="未记录" />
<span v-if="exerciseForm.id" class="ml-2 text-info">留空表示未记录</span>
</el-form-item>
<el-form-item label="运动强度" required>
<el-select v-model="exerciseForm.intensity" placeholder="请选择运动强度" class="w-full">
<el-form-item label="运动强度">
<el-select v-model="exerciseIntensity" placeholder="未记录" class="w-full">
<el-option label="未记录" value="" />
<el-option label="低强度" :value="1" />
<el-option label="中强度" :value="2" />
<el-option label="高强度" :value="3" />
@@ -492,8 +494,8 @@ interface ExerciseRecord {
patient_id?: number
record_date?: string
exercise_type?: string
duration?: number
intensity?: number
duration?: number | null
intensity?: number | null
intensity_text?: string
images?: string[]
note?: string
@@ -547,6 +549,11 @@ const trackingSubmitting = ref(false)
const bloodForm = ref<BloodRecord>(createBloodForm())
const dietForm = ref<DietRecord>(createDietForm())
const exerciseForm = ref<ExerciseRecord>(createExerciseForm())
// el-option uses a string sentinel; persisted unknowns remain SQL/JSON null.
const exerciseIntensity = computed<number | ''>({
get: () => exerciseForm.value.intensity ?? '',
set: (value) => { exerciseForm.value.intensity = value === '' ? null : value }
})
const trackingForm = ref({
note_date: '',
existing_content: '',
@@ -719,8 +726,8 @@ function recordTimePeriodLabel(record: BloodRecord) {
return labels[period] || period || '未提供时段'
}
function intensityLabel(value?: number) {
return ({ 1: '低强度', 2: '中强度', 3: '高强度' } as Record<number, string>)[Number(value)] || '—'
function intensityLabel(value?: number | null) {
return ({ 1: '低强度', 2: '中强度', 3: '高强度' } as Record<number, string>)[Number(value)] || '未记录'
}
function bloodDetailFields(record: BloodRecord) {
@@ -880,7 +887,7 @@ function getCell(metric: string, date: string): { value: string; isHigh: boolean
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 }
return { value: exercise.map((record) => record.duration != null ? `${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 }
@@ -970,7 +977,12 @@ function openDietRecord(source: DietRecord | undefined, date: string) {
function openExerciseRecord(source: ExerciseRecord | undefined, date: string) {
if (!canEditDailyRecord.value) return
exerciseForm.value = { ...createExerciseForm(date), ...source, images: [...(source?.images || [])] }
exerciseForm.value = {
...createExerciseForm(date), ...source,
// Existing omissions must not inherit the defaults used only for manual new records.
...(source ? { duration: source.duration ?? null, intensity: source.intensity ?? null } : {}),
images: [...(source?.images || [])]
}
exerciseDialogVisible.value = true
}
@@ -1063,12 +1075,12 @@ async function submitExercise() {
feedback.msgWarning('请选择运动类型')
return
}
if (!exerciseForm.value.duration) {
if (!exerciseForm.value.id && !exerciseForm.value.duration) {
feedback.msgWarning('请输入运动时长')
return
}
const targetDiagnosisId = diagnosisId.value
const payload = { ...exerciseForm.value }
const payload = { ...exerciseForm.value, duration: exerciseForm.value.duration ?? null, intensity: exerciseForm.value.intensity ?? null }
exerciseSubmitting.value = true
try {
if (payload.id) {
@@ -0,0 +1,47 @@
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const test = require('node:test')
const ts = require('typescript')
const vue = require('vue')
const { parse, compileScript, compileTemplate } = require('@vue/compiler-sfc')
const file = process.env.FOLLOWUP_EXERCISE_MATRIX_SOURCE || path.resolve(__dirname, '../src/views/tcm/diagnosis/components/DailyMatrix.vue')
const source = fs.readFileSync(file, 'utf8')
function setup() {
const descriptor = parse(source, {filename:file}).descriptor
const compiled = compileScript(descriptor, {id:'exercise-null'})
const output = ts.transpileModule(compiled.content, {compilerOptions:{target:ts.ScriptTarget.ES2022,module:ts.ModuleKind.CommonJS}}).outputText
const calls=[]; const warnings=[]; const module={exports:{}}
const api={diagnosisTrackingWindow:async()=>({}),diagnosisTrackingNotes:async()=>[],exerciseRecordEdit:async p=>calls.push(p),exerciseRecordAdd:async p=>calls.push(p)}
new Function('require','module','exports',output)(name=>name==='vue'?{...vue,onMounted(){},onUnmounted(){}}:name==='@/utils/perm'?{hasPermission:()=>true}:name==='@/api/tcm'?api:name==='@/utils/feedback'?{default:{msgWarning:m=>warnings.push(m)}}:{default:{}},module,module.exports)
const scope=vue.effectScope()
const matrix=scope.run(()=>module.exports.default.setup({diagnosisId:1,patientId:101,readOnly:false},{expose(){}}))
return {matrix,calls,warnings,dispose:()=>scope.stop()}
}
test('real DailyMatrix edit and submit preserve null duration/intensity',async()=>{
const f=setup(); f.matrix.openExerciseRecord({id:1,record_date:'2026-09-29',exercise_type:'散步',duration:null,intensity:null},'2026-09-29')
assert.equal(f.matrix.exerciseForm.value.duration,null);assert.equal(f.matrix.exerciseForm.value.intensity,null)
f.matrix.exerciseForm.value.note='仅改备注';await f.matrix.submitExercise()
assert.equal(f.calls.length,1);assert.equal(f.calls[0].duration,null);assert.equal(f.calls[0].intensity,null);assert.deepEqual(f.warnings,[]);f.dispose()
})
test('missing keys in existing row do not inherit manual-entry defaults',()=>{
const f=setup();f.matrix.openExerciseRecord({id:2,exercise_type:'散步'},'2026-09-29')
assert.equal(f.matrix.exerciseForm.value.duration,null);assert.equal(f.matrix.exerciseForm.value.intensity,null);f.dispose()
})
test('explicit known and zero values remain unchanged; cleared duration submits null',async()=>{
const f=setup();f.matrix.openExerciseRecord({id:2,exercise_type:'散步',duration:0,intensity:1},'2026-09-29')
await f.matrix.submitExercise();assert.equal(f.calls[0].duration,0);assert.equal(f.calls[0].intensity,1)
f.matrix.exerciseForm.value.duration=undefined;await f.matrix.submitExercise();assert.equal(f.calls[1].duration,null);f.dispose()
})
test('unknown intensity option maps to null without conflating known low intensity',()=>{
const f=setup();f.matrix.openExerciseRecord({id:1,exercise_type:'散步',duration:null,intensity:null},'2026-09-29')
assert.equal(f.matrix.exerciseIntensity.value,'');f.matrix.exerciseIntensity.value=1;assert.equal(f.matrix.exerciseForm.value.intensity,1)
f.matrix.exerciseIntensity.value='';assert.equal(f.matrix.exerciseForm.value.intensity,null)
assert.equal(f.matrix.intensityLabel(null),'未记录');assert.equal(f.matrix.intensityLabel(1),'低强度');f.dispose()
})
test('matrix compiles and detail rendering explicitly distinguishes unknown from zero',()=>{
const descriptor=parse(source,{filename:file}).descriptor;const script=compileScript(descriptor,{id:'exercise-compile'})
const render=compileTemplate({source:descriptor.template.content,filename:file,id:'exercise-compile',compilerOptions:{bindingMetadata:script.bindings}})
assert.deepEqual(render.errors,[]);assert.ok(/<el-option label="未记录" value=""/.test(source), "explicit unknown option exists")
assert.ok(/record\.duration == null \? '未记录'/.test(source), 'null duration renders unknown')
})