feat: add scoped read-only audio preview and isolated Dify transport
This commit is contained in:
@@ -23,6 +23,10 @@ export interface FollowupTranscriptSegment {
|
||||
export interface FollowupCapabilities {
|
||||
enabled: boolean
|
||||
audio_verified: boolean
|
||||
preview_only?: boolean
|
||||
preview_ready?: boolean
|
||||
can_review?: boolean
|
||||
test_scope_allowed?: boolean
|
||||
can_upload: boolean
|
||||
can_apply: boolean
|
||||
can_daily: boolean
|
||||
@@ -71,6 +75,7 @@ export interface FollowupTaskSummary {
|
||||
expires_at: number | string
|
||||
can_retry: boolean
|
||||
audio_available: boolean
|
||||
preview_only?: boolean
|
||||
transcript_available?: boolean
|
||||
pipeline_progress?: { transcribed: number; total: number }
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<section v-if="capabilities.enabled" class="followup-panel" aria-label="回访录音">
|
||||
<section v-if="featureEnabled" class="followup-panel" aria-label="回访录音">
|
||||
<div class="panel-toolbar">
|
||||
<div class="toolbar-title">
|
||||
<el-button v-if="selectedId" link type="primary" :disabled="busy" @click="backToList">返回列表</el-button>
|
||||
@@ -12,13 +12,15 @@
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="helpVisible" class="help-box">
|
||||
<p>录音只生成建议,不会自动写入。逐项核对后选择采纳,再确认写入;日常记录按日期和时段分别保留。</p>
|
||||
<p v-if="previewOnly">当前仅开放测试识别与提炼,可核对并保存审核草稿;测试预览任务不提供病历、日常记录或备注的确认写入。</p>
|
||||
<p v-else>录音只生成建议,不会自动写入。逐项核对后选择采纳,再确认写入;日常记录按日期和时段分别保留。</p>
|
||||
<p>仅当前可见标签页每 5 秒刷新;已编辑内容不会被覆盖。后台分析不受关闭页面影响,未知结果请先核对任务或账单,勿重复上传。</p>
|
||||
<p v-if="detail">任务 #{{ detail.id }} · 版本 {{ detail.version }} · 保留至 {{ formatExpiry(detail.expires_at) }}</p>
|
||||
</div>
|
||||
<el-alert v-if="!capabilities.audio_verified" type="warning" :closable="false" title="当前环境尚未通过音频能力验证,暂不可上传或重试。已有任务可查看。" />
|
||||
<el-alert v-if="previewOnly" type="warning" :closable="false" title="仅测试识别与提炼,不写入病例" description="可保存审核草稿;预览就绪不代表完整音频或准确度验收通过,不允许确认写入病历、日常记录或备注。" show-icon />
|
||||
<el-alert v-if="!audioReady" type="warning" :closable="false" :title="capabilities.preview_only ? '测试预览尚未就绪,暂不可上传或重试。已有任务可查看。' : '当前环境尚未通过音频能力验证,暂不可上传或重试。已有任务可查看。'" />
|
||||
<el-alert v-else-if="capabilities.can_upload && !capabilities.models.length" type="warning" :closable="false" title="暂无可用的分析模型,暂不可上传。已有任务可查看。" />
|
||||
<el-alert v-if="basicDirty" type="warning" :closable="false" title="病历有未保存修改,请先保存病历,再写入录音建议。" />
|
||||
<el-alert v-if="basicDirty" type="warning" :closable="false" :title="previewOnly ? '病历有未保存修改,测试预览不会覆盖这些修改。' : '病历有未保存修改,请先保存病历,再写入录音建议。'" />
|
||||
<el-alert v-if="createUncertain" type="warning" :closable="false" title="创建结果未确认,已停止重复提交。请刷新列表并联系管理员核对,不要重新上传同一录音。" />
|
||||
<el-alert v-if="errorMessage" type="error" :closable="false" :title="errorMessage" show-icon />
|
||||
<div v-if="!selectedId" class="task-list">
|
||||
@@ -86,11 +88,11 @@
|
||||
<div class="selection-summary"><strong>已选 {{ selectedCount }} 项</strong><span>{{ dirty ? '有未保存修改' : '草稿已同步' }}</span></div>
|
||||
<div v-if="canEditReview" class="action-buttons">
|
||||
<el-button :disabled="busy" :loading="mutation === 'save'" @click="saveDraft">保存草稿</el-button>
|
||||
<el-button type="primary" :disabled="busy || basicDirty || !selectedCount || !!applyIssue" :loading="mutation === 'apply'" @click="applySelected">确认写入</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>
|
||||
</div>
|
||||
<p v-if="detail.status === 'applied'" class="applied-note">本次审核已完成,所选项已写入诊单或日常记录,不能重复写入。</p>
|
||||
<p v-if="detail.status === 'applied'" class="applied-note">{{ previewOnly ? '该任务已有历史采纳记录;当前为测试预览,不允许再次写入。' : '本次审核已完成,所选项已写入诊单或日常记录,不能重复写入。' }}</p>
|
||||
</section>
|
||||
<el-dialog v-model="uploadVisible" title="上传回访录音" width="min(520px, calc(100vw - 32px))" append-to-body :close-on-click-modal="false" :close-on-press-escape="!uploading" :show-close="!uploading" :before-close="closeUpload">
|
||||
<el-form label-position="top" class="upload-form">
|
||||
@@ -109,7 +111,7 @@
|
||||
<el-alert v-if="createUncertain && !uploading" type="warning" :closable="false" title="创建结果未确认,请关闭弹窗后刷新任务列表并联系管理员核对,不要重新上传。" />
|
||||
<el-progress v-if="uploading" :percentage="uploadProgress" />
|
||||
<p v-if="uploadStage" class="upload-stage" role="status">{{ uploadStage }}</p>
|
||||
<p class="muted">分析仅生成建议,人工确认后才会写入。</p>
|
||||
<p class="muted">{{ previewOnly ? '本次仅测试识别与提炼,可保存审核草稿,不允许写入病历。' : '分析仅生成建议,人工确认后才会写入。' }}</p>
|
||||
<template #footer><el-button :disabled="uploading" @click="closeUpload()">取消</el-button><el-button type="primary" :disabled="!canUpload || busy || createUncertain" :loading="uploading" @click="submitUpload">上传并分析</el-button></template>
|
||||
</el-dialog>
|
||||
</section>
|
||||
@@ -124,7 +126,7 @@ import {
|
||||
type FollowupAudioChannel, type FollowupCapabilities, type FollowupChannelRoles, type FollowupReviewItem, type FollowupTaskDetail, type FollowupTaskSummary
|
||||
} from '@/api/followupAudio'
|
||||
import FollowupAudioReview from './FollowupAudioReview.vue'
|
||||
import { audioTimestamp, changedReviewTarget, cloneReviewItems, createFollowupScope, followupChannelLabel, followupError, followupProgressText, followupTaskStatus, hasStereoTranscript, normalizedChannelRoles, rebaseReviewItems, retainedTranscriptNotice, reviewFieldIssue, reviewIssue, reviewPending, uploadChunks, uploadIssue } from './followupAudioState'
|
||||
import { audioTimestamp, changedReviewTarget, cloneReviewItems, createFollowupScope, followupAudioReady, followupCanApply, followupCanReview, followupChannelLabel, followupError, followupFeatureEnabled, followupProgressText, followupTaskStatus, hasStereoTranscript, normalizedChannelRoles, rebaseReviewItems, retainedTranscriptNotice, 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;
|
||||
@@ -166,16 +168,21 @@ const channelLoading = ref(false)
|
||||
const playbackError = ref('')
|
||||
const documentVisible = ref(typeof document === 'undefined' || document.visibilityState !== 'hidden')
|
||||
const busy = computed(() => uploading.value || !!mutation.value)
|
||||
const canUpload = computed(() => !props.viewOnly && props.capabilities.enabled && props.capabilities.audio_verified && props.capabilities.can_upload && props.capabilities.models.length > 0)
|
||||
const canEditReview = computed(() => !props.viewOnly && props.capabilities.enabled && props.capabilities.can_apply && detail.value?.status === 'review')
|
||||
const featureEnabled = computed(() => followupFeatureEnabled(props.capabilities))
|
||||
const previewOnly = computed(() => !!props.capabilities.preview_only || !!detail.value?.preview_only)
|
||||
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 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(() => roleDirty.value ? '声道角色标注尚未保存,请先保存草稿并重新核对建议'
|
||||
const applyIssue = computed(() => previewOnly.value ? '测试预览仅保存审核草稿,不允许确认写入病历'
|
||||
: roleDirty.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) || '')
|
||||
const effectiveActive = computed(() => props.active && props.capabilities.enabled && documentVisible.value)
|
||||
const effectiveActive = computed(() => props.active && featureEnabled.value && documentVisible.value)
|
||||
const scope = createFollowupScope()
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
let pollRunning = false
|
||||
@@ -219,7 +226,7 @@ async function readDetail(id: number, preserve = false) {
|
||||
if (!scope.current(ticket) || id !== selectedId.value || (detail.value && fresh.version < detail.value.version)) return
|
||||
const newlyApplied = detail.value?.status === 'review' && fresh.status === 'applied'
|
||||
installDetail(fresh, preserve)
|
||||
if (newlyApplied) emit('applied', props.diagnosisId)
|
||||
if (newlyApplied && !previewOnly.value) emit('applied', props.diagnosisId)
|
||||
if (preserve) errorMessage.value = '服务端记录或版本已变化;已保留你的字段与日期修改,并取消所有选择。请重新核对当前值后选择采纳。'
|
||||
}
|
||||
async function refresh() {
|
||||
@@ -251,7 +258,7 @@ async function confirmLeave(): Promise<boolean> {
|
||||
} catch { return false }
|
||||
}
|
||||
async function openTask(id: number) {
|
||||
if (id === selectedId.value || busy.value) return
|
||||
if (!featureEnabled.value || id === selectedId.value || busy.value) return
|
||||
const previous = scope.capture()
|
||||
if (!await confirmLeave() || !scope.current(previous)) return
|
||||
stop(); resetRoles(); selectedId.value = id; detail.value = null; reviewItems.value = []; snapshot.value = '[]'; errorMessage.value = ''; detailSections.value = []
|
||||
@@ -286,22 +293,22 @@ async function submitUpload() {
|
||||
try {
|
||||
uploadStage.value = '正在创建私有上传会话'
|
||||
const session = await followupAudioUploadSession({ diagnosis_id: props.diagnosisId, file_name: recording.name, total_bytes: recording.size }, ticket.signal)
|
||||
if (!scope.current(ticket)) return
|
||||
if (!scope.current(ticket) || !canUpload.value) return
|
||||
uploadStage.value = '正在上传录音分片'
|
||||
const finished = await uploadChunks(recording, session.chunk_bytes,
|
||||
(index, chunk) => followupAudioUploadChunk(session.upload_id, index, chunk, ticket.signal),
|
||||
() => scope.current(ticket), percent => { uploadProgress.value = percent })
|
||||
() => scope.current(ticket) && canUpload.value, percent => { uploadProgress.value = percent })
|
||||
if (!finished) return
|
||||
uploadStage.value = '正在校验录音时长和完整性'
|
||||
await followupAudioUploadComplete(session.upload_id, ticket.signal)
|
||||
if (!scope.current(ticket)) return
|
||||
if (!scope.current(ticket) || !canUpload.value) return
|
||||
uploadStage.value = '正在创建分析任务'; creating = true; createUncertain.value = true
|
||||
const result = await followupAudioCreate({ diagnosis_id: props.diagnosisId, upload_id: session.upload_id, recorded_at: recordedAt.value, model_key: modelKey.value }, ticket.signal)
|
||||
if (!scope.current(ticket)) return
|
||||
createUncertain.value = false; uploading.value = false; uploadVisible.value = false; file.value = null; if (fileInput.value) fileInput.value.value = ''
|
||||
uploadStage.value = result.reused
|
||||
? result.reuse_message || '同一录音已有任务,已打开原任务,不会重复调用模型'
|
||||
: '任务已创建,后台将进行分析;审核前不会写入业务记录'
|
||||
: props.capabilities.preview_only ? '测试任务已创建,后台将识别与提炼;此任务不允许写入业务记录' : '任务已创建,后台将进行分析;审核前不会写入业务记录'
|
||||
if (result.reused) ElMessage.warning(uploadStage.value)
|
||||
await openTask(result.task_id)
|
||||
} catch (error) {
|
||||
@@ -328,25 +335,25 @@ async function saveDraft() {
|
||||
installDetail(saved, true, false)
|
||||
errorMessage.value = '业务记录已变化,版本已刷新;当前编辑已保留,全部选择已取消,请重新核对后保存或采纳。'
|
||||
} else {
|
||||
installDetail(saved, false); ElMessage.success(changedRoles ? '角色标注已保存,全部建议须重新核对;不会自动重新提炼' : '审核草稿已保存,尚未写入病历')
|
||||
installDetail(saved, false); ElMessage.success(changedRoles ? '角色标注已保存,全部建议须重新核对;不会自动重新提炼' : previewOnly.value ? '测试审核草稿已保存,不会写入病历' : '审核草稿已保存,尚未写入病历')
|
||||
}
|
||||
} catch (error) { if (scope.current(ticket)) await handleReviewError(error) }
|
||||
finally { if (scope.current(ticket)) { mutation.value = ''; await refresh() } }
|
||||
}
|
||||
async function applySelected() {
|
||||
if (!canEditReview.value || !detail.value || busy.value || !selectedCount.value) return
|
||||
if (!canApplyReview.value || !detail.value || busy.value || !selectedCount.value) return
|
||||
if (reviewRef.value?.hasUnsavedChanges()) { ElMessage.warning('请先保存或取消正在核对的单项修改'); return }
|
||||
if (props.basicDirty || (props.beforeApply && !props.beforeApply())) { ElMessage.warning('请先保存病历表单,当前修改不会被覆盖'); return }
|
||||
if (applyIssue.value) { ElMessage.warning(applyIssue.value); return }
|
||||
const ticket = scope.capture()
|
||||
try { await ElMessageBox.confirm(`将一次性采纳 ${selectedCount.value} 项;任一项冲突则整批不写入。确认已核对原话、日期和时间?`, '确认采纳', { type: 'warning', confirmButtonText: '全部采纳', cancelButtonText: '返回核对' }) } catch { return }
|
||||
if (!scope.current(ticket) || !canEditReview.value || !selectedCount.value || props.basicDirty || (props.beforeApply && !props.beforeApply()) || !detail.value) return
|
||||
if (!scope.current(ticket) || !canApplyReview.value || !selectedCount.value || props.basicDirty || (props.beforeApply && !props.beforeApply()) || !detail.value) return
|
||||
if (applyIssue.value) { ElMessage.warning(applyIssue.value); return }
|
||||
const diagnosisId = props.diagnosisId
|
||||
mutation.value = 'apply'; errorMessage.value = ''; clearTimer()
|
||||
try {
|
||||
await followupAudioApply({ id: detail.value.id, version: detail.value.version, items: cloneReviewItems(reviewItems.value) }, ticket.signal)
|
||||
if (!scope.current(ticket)) return
|
||||
if (!scope.current(ticket) || !canApplyReview.value) return
|
||||
detail.value = { ...detail.value, status: 'applied' }; snapshot.value = JSON.stringify(reviewItems.value)
|
||||
ElMessage.success('所选记录已全部采纳'); emit('applied', diagnosisId)
|
||||
await readDetail(selectedId.value)
|
||||
@@ -357,7 +364,7 @@ async function retryTask() {
|
||||
if (!detail.value?.can_retry || detail.value.status !== 'failed' || !canUpload.value || busy.value) return
|
||||
const ticket = scope.capture()
|
||||
try { await ElMessageBox.confirm('仅对服务端已确认失败、允许重试的任务重新分析。确认继续?', '确认重试', { type: 'warning' }) } catch { return }
|
||||
if (!scope.current(ticket) || !detail.value) return
|
||||
if (!scope.current(ticket) || !detail.value?.can_retry || detail.value.status !== 'failed' || !canUpload.value) return
|
||||
mutation.value = 'retry'; clearTimer()
|
||||
try { await followupAudioRetry(detail.value.id, ticket.signal); if (scope.current(ticket)) await readDetail(selectedId.value) }
|
||||
catch (error) { if (scope.current(ticket)) errorMessage.value = followupError(error) }
|
||||
@@ -366,7 +373,7 @@ async function retryTask() {
|
||||
async function reloadAudio() { releaseAudio(); await loadAudio() }
|
||||
async function playChannel(channel: FollowupPlaybackChannel) { await loadAudio((audioElement.value?.currentTime || 0) * 1000, channel === 'original' ? undefined : channel) }
|
||||
async function loadAudio(milliseconds?: number, channel?: FollowupAudioChannel) {
|
||||
if (!detail.value?.audio_available) return
|
||||
if (!featureEnabled.value || !detail.value?.audio_available) return
|
||||
const operation = ++playbackRequest
|
||||
playbackError.value = ''
|
||||
if (channel !== undefined && (!hasStereo.value || (channel !== 0 && channel !== 1))) {
|
||||
@@ -406,7 +413,7 @@ function formatExpiry(value: number | string) {
|
||||
return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })
|
||||
}
|
||||
function onVisibility() { documentVisible.value = document.visibilityState !== 'hidden' }
|
||||
watch([() => props.diagnosisId, effectiveActive], ([id, active]) => {
|
||||
watch([() => props.diagnosisId, effectiveActive, () => !!props.capabilities.preview_only], ([id, active]) => {
|
||||
stop()
|
||||
if (id !== lastDiagnosis) {
|
||||
resetRoles(); lastDiagnosis = id; selectedId.value = 0; detail.value = null; reviewItems.value = []; snapshot.value = '[]'
|
||||
|
||||
@@ -107,8 +107,21 @@ export function rebaseReviewItems(local: FollowupReviewItem[], fresh: FollowupRe
|
||||
time_period: edit.time_period, target_id: edit.target_id, selected: false, needs_review: true } : item
|
||||
})
|
||||
}
|
||||
/** Missing preview readiness/scope fails closed; normal deployments retain their full-verification rules. */
|
||||
export function followupFeatureEnabled(caps: FollowupCapabilities | null): boolean {
|
||||
return !!caps?.enabled && (!caps.preview_only || caps.test_scope_allowed === true)
|
||||
}
|
||||
export function followupAudioReady(caps: FollowupCapabilities | null): boolean {
|
||||
return !!caps && (caps.preview_only ? caps.preview_ready === true && caps.test_scope_allowed === true : caps.audio_verified)
|
||||
}
|
||||
export function followupCanReview(caps: FollowupCapabilities | null, previewTask = false): boolean {
|
||||
return followupFeatureEnabled(caps) && !!caps && (caps.preview_only || previewTask ? caps.can_review === true : caps.can_apply)
|
||||
}
|
||||
export function followupCanApply(caps: FollowupCapabilities | null, previewTask = false): boolean {
|
||||
return followupFeatureEnabled(caps) && !!caps && !caps.preview_only && !previewTask && caps.can_apply
|
||||
}
|
||||
export function uploadIssue(file: Pick<File, 'size' | 'name'> | null, recordedAt: string, model: string, caps: FollowupCapabilities | null, viewOnly: boolean): string {
|
||||
if (viewOnly || !caps?.enabled || !caps.audio_verified || !caps.can_upload) return '当前不可上传录音'
|
||||
if (viewOnly || !caps || !followupFeatureEnabled(caps) || !followupAudioReady(caps) || !caps.can_upload) return '当前不可上传录音'
|
||||
if (!file || !file.size) return '请选择非空录音文件'
|
||||
if (file.size > caps.limits.max_bytes) return '录音文件超过大小限制'
|
||||
if (!/\.(mp3|wav|m4a|amr)$/i.test(file.name)) return '请选择支持的录音格式'
|
||||
|
||||
@@ -1206,3 +1206,220 @@ test('task switch and observed audio expiry both close live stereo routing and r
|
||||
f.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
const previewCaps = (extra = {}) => ({ ...caps(), audio_verified: false, preview_only: true, preview_ready: true, can_review: true, can_apply: false, test_scope_allowed: true, ...extra })
|
||||
|
||||
test('preview readiness is independent of full verification and scope/readiness omissions fail closed', () => {
|
||||
const recording = { name: 'synthetic.wav', size: 100 }
|
||||
assert.equal(state.uploadIssue(recording, '2026-09-29 09:00:00', 'qwen', previewCaps(), false), '')
|
||||
assert.equal(state.followupCanReview(previewCaps()), true)
|
||||
assert.equal(state.followupCanApply(previewCaps({ can_apply: true, audio_verified: true })), false)
|
||||
for (const patch of [{ enabled: false }, { preview_ready: false }, { preview_ready: undefined }, { test_scope_allowed: false }, { test_scope_allowed: undefined }, { can_upload: false }]) {
|
||||
assert.match(state.uploadIssue(recording, '2026-09-29 09:00:00', 'qwen', previewCaps({ audio_verified: true, ...patch }), false), /不可上传/)
|
||||
}
|
||||
assert.equal(state.followupCanReview(previewCaps({ can_review: false, can_apply: true })), false)
|
||||
assert.equal(state.followupCanReview(previewCaps({ can_review: undefined, can_apply: true })), false)
|
||||
assert.equal(state.followupFeatureEnabled(previewCaps({ test_scope_allowed: false })), false)
|
||||
const normal = { ...caps(), preview_only: false, preview_ready: false, can_review: false, test_scope_allowed: false }
|
||||
assert.equal(state.followupAudioReady(normal), true)
|
||||
assert.equal(state.followupCanReview(normal), true)
|
||||
assert.equal(state.followupCanApply(normal), true)
|
||||
assert.match(state.uploadIssue(recording, '2026-09-29 09:00:00', 'qwen', { ...normal, audio_verified: false, preview_ready: true }, false), /不可上传/)
|
||||
for (const helper of [state.followupFeatureEnabled, state.followupAudioReady, state.followupCanReview, state.followupCanApply]) assert.equal(helper(null), false)
|
||||
})
|
||||
|
||||
test('preview actual component can upload, view transcript and save draft with full verification false but never apply', async () => {
|
||||
const f = setupPanel({ followupAudioDetail: async id => task(id, { preview_only: true }), followupAudioSaveDraft: async payload => {
|
||||
f.calls.push(['save', payload]); return task(payload.id, { preview_only: true, version: payload.version + 1, items: payload.items })
|
||||
} }, { capabilities: previewCaps() })
|
||||
await settle()
|
||||
assert.equal(f.panel.canUpload.value, true)
|
||||
f.panel.openUpload(); f.panel.file.value = new File(['synthetic'], 'test.wav'); f.panel.recordedAt.value = '2026-09-29 09:00:00'
|
||||
await f.panel.submitUpload()
|
||||
assert.ok(f.calls.some(call => call[0] === 'create'))
|
||||
assert.equal(f.panel.detail.value.transcript, '测试原文')
|
||||
assert.equal(f.props.capabilities.audio_verified, false)
|
||||
assert.equal(f.panel.canEditReview.value, true)
|
||||
assert.equal(f.panel.canApplyReview.value, false)
|
||||
f.panel.reviewItems.value[0].values.fasting_blood_sugar = 7
|
||||
await f.panel.saveDraft(); await f.panel.applySelected()
|
||||
assert.equal(f.calls.filter(call => call[0] === 'save').length, 1)
|
||||
assert.equal(f.calls.filter(call => call[0] === 'apply').length, 0)
|
||||
assert.deepEqual(f.events, [])
|
||||
assert.match(f.panel.applyIssue.value, /测试预览/)
|
||||
f.dispose()
|
||||
const source = fs.readFileSync(path.join(componentDir, 'FollowupAudioPanel.vue'), 'utf8')
|
||||
assert.match(source, /v-if="previewOnly"[^>]+title="仅测试识别与提炼,不写入病例"/)
|
||||
assert.match(source, /<el-button v-if="canApplyReview"[^>]+@click="applySelected">确认写入/)
|
||||
assert.match(source, /预览就绪不代表完整音频或准确度验收通过/)
|
||||
})
|
||||
|
||||
test('preview roles and review_refreshed drafts remain editable without can_apply and preserve dirty work', async () => {
|
||||
const current = stereoTask({ preview_only: true })
|
||||
const sent = []
|
||||
const f = setupPanel({ followupAudioDetail: async () => current, followupAudioSaveDraft: async payload => {
|
||||
sent.push(payload)
|
||||
return { ...current, version: 2, channel_roles: payload.channel_roles, review_refreshed: true, items: payload.items.map(row => ({ ...row, selected: false, needs_review: true })) }
|
||||
} }, { capabilities: previewCaps() }, { confirm: async () => { throw new Error('cancel') } })
|
||||
await settle(); await f.panel.openTask(1)
|
||||
f.panel.setChannelRoles('left_service'); f.panel.reviewItems.value[0].values.fasting_blood_sugar = 7.2
|
||||
assert.equal(f.panel.dirty.value, true)
|
||||
assert.equal(await f.panel.confirmLeave(), false)
|
||||
await f.panel.saveDraft()
|
||||
assert.equal(sent[0].channel_roles, 'left_service')
|
||||
assert.equal(sent[0].version, 1)
|
||||
assert.equal(f.panel.savedChannelRoles.value, 'left_service')
|
||||
assert.equal(f.panel.reviewItems.value[0].values.fasting_blood_sugar, 7.2)
|
||||
assert.equal(f.panel.reviewItems.value[0].selected, false)
|
||||
await f.panel.applySelected()
|
||||
assert.equal(f.calls.filter(call => call[0] === 'apply').length, 0)
|
||||
assert.deepEqual(f.events, [])
|
||||
f.dispose()
|
||||
})
|
||||
|
||||
test('preview retry uses preview readiness and rechecks readiness after confirmation', async () => {
|
||||
const failed = task(1, { status: 'failed', preview_only: true, can_retry: true })
|
||||
const f = setupPanel({ followupAudioDetail: async () => failed }, { capabilities: previewCaps() })
|
||||
await settle(); await f.panel.openTask(1); await f.panel.retryTask()
|
||||
assert.equal(f.calls.filter(call => call[0] === 'retry').length, 1)
|
||||
f.props.capabilities = previewCaps({ preview_ready: false, audio_verified: true })
|
||||
await f.panel.retryTask()
|
||||
assert.equal(f.calls.filter(call => call[0] === 'retry').length, 1)
|
||||
f.dispose()
|
||||
const confirm = deferred()
|
||||
const g = setupPanel({ followupAudioDetail: async () => failed }, { capabilities: previewCaps() }, { confirm: () => confirm.promise })
|
||||
await settle(); await g.panel.openTask(1)
|
||||
const retry = g.panel.retryTask(); await settle(); g.props.capabilities.preview_ready = false; confirm.resolve(true); await retry
|
||||
assert.equal(g.calls.filter(call => call[0] === 'retry').length, 0)
|
||||
g.dispose()
|
||||
})
|
||||
|
||||
test('preview task origin permanently blocks apply even after global mode becomes normal or can_apply is forged true', async () => {
|
||||
for (const capabilities of [previewCaps({ can_apply: true, audio_verified: true }), { ...caps(), preview_only: false, can_review: true }]) {
|
||||
const f = setupPanel({ followupAudioDetail: async () => task(1, { preview_only: true }) }, { capabilities })
|
||||
await settle(); await f.panel.openTask(1)
|
||||
assert.equal(f.panel.previewOnly.value, true)
|
||||
assert.equal(f.panel.canEditReview.value, true)
|
||||
assert.equal(f.panel.canApplyReview.value, false)
|
||||
await f.panel.applySelected()
|
||||
assert.equal(f.calls.filter(call => call[0] === 'apply').length, 0)
|
||||
assert.deepEqual(f.events, [])
|
||||
f.dispose()
|
||||
}
|
||||
assert.equal(state.followupCanApply(caps(), true), false)
|
||||
})
|
||||
|
||||
test('preview never emits an applied event from polling or a late normal-mode apply response', async () => {
|
||||
let current = task(1, { preview_only: true })
|
||||
const f = setupPanel({ followupAudioLists: async () => ({ items: [current] }), followupAudioDetail: async () => current }, { capabilities: previewCaps() })
|
||||
await settle(); await f.panel.openTask(1)
|
||||
current = { ...current, version: 2, status: 'applied' }; f.tick(); await settle()
|
||||
assert.equal(f.panel.detail.value.status, 'applied')
|
||||
assert.deepEqual(f.events, [])
|
||||
f.dispose()
|
||||
const response = deferred()
|
||||
const g = setupPanel({ followupAudioApply: async payload => { g.calls.push(['apply', payload]); return response.promise } })
|
||||
await settle(); await g.panel.openTask(1)
|
||||
const applying = g.panel.applySelected(); await settle()
|
||||
assert.equal(g.calls.filter(call => call[0] === 'apply').length, 1, 'request began in normal mode, not preview')
|
||||
g.props.capabilities = previewCaps(); await settle(); response.resolve({ id: 1, status: 'applied', applied_items: [] }); await applying
|
||||
assert.deepEqual(g.events, [])
|
||||
assert.equal(g.notices.filter(([kind]) => kind === 'success').length, 0)
|
||||
g.dispose()
|
||||
})
|
||||
|
||||
test('outside preview allowlist panel remains hidden and makes no feature requests; revocation fences old responses', async () => {
|
||||
for (const enabled of [false, true]) {
|
||||
const f = setupPanel({}, { capabilities: previewCaps({ enabled, test_scope_allowed: false, models: [] }) })
|
||||
await settle(); await f.panel.openTask(1); f.panel.openUpload(); await f.panel.saveDraft(); await f.panel.applySelected()
|
||||
assert.equal(f.panel.featureEnabled.value, false)
|
||||
assert.equal(f.panel.uploadVisible.value, false)
|
||||
assert.deepEqual(f.calls, [])
|
||||
assert.deepEqual(f.events, [])
|
||||
f.dispose()
|
||||
}
|
||||
const pending = deferred()
|
||||
const g = setupPanel({ followupAudioDetail: async () => pending.promise }, { capabilities: previewCaps() })
|
||||
await settle(); const opening = g.panel.openTask(1); await settle()
|
||||
g.props.capabilities = previewCaps({ enabled: false, test_scope_allowed: false, models: [] }); await settle()
|
||||
pending.resolve(task(1, { preview_only: true, transcript: '旧范围合成文本' })); await opening
|
||||
assert.equal(g.panel.detail.value, null)
|
||||
assert.equal(g.timers.size, 0)
|
||||
assert.equal(g.panel.featureEnabled.value, false)
|
||||
g.dispose()
|
||||
assert.match(fs.readFileSync(path.join(componentDir, 'FollowupAudioPanel.vue'), 'utf8'), /<section v-if="featureEnabled"/)
|
||||
})
|
||||
|
||||
test('preview readonly and can_review denial cannot save roles or drafts, while old patient saves stay fenced', async () => {
|
||||
for (const props of [{ viewOnly: true, capabilities: previewCaps() }, { capabilities: previewCaps({ can_review: false, can_apply: true }) }]) {
|
||||
const f = setupPanel({ followupAudioDetail: async () => stereoTask({ preview_only: true }) }, props)
|
||||
await settle(); await f.panel.openTask(1); f.panel.setChannelRoles('left_service'); await f.panel.saveDraft(); await f.panel.applySelected()
|
||||
assert.equal(f.panel.channelRoles.value, 'unconfirmed')
|
||||
assert.equal(f.calls.filter(call => ['save', 'apply'].includes(call[0])).length, 0)
|
||||
f.dispose()
|
||||
}
|
||||
const pending = deferred()
|
||||
const g = setupPanel({ followupAudioDetail: async () => stereoTask({ preview_only: true }), followupAudioSaveDraft: async () => pending.promise }, { capabilities: previewCaps() })
|
||||
await settle(); await g.panel.openTask(1); g.panel.setChannelRoles('left_service')
|
||||
const saving = g.panel.saveDraft(); await settle(); g.props.diagnosisId = 20; await settle()
|
||||
pending.resolve(stereoTask({ preview_only: true, version: 2, channel_roles: 'left_service' })); await saving
|
||||
assert.equal(g.panel.detail.value, null)
|
||||
assert.equal(g.panel.channelRoles.value, 'unconfirmed')
|
||||
assert.deepEqual(g.events, [])
|
||||
assert.deepEqual(g.notices, [])
|
||||
g.dispose()
|
||||
})
|
||||
|
||||
test('actual patient editor outside preview allowlist still opens and edits its ordinary diagnosis form', async () => {
|
||||
const edits = [], warnings = [], events = []
|
||||
const filename = path.join(componentDir, '../edit.vue')
|
||||
const descriptor = parse(fs.readFileSync(filename, 'utf8'), { filename }).descriptor
|
||||
const script = compileScript(descriptor, { id: 'preview-editor-runtime' })
|
||||
const module = execute(script.content, name => {
|
||||
if (name === 'vue') return { ...vue, onBeforeUnmount() {} }
|
||||
if (name === '@/api/tcm') return {
|
||||
tcmDiagnosisEdit: async payload => { edits.push(payload); return {} },
|
||||
tcmDiagnosisDetail: async ({ id }) => ({ id: String(id), patient_id: String(id + 100), patient_name: '合成患者', phone: '13800000000', id_card: '', remark: '普通原始备注', diabetes_discovery_year: '', show_card: 1 })
|
||||
}
|
||||
if (name === '@/api/app') return { getDictData: async () => ({}) }
|
||||
if (name === '@/api/followupAudio') return { followupAudioCapabilities: async () => previewCaps({ enabled: false, test_scope_allowed: false, can_upload: false, can_review: false, models: [] }) }
|
||||
if (name === '@/utils/feedback') return { default: { msgWarning: msg => warnings.push(msg) } }
|
||||
if (name === '@/utils/perm') return { hasPermission: () => true }
|
||||
if (name === '@/stores/modules/user') return { default: () => ({ userInfo: { id: 1 } }) }
|
||||
if (name === '@/stores/modules/app') return { default: () => ({ getImageUrl: value => value }) }
|
||||
if (name === 'element-plus') return { ElMessage: { warning() {} } }
|
||||
if (name === '@element-plus/icons-vue' || name === '@/api/patient') return {}
|
||||
if (name.endsWith('.vue')) return { default: {} }
|
||||
throw new Error(`Unexpected editor import: ${name}`)
|
||||
})
|
||||
const scope = vue.effectScope()
|
||||
try {
|
||||
const editor = scope.run(() => module.default.setup({}, { expose() {}, emit: (...args) => events.push(args) }))
|
||||
await editor.open('edit', 30); await settle()
|
||||
assert.equal(editor.visible.value, true)
|
||||
assert.equal(editor.formData.value.id, '30')
|
||||
assert.equal(editor.followupCapabilities.value.enabled, false)
|
||||
assert.equal(editor.followupCapabilities.value.test_scope_allowed, false)
|
||||
assert.equal(editor.basicDirty.value, false)
|
||||
editor.formData.value.remark = '合成普通表单编辑'
|
||||
assert.equal(editor.basicDirty.value, true)
|
||||
await editor.handleSubmit()
|
||||
assert.equal(edits.length, 1, 'ordinary diagnosis mock API remains usable; no real patient write')
|
||||
assert.equal(edits[0].remark, '合成普通表单编辑')
|
||||
assert.match(descriptor.template.content, /v-if="followupCapabilities\?\.enabled && formData.id"/)
|
||||
} finally { scope.stop() }
|
||||
})
|
||||
|
||||
test('preview upload stops before create when readiness, upload permission or allowed scope is withdrawn', async () => {
|
||||
for (const withdrawn of [{ preview_ready: false }, { can_upload: false }, { test_scope_allowed: false, enabled: false }]) {
|
||||
const pending = deferred()
|
||||
const f = setupPanel({ followupAudioUploadChunk: async () => pending.promise }, { capabilities: previewCaps() })
|
||||
await settle(); f.panel.file.value = new File(['synthetic'], 'test.wav'); f.panel.recordedAt.value = '2026-09-29 09:00:00'
|
||||
const uploading = f.panel.submitUpload(); await settle()
|
||||
Object.assign(f.props.capabilities, withdrawn); await settle(); pending.resolve({ received: true }); await uploading
|
||||
assert.equal(f.calls.filter(call => call[0] === 'complete' || call[0] === 'create').length, 0)
|
||||
assert.equal(f.panel.uploading.value, false)
|
||||
assert.deepEqual(f.events, [])
|
||||
f.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user