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

1585 lines
45 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>
<div class="detail-query-container">
<div class="query-page-shell">
<div class="shell-sidebar">
<div class="shell-panel-title">筛选条件</div>
<el-form label-position="top" class="filter-form">
<el-form-item label="交易时间">
<el-date-picker
v-model="dateRange"
class="filter-control"
type="datetimerange"
value-format="yyyy-MM-dd HH:mm:ss"
range-separator=""
start-placeholder="开始时间"
end-placeholder="结束时间"
unlink-panels
/>
</el-form-item>
<el-form-item label="本方主体">
<el-select
v-model="queryParams.ourSubjects"
class="filter-control"
multiple
filterable
collapse-tags
clearable
:loading="optionsLoading"
placeholder="请选择本方主体"
>
<el-option
v-for="item in optionData.ourSubjectOptions"
:key="`subject-${item.value}`"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="本方银行">
<el-select
v-model="queryParams.ourBanks"
class="filter-control"
multiple
filterable
collapse-tags
clearable
:loading="optionsLoading"
placeholder="请选择本方银行"
>
<el-option
v-for="item in optionData.ourBankOptions"
:key="`bank-${item.value}`"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="本方账号">
<el-select
v-model="queryParams.ourAccounts"
class="filter-control"
multiple
filterable
collapse-tags
clearable
:loading="optionsLoading"
placeholder="请选择本方账号"
>
<el-option
v-for="item in optionData.ourAccountOptions"
:key="`account-${item.value}`"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="金额区间">
<div class="amount-range">
<el-input
v-model="queryParams.amountMin"
placeholder="最小金额"
clearable
/>
<span class="amount-separator">-</span>
<el-input
v-model="queryParams.amountMax"
placeholder="最大金额"
clearable
/>
</div>
</el-form-item>
<el-form-item>
<div class="filter-item-header">
<span class="filter-item-label">对方名称</span>
<el-checkbox
v-model="queryParams.counterpartyNameEmpty"
class="empty-checkbox"
>
匹配空值
</el-checkbox>
</div>
<el-input
v-model="queryParams.counterpartyName"
placeholder="请输入对方名称"
clearable
/>
</el-form-item>
<el-form-item>
<div class="filter-item-header">
<span class="filter-item-label">对方账户</span>
<el-checkbox
v-model="queryParams.counterpartyAccountEmpty"
class="empty-checkbox"
>
匹配空值
</el-checkbox>
</div>
<el-input
v-model="queryParams.counterpartyAccount"
placeholder="请输入对方账户"
clearable
/>
</el-form-item>
<el-form-item>
<div class="filter-item-header">
<span class="filter-item-label">摘要</span>
<el-checkbox v-model="queryParams.userMemoEmpty" class="empty-checkbox">
匹配空值
</el-checkbox>
</div>
<el-input
v-model="queryParams.userMemo"
placeholder="请输入摘要关键词"
clearable
/>
</el-form-item>
<div class="filter-actions">
<el-button size="small" type="primary" @click="handleQuery">查询</el-button>
<el-button size="small" plain @click="resetQuery">重置</el-button>
</div>
</el-form>
</div>
<div class="shell-main">
<div class="shell-header">
<div class="shell-title-group">
<span class="shell-title">流水明细查询</span>
<el-radio-group
v-model="resultMode"
class="result-mode-switch"
size="small"
@change="handleResultModeChange"
>
<el-radio-button label="statement">流水明细</el-radio-button>
<el-radio-button label="counterparty">对手方</el-radio-button>
</el-radio-group>
</div>
<el-button
v-if="resultMode === 'statement'"
size="small"
type="primary"
plain
:disabled="total === 0"
@click="handleExport"
>
导出流水
</el-button>
<el-button
size="small"
plain
icon="el-icon-refresh"
:loading="counterpartyBackfillLoading"
@click="handleCounterpartyBackfill"
v-hasPermi="['ccdi:project:counterpartyEnterprise:backfill']"
>工商历史补全</el-button>
</div>
<el-alert
v-if="resultMode === 'statement' && prefillTip"
:closable="false"
class="prefill-alert"
show-icon
type="info"
:title="prefillTip"
/>
<el-tabs v-model="activeTab" class="result-tabs" @tab-click="handleTabChange">
<el-tab-pane label="全部" name="all" />
<el-tab-pane label="流入" name="in" />
<el-tab-pane label="流出" name="out" />
</el-tabs>
<div class="result-card">
<el-alert
v-if="resultMode === 'statement' && listError"
:closable="false"
class="result-alert"
show-icon
title="流水明细加载失败,请稍后重试"
type="error"
/>
<el-alert
v-if="resultMode === 'counterparty' && counterpartyError"
:closable="false"
class="result-alert"
show-icon
title="对手方加载失败,请稍后重试"
type="error"
/>
<el-table
v-if="resultMode === 'statement'"
v-loading="loading"
:data="list"
class="result-table"
@sort-change="handleSortChange"
>
<template slot="empty">
<el-empty
:image-size="96"
description="当前筛选条件下暂无流水明细"
/>
</template>
<el-table-column
label="交易时间"
prop="trxDate"
min-width="180"
sortable="custom"
/>
<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">
<el-button
v-if="scope.row.customerAccountName"
type="text"
class="counterparty-link"
@click="openCounterpartyDetail(scope.row.customerAccountName)"
>{{ scope.row.customerAccountName }}</el-button>
<span v-else>-</span>
</div>
<div class="secondary-text">
{{ formatField(scope.row.customerAccountNo) }}
</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
label="交易金额"
prop="displayAmount"
width="120"
align="right"
sortable="custom"
>
<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="small" @click="handleViewDetail(scope.row)">
详情
</el-button>
</template>
</el-table-column>
</el-table>
<el-table
v-else
v-loading="loading"
:data="counterpartyList"
class="result-table counterparty-table"
@sort-change="handleCounterpartySortChange"
>
<template slot="empty">
<el-empty
:image-size="96"
description="当前筛选条件下暂无对手方"
/>
</template>
<el-table-column
label="对手方名称"
prop="customerAccountName"
min-width="220"
:formatter="formatCounterpartyNameColumn"
/>
<el-table-column
label="流入合计"
prop="inAmount"
width="150"
align="right"
sortable="custom"
>
<template slot-scope="scope">
<div class="amount-stack">
<span class="amount-text amount-in">{{ formatAmount(scope.row.inAmount) }}</span>
<span class="secondary-text">{{ formatCount(scope.row.inCount) }} </span>
</div>
</template>
</el-table-column>
<el-table-column
label="流出合计"
prop="outAmount"
width="150"
align="right"
sortable="custom"
>
<template slot-scope="scope">
<div class="amount-stack">
<span class="amount-text amount-out">{{ formatAmount(scope.row.outAmount) }}</span>
<span class="secondary-text">{{ formatCount(scope.row.outCount) }} </span>
</div>
</template>
</el-table-column>
<el-table-column
label="总交易额"
prop="totalAmount"
width="150"
align="right"
sortable="custom"
>
<template slot-scope="scope">
<div class="amount-stack">
<span class="amount-text">{{ formatAmount(scope.row.totalAmount) }}</span>
<span class="secondary-text">{{ formatCount(scope.row.totalCount) }} </span>
</div>
</template>
</el-table-column>
<el-table-column
label="最近交易时间"
prop="latestTrxDate"
min-width="170"
sortable="custom"
>
<template slot-scope="scope">
{{ formatField(scope.row.latestTrxDate) }}
</template>
</el-table-column>
</el-table>
<pagination
v-show="currentTotal > 0"
:total="currentTotal"
:page.sync="queryParams.pageNum"
:limit.sync="queryParams.pageSize"
@pagination="handlePageChange"
/>
</div>
</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>
<el-dialog title="对手方工商信息" :visible.sync="counterpartyVisible" width="920px" append-to-body>
<div v-loading="counterpartyLoading">
<el-alert
v-if="counterpartyData.status === 'NOT_FOUND'"
title="当前项目尚无该对手方工商数据"
type="info"
:closable="false"
show-icon
class="counterparty-alert"
/>
<el-alert
v-else-if="counterpartyData.status === 'EXPIRED'"
title="工商缓存已过期,可重新查询"
type="warning"
:closable="false"
show-icon
class="counterparty-alert"
/>
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="流水对手方">{{ formatField(counterpartyData.counterpartyName) }}</el-descriptions-item>
<el-descriptions-item label="企业名称">{{ formatField(counterpartyData.enterpriseName) }}</el-descriptions-item>
<el-descriptions-item label="统一社会信用代码">{{ formatField(counterpartyData.socialCreditCode) }}</el-descriptions-item>
<el-descriptions-item label="法定代表人">{{ formatField(counterpartyData.legalRepresentative) }}</el-descriptions-item>
<el-descriptions-item label="注册资本">{{ formatCapital(counterpartyData.registeredCapital, counterpartyData.registeredCapitalUnit) }}</el-descriptions-item>
<el-descriptions-item label="注册日期">{{ formatDateOnly(counterpartyData.registerDate) }}</el-descriptions-item>
<el-descriptions-item label="成立日期">{{ formatDateOnly(counterpartyData.establishDate) }}</el-descriptions-item>
<el-descriptions-item label="机构类型">{{ formatField(counterpartyData.organizationTypeName) }}</el-descriptions-item>
<el-descriptions-item label="所属行业">{{ formatField(counterpartyData.industryName) }}</el-descriptions-item>
<el-descriptions-item label="区域">{{ formatField(counterpartyData.regionName) }}</el-descriptions-item>
<el-descriptions-item label="从业人数">{{ counterpartyData.employeeCount == null ? '-' : counterpartyData.employeeCount }}</el-descriptions-item>
<el-descriptions-item label="同步时间">{{ formatDate(counterpartyData.syncTime) }}</el-descriptions-item>
<el-descriptions-item label="注册地址" :span="2">{{ formatField(counterpartyData.registerAddress) }}</el-descriptions-item>
</el-descriptions>
<div class="counterparty-section-title">完整股东信息</div>
<el-table :data="counterpartyData.shareholders || []" border size="small">
<el-table-column label="序号" prop="sequenceNo" width="70" align="center" />
<el-table-column label="股东名称" prop="shareholderName" min-width="220" />
<el-table-column label="持股比例(%)" prop="stockPercent" width="130" align="right" />
<el-table-column label="认缴金额" min-width="150" align="right"><template slot-scope="scope">{{ formatCapital(scope.row.subscribedCapital, scope.row.capitalUnit) }}</template></el-table-column>
</el-table>
</div>
<div slot="footer">
<el-button
v-if="counterpartyData.canRefresh"
type="primary"
icon="el-icon-refresh"
:loading="counterpartyRefreshing"
@click="refreshCounterparty"
v-hasPermi="['ccdi:project:counterpartyEnterprise:refresh']"
>重新查询</el-button>
<el-button @click="counterpartyVisible=false">关闭</el-button>
</div>
</el-dialog>
</div>
</template>
<script>
import { parseTime } from "@/utils/ruoyi";
import {
listBankStatement,
listBankStatementCounterparties,
getBankStatementOptions,
getBankStatementDetail,
} from "@/api/ccdiProjectBankStatement";
import { buildFlowEvidenceFingerprint, buildFlowEvidenceSnapshot } from "@/utils/ccdiEvidence";
import {
getCounterpartyEnterpriseBackfillStatus,
getCounterpartyEnterpriseDetail,
refreshCounterpartyEnterprise,
startCounterpartyEnterpriseBackfill,
} from "@/api/ccdiProjectCounterpartyEnterprise";
const TAB_MAP = {
all: "all",
in: "in",
out: "out",
};
const SORT_MAP = {
trxDate: "trxDate",
displayAmount: "amount",
};
const COUNTERPARTY_SORT_MAP = {
inAmount: "inAmount",
outAmount: "outAmount",
totalAmount: "totalAmount",
totalCount: "totalCount",
latestTrxDate: "latestTrxDate",
};
const createDefaultQueryParams = (projectId) => ({
projectId,
pageNum: 1,
pageSize: 10,
tabType: "all",
transactionStartTime: "",
transactionEndTime: "",
counterpartyName: "",
counterpartyNameEmpty: false,
userMemo: "",
userMemoEmpty: false,
ourSubjects: [],
ourBanks: [],
ourAccounts: [],
amountMin: "",
amountMax: "",
counterpartyAccount: "",
counterpartyAccountEmpty: false,
orderBy: "trxDate",
orderDirection: "desc",
});
const createEmptyOptionData = () => ({
ourSubjectOptions: [],
ourBankOptions: [],
ourAccountOptions: [],
});
const normalizeHitTags = (hitTags) => (Array.isArray(hitTags) ? hitTags : []);
const createEmptyDetailData = () => ({
bankStatementId: "",
projectId: "",
trxDate: "",
currency: "",
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 createEmptyCounterpartyData = () => ({ status: "NOT_FOUND", canRefresh: false, shareholders: [] });
export default {
name: "DetailQuery",
props: {
projectId: {
type: [String, Number],
default: null,
},
projectInfo: {
type: Object,
default: () => ({
projectName: "",
updateTime: "",
projectStatus: "0",
}),
},
detailQueryPrefill: {
type: Object,
default: null,
},
},
data() {
return {
loading: false,
optionsLoading: false,
detailLoading: false,
detailVisible: false,
resultMode: "statement",
activeTab: "all",
dateRange: [],
list: [],
total: 0,
counterpartyList: [],
counterpartyTotal: 0,
counterpartyError: "",
listError: "",
prefillTip: "",
detailData: createEmptyDetailData(),
counterpartyVisible: false,
counterpartyLoading: false,
counterpartyRefreshing: false,
counterpartyBackfillLoading: false,
counterpartyBackfillTimer: null,
counterpartyData: createEmptyCounterpartyData(),
queryParams: createDefaultQueryParams(this.projectId),
optionData: createEmptyOptionData(),
};
},
created() {
this.getList();
this.getOptions();
},
computed: {
currentTotal() {
return this.resultMode === "counterparty" ? this.counterpartyTotal : this.total;
},
},
beforeDestroy() {
if (this.counterpartyBackfillTimer) clearInterval(this.counterpartyBackfillTimer);
},
watch: {
dateRange(value) {
this.queryParams.transactionStartTime = value && value[0] ? value[0] : "";
this.queryParams.transactionEndTime = value && value[1] ? value[1] : "";
},
projectId() {
this.syncProjectId();
this.getOptions();
this.getList();
},
detailQueryPrefill: {
immediate: true,
deep: true,
handler(value) {
this.applyDetailQueryPrefill(value);
},
},
},
methods: {
buildFlowEvidenceFingerprint,
async getList() {
this.syncProjectId();
if (!this.queryParams.projectId) {
this.list = [];
this.total = 0;
this.counterpartyList = [];
this.counterpartyTotal = 0;
this.listError = "";
this.counterpartyError = "";
return;
}
this.loading = true;
try {
if (this.resultMode === "counterparty") {
await this.getCounterpartyList();
return;
}
const res = await listBankStatement(this.queryParams);
this.list = (res.rows || []).map((item) => ({
...item,
hitTags: normalizeHitTags(item && item.hitTags),
}));
this.total = res.total || 0;
this.listError = "";
} catch (error) {
this.list = [];
this.total = 0;
this.listError = "加载流水明细失败";
console.error("加载流水明细失败", error);
} finally {
this.loading = false;
}
},
async getCounterpartyList() {
try {
const res = await listBankStatementCounterparties(this.queryParams);
this.counterpartyList = res.rows || [];
this.counterpartyTotal = res.total || 0;
this.counterpartyError = "";
} catch (error) {
this.counterpartyList = [];
this.counterpartyTotal = 0;
this.counterpartyError = "加载对手方失败";
console.error("加载对手方失败", error);
}
},
async getOptions() {
this.syncProjectId();
if (!this.queryParams.projectId) {
this.optionData = createEmptyOptionData();
return;
}
this.optionsLoading = true;
try {
const res = await getBankStatementOptions(this.queryParams.projectId);
const data = res.data || {};
this.optionData = {
ourSubjectOptions: data.ourSubjectOptions || [],
ourBankOptions: data.ourBankOptions || [],
ourAccountOptions: data.ourAccountOptions || [],
};
} catch (error) {
this.optionData = createEmptyOptionData();
console.error("加载流水筛选项失败", error);
} finally {
this.optionsLoading = false;
}
},
syncProjectId() {
this.queryParams.projectId = this.projectId;
this.queryParams.tabType = TAB_MAP[this.activeTab] || "all";
},
handleQuery() {
this.queryParams.pageNum = 1;
this.syncProjectId();
this.getList();
},
resetQuery() {
this.activeTab = "all";
this.dateRange = [];
this.queryParams = createDefaultQueryParams(this.projectId);
this.prefillTip = "";
this.syncProjectId();
this.getOptions();
this.getList();
},
applyDetailQueryPrefill(value) {
if (!value || !Array.isArray(value.ourCertNos) || !value.ourCertNos.length) {
return;
}
this.resultMode = "statement";
this.activeTab = "all";
this.dateRange = [];
this.queryParams = {
...createDefaultQueryParams(this.projectId),
ourCertNos: value.ourCertNos,
};
this.prefillTip = `当前查看:${value.title || "外部人员"} 的本方流水`;
this.syncProjectId();
this.getList();
},
handleResultModeChange(mode) {
this.queryParams.pageNum = 1;
if (mode === "counterparty") {
this.queryParams.orderBy = "totalAmount";
this.queryParams.orderDirection = "desc";
} else {
this.queryParams.orderBy = "trxDate";
this.queryParams.orderDirection = "desc";
}
this.getList();
},
handleTabChange(tab) {
const tabName = tab && tab.name ? tab.name : this.activeTab;
this.activeTab = tabName;
this.queryParams.pageNum = 1;
this.queryParams.tabType = TAB_MAP[tabName] || "all";
this.getList();
},
handleSortChange({ prop, order }) {
this.queryParams.orderBy = SORT_MAP[prop] || "trxDate";
this.queryParams.orderDirection = order === "ascending" ? "asc" : "desc";
if (!order) {
this.queryParams.orderBy = "trxDate";
this.queryParams.orderDirection = "desc";
}
this.getList();
},
handleCounterpartySortChange({ prop, order }) {
this.queryParams.orderBy = COUNTERPARTY_SORT_MAP[prop] || "totalAmount";
this.queryParams.orderDirection = order === "ascending" ? "asc" : "desc";
if (!order) {
this.queryParams.orderBy = "totalAmount";
this.queryParams.orderDirection = "desc";
}
this.getList();
},
handlePageChange(pageInfo) {
if (typeof pageInfo === "number") {
this.queryParams.pageNum = pageInfo;
} else {
this.queryParams.pageNum = pageInfo.page;
this.queryParams.pageSize = pageInfo.limit;
}
this.getList();
},
async handleViewDetail(row) {
if (!row || !row.bankStatementId) {
return;
}
this.detailVisible = true;
this.detailLoading = true;
try {
const res = await getBankStatementDetail(row.bankStatementId);
const detail = res.data || {};
this.detailData = {
...createEmptyDetailData(),
...detail,
hitTags: normalizeHitTags(detail.hitTags),
};
} 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();
},
async openCounterpartyDetail(counterpartyName) {
if (!this.projectId || !counterpartyName) return;
this.counterpartyVisible = true;
this.counterpartyLoading = true;
try {
const response = await getCounterpartyEnterpriseDetail(this.projectId, counterpartyName);
this.counterpartyData = { ...createEmptyCounterpartyData(), ...(response.data || {}) };
} catch (error) {
this.counterpartyVisible = false;
this.$message.error("加载对手方工商信息失败");
} finally {
this.counterpartyLoading = false;
}
},
async refreshCounterparty() {
this.counterpartyRefreshing = true;
try {
const response = await refreshCounterpartyEnterprise({ projectId: this.projectId, counterpartyName: this.counterpartyData.counterpartyName });
this.counterpartyData = { ...createEmptyCounterpartyData(), ...(response.data || {}) };
this.$message.success("对手方工商信息已更新");
} finally {
this.counterpartyRefreshing = false;
}
},
handleCounterpartyBackfill() {
this.$confirm("确认启动全部项目对手方工商信息历史补全?", "提示", { type: "warning" }).then(() => {
this.counterpartyBackfillLoading = true;
return startCounterpartyEnterpriseBackfill();
}).then(response => this.pollCounterpartyBackfill(response.data)).catch(() => { this.counterpartyBackfillLoading = false; });
},
pollCounterpartyBackfill(taskId) {
const query = () => getCounterpartyEnterpriseBackfillStatus(taskId).then(response => {
const status = response.data || {};
if (status.status === "COMPLETED") {
clearInterval(this.counterpartyBackfillTimer);
this.counterpartyBackfillTimer = null;
this.counterpartyBackfillLoading = false;
this.$notify({ title: "对手方工商补全完成", message: `成功 ${status.successCount || 0},失败 ${status.failureCount || 0}`, type: status.failureCount ? "warning" : "success" });
}
}).catch(() => {
clearInterval(this.counterpartyBackfillTimer);
this.counterpartyBackfillTimer = null;
this.counterpartyBackfillLoading = false;
});
query();
this.counterpartyBackfillTimer = setInterval(query, 2000);
},
handleAddEvidence() {
const detail = this.detailData || {};
const sourceRecordId = buildFlowEvidenceFingerprint(detail);
const amountText = this.formatSignedAmount(detail.displayAmount);
const counterparty = this.formatCounterpartyName(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: this.resolveFlowRelatedPerson(detail),
relatedPersonId: detail.cretNo || "",
evidenceTitle: `${this.resolveFlowRelatedPerson(detail)} / ${this.formatField(detail.leAccountNo)}`,
evidenceSummary: `${this.formatField(detail.trxDate)}${this.resolveFlowRelatedPerson(detail)}账户与${counterparty}发生交易,金额${amountText}${hitTagText}`,
sourceType: "BANK_STATEMENT",
sourceRecordId,
sourcePage: "流水详情",
snapshotJson: JSON.stringify(buildFlowEvidenceSnapshot(detail)),
});
},
resolveFlowRelatedPerson(detail) {
return this.formatField(detail.leAccountName) === "-" ? "关联人员" : this.formatField(detail.leAccountName);
},
handleExport() {
if (this.total === 0) {
return;
}
this.download(
"ccdi/project/bank-statement/export",
{ ...this.queryParams },
`${this.projectInfo.projectName || "项目"}_流水明细_${Date.now()}.xlsx`
);
},
formatField(value) {
if (value === null || value === undefined || value === "") {
return "-";
}
return String(value);
},
formatDate(value) {
return value ? parseTime(value, "{y}-{m}-{d} {h}:{i}:{s}") : "-";
},
formatDateOnly(value) {
return value ? parseTime(value, "{y}-{m}-{d}") : "-";
},
formatCapital(amount, unit) {
if (amount === null || amount === undefined || amount === "") return "-";
return `${amount}${unit || ""}`;
},
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,
});
},
formatCount(value) {
if (value === null || value === undefined || value === "") {
return 0;
}
const count = Number(value);
return Number.isNaN(count) ? 0 : count;
},
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}` : text;
},
getAmountClass(value) {
if (value === null || value === undefined || value === "") {
return "";
}
return Number(value) >= 0 ? "amount-in" : "amount-out";
},
formatCounterpartyName(detail) {
if (!detail) {
return "-";
}
return this.formatField(detail.customerAccountName);
},
formatCounterpartyRowName(row) {
if (!row || row.nameEmpty || !row.customerAccountName) {
return "为空";
}
return this.formatField(row.customerAccountName);
},
formatCounterpartyNameColumn(row) {
return this.formatCounterpartyRowName(row);
},
formatOriginalFileName(detail) {
if (!detail) {
return "暂无原始文件";
}
return (
detail.originalFileName ||
detail.sourceFileName ||
detail.fileName ||
"暂无原始文件"
);
},
formatOriginalFileUploadTime(detail) {
if (!detail) {
return "-";
}
return this.formatDate(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>
.detail-query-container {
padding: 16px;
background: #fff;
min-height: 480px;
}
.query-page-shell {
display: grid;
grid-template-columns: 300px minmax(0, 1fr);
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;
}
}
.counterparty-link {
max-width: 100%;
padding: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.counterparty-alert {
margin-bottom: 12px;
}
.counterparty-section-title {
margin: 18px 0 10px;
color: #303133;
font-size: 15px;
font-weight: 600;
letter-spacing: 0;
}
.shell-sidebar,
.shell-main {
border: 1px solid #ebeef5;
border-radius: 4px;
background: #fff;
}
.shell-sidebar {
padding: 16px 14px;
}
.shell-main {
padding: 20px;
}
.shell-panel-title,
.shell-title {
font-size: 16px;
font-weight: 600;
color: #303133;
}
.shell-title-group {
display: flex;
flex-direction: column;
gap: 4px;
}
.shell-subtitle {
font-size: 13px;
color: #909399;
}
.result-mode-switch {
margin-top: 8px;
:deep(.el-radio-button__inner) {
min-width: 82px;
}
}
.shell-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 20px;
}
.filter-form {
margin-top: 12px;
:deep(.el-form-item) {
margin-bottom: 14px;
}
:deep(.el-form-item__label) {
padding-bottom: 6px;
line-height: 20px;
font-size: 13px;
}
}
.filter-control {
width: 100%;
}
.filter-item-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 6px;
}
.filter-item-label {
font-size: 13px;
line-height: 20px;
color: #606266;
}
.empty-checkbox {
margin-top: 0;
flex-shrink: 0;
}
:deep(.empty-checkbox .el-checkbox__label) {
padding-left: 4px;
font-size: 12px;
color: #909399;
}
.amount-range {
display: flex;
align-items: center;
gap: 6px;
}
.amount-separator {
color: #909399;
font-size: 13px;
}
.filter-actions {
display: flex;
gap: 8px;
.el-button {
flex: 1;
}
}
.result-tabs {
margin-bottom: 16px;
}
.result-card {
border: none;
border-radius: 4px;
overflow: hidden;
}
.result-alert {
margin: 16px 16px 0;
}
.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: #909399;
font-size: 13px;
line-height: 20px;
}
.amount-text {
font-weight: 600;
}
.amount-stack {
display: inline-flex;
flex-direction: column;
align-items: flex-end;
gap: 4px;
min-width: 0;
line-height: 20px;
}
.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) {
.query-page-shell {
grid-template-columns: 1fr;
}
.filter-actions {
flex-direction: column;
}
.detail-overview-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 20px 24px;
}
}
@media (max-width: 768px) {
.detail-query-container {
padding: 8px;
}
.shell-sidebar,
.shell-main {
padding: 16px;
}
.shell-header {
flex-direction: column;
}
.result-alert {
margin: 12px 12px 0;
}
.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>