feat: 新增钢卷成本信息展示与能耗辅料成本报表页面
1. 在钢卷详情页新增成本信息模块,展示产线类型、投入产出量、报表信息和总成本 2. 新增成本数据服务类,支持按日期和产线获取完整成本明细与计算数据 3. 新增能耗和辅料两类成本报表页面,支持按产线筛选查看报表 4. 优化岗位管理页面,替换vis.js为ECharts实现岗位树图,新增职责弹窗查看功能 5. 优化综合成本页面,隐藏部分反填按钮和操作入口
This commit is contained in:
@@ -18,11 +18,11 @@
|
|||||||
<div slot="header" class="entry-header">
|
<div slot="header" class="entry-header">
|
||||||
<span class="entry-title">{{ activeReport.reportTitle }}</span>
|
<span class="entry-title">{{ activeReport.reportTitle }}</span>
|
||||||
<el-tag size="mini" style="margin-left:6px">{{ lineName(activeReport) }}</el-tag>
|
<el-tag size="mini" style="margin-left:6px">{{ lineName(activeReport) }}</el-tag>
|
||||||
<span class="entry-meta">{{ parseTime(activeReport.reportDate,'{y}-{m}-{d}') }} 投入{{ activeReport.inputWeight }}t 产出{{ activeReport.outputWeight }}t</span>
|
<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 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" @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-money" @click="openPriceMgr">价格管理</el-button>
|
||||||
<el-button size="mini" style="float:right;margin-left:8px" icon="el-icon-upload2" @click="backfillCost" :loading="backfilling">反填</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="float:right;margin-right:12px;font-size:12px;color:#606266;display:flex;align-items:center">
|
||||||
<span style="margin-right:4px">{{ inputMode ? '录入' : '查看' }}</span>
|
<span style="margin-right:4px">{{ inputMode ? '录入' : '查看' }}</span>
|
||||||
<el-switch v-model="inputMode" size="small" />
|
<el-switch v-model="inputMode" size="small" />
|
||||||
@@ -40,10 +40,10 @@
|
|||||||
</template>
|
</template>
|
||||||
<template slot-scope="s">
|
<template slot-scope="s">
|
||||||
<el-input v-model="s.row['q'+col.itemId]" size="mini" @input="recalcAll">
|
<el-input v-model="s.row['q'+col.itemId]" size="mini" @input="recalcAll">
|
||||||
<span slot="suffix" v-if="col.queryCondition" class="input-suffix-actions">
|
<!-- <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="反填" 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)" />
|
<i title="自动获取" :class="autoLoading[col.itemId]?'el-icon-loading':'el-icon-refresh'" class="ica ica-fetch" @click.stop="fetchAutoData(col, s.row)" />
|
||||||
</span>
|
</span> -->
|
||||||
</el-input>
|
</el-input>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
@@ -52,8 +52,18 @@
|
|||||||
<div class="col-hd">{{ col.itemName }}{{ col.unit ? '('+col.unit+')' : '' }}</div>
|
<div class="col-hd">{{ col.itemName }}{{ col.unit ? '('+col.unit+')' : '' }}</div>
|
||||||
</template>
|
</template>
|
||||||
<template slot-scope="s">
|
<template slot-scope="s">
|
||||||
<div class="shift-cell"><span class="shift-tag">甲</span><el-input v-model="s.row['q'+col.itemId+'_1']" size="mini" class="shift-input" @input="recalcAll"><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">
|
||||||
<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"><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>
|
<span class="shift-tag">甲</span>
|
||||||
|
<el-input v-model="s.row['q'+col.itemId+'_1']" size="mini" class="shift-input" @input="recalcAll">
|
||||||
|
<!-- <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">
|
||||||
|
<!-- <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>
|
||||||
<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">
|
<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">
|
||||||
|
|||||||
344
klp-ui/src/views/cost/costDataService.js
Normal file
344
klp-ui/src/views/cost/costDataService.js
Normal file
@@ -0,0 +1,344 @@
|
|||||||
|
import { listProdReport } from '@/api/cost/prodReport'
|
||||||
|
import { listProdDetail } from '@/api/cost/prodDetail'
|
||||||
|
import { listProdMetric } from '@/api/cost/prodMetric'
|
||||||
|
import { listItem } from '@/api/cost/item'
|
||||||
|
import { listPrice } from '@/api/cost/price'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 安全计算公式值
|
||||||
|
* @param {string} f - 公式表达式
|
||||||
|
* @returns {number|null}
|
||||||
|
*/
|
||||||
|
function evalFormula(f) {
|
||||||
|
const s = f.replace(/[^0-9+\-*/.()\s]/g, '')
|
||||||
|
if (!s) return null
|
||||||
|
try {
|
||||||
|
const r = new Function('return (' + s + ')')()
|
||||||
|
return isFinite(r) ? Math.round(r * 10000) / 10000 : null
|
||||||
|
} catch (e) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将明细数据按日期分组,并计算指标公式,生成完整行数据
|
||||||
|
*
|
||||||
|
* @param {Object} report - 生产月报
|
||||||
|
* @param {Array} details - 生产成本明细列表
|
||||||
|
* @param {Array} metrics - 生产指标列表
|
||||||
|
* @param {Array} items - 成本项目配置列表
|
||||||
|
* @returns {Array<Object>} data - 完整行数据数组
|
||||||
|
*/
|
||||||
|
function buildComputedRows(report, details, metrics, items) {
|
||||||
|
const itemCodeMap = {} // itemId -> itemCode
|
||||||
|
items.forEach(item => {
|
||||||
|
itemCodeMap[item.itemId] = item.itemCode
|
||||||
|
})
|
||||||
|
|
||||||
|
const esc = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||||
|
|
||||||
|
// 1. 按 detailDate 分组明细
|
||||||
|
const dateMap = {}
|
||||||
|
details.forEach(d => {
|
||||||
|
if (!d.detailDate) return
|
||||||
|
if (!dateMap[d.detailDate]) {
|
||||||
|
dateMap[d.detailDate] = { detailDate: d.detailDate }
|
||||||
|
}
|
||||||
|
const code = itemCodeMap[d.itemId]
|
||||||
|
const sfx = d.shift && d.shift !== '0' ? '_' + d.shift : ''
|
||||||
|
if (code) {
|
||||||
|
dateMap[d.detailDate]['q_' + code + sfx] = parseFloat(d.quantity) || 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const rows = Object.values(dateMap).sort((a, b) => a.detailDate.localeCompare(b.detailDate))
|
||||||
|
|
||||||
|
// 2. 补齐每个 itemCode 的总量(跨班次汇总)
|
||||||
|
items.forEach(item => {
|
||||||
|
const code = item.itemCode
|
||||||
|
if (!code) return
|
||||||
|
rows.forEach(row => {
|
||||||
|
const v1 = row['q_' + code + '_1'] || 0
|
||||||
|
const v2 = row['q_' + code + '_2'] || 0
|
||||||
|
if (v1 || v2) {
|
||||||
|
row['q_' + code] = v1 + v2
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// 3. 逐行计算指标公式
|
||||||
|
const rp = report || {}
|
||||||
|
rows.forEach(row => {
|
||||||
|
metrics.forEach((m, mi) => {
|
||||||
|
if (!m.metricFormula) return
|
||||||
|
|
||||||
|
let f = m.metricFormula
|
||||||
|
|
||||||
|
// 替换其他指标的引用(按定义顺序,前面的指标可被后面的引用)
|
||||||
|
for (let pmi = 0; pmi < mi; pmi++) {
|
||||||
|
const pm = metrics[pmi]
|
||||||
|
if (!pm.metricName) continue
|
||||||
|
const pn = esc(pm.metricName)
|
||||||
|
const pv = row['mv_' + pm.metricName]
|
||||||
|
if (pv != null) {
|
||||||
|
f = f.replace(new RegExp('@\\{' + pn + '\\}', 'g'), pv)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 替换明细项引用 @{itemCode}
|
||||||
|
items.forEach(item => {
|
||||||
|
if (!item.itemCode) return
|
||||||
|
const code = esc(item.itemCode)
|
||||||
|
const totalVal = parseFloat(row['q_' + item.itemCode]) || 0
|
||||||
|
const val1 = parseFloat(row['q_' + item.itemCode + '_1']) || 0
|
||||||
|
const val2 = parseFloat(row['q_' + item.itemCode + '_2']) || 0
|
||||||
|
|
||||||
|
f = f.replace(new RegExp('@\\{' + code + '\\}\\.甲班', 'g'), val1)
|
||||||
|
f = f.replace(new RegExp('@\\{' + code + '\\}\\.乙班', 'g'), val2)
|
||||||
|
f = f.replace(new RegExp('@\\{' + code + '\\}', 'g'), totalVal)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 替换系统变量
|
||||||
|
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)
|
||||||
|
|
||||||
|
const result = evalFormula(f)
|
||||||
|
if (result != null) {
|
||||||
|
row['mv_' + m.metricName] = result
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取指定日期和产线类型的全部成本数据
|
||||||
|
*
|
||||||
|
* @param {string} date - 日期,格式 yyyy-MM-dd,如 "2024-01-15"
|
||||||
|
* @param {string} lineType - 产线类型,"1"=镀锌,"2"=酸轧
|
||||||
|
* @returns {Promise<{
|
||||||
|
* report: Object|null, // 生产月报
|
||||||
|
* metrics: Array, // 生产指标明细列表(公式列)
|
||||||
|
* details: Array, // 生产成本明细列表(原始数据)
|
||||||
|
* items: Array, // 成本项目配置列表(原始数据列定义)
|
||||||
|
* data: Array, // 完整行数据:按日期分组,包含原始值(q_xxx)和计算值(mv_xxx)
|
||||||
|
* prices: Array, // 成本单价历史列表
|
||||||
|
* detailMap: Object, // 按 itemId 分组的明细
|
||||||
|
* itemMap: Object, // 按 itemId 索引的项目配置
|
||||||
|
* metricMap: Object // 按 metricId 索引的指标
|
||||||
|
* }>}
|
||||||
|
*/
|
||||||
|
export async function getCostDataByDate(date, lineType) {
|
||||||
|
// 1. 查找指定日期和产线类型的生产月报
|
||||||
|
// 假设每个日期只有一条月报,所以取第一个, 需要将日期转为第一天的日期
|
||||||
|
const firstDay = new Date(date)
|
||||||
|
firstDay.setDate(1)
|
||||||
|
console.log(firstDay)
|
||||||
|
// 使用北京时间,转换为东八区时间
|
||||||
|
const year = firstDay.getFullYear()
|
||||||
|
const month = (firstDay.getMonth() + 1).toString().padStart(2, '0')
|
||||||
|
const firstDayStr = year + '-' + month + '-01 00:00:00'
|
||||||
|
const reportRes = await listProdReport({
|
||||||
|
reportDate: firstDayStr,
|
||||||
|
lineType: lineType,
|
||||||
|
pageNum: 1,
|
||||||
|
pageSize: 1
|
||||||
|
})
|
||||||
|
console.log(reportRes)
|
||||||
|
const reports = reportRes.rows || []
|
||||||
|
const report = reports.length > 0 ? reports[0] : null
|
||||||
|
|
||||||
|
// 2. 获取所有成本项目配置(用于映射 itemId)
|
||||||
|
const itemRes = await listItem({ pageNum: 1, pageSize: 9999 })
|
||||||
|
const items = itemRes.rows || []
|
||||||
|
const itemMap = {}
|
||||||
|
items.forEach(item => {
|
||||||
|
itemMap[item.itemId] = item
|
||||||
|
})
|
||||||
|
|
||||||
|
// 3. 获取该日期下生效的单价历史
|
||||||
|
const priceRes = await listPrice({
|
||||||
|
effectiveDate: date,
|
||||||
|
pageNum: 1,
|
||||||
|
pageSize: 9999
|
||||||
|
})
|
||||||
|
const prices = priceRes.rows || []
|
||||||
|
|
||||||
|
// 如果没有找到月报,返回基础数据
|
||||||
|
if (!report) {
|
||||||
|
return {
|
||||||
|
report: null,
|
||||||
|
metrics: [],
|
||||||
|
details: [],
|
||||||
|
items,
|
||||||
|
data: [],
|
||||||
|
prices,
|
||||||
|
detailMap: {},
|
||||||
|
itemMap,
|
||||||
|
metricMap: {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const reportId = report.reportId
|
||||||
|
|
||||||
|
// 4. 获取该月报的所有生产成本明细
|
||||||
|
const detailRes = await listProdDetail({
|
||||||
|
reportId,
|
||||||
|
pageNum: 1,
|
||||||
|
pageSize: 9999
|
||||||
|
})
|
||||||
|
const details = detailRes.rows || []
|
||||||
|
|
||||||
|
// 5. 构建按 itemId 分组的明细
|
||||||
|
const detailMap = {}
|
||||||
|
details.forEach(detail => {
|
||||||
|
const key = detail.itemId
|
||||||
|
if (!detailMap[key]) {
|
||||||
|
detailMap[key] = []
|
||||||
|
}
|
||||||
|
detailMap[key].push(detail)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 6. 获取该月报的所有生产指标
|
||||||
|
const metricRes = await listProdMetric({
|
||||||
|
reportId,
|
||||||
|
pageNum: 1,
|
||||||
|
pageSize: 9999
|
||||||
|
})
|
||||||
|
const metrics = metricRes.rows || []
|
||||||
|
const metricMap = {}
|
||||||
|
metrics.forEach(metric => {
|
||||||
|
metricMap[metric.metricId] = metric
|
||||||
|
})
|
||||||
|
|
||||||
|
// 7. 构建完整行数据(原始值 + 公式计算值)
|
||||||
|
const data = buildComputedRows(report, details, metrics, items)
|
||||||
|
|
||||||
|
return {
|
||||||
|
report,
|
||||||
|
metrics,
|
||||||
|
details,
|
||||||
|
items,
|
||||||
|
data,
|
||||||
|
prices,
|
||||||
|
detailMap,
|
||||||
|
itemMap,
|
||||||
|
metricMap
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取指定日期范围、产线类型的成本数据,支持多天批量查询
|
||||||
|
*
|
||||||
|
* @param {string} startDate - 开始日期 yyyy-MM-dd
|
||||||
|
* @param {string} endDate - 结束日期 yyyy-MM-dd
|
||||||
|
* @param {string} lineType - 产线类型
|
||||||
|
* @returns {Promise<Array<{ date: string, report, metrics, details, items, data, prices }>>}
|
||||||
|
*/
|
||||||
|
export async function getCostDataByDateRange(startDate, endDate, lineType) {
|
||||||
|
// 查询日期范围内的所有月报
|
||||||
|
const reportRes = await listProdReport({
|
||||||
|
lineType,
|
||||||
|
pageNum: 1,
|
||||||
|
pageSize: 9999
|
||||||
|
})
|
||||||
|
const allReports = (reportRes.rows || []).filter(r => {
|
||||||
|
return r.reportDate >= startDate && r.reportDate <= endDate
|
||||||
|
})
|
||||||
|
|
||||||
|
// 获取所有项目配置和单价(共享数据,只查一次)
|
||||||
|
const itemRes = await listItem({ pageNum: 1, pageSize: 9999 })
|
||||||
|
const items = itemRes.rows || []
|
||||||
|
const itemMap = {}
|
||||||
|
items.forEach(item => {
|
||||||
|
itemMap[item.itemId] = item
|
||||||
|
})
|
||||||
|
|
||||||
|
const priceRes = await listPrice({
|
||||||
|
pageNum: 1,
|
||||||
|
pageSize: 9999
|
||||||
|
})
|
||||||
|
const prices = priceRes.rows || []
|
||||||
|
|
||||||
|
// 逐日报表获取完整数据
|
||||||
|
const results = []
|
||||||
|
for (const report of allReports) {
|
||||||
|
const reportId = report.reportId
|
||||||
|
|
||||||
|
const [detailRes, metricRes] = await Promise.all([
|
||||||
|
listProdDetail({ reportId, pageNum: 1, pageSize: 9999 }),
|
||||||
|
listProdMetric({ reportId, pageNum: 1, pageSize: 9999 })
|
||||||
|
])
|
||||||
|
|
||||||
|
const details = detailRes.rows || []
|
||||||
|
const metrics = metricRes.rows || []
|
||||||
|
|
||||||
|
const detailMap = {}
|
||||||
|
details.forEach(detail => {
|
||||||
|
const key = detail.itemId
|
||||||
|
if (!detailMap[key]) detailMap[key] = []
|
||||||
|
detailMap[key].push(detail)
|
||||||
|
})
|
||||||
|
|
||||||
|
const metricMap = {}
|
||||||
|
metrics.forEach(metric => {
|
||||||
|
metricMap[metric.metricId] = metric
|
||||||
|
})
|
||||||
|
|
||||||
|
const data = buildComputedRows(report, details, metrics, items)
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
date: report.reportDate,
|
||||||
|
report,
|
||||||
|
metrics,
|
||||||
|
details,
|
||||||
|
items,
|
||||||
|
data,
|
||||||
|
prices,
|
||||||
|
detailMap,
|
||||||
|
itemMap,
|
||||||
|
metricMap
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据 itemId 获取指定日期下该成本项目的明细数据
|
||||||
|
*
|
||||||
|
* @param {string} date - 日期 yyyy-MM-dd
|
||||||
|
* @param {string} lineType - 产线类型
|
||||||
|
* @param {string|number} itemId - 成本项目ID
|
||||||
|
* @returns {Promise<{ quantity: number|null, unitPrice: number|null, amount: number|null }>}
|
||||||
|
*/
|
||||||
|
export async function getCostItemData(date, lineType, itemId) {
|
||||||
|
const { details, items } = await getCostDataByDate(date, lineType)
|
||||||
|
const itemDetails = details.filter(d => String(d.itemId) === String(itemId))
|
||||||
|
const item = items.find(i => String(i.itemId) === String(itemId))
|
||||||
|
|
||||||
|
let totalQuantity = 0
|
||||||
|
itemDetails.forEach(d => {
|
||||||
|
totalQuantity += parseFloat(d.quantity) || 0
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
itemId,
|
||||||
|
itemName: item ? item.itemName : '',
|
||||||
|
itemCode: item ? item.itemCode : '',
|
||||||
|
category: item ? item.category : '',
|
||||||
|
unit: item ? item.unit : '',
|
||||||
|
quantity: totalQuantity || null,
|
||||||
|
unitPrice: itemDetails.length > 0 ? itemDetails[0].unitPrice : null,
|
||||||
|
amount: itemDetails.length > 0 ? itemDetails[0].amount : null,
|
||||||
|
details: itemDetails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
getCostDataByDate,
|
||||||
|
getCostDataByDateRange,
|
||||||
|
getCostItemData
|
||||||
|
}
|
||||||
162
klp-ui/src/views/cost/views/assised.vue
Normal file
162
klp-ui/src/views/cost/views/assised.vue
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
<template>
|
||||||
|
<div class="app-container">
|
||||||
|
<!-- 产线 tabs -->
|
||||||
|
<div class="line-tab-bar" v-if="productionLines.length">
|
||||||
|
<div class="line-tabs">
|
||||||
|
<span v-for="line in productionLines" :key="line.lineId"
|
||||||
|
:class="['line-tab', { active: currentLineId === line.lineId }]"
|
||||||
|
@click="switchLine(line.lineId)">{{ line.lineName }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
<el-table v-loading="gridLoading" height="calc(100vh - 260px)" :data="gridRows" border stripe size="mini" style="width:100%">
|
||||||
|
<el-table-column label="日期" width="135" fixed>
|
||||||
|
<template slot-scope="s">{{ parseTime(s.row.detailDate, '{y}-{m}-{d}') }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column v-for="col in displayCols" :key="'d'+col.itemId" align="center">
|
||||||
|
<template slot="header">
|
||||||
|
<div class="col-hd">{{ col.itemName }}{{ col.unit ? '('+col.unit+')' : '' }}</div>
|
||||||
|
</template>
|
||||||
|
<template slot-scope="s">{{ formatNum(s.row['q'+col.itemId]) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-card>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { mapGetters } from 'vuex'
|
||||||
|
import { listProdReport, getProdReport } from "@/api/cost/prodReport"
|
||||||
|
import { listProdDetail } from "@/api/cost/prodDetail"
|
||||||
|
import { listItem } from "@/api/cost/item"
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: "CostComprehensive",
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
tabs: [],
|
||||||
|
activeReport: null, gridLoading: false, gridRows: [],
|
||||||
|
allItems: [], allCols: [],
|
||||||
|
currentLineId: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
...mapGetters(['productionLines']),
|
||||||
|
displayCols() {
|
||||||
|
return this.allCols.filter(c => c.$type === 'detail')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created() {
|
||||||
|
this.$store.dispatch('productionLine/getProductionLines').then(() => {
|
||||||
|
if (this.productionLines.length > 0) {
|
||||||
|
this.currentLineId = this.productionLines[0].lineId
|
||||||
|
this.getTabList()
|
||||||
|
this.loadItems()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
switchLine(lineId) {
|
||||||
|
this.currentLineId = lineId
|
||||||
|
this.activeReport = null
|
||||||
|
this.gridRows = []
|
||||||
|
this.allCols = []
|
||||||
|
this.getTabList()
|
||||||
|
},
|
||||||
|
getTabList() {
|
||||||
|
const params = { pageNum: 1, pageSize: 9999 }
|
||||||
|
if (this.currentLineId) params.lineType = this.currentLineId
|
||||||
|
listProdReport(params).then(r => { this.tabs = r.rows || [] })
|
||||||
|
},
|
||||||
|
|
||||||
|
/* 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 || [])
|
||||||
|
} finally { this.gridLoading = false }
|
||||||
|
},
|
||||||
|
async restoreAllCols() {
|
||||||
|
await this.loadItems()
|
||||||
|
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 && i.category === '辅料')
|
||||||
|
if (item) cols.push({ $type: 'detail', itemId: item.itemId, itemCode: item.itemCode, itemName: item.itemName, unit: item.unit })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.allCols = cols
|
||||||
|
},
|
||||||
|
buildGrid(details) {
|
||||||
|
const map = {}; details.forEach(d => {
|
||||||
|
if (!d.detailDate) return
|
||||||
|
if (!map[d.detailDate]) map[d.detailDate] = { detailDate: d.detailDate }
|
||||||
|
const key = 'q' + d.itemId
|
||||||
|
const val = d.quantity ? Number(d.quantity) : 0
|
||||||
|
map[d.detailDate][key] = (map[d.detailDate][key] || 0) + val
|
||||||
|
})
|
||||||
|
this.gridRows = Object.values(map).sort((a,b) => a.detailDate.localeCompare(b.detailDate))
|
||||||
|
},
|
||||||
|
|
||||||
|
/* helpers */
|
||||||
|
lineName(row) {
|
||||||
|
if (row.lineType) {
|
||||||
|
const found = this.productionLines.find(l => l.lineId == row.lineType);
|
||||||
|
if (found) return found.lineName
|
||||||
|
else return row.lineType || '-'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
formatNum(val) { if (val === null || val === undefined || val === '') return ''; const n = Number(val); if (isNaN(n)) return val; return parseFloat(n.toFixed(4)) },
|
||||||
|
async loadItems() { if (!this.allItems.length) { const r = await listItem({ pageNum:1, pageSize:999 }); this.allItems = r.rows || [] } },
|
||||||
|
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 td { padding: 6px 0; }
|
||||||
|
/deep/ .el-table th { padding: 8px 0; font-size: 13px; }
|
||||||
|
/deep/ .el-table .cell { padding: 0 6px; line-height: 1.5; font-size: 13px; }
|
||||||
|
/deep/ .el-button--mini { padding: 4px 8px; font-size: 12px; }
|
||||||
|
/deep/ .el-button--mini.el-button--text { padding: 0; }
|
||||||
|
.line-tab-bar { display: flex; align-items: center; border-bottom: 1px solid #dcdfe6; margin-bottom: 10px; padding: 0 4px; }
|
||||||
|
.line-tabs { flex: 1; display: flex; overflow-x: auto; margin-bottom: -1px; }
|
||||||
|
.line-tab { display: inline-block; padding: 8px 20px; cursor: pointer; border-bottom: 2px solid transparent; white-space: nowrap; font-size: 14px; color: #606266; transition: all 0.2s; user-select: none; }
|
||||||
|
.line-tab:hover { color: #1890ff; }
|
||||||
|
.line-tab.active { color: #1890ff; border-bottom-color: #1890ff; font-weight: 500; }
|
||||||
|
.report-tab-bar { display: flex; align-items: center; border-bottom: 1px solid #dcdfe6; margin-bottom: 14px; padding: 0 4px; }
|
||||||
|
.report-tabs { flex: 1; display: flex; overflow-x: auto; margin-bottom: -1px; }
|
||||||
|
.report-tab { display: inline-block; padding: 9px 20px; cursor: pointer; border-bottom: 2px solid transparent; white-space: nowrap; font-size: 14px; 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: 80px 0; color: #999; font-size: 15px; }
|
||||||
|
.entry-header { line-height: 32px; }
|
||||||
|
.entry-title { font-weight: bold; font-size: 16px; }
|
||||||
|
.entry-meta { margin-left: 10px; color: #999; font-size: 13px; }
|
||||||
|
.col-hd { font-size: 13px; line-height: 1.4; }
|
||||||
|
</style>
|
||||||
162
klp-ui/src/views/cost/views/enegry.vue
Normal file
162
klp-ui/src/views/cost/views/enegry.vue
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
<template>
|
||||||
|
<div class="app-container">
|
||||||
|
<!-- 产线 tabs -->
|
||||||
|
<div class="line-tab-bar" v-if="productionLines.length">
|
||||||
|
<div class="line-tabs">
|
||||||
|
<span v-for="line in productionLines" :key="line.lineId"
|
||||||
|
:class="['line-tab', { active: currentLineId === line.lineId }]"
|
||||||
|
@click="switchLine(line.lineId)">{{ line.lineName }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
<el-table v-loading="gridLoading" height="calc(100vh - 260px)" :data="gridRows" border stripe size="mini" style="width:100%">
|
||||||
|
<el-table-column label="日期" width="135" fixed>
|
||||||
|
<template slot-scope="s">{{ parseTime(s.row.detailDate, '{y}-{m}-{d}') }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column v-for="col in displayCols" :key="'d'+col.itemId" align="center">
|
||||||
|
<template slot="header">
|
||||||
|
<div class="col-hd">{{ col.itemName }}{{ col.unit ? '('+col.unit+')' : '' }}</div>
|
||||||
|
</template>
|
||||||
|
<template slot-scope="s">{{ formatNum(s.row['q'+col.itemId]) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-card>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { mapGetters } from 'vuex'
|
||||||
|
import { listProdReport, getProdReport } from "@/api/cost/prodReport"
|
||||||
|
import { listProdDetail } from "@/api/cost/prodDetail"
|
||||||
|
import { listItem } from "@/api/cost/item"
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: "CostComprehensive",
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
tabs: [],
|
||||||
|
activeReport: null, gridLoading: false, gridRows: [],
|
||||||
|
allItems: [], allCols: [],
|
||||||
|
currentLineId: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
...mapGetters(['productionLines']),
|
||||||
|
displayCols() {
|
||||||
|
return this.allCols.filter(c => c.$type === 'detail')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created() {
|
||||||
|
this.$store.dispatch('productionLine/getProductionLines').then(() => {
|
||||||
|
if (this.productionLines.length > 0) {
|
||||||
|
this.currentLineId = this.productionLines[0].lineId
|
||||||
|
this.getTabList()
|
||||||
|
this.loadItems()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
switchLine(lineId) {
|
||||||
|
this.currentLineId = lineId
|
||||||
|
this.activeReport = null
|
||||||
|
this.gridRows = []
|
||||||
|
this.allCols = []
|
||||||
|
this.getTabList()
|
||||||
|
},
|
||||||
|
getTabList() {
|
||||||
|
const params = { pageNum: 1, pageSize: 9999 }
|
||||||
|
if (this.currentLineId) params.lineType = this.currentLineId
|
||||||
|
listProdReport(params).then(r => { this.tabs = r.rows || [] })
|
||||||
|
},
|
||||||
|
|
||||||
|
/* 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 || [])
|
||||||
|
} finally { this.gridLoading = false }
|
||||||
|
},
|
||||||
|
async restoreAllCols() {
|
||||||
|
await this.loadItems()
|
||||||
|
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 && i.category === '能耗')
|
||||||
|
if (item) cols.push({ $type: 'detail', itemId: item.itemId, itemCode: item.itemCode, itemName: item.itemName, unit: item.unit })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.allCols = cols
|
||||||
|
},
|
||||||
|
buildGrid(details) {
|
||||||
|
const map = {}; details.forEach(d => {
|
||||||
|
if (!d.detailDate) return
|
||||||
|
if (!map[d.detailDate]) map[d.detailDate] = { detailDate: d.detailDate }
|
||||||
|
const key = 'q' + d.itemId
|
||||||
|
const val = d.quantity ? Number(d.quantity) : 0
|
||||||
|
map[d.detailDate][key] = (map[d.detailDate][key] || 0) + val
|
||||||
|
})
|
||||||
|
this.gridRows = Object.values(map).sort((a,b) => a.detailDate.localeCompare(b.detailDate))
|
||||||
|
},
|
||||||
|
|
||||||
|
/* helpers */
|
||||||
|
lineName(row) {
|
||||||
|
if (row.lineType) {
|
||||||
|
const found = this.productionLines.find(l => l.lineId == row.lineType);
|
||||||
|
if (found) return found.lineName
|
||||||
|
else return row.lineType || '-'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
formatNum(val) { if (val === null || val === undefined || val === '') return ''; const n = Number(val); if (isNaN(n)) return val; return parseFloat(n.toFixed(4)) },
|
||||||
|
async loadItems() { if (!this.allItems.length) { const r = await listItem({ pageNum:1, pageSize:999 }); this.allItems = r.rows || [] } },
|
||||||
|
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 td { padding: 6px 0; }
|
||||||
|
/deep/ .el-table th { padding: 8px 0; font-size: 13px; }
|
||||||
|
/deep/ .el-table .cell { padding: 0 6px; line-height: 1.5; font-size: 13px; }
|
||||||
|
/deep/ .el-button--mini { padding: 4px 8px; font-size: 12px; }
|
||||||
|
/deep/ .el-button--mini.el-button--text { padding: 0; }
|
||||||
|
.line-tab-bar { display: flex; align-items: center; border-bottom: 1px solid #dcdfe6; margin-bottom: 10px; padding: 0 4px; }
|
||||||
|
.line-tabs { flex: 1; display: flex; overflow-x: auto; margin-bottom: -1px; }
|
||||||
|
.line-tab { display: inline-block; padding: 8px 20px; cursor: pointer; border-bottom: 2px solid transparent; white-space: nowrap; font-size: 14px; color: #606266; transition: all 0.2s; user-select: none; }
|
||||||
|
.line-tab:hover { color: #1890ff; }
|
||||||
|
.line-tab.active { color: #1890ff; border-bottom-color: #1890ff; font-weight: 500; }
|
||||||
|
.report-tab-bar { display: flex; align-items: center; border-bottom: 1px solid #dcdfe6; margin-bottom: 14px; padding: 0 4px; }
|
||||||
|
.report-tabs { flex: 1; display: flex; overflow-x: auto; margin-bottom: -1px; }
|
||||||
|
.report-tab { display: inline-block; padding: 9px 20px; cursor: pointer; border-bottom: 2px solid transparent; white-space: nowrap; font-size: 14px; 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: 80px 0; color: #999; font-size: 15px; }
|
||||||
|
.entry-header { line-height: 32px; }
|
||||||
|
.entry-title { font-weight: bold; font-size: 16px; }
|
||||||
|
.entry-meta { margin-left: 10px; color: #999; font-size: 13px; }
|
||||||
|
.col-hd { font-size: 13px; line-height: 1.4; }
|
||||||
|
</style>
|
||||||
@@ -16,13 +16,70 @@
|
|||||||
<el-descriptions-item label="钢卷净重">
|
<el-descriptions-item label="钢卷净重">
|
||||||
<span>{{ coilInfo.netWeight || 0 }} t</span>
|
<span>{{ coilInfo.netWeight || 0 }} t</span>
|
||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item v-if="costReport" label="产线类型">
|
||||||
|
<el-tag size="mini">{{ lineTypeName }}</el-tag>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item v-if="costReport" label="投入量">
|
||||||
|
<span>{{ costReport.inputWeight || 0 }} t</span>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item v-if="costReport" label="产出量">
|
||||||
|
<span>{{ costReport.outputWeight || 0 }} t</span>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item v-if="costReport" label="报表日期">
|
||||||
|
<span>{{ costReport.reportDate }}</span>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item v-if="costReport" label="报表名称">
|
||||||
|
<span>{{ costReport.reportTitle }}</span>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item v-if="totalCost != null" label="当班总成本">
|
||||||
|
<span class="cost-value">{{ totalCost }}</span>
|
||||||
|
<span class="cost-unit"> 元</span>
|
||||||
|
</el-descriptions-item>
|
||||||
</el-descriptions>
|
</el-descriptions>
|
||||||
|
|
||||||
|
<!-- 成本明细表格 -->
|
||||||
|
<div v-if="costRows && costRows.length" class="cost-table-wrap">
|
||||||
|
<div class="cost-table-title">当日生产成本明细</div>
|
||||||
|
<el-table :data="costRows" border stripe size="mini" style="width:100%">
|
||||||
|
<el-table-column label="日期" prop="detailDate" width="100" align="center" />
|
||||||
|
<template v-for="item in costItems">
|
||||||
|
<el-table-column v-if="item.itemCode" :key="'i' + item.itemId"
|
||||||
|
:label="item.itemName + (item.unit ? '(' + item.unit + ')' : '')" width="110" align="center">
|
||||||
|
<template slot-scope="s">
|
||||||
|
{{ formatVal(s.row['q_' + item.itemCode]) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</template>
|
||||||
|
<template v-for="m in costMetrics">
|
||||||
|
<el-table-column v-if="m.metricName" :key="'m' + m.metricId" :label="m.metricName" width="100"
|
||||||
|
align="center">
|
||||||
|
<template slot-scope="s">
|
||||||
|
{{ formatVal(s.row['mv_' + m.metricName]) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</template>
|
||||||
|
</el-table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { formatTime } from '../statusUtils'
|
import costDataService from '@/views/cost/costDataService'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 仓库ID -> 成本产线类型映射
|
||||||
|
* 酸连轧成品库 -> 2,镀锌成品库 -> 1
|
||||||
|
*/
|
||||||
|
const WAREHOUSE_LINE_TYPE_MAP = {
|
||||||
|
'1988150099140866050': '2',
|
||||||
|
'1988150323162836993': '1'
|
||||||
|
}
|
||||||
|
|
||||||
|
const LINE_TYPE_NAME_MAP = {
|
||||||
|
'2': '酸轧',
|
||||||
|
'1': '镀锌'
|
||||||
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'CostInfoSection',
|
name: 'CostInfoSection',
|
||||||
@@ -31,6 +88,101 @@ export default {
|
|||||||
traceResult: { type: Object, default: null },
|
traceResult: { type: Object, default: null },
|
||||||
hoardingDays: { type: Number, default: 0 },
|
hoardingDays: { type: Number, default: 0 },
|
||||||
hoardingCost: { type: [String, Number], default: '0.00' }
|
hoardingCost: { type: [String, Number], default: '0.00' }
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
costData: null,
|
||||||
|
loadingCost: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
lineType() {
|
||||||
|
return WAREHOUSE_LINE_TYPE_MAP[this.coilInfo.warehouseId] || null
|
||||||
|
},
|
||||||
|
lineTypeName() {
|
||||||
|
return this.lineType ? (LINE_TYPE_NAME_MAP[this.lineType] || this.lineType) : ''
|
||||||
|
},
|
||||||
|
costReport() {
|
||||||
|
return this.costData ? this.costData.report : null
|
||||||
|
},
|
||||||
|
costMetrics() {
|
||||||
|
return this.costData ? this.costData.metrics : []
|
||||||
|
},
|
||||||
|
costItems() {
|
||||||
|
if (!this.costData) return []
|
||||||
|
return this.costData.items.filter(item => this.costData.detailMap[item.itemId])
|
||||||
|
},
|
||||||
|
costRows() {
|
||||||
|
return this.costData ? this.costData.data : []
|
||||||
|
},
|
||||||
|
totalCost() {
|
||||||
|
if (!this.costRows || !this.costRows.length) return null
|
||||||
|
for (const row of this.costRows) {
|
||||||
|
for (const key of Object.keys(row)) {
|
||||||
|
if (key.startsWith('mv_') && row[key] != null) {
|
||||||
|
return row[key]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
'coilInfo.warehouseId': {
|
||||||
|
immediate: true,
|
||||||
|
handler() {
|
||||||
|
this.fetchCostData()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
formatVal(val) {
|
||||||
|
return val != null ? val : '-'
|
||||||
|
},
|
||||||
|
async fetchCostData() {
|
||||||
|
console.log(this.coilInfo.warehouseId)
|
||||||
|
console.log(this.lineType)
|
||||||
|
if (!this.lineType) return
|
||||||
|
const createTime = this.coilInfo.createTime
|
||||||
|
if (!createTime) return
|
||||||
|
const date = createTime.slice(0, 10)
|
||||||
|
this.loadingCost = true
|
||||||
|
try {
|
||||||
|
console.log(date, this.lineType, '获取成本数据')
|
||||||
|
this.costData = await costDataService.getCostDataByDate(date, this.lineType)
|
||||||
|
console.log(this.costData, '成本数据')
|
||||||
|
} catch (e) {
|
||||||
|
console.error('获取成本数据失败:', e)
|
||||||
|
} finally {
|
||||||
|
this.loadingCost = false
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.cost-value {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #f56c6c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cost-unit {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #909399;
|
||||||
|
margin-left: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cost-table-wrap {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cost-table-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #303133;
|
||||||
|
padding: 6px 0;
|
||||||
|
border-bottom: 1px solid #ebeef5;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -3,100 +3,41 @@
|
|||||||
<!-- 顶部搜索与操作栏 -->
|
<!-- 顶部搜索与操作栏 -->
|
||||||
<el-form :model="queryParams" ref="queryForm" size="small" :inline="true" v-show="showSearch" label-width="68px">
|
<el-form :model="queryParams" ref="queryForm" size="small" :inline="true" v-show="showSearch" label-width="68px">
|
||||||
<el-form-item label="岗位名称" prop="postName">
|
<el-form-item label="岗位名称" prop="postName">
|
||||||
<el-input
|
<el-input v-model="queryParams.postName" placeholder="请输入岗位名称" clearable @keyup.enter.native="handleQuery" />
|
||||||
v-model="queryParams.postName"
|
|
||||||
placeholder="请输入岗位名称"
|
|
||||||
clearable
|
|
||||||
@keyup.enter.native="handleQuery"
|
|
||||||
/>
|
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
<el-form-item>
|
<el-form-item>
|
||||||
|
<el-button type="primary" plain icon="el-icon-plus" size="mini" @click="handleAdd">新增岗位</el-button>
|
||||||
<el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
|
<el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
|
||||||
<el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
|
<el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
|
|
||||||
<el-row :gutter="10" class="mb8">
|
<div class="tree-panel" v-loading="loading">
|
||||||
<el-col :span="1.5">
|
|
||||||
<el-button
|
|
||||||
type="primary"
|
|
||||||
plain
|
|
||||||
icon="el-icon-plus"
|
|
||||||
size="mini"
|
|
||||||
@click="handleAdd"
|
|
||||||
>新增岗位</el-button>
|
|
||||||
</el-col>
|
|
||||||
<right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar>
|
|
||||||
</el-row>
|
|
||||||
|
|
||||||
<!-- 左右主体区域 -->
|
|
||||||
<el-row :gutter="16" class="post-content" v-loading="loading">
|
|
||||||
<!-- 左侧:ECharts 岗位树 + 岗位信息 -->
|
|
||||||
<el-col :span="18">
|
|
||||||
<div class="tree-panel">
|
|
||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
<span class="panel-title">岗位结构树</span>
|
<span v-if="!currentPost" class="panel-hint">单击节点查看详情,双击查看岗位职责</span>
|
||||||
<span v-if="!currentPost" class="panel-hint">点击节点查看详情</span>
|
<span v-else class="panel-hint">双击「{{ currentPost.postName }}」查看岗位职责</span>
|
||||||
</div>
|
<div v-if="currentPost">
|
||||||
<div ref="treeChart" class="chart-container"></div>
|
|
||||||
<!-- 岗位信息及操作 -->
|
|
||||||
<div v-if="currentPost" class="post-info-bar">
|
|
||||||
<span class="post-name">{{ currentPost.postName }}</span>
|
|
||||||
<div class="post-actions">
|
<div class="post-actions">
|
||||||
<el-button type="primary" size="mini" icon="el-icon-edit" @click="handleUpdate(currentPost)">编辑</el-button>
|
<el-button type="primary" size="mini" icon="el-icon-edit" @click="handleUpdate(currentPost)">编辑</el-button>
|
||||||
<el-button type="success" size="mini" icon="el-icon-plus" @click="handleAdd(currentPost)">添加子级</el-button>
|
<el-button type="success" size="mini" icon="el-icon-plus" @click="handleAdd(currentPost)">添加子级</el-button>
|
||||||
<el-button type="danger" size="mini" icon="el-icon-delete" @click="handleDelete(currentPost)">删除</el-button>
|
<el-button type="danger" size="mini" icon="el-icon-delete" @click="handleDelete(currentPost)">删除</el-button>
|
||||||
|
<el-button type="warning" size="mini" icon="el-icon-document"
|
||||||
|
@click="handleShowDuty(currentPost)">查看职责</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</el-col>
|
<div ref="treeChart" class="chart-container"></div>
|
||||||
|
<!-- 岗位信息及操作 -->
|
||||||
|
|
||||||
<!-- 右侧:岗位职责管理 -->
|
|
||||||
<el-col :span="6">
|
|
||||||
<div class="duty-panel">
|
|
||||||
<div class="panel-header">
|
|
||||||
<span class="panel-title">岗位职责</span>
|
|
||||||
<span v-if="currentPost" class="panel-sub-title">{{ currentPost.postName }}</span>
|
|
||||||
<span v-else class="panel-hint">请点击左侧岗位节点</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 自定义职责列表 -->
|
|
||||||
<div v-if="currentPost" class="duty-list-wrap">
|
|
||||||
<div class="duty-toolbar">
|
|
||||||
<el-button type="primary" plain icon="el-icon-plus" size="mini" @click="handleAddDuty">新增职责</el-button>
|
|
||||||
</div>
|
|
||||||
<div v-loading="dutyLoading" class="duty-list">
|
|
||||||
<div v-for="item in dutyList" :key="item.dutyId" class="duty-item">
|
|
||||||
<div class="duty-item-header">
|
|
||||||
<span class="duty-name">{{ item.dutyName }}</span>
|
|
||||||
<!-- <span class="duty-sort">排序: {{ item.sortOrder }}</span> -->
|
|
||||||
</div>
|
|
||||||
<div class="duty-content">{{ item.dutyContent }}</div>
|
|
||||||
<div v-if="item.remark" class="duty-remark">备注:{{ item.remark }}</div>
|
|
||||||
<div class="duty-item-actions">
|
|
||||||
<el-button type="text" size="mini" icon="el-icon-edit" @click="handleUpdateDuty(item)">修改</el-button>
|
|
||||||
<el-button type="text" size="mini" icon="el-icon-delete" style="color: #F56C6C" @click="handleDeleteDuty(item)">删除</el-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<el-empty v-if="!dutyLoading && dutyList.length === 0" description="暂无职责" :image-size="60" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 未选择岗位时的提示 -->
|
|
||||||
<div v-else class="empty-hint">
|
|
||||||
<i class="el-icon-info"></i>
|
|
||||||
<p>请点击左侧树图中的岗位节点以查看和管理其岗位职责</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</el-col>
|
|
||||||
</el-row>
|
|
||||||
|
|
||||||
<!-- 添加或修改岗位对话框 -->
|
<!-- 添加或修改岗位对话框 -->
|
||||||
<el-dialog :title="title" :visible.sync="open" width="600px" append-to-body>
|
<el-dialog :title="title" :visible.sync="open" width="600px" append-to-body>
|
||||||
<el-form ref="form" :model="form" :rules="rules" label-width="100px">
|
<el-form ref="form" :model="form" :rules="rules" label-width="100px">
|
||||||
<el-form-item label="上级岗位" prop="parentId">
|
<el-form-item label="上级岗位" prop="parentId">
|
||||||
<treeselect v-model="form.parentId" :options="postOptions" :normalizer="normalizer" placeholder="请选择上级岗位(留空为顶级)" />
|
<treeselect v-model="form.parentId" :options="postOptions" :normalizer="normalizer"
|
||||||
|
placeholder="请选择上级岗位(留空为顶级)" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="岗位名称" prop="postName">
|
<el-form-item label="岗位名称" prop="postName">
|
||||||
<el-input v-model="form.postName" placeholder="请输入岗位名称" />
|
<el-input v-model="form.postName" placeholder="请输入岗位名称" />
|
||||||
@@ -167,15 +108,39 @@
|
|||||||
<el-button @click="cancelDuty">取 消</el-button>
|
<el-button @click="cancelDuty">取 消</el-button>
|
||||||
</div>
|
</div>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- 岗位职责查看对话框(双击节点打开) -->
|
||||||
|
<el-dialog :title="dutyDialogTitle" :visible.sync="dutyDialogVisible" width="700px" append-to-body>
|
||||||
|
<div class="duty-dialog-wrap">
|
||||||
|
<div class="duty-dialog-toolbar">
|
||||||
|
<el-button type="primary" plain icon="el-icon-plus" size="mini" @click="handleAddDuty">新增职责</el-button>
|
||||||
|
</div>
|
||||||
|
<div v-loading="dutyLoading" class="duty-dialog-list">
|
||||||
|
<div v-for="item in dutyList" :key="item.dutyId" class="duty-item">
|
||||||
|
<div class="duty-item-header">
|
||||||
|
<span class="duty-name">{{ item.dutyName }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="duty-content">{{ item.dutyContent }}</div>
|
||||||
|
<div v-if="item.remark" class="duty-remark">备注:{{ item.remark }}</div>
|
||||||
|
<div class="duty-item-actions">
|
||||||
|
<el-button type="text" size="mini" icon="el-icon-edit" @click="handleUpdateDuty(item)">修改</el-button>
|
||||||
|
<el-button type="text" size="mini" icon="el-icon-delete" style="color: #F56C6C"
|
||||||
|
@click="handleDeleteDuty(item)">删除</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<el-empty v-if="!dutyLoading && dutyList.length === 0" description="暂无职责" :image-size="60" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-dialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { listPost, getPost, addPost, updatePost, delPost } from "@/api/wms/post";
|
import { listPost, getPost, addPost, updatePost, delPost } from "@/api/wms/post";
|
||||||
import { listPostDuty, getPostDuty, addPostDuty, updatePostDuty, delPostDuty } from "@/api/wms/postDuty";
|
import { listPostDuty, getPostDuty, addPostDuty, updatePostDuty, delPostDuty } from "@/api/wms/postDuty";
|
||||||
|
import * as echarts from 'echarts';
|
||||||
import Treeselect from "@riophae/vue-treeselect";
|
import Treeselect from "@riophae/vue-treeselect";
|
||||||
import "@riophae/vue-treeselect/dist/vue-treeselect.css";
|
import "@riophae/vue-treeselect/dist/vue-treeselect.css";
|
||||||
import { Network } from 'vis-network/peer/umd/vis-network.min.js';
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "WmsPost",
|
name: "WmsPost",
|
||||||
@@ -213,6 +178,9 @@ export default {
|
|||||||
dutyOpen: false,
|
dutyOpen: false,
|
||||||
dutyButtonLoading: false,
|
dutyButtonLoading: false,
|
||||||
dutyForm: {},
|
dutyForm: {},
|
||||||
|
// 职责查看弹窗
|
||||||
|
dutyDialogVisible: false,
|
||||||
|
dutyDialogTitle: "",
|
||||||
dutyRules: {
|
dutyRules: {
|
||||||
dutyName: [
|
dutyName: [
|
||||||
{ required: true, message: "职责名称不能为空", trigger: "blur" }
|
{ required: true, message: "职责名称不能为空", trigger: "blur" }
|
||||||
@@ -222,8 +190,6 @@ export default {
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
||||||
// vis.js 实例
|
|
||||||
network: null,
|
|
||||||
// 岗位类型颜色映射
|
// 岗位类型颜色映射
|
||||||
postTypeColors: {
|
postTypeColors: {
|
||||||
'PRODUCTION': '#409EFF',
|
'PRODUCTION': '#409EFF',
|
||||||
@@ -231,20 +197,19 @@ export default {
|
|||||||
'MAINTENANCE': '#E6A23C',
|
'MAINTENANCE': '#E6A23C',
|
||||||
'TECHNICAL': '#909399',
|
'TECHNICAL': '#909399',
|
||||||
'MANAGEMENT': '#F56C6C'
|
'MANAGEMENT': '#F56C6C'
|
||||||
}
|
},
|
||||||
|
// ECharts 相关
|
||||||
|
chart: null,
|
||||||
|
lastClickInfo: { nodeId: null, time: 0 }
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
created() {
|
created() {
|
||||||
this.getList();
|
this.getList();
|
||||||
},
|
},
|
||||||
beforeDestroy() {
|
beforeDestroy() {
|
||||||
if (this._resizeHandler) {
|
if (this.chart) {
|
||||||
window.removeEventListener('resize', this._resizeHandler);
|
this.chart.dispose();
|
||||||
this._resizeHandler = null;
|
this.chart = null;
|
||||||
}
|
|
||||||
if (this.network) {
|
|
||||||
this.network.destroy();
|
|
||||||
this.network = null;
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@@ -260,123 +225,229 @@ export default {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
/** 初始化 vis.js 树图 */
|
/** 初始化 ECharts 图 */
|
||||||
initChart() {
|
initChart() {
|
||||||
if (!this.$refs.treeChart) return;
|
if (!this.$refs.treeChart) return;
|
||||||
if (this.network) {
|
if (this.chart) {
|
||||||
this.network.destroy();
|
this.chart.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 构建扁平化的 nodes 和 edges
|
const chartDom = this.$refs.treeChart;
|
||||||
const nodesData = [];
|
this.chart = echarts.init(chartDom, null, { renderer: 'canvas' });
|
||||||
const edgesData = [];
|
|
||||||
this.buildVisData(this.postList, null, nodesData, edgesData);
|
|
||||||
|
|
||||||
if (nodesData.length === 0) {
|
if (this.postList.length === 0) {
|
||||||
nodesData.push({ id: 0, label: '暂无岗位', font: { color: '#909399' }, shape: 'box' });
|
this.chart.setOption({
|
||||||
|
title: {
|
||||||
|
text: '暂无岗位数据',
|
||||||
|
left: 'center',
|
||||||
|
top: 'center',
|
||||||
|
textStyle: { color: '#909399', fontSize: 14, fontWeight: 400 }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const that = this;
|
const { nodes, edges } = this.buildGraphData(this.postList);
|
||||||
const options = {
|
const { width, height } = this.calculateNodePositions(nodes);
|
||||||
layout: {
|
|
||||||
hierarchical: {
|
const option = {
|
||||||
enabled: true,
|
tooltip: {
|
||||||
direction: 'UD',
|
trigger: 'item',
|
||||||
sortMethod: 'directed',
|
triggerOn: 'mousemove',
|
||||||
levelSeparation: 100,
|
formatter: params => {
|
||||||
nodeSpacing: 150,
|
const data = params.data;
|
||||||
treeSpacing: 200
|
if (!data || !data.postId) return '';
|
||||||
|
const typeMap = { 'PRODUCTION': '生产岗', 'QUALITY': '质检岗', 'MAINTENANCE': '维修岗', 'TECHNICAL': '技术岗', 'MANAGEMENT': '管理岗' };
|
||||||
|
const typeLabel = typeMap[data.postType] || '未分类';
|
||||||
|
return `<div style="font-size:13px;font-weight:600;margin-bottom:4px">${data.name}</div>
|
||||||
|
<div style="font-size:12px;color:#909399">类型:${typeLabel}</div>`;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
physics: {
|
series: [{
|
||||||
enabled: false
|
type: 'graph',
|
||||||
|
layout: 'none',
|
||||||
|
roam: true,
|
||||||
|
draggable: false,
|
||||||
|
emphasis: {
|
||||||
|
focus: 'none',
|
||||||
|
itemStyle: {
|
||||||
|
shadowBlur: 10,
|
||||||
|
shadowColor: 'rgba(64,158,255,0.3)',
|
||||||
|
shadowOffsetY: 3
|
||||||
|
}
|
||||||
},
|
},
|
||||||
interaction: {
|
data: nodes,
|
||||||
hover: false,
|
links: edges,
|
||||||
hoverConnectedEdges: false,
|
lineStyle: {
|
||||||
selectable: false,
|
color: '#C0C4CC',
|
||||||
dragNodes: false,
|
|
||||||
zoomView: true,
|
|
||||||
dragView: true
|
|
||||||
},
|
|
||||||
edges: {
|
|
||||||
smooth: { type: 'curvedCW', roundness: 0.2 },
|
|
||||||
color: '#bbb',
|
|
||||||
width: 2,
|
width: 2,
|
||||||
arrows: { to: { enabled: true, scaleFactor: 0.5 } }
|
curveness: 0
|
||||||
},
|
},
|
||||||
nodes: {
|
animationDuration: 600,
|
||||||
shape: 'box',
|
animationEasing: 'cubicOut',
|
||||||
size: 40,
|
animationDelay: (idx) => idx * 40
|
||||||
font: {
|
}]
|
||||||
color: '#fff',
|
|
||||||
size: 12,
|
|
||||||
face: 'Microsoft YaHei',
|
|
||||||
multi: false
|
|
||||||
},
|
|
||||||
borderWidth: 0,
|
|
||||||
margin: {
|
|
||||||
top: 8,
|
|
||||||
bottom: 8,
|
|
||||||
left: 14,
|
|
||||||
right: 14
|
|
||||||
},
|
|
||||||
shadow: false
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const container = this.$refs.treeChart;
|
this.chart.setOption(option);
|
||||||
this.network = new Network(container, { nodes: nodesData, edges: edgesData }, options);
|
this.chart.resize({ width: width, height: height });
|
||||||
|
|
||||||
// 节点点击事件
|
this.chart.off('click');
|
||||||
this.network.on('click', function(params) {
|
this.chart.on('click', params => {
|
||||||
const nodeId = that.network.getNodeAt(params.pointer.DOM);
|
const data = params.data;
|
||||||
if (nodeId) {
|
if (!data || !data.postId) return;
|
||||||
const post = that.findPostById(that.postList, nodeId);
|
|
||||||
if (post) {
|
const now = Date.now();
|
||||||
that.handleTreeNodeClick(post);
|
if (this.lastClickInfo.nodeId === data.postId && now - this.lastClickInfo.time < 300) {
|
||||||
}
|
this.lastClickInfo = { nodeId: null, time: 0 };
|
||||||
|
this.handleNodeDblClick(data);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
this.lastClickInfo = { nodeId: data.postId, time: now };
|
||||||
|
this.handleTreeNodeClick(data);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 窗口大小变化自适应
|
if (this._resizeHandler) {
|
||||||
const resizeHandler = () => {
|
window.removeEventListener('resize', this._resizeHandler);
|
||||||
if (that.network) {
|
}
|
||||||
that.network.fit({ animation: false });
|
this._resizeHandler = () => {
|
||||||
|
if (this.chart) {
|
||||||
|
this.chart.resize();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
window.addEventListener('resize', resizeHandler);
|
window.addEventListener('resize', this._resizeHandler);
|
||||||
this._resizeHandler = resizeHandler;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/** 构建 vis.js 扁平化节点和边数据 */
|
/** 计算节点位置 */
|
||||||
buildVisData(nodes, parentId, nodesData, edgesData) {
|
calculateNodePositions(nodes) {
|
||||||
if (!nodes) return;
|
const depthNodes = {};
|
||||||
nodes.forEach(node => {
|
nodes.forEach(node => {
|
||||||
const color = this.postTypeColors[node.postType] || '#409EFF';
|
if (!depthNodes[node.depth]) {
|
||||||
nodesData.push({
|
depthNodes[node.depth] = [];
|
||||||
id: node.postId,
|
|
||||||
label: node.postName || '未命名',
|
|
||||||
color: { background: color, border: color }
|
|
||||||
});
|
|
||||||
if (parentId !== null) {
|
|
||||||
edgesData.push({
|
|
||||||
from: parentId,
|
|
||||||
to: node.postId
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (node.children && node.children.length > 0) {
|
|
||||||
this.buildVisData(node.children, node.postId, nodesData, edgesData);
|
|
||||||
}
|
}
|
||||||
|
depthNodes[node.depth].push(node);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const maxDepth = Math.max(...Object.keys(depthNodes).map(Number));
|
||||||
|
const maxNodesPerDepth = Math.max(...Object.values(depthNodes).map(nodes => nodes.length));
|
||||||
|
|
||||||
|
const horizontalGap = 30;
|
||||||
|
const verticalGap = 140;
|
||||||
|
|
||||||
|
const avgNodeWidth = nodes.reduce((sum, node) => sum + node.symbolSize[0], 0) / nodes.length;
|
||||||
|
const avgNodeHeight = nodes.reduce((sum, node) => sum + node.symbolSize[1], 0) / nodes.length;
|
||||||
|
|
||||||
|
const totalWidth = Math.max(1200, maxNodesPerDepth * (avgNodeWidth + horizontalGap) + 200);
|
||||||
|
const totalHeight = Math.max(800, (maxDepth + 1) * verticalGap + avgNodeHeight + 100);
|
||||||
|
const topPadding = 80;
|
||||||
|
|
||||||
|
Object.keys(depthNodes).forEach(depth => {
|
||||||
|
const depthNum = parseInt(depth);
|
||||||
|
const nodesAtDepth = depthNodes[depthNum];
|
||||||
|
const y = topPadding + depthNum * verticalGap;
|
||||||
|
|
||||||
|
const totalNodeWidth = nodesAtDepth.reduce((sum, node) => {
|
||||||
|
return sum + node.symbolSize[0];
|
||||||
|
}, 0) + (nodesAtDepth.length - 1) * horizontalGap;
|
||||||
|
|
||||||
|
const startX = Math.max(60, (totalWidth - totalNodeWidth) / 2);
|
||||||
|
|
||||||
|
let currentX = startX;
|
||||||
|
nodesAtDepth.forEach(node => {
|
||||||
|
node.x = currentX;
|
||||||
|
node.y = y;
|
||||||
|
currentX += node.symbolSize[0] + horizontalGap;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return { width: totalWidth, height: totalHeight };
|
||||||
},
|
},
|
||||||
|
|
||||||
/** 树节点点击 */
|
/** 构建 Graph 数据 */
|
||||||
|
buildGraphData(nodes, parentId = null, depth = 0) {
|
||||||
|
const nodesResult = [];
|
||||||
|
const edgesResult = [];
|
||||||
|
|
||||||
|
nodes.forEach(node => {
|
||||||
|
const bgColor = depth === 0 ? '#1A365D' : depth === 1 ? '#2C5282' : depth === 2 ? '#3182CE' : '#EBF8FF';
|
||||||
|
const labelColor = depth <= 2 ? '#FFFFFF' : '#303133';
|
||||||
|
const borderColor = depth > 2 ? '#3182CE' : 'transparent';
|
||||||
|
const borderWidth = depth > 2 ? 1 : 0;
|
||||||
|
|
||||||
|
const fontSize = depth === 0 ? 16 : depth === 1 ? 14 : depth === 2 ? 13 : 12;
|
||||||
|
const nodeHeight = depth === 0 ? 56 : depth === 1 ? 52 : depth === 2 ? 48 : 44;
|
||||||
|
const textLength = node.postName.length;
|
||||||
|
const nodeWidth = Math.max(160, textLength * fontSize * 0.65 + 50);
|
||||||
|
|
||||||
|
nodesResult.push({
|
||||||
|
id: node.postId,
|
||||||
|
name: node.postName,
|
||||||
|
postName: node.postName,
|
||||||
|
postId: node.postId,
|
||||||
|
postType: node.postType,
|
||||||
|
depth: depth,
|
||||||
|
symbol: 'rect',
|
||||||
|
symbolSize: [nodeWidth, nodeHeight],
|
||||||
|
itemStyle: {
|
||||||
|
color: bgColor,
|
||||||
|
borderColor: borderColor,
|
||||||
|
borderWidth: borderWidth,
|
||||||
|
borderRadius: 4,
|
||||||
|
shadowBlur: 8,
|
||||||
|
shadowColor: 'rgba(0, 0, 0, 0.12)',
|
||||||
|
shadowOffsetY: 2
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
show: true,
|
||||||
|
position: 'inside',
|
||||||
|
color: labelColor,
|
||||||
|
fontSize: fontSize,
|
||||||
|
fontWeight: 600,
|
||||||
|
fontFamily: 'Microsoft YaHei',
|
||||||
|
letterSpacing: 0.5
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (parentId !== null) {
|
||||||
|
edgesResult.push({
|
||||||
|
source: parentId,
|
||||||
|
target: node.postId,
|
||||||
|
lineStyle: {
|
||||||
|
color: '#C0C4CC',
|
||||||
|
width: 2,
|
||||||
|
curveness: 0
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.children && node.children.length > 0) {
|
||||||
|
const childResult = this.buildGraphData(node.children, node.postId, depth + 1);
|
||||||
|
nodesResult.push(...childResult.nodes);
|
||||||
|
edgesResult.push(...childResult.edges);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { nodes: nodesResult, edges: edgesResult };
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 树节点单击 */
|
||||||
handleTreeNodeClick(post) {
|
handleTreeNodeClick(post) {
|
||||||
this.currentPost = post;
|
this.currentPost = post;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 树节点双击 - 打开职责弹窗 */
|
||||||
|
handleNodeDblClick(post) {
|
||||||
|
this.currentPost = post;
|
||||||
|
this.dutyDialogTitle = `岗位职责 - ${post.postName}`;
|
||||||
|
this.dutyDialogVisible = true;
|
||||||
this.loadDutyList(post.postId);
|
this.loadDutyList(post.postId);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** 通过按钮查看职责 */
|
||||||
|
handleShowDuty(post) {
|
||||||
|
this.handleNodeDblClick(post);
|
||||||
|
},
|
||||||
|
|
||||||
/** 递归查找岗位 */
|
/** 递归查找岗位 */
|
||||||
findPostById(nodes, postId) {
|
findPostById(nodes, postId) {
|
||||||
for (let node of nodes) {
|
for (let node of nodes) {
|
||||||
@@ -592,40 +663,19 @@ export default {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.post-content {
|
|
||||||
margin-top: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tree-panel,
|
|
||||||
.duty-panel {
|
|
||||||
border: 1px solid #EBEEF5;
|
|
||||||
border-radius: 4px;
|
|
||||||
background: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tree-panel {
|
.tree-panel {
|
||||||
min-height: 600px;
|
min-height: calc(100vh - 140px);
|
||||||
}
|
|
||||||
|
|
||||||
.duty-panel {
|
|
||||||
min-height: 600px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-header {
|
.panel-header {
|
||||||
padding: 10px 14px;
|
padding: 12px 0;
|
||||||
border-bottom: 1px solid #EBEEF5;
|
|
||||||
background: #FAFAFA;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
flex-shrink: 0;
|
||||||
|
border-bottom: 1px solid #EBEEF5;
|
||||||
.panel-title {
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #303133;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-hint {
|
.panel-hint {
|
||||||
@@ -633,60 +683,28 @@ export default {
|
|||||||
color: #909399;
|
color: #909399;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-sub-title {
|
/* ---- ECharts 树图容器 ---- */
|
||||||
font-size: 12px;
|
|
||||||
color: #606266;
|
|
||||||
max-width: 140px;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chart-container {
|
.chart-container {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 600px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 540px;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.post-info-bar {
|
/* ---- 职责弹窗列表样式 ---- */
|
||||||
padding: 10px 14px;
|
.duty-dialog-wrap {
|
||||||
display: flex;
|
max-height: 500px;
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
border-top: 1px solid #EBEEF5;
|
|
||||||
background: #FAFAFA;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.post-info-bar .post-name {
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #303133;
|
|
||||||
margin-right: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.post-info-bar .post-actions {
|
|
||||||
margin-left: auto;
|
|
||||||
display: flex;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 自定义职责列表样式 */
|
|
||||||
.duty-list-wrap {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.duty-toolbar {
|
|
||||||
padding: 10px 14px;
|
|
||||||
border-bottom: 1px solid #EBEEF5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.duty-list {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 8px 14px 14px;
|
}
|
||||||
|
|
||||||
|
.duty-dialog-toolbar {
|
||||||
|
padding: 0 0 12px;
|
||||||
|
border-bottom: 1px solid #EBEEF5;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duty-dialog-list {
|
||||||
|
min-height: 100px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.duty-item {
|
.duty-item {
|
||||||
@@ -714,11 +732,6 @@ export default {
|
|||||||
color: #303133;
|
color: #303133;
|
||||||
}
|
}
|
||||||
|
|
||||||
.duty-sort {
|
|
||||||
font-size: 11px;
|
|
||||||
color: #909399;
|
|
||||||
}
|
|
||||||
|
|
||||||
.duty-content {
|
.duty-content {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #606266;
|
color: #606266;
|
||||||
@@ -740,24 +753,4 @@ export default {
|
|||||||
padding-top: 6px;
|
padding-top: 6px;
|
||||||
border-top: 1px dashed #EBEEF5;
|
border-top: 1px dashed #EBEEF5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-hint {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
flex: 1;
|
|
||||||
color: #C0C4CC;
|
|
||||||
min-height: 300px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-hint i {
|
|
||||||
font-size: 48px;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-hint p {
|
|
||||||
font-size: 13px;
|
|
||||||
color: #909399;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user