172 lines
6.6 KiB
Vue
172 lines
6.6 KiB
Vue
<template>
|
|
<view class="report">
|
|
<view class="header">销售报表</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">
|
|
<view class="tab" :class="{active: dim==='customer'}" @click="setDimension('customer')">按客户</view>
|
|
<view class="tab" :class="{active: dim==='product'}" @click="setDimension('product')">按货品</view>
|
|
</view>
|
|
|
|
<view class="summary" v-if="summaryItems.length">
|
|
<view class="summary-item" v-for="(item, ix) in summaryItems" :key="ix">
|
|
<text class="label">{{ item.label }}</text>
|
|
<text class="value">{{ item.value }}</text>
|
|
</view>
|
|
</view>
|
|
|
|
<view v-if="loading" class="loading">加载中...</view>
|
|
<view v-else-if="error" class="empty">{{ error }}</view>
|
|
<view v-else-if="!rows.length" class="empty">暂无统计数据</view>
|
|
<view v-else>
|
|
<view v-for="(row, idx) in rows" :key="idx" class="card">
|
|
<view class="row-head">
|
|
<view class="row-title">
|
|
<view class="title">{{ row.name }}</view>
|
|
<view class="subtitle" v-if="showProductSpec(row)">{{ row.spec }}</view>
|
|
</view>
|
|
</view>
|
|
<view class="row-body">
|
|
<view class="metric" v-for="(metric, mIdx) in rowMetrics(row)" :key="mIdx">
|
|
<text class="metric-label">{{ metric.label }}</text>
|
|
<text class="metric-value">{{ metric.value }}</text>
|
|
</view>
|
|
</view>
|
|
</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),
|
|
dim: 'customer',
|
|
rows: [],
|
|
summary: { salesAmount: 0, costAmount: 0, profit: 0, profitRate: 0, itemCount: 0 },
|
|
loading: false,
|
|
error: ''
|
|
}
|
|
},
|
|
onLoad(query) {
|
|
try {
|
|
const d = query && query.dim
|
|
if (d === 'product' || d === 'customer') this.dim = d
|
|
} catch(e){}
|
|
this.refresh()
|
|
},
|
|
computed: {
|
|
profitRateText() {
|
|
const rate = Number(this.summary?.profitRate || 0)
|
|
return rate.toFixed(2) + '%'
|
|
},
|
|
summaryItems() {
|
|
if (!this.rows.length) return []
|
|
return [
|
|
{ label: '销售额', value: `¥ ${this.fmt(this.summary.salesAmount)}` },
|
|
{ label: '成本', value: `¥ ${this.fmt(this.summary.costAmount)}` },
|
|
{ label: '利润', value: `¥ ${this.fmt(this.summary.profit)}` },
|
|
{ label: '利润率', value: this.profitRateText }
|
|
]
|
|
}
|
|
},
|
|
methods: {
|
|
fmt(n) { return Number(n || 0).toFixed(2) },
|
|
showProductSpec(row) { return this.dim === 'product' && row && row.spec },
|
|
rowMetrics(row) {
|
|
if (!row) return []
|
|
return [
|
|
{ label: '销售额', value: `¥ ${this.fmt(row.salesAmount)}` },
|
|
{ label: '成本', value: `¥ ${this.fmt(row.costAmount)}` },
|
|
{ label: '利润', value: `¥ ${this.fmt(row.profit)}` },
|
|
{ label: '利润率', value: `${Number(row.profitRate || 0).toFixed(2)}%` }
|
|
]
|
|
},
|
|
setDimension(d) {
|
|
if (d !== 'customer' && d !== 'product') return
|
|
if (this.dim === d) return
|
|
this.dim = d
|
|
this.refresh()
|
|
},
|
|
onStartChange(e) { this.startDate = e.detail.value; this.refresh() },
|
|
onEndChange(e) { this.endDate = e.detail.value; this.refresh() },
|
|
async refresh() {
|
|
this.loading = true
|
|
this.error = ''
|
|
try {
|
|
const resp = await get('/api/report/sales', {
|
|
dimension: this.dim,
|
|
startDate: this.startDate,
|
|
endDate: this.endDate
|
|
})
|
|
const items = Array.isArray(resp?.items) ? resp.items : []
|
|
this.rows = items.map(it => ({
|
|
name: it?.name || (this.dim === 'product' ? '未命名商品' : '未指定客户'),
|
|
spec: it?.spec || '',
|
|
salesAmount: Number(it?.salesAmount || 0),
|
|
costAmount: Number(it?.costAmount || 0),
|
|
profit: Number(it?.profit || 0),
|
|
profitRate: Number(it?.profitRate || 0)
|
|
}))
|
|
this.summary = {
|
|
salesAmount: Number(resp?.summary?.salesAmount || 0),
|
|
costAmount: Number(resp?.summary?.costAmount || 0),
|
|
profit: Number(resp?.summary?.profit || 0),
|
|
profitRate: Number(resp?.summary?.profitRate || 0),
|
|
itemCount: Number(resp?.summary?.itemCount || this.rows.length)
|
|
}
|
|
} catch (e) {
|
|
this.error = (e && e.message) || '报表加载失败'
|
|
} finally {
|
|
this.loading = false
|
|
}
|
|
}
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<style lang="scss">
|
|
.report { padding: 24rpx 20rpx 36rpx; display:flex; flex-direction:column; gap: 18rpx; }
|
|
.header { font-size: 34rpx; font-weight: 700; color:#1f2a44; padding-left: 8rpx; }
|
|
.toolbar { display:flex; align-items:center; justify-content:center; gap: 12rpx; background: #f7f9fc; border-radius: 16rpx; padding: 18rpx; }
|
|
.date { min-width: 200rpx; padding: 12rpx 18rpx; border-radius: 12rpx; background: #fff; border: 1rpx solid rgba(91,107,139,0.16); text-align:center; color:#32445b; }
|
|
.tabs { display:flex; gap: 12rpx; justify-content:center; }
|
|
.tab { padding: 10rpx 20rpx; border-radius: 999rpx; background: #f0f4ff; color:#5b6b8b; transition: all .2s ease; }
|
|
.tab.active { background: rgba(76,141,255,0.18); color:#3467d6; box-shadow: inset 0 0 0 2rpx rgba(76,141,255,0.45); }
|
|
.summary { display:grid; grid-template-columns: repeat(auto-fill, minmax(240rpx,1fr)); gap: 12rpx; }
|
|
.summary-item { background: #f7f9fc; border-radius: 16rpx; padding: 20rpx; display:flex; flex-direction:column; gap: 10rpx; }
|
|
.summary-item .label { font-size: 24rpx; color:#6e7a96; }
|
|
.summary-item .value { font-size: 32rpx; font-weight:700; color:#1f2a44; }
|
|
.card { background:#fff; border-radius: 18rpx; padding: 20rpx; box-shadow: 0 8rpx 20rpx rgba(31,42,68,0.08); display:flex; flex-direction:column; gap: 14rpx; }
|
|
.row-head { display:flex; justify-content:space-between; align-items:flex-start; }
|
|
.row-title { display:flex; flex-direction:column; gap: 6rpx; }
|
|
.title { font-size: 30rpx; font-weight:700; color:#1f2a44; }
|
|
.subtitle { font-size: 24rpx; color:#6e7a96; }
|
|
.row-body { display:flex; flex-wrap:wrap; gap: 12rpx 24rpx; }
|
|
.metric { display:flex; gap: 8rpx; align-items:center; background:#f4f6fb; border-radius: 12rpx; padding: 10rpx 16rpx; }
|
|
.metric-label { font-size: 24rpx; color:#6e7a96; }
|
|
.metric-value { font-size: 28rpx; color:#1f2a44; font-weight:600; }
|
|
.empty { text-align:center; padding: 80rpx 0; color:#9aa4be; font-size: 26rpx; }
|
|
.loading { text-align:center; padding: 40rpx 0; color:#5b6b8b; font-size: 24rpx; }
|
|
</style>
|
|
|
|
|