| | |
| | | <template> |
| | | <div class="production-cost-page"> |
| | | <el-card class="filter-card" shadow="never"> |
| | | <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> |
| | | <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"> |
| | | <!-- <div class="card-head-right"> |
| | | <el-radio-group |
| | | v-model="statisticsType" |
| | | size="small" |
| | |
| | | <el-radio-button label="day">按日</el-radio-button> |
| | | <el-radio-button label="month">按月</el-radio-button> |
| | | </el-radio-group> |
| | | </div> |
| | | </div> --> |
| | | </div> |
| | | </template> |
| | | |
| | | <div class="filter-layout"> |
| | | <el-form :model="searchForm" :inline="true" class="filter-form"> |
| | | <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-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 v-model="searchForm.dictCode" |
| | | clearable |
| | | filterable |
| | | placeholder="全部类别" |
| | | class="w-180" |
| | | @change="handleQuery"> |
| | | <el-option v-for="item in categoryOptions" |
| | | :key="item.dictCode" |
| | | :label="item.dictLabel" |
| | | :value="item.dictCode" /> |
| | | </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 v-model="searchForm.productOrderId" |
| | | clearable |
| | | filterable |
| | | placeholder="全部订单" |
| | | class="w-180" |
| | | @change="handleQuery"> |
| | | <el-option v-for="order in orderList" |
| | | :key="order.id" |
| | | :label="`${order.npsNo}`" |
| | | :value="order.id" /> |
| | | </el-select> |
| | | </el-form-item> |
| | | </el-form> |
| | | <div class="filter-actions"> |
| | | <el-button class="lux-btn" type="primary" @click="handleQuery"> |
| | | <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 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"> |
| | | <el-card class="panel-card" |
| | | shadow="never"> |
| | | <div class="kpi-strip"> |
| | | <div class="kpi-item kpi-total"> |
| | | <div class="kpi-label">总生产成本</div> |
| | |
| | | </div> |
| | | </div> |
| | | </el-card> |
| | | |
| | | <el-row :gutter="14" class="summary-row"> |
| | | <el-row :gutter="14" |
| | | class="summary-row"> |
| | | <el-col :span="12"> |
| | | <el-card class="table-card" shadow="never"> |
| | | <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="totalQuantity" label="用量" align="right" min-width="120"> |
| | | <el-table :data="categorySummary" |
| | | stripe |
| | | class="lux-table" |
| | | height="260"> |
| | | <el-table-column prop="name" |
| | | label="产品类别" |
| | | min-width="140" /> |
| | | <el-table-column prop="quantity" |
| | | label="用量" |
| | | align="right" |
| | | min-width="120"> |
| | | <template #default="scope"> |
| | | <span class="quantity-cell"> |
| | | <span class="quantity-value">{{ formatNumber(scope.row.totalQuantity, 2) }}</span> |
| | | <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="totalCost" label="成本(元)" align="right"> |
| | | <el-table-column prop="totalCost" |
| | | label="成本(元)" |
| | | align="right"> |
| | | <template #default="scope"> |
| | | <span class="cost-value">¥{{ formatMoney(scope.row.totalCost) }}</span> |
| | | </template> |
| | |
| | | </el-card> |
| | | </el-col> |
| | | <el-col :span="12"> |
| | | <el-card class="table-card" shadow="never"> |
| | | <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="totalQuantity" label="用量" align="right" min-width="120"> |
| | | <el-table :data="orderSummary" |
| | | stripe |
| | | class="lux-table" |
| | | height="260"> |
| | | <el-table-column prop="name" |
| | | label="生产订单" |
| | | min-width="150" /> |
| | | <el-table-column prop="strength" |
| | | label="产品类别" |
| | | min-width="120" /> |
| | | <el-table-column prop="quantity" |
| | | label="用量" |
| | | align="right" |
| | | min-width="120"> |
| | | <template #default="scope"> |
| | | <span class="quantity-cell"> |
| | | <span class="quantity-value">{{ formatNumber(scope.row.totalQuantity, 2) }}</span> |
| | | <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="totalCost" label="总成本(元)" align="right"> |
| | | <el-table-column prop="totalCost" |
| | | label="总成本(元)" |
| | | align="right"> |
| | | <template #default="scope"> |
| | | <span class="cost-value">¥{{ formatMoney(scope.row.totalCost) }}</span> |
| | | </template> |
| | |
| | | </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="totalQuantity" label="用量" align="right" min-width="130"> |
| | | <template #default="scope"> |
| | | <span class="quantity-cell"> |
| | | <span class="quantity-value">{{ formatNumber(scope.row.totalQuantity, 2) }}</span> |
| | | <span class="quantity-unit">{{ scope.row.unit || "-" }}</span> |
| | | </span> |
| | | <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">产品物料Top10</span> |
| | | </div> |
| | | </template> |
| | | </el-table-column> |
| | | <el-table-column prop="totalCost" label="成本(元)" align="right"> |
| | | <template #default="scope"> |
| | | <span class="cost-value">¥{{ formatMoney(scope.row.totalCost) }}</span> |
| | | <div ref="topOrdersChartRef" |
| | | class="chart-container" |
| | | style="height: 300px;"></div> |
| | | </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">生产订单Top10</span> |
| | | </div> |
| | | </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"> |
| | | </el-card> |
| | | </el-col> |
| | | </el-row> |
| | | <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> |
| | |
| | | <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="quantity" label="投入量" align="right" min-width="140"> |
| | | <el-table :data="detailMaterials" |
| | | class="lux-table" |
| | | stripe> |
| | | <el-table-column prop="materialName" |
| | | label="物料名称" |
| | | min-width="120" /> |
| | | <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> |
| | | </template> |
| | | </el-table-column> |
| | | <el-table-column prop="unitPrice" label="单价(元)" align="right"> |
| | | <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"> |
| | | <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> |
| | |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { computed, reactive, ref, watch } from "vue"; |
| | | import { DataLine } from "@element-plus/icons-vue"; |
| | | import { ElMessage } from "element-plus"; |
| | | import { computed, reactive, ref, watch, onMounted, nextTick } from "vue"; |
| | | import { DataLine } from "@element-plus/icons-vue"; |
| | | import { ElMessage } from "element-plus"; |
| | | import * as echarts from "echarts"; |
| | | import { getDicts } from "@/api/system/dict/data.js"; |
| | | import { productOrderListPage } from "@/api/productionManagement/productionOrder.js"; |
| | | import { |
| | | getProductionCostSummary, |
| | | getProductionCostAggregateByProduct, |
| | | getProductionCostAggregateByOrder, |
| | | getProductionCostTopOrders, |
| | | } from "@/api/costAccounting/productionCost.js"; |
| | | |
| | | const statisticsType = ref("day"); |
| | | 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, |
| | | 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)]; |
| | | }; |
| | | |
| | | 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); |
| | | } |
| | | 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)]; |
| | | }; |
| | | |
| | | return params; |
| | | }; |
| | | const searchForm = reactive({ |
| | | dateRange: getDefaultDateRange(), |
| | | monthRange: getDefaultMonthRange(), |
| | | dictCode: "", |
| | | productOrderId: "", |
| | | }); |
| | | |
| | | 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 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 orderList = ref([]); |
| | | |
| | | const timeColumnLabel = computed(() => (statisticsType.value === "day" ? "日期" : "月份")); |
| | | // 加载生产订单列表 |
| | | const loadOrders = () => { |
| | | productOrderListPage({ pageNum: -1, pageSize: -1 }) |
| | | .then(res => { |
| | | orderList.value = res.data.records || []; |
| | | }) |
| | | .finally(() => {}); |
| | | }; |
| | | 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 aggregateBy = (list, keyFn) => { |
| | | const map = new Map(); |
| | | for (const item of list) { |
| | | const key = keyFn(item); |
| | | if (!map.has(key)) { |
| | | map.set(key, { |
| | | totalCost: 0, |
| | | totalQuantity: 0, |
| | | materials: [], |
| | | const categoryOptions = ref([]); |
| | | // 获取产品类型字典 |
| | | const getProductTypeOptions = () => { |
| | | getDicts("product_type") |
| | | .then(res => { |
| | | if (res.code === 200) { |
| | | categoryOptions.value = res.data; |
| | | } |
| | | }) |
| | | .catch(err => { |
| | | console.error("获取产品类型字典失败:", err); |
| | | }); |
| | | }; |
| | | |
| | | 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, |
| | | dictCode: searchForm.dictCode || undefined, |
| | | productOrderId: searchForm.productOrderId || 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.dictCode || item.dictCode === searchForm.dictCode; |
| | | const hitOrder = |
| | | !searchForm.productOrderId || |
| | | item.productOrderId === searchForm.productOrderId; |
| | | 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, { |
| | | totalCost: 0, |
| | | totalQuantity: 0, |
| | | materials: [], |
| | | }); |
| | | } |
| | | const bucket = map.get(key); |
| | | bucket.totalCost += item.cost; |
| | | bucket.totalQuantity += Number(item.quantity) || 0; |
| | | 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, |
| | | totalQuantity: val.totalQuantity, |
| | | unit: val.materials[0]?.unit || "", |
| | | totalCost: val.totalCost, |
| | | materials: val.materials, |
| | | }); |
| | | } |
| | | const bucket = map.get(key); |
| | | bucket.totalCost += item.cost; |
| | | bucket.totalQuantity += Number(item.quantity) || 0; |
| | | bucket.materials.push(item); |
| | | } |
| | | return map; |
| | | }; |
| | | return rows.sort((a, b) => (a.timeLabel > b.timeLabel ? -1 : 1)); |
| | | }); |
| | | |
| | | const groupedMap = computed(() => |
| | | aggregateBy(filteredRecords.value, (item) => { |
| | | const timeKey = statisticsType.value === "day" ? item.date : item.month; |
| | | return `${timeKey}__${item.category}__${item.orderNo}`; |
| | | }) |
| | | ); |
| | | const page = reactive({ |
| | | current: 1, |
| | | size: 10, |
| | | }); |
| | | |
| | | const tableData = computed(() => { |
| | | const rows = []; |
| | | for (const [key, val] of groupedMap.value) { |
| | | const [timeLabel, category, orderNo] = key.split("__"); |
| | | rows.push({ |
| | | key, |
| | | timeLabel, |
| | | category, |
| | | orderNo, |
| | | totalQuantity: val.totalQuantity, |
| | | unit: val.materials[0]?.unit || "", |
| | | totalCost: val.totalCost, |
| | | materials: val.materials, |
| | | }); |
| | | } |
| | | return rows.sort((a, b) => (a.timeLabel > b.timeLabel ? -1 : 1)); |
| | | }); |
| | | const pagedTableData = computed(() => { |
| | | const start = (page.current - 1) * page.size; |
| | | return tableData.value.slice(start, start + page.size); |
| | | }); |
| | | |
| | | const page = reactive({ |
| | | current: 1, |
| | | size: 10, |
| | | }); |
| | | const categorySummary = ref([]); |
| | | const orderSummary = ref([]); |
| | | const topOrders = ref([]); |
| | | |
| | | const pagedTableData = computed(() => { |
| | | const start = (page.current - 1) * page.size; |
| | | return tableData.value.slice(start, start + page.size); |
| | | }); |
| | | const overview = ref({ |
| | | totalCost: 0, |
| | | orderCount: 0, |
| | | avgCostPerOrder: 0, |
| | | }); |
| | | |
| | | const categorySummary = computed(() => { |
| | | const map = aggregateBy(filteredRecords.value, (item) => item.category); |
| | | const rows = []; |
| | | for (const [category, val] of map) { |
| | | rows.push({ |
| | | category, |
| | | totalQuantity: val.totalQuantity, |
| | | unit: val.materials[0]?.unit || "", |
| | | totalCost: val.totalCost, |
| | | }); |
| | | } |
| | | return rows.sort((a, b) => b.totalCost - a.totalCost); |
| | | }); |
| | | // 图表相关 |
| | | const topOrdersChartRef = ref(null); |
| | | let topOrdersChartInstance = null; |
| | | |
| | | 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 || "-", |
| | | totalQuantity: val.totalQuantity, |
| | | unit: val.materials[0]?.unit || "", |
| | | totalCost: val.totalCost, |
| | | }); |
| | | } |
| | | return rows.sort((a, b) => b.totalCost - a.totalCost); |
| | | }); |
| | | const detailVisible = ref(false); |
| | | const detailRow = ref(null); |
| | | |
| | | const overview = computed(() => { |
| | | const orderCount = new Set(filteredRecords.value.map((item) => item.orderNo)).size; |
| | | const totalCost = filteredRecords.value.reduce((sum, item) => sum + item.cost, 0); |
| | | return { |
| | | totalCost, |
| | | orderCount, |
| | | avgCostPerOrder: orderCount === 0 ? 0 : totalCost / orderCount, |
| | | }; |
| | | }); |
| | | const detailMaterials = computed(() => detailRow.value?.materials || []); |
| | | |
| | | const detailVisible = ref(false); |
| | | const detailRow = ref(null); |
| | | |
| | | const detailMaterials = computed(() => detailRow.value?.materials || []); |
| | | |
| | | 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.totalQuantity.toFixed(2), |
| | | row.unit || "", |
| | | row.totalCost.toFixed(2), |
| | | ].join(",") |
| | | const detailTotalCost = computed(() => |
| | | detailMaterials.value.reduce((sum, item) => sum + item.cost, 0) |
| | | ); |
| | | 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 openDetail = row => { |
| | | detailRow.value = row; |
| | | detailVisible.value = true; |
| | | }; |
| | | |
| | | // 初始化生产订单Top10图表 |
| | | const initTopOrdersChart = () => { |
| | | nextTick(() => { |
| | | if (topOrdersChartRef.value) { |
| | | topOrdersChartInstance = echarts.init(topOrdersChartRef.value); |
| | | updateTopOrdersChart(); |
| | | } |
| | | }); |
| | | }; |
| | | |
| | | // 更新生产订单Top10图表 |
| | | const updateTopOrdersChart = () => { |
| | | if (!topOrdersChartInstance) return; |
| | | |
| | | const data = topOrders.value; |
| | | const xAxisData = data.map(item => item.name); |
| | | const seriesData = data.map(item => item.totalCost); |
| | | |
| | | const option = { |
| | | tooltip: { |
| | | trigger: "axis", |
| | | axisPointer: { |
| | | type: "shadow", |
| | | }, |
| | | backgroundColor: "rgba(255, 255, 255, 0.95)", |
| | | borderColor: "#409EFF", |
| | | borderWidth: 1, |
| | | textStyle: { color: "#303133" }, |
| | | }, |
| | | grid: { |
| | | left: "3%", |
| | | right: "4%", |
| | | bottom: "15%", |
| | | top: "3%", |
| | | containLabel: true, |
| | | }, |
| | | xAxis: { |
| | | type: "category", |
| | | data: xAxisData, |
| | | axisLabel: { |
| | | color: "#606266", |
| | | rotate: 45, |
| | | }, |
| | | axisLine: { lineStyle: { color: "#ebeef5" } }, |
| | | splitLine: { show: false }, |
| | | }, |
| | | yAxis: { |
| | | type: "value", |
| | | name: "成本(元)", |
| | | nameTextStyle: { color: "#606266" }, |
| | | axisLabel: { color: "#606266" }, |
| | | axisLine: { show: false }, |
| | | splitLine: { lineStyle: { color: "#f0f2f5" } }, |
| | | }, |
| | | series: [ |
| | | { |
| | | name: "成本", |
| | | type: "bar", |
| | | data: seriesData, |
| | | itemStyle: { |
| | | color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [ |
| | | { offset: 0, color: "#409EFF" }, |
| | | { offset: 1, color: "#66B1FF" }, |
| | | ]), |
| | | }, |
| | | barWidth: "60%", |
| | | }, |
| | | ], |
| | | }; |
| | | |
| | | topOrdersChartInstance.setOption(option); |
| | | }; |
| | | |
| | | // 窗口大小变化时重新渲染图表 |
| | | const handleResize = () => { |
| | | topOrdersChartInstance && topOrdersChartInstance.resize(); |
| | | }; |
| | | |
| | | const handleTypeChange = () => { |
| | | handleQuery(); |
| | | }; |
| | | |
| | | const handleQuery = () => { |
| | | page.current = 1; |
| | | // 构建API请求参数 |
| | | const apiParams = { |
| | | startDate: searchForm.dateRange?.[0], |
| | | endDate: searchForm.dateRange?.[1], |
| | | dictCode: searchForm.dictCode, |
| | | productOrderId: searchForm.productOrderId, |
| | | current: -1, |
| | | size: -1, |
| | | }; |
| | | |
| | | // 调用API获取概览数据 |
| | | getProductionCostSummary(apiParams) |
| | | .then(res => { |
| | | if (res.code === 200) { |
| | | const data = res.data; |
| | | overview.value = { |
| | | totalCost: parseFloat(data.totalCost) || 0, |
| | | orderCount: data.orderCount || 0, |
| | | avgCostPerOrder: parseFloat(data.averageOrderCost) || 0, |
| | | }; |
| | | } else { |
| | | ElMessage.error(res.message || "获取概览数据失败"); |
| | | } |
| | | }) |
| | | .catch(err => { |
| | | console.error("获取生产成本汇总数据失败:", err); |
| | | ElMessage.error("系统异常,获取概览数据失败"); |
| | | }); |
| | | getProductionCostAggregateByOrder; |
| | | // 调用API获取按产品物料汇总数据 |
| | | getProductionCostAggregateByProduct(apiParams) |
| | | .then(res => { |
| | | if (res.code === 200) { |
| | | // 按物料名称分组计算 |
| | | |
| | | // 这里简化处理,orderSummary暂时使用相同的数据 |
| | | // 实际项目中可能需要调用专门的API获取按订单汇总的数据 |
| | | categorySummary.value = res.data.records || []; |
| | | } else { |
| | | ElMessage.error(res.message || "获取物料汇总数据失败"); |
| | | } |
| | | }) |
| | | .catch(err => { |
| | | console.error("获取按产品物料汇总数据失败:", err); |
| | | ElMessage.error("系统异常,获取物料汇总数据失败"); |
| | | }); |
| | | getProductionCostAggregateByOrder(apiParams) |
| | | .then(res => { |
| | | if (res.code === 200) { |
| | | // 按物料名称分组计算 |
| | | |
| | | // 这里简化处理,orderSummary暂时使用相同的数据 |
| | | // 实际项目中可能需要调用专门的API获取按订单汇总的数据 |
| | | orderSummary.value = res.data.records || []; |
| | | } else { |
| | | ElMessage.error(res.message || "获取订单汇总数据失败"); |
| | | } |
| | | }) |
| | | .catch(err => { |
| | | console.error("获取按订单汇总数据失败:", err); |
| | | ElMessage.error("系统异常,获取订单汇总数据失败"); |
| | | }); |
| | | |
| | | // 调用API获取生产订单Top10数据 |
| | | getProductionCostTopOrders(apiParams) |
| | | .then(res => { |
| | | if (res.code === 200) { |
| | | topOrders.value = res.data || []; |
| | | updateTopOrdersChart(); |
| | | } else { |
| | | ElMessage.error(res.message || "获取生产订单Top10数据失败"); |
| | | } |
| | | }) |
| | | .catch(err => { |
| | | console.error("获取生产订单Top10数据失败:", err); |
| | | ElMessage.error("系统异常,获取生产订单Top10数据失败"); |
| | | }); |
| | | }; |
| | | |
| | | const handleReset = () => { |
| | | searchForm.dateRange = getDefaultDateRange(); |
| | | searchForm.monthRange = getDefaultMonthRange(); |
| | | searchForm.dictCode = ""; |
| | | searchForm.productOrderId = ""; |
| | | handleQuery(); |
| | | }; |
| | | |
| | | const handleSizeChange = val => { |
| | | page.size = val; |
| | | page.current = 1; |
| | | }; |
| | | |
| | | const handleCurrentChange = val => { |
| | | page.current = val; |
| | | }; |
| | | onMounted(() => { |
| | | getProductTypeOptions(); |
| | | loadOrders(); |
| | | handleQuery(); |
| | | initTopOrdersChart(); |
| | | window.addEventListener("resize", handleResize); |
| | | }); |
| | | }; |
| | | |
| | | const formatNumber = (v, digits = 2) => { |
| | | const n = Number.parseFloat(v); |
| | | if (!Number.isFinite(n)) return "--"; |
| | | return n.toLocaleString("zh-CN", { |
| | | minimumFractionDigits: digits, |
| | | maximumFractionDigits: digits, |
| | | const handleExport = () => { |
| | | const headers = [ |
| | | timeColumnLabel.value, |
| | | "产品类别", |
| | | "生产订单", |
| | | "用量", |
| | | "单位", |
| | | "成本(元)", |
| | | ]; |
| | | const lines = tableData.value.map(row => |
| | | [ |
| | | row.timeLabel, |
| | | row.category, |
| | | row.orderNo, |
| | | row.totalQuantity.toFixed(2), |
| | | row.unit || "", |
| | | 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; |
| | | }); |
| | | }; |
| | | |
| | | 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; |
| | | .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%); |
| | | } |
| | | 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 { |
| | | 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; |
| | | } |
| | | .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(3, 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-avg { |
| | | background: linear-gradient(135deg, rgba(99, 102, 241, 0.14), 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)); |
| | | .panel-card, |
| | | .summary-row { |
| | | margin-bottom: 14px; |
| | | } |
| | | |
| | | .filter-layout { |
| | | flex-direction: column; |
| | | 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(3, 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-avg { |
| | | background: linear-gradient( |
| | | 135deg, |
| | | rgba(99, 102, 241, 0.14), |
| | | 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 { |
| | | grid-template-columns: 1fr; |
| | | 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 { |
| | | justify-content: flex-start; |
| | | flex-wrap: wrap; |
| | | 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> |