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