235 lines
22 KiB
Vue
235 lines
22 KiB
Vue
<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>
|