ige
This commit is contained in:
@@ -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)) {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user