f
This commit is contained in:
1648
public/DWBG9FB2.json
Normal file
1648
public/DWBG9FB2.json
Normal file
File diff suppressed because one or more lines are too long
1648
public/DWBG9FB2_desensitized.json
Normal file
1648
public/DWBG9FB2_desensitized.json
Normal file
File diff suppressed because one or more lines are too long
1648
public/DWBG9FB2hcl.json
Normal file
1648
public/DWBG9FB2hcl.json
Normal file
File diff suppressed because one or more lines are too long
1184
public/DWBG9FB2hzy.json
Normal file
1184
public/DWBG9FB2hzy.json
Normal file
File diff suppressed because it is too large
Load Diff
2175
public/DWBG9FB3.json
2175
public/DWBG9FB3.json
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
1253
public/DWBG9FB3hcl.json
Normal file
File diff suppressed because one or more lines are too long
1029
public/DWBG9FB3hzy.json
Normal file
1029
public/DWBG9FB3hzy.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -5,75 +5,256 @@ import { fileURLToPath } from 'url';
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
// 读取JSON文件
|
||||
// 读取JSON文件
|
||||
const inputFile = path.join(__dirname, 'DWBG9FB3.json');
|
||||
const data = JSON.parse(fs.readFileSync(inputFile, 'utf8'));
|
||||
const INPUT_FILES = ['DWBG9FB2.json', 'DWBG9FB3.json'];
|
||||
|
||||
// 姓名映射表(保持同一姓名脱敏后一致)
|
||||
const nameMap = {
|
||||
'何志勇': '何某某',
|
||||
'覃圣有': '覃某',
|
||||
'刘飞': '刘某某',
|
||||
'陈波': '陈某某',
|
||||
'覃小群': '覃某某',
|
||||
'陈观海': '陈某某',
|
||||
'刘国富': '刘某某'
|
||||
};
|
||||
const ORG_SUFFIXES = [
|
||||
'股份有限公司城区支行',
|
||||
'农村商业银行股份有限公司',
|
||||
'物业服务有限公司南宁分公司',
|
||||
'物业服务有限公司',
|
||||
'房地产开发有限公司',
|
||||
'国际大酒店有限公司',
|
||||
'生态旅游家园开发有限公司',
|
||||
'农业发展有限公司',
|
||||
'农资有限公司',
|
||||
'贸易有限公司',
|
||||
'发展有限公司',
|
||||
'开发有限公司',
|
||||
'股份有限公司',
|
||||
'农村合作银行',
|
||||
'信用合作联社',
|
||||
'有限公司',
|
||||
'分公司',
|
||||
|
||||
// 脱敏函数
|
||||
function desensitizeName(name) {
|
||||
if (nameMap[name]) {
|
||||
return nameMap[name];
|
||||
];
|
||||
|
||||
const COMPANY_KEYWORDS = /银行|公司|联社|集团|酒店|有限|股份/;
|
||||
|
||||
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) {
|
||||
const surname = name[0];
|
||||
return surname + '某某';
|
||||
}
|
||||
return name;
|
||||
|
||||
return name.slice(0, 2) + '****';
|
||||
}
|
||||
|
||||
function desensitizeCourt(court) {
|
||||
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) {
|
||||
if (!idCard || idCard.length !== 18) return idCard;
|
||||
return idCard.substring(0, 6) + '********' + idCard.substring(14);
|
||||
if (!idCard || typeof idCard !== 'string') return idCard;
|
||||
if (idCard.length === 18) {
|
||||
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) {
|
||||
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);
|
||||
}
|
||||
|
||||
function desensitizeText(text) {
|
||||
function desensitizeAddressText(text) {
|
||||
if (!text || typeof text !== 'string') return text;
|
||||
let result = text;
|
||||
// 替换所有出现的人名
|
||||
for (const [realName, maskedName] of Object.entries(nameMap)) {
|
||||
// 替换姓名
|
||||
const regex1 = new RegExp(realName, 'g');
|
||||
result = result.replace(regex1, maskedName);
|
||||
// 替换姓名+某的形式(如:何志某 -> 何某某某)
|
||||
const regex2 = new RegExp(realName.substring(0, realName.length - 1) + '某', 'g');
|
||||
result = result.replace(regex2, maskedName);
|
||||
}
|
||||
|
||||
result = result.replace(/统一社会信用代码[::]?\s*[0-9A-Z]{15,18}/g, (m) => {
|
||||
const code = m.replace(/统一社会信用代码[::]?\s*/, '');
|
||||
return '统一社会信用代码:' + desensitizeIdCard(code);
|
||||
});
|
||||
|
||||
result = result.replace(/账号[::]?\s*[\d×]{10,}/g, '账号:********');
|
||||
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;
|
||||
}
|
||||
|
||||
// 递归遍历对象进行脱敏
|
||||
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') {
|
||||
return obj;
|
||||
}
|
||||
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.map(item => desensitizeObject(item));
|
||||
return obj.map((item) => desensitizeObject(item, replacements));
|
||||
}
|
||||
|
||||
const result = {};
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
switch (key) {
|
||||
case 'name':
|
||||
result[key] = desensitizeName(value);
|
||||
result[key] = desensitizePersonName(value);
|
||||
break;
|
||||
case 'id_card':
|
||||
result[key] = desensitizeIdCard(value);
|
||||
@@ -82,36 +263,69 @@ function desensitizeObject(obj) {
|
||||
result[key] = desensitizeMobile(value);
|
||||
break;
|
||||
case 'c_mc':
|
||||
// 当事人姓名
|
||||
result[key] = desensitizeName(value);
|
||||
result[key] =
|
||||
obj.n_dsrlx === '企业组织' || COMPANY_KEYWORDS.test(value)
|
||||
? desensitizeCompany(value)
|
||||
: desensitizePersonName(value);
|
||||
break;
|
||||
case 'c_gkws_dsr':
|
||||
case 'c_gkws_pjjg':
|
||||
// 判决书内容中的文本
|
||||
result[key] = desensitizeText(value);
|
||||
result[key] = desensitizeText(value, replacements);
|
||||
break;
|
||||
case 'n_jbfy':
|
||||
result[key] = desensitizeCourt(value);
|
||||
break;
|
||||
case 'c_ssdy':
|
||||
result[key] = value;
|
||||
break;
|
||||
case 'area_stat':
|
||||
result[key] = desensitizeAreaStat(value);
|
||||
break;
|
||||
default:
|
||||
result[key] = desensitizeObject(value);
|
||||
result[key] = desensitizeObject(value, replacements);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// 执行脱敏
|
||||
const desensitizedData = desensitizeObject(data);
|
||||
function processFile(filename) {
|
||||
const inputFile = path.join(__dirname, filename);
|
||||
const baseName = filename.replace('.json', '');
|
||||
const outputFile = path.join(__dirname, `${baseName}_desensitized.json`);
|
||||
|
||||
// 保存脱敏后的文件
|
||||
const outputFile = path.join(__dirname, 'DWBG9FB3_desensitized.json');
|
||||
fs.writeFileSync(outputFile, JSON.stringify(desensitizedData, null, 2), 'utf8');
|
||||
const data = JSON.parse(fs.readFileSync(inputFile, 'utf8'));
|
||||
const { personNames, companyNames, courtNames } = collectMappings(data);
|
||||
const replacements = buildReplacementList(personNames, companyNames, courtNames);
|
||||
const desensitizedData = desensitizeObject(data, replacements);
|
||||
|
||||
console.log('脱敏完成!');
|
||||
console.log('原始文件:', inputFile);
|
||||
console.log('脱敏后文件:', outputFile);
|
||||
fs.writeFileSync(outputFile, JSON.stringify(desensitizedData, null, 2), 'utf8');
|
||||
|
||||
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('- 姓名:已脱敏(保留姓氏)');
|
||||
console.log('- 身份证号:已脱敏(保留前6位和后4位)');
|
||||
console.log('- 手机号:已脱敏(保留前3位和后4位)');
|
||||
console.log('- 判决书文本中的姓名:已批量替换');
|
||||
console.log('- 姓名:保留姓氏,名字替换为「某某」');
|
||||
console.log('- 公司/机构:保留地区前缀与组织类型,中间替换为「****」');
|
||||
console.log('- 法院:市/县/区名称替换为「**」');
|
||||
console.log('- 省份/地区:次要省份(湖北、辽宁等)脱敏,广西自治区保留');
|
||||
console.log('- 判决书文本:地址、信用代码、路名等同步脱敏');
|
||||
console.log('- 身份证号/手机号:按字段规则脱敏');
|
||||
console.log(`- 合计处理:姓名 ${summary.person} 个,公司 ${summary.company} 个,法院 ${summary.court} 个`);
|
||||
|
||||
@@ -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, // 人企关系加强版
|
||||
|
||||
@@ -22,6 +22,11 @@ const router = createRouter({
|
||||
name: "DWBG9FB3",
|
||||
component: () => import("./views/DWBG9FB3Report.vue"),
|
||||
},
|
||||
{
|
||||
path: "/DWBG9FB2",
|
||||
name: "DWBG9FB2",
|
||||
component: () => import("./views/DWBG9FB2Report.vue"),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
301
src/ui/DWBG9FB2/components/ChartsSection.vue
Normal file
301
src/ui/DWBG9FB2/components/ChartsSection.vue
Normal 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>
|
||||
124
src/ui/DWBG9FB2/components/CoreRiskFindingsSection.vue
Normal file
124
src/ui/DWBG9FB2/components/CoreRiskFindingsSection.vue
Normal 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>
|
||||
423
src/ui/DWBG9FB2/components/JudicialCaseSection.vue
Normal file
423
src/ui/DWBG9FB2/components/JudicialCaseSection.vue
Normal 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 ? '收起' : '查看更多' }} >
|
||||
</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>
|
||||
141
src/ui/DWBG9FB2/components/QueryBlacklistSection.vue
Normal file
141
src/ui/DWBG9FB2/components/QueryBlacklistSection.vue
Normal 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>
|
||||
112
src/ui/DWBG9FB2/components/ReportHeaderSection.vue
Normal file
112
src/ui/DWBG9FB2/components/ReportHeaderSection.vue
Normal 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>
|
||||
85
src/ui/DWBG9FB2/components/RiskOverviewSection.vue
Normal file
85
src/ui/DWBG9FB2/components/RiskOverviewSection.vue
Normal 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>
|
||||
167
src/ui/DWBG9FB2/components/RiskRatingSection.vue
Normal file
167
src/ui/DWBG9FB2/components/RiskRatingSection.vue
Normal 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>
|
||||
137
src/ui/DWBG9FB2/components/RiskTimelineSection.vue
Normal file
137
src/ui/DWBG9FB2/components/RiskTimelineSection.vue
Normal 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>
|
||||
101
src/ui/DWBG9FB2/components/SuggestionSection.vue
Normal file
101
src/ui/DWBG9FB2/components/SuggestionSection.vue
Normal 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
115
src/ui/DWBG9FB2/index.vue
Normal 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>
|
||||
18
src/ui/DWBG9FB2/reportExport.js
Normal file
18
src/ui/DWBG9FB2/reportExport.js
Normal 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);
|
||||
}
|
||||
}
|
||||
680
src/ui/DWBG9FB2/reportHelper.js
Normal file
680
src/ui/DWBG9FB2/reportHelper.js
Normal 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];
|
||||
}
|
||||
74
src/ui/DWBG9FB2/shared.scss
Normal file
74
src/ui/DWBG9FB2/shared.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 [];
|
||||
|
||||
94
src/views/DWBG9FB2Report.vue
Normal file
94
src/views/DWBG9FB2Report.vue
Normal 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>
|
||||
Reference in New Issue
Block a user