This commit is contained in:
2025-09-18 21:17:44 +08:00
parent e560e90970
commit bff3d0414d
49 changed files with 1063 additions and 90 deletions

View File

@@ -0,0 +1,86 @@
<template>
<view class="page">
<view class="card">
<view class="row"><text class="label">名称</text><text v-if="!editing" class="value">{{ d.name }}</text><input v-else class="value-input" v-model="form.name" placeholder="必填" /></view>
<view class="row"><text class="label">联系人</text><text v-if="!editing" class="value">{{ d.contactName || '—' }}</text><input v-else class="value-input" v-model="form.contactName" placeholder="可选" /></view>
<view class="row"><text class="label">手机</text><text v-if="!editing" class="value">{{ d.mobile || '—' }}</text><input v-else class="value-input" v-model="form.mobile" placeholder="可选" /></view>
<view class="row"><text class="label">电话</text><text v-if="!editing" class="value">{{ d.phone || '—' }}</text><input v-else class="value-input" v-model="form.phone" placeholder="可选(座机)" /></view>
<view class="row"><text class="label">地址</text><text v-if="!editing" class="value">{{ d.address || '—' }}</text><input v-else class="value-input" v-model="form.address" placeholder="可选" /></view>
<view class="row"><text class="label">等级</text><text v-if="!editing" class="value">{{ d.level || '—' }}</text><input v-else class="value-input" v-model="form.level" placeholder="可选,如 VIP/A/B" /></view>
<view class="row"><text class="label">售价档位</text>
<text v-if="!editing" class="value">{{ d.priceLevel }}</text>
<picker v-else :range="priceLabels" :value="priceIdx" @change="onPriceChange"><view class="value">{{ priceLabels[priceIdx] }}</view></picker>
</view>
<view class="row"><text class="label">初始应收</text><text v-if="!editing" class="value">¥ {{ Number(d.arOpening||0).toFixed(2) }}</text><input v-else class="value-input" type="digit" v-model.number="form.arOpening" placeholder="0.00" /></view>
<view class="row"><text class="label">当前应收</text><text class="value emp">¥ {{ (Number(d.receivable||0)).toFixed(2) }}</text></view>
<view class="row"><text class="label">备注</text><text v-if="!editing" class="value">{{ d.remark || '—' }}</text><input v-else class="value-input" v-model="form.remark" placeholder="—" /></view>
</view>
<view class="bottom">
<button class="ghost" @click="toggleEdit">{{ editing ? '取消' : '编辑' }}</button>
<button class="primary" v-if="editing" @click="save">保存</button>
<button class="primary" v-else @click="choose">选择此客户</button>
</view>
</view>
</template>
<script>
import { get, put } from '../../common/http.js'
export default {
data(){ return { id: null, d: {}, editing: false, form: { name:'', contactName:'', mobile:'', phone:'', address:'', level:'', priceLevel:'零售价', arOpening:0, remark:'' }, priceLevels:['零售价','批发价','大单报价'], priceLabels:['零售价','批发价','大单报价'], priceIdx:0 } },
onLoad(q){ if (q && q.id) { this.id = Number(q.id); this.fetch() } },
methods: {
async fetch(){
try {
this.d = await get(`/api/customers/${this.id}`)
// 初始化表单
this.form = {
name: this.d.name || '', contactName: this.d.contactName || '', mobile: this.d.mobile || '', phone: this.d.phone || '',
address: this.d.address || '', level: this.d.level || '', priceLevel: this.d.priceLevel || 'retail', arOpening: Number(this.d.arOpening||0), remark: this.d.remark || ''
}
const idx = this.priceLevels.indexOf(this.form.priceLevel); this.priceIdx = idx >= 0 ? idx : 0
} catch(e){ uni.showToast({ title:'加载失败', icon:'none' }) }
},
toggleEdit(){ this.editing = !this.editing },
onPriceChange(e){ this.priceIdx = Number(e.detail.value); this.form.priceLevel = this.priceLevels[this.priceIdx] },
choose(){
const pages = getCurrentPages()
let targetIdx = -1
for (let i = pages.length - 2; i >= 0; i--) {
const vm = pages[i] && pages[i].$vm ? pages[i].$vm : null
if (vm && vm.order) { vm.order.customerId = this.d.id; vm.customerName = this.d.name; targetIdx = i; break }
}
if (targetIdx >= 0) {
const delta = (pages.length - 1) - targetIdx
uni.navigateBack({ delta })
} else {
uni.navigateBack()
}
},
async save(){
if (!this.form.name) return uni.showToast({ title:'请填写客户名称', icon:'none' })
try {
await put(`/api/customers/${this.id}`, this.form)
uni.showToast({ title:'已保存', icon:'success' })
this.editing = false
await this.fetch()
} catch(e) { uni.showToast({ title: e?.message || '保存失败', icon:'none' }) }
}
}
}
</script>
<style>
.page { padding-bottom: 140rpx; }
.card { background:#fff; margin: 16rpx; padding: 12rpx 16rpx; border-radius: 16rpx; }
.row { display:flex; justify-content: space-between; padding: 18rpx 8rpx; border-bottom: 1rpx solid #f3f3f3; }
.row:last-child { border-bottom: 0; }
.label { color:#666; }
.value { color:#333; max-width: 60%; text-align: right; }
.value-input { color:#333; text-align: right; flex: 1; }
.emp { color:#107e9b; font-weight: 700; }
.bottom { position: fixed; left:0; right:0; bottom:0; background:#fff; padding: 16rpx 24rpx calc(env(safe-area-inset-bottom) + 16rpx); box-shadow: 0 -4rpx 12rpx rgba(0,0,0,0.06); display:flex; gap: 12rpx; }
.primary { flex:1; background: linear-gradient(135deg, #A0E4FF 0%, #17A2C4 100%); color:#fff; border-radius: 999rpx; padding: 20rpx 0; }
.ghost { flex:1; background:#fff; color:#107e9b; border: 2rpx solid #A0E4FF; border-radius: 999rpx; padding: 18rpx 0; }
</style>

View File

@@ -2,12 +2,12 @@
<view class="page">
<view class="field"><text class="label">客户名称</text><input class="value" v-model="form.name" placeholder="必填" /></view>
<view class="field"><text class="label">客户等级</text><input class="value" v-model="form.level" placeholder="可选,如 VIP/A/B" /></view>
<view class="field">
<text class="label">售价档位</text>
<picker :range="priceLevels" :value="priceIdx" @change="onPriceChange">
<view class="value">{{ priceLevels[priceIdx] }}</view>
</picker>
</view>
<view class="field">
<text class="label">售价档位</text>
<picker :range="priceLabels" :value="priceIdx" @change="onPriceChange">
<view class="value">{{ priceLabels[priceIdx] }}</view>
</picker>
</view>
<view class="field"><text class="label">联系人</text><input class="value" v-model="form.contactName" placeholder="可选" /></view>
<view class="field"><text class="label">手机</text><input class="value" v-model="form.mobile" placeholder="可选" /></view>
<view class="field"><text class="label">电话</text><input class="value" v-model="form.phone" placeholder="可选(座机)" /></view>
@@ -26,13 +26,14 @@ export default {
return {
id: null,
form: { name:'', level:'', priceLevel:'retail', contactName:'', mobile:'', phone:'', address:'', arOpening:0, remark:'' },
priceLevels: ['retail','distribution','wholesale','big_client'],
priceLevels: ['零售价','批发价','大单报价'],
priceLabels: ['零售价','批发价','大单报价'],
priceIdx: 0
}
},
onLoad(query) { if (query && query.id) { this.id = Number(query.id) } },
methods: {
onPriceChange(e){ this.priceIdx = Number(e.detail.value); this.form.priceLevel = this.priceLevels[this.priceIdx] },
onPriceChange(e){ this.priceIdx = Number(e.detail.value); this.form.priceLevel = this.priceLevels[this.priceIdx] },
async save() {
if (!this.form.name) return uni.showToast({ title:'请填写客户名称', icon:'none' })
try {

View File

@@ -6,7 +6,7 @@
<button size="mini" :type="debtOnly ? 'primary' : 'default'" @click="toggleDebtOnly">只看欠款</button>
</view>
<scroll-view scroll-y class="list">
<view class="item" v-for="c in customers" :key="c.id" @click="select(c)">
<view class="item" v-for="c in customers" :key="c.id" @click="openDetail(c)">
<view class="name">{{ c.name }}</view>
<view class="meta">
{{ c.mobile || '—' }}
@@ -25,6 +25,7 @@
export default {
data() { return { kw: '', debtOnly: false, customers: [] } },
onLoad() { this.search() },
onShow() { this.search() },
methods: {
toggleDebtOnly() { this.debtOnly = !this.debtOnly; this.search() },
async search() {
@@ -35,13 +36,17 @@
},
createCustomer() { uni.navigateTo({ url: '/pages/customer/form' }) },
select(c) {
const opener = getCurrentPages()[getCurrentPages().length-2]
if (opener && opener.$vm) {
opener.$vm.order.customerId = c.id
opener.$vm.customerName = c.name
const pages = getCurrentPages()
const prev = pages.length >= 2 ? pages[pages.length - 2] : null
const vm = prev && prev.$vm ? prev.$vm : null
if (vm && vm.order) {
vm.order.customerId = c.id
vm.customerName = c.name
}
uni.navigateBack()
}
,
openDetail(c) { uni.navigateTo({ url: '/pages/customer/detail?id=' + c.id }) }
}
}
</script>

View File

@@ -156,6 +156,10 @@
uni.navigateTo({ url: '/pages/customer/select' })
return
}
if (item.key === 'supplier') {
uni.navigateTo({ url: '/pages/supplier/select' })
return
}
uni.showToast({ title: item.title + '(开发中)', icon: 'none' })
},
goProduct() {

View File

@@ -124,7 +124,7 @@
<input type="number" v-model.number="it.quantity" @input="recalc()" />
</view>
<view class="col price">
<input type="number" v-model.number="it.unitPrice" @input="recalc()" />
<input type="number" v-model.number="it.unitPrice" @input="onPriceInput(it)" />
</view>
<view class="col amount">¥ {{ (Number(it.quantity)*Number(it.unitPrice)).toFixed(2) }}</view>
</view>
@@ -139,7 +139,7 @@
</template>
<script>
import { post } from '../../common/http.js'
import { get, post } from '../../common/http.js'
import { INCOME_CATEGORIES, EXPENSE_CATEGORIES } from '../../common/constants.js'
function todayString() {
@@ -162,6 +162,9 @@
remark: ''
},
customerName: '',
customerPriceLevel: '零售价',
_lastCustomerId: null,
_priceCache: {},
supplierName: '',
items: [],
activeCategory: 'sale_income',
@@ -192,7 +195,46 @@
return Number(p.cash||0) + Number(p.bank||0) + Number(p.wechat||0)
}
},
onShow() {
if (this.biz === 'sale') {
if (this.order.customerId && this.order.customerId !== this._lastCustomerId) {
this.loadCustomerLevel(this.order.customerId).then(() => {
this._lastCustomerId = this.order.customerId
for (const it of this.items) { if (it && (it._autoPrice || !it.unitPrice)) this.autoPriceItem(it) }
})
}
for (const it of this.items) { if (it && !it.unitPrice) this.autoPriceItem(it) }
}
},
methods: {
async loadCustomerLevel(customerId) {
try {
const d = await get(`/api/customers/${customerId}`)
this.customerPriceLevel = d && d.priceLevel ? d.priceLevel : '零售价'
} catch(e) { this.customerPriceLevel = '零售价' }
},
priceFieldForLevel() {
const lvl = this.customerPriceLevel || '零售价'
if (lvl === '批发价') return 'wholesalePrice'
if (lvl === '大单报价') return 'bigClientPrice'
return 'retailPrice'
},
async autoPriceItem(it) {
if (this.biz !== 'sale') return
if (!it || !it.productId) return
const pid = it.productId
let detail = this._priceCache[pid]
if (!detail) {
try { detail = await get(`/api/products/${pid}`); this._priceCache[pid] = detail } catch(e) { return }
}
const field = this.priceFieldForLevel()
let price = Number(detail && detail[field] != null ? detail[field] : 0)
if (!price && field !== 'retailPrice') { price = Number(detail && detail.retailPrice != null ? detail.retailPrice : 0) }
it.unitPrice = price
it._autoPrice = true
this.recalc()
},
onPriceInput(it) { if (it) { it._autoPrice = false; this.recalc() } },
switchBiz(type) { this.biz = type },
onDateChange(e) { this.order.orderTime = e.detail.value },
chooseCustomer() {
@@ -214,6 +256,12 @@
const isSaleOrPurchase = (this.biz==='sale' || this.biz==='purchase')
const isCollectOrPay = (this.biz==='sale' && this.saleType==='collect') || (this.biz==='purchase' && this.purchaseType==='pay')
const saleTypeValue = this.biz==='sale' ? ('sale.' + this.saleType) : ('purchase.' + this.purchaseType)
// 前置校验:销售/进货 出入库要求有明细
if (isSaleOrPurchase && !isCollectOrPay) {
if (!this.items.length) { uni.showToast({ title: '请先选择商品', icon: 'none' }); return }
const invalid = this.items.find(it => !it.productId || Number(it.quantity||0) <= 0)
if (invalid) { uni.showToast({ title: '数量需大于0', icon: 'none' }); return }
}
const payload = isSaleOrPurchase ? (isCollectOrPay ? [
{ method: 'cash', amount: Number(this.payments.cash||0) },
{ method: 'bank', amount: Number(this.payments.bank||0) },

View File

@@ -28,7 +28,8 @@
select(p) {
const opener = getCurrentPages()[getCurrentPages().length-2]
if (opener && opener.$vm && opener.$vm.items) {
opener.$vm.items.push({ productId: p.id, productName: p.name, quantity: 1, unitPrice: Number(p.price || 0) })
const initPrice = Number((p.retailPrice != null ? p.retailPrice : (p.price || 0)))
opener.$vm.items.push({ productId: p.id, productName: p.name, quantity: 1, unitPrice: initPrice, _autoPrice: true })
}
uni.navigateBack()
}

View File

@@ -0,0 +1,50 @@
<template>
<view class="page">
<view class="field"><text class="label">供应商名称</text><input class="value" v-model="form.name" placeholder="必填" /></view>
<view class="field"><text class="label">联系人</text><input class="value" v-model="form.contactName" placeholder="可选" /></view>
<view class="field"><text class="label">手机</text><input class="value" v-model="form.mobile" placeholder="可选" /></view>
<view class="field"><text class="label">电话</text><input class="value" v-model="form.phone" placeholder="可选(座机)" /></view>
<view class="field"><text class="label">经营地址</text><input class="value" v-model="form.address" placeholder="可选" /></view>
<view class="field"><text class="label">初始应付款</text><input class="value" type="digit" v-model.number="form.apOpening" placeholder="默认 0.00" /></view>
<view class="field"><text class="label">应付款</text><input class="value" type="digit" v-model.number="form.apPayable" placeholder="默认 0.00" /></view>
<view class="textarea"><textarea v-model="form.remark" maxlength="200" placeholder="备注最多200字"></textarea></view>
<view class="bottom"><button class="primary" @click="save">保存</button></view>
</view>
</template>
<script>
import { post, put } from '../../common/http.js'
export default {
data() {
return {
id: null,
form: { name:'', contactName:'', mobile:'', phone:'', address:'', apOpening:0, apPayable:0, remark:'' }
}
},
onLoad(query) { if (query && query.id) { this.id = Number(query.id) } },
methods: {
async save() {
if (!this.form.name) return uni.showToast({ title:'请填写供应商名称', icon:'none' })
try {
if (this.id) await put(`/api/suppliers/${this.id}`, this.form)
else await post('/api/suppliers', this.form)
uni.showToast({ title:'保存成功', icon:'success' })
setTimeout(() => uni.navigateBack(), 500)
} catch(e) { uni.showToast({ title: e?.message || '保存失败', icon:'none' }) }
}
}
}
</script>
<style>
.page { padding-bottom: 140rpx; }
.field { display:flex; justify-content: space-between; padding: 22rpx 24rpx; background:#fff; border-bottom:1rpx solid #eee; }
.label { color:#666; }
.value { color:#333; text-align: right; flex: 1; }
.textarea { padding: 16rpx 24rpx; background:#fff; margin-top: 12rpx; }
.bottom { position: fixed; left:0; right:0; bottom:0; background:#fff; padding: 16rpx 24rpx calc(env(safe-area-inset-bottom) + 16rpx); box-shadow: 0 -4rpx 12rpx rgba(0,0,0,0.06); }
.primary { width: 100%; background: linear-gradient(135deg, #A0E4FF 0%, #17A2C4 100%); color:#fff; border-radius: 999rpx; padding: 20rpx 0; }
</style>

View File

@@ -3,28 +3,37 @@
<view class="search">
<input v-model="kw" placeholder="搜索供应商名称/电话" @confirm="search" />
<button size="mini" @click="search">搜索</button>
<button size="mini" :type="debtOnly ? 'primary' : 'default'" @click="toggleDebtOnly">只看欠款</button>
</view>
<scroll-view scroll-y class="list">
<view class="item" v-for="s in suppliers" :key="s.id" @click="select(s)">
<view class="name">{{ s.name }}</view>
<view class="meta">{{ s.mobile || '—' }}</view>
<view class="meta">
{{ s.mobile || '—' }}
<text v-if="typeof s.apPayable === 'number'">应付¥ {{ Number(s.apPayable).toFixed(2) }}</text>
</view>
</view>
</scroll-view>
<view class="bottom">
<button class="primary" @click="createSupplier">新增供应商</button>
</view>
</view>
</template>
<script>
import { get } from '../../common/http.js'
export default {
data() { return { kw: '', suppliers: [] } },
data() { return { kw: '', debtOnly: false, suppliers: [] } },
onLoad() { this.search() },
methods: {
toggleDebtOnly() { this.debtOnly = !this.debtOnly; this.search() },
async search() {
try {
const res = await get('/api/suppliers', { kw: this.kw, page: 1, size: 50 })
const res = await get('/api/suppliers', { kw: this.kw, debtOnly: this.debtOnly, page: 1, size: 50 })
this.suppliers = Array.isArray(res?.list) ? res.list : (Array.isArray(res) ? res : [])
} catch(e) { uni.showToast({ title: '加载失败', icon: 'none' }) }
},
createSupplier() { uni.navigateTo({ url: '/pages/supplier/form' }) },
select(s) {
const opener = getCurrentPages()[getCurrentPages().length-2]
if (opener && opener.$vm) {