<template>
|
<div class="app-container">
|
<div class="search_form">
|
<el-form :model="searchForm" :inline="true">
|
<el-form-item label="项目名称:">
|
<el-input
|
v-model="searchForm.projectName"
|
placeholder="请输入项目名称"
|
clearable
|
prefix-icon="Search"
|
style="width: 200px"
|
@change="handleSearch"
|
/>
|
</el-form-item>
|
<el-form-item label="客户名称:">
|
<el-input
|
v-model="searchForm.customerName"
|
placeholder="请输入客户名称"
|
clearable
|
prefix-icon="Search"
|
style="width: 200px"
|
@change="handleSearch"
|
/>
|
</el-form-item>
|
<el-form-item label="合同名称:">
|
<el-input
|
v-model="searchForm.contractName"
|
placeholder="请输入合同名称"
|
clearable
|
prefix-icon="Search"
|
style="width: 200px"
|
@change="handleSearch"
|
/>
|
</el-form-item>
|
<el-form-item>
|
<el-button type="primary" @click="handleSearch">搜索</el-button>
|
<el-button @click="handleReset">重置</el-button>
|
</el-form-item>
|
</el-form>
|
</div>
|
</div>
|
<div class="table_list">
|
<div class="app-container layout-fullscreen">
|
<!-- Search Form -->
|
<div class="summary-strip">
|
<div class="summary-stats">
|
<span>当前项目:<b>{{ summaryData.totalProjects || 0 }}</b> 个</span>
|
<span>关联客户:<b>{{ summaryData.totalCustomers || 0 }}</b> 家</span>
|
<span>合同总数:<b>{{ summaryData.totalContracts || 0 }}</b> 份</span>
|
<span>签约总额:<b>{{ fmtMoney(summaryData.totalAmount || 0) }}</b></span>
|
<span class="warn-summary">⚠ 签订超30天未完成回款:<span>{{
|
summaryData.totalOverdueCount || 0
|
}}</span> 份</span>
|
</div>
|
</div>
|
|
<!-- Layout Columns -->
|
<div class="layout-columns">
|
<!-- Column 1: Projects -->
|
<div class="column-panel" v-loading="prjLoading">
|
<div class="panel-header">
|
<span>1. 项目汇总 ({{ prjTotal }})</span>
|
</div>
|
<div class="panel-body">
|
<div v-if="projects.length === 0" class="empty-state">无匹配项目</div>
|
|
<div v-for="p in projects" :key="p.projectId"
|
class="item-card" :class="{ active: p.projectId === selectedProjectId }"
|
@click="handleSelectProject(p)">
|
<div class="card-title-row">
|
<div class="card-main-title">{{ p.projectName }}</div>
|
</div>
|
<div class="card-code">{{ p.projectCode || '暂无项目编号' }}</div>
|
<div class="meta-row" style="margin-top: 8px;">
|
<span>总额: <strong>{{ fmtMoney(p.contractAmounts) }}</strong></span>
|
<span>开票: <strong>{{ fmtMoney(p.invoiceAmount) }}</strong></span>
|
</div>
|
<div class="meta-row">
|
<span>回款: <strong>{{ fmtMoney(p.receiptPaymentAmount) }}</strong></span>
|
<span>应收: <strong>{{ fmtMoney(p.receiptableAmount) }}</strong></span>
|
</div>
|
<div class="meta-row">
|
<span>退货: <strong>{{ fmtMoney(p.returnAmount) }}</strong></span>
|
<span>进度: <strong :class="{'text-danger': p.overdueCount > 0}">{{
|
calcRate(p.receiptPaymentAmount, p.contractAmounts)
|
}}%</strong></span>
|
</div>
|
<div class="meta-row">
|
<span>客户: <strong>{{ p.customerCount || 0 }} 家</strong></span>
|
<span>合同: <strong>{{ p.contractCount || 0 }} 份</strong></span>
|
</div>
|
<div v-if="p.overdueCount > 0" style="margin-top: 6px;">
|
<el-tag type="danger" size="small" effect="light">⚠ {{ p.overdueCount }} 份超期合同</el-tag>
|
</div>
|
</div>
|
</div>
|
<div class="panel-pagination">
|
<el-pagination background small layout="total, prev, pager, next" :total="prjTotal" :page-size="prjPage.size"
|
v-model:current-page="prjPage.current" @current-change="handlePrjPageChange"/>
|
</div>
|
</div>
|
|
<!-- Column 2: Customers -->
|
<div class="column-panel" v-loading="custLoading">
|
<div class="panel-header">
|
<span>2. 客户汇总 ({{ custTotal }})</span>
|
</div>
|
<div class="panel-body">
|
<div v-if="!selectedProjectId" class="empty-state">请先在左侧选择项目</div>
|
<div v-else-if="customers.length === 0" class="empty-state">该项目下无匹配的客户信息</div>
|
|
<div v-for="c in customers" :key="c.customerId"
|
class="item-card" :class="{ active: c.customerId === selectedCustomerId }"
|
@click="handleSelectCustomer(c)">
|
<div class="card-title-row">
|
<div class="card-main-title">{{ c.customerName || '未命名客户' }}</div>
|
</div>
|
<div class="meta-row" style="margin-top: 8px;">
|
<span>签约: <strong>{{ fmtMoney(c.contractAmounts) }}</strong></span>
|
<span>进度: <strong :class="{'text-danger': c.overdueCount > 0}">{{
|
calcRate(c.receiptPaymentAmount, c.contractAmounts)
|
}}%</strong></span>
|
</div>
|
<div class="meta-row">
|
<span>退货: <strong>{{ fmtMoney(c.returnAmount) }}</strong></span>
|
<span>应收: <strong>{{ fmtMoney(c.receiptableAmount) }}</strong></span>
|
</div>
|
<div class="meta-row">
|
<span>已回款: <strong>{{ fmtMoney(c.receiptPaymentAmount) }}</strong></span>
|
<span>合同数: <strong>{{ c.contractCount || 0 }} 份</strong></span>
|
</div>
|
<div style="margin-top: 6px;">
|
<el-tag v-if="c.overdueCount > 0" type="danger" size="small" effect="light">⚠ {{ c.overdueCount }} 份超期</el-tag>
|
<el-tag v-else type="success" size="small" effect="light">正常履约</el-tag>
|
</div>
|
</div>
|
</div>
|
<div class="panel-pagination">
|
<el-pagination background small layout="total, prev, pager, next" :total="custTotal"
|
:page-size="custPage.size" v-model:current-page="custPage.current"
|
@current-change="handleCustPageChange"/>
|
</div>
|
</div>
|
|
<!-- Column 3: Contracts -->
|
<div class="column-panel" v-loading="contractLoading">
|
<div class="panel-header">
|
<span>3. 合同明细 ({{ contractTotal }})</span>
|
</div>
|
<div class="panel-body" style="padding: 0;">
|
<div v-if="!selectedCustomerId" class="empty-state" style="padding-top: 40px;">请先在中间栏选择客户</div>
|
<div v-else-if="contracts.length === 0" class="empty-state" style="padding-top: 40px;">
|
该客户下无匹配的合同明细
|
</div>
|
|
<el-table v-else :data="contracts" size="small" style="width: 100%; height: 100%;" stripe
|
:row-class-name="tableRowClassName">
|
<el-table-column label="合同信息" min-width="160" show-overflow-tooltip>
|
<template #default="{ row }">
|
<div style="font-weight: 600; color: #0f172a;">{{
|
row.contractName || row.salesContractNo || '未命名'
|
}}
|
</div>
|
<div style="font-size: 11px; color: #64748b;">合同编号:{{ row.salesContractNo }}</div>
|
</template>
|
</el-table-column>
|
|
<el-table-column label="金额与回款" min-width="150">
|
<template #default="{ row }">
|
<div style="font-size: 12px; color: #64748b;">总额: <span
|
style="color: #0f172a; font-weight: 500;">{{ fmtMoney(row.contractAmount) }}</span></div>
|
<div :class="row.isPaymentTimeout ? 'text-danger' : ''" style="font-size: 12px; font-weight: 500;">
|
已回: {{ fmtMoney(row.receiptPaymentAmount) }}
|
({{ calcRate(row.receiptPaymentAmount, row.contractAmount) }}%)
|
</div>
|
</template>
|
</el-table-column>
|
|
<el-table-column label="应收与退货" min-width="130">
|
<template #default="{ row }">
|
<div style="font-size: 12px; color: #64748b;">应收: <span
|
style="color: #0f172a;">{{ fmtMoney(row.receiptableAmount) }}</span></div>
|
<div style="font-size: 12px; color: #64748b;">退货: <span
|
style="color: #0f172a;">{{ fmtMoney(row.returnAmount) }}</span></div>
|
</template>
|
</el-table-column>
|
|
<el-table-column label="状态" width="100" align="center">
|
<template #default="{ row }">
|
<el-tag v-if="row.isPaymentTimeout" type="danger" size="small" effect="dark">超期未回</el-tag>
|
<el-tag
|
v-else-if="(row.receiptPaymentAmount || 0) >= (row.contractAmount || 0) && (row.contractAmount || 0) > 0"
|
type="success" size="small" effect="dark">已结清
|
</el-tag>
|
<el-tag v-else type="primary" size="small" effect="plain">履行中</el-tag>
|
</template>
|
</el-table-column>
|
</el-table>
|
</div>
|
<div class="panel-pagination">
|
<el-pagination background small layout="total, prev, pager, next" :total="contractTotal"
|
:page-size="contractPage.size" v-model:current-page="contractPage.current"
|
@current-change="handleContractPageChange"/>
|
</div>
|
</div>
|
|
</div>
|
</div>
|
</div>
|
</template>
|
|
<script setup>
|
import {onMounted, ref, reactive, toRefs} from "vue";
|
import {
|
projectTransactions,
|
customewTransactions,
|
customewTransactionsDetails,
|
transactionsSummary
|
} from "@/api/salesManagement/indicatorStats.js";
|
|
const summaryData = ref({});
|
const data = reactive({
|
searchForm: {
|
projectName: "",
|
customerName: "",
|
contractName: "",
|
},
|
});
|
const {searchForm} = toRefs(data);
|
|
// Column 1: Projects
|
const projects = ref([]);
|
const prjLoading = ref(false);
|
const prjTotal = ref(0);
|
const prjPage = reactive({current: 1, size: 20});
|
const selectedProjectId = ref(null);
|
|
// Column 2: Customers
|
const customers = ref([]);
|
const custLoading = ref(false);
|
const custTotal = ref(0);
|
const custPage = reactive({current: 1, size: 20});
|
const selectedCustomerId = ref(null);
|
|
// Column 3: Contracts
|
const contracts = ref([]);
|
const contractLoading = ref(false);
|
const contractTotal = ref(0);
|
const contractPage = reactive({current: 1, size: 20});
|
|
const getSummary = () => {
|
transactionsSummary().then(res => {
|
summaryData.value = res.data || {};
|
});
|
};
|
|
const handleSearch = () => {
|
prjPage.current = 1;
|
loadProjects();
|
getSummary();
|
};
|
|
const handleReset = () => {
|
searchForm.value = {
|
projectName: "",
|
customerName: "",
|
contractName: "",
|
};
|
handleSearch();
|
};
|
|
const loadProjects = () => {
|
prjLoading.value = true;
|
projectTransactions({...searchForm.value, ...prjPage}).then(res => {
|
prjLoading.value = false;
|
projects.value = res.data.records || [];
|
prjTotal.value = res.data.total || 0;
|
|
if (projects.value.length > 0) {
|
handleSelectProject(projects.value[0]);
|
} else {
|
selectedProjectId.value = null;
|
customers.value = [];
|
custTotal.value = 0;
|
contracts.value = [];
|
contractTotal.value = 0;
|
}
|
}).catch(() => {
|
prjLoading.value = false;
|
});
|
};
|
|
const handleSelectProject = (prj) => {
|
selectedProjectId.value = prj.projectId;
|
custPage.current = 1;
|
loadCustomers();
|
};
|
|
const loadCustomers = () => {
|
if (!selectedProjectId.value) return;
|
custLoading.value = true;
|
const params = {
|
projectId: selectedProjectId.value,
|
customerName: searchForm.value.customerName,
|
contractName: searchForm.value.contractName,
|
...custPage
|
};
|
customewTransactions(params).then(res => {
|
custLoading.value = false;
|
const records = res.data.records || res.data || [];
|
customers.value = records;
|
custTotal.value = res.data.total || records.length || 0;
|
|
if (customers.value.length > 0) {
|
handleSelectCustomer(customers.value[0]);
|
} else {
|
selectedCustomerId.value = null;
|
contracts.value = [];
|
contractTotal.value = 0;
|
}
|
}).catch(() => {
|
custLoading.value = false;
|
});
|
};
|
|
const handleSelectCustomer = (cust) => {
|
selectedCustomerId.value = cust.customerId;
|
contractPage.current = 1;
|
loadContracts();
|
};
|
|
const loadContracts = () => {
|
if (!selectedCustomerId.value) return;
|
contractLoading.value = true;
|
const params = {
|
projectId: selectedProjectId.value,
|
customerId: selectedCustomerId.value,
|
contractName: searchForm.value.contractName,
|
...contractPage
|
};
|
customewTransactionsDetails(params).then(res => {
|
contractLoading.value = false;
|
const records = Array.isArray(res.data) ? res.data : (res.data.records || []);
|
contracts.value = records;
|
contractTotal.value = res.data.total || records.length || 0;
|
}).catch(() => {
|
contractLoading.value = false;
|
});
|
};
|
|
const handlePrjPageChange = (val) => {
|
prjPage.current = val;
|
loadProjects();
|
};
|
|
const handleCustPageChange = (val) => {
|
custPage.current = val;
|
loadCustomers();
|
};
|
|
const handleContractPageChange = (val) => {
|
contractPage.current = val;
|
loadContracts();
|
};
|
|
const fmtMoney = (num) => {
|
if (num === null || num === undefined || isNaN(num)) return '¥ 0.00';
|
return '¥ ' + parseFloat(num).toLocaleString('zh-CN', {minimumFractionDigits: 2, maximumFractionDigits: 2});
|
};
|
|
const calcRate = (paid, amount) => {
|
if (!amount || amount == 0) return '0.0';
|
const val = ((paid || 0) / amount) * 100;
|
return val.toFixed(1);
|
};
|
|
const getDaysFromSign = (dateStr) => {
|
if (!dateStr) return 0;
|
const sign = new Date(dateStr);
|
const now = new Date();
|
const diffTime = now - sign;
|
return Math.max(0, Math.floor(diffTime / (1000 * 60 * 60 * 24)));
|
};
|
|
const tableRowClassName = ({row}) => {
|
if (row.isPaymentTimeout) {
|
return 'warning-row';
|
}
|
return '';
|
};
|
|
onMounted(() => {
|
loadProjects();
|
getSummary();
|
});
|
</script>
|
|
<style scoped>
|
/* CSS Variables */
|
.layout-fullscreen {
|
--bg: #f8fafc;
|
--card: #ffffff;
|
--text: #0f172a;
|
--muted: #64748b;
|
--border: #e2e8f0;
|
--primary: #2563eb;
|
--primary-50: #eff6ff;
|
--success: #16a34a;
|
--success-50: #dcfce7;
|
--warning: #d97706;
|
--danger: #dc2626;
|
--danger-50: #fef2f2;
|
--danger-border: #fecaca;
|
|
height: calc(100vh - 84px); /* Fallback height, allows container to take viewport height */
|
display: flex;
|
flex-direction: column;
|
background-color: var(--bg);
|
padding: 16px;
|
overflow: hidden; /* Hide body scroll, let columns scroll */
|
}
|
|
/* 顶部搜索 */
|
.search_form {
|
background: var(--card);
|
border: 1px solid var(--border);
|
border-radius: 8px;
|
padding: 16px 20px 0 20px;
|
margin-bottom: 12px;
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.02);
|
}
|
|
/* 动态统计汇总栏 */
|
.summary-strip {
|
background: var(--card);
|
border: 1px solid var(--border);
|
border-radius: 8px;
|
padding: 10px 20px;
|
display: flex;
|
justify-content: space-between;
|
align-items: center;
|
font-size: 13px;
|
flex-shrink: 0;
|
}
|
|
.summary-stats {
|
display: flex;
|
gap: 20px;
|
align-items: center;
|
flex-wrap: wrap;
|
}
|
|
.summary-stats b {
|
color: var(--primary);
|
font-size: 14px;
|
}
|
|
.warn-summary {
|
background: var(--danger-50);
|
color: var(--danger);
|
padding: 2px 8px;
|
border-radius: 4px;
|
font-weight: 600;
|
border: 1px solid var(--danger-border);
|
}
|
|
/* 三栏联动 */
|
.layout-columns {
|
flex: 1;
|
display: grid;
|
grid-template-columns: 310px 330px 1fr;
|
gap: 16px;
|
margin-top: 16px;
|
overflow: hidden;
|
}
|
|
@media (max-width: 1400px) {
|
.layout-columns {
|
grid-template-columns: 280px 300px 1fr;
|
}
|
}
|
|
@media (max-width: 1200px) {
|
.layout-columns {
|
display: flex;
|
flex-direction: column;
|
overflow-y: auto;
|
}
|
|
.layout-fullscreen {
|
overflow: auto;
|
}
|
|
.column-panel {
|
min-height: 400px;
|
}
|
}
|
|
.column-panel {
|
background: var(--card);
|
border: 1px solid var(--border);
|
border-radius: 8px;
|
display: flex;
|
flex-direction: column;
|
overflow: hidden;
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.02);
|
}
|
|
.panel-header {
|
padding: 12px 16px;
|
background: #f8fafc;
|
border-bottom: 1px solid var(--border);
|
font-size: 14px;
|
font-weight: 600;
|
flex-shrink: 0;
|
color: var(--text);
|
}
|
|
.panel-body {
|
flex: 1;
|
overflow-y: auto;
|
padding: 12px;
|
display: flex;
|
flex-direction: column;
|
gap: 12px;
|
}
|
|
.panel-pagination {
|
padding: 8px;
|
background: #ffffff;
|
border-top: 1px solid var(--border);
|
display: flex;
|
justify-content: center;
|
align-items: center;
|
flex-shrink: 0;
|
}
|
|
/* 列表卡片 (项目 & 客户) */
|
.item-card {
|
background: #ffffff;
|
border: 1px solid var(--border);
|
border-radius: 6px;
|
padding: 12px 14px;
|
cursor: pointer;
|
transition: all 0.15s;
|
}
|
|
.item-card:hover {
|
border-color: #cbd5e1;
|
background: #f8fafc;
|
}
|
|
.item-card.active {
|
border-color: var(--primary);
|
background: var(--primary-50);
|
box-shadow: 0 0 0 1px var(--primary);
|
}
|
|
.card-title-row {
|
display: flex;
|
justify-content: space-between;
|
align-items: flex-start;
|
margin-bottom: 6px;
|
}
|
|
.card-main-title {
|
font-size: 14px;
|
font-weight: 600;
|
color: var(--text);
|
line-height: 1.4;
|
}
|
|
.card-code {
|
font-size: 11px;
|
color: var(--muted);
|
background: #f1f5f9;
|
padding: 2px 6px;
|
border-radius: 4px;
|
display: inline-block;
|
}
|
|
.meta-row {
|
display: flex;
|
justify-content: space-between;
|
font-size: 12px;
|
color: var(--muted);
|
margin-top: 4px;
|
}
|
|
.meta-row strong {
|
color: var(--text);
|
font-weight: 600;
|
}
|
|
.text-danger {
|
color: var(--danger) !important;
|
font-weight: 700;
|
}
|
|
:deep(.warning-row) {
|
background-color: var(--danger-50) !important;
|
}
|
|
.empty-state {
|
text-align: center;
|
color: var(--muted);
|
padding: 40px 16px;
|
font-size: 13px;
|
}
|
</style>
|