feat: add reviewed follow-up audio patient enrichment

This commit is contained in:
2026-09-29 15:44:30 +08:00
parent 3cdc812f2b
commit 70be2fc70f
46 changed files with 7384 additions and 246 deletions
@@ -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>