feat(wms/report): 新增产线报表页面,支持多维度数据统计与可视化

该页面实现了产线相关的钢卷产出、消耗、品质、异常库位等数据的统计分析,支持按日/周/月维度拆分数据,提供折线图可视化展示与明细表格查询,包含快捷时间范围选择、多条件筛选功能
This commit is contained in:
2026-06-16 11:52:22 +08:00
parent f525770094
commit a3a4986cb8

View File

@@ -0,0 +1,740 @@
<template>
<div v-loading="loading" class="app-container line-report">
<!-- 查询条件 -->
<div class="filter-area">
<el-form label-width="70px" inline class="filter-form">
<el-form-item label="产线">
<el-select v-model="actionTypes" style="width: 150px;" placeholder="产线" clearable size="small" @change="handleQuery">
<el-option label="全部" value="" />
<el-option v-for="line in lineOptions" :key="line.value" :label="line.label" :value="line.value" />
</el-select>
</el-form-item>
<el-form-item label="品质">
<muti-select
v-model="queryParams.qualityStatusCsv"
:options="dict.type.coil_quality_status"
placeholder="品质"
clearable
style="width: 150px;"
size="small"
/>
</el-form-item>
<el-form-item label="规格">
<memo-input
v-model="queryParams.itemSpecification"
style="width: 130px;"
storage-key="coilSpec"
placeholder="规格"
clearable
size="small"
@keyup.enter.native="handleQuery"
/>
</el-form-item>
<el-form-item label="材质">
<muti-select
v-model="queryParams.itemMaterial"
style="width: 150px;"
:options="dict.type.coil_material"
placeholder="材质"
clearable
size="small"
/>
</el-form-item>
<el-form-item label="厂家">
<muti-select
v-model="queryParams.itemManufacturer"
style="width: 150px;"
:options="dict.type.coil_manufacturer"
placeholder="厂家"
clearable
size="small"
/>
</el-form-item>
<el-form-item label="入场号">
<el-input
v-model="queryParams.enterCoilNo"
style="width: 150px;"
placeholder="入场钢卷号"
clearable
size="small"
@keyup.enter.native="handleQuery"
/>
</el-form-item>
</el-form>
<div class="time-row">
<span class="time-label">时间范围</span>
<el-date-picker
v-model="startTime"
style="width: 170px;"
type="datetime"
value-format="yyyy-MM-dd HH:mm:ss"
placeholder="开始"
size="small"
/>
<span class="time-sep"></span>
<el-date-picker
v-model="endTime"
style="width: 170px;"
type="datetime"
value-format="yyyy-MM-dd HH:mm:ss"
placeholder="结束"
size="small"
/>
<span class="time-label period-label">周期</span>
<el-select v-model="periodType" style="width: 90px;" size="small" @change="recalcPeriod">
<el-option label="按日" value="day" />
<el-option label="按周" value="week" />
<el-option label="按月" value="month" />
</el-select>
<el-button-group size="small" class="quick-btns">
<el-button @click="setQuickPeriod('thisMonth')">本月</el-button>
<el-button @click="setQuickPeriod('lastMonth')">上月</el-button>
<el-button @click="setQuickPeriod('thisQuarter')">本季</el-button>
<el-button @click="setQuickPeriod('thisYear')">今年</el-button>
<el-button @click="setQuickPeriod('lastYear')">去年</el-button>
</el-button-group>
<el-button type="primary" size="small" icon="el-icon-search" @click="handleQuery">查询</el-button>
</div>
</div>
<!-- 口径说明 -->
<div class="explain-bar">
<span class="explain-icon">i</span>
<span class="explain-text">
<strong>统计口径</strong>{{ periodLabel }}周期拆分统计各期产出量与消耗量
<span class="explain-divider" />
<strong>M卷定义</strong>钢卷经部分加工后剩余部分返回原料库并重新编号新钢卷号中包含字母"M""M"不在前5位的钢卷定义为M卷不计入产出统计
<span class="explain-divider" />
<strong>成品率</strong> = 产出总重 / 消耗总重 × 100%
<strong>正品率</strong> = (1 - 异常率) × 100%
<strong>损耗率</strong> = 1 - 成品率
<strong>异常率</strong> = (异常库位钢卷数 + 特殊品质状态钢卷数) / 产出总数 × 100%
</span>
</div>
<!-- 周期数据摘要 -->
<div v-if="periodData.length > 0" class="summary-bar">
<div class="summary-item">
<span class="summary-label">周期数</span>
<span class="summary-value">{{ periodData.length }}</span>
</div>
<div class="summary-item">
<span class="summary-label">产出合计</span>
<span class="summary-value">{{ totalOutCount }} / {{ totalOutWeight }}t</span>
</div>
<div class="summary-item">
<span class="summary-label">消耗合计</span>
<span class="summary-value">{{ totalLossCount }} / {{ totalLossWeight }}t</span>
</div>
</div>
<!-- 折线图区域 -->
<div class="chart-grid">
<div v-for="(cfg, idx) in chartConfigs" :key="idx" class="chart-group">
<div class="group-title">{{ cfg.title }}</div>
<div :ref="'chart_' + idx" class="chart-container" :style="{ height: cfg.height || '280px' }" />
</div>
</div>
<!-- 明细表格 -->
<div v-if="periodData.length > 0" class="detail-section">
<div class="group-title">各期明细</div>
<div class="table-wrapper">
<el-table :data="periodData" border size="small" stripe height="400px" style="width: 100%">
<el-table-column prop="periodLabel" label="周期" min-width="100" fixed />
<el-table-column label="产出统计" align="center">
<el-table-column prop="outCount" label="数量" min-width="70" />
<el-table-column prop="outTotalWeight" label="总重(t)" min-width="90" />
<el-table-column prop="outAvgWeight" label="均重(t)" min-width="90" />
</el-table-column>
<el-table-column label="消耗统计" align="center">
<el-table-column prop="lossCount" label="数量" min-width="70" />
<el-table-column prop="lossTotalWeight" label="总重(t)" min-width="90" />
<el-table-column prop="lossAvgWeight" label="均重(t)" min-width="90" />
</el-table-column>
<el-table-column label="品质比率" align="center">
<el-table-column prop="passRate" label="成品率" min-width="75" />
<el-table-column prop="passRate2" label="正品率" min-width="75" />
<el-table-column prop="lossRate" label="损耗率" min-width="75" />
<el-table-column prop="abRate" label="异常率" min-width="75" />
</el-table-column>
<el-table-column label="M卷统计" align="center">
<el-table-column prop="mOutCount" label="产出数量" min-width="70" />
<el-table-column prop="mOutTotalWeight" label="产出总重(t)" min-width="90" />
<el-table-column prop="mOutAvgWeight" label="产出均重(t)" min-width="90" />
<el-table-column prop="mLossCount" label="消耗数量" min-width="70" />
<el-table-column prop="mLossTotalWeight" label="消耗总重(t)" min-width="90" />
<el-table-column prop="mLossAvgWeight" label="消耗均重(t)" min-width="90" />
<el-table-column prop="mPassRate" label="成品率" min-width="75" />
<el-table-column prop="mLossRate" label="损耗率" min-width="75" />
<el-table-column prop="mPassRate2" label="正品率" min-width="75" />
<el-table-column prop="mAbRate" label="异常率" min-width="75" />
</el-table-column>
<el-table-column label="异常库位" align="center">
<el-table-column prop="abTechCount" label="技术部" min-width="70" />
<el-table-column prop="abMiniCount" label="小钢卷库" min-width="75" />
<el-table-column prop="abRubbishCount" label="废品库" min-width="70" />
<el-table-column prop="abReturnCount" label="退货库" min-width="70" />
<el-table-column prop="abTechRate" label="技术部占比" min-width="85" />
<el-table-column prop="abMiniRate" label="小钢卷占比" min-width="85" />
<el-table-column prop="abRubbishRate" label="废品库占比" min-width="85" />
<el-table-column prop="abReturnRate" label="退货库占比" min-width="85" />
</el-table-column>
<el-table-column label="M异常库位" align="center">
<el-table-column prop="mAbTechCount" label="技术部" min-width="70" />
<el-table-column prop="mAbMiniCount" label="小钢卷库" min-width="75" />
<el-table-column prop="mAbRubbishCount" label="废品库" min-width="70" />
<el-table-column prop="mAbReturnCount" label="退货库" min-width="70" />
<el-table-column prop="mAbTechRate" label="技术部占比" min-width="85" />
<el-table-column prop="mAbMiniRate" label="小钢卷占比" min-width="85" />
<el-table-column prop="mAbRubbishRate" label="废品库占比" min-width="85" />
<el-table-column prop="mAbReturnRate" label="退货库占比" min-width="85" />
</el-table-column>
</el-table>
</div>
</div>
</div>
</template>
<script>
import * as echarts from 'echarts'
import { listLightCoil } from '@/api/wms/coil'
import { listLightPendingAction } from '@/api/wms/pendingAction'
import MemoInput from '@/components/MemoInput'
import MutiSelect from '@/components/MutiSelect'
import { calcSummary, calcMSummary, calcAbSummary } from '@/views/wms/report/js/calc'
export default {
name: 'LineReport',
components: { MemoInput, MutiSelect },
dicts: ['coil_material', 'coil_manufacturer', 'coil_quality_status'],
data() {
const addZero = (num) => num.toString().padStart(2, '0')
const now = new Date()
const monthStart = `${now.getFullYear()}-${addZero(now.getMonth() + 1)}-01 00:00:00`
const monthEnd = `${now.getFullYear()}-${addZero(now.getMonth() + 1)}-${addZero(now.getDate())} 23:59:59`
return {
chartInstances: [],
loading: false,
startTime: monthStart,
endTime: monthEnd,
periodType: 'day',
queryParams: {
enterCoilNo: '', currentCoilNo: '', itemName: '', itemSpecification: '',
itemMaterial: '', itemManufacturer: '', qualityStatusCsv: ''
},
actionTypes: '',
lineOptions: [
{ label: '酸轧线', value: '11,120,201,520' }, { label: '镀锌线', value: '202,501,521' },
{ label: '双机架', value: '205,504,524' }, { label: '镀铬线', value: '206,505,525' },
{ label: '拉矫线', value: '204,503,523' }, { label: '脱脂线', value: '203,502,522' }
],
allOutList: [],
allLossList: [],
periodData: []
}
},
computed: {
periodLabel() {
const map = { day: '日', week: '周', month: '月' }
return map[this.periodType] || '日'
},
totalOutCount() {
return this.periodData.reduce((s, i) => s + (i.outCount || 0), 0)
},
totalOutWeight() {
return this.periodData.reduce((s, i) => s + (parseFloat(i.outTotalWeight) || 0), 0).toFixed(2)
},
totalLossCount() {
return this.periodData.reduce((s, i) => s + (i.lossCount || 0), 0)
},
totalLossWeight() {
return this.periodData.reduce((s, i) => s + (parseFloat(i.lossTotalWeight) || 0), 0).toFixed(2)
},
chartConfigs() {
return [
// ====== Row 1: 数量/总重 ======
{
title: '产出与消耗数量/总重',
series: [
{ key: 'outCount', label: '产出数量(卷)', color: '#409eff', yAxisIndex: 0 },
{ key: 'lossCount', label: '消耗数量(卷)', color: '#f56c6c', yAxisIndex: 0 },
{ key: 'outTotalWeight', label: '产出总重(t)', color: '#67c23a', yAxisIndex: 1 },
{ key: 'lossTotalWeight', label: '消耗总重(t)', color: '#909399', yAxisIndex: 1 }
],
yAxis: [
{ type: 'value', name: '数量(卷)' },
{ type: 'value', name: '重量(t)' }
],
height: '280px'
},
{
title: 'M-卷产出与消耗数量/总重',
series: [
{ key: 'mOutCount', label: 'M-产出数量(卷)', color: '#409eff', yAxisIndex: 0 },
{ key: 'mLossCount', label: 'M-消耗数量(卷)', color: '#f56c6c', yAxisIndex: 0 },
{ key: 'mOutTotalWeight', label: 'M-产出总重(t)', color: '#67c23a', yAxisIndex: 1 },
{ key: 'mLossTotalWeight', label: 'M-消耗总重(t)', color: '#909399', yAxisIndex: 1 }
],
yAxis: [
{ type: 'value', name: '数量(卷)' },
{ type: 'value', name: '重量(t)' }
],
height: '280px'
},
// ====== Row 2: 均重 ======
{
title: '产出与消耗均重',
series: [
{ key: 'outAvgWeight', label: '产出均重(t)', color: '#e6a23c' },
{ key: 'lossAvgWeight', label: '消耗均重(t)', color: '#b37feb' }
],
yAxis: [{ type: 'value', name: '均重(t)' }],
height: '280px'
},
{
title: 'M-卷均重',
series: [
{ key: 'mOutAvgWeight', label: 'M-产出均重(t)', color: '#e6a23c' },
{ key: 'mLossAvgWeight', label: 'M-消耗均重(t)', color: '#b37feb' }
],
yAxis: [{ type: 'value', name: '均重(t)' }],
height: '280px'
},
// ====== Row 3: 品质比率 ======
{
title: '品质比率',
series: [
{ key: 'passRate', label: '成品率', color: '#67c23a', percent: true },
{ key: 'passRate2', label: '正品率', color: '#409eff', percent: true },
{ key: 'lossRate', label: '损耗率', color: '#f56c6c', percent: true },
{ key: 'abRate', label: '异常率', color: '#e6a23c', percent: true }
],
yAxis: [{ type: 'value', name: '%', axisLabel: { formatter: '{value}%' }}],
height: '280px'
},
{
title: 'M-卷品质比率',
series: [
{ key: 'mPassRate', label: 'M-成品率', color: '#67c23a', percent: true },
{ key: 'mPassRate2', label: 'M-正品率', color: '#409eff', percent: true },
{ key: 'mLossRate', label: 'M-损耗率', color: '#f56c6c', percent: true },
{ key: 'mAbRate', label: 'M-异常率', color: '#e6a23c', percent: true }
],
yAxis: [{ type: 'value', name: '%', axisLabel: { formatter: '{value}%' }}],
height: '280px'
},
// ====== Row 4: 异常库位 ======
{
title: '异常库位分布(钢卷数与占比)',
series: [
{ key: 'abTechCount', label: '技术部', color: '#409eff', yAxisIndex: 0 },
{ key: 'abMiniCount', label: '小钢卷库', color: '#67c23a', yAxisIndex: 0 },
{ key: 'abRubbishCount', label: '废品库', color: '#e6a23c', yAxisIndex: 0 },
{ key: 'abReturnCount', label: '退货库', color: '#f56c6c', yAxisIndex: 0 },
{ key: 'abTechRate', label: '技术部占比', color: '#409eff', yAxisIndex: 1, percent: true, dash: true },
{ key: 'abMiniRate', label: '小钢卷占比', color: '#67c23a', yAxisIndex: 1, percent: true, dash: true },
{ key: 'abRubbishRate', label: '废品库占比', color: '#e6a23c', yAxisIndex: 1, percent: true, dash: true },
{ key: 'abReturnRate', label: '退货库占比', color: '#f56c6c', yAxisIndex: 1, percent: true, dash: true }
],
yAxis: [
{ type: 'value', name: '钢卷数' },
{ type: 'value', name: '占比(%)', axisLabel: { formatter: '{value}%' }}
],
height: '280px'
},
{
title: 'M-异常库位分布(钢卷数与占比)',
series: [
{ key: 'mAbTechCount', label: '技术部', color: '#409eff', yAxisIndex: 0 },
{ key: 'mAbMiniCount', label: '小钢卷库', color: '#67c23a', yAxisIndex: 0 },
{ key: 'mAbRubbishCount', label: '废品库', color: '#e6a23c', yAxisIndex: 0 },
{ key: 'mAbReturnCount', label: '退货库', color: '#f56c6c', yAxisIndex: 0 },
{ key: 'mAbTechRate', label: '技术部占比', color: '#409eff', yAxisIndex: 1, percent: true, dash: true },
{ key: 'mAbMiniRate', label: '小钢卷占比', color: '#67c23a', yAxisIndex: 1, percent: true, dash: true },
{ key: 'mAbRubbishRate', label: '废品库占比', color: '#e6a23c', yAxisIndex: 1, percent: true, dash: true },
{ key: 'mAbReturnRate', label: '退货库占比', color: '#f56c6c', yAxisIndex: 1, percent: true, dash: true }
],
yAxis: [
{ type: 'value', name: '钢卷数' },
{ type: 'value', name: '占比(%)', axisLabel: { formatter: '{value}%' }}
],
height: '280px'
}
]
}
},
watch: {
periodData: {
deep: true,
handler() {
this.$nextTick(() => this.renderCharts())
}
}
},
created() {
this.handleQuery()
},
mounted() {
this.$nextTick(() => this.renderCharts())
},
beforeDestroy() {
this.disposeCharts()
},
methods: {
// ====== 时间快捷选择 ======
setQuickPeriod(type) {
const now = new Date(); const addZero = n => n.toString().padStart(2, '0')
const fd = d => `${d.getFullYear()}-${addZero(d.getMonth() + 1)}-${addZero(d.getDate())}`
switch (type) {
case 'thisMonth':
this.startTime = `${fd(new Date(now.getFullYear(), now.getMonth(), 1))} 00:00:00`
this.endTime = `${fd(now)} 23:59:59`
this.periodType = 'day'
break
case 'lastMonth': {
const lm = new Date(now.getFullYear(), now.getMonth() - 1, 1)
const lme = new Date(now.getFullYear(), now.getMonth(), 0)
this.startTime = `${fd(lm)} 00:00:00`
this.endTime = `${fd(lme)} 23:59:59`
this.periodType = 'day'
break
}
case 'thisQuarter': {
const qs = Math.floor(now.getMonth() / 3) * 3
this.startTime = `${fd(new Date(now.getFullYear(), qs, 1))} 00:00:00`
this.endTime = `${fd(now)} 23:59:59`
this.periodType = 'month'
break
}
case 'thisYear':
this.startTime = `${now.getFullYear()}-01-01 00:00:00`
this.endTime = `${fd(now)} 23:59:59`
this.periodType = 'month'
break
case 'lastYear':
this.startTime = `${now.getFullYear() - 1}-01-01 00:00:00`
this.endTime = `${now.getFullYear() - 1}-12-31 23:59:59`
this.periodType = 'month'
break
}
this.handleQuery()
},
// ====== 数据获取 ======
handleQuery() {
this.fetchData()
},
// 切换周期类型时仅前端重新汇总,无需重新请求接口
recalcPeriod() {
if (this.allOutList.length === 0 && this.allLossList.length === 0) return
this.disposeCharts()
this.chartInstances = []
this.splitByPeriod()
this.$nextTick(() => this.renderCharts())
},
async fetchData() {
this.loading = true
this.disposeCharts()
this.chartInstances = []
this.periodData = []
try {
const baseQuery = {
enterCoilNo: this.queryParams.enterCoilNo,
currentCoilNo: this.queryParams.currentCoilNo,
itemName: this.queryParams.itemName,
itemSpecification: this.queryParams.itemSpecification,
itemMaterial: this.queryParams.itemMaterial,
itemManufacturer: this.queryParams.itemManufacturer,
qualityStatusCsv: this.queryParams.qualityStatusCsv
}
// 1. 获取整个时间范围内的所有待处理操作
const actionRes = await listLightPendingAction({
...baseQuery,
startTime: this.startTime,
endTime: this.endTime,
actionTypes: this.actionTypes,
actionStatus: 2
})
const actions = actionRes.data || []
if (actions.length === 0) {
this.loading = false
return
}
// 2. 提取产出和消耗的钢卷ID
const outIds = actions.map(i => i.processedCoilIds).filter(Boolean).join(',')
const lossIds = actions.filter(i => i.coilId).map(i => i.coilId).join(',')
if (!outIds) {
this.loading = false
return
}
// 3. 批量获取产出和消耗钢卷数据
const mapItems = list => (list || []).map(item => {
const [th, w] = item.specification?.split('*') || []
return { ...item, computedThickness: parseFloat(th), computedWidth: parseFloat(w) }
})
const [outRes, lossRes] = await Promise.all([
listLightCoil({
...baseQuery, coilIds: outIds, startTime: '', endTime: '',
selectType: 'product', pageSize: 99999, pageNum: 1
}),
lossIds ? listLightCoil({
...baseQuery, coilIds: lossIds, startTime: '', endTime: '',
selectType: 'raw_material', pageSize: 99999, pageNum: 1
}) : Promise.resolve({ data: [] })
])
this.allOutList = mapItems(outRes)
this.allLossList = mapItems(lossRes)
// 4. 按周期拆分数据
this.splitByPeriod()
// 5. 渲染图表
this.$nextTick(() => this.renderCharts())
} catch (e) {
console.error('数据获取失败:', e)
this.$message.error('数据获取失败')
}
this.loading = false
},
// ====== 周期拆分 ======
splitByPeriod() {
const periods = this.generatePeriods()
this.periodData = periods.map(p => {
const st = p.start.getTime(); const et = p.end.getTime()
const outList = this.allOutList.filter(c => {
const t = new Date(c.createTime || c.createDate).getTime()
return t >= st && t <= et
})
const lossList = this.allLossList.filter(c => {
const t = new Date(c.createTime || c.createDate).getTime()
return t >= st && t <= et
})
const summary = calcSummary(outList, lossList)
const mSummary = calcMSummary(outList, lossList)
const abSummary = calcAbSummary(outList)
const abMap = {}
abSummary.forEach(i => { abMap[i.label] = i.value })
// M-异常库位处理M后的非M卷产出
const isMcoil = c => c.currentCoilNo && c.currentCoilNo.includes('M') && c.currentCoilNo.indexOf('M') > 4
const nonMOut = outList.filter(c => !isMcoil(c))
const mAbSummary = calcAbSummary(nonMOut)
const mAbMap = {}
mAbSummary.forEach(i => { mAbMap[i.label] = i.value })
return {
periodLabel: p.label,
// 基础统计
...summary,
// M卷统计
mOutCount: mSummary.outCount,
mOutTotalWeight: mSummary.outTotalWeight,
mLossCount: mSummary.lossCount,
mLossTotalWeight: mSummary.lossTotalWeight,
mOutAvgWeight: mSummary.outAvgWeight,
mLossAvgWeight: mSummary.lossAvgWeight,
mPassRate: mSummary.passRate,
mLossRate: mSummary.lossRate,
mPassRate2: mSummary.passRate2,
mAbRate: mSummary.abRate,
// 异常库位统计
abTechCount: abMap['技术部钢卷数'] || 0,
abMiniCount: abMap['小钢卷库钢卷数'] || 0,
abRubbishCount: abMap['废品库钢卷数'] || 0,
abReturnCount: abMap['退货库钢卷数'] || 0,
abTechRate: abMap['技术部占比'] || '0.00%',
abMiniRate: abMap['小钢卷库占比'] || '0.00%',
abRubbishRate: abMap['废品库占比'] || '0.00%',
abReturnRate: abMap['退货库占比'] || '0.00%',
// M异常库位统计
mAbTechCount: mAbMap['技术部钢卷数'] || 0,
mAbMiniCount: mAbMap['小钢卷库钢卷数'] || 0,
mAbRubbishCount: mAbMap['废品库钢卷数'] || 0,
mAbReturnCount: mAbMap['退货库钢卷数'] || 0,
mAbTechRate: mAbMap['技术部占比'] || '0.00%',
mAbMiniRate: mAbMap['小钢卷库占比'] || '0.00%',
mAbRubbishRate: mAbMap['废品库占比'] || '0.00%',
mAbReturnRate: mAbMap['退货库占比'] || '0.00%'
}
})
},
generatePeriods() {
const periods = []
const start = new Date(this.startTime)
const end = new Date(this.endTime)
if (this.periodType === 'day') {
const endMs = end.getTime()
const cur = new Date(start)
while (cur.getTime() <= endMs) {
const dayEnd = new Date(cur)
dayEnd.setHours(23, 59, 59, 999)
periods.push({
label: `${cur.getFullYear()}-${String(cur.getMonth() + 1).padStart(2, '0')}-${String(cur.getDate()).padStart(2, '0')}`,
start: new Date(cur),
end: new Date(Math.min(dayEnd.getTime(), endMs))
})
cur.setDate(cur.getDate() + 1)
cur.setHours(0, 0, 0, 0)
}
} else if (this.periodType === 'week') {
const endMs = end.getTime()
const cur = new Date(start)
const day = cur.getDay()
const diff = day === 0 ? -6 : 1 - day
cur.setDate(cur.getDate() + diff)
cur.setHours(0, 0, 0, 0)
while (cur.getTime() <= endMs) {
const weekEnd = new Date(cur)
weekEnd.setDate(weekEnd.getDate() + 6)
weekEnd.setHours(23, 59, 59, 999)
periods.push({
label: `${cur.getFullYear()}-W${this.getWeekNumber(cur)}`,
start: new Date(Math.max(cur.getTime(), start.getTime())),
end: new Date(Math.min(weekEnd.getTime(), endMs))
})
cur.setDate(cur.getDate() + 7)
}
} else if (this.periodType === 'month') {
const endMs = end.getTime()
const cur = new Date(start.getFullYear(), start.getMonth(), 1)
while (cur.getTime() <= endMs) {
const monthEnd = new Date(cur.getFullYear(), cur.getMonth() + 1, 0, 23, 59, 59, 999)
periods.push({
label: `${cur.getFullYear()}-${String(cur.getMonth() + 1).padStart(2, '0')}`,
start: new Date(Math.max(cur.getTime(), start.getTime())),
end: new Date(Math.min(monthEnd.getTime(), endMs))
})
cur.setMonth(cur.getMonth() + 1)
}
}
return periods
},
getWeekNumber(d) {
const temp = new Date(d.getFullYear(), d.getMonth(), d.getDate())
temp.setDate(temp.getDate() + 3 - (temp.getDay() + 6) % 7)
const week1 = new Date(temp.getFullYear(), 0, 4)
return Math.round(((temp - week1) / 86400000 + week1.getDay() + 1) / 7)
},
// ====== 图表渲染 ======
renderCharts() {
this.disposeCharts()
if (this.periodData.length === 0) return
const xLabels = this.periodData.map(p => p.periodLabel)
this.chartConfigs.forEach((cfg, idx) => {
const dom = this.$refs['chart_' + idx]
if (!dom || !dom[0]) return
const chart = echarts.init(dom[0])
this.chartInstances.push(chart)
const yAxis = cfg.yAxis || [{ type: 'value' }]
const series = cfg.series.map(s => ({
name: s.label,
type: 'line',
smooth: true,
symbol: s.dash ? 'diamond' : 'circle',
symbolSize: 6,
lineStyle: { width: 2, color: s.color, type: s.dash ? 'dashed' : 'solid' },
itemStyle: { color: s.color },
yAxisIndex: s.yAxisIndex || 0,
data: this.periodData.map(p => {
const raw = p[s.key]
if (s.percent) {
return parseFloat(raw || '0') || 0
}
return raw !== undefined && raw !== null ? parseFloat(raw) || raw : 0
})
}))
const option = {
tooltip: {
trigger: 'axis',
formatter: function(params) {
let html = params[0].axisValue + '<br/>'
params.forEach(p => {
const val = typeof p.value === 'number' ? p.value.toFixed(2) : p.value
html += p.marker + ' ' + p.seriesName + '' + val + '<br/>'
})
return html
}
},
legend: { data: cfg.series.map(s => s.label), top: 5 },
grid: { left: '3%', right: '4%', bottom: '15%', containLabel: true },
xAxis: { type: 'category', data: xLabels, axisLabel: { rotate: xLabels.length > 15 ? 45 : 0, fontSize: 11 }},
yAxis: yAxis,
dataZoom: [
{ type: 'inside', start: 0, end: 100 },
{ type: 'slider', start: 0, end: 100, height: 20, bottom: 5 }
],
series: series
}
chart.setOption(option)
const handler = () => chart.resize()
window.addEventListener('resize', handler)
chart._resizeHandler = handler
})
},
disposeCharts() {
this.chartInstances.forEach(chart => {
if (chart._resizeHandler) {
window.removeEventListener('resize', chart._resizeHandler)
}
chart.dispose()
})
this.chartInstances = []
}
}
}
</script>
<style scoped>
.line-report { min-width: 900px; }
.filter-area { background: #fff; padding: 8px 12px; margin-bottom: 8px; border-bottom: 1px solid #e4e7ed; }
.filter-form { margin-bottom: 0; }
.filter-form .el-form-item { margin-bottom: 6px; margin-right: 4px; }
.filter-form >>> .el-form-item__label { font-size: 12px; }
.time-row { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; padding-top: 6px; border-top: 1px solid #ebeef5; }
.time-label { font-size: 12px; color: #909399; font-weight: 600; white-space: nowrap; }
.period-label { color: #409eff; margin-left: 4px; }
.time-sep { color: #c0c4cc; font-size: 12px; }
.quick-btns { margin-left: auto; }
.explain-bar { display: flex; align-items: flex-start; gap: 6px; padding: 6px 12px; margin-bottom: 8px; background: #f0f9ff; border-radius: 4px; font-size: 12px; color: #606266; line-height: 1.6; }
.explain-icon { display: inline-flex; align-items: center; justify-content: center; width: 16px; height: 16px; min-width: 16px; border-radius: 50%; background: #909399; color: #fff; font-size: 11px; font-weight: 700; font-style: normal; margin-top: 2px; }
.explain-text { flex: 1; }
.explain-divider { display: inline-block; width: 0; height: 0; margin: 0 2px; border-left: 1px solid #c0c4cc; }
.summary-bar { display: flex; gap: 16px; padding: 10px 12px; background: #f5f7fa; border-radius: 4px; margin-bottom: 10px; flex-wrap: wrap; }
.summary-item { display: flex; flex-direction: column; align-items: center; min-width: 100px; }
.summary-label { font-size: 12px; color: #909399; white-space: nowrap; }
.summary-value { font-size: 16px; font-weight: 600; color: #303133; margin-top: 2px; white-space: nowrap; }
.chart-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.group-title { font-size: 13px; font-weight: 600; color: #303133; padding: 5px 0; margin-bottom: 6px; border-bottom: 2px solid #409eff; }
.chart-container { width: 100%; background: #fff; border: 1px solid #ebeef5; border-radius: 4px; }
.detail-section { margin-top: 8px; }
.table-wrapper { overflow-x: auto; }
.detail-section >>> .el-table th { background-color: #f5f7fa; font-size: 12px; }
.detail-section >>> .el-table td { font-size: 12px; }
</style>