Просмотр исходного кода

task876-20260922 天筑:生产总预览驾驶舱\原材料看板需求分析\销售总览驾驶舱

lucw 1 месяц назад
Родитель
Сommit
6cd062712c

+ 39 - 0
src/api/tianzhu/production.js

@@ -0,0 +1,39 @@
+import request from '@/utils/request';
+
+/** 石河子天筑生产看板总览 */
+export async function getProductionOverview(params) {
+  const res = await request.post('/dataScreen/tianzhu/production/overview', params);
+  if (res.data.code === 0 || res.data.code === '0') return res.data.data;
+  return Promise.reject(new Error(res.data.message || '获取生产看板数据失败'));
+}
+
+/** 可选厂区。当前中间库未确认厂区名称字段,展示名由厂区 ID 生成。 */
+export async function getProductionMixOrgs() {
+  const res = await request.get('/dataScreen/tianzhu/production/mix-orgs');
+  if (res.data.code === 0 || res.data.code === '0') return res.data.data || [];
+  return Promise.reject(new Error(res.data.message || '获取厂区列表失败'));
+}
+
+export async function getProductionStatistics(params) { const res = await request.post('/dataScreen/tianzhu/production/statistics', params); if (res.data.code === 0 || res.data.code === '0') return res.data.data; return Promise.reject(new Error(res.data.message || '获取生产统计数据失败')); }
+
+
+/** 浇筑统计下钻:浇筑方式、客户、工程、趋势及客户计划实际对比。 */
+export async function getPouringStatistics(params) {
+  const res = await request.post('/dataScreen/tianzhu/production/pouring-statistics', params);
+  if (res.data.code === 0 || res.data.code === '0') return res.data.data;
+  return Promise.reject(new Error(res.data.message || '获取浇筑统计数据失败'));
+}
+
+/** 任务执行统计下钻:任务完成、下达趋势与计划实际方量。 */
+export async function getTaskStatistics(params) {
+  const res = await request.post('/dataScreen/tianzhu/production/task-statistics', params);
+  if (res.data.code === 0 || res.data.code === '0') return res.data.data;
+  return Promise.reject(new Error(res.data.message || '获取任务执行统计数据失败'));
+}
+
+/** 生产车次分析下钻:调度员、司机、客户、工程及浇筑方式。 */
+export async function getProductionTripStatistics(params) {
+  const res = await request.post('/dataScreen/tianzhu/production/trip-statistics', params);
+  if (res.data.code === 0 || res.data.code === '0') return res.data.data;
+  return Promise.reject(new Error(res.data.message || '获取生产车次分析数据失败'));
+}

+ 58 - 0
src/api/tianzhu/rawMaterial.js

@@ -0,0 +1,58 @@
+import request from '@/utils/request';
+export async function getRawMaterialOverview(params) {
+  const res = await request.get('/dataScreen/tianzhu/raw-material/overview', {
+    params
+  });
+  if (res.data.code === 0 || res.data.code === '0') return res.data.data;
+  return Promise.reject(
+    new Error(res.data.message || '获取原材料看板数据失败')
+  );
+}
+export async function getRawMaterialDashboard(data) {
+  const res = await request.post(
+    '/dataScreen/tianzhu/raw-material/dashboard',
+    data
+  );
+  if (res.data.code === 0 || res.data.code === '0') return res.data.data;
+  return Promise.reject(
+    new Error(res.data.message || '获取原材料看板数据失败')
+  );
+}
+export async function getRawMaterialSupplierAnalysis(data) {
+  const res = await request.post(
+    '/dataScreen/tianzhu/raw-material/supplier-analysis',
+    data
+  );
+  if (res.data.code === 0 || res.data.code === '0') return res.data.data;
+  return Promise.reject(
+    new Error(res.data.message || '获取供应商分析数据失败')
+  );
+}
+export async function getRawMaterialPurchaseAnalysis(data) {
+  const res = await request.post(
+    '/dataScreen/tianzhu/raw-material/material-purchase-analysis',
+    data
+  );
+  if (res.data.code === 0 || res.data.code === '0') return res.data.data;
+  return Promise.reject(
+    new Error(res.data.message || '获取原材料采购分析数据失败')
+  );
+}
+export async function getRawMaterialInventoryMonitoring(data) {
+  const res = await request.post(
+    '/dataScreen/tianzhu/raw-material/inventory-monitoring',
+    data
+  );
+  if (res.data.code === 0 || res.data.code === '0') return res.data.data;
+  return Promise.reject(new Error(res.data.message || '获取库存监控数据失败'));
+}
+export async function getRawMaterialTransportWeighingAnalysis(data) {
+  const res = await request.post(
+    '/dataScreen/tianzhu/raw-material/transport-weighing-analysis',
+    data
+  );
+  if (res.data.code === 0 || res.data.code === '0') return res.data.data;
+  return Promise.reject(
+    new Error(res.data.message || '获取运输与过磅分析数据失败')
+  );
+}

+ 34 - 0
src/api/tianzhu/sales.js

@@ -0,0 +1,34 @@
+import request from '@/utils/request';
+export async function getSalesOverview(params) {
+  const res = await request.get('/dataScreen/tianzhu/sales/overview', { params });
+  if ((res.data.code === 0 || res.data.code === '0')) return res.data.data;
+  return Promise.reject(new Error(res.data.message || '获取销售看板数据失败'));
+}
+
+/** 客户分析;startTime包含、endTime不包含;可选mixOrgId/customerName/goodMark。 */
+export async function getSalesCustomerAnalysis(params) {
+  const res = await request.get('/dataScreen/tianzhu/sales/customer-analysis', { params });
+  if (res.data.code === 0 || res.data.code === '0') return res.data.data;
+  throw new Error(res.data.message || '获取客户分析失败');
+}
+
+/** 产品标号分析;startTime包含、endTime不包含,方量统一为发货单sendAmount。 */
+export async function getSalesProductAnalysis(params) {
+  const res = await request.get('/dataScreen/tianzhu/sales/product-analysis', { params });
+  if (res.data.code === 0 || res.data.code === '0') return res.data.data;
+  throw new Error(res.data.message || '获取产品标号分析失败');
+}
+
+/** 发货与盈亏监控;返回完整按日趋势,时间范围[startTime,endTime)。 */
+export async function getSalesLossMonitoring(params) {
+  const res = await request.get('/dataScreen/tianzhu/sales/loss-monitoring', { params });
+  if (res.data.code === 0 || res.data.code === '0') return res.data.data;
+  throw new Error(res.data.message || '获取发货与盈亏监控失败');
+}
+
+/** 订单与浇筑分析;浇筑方式取发货单,订单/计划趋势取生产任务。 */
+export async function getSalesOrderAnalysis(params) {
+  const res = await request.get('/dataScreen/tianzhu/sales/order-analysis', { params });
+  if (res.data.code === 0 || res.data.code === '0') return res.data.data;
+  throw new Error(res.data.message || '获取订单与浇筑分析失败');
+}

+ 185 - 14
src/router/routes.js

@@ -12,21 +12,192 @@ export const routes = [
     path: '/login',
     component: () => import('@/views/login/index.vue'),
     meta: { title: '登录' }
+  },
+  // 生产看板
+  {
+    path: '/tianzhu/production',
+    component: EleLayout,
+    meta: { hideFooter: true },
+    children: [
+      {
+        path: '',
+        component: () => import('@/views/tianzhu/productionDashboard.vue'),
+        meta: { title: '生产总览驾驶舱' }
+      }
+    ]
+  },
+  {
+    path: '/tianzhu/production-trip-statistics',
+    component: EleLayout,
+    meta: { hideFooter: true },
+    children: [
+      {
+        path: '',
+        component: () => import('@/views/tianzhu/productionTripStatistics.vue'),
+        meta: { title: '生产车次分析' }
+      }
+    ]
+  },
+  {
+    path: '/tianzhu/production-statistics',
+    component: EleLayout,
+    meta: { hideFooter: true },
+    children: [
+      {
+        path: '',
+        component: () => import('@/views/tianzhu/productionStatistics.vue'),
+        meta: { title: '生产方量统计' }
+      }
+    ]
+  },
+  {
+    path: '/tianzhu/task-statistics',
+    component: EleLayout,
+    meta: { hideFooter: true },
+    children: [
+      {
+        path: '',
+        component: () => import('@/views/tianzhu/taskStatistics.vue'),
+        meta: { title: '任务执行统计' }
+      }
+    ]
+  },
+  {
+    path: '/tianzhu/pouring-statistics',
+    component: EleLayout,
+    meta: { hideFooter: true },
+    children: [
+      {
+        path: '',
+        component: () => import('@/views/tianzhu/pouringStatistics.vue'),
+        meta: { title: '浇筑方量统计' }
+      }
+    ]
+  },
+  // 销售看板
+  {
+    path: '/tianzhu/sales',
+    component: EleLayout,
+    meta: { hideFooter: true },
+    children: [
+      {
+        path: '',
+        component: () => import('@/views/tianzhu/salesDashboard.vue'),
+        meta: { title: '销售总览驾驶舱' }
+      }
+    ]
+  },
+
+  {
+    path: '/tianzhu/sales-customer-analysis',
+    component: EleLayout,
+    meta: { hideFooter: true },
+    children: [
+      {
+        path: '',
+        component: () => import('@/views/tianzhu/salesCustomerAnalysis.vue'),
+        meta: { title: '销售客户分析' }
+      }
+    ]
+  },
+  {
+    path: '/tianzhu/sales-product-analysis',
+    component: EleLayout,
+    meta: { hideFooter: true },
+    children: [
+      {
+        path: '',
+        component: () => import('@/views/tianzhu/salesProductAnalysis.vue'),
+        meta: { title: '销售产品标号分析' }
+      }
+    ]
+  },
+  {
+    path: '/tianzhu/sales-loss-monitoring',
+    component: EleLayout,
+    meta: { hideFooter: true },
+    children: [
+      {
+        path: '',
+        component: () => import('@/views/tianzhu/salesLossMonitoring.vue'),
+        meta: { title: '发货与盈亏监控' }
+      }
+    ]
+  },
+  {
+    path: '/tianzhu/sales-order-analysis',
+    component: EleLayout,
+    meta: { hideFooter: true },
+    children: [
+      {
+        path: '',
+        component: () => import('@/views/tianzhu/salesOrderAnalysis.vue'),
+        meta: { title: '订单与浇筑分析' }
+      }
+    ]
+  },
+  // 原材料看板(采购)
+  {
+    path: '/tianzhu/raw-material',
+    component: EleLayout,
+    meta: { hideFooter: true },
+    children: [
+      {
+        path: '',
+        component: () => import('@/views/tianzhu/rawMaterialDashboard.vue'),
+        meta: { title: '采购总览驾驶舱' }
+      }
+    ]
+  },
+  {
+    path: '/tianzhu/raw-material-supplier-analysis',
+    component: EleLayout,
+    meta: { hideFooter: true },
+    children: [
+      {
+        path: '',
+        component: () => import('@/views/tianzhu/rawMaterialAnalysis.vue'),
+        meta: { title: '供应商分析', drillType: 'supplier' }
+      }
+    ]
+  },
+  {
+    path: '/tianzhu/raw-material-purchase-analysis',
+    component: EleLayout,
+    meta: { hideFooter: true },
+    children: [
+      {
+        path: '',
+        component: () => import('@/views/tianzhu/rawMaterialAnalysis.vue'),
+        meta: { title: '原材料采购分析', drillType: 'material' }
+      }
+    ]
+  },
+  {
+    path: '/tianzhu/raw-material-inventory-monitoring',
+    component: EleLayout,
+    meta: { hideFooter: true },
+    children: [
+      {
+        path: '',
+        component: () => import('@/views/tianzhu/rawMaterialAnalysis.vue'),
+        meta: { title: '库存监控', drillType: 'inventory' }
+      }
+    ]
+  },
+  {
+    path: '/tianzhu/raw-material-transport-analysis',
+    component: EleLayout,
+    meta: { hideFooter: true },
+    children: [
+      {
+        path: '',
+        component: () => import('@/views/tianzhu/rawMaterialAnalysis.vue'),
+        meta: { title: '运输与过磅分析', drillType: 'transport' }
+      }
+    ]
   }
-  // {
-  //   path: '/vis-page/factoryProductionDashboard',
-  //   component: EleLayout,
-  //   meta: { hideFooter: true },
-  //   children: [
-  //     {
-  //       path: '',
-  //       component: () =>
-  //         import('@/views/bpm/vis-page/factoryProductionDashboard.vue'),
-  //       meta: { title: '工厂生产综合看板' }
-  //     }
-  //   ]
-  // }
-  // 404
+
   // {
   //   path: '*',
   //   component: () => import('@/views/exception/404/index.vue')

+ 3 - 0
src/views/tianzhu/index.js

@@ -0,0 +1,3 @@
+import TianzhuProductionDashboard from '@/views/tianzhu/productionDashboard.vue';
+
+export default TianzhuProductionDashboard;

+ 102 - 0
src/views/tianzhu/pouringStatistics.vue

@@ -0,0 +1,102 @@
+<template>
+  <div class="page">
+    <header class="header">
+      <el-button icon="el-icon-back" @click="$router.push('/tianzhu/production')">返回总览</el-button>
+      <h2>浇筑方量统计</h2>
+      <div class="filters">
+        <el-date-picker v-model="months" type="monthrange" value-format="yyyy-MM" :clearable="false" range-separator="至" />
+        <el-button type="primary" icon="el-icon-search" @click="load">查询</el-button>
+      </div>
+    </header>
+    <p class="note">统计口径:发货单排除 status=99,按 assignTime 统计;客户计划与实际浇筑按生产任务关联发货单统计。</p>
+    <el-alert v-if="error" type="error" :title="error" :closable="false" />
+    <main>
+      <section v-for="item in chartItems" :key="item.key" class="panel">
+        <h3>{{ item.title }}<span>{{ item.unit }}</span></h3>
+        <div :ref="item.key" class="chart"></div>
+      </section>
+    </main>
+  </div>
+</template>
+
+<script>
+import * as echarts from 'echarts';
+import { getPouringStatistics } from '@/api/tianzhu/production';
+const pad = n => String(n).padStart(2, '0');
+const currentMonth = () => { const d = new Date(); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}`; };
+const number = value => Number(value || 0);
+
+export default {
+  name: 'TianzhuPouringStatistics',
+  data() { const month = currentMonth(); return { months: [month, month], dashboard: {}, error: '', charts: [] }; },
+  computed: {
+    chartItems() {
+      return [
+        { key: 'methodVolume', title: '浇筑方式方量占比', unit: '单位:m³' },
+        { key: 'methodTrips', title: '各浇筑方式车次对比', unit: '单位:车次' },
+        { key: 'customerVolume', title: '客户浇筑量排名', unit: '单位:m³' },
+        { key: 'projectVolume', title: '工程浇筑量排名', unit: '单位:m³' },
+        { key: 'customerTrips', title: '客户浇筑车次排名', unit: '单位:车次' },
+        { key: 'dailyVolume', title: '每日浇筑方量趋势', unit: '单位:m³' },
+        { key: 'planActual', title: '客户计划 vs 实际浇筑', unit: '单位:m³' }
+      ];
+    }
+  },
+  mounted() { window.addEventListener('resize', this.resize); this.load(); },
+  beforeDestroy() { window.removeEventListener('resize', this.resize); this.dispose(); },
+  methods: {
+    makeQuery() {
+      const [start, end] = this.months;
+      const [year, month] = end.split('-').map(Number);
+      const next = new Date(year, month, 1);
+      return { startTime: `${start}-01 00:00:00`, endTime: `${next.getFullYear()}-${pad(next.getMonth() + 1)}-01 00:00:00`, mixOrgId: this.$route.query.mixOrgId || null };
+    },
+    async load() {
+      this.error = '';
+      try { this.dashboard = await getPouringStatistics(this.makeQuery()); this.$nextTick(this.draw); }
+      catch (e) { this.error = e.message || '获取浇筑统计数据失败'; }
+    },
+    dispose() { this.charts.forEach(chart => chart.dispose()); this.charts = []; },
+    resize() { this.charts.forEach(chart => chart.resize()); },
+    element(key) { const ref = this.$refs[key]; return Array.isArray(ref) ? ref[0] : ref; },
+    init(key) { const element = this.element(key); if (!element) return null; const chart = echarts.init(element); this.charts.push(chart); return chart; },
+    bar(key, rows, unit, color) {
+      const chart = this.init(key); if (!chart) return;
+      chart.setOption({
+        tooltip: { trigger: 'axis', valueFormatter: value => `${number(value).toLocaleString()} ${unit}` },
+        grid: { left: 58, right: 18, top: 24, bottom: 56 },
+        xAxis: { type: 'category', data: rows.map(row => row.name), axisLabel: { color: '#9bc7eb', interval: 0, rotate: rows.length > 5 ? 28 : 0 }, axisLine: { lineStyle: { color: '#197abb' } } },
+        yAxis: { type: 'value', name: unit, nameTextStyle: { color: '#9bc7eb' }, axisLabel: { color: '#9bc7eb' }, splitLine: { lineStyle: { color: 'rgba(105,175,226,.22)', type: 'dashed' } } },
+        series: [{ type: 'bar', data: rows.map(row => number(row.value)), barMaxWidth: 34, itemStyle: { color, borderRadius: [4, 4, 0, 0] } }]
+      });
+    },
+    rank(key, rows, unit, color) { const chart = this.init(key); if (!chart) return; const sorted = [...rows].sort((a, b) => number(b.value) - number(a.value)); chart.setOption({ tooltip: { trigger: 'axis', valueFormatter: value => number(value).toLocaleString() + ' ' + unit}, grid: { left: 120, right: 38, top: 20, bottom: 22 }, xAxis: { type: 'value', name: unit, nameTextStyle: { color: '#9bc7eb' }, axisLabel: { color: '#9bc7eb' }, splitLine: { lineStyle: { color: 'rgba(105,175,226,.22)', type: 'dashed' } } }, yAxis: { type: 'category', inverse: true, data: sorted.map(row => row.name), axisLabel: { color: '#9bc7eb', width: 105, overflow: 'truncate' }, axisLine: { lineStyle: { color: '#197abb' } } }, series: [{ type: 'bar', data: sorted.map(row => number(row.value)), barMaxWidth: 24, label: { show: true, position: 'right', color: '#d8ecff', formatter: params => number(params.value).toLocaleString() }, itemStyle: { color, borderRadius: [0, 4, 4, 0] } }] }); },
+    draw() {
+      this.dispose();
+      const data = this.dashboard || {};
+      const methodVolume = data.pouringMethodVolumes || [];
+      const pie = this.init('methodVolume');
+      if (pie) pie.setOption({ tooltip: { trigger: 'item', valueFormatter: value => `${number(value).toLocaleString()} m³` }, legend: { bottom: 0, textStyle: { color: '#a9cdeb' } }, series: [{ type: 'pie', radius: ['42%', '67%'], data: methodVolume.map(row => ({ name: row.name, value: number(row.value) })), label: { color: '#d7edff', formatter: '{b}\n{d}%' } }] });
+      this.bar('methodTrips', data.pouringMethodTrips || [], '车次', '#ffae42');
+      this.rank('customerVolume', data.customerVolumes || [], 'm³', '#20b7ff');
+      this.rank('projectVolume', data.projectVolumes || [], 'm³', '#36e0a4');
+      this.bar('customerTrips', data.customerTrips || [], '车次', '#a582ff');
+      const daily = data.dailyVolumes || [];
+      const trend = this.init('dailyVolume');
+      if (trend) trend.setOption({ tooltip: { trigger: 'axis', valueFormatter: value => `${number(value).toLocaleString()} m³` }, grid: { left: 58, right: 18, top: 24, bottom: 42 }, xAxis: { type: 'category', data: daily.map(row => row.statDate), axisLabel: { color: '#9bc7eb' }, axisLine: { lineStyle: { color: '#197abb' } } }, yAxis: { type: 'value', name: 'm³', nameTextStyle: { color: '#9bc7eb' }, axisLabel: { color: '#9bc7eb' }, splitLine: { lineStyle: { color: 'rgba(105,175,226,.22)', type: 'dashed' } } }, series: [{ name: '浇筑方量', type: 'line', smooth: true, data: daily.map(row => number(row.value)), symbolSize: 7, lineStyle: { color: '#20b7ff' }, itemStyle: { color: '#20b7ff' }, areaStyle: { color: 'rgba(32,183,255,.22)' } }] });
+      const planRows = (data.customerPlanActual || []).slice(0, 10);
+      const plan = this.init('planActual');
+      if (plan) plan.setOption({ tooltip: { trigger: 'axis', valueFormatter: value => `${number(value).toLocaleString()} m³` }, legend: { top: 0, textStyle: { color: '#a9cdeb' } }, grid: { left: 58, right: 18, top: 42, bottom: 56 }, xAxis: { type: 'category', data: planRows.map(row => row.name), axisLabel: { color: '#9bc7eb', interval: 0, rotate: planRows.length > 5 ? 28 : 0 }, axisLine: { lineStyle: { color: '#197abb' } } }, yAxis: { type: 'value', name: 'm³', nameTextStyle: { color: '#9bc7eb' }, axisLabel: { color: '#9bc7eb' }, splitLine: { lineStyle: { color: 'rgba(105,175,226,.22)', type: 'dashed' } } }, series: [{ name: '计划浇筑', type: 'bar', data: planRows.map(row => number(row.plannedVolume)), barMaxWidth: 25, itemStyle: { color: '#ffae42', borderRadius: [4, 4, 0, 0] } }, { name: '实际浇筑', type: 'bar', data: planRows.map(row => number(row.actualVolume)), barMaxWidth: 25, itemStyle: { color: '#20b7ff', borderRadius: [4, 4, 0, 0] } }] });
+    }
+  }
+};
+</script>
+
+<style scoped>
+.page { min-height: 100vh; padding: 16px; color: #d8ecff; background: #020d28; background-image: linear-gradient(rgba(31,93,157,.12) 1px, transparent 1px), linear-gradient(90deg, rgba(31,93,157,.12) 1px, transparent 1px); background-size: 32px 32px; }
+.header { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding-bottom: 12px; border-bottom: 1px solid #167fcd; }
+h2 { margin: 0; color: #fff; letter-spacing: 3px; }.note { margin: 12px 0 0; color: #87b7da; font-size: 12px; }
+main { display: grid; grid-template-columns: repeat(3, minmax(280px, 1fr)); gap: 12px; margin-top: 14px; }.panel { height: 280px; padding: 10px; border: 1px solid #167fcd; background: rgba(4,37,88,.86); }h3 { margin: 0; color: #e6f6ff; font-size: 14px; }h3 span { margin-left: 8px; color: #79a9ce; font-weight: normal; font-size: 12px; }.chart { width: 100%; height: 245px; }:deep(.el-input__inner) { background: #06265b; border-color: #167fcd; color: #d8ecff; }:deep(.el-button) { background: #06488e; border-color: #168ee1; color: #fff; }@media (max-width:1000px) { main { grid-template-columns: 1fr; }.header { flex-wrap: wrap; } }
+</style>
+
+

+ 349 - 0
src/views/tianzhu/productionDashboard.vue

@@ -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>
+
+
+
+
+
+
+
+
+
+

+ 49 - 0
src/views/tianzhu/productionStatistics.vue

@@ -0,0 +1,49 @@
+<template>
+  <div class="page">
+    <header>
+      <el-button icon="el-icon-back" @click="$router.push('/tianzhu/production')">返回总览</el-button>
+      <h2>生产方量统计</h2>
+      <div><el-date-picker v-model="months" type="monthrange" value-format="yyyy-MM" :clearable="false" /><el-button type="primary" @click="load">查询</el-button></div>
+    </header>
+    <el-alert v-if="error" type="error" :title="error" :closable="false" />
+    <main><section v-for="chart in chartItems" :key="chart.key"><h3>{{ chart.title }}</h3><div :ref="chart.key" class="chart"></div></section></main>
+  </div>
+</template>
+<script>
+import * as echarts from 'echarts';
+import { getProductionStatistics } from '@/api/tianzhu/production';
+const pad = n => String(n).padStart(2, '0');
+const now = () => { const d = new Date(); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}`; };
+const value = number => Number(number || 0);
+export default {
+  name: 'TianzhuProductionStatistics',
+  data() { const month = now(); return { months: [month, month], data: {}, error: '', charts: [] }; },
+  computed: { chartItems() { return [
+    { key: 'lineVolume', title: '各生产线方量对比(单位:m³)' }, { key: 'linePlates', title: '各生产线盘数对比(单位:盘)' },
+    { key: 'lineTrips', title: '各生产线车次对比(单位:车次)' }, { key: 'gradePie', title: '混凝土标号方量占比(单位:m³)' },
+    { key: 'gradeRank', title: '各标号生产方量排名(单位:m³)' }, { key: 'gradeTrend', title: '各标号日产量趋势(单位:m³)' },
+    { key: 'efficiency', title: '生产线效率排名(单位:m³/盘)' }
+  ]; } },
+  mounted() { window.addEventListener('resize', this.resize); this.load(); },
+  beforeDestroy() { window.removeEventListener('resize', this.resize); this.dispose(); },
+  methods: {
+    query() { const [start, end] = this.months; const [year, month] = end.split('-').map(Number); const next = new Date(year, month, 1); return { startTime: `${start}-01 00:00:00`, endTime: `${next.getFullYear()}-${pad(next.getMonth() + 1)}-01 00:00:00`, mixOrgId: this.$route.query.mixOrgId || null }; },
+    async load() { try { this.error = ''; this.data = await getProductionStatistics(this.query()); this.$nextTick(this.draw); } catch (error) { this.error = error.message || '获取生产统计失败'; } },
+    dispose() { this.charts.forEach(chart => chart.dispose()); this.charts = []; }, resize() { this.charts.forEach(chart => chart.resize()); },
+    init(key) { const node = Array.isArray(this.$refs[key]) ? this.$refs[key][0] : this.$refs[key]; if (!node) return null; const chart = echarts.init(node); this.charts.push(chart); return chart; },
+    bar(key, rows, label, metric, color, unit) { const chart = this.init(key); if (!chart) return; chart.setOption({ tooltip: { trigger: 'axis', valueFormatter: item => `${value(item).toLocaleString()} ${unit}` }, grid: { left: 58, right: 18, top: 24, bottom: 54 }, xAxis: { type: 'category', data: rows.map(row => row[label]), axisLabel: { color: '#9bc7eb', interval: 0, rotate: rows.length > 5 ? 28 : 0 }, axisLine: { lineStyle: { color: '#197abb' } } }, yAxis: { type: 'value', name: unit, nameTextStyle: { color: '#9bc7eb' }, axisLabel: { color: '#9bc7eb' }, splitLine: { lineStyle: { color: 'rgba(105,175,226,.22)', type: 'dashed' } } }, series: [{ type: 'bar', data: rows.map(row => value(row[metric])), barMaxWidth: 34, itemStyle: { color, borderRadius: [4, 4, 0, 0] } }] }); },
+    rank(key, rows, label, metric, color, unit) { const chart = this.init(key); if (!chart) return; const sorted = [...rows].sort((a, b) => value(b[metric]) - value(a[metric])); chart.setOption({ tooltip: { trigger: 'axis', valueFormatter: item => value(item).toLocaleString() + ' ' + unit}, grid: { left: 110, right: 38, top: 20, bottom: 22 }, xAxis: { type: 'value', name: unit, nameTextStyle: { color: '#9bc7eb' }, axisLabel: { color: '#9bc7eb' }, splitLine: { lineStyle: { color: 'rgba(105,175,226,.22)', type: 'dashed' } } }, yAxis: { type: 'category', inverse: true, data: sorted.map(row => row[label]), axisLabel: { color: '#9bc7eb', width: 95, overflow: 'truncate' }, axisLine: { lineStyle: { color: '#197abb' } } }, series: [{ type: 'bar', data: sorted.map(row => value(row[metric])), barMaxWidth: 24, label: { show: true, position: 'right', color: '#d8ecff', formatter: params => value(params.value).toLocaleString() }, itemStyle: { color, borderRadius: [0, 4, 4, 0] } }] }); },
+    draw() {
+      this.dispose(); const lines = this.data.productionLines || []; const grades = this.data.grades || []; const daily = this.data.gradeDailyTrends || [];
+      this.bar('lineVolume', lines, 'lineName', 'productionVolume', '#20b7ff', 'm³'); this.bar('linePlates', lines, 'lineName', 'productionPlates', '#36e0a4', '盘'); this.bar('lineTrips', lines, 'lineName', 'productionTrips', '#ffae42', '车次'); this.bar('gradeRank', grades, 'goodMark', 'productionVolume', '#3a9cff', 'm³'); this.rank('efficiency', this.data.lineEfficiencies || [], 'lineName', 'efficiency', '#ffc54a', 'm³/盘');
+      const pie = this.init('gradePie'); if (pie) pie.setOption({ tooltip: { trigger: 'item', valueFormatter: item => `${value(item).toLocaleString()} m³` }, legend: { bottom: 0, textStyle: { color: '#a9cdeb' } }, series: [{ type: 'pie', radius: ['42%', '67%'], data: grades.map(row => ({ name: row.goodMark, value: value(row.productionVolume) })), label: { color: '#d7edff' } }] });
+      const dates = [...new Set(daily.map(row => row.statDate))]; const marks = [...new Set(daily.map(row => row.goodMark))]; const trend = this.init('gradeTrend'); if (trend) trend.setOption({ tooltip: { trigger: 'axis', valueFormatter: item => `${value(item).toLocaleString()} m³` }, legend: { data: marks, textStyle: { color: '#a9cdeb' } }, grid: { left: 58, right: 18, top: 40, bottom: 35 }, xAxis: { type: 'category', data: dates, axisLabel: { color: '#9bc7eb' }, axisLine: { lineStyle: { color: '#197abb' } } }, yAxis: { type: 'value', name: 'm³', nameTextStyle: { color: '#9bc7eb' }, axisLabel: { color: '#9bc7eb' }, splitLine: { lineStyle: { color: 'rgba(105,175,226,.22)', type: 'dashed' } } }, series: marks.map(mark => ({ name: mark, type: 'line', smooth: true, data: dates.map(date => value((daily.find(row => row.statDate === date && row.goodMark === mark) || {}).productionVolume)), areaStyle: { opacity: .08 } })) });
+    }
+  }
+};
+</script>
+<style scoped>
+.page{min-height:100vh;padding:16px;background:#020d28;color:#d8ecff;background-image:linear-gradient(rgba(31,93,157,.12) 1px,transparent 1px),linear-gradient(90deg,rgba(31,93,157,.12) 1px,transparent 1px);background-size:32px 32px}header{display:flex;align-items:center;justify-content:space-between;padding-bottom:12px;border-bottom:1px solid #167fcd}h2{margin:0;color:#fff;letter-spacing:3px}main{display:grid;grid-template-columns:repeat(3,minmax(280px,1fr));gap:12px;margin-top:14px}section{height:280px;padding:10px;border:1px solid #167fcd;background:rgba(4,37,88,.86)}h3{margin:0;color:#e6f6ff;font-size:14px}.chart{height:245px;width:100%}:deep(.el-input__inner){background:#06265b;border-color:#167fcd;color:#d8ecff}:deep(.el-button){background:#06488e;border-color:#168ee1;color:#fff}@media(max-width:1000px){main{grid-template-columns:1fr}header{gap:10px;flex-wrap:wrap}}
+</style>
+
+

+ 19 - 0
src/views/tianzhu/productionTripStatistics.vue

@@ -0,0 +1,19 @@
+<template>
+  <div class="page"><header class="header"><el-button icon="el-icon-back" @click="$router.push('/tianzhu/production')">返回总览</el-button><h2>生产车次分析</h2><div><el-date-picker v-model="months" type="monthrange" value-format="yyyy-MM" :clearable="false" range-separator="至" /><el-button type="primary" icon="el-icon-search" @click="load">查询</el-button></div></header><p class="note">生产指标按生产完成时间统计;司机运输指标按发货时间统计。</p><el-alert v-if="error" type="error" :title="error" :closable="false" /><main><section v-for="item in chartItems" :key="item.key" class="panel"><h3>{{ item.title }}<span>{{ item.unit }}</span></h3><div :ref="item.key" class="chart"></div></section></main></div>
+</template>
+<script>
+import * as echarts from 'echarts';
+import { getProductionTripStatistics } from '@/api/tianzhu/production';
+const pad = n => String(n).padStart(2, '0'); const month = () => { const d = new Date(); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}`; }; const val = v => Number(v || 0);
+export default { name: 'TianzhuProductionTripStatistics', data() { const current = month(); return { months: [current, current], data: {}, error: '', charts: [] }; }, computed: { chartItems() { return [
+  { key: 'dispatcherTrips', title: '调度员生产次数排名', unit: '单位:车次' }, { key: 'dispatcherVolumes', title: '调度员生产方量排名', unit: '单位:m³' }, { key: 'driverTrips', title: '司机运输次数排名', unit: '单位:车次' }, { key: 'driverVolumes', title: '司机运输方量排名', unit: '单位:m³' }, { key: 'customerVolumes', title: '各客户生产方量对比', unit: '单位:m³' }, { key: 'projectVolumes', title: '各工程生产方量对比', unit: '单位:m³' }, { key: 'pouringWay', title: '浇筑方式生产占比', unit: '单位:m³' }
+]; } }, mounted() { window.addEventListener('resize', this.resize); this.load(); }, beforeDestroy() { window.removeEventListener('resize', this.resize); this.dispose(); }, methods: {
+  query() { const [start, end] = this.months; const [year, mon] = end.split('-').map(Number); const next = new Date(year, mon, 1); return { startTime: `${start}-01 00:00:00`, endTime: `${next.getFullYear()}-${pad(next.getMonth() + 1)}-01 00:00:00`, mixOrgId: this.$route.query.mixOrgId || null }; },
+  async load() { this.error = ''; try { this.data = await getProductionTripStatistics(this.query()); this.$nextTick(this.draw); } catch (e) { this.error = e.message || '获取生产车次分析数据失败'; } }, dispose() { this.charts.forEach(c => c.dispose()); this.charts = []; }, resize() { this.charts.forEach(c => c.resize()); }, init(key) { const node = Array.isArray(this.$refs[key]) ? this.$refs[key][0] : this.$refs[key]; if (!node) return null; const chart = echarts.init(node); this.charts.push(chart); return chart; },
+  bar(key, rows, unit, color) { const chart = this.init(key); if (!chart) return; chart.setOption({ tooltip: { trigger: 'axis', valueFormatter: value => `${val(value).toLocaleString()} ${unit}` }, grid: { left: 58, right: 18, top: 24, bottom: 58 }, xAxis: { type: 'category', data: rows.map(r => r.name), axisLabel: { color: '#9bc7eb', interval: 0, rotate: rows.length > 5 ? 28 : 0 }, axisLine: { lineStyle: { color: '#197abb' } } }, yAxis: { type: 'value', name: unit, nameTextStyle: { color: '#9bc7eb' }, axisLabel: { color: '#9bc7eb' }, splitLine: { lineStyle: { color: 'rgba(105,175,226,.22)', type: 'dashed' } } }, series: [{ type: 'bar', data: rows.map(r => val(r.value)), barMaxWidth: 34, itemStyle: { color, borderRadius: [4, 4, 0, 0] } }] }); },
+  draw() { this.dispose(); const d = this.data || {}; this.bar('dispatcherTrips', d.dispatcherProductionTrips || [], '车次', '#20b7ff'); this.bar('dispatcherVolumes', d.dispatcherProductionVolumes || [], 'm³', '#36e0a4'); this.bar('driverTrips', d.driverTransportTrips || [], '车次', '#a582ff'); this.bar('driverVolumes', d.driverTransportVolumes || [], 'm³', '#ffae42'); this.bar('customerVolumes', d.customerProductionVolumes || [], 'm³', '#20b7ff'); this.bar('projectVolumes', d.projectProductionVolumes || [], 'm³', '#36e0a4'); const rows = d.pouringWayProductionVolumes || []; const pie = this.init('pouringWay'); if (pie) pie.setOption({ tooltip: { trigger: 'item', valueFormatter: value => `${val(value).toLocaleString()} m³` }, legend: { bottom: 0, textStyle: { color: '#a9cdeb' } }, series: [{ type: 'pie', radius: ['42%', '67%'], data: rows.map(r => ({ name: r.name, value: val(r.value) })), label: { color: '#d7edff', formatter: '{b}\n{d}%' } }] }); }
+} };
+</script>
+<style scoped>
+.page{min-height:100vh;padding:16px;color:#d8ecff;background:#020d28;background-image:linear-gradient(rgba(31,93,157,.12) 1px,transparent 1px),linear-gradient(90deg,rgba(31,93,157,.12) 1px,transparent 1px);background-size:32px 32px}.header{display:flex;align-items:center;justify-content:space-between;gap:12px;padding-bottom:12px;border-bottom:1px solid #167fcd}h2{margin:0;color:#fff;letter-spacing:3px}.note{margin:12px 0 0;color:#87b7da;font-size:12px}main{display:grid;grid-template-columns:repeat(3,minmax(280px,1fr));gap:12px;margin-top:14px}.panel{height:280px;padding:10px;border:1px solid #167fcd;background:rgba(4,37,88,.86)}h3{margin:0;color:#e6f6ff;font-size:14px}h3 span{margin-left:8px;color:#79a9ce;font-weight:normal;font-size:12px}.chart{height:245px;width:100%}:deep(.el-input__inner){background:#06265b;border-color:#167fcd;color:#d8ecff}:deep(.el-button){background:#06488e;border-color:#168ee1;color:#fff}@media(max-width:1000px){main{grid-template-columns:1fr}.header{flex-wrap:wrap}}
+</style>

+ 634 - 0
src/views/tianzhu/rawMaterialAnalysis.vue

@@ -0,0 +1,634 @@
+<template>
+  <div class="page">
+    <header class="header">
+      <el-button
+        icon="el-icon-back"
+        @click="$router.push('/tianzhu/raw-material')"
+        >返回总览</el-button
+      >
+      <h2>{{ config.title }}</h2>
+      <div class="filters">
+        <el-date-picker
+          v-model="range"
+          type="daterange"
+          value-format="yyyy-MM-dd"
+          :clearable="false"
+          range-separator="至"
+        />
+        <el-input
+          v-model="rawMaterialName"
+          clearable
+          placeholder="原材料品类名称"
+        />
+        <el-input v-model="supplierName" clearable placeholder="供应商名称" />
+        <el-button
+          type="primary"
+          icon="el-icon-search"
+          :loading="loading"
+          @click="load"
+          >查询</el-button
+        >
+      </div>
+    </header>
+    <p v-if="notice" class="note">{{ notice }}</p>
+    <el-alert v-if="error" type="error" :title="error" :closable="false" />
+    <main>
+      <section v-for="item in config.charts" :key="item.key" class="panel">
+        <h3
+          >{{ item.title }}<span>{{ item.unit }}</span></h3
+        >
+        <div :ref="item.key" class="chart"></div>
+      </section>
+    </main>
+  </div>
+</template>
+
+<script>
+  import * as echarts from 'echarts';
+  import {
+    getRawMaterialInventoryMonitoring,
+    getRawMaterialPurchaseAnalysis,
+    getRawMaterialSupplierAnalysis,
+    getRawMaterialTransportWeighingAnalysis
+  } from '@/api/tianzhu/rawMaterial';
+
+  const pad = (n) => String(n).padStart(2, '0');
+  const formatDate = (d) =>
+    d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate());
+  const value = (v) => Number(v || 0);
+  const todayRange = () => {
+    const now = new Date();
+    return [
+      formatDate(new Date(now.getFullYear(), now.getMonth(), 1)),
+      formatDate(now)
+    ];
+  };
+  const dayOf = (time) => String(time || '').slice(0, 10);
+  const previousDay = (time) => {
+    const d = new Date(dayOf(time) + 'T00:00:00');
+    d.setDate(d.getDate() - 1);
+    return formatDate(d);
+  };
+
+  const pageConfig = {
+    supplier: {
+      title: '供应商分析',
+      loader: getRawMaterialSupplierAnalysis,
+      noticeKey: 'supplyQuantityNotice',
+      charts: [
+        {
+          key: 'supplierRanks',
+          title: '供应商供货量排名',
+          unit: '单位:t',
+          type: 'rank',
+          rows: 'supplierRanks'
+        },
+        {
+          key: 'supplierCompare',
+          title: '供应商净重与供货数量对比',
+          unit: '净重(t) / 供货数量',
+          type: 'compare',
+          rows: 'supplierQuantityComparisons'
+        }
+      ]
+    },
+    material: {
+      title: '原材料采购分析',
+      loader: getRawMaterialPurchaseAnalysis,
+      charts: [
+        {
+          key: 'materialPurchase',
+          title: '各原材料采购量对比',
+          unit: '单位:t',
+          type: 'bar',
+          rows: 'materialPurchaseVolumes'
+        },
+        {
+          key: 'warehouseInbound',
+          title: '储料仓库存分布',
+          unit: '单位:t',
+          type: 'pie',
+          rows: 'warehouseInboundDistributions'
+        }
+      ]
+    },
+    inventory: {
+      title: '库存监控',
+      loader: getRawMaterialInventoryMonitoring,
+      noticeKey: 'inventoryBalanceNotice',
+      charts: [
+        {
+          key: 'balanceTrend',
+          title: '库存余额趋势',
+          unit: '单位:t',
+          type: 'area',
+          rows: 'inventoryBalanceTrends'
+        },
+        {
+          key: 'inboundConsumption',
+          title: '进料 vs 消耗对比',
+          unit: '单位:t',
+          type: 'inout',
+          rows: 'inboundConsumptionTrends'
+        }
+      ]
+    },
+    transport: {
+      title: '运输与过磅分析',
+      loader: getRawMaterialTransportWeighingAnalysis,
+      charts: [
+        {
+          key: 'vehicleRank',
+          title: '车辆运输量排名',
+          unit: '单位:t',
+          type: 'rank',
+          rows: 'vehicleTransportRanks'
+        },
+        {
+          key: 'receiptWay',
+          title: '收料方式占比',
+          unit: '单位:车次',
+          type: 'pie',
+          rows: 'receiptWayRatios'
+        },
+        {
+          key: 'dailyTrips',
+          title: '每日车次趋势',
+          unit: '单位:车次',
+          type: 'line',
+          rows: 'dailyPurchaseTrips'
+        }
+      ]
+    }
+  };
+
+  export default {
+    name: 'TianzhuRawMaterialAnalysis',
+    data() {
+      const q = this.$route.query;
+      const range =
+        q.startTime && q.endTime
+          ? [dayOf(q.startTime), previousDay(q.endTime)]
+          : todayRange();
+      return {
+        range,
+        rawMaterialName: q.rawMaterialName || '',
+        supplierName: q.supplierName || '',
+        data: {},
+        error: '',
+        loading: false,
+        charts: [],
+        drawTimer: null,
+        resizeTimer: null,
+        lateResizeTimer: null
+      };
+    },
+    computed: {
+      type() {
+        return this.$route.meta.drillType || 'supplier';
+      },
+      config() {
+        return pageConfig[this.type] || pageConfig.supplier;
+      },
+      notice() {
+        return this.config.noticeKey ? this.data[this.config.noticeKey] : '';
+      }
+    },
+    watch: {
+      '$route.meta.drillType'() {
+        this.load();
+      }
+    },
+    mounted() {
+      window.addEventListener('resize', this.resize);
+      this.load();
+    },
+    activated() {
+      this.scheduleDraw();
+    },
+    beforeDestroy() {
+      window.removeEventListener('resize', this.resize);
+      clearTimeout(this.drawTimer);
+      clearTimeout(this.resizeTimer);
+      clearTimeout(this.lateResizeTimer);
+      this.dispose();
+    },
+    methods: {
+      query() {
+        const end = new Date(this.range[1] + 'T00:00:00');
+        end.setDate(end.getDate() + 1);
+        const rawMaterialName = this.rawMaterialName.trim();
+        const supplierName = this.supplierName.trim();
+        return {
+          startTime: this.range[0] + ' 00:00:00',
+          endTime: formatDate(end) + ' 00:00:00',
+          mixOrgId: this.$route.query.mixOrgId || undefined,
+          rawMaterialName: rawMaterialName || undefined,
+          supplierName: supplierName || undefined
+        };
+      },
+      async load() {
+        if (this.loading || !this.range || this.range.length !== 2) return;
+        this.loading = true;
+        this.error = '';
+        try {
+          this.data = (await this.config.loader(this.query())) || {};
+          this.scheduleDraw();
+        } catch (e) {
+          this.error = e.message || '获取原材料下钻数据失败';
+        } finally {
+          this.loading = false;
+        }
+      },
+      scheduleDraw() {
+        clearTimeout(this.drawTimer);
+        clearTimeout(this.resizeTimer);
+        clearTimeout(this.lateResizeTimer);
+        this.$nextTick(() => {
+          this.drawTimer = setTimeout(() => {
+            this.draw();
+            this.resizeTimer = setTimeout(this.resize, 120);
+            this.lateResizeTimer = setTimeout(this.resize, 500);
+          }, 80);
+        });
+      },
+      dispose() {
+        this.charts.forEach((chart) => chart.dispose());
+        this.charts = [];
+      },
+      resize() {
+        this.charts.forEach((chart) => chart.resize());
+      },
+      init(key, rows) {
+        const node = Array.isArray(this.$refs[key])
+          ? this.$refs[key][0]
+          : this.$refs[key];
+        if (!node) return null;
+        const chart = echarts.init(node);
+        if (!rows.length)
+          chart.setOption({
+            graphic: [
+              {
+                type: 'text',
+                left: 'center',
+                top: 'middle',
+                style: { text: '暂无数据', fill: '#83acd0', fontSize: 14 }
+              }
+            ]
+          });
+        this.charts.push(chart);
+        return chart;
+      },
+      axis(unit) {
+        return {
+          type: 'value',
+          name: unit,
+          nameTextStyle: { color: '#9bc7eb' },
+          axisLabel: { color: '#9bc7eb' },
+          splitLine: {
+            lineStyle: { color: 'rgba(105,175,226,.22)', type: 'dashed' }
+          }
+        };
+      },
+      draw() {
+        this.dispose();
+        this.config.charts.forEach((item) => {
+          const rows = Array.isArray(this.data[item.rows])
+            ? this.data[item.rows]
+            : [];
+          if (item.type === 'rank') this.rank(item.key, rows, 't');
+          if (item.type === 'bar') this.bar(item.key, rows, 't');
+          if (item.type === 'pie') this.pie(item.key, rows);
+          if (item.type === 'line') this.line(item.key, rows, '车次');
+          if (item.type === 'area') this.area(item.key, rows);
+          if (item.type === 'inout') this.inout(item.key, rows);
+          if (item.type === 'compare') this.compare(item.key, rows);
+        });
+      },
+      rank(key, rows, unit) {
+        const chart = this.init(key, rows);
+        if (!chart || !rows.length) return;
+        const sorted = [...rows].sort(
+          (a, b) => value(b.value) - value(a.value)
+        );
+        chart.setOption({
+          color: ['#20b7ff'],
+          tooltip: {
+            trigger: 'axis',
+            valueFormatter: (v) => value(v).toLocaleString() + ' ' + unit
+          },
+          grid: { left: 128, right: 42, top: 22, bottom: 26 },
+          xAxis: this.axis(unit),
+          yAxis: {
+            type: 'category',
+            inverse: true,
+            data: sorted.map((row) => row.name || '未标注'),
+            axisLabel: { color: '#9bc7eb', width: 116, overflow: 'truncate' },
+            axisLine: { lineStyle: { color: '#197abb' } }
+          },
+          series: [
+            {
+              type: 'bar',
+              data: sorted.map((row) => value(row.value)),
+              barMaxWidth: 24,
+              label: {
+                show: true,
+                position: 'right',
+                color: '#d8ecff',
+                formatter: (p) => value(p.value).toLocaleString()
+              },
+              itemStyle: { borderRadius: [0, 4, 4, 0] }
+            }
+          ]
+        });
+      },
+      bar(key, rows, unit) {
+        const chart = this.init(key, rows);
+        if (!chart || !rows.length) return;
+        chart.setOption({
+          color: ['#36e0a4'],
+          tooltip: {
+            trigger: 'axis',
+            valueFormatter: (v) => value(v).toLocaleString() + ' ' + unit
+          },
+          grid: { left: 58, right: 18, top: 26, bottom: 62 },
+          xAxis: {
+            type: 'category',
+            data: rows.map((row) => row.name || '未标注'),
+            axisLabel: {
+              color: '#9bc7eb',
+              interval: 0,
+              rotate: rows.length > 5 ? 28 : 0
+            },
+            axisLine: { lineStyle: { color: '#197abb' } }
+          },
+          yAxis: this.axis(unit),
+          series: [
+            {
+              type: 'bar',
+              data: rows.map((row) => value(row.value)),
+              barMaxWidth: 34,
+              itemStyle: { borderRadius: [4, 4, 0, 0] }
+            }
+          ]
+        });
+      },
+      pie(key, rows) {
+        const chart = this.init(key, rows);
+        if (!chart || !rows.length) return;
+        chart.setOption({
+          color: ['#20b7ff', '#36e0a4', '#ffc54a', '#a582ff', '#ff7a7a'],
+          tooltip: { trigger: 'item' },
+          legend: {
+            bottom: 0,
+            type: 'scroll',
+            textStyle: { color: '#a9cdeb' }
+          },
+          series: [
+            {
+              type: 'pie',
+              radius: ['42%', '67%'],
+              data: rows.map((row) => ({
+                name: row.name || '未标注',
+                value: value(row.value)
+              })),
+              label: { color: '#d7edff', formatter: '{b}\n{d}%' }
+            }
+          ]
+        });
+      },
+      line(key, rows, unit) {
+        const chart = this.init(key, rows);
+        if (!chart || !rows.length) return;
+        chart.setOption({
+          color: ['#20b7ff'],
+          tooltip: {
+            trigger: 'axis',
+            valueFormatter: (v) => value(v).toLocaleString() + ' ' + unit
+          },
+          grid: { left: 58, right: 18, top: 28, bottom: 36 },
+          xAxis: {
+            type: 'category',
+            data: rows.map((row) => row.statDate),
+            axisLabel: { color: '#9bc7eb' },
+            axisLine: { lineStyle: { color: '#197abb' } }
+          },
+          yAxis: this.axis(unit),
+          series: [
+            {
+              type: 'line',
+              smooth: true,
+              showSymbol: false,
+              areaStyle: { opacity: 0.14 },
+              data: rows.map((row) => value(row.value))
+            }
+          ]
+        });
+      },
+      area(key, rows) {
+        const chart = this.init(key, rows);
+        if (!chart || !rows.length) return;
+        chart.setOption({
+          color: ['#ffc54a'],
+          tooltip: {
+            trigger: 'axis',
+            valueFormatter: (v) => value(v).toLocaleString() + ' t'
+          },
+          grid: { left: 58, right: 18, top: 28, bottom: 36 },
+          xAxis: {
+            type: 'category',
+            data: rows.map((row) => row.statDate),
+            axisLabel: { color: '#9bc7eb' },
+            axisLine: { lineStyle: { color: '#197abb' } }
+          },
+          yAxis: this.axis('t'),
+          series: [
+            {
+              type: 'line',
+              smooth: true,
+              showSymbol: false,
+              areaStyle: { opacity: 0.22 },
+              data: rows.map((row) => value(row.endingInventory || row.value))
+            }
+          ]
+        });
+      },
+      inout(key, rows) {
+        const chart = this.init(key, rows);
+        if (!chart || !rows.length) return;
+        chart.setOption({
+          color: ['#20b7ff', '#36e0a4'],
+          tooltip: {
+            trigger: 'axis',
+            valueFormatter: (v) => value(v).toLocaleString() + ' t'
+          },
+          legend: { top: 0, textStyle: { color: '#a9cdeb' } },
+          grid: { left: 58, right: 18, top: 42, bottom: 36 },
+          xAxis: {
+            type: 'category',
+            data: rows.map((row) => row.statDate),
+            axisLabel: { color: '#9bc7eb' },
+            axisLine: { lineStyle: { color: '#197abb' } }
+          },
+          yAxis: this.axis('t'),
+          series: [
+            {
+              name: '进料',
+              type: 'bar',
+              barMaxWidth: 28,
+              data: rows.map((row) => value(row.purchaseVolume))
+            },
+            {
+              name: '消耗',
+              type: 'bar',
+              barMaxWidth: 28,
+              data: rows.map((row) => value(row.consumptionVolume))
+            }
+          ]
+        });
+      },
+      compare(key, rows) {
+        const chart = this.init(key, rows);
+        if (!chart || !rows.length) return;
+        chart.setOption({
+          color: ['#20b7ff', '#ffc54a'],
+          tooltip: { trigger: 'axis' },
+          legend: { top: 0, textStyle: { color: '#a9cdeb' } },
+          grid: { left: 58, right: 18, top: 42, bottom: 62 },
+          xAxis: {
+            type: 'category',
+            data: rows.map((row) => row.name || '未标注'),
+            axisLabel: {
+              color: '#9bc7eb',
+              interval: 0,
+              rotate: rows.length > 5 ? 28 : 0
+            },
+            axisLine: { lineStyle: { color: '#197abb' } }
+          },
+          yAxis: this.axis(''),
+          series: [
+            {
+              name: '净重(t)',
+              type: 'bar',
+              barMaxWidth: 26,
+              data: rows.map((row) => value(row.value))
+            },
+            {
+              name: '供货数量',
+              type: 'bar',
+              barMaxWidth: 26,
+              data: rows.map((row) => value(row.compareValue))
+            }
+          ]
+        });
+      }
+    }
+  };
+</script>
+
+<style scoped>
+  .page {
+    min-height: 100vh;
+    padding: 16px;
+    color: #d8ecff;
+    background: #020d28;
+    background-image: linear-gradient(
+        rgba(31, 93, 157, 0.12) 1px,
+        transparent 1px
+      ),
+      linear-gradient(90deg, rgba(31, 93, 157, 0.12) 1px, transparent 1px);
+    background-size: 32px 32px;
+  }
+  .header {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    gap: 12px;
+    padding-bottom: 12px;
+    border-bottom: 1px solid #167fcd;
+  }
+  h2 {
+    margin: 0;
+    color: #fff;
+    letter-spacing: 3px;
+  }
+  .filters {
+    display: flex;
+    gap: 8px;
+    align-items: center;
+    justify-content: flex-end;
+    flex-wrap: wrap;
+  }
+  .filters .el-date-editor {
+    width: 260px;
+  }
+  .filters .el-input {
+    width: 170px;
+  }
+  .note {
+    margin: 12px 0 0;
+    color: #87b7da;
+    font-size: 12px;
+  }
+  main {
+    display: grid;
+    grid-template-columns: repeat(2, minmax(320px, 1fr));
+    gap: 12px;
+    margin-top: 14px;
+  }
+  .panel {
+    height: 310px;
+    padding: 10px;
+    border: 1px solid #167fcd;
+    background: rgba(4, 37, 88, 0.86);
+  }
+  h3 {
+    margin: 0;
+    color: #e6f6ff;
+    font-size: 14px;
+  }
+  h3 span {
+    margin-left: 8px;
+    color: #79a9ce;
+    font-weight: normal;
+    font-size: 12px;
+  }
+  .chart {
+    height: 275px;
+    width: 100%;
+  }
+  :deep(.el-input__inner) {
+    background: #06265b;
+    border-color: #167fcd;
+    color: #d8ecff;
+  }
+  :deep(.el-range-input) {
+    background: transparent;
+    color: #d8ecff;
+  }
+  :deep(.el-range-separator),
+  :deep(.el-input__icon) {
+    color: #a9cdeb;
+  }
+  :deep(.el-button) {
+    background: #06488e;
+    border-color: #168ee1;
+    color: #fff;
+  }
+  @media (max-width: 1000px) {
+    main {
+      grid-template-columns: 1fr;
+    }
+    .header {
+      align-items: flex-start;
+      flex-direction: column;
+    }
+    .filters {
+      justify-content: flex-start;
+    }
+    .filters .el-date-editor,
+    .filters .el-input {
+      width: 245px;
+    }
+  }
+</style>

+ 706 - 0
src/views/tianzhu/rawMaterialDashboard.vue

@@ -0,0 +1,706 @@
+<template>
+  <div
+    class="raw-cockpit"
+    v-loading="loading"
+    element-loading-background="rgba(2,18,43,.76)"
+  >
+    <header
+      ><h1>采购总览驾驶舱</h1
+      ><div class="status"
+        ><i class="el-icon-sunny"></i>{{ clock
+        }}<small>数据最后更新时间:{{ data.lastDataTime || '—' }}</small></div
+      ></header
+    >
+    <section class="toolbar">
+      <el-date-picker
+        v-model="range"
+        type="daterange"
+        value-format="yyyy-MM-dd"
+        range-separator="至"
+        :clearable="false"
+        size="small"
+      />
+      <el-select
+        v-model="mixOrgId"
+        clearable
+        placeholder="厂区选择"
+        size="small"
+        ><el-option
+          v-for="item in plants"
+          :key="item.id"
+          :label="item.name"
+          :value="String(item.id)"
+      /></el-select>
+      <el-input
+        v-model="rawMaterialName"
+        clearable
+        placeholder="原材料品类名称"
+        size="small"
+      />
+      <el-input
+        v-model="supplierName"
+        clearable
+        placeholder="供应商名称"
+        size="small"
+      />
+      <el-button type="primary" size="small" :loading="loading" @click="load"
+        >查询</el-button
+      ><el-button size="small" @click="reset">重置</el-button
+      ><el-button size="small" @click="load">刷新数据</el-button
+      ><el-button size="small" :disabled="!loaded" @click="exportData"
+        >导出图表</el-button
+      >
+      <span>库存及消耗为全供应商口径</span>
+    </section>
+    <el-alert
+      v-if="error"
+      :title="error"
+      type="error"
+      show-icon
+      :closable="false"
+    />
+    <section class="kpis"
+      ><article
+        v-for="card in cards"
+        :key="card.key"
+        :class="{ clickable: card.path }"
+        @click="open(card)"
+        ><label>{{ card.label }}</label
+        ><strong
+          >{{ loaded ? number(overview[card.key]) : '—'
+          }}<small>{{ card.unit }}</small></strong
+        ></article
+      ></section
+    >
+    <main>
+      <article
+        class="panel"
+        @click="go('/tianzhu/raw-material-transport-analysis')"
+        ><h2>日采购量趋势 <small>单位(t)</small></h2
+        ><div ref="purchase" class="chart"></div
+      ></article>
+      <article
+        class="panel"
+        @click="go('/tianzhu/raw-material-purchase-analysis')"
+        ><h2>原材料采购结构占比</h2><div ref="structure" class="chart"></div
+      ></article>
+      <article
+        class="panel ranking"
+        @click="go('/tianzhu/raw-material-supplier-analysis')"
+        ><h2>Top5供应商供货量</h2
+        ><ol
+          ><li v-for="(item, index) in suppliers" :key="item.id || item.name"
+            ><b>Top{{ index + 1 }}</b
+            ><span>{{ item.name || '未标注供应商' }}</span
+            ><em>{{ number(item.value) }}t</em></li
+          ><li v-if="!suppliers.length" class="empty">暂无数据</li></ol
+        ></article
+      >
+      <article
+        class="panel full"
+        @click="go('/tianzhu/raw-material-inventory-monitoring')"
+        ><h2>库存进出存趋势 <small>进料 / 消耗(t)</small></h2
+        ><div ref="inventory" class="chart"></div
+      ></article>
+    </main>
+    <footer
+      >采购统计按有效采购收料单聚合 ·
+      采购日期取两次过磅时间中较晚的有效时间</footer
+    >
+  </div>
+</template>
+
+<script>
+  import * as echarts from 'echarts';
+  import * as XLSX from 'xlsx';
+  import { getRawMaterialDashboard } from '@/api/tianzhu/rawMaterial';
+  import { getProductionMixOrgs } from '@/api/tianzhu/production';
+  const pad = (n) => String(n).padStart(2, '0');
+  const date = (d) =>
+    d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate());
+  const initialRange = () => {
+    const now = new Date();
+    return [date(new Date(now.getFullYear(), now.getMonth(), 1)), date(now)];
+  };
+  export default {
+    name: 'TianzhuRawMaterialDashboard',
+    data() {
+      return {
+        range: initialRange(),
+        mixOrgId: '',
+        rawMaterialName: '',
+        supplierName: '',
+        plants: [],
+        loading: false,
+        loaded: false,
+        error: '',
+        clock: '',
+        charts: [],
+        data: {
+          overview: {},
+          dailyPurchaseTrends: [],
+          materialStructures: [],
+          supplierTop5: [],
+          inventoryInOutTrends: []
+        }
+      };
+    },
+    computed: {
+      overview() {
+        return this.data.overview || {};
+      },
+      suppliers() {
+        return this.data.supplierTop5 || [];
+      },
+      cards() {
+        return [
+          { key: 'purchaseVolume', label: '本期采购总量', unit: 't' },
+          { key: 'purchaseTrips', label: '采购车次总数', unit: '车次' },
+          {
+            key: 'supplierCount',
+            label: '合作供应商数',
+            unit: '家',
+            path: '/tianzhu/raw-material-supplier-analysis'
+          },
+          {
+            key: 'materialCount',
+            label: '原材料品类数',
+            unit: '种',
+            path: '/tianzhu/raw-material-purchase-analysis'
+          },
+          {
+            key: 'currentInventory',
+            label: '当前期末库存',
+            unit: 't',
+            path: '/tianzhu/raw-material-inventory-monitoring'
+          },
+          {
+            key: 'consumptionVolume',
+            label: '本期消耗总量',
+            unit: 't',
+            path: '/tianzhu/raw-material-inventory-monitoring'
+          }
+        ];
+      }
+    },
+    mounted() {
+      this.tick();
+      this.timer = setInterval(this.tick, 1000);
+      window.addEventListener('resize', this.resize);
+      getProductionMixOrgs()
+        .then((rows) => {
+          this.plants = rows || [];
+        })
+        .catch(() => {});
+      this.load();
+    },
+    beforeDestroy() {
+      clearInterval(this.timer);
+      window.removeEventListener('resize', this.resize);
+      this.dispose();
+    },
+    methods: {
+      tick() {
+        this.clock = new Date().toLocaleString('zh-CN', { hour12: false });
+      },
+      number(v) {
+        const n = Number(v || 0);
+        return Number.isNaN(n)
+          ? v
+          : n.toLocaleString('zh-CN', { maximumFractionDigits: 2 });
+      },
+      query() {
+        const end = new Date(this.range[1] + 'T00:00:00');
+        end.setDate(end.getDate() + 1);
+        const rawMaterialName = this.rawMaterialName.trim();
+        const supplierName = this.supplierName.trim();
+        return {
+          startTime: this.range[0] + ' 00:00:00',
+          endTime: date(end) + ' 00:00:00',
+          mixOrgId: this.mixOrgId || undefined,
+          rawMaterialName: rawMaterialName || undefined,
+          supplierName: supplierName || undefined
+        };
+      },
+      async load() {
+        if (this.loading || !this.range || this.range.length !== 2) return;
+        this.loading = true;
+        this.error = '';
+        try {
+          this.data = await getRawMaterialDashboard(this.query());
+          this.loaded = true;
+        } catch (e) {
+          this.error = e.message || '获取采购驾驶舱数据失败';
+          this.loaded = false;
+        } finally {
+          this.loading = false;
+          this.$nextTick(this.draw);
+        }
+      },
+      reset() {
+        this.range = initialRange();
+        this.mixOrgId = '';
+        this.rawMaterialName = '';
+        this.supplierName = '';
+        this.load();
+      },
+      open(card) {
+        if (card.path && this.loaded) this.go(card.path);
+      },
+      go(path) {
+        if (this.loaded) this.$router.push({ path, query: this.query() });
+      },
+      dispose() {
+        this.charts.forEach((chart) => chart.dispose());
+        this.charts = [];
+      },
+      resize() {
+        this.charts.forEach((chart) => chart.resize());
+      },
+      axis(name) {
+        return {
+          type: 'value',
+          name: name,
+          nameTextStyle: { color: '#9fc8e5' },
+          axisLabel: { color: '#91b7d7' },
+          splitLine: {
+            lineStyle: { color: 'rgba(145,193,232,.3)', type: 'dashed' }
+          }
+        };
+      },
+      base(labels) {
+        return {
+          tooltip: { trigger: 'axis' },
+          legend: { top: 8, textStyle: { color: '#b5daf1' } },
+          grid: { left: 58, right: 26, top: 46, bottom: 35 },
+          xAxis: {
+            type: 'category',
+            data: labels,
+            axisLabel: { color: '#8eafd1', hideOverlap: true },
+            axisLine: { lineStyle: { color: '#3d729b' } }
+          },
+          yAxis: this.axis('t')
+        };
+      },
+      create(ref, option, hasData) {
+        const chart = echarts.init(this.$refs[ref]);
+        chart.setOption({
+          color: ['#147dff', '#34d7a0', '#ffc52c'],
+          textStyle: { color: '#afd6ef' },
+          graphic: hasData
+            ? []
+            : [
+                {
+                  type: 'text',
+                  left: 'center',
+                  top: 'middle',
+                  style: { text: '暂无数据', fill: '#83acd0', fontSize: 14 }
+                }
+              ],
+          ...option
+        });
+        this.charts.push(chart);
+      },
+      draw() {
+        this.dispose();
+        const daily = this.data.dailyPurchaseTrends || [],
+          structure = this.data.materialStructures || [],
+          inventory = this.data.inventoryInOutTrends || [];
+        this.create(
+          'purchase',
+          {
+            ...this.base(daily.map((x) => x.statDate)),
+            series: [
+              {
+                name: '采购量',
+                type: 'line',
+                smooth: true,
+                showSymbol: false,
+                areaStyle: { opacity: 0.25 },
+                lineStyle: { width: 3 },
+                data: daily.map((x) => x.value)
+              }
+            ]
+          },
+          daily.length > 0
+        );
+        this.create(
+          'structure',
+          {
+            tooltip: { trigger: 'item' },
+            legend: {
+              bottom: 4,
+              type: 'scroll',
+              textStyle: { color: '#b5daf1' }
+            },
+            series: [
+              {
+                type: 'pie',
+                radius: ['38%', '64%'],
+                center: ['50%', '44%'],
+                label: { color: '#d7efff', formatter: '{b}\\n{d}%' },
+                data: structure.map((x) => ({
+                  name: x.name || '未标注',
+                  value: Number(x.value || 0)
+                }))
+              }
+            ]
+          },
+          structure.length > 0
+        );
+        this.create(
+          'inventory',
+          {
+            ...this.base(inventory.map((x) => x.statDate)),
+            series: [
+              {
+                name: '进料',
+                type: 'bar',
+                barMaxWidth: 28,
+                data: inventory.map((x) => x.purchaseVolume)
+              },
+              {
+                name: '消耗',
+                type: 'bar',
+                barMaxWidth: 28,
+                data: inventory.map((x) => x.consumptionVolume)
+              }
+            ]
+          },
+          inventory.length > 0
+        );
+      },
+      exportData() {
+        const book = XLSX.utils.book_new();
+        const sheet = (name, rows) =>
+          XLSX.utils.book_append_sheet(
+            book,
+            XLSX.utils.aoa_to_sheet(rows),
+            name
+          );
+        sheet('采购总览', [
+          ['指标', '数值', '单位'],
+          ...this.cards.map((x) => [x.label, this.overview[x.key], x.unit])
+        ]);
+        sheet('每日采购趋势', [
+          ['日期', '采购量(t)'],
+          ...(this.data.dailyPurchaseTrends || []).map((x) => [
+            x.statDate,
+            x.value
+          ])
+        ]);
+        sheet('采购结构', [
+          ['原材料', '采购量(t)'],
+          ...(this.data.materialStructures || []).map((x) => [x.name, x.value])
+        ]);
+        XLSX.writeFile(
+          book,
+          '采购总览驾驶舱_' + this.range.join('_') + '.xlsx'
+        );
+      }
+    }
+  };
+</script>
+
+<style scoped>
+  .raw-cockpit {
+    min-height: calc(100vh - 50px);
+    padding: 0 18px 18px;
+    box-sizing: border-box;
+    color: #d9f0ff;
+    background: #031733
+      radial-gradient(
+        ellipse at 50% -20%,
+        rgba(11, 114, 199, 0.35),
+        transparent 48%
+      );
+    font-family: 'Microsoft YaHei', Arial, sans-serif;
+  }
+  header {
+    height: 82px;
+    position: relative;
+    display: flex;
+    justify-content: center;
+    border-bottom: 3px solid #0874ae;
+  }
+  header h1 {
+    z-index: 1;
+    margin: 0;
+    padding: 14px 95px 18px;
+    color: #fff;
+    font-size: 31px;
+    letter-spacing: 7px;
+    font-style: italic;
+    text-shadow: 0 0 15px #3bcfff;
+    border: 3px solid #0a91cc;
+    border-top: 0;
+    border-radius: 0 0 64px 64px;
+    background: linear-gradient(180deg, #083661, #041c3f);
+  }
+  .status {
+    position: absolute;
+    right: 2px;
+    top: 15px;
+    color: #d9efff;
+    font-size: 13px;
+  }
+  .status i {
+    margin-right: 7px;
+    color: #ffd467;
+    font-size: 25px;
+  }
+  .status small {
+    display: block;
+    margin: 5px 0 0 32px;
+    color: #8bb9de;
+    font-size: 11px;
+  }
+  .toolbar {
+    display: flex;
+    align-items: center;
+    gap: 9px;
+    flex-wrap: wrap;
+    padding: 18px 0 15px;
+  }
+  .toolbar .el-date-editor {
+    width: 260px;
+  }
+  .toolbar .el-select,
+  .toolbar .el-input {
+    width: 160px;
+  }
+  .toolbar .el-button {
+    margin: 0;
+  }
+  .toolbar > span {
+    margin-left: auto;
+    color: #73abd2;
+    font-size: 12px;
+  }
+  .kpis {
+    display: grid;
+    grid-template-columns: repeat(6, minmax(145px, 1fr));
+    gap: 14px;
+    margin-bottom: 28px;
+  }
+  .kpis article {
+    position: relative;
+    min-height: 88px;
+    padding: 14px 17px;
+    overflow: hidden;
+    border: 1px solid #087fc4;
+    background: linear-gradient(
+      135deg,
+      rgba(7, 48, 105, 0.96),
+      rgba(2, 29, 67, 0.94)
+    );
+    box-shadow: inset 0 0 22px rgba(16, 128, 209, 0.18);
+  }
+  .kpis article:before,
+  .kpis article:after {
+    position: absolute;
+    width: 13px;
+    height: 13px;
+    content: '';
+    border-color: #45d9ff;
+    border-style: solid;
+  }
+  .kpis article:before {
+    left: -1px;
+    top: -1px;
+    border-width: 2px 0 0 2px;
+  }
+  .kpis article:after {
+    right: -1px;
+    bottom: -1px;
+    border-width: 0 2px 2px 0;
+  }
+  .kpis label {
+    color: #c8e7fb;
+    font-size: 14px;
+  }
+  .kpis strong {
+    display: block;
+    margin-top: 9px;
+    color: #fff;
+    font: italic 700 27px/1 Arial, sans-serif;
+    letter-spacing: 1px;
+    text-shadow: 0 0 10px #51cfff;
+  }
+  .kpis small {
+    margin-left: 5px;
+    color: #bcdcf3;
+    font-size: 13px;
+    font-style: normal;
+    font-weight: 400;
+  }
+  .clickable,
+  .panel {
+    cursor: pointer;
+  }
+  .clickable:hover,
+  .panel:hover {
+    outline: 1px solid #49dfff;
+    box-shadow: 0 0 16px rgba(24, 185, 255, 0.24);
+  }
+  main {
+    display: grid;
+    grid-template-columns: 1.1fr 1.1fr 0.72fr;
+    gap: 16px;
+  }
+  .panel {
+    min-width: 0;
+    min-height: 265px;
+    overflow: hidden;
+    border: 1px solid #0b7aba;
+    background: linear-gradient(
+      135deg,
+      rgba(5, 42, 92, 0.92),
+      rgba(3, 25, 60, 0.92)
+    );
+    box-shadow: inset 0 0 25px rgba(10, 118, 193, 0.14);
+  }
+  .panel h2 {
+    height: 39px;
+    margin: 0;
+    padding: 9px 17px;
+    color: #e8f7ff;
+    font-size: 17px;
+    font-style: italic;
+    letter-spacing: 1px;
+    background: linear-gradient(90deg, #07569a, #06467e 82%, transparent);
+  }
+  .panel h2:before {
+    margin-right: 9px;
+    color: #55dcff;
+    content: '◆';
+  }
+  .panel h2 small {
+    float: right;
+    color: #a1c9e7;
+    font-size: 11px;
+    font-style: normal;
+  }
+  .chart {
+    height: 220px;
+  }
+  .ranking ol {
+    margin: 0;
+    padding: 8px 14px;
+    list-style: none;
+  }
+  .ranking li {
+    display: flex;
+    gap: 7px;
+    align-items: center;
+    padding: 10px 0;
+    border-bottom: 1px solid rgba(34, 115, 169, 0.55);
+    font-size: 12px;
+  }
+  .ranking b {
+    min-width: 35px;
+    padding: 2px 5px;
+    border-radius: 9px;
+    background: #1766bd;
+    color: #e6f7ff;
+    font-size: 10px;
+    text-align: center;
+  }
+  .ranking li:first-child b {
+    background: #c55959;
+  }
+  .ranking li:nth-child(2) b {
+    background: #a49125;
+  }
+  .ranking span {
+    flex: 1;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+  }
+  .ranking em {
+    color: #d9eeff;
+    font-style: normal;
+  }
+  .ranking .empty {
+    display: block;
+    padding: 70px 0;
+    color: #83acd0;
+    text-align: center;
+  }
+  .full {
+    grid-column: 1/-1;
+    min-height: 278px;
+  }
+  .full .chart {
+    height: 238px;
+  }
+  footer {
+    padding: 17px 0 0;
+    color: #6c9cc2;
+    font-size: 11px;
+    text-align: center;
+  }
+  .raw-cockpit ::v-deep .el-input__inner {
+    color: #dcefff;
+    border-color: #145894;
+    background: rgba(4, 37, 88, 0.86);
+  }
+  .raw-cockpit ::v-deep .el-range-input {
+    color: #dcefff;
+    background: transparent;
+  }
+  .raw-cockpit ::v-deep .el-range-separator,
+  .raw-cockpit ::v-deep .el-input__icon {
+    color: #b5d7f5;
+  }
+  .raw-cockpit ::v-deep .el-button--default {
+    color: #c2e2ff;
+    border-color: #205b89;
+    background: #0a2d50;
+  }
+  @media (max-width: 1250px) {
+    .kpis {
+      grid-template-columns: repeat(3, 1fr);
+    }
+    main {
+      grid-template-columns: 1fr 1fr;
+    }
+    .ranking {
+      grid-column: 1/-1;
+    }
+  }
+  @media (max-width: 760px) {
+    .raw-cockpit {
+      padding: 0 10px 14px;
+    }
+    header h1 {
+      padding-left: 25px;
+      padding-right: 25px;
+      font-size: 22px;
+      letter-spacing: 3px;
+    }
+    .status {
+      display: none;
+    }
+    .toolbar .el-date-editor {
+      width: 245px;
+    }
+    .toolbar .el-select,
+    .toolbar .el-input {
+      width: 245px;
+    }
+    .kpis,
+    main {
+      grid-template-columns: 1fr;
+    }
+    .ranking,
+    .full {
+      grid-column: auto;
+    }
+    .toolbar > span {
+      width: 100%;
+      margin-left: 0;
+    }
+  }
+</style>

+ 90 - 0
src/views/tianzhu/salesCustomerAnalysis.vue

@@ -0,0 +1,90 @@
+<template>
+  <div class="customer-analysis" v-loading="loading" element-loading-background="rgba(4,23,46,.7)">
+    <header><el-button size="small" icon="el-icon-back" @click="back">返回销售总览</el-button><h1>客户分析</h1><span>单位:m³</span></header>
+    <section class="filters">
+      <el-date-picker v-model="range" type="daterange" value-format="yyyy-MM-dd" :clearable="false" range-separator="至" size="small" />
+      <el-select v-model="mixOrgId" placeholder="全部厂区" clearable size="small"><el-option v-for="p in plants" :key="p.id" :label="p.name" :value="String(p.id)" /></el-select>
+      <el-input v-model="customerName" placeholder="客户名称(精确)" clearable size="small" @keyup.enter.native="load" />
+      <el-input v-model="goodMark" placeholder="混凝土标号" clearable size="small" @keyup.enter.native="load" />
+      <el-button type="primary" size="small" :loading="loading" @click="load">查询</el-button>
+      <el-button size="small" :disabled="loading" @click="reset">重置</el-button>
+    </section>
+    <el-alert v-if="error" :title="error" type="error" :closable="false" />
+    <p class="note">发货排名按期间发货日期统计;计划对比按期间计划任务统计,实际为这些任务截至查询结束日的累计有效发货量。客户按名称汇总,计划量每个任务只计一次。</p>
+    <section class="rankings"><article><h2>客户发货量排名</h2><div ref="customers" class="chart"></div></article><article><h2>工程发货量排名</h2><div ref="projects" class="chart"></div></article></section>
+    <article class="comparison"><h2>客户计划 vs 实际发货对比</h2><div ref="plans" class="chart"></div></article>
+    <p class="note">排名展示全部记录,可拖动右侧滑块查看;无发货的计划任务仍保留。标号和厂区筛选在计划对比中作用于任务,其关联发货按任务累计。</p>
+  </div>
+</template>
+<script>
+import * as echarts from 'echarts';
+import { getSalesCustomerAnalysis } from '@/api/tianzhu/sales';
+import { getProductionMixOrgs } from '@/api/tianzhu/production';
+const pad = n => String(n).padStart(2, '0');
+const day = d => `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}`;
+const current = () => { const n = new Date(); return [day(new Date(n.getFullYear(),n.getMonth(),1)),day(n)]; };
+const empty = () => ({ customerRanking: [], projectRanking: [], customerPlanActual: [] });
+export default {
+  name: 'TianzhuSalesCustomerAnalysis',
+  data() { const q = this.$route.query; return { range: q.startDate && q.endDate ? [q.startDate,q.endDate] : current(), mixOrgId:q.mixOrgId || '', customerName:q.customerName || '', goodMark:q.goodMark || '', plants:[], loading:false, error:'', data:empty() }; },
+  mounted() {
+    this.charts = [];
+    window.addEventListener('resize',this.resize);
+    this.observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(this.resize);
+    if(this.observer) this.observer.observe(this.$el);
+    getProductionMixOrgs().then(rows=>{if(!this.destroyed)this.plants=rows || [];}).catch(()=>{if(!this.destroyed)this.$message.warning('厂区选项加载失败');});
+    this.load();
+  },
+  beforeDestroy() { this.destroyed=true; window.removeEventListener('resize',this.resize); if(this.observer)this.observer.disconnect(); this.dispose(); },
+  methods: {
+    routeQuery() { return { startDate:this.range[0],endDate:this.range[1],mixOrgId:this.mixOrgId || undefined,customerName:this.customerName || undefined,goodMark:this.goodMark || undefined }; },
+    back() { this.$router.push({ path:'/tianzhu/sales',query:this.routeQuery() }); },
+    reset() {this.range=current();this.mixOrgId='';this.customerName='';this.goodMark='';this.load();},
+    async load() {
+      if(this.loading || !this.range || this.range.length!==2)return;
+      const [y,m,d]=this.range[1].split('-').map(Number);
+      this.loading=true;this.error='';
+      try {
+        const response=await getSalesCustomerAnalysis({startTime:this.range[0]+' 00:00:00',endTime:day(new Date(y,m-1,d+1))+' 00:00:00',mixOrgId:this.mixOrgId || undefined,customerName:this.customerName.trim() || undefined,goodMark:this.goodMark.trim() || undefined});
+        if(!this.destroyed)this.data=response || empty();
+      } catch(e) {if(!this.destroyed){this.data=empty();this.error=e.message || '客户分析加载失败';}}
+      finally {if(!this.destroyed){this.loading=false;this.$nextTick(this.draw);}}
+    },
+    dispose() {(this.charts || []).forEach(c=>c.dispose());this.charts=[];},
+    resize() {(this.charts || []).forEach(c=>c.resize());},
+    chart(ref,rows,series) {
+      const c=echarts.init(this.$refs[ref]);this.charts.push(c);
+      c.setOption({
+        color:['#19a7ff','#2bdfbf'],tooltip:{trigger:'axis',axisPointer:{type:'shadow'},valueFormatter:v=>Number(v || 0).toLocaleString('zh-CN')+' m³'},
+        legend:{top:8,textStyle:{color:'#c8e9ff'}},grid:{left:190,right:55,top:48,bottom:35},
+        xAxis:{type:'value',name:'m³',axisLabel:{color:'#a8cee9'},splitLine:{lineStyle:{color:'#234c6c',type:'dashed'}}},
+        yAxis:{type:'category',inverse:true,data:rows.map(r=>r.name),axisLabel:{color:'#bedff5',width:165,overflow:'truncate'},axisTick:{show:false}},
+        dataZoom: rows.length>10 ? [{type:'slider',yAxisIndex:0,right:8,startValue:0,endValue:9,filterMode:'filter',textStyle:{color:'#b9d5ed'}}] : [],
+        series:series.map(s=>({name:s.name,type:'bar',barMaxWidth:25,data:rows.map(r=>Number(r[s.key] || 0))})),
+        graphic:rows.length ? [] : [{type:'text',left:'center',top:'middle',style:{text:this.error?'数据加载失败':'暂无数据',fill:'#91b6d4'}}]
+      });
+    },
+    comparisonChart(ref, rows) {
+      const c = echarts.init(this.$refs[ref]); this.charts.push(c);
+      c.setOption({
+        color: ['#19a7ff', '#2bdfbf'], tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' }, valueFormatter: v => Number(v || 0).toLocaleString('zh-CN') + ' m³' },
+        legend: { top: 8, textStyle: { color: '#c8e9ff' } }, grid: { left: 75, right: 30, top: 48, bottom: 65 },
+        xAxis: { type: 'category', data: rows.map(r => r.name), axisLabel: { color: '#bedff5', interval: 0, rotate: rows.length > 5 ? 28 : 0, width: 120, overflow: 'truncate' }, axisTick: { show: false }, axisLine: { lineStyle: { color: '#286083' } } },
+        yAxis: { type: 'value', name: 'm³', axisLabel: { color: '#a8cee9' }, splitLine: { lineStyle: { color: '#234c6c', type: 'dashed' } } },
+        series: [{ name: '计划方量', type: 'bar', barMaxWidth: 30, data: rows.map(r => Number(r.plannedVolume || 0)) }, { name: '实际发货方量', type: 'bar', barMaxWidth: 30, data: rows.map(r => Number(r.actualVolume || 0)) }],
+        graphic: rows.length ? [] : [{ type: 'text', left: 'center', top: 'middle', style: { text: this.error ? '数据加载失败' : '暂无数据', fill: '#91b6d4' } }]
+      });
+    },
+    draw() {
+      if(this.destroyed)return;
+      this.dispose();
+      this.chart('customers',this.data.customerRanking || [],[{name:'发货方量',key:'value'}]);
+      this.chart('projects',this.data.projectRanking || [],[{name:'发货方量',key:'value'}]);
+      this.comparisonChart('plans', this.data.customerPlanActual || []);
+    }
+  }
+};
+</script>
+<style scoped>
+.customer-analysis{padding:22px;background:radial-gradient(at 50% 0,#103657,#04172e);min-height:calc(100vh - 90px);color:#d8efff;box-sizing:border-box}header{display:flex;align-items:center;justify-content:space-between;gap:15px}h1{color:#e0f4ff;font-size:26px;letter-spacing:4px;margin:0}.filters{display:flex;flex-wrap:wrap;gap:12px;margin:24px 0}.filters .el-input,.filters .el-select{width:170px}.filters .el-button{margin:0}.note{font-size:12px;color:#95bddc;line-height:1.8}.rankings{display:grid;grid-template-columns:1fr 1fr;gap:22px}article{border:1px solid #1c496d;border-radius:5px;overflow:hidden;min-width:0}h2{margin:0;padding:12px 18px;background:linear-gradient(90deg,#0b548e,#073663);font-size:16px;color:#d8efff}.chart{height:420px}.comparison{margin-top:22px}.customer-analysis ::v-deep .el-input__inner{background:#082749;border-color:#1a527d;color:#d6ecff}.customer-analysis ::v-deep .el-range-input{background:transparent;color:#d6ecff}.customer-analysis ::v-deep .el-range-separator{color:#aacbe7}@media(max-width:900px){.rankings{grid-template-columns:1fr}h1{font-size:20px}}
+</style>

+ 217 - 0
src/views/tianzhu/salesDashboard.vue

@@ -0,0 +1,217 @@
+<template>
+  <div class="sales-cockpit" v-loading="loading" element-loading-background="rgba(3,20,44,.7)">
+    <header class="cockpit-header"><div class="eyebrow">TIANZHU · SALES OVERVIEW</div><h1>销售总览驾驶舱</h1><time>{{ clock }}</time></header>
+    <section class="toolbar">
+      <el-date-picker v-model="range" type="daterange" value-format="yyyy-MM-dd" range-separator="至" :clearable="false" size="small" />
+      <el-select v-model="mixOrgId" clearable placeholder="全部厂区" size="small"><el-option v-for="item in plants" :key="item.id" :label="item.name" :value="item.id" /></el-select>
+      <el-input v-model="customerName" clearable placeholder="客户名称(精确)" size="small" @keyup.enter.native="load" />
+      <el-input v-model="goodMark" clearable placeholder="混凝土标号" size="small" @keyup.enter.native="load" />
+      <el-button type="primary" size="small" :loading="loading" @click="load">查询</el-button>
+      <el-button size="small" :disabled="loading" @click="reset">重置</el-button>
+      <el-button size="small" :disabled="!loaded || loading" @click="exportData">导出数据</el-button>
+      <span class="updated">查询更新:{{ refreshedAt || '—' }}</span>
+    </section>
+    <el-alert v-if="error" :title="error" type="error" show-icon :closable="false" />
+    <main>
+      <section class="upper">
+        <article class="panel drillable" role="button" tabindex="0" @click="openOrders" @keydown.enter="openOrders" @keydown.space.prevent="openOrders"><h2>每日销量 / 车次趋势</h2><div ref="daily" class="chart"></div></article>
+        <section class="kpis">
+            <article v-for="card in cards" :key="card.key" class="kpi" :class="{ clickable: card.key === 'customerCount' || card.key === 'salesVolume' }" :role="card.key === 'customerCount' || card.key === 'salesVolume' ? 'button' : undefined" :tabindex="card.key === 'customerCount' || card.key === 'salesVolume' ? 0 : undefined" @click="openDrilldown(card)" @keydown.enter="openDrilldown(card)" @keydown.space.prevent="openDrilldown(card)">
+            <strong>{{ loaded ? number(data[card.key]) : '—' }}</strong><div class="pedestal"></div><span>{{ card.label }}<small>({{ card.unit }})</small></span>
+          </article>
+        </section>
+        <article class="panel drillable" role="button" tabindex="0" @click="openProducts" @keydown.enter="openProducts" @keydown.space.prevent="openProducts"><h2>混凝土标号销量结构占比</h2><div ref="grades" class="chart"></div></article>
+      </section>
+      <section class="lower">
+        <article class="panel drillable" role="button" tabindex="0" @click="openLossMonitoring" @keydown.enter="openLossMonitoring" @keydown.space.prevent="openLossMonitoring"><h2>生产 vs 发货趋势 <span>m³</span></h2><div ref="comparison" class="chart"></div></article>
+        <article class="panel drillable" role="button" tabindex="0" @click="openOrders" @keydown.enter="openOrders" @keydown.space.prevent="openOrders"><h2>Top 5 客户发货量 <span>m³</span></h2><div ref="customers" class="chart"></div></article>
+      </section>
+    </main>
+    <footer>按发货日期统计有效发货单 · 销量为生产方量 · 标号占比按发货方量计算 · 合作客户按名称去重</footer>
+  </div>
+</template>
+
+<script>
+import * as echarts from 'echarts';
+import * as XLSX from 'xlsx';
+import { getSalesOverview } from '@/api/tianzhu/sales';
+import { getProductionMixOrgs } from '@/api/tianzhu/production';
+
+const pad = n => String(n).padStart(2, '0');
+const date = d => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
+const initialRange = () => { const now = new Date(); return [date(new Date(now.getFullYear(), now.getMonth(), 1)), date(now)]; };
+const empty = () => ({ dailyTrends: [], gradeDistribution: [], topCustomers: [] });
+export default {
+  name: 'TianzhuSalesDashboard',
+  data() {
+    const q = this.$route.query;
+    return {
+      range: q.startDate && q.endDate ? [q.startDate, q.endDate] : initialRange(), mixOrgId: q.mixOrgId || '', customerName: q.customerName || '', goodMark: q.goodMark || '', plants: [],
+      loading: false, loaded: false, error: '', data: empty(), refreshedAt: '', clock: '', appliedRange: [], appliedFilters: {},
+      cards: [
+        { key: 'salesVolume', label: '本期销量总量', unit: 'm³' },
+        { key: 'shipmentVolume', label: '本期发货方量', unit: 'm³' },
+        { key: 'shipmentTrips', label: '发货车次总数', unit: '次' },
+        { key: 'customerCount', label: '合作客户数', unit: '家' }
+      ]
+    };
+  },
+  mounted() {
+    this.charts = [];
+    this.tick();
+    this.timer = setInterval(this.tick, 1000);
+    window.addEventListener('resize', this.resize);
+    this.observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(this.resize);
+    if (this.observer) this.observer.observe(this.$el);
+    getProductionMixOrgs().then(rows => { this.plants = rows || []; }).catch(() => { this.$message.warning('厂区选项加载失败,可按全部厂区查询'); });
+    this.load();
+  },
+  beforeDestroy() {
+    this.destroyed = true;
+    clearInterval(this.timer);
+    window.removeEventListener('resize', this.resize);
+    if (this.observer) this.observer.disconnect();
+    this.dispose();
+  },
+  methods: {
+    openDrilldown(card) {
+      if (!this.loaded || this.loading) return;
+      if (card.key === 'customerCount') return this.openCustomers(card);
+      if (card.key === 'salesVolume') return this.openOrders();
+    },
+    openCustomers(card) {
+      if (card.key !== 'customerCount' || !this.loaded || this.loading) return;
+      this.$router.push({ path: '/tianzhu/sales-customer-analysis', query: { startDate: this.appliedRange[0], endDate: this.appliedRange[1], ...this.appliedFilters } });
+    },
+    openProducts() {
+      if (!this.loaded || this.loading) return;
+      this.$router.push({ path: '/tianzhu/sales-product-analysis', query: { startDate: this.appliedRange[0], endDate: this.appliedRange[1], ...this.appliedFilters } });
+    },
+    openLossMonitoring() {
+      if (!this.loaded || this.loading) return;
+      this.$router.push({ path: '/tianzhu/sales-loss-monitoring', query: { startDate: this.appliedRange[0], endDate: this.appliedRange[1], ...this.appliedFilters } });
+    },
+    openOrders() {
+      if (!this.loaded || this.loading) return;
+      this.$router.push({ path: '/tianzhu/sales-order-analysis', query: { startDate: this.appliedRange[0], endDate: this.appliedRange[1], ...this.appliedFilters } });
+    },
+    number(value) { return Number(value || 0).toLocaleString('zh-CN', { maximumFractionDigits: 2 }); },
+    tick() { const now = new Date(); this.clock = date(now) + ' ' + now.toLocaleTimeString('zh-CN', { hour12: false }); },
+    async load() {
+      if (this.loading || !this.range || this.range.length !== 2) return;
+      const selected = this.range.slice();
+      const filters = { mixOrgId: this.mixOrgId || undefined, customerName: this.customerName.trim() || undefined, goodMark: this.goodMark.trim() || undefined };
+      const [year, month, day] = selected[1].split('-').map(Number);
+      const end = date(new Date(year, month - 1, day + 1));
+      this.loading = true;
+      this.error = '';
+      try {
+        const result = await getSalesOverview({
+          startTime: selected[0] + ' 00:00:00', endTime: end + ' 00:00:00',
+          mixOrgId: this.mixOrgId || undefined, customerName: this.customerName.trim() || undefined,
+          goodMark: this.goodMark.trim() || undefined
+        });
+        if (this.destroyed) return;
+        this.data = result || empty();
+        this.loaded = true;
+        this.appliedRange = selected;
+        this.appliedFilters = filters;
+        this.refreshedAt = new Date().toLocaleString('zh-CN', { hour12: false });
+      } catch (e) {
+        if (this.destroyed) return;
+        this.error = e.message || '获取销售看板数据失败';
+        this.data = empty(); this.loaded = false; this.refreshedAt = '';
+      } finally {
+        if (!this.destroyed) { this.loading = false; this.$nextTick(this.draw); }
+      }
+    },
+    reset() { this.range = initialRange(); this.mixOrgId = ''; this.customerName = ''; this.goodMark = ''; this.load(); },
+    dispose() { (this.charts || []).forEach(chart => chart.dispose()); this.charts = []; },
+    resize() { (this.charts || []).forEach(chart => chart.resize()); },
+    init(ref, option, hasData) {
+      const chart = echarts.init(this.$refs[ref]);
+      chart.setOption({
+        color: ['#13a8ff', '#26e1c3', '#ffc844', '#5965ff', '#ed82d9'],
+        textStyle: { color: '#a7cae8' },
+        ...option,
+        graphic: hasData ? [] : [{ type: 'text', left: 'center', top: 'middle', style: { text: this.error ? '数据加载失败' : '暂无数据', fill: '#8caccc', fontSize: 14 } }]
+      });
+      this.charts.push(chart);
+    },
+    axis(name) {
+      return { type: 'value', name, nameTextStyle: { color: '#86aed2' }, axisLabel: { color: '#86aed2' }, splitLine: { lineStyle: { color: 'rgba(100,165,220,.2)', type: 'dashed' } } };
+    },
+    cartesian(labels) {
+      return {
+        tooltip: { trigger: 'axis' },
+        legend: { top: 8, textStyle: { color: '#b9d5ed' } },
+        grid: { left: 60, right: 52, top: 58, bottom: 40 },
+        xAxis: { type: 'category', data: labels, axisLabel: { color: '#89b3d8', hideOverlap: true }, axisLine: { lineStyle: { color: '#225078' } } },
+        yAxis: this.axis('m³')
+      };
+    },
+    draw() {
+      if (this.destroyed) return;
+      this.dispose();
+      const daily = this.data.dailyTrends || [];
+      const hasData = this.loaded && Number(this.data.shipmentTrips) > 0;
+      const dates = daily.map(row => row.statDate);
+      this.init('daily', {
+        ...this.cartesian(dates), yAxis: [this.axis('m³'), { ...this.axis('车次'), minInterval: 1, splitLine: { show: false } }],
+        series: [
+          { name: '销量 (m³)', type: 'line', smooth: true, showSymbol: false, areaStyle: { opacity: 0.18 }, data: daily.map(row => row.salesVolume) },
+          { name: '车次', type: 'line', yAxisIndex: 1, smooth: true, showSymbol: false, itemStyle: { color: '#ffc844' }, data: daily.map(row => row.shipmentTrips) }
+        ]
+      }, hasData);
+      // json-bigint 返回无原型对象,ECharts 需要普通的图表数据对象。
+      const grades = (this.data.gradeDistribution || []).map(row => ({ name: row.name, value: Number(row.value || 0) }));
+      this.init('grades', {
+        tooltip: { trigger: 'item', valueFormatter: value => this.number(value) + ' m³' },
+        legend: { bottom: 4, type: 'scroll', textStyle: { color: '#afd3ee' } },
+        series: [{ type: 'pie', radius: ['42%', '66%'], center: ['50%', '45%'], stillShowZeroSum: false,
+          label: { color: '#b9deff', formatter: '{b}\n{d}%' }, data: grades }]
+      }, hasData && grades.some(row => Number(row.value) > 0));
+      this.init('comparison', {
+        ...this.cartesian(dates),
+        series: [
+          { name: '生产方量', type: 'bar', barMaxWidth: 30, data: daily.map(row => row.salesVolume) },
+          { name: '发货方量', type: 'line', smooth: true, symbolSize: 5, data: daily.map(row => row.shipmentVolume) }
+        ]
+      }, hasData);
+      const customers = this.data.topCustomers || [];
+      const base = this.cartesian(customers.map(row => row.name));
+      base.grid.bottom = 70;
+      base.xAxis.axisLabel = { color: '#89b3d8', interval: 0, width: 110, overflow: 'truncate' };
+      base.xAxis.triggerEvent = true;
+      this.init('customers', { ...base, series: [{ name: '发货方量', type: 'bar', barMaxWidth: 46, data: customers.map(row => row.value) }] }, this.loaded && customers.length > 0);
+    },
+    exportData() {
+      const workbook = XLSX.utils.book_new();
+      const append = (title, rows) => XLSX.utils.book_append_sheet(workbook, XLSX.utils.aoa_to_sheet(rows), title);
+      append('总览', [['统计开始', this.appliedRange[0]], ['统计结束', this.appliedRange[1]], ['指标', '数值', '单位'], ...this.cards.map(card => [card.label, this.data[card.key], card.unit])]);
+      append('每日趋势', [['日期', '销量(m³)', '发货方量(m³)', '车次'], ...this.data.dailyTrends.map(row => [row.statDate, row.salesVolume, row.shipmentVolume, row.shipmentTrips])]);
+      append('标号结构', [['标号', '发货方量(m³)'], ...this.data.gradeDistribution.map(row => [row.name, row.value])]);
+      append('客户Top5', [['客户名称', '发货方量(m³)'], ...this.data.topCustomers.map(row => [row.name, row.value])]);
+      XLSX.writeFile(workbook, '销售总览_' + this.appliedRange.join('_') + '.xlsx');
+    }
+  }
+};
+</script>
+
+<style scoped>
+.drillable{cursor:pointer}.drillable:hover,.drillable:focus-visible{outline:1px solid #35c5ef;box-shadow:0 0 18px rgba(30,184,255,.2)}
+.kpi.clickable{cursor:pointer;border-radius:6px}.kpi.clickable:hover,.kpi.clickable:focus-visible{outline:1px solid #35c5ef;background:rgba(31,142,215,.12)}
+.sales-cockpit{box-sizing:border-box;min-height:calc(100vh - 90px);padding:0 24px 16px;background:radial-gradient(ellipse at 50% 0, #0c3560 0, #061c3a 50%, #04172e 100%);color:#d8efff}
+.cockpit-header{position:relative;display:flex;align-items:center;justify-content:space-between;min-height:88px;border-bottom:1px solid #0d4568}
+.cockpit-header h1{position:absolute;left:50%;top:0;transform:translateX(-50%);margin:0;padding:16px 75px 22px;white-space:nowrap;font-size:30px;color:#e6f6ff;letter-spacing:5px;font-style:italic;text-shadow:0 0 16px #138de0;border-bottom:3px solid #17c3de;border-radius:0 0 48px 48px;background:linear-gradient(180deg,#07335b,#07233e)}
+.eyebrow{font-size:11px;color:#4e9dc8;letter-spacing:2px}.cockpit-header time{font-size:13px;color:#b5d8ef;margin-left:auto}
+.toolbar{display:flex;align-items:center;flex-wrap:wrap;gap:10px;padding:22px 0}.toolbar .el-select,.toolbar .el-input{width:145px}.toolbar .el-date-editor{width:270px}.toolbar .el-button{margin:0}.updated{margin-left:auto;font-size:11px;color:#88b4d8}
+.upper{display:grid;grid-template-columns:minmax(280px,1fr) minmax(450px,1.8fr) minmax(280px,1fr);gap:22px;margin:8px 0 30px}.lower{display:grid;grid-template-columns:1fr 1fr;gap:24px}
+.panel{min-width:0;overflow:hidden;border:1px solid #124365;border-radius:2px 2px 7px 7px;background:linear-gradient(135deg,rgba(8,48,86,.65),rgba(3,23,47,.4))}
+.panel h2{position:relative;margin:0;padding:10px 16px 10px 30px;background:linear-gradient(90deg,#0b548e,#073663);font-size:15px;color:#d8efff;font-style:italic;letter-spacing:1px}.panel h2:before{content:'';position:absolute;left:12px;top:15px;width:8px;height:8px;background:#67dcff;transform:rotate(45deg);box-shadow:0 0 9px #37c9ff}.panel h2 span{float:right;font-size:12px;font-style:normal;color:#83b6db}
+.chart{height:250px}.lower .chart{height:340px}.kpis{display:grid;grid-template-columns:repeat(4,1fr);align-items:center;gap:14px}.kpi{text-align:center;min-width:0}.kpi strong{position:relative;z-index:1;font-family:Consolas,monospace;font-size:clamp(21px,2.3vw,40px);font-weight:400;color:#edffff;text-shadow:0 0 12px #30fcda}.pedestal{height:45px;margin:-12px 0 20px;background:linear-gradient(0deg,rgba(9,109,255,.65),transparent),repeating-linear-gradient(90deg,transparent 0,rgba(24,238,222,.16) 4px,transparent 7px);border-bottom:4px solid #24baff;box-shadow:0 9px 16px -9px #20c6ff;transform:perspective(130px) rotateX(18deg)}.kpi span{font-size:12px;color:#c9e5ff;white-space:nowrap}.kpi small{font-size:10px}
+footer{padding-top:20px;font-size:11px;color:#6694bb;text-align:center}
+.sales-cockpit ::v-deep .el-input__inner{background:#082749;border-color:#1a527d;color:#d6ecff}.sales-cockpit ::v-deep .el-range-input{background:transparent;color:#d6ecff}.sales-cockpit ::v-deep .el-range-separator{color:#aacbe7}.sales-cockpit ::v-deep .el-button--default{background:#0a2d50;border-color:#205b89;color:#c2e2ff}
+@media(max-width:1250px){.upper{grid-template-columns:1fr 1fr}.kpis{grid-column:1/-1;grid-row:1;padding:25px 0}.eyebrow{display:none}.cockpit-header time{font-size:11px}.cockpit-header h1{font-size:24px;padding-left:35px;padding-right:35px}}
+@media(max-width:760px){.sales-cockpit{padding:0 12px 16px}.cockpit-header{min-height:115px;align-items:flex-end;padding-bottom:10px}.cockpit-header h1{font-size:22px;letter-spacing:2px}.upper,.lower{grid-template-columns:1fr}.kpis{grid-template-columns:repeat(2,1fr);row-gap:30px}.updated{width:100%;margin-left:0}.chart,.lower .chart{height:290px}}
+</style>

+ 60 - 0
src/views/tianzhu/salesLossMonitoring.vue

@@ -0,0 +1,60 @@
+<template>
+  <div class="loss-monitoring" v-loading="loading" element-loading-background="rgba(4,23,46,.7)">
+    <header><el-button size="small" icon="el-icon-back" @click="back">返回销售总览</el-button><h1>发货与盈亏监控</h1><span>单位:m³</span></header>
+    <section class="filters">
+      <el-date-picker v-model="range" type="daterange" value-format="yyyy-MM-dd" :clearable="false" range-separator="至" size="small" />
+      <el-select v-model="mixOrgId" placeholder="全部厂区" clearable size="small"><el-option v-for="p in plants" :key="p.id" :label="p.name" :value="String(p.id)" /></el-select>
+      <el-input v-model="customerName" placeholder="客户名称(精确)" clearable size="small" @keyup.enter.native="load" />
+      <el-input v-model="goodMark" placeholder="混凝土标号(精确)" clearable size="small" @keyup.enter.native="load" />
+      <el-button type="primary" size="small" :loading="loading" @click="load">查询</el-button><el-button size="small" :disabled="loading" @click="reset">重置</el-button>
+    </section>
+    <el-alert v-if="error" :title="error" type="error" :closable="false" />
+    <p class="note">按有效发货单的发货日期逐日统计。扣料、退料直接使用源字段;生产方量取prodAmount,发货方量取sendAmount,盈亏方量为站内扣料减工地扣料。缺失日期补零。</p>
+    <section class="charts"><article><h2>扣料 / 退料趋势</h2><div ref="deductions" class="chart"></div></article><article><h2>生产 vs 发货对比</h2><div ref="comparison" class="chart"></div></article></section>
+    <article class="wide"><h2>每日盈亏方量趋势</h2><div ref="profitLoss" class="chart"></div></article>
+  </div>
+</template>
+<script>
+import * as echarts from 'echarts';
+import { getSalesLossMonitoring } from '@/api/tianzhu/sales';
+import { getProductionMixOrgs } from '@/api/tianzhu/production';
+const pad = n => String(n).padStart(2, '0');
+const day = d => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
+const current = () => { const n = new Date(); return [day(new Date(n.getFullYear(), n.getMonth(), 1)), day(n)]; };
+const empty = () => ({ dailyTrends: [] });
+export default {
+  name: 'TianzhuSalesLossMonitoring',
+  data() { const q = this.$route.query; return { range: q.startDate && q.endDate ? [q.startDate, q.endDate] : current(), mixOrgId: q.mixOrgId || '', customerName: q.customerName || '', goodMark: q.goodMark || '', plants: [], loading: false, error: '', data: empty() }; },
+  mounted() { this.charts = []; window.addEventListener('resize', this.resize); this.observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(this.resize); if (this.observer) this.observer.observe(this.$el); getProductionMixOrgs().then(rows => { if (!this.destroyed) this.plants = rows || []; }).catch(() => {}); this.load(); },
+  beforeDestroy() { this.destroyed = true; window.removeEventListener('resize', this.resize); if (this.observer) this.observer.disconnect(); this.dispose(); },
+  methods: {
+    query() { return { startDate: this.range[0], endDate: this.range[1], mixOrgId: this.mixOrgId || undefined, customerName: this.customerName.trim() || undefined, goodMark: this.goodMark.trim() || undefined }; },
+    back() { this.$router.push({ path: '/tianzhu/sales', query: this.query() }); },
+    reset() { this.range = current(); this.mixOrgId = ''; this.customerName = ''; this.goodMark = ''; this.load(); },
+    async load() {
+      if (this.loading || !this.range || this.range.length !== 2) return;
+      const [year, month, dateValue] = this.range[1].split('-').map(Number);
+      this.loading = true; this.error = '';
+      try { this.data = await getSalesLossMonitoring({ startTime: this.range[0] + ' 00:00:00', endTime: day(new Date(year, month - 1, dateValue + 1)) + ' 00:00:00', mixOrgId: this.mixOrgId || undefined, customerName: this.customerName.trim() || undefined, goodMark: this.goodMark.trim() || undefined }); }
+      catch (e) { this.data = empty(); this.error = e.message || '发货与盈亏监控加载失败'; }
+      finally { this.loading = false; this.$nextTick(this.draw); }
+    },
+    dispose() { (this.charts || []).forEach(c => c.dispose()); this.charts = []; },
+    resize() { (this.charts || []).forEach(c => c.resize()); },
+    axis(name) { return { type: 'value', name, nameTextStyle: { color: '#86aed2' }, axisLabel: { color: '#86aed2' }, splitLine: { lineStyle: { color: 'rgba(100,165,220,.2)', type: 'dashed' } } }; },
+    base(dates) { return { tooltip: { trigger: 'axis' }, legend: { top: 8, textStyle: { color: '#c8e9ff' } }, grid: { left: 60, right: 35, top: 52, bottom: 40 }, xAxis: { type: 'category', data: dates, axisLabel: { color: '#a8cee9', hideOverlap: true }, axisLine: { lineStyle: { color: '#286083' } } }, yAxis: this.axis('m³') }; },
+    draw() {
+      if (this.destroyed) return; this.dispose();
+      const rows = (this.data.dailyTrends || []).map(r => ({ statDate: r.statDate, stationDeduction: Number(r.stationDeduction || 0), siteDeduction: Number(r.siteDeduction || 0), siteReturn: Number(r.siteReturn || 0), salesVolume: Number(r.salesVolume || 0), shipmentVolume: Number(r.shipmentVolume || 0), profitLossVolume: Number(r.profitLossVolume || 0) }));
+      const dates = rows.map(r => r.statDate); const hasData = rows.some(r => r.stationDeduction || r.siteDeduction || r.siteReturn || r.salesVolume || r.shipmentVolume || r.profitLossVolume);
+      const make = (ref, option) => { const chart = echarts.init(this.$refs[ref]); chart.setOption({ color: ['#13a8ff', '#26e1c3', '#ffc844', '#ed82d9'], textStyle: { color: '#a7cae8' }, ...option, graphic: hasData ? [] : [{ type: 'text', left: 'center', top: 'middle', style: { text: this.error ? '数据加载失败' : '暂无数据', fill: '#91b6d4' } }] }); this.charts.push(chart); };
+      make('deductions', { ...this.base(dates), series: [{ name: '站内扣料', type: 'line', smooth: true, stack: 'total', areaStyle: { opacity: .3 }, data: rows.map(r => r.stationDeduction) }, { name: '工地扣料', type: 'line', smooth: true, stack: 'total', areaStyle: { opacity: .3 }, data: rows.map(r => r.siteDeduction) }, { name: '工地退料', type: 'line', smooth: true, stack: 'total', areaStyle: { opacity: .3 }, data: rows.map(r => r.siteReturn) }] });
+      make('comparison', { ...this.base(dates), series: [{ name: '生产方量', type: 'bar', barMaxWidth: 30, data: rows.map(r => r.salesVolume) }, { name: '发货方量', type: 'bar', barMaxWidth: 30, data: rows.map(r => r.shipmentVolume) }] });
+      make('profitLoss', { ...this.base(dates), series: [{ name: '盈亏方量', type: 'line', smooth: true, symbolSize: 5, areaStyle: { opacity: .22 }, data: rows.map(r => r.profitLossVolume) }] });
+    }
+  }
+};
+</script>
+<style scoped>
+.loss-monitoring{padding:22px;background:radial-gradient(at 50% 0,#103657,#04172e);min-height:calc(100vh - 90px);color:#d8efff;box-sizing:border-box}header{display:flex;align-items:center;justify-content:space-between;gap:15px}h1{color:#e0f4ff;font-size:26px;letter-spacing:4px;margin:0}.filters{display:flex;flex-wrap:wrap;gap:12px;margin:24px 0}.filters .el-input,.filters .el-select{width:175px}.filters .el-button{margin:0}.note{font-size:12px;color:#95bddc;line-height:1.8}.charts{display:grid;grid-template-columns:1fr 1fr;gap:22px}.charts article,.wide{border:1px solid #1c496d;border-radius:5px;overflow:hidden;min-width:0}.charts h2,.wide h2{margin:0;padding:12px 18px;background:linear-gradient(90deg,#0b548e,#073663);font-size:16px;color:#d8efff}.chart{height:380px}.wide{margin-top:22px}.wide .chart{height:330px}.loss-monitoring ::v-deep .el-input__inner{background:#082749;border-color:#1a527d;color:#d6ecff}.loss-monitoring ::v-deep .el-range-input{background:transparent;color:#d6ecff}.loss-monitoring ::v-deep .el-range-separator{color:#aacbe7}@media(max-width:900px){.charts{grid-template-columns:1fr}h1{font-size:20px}}
+</style>

+ 61 - 0
src/views/tianzhu/salesOrderAnalysis.vue

@@ -0,0 +1,61 @@
+<template>
+  <div class="order-analysis" v-loading="loading" element-loading-background="rgba(4,23,46,.7)">
+    <header><el-button size="small" icon="el-icon-back" @click="back">返回销售总览</el-button><h1>订单与浇筑分析</h1><span>单位:m³</span></header>
+    <section class="filters">
+      <el-date-picker v-model="range" type="daterange" value-format="yyyy-MM-dd" :clearable="false" range-separator="至" size="small" />
+      <el-select v-model="mixOrgId" placeholder="全部厂区" clearable size="small"><el-option v-for="p in plants" :key="p.id" :label="p.name" :value="String(p.id)" /></el-select>
+      <el-input v-model="customerName" placeholder="客户名称(精确)" clearable size="small" @keyup.enter.native="load" />
+      <el-input v-model="goodMark" placeholder="混凝土标号(精确)" clearable size="small" @keyup.enter.native="load" />
+      <el-button type="primary" size="small" :loading="loading" @click="load">查询</el-button><el-button size="small" :disabled="loading" @click="reset">重置</el-button>
+    </section>
+    <el-alert v-if="error" :title="error" type="error" :closable="false" />
+    <p class="note">浇筑方式排名按有效发货单sendAmount统计。当前系统的订单趋势按非作废生产任务统计:月度按insertTime计数,计划趋势按planPourTime汇总planAmount;后续销售订单表结构确认后再切换订单来源。</p>
+    <section class="charts"><article><h2>浇筑方式销量排名</h2><div ref="ways" class="chart"></div></article><article><h2>月度订单趋势(生产任务)</h2><div ref="orders" class="chart"></div></article></section>
+    <article class="wide"><h2>每日计划方量趋势</h2><div ref="plans" class="chart"></div></article>
+  </div>
+</template>
+<script>
+import * as echarts from 'echarts';
+import { getSalesOrderAnalysis } from '@/api/tianzhu/sales';
+import { getProductionMixOrgs } from '@/api/tianzhu/production';
+const pad = n => String(n).padStart(2, '0');
+const day = d => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
+const current = () => { const n = new Date(); return [day(new Date(n.getFullYear(), n.getMonth(), 1)), day(n)]; };
+const empty = () => ({ pouringWayRanking: [], monthlyOrderTrend: [], dailyPlanTrend: [] });
+export default {
+  name: 'TianzhuSalesOrderAnalysis',
+  data() { const q = this.$route.query; return { range: q.startDate && q.endDate ? [q.startDate, q.endDate] : current(), mixOrgId: q.mixOrgId || '', customerName: q.customerName || '', goodMark: q.goodMark || '', plants: [], loading: false, error: '', data: empty() }; },
+  mounted() { this.charts = []; window.addEventListener('resize', this.resize); this.observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(this.resize); if (this.observer) this.observer.observe(this.$el); getProductionMixOrgs().then(rows => { if (!this.destroyed) this.plants = rows || []; }).catch(() => {}); this.load(); },
+  beforeDestroy() { this.destroyed = true; window.removeEventListener('resize', this.resize); if (this.observer) this.observer.disconnect(); this.dispose(); },
+  methods: {
+    query() { return { startDate: this.range[0], endDate: this.range[1], mixOrgId: this.mixOrgId || undefined, customerName: this.customerName.trim() || undefined, goodMark: this.goodMark.trim() || undefined }; },
+    back() { this.$router.push({ path: '/tianzhu/sales', query: this.query() }); },
+    reset() { this.range = current(); this.mixOrgId = ''; this.customerName = ''; this.goodMark = ''; this.load(); },
+    async load() {
+      if (this.loading || !this.range || this.range.length !== 2) return;
+      const [year, month, dateValue] = this.range[1].split('-').map(Number);
+      this.loading = true; this.error = '';
+      try { this.data = await getSalesOrderAnalysis({ startTime: this.range[0] + ' 00:00:00', endTime: day(new Date(year, month - 1, dateValue + 1)) + ' 00:00:00', mixOrgId: this.mixOrgId || undefined, customerName: this.customerName.trim() || undefined, goodMark: this.goodMark.trim() || undefined }); }
+      catch (e) { this.data = empty(); this.error = e.message || '订单与浇筑分析加载失败'; }
+      finally { this.loading = false; this.$nextTick(this.draw); }
+    },
+    dispose() { (this.charts || []).forEach(c => c.dispose()); this.charts = []; },
+    resize() { (this.charts || []).forEach(c => c.resize()); },
+    draw() {
+      if (this.destroyed) return; this.dispose();
+      const ways = (this.data.pouringWayRanking || []).map(r => ({ name: r.name, value: Number(r.value || 0) }));
+      const orders = (this.data.monthlyOrderTrend || []).map(r => ({ name: r.name, value: Number(r.value || 0) }));
+      const plans = (this.data.dailyPlanTrend || []).map(r => ({ name: r.name, value: Number(r.value || 0) }));
+      const has = rows => rows.length > 0 && rows.some(r => r.value !== 0);
+      const emptyGraphic = rows => has(rows) ? [] : [{ type: 'text', left: 'center', top: 'middle', style: { text: this.error ? '数据加载失败' : '暂无数据', fill: '#91b6d4' } }];
+      const ranking = (ref, rows) => { const c = echarts.init(this.$refs[ref]); c.setOption({ color: ['#19a7ff'], tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' }, valueFormatter: v => `${Number(v || 0).toLocaleString('zh-CN')} m³` }, grid: { left: 170, right: 30, top: 35, bottom: 38 }, xAxis: { type: 'value', name: 'm³', axisLabel: { color: '#afd3ee' }, splitLine: { lineStyle: { color: 'rgba(100,165,220,.2)', type: 'dashed' } } }, yAxis: { type: 'category', inverse: true, data: rows.map(r => r.name), axisLabel: { color: '#bedff5', width: 145, overflow: 'truncate' }, axisTick: { show: false } }, series: [{ name: '发货方量', type: 'bar', barMaxWidth: 28, data: rows.map(r => r.value) }], graphic: emptyGraphic(rows) }); this.charts.push(c); };
+      const bar = (ref, rows, unit, color) => { const c = echarts.init(this.$refs[ref]); c.setOption({ color: [color], tooltip: { trigger: 'axis', valueFormatter: v => `${Number(v || 0).toLocaleString('zh-CN')} ${unit}` }, grid: { left: 75, right: 28, top: 35, bottom: 50 }, xAxis: { type: 'category', data: rows.map(r => r.name), axisLabel: { color: '#afd3ee', rotate: 20, interval: 0 }, axisLine: { lineStyle: { color: '#286083' } } }, yAxis: { type: 'value', name: unit, minInterval: unit === '个' ? 1 : 0, axisLabel: { color: '#afd3ee' }, splitLine: { lineStyle: { color: 'rgba(100,165,220,.2)', type: 'dashed' } } }, series: [{ type: 'bar', barMaxWidth: 46, data: rows.map(r => r.value) }], graphic: emptyGraphic(rows) }); this.charts.push(c); };
+      ranking('ways', ways); bar('orders', orders, '个', '#ffc844');
+      const c = echarts.init(this.$refs.plans); c.setOption({ color: ['#26e1c3'], tooltip: { trigger: 'axis', valueFormatter: v => `${Number(v || 0).toLocaleString('zh-CN')} m³` }, grid: { left: 60, right: 30, top: 35, bottom: 40 }, xAxis: { type: 'category', data: plans.map(r => r.name), axisLabel: { color: '#afd3ee', hideOverlap: true }, axisLine: { lineStyle: { color: '#286083' } } }, yAxis: { type: 'value', name: 'm³', axisLabel: { color: '#afd3ee' }, splitLine: { lineStyle: { color: 'rgba(100,165,220,.2)', type: 'dashed' } } }, series: [{ name: '计划方量', type: 'line', smooth: true, symbolSize: 5, areaStyle: { opacity: .2 }, data: plans.map(r => r.value) }], graphic: emptyGraphic(plans) }); this.charts.push(c);
+    }
+  }
+};
+</script>
+<style scoped>
+.order-analysis{padding:22px;background:radial-gradient(at 50% 0,#103657,#04172e);min-height:calc(100vh - 90px);color:#d8efff;box-sizing:border-box}header{display:flex;align-items:center;justify-content:space-between;gap:15px}h1{color:#e0f4ff;font-size:26px;letter-spacing:4px;margin:0}.filters{display:flex;flex-wrap:wrap;gap:12px;margin:24px 0}.filters .el-input,.filters .el-select{width:175px}.filters .el-button{margin:0}.note{font-size:12px;color:#95bddc;line-height:1.8}.charts{display:grid;grid-template-columns:1fr 1fr;gap:22px}.charts article,.wide{border:1px solid #1c496d;border-radius:5px;overflow:hidden;min-width:0}.charts h2,.wide h2{margin:0;padding:12px 18px;background:linear-gradient(90deg,#0b548e,#073663);font-size:16px;color:#d8efff}.chart{height:380px}.wide{margin-top:22px}.wide .chart{height:330px}.order-analysis ::v-deep .el-input__inner{background:#082749;border-color:#1a527d;color:#d6ecff}.order-analysis ::v-deep .el-range-input{background:transparent;color:#d6ecff}.order-analysis ::v-deep .el-range-separator{color:#aacbe7}@media(max-width:900px){.charts{grid-template-columns:1fr}h1{font-size:20px}}
+</style>

+ 57 - 0
src/views/tianzhu/salesProductAnalysis.vue

@@ -0,0 +1,57 @@
+<template>
+  <div class="product-analysis" v-loading="loading" element-loading-background="rgba(4,23,46,.7)">
+    <header><el-button size="small" icon="el-icon-back" @click="back">返回销售总览</el-button><h1>产品(混凝土标号)销售分析</h1><span>单位:m³</span></header>
+    <section class="filters">
+      <el-date-picker v-model="range" type="daterange" value-format="yyyy-MM-dd" :clearable="false" range-separator="至" size="small" />
+      <el-select v-model="mixOrgId" placeholder="全部厂区" clearable size="small"><el-option v-for="p in plants" :key="p.id" :label="p.name" :value="String(p.id)" /></el-select>
+      <el-input v-model="customerName" placeholder="客户名称(精确)" clearable size="small" @keyup.enter.native="load" />
+      <el-input v-model="goodMark" placeholder="混凝土标号(精确)" clearable size="small" @keyup.enter.native="load" />
+      <el-button type="primary" size="small" :loading="loading" @click="load">查询</el-button><el-button size="small" :disabled="loading" @click="reset">重置</el-button>
+    </section>
+    <el-alert v-if="error" :title="error" type="error" :closable="false" />
+    <p class="note">标号发货量按有效发货单的goodMark汇总,标号过多时取方量前五;浇筑方式按有效发货单pourWayName汇总全部发货方量。两项均按assignTime统计,排除status=99。</p>
+    <section class="charts"><article><h2>各标号发货量对比(前五)</h2><div ref="grades" class="chart"></div></article><article><h2>浇筑方式销量占比</h2><div ref="ways" class="chart"></div></article></section>
+  </div>
+</template>
+<script>
+import * as echarts from 'echarts';
+import { getSalesProductAnalysis } from '@/api/tianzhu/sales';
+import { getProductionMixOrgs } from '@/api/tianzhu/production';
+const pad = n => String(n).padStart(2, '0');
+const day = d => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
+const current = () => { const n = new Date(); return [day(new Date(n.getFullYear(), n.getMonth(), 1)), day(n)]; };
+const empty = () => ({ gradeRanking: [], pouringWayDistribution: [] });
+export default {
+  name: 'TianzhuSalesProductAnalysis',
+  data() { const q = this.$route.query; return { range: q.startDate && q.endDate ? [q.startDate, q.endDate] : current(), mixOrgId: q.mixOrgId || '', customerName: q.customerName || '', goodMark: q.goodMark || '', plants: [], loading: false, error: '', data: empty() }; },
+  mounted() { this.charts = []; window.addEventListener('resize', this.resize); this.observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(this.resize); if (this.observer) this.observer.observe(this.$el); getProductionMixOrgs().then(rows => { if (!this.destroyed) this.plants = rows || []; }).catch(() => {}); this.load(); },
+  beforeDestroy() { this.destroyed = true; window.removeEventListener('resize', this.resize); if (this.observer) this.observer.disconnect(); this.dispose(); },
+  methods: {
+    query() { return { startDate: this.range[0], endDate: this.range[1], mixOrgId: this.mixOrgId || undefined, customerName: this.customerName.trim() || undefined, goodMark: this.goodMark.trim() || undefined }; },
+    back() { this.$router.push({ path: '/tianzhu/sales', query: this.query() }); },
+    reset() { this.range = current(); this.mixOrgId = ''; this.customerName = ''; this.goodMark = ''; this.load(); },
+    async load() {
+      if (this.loading || !this.range || this.range.length !== 2) return;
+      const [year, month, dateValue] = this.range[1].split('-').map(Number);
+      this.loading = true; this.error = '';
+      try { this.data = await getSalesProductAnalysis({ startTime: this.range[0] + ' 00:00:00', endTime: day(new Date(year, month - 1, dateValue + 1)) + ' 00:00:00', mixOrgId: this.mixOrgId || undefined, customerName: this.customerName.trim() || undefined, goodMark: this.goodMark.trim() || undefined }); }
+      catch (e) { this.data = empty(); this.error = e.message || '产品标号分析加载失败'; }
+      finally { this.loading = false; this.$nextTick(this.draw); }
+    },
+    dispose() { (this.charts || []).forEach(c => c.dispose()); this.charts = []; },
+    resize() { (this.charts || []).forEach(c => c.resize()); },
+    draw() {
+      if (this.destroyed) return; this.dispose();
+      const grades = (this.data.gradeRanking || []).map(r => ({ name: r.name, value: Number(r.value || 0) }));
+      const ways = (this.data.pouringWayDistribution || []).map(r => ({ name: r.name, value: Number(r.value || 0) }));
+      const bar = echarts.init(this.$refs.grades); bar.setOption({ color: ['#18a9ff'], tooltip: { trigger: 'axis', valueFormatter: v => `${Number(v || 0).toLocaleString('zh-CN')} m³` }, grid: { left: 90, right: 28, top: 35, bottom: 45 }, xAxis: { type: 'category', data: grades.map(r => r.name), axisLabel: { color: '#afd3ee', rotate: 25, interval: 0 }, axisLine: { lineStyle: { color: '#286083' } } }, yAxis: { type: 'value', name: 'm³', axisLabel: { color: '#afd3ee' }, splitLine: { lineStyle: { color: 'rgba(100,165,220,.2)', type: 'dashed' } } }, series: [{ type: 'bar', barMaxWidth: 44, data: grades.map(r => r.value) }], graphic: grades.length ? [] : [{ type: 'text', left: 'center', top: 'middle', style: { text: this.error ? '数据加载失败' : '暂无数据', fill: '#91b6d4' } }] });
+      this.charts.push(bar);
+      const pie = echarts.init(this.$refs.ways); pie.setOption({ color: ['#13a8ff', '#26e1c3', '#ffc844', '#5965ff', '#ed82d9'], tooltip: { trigger: 'item', valueFormatter: v => `${Number(v || 0).toLocaleString('zh-CN')} m³` }, legend: { bottom: 5, type: 'scroll', textStyle: { color: '#afd3ee' } }, series: [{ type: 'pie', radius: ['40%', '66%'], center: ['50%', '45%'], label: { color: '#c4e6ff', formatter: '{b}\n{d}%' }, data: ways }], graphic: ways.length ? [] : [{ type: 'text', left: 'center', top: 'middle', style: { text: this.error ? '数据加载失败' : '暂无数据', fill: '#91b6d4' } }] });
+      this.charts.push(pie);
+    }
+  }
+};
+</script>
+<style scoped>
+.product-analysis{padding:22px;background:radial-gradient(at 50% 0,#103657,#04172e);min-height:calc(100vh - 90px);color:#d8efff;box-sizing:border-box}header{display:flex;align-items:center;justify-content:space-between;gap:15px}h1{color:#e0f4ff;font-size:26px;letter-spacing:4px;margin:0}.filters{display:flex;flex-wrap:wrap;gap:12px;margin:24px 0}.filters .el-input,.filters .el-select{width:175px}.filters .el-button{margin:0}.note{font-size:12px;color:#95bddc;line-height:1.8}.charts{display:grid;grid-template-columns:1fr 1fr;gap:22px}.charts article{border:1px solid #1c496d;border-radius:5px;overflow:hidden;min-width:0}.charts h2{margin:0;padding:12px 18px;background:linear-gradient(90deg,#0b548e,#073663);font-size:16px;color:#d8efff}.chart{height:450px}.product-analysis ::v-deep .el-input__inner{background:#082749;border-color:#1a527d;color:#d6ecff}.product-analysis ::v-deep .el-range-input{background:transparent;color:#d6ecff}.product-analysis ::v-deep .el-range-separator{color:#aacbe7}@media(max-width:900px){.charts{grid-template-columns:1fr}h1{font-size:20px}}
+</style>

+ 51 - 0
src/views/tianzhu/taskStatistics.vue

@@ -0,0 +1,51 @@
+<template>
+  <div class="page">
+    <header class="header">
+      <el-button icon="el-icon-back" @click="$router.push('/tianzhu/production')">返回总览</el-button>
+      <h2>任务执行统计</h2>
+      <div class="filters"><el-date-picker v-model="months" type="monthrange" value-format="yyyy-MM" :clearable="false" range-separator="至" /><el-button type="primary" icon="el-icon-search" @click="load">查询</el-button></div>
+    </header>
+    <p class="note">任务完成统计按任务下达时间计算;计划及实际方量按计划浇筑时间计算。</p>
+    <el-alert v-if="error" type="error" :title="error" :closable="false" />
+    <main><section v-for="item in chartItems" :key="item.key" class="panel"><h3>{{ item.title }}<span>{{ item.unit }}</span></h3><div :ref="item.key" class="chart"></div></section></main>
+  </div>
+</template>
+<script>
+import * as echarts from 'echarts';
+import { getTaskStatistics } from '@/api/tianzhu/production';
+const pad = n => String(n).padStart(2, '0');
+const currentMonth = () => { const d = new Date(); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}`; };
+const number = input => Number(input || 0);
+export default {
+  name: 'TianzhuTaskStatistics',
+  data() { const month = currentMonth(); return { months: [month, month], dashboard: {}, error: '', charts: [] }; },
+  computed: { chartItems() { return [
+    { key: 'customerRank', title: '客户任务完成排名', unit: '单位:个' }, { key: 'projectCompare', title: '工程任务完成对比', unit: '单位:个' },
+    { key: 'dailyTasks', title: '每日任务下达趋势', unit: '单位:个' }, { key: 'planActual', title: '计划 vs 实际方量对比', unit: '单位:m³' },
+    { key: 'dailyPlan', title: '每日计划方量趋势', unit: '单位:m³' }, { key: 'customerRate', title: '各客户任务完成率', unit: '单位:%' }
+  ]; } },
+  mounted() { window.addEventListener('resize', this.resize); this.load(); }, beforeDestroy() { window.removeEventListener('resize', this.resize); this.dispose(); },
+  methods: {
+    query() { const [start, end] = this.months; const [year, month] = end.split('-').map(Number); const next = new Date(year, month, 1); return { startTime: `${start}-01 00:00:00`, endTime: `${next.getFullYear()}-${pad(next.getMonth() + 1)}-01 00:00:00`, mixOrgId: this.$route.query.mixOrgId || null }; },
+    async load() { this.error = ''; try { this.dashboard = await getTaskStatistics(this.query()); this.$nextTick(this.draw); } catch (error) { this.error = error.message || '获取任务执行统计数据失败'; } },
+    dispose() { this.charts.forEach(chart => chart.dispose()); this.charts = []; }, resize() { this.charts.forEach(chart => chart.resize()); },
+    init(key) { const element = Array.isArray(this.$refs[key]) ? this.$refs[key][0] : this.$refs[key]; if (!element) return null; const chart = echarts.init(element); this.charts.push(chart); return chart; },
+    axes(rows, label, unit, rotate) { return { grid: { left: 58, right: 18, top: 34, bottom: 58 }, xAxis: { type: 'category', data: rows.map(row => row[label]), axisLabel: { color: '#9bc7eb', interval: 0, rotate: rotate ? 28 : 0 }, axisLine: { lineStyle: { color: '#197abb' } } }, yAxis: { type: 'value', name: unit, nameTextStyle: { color: '#9bc7eb' }, axisLabel: { color: '#9bc7eb' }, splitLine: { lineStyle: { color: 'rgba(105,175,226,.22)', type: 'dashed' } } } }; },
+    rank(key, rows, unit, color) { const chart = this.init(key); if (!chart) return; const sorted = [...rows].sort((a, b) => number(b.completedTaskCount) - number(a.completedTaskCount)); chart.setOption({ tooltip: { trigger: 'axis', valueFormatter: value => number(value) + ' ' + unit}, grid: { left: 120, right: 38, top: 20, bottom: 22 }, xAxis: { type: 'value', name: unit, nameTextStyle: { color: '#9bc7eb' }, axisLabel: { color: '#9bc7eb' }, splitLine: { lineStyle: { color: 'rgba(105,175,226,.22)', type: 'dashed' } } }, yAxis: { type: 'category', inverse: true, data: sorted.map(row => row.name), axisLabel: { color: '#9bc7eb', width: 105, overflow: 'truncate' }, axisLine: { lineStyle: { color: '#197abb' } } }, series: [{ type: 'bar', data: sorted.map(row => number(row.completedTaskCount)), barMaxWidth: 24, label: { show: true, position: 'right', color: '#d8ecff' }, itemStyle: { color, borderRadius: [0, 4, 4, 0] } }] }); },
+    draw() {
+      this.dispose(); const data = this.dashboard || {};
+      const customers = data.customerCompletionRanks || []; this.rank('customerRank', customers, '个', '#20b7ff'); let chart;
+      const projects = data.projectCompletionComparisons || []; chart = this.init('projectCompare'); if (chart) chart.setOption({ tooltip: { trigger: 'axis', valueFormatter: value => `${number(value)} 个` }, legend: { top: 0, textStyle: { color: '#a9cdeb' } }, ...this.axes(projects, 'name', '个', true), series: [{ name: '任务总数', type: 'bar', data: projects.map(row => number(row.taskTotal)), itemStyle: { color: '#ffae42', borderRadius: [4, 4, 0, 0] }, barMaxWidth: 25 }, { name: '已完成任务', type: 'bar', data: projects.map(row => number(row.completedTaskCount)), itemStyle: { color: '#36e0a4', borderRadius: [4, 4, 0, 0] }, barMaxWidth: 25 }] });
+      const dailyTasks = data.dailyTaskTrends || []; chart = this.init('dailyTasks'); if (chart) chart.setOption({ tooltip: { trigger: 'axis', valueFormatter: value => `${number(value)} 个` }, ...this.axes(dailyTasks, 'statDate', '个'), series: [{ name: '下达任务', type: 'line', smooth: true, data: dailyTasks.map(row => number(row.taskCount)), lineStyle: { color: '#a582ff' }, itemStyle: { color: '#a582ff' }, areaStyle: { color: 'rgba(165,130,255,.22)' } }] });
+      const total = data.planActualVolume || {}; chart = this.init('planActual'); if (chart) chart.setOption({ tooltip: { trigger: 'axis', valueFormatter: value => `${number(value).toLocaleString()} m³` }, ...this.axes([{ name: '方量对比' }], 'name', 'm³'), series: [{ name: '计划方量', type: 'bar', data: [number(total.plannedVolume)], itemStyle: { color: '#ffae42', borderRadius: [4, 4, 0, 0] }, barMaxWidth: 50 }, { name: '实际方量', type: 'bar', data: [number(total.actualVolume)], itemStyle: { color: '#20b7ff', borderRadius: [4, 4, 0, 0] }, barMaxWidth: 50 }], legend: { top: 0, textStyle: { color: '#a9cdeb' } } });
+      const dailyPlan = data.dailyPlanVolumes || []; chart = this.init('dailyPlan'); if (chart) chart.setOption({ tooltip: { trigger: 'axis', valueFormatter: value => `${number(value).toLocaleString()} m³` }, ...this.axes(dailyPlan, 'statDate', 'm³'), series: [{ name: '计划方量', type: 'line', smooth: true, data: dailyPlan.map(row => number(row.plannedVolume)), lineStyle: { color: '#ffae42' }, itemStyle: { color: '#ffae42' }, areaStyle: { color: 'rgba(255,174,66,.22)' } }] });
+      const rates = data.customerCompletionRates || []; chart = this.init('customerRate'); if (chart) chart.setOption({ tooltip: { trigger: 'axis', valueFormatter: value => `${number(value).toFixed(2)} %` }, ...this.axes(rates, 'name', '%', true), series: [{ type: 'bar', data: rates.map(row => number(row.completionRate)), itemStyle: { color: '#36e0a4', borderRadius: [4, 4, 0, 0] }, barMaxWidth: 34 }] });
+    }
+  }
+};
+</script>
+<style scoped>
+.page{min-height:100vh;padding:16px;color:#d8ecff;background:#020d28;background-image:linear-gradient(rgba(31,93,157,.12) 1px,transparent 1px),linear-gradient(90deg,rgba(31,93,157,.12) 1px,transparent 1px);background-size:32px 32px}.header{display:flex;align-items:center;justify-content:space-between;gap:12px;padding-bottom:12px;border-bottom:1px solid #167fcd}h2{margin:0;color:#fff;letter-spacing:3px}.note{margin:12px 0 0;color:#87b7da;font-size:12px}main{display:grid;grid-template-columns:repeat(3,minmax(280px,1fr));gap:12px;margin-top:14px}.panel{height:280px;padding:10px;border:1px solid #167fcd;background:rgba(4,37,88,.86)}h3{margin:0;color:#e6f6ff;font-size:14px}h3 span{margin-left:8px;color:#79a9ce;font-weight:normal;font-size:12px}.chart{height:245px;width:100%}:deep(.el-input__inner){background:#06265b;border-color:#167fcd;color:#d8ecff}:deep(.el-button){background:#06488e;border-color:#168ee1;color:#fff}@media(max-width:1000px){main{grid-template-columns:1fr}.header{flex-wrap:wrap}}
+</style>
+
+

Разница между файлами не показана из-за своего большого размера
+ 298 - 298
yarn.lock


Некоторые файлы не были показаны из-за большого количества измененных файлов