Files
klp-oa/klp-ui/src/views/cost/comprehensive.vue
砂糖 31d8d1ee16 feat: 多个页面优化与功能增强
1. 磨削页面:添加操作人权限控制,自动填充上次硬度值,移除字典值字段
2. 质保书页面:新增质保书类型筛选与表单字段,移除模板选择弹窗
3. 成本综合页面:添加产线校验、录入/查看切换、表格列控制与快捷操作
4. 质保书条目页面:新增类型筛选与表单字段,移除模板选择弹窗
2026-06-04 10:26:09 +08:00

785 lines
45 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="app-container">
<el-empty v-if="noLineType" description="请通过产线页面进入" />
<template v-else>
<div class="report-tab-bar">
<div class="report-tabs">
<span v-for="r in tabs" :key="r.reportId"
:class="['report-tab', { active: activeReport && activeReport.reportId === r.reportId }]"
@click="enter(r)">{{ r.reportTitle }}</span>
</div>
<el-button size="mini" icon="el-icon-setting" @click="openConfig">配置</el-button>
</div>
<div v-if="!activeReport" class="empty-hint">请在上方选择报表或点击"配置"管理报表</div>
<template v-if="activeReport">
<el-card class="mb8">
<div slot="header" class="entry-header">
<span class="entry-title">{{ activeReport.reportTitle }}</span>
<el-tag size="mini" style="margin-left:6px">{{ lineName(activeReport) }}</el-tag>
<span class="entry-meta">{{ parseTime(activeReport.reportDate,'{y}-{m}-{d}') }} 投入{{ activeReport.inputWeight }}t 产出{{ activeReport.outputWeight }}t</span>
<el-button type="primary" size="mini" style="float:right;margin-left:8px" @click="saveGrid" :loading="saving">保存</el-button>
<el-button size="mini" style="float:right;margin-left:8px" @click="openColCfg">列配置</el-button>
<span style="float:right;margin-right:12px;font-size:12px;color:#606266;display:flex;align-items:center">
<span style="margin-right:4px">{{ inputMode ? '录入' : '查看' }}</span>
<el-switch v-model="inputMode" size="small" />
</span>
</div>
<el-alert :title="'已配置'+allCols.length+'个列'" type="info" :closable="false" show-icon style="margin-bottom:8px" />
<el-table v-loading="gridLoading" :data="gridRows" border stripe size="mini" style="width:100%" :header-cell-style="headerStyle" :key="'tbl-'+inputMode">
<el-table-column label="日期" width="135" fixed>
<template slot-scope="s"><el-date-picker v-model="s.row.detailDate" type="date" value-format="yyyy-MM-dd" size="mini" style="width:124px" @change="sortGrid" /></template>
</el-table-column>
<template v-for="col in displayCols">
<el-table-column v-if="col.$type==='detail' && !col.isShift" :key="'d'+col.itemId" :label="col.itemName+(col.unit?'('+col.unit+')':'')" width="105" align="center">
<template slot-scope="s">
<el-input v-model="s.row['q'+col.itemId]" size="mini" @input="recalcAll">
<i slot="suffix" v-if="col.queryCondition" :class="autoLoading[col.itemId]?'el-icon-loading':'el-icon-refresh'" style="cursor:pointer;font-size:13px;line-height:24px;color:#409eff" @click.stop="fetchAutoData(col, s.row)" />
</el-input>
</template>
</el-table-column>
<el-table-column v-else-if="col.$type==='detail' && col.isShift" :key="'ds'+col.itemId" :label="col.itemName+(col.unit?'('+col.unit+')':'')" width="120" align="center">
<template slot-scope="s">
<div class="shift-cell"><span class="shift-tag"></span><el-input v-model="s.row['q'+col.itemId+'_1']" size="mini" class="shift-input" @input="recalcAll"><i slot="suffix" v-if="col.queryCondition" :class="autoLoading[col.itemId]?'el-icon-loading':'el-icon-refresh'" style="cursor:pointer;font-size:12px;line-height:24px;color:#409eff" @click.stop="fetchAutoData(col, s.row, '1')" /></el-input></div>
<div class="shift-cell"><span class="shift-tag"></span><el-input v-model="s.row['q'+col.itemId+'_2']" size="mini" class="shift-input" @input="recalcAll"><i slot="suffix" v-if="col.queryCondition" :class="autoLoading[col.itemId]?'el-icon-loading':'el-icon-refresh'" style="cursor:pointer;font-size:12px;line-height:24px;color:#409eff" @click.stop="fetchAutoData(col, s.row, '2')" /></el-input></div>
</template>
</el-table-column>
<el-table-column v-else-if="col.$type==='metric' && !col.isShift" :key="'m'+col.mIdx" :label="col.metricName+(col.unit?'('+col.unit+')':'')" width="85" align="center">
<template slot-scope="s">{{ s.row['mv'+col.mIdx]!=null ? s.row['mv'+col.mIdx] : '-' }}</template>
</el-table-column>
<el-table-column v-else-if="col.$type==='metric' && col.isShift" :key="'ms'+col.mIdx" :label="col.metricName+(col.unit?'('+col.unit+')':'')" width="95" align="center">
<template slot-scope="s">
<div class="shift-metric"> {{ s.row['mv'+col.mIdx+'_1']!=null ? s.row['mv'+col.mIdx+'_1'] : '-' }}</div>
<div class="shift-metric"> {{ s.row['mv'+col.mIdx+'_2']!=null ? s.row['mv'+col.mIdx+'_2'] : '-' }}</div>
</template>
</el-table-column>
</template>
<el-table-column label="操作" width="55" fixed="right" align="center">
<template slot-scope="s"><el-button size="mini" type="text" style="padding:0" @click="gridRows.splice(s.$index,1)">删除</el-button></template>
</el-table-column>
</el-table>
<div style="margin-top:8px;text-align:center"><el-button type="default" icon="el-icon-plus" size="mini" @click="gridRows.push({detailDate:''})">添加日期行</el-button></div>
</el-card>
<!-- Column config -->
<el-dialog title="列配置" :visible.sync="colOpen" width="880px" append-to-body top="5vh">
<div style="margin-bottom:8px;display:flex;justify-content:space-between;align-items:center">
<span style="color:#999;font-size:12px"> {{ allCols.length }} </span>
<div>
<el-button size="mini" type="danger" :disabled="!selCol.length" @click="batchDelCol">删除</el-button>
<el-button type="primary" size="mini" @click="showAddDetail=true">+ 明细列</el-button>
<el-button size="mini" type="success" @click="openMetricPicker">+ 指标列</el-button>
<el-button size="mini" plain @click="openMetricMgr">指标管理</el-button>
</div>
</div>
<el-table :data="allCols" border stripe size="mini" highlight-current-row @dragover.native.prevent @drop.native="onNativeDrop" @current-change="curIdx = allCols.indexOf($event)" @selection-change="selCol=$event">
<el-table-column type="selection" width="40" />
<el-table-column label="序号" type="index" width="50" align="center" />
<el-table-column label="类型" width="60" align="center">
<template slot-scope="s">
<el-tag v-if="s.row.$type==='detail'" type="primary" size="mini">明细</el-tag>
<el-tag v-else type="success" size="mini">指标</el-tag>
</template>
</el-table-column>
<el-table-column label="名称" width="140">
<template slot-scope="s">{{ s.row.$type==='detail' ? (s.row.itemName||s.row.itemCode) : (s.row.metricName||'(空)') }}</template>
</el-table-column>
<el-table-column label="公式" min-width="180">
<template slot-scope="s"><span v-if="s.row.$type==='metric'" style="font-size:11px;color:#666">{{ s.row.metricFormula }}</span></template>
</el-table-column>
<el-table-column label="单位" width="55" align="center">
<template slot-scope="s">{{ s.row.unit }}</template>
</el-table-column>
<el-table-column label="表头色" width="70" align="center">
<template slot-scope="s"><el-color-picker v-model="s.row.color" size="mini" :predefine="['#409eff','#67c23a','#e6a23c','#f56c6c','#909399']" /></template>
</el-table-column>
<el-table-column label="分班次" width="70" align="center">
<template slot-scope="s"><el-checkbox v-model="s.row.isShift" /></template>
</el-table-column>
<el-table-column label="操作" width="175" align="center">
<template slot-scope="s">
<span style="display:inline-flex;align-items:center">
<span class="drag-handle" title="拖拽排序"
:draggable="true"
@dragstart.stop="onDragStart(s.$index)"
@dragend="onDragEnd">
<i class="el-icon-rank" />
</span>
<el-button size="mini" type="text" icon="el-icon-d-arrow-left" :disabled="s.$index===0" title="置于最上" @click="moveColToEdge(s.$index,'top')" />
<el-button size="mini" type="text" icon="el-icon-top" :disabled="s.$index===0" @click="moveCol(s.$index,-1)" />
<el-button size="mini" type="text" icon="el-icon-bottom" :disabled="s.$index===allCols.length-1" @click="moveCol(s.$index,1)" />
<el-button size="mini" type="text" icon="el-icon-d-arrow-right" :disabled="s.$index===allCols.length-1" title="置于最下" @click="moveColToEdge(s.$index,'bottom')" />
<el-button size="mini" type="text" icon="el-icon-delete" @click="allCols.splice(s.$index,1)" />
</span>
</template>
</el-table-column>
</el-table>
<div slot="footer"><el-button type="primary" @click="saveColCfg" :loading="colSaving">保存配置</el-button><el-button @click="colOpen=false">取消</el-button></div>
</el-dialog>
<!-- Add detail -->
<el-dialog title="添加明细列" :visible.sync="showAddDetail" width="550px" append-to-body>
<el-table :data="availableItems" border stripe size="mini" @selection-change="selAdd=$event">
<el-table-column type="selection" width="45" />
<el-table-column label="编码" prop="itemCode" width="120" />
<el-table-column label="名称" prop="itemName" />
<el-table-column label="单位" prop="unit" width="70" />
</el-table>
<div slot="footer">
<el-button type="primary" :disabled="!selAdd.length" @click="batchAddDetailCols"> </el-button>
<el-button @click="showAddDetail=false"> </el-button>
</div>
</el-dialog>
<!-- Pick metric -->
<el-dialog title="选择指标列" :visible.sync="metricPickOpen" width="600px" append-to-body>
<el-table :data="metricPickList" border stripe size="mini" @selection-change="selMp=$event">
<el-table-column type="selection" width="45" />
<el-table-column label="名称" prop="metricName" width="150" />
<el-table-column label="公式" prop="metricFormula" min-width="200" />
<el-table-column label="单位" prop="unit" width="70" />
</el-table>
<div slot="footer">
<el-button type="primary" :disabled="!selMp.length" @click="doPickMetric"> </el-button>
<el-button @click="metricPickOpen=false"> </el-button>
</div>
</el-dialog>
<!-- Metric management -->
<el-dialog title="指标管理" :visible.sync="mgrOpen" width="750px" top="5vh" append-to-body>
<div style="margin-bottom:8px">
<el-button type="primary" size="mini" @click="addMetricDef">新增指标</el-button>
</div>
<el-table :data="mgrList" border stripe size="mini">
<el-table-column label="名称" prop="metricName" width="150" />
<el-table-column label="公式" prop="metricFormula" min-width="200" />
<el-table-column label="单位" prop="remark" width="70" />
<el-table-column label="操作" width="100">
<template slot-scope="s">
<el-button size="mini" type="text" @click="editMetricDef(s.row)">编辑</el-button>
<el-button size="mini" type="text" style="color:#f56c6c" @click="delMetricDef(s.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-dialog :title="defTitle" :visible.sync="defOpen" width="480px" append-to-body>
<el-form :model="defForm" label-width="80px" size="small">
<el-form-item label="指标名称"><el-input v-model="defForm.metricName" /></el-form-item>
<el-form-item label="公式"><el-input v-model="defForm.metricFormula" type="textarea" :rows="3" /></el-form-item>
<div class="formula-vars" v-if="allCols.length">
<span class="vars-label">变量</span>
<template v-for="c in allCols.filter(x=>x.$type==='detail')">
<el-tag :key="'v'+c.itemId" size="mini" class="vars-tag" @click="defForm.metricFormula=(defForm.metricFormula||'')+'@{'+c.itemCode+'}'">{{ c.itemCode }}</el-tag>
</template>
<el-tag size="mini" class="vars-tag var-sys" @click="defForm.metricFormula=(defForm.metricFormula||'')+'input_weight'">input_weight</el-tag>
<el-tag size="mini" class="vars-tag var-sys" @click="defForm.metricFormula=(defForm.metricFormula||'')+'output_weight'">output_weight</el-tag>
<template v-for="c in allCols.filter(x=>x.$type==='metric'&&x.metricName)">
<el-tag :key="'v'+c.metricName" size="mini" class="vars-tag var-mtr" @click="defForm.metricFormula=(defForm.metricFormula||'')+'@{'+c.metricName+'}'">{{ c.metricName }}</el-tag>
</template>
</div>
<el-form-item label="单位"><el-input v-model="defForm.unit" placeholder="如 %" /></el-form-item>
</el-form>
<div slot="footer">
<el-button type="primary" @click="submitMetricDef"> </el-button>
<el-button @click="defOpen=false"> </el-button>
</div>
</el-dialog>
</el-dialog>
<!-- Copy config -->
<el-dialog title="从已有报表复制配置" :visible.sync="copyCfgOpen" width="550px" append-to-body>
<el-table :data="copyReports" border stripe size="mini" highlight-current-row @current-change="copySrc=$event">
<el-table-column label="报表标题" prop="reportTitle" />
<el-table-column label="日期" width="110"><template slot-scope="s">{{ parseTime(s.row.reportDate,'{y}-{m}-{d}') }}</template></el-table-column>
<el-table-column label="产线" width="70"><template slot-scope="s">{{ lineName(s.row) }}</template></el-table-column>
</el-table>
<div slot="footer">
<el-button type="primary" :disabled="!copySrc" @click="doCopyCfg">确认复制</el-button>
<el-button @click="copyCfgOpen=false"> </el-button>
</div>
</el-dialog>
</template>
<!-- Report management -->
<el-dialog title="报表管理" :visible.sync="configOpen" width="900px" top="5vh" append-to-body>
<el-form :model="q" ref="qf" size="small" :inline="true" v-show="showSearch" label-width="80px">
<el-form-item label="报表标题" prop="reportTitle"><el-input v-model="q.reportTitle" placeholder="请输入" clearable @keyup.enter.native="search" /></el-form-item>
<el-form-item label="报表日期" prop="reportDate"><el-date-picker v-model="q.reportDate" type="date" value-format="yyyy-MM-dd" placeholder="选择日期" clearable /></el-form-item>
<el-form-item><el-button type="primary" icon="el-icon-search" size="mini" @click="search">搜索</el-button><el-button icon="el-icon-refresh" size="mini" @click="resetQ">重置</el-button></el-form-item>
</el-form>
<el-row :gutter="10" class="mb8">
<el-col :span="1.5"><el-button type="primary" plain icon="el-icon-plus" size="mini" @click="addRp">新增</el-button></el-col>
<el-col :span="1.5"><el-button type="success" plain icon="el-icon-edit" size="mini" :disabled="sel!==1" @click="editRp">修改</el-button></el-col>
<el-col :span="1.5"><el-button type="danger" plain icon="el-icon-delete" size="mini" :disabled="sel<1" @click="delRp">删除</el-button></el-col>
<el-col :span="1.5"><el-button type="warning" plain icon="el-icon-document-copy" size="mini" :disabled="sel!==1" @click="openCopyRp">复制</el-button></el-col>
<right-toolbar :showSearch.sync="showSearch" @queryTable="getList" />
</el-row>
<el-table v-loading="loading" :data="list" @selection-change="s=>{sel=s.length;selIds=s.map(r=>r.reportId)}" ref="rt">
<el-table-column type="selection" width="50" align="center" />
<el-table-column label="报表标题" prop="reportTitle" />
<el-table-column label="日期" width="120"><template slot-scope="s">{{ parseTime(s.row.reportDate,'{y}-{m}-{d}') }}</template></el-table-column>
<el-table-column label="产线" width="70"><template slot-scope="s">{{ lineName(s.row) }}</template></el-table-column>
<el-table-column label="操作" width="180" align="center">
<template slot-scope="s">
<el-button size="mini" type="text" style="padding:0" @click="editRp(s.row)">修改</el-button>
<el-button size="mini" type="text" style="padding:0" @click="copyInlineRp(s.row)">复制</el-button>
<el-button size="mini" type="text" style="padding:0;color:#f56c6c" @click="delRp(s.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<pagination v-show="total>0" :total="total" :page.sync="q.pageNum" :limit.sync="q.pageSize" @pagination="getList" />
<el-dialog :title="rpTitle" :visible.sync="rpOpen" width="500px" append-to-body>
<el-form ref="rpf" :model="rpForm" :rules="{reportTitle:[{required:true,message:'请输入',trigger:'blur'}]}" label-width="100px">
<el-form-item label="报表标题" prop="reportTitle"><el-input v-model="rpForm.reportTitle" /></el-form-item>
<el-form-item label="报表日期" prop="reportDate"><el-date-picker v-model="rpForm.reportDate" type="date" value-format="yyyy-MM-dd" style="width:100%" /></el-form-item>
<el-form-item label="备注" prop="remark"><el-input v-model="rpForm.remark" type="textarea" /></el-form-item>
</el-form>
<div slot="footer"><el-button :loading="rpBtnLoading" type="primary" @click="submitRp"> </el-button><el-button @click="rpOpen=false"> </el-button></div>
</el-dialog>
<!-- 复制报表弹窗 -->
<el-dialog title="复制报表" :visible.sync="copyRpOpen" width="500px" append-to-body>
<el-form :model="copyRpForm" label-width="100px" size="small">
<el-form-item label="报表标题"><el-input v-model="copyRpForm.reportTitle" /></el-form-item>
<el-form-item label="报表日期"><el-date-picker v-model="copyRpForm.reportDate" type="date" value-format="yyyy-MM-dd" style="width:100%" /></el-form-item>
</el-form>
<div slot="footer">
<el-button type="primary" @click="doCopyRp"> </el-button>
<el-button @click="copyRpOpen=false"> </el-button>
</div>
</el-dialog>
</el-dialog>
</template>
</div>
</div>
</template>
<script>
import { listProdReport, getProdReport, addProdReport, updateProdReport, delProdReport, copyProdReport } from "@/api/cost/prodReport"
import { listProdDetail, batchSaveProdDetail } from "@/api/cost/prodDetail"
import { listProdMetric, addProdMetric, updateProdMetric, delProdMetric, getProdMetric } from "@/api/cost/prodMetric"
import { listItem } from "@/api/cost/item"
import { listLightPendingAction } from "@/api/wms/pendingAction"
import { getCoilStatisticsList } from "@/api/wms/coil"
import { listAuxiliaryConsume } from "@/api/eqp/auxiliaryConsume"
import { listProductionLine } from "@/api/wms/productionLine"
import { listRollGrindAll } from "@/api/mes/roll/rollGrind"
function parseDateRange(detailDate) {
const d = (detailDate || '').slice(0, 10)
return {
startTime: d + ' 00:00:00',
endTime: d + ' 23:59:59'
}
}
/**
* 自动查询处理器注册表。
* key: item.category, value: async (queryCondition, row, col, report, shift) => fetchedValue
*/
const queryHandlers = {}
export function registerQueryHandler(category, handler) {
queryHandlers[category] = handler
}
const teamMap = { '1': '甲', '2': '乙' }
registerQueryHandler('原料', async (queryCondition, row, col, report, shift) => {
if (!row.detailDate) return null
const { startTime, endTime } = parseDateRange(row.detailDate)
const res = await listLightPendingAction({ actionStatus: 2, actionTypes: queryCondition, startTime, endTime, pageSize: 99999 })
const items = Array.isArray(res.data) ? res.data : (res.rows || [])
const ids = [...new Set(items.map(i => i.coilId).filter(Boolean))]
if (!ids.length) return null
const params = { coilIds: ids.join(',') }
if (shift && teamMap[shift]) params.team = teamMap[shift]
const stat = await getCoilStatisticsList(params)
const net = stat.data && stat.data.total_net_weight
return net != null ? net : null
})
registerQueryHandler('产出', async (queryCondition, row, col, report, shift) => {
if (!row.detailDate) return null
const { startTime, endTime } = parseDateRange(row.detailDate)
const res = await listLightPendingAction({ actionStatus: 2, actionTypes: queryCondition, startTime, endTime, pageSize: 99999 })
const items = Array.isArray(res.data) ? res.data : (res.rows || [])
const ids = []
for (const i of items) {
if (i.processedCoilIds) {
i.processedCoilIds.split(',').forEach(id => { id = id.trim(); if (id) ids.push(id) })
}
}
if (!ids.length) return null
const params = { coilIds: [...new Set(ids)].join(',') }
if (shift && teamMap[shift]) params.team = teamMap[shift]
const stat = await getCoilStatisticsList(params)
const net = stat.data && stat.data.total_net_weight
return net != null ? net : null
})
registerQueryHandler('辅料', async (queryCondition, row, col, report, shift) => {
if (!row.detailDate) return null
const d = (row.detailDate || '').slice(0, 10)
const res = await listAuxiliaryConsume({ recordDate: d, typeId: queryCondition, pageSize: 9999 })
const items = res.rows || []
const total = items.reduce((s, item) => s + (parseFloat(item.consume) || 0), 0)
if (col.isShift) {
const half = total / 2
return [half, half]
}
return total || null
})
registerQueryHandler('轧辊', async (queryCondition, row, col, report, shift) => {
if (!row.detailDate || !report.lineType) return null
const d = (row.detailDate || '').slice(0, 10)
const params = { lineId: report.lineType, beginTime: d + ' 00:00:00', endTime: d + ' 23:59:59', rollType: queryCondition }
if (shift && teamMap[shift]) params.team = teamMap[shift] + '班'
const res = await listRollGrindAll(params)
const items = res.data || []
if (!items.length) return null
let total = 0
items.forEach(item => { total += parseFloat(item.grindAmount) || 0 })
return total || null
})
export default {
name: "CostComprehensive",
data() {
return {
loading: false, list: [], tabs: [], total: 0, sel: 0, selIds: [], showSearch: true,
q: { pageNum: 1, pageSize: 10, reportTitle: undefined, reportDate: undefined },
rpOpen: false, rpTitle: "", rpBtnLoading: false, rpForm: {},
copyRpOpen: false, copyRpForm: {},
activeReport: null, gridLoading: false, gridRows: [], saving: false,
allItems: [], allCols: [],
colOpen: false, colSaving: false,
showAddDetail: false, selAdd: [], selCol: [], curIdx: -1, dragIdx: -1,
metricPickOpen: false, metricPickList: [], selMp: [],
mgrOpen: false, mgrList: [], defOpen: false, defTitle: '', defForm: {},
copyCfgOpen: false, copyReports: [], copySrc: null,
configOpen: false,
autoLoading: {},
lineOptions: [],
lineType: null,
noLineType: false,
inputMode: false
}
},
computed: {
availableItems() {
const used = new Set(this.allCols.filter(c => c.$type === 'detail').map(c => String(c.itemId)))
return this.allItems.filter(i => !used.has(String(i.itemId)))
},
displayCols() {
if (!this.inputMode) return this.allCols
return [...this.allCols.filter(c => c.$type === 'detail'), ...this.allCols.filter(c => c.$type === 'metric')]
},
headerStyle() {
return ({ column }) => {
if (!column || !column.label) return {}
const col = this.allCols.find(c => {
if (c.$type === 'detail') return column.label.startsWith(c.itemName || c.itemCode)
if (c.$type === 'metric') return column.label.startsWith(c.metricName)
return false
})
return col && col.color ? { background: col.color, color: '#fff' } : {}
}
}
},
watch: { configOpen(v) { if (!v) this.rpOpen = false } },
created() {
const lineType = this.$route.query.lineType
if (lineType) {
this.lineType = lineType
this.getTabList()
this.loadItems()
this.loadLines()
} else {
this.noLineType = true
}
},
methods: {
/* report */
getList() {
this.loading = true
const params = { ...this.q }
if (this.lineType) params.lineType = this.lineType
listProdReport(params).then(r=>{this.list=r.rows;this.total=r.total}).finally(()=>this.loading=false)
},
getTabList() {
const params = { pageNum: 1, pageSize: 9999 }
if (this.lineType) params.lineType = this.lineType
listProdReport(params).then(r => { this.tabs = r.rows || [] })
},
search() { this.q.pageNum = 1; this.getList() },
resetQ() { this.resetForm("qf"); this.search() },
addRp() { this.rpForm = { lineType: this.lineType || undefined }; this.rpTitle = "新增"; this.rpOpen = true },
editRp(row) {
const id = (row&&row.reportId)||this.selIds[0]; if(!id)return
getProdReport(id).then(r=>{
const d = r.data || {}
if (d.lineType) {
const match = this.lineOptions.find(l => l.lineName.includes(d.lineType === 'acid' ? '酸轧' : '镀锌'))
if (match) d.lineId = match.lineId
}
this.rpForm = d; this.rpTitle = "修改"; this.rpOpen = true
})
},
submitRp() {
this.$refs.rpf.validate(v=>{if(!v)return;this.rpBtnLoading=true;const fn=this.rpForm.reportId?updateProdReport:addProdReport;fn(this.rpForm).then(()=>{
this.$modal.msgSuccess("成功"); this.rpOpen = false
if (this.activeReport && this.activeReport.reportId === this.rpForm.reportId) {
Object.assign(this.activeReport, { reportTitle: this.rpForm.reportTitle, reportDate: this.rpForm.reportDate, lineId: this.rpForm.lineId, inputWeight: this.rpForm.inputWeight, outputWeight: this.rpForm.outputWeight })
}
this.getTabList(); this.getList()
}).finally(()=>this.rpBtnLoading=false)})
},
delRp(row) {
const ids=(row&&row.reportId)?[row.reportId]:this.selIds;if(!ids.length)return
this.$modal.confirm("确认删除?").then(()=>delProdReport(ids.join(','))).then(()=>{
if (this.activeReport && ids.includes(this.activeReport.reportId)) { this.activeReport = null; this.gridRows = []; this.allCols = [] }
this.getTabList(); this.getList(); this.$modal.msgSuccess("已删除")
})
},
openConfig() { this.configOpen = true; this.getList() },
openCopyRp() {
const row = this.list.find(r => r.reportId === this.selIds[0])
this.copyRpForm = { reportId: this.selIds[0], reportTitle: (row ? row.reportTitle : '') + '-副本', reportDate: row ? row.reportDate : undefined }
this.copyRpOpen = true
},
copyInlineRp(row) {
this.copyRpForm = { reportId: row.reportId, reportTitle: row.reportTitle + '-副本', reportDate: row.reportDate }
this.copyRpOpen = true
},
async doCopyRp() {
const sid = this.copyRpForm.reportId
if (!sid) return
await copyProdReport(sid, { reportTitle: this.copyRpForm.reportTitle, reportDate: this.copyRpForm.reportDate })
this.copyRpOpen = false
this.$modal.msgSuccess('复制成功')
this.getTabList(); this.getList()
},
/* column config */
async openColCfg() { await this.loadItems(); await this.restoreAllCols(); this.colOpen = true },
moveCol(idx, dir) { const arr = this.allCols; const t = idx + dir; if (t >= 0 && t < arr.length) { const item = arr.splice(idx, 1)[0]; arr.splice(t, 0, item) } },
moveColToEdge(idx, edge) { const arr = this.allCols; const item = arr.splice(idx, 1)[0]; edge === 'top' ? arr.unshift(item) : arr.push(item) },
onDragStart(idx) {
this.dragIdx = idx
},
onDragEnd() {
this.dragIdx = -1
},
onNativeDrop(e) {
if (this.dragIdx < 0) return
const tr = e.target.closest('tr')
if (!tr) return
const rows = Array.from(tr.parentElement.querySelectorAll('tr'))
const targetIdx = rows.indexOf(tr)
if (targetIdx >= 0) this.onDragDrop(targetIdx)
},
onDragDrop(targetIdx) {
if (this.dragIdx < 0 || targetIdx < 0 || this.dragIdx === targetIdx) return
const arr = this.allCols
const item = arr.splice(this.dragIdx, 1)[0]
const insertAt = this.dragIdx < targetIdx ? targetIdx - 1 : targetIdx
arr.splice(insertAt, 0, item)
this.dragIdx = -1
this.$forceUpdate()
},
batchAddDetailCols() {
this.selAdd.forEach(item => {
if (!this.allCols.find(c => c.$type === 'detail' && String(c.itemId) === String(item.itemId)))
this.allCols.push({ $type: 'detail', itemId: item.itemId, itemCode: item.itemCode, itemName: item.itemName, unit: item.unit, isShift: false, color: null, queryCondition: item.queryCondition, category: item.category })
})
this.showAddDetail = false; this.selAdd = []
},
batchDelCol() {
const set = new Set(this.selCol.map(r => r))
this.allCols = this.allCols.filter(c => !set.has(c))
this.selCol = []; this.curIdx = -1
},
/* metric picker */
async openMetricPicker() {
await this.loadAllMetrics(this.activeReport.reportId)
const used = new Set(this.allCols.filter(c => c.$type === 'metric' && c.metricId).map(c => String(c.metricId)))
this.metricPickList = (this._allMetricDefs || []).filter(m => !used.has(String(m.metricId)))
this.selMp = []; this.metricPickOpen = true
},
doPickMetric() {
this.selMp.forEach(m => {
this.allCols.push({ $type: 'metric', metricId: m.metricId, metricName: m.metricName, metricFormula: m.metricFormula, unit: m.remark||'', isShift: false, color: null })
})
this.metricPickOpen = false
},
/* metric management */
async openMetricMgr() {
await this.loadAllMetrics(this.activeReport.reportId)
this.mgrList = this._allMetricDefs || []
this.mgrOpen = true
},
addMetricDef() { this.defForm = { metricId: null, metricName: '', metricFormula: '', unit: '' }; this.defTitle = '新增指标'; this.defOpen = true },
editMetricDef(row) { this.defForm = { metricId: row.metricId, metricName: row.metricName, metricFormula: row.metricFormula, unit: row.remark||'' }; this.defTitle = '编辑指标'; this.defOpen = true },
async submitMetricDef() {
const f = this.defForm
if (!f.metricName) { this.$modal.msgWarning('请输入指标名称'); return }
if (f.metricId) {
await updateProdMetric({ metricId: f.metricId, metricName: f.metricName, metricFormula: f.metricFormula, remark: f.unit })
} else {
await addProdMetric({ reportId: this.activeReport.reportId, metricCode: f.metricName, metricName: f.metricName, metricFormula: f.metricFormula, metricValue: 0, remark: f.unit || '' })
}
this.defOpen = false; this.$modal.msgSuccess('保存成功')
await this.openMetricMgr()
},
async delMetricDef(row) {
this.$modal.confirm('确认删除指标 "' + row.metricName + '"').then(async () => {
await delProdMetric(row.metricId)
await this.openMetricMgr()
this.$modal.msgSuccess('已删除')
})
},
async saveColCfg() {
const rid = this.activeReport.reportId; this.colSaving = true
try {
const metricCols = this.allCols.filter(c => c.$type === 'metric')
for (const m of metricCols) {
if (!m.metricName) { this.$modal.msgWarning('存在空指标列'); return }
if (m.metricFormula) {
const testF = m.metricFormula.replace(/@\{[^}]+\}/g, '1').replace(/input_weight|output_weight/g, '1')
if (this.evalF(testF) === null) { this.$modal.msgError('指标 "' + m.metricName + '" 公式无效'); return }
}
}
// ensure metric definitions exist in DB for all metric columns before saving config
for (const mc of metricCols) {
if (!mc.metricId) {
const r = await addProdMetric({ reportId: rid, metricCode: mc.metricName, metricName: mc.metricName, metricFormula: mc.metricFormula || '', metricValue: 0, remark: mc.unit || '' })
mc.metricId = r.data && r.data.metricId || r.metricId
}
}
const columns = this.allCols.map(c => {
const o = { t: c.$type === 'detail' ? 'd' : 'm', s: !!c.isShift }
o.id = String(c.$type === 'detail' ? c.itemId : c.metricId)
if (c.color && typeof c.color === 'string') o.c = c.color
return o
})
this.activeReport.colConfig = JSON.stringify({ columns })
await updateProdReport({ reportId: rid, colConfig: this.activeReport.colConfig })
this.$modal.msgSuccess("列配置已保存"); this.colOpen = false
await this.loadGrid()
} finally { this.colSaving = false }
},
/* grid */
async loadGrid() {
const rid = this.activeReport.reportId; this.gridLoading = true
try {
const dr = await listProdDetail({ reportId: rid, pageNum: 1, pageSize: 9999 })
await this.restoreAllCols()
this.buildGrid(dr.rows || [])
this.recalcAll()
} finally { this.gridLoading = false }
},
async restoreAllCols() {
await this.loadItems(); await this.loadAllMetrics(this.activeReport.reportId)
const cfg = JSON.parse(this.activeReport.colConfig || 'null')
if (!cfg || !cfg.columns || !cfg.columns.length) { this.allCols = []; return }
const cols = []
for (const c of cfg.columns) {
if (c.t === 'd') {
const id = String(c.id)
const item = this.allItems.find(i => String(i.itemId) === id)
if (item) cols.push({ $type: 'detail', itemId: item.itemId, itemCode: item.itemCode, itemName: item.itemName, unit: item.unit, isShift: !!c.s, color: c.c || null, queryCondition: item.queryCondition, category: item.category })
} else if (c.t === 'm') {
const id = String(c.id)
let def = (this._allMetricDefs || []).find(m => String(m.metricId) === id)
// fallback: try to fetch metric by ID individually if not in cached list
if (!def && c.id) {
try { const r = await getProdMetric(c.id); if (r.data) { def = r.data; this._allMetricDefs.push(def) } } catch(e) {}
}
if (def) cols.push({ $type: 'metric', metricId: def.metricId, metricName: def.metricName, metricFormula: def.metricFormula, unit: def.remark||'', isShift: !!c.s, color: c.c || null })
}
}
this.allCols = cols
let mi = 0; this.allCols.forEach(c => { if (c.$type === 'metric') c.mIdx = mi++ })
},
buildGrid(details) {
const map = {}; details.forEach(d => {
if (!d.detailDate) return
if (!map[d.detailDate]) map[d.detailDate] = { detailDate: d.detailDate }
const sfx = d.shift && d.shift !== '0' ? '_' + d.shift : ''
map[d.detailDate]['q' + d.itemId + sfx] = d.quantity
})
this.gridRows = Object.values(map).sort((a,b) => a.detailDate.localeCompare(b.detailDate))
},
recalcAll() {
const rp = this.activeReport || {}; const esc = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const detailCols = this.allCols.filter(c => c.$type === 'detail')
const metricCols = this.allCols.filter(c => c.$type === 'metric')
this.gridRows.forEach(row => {
metricCols.forEach((m, mi) => {
if (!m.metricFormula) return
const ef = (shiftId) => {
let f = m.metricFormula
for (let pmi = 0; pmi < mi; pmi++) {
const pm = metricCols[pmi]; if (!pm.metricName) continue
const pn = esc(pm.metricName)
if (pm.isShift) {
const pv1 = row['mv'+pm.mIdx+'_1']; const pv2 = row['mv'+pm.mIdx+'_2']
if (pv1 != null) f = f.replace(new RegExp('@\\{'+pn+'\\}\\.甲班','g'), pv1)
if (pv2 != null) f = f.replace(new RegExp('@\\{'+pn+'\\}\\.乙班','g'), pv2)
}
const pv = pm.isShift ? (shiftId ? row['mv'+pm.mIdx+'_'+shiftId] : null) : row['mv'+pm.mIdx]
if (pv != null) f = f.replace(new RegExp('@\\{'+pn+'\\}','g'), pv)
}
detailCols.forEach(c => {
const item = this.allItems.find(i => String(i.itemId) === String(c.itemId))
if (!item || !item.itemCode) return; const code = item.itemCode
if (c.isShift) {
const v1 = parseFloat(row['q'+c.itemId+'_1'])||0; const v2 = parseFloat(row['q'+c.itemId+'_2'])||0
f = f.replace(new RegExp('@\\{'+code+'\\}\\.甲班','g'), v1).replace(new RegExp('@\\{'+code+'\\}\\.乙班','g'), v2)
const v = shiftId ? (shiftId==='1'?v1:v2) : v1+v2
f = f.replace(new RegExp('@\\{'+code+'\\}','g'), v)
} else {
const v = parseFloat(row['q'+c.itemId])||0
f = f.replace(new RegExp('@\\{'+code+'\\}\\.甲班','g'), v).replace(new RegExp('@\\{'+code+'\\}\\.乙班','g'), v).replace(new RegExp('@\\{'+code+'\\}','g'), v)
}
})
f = f.replace(/input_weight/g, rp.inputWeight||0).replace(/output_weight/g, rp.outputWeight||0)
return this.evalF(f)
}
if (m.isShift) { row['mv'+m.mIdx+'_1']=ef('1'); row['mv'+m.mIdx+'_2']=ef('2') }
else row['mv'+m.mIdx]=ef(null)
})
})
},
evalF(f) { const s = f.replace(/[^0-9+\-*/.()\s]/g,''); if(!s) return null; try { const r = new Function('return ('+s+')')(); return isFinite(r)?Math.round(r*10000)/10000:null } catch(e){ return null } },
sortGrid() { this.gridRows.sort((a,b)=>{if(!a.detailDate)return 1;if(!b.detailDate)return -1;return a.detailDate.localeCompare(b.detailDate)}) },
async saveGrid() {
const rid = this.activeReport.reportId; if (!rid) return; this.saving = true
try {
const exist = await listProdDetail({ reportId: rid, pageNum: 1, pageSize: 9999 })
const ids = (exist.rows||[]).map(d => d.detailId)
const detailCols = this.allCols.filter(c => c.$type === 'detail'); const detailList = []
this.gridRows.forEach(row => {
if (!row.detailDate) return
detailCols.forEach(col => {
const push = (shift, sfx) => { const qty = row['q'+col.itemId+sfx]; if (qty != null && qty !== '') detailList.push({ reportId: rid, detailDate: row.detailDate, shift: shift||'0', itemId: col.itemId, quantity: qty }) }
if (col.isShift) { push('1','_1'); push('2','_2') } else push(null,'')
})
})
await batchSaveProdDetail({ detailIds: ids, prodDetailList: detailList })
this.$modal.msgSuccess("保存成功"); await this.loadGrid()
} catch(e) { this.$modal.msgError("保存失败") } finally { this.saving = false }
},
/* copy config */
async openCopyCfg() { const r = await listProdReport({ pageNum:1, pageSize:9999 }); this.copyReports = (r.rows||[]).filter(rp=>rp.reportId!==this.activeReport.reportId); this.copySrc=null; this.copyCfgOpen=true },
async doCopyCfg() {
if (!this.copySrc) return; const sr = await getProdReport(this.copySrc.reportId)
const srcCfg = JSON.parse(((sr.data&&sr.data.colConfig)||'null'))
if (!srcCfg || (!srcCfg.columns && !srcCfg.itemIds)) { this.$modal.msgWarning('源报表无列配置'); return }
await this.loadItems(); await this.loadAllMetrics()
const usedIds = new Set(this.allCols.filter(c=>c.$type==='detail').map(c=>String(c.itemId)))
const usedMids = new Set(this.allCols.filter(c=>c.$type==='metric'&&c.metricId).map(c=>String(c.metricId)))
const cols = srcCfg.columns || []
cols.forEach(sc => {
if (sc.t === 'd') {
const sid = String(sc.id)
if (!usedIds.has(sid)) { const item = this.allItems.find(i=>String(i.itemId)===sid); if (item) { this.allCols.push({ $type:'detail', itemId:item.itemId, itemCode:item.itemCode, itemName:item.itemName, unit:item.unit, isShift:!!sc.s, color:sc.c||null, queryCondition:item.queryCondition, category:item.category }); usedIds.add(sid) } }
}
else if (sc.t === 'm') {
const sid = String(sc.id)
let def = this._allMetricDefs.find(m=>String(m.metricId)===sid)
if (def && !usedMids.has(sid)) { usedMids.add(sid); this.allCols.push({ $type:'metric', metricId:String(def.metricId), metricName:def.metricName, metricFormula:def.metricFormula, unit:def.remark||'', isShift:!!sc.s, color:sc.c||null }) }
}
})
this.copyCfgOpen = false; let mi = 0; this.allCols.forEach(c => { if (c.$type === 'metric') c.mIdx = mi++ })
this.$modal.msgSuccess('配置已复用')
},
/* auto fetch */
async fetchAutoData(col, row, shift) {
if (!col.queryCondition || this.autoLoading[col.itemId]) return
const handler = queryHandlers[col.category] || queryHandlers['default']
if (!handler) { this.$modal.msgWarning(`类别 "${col.category}" 未注册查询处理器`); return }
this.$set(this.autoLoading, col.itemId, true)
try {
const val = await handler(col.queryCondition, row, col, this.activeReport, shift)
if (val != null) {
const round3 = n => Math.round(n * 1000) / 1000
if (Array.isArray(val)) {
this.$set(row, 'q' + col.itemId + '_1', round3(val[0]))
this.$set(row, 'q' + col.itemId + '_2', round3(val[1]))
} else {
const key = 'q' + col.itemId + (shift ? '_' + shift : '')
this.$set(row, key, round3(val))
}
this.recalcAll()
}
} catch (e) {
this.$modal.msgError('自动获取数据失败')
} finally {
this.$set(this.autoLoading, col.itemId, false)
}
},
/* helpers */
async loadLines() { const r = await listProductionLine({ pageSize: 999 }); this.lineOptions = r.rows || [] },
lineName(row) {
if (row.lineType) {
const found = this.lineOptions.find(l => l.lineId == row.lineType);
if (found) return found.lineName
else return row.lineType || '-'
}
},
async loadItems() { if (!this.allItems.length) { const r = await listItem({ pageNum:1, pageSize:999 }); this.allItems = r.rows || [] } },
async loadAllMetrics(rid) {
const q = { pageNum:1, pageSize:99999 }; if (rid) q.reportId = rid
const r = await listProdMetric(q); const map = {}
;(r.rows||[]).forEach(m => { if (m.metricName && !map[m.metricId]) map[m.metricId] = m })
this._allMetricDefs = Object.values(map)
},
async enter(row) { const r = await getProdReport(row.reportId); if (r.data) this.activeReport = r.data; else this.activeReport = row; this.loadGrid() }
}
}
</script>
<style scoped>
.mb8 { margin-bottom: 8px; }
/deep/ .el-table--mini td { padding: 2px 0; }
/deep/ .el-table--mini th { padding: 4px 0; font-size: 12px; }
/deep/ .el-table--mini .cell { padding: 0 3px; line-height: 1.2; }
/deep/ .el-input--mini .el-input__inner { height: 24px; line-height: 24px; padding: 0 5px; }
/deep/ .el-date-editor.el-input--mini { width: 124px !important; }
/deep/ .el-date-editor.el-input--mini .el-input__inner { padding-left: 5px; padding-right: 18px; }
/deep/ .el-date-editor.el-input--mini .el-input__prefix { left: auto; right: 2px; }
/deep/ .el-button--mini { padding: 4px 8px; font-size: 12px; }
/deep/ .el-button--mini.el-button--text { padding: 0; }
.report-tab-bar { display: flex; align-items: center; border-bottom: 1px solid #dcdfe6; margin-bottom: 12px; padding: 0 4px; }
.report-tabs { flex: 1; display: flex; overflow-x: auto; margin-bottom: -1px; }
.report-tab { display: inline-block; padding: 7px 16px; cursor: pointer; border-bottom: 2px solid transparent; white-space: nowrap; font-size: 13px; color: #606266; transition: all 0.2s; user-select: none; }
.report-tab:hover { color: #409eff; }
.report-tab.active { color: #409eff; border-bottom-color: #409eff; font-weight: 500; }
.empty-hint { text-align: center; padding: 60px 0; color: #999; font-size: 14px; }
.entry-header { line-height: 28px; }
.entry-title { font-weight: bold; font-size: 15px; }
.entry-meta { margin-left: 10px; color: #999; font-size: 12px; }
.shift-cell { display: flex; align-items: center; }
.shift-tag { font-size: 10px; color: #909399; width: 16px; flex-shrink: 0; }
.shift-input { flex: 1; min-width: 0; }
.shift-metric { font-size: 12px; line-height: 1.5; }
.formula-vars { padding: 4px 6px; margin-bottom: 6px; background: #f5f7fa; border-radius: 3px; border: 1px solid #e4e7ed; line-height: 1.8; }
.vars-tag { cursor: pointer; margin: 1px; }
.vars-tag:hover { opacity: 0.8; }
.var-sys { background: #ecf5ff; border-color: #d9ecff; color: #409eff; }
.var-mtr { background: #f0f9eb; border-color: #e1f3d8; color: #67c23a; }
.drag-handle { cursor: grab; font-size: 14px; color: #909399; padding: 2px; display: inline-flex; align-items: center; }
.drag-handle:active { cursor: grabbing; }
.drag-handle:hover { color: #409eff; }
</style>