gongchunyi
8 小时以前 e1535c267711c7c8d560e8916437167bbcd3156b
feat: 进销质量类分析接口对接
已修改8个文件
838 ■■■■■ 文件已修改
src/api/viewIndex.js 67 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/reportAnalysis/qualityAnalysis/components/center-bottom.vue 182 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/reportAnalysis/qualityAnalysis/components/center-center.vue 101 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/reportAnalysis/qualityAnalysis/components/center-top.vue 46 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/reportAnalysis/qualityAnalysis/components/left-top.vue 91 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/reportAnalysis/qualityAnalysis/components/right-bottom.vue 227 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/reportAnalysis/qualityAnalysis/components/right-top.vue 121 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/reportAnalysis/qualityAnalysis/index.vue 3 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/viewIndex.js
@@ -1,6 +1,73 @@
// 首页接口
import request from "@/utils/request";
//  原材料检测
export const rawMaterialDetection = (query) => {
  return request({
    url: "/home/rawMaterialDetection",
    method: "get",
    params: query,
  });
};
//  过程检测
export const processDetection = (query) => {
  return request({
    url: "/home/processDetection",
    method: "get",
    params: query,
  });
};
//  成品出厂检测
export const factoryDetection = (query) => {
  return request({
    url: "/home/factoryDetection",
    method: "get",
    params: query,
  });
};
//  检验数量
export const qualityInspectionCount = () => {
  return request({
    url: "/home/qualityInspectionCount",
    method: "get",
  });
};
//  不合格预警
export const nonComplianceWarning = () => {
  return request({
    url: "/home/nonComplianceWarning",
    method: "get",
  });
};
//  完成检验数
export const completedInspectionCount = () => {
  return request({
    url: "/home/completedInspectionCount",
    method: "get",
  });
};
//  不合格产品排名
export const unqualifiedProductRanking = () => {
  return request({
    url: "/home/unqualifiedProductRanking",
    method: "get",
  });
};
//  不合格检品处理分析
export const unqualifiedProductProcessingAnalysis = () => {
  return request({
    url: "/home/unqualifiedProductProcessingAnalysis",
    method: "get",
  });
};
// 销售-采购-库存数据
export const getBusiness = () => {
  return request({
src/views/reportAnalysis/qualityAnalysis/components/center-bottom.vue
@@ -1,6 +1,9 @@
<template>
  <div>
    <PanelHeader title="工单执行效率分析" />
    <div class="chart-header">
      <PanelHeader title="完成检验数" />
      <div class="warn-range" @click="handleRangeClick">近7天</div>
    </div>
    <div class="main-panel panel-item-customers">
      <Echarts
          ref="chart"
@@ -20,7 +23,7 @@
<script setup>
import { ref, onMounted } from 'vue'
import { qualityStatistics } from '@/api/viewIndex.js'
import { completedInspectionCount } from '@/api/viewIndex.js'
import PanelHeader from './PanelHeader.vue'
import Echarts from '@/components/Echarts/echarts.vue'
@@ -29,21 +32,25 @@
  height: '135%',
}
const grid = { left: '3%', right: '4%', bottom: '3%', top: '10%', containLabel: true }
const grid = { left: '8%', right: '8%', bottom: '8%', top: '15%', containLabel: true }
const barLegend = {
  show: true,
  textStyle: { color: '#B8C8E0' },
  data: ['开工', '完成'],
  top: '5%',
  left: 'center',
  textStyle: { color: '#B8C8E0', fontSize: 14 },
  itemGap: 30,
  data: ['合格', '不合格', '合格率'],
}
// 柱状图:开工、完成;折线图:良品率(颜色 rgba(90, 216, 166, 1))
// 柱状图:合格(黄色)、不合格(紫色);折线图:合格率(蓝色)
const chartSeries = ref([
  {
    name: '开工',
    name: '合格',
    type: 'bar',
    barWidth: 20,
    barGap: '40%',
    barGap: '20%',
    yAxisIndex: 0,
    emphasis: { focus: 'series' },
    itemStyle: {
      color: {
@@ -53,18 +60,19 @@
        x2: 0,
        y2: 1,
        colorStops: [
          { offset: 1, color: 'rgba(0, 164, 237, 0)' },
          { offset: 0, color: 'rgba(78, 228, 255, 1)' },
          { offset: 0, color: 'rgba(255, 215, 0, 1)' }, // 金黄色顶部
          { offset: 1, color: 'rgba(255, 215, 0, 0.5)' }, // 半透明底部
        ],
      },
    },
    data: [],
  },
  {
    name: '完成',
    name: '不合格',
    type: 'bar',
    barGap: '40%',
    barGap: '20%',
    barWidth: 20,
    yAxisIndex: 0,
    emphasis: { focus: 'series' },
    itemStyle: {
      color: {
@@ -74,9 +82,34 @@
        x2: 0,
        y2: 1,
        colorStops: [
          { offset: 1, color: 'rgba(83, 126, 245, 0.19)' },
          { offset: 0, color: 'rgba(144, 97, 248, 1)' },
          { offset: 0, color: 'rgba(144, 97, 248, 1)' }, // 紫色顶部
          { offset: 1, color: 'rgba(144, 97, 248, 0.6)' }, // 半透明底部
        ],
      },
    },
    data: [],
  },
  {
    name: '合格率',
    type: 'line',
    yAxisIndex: 1,
    smooth: true,
    symbol: 'circle',
    symbolSize: 8,
    lineStyle: {
      color: 'rgba(78, 228, 255, 1)', // 青色
      width: 2,
    },
    itemStyle: {
      color: 'rgba(78, 228, 255, 1)',
      borderWidth: 2,
      borderColor: '#fff',
    },
    emphasis: {
      focus: 'series',
      itemStyle: {
        shadowBlur: 10,
        shadowColor: 'rgba(78, 228, 255, 0.8)',
      },
    },
    data: [],
@@ -86,53 +119,87 @@
const tooltip = {
  trigger: 'axis',
  axisPointer: { type: 'cross' },
  backgroundColor: 'rgba(0, 0, 0, 0.8)',
  borderColor: 'rgba(78, 228, 255, 0.5)',
  borderWidth: 1,
  textStyle: { color: '#B8C8E0' },
  formatter(params) {
    let result = params[0].axisValueLabel + '<br/>'
    params.forEach((item) => {
      const unit = item.seriesName === '近7天'
      result += `<div>${item.marker} ${item.seriesName}: ${item.value}${unit}</div>`
      let unit = ''
      if (item.seriesName === '合格率') {
        unit = '%'
      } else {
        unit = '件'
      }
      result += `<div style="margin: 4px 0;">${item.marker} ${item.seriesName}: ${item.value}${unit}</div>`
    })
    return result
  },
}
const xAxis1 = ref([
  { type: 'category', axisTick: { show: false }, axisLabel: { color: '#B8C8E0' }, data: [] },
  {
    type: 'category',
    axisTick: { show: false },
    axisLabel: { color: '#B8C8E0', fontSize: 12 },
    axisLine: { lineStyle: { color: 'rgba(184, 200, 224, 0.3)' } },
    data: [],
  },
])
const yAxis1 = [
  { type: 'value', name: '件', axisLabel: { color: '#B8C8E0' }, nameTextStyle: { color: '#B8C8E0' } },
  {
    type: 'value',
    name: '近7天',
    name: '单位: 件',
    nameLocation: 'start',
    nameTextStyle: { color: '#B8C8E0', fontSize: 12, padding: [0, 0, 0, 10] },
    axisLabel: { color: '#B8C8E0', fontSize: 12 },
    axisLine: { show: false },
    splitLine: {
      show: true,
      lineStyle: { color: 'rgba(184, 200, 224, 0.2)', type: 'dashed' },
    },
  },
  {
    type: 'value',
    name: '单位: %',
    nameLocation: 'end',
    nameTextStyle: { color: '#B8C8E0', fontSize: 12, padding: [0, 0, 0, 10] },
    min: 0,
    max: 100,
    axisLabel: { color: '#B8C8E0', formatter: '{value}%' },
    nameTextStyle: { color: '#B8C8E0' },
    splitLine: { lineStyle: { color: 'rgba(184, 200, 224, 0.2)' } },
    axisLabel: { color: '#B8C8E0', fontSize: 12, formatter: '{value}' },
    axisLine: { show: false },
    splitLine: {
      show: true,
      lineStyle: { color: 'rgba(184, 200, 224, 0.2)', type: 'dashed' },
    },
  },
]
const fetchData = () => {
  qualityStatistics()
      .then((res) => {
        if (!res?.data?.item || !Array.isArray(res.data.item)) return
        const items = res.data.item
        xAxis1.value[0].data = items.map((d) => d.date)
        // 开工:过程检验数
        chartSeries.value[0].data = items.map((d) => Number(d.processNum) || 0)
        // 完成:出厂数
        chartSeries.value[1].data = items.map((d) => Number(d.factoryNum) || 0)
        // 良品率:出厂数/过程数*100(无单独接口时用此占位)
        chartSeries.value[2].data = items.map((d) => {
          const processNum = Number(d.processNum) || 0
          const factoryNum = Number(d.factoryNum) || 0
          if (processNum <= 0) return 0
          return Math.min(100, Math.round((factoryNum / processNum) * 100))
        })
      })
      .catch((err) => {
        console.error('获取开工与良品率数据失败:', err)
      })
  completedInspectionCount()
    .then((res) => {
      if (res?.code === 200 && Array.isArray(res?.data)) {
        const items = res.data
        // 更新X轴日期数据
        xAxis1.value[0].data = items.map((d) => d.dateStr || '')
        // 更新合格数(黄色柱状图)
        chartSeries.value[0].data = items.map((d) => Number(d.qualifiedCount) || 0)
        // 更新不合格数(紫色柱状图)
        chartSeries.value[1].data = items.map((d) => Number(d.unqualifiedCount) || 0)
        // 更新合格率(蓝色折线图)
        chartSeries.value[2].data = items.map((d) => Number(d.passRate) || 0)
      }
    })
    .catch((err) => {
      console.error('获取完成检验数数据失败:', err)
    })
}
const handleRangeClick = () => {
  // 先按截图做静态"近7天",后续有真实筛选需求再接入
  fetchData()
}
onMounted(() => {
@@ -141,6 +208,35 @@
</script>
<style scoped>
.chart-header {
  position: relative;
  display: flex;
  align-items: center;
}
.warn-range {
  position: absolute;
  right: 0;
  top: 0;
  height: 32px;
  padding: 0 14px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  border-radius: 4px;
  color: #ffffff;
  font-weight: 600;
  font-size: 14px;
  background: linear-gradient(180deg, rgba(51, 120, 255, 1) 0%, rgba(0, 164, 237, 1) 100%);
  border: 1px solid rgba(78, 228, 255, 0.25);
  cursor: pointer;
  z-index: 10;
}
.warn-range:hover {
  background: linear-gradient(180deg, rgba(51, 140, 255, 1) 0%, rgba(0, 184, 237, 1) 100%);
}
.main-panel {
  display: flex;
  flex-direction: column;
@@ -152,5 +248,7 @@
  padding: 18px;
  width: 100%;
  height: 449px;
  position: relative;
  background: radial-gradient(circle at 50% 50%, rgba(78, 228, 255, 0.05) 0%, rgba(0, 0, 0, 0) 70%);
}
</style>
src/views/reportAnalysis/qualityAnalysis/components/center-center.vue
@@ -12,7 +12,8 @@
      <div class="warn-body">
        <div class="warn-list" role="list">
          <div v-for="item in warnings" :key="item.id" class="warn-item" role="listitem" @click="openWarning(item)">
            <div class="warn-tag" :class="tagClass(item.type)">{{ item.typeText }}</div>
            <div class="warn-tag" :class="tagClass(item.type)">{{ item.parentProductTitle }}-{{ item.productTitle }}
            </div>
            <div class="warn-text" :title="item.title">{{ item.title }}</div>
            <div class="warn-action" @click.stop="openWarning(item)">查看</div>
            <div class="warn-date">{{ item.date }}</div>
@@ -26,18 +27,18 @@
<script setup>
import { computed, getCurrentInstance, ref, onMounted } from 'vue'
import Echarts from '@/components/Echarts/echarts.vue'
import { qualityUnqualifiedListPage } from '@/api/qualityManagement/nonconformingManagement.js'
import { nonComplianceWarning } from '@/api/viewIndex.js'
const { proxy } = getCurrentInstance() || {}
const warnings = ref([
  { id: '1', type: 'raw', typeText: '原材料', title: '关于企业原材料调整通知', date: '2024.08.24' },
  { id: '2', type: 'raw', typeText: '原材料', title: '关于原材料消耗方案建设的通知', date: '2024.08.24' },
  { id: '3', type: 'final', typeText: '成品', title: '成品工作台系统维护计划安排', date: '2024.08.24' },
  { id: '4', type: 'final', typeText: '成品', title: '成品工作台系统维护计划安排', date: '2024.08.24' },
  { id: '5', type: 'semi', typeText: '半成品', title: 'HRM系统安全升级公告:加强访问控制…', date: '2024.08.24' },
  { id: '6', type: 'semi', typeText: '半成品', title: 'HRM系统安全升级公告:加强访问控制…', date: '2024.08.24' },
])
const warnings = ref([])
// 占比数据
const ratios = ref({
  rawMaterialRatio: 0,
  semiFinishedProductRatio: 0,
  finishedProductRatio: 0,
})
const TAG_COLORS = {
  raw: '#7C4DFF',
@@ -51,6 +52,14 @@
  return 'tag-semi'
}
// 根据productTitle映射类型
const mapProductTitleToType = (productTitle) => {
  if (productTitle === '原材料') return 'raw'
  if (productTitle === '半成品') return 'semi'
  if (productTitle === '成品') return 'final'
  return 'raw' // 默认值
}
const pieChartStyle = { width: '100%', height: '100%' }
const pieOptions = {
@@ -60,19 +69,14 @@
const pieTooltip = {
  trigger: 'item',
  formatter: (p) => `${p.name}:${p.value}`,
  formatter: (p) => `${p.name}:${p.value}%`,
}
const pieData = computed(() => {
  const counts = { raw: 0, final: 0, semi: 0 }
  warnings.value.forEach((w) => {
    const key = w.type in counts ? w.type : 'raw'
    counts[key] += 1
  })
  return [
    { name: '原材料', value: counts.raw, itemStyle: { color: TAG_COLORS.raw } },
    { name: '半成品', value: counts.semi, itemStyle: { color: TAG_COLORS.semi } },
    { name: '成品', value: counts.final, itemStyle: { color: TAG_COLORS.final } },
    { name: '原材料', value: ratios.value.rawMaterialRatio, itemStyle: { color: TAG_COLORS.raw } },
    { name: '半成品', value: ratios.value.semiFinishedProductRatio, itemStyle: { color: TAG_COLORS.semi } },
    { name: '成品', value: ratios.value.finishedProductRatio, itemStyle: { color: TAG_COLORS.final } },
  ]
})
@@ -111,34 +115,42 @@
const fetchWarnings = async () => {
  try {
    const res = await qualityUnqualifiedListPage({ pageNum: 1, pageSize: 6 })
    const rows = res?.rows || res?.data?.rows || res?.data || []
    if (!Array.isArray(rows) || rows.length === 0) return
    const res = await nonComplianceWarning()
    if (res?.code === 200 && res?.data) {
      const data = res.data
    warnings.value = rows.slice(0, 6).map((r, idx) => {
      const typeCode = r.inspectType ?? r.modelType ?? r.type
      const mappedType = typeCode === 0 || typeCode === '0' ? 'raw' : typeCode === 1 || typeCode === '1' ? 'semi' : 'final'
      const title = r.title || r.unqualifiedTitle || r.remark || r.unqualifiedReason || '不合格预警'
      const date = (r.warningTime || r.createTime || r.updateTime || '').slice(0, 10).replace(/-/g, '.') || '2024.08.24'
      return {
        id: r.id ?? r.unqualifiedId ?? `${idx}`,
        type: mappedType,
        typeText: mappedType === 'raw' ? '原材料' : mappedType === 'semi' ? '半成品' : '成品',
        title,
        date,
      // 更新占比数据
      ratios.value = {
        rawMaterialRatio: data.rawMaterialRatio ?? 0,
        semiFinishedProductRatio: data.semiFinishedProductRatio ?? 0,
        finishedProductRatio: data.finishedProductRatio ?? 0,
      }
    })
      // 更新警告列表
      const children = data.children || []
      warnings.value = children.map((item, idx) => {
        const type = mapProductTitleToType(item.parentProductTitle)
        const date = item.date ? item.date.replace(/-/g, '.') : ''
        return {
          id: item.id ?? `warning-${idx}`,
          type,
          parentProductTitle: item.parentProductTitle || '原材料',
          productTitle: item.productTitle || '原材料',
          title: item.description || '不合格预警',
          date,
        }
      })
    }
  } catch (e) {
    // 接口失败则保持 mock
    // 接口失败则保持空数据
    console.error('获取不合格预警失败:', e)
  }
}
const openWarning = (item) => {
  const title = `【${item.typeText}】${item.title}`
  const content = `${title}时间:${item.date}`
  const title = `【${item.parentProductTitle}-${item.productTitle}】${item.title}`
  if (proxy?.$modal?.alert) {
    proxy.$modal.alert(content)
    proxy.$modal.alert(title)
    return
  }
  // 兜底:没有全局 modal 时用 console
@@ -170,14 +182,11 @@
  align-items: center;
  justify-content: space-between;
  border-bottom: 1px solid;
  border-image: linear-gradient(
      270deg,
  border-image: linear-gradient(270deg,
      rgba(0, 126, 255, 0) 0%,
      rgba(0, 126, 255, 0.4549) 35%,
      #007eff 78%,
      #007eff 100%
    )
    1;
      #007eff 100%) 1;
  padding: 10px 0 6px;
}
@@ -235,13 +244,13 @@
.warn-item {
  display: grid;
  grid-template-columns: 88px 1fr auto 110px;
  grid-template-columns: 130px 1fr auto 110px;
  align-items: center;
  gap: 12px;
  color: #b8c8e0;
  font-size: 14px;
  line-height: 1;
  padding: 6px 0;
  line-height: 1.2;
  padding: 8px 0;
  border-radius: 4px;
  transition: background-color 0.2s, color 0.2s;
}
src/views/reportAnalysis/qualityAnalysis/components/center-top.vue
@@ -2,11 +2,7 @@
  <div>
    <!-- 顶部统计卡片 -->
    <div class="stats-cards">
      <div
        v-for="item in statItems"
        :key="item.name"
        class="stat-card"
      >
      <div v-for="item in statItems" :key="item.name" class="stat-card">
        <img src="@/assets/BI/icon@2x.png" alt="图标" class="card-icon" />
        <div class="card-content">
          <span class="card-label">{{ item.name }}</span>
@@ -25,7 +21,7 @@
<script setup>
import { ref, onMounted } from 'vue'
import { salesPurchaseStorageProductCount } from '@/api/viewIndex.js'
import { qualityInspectionCount } from '@/api/viewIndex.js'
const statItems = ref([])
@@ -37,18 +33,32 @@
const compareClass = (val) => (val >= 0 ? 'compare-up' : 'compare-down')
const fetchData = () => {
  salesPurchaseStorageProductCount()
  qualityInspectionCount()
    .then((res) => {
      if (res.code === 200 && Array.isArray(res.data)) {
        statItems.value = res.data.map((item) => ({
          name: item.name,
          value: item.value,
          rate: item.rate,
        }))
      if (res.code === 200 && res.data) {
        const data = res.data
        statItems.value = [
          {
            name: '总检验数',
            value: data.totalCount ?? 0,
            rate: data.totalCountGrowthRate ?? 0,
          },
          {
            name: '今日待完成数',
            value: data.todayPendingCount ?? 0,
            rate: data.todayPendingCountGrowthRate ?? 0,
          },
          {
            name: '今日已完成数',
            value: data.todayCompletedCount ?? 0,
            rate: data.todayCompletedCountGrowthRate ?? 0,
          },
        ]
      }
    })
    .catch((err) => {
      console.error('获取销售/采购/储存产品数失败:', err)
      console.error('获取质量检验统计失败:', err)
    })
}
@@ -97,7 +107,7 @@
.card-label {
  font-weight: 400;
  font-size: 19px;
  font-size: 16px;
  color: rgba(208, 231, 255, 0.7);
}
@@ -109,7 +119,7 @@
  color: #d0e7ff;
}
.card-compare > span:first-child {
.card-compare>span:first-child {
  font-size: 13px;
  opacity: 0.8;
}
@@ -121,7 +131,8 @@
.compare-icon {
  font-size: 14px;
  position: relative;
  top: -1px; /* 轻微上移,让箭头与文字垂直居中对齐 */
  top: -1px;
  /* 轻微上移,让箭头与文字垂直居中对齐 */
}
.compare-up .compare-value,
@@ -133,5 +144,4 @@
.compare-down .compare-icon {
  color: #ff5252;
}
</style>
src/views/reportAnalysis/qualityAnalysis/components/left-top.vue
@@ -13,13 +13,8 @@
        <div class="inspect-body">
          <div class="ring">
            <Echarts
              :chartStyle="ringChartStyle"
              :series="buildRingSeries(section)"
              :tooltip="ringTooltip"
              :legend="{ show: false }"
              :options="ringOptions"
            />
            <Echarts :chartStyle="ringChartStyle" :series="buildRingSeries(section)" :tooltip="ringTooltip"
              :legend="{ show: false }" :options="ringOptions" />
          </div>
          <div class="stats">
@@ -51,42 +46,72 @@
</template>
<script setup>
import { reactive } from 'vue'
import { reactive, onMounted } from 'vue'
import Echarts from '@/components/Echarts/echarts.vue'
import PanelHeader from './PanelHeader.vue'
import DateTypeSwitch from './DateTypeSwitch.vue'
import { rawMaterialDetection, processDetection, factoryDetection } from '@/api/viewIndex.js'
const QUALIFIED_COLOR = '#4EE4FF'
const UNQUALIFIED_COLOR = '#3378FF'
const TRACK_COLOR = 'rgba(78, 228, 255, 0.12)'
const apiMap = {
  raw: rawMaterialDetection,
  process: processDetection,
  final: factoryDetection,
}
const fetchSectionData = async (section) => {
  const api = apiMap[section.key]
  if (!api) return
  try {
    const res = await api({
      type: section.dateType,
    })
    if (res?.code === 200 && res?.data) {
      const data = res.data
      section.qualifiedCount = Number(data.qualifiedCount || 0)
      section.unqualifiedCount = Number(data.unqualifiedCount || 0)
      section.qualifiedRate = Number(data.qualifiedRate || 0)
      section.unqualifiedRate = Number(data.unqualifiedRate || 0)
    }
  } catch (err) {
    console.error(`${section.key} 接口请求失败`, err)
  }
}
const sections = reactive([
  {
    key: 'raw',
    title: '原材料检测',
    dateType: 1,
    qualifiedCount: 199,
    unqualifiedCount: 99,
    qualifiedRate: 90,
    unqualifiedRate: 10,
    qualifiedCount: 0,
    unqualifiedCount: 0,
    qualifiedRate: 0,
    unqualifiedRate: 0,
  },
  {
    key: 'process',
    title: '过程检测',
    dateType: 1,
    qualifiedCount: 199,
    unqualifiedCount: 99,
    qualifiedRate: 90,
    unqualifiedRate: 10,
    qualifiedCount: 0,
    unqualifiedCount: 0,
    qualifiedRate: 0,
    unqualifiedRate: 0,
  },
  {
    key: 'final',
    title: '成品出厂检测',
    dateType: 1,
    qualifiedCount: 199,
    unqualifiedCount: 99,
    qualifiedRate: 90,
    unqualifiedRate: 10,
    qualifiedCount: 0,
    unqualifiedCount: 0,
    qualifiedRate: 0,
    unqualifiedRate: 0,
  },
])
@@ -183,15 +208,15 @@
  const section = sections.find((s) => s.key === key)
  if (!section) return
  section.dateType = dateType
  const rates = calcRates(section.qualifiedCount, section.unqualifiedCount)
  section.qualifiedRate = rates.qualifiedRate
  section.unqualifiedRate = rates.unqualifiedRate
  // 切换日期类型时重新获取数据
  fetchSectionData(section)
}
sections.forEach((s) => {
  const rates = calcRates(s.qualifiedCount, s.unqualifiedCount)
  s.qualifiedRate = rates.qualifiedRate
  s.unqualifiedRate = rates.unqualifiedRate
// 组件挂载时获取所有section的数据
onMounted(() => {
  sections.forEach((section) => {
    fetchSectionData(section)
  })
})
</script>
@@ -248,7 +273,7 @@
        width: 18px;
        height: 7px;
        border-radius: 8px;
        background: linear-gradient(360deg, rgba(33,133,255,0.4) 0%, rgba(33,221,255,0) 100%);
        background: linear-gradient(360deg, rgba(33, 133, 255, 0.4) 0%, rgba(33, 221, 255, 0) 100%);
        position: absolute;
        top: 50%;
        left: -1px;
@@ -308,7 +333,7 @@
  flex: 1 1 auto;
  min-height: 0;
  display: flex;
  justify-content:space-around;
  justify-content: space-around;
  align-items: center;
  gap: 18px;
}
@@ -329,11 +354,9 @@
  position: absolute;
  inset: -8px;
  border-radius: 50%;
  background: repeating-conic-gradient(
    from 0deg,
    rgba(78, 228, 255, 0.75) 0 1deg,
    rgba(78, 228, 255, 0) 1deg 9deg
  );
  background: repeating-conic-gradient(from 0deg,
      rgba(78, 228, 255, 0.75) 0 1deg,
      rgba(78, 228, 255, 0) 1deg 9deg);
  -webkit-mask: radial-gradient(circle, transparent 62%, #000 63%);
  mask: radial-gradient(circle, transparent 62%, #000 63%);
  opacity: 0.35;
src/views/reportAnalysis/qualityAnalysis/components/right-bottom.vue
@@ -1,43 +1,34 @@
<template>
  <div>
    <PanelHeader title="产品大类" />
    <PanelHeader title="不合格检品处理分析" />
    <div class="panel-item-customers">
      <div class="pie-chart-wrapper" ref="pieWrapperRef">
        <div class="pie-background" ref="pieBackgroundRef"></div>
        <Echarts
            ref="chart"
            :chartStyle="chartStyle"
            :legend="landLegend"
            :series="landSeries"
            :tooltip="landTooltip"
            :color="landColors"
            :options="pieOptions"
            style="height: 100%"
            class="land-chart"
        />
        <Echarts ref="chart" :chartStyle="chartStyle" :legend="landLegend" :series="computedSeries"
          :tooltip="landTooltip" :color="landColors" :options="pieOptions" style="height: 100%" class="land-chart" />
      </div>
    </div>
  </div>
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue'
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import Echarts from '@/components/Echarts/echarts.vue'
import PanelHeader from './PanelHeader.vue'
import { productCategoryDistribution } from '@/api/viewIndex.js'
import { unqualifiedProductProcessingAnalysis } from '@/api/viewIndex.js'
import { useChartBackground } from '@/hooks/useChartBackground.js'
const pieWrapperRef = ref(null)
const pieBackgroundRef = ref(null)
const chart = ref(null)
// 数据列表(来自接口)
//  数据列表
const dataList = ref([])
// 颜色列表
//  颜色列表
const landColors = ['#26FFCB', '#24CBFF', '#35FBF4', '#2651FF', '#D1E4F5', '#5782F7', '#2F67EF', '#82BAFF']
// label 富文本:为每个颜色生成一个小圆点样式(确保在 label 中可见)
//  label 富文本样式
const dotRich = landColors.reduce((acc, color, idx) => {
  acc[`dot${idx}`] = {
    width: 8,
@@ -49,163 +40,113 @@
  return acc
}, {})
// 图例配置(右侧竖排)
const landLegend = {
//  图例配置
const landLegend = ref({
  show: false,
  icon: 'circle',
  data: [],
  right: '8%',
  top: '40%',
  orient: 'vertical',
  itemGap: 14,
  itemWidth: 6,
  itemHeight: 6,
  textStyle: {
    fontSize: 12,
    color: '#fff',
    rich: {
      unit: {
        color: '#fff',
        fontSize: 12,
        padding: [0, 10, 0, 0],
      },
      text: {
        width: 60,
        color: '#fff',
        fontSize: 12,
      },
    },
  },
  formatter: function (name) {
    const list = dataList.value || []
    const item = list.find((d) => d.name === name)
    if (!item) return name
    const val = Number(item.value || 0)
    const totalValue = list.reduce((sum, it) => sum + Number(it.value || 0), 0)
    const percent = totalValue ? ((val / totalValue) * 100).toFixed(2) : '0.00'
    return `{text|${name}}${val}{unit| 公顷}${percent}{unit|%}`
  },
}
      unit: { color: '#fff', fontSize: 12, padding: [0, 10, 0, 0] },
      text: { width: 60, color: '#fff', fontSize: 12 },
    }
  }
})
// 提示框
//  提示框配置
const landTooltip = {
  // triggerOn: 'hover',
  alwaysShowContent: true,
  trigger: 'item',
  alwaysShowContent: false,
  position: function (pt) {
    return [pt[0], 130]
  },
  formatter: function (params) {
    return `${params.name} (${params.value}类)`
    // 确保 params.data 存在
    if (!params.data) return ''
    const { name, value, rate } = params.data
    return `${name}<br/>数量:${value}个<br/>占比:${rate}%`
  },
}
// 双层环形饼图
const landSeries = ref([
  {
    name: '产品大类',
    type: 'pie',
    radius: ['35%', '55%'],
    center: ['50%', '50%'],
    label: {
      show: true,
      position: 'outside',
      color: '#fff',
      fontSize: 12,
      lineHeight: 18,
      rich: {
        ...dotRich,
        parent: { fontSize: 14, fontWeight: 600, color: '#fff', lineHeight: 20, overflow: 'break' },
        child: { fontSize: 12, color: '#fff', lineHeight: 18 },
//  使用计算属性处理 Series
const computedSeries = computed(() => {
  return [
    {
      name: '不合格检品处理分析',
      type: 'pie',
      radius: ['35%', '55%'],
      center: ['50%', '50%'],
      label: {
        show: true,
        position: 'outside',
        color: '#fff',
        rich: {
          ...dotRich,
          parent: { fontSize: 14, fontWeight: 600, color: '#fff', lineHeight: 20 },
          child: { fontSize: 12, color: '#fff', lineHeight: 18 },
        },
        formatter: function (params) {
          if (!params.data) return ''
          const dotKey = `dot${params.dataIndex % landColors.length}`
          return `{${dotKey}|} {parent|${params.data.name} (${params.data.value}个)}`
        },
      },
      formatter: function (params) {
        const children = params?.data?.children || []
        const parentName = params?.data?.name || ''
        const rawVal = params?.data?.value
        const parentValue = typeof rawVal === 'number' && !Number.isNaN(rawVal) ? rawVal : (Number(rawVal) || 0)
        const dotKey = `dot${(params?.dataIndex || 0) % landColors.length}`
        const dot = `{${dotKey}|} `
        const parentLine = `${dot}{parent|${parentName} (${parentValue}类)}`
        if (!children.length) return parentLine
        // 父级全部显示;子级最多 5 个,超出显示省略号
        const displayed = children.slice(0, 5).map((c) => `{child|${c.name}}`)
        if (children.length > 5) displayed.push('{child|…}')
        return [parentLine, ...displayed].join('\n')
      labelLine: {
        show: true,
        length: 20,
        lineStyle: { color: '#B8C8E0' },
      },
      data: dataList.value,
    },
    labelLine: {
      show: true,
      length: 20,
      length2: 20,
      lineStyle: {
        color: '#B8C8E0',
      },
    {
      // 内圈装饰
      type: 'pie',
      radius: ['35%', '40%'],
      center: ['50%', '50%'],
      silent: true,
      label: { show: false },
      itemStyle: { color: 'rgba(0, 127, 255, 0.25)' },
      data: [1],
    },
    itemStyle: {
      color: function (params) {
        return landColors[params.dataIndex % landColors.length]
      },
    },
    data: dataList.value,
  },
  {
    // 内圈
    type: 'pie',
    radius: ['35%', '40%'],
    center: ['50%', '50%'],
    silent: true,
    label: {
      show: false,
    },
    labelLine: {
      show: false,
    },
    itemStyle: {
      color: 'rgba(0, 127, 255, 0.25)',
    },
    data: [1],
  },
])
  ]
})
const chartStyle = {
  width: '100%',
  height: '126%',
}
const chartStyle = { width: '100%', height: '126%' }
const pieOptions = { backgroundColor: 'transparent' }
const pieOptions = {
  backgroundColor: 'transparent',
  textStyle: { color: '#B8C8E0' },
}
// 使用封装的背景位置调整方法,可自定义偏移值
//  背景处理钩子
const { adjustBackgroundPosition, init: initBackground, cleanup: cleanupBackground } = useChartBackground({
  wrapperRef: pieWrapperRef,
  backgroundRef: pieBackgroundRef,
  offsetX: '-51.5%', // X 轴偏移,可动态调整
  offsetY: '-39%',   // Y 轴偏移,可动态调整
  watchData: dataList // 监听数据变化,自动调整位置
  offsetX: '-51.5%',
  offsetY: '-39%',
  watchData: dataList
})
const loadData = async () => {
  try {
    const res = await productCategoryDistribution()
    const items = res?.data?.items || []
    dataList.value = items.map((it) => ({
      name: it.name,
      value: Number(it.value || 0),
      rate: it.rate,
      children: Array.isArray(it.children) ? it.children : [],
    }))
    landLegend.data = dataList.value.map((d) => d.name)
    landSeries.value[0].data = dataList.value
    // 数据加载完成后调整背景位置
    adjustBackgroundPosition()
    const res = await unqualifiedProductProcessingAnalysis()
    if (res && res.code === 200) {
      dataList.value = (res.data || []).map((it) => ({
        name: it.name,
        value: Number(it.value || 0),
        rate: it.rate,
      }))
      landLegend.value.data = dataList.value.map((d) => d.name)
      // 数据更新后微调背景
      setTimeout(() => {
        adjustBackgroundPosition()
      }, 100)
    }
  } catch (e) {
    console.error('获取产品大类分布失败:', e)
    dataList.value = []
    landLegend.data = []
    landSeries.value[0].data = []
    console.error('获取数据失败:', e)
  }
}
onMounted(() => {
  loadData()
@@ -229,7 +170,6 @@
  position: relative;
  width: 100%;
  height: 320px;
  background: transparent;
}
.pie-background {
@@ -242,9 +182,8 @@
  background-repeat: no-repeat;
  z-index: 1;
  pointer-events: none;
  /* 默认居中,会在 JS 中动态调整 */
  left: 50%;
  top: 50%;
  transform: translate(-51.5%, -39%);
}
</style>
</style>
src/views/reportAnalysis/qualityAnalysis/components/right-top.vue
@@ -1,83 +1,121 @@
<template>
  <div>
    <PanelHeader title="工单执行效率分析" />
    <PanelHeader title="不合格产品排名" />
    <div class="main-panel panel-item-customers">
      <div class="main-panel-container">
    <div
      style="color: white"
      class="main-panel-box"
      v-for="(item, index) in panelList"
      :key="index"
    >
      <div style="flex: 1" class="main-panel-box-left">Top{{ index + 1 }}</div>
      <div style="flex: 3" class="main-panel-box-right">
        <div class="main-panel-box-right-text">
          <span>总数量:{{ item.total }}</span>
          <span>已完成:{{ item.finished }}</span>
          <span>合格率:{{ item.qualifiedRate }}</span>
        </div>
        <div class="main-panel-box-right-progress">
          <el-progress :percentage="item.percentage" :format="format" />
        <div style="color: white" class="main-panel-box" v-for="(item, index) in panelList" :key="index">
          <!-- <div style="flex: 1" class="main-panel-box-left">{{ item.rank }}</div> -->
          <div style="flex: 1" class="main-panel-box-left">{{ item.productName }}</div>
          <div style="flex: 3" class="main-panel-box-right">
            <!-- <div class="main-panel-box-right-title">{{ item.productName }}</div> -->
            <div class="main-panel-box-right-text">
              <span>总数量:{{ item.total }}</span>
              <span>已完成:{{ item.finished }}</span>
              <span>合格率:{{ item.qualifiedRate }}%</span>
            </div>
            <div class="main-panel-box-right-progress">
              <el-progress :percentage="item.percentage" :format="format" />
            </div>
          </div>
        </div>
      </div>
    </div>
  </div>
    </div>
  </div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { unqualifiedProductRanking } from '@/api/viewIndex.js'
import PanelHeader from './PanelHeader.vue'
const panelList = [
        { total: 100, finished: 100, qualifiedRate: 100, percentage: 100 }, // Top1
        { total: 200, finished: 180, qualifiedRate: 90, percentage: 90 },  // Top2
        { total: 200, finished: 180, qualifiedRate: 90, percentage: 90 },  // Top2
        { total: 200, finished: 180, qualifiedRate: 90, percentage: 90 },  // Top2
        { total: 200, finished: 180, qualifiedRate: 90, percentage: 90 },  // Top2
        { total: 200, finished: 180, qualifiedRate: 90, percentage: 90 },  // Top2
        { total: 200, finished: 180, qualifiedRate: 90, percentage: 90 },  // Top2
        { total: 200, finished: 180, qualifiedRate: 90, percentage: 90 },  // Top2
        { total: 200, finished: 180, qualifiedRate: 90, percentage: 90 },  // Top2
        { total: 150, finished: 120, qualifiedRate: 80, percentage: 80 }   // Top3
      ]
      const format = (percentage) => {
      return `${percentage}%`;
    }
const panelList = ref([])
const format = (percentage) => {
  return `${percentage}%`
}
const fetchData = () => {
  unqualifiedProductRanking()
    .then((res) => {
      if (res?.code === 200 && Array.isArray(res?.data)) {
        const data = res.data
        panelList.value = data.map((item, index) => {
          const total = Number(item.totalCount) || 0
          const finished = Number(item.completedCount) || 0
          const passRate = Number(item.passRate) || 0
          return {
            rank: `Top${index + 1}`,
            productName: item.productName || `产品${index + 1}`,
            total: total.toFixed(2),
            finished: finished.toFixed(2),
            qualifiedRate: passRate.toFixed(2),
            percentage: Math.min(100, Math.max(0, passRate)), // 确保百分比在0-100之间
          }
        })
      }
    })
    .catch((err) => {
      console.error('获取工单执行效率分析数据失败:', err)
    })
}
onMounted(() => {
  // fetchData()
  fetchData()
})
</script>
<style scoped>
.main-panel-box{
.main-panel-box {
  display: flex;
  flex-direction: row;
  align-items: center;
  height: 40px;
  .main-panel-box-left{
  .main-panel-box-left {
    background: red;
    border-radius: 20px;
    text-align: center;
    line-height: 32px;
      margin: 0 20px;
    margin: 0 20px;
  }
  .main-panel-box-right{
  .main-panel-box-right {
    display: flex;
    flex-direction: column;
    .main-panel-box-right-text{
    flex: 1;
    .main-panel-box-right-title {
      font-size: 14px;
      font-weight: 600;
      color: #ffffff;
      margin-bottom: 6px;
    }
    .main-panel-box-right-text {
      font-size: 12px;
      display: flex;
      justify-content: space-between;
      padding-right: 60px;
      margin-bottom: 4px;
    }
    .main-panel-box-right-progress{
      :deep(.el-progress__text){
    .main-panel-box-right-progress {
      :deep(.el-progress__text) {
        color: white !important;
      }
    }
  }
}
.main-panel-container {
  display: flex;
  flex-direction: column;
  gap: 12px;
  height: 100%;
  overflow-y: auto;
}
.main-panel {
  display: flex;
  flex-direction: column;
@@ -89,5 +127,6 @@
  padding: 18px;
  width: 100%;
  height: 449px;
  overflow: hidden;
}
</style>
src/views/reportAnalysis/qualityAnalysis/index.vue
@@ -13,7 +13,7 @@
    <!-- 顶部标题栏 -->
    <div class="dashboard-header">
      <div class="factory-name">生产数据分析</div>
      <div class="factory-name">进销质量类分析</div>
    </div>
    <!-- 主要内容区域 -->
@@ -32,7 +32,6 @@
      <!-- 右侧区域 -->
      <div class="right-panel">
        <RightTop />
        <RightBottom />
      </div>