From 90efce7ecac1675916372faa9328578cf3e61c00 Mon Sep 17 00:00:00 2001
From: 张诺 <zhang_12370@163.com>
Date: 星期一, 02 二月 2026 16:16:57 +0800
Subject: [PATCH] BI大屏质量分析模块

---
 src/views/reportAnalysis/qualityAnalysis/components/ProductTypeSwitch.vue |   85 ++
 src/views/reportAnalysis/qualityAnalysis/components/center-bottom.vue     |  156 +++
 src/views/reportAnalysis/qualityAnalysis/index.vue                        |  290 +++++++
 src/views/reportAnalysis/qualityAnalysis/components/right-top.vue         |   93 ++
 src/views/reportAnalysis/qualityAnalysis/components/left-top.vue          |  425 ++++++++++
 src/views/reportAnalysis/qualityAnalysis/components/CarouselCards.vue     |  306 +++++++
 src/views/reportAnalysis/qualityAnalysis/components/center-center.vue     |  346 ++++++++
 src/views/reportAnalysis/qualityAnalysis/components/right-bottom.vue      |  250 ++++++
 src/views/reportAnalysis/qualityAnalysis/components/left-bottom.vue       |  170 ++++
 src/views/reportAnalysis/qualityAnalysis/components/center-top.vue        |  137 +++
 src/views/reportAnalysis/qualityAnalysis/components/DateTypeSwitch.vue    |   94 ++
 src/views/reportAnalysis/qualityAnalysis/components/PanelHeader.vue       |   33 
 12 files changed, 2,385 insertions(+), 0 deletions(-)

diff --git a/src/views/reportAnalysis/qualityAnalysis/components/CarouselCards.vue b/src/views/reportAnalysis/qualityAnalysis/components/CarouselCards.vue
new file mode 100644
index 0000000..0498824
--- /dev/null
+++ b/src/views/reportAnalysis/qualityAnalysis/components/CarouselCards.vue
@@ -0,0 +1,306 @@
+<template>
+  <div class="carousel-cards">
+    <button 
+      v-if="canScrollLeft"
+      class="nav-button nav-button-left"
+      @click="scrollLeftFn"
+    >
+      <img src="@/assets/BI/jiantou.png" alt="宸︾澶�" />
+    </button>
+    <div 
+      class="cards-container" 
+      :style="{ '--visible-count': visibleCount }"
+      ref="cardsContainerRef"
+    >
+      <div
+        v-for="(item, index) in items"
+        :key="index"
+        class="card-item"
+      >
+        <div v-if="item.icon" class="card-icon" :style="{ backgroundImage: `url(${item.icon})` }"></div>
+        <div class="card-title">
+          <div class="card-label">{{ item.label }}</div>
+          <div class="card-value">
+            <span class="value-number">{{ item.value }}</span>
+            <span class="value-unit">{{ item.unit }}</span>
+          </div>
+          <div v-if="item.rate ?? item.ratio ?? item.percent" class="card-rate">
+            <span class="rate-value">{{ item.rate ?? item.ratio ?? item.percent }}%</span>
+          </div>
+        </div>
+      </div>
+    </div>
+    <button 
+      v-if="canScrollRight"
+      class="nav-button nav-button-right"
+      @click="scrollRightFn"
+    >
+      <img src="@/assets/BI/jiantou.png" alt="鍙崇澶�" />
+    </button>
+  </div>
+</template>
+
+<script setup>
+import { ref, onMounted, onBeforeUnmount, nextTick, watch, computed } from 'vue'
+
+const props = defineProps({
+  items: {
+    type: Array,
+    default: () => [],
+    validator: (value) => {
+      return value.every(item => 
+        item && typeof item.label !== 'undefined' && 
+        typeof item.value !== 'undefined' && 
+        typeof item.unit !== 'undefined'
+      )
+    }
+  },
+  visibleCount: {
+    type: Number,
+    default: 3
+  }
+})
+
+const cardsContainerRef = ref(null)
+const currentScrollLeft = ref(0)
+const maxScrollLeft = ref(0)
+
+// 妫�鏌ユ槸鍚﹀彲浠ュ悜宸︽粴鍔�
+const canScrollLeft = computed(() => {
+  return currentScrollLeft.value > 0
+})
+
+// 妫�鏌ユ槸鍚﹀彲浠ュ悜鍙虫粴鍔�
+const canScrollRight = computed(() => {
+  return currentScrollLeft.value < maxScrollLeft.value
+})
+
+// 鏇存柊婊氬姩鐘舵��
+const updateScrollState = () => {
+  const container = cardsContainerRef.value
+  if (!container) return
+  
+  currentScrollLeft.value = container.scrollLeft
+  maxScrollLeft.value = container.scrollWidth - container.clientWidth
+}
+
+// 鍚戝乏婊氬姩
+const scrollLeftFn = () => {
+  const container = cardsContainerRef.value
+  if (!container) return
+  
+  const scrollItems = Array.from(container.querySelectorAll('.card-item'))
+  if (scrollItems.length === 0) return
+  
+  const itemWidth = scrollItems[0]?.offsetWidth || 0
+  const gap = 12
+  const scrollDistance = itemWidth + gap
+  
+  container.scrollBy({
+    left: -scrollDistance,
+    behavior: 'smooth'
+  })
+  
+  // 寤惰繜鏇存柊鐘舵�侊紝绛夊緟婊氬姩鍔ㄧ敾瀹屾垚
+  setTimeout(() => {
+    updateScrollState()
+  }, 300)
+}
+
+// 鍚戝彸婊氬姩
+const scrollRightFn = () => {
+  const container = cardsContainerRef.value
+  if (!container) return
+  
+  const scrollItems = Array.from(container.querySelectorAll('.card-item'))
+  if (scrollItems.length === 0) return
+  
+  const itemWidth = scrollItems[0]?.offsetWidth || 0
+  const gap = 12
+  const scrollDistance = itemWidth + gap
+  
+  container.scrollBy({
+    left: scrollDistance,
+    behavior: 'smooth'
+  })
+  
+  // 寤惰繜鏇存柊鐘舵�侊紝绛夊緟婊氬姩鍔ㄧ敾瀹屾垚
+  setTimeout(() => {
+    updateScrollState()
+  }, 300)
+}
+
+// 鐩戝惉 items 鍙樺寲锛屾洿鏂版粴鍔ㄧ姸鎬�
+watch(() => props.items, () => {
+  nextTick(() => {
+    updateScrollState()
+  })
+}, { deep: true })
+
+onMounted(() => {
+  nextTick(() => {
+    updateScrollState()
+    // 鐩戝惉婊氬姩浜嬩欢
+    const container = cardsContainerRef.value
+    if (container) {
+      container.addEventListener('scroll', updateScrollState)
+    }
+  })
+})
+
+onBeforeUnmount(() => {
+  // 娓呯悊婊氬姩浜嬩欢鐩戝惉鍣�
+  const container = cardsContainerRef.value
+  if (container) {
+    container.removeEventListener('scroll', updateScrollState)
+  }
+})
+</script>
+
+<style scoped>
+.carousel-cards {
+  width: 100%;
+  overflow: hidden;
+  position: relative;
+  display: flex;
+  align-items: center;
+}
+
+.cards-container {
+  display: flex;
+  gap: 12px;
+  width: 100%;
+  overflow-x: auto;
+  overflow-y: hidden;
+  scrollbar-width: none; /* Firefox */
+  -ms-overflow-style: none; /* IE and Edge */
+  padding-bottom: 4px;
+  scroll-behavior: smooth;
+}
+
+.cards-container::-webkit-scrollbar {
+  display: none; /* Chrome, Safari, Opera */
+}
+
+.nav-button {
+  position: absolute;
+  top: 50%;
+  transform: translateY(-50%);
+  width: 32px;
+  height: 32px;
+  background: rgba(26, 88, 176, 0.6);
+  border: 1px solid rgba(26, 88, 176, 0.8);
+  border-radius: 50%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  cursor: pointer;
+  z-index: 10;
+  transition: all 0.3s ease;
+  padding: 0;
+}
+
+.nav-button:hover {
+  background: rgba(26, 88, 176, 0.8);
+  transform: translateY(-50%) scale(1.1);
+}
+
+.nav-button-left {
+  left: -16px;
+}
+
+.nav-button-left img {
+  width: 16px;
+  height: 16px;
+  transform: rotate(180deg);
+}
+
+.nav-button-right {
+  right: -16px;
+}
+
+.nav-button-right img {
+  width: 16px;
+  height: 16px;
+}
+
+.card-item {
+  flex: 0 0 calc((100% - (var(--visible-count) - 1) * 12px) / var(--visible-count));
+  min-width: calc((100% - (var(--visible-count) - 1) * 12px) / var(--visible-count));
+  display: flex;
+  align-items: center;
+  background: linear-gradient(269deg, rgba(27,57,126,0.13) 0%, rgba(33,137,206,0.33) 98.13%, #24AFF4 100%);
+  border-radius: 8px 8px 8px 8px;
+  padding: 12px 16px;
+  transition: all 0.3s ease;
+}
+
+.card-item:hover {
+  transform: translateY(-2px);
+}
+
+.card-icon {
+  width: 80px;
+  height: 60px;
+  background-size: cover;
+  background-position: center;
+  background-repeat: no-repeat;
+  flex-shrink: 0;
+  margin-right: 12px;
+}
+
+.card-title {
+  display: flex;
+  align-items: flex-start;
+  flex-direction: column;
+  flex: 1;
+}
+
+.card-label {
+  font-weight: 400;
+  font-size: 14px;
+  color: #FFFFFF;
+  margin-bottom: 4px;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  width: 100%;
+}
+
+.card-value {
+  display: flex;
+  align-items: baseline;
+  gap: 4px;
+}
+
+.card-rate {
+  margin-top: 4px;
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  font-weight: 400;
+  font-size: 12px;
+  color: rgba(255, 255, 255, 0.85);
+}
+
+.rate-label {
+  opacity: 0.85;
+}
+
+.rate-value {
+  font-weight: 500;
+}
+
+.value-number {
+  font-weight: 400;
+  font-size: 14px;
+  color: #FFFFFF;
+  line-height: 1;
+}
+
+.value-unit {
+  font-size: 14px;
+  color: #FFFFFF;
+  font-weight: 400;
+}
+</style>
diff --git a/src/views/reportAnalysis/qualityAnalysis/components/DateTypeSwitch.vue b/src/views/reportAnalysis/qualityAnalysis/components/DateTypeSwitch.vue
new file mode 100644
index 0000000..0c57b25
--- /dev/null
+++ b/src/views/reportAnalysis/qualityAnalysis/components/DateTypeSwitch.vue
@@ -0,0 +1,94 @@
+<template>
+  <el-radio-group
+    v-model="currentValue"
+    class="date-type-switch"
+    @change="handleChange"
+  >
+    <el-radio-button :label="1">鍛�</el-radio-button>
+    <el-radio-button :label="2">鏈�</el-radio-button>
+    <el-radio-button :label="3">瀛e害</el-radio-button>
+  </el-radio-group>
+</template>
+
+<script setup>
+import { ref, watch } from 'vue'
+
+const props = defineProps({
+  modelValue: {
+    type: Number,
+    default: 1, // 榛樿閫変腑"鍛�"
+  },
+})
+
+const emit = defineEmits(['update:modelValue', 'change'])
+
+const currentValue = ref(props.modelValue)
+
+// 鐩戝惉澶栭儴鍊煎彉鍖�
+watch(
+  () => props.modelValue,
+  (newVal) => {
+    currentValue.value = newVal
+  }
+)
+
+// 澶勭悊鍊煎彉鍖�
+const handleChange = (value) => {
+  emit('update:modelValue', value)
+  emit('change', value)
+}
+</script>
+
+<style scoped>
+.date-type-switch {
+  display: inline-flex;
+}
+
+/* 鏈�変腑鐘舵�佺殑鏍峰紡 */
+.date-type-switch :deep(.el-radio-button__inner) {
+  background-color: rgba(26, 88, 176, 0.3);
+  color: rgba(184, 200, 224, 0.8);
+  border-color: rgba(255, 255, 255, 0.2);
+  border-radius: 0;
+  padding: 6px 20px;
+  font-size: 14px;
+  transition: all 0.3s;
+}
+
+/* 绗竴涓寜閽乏渚у渾瑙� */
+.date-type-switch :deep(.el-radio-button:first-child .el-radio-button__inner) {
+  border-top-left-radius: 4px;
+  border-bottom-left-radius: 4px;
+}
+
+/* 鏈�鍚庝竴涓寜閽彸渚у渾瑙� */
+.date-type-switch :deep(.el-radio-button:last-child .el-radio-button__inner) {
+  border-top-right-radius: 4px;
+  border-bottom-right-radius: 4px;
+}
+
+/* 鎸夐挳涔嬮棿鐨勫垎闅旂嚎 */
+.date-type-switch :deep(.el-radio-button:not(:last-child) .el-radio-button__inner) {
+  border-right: 1px solid rgba(255, 255, 255, 0.2);
+}
+
+/* 閫変腑鐘舵�佺殑鏍峰紡 */
+.date-type-switch :deep(.el-radio-button__original-radio:checked + .el-radio-button__inner) {
+  background: linear-gradient(180deg, #3378ff 0%, #00a4ed 100%);
+  color: #ffffff;
+  border-color: rgba(51, 120, 255, 0.8);
+  box-shadow: none;
+}
+
+/* 鎮仠鏁堟灉 */
+.date-type-switch :deep(.el-radio-button__inner:hover) {
+  color: rgba(184, 200, 224, 1);
+  border-color: rgba(255, 255, 255, 0.3);
+}
+
+/* 閫変腑鐘舵�佹偓鍋� */
+.date-type-switch :deep(.el-radio-button__original-radio:checked + .el-radio-button__inner:hover) {
+  background: linear-gradient(180deg, #4e8aff 0%, #4ee4ff 100%);
+  color: #ffffff;
+}
+</style>
diff --git a/src/views/reportAnalysis/qualityAnalysis/components/PanelHeader.vue b/src/views/reportAnalysis/qualityAnalysis/components/PanelHeader.vue
new file mode 100644
index 0000000..313f1df
--- /dev/null
+++ b/src/views/reportAnalysis/qualityAnalysis/components/PanelHeader.vue
@@ -0,0 +1,33 @@
+<template>
+  <div class="panel-header">
+    <span class="panel-title">{{ title }}</span>
+  </div>
+</template>
+
+<script setup>
+defineProps({
+  title: {
+    type: String,
+    required: true,
+    default: ''
+  }
+})
+</script>
+
+<style scoped>
+.panel-header {
+  background-image: url("@/assets/BI/kehuhetongback@2x.png");
+  background-size: 100% 100%;
+  background-position: center;
+  background-repeat: no-repeat;
+}
+
+.panel-title {
+  width: 100%;
+  font-weight: 500;
+  font-size: 16px;
+  color: #D9ECFF;
+  padding-left: 46px;
+  line-height: 36px;
+}
+</style>
diff --git a/src/views/reportAnalysis/qualityAnalysis/components/ProductTypeSwitch.vue b/src/views/reportAnalysis/qualityAnalysis/components/ProductTypeSwitch.vue
new file mode 100644
index 0000000..87cde44
--- /dev/null
+++ b/src/views/reportAnalysis/qualityAnalysis/components/ProductTypeSwitch.vue
@@ -0,0 +1,85 @@
+<template>
+  <el-radio-group
+    v-model="currentValue"
+    class="product-type-switch"
+    @change="handleChange"
+  >
+    <el-radio-button :label="1">鍘熸潗鏂�</el-radio-button>
+    <el-radio-button :label="3">鍗婃垚鍝�</el-radio-button>
+    <el-radio-button :label="2">鎴愬搧</el-radio-button>
+  </el-radio-group>
+</template>
+
+<script setup>
+import { ref, watch } from 'vue'
+
+const props = defineProps({
+  modelValue: {
+    type: Number,
+    default: 1, // 榛樿閫変腑"鍘熸潗鏂�"
+  },
+})
+
+const emit = defineEmits(['update:modelValue', 'change'])
+
+const currentValue = ref(props.modelValue)
+
+watch(
+  () => props.modelValue,
+  (newVal) => {
+    currentValue.value = newVal
+  }
+)
+
+const handleChange = (value) => {
+  emit('update:modelValue', value)
+  emit('change', value)
+}
+</script>
+
+<style scoped>
+.product-type-switch {
+  display: inline-flex;
+}
+
+.product-type-switch :deep(.el-radio-button__inner) {
+  background-color: rgba(26, 88, 176, 0.3);
+  color: rgba(184, 200, 224, 0.8);
+  border-color: rgba(255, 255, 255, 0.2);
+  border-radius: 0;
+  padding: 6px 20px;
+  font-size: 14px;
+  transition: all 0.3s;
+}
+
+.product-type-switch :deep(.el-radio-button:first-child .el-radio-button__inner) {
+  border-top-left-radius: 4px;
+  border-bottom-left-radius: 4px;
+}
+
+.product-type-switch :deep(.el-radio-button:last-child .el-radio-button__inner) {
+  border-top-right-radius: 4px;
+  border-bottom-right-radius: 4px;
+}
+
+.product-type-switch :deep(.el-radio-button:not(:last-child) .el-radio-button__inner) {
+  border-right: 1px solid rgba(255, 255, 255, 0.2);
+}
+
+.product-type-switch :deep(.el-radio-button__original-radio:checked + .el-radio-button__inner) {
+  background: linear-gradient(180deg, #3378ff 0%, #00a4ed 100%);
+  color: #ffffff;
+  border-color: rgba(51, 120, 255, 0.8);
+  box-shadow: none;
+}
+
+.product-type-switch :deep(.el-radio-button__inner:hover) {
+  color: rgba(184, 200, 224, 1);
+  border-color: rgba(255, 255, 255, 0.3);
+}
+
+.product-type-switch :deep(.el-radio-button__original-radio:checked + .el-radio-button__inner:hover) {
+  background: linear-gradient(180deg, #4e8aff 0%, #4ee4ff 100%);
+  color: #ffffff;
+}
+</style>
diff --git a/src/views/reportAnalysis/qualityAnalysis/components/center-bottom.vue b/src/views/reportAnalysis/qualityAnalysis/components/center-bottom.vue
new file mode 100644
index 0000000..2f2fb66
--- /dev/null
+++ b/src/views/reportAnalysis/qualityAnalysis/components/center-bottom.vue
@@ -0,0 +1,156 @@
+<template>
+  <div>
+    <PanelHeader title="宸ュ崟鎵ц鏁堢巼鍒嗘瀽" />
+    <div class="main-panel panel-item-customers">
+      <Echarts
+          ref="chart"
+          :chartStyle="chartStyle"
+          :grid="grid"
+          :legend="barLegend"
+          :series="chartSeries"
+          :tooltip="tooltip"
+          :xAxis="xAxis1"
+          :yAxis="yAxis1"
+          :options="{ backgroundColor: 'transparent', textStyle: { color: '#B8C8E0' } }"
+          style="height: 260px"
+      />
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref, onMounted } from 'vue'
+import { qualityStatistics } from '@/api/viewIndex.js'
+import PanelHeader from './PanelHeader.vue'
+import Echarts from '@/components/Echarts/echarts.vue'
+
+const chartStyle = {
+  width: '100%',
+  height: '135%',
+}
+
+const grid = { left: '3%', right: '4%', bottom: '3%', top: '10%', containLabel: true }
+
+const barLegend = {
+  show: true,
+  textStyle: { color: '#B8C8E0' },
+  data: ['寮�宸�', '瀹屾垚'],
+}
+
+// 鏌辩姸鍥撅細寮�宸ャ�佸畬鎴愶紱鎶樼嚎鍥撅細鑹搧鐜囷紙棰滆壊 rgba(90, 216, 166, 1)锛�
+const chartSeries = ref([
+  {
+    name: '寮�宸�',
+    type: 'bar',
+    barWidth: 20,
+    barGap: '40%',
+    emphasis: { focus: 'series' },
+    itemStyle: {
+      color: {
+        type: 'linear',
+        x: 0,
+        y: 0,
+        x2: 0,
+        y2: 1,
+        colorStops: [
+          { offset: 1, color: 'rgba(0, 164, 237, 0)' },
+          { offset: 0, color: 'rgba(78, 228, 255, 1)' },
+        ],
+      },
+    },
+    data: [],
+  },
+  {
+    name: '瀹屾垚',
+    type: 'bar',
+    barGap: '40%',
+    barWidth: 20,
+    emphasis: { focus: 'series' },
+    itemStyle: {
+      color: {
+        type: 'linear',
+        x: 0,
+        y: 0,
+        x2: 0,
+        y2: 1,
+        colorStops: [
+          { offset: 1, color: 'rgba(83, 126, 245, 0.19)' },
+          { offset: 0, color: 'rgba(144, 97, 248, 1)' },
+        ],
+      },
+    },
+    data: [],
+  },
+])
+
+const tooltip = {
+  trigger: 'axis',
+  axisPointer: { type: 'cross' },
+  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>`
+    })
+    return result
+  },
+}
+
+const xAxis1 = ref([
+  { type: 'category', axisTick: { show: false }, axisLabel: { color: '#B8C8E0' }, data: [] },
+])
+const yAxis1 = [
+  { type: 'value', name: '浠�', axisLabel: { color: '#B8C8E0' }, nameTextStyle: { color: '#B8C8E0' } },
+  {
+    type: 'value',
+    name: '杩�7澶�',
+    min: 0,
+    max: 100,
+    axisLabel: { color: '#B8C8E0', formatter: '{value}%' },
+    nameTextStyle: { color: '#B8C8E0' },
+    splitLine: { lineStyle: { color: 'rgba(184, 200, 224, 0.2)' } },
+  },
+]
+
+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)
+      })
+}
+
+onMounted(() => {
+  fetchData()
+})
+</script>
+
+<style scoped>
+.main-panel {
+  display: flex;
+  flex-direction: column;
+  gap: 20px;
+}
+
+.panel-item-customers {
+  border: 1px solid #1a58b0;
+  padding: 18px;
+  width: 100%;
+  height: 449px;
+}
+</style>
diff --git a/src/views/reportAnalysis/qualityAnalysis/components/center-center.vue b/src/views/reportAnalysis/qualityAnalysis/components/center-center.vue
new file mode 100644
index 0000000..8024092
--- /dev/null
+++ b/src/views/reportAnalysis/qualityAnalysis/components/center-center.vue
@@ -0,0 +1,346 @@
+<template>
+  <div>
+    <div class="warn-panel">
+      <div class="warn-header">
+        <div class="warn-header-left">
+          <div class="warn-badge"></div>
+          <span class="warn-title">涓嶅悎鏍奸璀�</span>
+        </div>
+        <div class="warn-range" @click="handleRangeClick">杩�7澶�</div>
+      </div>
+
+      <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-text" :title="item.title">{{ item.title }}</div>
+            <div class="warn-action" @click.stop="openWarning(item)">鏌ョ湅</div>
+            <div class="warn-date">{{ item.date }}</div>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { computed, getCurrentInstance, ref, onMounted } from 'vue'
+import Echarts from '@/components/Echarts/echarts.vue'
+import { qualityUnqualifiedListPage } from '@/api/qualityManagement/nonconformingManagement.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 TAG_COLORS = {
+  raw: '#7C4DFF',
+  final: '#F5A000',
+  semi: '#FF66CC',
+}
+
+const tagClass = (type) => {
+  if (type === 'raw') return 'tag-raw'
+  if (type === 'final') return 'tag-final'
+  return 'tag-semi'
+}
+
+const pieChartStyle = { width: '100%', height: '100%' }
+
+const pieOptions = {
+  backgroundColor: 'transparent',
+  textStyle: { color: '#B8C8E0' },
+}
+
+const pieTooltip = {
+  trigger: 'item',
+  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 } },
+  ]
+})
+
+const pieSeries = computed(() => {
+  return [
+    {
+      type: 'pie',
+      radius: ['0%', '68%'],
+      center: ['50%', '50%'],
+      startAngle: 90,
+      clockwise: true,
+      avoidLabelOverlap: true,
+      label: { show: false },
+      labelLine: { show: false },
+      itemStyle: {
+        borderColor: '#071a3a',
+        borderWidth: 4,
+        shadowBlur: 14,
+        shadowColor: 'rgba(0, 0, 0, 0.35)',
+      },
+      data: pieData.value,
+    },
+    {
+      // 鍐呭湀鏆楃幆锛屽寮哄眰娆�
+      type: 'pie',
+      radius: ['70%', '74%'],
+      center: ['50%', '50%'],
+      silent: true,
+      label: { show: false },
+      labelLine: { show: false },
+      itemStyle: { color: 'rgba(78, 228, 255, 0.12)' },
+      data: [1],
+    },
+  ]
+})
+
+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
+
+    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,
+      }
+    })
+  } catch (e) {
+    // 鎺ュ彛澶辫触鍒欎繚鎸� mock
+    console.error('鑾峰彇涓嶅悎鏍奸璀﹀け璐�:', e)
+  }
+}
+
+const openWarning = (item) => {
+  const title = `銆�${item.typeText}銆�${item.title}`
+  const content = `${title}鏃堕棿锛�${item.date}`
+  if (proxy?.$modal?.alert) {
+    proxy.$modal.alert(content)
+    return
+  }
+  // 鍏滃簳锛氭病鏈夊叏灞� modal 鏃剁敤 console
+  console.log('warning:', { ...item })
+}
+
+const handleRangeClick = () => {
+  // 鍏堟寜鎴浘鍋氶潤鎬佲�滆繎7澶┾�濓紝鍚庣画鏈夌湡瀹炵瓫閫夐渶姹傚啀鎺ュ叆
+}
+
+onMounted(() => {
+  fetchWarnings()
+})
+</script>
+
+<style scoped>
+.warn-panel {
+  border: 1px solid #1a58b0;
+  padding: 0 18px 18px;
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+  height: 100%;
+  box-sizing: border-box;
+}
+
+.warn-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  border-bottom: 1px solid;
+  border-image: linear-gradient(
+      270deg,
+      rgba(0, 126, 255, 0) 0%,
+      rgba(0, 126, 255, 0.4549) 35%,
+      #007eff 78%,
+      #007eff 100%
+    )
+    1;
+  padding: 10px 0 6px;
+}
+
+.warn-header-left {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+}
+
+.warn-badge {
+  width: 18px;
+  height: 18px;
+  background: linear-gradient(180deg, #2aa8ff 0%, #4ee4ff 100%);
+  clip-path: polygon(50% 0%, 100% 50%, 50% 100%, 0% 50%);
+  box-shadow: 0 0 12px rgba(78, 228, 255, 0.25);
+}
+
+.warn-title {
+  font-weight: 600;
+  font-size: 18px;
+  background: linear-gradient(360deg, #056dff 0%, #43e8fc 100%);
+  -webkit-background-clip: text;
+  -webkit-text-fill-color: transparent;
+  background-clip: text;
+  line-height: 24px;
+}
+
+.warn-range {
+  height: 32px;
+  padding: 0 14px;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  border-radius: 4px;
+  color: #ffffff;
+  font-weight: 600;
+  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;
+}
+
+.warn-body {
+  display: grid;
+  gap: 18px;
+  align-items: stretch;
+  min-height: 260px;
+}
+
+.warn-list {
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+  padding-top: 6px;
+}
+
+.warn-item {
+  display: grid;
+  grid-template-columns: 88px 1fr auto 110px;
+  align-items: center;
+  gap: 12px;
+  color: #b8c8e0;
+  font-size: 14px;
+  line-height: 1;
+  padding: 6px 0;
+  border-radius: 4px;
+  transition: background-color 0.2s, color 0.2s;
+}
+
+.warn-item:hover {
+  color: #ff4d4f;
+  background-color: rgba(255, 77, 79, 0.06);
+}
+
+.warn-item:hover .warn-text {
+  color: #ff4d4f;
+}
+
+.warn-tag {
+  height: 28px;
+  padding: 0 10px;
+  border-radius: 4px;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  font-weight: 700;
+  color: #ffffff;
+}
+
+.tag-raw {
+  background: #7c4dff;
+}
+
+.tag-final {
+  background: #f5a000;
+}
+
+.tag-semi {
+  background: #ff66cc;
+}
+
+.warn-text {
+  color: #e8f1ff;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+
+.warn-action {
+  color: #ff4d4f;
+  font-weight: 700;
+  white-space: nowrap;
+  cursor: pointer;
+}
+
+.warn-date {
+  color: rgba(184, 200, 224, 0.75);
+  white-space: nowrap;
+}
+
+.warn-chart {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.chart-frame {
+  width: 100%;
+  height: 260px;
+  border: 2px dashed rgba(184, 200, 224, 0.35);
+  position: relative;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: radial-gradient(circle at 50% 50%, rgba(78, 228, 255, 0.08) 0%, rgba(0, 0, 0, 0) 65%);
+}
+
+/* 澶栧湀鍒诲害鐜� */
+.chart-frame::before {
+  content: '';
+  position: absolute;
+  width: 220px;
+  height: 220px;
+  border-radius: 50%;
+  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.5;
+  pointer-events: none;
+}
+
+/* 鍗佸瓧杈呭姪绾� */
+.chart-frame::after {
+  content: '';
+  position: absolute;
+  width: 240px;
+  height: 240px;
+  background:
+    linear-gradient(to right, rgba(78, 228, 255, 0) 0%, rgba(78, 228, 255, 0.55) 50%, rgba(78, 228, 255, 0) 100%),
+    linear-gradient(to bottom, rgba(78, 228, 255, 0) 0%, rgba(78, 228, 255, 0.55) 50%, rgba(78, 228, 255, 0) 100%);
+  background-size: 100% 1px, 1px 100%;
+  background-position: center, center;
+  background-repeat: no-repeat;
+  opacity: 0.35;
+  pointer-events: none;
+}
+</style>
diff --git a/src/views/reportAnalysis/qualityAnalysis/components/center-top.vue b/src/views/reportAnalysis/qualityAnalysis/components/center-top.vue
new file mode 100644
index 0000000..0937b32
--- /dev/null
+++ b/src/views/reportAnalysis/qualityAnalysis/components/center-top.vue
@@ -0,0 +1,137 @@
+<template>
+  <div>
+    <!-- 椤堕儴缁熻鍗$墖 -->
+    <div class="stats-cards">
+      <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>
+          <span class="card-value">{{ item.value }}</span>
+          <div class="card-compare" :class="compareClass(Number(item.rate))">
+            <span>鍚屾瘮</span>
+            <span class="compare-value">{{ formatPercent(item.rate) }}</span>
+            <span class="compare-icon">{{ Number(item.rate) >= 0 ? '鈫�' : '鈫�' }}</span>
+          </div>
+        </div>
+      </div>
+    </div>
+
+  </div>
+</template>
+
+<script setup>
+import { ref, onMounted } from 'vue'
+import { salesPurchaseStorageProductCount } from '@/api/viewIndex.js'
+
+const statItems = ref([])
+
+const formatPercent = (val) => {
+  const num = Number(val) || 0
+  return `${num.toFixed(2)}%`
+}
+
+const compareClass = (val) => (val >= 0 ? 'compare-up' : 'compare-down')
+
+const fetchData = () => {
+  salesPurchaseStorageProductCount()
+    .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,
+        }))
+      }
+    })
+    .catch((err) => {
+      console.error('鑾峰彇閿�鍞�/閲囪喘/鍌ㄥ瓨浜у搧鏁板け璐�:', err)
+    })
+}
+
+onMounted(() => {
+  fetchData()
+})
+</script>
+
+<style scoped>
+.stats-cards {
+  display: flex;
+  gap: 30px;
+}
+
+.stat-card {
+  flex: 1;
+  display: flex;
+  align-items: center;
+  background-image: url('@/assets/BI/border@2x.png');
+  background-size: 100% 100%;
+  background-position: center;
+  background-repeat: no-repeat;
+  height: 142px;
+}
+
+.card-icon {
+  width: 100px;
+  height: 100px;
+  margin: 20px 20px 0 10px;
+}
+
+.card-content {
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+}
+
+.card-value {
+  font-weight: 500;
+  font-size: 40px;
+  background: linear-gradient(360deg, #008bfd 0%, #ffffff 100%);
+  -webkit-background-clip: text;
+  -webkit-text-fill-color: transparent;
+  background-clip: text;
+}
+
+.card-label {
+  font-weight: 400;
+  font-size: 19px;
+  color: rgba(208, 231, 255, 0.7);
+}
+
+.card-compare {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  font-size: 15px;
+  color: #d0e7ff;
+}
+
+.card-compare > span:first-child {
+  font-size: 13px;
+  opacity: 0.8;
+}
+
+.compare-value {
+  font-weight: 600;
+}
+
+.compare-icon {
+  font-size: 14px;
+  position: relative;
+  top: -1px; /* 杞诲井涓婄Щ锛岃绠ご涓庢枃瀛楀瀭鐩村眳涓榻� */
+}
+
+.compare-up .compare-value,
+.compare-up .compare-icon {
+  color: #00c853;
+}
+
+.compare-down .compare-value,
+.compare-down .compare-icon {
+  color: #ff5252;
+}
+
+</style>
diff --git a/src/views/reportAnalysis/qualityAnalysis/components/left-bottom.vue b/src/views/reportAnalysis/qualityAnalysis/components/left-bottom.vue
new file mode 100644
index 0000000..33f431d
--- /dev/null
+++ b/src/views/reportAnalysis/qualityAnalysis/components/left-bottom.vue
@@ -0,0 +1,170 @@
+<template>
+  <div>
+    <PanelHeader title="鍦ㄥ埗鍝佺粺璁″垎鏋�" />
+    <div class="main-panel panel-item-customers">
+      <CarouselCards :items="cardItems" :visible-count="3" />
+      <div class="chart-wrapper">
+        <Echarts
+          ref="chart"
+          :chartStyle="chartStyle"
+          :grid="grid"
+          :legend="workInProcessBarLegend"
+          :series="workInProcessBarSeries"
+          :tooltip="tooltip"
+          :xAxis="workInProcessXAxis"
+          :yAxis="workInProcessYAxis"
+          :options="{ backgroundColor: 'transparent', textStyle: { color: '#B8C8E0' } }"
+          style="height: 100%"
+        />
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref, onMounted } from 'vue'
+import Echarts from '@/components/Echarts/echarts.vue'
+import PanelHeader from './PanelHeader.vue'
+import CarouselCards from './CarouselCards.vue'
+import { getWorkInProcessTurnover } from '@/api/viewIndex.js'
+
+// 鍦ㄥ埗鍝佸懆杞粺璁″璞�
+const workInProcessStatistics = ref({
+  totalQuantity: 0,
+  avgTurnoverDays: 0,
+  turnoverEfficiency: 0,
+})
+
+// 杞挱鍗$墖鏁版嵁锛堢敱 workInProcessStatistics 鍚屾锛�
+const cardItems = ref([])
+
+const chartStyle = {
+  width: '100%',
+  height: '100%',
+}
+
+const grid = {
+  left: '3%',
+  right: '4%',
+  bottom: '3%',
+  containLabel: true,
+}
+
+const tooltip = {
+  trigger: 'axis',
+  axisPointer: { type: 'shadow' },
+  formatter: function (params) {
+    let result = params[0].axisValueLabel + '<br/>'
+    params.forEach((item) => {
+      result += `<div style="color: #B8C8E0">${item.marker} ${item.seriesName}: ${item.value}</div>`
+    })
+    return result
+  },
+}
+
+// 鍦ㄥ埗鍝佸伐搴忔煴鐘跺浘閰嶇疆
+const workInProcessXAxis = ref([
+  {
+    type: 'category',
+    axisTick: { show: false },
+    axisLabel: { color: '#B8C8E0' },
+    data: [],
+  },
+])
+const workInProcessYAxis = [
+  {
+    type: 'value',
+    axisLabel: { color: '#B8C8E0' },
+    name: '',
+  },
+]
+const workInProcessBarLegend = {
+  show: false,
+  textStyle: { color: '#B8C8E0' },
+  data: [],
+}
+const workInProcessBarSeries = ref([
+  {
+    name: '鍦ㄥ埗鍝佹暟閲�',
+    type: 'bar',
+    barWidth: 25,
+    barGap: 0,
+    emphasis: { focus: 'series' },
+    itemStyle: {
+      color: {
+        type: 'linear',
+        x: 0,
+        y: 0,
+        x2: 0,
+        y2: 1,
+        colorStops: [
+        { offset: 1, color: 'rgba(0,164,237,0)' },
+        { offset: 0, color: '#4EE4FF' },
+        ],
+      },
+    },
+    label: {
+      show: true,
+      position: 'top',
+      color: '#B8C8E0',
+    },
+    data: [],
+  },
+])
+
+const workInProcessTurnoverInfo = () => {
+  getWorkInProcessTurnover()
+    .then((res) => {
+      if (!res || !res.data) return
+      const stats = {
+        totalQuantity: res.data.totalOrderCount || 0,
+        avgTurnoverDays: res.data.averageTurnoverDays || 0,
+        turnoverEfficiency: res.data.turnoverEfficiency || 0,
+      }
+      workInProcessStatistics.value = stats
+      cardItems.value = [
+        { label: '鎬诲湪鍒舵暟閲�', value: stats.totalQuantity, unit: '浠�' },
+        { label: '骞冲潎鍛ㄨ浆澶╂暟', value: stats.avgTurnoverDays, unit: '澶�' },
+        { label: '鍛ㄨ浆鏁堢巼', value: stats.turnoverEfficiency, unit: '%' },
+      ]
+      if (res.data.processDetails && Array.isArray(res.data.processDetails)) {
+        workInProcessXAxis.value[0].data = res.data.processDetails
+      } else {
+        workInProcessXAxis.value[0].data = []
+      }
+      if (res.data.processQuantityDetails && Array.isArray(res.data.processQuantityDetails)) {
+        workInProcessBarSeries.value[0].data = res.data.processQuantityDetails
+      } else {
+        workInProcessBarSeries.value[0].data = []
+      }
+    })
+    .catch((err) => {
+      console.error('鑾峰彇鍦ㄥ埗鍝佸懆杞粺璁″け璐�:', err)
+    })
+}
+
+onMounted(() => {
+  workInProcessTurnoverInfo()
+})
+</script>
+
+<style scoped>
+.main-panel {
+  display: flex;
+  flex-direction: column;
+  gap: 20px;
+}
+
+.panel-item-customers {
+  border: 1px solid #1a58b0;
+  padding: 18px;
+  width: 100%;
+  height: 449px;
+}
+
+.chart-wrapper {
+  height: 70%;
+  flex: 1;
+  min-height: 200px;
+}
+</style>
diff --git a/src/views/reportAnalysis/qualityAnalysis/components/left-top.vue b/src/views/reportAnalysis/qualityAnalysis/components/left-top.vue
new file mode 100644
index 0000000..7debef5
--- /dev/null
+++ b/src/views/reportAnalysis/qualityAnalysis/components/left-top.vue
@@ -0,0 +1,425 @@
+<template>
+  <div>
+    <PanelHeader title="璐ㄩ噺鎸囨爣鍚堟牸鍒嗘瀽" />
+    <div class="main-panel panel-item-customers">
+      <div v-for="section in sections" :key="section.key" class="inspect-block">
+        <div class="filters-row">
+          <div class="filters-row-left">
+            <span></span>
+            <p>{{ section.title }}</p>
+          </div>
+          <DateTypeSwitch v-model="section.dateType" @change="(v) => handleDateTypeChange(section.key, v)" />
+        </div>
+
+        <div class="inspect-body">
+          <div class="ring">
+            <Echarts
+              :chartStyle="ringChartStyle"
+              :series="buildRingSeries(section)"
+              :tooltip="ringTooltip"
+              :legend="{ show: false }"
+              :options="ringOptions"
+            />
+          </div>
+
+          <div class="stats">
+            <div class="stat-row">
+              <div class="stat-left">
+                <span class="dot dot-qualified"></span>
+                <span class="stat-label">鍚堟牸鏁�</span>
+              </div>
+              <div class="stat-right">
+                <span class="stat-value">{{ section.qualifiedCount }}</span>
+                <span class="stat-percent">{{ formatPercent(section.qualifiedRate) }}</span>
+              </div>
+            </div>
+            <div class="stat-row">
+              <div class="stat-left">
+                <span class="dot dot-unqualified"></span>
+                <span class="stat-label">涓嶅悎鏍兼暟</span>
+              </div>
+              <div class="stat-right">
+                <span class="stat-value">{{ section.unqualifiedCount }}</span>
+                <span class="stat-percent">{{ formatPercent(section.unqualifiedRate) }}</span>
+              </div>
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { reactive } from 'vue'
+import Echarts from '@/components/Echarts/echarts.vue'
+import PanelHeader from './PanelHeader.vue'
+import DateTypeSwitch from './DateTypeSwitch.vue'
+
+const QUALIFIED_COLOR = '#4EE4FF'
+const UNQUALIFIED_COLOR = '#3378FF'
+const TRACK_COLOR = 'rgba(78, 228, 255, 0.12)'
+
+const sections = reactive([
+  {
+    key: 'raw',
+    title: '鍘熸潗鏂欐娴�',
+    dateType: 1,
+    qualifiedCount: 199,
+    unqualifiedCount: 99,
+    qualifiedRate: 90,
+    unqualifiedRate: 10,
+  },
+  {
+    key: 'process',
+    title: '杩囩▼妫�娴�',
+    dateType: 1,
+    qualifiedCount: 199,
+    unqualifiedCount: 99,
+    qualifiedRate: 90,
+    unqualifiedRate: 10,
+  },
+  {
+    key: 'final',
+    title: '鎴愬搧鍑哄巶妫�娴�',
+    dateType: 1,
+    qualifiedCount: 199,
+    unqualifiedCount: 99,
+    qualifiedRate: 90,
+    unqualifiedRate: 10,
+  },
+])
+
+const ringChartStyle = {
+  width: '110px',
+  height: '110px',
+}
+
+const ringOptions = {
+  backgroundColor: 'transparent',
+  textStyle: { color: '#B8C8E0' },
+}
+
+const ringTooltip = {
+  show: false,
+}
+
+const calcRates = (qualifiedCount, unqualifiedCount) => {
+  const total = Number(qualifiedCount || 0) + Number(unqualifiedCount || 0)
+  if (total <= 0) return { qualifiedRate: 0, unqualifiedRate: 0 }
+  const qualifiedRate = Math.round((Number(qualifiedCount || 0) / total) * 100)
+  const unqualifiedRate = Math.max(0, 100 - qualifiedRate)
+  return { qualifiedRate, unqualifiedRate }
+}
+
+const formatPercent = (v) => `${Number(v || 0)}%`
+
+const buildRingSeries = (section) => {
+  const qualified = Number(section.qualifiedCount || 0)
+  const unqualified = Number(section.unqualifiedCount || 0)
+  const total = qualified + unqualified
+
+  return [
+    {
+      type: 'pie',
+      radius: ['68%', '82%'],
+      center: ['50%', '50%'],
+      silent: true,
+      label: { show: false },
+      labelLine: { show: false },
+      itemStyle: { color: TRACK_COLOR },
+      data: [1],
+    },
+    {
+      name: section.title,
+      type: 'pie',
+      radius: ['68%', '82%'],
+      center: ['50%', '50%'],
+      silent: true,
+      label: { show: false },
+      labelLine: { show: false },
+      startAngle: 90,
+      clockwise: true,
+      minAngle: total > 0 ? 8 : 0,
+      itemStyle: {
+        borderColor: 'rgba(10, 28, 58, 0.95)',
+        borderWidth: 2,
+      },
+      data: [
+        {
+          value: qualified,
+          name: '鍚堟牸鏁�',
+          itemStyle: {
+            color: QUALIFIED_COLOR,
+            shadowBlur: 16,
+            shadowColor: 'rgba(78, 228, 255, 0.45)',
+          },
+        },
+        {
+          value: unqualified,
+          name: '涓嶅悎鏍兼暟',
+          itemStyle: {
+            color: UNQUALIFIED_COLOR,
+            shadowBlur: 10,
+            shadowColor: 'rgba(51, 120, 255, 0.35)',
+          },
+        },
+      ],
+    },
+    {
+      type: 'pie',
+      radius: ['52%', '56%'],
+      center: ['50%', '50%'],
+      silent: true,
+      label: { show: false },
+      labelLine: { show: false },
+      itemStyle: { color: 'rgba(0, 127, 255, 0.22)' },
+      data: [1],
+    },
+  ]
+}
+
+const handleDateTypeChange = (key, dateType) => {
+  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
+}
+
+sections.forEach((s) => {
+  const rates = calcRates(s.qualifiedCount, s.unqualifiedCount)
+  s.qualifiedRate = rates.qualifiedRate
+  s.unqualifiedRate = rates.unqualifiedRate
+})
+</script>
+
+<style scoped>
+.main-panel {
+  display: flex;
+  flex-direction: column;
+  height: 100%;
+  gap: 0;
+}
+
+.filters-row {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  gap: 12px;
+  margin-bottom: 10px;
+
+  .filters-row-left {
+    width: 50%;
+    color: white;
+    /* 鐢╢lex鏇夸唬float锛岃瀛愬厓绱犲榻愭洿绋冲畾 */
+    display: flex;
+    align-items: center;
+
+    span {
+      /* 鏍稿績锛氱埗绾х浉瀵瑰畾浣嶏紝浣滀负浼厓绱犲熀鍑� */
+      position: relative;
+      display: inline-block;
+      /* 缁欎吉鍏冪礌鍜屾枃瀛楃暀绌洪棿 */
+      padding-left: 22px;
+      /* 鏂囧瓧鍨傜洿灞呬腑 */
+      line-height: 23px;
+      margin-right: 8px;
+
+      &::after {
+        content: '';
+        display: inline-block;
+        width: 16px;
+        height: 16px;
+        clip-path: polygon(50% 0%, 100% 50%, 50% 100%, 0% 50%);
+        background: #217AFF;
+        position: absolute;
+        top: 50%;
+        left: 0;
+        transform: translateY(-50%);
+        /* 纭繚鑿卞舰鍦ㄦ笎鍙樺潡涓婃柟 */
+        z-index: 1;
+      }
+
+      &::before {
+        content: '';
+        display: inline-block;
+        width: 18px;
+        height: 7px;
+        border-radius: 8px;
+        background: linear-gradient(360deg, rgba(33,133,255,0.4) 0%, rgba(33,221,255,0) 100%);
+        position: absolute;
+        top: 50%;
+        left: -1px;
+        /* 绮惧噯璐村湪鑿卞舰姝d笅鏂� */
+        transform: translateY(calc(0% + 8px));
+        z-index: 0;
+      }
+    }
+
+    p {
+      width: 100px;
+      height: 23px;
+      /* 娓愬彉璧峰鑹插拰鑿卞舰缁熶竴锛屾洿鍗忚皟 */
+      background: linear-gradient(90deg, #217AFF 0%, rgba(33, 221, 255, 0) 100%);
+      /* 绮惧噯鍨傜洿灞呬腑 */
+      line-height: 26px;
+      text-align: center;
+      color: white;
+      /* 鐢ㄩ珮搴︾殑涓�鍗婂仛鍦嗚锛岀‘淇濆乏杈规槸瀹岀編鍗婂渾 */
+      border-radius: 12px 0 0 12px;
+      /* 鍙�夛細鍔犱竴鐐瑰乏鍐呰竟璺濓紝璁╂枃瀛椾笉璐磋竟 */
+      padding-left: 4px;
+    }
+  }
+}
+
+.panel-item-customers {
+  border: 1px solid #1a58b0;
+  padding: 14px 18px;
+  width: 100%;
+  height: 960px;
+  box-sizing: border-box;
+}
+
+.inspect-block {
+  flex: 1 1 0;
+  min-height: 0;
+  display: flex;
+  flex-direction: column;
+  padding: 8px 0;
+  gap: 6px;
+  position: relative;
+}
+
+.inspect-block:not(:last-child)::after {
+  content: '';
+  position: absolute;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  height: 1px;
+  background: linear-gradient(90deg, rgba(33, 122, 255, 0) 0%, rgba(33, 122, 255, 0.55) 50%, rgba(33, 122, 255, 0) 100%);
+  pointer-events: none;
+}
+
+.inspect-body {
+  flex: 1 1 auto;
+  min-height: 0;
+  display: flex;
+  justify-content:space-around;
+  align-items: center;
+  gap: 18px;
+}
+
+.ring {
+  width: 120px;
+  height: 120px;
+  flex: 0 0 120px;
+  position: relative;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+/* 澶栧湀鍒诲害锛堢偣鐘剁幆锛� */
+.ring::before {
+  content: '';
+  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
+  );
+  -webkit-mask: radial-gradient(circle, transparent 62%, #000 63%);
+  mask: radial-gradient(circle, transparent 62%, #000 63%);
+  opacity: 0.35;
+  pointer-events: none;
+}
+
+/* 鏌斿拰鍙戝厜鑳屾櫙 */
+.ring::after {
+  content: '';
+  position: absolute;
+  inset: -20px;
+  border-radius: 50%;
+  background: radial-gradient(circle, rgba(78, 228, 255, 0.18) 0%, rgba(78, 228, 255, 0.06) 40%, rgba(0, 0, 0, 0) 70%);
+  filter: blur(0.2px);
+  pointer-events: none;
+}
+
+.stats {
+  width: 240px;
+  flex: 0 0 240px;
+  display: grid;
+  grid-template-rows: 1fr 1fr;
+  gap: 10px;
+}
+
+.stat-row {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  height: 100%;
+  padding: 10px 14px;
+  border-radius: 4px;
+  border: 1px solid rgba(78, 228, 255, 0.22);
+  background: linear-gradient(90deg, rgba(33, 122, 255, 0.28) 0%, rgba(10, 28, 58, 0.35) 55%, rgba(10, 28, 58, 0.2) 100%);
+  box-shadow: inset 0 0 18px rgba(16, 45, 95, 0.25);
+}
+
+.stat-left {
+  display: inline-flex;
+  align-items: center;
+  gap: 10px;
+  color: #b8c8e0;
+  font-size: 12px;
+}
+
+.dot {
+  width: 10px;
+  height: 10px;
+  border-radius: 2px;
+  display: inline-block;
+  box-shadow: 0 0 10px rgba(78, 228, 255, 0.25);
+}
+
+.dot-qualified {
+  background: rgba(184, 200, 224, 0.85);
+}
+
+.dot-unqualified {
+  background: #4ee4ff;
+}
+
+.stat-right {
+  display: inline-flex;
+  align-items: baseline;
+  gap: 14px;
+}
+
+.stat-value {
+  color: #ffffff;
+  font-size: 14px;
+  font-weight: 600;
+  min-width: 40px;
+  text-align: right;
+  text-shadow: 0 0 10px rgba(78, 228, 255, 0.15);
+}
+
+.stat-percent {
+  color: rgba(184, 200, 224, 0.95);
+  font-size: 12px;
+  min-width: 40px;
+  text-align: right;
+}
+
+/* 璁╁垏鎹㈡寜閽洿璐磋繎鎴浘锛堟洿绱у噾锛� */
+:deep(.date-type-switch .el-radio-button__inner) {
+  padding: 4px 16px;
+  font-size: 12px;
+}
+</style>
diff --git a/src/views/reportAnalysis/qualityAnalysis/components/right-bottom.vue b/src/views/reportAnalysis/qualityAnalysis/components/right-bottom.vue
new file mode 100644
index 0000000..cd22d56
--- /dev/null
+++ b/src/views/reportAnalysis/qualityAnalysis/components/right-bottom.vue
@@ -0,0 +1,250 @@
+<template>
+  <div>
+    <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"
+        />
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref, onMounted, onBeforeUnmount } from 'vue'
+import Echarts from '@/components/Echarts/echarts.vue'
+import PanelHeader from './PanelHeader.vue'
+import { productCategoryDistribution } from '@/api/viewIndex.js'
+import { useChartBackground } from '@/hooks/useChartBackground.js'
+
+const pieWrapperRef = ref(null)
+const pieBackgroundRef = ref(null)
+const chart = ref(null)
+
+// 鏁版嵁鍒楄〃锛堟潵鑷帴鍙o級
+const dataList = ref([])
+
+// 棰滆壊鍒楄〃
+const landColors = ['#26FFCB', '#24CBFF', '#35FBF4', '#2651FF', '#D1E4F5', '#5782F7', '#2F67EF', '#82BAFF']
+
+// label 瀵屾枃鏈細涓烘瘡涓鑹茬敓鎴愪竴涓皬鍦嗙偣鏍峰紡锛堢‘淇濆湪 label 涓彲瑙侊級
+const dotRich = landColors.reduce((acc, color, idx) => {
+  acc[`dot${idx}`] = {
+    width: 8,
+    height: 8,
+    borderRadius: 8,
+    backgroundColor: color,
+    align: 'center',
+  }
+  return acc
+}, {})
+
+// 鍥句緥閰嶇疆锛堝彸渚х珫鎺掞級
+const landLegend = {
+  show: false,
+  icon: 'circle',
+  data: [],
+  right: '8%',
+  top: '40%',
+  orient: 'vertical',
+  itemGap: 14,
+  itemWidth: 6,
+  itemHeight: 6,
+  textStyle: {
+    fontSize: 12,
+    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|%}`
+  },
+}
+
+// 鎻愮ず妗�
+const landTooltip = {
+  // triggerOn: 'hover',
+  alwaysShowContent: true,
+  position: function (pt) {
+    return [pt[0], 130]
+  },
+  formatter: function (params) {
+    return `${params.name} (${params.value}绫�)`
+  },
+}
+
+// 鍙屽眰鐜舰楗煎浘
+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 },
+      },
+      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,
+      length2: 20,
+      lineStyle: {
+        color: '#B8C8E0',
+      },
+    },
+    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 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 // 鐩戝惉鏁版嵁鍙樺寲锛岃嚜鍔ㄨ皟鏁翠綅缃�
+})
+
+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()
+  } catch (e) {
+    console.error('鑾峰彇浜у搧澶х被鍒嗗竷澶辫触:', e)
+    dataList.value = []
+    landLegend.data = []
+    landSeries.value[0].data = []
+  }
+}
+
+
+onMounted(() => {
+  loadData()
+  initBackground()
+})
+
+onBeforeUnmount(() => {
+  cleanupBackground()
+})
+</script>
+
+<style scoped>
+.panel-item-customers {
+  border: 1px solid #1a58b0;
+  padding: 18px;
+  width: 100%;
+  height: 420px;
+}
+
+.pie-chart-wrapper {
+  position: relative;
+  width: 100%;
+  height: 320px;
+  background: transparent;
+}
+
+.pie-background {
+  position: absolute;
+  width: 360px;
+  height: 360px;
+  background-image: url('@/assets/BI/鐜懓鍥捐竟妗�.png');
+  background-size: contain;
+  background-position: center;
+  background-repeat: no-repeat;
+  z-index: 1;
+  pointer-events: none;
+  /* 榛樿灞呬腑锛屼細鍦� JS 涓姩鎬佽皟鏁� */
+  left: 50%;
+  top: 50%;
+  transform: translate(-51.5%, -39%);
+}
+</style>
diff --git a/src/views/reportAnalysis/qualityAnalysis/components/right-top.vue b/src/views/reportAnalysis/qualityAnalysis/components/right-top.vue
new file mode 100644
index 0000000..1400cb2
--- /dev/null
+++ b/src/views/reportAnalysis/qualityAnalysis/components/right-top.vue
@@ -0,0 +1,93 @@
+<template>
+  <div>
+    <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>
+      </div>
+    </div>
+  </div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref, onMounted } from 'vue'
+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}%`;
+    }
+onMounted(() => {
+  // fetchData()
+})
+</script>
+
+<style scoped>
+.main-panel-box{
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  height: 40px;
+  .main-panel-box-left{
+    background: red;
+    border-radius: 20px;
+    text-align: center;
+    line-height: 32px;
+      margin: 0 20px;
+  }
+  .main-panel-box-right{
+    display: flex;
+    flex-direction: column;
+    .main-panel-box-right-text{
+      font-size: 12px;
+      display: flex;
+      justify-content: space-between;
+      padding-right: 60px;
+    }
+    .main-panel-box-right-progress{
+      :deep(.el-progress__text){
+        color: white !important;
+      }
+    }
+  }
+}
+.main-panel {
+  display: flex;
+  flex-direction: column;
+  gap: 20px;
+}
+
+.panel-item-customers {
+  border: 1px solid #1a58b0;
+  padding: 18px;
+  width: 100%;
+  height: 449px;
+}
+</style>
diff --git a/src/views/reportAnalysis/qualityAnalysis/index.vue b/src/views/reportAnalysis/qualityAnalysis/index.vue
new file mode 100644
index 0000000..759bf03
--- /dev/null
+++ b/src/views/reportAnalysis/qualityAnalysis/index.vue
@@ -0,0 +1,290 @@
+<template>
+  <div class="scale-container">
+    <div class="data-dashboard" :style="{ transform: `scale(${scaleRatio})` }">
+    <!-- 鍏ㄥ睆鎸夐挳 - 绉诲姩鍒板乏涓婅 -->
+    <button class="fullscreen-btn" @click="toggleFullscreen" :title="isFullscreen ? '閫�鍑哄叏灞�' : '鍏ㄥ睆鏄剧ず'">
+      <svg v-if="!isFullscreen" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+        <path d="M8 3v3a2 2 0 0 1-2 2H3m18 0h-3a2 2 0 0 1-2-2V3m0 18v-3a2 2 0 0 1 2-2h3M3 16h3a2 2 0 0 1 2 2v3"/>
+      </svg>
+      <svg v-else width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+        <path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"/>
+      </svg>
+    </button>
+
+    <!-- 椤堕儴鏍囬鏍� -->
+    <div class="dashboard-header">
+      <div class="factory-name">鐢熶骇鏁版嵁鍒嗘瀽</div>
+    </div>
+
+    <!-- 涓昏鍐呭鍖哄煙 -->
+    <div class="dashboard-content">
+      <!-- 宸︿晶鍖哄煙 -->
+      <div class="left-panel">
+        <LeftTop />
+      </div>
+
+      <!-- 涓棿鍖哄煙 -->
+      <div class="center-panel">
+        <CenterTop />
+        <CenterCenter/>
+        <CenterBottom />
+      </div>
+
+      <!-- 鍙充晶鍖哄煙 -->
+      <div class="right-panel">
+
+        <RightTop />
+        <RightBottom />
+      </div>
+    </div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref, onMounted, onBeforeUnmount, nextTick } from 'vue'
+import autofit from 'autofit.js'
+import LeftBottom from './components/left-bottom.vue'
+import CenterCenter from './components/center-center.vue'
+import RightTop from './components/right-top.vue'
+import RightBottom from './components/right-bottom.vue'
+import useUserStore from '@/store/modules/user'
+import LeftTop from './components/left-top.vue'
+import CenterTop from './components/center-top.vue'
+import CenterBottom from './components/center-bottom.vue'
+
+// 鍏ㄥ睆鐩稿叧鐘舵��
+const isFullscreen = ref(false);
+
+// 缂╂斁姣斾緥
+const scaleRatio = ref(1)
+// 璁捐灏哄锛堝熀鍑嗗昂瀵革級- 鏍规嵁瀹為檯璁捐绋胯皟鏁�
+const designWidth = 1920
+const designHeight = 1080
+
+// 鐢ㄦ埛store
+const userStore = useUserStore()
+
+// 璁$畻缂╂斁姣斾緥
+const calculateScale = () => {
+  const container = document.querySelector('.scale-container')
+  if (!container) return
+
+  // 鑾峰彇瀹瑰櫒鐨勫疄闄呭昂瀵�
+  const rect = container.getBoundingClientRect?.()
+  const containerWidth = container.clientWidth || rect?.width || window.innerWidth
+  const containerHeight = container.clientHeight || rect?.height || window.innerHeight
+
+  // 璁$畻瀹介珮缂╂斁姣斾緥锛屽彇杈冨皬鍊间互淇濊瘉鍐呭瀹屾暣鏄剧ず锛堢瓑姣旂缉鏀撅級
+  const scaleX = containerWidth / designWidth
+  const scaleY = containerHeight / designHeight
+  scaleRatio.value = Math.min(scaleX, scaleY)
+}
+
+// 绐楀彛澶у皬鍙樺寲澶勭悊
+const handleResize = () => {
+  // 寤惰繜鎵ц锛岀‘淇滵OM鏇存柊瀹屾垚
+  setTimeout(() => {
+    calculateScale()
+  }, 100)
+}
+
+// 鍏ㄥ睆鍔熻兘瀹炵幇 - 閽堝scale-container鍏冪礌
+const toggleFullscreen = () => {
+  const element = document.querySelector('.scale-container')
+
+  if (!element) return
+
+  if (!isFullscreen.value) {
+    if (element.requestFullscreen) {
+      element.requestFullscreen()
+    } else if (element.webkitRequestFullscreen) {
+      element.webkitRequestFullscreen()
+    } else if (element.msRequestFullscreen) {
+      element.msRequestFullscreen()
+    }
+  } else {
+    if (document.exitFullscreen) {
+      document.exitFullscreen()
+    } else if (document.webkitExitFullscreen) {
+      document.webkitExitFullscreen()
+    } else if (document.msExitFullscreen) {
+      document.msExitFullscreen()
+    }
+  }
+}
+
+// 鐩戝惉鍏ㄥ睆鍙樺寲浜嬩欢
+const handleFullscreenChange = () => {
+  const fullscreenElement = document.fullscreenElement || 
+                           document.webkitFullscreenElement || 
+                           document.msFullscreenElement
+  isFullscreen.value = fullscreenElement && fullscreenElement.classList.contains('scale-container')
+
+  // 鍏ㄥ睆鐘舵�佸彉鍖栨椂锛屽欢杩熼噸鏂拌绠楃缉鏀炬瘮渚嬶紙纭繚DOM鏇存柊瀹屾垚锛�
+  setTimeout(() => {
+    calculateScale()
+  }, 200)
+}
+
+// 鐢熷懡鍛ㄦ湡閽╁瓙
+onMounted(() => {
+  // 浣跨敤nextTick纭繚DOM瀹屽叏娓叉煋鍚庡啀鍒濆鍖�
+  nextTick(() => {
+    // 璁$畻鍒濆缂╂斁姣斾緥
+    calculateScale()
+  })
+
+  window.addEventListener('resize', handleResize)
+  window.addEventListener('fullscreenchange', handleFullscreenChange)
+  window.addEventListener('webkitfullscreenchange', handleFullscreenChange)
+  window.addEventListener('MSFullscreenChange', handleFullscreenChange)
+})
+
+onBeforeUnmount(() => {
+  window.removeEventListener('resize', handleResize)
+  window.removeEventListener('fullscreenchange', handleFullscreenChange)
+  window.removeEventListener('webkitfullscreenchange', handleFullscreenChange)
+  window.removeEventListener('MSFullscreenChange', handleFullscreenChange)
+  // 绉婚櫎鎴戜滑娣诲姞鐨刟utofit鍔ㄦ�佽皟鏁寸洃鍚櫒
+  if (window._autofitUpdateHandler) {
+    window.removeEventListener('resize', window._autofitUpdateHandler)
+    delete window._autofitUpdateHandler
+  }
+  // 鍏抽棴autofit
+  autofit.off()
+})
+</script>
+
+<style scoped>
+/* 澶栭儴缂╂斁瀹瑰櫒 - 鍗犳嵁鏁翠釜瑙嗗彛 */
+.scale-container {
+position: relative;
+width: 100%;
+/* 椤甸潰鍦ㄥ父瑙勫竷灞�涓嬶紙鏈夐《鏍忥級榛樿鍑忓幓 84px锛岄伩鍏嶅唴瀹硅瑁佸垏 */
+height: calc(100vh - 84px);
+display: flex;
+align-items: center;
+justify-content: center;
+background-color: #000;
+overflow: hidden;
+}
+
+/* 鍐呴儴鍐呭鍖哄煙 - 鍥哄畾璁捐灏哄 */
+.data-dashboard {
+position: relative;
+width: 1920px;
+height: 1080px;
+background-image: url("@/assets/BI/backImage@2x.png");
+background-size: cover;
+background-position: center;
+background-repeat: no-repeat;
+transform-origin: center center;
+}
+
+/* 鍏ㄥ睆鐘舵�佺殑鏍峰紡 - 浣滅敤浜巗cale-container */
+.scale-container:fullscreen {
+width: 100vw;
+height: 100vh;
+margin: 0;
+padding: 0;
+background-color: #000;
+z-index: 9999;
+}
+
+/* Webkit娴忚鍣ㄥ墠缂� */
+.scale-container:-webkit-full-screen {
+width: 100vw;
+height: 100vh;
+margin: 0;
+padding: 0;
+background-color: #000;
+z-index: 9999;
+}
+
+/* MS娴忚鍣ㄥ墠缂� */
+.scale-container:-ms-fullscreen {
+width: 100vw;
+height: 100vh;
+margin: 0;
+padding: 0;
+background-color: #000;
+z-index: 9999;
+}
+
+
+.dashboard-header {
+position: relative;
+z-index: 1;
+height: 86px;
+background-image: url("@/assets/BI/biaoti.png");
+background-size: cover;
+background-repeat: no-repeat;
+display: flex;
+align-items: center;
+justify-content: center;
+}
+
+.factory-name {
+font-weight: 600;
+font-size: 52px;
+color: #FFFFFF;
+top: 16px;
+position: absolute;
+}
+
+.fullscreen-btn {
+position: absolute;
+top: 10px;
+left: 20px;
+width: 40px;
+height: 40px;
+background: rgba(0, 20, 60, 0.8);
+border: 1px solid rgba(0, 212, 255, 0.3);
+border-radius: 6px;
+color: #00d4ff;
+cursor: pointer;
+display: flex;
+align-items: center;
+justify-content: center;
+transition: all 0.3s;
+z-index: 10000;
+}
+
+.fullscreen-btn:hover {
+background: rgba(0, 30, 90, 0.9);
+border-color: rgba(0, 212, 255, 0.5);
+}
+
+.dashboard-content {
+position: relative;
+z-index: 1;
+display: flex;
+gap: 30px;
+padding: 0 30px;
+height: calc(100% - 86px);
+overflow: hidden;
+}
+
+/* 纭繚鍚勯潰鏉胯兘澶熸纭樉绀� */
+.left-panel, .center-panel, .right-panel {
+overflow: hidden;
+}
+
+.left-panel,
+.right-panel {
+flex: 1;
+display: flex;
+flex-direction: column;
+gap: 24px;
+width: 520px;
+}
+
+.center-panel {
+flex: 1.5;
+display: flex;
+flex-direction: column;
+gap: 20px;
+}
+
+</style>
\ No newline at end of file

--
Gitblit v1.9.3