feat: add reviewed follow-up audio patient enrichment
This commit is contained in:
@@ -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 })
|
||||
@@ -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<string | number>
|
||||
export type FollowupValues = Record<string, FollowupValue>
|
||||
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<FollowupKind, FollowupField[]>
|
||||
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<T> { 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<T>(response: Envelope<T>): 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<T>(name: string, params: Record<string, number>, signal?: AbortSignal): Promise<T> {
|
||||
return unwrap(await request.get<Envelope<T>>({ url: base + name, params, signal }, options))
|
||||
}
|
||||
async function post<T>(name: string, data: unknown, signal?: AbortSignal): Promise<T> {
|
||||
return unwrap(await request.post<Envelope<T>>({ url: base + name, data, signal, timeout: 120000 }, options))
|
||||
}
|
||||
export const followupAudioCapabilities = (diagnosisId: number, signal?: AbortSignal) => get<FollowupCapabilities>('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<FollowupTaskDetail>('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<Envelope<{ index: number; received: boolean }>>({
|
||||
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<FollowupTaskDetail>('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<Blob> {
|
||||
const blob = await request.get<Blob>({ url: base + 'audio', params: { id }, responseType: 'blob', signal, timeout: 120000 }, options)
|
||||
if (blob.type.includes('json')) {
|
||||
const response: Envelope<unknown> = JSON.parse(await blob.text())
|
||||
unwrap(response)
|
||||
throw new FollowupAudioError('录音响应不是音频文件')
|
||||
}
|
||||
return blob
|
||||
}
|
||||
@@ -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)"
|
||||
>
|
||||
<template v-if="row.key === 'tracking' && getTrackingContent(date)">
|
||||
<el-tooltip placement="top" effect="light" :show-after="150">
|
||||
@@ -90,6 +94,9 @@
|
||||
</template>
|
||||
<template v-else-if="getCell(row.key, date).value">
|
||||
<span>{{ getCell(row.key, date).value }}</span>
|
||||
<span v-if="getDayRecordCount(row.key, date) > 1" class="daily-matrix__record-count">
|
||||
{{ getDayRecordCount(row.key, date) }}条
|
||||
</span>
|
||||
<span v-if="getCell(row.key, date).isHigh" class="daily-matrix__cell-up">↑</span>
|
||||
<el-tooltip
|
||||
v-if="getCell(row.key, date).isPatientSelf"
|
||||
@@ -100,7 +107,10 @@
|
||||
<span class="daily-matrix__cell-patient">自录</span>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
<template v-else>—</template>
|
||||
<template v-else>
|
||||
<span v-if="getDayRecordCount(row.key, date) > 0" class="daily-matrix__record-count">查看{{ getDayRecordCount(row.key, date) }}条</span>
|
||||
<span v-else>—</span>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@@ -109,6 +119,7 @@
|
||||
|
||||
<div class="daily-matrix__chart">
|
||||
<div class="daily-matrix__section-title">血糖趋势</div>
|
||||
<p class="daily-matrix__hint">按日展示最新有效血糖值;点击表格查看当天全部原始记录。</p>
|
||||
<v-charts
|
||||
v-if="bloodTrendHasData"
|
||||
class="daily-matrix__chart-canvas"
|
||||
@@ -128,6 +139,84 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<el-drawer v-model="dayDetailVisible" :title="`${detailDate} 日常记录明细`" size="min(640px, 100vw)">
|
||||
<p class="daily-matrix__hint">每张卡片是一条独立记录;相同数值也不会合并。请在对应记录上点击编辑。</p>
|
||||
<section class="daily-matrix__detail-section">
|
||||
<div class="daily-matrix__detail-heading">
|
||||
<h3>血糖血压 · {{ dayBloodRecords.length }}条</h3>
|
||||
<el-button v-if="canEditDailyRecord" link type="primary" @click="openBloodRecord(undefined, detailDate)">新增一条</el-button>
|
||||
</div>
|
||||
<el-empty v-if="!dayBloodRecords.length" description="当天暂无血糖血压记录" :image-size="48" />
|
||||
<article v-for="record in dayBloodRecords" :key="record.id" class="daily-matrix__detail-record">
|
||||
<header class="daily-matrix__detail-heading">
|
||||
<strong>{{ recordTimeLabel(record) }} <small>#{{ record.id }}</small></strong>
|
||||
<el-button v-if="canEditDailyRecord" link type="primary" @click="openBloodRecord(record, detailDate)">编辑此条</el-button>
|
||||
</header>
|
||||
<div class="daily-matrix__detail-tags">
|
||||
<el-tag v-if="Number(record.record_time_estimated) === 1" type="warning" size="small">估计时间</el-tag>
|
||||
<el-tag v-if="record.record_time_period" type="info" size="small">时段:{{ recordTimePeriodLabel(record) }}</el-tag>
|
||||
<el-tag v-if="record.followup_audio_task_id" size="small">随访录音</el-tag>
|
||||
<span v-if="Number(record.source) === 1" class="daily-matrix__cell-patient">患者自录</span>
|
||||
</div>
|
||||
<p v-if="record.record_time_text" class="daily-matrix__time-evidence">时间原文:{{ record.record_time_text }}</p>
|
||||
<dl class="daily-matrix__detail-fields">
|
||||
<template v-for="field in bloodDetailFields(record)" :key="field.label">
|
||||
<dt>{{ field.label }}</dt><dd>{{ field.value }}</dd>
|
||||
</template>
|
||||
</dl>
|
||||
</article>
|
||||
</section>
|
||||
<section class="daily-matrix__detail-section">
|
||||
<div class="daily-matrix__detail-heading">
|
||||
<h3>饮食 · {{ dayDietRecords.length }}条</h3>
|
||||
<el-button v-if="canEditDailyRecord" link type="primary" @click="openDietRecord(undefined, detailDate)">新增一条</el-button>
|
||||
</div>
|
||||
<el-empty v-if="!dayDietRecords.length" description="当天暂无饮食记录" :image-size="48" />
|
||||
<article v-for="record in dayDietRecords" :key="record.id" class="daily-matrix__detail-record">
|
||||
<header class="daily-matrix__detail-heading">
|
||||
<strong>{{ recordTimeLabel(record) }} <small>#{{ record.id }}</small></strong>
|
||||
<el-button v-if="canEditDailyRecord" link type="primary" @click="openDietRecord(record, detailDate)">编辑此条</el-button>
|
||||
</header>
|
||||
<dl class="daily-matrix__detail-fields">
|
||||
<template v-for="meal in mealFields" :key="meal.key">
|
||||
<dt>{{ meal.label }}</dt>
|
||||
<dd>
|
||||
{{ record[`${meal.key}_foods`] || record[meal.key] || '—' }}
|
||||
<div v-if="record[`${meal.key}_images`]?.length" class="daily-matrix__detail-images">
|
||||
<el-image v-for="(url, index) in record[`${meal.key}_images`]" :key="`${url}-${index}`"
|
||||
:src="url" :preview-src-list="record[`${meal.key}_images`]" :initial-index="index" preview-teleported fit="cover" />
|
||||
</div>
|
||||
</dd>
|
||||
</template>
|
||||
<dt>备注</dt><dd>{{ record.note || '—' }}</dd>
|
||||
</dl>
|
||||
</article>
|
||||
</section>
|
||||
<section class="daily-matrix__detail-section">
|
||||
<div class="daily-matrix__detail-heading">
|
||||
<h3>运动 · {{ dayExerciseRecords.length }}条</h3>
|
||||
<el-button v-if="canEditDailyRecord" link type="primary" @click="openExerciseRecord(undefined, detailDate)">新增一条</el-button>
|
||||
</div>
|
||||
<el-empty v-if="!dayExerciseRecords.length" description="当天暂无运动记录" :image-size="48" />
|
||||
<article v-for="record in dayExerciseRecords" :key="record.id" class="daily-matrix__detail-record">
|
||||
<header class="daily-matrix__detail-heading">
|
||||
<strong>{{ recordTimeLabel(record) }} <small>#{{ record.id }}</small></strong>
|
||||
<el-button v-if="canEditDailyRecord" link type="primary" @click="openExerciseRecord(record, detailDate)">编辑此条</el-button>
|
||||
</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.note || '—' }}</dd>
|
||||
</dl>
|
||||
<div v-if="record.images?.length" class="daily-matrix__detail-images">
|
||||
<el-image v-for="(url, index) in record.images" :key="`${url}-${index}`" :src="url"
|
||||
:preview-src-list="record.images" :initial-index="index" preview-teleported fit="cover" />
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
</el-drawer>
|
||||
|
||||
<el-dialog v-model="bloodDialogVisible" :title="bloodDialogTitle" width="600px" :close-on-click-modal="false">
|
||||
<el-form :model="bloodForm" label-width="120px">
|
||||
<el-form-item label="记录日期" required>
|
||||
@@ -148,6 +237,12 @@
|
||||
class="w-full"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="Number(bloodForm.record_time_estimated) === 1" label="时间说明">
|
||||
<div class="daily-matrix__time-evidence">
|
||||
估计时间 · {{ recordTimePeriodLabel(bloodForm) }};原文:{{ bloodForm.record_time_text || '未提供' }}。
|
||||
手动修改记录时间后会清除估计标记;只改数值则保留来源。
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="空腹血糖">
|
||||
<el-input-number v-model="bloodForm.fasting_blood_sugar" :precision="2" :step="0.1" :min="0" :max="50" />
|
||||
<span class="ml-2">mmol/L</span>
|
||||
@@ -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<HTMLElement | null>(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<string, BloodRecord>()
|
||||
for (const record of bloodRecords.value) {
|
||||
// Group actual rows, never a synthetic record bearing the first row's id.
|
||||
function groupRecordsByDate<T extends { record_date?: string }>(records: T[]): Map<string, T[]> {
|
||||
const map = new Map<string, T[]>()
|
||||
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<keyof BloodRecord> = [
|
||||
'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<string, DietRecord>()
|
||||
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<string, ExerciseRecord>()
|
||||
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<string, string> = {
|
||||
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, string>)[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<string, TrackingNoteRecord>()
|
||||
@@ -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<string, keyof BloodRecord> = {
|
||||
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;
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
<template>
|
||||
<span v-if="readonly" class="readonly-value">{{ fieldText(modelValue, field) }}</span>
|
||||
<el-input-number v-else-if="field.type === 'number'" :model-value="numberValue" :disabled="disabled" :controls="false" @update:model-value="(value: number | undefined | null) => emit('update:modelValue', value ?? null)" />
|
||||
<el-select v-else-if="field.type === 'select' || field.type === 'multiselect'" :model-value="selectValue" :multiple="field.type === 'multiselect'" :disabled="disabled" clearable filterable @update:model-value="(value: FollowupValue) => emit('update:modelValue', value)">
|
||||
<el-option v-for="option in field.options || []" :key="String(option.value)" :label="option.label" :value="option.value" />
|
||||
</el-select>
|
||||
<el-date-picker v-else-if="field.type === 'date'" :model-value="textValue" type="date" value-format="YYYY-MM-DD" :disabled="disabled" @update:model-value="(value: string | null) => emit('update:modelValue', value || null)" />
|
||||
<el-input v-else :model-value="textValue" type="textarea" :autosize="{ minRows: 1, maxRows: 5 }" :disabled="disabled" @update:model-value="(value: FollowupValue) => emit('update:modelValue', value)" />
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { FollowupField, FollowupValue } from '@/api/followupAudio'
|
||||
import { fieldText } from './followupAudioState'
|
||||
const props = defineProps<{ field: FollowupField; modelValue?: FollowupValue; disabled?: boolean; readonly?: boolean }>()
|
||||
const emit = defineEmits<{ (event: 'update:modelValue', value: FollowupValue): void }>()
|
||||
const numberValue = computed(() => props.modelValue === null || props.modelValue === undefined || props.modelValue === '' ? undefined : Number(props.modelValue))
|
||||
const textValue = computed(() => props.modelValue === null || props.modelValue === undefined ? '' : String(props.modelValue))
|
||||
const selectValue = computed(() => {
|
||||
const canonical = (value: string | number) => props.field.options?.find(option => String(option.value) === String(value))?.value ?? value
|
||||
if (props.field.type === 'multiselect') return Array.isArray(props.modelValue) ? props.modelValue.map(canonical) : []
|
||||
return typeof props.modelValue === 'string' || typeof props.modelValue === 'number' ? canonical(props.modelValue) : ''
|
||||
})
|
||||
</script>
|
||||
<style scoped>
|
||||
.readonly-value { white-space: pre-wrap; overflow-wrap: anywhere; line-height: 1.6; }
|
||||
</style>
|
||||
@@ -0,0 +1,412 @@
|
||||
<template>
|
||||
<section v-if="capabilities.enabled" class="followup-panel" aria-label="回访录音">
|
||||
<div class="panel-toolbar">
|
||||
<div class="toolbar-title">
|
||||
<el-button v-if="selectedId" link type="primary" :disabled="busy" @click="backToList">返回列表</el-button>
|
||||
<h3 v-else>回访录音 <span>{{ tasks.length }}</span></h3>
|
||||
</div>
|
||||
<div class="toolbar-actions">
|
||||
<el-button link :aria-expanded="helpVisible" @click="helpVisible = !helpVisible">使用说明</el-button>
|
||||
<el-button :loading="loading" :disabled="busy" @click="refresh">刷新</el-button>
|
||||
<el-button v-if="!selectedId && !viewOnly && capabilities.can_upload" type="primary" :disabled="!canUpload || busy || createUncertain" @click="openUpload">上传录音</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="helpVisible" class="help-box">
|
||||
<p>录音只生成建议,不会自动写入。逐项核对后选择采纳,再确认写入;日常记录按日期和时段分别保留。</p>
|
||||
<p>仅当前可见标签页每 5 秒刷新;已编辑内容不会被覆盖。后台分析不受关闭页面影响,未知结果请先核对任务或账单,勿重复上传。</p>
|
||||
<p v-if="detail">任务 #{{ detail.id }} · 版本 {{ detail.version }} · 保留至 {{ formatExpiry(detail.expires_at) }}</p>
|
||||
</div>
|
||||
<el-alert v-if="!capabilities.audio_verified" type="warning" :closable="false" title="当前环境尚未通过音频能力验证,暂不可上传或重试。已有任务可查看。" />
|
||||
<el-alert v-else-if="capabilities.can_upload && !capabilities.models.length" type="warning" :closable="false" title="暂无可用的分析模型,暂不可上传。已有任务可查看。" />
|
||||
<el-alert v-if="basicDirty" type="warning" :closable="false" title="病历有未保存修改,请先保存病历,再写入录音建议。" />
|
||||
<el-alert v-if="createUncertain" type="warning" :closable="false" title="创建结果未确认,已停止重复提交。请刷新列表并联系管理员核对,不要重新上传同一录音。" />
|
||||
<el-alert v-if="errorMessage" type="error" :closable="false" :title="errorMessage" show-icon />
|
||||
<div v-if="!selectedId" class="task-list">
|
||||
<el-table :data="tasks" max-height="440" size="small" table-layout="fixed" empty-text="暂无录音,上传后可提炼回访资料" aria-label="录音任务列表">
|
||||
<el-table-column label="录音" min-width="210">
|
||||
<template #default="{ row }">
|
||||
<el-button class="filename-button" link type="primary" :disabled="busy" @click="openTask(row.id)">{{ row.file_name }}</el-button>
|
||||
<div class="task-time">{{ row.recorded_at }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="145">
|
||||
<template #default="{ row }"><el-tag size="small" :type="statusType(row.status)">{{ followupStatusLabels[row.status] || row.status }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="65" align="right">
|
||||
<template #default="{ row }"><el-button link type="primary" :disabled="busy" @click="openTask(row.id)">查看</el-button></template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
<div v-else-if="!detail" class="detail-loading" role="status">正在加载录音任务…</div>
|
||||
<section v-if="selectedId && detail" class="task-detail" aria-label="录音任务详情">
|
||||
<div class="detail-heading">
|
||||
<div class="recording-title"><h3>{{ detail.file_name }}</h3><span>通话时间 {{ detail.recorded_at }}</span></div>
|
||||
<el-tag size="small" :type="statusType(detail.status)">{{ followupStatusLabels[detail.status] || detail.status }}</el-tag>
|
||||
</div>
|
||||
<p v-if="detail.status === 'running' || detail.status === 'queued'" class="processing-state" role="status">{{ detail.stage || '任务正在后台处理,可稍后回来查看。' }}</p>
|
||||
<el-alert v-if="detail.error_message" type="warning" :closable="false" :title="detail.error_message" />
|
||||
<el-alert v-if="detail.status === 'needs_reconciliation'" type="error" :closable="false" title="上游结果待核对,禁止重发。请管理员先确认上游任务或账单。" />
|
||||
<el-alert v-if="detail.status === 'expired'" type="warning" :closable="false" title="录音和转录已过保留期,不能继续写入;已采纳的业务记录保留。" />
|
||||
<el-button v-if="detail.can_retry && detail.status === 'failed' && !viewOnly" class="retry-button" :disabled="!canUpload || busy" @click="retryTask">重试已确认失败的任务</el-button>
|
||||
<div class="summary-box">
|
||||
<p>{{ detail.summary || '暂无回访摘要' }}</p>
|
||||
<div class="summary-counts"><span>{{ detail.status === 'applied' ? '已写入' : '已选' }} <strong>{{ selectedCount }}</strong> 项</span><span>待核实 <strong>{{ pendingCount }}</strong> 项</span></div>
|
||||
</div>
|
||||
<FollowupAudioReview :key="selectedId" ref="reviewRef" v-model:items="reviewItems" :fields="detail.fields || capabilities.fields" :disabled="!canEditReview || busy" :can-daily="capabilities.can_daily" :applied="detail.status === 'applied'" :uncertainties="detail.uncertainties" :snapshot-items="detail.items" @seek="loadAudio" />
|
||||
<el-collapse v-model="detailSections" class="original-collapse">
|
||||
<el-collapse-item title="录音原文" name="original">
|
||||
<div class="audio-player">
|
||||
<el-button v-if="detail.audio_available" size="small" :loading="audioLoading" @click="reloadAudio">{{ audioUrl ? '重新加载录音' : '加载录音播放' }}</el-button>
|
||||
<span v-else class="muted">录音不可播放或已清理</span>
|
||||
<audio v-if="audioUrl" ref="audioElement" :src="audioUrl" controls preload="metadata" />
|
||||
</div>
|
||||
<pre class="transcript-text">{{ detail.transcript || '暂无转录' }}</pre>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
<div v-if="detail.status === 'review'" class="review-actions">
|
||||
<div class="selection-summary"><strong>已选 {{ selectedCount }} 项</strong><span>{{ dirty ? '有未保存修改' : '草稿已同步' }}</span></div>
|
||||
<div v-if="canEditReview" class="action-buttons">
|
||||
<el-button :disabled="busy" :loading="mutation === 'save'" @click="saveDraft">保存草稿</el-button>
|
||||
<el-button type="primary" :disabled="busy || basicDirty || !selectedCount || !!applyIssue" :loading="mutation === 'apply'" @click="applySelected">确认写入</el-button>
|
||||
</div>
|
||||
<p v-if="applyIssue" class="review-error" role="alert">{{ applyIssue }}</p>
|
||||
</div>
|
||||
<p v-if="detail.status === 'applied'" class="applied-note">本次审核已完成,所选项已写入诊单或日常记录,不能重复写入。</p>
|
||||
</section>
|
||||
<el-dialog v-model="uploadVisible" title="上传回访录音" width="min(520px, calc(100vw - 32px))" append-to-body :close-on-click-modal="false" :close-on-press-escape="!uploading" :show-close="!uploading" :before-close="closeUpload">
|
||||
<el-form label-position="top" class="upload-form">
|
||||
<el-form-item label="录音文件">
|
||||
<input ref="fileInput" type="file" accept="audio/mpeg,audio/mp4,audio/wav,audio/amr,.mp3,.m4a,.wav,.amr" :disabled="!canUpload || busy || createUncertain" aria-label="录音文件" @change="chooseFile" />
|
||||
<small>支持 MP3 / M4A / WAV / AMR,最多 {{ Math.floor(capabilities.limits.max_bytes / 1024 / 1024) }} MB / {{ Math.floor(capabilities.limits.max_seconds / 60) }} 分钟。</small>
|
||||
</el-form-item>
|
||||
<el-form-item label="实际通话时间(北京时间)">
|
||||
<el-date-picker v-model="recordedAt" type="datetime" value-format="YYYY-MM-DD HH:mm:ss" placeholder="用于识别昨天、前天等相对日期" :disabled="!canUpload || busy || createUncertain" />
|
||||
</el-form-item>
|
||||
<el-form-item label="分析模型">
|
||||
<el-select v-model="modelKey" :disabled="!canUpload || busy || createUncertain" placeholder="选择已配置的模型"><el-option v-for="model in capabilities.models" :key="model.value" :label="model.label" :value="model.value" /></el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<el-alert v-if="errorMessage" type="error" :closable="false" :title="errorMessage" show-icon />
|
||||
<el-alert v-if="createUncertain && !uploading" type="warning" :closable="false" title="创建结果未确认,请关闭弹窗后刷新任务列表并联系管理员核对,不要重新上传。" />
|
||||
<el-progress v-if="uploading" :percentage="uploadProgress" />
|
||||
<p v-if="uploadStage" class="upload-stage" role="status">{{ uploadStage }}</p>
|
||||
<p class="muted">分析仅生成建议,人工确认后才会写入。</p>
|
||||
<template #footer><el-button :disabled="uploading" @click="closeUpload()">取消</el-button><el-button type="primary" :disabled="!canUpload || busy || createUncertain" :loading="uploading" @click="submitUpload">上传并分析</el-button></template>
|
||||
</el-dialog>
|
||||
</section>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
FollowupAudioError, followupAudioApply, followupAudioBlob, followupAudioCreate,
|
||||
followupAudioDetail, followupAudioLists, followupAudioRetry, followupAudioSaveDraft,
|
||||
followupAudioUploadChunk, followupAudioUploadComplete, followupAudioUploadSession,
|
||||
type FollowupCapabilities, type FollowupReviewItem, type FollowupTaskDetail, type FollowupTaskSummary
|
||||
} from '@/api/followupAudio'
|
||||
import FollowupAudioReview from './FollowupAudioReview.vue'
|
||||
import { changedReviewTarget, cloneReviewItems, createFollowupScope, followupError, followupStatusLabels, rebaseReviewItems, reviewFieldIssue, reviewIssue, reviewPending, uploadChunks, uploadIssue } from './followupAudioState'
|
||||
const props = withDefaults(defineProps<{
|
||||
diagnosisId: number; viewOnly?: boolean; active?: boolean; capabilities: FollowupCapabilities;
|
||||
basicDirty?: boolean; beforeApply?: () => boolean
|
||||
}>(), { viewOnly: false, active: true, basicDirty: false })
|
||||
const emit = defineEmits<{ (event: 'applied', diagnosisId: number): void }>()
|
||||
const tasks = ref<FollowupTaskSummary[]>([])
|
||||
const detail = ref<FollowupTaskDetail | null>(null)
|
||||
const reviewItems = ref<FollowupReviewItem[]>([])
|
||||
const selectedId = ref(0)
|
||||
const snapshot = ref('[]')
|
||||
const reviewRef = ref<{ hasUnsavedChanges: () => boolean } | null>(null)
|
||||
const dirty = computed(() => JSON.stringify(reviewItems.value) !== snapshot.value || !!reviewRef.value?.hasUnsavedChanges())
|
||||
const uploadVisible = ref(false)
|
||||
const helpVisible = ref(false)
|
||||
const detailSections = ref<string[]>([])
|
||||
const file = ref<File | null>(null)
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
const recordedAt = ref('')
|
||||
const modelKey = ref(props.capabilities.models[0]?.value || '')
|
||||
const loading = ref(false)
|
||||
const uploading = ref(false)
|
||||
const uploadProgress = ref(0)
|
||||
const uploadStage = ref('')
|
||||
const createUncertain = ref(false)
|
||||
const mutation = ref<'save' | 'apply' | 'retry' | ''>('')
|
||||
const errorMessage = ref('')
|
||||
const audioUrl = ref('')
|
||||
const audioElement = ref<HTMLAudioElement | null>(null)
|
||||
const audioLoading = ref(false)
|
||||
const documentVisible = ref(typeof document === 'undefined' || document.visibilityState !== 'hidden')
|
||||
const busy = computed(() => uploading.value || !!mutation.value)
|
||||
const canUpload = computed(() => !props.viewOnly && props.capabilities.enabled && props.capabilities.audio_verified && props.capabilities.can_upload && props.capabilities.models.length > 0)
|
||||
const canEditReview = computed(() => !props.viewOnly && props.capabilities.enabled && props.capabilities.can_apply && detail.value?.status === 'review')
|
||||
const selectedCount = computed(() => reviewItems.value.filter(item => item.selected).length)
|
||||
const pendingCount = computed(() => reviewItems.value.filter(item => reviewPending(item, detail.value?.fields || props.capabilities.fields, props.capabilities.can_daily)).length + (detail.value?.uncertainties.length || 0))
|
||||
const applyIssue = computed(() => changedReviewTarget(reviewItems.value, detail.value?.items || [])
|
||||
? '写入目标或记录日期已更改,请先保存审核草稿取得新快照,再核对并采纳'
|
||||
: reviewItems.value.map(item => reviewFieldIssue(item, detail.value?.fields || props.capabilities.fields) || reviewIssue(item, props.capabilities.can_daily)).find(Boolean) || '')
|
||||
const effectiveActive = computed(() => props.active && props.capabilities.enabled && documentVisible.value)
|
||||
const scope = createFollowupScope()
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
let pollRunning = false
|
||||
let audioRequest = 0
|
||||
let lastDiagnosis = 0
|
||||
function clearTimer() { if (timer !== undefined) clearTimeout(timer); timer = undefined }
|
||||
function releaseAudio() {
|
||||
audioRequest++
|
||||
audioElement.value?.pause()
|
||||
if (audioUrl.value) URL.revokeObjectURL(audioUrl.value)
|
||||
audioUrl.value = ''; audioLoading.value = false
|
||||
}
|
||||
function stop() {
|
||||
scope.invalidate(); clearTimer(); releaseAudio()
|
||||
loading.value = false; uploading.value = false; mutation.value = ''; pollRunning = false
|
||||
}
|
||||
function installDetail(fresh: FollowupTaskDetail, preserve: boolean) {
|
||||
detail.value = fresh
|
||||
reviewItems.value = preserve ? rebaseReviewItems(reviewItems.value, fresh.items) : cloneReviewItems(fresh.items)
|
||||
snapshot.value = JSON.stringify(fresh.items)
|
||||
}
|
||||
async function readDetail(id: number, preserve = false) {
|
||||
const ticket = scope.capture()
|
||||
const fresh = await followupAudioDetail(id, ticket.signal)
|
||||
if (!scope.current(ticket) || id !== selectedId.value || (detail.value && fresh.version < detail.value.version)) return
|
||||
const newlyApplied = detail.value?.status === 'review' && fresh.status === 'applied'
|
||||
installDetail(fresh, preserve)
|
||||
if (newlyApplied) emit('applied', props.diagnosisId)
|
||||
if (preserve) errorMessage.value = '服务端记录或版本已变化;已保留你的字段与日期修改,并取消所有选择。请重新核对当前值后选择采纳。'
|
||||
}
|
||||
async function refresh() {
|
||||
if (!effectiveActive.value || !props.diagnosisId || pollRunning || busy.value) return
|
||||
const ticket = scope.capture()
|
||||
pollRunning = true; loading.value = true; clearTimer()
|
||||
try {
|
||||
const response = await followupAudioLists(props.diagnosisId, ticket.signal)
|
||||
if (!scope.current(ticket)) return
|
||||
tasks.value = response.items
|
||||
const updated = tasks.value.find(task => task.id === selectedId.value)
|
||||
if (updated && (!detail.value || updated.version > detail.value.version || (updated.version >= detail.value.version && updated.status !== detail.value.status) || updated.status === 'running')) {
|
||||
await readDetail(updated.id, dirty.value)
|
||||
}
|
||||
} catch (error) { if (scope.current(ticket)) errorMessage.value = followupError(error) }
|
||||
finally {
|
||||
if (scope.current(ticket)) {
|
||||
pollRunning = false; loading.value = false
|
||||
if (effectiveActive.value) timer = setTimeout(() => { void refresh() }, 5000)
|
||||
}
|
||||
}
|
||||
}
|
||||
async function confirmLeave(): Promise<boolean> {
|
||||
if (busy.value) { ElMessage.warning('正在上传或提交审核,请等待本次操作结束'); return false }
|
||||
if (!dirty.value) return true
|
||||
try {
|
||||
await ElMessageBox.confirm('审核修改尚未保存。关闭诊单或切换录音将丢失这些修改,是否继续?', '保留审核修改', { type: 'warning', confirmButtonText: '继续离开', cancelButtonText: '返回保存' })
|
||||
return true
|
||||
} catch { return false }
|
||||
}
|
||||
async function openTask(id: number) {
|
||||
if (id === selectedId.value || busy.value) return
|
||||
const previous = scope.capture()
|
||||
if (!await confirmLeave() || !scope.current(previous)) return
|
||||
stop(); selectedId.value = id; detail.value = null; reviewItems.value = []; snapshot.value = '[]'; errorMessage.value = ''; detailSections.value = []
|
||||
const ticket = scope.capture()
|
||||
try { await readDetail(id) } catch (error) { if (scope.current(ticket)) errorMessage.value = followupError(error) }
|
||||
if (scope.current(ticket)) await refresh()
|
||||
}
|
||||
async function backToList() {
|
||||
const ticket = scope.capture()
|
||||
if (!await confirmLeave() || !scope.current(ticket)) return
|
||||
stop(); selectedId.value = 0; detail.value = null; reviewItems.value = []; snapshot.value = '[]'; detailSections.value = []; errorMessage.value = ''
|
||||
await refresh()
|
||||
}
|
||||
function openUpload() { if (canUpload.value && !busy.value && !createUncertain.value) uploadVisible.value = true }
|
||||
function closeUpload(done?: () => void) { if (uploading.value) return; uploadVisible.value = false; done?.() }
|
||||
function statusType(status: string) {
|
||||
if (status === 'failed' || status === 'needs_reconciliation') return 'danger'
|
||||
if (status === 'applied') return 'success'
|
||||
if (status === 'review') return 'warning'
|
||||
return 'info'
|
||||
}
|
||||
function chooseFile(event: Event) { file.value = (event.target as HTMLInputElement).files?.[0] || null; uploadProgress.value = 0; uploadStage.value = '' }
|
||||
async function submitUpload() {
|
||||
if (busy.value || createUncertain.value) return
|
||||
const problem = uploadIssue(file.value, recordedAt.value, modelKey.value, props.capabilities, props.viewOnly)
|
||||
if (problem) { ElMessage.warning(problem); return }
|
||||
const recording = file.value
|
||||
if (!recording) return
|
||||
const ticket = scope.capture()
|
||||
uploading.value = true; uploadProgress.value = 0; errorMessage.value = ''; clearTimer()
|
||||
let creating = false
|
||||
try {
|
||||
uploadStage.value = '正在创建私有上传会话'
|
||||
const session = await followupAudioUploadSession({ diagnosis_id: props.diagnosisId, file_name: recording.name, total_bytes: recording.size }, ticket.signal)
|
||||
if (!scope.current(ticket)) return
|
||||
uploadStage.value = '正在上传录音分片'
|
||||
const finished = await uploadChunks(recording, session.chunk_bytes,
|
||||
(index, chunk) => followupAudioUploadChunk(session.upload_id, index, chunk, ticket.signal),
|
||||
() => scope.current(ticket), percent => { uploadProgress.value = percent })
|
||||
if (!finished) return
|
||||
uploadStage.value = '正在校验录音时长和完整性'
|
||||
await followupAudioUploadComplete(session.upload_id, ticket.signal)
|
||||
if (!scope.current(ticket)) return
|
||||
uploadStage.value = '正在创建分析任务'; creating = true; createUncertain.value = true
|
||||
const result = await followupAudioCreate({ diagnosis_id: props.diagnosisId, upload_id: session.upload_id, recorded_at: recordedAt.value, model_key: modelKey.value }, ticket.signal)
|
||||
if (!scope.current(ticket)) return
|
||||
createUncertain.value = false; uploading.value = false; uploadVisible.value = false; file.value = null; if (fileInput.value) fileInput.value.value = ''
|
||||
uploadStage.value = result.reused
|
||||
? result.reuse_message || '同一录音已有任务,已打开原任务,不会重复调用模型'
|
||||
: '任务已创建,后台将进行分析;审核前不会写入业务记录'
|
||||
if (result.reused) ElMessage.warning(uploadStage.value)
|
||||
await openTask(result.task_id)
|
||||
} catch (error) {
|
||||
if (scope.current(ticket)) { errorMessage.value = followupError(error); createUncertain.value = creating && !(error instanceof FollowupAudioError); uploadStage.value = '本次操作已停止,未自动重试' }
|
||||
} finally { if (scope.current(ticket)) { uploading.value = false; await refresh() } }
|
||||
}
|
||||
async function handleReviewError(error: unknown) {
|
||||
const ticket = scope.capture()
|
||||
errorMessage.value = followupError(error)
|
||||
const stale = error instanceof FollowupAudioError && error.code === 'FOLLOWUP_AUDIO_STALE_REVIEW'
|
||||
if (stale || /STALE_REVIEW|版本|快照|已变化/.test(errorMessage.value)) {
|
||||
try { await readDetail(selectedId.value, true) } catch { if (scope.current(ticket)) errorMessage.value += ';刷新失败,当前编辑仍已保留,请稍后刷新。' }
|
||||
}
|
||||
}
|
||||
async function saveDraft() {
|
||||
if (!canEditReview.value || !detail.value || busy.value) return
|
||||
if (reviewRef.value?.hasUnsavedChanges()) { ElMessage.warning('请先保存或取消正在核对的单项修改'); return }
|
||||
const ticket = scope.capture(); mutation.value = 'save'; errorMessage.value = ''; clearTimer()
|
||||
try {
|
||||
const saved = await followupAudioSaveDraft({ id: detail.value.id, version: detail.value.version, items: cloneReviewItems(reviewItems.value) }, ticket.signal)
|
||||
if (!scope.current(ticket)) return
|
||||
if (saved.review_refreshed) {
|
||||
installDetail(saved, true)
|
||||
errorMessage.value = '业务记录已变化,版本已刷新;当前编辑已保留,全部选择已取消,请重新核对后保存或采纳。'
|
||||
} else {
|
||||
installDetail(saved, false); ElMessage.success('审核草稿已保存,尚未写入病历')
|
||||
}
|
||||
} catch (error) { if (scope.current(ticket)) await handleReviewError(error) }
|
||||
finally { if (scope.current(ticket)) { mutation.value = ''; await refresh() } }
|
||||
}
|
||||
async function applySelected() {
|
||||
if (!canEditReview.value || !detail.value || busy.value || !selectedCount.value) return
|
||||
if (reviewRef.value?.hasUnsavedChanges()) { ElMessage.warning('请先保存或取消正在核对的单项修改'); return }
|
||||
if (props.basicDirty || (props.beforeApply && !props.beforeApply())) { ElMessage.warning('请先保存病历表单,当前修改不会被覆盖'); return }
|
||||
if (applyIssue.value) { ElMessage.warning(applyIssue.value); return }
|
||||
const ticket = scope.capture()
|
||||
try { await ElMessageBox.confirm(`将一次性采纳 ${selectedCount.value} 项;任一项冲突则整批不写入。确认已核对原话、日期和时间?`, '确认采纳', { type: 'warning', confirmButtonText: '全部采纳', cancelButtonText: '返回核对' }) } catch { return }
|
||||
if (!scope.current(ticket) || props.basicDirty || (props.beforeApply && !props.beforeApply()) || !detail.value) return
|
||||
const diagnosisId = props.diagnosisId
|
||||
mutation.value = 'apply'; errorMessage.value = ''; clearTimer()
|
||||
try {
|
||||
await followupAudioApply({ id: detail.value.id, version: detail.value.version, items: cloneReviewItems(reviewItems.value) }, ticket.signal)
|
||||
if (!scope.current(ticket)) return
|
||||
detail.value = { ...detail.value, status: 'applied' }; snapshot.value = JSON.stringify(reviewItems.value)
|
||||
ElMessage.success('所选记录已全部采纳'); emit('applied', diagnosisId)
|
||||
await readDetail(selectedId.value)
|
||||
} catch (error) { if (scope.current(ticket)) await handleReviewError(error) }
|
||||
finally { if (scope.current(ticket)) { mutation.value = ''; await refresh() } }
|
||||
}
|
||||
async function retryTask() {
|
||||
if (!detail.value?.can_retry || detail.value.status !== 'failed' || !canUpload.value || busy.value) return
|
||||
const ticket = scope.capture()
|
||||
try { await ElMessageBox.confirm('仅对服务端已确认失败、允许重试的任务重新分析。确认继续?', '确认重试', { type: 'warning' }) } catch { return }
|
||||
if (!scope.current(ticket) || !detail.value) return
|
||||
mutation.value = 'retry'; clearTimer()
|
||||
try { await followupAudioRetry(detail.value.id, ticket.signal); if (scope.current(ticket)) await readDetail(selectedId.value) }
|
||||
catch (error) { if (scope.current(ticket)) errorMessage.value = followupError(error) }
|
||||
finally { if (scope.current(ticket)) { mutation.value = ''; await refresh() } }
|
||||
}
|
||||
async function reloadAudio() { releaseAudio(); await loadAudio() }
|
||||
async function loadAudio(milliseconds?: number) {
|
||||
if (!detail.value?.audio_available) return
|
||||
if (!detailSections.value.includes('original')) detailSections.value = [...detailSections.value, 'original']
|
||||
const ticket = scope.capture(); const id = detail.value.id
|
||||
if (!audioUrl.value) {
|
||||
const requestId = ++audioRequest; audioLoading.value = true
|
||||
try {
|
||||
const blob = await followupAudioBlob(id, ticket.signal)
|
||||
if (!scope.current(ticket) || requestId !== audioRequest || id !== selectedId.value) return
|
||||
audioUrl.value = URL.createObjectURL(blob)
|
||||
} catch (error) { if (scope.current(ticket) && requestId === audioRequest) errorMessage.value = followupError(error) }
|
||||
finally { if (scope.current(ticket) && requestId === audioRequest) audioLoading.value = false }
|
||||
}
|
||||
await nextTick()
|
||||
if (!scope.current(ticket) || id !== selectedId.value || !audioElement.value || typeof milliseconds !== 'number') return
|
||||
audioElement.value.currentTime = Math.max(0, milliseconds / 1000)
|
||||
try { await audioElement.value.play() } catch { /* Native controls remain available when autoplay is blocked. */ }
|
||||
}
|
||||
function formatExpiry(value: number | string) {
|
||||
if (!value) return '由保留策略控制'
|
||||
const date = typeof value === 'number' || /^\d+$/.test(value) ? new Date(Number(value) * 1000) : new Date(value)
|
||||
return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })
|
||||
}
|
||||
function onVisibility() { documentVisible.value = document.visibilityState !== 'hidden' }
|
||||
watch([() => props.diagnosisId, effectiveActive], ([id, active]) => {
|
||||
stop()
|
||||
if (id !== lastDiagnosis) {
|
||||
lastDiagnosis = id; selectedId.value = 0; detail.value = null; reviewItems.value = []; snapshot.value = '[]'
|
||||
tasks.value = []; file.value = null; recordedAt.value = ''; uploadVisible.value = false; detailSections.value = []; helpVisible.value = false; errorMessage.value = ''; uploadStage.value = ''; createUncertain.value = false
|
||||
}
|
||||
if (active) void refresh()
|
||||
}, { immediate: true })
|
||||
watch(() => props.capabilities.models, models => {
|
||||
if (!models.some(model => model.value === modelKey.value)) modelKey.value = models[0]?.value || ''
|
||||
})
|
||||
onMounted(() => document.addEventListener('visibilitychange', onVisibility))
|
||||
onBeforeUnmount(() => { stop(); document.removeEventListener('visibilitychange', onVisibility) })
|
||||
defineExpose({ confirmLeave, refresh, hasUnsavedChanges: () => dirty.value })
|
||||
</script>
|
||||
<style scoped>
|
||||
.followup-panel { display: grid; gap: 12px; min-width: 0; color: var(--el-text-color-primary); font-size: 14px; }
|
||||
.panel-toolbar, .toolbar-actions, .detail-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; min-width: 0; }
|
||||
.toolbar-actions { justify-content: flex-end; gap: 8px; flex-wrap: wrap; }
|
||||
.toolbar-actions :deep(.el-button + .el-button) { margin-left: 0; }
|
||||
h3 { margin: 0; font-size: 14px; font-weight: 600; }
|
||||
.toolbar-title h3 span { margin-left: 5px; font-weight: 400; color: var(--el-text-color-secondary); }
|
||||
.help-box { background: #fff; border: 1px solid #e2e8f0; border-radius: 10px; padding: 10px 14px; color: var(--el-text-color-secondary); font-size: 12px; line-height: 1.7; }
|
||||
.help-box p { margin: 3px 0; }
|
||||
.task-list, .task-detail { min-width: 0; max-width: 100%; border: 1px solid #e2e8f0; border-radius: 10px; background: #fff; overflow: hidden; }
|
||||
.task-list { padding: 4px 12px; }
|
||||
.task-list :deep(.el-table) { --el-table-header-bg-color: #f8fafc; }
|
||||
.task-list :deep(.el-table .cell) { line-height: 1.5; }
|
||||
.filename-button { max-width: 100%; height: auto; text-align: left; white-space: normal; word-break: break-word; line-height: 1.6; }
|
||||
.filename-button :deep(span) { overflow-wrap: anywhere; }
|
||||
.task-time, .recording-title > span { display: block; color: var(--el-text-color-secondary); font-size: 12px; margin-top: 3px; }
|
||||
.task-detail { padding: 16px; display: grid; gap: 14px; }
|
||||
.detail-heading { align-items: flex-start; }
|
||||
.recording-title { min-width: 0; }
|
||||
.recording-title h3 { overflow-wrap: anywhere; line-height: 1.6; }
|
||||
.detail-heading :deep(.el-tag) { flex-shrink: 0; }
|
||||
.detail-loading { padding: 32px; text-align: center; color: var(--el-text-color-secondary); }
|
||||
.processing-state, .applied-note { margin: 0; color: var(--el-text-color-secondary); font-size: 13px; }
|
||||
.retry-button { justify-self: start; }
|
||||
.summary-box { padding-bottom: 12px; border-bottom: 1px solid #e2e8f0; }
|
||||
.summary-box > p { margin: 0 0 10px; line-height: 1.7; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.summary-counts { display: flex; gap: 20px; color: var(--el-text-color-secondary); font-size: 12px; }
|
||||
.summary-counts strong { color: var(--el-text-color-primary); font-weight: 600; }
|
||||
.original-collapse { --el-collapse-border-color: #e2e8f0; border-top: 0; }
|
||||
.original-collapse :deep(.el-collapse-item__header) { height: 38px; font-size: 13px; color: var(--el-text-color-regular); }
|
||||
.original-collapse :deep(.el-collapse-item__content) { padding-bottom: 10px; }
|
||||
.audio-player { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
|
||||
.audio-player audio { width: 100%; max-width: 440px; height: 36px; }
|
||||
.transcript-text { max-height: 280px; overflow-y: auto; margin: 12px 0 0; padding: 12px; background: #f8fafc; border-radius: 6px; white-space: pre-wrap; overflow-wrap: anywhere; font-family: inherit; }
|
||||
.review-actions { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 12px; align-items: center; padding-top: 2px; }
|
||||
.selection-summary { display: grid; gap: 3px; font-size: 13px; }
|
||||
.selection-summary span { color: var(--el-text-color-secondary); font-size: 12px; }
|
||||
.action-buttons { display: flex; gap: 8px; }
|
||||
.action-buttons :deep(.el-button + .el-button) { margin-left: 0; }
|
||||
.review-error { color: var(--el-color-danger); width: 100%; margin: 0; font-size: 12px; }
|
||||
.muted, .upload-stage { color: var(--el-text-color-secondary); font-size: 12px; line-height: 1.6; margin: 0; }
|
||||
.upload-stage { margin: 10px 0; color: var(--el-text-color-regular); }
|
||||
.upload-form { min-width: 0; }
|
||||
.upload-form input { max-width: 100%; }
|
||||
.upload-form small { display: block; color: var(--el-text-color-secondary); line-height: 1.5; margin-top: 6px; }
|
||||
.upload-form :deep(.el-date-editor), .upload-form :deep(.el-select) { width: 100%; }
|
||||
@media (max-width: 640px) {
|
||||
.panel-toolbar { align-items: flex-start; flex-wrap: wrap; gap: 8px; }
|
||||
.toolbar-actions { gap: 6px; }
|
||||
.task-list { padding: 0 4px; }
|
||||
.task-detail { padding: 12px; gap: 12px; }
|
||||
.detail-heading { flex-wrap: wrap; gap: 6px; }
|
||||
.review-actions { align-items: flex-start; }
|
||||
.action-buttons { margin-left: auto; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,233 @@
|
||||
<template>
|
||||
<section class="followup-review" aria-label="回访录音人工审核">
|
||||
<el-tabs v-model="group" class="review-tabs" aria-label="提炼结果分类">
|
||||
<el-tab-pane :label="`全部 ${counts.all}`" name="all" />
|
||||
<el-tab-pane :label="`诊单资料 ${counts.diagnosis}`" name="diagnosis" />
|
||||
<el-tab-pane :label="`日常记录 ${counts.daily}`" name="daily" />
|
||||
<el-tab-pane :label="`待核实 ${counts.pending + (uncertainties?.length || 0)}`" name="pending" />
|
||||
</el-tabs>
|
||||
<div class="review-caption"><span>{{ applied ? '审核结果' : '提炼结果' }} · {{ visibleItems.length }} 项</span><span v-if="!applied">跨分类已选 {{ counts.selected }} 项</span></div>
|
||||
<p v-if="!canDaily && counts.daily" class="permission-note">日常记录仅供查看,当前账号没有编辑权限。</p>
|
||||
<div class="review-table">
|
||||
<el-table :data="visibleItems" max-height="360" row-key="id" size="small" table-layout="fixed" :empty-text="items.length ? '此分类暂无内容' : '暂无结构化结果,可查看录音原文'" aria-label="回访提炼结果">
|
||||
<el-table-column v-if="!applied" label="采纳" width="50" align="center">
|
||||
<template #default="{ row }"><el-checkbox :model-value="row.selected" :disabled="disabled || (!row.selected && selectionBlocked(row))" :aria-label="`采纳${itemTitle(row)}`" @update:model-value="(value: boolean | string | number) => toggleSelection(row, !!value)" /></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="字段 / 内容" min-width="210">
|
||||
<template #default="{ row }">
|
||||
<div v-for="field in itemFields(row).slice(0, 2)" :key="field.key" class="result-line"><span>{{ field.label }}:</span>{{ fieldText(row.values[field.key], field) }}</div>
|
||||
<span v-if="!itemFields(row).length" class="muted">暂无有效字段</span>
|
||||
<div class="item-meta">{{ itemKindLabel(row) }}<span v-if="itemFields(row).length > 2"> · 共 {{ itemFields(row).length }} 个字段</span><span v-if="row.target_id"> · 更新已有记录</span></div>
|
||||
<div v-if="row.kind !== 'diagnosis'" class="item-meta mobile-event-time">{{ row.record_date || '日期待核实' }} · {{ reviewTimeValue(row.record_time) || row.time_period || '时间待核实' }}{{ row.time_estimated ? '(推定)' : '' }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="日期 / 时段" width="130">
|
||||
<template #default="{ row }"><span>{{ row.kind === 'diagnosis' ? '—' : row.record_date || '待补充日期' }}</span><div v-if="row.kind !== 'diagnosis'" class="item-meta">{{ reviewTimeValue(row.record_time) || row.time_period || '未提及时间' }}{{ row.time_estimated ? '(推定)' : '' }}</div></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="90">
|
||||
<template #default="{ row }"><el-tag size="small" :type="itemStatus(row).type">{{ itemStatus(row).label }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="60" align="right" fixed="right">
|
||||
<template #default="{ row }"><el-button link type="primary" @click="openReview(row.id)">{{ applied || disabled ? '查看' : '核对' }}</el-button></template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
<section v-if="group === 'pending' && uncertainties?.length" class="uncertainty-list" aria-label="原文待核实信息">
|
||||
<strong>原文中还需补充的信息</strong>
|
||||
<ul><li v-for="(value, index) in uncertainties" :key="index">{{ uncertaintyText(value) }}</li></ul>
|
||||
</section>
|
||||
<el-dialog :model-value="!!editor" :title="applied || disabled ? '查看审核结果' : '核对提炼内容'" width="min(760px, calc(100vw - 32px))" top="8vh" append-to-body :close-on-click-modal="false" :before-close="cancelReview">
|
||||
<section v-if="editor" class="review-editor">
|
||||
<div class="editor-heading"><strong>{{ followupKindLabels[editor.kind] }}</strong><el-tag size="small" :type="itemStatus(editor).type">{{ itemStatus(editor).label }}</el-tag></div>
|
||||
<p v-if="editorNotice" class="editor-notice" role="status">{{ editorNotice }}</p>
|
||||
<div class="evidence">
|
||||
<div class="evidence-title">录音原话</div>
|
||||
<blockquote v-for="(quote, index) in editor.evidence" :key="index"><span>{{ quote.text }}</span><el-button v-if="typeof quote.start_ms === 'number'" link type="primary" @click="emit('seek', quote.start_ms)">播放 {{ timestamp(quote.start_ms) }}</el-button></blockquote>
|
||||
<p v-if="!editor.evidence.length" class="muted">未提供原话证据,请人工核实。</p>
|
||||
<p v-if="editor.date_text || editor.time_text" class="muted">时间原话:{{ editor.date_text || '未提及日期' }} · {{ editor.time_text || '未提及时间' }}</p>
|
||||
</div>
|
||||
<div v-if="editor.conflict || editor.possible_duplicate || editor.time_estimated" class="review-flags"><span v-if="editor.conflict">与已有值冲突</span><span v-if="editor.possible_duplicate">可能存在重复记录</span><span v-if="editor.time_estimated">时间由时段推定</span></div>
|
||||
<template v-if="editor.kind !== 'diagnosis'">
|
||||
<div v-if="disabled || applied" class="readonly-event"><span>{{ editor.record_date || '未记录日期' }} · {{ reviewTimeValue(editor.record_time) || editor.time_period || '未记录时间' }}</span><span>{{ editor.target_id ? `更新记录 #${editor.target_id}` : '新增独立记录' }}</span></div>
|
||||
<div v-else class="event-time">
|
||||
<label class="target-field">写入目标<el-select :model-value="editor.target_id || 0" @update:model-value="(value: number) => chooseTarget(value)"><el-option label="新增独立记录(不覆盖已有行)" :value="0" /><el-option v-for="candidate in editorCandidates" :key="candidate.id" :value="candidate.id" :disabled="!!candidate.delete_time" :label="candidateLabel(editor, candidate)" /></el-select></label>
|
||||
<label>记录日期<el-date-picker :model-value="editor.record_date" type="date" value-format="YYYY-MM-DD" placeholder="请核实日期" @update:model-value="(value: string | null) => patchEditor({ record_date: value || null, target_id: null })" /></label>
|
||||
<label>记录时间<el-time-picker :model-value="reviewTimeValue(editor.record_time)" value-format="HH:mm" format="HH:mm" placeholder="未提及可留空" @update:model-value="(value: string | null) => patchEditor({ record_time: value || null })" /></label>
|
||||
<label>原始时段<el-input :model-value="editor.time_period || ''" placeholder="如早晨、餐后" @update:model-value="(value: string) => patchEditor({ time_period: value || null })" /></label>
|
||||
</div>
|
||||
<p v-if="targetChanged && !applied" class="target-hint" role="status">日期或目标已更改。请先保存本项,再保存草稿刷新目标当前值,之后重新核对并选择。</p>
|
||||
</template>
|
||||
<div class="field-grid field-grid--header"><span>字段</span><span>{{ applied ? '写入前值' : targetChanged ? '旧目标值(待刷新)' : '当前值' }}</span><span>{{ applied || disabled ? '审核值' : '拟采纳值' }}</span></div>
|
||||
<div v-for="field in itemFields(editor)" :key="field.key" class="field-grid">
|
||||
<div class="field-label">{{ field.label }}<small v-if="field.readonly">只读</small></div>
|
||||
<div class="current-value">{{ fieldText(editor.current_values[field.key], field) }}</div>
|
||||
<div class="suggested-value"><FollowupAudioField :field="field" :model-value="editor.values[field.key]" :readonly="disabled || applied || field.readonly" @update:model-value="value => setValue(field.key, value)" /><el-button v-if="!disabled && !applied" link type="danger" @click="removeField(field.key)">移除</el-button></div>
|
||||
</div>
|
||||
<el-select v-if="!disabled && !applied && availableFields(editor).length" class="add-field" placeholder="补充字段" :model-value="''" @update:model-value="(value: string) => addField(value)"><el-option v-for="field in availableFields(editor)" :key="field.key" :label="field.label" :value="field.key" /></el-select>
|
||||
<el-checkbox v-if="!disabled && !applied" :model-value="!editor.needs_review" class="review-confirm" @update:model-value="(value: boolean | string | number) => confirmReview(!!value)">我已核对原话、冲突 / 重复及日期时间,确认本项内容</el-checkbox>
|
||||
<p v-if="editorIssue && !disabled && !applied" class="review-error" role="status">{{ editorIssue }}</p>
|
||||
</section>
|
||||
<template #footer>
|
||||
<div class="editor-footer"><el-button @click="cancelReview()">{{ applied || disabled ? '关闭' : '取消' }}</el-button><template v-if="!disabled && !applied"><el-button @click="saveReview(false)">保存本项</el-button><el-button type="primary" :disabled="!!editorIssue || targetChanged" @click="saveReview(true)">核对完成并选中</el-button></template></div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</section>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import type { FollowupField, FollowupFields, FollowupReviewItem, FollowupValue } from '@/api/followupAudio'
|
||||
import FollowupAudioField from './FollowupAudioField.vue'
|
||||
import { cloneReviewItems, fieldText, followupKindLabels, hasEditableReviewFields, reviewCounts, reviewFieldIssue, reviewIssue, reviewPending, reviewTimeValue } from './followupAudioState'
|
||||
const props = defineProps<{ items: FollowupReviewItem[]; fields: FollowupFields; disabled: boolean; canDaily: boolean; applied?: boolean; uncertainties?: unknown[]; snapshotItems?: FollowupReviewItem[] }>()
|
||||
const emit = defineEmits<{ (event: 'update:items', items: FollowupReviewItem[]): void; (event: 'seek', milliseconds: number): void }>()
|
||||
type Group = 'all' | 'diagnosis' | 'daily' | 'pending'
|
||||
type Editable = Partial<Pick<FollowupReviewItem, 'values' | 'record_date' | 'record_time' | 'time_period' | 'selected' | 'target_id' | 'needs_review'>>
|
||||
const group = ref<Group>('all')
|
||||
const editor = ref<FollowupReviewItem | null>(null)
|
||||
const editorSource = ref('')
|
||||
const editorNotice = ref('')
|
||||
const counts = computed(() => reviewCounts(props.items, props.fields, props.canDaily))
|
||||
const visibleItems = computed(() => props.items.filter(item => group.value === 'all' || (group.value === 'diagnosis' && item.kind === 'diagnosis') || (group.value === 'daily' && item.kind !== 'diagnosis') || (group.value === 'pending' && reviewPending(item, props.fields, props.canDaily))))
|
||||
const editorDirty = computed(() => !!editor.value && JSON.stringify(editor.value) !== editorSource.value)
|
||||
const targetChanged = computed(() => {
|
||||
if (!editor.value || !editorSource.value) return false
|
||||
const source: FollowupReviewItem = props.snapshotItems?.find(item => item.id === editor.value?.id) || JSON.parse(editorSource.value)
|
||||
return editor.value.target_id !== source.target_id || editor.value.record_date !== source.record_date
|
||||
})
|
||||
const editorCandidates = computed(() => {
|
||||
if (!editor.value || !editorSource.value) return []
|
||||
const source: FollowupReviewItem = props.snapshotItems?.find(item => item.id === editor.value?.id) || JSON.parse(editorSource.value)
|
||||
return editor.value.record_date === source.record_date ? editor.value.candidates || [] : []
|
||||
})
|
||||
const editorIssue = computed(() => editor.value ? reviewFieldIssue({ ...editor.value, selected: true }, props.fields) || reviewIssue({ ...editor.value, selected: true }, props.canDaily) : '')
|
||||
function patch(id: string, update: Editable) { emit('update:items', props.items.map(item => item.id === id ? { ...item, ...update } : item)) }
|
||||
function itemFields(item: FollowupReviewItem): FollowupField[] { return Object.keys(item.values).map(key => (props.fields[item.kind] || []).find(field => field.key === key) || { key, label: key, type: 'text', readonly: true }) }
|
||||
function itemKindLabel(item: FollowupReviewItem) { return followupKindLabels[item.kind] }
|
||||
function itemTitle(item: FollowupReviewItem) { return itemFields(item).map(field => field.label).join('、') || followupKindLabels[item.kind] }
|
||||
function availableFields(item: FollowupReviewItem) { return (props.fields[item.kind] || []).filter(field => !field.readonly && !Object.prototype.hasOwnProperty.call(item.values, field.key)) }
|
||||
function selectionBlocked(item: FollowupReviewItem) { return props.disabled || !!props.applied || !hasEditableReviewFields(item, props.fields) || (item.kind !== 'diagnosis' && !props.canDaily) || !!reviewFieldIssue({ ...item, selected: true }, props.fields) }
|
||||
function toggleSelection(item: FollowupReviewItem, selected: boolean) {
|
||||
if (props.disabled || props.applied) return
|
||||
if (!selected) { patch(item.id, { selected: false }); return }
|
||||
if (selectionBlocked(item)) return
|
||||
if (reviewPending(item, props.fields, props.canDaily)) { openReview(item.id); return }
|
||||
patch(item.id, { selected: true })
|
||||
}
|
||||
function itemStatus(item: FollowupReviewItem): { label: string; type: 'success' | 'info' | 'warning' } {
|
||||
if (props.applied) return { label: item.selected ? '已写入' : '未采纳', type: item.selected ? 'success' : 'info' }
|
||||
if (!hasEditableReviewFields(item, props.fields)) return { label: '只读字段', type: 'info' }
|
||||
if (reviewPending(item, props.fields, props.canDaily)) return { label: '待核实', type: 'warning' }
|
||||
return { label: item.selected ? '已选择' : '已核对', type: item.selected ? 'success' : 'info' }
|
||||
}
|
||||
function openReview(id: string) {
|
||||
const item = props.items.find(candidate => candidate.id === id)
|
||||
if (!item) return
|
||||
editor.value = cloneReviewItems([item])[0]; editorSource.value = JSON.stringify(editor.value); editorNotice.value = ''
|
||||
}
|
||||
function cancelReview(done?: () => void) { editor.value = null; editorSource.value = ''; editorNotice.value = ''; done?.() }
|
||||
function patchEditor(update: Editable) {
|
||||
if (!editor.value || props.disabled || props.applied) return
|
||||
editor.value = { ...editor.value, ...update, selected: false, needs_review: true }
|
||||
}
|
||||
function setValue(key: string, value: FollowupValue) {
|
||||
if (!editor.value || !(props.fields[editor.value.kind] || []).some(field => field.key === key && !field.readonly)) return
|
||||
patchEditor({ values: { ...editor.value.values, [key]: value } })
|
||||
}
|
||||
function addField(key: string) {
|
||||
if (!editor.value) return
|
||||
const field = availableFields(editor.value).find(candidate => candidate.key === key)
|
||||
if (field) setValue(key, field.type === 'multiselect' ? [] : null)
|
||||
}
|
||||
function removeField(key: string) { if (!editor.value) return; const values = { ...editor.value.values }; delete values[key]; patchEditor({ values }) }
|
||||
function chooseTarget(id: number) {
|
||||
if (!editor.value || (id && !editorCandidates.value.some(candidate => candidate.id === id && !candidate.delete_time))) return
|
||||
patchEditor({ target_id: id || null })
|
||||
}
|
||||
function confirmReview(confirmed: boolean) {
|
||||
if (!editor.value || props.disabled || props.applied) return
|
||||
editor.value = { ...editor.value, needs_review: !confirmed, selected: confirmed ? editor.value.selected : false }
|
||||
}
|
||||
function saveReview(select: boolean) {
|
||||
if (!editor.value || props.disabled || props.applied || (select && (editorIssue.value || targetChanged.value))) return
|
||||
const item = editor.value
|
||||
patch(item.id, { values: { ...item.values }, record_date: item.record_date, record_time: item.record_time, time_period: item.time_period, target_id: item.target_id, selected: select || (!editorIssue.value && !targetChanged.value && item.selected), needs_review: targetChanged.value || item.needs_review })
|
||||
cancelReview()
|
||||
}
|
||||
function candidateLabel(item: FollowupReviewItem, candidate: NonNullable<FollowupReviewItem['candidates']>[number]) {
|
||||
const preview = (props.fields[item.kind] || []).filter(field => Object.prototype.hasOwnProperty.call(candidate.values, field.key)).slice(0, 2).map(field => `${field.label} ${fieldText(candidate.values[field.key], field)}`).join(',')
|
||||
return `#${candidate.id} · ${candidate.record_time || candidate.time_period || '时间未记录'}${candidate.time_estimated ? '(推定)' : ''}${preview ? ` · ${preview}` : ''}`
|
||||
}
|
||||
function timestamp(ms: number) { const seconds = Math.max(0, Math.floor(ms / 1000)); return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}` }
|
||||
function uncertaintyText(value: unknown) { return typeof value === 'string' ? value : JSON.stringify(value) }
|
||||
watch(() => props.items.find(item => item.id === editor.value?.id), fresh => {
|
||||
if (!editor.value) return
|
||||
if (!fresh) { cancelReview(); return }
|
||||
const current = JSON.stringify(fresh)
|
||||
if (current === editorSource.value) return
|
||||
const edited = editor.value
|
||||
editor.value = editorDirty.value && !props.applied ? { ...cloneReviewItems([fresh])[0], values: { ...edited.values }, record_date: edited.record_date, record_time: edited.record_time, time_period: edited.time_period, target_id: edited.target_id, selected: false, needs_review: true } : cloneReviewItems([fresh])[0]
|
||||
editorSource.value = current
|
||||
editorNotice.value = '任务数据已更新,当前编辑已保留;请重新核对后选择。'
|
||||
}, { deep: true })
|
||||
watch(() => props.applied, applied => { if (applied && editor.value) openReview(editor.value.id) })
|
||||
defineExpose({ hasUnsavedChanges: () => editorDirty.value })
|
||||
</script>
|
||||
<style scoped>
|
||||
.followup-review, .review-table { min-width: 0; max-width: 100%; }
|
||||
.review-tabs :deep(.el-tabs__header) { margin: 0 0 8px; }
|
||||
.review-tabs :deep(.el-tabs__item) { padding: 0 14px; font-size: 13px; height: 36px; }
|
||||
.review-tabs :deep(.el-tabs__nav-wrap::after) { height: 1px; background: #e2e8f0; }
|
||||
.review-caption { display: flex; gap: 12px; justify-content: space-between; margin: 0 0 8px; color: var(--el-text-color-secondary); font-size: 12px; }
|
||||
.review-table { overflow: hidden; }
|
||||
.review-table :deep(.el-table) { --el-table-header-bg-color: #f8fafc; }
|
||||
.review-table :deep(.el-table .cell) { line-height: 1.6; }
|
||||
.result-line { white-space: nowrap; text-overflow: ellipsis; overflow: hidden; }
|
||||
.result-line > span { color: #64748b; }
|
||||
.item-meta, .muted, .permission-note { color: #64748b; font-size: 12px; }
|
||||
.item-meta { margin-top: 2px; }
|
||||
.mobile-event-time { display: none; }
|
||||
.permission-note { margin: 0 0 8px; }
|
||||
.uncertainty-list { margin-top: 12px; padding: 10px 12px; background: #f8fafc; border-radius: 6px; font-size: 13px; line-height: 1.6; overflow-wrap: anywhere; }
|
||||
.uncertainty-list strong { font-size: 12px; font-weight: 500; color: var(--el-text-color-secondary); }
|
||||
.uncertainty-list ul { padding-left: 18px; margin: 6px 0 0; }
|
||||
.review-editor { display: grid; gap: 12px; min-width: 0; max-height: 65vh; overflow-y: auto; padding-right: 4px; }
|
||||
.editor-heading { display: flex; align-items: center; gap: 10px; }
|
||||
.editor-heading strong { font-size: 14px; }
|
||||
.evidence { padding: 10px 12px; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 6px; overflow-wrap: anywhere; }
|
||||
.evidence-title { color: var(--el-text-color-secondary); font-size: 12px; }
|
||||
.evidence blockquote { margin: 6px 0; white-space: pre-wrap; line-height: 1.6; }
|
||||
.evidence blockquote :deep(.el-button) { margin-left: 8px; }
|
||||
.evidence p { margin: 6px 0 0; }
|
||||
.review-flags { display: flex; flex-wrap: wrap; gap: 6px 14px; color: var(--el-color-warning-dark-2); font-size: 12px; }
|
||||
.event-time { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
|
||||
.event-time label { display: grid; gap: 5px; font-size: 12px; color: var(--el-text-color-regular); }
|
||||
.event-time .target-field { grid-column: 1 / -1; }
|
||||
.event-time :deep(.el-date-editor), .event-time :deep(.el-select), .suggested-value :deep(.el-input-number), .suggested-value :deep(.el-select), .suggested-value :deep(.el-date-editor) { width: 100%; }
|
||||
.readonly-event { display: flex; flex-wrap: wrap; gap: 8px 20px; font-size: 13px; }
|
||||
.target-hint, .editor-notice { margin: 0; color: var(--el-color-warning-dark-2); font-size: 12px; line-height: 1.6; }
|
||||
.field-grid { display: grid; grid-template-columns: 1fr 1fr 1.6fr; align-items: start; gap: 12px; padding-bottom: 10px; border-bottom: 1px solid #e2e8f0; font-size: 13px; }
|
||||
.field-grid--header { color: var(--el-text-color-secondary); font-size: 12px; padding-bottom: 6px; }
|
||||
.field-label small { display: block; color: var(--el-text-color-secondary); margin-top: 3px; }
|
||||
.current-value { white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.suggested-value { min-width: 0; display: grid; gap: 4px; }
|
||||
.suggested-value > :deep(.el-button) { justify-self: end; font-size: 12px; }
|
||||
.add-field { width: 100%; }
|
||||
.review-confirm { white-space: normal; height: auto; min-height: 32px; align-items: flex-start; }
|
||||
.review-confirm :deep(.el-checkbox__input) { margin-top: 4px; }
|
||||
.review-confirm :deep(.el-checkbox__label) { white-space: normal; line-height: 1.6; font-size: 13px; }
|
||||
.review-error { color: var(--el-color-danger); font-size: 12px; margin: 0; }
|
||||
.editor-footer { display: flex; justify-content: flex-end; flex-wrap: wrap; gap: 8px; }
|
||||
.editor-footer :deep(.el-button + .el-button) { margin-left: 0; }
|
||||
@media (max-width: 640px) {
|
||||
.mobile-event-time { display: block; }
|
||||
.review-tabs :deep(.el-tabs__item) { padding: 0 10px; }
|
||||
.review-caption { gap: 6px; flex-wrap: wrap; }
|
||||
.event-time { grid-template-columns: 1fr; }
|
||||
.field-grid { grid-template-columns: 1fr; gap: 6px; }
|
||||
.field-grid--header { display: none; }
|
||||
.field-label { font-weight: 600; }
|
||||
.current-value::before { content: '当前值:'; color: var(--el-text-color-secondary); }
|
||||
.suggested-value::before { content: '审核值:'; color: var(--el-text-color-secondary); }
|
||||
.editor-footer { gap: 6px; }
|
||||
}
|
||||
</style>
|
||||
@@ -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<string, string> = {
|
||||
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<File, 'size' | 'name'> | 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<boolean> {
|
||||
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 : '请求失败;当前审核内容已保留,请刷新任务状态后确认'
|
||||
}
|
||||
@@ -52,7 +52,7 @@
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
<el-tabs v-model="activeTab" class="diagnosis-tabs tcm-diagnosis-tabs">
|
||||
<el-tabs v-model="activeTab" :before-leave="beforeTabLeave" class="diagnosis-tabs tcm-diagnosis-tabs">
|
||||
<!-- 基本信息标签页 -->
|
||||
<el-tab-pane label="病历" name="basic">
|
||||
<el-form
|
||||
@@ -660,6 +660,7 @@
|
||||
lazy
|
||||
>
|
||||
<daily-matrix
|
||||
ref="dailyMatrixRef"
|
||||
v-if="formData.id"
|
||||
:diagnosis-id="Number(formData.id)"
|
||||
:patient-id="Number(formData.patient_id)"
|
||||
@@ -670,6 +671,21 @@
|
||||
<el-empty v-else description="请先保存诊单后查看" />
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane v-if="followupCapabilities?.enabled && formData.id" label="回访录音" name="followupAudio" lazy>
|
||||
<FollowupAudioPanel
|
||||
v-if="visible && formData.id && followupCapabilities"
|
||||
:key="Number(formData.id)"
|
||||
ref="followupAudioRef"
|
||||
:diagnosis-id="Number(formData.id)"
|
||||
:view-only="viewOnly"
|
||||
:active="activeTab === 'followupAudio'"
|
||||
:capabilities="followupCapabilities"
|
||||
:basic-dirty="basicDirty"
|
||||
:before-apply="canApplyFollowup"
|
||||
@applied="handleFollowupApplied"
|
||||
/>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 病历记录标签页(开方后自动显示) -->
|
||||
<el-tab-pane label="处方" name="bingli" :disabled="!formData.id" v-if="hasPermission(['tcm.diagnosis/chufang'])">
|
||||
<div v-if="formData.id" class="bingli-tab-content">
|
||||
@@ -785,6 +801,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { tcmDiagnosisAdd, tcmDiagnosisEdit, tcmDiagnosisDetail, checkPhone, checkIdCard, diagnosisTrackingNotes } from '@/api/tcm'
|
||||
import { getDictData } from '@/api/app'
|
||||
import feedback from '@/utils/feedback'
|
||||
@@ -794,6 +811,8 @@ import useAppStore from '@/stores/modules/app'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { QuestionFilled } from '@element-plus/icons-vue'
|
||||
import DailyMatrix from './components/DailyMatrix.vue'
|
||||
import FollowupAudioPanel from './components/FollowupAudioPanel.vue'
|
||||
import { followupAudioCapabilities, type FollowupCapabilities } from '@/api/followupAudio'
|
||||
import CaseRecordList from './components/CaseRecordList.vue'
|
||||
import CallRecordPanel from './components/CallRecordPanel.vue'
|
||||
import ImChatRecordPanel from './components/ImChatRecordPanel.vue'
|
||||
@@ -811,6 +830,12 @@ const getImageUrl = (url: string) => (url?.indexOf?.('http') === 0 ? url : appSt
|
||||
const caseRecordListRef = ref()
|
||||
const callRecordPanelRef = ref<{ refresh?: () => void } | null>(null)
|
||||
const prescriptionRef = ref()
|
||||
const dailyMatrixRef = ref<{ refresh?: () => Promise<void> | void } | null>(null)
|
||||
const followupAudioRef = ref<{ confirmLeave: () => Promise<boolean> } | null>(null)
|
||||
const followupCapabilities = ref<FollowupCapabilities | null>(null)
|
||||
const basicSnapshot = ref('')
|
||||
let diagnosisGeneration = 0
|
||||
let capabilitiesRequest: AbortController | undefined
|
||||
|
||||
const visible = ref(false)
|
||||
/** 只读查看:与 consumers 处方等处「查看诊单」一致,全部 Tab 仅展示不可保存 */
|
||||
@@ -970,6 +995,45 @@ const formData = ref({
|
||||
external_userid: ''
|
||||
})
|
||||
|
||||
// Include unmasked identity values so merely revealing a number is not an unsaved edit.
|
||||
const basicFingerprint = () => JSON.stringify({ ...formData.value,
|
||||
phone: originalPhone.value || formData.value.phone, id_card: originalIdCard.value || formData.value.id_card })
|
||||
const basicDirty = computed(() => !viewOnly.value && !!basicSnapshot.value && basicFingerprint() !== basicSnapshot.value)
|
||||
const canApplyFollowup = () => !basicDirty.value && visible.value && !viewOnly.value
|
||||
async function beforeTabLeave(next: string | number, previous: string | number): Promise<boolean> {
|
||||
if (next === 'followupAudio' && basicDirty.value) {
|
||||
feedback.msgWarning('病历有未保存修改,请先保存后再进入回访录音审核,当前输入已保留')
|
||||
return false
|
||||
}
|
||||
if (previous === 'followupAudio') return await followupAudioRef.value?.confirmLeave() ?? true
|
||||
return true
|
||||
}
|
||||
async function handleFollowupApplied(diagnosisId: number) {
|
||||
if (!visible.value || Number(formData.value.id) !== diagnosisId) return
|
||||
const generation = diagnosisGeneration
|
||||
try {
|
||||
// The response itself is also guarded, including edits made while it is in flight.
|
||||
await loadDiagnosisDetailIntoForm(diagnosisId, true)
|
||||
if (generation !== diagnosisGeneration || !visible.value || Number(formData.value.id) !== diagnosisId) return
|
||||
await dailyMatrixRef.value?.refresh?.()
|
||||
emit('success')
|
||||
} catch {
|
||||
if (generation === diagnosisGeneration) feedback.msgWarning('录音已采纳,但病历或日常记录刷新失败,请手动刷新查看')
|
||||
}
|
||||
}
|
||||
watch([visible, () => formData.value.id], async ([isVisible, id]) => {
|
||||
capabilitiesRequest?.abort()
|
||||
followupCapabilities.value = null
|
||||
if (!isVisible || !id) return
|
||||
const request = new AbortController()
|
||||
capabilitiesRequest = request
|
||||
try {
|
||||
const result = await followupAudioCapabilities(Number(id), request.signal)
|
||||
if (!request.signal.aborted && visible.value && String(formData.value.id) === String(id)) followupCapabilities.value = result
|
||||
} catch { /* Fail closed: an unavailable capability endpoint must not expose this feature. */ }
|
||||
})
|
||||
onBeforeUnmount(() => { diagnosisGeneration++; capabilitiesRequest?.abort() })
|
||||
|
||||
/** 就诊卡创建来源(与 zyt_tcm_diagnosis.create_source 一致) */
|
||||
const createSourceOptions = [
|
||||
{ value: '', label: '未设置' },
|
||||
@@ -1260,8 +1324,14 @@ const getDictOptions = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDiagnosisDetailIntoForm(id: number): Promise<void> {
|
||||
async function loadDiagnosisDetailIntoForm(id: number, preserveDirty = false): Promise<void> {
|
||||
const generation = diagnosisGeneration
|
||||
const data = await tcmDiagnosisDetail({ id })
|
||||
if (generation !== diagnosisGeneration || !visible.value) return
|
||||
if (preserveDirty && basicDirty.value) {
|
||||
feedback.msgWarning('录音已采纳,病历仍保留你的未保存修改;请核对后再保存,未用刷新覆盖输入')
|
||||
return
|
||||
}
|
||||
|
||||
originalPhone.value = data.phone || ''
|
||||
originalIdCard.value = data.id_card || ''
|
||||
@@ -1277,6 +1347,7 @@ async function loadDiagnosisDetailIntoForm(id: number): Promise<void> {
|
||||
formData.value.show_card = Number(data.show_card) === 0 ? 0 : 1
|
||||
const y = data.diabetes_discovery_year
|
||||
formData.value.diabetes_discovery_year = y == null || y === '' ? '' : String(y)
|
||||
basicSnapshot.value = basicFingerprint()
|
||||
}
|
||||
|
||||
const resetPatientBasicLockState = () => {
|
||||
@@ -1286,6 +1357,11 @@ const resetPatientBasicLockState = () => {
|
||||
}
|
||||
|
||||
const open = async (type: string, id?: number) => {
|
||||
const generation = ++diagnosisGeneration
|
||||
submitting.value = false
|
||||
basicSnapshot.value = ''
|
||||
followupCapabilities.value = null
|
||||
formData.value.id = ''
|
||||
viewOnly.value = false
|
||||
mode.value = type
|
||||
visible.value = true
|
||||
@@ -1296,6 +1372,7 @@ const open = async (type: string, id?: number) => {
|
||||
|
||||
// 加载字典数据
|
||||
await getDictOptions()
|
||||
if (generation !== diagnosisGeneration || !visible.value) return
|
||||
|
||||
if (type === 'edit' && id) {
|
||||
await loadDiagnosisDetailIntoForm(id)
|
||||
@@ -1310,6 +1387,11 @@ const open = async (type: string, id?: number) => {
|
||||
/** 只读打开:与编辑同一套界面与全部 Tab,不可保存 */
|
||||
const openViewOnly = async (id: number) => {
|
||||
if (!id) return
|
||||
const generation = ++diagnosisGeneration
|
||||
submitting.value = false
|
||||
basicSnapshot.value = ''
|
||||
followupCapabilities.value = null
|
||||
formData.value.id = ''
|
||||
viewOnly.value = true
|
||||
mode.value = 'edit'
|
||||
visible.value = true
|
||||
@@ -1318,13 +1400,17 @@ const openViewOnly = async (id: number) => {
|
||||
idCardRevealUnlocked.value = false
|
||||
resetPatientBasicLockState()
|
||||
await getDictOptions()
|
||||
if (generation !== diagnosisGeneration || !visible.value) return
|
||||
await loadDiagnosisDetailIntoForm(id)
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (viewOnly.value) return
|
||||
if (viewOnly.value || submitting.value) return
|
||||
const generation = diagnosisGeneration
|
||||
await formRef.value?.validate()
|
||||
|
||||
if (generation !== diagnosisGeneration || !visible.value) return
|
||||
// A user can keep typing while the request is pending. Only mark submitted bytes saved.
|
||||
const submittedForm = JSON.parse(basicFingerprint()) as typeof formData.value
|
||||
submitting.value = true
|
||||
try {
|
||||
// 准备提交数据,使用原始完整数据
|
||||
@@ -1336,6 +1422,7 @@ const handleSubmit = async () => {
|
||||
|
||||
if (mode.value === 'add') {
|
||||
const result = await tcmDiagnosisAdd(submitData)
|
||||
if (generation !== diagnosisGeneration || !visible.value) return
|
||||
|
||||
// 如果是新增,保存成功后切换到编辑模式,这样可以添加血糖血压记录
|
||||
if (result && result.id) {
|
||||
@@ -1343,11 +1430,14 @@ const handleSubmit = async () => {
|
||||
phoneRevealUnlocked.value = false
|
||||
idCardRevealUnlocked.value = false
|
||||
formData.value.id = result.id
|
||||
submittedForm.id = result.id
|
||||
|
||||
// 重新获取详情,确保patient_id等字段正确
|
||||
try {
|
||||
const detail = await tcmDiagnosisDetail({ id: result.id })
|
||||
if (generation !== diagnosisGeneration || !visible.value) return
|
||||
formData.value.patient_id = detail.patient_id
|
||||
submittedForm.patient_id = detail.patient_id
|
||||
|
||||
// 更新原始数据
|
||||
originalPhone.value = detail.phone || ''
|
||||
@@ -1367,11 +1457,13 @@ const handleSubmit = async () => {
|
||||
await tcmDiagnosisEdit(submitData)
|
||||
}
|
||||
|
||||
if (generation !== diagnosisGeneration || !visible.value) return
|
||||
basicSnapshot.value = JSON.stringify(submittedForm)
|
||||
emit('success')
|
||||
} catch (error) {
|
||||
console.error('提交失败:', error)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
if (generation === diagnosisGeneration) submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1395,7 +1487,12 @@ const handleOpenPrescription = () => {
|
||||
prescriptionRef.value?.open(data)
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
const handleClose = async () => {
|
||||
if (followupAudioRef.value && !await followupAudioRef.value.confirmLeave()) return
|
||||
diagnosisGeneration++
|
||||
capabilitiesRequest?.abort()
|
||||
followupCapabilities.value = null
|
||||
basicSnapshot.value = ''
|
||||
formRef.value?.resetFields()
|
||||
formData.value = {
|
||||
id: '',
|
||||
|
||||
@@ -0,0 +1,655 @@
|
||||
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, compileStyle } = require('@vue/compiler-sfc')
|
||||
const root = path.resolve(__dirname, '..')
|
||||
const componentDir = path.join(root, 'src/views/tcm/diagnosis/components')
|
||||
function execute(source, dependencies, globals = {}) {
|
||||
const output = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS } }).outputText
|
||||
const module = { exports: {} }
|
||||
new Function('require', 'module', 'exports', ...Object.keys(globals), output)(dependencies, module, module.exports, ...Object.values(globals))
|
||||
return module.exports
|
||||
}
|
||||
function loadTs(filename, dependencies = require) { return execute(fs.readFileSync(filename, 'utf8'), dependencies) }
|
||||
const state = loadTs(path.join(componentDir, 'followupAudioState.ts'))
|
||||
const apiModule = loadTs(path.join(root, 'src/api/followupAudio.ts'), () => ({ default: {} }))
|
||||
const deferred = () => { let resolve, reject; const promise = new Promise((yes, no) => { resolve = yes; reject = no }); return { promise, resolve, reject } }
|
||||
const settle = async () => { for (let i = 0; i < 40; i++) await Promise.resolve() }
|
||||
const caps = () => ({ enabled: true, audio_verified: true, can_upload: true, can_apply: true, can_daily: true, models: [{ value: 'qwen', label: 'Qwen' }, { value: 'openai', label: 'OpenAI' }], limits: { max_bytes: 524288000, max_seconds: 3600, chunk_bytes: 2097152 }, fields: { diagnosis: [{ key: 'remark', label: '备注', type: 'text' }], blood: [{ key: 'fasting_blood_sugar', label: '空腹血糖', type: 'number' }], diet: [], exercise: [], tracking_note: [] } })
|
||||
const item = (extra = {}) => ({ id: 'blood-1', kind: 'blood', values: { fasting_blood_sugar: 6.5 }, record_date: '2026-09-29', record_time: '08:00:00', time_period: '早晨', time_estimated: true, time_text: '早晨', date_text: '今天', evidence: [{ text: '今天早晨测了6.5', start_ms: 5000, end_ms: 8000 }], needs_review: false, selected: true, target_id: null, current_values: {}, expected_hash: 'immutable-hash', conflict: false, possible_duplicate: false, ...extra })
|
||||
const task = (id = 1, extra = {}) => ({ id, diagnosis_id: 10, file_name: 'synthetic.wav', recorded_at: '2026-09-29 09:00:00', status: 'review', stage: '', error_code: '', error_message: '', version: 1, created_at: 0, expires_at: 1800000000, can_retry: false, audio_available: true, summary: '测试摘要', transcript: '测试原文', uncertainties: [], items: [item()], applied_items: [], ...extra })
|
||||
|
||||
function setupPanel(overrides = {}, propsPatch = {}, options = {}) {
|
||||
const hooks = {}, listeners = new Map(), timers = new Map(), calls = [], events = [], notices = [], urls = [], revoked = []
|
||||
let nextTimer = 0
|
||||
const document = { visibilityState: 'visible', addEventListener: (name, callback) => listeners.set(name, callback), removeEventListener: name => listeners.delete(name) }
|
||||
const deps = {
|
||||
...apiModule,
|
||||
followupAudioLists: async id => { calls.push(['lists', id]); return { items: [task()] } },
|
||||
followupAudioDetail: async id => { calls.push(['detail', id]); return task(id) },
|
||||
followupAudioSaveDraft: async payload => { calls.push(['save', payload]); return task(payload.id, { version: payload.version + 1, items: payload.items }) },
|
||||
followupAudioApply: async payload => { calls.push(['apply', payload]); return { id: payload.id, status: 'applied', applied_items: [] } },
|
||||
followupAudioBlob: async id => { calls.push(['audio', id]); return new Blob(['synthetic'], { type: 'audio/wav' }) },
|
||||
followupAudioUploadSession: async payload => { calls.push(['session', payload]); return { upload_id: 'private-id', chunk_bytes: 2097152 } },
|
||||
followupAudioUploadChunk: async (id, index, blob) => { calls.push(['chunk', id, index, blob.size]); return { index, received: true } },
|
||||
followupAudioUploadComplete: async id => { calls.push(['complete', id]); return { upload_id: id, duration_seconds: 10, sha256: 'test' } },
|
||||
followupAudioCreate: async payload => { calls.push(['create', payload]); return { task_id: 2, status: 'queued' } },
|
||||
followupAudioRetry: async id => { calls.push(['retry', id]); return { id, status: 'queued' } },
|
||||
...overrides
|
||||
}
|
||||
const filename = path.join(componentDir, 'FollowupAudioPanel.vue')
|
||||
const descriptor = parse(fs.readFileSync(filename, 'utf8'), { filename }).descriptor
|
||||
const script = compileScript(descriptor, { id: 'panel-test' })
|
||||
const module = execute(script.content, name => {
|
||||
if (name === 'vue') return { ...vue, onMounted: fn => { hooks.mounted = fn }, onBeforeUnmount: fn => { hooks.unmount = fn } }
|
||||
if (name === 'element-plus') return { ElMessage: Object.fromEntries(['success', 'warning', 'error'].map(kind => [kind, msg => notices.push([kind, msg])])), ElMessageBox: { confirm: options.confirm || (async () => true) } }
|
||||
if (name === '@/api/followupAudio') return deps
|
||||
if (name === './followupAudioState') return state
|
||||
if (name === './FollowupAudioReview.vue') return { default: {} }
|
||||
throw new Error(`Unexpected import: ${name}`)
|
||||
}, {
|
||||
document,
|
||||
setTimeout: callback => { const id = ++nextTimer; timers.set(id, callback); return id }, clearTimeout: id => timers.delete(id),
|
||||
URL: { createObjectURL: blob => { const url = `blob:private-${urls.length}`; urls.push(url); return url }, revokeObjectURL: url => revoked.push(url) }
|
||||
})
|
||||
const props = vue.reactive({ diagnosisId: 10, viewOnly: false, active: true, capabilities: caps(), basicDirty: false, ...propsPatch })
|
||||
const scope = vue.effectScope()
|
||||
const panel = scope.run(() => module.default.setup(props, { expose() {}, emit: (...args) => events.push(args) }))
|
||||
hooks.mounted()
|
||||
return { panel, props, calls, events, notices, timers, listeners, document, urls, revoked,
|
||||
dispose() { hooks.unmount(); scope.stop() },
|
||||
tick() { const entries = [...timers.values()]; timers.clear(); for (const callback of entries) callback() }
|
||||
}
|
||||
}
|
||||
|
||||
test('gate and upload validation reject unverified, readonly, oversized, missing time and unknown model', () => {
|
||||
const file = { name: 'test.wav', size: 100 }
|
||||
assert.equal(state.uploadIssue(file, '2026-09-29 09:00:00', 'qwen', caps(), false), '')
|
||||
for (const options of [ { ...caps(), enabled: false }, { ...caps(), audio_verified: false }, { ...caps(), can_upload: false } ]) assert.match(state.uploadIssue(file, '2026-09-29 09:00:00', 'qwen', options, false), /不可上传/)
|
||||
assert.match(state.uploadIssue(file, '2026-09-29 09:00:00', 'qwen', caps(), true), /不可上传/)
|
||||
assert.match(state.uploadIssue({ ...file, size: 524288001 }, '2026-09-29 09:00:00', 'qwen', caps(), false), /超过/)
|
||||
assert.match(state.uploadIssue(file, '2026-02-30 09:00:00', 'qwen', caps(), false), /录音时间/)
|
||||
assert.match(state.uploadIssue(file, '', 'qwen', caps(), false), /录音时间/)
|
||||
assert.match(state.uploadIssue(file, '2026-09-29 09:00:00', 'other', caps(), false), /模型/)
|
||||
})
|
||||
|
||||
test('2MB chunks are sequential, acknowledged and stop immediately after stale generation', async () => {
|
||||
const chunks = [], progress = []
|
||||
const file = new Blob([new Uint8Array(4194305)])
|
||||
assert.equal(await state.uploadChunks(file, 2097152, async (index, blob) => { chunks.push([index, blob.size]); return { received: true } }, () => true, value => progress.push(value)), true)
|
||||
assert.deepEqual(chunks, [[0, 2097152], [1, 2097152], [2, 1]])
|
||||
assert.equal(progress.at(-1), 100)
|
||||
const scope = state.createFollowupScope(), ticket = scope.capture()
|
||||
const sent = []
|
||||
assert.equal(await state.uploadChunks(file, 2097152, async index => { sent.push(index); scope.invalidate(); return { received: true } }, () => scope.current(ticket), () => assert.fail('stale progress')), false)
|
||||
assert.deepEqual(sent, [0])
|
||||
await assert.rejects(state.uploadChunks(file, 1024, async () => ({ received: true }), () => true, () => {}), /分片配置/)
|
||||
})
|
||||
|
||||
test('unresolved uncertainty, invalid dates/times and missing daily permission block selected items only', () => {
|
||||
assert.equal(state.reviewIssue(item(), true), '')
|
||||
assert.match(state.reviewIssue(item({ needs_review: true }), true), /核对/)
|
||||
assert.match(state.reviewIssue(item({ record_date: null }), true), /日期/)
|
||||
assert.match(state.reviewIssue(item({ record_date: '2026-02-30' }), true), /日期/)
|
||||
assert.match(state.reviewIssue(item({ record_time: '25:00:00' }), true), /时间/)
|
||||
assert.match(state.reviewIssue(item(), false), /权限/)
|
||||
assert.equal(state.reviewIssue(item({ selected: false, record_date: null, needs_review: true }), false), '')
|
||||
})
|
||||
|
||||
test('stale refresh preserves edited values and dates but only fresh immutable evidence/hash and clears all selections', () => {
|
||||
const local = item({ values: { fasting_blood_sugar: 7.1 }, record_date: '2026-09-28' })
|
||||
const fresh = item({ evidence: [{ text: 'server-only' }], expected_hash: 'new', current_values: { fasting_blood_sugar: 8 }, conflict: true })
|
||||
const [result] = state.rebaseReviewItems([local], [fresh])
|
||||
assert.deepEqual(result.values, local.values)
|
||||
assert.equal(result.record_date, '2026-09-28')
|
||||
assert.equal(result.expected_hash, 'new')
|
||||
assert.deepEqual(result.evidence, fresh.evidence)
|
||||
assert.equal(result.current_values.fasting_blood_sugar, 8)
|
||||
assert.equal(result.selected, false)
|
||||
assert.equal(result.needs_review, true)
|
||||
})
|
||||
|
||||
test('same-day independent rows remain separate and option labels support numeric/string dictionaries', () => {
|
||||
const rows = state.cloneReviewItems([item(), item({ id: 'blood-2', record_time: '12:00:00' })])
|
||||
assert.equal(rows.length, 2)
|
||||
assert.deepEqual(rows.map(row => row.record_time), ['08:00:00', '12:00:00'])
|
||||
assert.equal(state.fieldText('1', { type: 'select', options: [{ value: 1, label: '有' }] }), '有')
|
||||
})
|
||||
|
||||
test('API uses exact routes, version, header-auth blob and FormData with no automatic retry', async () => {
|
||||
const calls = []
|
||||
let reply = { code: 1, data: { items: [] } }
|
||||
const request = Object.fromEntries(['get', 'post'].map(method => [method, async (config, options) => { calls.push({ method, config, options }); return reply }]))
|
||||
const api = loadTs(path.join(root, 'src/api/followupAudio.ts'), () => ({ default: request }))
|
||||
await api.followupAudioLists(10)
|
||||
await api.followupAudioSaveDraft({ id: 1, version: 4, items: [item()] })
|
||||
reply = { code: 1, data: { index: 0, received: true } }
|
||||
await api.followupAudioUploadChunk('private-id', 0, new Blob(['chunk']))
|
||||
reply = new Blob(['audio'], { type: 'audio/wav' })
|
||||
assert.equal(await api.followupAudioBlob(1), reply)
|
||||
assert.deepEqual(calls.map(call => call.config.url), ['/tcm.followupAudio/lists', '/tcm.followupAudio/saveDraft', '/tcm.followupAudio/uploadChunk', '/tcm.followupAudio/audio'])
|
||||
assert.equal(calls[1].config.data.version, 4)
|
||||
assert.equal(calls[2].config.data.get('upload_id'), 'private-id')
|
||||
assert.equal(calls[2].config.data.get('file').size, 5)
|
||||
assert.deepEqual(calls[3].config.params, { id: 1 })
|
||||
assert.equal(calls[3].config.responseType, 'blob')
|
||||
assert.ok(calls.every(call => call.options.isOpenRetry === false && call.options.isTransformResponse === false))
|
||||
reply = { code: 0, msg: '版本已变化', data: { code: 'FOLLOWUP_AUDIO_STALE_REVIEW' } }
|
||||
await assert.rejects(api.followupAudioApply({ id: 1, version: 4, items: [item()] }), error => error.code === 'FOLLOWUP_AUDIO_STALE_REVIEW')
|
||||
reply = new Blob([JSON.stringify({ code: 0, msg: '未授权', data: {} })], { type: 'application/json' })
|
||||
await assert.rejects(api.followupAudioBlob(1), /未授权/)
|
||||
})
|
||||
|
||||
test('all new Vue scripts/templates/styles and integrated diagnosis editor compile', () => {
|
||||
for (const name of ['FollowupAudioField.vue', 'FollowupAudioReview.vue', 'FollowupAudioPanel.vue', '../edit.vue']) {
|
||||
const filename = path.join(componentDir, name)
|
||||
const { descriptor, errors } = parse(fs.readFileSync(filename, 'utf8'), { filename })
|
||||
assert.deepEqual(errors, [], name)
|
||||
const script = compileScript(descriptor, { id: name })
|
||||
assert.deepEqual(compileTemplate({ source: descriptor.template.content, filename, id: name, compilerOptions: { bindingMetadata: script.bindings } }).errors, [], name)
|
||||
for (const style of descriptor.styles) assert.deepEqual(compileStyle({ source: style.content, filename, id: name, scoped: style.scoped, preprocessLang: style.lang }).errors, [], name)
|
||||
}
|
||||
})
|
||||
|
||||
test('actual panel polls only visible tab and stops all timers/listeners on leave', async () => {
|
||||
const f = setupPanel({}, { active: false })
|
||||
await settle(); assert.deepEqual(f.calls, [])
|
||||
f.props.active = true; await settle()
|
||||
assert.deepEqual(f.calls, [['lists', 10]])
|
||||
assert.equal(f.timers.size, 1)
|
||||
f.document.visibilityState = 'hidden'; f.listeners.get('visibilitychange')(); await settle()
|
||||
assert.equal(f.timers.size, 0)
|
||||
f.tick(); await settle(); assert.equal(f.calls.length, 1)
|
||||
f.document.visibilityState = 'visible'; f.listeners.get('visibilitychange')(); await settle()
|
||||
assert.equal(f.timers.size, 1)
|
||||
f.dispose(); assert.equal(f.timers.size, 0); assert.equal(f.listeners.size, 0)
|
||||
})
|
||||
|
||||
test('old patient and disposed detail callbacks never populate current patient state or emit applied', async () => {
|
||||
const old = deferred()
|
||||
const f = setupPanel({ followupAudioDetail: () => old.promise })
|
||||
await settle()
|
||||
const opening = f.panel.openTask(1); await settle()
|
||||
f.props.diagnosisId = 20; await settle()
|
||||
old.resolve(task()); await opening; await settle()
|
||||
assert.equal(f.panel.detail.value, null)
|
||||
assert.deepEqual(f.panel.reviewItems.value, [])
|
||||
assert.deepEqual(f.events, [])
|
||||
f.dispose()
|
||||
})
|
||||
|
||||
test('upload actual panel sends actual recorded_at/model and chunks before complete/create', async () => {
|
||||
const f = setupPanel(); await settle()
|
||||
f.panel.file.value = new File([new Uint8Array(2097153)], 'synthetic.wav')
|
||||
f.panel.recordedAt.value = '2026-09-29 09:00:00'; f.panel.modelKey.value = 'openai'
|
||||
await f.panel.submitUpload(); await settle()
|
||||
assert.deepEqual(f.calls.filter(call => call[0] === 'chunk').map(call => call.slice(2)), [[0, 2097152], [1, 1]])
|
||||
assert.deepEqual(f.calls.find(call => call[0] === 'create')[1], { diagnosis_id: 10, upload_id: 'private-id', recorded_at: '2026-09-29 09:00:00', model_key: 'openai' })
|
||||
assert.ok(f.calls.findIndex(call => call[0] === 'complete') < f.calls.findIndex(call => call[0] === 'create'))
|
||||
assert.equal(f.panel.createUncertain.value, false)
|
||||
f.dispose()
|
||||
})
|
||||
|
||||
test('unverified and view-only panels never upload, save, retry or apply even through method calls', async () => {
|
||||
for (const patch of [{ viewOnly: true }, { capabilities: { ...caps(), audio_verified: false, can_apply: false } }]) {
|
||||
const f = setupPanel({}, patch); await settle(); await f.panel.openTask(1)
|
||||
f.panel.file.value = new File(['test'], 'test.wav'); f.panel.recordedAt.value = '2026-09-29 09:00:00'
|
||||
await f.panel.submitUpload(); await f.panel.saveDraft(); await f.panel.applySelected(); await f.panel.retryTask()
|
||||
assert.ok(f.calls.every(call => ['lists', 'detail'].includes(call[0])))
|
||||
f.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test('dirty basic form and unresolved date prevent actual apply, save retains version and immutable evidence', async () => {
|
||||
const f = setupPanel({}, { basicDirty: true }); await settle(); await f.panel.openTask(1)
|
||||
await f.panel.applySelected(); assert.ok(!f.calls.some(call => call[0] === 'apply'))
|
||||
f.props.basicDirty = false
|
||||
f.panel.reviewItems.value[0].record_date = null
|
||||
await f.panel.applySelected(); assert.ok(!f.calls.some(call => call[0] === 'apply'))
|
||||
await f.panel.saveDraft()
|
||||
const payload = f.calls.find(call => call[0] === 'save')[1]
|
||||
assert.equal(payload.version, 1)
|
||||
assert.equal(payload.items[0].expected_hash, 'immutable-hash')
|
||||
assert.deepEqual(payload.items[0].evidence, item().evidence)
|
||||
f.dispose()
|
||||
})
|
||||
|
||||
test('one apply sends every item atomically once and emits diagnosis refresh only after observed success', async () => {
|
||||
let applied = false
|
||||
const f = setupPanel({
|
||||
followupAudioDetail: async id => task(id, { status: applied ? 'applied' : 'review', items: [item(), item({ id: 'blood-2', record_time: '12:00:00' })] }),
|
||||
followupAudioApply: async payload => { f.calls.push(['apply', payload]); applied = true; return { id: 1, status: 'applied', applied_items: [] } }
|
||||
})
|
||||
await settle(); await f.panel.openTask(1); await f.panel.applySelected(); await f.panel.applySelected()
|
||||
const requests = f.calls.filter(call => call[0] === 'apply')
|
||||
assert.equal(requests.length, 1); assert.equal(requests[0][1].items.length, 2)
|
||||
assert.deepEqual(f.events, [['applied', 10]])
|
||||
f.dispose()
|
||||
})
|
||||
|
||||
test('stale save refreshes current snapshot, preserves local edits, and never silently resubmits', async () => {
|
||||
let stale = false
|
||||
const f = setupPanel({
|
||||
followupAudioDetail: async id => task(id, { version: stale ? 2 : 1, items: [item({ expected_hash: stale ? 'fresh-hash' : 'old-hash' })] }),
|
||||
followupAudioSaveDraft: async payload => { f.calls.push(['save', payload]); stale = true; throw new apiModule.FollowupAudioError('版本已变化', 'FOLLOWUP_AUDIO_STALE_REVIEW') }
|
||||
})
|
||||
await settle(); await f.panel.openTask(1)
|
||||
f.panel.reviewItems.value[0].values.fasting_blood_sugar = 7.9
|
||||
await f.panel.saveDraft()
|
||||
assert.equal(f.panel.detail.value.version, 2)
|
||||
assert.equal(f.panel.reviewItems.value[0].values.fasting_blood_sugar, 7.9)
|
||||
assert.equal(f.panel.reviewItems.value[0].expected_hash, 'fresh-hash')
|
||||
assert.equal(f.panel.reviewItems.value[0].selected, false)
|
||||
assert.equal(f.panel.reviewItems.value[0].needs_review, true)
|
||||
assert.equal(f.calls.filter(call => call[0] === 'save').length, 1)
|
||||
f.dispose()
|
||||
})
|
||||
|
||||
test('authenticated audio object URLs revoked on hide and stale blobs never create object URLs', async () => {
|
||||
const pending = deferred()
|
||||
let slow = false
|
||||
const f = setupPanel({ followupAudioBlob: async () => slow ? pending.promise : new Blob(['synthetic'], { type: 'audio/wav' }) })
|
||||
await settle(); await f.panel.openTask(1); await f.panel.loadAudio()
|
||||
assert.equal(f.urls.length, 1)
|
||||
f.props.active = false; await settle(); assert.deepEqual(f.revoked, f.urls)
|
||||
f.props.active = true; await settle(); slow = true
|
||||
const loading = f.panel.loadAudio(); await settle(); f.props.diagnosisId = 20; await settle()
|
||||
pending.resolve(new Blob(['old patient'])); await loading
|
||||
assert.equal(f.urls.length, 1)
|
||||
assert.equal(f.panel.audioUrl.value, '')
|
||||
f.dispose()
|
||||
})
|
||||
|
||||
test('editor capability gate, dirty guard and successful apply hook wire diagnosis plus daily refresh', () => {
|
||||
const source = fs.readFileSync(path.join(componentDir, '../edit.vue'), 'utf8')
|
||||
assert.match(source, /v-if="followupCapabilities\?\.enabled && formData.id"/)
|
||||
assert.match(source, /:before-leave="beforeTabLeave"/)
|
||||
assert.match(source, /next === 'followupAudio' && basicDirty.value/)
|
||||
assert.match(source, /await loadDiagnosisDetailIntoForm\(diagnosisId, true\)/)
|
||||
assert.match(source, /await dailyMatrixRef.value\?\.refresh\?\.\(\)/)
|
||||
assert.match(source, /if \(preserveDirty && basicDirty.value\)/)
|
||||
assert.match(source, /generation !== diagnosisGeneration \|\| !visible.value/)
|
||||
})
|
||||
|
||||
|
||||
test('successful save with review_refreshed preserves unsent edits and requires explicit re-review', async () => {
|
||||
const f = setupPanel({ followupAudioSaveDraft: async () => task(1, { version: 2, review_refreshed: true, items: [item({ expected_hash: 'fresh-hash', selected: false, needs_review: true })] }) })
|
||||
await settle(); await f.panel.openTask(1)
|
||||
f.panel.reviewItems.value[0].values.fasting_blood_sugar = 9.2
|
||||
await f.panel.saveDraft()
|
||||
assert.equal(f.panel.reviewItems.value[0].values.fasting_blood_sugar, 9.2)
|
||||
assert.equal(f.panel.reviewItems.value[0].expected_hash, 'fresh-hash')
|
||||
assert.equal(f.panel.reviewItems.value[0].selected, false)
|
||||
assert.equal(f.panel.reviewItems.value[0].needs_review, true)
|
||||
assert.match(f.panel.errorMessage.value, /版本已刷新/)
|
||||
assert.equal(f.notices.filter(notice => notice[0] === 'success').length, 0)
|
||||
f.dispose()
|
||||
})
|
||||
|
||||
|
||||
test('model defaults to first verified profile, including OpenAI-only and no verified profiles', async () => {
|
||||
const f = setupPanel({}, { capabilities: { ...caps(), models: [{ value: 'openai', label: 'OpenAI' }] } })
|
||||
await settle()
|
||||
assert.equal(f.panel.modelKey.value, 'openai')
|
||||
f.props.capabilities = { ...caps(), models: [], audio_verified: false, can_upload: false }
|
||||
await settle()
|
||||
assert.equal(f.panel.modelKey.value, '')
|
||||
assert.equal(f.panel.canUpload.value, false)
|
||||
f.dispose()
|
||||
})
|
||||
|
||||
test('actual diagnosis editor preserves dirty form, refreshes diagnosis + daily after apply, and ignores old patient load', async () => {
|
||||
const warnings = [], events = [], pending = deferred(), saving = deferred()
|
||||
let detailCalls = 0, dailyRefreshes = 0
|
||||
const row = (id, remark = '') => ({ id: String(id), patient_id: String(id + 100), patient_name: '合成患者', phone: '13800000000', id_card: '', remark, diabetes_discovery_year: '', show_card: 1 })
|
||||
const filename = path.join(componentDir, '../edit.vue')
|
||||
const descriptor = parse(fs.readFileSync(filename, 'utf8'), { filename }).descriptor
|
||||
const script = compileScript(descriptor, { id: 'editor-runtime' })
|
||||
const module = execute(script.content, name => {
|
||||
if (name === 'vue') return { ...vue, onBeforeUnmount() {} }
|
||||
if (name === '@/api/tcm') return { tcmDiagnosisEdit: () => saving.promise, tcmDiagnosisDetail: async ({ id }) => { detailCalls++; return id === 99 ? pending.promise : row(id, detailCalls > 1 ? '已采纳' : '') } }
|
||||
if (name === '@/api/app') return { getDictData: async () => ({}) }
|
||||
if (name === '@/api/followupAudio') return { followupAudioCapabilities: async () => caps() }
|
||||
if (name === '@/utils/feedback') return { default: { msgWarning: msg => warnings.push(msg) } }
|
||||
if (name === '@/utils/perm') return { hasPermission: () => true }
|
||||
if (name === '@/stores/modules/user') return { default: () => ({ userInfo: { id: 1 } }) }
|
||||
if (name === '@/stores/modules/app') return { default: () => ({ getImageUrl: value => value }) }
|
||||
if (name === 'element-plus') return { ElMessage: { warning() {} } }
|
||||
if (name === '@element-plus/icons-vue' || name === '@/api/patient') return {}
|
||||
if (name.endsWith('.vue')) return { default: {} }
|
||||
throw new Error(`Unexpected editor import: ${name}`)
|
||||
})
|
||||
const scope = vue.effectScope()
|
||||
try {
|
||||
const editor = scope.run(() => module.default.setup({}, { expose() {}, emit: (...args) => events.push(args) }))
|
||||
await editor.open('edit', 10); await settle()
|
||||
assert.equal(editor.basicDirty.value, false)
|
||||
editor.formData.value.remark = '尚未保存'
|
||||
assert.equal(await editor.beforeTabLeave('followupAudio', 'basic'), false)
|
||||
assert.equal(editor.formData.value.remark, '尚未保存')
|
||||
assert.equal(editor.canApplyFollowup(), false)
|
||||
editor.dailyMatrixRef.value = { refresh: async () => { dailyRefreshes++ } }
|
||||
await editor.handleFollowupApplied(10)
|
||||
assert.equal(editor.formData.value.remark, '尚未保存')
|
||||
assert.equal(dailyRefreshes, 1)
|
||||
await editor.open('edit', 20)
|
||||
await editor.handleFollowupApplied(20)
|
||||
assert.equal(editor.formData.value.remark, '已采纳')
|
||||
assert.equal(editor.basicDirty.value, false)
|
||||
assert.equal(dailyRefreshes, 2)
|
||||
const oldLoad = editor.open('edit', 99); await settle()
|
||||
await editor.open('edit', 30)
|
||||
pending.resolve(row(99, '旧患者'))
|
||||
await oldLoad
|
||||
assert.equal(editor.formData.value.id, '30')
|
||||
assert.notEqual(editor.formData.value.remark, '旧患者')
|
||||
assert.ok(warnings.length >= 2)
|
||||
editor.formData.value.remark = '提交时的内容'
|
||||
const save = editor.handleSubmit(); await settle()
|
||||
editor.formData.value.remark = '提交期间继续输入'
|
||||
saving.resolve({})
|
||||
await save
|
||||
assert.equal(editor.basicDirty.value, true)
|
||||
assert.equal(await editor.beforeTabLeave('followupAudio', 'basic'), false)
|
||||
assert.equal(editor.formData.value.remark, '提交期间继续输入')
|
||||
} finally { scope.stop() }
|
||||
})
|
||||
|
||||
|
||||
test('changed real-row target and changed record date require saving a fresh snapshot before apply', async () => {
|
||||
assert.equal(state.changedReviewTarget([item()], [item()]), false)
|
||||
assert.equal(state.changedReviewTarget([item({ target_id: 7 })], [item()]), true)
|
||||
assert.equal(state.changedReviewTarget([item({ record_date: '2026-09-28' })], [item()]), true)
|
||||
const f = setupPanel(); await settle(); await f.panel.openTask(1)
|
||||
f.panel.reviewItems.value[0].target_id = 7
|
||||
await f.panel.applySelected()
|
||||
assert.equal(f.calls.filter(call => call[0] === 'apply').length, 0)
|
||||
assert.match(f.panel.applyIssue.value, /先保存审核草稿/)
|
||||
f.dispose()
|
||||
})
|
||||
|
||||
|
||||
test('read-only identity fields cannot be selected or applied, including mixed editable/read-only items', async () => {
|
||||
const catalog = caps().fields
|
||||
catalog.diagnosis = [{ key: 'phone', label: '手机号', type: 'text', readonly: true }, { key: 'remark', label: '病史补充', type: 'text' }]
|
||||
const identity = item({ kind: 'diagnosis', values: { phone: '13800000000' } })
|
||||
assert.equal(state.hasEditableReviewFields(identity, catalog), false)
|
||||
assert.match(state.reviewFieldIssue(identity, catalog), /不可编辑/)
|
||||
const mixed = { ...identity, values: { ...identity.values, remark: '需补充' } }
|
||||
assert.equal(state.hasEditableReviewFields(mixed, catalog), true)
|
||||
assert.match(state.reviewFieldIssue(mixed, catalog), /手机号/)
|
||||
const f = setupPanel({ followupAudioDetail: async () => task(1, { fields: catalog, items: [mixed] }) })
|
||||
await settle(); await f.panel.openTask(1); await f.panel.applySelected()
|
||||
assert.equal(f.calls.filter(call => call[0] === 'apply').length, 0)
|
||||
delete f.panel.reviewItems.value[0].values.phone
|
||||
assert.equal(f.panel.applyIssue.value, '')
|
||||
f.dispose()
|
||||
const source = fs.readFileSync(path.join(componentDir, 'FollowupAudioReview.vue'), 'utf8')
|
||||
assert.match(source, /:disabled="disabled \|\| \(!row.selected && selectionBlocked\(row\)\)"/)
|
||||
assert.match(source, /!hasEditableReviewFields\(item, props.fields\)/)
|
||||
})
|
||||
|
||||
|
||||
test('accepted audio extensions match backend and include AMR, not video or unsupported containers', () => {
|
||||
for (const ext of ['mp3', 'M4A', 'wav', 'amr']) assert.equal(state.uploadIssue({ name: `test.${ext}`, size: 100 }, '2026-09-29 09:00:00', 'qwen', caps(), false), '')
|
||||
for (const ext of ['mp4', 'webm', 'flac', 'ogg', 'aac']) assert.match(state.uploadIssue({ name: `test.${ext}`, size: 100 }, '2026-09-29 09:00:00', 'qwen', caps(), false), /格式/)
|
||||
})
|
||||
|
||||
test('same-audio creation reuse opens original task and tells operator it was not resent', async () => {
|
||||
const f = setupPanel({ followupAudioCreate: async () => ({ task_id: 1, status: 'review', reused: true, reuse_message: '同一录音已有任务,不会重复调用模型' }) })
|
||||
await settle(); f.panel.file.value = new File(['synthetic'], 'call.amr'); f.panel.recordedAt.value = '2026-09-29 09:00:00'
|
||||
await f.panel.submitUpload(); await settle()
|
||||
assert.equal(f.panel.selectedId.value, 1)
|
||||
assert.match(f.panel.uploadStage.value, /不会重复/)
|
||||
assert.ok(f.notices.some(([kind, message]) => kind === 'warning' && /不会重复/.test(message)))
|
||||
f.dispose()
|
||||
})
|
||||
|
||||
test('definitive validation error can be corrected, network-unknown create still blocks resend', async () => {
|
||||
for (const [error, uncertain] of [[new apiModule.FollowupAudioError('实际通话时间无效'), false], [new Error('network failed'), true]]) {
|
||||
const f = setupPanel({ followupAudioCreate: async () => { throw error } })
|
||||
await settle(); f.panel.file.value = new File(['synthetic'], 'call.wav'); f.panel.recordedAt.value = '2026-09-29 09:00:00'
|
||||
await f.panel.submitUpload()
|
||||
assert.equal(f.panel.createUncertain.value, uncertain)
|
||||
f.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
test('minute-precision API times and legacy second-precision times are valid time-picker values', () => {
|
||||
assert.equal(state.reviewTimeValue('08:00'), '08:00')
|
||||
assert.equal(state.reviewTimeValue('12:05:00'), '12:05')
|
||||
assert.equal(state.reviewTimeValue(null), null)
|
||||
assert.equal(state.reviewTimeValue('25:00'), null)
|
||||
const review = fs.readFileSync(path.join(componentDir, 'FollowupAudioReview.vue'), 'utf8')
|
||||
assert.match(review, /:model-value="reviewTimeValue\(editor.record_time\)" value-format="HH:mm"/)
|
||||
})
|
||||
|
||||
|
||||
function setupReview(propsPatch = {}) {
|
||||
const filename = path.join(componentDir, 'FollowupAudioReview.vue')
|
||||
const descriptor = parse(fs.readFileSync(filename, 'utf8'), { filename }).descriptor
|
||||
const script = compileScript(descriptor, { id: 'review-runtime' })
|
||||
const module = execute(script.content, name => {
|
||||
if (name === 'vue') return vue
|
||||
if (name === './followupAudioState') return state
|
||||
if (name === './FollowupAudioField.vue') return { default: {} }
|
||||
throw new Error(`Unexpected review import: ${name}`)
|
||||
})
|
||||
const props = vue.reactive({ items: [item()], fields: caps().fields, disabled: false, canDaily: true, applied: false, uncertainties: [], ...propsPatch })
|
||||
const events = [], exposed = {}
|
||||
const scope = vue.effectScope()
|
||||
const review = scope.run(() => module.default.setup(props, { expose: value => Object.assign(exposed, value), emit: (event, ...args) => { events.push([event, ...args]); if (event === 'update:items') props.items = args[0] } }))
|
||||
return { review, props, events, exposed, dispose: () => scope.stop() }
|
||||
}
|
||||
|
||||
test('task list/detail navigation protects unsaved changes and closes the task only after confirmation', async () => {
|
||||
let allow = false
|
||||
const f = setupPanel({}, {}, { confirm: async () => { if (!allow) throw new Error('cancel') } })
|
||||
await settle()
|
||||
assert.equal(f.panel.selectedId.value, 0)
|
||||
assert.equal(f.panel.detail.value, null)
|
||||
await f.panel.openTask(1)
|
||||
f.panel.reviewItems.value[0].values.fasting_blood_sugar = 7.1
|
||||
await f.panel.backToList()
|
||||
assert.equal(f.panel.selectedId.value, 1)
|
||||
assert.equal(f.panel.reviewItems.value[0].values.fasting_blood_sugar, 7.1)
|
||||
allow = true
|
||||
await f.panel.backToList()
|
||||
assert.equal(f.panel.selectedId.value, 0)
|
||||
assert.equal(f.panel.detail.value, null)
|
||||
assert.deepEqual(f.panel.reviewItems.value, [])
|
||||
assert.equal(f.panel.dirty.value, false)
|
||||
assert.equal(f.timers.size, 1)
|
||||
f.dispose()
|
||||
})
|
||||
|
||||
test('upload is closed by default, cannot close while sending, closes after creation and respects empty profiles', async () => {
|
||||
const f = setupPanel(); await settle()
|
||||
assert.equal(f.panel.uploadVisible.value, false)
|
||||
f.panel.openUpload(); assert.equal(f.panel.uploadVisible.value, true)
|
||||
f.panel.uploading.value = true; f.panel.closeUpload(); assert.equal(f.panel.uploadVisible.value, true)
|
||||
f.panel.uploading.value = false; f.panel.closeUpload(); assert.equal(f.panel.uploadVisible.value, false)
|
||||
f.panel.openUpload(); f.panel.file.value = new File(['synthetic'], 'call.wav'); f.panel.recordedAt.value = '2026-09-29 09:00:00'
|
||||
await f.panel.submitUpload()
|
||||
assert.equal(f.panel.uploadVisible.value, false)
|
||||
assert.equal(f.panel.selectedId.value, 2)
|
||||
f.props.capabilities = { ...caps(), models: [] }
|
||||
assert.equal(f.panel.canUpload.value, false)
|
||||
f.panel.openUpload(); assert.equal(f.panel.uploadVisible.value, false)
|
||||
f.dispose()
|
||||
})
|
||||
|
||||
test('review group counts and pending filter never change hidden selections or their payload', () => {
|
||||
const rows = [item({ id: 'diagnosis-1', kind: 'diagnosis', values: { remark: '补充病史' } }), item({ id: 'blood-1', selected: false, needs_review: true }), item({ id: 'blood-2', record_time: '12:00' })]
|
||||
const f = setupReview({ items: rows })
|
||||
const original = JSON.stringify(f.props.items)
|
||||
assert.deepEqual(f.review.counts.value, { all: 3, diagnosis: 1, daily: 2, pending: 1, selected: 2 })
|
||||
for (const [group, ids] of [['diagnosis', ['diagnosis-1']], ['daily', ['blood-1', 'blood-2']], ['pending', ['blood-1']], ['all', ['diagnosis-1', 'blood-1', 'blood-2']]]) {
|
||||
f.review.group.value = group
|
||||
assert.deepEqual(f.review.visibleItems.value.map(row => row.id), ids)
|
||||
assert.equal(f.review.counts.value.selected, 2)
|
||||
assert.equal(JSON.stringify(f.props.items), original)
|
||||
}
|
||||
assert.deepEqual(f.events, [])
|
||||
f.dispose()
|
||||
})
|
||||
|
||||
test('single-item modal drafts are isolated: cancel discards, save preserves immutable evidence, confirm selects', () => {
|
||||
const f = setupReview()
|
||||
assert.equal(f.review.editor.value, null)
|
||||
f.review.openReview('blood-1')
|
||||
f.review.setValue('fasting_blood_sugar', 7.8)
|
||||
assert.equal(f.props.items[0].values.fasting_blood_sugar, 6.5)
|
||||
assert.equal(f.exposed.hasUnsavedChanges(), true)
|
||||
f.review.cancelReview()
|
||||
assert.equal(f.props.items[0].values.fasting_blood_sugar, 6.5)
|
||||
assert.equal(f.exposed.hasUnsavedChanges(), false)
|
||||
assert.equal(f.events.length, 0)
|
||||
f.review.openReview('blood-1'); f.review.setValue('fasting_blood_sugar', 7.8); f.review.saveReview(false)
|
||||
assert.equal(f.props.items[0].values.fasting_blood_sugar, 7.8)
|
||||
assert.equal(f.props.items[0].selected, false)
|
||||
assert.equal(f.props.items[0].needs_review, true)
|
||||
assert.equal(f.props.items[0].expected_hash, 'immutable-hash')
|
||||
assert.deepEqual(f.props.items[0].evidence, item().evidence)
|
||||
f.review.openReview('blood-1'); f.review.confirmReview(true); f.review.saveReview(true)
|
||||
assert.equal(f.review.editor.value, null)
|
||||
assert.equal(f.props.items[0].selected, true)
|
||||
assert.equal(f.props.items[0].needs_review, false)
|
||||
f.dispose()
|
||||
})
|
||||
|
||||
test('pending selection opens one review modal and cannot be adopted until explicitly confirmed', () => {
|
||||
const f = setupReview({ items: [item({ needs_review: true, selected: false })] })
|
||||
f.review.toggleSelection(f.props.items[0], true)
|
||||
assert.equal(f.review.editor.value.id, 'blood-1')
|
||||
assert.equal(f.props.items[0].selected, false)
|
||||
f.review.saveReview(true); assert.equal(f.props.items[0].selected, false)
|
||||
assert.equal(f.events.length, 0)
|
||||
f.review.confirmReview(true); f.review.saveReview(true)
|
||||
assert.equal(f.props.items[0].selected, true)
|
||||
f.dispose()
|
||||
})
|
||||
|
||||
test('review modal rejects locked fields and fake/deleted targets; changed dates require a fresh snapshot', () => {
|
||||
const fields = caps().fields
|
||||
fields.diagnosis.push({ key: 'phone', label: '手机号', type: 'text', readonly: true })
|
||||
const f = setupReview({ fields, items: [item({ selected: false, candidates: [{ id: 7, values: { fasting_blood_sugar: 5.8 }, record_time: '08:30', time_period: null, time_estimated: false }, { id: 9, values: {}, record_time: null, time_period: null, time_estimated: false, delete_time: 1 }] }), item({ id: 'identity', kind: 'diagnosis', values: { phone: '13800000000' }, selected: false })] })
|
||||
assert.equal(f.review.selectionBlocked(f.props.items[1]), true)
|
||||
f.review.toggleSelection(f.props.items[1], true)
|
||||
assert.equal(f.events.length, 0)
|
||||
f.review.openReview('identity'); f.review.setValue('phone', '13900000000')
|
||||
assert.equal(f.review.editor.value.values.phone, '13800000000')
|
||||
f.review.openReview('blood-1'); f.review.chooseTarget(99); assert.equal(f.review.editor.value.target_id, null)
|
||||
f.review.chooseTarget(9); assert.equal(f.review.editor.value.target_id, null)
|
||||
f.review.chooseTarget(7); assert.equal(f.review.editor.value.target_id, 7)
|
||||
assert.equal(f.review.targetChanged.value, true)
|
||||
f.review.confirmReview(true); f.review.saveReview(true)
|
||||
assert.equal(f.props.items[0].target_id, null)
|
||||
f.review.patchEditor({ record_date: '2026-09-28', target_id: null })
|
||||
assert.deepEqual(f.review.editorCandidates.value, [])
|
||||
f.review.chooseTarget(7); assert.equal(f.review.editor.value.target_id, null)
|
||||
f.review.saveReview(false)
|
||||
assert.equal(f.props.items[0].record_date, '2026-09-28')
|
||||
assert.equal(f.props.items[0].selected, false)
|
||||
assert.equal(f.props.items[0].needs_review, true)
|
||||
f.dispose()
|
||||
})
|
||||
|
||||
test('server refresh during a modal edit keeps local content, uses fresh evidence, and cancels stale selection', async () => {
|
||||
const f = setupReview()
|
||||
f.review.openReview('blood-1'); f.review.setValue('fasting_blood_sugar', 8.2)
|
||||
f.props.items = [item({ expected_hash: 'fresh-hash', evidence: [{ text: 'fresh evidence' }], current_values: { fasting_blood_sugar: 7 } })]
|
||||
await settle()
|
||||
assert.equal(f.review.editor.value.values.fasting_blood_sugar, 8.2)
|
||||
assert.equal(f.review.editor.value.expected_hash, 'fresh-hash')
|
||||
assert.deepEqual(f.review.editor.value.evidence, [{ text: 'fresh evidence' }])
|
||||
assert.equal(f.review.editor.value.selected, false)
|
||||
assert.equal(f.review.editor.value.needs_review, true)
|
||||
assert.equal(f.exposed.hasUnsavedChanges(), true)
|
||||
assert.match(f.review.editorNotice.value, /已保留/)
|
||||
f.dispose()
|
||||
})
|
||||
|
||||
test('panel leave guard includes unsaved modal edits; recording original is collapsed until playback', async () => {
|
||||
const f = setupPanel({}, {}, { confirm: async () => { throw new Error('cancel') } })
|
||||
await settle(); await f.panel.openTask(1)
|
||||
const r = setupReview({ items: f.panel.reviewItems.value })
|
||||
f.panel.reviewRef.value = r.exposed
|
||||
r.review.openReview('blood-1'); r.review.setValue('fasting_blood_sugar', 9)
|
||||
assert.equal(f.panel.dirty.value, true)
|
||||
assert.equal(await f.panel.confirmLeave(), false)
|
||||
r.review.cancelReview(); assert.equal(f.panel.dirty.value, false)
|
||||
assert.deepEqual(f.panel.detailSections.value, [])
|
||||
await f.panel.loadAudio(5000)
|
||||
assert.deepEqual(f.panel.detailSections.value, ['original'])
|
||||
await f.panel.backToList()
|
||||
assert.deepEqual(f.panel.detailSections.value, [])
|
||||
r.dispose(); f.dispose()
|
||||
})
|
||||
|
||||
test('applied review is immutable and clearly distinguishes written and omitted rows', () => {
|
||||
const f = setupReview({ applied: true, disabled: true, items: [item(), item({ id: 'omitted', selected: false, needs_review: true })] })
|
||||
const original = JSON.stringify(f.props.items)
|
||||
assert.deepEqual(f.review.itemStatus(f.props.items[0]), { label: '已写入', type: 'success' })
|
||||
assert.deepEqual(f.review.itemStatus(f.props.items[1]), { label: '未采纳', type: 'info' })
|
||||
f.review.openReview('blood-1'); f.review.setValue('fasting_blood_sugar', 10); f.review.removeField('fasting_blood_sugar'); f.review.confirmReview(false); f.review.saveReview(true); f.review.toggleSelection(f.props.items[0], false)
|
||||
assert.equal(JSON.stringify(f.props.items), original)
|
||||
assert.deepEqual(f.events, [])
|
||||
f.dispose()
|
||||
})
|
||||
|
||||
test('readonly field renders plain human-readable text, not disabled controls', async () => {
|
||||
const filename = path.join(componentDir, 'FollowupAudioField.vue')
|
||||
const descriptor = parse(fs.readFileSync(filename, 'utf8'), { filename }).descriptor
|
||||
const script = compileScript(descriptor, { id: 'field-render', inlineTemplate: true })
|
||||
const module = execute(script.content, name => name === 'vue' ? vue : name === './followupAudioState' ? state : (() => { throw new Error(`Unexpected field import: ${name}`) })())
|
||||
const app = vue.createSSRApp(module.default, { field: { key: 'smoking', label: '吸烟', type: 'select', options: [{ label: '否', value: 0 }] }, modelValue: 0, readonly: true })
|
||||
app.config.warnHandler = () => {}
|
||||
const html = await require('@vue/server-renderer').renderToString(app)
|
||||
assert.match(html, /readonly-value[^>]*>否<\/span>/)
|
||||
assert.doesNotMatch(html, /<input|<textarea|<el-select|disabled/)
|
||||
})
|
||||
|
||||
|
||||
test('saving and reopening a local target change still compares against the server snapshot', () => {
|
||||
const f = setupReview({ snapshotItems: [item()] })
|
||||
f.review.openReview('blood-1')
|
||||
f.review.patchEditor({ record_date: '2026-09-28' })
|
||||
f.review.saveReview(false)
|
||||
f.review.openReview('blood-1')
|
||||
assert.equal(f.review.targetChanged.value, true)
|
||||
assert.deepEqual(f.review.editorCandidates.value, [])
|
||||
f.review.confirmReview(true)
|
||||
f.review.saveReview(true)
|
||||
assert.equal(f.props.items[0].selected, false)
|
||||
f.props.snapshotItems = state.cloneReviewItems(f.props.items)
|
||||
assert.equal(f.review.targetChanged.value, false)
|
||||
f.review.saveReview(true)
|
||||
assert.equal(f.props.items[0].selected, true)
|
||||
f.dispose()
|
||||
})
|
||||
|
||||
|
||||
test('unsaved modal edits cannot be bypassed by parent save/apply commands', async () => {
|
||||
const f = setupPanel(); await settle(); await f.panel.openTask(1)
|
||||
const r = setupReview({ items: f.panel.reviewItems.value })
|
||||
f.panel.reviewRef.value = r.exposed
|
||||
r.review.openReview('blood-1'); r.review.setValue('fasting_blood_sugar', 9)
|
||||
await f.panel.saveDraft(); await f.panel.applySelected()
|
||||
assert.equal(f.calls.filter(call => call[0] === 'save' || call[0] === 'apply').length, 0)
|
||||
assert.equal(r.review.editor.value.values.fasting_blood_sugar, 9)
|
||||
assert.ok(f.notices.every(([, message]) => message.includes('单项修改')))
|
||||
r.dispose(); f.dispose()
|
||||
})
|
||||
|
||||
|
||||
test('narrow review tables keep the review action reachable and show each event date in its content cell', () => {
|
||||
const source=fs.readFileSync(path.join(componentDir,'FollowupAudioReview.vue'),'utf8')
|
||||
assert.match(source, /label="操作"[^>]*fixed="right"/)
|
||||
assert.match(source, /class="item-meta mobile-event-time"/)
|
||||
assert.match(source, /\.mobile-event-time \{ display: block; \}/)
|
||||
})
|
||||
Reference in New Issue
Block a user