Files
zyt/admin/src/views/tcm/diagnosis/components/FollowupAudioReview.vue
T

235 lines
22 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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"><div v-if="quote.channel === 0 || quote.channel === 1" class="muted">{{ followupChannelLabel(quote.channel, channelRoles) }}</div><span>{{ quote.text }}</span><el-button v-if="evidencePlaybackLabel(quote)" link type="primary" @click="seekEvidence(quote)">{{ evidencePlaybackLabel(quote) }}</el-button></blockquote>
<p v-if="editor.evidence.some(quote => quote.position_type === 'segment')" class="muted">回听位置为转写片段范围,不是原话的逐字时间戳;请结合原始录音核对。</p>
<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" :disabled="Object.keys(editor.values).length <= 1" @click="removeField(field.key)">移除</el-button></div>
</div>
<p v-if="!disabled && !applied" class="muted">仅可更正或移除本项提议字段,不采纳整项请取消勾选;新增内容请返回原诊单编辑人工补录。</p>
<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 { FollowupAudioChannel, FollowupChannelRoles, FollowupField, FollowupFields, FollowupReviewItem, FollowupValue } from '@/api/followupAudio'
import FollowupAudioField from './FollowupAudioField.vue'
import { cloneReviewItems, evidencePlaybackLabel, fieldText, followupChannelLabel, 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[]; channelRoles?: FollowupChannelRoles }>()
const emit = defineEmits<{ (event: 'update:items', items: FollowupReviewItem[]): void; (event: 'seek', milliseconds: number, channel?: FollowupAudioChannel): 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 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
// Detail/snapshot items are editable drafts, not the server-owned original proposal.
if (update.values && Object.keys(update.values).some(key => !Object.prototype.hasOwnProperty.call(editor.value?.values, key))) return
editor.value = { ...editor.value, ...update, selected: false, needs_review: true }
}
function setValue(key: string, value: FollowupValue) {
if (!editor.value || !Object.prototype.hasOwnProperty.call(editor.value.values, key)
|| !(props.fields[editor.value.kind] || []).some(field => field.key === key && !field.readonly)) return
patchEditor({ values: { ...editor.value.values, [key]: value } })
}
function removeField(key: string) { if (!editor.value || Object.keys(editor.value.values).length <= 1) 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 seekEvidence(quote: FollowupReviewItem['evidence'][number]) {
if (!evidencePlaybackLabel(quote) || typeof quote.start_ms !== 'number') return
if (quote.channel === 0 || quote.channel === 1) emit('seek', quote.start_ms, quote.channel)
else emit('seek', quote.start_ms)
}
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; }
.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>