yyb
6 小时以前 154f3f8e6e8a98e0472d9cc02e7a11dc6bc2b0eb
标准/实际成本对比分析联调
已添加1个文件
已修改1个文件
594 ■■■■ 文件已修改
src/api/costAccounting/productionSettlementBatches.js 53 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/costAccounting/stdVsActCostAnalysis/index.vue 541 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/costAccounting/productionSettlementBatches.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,53 @@
import request from "@/utils/request";
// èŽ·å–äº§å“ç±»åˆ«ï¼ˆæŒ‰æœˆä»½ï¼‰
export function getProductTypes(params) {
  return request({
    url: "/productionSettlementBatches/getProductTypes",
    method: "get",
    params,
  });
}
// èŽ·å–ç§‘ç›®ç±»åˆ«ï¼ˆæŒ‰æœˆä»½ï¼‰
export function getSubjectNames(params) {
  return request({
    url: "/productionSettlementBatches/getSubjectNames",
    method: "get",
    params,
  });
}
// æ ‡å‡†æˆæœ¬å¯¼å…¥ï¼ˆel-upload éœ€è¦å®Œæ•´ URL)
export function getImportActionUrl() {
  return `${import.meta.env.VITE_APP_BASE_API}/productionSettlementBatches/import`;
}
// ä¸‹è½½å¯¼å…¥æ¨¡æ¿ï¼ˆGET,返回 blob)
export function downloadTemplate(params) {
  return request({
    url: "/productionSettlementBatches/downloadTemplate",
    method: "get",
    params,
    responseType: "blob",
  });
}
// æ ¸ç®—查询(按月份/产品类型/科目/成本类型)
export function getSettlement(params) {
  return request({
    url: "/productionSettlementBatches/getSettlement",
    method: "get",
    params,
  });
}
// æ±‡æ€»æˆæœ¬ï¼ˆç¬¬äºŒæ¨¡å— KPI)
export function getTotalCosts(params) {
  return request({
    url: "/productionSettlementBatches/getTotalCosts",
    method: "get",
    params,
  });
}
src/views/costAccounting/stdVsActCostAnalysis/index.vue
@@ -29,32 +29,30 @@
      <div class="filter-layout">
        <el-form :model="searchForm" :inline="true" class="filter-form">
          <el-form-item label="月份范围">
          <el-form-item label="月份">
            <el-date-picker
              v-model="searchForm.monthRange"
              type="monthrange"
              range-separator="至"
              start-placeholder="开始月份"
              end-placeholder="结束月份"
              v-model="searchForm.month"
              type="month"
              value-format="YYYY-MM"
              placeholder="选择月份"
              class="w-260"
              @change="handleQuery"
              @change="handleMonthChange"
            />
          </el-form-item>
          <el-form-item label="产品类别">
          <el-form-item label="产品类型">
            <el-select
              v-model="searchForm.category"
              v-model="searchForm.productType"
              clearable
              filterable
              placeholder="全部类别"
              placeholder="全部类型"
              class="w-180"
              @change="handleQuery"
            >
              <el-option
                v-for="item in categoryOptions"
                :key="item"
                :label="item"
                :value="item"
                :key="item.value"
                :label="item.label"
                :value="item.value"
              />
            </el-select>
          </el-form-item>
@@ -62,12 +60,16 @@
            <el-select
              v-model="searchForm.costType"
              clearable
              placeholder="全部类型"
              placeholder="全部成本类型"
              class="w-180"
              @change="handleQuery"
            >
              <el-option label="能耗成本" value="能耗成本" />
              <el-option label="生产成本" value="生产成本" />
              <el-option
                v-for="item in costTypeOptions"
                :key="item.value"
                :label="item.label"
                :value="item.value"
              />
            </el-select>
          </el-form-item>
        </el-form>
@@ -78,30 +80,31 @@
            <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"
            />
            <el-button class="lux-btn" type="success" plain @click="openImportDialog">
              æ ‡å‡†æˆæœ¬å¯¼å…¥
            </el-button>
          </div>
        </div>
      </div>
    </el-card>
    <ImportDialog
      ref="importDialogRef"
      v-model="importDialogVisible"
      title="标准成本导入"
      width="520px"
      :headers="importHeaders"
      :action="importAction"
      :auto-upload="false"
      :limit="1"
      tip-text="仅允许导入 xls、xlsx æ ¼å¼æ–‡ä»¶ã€‚"
      :show-download-template="true"
      :on-success="handleImportSuccess"
      @confirm="handleImportConfirm"
      @download-template="downloadTemplate"
      @close="handleImportDialogClose"
      @cancel="handleImportDialogClose"
    />
    <el-card class="panel-card glass-card kpi-card" shadow="never">
      <div class="kpi-strip">
@@ -194,29 +197,23 @@
        </div>
      </template>
      <el-table :data="pagedTableData" stripe class="lux-table" @sort-change="handleSortChange">
        <el-table-column prop="month" label="月份" width="110" />
        <el-table-column prop="category" label="产品类别" min-width="140" />
        <el-table-column prop="periodTime" label="月份" width="110" />
        <el-table-column prop="productType" label="产品类型" min-width="140" />
        <el-table-column prop="costType" label="成本类型" min-width="120" />
        <el-table-column prop="standardCost" label="标准成本(元)" sortable="custom" align="right">
          <template #default="scope">Â¥{{ formatMoney(scope.row.standardCost) }}</template>
        <el-table-column prop="subjectName" label="科目" min-width="140" show-overflow-tooltip />
        <el-table-column prop="budgetQty" label="预算耗量" sortable="custom" align="right" min-width="120" />
        <el-table-column prop="budgetPrice" label="预算单价" sortable="custom" align="right" min-width="120" />
        <el-table-column prop="budgetTotal" label="预算总成本" sortable="custom" align="right" min-width="130">
          <template #default="scope">Â¥{{ formatMoney(scope.row.budgetTotal) }}</template>
        </el-table-column>
        <el-table-column prop="actualCost" label="实际成本(元)" sortable="custom" align="right">
          <template #default="scope">Â¥{{ formatMoney(scope.row.actualCost) }}</template>
        <el-table-column prop="actualQty" label="实际耗量" sortable="custom" align="right" min-width="120" />
        <el-table-column prop="actualPrice" label="实际单价" sortable="custom" align="right" min-width="120" />
        <el-table-column prop="actualTotal" label="实际总成本" sortable="custom" align="right" min-width="130">
          <template #default="scope">Â¥{{ formatMoney(scope.row.actualTotal) }}</template>
        </el-table-column>
        <el-table-column prop="diff" label="差异(元)" sortable="custom" 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="差异率" sortable="custom" 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-column prop="diffQty" label="耗量差异" min-width="110" align="right" />
        <el-table-column prop="diffPrice" label="单价差异" min-width="110" align="right" />
        <el-table-column prop="diffTotal" label="总成本差异" min-width="110" align="right" />
      </el-table>
      <div class="pagination-container">
        <el-pagination
@@ -247,23 +244,40 @@
  ZoomIn,
} from "@element-plus/icons-vue";
import { ElMessage } from "element-plus";
import ImportDialog from "@/components/Dialog/ImportDialog.vue";
import { getToken } from "@/utils/auth.js";
import * as echarts from "echarts";
// import * as XLSX from "xlsx";
import {
  downloadTemplate as downloadProductionSettlementTemplate,
  getImportActionUrl,
  getSettlement,
  getTotalCosts,
  getProductTypes,
} from "@/api/costAccounting/productionSettlementBatches";
const getDefaultMonthRange = () => {
const getDefaultMonth = () => {
  const end = new Date();
  const start = new Date();
  start.setMonth(start.getMonth() - 2);
  return [start.toISOString().slice(0, 7), end.toISOString().slice(0, 7)];
  return end.toISOString().slice(0, 7);
};
const searchForm = reactive({
  monthRange: getDefaultMonthRange(),
  category: "",
  month: getDefaultMonth(),
  productType: "",
  costType: "",
});
const uploadRef = ref();
const categoryOptions = ref([]);
const costTypeOptions = ref([]);
const importDialogVisible = ref(false);
const importDialogRef = ref(null);
const importHeaders = computed(() => ({
  Authorization: `Bearer ${getToken()}`,
}));
const importAction = computed(() => getImportActionUrl());
const chartRef = ref(null);
const largeChartRef = ref(null);
let chartInstance = null;
@@ -271,150 +285,22 @@
const largeChartVisible = ref(false);
const currentChartOption = ref(null);
// ------------------------------
// å‡æ•°æ®ï¼šç”¨äºŽå…ˆè”调页面渲染
// ------------------------------
const actualCostSource = ref([]);
const standardCostSource = ref([]);
const fakeMonths = ["2026-01", "2026-02", "2026-03"];
const fakeCategories = [
  "粉煤灰",
  "石灰",
  "æ°´æ³¥",
  "铝粉膏",
  "脱模剂",
  "石膏",
  "打包带",
  "防腐剂(板材用)",
  "氧化镁(板材用)",
  "冷挤丝(板材用)",
  "卡扣(板材用)",
  "材料小计",
  "æ°´",
  "电",
  "蒸汽",
];
const fakeCostType = (category) => (["æ°´", "电", "蒸汽"].includes(category) ? "能耗成本" : "生产成本");
// æ¯ä¸ªç±»åˆ«çš„æ ‡å‡†æˆæœ¬åŸºå‡†å€¼ï¼ˆä»…用于假数据)
const baseStandardCostByCategory = {
  ç²‰ç…¤ç°: 98000,
  çŸ³ç°: 52000,
  æ°´æ³¥: 175000,
  é“ç²‰è†: 32000,
  è„±æ¨¡å‰‚: 21000,
  çŸ³è†: 41000,
  æ‰“包带: 14500,
  "防腐剂(板材用)": 12500,
  "氧化镁(板材用)": 22000,
  "冷挤丝(板材用)": 9800,
  "卡扣(板材用)": 8600,
  ææ–™å°è®¡: 420000,
  æ°´: 6800,
  ç”µ: 26000,
  è’¸æ±½: 52000,
};
// æœˆä»½æ³¢åŠ¨ç³»æ•°ï¼ˆè®©å›¾è¡¨çœ‹èµ·æ¥æ›´â€œçœŸå®žâ€ä¸€äº›ï¼‰
const monthFactorByMonth = {
  "2026-01": 1.0,
  "2026-02": 1.06,
  "2026-03": 0.97,
};
// å®žé™…成本相对标准成本的偏移比例(用于测试正负差异展示)
const diffRatioByCategory = {
  ç²‰ç…¤ç°: 0.05,
  çŸ³ç°: -0.01,
  æ°´æ³¥: 0.03,
  é“ç²‰è†: 0.0,
  è„±æ¨¡å‰‚: -0.04,
  çŸ³è†: 0.02,
  æ‰“包带: -0.03,
  "防腐剂(板材用)": 0.06,
  "氧化镁(板材用)": -0.02,
  "冷挤丝(板材用)": 0.01,
  "卡扣(板材用)": -0.05,
  ææ–™å°è®¡: 0.02,
  æ°´: -0.01,
  ç”µ: 0.04,
  è’¸æ±½: -0.03,
};
const buildFakeSources = () => {
  const stdRows = [];
  const actRows = [];
  for (const month of fakeMonths) {
    const monthFactor = monthFactorByMonth[month] ?? 1;
    const monthAdj = month === "2026-02" ? 0.005 : month === "2026-03" ? -0.006 : 0;
    for (const category of fakeCategories) {
      const costType = fakeCostType(category);
      const base = baseStandardCostByCategory[category] ?? 0;
      const standardCost = Math.round(base * monthFactor);
      const diffRatio = (diffRatioByCategory[category] ?? 0) + monthAdj;
      const actualCost = Math.round(standardCost * (1 + diffRatio));
      stdRows.push({ month, category, costType, standardCost });
      actRows.push({ month, category, costType, actualCost });
    }
  }
  standardCostSource.value = stdRows;
  actualCostSource.value = actRows;
};
buildFakeSources();
const categoryOptions = computed(() => {
  const all = [...actualCostSource.value, ...standardCostSource.value];
  return Array.from(new Set(all.map((item) => item.category)));
const settlementRows = ref([]);
const totalCosts = reactive({
  budgetTotal: 0,
  actualTotal: 0,
  diffTotal: 0,
  diffRate: "0%",
});
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(() => {
  return (Array.isArray(settlementRows.value) ? settlementRows.value : []).filter((item) => {
    const hitMonth = !searchForm.month || item.periodTime === searchForm.month;
    const hitProductType = !searchForm.productType || item.productType === searchForm.productType;
    const hitCostType = !searchForm.costType || item.costType === searchForm.costType;
    return hitMonth && hitProductType && hitCostType;
  });
});
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,
@@ -454,26 +340,45 @@
  return sortedTableData.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 overview = computed(() => ({
  standardCost: Number(totalCosts.budgetTotal || 0),
  actualCost: Number(totalCosts.actualTotal || 0),
  diff: Number(totalCosts.diffTotal || 0),
  diffRate: totalCosts.diffRate ?? "0%",
}));
const getChartData = () => {
  const xAxis = tableData.value.map(
    (item) => `${item.month}\n${item.category}-${item.costType.replace("成本", "")}`
  // å›¾è¡¨å£å¾„:按“科目”汇总展示全部科目
  const agg = new Map();
  for (const row of tableData.value) {
    const subjectName = String(row?.subjectName || "").trim() || "-";
    const budgetTotal = Number(row?.budgetTotal || 0);
    const actualTotal = Number(row?.actualTotal || 0);
    const bucket = agg.get(subjectName) || { subjectName, budgetTotal: 0, actualTotal: 0 };
    bucket.budgetTotal += Number.isFinite(budgetTotal) ? budgetTotal : 0;
    bucket.actualTotal += Number.isFinite(actualTotal) ? actualTotal : 0;
    agg.set(subjectName, bucket);
  }
  const rows = Array.from(agg.values()).sort((a, b) =>
    String(a.subjectName).localeCompare(String(b.subjectName), "zh-Hans-CN")
  );
  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 xAxis = rows.map((item) => item.subjectName);
  const standard = rows.map((item) => item.budgetTotal);
  const actual = rows.map((item) => item.actualTotal);
  const diffRate = rows.map((item) => {
    const base = Number(item.budgetTotal || 0);
    const diff = Number(item.actualTotal || 0) - base;
    if (!base) return 0;
    return (diff / base) * 100;
  });
  return { xAxis, standard, actual, diffRate, rows };
};
const buildChartOption = () => {
  const { xAxis, standard, actual, diffRate } = getChartData();
  const { xAxis, standard, actual, diffRate, rows } = getChartData();
  return {
    animation: true,
    animationDuration: 920,
@@ -493,13 +398,17 @@
      textStyle: { color: "rgba(15, 23, 42, 0.88)" },
      extraCssText: "box-shadow: 0 12px 40px rgba(15, 23, 42, 0.12); border-radius: 12px;",
      formatter: (params) => {
        const row = tableData.value[params[0]?.dataIndex] || {};
        const row = rows?.[params[0]?.dataIndex] || {};
        const budgetTotal = Number(row?.budgetTotal || 0);
        const actualTotal = Number(row?.actualTotal || 0);
        const diff = actualTotal - budgetTotal;
        const rate = budgetTotal ? (diff / budgetTotal) * 100 : 0;
        return [
          `${row.month || ""} ${row.category || ""} ${row.costType || ""}`,
          `标准成本:¥${formatMoney(row.standardCost || 0)}`,
          `实际成本:¥${formatMoney(row.actualCost || 0)}`,
          `差异:${formatSignedMoney(row.diff || 0)}`,
          `差异率:${formatPercent(row.diffRate || 0)}`,
          `科目:${row.subjectName || "-"}`,
          `预算总成本:¥${formatMoney(budgetTotal)}`,
          `实际总成本:¥${formatMoney(actualTotal)}`,
          `差异:${formatSignedMoney(diff)}`,
          `差异率:${formatPercent(rate)}`,
        ].join("<br/>");
      },
    },
@@ -636,72 +545,144 @@
  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 openImportDialog = () => {
  importDialogVisible.value = true;
};
const openUploadSelector = () => {
  const input = uploadRef.value?.$el?.querySelector?.("input[type='file']");
  if (!input) {
    ElMessage.warning("上传组件尚未就绪,请稍后重试");
    return;
  }
  input.click();
const handleImportConfirm = () => {
  importDialogRef.value?.submit?.();
};
const handleImportCommand = (command) => {
  if (command === "template") {
    downloadTemplate();
    return;
  }
  if (command === "upload") {
    openUploadSelector();
  }
const handleImportDialogClose = () => {
  importDialogRef.value?.clearFiles?.();
};
const downloadTemplate = () => {
  const sample = [
    { æœˆä»½: "2026-03", äº§å“ç±»åˆ«: "粉煤灰", æˆæœ¬ç±»åž‹: "标准生产成本", æ ‡å‡†æˆæœ¬: 98000 },
    { æœˆä»½: "2026-03", äº§å“ç±»åˆ«: "æ°´æ³¥", æˆæœ¬ç±»åž‹: "标准生产成本", æ ‡å‡†æˆæœ¬: 175000 },
    { æœˆä»½: "2026-03", äº§å“ç±»åˆ«: "电", æˆæœ¬ç±»åž‹: "标准能耗成本", æ ‡å‡†æˆæœ¬: 26000 },
    { æœˆä»½: "2026-03", äº§å“ç±»åˆ«: "蒸汽", æˆæœ¬ç±»åž‹: "标准能耗成本", æ ‡å‡†æˆæœ¬: 52000 },
    { æœˆä»½: "2026-03", äº§å“ç±»åˆ«: "æ°´", æˆæœ¬ç±»åž‹: "标准能耗成本", æ ‡å‡†æˆæœ¬: 6800 },
  ];
  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("模板已下载");
  downloadProductionSettlementTemplate({ periodTime: searchForm.month || undefined })
    .then((data) => {
      const blob =
        data instanceof Blob
          ? data
          : new Blob([data], {
              type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
            });
      const url = window.URL.createObjectURL(blob);
      const link = document.createElement("a");
      link.href = url;
      link.download = "标准成本导入模板.xlsx";
      document.body.appendChild(link);
      link.click();
      document.body.removeChild(link);
      window.URL.revokeObjectURL(url);
      ElMessage.success("模板下载成功");
    })
    .catch(() => {
      ElMessage.error("模板下载失败");
    });
};
const handleQuery = () => {
  updateChart();
const handleImportSuccess = (response) => {
  const code = response?.code;
  const msg = response?.msg || response?.message;
  if (code === 200) {
    ElMessage.success(msg || "导入成功");
    importDialogVisible.value = false;
    importDialogRef.value?.clearFiles?.();
    handleQuery();
    return;
  }
  ElMessage.error(msg || "导入失败");
};
const normalizeSettlementResponse = (data) => {
  const map = data && typeof data === "object" ? data : {};
  const rows = [];
  for (const [costType, list] of Object.entries(map)) {
    if (!Array.isArray(list)) continue;
    for (const item of list) {
      rows.push({
        ...item,
        periodTime: searchForm.month,
        costType: costType,
      });
    }
  }
  return rows;
};
const fetchCategoryOptions = async () => {
  if (!searchForm.month) {
    categoryOptions.value = [];
    return;
  }
  try {
    const { data } = await getProductTypes({ periodTime: searchForm.month });
    const list = Array.isArray(data) ? data : data?.records || [];
    categoryOptions.value = list.map((item) => ({
      label: item.label || item.name || item.typeName || item,
      value: item.value || item.code || item.typeCode || item,
    }));
  } catch (e) {
    categoryOptions.value = [];
  }
};
const fetchCostTypeOptions = async () => {
  if (!searchForm.month) {
    costTypeOptions.value = [];
    return;
  }
  try {
    // ä¸å¸¦ costType,拿到完整分组 key ç”¨äºŽä¸‹æ‹‰é€‰é¡¹
    const res = await getSettlement({ periodTime: searchForm.month });
    const map = res?.data || {};
    const keys = Object.keys(map || {});
    costTypeOptions.value = keys.map((k) => ({ label: k, value: k }));
  } catch (e) {
    costTypeOptions.value = [];
  }
};
const handleMonthChange = () => {
  searchForm.productType = "";
  searchForm.costType = "";
  Promise.all([fetchCategoryOptions(), fetchCostTypeOptions()]).then(() => {
    handleQuery();
  });
};
const handleQuery = async () => {
  try {
    const params = {
      periodTime: searchForm.month || undefined,
      productType: searchForm.productType || undefined,
      costType: searchForm.costType || undefined,
    };
    const [settlementRes, totalRes] = await Promise.all([getSettlement(params), getTotalCosts(params)]);
    const map = settlementRes?.data || {};
    const rows = normalizeSettlementResponse(map);
    settlementRows.value = rows;
    const totals = totalRes?.data || {};
    totalCosts.budgetTotal = Number(totals.budgetTotal || 0);
    totalCosts.actualTotal = Number(totals.actualTotal || 0);
    totalCosts.diffTotal = Number(totals.diffTotal || 0);
    totalCosts.diffRate = totals.diffRate ?? "0%";
    updateChart();
  } catch (e) {
    settlementRows.value = [];
    totalCosts.budgetTotal = 0;
    totalCosts.actualTotal = 0;
    totalCosts.diffTotal = 0;
    totalCosts.diffRate = "0%";
    updateChart();
  }
};
const handleReset = () => {
  searchForm.monthRange = getDefaultMonthRange();
  searchForm.category = "";
  searchForm.month = getDefaultMonth();
  searchForm.productType = "";
  searchForm.costType = "";
  tableSort.prop = "";
  tableSort.order = "";
@@ -735,6 +716,7 @@
};
const formatPercent = (v) => {
  if (typeof v === "string" && v.trim().endsWith("%")) return v.trim();
  const n = Number.parseFloat(v);
  const value = Number.isFinite(n) ? n : 0;
  const sign = value >= 0 ? "+" : "";
@@ -802,6 +784,9 @@
    }
    updateChart();
  });
  Promise.all([fetchCategoryOptions(), fetchCostTypeOptions()]).then(() => {
    handleQuery();
  });
  window.addEventListener("resize", handleResize);
});