- 实现图表列选择功能,支持已选和待选标签显示 - 添加拖拽排序功能,支持图表列的重新排列 - 集成本地存储,持久化保存图表列选择状态 - 添加删除功能,支持移除不需要的图表列 - 重构图表渲染逻辑,只渲染已选择的列 - 优化对话框布局样式,调整为弹性布局 - 添加空状态提示,改善用户体验
1455 lines
79 KiB
Vue
1455 lines
79 KiB
Vue
<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}') }}</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>
|
||
<el-button size="mini" style="float:right;margin-left:8px" icon="el-icon-money" @click="openPriceMgr">价格管理</el-button>
|
||
<el-button size="mini" style="float:right;margin-left:8px" icon="el-icon-warning-outline" @click="anomalyOpen = true">异常分析</el-button>
|
||
<el-button size="mini" style="float:right;margin-left:8px" icon="el-icon-s-data" @click="chartOpen = true">折线图统计</el-button>
|
||
<!-- <el-button size="mini" style="float:right;margin-left:8px" icon="el-icon-upload2" @click="backfillCost" :loading="backfilling">反填</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" height="calc(100vh - 260px)" :data="tableData" border stripe size="mini" style="width:100%" :header-cell-style="headerStyle" :row-class-name="rowClassName" :key="'tbl-'+inputMode">
|
||
<el-table-column label="日期" width="135" fixed>
|
||
<template slot-scope="s"><span v-if="s.row.$isSummary" class="summary-label">{{ s.row.detailDate }}</span><el-date-picker v-else 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" align="center" width="130">
|
||
<template slot="header">
|
||
<div class="col-hd">{{ col.itemName }}{{ col.unit ? '('+col.unit+')' : '' }}</div>
|
||
</template>
|
||
<template slot-scope="s">
|
||
<span v-if="s.row.$isSummary" class="summary-val">{{ s.row['q'+col.itemId] || '-' }}</span>
|
||
<el-input v-else v-model="s.row['q'+col.itemId]" size="mini" @input="recalcAll" :class="{'anomaly-input': isAnomalyCell(s.row.detailDate, col.itemId)}">
|
||
<!-- <span slot="suffix" v-if="col.queryCondition" class="input-suffix-actions">
|
||
<i title="反填" v-if="col.category==='辅料'||col.category==='能耗'" :class="backfillLoading[col.itemId]?'el-icon-loading':'el-icon-upload2'" class="ica ica-backfill" @click.stop="backfillCell(col, s.row)" />
|
||
<i title="自动获取" :class="autoLoading[col.itemId]?'el-icon-loading':'el-icon-refresh'" class="ica ica-fetch" @click.stop="fetchAutoData(col, s.row)" />
|
||
</span> -->
|
||
</el-input>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column v-else-if="col.$type==='detail' && col.isShift" :key="'ds'+col.itemId" align="center" width="135">
|
||
<template slot="header">
|
||
<div class="col-hd">{{ col.itemName }}{{ col.unit ? '('+col.unit+')' : '' }}</div>
|
||
</template>
|
||
<template slot-scope="s">
|
||
<template v-if="s.row.$isSummary">
|
||
<div class="shift-cell"><span class="shift-tag">甲</span><span class="summary-val">{{ s.row['q'+col.itemId+'_1'] || '-' }}</span></div>
|
||
<div class="shift-cell"><span class="shift-tag">乙</span><span class="summary-val">{{ s.row['q'+col.itemId+'_2'] || '-' }}</span></div>
|
||
</template>
|
||
<template v-else>
|
||
<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" :class="{'anomaly-input': isAnomalyCell(s.row.detailDate, col.itemId)}">
|
||
<!-- <span slot="suffix" v-if="col.queryCondition" class="input-suffix-actions"><i v-if="col.category==='辅料'||col.category==='能耗'" :class="backfillLoading[col.itemId]?'el-icon-loading':'el-icon-upload2'" class="ica ica-backfill" @click.stop="backfillCell(col, s.row, '1')" /><i :class="autoLoading[col.itemId]?'el-icon-loading':'el-icon-refresh'" class="ica ica-fetch" @click.stop="fetchAutoData(col, s.row, '1')" /></span> -->
|
||
</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" :class="{'anomaly-input': isAnomalyCell(s.row.detailDate, col.itemId)}">
|
||
<!-- <span slot="suffix" v-if="col.queryCondition" class="input-suffix-actions"><i v-if="col.category==='辅料'||col.category==='能耗'" :class="backfillLoading[col.itemId]?'el-icon-loading':'el-icon-upload2'" class="ica ica-backfill" @click.stop="backfillCell(col, s.row, '2')" /><i :class="autoLoading[col.itemId]?'el-icon-loading':'el-icon-refresh'" class="ica ica-fetch" @click.stop="fetchAutoData(col, s.row, '2')" /></span> -->
|
||
</el-input>
|
||
</div>
|
||
</template>
|
||
</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"><span v-if="s.row.$isSummary" class="summary-val">{{ s.row['mv'+col.mIdx]!=null ? s.row['mv'+col.mIdx] : '-' }}</span><template v-else>{{ s.row['mv'+col.mIdx]!=null ? s.row['mv'+col.mIdx] : '-' }}</template></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">
|
||
<template v-if="s.row.$isSummary">
|
||
<div class="shift-metric"><span class="summary-val">甲 {{ s.row['mv'+col.mIdx+'_1']!=null ? s.row['mv'+col.mIdx+'_1'] : '-' }}</span></div>
|
||
<div class="shift-metric"><span class="summary-val">乙 {{ s.row['mv'+col.mIdx+'_2']!=null ? s.row['mv'+col.mIdx+'_2'] : '-' }}</span></div>
|
||
</template>
|
||
<template v-else>
|
||
<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>
|
||
</template>
|
||
</el-table-column>
|
||
</template>
|
||
<el-table-column label="操作" width="60" fixed="right" align="center">
|
||
<template slot-scope="s">
|
||
<!-- <el-button size="mini" type="text" style="padding:0 2px;font-size:11px" @click="saveRow(s.row)">保存</el-button>
|
||
<el-button size="mini" type="text" style="padding:0 2px;font-size:11px" @click="fetchRow(s.row)">抓取</el-button>
|
||
<el-button size="mini" type="text" style="padding:0 2px;font-size:11px;color:#67c23a" @click="backfillRow(s.row)">反填</el-button> -->
|
||
<el-button v-if="!s.row.$isSummary" size="mini" type="text" style="padding:0 2px;font-size:11px;color:#f56c6c" @click="gridRows.splice(s.$index - summaryRows.length,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="tags" width="100" />
|
||
<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>
|
||
<el-tag size="mini" class="vars-tag var-price" @click="defForm.metricFormula=(defForm.metricFormula||'')+'price'">price(价格)</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-item label="标签"><el-input v-model="defForm.tags" placeholder="请输入标签" /></el-form-item>
|
||
<el-form-item label="使用单价"><el-switch v-model="defForm.usePrice" :active-value="1" :inactive-value="0" /></el-form-item>
|
||
<el-form-item label="单价"><el-input v-model="defForm.metricValue" type="number" 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>
|
||
|
||
<!-- Price management -->
|
||
<el-dialog title="价格管理" :visible.sync="priceOpen" width="650px" top="5vh" append-to-body>
|
||
<el-table :data="priceList" border stripe size="mini">
|
||
<el-table-column label="指标名称" prop="metricName" width="150" />
|
||
<el-table-column label="公式" prop="metricFormula" min-width="180" show-overflow-tooltip />
|
||
<el-table-column label="单价" width="180" align="center">
|
||
<template slot-scope="s">
|
||
<el-input v-model="s.row.metricValue" type="number" size="mini" style="width:150px" />
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
<el-empty v-if="!priceList.length" description="暂无非使用单价的指标" />
|
||
<div slot="footer">
|
||
<el-button type="primary" @click="savePrices" :loading="priceSaving">保存</el-button>
|
||
<el-button @click="priceOpen=false">取消</el-button>
|
||
</div>
|
||
</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" placeholder="请输入报表标题" /></el-form-item>
|
||
<el-form-item label="报表日期" prop="reportDate"><el-date-picker v-model="rpForm.reportDate" type="date" placeholder="选择日期" clearable 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" placeholder="请输入备注" /></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" placeholder="请输入报表标题" /></el-form-item>
|
||
<el-form-item label="报表日期"><el-date-picker v-model="copyRpForm.reportDate" type="date" placeholder="选择日期" clearable 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>
|
||
|
||
<!-- Progress dialog -->
|
||
<el-dialog :title="progressTitle" :visible.sync="progressOpen" width="580px" append-to-body top="5vh" :close-on-click-modal="false">
|
||
<el-table :data="progressTasks" border stripe size="mini" max-height="420">
|
||
<el-table-column label="任务" prop="label" min-width="220" />
|
||
<el-table-column label="状态" width="110" align="center">
|
||
<template slot-scope="s">
|
||
<el-tag v-if="s.row.status==='pending'" type="info" size="mini">等待中</el-tag>
|
||
<el-tag v-else-if="s.row.status==='running'" type="warning" size="mini"><i class="el-icon-loading" /> 执行中</el-tag>
|
||
<el-tag v-else-if="s.row.status==='success'" type="success" size="mini">成功</el-tag>
|
||
<el-tag v-else-if="s.row.status==='fail'" type="danger" size="mini">失败</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="信息" prop="error" min-width="160" show-overflow-tooltip />
|
||
</el-table>
|
||
<div slot="footer"><el-button @click="progressOpen=false">关 闭</el-button></div>
|
||
</el-dialog>
|
||
|
||
<!-- Anomaly analysis -->
|
||
<AnomalyAnalysis :visible.sync="anomalyOpen" :reportId="activeReport ? activeReport.reportId : null" :allItems="allItems" :gridRows="gridRows" :allCols="allCols" @anomaly-found="onAnomalyFound" />
|
||
|
||
<!-- Line chart -->
|
||
<el-dialog title="折线图统计" :visible.sync="chartOpen" width="100%" top="0" append-to-body :close-on-click-modal="false" custom-class="chart-fullscreen" @opened="onChartOpen" @closed="disposeCharts">
|
||
<div class="chart-tag-bar" v-if="chartDataCols.length">
|
||
<div class="chart-tag-row" v-if="chartActiveCols.length">
|
||
<span class="chart-tag-label">已选</span>
|
||
<el-tag v-for="(col, idx) in chartActiveCols" :key="getColKey(col)"
|
||
type="primary" effect="dark" size="small"
|
||
draggable="true"
|
||
:class="{ 'is-dragging': _chartTagDragIdx === idx, 'drag-over': _chartTagDragOverIdx === idx }"
|
||
closable
|
||
@close="removeChartCol(getColKey(col))"
|
||
@dragstart.native="onChartTagDragStart($event, idx)"
|
||
@dragenter.native="onChartTagDragEnter(idx)"
|
||
@dragleave.native="onChartTagDragLeave"
|
||
@dragover.native.prevent
|
||
@drop.native="onChartTagDrop(idx)"
|
||
@dragend.native="onChartTagDragEnd"
|
||
>{{ col.itemName || col.metricName || '' }}</el-tag>
|
||
</div>
|
||
<div class="chart-tag-row" v-if="chartUnselectedCols.length">
|
||
<span class="chart-tag-label">待选</span>
|
||
<el-tag v-for="col in chartUnselectedCols" :key="getColKey(col)"
|
||
type="info" effect="plain" size="small"
|
||
@click="addChartCol(getColKey(col))"
|
||
>{{ col.itemName || col.metricName || '' }}</el-tag>
|
||
</div>
|
||
</div>
|
||
<div class="chart-grid" v-if="chartActiveCols.length">
|
||
<div v-for="(col, idx) in chartActiveCols" :key="'c'+idx" class="chart-item">
|
||
<div :ref="'chart_'+idx" style="width:100%;height:280px"></div>
|
||
</div>
|
||
</div>
|
||
<el-empty v-else-if="chartDataCols.length" description="请在上方「待选」中点击添加列" />
|
||
<el-empty v-else description="暂无数据" />
|
||
</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 { listProdMetricResult, batchSaveProdMetricResult } from "@/api/cost/prodMetricResult"
|
||
import { listItem } from "@/api/cost/item"
|
||
import { listLightPendingAction } from "@/api/wms/pendingAction"
|
||
import { getCoilStatisticsList } from "@/api/wms/coil"
|
||
import { listAuxiliaryConsume, addAuxiliaryConsume, updateAuxiliaryConsume } from "@/api/eqp/auxiliaryConsume"
|
||
import { listProductionLine } from "@/api/wms/productionLine"
|
||
import { listRollGrindAll } from "@/api/mes/roll/rollGrind"
|
||
import { listEnergyRecord, addEnergyRecord, updateEnergyRecord } from "@/api/ems/energyRecord"
|
||
import AnomalyAnalysis from "./components/AnomalyAnalysis"
|
||
import * as echarts from 'echarts'
|
||
|
||
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 backfillHandlers = {}
|
||
export function registerBackfillHandler(category, handler) {
|
||
backfillHandlers[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
|
||
})
|
||
|
||
registerBackfillHandler('辅料', async (queryCondition, row, col, report, shift, value) => {
|
||
if (!row.detailDate) return
|
||
const d = row.detailDate.slice(0, 10)
|
||
const res = await listAuxiliaryConsume({ recordDate: d, typeId: queryCondition, pageSize: 9999 })
|
||
const items = res.rows || []
|
||
if (items.length > 0) {
|
||
await updateAuxiliaryConsume({ consumeId: items[0].consumeId, consume: value })
|
||
} else {
|
||
await addAuxiliaryConsume({ recordDate: d, typeId: queryCondition, consume: value })
|
||
}
|
||
})
|
||
|
||
registerBackfillHandler('能耗', async (queryCondition, row, col, report, shift, value) => {
|
||
if (!row.detailDate) return
|
||
const d = row.detailDate.slice(0, 10)
|
||
const res = await listEnergyRecord({ recordDate: d, meterId: queryCondition, pageSize: 9999 })
|
||
const items = res.rows || []
|
||
if (items.length > 0) {
|
||
await updateEnergyRecord({ energyRecordId: items[0].energyRecordId, consumption: value })
|
||
} else {
|
||
await addEnergyRecord({ recordDate: d, meterId: queryCondition, consumption: value })
|
||
}
|
||
})
|
||
|
||
export default {
|
||
name: "CostComprehensive",
|
||
components: { AnomalyAnalysis },
|
||
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: {}, backfillLoading: {},
|
||
lineOptions: [],
|
||
lineType: null,
|
||
noLineType: false,
|
||
inputMode: false,
|
||
priceOpen: false, priceList: [], priceSaving: false,
|
||
backfilling: false,
|
||
progressOpen: false, progressTitle: '', progressTasks: [],
|
||
anomalyOpen: false, anomalyMap: {},
|
||
chartOpen: false, _chartInstances: [],
|
||
chartSelectedKeys: [],
|
||
_chartTagDragIdx: -1,
|
||
_chartTagDragOverIdx: -1
|
||
}
|
||
},
|
||
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' } : {}
|
||
}
|
||
},
|
||
summaryRows() {
|
||
const rows = this.gridRows.filter(r => r.detailDate)
|
||
if (!rows.length) return []
|
||
const detailCols = this.allCols.filter(c => c.$type === 'detail')
|
||
const metricCols = this.allCols.filter(c => c.$type === 'metric')
|
||
const rp = this.activeReport || {}
|
||
const esc = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||
const 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*100)/100).toFixed(2):null } catch(e){ return null } }
|
||
const calc = (type) => {
|
||
const row = { detailDate: type === 'sum' ? '总和' : '平均', $isSummary: true, $summaryType: type }
|
||
const n = rows.length || 1
|
||
detailCols.forEach(col => {
|
||
if (col.isShift) {
|
||
const s1 = rows.reduce((a, r) => a + (parseFloat(r['q' + col.itemId + '_1']) || 0), 0)
|
||
const s2 = rows.reduce((a, r) => a + (parseFloat(r['q' + col.itemId + '_2']) || 0), 0)
|
||
row['q' + col.itemId + '_1'] = type === 'sum' ? s1.toFixed(2) : (s1 / n).toFixed(2)
|
||
row['q' + col.itemId + '_2'] = type === 'sum' ? s2.toFixed(2) : (s2 / n).toFixed(2)
|
||
row['q' + col.itemId] = type === 'sum' ? (s1 + s2).toFixed(2) : ((s1 + s2) / n).toFixed(2)
|
||
} else {
|
||
const s = rows.reduce((a, r) => a + (parseFloat(r['q' + col.itemId]) || 0), 0)
|
||
row['q' + col.itemId] = type === 'sum' ? s.toFixed(2) : (s / n).toFixed(2)
|
||
}
|
||
})
|
||
metricCols.forEach((m, mi) => {
|
||
if (!m.metricFormula) {
|
||
if (m.isShift) { row['mv'+m.mIdx+'_1'] = null; row['mv'+m.mIdx+'_2'] = null }
|
||
else row['mv'+m.mIdx] = null
|
||
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).replace(/price/g, (m.usePrice == 1) ? (m.metricValue || 0) : 0)
|
||
return 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)
|
||
})
|
||
return row
|
||
}
|
||
return [calc('sum'), calc('avg')]
|
||
},
|
||
tableData() {
|
||
return [...this.summaryRows, ...this.gridRows]
|
||
},
|
||
chartDataCols() {
|
||
if (!this.gridRows.length) return []
|
||
return this.displayCols.filter(col => {
|
||
if (col.$type === 'detail') {
|
||
const key = 'q' + col.itemId
|
||
return this.gridRows.some(r => r[key] != null)
|
||
}
|
||
const key = 'mv' + col.mIdx
|
||
return this.gridRows.some(r => r[key] != null)
|
||
})
|
||
},
|
||
chartActiveCols() {
|
||
return this.chartSelectedKeys
|
||
.map(key => this.chartDataCols.find(c => this.getColKey(c) === key))
|
||
.filter(Boolean)
|
||
},
|
||
chartUnselectedCols() {
|
||
const set = new Set(this.chartSelectedKeys)
|
||
return this.chartDataCols.filter(c => !set.has(this.getColKey(c)))
|
||
}
|
||
},
|
||
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: {
|
||
rowClassName({ row }) {
|
||
if (row.$isSummary) return 'summary-row'
|
||
return ''
|
||
},
|
||
/* 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||'', tags: m.tags || '', isShift: false, color: null, metricValue: m.metricValue, usePrice: m.usePrice })
|
||
})
|
||
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: '', tags: '', usePrice: 0, metricValue: '' }; this.defTitle = '新增指标'; this.defOpen = true },
|
||
editMetricDef(row) { this.defForm = { metricId: row.metricId, metricName: row.metricName, metricFormula: row.metricFormula, unit: row.remark||'', tags: row.tags || '', usePrice: row.usePrice || 0, metricValue: row.metricValue || '' }; 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, tags: f.tags || '', usePrice: f.usePrice, metricValue: f.metricValue || 0 })
|
||
} else {
|
||
await addProdMetric({ reportId: this.activeReport.reportId, metricCode: f.metricName, metricName: f.metricName, metricFormula: f.metricFormula, metricValue: f.metricValue || 0, remark: f.unit || '', tags: f.tags || '', usePrice: f.usePrice })
|
||
}
|
||
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('已删除')
|
||
})
|
||
},
|
||
|
||
/* price management */
|
||
async openPriceMgr() {
|
||
await this.loadAllMetrics(this.activeReport.reportId)
|
||
this.priceList = (this._allMetricDefs || []).filter(m => m.usePrice == 1).map(m => ({
|
||
...m,
|
||
_originValue: m.metricValue
|
||
}))
|
||
this.priceOpen = true
|
||
},
|
||
async savePrices() {
|
||
this.priceSaving = true
|
||
try {
|
||
for (const m of this.priceList) {
|
||
if (m.metricValue !== m._originValue) {
|
||
await updateProdMetric({ metricId: m.metricId, metricValue: m.metricValue || 0 })
|
||
}
|
||
}
|
||
this.priceOpen = false
|
||
this.$modal.msgSuccess('保存成功')
|
||
await this.loadAllMetrics(this.activeReport.reportId)
|
||
} finally { this.priceSaving = false }
|
||
},
|
||
|
||
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|price/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 || '', tags: mc.tags || '' })
|
||
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||'', tags: def.tags || '', isShift: !!c.s, color: c.c || null, metricValue: def.metricValue, usePrice: def.usePrice })
|
||
}
|
||
}
|
||
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 != null && d.quantity !== '' ? Number(d.quantity).toFixed(2) : 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).replace(/price/g, (m.usePrice == 1) ? (m.metricValue || 0) : 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*100)/100).toFixed(2):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 {
|
||
this.recalcAll()
|
||
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 })
|
||
|
||
const metricCols = this.allCols.filter(c => c.$type === 'metric' && c.metricId)
|
||
if (metricCols.length) {
|
||
const existMR = await listProdMetricResult({ reportId: rid, pageNum: 1, pageSize: 99999 })
|
||
const resultIds = (existMR.rows || []).map(r => r.resultId)
|
||
const metricResultList = []
|
||
this.gridRows.forEach(row => {
|
||
if (!row.detailDate) return
|
||
metricCols.forEach(m => {
|
||
const push = (teamGroup, value) => {
|
||
if (value != null && value !== '') {
|
||
metricResultList.push({ reportId: rid, metricId: m.metricId, metricDate: row.detailDate, teamGroup, calcValue: value, tags: m.tags || '' })
|
||
}
|
||
}
|
||
if (m.isShift) {
|
||
push('1', row['mv' + m.mIdx + '_1'])
|
||
push('2', row['mv' + m.mIdx + '_2'])
|
||
} else {
|
||
push('0', row['mv' + m.mIdx])
|
||
}
|
||
})
|
||
})
|
||
await batchSaveProdMetricResult({ resultIds, prodMetricResultList: metricResultList })
|
||
}
|
||
|
||
// 保存汇总数据到报表config
|
||
const summaryRows = this.summaryRows
|
||
const sumRow = summaryRows.find(r => r.$summaryType === 'sum')
|
||
const avgRow = summaryRows.find(r => r.$summaryType === 'avg')
|
||
const buildSummaryMap = (row) => {
|
||
const map = {}
|
||
if (!row) return map
|
||
this.allCols.forEach(col => {
|
||
const hdr = col.$type === 'detail'
|
||
? (col.itemName || col.itemCode) + (col.unit ? '(' + col.unit + ')' : '')
|
||
: col.metricName + (col.unit ? '(' + col.unit + ')' : '')
|
||
if (col.isShift) {
|
||
if (col.$type === 'detail') {
|
||
const v1 = row['q' + col.itemId + '_1']
|
||
const v2 = row['q' + col.itemId + '_2']
|
||
if (v1 != null) map[hdr + '(甲)'] = v1
|
||
if (v2 != null) map[hdr + '(乙)'] = v2
|
||
} else {
|
||
const v1 = row['mv' + col.mIdx + '_1']
|
||
const v2 = row['mv' + col.mIdx + '_2']
|
||
if (v1 != null) map[hdr + '(甲)'] = v1
|
||
if (v2 != null) map[hdr + '(乙)'] = v2
|
||
}
|
||
} else {
|
||
const v = col.$type === 'detail' ? row['q' + col.itemId] : row['mv' + col.mIdx]
|
||
if (v != null) map[hdr] = v
|
||
}
|
||
})
|
||
return map
|
||
}
|
||
const cfg = JSON.parse(this.activeReport.colConfig || '{}')
|
||
cfg.avg = buildSummaryMap(avgRow)
|
||
cfg.sum = buildSummaryMap(sumRow)
|
||
await updateProdReport({ reportId: rid, colConfig: JSON.stringify(cfg) })
|
||
|
||
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||'', tags: def.tags || '', isShift:!!sc.s, color:sc.c||null, metricValue: def.metricValue, usePrice: def.usePrice }) }
|
||
}
|
||
})
|
||
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 * 100) / 100).toFixed(2)
|
||
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)
|
||
}
|
||
},
|
||
|
||
async backfillCost() {
|
||
const detailCols = this.allCols.filter(c => c.$type === 'detail' && (c.category === '辅料' || c.category === '能耗') && c.queryCondition)
|
||
if (!detailCols.length) { this.$modal.msgWarning('无可用反填列(仅辅料/能耗)'); return }
|
||
this.backfilling = true
|
||
try {
|
||
let count = 0
|
||
for (const row of this.gridRows) {
|
||
if (!row.detailDate) continue
|
||
for (const col of detailCols) {
|
||
const handler = backfillHandlers[col.category]
|
||
if (!handler) continue
|
||
if (col.isShift) {
|
||
const v1 = row['q' + col.itemId + '_1']
|
||
if (v1 != null && v1 !== '') { await handler(col.queryCondition, row, col, this.activeReport, '1', v1); count++ }
|
||
const v2 = row['q' + col.itemId + '_2']
|
||
if (v2 != null && v2 !== '') { await handler(col.queryCondition, row, col, this.activeReport, '2', v2); count++ }
|
||
} else {
|
||
const v = row['q' + col.itemId]
|
||
if (v != null && v !== '') { await handler(col.queryCondition, row, col, this.activeReport, null, v); count++ }
|
||
}
|
||
}
|
||
}
|
||
this.$modal.msgSuccess(`反填完成,共处理${count}条`)
|
||
} catch (e) {
|
||
this.$modal.msgError('反填失败')
|
||
} finally {
|
||
this.backfilling = false
|
||
}
|
||
},
|
||
|
||
async backfillCell(col, row, shift) {
|
||
if (!col.queryCondition || this.backfillLoading[col.itemId]) return
|
||
const handler = backfillHandlers[col.category]
|
||
if (!handler) { this.$modal.msgWarning(`类别 "${col.category}" 未注册反填处理器`); return }
|
||
const val = shift ? row['q' + col.itemId + '_' + shift] : row['q' + col.itemId]
|
||
if (val == null || val === '') { this.$modal.msgWarning('当前单元格无数据'); return }
|
||
this.$set(this.backfillLoading, col.itemId, true)
|
||
try {
|
||
await handler(col.queryCondition, row, col, this.activeReport, shift || null, val)
|
||
this.$modal.msgSuccess('反填成功')
|
||
} catch (e) {
|
||
this.$modal.msgError('反填失败')
|
||
} finally {
|
||
this.$set(this.backfillLoading, col.itemId, false)
|
||
}
|
||
},
|
||
|
||
async runBatchTasks(title, tasks) {
|
||
this.progressTitle = title
|
||
this.progressTasks = tasks.map(t => ({ label: t.label, status: 'pending', error: '', run: t.run }))
|
||
this.progressOpen = true
|
||
await this.$nextTick()
|
||
for (const task of this.progressTasks) {
|
||
if (!this.progressOpen) break
|
||
task.status = 'running'
|
||
try {
|
||
await task.run()
|
||
task.status = 'success'
|
||
} catch (e) {
|
||
task.status = 'fail'
|
||
task.error = e.message || '执行失败'
|
||
}
|
||
}
|
||
},
|
||
|
||
async batchFetchCol(col) {
|
||
if (!col.queryCondition) return
|
||
const handler = queryHandlers[col.category] || queryHandlers['default']
|
||
if (!handler) { this.$modal.msgWarning(`类别 "${col.category}" 未注册查询处理器`); return }
|
||
const round3 = n => (Math.round(n * 100) / 100).toFixed(2)
|
||
const tasks = []
|
||
for (const row of this.gridRows) {
|
||
if (!row.detailDate) continue
|
||
tasks.push({
|
||
label: `${row.detailDate} ${col.itemName}`,
|
||
run: async () => {
|
||
const val = await handler(col.queryCondition, row, col, this.activeReport)
|
||
if (val != null) {
|
||
if (col.isShift && Array.isArray(val)) {
|
||
this.$set(row, 'q' + col.itemId + '_1', round3(val[0]))
|
||
this.$set(row, 'q' + col.itemId + '_2', round3(val[1]))
|
||
} else {
|
||
this.$set(row, 'q' + col.itemId, round3(val))
|
||
}
|
||
}
|
||
}
|
||
})
|
||
}
|
||
if (!tasks.length) { this.$modal.msgWarning('无可用行'); return }
|
||
this.$set(this.autoLoading, col.itemId, true)
|
||
await this.runBatchTasks(`批量抓取 - ${col.itemName}`, tasks)
|
||
this.$set(this.autoLoading, col.itemId, false)
|
||
this.recalcAll()
|
||
},
|
||
|
||
async batchBackfillCol(col) {
|
||
if (!col.queryCondition) return
|
||
const handler = backfillHandlers[col.category]
|
||
if (!handler) { this.$modal.msgWarning(`类别 "${col.category}" 未注册反填处理器`); return }
|
||
const tasks = []
|
||
for (const row of this.gridRows) {
|
||
if (!row.detailDate) continue
|
||
if (col.isShift) {
|
||
const v1 = row['q' + col.itemId + '_1']
|
||
if (v1 != null && v1 !== '') {
|
||
tasks.push({ label: `${row.detailDate} ${col.itemName}(甲)`, run: () => handler(col.queryCondition, row, col, this.activeReport, '1', v1) })
|
||
}
|
||
const v2 = row['q' + col.itemId + '_2']
|
||
if (v2 != null && v2 !== '') {
|
||
tasks.push({ label: `${row.detailDate} ${col.itemName}(乙)`, run: () => handler(col.queryCondition, row, col, this.activeReport, '2', v2) })
|
||
}
|
||
} else {
|
||
const v = row['q' + col.itemId]
|
||
if (v != null && v !== '') {
|
||
tasks.push({ label: `${row.detailDate} ${col.itemName}`, run: () => handler(col.queryCondition, row, col, this.activeReport, null, v) })
|
||
}
|
||
}
|
||
}
|
||
if (!tasks.length) { this.$modal.msgWarning('无可用数据'); return }
|
||
this.$set(this.backfillLoading, col.itemId, true)
|
||
await this.runBatchTasks(`批量反填 - ${col.itemName}`, tasks)
|
||
this.$set(this.backfillLoading, col.itemId, false)
|
||
},
|
||
|
||
async saveRow(row) {
|
||
if (!row.detailDate) return
|
||
const rid = this.activeReport.reportId; if (!rid) return
|
||
try {
|
||
const exist = await listProdDetail({ reportId: rid, pageNum: 1, pageSize: 9999 })
|
||
const dateStr = row.detailDate
|
||
const removeIds = (exist.rows || []).filter(d => d.detailDate === dateStr).map(d => d.detailId)
|
||
const detailCols = this.allCols.filter(c => c.$type === 'detail')
|
||
const detailList = []
|
||
detailCols.forEach(col => {
|
||
const push = (shift, sfx) => { const qty = row['q' + col.itemId + sfx]; if (qty != null && qty !== '') detailList.push({ reportId: rid, detailDate: dateStr, shift: shift || '0', itemId: col.itemId, quantity: qty }) }
|
||
if (col.isShift) { push('1', '_1'); push('2', '_2') } else push(null, '')
|
||
})
|
||
await batchSaveProdDetail({ detailIds: removeIds, prodDetailList: detailList })
|
||
this.$modal.msgSuccess('行保存成功')
|
||
} catch (e) {
|
||
this.$modal.msgError('行保存失败')
|
||
}
|
||
},
|
||
|
||
async fetchRow(row) {
|
||
if (!row.detailDate) return
|
||
const cols = this.allCols.filter(c => c.$type === 'detail' && c.queryCondition)
|
||
for (const col of cols) {
|
||
if (col.isShift) { await this.fetchAutoData(col, row, '1'); await this.fetchAutoData(col, row, '2') }
|
||
else { await this.fetchAutoData(col, row) }
|
||
}
|
||
this.$modal.msgSuccess('行抓取完成')
|
||
},
|
||
|
||
async backfillRow(row) {
|
||
if (!row.detailDate) return
|
||
const cols = this.allCols.filter(c => c.$type === 'detail' && (c.category === '辅料' || c.category === '能耗') && c.queryCondition)
|
||
if (!cols.length) { this.$modal.msgWarning('无可用反填列'); return }
|
||
for (const col of cols) {
|
||
const handler = backfillHandlers[col.category]
|
||
if (!handler) continue
|
||
if (col.isShift) {
|
||
const v1 = row['q' + col.itemId + '_1']
|
||
if (v1 != null && v1 !== '') { await handler(col.queryCondition, row, col, this.activeReport, '1', v1) }
|
||
const v2 = row['q' + col.itemId + '_2']
|
||
if (v2 != null && v2 !== '') { await handler(col.queryCondition, row, col, this.activeReport, '2', v2) }
|
||
} else {
|
||
const v = row['q' + col.itemId]
|
||
if (v != null && v !== '') { await handler(col.queryCondition, row, col, this.activeReport, null, v) }
|
||
}
|
||
}
|
||
this.$modal.msgSuccess('行反填完成')
|
||
},
|
||
|
||
/* 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 || '-'
|
||
}
|
||
},
|
||
onAnomalyFound(list) {
|
||
const map = {}
|
||
list.forEach(a => {
|
||
// Normalize date to yyyy-MM-dd string
|
||
const dt = (a.detailDate || '').slice(0, 10)
|
||
if (!dt) return
|
||
if (!map[dt]) map[dt] = new Set()
|
||
map[dt].add(String(a.itemId))
|
||
})
|
||
this.anomalyMap = map
|
||
this.anomalyOpen = false
|
||
this.$forceUpdate()
|
||
this.$modal.msgSuccess(`已将 ${list.length} 条异常数据标记到表格`)
|
||
},
|
||
isAnomalyCell(detailDate, itemId) {
|
||
if (!detailDate) return false
|
||
const dt = (detailDate || '').slice ? detailDate.slice(0, 10) : String(detailDate).slice(0, 10)
|
||
const set = this.anomalyMap[dt]
|
||
if (!set) return false
|
||
return set.has(String(itemId))
|
||
},
|
||
|
||
/* charts */
|
||
buildChartOption(col) {
|
||
const rows = this.gridRows.filter(r => r.detailDate).sort((a,b) => a.detailDate.localeCompare(b.detailDate))
|
||
const dates = rows.map(r => r.detailDate.slice(0,10))
|
||
const makeOption = (title, series) => ({
|
||
title: { text: title, left: 'center', textStyle: { fontSize: 12 } },
|
||
tooltip: { trigger: 'axis' },
|
||
legend: series.length > 1 ? { top: 'bottom', textStyle: { fontSize: 11 } } : undefined,
|
||
xAxis: { type: 'category', data: dates, axisLabel: { rotate: 30, fontSize: 10 } },
|
||
yAxis: { type: 'value', axisLabel: { fontSize: 10 } },
|
||
grid: { left: 50, right: 16, top: 36, bottom: 40 },
|
||
series
|
||
})
|
||
if (col.$type === 'detail') {
|
||
const label = (col.itemName || col.itemCode) + (col.unit ? '('+col.unit+')' : '')
|
||
if (col.isShift) {
|
||
return makeOption(label, [
|
||
{ name: '甲班', type: 'line', data: rows.map(r => { const v = parseFloat(r['q'+col.itemId+'_1']); return isNaN(v) ? 0 : v }), smooth: true },
|
||
{ name: '乙班', type: 'line', data: rows.map(r => { const v = parseFloat(r['q'+col.itemId+'_2']); return isNaN(v) ? 0 : v }), smooth: true }
|
||
])
|
||
}
|
||
return makeOption(label, [{ type: 'line', data: rows.map(r => { const v = parseFloat(r['q'+col.itemId]); return isNaN(v) ? 0 : v }), smooth: true }])
|
||
}
|
||
// metric
|
||
const label = (col.metricName || '') + (col.unit ? '('+col.unit+')' : '')
|
||
if (col.isShift) {
|
||
return makeOption(label, [
|
||
{ name: '甲班', type: 'line', data: rows.map(r => { const v = parseFloat(r['mv'+col.mIdx+'_1']); return isNaN(v) ? 0 : v }), smooth: true },
|
||
{ name: '乙班', type: 'line', data: rows.map(r => { const v = parseFloat(r['mv'+col.mIdx+'_2']); return isNaN(v) ? 0 : v }), smooth: true }
|
||
])
|
||
}
|
||
return makeOption(label, [{ type: 'line', data: rows.map(r => { const v = parseFloat(r['mv'+col.mIdx]); return isNaN(v) ? 0 : v }), smooth: true }])
|
||
},
|
||
getColKey(col) {
|
||
if (col.$type === 'detail') return 'd-' + col.itemId
|
||
return 'm-' + (col.metricId != null ? col.metricId : 'idx' + col.mIdx)
|
||
},
|
||
loadChartSelection() {
|
||
if (!this.activeReport) return
|
||
const key = 'cost_chart_cols_' + this.activeReport.reportId
|
||
const allKeys = this.chartDataCols.map(c => this.getColKey(c))
|
||
try {
|
||
const saved = localStorage.getItem(key)
|
||
if (saved) {
|
||
const parsed = JSON.parse(saved)
|
||
this.chartSelectedKeys = (parsed.selected || []).filter(k => allKeys.includes(k))
|
||
return
|
||
}
|
||
} catch (e) { /* ignore */ }
|
||
// default: select all
|
||
this.chartSelectedKeys = [...allKeys]
|
||
},
|
||
saveChartSelection() {
|
||
if (!this.activeReport) return
|
||
const key = 'cost_chart_cols_' + this.activeReport.reportId
|
||
localStorage.setItem(key, JSON.stringify({ selected: this.chartSelectedKeys }))
|
||
},
|
||
addChartCol(key) {
|
||
this.chartSelectedKeys.push(key)
|
||
this.saveChartSelection()
|
||
this.renderCharts()
|
||
},
|
||
removeChartCol(key) {
|
||
const idx = this.chartSelectedKeys.indexOf(key)
|
||
if (idx >= 0) this.chartSelectedKeys.splice(idx, 1)
|
||
this.saveChartSelection()
|
||
this.renderCharts()
|
||
},
|
||
onChartTagDragStart(e, idx) {
|
||
this._chartTagDragIdx = idx
|
||
this._chartTagDragOverIdx = -1
|
||
if (e.dataTransfer) e.dataTransfer.effectAllowed = 'move'
|
||
},
|
||
onChartTagDragEnter(idx) { this._chartTagDragOverIdx = idx },
|
||
onChartTagDragLeave() { this._chartTagDragOverIdx = -1 },
|
||
onChartTagDragEnd() { this._chartTagDragIdx = -1; this._chartTagDragOverIdx = -1 },
|
||
onChartTagDrop(targetIdx) {
|
||
if (this._chartTagDragIdx < 0 || this._chartTagDragIdx === targetIdx) { this._chartTagDragIdx = -1; this._chartTagDragOverIdx = -1; return }
|
||
const [item] = this.chartSelectedKeys.splice(this._chartTagDragIdx, 1)
|
||
const insertAt = this._chartTagDragIdx < targetIdx ? targetIdx - 1 : targetIdx
|
||
this.chartSelectedKeys.splice(insertAt, 0, item)
|
||
this._chartTagDragIdx = -1
|
||
this._chartTagDragOverIdx = -1
|
||
this.saveChartSelection()
|
||
this.renderCharts()
|
||
},
|
||
onChartOpen() {
|
||
this.loadChartSelection()
|
||
this.renderCharts()
|
||
},
|
||
renderCharts() {
|
||
this.disposeCharts()
|
||
this.$nextTick(() => {
|
||
this._chartInstances = []
|
||
this.chartActiveCols.forEach((col, idx) => {
|
||
const el = this.$refs['chart_'+idx]
|
||
if (!el || !el[0]) return
|
||
const chart = echarts.init(el[0])
|
||
chart.setOption(this.buildChartOption(col))
|
||
this._chartInstances.push(chart)
|
||
})
|
||
})
|
||
},
|
||
disposeCharts() {
|
||
if (this._chartInstances) {
|
||
this._chartInstances.forEach(c => c.dispose())
|
||
this._chartInstances = []
|
||
}
|
||
},
|
||
|
||
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; }
|
||
.var-price { background: #fef0f0; border-color: #fbc4c4; color: #f56c6c; }
|
||
.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; }
|
||
.col-hd { font-size: 12px; line-height: 1.3; }
|
||
.col-hd-actions { display: flex; align-items: center; gap: 4px; margin-top: 1px; font-size: 10px; }
|
||
.col-hd-actions .el-link { font-size: 10px; }
|
||
.input-suffix-actions { display: inline-flex; align-items: center; gap: 1px; }
|
||
.ica { cursor: pointer; font-size: 13px; line-height: 24px; }
|
||
.ica-fetch { color: #409eff; }
|
||
.ica-backfill { color: #67c23a; margin-right: 1px; }
|
||
.ica:hover { opacity: 0.7; }
|
||
/deep/ .anomaly-input .el-input__inner { background: #fef0f0 !important; border-color: #f56c6c !important; }
|
||
/deep/ .summary-row td { background: #f0f7ff !important; font-weight: bold; }
|
||
/deep/ .summary-row td .cell { color: #303133; }
|
||
.summary-label { font-weight: bold; color: #303133; padding: 0 5px; }
|
||
.summary-val { font-weight: bold; color: #303133; }
|
||
/deep/ .chart-fullscreen { width: 100% !important; max-width: 100% !important; height: 100vh !important; margin: 0 !important; display: flex; flex-direction: column; }
|
||
/deep/ .chart-fullscreen .el-dialog__body { flex: 1; max-height: none; overflow: auto; padding: 16px 20px; }
|
||
/deep/ .chart-fullscreen .el-dialog__header { padding: 12px 20px; flex-shrink: 0; }
|
||
.chart-tag-bar { padding: 0 4px 8px; }
|
||
.chart-tag-row { display: flex; flex-wrap: wrap; align-items: center; margin-bottom: 4px; }
|
||
.chart-tag-row:last-child { margin-bottom: 0; }
|
||
.chart-tag-label { font-size: 12px; color: #909399; margin-right: 4px; flex-shrink: 0; }
|
||
/deep/ .chart-tag-row .el-tag { transition: transform 0.15s, opacity 0.15s; user-select: none; margin: 1px 4px 1px 0; }
|
||
/deep/ .chart-tag-row .el-tag.is-dragging { opacity: 0.3; transform: scale(0.92); }
|
||
/deep/ .chart-tag-row .el-tag.drag-over { transform: translateY(-2px); border-color: #409eff !important; }
|
||
.chart-grid { display: flex; flex-wrap: wrap; }
|
||
.chart-item { width: 33.33%; padding: 6px; box-sizing: border-box; }
|
||
</style>
|