You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
haoliang-net/frontend/src/views/production/MachineProduction.vue

443 lines
12 KiB
Vue

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<template>
<div class="machine-production">
<!-- 筛选栏 -->
<el-form :inline="true" :model="filters" class="filter-bar">
<el-form-item label="日期范围">
<el-date-picker
v-model="filters.dateRange"
type="daterange"
value-format="YYYY-MM-DD"
range-separator="-"
start-placeholder="开始日期"
end-placeholder="结束日期"
/>
</el-form-item>
<el-form-item label="车间">
<el-select v-model="filters.workshopId" value-key="value" placeholder="请选择车间" clearable style="min-width:200px">
<el-option
v-for="w in options.workshops"
:key="w.value"
:label="w.label"
:value="w.value"
/>
</el-select>
</el-form-item>
<el-form-item label="机床">
<el-select
v-model="filters.machineIds"
value-key="value"
placeholder="请选择机床"
clearable
filterable
multiple
style="min-width:200px"
>
<el-option
v-for="m in options.machines"
:key="m.value"
:label="m.label"
:value="m.value"
/>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="loadData">查询</el-button>
<el-button @click="resetFilters">重置</el-button>
</el-form-item>
</el-form>
<!-- 统计卡片 -->
<el-row :gutter="16" class="stat-row">
<el-col :span="6">
<el-card shadow="hover">
<div class="stat-card">
<div class="stat-label">
总产量
<el-tooltip content="选定时间范围内所有机床的总产量。" placement="top">
<span class="info-icon">ⓘ</span>
</el-tooltip>
</div>
<div class="stat-value">
{{ summary.totalQuantity != null ? summary.totalQuantity.toLocaleString() : '-' }}
</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover">
<div class="stat-card">
<div class="stat-label">
运行机床数
<el-tooltip content="选定时间范围内有产量记录的机床数量。" placement="top">
<span class="info-icon">ⓘ</span>
</el-tooltip>
</div>
<div class="stat-value">
{{ summary.runningMachineCount != null ? summary.runningMachineCount : '-' }}
</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover">
<div class="stat-card">
<div class="stat-label">
平均单产
<el-tooltip content="平均每台机床的产量。" placement="top">
<span class="info-icon">ⓘ</span>
</el-tooltip>
</div>
<div class="stat-value">
{{ summary.avgPerMachine != null ? summary.avgPerMachine.toFixed(2) : '-' }}
</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover">
<div class="stat-card">
<div class="stat-label">
最高单产机床
<el-tooltip content="产量最高的机床名称。" placement="top">
<span class="info-icon">ⓘ</span>
</el-tooltip>
</div>
<div class="stat-value top-machine-name">
{{ summary.topMachineName || '-' }}
</div>
</div>
</el-card>
</el-col>
</el-row>
<!-- 图表行 -->
<el-row :gutter="16" class="chart-row">
<el-col :span="24">
<el-card shadow="hover">
<template #header>
<div style="display:flex;justify-content:space-between;align-items:center">
<span class="card-title">机床产量柱状图</span>
<div style="display:flex;align-items:center;gap:6px">
<span style="font-size:13px;color:#909399">TOP</span>
<el-select v-model="barChartTopN" size="small" style="width:80px" @change="initBarChart" filterable allow-create>
<el-option :value="10" label="10"/>
<el-option :value="20" label="20"/>
<el-option :value="50" label="50"/>
<el-option :value="100" label="100"/>
</el-select>
</div>
</div>
</template>
<div ref="barChartRef" style="height: 350px"></div>
</el-card>
</el-col>
</el-row>
<!-- 明细表格 -->
<el-card shadow="hover" class="table-card">
<template #header>
<span class="card-title">明细排行</span>
</template>
<el-table :data="tableData" stripe size="small" v-loading="loading">
<el-table-column prop="rank" label="排名" width="60" align="center" />
<el-table-column prop="machineName" label="机床名称" width="120" />
<el-table-column prop="programName" label="程序名" show-overflow-tooltip>
<template #default="{ row }">
<el-link type="primary" :underline="false" @click="goToProgram(row)">{{ row.programName }}</el-link>
</template>
</el-table-column>
<el-table-column prop="totalQuantity" label="产量" width="100" align="center" />
<el-table-column label="运行时间" width="100" align="center">
<template #default="{ row }">{{ row.runTime }}h</template>
</el-table-column>
<el-table-column prop="dayStatus" label="日状态" width="80" align="center" />
</el-table>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import request from '@/utils/request'
import echarts from '@/utils/echarts'
import type { ECharts } from 'echarts/core'
import type { ApiResponse } from '@/types'
/** 机床产量汇总 */
interface MachineProductionSummary {
totalQuantity: number
runningMachineCount: number
avgPerMachine: number
topMachineName: string
}
/** 机床产量排行条目 */
interface MachineProductionItem {
rank: number
machineName: string
programName: string
totalQuantity: number
runTime: number
dayStatus: string
}
/** 下拉选项 */
interface DropdownOption {
value: string
label: string
}
// ---------- 路由 ----------
const router = useRouter()
const route = useRoute()
// ---------- 状态 ----------
const loading = ref(false)
const tableData = ref<MachineProductionItem[]>([])
const summary = ref<Partial<MachineProductionSummary>>({})
const filters = reactive({
dateRange: [] as string[],
workshopId: '' as string | number,
machineIds: [] as string[],
})
const options = reactive({
workshops: [] as DropdownOption[],
machines: [] as DropdownOption[],
})
// ---------- ECharts ----------
const barChartRef = ref<HTMLElement>()
let barChart: ECharts | null = null
const barChartTopN = ref(10)
/** 初始化柱状图按机床聚合取TOP N */
function initBarChart() {
if (!barChartRef.value || !tableData.value.length) return
if (barChart) barChart.dispose()
barChart = echarts.init(barChartRef.value)
// 按机床名聚合产量
const machineMap = new Map<string, number>()
tableData.value.forEach(i => machineMap.set(i.machineName, (machineMap.get(i.machineName)||0) + i.totalQuantity))
const sorted = [...machineMap.entries()].sort((a,b) => b[1] - a[1]).slice(0, barChartTopN.value)
const names = sorted.map(e => e[0])
const quantities = sorted.map(e => e[1])
const total = quantities.reduce((s, v) => s + v, 0)
barChart.setOption({
tooltip: {
trigger: 'axis',
formatter: (params: { name: string; value: number }[]) => {
const item = params[0]
return `${item.name}<br/>产量: ${item.value} 件`
},
},
grid: { left: 50, right: 20, top: 30, bottom: 40 },
xAxis: {
type: 'category',
data: names,
axisLabel: { fontSize: 12, rotate: names.length > 6 ? 30 : 0 },
},
yAxis: { type: 'value', name: '件', axisLabel: { fontSize: 12 } },
series: [
{
type: 'bar',
data: quantities,
itemStyle: {
color: '#409EFF',
borderRadius: [4, 4, 0, 0],
},
barWidth: '50%',
label: {
show: true,
position: 'top',
formatter: (params: { value: number }) => {
if (total === 0) return ''
const pct = ((params.value / total) * 100).toFixed(1)
return `${params.value}件 (${pct}%)`
},
fontSize: 11,
},
},
],
})
}
/** 销毁所有图表 */
function disposeCharts() {
barChart?.dispose()
barChart = null
}
// ---------- 跨页面联动 ----------
/** 点击程序名跳转到程序产量页面 */
function goToProgram(row: MachineProductionItem) {
const startDate = filters.dateRange[0] ?? ''
const endDate = filters.dateRange[1] ?? ''
router.push({
path: '/production/program',
query: {
startDate,
endDate,
programNames: row.programName,
},
})
}
// ---------- 数据加载 ----------
/** 格式化日期 */
function fmtDate(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
/** 加载下拉选项 */
async function loadOptions() {
try {
const [wsRes, mcRes] = await Promise.all([
request.get<{ items: DropdownOption[] }>('/admin/workshop/list'),
request.get<{ items: DropdownOption[] }>('/admin/machine/list'),
])
options.workshops = wsRes.data?.items ?? []
options.machines = mcRes.data?.items ?? []
} catch {
// 保持容错,无法获取下拉项时不阻塞页面
}
}
/** 核心加载函数:同时请求汇总和明细 */
async function loadData() {
loading.value = true
try {
const params: Record<string, string | number> = {
startDate: filters.dateRange[0] ?? '',
endDate: filters.dateRange[1] ?? '',
workshopId: filters.workshopId ?? '',
machineIds: filters.machineIds.join(','),
}
const [summaryRes, listRes] = await Promise.all([
request.get<MachineProductionSummary>(
'/admin/production/machine/summary',
{ params }
),
request.get<{ items: MachineProductionItem[] }>(
'/admin/production/machine/list',
{ params }
),
])
summary.value = summaryRes.data ?? {}
tableData.value = listRes.data?.items ?? []
// 图表刷新
disposeCharts()
await nextTick()
initBarChart()
} catch {
// API 不存在时 catch 错误不阻塞
} finally {
loading.value = false
}
}
/** 重置筛选条件并重新加载 */
function resetFilters() {
const today = new Date()
const todayStr = fmtDate(today)
filters.dateRange = [todayStr, todayStr]
filters.workshopId = ''
filters.machineIds = []
loadData()
}
// ---------- 生命周期 ----------
onMounted(async () => {
// 优先从 URL query 初始化筛选条件(跨页面联动)
if (route.query.startDate) {
filters.dateRange = [route.query.startDate as string, (route.query.endDate as string) || (route.query.startDate as string)]
} else {
const today = new Date()
const todayStr = fmtDate(today)
filters.dateRange = [todayStr, todayStr]
}
if (route.query.machineIds) {
filters.machineIds = (route.query.machineIds as string).split(',').filter(Boolean)
}
await loadOptions()
loadData()
})
onBeforeUnmount(() => {
disposeCharts()
})
</script>
<style scoped lang="scss">
.machine-production {
padding: 0;
.filter-bar {
margin-bottom: 16px;
}
.stat-row {
margin-bottom: 16px;
.el-card {
height: 100%;
}
.stat-card {
text-align: center;
padding: 10px 0;
.stat-label {
font-size: 14px;
color: #909399;
margin-bottom: 8px;
.info-icon {
margin-left: 2px;
font-size: 13px;
color: #c0c4cc;
cursor: help;
vertical-align: middle;
&:hover {
color: #409eff;
}
}
}
.stat-value {
font-size: 28px;
font-weight: bold;
color: #303133;
}
.top-machine-name {
font-size: 20px;
}
}
}
.chart-row {
margin-bottom: 16px;
}
.card-title {
font-size: 15px;
font-weight: 500;
}
.table-card {
:deep(.el-card__body) {
padding: 0;
}
}
}
</style>