feat: 增强规则资产管理与审计页面运行时调试
后端新增规则资产版本管理和规则文件 CRUD 接口,优化风险 规则生成模板执行和员工数据模型字段,知识库 RAG 增强本 地回退和文档提取能力,清理旧风险规则文件统一由生成引擎 管理,前端审计页面增加运行时调试面板和规则资产编辑交互, 补充单元测试覆盖。
This commit is contained in:
792
web/src/components/shared/RiskRuleTestDialog.vue
Normal file
792
web/src/components/shared/RiskRuleTestDialog.vue
Normal file
@@ -0,0 +1,792 @@
|
||||
<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.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="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'
|
||||
|
||||
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
|
||||
? `已完成 ${recognizedCount} 份临时单据识别。请核对右侧识别字段,字段不足时可以直接在输入框补充。`
|
||||
: '上传文件没有提取到足够字段,暂不能直接执行规则。请在输入框补充票据城市、金额、发票号等关键信息。'
|
||||
))
|
||||
} 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) {
|
||||
const values = result?.field_values && typeof result.field_values === 'object'
|
||||
? result.field_values
|
||||
: {}
|
||||
return Object.entries(values).slice(0, 8).map(([key, value]) => ({
|
||||
key,
|
||||
label: formatFieldLabel(fields.value.find((field) => field.key === key) || { key }),
|
||||
value: Array.isArray(value) ? value.join('、') : String(value ?? '-')
|
||||
}))
|
||||
}
|
||||
|
||||
function buildEvidenceItems(result) {
|
||||
const evidence = result?.evidence && typeof result.evidence === 'object'
|
||||
? result.evidence
|
||||
: {}
|
||||
const items = []
|
||||
if (Array.isArray(evidence.failed_conditions)) {
|
||||
evidence.failed_conditions.slice(0, 3).forEach((condition) => {
|
||||
const left = Array.isArray(condition.left_values) ? condition.left_values.join('、') : '-'
|
||||
const right = Array.isArray(condition.right_values) ? condition.right_values.join('、') : '-'
|
||||
items.push(`${formatFieldName(condition.left)}:${left};${formatFieldName(condition.right)}:${right}`)
|
||||
})
|
||||
}
|
||||
if (Array.isArray(evidence.missing_fields)) {
|
||||
evidence.missing_fields.slice(0, 5).forEach((field) => {
|
||||
items.push(`${formatFieldName(field)} 缺失`)
|
||||
})
|
||||
}
|
||||
if (Array.isArray(evidence.keyword_hits)) {
|
||||
items.push(`命中关键词:${evidence.keyword_hits.join('、')}`)
|
||||
}
|
||||
if (evidence.condition_summary) {
|
||||
items.push(String(evidence.condition_summary))
|
||||
}
|
||||
return [...new Set(items)].slice(0, 5)
|
||||
}
|
||||
|
||||
function formatFieldLabel(field) {
|
||||
const key = String(field?.key || '').trim()
|
||||
const label = String(field?.display || field?.label || '').trim()
|
||||
if (!key) return label || '-'
|
||||
if (!label || label === key) return key
|
||||
return label.includes(`[${key}]`) ? label : `${label}[${key}]`
|
||||
}
|
||||
|
||||
function formatFieldName(key) {
|
||||
return formatFieldLabel(fields.value.find((field) => field.key === key) || { key })
|
||||
}
|
||||
|
||||
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 buildDocumentBrief(document) {
|
||||
const fields = Array.isArray(document?.document_fields) ? document.document_fields : []
|
||||
if (fields.length) {
|
||||
return fields.slice(0, 4).map((field) => `${field.label}:${field.value}`).join(';')
|
||||
}
|
||||
return String(document?.summary || document?.text || '未提取到结构化字段').slice(0, 120)
|
||||
}
|
||||
|
||||
function resolveFileStatusLabel(file) {
|
||||
return file.statusText || {
|
||||
pending: '待发送',
|
||||
recognizing: '识别中',
|
||||
recognized: '已识别',
|
||||
failed: '识别失败'
|
||||
}[file.status] || '待识别'
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user