497 lines
11 KiB
Vue
497 lines
11 KiB
Vue
<template>
|
||
<view class="page">
|
||
<view v-if="loading && !dash" class="state-box">
|
||
<text class="state-text">加载中...</text>
|
||
</view>
|
||
|
||
<block v-else-if="dash">
|
||
<!-- 本月经营 -->
|
||
<view class="hero-card">
|
||
<view class="hero-main">
|
||
<text class="hero-label">本月销售金额</text>
|
||
<text class="hero-amount">¥{{ moneyText(month.sales_amount) }}</text>
|
||
<text class="hero-sub">
|
||
销售 {{ month.sales_count || 0 }} 单 · 采购 {{ month.purchase_count || 0 }} 单 · 退货 ¥{{ moneyText(month.return_amount) }}
|
||
</text>
|
||
</view>
|
||
<view class="hero-side">
|
||
<view class="hero-line">
|
||
<text class="hero-line-label">本月毛利</text>
|
||
<text class="hero-line-value">{{ moneyText(month.profit) }}</text>
|
||
</view>
|
||
<view class="hero-line">
|
||
<text class="hero-line-label">毛利率</text>
|
||
<text class="hero-line-value">{{ moneyText(month.profit_rate) }}%</text>
|
||
</view>
|
||
<view class="hero-line">
|
||
<text class="hero-line-label">采购金额</text>
|
||
<text class="hero-line-value">{{ moneyText(month.purchase_amount) }}</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 指标格子 -->
|
||
<view class="kpi-grid">
|
||
<view class="kpi-cell" @tap="go('/pages/erp/document/index?doc_type=sales_out')">
|
||
<text class="kpi-value">{{ moneyText(today.sales_amount) }}</text>
|
||
<text class="kpi-label">今日销售({{ today.sales_count || 0 }} 单)</text>
|
||
</view>
|
||
<view class="kpi-cell" @tap="go('/pages/erp/stock/index')">
|
||
<text class="kpi-value">{{ moneyText(stock.value) }}</text>
|
||
<text class="kpi-label">库存货值({{ qtyText(stock.qty) }} 件)</text>
|
||
</view>
|
||
<view class="kpi-cell" @tap="go('/pages/erp/finance/bill?bill_type=1')">
|
||
<text class="kpi-value danger">{{ moneyText(bill.receivable) }}</text>
|
||
<text class="kpi-label">未收应收</text>
|
||
</view>
|
||
<view class="kpi-cell" @tap="go('/pages/erp/finance/bill?bill_type=2')">
|
||
<text class="kpi-value">{{ moneyText(bill.payable) }}</text>
|
||
<text class="kpi-label">未付应付</text>
|
||
</view>
|
||
<view class="kpi-cell" @tap="go('/pages/erp/document/index?status=2')">
|
||
<text class="kpi-value primary">{{ todo.approval_pending || 0 }}</text>
|
||
<text class="kpi-label">待我审批</text>
|
||
</view>
|
||
<view class="kpi-cell" @tap="go('/pages/erp/document/index?status=0')">
|
||
<text class="kpi-value">{{ todo.draft_docs || 0 }}</text>
|
||
<text class="kpi-label">草稿单据</text>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 库存预警 -->
|
||
<view class="card warn-card">
|
||
<view class="block-head">
|
||
<text class="block-title">库存预警</text>
|
||
<view class="block-more" @tap="go('/pages/erp/stock/warning')">
|
||
<text>查看全部</text>
|
||
<FaIcon name="chevron-right" color="#94a3b8" :size="14" />
|
||
</view>
|
||
</view>
|
||
<view class="warn-row">
|
||
<view class="warn-item" @tap="go('/pages/erp/stock/warning?tab=low')">
|
||
<text class="warn-value warning">{{ warning.low || 0 }}</text>
|
||
<text class="warn-label">低于安全库存</text>
|
||
</view>
|
||
<view class="warn-item" @tap="go('/pages/erp/stock/warning?tab=out')">
|
||
<text class="warn-value danger">{{ warning.out || 0 }}</text>
|
||
<text class="warn-label">缺货</text>
|
||
</view>
|
||
<view class="warn-item" @tap="go('/pages/erp/stock/warning?tab=over')">
|
||
<text class="warn-value primary">{{ warning.over || 0 }}</text>
|
||
<text class="warn-label">库存积压</text>
|
||
</view>
|
||
<view class="warn-item" @tap="go('/pages/erp/stock/batch?expire_state=expiring')">
|
||
<text class="warn-value warning">{{ warning.expiring || 0 }}</text>
|
||
<text class="warn-label">效期临近</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 近 6 个月购销趋势 -->
|
||
<view class="card trend-card">
|
||
<view class="block-head">
|
||
<text class="block-title">近 6 个月购销趋势</text>
|
||
<view class="legend">
|
||
<view class="legend-item">
|
||
<view class="legend-dot buy"></view>
|
||
<text>采购</text>
|
||
</view>
|
||
<view class="legend-item">
|
||
<view class="legend-dot sale"></view>
|
||
<text>销售</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
<view v-for="row in trend" :key="row.month" class="trend-row">
|
||
<text class="trend-month">{{ row.month }}</text>
|
||
<view class="trend-bars">
|
||
<view class="bar-track">
|
||
<view class="bar buy" :style="{ width: barWidth(row.buy) }"></view>
|
||
</view>
|
||
<view class="bar-track">
|
||
<view class="bar sale" :style="{ width: barWidth(row.sale) }"></view>
|
||
</view>
|
||
</view>
|
||
<text class="trend-value">¥{{ moneyText(row.sale) }}</text>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 常用入口 -->
|
||
<view class="card entry-card">
|
||
<view class="block-head">
|
||
<text class="block-title">常用功能</text>
|
||
</view>
|
||
<view class="entry-grid">
|
||
<view v-for="item in entries" :key="item.route" class="entry-item" @tap="go(item.route)">
|
||
<view class="entry-icon">
|
||
<FaIcon :name="item.icon" color="#2563eb" :size="18" />
|
||
</view>
|
||
<text class="entry-label">{{ item.label }}</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</block>
|
||
|
||
<view v-else class="state-box">
|
||
<u-empty mode="network" text="看板加载失败" />
|
||
<view class="retry" @tap="load">重新加载</view>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { computed, ref } from 'vue'
|
||
import { onShow } from '@dcloudio/uni-app'
|
||
import FaIcon from '@/components/FaIcon.vue'
|
||
import { fetchErpDashboard, moneyText, qtyText } from '@/api/erp.js'
|
||
|
||
const dash = ref(null)
|
||
const loading = ref(false)
|
||
|
||
const month = computed(() => (dash.value || {}).month || {})
|
||
const today = computed(() => (dash.value || {}).today || {})
|
||
const stock = computed(() => (dash.value || {}).stock || {})
|
||
const bill = computed(() => (dash.value || {}).partnerBill || {})
|
||
const todo = computed(() => (dash.value || {}).todo || {})
|
||
const warning = computed(() => (dash.value || {}).warning || {})
|
||
const trend = computed(() => (dash.value || {}).trend || [])
|
||
|
||
const maxTrend = computed(() => {
|
||
let max = 0
|
||
trend.value.forEach(row => {
|
||
max = Math.max(max, Number(row.buy || 0), Number(row.sale || 0))
|
||
})
|
||
return max || 1
|
||
})
|
||
|
||
const entries = [
|
||
{ label: '开单', icon: 'plus', route: '/pages/erp/document/edit' },
|
||
{ label: '单据', icon: 'clipboard-list', route: '/pages/erp/document/index' },
|
||
{ label: '商品', icon: 'boxes-stacked', route: '/pages/erp/product/index' },
|
||
{ label: '库存', icon: 'warehouse', route: '/pages/erp/stock/index' },
|
||
{ label: '客商', icon: 'users', route: '/pages/erp/partner/index' },
|
||
{ label: '收付款', icon: 'wallet', route: '/pages/erp/finance/fund' },
|
||
{ label: '报表', icon: 'chart-column', route: '/pages/erp/report/index' },
|
||
{ label: '流水', icon: 'arrow-right-arrow-left', route: '/pages/erp/stock/ledger' }
|
||
]
|
||
|
||
onShow(() => {
|
||
load()
|
||
})
|
||
|
||
function barWidth(value) {
|
||
const pct = (Number(value || 0) / maxTrend.value) * 100
|
||
return `${Math.max(pct, 2).toFixed(1)}%`
|
||
}
|
||
|
||
async function load() {
|
||
loading.value = true
|
||
try {
|
||
dash.value = await fetchErpDashboard()
|
||
} catch (e) {
|
||
dash.value = null
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
function go(route) {
|
||
uni.navigateTo({ url: route })
|
||
}
|
||
</script>
|
||
|
||
<style lang="scss" scoped>
|
||
@import '@/src/styles/page-common.scss';
|
||
|
||
.page {
|
||
min-height: 100vh;
|
||
background: $color-bg-page;
|
||
padding: 24rpx $page-padding-x 60rpx;
|
||
}
|
||
|
||
.state-box {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
padding: 160rpx 0;
|
||
}
|
||
|
||
.state-text {
|
||
font-size: 28rpx;
|
||
color: $color-text-muted;
|
||
}
|
||
|
||
.retry {
|
||
margin-top: 24rpx;
|
||
padding: 12rpx 36rpx;
|
||
border-radius: $radius-full;
|
||
background: $color-card;
|
||
font-size: 26rpx;
|
||
color: $color-primary;
|
||
box-shadow: $shadow-card;
|
||
}
|
||
|
||
.card {
|
||
@include card;
|
||
padding: 28rpx;
|
||
margin-bottom: 24rpx;
|
||
}
|
||
|
||
.block-head {
|
||
@include block-head;
|
||
}
|
||
|
||
.block-title {
|
||
@include section-title;
|
||
}
|
||
|
||
.block-more {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 4rpx;
|
||
font-size: 24rpx;
|
||
color: $color-text-muted;
|
||
}
|
||
|
||
.hero-card {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 24rpx;
|
||
padding: 32rpx 28rpx;
|
||
margin-bottom: 24rpx;
|
||
border-radius: $radius-lg;
|
||
background: $hero-gradient;
|
||
color: #ffffff;
|
||
box-shadow: $shadow-card;
|
||
}
|
||
|
||
.hero-label {
|
||
display: block;
|
||
font-size: 24rpx;
|
||
opacity: 0.85;
|
||
}
|
||
|
||
.hero-amount {
|
||
display: block;
|
||
margin-top: 8rpx;
|
||
font-size: 48rpx;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.hero-sub {
|
||
display: block;
|
||
margin-top: 10rpx;
|
||
font-size: 22rpx;
|
||
opacity: 0.85;
|
||
}
|
||
|
||
.hero-side {
|
||
flex-shrink: 0;
|
||
text-align: right;
|
||
}
|
||
|
||
.hero-line {
|
||
margin-bottom: 10rpx;
|
||
}
|
||
|
||
.hero-line-label {
|
||
display: block;
|
||
font-size: 21rpx;
|
||
opacity: 0.8;
|
||
}
|
||
|
||
.hero-line-value {
|
||
display: block;
|
||
font-size: 28rpx;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.kpi-grid {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 20rpx;
|
||
margin-bottom: 24rpx;
|
||
}
|
||
|
||
.kpi-cell {
|
||
width: calc((100% - 40rpx) / 3);
|
||
box-sizing: border-box;
|
||
@include card;
|
||
padding: 24rpx 16rpx;
|
||
text-align: center;
|
||
|
||
&:active {
|
||
opacity: 0.85;
|
||
}
|
||
}
|
||
|
||
.kpi-value {
|
||
display: block;
|
||
font-size: 30rpx;
|
||
font-weight: 600;
|
||
color: $color-text;
|
||
|
||
&.danger {
|
||
color: $color-danger;
|
||
}
|
||
|
||
&.warning {
|
||
color: $color-warning;
|
||
}
|
||
|
||
&.primary {
|
||
color: $color-primary;
|
||
}
|
||
}
|
||
|
||
.kpi-label {
|
||
display: block;
|
||
margin-top: 8rpx;
|
||
font-size: 21rpx;
|
||
color: $color-text-muted;
|
||
line-height: 1.4;
|
||
}
|
||
|
||
.warn-row {
|
||
display: flex;
|
||
gap: 12rpx;
|
||
}
|
||
|
||
.warn-item {
|
||
flex: 1;
|
||
text-align: center;
|
||
padding: 20rpx 0;
|
||
border-radius: $radius-md;
|
||
background: $color-bg-page;
|
||
}
|
||
|
||
.warn-value {
|
||
display: block;
|
||
font-size: 34rpx;
|
||
font-weight: 600;
|
||
|
||
&.warning {
|
||
color: $color-warning;
|
||
}
|
||
|
||
&.danger {
|
||
color: $color-danger;
|
||
}
|
||
|
||
&.primary {
|
||
color: $color-primary;
|
||
}
|
||
}
|
||
|
||
.warn-label {
|
||
display: block;
|
||
margin-top: 6rpx;
|
||
font-size: 21rpx;
|
||
color: $color-text-muted;
|
||
}
|
||
|
||
.legend {
|
||
display: flex;
|
||
gap: 20rpx;
|
||
font-size: 22rpx;
|
||
color: $color-text-muted;
|
||
}
|
||
|
||
.legend-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8rpx;
|
||
}
|
||
|
||
.legend-dot {
|
||
width: 16rpx;
|
||
height: 16rpx;
|
||
border-radius: 4rpx;
|
||
|
||
&.buy {
|
||
background: #93c5fd;
|
||
}
|
||
|
||
&.sale {
|
||
background: $color-primary;
|
||
}
|
||
}
|
||
|
||
.trend-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 16rpx;
|
||
padding: 14rpx 0;
|
||
}
|
||
|
||
.trend-month {
|
||
width: 110rpx;
|
||
font-size: 22rpx;
|
||
color: $color-text-muted;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.trend-bars {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8rpx;
|
||
}
|
||
|
||
.bar-track {
|
||
height: 14rpx;
|
||
border-radius: $radius-full;
|
||
background: $color-bg-page;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.bar {
|
||
height: 100%;
|
||
border-radius: $radius-full;
|
||
|
||
&.buy {
|
||
background: #93c5fd;
|
||
}
|
||
|
||
&.sale {
|
||
background: $color-primary;
|
||
}
|
||
}
|
||
|
||
.trend-value {
|
||
width: 150rpx;
|
||
text-align: right;
|
||
font-size: 22rpx;
|
||
color: $color-text-secondary;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.entry-grid {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.entry-item {
|
||
width: 25%;
|
||
padding: 18rpx 0;
|
||
text-align: center;
|
||
}
|
||
|
||
.entry-icon {
|
||
width: 84rpx;
|
||
height: 84rpx;
|
||
margin: 0 auto 10rpx;
|
||
border-radius: $radius-md;
|
||
background: $color-primary-bg;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.entry-label {
|
||
font-size: 24rpx;
|
||
color: $color-text-secondary;
|
||
}
|
||
</style>
|