2026-05-24 21:44:17 +08:00
|
|
|
|
<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>
|
|
|
|
|
|
|
2026-05-26 09:15:14 +08:00
|
|
|
|
<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>
|
|
|
|
|
|
|
2026-05-24 21:44:17 +08:00
|
|
|
|
<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>
|
|
|
|
|
|
|
2026-05-26 09:15:14 +08:00
|
|
|
|
<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>
|
|
|
|
|
|
|
2026-05-24 21:44:17 +08:00
|
|
|
|
<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="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>
|
|
|
|
|
|
<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,
|
|
|
|
|
|
formatFileSize,
|
|
|
|
|
|
formatTestError,
|
|
|
|
|
|
formatTime
|
|
|
|
|
|
} from './riskRuleTestDialogUtils.js'
|
2026-05-26 09:15:14 +08:00
|
|
|
|
import {
|
|
|
|
|
|
buildDocumentBrief,
|
|
|
|
|
|
buildEvidenceItems as buildEvidenceItemsModel,
|
|
|
|
|
|
buildRecognizedFieldRows as buildRecognizedFieldRowsModel,
|
|
|
|
|
|
buildResultFields as buildResultFieldsModel,
|
|
|
|
|
|
formatDocumentMeta,
|
|
|
|
|
|
formatFieldLabel,
|
|
|
|
|
|
resolveFileStatusLabel,
|
|
|
|
|
|
trimDebugText
|
|
|
|
|
|
} from './riskRuleTestDialogDisplay.js'
|
2026-05-24 21:44:17 +08:00
|
|
|
|
|
|
|
|
|
|
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}`
|
|
|
|
|
|
: '最近一次仿真:未命中风险'
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
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
|
2026-05-26 09:15:14 +08:00
|
|
|
|
? `已完成 ${recognizedCount} 份临时单据识别。下面会展示 OCR 结构化字段和原文片段,请先核对这些信息;字段不足时可以直接在输入框补充。`
|
|
|
|
|
|
: '上传文件没有提取到足够字段。下面仍会展示 OCR 返回内容,方便判断是票据质量问题还是字段映射问题。请在输入框补充城市、金额、发票号等关键信息。',
|
|
|
|
|
|
{ recognitionDocuments: documents }
|
2026-05-24 21:44:17 +08:00
|
|
|
|
))
|
|
|
|
|
|
} 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) {
|
2026-05-26 09:15:14 +08:00
|
|
|
|
return buildResultFieldsModel(result, fields.value)
|
2026-05-24 21:44:17 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-26 09:15:14 +08:00
|
|
|
|
function buildRecognizedFieldRows(result) {
|
|
|
|
|
|
return buildRecognizedFieldRowsModel(result, fields.value)
|
2026-05-24 21:44:17 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-26 09:15:14 +08:00
|
|
|
|
function buildEvidenceItems(result) {
|
|
|
|
|
|
return buildEvidenceItemsModel(result, fields.value)
|
2026-05-24 21:44:17 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function toAttachmentPayload(file) {
|
|
|
|
|
|
const document = file.ocrDocument || {}
|
|
|
|
|
|
return {
|
|
|
|
|
|
id: file.id,
|
|
|
|
|
|
name: file.name,
|
|
|
|
|
|
size: file.size,
|
|
|
|
|
|
content_type: file.contentType,
|
|
|
|
|
|
note: file.error || '',
|
|
|
|
|
|
recognition_status: file.status,
|
|
|
|
|
|
ocr_text: document.text || '',
|
|
|
|
|
|
summary: document.summary || '',
|
|
|
|
|
|
document_type: document.document_type || '',
|
|
|
|
|
|
document_type_label: document.document_type_label || '',
|
|
|
|
|
|
scene_code: document.scene_code || '',
|
|
|
|
|
|
scene_label: document.scene_label || '',
|
|
|
|
|
|
avg_score: document.avg_score || 0,
|
|
|
|
|
|
document_fields: Array.isArray(document.document_fields) ? document.document_fields : []
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function normalizeOcrDocuments(payload) {
|
|
|
|
|
|
const documents = Array.isArray(payload?.documents) ? payload.documents : []
|
|
|
|
|
|
return documents.map((item) => ({
|
|
|
|
|
|
filename: String(item?.filename || '').trim(),
|
|
|
|
|
|
summary: String(item?.summary || '').trim(),
|
|
|
|
|
|
text: String(item?.text || '').trim(),
|
|
|
|
|
|
avg_score: Number(item?.avg_score || 0),
|
|
|
|
|
|
document_type: String(item?.document_type || 'other').trim() || 'other',
|
|
|
|
|
|
document_type_label: String(item?.document_type_label || '').trim(),
|
|
|
|
|
|
scene_code: String(item?.scene_code || 'other').trim() || 'other',
|
|
|
|
|
|
scene_label: String(item?.scene_label || '').trim(),
|
|
|
|
|
|
document_fields: Array.isArray(item?.document_fields)
|
|
|
|
|
|
? item.document_fields
|
|
|
|
|
|
.map((field) => ({
|
|
|
|
|
|
key: String(field?.key || '').trim(),
|
|
|
|
|
|
label: String(field?.label || '').trim(),
|
|
|
|
|
|
value: String(field?.value || '').trim()
|
|
|
|
|
|
}))
|
|
|
|
|
|
.filter((field) => field.key && field.label && field.value)
|
|
|
|
|
|
: [],
|
|
|
|
|
|
warnings: Array.isArray(item?.warnings) ? item.warnings : []
|
|
|
|
|
|
}))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function mergeRecognizedDocuments(current, incoming) {
|
|
|
|
|
|
const next = [...current]
|
|
|
|
|
|
incoming.forEach((document) => {
|
|
|
|
|
|
const index = next.findIndex((item) => item.filename === document.filename)
|
|
|
|
|
|
if (index >= 0) {
|
|
|
|
|
|
next.splice(index, 1, document)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
next.push(document)
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
return next
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function documentHasMeaningfulText(document) {
|
|
|
|
|
|
return Boolean(
|
|
|
|
|
|
String(document?.text || document?.summary || '').trim() ||
|
|
|
|
|
|
(Array.isArray(document?.document_fields) && document.document_fields.length)
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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>
|
|
|
|
|
|
|