commit
b5099e5c4a
@ -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> |
||||||
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,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…
Reference in new issue