| | |
| | | |
| | | <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> |
| | |
| | | <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> |
| | |
| | | <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"> |
| | |
| | | </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 |
| | |
| | | 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({ periodTime: searchForm.month || undefined }) |
| | | ); |
| | | |
| | | const chartRef = ref(null); |
| | | const largeChartRef = ref(null); |
| | | let chartInstance = 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 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, |
| | |
| | | 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, |
| | |
| | | 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/>"); |
| | | }, |
| | | }, |
| | |
| | | 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", 产品类别: "瓷砖", 成本类型: "标准能耗成本", 标准成本: 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("模板已下载"); |
| | | 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 = ""; |
| | |
| | | }; |
| | | |
| | | 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 ? "+" : ""; |
| | |
| | | } |
| | | updateChart(); |
| | | }); |
| | | Promise.all([fetchCategoryOptions(), fetchCostTypeOptions()]).then(() => { |
| | | handleQuery(); |
| | | }); |
| | | window.addEventListener("resize", handleResize); |
| | | }); |
| | | |