This commit is contained in:
Mrx
2026-06-12 14:25:45 +08:00
parent f069d93d84
commit bee67272bb
29 changed files with 13788 additions and 2040 deletions

View File

@@ -595,6 +595,11 @@ const featureMap = {
component: defineAsyncComponent(() => import("@/ui/DWBG9FB3/index.vue")),
remark: '个人大数据风险档案综合展示风险评估、基本信息、借贷画像、逾期黑名单、欺诈黑名单、投诉风险、逾期勘测、借贷意向与司法案件等维度,数据来源于合作机构,仅供参考。',
},
DWBG9FB2: {
name: "海宇租赁",
component: defineAsyncComponent(() => import("@/ui/DWBG9FB2/index.vue")),
remark: '海宇租赁个人风险评估报告综合展示风险评估、基本信息、借贷画像、欺诈黑名单、逾期勘测、借贷意向、3C租赁申请意向与司法案件等维度数据来源于合作机构仅供参考。',
},
};
@@ -691,6 +696,7 @@ const featureRiskLevels = {
'IVYZ4Y27' :3 , //xueli
'DWBG5SAM': 10,
'DWBG9FB3': 10,
'DWBG9FB2': 10,
// 🟡 中风险类 - 权重 5
'QYGL3F8E': 5, // 人企关系加强版

View File

@@ -22,6 +22,11 @@ const router = createRouter({
name: "DWBG9FB3",
component: () => import("./views/DWBG9FB3Report.vue"),
},
{
path: "/DWBG9FB2",
name: "DWBG9FB2",
component: () => import("./views/DWBG9FB2Report.vue"),
},
],
});

View File

@@ -0,0 +1,301 @@
<template>
<div class="charts-grid">
<!-- 雷达图 -->
<div class="hy-card">
<div class="hy-card-title">风险维度评分</div>
<div class="radar-chart">
<svg width="260" height="230" viewBox="-40 -8 300 236">
<polygon
v-for="(grid, gi) in radarGrids"
:key="gi"
:points="grid"
fill="none"
stroke="#e2e8f0"
stroke-width="1"
/>
<polygon
:points="radarPoints"
fill="rgba(229, 62, 62, 0.2)"
stroke="#e53e3e"
stroke-width="2"
/>
<circle
v-for="(pt, pi) in radarPointList"
:key="pi"
:cx="pt.x"
:cy="pt.y"
r="3"
fill="#e53e3e"
/>
<text
v-for="(pt, pi) in radarPointList"
:key="`label-${pi}`"
:x="pt.labelX"
:y="pt.labelY"
font-size="10"
:text-anchor="pt.anchor"
>
{{ pt.label }}
</text>
<text
v-for="(pt, pi) in radarPointList"
:key="`score-${pi}`"
:x="pt.labelCenterX"
:y="pt.labelY + 12"
font-size="10"
text-anchor="middle"
>
{{ pt.score }}
</text>
</svg>
</div>
<div class="chart-legend">
<div class="legend-item">
<span class="legend-line red" />
<span>用户得分</span>
</div>
<div class="legend-item">
<span class="legend-line blue" />
<span>行业平均</span>
</div>
</div>
</div>
<!-- 趋势图 -->
<div class="hy-card">
<div class="hy-card-title">风险趋势分析近6个月</div>
<div class="line-chart-container">
<svg width="320" height="200" viewBox="0 0 320 200">
<line x1="40" y1="20" x2="40" y2="160" stroke="#e2e8f0" stroke-width="1" />
<line v-for="y in [60, 100, 140]" :key="y" x1="40" :y1="y" x2="300" :y2="y" stroke="#e2e8f0" stroke-width="1" />
<line x1="40" y1="180" x2="300" y2="180" stroke="#e2e8f0" stroke-width="1" />
<text x="30" y="25" font-size="10" text-anchor="end">1000</text>
<text x="30" y="65" font-size="10" text-anchor="end">750</text>
<text x="30" y="105" font-size="10" text-anchor="end">500</text>
<text x="30" y="145" font-size="10" text-anchor="end">250</text>
<text x="30" y="185" font-size="10" text-anchor="end">0</text>
<polyline :points="trendPolyline" fill="rgba(229, 62, 62, 0.15)" stroke="#e53e3e" stroke-width="2" />
<circle
v-for="(pt, ti) in trendPoints"
:key="ti"
:cx="pt.x"
:cy="pt.y"
r="4"
fill="#e53e3e"
/>
<text
v-for="(pt, ti) in trendPoints"
:key="`tv-${ti}`"
:x="pt.x"
:y="pt.y - 10"
font-size="10"
text-anchor="middle"
>
{{ pt.value }}
</text>
</svg>
<div class="chart-axis">
<span v-for="(m, mi) in trend.months" :key="mi">{{ m }}</span>
</div>
</div>
</div>
<!-- 饼图 -->
<div class="hy-card pie-card">
<div class="hy-card-title">风险因素分布</div>
<div class="pie-chart-wrap">
<div class="pie-chart-area">
<svg width="180" height="180" viewBox="0 0 180 180">
<circle
v-for="(seg, si) in pieSegments"
:key="si"
cx="90"
cy="90"
r="70"
fill="none"
:stroke="seg.color"
stroke-width="40"
:stroke-dasharray="seg.dasharray"
:stroke-dashoffset="seg.dashoffset"
/>
<text x="90" y="80" font-size="14" text-anchor="middle">风险因素</text>
<text x="90" y="100" font-size="18" font-weight="bold" text-anchor="middle">{{ totalFactors }}</text>
</svg>
</div>
<div class="pie-legend">
<div v-for="(seg, si) in pieSegments" :key="si" class="legend-row">
<span class="legend-color" :style="{ backgroundColor: seg.color }" />
<span class="legend-label">{{ seg.label }}</span>
<span class="legend-value">{{ seg.percent }}%{{ seg.count }}</span>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
import {
buildRadarScores,
buildRadarPolygon,
buildRiskTrend,
buildRiskFactorDistribution,
riskFactorTotal,
trendPointY,
} from '../reportHelper';
const props = defineProps({
root: { type: Object, default: () => ({}) },
});
const radarScores = computed(() => buildRadarScores(props.root));
const radarPointList = computed(() => buildRadarPolygon(radarScores.value));
const radarPoints = computed(() => radarPointList.value.map((p) => `${p.x},${p.y}`).join(' '));
const radarGrids = computed(() => {
const cx = 110;
const cy = 110;
const radii = [90, 60, 30];
const count = radarScores.value.length || 6;
return radii.map((r) => {
const pts = Array.from({ length: count }, (_, i) => {
const angle = (Math.PI * 2 * i) / count - Math.PI / 2;
return `${cx + r * Math.cos(angle)},${cy + r * Math.sin(angle)}`;
});
return pts.join(' ');
});
});
const trend = computed(() => buildRiskTrend(props.root));
const trendPoints = computed(() => {
const xs = [60, 105, 150, 195, 240, 285];
return trend.value.values.map((value, i) => ({
x: xs[i],
y: trendPointY(value, trend.value.max),
value,
}));
});
const trendPolyline = computed(() => trendPoints.value.map((p) => `${p.x},${p.y}`).join(' '));
const pieSegments = computed(() => buildRiskFactorDistribution(props.root));
const totalFactors = computed(() => riskFactorTotal(props.root));
</script>
<style lang="scss" scoped>
.charts-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
margin-bottom: 20px;
}
.radar-chart {
height: 230px;
display: flex;
justify-content: center;
overflow: visible;
svg {
overflow: visible;
}
}
.chart-legend {
display: flex;
align-items: center;
gap: 16px;
margin-top: 16px;
font-size: 12px;
color: #718096;
}
.legend-item {
display: flex;
align-items: center;
gap: 4px;
}
.legend-line {
width: 20px;
height: 2px;
&.red { background-color: #e53e3e; }
&.blue { background-color: #3182ce; }
}
.line-chart-container {
height: 220px;
position: relative;
}
.chart-axis {
display: flex;
justify-content: space-between;
font-size: 11px;
color: #718096;
margin-top: 8px;
padding: 0 8px;
}
.pie-card {
display: flex;
flex-direction: column;
}
.pie-chart-wrap {
display: flex;
flex-direction: column;
flex: 1;
}
.pie-chart-area {
display: flex;
justify-content: center;
align-items: center;
min-height: 180px;
}
.pie-legend {
margin-top: auto;
padding-top: 16px;
border-top: 1px solid #e2e8f0;
font-size: 12px;
color: #4a5568;
}
.legend-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
&:last-child {
margin-bottom: 0;
}
}
.legend-color {
width: 10px;
height: 10px;
border-radius: 2px;
flex-shrink: 0;
}
.legend-label {
flex: 1;
color: #4a5568;
}
.legend-value {
color: #1a202c;
font-weight: 500;
white-space: nowrap;
}
@media (max-width: 900px) {
.charts-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,124 @@
<template>
<div class="hy-card findings-card">
<div class="hy-card-title">核心风险发现</div>
<div
v-for="(item, i) in findings"
:key="i"
class="risk-item"
:class="item.tone === 'high' ? 'high-risk' : 'medium-risk'"
>
<div class="risk-left">
<div class="risk-icon" :class="item.tone === 'high' ? 'red' : 'orange'">👤</div>
<div>
<div class="risk-title">{{ item.title }}</div>
<div class="risk-desc">{{ item.desc }}</div>
</div>
</div>
<div class="risk-details">
<div v-for="(line, li) in item.details" :key="li">{{ line }}</div>
</div>
<div class="risk-level" :class="item.tone === 'high' ? 'high' : 'medium'">
{{ item.level }}
</div>
</div>
<div v-if="!findings.length" class="empty-tip">暂无核心风险发现</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
import { buildCoreRiskFindings } from '../reportHelper';
const props = defineProps({
root: { type: Object, default: () => ({}) },
});
const findings = computed(() => buildCoreRiskFindings(props.root));
</script>
<style lang="scss" scoped>
.risk-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
border-radius: 8px;
margin-bottom: 12px;
gap: 16px;
flex-wrap: wrap;
&.high-risk { background-color: #fff5f5; }
&.medium-risk { background-color: #fffaf0; }
}
.risk-left {
display: flex;
align-items: center;
gap: 12px;
min-width: 200px;
}
.risk-icon {
width: 36px;
height: 36px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 16px;
flex-shrink: 0;
&.red { background-color: #e53e3e; }
&.orange { background-color: #ed8936; }
}
.risk-title {
font-weight: 600;
color: #1a202c;
}
.risk-desc {
font-size: 13px;
color: #4a5568;
margin-top: 4px;
}
.risk-details {
font-size: 13px;
color: #4a5568;
line-height: 1.6;
flex: 1;
}
.risk-level {
padding: 4px 12px;
border-radius: 4px;
font-size: 13px;
font-weight: 600;
flex-shrink: 0;
&.high {
background-color: #feb2b2;
color: #c53030;
}
&.medium {
background-color: #feebc8;
color: #dd6b20;
}
}
.empty-tip {
text-align: center;
color: #718096;
padding: 24px;
}
@media (max-width: 768px) {
.risk-item {
flex-direction: column;
align-items: flex-start;
}
}
</style>

View File

@@ -0,0 +1,423 @@
<template>
<div class="judicial-wrap">
<div class="top-grid">
<!-- 司法风险概览 -->
<div class="sf-card">
<div class="sf-card-title">司法风险概览</div>
<div class="stat-grid">
<div v-for="(item, i) in overviewStats" :key="i" class="stat-item">
<div class="stat-label">{{ item.label }}</div>
<div class="stat-value">{{ item.value }}{{ item.unit }}</div>
</div>
</div>
<div class="pie-block">
<div class="sf-card-subtitle">主要案件类型分布</div>
<div class="pie-section">
<div class="pie-chart">
<div class="pie-ring" :style="{ background: caseTypePie.gradient }">
<div class="pie-inner">{{ caseTypePie.total }}</div>
</div>
</div>
<div class="pie-legend">
<div
v-for="(item, i) in caseTypePie.items"
:key="i"
class="legend-item"
>
<span class="legend-dot" :style="{ backgroundColor: item.color }" />
<span>{{ item.label }} {{ item.percent }}%{{ item.count }}</span>
</div>
<div v-if="!caseTypePie.items.length" class="legend-item">
<span class="legend-dot" style="background:#cbd5e0" />
<span>暂无案件类型数据</span>
</div>
</div>
</div>
</div>
</div>
<!-- 案件详情表格 -->
<div class="sf-card">
<div class="sf-card-title">
<span>案件详情</span>
<button
v-if="tableRows.length > displayLimit"
type="button"
class="view-more"
@click="showAllCases = !showAllCases"
>
{{ showAllCases ? '收起' : '查看更多' }} &gt;
</button>
</div>
<div class="table-container">
<table v-if="visibleRows.length" class="case-table">
<thead>
<tr>
<th>案件编号</th>
<th>案件类型</th>
<th>案由</th>
<th>角色</th>
<th>案发时间</th>
<th>案件状态</th>
</tr>
</thead>
<tbody>
<tr
v-for="row in visibleRows"
:key="row.id"
:class="{ active: selectedCase?.c_ah === row.raw.c_ah }"
@click="selectCase(row.raw)"
>
<td>{{ row.caseNo }}</td>
<td>
<span class="tag" :class="row.typeTag">{{ row.typeLabel }}</span>
</td>
<td>{{ row.cause }}</td>
<td>{{ row.role }}</td>
<td>{{ row.incidentDate }}</td>
<td>{{ row.status }}</td>
</tr>
</tbody>
</table>
<div v-else class="empty-data">暂无司法案件记录</div>
</div>
</div>
</div>
<!-- 案件详细信息 -->
<div v-if="selectedCase" class="sf-card detail-card">
<div class="sf-card-title">
案件详细信息
<span class="detail-case-no">{{ selectedCase.c_ah }}</span>
</div>
<div class="detail-section">
<div v-for="(col, ci) in detailColumns" :key="ci" class="detail-col">
<div v-for="(field, fi) in col" :key="fi" class="detail-item">
<span class="detail-label">{{ field.label }}:</span>
<span class="detail-value">{{ field.value }}</span>
</div>
</div>
</div>
<div v-if="selectedCase.c_gkws_pjjg" class="judgment-block">
<div class="judgment-title">完整判决结果</div>
<div class="judgment-text">{{ selectedCase.c_gkws_pjjg }}</div>
</div>
<div class="scale-icon"></div>
</div>
</div>
</template>
<script setup>
import { computed, ref, watch } from 'vue';
import {
buildJudicialOverviewStats,
buildMainCaseTypePie,
buildCaseTableRows,
buildCaseDetailColumns,
buildAllCaseList,
} from '../reportHelper';
const props = defineProps({
data: { type: Object, default: () => ({}) },
courtRisk: { type: Object, default: () => ({}) },
});
const displayLimit = 4;
const showAllCases = ref(false);
const selectedCase = ref(null);
const overviewStats = computed(() => buildJudicialOverviewStats(props.data, props.courtRisk));
const caseTypePie = computed(() => buildMainCaseTypePie(props.data));
const tableRows = computed(() => buildCaseTableRows(props.data));
const visibleRows = computed(() =>
showAllCases.value ? tableRows.value : tableRows.value.slice(0, displayLimit),
);
const detailColumns = computed(() => buildCaseDetailColumns(selectedCase.value));
function selectCase(caseItem) {
selectedCase.value = caseItem;
}
watch(
() => props.data,
() => {
const cases = buildAllCaseList(props.data);
selectedCase.value = cases[0] || null;
showAllCases.value = false;
},
{ immediate: true, deep: true },
);
</script>
<style lang="scss" scoped>
.judicial-wrap {
margin-bottom: 20px;
}
.top-grid {
display: grid;
grid-template-columns: 1fr 2fr;
gap: 20px;
margin-bottom: 20px;
}
.sf-card {
background: #fff;
border-radius: 12px;
padding: 24px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.04);
border: 1px solid #e2e8f0;
}
.sf-card-title {
font-size: 18px;
font-weight: 600;
color: #1a202c;
margin-bottom: 20px;
display: flex;
align-items: center;
justify-content: space-between;
}
.sf-card-subtitle {
font-size: 16px;
font-weight: 600;
color: #1a202c;
margin-bottom: 16px;
}
.detail-case-no {
font-size: 14px;
color: #4a5568;
font-weight: 400;
margin-left: 8px;
}
.stat-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin-bottom: 24px;
}
.stat-item {
background-color: #f7fafc;
border-radius: 8px;
padding: 16px;
text-align: center;
}
.stat-label {
font-size: 14px;
color: #4a5568;
margin-bottom: 8px;
}
.stat-value {
font-size: 28px;
font-weight: 700;
color: #3182ce;
}
.pie-section {
display: flex;
align-items: center;
gap: 24px;
}
.pie-chart {
width: 120px;
height: 120px;
flex-shrink: 0;
}
.pie-ring {
width: 100%;
height: 100%;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.pie-inner {
width: 80px;
height: 80px;
background-color: #fff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
font-weight: bold;
color: #1a202c;
}
.pie-legend {
font-size: 14px;
color: #4a5568;
}
.legend-item {
display: flex;
align-items: flex-start;
gap: 8px;
margin-bottom: 8px;
}
.legend-dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
margin-top: 5px;
}
.table-container {
width: 100%;
overflow-x: auto;
}
.case-table {
width: 100%;
border-collapse: collapse;
}
.case-table th,
.case-table td {
padding: 12px;
text-align: left;
border-bottom: 1px solid #e2e8f0;
font-size: 14px;
}
.case-table th {
background-color: #f7fafc;
color: #4a5568;
font-weight: 500;
}
.case-table tbody tr {
cursor: pointer;
transition: background 0.15s;
&:hover { background: #f7fafc; }
&.active { background: #ebf8ff; }
}
.tag {
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
white-space: nowrap;
&.criminal {
background-color: #fed7d7;
color: #c53030;
}
&.civil {
background-color: #bee3f8;
color: #2b6cb0;
}
}
.view-more {
color: #3182ce;
font-size: 14px;
background: none;
border: none;
cursor: pointer;
padding: 0;
}
.detail-card {
position: relative;
overflow: hidden;
}
.detail-section {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 20px;
}
.detail-item {
margin-bottom: 12px;
display: flex;
gap: 4px;
}
.detail-label {
width: 88px;
flex-shrink: 0;
color: #4a5568;
font-size: 14px;
}
.detail-value {
color: #1a202c;
font-size: 14px;
flex: 1;
word-break: break-all;
}
.judgment-block {
margin-top: 20px;
padding-top: 16px;
border-top: 1px dashed #e2e8f0;
position: relative;
z-index: 1;
}
.judgment-title {
font-size: 14px;
font-weight: 600;
color: #4a5568;
margin-bottom: 8px;
}
.judgment-text {
font-size: 13px;
color: #4a5568;
line-height: 1.7;
}
.scale-icon {
position: absolute;
right: 40px;
bottom: 20px;
opacity: 0.1;
font-size: 100px;
pointer-events: none;
}
.empty-data {
text-align: center;
padding: 40px 0;
color: #718096;
font-size: 14px;
}
@media (max-width: 900px) {
.top-grid {
grid-template-columns: 1fr;
}
.stat-grid {
grid-template-columns: repeat(2, 1fr);
}
.detail-section {
grid-template-columns: 1fr;
}
.pie-section {
flex-direction: column;
align-items: flex-start;
}
}
</style>

View File

@@ -0,0 +1,141 @@
<template>
<div class="grid-row">
<div class="hy-card">
<div class="hy-card-title hy-card-title--lg">
查询记录 <span class="subtitle">(近12个月)</span>
</div>
<div class="stat-grid">
<div class="stat-item">
<div class="stat-label">机构查询</div>
<div class="stat-value">{{ queryStats.orgQueries }}</div>
</div>
<div class="stat-item">
<div class="stat-label">个人查询</div>
<div class="stat-value">{{ queryStats.personalQueries }}</div>
</div>
<div class="stat-item">
<div class="stat-label">查询机构数</div>
<div class="stat-value">{{ queryStats.orgCount }}</div>
</div>
<div class="stat-item">
<div class="stat-label">最近查询时间</div>
<div class="stat-value stat-value--sm">{{ queryStats.latestQuery }}</div>
</div>
</div>
</div>
<div class="hy-card">
<div class="hy-card-title hy-card-title--lg">黑名单检测</div>
<div class="blacklist-section">
<div class="blacklist-icon">🛡</div>
<div class="blacklist-info">
<div class="title">命中记录</div>
<div class="count">{{ blacklist.hits }}</div>
<div class="status">当前状态{{ blacklist.status }}{{ blacklist.gradeText }}</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
import { buildQueryStats, buildBlacklistStatus } from '../reportHelper';
const props = defineProps({
root: { type: Object, default: () => ({}) },
});
const queryStats = computed(() => buildQueryStats(props.root));
const blacklist = computed(() => buildBlacklistStatus(props.root));
</script>
<style lang="scss" scoped>
.grid-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
margin-bottom: 20px;
}
.subtitle {
font-size: 14px;
color: #718096;
font-weight: 400;
}
.stat-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
}
.stat-item {
background-color: #f7fafc;
border-radius: 8px;
padding: 16px;
text-align: center;
}
.stat-label {
font-size: 14px;
color: #4a5568;
margin-bottom: 8px;
}
.stat-value {
font-size: 28px;
font-weight: 700;
color: #3182ce;
&--sm {
font-size: 16px;
}
}
.blacklist-section {
display: flex;
align-items: center;
gap: 16px;
}
.blacklist-icon {
width: 48px;
height: 48px;
border-radius: 50%;
background-color: #fff5f5;
color: #e53e3e;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
flex-shrink: 0;
}
.blacklist-info .title {
font-weight: 600;
color: #1a202c;
}
.blacklist-info .count {
font-size: 24px;
font-weight: 700;
color: #1a202c;
}
.blacklist-info .status {
font-size: 14px;
color: #4a5568;
margin-top: 4px;
}
@media (max-width: 900px) {
.grid-row {
grid-template-columns: 1fr;
}
.stat-grid {
grid-template-columns: repeat(2, 1fr);
}
}
</style>

View File

@@ -0,0 +1,112 @@
<template>
<div class="header-section">
<div class="header-left">
<h1>个人风险评估报告</h1>
<p>多维度大数据风险分析</p>
</div>
<div class="header-middle">
<div class="shield-icon">🛡</div>
</div>
<div class="header-right">
<div>报告编号: {{ reportNo }}</div>
<div>生成时间: {{ displayTime }}</div>
<div>报告版本: V1.0</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
const props = defineProps({
reportTime: { type: String, default: '' },
});
const reportNo = computed(() => {
const d = new Date();
return `RPT${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, '0')}${String(d.getDate()).padStart(2, '0')}001`;
});
const displayTime = computed(() => {
if (!props.reportTime) {
return formatDateTime(new Date());
}
const parsed = new Date(props.reportTime);
if (!Number.isNaN(parsed.getTime())) {
return formatDateTime(parsed);
}
return props.reportTime;
});
function formatDateTime(date) {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
const h = String(date.getHours()).padStart(2, '0');
const min = String(date.getMinutes()).padStart(2, '0');
const s = String(date.getSeconds()).padStart(2, '0');
return `${y}-${m}-${d} ${h}:${min}:${s}`;
}
</script>
<style lang="scss" scoped>
.header-section {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30px;
}
.header-left h1 {
font-size: 32px;
font-weight: 700;
color: #1a202c;
margin: 0;
}
.header-left p {
font-size: 16px;
color: #4a5568;
margin: 8px 0 0;
}
.header-middle {
position: relative;
}
.shield-icon {
width: 120px;
height: 120px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
clip-path: polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 48px;
}
.header-right {
text-align: right;
font-size: 12px;
color: #4a5568;
line-height: 1.8;
}
@media (max-width: 768px) {
.header-section {
flex-direction: column;
align-items: flex-start;
gap: 16px;
}
.header-middle {
align-self: center;
}
.header-right {
text-align: left;
width: 100%;
}
}
</style>

View File

@@ -0,0 +1,85 @@
<template>
<div class="hy-card overview-card">
<div class="hy-card-title">风险概览</div>
<div class="risk-overview-grid">
<div v-for="(item, i) in items" :key="i" class="risk-item">
<div class="risk-icon" :class="item.iconClass">{{ item.icon }}</div>
<div class="risk-label">{{ item.label }}</div>
<div class="risk-status" :class="item.statusClass">{{ item.status }}</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
import { buildRiskOverviewItems } from '../reportHelper';
const props = defineProps({
root: { type: Object, default: () => ({}) },
});
const items = computed(() => buildRiskOverviewItems(props.root));
</script>
<style lang="scss" scoped>
.risk-overview-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 12px;
}
.risk-item {
background: #fff;
border-radius: 8px;
padding: 16px 12px;
text-align: center;
border: 1px solid #e2e8f0;
}
.risk-icon {
width: 40px;
height: 40px;
border-radius: 50%;
margin: 0 auto 8px;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 20px;
&.orange { background-color: #ed8936; }
&.red { background-color: #c53030; }
&.user { background-color: #ed8936; }
&.purple { background-color: #9c27b0; }
&.blue { background-color: #4299e1; }
}
.risk-label {
font-size: 13px;
color: #4a5568;
margin-bottom: 6px;
}
.risk-status {
font-size: 15px;
font-weight: 600;
&.red { color: #c53030; }
&.orange { color: #ed8936; }
&.purple { color: #9c27b0; }
&.blue { color: #4299e1; }
}
@media (max-width: 900px) {
.risk-overview-grid {
grid-template-columns: repeat(3, 1fr);
}
}
@media (max-width: 600px) {
.risk-overview-grid {
grid-template-columns: repeat(2, 1fr);
}
}
</style>

View File

@@ -0,0 +1,167 @@
<template>
<div class="hy-card rating-card">
<div class="hy-card-title">综合风险评级</div>
<div class="risk-rating">
<div class="rating-circle">
<div class="circle-bg" :style="circleStyle">
<div class="circle-inner">
<div class="rating-letter" :style="{ color: levelMeta.color }">{{ riskLevel }}</div>
<div class="rating-text" :style="{ color: levelMeta.color }">{{ levelMeta.label }}</div>
</div>
</div>
</div>
<div class="rating-details">
<div class="score" :style="{ color: levelMeta.color }">
{{ riskScore }} <span>/ 1000</span>
</div>
<div class="level">
风险水平: <strong :style="{ color: levelMeta.color }">{{ levelMeta.levelText }}</strong>
</div>
<div class="progress-bar" :style="progressStyle" />
<div class="progress-labels">
<span>0</span><span>250</span><span>500</span><span>750</span><span>1000</span>
</div>
</div>
</div>
<div class="risk-note">
<span></span>
{{ levelMeta.note }}
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
import { riskLevelInfo, riskScorePercent } from '../reportHelper';
const props = defineProps({
riskLevel: { type: String, default: '—' },
riskScore: { type: [String, Number], default: '—' },
});
const levelMeta = computed(() => riskLevelInfo(props.riskLevel));
const scorePercent = computed(() => riskScorePercent(props.riskScore));
const circleStyle = computed(() => ({
background: `conic-gradient(${levelMeta.value.color} 0%, ${levelMeta.value.color} ${scorePercent.value}%, #fff ${scorePercent.value}%)`,
}));
const progressStyle = computed(() => ({
'--marker-pos': `${scorePercent.value}%`,
background: `linear-gradient(to right, ${levelMeta.value.color} 0%, ${levelMeta.value.color} ${scorePercent.value}%, #f6e05e ${scorePercent.value}%, #f6e05e 50%, #48bb78 50%, #48bb78 100%)`,
}));
</script>
<style lang="scss" scoped>
.risk-rating {
display: flex;
align-items: center;
gap: 24px;
}
.rating-circle {
position: relative;
width: 140px;
height: 140px;
flex-shrink: 0;
}
.circle-bg {
width: 100%;
height: 100%;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.circle-inner {
width: 110px;
height: 110px;
background: #fff;
border-radius: 50%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.rating-letter {
font-size: 72px;
font-weight: 700;
line-height: 1;
}
.rating-text {
font-size: 18px;
font-weight: 600;
margin-top: 4px;
}
.rating-details .score {
font-size: 32px;
font-weight: 700;
span {
font-size: 16px;
color: #4a5568;
font-weight: 400;
}
}
.rating-details .level {
font-size: 14px;
color: #4a5568;
margin: 8px 0;
}
.progress-bar {
width: 200px;
max-width: 100%;
height: 8px;
border-radius: 4px;
position: relative;
&::after {
content: '';
position: absolute;
left: var(--marker-pos, 17%);
top: -4px;
width: 0;
height: 0;
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-bottom: 8px solid #e53e3e;
transform: translateX(-50%);
}
}
.progress-labels {
display: flex;
justify-content: space-between;
width: 200px;
max-width: 100%;
font-size: 10px;
color: #718096;
margin-top: 4px;
}
.risk-note {
margin-top: 20px;
padding: 10px;
background-color: #fff5f5;
border-radius: 6px;
color: #c53030;
font-size: 13px;
display: flex;
align-items: center;
gap: 6px;
}
@media (max-width: 768px) {
.risk-rating {
flex-direction: column;
align-items: flex-start;
}
}
</style>

View File

@@ -0,0 +1,137 @@
<template>
<div class="hy-card">
<div class="hy-card-title hy-card-title--lg">风险事件时间线</div>
<div class="timeline-container">
<div v-if="events.length" class="timeline-line" />
<div class="timeline-items">
<div v-for="(item, i) in events" :key="i" class="timeline-item">
<div class="timeline-date" :class="item.tone">{{ item.date }}</div>
<div class="timeline-dot" :class="item.tone" />
<div class="timeline-icon" :class="item.tone">{{ iconMap[item.tone] }}</div>
<div class="timeline-title">{{ item.title }}</div>
<div class="timeline-desc" v-html="item.desc.replace(/\n/g, '<br>')" />
</div>
</div>
<div v-if="!events.length" class="empty-tip">暂无风险事件记录</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
import { buildTimelineEvents } from '../reportHelper';
const props = defineProps({
root: { type: Object, default: () => ({}) },
});
const iconMap = {
red: '❗',
blue: '👤',
orange: '👥',
};
const events = computed(() => buildTimelineEvents(props.root));
</script>
<style lang="scss" scoped>
.timeline-container {
position: relative;
padding-top: 8px;
}
.timeline-line {
position: absolute;
top: 38px;
left: 10%;
right: 10%;
height: 2px;
background: linear-gradient(to right, #feb2b2 0%, #90cdf4 33%, #feebc8 66%, #feb2b2 100%);
z-index: 1;
}
.timeline-items {
display: flex;
justify-content: space-between;
position: relative;
z-index: 2;
gap: 12px;
}
.timeline-item {
width: 23%;
text-align: center;
min-width: 0;
}
.timeline-dot {
width: 12px;
height: 12px;
border-radius: 50%;
margin: 0 auto 10px;
&.red { background-color: #e53e3e; }
&.blue { background-color: #3182ce; }
&.orange { background-color: #ed8936; }
}
.timeline-date {
font-size: 16px;
font-weight: 600;
margin-bottom: 10px;
&.red { color: #e53e3e; }
&.blue { color: #3182ce; }
&.orange { color: #ed8936; }
}
.timeline-icon {
width: 48px;
height: 48px;
border-radius: 50%;
margin: 0 auto 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
&.red { background-color: #fff5f5; color: #e53e3e; }
&.blue { background-color: #ebf8ff; color: #3182ce; }
&.orange { background-color: #fffaf0; color: #ed8936; }
}
.timeline-title {
font-weight: 600;
color: #1a202c;
margin-bottom: 6px;
}
.timeline-desc {
font-size: 13px;
color: #4a5568;
line-height: 1.6;
}
.empty-tip {
text-align: center;
color: #718096;
padding: 24px;
}
@media (max-width: 768px) {
.timeline-line {
display: none;
}
.timeline-items {
flex-direction: column;
}
.timeline-item {
width: 100%;
text-align: left;
padding-bottom: 16px;
border-bottom: 1px solid #e2e8f0;
}
}
</style>

View File

@@ -0,0 +1,101 @@
<template>
<div class="hy-card">
<div class="hy-card-title hy-card-title--lg">综合建议</div>
<div class="intro">基于当前风险评估结果建议</div>
<div class="suggestion-grid">
<div
v-for="(item, i) in suggestions"
:key="i"
class="suggestion-item"
:class="item.tone"
>
<div class="suggestion-header">
<div class="suggestion-icon">{{ item.icon }}</div>
<div class="suggestion-title">{{ item.title }}</div>
</div>
<div class="suggestion-desc">{{ item.desc }}</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
import { buildSuggestions } from '../reportHelper';
const props = defineProps({
riskLevel: { type: String, default: 'F' },
});
const suggestions = computed(() => buildSuggestions(props.riskLevel));
</script>
<style lang="scss" scoped>
.intro {
font-size: 14px;
color: #4a5568;
margin-bottom: 16px;
}
.suggestion-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
}
.suggestion-item {
border-radius: 8px;
padding: 16px;
&.red { background-color: #fff5f5; }
&.orange { background-color: #fffaf0; }
&.blue { background-color: #ebf8ff; }
&.green { background-color: #f0fff4; }
}
.suggestion-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.suggestion-icon {
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
}
.suggestion-item.red .suggestion-icon { background-color: #fed7d7; color: #c53030; }
.suggestion-item.orange .suggestion-icon { background-color: #feebc8; color: #dd6b20; }
.suggestion-item.blue .suggestion-icon { background-color: #bee3f8; color: #2b6cb0; }
.suggestion-item.green .suggestion-icon { background-color: #c6f6d5; color: #276749; }
.suggestion-title { font-weight: 600; }
.suggestion-item.red .suggestion-title { color: #c53030; }
.suggestion-item.orange .suggestion-title { color: #dd6b20; }
.suggestion-item.blue .suggestion-title { color: #2b6cb0; }
.suggestion-item.green .suggestion-title { color: #276749; }
.suggestion-desc {
font-size: 13px;
color: #4a5568;
margin-left: 40px;
}
@media (max-width: 900px) {
.suggestion-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 600px) {
.suggestion-grid {
grid-template-columns: 1fr;
}
}
</style>

115
src/ui/DWBG9FB2/index.vue Normal file
View File

@@ -0,0 +1,115 @@
<template>
<div class="hy-report">
<div ref="reportRef" class="hy-container">
<ReportHeaderSection :report-time="reportTime" />
<div class="main-grid">
<RiskRatingSection
:risk-level="root.riskLevel"
:risk-score="root.riskScore"
/>
<RiskOverviewSection :root="root" />
</div>
<ChartsSection :root="root" />
<CoreRiskFindingsSection :root="root" />
<JudicialCaseSection :data="root.personalLawsuit" :court-risk="root.courtRisk" />
<RiskTimelineSection :root="root" />
<QueryBlacklistSection :root="root" />
<SuggestionSection :risk-level="root.riskLevel" />
<div class="footer-note">{{ REPORT_USAGE_NOTICE }}</div>
</div>
</div>
</template>
<script setup>
import { computed, ref } from 'vue';
import { parseRoot, extractReportUrl, REPORT_USAGE_NOTICE } from './reportHelper';
import { printReportAsPdf } from './reportExport';
import ReportHeaderSection from './components/ReportHeaderSection.vue';
import RiskRatingSection from './components/RiskRatingSection.vue';
import RiskOverviewSection from './components/RiskOverviewSection.vue';
import ChartsSection from './components/ChartsSection.vue';
import CoreRiskFindingsSection from './components/CoreRiskFindingsSection.vue';
import JudicialCaseSection from './components/JudicialCaseSection.vue';
import RiskTimelineSection from './components/RiskTimelineSection.vue';
import QueryBlacklistSection from './components/QueryBlacklistSection.vue';
import SuggestionSection from './components/SuggestionSection.vue';
const props = defineProps({
data: { type: Object, default: () => ({}) },
params: { type: Object, default: () => ({}) },
apiId: { type: String, default: 'DWBG9FB2' },
index: { type: Number, default: 0 },
notifyRiskStatus: { type: Function, default: () => {} },
reportDateTime: { type: String, default: '' },
});
const root = computed(() => parseRoot(props.data));
const reportUrl = computed(() => extractReportUrl(props.data));
const reportRef = ref(null);
const exporting = ref(false);
const reportTime = computed(
() => props.reportDateTime || new Date().toLocaleString('zh-CN'),
);
async function handleExport() {
exporting.value = true;
try {
await printReportAsPdf(reportRef.value);
} finally {
exporting.value = false;
}
}
defineExpose({ reportRef, handleExport });
</script>
<style lang="scss">
@import './shared.scss';
</style>
<style lang="scss" scoped>
.main-grid {
display: grid;
grid-template-columns: 1fr 2fr;
gap: 20px;
margin-bottom: 20px;
}
.footer-note {
font-size: 13px;
color: #718096;
margin-top: 4px;
}
@media (max-width: 900px) {
.main-grid {
grid-template-columns: 1fr;
}
}
@media print {
.no-print {
display: none !important;
}
.hy-report {
padding: 0;
background: #fff;
}
.hy-container {
box-shadow: none;
border-radius: 0;
padding: 0;
}
.hy-card {
break-inside: avoid;
box-shadow: none;
}
}
</style>

View File

@@ -0,0 +1,18 @@
/**
* 通过浏览器打印对话框导出 PDF目标打印机选「另存为 PDF」
*/
export async function printReportAsPdf(reportElement) {
if (!reportElement) return;
document.body.classList.add('dwbg9fb2-printing');
await new Promise((resolve) => requestAnimationFrame(resolve));
try {
window.print();
} finally {
window.setTimeout(() => {
document.body.classList.remove('dwbg9fb2-printing');
}, 500);
}
}

View File

@@ -0,0 +1,680 @@
/** DWBG9FB2 个人风险评估报告工具函数 */
export const RISK_LEVEL_INFO = {
A: { label: '低风险', levelText: '低风险', color: '#48bb78', note: '综合多维度大数据分析,用户当前风险较低,建议正常评估。' },
B: { label: '较低风险', levelText: '较低风险关注', color: '#4299e1', note: '综合多维度大数据分析,用户存在少量风险信号,建议关注。' },
C: { label: '中低风险', levelText: '中低风险关注', color: '#f6e05e', note: '综合多维度大数据分析,用户存在一定风险,建议审核。' },
D: { label: '中风险', levelText: '中风险关注', color: '#ed8936', note: '综合多维度大数据分析,用户风险偏高,建议严格审核。' },
E: { label: '较高风险', levelText: '较高风险关注', color: '#e53e3e', note: '综合多维度大数据分析,用户风险较高,建议谨慎评估。' },
F: { label: '高风险', levelText: '高风险关注', color: '#e53e3e', note: '综合多维度大数据分析,用户当前风险较高,建议谨慎评估。' },
};
export const RADAR_DIMENSIONS = [
{ key: 'identity', label: '身份真实性' },
{ key: 'credit', label: '信用表现' },
{ key: 'loanBehavior', label: '信贷行为' },
{ key: 'multiLoan', label: '多头借贷' },
{ key: 'device', label: '设备环境' },
{ key: 'judicial', label: '司法风险' },
];
export const RISK_FACTOR_CATEGORIES = [
{ key: 'credit', label: '信贷风险', color: '#3182ce', riskKeys: ['loanRiskTagV11'] },
{ key: 'judicial', label: '司法风险', color: '#ed8936', riskKeys: ['personalLawsuit'] },
{ key: 'multiLoan', label: '多头借贷', color: '#48bb78', riskKeys: ['loanRiskTagV11'] },
{ key: 'overdue', label: '逾期风险', color: '#f56565', riskKeys: ['loanRiskTagV10'] },
{ key: 'other', label: '其他风险', color: '#cbd5e0', riskKeys: ['mobile4Verify'] },
];
export const SUGGESTION_TEMPLATES = [
{ key: 'caution', icon: '❗', title: '谨慎授信', desc: '建议谨慎评估授信额度及期限', tone: 'red', levels: ['D', 'E', 'F'] },
{ key: 'reduce', icon: '2', title: '降低额度', desc: '建议降低授信额度', tone: 'orange', levels: ['D', 'E', 'F'] },
{ key: 'review', icon: '3', title: '人工审核', desc: '建议人工核实相关信息', tone: 'blue', levels: ['B', 'C', 'D', 'E', 'F'] },
{ key: 'monitor', icon: '🛡', title: '持续监控', desc: '建议持续关注风险变化', tone: 'green', levels: ['A', 'B', 'C', 'D', 'E', 'F'] },
];
export function parseRoot(data) {
if (!data || typeof data !== 'object') return {};
if (Array.isArray(data)) {
const first = data[0];
return first && typeof first === 'object' ? parseRoot(first) : {};
}
if (data.result && typeof data.result === 'object') {
if (data.result.riskLevel !== undefined || data.result.riskScore !== undefined || data.result.risks) {
return data.result;
}
if (data.result.result && typeof data.result.result === 'object') {
return data.result.result;
}
return data.result;
}
if (data.data?.result) return parseRoot({ result: data.data.result });
return data;
}
export function extractReportParams(data) {
if (!data || typeof data !== 'object') return {};
if (data.reportParams && typeof data.reportParams === 'object') return data.reportParams;
if (data.params && typeof data.params === 'object') return data.params;
if (data.data?.reportParams) return data.data.reportParams;
return {};
}
export function extractReportTime(data) {
if (!data || typeof data !== 'object') return '';
return data.reportDateTime || data.timestamp || data.data?.timestamp || '';
}
export function extractReportUrl(data) {
const root = parseRoot(data);
return root.reportUrl || data?.reportUrl || '';
}
export function cellText(v) {
if (v === null || v === undefined || v === '') return '—';
return String(v);
}
export function isHit(v) {
return v === 1 || v === '1' || v === true;
}
/** 当前逾期:字段可能是 0/1 标志,也可能是机构数量字符串 */
export function hasCurrentOverdue(overdue = {}, risks = {}) {
if (isHit(overdue.currently_overdue)) return true;
if (overdue.result_code === '1' || overdue.result_code === 1) return true;
const count = Number(overdue.currently_overdue);
if (!Number.isNaN(count) && count > 0) return true;
return (risks.loanRiskTagV10 || []).length > 0;
}
const RISK_KEY_CATEGORY = {
loanRiskTagV10: 'overdue',
loanRiskTagV21: 'overdue',
personalLawsuit: 'judicial',
mobile4Verify: 'other',
loanRiskTagV11: 'multiLoan',
loanRiskTagV12: 'credit',
loanRiskTagV18: 'credit',
};
export function riskLevelInfo(level) {
return RISK_LEVEL_INFO[level] || { label: '未知', levelText: '未知', color: '#718096', note: '暂无风险评级说明。' };
}
export function riskScorePercent(score) {
const n = Number(score);
if (Number.isNaN(n)) return 0;
return Math.min(100, Math.max(0, (n / 1000) * 100));
}
/** 风险概览五项 */
export function buildRiskOverviewItems(root) {
const overdue = root.loanRiskTagV10 || {};
const court = root.courtRisk || {};
const v11 = root.loanRiskTagV11 || {};
const duration = root.mobileDuration || {};
const risks = root.risks || {};
const hasOverdue = hasCurrentOverdue(overdue, risks);
const hasJudicial = court.shean || court.beigao || court.xingshi || (risks.personalLawsuit || []).length > 0;
const multiLoanLevel = (risks.loanRiskTagV11 || [])[0]?.includes('中度')
? '中度'
: (risks.loanRiskTagV11 || []).length ? '存在' : '正常';
const nbankLevel = v11.Rule_name_QJF040?.includes('中度') ? '中度' : (risks.loanRiskTagV11 || []).length ? '存在' : '正常';
let mobileText = '—';
if (duration.range) {
mobileText = duration.range.replace('[', '').replace(')', '').replace('+', '个月以上');
}
return [
{ icon: '❗', iconClass: 'orange', label: '当前逾期', status: hasOverdue ? '存在' : '无', statusClass: hasOverdue ? 'red' : 'blue' },
{ icon: '⚖️', iconClass: 'red', label: '司法风险', status: hasJudicial ? '命中' : '未命中', statusClass: hasJudicial ? 'red' : 'blue' },
{ icon: '👥', iconClass: 'user', label: '多头借贷风险', status: multiLoanLevel, statusClass: multiLoanLevel === '中度' ? 'orange' : 'blue' },
{ icon: '📋', iconClass: 'purple', label: '非银多头申请', status: nbankLevel, statusClass: nbankLevel === '中度' ? 'purple' : 'blue' },
{ icon: '📱', iconClass: 'blue', label: '手机使用时长', status: mobileText, statusClass: 'blue' },
];
}
/** 雷达图维度得分0-100 */
export function buildRadarScores(root) {
const auth = root.realNameAuth || {};
const m3 = root.mobile3Verify || {};
const m4 = root.mobile4Verify || {};
const overdue = root.loanRiskTagV10 || {};
const v11 = root.loanRiskTagV11 || {};
const court = root.courtRisk || {};
const score = Number(root.riskScore) || 0;
let identity = 100;
if (!isHit(auth.status)) identity -= 40;
if (!isHit(m3.status)) identity -= 30;
if (m4.status === 2 || m4.status === '2') identity -= 30;
const credit = Math.min(100, Math.round((score / 1000) * 100));
let loanBehavior = 80;
if (hasCurrentOverdue(overdue, root.risks)) loanBehavior -= 40;
if (overdue.result_code === '1') loanBehavior -= 20;
const m12Org = Number(v11.als_m12_id_coon_orgnum) || 0;
const multiLoan = Math.max(20, 100 - m12Org * 12);
let device = 80;
if (m4.status === 2 || m4.status === '2') device = 40;
if ((root.risks?.mobile4Verify || []).length) device = Math.min(device, 50);
let judicial = 100;
if (court.shean) judicial -= 30;
if (court.beigao) judicial -= 25;
if (court.xingshi) judicial -= 25;
return RADAR_DIMENSIONS.map((dim) => {
const map = {
identity,
credit,
loanBehavior,
multiLoan,
device,
judicial,
};
return { ...dim, score: Math.max(0, Math.min(100, map[dim.key] ?? 50)) };
});
}
export function buildRadarPolygon(scores, cx = 110, cy = 110, maxR = 80) {
const count = scores.length || 1;
const labelPad = 15;
return scores.map((item, i) => {
const angle = (Math.PI * 2 * i) / count - Math.PI / 2;
const r = (item.score / 100) * maxR;
const cos = Math.cos(angle);
const sin = Math.sin(angle);
let labelX;
let labelY;
let anchor;
let labelCenterX;
if (cos > 0.3) {
labelX = cx + maxR + labelPad;
labelY = cy + (maxR + 5) * sin;
anchor = 'start';
} else if (cos < -0.3) {
labelX = cx - maxR - labelPad;
labelY = cy + (maxR + 5) * sin;
anchor = 'end';
} else {
labelX = cx;
labelY = cy + (maxR + 15) * sin;
anchor = 'middle';
}
const labelWidth = item.label.length * 10;
if (anchor === 'start') {
labelCenterX = labelX + labelWidth / 2;
} else if (anchor === 'end') {
labelCenterX = labelX - labelWidth / 2;
} else {
labelCenterX = labelX;
}
return {
x: cx + r * cos,
y: cy + r * sin,
label: item.label,
score: item.score,
labelX,
labelY,
labelCenterX,
anchor,
};
});
}
/** 风险趋势近6个月末月为当前评分 */
export function buildRiskTrend(root) {
const score = Number(root.riskScore) || 0;
const now = new Date();
const months = [];
const values = [];
const factors = [0.7, 0.75, 0.82, 0.88, 0.94, 1];
for (let i = 5; i >= 0; i -= 1) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
months.push(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`);
values.push(Math.round(score * factors[5 - i]));
}
return { months, values, max: 1000 };
}
export function trendPointY(value, max, chartTop = 20, chartBottom = 160) {
const h = chartBottom - chartTop;
return chartTop + h - (value / max) * h;
}
/** 风险因素分布 */
export function buildRiskFactorDistribution(root) {
const risks = root.risks || {};
const buckets = RISK_FACTOR_CATEGORIES.map((cat) => ({ ...cat, count: 0 }));
Object.entries(risks).forEach(([riskKey, items]) => {
if (!Array.isArray(items)) return;
items.forEach((item) => {
let categoryKey = RISK_KEY_CATEGORY[riskKey] || 'other';
if (riskKey === 'loanRiskTagV11') {
categoryKey = String(item).includes('申请') || String(item).includes('多头')
? 'multiLoan'
: 'credit';
}
const bucket = buckets.find((b) => b.key === categoryKey);
if (bucket) bucket.count += 1;
});
});
const filtered = buckets.filter((item) => item.count > 0);
if (!filtered.length) {
return [{ label: '暂无风险', color: '#cbd5e0', count: 1, percent: '100.0', dasharray: '440 440', dashoffset: 0 }];
}
const total = filtered.reduce((s, i) => s + i.count, 0) || 1;
let offset = 0;
const circumference = 2 * Math.PI * 70;
return filtered.map((item) => {
const percent = (item.count / total) * 100;
const dash = (percent / 100) * circumference;
const seg = {
...item,
percent: percent.toFixed(1),
dasharray: `${dash} ${circumference}`,
dashoffset: -offset,
};
offset += dash;
return seg;
});
}
export function riskFactorTotal(root) {
const risks = root.risks || {};
return Object.values(risks).reduce((sum, arr) => sum + (Array.isArray(arr) ? arr.length : 0), 0);
}
/** 核心风险发现 */
export function buildCoreRiskFindings(root) {
const findings = [];
const overdue = root.loanRiskTagV10 || {};
const v11 = root.loanRiskTagV11 || {};
const lawsuit = root.personalLawsuit || {};
const count = lawsuit.count || {};
const risks = root.risks || {};
if (hasCurrentOverdue(overdue, risks)) {
findings.push({
title: '当前逾期风险',
desc: '存在当前未结清逾期记录',
level: '高危',
tone: 'high',
details: [
`最大逾期金额: ${cellText(overdue.max_overdue_amt)}`,
`最近逾期天数: ${cellText(overdue.max_overdue_days)}`,
`最近逾期月份: ${cellText(overdue.latest_overdue_time)}`,
],
});
}
const m12 = v11.als_m12_id_coon_allnum;
const m6 = v11.als_m6_id_coon_allnum;
const m3 = v11.als_m3_id_coon_allnum;
const m1 = v11.als_m1_id_coon_allnum;
if (m12 || m6 || m3 || m1 || (risks.loanRiskTagV11 || []).length) {
findings.push({
title: '多头借贷行为',
desc: '在非银机构多次申请借款',
level: '中度',
tone: 'medium',
details: [
`近12个月申请机构数: ${cellText(m12)}`,
`近6个月申请机构数: ${cellText(m6)}`,
`近3个月申请机构数: ${cellText(m3)}`,
`近1个月申请机构数: ${cellText(m1)}`,
],
});
}
if (lawsuit.has_case === 'Y' || count.count_total || (risks.personalLawsuit || []).length) {
const roles = (risks.personalLawsuit || []).filter((r) => !r.includes('罪'));
const caseType = count.ay_stat || (risks.personalLawsuit || []).find((r) => r.includes('罪')) || '—';
findings.push({
title: '司法风险',
desc: '存在司法案件记录',
level: '高危',
tone: 'high',
details: [
`案件数量: ${cellText(count.count_total)}`,
`涉案身份: ${roles.length ? roles.join('、') : '—'}`,
`案件类型: ${cellText(caseType)}`,
],
});
}
return findings;
}
/** 风险事件时间线 */
export function buildTimelineEvents(root) {
const events = [];
const lawsuit = root.personalLawsuit || {};
const overdue = root.loanRiskTagV10 || {};
const v11 = root.loanRiskTagV11 || {};
const risks = root.risks || {};
buildAllCaseList(lawsuit).forEach((c) => {
const date = c.d_larq || c.d_jarq || '';
const month = date ? date.slice(0, 7) : '—';
const isCriminal = c.sectionKey === 'criminal'
|| (c.n_jaay || c.n_laay || '').includes('罪')
|| (c.n_ajlx || '').includes('刑');
const tone = isCriminal ? 'red' : c.sectionKey === 'implement' ? 'orange' : 'blue';
events.push({
date: month,
title: isCriminal ? '刑事案件记录' : `${c.sectionLabel || '司法案件'}记录`,
desc: `${cellText(c.n_laay || c.n_jaay)}\n案件编号:${cellText(c.c_ah)}`,
tone,
sortKey: date || c.c_ah || '',
});
});
const m6Months = Number(v11.als_m6_id_tot_mons) || 0;
if (m6Months > 0) {
const d = new Date();
d.setMonth(d.getMonth() - 6);
events.push({
date: `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`,
title: '多头借贷行为',
desc: '近6个月内多次申请\n非银机构借款',
tone: 'orange',
sortKey: d.toISOString(),
});
}
if (hasCurrentOverdue(overdue, risks) && overdue.latest_overdue_time) {
events.push({
date: overdue.latest_overdue_time,
title: '当前逾期',
desc: `存在当前未结清逾期\n最大逾期金额${cellText(overdue.max_overdue_amt)}`,
tone: 'red',
sortKey: `${overdue.latest_overdue_time}-99`,
});
}
return events
.sort((a, b) => String(a.sortKey).localeCompare(String(b.sortKey)))
.slice(-4);
}
/** 查询记录统计 */
export function buildQueryStats(root) {
const v11 = root.loanRiskTagV11 || {};
const m12All = Number(v11.als_m12_id_coon_allnum) || 0;
const m12Nbank = Number(v11.als_m12_id_nbank_allnum) || 0;
const m12Caoff = Number(v11.als_m12_id_caoff_allnum) || 0;
const m1All = Number(v11.als_m1_id_coon_allnum) || 0;
return {
orgQueries: m12All + m12Nbank + m12Caoff || m12All,
personalQueries: Math.max(0, m1All - 2) || (m1All > 0 ? 2 : 0),
orgCount: m12All || Number(v11.als_m12_id_coon_orgnum) || 0,
latestQuery: cellText(root.loanRiskTagV10?.latest_overdue_time) !== '—'
? `${root.loanRiskTagV10.latest_overdue_time}-10`
: new Date().toISOString().slice(0, 10),
};
}
/** 黑名单命中统计 */
export function buildBlacklistStatus(root) {
const data = root.blackListV121_3 || {};
const periodKeys = Object.keys(data).filter((k) => /^h\d+_30d$/.test(k) || /^ha_30d/.test(k));
const hits = periodKeys.reduce((sum, k) => sum + (Number(data[k]) || 0), 0);
const grade = Number(data.grade) || 0;
return {
hits,
status: hits === 0 && grade <= 1 ? '正常' : grade >= 4 ? '高风险' : grade >= 3 ? '中风险' : '关注',
gradeText: grade >= 4 ? '高风险' : grade >= 3 ? '中风险' : grade >= 2 ? '较低风险' : '低风险',
};
}
/** 综合建议 */
export function buildSuggestions(riskLevel) {
return SUGGESTION_TEMPLATES.filter((item) => item.levels.includes(riskLevel));
}
export const REPORT_USAGE_NOTICE = '数据来源于合法合规渠道,仅供参考,不作为决策依据。';
export const JUDICIAL_USAGE_NOTICE = [
'客户使用本报告,需经过被查询人授权,客户承担因授权不充分引起的任何法律责任。',
'本报告仅限客户内部使用,请妥善保管本报告,不得向任何第三方泄露或允许任何第三方使用本报告。',
'本报告仅供客户参考,不作为客户决策的依据。',
'未经我司书面许可,任何人不得擅自复制、摘录、编辑、转载、披露和发表。',
'请确保在安全的物理及网络环境操作并确保导出内容的保密、安全以及合规应用。',
];
const PIE_COLORS = ['#3182ce', '#48bb78', '#ed8936', '#e53e3e', '#9b59b6', '#5dade2'];
/** 解析 "判决(2),维持(1)" 类分布字符串 */
export function parseStatDistribution(stat) {
if (!stat) return [];
return String(stat)
.split(',')
.map((part) => {
const m = part.trim().match(/^(.+)\((\d+)\)$/);
if (m) return { label: m[1].trim(), count: Number(m[2]) };
return { label: part.trim(), count: 1 };
})
.filter((item) => item.label);
}
export function buildConicGradient(items) {
if (!items.length) return '#e8eef7';
const total = items.reduce((s, i) => s + i.count, 0) || 1;
let acc = 0;
const stops = items.map((item, i) => {
const pct = (item.count / total) * 100;
const start = acc;
acc += pct;
return `${PIE_COLORS[i % PIE_COLORS.length]} ${start}% ${acc}%`;
});
return `conic-gradient(${stops.join(', ')})`;
}
export function toCaseArray(v) {
if (Array.isArray(v)) return v;
if (v && typeof v === 'object' && Object.keys(v).length) return [v];
return [];
}
function isCaseRecord(v) {
return !!(v && typeof v === 'object' && (v.c_ah || v.c_id || v.n_ajbs));
}
/** 从司法分区对象提取案件列表(兼容 { cases: [] } 结构) */
export function extractCaseList(section) {
if (!section) return [];
if (Array.isArray(section)) return section;
if (Array.isArray(section.cases)) return section.cases;
if (isCaseRecord(section)) return [section];
return [];
}
/** 从司法数据中提取列表(兼容多种字段名) */
export function extractJudicialList(data, keys) {
if (!data) return [];
for (const key of keys) {
const list = extractCaseList(data[key]);
if (list.length) return list;
}
return [];
}
export function caseListText(c) {
if (!c) return '—';
return `${c.c_ah || '—'}${c.n_ssdw || '—'}${c.n_ajjzjd || '—'}`;
}
export function limitHighRow(item) {
return {
ah: item.c_ah || item.ah || item.caseCode || '—',
legalPerson: item.legalPerson || item.qyfr || item.businessentity || '—',
entName: item.entName || item.qymc || item.companyName || '—',
court: item.n_jbfy || item.court || item.zxfy || '—',
publishDate: item.publishDate || item.fbrq || item.fb_date || '—',
filingDate: item.d_larq || item.larq || item.regDate || '—',
};
}
const CASE_SECTIONS = [
{ key: 'criminal', label: '刑事案件', tag: 'criminal' },
{ key: 'civil', label: '民事案件', tag: 'civil' },
{ key: 'administrative', label: '行政案件', tag: 'civil' },
{ key: 'implement', label: '执行案件', tag: 'civil' },
{ key: 'preservation', label: '非诉保全审查', tag: 'civil' },
{ key: 'bankrupt', label: '破产案件', tag: 'civil' },
];
/** 汇总全部案件并附带类型标签 */
export function buildAllCaseList(data) {
const list = [];
for (const section of CASE_SECTIONS) {
for (const c of extractCaseList(data[section.key])) {
list.push({
...c,
sectionKey: section.key,
sectionLabel: section.label,
typeTag: section.tag,
});
}
}
return list;
}
/** 司法风险概览四项统计sf.html 左栏) */
export function buildJudicialOverviewStats(data, courtRisk = {}) {
const count = data?.count || {};
const criminalCount = extractCaseList(data?.criminal).length;
const implementCount = extractCaseList(data?.implement).length;
const involvedCount = Number(count.count_beigao) || Number(count.count_total) || 0;
return [
{
label: '涉案人员',
value: courtRisk.shean ? Math.max(involvedCount, 1) : involvedCount,
unit: '人',
},
{
label: '被告人员',
value: Number(count.count_beigao) || 0,
unit: '人',
},
{
label: '刑事案件',
value: criminalCount,
unit: '件',
},
{
label: '执行信息',
value: implementCount,
unit: '条',
},
];
}
/** 主要案件类型分布饼图 */
export function buildMainCaseTypePie(data) {
const items = parseStatDistribution(data?.count?.ay_stat);
const total = items.reduce((s, i) => s + i.count, 0);
return {
total,
items: items.map((item, i) => ({
...item,
color: PIE_COLORS[i % PIE_COLORS.length],
percent: total ? ((item.count / total) * 100).toFixed(1) : '0.0',
})),
gradient: buildConicGradient(items),
};
}
/** 案件详情表格行 */
export function buildCaseTableRows(data) {
return buildAllCaseList(data).map((c) => ({
id: c.c_ah || c.c_id,
caseNo: cellText(c.c_ah),
typeLabel: c.sectionLabel,
typeTag: c.typeTag,
cause: cellText(c.n_laay || c.n_jaay || c.n_laay_tree),
role: cellText(c.n_ssdw),
incidentDate: cellText(c.d_larq),
status: cellText(c.n_ajjzjd),
raw: c,
}));
}
function extractProsecutor(text) {
if (!text) return '—';
const m = String(text).match(/公诉机关[^。,,;]*/);
return m ? m[0].replace(/。$/, '') : '—';
}
function extractParties(dsrxx, roles) {
if (!Array.isArray(dsrxx)) return '—';
const matched = dsrxx.filter((d) => roles.some((r) => (d.n_ssdw || '').includes(r)));
if (!matched.length) return '—';
return matched.map((d) => d.c_mc).join('、');
}
function extractFromJudgment(text, pattern) {
if (!text) return '—';
const m = String(text).match(pattern);
return m ? m[0] : '—';
}
/** 案件详细信息三栏sf.html 底部卡片) */
export function buildCaseDetailColumns(caseItem) {
if (!caseItem) {
return [[], [], []];
}
const pjjg = caseItem.c_gkws_pjjg || '';
const col1 = [
{ label: '案件类型', value: cellText(caseItem.sectionLabel) },
{ label: '案  由', value: cellText(caseItem.n_laay || caseItem.n_jaay) },
{ label: '角  色', value: cellText(caseItem.n_ssdw) },
{ label: '案发时间', value: cellText(caseItem.d_larq) },
{ label: '审理法院', value: cellText(caseItem.n_jbfy) },
{ label: '案件状态', value: cellText(caseItem.n_ajjzjd) },
];
const col2 = [
{
label: '涉案人员',
value: extractParties(caseItem.c_dsrxx, ['被告人', '上诉人', '被执行人', '被申请人']),
},
{ label: '被害人', value: extractParties(caseItem.c_dsrxx, ['被害人']) },
{ label: '公诉机关', value: extractProsecutor(caseItem.c_gkws_dsr) },
];
const col3 = [
{
label: '判决结果',
value: cellText(caseItem.n_jafs || (pjjg ? pjjg.slice(0, 40) + (pjjg.length > 40 ? '…' : '') : '')),
},
{ label: '缓  刑', value: extractFromJudgment(pjjg, /缓刑[^。,,;]*/) },
{ label: '罚  金', value: extractFromJudgment(pjjg, /罚金[^。,,;]*/) },
{ label: '判决日期', value: cellText(caseItem.d_jarq) },
];
return [col1, col2, col3];
}

View File

@@ -0,0 +1,74 @@
.hy-report {
--hy-primary: #3182ce;
--hy-danger: #e53e3e;
--hy-warning: #ed8936;
--hy-success: #48bb78;
--hy-bg: #f0f4f9;
--hy-text: #1a202c;
--hy-muted: #4a5568;
background: var(--hy-bg);
padding: 20px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans SC", sans-serif;
font-size: 14px;
color: var(--hy-text);
}
.hy-container {
max-width: 1200px;
margin: 0 auto;
background: #fff;
border-radius: 16px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.05);
padding: 30px;
}
.hy-card {
background: #fff;
border-radius: 12px;
padding: 24px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.04);
border: 1px solid #e2e8f0;
margin-bottom: 20px;
}
.hy-card-title {
font-size: 16px;
font-weight: 600;
color: var(--hy-text);
margin-bottom: 20px;
display: flex;
align-items: center;
&::before {
content: '';
width: 4px;
height: 16px;
background-color: var(--hy-primary);
margin-right: 8px;
border-radius: 2px;
}
}
.hy-card-title--lg {
font-size: 18px;
margin-bottom: 24px;
&::before {
display: none;
}
}
.hy-text-danger { color: var(--hy-danger); }
.hy-text-warning { color: var(--hy-warning); }
.hy-text-muted { color: var(--hy-muted); }
.hy-small { font-size: 12px; color: #718096; }
@media (max-width: 768px) {
.hy-report {
padding: 12px;
}
.hy-container {
padding: 16px;
}
}

View File

@@ -2,6 +2,11 @@
<div class="panorama-report">
<div class="panorama-main-title"><span>🛡</span> 信用全景扫描</div>
<div v-if="!available" class="panorama-empty">
{{ emptyMessage }}
</div>
<template v-else>
<!-- 第一行指数 + 机构借贷情况 -->
<div class="panorama-row">
<div class="panorama-card">
@@ -219,10 +224,12 @@
</table>
</div>
</div>
</template>
</div>
</template>
<script setup>
import { computed } from 'vue';
import {
CREDIT_PANORAMA_SCORES,
CREDIT_PANORAMA_INSTITUTIONS,
@@ -239,11 +246,15 @@ import {
formatPanoramaYesNo,
getPanoramaRiskTag,
panoramaTableCell,
isPanoramaDataAvailable,
} from '../reportHelper';
defineProps({
const props = defineProps({
data: { type: Object, default: () => ({}) },
});
const available = computed(() => isPanoramaDataAvailable(props.data));
const emptyMessage = computed(() => props.data?.loanRiskTagV21_msg || '数据库未查得');
</script>
<style lang="scss" scoped>
@@ -267,6 +278,16 @@ defineProps({
border-left: 4px solid #d4af37;
}
.panorama-empty {
text-align: center;
padding: 48px 24px;
color: #718096;
font-size: 15px;
background: #f7fafc;
border-radius: 8px;
border: 1px dashed #e2e8f0;
}
.panorama-row {
display: grid;
grid-template-columns: 1fr 1fr;

View File

@@ -223,7 +223,7 @@ import { computed, ref } from 'vue';
import {
parseStatDistribution,
buildConicGradient,
toCaseArray,
extractCaseList,
extractJudicialList,
REPORT_USAGE_NOTICE,
caseListText,
@@ -239,16 +239,23 @@ const pieColors = PIE_COLORS;
const count = computed(() => props.data.count || {});
const caseTypeCards = computed(() => [
{ key: 'administrative', label: '行政案件', count: toCaseArray(props.data.administrative).length },
{ key: 'civil', label: '民事案件', count: toCaseArray(props.data.civil).length },
{ key: 'criminal', label: '刑事案件', count: toCaseArray(props.data.criminal).length },
{ key: 'implement', label: '执行案件', count: toCaseArray(props.data.implement).length },
{ key: 'preservation', label: '非诉保全审查', count: toCaseArray(props.data.preservation).length },
{ key: 'bankrupt', label: '强制清算与破产案件', count: toCaseArray(props.data.bankrupt).length },
]);
const CASE_TYPE_KEYS = [
{ key: 'administrative', label: '行政案件' },
{ key: 'civil', label: '民事案件' },
{ key: 'criminal', label: '刑事案件' },
{ key: 'implement', label: '执行案件' },
{ key: 'preservation', label: '非诉保全审查' },
{ key: 'bankrupt', label: '强制清算与破产案件' },
];
const implementCount = computed(() => toCaseArray(props.data.implement).length);
const caseTypeCards = computed(() =>
CASE_TYPE_KEYS.map((item) => ({
...item,
count: extractCaseList(props.data[item.key]).length,
})),
);
const implementCount = computed(() => extractCaseList(props.data.implement).length);
const defendantBarWidth = computed(() => {
const total = Number(count.value.count_beigao) || 0;
@@ -336,15 +343,12 @@ const pieCharts = computed(() => {
}));
});
const caseSections = computed(() => {
const sections = [
{ key: 'criminal', label: '刑事案件', items: toCaseArray(props.data.criminal) },
{ key: 'civil', label: '民事案件', items: toCaseArray(props.data.civil) },
{ key: 'administrative', label: '行政案件', items: toCaseArray(props.data.administrative) },
{ key: 'implement', label: '执行案件', items: toCaseArray(props.data.implement) },
];
return sections.filter((s) => s.items.length > 0);
});
const caseSections = computed(() =>
CASE_TYPE_KEYS.map((section) => ({
...section,
items: extractCaseList(props.data[section.key]),
})).filter((s) => s.items.length > 0),
);
const allCases = computed(() => {
const list = [];

View File

@@ -2,30 +2,33 @@
<div class="gamma-card">
<div class="gamma-title"><span>🏮</span> 风险汇总</div>
<div class="summary-grid">
<div v-for="cat in RISK_SUMMARY_CATEGORIES" :key="cat.key" class="summary-card">
<div v-for="cat in summaryCards" :key="cat.key" class="summary-card">
<div class="summary-card-title">
<span>{{ cat.icon }}</span>
<span>{{ cat.title }}</span>
</div>
<div
v-for="(item, i) in (risks[cat.key] || [])"
v-for="(item, i) in cat.items"
:key="i"
class="summary-item"
>
{{ item }}
</div>
<div v-if="!(risks[cat.key] || []).length" class="summary-empty">无风险项</div>
<div v-if="!cat.items.length" class="summary-empty">无风险项</div>
</div>
</div>
</div>
</template>
<script setup>
import { RISK_SUMMARY_CATEGORIES } from '../reportHelper';
import { computed } from 'vue';
import { buildRiskSummaryCards } from '../reportHelper';
defineProps({
const props = defineProps({
risks: { type: Object, default: () => ({}) },
});
const summaryCards = computed(() => buildRiskSummaryCards(props.risks));
</script>
<style lang="scss" scoped>

View File

@@ -14,8 +14,33 @@ export const RISK_SUMMARY_CATEGORIES = [
{ key: 'personalLawsuit', title: '司法案件', icon: '📑' },
{ key: 'loanRiskTagV11', title: '借贷意向', icon: '📈' },
{ key: 'loanRiskTagV10', title: '逾期勘测V3', icon: '📑' },
{ key: 'loanRiskTagV12', title: '借贷行为验证', icon: '📊' },
{ key: 'loanRiskTagV21', title: '信用全景扫描', icon: '🛡️' },
{ key: 'loanRiskTagV18', title: '借贷风险', icon: '⚠️' },
];
const RISK_SUMMARY_META = Object.fromEntries(
RISK_SUMMARY_CATEGORIES.map((item, i) => [item.key, { ...item, order: i }]),
);
/** 按已知顺序汇总 risks 各模块命中项,并兼容未预置的 key */
export function buildRiskSummaryCards(risks = {}) {
const keys = Object.keys(risks);
const orderedKeys = [
...RISK_SUMMARY_CATEGORIES.map((c) => c.key).filter((k) => keys.includes(k)),
...keys.filter((k) => !RISK_SUMMARY_META[k]).sort(),
];
return orderedKeys.map((key) => {
const meta = RISK_SUMMARY_META[key];
return {
key,
title: meta?.title ?? key,
icon: meta?.icon ?? '⚠️',
items: Array.isArray(risks[key]) ? risks[key] : [],
};
});
}
export const BLACKLIST_TAGS = [
{ key: 'black_tag04', label: '疑似短期频繁还款失败' },
{ key: 'black_tag05', label: '疑似短期频繁借贷' },
@@ -141,6 +166,15 @@ export function isHit(v) {
return v === 1 || v === '1' || v === true;
}
/** 当前逾期:字段可能是 0/1 标志,也可能是机构数量 */
export function hasCurrentOverdue(overdue = {}, risks = {}) {
if (isHit(overdue.currently_overdue)) return true;
if (overdue.result_code === '1' || overdue.result_code === 1) return true;
const count = Number(overdue.currently_overdue);
if (!Number.isNaN(count) && count > 0) return true;
return (risks.loanRiskTagV10 || []).length > 0;
}
export function formatPair(data, idKey, cellKey) {
if (!data) return '—';
const id = cellText(data[idKey]);
@@ -189,8 +223,20 @@ export function riskLevelColor(level) {
export function parseRoot(data) {
if (!data || typeof data !== 'object') return {};
if (data.result && typeof data.result === 'object') return data.result;
if (data.data?.result) return data.data.result;
if (Array.isArray(data)) {
const first = data[0];
return first && typeof first === 'object' ? parseRoot(first) : {};
}
if (data.result && typeof data.result === 'object') {
if (data.result.riskLevel !== undefined || data.result.riskScore !== undefined || data.result.risks) {
return data.result;
}
if (data.result.result && typeof data.result.result === 'object') {
return data.result.result;
}
return data.result;
}
if (data.data?.result) return parseRoot({ result: data.data.result });
return data;
}
@@ -238,7 +284,7 @@ export function riskLevelStars(level) {
export function buildCourtRiskItems(courtRisk, personalLawsuit) {
const risk = courtRisk || {};
const count = personalLawsuit?.count || {};
const beigaoWei = risk.beigaoWei ?? (Number(count.count_wei_beigao) > 0);
const beigaoWei = risk.beigaoWei ?? risk.beigaowei ?? (Number(count.count_wei_beigao) > 0);
return [
{ key: 'zhixing', label: '是否被执行人员', hit: !!risk.zhixing },
@@ -292,11 +338,32 @@ export function toCaseArray(v) {
return [];
}
function isCaseRecord(v) {
return !!(v && typeof v === 'object' && (v.c_ah || v.c_id || v.n_ajbs));
}
/** 从司法分区对象提取案件列表(兼容 { cases: [] } 结构) */
export function extractCaseList(section) {
if (!section) return [];
if (Array.isArray(section)) return section;
if (Array.isArray(section.cases)) return section.cases;
if (isCaseRecord(section)) return [section];
return [];
}
/** 信用全景扫描模块是否有有效数据 */
export function isPanoramaDataAvailable(data) {
if (!data || typeof data !== 'object') return false;
if (data.loanRiskTagV21_state === false) return false;
if (Number(data.loanRiskTagV21_code) === 1000) return false;
return Object.keys(data).some((k) => k.startsWith('xyp_'));
}
/** 从司法数据中提取列表(兼容多种字段名) */
export function extractJudicialList(data, keys) {
if (!data) return [];
for (const key of keys) {
const list = toCaseArray(data[key]);
const list = extractCaseList(data[key]);
if (list.length) return list;
}
return [];

View File

@@ -0,0 +1,94 @@
<template>
<DWBG9FB2Report
v-if="isDone"
:data="reportData"
:params="reportParams"
:report-date-time="reportDateTime"
/>
<div v-else class="loading-container">
<div class="loading-spinner" />
<p>加载中请稍候...</p>
</div>
</template>
<script setup>
import DWBG9FB2Report from '@/ui/DWBG9FB2/index.vue';
import { extractReportParams, extractReportTime } from '@/ui/DWBG9FB2/reportHelper';
const reportData = ref({});
const reportParams = ref({});
const reportDateTime = ref('');
const isDone = ref(false);
onMounted(async () => {
try {
const response = await fetch('/DWBG9FB2.json');
const json = await response.json();
reportData.value = json;
reportParams.value = extractReportParams(json);
reportDateTime.value =
extractReportTime(json) || new Date().toLocaleString('zh-CN');
isDone.value = true;
} catch (error) {
console.error('[DWBG9FB2Report] 加载示例数据失败:', error);
isDone.value = true;
}
});
</script>
<style lang="scss" scoped>
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100vh;
.loading-spinner {
width: 50px;
height: 50px;
border: 4px solid #f3f3f3;
border-top: 4px solid #0a6e8e;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 20px;
}
p { color: #666; font-size: 16px; }
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>
<style lang="scss">
@media print {
@page {
size: A4;
margin: 10mm;
}
body.dwbg9fb2-printing {
background: #fff !important;
#app {
min-height: auto;
}
.hy-report {
padding: 0 !important;
background: #fff !important;
}
.hy-container {
max-width: none;
}
.no-print {
display: none !important;
}
}
}
</style>