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

1137 lines
28 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>
<span class="shell-subtitle">按项目范围查询交易明细并查看详情</span>
</div>
<el-button
size="small"
type="primary"
plain
:disabled="total === 0"
@click="handleExport"
>
导出流水
</el-button>
</div>
<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="listError"
:closable="false"
class="result-alert"
show-icon
title="流水明细加载失败,请稍后重试"
type="error"
/>
<el-table
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">
{{ formatField(scope.row.customerAccountName) }}
</div>
<div class="secondary-text">
{{ formatField(scope.row.customerAccountNo) }}
</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
label="交易金额"
prop="displayAmount"
min-width="140"
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="详情" 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>
<pagination
v-show="total > 0"
:total="total"
: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"
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>
</div>
</template>
<script>
import { parseTime } from "@/utils/ruoyi";
import {
listBankStatement,
getBankStatementOptions,
getBankStatementDetail,
} from "@/api/ccdiProjectBankStatement";
const TAB_MAP = {
all: "all",
in: "in",
out: "out",
};
const SORT_MAP = {
trxDate: "trxDate",
displayAmount: "amount",
};
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: [],
});
export default {
name: "DetailQuery",
props: {
projectId: {
type: [String, Number],
default: null,
},
projectInfo: {
type: Object,
default: () => ({
projectName: "",
updateTime: "",
projectStatus: "0",
}),
},
},
data() {
return {
loading: false,
optionsLoading: false,
detailLoading: false,
detailVisible: false,
activeTab: "all",
dateRange: [],
list: [],
total: 0,
listError: "",
detailData: createEmptyDetailData(),
queryParams: createDefaultQueryParams(this.projectId),
optionData: createEmptyOptionData(),
};
},
created() {
this.getList();
this.getOptions();
},
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();
},
},
methods: {
async getList() {
this.syncProjectId();
if (!this.queryParams.projectId) {
this.list = [];
this.total = 0;
this.listError = "";
return;
}
this.loading = true;
try {
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 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.syncProjectId();
this.getOptions();
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();
},
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();
},
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}") : "-";
},
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}` : 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);
},
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;
}
.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;
}
.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;
}
.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) {
.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>