后端新增风险图谱算法模块、风险观察与反馈服务、规则 DSL 校验器和可解释性引擎,完善系统仪表盘和财务仪表盘统计, 优化 agent 运行和编排执行链路,清理旧开发文档,前端新增 系统趋势、负载热力图等多种仪表盘图表组件,完善操作反馈 对话框和工作台日期选择器,优化报销创建和审批详情交互, 补充单元测试覆盖。
382 lines
12 KiB
Vue
382 lines
12 KiB
Vue
<template>
|
||
<article v-if="visible" class="detail-card panel risk-observation-evidence-card">
|
||
<div class="detail-card-head">
|
||
<div>
|
||
<h3 class="detail-card-title-with-icon">
|
||
<i class="mdi mdi-shield-search"></i>
|
||
<span>风险证据链</span>
|
||
</h3>
|
||
<p>展示当前单据进入统一风险观察池后的评分、证据、图谱关系和反馈状态。</p>
|
||
</div>
|
||
<button
|
||
class="risk-evidence-refresh"
|
||
type="button"
|
||
:disabled="loading"
|
||
@click="loadObservations"
|
||
>
|
||
<i :class="loading ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-refresh'"></i>
|
||
<span>刷新</span>
|
||
</button>
|
||
</div>
|
||
|
||
<div v-if="loading" class="risk-evidence-state">
|
||
<i class="mdi mdi-loading mdi-spin"></i>
|
||
<span>正在加载风险证据链</span>
|
||
</div>
|
||
<div v-else-if="errorMessage" class="risk-evidence-state error">
|
||
<i class="mdi mdi-alert-circle-outline"></i>
|
||
<span>{{ errorMessage }}</span>
|
||
</div>
|
||
<template v-else-if="mainObservation">
|
||
<div class="risk-evidence-current-head">
|
||
<div>
|
||
<span>当前风险观察详情</span>
|
||
<strong>{{ mainObservation.title || formatSignal(mainObservation.riskSignal) }}</strong>
|
||
</div>
|
||
<em>{{ activeObservationPosition }}</em>
|
||
</div>
|
||
|
||
<div
|
||
:id="detailRegionId"
|
||
class="risk-evidence-detail-region"
|
||
role="region"
|
||
aria-live="polite"
|
||
>
|
||
<div class="risk-evidence-summary">
|
||
<div class="risk-evidence-score" :class="mainObservation.riskLevel">
|
||
<strong>{{ mainObservation.riskScore }}</strong>
|
||
<span>{{ formatRiskLevel(mainObservation.riskLevel) }}</span>
|
||
</div>
|
||
<div class="risk-evidence-copy">
|
||
<h4>{{ mainObservation.title || formatSignal(mainObservation.riskSignal) }}</h4>
|
||
<p>{{ mainObservation.description || '暂无风险描述。' }}</p>
|
||
<div class="risk-evidence-meta">
|
||
<span>{{ formatSource(mainObservation.source) }}</span>
|
||
<span>{{ mainObservation.algorithmVersion || '未记录算法版本' }}</span>
|
||
<span>{{ formatFeedbackStatus(mainObservation.feedbackStatus) }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="risk-evidence-grid">
|
||
<section class="risk-evidence-section">
|
||
<span class="risk-evidence-section-title">贡献分</span>
|
||
<div class="risk-score-list">
|
||
<div v-for="item in scoreItems" :key="item.key" class="risk-score-row">
|
||
<span>{{ item.label }}</span>
|
||
<i><b :style="{ width: item.width }"></b></i>
|
||
<strong>{{ item.value }}</strong>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="risk-evidence-section">
|
||
<span class="risk-evidence-section-title">证据</span>
|
||
<ul v-if="evidenceRows.length" class="risk-evidence-list">
|
||
<li v-for="item in evidenceRows" :key="item.key">
|
||
<strong>{{ item.title }}</strong>
|
||
<span>{{ item.detail }}</span>
|
||
</li>
|
||
</ul>
|
||
<p v-else class="risk-evidence-empty">暂无证据明细。</p>
|
||
</section>
|
||
|
||
<section class="risk-evidence-section">
|
||
<span class="risk-evidence-section-title">图谱关系</span>
|
||
<div class="risk-chip-list">
|
||
<span v-for="item in graphItems" :key="item">{{ item }}</span>
|
||
<em v-if="!graphItems.length">暂无图谱关系。</em>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="risk-evidence-section">
|
||
<span class="risk-evidence-section-title">基线与建议</span>
|
||
<div class="risk-chip-list">
|
||
<span v-for="item in baselineAndDecisionItems" :key="item">{{ item }}</span>
|
||
<em v-if="!baselineAndDecisionItems.length">暂无基线或建议动作。</em>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="risk-evidence-section">
|
||
<span class="risk-evidence-section-title">制度与相似案例</span>
|
||
<div class="risk-chip-list">
|
||
<span v-for="item in policyAndSimilarItems" :key="item">{{ item }}</span>
|
||
<em v-if="!policyAndSimilarItems.length">暂无制度引用或相似案例。</em>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="risk-evidence-section">
|
||
<span class="risk-evidence-section-title">反馈历史</span>
|
||
<ul v-if="feedbackRows.length" class="risk-evidence-list">
|
||
<li v-for="item in feedbackRows" :key="item.key">
|
||
<strong>{{ item.title }}</strong>
|
||
<span>{{ item.detail }}</span>
|
||
</li>
|
||
</ul>
|
||
<p v-else class="risk-evidence-empty">暂无人工反馈。</p>
|
||
</section>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="observations.length > 1" class="risk-observation-list">
|
||
<button
|
||
v-for="(item, index) in observations"
|
||
:key="observationIdentity(item, index)"
|
||
type="button"
|
||
class="risk-observation-row"
|
||
:class="{ active: isActiveObservation(item, index) }"
|
||
:aria-pressed="isActiveObservation(item, index)"
|
||
:aria-controls="detailRegionId"
|
||
@click="selectObservation(item, index)"
|
||
>
|
||
<span>{{ formatRiskLevel(item.riskLevel) }}</span>
|
||
<strong>{{ item.title || formatSignal(item.riskSignal) }}</strong>
|
||
<em>{{ item.riskScore }}</em>
|
||
</button>
|
||
</div>
|
||
</template>
|
||
</article>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { computed, ref, watch } from 'vue'
|
||
|
||
import { fetchClaimRiskObservations } from '../../services/riskObservations.js'
|
||
|
||
const props = defineProps({
|
||
claimId: { type: String, default: '' }
|
||
})
|
||
|
||
const observations = ref([])
|
||
const loading = ref(false)
|
||
const errorMessage = ref('')
|
||
const activeObservationKey = ref('')
|
||
const detailRegionId = 'risk-observation-active-detail'
|
||
let loadSequence = 0
|
||
|
||
const visible = computed(() =>
|
||
loading.value || Boolean(errorMessage.value) || observations.value.length > 0
|
||
)
|
||
const mainObservation = computed(() => {
|
||
if (!observations.value.length) {
|
||
return null
|
||
}
|
||
return (
|
||
observations.value.find((item, index) => isActiveObservation(item, index))
|
||
|| observations.value[0]
|
||
)
|
||
})
|
||
const activeObservationPosition = computed(() => {
|
||
if (observations.value.length <= 1) {
|
||
return '单条观察'
|
||
}
|
||
const activeIndex = observations.value.findIndex((item, index) =>
|
||
isActiveObservation(item, index)
|
||
)
|
||
return activeIndex >= 0 ? `${activeIndex + 1} / ${observations.value.length}` : '未选择'
|
||
})
|
||
const scoreItems = computed(() => {
|
||
const scores = mainObservation.value?.contributionScores || {}
|
||
return Object.entries(scores).map(([key, value]) => {
|
||
const numericValue = Math.max(0, Math.min(Number(value || 0), 100))
|
||
return {
|
||
key,
|
||
label: formatScoreLabel(key),
|
||
value: Math.round(numericValue),
|
||
width: `${numericValue}%`
|
||
}
|
||
})
|
||
})
|
||
const evidenceRows = computed(() =>
|
||
(mainObservation.value?.evidence || []).slice(0, 6).map((item, index) => ({
|
||
key: `${item.code || item.title || index}`,
|
||
title: String(item.title || item.code || item.source || `证据 ${index + 1}`).trim(),
|
||
detail: String(item.detail || item.message || item.summary || '').trim() || '已记录证据。'
|
||
}))
|
||
)
|
||
const graphItems = computed(() => [
|
||
...(mainObservation.value?.graphNodeKeys || []).slice(0, 4),
|
||
...(mainObservation.value?.graphEdgeKeys || []).slice(0, 4)
|
||
].map(formatChipValue).filter(Boolean))
|
||
const baselineAndDecisionItems = computed(() => [
|
||
...formatRecordItems(mainObservation.value?.baseline, '基线'),
|
||
...formatRecordItems(mainObservation.value?.decisionTrace, '建议')
|
||
])
|
||
const policyAndSimilarItems = computed(() => [
|
||
...(mainObservation.value?.policyRefs || [])
|
||
.map(formatChipValue)
|
||
.filter(Boolean)
|
||
.map((item) => `制度:${item}`),
|
||
...(mainObservation.value?.similarCaseClaimIds || [])
|
||
.map(formatChipValue)
|
||
.filter(Boolean)
|
||
.map((item) => `相似案例:${item}`)
|
||
])
|
||
const feedbackRows = computed(() =>
|
||
(mainObservation.value?.feedbackItems || []).slice(0, 5).map((item, index) => {
|
||
const feedbackType = item.feedbackType || item.feedback_type || item.type
|
||
const actor = String(item.actor || item.createdBy || item.created_by || '未记录人员').trim()
|
||
const createdAt = String(item.createdAt || item.created_at || '').trim()
|
||
const comment = String(item.comment || item.remark || item.message || '').trim()
|
||
return {
|
||
key: `${item.id || createdAt || index}`,
|
||
title: `${formatFeedbackType(feedbackType)} · ${actor}`,
|
||
detail: [comment || '已记录反馈。', createdAt].filter(Boolean).join(' · ')
|
||
}
|
||
})
|
||
)
|
||
|
||
watch(
|
||
() => props.claimId,
|
||
() => {
|
||
void loadObservations()
|
||
},
|
||
{ immediate: true }
|
||
)
|
||
|
||
async function loadObservations() {
|
||
const claimId = String(props.claimId || '').trim()
|
||
const sequence = ++loadSequence
|
||
errorMessage.value = ''
|
||
if (!claimId) {
|
||
observations.value = []
|
||
activeObservationKey.value = ''
|
||
loading.value = false
|
||
return
|
||
}
|
||
|
||
loading.value = true
|
||
try {
|
||
const payload = await fetchClaimRiskObservations(claimId)
|
||
if (sequence !== loadSequence) {
|
||
return
|
||
}
|
||
observations.value = payload
|
||
activeObservationKey.value = observationIdentity(payload[0], 0)
|
||
} catch (error) {
|
||
if (sequence === loadSequence) {
|
||
observations.value = []
|
||
activeObservationKey.value = ''
|
||
errorMessage.value = error?.message || '风险证据链加载失败。'
|
||
}
|
||
} finally {
|
||
if (sequence === loadSequence) {
|
||
loading.value = false
|
||
}
|
||
}
|
||
}
|
||
|
||
function observationIdentity(item, index = -1) {
|
||
const explicitKey = String(item?.observationKey || item?.id || '').trim()
|
||
if (explicitKey) {
|
||
return explicitKey
|
||
}
|
||
return [
|
||
item?.claimId,
|
||
item?.riskSignal,
|
||
item?.createdAt,
|
||
index >= 0 ? index : ''
|
||
].map((value) => String(value || '').trim()).filter(Boolean).join(':')
|
||
}
|
||
|
||
function isActiveObservation(item, index = -1) {
|
||
return observationIdentity(item, index) === activeObservationKey.value
|
||
}
|
||
|
||
function selectObservation(item, index = -1) {
|
||
const key = observationIdentity(item, index)
|
||
if (key) {
|
||
activeObservationKey.value = key
|
||
}
|
||
}
|
||
|
||
function formatScoreLabel(value) {
|
||
const labels = {
|
||
S_rule: '规则命中',
|
||
S_anomaly: '画像偏离',
|
||
S_graph: '图谱异常',
|
||
S_policy: '制度约束',
|
||
S_history: '历史反馈'
|
||
}
|
||
return labels[value] || value
|
||
}
|
||
|
||
function formatChipValue(value) {
|
||
if (typeof value === 'string') {
|
||
return value.trim()
|
||
}
|
||
if (value && typeof value === 'object') {
|
||
return String(value.key || value.edge_key || value.node_key || JSON.stringify(value)).trim()
|
||
}
|
||
return String(value || '').trim()
|
||
}
|
||
|
||
function formatRecordItems(value, prefix) {
|
||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||
return []
|
||
}
|
||
return Object.entries(value)
|
||
.slice(0, 6)
|
||
.map(([key, item]) => `${prefix}:${key}=${formatChipValue(item)}`)
|
||
.filter(Boolean)
|
||
}
|
||
|
||
function formatRiskLevel(value) {
|
||
const labels = {
|
||
critical: '重大风险',
|
||
high: '高风险',
|
||
medium: '中风险',
|
||
low: '低风险'
|
||
}
|
||
return labels[String(value || '').trim()] || '未知风险'
|
||
}
|
||
|
||
function formatSignal(value) {
|
||
const text = String(value || '').trim()
|
||
const labels = {
|
||
duplicate_invoice: '重复发票',
|
||
split_billing: '拆分报销',
|
||
frequent_small_claims: '高频小额',
|
||
location_mismatch: '地点不一致',
|
||
amount_outlier: '金额异常',
|
||
preapproval_absent: '缺少事前申请'
|
||
}
|
||
return labels[text] || text.replace(/_/g, ' ') || '未知风险'
|
||
}
|
||
|
||
function formatSource(value) {
|
||
const labels = {
|
||
financial_risk_graph: '风险图谱',
|
||
rule_center: '规则中心'
|
||
}
|
||
return labels[String(value || '').trim()] || '风险观察池'
|
||
}
|
||
|
||
function formatFeedbackType(value) {
|
||
const labels = {
|
||
confirm: '确认风险',
|
||
confirmed: '确认风险',
|
||
false_positive: '标记误报',
|
||
ignore: '忽略',
|
||
ignored: '忽略',
|
||
resolve: '已处理',
|
||
resolved: '已处理',
|
||
comment: '备注'
|
||
}
|
||
return labels[String(value || '').trim()] || '人工反馈'
|
||
}
|
||
|
||
function formatFeedbackStatus(value) {
|
||
const labels = {
|
||
confirmed: '已确认',
|
||
false_positive: '已标记误报',
|
||
ignored: '已忽略',
|
||
resolved: '已处理',
|
||
unreviewed: '未复核'
|
||
}
|
||
return labels[String(value || '').trim()] || '未复核'
|
||
}
|
||
</script>
|
||
|
||
<style scoped src="../../assets/styles/components/risk-observation-evidence-card.css"></style>
|