feat: 增强风险规则生成引擎与预算中心页面
后端拆分风险规则生成为解释器、语义分析、本体对齐等子模块, 优化模板执行和流程图生成,完善员工种子数据和导入逻辑,增强 报销单权限策略和草稿持久化,前端新增预算中心视图和趋势图 组件,重构审计页面和风险规则测试对话框交互,完善文档中心 和报销创建页面细节,补充单元测试覆盖。
This commit is contained in:
142
web/src/components/charts/BudgetTrendChart.vue
Normal file
142
web/src/components/charts/BudgetTrendChart.vue
Normal file
@@ -0,0 +1,142 @@
|
||||
<template>
|
||||
<div class="budget-trend-chart">
|
||||
<Line :data="chartData" :options="chartOptions" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { Line } from 'vue-chartjs'
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
Filler,
|
||||
Legend,
|
||||
LinearScale,
|
||||
LineElement,
|
||||
PointElement,
|
||||
Tooltip
|
||||
} from 'chart.js'
|
||||
import { useAnimationProgress } from '../../composables/useAnimationProgress.js'
|
||||
|
||||
ChartJS.register(CategoryScale, LinearScale, LineElement, PointElement, Filler, Tooltip, Legend)
|
||||
|
||||
const props = defineProps({
|
||||
labels: { type: Array, required: true },
|
||||
budget: { type: Array, required: true },
|
||||
used: { type: Array, required: true }
|
||||
})
|
||||
|
||||
const progress = useAnimationProgress([
|
||||
() => props.labels,
|
||||
() => props.budget,
|
||||
() => props.used
|
||||
], 1000)
|
||||
|
||||
const scaleSeries = (series) =>
|
||||
series.map((value) => Number((Number(value || 0) * progress.value).toFixed(2)))
|
||||
|
||||
const chartData = computed(() => ({
|
||||
labels: props.labels,
|
||||
datasets: [
|
||||
{
|
||||
label: '预算',
|
||||
data: scaleSeries(props.budget),
|
||||
borderColor: '#2f7fd7',
|
||||
backgroundColor: 'rgba(47, 127, 215, 0.08)',
|
||||
borderDash: [7, 5],
|
||||
borderWidth: 2,
|
||||
pointRadius: 3,
|
||||
pointHoverRadius: 5,
|
||||
pointBackgroundColor: '#ffffff',
|
||||
pointBorderColor: '#2f7fd7',
|
||||
pointBorderWidth: 2,
|
||||
tension: 0.34,
|
||||
fill: false
|
||||
},
|
||||
{
|
||||
label: '已发生',
|
||||
data: scaleSeries(props.used),
|
||||
borderColor: '#13a66b',
|
||||
backgroundColor: 'rgba(19, 166, 107, 0.12)',
|
||||
borderWidth: 2,
|
||||
pointRadius: 3,
|
||||
pointHoverRadius: 5,
|
||||
pointBackgroundColor: '#ffffff',
|
||||
pointBorderColor: '#13a66b',
|
||||
pointBorderWidth: 2,
|
||||
tension: 0.34,
|
||||
fill: false
|
||||
}
|
||||
]
|
||||
}))
|
||||
|
||||
const chartOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
mode: 'index',
|
||||
intersect: false
|
||||
},
|
||||
animation: {
|
||||
duration: 760,
|
||||
easing: 'easeOutQuart'
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: '#ffffff',
|
||||
borderColor: '#e2e8f0',
|
||||
borderWidth: 1,
|
||||
bodyColor: '#475569',
|
||||
titleColor: '#0f172a',
|
||||
padding: 12,
|
||||
displayColors: true,
|
||||
callbacks: {
|
||||
label(context) {
|
||||
const value = Number(context.parsed.y || 0)
|
||||
return `${context.dataset.label}: ${value.toLocaleString('zh-CN')} 元`
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: { display: false },
|
||||
ticks: {
|
||||
color: '#64748b',
|
||||
font: { size: 12 }
|
||||
},
|
||||
border: { display: false }
|
||||
},
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
max: 12000000,
|
||||
grid: {
|
||||
color: '#edf2f7',
|
||||
drawTicks: false
|
||||
},
|
||||
border: { display: false },
|
||||
ticks: {
|
||||
color: '#64748b',
|
||||
font: { size: 12 },
|
||||
stepSize: 3000000,
|
||||
callback(value) {
|
||||
if (value === 0) return '0'
|
||||
return `${Number(value) / 10000}万`
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.budget-trend-chart {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 220px;
|
||||
}
|
||||
</style>
|
||||
@@ -33,6 +33,7 @@
|
||||
>
|
||||
<span class="nav-icon" v-html="item.icon"></span>
|
||||
<span class="nav-label">{{ item.displayLabel }}</span>
|
||||
<span v-if="item.hasNewMessage" class="nav-unread-dot" aria-hidden="true"></span>
|
||||
<span v-if="item.badge" class="nav-badge">{{ item.badge }}</span>
|
||||
</button>
|
||||
</nav>
|
||||
@@ -83,7 +84,7 @@
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||||
|
||||
import { useApprovalInbox } from '../../composables/useApprovalInbox.js'
|
||||
import { useDocumentCenterInbox } from '../../composables/useDocumentCenterInbox.js'
|
||||
|
||||
const props = defineProps({
|
||||
navItems: { type: Array, required: true },
|
||||
@@ -113,19 +114,17 @@ const props = defineProps({
|
||||
const emit = defineEmits(['navigate', 'openChat', 'logout', 'toggle-collapse'])
|
||||
|
||||
const {
|
||||
badgeLabel: approvalBadgeLabel,
|
||||
refreshApprovalInbox,
|
||||
startApprovalInboxPolling,
|
||||
stopApprovalInboxPolling
|
||||
} = useApprovalInbox()
|
||||
hasUnread: documentInboxHasUnread,
|
||||
refreshDocumentInbox,
|
||||
startDocumentInboxPolling,
|
||||
stopDocumentInboxPolling
|
||||
} = useDocumentCenterInbox()
|
||||
|
||||
const sidebarMeta = {
|
||||
overview: { label: '财务总览' },
|
||||
workbench: { label: '个人工作台' },
|
||||
documents: { label: '单据中心' },
|
||||
requests: { label: '报销中心' },
|
||||
approval: { label: '审批中心' },
|
||||
archive: { label: '归档中心' },
|
||||
budget: { label: '预算中心' },
|
||||
policies: { label: '知识管理' },
|
||||
audit: { label: '任务规则中心' },
|
||||
logs: { label: '日志管理' },
|
||||
@@ -137,13 +136,14 @@ const decoratedNavItems = computed(() =>
|
||||
props.navItems.map((item) => ({
|
||||
...item,
|
||||
displayLabel: sidebarMeta[item.id]?.label ?? item.label,
|
||||
badge: item.id === 'approval' ? approvalBadgeLabel.value : sidebarMeta[item.id]?.badge
|
||||
hasNewMessage: item.id === 'documents' ? documentInboxHasUnread.value : false,
|
||||
badge: sidebarMeta[item.id]?.badge
|
||||
}))
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
void refreshApprovalInbox()
|
||||
startApprovalInboxPolling()
|
||||
void refreshDocumentInbox()
|
||||
startDocumentInboxPolling()
|
||||
})
|
||||
|
||||
|
||||
@@ -238,7 +238,7 @@ watch(
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopApprovalInboxPolling()
|
||||
stopDocumentInboxPolling()
|
||||
closeCollapsedUserMenuNow()
|
||||
})
|
||||
</script>
|
||||
@@ -463,6 +463,16 @@ onBeforeUnmount(() => {
|
||||
opacity var(--rail-fade-duration) var(--rail-motion-ease);
|
||||
}
|
||||
|
||||
.nav-unread-dot {
|
||||
flex: 0 0 auto;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border: 2px solid #fff;
|
||||
border-radius: 999px;
|
||||
background: #ef4444;
|
||||
box-shadow: 0 6px 14px rgba(239, 68, 68, 0.26);
|
||||
}
|
||||
|
||||
.rail-user {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
@@ -668,6 +678,14 @@ onBeforeUnmount(() => {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.rail-collapsed .nav-unread-dot {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 11px;
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
}
|
||||
|
||||
.rail-collapsed {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
@@ -47,26 +47,11 @@
|
||||
<div
|
||||
v-else
|
||||
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>
|
||||
@@ -74,87 +59,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
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)
|
||||
})
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
svg: { type: String, default: '' },
|
||||
@@ -188,6 +93,12 @@ const PALETTES = {
|
||||
accentDark: '#b91c1c',
|
||||
border: '#fecaca',
|
||||
surface: '#fef2f2'
|
||||
},
|
||||
critical: {
|
||||
accent: '#991b1b',
|
||||
accentDark: '#7f1d1d',
|
||||
border: '#fca5a5',
|
||||
surface: '#fff1f2'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,7 +109,8 @@ const DRAWIO_PALETTES = {
|
||||
green: { fill: '#ffffff', stroke: '#e2e8f0' },
|
||||
low: { fill: '#eff6ff', stroke: '#bfdbfe' },
|
||||
medium: { fill: '#fff7ed', stroke: '#fed7aa' },
|
||||
high: { fill: '#fef2f2', stroke: '#fecaca' }
|
||||
high: { fill: '#fef2f2', stroke: '#fecaca' },
|
||||
critical: { fill: '#fff1f2', stroke: '#fca5a5' }
|
||||
}
|
||||
|
||||
function normalizeText(value, fallback = '') {
|
||||
@@ -222,7 +134,11 @@ function isSafeSvg(value) {
|
||||
}
|
||||
|
||||
function isCurrentDisplaySvg(value) {
|
||||
return isSafeSvg(value) && value.includes('data-risk-flow-style="review-node-only"')
|
||||
return (
|
||||
isSafeSvg(value) &&
|
||||
value.includes('data-risk-flow-style="review-node-only"') &&
|
||||
value.includes('data-risk-flow-detail="logic-v2"')
|
||||
)
|
||||
}
|
||||
|
||||
function resolvePalette(severity) {
|
||||
@@ -262,10 +178,15 @@ function textLines(lines, x, y, anchor = 'middle', color = MUTED, fontSize = 13)
|
||||
.join('')
|
||||
}
|
||||
|
||||
function truncateText(value, length) {
|
||||
const text = normalizeText(value)
|
||||
return text.length <= length ? text : `${text.slice(0, Math.max(0, length - 1))}…`
|
||||
}
|
||||
|
||||
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)"/>
|
||||
<rect x="${x}" y="${y}" width="${width}" height="${height}" rx="6" ry="6" fill="${palette.fill}" stroke="${palette.stroke}" stroke-width="1.2"/>
|
||||
<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>`
|
||||
@@ -277,7 +198,7 @@ function diamond(title, body, x, y, width, height) {
|
||||
const points = `${cx},${y} ${x + width},${cy} ${cx},${y + height} ${x},${cy}`
|
||||
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)"/>
|
||||
<polygon points="${points}" fill="${palette.fill}" stroke="${palette.stroke}" stroke-width="1.2"/>
|
||||
<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>`
|
||||
@@ -291,6 +212,24 @@ function note(body) {
|
||||
</g>`
|
||||
}
|
||||
|
||||
function panel(title, rows, x, y, width, height) {
|
||||
const visibleRows = (Array.isArray(rows) ? rows : [])
|
||||
.filter(Boolean)
|
||||
.slice(0, 4)
|
||||
.map((row) => truncateText(row, 34))
|
||||
const renderedRows = visibleRows.length ? visibleRows : ['读取规则字段并归一化为判断事实']
|
||||
return `<g class="drawio-node panel-node">
|
||||
<rect x="${x}" y="${y}" width="${width}" height="${height}" rx="6" ry="6" fill="#ffffff" stroke="#e2e8f0" stroke-width="1.2"/>
|
||||
<text x="${x + 16}" y="${y + 26}" fill="#0f172a" font-family="${FONT}" font-size="13" font-weight="700">${escapeSvg(title)}</text>
|
||||
${renderedRows
|
||||
.map(
|
||||
(row, index) =>
|
||||
`<text x="${x + 16}" y="${y + 48 + index * 18}" fill="#334155" font-family="${FONT}" font-size="11" font-weight="400">${escapeSvg(row)}</text>`
|
||||
)
|
||||
.join('')}
|
||||
</g>`
|
||||
}
|
||||
|
||||
const palette = computed(() => resolvePalette(props.severity))
|
||||
|
||||
const accentStyle = computed(() => ({
|
||||
@@ -323,6 +262,11 @@ const flowModel = computed(() => {
|
||||
evidence: normalizeText(props.flow?.evidence, '读取规则字段'),
|
||||
decision: normalizeText(props.flow?.decision, '判断是否命中风险'),
|
||||
basis: normalizeText(props.flow?.basis || props.flow?.decision, '根据规则字段判断是否命中风险'),
|
||||
facts: Array.isArray(props.flow?.facts) ? props.flow.facts.map(normalizeText).filter(Boolean) : [],
|
||||
conditions: Array.isArray(props.flow?.conditions)
|
||||
? props.flow.conditions.map(normalizeText).filter(Boolean)
|
||||
: [],
|
||||
hitLogic: normalizeText(props.flow?.hitLogic || props.flow?.formula),
|
||||
pass: normalizeText(props.flow?.pass, '未命中风险,继续流转'),
|
||||
fail: normalizeText(props.flow?.fail, `命中${severityLabel},进入人工复核`)
|
||||
}
|
||||
@@ -336,11 +280,12 @@ const flowSteps = computed(() => [
|
||||
{
|
||||
title: '字段取数',
|
||||
text: `读取规则所需字段,并将字段证据送入判断节点。字段:${fieldSummary.value}`,
|
||||
fields: fieldDisplays.value
|
||||
fields: flowModel.value.facts.length ? flowModel.value.facts : fieldDisplays.value
|
||||
},
|
||||
{
|
||||
title: '判断依据',
|
||||
text: flowModel.value.basis || flowModel.value.decision
|
||||
text: flowModel.value.basis || flowModel.value.decision,
|
||||
fields: flowModel.value.conditions
|
||||
}
|
||||
])
|
||||
|
||||
@@ -367,46 +312,39 @@ const displaySvg = computed(() => {
|
||||
|
||||
const flow = flowModel.value
|
||||
const severity = props.severity
|
||||
const facts = flow.facts.length ? flow.facts : fieldDisplays.value.slice(0, 4)
|
||||
const conditions = flow.conditions.length ? flow.conditions : [flow.basis || flow.decision]
|
||||
const hitLogic = flow.hitLogic || flow.basis || flow.decision
|
||||
|
||||
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="风险规则流程说明">
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="860" height="360" viewBox="0 0 860 360" data-risk-flow-style="review-node-only" data-risk-flow-detail="logic-v2" role="img" aria-label="风险规则流程说明">
|
||||
<defs>
|
||||
<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"/>
|
||||
<path d="M 0 0 L 8 3 L 0 6 Z" fill="#cbd5e1"/>
|
||||
</marker>
|
||||
<marker id="arrow-risk" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
|
||||
<path d="M 0 0 L 8 3 L 0 6 Z" fill="${palette.value.accent}"/>
|
||||
</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 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"/>
|
||||
<rect width="860" height="360" fill="#ffffff"/>
|
||||
<rect x="18" y="18" width="824" height="324" rx="8" fill="none" stroke="#e2e8f0" stroke-width="1" stroke-dasharray="4,3"/>
|
||||
|
||||
<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>
|
||||
<text x="34" y="43" fill="#94a3b8" font-family="${FONT}" font-size="10.5" font-weight="700">RULE FLOW</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, 'green')}
|
||||
${node('进入复核', flow.fail, 562, 190, 126, 62, severity)}
|
||||
${note(flow.basis)}
|
||||
${node('业务输入', flow.start, 38, 142, 120, 62, 'neutral')}
|
||||
${panel('字段事实', facts, 196, 64, 240, 128)}
|
||||
${panel('判断条件', conditions, 196, 216, 382, 104)}
|
||||
${diamond('命中逻辑', hitLogic, 494, 80, 122, 122)}
|
||||
${node('继续流转', flow.pass, 688, 76, 122, 60, 'neutral')}
|
||||
${node('进入复核', flow.fail, 688, 226, 122, 68, severity)}
|
||||
|
||||
<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>
|
||||
<path d="M 158 173 H 176 V 128 H 196" fill="none" stroke="#cbd5e1" stroke-width="1.45" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrow)"/>
|
||||
<line x1="316" y1="192" x2="316" y2="216" stroke="#cbd5e1" stroke-width="1.45" stroke-linecap="round" marker-end="url(#arrow)"/>
|
||||
<path d="M 436 128 H 466 V 141 H 494" fill="none" stroke="#cbd5e1" stroke-width="1.45" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrow)"/>
|
||||
<line x1="555" y1="216" x2="555" y2="202" stroke="#cbd5e1" stroke-width="1.35" stroke-linecap="round" marker-end="url(#arrow)"/>
|
||||
<path d="M 616 125 H 648 V 106 H 688" fill="none" stroke="#cbd5e1" stroke-width="1.35" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrow)"/>
|
||||
<text x="651" y="119" text-anchor="middle" fill="#64748b" font-family="${FONT}" font-size="10.5" font-weight="500">否</text>
|
||||
<path d="M 616 166 H 648 V 260 H 688" fill="none" stroke="${palette.value.accent}" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrow-risk)"/>
|
||||
<text x="651" y="214" text-anchor="middle" fill="${palette.value.accentDark}" font-family="${FONT}" font-size="10.5" font-weight="700">是</text>
|
||||
</svg>`
|
||||
})
|
||||
</script>
|
||||
@@ -566,7 +504,7 @@ const displaySvg = computed(() => {
|
||||
.risk-rule-flow-svg-viewport {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 280px;
|
||||
height: 360px;
|
||||
overflow: hidden;
|
||||
border-radius: 6px;
|
||||
background: #ffffff;
|
||||
@@ -575,16 +513,11 @@ const displaySvg = computed(() => {
|
||||
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;
|
||||
width: 860px;
|
||||
height: 360px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -633,7 +566,7 @@ const displaySvg = computed(() => {
|
||||
}
|
||||
|
||||
.risk-rule-flow-image {
|
||||
width: min(760px, 100%);
|
||||
width: min(860px, 100%);
|
||||
display: block;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
|
||||
@@ -49,6 +49,31 @@
|
||||
</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>
|
||||
@@ -75,6 +100,20 @@
|
||||
</div>
|
||||
</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>
|
||||
@@ -262,6 +301,16 @@ import {
|
||||
formatTestError,
|
||||
formatTime
|
||||
} from './riskRuleTestDialogUtils.js'
|
||||
import {
|
||||
buildDocumentBrief,
|
||||
buildEvidenceItems as buildEvidenceItemsModel,
|
||||
buildRecognizedFieldRows as buildRecognizedFieldRowsModel,
|
||||
buildResultFields as buildResultFieldsModel,
|
||||
formatDocumentMeta,
|
||||
formatFieldLabel,
|
||||
resolveFileStatusLabel,
|
||||
trimDebugText
|
||||
} from './riskRuleTestDialogDisplay.js'
|
||||
|
||||
const props = defineProps({
|
||||
open: {
|
||||
@@ -568,8 +617,9 @@ async function recognizeTemporaryFiles(files, activeSessionId) {
|
||||
messages.value.push(buildMessage(
|
||||
'assistant',
|
||||
recognizedCount
|
||||
? `已完成 ${recognizedCount} 份临时单据识别。请核对右侧识别字段,字段不足时可以直接在输入框补充。`
|
||||
: '上传文件没有提取到足够字段,暂不能直接执行规则。请在输入框补充票据城市、金额、发票号等关键信息。'
|
||||
? `已完成 ${recognizedCount} 份临时单据识别。下面会展示 OCR 结构化字段和原文片段,请先核对这些信息;字段不足时可以直接在输入框补充。`
|
||||
: '上传文件没有提取到足够字段。下面仍会展示 OCR 返回内容,方便判断是票据质量问题还是字段映射问题。请在输入框补充城市、金额、发票号等关键信息。',
|
||||
{ recognitionDocuments: documents }
|
||||
))
|
||||
} catch (error) {
|
||||
if (!isActiveSession(activeSessionId)) return
|
||||
@@ -601,52 +651,15 @@ function buildMessage(role, text, 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 ?? '-')
|
||||
}))
|
||||
return buildResultFieldsModel(result, fields.value)
|
||||
}
|
||||
|
||||
function buildRecognizedFieldRows(result) {
|
||||
return buildRecognizedFieldRowsModel(result, fields.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 })
|
||||
return buildEvidenceItemsModel(result, fields.value)
|
||||
}
|
||||
|
||||
function toAttachmentPayload(file) {
|
||||
@@ -713,23 +726,6 @@ function documentHasMeaningfulText(document) {
|
||||
)
|
||||
}
|
||||
|
||||
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 字段。'
|
||||
|
||||
110
web/src/components/shared/riskRuleTestDialogDisplay.js
Normal file
110
web/src/components/shared/riskRuleTestDialogDisplay.js
Normal file
@@ -0,0 +1,110 @@
|
||||
export 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}]`
|
||||
}
|
||||
|
||||
export function buildResultFields(result, fields = []) {
|
||||
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.find((field) => field.key === key) || { key }),
|
||||
value: Array.isArray(value) ? value.join('、') : String(value ?? '-')
|
||||
}))
|
||||
}
|
||||
|
||||
export function buildRecognizedFieldRows(result, fields = []) {
|
||||
const rows = Array.isArray(result?.recognized_fields) ? result.recognized_fields : []
|
||||
return rows.slice(0, 12).map((field, index) => ({
|
||||
key: String(field?.key || `field-${index}`),
|
||||
label: formatFieldLabel(
|
||||
fields.find((item) => item.key === field?.key) || {
|
||||
key: field?.key,
|
||||
label: field?.label
|
||||
}
|
||||
),
|
||||
source: formatRecognitionSource(field?.source),
|
||||
value: formatDebugValue(field?.value)
|
||||
}))
|
||||
}
|
||||
|
||||
export function buildEvidenceItems(result, fields = []) {
|
||||
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, fields)}:${left};${formatFieldName(condition.right, fields)}:${right}`)
|
||||
})
|
||||
}
|
||||
if (Array.isArray(evidence.missing_fields)) {
|
||||
evidence.missing_fields.slice(0, 5).forEach((field) => {
|
||||
items.push(`${formatFieldName(field, fields)} 缺失`)
|
||||
})
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
export function buildDocumentBrief(document) {
|
||||
const fields = Array.isArray(document?.document_fields) ? document.document_fields : []
|
||||
if (fields.length) {
|
||||
return fields.slice(0, 6).map((field) => `${field.label}:${field.value}`).join(';')
|
||||
}
|
||||
return String(document?.summary || document?.text || '未提取到结构化字段').slice(0, 120)
|
||||
}
|
||||
|
||||
export function formatDocumentMeta(document) {
|
||||
const labels = [
|
||||
document?.document_type_label || '',
|
||||
document?.scene_label || '',
|
||||
document?.avg_score ? `置信度 ${Math.round(Number(document.avg_score) * 100)}%` : ''
|
||||
].filter(Boolean)
|
||||
return labels.join(' · ') || '未分类'
|
||||
}
|
||||
|
||||
export function resolveFileStatusLabel(file) {
|
||||
return file.statusText || {
|
||||
pending: '待发送',
|
||||
recognizing: '识别中',
|
||||
recognized: '已识别',
|
||||
failed: '识别失败'
|
||||
}[file.status] || '待识别'
|
||||
}
|
||||
|
||||
export function trimDebugText(text, maxLength = 800) {
|
||||
const value = String(text || '').replace(/\s+/g, ' ').trim()
|
||||
if (!value) return ''
|
||||
return value.length > maxLength ? `${value.slice(0, maxLength)}...` : value
|
||||
}
|
||||
|
||||
function formatRecognitionSource(source) {
|
||||
return {
|
||||
manual: '手动输入',
|
||||
ocr: 'OCR结构字段',
|
||||
inferred: '文本推断',
|
||||
model_refined: '模型过滤'
|
||||
}[String(source || '').trim()] || '未标注来源'
|
||||
}
|
||||
|
||||
function formatDebugValue(value) {
|
||||
if (Array.isArray(value)) return value.map((item) => String(item ?? '')).filter(Boolean).join('、') || '-'
|
||||
if (value && typeof value === 'object') return JSON.stringify(value)
|
||||
return String(value ?? '-')
|
||||
}
|
||||
|
||||
function formatFieldName(key, fields) {
|
||||
return formatFieldLabel(fields.find((field) => field.key === key) || { key })
|
||||
}
|
||||
Reference in New Issue
Block a user