Files
ccdi/ruoyi-ui/src/views/ccdiProject/components/detail/RiskDetailSection.vue

1412 lines
42 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<section class="risk-detail-section">
<div class="section-card">
<div class="section-header">
<div>
<div class="section-title">风险明细</div>
<div class="section-subtitle">展示涉疑交易与异常账户关联人员信息</div>
</div>
<el-button size="mini" type="text" :disabled="!projectId" @click="handleRiskDetailExport">
导出
</el-button>
</div>
<div class="block">
<div class="block-header">
<div>
<div class="block-title">涉疑交易明细</div>
<div class="block-subtitle">展示涉疑交易的关键字段与风险金额</div>
</div>
</div>
<el-form :inline="true" size="mini" class="suspicious-filter-form" @submit.native.prevent>
<el-form-item label="命中模型">
<el-select
v-model="selectedModelCode"
class="suspicious-filter-select"
:placeholder="allModelOptionLabel"
filterable
@change="handleSuspiciousModelSelectChange"
>
<el-option :label="allModelOptionLabel" value="" />
<el-option
v-for="item in displayRiskModelOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="预警类型">
<el-select
v-model="currentSuspiciousType"
class="suspicious-filter-select suspicious-type-select"
placeholder="全部预警类型"
@change="handleSuspiciousTypeSelectChange"
>
<el-option
v-for="item in suspiciousTypeOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="el-icon-search" :loading="suspiciousLoading" @click="handleSuspiciousQuery">
查询
</el-button>
</el-form-item>
</el-form>
<el-table
v-loading="suspiciousLoading"
:data="suspiciousTransactionList"
class="result-table"
>
<template slot="empty">
<el-empty :image-size="96" description="当前筛选条件下暂无涉疑交易明细" />
</template>
<el-table-column prop="trxDate" label="交易时间" min-width="180" />
<el-table-column label="本方账户" min-width="220">
<template slot-scope="scope">
<div class="multi-line-cell">
<div class="primary-text">{{ formatField(scope.row.leAccountNo) }}</div>
<div class="secondary-text">{{ formatField(scope.row.leAccountName) }}</div>
</div>
</template>
</el-table-column>
<el-table-column label="对方账户" min-width="220">
<template slot-scope="scope">
<div class="multi-line-cell">
<div class="primary-text">
{{ formatCounterpartyName(scope.row) }}
</div>
<div class="secondary-text">
{{ formatField(scope.row.customerAccountNo) }}
</div>
</div>
</template>
</el-table-column>
<el-table-column label="关联员工" min-width="160">
<template slot-scope="scope">
<div class="multi-line-cell">
<div class="primary-text">{{ formatRelatedStaff(scope.row) }}</div>
</div>
</template>
</el-table-column>
<el-table-column label="摘要 / 交易类型" min-width="200">
<template slot-scope="scope">
<div class="multi-line-cell">
<div class="primary-text">{{ formatField(scope.row.userMemo) }}</div>
<div class="secondary-text">{{ formatField(scope.row.cashType) }}</div>
</div>
</template>
</el-table-column>
<el-table-column prop="displayAmount" label="交易金额" width="120" align="right">
<template slot-scope="scope">
<span
class="amount-text"
:class="scope.row.displayAmount >= 0 ? 'amount-in' : 'amount-out'"
>
{{ formatAmount(scope.row.displayAmount) }}
</span>
</template>
</el-table-column>
<el-table-column label="异常标签" min-width="220">
<template slot-scope="scope">
<div v-if="scope.row.hitTags && scope.row.hitTags.length" class="hit-tag-list">
<el-tag
v-for="(tag, index) in scope.row.hitTags"
:key="`${scope.row.bankStatementId}-tag-${index}`"
size="mini"
:type="mapRiskLevelToTagType(tag.riskLevel)"
effect="plain"
>
{{ tag.ruleName }}
</el-tag>
</div>
<span v-else class="empty-text">-</span>
</template>
</el-table-column>
<el-table-column label="详情" width="100" fixed="right" align="center">
<template slot-scope="scope">
<el-button type="text" size="mini" @click="handleViewDetail(scope.row)">
详情
</el-button>
</template>
</el-table-column>
</el-table>
<pagination
v-show="suspiciousTotal > 0"
:total="suspiciousTotal"
:page.sync="suspiciousPageNum"
:limit.sync="suspiciousPageSize"
:page-sizes="riskDetailPageSizeOptions"
layout="total, sizes, prev, pager, next, jumper"
@pagination="handlePageChange"
/>
</div>
<div class="block">
<div class="block-header">
<div>
<div class="block-title">员工负面征信信息</div>
<div class="block-subtitle">展示当前项目员工的负面征信信息</div>
</div>
</div>
<el-table
v-loading="employeeCreditNegativeLoading"
:data="employeeCreditNegativeList"
class="result-table"
>
<template slot="empty">
<el-empty :image-size="96" description="当前项目暂无员工负面征信信息" />
</template>
<el-table-column prop="personName" label="员工姓名" min-width="140" />
<el-table-column label="身份证号" min-width="180" show-overflow-tooltip>
<template slot-scope="scope">
{{ formatIdCard(scope.row.personId) }}
</template>
</el-table-column>
<el-table-column prop="queryDate" label="最近征信查询日期" min-width="160" />
<el-table-column prop="civilCnt" label="民事案件笔数" min-width="120" align="center" />
<el-table-column label="民事案件金额" min-width="140" align="right">
<template slot-scope="scope">
{{ formatAmount(scope.row.civilLmt) }}
</template>
</el-table-column>
<el-table-column prop="enforceCnt" label="强制执行笔数" min-width="120" align="center" />
<el-table-column label="强制执行金额" min-width="140" align="right">
<template slot-scope="scope">
{{ formatAmount(scope.row.enforceLmt) }}
</template>
</el-table-column>
<el-table-column prop="admCnt" label="行政处罚笔数" min-width="120" align="center" />
<el-table-column label="行政处罚金额" min-width="140" align="right">
<template slot-scope="scope">
{{ formatAmount(scope.row.admLmt) }}
</template>
</el-table-column>
</el-table>
<pagination
v-show="employeeCreditNegativeTotal > 0"
:total="employeeCreditNegativeTotal"
:page.sync="employeeCreditNegativePageNum"
:limit.sync="employeeCreditNegativePageSize"
:page-sizes="riskDetailPageSizeOptions"
layout="total, sizes, prev, pager, next, jumper"
@pagination="handleEmployeeCreditNegativePageChange"
/>
</div>
<div class="block">
<div class="block-header">
<div>
<div class="block-title">异常账户人员信息</div>
<div class="block-subtitle">展示异常账户关联人员与处理状态</div>
</div>
</div>
<el-table
v-loading="abnormalAccountLoading"
:data="abnormalAccountList"
class="result-table"
>
<template slot="empty">
<el-empty :image-size="96" description="当前项目暂无异常账户人员信息" />
</template>
<el-table-column prop="accountNo" label="账号" min-width="160" />
<el-table-column prop="accountName" label="开户人" min-width="120" />
<el-table-column prop="bankName" label="银行" min-width="180" />
<el-table-column prop="abnormalType" label="异常类型" min-width="160" />
<el-table-column prop="abnormalTime" label="异常发生时间" min-width="160" />
<el-table-column prop="status" label="状态" min-width="120" />
<el-table-column prop="reasonDetail" label="命中原因" min-width="260" show-overflow-tooltip />
<el-table-column prop="involvedAmount" label="涉及金额" min-width="130" align="right">
<template slot-scope="scope">{{ formatAmount(scope.row.involvedAmount) }}</template>
</el-table-column>
</el-table>
<pagination
v-show="abnormalAccountTotal > 0"
:total="abnormalAccountTotal"
:page.sync="abnormalAccountPageNum"
:limit.sync="abnormalAccountPageSize"
:page-sizes="riskDetailPageSizeOptions"
layout="total, sizes, prev, pager, next, jumper"
@pagination="handleAbnormalAccountPageChange"
/>
</div>
</div>
<el-dialog
:visible.sync="detailVisible"
append-to-body
custom-class="detail-dialog"
width="980px"
@close="closeDetailDialog"
>
<template slot="title">
<div class="detail-dialog-title">
<span>流水详情</span>
<el-button
class="evidence-corner-btn"
size="mini"
plain
:disabled="detailLoading || !buildFlowEvidenceFingerprint(detailData)"
@click="handleAddEvidence"
>
加入证据库
</el-button>
</div>
</template>
<div v-loading="detailLoading" class="detail-dialog-body">
<div class="detail-overview-grid">
<div class="detail-field">
<div class="detail-label">交易时间</div>
<div class="detail-value">{{ formatField(detailData.trxDate) }}</div>
</div>
<div class="detail-field">
<div class="detail-label">交易金额</div>
<div class="detail-value amount-text" :class="getAmountClass(detailData.displayAmount)">
{{ formatSignedAmount(detailData.displayAmount) }}
</div>
</div>
<div class="detail-field">
<div class="detail-label">交易后余额</div>
<div class="detail-value">{{ formatAmount(detailData.amountBalance) }}</div>
</div>
<div class="detail-field">
<div class="detail-label">本方主体</div>
<div class="detail-value">{{ formatField(detailData.leAccountName) }}</div>
</div>
<div class="detail-field">
<div class="detail-label">本方账号</div>
<div class="detail-value">{{ formatField(detailData.leAccountNo) }}</div>
</div>
<div class="detail-field">
<div class="detail-label">本方银行</div>
<div class="detail-value">{{ formatField(detailData.bank) }}</div>
</div>
<div class="detail-field">
<div class="detail-label">对方名称</div>
<div class="detail-value">{{ formatCounterpartyName(detailData) }}</div>
</div>
<div class="detail-field">
<div class="detail-label">对方账户</div>
<div class="detail-value">{{ formatField(detailData.customerAccountNo) }}</div>
</div>
<div class="detail-field">
<div class="detail-label">对方银行</div>
<div class="detail-value">{{ formatField(detailData.customerBank) }}</div>
</div>
<div class="detail-field">
<div class="detail-label">摘要</div>
<div class="detail-value">{{ formatField(detailData.userMemo) }}</div>
</div>
<div class="detail-field">
<div class="detail-label">交易类型</div>
<div class="detail-value">{{ formatField(detailData.cashType) }}</div>
</div>
<div class="detail-field">
<div class="detail-label">银行摘要</div>
<div class="detail-value">{{ formatField(detailData.bankComments) }}</div>
</div>
<div class="detail-field detail-field--full">
<div class="detail-label">原始文件</div>
<div class="detail-file-block">
<i class="el-icon-document detail-file-icon"></i>
<div class="detail-file-meta">
<div class="detail-file-name">{{ formatOriginalFileName(detailData) }}</div>
<div class="detail-file-time">
上传时间{{ formatOriginalFileUploadTime(detailData) }}
</div>
</div>
</div>
</div>
</div>
<div class="detail-hit-tag-section">
<div class="detail-section-title">命中异常标签</div>
<div
v-if="detailData.hitTags && detailData.hitTags.length"
class="detail-hit-tag-items"
>
<div
v-for="(tag, index) in detailData.hitTags"
:key="`detail-tag-${index}`"
class="detail-hit-tag-item"
>
<div class="detail-hit-tag-header">
<span class="detail-hit-tag-name">{{ formatField(tag.ruleName) }}</span>
<el-tag size="mini" :type="mapRiskLevelToTagType(tag.riskLevel)" effect="plain">
{{ formatRiskLevel(tag.riskLevel) }}
</el-tag>
</div>
<div class="detail-hit-tag-reason">{{ formatField(tag.reasonDetail) }}</div>
</div>
</div>
<div v-else class="detail-hit-tag-empty">当前流水未命中异常标签</div>
</div>
</div>
<div slot="footer" class="detail-dialog-footer">
<el-button @click="closeDetailDialog">取消</el-button>
<el-button type="primary" @click="closeDetailDialog">确定</el-button>
</div>
</el-dialog>
</section>
</template>
<script>
import {
getOverviewAbnormalAccountPeople,
getOverviewEmployeeCreditNegative,
getOverviewSuspiciousTransactions,
} from "@/api/ccdi/projectOverview";
import { getBankStatementDetail } from "@/api/ccdiProjectBankStatement";
import { buildFlowEvidenceFingerprint, buildFlowEvidenceSnapshot } from "@/utils/ccdiEvidence";
const SUSPICIOUS_TYPE_OPTIONS = [
{ value: "ALL", label: "全部预警类型" },
{ value: "MODEL_RULE", label: "员工规则命中" },
{ value: "EXTERNAL_PERSON", label: "外部人员预警" },
];
const EXTERNAL_RISK_MODEL_OPTIONS = [
{ value: "EXTERNAL_LARGE_TRANSACTION", label: "外部人员大额交易" },
{ value: "EXTERNAL_SUSPICIOUS_GAMBLING", label: "外部人员可疑赌博" },
{ value: "EXTERNAL_SUSPICIOUS_RELATION", label: "外部人员可疑关系" },
];
const DEFAULT_SUSPICIOUS_PAGE_SIZE = 10;
const DEFAULT_RISK_DETAIL_PAGE_SIZE = 5;
const RISK_DETAIL_PAGE_SIZE_OPTIONS = [5, 10, 20, 50];
const normalizeSuspiciousType = (value) => (
SUSPICIOUS_TYPE_OPTIONS.some((item) => item.value === value) ? value : "ALL"
);
const normalizeRiskModelOptions = (rows) => {
if (!Array.isArray(rows)) {
return [];
}
const seen = new Set();
return rows
.map((item) => ({
value: item && (item.value || item.modelCode || item.key),
label: item && (item.label || item.modelName || item.title),
}))
.filter((item) => {
if (!item.value || seen.has(item.value)) {
return false;
}
seen.add(item.value);
return true;
});
};
const mergeRiskModelOptions = (...optionGroups) => {
const seen = new Set();
return optionGroups
.flat()
.filter((item) => {
if (!item || !item.value || seen.has(item.value)) {
return false;
}
seen.add(item.value);
return true;
});
};
const isExternalModelCode = (value) => (
typeof value === "string" && value.indexOf("EXTERNAL_") === 0
);
const ABNORMAL_CUSTOMER_TRANSACTION_RULE_CODE = "ABNORMAL_CUSTOMER_TRANSACTION";
const ABNORMAL_CUSTOMER_TRANSACTION_RULE_NAME = "与客户之间非正常资金往来";
const resolveAbnormalCustomerTransactionRuleName = (nameListHitType) => {
if (nameListHitType === "中介") {
return "疑似与中介往来";
}
if (nameListHitType === "信贷客户") {
return "与信贷客户之间非正常资金往来";
}
return ABNORMAL_CUSTOMER_TRANSACTION_RULE_NAME;
};
const normalizeRuleName = (tag, context) => {
if (!tag) {
return "";
}
if (
tag.ruleCode === ABNORMAL_CUSTOMER_TRANSACTION_RULE_CODE
|| tag.ruleName === "异常交易"
|| tag.ruleName === ABNORMAL_CUSTOMER_TRANSACTION_RULE_NAME
) {
return resolveAbnormalCustomerTransactionRuleName(context && context.nameListHitType);
}
return tag.ruleName || "";
};
const normalizeHitTags = (hitTags, context = {}) => {
if (!Array.isArray(hitTags)) {
return [];
}
const seen = new Set();
return hitTags
.map((tag) => ({
...tag,
ruleName: normalizeRuleName(tag, context),
}))
.filter((tag) => {
const key = `${tag.ruleCode || ""}|${tag.ruleName || ""}`;
if (seen.has(key)) {
return false;
}
seen.add(key);
return true;
})
.sort((left, right) => {
const leftPriority = left.ruleCode === ABNORMAL_CUSTOMER_TRANSACTION_RULE_CODE ? 0 : 1;
const rightPriority = right.ruleCode === ABNORMAL_CUSTOMER_TRANSACTION_RULE_CODE ? 0 : 1;
return leftPriority - rightPriority;
});
};
const createEmptyDetailData = () => ({
bankStatementId: "",
projectId: "",
currency: "",
trxDate: "",
leAccountNo: "",
leAccountName: "",
customerAccountName: "",
customerAccountNo: "",
customerBank: "",
customerReference: "",
userMemo: "",
bankComments: "",
bankTrxNumber: "",
bank: "",
cashType: "",
amountDr: "",
amountCr: "",
amountBalance: "",
displayAmount: "",
trxFlag: "",
trxType: "",
exceptionType: "",
internalFlag: "",
paymentMethod: "",
cretNo: "",
createDate: "",
originalFileName: "",
uploadTime: "",
sourceFileName: "",
fileName: "",
hitTags: [],
});
const normalizeDetailData = (detail) => ({
...createEmptyDetailData(),
...(detail || {}),
hitTags: normalizeHitTags(detail && detail.hitTags),
});
const normalizeSuspiciousRows = (rows) => (
Array.isArray(rows)
? rows.map((row) => ({
...row,
hitTags: normalizeHitTags(row && row.hitTags, row),
}))
: []
);
export default {
name: "RiskDetailSection",
props: {
sectionData: {
type: Object,
default: () => ({}),
},
},
data() {
return {
suspiciousLoading: false,
detailLoading: false,
detailVisible: false,
detailData: createEmptyDetailData(),
currentSuspiciousType: "ALL",
selectedModelCode: "",
suspiciousPageNum: 1,
suspiciousPageSize: DEFAULT_SUSPICIOUS_PAGE_SIZE,
suspiciousTotal: 0,
suspiciousTransactionList: [],
riskModelOptions: [],
employeeCreditNegativeLoading: false,
employeeCreditNegativePageNum: 1,
employeeCreditNegativePageSize: DEFAULT_RISK_DETAIL_PAGE_SIZE,
employeeCreditNegativeTotal: 0,
employeeCreditNegativeList: [],
abnormalAccountLoading: false,
abnormalAccountPageNum: 1,
abnormalAccountPageSize: DEFAULT_RISK_DETAIL_PAGE_SIZE,
abnormalAccountTotal: 0,
abnormalAccountList: [],
projectId: null,
statementDetailCache: {},
suspiciousRequestSeq: 0,
detailRequestSeq: 0,
};
},
computed: {
riskDetailPageSizeOptions() {
return RISK_DETAIL_PAGE_SIZE_OPTIONS;
},
suspiciousTypeOptions() {
return SUSPICIOUS_TYPE_OPTIONS;
},
isExternalSuspiciousType() {
return this.currentSuspiciousType === "EXTERNAL_PERSON";
},
displayRiskModelOptions() {
if (this.isExternalSuspiciousType) {
return EXTERNAL_RISK_MODEL_OPTIONS;
}
if (this.currentSuspiciousType === "ALL") {
return mergeRiskModelOptions(this.riskModelOptions, EXTERNAL_RISK_MODEL_OPTIONS);
}
return this.riskModelOptions;
},
allModelOptionLabel() {
return this.isExternalSuspiciousType ? "全部外部模型" : "全部模型";
},
},
watch: {
sectionData: {
immediate: true,
handler(value) {
const nextProjectId = value && value.projectId ? value.projectId : null;
const projectChanged = String(nextProjectId || "") !== String(this.projectId || "");
this.projectId = nextProjectId;
this.riskModelOptions = normalizeRiskModelOptions(value && value.riskModelOptions);
this.employeeCreditNegativePageNum = 1;
this.employeeCreditNegativePageSize = DEFAULT_RISK_DETAIL_PAGE_SIZE;
this.employeeCreditNegativeTotal = Number(value && value.employeeCreditNegativeTotal) || 0;
this.abnormalAccountPageNum = 1;
this.abnormalAccountPageSize = DEFAULT_RISK_DETAIL_PAGE_SIZE;
this.abnormalAccountTotal = 0;
this.abnormalAccountList = [];
this.employeeCreditNegativeList = Array.isArray(value && value.employeeCreditNegativeList)
? value.employeeCreditNegativeList
: [];
if (projectChanged) {
this.suspiciousRequestSeq += 1;
this.detailRequestSeq += 1;
this.currentSuspiciousType = normalizeSuspiciousType(value && value.suspiciousType);
this.selectedModelCode = "";
this.suspiciousPageNum = 1;
this.suspiciousPageSize = DEFAULT_SUSPICIOUS_PAGE_SIZE;
this.suspiciousTotal = Number(value && value.total) || 0;
this.suspiciousTransactionList = normalizeSuspiciousRows(
value && value.suspiciousTransactionList
);
this.suspiciousLoading = false;
this.statementDetailCache = {};
this.closeDetailDialog();
}
this.loadAbnormalAccountPeople();
},
},
},
methods: {
buildFlowEvidenceFingerprint,
handleSuspiciousModelSelectChange() {
this.resetSuspiciousFilterResults();
},
handleSuspiciousTypeSelectChange(value) {
if (value === "EXTERNAL_PERSON") {
const hasExternalModel = EXTERNAL_RISK_MODEL_OPTIONS
.some((item) => item.value === this.selectedModelCode);
if (!hasExternalModel) {
this.selectedModelCode = "";
}
} else if (value === "MODEL_RULE" && isExternalModelCode(this.selectedModelCode)) {
this.selectedModelCode = "";
}
this.resetSuspiciousFilterResults();
},
resetSuspiciousFilterResults() {
this.suspiciousRequestSeq += 1;
this.detailRequestSeq += 1;
this.suspiciousPageNum = 1;
this.suspiciousTotal = 0;
this.suspiciousTransactionList = [];
this.suspiciousLoading = false;
this.statementDetailCache = {};
this.closeDetailDialog();
},
async handleSuspiciousQuery() {
this.suspiciousPageNum = 1;
await this.loadSuspiciousTransactions();
},
async refreshAfterRiskExcluded() {
this.statementDetailCache = {};
await this.loadSuspiciousTransactions();
if (this.detailVisible && this.detailData && this.detailData.bankStatementId) {
try {
this.detailLoading = true;
this.detailData = await this.fetchStatementDetail(this.detailData.bankStatementId, false);
} catch (error) {
this.$message.error("刷新流水详情失败");
console.error("刷新流水详情失败", error);
} finally {
this.detailLoading = false;
}
}
},
async handlePageChange(pageInfo) {
if (typeof pageInfo === "number") {
this.suspiciousPageNum = pageInfo;
} else {
this.suspiciousPageNum = pageInfo.page;
this.suspiciousPageSize = pageInfo.limit || this.suspiciousPageSize;
}
await this.loadSuspiciousTransactions();
},
async handleEmployeeCreditNegativePageChange(pageInfo) {
if (typeof pageInfo === "number") {
this.employeeCreditNegativePageNum = pageInfo;
} else {
this.employeeCreditNegativePageNum = pageInfo.page;
this.employeeCreditNegativePageSize = pageInfo.limit || this.employeeCreditNegativePageSize;
}
await this.loadEmployeeCreditNegative();
},
async handleAbnormalAccountPageChange(pageInfo) {
if (typeof pageInfo === "number") {
this.abnormalAccountPageNum = pageInfo;
} else {
this.abnormalAccountPageNum = pageInfo.page;
this.abnormalAccountPageSize = pageInfo.limit || this.abnormalAccountPageSize;
}
await this.loadAbnormalAccountPeople();
},
async loadSuspiciousTransactions() {
const requestSeq = ++this.suspiciousRequestSeq;
if (!this.projectId) {
this.suspiciousTransactionList = [];
this.suspiciousTotal = 0;
this.suspiciousLoading = false;
return;
}
const query = {
projectId: this.projectId,
modelCode: this.selectedModelCode,
suspiciousType: this.currentSuspiciousType,
pageNum: this.suspiciousPageNum,
pageSize: this.suspiciousPageSize,
};
this.suspiciousLoading = true;
try {
const response = await getOverviewSuspiciousTransactions(query);
if (requestSeq !== this.suspiciousRequestSeq) {
return;
}
const data = (response && response.data) || {};
this.suspiciousTotal = Number(data.total) || 0;
this.suspiciousTransactionList = normalizeSuspiciousRows(data.rows);
} catch (error) {
if (requestSeq !== this.suspiciousRequestSeq) {
return;
}
this.suspiciousTransactionList = [];
this.suspiciousTotal = 0;
this.$message.error("加载涉疑交易明细失败");
console.error("加载涉疑交易明细失败", error);
} finally {
if (requestSeq === this.suspiciousRequestSeq) {
this.suspiciousLoading = false;
}
}
},
async loadEmployeeCreditNegative() {
if (!this.projectId) {
this.employeeCreditNegativeList = [];
this.employeeCreditNegativeTotal = 0;
this.employeeCreditNegativeLoading = false;
return;
}
this.employeeCreditNegativeLoading = true;
try {
const response = await getOverviewEmployeeCreditNegative({
projectId: this.projectId,
pageNum: this.employeeCreditNegativePageNum,
pageSize: this.employeeCreditNegativePageSize,
});
const data = (response && response.data) || {};
this.employeeCreditNegativeList = Array.isArray(data.rows) ? data.rows : [];
this.employeeCreditNegativeTotal = Number(data.total) || 0;
} catch (error) {
this.employeeCreditNegativeList = [];
this.employeeCreditNegativeTotal = 0;
this.$message.error("加载员工负面征信信息失败");
console.error("加载员工负面征信信息失败", error);
} finally {
this.employeeCreditNegativeLoading = false;
}
},
async loadAbnormalAccountPeople() {
if (!this.projectId) {
this.abnormalAccountList = [];
this.abnormalAccountTotal = 0;
this.abnormalAccountLoading = false;
return;
}
this.abnormalAccountLoading = true;
try {
const response = await getOverviewAbnormalAccountPeople({
projectId: this.projectId,
pageNum: this.abnormalAccountPageNum,
pageSize: this.abnormalAccountPageSize,
});
const data = (response && response.data) || {};
this.abnormalAccountList = Array.isArray(data.rows) ? data.rows : [];
this.abnormalAccountTotal = Number(data.total) || 0;
} catch (error) {
this.abnormalAccountList = [];
this.abnormalAccountTotal = 0;
this.$message.error("加载异常账户人员信息失败");
console.error("加载异常账户人员信息失败", error);
} finally {
this.abnormalAccountLoading = false;
}
},
async fetchStatementDetail(bankStatementId, silent, scope = {}) {
if (!bankStatementId) {
return createEmptyDetailData();
}
const modelCode = scope.modelCode !== undefined ? scope.modelCode : this.selectedModelCode;
const suspiciousType = scope.suspiciousType || this.currentSuspiciousType;
const cacheKey = `${bankStatementId}|${suspiciousType || "ALL"}|${modelCode || "ALL"}`;
if (this.statementDetailCache[cacheKey]) {
return this.statementDetailCache[cacheKey];
}
try {
const response = await getBankStatementDetail(bankStatementId, {
modelCode,
suspiciousType,
});
const detail = normalizeDetailData(response && response.data);
this.$set(this.statementDetailCache, cacheKey, detail);
return detail;
} catch (error) {
if (!silent) {
throw error;
}
return createEmptyDetailData();
}
},
async handleViewDetail(row) {
if (!row || !row.bankStatementId) {
return;
}
const requestSeq = ++this.detailRequestSeq;
const scope = {
modelCode: this.selectedModelCode,
suspiciousType: this.currentSuspiciousType,
};
this.detailVisible = true;
this.detailLoading = true;
try {
const detail = await this.fetchStatementDetail(row.bankStatementId, false, scope);
if (requestSeq !== this.detailRequestSeq) {
return;
}
this.detailData = {
...detail,
...row,
hitTags: row && Array.isArray(row.hitTags) && row.hitTags.length
? normalizeHitTags(row.hitTags, row)
: normalizeHitTags(detail.hitTags, row),
};
} catch (error) {
if (requestSeq !== this.detailRequestSeq) {
return;
}
this.detailData = createEmptyDetailData();
this.$message.error("加载流水详情失败");
console.error("加载流水详情失败", error);
} finally {
if (requestSeq === this.detailRequestSeq) {
this.detailLoading = false;
}
}
},
closeDetailDialog() {
this.detailVisible = false;
this.detailLoading = false;
this.detailData = createEmptyDetailData();
},
handleAddEvidence() {
const detail = this.detailData || {};
const sourceRecordId = buildFlowEvidenceFingerprint(detail);
const amountText = this.formatSignedAmount(detail.displayAmount);
const counterparty = this.formatCounterpartyName(detail);
const relatedPersonName = this.resolveFlowRelatedPerson(detail);
const hitTagText = Array.isArray(detail.hitTags) && detail.hitTags.length
? `,命中${detail.hitTags.map((tag) => tag.ruleName).filter(Boolean).join("、")}标签`
: "";
this.$emit("evidence-confirm", {
evidenceType: "FLOW",
relatedPersonName,
relatedPersonId: detail.cretNo || "",
evidenceTitle: `${relatedPersonName} / ${this.formatField(detail.leAccountNo)}`,
evidenceSummary: `${this.formatField(detail.trxDate)}${relatedPersonName}账户与${counterparty}发生交易,金额${amountText}${hitTagText}`,
sourceType: "BANK_STATEMENT",
sourceRecordId,
sourcePage: "流水详情",
snapshotJson: JSON.stringify(buildFlowEvidenceSnapshot(detail)),
});
},
resolveFlowRelatedPerson(detail) {
const value = this.formatField(detail.leAccountName);
return value === "-" ? "关联人员" : value;
},
handleRiskDetailExport() {
if (!this.projectId) {
return;
}
this.download(
"ccdi/project/overview/risk-details/export",
{
projectId: this.projectId,
modelCode: this.selectedModelCode,
suspiciousType: this.currentSuspiciousType,
},
`风险明细_${this.projectId}_${new Date().getTime()}.xlsx`
);
},
formatRelatedStaff(row) {
if (!row || !row.relatedStaffName) {
return "-";
}
return row.relatedStaffCode
? `${row.relatedStaffName}(${row.relatedStaffCode})`
: row.relatedStaffName;
},
formatSummaryAndCashType(row) {
const summary = row && row.userMemo ? row.userMemo : "";
const cashType = row && row.cashType ? row.cashType : "";
return `${summary}/${cashType}`;
},
formatCounterpartyName(detail) {
if (!detail) {
return "-";
}
return this.formatField(detail.customerAccountName);
},
formatField(value) {
if (value === null || value === undefined || value === "") {
return "-";
}
return String(value);
},
formatIdCard(value) {
if (value === null || value === undefined || value === "") {
return "-";
}
return String(value);
},
formatAmount(value) {
if (value === null || value === undefined || value === "") {
return "-";
}
const amount = Number(value);
if (Number.isNaN(amount)) {
return "-";
}
return amount.toLocaleString("zh-CN", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
},
formatSignedAmount(value) {
if (value === null || value === undefined || value === "") {
return "-";
}
const amount = Number(value);
if (Number.isNaN(amount)) {
return "-";
}
const text = this.formatAmount(amount);
return amount >= 0 ? `+${text}` : `-${this.formatAmount(Math.abs(amount))}`;
},
getAmountClass(value) {
if (value === null || value === undefined || value === "") {
return "";
}
return Number(value) >= 0 ? "amount-in" : "amount-out";
},
formatOriginalFileName(detail) {
if (!detail) {
return "暂无原始文件";
}
return (
detail.originalFileName ||
detail.sourceFileName ||
detail.fileName ||
"暂无原始文件"
);
},
formatOriginalFileUploadTime(detail) {
if (!detail || !detail.uploadTime) {
return "-";
}
return String(detail.uploadTime);
},
formatRiskLevel(value) {
const level = String(value || "").toUpperCase();
if (level === "HIGH") {
return "高风险";
}
if (level === "MEDIUM") {
return "中风险";
}
if (level === "LOW") {
return "低风险";
}
return "未标注";
},
mapRiskLevelToTagType(riskLevel) {
const level = String(riskLevel || "").toUpperCase();
if (level === "HIGH") {
return "danger";
}
if (level === "MEDIUM") {
return "warning";
}
return "info";
},
},
};
</script>
<style lang="scss" scoped>
.risk-detail-section {
margin-bottom: 16px;
}
.section-card {
padding: 20px;
border-radius: 0;
background: #fff;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.06);
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 18px;
padding-left: 12px;
border-left: 4px solid #2563eb;
}
.section-title {
font-size: 20px;
line-height: 28px;
font-weight: 700;
color: #0f172a;
}
.section-subtitle {
margin-top: 6px;
font-size: 12px;
color: #64748b;
}
.block + .block {
margin-top: 24px;
}
.block-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 14px;
}
.block-actions {
display: flex;
align-items: center;
gap: 12px;
}
.el-dropdown-link {
font-size: 12px;
color: #2563eb;
cursor: pointer;
}
.block-title {
position: relative;
padding-left: 10px;
font-size: 15px;
font-weight: 600;
color: #334155;
}
.block-title::before {
content: "";
position: absolute;
left: 0;
top: 50%;
width: 4px;
height: 14px;
border-radius: 999px;
background: #94a3b8;
transform: translateY(-50%);
}
.block-subtitle {
margin-top: 4px;
padding-left: 10px;
font-size: 12px;
color: #94a3b8;
}
.suspicious-filter-form {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 12px;
margin-bottom: 14px;
padding: 10px 12px;
background: #f8fafc;
border: 1px solid #e2e8f0;
}
.suspicious-filter-form :deep(.el-form-item) {
margin-right: 0;
margin-bottom: 0;
}
.suspicious-filter-form :deep(.el-form-item__label) {
color: #475569;
font-weight: 500;
}
.suspicious-filter-form :deep(.el-button) {
min-width: 88px;
}
.suspicious-filter-select {
width: 240px;
}
.suspicious-type-select {
width: 240px;
}
.result-table {
width: 100%;
}
.multi-line-cell {
display: flex;
flex-direction: column;
gap: 4px;
}
.primary-text {
color: #303133;
line-height: 20px;
min-width: 0;
overflow-wrap: anywhere;
word-break: break-word;
}
.secondary-text {
color: #909399;
font-size: 12px;
line-height: 18px;
min-width: 0;
overflow-wrap: anywhere;
word-break: break-word;
}
.hit-tag-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
min-width: 0;
}
.hit-tag-list ::v-deep .el-tag {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
}
.empty-text {
color: var(--ccdi-text-muted);
font-size: 13px;
line-height: 20px;
}
.amount-text {
font-weight: 600;
}
.amount-in {
color: #67c23a;
}
.amount-out {
color: #f56c6c;
}
.detail-dialog-body {
min-height: 280px;
}
.detail-overview-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 24px 32px;
margin-bottom: 24px;
}
.detail-field {
display: flex;
flex-direction: column;
gap: 10px;
min-width: 0;
}
.detail-field--full {
grid-column: 1 / -1;
}
.detail-label {
font-size: 14px;
line-height: 20px;
color: var(--ccdi-text-muted);
}
.detail-value {
color: var(--ccdi-text-primary);
line-height: 22px;
font-size: 16px;
font-weight: 500;
word-break: break-all;
}
.detail-file-block {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 2px 0;
}
.detail-file-icon {
margin-top: 2px;
font-size: 20px;
color: #a56a2a;
}
.detail-file-meta {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.detail-file-name {
color: var(--ccdi-text-primary);
line-height: 22px;
word-break: break-all;
}
.detail-file-time {
color: var(--ccdi-text-muted);
font-size: 12px;
line-height: 18px;
}
.detail-hit-tag-section {
border-top: 1px solid var(--ccdi-line);
padding-top: 24px;
}
.detail-section-title {
margin-bottom: 16px;
font-size: 16px;
font-weight: 600;
color: var(--ccdi-text-primary);
}
.detail-hit-tag-items {
display: flex;
flex-direction: column;
gap: 12px;
}
.detail-hit-tag-item {
padding: 12px 16px;
border: 1px solid var(--ccdi-border);
border-radius: 12px;
background: #fbfcfe;
}
.detail-hit-tag-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.detail-hit-tag-name {
color: var(--ccdi-text-primary);
font-size: 14px;
font-weight: 600;
line-height: 22px;
word-break: break-all;
}
.detail-hit-tag-reason {
margin-top: 8px;
color: var(--ccdi-text-secondary);
font-size: 13px;
line-height: 20px;
word-break: break-word;
}
.detail-hit-tag-empty {
color: var(--ccdi-text-muted);
font-size: 13px;
line-height: 20px;
}
.detail-dialog-footer {
display: flex;
justify-content: center;
gap: 12px;
}
.detail-dialog-title {
display: inline-flex;
align-items: center;
gap: 10px;
}
.evidence-corner-btn {
padding: 4px 9px;
font-size: 12px;
color: #5b7fb8;
border-color: #d6e4f7;
background: #f8fbff;
&:hover {
color: #2474e8;
border-color: #9fc3ff;
background: #edf5ff;
}
}
:deep(.detail-dialog) {
border-radius: 8px;
.el-dialog__header {
padding: 20px 24px;
border-bottom: 1px solid var(--ccdi-line);
}
.el-dialog__title {
font-size: 20px;
font-weight: 600;
color: var(--ccdi-text-primary);
}
.el-dialog__body {
padding: 24px;
}
.el-dialog__footer {
padding: 8px 24px 24px;
}
.el-button {
min-width: 180px;
}
}
@media (max-width: 992px) {
.detail-overview-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 20px 24px;
}
}
@media (max-width: 768px) {
.suspicious-filter-form {
align-items: stretch;
}
.suspicious-filter-form :deep(.el-form-item) {
width: 100%;
}
.suspicious-filter-form :deep(.el-form-item__content),
.suspicious-filter-select,
.suspicious-type-select,
.suspicious-filter-form :deep(.el-button) {
width: 100%;
}
.detail-overview-grid {
grid-template-columns: 1fr;
gap: 18px;
}
.detail-dialog-footer {
flex-direction: column;
}
.detail-hit-tag-header {
flex-direction: column;
align-items: stretch;
}
:deep(.detail-dialog) {
width: calc(100vw - 24px) !important;
margin-top: 8vh !important;
.el-dialog__header,
.el-dialog__body,
.el-dialog__footer {
padding-left: 16px;
padding-right: 16px;
}
.el-button {
width: 100%;
min-width: 0;
}
}
}
</style>