feat: 增强规则资产管理与审计页面运行时调试

后端新增规则资产版本管理和规则文件 CRUD 接口,优化风险
规则生成模板执行和员工数据模型字段,知识库 RAG 增强本
地回退和文档提取能力,清理旧风险规则文件统一由生成引擎
管理,前端审计页面增加运行时调试面板和规则资产编辑交互,
补充单元测试覆盖。
This commit is contained in:
caoxiaozhu
2026-05-24 21:44:17 +08:00
parent 575f093c74
commit 50b1c3f9a9
113 changed files with 13896 additions and 5044 deletions

View File

@@ -0,0 +1,185 @@
<template>
<div class="expense-application-mask" @click.self="emit('close')">
<section class="expense-application-dialog" role="dialog" aria-modal="true" aria-labelledby="expense-application-title">
<header class="application-dialog-header">
<div>
<span class="application-dialog-eyebrow">费用申请</span>
<h2 id="expense-application-title">发起申请</h2>
</div>
<button class="dialog-icon-btn" type="button" aria-label="关闭" @click="emit('close')">
<i class="mdi mdi-close"></i>
</button>
</header>
<div class="application-dialog-body">
<section class="application-input-panel">
<label class="application-field-label" for="application-intent-input">申请意图</label>
<textarea
id="application-intent-input"
v-model="draft"
rows="5"
placeholder="例如申请下周去北京做客户现场验收差旅预算18000元"
></textarea>
<div class="application-example-row">
<button
v-for="example in APPLICATION_EXAMPLES"
:key="example"
class="application-example-chip"
type="button"
@click="applyExample(example)"
>
{{ example }}
</button>
</div>
<button class="primary-parse-btn" type="button" :disabled="parsing || !draft.trim()" @click="parseApplication">
<i :class="parsing ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-brain'"></i>
<span>{{ parsing ? '识别中' : '识别申请字段' }}</span>
</button>
<p v-if="error" class="application-error">{{ error }}</p>
</section>
<section class="application-ontology-panel">
<div class="ontology-panel-head">
<span>本体识别结果</span>
<strong>{{ confidenceLabel }}</strong>
</div>
<div v-if="!fields" class="ontology-empty-state">
<i class="mdi mdi-file-search-outline"></i>
<p>输入申请事项后系统会调用本体解析费用场景金额时间地点和附件要求</p>
</div>
<template v-else>
<div class="ontology-chip-row">
<span class="ontology-chip">scenario: {{ ontology?.scenario || '-' }}</span>
<span class="ontology-chip">intent: {{ ontology?.intent || '-' }}</span>
<span class="ontology-chip">{{ fields.documentTypeLabel }}</span>
</div>
<div class="application-field-grid">
<div class="application-field-card">
<span>费用场景</span>
<strong>{{ fields.expenseTypeLabel }}</strong>
</div>
<div class="application-field-card">
<span>申请金额</span>
<strong>{{ fields.amountDisplay }}</strong>
</div>
<div class="application-field-card">
<span>业务时间</span>
<strong>{{ fields.timeRange }}</strong>
</div>
<div class="application-field-card">
<span>业务地点</span>
<strong>{{ fields.location }}</strong>
</div>
<div class="application-field-card">
<span>申请人</span>
<strong>{{ fields.applicant }}</strong>
</div>
<div class="application-field-card">
<span>所属部门</span>
<strong>{{ fields.department }}</strong>
</div>
</div>
<div class="application-policy-strip" :class="fields.attachmentPolicy.level">
<i class="mdi mdi-paperclip-check"></i>
<div>
<strong>附件要求{{ fields.attachmentPolicy.label }}</strong>
<p>{{ fields.attachmentPolicy.description }}</p>
</div>
</div>
<div class="application-reason-block">
<span>申请事由</span>
<p>{{ fields.reason }}</p>
</div>
<div v-if="fields.missingSlots.length" class="application-missing-block">
<span>待补充字段</span>
<div>
<em v-for="slot in fields.missingSlots" :key="slot.key">{{ slot.label }}</em>
</div>
</div>
</template>
</section>
</div>
<footer class="application-dialog-footer">
<button class="secondary-btn" type="button" @click="emit('close')">取消</button>
<button class="confirm-btn" type="button" :disabled="!fields" @click="confirmApplication">
<i class="mdi mdi-check-circle-outline"></i>
<span>确认本体字段</span>
</button>
</footer>
</section>
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
import { useSystemState } from '../../composables/useSystemState.js'
import { fetchOntologyParse } from '../../services/ontology.js'
import {
APPLICATION_EXAMPLES,
buildApplicationFieldsFromOntology,
buildExpenseApplicationOntologyContext
} from '../../utils/expenseApplicationOntology.js'
const emit = defineEmits(['close', 'confirmed'])
const { currentUser } = useSystemState()
const draft = ref(APPLICATION_EXAMPLES[0])
const parsing = ref(false)
const error = ref('')
const ontology = ref(null)
const fields = ref(null)
const confidenceLabel = computed(() => {
if (!ontology.value) return '待识别'
return `${Math.round(Number(ontology.value.confidence || 0) * 100)}%`
})
function applyExample(example) {
draft.value = example
}
async function parseApplication() {
const query = draft.value.trim()
if (!query) return
parsing.value = true
error.value = ''
try {
const payload = await fetchOntologyParse({
query,
user_id: currentUser.value?.username || currentUser.value?.name || 'anonymous',
context_json: buildExpenseApplicationOntologyContext(currentUser.value || {})
})
ontology.value = payload
fields.value = buildApplicationFieldsFromOntology(payload, query, currentUser.value || {})
} catch (err) {
ontology.value = null
fields.value = null
error.value = err?.message || '申请字段识别失败,请稍后重试。'
} finally {
parsing.value = false
}
}
function confirmApplication() {
if (!fields.value) return
emit('confirmed', {
ontology: ontology.value,
fields: fields.value,
sourceText: draft.value.trim()
})
}
</script>
<style scoped src="../../assets/styles/components/expense-application-dialog.css"></style>

View File

@@ -46,18 +46,115 @@
/>
<div
v-else
class="risk-rule-flow-svg"
role="img"
aria-label="风险规则流程说明"
v-html="displaySvg"
></div>
class="risk-rule-flow-svg-viewport"
@mousedown="onDragStart"
@touchstart="onTouchStart"
@dblclick="resetZoom"
>
<div
class="risk-rule-flow-svg-canvas"
:style="transformStyle"
v-html="displaySvg"
></div>
<div class="diagram-zoom-controls" @mousedown.stop @touchstart.stop>
<button class="zoom-btn" @click="zoomIn" title="放大">
<i class="mdi mdi-plus"></i>
</button>
<button class="zoom-btn" @click="zoomOut" title="缩小">
<i class="mdi mdi-minus"></i>
</button>
<button class="zoom-btn" @click="resetZoom" title="重置">
<i class="mdi mdi-arrow-expand-all"></i>
</button>
</div>
</div>
</div>
</div>
</section>
</template>
<script setup>
import { computed } from 'vue'
import { computed, ref, onUnmounted } from 'vue'
const scale = ref(1)
const translateX = ref(0)
const translateY = ref(0)
const isDragging = ref(false)
const dragStart = { x: 0, y: 0 }
const transformStyle = computed(() => ({
transform: `translate(${translateX.value}px, ${translateY.value}px) scale(${scale.value})`,
transformOrigin: 'center center',
transition: isDragging.value ? 'none' : 'transform 0.15s ease-out'
}))
function onDragStart(e) {
if (e.button !== 0) return
isDragging.value = true
dragStart.x = e.clientX - translateX.value
dragStart.y = e.clientY - translateY.value
window.addEventListener('mousemove', onDragging)
window.addEventListener('mouseup', onDragEnd)
}
function onDragging(e) {
if (!isDragging.value) return
translateX.value = e.clientX - dragStart.x
translateY.value = e.clientY - dragStart.y
}
function onDragEnd() {
isDragging.value = false
window.removeEventListener('mousemove', onDragging)
window.removeEventListener('mouseup', onDragEnd)
}
function onTouchStart(e) {
if (e.touches.length !== 1) return
isDragging.value = true
const touch = e.touches[0]
dragStart.x = touch.clientX - translateX.value
dragStart.y = touch.clientY - translateY.value
window.addEventListener('touchmove', onTouchMove, { passive: false })
window.addEventListener('touchend', onTouchEnd)
}
function onTouchMove(e) {
if (!isDragging.value || e.touches.length !== 1) return
e.preventDefault()
const touch = e.touches[0]
translateX.value = touch.clientX - dragStart.x
translateY.value = touch.clientY - dragStart.y
}
function onTouchEnd() {
isDragging.value = false
window.removeEventListener('touchmove', onTouchMove)
window.removeEventListener('touchend', onTouchEnd)
}
function zoomIn() {
scale.value = Math.min(scale.value + 0.15, 3)
}
function zoomOut() {
scale.value = Math.max(scale.value - 0.15, 0.4)
}
function resetZoom() {
scale.value = 1
translateX.value = 0
translateY.value = 0
}
onUnmounted(() => {
window.removeEventListener('mousemove', onDragging)
window.removeEventListener('mouseup', onDragEnd)
window.removeEventListener('touchmove', onTouchMove)
window.removeEventListener('touchend', onTouchEnd)
})
const props = defineProps({
svg: { type: String, default: '' },
@@ -68,8 +165,7 @@ const props = defineProps({
severityLabel: { type: String, default: '中风险' }
})
const FONT =
"-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica Neue, 'PingFang SC', 'Microsoft YaHei', 'Microsoft JhengHei', 'SimHei', sans-serif"
const FONT = "Helvetica, Arial, sans-serif"
const TEXT = '#0d0d0d'
const MUTED = '#6e6e80'
const NEUTRAL_LINE = '#cbd5e1'
@@ -95,6 +191,16 @@ const PALETTES = {
}
}
const DRAWIO_PALETTES = {
neutral: { fill: '#ffffff', stroke: '#e2e8f0' },
blue: { fill: '#ffffff', stroke: '#e2e8f0' },
yellow: { fill: '#ffffff', stroke: '#e2e8f0' },
green: { fill: '#ffffff', stroke: '#e2e8f0' },
low: { fill: '#eff6ff', stroke: '#bfdbfe' },
medium: { fill: '#fff7ed', stroke: '#fed7aa' },
high: { fill: '#fef2f2', stroke: '#fecaca' }
}
function normalizeText(value, fallback = '') {
return String(value || fallback || '').trim()
}
@@ -156,15 +262,12 @@ function textLines(lines, x, y, anchor = 'middle', color = MUTED, fontSize = 13)
.join('')
}
function node(title, body, x, y, width, height, currentPalette = null) {
const border = currentPalette?.border || NEUTRAL_BORDER
const stripe = currentPalette?.accent || NEUTRAL_LINE
const surface = currentPalette?.surface || '#ffffff'
return `<g>
<rect x="${x}" y="${y}" width="${width}" height="${height}" rx="7" ry="7" fill="${surface}" stroke="${border}" stroke-width="1.2"/>
<rect x="${x}" y="${y}" width="3.5" height="${height}" rx="1.75" ry="1.75" fill="${stripe}"/>
<text x="${x + width / 2}" y="${y + 24}" text-anchor="middle" fill="${TEXT}" font-family="${FONT}" font-size="13" font-weight="600">${escapeSvg(title)}</text>
${textLines(wrapText(body, width <= 126 ? 10 : 11, 1), x + width / 2, y + 43, 'middle', MUTED, 11)}
function node(title, body, x, y, width, height, type = 'blue') {
const palette = DRAWIO_PALETTES[type] || DRAWIO_PALETTES.blue
return `<g class="drawio-node">
<rect x="${x}" y="${y}" width="${width}" height="${height}" rx="3" ry="3" fill="${palette.fill}" stroke="${palette.stroke}" stroke-width="1.2" filter="url(#shadow)"/>
<text x="${x + width / 2}" y="${y + 24}" text-anchor="middle" fill="#0f172a" font-family="${FONT}" font-size="13" font-weight="600">${escapeSvg(title)}</text>
${textLines(wrapText(body, width <= 126 ? 10 : 11, 1), x + width / 2, y + 43, 'middle', '#475569', 11)}
</g>`
}
@@ -172,18 +275,19 @@ function diamond(title, body, x, y, width, height) {
const cx = x + width / 2
const cy = y + height / 2
const points = `${cx},${y} ${x + width},${cy} ${cx},${y + height} ${x},${cy}`
return `<g>
<polygon points="${points}" fill="#ffffff" stroke="${NEUTRAL_BORDER}" stroke-width="1.25"/>
<text x="${cx}" y="${cy - 10}" text-anchor="middle" fill="${TEXT}" font-family="${FONT}" font-size="12.5" font-weight="600">${escapeSvg(title)}</text>
${textLines(wrapText(body, 8, 2), cx, cy + 11, 'middle', MUTED, 10.2)}
const palette = DRAWIO_PALETTES.yellow
return `<g class="drawio-node">
<polygon points="${points}" fill="${palette.fill}" stroke="${palette.stroke}" stroke-width="1.2" filter="url(#shadow)"/>
<text x="${cx}" y="${cy - 8}" text-anchor="middle" fill="#0f172a" font-family="${FONT}" font-size="12.5" font-weight="600">${escapeSvg(title)}</text>
${textLines(wrapText(body, 8, 2), cx, cy + 12, 'middle', '#475569', 10.2)}
</g>`
}
function note(body) {
return `<g>
<rect x="214" y="218" width="290" height="36" rx="7" ry="7" fill="#ffffff" stroke="${NEUTRAL_BORDER}" stroke-width="1" stroke-dasharray="4,3"/>
<text x="226" y="240" fill="${MUTED}" font-family="${FONT}" font-size="10" font-weight="500">BASIS</text>
${textLines(wrapText(body, 22, 1), 268, 240, 'start', TEXT, 10.2)}
return `<g class="drawio-node note">
<rect x="214" y="218" width="290" height="36" rx="3" ry="3" fill="#ffffff" stroke="#cbd5e1" stroke-width="1.2" stroke-dasharray="3,3" filter="url(#shadow)"/>
<text x="226" y="240" fill="#64748b" font-family="${FONT}" font-size="10.5" font-weight="700">BASIS</text>
${textLines(wrapText(body, 22, 1), 272, 240, 'start', '#334155', 10.5)}
</g>`
}
@@ -262,29 +366,47 @@ const displaySvg = computed(() => {
}
const flow = flowModel.value
const currentPalette = palette.value
const severity = props.severity
return `<svg xmlns="http://www.w3.org/2000/svg" width="760" height="280" viewBox="0 0 760 280" data-risk-flow-style="review-node-only" role="img" aria-label="风险规则流程说明">
<defs>
<marker id="arrow-neutral" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="${NEUTRAL_LINE}"/>
<pattern id="grid" width="16" height="16" patternUnits="userSpaceOnUse">
<path d="M 16 0 L 0 0 0 16" fill="none" stroke="#e8ecef" stroke-width="0.75"/>
</pattern>
<marker id="arrow" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
<path d="M 0 0 L 8 3 L 0 6 Z" fill="#666666"/>
</marker>
<filter id="shadow" x="-10%" y="-10%" width="120%" height="120%">
<feDropShadow dx="1" dy="2" stdDeviation="1.5" flood-color="#000000" flood-opacity="0.08" />
</filter>
</defs>
<rect width="760" height="280" fill="#ffffff"/>
<rect x="18" y="18" width="724" height="244" rx="8" ry="8" fill="none" stroke="${NEUTRAL_BORDER}" stroke-width="1" stroke-dasharray="4,3"/>
<text x="34" y="43" fill="${MUTED}" font-family="${FONT}" font-size="11" font-weight="500">RULE FLOW</text>
${node('业务输入', flow.start, 48, 118, 124, 60)}
${node('字段取数', '读取字段证据', 214, 118, 132, 60)}
<rect width="760" height="280" fill="url(#grid)"/>
<rect x="0.5" y="0.5" width="759.5" height="279.5" rx="6" fill="none" stroke="#cbd5e1" stroke-width="1"/>
<text x="34" y="43" fill="#94a3b8" font-family="${FONT}" font-size="10.5" font-weight="700" letter-spacing="0.05em">RULE FLOW CANVAS</text>
${node('业务输入', flow.start, 48, 118, 124, 60, 'neutral')}
${node('字段取数', '读取字段证据', 214, 118, 132, 60, 'blue')}
${diamond('判断依据', flow.decision, 392, 92, 112, 112)}
${node('继续流转', flow.pass, 562, 74, 126, 60)}
${node('进入复核', flow.fail, 562, 190, 126, 62, currentPalette)}
${node('继续流转', flow.pass, 562, 74, 126, 60, 'green')}
${node('进入复核', flow.fail, 562, 190, 126, 62, severity)}
${note(flow.basis)}
<line x1="172" y1="148" x2="214" y2="148" stroke="${NEUTRAL_LINE}" stroke-width="1.45" marker-end="url(#arrow-neutral)"/>
<line x1="346" y1="148" x2="392" y2="148" stroke="${NEUTRAL_LINE}" stroke-width="1.45" marker-end="url(#arrow-neutral)"/>
<path d="M 504 127 L 532 127 L 532 104 L 562 104" fill="none" stroke="${NEUTRAL_LINE}" stroke-width="1.35" marker-end="url(#arrow-neutral)"/>
<text x="534" y="119" text-anchor="middle" fill="${MUTED}" font-family="${FONT}" font-size="10.5" font-weight="400">否</text>
<path d="M 504 169 L 532 169 L 532 221 L 562 221" fill="none" stroke="${NEUTRAL_LINE}" stroke-width="1.8" marker-end="url(#arrow-neutral)"/>
<text x="534" y="195" text-anchor="middle" fill="${MUTED}" font-family="${FONT}" font-size="10.5" font-weight="600">是</text>
<line x1="172" y1="148" x2="214" y2="148" stroke="#666666" stroke-width="1.5" marker-end="url(#arrow)"/>
<line x1="346" y1="148" x2="392" y2="148" stroke="#666666" stroke-width="1.5" marker-end="url(#arrow)"/>
<path d="M 504 127 L 532 127 L 532 104 L 562 104" fill="none" stroke="#666666" stroke-width="1.5" marker-end="url(#arrow)"/>
<g>
<rect x="521" y="108" width="22" height="15" fill="#ffffff" stroke="#cbd5e1" stroke-width="1" rx="2"/>
<text x="532" y="120" text-anchor="middle" fill="#475569" font-family="${FONT}" font-size="10" font-weight="bold">否</text>
</g>
<path d="M 504 169 L 532 169 L 532 221 L 562 221" fill="none" stroke="#666666" stroke-width="1.5" marker-end="url(#arrow)"/>
<g>
<rect x="521" y="187" width="22" height="15" fill="#ffffff" stroke="#cbd5e1" stroke-width="1" rx="2"/>
<text x="532" y="199" text-anchor="middle" fill="#475569" font-family="${FONT}" font-size="10" font-weight="bold">是</text>
</g>
</svg>`
})
</script>
@@ -294,10 +416,9 @@ const displaySvg = computed(() => {
width: 100%;
min-height: 0;
overflow: hidden;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #ffffff;
padding: 14px;
border: none;
background: transparent;
padding: 0;
cursor: default;
}
@@ -442,30 +563,84 @@ const displaySvg = computed(() => {
justify-content: flex-start;
}
.risk-rule-flow-image,
.risk-rule-flow-svg {
.risk-rule-flow-svg-viewport {
position: relative;
width: 100%;
height: 280px;
overflow: hidden;
border-radius: 6px;
background: #ffffff;
border: 1px solid #e2e8f0;
display: flex;
align-items: center;
justify-content: center;
user-select: none;
cursor: grab;
}
.risk-rule-flow-svg-viewport:active {
cursor: grabbing;
}
.risk-rule-flow-svg-canvas {
width: 760px;
height: 280px;
flex-shrink: 0;
}
.risk-rule-flow-svg-canvas :deep(svg) {
width: 100%;
height: 100%;
display: block;
}
.diagram-zoom-controls {
position: absolute;
bottom: 12px;
right: 12px;
display: flex;
flex-direction: column;
gap: 6px;
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(8px);
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 4px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
z-index: 10;
}
.zoom-btn {
width: 28px;
height: 28px;
display: grid;
place-items: center;
border: none;
border-radius: 6px;
background: transparent;
color: #475569;
cursor: pointer;
transition: all 0.2s ease;
}
.zoom-btn:hover {
background: #f1f5f9;
color: #0f172a;
}
.zoom-btn i {
font-size: 16px;
}
.risk-rule-flow-image {
width: min(760px, 100%);
display: block;
pointer-events: none;
user-select: none;
}
.risk-rule-flow-image {
height: auto;
object-fit: contain;
}
.risk-rule-flow-svg :deep(svg) {
width: 100%;
height: auto;
display: block;
}
.risk-rule-flow-svg :deep(*) {
pointer-events: none !important;
user-select: none;
}
@media (max-width: 980px) {
.risk-rule-flow-content {
grid-template-columns: 1fr;

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

View File

@@ -0,0 +1,28 @@
export function createId() {
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
return crypto.randomUUID()
}
return `${Date.now()}-${Math.random().toString(16).slice(2)}`
}
export function formatFileSize(size) {
const value = Number(size || 0)
if (value < 1024) return `${value}B`
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)}KB`
return `${(value / 1024 / 1024).toFixed(1)}MB`
}
export function formatTestError(error, fallback) {
const message = String(error?.message || '').trim()
if (/not\s*found/i.test(message)) {
return '测试接口暂未加载或规则详情已失效,请刷新规则详情后再试。'
}
return message || fallback
}
export function formatTime() {
return new Intl.DateTimeFormat('zh-CN', {
hour: '2-digit',
minute: '2-digit'
}).format(new Date())
}