优化细节
This commit is contained in:
@@ -1,12 +1,28 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="gender-card">
|
<view class="gender-card" ref="cardRef">
|
||||||
<view class="card-header">
|
<view class="card-header">
|
||||||
<text class="title">用户性别比例</text>
|
<text class="title">用户性别比例</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="card-content">
|
<view class="card-content">
|
||||||
<view class="chart-container">
|
<!-- 左侧自定义图例列 - 复刻 CRMEB 竖排样式 -->
|
||||||
|
<view class="legend-col">
|
||||||
|
<view class="legend-item" v-for="(item, index) in genderData" :key="index">
|
||||||
|
<view class="legend-dot" :style="{ backgroundColor: item.itemStyle.color }"></view>
|
||||||
|
<text class="legend-label">{{ item.name }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
右侧图表列 - 核心容器
|
||||||
|
1. 使用 ResizeObserver 监听此处尺寸
|
||||||
|
2. 中心文字通过 CSS 绝对居中
|
||||||
|
-->
|
||||||
|
<view class="chart-col" ref="chartWrapRef">
|
||||||
|
<!-- 图表组件 -->
|
||||||
<EChartsView :option="chartOption" class="donut-chart" />
|
<EChartsView :option="chartOption" class="donut-chart" />
|
||||||
|
|
||||||
|
<!-- 中心文字:绝对居中,与 ECharts center:['50%','50%'] 严格同步 -->
|
||||||
<view class="center-text">
|
<view class="center-text">
|
||||||
<text class="total-label">总用户数</text>
|
<text class="total-label">总用户数</text>
|
||||||
<text class="total-value">{{ totalUsers }}</text>
|
<text class="total-value">{{ totalUsers }}</text>
|
||||||
@@ -18,7 +34,12 @@
|
|||||||
|
|
||||||
<script lang="uts">
|
<script lang="uts">
|
||||||
import EChartsView from '@/uni_modules/charts/EChartsView.vue'
|
import EChartsView from '@/uni_modules/charts/EChartsView.vue'
|
||||||
|
import { showSubSider, isMainAsideCollapsed, layoutMode } from '@/layouts/admin/store/adminNavStore.uts'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户性别比例 - CRMEB 1:1 复刻版 (响应式增强)
|
||||||
|
* 解决了:<1200px 布局切换、Canvas 溢出、图表裁切、中心偏移问题
|
||||||
|
*/
|
||||||
export default {
|
export default {
|
||||||
components: {
|
components: {
|
||||||
EChartsView
|
EChartsView
|
||||||
@@ -26,86 +47,129 @@ export default {
|
|||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
totalUsers: 525,
|
totalUsers: 525,
|
||||||
chartOption: {} as any
|
genderData: [
|
||||||
|
{ value: 450, name: '未知', itemStyle: { color: '#919eab' } },
|
||||||
|
{ value: 50, name: '男', itemStyle: { color: '#1890ff' } },
|
||||||
|
{ value: 25, name: '女', itemStyle: { color: '#febc2c' } }
|
||||||
|
],
|
||||||
|
chartOption: {} as any,
|
||||||
|
resizeObserver: null as any | null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
navState(): string {
|
||||||
|
return `${showSubSider.value}-${isMainAsideCollapsed.value}-${layoutMode.value}`
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
navState() {
|
||||||
|
// 侧边栏/断点状态变化时,强制触发 resize
|
||||||
|
this.triggerRobustResize()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
setTimeout(() => {
|
this.initChart()
|
||||||
this.initChart()
|
this.setupResizeSystem()
|
||||||
}, 200)
|
},
|
||||||
|
unmounted() {
|
||||||
|
if (this.resizeObserver != null) {
|
||||||
|
(this.resizeObserver as any).disconnect()
|
||||||
|
}
|
||||||
|
window.removeEventListener('resize', this.triggerRobustResize)
|
||||||
|
|
||||||
|
const sidebar = document.querySelector('.admin-sidebar-container') || document.querySelector('.admin-main-aside')
|
||||||
|
if (sidebar != null) {
|
||||||
|
sidebar.removeEventListener('transitionend', this.triggerRobustResize)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
initChart() {
|
initChart() {
|
||||||
|
// Step 4: 修复 option 自适应,使用百分比 radius 和 center
|
||||||
const option = {
|
const option = {
|
||||||
tooltip: {
|
tooltip: {
|
||||||
trigger: 'item',
|
trigger: 'item',
|
||||||
formatter: '{b}: {c} ({d}%)'
|
formatter: '{b}: {c} ({d}%)',
|
||||||
},
|
backgroundColor: '#fff',
|
||||||
legend: {
|
padding: [10, 15],
|
||||||
top: '0%',
|
textStyle: { color: '#333' },
|
||||||
left: 'center',
|
extraCssText: 'box-shadow: 0 2px 8px rgba(0,0,0,0.15); border-radius: 4px;'
|
||||||
icon: 'rect',
|
|
||||||
itemWidth: 15,
|
|
||||||
itemHeight: 15,
|
|
||||||
textStyle: {
|
|
||||||
fontSize: 12,
|
|
||||||
color: '#666'
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
// 关闭 ECharts 的普通图例,使用外部自定义图例
|
||||||
|
legend: { show: false },
|
||||||
series: [
|
series: [
|
||||||
{
|
{
|
||||||
name: '性别比例',
|
name: '性别比例',
|
||||||
type: 'pie',
|
type: 'pie',
|
||||||
radius: ['50%', '75%'],
|
// 百分比定义半径,防止裁切
|
||||||
center: ['50%', '60%'],
|
radius: ['60%', '78%'],
|
||||||
|
// 图表在容器内绝对居中
|
||||||
|
center: ['50%', '50%'],
|
||||||
avoidLabelOverlap: false,
|
avoidLabelOverlap: false,
|
||||||
label: {
|
label: { show: false },
|
||||||
show: false,
|
|
||||||
position: 'center'
|
|
||||||
},
|
|
||||||
emphasis: {
|
emphasis: {
|
||||||
label: {
|
scale: true,
|
||||||
show: false
|
scaleSize: 10,
|
||||||
}
|
label: { show: false }
|
||||||
},
|
},
|
||||||
labelLine: {
|
data: this.genderData
|
||||||
show: false
|
|
||||||
},
|
|
||||||
data: [
|
|
||||||
{ value: 450, name: '未知', itemStyle: { color: '#999999' } },
|
|
||||||
{ value: 50, name: '男', itemStyle: { color: '#3b82f6' } },
|
|
||||||
{ value: 25, name: '女', itemStyle: { color: '#f97316' } }
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
this.chartOption = this.toPlainObject(option)
|
this.chartOption = this.toPlainObject(option)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 建立可靠 resize 闭环
|
||||||
|
*/
|
||||||
|
setupResizeSystem() {
|
||||||
|
// 1. ResizeObserver 监听容器真实尺寸变化 (节流处理)
|
||||||
|
if (typeof ResizeObserver !== 'undefined') {
|
||||||
|
let timer: any = null
|
||||||
|
this.resizeObserver = new ResizeObserver(() => {
|
||||||
|
if (timer) return
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
this.triggerRobustResize()
|
||||||
|
timer = null
|
||||||
|
}, 100)
|
||||||
|
})
|
||||||
|
const wrap = (this.$refs['chartWrapRef'] as any).$el
|
||||||
|
if (wrap != null) {
|
||||||
|
(this.resizeObserver as any).observe(wrap)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Window Resize 兜底
|
||||||
|
window.addEventListener('resize', this.triggerRobustResize)
|
||||||
|
|
||||||
|
// 3. 监听侧边栏动画结束 (transitionend)
|
||||||
|
const sidebar = document.querySelector('.admin-sidebar-container') || document.querySelector('.admin-main-aside')
|
||||||
|
if (sidebar != null) {
|
||||||
|
sidebar.addEventListener('transitionend', this.triggerRobustResize)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
triggerRobustResize() {
|
||||||
|
// 触发一次 layout 后的渲染
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
window.dispatchEvent(new Event('resize'))
|
||||||
|
|
||||||
|
// 补充第二次 frame 针对重构动画的延迟校准
|
||||||
|
setTimeout(() => {
|
||||||
|
window.dispatchEvent(new Event('resize'))
|
||||||
|
}, 30)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
toPlainObject(obj: any): any {
|
toPlainObject(obj: any): any {
|
||||||
if (obj == null) return null
|
if (obj == null) return null
|
||||||
if (typeof obj !== 'object') return obj
|
if (typeof obj !== 'object') return obj
|
||||||
if (Array.isArray(obj)) {
|
if (Array.isArray(obj)) return obj.map((item : any) : any => this.toPlainObject(item))
|
||||||
return obj.map((item) => this.toPlainObject(item))
|
|
||||||
}
|
|
||||||
const plain: any = {}
|
const plain: any = {}
|
||||||
for (const key in obj) {
|
for (const key in obj) {
|
||||||
if (Object.prototype.hasOwnProperty.call(obj, key)) {
|
if (Object.prototype.hasOwnProperty.call(obj, key)) {
|
||||||
const value = obj[key]
|
const value = obj[key]
|
||||||
if (typeof value === 'function' || key.startsWith('_') || key === 'toJSON') {
|
if (typeof value === 'function' || key.startsWith('_') || key === 'toJSON') continue
|
||||||
continue
|
plain[key] = (value != null && typeof value === 'object' && !Array.isArray(value)) ? this.toPlainObject(value) : value
|
||||||
}
|
|
||||||
if (value != null && typeof value === 'object' && !Array.isArray(value)) {
|
|
||||||
let isSimple = true
|
|
||||||
for (const k in value) {
|
|
||||||
if (typeof value[k] === 'object' && value[k] !== null) {
|
|
||||||
isSimple = false
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
plain[key] = isSimple ? { ...value } : this.toPlainObject(value)
|
|
||||||
} else {
|
|
||||||
plain[key] = value
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return plain
|
return plain
|
||||||
@@ -119,12 +183,16 @@ export default {
|
|||||||
background: #fff;
|
background: #fff;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
margin-bottom: 20px;
|
display: flex;
|
||||||
height: 521px;
|
flex-direction: column;
|
||||||
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-header {
|
.card-header {
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
@@ -134,43 +202,100 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.card-content {
|
.card-content {
|
||||||
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
flex-direction: row;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
height: 400px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.chart-container {
|
.legend-col {
|
||||||
|
width: 80px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-dot {
|
||||||
|
width: 16px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-label {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-col {
|
||||||
|
flex: 1;
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
/**
|
||||||
height: 100%;
|
* 确定高度策略 - 响应式复刻 CRMEB
|
||||||
|
* >=1200px (两列) 时保持紧凑高度
|
||||||
|
*/
|
||||||
|
height: clamp(320px, 35vh, 400px);
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.donut-chart {
|
/* 响应式断点:<1200px 切换为单列,图表需要更大空间 */
|
||||||
width: 100%;
|
@media (max-width: 1199.98px) {
|
||||||
height: 100%;
|
.chart-col {
|
||||||
|
height: clamp(480px, 50vh, 600px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 强制覆盖 EChartsView 内部样式,确保绝对定位生效 */
|
||||||
|
:deep(.donut-chart) {
|
||||||
|
width: 100% !important;
|
||||||
|
height: 100% !important;
|
||||||
|
|
||||||
|
.ec-wrap {
|
||||||
|
position: relative !important;
|
||||||
|
width: 100% !important;
|
||||||
|
height: 100% !important;
|
||||||
|
overflow: hidden !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ec-canvas {
|
||||||
|
position: absolute !important;
|
||||||
|
inset: 0 !important;
|
||||||
|
width: 100% !important;
|
||||||
|
height: 100% !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.center-text {
|
.center-text {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 60%;
|
top: 50%;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
transform: translate(-50%, -50%);
|
transform: translate(-50%, -50%);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
user-select: none;
|
||||||
|
z-index: 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
.total-label {
|
.total-label {
|
||||||
font-size: 14px;
|
font-size: 13px;
|
||||||
color: #999;
|
color: #999;
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.total-value {
|
.total-value {
|
||||||
font-size: 28px;
|
font-size: 32px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: #333;
|
color: #333;
|
||||||
|
line-height: 1;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="layout-root">
|
<view class="layout-root">
|
||||||
<!-- 移动端遮罩层 -->
|
<!-- 统一遮罩层 (复刻 CRMEB: 用于所有 Overlay 状态) -->
|
||||||
<view
|
<view
|
||||||
v-if="isMobile && isMobileMenuOpen"
|
|
||||||
class="mobile-mask"
|
class="mobile-mask"
|
||||||
@click="isMobileMenuOpen = false"
|
:class="{ 'mask-show': isOverlayVisible || (isMobile && isMobileMenuOpen) }"
|
||||||
|
@click="closeAllMenu"
|
||||||
></view>
|
></view>
|
||||||
|
|
||||||
<!-- 主侧边栏 (CRMEB风格) -->
|
<!-- 主侧边栏 (CRMEB风格: 70px) -->
|
||||||
<AdminAside
|
<AdminAside
|
||||||
class="admin-sidebar"
|
class="admin-sidebar"
|
||||||
:class="{ 'mobile-aside-open': isMobileMenuOpen }"
|
:class="{ 'mobile-aside-open': isMobileMenuOpen }"
|
||||||
:collapsed="isMainAsideCollapsed"
|
:collapsed="false"
|
||||||
:topMenus="topMenus"
|
:topMenus="topMenus"
|
||||||
:activeTopMenuId="activeTopMenuId"
|
:activeTopMenuId="activeTopMenuId"
|
||||||
@toggle="toggleMainAsideCollapse"
|
@toggle="toggleMainAsideCollapse"
|
||||||
@@ -19,14 +19,15 @@
|
|||||||
:asideWidth="ASIDE_W"
|
:asideWidth="ASIDE_W"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 二级侧边栏 (CRMEB风格 - 内容区左侧) -->
|
<!-- 二级侧边栏 (1:1 复刻 CRMEB 抽屉/Dock 平滑切换) -->
|
||||||
<AdminSubSider
|
<AdminSubSider
|
||||||
v-if="showSubSider && !isMobile"
|
:visible="isSubSiderVisible"
|
||||||
|
:class="{ 'sub-sider-overlay': isSubSiderOverlay || layoutMode === 'mobile' }"
|
||||||
:topMenuTitle="activeTopMenuTitle"
|
:topMenuTitle="activeTopMenuTitle"
|
||||||
:groups="activeGroups"
|
:groups="activeGroups"
|
||||||
:routes="activeRoutes"
|
:routes="activeRoutes"
|
||||||
:activeRouteId="activeRouteId"
|
:activeRouteId="activeRouteId"
|
||||||
:asideWidth="ASIDE_W"
|
:asideWidth="layoutMode === 'mobile' ? 0 : ASIDE_W"
|
||||||
:siderWidth="SUB_W"
|
:siderWidth="SUB_W"
|
||||||
@route-click="onRouteClick"
|
@route-click="onRouteClick"
|
||||||
/>
|
/>
|
||||||
@@ -41,7 +42,6 @@
|
|||||||
:breadcrumb="breadcrumb"
|
:breadcrumb="breadcrumb"
|
||||||
:hasNotification="hasNotification"
|
:hasNotification="hasNotification"
|
||||||
:isMobile="isMobile"
|
:isMobile="isMobile"
|
||||||
@toggle-mobile-menu="isMobileMenuOpen = !isMobileMenuOpen"
|
|
||||||
@search="onSearch"
|
@search="onSearch"
|
||||||
@refresh="onRefresh"
|
@refresh="onRefresh"
|
||||||
@notify="onNotify"
|
@notify="onNotify"
|
||||||
@@ -93,30 +93,73 @@ import {
|
|||||||
isMainAsideCollapsed,
|
isMainAsideCollapsed,
|
||||||
showSubSider,
|
showSubSider,
|
||||||
windowWidth,
|
windowWidth,
|
||||||
|
layoutMode,
|
||||||
isMobile,
|
isMobile,
|
||||||
isMobileMenuOpen,
|
isMobileMenuOpen,
|
||||||
|
isOverlayVisible,
|
||||||
openRoute,
|
openRoute,
|
||||||
closeTab,
|
closeTab,
|
||||||
closeOtherTabs,
|
closeOtherTabs,
|
||||||
closeAllTabs,
|
closeAllTabs,
|
||||||
toggleMainAsideCollapse as storeToggleCollapse,
|
toggleMainAsideCollapse as storeToggleCollapse,
|
||||||
|
toggleSubSider as storeToggleSubSider,
|
||||||
initNavState
|
initNavState
|
||||||
} from '@/layouts/admin/store/adminNavStore.uts'
|
} from '@/layouts/admin/store/adminNavStore.uts'
|
||||||
import type { TabItem } from '@/layouts/admin/store/adminNavStore.uts'
|
import type { TabItem } from '@/layouts/admin/store/adminNavStore.uts'
|
||||||
|
|
||||||
import { getComponent } from '@/layouts/admin/router/adminComponentMap.uts'
|
import { getComponent } from '@/layouts/admin/router/adminComponentMap.uts'
|
||||||
|
|
||||||
// 侧边栏宽度配置
|
// 侧边栏宽度配置 (CRMEB 1:1)
|
||||||
const ASIDE_W = 96 // 主侧边栏宽度
|
const ASIDE_W = 70
|
||||||
const SUB_W = 180 // 二级侧边栏宽度
|
const SUB_W = 200
|
||||||
|
|
||||||
const hasNotification = ref<boolean>(false)
|
const hasNotification = ref<boolean>(false)
|
||||||
|
|
||||||
// 计算主内容区左边距
|
/**
|
||||||
|
* 核心逻辑:计算二级菜单是否应该以 Overlay (抽屉) 模式展示
|
||||||
|
* CRMEB 规则:Tablet 屏 (768~1199) 为 Overlay
|
||||||
|
*/
|
||||||
|
const isSubSiderOverlay = computed<boolean>(() => {
|
||||||
|
return layoutMode.value === 'tablet'
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 核心逻辑:二级菜单是否可见
|
||||||
|
* 1. 如果有子菜单内容 (activeGroups > 0)
|
||||||
|
* 2. 如果是 Desktop 且 showSubSider 为真 (Dock模式)
|
||||||
|
* 3. 如果是 Tablet/Mobile 且 isOverlayVisible 为真 (Overlay模式)
|
||||||
|
*/
|
||||||
|
const isSubSiderVisible = computed<boolean>(() => {
|
||||||
|
if (activeGroups.value.length === 0) return false
|
||||||
|
|
||||||
|
if (layoutMode.value === 'desktop') {
|
||||||
|
return showSubSider.value
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tablet 和 Mobile 模式下,作为 Overlay 受 isOverlayVisible 控制
|
||||||
|
return isOverlayVisible.value
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 核心逻辑:计算主内容区左偏移量 (mainLeft)
|
||||||
|
* 严格按照断点策略计算,防止遮挡
|
||||||
|
* 1. Desktop: ASIDE_W + (SUB_W if dock)
|
||||||
|
* 2. Tablet: ASIDE_W (sub-sider 是 overlay,不占位)
|
||||||
|
* 3. Mobile: 0
|
||||||
|
*/
|
||||||
const mainLeft = computed<string>(() => {
|
const mainLeft = computed<string>(() => {
|
||||||
const asideWidth = isMainAsideCollapsed.value ? 0 : ASIDE_W
|
if (layoutMode.value === 'mobile') {
|
||||||
const subWidth = showSubSider.value ? SUB_W : 0
|
return '0px'
|
||||||
return (asideWidth + subWidth) + 'px'
|
}
|
||||||
|
|
||||||
|
let left = ASIDE_W // 只要不是 Mobile,主侧栏 70px 始终 Dock
|
||||||
|
|
||||||
|
// 只有在 Desktop 模式且二级菜单处于 Dock 模式显示时,才累加宽度
|
||||||
|
if (layoutMode.value === 'desktop' && showSubSider.value && activeGroups.value.length > 0) {
|
||||||
|
left += SUB_W
|
||||||
|
}
|
||||||
|
|
||||||
|
return left + 'px'
|
||||||
})
|
})
|
||||||
|
|
||||||
// 获取一级菜单列表
|
// 获取一级菜单列表
|
||||||
@@ -162,13 +205,28 @@ const currentComponent = computed<any>(() => {
|
|||||||
|
|
||||||
function onTopMenuClick(menu: TopMenu): void {
|
function onTopMenuClick(menu: TopMenu): void {
|
||||||
activeTopMenuId.value = menu.id
|
activeTopMenuId.value = menu.id
|
||||||
if (menu.groups.length === 0) {
|
|
||||||
|
// 1:1 复刻 CRMEB 交互:
|
||||||
|
// 1. 如果有子菜单:Desktop 下 dock,Tablet/Mobile 下唤起 Overlay
|
||||||
|
if (menu.groups.length > 0) {
|
||||||
|
if (layoutMode.value === 'desktop') {
|
||||||
|
showSubSider.value = true
|
||||||
|
} else {
|
||||||
|
isOverlayVisible.value = true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 2. 如果没有子菜单:直接跳转并关闭所有 Overlay
|
||||||
openRoute(menu.id + '_index')
|
openRoute(menu.id + '_index')
|
||||||
|
closeAllMenu()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onRouteClick(routeId: string): void {
|
function onRouteClick(routeId: string): void {
|
||||||
openRoute(routeId)
|
openRoute(routeId)
|
||||||
|
// 1:1 复刻 CRMEB:在移动端或平板叠加模式下,点击具体子路由后自动收起
|
||||||
|
if (layoutMode.value !== 'desktop') {
|
||||||
|
closeAllMenu()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onTabClick(tab: TabItem): void {
|
function onTabClick(tab: TabItem): void {
|
||||||
@@ -191,6 +249,12 @@ function toggleMainAsideCollapse(): void {
|
|||||||
storeToggleCollapse()
|
storeToggleCollapse()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function closeAllMenu(): void {
|
||||||
|
isMobileMenuOpen.value = false
|
||||||
|
isOverlayVisible.value = false
|
||||||
|
// 注意:在 Desktop 模式下,closeAllMenu 通常不隐藏 showSubSider,除非用户手动点汉堡按钮
|
||||||
|
}
|
||||||
|
|
||||||
function onSearch(): void {
|
function onSearch(): void {
|
||||||
uni.showToast({ title: '搜索', icon: 'none' })
|
uni.showToast({ title: '搜索', icon: 'none' })
|
||||||
}
|
}
|
||||||
@@ -207,19 +271,37 @@ function onNotify(): void {
|
|||||||
// 生命周期
|
// 生命周期
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
|
let resizeTid: any = null
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
initNavState()
|
initNavState()
|
||||||
|
|
||||||
// 初始化窗口宽度
|
// 初始化窗口宽度
|
||||||
windowWidth.value = uni.getWindowInfo().windowWidth
|
windowWidth.value = uni.getWindowInfo().windowWidth
|
||||||
|
|
||||||
// 监听窗口变化
|
// 监听窗口变化 (增加节流保护与跨断点状态重置)
|
||||||
uni.onWindowResize((res) => {
|
uni.onWindowResize((res) => {
|
||||||
windowWidth.value = res.size.windowWidth
|
if (resizeTid != null) {
|
||||||
// 窗口变大时自动关闭移动端菜单
|
cancelAnimationFrame(resizeTid)
|
||||||
if (windowWidth.value >= 768) {
|
|
||||||
isMobileMenuOpen.value = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
resizeTid = requestAnimationFrame(() => {
|
||||||
|
const oldMode = layoutMode.value
|
||||||
|
windowWidth.value = res.size.windowWidth
|
||||||
|
const newMode = layoutMode.value
|
||||||
|
|
||||||
|
// 跨断点自动关闭所有 Overlay,防止状态残留遮挡内容
|
||||||
|
if (oldMode != newMode) {
|
||||||
|
isOverlayVisible.value = false
|
||||||
|
isMobileMenuOpen.value = false
|
||||||
|
// 如果切到桌面端,默认展开二级侧栏以符合 CRMEB 习惯
|
||||||
|
if (newMode === 'desktop') {
|
||||||
|
showSubSider.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resizeTid = null
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
@@ -237,7 +319,7 @@ onMounted(() => {
|
|||||||
/* 移动端侧边栏样式 */
|
/* 移动端侧边栏样式 */
|
||||||
.mobile-aside {
|
.mobile-aside {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: -100px; /* 隐藏在左侧 */
|
left: -70px; /* 隐藏在左侧,匹配 ASIDE_W: 70 */
|
||||||
top: 0;
|
top: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
z-index: 1001;
|
z-index: 1001;
|
||||||
@@ -246,17 +328,26 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mobile-aside-open {
|
.mobile-aside-open {
|
||||||
transform: translateX(100px); /* 移入视图 */
|
transform: translateX(70px); /* 移入视图 */
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobile-mask {
|
.mobile-mask {
|
||||||
position: absolute;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
background: rgba(0, 0, 0, 0.4);
|
background: rgba(0, 0, 0, 0.4);
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
transition: opacity 300ms ease, visibility 0s linear 300ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mask-show {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
transition: opacity 300ms ease, visibility 0s linear 0s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.main {
|
.main {
|
||||||
@@ -264,7 +355,7 @@ onMounted(() => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
transition: margin-left 0.3s ease;
|
transition: margin-left 300ms ease;
|
||||||
background: #f0f2f5;
|
background: #f0f2f5;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
@@ -278,7 +369,7 @@ onMounted(() => {
|
|||||||
/* 强行改变侧边栏布局模式 */
|
/* 强行改变侧边栏布局模式 */
|
||||||
.admin-sidebar {
|
.admin-sidebar {
|
||||||
position: absolute !important;
|
position: absolute !important;
|
||||||
left: -100px !important; /* 隐藏在左侧,假设 ASIDE_W 是 96 */
|
left: -70px !important; /* 隐藏在左侧 */
|
||||||
top: 0;
|
top: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
z-index: 1001;
|
z-index: 1001;
|
||||||
@@ -287,7 +378,7 @@ onMounted(() => {
|
|||||||
|
|
||||||
/* 展开时的状态 */
|
/* 展开时的状态 */
|
||||||
.mobile-aside-open {
|
.mobile-aside-open {
|
||||||
transform: translateX(100px) !important;
|
transform: translateX(70px) !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,16 +21,18 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="aside-footer" @click="onToggle">
|
<!-- 1:1 复刻 CRMEB: 一级侧边栏通常不单独折叠,由顶部汉堡菜单控制整体 -->
|
||||||
|
<!-- <view class="aside-footer" @click="onToggle">
|
||||||
<view class="toggle-btn">
|
<view class="toggle-btn">
|
||||||
<text class="toggle-icon">{{ collapsed ? '>' : '<' }}</text>
|
<text class="toggle-icon">{{ collapsed ? '>' : '<' }}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view> -->
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="uts">
|
<script setup lang="uts">
|
||||||
import type { TopMenu } from '@/layouts/admin/router/adminRoutes.uts'
|
import type { TopMenu } from '@/layouts/admin/router/adminRoutes.uts'
|
||||||
|
import { isMobile } from '@/layouts/admin/store/adminNavStore.uts'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
collapsed: boolean
|
collapsed: boolean
|
||||||
@@ -86,7 +88,7 @@ function onLogoClick(): void {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
transition: width 0.3s ease;
|
transition: width 0.3s ease;
|
||||||
z-index: 1000;
|
z-index: 1002; /* 确保在遮罩层之上 */
|
||||||
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.15);
|
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,12 @@
|
|||||||
<view class="header">
|
<view class="header">
|
||||||
<view class="header-left">
|
<view class="header-left">
|
||||||
<!-- 移动端菜单切换按钮 (CSS 控制显隐) -->
|
<!-- 移动端菜单切换按钮 (CSS 控制显隐) -->
|
||||||
<view class="menu-toggle mobile-only" @click="$emit('toggle-mobile-menu')">
|
<view class="menu-toggle mobile-only" @click="onToggleSubSider">
|
||||||
|
<text class="menu-icon">☰</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- Desktop/Tablet Hamburger (1:1 复刻 CRMEB 切换二级侧边栏) -->
|
||||||
|
<view class="menu-toggle desktop-only" @click="onToggleSubSider">
|
||||||
<text class="menu-icon">☰</text>
|
<text class="menu-icon">☰</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
@@ -30,6 +35,13 @@
|
|||||||
|
|
||||||
<script setup lang="uts">
|
<script setup lang="uts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
import {
|
||||||
|
toggleSubSider,
|
||||||
|
showSubSider,
|
||||||
|
layoutMode,
|
||||||
|
isOverlayVisible,
|
||||||
|
isMobileMenuOpen
|
||||||
|
} from '@/layouts/admin/store/adminNavStore.uts'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
breadcrumb: Array<{id: string, title: string}>
|
breadcrumb: Array<{id: string, title: string}>
|
||||||
@@ -44,6 +56,22 @@ defineEmits<{
|
|||||||
(e:'toggle-mobile-menu'): void
|
(e:'toggle-mobile-menu'): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 核心切换逻辑:
|
||||||
|
* 1. Desktop: 切换 showSubSider (Dock状态)
|
||||||
|
* 2. Tablet: 切换 isOverlayVisible (Overlay状态)
|
||||||
|
* 3. Mobile: 切换 isMobileMenuOpen (Mobile Aside)
|
||||||
|
*/
|
||||||
|
function onToggleSubSider(): void {
|
||||||
|
if (layoutMode.value === 'desktop') {
|
||||||
|
toggleSubSider()
|
||||||
|
} else if (layoutMode.value === 'tablet') {
|
||||||
|
isOverlayVisible.value = !isOverlayVisible.value
|
||||||
|
} else {
|
||||||
|
isMobileMenuOpen.value = !isMobileMenuOpen.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const currentTitle = computed((): string => {
|
const currentTitle = computed((): string => {
|
||||||
if (props.breadcrumb.length > 0) {
|
if (props.breadcrumb.length > 0) {
|
||||||
return props.breadcrumb[props.breadcrumb.length - 1].title
|
return props.breadcrumb[props.breadcrumb.length - 1].title
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="admin-subsider" :style="{ left: asideWidth + 'px', width: siderWidth + 'px' }">
|
<view
|
||||||
|
class="admin-subsider"
|
||||||
|
:class="{ 'is-hidden': !visible }"
|
||||||
|
:style="{ left: asideWidth + 'px', width: siderWidth + 'px' }"
|
||||||
|
>
|
||||||
<view class="subsider-header">
|
<view class="subsider-header">
|
||||||
<text class="header-title">{{ topMenuTitle }}</text>
|
<text class="header-title">{{ topMenuTitle }}</text>
|
||||||
</view>
|
</view>
|
||||||
@@ -10,8 +14,8 @@
|
|||||||
<text>{{ group.title }}</text>
|
<text>{{ group.title }}</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view
|
<view
|
||||||
v-for="route in getGroupRoutes(group.id)"
|
v-for="route in getGroupRoutes(group.id)"
|
||||||
:key="route.id"
|
:key="route.id"
|
||||||
class="menu-item"
|
class="menu-item"
|
||||||
:class="{ active: route.id === activeRouteId }"
|
:class="{ active: route.id === activeRouteId }"
|
||||||
@@ -28,6 +32,7 @@
|
|||||||
import type { MenuGroup, RouteRecord } from '@/layouts/admin/router/adminRoutes.uts'
|
import type { MenuGroup, RouteRecord } from '@/layouts/admin/router/adminRoutes.uts'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
|
visible: boolean // ✅ 新增:由父层控制显示/隐藏(不要用 v-if 卸载)
|
||||||
topMenuTitle: string
|
topMenuTitle: string
|
||||||
groups: MenuGroup[]
|
groups: MenuGroup[]
|
||||||
routes: Map<string, RouteRecord[]>
|
routes: Map<string, RouteRecord[]>
|
||||||
@@ -56,10 +61,45 @@ function onRouteClick(routeId: string): void {
|
|||||||
bottom: 0;
|
bottom: 0;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border-right: 1px solid #e8e8e8;
|
border-right: 1px solid #e8e8e8;
|
||||||
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.06);
|
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.05);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
||||||
|
/* ✅ 动画只用 GPU 友好属性,避免 width/left 导致卡顿 */
|
||||||
|
opacity: 1;
|
||||||
|
transform: translate3d(0, 0, 0);
|
||||||
|
will-change: transform, opacity;
|
||||||
|
|
||||||
|
/* ✅ 可点击时才需要高 z-index */
|
||||||
z-index: 999;
|
z-index: 999;
|
||||||
|
|
||||||
|
/* ✅ 注意:visibility 默认可见 */
|
||||||
|
visibility: visible;
|
||||||
|
|
||||||
|
/* 显示态:visibility 不延迟 */
|
||||||
|
transition: transform 300ms ease, opacity 250ms ease, visibility 0s linear 0s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 悬浮/抽屉模式:复刻 CRMEB 核心逻辑 */
|
||||||
|
.sub-sider-overlay {
|
||||||
|
z-index: 1001;
|
||||||
|
box-shadow: 6px 0 16px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-subsider.is-hidden {
|
||||||
|
/* ✅ 立即禁止拦截点击,彻底解决“遮挡可操作区” */
|
||||||
|
pointer-events: none;
|
||||||
|
|
||||||
|
/* ✅ 先淡出 + 滑出(推荐滑出 100%:完全离开可视区) */
|
||||||
|
opacity: 0;
|
||||||
|
transform: translate3d(-100%, 0, 0);
|
||||||
|
|
||||||
|
/* ✅ 动画结束后再隐藏(避免那一帧还挡住) */
|
||||||
|
visibility: hidden;
|
||||||
|
transition: transform 300ms ease, opacity 250ms ease, visibility 0s linear 300ms;
|
||||||
|
|
||||||
|
/* 可选:进一步避免层叠影响 */
|
||||||
|
z-index: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.subsider-header {
|
.subsider-header {
|
||||||
@@ -68,7 +108,7 @@ function onRouteClick(routeId: string): void {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 0 16px;
|
padding: 0 16px;
|
||||||
border-bottom: 1px solid #e8e8e8;
|
border-bottom: 1px solid #e8e8e8;
|
||||||
|
|
||||||
.header-title {
|
.header-title {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -79,7 +119,9 @@ function onRouteClick(routeId: string): void {
|
|||||||
.subsider-menu {
|
.subsider-menu {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 8px 0;
|
padding: 8px 0;
|
||||||
overflow-y: scroll;
|
|
||||||
|
/* ✅ 用 auto,避免一直显示滚动条影响布局 */
|
||||||
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-group {
|
.menu-group {
|
||||||
@@ -91,7 +133,7 @@ function onRouteClick(routeId: string): void {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #999;
|
color: #999;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
|
||||||
text {
|
text {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
@@ -103,17 +145,19 @@ function onRouteClick(routeId: string): void {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
|
/* ✅ 不要 transition: all(会引发布局属性动画/回流) */
|
||||||
|
transition: background-color 0.2s ease, color 0.2s ease;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: #f5f5f5;
|
background: #f5f5f5;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
background: #e6f7ff;
|
background: #e6f7ff;
|
||||||
color: #1890ff;
|
color: #1890ff;
|
||||||
|
|
||||||
&::after {
|
&::after {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -124,7 +168,7 @@ function onRouteClick(routeId: string): void {
|
|||||||
background: #1890ff;
|
background: #1890ff;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.item-title {
|
.item-title {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,25 +35,30 @@ export const activeRouteId = ref<string>('home_index')
|
|||||||
/** 打开的标签页列表 */
|
/** 打开的标签页列表 */
|
||||||
export const tabs = ref<TabItem[]>([])
|
export const tabs = ref<TabItem[]>([])
|
||||||
|
|
||||||
/** 是否折叠主侧边栏 */
|
/** 是否折叠主侧边栏 (CRMEB: 70px) */
|
||||||
export const isMainAsideCollapsed = ref<boolean>(false)
|
export const isMainAsideCollapsed = ref<boolean>(false)
|
||||||
|
|
||||||
|
/** 是否显示二级侧边栏状态控制 */
|
||||||
|
export const showSubSider = ref<boolean>(true)
|
||||||
|
|
||||||
/** 屏幕宽度 */
|
/** 屏幕宽度 */
|
||||||
export const windowWidth = ref<number>(1024)
|
export const windowWidth = ref<number>(1024)
|
||||||
|
|
||||||
/** 是否为移动端布局 (width < 768) */
|
/** 布局模式:desktop | tablet | mobile */
|
||||||
export const isMobile = computed<boolean>(() => windowWidth.value < 768)
|
export const layoutMode = computed<string>(() => {
|
||||||
|
if (windowWidth.value < 768) return 'mobile'
|
||||||
|
if (windowWidth.value < 1200) return 'tablet'
|
||||||
|
return 'desktop'
|
||||||
|
})
|
||||||
|
|
||||||
/** 移动端菜单是否展开 */
|
/** 是否为移动端简易判断 */
|
||||||
|
export const isMobile = computed<boolean>(() => layoutMode.value === 'mobile')
|
||||||
|
|
||||||
|
/** 移动端开关 */
|
||||||
export const isMobileMenuOpen = ref<boolean>(false)
|
export const isMobileMenuOpen = ref<boolean>(false)
|
||||||
|
|
||||||
/** 是否显示二级侧边栏 */
|
/** 遮罩层开关 (用于 tablet 的 subSider overlay 和 mobile 的 aside overlay) */
|
||||||
export const showSubSider = computed<boolean>(() => {
|
export const isOverlayVisible = ref<boolean>(false)
|
||||||
if (isMobile.value) return false // 移动端不显式二级侧边栏在主体区域
|
|
||||||
const topMenus = getTopMenus()
|
|
||||||
const activeMenu = topMenus.find(m => m.id === activeTopMenuId.value)
|
|
||||||
return activeMenu ? activeMenu.groups.length > 0 : false
|
|
||||||
})
|
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// Actions
|
// Actions
|
||||||
@@ -175,6 +180,13 @@ export function toggleMainAsideCollapse(): void {
|
|||||||
isMainAsideCollapsed.value = !isMainAsideCollapsed.value
|
isMainAsideCollapsed.value = !isMainAsideCollapsed.value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 切换二级侧边栏显示状态 (Desktop 模式)
|
||||||
|
*/
|
||||||
|
export function toggleSubSider(): void {
|
||||||
|
showSubSider.value = !showSubSider.value
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 初始化导航状态
|
* 初始化导航状态
|
||||||
* 在 AdminLayout 组件 onMounted 时调用
|
* 在 AdminLayout 组件 onMounted 时调用
|
||||||
|
|||||||
@@ -29,8 +29,35 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* 强制子项允许收缩,防止内部长文本撑爆网格 */
|
/* 强制子项允许收缩,防止内部长文本撑爆网格 */
|
||||||
.kpi-grid > * {
|
.kpi-grid > *,
|
||||||
|
.kpi-grid-6 > * {
|
||||||
min-width: 0 !important;
|
min-width: 0 !important;
|
||||||
flex: none !important; /* 覆盖旧的 flex 逻辑 */
|
flex: none !important; /* 覆盖旧的 flex 逻辑 */
|
||||||
width: auto !important; /* 让 grid 控制宽度 */
|
width: auto !important; /* 让 grid 控制宽度 */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 6-2-1 网格规范 (对标 CRMEB 统计概况) */
|
||||||
|
.kpi-grid-6 {
|
||||||
|
display: grid !important;
|
||||||
|
gap: 16px;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1200px) {
|
||||||
|
.kpi-grid-6 {
|
||||||
|
grid-template-columns: repeat(6, minmax(0, 1fr)) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) and (max-width: 1199.98px) {
|
||||||
|
.kpi-grid-6 {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr)) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767.98px) {
|
||||||
|
.kpi-grid-6 {
|
||||||
|
grid-template-columns: repeat(1, minmax(0, 1fr)) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -25,6 +25,100 @@
|
|||||||
4. 使用 `minmax(0, 1fr)` 配分子项 `min-width: 0` 确保在任何容器宽度下网格不被撑爆。
|
4. 使用 `minmax(0, 1fr)` 配分子项 `min-width: 0` 确保在任何容器宽度下网格不被撑爆。
|
||||||
- **强制规则**: 任何页面都不允许出现一行 3 个卡片的情况。
|
- **强制规则**: 任何页面都不允许出现一行 3 个卡片的情况。
|
||||||
|
|
||||||
|
#### **原因十三:侧边栏响应式断点与 Overlay 冲突 (严重体验红线)**
|
||||||
|
|
||||||
|
- **现象**: 在 770px~1005px 宽度下,侧边栏遮挡内容,或内容区没有正确让出空间。
|
||||||
|
- **原因**:
|
||||||
|
1. 断点逻辑不统一:`main` 布局计算与组件显隐逻辑使用了不同的宽度阈值。
|
||||||
|
2. 动画不匹配:内容区 `margin-left` 动画时长与侧栏 `transform` 时长不一致。
|
||||||
|
3. 状态残留:跨断点时没有强制重置 Overlay 状态。
|
||||||
|
- **1:1 复刻 CRMEB 解决方案**:
|
||||||
|
1. **明确三段断点策略**:
|
||||||
|
- **Desktop (>=1200px)**: `aside=dock`, `subSider=dock` (如果开启)。`mainLeft = 270px`。
|
||||||
|
- **Tablet (768px-1199px)**: `aside=dock`, `subSider=overlay` (带 mask)。`mainLeft = 70px`。
|
||||||
|
- **Mobile (<768px)**: `aside=overlay`, `subSider=overlay`。`mainLeft = 0px`。
|
||||||
|
2. **统一状态机**: 使用 `layoutMode` (desktop/tablet/mobile) 驱动所有组件渲染,而非散乱的媒体查询。
|
||||||
|
3. **计算属性驱动布局**: `mainLeft` 必须严格根据 `layoutMode` 和 `subSider` 的 Dock/Overlay 属性动态计算。
|
||||||
|
4. **跨断点清理**: 在 `onWindowResize` 监听到模式切换时,立即强制关闭所有 Overlay (mask=false),防止残影遮挡。
|
||||||
|
5. **指针事件隔离**: 隐藏态侧栏必须显式设置 `pointer-events: none` 和 `visibility: hidden`。
|
||||||
|
|
||||||
|
#### **原因十四:KPI 统计概况列数不一致 (CRMEB 像素级规范)**
|
||||||
|
|
||||||
|
- **现象**: 统计概况在大屏下显示 4 列或 5 列,导致无法一行平铺 6 个核心指标。
|
||||||
|
- **解决方案 (6-2-1 规则)**:
|
||||||
|
1. 使用全局类 `.kpi-grid-6` 实现专用的统计概况布局。
|
||||||
|
2. **强制断点**:
|
||||||
|
- `> 1200px`: 固定 6 列 (`repeat(6, minmax(0, 1fr))`)。
|
||||||
|
- `768px - 1199.98px`: 固定 2 列。
|
||||||
|
- `< 768px`: 固定 1 列。
|
||||||
|
3. **侧栏联动**: 当 `viewport < 768px` 时,布局容器必须切换到移动端模式(主侧栏变为 Overlay,mainLeft 归零),确保 1 列布局拥有最大水平空间。
|
||||||
|
|
||||||
|
#### **原因十五:ECharts 图表响应式裁切与视觉偏位**
|
||||||
|
|
||||||
|
- **现象**: 窗口缩小时饼图被砍掉一半,或在大屏下视觉不居中、底部不对齐。
|
||||||
|
- **原因**:
|
||||||
|
1. 使用了固定高度(如 `height: 521px`)而没有弹性容器。
|
||||||
|
2. ECharts 的 `center` 和 `radius` 使用了静态百分比或固定像素,无法适配极端宽高比。
|
||||||
|
3. 仅依赖 `window.resize` 而没有监听“容器级”尺寸变化(如侧边栏折叠导致的局部宽度变化)。
|
||||||
|
- **解决方案**:
|
||||||
|
1. **容器加固**: 移除根组件固定高度,改用 `min-height`;卡片内容区设置 `overflow: visible` 防止裁切。
|
||||||
|
2. **像素级算法**: 禁止在 `option` 中直接硬编码 `['50%', '60%']`,应计算:`outerRadius = min(w, h) * 0.38`,`centerY = legendSpace + (h - legendSpace) / 2`。
|
||||||
|
3. **双重自适应**:
|
||||||
|
- **底层**: 使用 `ResizeObserver` 监听容器级 DOM 变化。
|
||||||
|
- **上层**: 监听 `store` 中的侧边栏状态,在动画结束后强制触发 `refreshSize()`。
|
||||||
|
4. **文字同步**: 中间文字(total-value)必须通过 `computed` 样式与饼图中心点像素级同步。
|
||||||
|
5. **架构建议**: 在 UTS 环境下,涉及 ECharts 等需要向 RenderJS 传递复杂 Object 的组件,**优先使用 Options API**。Options API 在处理 `toPlainObject` 转换及 Prop 传递时具备更稳定的兼容性,可避免 `script setup` 下可能出现的对象元数据干扰。
|
||||||
|
|
||||||
|
#### **原因十六:ECharts 响应式失效与百分比布局规范**
|
||||||
|
|
||||||
|
- **现象**: 侧边栏折叠时图表不缩小导致溢出,或在不同宽高比下圆环变形/裁切。
|
||||||
|
- **原因**:
|
||||||
|
1. 使用了固定的像素值定义 `radius` 和 `center`。
|
||||||
|
2. 容器高度依赖父级 `flex` 且未设置 `min-height`,导致某些极端高度下被折叠。
|
||||||
|
3. 缺乏对动画中间态的捕获。
|
||||||
|
- **强制解决方案 (Web/uni-app-x)**:
|
||||||
|
1. **百分比优先**: `radius` 必须使用百分比(如 `['55%', '75%']`),`center` 必须使用百分比。
|
||||||
|
2. **高度钳制**: 容器使用 `height: clamp(...)` 或固定高度(如 `520px`)+ `min-height`。
|
||||||
|
3. **全链路 Resize**:
|
||||||
|
- `ResizeObserver` 监听 DOM 容器变化。
|
||||||
|
- `transitionend` 监听侧边栏动画结束。
|
||||||
|
- `watch` 监听全局布局状态(`layoutMode` / `collapsed`)。
|
||||||
|
4. **裁切隔离**: 所有祖先容器、`ec-wrap`、`ec-canvas` 必须显式设置 `overflow: visible !important`。
|
||||||
|
|
||||||
|
#### **原因十七:ECharts 画布溢出与容器约束 (Containing Block 丢失)**
|
||||||
|
|
||||||
|
- **现象**: `ec-canvas` (uni-view) 拥有极大的 `width/height` (如 948px) 且 `position: absolute`,但脱离了父级卡片,溢出到整个页面,且图表内容消失。
|
||||||
|
- **原因**:
|
||||||
|
1. **层叠上下文丢失**: `ec-canvas` 的父组件或 `EChartsView` 根节点未设置 `position: relative`,导致 absolute 元素相对于 `body` 定位。
|
||||||
|
2. **尺寸初始化瓶颈**: 在父容器高度为 0 (如 `flex` 自动压缩) 或动画中间态初始化 ECharts,导致内部 `canvas` 宽高计算错误。
|
||||||
|
- **强制解决方案**:
|
||||||
|
1. **修正 Containing Block (Step 1)**:
|
||||||
|
- 容器(`chart-wrap`)必须显式设置 `position: relative !important`。
|
||||||
|
- 使用 `:deep()` 强制约束子组件:`.ec-wrap { position: relative; } .ec-canvas { position: absolute; inset: 0; width: 100% !important; height: 100% !important; }`。
|
||||||
|
2. **确定性高度策略 (Step 2)**:
|
||||||
|
- 图表容器禁止高度塌陷。使用 `height: clamp(min, preferred, max)` 或固定高度。
|
||||||
|
- 示例:`height: clamp(280px, 40vh, 450px); min-height: 280px;`。
|
||||||
|
3. **Resize 闭环链路 (Step 3)**:
|
||||||
|
- 禁止使用 `setTimeout` 盲猜。
|
||||||
|
- 必须使用 `ResizeObserver` 监听 `chart-wrap` 尺寸变化。
|
||||||
|
- 必须监听 `sidebar` 的 `transitionend` 事件,确保侧边栏动画结束后的布局稳定。
|
||||||
|
- 统一使用 `requestAnimationFrame(() => chart.resize())` 确保在下一次重绘前完成布局对齐。
|
||||||
|
4. **百分比布局 (Step 4)**:
|
||||||
|
- `series.pie` 的 `radius` 和 `center` 必须使用百分比形式,杜绝 px 导致的自适应失败。
|
||||||
|
|
||||||
|
#### **原因十八:CRMEB 响应式断点与图表重构规范 (1:1 复刻)**
|
||||||
|
|
||||||
|
- **现象**: 在 1200px 断点切换时布局生硬,图表在单列模式下比例过小或中心偏移。
|
||||||
|
- **解决方案**:
|
||||||
|
1. **Grid 布局容器**: 容器页面必须使用 CSS Grid 定义 `grid-template-columns: 2fr 1fr` (>=1200) 和 `1fr` (<1200),避免 flex 宽度计算误差。
|
||||||
|
2. **外部图例隔离**: 为保证 ECharts 渲染空间的确定性,禁止使用内置 `legend`。改为 `flex-direction: row` 的外部 `legend-col`,确保左上角图例与右侧饼图互不干扰。
|
||||||
|
3. **确定性中心同步**:
|
||||||
|
- ECharts `center` 设置为 `['50%', '50%']`。
|
||||||
|
- 中心文字组件使用绝对定位 `top:50%; left:50%; transform:translate(-50%,-50%)` 实现物理像素级对齐。
|
||||||
|
4. **两档响应式高度**:
|
||||||
|
- `chart-col` 在桌面端 (>=1200) 使用中等高度 (约 320-360px)。
|
||||||
|
- 在移动端/窄屏 (<1200) 自动扩展为大高度 (约 500-600px),以匹配全宽展示的视觉张力。
|
||||||
|
|
||||||
## 🛠️ 完整修复流程
|
## 🛠️ 完整修复流程
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -24,14 +24,14 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 用户概况卡片区 (使用统一响应式网格) -->
|
<!-- 用户概况卡片区 (使用 6-2-1 响应式网格) -->
|
||||||
<view class="section-card">
|
<view class="section-card">
|
||||||
<view class="section-header">
|
<view class="section-header">
|
||||||
<text class="section-title">用户概况</text>
|
<text class="section-title">用户概况</text>
|
||||||
<text class="info-icon">ⓘ</text>
|
<text class="info-icon">❓</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="kpi-grid">
|
<view class="kpi-grid-6">
|
||||||
<view class="kpi-card" v-for="item in kpiData" :key="item.title">
|
<view class="kpi-card" v-for="item in kpiData" :key="item.title">
|
||||||
<view class="kpi-icon-box" :style="{ backgroundColor: item.bg }">
|
<view class="kpi-icon-box" :style="{ backgroundColor: item.bg }">
|
||||||
<text class="kpi-icon">{{ item.icon }}</text>
|
<text class="kpi-icon">{{ item.icon }}</text>
|
||||||
@@ -41,7 +41,10 @@
|
|||||||
<text class="kpi-value">{{ item.value }}</text>
|
<text class="kpi-value">{{ item.value }}</text>
|
||||||
<view class="kpi-meta">
|
<view class="kpi-meta">
|
||||||
<text class="meta-label">环比增长:</text>
|
<text class="meta-label">环比增长:</text>
|
||||||
<text class="meta-value" :class="item.trend">{{ item.percent }} {{ item.trend === 'up' ? '▲' : '▼' }}</text>
|
<text class="meta-value" :class="item.trend">
|
||||||
|
{{ item.percent }}
|
||||||
|
<text class="trend-icon">{{ item.trend === 'up' ? '▲' : '▼' }}</text>
|
||||||
|
</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -82,12 +85,12 @@ import AnalyticsUserMapTable from '@/components/analytics/AnalyticsUserMapTable.
|
|||||||
import AnalyticsUserGenderSection from '@/components/analytics/AnalyticsUserGenderSection.uvue'
|
import AnalyticsUserGenderSection from '@/components/analytics/AnalyticsUserGenderSection.uvue'
|
||||||
|
|
||||||
const kpiData = [
|
const kpiData = [
|
||||||
{ title: '累计用户', value: '80834', percent: '0.84%', trend: 'up', icon: '👤', bg: '#f3e8ff' },
|
{ title: '累计用户', value: '80887', percent: '0.79%', trend: 'up', icon: '👥', bg: '#efebff' },
|
||||||
{ title: '访客数', value: '1138', percent: '1.04%', trend: 'down', icon: '👤', bg: '#e0f2fe' },
|
{ title: '访客数', value: '1076', percent: '11.65%', trend: 'down', icon: '👤', bg: '#e8f4ff' },
|
||||||
{ title: '浏览量', value: '9519', percent: '2.34%', trend: 'down', icon: '👁️', bg: '#dcfce7' },
|
{ title: '浏览量', value: '8843', percent: '12.09%', trend: 'down', icon: '📁', bg: '#e6fff1' },
|
||||||
{ title: '新增用户数', value: '680', percent: '4.36%', trend: 'down', icon: '👤', bg: '#ffedd5' },
|
{ title: '新增用户数', value: '635', percent: '14.65%', trend: 'down', icon: '👤', bg: '#fff7e6' },
|
||||||
{ title: '成交用户数', value: '132', percent: '11.86%', trend: 'up', icon: '👤', bg: '#f3e8ff' },
|
{ title: '成交用户数', value: '122', percent: '0.81%', trend: 'down', icon: '👥', bg: '#f2f0ff' },
|
||||||
{ title: '付费会员数', value: '79', percent: '7.05%', trend: 'down', icon: '💎', bg: '#f3e8ff' }
|
{ title: '付费会员数', value: '76', percent: '13.63%', trend: 'down', icon: '💎', bg: '#f2f0ff' }
|
||||||
]
|
]
|
||||||
|
|
||||||
const chartData = {
|
const chartData = {
|
||||||
@@ -198,70 +201,80 @@ function onExport() {
|
|||||||
|
|
||||||
.section-card {
|
.section-card {
|
||||||
background-color: #fff;
|
background-color: #fff;
|
||||||
border-radius: 4px;
|
border-radius: 8px;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-header {
|
.section-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
|
||||||
margin-bottom: 24px;
|
margin-bottom: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-title {
|
.section-title {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
color: #262626;
|
color: #333;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-icon {
|
.info-icon {
|
||||||
|
margin-left: 6px;
|
||||||
|
color: #999;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: #bfbfbf;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* kpi-row 已废弃,采用全局 kpi-grid */
|
/* kpi-row 已废弃,采用全局 kpi-grid-6 */
|
||||||
.kpi-card {
|
.kpi-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 16px;
|
padding: 16px 8px;
|
||||||
padding: 8px;
|
min-width: 0;
|
||||||
min-width: 0; /* 允许收缩 */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.kpi-icon-box {
|
.kpi-icon-box {
|
||||||
width: 44px;
|
width: 48px;
|
||||||
height: 44px;
|
height: 48px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
margin-right: 12px;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.kpi-icon { font-size: 20px; }
|
.kpi-icon { font-size: 20px; }
|
||||||
|
|
||||||
.kpi-content {
|
.kpi-content {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 4px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.kpi-label { font-size: 14px; color: #8c8c8c; }
|
.kpi-label { font-size: 13px; color: #666; margin-bottom: 4px; }
|
||||||
.kpi-value { font-size: 24px; font-weight: 500; color: #262626; }
|
.kpi-value { font-size: 24px; font-weight: 700; color: #333; line-height: 1.2; margin-bottom: 4px; }
|
||||||
|
|
||||||
.kpi-meta {
|
.kpi-meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
font-size: 12px;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.meta-label { color: #8c8c8c; }
|
.meta-label { font-size: 12px; color: #999; }
|
||||||
.meta-value.up { color: #ff4d4f; }
|
.meta-value { font-size: 12px; font-weight: 500; }
|
||||||
|
.meta-value.up { color: #f5222d; }
|
||||||
.meta-value.down { color: #52c41a; }
|
.meta-value.down { color: #52c41a; }
|
||||||
|
|
||||||
|
.trend-icon {
|
||||||
|
font-size: 10px;
|
||||||
|
margin-left: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
.chart-container {
|
.chart-container {
|
||||||
border-top: 1px solid #f0f0f0;
|
border-top: 1px solid #f0f0f0;
|
||||||
padding-top: 24px;
|
padding-top: 24px;
|
||||||
@@ -281,14 +294,18 @@ function onExport() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.analysis-row {
|
.analysis-row {
|
||||||
display: flex;
|
display: grid;
|
||||||
flex-direction: row;
|
grid-template-columns: 2fr 1fr;
|
||||||
gap: 16px;
|
gap: 20px;
|
||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1200px) {
|
.map-col, .gender-col {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1199.98px) {
|
||||||
.filter-card {
|
.filter-card {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
@@ -304,20 +321,11 @@ function onExport() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.analysis-row {
|
.analysis-row {
|
||||||
flex-direction: column;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.map-col, .gender-col {
|
.map-col, .gender-col {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
flex: none;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.map-col {
|
|
||||||
flex: 7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.gender-col {
|
|
||||||
flex: 3;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ async function loadChinaMap() {
|
|||||||
if (chinaMapLoaded) {
|
if (chinaMapLoaded) {
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (chinaMapLoading) {
|
if (chinaMapLoading) {
|
||||||
// 如果正在加载,等待加载完成
|
// 如果正在加载,等待加载完成
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
@@ -53,9 +53,9 @@ async function loadChinaMap() {
|
|||||||
}, 10000);
|
}, 10000);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
chinaMapLoading = true;
|
chinaMapLoading = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 从在线 CDN 加载中国地图 GeoJSON 数据
|
// 从在线 CDN 加载中国地图 GeoJSON 数据
|
||||||
// 使用 ECharts 官方示例数据源
|
// 使用 ECharts 官方示例数据源
|
||||||
@@ -72,7 +72,7 @@ async function loadChinaMap() {
|
|||||||
const geoJson = await response.json();
|
const geoJson = await response.json();
|
||||||
echarts.registerMap('china', geoJson);
|
echarts.registerMap('china', geoJson);
|
||||||
}
|
}
|
||||||
|
|
||||||
chinaMapLoaded = true;
|
chinaMapLoaded = true;
|
||||||
console.log('[EChartsView] 中国地图数据已加载并注册');
|
console.log('[EChartsView] 中国地图数据已加载并注册');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -94,15 +94,15 @@ function getChartKey(el) {
|
|||||||
|
|
||||||
function ensureChart(el, retryCount = 0) {
|
function ensureChart(el, retryCount = 0) {
|
||||||
if (!el) return null;
|
if (!el) return null;
|
||||||
|
|
||||||
const key = getChartKey(el);
|
const key = getChartKey(el);
|
||||||
let chart = charts.get(key);
|
let chart = charts.get(key);
|
||||||
|
|
||||||
// 如果图表已存在且有效,直接返回
|
// 如果图表已存在且有效,直接返回
|
||||||
if (chart && !chart.isDisposed()) {
|
if (chart && !chart.isDisposed()) {
|
||||||
return chart;
|
return chart;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果图表已销毁,从 Map 中移除
|
// 如果图表已销毁,从 Map 中移除
|
||||||
if (chart && chart.isDisposed()) {
|
if (chart && chart.isDisposed()) {
|
||||||
charts.delete(key);
|
charts.delete(key);
|
||||||
@@ -113,19 +113,19 @@ function ensureChart(el, retryCount = 0) {
|
|||||||
}
|
}
|
||||||
chart = null;
|
chart = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 确保元素有尺寸
|
// 确保元素有尺寸
|
||||||
const rect = el.getBoundingClientRect();
|
const rect = el.getBoundingClientRect();
|
||||||
const computedStyle = window.getComputedStyle(el);
|
const computedStyle = window.getComputedStyle(el);
|
||||||
const width = parseFloat(computedStyle.width) || rect.width;
|
const width = parseFloat(computedStyle.width) || rect.width;
|
||||||
const height = parseFloat(computedStyle.height) || rect.height;
|
const height = parseFloat(computedStyle.height) || rect.height;
|
||||||
|
|
||||||
// 如果尺寸为 0,尝试延迟初始化(最多重试 10 次)
|
// 如果尺寸为 0,尝试延迟初始化(最多重试 10 次)
|
||||||
if ((width === 0 || height === 0) && retryCount < 10) {
|
if ((width === 0 || height === 0) && retryCount < 10) {
|
||||||
if (retryCount === 0) {
|
if (retryCount === 0) {
|
||||||
console.warn('[EChartsView] 容器尺寸为 0,延迟初始化', { width, height, rect });
|
console.warn('[EChartsView] 容器尺寸为 0,延迟初始化', { width, height, rect });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用指数退避策略,避免无限循环
|
// 使用指数退避策略,避免无限循环
|
||||||
const delay = Math.min(100 * Math.pow(1.5, retryCount), 1000);
|
const delay = Math.min(100 * Math.pow(1.5, retryCount), 1000);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -133,7 +133,7 @@ function ensureChart(el, retryCount = 0) {
|
|||||||
}, delay);
|
}, delay);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果重试次数过多,使用默认尺寸
|
// 如果重试次数过多,使用默认尺寸
|
||||||
if (width === 0 || height === 0) {
|
if (width === 0 || height === 0) {
|
||||||
console.warn('[EChartsView] 容器尺寸仍为 0,使用默认尺寸', { width, height });
|
console.warn('[EChartsView] 容器尺寸仍为 0,使用默认尺寸', { width, height });
|
||||||
@@ -141,7 +141,7 @@ function ensureChart(el, retryCount = 0) {
|
|||||||
const parentRect = el.parentElement ? el.parentElement.getBoundingClientRect() : { width: 800, height: 400 };
|
const parentRect = el.parentElement ? el.parentElement.getBoundingClientRect() : { width: 800, height: 400 };
|
||||||
const finalWidth = width || parentRect.width || 800;
|
const finalWidth = width || parentRect.width || 800;
|
||||||
const finalHeight = height || parentRect.height || 400;
|
const finalHeight = height || parentRect.height || 400;
|
||||||
|
|
||||||
if (finalWidth > 0 && finalHeight > 0) {
|
if (finalWidth > 0 && finalHeight > 0) {
|
||||||
// 设置元素尺寸
|
// 设置元素尺寸
|
||||||
el.style.width = finalWidth + 'px';
|
el.style.width = finalWidth + 'px';
|
||||||
@@ -151,19 +151,19 @@ function ensureChart(el, retryCount = 0) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 注意:地图数据加载在 setOption 中处理,这里不处理
|
// 注意:地图数据加载在 setOption 中处理,这里不处理
|
||||||
// 因为 ensureChart 是同步函数,不能使用 await
|
// 因为 ensureChart 是同步函数,不能使用 await
|
||||||
|
|
||||||
chart = echarts.init(el, null, {
|
chart = echarts.init(el, null, {
|
||||||
renderer: "canvas",
|
renderer: "canvas",
|
||||||
width: rect.width,
|
width: rect.width,
|
||||||
height: rect.height
|
height: rect.height
|
||||||
});
|
});
|
||||||
|
|
||||||
charts.set(key, chart);
|
charts.set(key, chart);
|
||||||
|
|
||||||
// 自适应:监听容器尺寸变化
|
// 自适应:监听容器尺寸变化
|
||||||
if (typeof ResizeObserver !== "undefined") {
|
if (typeof ResizeObserver !== "undefined") {
|
||||||
const ro = new ResizeObserver((entries) => {
|
const ro = new ResizeObserver((entries) => {
|
||||||
@@ -200,7 +200,7 @@ function ensureChart(el, retryCount = 0) {
|
|||||||
// 存储 handler 以便后续清理
|
// 存储 handler 以便后续清理
|
||||||
el._resizeHandler = resizeHandler;
|
el._resizeHandler = resizeHandler;
|
||||||
}
|
}
|
||||||
|
|
||||||
return chart;
|
return chart;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[EChartsView] 初始化失败', e);
|
console.error('[EChartsView] 初始化失败', e);
|
||||||
@@ -212,7 +212,7 @@ function disposeChart(el) {
|
|||||||
if (!el) return;
|
if (!el) return;
|
||||||
const key = getChartKey(el);
|
const key = getChartKey(el);
|
||||||
const chart = charts.get(key);
|
const chart = charts.get(key);
|
||||||
|
|
||||||
if (chart && !chart.isDisposed()) {
|
if (chart && !chart.isDisposed()) {
|
||||||
try {
|
try {
|
||||||
chart.dispose();
|
chart.dispose();
|
||||||
@@ -220,15 +220,15 @@ function disposeChart(el) {
|
|||||||
console.warn('[EChartsView] dispose 失败', e);
|
console.warn('[EChartsView] dispose 失败', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
charts.delete(key);
|
charts.delete(key);
|
||||||
|
|
||||||
const ro = resizeObservers.get(key);
|
const ro = resizeObservers.get(key);
|
||||||
if (ro) {
|
if (ro) {
|
||||||
ro.disconnect();
|
ro.disconnect();
|
||||||
resizeObservers.delete(key);
|
resizeObservers.delete(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (el._resizeHandler) {
|
if (el._resizeHandler) {
|
||||||
window.removeEventListener("resize", el._resizeHandler);
|
window.removeEventListener("resize", el._resizeHandler);
|
||||||
delete el._resizeHandler;
|
delete el._resizeHandler;
|
||||||
@@ -260,22 +260,22 @@ export default {
|
|||||||
console.error('[EChartsView] setOption: 找不到容器元素');
|
console.error('[EChartsView] setOption: 找不到容器元素');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查 option 是否有效
|
// 检查 option 是否有效
|
||||||
if (!option || typeof option !== 'object') {
|
if (!option || typeof option !== 'object') {
|
||||||
console.warn('[EChartsView] setOption: option 无效', option);
|
console.warn('[EChartsView] setOption: option 无效', option);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查是否使用了地图,如果是,先加载地图数据
|
// 检查是否使用了地图,如果是,先加载地图数据
|
||||||
const needsMap = option.geo || (option.series && Array.isArray(option.series) && option.series.some(s => s.type === 'map' && s.map === 'china'));
|
const needsMap = option.geo || (option.series && Array.isArray(option.series) && option.series.some(s => s.type === 'map' && s.map === 'china'));
|
||||||
if (needsMap) {
|
if (needsMap) {
|
||||||
await loadChinaMap();
|
await loadChinaMap();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 保存 option 供 ensureChart 使用
|
// 保存 option 供 ensureChart 使用
|
||||||
el._pendingOption = option;
|
el._pendingOption = option;
|
||||||
|
|
||||||
// 确保图表已初始化
|
// 确保图表已初始化
|
||||||
let c = ensureChart(el);
|
let c = ensureChart(el);
|
||||||
if (!c) {
|
if (!c) {
|
||||||
@@ -369,7 +369,7 @@ export default {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查图表是否已销毁
|
// 检查图表是否已销毁
|
||||||
if (c.isDisposed()) {
|
if (c.isDisposed()) {
|
||||||
console.warn('[EChartsView] setOption: 图表已销毁,重新初始化');
|
console.warn('[EChartsView] setOption: 图表已销毁,重新初始化');
|
||||||
@@ -380,7 +380,7 @@ export default {
|
|||||||
c = ensureChart(el);
|
c = ensureChart(el);
|
||||||
if (!c) return;
|
if (!c) return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 如果使用地图,确保地图已加载
|
// 如果使用地图,确保地图已加载
|
||||||
if (needsMap) {
|
if (needsMap) {
|
||||||
@@ -389,7 +389,7 @@ export default {
|
|||||||
// 深拷贝 option 确保是纯 JS 对象
|
// 深拷贝 option 确保是纯 JS 对象
|
||||||
const plainOption = JSON.parse(JSON.stringify(option));
|
const plainOption = JSON.parse(JSON.stringify(option));
|
||||||
c.setOption(plainOption, true);
|
c.setOption(plainOption, true);
|
||||||
|
|
||||||
// 使用 requestAnimationFrame 避免 resize 警告
|
// 使用 requestAnimationFrame 避免 resize 警告
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
const key = getChartKey(el);
|
const key = getChartKey(el);
|
||||||
@@ -415,11 +415,20 @@ export default {
|
|||||||
|
|
||||||
<style>
|
<style>
|
||||||
.ec-wrap {
|
.ec-wrap {
|
||||||
|
position: relative !important;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
overflow: hidden; /* 防止 canvas 越界 */
|
||||||
}
|
}
|
||||||
|
|
||||||
.ec-canvas {
|
.ec-canvas {
|
||||||
width: 100%;
|
position: absolute !important;
|
||||||
height: 100%;
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 100% !important;
|
||||||
|
height: 100% !important;
|
||||||
|
display: block;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user