lucw 1 неделя назад
Родитель
Сommit
4c6571c684

+ 22 - 4
src/api/aps/index.js

@@ -70,13 +70,31 @@ export async function warehousing(data) {
   return Promise.reject(new Error(res.data.message));
 }
 
-// 工厂列表
+// 工厂列表(兼容 { code, data } 包装与直接返回数组两种后端实现)
 export async function getFactoryList(params) {
   const res = await request.get(`/main/factoryarea/getFactoryList`, { params });
-  if (res.data.code == 0) {
-    return res.data.data;
+  const body = res.data;
+  if (Array.isArray(body)) {
+    return body;
   }
-  return Promise.reject(new Error(res.data.message));
+  if (body && body.code == 0) {
+    return body.data;
+  }
+  return Promise.reject(new Error((body && body.message) || '获取工厂列表失败'));
+}
+
+// 当前登录用户所属工厂ID(雪花ID以字符串返回,取不到时为 null)
+// 兼容:包装体 { code, data } 与直接返回裸值两种后端实现
+export async function getCurrentUserFactoryId() {
+  const res = await request.get(`/main/factoryarea/getCurrentUserFactoryId`);
+  const body = res.data;
+  if (body && typeof body === 'object') {
+    if (body.code == 0) {
+      return body.data;
+    }
+    return Promise.reject(new Error(body.message || '获取当前用户所属工厂失败'));
+  }
+  return body;
 }
 
 // aps生产计划获取产品的多个bom版本

+ 99 - 0
src/api/qmsDashboard/index.js

@@ -0,0 +1,99 @@
+import request from '@/utils/request';
+
+/**
+ * 质量管理看板接口
+ * 入参统一:{ timeType: 1本月 2本季度 3本年, startTime, endTime, qualityType, topN }
+ */
+async function post(url, data) {
+  const res = await request.post(url, data || {});
+  if (res.data.code == 0) {
+    return res.data.data;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
+
+// 首屏聚合(KPI + 8 个图表 + 预警列表)
+export function getOverview(data) {
+  return post('/qms/dashboard/overview', data);
+}
+
+// 核心 KPI
+export function getKpi(data) {
+  return post('/qms/dashboard/kpi', data);
+}
+
+// 三类检验分布
+export function getQualityTypeDist(data) {
+  return post('/qms/dashboard/qualityTypeDist', data);
+}
+
+// 合格率 / 不良率趋势
+export function getTrend(data) {
+  return post('/qms/dashboard/trend', data);
+}
+
+// 不良类型分布
+export function getDefectTypeDist(data) {
+  return post('/qms/dashboard/defectTypeDist', data);
+}
+
+/**
+ * 不良类型占比:复用质量首页接口
+ * 入参 { factoryId, createTimeStart, createTimeEnd },时间格式 yyyy-MM(不传默认当年)
+ * 后端为 @RequestParam,参数必须走 query,不能放在 JSON body
+ */
+export async function getDefectTypeProportion(data) {
+  const res = await request({
+    url: '/qms/quality_work_order/queryProportionDefectiveTypes',
+    method: 'post',
+    params: data || {}
+  });
+  if (res.data.code == 0) {
+    return res.data.data;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
+
+// 记录方法分布(原检验方式分布)
+export function getRecordingMethodDist(data) {
+  return post('/qms/dashboard/recordingMethodDist', data);
+}
+
+// 检验流程状态
+export function getFlowStatus(data) {
+  return post('/qms/dashboard/flowStatus', data);
+}
+
+// 供应商来料质量排名
+export function getSupplierRank(data) {
+  return post('/qms/dashboard/supplierRank', data);
+}
+
+// 不良品处置分布
+export function getDisposalDist(data) {
+  return post('/qms/dashboard/disposalDist', data);
+}
+
+// 预警消息列表(消息推送记录)
+export function getAlertList(data) {
+  return post('/qms/dashboard/alertList', data);
+}
+
+/**
+ * 工厂 / 班组 / 产线合格率:复用质量首页既有接口
+ * 入参 { timeType: 1本周(近7天) 2本月(近31天) 3本年(近12个月) },注意与看板主 timeType(本季度) 语义不同
+ */
+// 工厂合格率
+export function getFactoryRate(data) {
+  return post('/qms/quality_work_order/queryFactory', data);
+}
+
+// 班组合格率
+export function getTeamRate(data) {
+  return post('/qms/quality_work_order/queryTeam', data);
+}
+
+// 产线合格率
+export function getProductionLineRate(data) {
+  return post('/qms/quality_work_order/queryProductionLine', data);
+}

+ 58 - 11
src/views/inspectionWork/components/sampleListDialog.vue

@@ -43,6 +43,13 @@
               :item-size="64"
               key-field="__vsKey"
               :buffer="200"
+              :style="{
+                height:
+                  Math.min(
+                    (props.row.qualitySampleTemplateList || []).length * 64,
+                    400
+                  ) + 'px'
+              }"
               v-if="(props.row.qualitySampleTemplateList || []).length"
             >
               <template #default="{ item }">
@@ -311,12 +318,55 @@
         scrollBound: false, // 滚动监听是否已绑定
         resizeObserver: null, // 监听 dialog body 大小变化
         isFullscreen: false, // 当前是否处于全屏
-        DEFAULT_TABLE_HEIGHT: 560, // 默认(非全屏)表格高度
-        tableHeight: 560, // 当前表格高度(全屏时由 recomputeTableHeight 重算)
+        DEFAULT_TABLE_HEIGHT: 560, // 默认(非全屏)表格最大高度
+        MAX_TABLE_HEIGHT: 560, // 硬上限:非全屏和全屏都受此限制
+        _fullscreenTableHeight: 560, // 全屏时由 recomputeTableHeight 重算(dialog body 高度)
         PRELOAD_COUNT: 3 // 预加载样品数(打开弹框时立即加载,滚动时永驻内存不卸载)
       };
     },
-    computed: {},
+    computed: {
+      // 表格高度:
+      //   - 非全屏:取"内容自然高度"和默认上限的较小者
+      //   - 全屏:取 dialog body 高度(recomputeTableHeight 重算)
+      //   - 两种情况都受 MAX_TABLE_HEIGHT 硬上限约束(避免 dialog body 很高时表格过大)
+      tableHeight() {
+        const cap = Math.min(
+          this.isFullscreen
+            ? this._fullscreenTableHeight
+            : this.DEFAULT_TABLE_HEIGHT,
+          this.MAX_TABLE_HEIGHT
+        );
+        // 最小 200,避免空数据/异常情况下表格太矮
+        return Math.max(200, Math.min(cap, this.naturalContentHeight));
+      },
+      // 估算 tableData 实际占的高度(el-table 的 :height 需要精确数字)
+      // 注意:内层 RecycleScroller 现在是动态高度(按模板数自适应,封顶 400),
+      // 这里要保持一致,否则估算会比实际大、表格会撑到 cap 而不是收缩
+      naturalContentHeight() {
+        const TABLE_HEADER = 44;       // el-table 表头
+        const MAIN_ROW = 48;            // 主行
+        const EXPAND_HEADER = 40;       // virtual-table-header(包含 padding)
+        const EXPAND_WRAPPER_PAD = 20;  // virtual-expand-wrapper 的上下 padding
+        const EXPAND_EMPTY = 56;        // "暂无检测项" 占位(padding 20 + 文本)
+        const SCROLLER_MAX = 400;       // RecycleScroller 高度封顶
+        const SCROLLER_ITEM = 64;       // 单个模板行高度
+
+        let h = TABLE_HEADER;
+        for (const row of this.tableData) {
+          h += MAIN_ROW;
+          const key = row.sampleCode || row.__rowKey;
+          const expanded = this.expandedKeys.includes(key);
+          if (expanded) {
+            const tplLen = (row.qualitySampleTemplateList || []).length;
+            h += EXPAND_HEADER + EXPAND_WRAPPER_PAD;
+            h += tplLen > 0
+              ? Math.min(tplLen * SCROLLER_ITEM, SCROLLER_MAX)
+              : EXPAND_EMPTY;
+          }
+        }
+        return h;
+      }
+    },
 
     created() {},
     beforeDestroy() {
@@ -370,7 +420,6 @@
           this.bindScrollListener();
           // 重置全屏状态并重新计算表格高度(防止上次打开残留全屏)
           this.isFullscreen = false;
-          this.tableHeight = this.DEFAULT_TABLE_HEIGHT;
           this.observeBodyResize();
         });
       },
@@ -417,14 +466,12 @@
         });
       },
 
-      // 重新计算表格高度:全屏时填满 dialog body,非全屏回到默认值
+      // 重新计算表格高度:仅全屏时需要——把 dialog body 高度写进 _fullscreenTableHeight;
+      // 非全屏由 computed tableHeight 直接用 DEFAULT_TABLE_HEIGHT,无需重算
       recomputeTableHeight() {
+        if (!this.isFullscreen) return;
         const tableEl = this.$refs.mainTable && this.$refs.mainTable.$el;
         if (!tableEl) return;
-        if (!this.isFullscreen) {
-          this.tableHeight = this.DEFAULT_TABLE_HEIGHT;
-          return;
-        }
         // 全屏:沿 DOM 找到 .el-dialog,再取 .el-dialog__body 的内容区高度
         // (不用 .ele-modal-wrap 这个 class——实际类名是 ele-modal-wrap-fullscreen)
         const dialog = tableEl.closest('.el-dialog');
@@ -434,7 +481,7 @@
         const style = getComputedStyle(body);
         const padTop = parseFloat(style.paddingTop) || 0;
         const padBottom = parseFloat(style.paddingBottom) || 0;
-        this.tableHeight = body.clientHeight - padTop - padBottom;
+        this._fullscreenTableHeight = body.clientHeight - padTop - padBottom;
       },
 
       // 监听 dialog body 尺寸变化(拖拽 / resize / 全屏切换都会触发)
@@ -720,7 +767,7 @@
   }
 
   .virtual-scroller {
-    height: 400px;
+    /* 高度由 :style 动态控制(按模板数量自适应,封顶 400) */
     border: 1px solid #ebeef5;
     border-top: none;
     background: #fff;

+ 673 - 0
src/views/qualityBoard/components/DrillDialog.vue

@@ -0,0 +1,673 @@
+<template>
+  <el-dialog
+    :visible="visible"
+    :show-close="false"
+    :close-on-click-modal="true"
+    append-to-body
+    top="6vh"
+    width="86%"
+    custom-class="qb-drill-dialog"
+    @close="handleClose"
+  >
+    <!-- 头部:标题 + 口径说明 + 导出 / 关闭 -->
+    <div slot="title" class="qb-drill-head">
+      <div class="qb-drill-head-left">
+        <span class="qb-drill-title">
+          <i class="qb-drill-title-bar"></i>
+          {{ config.title }}
+        </span>
+        <span class="qb-drill-source">{{ config.source }}</span>
+        <!-- 继承看板顶部工厂筛选时给出显式提示,避免误读为全厂数据 -->
+        <span v-if="factoryName" class="qb-drill-factory">工厂:{{ factoryName }}</span>
+      </div>
+      <div class="qb-drill-head-right">
+        <button class="qb-drill-btn" :disabled="exporting || loading" @click="exportCsv">
+          <i :class="exporting ? 'el-icon-loading' : 'el-icon-download'"></i>
+          {{ exporting ? '导出中…' : '导出 Excel' }}
+        </button>
+        <button type="button" class="qb-drill-btn qb-drill-btn-close" title="关闭" @click="handleClose">
+          <i class="el-icon-close"></i> 关闭
+        </button>
+      </div>
+    </div>
+
+    <div class="qb-drill-body">
+      <!-- 筛选区 -->
+      <form class="qb-drill-filters" @submit.prevent="handleSearch">
+        <div v-for="item in config.filters" :key="item.prop" class="qb-drill-field" :style="{ width: (item.width || 160) + 'px' }">
+          <span class="qb-drill-label">{{ item.label }}</span>
+          <el-select
+            v-if="item.type === 'select'"
+            v-model="where[item.prop]"
+            size="small"
+            clearable
+            placeholder="全部"
+            popper-class="qb-drill-popper"
+            class="qb-drill-control"
+          >
+            <el-option v-for="opt in filterOptions(item)" :key="opt.value" :label="opt.label" :value="opt.value" />
+          </el-select>
+          <el-input
+            v-else
+            v-model="where[item.prop]"
+            size="small"
+            clearable
+            :placeholder="`请输入${item.label}`"
+            class="qb-drill-control"
+          />
+        </div>
+
+        <div class="qb-drill-field qb-drill-field-date">
+          <span class="qb-drill-label">{{ config.dateLabel || '创建时间' }}</span>
+          <el-date-picker
+            v-model="range"
+            type="datetimerange"
+            size="small"
+            range-separator="至"
+            start-placeholder="开始时间"
+            end-placeholder="结束时间"
+            value-format="yyyy-MM-dd HH:mm:ss"
+            :default-time="['00:00:00', '23:59:59']"
+            popper-class="qb-drill-popper"
+            class="qb-drill-range"
+          />
+        </div>
+
+        <div class="qb-drill-actions">
+          <button type="submit" class="qb-drill-btn qb-drill-btn-primary" :disabled="loading">
+            <i class="el-icon-search"></i> 查询
+          </button>
+          <button type="button" class="qb-drill-btn" @click="handleReset">
+            <i class="el-icon-refresh-left"></i> 重置
+          </button>
+        </div>
+      </form>
+
+      <!-- 表格 -->
+      <div class="qb-drill-table">
+        <el-table
+          v-loading="loading"
+          element-loading-text="加载中…"
+          element-loading-background="rgba(8, 18, 36, 0.65)"
+          :data="list"
+          :height="tableHeight"
+          size="mini"
+          border
+          stripe
+          empty-text="暂无明细数据,可调整筛选条件后重试"
+          row-key="id"
+        >
+          <el-table-column
+            v-for="col in config.columns"
+            :key="col.prop"
+            :prop="col.prop"
+            :label="col.label"
+            :width="col.width"
+            :min-width="col.width ? undefined : 110"
+            :fixed="col.fixed"
+            show-overflow-tooltip
+          >
+            <template slot-scope="{ row }">
+              <el-tag v-if="col.tag" size="mini" effect="dark" :type="col.tag(row)" class="qb-drill-tag">
+                {{ displayText(col, row) }}
+              </el-tag>
+              <span v-else>{{ displayText(col, row) }}</span>
+            </template>
+          </el-table-column>
+        </el-table>
+      </div>
+
+      <!-- 底部分页 -->
+      <div class="qb-drill-foot">
+        <span class="qb-drill-total">共 <i>{{ total }}</i> 条</span>
+        <el-pagination
+          background
+          layout="sizes, prev, pager, next, jumper"
+          :total="total"
+          :current-page="page"
+          :page-size="size"
+          :page-sizes="[20, 50, 100]"
+          @current-change="handlePageChange"
+          @size-change="handleSizeChange"
+        />
+      </div>
+    </div>
+  </el-dialog>
+</template>
+
+<script>
+import dictMixins from '@/mixins/dictMixins';
+import { DRILL_CONFIG, toTimestamp } from './drillConfig';
+
+const EXPORT_PAGE_SIZE = 200;
+const EXPORT_MAX = 5000;
+
+export default {
+  name: 'QbDrillDialog',
+  mixins: [dictMixins],
+  props: {
+    visible: { type: Boolean, default: false },
+    drillKey: { type: String, default: '' },
+    timeRange: { type: Array, default: () => [] },
+    /** 图表点击带入的附加条件,如 { qualityType: 1 } / { supplierName: 'xx' } */
+    extraWhere: { type: Object, default: () => ({}) },
+    /** 看板顶部选中的工厂名称,为空表示全部工厂 */
+    factoryName: { type: String, default: '' }
+  },
+  data() {
+    return {
+      where: {},
+      range: [],
+      list: [],
+      total: 0,
+      page: 1,
+      size: 20,
+      loading: false,
+      exporting: false,
+      reqSeq: 0,
+      tableHeight: 420
+    };
+  },
+  computed: {
+    config() {
+      return DRILL_CONFIG[this.drillKey] || { title: '明细', source: '', filters: [], columns: [], defaultWhere: {} };
+    },
+    dictCtx() {
+      return { dictName: (name, code) => this.getDictName(name, code) };
+    }
+  },
+  watch: {
+    visible(val) {
+      if (val) {
+        this.initDialog();
+      }
+    }
+  },
+  mounted() {
+    this.resize();
+    window.addEventListener('resize', this.resize);
+  },
+  beforeDestroy() {
+    window.removeEventListener('resize', this.resize);
+  },
+  methods: {
+    resize() {
+      const height = (window.innerHeight || 900) - 350;
+      this.tableHeight = Math.max(260, Math.min(height, 680));
+    },
+    /** 打开弹窗:重置筛选、带入看板时间范围、拉取第一页 */
+    initDialog() {
+      this.resetWhere();
+      this.range = Array.isArray(this.timeRange) && this.timeRange.length === 2 ? [...this.timeRange] : [];
+      this.page = 1;
+      this.list = [];
+      this.total = 0;
+      (this.config.dicts || []).forEach((name) => this.requestDict(name));
+      this.fetchList();
+    },
+    /** 关闭时清空列表,避免下次打开闪现旧数据 */
+    handleClose() {
+      this.reqSeq += 1;
+      this.loading = false;
+      this.exporting = false;
+      this.list = [];
+      this.total = 0;
+      this.$emit('update:visible', false);
+    },
+    /** 重置筛选:合并配置默认条件与图表点击带入的附加条件,并预置响应式 key */
+    resetWhere() {
+      const where = {
+        ...JSON.parse(JSON.stringify(this.config.defaultWhere || {})),
+        ...JSON.parse(JSON.stringify(this.extraWhere || {}))
+      };
+      (this.config.filters || []).forEach((item) => {
+        if (where[item.prop] === undefined) where[item.prop] = '';
+      });
+      this.where = where;
+    },
+    filterOptions(filter) {
+      if (filter.options) return filter.options;
+      if (!filter.dict) return [];
+      return (this.dict[this.dictEnum[filter.dict]] || []).map((item) => ({
+        label: item.dictValue,
+        value: item.dictCode
+      }));
+    },
+    /** 单元格显示值:字典 > 自定义格式化 > 原始值 */
+    displayText(col, row) {
+      if (col.dict) return this.getDictName(col.dict, row[col.prop]);
+      if (typeof col.formatter === 'function') return col.formatter(row, this.dictCtx);
+      const value = row[col.prop];
+      return value === null || value === undefined ? '' : value;
+    },
+    /** 去掉空值,避免把空字符串传给后端做模糊查询 */
+    buildParams(extra = {}) {
+      const params = {};
+      Object.keys(this.where || {}).forEach((key) => {
+        const value = this.where[key];
+        if (value !== '' && value !== null && value !== undefined) params[key] = value;
+      });
+      if (Array.isArray(this.range) && this.range.length === 2) {
+        const [start, end] = this.range;
+        if (start && end) {
+          if (this.config.dateMode === 'timestamp') {
+            params.startTime = toTimestamp(start);
+            params.endTime = toTimestamp(end);
+          } else {
+            params.startTime = start;
+            params.endTime = end;
+          }
+        }
+      }
+      return { ...params, ...extra };
+    },
+    async fetchList() {
+      this.reqSeq += 1;
+      const seq = this.reqSeq;
+      this.loading = true;
+      try {
+        const res = await this.config.request(this.buildParams({ pageNum: this.page, size: this.size }));
+        if (seq !== this.reqSeq) return;
+        this.list = (res && (res.list || res.records)) || [];
+        this.total = Number((res && (res.total || res.count)) || 0);
+      } catch (e) {
+        if (seq === this.reqSeq) {
+          this.list = [];
+          this.total = 0;
+          this.$message.error((e && e.message) || '明细数据加载失败');
+        }
+      } finally {
+        if (seq === this.reqSeq) this.loading = false;
+      }
+    },
+    handleSearch() {
+      this.page = 1;
+      this.fetchList();
+    },
+    handleReset() {
+      this.resetWhere();
+      this.range = Array.isArray(this.timeRange) && this.timeRange.length === 2 ? [...this.timeRange] : [];
+      this.page = 1;
+      this.fetchList();
+    },
+    handlePageChange(page) {
+      this.page = page;
+      this.fetchList();
+    },
+    handleSizeChange(size) {
+      this.size = size;
+      this.page = 1;
+      this.fetchList();
+    },
+    /** 按当前筛选条件分页拉全量,前端生成 CSV(带 BOM,Excel 打开不乱码) */
+    async exportCsv() {
+      if (this.exporting) return;
+      this.exporting = true;
+      try {
+        const rows = [];
+        let pageNum = 1;
+        let total = 0;
+        do {
+          const res = await this.config.request(this.buildParams({ pageNum, size: EXPORT_PAGE_SIZE }));
+          const pageList = (res && (res.list || res.records)) || [];
+          total = Number((res && (res.total || res.count)) || 0);
+          rows.push(...pageList);
+          pageNum += 1;
+        } while (rows.length < total && rows.length < EXPORT_MAX && pageNum <= 30);
+
+        const header = this.config.columns.map((col) => this.csvCell(col.label)).join(',');
+        const body = rows.map((row) =>
+          this.config.columns.map((col) => this.csvCell(this.displayText(col, row))).join(',')
+        );
+        const csv = [header, ...body].join('\r\n');
+        const blob = new Blob([`\ufeff${csv}`], { type: 'text/csv;charset=utf-8;' });
+        const link = document.createElement('a');
+        link.href = URL.createObjectURL(blob);
+        link.download = `${this.config.title}_${this.exportStamp()}.csv`;
+        document.body.appendChild(link);
+        link.click();
+        document.body.removeChild(link);
+        URL.revokeObjectURL(link.href);
+        this.$message.success(`已导出 ${rows.length} 条明细`);
+      } catch (e) {
+        this.$message.error((e && e.message) || '导出失败,请稍后重试');
+      } finally {
+        this.exporting = false;
+      }
+    },
+    csvCell(value) {
+      const text = value === null || value === undefined ? '' : String(value);
+      return `"${text.replace(/"/g, '""')}"`;
+    },
+    exportStamp() {
+      const now = new Date();
+      const pad = (num) => String(num).padStart(2, '0');
+      return `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}${pad(now.getHours())}${pad(now.getMinutes())}`;
+    }
+  }
+};
+</script>
+
+<style>
+/* append-to-body 后 scoped 失效,统一以 .qb-drill-dialog / .qb-drill-popper 收窄作用域 */
+.qb-drill-dialog.el-dialog {
+  border: 1px solid #2a4d92;
+  border-radius: 10px;
+  background: radial-gradient(circle at 12% 0%, #16304f 0%, #0c1c42 46%, #0a1730 100%);
+  box-shadow: 0 24px 60px rgba(0, 0, 0, 0.55);
+  overflow: hidden;
+}
+.qb-drill-dialog .el-dialog__header {
+  position: relative;
+  padding: 0;
+  border-bottom: 1px solid rgba(59, 167, 255, 0.18);
+}
+.qb-drill-dialog .el-dialog__body {
+  padding: 0;
+  color: #dbe7f7;
+}
+.qb-drill-head {
+  display: flex;
+  align-items: center;
+  width: 100%;
+  box-sizing: border-box;
+  /* 右侧预留按钮组宽度,标题过长时自动换行,不会压住按钮 */
+  padding: 14px 240px 14px 18px;
+}
+.qb-drill-head-left {
+  display: flex;
+  flex: 1 1 auto;
+  flex-direction: column;
+  min-width: 0;
+}
+.qb-drill-title {
+  display: flex;
+  align-items: center;
+  font-size: 18px;
+  font-weight: 600;
+  letter-spacing: 1px;
+  color: #eaf4ff;
+}
+.qb-drill-title-bar {
+  width: 4px;
+  height: 16px;
+  margin-right: 8px;
+  border-radius: 2px;
+  background: linear-gradient(180deg, #4fb6ff, #25d0b0);
+}
+.qb-drill-source {
+  margin-top: 4px;
+  font-size: 12px;
+  color: #8aa0bd;
+}
+/* 工厂筛选提示:与标题同一行,用边框胶囊与口径说明区分 */
+.qb-drill-factory {
+  margin-top: 4px;
+  margin-left: 10px;
+  padding: 1px 10px;
+  font-size: 12px;
+  color: #7fe3c8;
+  border: 1px solid rgba(37, 208, 176, 0.45);
+  border-radius: 10px;
+}
+/* 导出 / 关闭按钮组:绝对定位到标题栏右上角,不受标题宽度影响 */
+.qb-drill-head-right {
+  position: absolute;
+  top: 50%;
+  right: 18px;
+  z-index: 2;
+  display: flex;
+  align-items: center;
+  transform: translateY(-50%);
+}
+.qb-drill-btn {
+  display: inline-flex;
+  align-items: center;
+  padding: 6px 14px;
+  font-size: 12px;
+  color: #cfe2ff;
+  background: rgba(59, 167, 255, 0.12);
+  border: 1px solid rgba(59, 167, 255, 0.5);
+  border-radius: 6px;
+  cursor: pointer;
+  transition: all 0.2s;
+}
+.qb-drill-btn i {
+  margin-right: 4px;
+}
+.qb-drill-btn:hover:not(:disabled) {
+  color: #fff;
+  background: rgba(59, 167, 255, 0.26);
+  border-color: #3ba7ff;
+  transform: translateY(-1px);
+}
+.qb-drill-btn:disabled {
+  opacity: 0.55;
+  cursor: not-allowed;
+}
+.qb-drill-btn-primary {
+  color: #fff;
+  background: linear-gradient(90deg, #2b7cff, #25d0b0);
+  border-color: transparent;
+  font-weight: 600;
+}
+/* 右上角关闭按钮 */
+.qb-drill-btn-close {
+  justify-content: center;
+  min-width: 74px;
+  margin-left: 10px;
+  padding: 7px 16px;
+  color: #ff8fa0;
+  background: rgba(255, 107, 129, 0.1);
+  border-color: rgba(255, 107, 129, 0.45);
+}
+.qb-drill-btn-close:hover:not(:disabled) {
+  color: #fff;
+  background: rgba(255, 107, 129, 0.28);
+  border-color: #ff6b81;
+}
+.qb-drill-body {
+  padding: 0 18px 12px;
+}
+.qb-drill-filters {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: center;
+  gap: 8px 10px;
+  padding: 12px 0;
+  border-bottom: 1px dashed rgba(59, 167, 255, 0.16);
+}
+.qb-drill-field {
+  display: flex;
+  align-items: center;
+  min-width: 0;
+}
+.qb-drill-label {
+  flex: 0 0 auto;
+  margin-right: 6px;
+  font-size: 12px;
+  color: #8aa0bd;
+  white-space: nowrap;
+}
+.qb-drill-control {
+  flex: 1;
+  min-width: 0;
+}
+.qb-drill-field-date {
+  width: 360px;
+}
+.qb-drill-range {
+  flex: 1;
+  min-width: 0;
+}
+.qb-drill-actions {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  margin-left: auto;
+}
+.qb-drill-table {
+  margin-top: 10px;
+}
+.qb-drill-foot {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding-top: 10px;
+}
+.qb-drill-total {
+  font-size: 12px;
+  color: #8aa0bd;
+}
+.qb-drill-total i {
+  font-style: normal;
+  font-weight: 700;
+  color: #4fb6ff;
+}
+
+.qb-drill-tag {
+  border: none;
+}
+/* 输入控件深色化 */
+.qb-drill-dialog .el-input__inner,
+.qb-drill-dialog .el-range-input,
+.qb-drill-dialog .el-range-separator {
+  background: #0e1a30;
+  color: #dbe7f7;
+  border-color: rgba(42, 77, 146, 0.9);
+  font-size: 12px;
+}
+.qb-drill-dialog .el-range-editor.el-input__inner {
+  background: #0e1a30;
+  border-color: rgba(42, 77, 146, 0.9);
+}
+.qb-drill-dialog .el-input__inner:focus,
+.qb-drill-dialog .el-range-editor.is-active {
+  border-color: #3ba7ff;
+  box-shadow: 0 0 0 2px rgba(59, 167, 255, 0.16);
+}
+.qb-drill-dialog .el-range-editor.is-active .el-range-input {
+  background: #0e1a30;
+}
+.qb-drill-dialog .el-range-editor .el-range__icon,
+.qb-drill-dialog .el-range-editor .el-range__close-icon {
+  color: #54708f;
+}
+/* 表格深色化 */
+.qb-drill-dialog .el-table {
+  background: transparent;
+  color: #cfe2ff;
+  font-size: 12px;
+}
+.qb-drill-dialog .el-table::before {
+  background-color: rgba(42, 77, 146, 0.5);
+}
+.qb-drill-dialog .el-table th.el-table__cell {
+  background: #10233f;
+  color: #8aa0bd;
+  font-weight: 600;
+  font-size: 12px;
+  border-bottom-color: rgba(42, 77, 146, 0.6);
+}
+.qb-drill-dialog .el-table tr {
+  background: transparent;
+}
+.qb-drill-dialog .el-table td.el-table__cell {
+  border-bottom-color: rgba(42, 77, 146, 0.35);
+  background: transparent;
+}
+.qb-drill-dialog .el-table--striped .el-table__body tr.el-table__row--striped td.el-table__cell {
+  background: rgba(59, 167, 255, 0.04);
+}
+.qb-drill-dialog .el-table__body tr:hover > td.el-table__cell {
+  background: rgba(59, 167, 255, 0.12) !important;
+}
+.qb-drill-dialog .el-table__empty-text {
+  color: #54708f;
+  font-size: 12px;
+}
+.qb-drill-dialog .el-table__fixed-right::before,
+.qb-drill-dialog .el-table__fixed::before {
+  background-color: rgba(42, 77, 146, 0.5);
+}
+/* 分页深色化 */
+.qb-drill-dialog .el-pagination {
+  padding: 0;
+  color: #8aa0bd;
+  font-size: 12px;
+}
+.qb-drill-dialog .el-pagination.is-background .btn-prev,
+.qb-drill-dialog .el-pagination.is-background .btn-next,
+.qb-drill-dialog .el-pagination.is-background .el-pager li {
+  background: rgba(59, 167, 255, 0.08);
+  color: #8aa0bd;
+  border: 1px solid rgba(42, 77, 146, 0.6);
+}
+.qb-drill-dialog .el-pagination.is-background .el-pager li:not(.disabled).active {
+  background: linear-gradient(90deg, #2b7cff, #25d0b0);
+  color: #fff;
+  border-color: transparent;
+}
+.qb-drill-dialog .el-pagination .el-select .el-input .el-input__inner,
+.qb-drill-dialog .el-pagination__editor.el-input .el-input__inner {
+  background: #0e1a30;
+  color: #dbe7f7;
+  border-color: rgba(42, 77, 146, 0.9);
+}
+/* 下拉面板挂载在 body,单独收窄 */
+.qb-drill-popper.el-select-dropdown,
+.qb-drill-popper.el-picker-panel {
+  background: #0e1a30;
+  border-color: #2a4d92;
+  color: #dbe7f7;
+}
+.qb-drill-popper .el-select-dropdown__item {
+  color: #cfe2ff;
+  font-size: 12px;
+}
+.qb-drill-popper .el-select-dropdown__item.hover,
+.qb-drill-popper .el-select-dropdown__item:hover {
+  background: rgba(59, 167, 255, 0.14);
+}
+.qb-drill-popper .el-select-dropdown__item.selected {
+  color: #4fb6ff;
+}
+.qb-drill-popper .el-date-table th {
+  color: #8aa0bd;
+}
+.qb-drill-popper .el-date-table td.available:hover,
+.qb-drill-popper .el-date-table td.today span {
+  color: #4fb6ff;
+}
+.qb-drill-popper .el-date-table td.in-range div,
+.qb-drill-popper .el-date-table td.in-range div:hover {
+  background: rgba(59, 167, 255, 0.16);
+}
+.qb-drill-popper .el-picker-panel__icon-btn,
+.qb-drill-popper .el-date-picker__header-label,
+.qb-drill-popper .el-date-range-picker__header div {
+  color: #cfe2ff;
+}
+.qb-drill-popper .el-picker-panel__footer {
+  background: #0c1c42;
+  border-top-color: rgba(42, 77, 146, 0.5);
+}
+.qb-drill-popper .el-button--text {
+  color: #4fb6ff;
+}
+.qb-drill-popper .el-time-panel {
+  background: #0e1a30;
+  border-color: #2a4d92;
+}
+.qb-drill-popper .el-time-spinner__item {
+  color: #cfe2ff;
+}
+@media (max-width: 1280px) {
+  .qb-drill-field {
+    width: 160px !important;
+  }
+  .qb-drill-field-date {
+    width: 320px !important;
+  }
+}
+</style>

+ 249 - 0
src/views/qualityBoard/components/drillConfig.js

@@ -0,0 +1,249 @@
+import { getList as getWorkOrderList } from '@/api/inspectionWork';
+import { getList as getUnqualifiedList } from '@/api/unacceptedProduct';
+import { reviewStatus } from '@/enum/dict';
+import { recordingMethodList } from '@/utils/util';
+
+/* 检测工单状态:与列表页 inspectionWorkList 保持一致 */
+const WORK_ORDER_STATUS = [
+  { label: '未报工', value: 0 },
+  { label: '已报工', value: 1 },
+  { label: '已关闭', value: 2 },
+  { label: '待请样', value: 3 }
+];
+const WORK_ORDER_STATUS_TAG = { 0: 'warning', 1: 'success', 2: 'info', 3: '' };
+
+/* 质检报告:后端 reportStatus 0 未生成 / 1 已生成 */
+const REPORT_STATUS = [
+  { label: '未生成', value: 0 },
+  { label: '已生成', value: 1 }
+];
+
+/* 不良品处置状态:0 待处置 / 1 部分处置 / 2 处置完成 */
+const DISPOSE_TAG = { 0: 'warning', 1: '', 2: 'success' };
+const APPROVAL_TAG = { 0: 'info', 1: 'warning', 2: 'success', 3: 'danger' };
+
+/** 处置方式下拉(字典不可用时兜底) */
+const disposeOptions = [
+  { label: '待处置', value: 0 },
+  { label: '部分处置', value: 1 },
+  { label: '处置完成', value: 2 }
+];
+
+/** 记录方法:复用列表页常量,并补上后端可能返回的“单样自定义”(3),避免数值直接显示 */
+const recordingOptions = [...recordingMethodList, { label: '单样自定义', value: 3 }];
+
+/** 检测工单列表列(字段均来自 QualityWorkOrderVO,缺失字段已剔除) */
+const WORK_ORDER_COLUMNS = [
+  { prop: 'code', label: '检测工单编码', width: 170, fixed: 'left' },
+  { prop: 'name', label: '检测工单名称', width: 130 },
+  { prop: 'sourceCode', label: '来源单号', width: 150, formatter: (row) => row.planSourceCode || row.workOrderCode || '' },
+  { prop: 'qualityType', label: '类型', width: 110, dict: '质检计划类型' },
+  { prop: 'qualityMode', label: '检测方式', width: 100, dict: '取样类型' },
+  { prop: 'recordingMethod', label: '记录方法', width: 110, formatter: (row) => (recordingOptions.find((item) => item.value == row.recordingMethod) || {}).label || '' },
+  { prop: 'productCode', label: '物品编码', width: 120 },
+  { prop: 'productName', label: '物品名称', width: 130 },
+  { prop: 'specification', label: '规格', width: 110 },
+  { prop: 'brandNo', label: '牌号', width: 90 },
+  { prop: 'colorKey', label: '颜色', width: 80 },
+  { prop: 'batchNo', label: '批次号', width: 130 },
+  { prop: 'supplierName', label: '供应商', width: 150 },
+  { prop: 'inspectionTeamName', label: '送检班组', width: 110 },
+  { prop: 'createUserName', label: '送检人', width: 90 },
+  { prop: 'produceTaskName', label: '工序', width: 110 },
+  { prop: 'total', label: '总数量', width: 80 },
+  { prop: 'qualifiedNumber', label: '合格数', width: 80 },
+  { prop: 'qualificationRate', label: '合格率', width: 80 },
+  { prop: 'noQualifiedNumber', label: '不合格数', width: 90 },
+  { prop: 'noQualificationRate', label: '不良率', width: 80 },
+  { prop: 'hours', label: '工时', width: 70 },
+  { prop: 'qualityName', label: '接收人', width: 90 },
+  { prop: 'executeUserName', label: '执行人', width: 90 },
+  { prop: 'qualityTime', label: '检测时间', width: 145 },
+  {
+    prop: 'status',
+    label: '状态',
+    width: 90,
+    tag: (row) => WORK_ORDER_STATUS_TAG[row.status] || 'info',
+    formatter: (row) => (WORK_ORDER_STATUS.find((item) => item.value == row.status) || {}).label || ''
+  },
+  { prop: 'createTime', label: '创建时间', width: 145, fixed: 'right' }
+];
+
+/** 一次通过:只保留与“批次结论”相关的列,避免与完成率弹窗完全重复 */
+const FIRST_PASS_COLUMNS = WORK_ORDER_COLUMNS.filter((col) =>
+  ['code', 'batchNo', 'qualityType', 'qualityMode', 'productCode', 'productName', 'specification', 'total', 'qualifiedNumber', 'noQualifiedNumber', 'qualificationRate', 'qualityName', 'executeUserName', 'qualityTime', 'status', 'createTime'].includes(col.prop)
+);
+
+/** 不良品台账列(来源 QmsUnqualifiedProductsVO;创建人/不良类型字段后端未返回,暂不展示) */
+const UNQUALIFIED_COLUMNS = [
+  { prop: 'unqualifiedProductsCode', label: '编码', width: 150, fixed: 'left' },
+  { prop: 'sourceCode', label: '来源编码', width: 150 },
+  { prop: 'categoryCode', label: '物品编码', width: 130 },
+  { prop: 'categoryName', label: '物品名称', width: 130 },
+  { prop: 'specification', label: '规格', width: 100 },
+  { prop: 'brandNum', label: '牌号', width: 90 },
+  { prop: 'modelType', label: '型号', width: 100 },
+  { prop: 'quantity', label: '数量', width: 80 },
+  { prop: 'batchNo', label: '批次号', width: 110 },
+  { prop: 'taskName', label: '工序', width: 110 },
+  { prop: 'qualityType', label: '检测类型', width: 110, dict: '质检计划类型' },
+  {
+    prop: 'status',
+    label: '状态',
+    width: 100,
+    tag: (row) => DISPOSE_TAG[row.status] || 'info',
+    dict: '处置状态'
+  },
+  {
+    prop: 'approvalStatus',
+    label: '审核状态',
+    width: 100,
+    tag: (row) => APPROVAL_TAG[row.approvalStatus] || 'info',
+    formatter: (row) => reviewStatus[row.approvalStatus] || ''
+  },
+  { prop: 'disposeTime', label: '处置时间', width: 145 },
+  { prop: 'createTime', label: '创建时间', width: 145 },
+  { prop: 'stayTime', label: '滞留时长', width: 110, formatter: (row) => durationText(row.createTime) }
+];
+
+/** 把创建时间转换为“X 天 X 小时”的滞留时长 */
+function durationText(time) {
+  if (!time) return '';
+  const start = new Date(String(time).replace(/-/g, '/')).getTime();
+  if (!isFinite(start)) return '';
+  const diff = Date.now() - start;
+  if (diff <= 0) return '0 小时';
+  const hours = Math.floor(diff / 3600000);
+  if (hours < 24) return `${hours} 小时`;
+  return `${Math.floor(hours / 24)} 天 ${hours % 24} 小时`;
+}
+
+const pad = (num) => String(num).padStart(2, '0');
+
+function formatTime(date, endOfDay) {
+  return [
+    `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`,
+    endOfDay ? '23:59:59' : '00:00:00'
+  ].join(' ');
+}
+
+/**
+ * 看板时间维度 -> 明细弹窗默认时间区间
+ * timeType:1 本月 / 2 本季度 / 3 本年
+ */
+export function getDefaultRange(timeType) {
+  const now = new Date();
+  const year = now.getFullYear();
+  const month = now.getMonth();
+  let start;
+  if (Number(timeType) === 2) {
+    const quarterStart = Math.floor(month / 3) * 3;
+    start = new Date(year, quarterStart, 1);
+  } else if (Number(timeType) === 3) {
+    start = new Date(year, 0, 1);
+  } else {
+    start = new Date(year, month, 1);
+  }
+  return [formatTime(start, false), formatTime(now, true)];
+}
+
+/** yyyy-MM-dd HH:mm:ss -> 毫秒时间戳(检测工单 PO 的 startTime/endTime 为 Date,用时间戳最稳妥) */
+export function toTimestamp(value) {
+  if (!value) return undefined;
+  const time = new Date(String(value).replace(/-/g, '/')).getTime();
+  return isFinite(time) ? time : undefined;
+}
+
+/** 统一请求包装:接口返回 { list, total } */
+const workOrderRequest = (params) => getWorkOrderList(params);
+const unqualifiedRequest = (params) => getUnqualifiedList(params);
+
+const DATE_LABEL = '创建时间';
+
+/** 检测工单:时间筛选参数用时间戳 */
+const workOrderBase = {
+  request: workOrderRequest,
+  dateMode: 'timestamp',
+  dateLabel: DATE_LABEL,
+  dicts: ['质检计划类型', '取样类型']
+};
+
+/** 不良品台账:时间筛选用字符串(后端 PO 为 String) */
+const unqualifiedBase = {
+  request: unqualifiedRequest,
+  dateMode: 'string',
+  dateLabel: DATE_LABEL,
+  dicts: ['质检计划类型', '处置状态']
+};
+
+export const DRILL_CONFIG = {
+  completion: {
+    ...workOrderBase,
+    title: '检验批次明细',
+    source: '7.2.1 · 对齐系统「执行管理 - 检测工单」,按检测工单维度统计',
+    defaultWhere: {},
+    filters: [
+      { prop: 'code', label: '检测工单编码', type: 'text', width: 180 },
+      { prop: 'name', label: '检测工单名称', type: 'text', width: 160 },
+      { prop: 'sourceCode', label: '来源单号', type: 'text', width: 160 },
+      { prop: 'qualityType', label: '类型', type: 'select', dict: '质检计划类型', width: 140 },
+      { prop: 'qualityMode', label: '检测方式', type: 'select', dict: '取样类型', width: 130 },
+      { prop: 'recordingMethod', label: '记录方法', type: 'select', options: recordingOptions, width: 140 },
+      { prop: 'productCode', label: '物品编码', type: 'text', width: 150 },
+      { prop: 'productName', label: '物品名称', type: 'text', width: 150 },
+      { prop: 'qualityName', label: '检测人', type: 'text', width: 130 },
+      { prop: 'supplierName', label: '供应商', type: 'text', width: 160 },
+      { prop: 'status', label: '状态', type: 'select', options: WORK_ORDER_STATUS, width: 120 },
+      { prop: 'reportStatus', label: '质检报告', type: 'select', options: REPORT_STATUS, width: 130 }
+    ],
+    columns: WORK_ORDER_COLUMNS
+  },
+  firstPass: {
+    ...workOrderBase,
+    title: '一次通过明细',
+    source: '7.2.2 · 检测工单批次;首检结论/返工返修次数后端未返回,暂以批次合格率呈现',
+    defaultWhere: { status: 1 },
+    filters: [
+      { prop: 'code', label: '检测工单编码', type: 'text', width: 180 },
+      { prop: 'qualityType', label: '类型', type: 'select', dict: '质检计划类型', width: 140 },
+      { prop: 'batchNo', label: '批次号', type: 'text', width: 150 },
+      { prop: 'productCode', label: '物品编码', type: 'text', width: 150 },
+      { prop: 'qualityName', label: '执行人', type: 'text', width: 130 },
+      { prop: 'status', label: '状态', type: 'select', options: WORK_ORDER_STATUS, width: 120 }
+    ],
+    columns: FIRST_PASS_COLUMNS
+  },
+  defect: {
+    ...unqualifiedBase,
+    title: '不良明细',
+    source: '7.2.3 · 对齐系统「不良品管理 - 不良品台账」',
+    defaultWhere: {},
+    filters: [
+      { prop: 'unqualifiedProductsCode', label: '编码', type: 'text', width: 170 },
+      { prop: 'sourceCode', label: '来源编码', type: 'text', width: 160 },
+      { prop: 'taskName', label: '工序', type: 'text', width: 140 },
+      { prop: 'categoryCode', label: '物品编码', type: 'text', width: 150 },
+      { prop: 'qualityType', label: '检测类型', type: 'select', dict: '质检计划类型', width: 140 },
+      { prop: 'status', label: '状态', type: 'select', dict: '处置状态', options: disposeOptions, width: 130 }
+    ],
+    columns: UNQUALIFIED_COLUMNS
+  },
+  pending: {
+    ...unqualifiedBase,
+    title: '待处理异常明细',
+    source: '7.2.4 · 不良待处置;待请样/待审核滞留口径待后端补充聚合接口',
+    defaultWhere: { status: 0 },
+    filters: [
+      { prop: 'unqualifiedProductsCode', label: '编码', type: 'text', width: 170 },
+      { prop: 'sourceCode', label: '来源编码', type: 'text', width: 160 },
+      { prop: 'taskName', label: '工序', type: 'text', width: 140 },
+      { prop: 'categoryCode', label: '物品编码', type: 'text', width: 150 },
+      { prop: 'qualityType', label: '检测类型', type: 'select', dict: '质检计划类型', width: 140 },
+      { prop: 'status', label: '状态', type: 'select', dict: '处置状态', width: 130 }
+    ],
+    columns: UNQUALIFIED_COLUMNS
+  }
+};
+
+/* 累计检验批次与检验完成率同口径,共用一份配置 */
+DRILL_CONFIG.batch = DRILL_CONFIG.completion;

+ 1741 - 0
src/views/qualityBoard/index.vue

@@ -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>

+ 8 - 1
src/views/sample/samplemanagement/index.vue

@@ -58,7 +58,7 @@
             type="primary"
             :underline="false"
             @click="goDetail(row.id)"
-            v-if="row.status !== 2"
+            v-if="row.status !== 2 && !unqualified_approval"
           >
             处置
           </el-link>
@@ -86,6 +86,7 @@ import sample from './components/addOrEditSample.vue';
 import { getList, removeItem } from '@/api/samplemanagement';
 import dictMixins from '@/mixins/dictMixins';
 import tabMixins from '@/mixins/tableColumnsMixin';
+import { parameterGetByCode } from '@/api/main/index';
 
 export default {
   mixins: [dictMixins, tabMixins],
@@ -94,6 +95,7 @@ export default {
   },
   data() {
     return {
+      unqualified_approval: 0,
       cacheKeyUrl: 'qsm-c2e9664a-samplemanagement',
       columns: [
         {
@@ -208,6 +210,11 @@ export default {
   },
   created() {
     this.requestDict('处置状态');
+    parameterGetByCode({
+      code: 'unqualifiedList_approval'
+    }).then((res) => {
+      this.unqualified_approval = res.value;
+    });
   },
 
   computed: {