Files
X-Financial/web/src/components/shared/RiskRuleTestDialog.vue

782 lines
30 KiB
Vue
Raw Normal View History

<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>