Update package.json and package-lock.json to include flv.js and hls.js dependencies; enhance Vite config with additional API proxy; refactor App.vue and ChinaMap.vue for improved component functionality and layout; optimize MarketEnvironmentMonitor and MarketRealtimeMonitor components for better user experience; update SupplyDemandData and PurchaserAnalysis components for enhanced data presentation.

main
Swanky 1 week ago
parent b0a75b58cd
commit 3bfa482e03
  1. 54
      package-lock.json
  2. 2
      package.json
  3. 54
      public/datas/market-cameras.json
  4. BIN
      public/images/图例bg.png
  5. BIN
      public/images/大气压强.png
  6. BIN
      public/images/已交易.png
  7. BIN
      public/images/待交易.png
  8. BIN
      public/images/总数量.png
  9. BIN
      public/images/成交订单.png
  10. BIN
      public/images/按钮.png
  11. BIN
      public/images/按钮选中.png
  12. BIN
      public/images/温度.png
  13. BIN
      public/images/湿度.png
  14. BIN
      public/images/空气质量.png
  15. BIN
      public/images/紫外线.png
  16. BIN
      public/images/降雨量.png
  17. BIN
      public/images/风力.png
  18. BIN
      public/images/风向.png
  19. 26
      src/App.vue
  20. 969
      src/components/ChinaMap.vue
  21. 601
      src/components/MarketEnvironmentMonitor.vue
  22. 849
      src/components/MarketRealtimeMonitor.vue
  23. 76
      src/components/MonitorLivePlayer.vue
  24. 494
      src/components/PurchaserAnalysis.vue
  25. 1776
      src/components/SupplyDemandData.vue
  26. 351
      src/components/YakSalesTypeStats.vue
  27. 123
      src/utils/liveStreamPlayer.js
  28. 246
      src/utils/pie3d.js
  29. 22
      src/utils/systemConfig.js
  30. 26
      src/views/Dashboard.vue
  31. 4
      vite.config.js

54
package-lock.json generated

@ -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",

@ -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"

@ -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"
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 878 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

@ -45,7 +45,12 @@
<div class="right-section">
<!-- 当供应信息展开时整个右侧区域显示供应信息 -->
<div v-if="isSupplyExpanded" class="expanded-supply-container">
<SupplyDemandData :force-expanded="true" @expand-change="handleSupplyExpand" />
<SupplyDemandData
:force-expanded="true"
:initial-detail-id="supplyDetailId"
@expand-change="handleSupplyExpand"
@initial-detail-applied="clearSupplyDetailId"
/>
</div>
<!-- 正常状态下的右侧布局 -->
@ -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 () => {

File diff suppressed because it is too large Load Diff

@ -1,21 +1,22 @@
<template>
<BaseCard title="市场环境监控">
<div class="monitor-content">
<div class="environment-grid">
<div v-for="item in environmentData" :key="item.id" class="env-item" :class="item.statusClass">
<div class="env-icon" :style="{ color: item.iconColor }">
{{ item.icon }}
<div v-if="loading" class="state-box">加载中...</div>
<div v-else class="environment-grid">
<div
v-for="item in environmentData"
:key="item.key"
class="env-card"
>
<div class="env-head">
<img class="env-icon" :src="item.icon" :alt="item.name" />
<span class="env-name">{{ item.name }}</span>
</div>
<div class="env-info">
<div class="env-name">{{ item.name }}</div>
<div class="env-value" :style="{ color: item.valueColor }">
{{ item.value }}
<span class="env-unit">{{ item.unit }}</span>
</div>
<div class="env-status" :class="item.statusClass">
{{ item.statusText }}
</div>
<div class="env-value-row">
<span class="env-value">{{ item.displayValue }}</span>
<span v-if="item.unit" class="env-unit">{{ item.unit }}</span>
</div>
<span class="env-status" :class="item.statusClass">{{ item.statusText }}</span>
</div>
</div>
</div>
@ -26,57 +27,119 @@
import { ref, computed, onMounted, onUnmounted } from 'vue'
import BaseCard from './BaseCard.vue'
//
const temperature = ref(23)
const humidity = ref(58)
const airQuality = ref(45)
const pressure = ref(1013)
const uvIndex = ref(3)
const rainfall = ref(0.5)
const windDirection = ref(135)
const windLevel = ref(2)
//
const getStatus = (value, type) => {
switch (type) {
case 'temperature':
if (value >= 15 && value <= 25) return { class: 'excellent', text: '适宜', color: '#67C23A' }
if (value < 15 || value > 25) return { class: 'warning', text: '偏冷/热', color: '#E6A23C' }
return { class: 'danger', text: '异常', color: '#F56C6C' }
case 'humidity':
if (value >= 40 && value <= 70) return { class: 'excellent', text: '舒适', color: '#67C23A' }
if (value < 40 || value > 70) return { class: 'warning', text: '干燥/潮湿', color: '#E6A23C' }
return { class: 'danger', text: '异常', color: '#F56C6C' }
case 'airQuality':
if (value <= 50) return { class: 'excellent', text: '优良', color: '#67C23A' }
if (value <= 100) return { class: 'good', text: '良好', color: '#409EFF' }
if (value <= 150) return { class: 'warning', text: '轻度污染', color: '#E6A23C' }
return { class: 'danger', text: '重度污染', color: '#F56C6C' }
case 'pressure':
if (value >= 1000 && value <= 1020) return { class: 'excellent', text: '正常', color: '#67C23A' }
return { class: 'warning', text: '异常', color: '#E6A23C' }
case 'uvIndex':
if (value <= 2) return { class: 'excellent', text: '低', color: '#67C23A' }
if (value <= 5) return { class: 'good', text: '中等', color: '#409EFF' }
if (value <= 7) return { class: 'warning', text: '高', color: '#E6A23C' }
return { class: 'danger', text: '极高', color: '#F56C6C' }
case 'rainfall':
if (value < 1) return { class: 'excellent', text: '无雨', color: '#67C23A' }
if (value < 5) return { class: 'good', text: '小雨', color: '#409EFF' }
if (value < 15) return { class: 'warning', text: '中雨', color: '#E6A23C' }
return { class: 'danger', text: '大雨', color: '#F56C6C' }
case 'windLevel':
if (value <= 3) return { class: 'excellent', text: '微风', color: '#67C23A' }
if (value <= 5) return { class: 'good', text: '和风', color: '#409EFF' }
if (value <= 7) return { class: 'warning', text: '强风', color: '#E6A23C' }
return { class: 'danger', text: '大风', color: '#F56C6C' }
default:
return { class: 'normal', text: '正常', color: '#409EFF' }
const API_URL = '/api/dashboard/market-environment'
const ICONS = {
temperature: '/images/温度.png',
humidity: '/images/湿度.png',
airQuality: '/images/空气质量.png',
pressure: '/images/大气压强.png',
uv: '/images/紫外线.png',
rainfall: '/images/降雨量.png',
windDirection: '/images/风向.png',
windForce: '/images/风力.png'
}
const metrics = ref({
temperature: null,
humidity: null,
pm25: null,
airPressure: null,
uvIndex: null,
rainfall: null,
windSpeed: null,
windDirection: null
})
const loading = ref(false)
const formatMetric = (value, digits = 1) => {
if (value === null || value === undefined || value === '') {
return null
}
const num = Number(value)
if (Number.isNaN(num)) {
return null
}
if (digits === 0) {
return Math.round(num)
}
return Number(num.toFixed(digits))
}
//
const getWindDirection = (degree) => {
const calcAqiFromPm25 = (pm25) => {
const cp = Number(pm25)
if (Number.isNaN(cp)) {
return null
}
const breaks = [
[0, 35, 0, 50],
[35, 75, 50, 100],
[75, 115, 100, 150],
[115, 150, 150, 200],
[150, 250, 200, 300],
[250, 350, 300, 400],
[350, 500, 400, 500]
]
for (const [clo, chi, ilo, ihi] of breaks) {
if (cp <= chi) {
return Math.round(((ihi - ilo) / (chi - clo)) * (cp - clo) + ilo)
}
}
return 500
}
// good #0CA34C | info #106DAE | warn #BF7412
const getTemperatureStatus = (value) => {
if (value === null) return { text: '--', statusClass: 'status-muted' }
if (value >= 15 && value <= 25) return { text: '适宜', statusClass: 'status-good' }
if (value > 25 && value <= 30) return { text: '偏热', statusClass: 'status-warn' }
if (value < 15 && value >= 5) return { text: '偏冷', statusClass: 'status-warn' }
return { text: '异常', statusClass: 'status-warn' }
}
const getHumidityStatus = (value) => {
if (value === null) return { text: '--', statusClass: 'status-muted' }
if (value >= 40 && value <= 70) return { text: '舒适', statusClass: 'status-good' }
if (value < 40) return { text: '干燥', statusClass: 'status-warn' }
return { text: '潮湿', statusClass: 'status-warn' }
}
const getAqiStatus = (aqi) => {
if (aqi === null) return { text: '--', statusClass: 'status-muted' }
if (aqi <= 50) return { text: '优', statusClass: 'status-good' }
if (aqi <= 100) return { text: '良', statusClass: 'status-good' }
if (aqi <= 150) return { text: '轻度污染', statusClass: 'status-warn' }
if (aqi <= 200) return { text: '中度污染', statusClass: 'status-warn' }
return { text: '重度污染', statusClass: 'status-warn' }
}
const getPressureStatus = (value) => {
if (value === null) return { text: '--', statusClass: 'status-muted' }
if (value >= 1000 && value <= 1025) return { text: '正常', statusClass: 'status-good' }
return { text: '异常', statusClass: 'status-warn' }
}
const getUvStatus = (value) => {
if (value === null) return { text: '--', statusClass: 'status-muted' }
if (value <= 2) return { text: '弱', statusClass: 'status-good' }
if (value <= 5) return { text: '中等', statusClass: 'status-info' }
if (value <= 7) return { text: '强', statusClass: 'status-warn' }
return { text: '很强', statusClass: 'status-warn' }
}
const getRainfallStatus = (value) => {
if (value === null) return { text: '--', statusClass: 'status-muted' }
if (value < 0.1) return { text: '无雨', statusClass: 'status-good' }
if (value < 5) return { text: '小雨', statusClass: 'status-info' }
if (value < 15) return { text: '中雨', statusClass: 'status-warn' }
return { text: '大雨', statusClass: 'status-warn' }
}
const getWindDirectionText = (degree) => {
if (degree === null) {
return '--'
}
const directions = [
{ min: 0, max: 22.5, name: '北风' },
{ min: 22.5, max: 67.5, name: '东北风' },
@ -88,286 +151,316 @@ const getWindDirection = (degree) => {
{ min: 292.5, max: 337.5, name: '西北风' },
{ min: 337.5, max: 360, name: '北风' }
]
const direction = directions.find(d => degree >= d.min && degree < d.max)
return direction ? direction.name : '无风'
const normalized = ((Number(degree) % 360) + 360) % 360
const direction = directions.find((item) => normalized >= item.min && normalized < item.max)
return direction ? direction.name : '--'
}
const getWindLevel = (speed) => {
if (speed === null) {
return null
}
const s = Number(speed)
if (Number.isNaN(s)) {
return null
}
if (s < 0.3) return 0
if (s < 1.6) return 1
if (s < 3.4) return 2
if (s < 5.5) return 3
if (s < 8.0) return 4
if (s < 10.8) return 5
if (s < 13.9) return 6
if (s < 17.2) return 7
if (s < 20.8) return 8
if (s < 24.5) return 9
if (s < 28.5) return 10
if (s < 32.7) return 11
return 12
}
const getWindLevelStatus = (level) => {
if (level === null) return { text: '--', statusClass: 'status-muted' }
if (level <= 3) return { text: '微风', statusClass: 'status-good' }
if (level <= 5) return { text: '和风', statusClass: 'status-info' }
if (level <= 7) return { text: '强风', statusClass: 'status-warn' }
return { text: '大风', statusClass: 'status-warn' }
}
const displayOrDash = (value) => {
if (value === null || value === undefined || value === '') {
return '--'
}
return value
}
//
const environmentData = computed(() => {
const tempStatus = getStatus(temperature.value, 'temperature')
const humidityStatus = getStatus(humidity.value, 'humidity')
const airQualityStatus = getStatus(airQuality.value, 'airQuality')
const pressureStatus = getStatus(pressure.value, 'pressure')
const uvStatus = getStatus(uvIndex.value, 'uvIndex')
const rainfallStatus = getStatus(rainfall.value, 'rainfall')
const windLevelStatus = getStatus(windLevel.value, 'windLevel')
const temperature = formatMetric(metrics.value.temperature, 1)
const humidity = formatMetric(metrics.value.humidity, 0)
const aqi = calcAqiFromPm25(metrics.value.pm25)
const airPressure = formatMetric(metrics.value.airPressure, 0)
const uvIndex = formatMetric(metrics.value.uvIndex, 0)
const rainfall = formatMetric(metrics.value.rainfall, 1)
const windDirection = formatMetric(metrics.value.windDirection, 0)
const windLevel = getWindLevel(formatMetric(metrics.value.windSpeed, 1))
const tempStatus = getTemperatureStatus(temperature)
const humidityStatus = getHumidityStatus(humidity)
const aqiStatus = getAqiStatus(aqi)
const pressureStatus = getPressureStatus(airPressure)
const uvStatus = getUvStatus(uvIndex)
const rainfallStatus = getRainfallStatus(rainfall)
const windLevelStatus = getWindLevelStatus(windLevel)
return [
{
id: 1,
key: 'temperature',
name: '温度',
value: temperature.value,
unit: '°C',
icon: '🌡',
iconColor: tempStatus.color,
valueColor: tempStatus.color,
statusClass: tempStatus.class,
statusText: tempStatus.text
icon: ICONS.temperature,
displayValue: displayOrDash(temperature),
unit: '℃',
statusText: tempStatus.text,
statusClass: tempStatus.statusClass
},
{
id: 2,
key: 'humidity',
name: '湿度',
value: humidity.value,
unit: '%',
icon: '💧',
iconColor: humidityStatus.color,
valueColor: humidityStatus.color,
statusClass: humidityStatus.class,
statusText: humidityStatus.text
icon: ICONS.humidity,
displayValue: displayOrDash(humidity),
unit: '%RH',
statusText: humidityStatus.text,
statusClass: humidityStatus.statusClass
},
{
id: 3,
key: 'airQuality',
name: '空气质量',
value: airQuality.value,
icon: ICONS.airQuality,
displayValue: displayOrDash(aqi),
unit: 'AQI',
icon: '🌬',
iconColor: airQualityStatus.color,
valueColor: airQualityStatus.color,
statusClass: airQualityStatus.class,
statusText: airQualityStatus.text
statusText: aqiStatus.text,
statusClass: aqiStatus.statusClass
},
{
id: 4,
key: 'pressure',
name: '大气压强',
value: pressure.value,
icon: ICONS.pressure,
displayValue: displayOrDash(airPressure),
unit: 'hPa',
icon: '📊',
iconColor: pressureStatus.color,
valueColor: pressureStatus.color,
statusClass: pressureStatus.class,
statusText: pressureStatus.text
statusText: pressureStatus.text,
statusClass: pressureStatus.statusClass
},
{
id: 5,
key: 'uv',
name: '紫外线',
value: uvIndex.value,
icon: ICONS.uv,
displayValue: displayOrDash(uvIndex),
unit: '级',
icon: '☀',
iconColor: uvStatus.color,
valueColor: uvStatus.color,
statusClass: uvStatus.class,
statusText: uvStatus.text
statusText: uvStatus.text,
statusClass: uvStatus.statusClass
},
{
id: 6,
key: 'rainfall',
name: '降雨量',
value: rainfall.value,
icon: ICONS.rainfall,
displayValue: displayOrDash(rainfall),
unit: 'mm',
icon: '🌧',
iconColor: rainfallStatus.color,
valueColor: rainfallStatus.color,
statusClass: rainfallStatus.class,
statusText: rainfallStatus.text
statusText: rainfallStatus.text,
statusClass: rainfallStatus.statusClass
},
{
id: 7,
key: 'windDirection',
name: '风向',
value: getWindDirection(windDirection.value),
icon: ICONS.windDirection,
displayValue: getWindDirectionText(windDirection),
unit: '',
icon: '🧭',
iconColor: '#409EFF',
valueColor: '#409EFF',
statusClass: 'normal',
statusText: `${windDirection.value}°`
statusText: windDirection === null ? '--' : `${windDirection}°`,
statusClass: windDirection === null ? 'status-muted' : 'status-info'
},
{
id: 8,
key: 'windForce',
name: '风力',
value: windLevel.value,
icon: ICONS.windForce,
displayValue: displayOrDash(windLevel),
unit: '级',
icon: '💨',
iconColor: windLevelStatus.color,
valueColor: windLevelStatus.color,
statusClass: windLevelStatus.class,
statusText: windLevelStatus.text
statusText: windLevelStatus.text,
statusClass: windLevelStatus.statusClass
}
]
})
//
let updateTimer = null
const updateData = () => {
// 15-28
temperature.value = (15 + Math.random() * 13).toFixed(1)
// 湿 35-85%
humidity.value = Math.round(35 + Math.random() * 50)
// 20-120
airQuality.value = Math.round(20 + Math.random() * 100)
// 995-1025
pressure.value = Math.round(995 + Math.random() * 30)
// 线 0-8
uvIndex.value = Math.round(Math.random() * 8)
// 0-10mm
rainfall.value = (Math.random() * 10).toFixed(1)
// 0-360
windDirection.value = Math.round(Math.random() * 360)
// 0-8
windLevel.value = Math.round(Math.random() * 8)
const loadData = async () => {
loading.value = true
try {
const response = await fetch(API_URL)
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
const result = await response.json()
if (result.code !== undefined && result.code !== 1) {
throw new Error(result.message || '接口返回异常')
}
const payload = result.data ?? result
metrics.value = {
temperature: payload.temperature ?? null,
humidity: payload.humidity ?? null,
pm25: payload.pm25 ?? null,
airPressure: payload.airPressure ?? payload.air_pressure ?? null,
uvIndex: payload.uvIndex ?? payload.uv_index ?? null,
rainfall: payload.rainfall ?? null,
windSpeed: payload.windSpeed ?? payload.wind_speed ?? null,
windDirection: payload.windDirection ?? payload.wind_direction ?? null
}
} catch (error) {
console.error('加载市场环境监控失败:', error)
} finally {
loading.value = false
}
}
onMounted(() => {
updateTimer = setInterval(updateData, 8000)
onMounted(async () => {
await loadData()
window.addEventListener('dataReloaded', loadData)
})
onUnmounted(() => {
if (updateTimer) {
clearInterval(updateTimer)
}
window.removeEventListener('dataReloaded', loadData)
})
</script>
<style scoped>
.monitor-content {
width: 100%;
height: 100%;
min-height: 0;
box-sizing: border-box;
}
.state-box {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
padding: 8px;
color: rgba(255, 255, 255, 0.65);
font-size: 14px;
}
.environment-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
grid-template-columns: repeat(4, minmax(0, 1fr));
grid-template-rows: auto auto;
gap: 20px;
width: 100%;
height: 100%;
align-content: center;
box-sizing: border-box;
}
.env-item {
background: linear-gradient(135deg, rgba(255, 255, 255, 0.03), rgba(255, 255, 255, 0.01));
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
padding: 10px;
.env-card {
box-sizing: border-box;
width: 100%;
aspect-ratio: 148 / 104;
height: auto;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
transition: all 0.3s ease;
position: relative;
background: linear-gradient(180deg, rgba(8, 72, 88, 0.42) 0%, rgba(4, 38, 52, 0.58) 100%);
border: 1px solid rgba(16, 150, 161, 0.65);
border-radius: 4px;
overflow: hidden;
justify-content: center;
}
.env-item:hover {
border-color: rgba(64, 158, 255, 0.4);
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(64, 158, 255, 0.15);
}
.env-item::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 2px;
background: linear-gradient(90deg, transparent, rgba(64, 158, 255, 0.6), transparent);
transform: translateX(-100%);
transition: transform 0.6s ease;
}
.env-item:hover::before {
transform: translateX(100%);
.env-head {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
width: 100%;
height: 30px;
padding: 0 8px;
box-sizing: border-box;
background: #025f66;
flex-shrink: 0;
}
.env-icon {
font-size: 24px;
margin-bottom: 4px;
transition: transform 0.3s ease;
width: 22px;
height: 22px;
object-fit: contain;
flex-shrink: 0;
}
.env-item:hover .env-icon {
transform: scale(1.1);
.env-name {
font-family: 'Microsoft YaHei', sans-serif;
font-size: 16px;
font-weight: 500;
color: rgba(255, 255, 255, 0.92);
white-space: nowrap;
line-height: 22px;
}
.env-info {
text-align: center;
.env-value-row {
display: flex;
align-items: baseline;
justify-content: center;
gap: 4px;
width: 100%;
}
.env-name {
font-size: 14px;
color: #a0a8b8;
margin-bottom: 4px;
font-weight: 500;
flex: 1;
min-height: 0;
padding: 4px 6px 2px;
box-sizing: border-box;
}
.env-value {
font-size: 16px;
font-weight: bold;
margin-bottom: 4px;
transition: color 0.3s ease;
font-family: 'Microsoft YaHei', sans-serif;
font-size: 24px;
font-weight: 700;
line-height: 1;
color: #0ce3fc;
}
.env-unit {
font-size: 10px;
opacity: 0.8;
margin-left: 2px;
font-family: 'Microsoft YaHei', sans-serif;
font-size: 14px;
font-weight: 500;
color: #0ce3fc;
}
.env-status {
display: inline-flex;
align-items: center;
justify-content: center;
width: 120px;
height: 20px;
margin-bottom: 8px;
padding: 0 8px;
box-sizing: border-box;
border-radius: 999px;
border: 1px solid #06c7be;
font-family: 'Microsoft YaHei', sans-serif;
font-size: 12px;
padding: 2px 6px;
border-radius: 8px;
font-weight: 500;
background: rgba(255, 255, 255, 0.1);
transition: all 0.3s ease;
}
.env-status.excellent {
background: rgba(103, 194, 58, 0.2);
color: #67C23A;
border: 1px solid rgba(103, 194, 58, 0.3);
}
.env-status.good {
background: rgba(64, 158, 255, 0.2);
color: #409EFF;
border: 1px solid rgba(64, 158, 255, 0.3);
line-height: 1;
background: transparent;
flex-shrink: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.env-status.warning {
background: rgba(230, 162, 60, 0.2);
color: #E6A23C;
border: 1px solid rgba(230, 162, 60, 0.3);
.status-good {
color: #0ca34c;
}
.env-status.danger {
background: rgba(245, 108, 108, 0.2);
color: #F56C6C;
border: 1px solid rgba(245, 108, 108, 0.3);
.status-info {
color: #106dae;
}
.env-status.normal {
background: rgba(64, 158, 255, 0.15);
color: #409EFF;
border: 1px solid rgba(64, 158, 255, 0.25);
.status-warn {
color: #bf7412;
}
/* 响应式布局 */
@media (max-width: 1200px) {
.environment-grid {
grid-template-columns: repeat(3, 1fr);
}
}
@media (max-width: 900px) {
.environment-grid {
grid-template-columns: repeat(2, 1fr);
}
.status-muted {
color: rgba(255, 255, 255, 0.45);
}
</style>
</style>

File diff suppressed because it is too large Load Diff

@ -0,0 +1,76 @@
<template>
<div class="live-player">
<video
ref="videoRef"
class="screen-media"
muted
autoplay
playsinline
/>
</div>
</template>
<script setup>
import { ref, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { createLivePlayer, destroyLivePlayer } from '../utils/liveStreamPlayer.js'
const props = defineProps({
url: {
type: String,
default: ''
},
playerConfig: {
type: Object,
default: () => ({})
},
active: {
type: Boolean,
default: true
}
})
const videoRef = ref(null)
let playerInstance = null
const teardown = () => {
destroyLivePlayer(playerInstance)
playerInstance = null
}
const setup = async () => {
teardown()
if (!props.active || !props.url) {
return
}
await nextTick()
if (!videoRef.value) {
return
}
playerInstance = createLivePlayer(videoRef.value, props.url, props.playerConfig)
}
watch(
() => [props.url, props.active, props.playerConfig],
setup,
{ deep: true }
)
onMounted(setup)
onBeforeUnmount(teardown)
</script>
<style scoped>
.live-player {
width: 100%;
height: 100%;
overflow: hidden;
}
.screen-media {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
background: #0a1418;
}
</style>

@ -1,6 +1,18 @@
<template>
<BaseCard title="采购商户来源分析">
<div ref="chartRef" class="chart"></div>
<template #headerExtra>
<span class="unit-label">采购商户数量()</span>
</template>
<div class="chart-wrapper">
<div ref="chartRef" class="chart"></div>
<img
ref="baseRef"
class="chart-base"
src="/images/底座.png"
alt=""
/>
</div>
</BaseCard>
</template>
@ -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 `<div style="padding: 8px;">
<strong style="color: #00d4ff;">${params[0].name}</strong><br/>
<span style="color: #67c23a;">采购商户: ${params[0].value} </span><br/>
<span style="color: #8cc8ff;">${item.description}</span>
</div>`
}
},
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 [
`<div style="font-weight:700;margin-bottom:6px;">${item.name}</div>`,
`<div style="color:#41a6fc;margin-top:2px;">采购商户:${item.value}家</div>`,
`<div style="color:rgba(255,255,255,0.72);margin-top:4px;font-size:12px;">${item.description}</div>`
].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()
})
</script>
<style scoped>
.unit-label {
font-family: 'Microsoft YaHei', sans-serif;
font-size: 13px;
color: rgba(255, 255, 255, 0.82);
white-space: nowrap;
padding-right: 4px;
}
.chart-wrapper {
position: relative;
flex: 1;
min-height: 0;
width: 100%;
height: 100%;
}
.chart {
width: 100%;
height: 100%;
min-height: 180px;
}
.chart-base {
position: absolute;
left: 0;
bottom: 0;
width: 0;
height: auto;
pointer-events: none;
user-select: none;
z-index: 0;
}
</style>
</style>

File diff suppressed because it is too large Load Diff

@ -5,14 +5,12 @@
<div ref="chartRef" class="chart"></div>
</div>
<div class="legend-container">
<div v-for="item in salesData" :key="item.name" class="legend-item">
<div v-for="item in chartItems" :key="item.name" class="legend-item">
<span class="legend-dot" :style="{ backgroundColor: item.color }"></span>
<div class="legend-info">
<span class="legend-text">{{ item.name }}</span>
<div class="legend-stats">
<span class="legend-count">{{ item.count }}</span>
<span class="legend-percent">{{ item.value }}%</span>
</div>
<div class="legend-text">
<span class="legend-name">{{ item.name }}</span>
<span class="legend-percent">{{ formatPercent(item.percent) }}%</span>
<span class="legend-count">{{ item.value }}</span>
</div>
</div>
</div>
@ -21,276 +19,201 @@
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import { ref, computed, onMounted, onUnmounted } from 'vue'
import * as echarts from 'echarts'
import 'echarts-gl'
import BaseCard from './BaseCard.vue'
import { buildPie3DOption, buildEmptyPie3DPlaceholder, enrichPieData } from '../utils/pie3d.js'
const API_URL = '/api/dashboard/yak-sales-type-stats'
const TYPE_COLORS = {
'屠宰用途': '#0051ea',
'养殖用途': '#f0a517',
'其他用途': '#d1d2d3'
}
const SLICE_OPACITY = 0.78
const DEFAULT_TYPES = [
{ name: '屠宰用途', value: 0, percent: 0 },
{ name: '养殖用途', value: 0, percent: 0 },
{ name: '其他用途', value: 0, percent: 0 }
]
const CHART_VIEW = {
internalDiameterRatio: 0.58,
boxHeight: 40,
viewAlpha: 28,
viewBeta: 48,
viewDistance: 185,
preserveOrder: true,
labelMode: 'none',
autoRotate: true,
autoRotateSpeed: 5
}
const chartRef = ref(null)
const salesData = ref([...DEFAULT_TYPES])
let chartInstance = null
//
const salesData = ref([
{
name: '屠宰用途',
value: 52,
count: 1456,
color: '#E6A23C',
description: '用于屠宰加工的牦牛'
},
{
name: '养殖用途',
value: 35,
count: 980,
color: '#67C23A',
description: '用于继续养殖的牦牛'
},
{
name: '其他用途',
value: 13,
count: 364,
color: '#409EFF',
description: '其他特殊用途的牦牛'
const colorList = computed(() => salesData.value.map((item) => TYPE_COLORS[item.name] || '#4dd9ff'))
const chartItems = computed(() => {
return enrichPieData(salesData.value, colorList.value).map((item) => ({
...item,
color: TYPE_COLORS[item.name] || item.color,
opacity: SLICE_OPACITY
}))
})
const formatPercent = (value) => Number(value || 0).toFixed(2)
const buildFullOption = (data) => {
const pieData = chartItems.value.filter((item) => item.value > 0)
if (!pieData.length) {
return buildEmptyPie3DPlaceholder(
[TYPE_COLORS['屠宰用途'], TYPE_COLORS['养殖用途'], TYPE_COLORS['其他用途']],
CHART_VIEW
)
}
])
//
const updateData = () => {
salesData.value.forEach(item => {
// 100%
const variance = (Math.random() - 0.5) * 4 // ±2%
item.value = Math.max(5, Math.min(80, item.value + variance))
//
const totalCount = 2800 // 2800
item.count = Math.floor((item.value / 100) * totalCount)
})
// 100%
const totalPercent = salesData.value.reduce((sum, item) => sum + item.value, 0)
salesData.value.forEach(item => {
item.value = Math.round((item.value / totalPercent) * 100)
return buildPie3DOption({
pieData,
...CHART_VIEW
})
//
const totalCount = 2800
salesData.value.forEach(item => {
item.count = Math.floor((item.value / 100) * totalCount)
}
const updateChart = () => {
if (!chartInstance) return
chartInstance.clear()
chartInstance.setOption(buildFullOption(salesData.value), true)
chartInstance.resize()
}
const normalizeItems = (items) => {
const map = {}
;(items || []).forEach((item) => {
map[item.name] = {
name: item.name,
value: Number(item.value) || 0,
percent: Number(item.percent) || 0
}
})
return DEFAULT_TYPES.map((item) => map[item.name] || { ...item })
}
const initChart = () => {
if (!chartRef.value) return
chartInstance = echarts.init(chartRef.value)
const updateChart = () => {
const option = {
tooltip: {
trigger: 'item',
backgroundColor: 'rgba(15, 25, 45, 0.95)',
borderColor: '#00d4ff',
borderWidth: 1,
textStyle: {
color: '#fff',
fontSize: 13
},
formatter: function(params) {
const item = salesData.value.find(d => d.name === params.name)
return `<div style="padding: 8px;">
<strong style="color: #00d4ff;">${params.name}</strong><br/>
<span style="color: #67c23a;">数量: ${item.count} </span><br/>
<span style="color: #8cc8ff;">占比: ${params.percent}%</span><br/>
<span style="color: #a0a8b8;">${item.description}</span>
</div>`
}
},
series: [{
name: '销售类型',
type: 'pie',
radius: ['35%', '75%'],
center: ['50%', '50%'],
data: salesData.value.map(item => ({
name: item.name,
value: item.value,
itemStyle: {
color: item.color,
borderRadius: 8,
borderColor: '#1a1f2e',
borderWidth: 3,
shadowBlur: 10,
shadowColor: 'rgba(0, 0, 0, 0.3)'
}
})),
emphasis: {
itemStyle: {
shadowBlur: 20,
shadowOffsetX: 0,
shadowColor: 'rgba(0, 212, 255, 0.5)',
borderColor: '#00d4ff',
borderWidth: 3
},
scaleSize: 10
},
labelLine: {
show: false
},
label: {
show: true,
position: 'center',
fontSize: 14,
fontWeight: 'bold',
color: '#fff',
formatter: function(params) {
if (params.name === '屠宰用途') {
return `${params.percent}%\n${params.name}`
}
return ''
}
},
animationType: 'scale',
animationEasing: 'elasticOut',
animationDelay: function (idx) {
return Math.random() * 200
}
}]
const loadData = async () => {
try {
const response = await fetch(API_URL)
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
chartInstance.setOption(option)
}
updateChart()
//
let currentIndex = 0
const timer = setInterval(() => {
chartInstance.dispatchAction({
type: 'downplay',
seriesIndex: 0,
dataIndex: currentIndex
})
currentIndex = (currentIndex + 1) % salesData.value.length
chartInstance.dispatchAction({
type: 'highlight',
seriesIndex: 0,
dataIndex: currentIndex
})
}, 3000)
//
const dataTimer = setInterval(() => {
updateData()
const result = await response.json()
if (result.code !== 1 || !result.data) {
throw new Error(result.message || '接口返回异常')
}
salesData.value = normalizeItems(result.data)
updateChart()
}, 10000) // 10
} catch (error) {
console.error('加载牦牛销售类型统计失败:', error)
}
}
chartInstance._autoTimer = timer
chartInstance._dataTimer = dataTimer
const handleResize = () => {
chartInstance?.resize()
}
onMounted(() => {
initChart()
window.addEventListener('resize', () => {
chartInstance?.resize()
})
onMounted(async () => {
if (chartRef.value) {
chartInstance = echarts.init(chartRef.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()
}
window.removeEventListener('resize', handleResize)
window.removeEventListener('dataReloaded', loadData)
chartInstance?.dispose()
})
</script>
<style scoped>
.sales-container {
display: flex;
align-items: center;
align-items: stretch;
height: 100%;
gap: 20px;
padding: 10px;
min-height: 0;
gap: 12px;
padding: 4px 6px 8px;
}
.chart-wrapper {
flex: 1;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
min-width: 0;
min-height: 0;
}
.chart {
width: 100%;
height: 100%;
min-height: 200px;
min-height: 180px;
}
.legend-container {
display: flex;
flex-direction: column;
gap: 16px;
min-width: 140px;
max-width: 160px;
justify-content: center;
gap: 18px;
width: 168px;
flex-shrink: 0;
padding-right: 4px;
}
.legend-item {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 8px;
border-radius: 6px;
background: rgba(255, 255, 255, 0.02);
border: 1px solid rgba(255, 255, 255, 0.05);
transition: all 0.3s ease;
}
.legend-item:hover {
background: rgba(255, 255, 255, 0.05);
border-color: rgba(64, 158, 255, 0.2);
transform: translateX(2px);
gap: 10px;
}
.legend-dot {
width: 12px;
height: 12px;
border-radius: 50%;
border-radius: 2px;
flex-shrink: 0;
margin-top: 2px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
margin-top: 4px;
box-shadow: 0 0 6px rgba(0, 0, 0, 0.25);
}
.legend-info {
flex: 1;
.legend-text {
display: flex;
flex-direction: column;
flex-wrap: wrap;
align-items: baseline;
gap: 4px;
font-family: 'Microsoft YaHei', sans-serif;
font-size: 14px;
line-height: 1.5;
color: #ffffff;
}
.legend-text {
.legend-name {
color: #ffffff;
font-size: 12px;
font-weight: 500;
line-height: 1.2;
}
.legend-stats {
display: flex;
flex-direction: column;
gap: 2px;
.legend-percent {
color: #8ecfff;
font-weight: 700;
}
.legend-count {
color: #8cc8ff;
font-size: 11px;
font-weight: 600;
}
.legend-percent {
color: #00d4ff;
font-size: 14px;
font-weight: bold;
color: #ffffff;
font-weight: 400;
}
</style>
</style>

@ -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()
}
}

@ -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 }
})
}
}

@ -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)

@ -54,7 +54,12 @@
<div class="side-panel-body">
<div class="right-section">
<div v-if="isSupplyExpanded" class="expanded-supply-container">
<SupplyDemandData :force-expanded="true" @expand-change="handleSupplyExpand" />
<SupplyDemandData
:force-expanded="true"
:initial-detail-id="supplyDetailId"
@expand-change="handleSupplyExpand"
@initial-detail-applied="clearSupplyDetailId"
/>
</div>
<template v-else>
<div class="right-center-column">
@ -131,10 +136,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 = ''
}
//

@ -11,6 +11,10 @@ export default defineConfig({
target: 'http://localhost:8080',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '')
},
'/profile': {
target: 'http://localhost:8080',
changeOrigin: true
}
}
},

Loading…
Cancel
Save