feat: 初始化红原县智慧活畜交易大数据平台项目

master
Swanky 1 month ago
commit b5099e5c4a
  1. 35
      .gitignore
  2. 535
      PROJECT_HISTORY.md
  3. 13
      index.html
  4. 1802
      package-lock.json
  5. 21
      package.json
  6. 1
      public/data/china.json
  7. 116
      public/data/mockData.json
  8. 477
      src/App.vue
  9. 458
      src/components/FlowMonitorMap.vue
  10. 466
      src/components/MarketAnalysis.vue
  11. 1315
      src/components/SceneManagement.vue
  12. 9
      src/main.js
  13. 449
      src/style.css
  14. 7
      vite.config.js

35
.gitignore vendored

@ -0,0 +1,35 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Environment files
.env
.env.local
.env.*.local
# Coverage directory
coverage
# Misc
*.local

@ -0,0 +1,535 @@
# 项目开发历史记录
## 一、项目概述
本项目是一个牦牛产业数据可视化平台,采用 Vue.js 框架开发,包含多个功能模块:交易流向监测、市场环境监测、多维度市场分析、牦牛供应展示等。平台集成了 Element UI 组件库和 ECharts 数据可视化库,实现了响应式布局和交互式数据展示功能。
**技术栈:**
- 前端框架:Vue.js 3.x(Composition API)
- UI 组件库:Element Plus
- 图表库:ECharts
- 地图数据:GeoJSON(china.json)
- 样式方案:CSS Scoped + CSS Flexbox/Grid
- 构建工具:Vite
---
## 二、组件架构演变
### 2.1 初始阶段:单文件 App.vue
项目最初是一个单文件的 `App.vue`,包含了所有的业务逻辑和 UI 代码。随着功能增加,代码变得臃肿,难以维护。
### 2.2 组件拆分阶段
按照单一职责原则,将 monolithic App.vue 拆分为以下独立组件:
```
src/components/
├── AppHeader.vue # 顶部导航栏
├── TransactionStats.vue # 实时交易统计
├── SalesChart.vue # 销售趋势图表
├── YakPopulationChart.vue # 牦牛存栏图表
├── FlowMonitorMap.vue # 交易流向地图
├── SceneManagement.vue # 场景管理(监控+环境)
└── MarketAnalysis.vue # 多维度市场分析
```
---
## 三、主题风格演变
### 3.1 初始深绿色主题
最初尝试使用偏绿色的深色风格,模拟草原风格:
```css
--primary-color: #22c55e;
--primary-dark: #15803d;
--background-dark: #0f172a;
--card-dark: #1e293b;
```
### 3.2 朴素灰色白色主题
用户反馈绿色不太合适,要求改回朴实一点的深色主题:
```css
--background-color: #f8fafc;
--card-background: #ffffff;
--text-primary: #1e293b;
--text-secondary: #64748b;
--border-color: #e2e8f0;
--primary-color: #22c55e; /* 保留绿色作为强调色 */
```
### 3.3 顶部导航栏样式
将顶部导航栏从白色改为深蓝色,增强视觉层次:
```css
.app-header {
background: linear-gradient(135deg, #1e40af 0%, #3b82f6 100%);
color: white;
}
```
---
## 四、核心功能模块实现
### 4.1 场景管理组件(SceneManagement.vue)
场景管理组件是平台的核心模块之一,负责展示监控视频和环境监测数据。
**布局演变:**
1. **初始布局**:简单的垂直排列
2. **卡片式布局**:引入 Element UI 的 el-card 组件
3. **双栏布局**:左侧展示环境指标,右侧展示监控视频
**最终布局结构:**
```html
<el-row :gutter="20">
<!-- 左侧:环境监测指标 -->
<el-col :span="12">
<div class="indicators-left">
<div class="indicator-row">
<env-card v-for="item in firstRowIndicators" :key="item.id" :data="item" />
</div>
<div class="indicator-row">
<env-card v-for="item in secondRowIndicators" :key="item.id" :data="item" />
</div>
</div>
</el-col>
<!-- 右侧:监控视频 -->
<el-col :span="12">
<div class="monitor-right">
<div class="video-container" v-for="camera in activeCameras" :key="camera.id">
<video :src="camera.src" controls />
<div class="video-info">
<span class="camera-name">{{ camera.name }}</span>
</div>
</div>
<div class="video-nav">
<button @click="switchToPrevCamera" :disabled="currentCameraIndex === 0"></button>
<button @click="switchToNextCamera" :disabled="currentCameraIndex === cameras.length - 1"></button>
</div>
</div>
</el-col>
</el-row>
```
**环境监测状态样式:**
```css
.env-card.normal {
background: #ffffff;
border: 1px solid #e2e8f0;
}
.env-card.warning {
background: #fef2f2;
border: 1px solid #fecaca;
}
.env-card.critical {
background: #fef2f2;
border: 1px solid #ef4444;
}
```
### 4.2 交易流向监测组件(FlowMonitorMap.vue)
使用 ECharts 实现地理数据可视化,展示牦牛销售网络、源地供应和红原出栏分布。
**实现方案:**
```javascript
<script setup>
import { ref, onMounted, watch } from 'vue';
import * as echarts from 'echarts';
const props = defineProps({
mapData: {
type: Object,
default: null
}
});
const chartRef = ref(null);
let chart = null;
let chinaMapData = null;
const mapTypes = [
{ value: 'sales', label: '销售网络分布' },
{ value: 'supply', label: '源地供应分布' },
{ value: 'slaughter', label: '红原出栏分布' }
];
const activeMapType = ref('sales');
const initChart = async () => {
chart = echarts.init(chartRef.value);
const response = await fetch('/data/china.json');
chinaMapData = await response.json();
echarts.registerMap('china', chinaMapData);
updateChart();
window.addEventListener('resize', handleResize);
};
const updateChart = () => {
const data = mockData[activeMapType.value];
const option = {
backgroundColor: '#f5f7fa',
title: {
text: data.title,
subtext: data.subtitle,
left: 'center',
top: 20
},
geo: {
map: 'china',
roam: true,
center: data.center,
zoom: data.zoom,
itemStyle: {
areaColor: '#e2e8f0',
borderColor: '#94a3b8'
}
},
series: [
{
type: 'lines',
coordinateSystem: 'geo',
lineStyle: {
color: '#22c55e',
width: 2,
curveness: 0.15
},
effect: {
show: true,
period: 4,
trailLength: 0.3,
symbol: 'arrow',
symbolSize: 8
}
},
{
name: '核心',
type: 'effectScatter',
coordinateSystem: 'geo',
symbolSize: (val) => val[2] / 2,
itemStyle: {
color: '#ef4444'
},
rippleEffect: {
brushType: 'stroke',
scale: 3
}
}
]
};
chart.setOption(option, true);
};
</script>
```
### 4.3 多维度市场分析组件(MarketAnalysis.vue)
包含三个分析维度:销售结构分析、价格趋势分析、客户来源分析。
**布局结构:**
```html
<el-row :gutter="20">
<el-col :span="12">
<el-card>
<template #header>
<div class="card-header">
<span>销售结构分析</span>
<el-radio-group v-model="salesViewType" size="small">
<el-radio-button value="pie">饼图</el-radio-button>
<el-radio-button value="bar">柱状图</el-radio-button>
</el-radio-group>
</div>
</template>
<div ref="salesChartRef" class="chart-container" />
</el-card>
</el-col>
<el-col :span="12">
<el-card>
<template #header>
<div class="card-header">
<span>价格趋势分析</span>
<el-radio-group v-model="pricePeriod" size="small">
<el-radio-button value="week"></el-radio-button>
<el-radio-button value="month"></el-radio-button>
<el-radio-button value="year"></el-radio-button>
</el-radio-group>
</div>
</template>
<div ref="priceChartRef" class="chart-container" />
</el-card>
</el-col>
</el-row>
<el-row :gutter="20" style="margin-top: 20px;">
<el-col :span="24">
<el-card>
<template #header>
<div class="card-header">
<span>客户来源分析</span>
</div>
</template>
<div ref="customerChartRef" class="chart-container" />
</el-card>
</el-col>
</el-row>
```
---
## 五、数据架构演变
### 5.1 初始阶段:组件内嵌 mock 数据
各组件内部定义模拟数据,导致数据冗余和重复请求。
```javascript
const mockData = {
salesByPriceRange: [...],
priceTrend: [...],
// ...
};
```
### 5.2 JSON 文件阶段
将 mock 数据移到静态 JSON 文件:
```json
{
"marketAnalysis": {
"salesByPriceRange": [...],
"salesByRegion": [...],
"priceTrend": [...],
"customerSource": [...]
}
}
```
### 5.3 共享数据架构阶段(最终方案)
在 App.vue 中集中获取数据,通过 props 传递给子组件:
```javascript
// App.vue
<script setup>
import { ref, onMounted } from 'vue';
import SceneManagement from './components/SceneManagement.vue';
import MarketAnalysis from './components/MarketAnalysis.vue';
import FlowMonitorMap from './components/FlowMonitorMap.vue';
const sharedData = ref(null);
const fetchSharedData = async () => {
try {
const response = await fetch('/data/mockData.json');
sharedData.value = await response.json();
} catch (error) {
console.error('获取共享数据失败:', error);
}
};
onMounted(() => {
fetchSharedData();
});
</script>
<template>
<SceneManagement v-if="sharedData" :scene-data="sharedData.sceneData" />
<MarketAnalysis v-if="sharedData" :market-analysis-data="sharedData.marketAnalysis" />
<FlowMonitorMap v-if="sharedData" :map-data="sharedData.flowData" />
</template>
```
**优势:**
- 减少网络请求次数(只请求一次)
- 保证数据一致性
- 便于数据管理和维护
---
## 六、关键文件结构
```
zhhxjy/
├── public/
│ └── data/
│ ├── mockData.json # 共享模拟数据
│ └── china.json # 中国地图地理数据
├── src/
│ ├── components/
│ │ ├── AppHeader.vue # 顶部导航
│ │ ├── TransactionStats.vue
│ │ ├── SalesChart.vue
│ │ ├── YakPopulationChart.vue
│ │ ├── FlowMonitorMap.vue # 交易流向地图
│ │ ├── SceneManagement.vue # 场景管理
│ │ └── MarketAnalysis.vue # 市场分析
│ ├── App.vue # 主应用组件
│ ├── main.js # 应用入口
│ └── style.css # 全局样式
├── index.html
├── package.json
└── vite.config.js
```
---
## 七、遇到的问题及解决方案
### 7.1 地图数据加载失败
**问题**:从外部 URL 下载地图数据失败(404 错误)
**解决方案**:
- 使用本地 china.json 文件
- 放置在 public/data/ 目录下
- 通过 `/data/china.json` 路径访问
### 7.2 视频容器高度不匹配
**问题**:增加视频宽度后,容器高度计算错误,导致内容溢出或留白
**解决方案**:
```css
.video-wrapper {
height: 100%;
display: flex;
flex-direction: column;
}
.video-content {
flex: 1;
height: 100%;
}
.video-info-main {
flex: 1;
display: flex;
flex-direction: column;
}
```
### 7.3 右侧导航按钮消失
**问题**:视频宽度增加后,右侧切换按钮被隐藏
**解决方案**:
```css
.monitor-right {
width: 800px; /* 增加容器宽度 */
}
.video-content {
width: 320px; /* 固定视频宽度 */
}
```
### 7.4 预警颜色不一致
**问题**:环境监测的预警状态使用了黄色主题,与整体风格不符
**解决方案**:
```css
.env-card.warning {
background: #fef2f2;
border: 1px solid #fecaca;
}
.env-card.critical {
background: #fef2f2;
border: 1px solid #ef4444;
}
.warning-badge {
background: #ef4444;
color: white;
}
```
### 7.5 Mock 数据位置错误
**问题**:mockData.json 放在 src/data 目录,无法通过 HTTP 请求访问
**解决方案**:
- 创建 public/data/ 目录
- 将 mockData.json 移动到新目录
- 通过 `/data/mockData.json` 路径访问
---
## 八、开发里程碑
### 里程碑 1:组件拆分
- 完成 App.vue 到独立组件的拆分
- 建立规范的目录结构
### 里程碑 2:主题定制
- 实现灰色白色主题
- 保留绿色作为强调色
- 优化顶部导航栏样式
### 里程碑 3:布局优化
- 实现卡片式布局
- 优化监控视频布局
- 修复各种溢出和高度问题
### 里程碑 4:数据架构
- 实现共享数据获取
- 减少冗余请求
- 统一数据管理
### 里程碑 5:地图集成
- 实现本地地图数据加载
- 完成交易流向可视化
- 添加交互控制功能
---
## 九、后续优化建议
1. **性能优化**:对 ECharts 图表实现懒加载
2. **响应式设计**:增加移动端适配
3. **数据可视化**:添加更多交互功能(缩放、筛选、导出)
4. **错误处理**:增加请求失败的重试机制和错误提示
5. **代码规范**:添加 TypeScript 类型支持
6. **测试覆盖**:增加单元测试和 E2E 测试
---
## 十、技术要点总结
| 功能 | 技术方案 | 关键代码 |
|------|----------|----------|
| 组件通信 | Props + Events | `defineProps()`, `defineEmits()` |
| 状态管理 | Ref + Watch | `ref()`, `watch()` |
| 图表渲染 | ECharts | `echarts.init()`, `chart.setOption()` |
| 地图渲染 | ECharts Geo | `registerMap()`, `geo` 配置 |
| 卡片布局 | Element UI | `el-card`, `el-row`, `el-col` |
| 数据获取 | Fetch API | `fetch('/data/xxx.json')` |
| 样式隔离 | Scoped CSS | `<style scoped>` |
| 响应式 | Flexbox | `display: flex`, `flex: 1` |
---
*文档生成时间:2025-12-27*

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<!-- <link rel="icon" type="image/svg+xml" href="/vite.svg" /> -->
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>红原县智慧活畜交易大数据平台</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

1802
package-lock.json generated

File diff suppressed because it is too large Load Diff

@ -0,0 +1,21 @@
{
"name": "zhhxjy",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"echarts": "^6.0.0",
"element-plus": "^2.13.0",
"ol": "^10.7.0",
"vue": "^3.2.47"
},
"devDependencies": {
"@vitejs/plugin-vue": "^4.2.3",
"vite": "^4.4.5"
}
}

File diff suppressed because one or more lines are too long

@ -0,0 +1,116 @@
{
"baseData": {
"periods": ["日", "周", "年"],
"stats": {
"日": {
"牦牛交易总量": 1234,
"订单交易总量": 567,
"销售商户数量": 89,
"采购商户数量": 123
},
"周": {
"牦牛交易总量": 8638,
"订单交易总量": 3969,
"销售商户数量": 412,
"采购商户数量": 689
},
"年": {
"牦牛交易总量": 449350,
"订单交易总量": 206355,
"销售商户数量": 12500,
"采购商户数量": 35800
}
},
"yak成交统计": {
"dates": ["1月", "2月", "3月", "4月", "5月", "6月", "7月", "8月", "9月", "10月", "11月", "12月"],
"2023": {
"成交量": [1200, 1500, 1300, 1800, 2100, 2500, 2300, 2700, 2900, 3200, 3500, 3800],
"成交额": [4800000, 6000000, 5200000, 7200000, 8400000, 10000000, 9200000, 10800000, 11600000, 12800000, 14000000, 15200000]
},
"2024": {
"成交量": [2000, 2200, 2500, 2800, 3200, 3500, 3800, 4200, 4500, 4800, 5200, 5500],
"成交额": [8000000, 8800000, 10000000, 11200000, 12800000, 14000000, 15200000, 16800000, 18000000, 19200000, 20800000, 22000000]
},
"2025": {
"成交量": [3000, 3500, 3800, 4200, 4500, 5000, 5500, 6000, 6500, 7000, 7500, 8000],
"成交额": [12000000, 14000000, 15200000, 16800000, 18000000, 20000000, 22000000, 24000000, 26000000, 28000000, 30000000, 32000000]
}
},
"综合销售统计": {
"regions": ["本县", "省内", "华东", "华南", "华中", "华北", "西南", "西北"],
"sales": [12000, 8000, 6000, 5000, 4000, 3500, 3000, 2500]
}
},
"marketAnalysis": {
"salesByPriceRange": [
{ "name": "5000元以下", "value": 15, "percentage": "15%" },
{ "name": "5000-8000元", "value": 25, "percentage": "25%" },
{ "name": "8000-10000元", "value": 30, "percentage": "30%" },
{ "name": "10000-15000元", "value": 20, "percentage": "20%" },
{ "name": "15000元以上", "value": 10, "percentage": "10%" }
],
"salesByRegion": [
{ "name": "本县", "value": 35, "percentage": "35%" },
{ "name": "省内其他地区", "value": 25, "percentage": "25%" },
{ "name": "华东地区", "value": 18, "percentage": "18%" },
{ "name": "华南地区", "value": 12, "percentage": "12%" },
{ "name": "西南地区", "value": 6, "percentage": "6%" },
{ "name": "西北地区", "value": 4, "percentage": "4%" }
],
"priceTrend": {
"day": {
"dates": ["00:00", "04:00", "08:00", "12:00", "16:00", "20:00", "24:00"],
"cattle": [28.5, 28.3, 28.8, 29.2, 29.5, 29.3, 29.1],
"meat": [42.0, 41.8, 42.2, 42.5, 42.8, 42.6, 42.4]
},
"week": {
"dates": ["周一", "周二", "周三", "周四", "周五", "周六", "周日"],
"cattle": [28.2, 28.5, 28.8, 29.1, 29.4, 29.6, 29.3],
"meat": [41.5, 41.8, 42.1, 42.4, 42.7, 43.0, 42.6]
},
"month": {
"dates": ["1月", "2月", "3月", "4月", "5月", "6月", "7月", "8月", "9月", "10月", "11月", "12月"],
"cattle": [26.5, 27.0, 27.5, 28.0, 28.5, 29.0, 29.5, 30.0, 30.5, 31.0, 31.5, 32.0],
"meat": [40.0, 40.5, 41.0, 41.5, 42.0, 42.5, 43.0, 43.5, 44.0, 44.5, 45.0, 45.5]
}
},
"customerSource": {
"regions": ["本县", "省内其他地区", "华东地区", "华南地区", "华中地区", "华北地区", "西南地区", "西北地区"],
"2023": [3500, 2800, 2200, 1800, 1500, 1200, 980, 720],
"2024": [4200, 3400, 2600, 2100, 1800, 1400, 1150, 850],
"2025": [5100, 4100, 3200, 2600, 2200, 1700, 1380, 1020]
}
},
"sceneManagement": {
"realtimeStats": [
{ "key": "total", "label": "牦牛供应总量", "value": 2345, "unit": "头", "icon": "🐂", "color": "#68d391", "trend": "up", "change": "+12.5%", "history": [45, 52, 48, 61, 55, 67, 72, 68, 75, 80] },
{ "key": "sold", "label": "已售牦牛数", "value": 1123, "unit": "头", "icon": "💰", "color": "#4299e1", "trend": "up", "change": "+8.3%", "history": [30, 35, 32, 40, 38, 45, 48, 50, 52, 55] },
{ "key": "pending", "label": "待售牦牛数", "value": 1222, "unit": "头", "icon": "📦", "color": "#ed8936", "trend": "down", "change": "-5.2%", "history": [55, 50, 52, 48, 45, 42, 40, 38, 35, 32] },
{ "key": "vehicles", "label": "进场车辆数", "value": 45, "unit": "辆", "icon": "🚛", "color": "#9f7aea", "trend": "up", "change": "+15.8%", "history": [8, 10, 12, 11, 14, 15, 18, 16, 20, 22] },
{ "key": "parking", "label": "剩余车位", "value": 15, "unit": "个", "icon": "🅿", "color": "#f56565", "trend": "down", "change": "-20.0%", "history": [25, 22, 20, 18, 16, 15, 14, 12, 10, 8] },
{ "key": "suppliers", "label": "供应商数量", "value": 67, "unit": "家", "icon": "👥", "color": "#38b2ac", "trend": "up", "change": "+3.2%", "history": [12, 15, 18, 20, 22, 25, 28, 30, 32, 35] }
],
"surveillanceCameras": [
{ "id": 1, "name": "入场口摄像头1", "zone": "入场口", "status": "normal", "statusText": "正常", "active": true, "recording": true, "time": "14:32:45" },
{ "id": 2, "name": "入场口摄像头2", "zone": "入场口", "status": "normal", "statusText": "正常", "active": true, "recording": true, "time": "14:32:45" },
{ "id": 3, "name": "交易区摄像头1", "zone": "交易区", "status": "normal", "statusText": "正常", "active": true, "recording": true, "time": "14:32:45" },
{ "id": 4, "name": "检疫区摄像头", "zone": "检疫区", "status": "warning", "statusText": "预警", "active": false, "recording": false, "time": "14:32:45" }
],
"envIndicators": [
{ "key": "temperature", "name": "气温", "value": 18, "unit": "°C", "icon": "🌡", "percentage": 45, "normalRange": "10-25°C", "warningRange": ">30°C", "isWarning": false, "isCritical": false },
{ "key": "humidity", "name": "湿度", "value": 45, "unit": "%", "icon": "💧", "percentage": 45, "normalRange": "30-60%", "warningRange": ">70%", "isWarning": false, "isCritical": false },
{ "key": "airQuality", "name": "空气质量", "value": 120, "unit": "AQI", "icon": "🌬", "percentage": 60, "normalRange": "0-100", "warningRange": "100-150", "isWarning": true, "isCritical": false },
{ "key": "windDir", "name": "风向", "value": 0, "unit": "°", "icon": "🧭", "percentage": 0, "normalRange": "任意", "warningRange": "无", "isWarning": false, "isCritical": false },
{ "key": "windSpeed", "name": "风力", "value": 3, "unit": "级", "icon": "🍃", "percentage": 30, "normalRange": "0-5级", "warningRange": ">6级", "isWarning": false, "isCritical": false }
],
"supplyList": [
{ "id": 1, "name": "张三", "phone": "138****1234", "plateNumber": "川A12345", "yakCount": 50, "quarantineNo": "QY2024001", "entryTime": "2025-12-27T14:00:00", "quarantineStatus": "qualified", "isUrgent": false },
{ "id": 2, "name": "李四", "phone": "139****5678", "plateNumber": "川B67890", "yakCount": 30, "quarantineNo": "QY2024002", "entryTime": "2025-12-27T13:30:00", "quarantineStatus": "pending", "isUrgent": false },
{ "id": 3, "name": "王五", "phone": "136****2468", "plateNumber": "川C24680", "yakCount": 45, "quarantineNo": "QY2024003", "entryTime": "2025-12-27T13:00:00", "quarantineStatus": "qualified", "isUrgent": false },
{ "id": 4, "name": "赵六", "phone": "137****1357", "plateNumber": "川D13579", "yakCount": 60, "quarantineNo": "QY2024004", "entryTime": "2025-12-27T12:30:00", "quarantineStatus": "processing", "isUrgent": true },
{ "id": 5, "name": "孙七", "phone": "135****9753", "plateNumber": "川E97531", "yakCount": 25, "quarantineNo": "QY2024005", "entryTime": "2025-12-27T12:00:00", "quarantineStatus": "qualified", "isUrgent": false },
{ "id": 6, "name": "周八", "phone": "131****1111", "plateNumber": "川F11111", "yakCount": 40, "quarantineNo": "QY2024006", "entryTime": "2025-12-27T11:30:00", "quarantineStatus": "pending", "isUrgent": false },
{ "id": 7, "name": "吴九", "phone": "132****2222", "plateNumber": "川G22222", "yakCount": 35, "quarantineNo": "QY2024007", "entryTime": "2025-12-27T11:00:00", "quarantineStatus": "rejected", "isUrgent": true }
]
}
}

@ -0,0 +1,477 @@
<template>
<div class="app">
<!-- Header 区域 -->
<header class="header">
<div class="header-title">红原县智慧活畜交易大数据平台</div>
<div class="tabs">
<div
v-for="tab in tabs"
:key="tab.id"
class="tab-item"
:class="{ active: activeTab === tab.id }"
@click="activeTab = tab.id"
>
{{ tab.name }}
</div>
</div>
</header>
<!-- 正文内容区域 -->
<main class="content">
<div class="tab-content" style="padding:24px;overflow:auto;">
<!-- 数据加载状态 -->
<div v-if="isLoading" class="loading-container">
<el-icon class="loading-icon"><Loading /></el-icon>
<p>数据加载中...</p>
</div>
<!-- 数据加载错误 -->
<div v-else-if="dataError" class="error-container">
<el-icon class="error-icon"><Warning /></el-icon>
<p>{{ dataError }}</p>
<el-button type="primary" @click="fetchSharedData">重新加载</el-button>
</div>
<!-- 正常数据展示 -->
<template v-else>
<!-- 交易基础数据 -->
<div v-if="activeTab === 'base-data' && sharedData">
<!-- 实时交易统计 -->
<div class="card-section">
<h3 class="card-title">实时交易统计</h3>
<div class="card-content">
<div class="period-selector">
<button
v-for="period in sharedData.baseData.periods"
:key="period"
class="period-btn"
:class="{ active: activePeriod === period }"
@click="activePeriod = period"
>
{{ period }}
</button>
</div>
<div class="stats-grid">
<div class="stat-card" v-for="(value, label) in sharedData.baseData.stats[activePeriod]" :key="label">
<div class="stat-value">{{ value.toLocaleString() }}</div>
<div class="stat-label">{{ label }}</div>
</div>
</div>
</div>
</div>
<!-- 统计图表区域 -->
<div class="charts-row">
<!-- 牦牛成交统计 -->
<div class="card-section chart-card">
<h3 class="card-title">
牦牛成交统计
<div class="year-selector">
<select
v-model="activeYear"
class="year-select"
@change="updateYakChart"
>
<option v-for="year in years" :key="year" :value="year">{{ year }}</option>
</select>
</div>
</h3>
<div class="card-content">
<div class="chart-container">
<div ref="yakChartRef" style="width: 100%; height: 100%;"></div>
</div>
</div>
</div>
<!-- 综合销售统计 -->
<div class="card-section chart-card">
<h3 class="card-title">综合销售统计</h3>
<div class="card-content">
<div class="chart-container">
<div ref="salesChartRef" style="width: 100%; height: 100%;"></div>
</div>
</div>
</div>
</div>
</div>
<!-- 2. 交易流向监测 -->
<div v-else-if="activeTab === 'flow-monitor'" class="flow-monitor-container">
<FlowMonitorMap />
</div>
<!-- 3. 交易场景管理 -->
<div v-else-if="activeTab === 'scene-management'">
<SceneManagement v-if="sharedData" :scene-data="sharedData.sceneManagement" />
</div>
<!-- 4. 多维度市场分析 -->
<div v-else-if="activeTab === 'market-analysis'">
<MarketAnalysis v-if="sharedData" :market-analysis-data="sharedData.marketAnalysis" />
</div>
</template>
</div>
</main>
</div>
</template>
<script setup>
import { ref, onMounted, watch, onBeforeUnmount } from 'vue';
import * as echarts from 'echarts';
import { Loading, Warning } from '@element-plus/icons-vue';
import FlowMonitorMap from './components/FlowMonitorMap.vue';
import SceneManagement from './components/SceneManagement.vue';
import MarketAnalysis from './components/MarketAnalysis.vue';
//
const sharedData = ref(null);
const isLoading = ref(true);
const dataError = ref(null);
const fetchSharedData = async () => {
isLoading.value = true;
dataError.value = null;
try {
const response = await fetch('/data/mockData.json');
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
sharedData.value = await response.json();
} catch (error) {
console.error('获取共享数据失败:', error);
dataError.value = '数据加载失败,请刷新页面重试';
} finally {
isLoading.value = false;
}
};
//
const tabs = [
{ id: 'base-data', name: '交易基础数据' },
{ id: 'flow-monitor', name: '交易流向监测' },
{ id: 'scene-management', name: '交易场景管理' },
{ id: 'market-analysis', name: '多维度市场分析' }
];
//
const activeTab = ref('base-data');
//
const activePeriod = ref('日');
//
const years = ['2023', '2024', '2025'];
const activeYear = ref('2025');
// ECharts
const yakChartRef = ref(null);
const salesChartRef = ref(null);
let yakChart = null;
let salesChart = null;
//
const initYakChart = () => {
if (yakChartRef.value) {
yakChart = echarts.init(yakChartRef.value);
updateYakChart();
}
};
//
const updateYakChart = () => {
if (yakChart && sharedData.value) {
const yearData = sharedData.value.baseData['yak成交统计'][activeYear.value];
const option = {
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'cross'
}
},
legend: {
data: ['成交量', '成交额'],
textStyle: {
color: '#333333'
},
top: 10
},
grid: {
left: '3%',
right: '4%',
top: 60,
bottom: '3%',
containLabel: true,
backgroundColor: 'transparent'
},
xAxis: {
type: 'category',
data: sharedData.value.baseData['yak成交统计'].dates,
axisLabel: {
color: '#333333'
},
axisLine: {
lineStyle: {
color: '#cccccc'
}
}
},
yAxis: [
{
type: 'value',
name: '成交量',
axisLabel: {
color: '#333333',
formatter: '{value} 头'
},
axisLine: {
lineStyle: {
color: '#1890ff'
}
},
splitLine: {
lineStyle: {
color: '#e0e0e0',
type: 'dashed'
}
}
},
{
type: 'value',
name: '成交额',
axisLabel: {
color: '#333333',
formatter: '{value} 元'
},
axisLine: {
lineStyle: {
color: '#52c41a'
}
},
splitLine: {
show: false
}
}
],
series: [
{
name: '成交量',
type: 'line',
data: yearData.成交量,
smooth: true,
itemStyle: {
color: '#1890ff'
},
lineStyle: {
width: 3
},
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: 'rgba(24, 144, 255, 0.3)' },
{ offset: 1, color: 'rgba(24, 144, 255, 0.05)' }
])
}
},
{
name: '成交额',
type: 'line',
yAxisIndex: 1,
data: yearData.成交额,
smooth: true,
itemStyle: {
color: '#52c41a'
},
lineStyle: {
width: 3
},
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: 'rgba(82, 196, 26, 0.3)' },
{ offset: 1, color: 'rgba(82, 196, 26, 0.05)' }
])
}
}
]
};
yakChart.setOption(option);
}
};
//
const initSalesChart = () => {
if (salesChartRef.value && sharedData.value) {
salesChart = echarts.init(salesChartRef.value);
const option = {
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow'
}
},
legend: {
data: ['销售额'],
textStyle: {
color: '#333333'
},
top: 10
},
grid: {
left: '3%',
right: '4%',
top: 60,
bottom: '3%',
containLabel: true,
backgroundColor: 'transparent'
},
xAxis: {
type: 'category',
data: sharedData.value.baseData['综合销售统计'].regions,
axisLabel: {
color: '#333333',
rotate: 45
},
axisLine: {
lineStyle: {
color: '#cccccc'
}
}
},
yAxis: {
type: 'value',
name: '销售额',
axisLabel: {
color: '#333333',
formatter: '{value} 元'
},
axisLine: {
lineStyle: {
color: '#cccccc'
}
},
splitLine: {
lineStyle: {
color: '#e0e0e0',
type: 'dashed'
}
}
},
series: [
{
name: '销售额',
type: 'bar',
data: sharedData.value.baseData['综合销售统计'].sales,
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: '#1890ff' },
{ offset: 1, color: '#096dd9' }
]),
borderRadius: [4, 4, 0, 0]
},
emphasis: {
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: '#40a9ff' },
{ offset: 1, color: '#1890ff' }
])
}
}
}
]
};
salesChart.setOption(option);
}
};
//
const handleResize = () => {
if (yakChart) {
yakChart.resize();
}
if (salesChart) {
salesChart.resize();
}
};
//
onMounted(async () => {
await fetchSharedData();
if (activeTab.value === 'base-data') {
initYakChart();
initSalesChart();
}
window.addEventListener('resize', handleResize);
});
//
watch(activeTab, (newTab) => {
if (newTab === 'base-data') {
setTimeout(() => {
initYakChart();
initSalesChart();
}, 0);
} else {
//
if (yakChart) {
yakChart.dispose();
yakChart = null;
}
if (salesChart) {
salesChart.dispose();
salesChart = null;
}
}
});
//
onBeforeUnmount(() => {
if (yakChart) {
yakChart.dispose();
}
if (salesChart) {
salesChart.dispose();
}
window.removeEventListener('resize', handleResize);
});
</script>
<style scoped>
.loading-container,
.error-container {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 400px;
color: #666;
}
.loading-icon {
font-size: 48px;
color: #1890ff;
animation: spin 1.5s linear infinite;
}
.error-icon {
font-size: 48px;
color: #ff4d4f;
margin-bottom: 16px;
}
.loading-container p,
.error-container p {
margin-top: 16px;
font-size: 16px;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style>

@ -0,0 +1,458 @@
<template>
<div class="flow-monitor-container">
<div ref="chartRef" class="chart-container"></div>
<div class="map-controls">
<div class="control-group">
<span class="control-label">地图类型</span>
<div class="button-group">
<button
v-for="type in mapTypes"
:key="type.value"
class="map-btn"
:class="{ active: activeMapType === type.value }"
@click="switchMapType(type.value)"
>
{{ type.label }}
</button>
</div>
</div>
<div class="legend">
<div class="legend-item">
<span class="legend-dot red"></span>
<span>红原县核心</span>
</div>
<div class="legend-item">
<span class="legend-dot green"></span>
<span>{{ activeMapType === 'sales' ? '销售网络' : activeMapType === 'supply' ? '供应源地' : '出栏分布' }}</span>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount, watch } from 'vue';
import * as echarts from 'echarts';
const props = defineProps({
mapData: {
type: Object,
default: null
}
});
const chartRef = ref(null);
let chart = null;
let chinaMapData = null;
const mapTypes = [
{ value: 'sales', label: '销售网络分布' },
{ value: 'supply', label: '源地供应分布' },
{ value: 'slaughter', label: '红原出栏分布' }
];
const activeMapType = ref('sales');
const mockData = {
sales: {
title: '牦牛销售网络分布',
subtitle: '全国销售网络覆盖情况',
center: [104.06, 33.52],
zoom: 5,
points: [
{ name: '红原县', value: [102.55, 33.00, 100], symbolSize: 45 },
{ name: '成都市', value: [104.06, 30.67, 85], symbolSize: 38 },
{ name: '重庆市', value: [106.55, 29.56, 72], symbolSize: 32 },
{ name: '西安市', value: [108.93, 34.34, 68], symbolSize: 30 },
{ name: '兰州市', value: [103.82, 36.06, 55], symbolSize: 26 },
{ name: '西宁市', value: [101.78, 36.62, 48], symbolSize: 24 },
{ name: '昆明市', value: [102.83, 25.05, 45], symbolSize: 23 },
{ name: '贵阳市', value: [106.63, 26.65, 42], symbolSize: 22 },
{ name: '绵阳市', value: [104.68, 31.47, 52], symbolSize: 25 },
{ name: '南充市', value: [106.11, 30.84, 48], symbolSize: 24 },
{ name: '泸州市', value: [105.44, 28.87, 45], symbolSize: 23 },
{ name: '宜宾市', value: [104.64, 28.75, 42], symbolSize: 22 }
],
lines: [
{ fromName: '红原县', toName: '成都市', value: 85 },
{ fromName: '红原县', toName: '重庆市', value: 72 },
{ fromName: '红原县', toName: '西安市', value: 68 },
{ fromName: '红原县', toName: '兰州市', value: 55 },
{ fromName: '红原县', toName: '西宁市', value: 48 },
{ fromName: '红原县', toName: '昆明市', value: 45 },
{ fromName: '红原县', toName: '贵阳市', value: 42 },
{ fromName: '红原县', toName: '绵阳市', value: 52 },
{ fromName: '红原县', toName: '南充市', value: 48 }
]
},
supply: {
title: '牦牛源地供应分布',
subtitle: '主要供应来源区域',
center: [102.5, 33.0],
zoom: 6,
points: [
{ name: '红原县', value: [102.55, 33.00, 100], symbolSize: 45 },
{ name: '若尔盖县', value: [102.83, 33.58, 88], symbolSize: 40 },
{ name: '阿坝县', value: [101.71, 32.90, 75], symbolSize: 35 },
{ name: '壤塘县', value: [100.99, 32.26, 65], symbolSize: 30 },
{ name: '马尔康市', value: [102.22, 31.91, 58], symbolSize: 28 },
{ name: '松潘县', value: [103.61, 32.64, 55], symbolSize: 27 },
{ name: '九寨沟县', value: [104.24, 33.27, 50], symbolSize: 25 },
{ name: '金川县', value: [102.06, 31.48, 52], symbolSize: 26 },
{ name: '小金县', value: [102.36, 30.99, 48], symbolSize: 24 },
{ name: '汶川县', value: [103.59, 31.48, 45], symbolSize: 23 },
{ name: '色达县', value: [100.35, 32.27, 52], symbolSize: 26 },
{ name: '石渠县', value: [98.10, 32.97, 48], symbolSize: 24 }
],
lines: [
{ fromName: '若尔盖县', toName: '红原县', value: 88 },
{ fromName: '阿坝县', toName: '红原县', value: 75 },
{ fromName: '壤塘县', toName: '红原县', value: 65 },
{ fromName: '马尔康市', toName: '红原县', value: 58 },
{ fromName: '松潘县', toName: '红原县', value: 55 },
{ fromName: '九寨沟县', toName: '红原县', value: 50 },
{ fromName: '金川县', toName: '红原县', value: 52 },
{ fromName: '色达县', toName: '红原县', value: 52 },
{ fromName: '石渠县', toName: '红原县', value: 48 }
]
},
slaughter: {
title: '红原牦牛出栏分布',
subtitle: '各乡镇出栏数量统计',
center: [102.55, 33.0],
zoom: 7,
points: [
{ name: '红原县', value: [102.55, 33.00, 100], symbolSize: 45 },
{ name: '邛溪镇', value: [102.58, 33.03, 85], symbolSize: 38 },
{ name: '刷经寺镇', value: [102.48, 32.85, 72], symbolSize: 34 },
{ name: '安曲镇', value: [102.75, 33.15, 68], symbolSize: 32 },
{ name: '瓦切镇', value: [102.88, 33.28, 65], symbolSize: 31 },
{ name: '龙日镇', value: [102.65, 33.18, 58], symbolSize: 28 },
{ name: '色地镇', value: [102.95, 33.35, 55], symbolSize: 27 },
{ name: '麦洼乡', value: [102.42, 32.95, 48], symbolSize: 24 },
{ name: '阿木乡', value: [102.72, 32.88, 45], symbolSize: 23 },
{ name: '江茸乡', value: [102.55, 32.92, 42], symbolSize: 22 },
{ name: '查尔玛乡', value: [102.82, 33.05, 38], symbolSize: 21 },
{ name: '红原牧场', value: [102.48, 33.08, 75], symbolSize: 35 }
],
lines: [
{ fromName: '邛溪镇', toName: '红原县', value: 85 },
{ fromName: '刷经寺镇', toName: '红原县', value: 72 },
{ fromName: '安曲镇', toName: '红原县', value: 68 },
{ fromName: '瓦切镇', toName: '红原县', value: 65 },
{ fromName: '龙日镇', toName: '红原县', value: 58 },
{ fromName: '色地镇', toName: '红原县', value: 55 },
{ fromName: '麦洼乡', toName: '红原县', value: 48 },
{ fromName: '阿木乡', toName: '红原县', value: 45 },
{ fromName: '江茸乡', toName: '红原县', value: 42 },
{ fromName: '红原牧场', toName: '红原县', value: 75 }
]
}
};
const getPointData = (data) => {
return data.points.map(item => ({
name: item.name,
value: item.value
}));
};
const getCorePoint = (data) => {
return data.points.filter(p => p.name === '红原县').map(item => ({
name: item.name,
value: item.value
}));
};
const getLineData = (data) => {
const lines = [];
data.lines.forEach(item => {
const fromPoint = data.points.find(p => p.name === item.fromName);
const toPoint = data.points.find(p => p.name === item.toName);
if (fromPoint && toPoint) {
lines.push({
fromName: item.fromName,
toName: item.toName,
coords: [fromPoint.value, toPoint.value],
lineStyle: {
width: Math.max(1, item.value / 20),
opacity: 0.5
}
});
}
});
return lines;
};
const initChart = async () => {
if (!chartRef.value) return;
chart = echarts.init(chartRef.value);
try {
const response = await fetch('/data/china.json');
chinaMapData = await response.json();
echarts.registerMap('china', chinaMapData);
updateChart();
} catch (error) {
console.error('加载地图数据失败:', error);
}
window.addEventListener('resize', handleResize);
};
const updateChart = () => {
if (!chart) return;
const data = mockData[activeMapType.value];
const option = {
backgroundColor: '#f5f7fa',
title: {
text: data.title,
subtext: data.subtitle,
left: 'center',
top: 20,
textStyle: {
fontSize: 20,
fontWeight: 'bold',
color: '#1e293b'
},
subtextStyle: {
fontSize: 14,
color: '#64748b'
}
},
tooltip: {
trigger: 'item',
formatter: (params) => {
if (params.seriesType === 'lines') {
return `${params.data.fromName}${params.data.toName}<br/>流量: ${params.data.value}`;
}
return `${params.name}<br/>数量: ${params.value[2]}`;
}
},
geo: {
map: 'china',
roam: true,
center: data.center,
zoom: data.zoom,
itemStyle: {
areaColor: '#e2e8f0',
borderColor: '#94a3b8'
},
emphasis: {
itemStyle: {
areaColor: '#cbd5e1'
},
label: {
show: false
}
},
select: {
itemStyle: {
areaColor: '#bfdbfe'
}
}
},
series: [
{
type: 'lines',
coordinateSystem: 'geo',
polyline: false,
data: getLineData(data),
lineStyle: {
color: '#22c55e',
width: 2,
curveness: 0.15,
opacity: 0.5
},
effect: {
show: true,
period: 4,
trailLength: 0.3,
symbol: 'arrow',
symbolSize: 8,
color: '#22c55e'
}
},
{
name: '核心',
type: 'effectScatter',
coordinateSystem: 'geo',
data: getCorePoint(data),
symbolSize: (val) => val[2] / 2,
itemStyle: {
color: '#ef4444',
shadowBlur: 20,
shadowColor: '#ef4444'
},
rippleEffect: {
brushType: 'stroke',
scale: 3
},
z: 10
},
{
name: '分布点',
type: 'scatter',
coordinateSystem: 'geo',
data: getPointData(data).filter(p => p.name !== '红原县'),
symbolSize: (val) => val[2] / 2,
itemStyle: {
color: '#22c55e',
shadowBlur: 10,
shadowColor: 'rgba(34, 197, 94, 0.5)'
},
label: {
show: true,
formatter: '{b}',
position: 'right',
fontSize: 10,
color: '#1e293b',
distance: 8
},
z: 5
}
]
};
chart.setOption(option, true);
};
const switchMapType = (type) => {
activeMapType.value = type;
updateChart();
};
const handleResize = () => {
if (chart) {
chart.resize();
}
};
watch(activeMapType, () => {
updateChart();
});
onMounted(() => {
initChart();
});
onBeforeUnmount(() => {
window.removeEventListener('resize', handleResize);
if (chart) {
chart.dispose();
}
});
</script>
<style scoped>
.flow-monitor-container {
position: relative;
width: 100%;
height: 100%;
min-height: 600px;
background: linear-gradient(135deg, #f0f4f8 0%, #e2e8f0 100%);
border-radius: 12px;
overflow: hidden;
}
.chart-container {
width: 100%;
height: 100%;
}
.map-controls {
position: absolute;
top: 80px;
left: 20px;
display: flex;
flex-direction: column;
gap: 16px;
z-index: 10;
}
.control-group {
background: rgba(255, 255, 255, 0.95);
border-radius: 10px;
padding: 16px;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.08);
}
.control-label {
display: block;
font-size: 12px;
font-weight: 600;
color: #64748b;
margin-bottom: 10px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.button-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.map-btn {
padding: 10px 18px;
background: #f8fafc;
border: 2px solid #e2e8f0;
border-radius: 8px;
color: #475569;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.25s ease;
text-align: left;
}
.map-btn:hover {
background: #f1f5f9;
border-color: #cbd5e1;
color: #1e293b;
}
.map-btn.active {
background: linear-gradient(135deg, #22c55e 0%, #16a34a 100%);
border-color: #16a34a;
color: white;
box-shadow: 0 4px 12px rgba(34, 197, 94, 0.35);
}
.legend {
background: rgba(255, 255, 255, 0.95);
border-radius: 10px;
padding: 14px;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.08);
}
.legend-item {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 0;
font-size: 13px;
color: #475569;
}
.legend-dot {
width: 12px;
height: 12px;
border-radius: 50%;
}
.legend-dot.red {
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
box-shadow: 0 2px 8px rgba(239, 68, 68, 0.4);
}
.legend-dot.green {
background: linear-gradient(135deg, #22c55e 0%, #16a34a 100%);
box-shadow: 0 2px 8px rgba(34, 197, 94, 0.4);
}
</style>

@ -0,0 +1,466 @@
<template>
<div class="market-analysis">
<el-row :gutter="20">
<el-col :span="12">
<el-card shadow="hover">
<template #header>
<div style="display: flex; justify-content: space-between; align-items: center;">
<span style="font-size: 16px; font-weight: 600;">
<span style="margin-right: 8px;">📊</span>销售结构分析
</span>
<el-radio-group v-model="salesStructureType" size="small">
<el-radio-button label="price">价格区间</el-radio-button>
<el-radio-button label="region">区域分布</el-radio-button>
</el-radio-group>
</div>
</template>
<div class="chart-container">
<div ref="salesStructureChartRef" style="width: 100%; height: 300px;"></div>
</div>
</el-card>
</el-col>
<el-col :span="12">
<el-card shadow="hover">
<template #header>
<div style="display: flex; justify-content: space-between; align-items: center;">
<span style="font-size: 16px; font-weight: 600;">
<span style="margin-right: 8px;">📈</span>价格趋势分析
</span>
<el-radio-group v-model="pricePeriod" size="small">
<el-radio-button label="day"></el-radio-button>
<el-radio-button label="week"></el-radio-button>
<el-radio-button label="month"></el-radio-button>
</el-radio-group>
</div>
</template>
<div class="chart-container">
<div ref="priceTrendChartRef" style="width: 100%; height: 300px;"></div>
</div>
</el-card>
</el-col>
</el-row>
<el-row :gutter="20" style="margin-top: 20px;">
<el-col :span="24">
<el-card shadow="hover">
<template #header>
<div style="display: flex; justify-content: space-between; align-items: center;">
<span style="font-size: 16px; font-weight: 600;">
<span style="margin-right: 8px;">👥</span>客户来源分析
</span>
<el-select v-model="customerYear" size="small" style="width: 120px">
<el-option label="2023年" value="2023"></el-option>
<el-option label="2024年" value="2024"></el-option>
<el-option label="2025年" value="2025"></el-option>
</el-select>
</div>
</template>
<div class="chart-container">
<div ref="customerSourceChartRef" style="width: 100%; height: 350px;"></div>
</div>
</el-card>
</el-col>
</el-row>
</div>
</template>
<script setup>
import { ref, watch, onBeforeUnmount, onMounted } from 'vue';
import * as echarts from 'echarts';
const props = defineProps({
marketAnalysisData: {
type: Object,
default: null
}
});
const salesStructureType = ref('price');
const pricePeriod = ref('day');
const customerYear = ref('2025');
const salesStructureChartRef = ref(null);
const priceTrendChartRef = ref(null);
const customerSourceChartRef = ref(null);
let salesStructureChart = null;
let priceTrendChart = null;
let customerSourceChart = null;
const initCharts = () => {
if (props.marketAnalysisData) {
initSalesStructureChart();
initPriceTrendChart();
initCustomerSourceChart();
}
};
const initSalesStructureChart = () => {
if (salesStructureChartRef.value && props.marketAnalysisData.salesByPriceRange.length > 0) {
salesStructureChart = echarts.init(salesStructureChartRef.value);
updateSalesStructureChart();
}
};
const updateSalesStructureChart = () => {
if (salesStructureChart) {
const data = salesStructureType.value === 'price'
? props.marketAnalysisData.salesByPriceRange
: props.marketAnalysisData.salesByRegion;
const title = salesStructureType.value === 'price' ? '价格区间销售占比' : '区域销售占比';
const colors = ['#52c41a', '#1890ff', '#faad14', '#722ed1', '#eb2f96', '#13c2c2', '#fa541c', '#2f54eb'];
const option = {
tooltip: {
trigger: 'item',
formatter: '{b}: {c}% ({d}%)'
},
legend: {
orient: 'vertical',
right: '5%',
top: 'center',
textStyle: {
color: '#333333',
fontSize: 13
}
},
series: [
{
name: title,
type: 'pie',
radius: ['40%', '70%'],
center: ['35%', '50%'],
avoidLabelOverlap: true,
itemStyle: {
borderRadius: 8,
borderColor: '#ffffff',
borderWidth: 2
},
label: {
show: true,
formatter: '{b}\n{c}%',
fontSize: 12,
color: '#333333'
},
emphasis: {
label: {
show: true,
fontSize: 14,
fontWeight: 'bold'
},
itemStyle: {
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: 'rgba(0, 0, 0, 0.3)'
}
},
labelLine: {
show: true,
length: 15,
length2: 10
},
data: data.map((item, index) => ({
...item,
itemStyle: {
color: colors[index % colors.length]
}
}))
}
]
};
salesStructureChart.setOption(option);
}
};
const initPriceTrendChart = () => {
if (priceTrendChartRef.value && props.marketAnalysisData.priceTrend) {
priceTrendChart = echarts.init(priceTrendChartRef.value);
updatePriceTrendChart();
}
};
const updatePriceTrendChart = () => {
if (priceTrendChart && props.marketAnalysisData.priceTrend[pricePeriod.value]) {
const data = props.marketAnalysisData.priceTrend[pricePeriod.value];
const option = {
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'cross'
}
},
legend: {
data: ['活牛价格', '鲜肉价格'],
textStyle: {
color: '#333333',
fontSize: 13
},
top: 10
},
grid: {
left: '3%',
right: '4%',
top: 60,
bottom: '3%',
containLabel: true
},
xAxis: {
type: 'category',
data: data.dates,
axisLabel: {
color: '#333333',
fontSize: 12
},
axisLine: {
lineStyle: {
color: '#cccccc'
}
}
},
yAxis: [
{
type: 'value',
name: '活牛价格',
position: 'left',
axisLabel: {
color: '#333333',
formatter: '{value} 元/公斤'
},
axisLine: {
lineStyle: {
color: '#52c41a'
}
},
splitLine: {
lineStyle: {
color: '#e0e0e0',
type: 'dashed'
}
}
},
{
type: 'value',
name: '鲜肉价格',
position: 'right',
axisLabel: {
color: '#333333',
formatter: '{value} 元/公斤'
},
axisLine: {
lineStyle: {
color: '#1890ff'
}
},
splitLine: {
show: false
}
}
],
series: [
{
name: '活牛价格',
type: 'line',
data: data.cattle,
smooth: true,
symbol: 'circle',
symbolSize: 8,
itemStyle: {
color: '#52c41a'
},
lineStyle: {
width: 3
},
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: 'rgba(82, 196, 26, 0.3)' },
{ offset: 1, color: 'rgba(82, 196, 26, 0.05)' }
])
}
},
{
name: '鲜肉价格',
type: 'line',
yAxisIndex: 1,
data: data.meat,
smooth: true,
symbol: 'circle',
symbolSize: 8,
itemStyle: {
color: '#1890ff'
},
lineStyle: {
width: 3
},
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: 'rgba(24, 144, 255, 0.3)' },
{ offset: 1, color: 'rgba(24, 144, 255, 0.05)' }
])
}
}
]
};
priceTrendChart.setOption(option);
}
};
const initCustomerSourceChart = () => {
if (customerSourceChartRef.value && props.marketAnalysisData.customerSource.regions) {
customerSourceChart = echarts.init(customerSourceChartRef.value);
updateCustomerSourceChart();
}
};
const updateCustomerSourceChart = () => {
if (customerSourceChart && props.marketAnalysisData.customerSource[customerYear.value]) {
const option = {
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow'
}
},
legend: {
data: ['采购商数量'],
textStyle: {
color: '#333333',
fontSize: 13
},
top: 10
},
grid: {
left: '3%',
right: '4%',
top: 60,
bottom: '3%',
containLabel: true
},
xAxis: {
type: 'category',
data: props.marketAnalysisData.customerSource.regions,
axisLabel: {
color: '#333333',
fontSize: 12,
rotate: 30
},
axisLine: {
lineStyle: {
color: '#cccccc'
}
}
},
yAxis: {
type: 'value',
name: '采购商数量',
axisLabel: {
color: '#333333',
formatter: '{value} 人'
},
axisLine: {
lineStyle: {
color: '#cccccc'
}
},
splitLine: {
lineStyle: {
color: '#e0e0e0',
type: 'dashed'
}
}
},
series: [
{
name: '采购商数量',
type: 'bar',
data: props.marketAnalysisData.customerSource[customerYear.value],
barWidth: '50%',
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: '#52c41a' },
{ offset: 1, color: '#389e0d' }
]),
borderRadius: [4, 4, 0, 0]
},
emphasis: {
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: '#73d13d' },
{ offset: 1, color: '#52c41a' }
])
}
},
label: {
show: true,
position: 'top',
formatter: '{c}',
fontSize: 11,
color: '#333333'
}
}
]
};
customerSourceChart.setOption(option);
}
};
const handleResize = () => {
if (salesStructureChart) {
salesStructureChart.resize();
}
if (priceTrendChart) {
priceTrendChart.resize();
}
if (customerSourceChart) {
customerSourceChart.resize();
}
};
watch(salesStructureType, () => {
updateSalesStructureChart();
});
watch(pricePeriod, () => {
updatePriceTrendChart();
});
watch(customerYear, () => {
updateCustomerSourceChart();
});
watch(() => props.marketAnalysisData, () => {
initCharts();
}, { deep: true });
onMounted(() => {
initCharts();
window.addEventListener('resize', handleResize);
});
onBeforeUnmount(() => {
if (salesStructureChart) {
salesStructureChart.dispose();
}
if (priceTrendChart) {
priceTrendChart.dispose();
}
if (customerSourceChart) {
customerSourceChart.dispose();
}
window.removeEventListener('resize', handleResize);
});
</script>
<style scoped>
.market-analysis {
width: 100%;
}
.chart-container {
width: 100%;
height: 350px;
}
</style>

File diff suppressed because it is too large Load Diff

@ -0,0 +1,9 @@
import { createApp } from 'vue'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import './style.css'
import App from './App.vue'
const app = createApp(App)
app.use(ElementPlus)
app.mount('#app')

@ -0,0 +1,449 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: Arial, sans-serif;
background-color: #f5f7fa;
color: #333333;
}
#app {
width: 100vw;
height: 100vh;
overflow: hidden;
}
.header {
height: 80px;
background-color: #1e40af;
color: white;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.header-title {
font-size: 24px;
font-weight: bold;
}
.tabs {
display: flex;
}
.tab-item {
padding: 12px 24px;
margin-left: 10px;
background-color: rgba(255, 255, 255, 0.1);
border-radius: 4px;
cursor: pointer;
transition: all 0.3s;
color: rgba(255, 255, 255, 0.9);
font-size: 16px;
}
.tab-item.active {
background-color: #22c55e;
color: white;
}
.tab-item:hover {
background-color: rgba(255, 255, 255, 0.15);
color: white;
}
.tab-item.active:hover {
background-color: #22c55e;
}
.content {
height: calc(100vh - 80px);
padding: 0;
background-color: #f5f7fa;
overflow: hidden;
}
.tab-content {
background-color: transparent;
padding: 0;
border-radius: 0;
box-shadow: none;
border: none;
height: 100%;
overflow: hidden;
}
.section {
margin-bottom: 30px;
}
.section-title {
font-size: 18px;
font-weight: bold;
margin-bottom: 20px;
color: #333333;
}
.section-subtitle {
font-size: 16px;
font-weight: bold;
margin: 15px 0 10px 0;
color: #666666;
}
/* 卡片式布局 */
.card-section {
margin-bottom: 25px;
background-color: #ffffff;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
overflow: hidden;
border: 1px solid #e0e0e0;
}
.card-title {
font-size: 16px;
font-weight: bold;
padding: 15px 20px;
margin: 0;
background-color: #f8f9fa;
color: #333333;
border-bottom: 1px solid #e0e0e0;
}
.card-content {
padding: 20px;
}
/* 图表行布局 */
.charts-row {
display: flex;
gap: 20px;
margin-bottom: 25px;
flex-wrap: wrap;
}
/* 图表卡片 */
.chart-card {
flex: 1;
min-width: 45%;
margin-bottom: 0;
}
/* 调整图表容器高度 */
.chart-card .chart-container {
height: 350px;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin-bottom: 20px;
}
.stat-card {
background-color: #ffffff;
padding: 20px;
border-radius: 8px;
text-align: center;
border: 1px solid #e0e0e0;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
}
.stat-value {
font-size: 32px;
font-weight: bold;
color: #22c55e;
margin-bottom: 8px;
}
.stat-label {
font-size: 18px;
color: #666666;
}
.chart-container {
height: 450px;
margin: 20px 0;
}
.chart-container > div {
background-color: #ffffff !important;
color: #333333 !important;
}
.map-container {
width: 100%;
height: 100%;
background-color: #ffffff;
border-radius: 0;
border: none;
overflow: hidden;
display: block;
}
#map {
width: 100%;
height: 100%;
}
/* 交易流向监测布局 */
.flow-monitor-container {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
}
.map-switcher {
position: absolute;
top: 20px;
left: 20px;
display: flex;
gap: 10px;
z-index: 1000;
padding: 0;
margin: 0;
}
.map-switch-btn {
padding: 12px 24px;
background-color: rgba(255, 255, 255, 0.9);
border: 1px solid rgba(0, 0, 0, 0.2);
border-radius: 6px;
color: #333333;
cursor: pointer;
transition: all 0.3s;
font-size: 14px;
backdrop-filter: blur(5px);
}
.map-switch-btn:hover {
background-color: rgba(248, 249, 250, 0.9);
border-color: rgba(0, 0, 0, 0.3);
}
.map-switch-btn.active {
background-color: rgba(34, 197, 94, 0.9);
color: white;
border-color: rgba(34, 197, 94, 0.9);
}
.table-container {
overflow-x: auto;
margin: 20px 0;
}
.data-table {
width: 100%;
border-collapse: collapse;
background-color: #ffffff;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
}
.data-table th,
.data-table td {
padding: 12px;
text-align: left;
border-bottom: 1px solid #e0e0e0;
color: #333333;
}
.data-table th {
background-color: #f8f9fa;
font-weight: bold;
color: #333333;
}
.data-table tr:hover {
background-color: rgba(0, 0, 0, 0.02);
}
.period-selector {
margin-bottom: 20px;
}
.period-btn {
padding: 8px 16px;
margin-right: 10px;
background-color: #f8f9fa;
border: 1px solid #e0e0e0;
border-radius: 4px;
cursor: pointer;
transition: all 0.3s;
color: #333333;
}
.period-btn:hover {
background-color: #e9ecef;
}
.period-btn.active {
background-color: #22c55e;
color: white;
border-color: #22c55e;
}
.environment-monitor {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 15px;
margin: 20px 0;
}
.env-item {
background-color: #ffffff;
padding: 15px;
border-radius: 8px;
text-align: center;
border: 1px solid #e0e0e0;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
}
.env-value {
font-size: 20px;
font-weight: bold;
color: #333333;
margin-bottom: 5px;
}
.env-label {
font-size: 14px;
color: #666666;
}
.env-item.warning {
border-color: #faad14;
background-color: rgba(250, 173, 20, 0.05);
color: #faad14;
}
.env-item.error {
border-color: #f5222d;
background-color: rgba(245, 34, 45, 0.05);
color: #f5222d;
}
.env-item.warning .env-value,
.env-item.warning .env-label {
color: #faad14;
}
.env-item.error .env-value,
.env-item.error .env-label {
color: #f5222d;
}
.video-monitors {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 20px;
margin: 20px 0;
}
.video-monitor {
background-color: #000;
height: 200px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 14px;
border: 1px solid #e0e0e0;
}
.supply-list {
margin: 20px 0;
background-color: #ffffff;
border-radius: 8px;
overflow: hidden;
border: 1px solid #e0e0e0;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
}
.supply-item {
padding: 15px;
border-bottom: 1px solid #e0e0e0;
display: flex;
justify-content: space-between;
align-items: center;
color: #333333;
}
.supply-item:hover {
background-color: rgba(0, 0, 0, 0.02);
}
.supply-item:last-child {
border-bottom: none;
}
.supply-info {
display: flex;
gap: 20px;
}
.year-selector {
display: flex;
align-items: center;
gap: 10px;
}
.year-selector label {
color: #666666;
font-size: 14px;
}
.year-select {
padding: 8px 12px;
background-color: #ffffff;
border: 1px solid #e0e0e0;
border-radius: 4px;
color: #333333;
font-size: 14px;
cursor: pointer;
outline: none;
transition: all 0.3s;
}
.year-select:hover {
border-color: #22c55e;
}
.year-select:focus {
border-color: #22c55e;
box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.2);
}
.year-select option {
background-color: #ffffff;
color: #333333;
padding: 8px;
}
.card-title {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 16px;
font-weight: bold;
padding: 15px 20px;
margin: 0;
background-color: #f8f9fa;
color: #333333;
border-bottom: 1px solid #e0e0e0;
}
.card-title .year-selector {
margin-bottom: 0;
}

@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [vue()],
})
Loading…
Cancel
Save