销售看板和供货看板

This commit is contained in:
砂糖
2025-09-04 14:57:05 +08:00
parent 3aac9872ed
commit 1e0387acbb
6 changed files with 1227 additions and 5 deletions

View File

@@ -6,7 +6,7 @@
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1"> <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
<meta name="renderer" content="webkit"> <meta name="renderer" content="webkit">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no"> <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<link rel="icon" href="/favicon.ico"> <link rel="icon" href="/favicon.png">
<title>创高家具销售系统</title> <title>创高家具销售系统</title>
<!--[if lt IE 11]><script>window.location.href='/html/ie.html';</script><![endif]--> <!--[if lt IE 11]><script>window.location.href='/html/ie.html';</script><![endif]-->
<style> <style>

BIN
gear-ui3/public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

View File

@@ -5,7 +5,7 @@
<el-tab-pane <el-tab-pane
v-for="menu in filteredMenus" v-for="menu in filteredMenus"
:key="menu.path" :key="menu.path"
:label="menu.meta.title" :label="menu.meta?.title"
:name="menu.path" :name="menu.path"
> >
<div class="app-grid"> <div class="app-grid">
@@ -20,7 +20,7 @@
<svg-icon :icon-class="child.meta.icon || 'documentation'" class="app-icon" /> <svg-icon :icon-class="child.meta.icon || 'documentation'" class="app-icon" />
</div> </div>
<!-- 文字区域 --> <!-- 文字区域 -->
<span class="app-name">{{ child.meta.title }}</span> <span class="app-name">{{ child.meta?.title }}</span>
<!-- 三点菜单区域 --> <!-- 三点菜单区域 -->
<div class="app-actions"> <div class="app-actions">

View File

@@ -0,0 +1,481 @@
<template>
<div class="sales-dashboard">
<el-loading v-if="loading" fullscreen text="数据加载中..." />
<!-- 指标卡区域 -->
<el-row :gutter="20" class="mb-6">
<el-col :span="6">
<el-card class="stat-card">
<div class="flex justify-between items-center">
<div>
<p class="text-gray-500 text-sm">总客户数</p>
<h3 class="text-2xl font-bold">{{ totalCustomers }}</h3>
</div>
<el-icon class="text-primary text-xl"><UserFilled /></el-icon>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card class="stat-card">
<div class="flex justify-between items-center">
<div>
<p class="text-gray-500 text-sm">总订单数</p>
<h3 class="text-2xl font-bold">{{ totalOrders }}</h3>
</div>
<el-icon class="text-success text-xl"><ShoppingCart /></el-icon>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card class="stat-card">
<div class="flex justify-between items-center">
<div>
<p class="text-gray-500 text-sm">总销售额</p>
<h3 class="text-2xl font-bold">¥{{ totalSales.toFixed(2) }}</h3>
</div>
<el-icon class="text-warning text-xl"><Money /></el-icon>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card class="stat-card">
<div class="flex justify-between items-center">
<div>
<p class="text-gray-500 text-sm">退换货率</p>
<h3 class="text-2xl font-bold">{{ returnExchangeRate.toFixed(2) }}%</h3>
</div>
<el-icon class="text-danger text-xl"><RefreshRight /></el-icon>
</div>
</el-card>
</el-col>
</el-row>
<!-- 图表区域 -->
<el-row :gutter="20" class="mb-6">
<el-col :span="12">
<el-card>
<template #header>
<div class="flex justify-between items-center">
<span>订单趋势分析</span>
<el-select v-model="orderTrendRange" style="width: 100px;" placeholder="时间范围" size="small" @change="renderOrderTrendChart">
<el-option label="近7天" value="7" />
<el-option label="近30天" value="30" />
<el-option label="近90天" value="90" />
</el-select>
</div>
</template>
<div ref="orderTrendChart" class="chart-container" />
</el-card>
</el-col>
<el-col :span="12">
<el-card>
<template #header>
<div class="flex justify-between items-center">
<span>产品销售排行</span>
<el-select v-model="productRankType" style="width: 100px;" placeholder="统计类型" size="small" @change="renderProductRankChart">
<el-option label="销量" value="quantity" />
<el-option label="销售额" value="amount" />
</el-select>
</div>
</template>
<div ref="productRankChart" class="chart-container" />
</el-card>
</el-col>
</el-row>
<el-row :gutter="20" class="mb-6">
<el-col :span="12">
<el-card>
<template #header>
<span>客户跟进状态分布</span>
</template>
<div ref="followUpStatusChart" class="chart-container" />
</el-card>
</el-col>
<el-col :span="12">
<el-card>
<template #header>
<span>订单状态分布</span>
</template>
<div ref="orderStatusChart" class="chart-container" />
</el-card>
</el-col>
</el-row>
<!-- 订单明细表 -->
<el-row>
<el-col :span="24">
<el-card>
<template #header>
<div class="flex justify-between items-center">
<span>最近订单列表</span>
</div>
</template>
<el-table
:data="paginatedOrders"
border
style="width: 100%"
:header-cell-style="{ background: '#f5f7fa' }"
>
<el-table-column prop="orderCode" label="订单编号" />
<el-table-column prop="customerName" label="客户名称" />
<el-table-column prop="salesManager" label="销售经理" />
<el-table-column
prop="taxAmount"
label="订单金额"
align="right"
:formatter="(row) => `¥${Number(row.taxAmount).toFixed(2)}`"
/>
<el-table-column
prop="orderStatus"
label="订单状态"
align="center"
>
<template #default="scope">
<dict-tag :options="order_status" :value="scope.row.orderStatus" />
</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:page-sizes="[10, 20, 50]"
:total="filteredOrders.length"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
class="mt-4 flex justify-end"
/>
</el-card>
</el-col>
</el-row>
</div>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
import * as echarts from 'echarts'
import {
UserFilled, ShoppingCart, Money, RefreshRight
} from '@element-plus/icons-vue'
import { listOrder } from '@/api/wms/order'
import { listOrderDetail } from '@/api/wms/orderDetail'
import { listReturnExchange } from '@/api/oa/returnExchange'
import { listCustomer } from '@/api/wms/customer'
const { proxy } = getCurrentInstance()
const { order_status } = proxy.useDict("order_status")
// 响应式数据
const loading = ref(true)
const customers = ref([])
const orders = ref([])
const orderDetails = ref([])
const returnExchanges = ref([])
// 图表容器引用
const orderTrendChart = ref(null)
const productRankChart = ref(null)
const followUpStatusChart = ref(null)
const orderStatusChart = ref(null)
// 筛选与分页参数
const orderTrendRange = ref('30') // 默认近30天
const productRankType = ref('quantity') // 默认按销量统计
const orderSearch = ref('')
const currentPage = ref(1)
const pageSize = ref(10)
const followUpStatusLabel = {
0: '未跟进',
1: '跟进中',
2: '已跟进'
}
// 指标计算computed确保响应式
const totalCustomers = computed(() => customers.value.length)
const totalOrders = computed(() => orders.value.length)
const totalSales = computed(() => {
return orders.value.reduce(
(sum, order) => sum + Number(order.taxAmount || 0),
0
)
})
const returnExchangeRate = computed(() => {
if (totalOrders.value === 0) return 0
return (returnExchanges.value.length / totalOrders.value) * 100
})
// 筛选后的订单(支持搜索)
const filteredOrders = computed(() => {
return orders.value.filter(order =>
order.orderCode.includes(orderSearch.value)
)
})
// 分页后的订单
const paginatedOrders = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
return filteredOrders.value.slice(start, start + pageSize.value)
})
// 页面加载时初始化数据
onMounted(async () => {
try {
loading.value = true
// 并行请求所有数据(提升加载效率)
const [customerRes, orderRes, orderDetailRes, returnExchangeRes] = await Promise.all([
listCustomer({pageNum: 1, pageSize: 1000}),
listOrder({pageNum: 1, pageSize: 1000}),
listOrderDetail({pageNum: 1, pageSize: 1000}),
listReturnExchange({pageNum: 1, pageSize: 1000})
])
// 赋值数据(假设接口返回格式为 { data: [...] }
customers.value = customerRes.rows || []
orders.value = orderRes.rows || []
orderDetails.value = orderDetailRes.rows || []
returnExchanges.value = returnExchangeRes.rows || []
// 初始化所有图表
renderOrderTrendChart()
renderProductRankChart()
renderFollowUpStatusChart()
renderOrderStatusChart()
} catch (error) {
console.error('销售数据加载失败:', error)
// 可添加全局错误提示
} finally {
loading.value = false
}
})
// ---------- 图表渲染函数 ----------
// 订单趋势图(双轴:订单数+销售额)
const renderOrderTrendChart = () => {
const chartDom = orderTrendChart.value
if (!chartDom) return
const chart = echarts.init(chartDom)
// 生成时间范围日期
const days = Number(orderTrendRange.value)
const dates = []
const now = new Date()
for (let i = days - 1; i >= 0; i--) {
const date = new Date(now)
date.setDate(now.getDate() - i)
dates.push(date.toLocaleDateString())
}
// 按日期统计订单数和销售额
const orderCountMap = {}
const salesMap = {}
dates.forEach(date => {
orderCountMap[date] = 0
salesMap[date] = 0
})
orders.value.forEach(order => {
// 假设订单有createTime字段需与dates格式匹配
const orderDate = new Date(order.createTime).toLocaleDateString()
if (orderCountMap[orderDate] !== undefined) {
orderCountMap[orderDate]++
salesMap[orderDate] += Number(order.taxAmount || 0)
}
})
const option = {
tooltip: {
trigger: 'axis',
axisPointer: { type: 'cross' }
},
legend: { data: ['订单数', '销售额'] },
xAxis: {
type: 'category',
data: dates,
axisLabel: { rotate: 30, fontSize: 12 }
},
yAxis: [
{ type: 'value', name: '订单数', min: 0 },
{
type: 'value',
name: '销售额',
min: 0,
axisLabel: { formatter: '¥{value}' }
}
],
series: [
{
name: '订单数',
type: 'bar',
data: dates.map(date => orderCountMap[date]),
barWidth: '40%'
},
{
name: '销售额',
type: 'line',
yAxisIndex: 1,
data: dates.map(date => salesMap[date]),
smooth: true,
lineStyle: { width: 2 }
}
]
}
chart.setOption(option)
}
// 产品销售排行图(饼图:支持销量/销售额切换)
const renderProductRankChart = () => {
const chartDom = productRankChart.value
if (!chartDom) return
const chart = echarts.init(chartDom)
// 统计产品数据
const productStats = {}
orderDetails.value.forEach(detail => {
const productName = detail.productName
if (!productStats[productName]) {
productStats[productName] = {
quantity: 0,
amount: 0
}
}
productStats[productName].quantity += Number(detail.quantity || 0)
productStats[productName].amount +=
Number(detail.quantity || 0) * Number(detail.taxPrice || 0)
})
// 转换为图表数据取Top10
const chartData = Object.entries(productStats)
.map(([name, stats]) => ({
name,
value: productRankType.value === 'quantity'
? stats.quantity
: stats.amount
}))
.sort((a, b) => b.value - a.value)
.slice(0, 10)
const option = {
tooltip: { trigger: 'item' },
series: [
{
name: productRankType.value === 'quantity' ? '销量' : '销售额',
type: 'pie',
radius: ['40%', '70%'],
data: chartData,
label: { formatter: '{b}: {c}' },
emphasis: { itemStyle: { shadowBlur: 10, shadowOffsetX: 0, shadowColor: 'rgba(0, 0, 0, 0.5)' } }
}
]
}
chart.setOption(option)
}
// 客户跟进状态分布图(饼图)
const renderFollowUpStatusChart = () => {
const chartDom = followUpStatusChart.value
if (!chartDom) return
const chart = echarts.init(chartDom)
// 统计跟进状态
const statusStats = { 0: 0, 1: 0, 2: 0 }
customers.value.forEach(customer => {
const status = customer.followUpStatus
if (statusStats[status] !== undefined) {
statusStats[status]++
}
})
// 转换为图表数据
const chartData = Object.entries(statusStats).map(([key, value]) => ({
name: followUpStatusLabel[key],
value
}))
const option = {
tooltip: { trigger: 'item' },
series: [
{
name: '客户跟进状态',
type: 'pie',
radius: '60%',
data: chartData,
label: { formatter: '{b}: {c} ({d}%)' }
}
]
}
chart.setOption(option)
}
// 订单状态分布图(饼图)
const renderOrderStatusChart = () => {
const chartDom = orderStatusChart.value
if (!chartDom) return
const chart = echarts.init(chartDom)
// 统计订单状态
const statusStats = {}
orders.value.forEach(order => {
const status = order.orderStatus
if (!statusStats[status]) {
statusStats[status] = 0
}
statusStats[status]++
})
// 转换为图表数据
const chartData = Object.entries(statusStats).map(([key, value]) => ({
name: key,
value
}))
const option = {
tooltip: { trigger: 'item' },
series: [
{
name: '订单状态',
type: 'pie',
radius: '60%',
data: chartData,
label: { formatter: '{b}: {c} ({d}%)' }
}
]
}
chart.setOption(option)
}
// ---------- 分页与操作函数 ----------
const handleSizeChange = (val) => {
pageSize.value = val
}
const handleCurrentChange = (val) => {
currentPage.value = val
}
const viewOrderDetail = (row) => {
console.log('查看订单详情:', row)
// 可跳转至订单详情页router.push(`/order/detail/${row.orderId}`)
}
</script>
<style scoped>
.el-row {
margin-bottom: 10px;
}
.sales-dashboard {
padding: 16px;
}
.stat-card {
transition: all 0.3s ease;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
}
.stat-card:hover {
transform: translateY(-5px);
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.1);
}
.chart-container {
width: 100%;
height: 300px;
}
</style>

View File

@@ -0,0 +1,715 @@
<template>
<!-- 根容器移除Tailwind使用Element原生布局 -->
<div class="page-container" v-loading="loading" element-loading-text="正在加载数据...">
<!-- 错误提示 -->
<el-alert
v-if="errorMsg"
title="数据加载失败"
:description="errorMsg"
type="error"
show-icon
style="margin-bottom: 20px;"
/>
<!-- 页面标题补充基础样式 -->
<div style="font-size: 18px; font-weight: 500; margin-bottom: 20px;">
数据分析页面
</div>
<!-- 主内容区数据加载成功才显示 -->
<div v-if="!loading && !errorMsg">
<!-- 第一行四个指标卡 -->
<el-row :gutter="20" style="margin-bottom: 20px;">
<el-col :span="6">
<el-card class="stat-card" :body-style="{ padding: '15px' }">
<div style="display: flex; justify-content: space-between; align-items: center;">
<div>
<p style="color: #666; font-size: 12px;">总供应商数</p>
<h3 style="font-size: 20px; font-weight: bold; margin-top: 5px;">{{ totalSuppliers }}</h3>
</div>
<div style="width: 48px; height: 48px; border-radius: 50%; background: #e6f4ff; display: flex; align-items: center; justify-content: center; color: #409eff;">
<el-icon><user /></el-icon>
</div>
</div>
<div style="margin-top: 8px; font-size: 12px; color: #666;">
<span :class="supplierTrendClass">
<el-icon v-if="supplierTrend > 0"><arrow-up /></el-icon>
<el-icon v-if="supplierTrend < 0"><arrow-down /></el-icon>
{{ Math.abs(supplierTrend) }}%
</span>
<span> 较上月</span>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card class="stat-card" :body-style="{ padding: '15px' }">
<div style="display: flex; justify-content: space-between; align-items: center;">
<div>
<p style="color: #666; font-size: 12px;">总供货类型</p>
<h3 style="font-size: 20px; font-weight: bold; margin-top: 5px;">{{ totalSupplyTypes }}</h3>
</div>
<div style="width: 48px; height: 48px; border-radius: 50%; background: #f0f9eb; display: flex; align-items: center; justify-content: center; color: #67c23a;">
<el-icon><goods /></el-icon>
</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card class="stat-card" :body-style="{ padding: '15px' }">
<div style="display: flex; justify-content: space-between; align-items: center;">
<div>
<p style="color: #666; font-size: 12px;">总采购金额</p>
<h3 style="font-size: 20px; font-weight: bold; margin-top: 5px;">¥{{ totalPurchaseAmount.toLocaleString() }}</h3>
</div>
<div style="width: 48px; height: 48px; border-radius: 50%; background: #f9f0ff; display: flex; align-items: center; justify-content: center; color: #9400d3;">
<el-icon><shoppingCart /></el-icon>
</div>
</div>
<div style="margin-top: 8px; font-size: 12px; color: #666;">
<span :class="amountTrendClass">
<el-icon v-if="amountTrend > 0"><arrow-up /></el-icon>
<el-icon v-if="amountTrend < 0"><arrow-down /></el-icon>
{{ Math.abs(amountTrend) }}%
</span>
<span> 较上月</span>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card class="stat-card" :body-style="{ padding: '15px' }">
<div style="display: flex; justify-content: space-between; align-items: center;">
<div>
<p style="color: #666; font-size: 12px;">已完成计划</p>
<h3 style="font-size: 20px; font-weight: bold; margin-top: 5px;">{{ completedPlans }}</h3>
</div>
</div>
<div style="margin-top: 8px; font-size: 12px; color: #666;">
<span style="color: #67c23a;">
{{ completionRate }}% 完成率
</span>
</div>
</el-card>
</el-col>
</el-row>
<!-- 第二行两个图表 -->
<el-row :gutter="20" style="margin-bottom: 20px;">
<el-col :span="12">
<el-card>
<template #header>
<div style="display: flex; justify-content: space-between; align-items: center;">
<span>供货类型分类占比</span>
</div>
</template>
<!-- 图表容器确保固定高度避免图表不显示 -->
<div class="chart-container">
<div ref="typePieChart" style="width: 100%; height: 320px;"></div>
</div>
</el-card>
</el-col>
<el-col :span="12">
<el-card>
<template #header>
<div style="display: flex; justify-content: space-between; align-items: center;">
<div style="font-size: 16px; font-weight: bold;">采购计划趋势</div>
<el-select v-model="planChartTimeRange" style="width: 100px;" placeholder="选择时间范围" size="small" @change="initPlanLineChart">
<el-option label="近7天" value="7days" />
<el-option label="近30天" value="30days" />
<el-option label="近90天" value="90days" />
</el-select>
</div>
</template>
<div class="chart-container">
<div ref="planLineChart" style="width: 100%; height: 320px; border: 1px solid #eee; border-radius: 4px;"></div>
</div>
<div style="margin-top: 10px; font-size: 12px; color: #999;">
数据状态采购计划{{ purchasePlans.length }} / 状态字典{{ purchase_status.length }}
</div>
</el-card>
</el-col>
</el-row>
<!-- 第三行采购计划表格 -->
<el-row style="margin-bottom: 20px;">
<el-col :span="24">
<el-card>
<template #header>
<!-- 表格头部替换flex-wrap为自适应布局 -->
<div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px;">
<span>采购计划列表</span>
<div style="display: flex; gap: 10px; align-items: center;">
<el-select v-model="statusFilter" placeholder="筛选状态" size="small">
<el-option label="全部状态" value="" />
<el-option
v-for="(item, index) in purchase_status"
:key="index"
:label="item.label"
:value="item.value"
/>
</el-select>
<el-input
v-model="searchKeyword"
placeholder="搜索材料名称/供应商"
size="small"
:prefix-icon="Search"
style="width: 200px;"
/>
</div>
</div>
</template>
<el-table
:data="paginatedPlans"
border
style="width: 100%; height: 500px;"
:header-cell-style="{ background: '#f5f7fa' }"
v-loading="tableLoading"
>
<el-table-column
prop="detailCode"
label="计划编号"
align="center"
/>
<el-table-column
prop="rawMaterialName"
label="材料名称"
width="180"
/>
<el-table-column
prop="supplierName"
label="供应商"
width="180"
/>
<el-table-column
prop="quantity"
label="采购数量"
align="right"
:formatter="(row) => `${row.quantity} ${row.unit || ''}`"
/>
<el-table-column
prop="unitPrice"
label="单价"
align="right"
:formatter="(row) => `¥${Number(row.unitPrice).toFixed(2)}`"
/>
<el-table-column
prop="totalAmount"
label="总金额"
align="right"
:formatter="(row) => `¥${Number(row.totalAmount).toFixed(2)}`"
/>
<el-table-column
prop="owner"
label="负责人"
align="center"
/>
<el-table-column
prop="status"
label="状态"
align="center"
>
<template #default="scope">
<!-- 确保DictTag组件正确导入 -->
<dict-tag
:options="purchase_status"
:value="scope.row.status"
/>
</template>
</el-table-column>
<!-- <el-table-column
label="操作"
width="180"
align="center"
>
<template #default="scope">
<el-button
size="small"
type="text"
@click="handleViewDetail(scope.row)"
>
查看详情
</el-button>
<el-button
size="small"
type="text"
v-if="scope.row.annex"
@click="handleDownloadAnnex(scope.row)"
>
下载附件
</el-button>
</template>
</el-table-column> -->
</el-table>
<!-- 分页固定右对齐 -->
<div style="display: flex; justify-content: flex-end; margin-top: 16px;">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:page-sizes="[10, 20, 50]"
:total="filteredPurchasePlans.length"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</el-card>
</el-col>
</el-row>
</div>
</div>
</template>
<script setup>
// 1. 补充缺失的Vue核心API导入关键之前未导入导致响应式失效
import { ref, computed, onMounted, watch, getCurrentInstance, onUnmounted } from 'vue'
import * as echarts from 'echarts'
// 2. 导入Element图标和组件
import {
User, Goods, ShoppingCart,
ArrowUp, ArrowDown, Search
} from '@element-plus/icons-vue'
import { ElMessage, ElAlert } from 'element-plus'
// 4. 导入API修正拼写listPurchasePlanDetail
import { listSupplier } from '@/api/oa/supplier'
import { listSupplyType } from '@/api/oa/supplyType'
import { listPurchasePlanDetail } from '@/api/oa/purchasePlanDetail'
// 5. 获取当前实例和字典数据确保proxy存在
const { proxy } = getCurrentInstance()
// 获取采购状态字典(容错:避免字典未加载导致报错)
const { purchase_status = [] } = proxy?.useDict('purchase_status') || {}
// 6. 状态管理初始化默认值避免undefined
const loading = ref(true)
const tableLoading = ref(false)
const errorMsg = ref('')
const suppliers = ref([])
const supplyTypes = ref([])
const purchasePlans = ref([])
// 图表相关初始化ref为空
const typePieChart = ref(null)
const planLineChart = ref(null)
const typeChartTimeRange = ref('month')
const planChartTimeRange = ref('30days')
// 表格相关
const searchKeyword = ref('')
const statusFilter = ref('')
const currentPage = ref(1)
const pageSize = ref(10)
// 7. 指标卡数据(容错处理:避免数组空值)
const totalSuppliers = computed(() => suppliers.value?.length || 0)
const totalSupplyTypes = computed(() => supplyTypes.value?.length || 0)
const totalPurchasePlans = computed(() => purchasePlans.value?.length || 0)
const totalPurchaseAmount = computed(() => {
return purchasePlans.value?.reduce((sum, plan) => {
return sum + Number(plan.totalAmount || 0)
}, 0) || 0
})
const completedPlans = computed(() => {
const completedValue = 4
return purchasePlans.value?.filter(plan => plan.status === completedValue).length || 0
})
// 趋势数据(模拟)
const supplierTrend = ref(5.2)
const typeTrend = ref(-2.1)
const amountTrend = ref(8.7)
// 完成率容错避免除以0
const completionRate = computed(() => {
if (totalPurchasePlans.value === 0) return 0
return Math.round((completedPlans.value / totalPurchasePlans.value) * 100)
})
// 趋势样式computed正常工作
const supplierTrendClass = computed(() => {
return supplierTrend.value > 0 ? 'color: #67c23a;' : 'color: #f56c6c;'
})
const typeTrendClass = computed(() => {
return typeTrend.value > 0 ? 'color: #67c23a;' : 'color: #f56c6c;'
})
const amountTrendClass = computed(() => {
return amountTrend.value > 0 ? 'color: #67c23a;' : 'color: #f56c6c;'
})
// 8. 筛选后的采购计划(容错:处理空数组)
const filteredPurchasePlans = computed(() => {
const plans = purchasePlans.value || []
return plans.filter(plan => {
// 状态筛选容错statusFilter为空时不筛选
if (statusFilter.value !== '' && plan.status !== Number(statusFilter.value)) {
return false
}
// 关键词搜索容错处理null/undefined
if (searchKeyword.value) {
const keyword = searchKeyword.value.toLowerCase()
const materialName = (plan.rawMaterialName || '').toLowerCase()
const supplierName = (plan.supplierName || '').toLowerCase()
return materialName.includes(keyword) || supplierName.includes(keyword)
}
return true
})
})
// 分页数据容错避免slice越界
const paginatedPlans = computed(() => {
const plans = filteredPurchasePlans.value || []
const startIndex = (currentPage.value - 1) * pageSize.value
return plans.slice(startIndex, startIndex + pageSize.value)
})
// 9. 页面加载逻辑(关键:修复异步错误处理)
onMounted(async () => {
try {
loading.value = true
console.log('开始获取数据')
// 并行调用API带分页参数确保获取全量数据
const [supplierRes, typeRes, planRes] = await Promise.all([
listSupplier({ pageNum: 1, pageSize: 1000 }),
listSupplyType({ pageNum: 1, pageSize: 1000 }),
listPurchasePlanDetail({ pageNum: 1, pageSize: 1000 })
])
console.log('API返回数据', supplierRes, typeRes, planRes)
// 处理返回数据容错检查code和rows字段
if (supplierRes?.code === 200) {
suppliers.value = supplierRes.rows || []
} else {
throw new Error(`供应商数据错误:${supplierRes?.msg || '未知错误'}`)
}
if (typeRes?.code === 200) {
supplyTypes.value = typeRes.rows || []
} else {
throw new Error(`供货类型数据错误:${typeRes?.msg || '未知错误'}`)
}
if (planRes?.code === 200) {
purchasePlans.value = planRes.rows || []
} else {
throw new Error(`采购计划数据错误:${planRes?.msg || '未知错误'}`)
}
// 初始化图表确保DOM已渲染
setTimeout(() => {
initTypePieChart()
initPlanLineChart()
}, 100)
} catch (error) {
console.error('页面加载失败:', error)
errorMsg.value = error.message || '数据加载失败,请刷新页面重试'
} finally {
loading.value = false
}
})
// 10. 初始化供货类型饼图容错避免DOM未加载
const initTypePieChart = () => {
if (!typePieChart.value || !purchasePlans.value.length) return
const chart = echarts.init(typePieChart.value)
// 清除已有图表(避免重复渲染)
chart.clear()
// 处理数据:按供应商类型统计采购金额
const typeMap = {}
purchasePlans.value.forEach(plan => {
const supplier = suppliers.value.find(s => s.supplierId === plan.supplierId)
const typeName = supplier?.typeName || '其他'
typeMap[typeName] = (typeMap[typeName] || 0) + Number(plan.totalAmount || 0)
})
const chartData = Object.entries(typeMap).map(([name, value]) => ({ name, value }))
// 图表配置(简化配置,确保显示)
const option = {
tooltip: {
trigger: 'item',
formatter: '{a}<br/>{b}: ¥{c} ({d}%)'
},
legend: {
orient: 'vertical',
left: 10,
data: chartData.map(item => item.name),
textStyle: { fontSize: 12 }
},
series: [{
name: '采购金额',
type: 'pie',
radius: ['40%', '70%'],
data: chartData,
itemStyle: { borderRadius: 8 },
label: { show: false },
labelLine: { show: false }
}]
}
chart.setOption(option)
// 窗口resize监听添加防抖
const handleResize = debounce(() => chart.resize(), 100)
window.addEventListener('resize', handleResize)
// 组件卸载时清理
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
chart.dispose() // 销毁图表,避免内存泄漏
})
}
// 11. 初始化采购计划折线图(修复类型匹配与日期逻辑)
const initPlanLineChart = async () => {
try {
// 1. 确认DOM元素存在
console.log('折线图DOM元素', planLineChart.value);
if (!planLineChart.value) {
console.error('折线图DOM容器不存在');
ElMessage.warning('图表容器未加载,请刷新页面重试');
return;
}
const purchase_status = [
{ label: '待提交', value: 0 },
{ label: '在途', value: 1 },
{ label: '到货', value: 2 },
{ label: '待审核', value: 3 },
{ label: '已完成', value: 4 }
]
// 2. 确认核心数据存在
console.log('采购计划数据:', purchasePlans.value);
console.log('采购状态字典:', purchase_status);
if (!purchasePlans.value.length) {
console.warn('无采购计划数据,无法渲染折线图');
const chart = echarts.init(planLineChart.value);
chart.setOption({
tooltip: { trigger: 'axis' },
legend: { data: [] },
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
xAxis: { type: 'category', data: [] },
yAxis: { type: 'value', min: 0 },
series: [],
graphic: {
type: 'text',
left: 'center',
top: 'center',
style: { text: '暂无采购计划数据', fill: '#999', fontSize: 14 }
}
});
return;
}
// 3. 确保echarts实例正确
let chart = echarts.getInstanceByDom(planLineChart.value);
if (!chart) chart = echarts.init(planLineChart.value);
chart.clear();
// 4. 生成日期范围(统一格式:年-月-日)
const getDateRange = (rangeType) => {
const dates = [];
const now = new Date();
let days = 7;
if (rangeType === '30days') days = 30;
if (rangeType === '90days') days = 90;
for (let i = days - 1; i >= 0; i--) {
const date = new Date(now);
date.setDate(now.getDate() - i);
const formatDate = `${date.getFullYear()}-${(date.getMonth() + 1).toString().padStart(2, '0')}-${date.getDate().toString().padStart(2, '0')}`;
dates.push(formatDate);
}
console.log('生成的日期范围:', dates);
return dates;
};
const dateRange = getDateRange(planChartTimeRange.value);
// 5. 初始化日期-状态映射(统一状态为数字类型)
const dateMap = {};
dateRange.forEach(date => {
dateMap[date] = {};
purchase_status.forEach(status => {
const statusNum = Number(status.value); // 字典value转数字
dateMap[date][statusNum] = 0; // 用数字作为键
});
});
// 6. 填充采购计划数据(状态转数字匹配)
purchasePlans.value.forEach(plan => {
let planDate = '';
// 处理计划日期
if (plan.createTime) {
try {
const createDate = new Date(plan.createTime);
planDate = `${createDate.getFullYear()}-${(createDate.getMonth() + 1).toString().padStart(2, '0')}-${createDate.getDate().toString().padStart(2, '0')}`;
} catch (e) {
console.warn(`计划${plan.detailId}的createTime格式错误`, plan.createTime);
planDate = new Date().toISOString().split('T')[0];
}
} else {
console.warn(`计划${plan.detailId}无createTime使用当前日期`);
planDate = new Date().toISOString().split('T')[0];
}
// 统计金额(状态转数字匹配)
if (dateMap[planDate] && plan.status !== undefined) {
const planStatusNum = Number(plan.status); // 计划status转数字
const amount = Number(plan.totalAmount || 0);
if (dateMap[planDate].hasOwnProperty(planStatusNum)) {
dateMap[planDate][planStatusNum] += amount;
console.log(`计划${plan.detailId}${planDate} / 状态${planStatusNum} / 金额${amount}`);
} else {
console.warn(`计划${plan.detailId}的状态${plan.status}(转数字为${planStatusNum})不在字典中,跳过统计`);
}
}
});
// 7. 生成图表系列数据(基于数字状态)
const series = purchase_status.map(status => {
const statusNum = Number(status.value); // 字典value转数字
const data = dateRange.map(date => {
return dateMap[date][statusNum] || 0; // 容错默认0
});
console.log(`状态${status.label}(值${statusNum})的数据:`, data);
return {
name: status.label,
type: 'line',
smooth: true,
data: data,
itemStyle: { lineStyle: { width: 2 } } // 加粗线条便于观察
};
});
// 8. 图表配置与渲染
const option = {
tooltip: {
trigger: 'axis',
formatter: '{b}<br/>{a}:¥{c}',
backgroundColor: 'rgba(255,255,255,0.9)',
borderColor: '#eee',
borderWidth: 1
},
legend: {
data: purchase_status.map(s => s.label),
top: 0,
left: 'center',
textStyle: { fontSize: 12 },
itemWidth: 10,
itemHeight: 10
},
grid: {
left: '5%',
right: '5%',
bottom: '15%',
top: '20%',
containLabel: true
},
xAxis: {
type: 'category',
boundaryGap: false,
data: dateRange,
axisLabel: {
fontSize: 11,
rotate: 45,
interval: 0
},
axisLine: { lineStyle: { color: '#eee' } }
},
yAxis: {
type: 'value',
min: 0,
axisLabel: {
formatter: '¥{value}',
fontSize: 11
},
splitLine: { lineStyle: { color: '#f5f5f5' } }
},
series: series,
graphic: {
type: 'text',
left: 'center',
top: '10%',
style: { text: '图表加载中...', fill: '#666', fontSize: 12 },
silent: true
}
};
chart.setOption(option);
console.log('折线图配置已生效:', option);
// 9. 窗口resize监听与清理
const handleResize = debounce(() => {
console.log('窗口resize重绘折线图');
chart.resize();
}, 100);
window.addEventListener('resize', handleResize);
onUnmounted(() => {
window.removeEventListener('resize', handleResize);
chart.dispose();
console.log('折线图已销毁');
});
} catch (error) {
console.error('折线图初始化失败:', error);
ElMessage.error(`图表加载失败:${error.message}`);
}
};
// 12. 工具函数防抖避免频繁resize
const debounce = (fn, delay = 100) => {
let timer = null
return (...args) => {
clearTimeout(timer)
timer = setTimeout(() => fn.apply(this, args), delay)
}
}
// 分页事件
const handleSizeChange = (val) => {
pageSize.value = val
currentPage.value = 1 // 重置页码
}
const handleCurrentChange = (val) => {
currentPage.value = val
}
// 监听筛选条件变化,重置分页
watch([searchKeyword, statusFilter], () => {
currentPage.value = 1
})
</script>
<style scoped>
/* 13. 基础样式移除Tailwind使用原生CSS */
.page-container {
padding: 16px;
box-sizing: border-box;
}
.stat-card {
transition: all 0.3s ease;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.stat-card:hover {
transform: translateY(-5px);
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.1);
}
.chart-container {
position: relative;
width: 100%;
height: 100%;
}
/* 修复图标垂直居中 */
.el-icon {
vertical-align: middle;
}
</style>

View File

@@ -91,6 +91,22 @@
<el-table-column label="单价" align="center" prop="unitPrice" /> <el-table-column label="单价" align="center" prop="unitPrice" />
<el-table-column label="总金额" align="center" prop="totalAmount" /> <el-table-column label="总金额" align="center" prop="totalAmount" />
<el-table-column label="备注" align="center" prop="remark" /> <el-table-column label="备注" align="center" prop="remark" />
<el-table-column label="状态" align="center" prop="status">
<template #default="scope">
<el-select
v-model="scope.row.status"
placeholder="请选择状态"
@change="handleStatusChange(scope.row)"
>
<el-option
v-for="value in purchase_status"
:value="parseInt(value.value)"
:label="value.label"
>
</el-option>
</el-select>
</template>
</el-table-column>
<el-table-column label="操作" align="center" class-name="small-padding fixed-width"> <el-table-column label="操作" align="center" class-name="small-padding fixed-width">
<template #default="scope"> <template #default="scope">
<el-button link type="primary" icon="Edit" @click="handleUpdate(scope.row)">修改</el-button> <el-button link type="primary" icon="Edit" @click="handleUpdate(scope.row)">修改</el-button>
@@ -138,9 +154,9 @@
<el-form-item label="单价" prop="unitPrice"> <el-form-item label="单价" prop="unitPrice">
<el-input v-model="form.unitPrice" placeholder="请输入单价" /> <el-input v-model="form.unitPrice" placeholder="请输入单价" />
</el-form-item> </el-form-item>
<el-form-item label="总金额" prop="totalAmount"> <!-- <el-form-item label="总金额" prop="totalAmount">
<el-input v-model="form.totalAmount" placeholder="请输入总金额" /> <el-input v-model="form.totalAmount" placeholder="请输入总金额" />
</el-form-item> </el-form-item> -->
<el-form-item label="备注" prop="remark"> <el-form-item label="备注" prop="remark">
<el-input v-model="form.remark" placeholder="请输入备注" /> <el-input v-model="form.remark" placeholder="请输入备注" />
</el-form-item> </el-form-item>
@@ -161,6 +177,8 @@ import { listSupplier } from "@/api/oa/supplier";
const { proxy } = getCurrentInstance(); const { proxy } = getCurrentInstance();
const { purchase_status } = proxy.useDict('purchase_status')
const purchasePlanDetailList = ref([]); const purchasePlanDetailList = ref([]);
const open = ref(false); const open = ref(false);
const buttonLoading = ref(false); const buttonLoading = ref(false);
@@ -176,6 +194,14 @@ const formatterTime = (time) => {
return proxy.parseTime(time, '{y}-{m}-{d}') return proxy.parseTime(time, '{y}-{m}-{d}')
} }
const handleStatusChange = (row) => {
const { totalAmount, ...payload } = row;
updatePurchasePlanDetail(payload).then(response => {
proxy.$modal.msgSuccess("修改成功");
getList();
});
}
const data = reactive({ const data = reactive({
form: {}, form: {},
queryParams: { queryParams: {