Files
klp-oa/klp-ui/src/views/cost/comprehensive.vue
jhd 32f7290778 feat(cost): 优化折线图统计功能
- 实现图表列选择功能,支持已选和待选标签显示
- 添加拖拽排序功能,支持图表列的重新排列
- 集成本地存储,持久化保存图表列选择状态
- 添加删除功能,支持移除不需要的图表列
- 重构图表渲染逻辑,只渲染已选择的列
- 优化对话框布局样式,调整为弹性布局
- 添加空状态提示,改善用户体验
2026-07-13 09:17:16 +08:00

1455 lines
79 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}') }}</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>