feat(followup-audio): inline review with serialized draft autosave
This commit is contained in:
@@ -57,14 +57,14 @@
|
||||
<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="声道角色人工标注">
|
||||
<label>声道角色(人工确认)<el-select :model-value="channelRoles" :disabled="!canEditReview || busy" @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 class="muted">声道不等于已核实的说话人身份。角色标注不自动重新提炼;变更后保存草稿会取消全部采纳,须重新核对。</p>
|
||||
<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>
|
||||
<p v-if="roleDirty" class="review-error" role="status">角色标注正在等待保存;保存后请逐项重新核对。</p>
|
||||
</section>
|
||||
<FollowupAudioReview :key="selectedId" ref="reviewRef" v-model:items="reviewItems" :fields="detail.fields || capabilities.fields" :disabled="!canEditReview || busy" :can-daily="capabilities.can_daily" :applied="detail.status === 'applied'" :uncertainties="detail.uncertainties" :snapshot-items="detail.items" :channel-roles="savedChannelRoles" @seek="loadDialogue" />
|
||||
<el-collapse v-model="detailSections" class="original-collapse">
|
||||
<el-collapse-item title="完整对话与录音回听" name="original">
|
||||
<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">
|
||||
<el-button v-if="detail.audio_available" size="small" :loading="audioLoading" @click="reloadAudio">{{ audioUrl ? '重新加载录音' : '加载录音播放' }}</el-button>
|
||||
<span v-else class="muted">录音不可播放或已清理</span>
|
||||
@@ -77,6 +77,10 @@
|
||||
</div>
|
||||
<p v-if="playbackError" class="review-error" role="alert">{{ playbackError }}</p>
|
||||
<p v-if="detail.audio_available" class="muted">回听对话默认从该区间前 3 秒开始,连续播放双方声音;可随时切换为只听一方。</p>
|
||||
</template>
|
||||
</FollowupAudioReview>
|
||||
<el-collapse v-model="detailSections" class="original-collapse">
|
||||
<el-collapse-item title="完整对话转写" name="original">
|
||||
<div v-if="transcriptSegments.length" class="transcript-segments">
|
||||
<p class="muted">双方转写按同一时间区间并列展示,不表示发言先后;重叠发言与具体问答请结合录音核对。</p>
|
||||
<section v-for="window in conversationWindows" :key="window.id" class="transcript-segment">
|
||||
@@ -93,9 +97,9 @@
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
<div v-if="detail.status === 'review'" class="review-actions">
|
||||
<div class="selection-summary"><strong>已选 {{ selectedCount }} 项</strong><span>{{ dirty ? '有未保存修改' : '草稿已同步' }}</span></div>
|
||||
<div class="selection-summary"><strong>已选 {{ selectedCount }} 项</strong><span>{{ saveStatusText }}</span></div>
|
||||
<div v-if="canEditReview" class="action-buttons">
|
||||
<el-button :disabled="busy" :loading="mutation === 'save'" @click="saveDraft">保存草稿</el-button>
|
||||
<el-button link :disabled="busy" :loading="mutation === 'save'" @click="saveDraft()">{{ saveState === 'error' || saveState === 'conflict' ? '重试保存草稿' : '立即保存草稿' }}</el-button>
|
||||
<el-button v-if="canApplyReview" type="primary" :disabled="busy || basicDirty || !selectedCount || !!applyIssue" :loading="mutation === 'apply'" @click="applySelected">确认写入</el-button>
|
||||
</div>
|
||||
<p v-if="applyIssue" class="review-error" role="alert">{{ applyIssue }}</p>
|
||||
@@ -119,9 +123,13 @@
|
||||
</el-select>
|
||||
<small>已知角色会在提炼前提供给模型;不确定请勿猜测。单声道不按左右区分身份。</small>
|
||||
</el-form-item>
|
||||
<el-collapse class="advanced-settings">
|
||||
<el-collapse-item title="高级设置 · 分析模型" name="model">
|
||||
<el-form-item label="分析模型">
|
||||
<el-select v-model="modelKey" :disabled="!canUpload || busy || createUncertain" placeholder="选择已配置的模型"><el-option v-for="model in capabilities.models" :key="model.value" :label="model.label" :value="model.value" /></el-select>
|
||||
</el-form-item>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
</el-form>
|
||||
<el-alert v-if="errorMessage" type="error" :closable="false" :title="errorMessage" show-icon />
|
||||
<el-alert v-if="createUncertain && !uploading" type="warning" :closable="false" title="创建结果未确认,请关闭弹窗后刷新任务列表并联系管理员核对,不要重新上传。" />
|
||||
@@ -156,6 +164,16 @@ const selectedId = ref(0)
|
||||
const snapshot = ref('[]')
|
||||
const channelRoles = ref<FollowupChannelRoles>('unconfirmed')
|
||||
const roleSnapshot = ref<FollowupChannelRoles>('unconfirmed')
|
||||
const editingRoles = ref(false)
|
||||
const saveState = ref<'idle' | 'pending' | 'saving' | 'saved' | 'error' | 'conflict'>('idle')
|
||||
const persistedRevision = ref(0)
|
||||
const committedDirty = computed(() => roleDirty.value || JSON.stringify(reviewItems.value) !== snapshot.value)
|
||||
const saveStatusText = computed(() => saveState.value === 'saving' ? '正在自动保存…'
|
||||
: saveState.value === 'error' ? '保存失败,修改仍保留;请重试保存'
|
||||
: saveState.value === 'conflict' ? '自动保存已暂停;请重新核对后重试保存'
|
||||
: committedDirty.value ? '等待自动保存…'
|
||||
: reviewRef.value?.hasUnsavedChanges() ? '当前单项尚未完成核对'
|
||||
: saveState.value === 'saved' ? '审核草稿已自动保存' : '草稿已同步')
|
||||
const roleDirty = computed(() => channelRoles.value !== roleSnapshot.value)
|
||||
const savedChannelRoles = computed(() => normalizedChannelRoles(detail.value?.channel_roles))
|
||||
const transcriptSegments = computed(() => [...(detail.value?.transcript_segments || [])].sort((a, b) => a.start_ms - b.start_ms || (a.channel ?? -1) - (b.channel ?? -1)))
|
||||
@@ -195,6 +213,9 @@ const canApplyReview = computed(() => !props.viewOnly && followupCanApply(props.
|
||||
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 ? '测试预览仅保存审核草稿,不允许确认写入病历'
|
||||
: saveState.value === 'conflict' ? '记录版本已变化,请重新核对后重试保存草稿'
|
||||
: saveState.value === 'error' ? '草稿保存失败,请重试保存成功后再确认写入'
|
||||
: saveState.value === 'saving' || committedDirty.value ? '审核草稿尚未保存完成,请稍候再确认写入'
|
||||
: roleDirty.value ? '声道角色标注尚未保存,请先保存草稿并重新核对建议'
|
||||
: hasStereo.value && savedChannelRoles.value === 'unconfirmed' ? '请先回听并确认声道角色,保存草稿后重新核对建议'
|
||||
: changedReviewTarget(reviewItems.value, detail.value?.items || [])
|
||||
@@ -203,6 +224,9 @@ const applyIssue = computed(() => previewOnly.value ? '测试预览仅保存审
|
||||
const effectiveActive = computed(() => props.active && featureEnabled.value && documentVisible.value)
|
||||
const scope = createFollowupScope()
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
let autosaveTimer: ReturnType<typeof setTimeout> | undefined
|
||||
let hydrating = false
|
||||
let savePromise: Promise<void> | null = null
|
||||
let pollRunning = false
|
||||
let audioRequest = 0
|
||||
let playbackRequest = 0
|
||||
@@ -218,7 +242,9 @@ function releaseAudio() {
|
||||
audioUrl.value = ''; audioLoading.value = false; channelLoading.value = false; playbackChannel.value = 'original'; playbackError.value = ''
|
||||
}
|
||||
function stop() {
|
||||
scope.invalidate(); clearTimer(); releaseAudio()
|
||||
scope.invalidate(); clearTimer(); clearAutosave(); releaseAudio()
|
||||
savePromise = null
|
||||
if (saveState.value === 'saving') { saveState.value = 'error'; errorMessage.value = '保存结果尚未确认;修改仍保留,请刷新核对版本后重试保存。' }
|
||||
loading.value = false; uploading.value = false; mutation.value = ''; pollRunning = false
|
||||
}
|
||||
function installDetail(fresh: FollowupTaskDetail, preserve: boolean, preserveRoles = preserve) {
|
||||
@@ -229,25 +255,30 @@ function installDetail(fresh: FollowupTaskDetail, preserve: boolean, preserveRol
|
||||
if (retainedTranscriptNotice(fresh) && (!detail.value || detail.value.status !== fresh.status || !detail.value.transcript_available)) {
|
||||
if (!detailSections.value.includes('original')) detailSections.value = [...detailSections.value, 'original']
|
||||
}
|
||||
hydrating = true
|
||||
try {
|
||||
detail.value = fresh
|
||||
reviewItems.value = preserve ? rebaseReviewItems(reviewItems.value, fresh.items) : cloneReviewItems(fresh.items)
|
||||
const nextItems = preserve ? rebaseReviewItems(reviewItems.value, fresh.items) : cloneReviewItems(fresh.items)
|
||||
if (JSON.stringify(nextItems) !== JSON.stringify(reviewItems.value)) reviewItems.value = nextItems
|
||||
snapshot.value = JSON.stringify(fresh.items)
|
||||
roleSnapshot.value = normalizedChannelRoles(fresh.channel_roles)
|
||||
if (!keepRoles) channelRoles.value = roleSnapshot.value
|
||||
} finally { hydrating = false }
|
||||
}
|
||||
function resetRoles() { channelRoles.value = 'unconfirmed'; roleSnapshot.value = 'unconfirmed' }
|
||||
function resetRoles() { hydrating = true; channelRoles.value = 'unconfirmed'; roleSnapshot.value = 'unconfirmed'; hydrating = false; editingRoles.value = false; saveState.value = 'idle'; persistedRevision.value = 0 }
|
||||
function setChannelRoles(value: unknown) {
|
||||
if (!canEditReview.value || busy.value || !hasStereo.value || !['unconfirmed', 'left_service', 'right_service'].includes(String(value))) return
|
||||
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) {
|
||||
const ticket = scope.capture()
|
||||
const fresh = await followupAudioDetail(id, ticket.signal)
|
||||
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'
|
||||
installDetail(fresh, preserve)
|
||||
if (newlyApplied && !previewOnly.value) emit('applied', props.diagnosisId)
|
||||
if (preserve && fresh.status === 'review') errorMessage.value = '服务端记录或版本已变化;已保留你的字段与日期修改,并取消所有选择。请重新核对当前值后选择采纳。'
|
||||
if (preserve && fresh.status === 'review') { clearAutosave(); saveState.value = 'conflict'; errorMessage.value = '服务端记录或版本已变化;已保留你的字段与日期修改,并取消所有选择。自动保存已暂停,请重新核对后重试保存草稿。' }
|
||||
else if (preserve && newlyApplied) errorMessage.value = '任务已由其他人确认,已显示实际写入结果;本页未保存的修改未写入。'
|
||||
}
|
||||
async function refresh() {
|
||||
@@ -354,25 +385,88 @@ async function handleReviewError(error: unknown) {
|
||||
errorMessage.value = followupError(error)
|
||||
const stale = error instanceof FollowupAudioError && error.code === 'FOLLOWUP_AUDIO_STALE_REVIEW'
|
||||
if (stale || /STALE_REVIEW|版本|快照|已变化/.test(errorMessage.value)) {
|
||||
clearAutosave(); saveState.value = 'conflict'
|
||||
try { await readDetail(selectedId.value, true) } catch { if (scope.current(ticket)) errorMessage.value += ';刷新失败,当前编辑仍已保留,请稍后刷新。' }
|
||||
}
|
||||
}
|
||||
async function saveDraft() {
|
||||
if (!canEditReview.value || !detail.value || busy.value) return
|
||||
if (reviewRef.value?.hasUnsavedChanges()) { ElMessage.warning('请先保存或取消正在核对的单项修改'); return }
|
||||
const ticket = scope.capture(); mutation.value = 'save'; errorMessage.value = ''; clearTimer()
|
||||
const changedRoles = roleDirty.value
|
||||
try {
|
||||
const saved = await followupAudioSaveDraft({ id: detail.value.id, version: detail.value.version, items: cloneReviewItems(reviewItems.value), ...(changedRoles && hasStereo.value ? { channel_roles: channelRoles.value } : {}) }, ticket.signal)
|
||||
if (!scope.current(ticket)) return
|
||||
if (saved.review_refreshed) {
|
||||
installDetail(saved, true, false)
|
||||
errorMessage.value = '业务记录已变化,版本已刷新;当前编辑已保留,全部选择已取消,请重新核对后保存或采纳。'
|
||||
} else {
|
||||
installDetail(saved, false); ElMessage.success(changedRoles ? '角色标注已保存,全部建议须重新核对;不会自动重新提炼' : previewOnly.value ? '测试审核草稿已保存,不会写入病历' : '审核草稿已保存,尚未写入病历')
|
||||
function clearAutosave() { if (autosaveTimer !== undefined) clearTimeout(autosaveTimer); autosaveTimer = undefined }
|
||||
function scheduleAutosave() {
|
||||
clearAutosave()
|
||||
if (hydrating || !effectiveActive.value || !canEditReview.value || !committedDirty.value || ['error', 'conflict'].includes(saveState.value)) return
|
||||
if (mutation.value === 'save') return
|
||||
saveState.value = 'pending'
|
||||
autosaveTimer = setTimeout(() => { autosaveTimer = undefined; void saveDraft(true) }, 650)
|
||||
}
|
||||
// Merge only changes made after the request began. Server-owned evidence and hashes always win.
|
||||
function mergeSaveResponse(sent: FollowupReviewItem[], saved: FollowupReviewItem[], local: FollowupReviewItem[]) {
|
||||
const before = new Map(sent.map(item => [item.id, item]))
|
||||
const latest = new Map(local.map(item => [item.id, item]))
|
||||
return cloneReviewItems(saved).map(item => {
|
||||
const previous = before.get(item.id), edit = latest.get(item.id)
|
||||
if (!previous || !edit) return item
|
||||
const serverRequiresReview = (item.needs_review && !previous.needs_review) || (previous.selected && !item.selected)
|
||||
for (const key of ['values', 'record_date', 'record_time', 'time_period', 'target_id', 'selected', 'needs_review'] as const) {
|
||||
if (serverRequiresReview && (key === 'selected' || key === 'needs_review')) continue
|
||||
if (JSON.stringify(previous[key]) !== JSON.stringify(edit[key])) Object.assign(item, { [key]: edit[key] })
|
||||
}
|
||||
} catch (error) { if (scope.current(ticket)) await handleReviewError(error) }
|
||||
finally { if (scope.current(ticket)) { mutation.value = ''; await refresh() } }
|
||||
return item
|
||||
})
|
||||
}
|
||||
async function saveDraft(automatic = false): Promise<void> {
|
||||
if (savePromise) return savePromise
|
||||
if (!canEditReview.value || !detail.value || busy.value || !effectiveActive.value) return
|
||||
if (!automatic && reviewRef.value?.hasUnsavedChanges()) { ElMessage.warning('请先保存或取消正在核对的单项修改'); return }
|
||||
if (automatic && (!committedDirty.value || ['error', 'conflict'].includes(saveState.value))) return
|
||||
clearAutosave()
|
||||
const ticket = scope.capture(), taskId = detail.value.id, diagnosisId = props.diagnosisId
|
||||
const current = () => scope.current(ticket) && selectedId.value === taskId && props.diagnosisId === diagnosisId
|
||||
const changedRoles = roleDirty.value, sentRoles = channelRoles.value
|
||||
const sentItems = cloneReviewItems(reviewItems.value), version = detail.value.version
|
||||
mutation.value = 'save'; saveState.value = 'saving'; errorMessage.value = ''; clearTimer()
|
||||
const operation = (async () => {
|
||||
try {
|
||||
const saved = await followupAudioSaveDraft({ id: taskId, version, items: sentItems, ...(changedRoles && hasStereo.value ? { channel_roles: sentRoles } : {}) }, ticket.signal)
|
||||
if (!current()) return
|
||||
if (detail.value && saved.version < detail.value.version) {
|
||||
saveState.value = 'conflict'; errorMessage.value = '服务端已有更新版本;自动保存已暂停,请核对当前记录后重试保存。'; return
|
||||
}
|
||||
if (saved.review_refreshed) {
|
||||
const latestRoles = channelRoles.value
|
||||
installDetail(saved, true, false)
|
||||
if (latestRoles !== sentRoles) { hydrating = true; channelRoles.value = latestRoles; hydrating = false }
|
||||
saveState.value = 'conflict'
|
||||
errorMessage.value = '业务记录已变化,版本已刷新;当前编辑已保留,全部选择已取消。自动保存已暂停,请重新核对后重试保存草稿。'
|
||||
} else {
|
||||
const latestRoles = channelRoles.value
|
||||
const merged = changedRoles ? rebaseReviewItems(reviewItems.value, saved.items) : mergeSaveResponse(sentItems, saved.items, reviewItems.value)
|
||||
hydrating = true
|
||||
try {
|
||||
detail.value = saved; snapshot.value = JSON.stringify(saved.items)
|
||||
roleSnapshot.value = normalizedChannelRoles(saved.channel_roles)
|
||||
channelRoles.value = latestRoles === sentRoles ? roleSnapshot.value : latestRoles
|
||||
if (JSON.stringify(merged) !== JSON.stringify(reviewItems.value)) reviewItems.value = merged
|
||||
} finally { hydrating = false }
|
||||
editingRoles.value = roleDirty.value
|
||||
saveState.value = committedDirty.value ? 'pending' : 'saved'
|
||||
if (!committedDirty.value) persistedRevision.value++
|
||||
if (!automatic) ElMessage.success(changedRoles ? '角色标注已保存,全部建议须重新核对;不会自动重新提炼' : previewOnly.value ? '测试审核草稿已保存,不会写入病历' : '审核草稿已保存,尚未写入病历')
|
||||
}
|
||||
} catch (error) {
|
||||
if (current()) {
|
||||
saveState.value = 'error'
|
||||
await handleReviewError(error)
|
||||
if (current() && !['conflict'].includes(saveState.value)) errorMessage.value += ';自动保存已暂停,修改仍保留,请重试保存草稿。'
|
||||
}
|
||||
} finally {
|
||||
if (current()) {
|
||||
mutation.value = ''; savePromise = null
|
||||
scheduleAutosave()
|
||||
await refresh()
|
||||
}
|
||||
}
|
||||
})()
|
||||
savePromise = operation
|
||||
return operation
|
||||
}
|
||||
async function applySelected() {
|
||||
if (!canApplyReview.value || !detail.value || busy.value || !selectedCount.value) return
|
||||
@@ -414,7 +508,6 @@ async function loadAudio(milliseconds?: number, channel?: FollowupAudioChannel)
|
||||
if (channel !== undefined && (!hasStereo.value || (channel !== 0 && channel !== 1))) {
|
||||
releaseAudio(); playbackError.value = '未取得可验证的双声道片段,无法隔离声道;请使用原音播放。'; return
|
||||
}
|
||||
if (!detailSections.value.includes('original')) detailSections.value = [...detailSections.value, 'original']
|
||||
const ticket = scope.capture(); const id = detail.value.id
|
||||
if (!audioUrl.value) {
|
||||
const requestId = ++audioRequest; audioLoading.value = true
|
||||
@@ -454,8 +547,9 @@ watch([() => props.diagnosisId, effectiveActive, () => !!props.capabilities.prev
|
||||
resetRoles(); lastDiagnosis = id; selectedId.value = 0; detail.value = null; reviewItems.value = []; snapshot.value = '[]'
|
||||
tasks.value = []; file.value = null; recordedAt.value = ''; uploadChannelRoles.value = 'unconfirmed'; uploadVisible.value = false; detailSections.value = []; helpVisible.value = false; errorMessage.value = ''; uploadStage.value = ''; createUncertain.value = false
|
||||
}
|
||||
if (active) void refresh()
|
||||
if (active) { void refresh(); scheduleAutosave() }
|
||||
}, { immediate: true })
|
||||
watch([() => JSON.stringify(reviewItems.value), channelRoles], () => { if (!hydrating) scheduleAutosave() }, { flush: 'sync' })
|
||||
watch(() => props.capabilities.models, models => {
|
||||
if (!models.some(model => model.value === modelKey.value)) modelKey.value = models[0]?.value || ''
|
||||
})
|
||||
@@ -479,7 +573,7 @@ h3 { margin: 0; font-size: 14px; font-weight: 600; }
|
||||
.filename-button { max-width: 100%; height: auto; text-align: left; white-space: normal; word-break: break-word; line-height: 1.6; }
|
||||
.filename-button :deep(span) { overflow-wrap: anywhere; }
|
||||
.task-time, .recording-title > span { display: block; color: var(--el-text-color-secondary); font-size: 12px; margin-top: 3px; }
|
||||
.task-detail { padding: 16px; display: grid; gap: 14px; }
|
||||
.task-detail { padding: 16px; display: grid; gap: 14px; overflow: visible; }
|
||||
.detail-heading { align-items: flex-start; }
|
||||
.recording-title { min-width: 0; }
|
||||
.recording-title h3 { overflow-wrap: anywhere; line-height: 1.6; }
|
||||
@@ -492,6 +586,7 @@ h3 { margin: 0; font-size: 14px; font-weight: 600; }
|
||||
.summary-counts { display: flex; gap: 20px; color: var(--el-text-color-secondary); font-size: 12px; }
|
||||
.summary-counts strong { color: var(--el-text-color-primary); font-weight: 600; }
|
||||
.channel-role-box { display: grid; gap: 6px; font-size: 12px; }
|
||||
.role-summary { display: flex; align-items: center; gap: 12px; }
|
||||
.channel-role-box label { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; }
|
||||
.channel-role-box :deep(.el-select) { width: 260px; max-width: 100%; }
|
||||
.channel-playback { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; margin: 8px 0; }
|
||||
|
||||
@@ -36,7 +36,10 @@
|
||||
<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 ref="reviewWorkspace" class="review-workspace" tabindex="-1" aria-label="同页核对与回听">
|
||||
<div class="review-player"><slot name="player" /></div>
|
||||
<p v-if="navigationNotice" class="editor-notice" role="status">{{ navigationNotice }}</p>
|
||||
<div v-if="editor" class="inline-editor-heading"><strong>{{ applied || disabled ? '查看审核结果' : '核对提炼内容' }} · {{ editorPosition }} / {{ items.length }}</strong><el-button link @click="cancelReview()">{{ editorDirty ? '取消本项修改' : '收起核对' }}</el-button></div>
|
||||
<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>
|
||||
@@ -56,7 +59,7 @@
|
||||
<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>
|
||||
<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">
|
||||
@@ -68,25 +71,35 @@
|
||||
<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>
|
||||
<div v-if="editor && !disabled && !applied" class="editor-footer">
|
||||
<span class="draft-hint" role="status">{{ saveState === 'saving' || saveState === 'pending' ? '审核草稿同步中,不写入病例' : saveState === 'error' || saveState === 'conflict' ? '草稿同步需处理,请查看下方提示' : '核对结果自动保存为草稿,最终确认后才写入病例' }}</span>
|
||||
<el-button @click="skipAndNext">暂不采纳并下一项</el-button>
|
||||
<el-button :disabled="!editorDirty" @click="saveReview(false)">保存本项修改</el-button>
|
||||
<el-button type="primary" :disabled="!!editorIssue || targetChanged" @click="saveReview(true, true)">{{ nextReviewId ? '采纳并下一项' : '完成核对并选中' }}</el-button>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { computed, nextTick, 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 }>()
|
||||
const props = defineProps<{ items: FollowupReviewItem[]; fields: FollowupFields; disabled: boolean; canDaily: boolean; applied?: boolean; uncertainties?: unknown[]; snapshotItems?: FollowupReviewItem[]; channelRoles?: FollowupChannelRoles; saveState?: string; persistedRevision?: number }>()
|
||||
const emit = defineEmits<{ (event: 'update:items', items: FollowupReviewItem[]): void; (event: 'seek', milliseconds: number, channel?: FollowupAudioChannel): void; (event: 'saved-next'): 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 navigationNotice = ref('')
|
||||
const reviewWorkspace = ref<HTMLElement | null>(null)
|
||||
const editorPosition = computed(() => props.items.findIndex(item => item.id === editor.value?.id) + 1)
|
||||
const nextReviewId = computed(() => {
|
||||
const index = visibleItems.value.findIndex(item => item.id === editor.value?.id)
|
||||
return index < 0 ? null : visibleItems.value.slice(index + 1).find(item => !item.selected && !selectionBlocked(item))?.id || null
|
||||
})
|
||||
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)
|
||||
@@ -120,9 +133,18 @@ function itemStatus(item: FollowupReviewItem): { label: string; type: 'success'
|
||||
return { label: item.selected ? '已选择' : '已核对', type: item.selected ? 'success' : 'info' }
|
||||
}
|
||||
function openReview(id: string) {
|
||||
if (editor.value?.id === id && editorDirty.value && !props.applied) {
|
||||
void nextTick(() => { reviewWorkspace.value?.scrollIntoView({ block: 'start' }); reviewWorkspace.value?.focus({ preventScroll: true }) })
|
||||
return
|
||||
}
|
||||
if (editor.value && editor.value.id !== id && editorDirty.value && !props.applied) {
|
||||
editorNotice.value = '当前项有未保存修改,请先保存或取消本项修改,再核对其他项。'
|
||||
return
|
||||
}
|
||||
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 = ''
|
||||
editor.value = cloneReviewItems([item])[0]; editorSource.value = JSON.stringify(editor.value); editorNotice.value = ''; navigationNotice.value = ''
|
||||
void nextTick(() => { reviewWorkspace.value?.scrollIntoView({ block: 'start' }); reviewWorkspace.value?.focus({ preventScroll: true }) })
|
||||
}
|
||||
function cancelReview(done?: () => void) { editor.value = null; editorSource.value = ''; editorNotice.value = ''; done?.() }
|
||||
function patchEditor(update: Editable) {
|
||||
@@ -145,11 +167,24 @@ 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) {
|
||||
function saveReview(select: boolean, advance = false) {
|
||||
if (!editor.value || props.disabled || props.applied || (select && (editorIssue.value || targetChanged.value))) return
|
||||
const item = editor.value
|
||||
const nextId = advance ? nextReviewId.value : null
|
||||
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()
|
||||
emit('saved-next')
|
||||
if (nextId) openReview(nextId)
|
||||
else if (advance) navigationNotice.value = '当前列表已核对至末项;可切换分类继续,或确认写入已选内容。'
|
||||
}
|
||||
function skipAndNext() {
|
||||
if (!editor.value || props.disabled || props.applied) return
|
||||
if (editorDirty.value) { editorNotice.value = '当前项有未保存修改,请先保存或取消修改后再跳过。'; return }
|
||||
const nextId = nextReviewId.value
|
||||
patch(editor.value.id, { selected: false })
|
||||
cancelReview(); emit('saved-next')
|
||||
if (nextId) openReview(nextId)
|
||||
else navigationNotice.value = '本项暂不采纳,未写入病例;可切换分类继续核对。'
|
||||
}
|
||||
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(',')
|
||||
@@ -215,6 +250,11 @@ defineExpose({ hasUnsavedChanges: () => editorDirty.value })
|
||||
.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-workspace { margin-top: 14px; scroll-margin-top: 12px; outline: none; }
|
||||
.review-workspace:focus-visible { outline: 2px solid var(--el-color-primary); outline-offset: 4px; }
|
||||
.review-player { position: sticky; top: 0; z-index: 2; background: var(--el-bg-color, #fff); padding: 10px 0; border-bottom: 1px solid var(--el-border-color-lighter); }
|
||||
.inline-editor-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin: 14px 0; }
|
||||
.draft-hint { flex: 1 1 100%; font-size: 12px; color: var(--el-text-color-secondary); }
|
||||
.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; }
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const test = require('node:test')
|
||||
const ts = require('typescript')
|
||||
const vue = require('vue')
|
||||
const { parse, compileScript, compileTemplate } = require('@vue/compiler-sfc')
|
||||
const dir = path.resolve(__dirname, '../src/views/tcm/diagnosis/components')
|
||||
const reviewFile = process.env.FOLLOWUP_INLINE_REVIEW_SOURCE || path.join(dir, 'FollowupAudioReview.vue')
|
||||
function execute(source, requireFn) {
|
||||
const out = ts.transpileModule(source,{compilerOptions:{target:ts.ScriptTarget.ES2022,module:ts.ModuleKind.CommonJS}}).outputText
|
||||
const module={exports:{}};new Function('require','module','exports',out)(requireFn,module,module.exports);return module.exports
|
||||
}
|
||||
const state=execute(fs.readFileSync(path.join(dir,'followupAudioState.ts'),'utf8'),require)
|
||||
const row=(id,extra={})=>({id,kind:'blood',values:{fasting_blood_sugar:6.2},record_date:'2026-10-12',record_time:'08:00',time_period:'早晨',time_estimated:true,time_text:'早晨',date_text:'今天',evidence:[{text:'六点二',channel:1,segment_id:'right-1',start_ms:0,end_ms:15000,position_type:'segment'}],needs_review:true,selected:false,target_id:null,current_values:{},expected_hash:'original',conflict:false,possible_duplicate:false,...extra})
|
||||
function setup(extra={}){
|
||||
const descriptor=parse(fs.readFileSync(reviewFile,'utf8'),{filename:reviewFile}).descriptor
|
||||
const compiled=compileScript(descriptor,{id:'inline-review'})
|
||||
const mod=execute(compiled.content,name=>name==='vue'?vue:name==='./followupAudioState'?state:{default:{}})
|
||||
const props=vue.reactive({items:[row('one'),row('two'),row('three')],fields:{blood:[{key:'fasting_blood_sugar',label:'空腹血糖',type:'number'}]},disabled:false,canDaily:true,applied:false,saveState:'saved',...extra})
|
||||
const events=[];const scope=vue.effectScope();const review=scope.run(()=>mod.default.setup(props,{expose(){},emit(event,...args){events.push([event,...args]);if(event==='update:items')props.items=args[0]}}))
|
||||
return {review,props,events,dispose:()=>scope.stop()}
|
||||
}
|
||||
test('inline editor has one permanently mounted player slot and no modal overlay',()=>{
|
||||
const source=fs.readFileSync(reviewFile,'utf8');assert.doesNotMatch(source,/<el-dialog/);assert.equal((source.match(/<slot name="player"/g)||[]).length,1)
|
||||
assert.match(source,/position: sticky/);assert.match(source,/aria-label="同页核对与回听"/)
|
||||
const descriptor=parse(source,{filename:reviewFile}).descriptor;const script=compileScript(descriptor,{id:'inline-compile'})
|
||||
const render=compileTemplate({source:descriptor.template.content,filename:reviewFile,id:'inline-compile',compilerOptions:{bindingMetadata:script.bindings}});assert.deepEqual(render.errors,[])
|
||||
})
|
||||
test('accept-next requires explicit verification, retains immutable evidence and opens next unselected item',async()=>{
|
||||
const f=setup({items:[row('one'),row('two',{selected:true,needs_review:false}),row('three')]});f.review.openReview('one')
|
||||
f.review.saveReview(true,true);assert.equal(f.props.items[0].selected,false);assert.equal(f.review.editor.value.id,'one')
|
||||
f.review.confirmReview(true);f.review.saveReview(true,true)
|
||||
assert.equal(f.props.items[0].selected,true);assert.equal(f.props.items[0].needs_review,false);assert.equal(f.review.editor.value.id,'three')
|
||||
assert.deepEqual(f.props.items[0].evidence,row('one').evidence);assert.ok(f.events.some(e=>e[0]==='saved-next'))
|
||||
await vue.nextTick();assert.equal(f.review.editor.value.id,'three');f.dispose()
|
||||
})
|
||||
test('pending-filter accept-next captures next id before accepted row leaves filter',()=>{
|
||||
const f=setup();f.review.group.value='pending';f.review.openReview('one');f.review.confirmReview(true);f.review.saveReview(true,true)
|
||||
assert.equal(f.review.editor.value.id,'two');assert.deepEqual(f.review.visibleItems.value.map(r=>r.id),['two','three']);f.dispose()
|
||||
})
|
||||
test('skip preserves patient values, does not claim review complete, and never writes or selects',()=>{
|
||||
const f=setup();f.review.openReview('one');f.review.skipAndNext();assert.equal(f.review.editor.value.id,'two');assert.equal(f.props.items[0].needs_review,true);assert.equal(f.props.items[0].selected,false)
|
||||
assert.deepEqual(f.props.items[0].values,{fasting_blood_sugar:6.2});assert.ok(f.events.every(e=>['update:items','saved-next'].includes(e[0])));f.dispose()
|
||||
})
|
||||
test('inline row switching or skipping never silently drops unsaved edits',()=>{
|
||||
const f=setup();f.review.openReview('one');f.review.setValue('fasting_blood_sugar',6.8);f.review.openReview('two');assert.equal(f.review.editor.value.id,'one');assert.match(f.review.editorNotice.value,/未保存/)
|
||||
f.review.skipAndNext();assert.equal(f.review.editor.value.id,'one');assert.equal(f.review.editor.value.values.fasting_blood_sugar,6.8);assert.equal(f.props.items[0].values.fasting_blood_sugar,6.2);f.dispose()
|
||||
})
|
||||
test('date change remains gated until saved and authoritative target refreshed',()=>{
|
||||
const f=setup();f.review.openReview('one');f.review.patchEditor({record_date:'2026-10-11'});f.review.confirmReview(true);f.review.saveReview(true,true);assert.equal(f.props.items[0].selected,false);assert.equal(f.review.editor.value.id,'one')
|
||||
f.review.saveReview(false);assert.equal(f.props.items[0].record_date,'2026-10-11');assert.equal(f.props.items[0].needs_review,true);assert.ok(f.events.some(e=>e[0]==='saved-next'));f.dispose()
|
||||
})
|
||||
test('last item completes without wrapping or applying, read-only cannot advance mutations',()=>{
|
||||
const f=setup({items:[row('one')]});f.review.openReview('one');f.review.confirmReview(true);f.review.saveReview(true,true);assert.equal(f.review.editor.value,null);assert.match(f.review.navigationNotice.value,/末项/);assert.equal(f.events.some(e=>e[0]==='apply'),false);f.dispose()
|
||||
const g=setup({disabled:true});g.review.openReview('one');g.review.skipAndNext();g.review.saveReview(true,true);assert.equal(g.events.length,0);g.dispose()
|
||||
})
|
||||
|
||||
test('reopening the same row or toggling its selection preserves unsaved editor data',()=>{
|
||||
const f=setup();f.review.openReview('one');f.review.setValue('fasting_blood_sugar',7.1)
|
||||
f.review.openReview('one');assert.equal(f.review.editor.value.values.fasting_blood_sugar,7.1)
|
||||
f.review.toggleSelection(f.props.items[0],true);assert.equal(f.review.editor.value.values.fasting_blood_sugar,7.1)
|
||||
assert.equal(f.props.items[0].values.fasting_blood_sugar,6.2);assert.equal(f.props.items[0].selected,false);f.dispose()
|
||||
})
|
||||
@@ -369,7 +369,7 @@ test('changed real-row target and changed record date require saving a fresh sna
|
||||
f.panel.reviewItems.value[0].target_id = 7
|
||||
await f.panel.applySelected()
|
||||
assert.equal(f.calls.filter(call => call[0] === 'apply').length, 0)
|
||||
assert.match(f.panel.applyIssue.value, /先保存审核草稿/)
|
||||
assert.match(f.panel.applyIssue.value, /审核草稿尚未保存完成/)
|
||||
f.dispose()
|
||||
})
|
||||
|
||||
@@ -387,6 +387,8 @@ test('read-only identity fields cannot be selected or applied, including mixed e
|
||||
await settle(); await f.panel.openTask(1); await f.panel.applySelected()
|
||||
assert.equal(f.calls.filter(call => call[0] === 'apply').length, 0)
|
||||
delete f.panel.reviewItems.value[0].values.phone
|
||||
assert.match(f.panel.applyIssue.value, /尚未保存完成/)
|
||||
await f.panel.saveDraft()
|
||||
assert.equal(f.panel.applyIssue.value, '')
|
||||
f.dispose()
|
||||
const source = fs.readFileSync(path.join(componentDir, 'FollowupAudioReview.vue'), 'utf8')
|
||||
@@ -576,7 +578,7 @@ test('server refresh during a modal edit keeps local content, uses fresh evidenc
|
||||
f.dispose()
|
||||
})
|
||||
|
||||
test('panel leave guard includes unsaved modal edits; recording original is collapsed until playback', async () => {
|
||||
test('panel leave guard includes unsaved editor edits; playback stays separate from collapsed transcript', async () => {
|
||||
const f = setupPanel({}, {}, { confirm: async () => { throw new Error('cancel') } })
|
||||
await settle(); await f.panel.openTask(1)
|
||||
const r = setupReview({ items: f.panel.reviewItems.value })
|
||||
@@ -587,7 +589,7 @@ test('panel leave guard includes unsaved modal edits; recording original is coll
|
||||
r.review.cancelReview(); assert.equal(f.panel.dirty.value, false)
|
||||
assert.deepEqual(f.panel.detailSections.value, [])
|
||||
await f.panel.loadAudio(5000)
|
||||
assert.deepEqual(f.panel.detailSections.value, ['original'])
|
||||
assert.deepEqual(f.panel.detailSections.value, [], 'same-page player does not expand the transcript')
|
||||
await f.panel.backToList()
|
||||
assert.deepEqual(f.panel.detailSections.value, [])
|
||||
r.dispose(); f.dispose()
|
||||
@@ -1151,7 +1153,7 @@ test('API forwards optional channel_roles only in draft and stereo apply uses se
|
||||
let current = stereoTask()
|
||||
const applied = []
|
||||
const f = setupPanel({ followupAudioDetail: async () => current, followupAudioSaveDraft: async payload => {
|
||||
current = { ...current, version: 2, channel_roles: payload.channel_roles, items: payload.items.map(row => ({ ...row, selected: false, needs_review: true })) }
|
||||
current = { ...current, version: payload.version + 1, channel_roles: payload.channel_roles || current.channel_roles, items: payload.channel_roles ? payload.items.map(row => ({ ...row, selected: false, needs_review: true })) : payload.items }
|
||||
return current
|
||||
}, followupAudioApply: async payload => { applied.push(payload); current = { ...current, status: 'applied', items: payload.items }; return { id: 1, status: 'applied', applied_items: [] } } })
|
||||
await settle(); await f.panel.openTask(1); f.panel.setChannelRoles('left_service'); await f.panel.saveDraft()
|
||||
@@ -1159,9 +1161,10 @@ test('API forwards optional channel_roles only in draft and stereo apply uses se
|
||||
const review = setupReview({ items: f.panel.reviewItems.value })
|
||||
review.review.openReview('blood-1'); review.review.confirmReview(true); review.review.saveReview(true)
|
||||
f.panel.reviewItems.value = review.props.items
|
||||
await f.panel.saveDraft()
|
||||
await f.panel.applySelected()
|
||||
assert.equal(applied.length, 1)
|
||||
assert.equal(applied[0].version, 2)
|
||||
assert.equal(applied[0].version, 3)
|
||||
assert.equal(Object.hasOwn(applied[0], 'channel_roles'), false, 'apply uses authoritative server-saved role mapping')
|
||||
assert.equal(f.panel.detail.value.status, 'applied')
|
||||
assert.deepEqual(f.events, [['applied', 10]])
|
||||
@@ -1553,3 +1556,240 @@ test('cancelled file selection keeps current file and roles; new upload starts u
|
||||
assert.equal(f.panel.uploadChannelRoles.value, 'unconfirmed')
|
||||
f.dispose()
|
||||
})
|
||||
|
||||
test('autosave ignores hydration, debounces committed draft edits and never writes clinical records', async () => {
|
||||
const f = setupPanel({}, { capabilities: previewCaps() })
|
||||
await settle(); await f.panel.openTask(1)
|
||||
f.tick(); await settle()
|
||||
assert.equal(f.calls.filter(x => x[0] === 'save').length, 0, 'server hydration does not save itself')
|
||||
f.panel.reviewItems.value[0].values.fasting_blood_sugar = 7.1
|
||||
f.panel.reviewItems.value[0].values.fasting_blood_sugar = 7.2
|
||||
f.panel.reviewItems.value[0].selected = false
|
||||
assert.equal(f.panel.saveState.value, 'pending')
|
||||
assert.equal(f.calls.filter(x => x[0] === 'save').length, 0)
|
||||
f.tick(); await settle()
|
||||
const saves = f.calls.filter(x => x[0] === 'save')
|
||||
assert.equal(saves.length, 1)
|
||||
assert.equal(saves[0][1].items[0].values.fasting_blood_sugar, 7.2)
|
||||
assert.equal(saves[0][1].items[0].selected, false)
|
||||
assert.equal(f.panel.saveState.value, 'saved')
|
||||
assert.equal(f.panel.persistedRevision.value, 1)
|
||||
f.tick(); await settle()
|
||||
assert.equal(f.calls.filter(x => x[0] === 'save').length, 1, 'ACK hydration causes no write loop')
|
||||
assert.equal(f.calls.filter(x => x[0] === 'apply').length, 0)
|
||||
assert.deepEqual(f.events, [])
|
||||
f.dispose()
|
||||
})
|
||||
|
||||
test('autosave serializes delayed commits, merges newer local edits and advances request versions', async () => {
|
||||
const requests = [], pending = []
|
||||
const f = setupPanel({ followupAudioSaveDraft: payload => { requests.push(payload); const next = deferred(); pending.push(next); return next.promise } })
|
||||
await settle(); await f.panel.openTask(1)
|
||||
f.panel.reviewItems.value[0].values.fasting_blood_sugar = 7
|
||||
f.tick(); await settle()
|
||||
assert.equal(requests.length, 1)
|
||||
assert.equal(f.panel.saveState.value, 'saving')
|
||||
f.panel.reviewItems.value[0].values.fasting_blood_sugar = 8
|
||||
f.panel.reviewItems.value[0].selected = false
|
||||
f.tick(); await settle()
|
||||
assert.equal(requests.length, 1, 'only one request in flight')
|
||||
pending[0].resolve(task(1, { version: 2, items: requests[0].items })); await settle()
|
||||
assert.equal(f.panel.reviewItems.value[0].values.fasting_blood_sugar, 8)
|
||||
assert.equal(f.panel.reviewItems.value[0].selected, false)
|
||||
assert.equal(f.panel.saveState.value, 'pending')
|
||||
assert.equal(f.panel.persistedRevision.value, 0, 'outdated ACK does not claim latest edits saved')
|
||||
f.tick(); await settle()
|
||||
assert.equal(requests.length, 2)
|
||||
assert.equal(requests[1].version, 2)
|
||||
assert.equal(requests[1].items[0].values.fasting_blood_sugar, 8)
|
||||
pending[1].resolve(task(1, { version: 3, items: requests[1].items })); await settle()
|
||||
assert.equal(f.panel.detail.value.version, 3)
|
||||
assert.equal(f.panel.saveState.value, 'saved')
|
||||
assert.equal(f.panel.committedDirty.value, false)
|
||||
assert.equal(f.panel.persistedRevision.value, 1)
|
||||
f.dispose()
|
||||
})
|
||||
|
||||
test('same-data save ACK retains item references and never disables or saves a dirty next editor', async () => {
|
||||
const pending = deferred(), sent = []
|
||||
const f = setupPanel({ followupAudioSaveDraft: payload => { sent.push(payload); return pending.promise } })
|
||||
await settle(); await f.panel.openTask(1)
|
||||
f.panel.reviewItems.value[0].values.fasting_blood_sugar = 7
|
||||
f.tick(); await settle()
|
||||
const committed = f.panel.reviewItems.value
|
||||
f.panel.reviewRef.value = { hasUnsavedChanges: () => true }
|
||||
pending.resolve(task(1, { version: 2, items: sent[0].items })); await settle()
|
||||
assert.equal(f.panel.reviewItems.value, committed, 'ACK must not trigger Review items replacement')
|
||||
assert.equal(f.panel.dirty.value, true, 'uncommitted next editor remains dirty')
|
||||
assert.equal(f.panel.committedDirty.value, false)
|
||||
assert.equal(sent.length, 1)
|
||||
await f.panel.applySelected()
|
||||
assert.equal(f.calls.filter(x => x[0] === 'apply').length, 0)
|
||||
f.dispose()
|
||||
const source = fs.readFileSync(path.join(componentDir, 'FollowupAudioPanel.vue'), 'utf8')
|
||||
assert.match(source, /:disabled="!canEditReview \|\| uploading \|\| \(mutation !== '' && mutation !== 'save'\)"/)
|
||||
})
|
||||
|
||||
test('autosave writes committed items even while a different editor has uncommitted changes', async () => {
|
||||
const f = setupPanel(); await settle(); await f.panel.openTask(1)
|
||||
f.panel.reviewItems.value[0].selected = false
|
||||
f.panel.reviewRef.value = { hasUnsavedChanges: () => true }
|
||||
f.tick(); await settle()
|
||||
assert.equal(f.calls.filter(x => x[0] === 'save').length, 1)
|
||||
assert.equal(f.panel.dirty.value, true)
|
||||
assert.equal(f.panel.saveState.value, 'saved')
|
||||
f.dispose()
|
||||
})
|
||||
|
||||
test('autosave failure stops retries and Apply until an explicit retry succeeds', async () => {
|
||||
let failing = true
|
||||
const f = setupPanel({ followupAudioSaveDraft: async payload => {
|
||||
f.calls.push(['save', payload])
|
||||
if (failing) throw new Error('网络中断')
|
||||
return task(1, { version: payload.version + 1, items: payload.items })
|
||||
} })
|
||||
await settle(); await f.panel.openTask(1)
|
||||
f.panel.reviewItems.value[0].values.fasting_blood_sugar = 7
|
||||
f.tick(); await settle()
|
||||
assert.equal(f.panel.saveState.value, 'error')
|
||||
assert.match(f.panel.errorMessage.value, /自动保存已暂停.*重试保存/)
|
||||
f.panel.reviewItems.value[0].values.fasting_blood_sugar = 8
|
||||
f.tick(); await settle(); f.tick(); await settle()
|
||||
await f.panel.applySelected()
|
||||
assert.equal(f.calls.filter(x => x[0] === 'save').length, 1)
|
||||
assert.equal(f.calls.filter(x => x[0] === 'apply').length, 0)
|
||||
assert.equal(f.panel.reviewItems.value[0].values.fasting_blood_sugar, 8)
|
||||
failing = false; await f.panel.saveDraft()
|
||||
assert.equal(f.panel.saveState.value, 'saved')
|
||||
assert.equal(f.calls.filter(x => x[0] === 'save').length, 2)
|
||||
f.dispose()
|
||||
})
|
||||
|
||||
test('autosave conflict refreshes immutable metadata, keeps local values and pauses instead of looping', async () => {
|
||||
let stale = false
|
||||
const f = setupPanel({
|
||||
followupAudioDetail: async () => task(1, { version: stale ? 2 : 1, items: [item({ expected_hash: stale ? 'new' : 'old' })] }),
|
||||
followupAudioSaveDraft: async payload => { f.calls.push(['save', payload]); stale = true; throw new apiModule.FollowupAudioError('版本已变化', 'FOLLOWUP_AUDIO_STALE_REVIEW') }
|
||||
})
|
||||
await settle(); await f.panel.openTask(1)
|
||||
f.panel.reviewItems.value[0].values.fasting_blood_sugar = 8
|
||||
f.tick(); await settle()
|
||||
assert.equal(f.panel.saveState.value, 'conflict')
|
||||
assert.equal(f.panel.reviewItems.value[0].expected_hash, 'new')
|
||||
assert.equal(f.panel.reviewItems.value[0].values.fasting_blood_sugar, 8)
|
||||
assert.equal(f.panel.reviewItems.value[0].selected, false)
|
||||
assert.equal(f.panel.reviewItems.value[0].needs_review, true)
|
||||
f.panel.reviewItems.value[0].values.fasting_blood_sugar = 9
|
||||
f.tick(); await settle(); f.tick(); await settle()
|
||||
assert.equal(f.calls.filter(x => x[0] === 'save').length, 1)
|
||||
assert.equal(f.panel.saveState.value, 'conflict')
|
||||
assert.match(f.panel.errorMessage.value, /重新核对.*重试保存/)
|
||||
f.dispose()
|
||||
})
|
||||
|
||||
test('autosave Apply stays explicit and blocked while pending, saving or edited during confirmation', async () => {
|
||||
const pending = deferred(), confirmation = deferred(), sent = []
|
||||
const f = setupPanel({ followupAudioSaveDraft: payload => { sent.push(payload); return pending.promise } }, {}, { confirm: () => confirmation.promise })
|
||||
await settle(); await f.panel.openTask(1)
|
||||
f.panel.reviewItems.value[0].values.fasting_blood_sugar = 7
|
||||
await f.panel.applySelected()
|
||||
assert.equal(f.calls.filter(x => x[0] === 'apply').length, 0)
|
||||
f.tick(); await settle(); await f.panel.applySelected()
|
||||
assert.equal(f.calls.filter(x => x[0] === 'apply').length, 0)
|
||||
pending.resolve(task(1, { version: 2, items: sent[0].items })); await settle()
|
||||
assert.equal(f.calls.filter(x => x[0] === 'apply').length, 0, 'draft ACK never auto-applies')
|
||||
const applying = f.panel.applySelected(); await settle()
|
||||
f.panel.reviewItems.value[0].values.fasting_blood_sugar = 8
|
||||
confirmation.resolve(true); await applying
|
||||
assert.equal(f.calls.filter(x => x[0] === 'apply').length, 0, 'confirmation does not authorize edits made after it opened')
|
||||
f.dispose()
|
||||
})
|
||||
|
||||
test('pending autosave and late ACK cannot cross patients or tasks', async () => {
|
||||
const pending = deferred(), sent = []
|
||||
const f = setupPanel({ followupAudioSaveDraft: payload => { sent.push(payload); return pending.promise } })
|
||||
await settle(); await f.panel.openTask(1)
|
||||
f.panel.reviewItems.value[0].values.fasting_blood_sugar = 7
|
||||
f.props.diagnosisId = 20; await settle(); f.tick(); await settle()
|
||||
assert.equal(sent.length, 0, 'debounced old-patient draft is cancelled')
|
||||
await f.panel.openTask(2)
|
||||
f.panel.reviewItems.value[0].values.fasting_blood_sugar = 8
|
||||
f.tick(); await settle()
|
||||
assert.equal(sent.length, 1)
|
||||
assert.equal(sent[0].id, 2)
|
||||
f.props.diagnosisId = 30; await settle(); await f.panel.openTask(3)
|
||||
pending.resolve(task(2, { version: 2, items: sent[0].items })); await settle()
|
||||
assert.equal(f.panel.selectedId.value, 3)
|
||||
assert.equal(f.panel.reviewItems.value[0].values.fasting_blood_sugar, 6.5)
|
||||
assert.equal(f.panel.persistedRevision.value, 0)
|
||||
assert.equal(f.panel.saveState.value, 'idle')
|
||||
assert.deepEqual(f.events, [])
|
||||
f.dispose()
|
||||
})
|
||||
|
||||
test('role autosave preserves a newer mapping and serializes another save without selecting suggestions', async () => {
|
||||
const requests = [], pending = []
|
||||
const f = setupPanel({ followupAudioDetail: async () => stereoTask(), followupAudioSaveDraft: payload => { requests.push(payload); const request = deferred(); pending.push(request); return request.promise } })
|
||||
await settle(); await f.panel.openTask(1)
|
||||
f.panel.setChannelRoles('left_service'); f.tick(); await settle()
|
||||
f.panel.setChannelRoles('right_service')
|
||||
assert.equal(f.panel.channelRoles.value, 'right_service', 'role edits stay available during save')
|
||||
pending[0].resolve(stereoTask({ version: 2, channel_roles: 'left_service', items: [item({ selected: false, needs_review: true })] })); await settle()
|
||||
assert.equal(f.panel.channelRoles.value, 'right_service')
|
||||
assert.equal(f.panel.savedChannelRoles.value, 'left_service')
|
||||
assert.equal(f.panel.reviewItems.value[0].selected, false)
|
||||
f.tick(); await settle()
|
||||
assert.equal(requests.length, 2)
|
||||
assert.equal(requests[1].version, 2)
|
||||
assert.equal(requests[1].channel_roles, 'right_service')
|
||||
pending[1].resolve(stereoTask({ version: 3, channel_roles: 'right_service', items: requests[1].items })); await settle()
|
||||
assert.equal(f.panel.saveState.value, 'saved')
|
||||
assert.equal(f.panel.roleDirty.value, false)
|
||||
assert.equal(f.calls.filter(x => x[0] === 'apply').length, 0)
|
||||
f.dispose()
|
||||
})
|
||||
|
||||
test('panel has one same-page slotted player and collapses only transcript and advanced settings', () => {
|
||||
const source = fs.readFileSync(path.join(componentDir, 'FollowupAudioPanel.vue'), 'utf8')
|
||||
assert.equal((source.match(/<audio\s/g) || []).length, 1)
|
||||
assert.match(source, /<template #player>[\s\S]*?<audio[\s\S]*?<\/template>\s*<\/FollowupAudioReview>/)
|
||||
assert.doesNotMatch(source.slice(source.indexOf('<el-collapse v-model="detailSections"'), source.indexOf('<div v-if="detail.status === \'review\'" class="review-actions"')), /<audio\s/)
|
||||
assert.match(source, /el-collapse-item title="高级设置 · 分析模型"/)
|
||||
assert.match(source, /!editingRoles && !roleDirty/)
|
||||
assert.match(source, /@click="editingRoles = true">修改/)
|
||||
})
|
||||
|
||||
test('late save merging never resurrects selections invalidated by the server snapshot', async () => {
|
||||
const pending = deferred(), sent = []
|
||||
const f = setupPanel({ followupAudioSaveDraft: payload => { sent.push(payload); return pending.promise } })
|
||||
await settle(); await f.panel.openTask(1)
|
||||
f.panel.reviewItems.value[0].target_id = 9
|
||||
f.panel.reviewItems.value[0].selected = false
|
||||
f.tick(); await settle()
|
||||
f.panel.reviewItems.value[0].selected = true
|
||||
f.panel.reviewItems.value[0].values.fasting_blood_sugar = 8
|
||||
pending.resolve(task(1, { version: 2, items: sent[0].items.map(row => ({ ...row, expected_hash: 'new-target', selected: false, needs_review: true })) })); await settle()
|
||||
assert.equal(f.panel.reviewItems.value[0].values.fasting_blood_sugar, 8)
|
||||
assert.equal(f.panel.reviewItems.value[0].expected_hash, 'new-target')
|
||||
assert.equal(f.panel.reviewItems.value[0].selected, false)
|
||||
assert.equal(f.panel.reviewItems.value[0].needs_review, true)
|
||||
f.dispose()
|
||||
})
|
||||
|
||||
test('detail fetch begun before an edit preserves that edit when the new snapshot arrives', async () => {
|
||||
const pending = deferred()
|
||||
let fetching = false
|
||||
const f = setupPanel({ followupAudioDetail: async () => fetching ? pending.promise : task() })
|
||||
await settle(); await f.panel.openTask(1)
|
||||
fetching = true
|
||||
const refreshing = f.panel.readDetail(1)
|
||||
f.panel.reviewItems.value[0].values.fasting_blood_sugar = 8
|
||||
pending.resolve(task(1, { version: 2, items: [item({ expected_hash: 'fresh' })] })); await refreshing
|
||||
assert.equal(f.panel.reviewItems.value[0].values.fasting_blood_sugar, 8)
|
||||
assert.equal(f.panel.reviewItems.value[0].expected_hash, 'fresh')
|
||||
assert.equal(f.panel.saveState.value, 'conflict')
|
||||
assert.equal(f.panel.reviewItems.value[0].selected, false)
|
||||
f.tick(); await settle()
|
||||
assert.equal(f.calls.filter(x => x[0] === 'save').length, 0)
|
||||
f.dispose()
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user