| | |
| | | <template> |
| | | <div class="app-container"> |
| | | <div class="search_form" |
| | | style="margin-bottom: 20px;"> |
| | | <div> |
| | | <span class="search_title">客户名称:</span> |
| | | <el-input v-model="searchForm.searchText" |
| | | style="width: 240px" |
| | | placeholder="输入客户名称搜索" |
| | | @change="handleQuery" |
| | | clearable |
| | | prefix-icon="Search" /> |
| | | <el-button type="primary" |
| | | @click="handleQuery" |
| | | style="margin-left: 10px">搜索</el-button> |
| | | <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> |
| | | <el-row :gutter="20"> |
| | | <el-col :span="12"> |
| | | <div class="table_list" |
| | | style="width: 100%"> |
| | | <el-table :data="tableData" |
| | | border |
| | | v-loading="tableLoading" |
| | | :row-key="(row) => row.customerId" |
| | | show-summary |
| | | :summary-method="summarizeMainTable" |
| | | @row-click="rowClickMethod" |
| | | highlight-current-row |
| | | height="calc(100vh - 18.5em)"> |
| | | <el-table-column align="center" |
| | | label="序号" |
| | | type="index" |
| | | width="60" /> |
| | | <el-table-column label="客户名称" |
| | | prop="customerName" |
| | | show-overflow-tooltip |
| | | width="200" /> |
| | | <el-table-column label="合同金额(元)" |
| | | prop="contractAmounts" |
| | | show-overflow-tooltip |
| | | :formatter="formattedNumber" |
| | | width="200" /> |
| | | <el-table-column label="回款金额(元)" |
| | | prop="receiptPaymentAmount" |
| | | show-overflow-tooltip |
| | | :formatter="formattedNumber" |
| | | width="200" /> |
| | | <el-table-column label="退货金额(元)" |
| | | prop="returnAmount" |
| | | show-overflow-tooltip |
| | | :formatter="formattedNumber" |
| | | width="200" /> |
| | | <el-table-column label="应收金额(元)" |
| | | prop="receiptableAmount" |
| | | show-overflow-tooltip |
| | | width="200"> |
| | | <template #default="{ row, column }"> |
| | | <el-text type="danger"> |
| | | {{ formattedNumber(row, column, row.receiptableAmount) }} |
| | | </el-text> |
| | | |
| | | <!-- 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> |
| | | <pagination v-show="total > 0" |
| | | :total="total" |
| | | layout="total, sizes, prev, pager, next, jumper" |
| | | :page="page.current" |
| | | :limit="page.size" |
| | | @pagination="paginationChange" /> |
| | | </div> |
| | | </el-col> |
| | | <el-col :span="12"> |
| | | <div class="table_list" |
| | | style="width: 100%"> |
| | | <el-table :data="receiptRecord" |
| | | border |
| | | :row-key="(row) => row.id" |
| | | show-summary |
| | | :summary-method="summarizeMainTable1" |
| | | height="calc(100vh - 18.5em)"> |
| | | <el-table-column align="center" |
| | | label="序号" |
| | | type="index" |
| | | width="60" /> |
| | | <el-table-column label="合同签订日期" |
| | | prop="executionDate" |
| | | show-overflow-tooltip |
| | | width="110" /> |
| | | <el-table-column label="销售合同号" |
| | | prop="salesContractNo" |
| | | show-overflow-tooltip |
| | | width="200" /> |
| | | <el-table-column label="合同金额(元)" |
| | | prop="contractAmount" |
| | | show-overflow-tooltip |
| | | :formatter="formattedNumber" |
| | | width="200" /> |
| | | <el-table-column label="回款金额(元)" |
| | | prop="receiptPaymentAmount" |
| | | show-overflow-tooltip |
| | | :formatter="formattedNumber" |
| | | width="200" /> |
| | | <el-table-column label="退货金额(元)" |
| | | prop="returnAmount" |
| | | show-overflow-tooltip |
| | | :formatter="formattedNumber" |
| | | width="200" /> |
| | | <el-table-column label="应收金额(元)" |
| | | prop="receiptableAmount" |
| | | show-overflow-tooltip |
| | | width="200"> |
| | | <template #default="{ row, column }"> |
| | | <el-text type="danger"> |
| | | {{ formattedNumber(row, column, row.receiptableAmount) }} |
| | | </el-text> |
| | | </template> |
| | | </el-table-column> |
| | | </el-table> |
| | | <pagination v-show="recordTotal > 0" |
| | | :total="recordTotal" |
| | | layout="total, sizes, prev, pager, next, jumper" |
| | | :page="recordPage.current" |
| | | :limit="recordPage.size" |
| | | @pagination="recordPaginationChange" /> |
| | | <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> |
| | | </el-col> |
| | | </el-row> |
| | | </div> |
| | | |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { onMounted, ref, reactive, toRefs, getCurrentInstance } from "vue"; |
| | | import { |
| | | customewTransactions, |
| | | customewTransactionsDetails, |
| | | } from "@/api/salesManagement/indicatorStats.js"; |
| | | import Pagination from "../../../components/PIMTable/Pagination.vue"; |
| | | const { proxy } = getCurrentInstance(); |
| | | const tableData = ref([]); |
| | | const receiptRecord = ref([]); |
| | | const tableLoading = ref(false); |
| | | const page = reactive({ |
| | | current: 1, |
| | | size: 100, |
| | | 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 recordPage = reactive({ |
| | | current: 1, |
| | | size: 100, |
| | | }; |
| | | |
| | | 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 total = ref(0); |
| | | const recordTotal = ref(0); |
| | | const data = reactive({ |
| | | searchForm: { |
| | | searchText: "", |
| | | invoiceDate: "", |
| | | }, |
| | | }; |
| | | |
| | | 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 customerId = ref(""); |
| | | const { searchForm } = toRefs(data); |
| | | const originReceiptRecord = ref([]); |
| | | // 查询列表 |
| | | /** 搜索按钮操作 */ |
| | | const handleQuery = () => { |
| | | page.current = 1; |
| | | getList(); |
| | | }; |
| | | const paginationChange = obj => { |
| | | page.current = obj.page; |
| | | page.size = obj.limit; |
| | | getList(); |
| | | }; |
| | | const getList = () => { |
| | | tableLoading.value = true; |
| | | customewTransactions({ ...searchForm.value, ...page }).then(res => { |
| | | tableLoading.value = false; |
| | | tableData.value = res.data.records; |
| | | total.value = res.data.total; |
| | | if (tableData.value.length > 0) { |
| | | recordPage.current = 1; |
| | | customerId.value = tableData.value[0].customerId; |
| | | receiptPaymentList(customerId.value); |
| | | } |
| | | }); |
| | | }; |
| | | const formattedNumber = (row, column, cellValue) => { |
| | | return cellValue ? parseFloat(cellValue).toFixed(2) : "0.00"; |
| | | }; |
| | | // 主表合计方法 |
| | | const summarizeMainTable = param => { |
| | | return proxy.summarizeTable( |
| | | param, |
| | | [ |
| | | "contractAmounts", |
| | | "receiptPaymentAmount", |
| | | "returnAmount", |
| | | "receiptableAmount", |
| | | ], |
| | | { |
| | | ticketsNum: { noDecimal: true }, // 不保留小数 |
| | | futureTickets: { noDecimal: true }, // 不保留小数 |
| | | } |
| | | ); |
| | | }; |
| | | // 子表合计方法 |
| | | const summarizeMainTable1 = param => { |
| | | var summarizeTable = proxy.summarizeTable( |
| | | param, |
| | | [ |
| | | "contractAmount", |
| | | "receiptPaymentAmount", |
| | | "returnAmount", |
| | | "receiptableAmount", |
| | | ], |
| | | { |
| | | ticketsNum: { noDecimal: true }, // 不保留小数 |
| | | futureTickets: { noDecimal: true }, // 不保留小数 |
| | | } |
| | | ); |
| | | return summarizeTable; |
| | | }; |
| | | }; |
| | | |
| | | const receiptPaymentList = id => { |
| | | const param = { |
| | | customerId: id, |
| | | current: recordPage.current, |
| | | size: recordPage.size, |
| | | }; |
| | | customewTransactionsDetails(param).then(res => { |
| | | if (Array.isArray(res.data)) { |
| | | originReceiptRecord.value = res.data; |
| | | recordTotal.value = res.data.length; |
| | | handlePagination({ page: 1, limit: recordPage.size }); |
| | | } else { |
| | | receiptRecord.value = res.data.records; |
| | | recordTotal.value = res.data.total; |
| | | } |
| | | }); |
| | | 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 |
| | | }; |
| | | |
| | | // 汇款记录列表分页 |
| | | const recordPaginationChange = pagination => { |
| | | recordPage.current = pagination.page; |
| | | recordPage.size = pagination.limit; |
| | | receiptPaymentList(customerId.value); |
| | | }; |
| | | |
| | | const rowClickMethod = row => { |
| | | customerId.value = row.customerId; |
| | | receiptPaymentList(customerId.value); |
| | | }; |
| | | |
| | | const handlePagination = ({ page, limit }) => { |
| | | recordPage.current = page; |
| | | recordPage.size = limit; |
| | | |
| | | const start = (page - 1) * limit; |
| | | const end = start + limit; |
| | | |
| | | receiptRecord.value = originReceiptRecord.value.slice(start, end); |
| | | }; |
| | | |
| | | onMounted(() => { |
| | | getList(); |
| | | 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 lang="scss"> |
| | | .table_list { |
| | | width: 50%; |
| | | <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> |