Merge branch 'dev_银川_中盛建材' of http://114.132.189.42:9002/r/product-inventory-management into dev_银川_中盛建材
| | |
| | | label: "æä½", |
| | | align: "center", |
| | | fixed: "right", |
| | | width: 250, |
| | | width: 150, |
| | | operation: [ |
| | | { |
| | | name: "ç¼è¾", |
| | |
| | | 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); |
| | | // }, |
| | | // }, |
| | | ], |
| | | }, |
| | | ]); |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <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> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <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> |
| | |
| | | :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> |
| | |
| | | { |
| | | label: "ç产订åå·", |
| | | prop: "npsNo", |
| | | width: "120px", |
| | | width: "150px", |
| | | }, |
| | | { |
| | | label: "产ååç§°", |