| | |
| | | <template> |
| | | <view class="sales-account"> |
| | | <!-- 使用通用页面头部组件 --> |
| | | <PageHeader title="客户往来" |
| | | @back="goBack" /> |
| | | |
| | | <!-- 统计条(需求七:应收摘要) --> |
| | | <view class="summary-strip"> |
| | | <view class="summary-cell"> |
| | | <text class="summary-num">{{ summary.totalProjects || 0 }}</text> |
| | | <text class="summary-cap">项目</text> |
| | | </view> |
| | | <view class="summary-cell"> |
| | | <text class="summary-num">{{ summary.totalCustomers || 0 }}</text> |
| | | <text class="summary-cap">客户</text> |
| | | </view> |
| | | <view class="summary-cell"> |
| | | <text class="summary-num">{{ summary.totalContracts || 0 }}</text> |
| | | <text class="summary-cap">合同</text> |
| | | </view> |
| | | <view class="summary-cell"> |
| | | <text class="summary-num">{{ fmtMoney(summary.totalAmount) }}</text> |
| | | <text class="summary-cap">签约总额</text> |
| | | </view> |
| | | <view class="summary-cell overdue"> |
| | | <text class="summary-num">{{ summary.totalOverdueCount || 0 }}</text> |
| | | <text class="summary-cap">超30天未回款</text> |
| | | </view> |
| | | </view> |
| | | |
| | | <!-- 搜索区域 --> |
| | | <view class="search-section"> |
| | | <view class="search-bar"> |
| | | <view class="search-input"> |
| | | <up-input class="search-text" |
| | | placeholder="请输入客户名称" |
| | | v-model="searchForm.searchText" |
| | | @change="searchChange" |
| | | clearable /> |
| | | <up-input v-model="query.projectName" |
| | | class="search-text" |
| | | placeholder="请输入项目名称搜索" |
| | | clearable |
| | | @change="getList" /> |
| | | </view> |
| | | <view class="search-button" |
| | | @click="handleQuery"> |
| | | <up-icon name="search" |
| | | <view class="filter-button" |
| | | @click="showFilter = true"> |
| | | <up-icon name="list" |
| | | size="24" |
| | | color="#999"></up-icon> |
| | | color="#999" /> |
| | | </view> |
| | | </view> |
| | | </view> |
| | | <!-- 客户列表 --> |
| | | <view class="customer-list-container"> |
| | | <view class="customer-list" |
| | | v-if="tableData.length > 0"> |
| | | <view v-for="(item, index) in tableData" |
| | | :key="item.id" |
| | | class="customer-item" |
| | | @click="rowClickMethod(item)"> |
| | | <view class="item-header"> |
| | | <view class="item-left"> |
| | | <view class="customer-icon"> |
| | | <up-icon name="file-text" |
| | | size="16" |
| | | color="#ffffff"></up-icon> |
| | | </view> |
| | | <text class="customer-name">{{ item.customerName }}</text> |
| | | </view> |
| | | <view class="item-right"> |
| | | <up-icon name="arrow-right" |
| | | |
| | | <!-- 项目列表 --> |
| | | <view v-if="projectList.length > 0" |
| | | class="ledger-list"> |
| | | <view v-for="item in projectList" |
| | | :key="item.projectId" |
| | | class="ledger-item" |
| | | @click="goDetail(item)"> |
| | | <view class="item-header"> |
| | | <view class="item-left"> |
| | | <view class="document-icon"> |
| | | <up-icon name="file-text" |
| | | size="16" |
| | | color="#999"></up-icon> |
| | | color="#ffffff" /> |
| | | </view> |
| | | <text class="item-id">{{ item.projectName || "-" }}</text> |
| | | </view> |
| | | <up-divider></up-divider> |
| | | <view class="item-details"> |
| | | <view class="detail-row"> |
| | | <text class="detail-label">开票金额(元)</text> |
| | | <text class="detail-value">{{ formattedNumber(item.invoiceTotal) }}</text> |
| | | </view> |
| | | <view class="detail-row"> |
| | | <text class="detail-label">回款金额(元)</text> |
| | | <text class="detail-value">{{ formattedNumber(item.receiptPaymentAmount) }}</text> |
| | | </view> |
| | | <view class="detail-row"> |
| | | <text class="detail-label">应收金额(元)</text> |
| | | <text class="detail-value highlight danger">{{ formattedNumber(item.unReceiptPaymentAmount) }}</text> |
| | | </view> |
| | | <up-tag :text="overdueText(item.overdueCount)" |
| | | :type="item.overdueCount > 0 ? 'error' : 'success'" |
| | | size="mini" |
| | | shape="circle" /> |
| | | </view> |
| | | <up-divider></up-divider> |
| | | <view class="item-details"> |
| | | <view class="detail-row"> |
| | | <text class="detail-label">项目编号</text> |
| | | <text class="detail-value">{{ item.projectCode || "-" }}</text> |
| | | </view> |
| | | <view class="detail-row"> |
| | | <text class="detail-label">签约总额</text> |
| | | <text class="detail-value highlight">{{ fmtMoney(item.contractAmounts) }}</text> |
| | | </view> |
| | | <view class="detail-row"> |
| | | <text class="detail-label">开票金额</text> |
| | | <text class="detail-value">{{ fmtMoney(item.invoiceAmount) }}</text> |
| | | </view> |
| | | <view class="detail-row"> |
| | | <text class="detail-label">回款金额</text> |
| | | <text class="detail-value highlight">{{ fmtMoney(item.receiptPaymentAmount) }}</text> |
| | | </view> |
| | | <view class="detail-row"> |
| | | <text class="detail-label">应收金额</text> |
| | | <text class="detail-value danger">{{ fmtMoney(item.receiptableAmount) }}</text> |
| | | </view> |
| | | <view class="detail-row"> |
| | | <text class="detail-label">退货金额</text> |
| | | <text class="detail-value">{{ fmtMoney(item.returnAmount) }}</text> |
| | | </view> |
| | | <view class="detail-row"> |
| | | <text class="detail-label">回款进度</text> |
| | | <text class="detail-value" |
| | | :class="{ danger: item.overdueCount > 0 }"> |
| | | {{ calcRate(item.receiptPaymentAmount, item.contractAmounts) }}% |
| | | </text> |
| | | </view> |
| | | <view class="detail-row"> |
| | | <text class="detail-label">客户 / 合同</text> |
| | | <text class="detail-value">{{ item.customerCount || 0 }} 家 / {{ item.contractCount || 0 }} 份</text> |
| | | </view> |
| | | </view> |
| | | </view> |
| | | <view v-else |
| | | class="no-data"> |
| | | <text>暂无客户数据</text> |
| | | <view class="action-buttons"> |
| | | <up-button class="action-btn" |
| | | size="small" |
| | | type="primary" |
| | | @click.stop="goDetail(item)">查看客户往来</up-button> |
| | | </view> |
| | | </view> |
| | | </view> |
| | | <view v-else |
| | | class="no-data"> |
| | | <text>暂无项目往来数据</text> |
| | | </view> |
| | | |
| | | <FilterPanel v-model:show="showFilter" |
| | | :model-value="query" |
| | | title="客户往来筛选" |
| | | :fields="filterFields" |
| | | @search="onFilterSearch" |
| | | @reset="onFilterSearch" /> |
| | | </view> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { onMounted, ref, reactive, toRefs } from "vue"; |
| | | import { reactive, ref, onMounted } from "vue"; |
| | | import { onShow } from "@dcloudio/uni-app"; |
| | | import { invoiceLedgerSalesAccount } from "@/api/salesManagement/invoiceLedger"; |
| | | import PageHeader from "@/components/PageHeader.vue"; |
| | | import FilterPanel from "@/components/FilterPanel.vue"; |
| | | import { |
| | | projectTransactions, |
| | | transactionsSummary, |
| | | } from "@/api/salesManagement/indicatorStats"; |
| | | import { unwrapList } from "@/utils/list"; |
| | | |
| | | const tableData = ref([]); |
| | | const projectList = ref([]); |
| | | const summary = ref({}); |
| | | const showFilter = ref(false); |
| | | |
| | | const page = reactive({ |
| | | current: -1, |
| | | size: -1, |
| | | const query = reactive({ |
| | | projectName: "", |
| | | customerName: "", |
| | | contractName: "", |
| | | }); |
| | | |
| | | const data = reactive({ |
| | | searchForm: { |
| | | searchText: "", |
| | | invoiceDate: "", |
| | | }, |
| | | }); |
| | | const filterFields = [ |
| | | { prop: "customerName", label: "客户名称", type: "input", placeholder: "请输入客户名称" }, |
| | | { prop: "contractName", label: "合同名称", type: "input", placeholder: "请输入合同名称" }, |
| | | ]; |
| | | |
| | | const { searchForm } = toRefs(data); |
| | | |
| | | // 返回上一页 |
| | | const goBack = () => { |
| | | uni.navigateBack(); |
| | | }; |
| | | // 搜索框变化时触发 |
| | | const searchChange = val => { |
| | | searchForm.value.searchText = val; |
| | | getList(); |
| | | }; |
| | | // 查询列表 |
| | | const handleQuery = () => { |
| | | getList(); |
| | | }; |
| | | |
| | | const getList = () => { |
| | | showLoadingToast("加载中..."); |
| | | invoiceLedgerSalesAccount({ ...searchForm.value, ...page }) |
| | | uni.showLoading({ title: "加载中...", mask: true }); |
| | | const params = { ...query, current: -1, size: -1 }; |
| | | projectTransactions(params) |
| | | .then(res => { |
| | | tableData.value = res.data.records; |
| | | closeToast(); |
| | | projectList.value = unwrapList(res); |
| | | uni.hideLoading(); |
| | | }) |
| | | .catch(() => { |
| | | closeToast(); |
| | | uni.showToast({ |
| | | title: "查询失败", |
| | | icon: "error", |
| | | }); |
| | | uni.hideLoading(); |
| | | uni.showToast({ title: "查询失败", icon: "none" }); |
| | | }); |
| | | }; |
| | | |
| | | const formattedNumber = value => { |
| | | return parseFloat(value || 0).toFixed(2); |
| | | const getSummary = () => { |
| | | transactionsSummary() |
| | | .then(res => { |
| | | summary.value = res.data || {}; |
| | | }) |
| | | .catch(() => {}); |
| | | }; |
| | | |
| | | // 显示加载提示 |
| | | const showLoadingToast = message => { |
| | | uni.showLoading({ |
| | | title: message, |
| | | mask: true, |
| | | }); |
| | | const onFilterSearch = values => { |
| | | Object.assign(query, values || {}); |
| | | getList(); |
| | | }; |
| | | |
| | | // 关闭提示 |
| | | const closeToast = () => { |
| | | uni.hideLoading(); |
| | | }; |
| | | |
| | | const rowClickMethod = row => { |
| | | // 使用 uni.setStorageSync 存储客户信息 |
| | | uni.setStorageSync("customerId", row.id); |
| | | // 跳转到回款记录明细页面 |
| | | const goDetail = item => { |
| | | if (!item?.projectId) return; |
| | | uni.navigateTo({ |
| | | url: "/pages/sales/receiptPaymentLedger/detail", |
| | | url: `/pages/sales/receiptPaymentLedger/detail?projectId=${item.projectId}` |
| | | + `&projectName=${encodeURIComponent(item.projectName || "")}`, |
| | | }); |
| | | }; |
| | | |
| | | const overdueText = count => (count > 0 ? `${count} 份超期` : "正常履约"); |
| | | |
| | | const fmtMoney = value => { |
| | | const num = Number(value); |
| | | if (!Number.isFinite(num)) return "¥ 0.00"; |
| | | return "¥ " + num.toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); |
| | | }; |
| | | |
| | | const calcRate = (paid, amount) => { |
| | | const total = Number(amount); |
| | | if (!Number.isFinite(total) || total === 0) return "0.0"; |
| | | return (((Number(paid) || 0) / total) * 100).toFixed(1); |
| | | }; |
| | | |
| | | onShow(() => { |
| | | // 页面显示时刷新列表 |
| | | getList(); |
| | | getSummary(); |
| | | }); |
| | | |
| | | onMounted(() => { |
| | | getList(); |
| | | getSummary(); |
| | | }); |
| | | </script> |
| | | |
| | | <style scoped lang="scss"> |
| | | @import "@/styles/sales-common.scss"; |
| | | |
| | | // 客户往来特有样式 |
| | | .summary-strip { |
| | | display: flex; |
| | | flex-wrap: wrap; |
| | | background: #ffffff; |
| | | margin: 12px 12px 0; |
| | | border-radius: 12px; |
| | | padding: 12px 8px; |
| | | box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); |
| | | } |
| | | |
| | | .summary-cell { |
| | | flex: 1 0 33%; |
| | | display: flex; |
| | | flex-direction: column; |
| | | align-items: center; |
| | | padding: 6px 0; |
| | | } |
| | | |
| | | .summary-num { |
| | | font-size: 16px; |
| | | font-weight: 600; |
| | | color: #333333; |
| | | } |
| | | |
| | | .summary-cap { |
| | | font-size: 11px; |
| | | color: #909399; |
| | | margin-top: 2px; |
| | | } |
| | | |
| | | .summary-cell.overdue .summary-num { |
| | | color: #ff4757; |
| | | } |
| | | |
| | | .detail-value.danger { |
| | | color: #ff4757; // 与公共样式中的 #ee0a24 不同 |
| | | color: #ff4757; |
| | | font-weight: 500; |
| | | } |
| | | </style> |
| | | </style> |