feat: 新增风险规则生成引擎与知识图谱可视化
后端新增风险规则自动生成和模板执行服务,支持从规则资产 批量生成并持久化风险规则文件;知识库入库日志增强图谱 查询和本地 RAG 回退,前端审计页面增加风险规则模型和流 程图组件,知识入库面板拆分为图谱可视化子组件,报销创 建页面增加引导式流程模型,更新知识库索引数据。
This commit is contained in:
932
web/package-lock.json
generated
932
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,7 @@
|
||||
"preview": "vite preview --host 0.0.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@antv/g6": "^5.1.1",
|
||||
"@primevue/themes": "^4.5.4",
|
||||
"@vitejs/plugin-vue": "^5.2.4",
|
||||
"@vueuse/motion": "^3.0.3",
|
||||
|
||||
@@ -828,6 +828,64 @@
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.shared-confirm-card:has(.risk-rule-create-form) {
|
||||
width: min(680px, 100%);
|
||||
}
|
||||
|
||||
.risk-rule-create-form {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.risk-rule-create-form label {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.risk-rule-create-form label.span-2 {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.risk-rule-create-form label span {
|
||||
color: #475569;
|
||||
font-size: 12px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.risk-rule-create-form select,
|
||||
.risk-rule-create-form textarea {
|
||||
width: 100%;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
color: #0f172a;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.risk-rule-create-form select {
|
||||
min-height: 42px;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.risk-rule-create-form textarea {
|
||||
min-height: 140px;
|
||||
resize: vertical;
|
||||
padding: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.risk-rule-create-form select:focus,
|
||||
.risk-rule-create-form textarea:focus {
|
||||
outline: 0;
|
||||
border-color: rgba(16, 185, 129, 0.5);
|
||||
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.12);
|
||||
}
|
||||
|
||||
.risk-rule-create-form textarea::placeholder {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.publish-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1036,16 +1094,15 @@
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.json-risk-summary-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.field.span-2 {
|
||||
grid-column: span 1;
|
||||
}
|
||||
}
|
||||
|
||||
.json-risk-skill-detail .detail-scroll {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.json-risk-editor-shell {
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
@@ -1053,26 +1110,118 @@
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.asset-detail-topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 14px 0 10px;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.asset-detail-topbar.panel {
|
||||
padding: 14px 0 10px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.asset-detail-topbar-main {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.asset-detail-topbar-main h2 {
|
||||
margin: 0;
|
||||
color: #0f172a;
|
||||
font-size: 18px;
|
||||
font-weight: 850;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.asset-detail-topbar-main p {
|
||||
flex-basis: 100%;
|
||||
margin: 0;
|
||||
max-width: 860px;
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.asset-detail-topbar-meta {
|
||||
flex: 0 0 auto;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.asset-detail-topbar .hero-review-meta {
|
||||
flex-basis: 100%;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.asset-detail-topbar .review-note-block {
|
||||
flex-basis: 100%;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.asset-detail-topbar .hero-stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.asset-detail-topbar .hero-stat {
|
||||
min-height: 30px;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.asset-detail-topbar .hero-stat span {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.asset-detail-topbar .hero-stat strong {
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.json-risk-editor-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
padding-top: 4px;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.json-risk-editor-title {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.json-risk-head-copy {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.json-risk-head-title-row {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.json-risk-editor-title h2 {
|
||||
color: #0f172a;
|
||||
font-size: 18px;
|
||||
font-weight: 850;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.json-risk-editor-title p {
|
||||
@@ -1085,7 +1234,7 @@
|
||||
|
||||
.json-risk-head-subtitle {
|
||||
display: -webkit-box;
|
||||
margin: 6px 0 0;
|
||||
margin: 0;
|
||||
max-width: 760px;
|
||||
overflow: hidden;
|
||||
color: #64748b;
|
||||
@@ -1095,11 +1244,24 @@
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.json-risk-head-category {
|
||||
margin: 6px 0 0;
|
||||
color: #be123c;
|
||||
.json-risk-head-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.json-risk-head-meta span {
|
||||
min-height: 24px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0 8px;
|
||||
border-radius: 999px;
|
||||
background: #f8fafc;
|
||||
color: #475569;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
font-weight: 750;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.skill-name-cell .skill-list-subtitle {
|
||||
@@ -1131,16 +1293,41 @@
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.json-risk-mode-pill.high {
|
||||
background: #fef2f2;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.json-risk-mode-pill.medium {
|
||||
background: #fff7ed;
|
||||
color: #ea580c;
|
||||
}
|
||||
|
||||
.json-risk-mode-pill.low {
|
||||
background: #ecfdf5;
|
||||
color: #059669;
|
||||
}
|
||||
|
||||
.json-risk-editor-body {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
display: block;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 0 2px 2px 0;
|
||||
}
|
||||
|
||||
.json-risk-main-stage {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
.json-risk-flow-card {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.json-risk-description-card {
|
||||
@@ -1193,46 +1380,6 @@
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.json-risk-flow-diagram {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr) auto minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.json-risk-flow-column {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
padding: 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.json-risk-flow-column.center {
|
||||
text-align: center;
|
||||
background: #fff1f2;
|
||||
border-color: #fecdd3;
|
||||
}
|
||||
|
||||
.json-risk-flow-column code {
|
||||
font-size: 11px;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.json-risk-flow-label {
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
color: #64748b;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.json-risk-flow-arrow {
|
||||
color: #94a3b8;
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.json-risk-editor-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1253,3 +1400,21 @@
|
||||
border: 1px solid #e2e8f0;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.risk-rule-create-form,
|
||||
.json-risk-summary-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.json-risk-editor-head {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.json-risk-editor-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -555,20 +555,33 @@ tbody tr:hover {
|
||||
background: rgba(16, 185, 129, 0.08);
|
||||
}
|
||||
|
||||
.detail-scroll {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.spreadsheet-skill-detail .detail-scroll {
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
align-content: stretch;
|
||||
grid-template-rows: minmax(0, 1fr);
|
||||
}
|
||||
.detail-scroll {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.spreadsheet-skill-detail .detail-scroll {
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
align-content: stretch;
|
||||
grid-template-rows: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.json-risk-skill-detail .detail-scroll {
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
align-content: stretch;
|
||||
grid-template-rows: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.detail-loading-state {
|
||||
width: 100%;
|
||||
min-height: 260px;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.detail-hero {
|
||||
display: grid;
|
||||
|
||||
@@ -687,6 +687,13 @@
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.insight-body.document-review-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-content: stretch;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.review-side-card {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
@@ -1186,6 +1193,10 @@
|
||||
|
||||
.review-ticket-drawer {
|
||||
min-height: 0;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
height: 100%;
|
||||
flex: 1 1 auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.review-document-switch-head {
|
||||
@@ -1226,6 +1237,7 @@
|
||||
|
||||
.review-document-stage {
|
||||
display: grid;
|
||||
grid-template-rows: auto auto minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
min-height: 0;
|
||||
}
|
||||
@@ -1292,7 +1304,8 @@
|
||||
.review-document-scroll {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
max-height: 430px;
|
||||
max-height: none;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
@@ -1320,8 +1333,15 @@
|
||||
.review-document-preview-card.image img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 188px;
|
||||
object-fit: cover;
|
||||
height: auto;
|
||||
max-height: 260px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.review-document-preview-card.image {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 220px;
|
||||
}
|
||||
|
||||
.review-document-preview-placeholder {
|
||||
|
||||
643
web/src/components/logs/KnowledgeIngestGraphView.vue
Normal file
643
web/src/components/logs/KnowledgeIngestGraphView.vue
Normal file
@@ -0,0 +1,643 @@
|
||||
<template>
|
||||
<section class="knowledge-graph-space">
|
||||
<header class="graph-head">
|
||||
<div>
|
||||
<span class="graph-eyebrow">LightRAG 知识图谱</span>
|
||||
<h4>实体关系空间</h4>
|
||||
</div>
|
||||
<div class="graph-toolbar">
|
||||
<label class="graph-search">
|
||||
<i class="mdi mdi-magnify"></i>
|
||||
<input
|
||||
v-model.trim="graphQuery"
|
||||
type="search"
|
||||
placeholder="搜索实体"
|
||||
@keydown.enter.prevent="focusMatchedNode"
|
||||
/>
|
||||
</label>
|
||||
<button type="button" title="定位匹配实体" @click="focusMatchedNode">
|
||||
<i class="mdi mdi-crosshairs-gps"></i>
|
||||
</button>
|
||||
<button type="button" title="缩小" @click="zoomGraph(0.86)">
|
||||
<i class="mdi mdi-magnify-minus-outline"></i>
|
||||
</button>
|
||||
<button type="button" title="放大" @click="zoomGraph(1.16)">
|
||||
<i class="mdi mdi-magnify-plus-outline"></i>
|
||||
</button>
|
||||
<button type="button" title="适配画布" @click="fitGraph">
|
||||
<i class="mdi mdi-fit-to-page-outline"></i>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="graph-stats">
|
||||
<span>{{ graphSummary.entityCount }} 实体</span>
|
||||
<span>{{ graphSummary.relationCount }} 关系</span>
|
||||
<span>{{ graphSummary.visibleNodeCount }} 节点可见</span>
|
||||
<span>{{ graphSummary.visibleEdgeCount }} 连线可见</span>
|
||||
</div>
|
||||
|
||||
<div class="graph-body" :class="{ empty: !graphData.nodes.length }">
|
||||
<div class="graph-theater">
|
||||
<div v-if="graphData.nodes.length" ref="graphContainer" class="g6-canvas"></div>
|
||||
<div v-else class="graph-empty">
|
||||
<strong>暂无图谱数据</strong>
|
||||
<span>本次归集还没有返回可展示的实体关系。</span>
|
||||
</div>
|
||||
|
||||
<div v-if="graphData.nodes.length" class="graph-hud">
|
||||
<span>G6 Force</span>
|
||||
<span>拖拽节点</span>
|
||||
<span>滚轮缩放</span>
|
||||
<span>点击查看详情</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside class="graph-inspector">
|
||||
<template v-if="selectedNode">
|
||||
<div class="inspector-title">
|
||||
<span>节点详情</span>
|
||||
<h5>{{ selectedNode.name }}</h5>
|
||||
</div>
|
||||
|
||||
<div class="node-facts">
|
||||
<div>
|
||||
<span>关系数</span>
|
||||
<strong>{{ selectedNode.degree }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>入边</span>
|
||||
<strong>{{ selectedNodeIncoming.length }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>出边</span>
|
||||
<strong>{{ selectedNodeOutgoing.length }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="node-meta">
|
||||
<span>类型</span>
|
||||
<strong>{{ selectedNode.type || '实体' }}</strong>
|
||||
</div>
|
||||
|
||||
<KnowledgeIngestNodeDetails
|
||||
:node="selectedNode"
|
||||
:incoming="selectedNodeIncoming"
|
||||
:outgoing="selectedNodeOutgoing"
|
||||
:relations="selectedNodeRelations"
|
||||
@focus-peer="focusRelationPeer"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<div v-else class="inspector-empty">
|
||||
<strong>选择一个实体</strong>
|
||||
<span>点击图谱中的节点,查看它的入边、出边和关系明细。</span>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Graph, NodeEvent } from '@antv/g6'
|
||||
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import KnowledgeIngestNodeDetails from './KnowledgeIngestNodeDetails.vue'
|
||||
import { useKnowledgeIngestGraph } from './useKnowledgeIngestGraph.js'
|
||||
|
||||
const props = defineProps({
|
||||
graph: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
documents: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
})
|
||||
|
||||
const graphContainer = ref(null)
|
||||
let graphInstance = null
|
||||
let resizeObserver = null
|
||||
|
||||
const {
|
||||
activeNodeId,
|
||||
graphData,
|
||||
graphQuery,
|
||||
graphSummary,
|
||||
selectNodeById,
|
||||
focusFirstMatch,
|
||||
selectRelationPeer,
|
||||
selectedNode,
|
||||
selectedNodeIncoming,
|
||||
selectedNodeOutgoing,
|
||||
selectedNodeRelations
|
||||
} = useKnowledgeIngestGraph(props)
|
||||
|
||||
onMounted(() => {
|
||||
initGraph()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
resizeObserver?.disconnect()
|
||||
graphInstance?.destroy()
|
||||
resizeObserver = null
|
||||
graphInstance = null
|
||||
})
|
||||
|
||||
watch(
|
||||
graphData,
|
||||
() => {
|
||||
renderGraph()
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
watch(activeNodeId, (nodeId) => {
|
||||
syncGraphSelection(nodeId)
|
||||
})
|
||||
|
||||
async function initGraph() {
|
||||
if (!graphData.value.nodes.length) return
|
||||
await nextTick()
|
||||
if (!graphContainer.value || graphInstance) return
|
||||
|
||||
graphInstance = new Graph(buildGraphOptions())
|
||||
graphInstance.on(NodeEvent.CLICK, async (event) => {
|
||||
const nodeId = String(event?.target?.id || '').trim()
|
||||
if (!nodeId) return
|
||||
selectNodeById(nodeId)
|
||||
await focusGraphNode(nodeId, { focus: false })
|
||||
})
|
||||
await graphInstance.render()
|
||||
await syncGraphSelection(activeNodeId.value)
|
||||
observeResize()
|
||||
await fitGraph()
|
||||
}
|
||||
|
||||
function buildGraphOptions() {
|
||||
return {
|
||||
container: graphContainer.value,
|
||||
autoResize: true,
|
||||
background: 'transparent',
|
||||
zoomRange: [0.22, 2.8],
|
||||
autoFit: {
|
||||
type: 'view',
|
||||
options: { padding: [86, 122, 86, 122] },
|
||||
animation: { duration: 520, easing: 'ease-in-out' }
|
||||
},
|
||||
data: graphData.value,
|
||||
layout: {
|
||||
type: 'd3-force',
|
||||
preventOverlap: true,
|
||||
centerStrength: 0.34,
|
||||
linkDistance: (edge) => (edge?.data?.weight > 2 ? 155 : 210),
|
||||
edgeStrength: 0.22,
|
||||
nodeStrength: (node) => (node?.data?.degree >= 5 ? -520 : -360),
|
||||
collide: {
|
||||
radius: (node) => 40 + Math.sqrt(Math.max(node?.data?.degree || 1, 1)) * 12,
|
||||
strength: 0.88,
|
||||
iterations: 3
|
||||
},
|
||||
manyBody: {
|
||||
strength: (node) => (node?.data?.degree >= 5 ? -620 : -420),
|
||||
distanceMax: 620
|
||||
},
|
||||
center: {
|
||||
strength: 0.24
|
||||
},
|
||||
alpha: 0.88,
|
||||
alphaDecay: 0.035,
|
||||
velocityDecay: 0.32,
|
||||
iterations: 360
|
||||
},
|
||||
node: {
|
||||
type: 'circle',
|
||||
style: (datum) => datum.style,
|
||||
state: {
|
||||
selected: {
|
||||
lineWidth: 4,
|
||||
stroke: '#1d4ed8',
|
||||
halo: true,
|
||||
haloStroke: '#60a5fa',
|
||||
haloLineWidth: 24,
|
||||
haloStrokeOpacity: 0.24,
|
||||
shadowBlur: 22
|
||||
},
|
||||
active: {
|
||||
lineWidth: 3,
|
||||
halo: true,
|
||||
haloLineWidth: 16,
|
||||
haloStrokeOpacity: 0.28
|
||||
},
|
||||
inactive: {
|
||||
opacity: 0.2
|
||||
},
|
||||
dimmed: {
|
||||
opacity: 0.18
|
||||
}
|
||||
}
|
||||
},
|
||||
edge: {
|
||||
type: 'line',
|
||||
style: (datum) => datum.style,
|
||||
state: {
|
||||
selected: {
|
||||
stroke: '#2563eb',
|
||||
lineWidth: 2.8,
|
||||
opacity: 0.96,
|
||||
halo: true,
|
||||
haloStroke: '#93c5fd',
|
||||
haloLineWidth: 8,
|
||||
haloStrokeOpacity: 0.2
|
||||
},
|
||||
active: {
|
||||
stroke: '#60a5fa',
|
||||
lineWidth: 2.4,
|
||||
opacity: 0.9
|
||||
},
|
||||
inactive: {
|
||||
opacity: 0.14
|
||||
}
|
||||
}
|
||||
},
|
||||
behaviors: [
|
||||
{ type: 'drag-canvas', key: 'drag-canvas' },
|
||||
{ type: 'zoom-canvas', key: 'zoom-canvas', sensitivity: 1.15 },
|
||||
{ type: 'drag-element-force', key: 'drag-element-force', fixed: true }
|
||||
],
|
||||
animation: { duration: 420, easing: 'ease-out' }
|
||||
}
|
||||
}
|
||||
|
||||
async function renderGraph() {
|
||||
if (!graphData.value.nodes.length) return
|
||||
if (!graphInstance) {
|
||||
await initGraph()
|
||||
return
|
||||
}
|
||||
graphInstance.setData(graphData.value)
|
||||
await graphInstance.render()
|
||||
await syncGraphSelection(activeNodeId.value)
|
||||
await fitGraph()
|
||||
}
|
||||
|
||||
async function syncGraphSelection(nodeId) {
|
||||
if (!graphInstance || graphInstance.destroyed || !nodeId) return
|
||||
const stateMap = {}
|
||||
for (const node of graphData.value.nodes) {
|
||||
if (node.id === nodeId) {
|
||||
stateMap[node.id] = ['selected']
|
||||
} else if (node.states?.includes('dimmed')) {
|
||||
stateMap[node.id] = ['dimmed']
|
||||
} else {
|
||||
stateMap[node.id] = []
|
||||
}
|
||||
}
|
||||
for (const edge of graphData.value.edges) {
|
||||
stateMap[edge.id] = edge.source === nodeId || edge.target === nodeId ? ['selected'] : ['inactive']
|
||||
}
|
||||
await graphInstance.setElementState(stateMap, false)
|
||||
}
|
||||
|
||||
async function fitGraph() {
|
||||
if (!graphInstance || graphInstance.destroyed) return
|
||||
await graphInstance.fitView(
|
||||
{ padding: [84, 118, 84, 118] },
|
||||
{ duration: 420, easing: 'ease-in-out' }
|
||||
)
|
||||
}
|
||||
|
||||
async function zoomGraph(ratio) {
|
||||
if (!graphInstance || graphInstance.destroyed) return
|
||||
await graphInstance.zoomBy(ratio, { duration: 220, easing: 'ease-out' })
|
||||
}
|
||||
|
||||
async function focusMatchedNode() {
|
||||
const nodeId = focusFirstMatch()
|
||||
if (nodeId) await focusGraphNode(nodeId)
|
||||
}
|
||||
|
||||
async function focusRelationPeer(relation) {
|
||||
const nodeId = selectRelationPeer(relation)
|
||||
if (nodeId) await focusGraphNode(nodeId)
|
||||
}
|
||||
|
||||
async function focusGraphNode(nodeId, options = {}) {
|
||||
if (!graphInstance || graphInstance.destroyed) return
|
||||
await syncGraphSelection(nodeId)
|
||||
if (options.focus === false) return
|
||||
await graphInstance.focusElement(nodeId, { duration: 360, easing: 'ease-in-out' })
|
||||
}
|
||||
|
||||
function observeResize() {
|
||||
if (!graphContainer.value || typeof ResizeObserver === 'undefined') return
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
if (!graphInstance || graphInstance.destroyed) return
|
||||
graphInstance.setSize(graphContainer.value.clientWidth, graphContainer.value.clientHeight)
|
||||
})
|
||||
resizeObserver.observe(graphContainer.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.knowledge-graph-space {
|
||||
min-height: 1080px;
|
||||
display: grid;
|
||||
grid-template-rows: auto auto minmax(1020px, 1fr);
|
||||
gap: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.graph-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.graph-eyebrow {
|
||||
color: #0f766e;
|
||||
font-size: 12px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.graph-head h4 {
|
||||
margin: 4px 0 0;
|
||||
color: #0f172a;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.graph-toolbar,
|
||||
.graph-stats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.graph-search {
|
||||
min-width: 230px;
|
||||
min-height: 32px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0 9px;
|
||||
border: 1px solid #dbe6ef;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.graph-search input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
background: transparent;
|
||||
color: #0f172a;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.graph-toolbar button {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1px solid #dbe6ef;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: #475569;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s ease, color 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.graph-toolbar button:hover {
|
||||
border-color: #2563eb;
|
||||
color: #2563eb;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.graph-stats {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.graph-stats span {
|
||||
min-height: 26px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0 9px;
|
||||
border: 1px solid #dbe6ef;
|
||||
border-radius: 999px;
|
||||
background: #fff;
|
||||
color: #475569;
|
||||
font-size: 12px;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.graph-body {
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 360px;
|
||||
gap: 14px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.graph-theater {
|
||||
position: relative;
|
||||
min-height: 1020px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(148, 163, 184, 0.24);
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #f8fafc 54%, #eef4fb 100%);
|
||||
box-shadow: inset 0 0 0 1px rgba(226, 232, 240, 0.82);
|
||||
}
|
||||
|
||||
.graph-theater::before {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
content: '';
|
||||
pointer-events: none;
|
||||
background:
|
||||
linear-gradient(rgba(148, 163, 184, 0.18) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(148, 163, 184, 0.18) 1px, transparent 1px);
|
||||
background-size: 34px 34px;
|
||||
mask-image: radial-gradient(circle at center, black 0%, black 58%, transparent 100%);
|
||||
}
|
||||
|
||||
.g6-canvas {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.g6-canvas:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.graph-hud {
|
||||
position: absolute;
|
||||
left: 14px;
|
||||
right: 14px;
|
||||
bottom: 12px;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.graph-hud span {
|
||||
min-height: 24px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0 8px;
|
||||
border: 1px solid rgba(203, 213, 225, 0.86);
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.76);
|
||||
color: #334155;
|
||||
font-size: 11px;
|
||||
font-weight: 750;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.graph-inspector {
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 12px;
|
||||
overflow: auto;
|
||||
padding: 12px;
|
||||
border: 1px solid #dbe6ef;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.inspector-title span {
|
||||
color: #0f766e;
|
||||
font-size: 12px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.inspector-title h5 {
|
||||
margin: 4px 0 0;
|
||||
color: #0f172a;
|
||||
font-size: 15px;
|
||||
line-height: 1.35;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.node-facts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.node-facts div,
|
||||
.node-meta {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
padding: 9px;
|
||||
border-radius: 8px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.node-meta {
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.node-facts span,
|
||||
.node-meta span {
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.node-facts strong,
|
||||
.node-meta strong {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: #0f172a;
|
||||
font-size: 15px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.graph-empty,
|
||||
.inspector-empty {
|
||||
min-height: 120px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
align-content: center;
|
||||
gap: 6px;
|
||||
color: #64748b;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.graph-empty {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.graph-empty strong,
|
||||
.inspector-empty strong {
|
||||
color: #0f172a;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.inspector-empty strong {
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.graph-empty span,
|
||||
.inspector-empty span {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.graph-toolbar button {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.knowledge-graph-space {
|
||||
min-height: 980px;
|
||||
grid-template-rows: auto auto minmax(900px, 1fr);
|
||||
}
|
||||
|
||||
.graph-head {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.graph-toolbar {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.graph-body {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: minmax(780px, 1fr) minmax(220px, auto);
|
||||
}
|
||||
|
||||
.graph-theater {
|
||||
min-height: 780px;
|
||||
}
|
||||
|
||||
.graph-inspector {
|
||||
max-height: 240px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 620px) {
|
||||
.graph-search {
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.graph-toolbar {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
386
web/src/components/logs/KnowledgeIngestNodeDetails.vue
Normal file
386
web/src/components/logs/KnowledgeIngestNodeDetails.vue
Normal file
@@ -0,0 +1,386 @@
|
||||
<template>
|
||||
<div class="node-detail-panel">
|
||||
<section class="detail-section">
|
||||
<div class="detail-section-head">
|
||||
<strong>节点说明</strong>
|
||||
<span>{{ safeNode.type || '实体' }}</span>
|
||||
</div>
|
||||
<div v-if="descriptionItems.length" class="description-list">
|
||||
<p v-for="(description, index) in descriptionItems" :key="index">
|
||||
{{ description }}
|
||||
</p>
|
||||
</div>
|
||||
<div v-else class="detail-empty compact">
|
||||
当前节点暂无 LightRAG 描述,完成新的归集后会从图谱属性中补充。
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="detail-section">
|
||||
<div class="detail-section-head">
|
||||
<strong>节点属性</strong>
|
||||
<span>{{ propertyItems.length }} 项</span>
|
||||
</div>
|
||||
<dl class="property-grid">
|
||||
<div>
|
||||
<dt>类型</dt>
|
||||
<dd>{{ safeNode.type || '实体' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>关系数</dt>
|
||||
<dd>{{ safeNode.degree || 0 }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>入边</dt>
|
||||
<dd>{{ incoming.length }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>出边</dt>
|
||||
<dd>{{ outgoing.length }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div v-if="labelItems.length" class="label-row">
|
||||
<span v-for="label in labelItems" :key="label">{{ label }}</span>
|
||||
</div>
|
||||
|
||||
<dl v-if="propertyItems.length" class="raw-property-list">
|
||||
<div v-for="item in propertyItems" :key="item.key">
|
||||
<dt>{{ item.label }}</dt>
|
||||
<dd>{{ item.value }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section class="detail-section relation-section">
|
||||
<div class="detail-section-head">
|
||||
<strong>关系语境</strong>
|
||||
<span>{{ relationRows.length }} 条</span>
|
||||
</div>
|
||||
<div v-if="relationRows.length" class="relation-detail-list">
|
||||
<button
|
||||
v-for="relation in relationRows"
|
||||
:key="relation.key"
|
||||
type="button"
|
||||
@click="$emit('focus-peer', relation.raw)"
|
||||
>
|
||||
<span class="relation-direction">{{ relation.directionLabel }}</span>
|
||||
<span class="relation-peer">{{ relation.peerName }}</span>
|
||||
<strong>{{ relation.type }}</strong>
|
||||
<p v-if="relation.description">{{ relation.description }}</p>
|
||||
<div v-if="relation.keywords.length" class="keyword-row">
|
||||
<span v-for="keyword in relation.keywords" :key="keyword">{{ keyword }}</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="detail-empty compact">暂无关联关系。</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
node: {
|
||||
type: Object,
|
||||
default: null
|
||||
},
|
||||
incoming: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
outgoing: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
relations: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
})
|
||||
|
||||
defineEmits(['focus-peer'])
|
||||
|
||||
const safeNode = computed(() => (props.node && typeof props.node === 'object' ? props.node : {}))
|
||||
|
||||
const descriptionItems = computed(() => {
|
||||
const descriptions = Array.isArray(safeNode.value.descriptions)
|
||||
? safeNode.value.descriptions
|
||||
: []
|
||||
const fallback = String(safeNode.value.description || '').trim()
|
||||
return dedupeTextItems(descriptions.length ? descriptions : [fallback]).slice(0, 6)
|
||||
})
|
||||
|
||||
const labelItems = computed(() => dedupeTextItems(safeNode.value.labels).slice(0, 8))
|
||||
|
||||
const propertyItems = computed(() => {
|
||||
const properties =
|
||||
safeNode.value.properties && typeof safeNode.value.properties === 'object'
|
||||
? safeNode.value.properties
|
||||
: {}
|
||||
return Object.entries(properties)
|
||||
.filter(([key, value]) => !hiddenPropertyKeys.has(key) && String(value || '').trim())
|
||||
.map(([key, value]) => ({
|
||||
key,
|
||||
label: formatPropertyKey(key),
|
||||
value: String(value).trim()
|
||||
}))
|
||||
.slice(0, 10)
|
||||
})
|
||||
|
||||
const relationRows = computed(() => {
|
||||
const nodeName = String(safeNode.value.name || '').trim()
|
||||
if (!nodeName || !Array.isArray(props.relations)) return []
|
||||
return props.relations.map((relation, index) => {
|
||||
const isOutgoing = relation.source === nodeName
|
||||
const peerName = isOutgoing ? relation.target : relation.source
|
||||
return {
|
||||
key: `${relation.source}-${relation.target}-${relation.type}-${index}`,
|
||||
raw: relation,
|
||||
peerName,
|
||||
type: String(relation.type || '关联').trim(),
|
||||
directionLabel: isOutgoing ? '指向' : '来自',
|
||||
description: String(relation.description || '').trim(),
|
||||
keywords: dedupeTextItems(relation.keywords).slice(0, 6)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const hiddenPropertyKeys = new Set(['source_id', 'file_path', 'truncate'])
|
||||
|
||||
function dedupeTextItems(items) {
|
||||
const sourceItems = Array.isArray(items)
|
||||
? items
|
||||
: String(items || '')
|
||||
.split('<SEP>')
|
||||
.filter(Boolean)
|
||||
const result = []
|
||||
const seen = new Set()
|
||||
for (const item of sourceItems) {
|
||||
const text = String(item || '').trim()
|
||||
if (!text || seen.has(text)) continue
|
||||
seen.add(text)
|
||||
result.push(text)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function formatPropertyKey(key) {
|
||||
const labels = {
|
||||
entity_id: '实体ID',
|
||||
entity_type: '实体类型',
|
||||
created_at: '创建时间',
|
||||
weight: '权重'
|
||||
}
|
||||
return labels[key] || key
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.node-detail-panel,
|
||||
.detail-section,
|
||||
.description-list,
|
||||
.relation-detail-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.detail-section {
|
||||
min-width: 0;
|
||||
padding: 11px;
|
||||
border: 1px solid #e5edf5;
|
||||
border-radius: 8px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.detail-section-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.detail-section-head strong {
|
||||
color: #0f172a;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.detail-section-head span {
|
||||
max-width: 56%;
|
||||
overflow: hidden;
|
||||
color: #475569;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.description-list {
|
||||
max-height: 190px;
|
||||
overflow: auto;
|
||||
padding-right: 3px;
|
||||
}
|
||||
|
||||
.description-list p {
|
||||
margin: 0;
|
||||
padding: 9px;
|
||||
border: 1px solid #dbeafe;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: #1e293b;
|
||||
font-size: 12px;
|
||||
line-height: 1.62;
|
||||
}
|
||||
|
||||
.property-grid,
|
||||
.raw-property-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.property-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.property-grid div,
|
||||
.raw-property-list div {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.property-grid div {
|
||||
padding: 8px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.property-grid dt,
|
||||
.raw-property-list dt {
|
||||
color: #64748b;
|
||||
font-size: 11px;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.property-grid dd,
|
||||
.raw-property-list dd {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
color: #0f172a;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.label-row,
|
||||
.keyword-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.label-row span,
|
||||
.keyword-row span,
|
||||
.relation-direction {
|
||||
min-height: 22px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0 7px;
|
||||
border-radius: 999px;
|
||||
background: #e0f2fe;
|
||||
color: #075985;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.raw-property-list {
|
||||
max-height: 160px;
|
||||
overflow: auto;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.relation-section {
|
||||
max-height: 360px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.relation-detail-list {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding-right: 3px;
|
||||
}
|
||||
|
||||
.relation-detail-list button {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
gap: 7px;
|
||||
padding: 9px;
|
||||
border: 1px solid #dbeafe;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: border-color 0.18s ease, background 0.18s ease, transform 0.18s ease;
|
||||
}
|
||||
|
||||
.relation-detail-list button:hover {
|
||||
border-color: #60a5fa;
|
||||
background: #eff6ff;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.relation-peer,
|
||||
.relation-detail-list strong {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: #0f172a;
|
||||
font-size: 12px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.relation-detail-list strong {
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.relation-detail-list p,
|
||||
.detail-empty {
|
||||
grid-column: 1 / -1;
|
||||
margin: 0;
|
||||
color: #334155;
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.keyword-row {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.keyword-row span {
|
||||
background: #ecfdf5;
|
||||
color: #047857;
|
||||
}
|
||||
|
||||
.detail-empty {
|
||||
min-height: 82px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.detail-empty.compact {
|
||||
min-height: 52px;
|
||||
padding: 8px;
|
||||
border: 1px dashed #cbd5e1;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.relation-detail-list button {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
165
web/src/components/logs/KnowledgeIngestNodeEvidence.vue
Normal file
165
web/src/components/logs/KnowledgeIngestNodeEvidence.vue
Normal file
@@ -0,0 +1,165 @@
|
||||
<template>
|
||||
<div class="evidence-panel">
|
||||
<div class="evidence-head">
|
||||
<strong>关联切片正文</strong>
|
||||
<span>{{ safeEvidence.chunkCount }} 段</span>
|
||||
</div>
|
||||
|
||||
<div v-if="safeEvidence.items.length" class="evidence-list">
|
||||
<article
|
||||
v-for="item in safeEvidence.items"
|
||||
:key="item.documentId"
|
||||
class="evidence-document"
|
||||
>
|
||||
<div class="evidence-document-head">
|
||||
<div>
|
||||
<strong>{{ item.name }}</strong>
|
||||
<small>{{ item.matchType }}</small>
|
||||
</div>
|
||||
<span>{{ item.chunks.length }} 段</span>
|
||||
</div>
|
||||
|
||||
<div v-if="item.chunks.length" class="evidence-chunks">
|
||||
<div v-for="chunk in item.chunks" :key="chunk.id" class="evidence-chunk">
|
||||
<span>#{{ chunk.order + 1 }}</span>
|
||||
<p>{{ chunk.excerpt || chunk.summary || '暂无正文片段' }}</p>
|
||||
<small>{{ chunk.tokens }} tokens</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="evidence-empty compact">
|
||||
命中了该实体,但当前日志只返回了 chunk id,未返回对应正文片段。
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div v-else class="evidence-empty">
|
||||
当前日志没有返回该实体和切片的映射;新归纳日志会优先显示精确切片,旧日志按文档实体降级匹配。
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
evidence: {
|
||||
type: Object,
|
||||
default: () => ({ items: [], chunkCount: 0 })
|
||||
}
|
||||
})
|
||||
|
||||
const safeEvidence = computed(() => ({
|
||||
items: Array.isArray(props.evidence?.items) ? props.evidence.items : [],
|
||||
chunkCount: Number(props.evidence?.chunkCount || 0)
|
||||
}))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.evidence-panel,
|
||||
.evidence-list,
|
||||
.evidence-chunks {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.evidence-head,
|
||||
.evidence-document-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.evidence-head strong {
|
||||
color: #0f172a;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.evidence-head span,
|
||||
.evidence-document-head small,
|
||||
.evidence-document-head span {
|
||||
color: #64748b;
|
||||
font-size: 11px;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.evidence-document {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
border: 1px solid #e5edf5;
|
||||
border-radius: 8px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.evidence-document-head {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.evidence-document-head div {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.evidence-document-head strong {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: #0f172a;
|
||||
font-size: 12px;
|
||||
line-height: 1.35;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.evidence-chunk {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
border: 1px solid #dbeafe;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.evidence-chunk span {
|
||||
color: #2563eb;
|
||||
font-size: 11px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.evidence-chunk p {
|
||||
margin: 0;
|
||||
color: #334155;
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.evidence-chunk small {
|
||||
color: #64748b;
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.evidence-empty {
|
||||
min-height: 96px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
align-content: center;
|
||||
gap: 6px;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.evidence-empty.compact {
|
||||
min-height: 56px;
|
||||
padding: 8px;
|
||||
border: 1px dashed #cbd5e1;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
</style>
|
||||
195
web/src/components/logs/KnowledgeIngestRunInfo.vue
Normal file
195
web/src/components/logs/KnowledgeIngestRunInfo.vue
Normal file
@@ -0,0 +1,195 @@
|
||||
<template>
|
||||
<section class="ingest-run-info">
|
||||
<div class="info-title">
|
||||
<div>
|
||||
<span>基本信息</span>
|
||||
<h4>{{ model.folder || '未指定知识目录' }}</h4>
|
||||
</div>
|
||||
<strong :class="model.statusTone">{{ model.statusLabel }}</strong>
|
||||
</div>
|
||||
|
||||
<div class="info-grid">
|
||||
<div v-for="item in infoItems" :key="item.label" class="info-item">
|
||||
<span>{{ item.label }}</span>
|
||||
<strong>{{ item.value }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
run: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
model: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const infoItems = computed(() => {
|
||||
const routeJson = props.run?.route_json || {}
|
||||
const currentDocument = props.model.documents?.find(
|
||||
(item) => item.documentId === props.model.currentDocumentId
|
||||
)
|
||||
return [
|
||||
{ label: 'Trace ID', value: props.run?.run_id || '-' },
|
||||
{ label: '任务类型', value: resolveJobType(routeJson.job_type) },
|
||||
{ label: '触发来源', value: resolveSource(props.run?.source) },
|
||||
{ label: '当前阶段', value: props.model.phaseLabel || '-' },
|
||||
{ label: '开始时间', value: formatDateTime(props.run?.started_at) },
|
||||
{ label: '结束时间', value: formatDateTime(props.run?.finished_at) },
|
||||
{ label: '执行耗时', value: formatElapsed(props.run?.started_at, props.run?.finished_at) },
|
||||
{ label: '当前文件', value: currentDocument?.name || '-' }
|
||||
]
|
||||
})
|
||||
|
||||
function resolveJobType(value) {
|
||||
const jobType = String(value || '').trim()
|
||||
if (jobType === 'knowledge_index_sync') return 'LightRAG 知识归纳'
|
||||
if (jobType === 'llm_wiki_sync') return 'LLM Wiki 知识归纳'
|
||||
return jobType || '-'
|
||||
}
|
||||
|
||||
function resolveSource(value) {
|
||||
const source = String(value || '').trim()
|
||||
if (source === 'manual') return '手动触发'
|
||||
if (source === 'scheduled') return '定时任务'
|
||||
if (source === 'system') return '系统任务'
|
||||
return source || '-'
|
||||
}
|
||||
|
||||
function formatDateTime(value) {
|
||||
if (!value) return '-'
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return String(value)
|
||||
return date.toLocaleString('zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false
|
||||
})
|
||||
}
|
||||
|
||||
function formatElapsed(startedAt, finishedAt) {
|
||||
const started = new Date(startedAt || '')
|
||||
const finished = finishedAt ? new Date(finishedAt) : new Date()
|
||||
if (Number.isNaN(started.getTime()) || Number.isNaN(finished.getTime())) return '-'
|
||||
const seconds = Math.max(0, Math.round((finished.getTime() - started.getTime()) / 1000))
|
||||
if (seconds < 60) return `${seconds}s`
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const restSeconds = seconds % 60
|
||||
if (minutes < 60) return `${minutes}m ${restSeconds}s`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
return `${hours}h ${minutes % 60}m`
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ingest-run-info {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
border: 1px solid #dbe6ef;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.info-title {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.info-title span {
|
||||
color: #0f766e;
|
||||
font-size: 12px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.info-title h4 {
|
||||
margin: 4px 0 0;
|
||||
color: #0f172a;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.info-title > strong {
|
||||
align-self: flex-start;
|
||||
min-height: 26px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0 9px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.info-title > strong.success {
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.info-title > strong.warning {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.info-title > strong.danger {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.info-title > strong.muted {
|
||||
background: #eef2f7;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.info-item span {
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.info-item strong {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: #0f172a;
|
||||
font-size: 13px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.info-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 620px) {
|
||||
.info-title {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.info-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -16,184 +16,18 @@
|
||||
<span :style="{ width: `${model.progress.percent}%` }"></span>
|
||||
</div>
|
||||
|
||||
<div class="metric-strip">
|
||||
<div v-for="metric in model.metrics" :key="metric.label" class="metric-tile">
|
||||
<span>{{ metric.label }}</span>
|
||||
<strong>{{ metric.value }}</strong>
|
||||
<small>{{ metric.hint }}</small>
|
||||
</div>
|
||||
</div>
|
||||
<KnowledgeIngestRunInfo :run="props.run" :model="model" />
|
||||
|
||||
<div class="ingest-workspace">
|
||||
<aside class="file-rail">
|
||||
<button
|
||||
v-for="document in model.documents"
|
||||
:key="document.documentId"
|
||||
type="button"
|
||||
class="file-item"
|
||||
:class="{ active: selectedDocumentId === document.documentId }"
|
||||
@click="selectDocument(document.documentId)"
|
||||
>
|
||||
<i :class="documentIcon(document)"></i>
|
||||
<span class="file-copy">
|
||||
<strong>{{ document.name }}</strong>
|
||||
<small>
|
||||
{{ document.phaseLabel }} · {{ document.chunkCount }} chunk
|
||||
</small>
|
||||
</span>
|
||||
<span class="mini-status" :class="document.statusTone">
|
||||
{{ document.statusLabel }}
|
||||
</span>
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
<section v-if="selectedDocument" class="file-detail">
|
||||
<div class="detail-topline">
|
||||
<div>
|
||||
<h4>{{ selectedDocument.name }}</h4>
|
||||
<p>
|
||||
{{ selectedDocument.folder || '根目录' }}
|
||||
<span v-if="selectedDocument.extension"> · {{ selectedDocument.extension }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<span class="status-chip" :class="selectedDocument.statusTone">
|
||||
{{ selectedDocument.statusLabel }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="detail-stats">
|
||||
<div>
|
||||
<span>原文字符</span>
|
||||
<strong>{{ formatKnowledgeMetric(selectedDocument.textChars) }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>索引字符</span>
|
||||
<strong>{{ formatKnowledgeMetric(selectedDocument.indexedTextChars) }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Chunk</span>
|
||||
<strong>{{ formatKnowledgeMetric(selectedDocument.chunkCount) }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>实体 / 关系</span>
|
||||
<strong>
|
||||
{{ formatKnowledgeMetric(selectedDocument.entityCount) }}
|
||||
/
|
||||
{{ formatKnowledgeMetric(selectedDocument.relationCount) }}
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="selectedDocument.error" class="error-note">
|
||||
{{ selectedDocument.error }}
|
||||
</p>
|
||||
|
||||
<div class="detail-section-grid">
|
||||
<section class="detail-section">
|
||||
<div class="section-head">
|
||||
<h5>Chunk 信息</h5>
|
||||
<span>{{ selectedDocument.chunks.length }} 条</span>
|
||||
</div>
|
||||
<div v-if="selectedDocument.chunks.length" class="chunk-list">
|
||||
<div v-for="chunk in selectedDocument.chunks" :key="chunk.id" class="chunk-row">
|
||||
<span class="chunk-index">#{{ chunk.order + 1 }}</span>
|
||||
<div>
|
||||
<strong>{{ compactId(chunk.id) }}</strong>
|
||||
<p>{{ chunk.summary || '暂无摘要' }}</p>
|
||||
</div>
|
||||
<small>{{ chunk.tokens }} tokens</small>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="compact-empty">暂无 chunk 明细</div>
|
||||
</section>
|
||||
|
||||
<section class="detail-section">
|
||||
<div class="section-head">
|
||||
<h5>章节提取</h5>
|
||||
<span>{{ selectedDocument.sectionCount }} 条</span>
|
||||
</div>
|
||||
<div v-if="selectedDocument.sections.length" class="section-list">
|
||||
<div
|
||||
v-for="section in selectedDocument.sections"
|
||||
:key="section.title"
|
||||
class="section-row"
|
||||
>
|
||||
<strong>{{ section.title }}</strong>
|
||||
<p>{{ section.excerpt || '暂无章节摘要' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="compact-empty">暂无章节信息</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="detail-section">
|
||||
<div class="section-head">
|
||||
<h5>处理事件</h5>
|
||||
<span>{{ selectedDocument.events.length }} 条</span>
|
||||
</div>
|
||||
<div v-if="selectedDocument.events.length" class="event-list">
|
||||
<div
|
||||
v-for="event in selectedDocument.events"
|
||||
:key="`${event.at}-${event.message}`"
|
||||
class="event-row"
|
||||
:class="event.level"
|
||||
>
|
||||
<span></span>
|
||||
<div>
|
||||
<strong>{{ formatEventTime(event.at) }}</strong>
|
||||
<p>{{ event.message }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="compact-empty">暂无处理事件</div>
|
||||
</section>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="graph-section">
|
||||
<div class="section-head">
|
||||
<h4>图谱形成</h4>
|
||||
<span>
|
||||
{{ formatKnowledgeMetric(model.graph.entityCount) }} 实体 ·
|
||||
{{ formatKnowledgeMetric(model.graph.relationCount) }} 关系
|
||||
</span>
|
||||
</div>
|
||||
<div class="graph-grid">
|
||||
<div class="graph-pane">
|
||||
<h5>实体</h5>
|
||||
<div v-if="model.graph.entities.length" class="entity-cloud">
|
||||
<span v-for="entity in model.graph.entities" :key="entity">{{ entity }}</span>
|
||||
</div>
|
||||
<div v-else class="compact-empty">暂无实体</div>
|
||||
</div>
|
||||
<div class="graph-pane">
|
||||
<h5>关系</h5>
|
||||
<div v-if="model.graph.relations.length" class="relation-list">
|
||||
<div
|
||||
v-for="relation in model.graph.relations"
|
||||
:key="`${relation.source}-${relation.target}-${relation.type}`"
|
||||
class="relation-row"
|
||||
>
|
||||
<strong>{{ relation.source }}</strong>
|
||||
<i class="mdi mdi-arrow-right-thin"></i>
|
||||
<strong>{{ relation.target }}</strong>
|
||||
<span>{{ relation.type }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="compact-empty">暂无关系</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<KnowledgeIngestGraphView :graph="model.graph" />
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import {
|
||||
buildKnowledgeIngestLogModel,
|
||||
formatKnowledgeMetric
|
||||
} from '../../utils/knowledgeIngestLogModel.js'
|
||||
import KnowledgeIngestGraphView from './KnowledgeIngestGraphView.vue'
|
||||
import KnowledgeIngestRunInfo from './KnowledgeIngestRunInfo.vue'
|
||||
import { buildKnowledgeIngestLogModel } from '../../utils/knowledgeIngestLogModel.js'
|
||||
|
||||
const props = defineProps({
|
||||
run: {
|
||||
@@ -202,63 +36,18 @@ const props = defineProps({
|
||||
}
|
||||
})
|
||||
|
||||
const selectedDocumentId = ref('')
|
||||
const model = computed(() => buildKnowledgeIngestLogModel(props.run))
|
||||
const selectedDocument = computed(
|
||||
() => model.value.documents.find((item) => item.documentId === selectedDocumentId.value) || null
|
||||
)
|
||||
|
||||
watch(
|
||||
() => model.value.selectedDocumentId,
|
||||
(nextDocumentId) => {
|
||||
if (!nextDocumentId) {
|
||||
selectedDocumentId.value = ''
|
||||
return
|
||||
}
|
||||
if (!selectedDocumentId.value || !model.value.documents.some((item) => item.documentId === selectedDocumentId.value)) {
|
||||
selectedDocumentId.value = nextDocumentId
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
function selectDocument(documentId) {
|
||||
selectedDocumentId.value = documentId
|
||||
}
|
||||
|
||||
function documentIcon(document) {
|
||||
const extension = String(document?.extension || '').toLowerCase()
|
||||
if (extension === 'pdf') return 'mdi mdi-file-pdf-box'
|
||||
if (['doc', 'docx'].includes(extension)) return 'mdi mdi-file-word-box'
|
||||
if (['xls', 'xlsx', 'csv'].includes(extension)) return 'mdi mdi-file-excel-box'
|
||||
if (['ppt', 'pptx'].includes(extension)) return 'mdi mdi-file-powerpoint-box'
|
||||
return 'mdi mdi-file-document-outline'
|
||||
}
|
||||
|
||||
function compactId(value) {
|
||||
const text = String(value || '').trim()
|
||||
if (text.length <= 18) return text || 'chunk'
|
||||
return `${text.slice(0, 8)}...${text.slice(-6)}`
|
||||
}
|
||||
|
||||
function formatEventTime(value) {
|
||||
if (!value) return '刚刚'
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return String(value)
|
||||
return date.toLocaleTimeString('zh-CN', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.knowledge-ingest-panel {
|
||||
display: grid;
|
||||
grid-template-rows: auto auto auto minmax(1080px, 1fr);
|
||||
gap: 14px;
|
||||
height: clamp(1500px, calc(100dvh + 860px), 1880px);
|
||||
min-height: 1500px;
|
||||
padding: 18px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ingest-head {
|
||||
@@ -328,381 +117,15 @@ function formatEventTime(value) {
|
||||
transition: width 0.24s ease;
|
||||
}
|
||||
|
||||
.metric-strip {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.metric-tile {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
padding: 11px 12px;
|
||||
border: 1px solid #e5edf5;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.metric-tile span,
|
||||
.metric-tile small {
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.metric-tile strong {
|
||||
color: #0f172a;
|
||||
font-size: 18px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.ingest-workspace {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(230px, 0.85fr) minmax(0, 2fr);
|
||||
gap: 14px;
|
||||
min-height: 360px;
|
||||
}
|
||||
|
||||
.file-rail {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.file-item {
|
||||
width: 100%;
|
||||
min-height: 58px;
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
border: 1px solid #e5edf5;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.file-item.active {
|
||||
border-color: rgba(15, 118, 110, 0.38);
|
||||
background: #f0fdfa;
|
||||
box-shadow: 0 0 0 3px rgba(15, 118, 110, 0.08);
|
||||
}
|
||||
|
||||
.file-item > i {
|
||||
color: #334155;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.file-copy {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.file-copy strong,
|
||||
.file-copy small {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.file-copy strong {
|
||||
color: #0f172a;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.file-copy small {
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.mini-status,
|
||||
.status-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 24px;
|
||||
padding: 0 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mini-status.success,
|
||||
.status-chip.success {
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.mini-status.warning,
|
||||
.status-chip.warning {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.mini-status.danger,
|
||||
.status-chip.danger {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.mini-status.muted,
|
||||
.status-chip.muted {
|
||||
background: #eef2f7;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.file-detail {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.detail-topline {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid #e5edf5;
|
||||
}
|
||||
|
||||
.detail-topline h4 {
|
||||
margin: 0;
|
||||
color: #0f172a;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.detail-topline p {
|
||||
margin: 5px 0 0;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.detail-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.detail-stats div {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.detail-stats span {
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.detail-stats strong {
|
||||
color: #0f172a;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.error-note {
|
||||
margin: 0;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 8px;
|
||||
background: #fff1f2;
|
||||
color: #991b1b;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.detail-section-grid,
|
||||
.graph-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.detail-section,
|
||||
.graph-section,
|
||||
.graph-pane {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.section-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.section-head h4,
|
||||
.section-head h5,
|
||||
.graph-pane h5 {
|
||||
margin: 0;
|
||||
color: #0f172a;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.section-head span {
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.chunk-list,
|
||||
.section-list,
|
||||
.event-list,
|
||||
.relation-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chunk-row,
|
||||
.section-row,
|
||||
.event-row,
|
||||
.relation-row {
|
||||
min-width: 0;
|
||||
border: 1px solid #e5edf5;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.chunk-row {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.chunk-index {
|
||||
color: #2563eb;
|
||||
font-size: 12px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.chunk-row strong,
|
||||
.section-row strong,
|
||||
.event-row strong,
|
||||
.relation-row strong {
|
||||
color: #0f172a;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.chunk-row p,
|
||||
.section-row p,
|
||||
.event-row p {
|
||||
margin: 4px 0 0;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.chunk-row small {
|
||||
color: #64748b;
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.section-row {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.event-row {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.event-row > span {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
margin-top: 5px;
|
||||
border-radius: 999px;
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.event-row.error > span {
|
||||
background: #dc2626;
|
||||
}
|
||||
|
||||
.entity-cloud {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.entity-cloud span {
|
||||
max-width: 100%;
|
||||
padding: 5px 9px;
|
||||
border: 1px solid #bfdbfe;
|
||||
border-radius: 999px;
|
||||
background: #eff6ff;
|
||||
color: #1d4ed8;
|
||||
font-size: 12px;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.relation-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 9px 10px;
|
||||
}
|
||||
|
||||
.relation-row strong {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.relation-row i {
|
||||
color: #0f766e;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.relation-row span {
|
||||
padding: 3px 7px;
|
||||
border-radius: 999px;
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.compact-empty {
|
||||
min-height: 42px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1px dashed #cbd5e1;
|
||||
border-radius: 8px;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.metric-strip,
|
||||
.detail-stats,
|
||||
.detail-section-grid,
|
||||
.graph-grid,
|
||||
.ingest-workspace {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.file-rail {
|
||||
max-height: 260px;
|
||||
overflow: auto;
|
||||
.knowledge-ingest-panel {
|
||||
height: clamp(1460px, calc(100dvh + 840px), 1840px);
|
||||
min-height: 1460px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 620px) {
|
||||
.ingest-head,
|
||||
.detail-topline {
|
||||
.ingest-head {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
@@ -711,17 +134,9 @@ function formatEventTime(value) {
|
||||
height: 64px;
|
||||
}
|
||||
|
||||
.file-item {
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.mini-status {
|
||||
grid-column: 2;
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.relation-row {
|
||||
grid-template-columns: 1fr;
|
||||
.knowledge-ingest-panel {
|
||||
height: clamp(1520px, calc(100dvh + 900px), 1900px);
|
||||
min-height: 1520px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
415
web/src/components/logs/useKnowledgeIngestGraph.js
Normal file
415
web/src/components/logs/useKnowledgeIngestGraph.js
Normal file
@@ -0,0 +1,415 @@
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
const MAX_VISIBLE_NODES = 72
|
||||
const MAX_VISIBLE_EDGES = 180
|
||||
const MAX_RELATION_PREVIEW = 40
|
||||
|
||||
const NODE_TONES = {
|
||||
hub: {
|
||||
fill: '#2563eb',
|
||||
stroke: '#dbeafe',
|
||||
halo: '#93c5fd',
|
||||
shadow: 'rgba(37, 99, 235, 0.20)'
|
||||
},
|
||||
strong: {
|
||||
fill: '#0f766e',
|
||||
stroke: '#ccfbf1',
|
||||
halo: '#5eead4',
|
||||
shadow: 'rgba(15, 118, 110, 0.18)'
|
||||
},
|
||||
accent: {
|
||||
fill: '#d97706',
|
||||
stroke: '#fef3c7',
|
||||
halo: '#fbbf24',
|
||||
shadow: 'rgba(217, 119, 6, 0.16)'
|
||||
},
|
||||
normal: {
|
||||
fill: '#4f46e5',
|
||||
stroke: '#e0e7ff',
|
||||
halo: '#a5b4fc',
|
||||
shadow: 'rgba(79, 70, 229, 0.16)'
|
||||
},
|
||||
muted: {
|
||||
fill: '#64748b',
|
||||
stroke: '#e2e8f0',
|
||||
halo: '#cbd5e1',
|
||||
shadow: 'rgba(100, 116, 139, 0.12)'
|
||||
}
|
||||
}
|
||||
|
||||
export function useKnowledgeIngestGraph(props) {
|
||||
const graphQuery = ref('')
|
||||
const activeNodeId = ref('')
|
||||
|
||||
const allRelations = computed(() => normalizeRelations(props.graph?.relations))
|
||||
const rankedNodes = computed(() => buildRankedNodes(props.graph, allRelations.value))
|
||||
const visibleNodes = computed(() => {
|
||||
const query = graphQuery.value.toLowerCase()
|
||||
return rankedNodes.value.slice(0, MAX_VISIBLE_NODES).map((node, index) => ({
|
||||
...node,
|
||||
rank: index + 1,
|
||||
matchesQuery: query ? node.name.toLowerCase().includes(query) : true
|
||||
}))
|
||||
})
|
||||
const visibleNodeNameSet = computed(() => new Set(visibleNodes.value.map((node) => node.name)))
|
||||
const visibleRelations = computed(() =>
|
||||
allRelations.value
|
||||
.filter(
|
||||
(relation) =>
|
||||
visibleNodeNameSet.value.has(relation.source) && visibleNodeNameSet.value.has(relation.target)
|
||||
)
|
||||
.slice(0, MAX_VISIBLE_EDGES)
|
||||
)
|
||||
const nodeIdByName = computed(() =>
|
||||
new Map(visibleNodes.value.map((node) => [node.name, node.id]))
|
||||
)
|
||||
const selectedNode = computed(() => {
|
||||
if (!visibleNodes.value.length) return null
|
||||
return visibleNodes.value.find((node) => node.id === activeNodeId.value) || visibleNodes.value[0]
|
||||
})
|
||||
const selectedNodeRelations = computed(() => {
|
||||
if (!selectedNode.value) return []
|
||||
return allRelations.value
|
||||
.filter(
|
||||
(relation) =>
|
||||
relation.source === selectedNode.value.name || relation.target === selectedNode.value.name
|
||||
)
|
||||
.slice(0, MAX_RELATION_PREVIEW)
|
||||
})
|
||||
const selectedNodeIncoming = computed(() =>
|
||||
selectedNodeRelations.value.filter((relation) => relation.target === selectedNode.value?.name)
|
||||
)
|
||||
const selectedNodeOutgoing = computed(() =>
|
||||
selectedNodeRelations.value.filter((relation) => relation.source === selectedNode.value?.name)
|
||||
)
|
||||
const graphData = computed(() => ({
|
||||
nodes: visibleNodes.value.map((node) => toG6Node(node)),
|
||||
edges: visibleRelations.value
|
||||
.map((relation, index) => toG6Edge(relation, index, nodeIdByName.value))
|
||||
.filter(Boolean)
|
||||
}))
|
||||
const graphSummary = computed(() => ({
|
||||
entityCount: formatNumber(resolveCount(props.graph?.entityCount, props.graph?.entity_count, rankedNodes.value.length)),
|
||||
relationCount: formatNumber(resolveCount(props.graph?.relationCount, props.graph?.relation_count, allRelations.value.length)),
|
||||
visibleNodeCount: formatNumber(visibleNodes.value.length),
|
||||
visibleEdgeCount: formatNumber(visibleRelations.value.length)
|
||||
}))
|
||||
|
||||
watch(
|
||||
visibleNodes,
|
||||
(nextNodes) => {
|
||||
if (!nextNodes.length) {
|
||||
activeNodeId.value = ''
|
||||
return
|
||||
}
|
||||
if (!nextNodes.some((node) => node.id === activeNodeId.value)) {
|
||||
activeNodeId.value = nextNodes[0].id
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
function selectNodeById(nodeId) {
|
||||
const matchedNode = visibleNodes.value.find((node) => node.id === nodeId)
|
||||
if (!matchedNode) return ''
|
||||
activeNodeId.value = matchedNode.id
|
||||
return matchedNode.id
|
||||
}
|
||||
|
||||
function focusFirstMatch() {
|
||||
const matchedNode = visibleNodes.value.find((node) => node.matchesQuery)
|
||||
if (!matchedNode) return ''
|
||||
activeNodeId.value = matchedNode.id
|
||||
return matchedNode.id
|
||||
}
|
||||
|
||||
function selectRelationPeer(relation) {
|
||||
if (!selectedNode.value) return ''
|
||||
const peerName = relation.source === selectedNode.value.name ? relation.target : relation.source
|
||||
const peerId = nodeIdByName.value.get(peerName)
|
||||
if (!peerId) return ''
|
||||
activeNodeId.value = peerId
|
||||
return peerId
|
||||
}
|
||||
|
||||
return {
|
||||
activeNodeId,
|
||||
graphData,
|
||||
graphQuery,
|
||||
graphSummary,
|
||||
selectNodeById,
|
||||
focusFirstMatch,
|
||||
selectRelationPeer,
|
||||
selectedNode,
|
||||
selectedNodeIncoming,
|
||||
selectedNodeOutgoing,
|
||||
selectedNodeRelations,
|
||||
truncateText,
|
||||
visibleNodes,
|
||||
visibleRelations
|
||||
}
|
||||
}
|
||||
|
||||
function buildRankedNodes(graph, relations) {
|
||||
const degreeMap = new Map()
|
||||
for (const relation of relations) {
|
||||
degreeMap.set(relation.source, (degreeMap.get(relation.source) || 0) + 1)
|
||||
degreeMap.set(relation.target, (degreeMap.get(relation.target) || 0) + 1)
|
||||
}
|
||||
|
||||
const byName = new Map()
|
||||
for (const entity of normalizeEntities(graph?.entities)) {
|
||||
byName.set(entity.name, entity)
|
||||
}
|
||||
for (const relation of relations) {
|
||||
if (!byName.has(relation.source)) byName.set(relation.source, { name: relation.source, type: '关系实体' })
|
||||
if (!byName.has(relation.target)) byName.set(relation.target, { name: relation.target, type: '关系实体' })
|
||||
}
|
||||
|
||||
return [...byName.values()]
|
||||
.map((entity) => ({
|
||||
...entity,
|
||||
id: toNodeId(entity.name),
|
||||
degree: degreeMap.get(entity.name) || 0
|
||||
}))
|
||||
.sort((left, right) => {
|
||||
const degreeDelta = right.degree - left.degree
|
||||
if (degreeDelta !== 0) return degreeDelta
|
||||
return left.name.localeCompare(right.name, 'zh-CN')
|
||||
})
|
||||
}
|
||||
|
||||
function toG6Node(node) {
|
||||
const tone = resolveNodeTone(node)
|
||||
const palette = NODE_TONES[tone]
|
||||
const size = clamp(34 + Math.sqrt(Math.max(node.degree, 1)) * 13, 38, node.rank === 1 ? 82 : 70)
|
||||
const opacity = node.matchesQuery ? 1 : 0.24
|
||||
|
||||
return {
|
||||
id: node.id,
|
||||
data: {
|
||||
name: node.name,
|
||||
type: node.type || '实体',
|
||||
description: node.description || '',
|
||||
descriptions: node.descriptions || [],
|
||||
properties: node.properties || {},
|
||||
labels: node.labels || [],
|
||||
degree: node.degree,
|
||||
rank: node.rank,
|
||||
matchesQuery: node.matchesQuery,
|
||||
tone
|
||||
},
|
||||
style: {
|
||||
size,
|
||||
fill: palette.fill,
|
||||
stroke: palette.stroke,
|
||||
lineWidth: 2,
|
||||
opacity,
|
||||
shadowColor: palette.shadow,
|
||||
shadowBlur: 14,
|
||||
label: true,
|
||||
labelText: truncateText(node.name, node.rank === 1 ? 16 : 12),
|
||||
labelFill: '#0f172a',
|
||||
labelFontSize: node.rank === 1 ? 13 : 12,
|
||||
labelFontWeight: 850,
|
||||
labelPlacement: 'bottom',
|
||||
labelOffsetY: 10,
|
||||
labelBackground: true,
|
||||
labelBackgroundFill: 'rgba(255, 255, 255, 0.94)',
|
||||
labelBackgroundStroke: 'rgba(203, 213, 225, 0.95)',
|
||||
labelBackgroundLineWidth: 1,
|
||||
labelBackgroundRadius: 4,
|
||||
labelBackgroundPadding: [2, 6],
|
||||
halo: true,
|
||||
haloStroke: palette.halo,
|
||||
haloLineWidth: 10,
|
||||
haloStrokeOpacity: 0.16,
|
||||
badge: node.degree > 0,
|
||||
badges: [
|
||||
{
|
||||
text: String(node.degree),
|
||||
placement: 'right-top',
|
||||
fill: 'rgba(255, 255, 255, 0.94)',
|
||||
stroke: 'rgba(37, 99, 235, 0.28)',
|
||||
color: '#1e293b',
|
||||
fontSize: 10,
|
||||
padding: [2, 5]
|
||||
}
|
||||
]
|
||||
},
|
||||
states: node.matchesQuery ? [] : ['dimmed']
|
||||
}
|
||||
}
|
||||
|
||||
function toG6Edge(relation, index, nodeIdByName) {
|
||||
const sourceId = nodeIdByName.get(relation.source)
|
||||
const targetId = nodeIdByName.get(relation.target)
|
||||
if (!sourceId || !targetId || sourceId === targetId) return null
|
||||
const weight = clamp(Number(relation.weight || relation.confidence || 1), 1, 6)
|
||||
|
||||
return {
|
||||
id: `edge-${index}-${sourceId}-${targetId}`,
|
||||
source: sourceId,
|
||||
target: targetId,
|
||||
data: {
|
||||
sourceName: relation.source,
|
||||
targetName: relation.target,
|
||||
type: relation.type,
|
||||
description: relation.description || '',
|
||||
keywords: relation.keywords || [],
|
||||
properties: relation.properties || {},
|
||||
weight
|
||||
},
|
||||
style: {
|
||||
stroke: 'rgba(37, 99, 235, 0.34)',
|
||||
lineWidth: clamp(1 + weight * 0.22, 1.2, 2.6),
|
||||
opacity: 0.9,
|
||||
endArrow: true,
|
||||
endArrowSize: 8,
|
||||
label: index < 42,
|
||||
labelText: truncateText(relation.type, 10),
|
||||
labelFill: '#0f172a',
|
||||
labelFontSize: 10,
|
||||
labelFontWeight: 800,
|
||||
labelBackground: true,
|
||||
labelBackgroundFill: 'rgba(255, 255, 255, 0.94)',
|
||||
labelBackgroundStroke: 'rgba(203, 213, 225, 0.95)',
|
||||
labelBackgroundLineWidth: 1,
|
||||
labelBackgroundRadius: 5,
|
||||
labelBackgroundPadding: [2, 5]
|
||||
},
|
||||
states: []
|
||||
}
|
||||
}
|
||||
|
||||
function resolveNodeTone(node) {
|
||||
if (node.rank === 1 || node.degree >= 6) return 'hub'
|
||||
if (node.degree >= 4) return 'strong'
|
||||
if (node.degree >= 2 && node.rank % 3 === 0) return 'accent'
|
||||
if (node.degree === 0) return 'muted'
|
||||
return 'normal'
|
||||
}
|
||||
|
||||
function normalizeRelations(rawRelations) {
|
||||
if (!Array.isArray(rawRelations)) return []
|
||||
const seen = new Set()
|
||||
return rawRelations
|
||||
.map((relation) => ({
|
||||
source: String(relation?.source || relation?.from || relation?.head || '').trim(),
|
||||
target: String(relation?.target || relation?.to || relation?.tail || '').trim(),
|
||||
type: String(relation?.type || relation?.relation || relation?.label || '关联').trim(),
|
||||
description: String(relation?.description || '').trim(),
|
||||
keywords: dedupeTextList(relation?.keywords || []),
|
||||
properties: normalizeProperties(relation?.properties),
|
||||
weight: relation?.weight ?? relation?.confidence ?? 1
|
||||
}))
|
||||
.filter((relation) => relation.source && relation.target)
|
||||
.filter((relation) => {
|
||||
const key = `${relation.source}\u0000${relation.target}\u0000${relation.type}`
|
||||
if (seen.has(key)) return false
|
||||
seen.add(key)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
function normalizeEntities(rawEntities) {
|
||||
if (!Array.isArray(rawEntities)) return []
|
||||
const seen = new Set()
|
||||
return rawEntities
|
||||
.map((entity) => {
|
||||
if (typeof entity === 'string') {
|
||||
return {
|
||||
name: entity.trim(),
|
||||
type: '实体',
|
||||
description: '',
|
||||
descriptions: [],
|
||||
properties: {},
|
||||
labels: []
|
||||
}
|
||||
}
|
||||
return {
|
||||
name: String(
|
||||
entity?.name || entity?.entity || entity?.entity_id || entity?.title || entity?.id || ''
|
||||
).trim(),
|
||||
type: String(entity?.type || entity?.entity_type || entity?.category || entity?.kind || '实体').trim(),
|
||||
description: String(entity?.description || '').trim(),
|
||||
descriptions: normalizeDescriptions(entity),
|
||||
properties: normalizeProperties(entity?.properties),
|
||||
labels: dedupeTextList(entity?.labels || [])
|
||||
}
|
||||
})
|
||||
.filter((entity) => entity.name)
|
||||
.filter((entity) => {
|
||||
if (seen.has(entity.name)) return false
|
||||
seen.add(entity.name)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
function toNodeId(value) {
|
||||
const text = String(value || '').trim()
|
||||
let hash = 0
|
||||
for (let index = 0; index < text.length; index += 1) {
|
||||
hash = (hash << 5) - hash + text.charCodeAt(index)
|
||||
hash |= 0
|
||||
}
|
||||
return `node-${Math.abs(hash).toString(36)}`
|
||||
}
|
||||
|
||||
function normalizeDescriptions(entity) {
|
||||
const descriptions = dedupeTextList(entity?.descriptions)
|
||||
if (descriptions.length) return descriptions
|
||||
const description = String(entity?.description || '').trim()
|
||||
return description ? [description] : []
|
||||
}
|
||||
|
||||
function normalizeProperties(rawProperties) {
|
||||
if (!rawProperties || typeof rawProperties !== 'object' || Array.isArray(rawProperties)) {
|
||||
return {}
|
||||
}
|
||||
const hiddenKeys = new Set(['source_id', 'file_path', 'truncate'])
|
||||
return Object.fromEntries(
|
||||
Object.entries(rawProperties)
|
||||
.filter(([key, value]) => !hiddenKeys.has(key) && String(value || '').trim())
|
||||
.map(([key, value]) => [key, String(value).trim()])
|
||||
)
|
||||
}
|
||||
|
||||
function truncateText(value, maxLength) {
|
||||
const text = String(value || '').trim()
|
||||
if (text.length <= maxLength) return text
|
||||
return `${text.slice(0, maxLength - 1)}...`
|
||||
}
|
||||
|
||||
function resolveCount(...values) {
|
||||
for (const value of values) {
|
||||
const number = Number(value)
|
||||
if (Number.isFinite(number) && number >= 0) return number
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
function formatNumber(value) {
|
||||
return new Intl.NumberFormat('zh-CN').format(resolveCount(value))
|
||||
}
|
||||
|
||||
function dedupeTextList(items) {
|
||||
const result = []
|
||||
const seen = new Set()
|
||||
const sourceItems = Array.isArray(items)
|
||||
? items
|
||||
: String(items || '')
|
||||
.split('<SEP>')
|
||||
.filter(Boolean)
|
||||
for (const item of sourceItems) {
|
||||
const text = String(item || '').trim()
|
||||
if (!text || seen.has(text)) continue
|
||||
seen.add(text)
|
||||
result.push(text)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function clamp(value, min, max) {
|
||||
return Math.max(min, Math.min(max, value))
|
||||
}
|
||||
487
web/src/components/shared/RiskRuleFlowDiagram.vue
Normal file
487
web/src/components/shared/RiskRuleFlowDiagram.vue
Normal file
@@ -0,0 +1,487 @@
|
||||
<template>
|
||||
<section class="risk-rule-flow-figure" aria-label="风险规则流程说明" :style="accentStyle">
|
||||
<div class="risk-rule-flow-content">
|
||||
<aside class="risk-rule-flow-explainer">
|
||||
<div class="risk-rule-section-title risk-rule-flow-copy-head">
|
||||
<strong>流程解释</strong>
|
||||
<span>{{ flowModel.severityLabel }}</span>
|
||||
</div>
|
||||
|
||||
<ol class="risk-rule-flow-steps" aria-label="文字流程说明">
|
||||
<li v-for="(item, index) in flowSteps" :key="item.title">
|
||||
<span class="risk-rule-step-index">{{ index + 1 }}</span>
|
||||
<div>
|
||||
<strong>{{ item.title }}</strong>
|
||||
<p>{{ item.text }}</p>
|
||||
<div v-if="item.fields?.length" class="risk-rule-field-list">
|
||||
<span v-for="field in item.fields" :key="field">{{ field }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<div class="risk-rule-flow-branches" aria-label="是或否分支">
|
||||
<div v-for="item in flowBranches" :key="item.answer" class="risk-rule-flow-branch">
|
||||
<span class="risk-rule-branch-answer" :class="`answer-${item.tone}`">
|
||||
{{ item.answer }}
|
||||
</span>
|
||||
<div>
|
||||
<strong>{{ item.title }}</strong>
|
||||
<p>{{ item.text }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="risk-rule-flow-visual">
|
||||
<div class="risk-rule-section-title risk-rule-flow-visual-title">
|
||||
<strong>流程图</strong>
|
||||
</div>
|
||||
<img
|
||||
v-if="src"
|
||||
class="risk-rule-flow-image"
|
||||
:src="src"
|
||||
alt="风险规则流程说明"
|
||||
draggable="false"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="risk-rule-flow-svg"
|
||||
role="img"
|
||||
aria-label="风险规则流程说明"
|
||||
v-html="displaySvg"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
svg: { type: String, default: '' },
|
||||
src: { type: String, default: '' },
|
||||
flow: { type: Object, default: () => ({}) },
|
||||
fields: { type: Array, default: () => [] },
|
||||
severity: { type: String, default: 'medium' },
|
||||
severityLabel: { type: String, default: '中风险' }
|
||||
})
|
||||
|
||||
const FONT =
|
||||
"-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica Neue, 'PingFang SC', 'Microsoft YaHei', 'Microsoft JhengHei', 'SimHei', sans-serif"
|
||||
const TEXT = '#0d0d0d'
|
||||
const MUTED = '#6e6e80'
|
||||
const NEUTRAL_LINE = '#cbd5e1'
|
||||
const NEUTRAL_BORDER = '#e2e8f0'
|
||||
const PALETTES = {
|
||||
low: {
|
||||
accent: '#2563eb',
|
||||
accentDark: '#1d4ed8',
|
||||
border: '#bfdbfe',
|
||||
surface: '#eff6ff'
|
||||
},
|
||||
medium: {
|
||||
accent: '#f97316',
|
||||
accentDark: '#c2410c',
|
||||
border: '#fed7aa',
|
||||
surface: '#fff7ed'
|
||||
},
|
||||
high: {
|
||||
accent: '#dc2626',
|
||||
accentDark: '#b91c1c',
|
||||
border: '#fecaca',
|
||||
surface: '#fef2f2'
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeText(value, fallback = '') {
|
||||
return String(value || fallback || '').trim()
|
||||
}
|
||||
|
||||
function escapeSvg(value) {
|
||||
return normalizeText(value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
function isSafeSvg(value) {
|
||||
const text = normalizeText(value)
|
||||
if (!text.startsWith('<svg') || !text.endsWith('</svg>')) {
|
||||
return false
|
||||
}
|
||||
return !/(<script|<foreignObject|<iframe|<object|<embed|\son\w+=|javascript:)/i.test(text)
|
||||
}
|
||||
|
||||
function isCurrentDisplaySvg(value) {
|
||||
return isSafeSvg(value) && value.includes('data-risk-flow-style="review-node-only"')
|
||||
}
|
||||
|
||||
function resolvePalette(severity) {
|
||||
return PALETTES[normalizeText(severity).toLowerCase()] || PALETTES.medium
|
||||
}
|
||||
|
||||
function formatFieldDisplay(item) {
|
||||
const key = normalizeText(item?.key)
|
||||
const label = normalizeText(item?.label || key)
|
||||
if (label && key && label !== key) {
|
||||
return `${label}[${key}]`
|
||||
}
|
||||
return label || key
|
||||
}
|
||||
|
||||
function wrapText(value, width, maxLines) {
|
||||
const text = normalizeText(value)
|
||||
if (!text) {
|
||||
return ['']
|
||||
}
|
||||
const lines = []
|
||||
for (let index = 0; index < text.length; index += width) {
|
||||
lines.push(text.slice(index, index + width))
|
||||
}
|
||||
if (lines.length > maxLines) {
|
||||
return [...lines.slice(0, maxLines - 1), `${lines[maxLines - 1].slice(0, width - 1)}…`]
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
function textLines(lines, x, y, anchor = 'middle', color = MUTED, fontSize = 13) {
|
||||
return lines
|
||||
.map(
|
||||
(line, index) =>
|
||||
`<text x="${x}" y="${y + index * (fontSize + 5)}" text-anchor="${anchor}" fill="${color}" font-family="${FONT}" font-size="${fontSize}" font-weight="400">${escapeSvg(line)}</text>`
|
||||
)
|
||||
.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)}
|
||||
</g>`
|
||||
}
|
||||
|
||||
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)}
|
||||
</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)}
|
||||
</g>`
|
||||
}
|
||||
|
||||
const palette = computed(() => resolvePalette(props.severity))
|
||||
|
||||
const accentStyle = computed(() => ({
|
||||
'--risk-flow-accent': palette.value.accent,
|
||||
'--risk-flow-accent-dark': palette.value.accentDark,
|
||||
'--risk-flow-border': palette.value.border,
|
||||
'--risk-flow-surface': palette.value.surface
|
||||
}))
|
||||
|
||||
const fieldDisplays = computed(() =>
|
||||
(Array.isArray(props.fields) ? props.fields : []).map(formatFieldDisplay).filter(Boolean)
|
||||
)
|
||||
|
||||
const fieldSummary = computed(() => {
|
||||
const fields = fieldDisplays.value
|
||||
if (!fields.length) {
|
||||
return '规则字段'
|
||||
}
|
||||
if (fields.length <= 4) {
|
||||
return fields.join('、')
|
||||
}
|
||||
return `${fields.slice(0, 4).join('、')} 等 ${fields.length} 项字段`
|
||||
})
|
||||
|
||||
const flowModel = computed(() => {
|
||||
const severityLabel = normalizeText(props.severityLabel, '中风险')
|
||||
return {
|
||||
severityLabel,
|
||||
start: normalizeText(props.flow?.start, '业务单据提交'),
|
||||
evidence: normalizeText(props.flow?.evidence, '读取规则字段'),
|
||||
decision: normalizeText(props.flow?.decision, '判断是否命中风险'),
|
||||
basis: normalizeText(props.flow?.basis || props.flow?.decision, '根据规则字段判断是否命中风险'),
|
||||
pass: normalizeText(props.flow?.pass, '未命中风险,继续流转'),
|
||||
fail: normalizeText(props.flow?.fail, `命中${severityLabel},进入人工复核`)
|
||||
}
|
||||
})
|
||||
|
||||
const flowSteps = computed(() => [
|
||||
{
|
||||
title: '业务输入',
|
||||
text: flowModel.value.start
|
||||
},
|
||||
{
|
||||
title: '字段取数',
|
||||
text: `读取规则所需字段,并将字段证据送入判断节点。字段:${fieldSummary.value}`,
|
||||
fields: fieldDisplays.value
|
||||
},
|
||||
{
|
||||
title: '判断依据',
|
||||
text: flowModel.value.basis || flowModel.value.decision
|
||||
}
|
||||
])
|
||||
|
||||
const flowBranches = computed(() => [
|
||||
{
|
||||
answer: '否',
|
||||
tone: 'pass',
|
||||
title: '不命中风险',
|
||||
text: flowModel.value.pass
|
||||
},
|
||||
{
|
||||
answer: '是',
|
||||
tone: 'risk',
|
||||
title: `命中${flowModel.value.severityLabel}`,
|
||||
text: flowModel.value.fail
|
||||
}
|
||||
])
|
||||
|
||||
const displaySvg = computed(() => {
|
||||
const providedSvg = normalizeText(props.svg)
|
||||
if (isCurrentDisplaySvg(providedSvg)) {
|
||||
return providedSvg
|
||||
}
|
||||
|
||||
const flow = flowModel.value
|
||||
const currentPalette = palette.value
|
||||
|
||||
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}"/>
|
||||
</marker>
|
||||
</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)}
|
||||
${diamond('判断依据', flow.decision, 392, 92, 112, 112)}
|
||||
${node('继续流转', flow.pass, 562, 74, 126, 60)}
|
||||
${node('进入复核', flow.fail, 562, 190, 126, 62, currentPalette)}
|
||||
${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>
|
||||
</svg>`
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.risk-rule-flow-figure {
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
padding: 14px;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.risk-rule-flow-content {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(260px, 0.78fr) minmax(0, 1.22fr);
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.risk-rule-flow-explainer {
|
||||
min-width: 0;
|
||||
align-self: stretch;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding-right: 16px;
|
||||
border-right: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.risk-rule-section-title {
|
||||
min-height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.risk-rule-flow-copy-head {
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.risk-rule-section-title strong {
|
||||
color: #0f172a;
|
||||
font-size: 14px;
|
||||
font-weight: 850;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.risk-rule-flow-copy-head span {
|
||||
color: var(--risk-flow-accent-dark);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.risk-rule-flow-steps {
|
||||
display: grid;
|
||||
gap: 11px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.risk-rule-flow-steps li,
|
||||
.risk-rule-flow-branch {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: 26px minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.risk-rule-step-index,
|
||||
.risk-rule-branch-answer {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.risk-rule-step-index,
|
||||
.answer-pass {
|
||||
background: #f8fafc;
|
||||
color: #64748b;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.answer-risk {
|
||||
background: var(--risk-flow-surface);
|
||||
color: var(--risk-flow-accent-dark);
|
||||
border: 1px solid var(--risk-flow-border);
|
||||
}
|
||||
|
||||
.risk-rule-flow-steps strong,
|
||||
.risk-rule-flow-branch strong {
|
||||
display: block;
|
||||
color: #111827;
|
||||
font-size: 12.5px;
|
||||
font-weight: 800;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.risk-rule-flow-explainer p {
|
||||
margin: 4px 0 0;
|
||||
color: #475569;
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.risk-rule-field-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.risk-rule-field-list span {
|
||||
max-width: 100%;
|
||||
padding: 3px 7px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 999px;
|
||||
background: #f8fafc;
|
||||
color: #334155;
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.risk-rule-flow-branches {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.risk-rule-flow-visual {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
place-items: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.risk-rule-flow-visual-title {
|
||||
justify-self: start;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.risk-rule-flow-image,
|
||||
.risk-rule-flow-svg {
|
||||
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;
|
||||
}
|
||||
|
||||
.risk-rule-flow-explainer {
|
||||
padding-right: 0;
|
||||
padding-bottom: 12px;
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.risk-rule-flow-figure {
|
||||
padding: 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -144,6 +144,16 @@ export function saveAgentAssetRuleJson(assetId, payload, options = {}) {
|
||||
})
|
||||
}
|
||||
|
||||
export function generateRiskRuleAsset(payload, options = {}) {
|
||||
return apiRequest('/agent-assets/risk-rules/generate', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
headers: buildWriteHeaders(options),
|
||||
timeoutMs: options.timeoutMs || 60000,
|
||||
timeoutMessage: '风险规则生成时间较长,请稍后查看是否已生成草稿。'
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchAgentAssetSpreadsheetChangeRecords(assetId, limit = 30) {
|
||||
return apiRequest(
|
||||
`/agent-assets/${assetId}/spreadsheet/change-records${buildQuery({ limit })}`
|
||||
|
||||
@@ -110,14 +110,14 @@ export function buildTypeFilterOptions(rows) {
|
||||
const typeMap = new Map()
|
||||
|
||||
for (const row of rows) {
|
||||
const value = String(row?.typeCode || 'other').trim() || 'other'
|
||||
const value = String(row?.archiveTypeCode || row?.typeCode || 'other').trim() || 'other'
|
||||
if (!typeMap.has(value)) {
|
||||
typeMap.set(value, String(row?.type || row?.typeLabel || value).trim() || value)
|
||||
typeMap.set(value, String(row?.archiveType || row?.type || row?.typeLabel || value).trim() || value)
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
{ value: ARCHIVE_FILTER_ALL, label: '全部类型' },
|
||||
{ value: ARCHIVE_FILTER_ALL, label: '全部归档类型' },
|
||||
...Array.from(typeMap.entries())
|
||||
.sort((left, right) => left[1].localeCompare(right[1], 'zh-CN'))
|
||||
.map(([value, label]) => ({ value, label }))
|
||||
@@ -176,7 +176,7 @@ export function applyArchiveListFilters(rows, filters) {
|
||||
}
|
||||
|
||||
if (filters.type && filters.type !== ARCHIVE_FILTER_ALL) {
|
||||
filteredRows = filteredRows.filter((row) => String(row.typeCode || '').trim() === filters.type)
|
||||
filteredRows = filteredRows.filter((row) => String(row.archiveTypeCode || row.typeCode || '').trim() === filters.type)
|
||||
}
|
||||
|
||||
if (filters.department && filters.department !== ARCHIVE_FILTER_ALL) {
|
||||
@@ -193,6 +193,7 @@ export function applyArchiveListFilters(rows, filters) {
|
||||
String(row.id || '').toLowerCase().includes(keyword)
|
||||
|| String(row.applicant || '').toLowerCase().includes(keyword)
|
||||
|| String(row.department || '').toLowerCase().includes(keyword)
|
||||
|| String(row.archiveType || '').toLowerCase().includes(keyword)
|
||||
|| String(row.type || '').toLowerCase().includes(keyword)
|
||||
|| String(row.amount || '').toLowerCase().includes(keyword)
|
||||
|| String(row.risk || '').toLowerCase().includes(keyword)
|
||||
|
||||
@@ -130,12 +130,14 @@ function normalizeDocument(rawDocument) {
|
||||
textChars: toNumber(document.text_chars),
|
||||
indexedTextChars: toNumber(document.indexed_text_chars),
|
||||
sectionCount: toNumber(document.section_count || sections.length),
|
||||
sections,
|
||||
chunkCount: toNumber(document.chunk_count || chunks.length),
|
||||
chunkIds: normalizeTextList(document.chunk_ids),
|
||||
chunks,
|
||||
entityCount: toNumber(document.entity_count || entities.length),
|
||||
relationCount: toNumber(document.relation_count || relations.length),
|
||||
entities,
|
||||
entityChunks: normalizeEntityChunks(document.entity_chunks || document.entityChunks),
|
||||
relations,
|
||||
events: normalizeEvents(document.events)
|
||||
}
|
||||
@@ -165,7 +167,9 @@ function normalizeProgress(rawProgress, documents) {
|
||||
|
||||
function normalizeGraph(rawGraph, documents) {
|
||||
const graph = asObject(rawGraph)
|
||||
const fallbackEntities = dedupeTextList(documents.flatMap((item) => item.entities))
|
||||
const graphEntities = normalizeEntities(graph.entities)
|
||||
const fallbackEntities = dedupeEntities(documents.flatMap((item) => item.entities))
|
||||
const graphRelations = normalizeRelations(graph.relations)
|
||||
const fallbackRelations = dedupeRelations(documents.flatMap((item) => item.relations))
|
||||
return {
|
||||
chunkCount: toNumber(
|
||||
@@ -177,12 +181,8 @@ function normalizeGraph(rawGraph, documents) {
|
||||
relationCount: toNumber(
|
||||
graph.relation_count || documents.reduce((total, item) => total + item.relationCount, 0)
|
||||
),
|
||||
entities: normalizeTextList(graph.entities).length
|
||||
? normalizeTextList(graph.entities)
|
||||
: fallbackEntities,
|
||||
relations: normalizeRelations(graph.relations).length
|
||||
? normalizeRelations(graph.relations)
|
||||
: fallbackRelations
|
||||
entities: graphEntities.length ? graphEntities : fallbackEntities,
|
||||
relations: graphRelations.length ? graphRelations : fallbackRelations
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,12 +195,28 @@ function normalizeChunks(rawChunks) {
|
||||
id: String(item.id || item._id || `chunk-${index + 1}`).trim(),
|
||||
order: toNumber(item.order ?? item.chunk_order_index ?? index),
|
||||
tokens: toNumber(item.tokens),
|
||||
summary: String(item.summary || item.content || '').trim()
|
||||
summary: String(item.summary || item.content || '').trim(),
|
||||
excerpt: String(item.excerpt || item.content_preview || item.summary || item.content || '').trim()
|
||||
}
|
||||
})
|
||||
.sort((left, right) => left.order - right.order)
|
||||
}
|
||||
|
||||
function normalizeEntityChunks(rawItems) {
|
||||
if (!Array.isArray(rawItems)) return []
|
||||
const result = []
|
||||
const seen = new Set()
|
||||
for (const rawItem of rawItems) {
|
||||
const item = asObject(rawItem)
|
||||
const entity = String(item.entity || item.name || '').trim()
|
||||
const chunkIds = normalizeTextList(item.chunk_ids || item.chunkIds)
|
||||
if (!entity || !chunkIds.length || seen.has(entity)) continue
|
||||
seen.add(entity)
|
||||
result.push({ entity, chunkIds })
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function normalizeSections(rawSections) {
|
||||
if (!Array.isArray(rawSections)) return []
|
||||
return rawSections.map((section, index) => {
|
||||
@@ -225,7 +241,8 @@ function normalizeEvents(rawEvents) {
|
||||
}
|
||||
|
||||
function normalizeEntities(rawEntities) {
|
||||
return normalizeTextList(rawEntities)
|
||||
if (!Array.isArray(rawEntities)) return []
|
||||
return dedupeEntities(rawEntities)
|
||||
}
|
||||
|
||||
function normalizeRelations(rawRelations) {
|
||||
@@ -236,7 +253,11 @@ function normalizeRelations(rawRelations) {
|
||||
return {
|
||||
source: String(item.source || item.from || '').trim(),
|
||||
target: String(item.target || item.to || '').trim(),
|
||||
type: String(item.type || '关联').trim()
|
||||
type: String(item.type || '关联').trim(),
|
||||
description: String(item.description || '').trim(),
|
||||
keywords: normalizeTextList(item.keywords),
|
||||
weight: toNumber(item.weight || item.confidence || 1),
|
||||
properties: asObject(item.properties)
|
||||
}
|
||||
})
|
||||
.filter((item) => item.source && item.target)
|
||||
@@ -263,15 +284,22 @@ function asObject(value) {
|
||||
}
|
||||
|
||||
function normalizeTextList(value) {
|
||||
if (!Array.isArray(value)) return []
|
||||
return dedupeTextList(value)
|
||||
if (Array.isArray(value)) return dedupeTextList(value)
|
||||
return dedupeTextList(
|
||||
String(value || '')
|
||||
.split('<SEP>')
|
||||
.filter(Boolean)
|
||||
)
|
||||
}
|
||||
|
||||
function dedupeTextList(items) {
|
||||
const result = []
|
||||
const seen = new Set()
|
||||
for (const item of items) {
|
||||
const text = String(item || '').trim()
|
||||
const text =
|
||||
typeof item === 'string'
|
||||
? item.trim()
|
||||
: String(item?.name || item?.entity || item?.title || item?.id || '').trim()
|
||||
if (!text || seen.has(text)) continue
|
||||
seen.add(text)
|
||||
result.push(text)
|
||||
@@ -279,6 +307,43 @@ function dedupeTextList(items) {
|
||||
return result
|
||||
}
|
||||
|
||||
function dedupeEntities(items) {
|
||||
const result = []
|
||||
const seen = new Set()
|
||||
for (const rawItem of items) {
|
||||
const item = asObject(rawItem)
|
||||
const name =
|
||||
typeof rawItem === 'string'
|
||||
? rawItem.trim()
|
||||
: String(
|
||||
item.name ||
|
||||
item.entity ||
|
||||
item.entity_id ||
|
||||
item.title ||
|
||||
item.id ||
|
||||
''
|
||||
).trim()
|
||||
if (!name || seen.has(name)) continue
|
||||
seen.add(name)
|
||||
const description = String(item.description || '').trim()
|
||||
const descriptions = normalizeTextList(item.descriptions).length
|
||||
? normalizeTextList(item.descriptions)
|
||||
: description
|
||||
? [description]
|
||||
: []
|
||||
result.push({
|
||||
...item,
|
||||
name,
|
||||
type: String(item.type || item.entity_type || item.category || item.kind || '实体').trim(),
|
||||
description,
|
||||
descriptions,
|
||||
properties: asObject(item.properties),
|
||||
labels: normalizeTextList(item.labels)
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function dedupeRelations(items) {
|
||||
const result = []
|
||||
const seen = new Set()
|
||||
@@ -289,7 +354,7 @@ function dedupeRelations(items) {
|
||||
const key = `${source}::${target}::${type}`
|
||||
if (!source || !target || seen.has(key)) continue
|
||||
seen.add(key)
|
||||
result.push({ source, target, type })
|
||||
result.push({ ...item, source, target, type })
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
<div class="filter-set">
|
||||
<div class="list-search">
|
||||
<i class="mdi mdi-magnify"></i>
|
||||
<input v-model="listKeyword" type="search" placeholder="搜索单号、申请人、部门、报销类型..." />
|
||||
<input v-model="listKeyword" type="search" placeholder="搜索单号、申请人、部门、归档类型..." />
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -102,7 +102,7 @@
|
||||
<th>单号</th>
|
||||
<th>申请人</th>
|
||||
<th>申请部门</th>
|
||||
<th>报销类型</th>
|
||||
<th>归档类型</th>
|
||||
<th>金额</th>
|
||||
<th>提交时间 <i class="mdi mdi-sort"></i></th>
|
||||
<th>归档节点</th>
|
||||
@@ -120,7 +120,7 @@
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ row.department }}</td>
|
||||
<td>{{ row.type }}</td>
|
||||
<td>{{ row.archiveType }}</td>
|
||||
<td>{{ row.amount }}</td>
|
||||
<td>{{ row.time }}</td>
|
||||
<td>{{ row.node }}</td>
|
||||
|
||||
@@ -11,14 +11,14 @@
|
||||
}"
|
||||
>
|
||||
<div class="detail-scroll">
|
||||
<section
|
||||
v-if="!selectedSkill.usesSpreadsheetRule && !selectedSkill.usesJsonRiskRule"
|
||||
class="detail-hero panel"
|
||||
>
|
||||
<div class="hero-title">
|
||||
<div class="skill-badge" :class="selectedSkill.badgeTone">{{ selectedSkill.typeLabel }}</div>
|
||||
<h2>{{ selectedSkill.name }}</h2>
|
||||
<p>{{ selectedSkill.summary || '当前资产尚未补充说明。' }}</p>
|
||||
<section
|
||||
v-if="!selectedSkill.usesSpreadsheetRule && !selectedSkill.usesJsonRiskRule"
|
||||
class="detail-hero panel asset-detail-topbar list-toolbar"
|
||||
>
|
||||
<div class="hero-title asset-detail-topbar-main filter-set">
|
||||
<div class="skill-badge" :class="selectedSkill.badgeTone">{{ selectedSkill.typeLabel }}</div>
|
||||
<h2>{{ selectedSkill.name }}</h2>
|
||||
<p>{{ selectedSkill.summary || '当前资产尚未补充说明。' }}</p>
|
||||
|
||||
<div class="hero-review-meta">
|
||||
<span>
|
||||
@@ -52,7 +52,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hero-stats">
|
||||
<div class="hero-stats asset-detail-topbar-meta toolbar-actions">
|
||||
<div class="hero-stat">
|
||||
<span>资产编码</span>
|
||||
<strong>{{ selectedSkill.code }}</strong>
|
||||
@@ -81,12 +81,12 @@
|
||||
</section>
|
||||
|
||||
<TableLoadingState
|
||||
v-else-if="detailLoading && selectedSkill.loading"
|
||||
class="detail-inline-state panel"
|
||||
variant="detail"
|
||||
title="正在加载资产详情"
|
||||
message="列表数据已就绪,正在补充版本、审核和运行信息"
|
||||
icon="mdi mdi-file-document-outline"
|
||||
v-else-if="detailLoading && selectedSkill.loading"
|
||||
class="detail-loading-state panel"
|
||||
variant="panel"
|
||||
title="正在加载资产详情"
|
||||
message="列表数据已就绪,正在补充版本、审核和运行信息"
|
||||
icon="mdi mdi-file-document-outline"
|
||||
:show-skeleton="false"
|
||||
/>
|
||||
|
||||
@@ -94,8 +94,8 @@
|
||||
v-else-if="selectedSkill.usesSpreadsheetRule"
|
||||
class="spreadsheet-editor-shell panel"
|
||||
>
|
||||
<header class="spreadsheet-editor-head">
|
||||
<div class="spreadsheet-editor-title">
|
||||
<header class="spreadsheet-editor-head asset-detail-topbar list-toolbar">
|
||||
<div class="spreadsheet-editor-title asset-detail-topbar-main filter-set">
|
||||
<div class="skill-badge" :class="selectedSkill.badgeTone">{{ selectedSkill.typeLabel }}</div>
|
||||
<div>
|
||||
<h2>{{ selectedSkill.name }}</h2>
|
||||
@@ -103,7 +103,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="spreadsheet-editor-actions">
|
||||
<div class="spreadsheet-editor-actions asset-detail-topbar-meta toolbar-actions">
|
||||
<span class="spreadsheet-mode-pill">
|
||||
{{ selectedSpreadsheetModeLabel }}
|
||||
</span>
|
||||
@@ -209,124 +209,93 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
v-else-if="selectedSkill.usesJsonRiskRule"
|
||||
class="json-risk-editor-shell panel"
|
||||
>
|
||||
<header class="json-risk-editor-head">
|
||||
<div class="json-risk-editor-title">
|
||||
<div class="skill-badge" :class="selectedSkill.badgeTone">{{ selectedSkill.typeLabel }}</div>
|
||||
<div>
|
||||
<h2>{{ selectedSkill.name }}</h2>
|
||||
<p class="json-risk-head-subtitle">
|
||||
{{ selectedSkill.riskRuleSubtitle || '平台通用风险规则' }}
|
||||
</p>
|
||||
<p v-if="selectedSkill.riskCategory" class="json-risk-head-category">
|
||||
适用场景:{{ selectedSkill.riskCategory }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="json-risk-editor-actions">
|
||||
<span class="json-risk-mode-pill">JSON 风险规则</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="json-risk-editor-body">
|
||||
<section class="json-risk-main-stage">
|
||||
<article class="detail-card panel json-risk-summary-card">
|
||||
<div class="card-head">
|
||||
<div>
|
||||
<h3>规则摘要</h3>
|
||||
<p>检查器与字段关系为只读说明,实际判断逻辑由平台代码实现。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedSkill.riskRuleSummary" class="json-risk-summary-grid">
|
||||
<span><strong>适用场景</strong>{{ selectedSkill.riskCategory || '-' }}</span>
|
||||
<span><strong>检查器</strong>{{ selectedSkill.riskRuleSummary.evaluator || '-' }}</span>
|
||||
<span><strong>本体信号</strong>{{ selectedSkill.riskRuleSummary.ontologySignal || '-' }}</span>
|
||||
<span>
|
||||
<strong>申报字段</strong>
|
||||
{{ selectedSkill.riskRuleSummary.inputs?.declared || 'claim.location' }}
|
||||
</span>
|
||||
<span>
|
||||
<strong>证据字段</strong>
|
||||
{{ (selectedSkill.riskRuleSummary.inputs?.evidence || []).join('、') || '-' }}
|
||||
</span>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="detail-card panel json-risk-flow-card">
|
||||
<div class="card-head">
|
||||
<div>
|
||||
<h3>字段关系</h3>
|
||||
<p>提交报销时从表单与 OCR 组装验审上下文,再执行一致性检查。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="json-risk-flow-diagram">
|
||||
<div class="json-risk-flow-column">
|
||||
<span class="json-risk-flow-label">输入</span>
|
||||
<code>claim.location</code>
|
||||
<code>attachment.cities[]</code>
|
||||
<code>item.item_location</code>
|
||||
</div>
|
||||
<div class="json-risk-flow-arrow">→</div>
|
||||
<div class="json-risk-flow-column center">
|
||||
<span class="json-risk-flow-label">检查</span>
|
||||
<strong>{{ selectedSkill.riskRuleSummary?.evaluator || 'location_consistency' }}</strong>
|
||||
</div>
|
||||
<div class="json-risk-flow-arrow">→</div>
|
||||
<div class="json-risk-flow-column">
|
||||
<span class="json-risk-flow-label">输出</span>
|
||||
<code>risk_flags_json</code>
|
||||
<code>severity / message</code>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article
|
||||
v-if="selectedSkill.riskRuleDescription"
|
||||
class="detail-card panel json-risk-description-card"
|
||||
>
|
||||
<div class="card-head">
|
||||
<div>
|
||||
<h3>规则说明</h3>
|
||||
<p>本条风险规则的业务背景、识别逻辑与适用场景(来自 JSON 契约 description 字段)。</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="json-risk-description-text">{{ selectedSkill.riskRuleDescription }}</p>
|
||||
<p
|
||||
v-if="selectedSkill.riskRuleSourceRef"
|
||||
class="json-risk-description-source"
|
||||
>
|
||||
来源:{{ selectedSkill.riskRuleSourceRef }}
|
||||
</p>
|
||||
</article>
|
||||
|
||||
<article class="detail-card panel json-editor-card">
|
||||
<div class="card-head">
|
||||
<div>
|
||||
<h3>规则 JSON 契约</h3>
|
||||
<p>保存后写入 server/rules/risk-rules/,提交验审与 Agent 风险问答共用同一检查器。</p>
|
||||
</div>
|
||||
</div>
|
||||
<label class="field">
|
||||
<span>{{ selectedSkill.ruleDocument?.file_name || `${selectedSkill.code}.json` }}</span>
|
||||
<textarea
|
||||
v-model="selectedSkill.riskRuleJsonText"
|
||||
class="json-editor json-risk-editor"
|
||||
:class="{ disabled: !canEditMarkdown }"
|
||||
spellcheck="false"
|
||||
:readonly="!canEditMarkdown || detailBusy"
|
||||
></textarea>
|
||||
</label>
|
||||
<div class="editor-foot">
|
||||
<span>请勿在 JSON 中配置公司差标;evaluator 变更需同步发布服务端检查器。</span>
|
||||
<span>平台内置规则,一般不频繁变更,直接维护 JSON 契约即可。</span>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
<section
|
||||
v-else-if="selectedSkill.usesJsonRiskRule"
|
||||
class="json-risk-editor-shell panel"
|
||||
>
|
||||
<header class="json-risk-editor-head asset-detail-topbar list-toolbar">
|
||||
<div class="json-risk-editor-title asset-detail-topbar-main filter-set">
|
||||
<div class="skill-badge" :class="selectedSkill.badgeTone">{{ selectedSkill.typeLabel }}</div>
|
||||
<div class="json-risk-head-copy">
|
||||
<div class="json-risk-head-title-row">
|
||||
<h2>{{ selectedSkill.name }}</h2>
|
||||
</div>
|
||||
<p class="json-risk-head-subtitle">
|
||||
{{ selectedSkill.riskRuleSubtitle || '平台通用风险规则' }}
|
||||
</p>
|
||||
<div class="json-risk-head-meta">
|
||||
<span v-if="selectedSkill.riskCategory">适用场景:{{ selectedSkill.riskCategory }}</span>
|
||||
<span>业务域:{{ selectedSkill.category || '-' }}</span>
|
||||
<span>最近更新:{{ selectedSkill.updatedAt || '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="json-risk-editor-actions asset-detail-topbar-meta toolbar-actions">
|
||||
<span class="json-risk-mode-pill" :class="selectedSkill.riskRuleSeverity">
|
||||
{{ selectedSkill.riskRuleSeverityLabel }}
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="json-risk-editor-body">
|
||||
<section class="json-risk-main-stage">
|
||||
<article class="detail-card panel json-risk-summary-card">
|
||||
<div class="card-head">
|
||||
<div>
|
||||
<h3>基本信息</h3>
|
||||
<p>这条规则的业务域、风险等级、创建时间和使用字段。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="json-risk-summary-grid">
|
||||
<span><strong>业务域</strong>{{ selectedSkill.category || '-' }}</span>
|
||||
<span><strong>风险等级</strong>{{ selectedSkill.riskRuleSeverityLabel || '-' }}</span>
|
||||
<span><strong>适用场景</strong>{{ selectedSkill.riskCategory || selectedSkill.scope || '-' }}</span>
|
||||
<span><strong>创建时间</strong>{{ selectedSkill.riskRuleCreatedAt || selectedSkill.updatedAt }}</span>
|
||||
<span><strong>已创建</strong>{{ selectedSkill.riskRuleAgeLabel || '-' }}</span>
|
||||
<span>
|
||||
<strong>使用字段</strong>
|
||||
{{ selectedSkill.riskRuleFieldSummary || '-' }}
|
||||
</span>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article
|
||||
v-if="selectedSkill.riskRuleBusinessDescription"
|
||||
class="detail-card panel json-risk-description-card"
|
||||
>
|
||||
<div class="card-head">
|
||||
<div>
|
||||
<h3>业务说明</h3>
|
||||
<p>面向规则制定者和审核人的自然语言说明。</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="json-risk-description-text">{{ selectedSkill.riskRuleBusinessDescription }}</p>
|
||||
<p
|
||||
v-if="selectedSkill.riskRuleSourceRef"
|
||||
class="json-risk-description-source"
|
||||
>
|
||||
来源:{{ selectedSkill.riskRuleSourceRef }}
|
||||
</p>
|
||||
</article>
|
||||
|
||||
<article class="detail-card panel json-risk-flow-card">
|
||||
<div class="card-head">
|
||||
<div>
|
||||
<h3>判断流程</h3>
|
||||
<p>规则从业务单据开始,读取字段证据后按判断依据决定是否进入复核。</p>
|
||||
</div>
|
||||
</div>
|
||||
<RiskRuleFlowDiagram
|
||||
:svg="selectedSkill.riskRuleFlowDiagramSvg"
|
||||
:flow="selectedSkill.riskRuleFlow"
|
||||
:fields="selectedSkill.riskRuleFields"
|
||||
:severity="selectedSkill.riskRuleSeverity"
|
||||
:severity-label="selectedSkill.riskRuleSeverityLabel"
|
||||
/>
|
||||
</article>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
<div
|
||||
@@ -672,19 +641,9 @@
|
||||
<i class="mdi mdi-file-upload-outline"></i>
|
||||
<span>{{ actionState === 'upload-spreadsheet' ? '导入中...' : '上传表格' }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-else-if="selectedSkillUsesJsonRisk"
|
||||
class="minor-action"
|
||||
type="button"
|
||||
:disabled="!canEditMarkdown || detailBusy"
|
||||
@click="saveRiskRuleJson"
|
||||
>
|
||||
<i class="mdi mdi-content-save-outline"></i>
|
||||
<span>{{ actionState === 'save-risk-json' ? '保存中...' : '保存 JSON' }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-else-if="!selectedSkill.usesSpreadsheetRule"
|
||||
class="minor-action"
|
||||
<button
|
||||
v-else-if="!selectedSkill.usesSpreadsheetRule && !selectedSkillUsesJsonRisk"
|
||||
class="minor-action"
|
||||
type="button"
|
||||
:disabled="!canEditMarkdown || detailBusy"
|
||||
@click="saveRuleMarkdown"
|
||||
@@ -893,10 +852,15 @@
|
||||
<span>清空筛选</span>
|
||||
</button>
|
||||
|
||||
<button class="create-btn" type="button" disabled>
|
||||
<i class="mdi mdi-plus"></i>
|
||||
<span>{{ createButtonLabel }}</span>
|
||||
</button>
|
||||
<button
|
||||
class="create-btn"
|
||||
type="button"
|
||||
:disabled="!canCreateRiskRule"
|
||||
@click="openRiskRuleCreateDialog"
|
||||
>
|
||||
<i class="mdi mdi-plus"></i>
|
||||
<span>{{ createButtonLabel }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -982,11 +946,63 @@
|
||||
<footer v-if="!loading && !errorMessage && visibleSkills.length" class="list-foot">
|
||||
<span class="page-summary">当前展示 {{ visibleSkills.length }} 条资产</span>
|
||||
</footer>
|
||||
</article>
|
||||
</Transition>
|
||||
|
||||
<ConfirmDialog
|
||||
:open="Boolean(versionSwitchTarget)"
|
||||
</article>
|
||||
</Transition>
|
||||
|
||||
<ConfirmDialog
|
||||
:open="riskRuleCreateOpen"
|
||||
badge="自然语言规则"
|
||||
badge-tone="info"
|
||||
title="新建风险规则"
|
||||
description="选择业务域和风险等级后,用自然语言描述规则,系统会生成可审核的风险规则草稿。"
|
||||
cancel-text="取消"
|
||||
confirm-text="开始生成"
|
||||
busy-text="生成中..."
|
||||
confirm-tone="primary"
|
||||
confirm-icon="mdi mdi-auto-fix"
|
||||
:busy="riskRuleCreateBusy"
|
||||
:close-on-mask="!riskRuleCreateBusy"
|
||||
@close="closeRiskRuleCreateDialog"
|
||||
@confirm="submitRiskRuleCreate"
|
||||
>
|
||||
<div class="risk-rule-create-form">
|
||||
<label>
|
||||
<span>业务域</span>
|
||||
<select v-model="riskRuleCreateForm.business_domain" :disabled="riskRuleCreateBusy">
|
||||
<option
|
||||
v-for="option in riskRuleCreateDomainOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>风险等级</span>
|
||||
<select v-model="riskRuleCreateForm.risk_level" :disabled="riskRuleCreateBusy">
|
||||
<option
|
||||
v-for="option in riskRuleLevelOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="span-2">
|
||||
<span>自然语言规则</span>
|
||||
<textarea
|
||||
v-model="riskRuleCreateForm.natural_language"
|
||||
:disabled="riskRuleCreateBusy"
|
||||
placeholder="例如:住宿城市必须出现在本次差旅行程城市中,否则提示高风险并要求补充说明。"
|
||||
></textarea>
|
||||
</label>
|
||||
</div>
|
||||
</ConfirmDialog>
|
||||
|
||||
<ConfirmDialog
|
||||
:open="Boolean(versionSwitchTarget)"
|
||||
badge="切换版本"
|
||||
badge-tone="info"
|
||||
title="切换规则版本"
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
</article>
|
||||
|
||||
<template v-else-if="isHermes && hermesRun">
|
||||
<article class="detail-hero panel">
|
||||
<article v-if="!isKnowledgeIngestRunDetail" class="detail-hero panel">
|
||||
<div class="hero-copy">
|
||||
<div class="hero-tags">
|
||||
<span class="level-pill" :class="resolveLevelTone(resolveRunLevel(hermesRun))">
|
||||
@@ -48,7 +48,7 @@
|
||||
:run="hermesRun"
|
||||
/>
|
||||
|
||||
<div class="detail-grid">
|
||||
<div v-if="!isKnowledgeIngestRunDetail" class="detail-grid">
|
||||
<article class="panel detail-card wide">
|
||||
<div class="card-head">
|
||||
<h3>基本信息</h3>
|
||||
|
||||
@@ -82,7 +82,7 @@
|
||||
/>
|
||||
</span>
|
||||
|
||||
<div class="message-bubble">
|
||||
<div class="message-bubble" :class="buildMessageBubbleClass(message)">
|
||||
<header class="message-meta">
|
||||
<strong>{{ message.role === 'assistant' ? (message.assistantName || ASSISTANT_DISPLAY_NAME) : '我' }}</strong>
|
||||
<time>{{ message.time }}</time>
|
||||
@@ -357,6 +357,13 @@
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<div
|
||||
v-if="buildReviewNextStepRichCopyForMessage(message)"
|
||||
class="review-next-step-rich-copy message-answer-markdown"
|
||||
v-html="renderMarkdown(buildReviewNextStepRichCopyForMessage(message))"
|
||||
@click="handleAssistantMarkdownClick($event, message)"
|
||||
></div>
|
||||
|
||||
<div
|
||||
v-if="resolveReviewFooterActions(message.reviewPayload).length"
|
||||
class="review-footer-actions"
|
||||
@@ -773,7 +780,11 @@
|
||||
</div>
|
||||
|
||||
<Transition name="insight-switch" mode="out-in">
|
||||
<div :key="`${activeSessionType}-${currentInsight.intent}-${currentInsight.title}-${reviewDrawerMode}`" class="insight-body">
|
||||
<div
|
||||
:key="`${activeSessionType}-${currentInsight.intent}-${currentInsight.title}-${reviewDrawerMode}`"
|
||||
class="insight-body"
|
||||
:class="{ 'document-review-body': isReviewDocumentDrawer }"
|
||||
>
|
||||
<template v-if="isKnowledgeSession">
|
||||
<section class="insight-card knowledge-hot-card">
|
||||
<div class="card-head">
|
||||
@@ -1145,7 +1156,7 @@
|
||||
v-model="activeReviewDocument.scene_label"
|
||||
type="text"
|
||||
:disabled="submitting || reviewActionBusy"
|
||||
placeholder="例如:业务招待费 / 差旅费"
|
||||
placeholder="例如:出租车/网约车票据 / 火车/高铁票"
|
||||
/>
|
||||
</label>
|
||||
|
||||
@@ -1302,6 +1313,22 @@
|
||||
@confirm="confirmDeleteCurrentSession"
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
:open="nextStepConfirmDialog.open"
|
||||
badge="提交确认"
|
||||
badge-tone="primary"
|
||||
title="确认提交当前单据?"
|
||||
description="提交后单据将进入审批流程,请确认关键信息、票据和风险提示已经核对无误。"
|
||||
cancel-text="再检查一下"
|
||||
confirm-text="确认提交"
|
||||
busy-text="提交中..."
|
||||
confirm-tone="primary"
|
||||
confirm-icon="mdi mdi-send-check-outline"
|
||||
:busy="reviewActionBusy"
|
||||
@close="closeReviewNextStepConfirm"
|
||||
@confirm="confirmReviewNextStepSubmit"
|
||||
/>
|
||||
|
||||
<Transition name="assistant-modal">
|
||||
<div v-if="documentPreviewDialog.open" class="assistant-overlay review-overlay">
|
||||
<section class="review-preview-modal">
|
||||
@@ -1318,11 +1345,13 @@
|
||||
<div class="review-preview-body" :class="documentPreviewDialog.kind">
|
||||
<img
|
||||
v-if="documentPreviewDialog.kind === 'image'"
|
||||
:key="documentPreviewDialog.renderKey"
|
||||
:src="documentPreviewDialog.url"
|
||||
:alt="documentPreviewDialog.filename"
|
||||
/>
|
||||
<iframe
|
||||
v-else-if="documentPreviewDialog.kind === 'pdf'"
|
||||
:key="documentPreviewDialog.renderKey"
|
||||
:src="documentPreviewDialog.url"
|
||||
title="票据 PDF 原图预览"
|
||||
></iframe>
|
||||
|
||||
@@ -20,7 +20,11 @@ import {
|
||||
import { normalizeRequestForUi } from '../../utils/requestViewModel.js'
|
||||
import TravelRequestDetailView from '../TravelRequestDetailView.vue'
|
||||
|
||||
const tabs = ['全部归档', '差旅报销', '招待报销', '其他费用']
|
||||
const ARCHIVE_TAB_ALL = '全部归档'
|
||||
const ARCHIVE_TAB_REIMBURSEMENT = '报销归档'
|
||||
const ARCHIVE_TYPE_REIMBURSEMENT = '报销'
|
||||
const ARCHIVE_TYPE_REIMBURSEMENT_CODE = 'reimbursement'
|
||||
const tabs = [ARCHIVE_TAB_ALL, ARCHIVE_TAB_REIMBURSEMENT]
|
||||
const RISK_FILTER_OPTIONS = [
|
||||
{ value: ARCHIVE_FILTER_ALL, label: '全部风险' },
|
||||
{ value: 'has', label: '有风险' },
|
||||
@@ -40,17 +44,6 @@ function formatCurrency(value) {
|
||||
}).format(Number.isFinite(amount) ? amount : 0)
|
||||
}
|
||||
|
||||
function resolveArchiveTypeTab(request) {
|
||||
const expenseType = String(request?.typeCode || request?.expenseType || '').trim().toLowerCase()
|
||||
if (expenseType === 'travel') {
|
||||
return '差旅报销'
|
||||
}
|
||||
if (expenseType === 'entertainment') {
|
||||
return '招待报销'
|
||||
}
|
||||
return '其他费用'
|
||||
}
|
||||
|
||||
function buildArchiveRow(request) {
|
||||
const normalized = normalizeRequestForUi(request)
|
||||
const riskCount = countClaimRisks(normalized.riskFlags, normalized.riskSummary)
|
||||
@@ -75,6 +68,8 @@ function buildArchiveRow(request) {
|
||||
archivedAt: normalized.updatedAt || normalized.applyTime,
|
||||
archiveMonth,
|
||||
archiveMonthLabel: formatArchiveMonthLabel(archiveMonth),
|
||||
archiveType: ARCHIVE_TYPE_REIMBURSEMENT,
|
||||
archiveTypeCode: ARCHIVE_TYPE_REIMBURSEMENT_CODE,
|
||||
node: normalized.workflowNode || '归档入账',
|
||||
hasRisk,
|
||||
riskCount,
|
||||
@@ -82,7 +77,7 @@ function buildArchiveRow(request) {
|
||||
riskTone,
|
||||
status: '已归档',
|
||||
statusTone: 'archived',
|
||||
archiveTab: resolveArchiveTypeTab(normalized)
|
||||
archiveTab: ARCHIVE_TAB_REIMBURSEMENT
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,7 +93,7 @@ export default {
|
||||
TableEmptyState
|
||||
},
|
||||
setup() {
|
||||
const activeTab = ref('全部归档')
|
||||
const activeTab = ref(ARCHIVE_TAB_ALL)
|
||||
const activeRiskFilter = ref(ARCHIVE_FILTER_ALL)
|
||||
const activeTypeFilter = ref(ARCHIVE_FILTER_ALL)
|
||||
const activeDepartmentFilter = ref(ARCHIVE_FILTER_ALL)
|
||||
@@ -115,7 +110,7 @@ export default {
|
||||
const archiveMonthFilterOptions = computed(() => buildArchiveMonthFilterOptions(rows.value))
|
||||
|
||||
const riskFilterLabel = computed(() => resolveFilterLabel(RISK_FILTER_OPTIONS, activeRiskFilter.value, '全部风险'))
|
||||
const typeFilterLabel = computed(() => resolveFilterLabel(typeFilterOptions.value, activeTypeFilter.value, '费用类型'))
|
||||
const typeFilterLabel = computed(() => resolveFilterLabel(typeFilterOptions.value, activeTypeFilter.value, '归档类型'))
|
||||
const departmentFilterLabel = computed(() => resolveFilterLabel(departmentFilterOptions.value, activeDepartmentFilter.value, '所属部门'))
|
||||
const archiveMonthFilterLabel = computed(() => resolveFilterLabel(archiveMonthFilterOptions.value, activeArchiveMonthFilter.value, '归档月份'))
|
||||
|
||||
@@ -193,8 +188,8 @@ export default {
|
||||
eyebrow: filtersActive ? '筛选结果为空' : '归档中心',
|
||||
title: filtersActive ? '没有符合当前筛选条件的归档单据' : `“${activeTab.value}”里暂时没有归档单据`,
|
||||
desc: filtersActive
|
||||
? '可以调整风险、费用类型、部门或归档月份筛选,也可以修改搜索关键词后重试。'
|
||||
: '可以切换到其他分类查看,或调整筛选条件后重新检索。',
|
||||
? '可以调整风险、归档类型、部门或归档月份筛选,也可以修改搜索关键词后重试。'
|
||||
: '可以切换到其他归档分类查看,或调整筛选条件后重新检索。',
|
||||
icon: 'mdi mdi-archive-outline',
|
||||
actionLabel: null,
|
||||
actionIcon: null,
|
||||
@@ -205,7 +200,7 @@ export default {
|
||||
})
|
||||
|
||||
function resetListFilters() {
|
||||
activeTab.value = '全部归档'
|
||||
activeTab.value = ARCHIVE_TAB_ALL
|
||||
activeRiskFilter.value = ARCHIVE_FILTER_ALL
|
||||
activeTypeFilter.value = ARCHIVE_FILTER_ALL
|
||||
activeDepartmentFilter.value = ARCHIVE_FILTER_ALL
|
||||
|
||||
@@ -2,6 +2,7 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import ConfirmDialog from '../../components/shared/ConfirmDialog.vue'
|
||||
import { fetchEmployees } from '../../services/employees.js'
|
||||
import RiskRuleFlowDiagram from '../../components/shared/RiskRuleFlowDiagram.vue'
|
||||
import TableLoadingState from '../../components/shared/TableLoadingState.vue'
|
||||
import TableEmptyState from '../../components/shared/TableEmptyState.vue'
|
||||
import { useSystemState } from '../../composables/useSystemState.js'
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
fetchAgentAssetRuleJson,
|
||||
fetchAgentAssetVersionTimeline,
|
||||
fetchAgentRuns,
|
||||
generateRiskRuleAsset,
|
||||
saveAgentAssetRuleJson,
|
||||
importAgentAssetSpreadsheetContent,
|
||||
restoreAgentAssetVersion,
|
||||
@@ -58,11 +60,17 @@ import {
|
||||
parseRuntimeRuleText,
|
||||
buildMarkdownVersionContent
|
||||
} from './auditViewModel.js'
|
||||
import {
|
||||
createDefaultRiskRuleForm,
|
||||
RISK_RULE_CREATE_DOMAIN_OPTIONS,
|
||||
RISK_RULE_LEVEL_OPTIONS
|
||||
} from './auditViewRiskRuleModel.js'
|
||||
|
||||
export default {
|
||||
name: 'AuditView',
|
||||
components: {
|
||||
ConfirmDialog,
|
||||
RiskRuleFlowDiagram,
|
||||
TableLoadingState,
|
||||
TableEmptyState
|
||||
},
|
||||
@@ -95,6 +103,8 @@ export default {
|
||||
const reviewSubmitReviewer = ref('')
|
||||
const reviewSubmitReviewerLoading = ref(false)
|
||||
const reviewSubmitReviewerOptions = ref([])
|
||||
const riskRuleCreateOpen = ref(false)
|
||||
const riskRuleCreateForm = ref(createDefaultRiskRuleForm())
|
||||
const runLoading = ref(false)
|
||||
const runs = ref([])
|
||||
const spreadsheetUploadInput = ref(null)
|
||||
@@ -152,6 +162,10 @@ export default {
|
||||
!selectedSkill.value?.isPreviewMock &&
|
||||
(isAdmin.value || isFinance.value)
|
||||
)
|
||||
const canCreateRiskRule = computed(
|
||||
() => activeType.value === 'riskRules' && (isAdmin.value || isFinance.value) && !detailBusy.value
|
||||
)
|
||||
const riskRuleCreateBusy = computed(() => actionState.value === 'generate-risk-rule')
|
||||
const canEditMarkdown = computed(() => canEditSelected.value && selectedSkillIsRule.value)
|
||||
const isDisplayingWorkingVersion = computed(
|
||||
() => selectedSkill.value?.displayVersion === selectedSkill.value?.workingVersion
|
||||
@@ -492,6 +506,53 @@ export default {
|
||||
return currentUser.value?.name || currentUser.value?.username || 'system'
|
||||
}
|
||||
|
||||
function openRiskRuleCreateDialog() {
|
||||
if (activeType.value !== 'riskRules') {
|
||||
return
|
||||
}
|
||||
riskRuleCreateForm.value = createDefaultRiskRuleForm()
|
||||
riskRuleCreateOpen.value = true
|
||||
}
|
||||
|
||||
function closeRiskRuleCreateDialog() {
|
||||
if (riskRuleCreateBusy.value) {
|
||||
return
|
||||
}
|
||||
riskRuleCreateOpen.value = false
|
||||
}
|
||||
|
||||
async function submitRiskRuleCreate() {
|
||||
if (!canCreateRiskRule.value || riskRuleCreateBusy.value) {
|
||||
return
|
||||
}
|
||||
const naturalLanguage = String(riskRuleCreateForm.value.natural_language || '').trim()
|
||||
if (naturalLanguage.length < 8) {
|
||||
toast('请至少输入 8 个字的风险规则描述。')
|
||||
return
|
||||
}
|
||||
|
||||
actionState.value = 'generate-risk-rule'
|
||||
try {
|
||||
const detail = await generateRiskRuleAsset(
|
||||
{
|
||||
business_domain: riskRuleCreateForm.value.business_domain,
|
||||
risk_level: riskRuleCreateForm.value.risk_level,
|
||||
natural_language: naturalLanguage
|
||||
},
|
||||
{ actor: resolveActor() }
|
||||
)
|
||||
riskRuleCreateOpen.value = false
|
||||
await refreshCurrentAssets()
|
||||
selectedSkill.value = buildDetailViewModel(detail, runs.value)
|
||||
await loadRiskRuleJson(detail.id)
|
||||
toast('风险规则草稿已生成,请在详情中核对业务说明和判断流程。')
|
||||
} catch (error) {
|
||||
toast(error?.message || '风险规则生成失败,请稍后重试。')
|
||||
} finally {
|
||||
actionState.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function persistRuleRuntimeConfig(asset, runtimeRule) {
|
||||
await updateAgentAsset(
|
||||
asset.id,
|
||||
@@ -1421,6 +1482,7 @@ export default {
|
||||
activeFilterTokens,
|
||||
canManageSelected,
|
||||
canEditSelected,
|
||||
canCreateRiskRule,
|
||||
canSubmitReview,
|
||||
hasReviewSubmitReviewers,
|
||||
canReviewSelected,
|
||||
@@ -1444,6 +1506,11 @@ export default {
|
||||
reviewSubmitReviewer,
|
||||
reviewSubmitReviewerLoading,
|
||||
reviewSubmitReviewerOptions,
|
||||
riskRuleCreateOpen,
|
||||
riskRuleCreateForm,
|
||||
riskRuleCreateBusy,
|
||||
riskRuleCreateDomainOptions: RISK_RULE_CREATE_DOMAIN_OPTIONS,
|
||||
riskRuleLevelOptions: RISK_RULE_LEVEL_OPTIONS,
|
||||
showReviewNote,
|
||||
spreadsheetUploadInput,
|
||||
spreadsheetOnlyOfficeLoading,
|
||||
@@ -1464,6 +1531,9 @@ export default {
|
||||
toggleFilterPopover,
|
||||
selectFilter,
|
||||
closeFilterPopover,
|
||||
openRiskRuleCreateDialog,
|
||||
closeRiskRuleCreateDialog,
|
||||
submitRiskRuleCreate,
|
||||
openVersionSwitch,
|
||||
cancelVersionSwitch,
|
||||
confirmVersionSwitch,
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useTravelReimbursementSessionState } from './useTravelReimbursementSess
|
||||
import { useTravelReimbursementReviewDrawer } from './useTravelReimbursementReviewDrawer.js'
|
||||
import { useTravelReimbursementSubmitComposer } from './useTravelReimbursementSubmitComposer.js'
|
||||
import { useTravelReimbursementReviewActions } from './useTravelReimbursementReviewActions.js'
|
||||
import { useTravelReimbursementGuidedFlow } from './useTravelReimbursementGuidedFlow.js'
|
||||
import { recognizeOcrFiles } from '../../services/ocr.js'
|
||||
import { fetchAgentRunDetail } from '../../services/agentAssets.js'
|
||||
import { deleteConversation, runOrchestrator } from '../../services/orchestrator.js'
|
||||
@@ -536,6 +537,7 @@ export default {
|
||||
currentInsight,
|
||||
reviewFilePreviews,
|
||||
composerUploadIntent,
|
||||
guidedFlowState,
|
||||
insightPanelCollapsed,
|
||||
sessionSwitchBusy,
|
||||
buildEmptySessionState,
|
||||
@@ -871,7 +873,8 @@ export default {
|
||||
})
|
||||
sessionRuntimeRefs = {
|
||||
attachedFiles,
|
||||
composerFilesExpanded
|
||||
composerFilesExpanded,
|
||||
guidedFlowState
|
||||
}
|
||||
const {
|
||||
confirmPendingAttachmentAssociationInternal,
|
||||
@@ -961,6 +964,34 @@ export default {
|
||||
|| composerBusinessTimeTags.value.length
|
||||
)
|
||||
)
|
||||
const {
|
||||
handleGuidedShortcut,
|
||||
handleGuidedComposerSubmit,
|
||||
handleGuidedSuggestedAction,
|
||||
resetGuidedFlowState
|
||||
} = useTravelReimbursementGuidedFlow({
|
||||
guidedFlowState,
|
||||
messages,
|
||||
composerDraft,
|
||||
attachedFiles,
|
||||
composerBusinessTimeTags,
|
||||
composerBusinessTimeDraftTouched,
|
||||
fileInputRef,
|
||||
submitting,
|
||||
reviewActionBusy,
|
||||
sessionSwitchBusy,
|
||||
createMessage,
|
||||
nextTick,
|
||||
scrollToBottom,
|
||||
persistSessionState,
|
||||
clearAttachedFiles,
|
||||
adjustComposerTextareaHeight,
|
||||
buildComposerBusinessTimeContext,
|
||||
openTravelCalculator,
|
||||
lockSuggestedActionMessage,
|
||||
submitExistingComposer: submitComposerInternal,
|
||||
toast
|
||||
})
|
||||
function toggleTravelCalculator() {
|
||||
return toggleTravelCalculatorInternal()
|
||||
}
|
||||
@@ -1050,6 +1081,7 @@ export default {
|
||||
reviewFilePreviews: reviewFilePreviews.value,
|
||||
composerDraft: composerDraft.value,
|
||||
composerUploadIntent: composerUploadIntent.value,
|
||||
guidedFlowState: guidedFlowState.value,
|
||||
insightPanelCollapsed: insightPanelCollapsed.value
|
||||
}),
|
||||
() => {
|
||||
@@ -1168,6 +1200,7 @@ export default {
|
||||
function resetCurrentSessionState() {
|
||||
const emptyState = buildEmptySessionState(activeSessionType.value)
|
||||
sessionSnapshots.value[activeSessionType.value] = emptyState
|
||||
resetGuidedFlowState()
|
||||
applySessionState(emptyState)
|
||||
resetFlowRun({ startedAt: 0, openDrawer: false })
|
||||
}
|
||||
@@ -1239,6 +1272,9 @@ export default {
|
||||
await switchSessionType(shortcut.targetSessionType)
|
||||
return
|
||||
}
|
||||
if (handleGuidedShortcut(shortcut)) {
|
||||
return
|
||||
}
|
||||
|
||||
const prompt = String(shortcut?.prompt || '').trim()
|
||||
if (!prompt) return
|
||||
@@ -1293,6 +1329,7 @@ export default {
|
||||
const actionType = String(action?.action_type || '').trim()
|
||||
if (!actionType || submitting.value || reviewActionBusy.value || sessionSwitchBusy.value) return
|
||||
if (message?.suggestedActionsLocked) return
|
||||
if (await handleGuidedSuggestedAction(message, action)) return
|
||||
|
||||
if (actionType === 'confirm_expense_intent') {
|
||||
const originalMessage = String(action?.payload?.original_message || message?.text || '').trim()
|
||||
@@ -1746,6 +1783,9 @@ export default {
|
||||
// submitting.value = true
|
||||
// recognizeOcrFiles(files)
|
||||
// submitting.value = false
|
||||
if (await handleGuidedComposerSubmit(options)) {
|
||||
return null
|
||||
}
|
||||
return submitComposerInternal(options)
|
||||
}
|
||||
|
||||
|
||||
@@ -87,7 +87,7 @@ export const TAB_META = {
|
||||
typeKey: 'rules',
|
||||
label: '风险规则',
|
||||
typeLabel: '风险规则',
|
||||
createButtonLabel: '风险规则已接入',
|
||||
createButtonLabel: '新建风险规则',
|
||||
hintText: '仅展示平台风险规则;适用场景按差旅、发票、餐饮招待等分类,可用「使用场景」筛选。',
|
||||
searchPlaceholder: '搜索风险规则名称、编码或负责人',
|
||||
tableColumns: RULE_TABLE_COLUMNS,
|
||||
|
||||
@@ -19,6 +19,17 @@ import {
|
||||
TYPE_META,
|
||||
VERSION_STATE_META
|
||||
} from './auditViewMetadata.js'
|
||||
import {
|
||||
buildRiskRuleFieldSummary,
|
||||
formatRiskRuleAge,
|
||||
resolveRiskRuleBusinessDescription,
|
||||
resolveRiskRuleCreatedAt,
|
||||
resolveRiskRuleFields,
|
||||
resolveRiskRuleFlow,
|
||||
resolveRiskRuleFlowDiagramSvg,
|
||||
resolveRiskRuleSeverity,
|
||||
resolveRiskRuleSeverityLabel
|
||||
} from './auditViewRiskRuleModel.js'
|
||||
|
||||
export {
|
||||
DETAIL_TITLES,
|
||||
@@ -413,14 +424,26 @@ export function applyRiskRuleJsonState(target, payload, apiPayload) {
|
||||
const riskCategory =
|
||||
normalizeText(rulePayload.risk_category) ||
|
||||
resolveRiskRuleCategory({ ...target, risk_category: rulePayload.risk_category, config_json: rulePayload })
|
||||
const riskRuleFields = resolveRiskRuleFields(rulePayload)
|
||||
const riskRuleCreatedAt = resolveRiskRuleCreatedAt(rulePayload, target.createdAt || target.updatedAt)
|
||||
|
||||
return {
|
||||
...target,
|
||||
riskRuleDescription: fullDescription,
|
||||
riskRuleBusinessDescription: resolveRiskRuleBusinessDescription(rulePayload, fullDescription),
|
||||
riskRuleSubtitle: buildRiskListSubtitle(fullDescription, 48),
|
||||
riskCategory,
|
||||
scope: riskCategory,
|
||||
riskRuleSourceRef: resolveRiskRuleSourceRef(rulePayload),
|
||||
riskRuleSeverity: resolveRiskRuleSeverity(rulePayload),
|
||||
riskRuleSeverityLabel: resolveRiskRuleSeverityLabel(rulePayload),
|
||||
riskRuleCreatedAt: formatDateTime(riskRuleCreatedAt),
|
||||
riskRuleAgeLabel: formatRiskRuleAge(riskRuleCreatedAt),
|
||||
riskRuleFields,
|
||||
riskRuleFieldSummary: buildRiskRuleFieldSummary(riskRuleFields),
|
||||
riskRuleFlow: resolveRiskRuleFlow(rulePayload, riskRuleFields),
|
||||
riskRuleFlowDiagramSvg:
|
||||
normalizeText(apiPayload?.flow_diagram_svg) || resolveRiskRuleFlowDiagramSvg(rulePayload),
|
||||
riskRuleSummary: {
|
||||
name: apiPayload?.name || target.name,
|
||||
evaluator: apiPayload?.evaluator || rulePayload.evaluator || '',
|
||||
@@ -1219,6 +1242,7 @@ export function buildDetailViewModel(detail, runs) {
|
||||
statusValue: detail.status,
|
||||
statusTone: statusMeta.tone,
|
||||
hitRate: buildRowMetric(detail, typeKey),
|
||||
createdAt: detail.created_at,
|
||||
updatedAt: formatDateTime(detail.updated_at),
|
||||
badgeTone: tabMeta.badgeTone,
|
||||
configJson,
|
||||
@@ -1227,8 +1251,17 @@ export function buildDetailViewModel(detail, runs) {
|
||||
riskRuleJsonText: '{}',
|
||||
riskRuleSummary: null,
|
||||
riskRuleDescription: '',
|
||||
riskRuleBusinessDescription: '',
|
||||
riskRuleSubtitle: usesJsonRiskRule ? buildRiskListSubtitle(detail.description) : '',
|
||||
riskRuleSourceRef: '',
|
||||
riskRuleSeverity: 'medium',
|
||||
riskRuleSeverityLabel: '中风险',
|
||||
riskRuleCreatedAt: formatDateTime(detail.created_at),
|
||||
riskRuleAgeLabel: formatRiskRuleAge(detail.created_at),
|
||||
riskRuleFields: [],
|
||||
riskRuleFieldSummary: '未识别字段',
|
||||
riskRuleFlow: resolveRiskRuleFlow({}, []),
|
||||
riskRuleFlowDiagramSvg: normalizeText(configJson.flow_diagram_svg),
|
||||
riskCategory: typeKey === 'rules' ? ruleScenarioCategory : '',
|
||||
ruleDocument,
|
||||
scenarioList: typeKey === 'rules' && ruleScenarioCategory
|
||||
|
||||
166
web/src/views/scripts/auditViewRiskRuleModel.js
Normal file
166
web/src/views/scripts/auditViewRiskRuleModel.js
Normal file
@@ -0,0 +1,166 @@
|
||||
export const RISK_RULE_CREATE_DOMAIN_OPTIONS = [
|
||||
{ value: 'expense', label: '报销' },
|
||||
{ value: 'ar', label: '应收' },
|
||||
{ value: 'ap', label: '应付' }
|
||||
]
|
||||
|
||||
export const RISK_RULE_LEVEL_OPTIONS = [
|
||||
{ value: 'medium', label: '中风险' },
|
||||
{ value: 'high', label: '高风险' },
|
||||
{ value: 'low', label: '低风险' }
|
||||
]
|
||||
|
||||
const RISK_LEVEL_LABELS = {
|
||||
low: '低风险',
|
||||
medium: '中风险',
|
||||
high: '高风险'
|
||||
}
|
||||
|
||||
export function createDefaultRiskRuleForm() {
|
||||
return {
|
||||
business_domain: 'expense',
|
||||
risk_level: 'medium',
|
||||
natural_language: ''
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeRiskRuleText(value) {
|
||||
return String(value || '').trim()
|
||||
}
|
||||
|
||||
export function formatRiskRuleFieldDisplay(field) {
|
||||
const key = normalizeRiskRuleText(field?.key)
|
||||
const label = normalizeRiskRuleText(field?.label || key)
|
||||
if (label && key && label !== key) {
|
||||
return `${label}[${key}]`
|
||||
}
|
||||
return label || key
|
||||
}
|
||||
|
||||
export function resolveRiskRuleSeverity(payload) {
|
||||
const outcomes = payload && typeof payload === 'object' ? payload.outcomes || {} : {}
|
||||
const fail = outcomes && typeof outcomes.fail === 'object' ? outcomes.fail : {}
|
||||
const severity = normalizeRiskRuleText(fail.severity || payload?.severity).toLowerCase()
|
||||
return ['low', 'medium', 'high'].includes(severity) ? severity : 'medium'
|
||||
}
|
||||
|
||||
export function resolveRiskRuleSeverityLabel(payload) {
|
||||
return RISK_LEVEL_LABELS[resolveRiskRuleSeverity(payload)] || '中风险'
|
||||
}
|
||||
|
||||
export function resolveRiskRuleFields(payload) {
|
||||
const inputs = payload && typeof payload === 'object' ? payload.inputs || {} : {}
|
||||
const fieldRows = Array.isArray(inputs.fields) ? inputs.fields : []
|
||||
if (fieldRows.length) {
|
||||
return fieldRows
|
||||
.map((item) => ({
|
||||
key: normalizeRiskRuleText(item?.key),
|
||||
label: normalizeRiskRuleText(item?.label || item?.key),
|
||||
display: formatRiskRuleFieldDisplay(item),
|
||||
source: normalizeRiskRuleText(item?.source),
|
||||
type: normalizeRiskRuleText(item?.type)
|
||||
}))
|
||||
.filter((item) => item.key || item.label)
|
||||
}
|
||||
|
||||
return Object.entries(inputs)
|
||||
.map(([label, key]) => ({
|
||||
key: normalizeRiskRuleText(key),
|
||||
label: normalizeRiskRuleText(label),
|
||||
display: formatRiskRuleFieldDisplay({ key, label }),
|
||||
source: '',
|
||||
type: ''
|
||||
}))
|
||||
.filter((item) => item.key || item.label)
|
||||
}
|
||||
|
||||
export function buildRiskRuleFieldSummary(fields) {
|
||||
const labels = fields.map(formatRiskRuleFieldDisplay).filter(Boolean)
|
||||
if (!labels.length) {
|
||||
return '未识别字段'
|
||||
}
|
||||
if (labels.length <= 4) {
|
||||
return labels.join('、')
|
||||
}
|
||||
return `${labels.slice(0, 4).join('、')} 等 ${labels.length} 项`
|
||||
}
|
||||
|
||||
export function resolveRiskRuleCreatedAt(payload, fallback) {
|
||||
const metadata = payload && typeof payload === 'object' ? payload.metadata || {} : {}
|
||||
return normalizeRiskRuleText(metadata.created_at || fallback)
|
||||
}
|
||||
|
||||
export function formatRiskRuleAge(value) {
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '未记录'
|
||||
}
|
||||
const diffMs = Date.now() - date.getTime()
|
||||
if (diffMs < 0) {
|
||||
return '刚刚创建'
|
||||
}
|
||||
const minutes = Math.floor(diffMs / 60000)
|
||||
if (minutes < 1) {
|
||||
return '刚刚创建'
|
||||
}
|
||||
if (minutes < 60) {
|
||||
return `${minutes} 分钟`
|
||||
}
|
||||
const hours = Math.floor(minutes / 60)
|
||||
if (hours < 24) {
|
||||
return `${hours} 小时`
|
||||
}
|
||||
const days = Math.floor(hours / 24)
|
||||
if (days < 30) {
|
||||
return `${days} 天`
|
||||
}
|
||||
const months = Math.floor(days / 30)
|
||||
if (months < 12) {
|
||||
return `${months} 个月`
|
||||
}
|
||||
return `${Math.floor(months / 12)} 年`
|
||||
}
|
||||
|
||||
export function resolveRiskRuleBusinessDescription(payload, fallback) {
|
||||
const metadata = payload && typeof payload === 'object' ? payload.metadata || {} : {}
|
||||
return (
|
||||
normalizeRiskRuleText(metadata.business_explanation) ||
|
||||
normalizeRiskRuleText(payload?.description) ||
|
||||
normalizeRiskRuleText(fallback)
|
||||
)
|
||||
}
|
||||
|
||||
export function resolveRiskRuleFlowDiagramSvg(payload) {
|
||||
const metadata = payload && typeof payload === 'object' ? payload.metadata || {} : {}
|
||||
return (
|
||||
normalizeRiskRuleText(payload?.flow_diagram_svg) ||
|
||||
normalizeRiskRuleText(metadata.flow_diagram_svg)
|
||||
)
|
||||
}
|
||||
|
||||
export function resolveRiskRuleConditionSummary(payload) {
|
||||
const metadata = payload && typeof payload === 'object' ? payload.metadata || {} : {}
|
||||
const params = payload && typeof payload === 'object' ? payload.params || {} : {}
|
||||
return (
|
||||
normalizeRiskRuleText(metadata.condition_summary) ||
|
||||
normalizeRiskRuleText(params.condition_summary) ||
|
||||
'根据规则字段判断是否命中风险'
|
||||
)
|
||||
}
|
||||
|
||||
export function resolveRiskRuleFlow(payload, fields) {
|
||||
const metadata = payload && typeof payload === 'object' ? payload.metadata || {} : {}
|
||||
const flow = metadata && typeof metadata.flow === 'object' ? metadata.flow : {}
|
||||
const fieldSummary = buildRiskRuleFieldSummary(fields)
|
||||
const conditionSummary = resolveRiskRuleConditionSummary(payload)
|
||||
const severityLabel = resolveRiskRuleSeverityLabel(payload)
|
||||
|
||||
return {
|
||||
start: normalizeRiskRuleText(flow.start) || '业务单据提交',
|
||||
evidence: normalizeRiskRuleText(flow.evidence) || `读取 ${fieldSummary}`,
|
||||
decision: normalizeRiskRuleText(flow.decision) || conditionSummary,
|
||||
basis: conditionSummary,
|
||||
pass: normalizeRiskRuleText(flow.pass) || '未命中风险,继续流转',
|
||||
fail: normalizeRiskRuleText(flow.fail) || `命中${severityLabel},进入人工复核`
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,11 @@ import { buildSuggestedActionKey } from '../../utils/suggestedActionKey.js'
|
||||
import { normalizeExpenseQueryPayload } from './travelReimbursementExpenseQueryModel.js'
|
||||
import { buildAgentInsight, buildReviewFilePreviewsFromReviewPayload } from './travelReimbursementAttachmentModel.js'
|
||||
import { resolveExpenseTypeLabel } from './travelReimbursementReviewModel.js'
|
||||
import {
|
||||
GUIDED_ACTION_OPEN_TRAVEL_CALCULATOR,
|
||||
GUIDED_ACTION_START_REIMBURSEMENT,
|
||||
GUIDED_ACTION_START_STATUS_QUERY
|
||||
} from './travelReimbursementGuidedFlowModel.js'
|
||||
|
||||
export const SESSION_TYPE_EXPENSE = 'expense'
|
||||
export const SESSION_TYPE_KNOWLEDGE = 'knowledge'
|
||||
@@ -88,34 +93,19 @@ export const ASSISTANT_DISPLAY_NAME = '财务助手'
|
||||
|
||||
export const EXPENSE_WELCOME_QUICK_ACTIONS = [
|
||||
{
|
||||
label: '发起差旅报销',
|
||||
prompt: '我要报销一笔出差费用,请帮我说明需要准备的材料,并引导我上传票据。',
|
||||
icon: 'mdi mdi-bag-suitcase-outline'
|
||||
label: '快速发起报销',
|
||||
action: GUIDED_ACTION_START_REIMBURSEMENT,
|
||||
icon: 'mdi mdi-receipt-text-plus-outline'
|
||||
},
|
||||
{
|
||||
label: '招待费报销',
|
||||
prompt: '我要报销客户招待餐费,请告诉我需要补充的客户、参与人员和票据要求。',
|
||||
icon: 'mdi mdi-food-fork-drink'
|
||||
label: '查询单据状态',
|
||||
action: GUIDED_ACTION_START_STATUS_QUERY,
|
||||
icon: 'mdi mdi-file-search-outline'
|
||||
},
|
||||
{
|
||||
label: '交通费报销',
|
||||
prompt: '我要报销交通出行费用,请帮我识别场景并列出待补充信息。',
|
||||
icon: 'mdi mdi-car-outline'
|
||||
},
|
||||
{
|
||||
label: '上传票据识别',
|
||||
prompt: '我已准备好票据,请帮我识别并整理报销核对信息。',
|
||||
icon: 'mdi mdi-file-upload-outline'
|
||||
},
|
||||
{
|
||||
label: '查询近期报销',
|
||||
prompt: '帮我查询近10天的报销记录和金额汇总。',
|
||||
icon: 'mdi mdi-chart-timeline-variant'
|
||||
},
|
||||
{
|
||||
label: '解释报销风险',
|
||||
prompt: '请结合公司制度,说明酒店超标、发票抬头不一致等常见报销风险。',
|
||||
icon: 'mdi mdi-shield-alert-outline'
|
||||
label: '差旅计算器',
|
||||
action: GUIDED_ACTION_OPEN_TRAVEL_CALCULATOR,
|
||||
icon: 'mdi mdi-calculator-variant-outline'
|
||||
}
|
||||
]
|
||||
|
||||
@@ -436,22 +426,6 @@ export function buildWelcomeQuickActions(sessionType, user, entrySource, linkedR
|
||||
}))
|
||||
}
|
||||
|
||||
if (entrySource === 'detail' && linkedRequest?.id) {
|
||||
return [
|
||||
{
|
||||
label: '补充当前单据票据',
|
||||
prompt: `请结合单据 ${linkedRequest.id},帮我继续补充票据并更新识别结果。`,
|
||||
icon: 'mdi mdi-file-plus-outline'
|
||||
},
|
||||
{
|
||||
label: '解释本单风险',
|
||||
prompt: `请解释单据 ${linkedRequest.id} 当前存在的报销风险与处理建议。`,
|
||||
icon: 'mdi mdi-shield-alert-outline'
|
||||
},
|
||||
...EXPENSE_WELCOME_QUICK_ACTIONS.slice(0, 4)
|
||||
]
|
||||
}
|
||||
|
||||
return EXPENSE_WELCOME_QUICK_ACTIONS
|
||||
}
|
||||
|
||||
|
||||
508
web/src/views/scripts/travelReimbursementGuidedFlowModel.js
Normal file
508
web/src/views/scripts/travelReimbursementGuidedFlowModel.js
Normal file
@@ -0,0 +1,508 @@
|
||||
export const GUIDED_FLOW_MODE_NONE = ''
|
||||
export const GUIDED_FLOW_MODE_REIMBURSEMENT = 'reimbursement_guide'
|
||||
export const GUIDED_FLOW_MODE_STATUS_QUERY = 'status_query_guide'
|
||||
|
||||
export const GUIDED_ACTION_START_REIMBURSEMENT = 'start_guided_reimbursement'
|
||||
export const GUIDED_ACTION_START_STATUS_QUERY = 'start_guided_status_query'
|
||||
export const GUIDED_ACTION_OPEN_TRAVEL_CALCULATOR = 'open_travel_calculator'
|
||||
export const GUIDED_ACTION_SELECT_EXPENSE_TYPE = 'guided_select_expense_type'
|
||||
export const GUIDED_ACTION_CONFIRM_REIMBURSEMENT_REVIEW = 'guided_confirm_reimbursement_review'
|
||||
export const GUIDED_ACTION_CONTINUE_FILLING = 'guided_continue_filling'
|
||||
export const GUIDED_ACTION_PROCESS_INTERRUPTION = 'guided_process_interruption'
|
||||
export const GUIDED_ACTION_SELECT_QUERY_MODE = 'guided_select_query_mode'
|
||||
export const GUIDED_ACTION_SELECT_QUERY_STATUS = 'guided_select_query_status'
|
||||
|
||||
export const GUIDED_EXPENSE_TYPES = [
|
||||
{ key: 'travel', label: '差旅费', description: '出差、跨城交通、住宿和补贴', icon: 'mdi mdi-bag-suitcase-outline' },
|
||||
{ key: 'transport', label: '交通费', description: '市内交通、打车、停车和通行费', icon: 'mdi mdi-car-outline' },
|
||||
{ key: 'hotel', label: '住宿费', description: '单独住宿或酒店票据', icon: 'mdi mdi-bed-outline' },
|
||||
{ key: 'meal', label: '业务招待费', description: '客户接待、餐饮和工作招待', icon: 'mdi mdi-food-fork-drink' },
|
||||
{ key: 'office', label: '办公用品费', description: '办公用品、文具和低值易耗品', icon: 'mdi mdi-briefcase-outline' },
|
||||
{ key: 'other', label: '其他费用', description: '暂不属于以上类型的费用', icon: 'mdi mdi-dots-horizontal-circle-outline' }
|
||||
]
|
||||
|
||||
const GUIDED_REIMBURSEMENT_STEPS = {
|
||||
travel: [
|
||||
{ key: 'reason', summaryLabel: '事由', prompt: '请先告诉我本次出差事由,例如:去上海支持项目部署。' },
|
||||
{ key: 'location', summaryLabel: '出差地点', prompt: '本次出差地点是哪里?可以回复城市或具体客户地点。' },
|
||||
{ key: 'time_range', summaryLabel: '出差时间/天数', prompt: '请补充出差时间或天数,例如:2026-05-20 至 2026-05-23,出差 3 天。' },
|
||||
{ key: 'amount', summaryLabel: '金额', prompt: '请补充本次预计或实际报销金额。如果还没有汇总,可以回复“待核算”。' },
|
||||
{ key: 'attachments', summaryLabel: '票据', prompt: '票据可以现在上传,也可以回复“稍后上传”。上传后我会在生成核对信息时一起处理。' }
|
||||
],
|
||||
transport: [
|
||||
{ key: 'reason', summaryLabel: '出行事由', prompt: '请说明本次交通费事由,例如:送客户去机场。' },
|
||||
{ key: 'time_range', summaryLabel: '出行时间', prompt: '请补充出行时间,例如:2026-05-20 下午。' },
|
||||
{ key: 'location', summaryLabel: '路线/地点', prompt: '请补充出行路线或地点,例如:公司至机场。' },
|
||||
{ key: 'amount', summaryLabel: '金额', prompt: '请补充交通费金额。如果票据里再识别金额,可以回复“以票据为准”。' },
|
||||
{ key: 'attachments', summaryLabel: '票据', prompt: '请上传出租车、网约车、停车或通行费等票据;也可以回复“稍后上传”。' }
|
||||
],
|
||||
hotel: [
|
||||
{ key: 'reason', summaryLabel: '住宿事由', prompt: '请说明住宿事由,例如:项目现场支持期间住宿。' },
|
||||
{ key: 'location', summaryLabel: '城市/酒店地点', prompt: '住宿城市或酒店地点是哪里?' },
|
||||
{ key: 'time_range', summaryLabel: '入住离店时间', prompt: '请补充入住和离店时间,例如:2026-05-20 至 2026-05-23。' },
|
||||
{ key: 'amount', summaryLabel: '金额', prompt: '请补充住宿金额。如果还没有汇总,可以回复“待核算”。' },
|
||||
{ key: 'attachments', summaryLabel: '票据', prompt: '请上传酒店发票或住宿水单;也可以回复“稍后上传”。' }
|
||||
],
|
||||
meal: [
|
||||
{ key: 'customer_name', summaryLabel: '客户单位', prompt: '请补充客户单位或接待对象。' },
|
||||
{ key: 'participants', summaryLabel: '参与人员', prompt: '请补充参与人员,例如:客户 2 人,我方 1 人。' },
|
||||
{ key: 'time_range', summaryLabel: '招待时间', prompt: '请补充招待时间,例如:2026-05-20 晚。' },
|
||||
{ key: 'location', summaryLabel: '招待地点', prompt: '请补充招待地点或商户名称。' },
|
||||
{ key: 'amount', summaryLabel: '金额', prompt: '请补充招待金额。' },
|
||||
{ key: 'reason', summaryLabel: '事由', prompt: '请补充招待事由,例如:项目沟通或客户接待。' },
|
||||
{ key: 'attachments', summaryLabel: '票据', prompt: '请上传餐饮发票或相关凭证;也可以回复“稍后上传”。' }
|
||||
],
|
||||
office: [
|
||||
{ key: 'reason', summaryLabel: '采购用途', prompt: '请说明采购用途,例如:项目现场临时采购办公用品。' },
|
||||
{ key: 'location', summaryLabel: '商户/采购地点', prompt: '请补充商户或采购地点。' },
|
||||
{ key: 'time_range', summaryLabel: '发生时间', prompt: '请补充费用发生时间。' },
|
||||
{ key: 'amount', summaryLabel: '金额', prompt: '请补充办公用品金额。' },
|
||||
{ key: 'attachments', summaryLabel: '票据', prompt: '请上传办公用品发票或购物凭证;也可以回复“稍后上传”。' }
|
||||
],
|
||||
other: [
|
||||
{ key: 'reason', summaryLabel: '费用说明', prompt: '请说明这笔费用的具体内容和用途。' },
|
||||
{ key: 'time_range', summaryLabel: '发生时间', prompt: '请补充费用发生时间。' },
|
||||
{ key: 'location', summaryLabel: '地点/对象', prompt: '请补充费用发生地点或关联对象。' },
|
||||
{ key: 'amount', summaryLabel: '金额', prompt: '请补充费用金额。' },
|
||||
{ key: 'attachments', summaryLabel: '票据', prompt: '请上传相关票据;也可以回复“稍后上传”。' }
|
||||
]
|
||||
}
|
||||
|
||||
export const GUIDED_QUERY_MODES = [
|
||||
{ key: 'claim_no', label: '按单号', description: '输入报销单号精准查询', icon: 'mdi mdi-pound' },
|
||||
{ key: 'status', label: '按状态', description: '查询草稿、审批中或已归档单据', icon: 'mdi mdi-list-status' },
|
||||
{ key: 'time_range', label: '按时间范围', description: '例如上周、去年、2026-05', icon: 'mdi mdi-calendar-search-outline' },
|
||||
{ key: 'keyword', label: '按地点/事由', description: '例如北京、上海电力、服务器部署', icon: 'mdi mdi-map-search-outline' }
|
||||
]
|
||||
|
||||
export const GUIDED_QUERY_STATUS_OPTIONS = [
|
||||
{ key: 'draft', label: '草稿', description: '还没有正式提交的单据' },
|
||||
{ key: 'pending', label: '审批中', description: '正在流转审批的单据' },
|
||||
{ key: 'returned', label: '已退回', description: '需要补充或修改的单据' },
|
||||
{ key: 'archived', label: '已归档', description: '已完成归档的单据' },
|
||||
{ key: 'completed', label: '已完成', description: '已审核完成或已入账的单据' }
|
||||
]
|
||||
|
||||
const NO_ATTACHMENT_TEXT_PATTERN = /^(稍后|暂不|不用|没有|待上传|后面|后续|先不|以票据为准)/u
|
||||
const INTERRUPTION_PATTERN = /(查一下|查询|状态|报销了吗|报销了么|多少|总额|标准|制度|规则|为什么|怎么|可以吗|能不能|差旅计算器|计算一下|解释|风险|打开|跳转|查看|审批|归档|入账|[??])/u
|
||||
|
||||
function uniqueValues(values) {
|
||||
return Array.from(new Set((Array.isArray(values) ? values : []).map((item) => String(item || '').trim()).filter(Boolean)))
|
||||
}
|
||||
|
||||
function normalizeText(value) {
|
||||
return String(value || '').trim()
|
||||
}
|
||||
|
||||
function normalizeValues(values) {
|
||||
if (!values || typeof values !== 'object') {
|
||||
return {}
|
||||
}
|
||||
return Object.entries(values).reduce((result, [key, value]) => {
|
||||
if (key === 'attachment_names') {
|
||||
result[key] = uniqueValues(value)
|
||||
return result
|
||||
}
|
||||
result[key] = normalizeText(value)
|
||||
return result
|
||||
}, {})
|
||||
}
|
||||
|
||||
export function createEmptyGuidedFlowState() {
|
||||
return {
|
||||
mode: GUIDED_FLOW_MODE_NONE,
|
||||
stepKey: '',
|
||||
expenseType: '',
|
||||
values: {},
|
||||
pendingInterruptionText: ''
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeGuidedFlowState(state) {
|
||||
const source = state && typeof state === 'object' ? state : {}
|
||||
const mode = normalizeText(source.mode)
|
||||
const supportedMode = [GUIDED_FLOW_MODE_REIMBURSEMENT, GUIDED_FLOW_MODE_STATUS_QUERY].includes(mode)
|
||||
? mode
|
||||
: GUIDED_FLOW_MODE_NONE
|
||||
if (!supportedMode) {
|
||||
return createEmptyGuidedFlowState()
|
||||
}
|
||||
|
||||
return {
|
||||
mode: supportedMode,
|
||||
stepKey: normalizeText(source.stepKey),
|
||||
expenseType: normalizeText(source.expenseType),
|
||||
values: normalizeValues(source.values),
|
||||
pendingInterruptionText: normalizeText(source.pendingInterruptionText)
|
||||
}
|
||||
}
|
||||
|
||||
export function isGuidedFlowActive(state) {
|
||||
return Boolean(normalizeGuidedFlowState(state).mode)
|
||||
}
|
||||
|
||||
export function getGuidedExpenseType(expenseType) {
|
||||
const key = normalizeText(expenseType)
|
||||
return GUIDED_EXPENSE_TYPES.find((item) => item.key === key) || null
|
||||
}
|
||||
|
||||
export function getGuidedExpenseTypeLabel(expenseType) {
|
||||
return getGuidedExpenseType(expenseType)?.label || ''
|
||||
}
|
||||
|
||||
export function buildGuidedExpenseTypeActions() {
|
||||
return GUIDED_EXPENSE_TYPES.map((option) => ({
|
||||
label: option.label,
|
||||
description: option.description,
|
||||
icon: option.icon,
|
||||
action_type: GUIDED_ACTION_SELECT_EXPENSE_TYPE,
|
||||
payload: {
|
||||
expense_type: option.key,
|
||||
expense_type_label: option.label
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
export function buildGuidedReimbursementStartText() {
|
||||
return [
|
||||
'请问你要报销的类型?',
|
||||
'',
|
||||
'先选一个最贴近的费用场景,我会按对应流程逐项询问。这个过程只做本地引导,不会自动创建草稿。'
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
export function createGuidedReimbursementState() {
|
||||
return {
|
||||
...createEmptyGuidedFlowState(),
|
||||
mode: GUIDED_FLOW_MODE_REIMBURSEMENT,
|
||||
stepKey: 'expense_type'
|
||||
}
|
||||
}
|
||||
|
||||
export function selectGuidedExpenseType(state, expenseType) {
|
||||
const type = getGuidedExpenseType(expenseType)
|
||||
if (!type) {
|
||||
return normalizeGuidedFlowState(state)
|
||||
}
|
||||
const steps = GUIDED_REIMBURSEMENT_STEPS[type.key] || []
|
||||
return {
|
||||
...normalizeGuidedFlowState(state),
|
||||
mode: GUIDED_FLOW_MODE_REIMBURSEMENT,
|
||||
expenseType: type.key,
|
||||
stepKey: steps[0]?.key || 'summary',
|
||||
pendingInterruptionText: ''
|
||||
}
|
||||
}
|
||||
|
||||
export function getGuidedReimbursementSteps(expenseType) {
|
||||
const key = normalizeText(expenseType)
|
||||
return GUIDED_REIMBURSEMENT_STEPS[key] || []
|
||||
}
|
||||
|
||||
export function getCurrentGuidedStep(state) {
|
||||
const current = normalizeGuidedFlowState(state)
|
||||
if (current.mode !== GUIDED_FLOW_MODE_REIMBURSEMENT || !current.expenseType) {
|
||||
return null
|
||||
}
|
||||
return getGuidedReimbursementSteps(current.expenseType).find((step) => step.key === current.stepKey) || null
|
||||
}
|
||||
|
||||
export function buildGuidedStepPromptText(state) {
|
||||
const current = normalizeGuidedFlowState(state)
|
||||
const step = getCurrentGuidedStep(current)
|
||||
const typeLabel = getGuidedExpenseTypeLabel(current.expenseType)
|
||||
if (!step || !typeLabel) {
|
||||
return buildGuidedReimbursementStartText()
|
||||
}
|
||||
const steps = getGuidedReimbursementSteps(current.expenseType)
|
||||
const stepIndex = Math.max(0, steps.findIndex((item) => item.key === step.key))
|
||||
return [
|
||||
`已选择“${typeLabel}”。`,
|
||||
'',
|
||||
`第 ${stepIndex + 1} 步:${step.summaryLabel}`,
|
||||
step.prompt,
|
||||
'',
|
||||
'直接回复这一项即可。'
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
export function resolveGuidedExpenseTypeFromText(text) {
|
||||
const normalized = normalizeText(text)
|
||||
if (!normalized) {
|
||||
return ''
|
||||
}
|
||||
const exact = GUIDED_EXPENSE_TYPES.find((item) => normalized === item.label || normalized === item.key)
|
||||
if (exact) {
|
||||
return exact.key
|
||||
}
|
||||
const matched = GUIDED_EXPENSE_TYPES.find((item) => normalized.includes(item.label))
|
||||
return matched?.key || ''
|
||||
}
|
||||
|
||||
export function applyGuidedReimbursementAnswer(state, answerText, attachmentNames = []) {
|
||||
const current = normalizeGuidedFlowState(state)
|
||||
const step = getCurrentGuidedStep(current)
|
||||
if (!step) {
|
||||
return current
|
||||
}
|
||||
|
||||
const answer = normalizeText(answerText)
|
||||
const nextValues = { ...current.values }
|
||||
if (step.key === 'attachments') {
|
||||
const nextAttachmentNames = uniqueValues([
|
||||
...(Array.isArray(nextValues.attachment_names) ? nextValues.attachment_names : []),
|
||||
...attachmentNames
|
||||
])
|
||||
if (nextAttachmentNames.length) {
|
||||
nextValues.attachment_names = nextAttachmentNames
|
||||
}
|
||||
nextValues.attachments = answer || (nextAttachmentNames.length ? `已选择 ${nextAttachmentNames.length} 份附件` : '稍后上传')
|
||||
} else {
|
||||
nextValues[step.key] = answer
|
||||
}
|
||||
|
||||
const steps = getGuidedReimbursementSteps(current.expenseType)
|
||||
const currentIndex = steps.findIndex((item) => item.key === step.key)
|
||||
const nextStep = steps[currentIndex + 1]
|
||||
return {
|
||||
...current,
|
||||
values: normalizeValues(nextValues),
|
||||
stepKey: nextStep?.key || 'summary',
|
||||
pendingInterruptionText: ''
|
||||
}
|
||||
}
|
||||
|
||||
export function isGuidedReimbursementReadyForReview(state) {
|
||||
const current = normalizeGuidedFlowState(state)
|
||||
return current.mode === GUIDED_FLOW_MODE_REIMBURSEMENT
|
||||
&& Boolean(current.expenseType)
|
||||
&& current.stepKey === 'summary'
|
||||
}
|
||||
|
||||
export function buildGuidedReimbursementSummaryText(state) {
|
||||
const current = normalizeGuidedFlowState(state)
|
||||
const typeLabel = getGuidedExpenseTypeLabel(current.expenseType) || '报销'
|
||||
const steps = getGuidedReimbursementSteps(current.expenseType)
|
||||
const lines = [
|
||||
`已完成“${typeLabel}”的引导填写。`,
|
||||
'',
|
||||
'请核查下面的关键信息:'
|
||||
]
|
||||
|
||||
steps.forEach((step) => {
|
||||
const value = step.key === 'attachments'
|
||||
? (current.values.attachment_names?.length
|
||||
? current.values.attachment_names.join('、')
|
||||
: current.values.attachments || '稍后上传')
|
||||
: current.values[step.key]
|
||||
lines.push(`- ${step.summaryLabel}:${value || '待补充'}`)
|
||||
})
|
||||
|
||||
lines.push('')
|
||||
lines.push('如果这些信息无误,我可以继续生成右侧报销核对信息;生成核对信息后,再由你决定保存草稿或继续下一步。')
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
export function buildGuidedReviewConfirmationActions() {
|
||||
return [{
|
||||
label: '生成报销核对信息',
|
||||
description: '进入现有报销核对流程,不会直接保存草稿',
|
||||
icon: 'mdi mdi-clipboard-check-outline',
|
||||
action_type: GUIDED_ACTION_CONFIRM_REIMBURSEMENT_REVIEW
|
||||
}]
|
||||
}
|
||||
|
||||
export function buildGuidedReviewSubmitOptions(state, files = []) {
|
||||
const current = normalizeGuidedFlowState(state)
|
||||
const type = getGuidedExpenseType(current.expenseType)
|
||||
const values = current.values || {}
|
||||
const typeLabel = type?.label || '其他费用'
|
||||
const fieldLines = getGuidedReimbursementSteps(current.expenseType).map((step) => {
|
||||
const value = step.key === 'attachments'
|
||||
? (values.attachment_names?.length ? values.attachment_names.join('、') : values.attachments || '稍后上传')
|
||||
: values[step.key]
|
||||
return `${step.summaryLabel}:${value || '待补充'}`
|
||||
})
|
||||
const rawText = [
|
||||
`报销类型:${typeLabel}`,
|
||||
...fieldLines
|
||||
].join('\n')
|
||||
const reviewFormValues = {
|
||||
expense_type: typeLabel,
|
||||
reimbursement_type: typeLabel,
|
||||
reason: values.reason || values.customer_name || '',
|
||||
reason_value: values.reason || '',
|
||||
customer_name: values.customer_name || '',
|
||||
participants: values.participants || '',
|
||||
location: values.location || '',
|
||||
business_location: values.location || '',
|
||||
time_range: values.time_range || '',
|
||||
business_time: values.time_range || '',
|
||||
amount: values.amount || '',
|
||||
attachment_names: Array.isArray(values.attachment_names) ? values.attachment_names : []
|
||||
}
|
||||
|
||||
return {
|
||||
rawText,
|
||||
userText: '生成报销核对信息',
|
||||
pendingText: '正在生成右侧报销核对信息...',
|
||||
systemGenerated: true,
|
||||
files,
|
||||
extraContext: {
|
||||
draft_claim_id: '',
|
||||
user_input_text: rawText,
|
||||
expense_scene_selection: {
|
||||
expense_type: type?.key || current.expenseType || 'other',
|
||||
expense_type_label: typeLabel,
|
||||
original_message: rawText
|
||||
},
|
||||
review_form_values: reviewFormValues
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function shouldConfirmGuidedInterruption(text, state) {
|
||||
const current = normalizeGuidedFlowState(state)
|
||||
if (!current.mode || current.pendingInterruptionText) {
|
||||
return false
|
||||
}
|
||||
const normalized = normalizeText(text)
|
||||
if (!normalized || NO_ATTACHMENT_TEXT_PATTERN.test(normalized)) {
|
||||
return false
|
||||
}
|
||||
return INTERRUPTION_PATTERN.test(normalized)
|
||||
}
|
||||
|
||||
export function buildGuidedInterruptionText(text) {
|
||||
return [
|
||||
`我看到你刚才输入的是:“${normalizeText(text)}”。`,
|
||||
'',
|
||||
'这看起来像一个新的问题。你想继续填写当前引导,还是先暂停当前引导并处理这个问题?'
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
export function buildGuidedInterruptionActions() {
|
||||
return [
|
||||
{
|
||||
label: '继续填写',
|
||||
description: '保留当前引导,继续回答这一项',
|
||||
icon: 'mdi mdi-pencil-outline',
|
||||
action_type: GUIDED_ACTION_CONTINUE_FILLING
|
||||
},
|
||||
{
|
||||
label: '暂停当前引导并处理这个问题',
|
||||
description: '暂停引导,把刚才输入交给财务助手处理',
|
||||
icon: 'mdi mdi-chat-processing-outline',
|
||||
action_type: GUIDED_ACTION_PROCESS_INTERRUPTION
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
export function createGuidedStatusQueryState() {
|
||||
return {
|
||||
...createEmptyGuidedFlowState(),
|
||||
mode: GUIDED_FLOW_MODE_STATUS_QUERY,
|
||||
stepKey: 'query_mode'
|
||||
}
|
||||
}
|
||||
|
||||
export function buildGuidedStatusQueryStartText() {
|
||||
return [
|
||||
'你想按什么条件查询单据状态?',
|
||||
'',
|
||||
'先选查询方式,我再向你收集对应条件。'
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
export function buildGuidedQueryModeActions() {
|
||||
return GUIDED_QUERY_MODES.map((option) => ({
|
||||
label: option.label,
|
||||
description: option.description,
|
||||
icon: option.icon,
|
||||
action_type: GUIDED_ACTION_SELECT_QUERY_MODE,
|
||||
payload: {
|
||||
query_mode: option.key,
|
||||
query_mode_label: option.label
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
export function buildGuidedQueryStatusActions() {
|
||||
return GUIDED_QUERY_STATUS_OPTIONS.map((option) => ({
|
||||
label: option.label,
|
||||
description: option.description,
|
||||
icon: 'mdi mdi-checkbox-marked-circle-outline',
|
||||
action_type: GUIDED_ACTION_SELECT_QUERY_STATUS,
|
||||
payload: {
|
||||
query_status: option.key,
|
||||
query_status_label: option.label
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
export function resolveGuidedQueryModeFromText(text) {
|
||||
const normalized = normalizeText(text)
|
||||
if (!normalized) return ''
|
||||
const exact = GUIDED_QUERY_MODES.find((item) => normalized === item.label || normalized === item.key)
|
||||
if (exact) return exact.key
|
||||
if (/单号|编号|EXP-/i.test(normalized)) return 'claim_no'
|
||||
if (/状态|草稿|审批|退回|归档|完成/.test(normalized)) return 'status'
|
||||
if (/上周|本周|去年|今年|月份|时间|日期|[0-9]{4}-[0-9]{2}/.test(normalized)) return 'time_range'
|
||||
return 'keyword'
|
||||
}
|
||||
|
||||
export function selectGuidedQueryMode(state, queryMode) {
|
||||
const current = normalizeGuidedFlowState(state)
|
||||
const mode = GUIDED_QUERY_MODES.find((item) => item.key === normalizeText(queryMode))
|
||||
if (!mode) {
|
||||
return current
|
||||
}
|
||||
return {
|
||||
...current,
|
||||
mode: GUIDED_FLOW_MODE_STATUS_QUERY,
|
||||
stepKey: mode.key === 'status' ? 'status_value' : 'query_value',
|
||||
values: {
|
||||
...current.values,
|
||||
query_mode: mode.key,
|
||||
query_mode_label: mode.label
|
||||
},
|
||||
pendingInterruptionText: ''
|
||||
}
|
||||
}
|
||||
|
||||
export function buildGuidedQueryPromptText(state) {
|
||||
const current = normalizeGuidedFlowState(state)
|
||||
const mode = normalizeText(current.values.query_mode)
|
||||
if (!mode) {
|
||||
return buildGuidedStatusQueryStartText()
|
||||
}
|
||||
if (mode === 'status') {
|
||||
return [
|
||||
'请选择要查询的单据状态。',
|
||||
'',
|
||||
'我会按所选状态筛选最近的报销单据。'
|
||||
].join('\n')
|
||||
}
|
||||
const prompts = {
|
||||
claim_no: '请输入报销单号,例如 EXP-202605-001。',
|
||||
time_range: '请输入查询时间范围,例如:上周、今年 5 月、2025 年全年。',
|
||||
keyword: '请输入地点、客户或事由关键词,例如:上海电力、北京、服务器部署。'
|
||||
}
|
||||
return prompts[mode] || '请补充查询条件。'
|
||||
}
|
||||
|
||||
export function buildGuidedStatusQueryText(state, valueText) {
|
||||
const current = normalizeGuidedFlowState(state)
|
||||
const mode = normalizeText(current.values.query_mode)
|
||||
const value = normalizeText(valueText)
|
||||
if (mode === 'claim_no') {
|
||||
return `帮我查询单号 ${value} 的报销单状态`
|
||||
}
|
||||
if (mode === 'status') {
|
||||
return `帮我查询${value}的报销单据,筛选最近的 5 条记录`
|
||||
}
|
||||
if (mode === 'time_range') {
|
||||
return `帮我查询${value}提交或发生的报销单据状态,筛选最近的 5 条记录`
|
||||
}
|
||||
return `帮我查询地点或事由包含“${value}”的报销单据状态,筛选最近的 5 条记录`
|
||||
}
|
||||
439
web/src/views/scripts/useTravelReimbursementGuidedFlow.js
Normal file
439
web/src/views/scripts/useTravelReimbursementGuidedFlow.js
Normal file
@@ -0,0 +1,439 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
import {
|
||||
GUIDED_ACTION_CONFIRM_REIMBURSEMENT_REVIEW,
|
||||
GUIDED_ACTION_CONTINUE_FILLING,
|
||||
GUIDED_ACTION_OPEN_TRAVEL_CALCULATOR,
|
||||
GUIDED_ACTION_PROCESS_INTERRUPTION,
|
||||
GUIDED_ACTION_SELECT_EXPENSE_TYPE,
|
||||
GUIDED_ACTION_SELECT_QUERY_MODE,
|
||||
GUIDED_ACTION_SELECT_QUERY_STATUS,
|
||||
GUIDED_ACTION_START_REIMBURSEMENT,
|
||||
GUIDED_ACTION_START_STATUS_QUERY,
|
||||
GUIDED_FLOW_MODE_REIMBURSEMENT,
|
||||
GUIDED_FLOW_MODE_STATUS_QUERY,
|
||||
applyGuidedReimbursementAnswer,
|
||||
buildGuidedExpenseTypeActions,
|
||||
buildGuidedInterruptionActions,
|
||||
buildGuidedInterruptionText,
|
||||
buildGuidedQueryModeActions,
|
||||
buildGuidedQueryPromptText,
|
||||
buildGuidedQueryStatusActions,
|
||||
buildGuidedReimbursementStartText,
|
||||
buildGuidedReimbursementSummaryText,
|
||||
buildGuidedReviewConfirmationActions,
|
||||
buildGuidedReviewSubmitOptions,
|
||||
buildGuidedStatusQueryStartText,
|
||||
buildGuidedStatusQueryText,
|
||||
buildGuidedStepPromptText,
|
||||
createEmptyGuidedFlowState,
|
||||
createGuidedReimbursementState,
|
||||
createGuidedStatusQueryState,
|
||||
getCurrentGuidedStep,
|
||||
isGuidedFlowActive,
|
||||
isGuidedReimbursementReadyForReview,
|
||||
normalizeGuidedFlowState,
|
||||
resolveGuidedExpenseTypeFromText,
|
||||
resolveGuidedQueryModeFromText,
|
||||
selectGuidedExpenseType,
|
||||
selectGuidedQueryMode,
|
||||
shouldConfirmGuidedInterruption
|
||||
} from './travelReimbursementGuidedFlowModel.js'
|
||||
|
||||
function normalizeText(value) {
|
||||
return String(value || '').trim()
|
||||
}
|
||||
|
||||
function buildFileNames(files) {
|
||||
return Array.from(files || [])
|
||||
.map((file) => normalizeText(file?.name))
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
function mergePendingFiles(currentFiles, nextFiles) {
|
||||
const merged = [...Array.from(currentFiles || [])]
|
||||
Array.from(nextFiles || []).forEach((file) => {
|
||||
const name = normalizeText(file?.name)
|
||||
if (!name) return
|
||||
const duplicated = merged.some((item) => normalizeText(item?.name) === name && Number(item?.size || 0) === Number(file?.size || 0))
|
||||
if (!duplicated) {
|
||||
merged.push(file)
|
||||
}
|
||||
})
|
||||
return merged
|
||||
}
|
||||
|
||||
export function useTravelReimbursementGuidedFlow({
|
||||
guidedFlowState,
|
||||
messages,
|
||||
composerDraft,
|
||||
attachedFiles,
|
||||
composerBusinessTimeTags,
|
||||
composerBusinessTimeDraftTouched,
|
||||
fileInputRef,
|
||||
submitting,
|
||||
reviewActionBusy,
|
||||
sessionSwitchBusy,
|
||||
createMessage,
|
||||
nextTick,
|
||||
scrollToBottom,
|
||||
persistSessionState,
|
||||
clearAttachedFiles,
|
||||
adjustComposerTextareaHeight,
|
||||
buildComposerBusinessTimeContext,
|
||||
openTravelCalculator,
|
||||
lockSuggestedActionMessage,
|
||||
submitExistingComposer,
|
||||
toast
|
||||
}) {
|
||||
const guidedPendingFiles = ref([])
|
||||
|
||||
function persistAndScroll() {
|
||||
persistSessionState()
|
||||
nextTick(() => {
|
||||
adjustComposerTextareaHeight?.()
|
||||
scrollToBottom?.()
|
||||
})
|
||||
}
|
||||
|
||||
function clearComposerRuntime() {
|
||||
composerDraft.value = ''
|
||||
clearAttachedFiles?.()
|
||||
if (fileInputRef?.value) {
|
||||
fileInputRef.value.value = ''
|
||||
}
|
||||
if (composerBusinessTimeTags) {
|
||||
composerBusinessTimeTags.value = []
|
||||
}
|
||||
if (composerBusinessTimeDraftTouched) {
|
||||
composerBusinessTimeDraftTouched.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function pushAssistant(text, extras = {}) {
|
||||
messages.value.push(createMessage('assistant', text, [], extras))
|
||||
}
|
||||
|
||||
function pushUser(text, attachmentNames = []) {
|
||||
const normalizedText = normalizeText(text)
|
||||
messages.value.push(createMessage('user', normalizedText || `上传 ${attachmentNames.length} 份附件`, attachmentNames))
|
||||
}
|
||||
|
||||
function resetGuidedFlowState() {
|
||||
guidedFlowState.value = createEmptyGuidedFlowState()
|
||||
guidedPendingFiles.value = []
|
||||
}
|
||||
|
||||
function startGuidedReimbursement() {
|
||||
guidedFlowState.value = createGuidedReimbursementState()
|
||||
guidedPendingFiles.value = []
|
||||
pushAssistant(buildGuidedReimbursementStartText(), {
|
||||
meta: ['引导式报销'],
|
||||
suggestedActions: buildGuidedExpenseTypeActions()
|
||||
})
|
||||
persistAndScroll()
|
||||
}
|
||||
|
||||
function startGuidedStatusQuery() {
|
||||
guidedFlowState.value = createGuidedStatusQueryState()
|
||||
guidedPendingFiles.value = []
|
||||
pushAssistant(buildGuidedStatusQueryStartText(), {
|
||||
meta: ['引导式查询'],
|
||||
suggestedActions: buildGuidedQueryModeActions()
|
||||
})
|
||||
persistAndScroll()
|
||||
}
|
||||
|
||||
function handleGuidedShortcut(shortcut) {
|
||||
const actionType = normalizeText(shortcut?.action)
|
||||
if (actionType === GUIDED_ACTION_START_REIMBURSEMENT) {
|
||||
startGuidedReimbursement()
|
||||
return true
|
||||
}
|
||||
if (actionType === GUIDED_ACTION_START_STATUS_QUERY) {
|
||||
startGuidedStatusQuery()
|
||||
return true
|
||||
}
|
||||
if (actionType === GUIDED_ACTION_OPEN_TRAVEL_CALCULATOR) {
|
||||
openTravelCalculator?.()
|
||||
pushAssistant('差旅计算器已打开。你可以直接填写目的地、天数和金额,我会按规则中心标准帮你测算。', {
|
||||
meta: ['差旅计算器']
|
||||
})
|
||||
persistAndScroll()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function buildAnswerText(rawText, state) {
|
||||
const text = normalizeText(rawText)
|
||||
if (text) {
|
||||
return text
|
||||
}
|
||||
const currentStep = getCurrentGuidedStep(state)
|
||||
if (currentStep?.key === 'time_range') {
|
||||
const businessTimeContext = buildComposerBusinessTimeContext?.()
|
||||
return normalizeText(businessTimeContext?.time_range || businessTimeContext?.business_time)
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function pushNextReimbursementPrompt() {
|
||||
pushAssistant(buildGuidedStepPromptText(guidedFlowState.value), {
|
||||
meta: ['引导式报销']
|
||||
})
|
||||
}
|
||||
|
||||
function pushReimbursementSummary() {
|
||||
pushAssistant(buildGuidedReimbursementSummaryText(guidedFlowState.value), {
|
||||
meta: ['待生成核对信息'],
|
||||
suggestedActions: buildGuidedReviewConfirmationActions()
|
||||
})
|
||||
}
|
||||
|
||||
function handleReimbursementAnswer(answerText, files) {
|
||||
const currentState = normalizeGuidedFlowState(guidedFlowState.value)
|
||||
const currentStep = getCurrentGuidedStep(currentState)
|
||||
const fileNames = buildFileNames(files)
|
||||
|
||||
if (currentState.stepKey === 'expense_type') {
|
||||
const expenseType = resolveGuidedExpenseTypeFromText(answerText)
|
||||
if (!expenseType) {
|
||||
pushAssistant('我还需要先确认报销类型。请点击下面最贴近的费用场景后,我再继续问下一项。', {
|
||||
meta: ['等待选择报销类型'],
|
||||
suggestedActions: buildGuidedExpenseTypeActions()
|
||||
})
|
||||
return
|
||||
}
|
||||
guidedFlowState.value = selectGuidedExpenseType(currentState, expenseType)
|
||||
pushNextReimbursementPrompt()
|
||||
return
|
||||
}
|
||||
|
||||
if (!currentStep) {
|
||||
pushAssistant(buildGuidedReimbursementStartText(), {
|
||||
meta: ['引导式报销'],
|
||||
suggestedActions: buildGuidedExpenseTypeActions()
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (!answerText && fileNames.length && currentStep.key !== 'attachments') {
|
||||
guidedPendingFiles.value = mergePendingFiles(guidedPendingFiles.value, files)
|
||||
pushAssistant([
|
||||
`我已先记录 ${fileNames.length} 份附件。`,
|
||||
'',
|
||||
`当前还需要补充:${currentStep.summaryLabel}。`,
|
||||
currentStep.prompt
|
||||
].join('\n'), {
|
||||
meta: ['已记录附件']
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (fileNames.length) {
|
||||
guidedPendingFiles.value = mergePendingFiles(guidedPendingFiles.value, files)
|
||||
}
|
||||
guidedFlowState.value = applyGuidedReimbursementAnswer(currentState, answerText, fileNames)
|
||||
if (isGuidedReimbursementReadyForReview(guidedFlowState.value)) {
|
||||
pushReimbursementSummary()
|
||||
return
|
||||
}
|
||||
pushNextReimbursementPrompt()
|
||||
}
|
||||
|
||||
async function runStatusQuery(queryText, skipUserMessage = true) {
|
||||
const normalizedQuery = normalizeText(queryText)
|
||||
resetGuidedFlowState()
|
||||
clearComposerRuntime()
|
||||
persistAndScroll()
|
||||
if (!normalizedQuery) {
|
||||
return true
|
||||
}
|
||||
await submitExistingComposer({
|
||||
rawText: normalizedQuery,
|
||||
userText: normalizedQuery,
|
||||
pendingText: '正在查询单据状态...',
|
||||
skipUserMessage
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
async function handleStatusQueryAnswer(answerText) {
|
||||
const currentState = normalizeGuidedFlowState(guidedFlowState.value)
|
||||
if (currentState.stepKey === 'query_mode') {
|
||||
const queryMode = resolveGuidedQueryModeFromText(answerText)
|
||||
if (!queryMode) {
|
||||
pushAssistant(buildGuidedStatusQueryStartText(), {
|
||||
meta: ['引导式查询'],
|
||||
suggestedActions: buildGuidedQueryModeActions()
|
||||
})
|
||||
return true
|
||||
}
|
||||
guidedFlowState.value = selectGuidedQueryMode(currentState, queryMode)
|
||||
const actions = guidedFlowState.value.stepKey === 'status_value' ? buildGuidedQueryStatusActions() : []
|
||||
pushAssistant(buildGuidedQueryPromptText(guidedFlowState.value), {
|
||||
meta: ['引导式查询'],
|
||||
suggestedActions: actions
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
const queryText = buildGuidedStatusQueryText(currentState, answerText)
|
||||
return runStatusQuery(queryText, true)
|
||||
}
|
||||
|
||||
async function handleGuidedComposerSubmit(options = {}) {
|
||||
const currentState = normalizeGuidedFlowState(guidedFlowState.value)
|
||||
if (!isGuidedFlowActive(currentState)) {
|
||||
return false
|
||||
}
|
||||
if (options.systemGenerated || normalizeText(options.extraContext?.review_action)) {
|
||||
return false
|
||||
}
|
||||
if (submitting.value || reviewActionBusy.value || sessionSwitchBusy.value) {
|
||||
return true
|
||||
}
|
||||
|
||||
const files = Array.from(options.files ?? attachedFiles.value ?? [])
|
||||
const fileNames = buildFileNames(files)
|
||||
const answerText = buildAnswerText(options.rawText ?? composerDraft.value, currentState)
|
||||
if (!answerText && !fileNames.length) {
|
||||
return true
|
||||
}
|
||||
|
||||
pushUser(answerText, fileNames)
|
||||
if (shouldConfirmGuidedInterruption(answerText, currentState) && !fileNames.length) {
|
||||
guidedFlowState.value = {
|
||||
...currentState,
|
||||
pendingInterruptionText: answerText
|
||||
}
|
||||
pushAssistant(buildGuidedInterruptionText(answerText), {
|
||||
meta: ['等待确认是否打断'],
|
||||
suggestedActions: buildGuidedInterruptionActions()
|
||||
})
|
||||
clearComposerRuntime()
|
||||
persistAndScroll()
|
||||
return true
|
||||
}
|
||||
|
||||
if (currentState.mode === GUIDED_FLOW_MODE_REIMBURSEMENT) {
|
||||
handleReimbursementAnswer(answerText, files)
|
||||
clearComposerRuntime()
|
||||
persistAndScroll()
|
||||
return true
|
||||
}
|
||||
|
||||
if (currentState.mode === GUIDED_FLOW_MODE_STATUS_QUERY) {
|
||||
clearComposerRuntime()
|
||||
persistAndScroll()
|
||||
await handleStatusQueryAnswer(answerText)
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
async function handleGuidedSuggestedAction(message, action) {
|
||||
const actionType = normalizeText(action?.action_type)
|
||||
if (!actionType) {
|
||||
return false
|
||||
}
|
||||
const guidedActionTypes = new Set([
|
||||
GUIDED_ACTION_SELECT_EXPENSE_TYPE,
|
||||
GUIDED_ACTION_CONFIRM_REIMBURSEMENT_REVIEW,
|
||||
GUIDED_ACTION_CONTINUE_FILLING,
|
||||
GUIDED_ACTION_PROCESS_INTERRUPTION,
|
||||
GUIDED_ACTION_SELECT_QUERY_MODE,
|
||||
GUIDED_ACTION_SELECT_QUERY_STATUS
|
||||
])
|
||||
if (!guidedActionTypes.has(actionType)) {
|
||||
return false
|
||||
}
|
||||
if (submitting.value || reviewActionBusy.value || sessionSwitchBusy.value || message?.suggestedActionsLocked) {
|
||||
return true
|
||||
}
|
||||
if (!lockSuggestedActionMessage(message, action)) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (actionType === GUIDED_ACTION_SELECT_EXPENSE_TYPE) {
|
||||
const expenseType = normalizeText(action?.payload?.expense_type)
|
||||
const expenseTypeLabel = normalizeText(action?.payload?.expense_type_label || action?.label)
|
||||
guidedFlowState.value = selectGuidedExpenseType(guidedFlowState.value, expenseType)
|
||||
pushUser(`选择${expenseTypeLabel || '报销类型'}`)
|
||||
pushNextReimbursementPrompt()
|
||||
persistAndScroll()
|
||||
return true
|
||||
}
|
||||
|
||||
if (actionType === GUIDED_ACTION_CONFIRM_REIMBURSEMENT_REVIEW) {
|
||||
const submitOptions = buildGuidedReviewSubmitOptions(guidedFlowState.value, guidedPendingFiles.value)
|
||||
resetGuidedFlowState()
|
||||
persistAndScroll()
|
||||
await submitExistingComposer(submitOptions)
|
||||
return true
|
||||
}
|
||||
|
||||
if (actionType === GUIDED_ACTION_CONTINUE_FILLING) {
|
||||
const pendingState = {
|
||||
...normalizeGuidedFlowState(guidedFlowState.value),
|
||||
pendingInterruptionText: ''
|
||||
}
|
||||
guidedFlowState.value = pendingState
|
||||
if (pendingState.mode === GUIDED_FLOW_MODE_STATUS_QUERY) {
|
||||
pushAssistant(buildGuidedQueryPromptText(pendingState), {
|
||||
meta: ['引导式查询'],
|
||||
suggestedActions: pendingState.stepKey === 'status_value' ? buildGuidedQueryStatusActions() : []
|
||||
})
|
||||
} else {
|
||||
pushNextReimbursementPrompt()
|
||||
}
|
||||
persistAndScroll()
|
||||
return true
|
||||
}
|
||||
|
||||
if (actionType === GUIDED_ACTION_PROCESS_INTERRUPTION) {
|
||||
const pendingText = normalizeText(guidedFlowState.value?.pendingInterruptionText)
|
||||
resetGuidedFlowState()
|
||||
persistAndScroll()
|
||||
await submitExistingComposer({
|
||||
rawText: pendingText,
|
||||
userText: pendingText,
|
||||
pendingText: '正在处理你的问题...',
|
||||
skipUserMessage: true
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
if (actionType === GUIDED_ACTION_SELECT_QUERY_MODE) {
|
||||
const queryMode = normalizeText(action?.payload?.query_mode)
|
||||
const queryModeLabel = normalizeText(action?.payload?.query_mode_label || action?.label)
|
||||
guidedFlowState.value = selectGuidedQueryMode(guidedFlowState.value, queryMode)
|
||||
pushUser(`选择${queryModeLabel || '查询方式'}`)
|
||||
pushAssistant(buildGuidedQueryPromptText(guidedFlowState.value), {
|
||||
meta: ['引导式查询'],
|
||||
suggestedActions: guidedFlowState.value.stepKey === 'status_value' ? buildGuidedQueryStatusActions() : []
|
||||
})
|
||||
persistAndScroll()
|
||||
return true
|
||||
}
|
||||
|
||||
if (actionType === GUIDED_ACTION_SELECT_QUERY_STATUS) {
|
||||
const statusLabel = normalizeText(action?.payload?.query_status_label || action?.label)
|
||||
pushUser(`选择${statusLabel || '单据状态'}`)
|
||||
const queryText = buildGuidedStatusQueryText(guidedFlowState.value, statusLabel)
|
||||
await runStatusQuery(queryText, true)
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
return {
|
||||
handleGuidedShortcut,
|
||||
handleGuidedComposerSubmit,
|
||||
handleGuidedSuggestedAction,
|
||||
resetGuidedFlowState
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
SESSION_TYPE_KNOWLEDGE,
|
||||
buildInitialInsightFromConversation,
|
||||
buildWelcomeInsight,
|
||||
buildWelcomeQuickActions,
|
||||
createWelcomeAssistantMessage,
|
||||
hasMeaningfulSessionMessages,
|
||||
normalizeInitialConversationMessages,
|
||||
@@ -25,6 +26,10 @@ import {
|
||||
serializeSessionMessages,
|
||||
shouldPreferPersistedSessionState
|
||||
} from './travelReimbursementConversationModel.js'
|
||||
import {
|
||||
createEmptyGuidedFlowState,
|
||||
normalizeGuidedFlowState
|
||||
} from './travelReimbursementGuidedFlowModel.js'
|
||||
|
||||
export function useTravelReimbursementSessionState({
|
||||
props,
|
||||
@@ -36,9 +41,26 @@ export function useTravelReimbursementSessionState({
|
||||
scrollToBottom,
|
||||
getSessionRuntimeRefs = () => ({})
|
||||
}) {
|
||||
function refreshWelcomeQuickActions(messages, sessionType) {
|
||||
if (!Array.isArray(messages) || !messages.length) {
|
||||
return []
|
||||
}
|
||||
const currentActions = buildWelcomeQuickActions(
|
||||
sessionType,
|
||||
currentUser.value,
|
||||
props.entrySource,
|
||||
linkedRequest.value
|
||||
)
|
||||
return messages.map((message) => (
|
||||
message?.isWelcome
|
||||
? { ...message, welcomeQuickActions: currentActions }
|
||||
: message
|
||||
))
|
||||
}
|
||||
|
||||
function buildConversationSessionState(conversation, fallbackSessionType = SESSION_TYPE_EXPENSE) {
|
||||
const sessionType = resolveInitialSessionType(conversation) || fallbackSessionType
|
||||
const restoredMessages = normalizeInitialConversationMessages(conversation)
|
||||
const restoredMessages = refreshWelcomeQuickActions(normalizeInitialConversationMessages(conversation), sessionType)
|
||||
const initialInsight = buildInitialInsightFromConversation(conversation)
|
||||
const restoredReviewFilePreviews = buildReviewFilePreviewsFromMessages(restoredMessages)
|
||||
|
||||
@@ -56,6 +78,7 @@ export function useTravelReimbursementSessionState({
|
||||
attachedFiles: [],
|
||||
composerFilesExpanded: false,
|
||||
composerUploadIntent: '',
|
||||
guidedFlowState: createEmptyGuidedFlowState(),
|
||||
insightPanelCollapsed: false
|
||||
}
|
||||
}
|
||||
@@ -79,6 +102,7 @@ export function useTravelReimbursementSessionState({
|
||||
attachedFiles: [],
|
||||
composerFilesExpanded: false,
|
||||
composerUploadIntent: '',
|
||||
guidedFlowState: createEmptyGuidedFlowState(),
|
||||
insightPanelCollapsed: false
|
||||
}
|
||||
}
|
||||
@@ -90,7 +114,7 @@ export function useTravelReimbursementSessionState({
|
||||
}
|
||||
|
||||
const sessionType = String(state.sessionType || snapshot.sessionType || fallbackSessionType || '').trim() || SESSION_TYPE_EXPENSE
|
||||
const restoredMessages = normalizeSnapshotMessages(state.messages)
|
||||
const restoredMessages = refreshWelcomeQuickActions(normalizeSnapshotMessages(state.messages), sessionType)
|
||||
if (
|
||||
!hasMeaningfulSessionMessages(restoredMessages)
|
||||
&& !String(state.conversationId || '').trim()
|
||||
@@ -114,6 +138,7 @@ export function useTravelReimbursementSessionState({
|
||||
attachedFiles: [],
|
||||
composerFilesExpanded: false,
|
||||
composerUploadIntent: String(state.composerUploadIntent || '').trim(),
|
||||
guidedFlowState: normalizeGuidedFlowState(state.guidedFlowState),
|
||||
insightPanelCollapsed: Boolean(state.insightPanelCollapsed)
|
||||
}
|
||||
}
|
||||
@@ -155,6 +180,7 @@ export function useTravelReimbursementSessionState({
|
||||
})
|
||||
const currentInsight = ref(initialSessionState.currentInsight)
|
||||
const composerUploadIntent = ref(String(initialSessionState.composerUploadIntent || '').trim())
|
||||
const guidedFlowState = ref(normalizeGuidedFlowState(initialSessionState.guidedFlowState))
|
||||
const insightPanelCollapsed = ref(false)
|
||||
const sessionSwitchBusy = ref(false)
|
||||
let knowledgeSessionResetPromise = Promise.resolve()
|
||||
@@ -170,6 +196,7 @@ export function useTravelReimbursementSessionState({
|
||||
reviewFilePreviews: filterPersistableFilePreviews(state.reviewFilePreviews),
|
||||
composerDraft: String(state.composerDraft || ''),
|
||||
composerUploadIntent: String(state.composerUploadIntent || '').trim(),
|
||||
guidedFlowState: normalizeGuidedFlowState(state.guidedFlowState),
|
||||
insightPanelCollapsed: Boolean(state.insightPanelCollapsed)
|
||||
}
|
||||
}
|
||||
@@ -209,6 +236,7 @@ export function useTravelReimbursementSessionState({
|
||||
attachedFiles: runtimeRefs.attachedFiles?.value ?? [],
|
||||
composerFilesExpanded: runtimeRefs.composerFilesExpanded?.value ?? false,
|
||||
composerUploadIntent: composerUploadIntent.value,
|
||||
guidedFlowState: runtimeRefs.guidedFlowState?.value ?? guidedFlowState.value,
|
||||
insightPanelCollapsed: insightPanelCollapsed.value
|
||||
}
|
||||
}
|
||||
@@ -246,6 +274,11 @@ export function useTravelReimbursementSessionState({
|
||||
runtimeRefs.composerFilesExpanded.value = Boolean(nextState.composerFilesExpanded)
|
||||
}
|
||||
composerUploadIntent.value = String(nextState.composerUploadIntent || '').trim()
|
||||
const nextGuidedFlowState = normalizeGuidedFlowState(nextState.guidedFlowState)
|
||||
guidedFlowState.value = nextGuidedFlowState
|
||||
if (runtimeRefs.guidedFlowState) {
|
||||
runtimeRefs.guidedFlowState.value = nextGuidedFlowState
|
||||
}
|
||||
insightPanelCollapsed.value = Boolean(nextState.insightPanelCollapsed)
|
||||
nextTick(() => {
|
||||
adjustComposerTextareaHeight()
|
||||
@@ -322,6 +355,7 @@ export function useTravelReimbursementSessionState({
|
||||
currentInsight,
|
||||
reviewFilePreviews,
|
||||
composerUploadIntent,
|
||||
guidedFlowState,
|
||||
insightPanelCollapsed,
|
||||
sessionSwitchBusy,
|
||||
initialSessionState,
|
||||
|
||||
@@ -685,8 +685,8 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
const backendMessage = buildBackendMessage(rawText, effectiveFileNames, effectiveOcrSummary)
|
||||
const orchestratorOptions = isKnowledgeSession.value
|
||||
? {
|
||||
timeoutMs: 18000,
|
||||
timeoutMessage: '知识问答整理超时,已停止等待。建议缩小问题范围或稍后重试。'
|
||||
timeoutMs: 75000,
|
||||
timeoutMessage: '知识问答仍在检索整理,已停止等待。请稍后重试,或补充制度名称、费用类型等限定条件。'
|
||||
}
|
||||
: {
|
||||
timeoutMs: 120000,
|
||||
|
||||
@@ -18,10 +18,12 @@ const sampleRows = [
|
||||
id: 'EXP-001',
|
||||
typeCode: 'travel',
|
||||
type: '差旅费',
|
||||
archiveTypeCode: 'reimbursement',
|
||||
archiveType: '报销',
|
||||
department: '研发部',
|
||||
archiveMonth: '2026-05',
|
||||
archiveMonthLabel: '2026年05月',
|
||||
archiveTab: '差旅报销',
|
||||
archiveTab: '报销归档',
|
||||
hasRisk: true,
|
||||
riskTone: 'high',
|
||||
risk: '2条',
|
||||
@@ -31,10 +33,12 @@ const sampleRows = [
|
||||
id: 'EXP-002',
|
||||
typeCode: 'entertainment',
|
||||
type: '业务招待费',
|
||||
archiveTypeCode: 'reimbursement',
|
||||
archiveType: '报销',
|
||||
department: '销售部',
|
||||
archiveMonth: '2026-04',
|
||||
archiveMonthLabel: '2026年04月',
|
||||
archiveTab: '招待报销',
|
||||
archiveTab: '报销归档',
|
||||
hasRisk: false,
|
||||
riskTone: 'none',
|
||||
risk: '0条',
|
||||
@@ -80,14 +84,27 @@ test('applyArchiveListFilters supports department and archive month', () => {
|
||||
assert.equal(filtered[0].id, 'EXP-002')
|
||||
})
|
||||
|
||||
test('build filter options are derived from loaded rows', () => {
|
||||
test('applyArchiveListFilters supports the unified reimbursement archive tab', () => {
|
||||
const reimbursementRows = applyArchiveListFilters(sampleRows, {
|
||||
tab: '报销归档'
|
||||
})
|
||||
const oldTravelRows = applyArchiveListFilters(sampleRows, {
|
||||
tab: '差旅报销'
|
||||
})
|
||||
|
||||
assert.equal(reimbursementRows.length, 2)
|
||||
assert.equal(oldTravelRows.length, 0)
|
||||
})
|
||||
|
||||
test('build filter options are derived from archive types', () => {
|
||||
const typeLabels = buildTypeFilterOptions(sampleRows).map((item) => item.label)
|
||||
const typeValues = buildTypeFilterOptions(sampleRows).map((item) => item.value)
|
||||
const departmentLabels = buildDepartmentFilterOptions(sampleRows).map((item) => item.label)
|
||||
const monthOptions = buildArchiveMonthFilterOptions(sampleRows)
|
||||
|
||||
assert.equal(typeLabels[0], '全部类型')
|
||||
assert.ok(typeLabels.includes('差旅费'))
|
||||
assert.ok(typeLabels.includes('业务招待费'))
|
||||
assert.equal(typeLabels[0], '全部归档类型')
|
||||
assert.deepEqual(typeValues, ['all', 'reimbursement'])
|
||||
assert.deepEqual(typeLabels, ['全部归档类型', '报销'])
|
||||
assert.equal(departmentLabels[0], '全部部门')
|
||||
assert.ok(departmentLabels.includes('研发部'))
|
||||
assert.ok(departmentLabels.includes('销售部'))
|
||||
|
||||
@@ -32,3 +32,26 @@ test('archive center is wired into navigation and api client', () => {
|
||||
assert.match(navigationScript, /id:\s*'archive'/)
|
||||
assert.match(reimbursementsService, /\/reimbursements\/claims\/archives/)
|
||||
})
|
||||
|
||||
test('archive center uses generic archive category and type wording', () => {
|
||||
const archiveView = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/ArchiveCenterView.vue', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const archiveScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/ArchiveCenterView.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
assert.match(archiveScript, /const tabs = \[ARCHIVE_TAB_ALL, ARCHIVE_TAB_REIMBURSEMENT\]/)
|
||||
assert.match(archiveScript, /const ARCHIVE_TAB_REIMBURSEMENT = '报销归档'/)
|
||||
assert.match(archiveScript, /archiveType:\s*ARCHIVE_TYPE_REIMBURSEMENT/)
|
||||
assert.match(archiveScript, /archiveTypeCode:\s*ARCHIVE_TYPE_REIMBURSEMENT_CODE/)
|
||||
assert.doesNotMatch(archiveScript, /'差旅报销'/)
|
||||
assert.doesNotMatch(archiveScript, /'招待报销'/)
|
||||
assert.doesNotMatch(archiveScript, /'其他费用'/)
|
||||
assert.match(archiveView, /placeholder="搜索单号、申请人、部门、归档类型\.\.\."/)
|
||||
assert.match(archiveView, /<th>归档类型<\/th>/)
|
||||
assert.match(archiveView, /\{\{\s*row\.archiveType\s*\}\}/)
|
||||
assert.doesNotMatch(archiveView, /<th>报销类型<\/th>/)
|
||||
})
|
||||
|
||||
@@ -26,8 +26,26 @@ function buildRun() {
|
||||
chunk_count: 5,
|
||||
entity_count: 3,
|
||||
relation_count: 2,
|
||||
entities: ['远光软件', '支出管理'],
|
||||
relations: [{ source: '远光软件', target: '支出管理', type: '关联' }]
|
||||
entities: [
|
||||
{
|
||||
name: '远光软件',
|
||||
type: 'ORGANIZATION',
|
||||
description: '远光软件是支出管理制度的公司主体。',
|
||||
descriptions: ['远光软件是支出管理制度的公司主体。'],
|
||||
properties: { created_at: '2026-05-23' }
|
||||
},
|
||||
'支出管理'
|
||||
],
|
||||
relations: [
|
||||
{
|
||||
source: '远光软件',
|
||||
target: '支出管理',
|
||||
type: '约束',
|
||||
description: '通过制度约束支出审批。',
|
||||
keywords: ['制度', '审批'],
|
||||
weight: 2.5
|
||||
}
|
||||
]
|
||||
},
|
||||
documents: [
|
||||
{
|
||||
@@ -40,7 +58,8 @@ function buildRun() {
|
||||
chunk_count: 3,
|
||||
entity_count: 2,
|
||||
relation_count: 1,
|
||||
chunks: [{ id: 'chunk-1', order: 0, tokens: 21, summary: '支出管理范围' }],
|
||||
chunks: [{ id: 'chunk-1', order: 0, tokens: 21, summary: '支出管理范围', excerpt: '支出管理范围正文' }],
|
||||
entity_chunks: [{ entity: '支出管理', chunk_ids: ['chunk-1'] }],
|
||||
sections: [{ title: '第一章 总则', excerpt: '适用于公司支出。' }],
|
||||
events: [{ at: '2026-05-22T08:00:00Z', level: 'info', message: '完成' }]
|
||||
},
|
||||
@@ -75,8 +94,18 @@ function testBuildsInteractiveModel() {
|
||||
assert.equal(model.documents.length, 2)
|
||||
assert.equal(model.documents[0].statusLabel, '已完成')
|
||||
assert.equal(model.documents[0].chunks[0].summary, '支出管理范围')
|
||||
assert.equal(model.documents[0].chunks[0].excerpt, '支出管理范围正文')
|
||||
assert.deepEqual(model.documents[0].entityChunks, [{ entity: '支出管理', chunkIds: ['chunk-1'] }])
|
||||
assert.deepEqual(model.documents[1].chunks, [])
|
||||
assert.deepEqual(model.documents[1].sections, [])
|
||||
assert.deepEqual(model.documents[1].events, [])
|
||||
assert.equal(model.graph.entityCount, 3)
|
||||
assert.equal(model.graph.entities[0].name, '远光软件')
|
||||
assert.equal(model.graph.entities[0].type, 'ORGANIZATION')
|
||||
assert.equal(model.graph.entities[0].descriptions[0], '远光软件是支出管理制度的公司主体。')
|
||||
assert.equal(model.graph.relations[0].source, '远光软件')
|
||||
assert.equal(model.graph.relations[0].description, '通过制度约束支出审批。')
|
||||
assert.deepEqual(model.graph.relations[0].keywords, ['制度', '审批'])
|
||||
assert.equal(model.metrics[1].value, '5')
|
||||
}
|
||||
|
||||
|
||||
@@ -38,3 +38,8 @@ test('composer keeps backend raw text but displays structured user message', ()
|
||||
assert.match(submitComposerScript, /const rawText = resolveComposerSubmitText\(options\.rawText\)\.trim\(\)/)
|
||||
assert.match(submitComposerScript, /resolveComposerDisplaySubmitText\(rawText\)/)
|
||||
})
|
||||
|
||||
test('knowledge questions keep enough request time for LightRAG retrieval', () => {
|
||||
assert.match(submitComposerScript, /timeoutMs:\s*75000/)
|
||||
assert.match(submitComposerScript, /知识问答仍在检索整理/)
|
||||
})
|
||||
|
||||
189
web/tests/travel-reimbursement-guided-flow.test.mjs
Normal file
189
web/tests/travel-reimbursement-guided-flow.test.mjs
Normal file
@@ -0,0 +1,189 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import {
|
||||
EXPENSE_WELCOME_QUICK_ACTIONS,
|
||||
SESSION_TYPE_EXPENSE,
|
||||
SESSION_TYPE_KNOWLEDGE,
|
||||
buildWelcomeQuickActions
|
||||
} from '../src/views/scripts/travelReimbursementConversationModel.js'
|
||||
import {
|
||||
GUIDED_ACTION_CONFIRM_REIMBURSEMENT_REVIEW,
|
||||
GUIDED_ACTION_CONTINUE_FILLING,
|
||||
GUIDED_ACTION_OPEN_TRAVEL_CALCULATOR,
|
||||
GUIDED_ACTION_PROCESS_INTERRUPTION,
|
||||
GUIDED_ACTION_SELECT_EXPENSE_TYPE,
|
||||
GUIDED_ACTION_SELECT_QUERY_MODE,
|
||||
GUIDED_ACTION_SELECT_QUERY_STATUS,
|
||||
GUIDED_ACTION_START_REIMBURSEMENT,
|
||||
GUIDED_ACTION_START_STATUS_QUERY,
|
||||
GUIDED_FLOW_MODE_REIMBURSEMENT,
|
||||
GUIDED_FLOW_MODE_STATUS_QUERY,
|
||||
applyGuidedReimbursementAnswer,
|
||||
buildGuidedExpenseTypeActions,
|
||||
buildGuidedInterruptionActions,
|
||||
buildGuidedQueryModeActions,
|
||||
buildGuidedQueryStatusActions,
|
||||
buildGuidedReimbursementSummaryText,
|
||||
buildGuidedReviewConfirmationActions,
|
||||
buildGuidedReviewSubmitOptions,
|
||||
buildGuidedStatusQueryText,
|
||||
buildGuidedStepPromptText,
|
||||
createEmptyGuidedFlowState,
|
||||
createGuidedReimbursementState,
|
||||
createGuidedStatusQueryState,
|
||||
isGuidedReimbursementReadyForReview,
|
||||
normalizeGuidedFlowState,
|
||||
selectGuidedExpenseType,
|
||||
selectGuidedQueryMode,
|
||||
shouldConfirmGuidedInterruption
|
||||
} from '../src/views/scripts/travelReimbursementGuidedFlowModel.js'
|
||||
|
||||
const createViewScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/TravelReimbursementCreateView.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const guidedFlowScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementGuidedFlow.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const sessionStateScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementSessionState.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
test('welcome quick actions are reduced to three guided local actions', () => {
|
||||
assert.deepEqual(
|
||||
EXPENSE_WELCOME_QUICK_ACTIONS.map((item) => item.label),
|
||||
['快速发起报销', '查询单据状态', '差旅计算器']
|
||||
)
|
||||
assert.deepEqual(
|
||||
EXPENSE_WELCOME_QUICK_ACTIONS.map((item) => item.action),
|
||||
[
|
||||
GUIDED_ACTION_START_REIMBURSEMENT,
|
||||
GUIDED_ACTION_START_STATUS_QUERY,
|
||||
GUIDED_ACTION_OPEN_TRAVEL_CALCULATOR
|
||||
]
|
||||
)
|
||||
assert.ok(EXPENSE_WELCOME_QUICK_ACTIONS.every((item) => !item.prompt))
|
||||
assert.equal(buildWelcomeQuickActions(SESSION_TYPE_EXPENSE).length, 3)
|
||||
assert.notDeepEqual(
|
||||
buildWelcomeQuickActions(SESSION_TYPE_KNOWLEDGE).map((item) => item.label),
|
||||
EXPENSE_WELCOME_QUICK_ACTIONS.map((item) => item.label),
|
||||
'knowledge hot questions should stay independent'
|
||||
)
|
||||
})
|
||||
|
||||
test('guided reimbursement asks type first and walks travel fields in order', () => {
|
||||
const typeActions = buildGuidedExpenseTypeActions()
|
||||
assert.deepEqual(
|
||||
typeActions.map((action) => action.label),
|
||||
['差旅费', '交通费', '住宿费', '业务招待费', '办公用品费', '其他费用']
|
||||
)
|
||||
assert.ok(typeActions.every((action) => action.action_type === GUIDED_ACTION_SELECT_EXPENSE_TYPE))
|
||||
|
||||
let state = createGuidedReimbursementState()
|
||||
assert.equal(state.mode, GUIDED_FLOW_MODE_REIMBURSEMENT)
|
||||
assert.equal(state.stepKey, 'expense_type')
|
||||
|
||||
state = selectGuidedExpenseType(state, 'travel')
|
||||
assert.equal(state.stepKey, 'reason')
|
||||
assert.match(buildGuidedStepPromptText(state), /第 1 步:事由/)
|
||||
|
||||
state = applyGuidedReimbursementAnswer(state, '去上海支持上海电力部署项目')
|
||||
assert.equal(state.stepKey, 'location')
|
||||
state = applyGuidedReimbursementAnswer(state, '上海')
|
||||
assert.equal(state.stepKey, 'time_range')
|
||||
state = applyGuidedReimbursementAnswer(state, '2026-05-20 至 2026-05-23,出差 3 天')
|
||||
assert.equal(state.stepKey, 'amount')
|
||||
state = applyGuidedReimbursementAnswer(state, '待核算')
|
||||
assert.equal(state.stepKey, 'attachments')
|
||||
state = applyGuidedReimbursementAnswer(state, '稍后上传')
|
||||
|
||||
assert.ok(isGuidedReimbursementReadyForReview(state))
|
||||
assert.match(buildGuidedReimbursementSummaryText(state), /已完成“差旅费”的引导填写/)
|
||||
assert.deepEqual(
|
||||
buildGuidedReviewConfirmationActions().map((action) => action.action_type),
|
||||
[GUIDED_ACTION_CONFIRM_REIMBURSEMENT_REVIEW]
|
||||
)
|
||||
|
||||
const submitOptions = buildGuidedReviewSubmitOptions(state)
|
||||
assert.equal(submitOptions.systemGenerated, true)
|
||||
assert.equal(submitOptions.extraContext.expense_scene_selection.expense_type, 'travel')
|
||||
assert.equal(submitOptions.extraContext.review_form_values.expense_type, '差旅费')
|
||||
assert.match(submitOptions.rawText, /事由:去上海支持上海电力部署项目/)
|
||||
assert.match(submitOptions.rawText, /出差时间\/天数:2026-05-20 至 2026-05-23,出差 3 天/)
|
||||
})
|
||||
|
||||
test('guided reimbursement interrupts suspicious questions before expensive flow', () => {
|
||||
const state = selectGuidedExpenseType(createGuidedReimbursementState(), 'transport')
|
||||
assert.equal(shouldConfirmGuidedInterruption('送客户去机场', state), false)
|
||||
assert.equal(shouldConfirmGuidedInterruption('帮我查询一下上周的报销状态?', state), true)
|
||||
assert.deepEqual(
|
||||
buildGuidedInterruptionActions().map((action) => action.action_type),
|
||||
[GUIDED_ACTION_CONTINUE_FILLING, GUIDED_ACTION_PROCESS_INTERRUPTION]
|
||||
)
|
||||
})
|
||||
|
||||
test('status query guide collects a query mode before calling existing query flow', () => {
|
||||
let state = createGuidedStatusQueryState()
|
||||
assert.equal(state.mode, GUIDED_FLOW_MODE_STATUS_QUERY)
|
||||
assert.equal(state.stepKey, 'query_mode')
|
||||
assert.ok(buildGuidedQueryModeActions().every((action) => action.action_type === GUIDED_ACTION_SELECT_QUERY_MODE))
|
||||
|
||||
state = selectGuidedQueryMode(state, 'status')
|
||||
assert.equal(state.stepKey, 'status_value')
|
||||
assert.ok(buildGuidedQueryStatusActions().every((action) => action.action_type === GUIDED_ACTION_SELECT_QUERY_STATUS))
|
||||
assert.equal(buildGuidedStatusQueryText(state, '已归档'), '帮我查询已归档的报销单据,筛选最近的 5 条记录')
|
||||
|
||||
const keywordState = selectGuidedQueryMode(createGuidedStatusQueryState(), 'keyword')
|
||||
assert.equal(keywordState.stepKey, 'query_value')
|
||||
assert.equal(
|
||||
buildGuidedStatusQueryText(keywordState, '上海电力'),
|
||||
'帮我查询地点或事由包含“上海电力”的报销单据状态,筛选最近的 5 条记录'
|
||||
)
|
||||
})
|
||||
|
||||
test('guided flow state is serializable and restored through session state', () => {
|
||||
const empty = createEmptyGuidedFlowState()
|
||||
assert.deepEqual(normalizeGuidedFlowState({ mode: 'bad' }), empty)
|
||||
assert.deepEqual(
|
||||
normalizeGuidedFlowState({
|
||||
mode: GUIDED_FLOW_MODE_REIMBURSEMENT,
|
||||
stepKey: 'amount',
|
||||
expenseType: 'travel',
|
||||
values: {
|
||||
amount: 200,
|
||||
attachment_names: ['a.pdf', '', 'a.pdf']
|
||||
},
|
||||
pendingInterruptionText: '查询状态?'
|
||||
}),
|
||||
{
|
||||
mode: GUIDED_FLOW_MODE_REIMBURSEMENT,
|
||||
stepKey: 'amount',
|
||||
expenseType: 'travel',
|
||||
values: {
|
||||
amount: '200',
|
||||
attachment_names: ['a.pdf']
|
||||
},
|
||||
pendingInterruptionText: '查询状态?'
|
||||
}
|
||||
)
|
||||
|
||||
assert.match(sessionStateScript, /guidedFlowState:\s*normalizeGuidedFlowState\(state\.guidedFlowState\)/)
|
||||
assert.match(sessionStateScript, /runtimeRefs\.guidedFlowState\?\.value/)
|
||||
assert.match(sessionStateScript, /guidedFlowState,\s*\n\s*insightPanelCollapsed/)
|
||||
assert.match(sessionStateScript, /function refreshWelcomeQuickActions/)
|
||||
assert.match(sessionStateScript, /buildWelcomeQuickActions\(/)
|
||||
})
|
||||
|
||||
test('guided flow is local until final confirmation or collected query handoff', () => {
|
||||
assert.doesNotMatch(guidedFlowScript, /runOrchestrator/)
|
||||
assert.doesNotMatch(guidedFlowScript, /startExpenseClaimDraftFlowStep/)
|
||||
assert.doesNotMatch(guidedFlowScript, /review_action:\s*['"]save_draft['"]/)
|
||||
assert.match(createViewScript, /if \(await handleGuidedComposerSubmit\(options\)\) \{[\s\S]*return null[\s\S]*\}[\s\S]*return submitComposerInternal\(options\)/)
|
||||
assert.match(guidedFlowScript, /submitExistingComposer\(submitOptions\)/)
|
||||
assert.match(guidedFlowScript, /submitExistingComposer\(\{[\s\S]*pendingText:\s*'正在查询单据状态\.\.\.'/)
|
||||
})
|
||||
Reference in New Issue
Block a user