|
|
@@ -0,0 +1,1741 @@
|
|
|
+<template>
|
|
|
+ <div class="qb-page">
|
|
|
+ <!-- 头部 -->
|
|
|
+ <div class="qb-header">
|
|
|
+ <div class="qb-title">
|
|
|
+ <span class="qb-title-main">质量管理看板</span>
|
|
|
+ </div>
|
|
|
+ <div class="qb-header-right">
|
|
|
+ <!-- 工厂维度:除“工厂合格率”外的所有图表、KPI、明细弹窗都按此工厂筛选 -->
|
|
|
+ <el-select
|
|
|
+ v-model="factoryId"
|
|
|
+ class="qb-factory"
|
|
|
+ size="mini"
|
|
|
+ placeholder="全部工厂"
|
|
|
+ clearable
|
|
|
+ popper-class="qb-factory-popper"
|
|
|
+ @change="changeFactory"
|
|
|
+ >
|
|
|
+ <el-option v-for="item in factoryList" :key="item.id" :label="item.name" :value="item.id" />
|
|
|
+ </el-select>
|
|
|
+ <div class="qb-range">
|
|
|
+ <span
|
|
|
+ v-for="item in rangeList"
|
|
|
+ :key="item.value"
|
|
|
+ class="qb-range-item"
|
|
|
+ :class="{ active: timeType === item.value }"
|
|
|
+ @click="changeRange(item.value)"
|
|
|
+ >{{ item.label }}</span
|
|
|
+ >
|
|
|
+ </div>
|
|
|
+ <i class="el-icon-refresh qb-refresh" :class="{ loading: loading }" @click="fetchData"></i>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- KPI -->
|
|
|
+ <div class="qb-kpi-row">
|
|
|
+ <div
|
|
|
+ v-for="item in kpiList"
|
|
|
+ :key="item.key"
|
|
|
+ class="qb-kpi"
|
|
|
+ :title="item.tip || '点击查看明细'"
|
|
|
+ @click="openKpiDrill(item.key)"
|
|
|
+ >
|
|
|
+ <div class="qb-kpi-label">{{ item.label }}</div>
|
|
|
+ <div class="qb-kpi-body">
|
|
|
+ <div class="qb-kpi-value">
|
|
|
+ {{ item.value }}<span class="qb-kpi-unit">{{ item.unit }}</span>
|
|
|
+ </div>
|
|
|
+ <div class="qb-kpi-ring" :title="item.ringTip">
|
|
|
+ <svg viewBox="0 0 36 36">
|
|
|
+ <!-- r=15.9155 => 周长 100,dasharray 直接用百分比,无需 pathLength(部分浏览器不支持) -->
|
|
|
+ <circle class="qb-ring-track" cx="18" cy="18" r="15.9155" />
|
|
|
+ <circle
|
|
|
+ class="qb-ring-bar"
|
|
|
+ cx="18"
|
|
|
+ cy="18"
|
|
|
+ r="15.9155"
|
|
|
+ :stroke="item.ringColor"
|
|
|
+ :stroke-dasharray="item.ringPercent + ' ' + (100 - item.ringPercent)"
|
|
|
+ transform="rotate(-90 18 18)"
|
|
|
+ />
|
|
|
+ </svg>
|
|
|
+ <span class="qb-ring-text">{{ item.ringPercent }}<i>%</i></span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="qb-kpi-foot">
|
|
|
+ <span class="qb-kpi-foot-text">{{ item.foot }}</span>
|
|
|
+ <span class="qb-kpi-more">明细 ›</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 行2:合格率三视图(工厂 / 班组 / 产线),时间维度统一跟随页面顶部 本月 / 本季度 / 本年 -->
|
|
|
+ <div class="qb-row">
|
|
|
+ <div class="qb-card qb-col-4">
|
|
|
+ <div class="qb-card-head">
|
|
|
+ <span title="合格率 = 合格数量 ÷ 检验数量 × 100%">工厂合格率</span>
|
|
|
+ <span class="qb-tip">{{ rateRangeLabel }}</span>
|
|
|
+ </div>
|
|
|
+ <v-chart v-if="factoryRateOption" class="qb-chart" :option="factoryRateOption" autoresize />
|
|
|
+ <div v-else class="qb-empty">暂无数据</div>
|
|
|
+ </div>
|
|
|
+ <div class="qb-card qb-col-4">
|
|
|
+ <div class="qb-card-head">
|
|
|
+ <span title="合格率 = 合格数量 ÷ 检验数量 × 100%">班组合格率</span>
|
|
|
+ <span class="qb-tip">{{ rateRangeLabel }}</span>
|
|
|
+ </div>
|
|
|
+ <v-chart v-if="teamRateOption" class="qb-chart" :option="teamRateOption" autoresize />
|
|
|
+ <div v-else class="qb-empty">暂无数据</div>
|
|
|
+ </div>
|
|
|
+ <div class="qb-card qb-col-4">
|
|
|
+ <div class="qb-card-head">
|
|
|
+ <span title="合格率 = 合格数量 ÷ 检验数量 × 100%">产线合格率</span>
|
|
|
+ <span class="qb-tip">{{ rateRangeLabel }}</span>
|
|
|
+ </div>
|
|
|
+ <v-chart v-if="lineRateOption" class="qb-chart" :option="lineRateOption" autoresize />
|
|
|
+ <div v-else class="qb-empty">暂无数据</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 行3 -->
|
|
|
+ <div class="qb-row">
|
|
|
+ <div class="qb-card qb-col-3">
|
|
|
+ <div class="qb-card-head">
|
|
|
+ <span title="完成率 = 该类已完成批次数 ÷ 该类计划批次数 × 100%">三类检验分布</span>
|
|
|
+ <span class="qb-link" @click="openKpiDrill('completion')">明细</span>
|
|
|
+ </div>
|
|
|
+ <v-chart
|
|
|
+ v-if="typeOption"
|
|
|
+ class="qb-chart qb-chart-type"
|
|
|
+ :option="typeOption"
|
|
|
+ autoresize
|
|
|
+ @click="handleTypeDrill"
|
|
|
+ />
|
|
|
+ <div v-else class="qb-empty qb-empty-type">暂无数据</div>
|
|
|
+ <div class="qb-type-foot">
|
|
|
+ <span class="qb-type-foot-item"
|
|
|
+ >累计检验批次 <i>{{ typeSummary.doneBatch }}</i> 批</span
|
|
|
+ >
|
|
|
+ <span class="qb-type-foot-split"></span>
|
|
|
+ <span class="qb-type-foot-item">合格率 <i>{{ typeSummary.qualificationRate }}%</i></span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="qb-card qb-col-4">
|
|
|
+ <div class="qb-card-head">
|
|
|
+ <span>合格率 / 不良率趋势</span>
|
|
|
+ <span class="qb-tip">合格率按批次,不良率按数量</span>
|
|
|
+ </div>
|
|
|
+ <v-chart v-if="trendOption" class="qb-chart" :option="trendOption" autoresize />
|
|
|
+ <div v-else class="qb-empty">暂无数据</div>
|
|
|
+ </div>
|
|
|
+ <div class="qb-card qb-col-4">
|
|
|
+ <div class="qb-card-head">
|
|
|
+ <span title="不良类型占比 = 该类型不合格数量 ÷ 各类不合格数量合计">不良类型占比</span>
|
|
|
+ <span class="qb-link" @click="openKpiDrill('defect')">明细</span>
|
|
|
+ </div>
|
|
|
+ <div v-if="defectOption" class="qb-pie-wrap">
|
|
|
+ <v-chart class="qb-chart" :option="defectOption" autoresize @click="handleDefectTypeDrill" />
|
|
|
+ <!-- 中心合计用覆盖层实现,不依赖 echarts graphic 组件,窄卡片下也不会错位 -->
|
|
|
+ <div class="qb-pie-center">
|
|
|
+ <span class="qb-pie-center-label">不良次数</span>
|
|
|
+ <span class="qb-pie-center-value">{{ defectTotal }}</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div v-else class="qb-empty">暂无数据</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 行4 -->
|
|
|
+ <div class="qb-row">
|
|
|
+ <div class="qb-card qb-col-4">
|
|
|
+ <div class="qb-card-head">
|
|
|
+ <span>记录方法分布</span>
|
|
|
+ <span class="qb-tip">占比 = 该方式次数 ÷ 总次数</span>
|
|
|
+ </div>
|
|
|
+ <v-chart v-if="methodOption" class="qb-chart" :option="methodOption" autoresize @click="handleMethodDrill" />
|
|
|
+ <div v-else class="qb-empty">暂无数据</div>
|
|
|
+ </div>
|
|
|
+ <div class="qb-card qb-col-6">
|
|
|
+ <div class="qb-card-head">
|
|
|
+ <span>检验流程状态</span>
|
|
|
+ <span class="qb-tip">节点占比 = 节点批次数 ÷ 检验总批次</span>
|
|
|
+ </div>
|
|
|
+ <div class="qb-flow">
|
|
|
+ <div v-for="(node, index) in flowStatus" :key="node.code" class="qb-flow-item">
|
|
|
+ <div class="qb-flow-count">{{ node.value || 0 }}</div>
|
|
|
+ <div class="qb-flow-name">{{ node.name }}</div>
|
|
|
+ <div class="qb-flow-rate">{{ node.rate || 0 }}%</div>
|
|
|
+ <i v-if="index < flowStatus.length - 1" class="el-icon-arrow-right qb-flow-arrow"></i>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <!-- 总体完成率进度条:(已完成 + 已出证) 批次 ÷ 检验批次总数 -->
|
|
|
+ <div class="qb-progress">
|
|
|
+ <div class="qb-progress-head">
|
|
|
+ <span>总体完成率</span>
|
|
|
+ <span class="qb-progress-value">{{ overallRate }}<i>%</i></span>
|
|
|
+ </div>
|
|
|
+ <div class="qb-progress-track">
|
|
|
+ <div class="qb-progress-bar" :style="{ width: overallRate + '%' }"></div>
|
|
|
+ </div>
|
|
|
+ <div class="qb-progress-foot">
|
|
|
+ 已完成 {{ kpi.completedBatches || 0 }} / 应检 {{ kpi.batchTotal || 0 }} 批
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 行5 -->
|
|
|
+ <div class="qb-row">
|
|
|
+ <div class="qb-card qb-col-4">
|
|
|
+ <div class="qb-card-head">
|
|
|
+ <span>供应商来料质量排名</span>
|
|
|
+ <span class="qb-link" @click="openKpiDrill('completion', { qualityType: 1 })">明细</span>
|
|
|
+ </div>
|
|
|
+ <div class="qb-supplier">
|
|
|
+ <div
|
|
|
+ v-for="(item, index) in supplierRank"
|
|
|
+ :key="item.supplierId"
|
|
|
+ class="qb-supplier-item"
|
|
|
+ title="点击查看该供应商来料检验明细"
|
|
|
+ @click="handleSupplierDrill(item)"
|
|
|
+ >
|
|
|
+ <span class="qb-supplier-index" :class="'top' + (index + 1)">TOP{{ index + 1 }}</span>
|
|
|
+ <span class="qb-supplier-name">{{ item.supplierName }}</span>
|
|
|
+ <span class="qb-supplier-bar">
|
|
|
+ <i :style="{ width: (item.qualificationRate || 0) + '%' }"></i>
|
|
|
+ </span>
|
|
|
+ <span class="qb-supplier-rate">{{ item.qualificationRate || 0 }}%</span>
|
|
|
+ </div>
|
|
|
+ <div v-if="!supplierRank.length" class="qb-empty">暂无数据</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="qb-card qb-col-3">
|
|
|
+ <div class="qb-card-head">
|
|
|
+ <span>不良品处置</span>
|
|
|
+ <span class="qb-tip">已处置 {{ disposalDone }} / {{ disposalBase }} 条</span>
|
|
|
+ </div>
|
|
|
+ <div class="qb-disposal">
|
|
|
+ <!-- 左:环形进度(已处置 vs 待处置),中心显示处置率百分比 -->
|
|
|
+ <div class="qb-disposal-donut">
|
|
|
+ <v-chart
|
|
|
+ v-if="disposalOption"
|
|
|
+ class="qb-chart qb-chart-donut"
|
|
|
+ :option="disposalOption"
|
|
|
+ autoresize
|
|
|
+ @click="handleDisposalDonut"
|
|
|
+ />
|
|
|
+ <div v-else class="qb-empty qb-empty-donut">暂无数据</div>
|
|
|
+ <div v-if="disposalOption" class="qb-disposal-center">
|
|
|
+ <span class="qb-disposal-center-value">{{ disposalRate }}<i>%</i></span>
|
|
|
+ <span class="qb-disposal-center-label">不良品处置率</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <!-- 右:各处置方式横向进度条,条上显示统计数量 + 占比 -->
|
|
|
+ <div class="qb-disposal-list">
|
|
|
+ <div
|
|
|
+ v-for="item in disposalList"
|
|
|
+ :key="item.code"
|
|
|
+ class="qb-disposal-row"
|
|
|
+ :title="`${item.tip}(点击查看不良明细)`"
|
|
|
+ @click="handleDisposalDrill(item)"
|
|
|
+ >
|
|
|
+ <span class="qb-disposal-name">{{ item.name }}</span>
|
|
|
+ <span class="qb-disposal-count">{{ item.value }} 件</span>
|
|
|
+ <span class="qb-disposal-track">
|
|
|
+ <i :style="{ width: item.rate + '%', background: item.color }"></i>
|
|
|
+ </span>
|
|
|
+ <span class="qb-disposal-rate">{{ item.rate }}%</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="qb-card qb-col-3">
|
|
|
+ <div class="qb-card-head">
|
|
|
+ <span>质量预警</span>
|
|
|
+ <span class="qb-tip">消息推送记录</span>
|
|
|
+ </div>
|
|
|
+ <div class="qb-alert">
|
|
|
+ <div v-for="item in alertList" :key="item.id" class="qb-alert-item">
|
|
|
+ <span class="qb-alert-level" :class="'lv' + (item.alertLevel || 0)">{{ item.alertLevelName }}</span>
|
|
|
+ <span class="qb-alert-content" :title="item.content">{{ item.content }}</span>
|
|
|
+ <span class="qb-alert-time">{{ item.createTime }}</span>
|
|
|
+ </div>
|
|
|
+ <div v-if="!alertList.length" class="qb-empty">暂无预警</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- KPI 明细弹窗:纯前端弹窗,不跳转列表页 -->
|
|
|
+ <DrillDialog
|
|
|
+ :visible.sync="drillVisible"
|
|
|
+ :drill-key="drillKey"
|
|
|
+ :time-range="drillRange"
|
|
|
+ :extra-where="drillExtra"
|
|
|
+ :factory-name="factoryName"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script>
|
|
|
+import {
|
|
|
+ getOverview,
|
|
|
+ getFactoryRate,
|
|
|
+ getTeamRate,
|
|
|
+ getProductionLineRate,
|
|
|
+ getDefectTypeProportion
|
|
|
+} from '@/api/qmsDashboard';
|
|
|
+import { getFactoryList, getCurrentUserFactoryId } from '@/api/aps';
|
|
|
+import DrillDialog from './components/DrillDialog';
|
|
|
+import { getDefaultRange, DRILL_CONFIG } from './components/drillConfig';
|
|
|
+import { use } from 'echarts/core';
|
|
|
+import { CanvasRenderer } from 'echarts/renderers';
|
|
|
+import { BarChart, PieChart, LineChart } from 'echarts/charts';
|
|
|
+import { GridComponent, TooltipComponent, LegendComponent } from 'echarts/components';
|
|
|
+import VChart from 'vue-echarts';
|
|
|
+
|
|
|
+use([CanvasRenderer, BarChart, PieChart, LineChart, GridComponent, TooltipComponent, LegendComponent]);
|
|
|
+
|
|
|
+const AXIS_COLOR = '#8aa0bd';
|
|
|
+const SPLIT_COLOR = 'rgba(140,160,190,0.15)';
|
|
|
+const PALETTE = ['#3ba7ff', '#25d0b0', '#ffb547', '#ff6b81', '#9b7bff', '#41d1a7', '#ff9f43', '#5f8bff'];
|
|
|
+
|
|
|
+export default {
|
|
|
+ components: { VChart, DrillDialog },
|
|
|
+ data() {
|
|
|
+ return {
|
|
|
+ loading: false,
|
|
|
+ timeType: 1,
|
|
|
+ /* 工厂维度:'' 表示全部工厂;工厂 id 为雪花 id,统一用字符串避免精度丢失 */
|
|
|
+ factoryId: '',
|
|
|
+ factoryList: [],
|
|
|
+ drillVisible: false,
|
|
|
+ drillKey: '',
|
|
|
+ drillExtra: {},
|
|
|
+ rangeList: [
|
|
|
+ { label: '本月', value: 1 },
|
|
|
+ { label: '本季度', value: 2 },
|
|
|
+ { label: '本年', value: 3 }
|
|
|
+ ],
|
|
|
+ kpi: {},
|
|
|
+ qualityTypeDist: [],
|
|
|
+ trend: [],
|
|
|
+ /* 不良类型占比:来自质量首页同款接口,字段 { name, value } */
|
|
|
+ defectTypeProportion: [],
|
|
|
+ recordingMethodDist: [],
|
|
|
+ flowStatus: [],
|
|
|
+ supplierRank: [],
|
|
|
+ disposalDist: [],
|
|
|
+ alertList: [],
|
|
|
+ /* 工厂 / 班组 / 产线合格率:时间维度统一跟随页面顶部 timeType,不再各自维护 */
|
|
|
+ factoryRateList: [],
|
|
|
+ teamRateList: [],
|
|
|
+ lineRateList: []
|
|
|
+ };
|
|
|
+ },
|
|
|
+ computed: {
|
|
|
+ /**
|
|
|
+ * 总体完成率(%):后端 overallCompletionRate = (已完成 + 已出证) 批次 / 检验批次总数
|
|
|
+ * 统一走 toRing 限幅 0~100,避免脏数据把进度条拉出轨道
|
|
|
+ */
|
|
|
+ overallRate() {
|
|
|
+ return this.toRing(this.kpi && this.kpi.overallCompletionRate);
|
|
|
+ },
|
|
|
+ /** 明细弹窗默认时间区间:跟随看板 本月/本季度/本年 切换 */
|
|
|
+ drillRange() {
|
|
|
+ return getDefaultRange(this.timeType);
|
|
|
+ },
|
|
|
+ /** 三张合格率卡片右上角提示:与页面顶部时间维度保持一致的文案 */
|
|
|
+ rateRangeLabel() {
|
|
|
+ const hit = (this.rangeList || []).find((item) => item.value === this.timeType);
|
|
|
+ return hit ? hit.label : '';
|
|
|
+ },
|
|
|
+ /** 当前选中的工厂名称,明细弹窗头部展示用;未选工厂时为空 */
|
|
|
+ factoryName() {
|
|
|
+ const hit = (this.factoryList || []).find((item) => item.id === this.factoryId);
|
|
|
+ return hit ? hit.name : '';
|
|
|
+ },
|
|
|
+ kpiList() {
|
|
|
+ const kpi = this.kpi || {};
|
|
|
+ /* 待处理异常没有现成比例,用 待处理 / 不良品总数 作为环形进度 */
|
|
|
+ const pendingPercent = kpi.unqualifiedTotal
|
|
|
+ ? this.toRing((Number(kpi.pendingException || 0) * 100) / Number(kpi.unqualifiedTotal))
|
|
|
+ : 0;
|
|
|
+ return [
|
|
|
+ {
|
|
|
+ key: 'completion',
|
|
|
+ label: '检验完成率',
|
|
|
+ value: kpi.inspectionCompletionRate || 0,
|
|
|
+ unit: '%',
|
|
|
+ foot: `已完成 ${kpi.batchDone || 0} / 应检 ${kpi.batchTotal || 0} 批`,
|
|
|
+ tip: '口径:分母取已生成的检验工单批次数(方案A),接入 MES 应检计划后自动切换',
|
|
|
+ ringPercent: this.toRing(kpi.inspectionCompletionRate),
|
|
|
+ ringColor: '#3ba7ff',
|
|
|
+ ringTip: `检验完成率 ${this.toRing(kpi.inspectionCompletionRate)}%`
|
|
|
+ },
|
|
|
+ {
|
|
|
+ key: 'firstPass',
|
|
|
+ label: '一次通过率',
|
|
|
+ value: kpi.firstPassRate || 0,
|
|
|
+ unit: '%',
|
|
|
+ foot: `一次通过 ${kpi.firstPassBatches || 0} 批`,
|
|
|
+ ringPercent: this.toRing(kpi.firstPassRate),
|
|
|
+ ringColor: '#25d0b0',
|
|
|
+ ringTip: `一次通过率 ${this.toRing(kpi.firstPassRate)}%`
|
|
|
+ },
|
|
|
+ {
|
|
|
+ key: 'defect',
|
|
|
+ label: '不良率',
|
|
|
+ value: kpi.defectRate || 0,
|
|
|
+ unit: '%',
|
|
|
+ foot: `不合格 ${kpi.noQualifiedTotal || 0} / 检验 ${kpi.inspectTotal || 0}`,
|
|
|
+ ringPercent: this.toRing(kpi.defectRate),
|
|
|
+ ringColor: '#ff6b81',
|
|
|
+ ringTip: `不良率 ${this.toRing(kpi.defectRate)}%`
|
|
|
+ },
|
|
|
+ {
|
|
|
+ key: 'pending',
|
|
|
+ label: '待处理异常',
|
|
|
+ value: kpi.pendingException || 0,
|
|
|
+ unit: '条',
|
|
|
+ foot: '不良品台账待处置',
|
|
|
+ ringPercent: pendingPercent,
|
|
|
+ ringColor: '#ffb547',
|
|
|
+ ringTip: `待处理占比 ${pendingPercent}%(待处理 / 不良品总数)`
|
|
|
+ },
|
|
|
+ {
|
|
|
+ key: 'batch',
|
|
|
+ label: '累计检验批次',
|
|
|
+ value: kpi.batchCumulative || 0,
|
|
|
+ unit: '批',
|
|
|
+ foot: `合格率 ${kpi.qualificationRate || 0}%`,
|
|
|
+ ringPercent: this.toRing(kpi.qualificationRate),
|
|
|
+ ringColor: '#9b7bff',
|
|
|
+ ringTip: `批次合格率 ${this.toRing(kpi.qualificationRate)}%`
|
|
|
+ }
|
|
|
+ ];
|
|
|
+ },
|
|
|
+ /**
|
|
|
+ * 三类检验行数据:固定 来料/生产/成品 三行(无数据补 0),其余类型合并为“其他”
|
|
|
+ */
|
|
|
+ typeRows() {
|
|
|
+ const raw = this.qualityTypeDist || [];
|
|
|
+ const pick = (code, fallbackName) => {
|
|
|
+ const hit = raw.find((item) => String(item.code) === code);
|
|
|
+ return {
|
|
|
+ code,
|
|
|
+ name: (hit && hit.name) || fallbackName,
|
|
|
+ planBatch: Number((hit && hit.planBatch) || 0),
|
|
|
+ doneBatch: Number((hit && hit.doneBatch) || 0),
|
|
|
+ qualifiedBatch: Number((hit && hit.qualifiedBatch) || 0),
|
|
|
+ completionRate: Number((hit && hit.completionRate) || 0)
|
|
|
+ };
|
|
|
+ };
|
|
|
+ const rows = [pick('1', '来料检验'), pick('2', '生产检验'), pick('3', '成品检验')];
|
|
|
+ const others = raw.filter((item) => !['1', '2', '3'].includes(String(item.code)));
|
|
|
+ if (others.length) {
|
|
|
+ rows.push({
|
|
|
+ code: '0',
|
|
|
+ name: '其他',
|
|
|
+ planBatch: others.reduce((sum, item) => sum + Number(item.planBatch || 0), 0),
|
|
|
+ doneBatch: others.reduce((sum, item) => sum + Number(item.doneBatch || 0), 0),
|
|
|
+ qualifiedBatch: others.reduce((sum, item) => sum + Number(item.qualifiedBatch || 0), 0),
|
|
|
+ completionRate: 0
|
|
|
+ });
|
|
|
+ }
|
|
|
+ return rows;
|
|
|
+ },
|
|
|
+ /**
|
|
|
+ * 累计统计(对应需求“统计累计检验批次和合格率”):
|
|
|
+ * 累计检验批次 = 各类完成批次之和;合格率 = 各类合格批次之和 / 累计检验批次
|
|
|
+ */
|
|
|
+ typeSummary() {
|
|
|
+ const rows = this.typeRows;
|
|
|
+ const plan = rows.reduce((sum, item) => sum + item.planBatch, 0);
|
|
|
+ const done = rows.reduce((sum, item) => sum + item.doneBatch, 0);
|
|
|
+ const qualified = rows.reduce((sum, item) => sum + item.qualifiedBatch, 0);
|
|
|
+ return {
|
|
|
+ planBatch: plan,
|
|
|
+ doneBatch: done,
|
|
|
+ completionRate: plan ? Math.round((done / plan) * 10000) / 100 : 0,
|
|
|
+ qualificationRate: done ? Math.round((qualified / done) * 10000) / 100 : 0
|
|
|
+ };
|
|
|
+ },
|
|
|
+ typeOption() {
|
|
|
+ const rows = this.typeRows;
|
|
|
+ if (!rows.some((item) => item.planBatch > 0)) return null;
|
|
|
+ return {
|
|
|
+ grid: { left: 10, right: 48, top: 28, bottom: 4, containLabel: true },
|
|
|
+ tooltip: {
|
|
|
+ trigger: 'axis',
|
|
|
+ axisPointer: { type: 'shadow' },
|
|
|
+ formatter: (params) => {
|
|
|
+ const row = rows[params[0].dataIndex] || {};
|
|
|
+ return [
|
|
|
+ `<b>${row.name || ''}</b>`,
|
|
|
+ `计划批次:${row.planBatch || 0}`,
|
|
|
+ `完成批次:${row.doneBatch || 0}`,
|
|
|
+ `完成率:${row.completionRate || 0}%`
|
|
|
+ ].join('<br/>');
|
|
|
+ }
|
|
|
+ },
|
|
|
+ legend: {
|
|
|
+ data: ['计划批次', '完成批次'],
|
|
|
+ textStyle: { color: AXIS_COLOR, fontSize: 12 },
|
|
|
+ itemWidth: 10,
|
|
|
+ itemHeight: 10,
|
|
|
+ top: 0
|
|
|
+ },
|
|
|
+ xAxis: {
|
|
|
+ type: 'value',
|
|
|
+ splitLine: { lineStyle: { color: SPLIT_COLOR } },
|
|
|
+ axisLabel: { color: AXIS_COLOR }
|
|
|
+ },
|
|
|
+ yAxis: {
|
|
|
+ type: 'category',
|
|
|
+ data: rows.map((item) => item.name),
|
|
|
+ axisLabel: { color: AXIS_COLOR },
|
|
|
+ axisLine: { lineStyle: { color: SPLIT_COLOR } }
|
|
|
+ },
|
|
|
+ series: [
|
|
|
+ {
|
|
|
+ name: '计划批次',
|
|
|
+ type: 'bar',
|
|
|
+ barWidth: 18,
|
|
|
+ z: 1,
|
|
|
+ itemStyle: { color: 'rgba(80, 140, 200, 0.22)', borderRadius: 9 },
|
|
|
+ data: rows.map((item) => item.planBatch)
|
|
|
+ },
|
|
|
+ {
|
|
|
+ name: '完成批次',
|
|
|
+ type: 'bar',
|
|
|
+ barWidth: 18,
|
|
|
+ barGap: '-100%',
|
|
|
+ z: 2,
|
|
|
+ itemStyle: { color: PALETTE[0], borderRadius: 9 },
|
|
|
+ /* 完成率直接标注在完成条右侧,一眼能读到需求里的“完成率” */
|
|
|
+ label: {
|
|
|
+ show: true,
|
|
|
+ position: 'right',
|
|
|
+ color: '#cfe2ff',
|
|
|
+ fontSize: 12,
|
|
|
+ formatter: (params) => `${rows[params.dataIndex].completionRate || 0}%`
|
|
|
+ },
|
|
|
+ data: rows.map((item) => item.doneBatch)
|
|
|
+ }
|
|
|
+ ]
|
|
|
+ };
|
|
|
+ },
|
|
|
+ trendOption() {
|
|
|
+ if (!this.trend.length) return null;
|
|
|
+ return {
|
|
|
+ color: [PALETTE[0], PALETTE[3]],
|
|
|
+ grid: { left: 10, right: 20, top: 40, bottom: 10, containLabel: true },
|
|
|
+ tooltip: { trigger: 'axis' },
|
|
|
+ legend: { data: ['合格率', '不良率'], textStyle: { color: AXIS_COLOR }, top: 0 },
|
|
|
+ xAxis: {
|
|
|
+ type: 'category',
|
|
|
+ data: this.trend.map((item) => item.label),
|
|
|
+ axisLabel: { color: AXIS_COLOR },
|
|
|
+ axisLine: { lineStyle: { color: SPLIT_COLOR } }
|
|
|
+ },
|
|
|
+ yAxis: {
|
|
|
+ type: 'value',
|
|
|
+ splitLine: { lineStyle: { color: SPLIT_COLOR } },
|
|
|
+ axisLabel: { color: AXIS_COLOR, formatter: '{value}%' }
|
|
|
+ },
|
|
|
+ series: [
|
|
|
+ {
|
|
|
+ name: '合格率',
|
|
|
+ type: 'line',
|
|
|
+ smooth: true,
|
|
|
+ areaStyle: { opacity: 0.15 },
|
|
|
+ data: this.trend.map((item) => item.qualificationRate || 0)
|
|
|
+ },
|
|
|
+ {
|
|
|
+ name: '不良率',
|
|
|
+ type: 'line',
|
|
|
+ smooth: true,
|
|
|
+ data: this.trend.map((item) => item.noQualificationRate || 0)
|
|
|
+ }
|
|
|
+ ]
|
|
|
+ };
|
|
|
+ },
|
|
|
+ /** 不良次数合计(环形中心展示) */
|
|
|
+ defectTotal() {
|
|
|
+ return this.defectTypeProportion.reduce(
|
|
|
+ (sum, item) => sum + (Number(item.value) || 0),
|
|
|
+ 0
|
|
|
+ );
|
|
|
+ },
|
|
|
+ /**
|
|
|
+ * 不良类型占比:沿用质量首页口径(按不合格数量统计、图例带次数、环形中心展示合计),
|
|
|
+ * 仅配色与字号适配看板深色风格
|
|
|
+ */
|
|
|
+ defectOption() {
|
|
|
+ const list = this.defectTypeProportion;
|
|
|
+ if (!list || !list.length) return null;
|
|
|
+ return {
|
|
|
+ color: PALETTE,
|
|
|
+ tooltip: {
|
|
|
+ trigger: 'item',
|
|
|
+ formatter: (params) => `${params.name}<br/>次数:${params.value}(${params.percent}%)`
|
|
|
+ },
|
|
|
+ legend: {
|
|
|
+ type: 'scroll',
|
|
|
+ orient: 'vertical',
|
|
|
+ right: 0,
|
|
|
+ top: 'center',
|
|
|
+ itemWidth: 10,
|
|
|
+ itemHeight: 10,
|
|
|
+ textStyle: { color: AXIS_COLOR, fontSize: 12 },
|
|
|
+ formatter: (name) => {
|
|
|
+ const hit = list.find((item) => item.name === name);
|
|
|
+ return `${name} | ${hit ? hit.value : 0}`;
|
|
|
+ }
|
|
|
+ },
|
|
|
+ series: [
|
|
|
+ {
|
|
|
+ name: '不良类型占比',
|
|
|
+ type: 'pie',
|
|
|
+ radius: ['52%', '72%'],
|
|
|
+ center: ['32%', '50%'],
|
|
|
+ avoidLabelOverlap: true,
|
|
|
+ itemStyle: { borderColor: '#0e1626', borderWidth: 2 },
|
|
|
+ label: { show: false },
|
|
|
+ labelLine: { show: false },
|
|
|
+ data: list.map((item, index) => ({
|
|
|
+ name: item.name,
|
|
|
+ value: item.value,
|
|
|
+ itemStyle: { color: PALETTE[index % PALETTE.length] }
|
|
|
+ }))
|
|
|
+ }
|
|
|
+ ]
|
|
|
+ };
|
|
|
+ },
|
|
|
+ /**
|
|
|
+ * 工厂合格率:环形饼展示各工厂,图例文字直接带合格率数值,避免只看到占比
|
|
|
+ */
|
|
|
+ factoryRateOption() {
|
|
|
+ const list = this.factoryRateList || [];
|
|
|
+ if (!list.length) return null;
|
|
|
+ const nameOf = (item) => item.factoryName || '未命名工厂';
|
|
|
+ return {
|
|
|
+ color: PALETTE,
|
|
|
+ tooltip: {
|
|
|
+ trigger: 'item',
|
|
|
+ formatter: (params) => `${params.name}<br/>合格率:${params.value}%`
|
|
|
+ },
|
|
|
+ legend: {
|
|
|
+ type: 'scroll',
|
|
|
+ orient: 'vertical',
|
|
|
+ right: 0,
|
|
|
+ top: 'center',
|
|
|
+ itemWidth: 10,
|
|
|
+ itemHeight: 10,
|
|
|
+ textStyle: { color: AXIS_COLOR, fontSize: 12 },
|
|
|
+ formatter: (name) => {
|
|
|
+ const hit = list.find((item) => nameOf(item) === name);
|
|
|
+ return hit ? `${name} ${this.toPercent(hit.qualificationRate)}%` : name;
|
|
|
+ }
|
|
|
+ },
|
|
|
+ series: [
|
|
|
+ {
|
|
|
+ name: '工厂合格率',
|
|
|
+ type: 'pie',
|
|
|
+ radius: ['48%', '72%'],
|
|
|
+ center: ['34%', '50%'],
|
|
|
+ label: { show: false },
|
|
|
+ labelLine: { show: false },
|
|
|
+ itemStyle: { borderRadius: 6 },
|
|
|
+ data: list.map((item, index) => ({
|
|
|
+ name: nameOf(item),
|
|
|
+ value: this.toPercent(item.qualificationRate),
|
|
|
+ itemStyle: { color: PALETTE[index % PALETTE.length] }
|
|
|
+ }))
|
|
|
+ }
|
|
|
+ ]
|
|
|
+ };
|
|
|
+ },
|
|
|
+ /** 班组合格率:柱状图,柱顶标合格率 */
|
|
|
+ teamRateOption() {
|
|
|
+ const list = this.teamRateList || [];
|
|
|
+ if (!list.length) return null;
|
|
|
+ return this.rateBarOption(
|
|
|
+ list.map((item) => item.teamName || '未命名班组'),
|
|
|
+ list.map((item) => this.toPercent(item.qualificationRate)),
|
|
|
+ PALETTE[1]
|
|
|
+ );
|
|
|
+ },
|
|
|
+ /** 产线合格率:柱状图,柱顶标合格率 */
|
|
|
+ lineRateOption() {
|
|
|
+ const list = this.lineRateList || [];
|
|
|
+ if (!list.length) return null;
|
|
|
+ return this.rateBarOption(
|
|
|
+ list.map((item) => item.productionLineName || '未命名产线'),
|
|
|
+ list.map((item) => this.toPercent(item.qualificationRate)),
|
|
|
+ PALETTE[0]
|
|
|
+ );
|
|
|
+ },
|
|
|
+ /**
|
|
|
+ * 记录方法分布:柱长表示次数,柱顶同时标注次数与占比(需求要求显示占比)
|
|
|
+ * 占比 = 该类记录方法次数 ÷ 记录方法总次数,后端 QmsDashboardDAO 已算好 rate
|
|
|
+ */
|
|
|
+ methodOption() {
|
|
|
+ const list = this.recordingMethodDist;
|
|
|
+ if (!list || !list.length) return null;
|
|
|
+ return {
|
|
|
+ color: PALETTE,
|
|
|
+ grid: { left: 10, right: 20, top: 40, bottom: 10, containLabel: true },
|
|
|
+ tooltip: {
|
|
|
+ trigger: 'axis',
|
|
|
+ axisPointer: { type: 'shadow' },
|
|
|
+ formatter: (params) => {
|
|
|
+ const item = list[params[0].dataIndex] || {};
|
|
|
+ return `${item.name || ''}<br/>次数:${item.value || 0} 次<br/>占比:${this.toPercent(item.rate)}%`;
|
|
|
+ }
|
|
|
+ },
|
|
|
+ xAxis: {
|
|
|
+ type: 'category',
|
|
|
+ data: list.map((item) => item.name),
|
|
|
+ axisLabel: { color: AXIS_COLOR, interval: 0 },
|
|
|
+ axisLine: { lineStyle: { color: SPLIT_COLOR } }
|
|
|
+ },
|
|
|
+ yAxis: {
|
|
|
+ type: 'value',
|
|
|
+ name: '次数',
|
|
|
+ nameTextStyle: { color: AXIS_COLOR, fontSize: 11, padding: [0, 0, 0, 10] },
|
|
|
+ splitLine: { lineStyle: { color: SPLIT_COLOR } },
|
|
|
+ axisLabel: { color: AXIS_COLOR }
|
|
|
+ },
|
|
|
+ series: [
|
|
|
+ {
|
|
|
+ type: 'bar',
|
|
|
+ barWidth: 26,
|
|
|
+ data: list.map((item, index) => ({
|
|
|
+ value: item.value || 0,
|
|
|
+ itemStyle: { color: PALETTE[index % PALETTE.length] }
|
|
|
+ })),
|
|
|
+ itemStyle: { borderRadius: [8, 8, 0, 0] },
|
|
|
+ /* 第一行次数、第二行占比,占比用高亮色加粗,避免和次数混淆 */
|
|
|
+ label: {
|
|
|
+ show: true,
|
|
|
+ position: 'top',
|
|
|
+ lineHeight: 15,
|
|
|
+ formatter: (params) => {
|
|
|
+ const item = list[params.dataIndex] || {};
|
|
|
+ return `{c|${item.value || 0} 次}\n{p|${this.toPercent(item.rate)}%}`;
|
|
|
+ },
|
|
|
+ rich: {
|
|
|
+ c: { color: '#cfe2ff', fontSize: 11 },
|
|
|
+ p: { color: '#4fd6ff', fontSize: 13, fontWeight: 600 }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ ]
|
|
|
+ };
|
|
|
+ },
|
|
|
+ /**
|
|
|
+ * 处置方式清单:环形图与右侧横向进度条共用同一份数据(含配色、占比、tooltip 文案),
|
|
|
+ * 保证两边颜色/占比完全一致,不会出现环形与进度条对不上的情况
|
|
|
+ */
|
|
|
+ disposalList() {
|
|
|
+ return (this.disposalDist || []).map((item, index) => {
|
|
|
+ const name = item.name || '未设置';
|
|
|
+ const value = Number(item.value || 0);
|
|
|
+ const rate = this.toPercent(item.rate);
|
|
|
+ return {
|
|
|
+ code: item.code,
|
|
|
+ name,
|
|
|
+ value,
|
|
|
+ rate,
|
|
|
+ color: PALETTE[index % PALETTE.length],
|
|
|
+ tip: `${name}:${value} 件,占比 ${rate}%`
|
|
|
+ };
|
|
|
+ });
|
|
|
+ },
|
|
|
+ disposalRate() {
|
|
|
+ return this.toPercent(this.kpi && this.kpi.disposalRate);
|
|
|
+ },
|
|
|
+ /** 处置明细总数:KPI 处置率的分母,也用作环形进度总量 */
|
|
|
+ disposalBase() {
|
|
|
+ const num = Number(this.kpi && this.kpi.disposalDetailTotal);
|
|
|
+ return isFinite(num) && num > 0 ? num : 0;
|
|
|
+ },
|
|
|
+ /** 已处置数:KPI 处置率的分子,兜底不超过分母 */
|
|
|
+ disposalDone() {
|
|
|
+ const num = Number(this.kpi && this.kpi.unqualifiedDisposed);
|
|
|
+ const done = isFinite(num) && num > 0 ? num : 0;
|
|
|
+ return Math.min(done, this.disposalBase);
|
|
|
+ },
|
|
|
+ /**
|
|
|
+ * 左侧环形进度:已处置 / 待处置两段,中心由 HTML 叠层显示 disposalRate
|
|
|
+ * 口径与 KPI 处置率一致:unqualifiedDisposed ÷ disposalDetailTotal
|
|
|
+ */
|
|
|
+ disposalOption() {
|
|
|
+ const base = this.disposalBase;
|
|
|
+ if (!base) return null;
|
|
|
+ const done = this.disposalDone;
|
|
|
+ return {
|
|
|
+ tooltip: {
|
|
|
+ trigger: 'item',
|
|
|
+ formatter: (params) => `${params.name}:${params.value} 条(${params.percent}%)`
|
|
|
+ },
|
|
|
+ series: [
|
|
|
+ {
|
|
|
+ type: 'pie',
|
|
|
+ radius: ['62%', '80%'],
|
|
|
+ center: ['50%', '50%'],
|
|
|
+ startAngle: 90,
|
|
|
+ label: { show: false },
|
|
|
+ labelLine: { show: false },
|
|
|
+ data: [
|
|
|
+ { name: '已处置', value: done, itemStyle: { color: '#25d0b0', borderRadius: 8 } },
|
|
|
+ {
|
|
|
+ name: '待处置',
|
|
|
+ value: base - done,
|
|
|
+ itemStyle: { color: 'rgba(80, 140, 200, 0.22)', borderRadius: 8 }
|
|
|
+ }
|
|
|
+ ]
|
|
|
+ }
|
|
|
+ ]
|
|
|
+ };
|
|
|
+ }
|
|
|
+ },
|
|
|
+ async mounted() {
|
|
|
+ /* 默认工厂确定后再取数,避免先按“全部工厂”查一遍再按工厂查一遍 */
|
|
|
+ await this.loadFactoryList();
|
|
|
+ this.fetchData();
|
|
|
+ },
|
|
|
+ methods: {
|
|
|
+ /**
|
|
|
+ * 工厂下拉数据(后端 /main/factoryarea/getFactoryList 只返回 type=1 的工厂)
|
|
|
+ * 默认选中当前登录人所属工厂;取不到(或已不在工厂列表中)时取第一个工厂
|
|
|
+ */
|
|
|
+ async loadFactoryList() {
|
|
|
+ /* 两个接口互不影响:任一失败都不应该让工厂下拉变成“无数据” */
|
|
|
+ const toResult = (p) => p.then((v) => ({ ok: true, v }), (e) => ({ ok: false, e }));
|
|
|
+ const [listRes, userRes] = await Promise.all([
|
|
|
+ toResult(getFactoryList()),
|
|
|
+ toResult(getCurrentUserFactoryId())
|
|
|
+ ]);
|
|
|
+ let list = [];
|
|
|
+ if (listRes.ok) {
|
|
|
+ /* 兼容后端返回数组 / 分页对象 / 二次包装三种形态 */
|
|
|
+ const raw = listRes.v;
|
|
|
+ let arr = [];
|
|
|
+ if (Array.isArray(raw)) {
|
|
|
+ arr = raw;
|
|
|
+ } else if (raw && typeof raw === 'object') {
|
|
|
+ const keys = ['list', 'records', 'content', 'rows', 'data'];
|
|
|
+ const hit = keys.find((key) => Array.isArray(raw[key]));
|
|
|
+ arr = hit ? raw[hit] : [];
|
|
|
+ }
|
|
|
+ list = arr
|
|
|
+ .filter((item) => item && item.id !== undefined && item.id !== null)
|
|
|
+ .map((item) => ({ id: String(item.id), name: item.name }));
|
|
|
+ } else {
|
|
|
+ console.warn('[质量看板] 工厂列表接口异常:', listRes.e);
|
|
|
+ }
|
|
|
+ if (!userRes.ok) {
|
|
|
+ console.warn('[质量看板] 当前用户所属工厂接口异常:', userRes.e);
|
|
|
+ }
|
|
|
+ this.factoryList = list;
|
|
|
+ /* 当前用户工厂归一化:null / '' / undefined / 空数组 都视为“没取到” */
|
|
|
+ let userFactoryId = userRes.ok ? userRes.v : null;
|
|
|
+ if (Array.isArray(userFactoryId)) {
|
|
|
+ userFactoryId = userFactoryId.length ? userFactoryId[0] : null;
|
|
|
+ } else if (userFactoryId && typeof userFactoryId === 'object') {
|
|
|
+ userFactoryId = userFactoryId.id !== undefined ? userFactoryId.id : null;
|
|
|
+ }
|
|
|
+ if (userFactoryId === '' || userFactoryId === undefined) {
|
|
|
+ userFactoryId = null;
|
|
|
+ }
|
|
|
+ const matched = userFactoryId !== null
|
|
|
+ ? list.find((item) => item.id === String(userFactoryId))
|
|
|
+ : null;
|
|
|
+ /* 取不到(或不在工厂列表中)时默认选中列表第一条;列表为空才保持“全部工厂” */
|
|
|
+ this.factoryId = matched ? matched.id : (list.length ? list[0].id : '');
|
|
|
+ },
|
|
|
+ /** 切换工厂:除“工厂合格率”外的所有区块按新工厂重新取数 */
|
|
|
+ changeFactory() {
|
|
|
+ this.fetchData();
|
|
|
+ },
|
|
|
+ changeRange(type) {
|
|
|
+ if (this.timeType === type) return;
|
|
|
+ this.timeType = type;
|
|
|
+ this.fetchData();
|
|
|
+ },
|
|
|
+ /**
|
|
|
+ * 合格率接口的时间参数:跟随页面顶部 本月 / 本季度 / 本年,换算成具体起止时间
|
|
|
+ * timeType = 4 表示按调用方传入的区间取数(后端 quality_work_order 支持)
|
|
|
+ */
|
|
|
+ buildRateRange() {
|
|
|
+ const now = new Date();
|
|
|
+ let start;
|
|
|
+ if (this.timeType === 1) {
|
|
|
+ start = new Date(now.getFullYear(), now.getMonth(), 1);
|
|
|
+ } else if (this.timeType === 2) {
|
|
|
+ /* 本季度首月:0~2 月为 Q1,以此类推 */
|
|
|
+ start = new Date(now.getFullYear(), Math.floor(now.getMonth() / 3) * 3, 1);
|
|
|
+ } else {
|
|
|
+ start = new Date(now.getFullYear(), 0, 1);
|
|
|
+ }
|
|
|
+ return { timeType: 4, startTime: start.getTime(), endTime: now.getTime() };
|
|
|
+ },
|
|
|
+ /**
|
|
|
+ * 三个合格率接口共用同一时间条件,各自独立请求,单个失败只影响自己那张卡片
|
|
|
+ * 工厂合格率本身按工厂分组对比,不传工厂;班组 / 产线合格率按顶部工厂筛选
|
|
|
+ */
|
|
|
+ fetchRateData() {
|
|
|
+ const timeParam = this.buildRateRange();
|
|
|
+ const factoryParam = this.factoryId ? { factoriesId: this.factoryId } : {};
|
|
|
+ return Promise.all([
|
|
|
+ getFactoryRate(timeParam)
|
|
|
+ .then((list) => {
|
|
|
+ this.factoryRateList = list || [];
|
|
|
+ })
|
|
|
+ .catch(() => {
|
|
|
+ this.factoryRateList = [];
|
|
|
+ }),
|
|
|
+ getTeamRate({ ...timeParam, ...factoryParam })
|
|
|
+ .then((list) => {
|
|
|
+ this.teamRateList = list || [];
|
|
|
+ })
|
|
|
+ .catch(() => {
|
|
|
+ this.teamRateList = [];
|
|
|
+ }),
|
|
|
+ getProductionLineRate({ ...timeParam, ...factoryParam })
|
|
|
+ .then((list) => {
|
|
|
+ this.lineRateList = list || [];
|
|
|
+ })
|
|
|
+ .catch(() => {
|
|
|
+ this.lineRateList = [];
|
|
|
+ })
|
|
|
+ ]);
|
|
|
+ },
|
|
|
+ /**
|
|
|
+ * 把看板 timeType(1 本月 / 2 本季度 / 3 本年)换算成后台要求的 yyyy-MM 区间
|
|
|
+ * 后端 queryProportionDefectiveTypes 按 createTimeStart ~ createTimeEnd 的月份取数
|
|
|
+ */
|
|
|
+ buildDefectRange() {
|
|
|
+ const now = new Date();
|
|
|
+ const year = now.getFullYear();
|
|
|
+ const month = now.getMonth() + 1;
|
|
|
+ let startMonth = 1;
|
|
|
+ if (this.timeType === 1) {
|
|
|
+ startMonth = month;
|
|
|
+ } else if (this.timeType === 2) {
|
|
|
+ /* 本季度:1~3 月为 Q1,以此类推 */
|
|
|
+ startMonth = Math.floor((month - 1) / 3) * 3 + 1;
|
|
|
+ }
|
|
|
+ const fmt = (m) => `${year}-${String(m).padStart(2, '0')}`;
|
|
|
+ const params = { createTimeStart: fmt(startMonth), createTimeEnd: fmt(month) };
|
|
|
+ if (this.factoryId) {
|
|
|
+ params.factoryId = this.factoryId;
|
|
|
+ }
|
|
|
+ return params;
|
|
|
+ },
|
|
|
+ /** 不良类型占比:接口失败只清空本卡片,不影响其他区块 */
|
|
|
+ async fetchDefectTypeProportion() {
|
|
|
+ try {
|
|
|
+ const list = await getDefectTypeProportion(this.buildDefectRange());
|
|
|
+ this.defectTypeProportion = (list || [])
|
|
|
+ .map((item) => ({
|
|
|
+ name: item.categoryLevelClassName || '未分类',
|
|
|
+ value: Number(item.unQualifiedNumber) || 0
|
|
|
+ }))
|
|
|
+ .filter((item) => item.value > 0);
|
|
|
+ } catch (e) {
|
|
|
+ this.defectTypeProportion = [];
|
|
|
+ }
|
|
|
+ },
|
|
|
+ /** 合格率柱状图(班组 / 产线通用):柱顶标数值,Y 轴固定 0~100 便于横向对比 */
|
|
|
+ rateBarOption(names, values, color) {
|
|
|
+ return {
|
|
|
+ grid: { left: 10, right: 16, top: 26, bottom: 4, containLabel: true },
|
|
|
+ tooltip: {
|
|
|
+ trigger: 'axis',
|
|
|
+ axisPointer: { type: 'shadow' },
|
|
|
+ formatter: (params) => `${params[0].name}<br/>合格率:${params[0].value}%`
|
|
|
+ },
|
|
|
+ xAxis: {
|
|
|
+ type: 'category',
|
|
|
+ data: names,
|
|
|
+ axisTick: { show: false },
|
|
|
+ axisLine: { lineStyle: { color: SPLIT_COLOR } },
|
|
|
+ axisLabel: {
|
|
|
+ color: AXIS_COLOR,
|
|
|
+ interval: 0,
|
|
|
+ fontSize: 11,
|
|
|
+ rotate: names.length > 5 ? 25 : 0
|
|
|
+ }
|
|
|
+ },
|
|
|
+ yAxis: {
|
|
|
+ type: 'value',
|
|
|
+ max: 100,
|
|
|
+ splitLine: { lineStyle: { color: SPLIT_COLOR } },
|
|
|
+ axisLabel: { color: AXIS_COLOR, formatter: '{value}%' }
|
|
|
+ },
|
|
|
+ series: [
|
|
|
+ {
|
|
|
+ type: 'bar',
|
|
|
+ barMaxWidth: 26,
|
|
|
+ itemStyle: { color, borderRadius: [8, 8, 0, 0] },
|
|
|
+ label: { show: true, position: 'top', color: '#cfe2ff', fontSize: 11, formatter: '{c}%' },
|
|
|
+ data: values
|
|
|
+ }
|
|
|
+ ]
|
|
|
+ };
|
|
|
+ },
|
|
|
+ async fetchData() {
|
|
|
+ this.loading = true;
|
|
|
+ /* 工厂 / 班组 / 产线合格率跟随顶部 本月/本季度/本年,与主看板数据并行加载 */
|
|
|
+ this.fetchRateData();
|
|
|
+ /* 不良类型占比走质量首页同款接口,时间维度跟随看板 本月/本季度/本年 */
|
|
|
+ this.fetchDefectTypeProportion();
|
|
|
+ try {
|
|
|
+ const data = await getOverview({ timeType: this.timeType, factoryId: this.factoryId || null });
|
|
|
+ this.kpi = data.kpi || {};
|
|
|
+ this.qualityTypeDist = data.qualityTypeDist || [];
|
|
|
+ this.trend = data.trend || [];
|
|
|
+ this.recordingMethodDist = data.recordingMethodDist || [];
|
|
|
+ this.flowStatus = data.flowStatus || [];
|
|
|
+ this.supplierRank = (data.supplierRank || []).slice(0, 5);
|
|
|
+ this.disposalDist = data.disposalDist || [];
|
|
|
+ this.alertList = (data.alertList || []).slice(0, 6);
|
|
|
+ } catch (e) {
|
|
|
+ this.$message.error(e.message || '看板数据加载失败');
|
|
|
+ } finally {
|
|
|
+ this.loading = false;
|
|
|
+ }
|
|
|
+ },
|
|
|
+ /**
|
|
|
+ * 环形进度百分比:限幅 0~100,保留 1 位小数(SVG 用 pathLength=100 归一化)
|
|
|
+ */
|
|
|
+ toRing(value) {
|
|
|
+ const num = Number(value);
|
|
|
+ if (!isFinite(num) || num <= 0) {
|
|
|
+ return 0;
|
|
|
+ }
|
|
|
+ return Math.round(Math.min(num, 100) * 10) / 10;
|
|
|
+ },
|
|
|
+ /**
|
|
|
+ * 占比展示:保留 1 位小数,空值/异常值统一显示 0(后端 rate 为 BigDecimal 百分比,如 34.50)
|
|
|
+ */
|
|
|
+ toPercent(value) {
|
|
|
+ const num = Number(value);
|
|
|
+ if (!isFinite(num) || num <= 0) {
|
|
|
+ return 0;
|
|
|
+ }
|
|
|
+ return Math.round(num * 10) / 10;
|
|
|
+ },
|
|
|
+ /**
|
|
|
+ * 打开明细弹窗(不跳转路由),配置见 components/drillConfig.js
|
|
|
+ * @param {String} key 弹窗配置 key
|
|
|
+ * @param {Object} extraWhere 图表点击带入的附加筛选条件,如 { qualityType: 1 }
|
|
|
+ */
|
|
|
+ openKpiDrill(key, extraWhere) {
|
|
|
+ if (!DRILL_CONFIG[key]) {
|
|
|
+ this.$message.warning('该指标暂未配置明细');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ this.drillKey = key;
|
|
|
+ /* 明细继承看板顶部工厂筛选(工单台账与不良品台账均按 factoriesId 过滤) */
|
|
|
+ this.drillExtra = { ...(extraWhere || {}) };
|
|
|
+ if (this.factoryId) {
|
|
|
+ this.drillExtra.factoriesId = this.factoryId;
|
|
|
+ }
|
|
|
+ this.drillVisible = true;
|
|
|
+ },
|
|
|
+ /** 三类检验:点柱条按质检类型下钻(来料 1 / 生产 2 / 成品 3) */
|
|
|
+ handleTypeDrill(params) {
|
|
|
+ const row = this.typeRows[params && params.dataIndex];
|
|
|
+ if (!row) return;
|
|
|
+ if (row.code === '0') {
|
|
|
+ this.openKpiDrill('completion');
|
|
|
+ this.$message.info('“其他”包含多种质检类型,已展示全部检验批次');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ this.openKpiDrill('completion', { qualityType: Number(row.code) });
|
|
|
+ },
|
|
|
+ /** 记录方法分布:点柱条按记录方法下钻 */
|
|
|
+ handleMethodDrill(params) {
|
|
|
+ const item = this.recordingMethodDist[params && params.dataIndex];
|
|
|
+ if (!item || item.code === undefined || item.code === null) {
|
|
|
+ this.openKpiDrill('completion');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ this.openKpiDrill('completion', { recordingMethod: Number(item.code) });
|
|
|
+ },
|
|
|
+ /** 不良类型占比:台账主表无不良类型字段,点击扇区只打开不良明细 */
|
|
|
+ handleDefectTypeDrill() {
|
|
|
+ this.openKpiDrill('defect');
|
|
|
+ this.$message.info('不良类型暂不支持作为台账筛选条件,已展示筛选期间内全部不良明细');
|
|
|
+ },
|
|
|
+ /** 供应商排名:按供应商 + 来料检验下钻 */
|
|
|
+ handleSupplierDrill(item) {
|
|
|
+ if (!item || !item.supplierName) {
|
|
|
+ this.openKpiDrill('completion', { qualityType: 1 });
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ this.openKpiDrill('completion', { qualityType: 1, supplierName: item.supplierName });
|
|
|
+ },
|
|
|
+ /** 不良品处置环形:已处置 / 待处置 两段分别下钻 */
|
|
|
+ handleDisposalDonut(params) {
|
|
|
+ const name = params && params.name;
|
|
|
+ this.openKpiDrill('defect', { status: name === '已处置' ? 2 : 0 });
|
|
|
+ },
|
|
|
+ /** 处置方式行:接口无 dispose_type 筛选,暂展示全部不良明细 */
|
|
|
+ handleDisposalDrill() {
|
|
|
+ this.openKpiDrill('defect');
|
|
|
+ this.$message.info('处置方式暂不支持作为台账筛选条件,已展示筛选期间内全部不良明细');
|
|
|
+ }
|
|
|
+ }
|
|
|
+};
|
|
|
+</script>
|
|
|
+
|
|
|
+<style scoped>
|
|
|
+.qb-page {
|
|
|
+ min-height: calc(100vh - 100px);
|
|
|
+ padding: 16px 18px 24px;
|
|
|
+ background: radial-gradient(circle at 20% 0%, #142a4a 0%, #0b1220 45%, #080e19 100%);
|
|
|
+ color: #dbe7f7;
|
|
|
+ box-sizing: border-box;
|
|
|
+}
|
|
|
+.qb-header {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: space-between;
|
|
|
+ margin-bottom: 14px;
|
|
|
+}
|
|
|
+.qb-title-main {
|
|
|
+ font-size: 22px;
|
|
|
+ font-weight: 700;
|
|
|
+ letter-spacing: 2px;
|
|
|
+ background: linear-gradient(90deg, #4fb6ff, #25d0b0);
|
|
|
+ -webkit-background-clip: text;
|
|
|
+ background-clip: text;
|
|
|
+ color: transparent;
|
|
|
+}
|
|
|
+.qb-title-sub {
|
|
|
+ margin-left: 10px;
|
|
|
+ font-size: 12px;
|
|
|
+ color: #54708f;
|
|
|
+ letter-spacing: 1px;
|
|
|
+}
|
|
|
+.qb-header-right {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+}
|
|
|
+/* 工厂下拉:与时间维度胶囊同一视觉语言(深色透明底 + 细边框) */
|
|
|
+.qb-factory {
|
|
|
+ width: 140px;
|
|
|
+ margin-right: 12px;
|
|
|
+}
|
|
|
+.qb-factory ::v-deep .el-input__inner {
|
|
|
+ height: 28px;
|
|
|
+ line-height: 28px;
|
|
|
+ padding: 0 30px 0 12px;
|
|
|
+ font-size: 12px;
|
|
|
+ color: #cfe2ff;
|
|
|
+ background: rgba(80, 140, 200, 0.12);
|
|
|
+ border: 1px solid rgba(80, 140, 200, 0.35);
|
|
|
+ border-radius: 15px;
|
|
|
+}
|
|
|
+.qb-factory ::v-deep .el-input__inner::placeholder {
|
|
|
+ color: #8aa0bd;
|
|
|
+}
|
|
|
+.qb-factory ::v-deep .el-input__suffix {
|
|
|
+ line-height: 28px;
|
|
|
+}
|
|
|
+.qb-factory ::v-deep .el-select__caret {
|
|
|
+ color: #8aa0bd;
|
|
|
+}
|
|
|
+.qb-factory ::v-deep .el-input__icon {
|
|
|
+ line-height: 28px;
|
|
|
+}
|
|
|
+.qb-range {
|
|
|
+ display: flex;
|
|
|
+ border: 1px solid rgba(80, 140, 200, 0.35);
|
|
|
+ border-radius: 16px;
|
|
|
+ overflow: hidden;
|
|
|
+}
|
|
|
+.qb-range-item {
|
|
|
+ padding: 5px 16px;
|
|
|
+ font-size: 12px;
|
|
|
+ color: #8aa0bd;
|
|
|
+ cursor: pointer;
|
|
|
+ transition: all 0.2s;
|
|
|
+}
|
|
|
+.qb-range-item.active {
|
|
|
+ color: #fff;
|
|
|
+ background: linear-gradient(90deg, #2b7cff, #25d0b0);
|
|
|
+}
|
|
|
+.qb-refresh {
|
|
|
+ margin-left: 14px;
|
|
|
+ font-size: 18px;
|
|
|
+ color: #8aa0bd;
|
|
|
+ cursor: pointer;
|
|
|
+}
|
|
|
+.qb-refresh.loading {
|
|
|
+ animation: qb-rotate 1s linear infinite;
|
|
|
+}
|
|
|
+@keyframes qb-rotate {
|
|
|
+ to {
|
|
|
+ transform: rotate(360deg);
|
|
|
+ }
|
|
|
+}
|
|
|
+.qb-kpi-row {
|
|
|
+ display: grid;
|
|
|
+ grid-template-columns: repeat(5, 1fr);
|
|
|
+ gap: 12px;
|
|
|
+ margin-bottom: 12px;
|
|
|
+}
|
|
|
+.qb-kpi {
|
|
|
+ padding: 14px 16px;
|
|
|
+ border-radius: 10px;
|
|
|
+ background: linear-gradient(160deg, rgba(35, 80, 140, 0.35), rgba(14, 24, 40, 0.85));
|
|
|
+ border: 1px solid rgba(70, 130, 200, 0.25);
|
|
|
+ cursor: pointer;
|
|
|
+ transition: transform 0.2s, border-color 0.2s;
|
|
|
+}
|
|
|
+.qb-kpi:hover {
|
|
|
+ transform: translateY(-2px);
|
|
|
+ border-color: rgba(80, 200, 255, 0.6);
|
|
|
+}
|
|
|
+.qb-kpi-label {
|
|
|
+ font-size: 13px;
|
|
|
+ color: #8aa0bd;
|
|
|
+}
|
|
|
+.qb-kpi-body {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: space-between;
|
|
|
+ margin: 4px 0 6px;
|
|
|
+}
|
|
|
+.qb-kpi-value {
|
|
|
+ font-size: 26px;
|
|
|
+ font-weight: 700;
|
|
|
+ color: #4fd6ff;
|
|
|
+ font-family: 'DIN Alternate', Arial, sans-serif;
|
|
|
+ white-space: nowrap;
|
|
|
+}
|
|
|
+.qb-kpi-unit {
|
|
|
+ margin-left: 4px;
|
|
|
+ font-size: 13px;
|
|
|
+ color: #8aa0bd;
|
|
|
+}
|
|
|
+.qb-kpi-ring {
|
|
|
+ position: relative;
|
|
|
+ flex: 0 0 52px;
|
|
|
+ width: 52px;
|
|
|
+ height: 52px;
|
|
|
+ margin-left: 8px;
|
|
|
+}
|
|
|
+.qb-kpi-ring svg {
|
|
|
+ display: block;
|
|
|
+ width: 100%;
|
|
|
+ height: 100%;
|
|
|
+}
|
|
|
+.qb-ring-track {
|
|
|
+ fill: none;
|
|
|
+ stroke: rgba(80, 140, 200, 0.22);
|
|
|
+ stroke-width: 3.2;
|
|
|
+}
|
|
|
+.qb-ring-bar {
|
|
|
+ fill: none;
|
|
|
+ stroke-width: 3.2;
|
|
|
+ stroke-linecap: round;
|
|
|
+ transition: stroke-dasharray 0.6s ease;
|
|
|
+}
|
|
|
+.qb-ring-text {
|
|
|
+ position: absolute;
|
|
|
+ top: 0;
|
|
|
+ right: 0;
|
|
|
+ bottom: 0;
|
|
|
+ left: 0;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ font-size: 11px;
|
|
|
+ font-weight: 600;
|
|
|
+ color: #cfe2ff;
|
|
|
+ font-family: 'DIN Alternate', Arial, sans-serif;
|
|
|
+}
|
|
|
+.qb-ring-text i {
|
|
|
+ margin-left: 1px;
|
|
|
+ font-size: 9px;
|
|
|
+ font-style: normal;
|
|
|
+ color: #8aa0bd;
|
|
|
+}
|
|
|
+.qb-kpi-foot {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: space-between;
|
|
|
+ font-size: 12px;
|
|
|
+ color: #6d84a3;
|
|
|
+}
|
|
|
+.qb-kpi-foot-text {
|
|
|
+ overflow: hidden;
|
|
|
+ text-overflow: ellipsis;
|
|
|
+ white-space: nowrap;
|
|
|
+}
|
|
|
+.qb-kpi-more {
|
|
|
+ flex: 0 0 auto;
|
|
|
+ margin-left: 8px;
|
|
|
+ color: #3ba7ff;
|
|
|
+ opacity: 0;
|
|
|
+ transform: translateX(-4px);
|
|
|
+ transition: opacity 0.2s, transform 0.2s;
|
|
|
+}
|
|
|
+.qb-kpi:hover .qb-kpi-more {
|
|
|
+ opacity: 1;
|
|
|
+ transform: translateX(0);
|
|
|
+}
|
|
|
+.qb-kpi:active {
|
|
|
+ transform: translateY(0) scale(0.995);
|
|
|
+}
|
|
|
+.qb-row {
|
|
|
+ display: grid;
|
|
|
+ grid-template-columns: repeat(12, 1fr);
|
|
|
+ gap: 12px;
|
|
|
+ margin-bottom: 12px;
|
|
|
+}
|
|
|
+.qb-col-3 {
|
|
|
+ grid-column: span 3;
|
|
|
+}
|
|
|
+.qb-col-4 {
|
|
|
+ grid-column: span 4;
|
|
|
+}
|
|
|
+.qb-col-6 {
|
|
|
+ grid-column: span 6;
|
|
|
+}
|
|
|
+.qb-card {
|
|
|
+ padding: 12px 14px;
|
|
|
+ border-radius: 10px;
|
|
|
+ background: rgba(13, 24, 40, 0.85);
|
|
|
+ border: 1px solid rgba(70, 130, 200, 0.22);
|
|
|
+}
|
|
|
+.qb-card-head {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: space-between;
|
|
|
+ font-size: 14px;
|
|
|
+ font-weight: 600;
|
|
|
+ color: #cfe2ff;
|
|
|
+ padding-left: 8px;
|
|
|
+ border-left: 3px solid #2b7cff;
|
|
|
+ margin-bottom: 8px;
|
|
|
+}
|
|
|
+.qb-link {
|
|
|
+ font-size: 12px;
|
|
|
+ font-weight: 400;
|
|
|
+ color: #4fb6ff;
|
|
|
+ cursor: pointer;
|
|
|
+}
|
|
|
+.qb-tip {
|
|
|
+ font-size: 12px;
|
|
|
+ font-weight: 400;
|
|
|
+ color: #6d84a3;
|
|
|
+}
|
|
|
+.qb-chart {
|
|
|
+ height: 220px;
|
|
|
+ width: 100%;
|
|
|
+}
|
|
|
+/* 不良类型占比:环形中心合计文字用覆盖层定位,left 与饼图 center 保持一致 */
|
|
|
+.qb-pie-wrap {
|
|
|
+ position: relative;
|
|
|
+}
|
|
|
+.qb-pie-center {
|
|
|
+ position: absolute;
|
|
|
+ top: 50%;
|
|
|
+ left: 32%;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ align-items: center;
|
|
|
+ pointer-events: none;
|
|
|
+ transform: translate(-50%, -50%);
|
|
|
+}
|
|
|
+.qb-pie-center-label {
|
|
|
+ font-size: 11px;
|
|
|
+ color: #8aa0bd;
|
|
|
+}
|
|
|
+.qb-pie-center-value {
|
|
|
+ margin-top: 2px;
|
|
|
+ font-size: 16px;
|
|
|
+ font-weight: 600;
|
|
|
+ color: #cfe2ff;
|
|
|
+ font-family: 'DIN Alternate', Arial, sans-serif;
|
|
|
+}
|
|
|
+/* 三类检验卡片底部有累计统计条,图高相应收一点,保持与同行卡片等高 */
|
|
|
+.qb-chart-type,
|
|
|
+.qb-empty.qb-empty-type {
|
|
|
+ height: 186px;
|
|
|
+}
|
|
|
+.qb-type-foot {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: space-around;
|
|
|
+ height: 34px;
|
|
|
+ padding-top: 6px;
|
|
|
+ border-top: 1px dashed rgba(70, 130, 200, 0.18);
|
|
|
+ font-size: 12px;
|
|
|
+ color: #6d84a3;
|
|
|
+}
|
|
|
+.qb-type-foot i {
|
|
|
+ margin: 0 2px;
|
|
|
+ font-size: 15px;
|
|
|
+ font-style: normal;
|
|
|
+ font-weight: 600;
|
|
|
+ color: #4fd6ff;
|
|
|
+ font-family: 'DIN Alternate', Arial, sans-serif;
|
|
|
+}
|
|
|
+.qb-type-foot-split {
|
|
|
+ width: 1px;
|
|
|
+ height: 14px;
|
|
|
+ background: rgba(70, 130, 200, 0.25);
|
|
|
+}
|
|
|
+/* 不良品处置:左环形图 + 右横向进度条,整块固定 220px 与同行卡片等高 */
|
|
|
+.qb-disposal {
|
|
|
+ display: flex;
|
|
|
+ align-items: stretch;
|
|
|
+ height: 220px;
|
|
|
+}
|
|
|
+.qb-disposal-donut {
|
|
|
+ position: relative;
|
|
|
+ flex: 0 0 42%;
|
|
|
+ min-width: 0;
|
|
|
+}
|
|
|
+.qb-chart.qb-chart-donut {
|
|
|
+ height: 100%;
|
|
|
+}
|
|
|
+.qb-empty.qb-empty-donut {
|
|
|
+ height: 100%;
|
|
|
+}
|
|
|
+.qb-disposal-center {
|
|
|
+ position: absolute;
|
|
|
+ top: 0;
|
|
|
+ right: 0;
|
|
|
+ bottom: 0;
|
|
|
+ left: 0;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ pointer-events: none;
|
|
|
+}
|
|
|
+.qb-disposal-center-label {
|
|
|
+ font-size: 10px;
|
|
|
+ color: #6d84a3;
|
|
|
+}
|
|
|
+.qb-disposal-center-value {
|
|
|
+ font-size: 20px;
|
|
|
+ font-weight: 700;
|
|
|
+ color: #4fd6ff;
|
|
|
+ font-family: 'DIN Alternate', Arial, sans-serif;
|
|
|
+}
|
|
|
+.qb-disposal-center-value i {
|
|
|
+ margin-left: 1px;
|
|
|
+ font-size: 10px;
|
|
|
+ font-style: normal;
|
|
|
+ color: #8aa0bd;
|
|
|
+}
|
|
|
+.qb-disposal-list {
|
|
|
+ display: flex;
|
|
|
+ flex: 1;
|
|
|
+ flex-direction: column;
|
|
|
+ justify-content: center;
|
|
|
+ min-width: 0;
|
|
|
+ padding: 0 4px 0 6px;
|
|
|
+ /* 行宽不再超出容器,禁止横向滚动,避免出现难看的横向滚动条 */
|
|
|
+ overflow-x: hidden;
|
|
|
+ overflow-y: auto;
|
|
|
+ scrollbar-width: thin;
|
|
|
+ scrollbar-color: rgba(79, 182, 255, 0.45) transparent;
|
|
|
+}
|
|
|
+/* 纵向内容过多时才出现的细滚动条,与深色卡片统一 */
|
|
|
+.qb-disposal-list::-webkit-scrollbar {
|
|
|
+ width: 4px;
|
|
|
+}
|
|
|
+.qb-disposal-list::-webkit-scrollbar-track {
|
|
|
+ background: transparent;
|
|
|
+}
|
|
|
+.qb-disposal-list::-webkit-scrollbar-thumb {
|
|
|
+ background: rgba(79, 182, 255, 0.45);
|
|
|
+ border-radius: 2px;
|
|
|
+}
|
|
|
+.qb-disposal-list::-webkit-scrollbar-thumb:hover {
|
|
|
+ background: rgba(79, 182, 255, 0.7);
|
|
|
+}
|
|
|
+.qb-disposal-row {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ height: 20px;
|
|
|
+ flex: 0 0 20px;
|
|
|
+ padding: 0 4px;
|
|
|
+ font-size: 11px;
|
|
|
+ border-radius: 4px;
|
|
|
+ cursor: pointer;
|
|
|
+ transition: background 0.2s;
|
|
|
+}
|
|
|
+.qb-disposal-row:hover {
|
|
|
+ background: rgba(59, 167, 255, 0.1);
|
|
|
+}
|
|
|
+.qb-disposal-name {
|
|
|
+ flex: 0 0 40px;
|
|
|
+ overflow: hidden;
|
|
|
+ text-overflow: ellipsis;
|
|
|
+ white-space: nowrap;
|
|
|
+ color: #9db8d8;
|
|
|
+}
|
|
|
+.qb-disposal-count {
|
|
|
+ flex: 0 0 auto;
|
|
|
+ margin-left: 2px;
|
|
|
+ color: #cfe2ff;
|
|
|
+ font-weight: 600;
|
|
|
+ font-family: 'DIN Alternate', Arial, sans-serif;
|
|
|
+}
|
|
|
+.qb-disposal-track {
|
|
|
+ flex: 1;
|
|
|
+ height: 6px;
|
|
|
+ margin: 0 5px;
|
|
|
+ border-radius: 3px;
|
|
|
+ background: rgba(80, 140, 200, 0.18);
|
|
|
+ overflow: hidden;
|
|
|
+}
|
|
|
+.qb-disposal-track i {
|
|
|
+ display: block;
|
|
|
+ height: 100%;
|
|
|
+ min-width: 2px;
|
|
|
+ border-radius: 3px;
|
|
|
+ transition: width 0.6s ease;
|
|
|
+}
|
|
|
+.qb-disposal-rate {
|
|
|
+ flex: 0 0 38px;
|
|
|
+ text-align: right;
|
|
|
+ color: #6d84a3;
|
|
|
+ font-family: 'DIN Alternate', Arial, sans-serif;
|
|
|
+}
|
|
|
+.qb-empty {
|
|
|
+ height: 220px;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ color: #52698a;
|
|
|
+ font-size: 13px;
|
|
|
+}
|
|
|
+/* 流程节点区 + 底部总体完成率进度条,合计高度与同行 220px 图表等高 */
|
|
|
+.qb-flow {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: space-around;
|
|
|
+ height: 158px;
|
|
|
+}
|
|
|
+.qb-progress {
|
|
|
+ padding: 4px 6px 0;
|
|
|
+}
|
|
|
+.qb-progress-head {
|
|
|
+ display: flex;
|
|
|
+ align-items: baseline;
|
|
|
+ justify-content: space-between;
|
|
|
+ font-size: 12px;
|
|
|
+ color: #8aa0bd;
|
|
|
+}
|
|
|
+.qb-progress-value {
|
|
|
+ font-size: 16px;
|
|
|
+ font-weight: 700;
|
|
|
+ color: #4fd6ff;
|
|
|
+ font-family: 'DIN Alternate', Arial, sans-serif;
|
|
|
+}
|
|
|
+.qb-progress-value i {
|
|
|
+ margin-left: 1px;
|
|
|
+ font-size: 10px;
|
|
|
+ font-style: normal;
|
|
|
+ color: #8aa0bd;
|
|
|
+}
|
|
|
+.qb-progress-track {
|
|
|
+ position: relative;
|
|
|
+ height: 8px;
|
|
|
+ margin: 6px 0 5px;
|
|
|
+ border-radius: 4px;
|
|
|
+ background: rgba(80, 140, 200, 0.18);
|
|
|
+ overflow: hidden;
|
|
|
+}
|
|
|
+.qb-progress-bar {
|
|
|
+ height: 100%;
|
|
|
+ border-radius: 4px;
|
|
|
+ background: linear-gradient(90deg, #2a7fe0, #25d0b0);
|
|
|
+ transition: width 0.6s ease;
|
|
|
+}
|
|
|
+.qb-progress-foot {
|
|
|
+ font-size: 11px;
|
|
|
+ color: #6d84a3;
|
|
|
+ text-align: right;
|
|
|
+}
|
|
|
+.qb-flow-item {
|
|
|
+ position: relative;
|
|
|
+ flex: 1;
|
|
|
+ text-align: center;
|
|
|
+}
|
|
|
+.qb-flow-count {
|
|
|
+ font-size: 26px;
|
|
|
+ font-weight: 700;
|
|
|
+ color: #4fd6ff;
|
|
|
+}
|
|
|
+.qb-flow-name {
|
|
|
+ margin: 6px 0 2px;
|
|
|
+ font-size: 13px;
|
|
|
+ color: #b6c9e0;
|
|
|
+}
|
|
|
+.qb-flow-rate {
|
|
|
+ font-size: 12px;
|
|
|
+ color: #6d84a3;
|
|
|
+}
|
|
|
+.qb-flow-arrow {
|
|
|
+ position: absolute;
|
|
|
+ right: -6px;
|
|
|
+ top: 34%;
|
|
|
+ color: #35597f;
|
|
|
+}
|
|
|
+.qb-supplier-item {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ padding: 7px 6px;
|
|
|
+ margin: 0 -6px;
|
|
|
+ font-size: 13px;
|
|
|
+ border-radius: 6px;
|
|
|
+ cursor: pointer;
|
|
|
+ transition: background 0.2s;
|
|
|
+}
|
|
|
+.qb-supplier-item:hover {
|
|
|
+ background: rgba(59, 167, 255, 0.1);
|
|
|
+}
|
|
|
+/* 名次徽标:TOP1 ~ TOP5,药丸形(数字改文字后圆形容不下,等比放宽) */
|
|
|
+.qb-supplier-index {
|
|
|
+ flex: 0 0 auto;
|
|
|
+ min-width: 38px;
|
|
|
+ height: 18px;
|
|
|
+ padding: 0 5px;
|
|
|
+ line-height: 18px;
|
|
|
+ text-align: center;
|
|
|
+ border-radius: 9px;
|
|
|
+ background: rgba(80, 140, 200, 0.25);
|
|
|
+ color: #9db8d8;
|
|
|
+ font-size: 10px;
|
|
|
+ font-weight: 600;
|
|
|
+ letter-spacing: 0.5px;
|
|
|
+ font-family: 'DIN Alternate', Arial, sans-serif;
|
|
|
+ margin-right: 8px;
|
|
|
+ box-sizing: border-box;
|
|
|
+}
|
|
|
+.qb-supplier-index.top1 {
|
|
|
+ background: #ffb547;
|
|
|
+ color: #1a1205;
|
|
|
+}
|
|
|
+.qb-supplier-index.top2 {
|
|
|
+ background: #9fb4c9;
|
|
|
+ color: #141a22;
|
|
|
+}
|
|
|
+.qb-supplier-index.top3 {
|
|
|
+ background: #c98b52;
|
|
|
+ color: #1c1208;
|
|
|
+}
|
|
|
+.qb-supplier-name {
|
|
|
+ width: 96px;
|
|
|
+ overflow: hidden;
|
|
|
+ text-overflow: ellipsis;
|
|
|
+ white-space: nowrap;
|
|
|
+ color: #c3d5ea;
|
|
|
+}
|
|
|
+.qb-supplier-bar {
|
|
|
+ flex: 1;
|
|
|
+ height: 6px;
|
|
|
+ margin: 0 10px;
|
|
|
+ border-radius: 3px;
|
|
|
+ background: rgba(80, 140, 200, 0.18);
|
|
|
+ overflow: hidden;
|
|
|
+}
|
|
|
+.qb-supplier-bar i {
|
|
|
+ display: block;
|
|
|
+ height: 100%;
|
|
|
+ border-radius: 3px;
|
|
|
+ background: linear-gradient(90deg, #2b7cff, #25d0b0);
|
|
|
+}
|
|
|
+.qb-supplier-rate {
|
|
|
+ width: 52px;
|
|
|
+ text-align: right;
|
|
|
+ color: #4fd6ff;
|
|
|
+}
|
|
|
+.qb-alert-item {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ padding: 6px 0;
|
|
|
+ font-size: 12px;
|
|
|
+ border-bottom: 1px dashed rgba(70, 130, 200, 0.18);
|
|
|
+}
|
|
|
+.qb-alert-level {
|
|
|
+ flex-shrink: 0;
|
|
|
+ padding: 1px 6px;
|
|
|
+ margin-right: 8px;
|
|
|
+ border-radius: 3px;
|
|
|
+ font-size: 11px;
|
|
|
+ background: rgba(120, 140, 170, 0.25);
|
|
|
+ color: #a9bdd6;
|
|
|
+}
|
|
|
+.qb-alert-level.lv1 {
|
|
|
+ background: rgba(255, 107, 129, 0.2);
|
|
|
+ color: #ff8497;
|
|
|
+}
|
|
|
+.qb-alert-level.lv2 {
|
|
|
+ background: rgba(255, 181, 71, 0.2);
|
|
|
+ color: #ffc978;
|
|
|
+}
|
|
|
+.qb-alert-content {
|
|
|
+ flex: 1;
|
|
|
+ overflow: hidden;
|
|
|
+ text-overflow: ellipsis;
|
|
|
+ white-space: nowrap;
|
|
|
+ color: #b6c9e0;
|
|
|
+}
|
|
|
+.qb-alert-time {
|
|
|
+ margin-left: 8px;
|
|
|
+ color: #5f7796;
|
|
|
+}
|
|
|
+@media screen and (max-width: 1400px) {
|
|
|
+ .qb-kpi-row {
|
|
|
+ grid-template-columns: repeat(3, 1fr);
|
|
|
+ }
|
|
|
+ .qb-col-3,
|
|
|
+ .qb-col-4,
|
|
|
+ .qb-col-6 {
|
|
|
+ grid-column: span 6;
|
|
|
+ }
|
|
|
+}
|
|
|
+</style>
|
|
|
+
|
|
|
+<!-- 工厂下拉浮层挂在 body 上,scoped 样式无法命中,单独声明全局样式 -->
|
|
|
+<style>
|
|
|
+.qb-factory-popper {
|
|
|
+ border: 1px solid rgba(80, 140, 200, 0.35) !important;
|
|
|
+ background: #0f1a2c !important;
|
|
|
+}
|
|
|
+.qb-factory-popper .el-select-dropdown__item {
|
|
|
+ height: 30px;
|
|
|
+ line-height: 30px;
|
|
|
+ font-size: 12px;
|
|
|
+ color: #b6c9e0;
|
|
|
+}
|
|
|
+.qb-factory-popper .el-select-dropdown__item.hover,
|
|
|
+.qb-factory-popper .el-select-dropdown__item:hover {
|
|
|
+ background: rgba(80, 140, 200, 0.18);
|
|
|
+}
|
|
|
+.qb-factory-popper .el-select-dropdown__item.selected {
|
|
|
+ color: #25d0b0;
|
|
|
+ font-weight: 600;
|
|
|
+}
|
|
|
+.qb-factory-popper .popper__arrow,
|
|
|
+.qb-factory-popper .popper__arrow::after {
|
|
|
+ display: none;
|
|
|
+}
|
|
|
+</style>
|