782 lines
30 KiB
Vue
782 lines
30 KiB
Vue
<template>
|
||
<Transition name="risk-sim-dialog">
|
||
<div v-if="open" class="risk-sim-backdrop" @click.self="handleClose">
|
||
<section class="risk-sim-modal" role="dialog" aria-modal="true" aria-label="风险规则仿真测试">
|
||
<header class="risk-sim-head">
|
||
<div class="risk-sim-title">
|
||
<span>独立仿真测试</span>
|
||
<h3>{{ rule?.name || '风险规则' }}</h3>
|
||
<p>临时对话只做单据识别和风险规则执行,不创建报销单,不写入主工作台会话。</p>
|
||
</div>
|
||
<button type="button" class="risk-sim-icon-btn" aria-label="关闭" @click="handleClose">
|
||
<i class="mdi mdi-close"></i>
|
||
</button>
|
||
</header>
|
||
|
||
<div class="risk-sim-meta">
|
||
<span>版本:{{ testVersion }}</span>
|
||
<span :class="severityTone">{{ rule?.riskRuleSeverityLabel || '中风险' }}</span>
|
||
<span>{{ requiresAttachment ? '测试需附件' : '文字测试' }}</span>
|
||
<span>{{ latestSummary?.test_passed ? '测试结论已保存' : '测试结论未保存' }}</span>
|
||
<span>Session:{{ sessionShortId }}</span>
|
||
</div>
|
||
|
||
<div class="risk-sim-main">
|
||
<section class="risk-sim-dialog-panel">
|
||
<div ref="messageListRef" class="risk-sim-message-list" aria-live="polite">
|
||
<article
|
||
v-for="message in messages"
|
||
:key="message.id"
|
||
class="risk-sim-message-row"
|
||
:class="message.role"
|
||
>
|
||
<span class="risk-sim-avatar" aria-hidden="true">
|
||
<i :class="message.role === 'assistant' ? 'mdi mdi-shield-search-outline' : 'mdi mdi-account-outline'"></i>
|
||
</span>
|
||
|
||
<div class="risk-sim-bubble">
|
||
<header>
|
||
<strong>{{ message.role === 'assistant' ? '风险仿真助手' : '我' }}</strong>
|
||
<time>{{ message.time }}</time>
|
||
</header>
|
||
|
||
<p v-if="message.text">{{ message.text }}</p>
|
||
|
||
<div v-if="message.attachments?.length" class="risk-sim-message-files">
|
||
<span v-for="file in message.attachments" :key="`${message.id}-${file.id}`">
|
||
<i class="mdi mdi-file-document-outline"></i>
|
||
{{ file.name }}
|
||
</span>
|
||
</div>
|
||
|
||
<div v-if="message.recognitionDocuments?.length" class="risk-sim-recognition-debug">
|
||
<span>单据识别明细</span>
|
||
<article
|
||
v-for="document in message.recognitionDocuments"
|
||
:key="`${message.id}-${document.filename}`"
|
||
>
|
||
<header>
|
||
<strong>{{ document.filename || '临时单据' }}</strong>
|
||
<em>{{ formatDocumentMeta(document) }}</em>
|
||
</header>
|
||
<p v-if="document.summary">摘要:{{ document.summary }}</p>
|
||
<div v-if="document.document_fields?.length" class="risk-sim-debug-field-list">
|
||
<b
|
||
v-for="field in document.document_fields"
|
||
:key="`${document.filename}-${field.key}-${field.value}`"
|
||
>
|
||
{{ field.label }}[{{ field.key }}]:{{ field.value }}
|
||
</b>
|
||
</div>
|
||
<p v-if="document.text" class="risk-sim-debug-ocr-text">
|
||
OCR原文:{{ trimDebugText(document.text, 800) }}
|
||
</p>
|
||
</article>
|
||
</div>
|
||
|
||
<div v-if="message.result" class="risk-sim-result-card" :class="message.result.severity">
|
||
<div class="risk-sim-result-head">
|
||
<div>
|
||
<span>{{ message.result.ready === false ? '流程状态' : '识别结果' }}</span>
|
||
<strong>
|
||
{{ message.result.ready === false ? '待补充后再判断' : (message.result.hit ? '命中风险' : '未命中风险') }}
|
||
</strong>
|
||
</div>
|
||
<b>{{ message.result.severity_label }}</b>
|
||
</div>
|
||
|
||
<p v-if="message.result.blocking_reason" class="risk-sim-blocking-message">
|
||
{{ message.result.blocking_reason }}
|
||
</p>
|
||
|
||
<p v-if="message.result.message" class="risk-sim-result-message">
|
||
{{ message.result.message }}
|
||
</p>
|
||
|
||
<div class="risk-sim-field-grid">
|
||
<div v-for="item in buildResultFields(message.result)" :key="item.key">
|
||
<span>{{ item.label }}</span>
|
||
<strong>{{ item.value }}</strong>
|
||
</div>
|
||
</div>
|
||
|
||
<div
|
||
v-if="buildFieldPipelineSections(message.result).length"
|
||
class="risk-sim-field-pipeline"
|
||
>
|
||
<section
|
||
v-for="section in buildFieldPipelineSections(message.result)"
|
||
:key="section.key"
|
||
>
|
||
<header>
|
||
<span>{{ section.title }}</span>
|
||
<small>{{ section.description }}</small>
|
||
</header>
|
||
<ul>
|
||
<li v-for="field in section.rows" :key="field.key">
|
||
<strong>{{ field.label }}</strong>
|
||
<em>{{ field.source }}</em>
|
||
<b>{{ field.value }}</b>
|
||
</li>
|
||
</ul>
|
||
</section>
|
||
</div>
|
||
|
||
<div
|
||
v-if="buildRecognizedFieldRows(message.result).length"
|
||
class="risk-sim-recognized-fields"
|
||
>
|
||
<span>规则实际取用字段</span>
|
||
<ul>
|
||
<li v-for="field in buildRecognizedFieldRows(message.result)" :key="field.key">
|
||
<strong>{{ field.label }}</strong>
|
||
<em>{{ field.source }}</em>
|
||
<b>{{ field.value }}</b>
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
|
||
<div v-if="buildEvidenceItems(message.result).length" class="risk-sim-evidence">
|
||
<span>判断依据</span>
|
||
<ul>
|
||
<li v-for="item in buildEvidenceItems(message.result)" :key="item">{{ item }}</li>
|
||
</ul>
|
||
</div>
|
||
|
||
<div v-if="buildTraceItems(message.result).length" class="risk-sim-evidence">
|
||
<span>执行路径</span>
|
||
<ul>
|
||
<li v-for="item in buildTraceItems(message.result)" :key="item">{{ item }}</li>
|
||
</ul>
|
||
</div>
|
||
|
||
<div v-if="message.result.missing_fields?.length" class="risk-sim-missing-fields">
|
||
<span>待补充字段</span>
|
||
<b v-for="field in message.result.missing_fields" :key="field.key">
|
||
{{ formatFieldLabel(field) }}
|
||
</b>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</article>
|
||
|
||
<article v-if="busyAction === 'simulate' || recognitionBusy" class="risk-sim-message-row assistant">
|
||
<span class="risk-sim-avatar" aria-hidden="true">
|
||
<i class="mdi mdi-shield-search-outline"></i>
|
||
</span>
|
||
<div class="risk-sim-bubble">
|
||
<header>
|
||
<strong>风险仿真助手</strong>
|
||
<time>{{ currentTime }}</time>
|
||
</header>
|
||
<p class="risk-sim-thinking">
|
||
<i class="mdi mdi-loading mdi-spin"></i>
|
||
{{ recognitionBusy ? '正在识别临时单据...' : '正在调用规则执行器识别风险...' }}
|
||
</p>
|
||
</div>
|
||
</article>
|
||
</div>
|
||
|
||
<div v-if="requiresAttachment && uploadedFiles.length" class="risk-sim-file-strip">
|
||
<span>本轮临时附件</span>
|
||
<div>
|
||
<button
|
||
v-for="file in uploadedFiles"
|
||
:key="file.id"
|
||
type="button"
|
||
class="risk-sim-file-chip"
|
||
:class="file.status"
|
||
:title="`${file.name} · ${formatFileSize(file.size)}`"
|
||
@click="removeFile(file.id)"
|
||
>
|
||
<i class="mdi mdi-file-document-outline"></i>
|
||
<span>{{ file.name }}</span>
|
||
<em>{{ resolveFileStatusLabel(file) }}</em>
|
||
<i class="mdi mdi-close"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<footer class="risk-sim-composer" :class="{ 'text-only': !requiresAttachment }">
|
||
<input
|
||
v-if="requiresAttachment"
|
||
ref="fileInputRef"
|
||
class="risk-sim-file-input"
|
||
type="file"
|
||
multiple
|
||
@change="handleFileChange"
|
||
/>
|
||
<button
|
||
v-if="requiresAttachment"
|
||
type="button"
|
||
class="risk-sim-tool-btn"
|
||
:disabled="busy"
|
||
aria-label="上传临时单据"
|
||
@click="triggerFilePick"
|
||
>
|
||
<i class="mdi mdi-paperclip"></i>
|
||
</button>
|
||
<div class="risk-sim-composer-shell">
|
||
<textarea
|
||
ref="composerRef"
|
||
v-model="draft"
|
||
:disabled="busy"
|
||
rows="1"
|
||
:placeholder="composerPlaceholder"
|
||
@keydown.enter.exact.prevent="sendMessage"
|
||
></textarea>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
class="risk-sim-send-btn"
|
||
:disabled="busy || !canSend"
|
||
:title="sendBlockedReason"
|
||
aria-label="执行风险识别"
|
||
@click="sendMessage"
|
||
>
|
||
<i :class="busyAction === 'simulate' ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-send'"></i>
|
||
</button>
|
||
</footer>
|
||
</section>
|
||
|
||
<aside class="risk-sim-context-panel">
|
||
<section>
|
||
<span>仿真流程</span>
|
||
<div class="risk-sim-step-list">
|
||
<div
|
||
v-for="step in simulationSteps"
|
||
:key="step.key"
|
||
class="risk-sim-step"
|
||
:class="step.status"
|
||
>
|
||
<i :class="step.icon"></i>
|
||
<div>
|
||
<strong>{{ step.label }}</strong>
|
||
<p>{{ step.description }}</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section v-if="recognizedDocuments.length || recognitionError">
|
||
<span>单据识别</span>
|
||
<div class="risk-sim-recognition-list">
|
||
<article v-for="item in recognizedDocuments" :key="item.filename">
|
||
<strong>{{ item.document_type_label || item.scene_label || '已识别单据' }}</strong>
|
||
<p>{{ item.filename }}</p>
|
||
<small>{{ buildDocumentBrief(item) }}</small>
|
||
</article>
|
||
<p v-if="recognitionError" class="risk-sim-error-text">{{ recognitionError }}</p>
|
||
</div>
|
||
</section>
|
||
|
||
<section>
|
||
<span>规则边界</span>
|
||
<strong>{{ requiresAttachment ? '附件和文字合并判断' : '仅使用文字事实判断' }}</strong>
|
||
<p>{{ boundaryDescription }}</p>
|
||
</section>
|
||
|
||
<section>
|
||
<span>测试报告</span>
|
||
<strong>{{ testReportTitle }}</strong>
|
||
<p>{{ testReportDescription }}</p>
|
||
</section>
|
||
|
||
<section>
|
||
<span>使用字段</span>
|
||
<div class="risk-sim-field-list">
|
||
<b v-for="field in displayFields" :key="field.key">{{ field.label }}</b>
|
||
<em v-if="!displayFields.length">当前规则未声明字段</em>
|
||
</div>
|
||
</section>
|
||
|
||
<section>
|
||
<span>关闭后清理</span>
|
||
<p>聊天记录、临时附件、识别结果会从弹窗内存中清空。</p>
|
||
</section>
|
||
</aside>
|
||
</div>
|
||
|
||
<footer class="risk-sim-foot">
|
||
<span>{{ lastSimulationHint }}</span>
|
||
<div>
|
||
<button type="button" class="risk-sim-secondary-btn" :disabled="busy" @click="resetConversation">
|
||
<i class="mdi mdi-refresh"></i>
|
||
<span>清空本轮</span>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="risk-sim-primary-btn"
|
||
:disabled="busy || !activeSimulationResult?.ready"
|
||
@click="saveSimulationConclusion"
|
||
>
|
||
<i :class="busyAction === 'report' ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-check-decagram-outline'"></i>
|
||
<span>{{ busyAction === 'report' ? '保存中' : '确认测试通过' }}</span>
|
||
</button>
|
||
</div>
|
||
</footer>
|
||
</section>
|
||
</div>
|
||
</Transition>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { computed, nextTick, ref, watch } from 'vue'
|
||
|
||
import {
|
||
confirmRiskRuleTestReport,
|
||
runRiskRuleSampleTest,
|
||
simulateRiskRuleTest
|
||
} from '../../services/agentAssets.js'
|
||
import { recognizeOcrFiles } from '../../services/ocr.js'
|
||
import { useToast } from '../../composables/useToast.js'
|
||
import {
|
||
createId,
|
||
documentHasMeaningfulText,
|
||
formatFileSize,
|
||
formatTestError,
|
||
formatTime,
|
||
mergeRecognizedDocuments,
|
||
normalizeOcrDocuments,
|
||
toAttachmentPayload
|
||
} from './riskRuleTestDialogUtils.js'
|
||
import {
|
||
buildDocumentBrief,
|
||
buildEvidenceItems as buildEvidenceItemsModel,
|
||
buildFieldPipelineSections as buildFieldPipelineSectionsModel,
|
||
buildRecognizedFieldRows as buildRecognizedFieldRowsModel,
|
||
buildResultFields as buildResultFieldsModel,
|
||
buildTraceItems as buildTraceItemsModel,
|
||
formatDocumentMeta,
|
||
formatFieldLabel,
|
||
resolveFileStatusLabel,
|
||
trimDebugText
|
||
} from './riskRuleTestDialogDisplay.js'
|
||
|
||
const props = defineProps({
|
||
open: {
|
||
type: Boolean,
|
||
default: false
|
||
},
|
||
rule: {
|
||
type: Object,
|
||
default: null
|
||
}
|
||
})
|
||
|
||
const emit = defineEmits(['close', 'report-saved'])
|
||
const { toast } = useToast()
|
||
|
||
const messageListRef = ref(null)
|
||
const fileInputRef = ref(null)
|
||
const composerRef = ref(null)
|
||
const sessionId = ref('')
|
||
const draft = ref('')
|
||
const messages = ref([])
|
||
const uploadedFiles = ref([])
|
||
const recognizedDocuments = ref([])
|
||
const recognitionError = ref('')
|
||
const activeSimulationResult = ref(null)
|
||
const latestSummary = ref(null)
|
||
const busyAction = ref('')
|
||
|
||
const requiresAttachment = computed(() => Boolean(props.rule?.riskRuleRequiresAttachment))
|
||
const recognitionBusy = computed(() => busyAction.value === 'recognize')
|
||
const busy = computed(() => Boolean(busyAction.value))
|
||
const hasPendingFiles = computed(() => uploadedFiles.value.some((file) => file.status === 'recognizing'))
|
||
const hasRecognizedFiles = computed(() => uploadedFiles.value.some((file) => file.status === 'recognized'))
|
||
const hasFailedOnlyFiles = computed(() => uploadedFiles.value.length > 0 && uploadedFiles.value.every((file) => file.status === 'failed'))
|
||
const canSend = computed(() => {
|
||
if (hasPendingFiles.value) return false
|
||
const hasText = Boolean(draft.value.trim())
|
||
if (requiresAttachment.value) {
|
||
return hasText && uploadedFiles.value.length > 0
|
||
}
|
||
return hasText
|
||
})
|
||
const fields = computed(() => (Array.isArray(props.rule?.riskRuleFields) ? props.rule.riskRuleFields : []))
|
||
const displayFields = computed(() => fields.value.map((field) => ({
|
||
key: field.key,
|
||
label: formatFieldLabel(field)
|
||
})))
|
||
const composerPlaceholder = computed(() => requiresAttachment.value
|
||
? '填写测试意图并上传附件,例如:请检查这张酒店发票是否与行程城市一致'
|
||
: '描述测试事实,例如:酒店发票城市上海,申报目的地北京,金额580元')
|
||
const boundaryDescription = computed(() => requiresAttachment.value
|
||
? '附件选择后不会立即识别,点击发送时才会和输入内容一起进入仿真;不会创建报销草稿或写入风险标记。'
|
||
: '这条规则不需要上传附件,测试窗口只根据输入文字执行规则;不会创建报销草稿或影响审批流。')
|
||
const testVersion = computed(() => props.rule?.workingVersion || props.rule?.displayVersion || props.rule?.version || '-')
|
||
const sessionShortId = computed(() => sessionId.value ? sessionId.value.slice(-8).toUpperCase() : '-')
|
||
const currentTime = computed(() => formatTime())
|
||
const severityTone = computed(() => `tone-${props.rule?.riskRuleSeverity || 'medium'}`)
|
||
const sendBlockedReason = computed(() => {
|
||
if (hasPendingFiles.value) return '单据识别中,请稍后执行风险识别。'
|
||
if (requiresAttachment.value && !uploadedFiles.value.length) return '这条规则要求上传测试附件。'
|
||
if (requiresAttachment.value && !draft.value.trim()) return '请填写测试意图或关键事实,附件会和文字一起判断。'
|
||
if (!draft.value.trim()) return '请先描述测试单据或测试事实。'
|
||
return ''
|
||
})
|
||
const simulationSteps = computed(() => [
|
||
{
|
||
key: 'recognize',
|
||
label: '1. 单据识别',
|
||
description: buildRecognitionStepDescription(),
|
||
status: resolveRecognitionStepStatus(),
|
||
icon: recognitionBusy.value ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-text-recognition'
|
||
},
|
||
{
|
||
key: 'fields',
|
||
label: '2. 字段确认',
|
||
description: buildFieldStepDescription(),
|
||
status: resolveFieldStepStatus(),
|
||
icon: 'mdi mdi-format-list-checks'
|
||
},
|
||
{
|
||
key: 'execute',
|
||
label: '3. 规则执行',
|
||
description: activeSimulationResult.value?.ready
|
||
? '已使用规则执行器完成判断。'
|
||
: '字段齐备后才会执行规则。',
|
||
status: activeSimulationResult.value?.ready ? 'done' : 'pending',
|
||
icon: 'mdi mdi-shield-check-outline'
|
||
}
|
||
])
|
||
const lastSimulationHint = computed(() => {
|
||
if (!activeSimulationResult.value) {
|
||
return '本窗口是独立临时会话,关闭后会清空聊天记录和上传文件。'
|
||
}
|
||
if (activeSimulationResult.value.ready === false) {
|
||
return `最近一次仿真:${activeSimulationResult.value.summary || '待补充字段'}`
|
||
}
|
||
return activeSimulationResult.value.hit
|
||
? `最近一次仿真:命中${activeSimulationResult.value.severity_label}`
|
||
: '最近一次仿真:未命中风险'
|
||
})
|
||
const testReportTitle = computed(() => latestSummary.value?.test_passed ? '已确认测试通过' : '待确认测试结论')
|
||
const testReportDescription = computed(() => {
|
||
const summary = latestSummary.value
|
||
if (!summary) return '暂无测试报告。完成一次仿真后,可点击底部按钮确认测试通过。'
|
||
if (summary.report?.summary) return summary.report.summary
|
||
if (summary.sample?.summary) return `样例复核:${summary.sample.summary}`
|
||
if (summary.scenario?.summary) return `场景试运行:${summary.scenario.summary}`
|
||
return summary.test_passed ? '测试结论已保存。' : '暂无通过结论。'
|
||
})
|
||
|
||
watch(
|
||
() => props.open,
|
||
(open) => {
|
||
if (open) {
|
||
initializeSession()
|
||
} else {
|
||
destroySession()
|
||
}
|
||
},
|
||
{ immediate: true }
|
||
)
|
||
|
||
function initializeSession() {
|
||
sessionId.value = createId()
|
||
latestSummary.value = props.rule?.latestTestSummary || null
|
||
draft.value = ''
|
||
uploadedFiles.value = []
|
||
recognizedDocuments.value = []
|
||
recognitionError.value = ''
|
||
activeSimulationResult.value = null
|
||
busyAction.value = ''
|
||
messages.value = [buildMessage('assistant', buildWelcomeMessage())]
|
||
clearFileInput()
|
||
nextTick(() => {
|
||
scrollMessagesToBottom()
|
||
composerRef.value?.focus()
|
||
})
|
||
}
|
||
|
||
function destroySession() {
|
||
draft.value = ''
|
||
messages.value = []
|
||
uploadedFiles.value = []
|
||
recognizedDocuments.value = []
|
||
recognitionError.value = ''
|
||
activeSimulationResult.value = null
|
||
busyAction.value = ''
|
||
sessionId.value = ''
|
||
clearFileInput()
|
||
}
|
||
|
||
function resetConversation() {
|
||
initializeSession()
|
||
}
|
||
|
||
function handleClose() {
|
||
destroySession()
|
||
emit('close')
|
||
}
|
||
|
||
function triggerFilePick() {
|
||
if (!requiresAttachment.value) return
|
||
fileInputRef.value?.click()
|
||
}
|
||
|
||
function handleFileChange(event) {
|
||
if (!requiresAttachment.value) {
|
||
clearFileInput()
|
||
return
|
||
}
|
||
const input = event.target
|
||
const incoming = Array.from(input?.files || [])
|
||
if (!incoming.length) return
|
||
const nextFiles = incoming.map((file) => ({
|
||
id: createId(),
|
||
name: file.name,
|
||
size: file.size,
|
||
contentType: file.type || '',
|
||
status: 'pending',
|
||
statusText: '待发送',
|
||
ocrDocument: null,
|
||
error: '',
|
||
file
|
||
}))
|
||
uploadedFiles.value = [...uploadedFiles.value, ...nextFiles].slice(0, 12)
|
||
clearFileInput()
|
||
}
|
||
|
||
function removeFile(fileId) {
|
||
uploadedFiles.value = uploadedFiles.value.filter((file) => file.id !== fileId)
|
||
}
|
||
|
||
async function sendMessage() {
|
||
if (!props.rule?.id || !canSend.value || busy.value) return
|
||
const activeSessionId = sessionId.value
|
||
const text = draft.value.trim()
|
||
const runFiles = requiresAttachment.value ? uploadedFiles.value.slice() : []
|
||
messages.value.push(buildMessage('user', text, { attachments: runFiles }))
|
||
draft.value = ''
|
||
await nextTick()
|
||
scrollMessagesToBottom()
|
||
|
||
try {
|
||
let attachments = []
|
||
if (requiresAttachment.value) {
|
||
const filesForRecognition = runFiles.filter((file) => file.status !== 'recognized')
|
||
if (filesForRecognition.length) {
|
||
busyAction.value = 'recognize'
|
||
await recognizeTemporaryFiles(filesForRecognition, activeSessionId)
|
||
if (!isActiveSession(activeSessionId)) return
|
||
}
|
||
attachments = runFiles.map(toAttachmentPayload)
|
||
}
|
||
busyAction.value = 'simulate'
|
||
const result = await simulateRiskRuleTest(props.rule.id, {
|
||
version: testVersion.value,
|
||
message: text,
|
||
attachments
|
||
})
|
||
if (!isActiveSession(activeSessionId)) return
|
||
activeSimulationResult.value = result
|
||
messages.value.push(buildMessage('assistant', result.summary, { result }))
|
||
} catch (error) {
|
||
if (!isActiveSession(activeSessionId)) return
|
||
messages.value.push(buildMessage('assistant', formatTestError(error, '仿真识别失败,请稍后重试。')))
|
||
} finally {
|
||
if (isActiveSession(activeSessionId)) {
|
||
busyAction.value = ''
|
||
await nextTick()
|
||
scrollMessagesToBottom()
|
||
composerRef.value?.focus()
|
||
}
|
||
}
|
||
}
|
||
|
||
async function saveSimulationConclusion() {
|
||
if (!props.rule?.id || !activeSimulationResult.value?.ready || busy.value) return
|
||
const activeSessionId = sessionId.value
|
||
busyAction.value = 'report'
|
||
try {
|
||
const sample = await runRiskRuleSampleTest(props.rule.id, {
|
||
version: testVersion.value,
|
||
cases: []
|
||
})
|
||
if (!sample?.passed) {
|
||
messages.value.push(buildMessage('assistant', '系统样例复核未通过,暂不能保存测试通过结论。请调整规则后重新仿真。'))
|
||
return
|
||
}
|
||
const report = await confirmRiskRuleTestReport(props.rule.id, {
|
||
version: testVersion.value,
|
||
confirm_passed: true,
|
||
note: '通过独立对话仿真后确认测试通过;聊天记录和临时附件不保存。'
|
||
})
|
||
if (!isActiveSession(activeSessionId)) return
|
||
latestSummary.value = {
|
||
...(latestSummary.value || {}),
|
||
sample,
|
||
report,
|
||
test_passed: true
|
||
}
|
||
emit('report-saved', latestSummary.value)
|
||
messages.value.push(buildMessage('assistant', '测试通过结论已保存。这个动作只更新规则生命周期状态,不保存本轮聊天记录或上传文件。'))
|
||
} catch (error) {
|
||
if (!isActiveSession(activeSessionId)) return
|
||
messages.value.push(buildMessage('assistant', formatTestError(error, '测试结论保存失败,请稍后重试。')))
|
||
} finally {
|
||
if (isActiveSession(activeSessionId)) {
|
||
busyAction.value = ''
|
||
await nextTick()
|
||
scrollMessagesToBottom()
|
||
}
|
||
}
|
||
}
|
||
|
||
async function recognizeTemporaryFiles(files, activeSessionId) {
|
||
if (!files.length) return
|
||
recognitionError.value = ''
|
||
files.forEach((file) => {
|
||
const target = uploadedFiles.value.find((item) => item.id === file.id)
|
||
if (!target) return
|
||
target.status = 'recognizing'
|
||
target.statusText = '识别中'
|
||
target.error = ''
|
||
})
|
||
try {
|
||
const payload = await recognizeOcrFiles(files.map((file) => file.file), {
|
||
timeoutMs: 90000,
|
||
timeoutMessage: '单据 OCR 识别超时,请补充关键字段后再执行规则。'
|
||
})
|
||
if (!isActiveSession(activeSessionId)) return
|
||
const documents = normalizeOcrDocuments(payload)
|
||
recognizedDocuments.value = mergeRecognizedDocuments(recognizedDocuments.value, documents)
|
||
files.forEach((file, index) => {
|
||
const document = documents[index] || null
|
||
const target = uploadedFiles.value.find((item) => item.id === file.id)
|
||
if (!target) return
|
||
if (document && documentHasMeaningfulText(document)) {
|
||
target.status = 'recognized'
|
||
target.statusText = '已识别'
|
||
target.ocrDocument = document
|
||
target.error = ''
|
||
} else {
|
||
target.status = 'failed'
|
||
target.statusText = '识别不足'
|
||
target.ocrDocument = document
|
||
target.error = '未提取到足够文本'
|
||
}
|
||
})
|
||
const recognizedCount = files.filter((file) => {
|
||
const target = uploadedFiles.value.find((item) => item.id === file.id)
|
||
return target?.status === 'recognized'
|
||
}).length
|
||
messages.value.push(buildMessage(
|
||
'assistant',
|
||
recognizedCount
|
||
? `已完成 ${recognizedCount} 份临时单据识别。下面会展示 OCR 结构化字段和原文片段,请先核对这些信息;字段不足时可以直接在输入框补充。`
|
||
: '上传文件没有提取到足够字段。下面仍会展示 OCR 返回内容,方便判断是票据质量问题还是字段映射问题。请在输入框补充城市、金额、发票号等关键信息。',
|
||
{ recognitionDocuments: documents }
|
||
))
|
||
} catch (error) {
|
||
if (!isActiveSession(activeSessionId)) return
|
||
recognitionError.value = formatTestError(error, '单据识别失败,请补充关键字段后再执行规则。')
|
||
files.forEach((file) => {
|
||
const target = uploadedFiles.value.find((item) => item.id === file.id)
|
||
if (!target) return
|
||
target.status = 'failed'
|
||
target.statusText = '识别失败'
|
||
target.error = recognitionError.value
|
||
})
|
||
messages.value.push(buildMessage('assistant', recognitionError.value))
|
||
} finally {
|
||
if (isActiveSession(activeSessionId)) {
|
||
await nextTick()
|
||
scrollMessagesToBottom()
|
||
}
|
||
}
|
||
}
|
||
|
||
function buildMessage(role, text, extra = {}) {
|
||
return {
|
||
id: createId(),
|
||
role,
|
||
text,
|
||
time: formatTime(),
|
||
...extra
|
||
}
|
||
}
|
||
|
||
function buildResultFields(result) {
|
||
return buildResultFieldsModel(result, fields.value)
|
||
}
|
||
|
||
function buildRecognizedFieldRows(result) {
|
||
return buildRecognizedFieldRowsModel(result, fields.value)
|
||
}
|
||
|
||
function buildFieldPipelineSections(result) {
|
||
return buildFieldPipelineSectionsModel(result, fields.value)
|
||
}
|
||
|
||
function buildEvidenceItems(result) {
|
||
return buildEvidenceItemsModel(result, fields.value)
|
||
}
|
||
|
||
function buildTraceItems(result) {
|
||
return buildTraceItemsModel(result, fields.value)
|
||
}
|
||
|
||
function buildRecognitionStepDescription() {
|
||
if (!requiresAttachment.value) return '当前规则不需要附件,直接根据文字测试事实抽取字段。'
|
||
if (recognitionBusy.value) return '正在读取临时附件并提取 OCR 字段。'
|
||
if (hasRecognizedFiles.value) return `已识别 ${recognizedDocuments.value.length} 份临时单据。`
|
||
if (hasFailedOnlyFiles.value) return '识别不足,请在对话中补充字段。'
|
||
if (uploadedFiles.value.length) return '附件已加入本轮,点击发送后会和文字一起识别。'
|
||
return '需要上传测试附件,并填写测试意图后执行。'
|
||
}
|
||
|
||
function resolveRecognitionStepStatus() {
|
||
if (!requiresAttachment.value) return 'done'
|
||
if (recognitionBusy.value) return 'running'
|
||
if (hasRecognizedFiles.value) return 'done'
|
||
if (hasFailedOnlyFiles.value || recognitionError.value) return 'warning'
|
||
return 'pending'
|
||
}
|
||
|
||
function buildFieldStepDescription() {
|
||
if (activeSimulationResult.value?.recognized_fields?.length) {
|
||
return `已确认 ${activeSimulationResult.value.recognized_fields.length} 个字段。`
|
||
}
|
||
if (draft.value.trim()) return '将使用你输入的文字抽取测试字段。'
|
||
return '识别完成或补充字段后进入确认。'
|
||
}
|
||
|
||
function resolveFieldStepStatus() {
|
||
if (activeSimulationResult.value?.ready) return 'done'
|
||
if (activeSimulationResult.value?.missing_fields?.length) return 'warning'
|
||
if (hasRecognizedFiles.value || draft.value.trim()) return 'running'
|
||
return 'pending'
|
||
}
|
||
|
||
function buildWelcomeMessage() {
|
||
if (requiresAttachment.value) {
|
||
return '这条规则要求测试附件。请先上传临时票据并填写测试意图,点击发送后我会统一识别附件和文字,再交给规则执行器判断。'
|
||
}
|
||
return '这条规则不需要上传附件。你可以直接输入测试事实,我只会执行风险识别,不创建单据、不写入主工作台会话。'
|
||
}
|
||
|
||
function clearFileInput() {
|
||
if (fileInputRef.value) {
|
||
fileInputRef.value.value = ''
|
||
}
|
||
}
|
||
|
||
function scrollMessagesToBottom() {
|
||
const target = messageListRef.value
|
||
if (target) {
|
||
target.scrollTop = target.scrollHeight
|
||
}
|
||
}
|
||
|
||
function isActiveSession(activeSessionId) {
|
||
return props.open && activeSessionId && activeSessionId === sessionId.value
|
||
}
|
||
|
||
</script>
|
||
|
||
<style src="../../assets/styles/components/risk-rule-test-dialog.css"></style>
|