This commit is contained in:
2025-09-20 12:05:53 +08:00
parent bff3d0414d
commit 9b107d665a
73 changed files with 2903 additions and 140 deletions

View File

@@ -0,0 +1,74 @@
<template>
<view class="page">
<view class="form">
<view class="field"><text class="label">账户名称</text><input class="input" v-model="form.name" placeholder="必填"/></view>
<view class="field" @click="showType=true">
<text class="label">账户类型</text>
<text class="value">{{ typeLabel(form.type) }}</text>
</view>
<view v-if="form.type==='bank'" class="field"><text class="label">银行名称</text><input class="input" v-model="form.bankName" placeholder="选填"/></view>
<view v-if="form.type==='bank'" class="field"><text class="label">银行账号</text><input class="input" v-model="form.bankAccount" placeholder="选填"/></view>
<view class="field"><text class="label">当前余额</text><input class="input" type="number" v-model="form.openingBalance" placeholder="0.00"/></view>
</view>
<view class="actions">
<button class="primary" @click="save">保存</button>
</view>
<uni-popup ref="popup" type="bottom" v-model="showType">
<view class="sheet">
<view class="sheet-item" v-for="t in types" :key="t.key" @click="form.type=t.key;showType=false">{{ t.name }}</view>
<view class="sheet-cancel" @click="showType=false">取消</view>
</view>
</uni-popup>
</view>
</template>
<script>
import { post, put, get } from '../../common/http.js'
export default {
data(){
return {
id: null,
form: { name: '', type: 'cash', bankName: '', bankAccount: '', openingBalance: '' },
showType: false,
types: [
{ key: 'cash', name: '现金' },
{ key: 'bank', name: '银行存款' },
{ key: 'wechat', name: '微信' },
{ key: 'alipay', name: '支付宝' },
{ key: 'other', name: '其他' }
]
}
},
onLoad(q){ this.id = q && q.id ? Number(q.id) : null; if (this.id) this.load(); },
methods: {
typeLabel(t){ const m = {cash:'现金', bank:'银行存款', wechat:'微信', alipay:'支付宝', other:'其他'}; return m[t]||t },
async load(){ try { const list = await get('/api/accounts'); const a = (Array.isArray(list)?list:(list?.list||[])).find(x=>x.id==this.id); if (a) { this.form={ name:a.name, type:a.type, bankName:a.bank_name||a.bankName||'', bankAccount:a.bank_account||a.bankAccount||'', openingBalance:'' } } } catch(e){} },
async save(){
if (!this.form.name) { uni.showToast({ title: '请输入名称', icon: 'none' }); return }
try {
const body = { ...this.form, openingBalance: Number(this.form.openingBalance||0) }
if (this.id) await put(`/api/accounts/${this.id}`, body)
else await post('/api/accounts', { ...body, status: 1 })
uni.showToast({ title: '已保存', icon: 'success' })
setTimeout(()=>uni.navigateBack(), 300)
} catch(e) { uni.showToast({ title: '保存失败', icon: 'none' }) }
}
}
}
</script>
<style>
.page { display:flex; flex-direction: column; height: 100vh; }
.form { background:#fff; }
.field { display:flex; align-items:center; justify-content: space-between; padding: 18rpx 20rpx; border-bottom:1rpx solid #f3f3f3; }
.label { color:#666; }
.input { flex:1; text-align: right; color:#333; }
.value { color:#333; }
.actions { margin-top: 20rpx; padding: 0 20rpx; }
.primary { width: 100%; background: #3c9cff; color:#fff; border-radius: 8rpx; padding: 22rpx 0; }
.sheet { background:#fff; }
.sheet-item { padding: 26rpx; text-align:center; border-bottom:1rpx solid #f2f2f2; }
.sheet-cancel { padding: 26rpx; text-align:center; color:#666; }
</style>

View File

@@ -0,0 +1,87 @@
<template>
<view class="page">
<view class="filters">
<picker mode="date" :value="startDate" @change="e=>{startDate=e.detail.value;load()}">
<view class="field"><text class="label">开始</text><text class="value">{{ startDate || '—' }}</text></view>
</picker>
<picker mode="date" :value="endDate" @change="e=>{endDate=e.detail.value;load()}">
<view class="field"><text class="label">结束</text><text class="value">{{ endDate || '—' }}</text></view>
</picker>
</view>
<view class="summary">
<view class="sum-item"><text class="k">收入</text><text class="v">{{ fmt(income) }}</text></view>
<view class="sum-item"><text class="k">支出</text><text class="v">{{ fmt(expense) }}</text></view>
<view class="sum-item"><text class="k">期初</text><text class="v">{{ fmt(opening) }}</text></view>
<view class="sum-item"><text class="k">期末</text><text class="v">{{ fmt(ending) }}</text></view>
</view>
<scroll-view scroll-y class="list">
<view class="item" v-for="it in list" :key="it.id">
<view class="row">
<text class="title">{{ it.src==='other' ? (it.category || '其他') : (it.remark || '收付款') }}</text>
<text class="amount" :class="{ in: it.direction==='in', out: it.direction==='out' }">{{ it.direction==='in' ? '+' : '-' }}{{ fmt(it.amount) }}</text>
</view>
<view class="meta">{{ formatDate(it.tx_time || it.txTime) }} · {{ it.remark || '-' }}</view>
</view>
</scroll-view>
</view>
</template>
<script>
import { get } from '../../common/http.js'
export default {
data() {
return { accountId: null, startDate: '', endDate: '', list: [], opening: 0, income: 0, expense: 0, ending: 0 }
},
onLoad(query) {
this.accountId = Number(query && query.id)
this.quickInit()
this.load()
},
methods: {
quickInit() {
// 默认本月
const now = new Date()
const y = now.getFullYear(), m = now.getMonth()+1
this.startDate = `${y}-${String(m).padStart(2,'0')}-01`
const lastDay = new Date(y, m, 0).getDate()
this.endDate = `${y}-${String(m).padStart(2,'0')}-${String(lastDay).padStart(2,'0')}`
},
async load(page=1, size=50) {
try {
const res = await get(`/api/accounts/${this.accountId}/ledger`, { startDate: this.startDate, endDate: this.endDate, page, size })
this.list = (res && res.list) || []
this.opening = Number(res && res.opening || 0)
this.income = Number(res && res.income || 0)
this.expense = Number(res && res.expense || 0)
this.ending = Number(res && res.ending || 0)
} catch (e) {
uni.showToast({ title: '加载失败', icon: 'none' })
}
},
fmt(v) { return (typeof v === 'number' ? v : Number(v||0)).toFixed(2) },
formatDate(s) { if (!s) return '-'; try { const d=new Date(s); const pad=n=>String(n).padStart(2,'0'); return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}` } catch(e){ return s } }
}
}
</script>
<style>
.page { display:flex; flex-direction: column; height: 100vh; }
.filters { display:flex; gap: 16rpx; padding: 16rpx; background:#fff; }
.field { display:flex; justify-content: space-between; align-items:center; padding: 16rpx; border:1rpx solid #eee; border-radius: 12rpx; min-width: 300rpx; }
.label { color:#666; }
.value { color:#333; }
.summary { display:grid; grid-template-columns: repeat(4,1fr); gap: 12rpx; padding: 12rpx 16rpx; background:#fff; border-top:1rpx solid #f1f1f1; border-bottom:1rpx solid #f1f1f1; }
.sum-item { padding: 12rpx; text-align:center; }
.k { display:block; color:#888; font-size: 24rpx; }
.v { display:block; margin-top:6rpx; font-weight:700; color:#333; }
.list { flex:1; }
.item { padding: 18rpx 16rpx; border-bottom:1rpx solid #f4f4f4; background:#fff; }
.row { display:flex; align-items:center; justify-content: space-between; margin-bottom: 6rpx; }
.title { color:#333; }
.amount { font-weight:700; }
.amount.in { color:#2a9d8f; }
.amount.out { color:#d35b5b; }
.meta { color:#999; font-size: 24rpx; }
</style>

View File

@@ -6,6 +6,7 @@
<view class="meta">{{ typeLabel(a.type) }} · 余额{{ a.balance?.toFixed ? a.balance.toFixed(2) : a.balance }}</view>
</view>
</scroll-view>
<view class="fab" @click="create"></view>
</view>
</template>
@@ -13,8 +14,9 @@
import { get } from '../../common/http.js'
const TYPE_MAP = { cash: '现金', bank: '银行', alipay: '支付宝', wechat: '微信', other: '其他' }
export default {
data() { return { accounts: [] } },
async onLoad() {
data() { return { accounts: [], mode: 'view' } },
async onLoad(q) {
this.mode = (q && q.mode) || 'view'
try {
const res = await get('/api/accounts')
this.accounts = Array.isArray(res) ? res : (res?.list || [])
@@ -22,13 +24,18 @@
},
methods: {
select(a) {
const opener = getCurrentPages()[getCurrentPages().length-2]
if (opener && opener.$vm) {
opener.$vm.selectedAccountId = a.id
opener.$vm.selectedAccountName = a.name
if (this.mode === 'pick') {
const opener = getCurrentPages()[getCurrentPages().length-2]
if (opener && opener.$vm) {
opener.$vm.selectedAccountId = a.id
opener.$vm.selectedAccountName = a.name
}
uni.navigateBack()
} else {
uni.navigateTo({ url: `/pages/account/ledger?id=${a.id}` })
}
uni.navigateBack()
},
create() { uni.navigateTo({ url: '/pages/account/form' }) },
typeLabel(t) { return TYPE_MAP[t] || t }
}
}
@@ -40,6 +47,7 @@
.item { padding: 20rpx 24rpx; background:#fff; border-bottom: 1rpx solid #f1f1f1; }
.name { color:#333; margin-bottom: 6rpx; }
.meta { color:#888; font-size: 24rpx; }
.fab { position: fixed; right: 32rpx; bottom: 120rpx; width: 100rpx; height: 100rpx; border-radius: 50%; background:#3c9cff; color:#fff; display:flex; align-items:center; justify-content:center; font-size: 52rpx; box-shadow: 0 10rpx 20rpx rgba(0,0,0,0.18); }
</style>

View File

@@ -78,10 +78,10 @@
<view class="tab" :class="{ active: activeTab==='detail' }" @click="goDetail">
<text>明细</text>
</view>
<view class="tab" :class="{ active: activeTab==='report' }" @click="activeTab='report'">
<view class="tab" :class="{ active: activeTab==='report' }" @click="goReport">
<text>报表</text>
</view>
<view class="tab" :class="{ active: activeTab==='me' }" @click="activeTab='me'">
<view class="tab" :class="{ active: activeTab==='me' }" @click="goMe">
<text>我的</text>
</view>
</view>
@@ -156,6 +156,11 @@
uni.navigateTo({ url: '/pages/customer/select' })
return
}
if (item.key === 'account') {
// 进入账户模块(先使用账户选择页,已对接后端 /api/accounts
uni.navigateTo({ url: '/pages/account/select' })
return
}
if (item.key === 'supplier') {
uni.navigateTo({ url: '/pages/supplier/select' })
return
@@ -174,6 +179,14 @@
try { console.log('[index] goDetail → /pages/detail/index') } catch(e){}
uni.navigateTo({ url: '/pages/detail/index' })
},
goReport() {
this.activeTab = 'report'
uni.navigateTo({ url: '/pages/report/entry' })
},
goMe() {
this.activeTab = 'me'
uni.navigateTo({ url: '/pages/my/index' })
},
onNoticeTap(n) {
uni.showModal({
title: '广告',

View File

@@ -0,0 +1,59 @@
<template>
<view class="about">
<view class="hero">
<image class="logo" src="/static/logo.png" mode="aspectFit" />
<text class="title">五金配件管家</text>
<text class="subtitle">专注小微门店的极简进销存</text>
</view>
<view class="card">
<view class="row">
<text class="label">版本</text>
<text class="value">1.0.0</text>
</view>
<view class="row">
<text class="label">隐私协议</text>
<text class="link" @click="openPolicy">查看</text>
</view>
<view class="row">
<text class="label">用户协议</text>
<text class="link" @click="openTerms">查看</text>
</view>
<view class="row">
<text class="label">个人信息安全投诉</text>
<text class="link" @click="openComplaint">提交</text>
</view>
</view>
</view>
</template>
<script>
export default {
methods: {
openPolicy() {
uni.showModal({ title: '隐私协议', content: '隐私协议(静态占位)', showCancel: false })
},
openTerms() {
uni.showModal({ title: '用户协议', content: '用户协议(静态占位)', showCancel: false })
},
openComplaint() {
uni.showToast({ title: '暂未开通', icon: 'none' })
}
}
}
</script>
<style>
.about { padding: 24rpx; }
.hero { padding: 32rpx 24rpx; display: flex; flex-direction: column; align-items: center; gap: 10rpx; }
.logo { width: 160rpx; height: 160rpx; border-radius: 32rpx; }
.title { margin-top: 8rpx; font-size: 36rpx; font-weight: 800; color: #333; }
.subtitle { font-size: 26rpx; color: #888; }
.card { margin-top: 18rpx; background: #fff; border-radius: 16rpx; overflow: hidden; }
.row { display: flex; align-items: center; padding: 24rpx; border-top: 1rpx solid #f2f2f2; }
.label { color: #666; }
.value { margin-left: auto; color: #333; }
.link { margin-left: auto; color: #1aad19; }
</style>

152
frontend/pages/my/index.vue Normal file
View File

@@ -0,0 +1,152 @@
<template>
<view class="me">
<view class="card user">
<image class="avatar" :src="avatarUrl" mode="aspectFill" @error="onAvatarError" />
<view class="meta">
<text class="name">{{ shopName }}</text>
<text class="phone">{{ mobileDisplay }}</text>
<text class="role">老板</text>
</view>
</view>
<view class="group">
<view class="group-title">会员与订单</view>
<view class="cell" @click="goVip">
<text>VIP会员</text>
<text class="arrow"></text>
</view>
<view class="cell" @click="goMyOrders">
<text>我的订单</text>
<text class="arrow"></text>
</view>
</view>
<view class="group">
<view class="group-title">基础管理</view>
<view class="cell" @click="goSupplier">
<text>供应商管理</text>
<text class="arrow"></text>
</view>
<view class="cell" @click="goCustomer">
<text>客户管理</text>
<text class="arrow"></text>
</view>
<view class="cell" @click="goCustomerQuote">
<text>客户报价</text>
<text class="arrow"></text>
</view>
<view class="cell" @click="goShop">
<text>店铺管理</text>
<text class="arrow"></text>
</view>
</view>
<view class="group">
<view class="group-title">设置中心</view>
<view class="cell" @click="editProfile">
<text>账号与安全</text>
<text class="desc">修改头像姓名密码</text>
<text class="arrow"></text>
</view>
<view class="cell" @click="goProductSettings">
<text>商品设置</text>
<text class="arrow"></text>
</view>
<view class="cell" @click="goSystemParams">
<text>系统参数</text>
<text class="desc">低价提示默认收款单行折扣等</text>
<text class="arrow"></text>
</view>
<view class="cell" @click="goAbout">
<text>关于与协议</text>
<text class="arrow"></text>
</view>
<view class="cell danger" @click="logout">
<text>退出登录</text>
</view>
</view>
</view>
</template>
<script>
import { get } from '../../common/http.js'
export default {
data() {
return {
avatarUrl: '/static/logo.png',
shopName: '我的店铺',
mobile: ''
}
},
onLoad() {
this.fetchProfile()
},
computed: {
mobileDisplay() {
const m = String(this.mobile || '')
return m.length === 11 ? m.slice(0,3) + '****' + m.slice(7) : (m || '未绑定手机号')
}
},
methods: {
async fetchProfile() {
// 后端暂无专门店铺/用户信息接口,先使用概览接口作为在线性检测与占位数据来源
try { await get('/api/dashboard/overview') } catch(e) {}
// 读取本地可能保存的店铺名与头像
try {
const storeName = uni.getStorageSync('SHOP_NAME') || ''
const avatar = uni.getStorageSync('USER_AVATAR') || ''
const phone = uni.getStorageSync('USER_MOBILE') || ''
if (storeName) this.shopName = storeName
if (avatar) this.avatarUrl = avatar
this.mobile = phone
} catch(e) {}
},
onAvatarError() {
this.avatarUrl = '/static/logo.png'
},
goVip() { uni.showToast({ title: 'VIP会员开发中', icon: 'none' }) },
goMyOrders() { uni.navigateTo({ url: '/pages/detail/index' }) },
goSupplier() { uni.navigateTo({ url: '/pages/supplier/select' }) },
goCustomer() { uni.navigateTo({ url: '/pages/customer/select' }) },
goCustomerQuote() { uni.showToast({ title: '客户报价(开发中)', icon: 'none' }) },
goShop() { uni.showToast({ title: '店铺管理(开发中)', icon: 'none' }) },
editProfile() { uni.showToast({ title: '账号与安全(开发中)', icon: 'none' }) },
goProductSettings() { uni.navigateTo({ url: '/pages/product/settings' }) },
goSystemParams() { uni.showToast({ title: '系统参数(开发中)', icon: 'none' }) },
goAbout() { uni.navigateTo({ url: '/pages/my/about' }) },
logout() {
try {
uni.removeStorageSync('TOKEN')
uni.removeStorageSync('USER_AVATAR')
uni.removeStorageSync('USER_NAME')
uni.removeStorageSync('USER_MOBILE')
uni.removeStorageSync('SHOP_NAME')
uni.showToast({ title: '已退出', icon: 'none' })
setTimeout(() => { uni.reLaunch({ url: '/pages/index/index' }) }, 300)
} catch(e) {
uni.reLaunch({ url: '/pages/index/index' })
}
}
}
}
</script>
<style>
.me { padding: 24rpx; }
.card.user { display: flex; gap: 18rpx; padding: 22rpx; background: #fff; border-radius: 16rpx; box-shadow: 0 6rpx 16rpx rgba(0,0,0,0.06); align-items: center; }
.avatar { width: 120rpx; height: 120rpx; border-radius: 60rpx; background: #f5f5f5; }
.meta { display: flex; flex-direction: column; gap: 6rpx; }
.name { font-size: 34rpx; font-weight: 700; color: #333; }
.phone { font-size: 26rpx; color: #888; }
.role { font-size: 22rpx; color: #999; }
.group { margin-top: 24rpx; background: #fff; border-radius: 16rpx; overflow: hidden; }
.group-title { padding: 18rpx 22rpx; font-size: 26rpx; color: #999; background: #fafafa; }
.cell { display: flex; align-items: center; padding: 26rpx 22rpx; border-top: 1rpx solid #f0f0f0; color: #333; }
.cell .desc { margin-left: auto; margin-right: 8rpx; font-size: 22rpx; color: #999; }
.cell .arrow { margin-left: auto; color: #bbb; }
.cell.danger { color: #dd524d; justify-content: center; font-weight: 700; }
</style>

View File

@@ -89,6 +89,11 @@
<!-- 其它收入/支出 表单 -->
<view v-else>
<!-- 往来单位类型切换 -->
<view class="subtabs">
<button class="subbtn" :class="{ active: counterpartyType==='customer' }" @click="setCounterparty('customer')">客户</button>
<button class="subbtn" :class="{ active: counterpartyType==='supplier' }" @click="setCounterparty('supplier')">供应商</button>
</view>
<view class="chips">
<view v-for="c in (biz==='income' ? incomeCategories : expenseCategories)" :key="c.key" class="chip" :class="{ active: activeCategory===c.key }" @click="activeCategory=c.key">{{ c.label }}</view>
</view>
@@ -139,8 +144,8 @@
</template>
<script>
import { get, post } from '../../common/http.js'
import { INCOME_CATEGORIES, EXPENSE_CATEGORIES } from '../../common/constants.js'
import { get, post } from '../../common/http.js'
import { INCOME_CATEGORIES, EXPENSE_CATEGORIES } from '../../common/constants.js'
function todayString() {
const d = new Date()
@@ -151,7 +156,7 @@
export default {
data() {
return {
return {
biz: 'sale',
saleType: 'out',
purchaseType: 'in',
@@ -168,6 +173,7 @@
supplierName: '',
items: [],
activeCategory: 'sale_income',
counterpartyType: 'customer',
trxAmount: 0,
selectedAccountId: null,
selectedAccountName: '',
@@ -185,16 +191,19 @@
},
customerLabel() { return this.customerName || '零售客户' },
supplierLabel() { return this.supplierName || '零散供应商' },
incomeCategories() { return INCOME_CATEGORIES },
expenseCategories() { return EXPENSE_CATEGORIES },
incomeCategories() { return this._incomeCategories || INCOME_CATEGORIES },
expenseCategories() { return this._expenseCategories || EXPENSE_CATEGORIES },
accountLabel() { return this.selectedAccountName || '现金' },
counterpartyLabel() { return this.customerName || this.supplierName || '—' },
counterpartyLabel() { return this.counterpartyType==='customer' ? (this.customerName || '—') : (this.supplierName || '—') },
// 收款/付款合计
payTotal() {
const p = this.payments || { cash:0, bank:0, wechat:0 }
return Number(p.cash||0) + Number(p.bank||0) + Number(p.wechat||0)
}
},
onLoad() {
this.fetchCategories()
},
onShow() {
if (this.biz === 'sale') {
if (this.order.customerId && this.order.customerId !== this._lastCustomerId) {
@@ -207,6 +216,20 @@
}
},
methods: {
async fetchCategories() {
try {
const res = await get('/api/finance/categories')
if (res && Array.isArray(res.incomeCategories)) this._incomeCategories = res.incomeCategories
if (res && Array.isArray(res.expenseCategories)) this._expenseCategories = res.expenseCategories
this.ensureActiveCategory()
} catch (_) { this.ensureActiveCategory() }
},
ensureActiveCategory() {
const list = this.biz==='income' ? (this.incomeCategories||[]) : (this.expenseCategories||[])
if (!list.length) return
const exists = list.some(it => it && it.key === this.activeCategory)
if (!exists) this.activeCategory = list[0].key
},
async loadCustomerLevel(customerId) {
try {
const d = await get(`/api/customers/${customerId}`)
@@ -235,7 +258,7 @@
this.recalc()
},
onPriceInput(it) { if (it) { it._autoPrice = false; this.recalc() } },
switchBiz(type) { this.biz = type },
switchBiz(type) { this.biz = type; this.ensureActiveCategory() },
onDateChange(e) { this.order.orderTime = e.detail.value },
chooseCustomer() {
uni.navigateTo({ url: '/pages/customer/select' })
@@ -244,12 +267,13 @@
chooseProduct() {
uni.navigateTo({ url: '/pages/product/select' })
},
chooseAccount() { uni.navigateTo({ url: '/pages/account/select' }) },
chooseAccount() { uni.navigateTo({ url: '/pages/account/select?mode=pick' }) },
chooseCounterparty() {
if (this.biz==='income' || this.biz==='expense') {
uni.navigateTo({ url: '/pages/customer/select' })
}
if (!(this.biz==='income' || this.biz==='expense')) return
if (this.counterpartyType==='customer') { uni.navigateTo({ url: '/pages/customer/select' }) }
else { uni.navigateTo({ url: '/pages/supplier/select' }) }
},
setCounterparty(t) { this.counterpartyType = t; this.ensureActiveCategory() },
recalc() { this.$forceUpdate() },
recalcPay() { this.$forceUpdate() },
async submit() {
@@ -262,7 +286,7 @@
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 ? [
const payload = isSaleOrPurchase ? (isCollectOrPay ? [
{ method: 'cash', amount: Number(this.payments.cash||0) },
{ method: 'bank', amount: Number(this.payments.bank||0) },
{ method: 'wechat', amount: Number(this.payments.wechat||0) }
@@ -276,7 +300,8 @@
}) : {
type: this.biz,
category: this.activeCategory,
counterpartyId: this.order.customerId || null,
counterpartyType: this.counterpartyType,
counterpartyId: this.counterpartyType==='customer' ? (this.order.customerId || null) : (this.order.supplierId || null),
accountId: this.selectedAccountId || null,
amount: Number(this.trxAmount||0),
txTime: this.order.orderTime,
@@ -328,6 +353,10 @@
.textarea { position: relative; padding: 16rpx 24rpx; background:#fff; border-top: 1rpx solid #eee; }
.amount-badge { position: absolute; right: 24rpx; top: -36rpx; background: #d1f0ff; color:#107e9b; padding: 8rpx 16rpx; border-radius: 12rpx; font-size: 24rpx; }
.date-mini { position: absolute; right: 24rpx; bottom: 20rpx; color:#666; font-size: 24rpx; }
/* 分类chips样式选中后文字变红 */
.chips { display:flex; flex-wrap: wrap; gap: 12rpx; padding: 12rpx 24rpx; }
.chip { padding: 10rpx 20rpx; border-radius: 999rpx; background: #f4f4f4; color:#666; }
.chip.active { color: #e54d42; }
</style>

View File

@@ -0,0 +1,44 @@
<template>
<view class="entry">
<view class="section">
<view class="section-title">资金报表</view>
<view class="grid">
<view class="btn" @click="go('sale','customer')">利润统计</view>
<view class="btn" @click="go('sale','product')">营业员统计</view>
<view class="btn" @click="go('sale','customer')">经营业绩</view>
</view>
</view>
<view class="section">
<view class="section-title">进销存报表</view>
<view class="grid">
<view class="btn" @click="go('sale','customer')">销售统计</view>
<view class="btn" @click="go('purchase','supplier')">进货统计</view>
<view class="btn" @click="go('inventory','qty')">库存统计</view>
<view class="btn" @click="go('arap','ar')">应收对账单</view>
<view class="btn" @click="go('arap','ap')">应付对账单</view>
</view>
</view>
</view>
</template>
<script>
export default {
methods: {
go(mode, dim) {
const q = `mode=${encodeURIComponent(mode)}&dim=${encodeURIComponent(dim||'')}`
uni.navigateTo({ url: `/pages/report/index?${q}` })
}
}
}
</script>
<style>
.entry { padding: 20rpx; }
.section { margin-bottom: 24rpx; }
.section-title { background:#f1f4f8; color:#6a7a8a; padding: 14rpx 16rpx; border-radius: 12rpx; font-weight: 700; }
.grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18rpx; padding: 18rpx 6rpx 0; }
.btn { text-align: center; padding: 18rpx 8rpx; border: 1rpx solid #e5e9ef; border-radius: 12rpx; color:#333; background: #fff; }
.btn:active { background: #f6f8fa; }
</style>

View File

@@ -0,0 +1,291 @@
<template>
<view class="report">
<view class="modes">
<view class="mode-tab" :class="{active: mode==='sale'}" @click="setMode('sale')">销售统计</view>
<view class="mode-tab" :class="{active: mode==='purchase'}" @click="setMode('purchase')">进货统计</view>
<view class="mode-tab" :class="{active: mode==='inventory'}" @click="setMode('inventory')">库存统计</view>
<view class="mode-tab" :class="{active: mode==='arap'}" @click="setMode('arap')">应收/应付对账</view>
</view>
<view class="toolbar">
<picker mode="date" :value="startDate" @change="onStartChange"><view class="date">{{ startDate }}</view></picker>
<text style="margin: 0 8rpx;"></text>
<picker mode="date" :value="endDate" @change="onEndChange"><view class="date">{{ endDate }}</view></picker>
</view>
<view class="tabs" v-if="mode==='sale'">
<view class="tab" :class="{active: dim==='customer'}" @click="dim='customer'; refresh()">按客户</view>
<view class="tab" :class="{active: dim==='product'}" @click="dim='product'; refresh()">按货品</view>
</view>
<view class="tabs" v-else-if="mode==='purchase'">
<view class="tab" :class="{active: dim==='supplier'}" @click="dim='supplier'; refresh()">按供应商</view>
<view class="tab" :class="{active: dim==='product'}" @click="dim='product'; refresh()">按货品</view>
</view>
<view class="tabs" v-else-if="mode==='inventory'">
<view class="tab" :class="{active: dim==='qty'}" @click="dim='qty'; refresh()">按数量</view>
<view class="tab" :class="{active: dim==='amount'}" @click="dim='amount'; refresh()">按金额</view>
</view>
<view class="tabs" v-else-if="mode==='arap'">
<view class="tab" :class="{active: dim==='ar'}" @click="dim='ar'; refresh()">应收对账</view>
<view class="tab" :class="{active: dim==='ap'}" @click="dim='ap'; refresh()">应付对账</view>
</view>
<view class="summary">
<view class="item"><text class="label">销售额</text><text class="value"> {{ fmt(total.sales) }}</text></view>
<view class="item"><text class="label">成本</text><text class="value"> {{ fmt(total.cost) }}</text></view>
<view class="item"><text class="label">利润</text><text class="value"> {{ fmt(total.profit) }}</text></view>
<view class="item"><text class="label">利润率</text><text class="value">{{ profitRate }}</text></view>
</view>
<view v-for="(row, idx) in rows" :key="idx" class="card">
<view class="row-head">
<image v-if="row.avatar" class="thumb" :src="row.avatar" />
<view class="title">{{ row.name }}</view>
</view>
<view class="row-body">
<text>销售额 {{ fmt(row.sales) }}</text>
<text style="margin-left: 18rpx;">成本 {{ fmt(row.cost) }}</text>
<text style="margin-left: 18rpx;">利润 {{ fmt(row.profit) }}</text>
</view>
</view>
</view>
</template>
<script>
import { get } from '../../common/http.js'
function formatDate(d) {
const y = d.getFullYear()
const m = String(d.getMonth()+1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
export default {
data() {
const now = new Date()
const start = new Date(now.getFullYear(), now.getMonth(), 1)
return {
startDate: formatDate(start),
endDate: formatDate(now),
mode: 'sale',
dim: 'customer',
rows: [],
total: { sales: 0, cost: 0, profit: 0 }
}
},
onLoad(query) {
try {
const m = query && query.mode
const d = query && query.dim
if (m) this.mode = m
if (d) this.dim = d
} catch(e){}
this.refresh()
},
computed: {
profitRate() {
const { sales, profit } = this.total
if (!sales) return '0.00%'
return ((profit / sales) * 100).toFixed(2) + '%'
}
},
methods: {
fmt(n) { return Number(n || 0).toFixed(2) },
setMode(m) {
this.mode = m
this.dim = m === 'sale' ? 'customer' : m === 'purchase' ? 'supplier' : m === 'inventory' ? 'qty' : 'ar'
this.refresh()
},
onStartChange(e) { this.startDate = e.detail.value; this.refresh() },
onEndChange(e) { this.endDate = e.detail.value; this.refresh() },
async refresh() {
if (this.mode === 'sale') {
if (this.dim === 'customer') return this.loadByCustomer()
if (this.dim === 'product') return this.loadByProduct()
}
if (this.mode === 'purchase') {
if (this.dim === 'supplier') return this.loadPurchaseBySupplier()
if (this.dim === 'product') return this.loadPurchaseByProduct()
}
if (this.mode === 'inventory') {
if (this.dim === 'qty') return this.loadInventoryByQty()
if (this.dim === 'amount') return this.loadInventoryByAmount()
}
if (this.mode === 'arap') {
if (this.dim === 'ar') return this.loadAR()
if (this.dim === 'ap') return this.loadAP()
}
},
async loadByCustomer() {
// 数据来源:/api/orders?biz=sale&type=out 与 /api/products/{id} 获取成本(近似),或由订单明细返回单价与估算成本
// 当前后端列表返回字段包含 amount、customerName缺少明细成本采用二段法
// 1) 列表聚合销售额2) 如存在 productId 与单位进价可获取成本;暂以 0 成本占位,保留接口演进点。
try {
const listResp = await get('/api/orders', { biz: 'sale', type: 'out', startDate: this.startDate, endDate: this.endDate, page: 1, size: 200 })
const list = (listResp && (listResp.list || listResp)) || []
const map = new Map()
let totalSales = 0
for (const it of list) {
const name = it.customerName || '未知客户'
const amount = Number(it.amount || 0)
totalSales += amount
if (!map.has(name)) map.set(name, { name, sales: 0, cost: 0, profit: 0 })
const row = map.get(name)
row.sales += amount
}
const rows = Array.from(map.values()).map(r => ({ ...r, profit: r.sales - r.cost }))
const total = { sales: totalSales, cost: 0, profit: totalSales }
this.rows = rows
this.total = total
} catch (e) {
uni.showToast({ title: '加载失败', icon: 'none' })
}
},
async loadByProduct() {
try {
const listResp = await get('/api/orders', { biz: 'sale', type: 'out', startDate: this.startDate, endDate: this.endDate, page: 1, size: 200 })
const list = (listResp && (listResp.list || listResp)) || []
// 订单详情接口包含明细,逐单补拉详情聚合(规模较小时可接受;后端如提供汇总接口可替换)
const agg = new Map()
for (const it of list) {
try {
const d = await get(`/api/orders/${it.id}`)
const items = d && d.items || []
for (const m of items) {
const key = String(m.productId || m.name)
if (!agg.has(key)) agg.set(key, { name: m.name || ('#'+key), sales: 0, cost: 0, profit: 0 })
const row = agg.get(key)
const sales = Number(m.amount || 0)
// 近似成本:缺后端返回进价,暂以 0待后端扩展返回 purchasePrice
row.sales += sales
}
} catch(_) {}
}
const rows = Array.from(agg.values()).map(r => ({ ...r, profit: r.sales - r.cost }))
const totalSales = rows.reduce((s, r) => s + r.sales, 0)
this.rows = rows
this.total = { sales: totalSales, cost: 0, profit: totalSales }
} catch (e) {
uni.showToast({ title: '加载失败', icon: 'none' })
}
},
async loadPurchaseBySupplier() {
try {
const listResp = await get('/api/purchase-orders', { startDate: this.startDate, endDate: this.endDate, page: 1, size: 200 })
const list = (listResp && (listResp.list || listResp)) || []
const map = new Map(); let total = 0
for (const it of list) {
const name = it.supplierName || '未知供应商'
const amount = Number(it.amount || 0)
total += amount
if (!map.has(name)) map.set(name, { name, sales: 0, cost: 0, profit: 0 })
const row = map.get(name)
// 在进货统计语境里sales 用来展示“进货额”cost/profit 保持 0
row.sales += amount
}
this.rows = Array.from(map.values())
this.total = { sales: total, cost: 0, profit: 0 }
} catch(e) { uni.showToast({ title: '加载失败', icon: 'none' }) }
},
async loadPurchaseByProduct() {
try {
const listResp = await get('/api/purchase-orders', { startDate: this.startDate, endDate: this.endDate, page: 1, size: 200 })
const list = (listResp && (listResp.list || listResp)) || []
const agg = new Map()
for (const it of list) {
try {
const d = await get(`/api/purchase-orders/${it.id}`)
for (const m of (d?.items || [])) {
const key = String(m.productId || m.name)
if (!agg.has(key)) agg.set(key, { name: m.name || ('#'+key), sales: 0, cost: 0, profit: 0 })
const row = agg.get(key)
row.sales += Number(m.amount || 0) // 这里的 sales 表示“进货额”
}
} catch(_){}
}
const rows = Array.from(agg.values())
const total = rows.reduce((s, r)=> s + r.sales, 0)
this.rows = rows; this.total = { sales: total, cost: 0, profit: 0 }
} catch(e) { uni.showToast({ title: '加载失败', icon: 'none' }) }
},
async loadInventoryByQty() {
try {
const resp = await get('/api/inventories/logs', { startDate: this.startDate, endDate: this.endDate, page: 1, size: 200 })
const list = (resp && (resp.list || resp)) || []
const map = new Map(); let totalQty = 0
for (const it of list) {
const key = it.productId || '未知'
if (!map.has(key)) map.set(key, { name: String(key), sales: 0, cost: 0, profit: 0 })
const row = map.get(key)
const q = Number(it.qtyDelta || 0)
row.sales += q
totalQty += q
}
this.rows = Array.from(map.values())
this.total = { sales: totalQty, cost: 0, profit: 0 }
} catch(e) { uni.showToast({ title: '加载失败', icon: 'none' }) }
},
async loadInventoryByAmount() {
try {
const resp = await get('/api/inventories/logs', { startDate: this.startDate, endDate: this.endDate, page: 1, size: 200 })
const list = (resp && (resp.list || resp)) || []
const map = new Map(); let totalAmt = 0
for (const it of list) {
const key = it.productId || '未知'
if (!map.has(key)) map.set(key, { name: String(key), sales: 0, cost: 0, profit: 0 })
const row = map.get(key)
const a = Number(it.amount || it.amountDelta || 0)
row.sales += a
totalAmt += a
}
this.rows = Array.from(map.values())
this.total = { sales: totalAmt, cost: 0, profit: 0 }
} catch(e) { uni.showToast({ title: '加载失败', icon: 'none' }) }
},
async loadAR() {
// 读取客户列表含 receivable 字段,作为对账口径(期末=期初+增加-收回-抹零);后端如提供期间变动接口再替换
try {
const res = await get('/api/customers', { page: 1, size: 100, debtOnly: false })
const list = Array.isArray(res?.list) ? res.list : (Array.isArray(res) ? res : [])
const rows = list.map(c => ({ name: c.name, sales: Number(c.receivable || 0), cost: 0, profit: 0 }))
const total = rows.reduce((s, r)=> s + r.sales, 0)
this.rows = rows; this.total = { sales: total, cost: 0, profit: 0 }
} catch(e) { uni.showToast({ title: '加载失败', icon: 'none' }) }
},
async loadAP() {
// 供应商暂未返回应付字段先展示总览为0并提示后端扩展遵循“不开假数据”
try {
const res = await get('/api/suppliers', { page: 1, size: 100 })
const list = Array.isArray(res?.list) ? res.list : (Array.isArray(res) ? res : [])
const rows = list.map(s => ({ name: s.name, sales: Number(s.apPayable || 0), cost: 0, profit: 0 }))
const total = rows.reduce((s, r)=> s + r.sales, 0)
this.rows = rows; this.total = { sales: total, cost: 0, profit: 0 }
} catch(e) { uni.showToast({ title: '加载失败', icon: 'none' }) }
}
}
}
</script>
<style>
.report { padding: 20rpx; }
.modes { display: flex; gap: 12rpx; margin-bottom: 14rpx; }
.mode-tab { flex: 1; text-align: center; padding: 16rpx 0; border-radius: 999rpx; background: #f4f4f4; color: #666; border: 1rpx solid #e9e9e9; }
.mode-tab.active { background: #1aad19; color: #fff; border-color: #1aad19; font-weight: 700; }
.toolbar { display: flex; align-items: center; gap: 8rpx; background: #fff; padding: 14rpx 16rpx; border-radius: 12rpx; }
.date { padding: 10rpx 16rpx; border: 1rpx solid #eee; border-radius: 8rpx; }
.tabs { display: flex; gap: 16rpx; margin-top: 14rpx; }
.tab { padding: 12rpx 18rpx; border-radius: 999rpx; background: #f4f4f4; color: #666; }
.tab.active { background: #1aad19; color: #fff; }
.summary { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8rpx; margin-top: 14rpx; }
.summary .item { background: #fff; border-radius: 12rpx; padding: 16rpx; }
.summary .label { font-size: 22rpx; color: #888; }
.summary .value { display: block; margin-top: 8rpx; font-weight: 700; color: #333; }
.card { margin-top: 16rpx; background: #fff; border-radius: 12rpx; padding: 16rpx; }
.row-head { display: flex; align-items: center; gap: 12rpx; }
.thumb { width: 72rpx; height: 72rpx; border-radius: 8rpx; background: #f2f2f2; }
.title { font-size: 28rpx; font-weight: 700; }
.row-body { margin-top: 10rpx; color: #666; }
</style>