This commit is contained in:
gr
2026-10-15 11:56:36 +08:00
35 changed files with 2289 additions and 72 deletions
@@ -205,8 +205,8 @@
</header>
<dl class="daily-matrix__detail-fields">
<dt>运动</dt><dd>{{ record.exercise_type || '—' }}</dd>
<dt>时长</dt><dd>{{ record.duration ?? '—' }} 分钟</dd>
<dt>强度</dt><dd>{{ record.intensity_text || intensityLabel(record.intensity) }}</dd>
<dt>时长</dt><dd>{{ record.duration == null ? '未记录' : `${record.duration} 分钟` }}</dd>
<dt>强度</dt><dd>{{ intensityLabel(record.intensity) }}</dd>
<dt>备注</dt><dd>{{ record.note || '—' }}</dd>
</dl>
<div v-if="record.images?.length" class="daily-matrix__detail-images">
@@ -343,11 +343,13 @@
<el-option label="其他" value="其他" />
</el-select>
</el-form-item>
<el-form-item label="运动时长(分钟)" required>
<el-input-number v-model="exerciseForm.duration" :min="0" :max="300" />
<el-form-item label="运动时长(分钟)" :required="!exerciseForm.id">
<el-input-number v-model="exerciseForm.duration" :min="0" :max="300" placeholder="未记录" />
<span v-if="exerciseForm.id" class="ml-2 text-info">留空表示未记录</span>
</el-form-item>
<el-form-item label="运动强度" required>
<el-select v-model="exerciseForm.intensity" placeholder="请选择运动强度" class="w-full">
<el-form-item label="运动强度">
<el-select v-model="exerciseIntensity" placeholder="未记录" class="w-full">
<el-option label="未记录" value="" />
<el-option label="低强度" :value="1" />
<el-option label="中强度" :value="2" />
<el-option label="高强度" :value="3" />
@@ -492,8 +494,8 @@ interface ExerciseRecord {
patient_id?: number
record_date?: string
exercise_type?: string
duration?: number
intensity?: number
duration?: number | null
intensity?: number | null
intensity_text?: string
images?: string[]
note?: string
@@ -547,6 +549,11 @@ const trackingSubmitting = ref(false)
const bloodForm = ref<BloodRecord>(createBloodForm())
const dietForm = ref<DietRecord>(createDietForm())
const exerciseForm = ref<ExerciseRecord>(createExerciseForm())
// el-option uses a string sentinel; persisted unknowns remain SQL/JSON null.
const exerciseIntensity = computed<number | ''>({
get: () => exerciseForm.value.intensity ?? '',
set: (value) => { exerciseForm.value.intensity = value === '' ? null : value }
})
const trackingForm = ref({
note_date: '',
existing_content: '',
@@ -719,8 +726,8 @@ function recordTimePeriodLabel(record: BloodRecord) {
return labels[period] || period || '未提供时段'
}
function intensityLabel(value?: number) {
return ({ 1: '低强度', 2: '中强度', 3: '高强度' } as Record<number, string>)[Number(value)] || '—'
function intensityLabel(value?: number | null) {
return ({ 1: '低强度', 2: '中强度', 3: '高强度' } as Record<number, string>)[Number(value)] || '未记录'
}
function bloodDetailFields(record: BloodRecord) {
@@ -880,7 +887,7 @@ function getCell(metric: string, date: string): { value: string; isHigh: boolean
return { value: count ? '✓' : '', isHigh: false, hasRecord: count > 0 }
}
if (metric === 'exercise') {
return { value: exercise.map((record) => record.duration ? `${record.duration}min` : record.exercise_type || '已记录').join(' / '), isHigh: false, hasRecord: exercise.length > 0 }
return { value: exercise.map((record) => record.duration != null ? `${record.duration}min` : record.exercise_type || '已记录').join(' / '), isHigh: false, hasRecord: exercise.length > 0 }
}
if (metric === 'tracking') {
return { value: tracking?.content ? summarizeTracking(tracking.content) : '', isHigh: false, hasRecord: !!tracking?.content }
@@ -970,7 +977,12 @@ function openDietRecord(source: DietRecord | undefined, date: string) {
function openExerciseRecord(source: ExerciseRecord | undefined, date: string) {
if (!canEditDailyRecord.value) return
exerciseForm.value = { ...createExerciseForm(date), ...source, images: [...(source?.images || [])] }
exerciseForm.value = {
...createExerciseForm(date), ...source,
// Existing omissions must not inherit the defaults used only for manual new records.
...(source ? { duration: source.duration ?? null, intensity: source.intensity ?? null } : {}),
images: [...(source?.images || [])]
}
exerciseDialogVisible.value = true
}
@@ -1063,12 +1075,12 @@ async function submitExercise() {
feedback.msgWarning('请选择运动类型')
return
}
if (!exerciseForm.value.duration) {
if (!exerciseForm.value.id && !exerciseForm.value.duration) {
feedback.msgWarning('请输入运动时长')
return
}
const targetDiagnosisId = diagnosisId.value
const payload = { ...exerciseForm.value }
const payload = { ...exerciseForm.value, duration: exerciseForm.value.duration ?? null, intensity: exerciseForm.value.intensity ?? null }
exerciseSubmitting.value = true
try {
if (payload.id) {
@@ -34,8 +34,8 @@
<el-table-column label="状态" width="145">
<template #default="{ row }"><el-tag size="small" :type="statusType(row.status)">{{ followupTaskStatus(row) }}</el-tag><div v-if="row.transcript_available && (row.status === 'failed' || row.status === 'needs_reconciliation')" class="task-time">转写已保留</div></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 label="操作" width="112" align="right">
<template #default="{ row }"><el-button link type="primary" :disabled="busy" @click="openTask(row.id)">查看</el-button><el-button v-if="!viewOnly && row.can_delete !== undefined" link type="danger" :disabled="busy || !canDeleteTask(row)" :title="row.delete_block_reason || '删除未入单录音任务'" @click="deleteRecording(row)">删除</el-button></template>
</el-table-column>
</el-table>
</div>
@@ -43,7 +43,7 @@
<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)">{{ followupTaskStatus(detail) }}</el-tag>
<div class="detail-actions"><el-tag size="small" :type="statusType(detail.status)">{{ followupTaskStatus(detail) }}</el-tag><el-button v-if="!viewOnly && detail.can_delete !== undefined" link type="danger" :disabled="busy || !canDeleteTask(detail)" :title="detail.delete_block_reason || '删除未入单录音任务'" @click="deleteRecording(detail)">删除录音</el-button></div>
</div>
<p v-if="detail.status === 'running' || detail.status === 'queued'" class="processing-state" role="status">{{ followupProgressText(detail) }}</p>
<el-alert v-if="detail.error_message || detail.status === 'failed'" :type="detail.status === 'failed' ? 'error' : 'warning'" :closable="false" :title="detail.error_message || '任务处理失败,不能写入;请先核对失败原因。'" />
@@ -56,13 +56,25 @@
<p>{{ detail.summary || '暂无回访摘要' }}</p>
<div class="summary-counts"><span>{{ detail.status === 'applied' ? '已写入' : '已选' }} <strong>{{ selectedCount }}</strong> 项</span><span>待核实 <strong>{{ pendingCount }}</strong> 项</span></div>
</div>
<section v-if="hasStereo" class="channel-role-box" aria-label="声道角色人工标注">
<el-alert v-if="detail.status === 'review' && !reviewItems.length" type="warning" :closable="false" title="未生成可写入信息" :description="detail.uncertainties.length ? `本次提炼保留了 ${detail.uncertainties.length} 条待核实原因,已展示在下方。请结合原文回听核对,尤其注意声道角色是否相符。` : '请查看回访摘要并回听录音;本次没有可供确认写入的结构化候选。'" show-icon />
<section v-if="hasStereo && !['queued', 'running'].includes(detail.status)" class="channel-role-box" aria-label="声道角色人工标注">
<div v-if="savedChannelRoles !== 'unconfirmed' && !editingRoles && !roleDirty" class="role-summary"><span>{{ savedChannelRoles === 'left_service' ? '左客服端 / 右患者或家属端' : '右客服端 / 左患者或家属端' }} · 已确认</span><el-button v-if="canEditReview" link type="primary" @click="editingRoles = true">修改</el-button></div>
<label v-else>声道角色(人工确认)<el-select :model-value="channelRoles" :disabled="!canEditReview || (busy && mutation !== 'save')" @update:model-value="setChannelRoles"><el-option label="角色待确认" value="unconfirmed" /><el-option label="左客服端 / 右患者或家属端" value="left_service" /><el-option label="右客服端 / 左患者或家属端" value="right_service" /></el-select></label>
<p v-if="savedChannelRoles === 'unconfirmed' || editingRoles || roleDirty" class="muted">声道不等于已核实的说话人身份。角色标注不自动重新提炼;变更后保存草稿会取消全部采纳,须重新核对。</p>
<p v-if="savedChannelRoles !== 'unconfirmed' && !roleDirty" class="muted">当前角色已确认,无需再次选择;只有左右标错时才需修改。</p>
<p v-if="roleDirty" class="review-error" role="status">角色标注正在等待保存;保存后请逐项重新核对。</p>
</section>
<section v-if="detail.role_check?.conflict || (detail.can_reextract !== undefined && savedChannelRoles !== extractionRoles && savedChannelRoles !== 'unconfirmed')" class="role-reextract" aria-label="角色纠错与重新提炼">
<strong>{{ detail.role_check?.conflict ? '声道角色与转写内容可能不一致' : '审核角色已修改,原提炼结果尚未更新' }}</strong>
<p>{{ detail.role_check?.message || '修改角色标注不会自动改变已生成的建议,可按核实后的角色重新提炼。' }}</p>
<blockquote v-for="(evidence, index) in detail.role_check?.evidence || []" :key="index"><span>{{ evidence.channel === 0 ? '左声道' : '右声道' }} · {{ audioTimestamp(evidence.start_ms) }}:{{ evidence.text }}</span><el-button v-if="detail.audio_available" link type="primary" @click="loadDialogue(evidence.start_ms)">回听依据</el-button></blockquote>
<p>当前提炼依据:{{ extractionRoleLabel(extractionRoles) }}。{{ reextractTarget !== 'unconfirmed' ? `待确认的新角色:${extractionRoleLabel(reextractTarget)}。` : '请先回听并修改上方角色标注。' }}建议仅供核对,系统不会自动交换角色。</p>
<el-button type="primary" plain :disabled="!canReextract || busy || dirty || reextractTarget === 'unconfirmed' || reextractTarget === extractionRoles" @click="reextractWithRoles">确认角色并重新提炼</el-button>
<p v-if="dirty">请先完成当前单项核对或取消编辑,等待审核草稿保存后再重新提炼。</p>
<p v-else-if="detail.reextract_block_reason">{{ detail.reextract_block_reason }}</p>
<p class="muted">复用已完成的转写,不重复语音识别;旧结果保留,重新提炼不自动写入病例。</p>
</section>
<p v-if="(detail.extraction_revision || 0) > 0" class="muted">第 {{ (detail.extraction_revision || 0) + 1 }} 版提炼 · {{ extractionRoleLabel(extractionRoles) }} · 历史结果已保留</p>
<FollowupAudioReview :key="selectedId" ref="reviewRef" v-model:items="reviewItems" :fields="detail.fields || capabilities.fields" :disabled="!canEditReview || uploading || (mutation !== '' && mutation !== 'save')" :can-daily="capabilities.can_daily" :applied="detail.status === 'applied'" :uncertainties="detail.uncertainties" :snapshot-items="detail.items" :channel-roles="savedChannelRoles" :save-state="saveState" :persisted-revision="persistedRevision" @saved-next="saveDraft(true)" @seek="loadDialogue">
<template #player>
<div class="audio-player">
@@ -144,13 +156,13 @@
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
FollowupAudioError, followupAudioApply, followupAudioBlob, followupAudioCreate,
FollowupAudioError, followupAudioReextract, followupAudioDelete, followupAudioApply, followupAudioBlob, followupAudioCreate,
followupAudioDetail, followupAudioLists, followupAudioRetry, followupAudioSaveDraft,
followupAudioUploadChunk, followupAudioUploadComplete, followupAudioUploadSession,
type FollowupAudioChannel, type FollowupCapabilities, type FollowupChannelRoles, type FollowupReviewItem, type FollowupTaskDetail, type FollowupTaskSummary
} from '@/api/followupAudio'
import FollowupAudioReview from './FollowupAudioReview.vue'
import { channelPlaybackLabel, dialogueWindows, dialoguePlaybackStart, audioTimestamp, changedReviewTarget, cloneReviewItems, createFollowupScope, followupAudioReady, followupCanApply, followupCanReview, followupChannelLabel, followupError, followupFeatureEnabled, followupProgressText, followupTaskStatus, hasStereoTranscript, normalizedChannelRoles, rebaseReviewItems, retainedTranscriptNotice, reviewFieldIssue, reviewIssue, reviewPending, uploadChunks, uploadIssue } from './followupAudioState'
import { channelPlaybackLabel, dialogueWindows, dialoguePlaybackStart, audioTimestamp, changedReviewTarget, cloneReviewItems, createFollowupScope, followupAudioReady, followupCanApply, followupCanReview, followupChannelLabel, followupError, followupFeatureEnabled, followupProgressText, followupTaskStatus, hasStereoTranscript, normalizedChannelRoles, rebaseReviewItems, retainedTranscriptNotice, reviewBatchIssue, reviewFieldIssue, reviewIssue, reviewPending, uploadChunks, uploadIssue } from './followupAudioState'
import { createFollowupAudioPlayback, type FollowupPlaybackChannel } from './followupAudioPlayback'
const props = withDefaults(defineProps<{
diagnosisId: number; viewOnly?: boolean; active?: boolean; capabilities: FollowupCapabilities;
@@ -194,7 +206,7 @@ const uploading = ref(false)
const uploadProgress = ref(0)
const uploadStage = ref('')
const createUncertain = ref(false)
const mutation = ref<'save' | 'apply' | 'retry' | ''>('')
const mutation = ref<'save' | 'apply' | 'retry' | 'delete' | 'reextract' | ''>('')
const errorMessage = ref('')
const audioUrl = ref('')
const audioElement = ref<HTMLAudioElement | null>(null)
@@ -210,6 +222,12 @@ const audioReady = computed(() => followupAudioReady(props.capabilities))
const canUpload = computed(() => !props.viewOnly && featureEnabled.value && audioReady.value && props.capabilities.can_upload && props.capabilities.models.length > 0)
const canEditReview = computed(() => !props.viewOnly && followupCanReview(props.capabilities, !!detail.value?.preview_only) && detail.value?.status === 'review')
const canApplyReview = computed(() => !props.viewOnly && followupCanApply(props.capabilities, !!detail.value?.preview_only) && detail.value?.status === 'review')
const extractionRoles = computed(() => normalizedChannelRoles(detail.value?.extraction_channel_roles))
const reextractTarget = computed<FollowupChannelRoles>(() => {
if (savedChannelRoles.value !== 'unconfirmed' && savedChannelRoles.value !== extractionRoles.value) return savedChannelRoles.value
return normalizedChannelRoles(detail.value?.role_check?.suggested_roles)
})
const canReextract = computed(() => !props.viewOnly && featureEnabled.value && detail.value?.status === 'review' && detail.value?.can_reextract === true)
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(() => previewOnly.value ? '测试预览仅保存审核草稿,不允许确认写入病历'
@@ -220,7 +238,7 @@ const applyIssue = computed(() => previewOnly.value ? '测试预览仅保存审
: hasStereo.value && savedChannelRoles.value === 'unconfirmed' ? '请先回听并确认声道角色,保存草稿后重新核对建议'
: 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) || '')
: reviewBatchIssue(reviewItems.value, detail.value?.fields || props.capabilities.fields) || 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 && featureEnabled.value && documentVisible.value)
const scope = createFollowupScope()
let timer: ReturnType<typeof setTimeout> | undefined
@@ -270,9 +288,25 @@ function setChannelRoles(value: unknown) {
if (!canEditReview.value || (busy.value && mutation.value !== 'save') || !hasStereo.value || !['unconfirmed', 'left_service', 'right_service'].includes(String(value))) return
channelRoles.value = value as FollowupChannelRoles
}
async function readDetail(id: number, preserve = false) {
function taskUnavailable(error: unknown): boolean {
return error instanceof FollowupAudioError && (error.code === 'FOLLOWUP_AUDIO_TASK_UNAVAILABLE' || error.message.includes('FOLLOWUP_AUDIO_TASK_UNAVAILABLE'))
}
function forgetUnavailableTask(id: number) {
if (selectedId.value !== id) return
stop(); resetRoles(); selectedId.value = 0; detail.value = null; reviewItems.value = []; snapshot.value = '[]'; detailSections.value = []
tasks.value = tasks.value.filter(row => row.id !== id)
errorMessage.value = '录音已删除或不再可访问,已关闭本页缓存与播放;未写入病例。'
void refresh()
}
async function readDetail(id: number, preserve = false, verifyOnly = false) {
const ticket = scope.capture()
const fresh = await followupAudioDetail(id, ticket.signal)
let fresh: FollowupTaskDetail
try { fresh = await followupAudioDetail(id, ticket.signal) }
catch (error) {
if (scope.current(ticket) && id === selectedId.value && taskUnavailable(error)) { forgetUnavailableTask(id); return }
throw error
}
if (verifyOnly && detail.value && fresh.version === detail.value.version && fresh.status === detail.value.status) return
if (!scope.current(ticket) || id !== selectedId.value || (detail.value && fresh.version < detail.value.version)) return
preserve = preserve || (!!detail.value && dirty.value)
const newlyApplied = detail.value?.status === 'review' && fresh.status === 'applied'
@@ -292,6 +326,9 @@ async function refresh() {
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)
} else if (!updated && selectedId.value && detail.value) {
// A limited list can omit older tasks; only an authoritative detail denial revokes them.
await readDetail(selectedId.value, false, true)
}
} catch (error) { if (scope.current(ticket)) errorMessage.value = followupError(error) }
finally {
@@ -382,6 +419,7 @@ async function submitUpload() {
}
async function handleReviewError(error: unknown) {
const ticket = scope.capture()
if (taskUnavailable(error) && detail.value) { forgetUnavailableTask(detail.value.id); return }
errorMessage.value = followupError(error)
const stale = error instanceof FollowupAudioError && error.code === 'FOLLOWUP_AUDIO_STALE_REVIEW'
if (stale || /STALE_REVIEW|版本|快照|已变化/.test(errorMessage.value)) {
@@ -488,6 +526,57 @@ async function applySelected() {
} catch (error) { if (scope.current(ticket)) await handleReviewError(error) }
finally { if (scope.current(ticket)) { mutation.value = ''; await refresh() } }
}
function extractionRoleLabel(roles: FollowupChannelRoles): string {
return roles === 'left_service' ? '左客服 / 右患者或家属' : roles === 'right_service' ? '右客服 / 左患者或家属' : '角色未确认'
}
async function reextractWithRoles() {
const roles = reextractTarget.value
if (!canReextract.value || !detail.value || busy.value || roles === 'unconfirmed' || roles === extractionRoles.value) return
if (dirty.value) { ElMessage.warning('请先完成或取消当前审核修改,等待草稿保存后再重新提炼'); return }
const ticket = scope.capture(), id = detail.value.id, version = detail.value.version, diagnosisId = props.diagnosisId
const current = () => scope.current(ticket) && diagnosisId === props.diagnosisId && selectedId.value === id
clearAutosave(); clearTimer(); mutation.value = 'reextract'; errorMessage.value = ''
let confirmed = false
try {
await ElMessageBox.confirm(`确认已回听并核实角色为“${extractionRoleLabel(roles)}”?将保留旧版结果,复用已完成的转写重新提炼,不重复语音识别;新建议须重新审核,不自动写入病例。`, '确认角色并重新提炼', { type: 'warning', confirmButtonText: '角色正确,开始重新提炼', cancelButtonText: '返回回听' })
if (!current() || !canReextract.value || dirty.value) return
confirmed = true
const fresh = await followupAudioReextract({ id, version, channel_roles: roles }, ticket.signal)
if (!current()) return
installDetail(fresh, false); saveState.value = 'idle'; editingRoles.value = false
ElMessage.success('已提交重新提炼,将复用原转写;新结果仍需审核,不自动写入病例')
} catch (error) { if (confirmed && current()) await handleReviewError(error) }
finally { if (current()) { mutation.value = ''; scheduleAutosave(); await refresh() } }
}
function canDeleteTask(task: FollowupTaskSummary): boolean {
return !props.viewOnly && featureEnabled.value && task.can_delete === true && ['review', 'failed'].includes(task.status)
}
async function deleteRecording(task: FollowupTaskSummary) {
if (busy.value || !canDeleteTask(task)) return
const ticket = scope.capture(), diagnosisId = props.diagnosisId, id = task.id, version = task.version
const current = () => scope.current(ticket) && diagnosisId === props.diagnosisId
clearAutosave(); clearTimer(); mutation.value = 'delete'; errorMessage.value = ''
let confirmed = false
try {
await ElMessageBox.confirm('删除后,该录音任务及审核草稿将从列表移除,未保存修改会丢弃;不删除或回滚病例。原录音和转写仍按原保留期限清理。重新上传同一文件将创建新的分析任务。确认删除?', '删除录音任务', { type: 'warning', confirmButtonText: '确认删除', cancelButtonText: '保留录音' })
if (!current() || !canDeleteTask(task)) return
confirmed = true
await followupAudioDelete({ id, version }, ticket.signal)
if (!current()) return
// Fence pending playback, autosaves and reads; deletion never revives a stale draft.
if (selectedId.value === id) {
stop(); resetRoles(); selectedId.value = 0; detail.value = null; reviewItems.value = []; snapshot.value = '[]'; detailSections.value = []
}
tasks.value = tasks.value.filter(row => row.id !== id)
errorMessage.value = ''
ElMessage.success('录音任务已删除,未修改病例。原资料按保留策略清理。')
await refresh()
} catch (error) {
if (confirmed && current()) errorMessage.value = followupError(error)
} finally {
if (current()) { mutation.value = ''; scheduleAutosave(); await refresh() }
}
}
async function retryTask() {
if (!detail.value?.can_retry || detail.value.status !== 'failed' || !canUpload.value || busy.value) return
const ticket = scope.capture()
@@ -577,6 +666,11 @@ h3 { margin: 0; font-size: 14px; font-weight: 600; }
.detail-heading { align-items: flex-start; }
.recording-title { min-width: 0; }
.recording-title h3 { overflow-wrap: anywhere; line-height: 1.6; }
.role-reextract { padding: 12px; border: 1px solid var(--el-color-warning-light-5); background: var(--el-color-warning-light-9); border-radius: 6px; display: grid; gap: 8px; }
.role-reextract p { margin: 0; line-height: 1.6; }
.role-reextract blockquote { margin: 0; padding: 8px; background: var(--el-bg-color); white-space: pre-wrap; overflow-wrap: anywhere; }
.role-reextract :deep(.el-button) { justify-self: start; }
.detail-actions { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.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; }
@@ -32,8 +32,8 @@
</el-table-column>
</el-table>
</div>
<section v-if="group === 'pending' && uncertainties?.length" class="uncertainty-list" aria-label="原文待核实信息">
<strong>原文中还需补充的信息</strong>
<section v-if="(group === 'pending' || !items.length) && uncertainties?.length" class="uncertainty-list" aria-label="原文待核实信息">
<strong>本次未能确认的信息与原因</strong>
<ul><li v-for="(value, index) in uncertainties" :key="index">{{ uncertaintyText(value) }}</li></ul>
</section>
<section ref="reviewWorkspace" class="review-workspace" tabindex="-1" aria-label="同页核对与回听">
@@ -70,6 +70,22 @@ export function fieldText(value: FollowupValue | undefined, field: FollowupField
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))
}
/** Prevent multiple selected writes to the same diagnosis field; never silently pick a last value. */
export function reviewBatchIssue(items: FollowupReviewItem[], fields: FollowupFields): string {
const touched = new Set<string>()
for (const item of items.filter(item => item.selected)) {
if (item.kind === 'diagnosis') {
for (const key of Object.keys(item.values)) {
if (touched.has(key)) {
const label = fields.diagnosis?.find(field => field.key === key)?.label || key
return `${label}存在多条选中建议,请合并内容到一项或仅保留一项后再确认写入`
}
touched.add(key)
}
}
}
return ''
}
export function reviewFieldIssue(item: FollowupReviewItem, fields: FollowupFields): string {
if (!item.selected) return ''
for (const key of Object.keys(item.values)) {
+60 -13
View File
@@ -671,9 +671,14 @@
<el-empty v-else description="请先保存诊单后查看" />
</el-tab-pane>
<el-tab-pane v-if="followupCapabilities?.enabled && formData.id" label="回访录音" name="followupAudio" lazy>
<el-tab-pane v-if="formData.id && hasFollowupAudioPermission" label="回访录音" name="followupAudio" lazy>
<el-alert v-if="followupCapabilitiesLoading" title="正在检查回访录音状态…" type="info" :closable="false" show-icon />
<div v-else-if="followupCapabilitiesError" role="alert">
<el-alert :title="followupCapabilitiesError" type="error" :closable="false" show-icon />
<el-button class="mt-3" @click="loadFollowupCapabilities">重新加载</el-button>
</div>
<FollowupAudioPanel
v-if="visible && formData.id && followupCapabilities"
v-else-if="visible && followupCapabilities?.enabled && followupCapabilitiesDiagnosisId === Number(formData.id)"
:key="Number(formData.id)"
ref="followupAudioRef"
:diagnosis-id="Number(formData.id)"
@@ -684,6 +689,13 @@
:before-apply="canApplyFollowup"
@applied="handleFollowupApplied"
/>
<el-alert
v-else
:title="followupCapabilities?.test_scope_allowed === false ? '当前诊单未开放回访录音' : '当前未启用回访录音,请联系管理员确认'"
type="info"
:closable="false"
show-icon
/>
</el-tab-pane>
<!-- 病历记录标签页(开方后自动显示) -->
@@ -833,6 +845,9 @@ 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 followupCapabilitiesLoading = ref(false)
const followupCapabilitiesError = ref('')
const followupCapabilitiesDiagnosisId = ref<number | null>(null)
const basicSnapshot = ref('')
let diagnosisGeneration = 0
let capabilitiesRequest: AbortController | undefined
@@ -847,6 +862,7 @@ const submitting = ref(false)
/** 拥有后手机号可正常编辑,失焦不再强制脱敏 */
const hasPhonePlainPermission = computed(() => hasPermission(['tcm.diagnosis/phonePlain']))
const hasDailyRecordPermission = computed(() => hasPermission(['tcm.diagnosis/dailyRecord']))
const hasFollowupAudioPermission = computed(() => hasPermission(['tcm.diagnosis/followupAudio']))
const patientBasicLocked = ref(false)
const canEditPatientBasicFromApi = ref(true)
const latestPrescriptionOrder = ref<{
@@ -1021,18 +1037,50 @@ async function handleFollowupApplied(diagnosisId: number) {
if (generation === diagnosisGeneration) feedback.msgWarning('录音已采纳,但病历或日常记录刷新失败,请手动刷新查看')
}
}
watch([visible, () => formData.value.id], async ([isVisible, id]) => {
function resetFollowupCapabilities() {
capabilitiesRequest?.abort()
capabilitiesRequest = undefined
followupCapabilities.value = null
if (!isVisible || !id) return
followupCapabilitiesDiagnosisId.value = null
followupCapabilitiesLoading.value = false
followupCapabilitiesError.value = ''
}
async function loadFollowupCapabilities(): Promise<void> {
// A rapid second click must not create another request for the same context.
if (followupCapabilitiesLoading.value) return
resetFollowupCapabilities()
const id = Number(formData.value.id)
if (!visible.value || !id || !hasFollowupAudioPermission.value) return
const generation = diagnosisGeneration
const request = new AbortController()
capabilitiesRequest = request
followupCapabilitiesLoading.value = true
const isCurrent = () => capabilitiesRequest === request && !request.signal.aborted
&& generation === diagnosisGeneration && visible.value && Number(formData.value.id) === id
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() })
const result = await followupAudioCapabilities(id, request.signal)
if (!isCurrent()) return
if (typeof result?.enabled !== 'boolean') throw new Error('Invalid capability response')
followupCapabilities.value = result
followupCapabilitiesDiagnosisId.value = id
} catch {
if (!isCurrent()) return
// No endpoint failure (including permission denial) may mount the panel.
followupCapabilitiesError.value = '回访录音状态加载失败,请重新加载;若仍失败,请联系管理员检查权限或服务状态'
} finally {
if (isCurrent()) {
followupCapabilitiesLoading.value = false
capabilitiesRequest = undefined
}
}
}
watch([visible, () => formData.value.id, hasFollowupAudioPermission], () => {
// Invalidate synchronously on patient switches and permission changes.
if (!hasFollowupAudioPermission.value && activeTab.value === 'followupAudio') activeTab.value = 'basic'
resetFollowupCapabilities()
void loadFollowupCapabilities()
}, { flush: 'sync' })
onBeforeUnmount(() => { diagnosisGeneration++; resetFollowupCapabilities() })
/** 就诊卡创建来源(与 zyt_tcm_diagnosis.create_source 一致) */
const createSourceOptions = [
@@ -1360,7 +1408,7 @@ const open = async (type: string, id?: number) => {
const generation = ++diagnosisGeneration
submitting.value = false
basicSnapshot.value = ''
followupCapabilities.value = null
resetFollowupCapabilities()
formData.value.id = ''
viewOnly.value = false
mode.value = type
@@ -1390,7 +1438,7 @@ const openViewOnly = async (id: number) => {
const generation = ++diagnosisGeneration
submitting.value = false
basicSnapshot.value = ''
followupCapabilities.value = null
resetFollowupCapabilities()
formData.value.id = ''
viewOnly.value = true
mode.value = 'edit'
@@ -1490,8 +1538,7 @@ const handleOpenPrescription = () => {
const handleClose = async () => {
if (followupAudioRef.value && !await followupAudioRef.value.confirmLeave()) return
diagnosisGeneration++
capabilitiesRequest?.abort()
followupCapabilities.value = null
resetFollowupCapabilities()
basicSnapshot.value = ''
formRef.value?.resetFields()
formData.value = {