zhangwencui
10 小时以前 31328593a2e9d99ad82044bed3331ba070f657ff
Merge branch 'dev_银川_中盛建材' of http://114.132.189.42:9002/r/product-inventory-management into dev_银川_中盛建材
已添加2个文件
已修改2个文件
1788 ■■■■■ 文件已修改
src/views/basicData/customerFile/index.vue 30 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/costAccounting/productionCostAccounting/index.vue 905 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/costAccounting/stdVsActCostAnalysis/index.vue 849 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/productionManagement/productionOrder/index.vue 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/basicData/customerFile/index.vue
@@ -784,7 +784,7 @@
      label: "操作",
      align: "center",
      fixed: "right",
      width: 250,
      width: 150,
      operation: [
        {
          name: "编辑",
@@ -800,20 +800,20 @@
            openDetailDialog(row);
          },
        },
        {
          name: "回访提醒",
          type: "text",
          clickFun: row => {
            openReminderDialog(row);
          },
        },
        {
          name: "添加洽谈进度",
          type: "text",
          clickFun: row => {
            openNegotiationDialog(row);
          },
        },
        // {
        //   name: "回访提醒",
        //   type: "text",
        //   clickFun: row => {
        //     openReminderDialog(row);
        //   },
        // },
        // {
        //   name: "添加洽谈进度",
        //   type: "text",
        //   clickFun: row => {
        //     openNegotiationDialog(row);
        //   },
        // },
      ],
    },
  ]);
src/views/costAccounting/productionCostAccounting/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,905 @@
<template>
  <div class="production-cost-page">
    <el-card class="filter-card" shadow="never">
      <template #header>
        <div class="card-head">
          <div class="card-head-left">
            <el-icon class="card-icon ui-icon"><DataLine /></el-icon>
            <span class="card-title">生产成本核算</span>
            <span class="subtle">成本 = Î£ æŠ•入量 Ã— å¯¹åº”单价</span>
          </div>
          <div class="card-head-right">
            <el-radio-group
              v-model="statisticsType"
              size="small"
              @change="handleTypeChange"
            >
              <el-radio-button label="day">按日</el-radio-button>
              <el-radio-button label="month">按月</el-radio-button>
            </el-radio-group>
          </div>
        </div>
      </template>
      <div class="filter-layout">
        <el-form :model="searchForm" :inline="true" class="filter-form">
          <el-form-item label="时间范围">
            <el-date-picker
              v-if="statisticsType === 'day'"
              v-model="searchForm.dateRange"
              type="daterange"
              range-separator="至"
              start-placeholder="开始日期"
              end-placeholder="结束日期"
              value-format="YYYY-MM-DD"
              class="w-260"
              @change="handleQuery"
            />
            <el-date-picker
              v-else
              v-model="searchForm.monthRange"
              type="monthrange"
              range-separator="至"
              start-placeholder="开始月份"
              end-placeholder="结束月份"
              value-format="YYYY-MM"
              class="w-260"
              @change="handleQuery"
            />
          </el-form-item>
          <el-form-item label="产品类别">
            <el-select
              v-model="searchForm.category"
              clearable
              filterable
              placeholder="全部类别"
              class="w-180"
              @change="handleQuery"
            >
              <el-option
                v-for="item in categoryOptions"
                :key="item"
                :label="item"
                :value="item"
              />
            </el-select>
          </el-form-item>
          <el-form-item label="生产订单">
            <el-select
              v-model="searchForm.orderNo"
              clearable
              filterable
              placeholder="全部订单"
              class="w-180"
              @change="handleQuery"
            >
              <el-option
                v-for="item in orderOptions"
                :key="item"
                :label="item"
                :value="item"
              />
            </el-select>
          </el-form-item>
        </el-form>
        <div class="filter-actions">
          <el-button class="lux-btn" type="primary" @click="handleQuery">
            åˆ·æ–°
          </el-button>
          <el-button class="lux-btn" @click="handleReset">重置</el-button>
          <el-button class="lux-btn" type="success" plain @click="handleExport">
            å¯¼å‡º
          </el-button>
        </div>
      </div>
    </el-card>
    <el-card class="panel-card" shadow="never">
      <div class="kpi-strip">
        <div class="kpi-item kpi-total">
          <div class="kpi-label">总生产成本</div>
          <div class="kpi-value">Â¥{{ formatMoney(overview.totalCost) }}</div>
        </div>
        <div class="kpi-item kpi-raw">
          <div class="kpi-label">原料成本</div>
          <div class="kpi-value">Â¥{{ formatMoney(overview.rawCost) }}</div>
        </div>
        <div class="kpi-item kpi-aux">
          <div class="kpi-label">辅料成本</div>
          <div class="kpi-value">Â¥{{ formatMoney(overview.auxCost) }}</div>
        </div>
        <div class="kpi-item kpi-order">
          <div class="kpi-label">订单数量</div>
          <div class="kpi-value">{{ overview.orderCount }}</div>
        </div>
      </div>
    </el-card>
    <el-row :gutter="14" class="summary-row">
      <el-col :span="12">
        <el-card class="table-card" shadow="never">
          <template #header>
            <div class="panel-head">
              <span class="card-title">按产品类别汇总</span>
            </div>
          </template>
          <el-table :data="categorySummary" stripe class="lux-table" height="260">
            <el-table-column prop="category" label="产品类别" min-width="140" />
            <el-table-column prop="rawCost" label="原料成本(元)" align="right">
              <template #default="scope">
                <span class="price-value">{{ formatMoney(scope.row.rawCost) }}</span>
              </template>
            </el-table-column>
            <el-table-column prop="auxCost" label="辅料成本(元)" align="right">
              <template #default="scope">
                <span class="price-value">{{ formatMoney(scope.row.auxCost) }}</span>
              </template>
            </el-table-column>
            <el-table-column prop="totalCost" label="总成本(元)" align="right">
              <template #default="scope">
                <span class="cost-value">Â¥{{ formatMoney(scope.row.totalCost) }}</span>
              </template>
            </el-table-column>
          </el-table>
        </el-card>
      </el-col>
      <el-col :span="12">
        <el-card class="table-card" shadow="never">
          <template #header>
            <div class="panel-head">
              <span class="card-title">按生产订单汇总</span>
            </div>
          </template>
          <el-table :data="orderSummary" stripe class="lux-table" height="260">
            <el-table-column prop="orderNo" label="生产订单" min-width="150" />
            <el-table-column prop="category" label="产品类别" min-width="120" />
            <el-table-column prop="totalCost" label="总成本(元)" align="right">
              <template #default="scope">
                <span class="cost-value">Â¥{{ formatMoney(scope.row.totalCost) }}</span>
              </template>
            </el-table-column>
          </el-table>
        </el-card>
      </el-col>
    </el-row>
    <el-card class="table-card" shadow="never">
      <template #header>
        <div class="panel-head">
          <span class="card-title">多维度汇总明细</span>
          <span class="subtle">{{ timeColumnLabel }} + äº§å“ç±»åˆ« + ç”Ÿäº§è®¢å•</span>
        </div>
      </template>
      <el-table :data="pagedTableData" stripe class="lux-table">
        <el-table-column prop="timeLabel" :label="timeColumnLabel" min-width="110" />
        <el-table-column prop="category" label="产品类别" min-width="120" />
        <el-table-column prop="orderNo" label="生产订单" min-width="150" />
        <el-table-column prop="rawCost" label="原料成本(元)" align="right">
          <template #default="scope">
            <span class="price-value">{{ formatMoney(scope.row.rawCost) }}</span>
          </template>
        </el-table-column>
        <el-table-column prop="auxCost" label="辅料成本(元)" align="right">
          <template #default="scope">
            <span class="price-value">{{ formatMoney(scope.row.auxCost) }}</span>
          </template>
        </el-table-column>
        <el-table-column prop="totalCost" label="总成本(元)" align="right">
          <template #default="scope">
            <span class="cost-value">Â¥{{ formatMoney(scope.row.totalCost) }}</span>
          </template>
        </el-table-column>
        <el-table-column label="拆分明细" width="92" fixed="right">
          <template #default="scope">
            <el-button link type="primary" @click="openDetail(scope.row)">查看</el-button>
          </template>
        </el-table-column>
      </el-table>
      <div class="pagination-container">
        <el-pagination
          v-model:current-page="page.current"
          v-model:page-size="page.size"
          :page-sizes="[10, 20, 50, 100]"
          :total="tableData.length"
          layout="total, sizes, prev, pager, next, jumper"
          @size-change="handleSizeChange"
          @current-change="handleCurrentChange"
        />
      </div>
    </el-card>
    <el-drawer
      v-model="detailVisible"
      :with-header="false"
      class="detail-drawer"
      size="760px"
      :close-on-click-modal="true"
      :close-on-press-escape="true"
      destroy-on-close
    >
      <div v-if="detailRow" class="drawer-head">
        <div class="meta-item">
          <span class="meta-label">{{ timeColumnLabel }}</span>
          <span class="meta-value">{{ detailRow.timeLabel }}</span>
        </div>
        <div class="meta-item">
          <span class="meta-label">产品类别</span>
          <span class="meta-value">{{ detailRow.category }}</span>
        </div>
        <div class="meta-item">
          <span class="meta-label">生产订单</span>
          <span class="meta-value">{{ detailRow.orderNo }}</span>
        </div>
      </div>
      <el-table :data="detailMaterials" class="lux-table" stripe>
        <el-table-column prop="materialName" label="物料名称" min-width="120" />
        <el-table-column prop="materialType" label="类型" width="94">
          <template #default="scope">
            <span
              class="material-type-tag"
              :class="scope.row.materialType === '原料' ? 'is-raw' : 'is-aux'"
            >
              {{ scope.row.materialType }}
            </span>
          </template>
        </el-table-column>
        <el-table-column prop="quantity" label="投入量" align="right" min-width="140">
          <template #default="scope">
            <span class="quantity-cell">
              <span class="quantity-value">{{ formatNumber(scope.row.quantity, 2) }}</span>
              <span class="quantity-unit">{{ scope.row.unit }}</span>
            </span>
          </template>
        </el-table-column>
        <el-table-column prop="unitPrice" label="单价(元)" align="right">
          <template #default="scope">
            {{ formatNumber(scope.row.unitPrice, 2) }}
          </template>
        </el-table-column>
        <el-table-column prop="cost" label="成本(元)" align="right" min-width="132">
          <template #default="scope">
            <span class="cost-value no-wrap-money">Â¥{{ formatMoney(scope.row.cost) }}</span>
          </template>
        </el-table-column>
      </el-table>
      <div class="drawer-foot">
        <div class="foot-item">
          <span class="foot-label">原料</span>
          <span class="foot-value no-wrap-money">Â¥{{ formatMoney(detailRawCost) }}</span>
        </div>
        <div class="foot-item">
          <span class="foot-label">辅料</span>
          <span class="foot-value no-wrap-money">Â¥{{ formatMoney(detailAuxCost) }}</span>
        </div>
        <div class="foot-item total">
          <span class="foot-label">合计</span>
          <span class="foot-value no-wrap-money">Â¥{{ formatMoney(detailTotalCost) }}</span>
        </div>
      </div>
    </el-drawer>
  </div>
</template>
<script setup>
import { computed, reactive, ref, watch } from "vue";
import { DataLine } from "@element-plus/icons-vue";
import { ElMessage } from "element-plus";
const statisticsType = ref("day");
const getDefaultDateRange = () => {
  const end = new Date();
  const start = new Date();
  start.setDate(start.getDate() - 6);
  return [start.toISOString().slice(0, 10), end.toISOString().slice(0, 10)];
};
const getDefaultMonthRange = () => {
  const end = new Date();
  const start = new Date();
  start.setMonth(start.getMonth() - 2);
  return [start.toISOString().slice(0, 7), end.toISOString().slice(0, 7)];
};
const searchForm = reactive({
  dateRange: getDefaultDateRange(),
  monthRange: getDefaultMonthRange(),
  category: "",
  orderNo: "",
});
const sourceRecords = ref([
  { date: "2026-03-17", category: "瓷砖", orderNo: "PO-260317-01", materialName: "陶瓷粉", materialType: "原料", quantity: 1200, unit: "kg", unitPrice: 2.8 },
  { date: "2026-03-17", category: "瓷砖", orderNo: "PO-260317-01", materialName: "釉料", materialType: "辅料", quantity: 180, unit: "kg", unitPrice: 8.6 },
  { date: "2026-03-17", category: "æ°´æ³¥", orderNo: "PO-260317-02", materialName: "熟料", materialType: "原料", quantity: 2200, unit: "kg", unitPrice: 1.36 },
  { date: "2026-03-17", category: "æ°´æ³¥", orderNo: "PO-260317-02", materialName: "石膏", materialType: "辅料", quantity: 260, unit: "kg", unitPrice: 0.92 },
  { date: "2026-03-18", category: "砂浆", orderNo: "PO-260318-01", materialName: "机制砂", materialType: "原料", quantity: 1600, unit: "kg", unitPrice: 0.58 },
  { date: "2026-03-18", category: "砂浆", orderNo: "PO-260318-01", materialName: "保水剂", materialType: "辅料", quantity: 65, unit: "kg", unitPrice: 11.4 },
  { date: "2026-03-19", category: "瓷砖", orderNo: "PO-260319-01", materialName: "陶瓷粉", materialType: "原料", quantity: 980, unit: "kg", unitPrice: 2.9 },
  { date: "2026-03-19", category: "瓷砖", orderNo: "PO-260319-01", materialName: "色料", materialType: "辅料", quantity: 42, unit: "kg", unitPrice: 15.8 },
  { date: "2026-03-19", category: "砂浆", orderNo: "PO-260319-03", materialName: "机制砂", materialType: "原料", quantity: 1400, unit: "kg", unitPrice: 0.56 },
  { date: "2026-03-19", category: "砂浆", orderNo: "PO-260319-03", materialName: "减水剂", materialType: "辅料", quantity: 74, unit: "kg", unitPrice: 7.2 },
  { date: "2026-03-20", category: "æ°´æ³¥", orderNo: "PO-260320-02", materialName: "熟料", materialType: "原料", quantity: 2400, unit: "kg", unitPrice: 1.33 },
  { date: "2026-03-20", category: "æ°´æ³¥", orderNo: "PO-260320-02", materialName: "矿粉", materialType: "辅料", quantity: 380, unit: "kg", unitPrice: 1.08 },
]);
const normalizedRecords = computed(() =>
  sourceRecords.value.map((item) => {
    const month = item.date.slice(0, 7);
    const cost = Number(item.quantity) * Number(item.unitPrice);
    return { ...item, month, cost };
  })
);
const categoryOptions = computed(() =>
  Array.from(new Set(normalizedRecords.value.map((item) => item.category)))
);
const orderOptions = computed(() =>
  Array.from(new Set(normalizedRecords.value.map((item) => item.orderNo)))
);
const inRange = (value, range) => {
  if (!Array.isArray(range) || range.length !== 2 || !range[0] || !range[1]) return true;
  return value >= range[0] && value <= range[1];
};
const getMonthRangeDays = (monthRange) => {
  if (!Array.isArray(monthRange) || monthRange.length !== 2 || !monthRange[0] || !monthRange[1]) {
    return 0;
  }
  const [startMonth, endMonth] = monthRange;
  const startDate = new Date(`${startMonth}-01T00:00:00`);
  const endDate = new Date(`${endMonth}-01T00:00:00`);
  if (Number.isNaN(startDate.getTime()) || Number.isNaN(endDate.getTime()) || startDate > endDate) {
    return 0;
  }
  const endMonthLastDay = new Date(endDate.getFullYear(), endDate.getMonth() + 1, 0);
  const diffMs = endMonthLastDay.getTime() - startDate.getTime();
  return Math.floor(diffMs / (24 * 60 * 60 * 1000)) + 1;
};
const buildQueryParams = () => {
  const isDay = statisticsType.value === "day";
  const params = {
    statisticsType: statisticsType.value,
    category: searchForm.category || undefined,
    orderNo: searchForm.orderNo || undefined,
  };
  if (isDay) {
    const [startDate, endDate] = searchForm.dateRange || [];
    params.startDate = startDate;
    params.endDate = endDate;
  } else {
    const [startMonth, endMonth] = searchForm.monthRange || [];
    params.startMonth = startMonth;
    params.endMonth = endMonth;
    params.days = getMonthRangeDays(searchForm.monthRange);
  }
  return params;
};
const filteredRecords = computed(() =>
  normalizedRecords.value.filter((item) => {
    const hitTime =
      statisticsType.value === "day"
        ? inRange(item.date, searchForm.dateRange)
        : inRange(item.month, searchForm.monthRange);
    const hitCategory = !searchForm.category || item.category === searchForm.category;
    const hitOrder = !searchForm.orderNo || item.orderNo === searchForm.orderNo;
    return hitTime && hitCategory && hitOrder;
  })
);
const timeColumnLabel = computed(() => (statisticsType.value === "day" ? "日期" : "月份"));
const aggregateBy = (list, keyFn) => {
  const map = new Map();
  for (const item of list) {
    const key = keyFn(item);
    if (!map.has(key)) {
      map.set(key, {
        rawCost: 0,
        auxCost: 0,
        totalCost: 0,
        materials: [],
      });
    }
    const bucket = map.get(key);
    if (item.materialType === "原料") bucket.rawCost += item.cost;
    if (item.materialType === "辅料") bucket.auxCost += item.cost;
    bucket.totalCost += item.cost;
    bucket.materials.push(item);
  }
  return map;
};
const groupedMap = computed(() =>
  aggregateBy(filteredRecords.value, (item) => {
    const timeKey = statisticsType.value === "day" ? item.date : item.month;
    return `${timeKey}__${item.category}__${item.orderNo}`;
  })
);
const tableData = computed(() => {
  const rows = [];
  for (const [key, val] of groupedMap.value) {
    const [timeLabel, category, orderNo] = key.split("__");
    rows.push({
      key,
      timeLabel,
      category,
      orderNo,
      rawCost: val.rawCost,
      auxCost: val.auxCost,
      totalCost: val.totalCost,
      materials: val.materials,
    });
  }
  return rows.sort((a, b) => (a.timeLabel > b.timeLabel ? -1 : 1));
});
const page = reactive({
  current: 1,
  size: 10,
});
const pagedTableData = computed(() => {
  const start = (page.current - 1) * page.size;
  return tableData.value.slice(start, start + page.size);
});
const categorySummary = computed(() => {
  const map = aggregateBy(filteredRecords.value, (item) => item.category);
  const rows = [];
  for (const [category, val] of map) {
    rows.push({
      category,
      rawCost: val.rawCost,
      auxCost: val.auxCost,
      totalCost: val.totalCost,
    });
  }
  return rows.sort((a, b) => b.totalCost - a.totalCost);
});
const orderSummary = computed(() => {
  const map = aggregateBy(filteredRecords.value, (item) => item.orderNo);
  const rows = [];
  for (const [orderNo, val] of map) {
    rows.push({
      orderNo,
      category: val.materials[0]?.category || "-",
      totalCost: val.totalCost,
    });
  }
  return rows.sort((a, b) => b.totalCost - a.totalCost);
});
const overview = computed(() => {
  const rawCost = filteredRecords.value
    .filter((item) => item.materialType === "原料")
    .reduce((sum, item) => sum + item.cost, 0);
  const auxCost = filteredRecords.value
    .filter((item) => item.materialType === "辅料")
    .reduce((sum, item) => sum + item.cost, 0);
  const orderCount = new Set(filteredRecords.value.map((item) => item.orderNo)).size;
  return {
    rawCost,
    auxCost,
    totalCost: rawCost + auxCost,
    orderCount,
  };
});
const detailVisible = ref(false);
const detailRow = ref(null);
const detailMaterials = computed(() => detailRow.value?.materials || []);
const detailRawCost = computed(() =>
  detailMaterials.value
    .filter((item) => item.materialType === "原料")
    .reduce((sum, item) => sum + item.cost, 0)
);
const detailAuxCost = computed(() =>
  detailMaterials.value
    .filter((item) => item.materialType === "辅料")
    .reduce((sum, item) => sum + item.cost, 0)
);
const detailTotalCost = computed(() =>
  detailMaterials.value.reduce((sum, item) => sum + item.cost, 0)
);
const openDetail = (row) => {
  detailRow.value = row;
  detailVisible.value = true;
};
const handleTypeChange = () => {
  handleQuery();
};
const handleQuery = () => {
  page.current = 1;
  const queryParams = buildQueryParams();
  console.log("[productionCostAccounting] query params:", queryParams);
  ElMessage.success("已按条件完成汇总");
};
const handleReset = () => {
  searchForm.dateRange = getDefaultDateRange();
  searchForm.monthRange = getDefaultMonthRange();
  searchForm.category = "";
  searchForm.orderNo = "";
  handleQuery();
};
const handleSizeChange = (val) => {
  page.size = val;
  page.current = 1;
};
const handleCurrentChange = (val) => {
  page.current = val;
};
const handleExport = () => {
  const headers = [timeColumnLabel.value, "产品类别", "生产订单", "原料成本", "辅料成本", "总成本"];
  const lines = tableData.value.map((row) =>
    [
      row.timeLabel,
      row.category,
      row.orderNo,
      row.rawCost.toFixed(2),
      row.auxCost.toFixed(2),
      row.totalCost.toFixed(2),
    ].join(",")
  );
  const csv = [headers.join(","), ...lines].join("\n");
  const blob = new Blob(["\uFEFF" + csv], { type: "text/csv;charset=utf-8;" });
  const url = URL.createObjectURL(blob);
  const link = document.createElement("a");
  link.href = url;
  link.download = `生产成本汇总_${statisticsType.value}_${Date.now()}.csv`;
  link.click();
  URL.revokeObjectURL(url);
  ElMessage.success("导出成功");
};
const formatMoney = (v) => {
  const n = Number.parseFloat(v);
  const value = Number.isFinite(n) ? n : 0;
  return value.toLocaleString("zh-CN", {
    minimumFractionDigits: 2,
    maximumFractionDigits: 2,
  });
};
const formatNumber = (v, digits = 2) => {
  const n = Number.parseFloat(v);
  if (!Number.isFinite(n)) return "--";
  return n.toLocaleString("zh-CN", {
    minimumFractionDigits: digits,
    maximumFractionDigits: digits,
  });
};
watch(tableData, () => {
  const maxPage = Math.max(1, Math.ceil(tableData.value.length / page.size));
  if (page.current > maxPage) page.current = maxPage;
});
</script>
<style scoped lang="scss">
.production-cost-page {
  --lux-bg: #f6f7fb;
  --lux-card: rgba(255, 255, 255, 0.86);
  --lux-border: rgba(15, 23, 42, 0.08);
  --lux-text: rgba(15, 23, 42, 0.92);
  --lux-subtle: rgba(15, 23, 42, 0.58);
  --lux-muted: rgba(15, 23, 42, 0.38);
  --lux-primary: #2f6fed;
  --lux-success: #16a34a;
  --lux-warning: #f59e0b;
  --lux-danger: #ef4444;
  --lux-shadow: 0 18px 50px rgba(15, 23, 42, 0.08);
  --lux-shadow-soft: 0 10px 28px rgba(15, 23, 42, 0.06);
  --lux-radius: 14px;
  padding: 18px 22px 24px;
  background: radial-gradient(
      1200px 420px at 20% 0%,
      rgba(47, 111, 237, 0.1),
      transparent 55%
    ),
    linear-gradient(180deg, var(--lux-bg) 0%, #ffffff 58%);
}
.filter-card,
.panel-card,
.table-card {
  border-radius: var(--lux-radius);
  border-color: var(--lux-border);
  background: var(--lux-card);
  box-shadow: var(--lux-shadow-soft);
}
.filter-card {
  margin-bottom: 16px;
}
.panel-card,
.summary-row {
  margin-bottom: 14px;
}
.filter-layout {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 14px;
}
.filter-form {
  display: flex;
  flex-wrap: wrap;
  gap: 10px 14px;
}
.filter-form :deep(.el-form-item) {
  margin: 0;
}
.filter-actions {
  display: flex;
  gap: 10px;
}
.card-head,
.panel-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
}
.card-head-left {
  display: flex;
  align-items: center;
  gap: 8px;
}
.card-head-right {
  display: flex;
  align-items: center;
}
.card-icon {
  color: var(--lux-primary);
}
.card-title {
  font-weight: 760;
  color: var(--lux-text);
}
.subtle {
  color: var(--lux-subtle);
  font-size: 12px;
}
.kpi-strip {
  display: grid;
  grid-template-columns: repeat(4, minmax(0, 1fr));
  gap: 12px;
}
.kpi-item {
  padding: 12px 14px;
  border-radius: 12px;
  border: 1px solid rgba(15, 23, 42, 0.08);
}
.kpi-total {
  background: linear-gradient(135deg, rgba(47, 111, 237, 0.1), rgba(255, 255, 255, 0.86));
}
.kpi-raw {
  background: linear-gradient(135deg, rgba(22, 163, 74, 0.1), rgba(255, 255, 255, 0.86));
}
.kpi-aux {
  background: linear-gradient(135deg, rgba(245, 158, 11, 0.1), rgba(255, 255, 255, 0.86));
}
.kpi-order {
  background: linear-gradient(135deg, rgba(100, 116, 139, 0.1), rgba(255, 255, 255, 0.86));
}
.kpi-label {
  font-size: 12px;
  color: var(--lux-subtle);
}
.kpi-value {
  font-size: 22px;
  margin-top: 6px;
  font-weight: 780;
  color: var(--lux-text);
}
.price-value {
  font-weight: 700;
  color: var(--lux-success);
}
.cost-value {
  font-weight: 700;
  color: var(--lux-danger);
}
.no-wrap-money {
  display: inline-block;
  white-space: nowrap;
}
.drawer-head {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: 10px;
  margin-bottom: 12px;
}
.meta-item {
  padding: 10px 12px;
  border-radius: 10px;
  border: 1px solid var(--lux-border);
  background: rgba(15, 23, 42, 0.03);
  display: grid;
  gap: 4px;
}
.meta-label {
  font-size: 12px;
  color: var(--lux-subtle);
}
.meta-value {
  color: var(--lux-text);
  font-size: 16px;
  font-weight: 700;
}
.material-type-tag {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: 46px;
  height: 24px;
  padding: 0 8px;
  border-radius: 999px;
  font-size: 12px;
  font-weight: 700;
}
.material-type-tag.is-raw {
  color: #15803d;
  background: rgba(22, 163, 74, 0.12);
}
.material-type-tag.is-aux {
  color: #b45309;
  background: rgba(245, 158, 11, 0.16);
}
.quantity-value {
  font-weight: 700;
  color: var(--lux-text);
  margin-right: 6px;
}
.quantity-cell {
  display: inline-flex;
  align-items: baseline;
  white-space: nowrap;
}
.quantity-unit {
  color: var(--lux-subtle);
}
.drawer-foot {
  display: flex;
  justify-content: flex-end;
  gap: 12px;
  margin-top: 12px;
  padding-top: 12px;
  border-top: 1px dashed var(--lux-border);
}
.foot-item {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  padding: 7px 16px;
  border-radius: 999px;
  border: 1px solid var(--lux-border);
  background: #fff;
}
.foot-label {
  color: var(--lux-subtle);
  font-size: 14px;
}
.foot-value {
  color: var(--lux-text);
  font-weight: 700;
  font-size: 16px;
}
.foot-item.total {
  border-color: rgba(47, 111, 237, 0.26);
  background: rgba(47, 111, 237, 0.08);
}
.foot-item.total .foot-value {
  color: #1e3a8a;
  font-size: 18px;
  font-weight: 800;
}
.pagination-container {
  display: flex;
  justify-content: flex-end;
  padding-top: 12px;
}
.strong {
  font-weight: 800;
}
.w-260 {
  width: 260px;
}
.w-180 {
  width: 180px;
}
::deep(.lux-table) {
  border-radius: 12px;
  overflow: hidden;
}
::deep(.lux-table th.el-table__cell) {
  background: rgba(15, 23, 42, 0.03);
}
::deep(.lux-table .el-table__row:hover > td.el-table__cell) {
  background-color: rgba(47, 111, 237, 0.06) !important;
}
@media (max-width: 1100px) {
  .kpi-strip {
    grid-template-columns: repeat(2, minmax(0, 1fr));
  }
  .filter-layout {
    flex-direction: column;
  }
  .drawer-head {
    grid-template-columns: 1fr;
  }
  .drawer-foot {
    justify-content: flex-start;
    flex-wrap: wrap;
  }
}
</style>
src/views/costAccounting/stdVsActCostAnalysis/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,849 @@
<template>
  <div class="std-cost-page">
    <el-card class="filter-card" shadow="never">
      <template #header>
        <div class="card-head">
          <div class="card-head-left">
            <el-icon class="card-icon ui-icon"><DataLine /></el-icon>
            <span class="card-title">标准/实际成本对比分析</span>
            <span class="subtle">差异 = å®žé™…成本 - æ ‡å‡†æˆæœ¬</span>
          </div>
        </div>
      </template>
      <div class="filter-layout">
        <el-form :model="searchForm" :inline="true" class="filter-form">
          <el-form-item label="月份范围">
            <el-date-picker
              v-model="searchForm.monthRange"
              type="monthrange"
              range-separator="至"
              start-placeholder="开始月份"
              end-placeholder="结束月份"
              value-format="YYYY-MM"
              class="w-260"
              @change="handleQuery"
            />
          </el-form-item>
          <el-form-item label="产品类别">
            <el-select
              v-model="searchForm.category"
              clearable
              filterable
              placeholder="全部类别"
              class="w-180"
              @change="handleQuery"
            >
              <el-option
                v-for="item in categoryOptions"
                :key="item"
                :label="item"
                :value="item"
              />
            </el-select>
          </el-form-item>
          <el-form-item label="成本类型">
            <el-select
              v-model="searchForm.costType"
              clearable
              placeholder="全部类型"
              class="w-180"
              @change="handleQuery"
            >
              <el-option label="能耗成本" value="能耗成本" />
              <el-option label="生产成本" value="生产成本" />
            </el-select>
          </el-form-item>
        </el-form>
        <div class="filter-actions">
          <div class="action-group">
            <el-button class="lux-btn" type="primary" @click="handleQuery">刷新</el-button>
            <el-button class="lux-btn" @click="handleReset">重置</el-button>
          </div>
          <div class="action-group">
            <el-dropdown trigger="click" @command="handleImportCommand">
              <el-button class="lux-btn" type="success" plain>
                æ ‡å‡†æˆæœ¬å¯¼å…¥
                <el-icon class="el-icon--right"><ArrowDown /></el-icon>
              </el-button>
              <template #dropdown>
                <el-dropdown-menu>
                  <el-dropdown-item command="template">下载导入模板</el-dropdown-item>
                  <el-dropdown-item command="upload">Excel å¯¼å…¥</el-dropdown-item>
                </el-dropdown-menu>
              </template>
            </el-dropdown>
            <el-upload
              ref="uploadRef"
              class="hidden-upload"
              :auto-upload="false"
              :show-file-list="false"
              accept=".xlsx,.xls"
              :on-change="handleFileChange"
            />
          </div>
        </div>
      </div>
    </el-card>
    <el-card class="panel-card" shadow="never">
      <div class="kpi-strip">
        <div class="kpi-item kpi-std">
          <div class="kpi-label">标准成本合计</div>
          <div class="kpi-value">Â¥{{ formatMoney(overview.standardCost) }}</div>
        </div>
        <div class="kpi-item kpi-act">
          <div class="kpi-label">实际成本合计</div>
          <div class="kpi-value">Â¥{{ formatMoney(overview.actualCost) }}</div>
        </div>
        <div class="kpi-item kpi-diff">
          <div class="kpi-label">差异合计</div>
          <div class="kpi-value" :class="overview.diff >= 0 ? 'cost-value' : 'ok-value'">
            Â¥{{ formatMoney(overview.diff) }}
          </div>
        </div>
        <div class="kpi-item kpi-rate">
          <div class="kpi-label">差异率</div>
          <div class="kpi-value">{{ formatPercent(overview.diffRate) }}</div>
        </div>
      </div>
    </el-card>
    <el-card class="table-card" shadow="never">
      <template #header>
        <div class="panel-head">
          <span class="card-title">标准/实际成本可视化(柱状 + æŠ˜çº¿ï¼‰</span>
          <span class="subtle">支持按月份、产品类别、成本类型筛选</span>
        </div>
      </template>
      <div class="chart-wrap">
        <div class="chart-tools chart-tools-inline" @click.stop>
          <button class="chart-tool" type="button" @click="openLargeChart">查看大图</button>
          <button class="chart-tool" type="button" @click="downloadChartImage">下载图表</button>
        </div>
        <div ref="chartRef" class="chart-content"></div>
      </div>
    </el-card>
    <el-dialog
      v-model="largeChartVisible"
      title="标准/实际成本对比大图"
      width="88%"
      top="6vh"
      destroy-on-close
      @opened="initLargeChart"
      @closed="disposeLargeChart"
    >
      <div ref="largeChartRef" class="large-chart-content"></div>
    </el-dialog>
    <el-card class="table-card" shadow="never">
      <template #header>
        <div class="panel-head">
          <span class="card-title">对比明细</span>
          <span class="subtle">共 {{ tableData.length }} æ¡</span>
        </div>
      </template>
      <el-table :data="pagedTableData" stripe class="lux-table">
        <el-table-column prop="month" label="月份" width="110" />
        <el-table-column prop="category" label="产品类别" min-width="140" />
        <el-table-column prop="costType" label="成本类型" min-width="120" />
        <el-table-column prop="standardCost" label="标准成本(元)" align="right">
          <template #default="scope">Â¥{{ formatMoney(scope.row.standardCost) }}</template>
        </el-table-column>
        <el-table-column prop="actualCost" label="实际成本(元)" align="right">
          <template #default="scope">Â¥{{ formatMoney(scope.row.actualCost) }}</template>
        </el-table-column>
        <el-table-column prop="diff" label="差异(元)" align="right">
          <template #default="scope">
            <span :class="scope.row.diff >= 0 ? 'cost-value' : 'ok-value'">
              {{ formatSignedMoney(scope.row.diff) }}
            </span>
          </template>
        </el-table-column>
        <el-table-column prop="diffRate" label="差异率" align="right">
          <template #default="scope">
            <span :class="scope.row.diffRate >= 0 ? 'cost-value' : 'ok-value'">
              {{ formatPercent(scope.row.diffRate) }}
            </span>
          </template>
        </el-table-column>
      </el-table>
      <div class="pagination-container">
        <el-pagination
          v-model:current-page="page.current"
          v-model:page-size="page.size"
          :page-sizes="[10, 20, 50, 100]"
          :total="tableData.length"
          layout="total, sizes, prev, pager, next, jumper"
          @size-change="handleSizeChange"
          @current-change="handleCurrentChange"
        />
      </div>
    </el-card>
  </div>
</template>
<script setup>
import { computed, nextTick, onMounted, onUnmounted, reactive, ref, watch } from "vue";
import { ArrowDown, DataLine } from "@element-plus/icons-vue";
import { ElMessage } from "element-plus";
import * as echarts from "echarts";
import * as XLSX from "xlsx";
const getDefaultMonthRange = () => {
  const end = new Date();
  const start = new Date();
  start.setMonth(start.getMonth() - 2);
  return [start.toISOString().slice(0, 7), end.toISOString().slice(0, 7)];
};
const searchForm = reactive({
  monthRange: getDefaultMonthRange(),
  category: "",
  costType: "",
});
const uploadRef = ref();
const chartRef = ref(null);
const largeChartRef = ref(null);
let chartInstance = null;
let largeChartInstance = null;
const largeChartVisible = ref(false);
const currentChartOption = ref(null);
const actualCostSource = ref([
  { month: "2026-01", category: "瓷砖", costType: "能耗成本", actualCost: 182000 },
  { month: "2026-01", category: "瓷砖", costType: "生产成本", actualCost: 465000 },
  { month: "2026-01", category: "æ°´æ³¥", costType: "能耗成本", actualCost: 138500 },
  { month: "2026-01", category: "æ°´æ³¥", costType: "生产成本", actualCost: 398000 },
  { month: "2026-02", category: "瓷砖", costType: "能耗成本", actualCost: 191500 },
  { month: "2026-02", category: "瓷砖", costType: "生产成本", actualCost: 472500 },
  { month: "2026-02", category: "æ°´æ³¥", costType: "能耗成本", actualCost: 142300 },
  { month: "2026-02", category: "æ°´æ³¥", costType: "生产成本", actualCost: 407000 },
  { month: "2026-03", category: "砂浆", costType: "能耗成本", actualCost: 95800 },
  { month: "2026-03", category: "砂浆", costType: "生产成本", actualCost: 265400 },
  { month: "2026-03", category: "瓷砖", costType: "能耗成本", actualCost: 189800 },
  { month: "2026-03", category: "瓷砖", costType: "生产成本", actualCost: 469900 },
]);
const standardCostSource = ref([
  { month: "2026-01", category: "瓷砖", costType: "能耗成本", standardCost: 176000 },
  { month: "2026-01", category: "瓷砖", costType: "生产成本", standardCost: 452000 },
  { month: "2026-01", category: "æ°´æ³¥", costType: "能耗成本", standardCost: 136000 },
  { month: "2026-01", category: "æ°´æ³¥", costType: "生产成本", standardCost: 392000 },
  { month: "2026-02", category: "瓷砖", costType: "能耗成本", standardCost: 186000 },
  { month: "2026-02", category: "瓷砖", costType: "生产成本", standardCost: 458000 },
  { month: "2026-02", category: "æ°´æ³¥", costType: "能耗成本", standardCost: 139000 },
  { month: "2026-02", category: "æ°´æ³¥", costType: "生产成本", standardCost: 401000 },
  { month: "2026-03", category: "砂浆", costType: "能耗成本", standardCost: 93000 },
  { month: "2026-03", category: "砂浆", costType: "生产成本", standardCost: 259000 },
  { month: "2026-03", category: "瓷砖", costType: "能耗成本", standardCost: 185000 },
  { month: "2026-03", category: "瓷砖", costType: "生产成本", standardCost: 461000 },
]);
const categoryOptions = computed(() => {
  const all = [...actualCostSource.value, ...standardCostSource.value];
  return Array.from(new Set(all.map((item) => item.category)));
});
const inRange = (value, range) => {
  if (!Array.isArray(range) || range.length !== 2 || !range[0] || !range[1]) return true;
  return value >= range[0] && value <= range[1];
};
const mergedRows = computed(() => {
  const key = (item) => `${item.month}__${item.category}__${item.costType}`;
  const stdMap = new Map(standardCostSource.value.map((item) => [key(item), item]));
  const actMap = new Map(actualCostSource.value.map((item) => [key(item), item]));
  const keySet = new Set([...stdMap.keys(), ...actMap.keys()]);
  const rows = [];
  for (const k of keySet) {
    const std = stdMap.get(k);
    const act = actMap.get(k);
    const month = std?.month || act?.month || "";
    const category = std?.category || act?.category || "";
    const costType = std?.costType || act?.costType || "";
    const standardCost = Number(std?.standardCost || 0);
    const actualCost = Number(act?.actualCost || 0);
    const diff = actualCost - standardCost;
    const diffRate = standardCost === 0 ? 0 : (diff / standardCost) * 100;
    rows.push({ month, category, costType, standardCost, actualCost, diff, diffRate });
  }
  return rows.sort((a, b) => {
    if (a.month !== b.month) return a.month > b.month ? 1 : -1;
    if (a.category !== b.category) return a.category.localeCompare(b.category, "zh-Hans-CN");
    return a.costType.localeCompare(b.costType, "zh-Hans-CN");
  });
});
const tableData = computed(() =>
  mergedRows.value.filter((item) => {
    const hitMonth = inRange(item.month, searchForm.monthRange);
    const hitCategory = !searchForm.category || item.category === searchForm.category;
    const hitCostType = !searchForm.costType || item.costType === searchForm.costType;
    return hitMonth && hitCategory && hitCostType;
  })
);
const page = reactive({
  current: 1,
  size: 10,
});
const pagedTableData = computed(() => {
  const start = (page.current - 1) * page.size;
  return tableData.value.slice(start, start + page.size);
});
const overview = computed(() => {
  const standardCost = tableData.value.reduce((sum, item) => sum + item.standardCost, 0);
  const actualCost = tableData.value.reduce((sum, item) => sum + item.actualCost, 0);
  const diff = actualCost - standardCost;
  const diffRate = standardCost === 0 ? 0 : (diff / standardCost) * 100;
  return { standardCost, actualCost, diff, diffRate };
});
const getChartData = () => {
  const xAxis = tableData.value.map(
    (item) => `${item.month}\n${item.category}-${item.costType.replace("成本", "")}`
  );
  const standard = tableData.value.map((item) => item.standardCost);
  const actual = tableData.value.map((item) => item.actualCost);
  const diffRate = tableData.value.map((item) => Number(item.diffRate.toFixed(2)));
  return { xAxis, standard, actual, diffRate };
};
const buildChartOption = () => {
  const { xAxis, standard, actual, diffRate } = getChartData();
  return {
    tooltip: {
      trigger: "axis",
      axisPointer: { type: "shadow" },
      formatter: (params) => {
        const row = tableData.value[params[0]?.dataIndex] || {};
        return [
          `${row.month || ""} ${row.category || ""} ${row.costType || ""}`,
          `标准成本:¥${formatMoney(row.standardCost || 0)}`,
          `实际成本:¥${formatMoney(row.actualCost || 0)}`,
          `差异:${formatSignedMoney(row.diff || 0)}`,
          `差异率:${formatPercent(row.diffRate || 0)}`,
        ].join("<br/>");
      },
    },
    legend: { data: ["标准成本", "实际成本", "差异率"] },
    grid: { left: "4%", right: "4%", top: "16%", bottom: "16%", containLabel: true },
    xAxis: {
      type: "category",
      data: xAxis,
      axisLabel: { color: "rgba(15, 23, 42, 0.62)" },
      axisLine: { lineStyle: { color: "rgba(15, 23, 42, 0.08)" } },
    },
    yAxis: [
      {
        type: "value",
        name: "成本(元)",
        axisLabel: { color: "rgba(15, 23, 42, 0.58)" },
        splitLine: { lineStyle: { color: "rgba(15, 23, 42, 0.06)" } },
      },
      {
        type: "value",
        name: "差异率(%)",
        axisLabel: { color: "rgba(15, 23, 42, 0.58)" },
        splitLine: { show: false },
      },
    ],
    series: [
      {
        name: "标准成本",
        type: "bar",
        barMaxWidth: 24,
        data: standard,
        itemStyle: { color: "#5b8cff", borderRadius: [4, 4, 0, 0] },
      },
      {
        name: "实际成本",
        type: "bar",
        barMaxWidth: 24,
        data: actual,
        itemStyle: { color: "#f59e0b", borderRadius: [4, 4, 0, 0] },
      },
      {
        name: "差异率",
        type: "line",
        yAxisIndex: 1,
        smooth: true,
        data: diffRate,
        itemStyle: { color: "#ef4444" },
        lineStyle: { width: 2 },
      },
    ],
  };
};
const updateChart = () => {
  const option = buildChartOption();
  currentChartOption.value = option;
  chartInstance?.setOption(option);
  largeChartInstance?.setOption(option);
};
const normalizeCostType = (value) => {
  const text = String(value || "").trim();
  if (!text) return "";
  if (text.includes("能耗")) return "能耗成本";
  if (text.includes("生产")) return "生产成本";
  return text;
};
const parseImportedRows = (rows) => {
  const normalized = rows
    .map((item) => {
      const month = String(item["月份"] || item.month || "").slice(0, 7);
      const category = String(item["产品类别"] || item.category || "").trim();
      const costType = normalizeCostType(item["成本类型"] || item.costType);
      const standardCost = Number(item["标准成本"] ?? item.standardCost ?? 0);
      return { month, category, costType, standardCost };
    })
    .filter((item) => item.month && item.category && item.costType && Number.isFinite(item.standardCost));
  return normalized;
};
const replaceStandardSourceByImport = (importRows) => {
  const map = new Map();
  for (const item of importRows) {
    const k = `${item.month}__${item.category}__${item.costType}`;
    map.set(k, item);
  }
  standardCostSource.value = Array.from(map.values());
};
const handleFileChange = async (uploadFile) => {
  try {
    const file = uploadFile.raw;
    if (!file) return;
    const data = await file.arrayBuffer();
    const workbook = XLSX.read(data, { type: "array" });
    const sheetName = workbook.SheetNames[0];
    const sheet = workbook.Sheets[sheetName];
    const rows = XLSX.utils.sheet_to_json(sheet, { defval: "" });
    const parsed = parseImportedRows(rows);
    if (!parsed.length) {
      ElMessage.warning("导入失败:模板内容为空或字段不匹配");
      return;
    }
    replaceStandardSourceByImport(parsed);
    ElMessage.success(`导入成功:${parsed.length} æ¡æ ‡å‡†æˆæœ¬è®°å½•`);
    handleQuery();
  } catch (error) {
    console.error(error);
    ElMessage.error("导入失败,请检查 Excel æ ¼å¼");
  } finally {
    uploadRef.value?.clearFiles?.();
  }
};
const openUploadSelector = () => {
  const input = uploadRef.value?.$el?.querySelector?.("input[type='file']");
  if (!input) {
    ElMessage.warning("上传组件尚未就绪,请稍后重试");
    return;
  }
  input.click();
};
const handleImportCommand = (command) => {
  if (command === "template") {
    downloadTemplate();
    return;
  }
  if (command === "upload") {
    openUploadSelector();
  }
};
const downloadTemplate = () => {
  const sample = [
    { æœˆä»½: "2026-03", äº§å“ç±»åˆ«: "瓷砖", æˆæœ¬ç±»åž‹: "标准能耗成本", æ ‡å‡†æˆæœ¬: 185000 },
    { æœˆä»½: "2026-03", äº§å“ç±»åˆ«: "瓷砖", æˆæœ¬ç±»åž‹: "标准生产成本", æ ‡å‡†æˆæœ¬: 461000 },
    { æœˆä»½: "2026-03", äº§å“ç±»åˆ«: "æ°´æ³¥", æˆæœ¬ç±»åž‹: "标准能耗成本", æ ‡å‡†æˆæœ¬: 140000 },
    { æœˆä»½: "2026-03", äº§å“ç±»åˆ«: "æ°´æ³¥", æˆæœ¬ç±»åž‹: "标准生产成本", æ ‡å‡†æˆæœ¬: 405000 },
  ];
  const ws = XLSX.utils.json_to_sheet(sample);
  const wb = XLSX.utils.book_new();
  XLSX.utils.book_append_sheet(wb, ws, "标准成本模板");
  XLSX.writeFile(wb, "标准成本按月导入模板.xlsx");
  ElMessage.success("模板已下载");
};
const handleQuery = () => {
  updateChart();
};
const handleReset = () => {
  searchForm.monthRange = getDefaultMonthRange();
  searchForm.category = "";
  searchForm.costType = "";
  page.current = 1;
  handleQuery();
};
const handleSizeChange = (val) => {
  page.size = val;
  page.current = 1;
};
const handleCurrentChange = (val) => {
  page.current = val;
};
const formatMoney = (v) => {
  const n = Number.parseFloat(v);
  const value = Number.isFinite(n) ? n : 0;
  return value.toLocaleString("zh-CN", {
    minimumFractionDigits: 2,
    maximumFractionDigits: 2,
  });
};
const formatSignedMoney = (v) => {
  const n = Number.parseFloat(v);
  const value = Number.isFinite(n) ? n : 0;
  const sign = value >= 0 ? "+" : "";
  return `${sign}Â¥${formatMoney(value)}`;
};
const formatPercent = (v) => {
  const n = Number.parseFloat(v);
  const value = Number.isFinite(n) ? n : 0;
  const sign = value >= 0 ? "+" : "";
  return `${sign}${value.toFixed(2)}%`;
};
const handleResize = () => {
  chartInstance?.resize?.();
  largeChartInstance?.resize?.();
};
const openLargeChart = () => {
  if (!tableData.value.length) {
    ElMessage.warning("暂无图表数据可查看");
    return;
  }
  largeChartVisible.value = true;
};
const initLargeChart = () => {
  nextTick(() => {
    if (!largeChartRef.value) return;
    if (!largeChartInstance) {
      largeChartInstance = echarts.init(largeChartRef.value);
    }
    if (currentChartOption.value) {
      largeChartInstance.setOption(currentChartOption.value);
    } else {
      updateChart();
    }
  });
};
const disposeLargeChart = () => {
  largeChartInstance?.dispose?.();
  largeChartInstance = null;
};
const downloadChartImage = () => {
  const sourceChart = chartInstance || largeChartInstance;
  if (!sourceChart) {
    ElMessage.warning("图表尚未加载完成");
    return;
  }
  const url = sourceChart.getDataURL({
    type: "png",
    pixelRatio: 2,
    backgroundColor: "#ffffff",
  });
  const link = document.createElement("a");
  link.href = url;
  link.download = `标准实际成本对比图_${new Date().toISOString().slice(0, 10)}.png`;
  document.body.appendChild(link);
  link.click();
  document.body.removeChild(link);
  ElMessage.success("图表下载成功");
};
onMounted(() => {
  nextTick(() => {
    if (chartRef.value && !chartInstance) {
      chartInstance = echarts.init(chartRef.value);
    }
    updateChart();
  });
  window.addEventListener("resize", handleResize);
});
onUnmounted(() => {
  window.removeEventListener("resize", handleResize);
  chartInstance?.dispose?.();
  chartInstance = null;
  disposeLargeChart();
});
watch(tableData, () => {
  const maxPage = Math.max(1, Math.ceil(tableData.value.length / page.size));
  if (page.current > maxPage) page.current = maxPage;
  nextTick(updateChart);
});
</script>
<style scoped lang="scss">
.std-cost-page {
  --lux-bg: #f6f7fb;
  --lux-card: rgba(255, 255, 255, 0.86);
  --lux-border: rgba(15, 23, 42, 0.08);
  --lux-text: rgba(15, 23, 42, 0.92);
  --lux-subtle: rgba(15, 23, 42, 0.58);
  --lux-primary: #2f6fed;
  --lux-success: #16a34a;
  --lux-warning: #f59e0b;
  --lux-danger: #ef4444;
  --lux-shadow-soft: 0 10px 28px rgba(15, 23, 42, 0.06);
  --lux-radius: 14px;
  padding: 18px 22px 24px;
  background: radial-gradient(
      1200px 420px at 20% 0%,
      rgba(47, 111, 237, 0.1),
      transparent 55%
    ),
    linear-gradient(180deg, var(--lux-bg) 0%, #ffffff 58%);
}
.filter-card,
.panel-card,
.table-card {
  border-radius: var(--lux-radius);
  border-color: var(--lux-border);
  background: var(--lux-card);
  box-shadow: var(--lux-shadow-soft);
}
.filter-card,
.panel-card,
.table-card {
  margin-bottom: 14px;
}
.filter-layout {
  display: flex;
  flex-direction: column;
  gap: 10px;
}
.filter-form {
  display: flex;
  flex-wrap: wrap;
  gap: 10px 14px;
  align-items: center;
}
.filter-form :deep(.el-form-item) {
  margin: 0;
}
.filter-actions {
  display: flex;
  justify-content: flex-end;
  align-items: center;
  flex-wrap: wrap;
  gap: 10px 14px;
  padding-top: 10px;
  border-top: 1px dashed rgba(15, 23, 42, 0.1);
}
.action-group {
  display: flex;
  align-items: center;
  gap: 10px;
  flex-wrap: wrap;
}
.filter-actions :deep(.el-upload) {
  display: inline-flex;
}
.hidden-upload {
  width: 0;
  height: 0;
  overflow: hidden;
}
.card-head,
.panel-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
}
.card-head-left {
  display: flex;
  align-items: center;
  gap: 8px;
}
.card-icon {
  color: var(--lux-primary);
}
.card-title {
  font-weight: 760;
  color: var(--lux-text);
}
.subtle {
  color: var(--lux-subtle);
  font-size: 12px;
}
.kpi-strip {
  display: grid;
  grid-template-columns: repeat(4, minmax(0, 1fr));
  gap: 12px;
}
.kpi-item {
  padding: 12px 14px;
  border-radius: 12px;
  border: 1px solid rgba(15, 23, 42, 0.08);
}
.kpi-std {
  background: linear-gradient(135deg, rgba(47, 111, 237, 0.1), rgba(255, 255, 255, 0.86));
}
.kpi-act {
  background: linear-gradient(135deg, rgba(245, 158, 11, 0.1), rgba(255, 255, 255, 0.86));
}
.kpi-diff {
  background: linear-gradient(135deg, rgba(239, 68, 68, 0.08), rgba(255, 255, 255, 0.86));
}
.kpi-rate {
  background: linear-gradient(135deg, rgba(22, 163, 74, 0.1), rgba(255, 255, 255, 0.86));
}
.kpi-label {
  font-size: 12px;
  color: var(--lux-subtle);
}
.kpi-value {
  margin-top: 6px;
  font-size: 22px;
  font-weight: 780;
  color: var(--lux-text);
}
.cost-value {
  color: var(--lux-danger);
  font-weight: 700;
}
.ok-value {
  color: var(--lux-success);
  font-weight: 700;
}
.chart-wrap {
  position: relative;
  padding-top: 34px;
  border-radius: 12px;
  overflow: hidden;
}
.chart-content {
  height: 360px;
}
.chart-tools {
  display: flex;
  align-items: center;
  gap: 8px;
}
.chart-tools-inline {
  position: absolute;
  top: 4px;
  right: 6px;
  z-index: 2;
}
.chart-tool {
  font-size: 11px;
  font-weight: 650;
  line-height: 1;
  padding: 6px 10px;
  border-radius: 10px;
  border: 1px solid rgba(15, 23, 42, 0.1);
  background: rgba(255, 255, 255, 0.78);
  color: rgba(15, 23, 42, 0.72);
  cursor: pointer;
  transition: background-color 0.16s ease, border-color 0.16s ease, transform 0.16s ease;
}
.chart-tool:hover {
  background: rgba(47, 111, 237, 0.08);
  border-color: rgba(47, 111, 237, 0.22);
  transform: translateY(-1px);
}
.large-chart-content {
  height: 70vh;
  min-height: 520px;
}
.pagination-container {
  display: flex;
  justify-content: flex-end;
  padding-top: 12px;
}
.w-260 {
  width: 260px;
}
.w-180 {
  width: 180px;
}
::deep(.lux-table) {
  border-radius: 12px;
  overflow: hidden;
}
::deep(.lux-table th.el-table__cell) {
  background: rgba(15, 23, 42, 0.03);
}
::deep(.lux-table .el-table__row:hover > td.el-table__cell) {
  background-color: rgba(47, 111, 237, 0.06) !important;
}
@media (max-width: 1100px) {
  .kpi-strip {
    grid-template-columns: repeat(2, minmax(0, 1fr));
  }
  .filter-actions {
    justify-content: flex-start;
    padding-top: 8px;
  }
}
</style>
src/views/productionManagement/productionOrder/index.vue
@@ -91,7 +91,7 @@
                       :status="toProgressPercentage(row?.completionStatus) >= 100 ? 'success' : ''" />
        </template>
        <template #quantity="{ row }">
          {{ row.quantity || '-' }}<span style="color:rgb(63, 95, 211)"> å—</span>
          {{ row.quantity || '-' }}<span style="color:rgb(63, 95, 211)"> æ–¹</span>
        </template>
        <template #completeQuantity="{ row }">
          {{ row.completeQuantity || '-' }}<span style="color:rgb(42, 169, 146)"> æ–¹</span>
@@ -312,7 +312,7 @@
    {
      label: "生产订单号",
      prop: "npsNo",
      width: "120px",
      width: "150px",
    },
    {
      label: "产品名称",