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

1648
public/DWBG9FB2.json Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

1648
public/DWBG9FB2hcl.json Normal file

File diff suppressed because one or more lines are too long

1184
public/DWBG9FB2hzy.json Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

1253
public/DWBG9FB3hcl.json Normal file

File diff suppressed because one or more lines are too long

1029
public/DWBG9FB3hzy.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -5,75 +5,256 @@ import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename); const __dirname = path.dirname(__filename);
// 读取JSON文件 const INPUT_FILES = ['DWBG9FB2.json', 'DWBG9FB3.json'];
// 读取JSON文件
const inputFile = path.join(__dirname, 'DWBG9FB3.json');
const data = JSON.parse(fs.readFileSync(inputFile, 'utf8'));
// 姓名映射表(保持同一姓名脱敏后一致) const ORG_SUFFIXES = [
const nameMap = { '股份有限公司城区支行',
'何志勇': '何某某', '农村商业银行股份有限公司',
'覃圣有': '覃某', '物业服务有限公司南宁分公司',
'刘飞': '刘某某', '物业服务有限公司',
'陈波': '陈某某', '房地产开发有限公司',
'覃小群': '覃某某', '国际大酒店有限公司',
'陈观海': '陈某某', '生态旅游家园开发有限公司',
'刘国富': '刘某某' '农业发展有限公司',
}; '农资有限公司',
'贸易有限公司',
'发展有限公司',
'开发有限公司',
'股份有限公司',
'农村合作银行',
'信用合作联社',
'有限公司',
'分公司',
// 脱敏函数 ];
function desensitizeName(name) {
if (nameMap[name]) { const COMPANY_KEYWORDS = /银行|公司|联社|集团|酒店|有限|股份/;
return nameMap[name];
const NAME_BLACKLIST = new Set([
'当事人', '法律关系', '法律关', '案涉房', '案涉', '房屋', '所有权',
'合同法律', '侵权法律', '物业服务', '诉讼请求', '正当理由',
'民发物业', '民发实', '民发', '广西广为', '广西中硕','广西',
]);
// ── 基础脱敏函数 ──────────────────────────────────────────
function desensitizePersonName(name) {
if (!name || typeof name !== 'string') return name;
if (COMPANY_KEYWORDS.test(name)) return desensitizeCompany(name);
if (name.length <= 1) return name;
return name[0] + '某某';
}
function desensitizeCompany(name) {
if (!name || typeof name !== 'string') return name;
for (const suffix of ORG_SUFFIXES) {
if (!name.endsWith(suffix)) continue;
const core = name.slice(0, -suffix.length);
if (core.startsWith('广西')) return '广西****' + suffix;
if (core.startsWith('湖北')) return '湖北****' + suffix;
if (core.startsWith('桂林市')) return '桂林市****' + suffix;
if (core.startsWith('兴安县')) return '兴安县****' + suffix;
if (core.startsWith('资源县')) return '资源县****' + suffix;
if (/^.+县/.test(core)) return '**县****' + suffix;
if (/^.+市/.test(core)) return '**市****' + suffix;
return core.slice(0, 2) + '****' + suffix;
} }
// 对于未知的姓名,保留姓氏,名字用星号代替
if (name && name.length > 0) { return name.slice(0, 2) + '****';
const surname = name[0]; }
return surname + '某某';
} function desensitizeCourt(court) {
return name; if (!court || typeof court !== 'string') return court;
return court
.replace(/^(.+?市)(.+?区)/, '**市**区')
.replace(/^(.+?市)(.+?县)/, '**市**县')
.replace(/^(.+?县)/, '**县')
.replace(/^(.+?市)/, '**市');
}
function desensitizeProvince(text) {
if (!text || typeof text !== 'string') return text;
return text
.replace(/湖北省/g, '**省')
.replace(/辽宁省/g, '**省');
}
function desensitizeAreaStat(text) {
if (!text || typeof text !== 'string') return text;
return desensitizeProvince(text).replace(/辽宁省\(/g, '**省(');
} }
function desensitizeIdCard(idCard) { function desensitizeIdCard(idCard) {
if (!idCard || idCard.length !== 18) return idCard; if (!idCard || typeof idCard !== 'string') return idCard;
if (idCard.length === 18) {
return idCard.substring(0, 6) + '********' + idCard.substring(14); return idCard.substring(0, 6) + '********' + idCard.substring(14);
}
if (/^[0-9A-Z]{15,18}$/.test(idCard)) {
return idCard.substring(0, 4) + '**********' + idCard.substring(idCard.length - 4);
}
return idCard;
} }
function desensitizeMobile(mobile) { function desensitizeMobile(mobile) {
if (!mobile || mobile.length !== 11) return mobile; if (!mobile || typeof mobile !== 'string' || mobile.length !== 11) return mobile;
return mobile.substring(0, 3) + '****' + mobile.substring(7); return mobile.substring(0, 3) + '****' + mobile.substring(7);
} }
function desensitizeText(text) { function desensitizeAddressText(text) {
if (!text || typeof text !== 'string') return text; if (!text || typeof text !== 'string') return text;
let result = text; let result = text;
// 替换所有出现的人名
for (const [realName, maskedName] of Object.entries(nameMap)) { result = result.replace(/统一社会信用代码[:]?\s*[0-9A-Z]{15,18}/g, (m) => {
// 替换姓名 const code = m.replace(/统一社会信用代码[:]?\s*/, '');
const regex1 = new RegExp(realName, 'g'); return '统一社会信用代码:' + desensitizeIdCard(code);
result = result.replace(regex1, maskedName); });
// 替换姓名+某的形式(如:何志某 -> 何某某某)
const regex2 = new RegExp(realName.substring(0, realName.length - 1) + '某', 'g'); result = result.replace(/账号[:]?\s*[\d×]{10,}/g, '账号:********');
result = result.replace(regex2, maskedName); result = result.replace(/[\u4e00-\u9fa5]{2,6}路\d+号[^,,。;;]*/g, '**路**号****');
} result = result.replace(/民发[·・][\u4e00-\u9fa5A-Za-z0-9]{2,15}?(?:小区|会所)/g, '****小区');
result = result.replace(/民发物业服务有限公司南宁分公司/g, '****物业服务有限公司南宁分公司');
result = result.replace(/民发物业服务有限公司/g, '****物业服务有限公司');
result = result.replace(/民发物业南宁分公司/g, '****物业南宁分公司');
result = result.replace(/民发物业公司/g, '****物业公司');
result = result.replace(/广西中硕资产评估有限责任公司/g, '广西****资产评估有限责任公司');
result = result.replace(/民发实业集团\(广西\)房地产开发有限公司/g, '****实业集团(广西)****开发有限公司');
result = result.replace(/[\u4e00-\u9fa5]{2,4}市[\u4e00-\u9fa5]{2,6}区人民法院/g, '**市**区人民法院');
result = result.replace(/上诉于([\u4e00-\u9fa5]{2,4})市中级人民法院/g, '上诉于**市中级人民法院');
result = result.replace(/开户名称[:]([\u4e00-\u9fa5]{2,4})市中级人民法院/g, '开户名称:**市中级人民法院');
result = result.replace(/([\u4e00-\u9fa5]{2,4})县人民法院/g, '**县人民法院');
result = result.replace(/住广西壮族自治区[^,,。;;]{2,20}?[区县]/g, '住广西壮族自治区**市**区');
result = result.replace(/住所地广西壮族自治区[^,,。;;]{2,30}/g, '住所地广西壮族自治区**市**区****');
result = result.replace(/住所地湖北省[^,,。;;]{2,30}/g, '住所地**省**市**区****');
result = result.replace(/位于[\u4e00-\u9fa5]{2,4}市[\u4e00-\u9fa5]{2,6}区/g, '位于**市**区');
result = result.replace(/坐落于[\u4e00-\u9fa5]{2,4}市[\u4e00-\u9fa5]{2,6}区/g, '坐落于**市**区');
result = result.replace(/系[\u4e00-\u9fa5]{2,5}市[\u4e00-\u9fa5]{2,6}区/g, '系**市**区');
result = result.replace(/[\u4e00-\u9fa5·・A-Za-z0-9]+栋\d+单元\d+号/g, '****栋**单元**号');
return result; return result;
} }
// 递归遍历对象进行脱敏 // ── 从数据中收集替换映射 ──────────────────────────────────
function desensitizeObject(obj) {
function isValidPersonName(name) {
return (
name &&
name.length >= 2 &&
name.length <= 4 &&
!COMPANY_KEYWORDS.test(name) &&
!NAME_BLACKLIST.has(name) &&
/^[\u4e00-\u9fa5]+$/.test(name)
);
}
function collectMappings(data) {
const personNames = new Set();
const companyNames = new Set();
const courtNames = new Set();
function walk(obj) {
if (!obj || typeof obj !== 'object') return;
if (Array.isArray(obj)) {
obj.forEach(walk);
return;
}
if (obj.c_mc) {
if (obj.n_dsrlx === '企业组织' || COMPANY_KEYWORDS.test(obj.c_mc)) {
companyNames.add(obj.c_mc);
} else if (isValidPersonName(obj.c_mc)) {
personNames.add(obj.c_mc);
}
}
if (obj.n_jbfy) courtNames.add(obj.n_jbfy);
for (const field of ['c_gkws_dsr', 'c_gkws_pjjg']) {
if (obj[field]) extractNamesFromLegalText(obj[field], personNames, companyNames);
}
Object.values(obj).forEach(walk);
}
walk(data);
return { personNames, companyNames, courtNames };
}
function extractNamesFromLegalText(text, personNames, companyNames) {
if (!text || typeof text !== 'string') return;
const rolePatterns = [
/(?:原告|被告|上诉人|被上诉人|原审被告人|被告人|负责人|法定代表人|案外人|委托诉讼代理人|代理人|承租人|出租人)[:]([\u4e00-\u9fa5]{2,4})/g,
/与案外人([\u4e00-\u9fa5]{2,4})签/g,
/([\u4e00-\u9fa5]{2,4})所有的/g,
/向([\u4e00-\u9fa5]{2,4})转账/g,
];
let match;
for (const rolePattern of rolePatterns) {
while ((match = rolePattern.exec(text)) !== null) {
if (isValidPersonName(match[1])) personNames.add(match[1]);
}
}
const companyPattern =
/([\u4e00-\u9fa5()·・]{4,40}?(?:有限公司|股份有限公司|合作银行|信用合作联社))/g;
while ((match = companyPattern.exec(text)) !== null) {
companyNames.add(match[1]);
}
}
function buildReplacementList(personNames, companyNames, courtNames) {
const replacements = [];
for (const name of personNames) {
replacements.push({ from: name, to: desensitizePersonName(name) });
}
for (const name of companyNames) {
replacements.push({ from: name, to: desensitizeCompany(name) });
}
for (const court of courtNames) {
replacements.push({ from: court, to: desensitizeCourt(court) });
}
replacements.sort((a, b) => b.from.length - a.from.length);
return replacements;
}
function desensitizeText(text, replacements) {
if (!text || typeof text !== 'string') return text;
let result = text;
for (const { from, to } of replacements) {
if (from && to && from !== to) {
result = result.split(from).join(to);
}
}
result = desensitizeAddressText(result);
result = desensitizeProvince(result);
return result;
}
// ── 递归脱敏 ──────────────────────────────────────────────
function desensitizeObject(obj, replacements) {
if (obj === null || typeof obj !== 'object') { if (obj === null || typeof obj !== 'object') {
return obj; return obj;
} }
if (Array.isArray(obj)) { if (Array.isArray(obj)) {
return obj.map(item => desensitizeObject(item)); return obj.map((item) => desensitizeObject(item, replacements));
} }
const result = {}; const result = {};
for (const [key, value] of Object.entries(obj)) { for (const [key, value] of Object.entries(obj)) {
switch (key) { switch (key) {
case 'name': case 'name':
result[key] = desensitizeName(value); result[key] = desensitizePersonName(value);
break; break;
case 'id_card': case 'id_card':
result[key] = desensitizeIdCard(value); result[key] = desensitizeIdCard(value);
@@ -82,36 +263,69 @@ function desensitizeObject(obj) {
result[key] = desensitizeMobile(value); result[key] = desensitizeMobile(value);
break; break;
case 'c_mc': case 'c_mc':
// 当事人姓名 result[key] =
result[key] = desensitizeName(value); obj.n_dsrlx === '企业组织' || COMPANY_KEYWORDS.test(value)
? desensitizeCompany(value)
: desensitizePersonName(value);
break; break;
case 'c_gkws_dsr': case 'c_gkws_dsr':
case 'c_gkws_pjjg': case 'c_gkws_pjjg':
// 判决书内容中的文本 result[key] = desensitizeText(value, replacements);
result[key] = desensitizeText(value); break;
case 'n_jbfy':
result[key] = desensitizeCourt(value);
break;
case 'c_ssdy':
result[key] = value;
break;
case 'area_stat':
result[key] = desensitizeAreaStat(value);
break; break;
default: default:
result[key] = desensitizeObject(value); result[key] = desensitizeObject(value, replacements);
break; break;
} }
} }
return result; return result;
} }
// 执行脱敏 function processFile(filename) {
const desensitizedData = desensitizeObject(data); const inputFile = path.join(__dirname, filename);
const baseName = filename.replace('.json', '');
const outputFile = path.join(__dirname, `${baseName}_desensitized.json`);
// 保存脱敏后的文件 const data = JSON.parse(fs.readFileSync(inputFile, 'utf8'));
const outputFile = path.join(__dirname, 'DWBG9FB3_desensitized.json'); const { personNames, companyNames, courtNames } = collectMappings(data);
fs.writeFileSync(outputFile, JSON.stringify(desensitizedData, null, 2), 'utf8'); const replacements = buildReplacementList(personNames, companyNames, courtNames);
const desensitizedData = desensitizeObject(data, replacements);
console.log('脱敏完成!'); fs.writeFileSync(outputFile, JSON.stringify(desensitizedData, null, 2), 'utf8');
console.log('原始文件:', inputFile);
console.log('脱敏后文件:', outputFile); console.log(`\n${filename} 脱敏完成`);
console.log(` 原始文件:${inputFile}`);
console.log(` 输出文件:${outputFile}`);
console.log(` 姓名 ${personNames.size} 个,公司 ${companyNames.size} 个,法院 ${courtNames.size}`);
return { personNames, companyNames, courtNames };
}
// ── 执行 ──────────────────────────────────────────────────
console.log('开始脱敏处理...');
const summary = { person: 0, company: 0, court: 0 };
for (const file of INPUT_FILES) {
const stats = processFile(file);
summary.person += stats.personNames.size;
summary.company += stats.companyNames.size;
summary.court += stats.courtNames.size;
}
// 显示脱敏摘要
console.log('\n脱敏摘要'); console.log('\n脱敏摘要');
console.log('- 姓名:已脱敏(保留姓氏)'); console.log('- 姓名:保留姓氏,名字替换为「某某」');
console.log('- 身份证号已脱敏保留前6位和后4位'); console.log('- 公司/机构:保留地区前缀与组织类型,中间替换为「****」');
console.log('- 手机号已脱敏保留前3位和后4位'); console.log('- 法院:市/县/区名称替换为「**」');
console.log('- 判决书文本中的姓名:已批量替换'); console.log('- 省份/地区:次要省份(湖北、辽宁等)脱敏,广西自治区保留');
console.log('- 判决书文本:地址、信用代码、路名等同步脱敏');
console.log('- 身份证号/手机号:按字段规则脱敏');
console.log(`- 合计处理:姓名 ${summary.person} 个,公司 ${summary.company} 个,法院 ${summary.court}`);

View File

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

View File

@@ -22,6 +22,11 @@ const router = createRouter({
name: "DWBG9FB3", name: "DWBG9FB3",
component: () => import("./views/DWBG9FB3Report.vue"), 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-report">
<div class="panorama-main-title"><span>🛡</span> 信用全景扫描</div> <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-row">
<div class="panorama-card"> <div class="panorama-card">
@@ -219,10 +224,12 @@
</table> </table>
</div> </div>
</div> </div>
</template>
</div> </div>
</template> </template>
<script setup> <script setup>
import { computed } from 'vue';
import { import {
CREDIT_PANORAMA_SCORES, CREDIT_PANORAMA_SCORES,
CREDIT_PANORAMA_INSTITUTIONS, CREDIT_PANORAMA_INSTITUTIONS,
@@ -239,11 +246,15 @@ import {
formatPanoramaYesNo, formatPanoramaYesNo,
getPanoramaRiskTag, getPanoramaRiskTag,
panoramaTableCell, panoramaTableCell,
isPanoramaDataAvailable,
} from '../reportHelper'; } from '../reportHelper';
defineProps({ const props = defineProps({
data: { type: Object, default: () => ({}) }, data: { type: Object, default: () => ({}) },
}); });
const available = computed(() => isPanoramaDataAvailable(props.data));
const emptyMessage = computed(() => props.data?.loanRiskTagV21_msg || '数据库未查得');
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@@ -267,6 +278,16 @@ defineProps({
border-left: 4px solid #d4af37; 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 { .panorama-row {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;

View File

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

View File

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

View File

@@ -14,8 +14,33 @@ export const RISK_SUMMARY_CATEGORIES = [
{ key: 'personalLawsuit', title: '司法案件', icon: '📑' }, { key: 'personalLawsuit', title: '司法案件', icon: '📑' },
{ key: 'loanRiskTagV11', title: '借贷意向', icon: '📈' }, { key: 'loanRiskTagV11', title: '借贷意向', icon: '📈' },
{ key: 'loanRiskTagV10', title: '逾期勘测V3', 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 = [ export const BLACKLIST_TAGS = [
{ key: 'black_tag04', label: '疑似短期频繁还款失败' }, { key: 'black_tag04', label: '疑似短期频繁还款失败' },
{ key: 'black_tag05', label: '疑似短期频繁借贷' }, { key: 'black_tag05', label: '疑似短期频繁借贷' },
@@ -141,6 +166,15 @@ export function isHit(v) {
return v === 1 || v === '1' || v === true; 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) { export function formatPair(data, idKey, cellKey) {
if (!data) return '—'; if (!data) return '—';
const id = cellText(data[idKey]); const id = cellText(data[idKey]);
@@ -189,8 +223,20 @@ export function riskLevelColor(level) {
export function parseRoot(data) { export function parseRoot(data) {
if (!data || typeof data !== 'object') return {}; if (!data || typeof data !== 'object') return {};
if (data.result && typeof data.result === 'object') return data.result; if (Array.isArray(data)) {
if (data.data?.result) return data.data.result; 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; return data;
} }
@@ -238,7 +284,7 @@ export function riskLevelStars(level) {
export function buildCourtRiskItems(courtRisk, personalLawsuit) { export function buildCourtRiskItems(courtRisk, personalLawsuit) {
const risk = courtRisk || {}; const risk = courtRisk || {};
const count = personalLawsuit?.count || {}; 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 [ return [
{ key: 'zhixing', label: '是否被执行人员', hit: !!risk.zhixing }, { key: 'zhixing', label: '是否被执行人员', hit: !!risk.zhixing },
@@ -292,11 +338,32 @@ export function toCaseArray(v) {
return []; 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) { export function extractJudicialList(data, keys) {
if (!data) return []; if (!data) return [];
for (const key of keys) { for (const key of keys) {
const list = toCaseArray(data[key]); const list = extractCaseList(data[key]);
if (list.length) return list; if (list.length) return list;
} }
return []; 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>