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

924 lines
25 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>
</div>
<div class="block">
<div class="block-header">
<div>
<div class="block-title">涉疑交易明细</div>
<div class="block-subtitle">展示涉疑交易的关键字段与风险金额</div>
</div>
<div class="block-actions">
<el-dropdown size="mini" @command="handleSuspiciousTypeChange">
<span class="el-dropdown-link">
{{ currentSuspiciousTypeLabel }}
<i class="el-icon-arrow-down el-icon--right"></i>
</span>
<el-dropdown-menu slot="dropdown">
<el-dropdown-item
v-for="item in suspiciousTypeOptions"
:key="item.value"
:command="item.value"
>
{{ item.label }}
</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
<el-button
size="mini"
type="text"
:disabled="!projectId || suspiciousTotal === 0"
@click="handleExport"
>
导出
</el-button>
</div>
</div>
<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="240">
<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 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 prop="displayAmount" label="交易金额" min-width="140" 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="详情" 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="[5]"
layout="total, 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>
<el-button size="mini" type="text">导出</el-button>
</div>
<el-table :data="sectionData.abnormalAccountList || []" class="detail-table">
<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="lastTradeDate" label="异常发生时间" min-width="140" />
<el-table-column prop="handler" label="状态" min-width="100" />
<el-table-column label="操作" width="100" align="right">
<template slot-scope="scope">
<el-button type="text" size="mini">{{ scope.row.actionLabel || "查看详情" }}</el-button>
</template>
</el-table-column>
</el-table>
</div>
</div>
<el-dialog
:visible.sync="detailVisible"
append-to-body
custom-class="detail-dialog"
title="流水详情"
width="980px"
@close="closeDetailDialog"
>
<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 { getOverviewSuspiciousTransactions } from "@/api/ccdi/projectOverview";
import { getBankStatementDetail } from "@/api/ccdiProjectBankStatement";
const SUSPICIOUS_TYPE_OPTIONS = [
{ value: "ALL", label: "全部可疑人员类型" },
{ value: "NAME_LIST", label: "名单库命中" },
{ value: "MODEL_RULE", label: "模型规则命中" },
];
const normalizeHitTags = (hitTags) => (Array.isArray(hitTags) ? hitTags : []);
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),
});
export default {
name: "RiskDetailSection",
props: {
sectionData: {
type: Object,
default: () => ({}),
},
},
data() {
return {
suspiciousLoading: false,
detailLoading: false,
detailVisible: false,
detailData: createEmptyDetailData(),
currentSuspiciousType: "ALL",
suspiciousPageNum: 1,
suspiciousPageSize: 5,
suspiciousTotal: 0,
suspiciousTransactionList: [],
projectId: null,
statementDetailCache: {},
};
},
computed: {
suspiciousTypeOptions() {
return SUSPICIOUS_TYPE_OPTIONS;
},
currentSuspiciousTypeLabel() {
const matched = this.suspiciousTypeOptions.find((item) => item.value === this.currentSuspiciousType);
return matched ? matched.label : "全部可疑人员类型";
},
},
watch: {
sectionData: {
immediate: true,
deep: true,
handler(value) {
this.projectId = value && value.projectId ? value.projectId : null;
this.currentSuspiciousType = value && value.suspiciousType ? value.suspiciousType : "ALL";
this.suspiciousPageNum = 1;
this.suspiciousPageSize = 5;
this.suspiciousTotal = Number(value && value.total) || 0;
const rows = Array.isArray(value && value.suspiciousTransactionList)
? value.suspiciousTransactionList
: [];
this.hydrateSuspiciousRows(rows);
},
},
},
methods: {
async handleSuspiciousTypeChange(command) {
this.currentSuspiciousType = command;
this.suspiciousPageNum = 1;
await this.loadSuspiciousTransactions();
},
async handlePageChange(pageInfo) {
if (typeof pageInfo === "number") {
this.suspiciousPageNum = pageInfo;
} else {
this.suspiciousPageNum = pageInfo.page;
this.suspiciousPageSize = 5;
}
await this.loadSuspiciousTransactions();
},
async loadSuspiciousTransactions() {
if (!this.projectId) {
this.suspiciousTransactionList = [];
this.suspiciousTotal = 0;
this.suspiciousLoading = false;
return;
}
this.suspiciousLoading = true;
try {
const response = await getOverviewSuspiciousTransactions({
projectId: this.projectId,
suspiciousType: this.currentSuspiciousType,
pageNum: this.suspiciousPageNum,
pageSize: 5,
});
const data = (response && response.data) || {};
this.suspiciousTotal = Number(data.total) || 0;
await this.hydrateSuspiciousRows(Array.isArray(data.rows) ? data.rows : []);
} catch (error) {
this.suspiciousTransactionList = [];
this.suspiciousTotal = 0;
this.$message.error("加载涉疑交易明细失败");
console.error("加载涉疑交易明细失败", error);
this.suspiciousLoading = false;
}
},
async hydrateSuspiciousRows(rows) {
const safeRows = Array.isArray(rows) ? rows : [];
if (!safeRows.length) {
this.suspiciousTransactionList = [];
this.suspiciousLoading = false;
return;
}
this.suspiciousLoading = true;
try {
const enrichedRows = await Promise.all(
safeRows.map(async (row) => {
if (!row || !row.bankStatementId) {
return {
...row,
hitTags: [],
};
}
try {
const detail = await this.fetchStatementDetail(row.bankStatementId, true);
return {
...detail,
...row,
hitTags: normalizeHitTags(detail.hitTags),
};
} catch (error) {
return {
...row,
hitTags: [],
};
}
})
);
this.suspiciousTransactionList = enrichedRows;
} finally {
this.suspiciousLoading = false;
}
},
async fetchStatementDetail(bankStatementId, silent) {
if (!bankStatementId) {
return createEmptyDetailData();
}
if (this.statementDetailCache[bankStatementId]) {
return this.statementDetailCache[bankStatementId];
}
try {
const response = await getBankStatementDetail(bankStatementId);
const detail = normalizeDetailData(response && response.data);
this.$set(this.statementDetailCache, bankStatementId, detail);
return detail;
} catch (error) {
if (!silent) {
throw error;
}
return createEmptyDetailData();
}
},
async handleViewDetail(row) {
if (!row || !row.bankStatementId) {
return;
}
this.detailVisible = true;
this.detailLoading = true;
try {
this.detailData = await this.fetchStatementDetail(row.bankStatementId, false);
} catch (error) {
this.detailData = createEmptyDetailData();
this.$message.error("加载流水详情失败");
console.error("加载流水详情失败", error);
} finally {
this.detailLoading = false;
}
},
closeDetailDialog() {
this.detailVisible = false;
this.detailLoading = false;
this.detailData = createEmptyDetailData();
},
handleExport() {
if (!this.projectId || this.suspiciousTotal === 0) {
return;
}
this.download(
"ccdi/project/overview/suspicious-transactions/export",
{
projectId: this.projectId,
suspiciousType: this.currentSuspiciousType,
},
`涉疑交易明细_${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);
},
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 {
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;
}
.result-table {
width: 100%;
}
.multi-line-cell {
display: flex;
flex-direction: column;
gap: 4px;
}
.primary-text {
color: #303133;
line-height: 20px;
}
.secondary-text {
color: #909399;
font-size: 12px;
line-height: 18px;
}
.hit-tag-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.empty-text {
color: #909399;
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: #909399;
}
.detail-value {
color: #303133;
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: #f59a23;
}
.detail-file-meta {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.detail-file-name {
color: #303133;
line-height: 22px;
word-break: break-all;
}
.detail-file-time {
color: #909399;
font-size: 12px;
line-height: 18px;
}
.detail-hit-tag-section {
border-top: 1px solid #ebeef5;
padding-top: 24px;
}
.detail-section-title {
margin-bottom: 16px;
font-size: 16px;
font-weight: 600;
color: #303133;
}
.detail-hit-tag-items {
display: flex;
flex-direction: column;
gap: 12px;
}
.detail-hit-tag-item {
padding: 12px 16px;
border: 1px solid #ebeef5;
border-radius: 4px;
background: #fafafa;
}
.detail-hit-tag-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.detail-hit-tag-name {
color: #303133;
font-size: 14px;
font-weight: 600;
line-height: 22px;
word-break: break-all;
}
.detail-hit-tag-reason {
margin-top: 8px;
color: #606266;
font-size: 13px;
line-height: 20px;
word-break: break-word;
}
.detail-hit-tag-empty {
color: #909399;
font-size: 13px;
line-height: 20px;
}
.detail-dialog-footer {
display: flex;
justify-content: center;
gap: 12px;
}
:deep(.detail-dialog) {
border-radius: 8px;
.el-dialog__header {
padding: 20px 24px;
border-bottom: 1px solid #ebeef5;
}
.el-dialog__title {
font-size: 20px;
font-weight: 600;
color: #303133;
}
.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) {
.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>