diff --git a/package-lock.json b/package-lock.json index 53a4f62..de327f8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,8 @@ "axios": "^1.5.0", "echarts": "^5.4.3", "echarts-gl": "^2.1.0", + "flv.js": "^1.6.2", + "hls.js": "^1.6.16", "lunar-javascript": "^1.7.3", "vue": "^3.3.4", "vue-router": "^4.5.1" @@ -1098,6 +1100,12 @@ "node": ">= 0.4" } }, + "node_modules/es6-promise": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz", + "integrity": "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==", + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.18.20", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz", @@ -1153,6 +1161,16 @@ "node": ">=8" } }, + "node_modules/flv.js": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/flv.js/-/flv.js-1.6.2.tgz", + "integrity": "sha512-xre4gUbX1MPtgQRKj2pxJENp/RnaHaxYvy3YToVVCrSmAWUu85b9mug6pTXF6zakUjNP2lFWZ1rkSX7gxhB/2A==", + "license": "Apache-2.0", + "dependencies": { + "es6-promise": "^4.2.8", + "webworkify-webpack": "^2.1.5" + } + }, "node_modules/follow-redirects": { "version": "1.15.9", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", @@ -1291,6 +1309,12 @@ "node": ">= 0.4" } }, + "node_modules/hls.js": { + "version": "1.6.16", + "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.16.tgz", + "integrity": "sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA==", + "license": "Apache-2.0" + }, "node_modules/immutable": { "version": "5.1.3", "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.3.tgz", @@ -1622,6 +1646,12 @@ "vue": "^3.2.0" } }, + "node_modules/webworkify-webpack": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/webworkify-webpack/-/webworkify-webpack-2.1.5.tgz", + "integrity": "sha512-2akF8FIyUvbiBBdD+RoHpoTbHMQF2HwjcxfDvgztAX5YwbZNyrtfUMgvfgFVsgDhDPVTlkbb5vyasqDHfIDPQw==", + "license": "MIT" + }, "node_modules/zrender": { "version": "5.6.1", "resolved": "https://registry.npmjs.org/zrender/-/zrender-5.6.1.tgz", @@ -2215,6 +2245,11 @@ "hasown": "^2.0.2" } }, + "es6-promise": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz", + "integrity": "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==" + }, "esbuild": { "version": "0.18.20", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz", @@ -2260,6 +2295,15 @@ "to-regex-range": "^5.0.1" } }, + "flv.js": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/flv.js/-/flv.js-1.6.2.tgz", + "integrity": "sha512-xre4gUbX1MPtgQRKj2pxJENp/RnaHaxYvy3YToVVCrSmAWUu85b9mug6pTXF6zakUjNP2lFWZ1rkSX7gxhB/2A==", + "requires": { + "es6-promise": "^4.2.8", + "webworkify-webpack": "^2.1.5" + } + }, "follow-redirects": { "version": "1.15.9", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", @@ -2341,6 +2385,11 @@ "function-bind": "^1.1.2" } }, + "hls.js": { + "version": "1.6.16", + "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.16.tgz", + "integrity": "sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA==" + }, "immutable": { "version": "5.1.3", "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.3.tgz", @@ -2531,6 +2580,11 @@ "@vue/devtools-api": "^6.6.4" } }, + "webworkify-webpack": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/webworkify-webpack/-/webworkify-webpack-2.1.5.tgz", + "integrity": "sha512-2akF8FIyUvbiBBdD+RoHpoTbHMQF2HwjcxfDvgztAX5YwbZNyrtfUMgvfgFVsgDhDPVTlkbb5vyasqDHfIDPQw==" + }, "zrender": { "version": "5.6.1", "resolved": "https://registry.npmjs.org/zrender/-/zrender-5.6.1.tgz", diff --git a/package.json b/package.json index 260ce0a..82e8c2c 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,8 @@ "axios": "^1.5.0", "echarts": "^5.4.3", "echarts-gl": "^2.1.0", + "flv.js": "^1.6.2", + "hls.js": "^1.6.16", "lunar-javascript": "^1.7.3", "vue": "^3.3.4", "vue-router": "^4.5.1" diff --git a/public/datas/market-cameras.json b/public/datas/market-cameras.json new file mode 100644 index 0000000..560c193 --- /dev/null +++ b/public/datas/market-cameras.json @@ -0,0 +1,54 @@ +{ + "pageSize": 2, + "autoPlayInterval": 10000, + "cameras": [ + { + "id": 1, + "name": "交易大厅主区", + "resolution": "1920x1080", + "preview": "/images/monitor/交易大厅主区.jpg", + "streamUrl": "", + "status": "online" + }, + { + "id": 2, + "name": "牦牛展示区", + "resolution": "1920x1080", + "preview": "/images/monitor/牦牛展示区.jpg", + "streamUrl": "", + "status": "online" + }, + { + "id": 3, + "name": "停车场入口", + "resolution": "1280x720", + "preview": "/images/monitor/停车场入口.jpg", + "streamUrl": "", + "status": "online" + }, + { + "id": 4, + "name": "安全出口", + "resolution": "1280x720", + "preview": "/images/monitor/安全出口.jpg", + "streamUrl": "", + "status": "offline" + }, + { + "id": 5, + "name": "办公区域", + "resolution": "1920x1080", + "preview": "/images/monitor/办公区域.jpg", + "streamUrl": "", + "status": "error" + }, + { + "id": 6, + "name": "仓储区域", + "resolution": "1280x720", + "preview": "/images/monitor/仓储区域.jpg", + "streamUrl": "", + "status": "online" + } + ] +} diff --git a/public/images/图例bg.png b/public/images/图例bg.png new file mode 100644 index 0000000..557d7c4 Binary files /dev/null and b/public/images/图例bg.png differ diff --git a/public/images/大气压强.png b/public/images/大气压强.png new file mode 100644 index 0000000..ec05a3e Binary files /dev/null and b/public/images/大气压强.png differ diff --git a/public/images/已交易.png b/public/images/已交易.png new file mode 100644 index 0000000..636c7ce Binary files /dev/null and b/public/images/已交易.png differ diff --git a/public/images/待交易.png b/public/images/待交易.png new file mode 100644 index 0000000..12de3e1 Binary files /dev/null and b/public/images/待交易.png differ diff --git a/public/images/总数量.png b/public/images/总数量.png new file mode 100644 index 0000000..4b5e43c Binary files /dev/null and b/public/images/总数量.png differ diff --git a/public/images/成交订单.png b/public/images/成交订单.png new file mode 100644 index 0000000..603d877 Binary files /dev/null and b/public/images/成交订单.png differ diff --git a/public/images/按钮.png b/public/images/按钮.png new file mode 100644 index 0000000..cefedbd Binary files /dev/null and b/public/images/按钮.png differ diff --git a/public/images/按钮选中.png b/public/images/按钮选中.png new file mode 100644 index 0000000..8790d28 Binary files /dev/null and b/public/images/按钮选中.png differ diff --git a/public/images/温度.png b/public/images/温度.png new file mode 100644 index 0000000..dd1ff03 Binary files /dev/null and b/public/images/温度.png differ diff --git a/public/images/湿度.png b/public/images/湿度.png new file mode 100644 index 0000000..127c785 Binary files /dev/null and b/public/images/湿度.png differ diff --git a/public/images/空气质量.png b/public/images/空气质量.png new file mode 100644 index 0000000..59e9701 Binary files /dev/null and b/public/images/空气质量.png differ diff --git a/public/images/紫外线.png b/public/images/紫外线.png new file mode 100644 index 0000000..ceb5492 Binary files /dev/null and b/public/images/紫外线.png differ diff --git a/public/images/降雨量.png b/public/images/降雨量.png new file mode 100644 index 0000000..e33aadd Binary files /dev/null and b/public/images/降雨量.png differ diff --git a/public/images/风力.png b/public/images/风力.png new file mode 100644 index 0000000..9e14718 Binary files /dev/null and b/public/images/风力.png differ diff --git a/public/images/风向.png b/public/images/风向.png new file mode 100644 index 0000000..1c5b8fc Binary files /dev/null and b/public/images/风向.png differ diff --git a/src/App.vue b/src/App.vue index 6fe5ec7..00b1d91 100644 --- a/src/App.vue +++ b/src/App.vue @@ -45,7 +45,12 @@
- +
@@ -119,10 +124,25 @@ const systemConfig = ref({ }) const isLayoutReady = ref(false) // 控制页面渲染时机 const isSupplyExpanded = ref(false) // 控制供应信息组件是否展开 +const supplyDetailId = ref('') // 处理供应信息组件展开/收缩 -const handleSupplyExpand = (expanded) => { - isSupplyExpanded.value = expanded +const handleSupplyExpand = (payload) => { + if (typeof payload === 'boolean') { + isSupplyExpanded.value = payload + if (!payload) { + supplyDetailId.value = '' + } + return + } + isSupplyExpanded.value = !!payload.expanded + if (payload.detailId) { + supplyDetailId.value = payload.detailId + } +} + +const clearSupplyDetailId = () => { + supplyDetailId.value = '' } onMounted(async () => { diff --git a/src/components/ChinaMap.vue b/src/components/ChinaMap.vue index fbc18f3..73a0936 100644 --- a/src/components/ChinaMap.vue +++ b/src/components/ChinaMap.vue @@ -1,16 +1,22 @@ @@ -80,6 +105,10 @@ import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue' import * as echarts from 'echarts' import BaseCard from './BaseCard.vue' +import { loadSystemConfig, DEFAULT_MAP_HUB, DEFAULT_MAP_FLOW_LEVELS } from '../utils/systemConfig.js' + +const MAP_TRADING_API = '/api/dashboard/map-trading-network' +const FALLBACK_TRADING_JSON = './yak-trading-data.json' const chartRef = ref(null) const sourceChartRef = ref(null) @@ -90,6 +119,9 @@ let sourceChartInstance = null let salesChartInstance = null let chinaMapData = null let tradingData = null +let mapSystemConfig = null +let cachedOutlineKey = '' +let cachedOutlinePaths = [] // 浮动面板状态 const showSourcePanel = ref(false) @@ -108,6 +140,47 @@ const modes = [ { key: 'local', label: '红原出栏分布图' } ] +const BTN_NORMAL = '/images/按钮.png' +const BTN_ACTIVE = '/images/按钮选中.png' +const LEGEND_BG = '/images/图例bg.png' +const GEO_DATA_INDEX = 2 +const WIREFRAME_GEO_INDEX = 3 +const MAP_ASPECT_SCALE = 0.82 +const MAP_ZOOM_FACTOR = 0.97 +const MAP_LAYOUT_SIZE = '84%' +const MAP_ANCHOR_CENTER = [104.8, 37.2] +const MAP_ANCHOR_ZOOM = 1.05 +const MAP_GLOBAL_OFFSET_LNG = 1.45 +const MAP_GLOBAL_OFFSET_LAT = 0.9 +const MAP_GLOBAL_LAYOUT_X = -2.8 +const MAP_GLOBAL_LAYOUT_Y = 3.6 +const MAP_SHADOW_OFFSET_FAR = 1.25 +const MAP_SHADOW_OFFSET_NEAR = 0.75 +const MAP_SHADOW_OFFSET_LNG = -0.45 +const MAP_SHADOW_OFFSET_FAR_LOCAL = 5 +const MAP_SHADOW_OFFSET_NEAR_LOCAL = 3 +const MAP_SHADOW_OFFSET_LNG_LOCAL = -2 +const MAP_WIREFRAME_OFFSET = -0.65 +const MAP_WIREFRAME_OFFSET_LNG = 0.12 +const MAP_WIREFRAME_OFFSET_LOCAL = -3.5 +const MAP_WIREFRAME_OFFSET_LNG_LOCAL = 0.6 +const MAP_FILL_WEST = [45, 181, 181] +const MAP_FILL_EAST = [12, 123, 176] +const MAP_GRADIENT_MIN_LNG = 73 +const MAP_GRADIENT_MAX_LNG = 135 +const MAP_GRADIENT_MIN_LAT = 18 +const MAP_GRADIENT_MAX_LAT = 54 +const MAP_WIREFRAME_COLOR = '#f8feff' +const MAP_INNER_BORDER = 'rgba(6, 38, 62, 0.62)' +const MAP_INNER_BORDER_WIDTH = 0.8 +const MAP_SHADOW_FAR = '#000000' +const MAP_SHADOW_NEAR = '#000000' +const MAP_LABEL_COLOR = '#ffffff' +const MAP_FILL_OPACITY = 0.88 +const MAP_TEXTURE_SIZE = 128 + +const mapTextureCache = new Map() + // 获取当前模式对应的地图文件路径 const getMapFilePath = (mode) => { switch (mode) { @@ -128,21 +201,74 @@ const getMapName = (mode) => { } } +const getHubName = () => { + return tradingData?.centerCity?.name || mapSystemConfig?.mapHub?.name || DEFAULT_MAP_HUB.name +} + +const buildGeoCoordMap = (systemConfig, fallbackGeoCoordMap = {}) => { + const hub = systemConfig?.mapHub || DEFAULT_MAP_HUB + return { + ...fallbackGeoCoordMap, + ...(systemConfig?.mapGeoCoordMap || {}), + [hub.name]: hub.coordinates + } +} + +const loadTradingNetwork = async (systemConfig, fallbackPayload) => { + const hub = systemConfig?.mapHub || DEFAULT_MAP_HUB + const buildPayload = (tradingModes) => ({ + centerCity: { + name: hub.name, + coordinates: hub.coordinates, + description: hub.description || fallbackPayload?.centerCity?.description || '' + }, + geoCoordMap: buildGeoCoordMap(systemConfig, fallbackPayload?.geoCoordMap), + tradingModes, + flowLevels: systemConfig?.mapFlowLevels || fallbackPayload?.flowLevels || DEFAULT_MAP_FLOW_LEVELS + }) + + try { + const response = await fetch(MAP_TRADING_API) + if (!response.ok) { + throw new Error(`HTTP ${response.status}`) + } + const result = await response.json() + if (result.code !== 1 || !result.data?.tradingModes) { + throw new Error(result.message || '地图迁徙接口返回异常') + } + return buildPayload(result.data.tradingModes) + } catch (error) { + console.warn('加载地图迁徙数据失败,使用本地兜底数据:', error) + if (!fallbackPayload?.tradingModes) { + return null + } + return buildPayload(fallbackPayload.tradingModes) + } +} + // 加载地图数据和交易数据 const loadData = async (mapMode = 'china') => { try { - // 加载对应的地图数据 const mapFilePath = getMapFilePath(mapMode) - const mapResponse = await fetch(mapFilePath) + const [mapResponse, systemConfig, fallbackResponse] = await Promise.all([ + fetch(mapFilePath), + loadSystemConfig(), + fetch(FALLBACK_TRADING_JSON) + ]) chinaMapData = await mapResponse.json() - - // 加载牦牛交易数据 - const tradingResponse = await fetch('./yak-trading-data.json') - tradingData = await tradingResponse.json() + mapSystemConfig = systemConfig + const fallbackPayload = await fallbackResponse.json() + tradingData = await loadTradingNetwork(systemConfig, fallbackPayload) + if (!tradingData) { + return false + } // 注册地图 const mapName = getMapName(mapMode) echarts.registerMap(mapName, chinaMapData) + cachedOutlineKey = '' + cachedOutlinePaths = [] + mapTextureCache.clear() return true } catch (error) { @@ -160,17 +286,15 @@ const getFlowColor = (value) => { } // 获取节点颜色(区分流出和流入) -const getNodeColor = (city, modeData) => { +const getNodeColor = (city) => { + const hubName = getHubName() if (currentMode.value === 'outflow') { - // 流出模式:红原县为红色(流出源),其他为蓝色(流入目标) - return city === '红原县' ? '#FF6B6B' : '#00D4FF' - } else if (currentMode.value === 'inflow') { - // 流入模式:红原县为绿色(流入目标),其他为橙色(流出源) - return city === '红原县' ? '#67C23A' : '#E6A23C' - } else { - // 本地模式:红原县为紫色,其他为青色 - return city === '红原县' ? '#9C27B0' : '#26C6DA' + return city === hubName ? '#FFD048' : '#6ecfff' + } + if (currentMode.value === 'inflow') { + return city === hubName ? '#67C23A' : '#5eb8ff' } + return city === hubName ? '#FFD048' : '#6ecfff' } // 获取当前模式的数据 @@ -195,6 +319,7 @@ const generateScatterData = () => { citySet.forEach(city => { const coord = tradingData.geoCoordMap[city] if (coord) { + const hubName = getHubName() // 计算该城市的总流量 let totalFlow = 0 modeData.flows.forEach(flow => { @@ -206,13 +331,14 @@ const generateScatterData = () => { scatterData.push({ name: city, value: coord.concat([totalFlow]), + symbol: city === hubName ? 'diamond' : 'circle', symbolSize: Math.max(6, Math.min(18, totalFlow / 80)), itemStyle: { - color: getNodeColor(city, modeData), + color: getNodeColor(city), borderColor: '#fff', borderWidth: 1.5, shadowBlur: 6, - shadowColor: getNodeColor(city, modeData) + shadowColor: getNodeColor(city) } }) } @@ -224,16 +350,22 @@ const generateScatterData = () => { // 生成流向线数据 const generateLinesData = () => { const modeData = getCurrentModeData() - return modeData.flows.map(flow => ({ - fromName: flow.from, - toName: flow.to, - coords: [ - tradingData.geoCoordMap[flow.from], - tradingData.geoCoordMap[flow.to] - ], - value: flow.value, - description: flow.description - })) + return modeData.flows + .map((flow) => { + const fromCoord = tradingData.geoCoordMap[flow.from] + const toCoord = tradingData.geoCoordMap[flow.to] + if (!fromCoord || !toCoord) { + return null + } + return { + fromName: flow.from, + toName: flow.to, + coords: [fromCoord, toCoord], + value: flow.value, + description: flow.description + } + }) + .filter(Boolean) } // 生成波纹效果数据 @@ -249,18 +381,21 @@ const generateEffectScatterData = () => { }) // 为流量大的城市添加波纹效果 + const flowThreshold = tradingData?.flowLevels?.medium?.threshold || 40 + const hubName = getHubName() Object.entries(cityFlows).forEach(([city, totalFlow]) => { - if (totalFlow > 400) { // 流量阈值 + if (totalFlow > flowThreshold) { const coord = tradingData.geoCoordMap[city] if (coord) { effectData.push({ name: city, value: coord.concat([totalFlow]), + symbol: city === hubName ? 'diamond' : 'circle', symbolSize: Math.max(14, Math.min(26, totalFlow / 60)), itemStyle: { - color: getNodeColor(city, modeData), + color: getNodeColor(city), shadowBlur: 12, - shadowColor: getNodeColor(city, modeData) + shadowColor: getNodeColor(city) } }) } @@ -483,7 +618,7 @@ const generateDetailData = (cityName) => { ] // 根据不同城市返回相应的明细数据 - if (cityName === '红原县') { + if (cityName === getHubName()) { return baseData } else { // 为其他城市生成相应的数据 @@ -521,70 +656,386 @@ const closeDetailPanel = () => { // 计算地图最佳视野范围 const calculateMapBounds = () => { - // 对于红原出栏分布图模式,让ECharts自动适应GeoJSON边界,不需要手动计算 if (currentMode.value === 'local') { - console.log('红原出栏分布图模式:使用ECharts自动缩放') - return { center: [104, 35], zoom: 1.0 } // 返回默认值,实际不会使用 + return { center: [104, 35], zoom: 1.0 } } - - // 其他模式使用原有的基于交易数据的计算方式 - const modeData = getCurrentModeData() - const cities = new Set() - - // 收集当前模式下所有涉及的城市 - modeData.flows.forEach(flow => { - cities.add(flow.from) - cities.add(flow.to) + + return { + center: [...MAP_ANCHOR_CENTER], + zoom: MAP_ANCHOR_ZOOM * MAP_ZOOM_FACTOR + } +} + +const clamp = (value, min, max) => Math.min(max, Math.max(min, value)) + +const getFeatureCenter = (feature) => { + const cp = feature?.properties?.cp || feature?.properties?.center + if (Array.isArray(cp) && cp.length >= 2) { + return cp + } + if (Array.isArray(cp) && cp.length === 1) { + return [cp[0], 35] + } + return [104, 35] +} + +const getRegionAreaColor = (lng, lat, minLng, maxLng, minLat, maxLat) => { + const { r, g, b } = blendDesignColor(lng, lat, minLng, maxLng, minLat, maxLat) + const cacheKey = `${r},${g},${b},${MAP_FILL_OPACITY}` + + if (!mapTextureCache.has(cacheKey)) { + mapTextureCache.set(cacheKey, createRegionTextureCanvas(r, g, b, MAP_FILL_OPACITY)) + } + + return { + image: mapTextureCache.get(cacheKey), + repeat: 'repeat' + } +} + +const createRegionTextureCanvas = (r, g, b, alpha) => { + if (typeof document === 'undefined') { + return null + } + + const size = MAP_TEXTURE_SIZE + const canvas = document.createElement('canvas') + canvas.width = size + canvas.height = size + const ctx = canvas.getContext('2d') + if (!ctx) { + return null + } + + ctx.fillStyle = `rgba(${r}, ${g}, ${b}, ${alpha})` + ctx.fillRect(0, 0, size, size) + + for (let i = 0; i < 420; i += 1) { + const x = Math.random() * size + const y = Math.random() * size + const radius = Math.random() * 1.6 + 0.3 + ctx.fillStyle = `rgba(255, 255, 255, ${Math.random() * 0.1 + 0.02})` + ctx.beginPath() + ctx.arc(x, y, radius, 0, Math.PI * 2) + ctx.fill() + } + + for (let i = 0; i < 260; i += 1) { + const x = Math.random() * size + const y = Math.random() * size + const radius = Math.random() * 2.4 + 0.6 + ctx.fillStyle = `rgba(8, 45, 72, ${Math.random() * 0.08 + 0.02})` + ctx.beginPath() + ctx.arc(x, y, radius, 0, Math.PI * 2) + ctx.fill() + } + + for (let i = 0; i < 6; i += 1) { + const x = Math.random() * size + const y = Math.random() * size + const radius = Math.random() * 28 + 18 + const gradient = ctx.createRadialGradient(x, y, 0, x, y, radius) + gradient.addColorStop(0, `rgba(255, 255, 255, ${Math.random() * 0.06 + 0.02})`) + gradient.addColorStop(1, 'rgba(255, 255, 255, 0)') + ctx.fillStyle = gradient + ctx.beginPath() + ctx.arc(x, y, radius, 0, Math.PI * 2) + ctx.fill() + } + + return canvas +} + +const blendDesignColor = (lng, lat, minLng, maxLng, minLat, maxLat) => { + const lngRatio = clamp((lng - minLng) / (maxLng - minLng), 0, 1) + const latRatio = clamp((lat - minLat) / (maxLat - minLat), 0, 1) + + let r = MAP_FILL_WEST[0] + (MAP_FILL_EAST[0] - MAP_FILL_WEST[0]) * lngRatio + let g = MAP_FILL_WEST[1] + (MAP_FILL_EAST[1] - MAP_FILL_WEST[1]) * lngRatio + let b = MAP_FILL_WEST[2] + (MAP_FILL_EAST[2] - MAP_FILL_WEST[2]) * lngRatio + + const northBoost = (0.62 - latRatio) * 16 + const southDepth = (latRatio - 0.38) * 10 + r = clamp(r + northBoost - southDepth * 0.35, 0, 255) + g = clamp(g + northBoost * 0.95 - southDepth * 0.25, 0, 255) + b = clamp(b + northBoost * 0.55 - southDepth * 0.15, 0, 255) + + return { + r: Math.round(r), + g: Math.round(g), + b: Math.round(b) + } +} + +const pointKey = ([lng, lat]) => `${lng.toFixed(5)},${lat.toFixed(5)}` + +const edgeKey = (a, b) => { + const ka = pointKey(a) + const kb = pointKey(b) + return ka < kb ? `${ka}|${kb}` : `${kb}|${ka}` +} + +const forEachOuterRing = (geometry, callback) => { + if (!geometry) return + if (geometry.type === 'Polygon') { + if (geometry.coordinates[0]) { + callback(geometry.coordinates[0]) + } + } else if (geometry.type === 'MultiPolygon') { + geometry.coordinates.forEach((polygon) => { + if (polygon[0]) { + callback(polygon[0]) + } + }) + } +} + +const stitchBoundarySegments = (segments) => { + const adj = new Map() + segments.forEach(([a, b], idx) => { + const ka = pointKey(a) + const kb = pointKey(b) + if (!adj.has(ka)) adj.set(ka, []) + if (!adj.has(kb)) adj.set(kb, []) + adj.get(ka).push({ point: b, idx }) + adj.get(kb).push({ point: a, idx }) }) - - // 获取所有城市的坐标 - const coordinates = Array.from(cities) - .map(city => tradingData.geoCoordMap[city]) - .filter(coord => coord && coord.length === 2) - - if (coordinates.length === 0) { - return { center: [104, 35], zoom: 0.9 } + + const used = new Set() + const paths = [] + + segments.forEach(([a, b], idx) => { + if (used.has(idx)) { + return + } + + used.add(idx) + const path = [a, b] + + const extendTail = () => { + const tailKey = pointKey(path[path.length - 1]) + const candidates = (adj.get(tailKey) || []).filter((item) => !used.has(item.idx)) + if (!candidates.length) { + return false + } + const next = candidates[0] + used.add(next.idx) + path.push(next.point) + return true + } + + const extendHead = () => { + const headKey = pointKey(path[0]) + const candidates = (adj.get(headKey) || []).filter((item) => !used.has(item.idx)) + if (!candidates.length) { + return false + } + const next = candidates[0] + used.add(next.idx) + path.unshift(next.point) + return true + } + + while (extendTail()) {} + while (extendHead()) {} + paths.push(path) + }) + + return paths +} + +const buildNationalOutlinePaths = (geojson) => { + if (!geojson?.features?.length) { + return [] } - - // 计算经纬度边界 - const lngs = coordinates.map(coord => coord[0]) - const lats = coordinates.map(coord => coord[1]) - - const minLng = Math.min(...lngs) - const maxLng = Math.max(...lngs) - const minLat = Math.min(...lats) - const maxLat = Math.max(...lats) - - // 计算中心点 - const centerLng = (minLng + maxLng) / 2 - const centerLat = (minLat + maxLat) / 2 - - // 计算跨度 - const lngSpan = maxLng - minLng - const latSpan = maxLat - minLat - const maxSpan = Math.max(lngSpan, latSpan) - - // 根据跨度计算缩放级别,添加适当的边距 - let zoom = 0.9 - if (maxSpan > 0) { - // 基础缩放计算,加上20%的边距 - const baseZoom = Math.min(20 / (maxSpan * 1.2), 3.5) - zoom = Math.max(1.2, Math.min(baseZoom, 3.0)) + + const edgeCount = new Map() + const edgeList = [] + + geojson.features.forEach((feature) => { + forEachOuterRing(feature.geometry, (ring) => { + if (!Array.isArray(ring) || ring.length < 2) { + return + } + for (let i = 0; i < ring.length - 1; i += 1) { + const a = ring[i] + const b = ring[i + 1] + if (!a || !b || (a[0] === b[0] && a[1] === b[1])) { + continue + } + const key = edgeKey(a, b) + edgeCount.set(key, (edgeCount.get(key) || 0) + 1) + edgeList.push({ key, a, b }) + } + }) + }) + + const boundarySegments = [] + edgeList.forEach(({ key, a, b }) => { + if (edgeCount.get(key) === 1) { + boundarySegments.push([a, b]) + } + }) + + return stitchBoundarySegments(boundarySegments) +} + +const getWireframePaths = () => { + const mapName = getMapName(currentMode.value) + const cacheKey = `${mapName}-${chinaMapData?.features?.length || 0}` + if (cacheKey === cachedOutlineKey) { + return cachedOutlinePaths } - - // 针对不同模式进行微调 - if (currentMode.value === 'outflow' || currentMode.value === 'inflow') { - zoom = Math.min(zoom * 1.1, 2.8) // 销售和供应模式稍微放大一些 - } else { - zoom = Math.min(zoom * 1.2, 3.0) // 本地模式更大放大 + + const minPoints = currentMode.value === 'local' ? 8 : 30 + cachedOutlineKey = cacheKey + cachedOutlinePaths = buildNationalOutlinePaths(chinaMapData) + .filter((path) => path.length >= minPoints) + + return cachedOutlinePaths +} + +const buildRegionFillData = () => { + if (!chinaMapData?.features?.length) { + return [] } - + + let minLng = MAP_GRADIENT_MIN_LNG + let maxLng = MAP_GRADIENT_MAX_LNG + let minLat = MAP_GRADIENT_MIN_LAT + let maxLat = MAP_GRADIENT_MAX_LAT + + if (currentMode.value === 'local') { + const centers = chinaMapData.features.map(getFeatureCenter) + const lngs = centers.map((center) => center[0]) + const lats = centers.map((center) => center[1]) + minLng = Math.min(...lngs) + maxLng = Math.max(...lngs) + minLat = Math.min(...lats) + maxLat = Math.max(...lats) + if (maxLng - minLng < 0.001) { + maxLng = minLng + 1 + } + if (maxLat - minLat < 0.001) { + maxLat = minLat + 1 + } + } + + return chinaMapData.features.map((feature) => { + const name = feature.properties?.name + if (!name) { + return null + } + const [lng, lat] = getFeatureCenter(feature) + return { + name, + itemStyle: { + areaColor: getRegionAreaColor(lng, lat, minLng, maxLng, minLat, maxLat), + borderColor: MAP_INNER_BORDER, + borderWidth: MAP_INNER_BORDER_WIDTH + } + } + }).filter(Boolean) +} + +const getMapProjection = (mapBounds, latOffset = 0, lngOffset = 0) => { + const isLocal = currentMode.value === 'local' + if (isLocal) { + return { + aspectScale: MAP_ASPECT_SCALE, + layoutCenter: [ + `${50 + lngOffset + MAP_GLOBAL_LAYOUT_X}%`, + `${50 + latOffset + MAP_GLOBAL_LAYOUT_Y}%` + ], + layoutSize: MAP_LAYOUT_SIZE + } + } + const center = mapBounds.center || MAP_ANCHOR_CENTER + const zoom = mapBounds.zoom || MAP_ANCHOR_ZOOM return { - center: [centerLng, centerLat], - zoom: zoom + aspectScale: MAP_ASPECT_SCALE, + zoom, + center: [ + center[0] + lngOffset + MAP_GLOBAL_OFFSET_LNG, + center[1] + latOffset + MAP_GLOBAL_OFFSET_LAT + ] } } +const buildGeoLayers = (mapName, mapBounds) => { + const isLocal = currentMode.value === 'local' + const shadowFarOffset = isLocal ? MAP_SHADOW_OFFSET_FAR_LOCAL : MAP_SHADOW_OFFSET_FAR + const shadowNearOffset = isLocal ? MAP_SHADOW_OFFSET_NEAR_LOCAL : MAP_SHADOW_OFFSET_NEAR + const shadowLngOffset = isLocal ? MAP_SHADOW_OFFSET_LNG_LOCAL : MAP_SHADOW_OFFSET_LNG + const wireframeOffset = isLocal ? MAP_WIREFRAME_OFFSET_LOCAL : MAP_WIREFRAME_OFFSET + const wireframeLngOffset = isLocal ? MAP_WIREFRAME_OFFSET_LNG_LOCAL : MAP_WIREFRAME_OFFSET_LNG + + return [ + { + map: mapName, + roam: false, + ...getMapProjection(mapBounds, shadowFarOffset, shadowLngOffset), + silent: true, + zlevel: 0, + z: 1, + label: { show: false }, + itemStyle: { + areaColor: MAP_SHADOW_FAR, + borderColor: MAP_SHADOW_FAR, + borderWidth: 0 + } + }, + { + map: mapName, + roam: false, + ...getMapProjection(mapBounds, shadowNearOffset, shadowLngOffset), + silent: true, + zlevel: 0, + z: 2, + label: { show: false }, + itemStyle: { + areaColor: MAP_SHADOW_NEAR, + borderColor: MAP_SHADOW_NEAR, + borderWidth: 0 + } + }, + { + map: mapName, + roam: false, + ...getMapProjection(mapBounds, 0), + silent: true, + zlevel: 1, + z: 1, + label: { show: false }, + itemStyle: { + areaColor: 'rgba(0, 0, 0, 0)', + borderColor: 'transparent', + borderWidth: 0 + }, + emphasis: { + disabled: true + } + }, + { + map: mapName, + roam: false, + ...getMapProjection(mapBounds, wireframeOffset, wireframeLngOffset), + silent: true, + zlevel: 4, + z: 1, + label: { show: false }, + itemStyle: { + areaColor: 'rgba(0, 0, 0, 0)', + borderColor: 'transparent', + borderWidth: 0 + }, + emphasis: { + disabled: true + } + } + ] +} + // 初始化图表配置 const getChartOption = () => { const modeData = getCurrentModeData() @@ -592,6 +1043,10 @@ const getChartOption = () => { const linesData = generateLinesData() const effectScatterData = generateEffectScatterData() const mapBounds = calculateMapBounds() + const mapName = getMapName(currentMode.value) + const mapLayout = getMapProjection(mapBounds, 0) + const outlinePaths = getWireframePaths() + const wireframeData = outlinePaths.map((coords) => ({ coords })) return { backgroundColor: 'transparent', // 透明背景,与卡片一致 @@ -626,7 +1081,8 @@ const getChartOption = () => {
` } else if (params.seriesType === 'scatter') { const value = params.value[2] || 0 - const nodeType = params.name === '红原县' ? '中心节点' : + const hubName = getHubName() + const nodeType = params.name === hubName ? '中心节点' : (currentMode.value === 'outflow' ? '销售市场' : '供应来源') return `
${params.name}
@@ -654,80 +1110,77 @@ const getChartOption = () => { return params.name } }, - legend: { - orient: 'vertical', - left: 'left', - top: 'bottom', - data: ['交易城市', '流向线', '重要节点'], - textStyle: { - color: '#8cc8ff', - fontSize: 12 - }, - itemStyle: { - borderColor: '#00d4ff' - } - }, - geo: { - map: getMapName(currentMode.value), - roam: true, - // 只有在非local模式时才手动设置zoom和center,让ECharts自动适应GeoJSON范围 - ...(currentMode.value !== 'local' && { - zoom: mapBounds.zoom, - center: mapBounds.center - }), - label: { - show: true, - color: '#ffffff', - fontSize: 11, - fontWeight: 'bold', - shadowBlur: 3, - shadowColor: '#000' - }, - emphasis: { + geo: buildGeoLayers(mapName, mapBounds), + series: [ + { + name: 'mapFill', + type: 'map', + map: mapName, + roam: false, + ...mapLayout, + zlevel: 2, + silent: true, + selectedMode: false, + data: buildRegionFillData(), + itemStyle: { + borderColor: MAP_INNER_BORDER, + borderWidth: MAP_INNER_BORDER_WIDTH + }, label: { show: true, - color: '#00d4ff', - fontSize: 13, - fontWeight: 'bold' + color: MAP_LABEL_COLOR, + fontSize: 11, + fontFamily: 'Microsoft YaHei, sans-serif', + fontWeight: 'bold', + textBorderColor: 'rgba(0, 0, 0, 0.45)', + textBorderWidth: 2 }, - itemStyle: { - areaColor: '#1e3a5f', - borderColor: '#00d4ff', - borderWidth: 2, - shadowBlur: 10, - shadowColor: '#00d4ff' + emphasis: { + disabled: true + } + }, + { + name: 'mapWireframe', + type: 'lines', + coordinateSystem: 'geo', + geoIndex: WIREFRAME_GEO_INDEX, + zlevel: 5, + polyline: true, + silent: true, + data: wireframeData, + lineStyle: { + color: MAP_WIREFRAME_COLOR, + width: 3.2, + opacity: 0.96, + cap: 'round', + join: 'round', + shadowBlur: 16, + shadowColor: 'rgba(120, 230, 255, 0.6)' } }, - itemStyle: { - areaColor: '#0f1b2e', // 深蓝色地图 - borderColor: '#2c5282', - borderWidth: 1, - shadowBlur: 5, - shadowColor: 'rgba(0, 212, 255, 0.3)' - } - }, - series: [ { name: '交易城市', type: 'scatter', coordinateSystem: 'geo', + geoIndex: GEO_DATA_INDEX, + zlevel: 6, data: scatterData, symbolSize: 8, label: { show: true, position: 'top', - color: '#ffffff', + color: MAP_LABEL_COLOR, fontSize: 11, fontWeight: 'bold', formatter: '{b}', - shadowBlur: 2, - shadowColor: '#000' + textBorderColor: 'rgba(0, 0, 0, 0.35)', + textBorderWidth: 2 }, emphasis: { label: { show: true, fontSize: 13, - color: '#00d4ff' + color: MAP_LABEL_COLOR }, itemStyle: { borderColor: '#00d4ff', @@ -737,28 +1190,30 @@ const getChartOption = () => { } }, { - name: '流向线', + name: '流向', type: 'lines', coordinateSystem: 'geo', + geoIndex: GEO_DATA_INDEX, + zlevel: 6, data: linesData, large: true, effect: { show: true, - constantSpeed: 40, + constantSpeed: 45, symbol: 'arrow', - symbolSize: 12, - trailLength: 0.15, - color: '#ffffff', - shadowBlur: 6, - shadowColor: '#00d4ff' + symbolSize: 10, + trailLength: 0.2, + color: '#e8f7ff', + shadowBlur: 8, + shadowColor: 'rgba(110, 207, 255, 0.8)' }, lineStyle: { - color: '#00d4ff', - width: 1.2, - opacity: 0.7, - curveness: 0.3, - shadowBlur: 3, - shadowColor: '#00d4ff' + color: '#7ee8ff', + width: 1.5, + opacity: 0.85, + curveness: 0.28, + shadowBlur: 8, + shadowColor: 'rgba(110, 232, 255, 0.55)' }, emphasis: { lineStyle: { @@ -772,6 +1227,8 @@ const getChartOption = () => { name: '重要节点', type: 'effectScatter', coordinateSystem: 'geo', + geoIndex: GEO_DATA_INDEX, + zlevel: 6, data: effectScatterData, symbolSize: function(val) { return Math.max(14, Math.min(26, val[2] / 60)) @@ -785,12 +1242,12 @@ const getChartOption = () => { label: { show: true, position: 'top', - color: '#ffffff', + color: MAP_LABEL_COLOR, fontSize: 12, fontWeight: 'bold', formatter: '{b}', - shadowBlur: 3, - shadowColor: '#000' + textBorderColor: 'rgba(0, 0, 0, 0.35)', + textBorderWidth: 2 }, itemStyle: { shadowBlur: 15 @@ -819,6 +1276,9 @@ const reloadMapData = async (mapMode) => { const mapName = getMapName(mapMode) echarts.registerMap(mapName, chinaMapData) + cachedOutlineKey = '' + cachedOutlinePaths = [] + mapTextureCache.clear() return true } catch (error) { @@ -872,11 +1332,8 @@ const updateChart = async (needReloadMap = false) => { } chartInstance.setOption(getChartOption(), { - replaceMerge: ['series', 'geo'], - transition: { - duration: 1200, - easing: 'cubicInOut' - } + notMerge: false, + replaceMerge: ['geo', 'series'] }) } } @@ -911,64 +1368,184 @@ onUnmounted(() => { \ No newline at end of file + diff --git a/src/components/MarketRealtimeMonitor.vue b/src/components/MarketRealtimeMonitor.vue index 692c3bc..45c9970 100644 --- a/src/components/MarketRealtimeMonitor.vue +++ b/src/components/MarketRealtimeMonitor.vue @@ -1,75 +1,124 @@ @@ -77,112 +126,110 @@ \ No newline at end of file + diff --git a/src/components/MonitorLivePlayer.vue b/src/components/MonitorLivePlayer.vue new file mode 100644 index 0000000..c7d8663 --- /dev/null +++ b/src/components/MonitorLivePlayer.vue @@ -0,0 +1,76 @@ + + + + + diff --git a/src/components/PurchaserAnalysis.vue b/src/components/PurchaserAnalysis.vue index b0e6528..1f6f95b 100644 --- a/src/components/PurchaserAnalysis.vue +++ b/src/components/PurchaserAnalysis.vue @@ -1,6 +1,18 @@ @@ -8,216 +20,344 @@ import { ref, onMounted, onUnmounted } from 'vue' import * as echarts from 'echarts' import BaseCard from './BaseCard.vue' +import { bindChartBaseSync, syncChartBaseToGrid } from '../utils/chartBaseLayout.js' + +const API_URL = '/api/dashboard/buyer-source-analysis' + +const CHART_GRID = { + left: 48, + right: 24, + bottom: 56, + top: 18 +} + +const BAR_WIDTH = 13 +const CAP_RY = 3.5 +const MIN_BAR_PX = 8 + +const CYLINDER_PALETTES = [ + { + edge: 'rgba(44, 162, 163, 0.60)', + center: 'rgba(2, 206, 214, 0.80)', + capCenter: 'rgba(2, 206, 214, 0.95)', + capEdge: 'rgba(44, 162, 163, 0.70)', + bottom: 'rgba(44, 162, 163, 0.42)', + zeroCenter: 'rgba(2, 206, 214, 0.45)' + }, + { + edge: 'rgba(44, 162, 163, 0.60)', + center: 'rgba(25, 217, 170, 0.80)', + capCenter: 'rgba(25, 217, 170, 0.95)', + capEdge: 'rgba(44, 162, 163, 0.70)', + bottom: 'rgba(44, 162, 163, 0.42)', + zeroCenter: 'rgba(25, 217, 170, 0.45)' + } +] + +const ZERO_EDGE = 'rgba(44, 162, 163, 0.35)' +const ZERO_BOTTOM = 'rgba(44, 162, 163, 0.22)' const chartRef = ref(null) +const baseRef = ref(null) +const regionsData = ref([]) let chartInstance = null +let tipTimer = null +let tipIndex = 0 +let unbindChartBaseSync = null + +const calcAxisMax = (values, ratio = 1.25) => { + const maxVal = Math.max(0, ...values.map((v) => Number(v) || 0)) + if (maxVal === 0) { + return 5 + } + + const raw = maxVal * ratio + const magnitude = Math.pow(10, Math.floor(Math.log10(raw))) + const normalized = raw / magnitude + let nice = 10 + + if (normalized <= 1) nice = 1 + else if (normalized <= 2) nice = 2 + else if (normalized <= 5) nice = 5 -// 采购商户来源数据 - 基于销售网络分布图的目标区域 -const regionsData = ref([ - { name: '果洛藏族自治州', value: 156, color: '#409EFF', description: '青海果洛州采购商户' }, - { name: '成都', value: 142, color: '#67C23A', description: '四川省会城市采购商户' }, - { name: '玉树藏族自治州', value: 128, color: '#E6A23C', description: '青海玉树州采购商户' }, - { name: '甘孜藏族自治州', value: 115, color: '#F56C6C', description: '四川甘孜州采购商户' }, - { name: '拉萨', value: 98, color: '#909399', description: '西藏自治区采购商户' }, - { name: '西宁', value: 89, color: '#00D4AA', description: '青海省会城市采购商户' }, - { name: '兰州', value: 76, color: '#8B5CF6', description: '甘肃省会城市采购商户' }, - { name: '甘南藏族自治州', value: 68, color: '#FF6B9D', description: '甘肃甘南州采购商户' }, - { name: '昌都', value: 54, color: '#FFB800', description: '西藏昌都地区采购商户' } -]) - -// 数据更新函数 -const updateData = () => { - regionsData.value.forEach(region => { - // 小幅度随机调整数据 - const variance = (Math.random() - 0.5) * 20 // ±10的变化 - region.value = Math.max(20, Math.floor(region.value + variance)) - }) + return nice * magnitude } -const initChart = () => { - if (!chartRef.value) return - - chartInstance = echarts.init(chartRef.value) - - const updateChart = () => { - const option = { - tooltip: { - trigger: 'axis', - backgroundColor: 'rgba(15, 25, 45, 0.95)', - borderColor: '#00d4ff', - borderWidth: 1, - textStyle: { - color: '#fff', - fontSize: 15 - }, - formatter: function(params) { - const item = regionsData.value[params[0].dataIndex] - return `
- ${params[0].name}
- 采购商户: ${params[0].value} 家
- ${item.description} -
` - } - }, - grid: { - left: '8%', - right: '8%', - bottom: '20%', - top: '15%', - containLabel: true +const getZeroDisplayValue = (axisMax) => axisMax * 0.04 + +const createCylinderBarRenderer = (axisMax) => (params, api) => { + const categoryIndex = params.dataIndex + const rawValue = Number(api.value(1)) || 0 + const displayValue = rawValue > 0 ? rawValue : getZeroDisplayValue(axisMax) + const isZero = rawValue === 0 + + const base = api.coord([categoryIndex, 0]) + const top = api.coord([categoryIndex, displayValue]) + const cx = base[0] + const topY = top[1] + const bodyTop = topY + CAP_RY + const bodyHeight = Math.max(base[1] - bodyTop, MIN_BAR_PX) + const x = cx - BAR_WIDTH / 2 + const palette = CYLINDER_PALETTES[categoryIndex % 2] + + const bodyGradient = new echarts.graphic.LinearGradient(0, 0, 1, 0, [ + { offset: 0, color: isZero ? ZERO_EDGE : palette.edge }, + { offset: 0.5, color: isZero ? palette.zeroCenter : palette.center }, + { offset: 1, color: isZero ? ZERO_EDGE : palette.edge } + ]) + + const capGradient = new echarts.graphic.LinearGradient(0, 0, 1, 0, [ + { offset: 0, color: isZero ? ZERO_EDGE : palette.capEdge }, + { offset: 0.5, color: isZero ? palette.zeroCenter : palette.capCenter }, + { offset: 1, color: isZero ? ZERO_EDGE : palette.capEdge } + ]) + + const bottomCap = { + type: 'ellipse', + shape: { + cx, + cy: base[1], + rx: BAR_WIDTH / 2, + ry: CAP_RY * 0.55 + }, + style: { + fill: isZero ? ZERO_BOTTOM : palette.bottom + } + } + + const body = { + type: 'rect', + shape: { + x, + y: bodyTop, + width: BAR_WIDTH, + height: bodyHeight, + r: [0, 0, 0, 0] + }, + style: { + fill: bodyGradient + } + } + + const topCap = { + type: 'ellipse', + shape: { + cx, + cy: bodyTop, + rx: BAR_WIDTH / 2, + ry: CAP_RY + }, + style: { + fill: capGradient + } + } + + return { + type: 'group', + children: [bottomCap, body, topCap] + } +} + +const buildChartOption = (axisMax) => { + const labels = regionsData.value.map((item) => item.name) + const values = regionsData.value.map((item) => item.value) + + return { + animation: true, + animationDuration: 800, + animationEasing: 'cubicOut', + tooltip: { + trigger: 'axis', + backgroundColor: 'rgba(6, 28, 58, 0.94)', + borderColor: '#41a6fc', + borderWidth: 1, + padding: [10, 14], + textStyle: { + color: '#ffffff', + fontSize: 13, + fontFamily: 'Microsoft YaHei, sans-serif' }, - xAxis: { - type: 'category', - data: regionsData.value.map(item => item.name), - axisLine: { - lineStyle: { - color: '#4a5568' - } - }, - axisTick: { - show: false - }, - axisLabel: { - color: '#a0a8b8', - fontSize: 12, - rotate: 30, - interval: 0, - formatter: function(value) { - // 对长地名进行换行处理 - if (value.length > 6) { - return value.substring(0, 4) + '\n' + value.substring(4) - } + extraCssText: 'box-shadow: 0 0 14px rgba(65, 166, 252, 0.35); border-radius: 6px;', + formatter(params) { + const idx = params[0]?.dataIndex ?? 0 + const item = regionsData.value[idx] + if (!item) { + return '' + } + return [ + `
${item.name}
`, + `
采购商户:${item.value}家
`, + `
${item.description}
` + ].join('') + } + }, + grid: CHART_GRID, + xAxis: { + type: 'category', + data: labels, + boundaryGap: true, + axisLine: { show: false }, + axisTick: { show: false }, + axisLabel: { + color: '#ffffff', + fontSize: 11, + fontFamily: 'Microsoft YaHei, sans-serif', + interval: 0, + margin: 10, + formatter(value) { + if (value.length <= 5) { return value } + return `${value.slice(0, 5)}\n${value.slice(5)}` } - }, - yAxis: { - type: 'value', - name: '采购商户数量(家)', - nameTextStyle: { - color: '#a0a8b8', - fontSize: 13 - }, - splitLine: { - lineStyle: { - color: '#2d3748', - type: 'dashed' - } - }, - axisLine: { - show: false - }, - axisTick: { - show: false - }, - axisLabel: { - color: '#a0a8b8', - fontSize: 13 + } + }, + yAxis: { + type: 'value', + min: 0, + max: axisMax, + splitNumber: 5, + splitLine: { + lineStyle: { + color: 'rgba(80, 160, 220, 0.22)', + type: 'dashed' } }, - series: [{ - name: '采购商户数量', - type: 'bar', - data: regionsData.value.map((item, index) => ({ - value: item.value, - itemStyle: { - color: { - type: 'linear', - x: 0, - y: 0, - x2: 0, - y2: 1, - colorStops: [{ - offset: 0, - color: item.color - }, { - offset: 1, - color: item.color + '40' // 添加透明度 - }] - }, - borderRadius: [6, 6, 0, 0], - borderWidth: 2, - borderColor: item.color, - shadowBlur: 8, - shadowColor: item.color + '30' - } - })), - barWidth: '60%', - emphasis: { - itemStyle: { - shadowBlur: 15, - shadowColor: 'rgba(0, 212, 255, 0.6)', - borderColor: '#00d4ff', - borderWidth: 3 - }, - scaleSize: 5 - }, - label: { - show: true, - position: 'top', - color: '#fff', - fontSize: 13, - fontWeight: 'bold', - formatter: '{c}家' - }, - animationDelay: function (idx) { - return idx * 100 - } - }], - animationEasing: 'elasticOut', - animationDelayUpdate: function (idx) { - return idx * 50 + axisLine: { show: false }, + axisTick: { show: false }, + axisLabel: { + color: '#ffffff', + fontSize: 12, + fontFamily: 'Microsoft YaHei, sans-serif' } - } - chartInstance.setOption(option) + }, + series: [{ + name: '采购商户数量', + type: 'custom', + renderItem: createCylinderBarRenderer(axisMax), + data: values.map((value, index) => [index, value]), + encode: { x: 0, y: 1 }, + z: 2 + }] } +} - updateChart() +const updateChart = () => { + if (!chartInstance || !regionsData.value.length) { + return + } - // 添加动态效果 - let currentIndex = 0 - const timer = setInterval(() => { + const values = regionsData.value.map((item) => item.value) + const axisMax = calcAxisMax(values, 1.3) + chartInstance.setOption(buildChartOption(axisMax), true) + syncChartBaseToGrid(chartInstance, baseRef.value) +} + +const startTipCarousel = () => { + stopTipCarousel() + if (!chartInstance || regionsData.value.length === 0) { + return + } + + tipTimer = setInterval(() => { + chartInstance.dispatchAction({ type: 'hideTip' }) chartInstance.dispatchAction({ type: 'showTip', seriesIndex: 0, - dataIndex: currentIndex + dataIndex: tipIndex }) - currentIndex = (currentIndex + 1) % regionsData.value.length + tipIndex = (tipIndex + 1) % regionsData.value.length }, 3000) +} + +const stopTipCarousel = () => { + if (tipTimer) { + clearInterval(tipTimer) + tipTimer = null + } +} + +const loadData = async () => { + try { + const response = await fetch(API_URL) + if (!response.ok) { + throw new Error(`HTTP ${response.status}`) + } + + const result = await response.json() + if (result.code !== 1 || !result.data) { + throw new Error(result.message || '接口返回异常') + } - // 定期更新数据 - const dataTimer = setInterval(() => { - updateData() + regionsData.value = (result.data || []).map((item) => ({ + name: item.name, + value: Number(item.value) || 0, + fullName: item.fullName || '', + description: item.description || `${item.name}采购商户` + })) + tipIndex = 0 updateChart() - }, 12000) // 12秒更新一次 + startTipCarousel() + } catch (error) { + console.error('加载采购商户来源分析失败:', error) + } +} - chartInstance._autoTimer = timer - chartInstance._dataTimer = dataTimer +const handleResize = () => { + chartInstance?.resize() + syncChartBaseToGrid(chartInstance, baseRef.value) } -onMounted(() => { - initChart() - window.addEventListener('resize', () => { - chartInstance?.resize() - }) +onMounted(async () => { + if (chartRef.value) { + chartInstance = echarts.init(chartRef.value) + unbindChartBaseSync = bindChartBaseSync(chartInstance, baseRef.value) + } + await loadData() + window.addEventListener('resize', handleResize) + window.addEventListener('dataReloaded', loadData) }) onUnmounted(() => { - if (chartInstance) { - if (chartInstance._autoTimer) { - clearInterval(chartInstance._autoTimer) - } - if (chartInstance._dataTimer) { - clearInterval(chartInstance._dataTimer) - } - chartInstance.dispose() - } + stopTipCarousel() + window.removeEventListener('resize', handleResize) + window.removeEventListener('dataReloaded', loadData) + unbindChartBaseSync?.() + chartInstance?.dispose() }) \ No newline at end of file + diff --git a/src/components/SupplyDemandData.vue b/src/components/SupplyDemandData.vue index f6d8ff9..ef19684 100644 --- a/src/components/SupplyDemandData.vue +++ b/src/components/SupplyDemandData.vue @@ -1,88 +1,100 @@ - - \ No newline at end of file + diff --git a/src/components/YakSalesTypeStats.vue b/src/components/YakSalesTypeStats.vue index d042d06..e58048f 100644 --- a/src/components/YakSalesTypeStats.vue +++ b/src/components/YakSalesTypeStats.vue @@ -5,14 +5,12 @@
-
+
-
- {{ item.name }} -
- {{ item.count }}头 - {{ item.value }}% -
+
+ {{ item.name }}: + {{ formatPercent(item.percent) }}% + {{ item.value }}头
@@ -21,276 +19,201 @@ \ No newline at end of file + diff --git a/src/utils/liveStreamPlayer.js b/src/utils/liveStreamPlayer.js new file mode 100644 index 0000000..8d5c05b --- /dev/null +++ b/src/utils/liveStreamPlayer.js @@ -0,0 +1,123 @@ +import Hls from 'hls.js' +import flvjs from 'flv.js' + +const DEFAULT_FORMAT_RULES = [ + { type: 'hls', match: '.m3u8', library: 'hls.js' }, + { type: 'flv', match: '.flv', library: 'flv.js' }, + { type: 'mp4', match: '.mp4', library: 'native' }, + { type: 'webm', match: '.webm', library: 'native' } +] + +export function getFormatRules(playerConfig) { + const rules = playerConfig?.formatRules + return Array.isArray(rules) && rules.length ? rules : DEFAULT_FORMAT_RULES +} + +export function detectStreamType(url, playerConfig) { + if (!url) { + return 'native' + } + const lower = ('' + url).toLowerCase() + const rules = getFormatRules(playerConfig) + for (const rule of rules) { + const match = rule.match ? ('' + rule.match).toLowerCase() : '' + if (match && lower.includes(match)) { + return rule.type || 'native' + } + } + return 'native' +} + +export function resolveStreamUrl(camera, playerConfig) { + if (!camera) { + return '' + } + const priority = playerConfig?.urlPriority || ['hdStreamUrl', 'streamUrl', 'playUrl', 'preview'] + for (const key of priority) { + const value = camera[key] + if (value) { + return value + } + } + return '' +} + +export function createLivePlayer(videoEl, url, playerConfig = {}) { + if (!videoEl || !url) { + return null + } + + const streamType = detectStreamType(url, playerConfig) + const options = playerConfig?.playerOptions || {} + + if (streamType === 'hls') { + if (videoEl.canPlayType('application/vnd.apple.mpegurl')) { + videoEl.src = url + videoEl.play().catch(() => {}) + return { type: 'native-hls', videoEl } + } + if (Hls.isSupported()) { + const hls = new Hls({ + enableWorker: true, + lowLatencyMode: true, + ...(options.hls || {}) + }) + hls.loadSource(url) + hls.attachMedia(videoEl) + hls.on(Hls.Events.MANIFEST_PARSED, () => { + videoEl.play().catch(() => {}) + }) + hls.on(Hls.Events.ERROR, (_, data) => { + if (data?.fatal) { + console.error('HLS 播放失败:', data) + } + }) + return { type: 'hls', instance: hls, videoEl } + } + } + + if (streamType === 'flv' && flvjs.isSupported()) { + const flvPlayer = flvjs.createPlayer( + { + type: 'flv', + url, + isLive: true, + hasAudio: true, + hasVideo: true, + ...(options.flv || {}) + }, + { + enableWorker: true, + enableStashBuffer: false, + ...(options.flvMedia || {}) + } + ) + flvPlayer.attachMediaElement(videoEl) + flvPlayer.load() + flvPlayer.play().catch(() => {}) + return { type: 'flv', instance: flvPlayer, videoEl } + } + + videoEl.src = url + videoEl.play().catch(() => {}) + return { type: 'native', videoEl } +} + +export function destroyLivePlayer(player) { + if (!player) { + return + } + if (player.type === 'hls' && player.instance) { + player.instance.destroy() + } else if (player.type === 'flv' && player.instance) { + player.instance.pause() + player.instance.unload() + player.instance.detachMediaElement() + player.instance.destroy() + } + if (player.videoEl) { + player.videoEl.pause() + player.videoEl.removeAttribute('src') + player.videoEl.load() + } +} diff --git a/src/utils/pie3d.js b/src/utils/pie3d.js index 0cf18e9..62dd882 100644 --- a/src/utils/pie3d.js +++ b/src/utils/pie3d.js @@ -73,37 +73,171 @@ export const getParametricEquation = ( const PLACEHOLDER_SLICE_COUNT = 5 const PLACEHOLDER_SLICE_HEIGHT = 0.1 -const LABEL_LAYOUT = { +const DEFAULT_LABEL_LAYOUT = { centerX: 50, - centerY: 37, - radiusX: 29, - radiusY: 21, - radialLen: 7, - horizontalLen: 10 + centerY: 40, + projectScale: 33, + yCompress: 0.66, + startAngle: 0, + radialLen: 9, + horizontalLen: 11, + lineColor: 'rgba(142, 207, 255, 0.55)', + textColor: '#d8ecff', + percentColor: '#ffffff', + fontSize: 13 } -const buildLabelGraphics = (sortedData, sumValue) => { +const buildOverlayLabelSeries = (pieData, overlayConfig = {}) => { + const { + center = ['38%', '41%'], + radius = ['24%', '44%'], + startAngle = 48, + clockwise = false, + nameColor = '#41a6fc', + percentColor = '#ffffff', + lineColor = 'rgba(65, 166, 252, 0.55)', + fontSize = 13, + labelLineLength = 10, + labelLineLength2 = 12 + } = overlayConfig + + return { + name: 'pie-label-overlay', + type: 'pie', + radius, + center, + startAngle, + clockwise, + silent: true, + animation: false, + z: 10, + zlevel: 2, + itemStyle: { + color: 'transparent', + borderWidth: 0, + opacity: 0 + }, + emphasis: { + disabled: true + }, + labelLayout: { + hideOverlap: false + }, + labelLine: { + show: true, + length: labelLineLength, + length2: labelLineLength2, + smooth: false, + lineStyle: { + color: lineColor, + width: 1 + } + }, + label: { + show: true, + alignTo: 'edge', + edgeDistance: 4, + formatter: (params) => { + const percent = params.data?.percent ?? params.percent ?? 0 + return `{name|${params.name}}\n{percent|${percent}%}` + }, + rich: { + name: { + color: nameColor, + fontSize, + fontWeight: 500, + lineHeight: 18, + fontFamily: 'Microsoft YaHei' + }, + percent: { + color: percentColor, + fontSize, + fontWeight: 700, + lineHeight: 18, + fontFamily: 'Microsoft YaHei' + } + } + }, + data: pieData.map((item) => ({ + name: item.name, + value: item.value, + percent: item.percent, + labelLine: Number(item.percent) < 5 + ? { + length: 6, + length2: 22, + lineStyle: { color: lineColor, width: 1 } + } + : undefined + })) + } +} + +const projectSlicePoint = (midRatio, layout, view) => { + const { + centerX, + centerY, + projectScale, + yCompress, + startAngle + } = layout + const viewAlpha = view.viewAlpha ?? 28 + const viewBeta = view.viewBeta ?? 48 + + const midRadian = startAngle + midRatio * Math.PI * 2 + const x = Math.cos(midRadian) + const y = Math.sin(midRadian) + + const betaRad = (viewBeta * Math.PI) / 180 + const alphaRad = (viewAlpha * Math.PI) / 180 + + const xb = x * Math.cos(betaRad) - y * Math.sin(betaRad) + const yb = x * Math.sin(betaRad) + y * Math.cos(betaRad) + const yf = yb * Math.cos(alphaRad) + const xf = xb + + return { + anchorX: centerX + xf * projectScale, + anchorY: centerY - yf * projectScale * yCompress, + dirX: xf, + dirY: -yf + } +} + +const buildLabelGraphics = (sortedData, sumValue, labelOptions = {}) => { if (!sortedData.length || !sumValue) { return [] } - const { centerX, centerY, radiusX, radiusY, radialLen, horizontalLen } = LABEL_LAYOUT + const layout = { + ...DEFAULT_LABEL_LAYOUT, + ...(labelOptions.layout || {}) + } + const view = labelOptions.view || {} + const textColor = labelOptions.textColor || layout.textColor + const percentColor = labelOptions.percentColor || layout.percentColor + const lineColor = labelOptions.lineColor || layout.lineColor + const fontSize = labelOptions.fontSize || layout.fontSize + let startValue = 0 const graphics = [] sortedData.forEach((item) => { const endValue = startValue + item.value const midRatio = (startValue + endValue) / 2 / sumValue - const midRadian = midRatio * Math.PI * 2 - const cos = Math.cos(midRadian) - const sin = Math.sin(midRadian) - const onRight = cos >= 0 - - const anchorX = centerX + cos * radiusX - const anchorY = centerY - sin * radiusY - const radialX = cos * radialLen * 3.2 - const radialY = -sin * radialLen * 2.4 - const endX = radialX + (onRight ? horizontalLen * 3.6 : -horizontalLen * 3.6) + const sliceRatio = (endValue - startValue) / sumValue + const { anchorX, anchorY, dirX, dirY } = projectSlicePoint(midRatio, layout, view) + const onRight = dirX >= 0 + const dirLen = Math.hypot(dirX, dirY) || 1 + const normX = dirX / dirLen + const normY = dirY / dirLen + const radialBoost = sliceRatio < 0.05 ? 6 : 0 + const horizontalBoost = sliceRatio < 0.05 ? 8 : 0 + const { radialLen, horizontalLen } = layout + + const radialX = normX * (radialLen + radialBoost) + const radialY = normY * (radialLen + radialBoost) + const endX = radialX + (onRight ? horizontalLen + horizontalBoost : -(horizontalLen + horizontalBoost)) const endY = radialY graphics.push({ @@ -122,23 +256,41 @@ const buildLabelGraphics = (sortedData, sumValue) => { ] }, style: { - stroke: 'rgba(142, 207, 255, 0.55)', + stroke: lineColor, lineWidth: 1, fill: null }, silent: true }, { - type: 'text', - x: endX + (onRight ? 5 : -5), + type: 'group', + x: endX + (onRight ? 6 : -6), y: endY, - style: { - text: `${item.name}\n${item.percent}%`, - fill: '#d8ecff', - font: '13px Microsoft YaHei', - textAlign: onRight ? 'left' : 'right', - textVerticalAlign: 'middle' - }, + children: [ + { + type: 'text', + style: { + text: item.name, + fill: textColor, + font: `${fontSize}px Microsoft YaHei`, + textAlign: onRight ? 'left' : 'right', + textVerticalAlign: 'bottom' + }, + silent: true + }, + { + type: 'text', + y: 16, + style: { + text: `${item.percent}%`, + fill: percentColor, + font: `700 ${fontSize}px Microsoft YaHei`, + textAlign: onRight ? 'left' : 'right', + textVerticalAlign: 'top' + }, + silent: true + } + ], silent: true } ], @@ -190,7 +342,16 @@ export const buildPie3DOption = ({ viewAlpha = 28, viewBeta = 48, viewDistance = 185, - placeholderMode = false + placeholderMode = false, + preserveOrder = false, + labelMode = 'graphic', + autoRotate = false, + autoRotateSpeed = 6, + labelColor, + labelPercentColor, + labelLayout, + labelLineColor, + labelOverlay }) => { const series = [] let sumValue = 0 @@ -204,7 +365,7 @@ export const buildPie3DOption = ({ return null } - const sortedData = placeholderMode + const sortedData = placeholderMode || preserveOrder ? validData : [...validData].sort((a, b) => b.value - a.value) @@ -283,7 +444,8 @@ export const buildPie3DOption = ({ rotateSensitivity: 0, zoomSensitivity: 0, panSensitivity: 0, - autoRotate: false + autoRotate, + autoRotateSpeed }, light: { main: { @@ -297,10 +459,28 @@ export const buildPie3DOption = ({ } } }, - series, + series: [ + ...series, + ...(labelMode === 'overlay' && !placeholderMode + ? [buildOverlayLabelSeries(sortedData, { + nameColor: labelColor, + percentColor: labelPercentColor, + lineColor: labelLineColor, + ...(labelOverlay || {}) + })] + : []) + ], graphic: placeholderMode ? buildCenterGraphic('暂无统计数据') - : buildLabelGraphics(sortedData, sumValue) + : labelMode === 'none' || labelMode === 'overlay' + ? [] + : buildLabelGraphics(sortedData, sumValue, { + textColor: labelColor, + percentColor: labelPercentColor, + lineColor: labelLineColor, + layout: labelLayout, + view: { viewAlpha, viewBeta, viewDistance } + }) } } diff --git a/src/utils/systemConfig.js b/src/utils/systemConfig.js index b13f11e..b04f39b 100644 --- a/src/utils/systemConfig.js +++ b/src/utils/systemConfig.js @@ -1,10 +1,30 @@ const API_URL = '/api/dashboard/system-config' +const DEFAULT_MAP_HUB = { + name: '红原县', + coordinates: [102.568685, 32.826358], + description: '四川省阿坝州红原县,中国重要的牦牛养殖基地', + localOriginKeyword: '红原' +} + +const DEFAULT_MAP_FLOW_LEVELS = { + high: { threshold: 80, color: '#E6A23C', description: '主要流向' }, + medium: { threshold: 40, color: '#409EFF', description: '重要流向' }, + low: { threshold: 0, color: '#67C23A', description: '一般流向' } +} + const DEFAULT_SYSTEM_CONFIG = { title: '/标题.png', - titleBackground: '/images/标题背景.png' + titleBackground: '/images/标题背景.png', + mapHub: DEFAULT_MAP_HUB, + mapFlowLevels: DEFAULT_MAP_FLOW_LEVELS, + mapGeoCoordMap: { + '红原县': [102.568685, 32.826358] + } } +export { DEFAULT_MAP_HUB, DEFAULT_MAP_FLOW_LEVELS } + export async function loadSystemConfig() { try { const response = await fetch(API_URL) diff --git a/src/views/Dashboard.vue b/src/views/Dashboard.vue index e8c2c03..b732557 100644 --- a/src/views/Dashboard.vue +++ b/src/views/Dashboard.vue @@ -54,7 +54,12 @@
- +