Files
X-Financial/web/src/components/travel/RiskObservationEvidenceCard.vue
caoxiaozhu 7989f3a159 feat: 新增风险图谱算法与系统仪表盘及操作反馈体系
后端新增风险图谱算法模块、风险观察与反馈服务、规则 DSL
校验器和可解释性引擎,完善系统仪表盘和财务仪表盘统计,
优化 agent 运行和编排执行链路,清理旧开发文档,前端新增
系统趋势、负载热力图等多种仪表盘图表组件,完善操作反馈
对话框和工作台日期选择器,优化报销创建和审批详情交互,
补充单元测试覆盖。
2026-05-30 15:46:51 +08:00

382 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>