|
|
@@ -0,0 +1,349 @@
|
|
|
+<template>
|
|
|
+ <div class="tianzhu-production-page">
|
|
|
+ <header class="dashboard-header">
|
|
|
+ <el-date-picker
|
|
|
+ v-model="monthRange"
|
|
|
+ class="header-date-range"
|
|
|
+ type="monthrange"
|
|
|
+ range-separator="至"
|
|
|
+ start-placeholder="开始月份"
|
|
|
+ end-placeholder="结束月份"
|
|
|
+ value-format="yyyy-MM"
|
|
|
+ :clearable="false"
|
|
|
+ />
|
|
|
+ <div class="cockpit-title">生产总览驾驶舱</div>
|
|
|
+ <div class="header-status">
|
|
|
+ <i class="el-icon-time"></i>
|
|
|
+ <span>数据最后更新:{{ data.dataLastUpdateTime || '暂无有效生产记录' }}</span>
|
|
|
+ </div>
|
|
|
+ </header>
|
|
|
+
|
|
|
+ <section class="dashboard-actions">
|
|
|
+ <el-select v-model="mixOrgId" class="plant-select" clearable placeholder="厂区选择">
|
|
|
+ <el-option v-for="item in mixOrgOptions" :key="item.id" :label="item.name" :value="item.id" />
|
|
|
+ </el-select>
|
|
|
+ <el-button type="primary" icon="el-icon-search" :loading="loading" @click="loadData">查询</el-button>
|
|
|
+ <el-button icon="el-icon-refresh-left" :disabled="loading" @click="reset">重置</el-button>
|
|
|
+ <el-button icon="el-icon-download" :disabled="!loaded || loading" @click="exportData">导出图表</el-button>
|
|
|
+ </section>
|
|
|
+ <el-alert v-if="error" :title="error" type="error" show-icon :closable="false" />
|
|
|
+
|
|
|
+ <section class="section-title">KPI 指标</section>
|
|
|
+ <section class="kpi-grid">
|
|
|
+ <article v-for="item in cards" :key="item.key" class="kpi-card" :class="{ clickable: item.drilldown }" @click="drilldown(item)">
|
|
|
+ <div class="kpi-label"><i :class="['dot', item.color]"></i>{{ item.label }}</div>
|
|
|
+ <div class="kpi-value">{{ formatValue(data[item.key]) }}<small>{{ item.unit }}</small></div>
|
|
|
+ <div class="kpi-description">{{ item.description }}</div>
|
|
|
+ </article>
|
|
|
+ </section>
|
|
|
+
|
|
|
+ <section class="trend-grid">
|
|
|
+ <article class="trend-card">
|
|
|
+ <h3>每日生产方量趋势</h3>
|
|
|
+ <div ref="volumeChart" class="chart"></div>
|
|
|
+ </article>
|
|
|
+ <article class="trend-card">
|
|
|
+ <h3>每日生产盘数趋势</h3>
|
|
|
+ <div ref="platesChart" class="chart"></div>
|
|
|
+ </article>
|
|
|
+ <article class="trend-card">
|
|
|
+ <h3>每日生产车次趋势</h3>
|
|
|
+ <div ref="tripsChart" class="chart"></div>
|
|
|
+ </article>
|
|
|
+ </section>
|
|
|
+
|
|
|
+ <footer class="metric-note">
|
|
|
+ 每日生产方量按 prodEnd 汇总 prodMete;每日生产盘数按 prodEnd 汇总 pieCnt;每日生产车次按日期内 assignOrderCode 去重统计。跨日派车编号会在不同日期分别计入。
|
|
|
+ </footer>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script>
|
|
|
+import * as echarts from 'echarts';
|
|
|
+import * as XLSX from 'xlsx';
|
|
|
+import { getProductionMixOrgs, getProductionOverview } from '@/api/tianzhu/production';
|
|
|
+
|
|
|
+const pad = (number) => String(number).padStart(2, '0');
|
|
|
+const currentMonth = () => {
|
|
|
+ const now = new Date();
|
|
|
+ return `${now.getFullYear()}-${pad(now.getMonth() + 1)}`;
|
|
|
+};
|
|
|
+
|
|
|
+export default {
|
|
|
+ name: 'TianzhuProductionDashboard',
|
|
|
+ data() {
|
|
|
+ const month = currentMonth();
|
|
|
+ return {
|
|
|
+ monthRange: [month, month],
|
|
|
+ mixOrgId: '',
|
|
|
+ mixOrgOptions: [],
|
|
|
+ loading: false,
|
|
|
+ loaded: false,
|
|
|
+ error: '',
|
|
|
+ data: { dailyTrends: [] },
|
|
|
+ chartInstances: []
|
|
|
+ };
|
|
|
+ },
|
|
|
+ computed: {
|
|
|
+ cards() {
|
|
|
+ return [
|
|
|
+ { drilldown: 'production-statistics', key: 'productionVolume', label: '本期生产方量', unit: 'm³', color: 'blue', description: '生产记录按生产完成时间统计' },
|
|
|
+ { drilldown: 'production-statistics', key: 'productionPlates', label: '本期生产盘数', unit: '盘', color: 'blue', description: '生产记录盘数汇总' },
|
|
|
+ { drilldown: 'production-trip-statistics', key: 'productionTrips', label: '本期生产车次', unit: '车次', color: 'orange', description: '派车编号期间去重' },
|
|
|
+ { key: 'pouringVolume', label: '本期浇筑方量', unit: 'm³', color: 'orange', description: '发货单调度方量汇总', drilldown: 'pouring-statistics' },
|
|
|
+ { key: 'pouringTrips', label: '本期浇筑车次', unit: '车次', color: 'blue', description: '有效发货单记录数', drilldown: 'pouring-statistics' },
|
|
|
+ { key: 'salesVolume', label: '销售方量总计', unit: 'm³', color: 'blue', description: '销售生产方量汇总' },
|
|
|
+ { key: 'taskTotal', label: '任务总数', unit: '个', color: 'green', description: '按任务下达时间统计', drilldown: 'task-statistics' },
|
|
|
+ { key: 'taskCompleted', label: '已完成任务', unit: '个', color: 'green', description: '状态为已完成的任务数', drilldown: 'task-statistics' },
|
|
|
+ { key: 'taskCompletionRate', label: '任务完成率', unit: '%', color: 'orange', description: '已完成任务 ÷ 任务总数' },
|
|
|
+ { key: 'plannedVolume', label: '计划方量总计', unit: 'm³', color: 'orange', description: '按计划浇筑时间统计' },
|
|
|
+ { key: 'completedVolume', label: '完成方量总计', unit: 'm³', color: 'orange', description: '计划任务关联有效生产记录' },
{ key: 'volumeCompletionRate', label: '完成率', unit: '%', color: 'green', description: '完成方量 ÷ 计划方量' }
|
|
|
+ ];
|
|
|
+ }
|
|
|
+ },
|
|
|
+ mounted() {
|
|
|
+ window.addEventListener('resize', this.resizeCharts);
|
|
|
+ this.loadMixOrgs();
|
|
|
+ this.loadData();
|
|
|
+ },
|
|
|
+ beforeDestroy() {
|
|
|
+ window.removeEventListener('resize', this.resizeCharts);
|
|
|
+ this.disposeCharts();
|
|
|
+ },
|
|
|
+ methods: {
|
|
|
+ buildQuery() {
|
|
|
+ const range = this.monthRange || [];
|
|
|
+ const start = range[0];
|
|
|
+ const end = range[1] || start;
|
|
|
+ const [year, month] = end.split('-').map(Number);
|
|
|
+ const nextMonth = new Date(year, month, 1);
|
|
|
+ return {
|
|
|
+ startTime: `${start}-01 00:00:00`,
|
|
|
+ endTime: `${nextMonth.getFullYear()}-${pad(nextMonth.getMonth() + 1)}-01 00:00:00`,
|
|
|
+ mixOrgId: this.mixOrgId || null
|
|
|
+ };
|
|
|
+ },
|
|
|
+ formatValue(value) {
|
|
|
+ if (value === null || value === undefined || value === '') return '0';
|
|
|
+ const number = Number(value);
|
|
|
+ return Number.isNaN(number) ? value : number.toLocaleString('zh-CN', { maximumFractionDigits: 2 });
|
|
|
+ },
|
|
|
+ async loadMixOrgs() {
|
|
|
+ try {
|
|
|
+ this.mixOrgOptions = await getProductionMixOrgs();
|
|
|
+ } catch (error) {
|
|
|
+ this.mixOrgOptions = [];
|
|
|
+ }
|
|
|
+ },
|
|
|
+ async loadData() {
|
|
|
+ if (!this.monthRange || !this.monthRange[0]) return;
|
|
|
+ this.loading = true;
|
|
|
+ this.error = '';
|
|
|
+ try {
|
|
|
+ this.data = await getProductionOverview(this.buildQuery());
|
|
|
+ this.loaded = true;
|
|
|
+ this.$nextTick(this.drawCharts);
|
|
|
+ } catch (error) {
|
|
|
+ this.loaded = false;
|
|
|
+ this.error = error.message || '获取生产看板数据失败';
|
|
|
+ } finally {
|
|
|
+ this.loading = false;
|
|
|
+ }
|
|
|
+ },
|
|
|
+ drilldown(item) { if (item.drilldown) this.$router.push({ path: '/tianzhu/' + item.drilldown, query: { mixOrgId: this.mixOrgId || undefined } }); },
|
|
|
+ reset() {
|
|
|
+ const month = currentMonth();
|
|
|
+ this.monthRange = [month, month];
|
|
|
+ this.mixOrgId = '';
|
|
|
+ this.data = { dailyTrends: [] };
|
|
|
+ this.loaded = false;
|
|
|
+ this.loadData();
|
|
|
+ },
|
|
|
+ // 导出当前已查询的总览卡片和三类每日趋势,数值与页面展示使用同一接口返回值。
|
|
|
+ exportData() {
|
|
|
+ if (!this.loaded) return;
|
|
|
+ const workbook = XLSX.utils.book_new();
|
|
|
+ const append = (title, rows) => XLSX.utils.book_append_sheet(workbook, XLSX.utils.aoa_to_sheet(rows), title);
|
|
|
+ const trends = this.data.dailyTrends || [];
|
|
|
+ const plant = this.mixOrgOptions.find((item) => String(item.id) === String(this.mixOrgId));
|
|
|
+ const plantName = plant ? plant.name : '全部厂区';
|
|
|
+ append('生产总览', [
|
|
|
+ ['统计开始', this.buildQuery().startTime],
|
|
|
+ ['统计结束', this.buildQuery().endTime],
|
|
|
+ ['厂区', plantName],
|
|
|
+ ['指标', '数值', '单位'],
|
|
|
+ ...this.cards.map((card) => [card.label, this.data[card.key], card.unit])
|
|
|
+ ]);
|
|
|
+ append('每日生产趋势', [
|
|
|
+ ['日期', '生产方量(m³)', '生产盘数(盘)', '生产车次(车次)'],
|
|
|
+ ...trends.map((item) => [item.statDate, item.productionVolume, item.productionPlates, item.productionTrips])
|
|
|
+ ]);
|
|
|
+ XLSX.writeFile(workbook, `生产总览_${this.monthRange.join('_')}.xlsx`);
|
|
|
+ },
|
|
|
+ disposeCharts() {
|
|
|
+ this.chartInstances.forEach((chart) => chart.dispose());
|
|
|
+ this.chartInstances = [];
|
|
|
+ },
|
|
|
+ resizeCharts() {
|
|
|
+ this.chartInstances.forEach((chart) => chart.resize());
|
|
|
+ },
|
|
|
+ drawCharts() {
|
|
|
+ this.disposeCharts();
|
|
|
+ const trends = this.data.dailyTrends || [];
|
|
|
+ const dates = trends.map((item) => item.statDate ? item.statDate.slice(5) : '');
|
|
|
+ this.chartInstances = [
|
|
|
+ this.createChart(this.$refs.volumeChart, 'line', dates, trends.map((item) => item.productionVolume), '方量(m³)', '#1677ff'),
|
|
|
+ this.createChart(this.$refs.platesChart, 'line', dates, trends.map((item) => item.productionPlates), '盘数(盘)', '#36b37e'),
|
|
|
+ this.createChart(this.$refs.tripsChart, 'bar', dates, trends.map((item) => item.productionTrips), '车次', '#ff8a34')
|
|
|
+ ].filter(Boolean);
|
|
|
+ },
|
|
|
+ createChart(element, type, dates, values, unit, color) {
|
|
|
+ if (!element) return null;
|
|
|
+ const chart = echarts.init(element);
|
|
|
+ chart.setOption({
|
|
|
+ color: [color],
|
|
|
+ tooltip: { trigger: 'axis', valueFormatter: (value) => `${this.formatValue(value)} ${unit}` },
|
|
|
+ grid: { left: 54, right: 22, top: 28, bottom: 42 },
|
|
|
+ xAxis: { type: 'category', data: dates, boundaryGap: type === 'bar', axisTick: { show: false }, axisLine: { lineStyle: { color: 'rgba(34, 137, 210, .72)' } }, axisLabel: { color: '#94bee5' } },
|
|
|
+ yAxis: { type: 'value', name: unit, nameTextStyle: { color: '#83b6df' }, axisLabel: { color: '#94bee5' }, splitLine: { lineStyle: { color: 'rgba(87, 157, 215, .25)', type: 'dashed' } } },
|
|
|
+ series: [{
|
|
|
+ type,
|
|
|
+ data: values,
|
|
|
+ smooth: type === 'line',
|
|
|
+ symbol: 'circle',
|
|
|
+ symbolSize: 6,
|
|
|
+ barMaxWidth: 36,
|
|
|
+ lineStyle: { width: 3 },
|
|
|
+ areaStyle: type === 'line' ? { color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [{ offset: 0, color: `${color}66` }, { offset: 1, color: `${color}08` }]) } : undefined,
|
|
|
+ itemStyle: { borderRadius: type === 'bar' ? [5, 5, 0, 0] : 0 }
|
|
|
+ }]
|
|
|
+ });
|
|
|
+ return chart;
|
|
|
+ }
|
|
|
+ }
|
|
|
+};
|
|
|
+</script>
|
|
|
+
|
|
|
+<style scoped>
|
|
|
+.tianzhu-production-page {
|
|
|
+ min-height: calc(100vh - 50px);
|
|
|
+ padding: 12px 16px 20px;
|
|
|
+ box-sizing: border-box;
|
|
|
+ overflow: hidden;
|
|
|
+ color: #d8ecff;
|
|
|
+ background-color: #020d28;
|
|
|
+ background-image:
|
|
|
+ radial-gradient(circle at 52% -30%, rgba(14, 111, 202, .32), transparent 43%),
|
|
|
+ linear-gradient(rgba(31, 93, 157, .12) 1px, transparent 1px),
|
|
|
+ linear-gradient(90deg, rgba(31, 93, 157, .12) 1px, transparent 1px);
|
|
|
+ background-size: auto, 32px 32px, 32px 32px;
|
|
|
+ font-family: 'Microsoft YaHei', Arial, sans-serif;
|
|
|
+}
|
|
|
+.dashboard-header {
|
|
|
+ position: relative;
|
|
|
+ display: grid;
|
|
|
+ grid-template-columns: minmax(360px, 1fr) auto minmax(360px, 1fr);
|
|
|
+ align-items: center;
|
|
|
+ min-height: 54px;
|
|
|
+ margin-bottom: 16px;
|
|
|
+ border-bottom: 1px solid rgba(18, 139, 230, .55);
|
|
|
+}
|
|
|
+.dashboard-header::before,
|
|
|
+.dashboard-header::after {
|
|
|
+ position: absolute;
|
|
|
+ bottom: -2px;
|
|
|
+ width: 21%;
|
|
|
+ height: 2px;
|
|
|
+ content: '';
|
|
|
+ background: #19b7ff;
|
|
|
+ box-shadow: 0 0 10px #19b7ff;
|
|
|
+}
|
|
|
+.dashboard-header::before { left: 0; }
|
|
|
+.dashboard-header::after { right: 0; }
|
|
|
+.cockpit-title {
|
|
|
+ position: relative;
|
|
|
+ min-width: 310px;
|
|
|
+ padding: 8px 38px 10px;
|
|
|
+ text-align: center;
|
|
|
+ color: #f3fbff;
|
|
|
+ font-size: 23px;
|
|
|
+ font-weight: 700;
|
|
|
+ letter-spacing: 5px;
|
|
|
+ text-shadow: 0 0 12px rgba(52, 190, 255, .9);
|
|
|
+ border: 1px solid #1289d0;
|
|
|
+ border-top: 0;
|
|
|
+ border-radius: 0 0 16px 16px;
|
|
|
+ background: linear-gradient(180deg, rgba(13, 94, 166, .75), rgba(1, 27, 72, .45));
|
|
|
+ box-shadow: 0 4px 14px rgba(0, 137, 225, .25);
|
|
|
+}
|
|
|
+.dashboard-actions { display: flex; align-items: center; gap: 7px; min-height: 45px; margin: 2px 0 13px; }
|
|
|
+.header-date-range { justify-self: start; }
|
|
|
+.header-status { justify-self: end; color: #9ec9ed; font-size: 12px; white-space: nowrap; }
|
|
|
+.header-status i { margin-right: 6px; color: #ffd868; font-size: 16px; }
|
|
|
+.plant-select { width: 114px; }
|
|
|
+:deep(.el-input__inner) {
|
|
|
+ height: 30px;
|
|
|
+ color: #dcefff;
|
|
|
+ border-color: #145894;
|
|
|
+ background: rgba(4, 37, 88, .86);
|
|
|
+}
|
|
|
+:deep(.el-date-editor.el-input__inner), :deep(.el-date-editor--monthrange) { width: 220px; }
|
|
|
+:deep(.el-range-input), :deep(.el-range-separator), :deep(.el-input__icon) { color: #b5d7f5; background: transparent; }
|
|
|
+:deep(.el-button) { padding: 7px 12px; color: #badfff; border-color: #145894; background: rgba(4, 44, 102, .92); }
|
|
|
+:deep(.el-button--primary) { color: #fff; border-color: #168ee1; background: linear-gradient(180deg, #168ee1, #0864b0); }
|
|
|
+:deep(.el-alert) { margin-bottom: 12px; }
|
|
|
+.section-title {
|
|
|
+ position: relative;
|
|
|
+ margin: 0 0 10px;
|
|
|
+ padding-left: 16px;
|
|
|
+ color: #e7f6ff;
|
|
|
+ font-size: 14px;
|
|
|
+ font-weight: 700;
|
|
|
+ letter-spacing: 1px;
|
|
|
+}
|
|
|
+.section-title::before { position: absolute; left: 0; top: 4px; width: 8px; height: 8px; content: ''; transform: rotate(45deg); background: #28d4ff; box-shadow: 0 0 8px #28d4ff; }
|
|
|
+.kpi-grid { display: grid; grid-template-columns: repeat(6, minmax(145px, 1fr)); gap: 10px; }
|
|
|
+.kpi-card {
|
|
|
+ position: relative;
|
|
|
+ min-height: 92px;
|
|
|
+ padding: 13px 15px 10px;
|
|
|
+ overflow: hidden;
|
|
|
+ box-sizing: border-box;
|
|
|
+ border: 1px solid rgba(21, 126, 212, .68);
|
|
|
+ background: linear-gradient(135deg, rgba(5, 47, 108, .95), rgba(3, 26, 69, .88));
|
|
|
+ box-shadow: inset 0 0 22px rgba(15, 107, 197, .18), 0 0 13px rgba(0, 93, 183, .1);
|
|
|
+}
|
|
|
+.kpi-card.clickable { cursor: pointer; }.kpi-card.clickable:hover { border-color: #43cfff; box-shadow: inset 0 0 24px rgba(15, 141, 235, .32), 0 0 15px rgba(31, 172, 255, .25); }
|
|
|
+.kpi-card::after { position: absolute; right: -16px; bottom: -22px; width: 85px; height: 70px; content: ''; transform: rotate(-28deg); border: 1px solid rgba(47, 185, 255, .18); }
|
|
|
+.kpi-label { position: relative; z-index: 1; display: flex; align-items: center; color: #a9ceef; font-size: 12px; }
|
|
|
+.dot { width: 6px; height: 6px; margin-right: 7px; border-radius: 50%; box-shadow: 0 0 7px currentColor; }
|
|
|
+.dot.blue { color: #27bfff; background: #27bfff; }.dot.orange { color: #ffb34a; background: #ffb34a; }.dot.green { color: #35e7a7; background: #35e7a7; }
|
|
|
+.kpi-value { position: relative; z-index: 1; margin-top: 8px; color: #fff; font-family: 'Arial Narrow', 'Microsoft YaHei', sans-serif; font-size: 27px; font-weight: 700; line-height: 1; letter-spacing: 1px; text-shadow: 0 0 8px rgba(110, 209, 255, .52); }
|
|
|
+.kpi-value small { margin-left: 5px; color: #b6d6f3; font-size: 11px; font-weight: 400; }
|
|
|
+.kpi-description { position: relative; z-index: 1; margin-top: 9px; color: #6799c7; font-size: 10px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
|
+.trend-grid { display: grid; grid-template-columns: repeat(3, minmax(260px, 1fr)); gap: 10px; margin-top: 13px; }
|
|
|
+.trend-card {
|
|
|
+ position: relative;
|
|
|
+ height: 267px;
|
|
|
+ padding: 10px 12px 5px;
|
|
|
+ box-sizing: border-box;
|
|
|
+ border: 1px solid rgba(19, 132, 215, .72);
|
|
|
+ background: linear-gradient(180deg, rgba(5, 43, 94, .94), rgba(2, 23, 62, .92));
|
|
|
+ box-shadow: inset 0 0 26px rgba(6, 105, 190, .14);
|
|
|
+}
|
|
|
+.trend-card::before { position: absolute; top: 10px; left: 11px; width: 7px; height: 7px; content: ''; transform: rotate(45deg); background: #23c7ff; box-shadow: 0 0 8px #23c7ff; }
|
|
|
+.trend-card h3 { margin: 0; padding-left: 15px; color: #e6f6ff; font-size: 13px; font-weight: 600; }.chart { width: 100%; height: 225px; }
|
|
|
+.metric-note { margin-top: 11px; color: #5e91bb; font-size: 11px; text-align: center; }
|
|
|
+@media (max-width: 1280px) { .kpi-grid { grid-template-columns: repeat(4, minmax(145px, 1fr)); }.dashboard-header { grid-template-columns: 1fr auto; }.cockpit-title { order: -1; grid-column: 1 / 3; justify-self: center; }.header-status { display: none; } }
|
|
|
+@media (max-width: 960px) { .dashboard-header { display: flex; flex-direction: column; align-items: stretch; padding-bottom: 9px; }.cockpit-title { align-self: center; }.dashboard-actions { justify-content: center; flex-wrap: wrap; }.kpi-grid { grid-template-columns: repeat(2, minmax(145px, 1fr)); }.trend-grid { grid-template-columns: 1fr; } }
|
|
|
+</style>
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+
|