import { echarts } from '@/plugins/echarts' import { storeToRefs } from 'pinia' import { useSettingStore } from '@/store/modules/setting' import { getCssVar } from '@/utils/ui' const useChartOps = () => ({ /** */ chartHeight: '16rem', /** 字体大小 */ fontSize: 13, /** 字体颜色 */ fontColor: '#999', /** 主题颜色 */ themeColor: getCssVar('--el-color-primary-light-1'), /** 颜色组 */ colors: [ getCssVar('--el-color-primary-light-1'), '#4ABEFF', '#EDF2FF', '#14DEBA', '#FFAF20', '#FA8A6C', '#FFAF20' ] }) const RESIZE_DELAYS = [50, 100, 200, 350] const MENU_RESIZE_DELAYS = [50, 100, 200] const RESIZE_DEBOUNCE_DELAY = 100 function useChart(options = {}) { const { initOptions, initDelay = 0, threshold = 0.1, autoTheme = true } = options const settingStore = useSettingStore() const { isDark, menuOpen, menuType } = storeToRefs(settingStore) const chartRef = ref() let chart = null let intersectionObserver = null let pendingOptions = null let resizeTimeoutId = null let resizeFrameId = null let isDestroyed = false let emptyStateDiv = null const clearTimers = () => { if (resizeTimeoutId) { clearTimeout(resizeTimeoutId) resizeTimeoutId = null } if (resizeFrameId) { cancelAnimationFrame(resizeFrameId) resizeFrameId = null } } const requestAnimationResize = () => { if (resizeFrameId) { cancelAnimationFrame(resizeFrameId) } resizeFrameId = requestAnimationFrame(() => { handleResize() resizeFrameId = null }) } const debouncedResize = () => { if (resizeTimeoutId) { clearTimeout(resizeTimeoutId) } resizeTimeoutId = window.setTimeout(() => { requestAnimationResize() resizeTimeoutId = null }, RESIZE_DEBOUNCE_DELAY) } const multiDelayResize = (delays) => { nextTick(requestAnimationResize) delays.forEach((delay) => { setTimeout(requestAnimationResize, delay) }) } let menuOpenStopHandle = null let menuTypeStopHandle = null const setupMenuWatchers = () => { menuOpenStopHandle = watch(menuOpen, () => multiDelayResize(RESIZE_DELAYS)) menuTypeStopHandle = watch(menuType, () => { nextTick(requestAnimationResize) setTimeout(() => multiDelayResize(MENU_RESIZE_DELAYS), 0) }) } const cleanupMenuWatchers = () => { menuOpenStopHandle?.() menuTypeStopHandle?.() menuOpenStopHandle = null menuTypeStopHandle = null } let themeStopHandle = null const setupThemeWatcher = () => { if (autoTheme) { themeStopHandle = watch(isDark, () => { emptyStateManager.updateStyle() if (chart && !isDestroyed) { requestAnimationFrame(() => { if (chart && !isDestroyed) { const currentOptions = chart.getOption() if (currentOptions) { updateChart(currentOptions) } } }) } }) } } const cleanupThemeWatcher = () => { themeStopHandle?.() themeStopHandle = null } const createLineStyle = (color, width = 1, type) => ({ color, width, ...(type && { type }) }) const styleCache = { axisLine: null, splitLine: null, axisLabel: null, lastDarkValue: isDark.value } const clearStyleCache = () => { styleCache.axisLine = null styleCache.splitLine = null styleCache.axisLabel = null styleCache.lastDarkValue = isDark.value } const getAxisLineStyle = (show = true) => { if (styleCache.lastDarkValue !== isDark.value) { clearStyleCache() } if (!styleCache.axisLine) { styleCache.axisLine = { show, lineStyle: createLineStyle(isDark.value ? '#444' : '#EDEDED') } } return styleCache.axisLine } const getSplitLineStyle = (show = true) => { if (styleCache.lastDarkValue !== isDark.value) { clearStyleCache() } if (!styleCache.splitLine) { styleCache.splitLine = { show, lineStyle: createLineStyle(isDark.value ? '#444' : '#EDEDED', 1, 'dashed') } } return styleCache.splitLine } const getAxisLabelStyle = (show = true) => { if (styleCache.lastDarkValue !== isDark.value) { clearStyleCache() } if (!styleCache.axisLabel) { const { fontColor, fontSize } = useChartOps() styleCache.axisLabel = { show, color: fontColor, fontSize } } return styleCache.axisLabel } const getAxisTickStyle = () => ({ show: false }) const getAnimationConfig = (animationDelay = 50, animationDuration = 1500) => ({ animationDelay: (idx) => idx * animationDelay + 200, animationDuration: (idx) => animationDuration - idx * 50, animationEasing: 'quarticOut' }) const getTooltipStyle = (trigger = 'axis', customOptions = {}) => ({ trigger, backgroundColor: isDark.value ? 'rgba(0, 0, 0, 0.8)' : 'rgba(255, 255, 255, 0.9)', borderColor: isDark.value ? '#333' : '#ddd', borderWidth: 1, textStyle: { color: isDark.value ? '#fff' : '#333' }, ...customOptions }) const getLegendStyle = (position = 'bottom', customOptions = {}) => { const baseConfig = { textStyle: { color: isDark.value ? '#fff' : '#333' }, itemWidth: 12, itemHeight: 12, itemGap: 20, ...customOptions } switch (position) { case 'bottom': return { ...baseConfig, bottom: 0, left: 'center', orient: 'horizontal', icon: 'roundRect' } case 'top': return { ...baseConfig, top: 0, left: 'center', orient: 'horizontal', icon: 'roundRect' } case 'left': return { ...baseConfig, left: 0, top: 'center', orient: 'vertical', icon: 'roundRect' } case 'right': return { ...baseConfig, right: 0, top: 'center', orient: 'vertical', icon: 'roundRect' } default: return baseConfig } } const getGridWithLegend = (showLegend, legendPosition = 'bottom', baseGrid = {}) => { const defaultGrid = { top: 15, right: 15, bottom: 8, left: 0, containLabel: true, ...baseGrid } if (!showLegend) { return defaultGrid } switch (legendPosition) { case 'bottom': return { ...defaultGrid, bottom: 40 } case 'top': return { ...defaultGrid, top: 40 } case 'left': return { ...defaultGrid, left: 120 } case 'right': return { ...defaultGrid, right: 120 } default: return defaultGrid } } const createIntersectionObserver = () => { if (intersectionObserver || !chartRef.value) return intersectionObserver = new IntersectionObserver( (entries) => { entries.forEach((entry) => { if (entry.isIntersecting && pendingOptions && !isDestroyed) { requestAnimationFrame(() => { if (!isDestroyed && pendingOptions) { try { if (!chart) { chart = echarts.init(entry.target) } const event = new CustomEvent('chartVisible', { detail: { options: pendingOptions } }) entry.target.dispatchEvent(event) pendingOptions = null cleanupIntersectionObserver() } catch (error) { console.error('图表初始化失败:', error) } } }) } }) }, { threshold } ) intersectionObserver.observe(chartRef.value) } const cleanupIntersectionObserver = () => { if (intersectionObserver) { intersectionObserver.disconnect() intersectionObserver = null } } const isContainerVisible = (element) => { const rect = element.getBoundingClientRect() return rect.width > 0 && rect.height > 0 && rect.top < window.innerHeight && rect.bottom > 0 } const performChartInit = (options2) => { if (!chart && chartRef.value && !isDestroyed) { chart = echarts.init(chartRef.value) setupMenuWatchers() setupThemeWatcher() } if (chart && !isDestroyed) { chart.setOption(options2) pendingOptions = null } } const emptyStateManager = { create: () => { if (!chartRef.value || emptyStateDiv) return emptyStateDiv = document.createElement('div') emptyStateDiv.style.cssText = ` position: absolute; top: 0; left: 0; right: 0; bottom: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; font-size: 12px; color: ${isDark.value ? '#555555' : '#B3B2B2'}; background: transparent; z-index: 10; ` emptyStateDiv.innerHTML = `暂无数据` if ( chartRef.value.style.position !== 'relative' && chartRef.value.style.position !== 'absolute' ) { chartRef.value.style.position = 'relative' } chartRef.value.appendChild(emptyStateDiv) }, remove: () => { if (emptyStateDiv && chartRef.value) { chartRef.value.removeChild(emptyStateDiv) emptyStateDiv = null } }, updateStyle: () => { if (emptyStateDiv) { emptyStateDiv.style.color = isDark.value ? '#666' : '#999' } } } const initChart = (options2 = {}, isEmpty = false) => { if (!chartRef.value || isDestroyed) return const mergedOptions = { ...initOptions, ...options2 } try { if (isEmpty) { if (chart) { chart.clear() } emptyStateManager.create() return } else { emptyStateManager.remove() } if (isContainerVisible(chartRef.value)) { if (initDelay > 0) { setTimeout(() => performChartInit(mergedOptions), initDelay) } else { performChartInit(mergedOptions) } } else { pendingOptions = mergedOptions createIntersectionObserver() } } catch (error) { console.error('图表初始化失败:', error) } } const updateChart = (options2) => { if (isDestroyed) return try { if (!chart) { initChart(options2) return } chart.setOption(options2) } catch (error) { console.error('图表更新失败:', error) } } const handleResize = () => { if (chart && !isDestroyed) { try { chart.resize() } catch (error) { console.error('图表resize失败:', error) } } } const destroyChart = () => { isDestroyed = true if (chart) { try { chart.dispose() } catch (error) { console.error('图表销毁失败:', error) } finally { chart = null } } cleanupMenuWatchers() cleanupThemeWatcher() emptyStateManager.remove() cleanupIntersectionObserver() clearTimers() clearStyleCache() pendingOptions = null } const getChartInstance = () => chart const isChartInitialized = () => chart !== null onMounted(() => { window.addEventListener('resize', debouncedResize) }) onBeforeUnmount(() => { window.removeEventListener('resize', debouncedResize) }) onUnmounted(() => { destroyChart() }) return { isDark, chartRef, initChart, updateChart, handleResize, destroyChart, getChartInstance, isChartInitialized, emptyStateManager, getAxisLineStyle, getSplitLineStyle, getAxisLabelStyle, getAxisTickStyle, getAnimationConfig, getTooltipStyle, getLegendStyle, useChartOps, getGridWithLegend } } function useChartComponent(options) { const { props, generateOptions, checkEmpty, watchSources = [], onVisible, chartOptions = {} } = options const chart = useChart(chartOptions) const { chartRef, initChart, isDark, emptyStateManager } = chart const isEmpty = computed(() => { if (props.isEmpty) return true if (checkEmpty) return checkEmpty() return false }) const updateChart = () => { nextTick(() => { if (isEmpty.value) { if (chart.getChartInstance()) { chart.getChartInstance()?.clear() } emptyStateManager.create() } else { emptyStateManager.remove() initChart(generateOptions()) } }) } const handleChartVisible = () => { if (onVisible) { onVisible() } else { updateChart() } } const stopHandles = [] const setupWatchers = () => { if (watchSources.length > 0) { const stopHandle = watch(watchSources, updateChart, { deep: true }) stopHandles.push(stopHandle) } const themeStopHandle = watch(isDark, () => { emptyStateManager.updateStyle() updateChart() }) stopHandles.push(themeStopHandle) } const cleanupWatchers = () => { stopHandles.forEach((stop) => stop()) stopHandles.length = 0 } const setupLifecycle = () => { onMounted(() => { updateChart() if (chartRef.value) { chartRef.value.addEventListener('chartVisible', handleChartVisible) } }) onBeforeUnmount(() => { if (chartRef.value) { chartRef.value.removeEventListener('chartVisible', handleChartVisible) } cleanupWatchers() emptyStateManager.remove() }) } setupWatchers() setupLifecycle() return { ...chart, isEmpty, updateChart, handleChartVisible } } export { useChart, useChartComponent, useChartOps }