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