This commit is contained in:
hero920103 committed 2026-10-19 01:13:17 +08:00
1 parent 5718a00e31
commit 1850ac640e
226 files changed
+37772 -12071

No files matched your search

+408
View File
@@ -0,0 +1,408 @@
<template>
<view class="page">
<view v-if="loading && !profile" class="state-box">
<text class="state-text">加载中...</text>
</view>
<block v-else-if="profile">
<view class="hero">
<view class="hero-top">
<text class="hero-name">{{ name }}</text>
<u-tag :text="partnerTypeLabel(partnerType)" type="primary" size="mini" />
</view>
<text class="hero-sub">{{ [profile.contact_person, profile.contact_phone].filter(Boolean).join(' · ') || '未填联系人' }}</text>
<view class="hero-grid">
<view class="hero-cell">
<text class="cell-label">{{ isSupplier ? '未付应付' : '未收应收' }}</text>
<text class="cell-value owe">¥{{ moneyText(openAmount) }}</text>
</view>
<view class="hero-cell">
<text class="cell-label">{{ isSupplier ? '累计应付' : '累计应收' }}</text>
<text class="cell-value">¥{{ moneyText(totalAmount) }}</text>
</view>
<view class="hero-cell">
<text class="cell-label">账期</text>
<text class="cell-value">{{ Number(profile.payment_days) > 0 ? profile.payment_days + ' 天' : '现结' }}</text>
</view>
</view>
</view>
<view class="card">
<view class="block-head">
<text class="block-title">基础资料</text>
</view>
<view class="line">
<text class="line-label">等级 / 类型</text>
<text class="line-value">{{ join([profile.supplier_level || profile.customer_level, profile.supplier_type || profile.customer_type]) }}</text>
</view>
<view class="line">
<text class="line-label">联系电话</text>
<text class="line-value link" @tap="callPhone">{{ dash(profile.contact_phone) }}</text>
</view>
<view class="line">
<text class="line-label">邮箱</text>
<text class="line-value">{{ dash(profile.contact_email) }}</text>
</view>
<view class="line">
<text class="line-label">地址</text>
<text class="line-value">{{ dash(profile.address) }}</text>
</view>
<view class="line">
<text class="line-label">开票抬头</text>
<text class="line-value">{{ join([profile.invoice_title, profile.tax_number]) }}</text>
</view>
<view class="line">
<text class="line-label">收款账户</text>
<text class="line-value">{{ join([profile.bank_name, profile.bank_account]) }}</text>
</view>
<view v-if="Number(profile.credit_limit) > 0" class="line">
<text class="line-label">信用额度</text>
<text class="line-value">¥{{ moneyText(profile.credit_limit) }}</text>
</view>
</view>
<view class="card">
<view class="block-head">
<text class="block-title">未结账单({{ bills.length }} 张)</text>
<text class="block-tip" @tap="goAllBills">全部 {{ billTotal }} 张</text>
</view>
<view v-if="!bills.length" class="empty-line">
<text>没有未结账单</text>
</view>
<view v-for="b in bills" :key="b.id" class="line tap" @tap="goBill(b)">
<view class="line-label grow">
<text>{{ b.source_doc_no || `账单#${b.id}` }}</text>
<text class="sub">{{ b.bill_date }}{{ b.due_date ? ' · 到期 ' + b.due_date : '' }}</text>
</view>
<view class="line-value right">
<text class="bal">¥{{ moneyText(b.balance) }}</text>
<text v-if="b.overdue_days > 0" class="over">逾期 {{ b.overdue_days }} 天</text>
</view>
</view>
</view>
<view class="footer-space"></view>
<view class="actions">
<view class="act" @tap="goDocs">
<text>历史单据</text>
</view>
<view class="act primary" @tap="goFund">
<text>{{ isSupplier ? '去付款' : '去收款' }}</text>
</view>
</view>
</block>
</view>
</template>
<script setup>
/**
* 客商详情:档案 + 未结账单 + 一键收付款。
* 余额口径直接取往来账(bill)与对账汇总,和 Web 端「应收应付」页一致,
* 端上不另算一套,避免业务在手机上看到的欠款跟财务对不上。
*/
import { computed, ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import {
fetchBills,
fetchPartnerSummary,
getPartner,
moneyText,
partnerTypeLabel
} from '@/api/erp.js'
const profile = ref(null)
const bills = ref([])
const billTotal = ref(0)
const summary = ref({})
const loading = ref(false)
const partnerId = ref(0)
const partnerType = ref('customer')
const isSupplier = computed(() => partnerType.value === 'supplier')
const name = computed(() => {
const p = profile.value || {}
return p.supplier_name || p.customer_name || p.name || '未命名客商'
})
const openAmount = computed(() => {
const s = summary.value || {}
return Number(isSupplier.value ? s.pay_open : s.recv_open) || 0
})
const totalAmount = computed(() => {
const s = summary.value || {}
return Number(isSupplier.value ? s.pay_total : s.recv_total) || 0
})
onLoad(async (query = {}) => {
partnerId.value = Number(query.id) || 0
partnerType.value = String(query.partner_type) === 'supplier' ? 'supplier' : 'customer'
uni.setNavigationBarTitle({ title: partnerTypeLabel(partnerType.value) })
await load()
})
async function load() {
if (!partnerId.value) {
uni.showToast({ title: '缺少客商 ID', icon: 'none' })
return
}
loading.value = true
try {
const [p, b, rows] = await Promise.all([
getPartner(partnerId.value, partnerType.value),
fetchBills({ partnerId: partnerId.value, partnerType: partnerType.value, settleState: 'open', pageSize: 10 }),
fetchPartnerSummary(partnerType.value)
])
profile.value = p
bills.value = b.list || []
billTotal.value = b.total || 0
// 对账汇总按 partner_type:id 归并,这里只取当前这一家
summary.value = rows.find((r) => Number(r.partner_id) === partnerId.value) || {}
} catch (e) {
uni.showToast({ title: '客商信息加载失败', icon: 'none' })
} finally {
loading.value = false
}
}
function goFund() {
const fundType = isSupplier.value ? 2 : 1
uni.navigateTo({
url: `/pages/erp/finance/fund-edit?fund_type=${fundType}&partner_id=${partnerId.value}&partner_name=${encodeURIComponent(name.value)}`
})
}
function goDocs() {
uni.navigateTo({ url: `/pages/erp/document/index?partner_id=${partnerId.value}` })
}
function goAllBills() {
const billType = isSupplier.value ? 2 : 1
uni.navigateTo({ url: `/pages/erp/finance/bill?bill_type=${billType}&partner_id=${partnerId.value}` })
}
function goBill(bill) {
uni.navigateTo({ url: `/pages/erp/finance/bill-detail?id=${bill.id}` })
}
function callPhone() {
const phone = String(profile.value?.contact_phone || '').trim()
if (!phone) return
uni.makePhoneCall({ phoneNumber: phone, fail: () => {} })
}
function dash(v) {
const s = String(v ?? '').trim()
return s || '—'
}
function join(arr) {
const s = (arr || []).filter(Boolean).join(' / ')
return s || '—'
}
</script>
<style lang="scss" scoped>
@import '@/src/styles/page-common.scss';
.page {
min-height: 100vh;
background: $color-bg-page;
padding-top: 20rpx;
}
.hero {
margin: 0 $page-padding-x 24rpx;
padding: 32rpx;
border-radius: $radius-lg;
background: $hero-gradient;
color: #ffffff;
box-shadow: $shadow-float;
}
.hero-top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16rpx;
}
.hero-name {
flex: 1;
font-size: 36rpx;
font-weight: 600;
line-height: 1.4;
}
.hero-sub {
display: block;
margin-top: 8rpx;
font-size: 24rpx;
opacity: 0.85;
}
.hero-grid {
display: flex;
margin-top: 28rpx;
padding-top: 22rpx;
border-top: 1rpx solid rgba(255, 255, 255, 0.22);
}
.hero-cell {
flex: 1;
.cell-label {
display: block;
font-size: 22rpx;
opacity: 0.8;
}
.cell-value {
display: block;
margin-top: 6rpx;
font-size: 28rpx;
font-weight: 500;
&.owe {
color: #fecaca;
}
}
}
.card {
@include card;
margin: 0 $page-padding-x 24rpx;
padding: 24rpx 28rpx;
}
.block-head {
@include block-head;
}
.block-title {
@include section-title;
}
.block-tip {
font-size: 22rpx;
color: $color-text-muted;
}
.line {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 20rpx;
padding: 16rpx 0;
border-bottom: 1rpx solid $color-divider;
font-size: 26rpx;
&:last-child {
border-bottom: none;
}
&.tap:active {
opacity: 0.7;
}
}
.line-label {
color: $color-text-secondary;
flex-shrink: 0;
&.grow {
flex: 1;
min-width: 0;
}
}
.sub {
display: block;
margin-top: 4rpx;
font-size: 22rpx;
color: $color-text-muted;
}
.line-value {
flex: 1;
text-align: right;
color: $color-text;
word-break: break-all;
&.link {
color: $color-primary;
}
}
.right {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4rpx;
}
.bal {
font-size: 27rpx;
font-weight: 500;
color: #dc2626;
}
.over {
font-size: 21rpx;
color: #f59e0b;
}
.empty-line {
padding: 40rpx 0;
text-align: center;
font-size: 25rpx;
color: $color-text-muted;
}
.footer-space {
height: calc(150rpx + env(safe-area-inset-bottom));
}
.actions {
position: fixed;
left: 0;
right: 0;
bottom: 0;
display: flex;
gap: 16rpx;
padding: 18rpx $page-padding-x calc(18rpx + env(safe-area-inset-bottom));
background: $color-card;
box-shadow: 0 -4rpx 16rpx rgba(15, 23, 42, 0.06);
@include dialog-responsive;
}
.act {
flex: 1;
height: 76rpx;
line-height: 76rpx;
text-align: center;
border-radius: $radius-full;
background: $color-bg-page;
font-size: 26rpx;
color: $color-text-secondary;
&.primary {
background: #2563eb;
color: #ffffff;
}
&:active {
opacity: 0.85;
}
}
.state-box {
display: flex;
justify-content: center;
padding: 140rpx 0;
}
.state-text {
font-size: 28rpx;
color: $color-text-muted;
}
</style>
+320
View File
@@ -0,0 +1,320 @@
<template>
<view class="page">
<view class="filter-bar">
<view class="type-row">
<view
v-for="t in PARTNER_TYPES"
:key="t.value"
class="type-item"
:class="{ active: partnerType === t.value }"
@tap="switchType(t.value)"
>
<text>{{ t.label }}</text>
</view>
</view>
<view class="search-box">
<FaIcon name="magnifying-glass" color="#94a3b8" :size="16" />
<input
v-model="keyword"
class="search-input"
:placeholder="`搜索${partnerTypeLabel(partnerType)}名称`"
confirm-type="search"
@confirm="refresh"
/>
<text v-if="keyword" class="search-clear" @tap="clearKeyword">清空</text>
</view>
<view class="sum-row">
<text class="sum-item">共 {{ total }} 家</text>
<text class="sum-item">未结合计 ¥{{ moneyText(openTotal) }}</text>
</view>
</view>
<view v-if="loading && !list.length" class="state-box">
<text class="state-text">加载中...</text>
</view>
<view v-else-if="!list.length" class="state-box">
<u-empty mode="search" :text="`没有匹配的${partnerTypeLabel(partnerType)}`" />
<text class="state-tip">客商建档请在 Web 后台 CRM / 进销存里完成</text>
</view>
<template v-else>
<view v-for="row in list" :key="row.partner_id" class="p-card" @tap="openDetail(row)">
<view class="p-top">
<text class="p-name">{{ row.partner_name }}</text>
<text class="p-open" :class="{ owe: Number(row.open_amount) > 0 }">
{{ Number(row.open_amount) > 0 ? `未结 ¥${moneyText(row.open_amount)}` : '已结清' }}
</text>
</view>
<view class="p-meta">
<text v-if="row.contact_person">{{ row.contact_person }} {{ row.contact_phone || '' }}</text>
<text v-if="row.level">等级 {{ row.level }}</text>
<text v-if="Number(row.payment_days) > 0">账期 {{ row.payment_days }} 天</text>
<text v-if="Number(row.credit_limit) > 0">信用额 ¥{{ moneyText(row.credit_limit) }}</text>
</view>
</view>
<view v-if="finished" class="load-done">
<text>{{ list.length ? '没有更多了' : '' }}</text>
</view>
</template>
<view class="fab" @tap="goBills">
<FaIcon name="receipt" color="#ffffff" :size="20" />
<text>{{ partnerType === 'supplier' ? '应付账单' : '应收账单' }}</text>
</view>
</view>
</template>
<script setup>
/**
* 客商列表(供应商 / 客户同一个页,靠 partner_type 分流)。
* 与后端 partner/list 一致:只列正式启用、有归属的对象,公海客户不参与开单也不在这里出现。
*/
import { computed, ref } from 'vue'
import { onLoad, onReachBottom, onShow } from '@dcloudio/uni-app'
import FaIcon from '@/components/FaIcon.vue'
import { PARTNER_TYPES, fetchPartners, moneyText, partnerTypeLabel } from '@/api/erp.js'
const PAGE_SIZE = 20
const list = ref([])
const total = ref(0)
const page = ref(1)
const loading = ref(false)
const finished = ref(false)
const keyword = ref('')
const partnerType = ref('customer')
const openTotal = computed(() => list.value.reduce((sum, r) => sum + Number(r.open_amount || 0), 0))
onLoad((query = {}) => {
if (query.partner_type) partnerType.value = String(query.partner_type) === 'supplier' ? 'supplier' : 'customer'
})
onShow(() => {
refresh()
})
onReachBottom(() => {
if (finished.value || loading.value) return
loadMore()
})
function switchType(value) {
if (partnerType.value === value) return
partnerType.value = value
list.value = []
refresh()
uni.setNavigationBarTitle({ title: partnerTypeLabel(value) })
}
async function refresh() {
page.value = 1
finished.value = false
list.value = []
await loadPage()
}
async function loadMore() {
page.value += 1
await loadPage()
}
async function loadPage() {
loading.value = true
try {
const data = await fetchPartners({
page: page.value,
pageSize: PAGE_SIZE,
partnerType: partnerType.value,
keyword: keyword.value.trim()
})
const rows = data.list || []
list.value = page.value === 1 ? rows : [...list.value, ...rows]
total.value = data.total || 0
finished.value = list.value.length >= total.value
} catch (e) {
if (page.value > 1) page.value -= 1
} finally {
loading.value = false
}
}
function clearKeyword() {
keyword.value = ''
refresh()
}
function openDetail(row) {
uni.navigateTo({
url: `/pages/erp/partner/detail?id=${row.partner_id}&partner_type=${partnerType.value}`
})
}
function goBills() {
uni.navigateTo({
url: `/pages/erp/finance/bill?bill_type=${partnerType.value === 'supplier' ? 2 : 1}`
})
}
</script>
<style lang="scss" scoped>
@import '@/src/styles/page-common.scss';
.page {
min-height: 100vh;
background: $color-bg-page;
padding-bottom: calc(160rpx + env(safe-area-inset-bottom));
}
.filter-bar {
position: sticky;
top: 0;
z-index: 10;
background: $color-bg-page;
padding: 20rpx $page-padding-x 8rpx;
}
.type-row {
display: flex;
gap: 16rpx;
margin-bottom: 18rpx;
}
.type-item {
padding: 10rpx 32rpx;
border-radius: $radius-full;
background: $color-card;
font-size: 26rpx;
color: $color-text-secondary;
box-shadow: $shadow-card;
&.active {
background: #2563eb;
color: #ffffff;
font-weight: 500;
}
}
.search-box {
display: flex;
align-items: center;
gap: 12rpx;
height: 72rpx;
padding: 0 24rpx;
background: $color-card;
border-radius: $radius-full;
box-shadow: $shadow-card;
.search-input {
flex: 1;
font-size: 26rpx;
color: $color-text;
}
.search-clear {
font-size: 24rpx;
color: $color-text-muted;
}
}
.sum-row {
display: flex;
justify-content: space-between;
padding: 16rpx 8rpx 4rpx;
}
.sum-item {
font-size: 24rpx;
color: $color-text-muted;
}
.p-card {
@include card;
margin: 0 $page-padding-x 20rpx;
padding: 24rpx 28rpx;
&:active {
opacity: 0.85;
}
}
.p-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
}
.p-name {
flex: 1;
font-size: 29rpx;
font-weight: 500;
color: $color-text;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.p-open {
flex-shrink: 0;
font-size: 25rpx;
color: $color-text-muted;
&.owe {
color: #dc2626;
font-weight: 500;
}
}
.p-meta {
display: flex;
flex-wrap: wrap;
gap: 8rpx 24rpx;
margin-top: 10rpx;
font-size: 23rpx;
color: $color-text-muted;
}
.load-done {
display: flex;
justify-content: center;
padding: 8rpx 0 24rpx;
font-size: 24rpx;
color: $color-text-muted;
}
.state-box {
display: flex;
flex-direction: column;
align-items: center;
padding: 120rpx 40rpx;
}
.state-text,
.state-tip {
font-size: 26rpx;
color: $color-text-muted;
margin-top: 16rpx;
}
.fab {
position: fixed;
left: 50%;
transform: translateX(-50%);
bottom: calc(48rpx + env(safe-area-inset-bottom));
display: flex;
align-items: center;
gap: 10rpx;
height: 84rpx;
padding: 0 44rpx;
border-radius: $radius-full;
background: #2563eb;
color: #ffffff;
font-size: 27rpx;
font-weight: 500;
box-shadow: 0 8rpx 24rpx rgba(37, 99, 235, 0.4);
&:active {
opacity: 0.85;
}
}
</style>