first commit
This commit is contained in:
15
frontend/index.html
Normal file
15
frontend/index.html
Normal file
@@ -0,0 +1,15 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>农业股票数据分析系统</title>
|
||||
<meta name="description" content="基于Spark大数据处理的农业股票市场监控与分析平台" />
|
||||
<meta name="keywords" content="农业股票,大数据,Spark,数据分析,股票监控,Vue.js" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
3878
frontend/package-lock.json
generated
Normal file
3878
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
41
frontend/package.json
Normal file
41
frontend/package.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "agricultural-stock-frontend",
|
||||
"version": "1.0.0",
|
||||
"description": "农业领域上市公司行情可视化监控平台前端",
|
||||
"author": "Agricultural Stock Platform Team",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --fix --ignore-path .gitignore",
|
||||
"format": "prettier --write src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.3.0",
|
||||
"vue-router": "^4.2.0",
|
||||
"vuex": "^4.0.2",
|
||||
"element-plus": "^2.3.0",
|
||||
"echarts": "^5.4.0",
|
||||
"axios": "^1.4.0",
|
||||
"dayjs": "^1.11.0",
|
||||
"lodash": "^4.17.21",
|
||||
"sockjs-client": "^1.6.1",
|
||||
"stompjs": "^2.3.3",
|
||||
"@element-plus/icons-vue": "^2.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^4.2.0",
|
||||
"vite": "^4.3.0",
|
||||
"eslint": "^8.39.0",
|
||||
"eslint-plugin-vue": "^9.11.0",
|
||||
"prettier": "^2.8.8",
|
||||
"sass": "^1.62.0",
|
||||
"unplugin-auto-import": "^0.16.0",
|
||||
"unplugin-vue-components": "^0.25.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.0.0",
|
||||
"npm": ">=8.0.0"
|
||||
}
|
||||
}
|
||||
2
frontend/public/favicon.ico
Normal file
2
frontend/public/favicon.ico
Normal file
@@ -0,0 +1,2 @@
|
||||
# Favicon placeholder
|
||||
# This file serves as a placeholder to prevent 404 errors for favicon requests
|
||||
203
frontend/src/App.vue
Normal file
203
frontend/src/App.vue
Normal file
@@ -0,0 +1,203 @@
|
||||
<template>
|
||||
<div id="app">
|
||||
<!-- 顶部导航栏 -->
|
||||
<div class="app-header">
|
||||
<div class="header-content">
|
||||
<div class="logo-section">
|
||||
<router-link to="/" class="logo">
|
||||
<el-icon><DataAnalysis /></el-icon>
|
||||
<span>农业股票分析平台</span>
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<!-- 导航菜单 -->
|
||||
<div class="nav-menu">
|
||||
<el-menu
|
||||
:default-active="$route.path"
|
||||
mode="horizontal"
|
||||
:ellipsis="false"
|
||||
router
|
||||
>
|
||||
<el-menu-item index="/">
|
||||
<el-icon><House /></el-icon>
|
||||
<span>仪表盘</span>
|
||||
</el-menu-item>
|
||||
|
||||
<el-menu-item index="/search">
|
||||
<el-icon><Search /></el-icon>
|
||||
<span>股票搜索</span>
|
||||
</el-menu-item>
|
||||
|
||||
<el-menu-item index="/rankings">
|
||||
<el-icon><TrendCharts /></el-icon>
|
||||
<span>排行榜</span>
|
||||
</el-menu-item>
|
||||
|
||||
<el-menu-item index="/market-analysis">
|
||||
<el-icon><Pie /></el-icon>
|
||||
<span>市场分析</span>
|
||||
</el-menu-item>
|
||||
|
||||
<el-menu-item index="/health">
|
||||
<el-icon><Monitor /></el-icon>
|
||||
<span>系统监控</span>
|
||||
</el-menu-item>
|
||||
</el-menu>
|
||||
</div>
|
||||
|
||||
<!-- 右侧操作区 -->
|
||||
<div class="header-actions">
|
||||
<el-button type="primary" size="small" @click="refreshPage">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
刷新
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 主内容区域 -->
|
||||
<div class="app-main">
|
||||
<router-view />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'App',
|
||||
methods: {
|
||||
refreshPage() {
|
||||
window.location.reload()
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
#app {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
background: white;
|
||||
border-bottom: 1px solid #e6e6e6;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
z-index: 1000;
|
||||
|
||||
.header-content {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 20px;
|
||||
height: 60px;
|
||||
}
|
||||
|
||||
.logo-section {
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: #303133;
|
||||
text-decoration: none;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
|
||||
&:hover {
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
.el-icon {
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.nav-menu {
|
||||
flex: 1;
|
||||
margin: 0 40px;
|
||||
|
||||
:deep(.el-menu) {
|
||||
border-bottom: none;
|
||||
|
||||
.el-menu-item {
|
||||
border-bottom: 2px solid transparent;
|
||||
|
||||
&:hover {
|
||||
background-color: #f5f7fa;
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
border-bottom-color: #409eff;
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
.el-icon {
|
||||
margin-right: 6px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.app-main {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
background-color: #f5f7fa;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
// 响应式设计
|
||||
@media (max-width: 768px) {
|
||||
.app-header {
|
||||
.header-content {
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.nav-menu {
|
||||
margin: 0 20px;
|
||||
|
||||
:deep(.el-menu) {
|
||||
.el-menu-item {
|
||||
padding: 0 10px;
|
||||
|
||||
span {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.logo-section {
|
||||
.logo {
|
||||
span {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
33
frontend/src/api/market.js
Normal file
33
frontend/src/api/market.js
Normal file
@@ -0,0 +1,33 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
/**
|
||||
* 市场分析数据API
|
||||
*/
|
||||
|
||||
// 获取最新的市场分析数据
|
||||
export function getLatestMarketAnalysis() {
|
||||
return request({
|
||||
url: '/api/market/latest',
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
// 获取指定日期范围的市场分析数据
|
||||
export function getMarketAnalysisByDateRange(startDate, endDate) {
|
||||
return request({
|
||||
url: '/api/market/range',
|
||||
method: 'get',
|
||||
params: {
|
||||
startDate,
|
||||
endDate
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 获取最近N天的市场分析数据
|
||||
export function getRecentMarketAnalysis(days) {
|
||||
return request({
|
||||
url: `/api/market/recent/${days}`,
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
105
frontend/src/api/stock.js
Normal file
105
frontend/src/api/stock.js
Normal file
@@ -0,0 +1,105 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
/**
|
||||
* 股票数据API
|
||||
*/
|
||||
|
||||
// 获取实时股票数据
|
||||
export function getRealtimeStockData() {
|
||||
return request({
|
||||
url: '/api/stock/realtime',
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
// 获取股票历史数据
|
||||
export function getStockHistory(stockCode, startDate, endDate) {
|
||||
return request({
|
||||
url: `/api/stock/history/${stockCode}`,
|
||||
method: 'get',
|
||||
params: {
|
||||
startDate,
|
||||
endDate
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 获取涨幅排行榜
|
||||
export function getGrowthRanking(limit = 10) {
|
||||
return request({
|
||||
url: '/api/stock/ranking/growth',
|
||||
method: 'get',
|
||||
params: { limit }
|
||||
})
|
||||
}
|
||||
|
||||
// 获取市值排行榜
|
||||
export function getMarketCapRanking(limit = 10) {
|
||||
return request({
|
||||
url: '/api/stock/ranking/market-cap',
|
||||
method: 'get',
|
||||
params: { limit }
|
||||
})
|
||||
}
|
||||
|
||||
// 获取成交量排行榜
|
||||
export function getVolumeRanking(limit = 10) {
|
||||
return request({
|
||||
url: '/api/stock/ranking/volume',
|
||||
method: 'get',
|
||||
params: { limit }
|
||||
})
|
||||
}
|
||||
|
||||
// 获取股票趋势分析
|
||||
export function getStockTrend(stockCode, days = 30) {
|
||||
return request({
|
||||
url: `/api/stock/trend/${stockCode}`,
|
||||
method: 'get',
|
||||
params: { days }
|
||||
})
|
||||
}
|
||||
|
||||
// 获取市场综合分析
|
||||
export function getMarketAnalysis() {
|
||||
return request({
|
||||
url: '/api/stock/market-analysis',
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
// 获取股票预测数据
|
||||
export function getStockPrediction(stockCode, days = 7) {
|
||||
return request({
|
||||
url: `/api/stock/prediction/${stockCode}`,
|
||||
method: 'get',
|
||||
params: { days }
|
||||
})
|
||||
}
|
||||
|
||||
// 搜索股票
|
||||
export function searchStocks(keyword) {
|
||||
return request({
|
||||
url: '/api/stock/search',
|
||||
method: 'get',
|
||||
params: { keyword }
|
||||
})
|
||||
}
|
||||
|
||||
// 保存股票数据
|
||||
export function saveStockData(stockData) {
|
||||
return request({
|
||||
url: '/api/stock/save',
|
||||
method: 'post',
|
||||
data: stockData
|
||||
})
|
||||
}
|
||||
|
||||
// 批量保存股票数据
|
||||
export function batchSaveStockData(stockDataList) {
|
||||
return request({
|
||||
url: '/api/stock/batch-save',
|
||||
method: 'post',
|
||||
data: stockDataList
|
||||
})
|
||||
}
|
||||
560
frontend/src/components/MarketOverview.vue
Normal file
560
frontend/src/components/MarketOverview.vue
Normal file
@@ -0,0 +1,560 @@
|
||||
<template>
|
||||
<div class="market-overview">
|
||||
<!-- 市场统计卡片 -->
|
||||
<el-row :gutter="20" class="stats-cards">
|
||||
<el-col :span="6">
|
||||
<el-card class="stat-card up">
|
||||
<div class="stat-content">
|
||||
<div class="stat-icon">
|
||||
<el-icon><TrendCharts /></el-icon>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">{{ marketStats.upCount }}</div>
|
||||
<div class="stat-label">上涨股票</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-card class="stat-card down">
|
||||
<div class="stat-content">
|
||||
<div class="stat-icon">
|
||||
<el-icon><Bottom /></el-icon>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">{{ marketStats.downCount }}</div>
|
||||
<div class="stat-label">下跌股票</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-card class="stat-card volume">
|
||||
<div class="stat-content">
|
||||
<div class="stat-icon">
|
||||
<el-icon><Histogram /></el-icon>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">{{ formatVolume(marketStats.totalVolume) }}</div>
|
||||
<div class="stat-label">总成交量</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-card class="stat-card market-cap">
|
||||
<div class="stat-content">
|
||||
<div class="stat-icon">
|
||||
<el-icon><Money /></el-icon>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">{{ formatMarketCap(marketStats.totalMarketCap) }}</div>
|
||||
<div class="stat-label">总市值</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 图表区域 -->
|
||||
<el-row :gutter="20" class="charts-row">
|
||||
<el-col :span="12">
|
||||
<el-card class="chart-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>市场指数走势</span>
|
||||
<el-button-group size="small">
|
||||
<el-button
|
||||
v-for="period in timePeriods"
|
||||
:key="period.value"
|
||||
:type="selectedPeriod === period.value ? 'primary' : ''"
|
||||
@click="changePeriod(period.value)"
|
||||
>
|
||||
{{ period.label }}
|
||||
</el-button>
|
||||
</el-button-group>
|
||||
</div>
|
||||
</template>
|
||||
<div ref="indexChart" class="chart-container"></div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-card class="chart-card">
|
||||
<template #header>
|
||||
<span>涨跌分布</span>
|
||||
</template>
|
||||
<div ref="distributionChart" class="chart-container"></div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="20" class="charts-row">
|
||||
<el-col :span="12">
|
||||
<el-card class="chart-card">
|
||||
<template #header>
|
||||
<span>行业热力图</span>
|
||||
</template>
|
||||
<div ref="heatmapChart" class="chart-container"></div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-card class="chart-card">
|
||||
<template #header>
|
||||
<span>成交量分析</span>
|
||||
</template>
|
||||
<div ref="volumeChart" class="chart-container"></div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, nextTick } from 'vue'
|
||||
import * as echarts from 'echarts'
|
||||
import { stockApi } from '@/api/stock'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
// 响应式数据
|
||||
const marketStats = ref({
|
||||
upCount: 0,
|
||||
downCount: 0,
|
||||
totalVolume: 0,
|
||||
totalMarketCap: 0
|
||||
})
|
||||
|
||||
const selectedPeriod = ref('1d')
|
||||
const timePeriods = [
|
||||
{ label: '1日', value: '1d' },
|
||||
{ label: '1周', value: '1w' },
|
||||
{ label: '1月', value: '1m' },
|
||||
{ label: '3月', value: '3m' }
|
||||
]
|
||||
|
||||
// 图表引用
|
||||
const indexChart = ref(null)
|
||||
const distributionChart = ref(null)
|
||||
const heatmapChart = ref(null)
|
||||
const volumeChart = ref(null)
|
||||
|
||||
// 图表实例
|
||||
let indexChartInstance = null
|
||||
let distributionChartInstance = null
|
||||
let heatmapChartInstance = null
|
||||
let volumeChartInstance = null
|
||||
|
||||
// 格式化成交量
|
||||
const formatVolume = (volume) => {
|
||||
if (volume >= 100000000) {
|
||||
return (volume / 100000000).toFixed(1) + '亿'
|
||||
} else if (volume >= 10000) {
|
||||
return (volume / 10000).toFixed(1) + '万'
|
||||
}
|
||||
return volume.toString()
|
||||
}
|
||||
|
||||
// 格式化市值
|
||||
const formatMarketCap = (marketCap) => {
|
||||
if (marketCap >= 1000000000000) {
|
||||
return (marketCap / 1000000000000).toFixed(2) + '万亿'
|
||||
} else if (marketCap >= 100000000) {
|
||||
return (marketCap / 100000000).toFixed(1) + '亿'
|
||||
}
|
||||
return marketCap.toString()
|
||||
}
|
||||
|
||||
// 切换时间周期
|
||||
const changePeriod = (period) => {
|
||||
selectedPeriod.value = period
|
||||
loadIndexData()
|
||||
}
|
||||
|
||||
// 加载市场统计数据
|
||||
const loadMarketStats = async () => {
|
||||
try {
|
||||
const response = await stockApi.getMarketAnalysis()
|
||||
if (response.data) {
|
||||
const overview = response.data.marketOverview
|
||||
marketStats.value = {
|
||||
upCount: overview.upCount || 0,
|
||||
downCount: overview.downCount || 0,
|
||||
totalVolume: overview.totalVolume || 0,
|
||||
totalMarketCap: overview.totalMarketCap || 0
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载市场统计数据失败:', error)
|
||||
ElMessage.error('加载市场数据失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 加载指数数据
|
||||
const loadIndexData = async () => {
|
||||
try {
|
||||
// 模拟指数数据
|
||||
const dates = []
|
||||
const values = []
|
||||
const baseValue = 3200
|
||||
|
||||
for (let i = 29; i >= 0; i--) {
|
||||
const date = new Date()
|
||||
date.setDate(date.getDate() - i)
|
||||
dates.push(date.toISOString().split('T')[0])
|
||||
|
||||
const randomChange = (Math.random() - 0.5) * 100
|
||||
values.push(baseValue + randomChange + i * 2)
|
||||
}
|
||||
|
||||
initIndexChart(dates, values)
|
||||
} catch (error) {
|
||||
console.error('加载指数数据失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化指数图表
|
||||
const initIndexChart = (dates, values) => {
|
||||
if (!indexChartInstance) {
|
||||
indexChartInstance = echarts.init(indexChart.value)
|
||||
}
|
||||
|
||||
const option = {
|
||||
title: {
|
||||
text: '农业股指数',
|
||||
left: 'center',
|
||||
textStyle: { fontSize: 14 }
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
formatter: (params) => {
|
||||
const data = params[0]
|
||||
return `${data.name}<br/>指数: ${data.value.toFixed(2)}`
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
left: '3%',
|
||||
right: '4%',
|
||||
bottom: '3%',
|
||||
containLabel: true
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: dates,
|
||||
axisLabel: {
|
||||
formatter: (value) => {
|
||||
return value.split('-').slice(1).join('/')
|
||||
}
|
||||
}
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
scale: true
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '指数',
|
||||
type: 'line',
|
||||
data: values,
|
||||
smooth: true,
|
||||
symbol: 'circle',
|
||||
symbolSize: 4,
|
||||
lineStyle: {
|
||||
color: '#1890ff',
|
||||
width: 2
|
||||
},
|
||||
itemStyle: {
|
||||
color: '#1890ff'
|
||||
},
|
||||
areaStyle: {
|
||||
color: {
|
||||
type: 'linear',
|
||||
x: 0,
|
||||
y: 0,
|
||||
x2: 0,
|
||||
y2: 1,
|
||||
colorStops: [
|
||||
{ offset: 0, color: 'rgba(24, 144, 255, 0.3)' },
|
||||
{ offset: 1, color: 'rgba(24, 144, 255, 0.1)' }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
indexChartInstance.setOption(option)
|
||||
}
|
||||
|
||||
// 初始化分布图表
|
||||
const initDistributionChart = () => {
|
||||
if (!distributionChartInstance) {
|
||||
distributionChartInstance = echarts.init(distributionChart.value)
|
||||
}
|
||||
|
||||
const option = {
|
||||
title: {
|
||||
text: '涨跌分布',
|
||||
left: 'center',
|
||||
textStyle: { fontSize: 14 }
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: '{a} <br/>{b}: {c} ({d}%)'
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '涨跌分布',
|
||||
type: 'pie',
|
||||
radius: ['50%', '70%'],
|
||||
center: ['50%', '60%'],
|
||||
data: [
|
||||
{ value: marketStats.value.upCount, name: '上涨', itemStyle: { color: '#f5222d' } },
|
||||
{ value: marketStats.value.downCount, name: '下跌', itemStyle: { color: '#52c41a' } },
|
||||
{ value: 20, name: '平盘', itemStyle: { color: '#faad14' } }
|
||||
],
|
||||
label: {
|
||||
formatter: '{b}\n{c}只'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
distributionChartInstance.setOption(option)
|
||||
}
|
||||
|
||||
// 初始化热力图
|
||||
const initHeatmapChart = () => {
|
||||
if (!heatmapChartInstance) {
|
||||
heatmapChartInstance = echarts.init(heatmapChart.value)
|
||||
}
|
||||
|
||||
// 模拟行业数据
|
||||
const industries = ['种植业', '畜牧业', '渔业', '农产品加工', '农业机械', '农业科技']
|
||||
const data = industries.map((industry, index) => ({
|
||||
name: industry,
|
||||
value: Math.random() * 10 - 5,
|
||||
children: []
|
||||
}))
|
||||
|
||||
const option = {
|
||||
title: {
|
||||
text: '行业涨跌幅热力图',
|
||||
left: 'center',
|
||||
textStyle: { fontSize: 14 }
|
||||
},
|
||||
tooltip: {
|
||||
formatter: (params) => {
|
||||
return `${params.name}<br/>涨跌幅: ${params.value.toFixed(2)}%`
|
||||
}
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '行业涨跌幅',
|
||||
type: 'treemap',
|
||||
data: data,
|
||||
label: {
|
||||
show: true,
|
||||
formatter: '{b}\n{c}%'
|
||||
},
|
||||
levels: [
|
||||
{
|
||||
itemStyle: {
|
||||
borderColor: '#777',
|
||||
borderWidth: 0,
|
||||
gapWidth: 1
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
heatmapChartInstance.setOption(option)
|
||||
}
|
||||
|
||||
// 初始化成交量图表
|
||||
const initVolumeChart = () => {
|
||||
if (!volumeChartInstance) {
|
||||
volumeChartInstance = echarts.init(volumeChart.value)
|
||||
}
|
||||
|
||||
// 模拟成交量数据
|
||||
const dates = []
|
||||
const volumes = []
|
||||
|
||||
for (let i = 6; i >= 0; i--) {
|
||||
const date = new Date()
|
||||
date.setDate(date.getDate() - i)
|
||||
dates.push(date.toISOString().split('T')[0])
|
||||
volumes.push(Math.random() * 1000000000 + 500000000)
|
||||
}
|
||||
|
||||
const option = {
|
||||
title: {
|
||||
text: '7日成交量趋势',
|
||||
left: 'center',
|
||||
textStyle: { fontSize: 14 }
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
formatter: (params) => {
|
||||
const data = params[0]
|
||||
return `${data.name}<br/>成交量: ${formatVolume(data.value)}`
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
left: '3%',
|
||||
right: '4%',
|
||||
bottom: '3%',
|
||||
containLabel: true
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: dates,
|
||||
axisLabel: {
|
||||
formatter: (value) => {
|
||||
return value.split('-').slice(1).join('/')
|
||||
}
|
||||
}
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
axisLabel: {
|
||||
formatter: formatVolume
|
||||
}
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '成交量',
|
||||
type: 'bar',
|
||||
data: volumes,
|
||||
itemStyle: {
|
||||
color: '#722ed1'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
volumeChartInstance.setOption(option)
|
||||
}
|
||||
|
||||
// 初始化所有图表
|
||||
const initCharts = async () => {
|
||||
await nextTick()
|
||||
await loadIndexData()
|
||||
initDistributionChart()
|
||||
initHeatmapChart()
|
||||
initVolumeChart()
|
||||
}
|
||||
|
||||
// 刷新数据
|
||||
const refresh = async () => {
|
||||
await loadMarketStats()
|
||||
await initCharts()
|
||||
}
|
||||
|
||||
// 组件挂载
|
||||
onMounted(async () => {
|
||||
await loadMarketStats()
|
||||
await initCharts()
|
||||
|
||||
// 监听窗口大小变化
|
||||
window.addEventListener('resize', () => {
|
||||
indexChartInstance?.resize()
|
||||
distributionChartInstance?.resize()
|
||||
heatmapChartInstance?.resize()
|
||||
volumeChartInstance?.resize()
|
||||
})
|
||||
})
|
||||
|
||||
// 暴露方法给父组件
|
||||
defineExpose({
|
||||
refresh
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.market-overview {
|
||||
.stats-cards {
|
||||
margin-bottom: 20px;
|
||||
|
||||
.stat-card {
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
|
||||
&.up {
|
||||
background: linear-gradient(135deg, #52c41a 0%, #73d13d 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
&.down {
|
||||
background: linear-gradient(135deg, #f5222d 0%, #ff4d4f 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
&.volume {
|
||||
background: linear-gradient(135deg, #722ed1 0%, #9254de 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
&.market-cap {
|
||||
background: linear-gradient(135deg, #1890ff 0%, #40a9ff 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
:deep(.el-card__body) {
|
||||
padding: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.stat-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.stat-icon {
|
||||
font-size: 32px;
|
||||
margin-right: 16px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.stat-info {
|
||||
.stat-value {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
line-height: 1;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 14px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.charts-row {
|
||||
margin-bottom: 20px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.chart-card {
|
||||
border-radius: 8px;
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
height: 300px;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
24
frontend/src/main.js
Normal file
24
frontend/src/main.js
Normal file
@@ -0,0 +1,24 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import store from './store'
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
|
||||
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
|
||||
import './styles/index.scss'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
// 注册所有图标
|
||||
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
||||
app.component(key, component)
|
||||
}
|
||||
|
||||
app.use(ElementPlus, {
|
||||
locale: zhCn,
|
||||
})
|
||||
app.use(store)
|
||||
app.use(router)
|
||||
|
||||
app.mount('#app')
|
||||
75
frontend/src/router/index.js
Normal file
75
frontend/src/router/index.js
Normal file
@@ -0,0 +1,75 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import Dashboard from '@/views/Dashboard.vue'
|
||||
import HealthCheck from '@/views/HealthCheck.vue'
|
||||
import Rankings from '@/views/Rankings.vue'
|
||||
import StockSearch from '@/views/StockSearch.vue'
|
||||
import StockDetail from '@/views/StockDetail.vue'
|
||||
import MarketAnalysis from '@/views/MarketAnalysis.vue'
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/',
|
||||
name: 'Dashboard',
|
||||
component: Dashboard,
|
||||
meta: { title: '仪表盘' }
|
||||
},
|
||||
{
|
||||
path: '/rankings',
|
||||
name: 'Rankings',
|
||||
component: Rankings,
|
||||
meta: { title: '股票排行榜' }
|
||||
},
|
||||
{
|
||||
path: '/search',
|
||||
name: 'StockSearch',
|
||||
component: StockSearch,
|
||||
meta: { title: '股票搜索' }
|
||||
},
|
||||
{
|
||||
path: '/stock/:stockCode',
|
||||
name: 'StockDetail',
|
||||
component: StockDetail,
|
||||
meta: { title: '股票详情' }
|
||||
},
|
||||
{
|
||||
path: '/stock/:stockCode/trend',
|
||||
name: 'StockTrend',
|
||||
component: StockDetail,
|
||||
meta: { title: '股票趋势' }
|
||||
},
|
||||
{
|
||||
path: '/stock/:stockCode/prediction',
|
||||
name: 'StockPrediction',
|
||||
component: StockDetail,
|
||||
meta: { title: '股票预测' }
|
||||
},
|
||||
{
|
||||
path: '/market-analysis',
|
||||
name: 'MarketAnalysis',
|
||||
component: MarketAnalysis,
|
||||
meta: { title: '市场分析' }
|
||||
},
|
||||
{
|
||||
path: '/health',
|
||||
name: 'HealthCheck',
|
||||
component: HealthCheck,
|
||||
meta: { title: '健康检查' }
|
||||
}
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL || '/'),
|
||||
routes
|
||||
})
|
||||
|
||||
// 路由守卫 - 设置页面标题
|
||||
router.beforeEach((to, from, next) => {
|
||||
if (to.meta.title) {
|
||||
document.title = `${to.meta.title} - 农业股票数据分析平台`
|
||||
} else {
|
||||
document.title = '农业股票数据分析平台'
|
||||
}
|
||||
next()
|
||||
})
|
||||
|
||||
export default router
|
||||
36
frontend/src/store/index.js
Normal file
36
frontend/src/store/index.js
Normal file
@@ -0,0 +1,36 @@
|
||||
import { createStore } from 'vuex'
|
||||
|
||||
export default createStore({
|
||||
state: {
|
||||
marketData: {},
|
||||
recentData: [],
|
||||
loading: false
|
||||
},
|
||||
mutations: {
|
||||
SET_MARKET_DATA(state, data) {
|
||||
state.marketData = data
|
||||
},
|
||||
SET_RECENT_DATA(state, data) {
|
||||
state.recentData = data
|
||||
},
|
||||
SET_LOADING(state, loading) {
|
||||
state.loading = loading
|
||||
}
|
||||
},
|
||||
actions: {
|
||||
updateMarketData({ commit }, data) {
|
||||
commit('SET_MARKET_DATA', data)
|
||||
},
|
||||
updateRecentData({ commit }, data) {
|
||||
commit('SET_RECENT_DATA', data)
|
||||
},
|
||||
setLoading({ commit }, loading) {
|
||||
commit('SET_LOADING', loading)
|
||||
}
|
||||
},
|
||||
getters: {
|
||||
marketData: state => state.marketData,
|
||||
recentData: state => state.recentData,
|
||||
loading: state => state.loading
|
||||
}
|
||||
})
|
||||
50
frontend/src/styles/index.scss
Normal file
50
frontend/src/styles/index.scss
Normal file
@@ -0,0 +1,50 @@
|
||||
@import "./variables.scss";
|
||||
|
||||
// 全局样式
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: 'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', '微软雅黑', Arial, sans-serif;
|
||||
background-color: $bg-color-page;
|
||||
color: $text-color-primary;
|
||||
}
|
||||
|
||||
#app {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
// Element Plus 样式覆盖
|
||||
.el-card {
|
||||
border-radius: $border-radius-base;
|
||||
box-shadow: $box-shadow-light;
|
||||
}
|
||||
|
||||
.el-table {
|
||||
.el-table__header {
|
||||
th {
|
||||
background-color: $fill-color-light;
|
||||
color: $text-color-regular;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 动画
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.fade-in {
|
||||
animation: fadeIn $transition-duration ease-in-out;
|
||||
}
|
||||
69
frontend/src/styles/variables.scss
Normal file
69
frontend/src/styles/variables.scss
Normal file
@@ -0,0 +1,69 @@
|
||||
// 颜色变量
|
||||
$primary-color: #409eff;
|
||||
$success-color: #67c23a;
|
||||
$warning-color: #e6a23c;
|
||||
$danger-color: #f56c6c;
|
||||
$info-color: #909399;
|
||||
|
||||
// 背景颜色
|
||||
$bg-color: #ffffff;
|
||||
$bg-color-page: #f2f3f5;
|
||||
$bg-color-overlay: #ffffff;
|
||||
|
||||
// 文字颜色
|
||||
$text-color-primary: #303133;
|
||||
$text-color-regular: #606266;
|
||||
$text-color-secondary: #909399;
|
||||
$text-color-placeholder: #a8abb2;
|
||||
$text-color-disabled: #c0c4cc;
|
||||
|
||||
// 边框颜色
|
||||
$border-color: #dcdfe6;
|
||||
$border-color-light: #e4e7ed;
|
||||
$border-color-lighter: #ebeef5;
|
||||
$border-color-extra-light: #f2f6fc;
|
||||
$border-color-dark: #d4d7de;
|
||||
|
||||
// 填充颜色
|
||||
$fill-color: #f0f2f5;
|
||||
$fill-color-light: #f5f7fa;
|
||||
$fill-color-lighter: #fafafa;
|
||||
$fill-color-extra-light: #fafcff;
|
||||
$fill-color-dark: #ebedf0;
|
||||
$fill-color-darker: #e6e8eb;
|
||||
$fill-color-blank: #ffffff;
|
||||
|
||||
// 间距
|
||||
$spacing-xs: 4px;
|
||||
$spacing-sm: 8px;
|
||||
$spacing-md: 16px;
|
||||
$spacing-lg: 24px;
|
||||
$spacing-xl: 32px;
|
||||
|
||||
// 边框半径
|
||||
$border-radius-base: 4px;
|
||||
$border-radius-small: 2px;
|
||||
$border-radius-round: 20px;
|
||||
$border-radius-circle: 50%;
|
||||
|
||||
// 字体大小
|
||||
$font-size-extra-large: 20px;
|
||||
$font-size-large: 18px;
|
||||
$font-size-medium: 16px;
|
||||
$font-size-base: 14px;
|
||||
$font-size-small: 13px;
|
||||
$font-size-extra-small: 12px;
|
||||
|
||||
// 阴影
|
||||
$box-shadow-base: 0 2px 4px rgba(0, 0, 0, 0.12), 0 0 6px rgba(0, 0, 0, 0.04);
|
||||
$box-shadow-dark: 0 2px 4px rgba(0, 0, 0, 0.12), 0 0 6px rgba(0, 0, 0, 0.12);
|
||||
$box-shadow-light: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
|
||||
|
||||
// 层级
|
||||
$index-normal: 1;
|
||||
$index-top: 1000;
|
||||
$index-popper: 2000;
|
||||
|
||||
// 动画持续时间
|
||||
$transition-duration: 0.3s;
|
||||
$transition-duration-fast: 0.2s;
|
||||
93
frontend/src/utils/request.js
Normal file
93
frontend/src/utils/request.js
Normal file
@@ -0,0 +1,93 @@
|
||||
import axios from 'axios'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
// 创建axios实例
|
||||
const service = axios.create({
|
||||
baseURL: import.meta.env.VITE_APP_BASE_API || 'http://localhost:8080', // 后端API地址
|
||||
timeout: 15000 // 请求超时时间
|
||||
})
|
||||
|
||||
// 请求拦截器
|
||||
service.interceptors.request.use(
|
||||
config => {
|
||||
// 在发送请求之前做些什么
|
||||
return config
|
||||
},
|
||||
error => {
|
||||
// 对请求错误做些什么
|
||||
console.log(error) // for debug
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
// 响应拦截器
|
||||
service.interceptors.response.use(
|
||||
response => {
|
||||
const res = response.data
|
||||
|
||||
// 如果自定义状态码不是200,则显示错误信息
|
||||
if (res.code !== 200) {
|
||||
ElMessage({
|
||||
message: res.message || '请求失败',
|
||||
type: 'error',
|
||||
duration: 5 * 1000
|
||||
})
|
||||
return Promise.reject(new Error(res.message || 'Error'))
|
||||
} else {
|
||||
return res
|
||||
}
|
||||
},
|
||||
error => {
|
||||
console.log('err' + error) // for debug
|
||||
let message = '网络错误'
|
||||
|
||||
if (error.response) {
|
||||
switch (error.response.status) {
|
||||
case 400:
|
||||
message = '请求错误'
|
||||
break
|
||||
case 401:
|
||||
message = '未授权,请登录'
|
||||
break
|
||||
case 403:
|
||||
message = '拒绝访问'
|
||||
break
|
||||
case 404:
|
||||
message = '请求地址出错'
|
||||
break
|
||||
case 408:
|
||||
message = '请求超时'
|
||||
break
|
||||
case 500:
|
||||
message = '服务器内部错误'
|
||||
break
|
||||
case 501:
|
||||
message = '服务未实现'
|
||||
break
|
||||
case 502:
|
||||
message = '网关错误'
|
||||
break
|
||||
case 503:
|
||||
message = '服务不可用'
|
||||
break
|
||||
case 504:
|
||||
message = '网关超时'
|
||||
break
|
||||
case 505:
|
||||
message = 'HTTP版本不受支持'
|
||||
break
|
||||
default:
|
||||
message = '网络错误'
|
||||
}
|
||||
}
|
||||
|
||||
ElMessage({
|
||||
message: message,
|
||||
type: 'error',
|
||||
duration: 5 * 1000
|
||||
})
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
export default service
|
||||
558
frontend/src/views/Dashboard.vue
Normal file
558
frontend/src/views/Dashboard.vue
Normal file
@@ -0,0 +1,558 @@
|
||||
<template>
|
||||
<div class="dashboard">
|
||||
<!-- 页面标题 -->
|
||||
<div class="page-header">
|
||||
<h1>农业股票数据分析平台</h1>
|
||||
<p>基于Spark大数据处理的农业股票市场监控系统</p>
|
||||
</div>
|
||||
|
||||
<!-- 市场总览卡片 -->
|
||||
<el-row :gutter="20" class="overview-cards">
|
||||
<el-col :span="6">
|
||||
<el-card class="overview-card up">
|
||||
<div class="card-content">
|
||||
<div class="card-icon">
|
||||
<el-icon><TrendCharts /></el-icon>
|
||||
</div>
|
||||
<div class="card-info">
|
||||
<div class="card-title">上涨股票</div>
|
||||
<div class="card-value">{{ marketData.upCount || 0 }}</div>
|
||||
<div class="card-desc">只股票上涨</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-card class="overview-card down">
|
||||
<div class="card-content">
|
||||
<div class="card-icon">
|
||||
<el-icon><TrendCharts /></el-icon>
|
||||
</div>
|
||||
<div class="card-info">
|
||||
<div class="card-title">下跌股票</div>
|
||||
<div class="card-value">{{ marketData.downCount || 0 }}</div>
|
||||
<div class="card-desc">只股票下跌</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-card class="overview-card volume">
|
||||
<div class="card-content">
|
||||
<div class="card-icon">
|
||||
<el-icon><DataAnalysis /></el-icon>
|
||||
</div>
|
||||
<div class="card-info">
|
||||
<div class="card-title">总成交量</div>
|
||||
<div class="card-value">{{ formatNumber(marketData.totalVolume) }}</div>
|
||||
<div class="card-desc">手</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-card class="overview-card market-cap">
|
||||
<div class="card-content">
|
||||
<div class="card-icon">
|
||||
<el-icon><Money /></el-icon>
|
||||
</div>
|
||||
<div class="card-info">
|
||||
<div class="card-title">总市值</div>
|
||||
<div class="card-value">{{ formatNumber(marketData.totalMarketCap) }}</div>
|
||||
<div class="card-desc">亿元</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 图表区域 -->
|
||||
<el-row :gutter="20" class="chart-section">
|
||||
<el-col :span="12">
|
||||
<el-card class="chart-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>市场涨跌分布</span>
|
||||
<el-button type="primary" size="small" @click="refreshData">刷新</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<div id="pie-chart" style="height: 400px;"></div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-card class="chart-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>市场趋势分析</span>
|
||||
<el-button type="primary" size="small" @click="loadTrendData">查看趋势</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<div id="line-chart" style="height: 400px;"></div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 快速导航 -->
|
||||
<el-row :gutter="20" class="quick-nav-section">
|
||||
<el-col :span="6">
|
||||
<el-card class="nav-card" @click="navigateTo('/search')">
|
||||
<div class="nav-content">
|
||||
<div class="nav-icon search">
|
||||
<el-icon><Search /></el-icon>
|
||||
</div>
|
||||
<div class="nav-text">
|
||||
<h3>股票搜索</h3>
|
||||
<p>搜索和查看股票详细信息</p>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-card class="nav-card" @click="navigateTo('/rankings')">
|
||||
<div class="nav-content">
|
||||
<div class="nav-icon rankings">
|
||||
<el-icon><TrendCharts /></el-icon>
|
||||
</div>
|
||||
<div class="nav-text">
|
||||
<h3>股票排行榜</h3>
|
||||
<p>涨幅、市值、成交量排行</p>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-card class="nav-card" @click="navigateTo('/market-analysis')">
|
||||
<div class="nav-content">
|
||||
<div class="nav-icon analysis">
|
||||
<el-icon><Pie /></el-icon>
|
||||
</div>
|
||||
<div class="nav-text">
|
||||
<h3>市场分析</h3>
|
||||
<p>深度市场分析和趋势预测</p>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-card class="nav-card" @click="navigateTo('/health')">
|
||||
<div class="nav-content">
|
||||
<div class="nav-icon health">
|
||||
<el-icon><Monitor /></el-icon>
|
||||
</div>
|
||||
<div class="nav-text">
|
||||
<h3>系统监控</h3>
|
||||
<p>系统健康状态检查</p>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 数据表格 -->
|
||||
<el-row>
|
||||
<el-col :span="24">
|
||||
<el-card class="table-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>最新市场分析数据</span>
|
||||
<el-button type="success" size="small" @click="runSparkAnalysis">
|
||||
<el-icon><Lightning /></el-icon>
|
||||
运行Spark分析
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<el-table :data="recentData" style="width: 100%">
|
||||
<el-table-column prop="analysisDate" label="分析日期" width="120" />
|
||||
<el-table-column prop="totalCount" label="总股票数" width="100" />
|
||||
<el-table-column prop="upCount" label="上涨" width="80">
|
||||
<template #default="scope">
|
||||
<span style="color: #f56c6c">{{ scope.row.upCount }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="downCount" label="下跌" width="80">
|
||||
<template #default="scope">
|
||||
<span style="color: #67c23a">{{ scope.row.downCount }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="flatCount" label="平盘" width="80" />
|
||||
<el-table-column prop="totalVolume" label="总成交量" width="120">
|
||||
<template #default="scope">
|
||||
{{ formatNumber(scope.row.totalVolume) }}手
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="totalMarketCap" label="总市值" width="120">
|
||||
<template #default="scope">
|
||||
{{ formatNumber(scope.row.totalMarketCap) }}亿
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="avgChangePercent" label="平均涨跌幅" width="120">
|
||||
<template #default="scope">
|
||||
<span :style="{ color: scope.row.avgChangePercent >= 0 ? '#f56c6c' : '#67c23a' }">
|
||||
{{ scope.row.avgChangePercent }}%
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, onMounted, nextTick } from 'vue'
|
||||
import * as echarts from 'echarts'
|
||||
import { getLatestMarketAnalysis, getRecentMarketAnalysis } from '@/api/market'
|
||||
import { getMarketAnalysis, getRealtimeStockData } from '@/api/stock'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
export default {
|
||||
name: 'Dashboard',
|
||||
setup() {
|
||||
const router = useRouter()
|
||||
const marketData = ref({})
|
||||
const recentData = ref([])
|
||||
const loading = ref(false)
|
||||
|
||||
// 加载最新市场数据
|
||||
const loadLatestData = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
const response = await getLatestMarketAnalysis()
|
||||
if (response.data) {
|
||||
marketData.value = response.data
|
||||
initPieChart()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载市场数据失败:', error)
|
||||
ElMessage.error('加载市场数据失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 加载最近数据
|
||||
const loadRecentData = async () => {
|
||||
try {
|
||||
const response = await getRecentMarketAnalysis(7)
|
||||
if (response.data) {
|
||||
recentData.value = response.data
|
||||
initLineChart()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载历史数据失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化饼图
|
||||
const initPieChart = async () => {
|
||||
await nextTick()
|
||||
const chartDom = document.getElementById('pie-chart')
|
||||
if (!chartDom) return
|
||||
|
||||
const myChart = echarts.init(chartDom)
|
||||
const option = {
|
||||
title: {
|
||||
text: '市场涨跌分布',
|
||||
left: 'center'
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'item'
|
||||
},
|
||||
legend: {
|
||||
orient: 'vertical',
|
||||
left: 'left'
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '股票数量',
|
||||
type: 'pie',
|
||||
radius: '50%',
|
||||
data: [
|
||||
{ value: marketData.value.upCount || 0, name: '上涨股票', itemStyle: { color: '#f56c6c' } },
|
||||
{ value: marketData.value.downCount || 0, name: '下跌股票', itemStyle: { color: '#67c23a' } },
|
||||
{ value: marketData.value.flatCount || 0, name: '平盘股票', itemStyle: { color: '#909399' } }
|
||||
],
|
||||
emphasis: {
|
||||
itemStyle: {
|
||||
shadowBlur: 10,
|
||||
shadowOffsetX: 0,
|
||||
shadowColor: 'rgba(0, 0, 0, 0.5)'
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
myChart.setOption(option)
|
||||
}
|
||||
|
||||
// 初始化折线图
|
||||
const initLineChart = async () => {
|
||||
await nextTick()
|
||||
const chartDom = document.getElementById('line-chart')
|
||||
if (!chartDom) return
|
||||
|
||||
const myChart = echarts.init(chartDom)
|
||||
const dates = recentData.value.map(item => item.analysisDate).reverse()
|
||||
const avgChanges = recentData.value.map(item => item.avgChangePercent).reverse()
|
||||
|
||||
const option = {
|
||||
title: {
|
||||
text: '市场平均涨跌幅趋势',
|
||||
left: 'center'
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis'
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: dates
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
name: '涨跌幅(%)'
|
||||
},
|
||||
series: [
|
||||
{
|
||||
data: avgChanges,
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
lineStyle: {
|
||||
color: '#409EFF'
|
||||
},
|
||||
areaStyle: {
|
||||
color: 'rgba(64, 158, 255, 0.1)'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
myChart.setOption(option)
|
||||
}
|
||||
|
||||
// 格式化数字
|
||||
const formatNumber = (num) => {
|
||||
if (!num) return '0'
|
||||
return (num / 10000).toFixed(1)
|
||||
}
|
||||
|
||||
// 刷新数据
|
||||
const refreshData = () => {
|
||||
loadLatestData()
|
||||
loadRecentData()
|
||||
}
|
||||
|
||||
// 加载趋势数据
|
||||
const loadTrendData = () => {
|
||||
loadRecentData()
|
||||
}
|
||||
|
||||
// 模拟运行Spark分析
|
||||
const runSparkAnalysis = () => {
|
||||
ElMessage.success('Spark分析任务已提交,请稍候查看结果')
|
||||
setTimeout(() => {
|
||||
refreshData()
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
// 导航到指定页面
|
||||
const navigateTo = (path) => {
|
||||
router.push(path)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadLatestData()
|
||||
loadRecentData()
|
||||
})
|
||||
|
||||
return {
|
||||
marketData,
|
||||
recentData,
|
||||
loading,
|
||||
refreshData,
|
||||
loadTrendData,
|
||||
runSparkAnalysis,
|
||||
formatNumber,
|
||||
navigateTo
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.dashboard {
|
||||
padding: 20px;
|
||||
background-color: #f5f7fa;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
|
||||
h1 {
|
||||
font-size: 28px;
|
||||
color: #303133;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
p {
|
||||
color: #909399;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.overview-cards {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.overview-card {
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
|
||||
|
||||
&.up {
|
||||
background: linear-gradient(135deg, #f56c6c, #ff8a80);
|
||||
color: white;
|
||||
}
|
||||
|
||||
&.down {
|
||||
background: linear-gradient(135deg, #67c23a, #81c784);
|
||||
color: white;
|
||||
}
|
||||
|
||||
&.volume {
|
||||
background: linear-gradient(135deg, #409eff, #64b5f6);
|
||||
color: white;
|
||||
}
|
||||
|
||||
&.market-cap {
|
||||
background: linear-gradient(135deg, #e6a23c, #ffb74d);
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
.card-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.card-icon {
|
||||
font-size: 40px;
|
||||
margin-right: 15px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.card-info {
|
||||
flex: 1;
|
||||
|
||||
.card-title {
|
||||
font-size: 14px;
|
||||
opacity: 0.9;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.card-value {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.card-desc {
|
||||
font-size: 12px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.quick-nav-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.nav-card {
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 20px 0 rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.nav-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10px;
|
||||
|
||||
.nav-icon {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 15px;
|
||||
font-size: 24px;
|
||||
|
||||
&.search {
|
||||
background: linear-gradient(135deg, #409eff, #66d9ef);
|
||||
color: white;
|
||||
}
|
||||
|
||||
&.rankings {
|
||||
background: linear-gradient(135deg, #f56c6c, #ff9a9e);
|
||||
color: white;
|
||||
}
|
||||
|
||||
&.analysis {
|
||||
background: linear-gradient(135deg, #67c23a, #95de64);
|
||||
color: white;
|
||||
}
|
||||
|
||||
&.health {
|
||||
background: linear-gradient(135deg, #e6a23c, #ffc53d);
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-text {
|
||||
flex: 1;
|
||||
|
||||
h3 {
|
||||
margin: 0 0 5px 0;
|
||||
font-size: 16px;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chart-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.chart-card, .table-card {
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
span {
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
622
frontend/src/views/DataManagement.vue
Normal file
622
frontend/src/views/DataManagement.vue
Normal file
@@ -0,0 +1,622 @@
|
||||
<template>
|
||||
<div class="data-management">
|
||||
<!-- 页面标题 -->
|
||||
<div class="page-header">
|
||||
<h1>数据管理</h1>
|
||||
<p>股票数据的导入、导出和批量管理</p>
|
||||
</div>
|
||||
|
||||
<!-- 数据操作区域 -->
|
||||
<el-row :gutter="20" class="operation-section">
|
||||
<!-- 数据导入 -->
|
||||
<el-col :span="12">
|
||||
<el-card class="operation-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>数据导入</span>
|
||||
<el-icon><Upload /></el-icon>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="import-section">
|
||||
<el-upload
|
||||
ref="uploadRef"
|
||||
:auto-upload="false"
|
||||
:on-change="handleFileChange"
|
||||
:show-file-list="true"
|
||||
accept=".json,.csv"
|
||||
drag
|
||||
>
|
||||
<el-icon class="el-icon--upload"><UploadFilled /></el-icon>
|
||||
<div class="el-upload__text">
|
||||
将文件拖到此处,或<em>点击上传</em>
|
||||
</div>
|
||||
<template #tip>
|
||||
<div class="el-upload__tip">
|
||||
支持 JSON 和 CSV 格式的股票数据文件
|
||||
</div>
|
||||
</template>
|
||||
</el-upload>
|
||||
|
||||
<div class="import-actions" v-if="uploadFile">
|
||||
<el-button type="primary" @click="importData" :loading="importing">
|
||||
<el-icon><Upload /></el-icon>
|
||||
导入数据
|
||||
</el-button>
|
||||
<el-button @click="clearUpload">
|
||||
<el-icon><Close /></el-icon>
|
||||
清除
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<!-- 数据导出 -->
|
||||
<el-col :span="12">
|
||||
<el-card class="operation-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>数据导出</span>
|
||||
<el-icon><Download /></el-icon>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="export-section">
|
||||
<div class="export-options">
|
||||
<el-form :model="exportForm" label-width="100px">
|
||||
<el-form-item label="导出格式">
|
||||
<el-radio-group v-model="exportForm.format">
|
||||
<el-radio label="json">JSON</el-radio>
|
||||
<el-radio label="csv">CSV</el-radio>
|
||||
<el-radio label="excel">Excel</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="数据范围">
|
||||
<el-select v-model="exportForm.range" style="width: 100%;">
|
||||
<el-option label="全部数据" value="all" />
|
||||
<el-option label="最近一周" value="week" />
|
||||
<el-option label="最近一月" value="month" />
|
||||
<el-option label="自定义日期" value="custom" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="日期范围" v-if="exportForm.range === 'custom'">
|
||||
<el-date-picker
|
||||
v-model="exportForm.dateRange"
|
||||
type="daterange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
style="width: 100%;"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<div class="export-actions">
|
||||
<el-button type="success" @click="exportData" :loading="exporting">
|
||||
<el-icon><Download /></el-icon>
|
||||
导出数据
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 批量操作区域 -->
|
||||
<el-row class="batch-section">
|
||||
<el-col :span="24">
|
||||
<el-card class="batch-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>批量操作</span>
|
||||
<div class="batch-actions">
|
||||
<el-button type="primary" @click="showBatchDialog = true">
|
||||
<el-icon><Plus /></el-icon>
|
||||
批量添加
|
||||
</el-button>
|
||||
<el-button type="danger" @click="batchDelete" :disabled="selectedRows.length === 0">
|
||||
<el-icon><Delete /></el-icon>
|
||||
批量删除 ({{ selectedRows.length }})
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 数据表格 -->
|
||||
<el-table
|
||||
:data="paginatedStockData"
|
||||
v-loading="loading"
|
||||
@selection-change="handleSelectionChange"
|
||||
stripe
|
||||
>
|
||||
<el-table-column type="selection" width="55" />
|
||||
<el-table-column prop="stockCode" label="股票代码" width="100" />
|
||||
<el-table-column prop="stockName" label="股票名称" width="150" />
|
||||
<el-table-column prop="currentPrice" label="当前价格" width="100">
|
||||
<template #default="scope">
|
||||
¥{{ scope.row.currentPrice?.toFixed(2) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="changePercent" label="涨跌幅" width="100">
|
||||
<template #default="scope">
|
||||
<span :style="{ color: scope.row.changePercent >= 0 ? '#f56c6c' : '#67c23a' }">
|
||||
{{ scope.row.changePercent >= 0 ? '+' : '' }}{{ scope.row.changePercent?.toFixed(2) }}%
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="volume" label="成交量" width="120">
|
||||
<template #default="scope">
|
||||
{{ formatVolume(scope.row.volume) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="marketCap" label="市值" width="120">
|
||||
<template #default="scope">
|
||||
{{ formatMarketCap(scope.row.marketCap) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="timestamp" label="更新时间" width="160">
|
||||
<template #default="scope">
|
||||
{{ formatDateTime(scope.row.timestamp) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="150">
|
||||
<template #default="scope">
|
||||
<el-button type="primary" size="small" @click="editStock(scope.row)">
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button type="danger" size="small" @click="deleteStock(scope.row)">
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="pagination-container" v-if="stockData.length > 0">
|
||||
<el-pagination
|
||||
v-model:current-page="currentPage"
|
||||
v-model:page-size="pageSize"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
:total="stockData.length"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 批量添加对话框 -->
|
||||
<el-dialog
|
||||
v-model="showBatchDialog"
|
||||
title="批量添加股票数据"
|
||||
width="600px"
|
||||
:before-close="closeBatchDialog"
|
||||
>
|
||||
<div class="batch-form">
|
||||
<el-form ref="batchFormRef" :model="batchForm" label-width="100px">
|
||||
<el-form-item label="数据输入">
|
||||
<el-input
|
||||
v-model="batchForm.jsonData"
|
||||
type="textarea"
|
||||
:rows="10"
|
||||
placeholder="请输入JSON格式的股票数据数组..."
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<div class="form-tips">
|
||||
<el-alert
|
||||
title="数据格式示例"
|
||||
type="info"
|
||||
:closable="false"
|
||||
/>
|
||||
<pre class="json-example">{{ jsonExample }}</pre>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<span class="dialog-footer">
|
||||
<el-button @click="closeBatchDialog">取消</el-button>
|
||||
<el-button type="primary" @click="saveBatchData" :loading="batchSaving">
|
||||
保存数据
|
||||
</el-button>
|
||||
</span>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { getRealtimeStockData, saveStockData, batchSaveStockData } from '@/api/stock'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
|
||||
export default {
|
||||
name: 'DataManagement',
|
||||
setup() {
|
||||
const stockData = ref([])
|
||||
const selectedRows = ref([])
|
||||
const loading = ref(false)
|
||||
const importing = ref(false)
|
||||
const exporting = ref(false)
|
||||
const batchSaving = ref(false)
|
||||
const uploadFile = ref(null)
|
||||
const showBatchDialog = ref(false)
|
||||
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(20)
|
||||
|
||||
const exportForm = ref({
|
||||
format: 'json',
|
||||
range: 'all',
|
||||
dateRange: []
|
||||
})
|
||||
|
||||
const batchForm = ref({
|
||||
jsonData: ''
|
||||
})
|
||||
|
||||
const jsonExample = ref(`[
|
||||
{
|
||||
"stockCode": "000001",
|
||||
"stockName": "平安银行",
|
||||
"currentPrice": 12.85,
|
||||
"changePercent": 2.15,
|
||||
"changeAmount": 0.27,
|
||||
"volume": 125643200,
|
||||
"marketCap": 2485632000
|
||||
}
|
||||
]`)
|
||||
|
||||
// 加载股票数据
|
||||
const loadStockData = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
const response = await getRealtimeStockData()
|
||||
if (response.data) {
|
||||
stockData.value = response.data
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载股票数据失败:', error)
|
||||
ElMessage.error('加载股票数据失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 分页数据
|
||||
const paginatedStockData = computed(() => {
|
||||
const start = (currentPage.value - 1) * pageSize.value
|
||||
const end = start + pageSize.value
|
||||
return stockData.value.slice(start, end)
|
||||
})
|
||||
|
||||
// 文件上传处理
|
||||
const handleFileChange = (file) => {
|
||||
uploadFile.value = file
|
||||
}
|
||||
|
||||
// 清除上传
|
||||
const clearUpload = () => {
|
||||
uploadFile.value = null
|
||||
}
|
||||
|
||||
// 导入数据
|
||||
const importData = async () => {
|
||||
if (!uploadFile.value) {
|
||||
ElMessage.warning('请选择要导入的文件')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
importing.value = true
|
||||
// 这里应该实现文件解析和数据导入逻辑
|
||||
ElMessage.success('数据导入成功')
|
||||
uploadFile.value = null
|
||||
await loadStockData()
|
||||
} catch (error) {
|
||||
console.error('导入数据失败:', error)
|
||||
ElMessage.error('导入数据失败')
|
||||
} finally {
|
||||
importing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 导出数据
|
||||
const exportData = async () => {
|
||||
try {
|
||||
exporting.value = true
|
||||
|
||||
// 模拟导出过程
|
||||
const exportData = stockData.value
|
||||
const filename = `stock_data_${new Date().toISOString().split('T')[0]}.${exportForm.value.format}`
|
||||
|
||||
// 这里应该实现实际的导出逻辑
|
||||
ElMessage.success(`数据已导出为 ${filename}`)
|
||||
} catch (error) {
|
||||
console.error('导出数据失败:', error)
|
||||
ElMessage.error('导出数据失败')
|
||||
} finally {
|
||||
exporting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 批量保存数据
|
||||
const saveBatchData = async () => {
|
||||
if (!batchForm.value.jsonData.trim()) {
|
||||
ElMessage.warning('请输入股票数据')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
batchSaving.value = true
|
||||
const data = JSON.parse(batchForm.value.jsonData)
|
||||
|
||||
if (!Array.isArray(data)) {
|
||||
throw new Error('数据格式错误,应为数组格式')
|
||||
}
|
||||
|
||||
const response = await batchSaveStockData(data)
|
||||
if (response.data) {
|
||||
ElMessage.success(`成功保存 ${response.data} 条股票数据`)
|
||||
closeBatchDialog()
|
||||
await loadStockData()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('批量保存失败:', error)
|
||||
ElMessage.error('批量保存失败: ' + error.message)
|
||||
} finally {
|
||||
batchSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭批量对话框
|
||||
const closeBatchDialog = () => {
|
||||
showBatchDialog.value = false
|
||||
batchForm.value.jsonData = ''
|
||||
}
|
||||
|
||||
// 选择变化
|
||||
const handleSelectionChange = (selection) => {
|
||||
selectedRows.value = selection
|
||||
}
|
||||
|
||||
// 批量删除
|
||||
const batchDelete = async () => {
|
||||
if (selectedRows.value.length === 0) {
|
||||
ElMessage.warning('请选择要删除的数据')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定要删除选中的 ${selectedRows.value.length} 条数据吗?`,
|
||||
'确认删除',
|
||||
{
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}
|
||||
)
|
||||
|
||||
// 这里应该调用批量删除API
|
||||
ElMessage.success('批量删除成功')
|
||||
await loadStockData()
|
||||
} catch (error) {
|
||||
// 用户取消删除
|
||||
}
|
||||
}
|
||||
|
||||
// 编辑股票
|
||||
const editStock = (stock) => {
|
||||
ElMessage.info(`编辑股票: ${stock.stockName}`)
|
||||
}
|
||||
|
||||
// 删除股票
|
||||
const deleteStock = async (stock) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定要删除股票 ${stock.stockName} 吗?`,
|
||||
'确认删除',
|
||||
{
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}
|
||||
)
|
||||
|
||||
// 这里应该调用删除API
|
||||
ElMessage.success('删除成功')
|
||||
await loadStockData()
|
||||
} catch (error) {
|
||||
// 用户取消删除
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化函数
|
||||
const formatVolume = (volume) => {
|
||||
if (!volume) return '0手'
|
||||
if (volume >= 100000000) {
|
||||
return (volume / 100000000).toFixed(1) + '亿手'
|
||||
} else if (volume >= 10000) {
|
||||
return (volume / 10000).toFixed(1) + '万手'
|
||||
} else {
|
||||
return volume + '手'
|
||||
}
|
||||
}
|
||||
|
||||
const formatMarketCap = (marketCap) => {
|
||||
if (!marketCap) return '0元'
|
||||
if (marketCap >= 100000000) {
|
||||
return (marketCap / 100000000).toFixed(1) + '亿元'
|
||||
} else if (marketCap >= 10000) {
|
||||
return (marketCap / 10000).toFixed(1) + '万元'
|
||||
} else {
|
||||
return marketCap + '元'
|
||||
}
|
||||
}
|
||||
|
||||
const formatDateTime = (timestamp) => {
|
||||
if (!timestamp) return ''
|
||||
return new Date(timestamp).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
// 分页事件
|
||||
const handleSizeChange = (val) => {
|
||||
pageSize.value = val
|
||||
currentPage.value = 1
|
||||
}
|
||||
|
||||
const handleCurrentChange = (val) => {
|
||||
currentPage.value = val
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadStockData()
|
||||
})
|
||||
|
||||
return {
|
||||
stockData,
|
||||
selectedRows,
|
||||
loading,
|
||||
importing,
|
||||
exporting,
|
||||
batchSaving,
|
||||
uploadFile,
|
||||
showBatchDialog,
|
||||
currentPage,
|
||||
pageSize,
|
||||
exportForm,
|
||||
batchForm,
|
||||
jsonExample,
|
||||
paginatedStockData,
|
||||
handleFileChange,
|
||||
clearUpload,
|
||||
importData,
|
||||
exportData,
|
||||
saveBatchData,
|
||||
closeBatchDialog,
|
||||
handleSelectionChange,
|
||||
batchDelete,
|
||||
editStock,
|
||||
deleteStock,
|
||||
formatVolume,
|
||||
formatMarketCap,
|
||||
formatDateTime,
|
||||
handleSizeChange,
|
||||
handleCurrentChange
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.data-management {
|
||||
padding: 20px;
|
||||
background-color: #f5f7fa;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
|
||||
h1 {
|
||||
font-size: 28px;
|
||||
color: #303133;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
p {
|
||||
color: #909399;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.operation-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.operation-card, .batch-card {
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
span {
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
}
|
||||
|
||||
.import-section, .export-section {
|
||||
.import-actions, .export-actions {
|
||||
margin-top: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.export-options {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.batch-section {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.batch-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.pagination-container {
|
||||
margin-top: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.batch-form {
|
||||
.form-tips {
|
||||
margin-top: 10px;
|
||||
|
||||
.json-example {
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #e9ecef;
|
||||
border-radius: 4px;
|
||||
padding: 10px;
|
||||
margin-top: 10px;
|
||||
font-size: 12px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-upload) {
|
||||
width: 100%;
|
||||
|
||||
.el-upload-dragger {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-table) {
|
||||
.el-table__header {
|
||||
th {
|
||||
background-color: #f8f9fa;
|
||||
color: #303133;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
108
frontend/src/views/HealthCheck.vue
Normal file
108
frontend/src/views/HealthCheck.vue
Normal file
@@ -0,0 +1,108 @@
|
||||
<template>
|
||||
<div class="health-check">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>系统健康检查</span>
|
||||
<el-tag type="success">运行正常</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="前端状态">
|
||||
<el-tag type="success">正常运行</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="端口">3000</el-descriptions-item>
|
||||
<el-descriptions-item label="Vue版本">{{ vueVersion }}</el-descriptions-item>
|
||||
<el-descriptions-item label="Element Plus">已加载</el-descriptions-item>
|
||||
<el-descriptions-item label="路由">{{ routerReady ? '已配置' : '未配置' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="状态管理">{{ storeReady ? '已配置' : '未配置' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<div style="margin-top: 20px;">
|
||||
<el-button type="primary" @click="testApi">测试API连接</el-button>
|
||||
<el-button type="success" @click="testStyles">测试样式变量</el-button>
|
||||
</div>
|
||||
|
||||
<div v-if="apiStatus" style="margin-top: 15px;">
|
||||
<el-alert :title="apiStatus.title" :type="apiStatus.type" show-icon />
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useStore } from 'vuex'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { version } from 'vue'
|
||||
|
||||
export default {
|
||||
name: 'HealthCheck',
|
||||
setup() {
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const store = useStore()
|
||||
const apiStatus = ref(null)
|
||||
|
||||
const vueVersion = ref(version)
|
||||
const routerReady = ref(!!router)
|
||||
const storeReady = ref(!!store)
|
||||
|
||||
const testApi = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/health')
|
||||
if (response.ok) {
|
||||
apiStatus.value = {
|
||||
title: 'API连接成功',
|
||||
type: 'success'
|
||||
}
|
||||
} else {
|
||||
apiStatus.value = {
|
||||
title: 'API连接失败',
|
||||
type: 'error'
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
apiStatus.value = {
|
||||
title: `API连接错误: ${error.message}`,
|
||||
type: 'warning'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const testStyles = () => {
|
||||
ElMessage.success('SCSS变量加载正常!')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
console.log('健康检查页面已加载')
|
||||
})
|
||||
|
||||
return {
|
||||
vueVersion,
|
||||
routerReady,
|
||||
storeReady,
|
||||
apiStatus,
|
||||
testApi,
|
||||
testStyles
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "@/styles/variables.scss";
|
||||
|
||||
.health-check {
|
||||
padding: 20px;
|
||||
background-color: $bg-color-page;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
796
frontend/src/views/MarketAnalysis.vue
Normal file
796
frontend/src/views/MarketAnalysis.vue
Normal file
@@ -0,0 +1,796 @@
|
||||
<template>
|
||||
<div class="market-analysis">
|
||||
<!-- 页面标题 -->
|
||||
<div class="page-header">
|
||||
<h1>市场分析</h1>
|
||||
<p>基于大数据的农业股票市场深度分析</p>
|
||||
</div>
|
||||
|
||||
<!-- 最新市场分析卡片 -->
|
||||
<div class="latest-analysis" v-if="latestAnalysis.analysisDate">
|
||||
<el-card class="analysis-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>最新市场分析</span>
|
||||
<div class="analysis-date">
|
||||
<el-icon><Calendar /></el-icon>
|
||||
{{ formatDate(latestAnalysis.analysisDate) }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 市场概览指标 -->
|
||||
<div class="market-overview">
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="6">
|
||||
<div class="overview-item total">
|
||||
<div class="item-icon">
|
||||
<el-icon><Pie /></el-icon>
|
||||
</div>
|
||||
<div class="item-content">
|
||||
<div class="item-value">{{ latestAnalysis.totalCount }}</div>
|
||||
<div class="item-label">总股票数</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="overview-item up">
|
||||
<div class="item-icon">
|
||||
<el-icon><CaretTop /></el-icon>
|
||||
</div>
|
||||
<div class="item-content">
|
||||
<div class="item-value">{{ latestAnalysis.upCount }}</div>
|
||||
<div class="item-label">上涨股票</div>
|
||||
<div class="item-percent">{{ getUpPercent() }}%</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="overview-item down">
|
||||
<div class="item-icon">
|
||||
<el-icon><CaretBottom /></el-icon>
|
||||
</div>
|
||||
<div class="item-content">
|
||||
<div class="item-value">{{ latestAnalysis.downCount }}</div>
|
||||
<div class="item-label">下跌股票</div>
|
||||
<div class="item-percent">{{ getDownPercent() }}%</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="overview-item flat">
|
||||
<div class="item-icon">
|
||||
<el-icon><Minus /></el-icon>
|
||||
</div>
|
||||
<div class="item-content">
|
||||
<div class="item-value">{{ latestAnalysis.flatCount }}</div>
|
||||
<div class="item-label">平盘股票</div>
|
||||
<div class="item-percent">{{ getFlatPercent() }}%</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
|
||||
<!-- 市场关键指标 -->
|
||||
<div class="market-metrics">
|
||||
<el-row :gutter="20" style="margin-top: 20px;">
|
||||
<el-col :span="8">
|
||||
<div class="metric-card">
|
||||
<div class="metric-header">
|
||||
<el-icon><DataAnalysis /></el-icon>
|
||||
<span>总成交量</span>
|
||||
</div>
|
||||
<div class="metric-value volume">
|
||||
{{ formatVolume(latestAnalysis.totalVolume) }}
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<div class="metric-card">
|
||||
<div class="metric-header">
|
||||
<el-icon><Money /></el-icon>
|
||||
<span>总市值</span>
|
||||
</div>
|
||||
<div class="metric-value market-cap">
|
||||
{{ formatMarketCap(latestAnalysis.totalMarketCap) }}
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<div class="metric-card">
|
||||
<div class="metric-header">
|
||||
<el-icon><TrendCharts /></el-icon>
|
||||
<span>平均涨跌幅</span>
|
||||
</div>
|
||||
<div class="metric-value" :class="{ 'positive': latestAnalysis.avgChangePercent >= 0, 'negative': latestAnalysis.avgChangePercent < 0 }">
|
||||
{{ latestAnalysis.avgChangePercent >= 0 ? '+' : '' }}{{ latestAnalysis.avgChangePercent?.toFixed(2) }}%
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<!-- 图表分析区域 -->
|
||||
<el-row :gutter="20" class="charts-section">
|
||||
<!-- 市场涨跌分布 -->
|
||||
<el-col :span="12">
|
||||
<el-card class="chart-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>市场涨跌分布</span>
|
||||
<el-button type="primary" size="small" @click="refreshLatestData" :loading="latestLoading">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
刷新
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<div id="distribution-chart" style="height: 350px;" v-loading="latestLoading"></div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<!-- 历史趋势分析 -->
|
||||
<el-col :span="12">
|
||||
<el-card class="chart-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>历史趋势分析</span>
|
||||
<div class="trend-controls">
|
||||
<el-select v-model="trendDays" @change="loadTrendData" size="small" style="width: 120px;">
|
||||
<el-option label="7天" :value="7" />
|
||||
<el-option label="15天" :value="15" />
|
||||
<el-option label="30天" :value="30" />
|
||||
<el-option label="60天" :value="60" />
|
||||
<el-option label="90天" :value="90" />
|
||||
</el-select>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div id="trend-chart" style="height: 350px;" v-loading="trendLoading"></div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 详细分析数据表格 -->
|
||||
<div class="analysis-table-section">
|
||||
<el-card class="table-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>历史分析数据</span>
|
||||
<div class="table-controls">
|
||||
<el-date-picker
|
||||
v-model="dateRange"
|
||||
type="daterange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
size="small"
|
||||
@change="loadRangeData"
|
||||
style="margin-right: 10px;"
|
||||
/>
|
||||
<el-button type="primary" size="small" @click="loadRangeData" :loading="rangeLoading">
|
||||
<el-icon><Search /></el-icon>
|
||||
查询
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-table :data="paginatedRangeData" v-loading="rangeLoading" stripe>
|
||||
<el-table-column prop="analysisDate" label="分析日期" width="120">
|
||||
<template #default="scope">
|
||||
{{ formatDate(scope.row.analysisDate) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="totalCount" label="总股票数" width="100" align="center" />
|
||||
<el-table-column label="涨跌分布" width="200">
|
||||
<template #default="scope">
|
||||
<div class="distribution-mini">
|
||||
<span class="dist-item up">
|
||||
<el-icon><CaretTop /></el-icon>
|
||||
{{ scope.row.upCount }}
|
||||
</span>
|
||||
<span class="dist-item down">
|
||||
<el-icon><CaretBottom /></el-icon>
|
||||
{{ scope.row.downCount }}
|
||||
</span>
|
||||
<span class="dist-item flat">
|
||||
<el-icon><Minus /></el-icon>
|
||||
{{ scope.row.flatCount }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="totalVolume" label="总成交量" width="150">
|
||||
<template #default="scope">
|
||||
{{ formatVolume(scope.row.totalVolume) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="totalMarketCap" label="总市值" width="150">
|
||||
<template #default="scope">
|
||||
{{ formatMarketCap(scope.row.totalMarketCap) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="avgChangePercent" label="平均涨跌幅" width="120">
|
||||
<template #default="scope">
|
||||
<span :style="{ color: scope.row.avgChangePercent >= 0 ? '#f56c6c' : '#67c23a' }">
|
||||
{{ scope.row.avgChangePercent >= 0 ? '+' : '' }}{{ scope.row.avgChangePercent?.toFixed(2) }}%
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="市场情绪" width="100">
|
||||
<template #default="scope">
|
||||
<el-tag :type="getMarketSentimentType(scope.row)" size="small">
|
||||
{{ getMarketSentiment(scope.row) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120">
|
||||
<template #default="scope">
|
||||
<el-button type="primary" size="small" @click="viewDetails(scope.row)">
|
||||
查看详情
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="pagination-container" v-if="rangeData.length > 0">
|
||||
<el-pagination
|
||||
v-model:current-page="currentPage"
|
||||
v-model:page-size="pageSize"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
:total="rangeData.length"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, onMounted, computed, nextTick } from 'vue'
|
||||
import { getLatestMarketAnalysis, getRecentMarketAnalysis, getMarketAnalysisByDateRange } from '@/api/market'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import * as echarts from 'echarts'
|
||||
|
||||
export default {
|
||||
name: 'MarketAnalysis',
|
||||
setup() {
|
||||
const latestAnalysis = ref({})
|
||||
const trendData = ref([])
|
||||
const rangeData = ref([])
|
||||
|
||||
const latestLoading = ref(false)
|
||||
const trendLoading = ref(false)
|
||||
const rangeLoading = ref(false)
|
||||
|
||||
const trendDays = ref(30)
|
||||
const dateRange = ref([])
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(20)
|
||||
|
||||
// 加载最新分析数据
|
||||
const loadLatestData = async () => {
|
||||
try {
|
||||
latestLoading.value = true
|
||||
const response = await getLatestMarketAnalysis()
|
||||
if (response.data) {
|
||||
latestAnalysis.value = response.data
|
||||
await nextTick()
|
||||
initDistributionChart()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载最新分析数据失败:', error)
|
||||
ElMessage.error('加载最新分析数据失败')
|
||||
} finally {
|
||||
latestLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 加载趋势数据
|
||||
const loadTrendData = async () => {
|
||||
try {
|
||||
trendLoading.value = true
|
||||
const response = await getRecentMarketAnalysis(trendDays.value)
|
||||
if (response.data) {
|
||||
trendData.value = response.data
|
||||
await nextTick()
|
||||
initTrendChart()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载趋势数据失败:', error)
|
||||
ElMessage.error('加载趋势数据失败')
|
||||
} finally {
|
||||
trendLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 加载日期范围数据
|
||||
const loadRangeData = async () => {
|
||||
if (!dateRange.value || dateRange.value.length !== 2) {
|
||||
await loadTrendData() // 如果没有选择日期范围,默认加载最近趋势数据
|
||||
rangeData.value = trendData.value
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
rangeLoading.value = true
|
||||
const [startDate, endDate] = dateRange.value
|
||||
const response = await getMarketAnalysisByDateRange(
|
||||
startDate.toISOString().split('T')[0],
|
||||
endDate.toISOString().split('T')[0]
|
||||
)
|
||||
if (response.data) {
|
||||
rangeData.value = response.data
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载日期范围数据失败:', error)
|
||||
ElMessage.error('加载数据失败')
|
||||
} finally {
|
||||
rangeLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化分布图表
|
||||
const initDistributionChart = async () => {
|
||||
const chartDom = document.getElementById('distribution-chart')
|
||||
if (!chartDom || !latestAnalysis.value.totalCount) return
|
||||
|
||||
const myChart = echarts.init(chartDom)
|
||||
const option = {
|
||||
title: {
|
||||
text: '市场涨跌分布',
|
||||
left: 'center',
|
||||
textStyle: {
|
||||
fontSize: 16
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: '{a} <br/>{b}: {c} ({d}%)'
|
||||
},
|
||||
legend: {
|
||||
orient: 'horizontal',
|
||||
bottom: '10%',
|
||||
left: 'center'
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '股票分布',
|
||||
type: 'pie',
|
||||
radius: ['30%', '70%'],
|
||||
center: ['50%', '45%'],
|
||||
data: [
|
||||
{
|
||||
value: latestAnalysis.value.upCount,
|
||||
name: '上涨',
|
||||
itemStyle: { color: '#f56c6c' }
|
||||
},
|
||||
{
|
||||
value: latestAnalysis.value.downCount,
|
||||
name: '下跌',
|
||||
itemStyle: { color: '#67c23a' }
|
||||
},
|
||||
{
|
||||
value: latestAnalysis.value.flatCount,
|
||||
name: '平盘',
|
||||
itemStyle: { color: '#909399' }
|
||||
}
|
||||
],
|
||||
emphasis: {
|
||||
itemStyle: {
|
||||
shadowBlur: 10,
|
||||
shadowOffsetX: 0,
|
||||
shadowColor: 'rgba(0, 0, 0, 0.5)'
|
||||
}
|
||||
},
|
||||
label: {
|
||||
show: true,
|
||||
formatter: '{b}\n{d}%'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
myChart.setOption(option)
|
||||
}
|
||||
|
||||
// 初始化趋势图表
|
||||
const initTrendChart = async () => {
|
||||
const chartDom = document.getElementById('trend-chart')
|
||||
if (!chartDom || trendData.value.length === 0) return
|
||||
|
||||
const myChart = echarts.init(chartDom)
|
||||
const dates = trendData.value.map(item => formatDate(item.analysisDate)).reverse()
|
||||
const avgChanges = trendData.value.map(item => item.avgChangePercent).reverse()
|
||||
const upCounts = trendData.value.map(item => item.upCount).reverse()
|
||||
const downCounts = trendData.value.map(item => item.downCount).reverse()
|
||||
|
||||
const option = {
|
||||
title: {
|
||||
text: '市场趋势分析',
|
||||
left: 'center',
|
||||
textStyle: {
|
||||
fontSize: 16
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis'
|
||||
},
|
||||
legend: {
|
||||
data: ['平均涨跌幅', '上涨股票数', '下跌股票数'],
|
||||
bottom: '5%'
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: dates,
|
||||
axisLabel: {
|
||||
rotate: 45
|
||||
}
|
||||
},
|
||||
yAxis: [
|
||||
{
|
||||
type: 'value',
|
||||
name: '涨跌幅(%)',
|
||||
position: 'left'
|
||||
},
|
||||
{
|
||||
type: 'value',
|
||||
name: '股票数量',
|
||||
position: 'right'
|
||||
}
|
||||
],
|
||||
series: [
|
||||
{
|
||||
name: '平均涨跌幅',
|
||||
type: 'line',
|
||||
data: avgChanges,
|
||||
smooth: true,
|
||||
lineStyle: { color: '#409EFF' },
|
||||
areaStyle: { color: 'rgba(64, 158, 255, 0.1)' }
|
||||
},
|
||||
{
|
||||
name: '上涨股票数',
|
||||
type: 'bar',
|
||||
yAxisIndex: 1,
|
||||
data: upCounts,
|
||||
itemStyle: { color: '#f56c6c' }
|
||||
},
|
||||
{
|
||||
name: '下跌股票数',
|
||||
type: 'bar',
|
||||
yAxisIndex: 1,
|
||||
data: downCounts,
|
||||
itemStyle: { color: '#67c23a' }
|
||||
}
|
||||
]
|
||||
}
|
||||
myChart.setOption(option)
|
||||
}
|
||||
|
||||
// 分页数据
|
||||
const paginatedRangeData = computed(() => {
|
||||
const start = (currentPage.value - 1) * pageSize.value
|
||||
const end = start + pageSize.value
|
||||
return rangeData.value.slice(start, end)
|
||||
})
|
||||
|
||||
// 计算百分比
|
||||
const getUpPercent = () => {
|
||||
if (!latestAnalysis.value.totalCount) return 0
|
||||
return ((latestAnalysis.value.upCount / latestAnalysis.value.totalCount) * 100).toFixed(1)
|
||||
}
|
||||
|
||||
const getDownPercent = () => {
|
||||
if (!latestAnalysis.value.totalCount) return 0
|
||||
return ((latestAnalysis.value.downCount / latestAnalysis.value.totalCount) * 100).toFixed(1)
|
||||
}
|
||||
|
||||
const getFlatPercent = () => {
|
||||
if (!latestAnalysis.value.totalCount) return 0
|
||||
return ((latestAnalysis.value.flatCount / latestAnalysis.value.totalCount) * 100).toFixed(1)
|
||||
}
|
||||
|
||||
// 格式化函数
|
||||
const formatDate = (dateStr) => {
|
||||
if (!dateStr) return ''
|
||||
return dateStr.split('T')[0]
|
||||
}
|
||||
|
||||
const formatVolume = (volume) => {
|
||||
if (!volume) return '0手'
|
||||
if (volume >= 100000000) {
|
||||
return (volume / 100000000).toFixed(1) + '亿手'
|
||||
} else if (volume >= 10000) {
|
||||
return (volume / 10000).toFixed(1) + '万手'
|
||||
} else {
|
||||
return volume + '手'
|
||||
}
|
||||
}
|
||||
|
||||
const formatMarketCap = (marketCap) => {
|
||||
if (!marketCap) return '0元'
|
||||
if (marketCap >= 100000000) {
|
||||
return (marketCap / 100000000).toFixed(1) + '亿元'
|
||||
} else if (marketCap >= 10000) {
|
||||
return (marketCap / 10000).toFixed(1) + '万元'
|
||||
} else {
|
||||
return marketCap + '元'
|
||||
}
|
||||
}
|
||||
|
||||
// 市场情绪分析
|
||||
const getMarketSentiment = (data) => {
|
||||
const upPercent = (data.upCount / data.totalCount) * 100
|
||||
if (upPercent >= 60) return '乐观'
|
||||
if (upPercent >= 40) return '中性'
|
||||
return '悲观'
|
||||
}
|
||||
|
||||
const getMarketSentimentType = (data) => {
|
||||
const upPercent = (data.upCount / data.totalCount) * 100
|
||||
if (upPercent >= 60) return 'success'
|
||||
if (upPercent >= 40) return 'warning'
|
||||
return 'danger'
|
||||
}
|
||||
|
||||
// 分页事件
|
||||
const handleSizeChange = (val) => {
|
||||
pageSize.value = val
|
||||
currentPage.value = 1
|
||||
}
|
||||
|
||||
const handleCurrentChange = (val) => {
|
||||
currentPage.value = val
|
||||
}
|
||||
|
||||
// 刷新最新数据
|
||||
const refreshLatestData = () => {
|
||||
loadLatestData()
|
||||
}
|
||||
|
||||
// 查看详情
|
||||
const viewDetails = (data) => {
|
||||
ElMessage.info(`查看 ${formatDate(data.analysisDate)} 的详细分析数据`)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadLatestData()
|
||||
await loadTrendData()
|
||||
rangeData.value = trendData.value
|
||||
})
|
||||
|
||||
return {
|
||||
latestAnalysis,
|
||||
trendData,
|
||||
rangeData,
|
||||
latestLoading,
|
||||
trendLoading,
|
||||
rangeLoading,
|
||||
trendDays,
|
||||
dateRange,
|
||||
currentPage,
|
||||
pageSize,
|
||||
paginatedRangeData,
|
||||
loadTrendData,
|
||||
loadRangeData,
|
||||
getUpPercent,
|
||||
getDownPercent,
|
||||
getFlatPercent,
|
||||
formatDate,
|
||||
formatVolume,
|
||||
formatMarketCap,
|
||||
getMarketSentiment,
|
||||
getMarketSentimentType,
|
||||
handleSizeChange,
|
||||
handleCurrentChange,
|
||||
refreshLatestData,
|
||||
viewDetails
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.market-analysis {
|
||||
padding: 20px;
|
||||
background-color: #f5f7fa;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
|
||||
h1 {
|
||||
font-size: 28px;
|
||||
color: #303133;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
p {
|
||||
color: #909399;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.latest-analysis {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.analysis-card, .chart-card, .table-card {
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
span {
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.analysis-date {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
color: #909399;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.market-overview {
|
||||
.overview-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
color: white;
|
||||
|
||||
.item-icon {
|
||||
font-size: 40px;
|
||||
margin-right: 15px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.item-content {
|
||||
flex: 1;
|
||||
|
||||
.item-value {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.item-label {
|
||||
font-size: 14px;
|
||||
opacity: 0.9;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.item-percent {
|
||||
font-size: 12px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
&.total {
|
||||
background: linear-gradient(135deg, #606266, #909399);
|
||||
}
|
||||
|
||||
&.up {
|
||||
background: linear-gradient(135deg, #f56c6c, #ff8a80);
|
||||
}
|
||||
|
||||
&.down {
|
||||
background: linear-gradient(135deg, #67c23a, #81c784);
|
||||
}
|
||||
|
||||
&.flat {
|
||||
background: linear-gradient(135deg, #909399, #b0b3b8);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.market-metrics {
|
||||
.metric-card {
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
|
||||
.metric-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 15px;
|
||||
color: #606266;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
|
||||
&.volume {
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
&.market-cap {
|
||||
color: #e6a23c;
|
||||
}
|
||||
|
||||
&.positive {
|
||||
color: #f56c6c;
|
||||
}
|
||||
|
||||
&.negative {
|
||||
color: #67c23a;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.charts-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.trend-controls, .table-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.analysis-table-section {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.distribution-mini {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
|
||||
.dist-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
font-size: 12px;
|
||||
|
||||
&.up {
|
||||
color: #f56c6c;
|
||||
}
|
||||
|
||||
&.down {
|
||||
color: #67c23a;
|
||||
}
|
||||
|
||||
&.flat {
|
||||
color: #909399;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.pagination-container {
|
||||
margin-top: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
:deep(.el-table) {
|
||||
.el-table__header {
|
||||
th {
|
||||
background-color: #f8f9fa;
|
||||
color: #303133;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
407
frontend/src/views/Rankings.vue
Normal file
407
frontend/src/views/Rankings.vue
Normal file
@@ -0,0 +1,407 @@
|
||||
<template>
|
||||
<div class="rankings">
|
||||
<!-- 页面标题 -->
|
||||
<div class="page-header">
|
||||
<h1>股票排行榜</h1>
|
||||
<p>实时股票市场排行数据</p>
|
||||
</div>
|
||||
|
||||
<!-- 排行榜类型切换 -->
|
||||
<div class="ranking-tabs">
|
||||
<el-tabs v-model="activeTab" @tab-change="handleTabChange">
|
||||
<el-tab-pane label="涨幅排行" name="growth">
|
||||
<div class="ranking-content">
|
||||
<div class="ranking-header">
|
||||
<span class="ranking-title">
|
||||
<el-icon><TrendCharts /></el-icon>
|
||||
涨幅排行榜
|
||||
</span>
|
||||
<div class="ranking-controls">
|
||||
<el-select v-model="rankingLimit" @change="loadRankingData" size="small" style="width: 100px;">
|
||||
<el-option label="前10名" :value="10" />
|
||||
<el-option label="前20名" :value="20" />
|
||||
<el-option label="前50名" :value="50" />
|
||||
</el-select>
|
||||
<el-button type="primary" size="small" @click="loadRankingData" :loading="loading">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
刷新
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<el-table :data="growthRanking" v-loading="loading" stripe>
|
||||
<el-table-column label="排名" width="80" align="center">
|
||||
<template #default="scope">
|
||||
<el-tag
|
||||
:type="scope.$index < 3 ? 'danger' : 'info'"
|
||||
effect="dark"
|
||||
size="small"
|
||||
>
|
||||
{{ scope.$index + 1 }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="stockCode" label="股票代码" width="100" />
|
||||
<el-table-column prop="stockName" label="股票名称" width="150" />
|
||||
<el-table-column prop="currentPrice" label="当前价格" width="100">
|
||||
<template #default="scope">
|
||||
¥{{ scope.row.currentPrice?.toFixed(2) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="changePercent" label="涨跌幅" width="120">
|
||||
<template #default="scope">
|
||||
<span :style="{ color: scope.row.changePercent >= 0 ? '#f56c6c' : '#67c23a' }">
|
||||
{{ scope.row.changePercent >= 0 ? '+' : '' }}{{ scope.row.changePercent?.toFixed(2) }}%
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="changeAmount" label="涨跌额" width="100">
|
||||
<template #default="scope">
|
||||
<span :style="{ color: scope.row.changeAmount >= 0 ? '#f56c6c' : '#67c23a' }">
|
||||
{{ scope.row.changeAmount >= 0 ? '+' : '' }}{{ scope.row.changeAmount?.toFixed(2) }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="volume" label="成交量" width="120">
|
||||
<template #default="scope">
|
||||
{{ formatVolume(scope.row.volume) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="marketCap" label="市值" width="120">
|
||||
<template #default="scope">
|
||||
{{ formatMarketCap(scope.row.marketCap) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="150">
|
||||
<template #default="scope">
|
||||
<el-button type="primary" size="small" @click="viewDetails(scope.row)">
|
||||
详情
|
||||
</el-button>
|
||||
<el-button type="success" size="small" @click="viewTrend(scope.row)">
|
||||
趋势
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="市值排行" name="marketCap">
|
||||
<div class="ranking-content">
|
||||
<div class="ranking-header">
|
||||
<span class="ranking-title">
|
||||
<el-icon><Money /></el-icon>
|
||||
市值排行榜
|
||||
</span>
|
||||
<div class="ranking-controls">
|
||||
<el-select v-model="rankingLimit" @change="loadRankingData" size="small" style="width: 100px;">
|
||||
<el-option label="前10名" :value="10" />
|
||||
<el-option label="前20名" :value="20" />
|
||||
<el-option label="前50名" :value="50" />
|
||||
</el-select>
|
||||
<el-button type="primary" size="small" @click="loadRankingData" :loading="loading">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
刷新
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<el-table :data="marketCapRanking" v-loading="loading" stripe>
|
||||
<el-table-column label="排名" width="80" align="center">
|
||||
<template #default="scope">
|
||||
<el-tag
|
||||
:type="scope.$index < 3 ? 'warning' : 'info'"
|
||||
effect="dark"
|
||||
size="small"
|
||||
>
|
||||
{{ scope.$index + 1 }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="stockCode" label="股票代码" width="100" />
|
||||
<el-table-column prop="stockName" label="股票名称" width="150" />
|
||||
<el-table-column prop="currentPrice" label="当前价格" width="100">
|
||||
<template #default="scope">
|
||||
¥{{ scope.row.currentPrice?.toFixed(2) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="marketCap" label="市值" width="150">
|
||||
<template #default="scope">
|
||||
<span style="font-weight: bold; color: #e6a23c;">
|
||||
{{ formatMarketCap(scope.row.marketCap) }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="changePercent" label="涨跌幅" width="120">
|
||||
<template #default="scope">
|
||||
<span :style="{ color: scope.row.changePercent >= 0 ? '#f56c6c' : '#67c23a' }">
|
||||
{{ scope.row.changePercent >= 0 ? '+' : '' }}{{ scope.row.changePercent?.toFixed(2) }}%
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="volume" label="成交量" width="120">
|
||||
<template #default="scope">
|
||||
{{ formatVolume(scope.row.volume) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="150">
|
||||
<template #default="scope">
|
||||
<el-button type="primary" size="small" @click="viewDetails(scope.row)">
|
||||
详情
|
||||
</el-button>
|
||||
<el-button type="success" size="small" @click="viewTrend(scope.row)">
|
||||
趋势
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="成交量排行" name="volume">
|
||||
<div class="ranking-content">
|
||||
<div class="ranking-header">
|
||||
<span class="ranking-title">
|
||||
<el-icon><DataAnalysis /></el-icon>
|
||||
成交量排行榜
|
||||
</span>
|
||||
<div class="ranking-controls">
|
||||
<el-select v-model="rankingLimit" @change="loadRankingData" size="small" style="width: 100px;">
|
||||
<el-option label="前10名" :value="10" />
|
||||
<el-option label="前20名" :value="20" />
|
||||
<el-option label="前50名" :value="50" />
|
||||
</el-select>
|
||||
<el-button type="primary" size="small" @click="loadRankingData" :loading="loading">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
刷新
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<el-table :data="volumeRanking" v-loading="loading" stripe>
|
||||
<el-table-column label="排名" width="80" align="center">
|
||||
<template #default="scope">
|
||||
<el-tag
|
||||
:type="scope.$index < 3 ? 'success' : 'info'"
|
||||
effect="dark"
|
||||
size="small"
|
||||
>
|
||||
{{ scope.$index + 1 }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="stockCode" label="股票代码" width="100" />
|
||||
<el-table-column prop="stockName" label="股票名称" width="150" />
|
||||
<el-table-column prop="currentPrice" label="当前价格" width="100">
|
||||
<template #default="scope">
|
||||
¥{{ scope.row.currentPrice?.toFixed(2) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="volume" label="成交量" width="150">
|
||||
<template #default="scope">
|
||||
<span style="font-weight: bold; color: #409eff;">
|
||||
{{ formatVolume(scope.row.volume) }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="changePercent" label="涨跌幅" width="120">
|
||||
<template #default="scope">
|
||||
<span :style="{ color: scope.row.changePercent >= 0 ? '#f56c6c' : '#67c23a' }">
|
||||
{{ scope.row.changePercent >= 0 ? '+' : '' }}{{ scope.row.changePercent?.toFixed(2) }}%
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="marketCap" label="市值" width="120">
|
||||
<template #default="scope">
|
||||
{{ formatMarketCap(scope.row.marketCap) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="150">
|
||||
<template #default="scope">
|
||||
<el-button type="primary" size="small" @click="viewDetails(scope.row)">
|
||||
详情
|
||||
</el-button>
|
||||
<el-button type="success" size="small" @click="viewTrend(scope.row)">
|
||||
趋势
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { getGrowthRanking, getMarketCapRanking, getVolumeRanking } from '@/api/stock'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
export default {
|
||||
name: 'Rankings',
|
||||
setup() {
|
||||
const router = useRouter()
|
||||
const activeTab = ref('growth')
|
||||
const rankingLimit = ref(10)
|
||||
const loading = ref(false)
|
||||
|
||||
const growthRanking = ref([])
|
||||
const marketCapRanking = ref([])
|
||||
const volumeRanking = ref([])
|
||||
|
||||
// 加载排行榜数据
|
||||
const loadRankingData = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
|
||||
if (activeTab.value === 'growth') {
|
||||
const response = await getGrowthRanking(rankingLimit.value)
|
||||
if (response.data) {
|
||||
growthRanking.value = response.data
|
||||
}
|
||||
} else if (activeTab.value === 'marketCap') {
|
||||
const response = await getMarketCapRanking(rankingLimit.value)
|
||||
if (response.data) {
|
||||
marketCapRanking.value = response.data
|
||||
}
|
||||
} else if (activeTab.value === 'volume') {
|
||||
const response = await getVolumeRanking(rankingLimit.value)
|
||||
if (response.data) {
|
||||
volumeRanking.value = response.data
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载排行榜数据失败:', error)
|
||||
ElMessage.error('加载排行榜数据失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 标签页切换
|
||||
const handleTabChange = (tabName) => {
|
||||
activeTab.value = tabName
|
||||
loadRankingData()
|
||||
}
|
||||
|
||||
// 格式化成交量
|
||||
const formatVolume = (volume) => {
|
||||
if (!volume) return '0手'
|
||||
if (volume >= 100000000) {
|
||||
return (volume / 100000000).toFixed(1) + '亿手'
|
||||
} else if (volume >= 10000) {
|
||||
return (volume / 10000).toFixed(1) + '万手'
|
||||
} else {
|
||||
return volume + '手'
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化市值
|
||||
const formatMarketCap = (marketCap) => {
|
||||
if (!marketCap) return '0元'
|
||||
if (marketCap >= 100000000) {
|
||||
return (marketCap / 100000000).toFixed(1) + '亿元'
|
||||
} else if (marketCap >= 10000) {
|
||||
return (marketCap / 10000).toFixed(1) + '万元'
|
||||
} else {
|
||||
return marketCap + '元'
|
||||
}
|
||||
}
|
||||
|
||||
// 查看股票详情
|
||||
const viewDetails = (stock) => {
|
||||
router.push(`/stock/${stock.stockCode}`)
|
||||
}
|
||||
|
||||
// 查看股票趋势
|
||||
const viewTrend = (stock) => {
|
||||
router.push(`/stock/${stock.stockCode}/trend`)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadRankingData()
|
||||
})
|
||||
|
||||
return {
|
||||
activeTab,
|
||||
rankingLimit,
|
||||
loading,
|
||||
growthRanking,
|
||||
marketCapRanking,
|
||||
volumeRanking,
|
||||
loadRankingData,
|
||||
handleTabChange,
|
||||
formatVolume,
|
||||
formatMarketCap,
|
||||
viewDetails,
|
||||
viewTrend
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.rankings {
|
||||
padding: 20px;
|
||||
background-color: #f5f7fa;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
|
||||
h1 {
|
||||
font-size: 28px;
|
||||
color: #303133;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
p {
|
||||
color: #909399;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.ranking-tabs {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.ranking-content {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.ranking-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
|
||||
.ranking-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ranking-controls {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-table) {
|
||||
.el-table__header {
|
||||
th {
|
||||
background-color: #f8f9fa;
|
||||
color: #303133;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
772
frontend/src/views/StockDetail.vue
Normal file
772
frontend/src/views/StockDetail.vue
Normal file
@@ -0,0 +1,772 @@
|
||||
<template>
|
||||
<div class="stock-detail">
|
||||
<!-- 页面标题 -->
|
||||
<div class="page-header">
|
||||
<div class="stock-info">
|
||||
<h1>{{ stockInfo.stockName || stockCode }}</h1>
|
||||
<p>股票代码: {{ stockCode }}</p>
|
||||
</div>
|
||||
<div class="action-buttons">
|
||||
<el-button @click="goBack">
|
||||
<el-icon><ArrowLeft /></el-icon>
|
||||
返回
|
||||
</el-button>
|
||||
<el-button type="primary" @click="refreshData" :loading="loading">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
刷新数据
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 股票基本信息卡片 -->
|
||||
<div class="stock-overview" v-if="stockInfo.stockCode">
|
||||
<el-card class="overview-card">
|
||||
<div class="overview-content">
|
||||
<div class="price-section">
|
||||
<div class="current-price">
|
||||
¥{{ stockInfo.currentPrice?.toFixed(2) }}
|
||||
</div>
|
||||
<div class="price-change" :class="{ 'positive': stockInfo.changePercent >= 0, 'negative': stockInfo.changePercent < 0 }">
|
||||
{{ stockInfo.changePercent >= 0 ? '+' : '' }}{{ stockInfo.changePercent?.toFixed(2) }}%
|
||||
({{ stockInfo.changePercent >= 0 ? '+' : '' }}{{ stockInfo.changeAmount?.toFixed(2) }})
|
||||
</div>
|
||||
</div>
|
||||
<div class="stock-metrics">
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="6">
|
||||
<div class="metric-item">
|
||||
<div class="metric-label">成交量</div>
|
||||
<div class="metric-value">{{ formatVolume(stockInfo.volume) }}</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="metric-item">
|
||||
<div class="metric-label">市值</div>
|
||||
<div class="metric-value">{{ formatMarketCap(stockInfo.marketCap) }}</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="metric-item">
|
||||
<div class="metric-label">最高价</div>
|
||||
<div class="metric-value">¥{{ stockInfo.highPrice?.toFixed(2) }}</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="metric-item">
|
||||
<div class="metric-label">最低价</div>
|
||||
<div class="metric-value">¥{{ stockInfo.lowPrice?.toFixed(2) }}</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<!-- 图表和分析区域 -->
|
||||
<el-row :gutter="20" class="charts-section">
|
||||
<!-- 历史价格走势 -->
|
||||
<el-col :span="12">
|
||||
<el-card class="chart-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>历史价格走势</span>
|
||||
<div class="chart-controls">
|
||||
<el-select v-model="historyDays" @change="loadHistoryData" size="small" style="width: 120px;">
|
||||
<el-option label="7天" :value="7" />
|
||||
<el-option label="30天" :value="30" />
|
||||
<el-option label="90天" :value="90" />
|
||||
<el-option label="180天" :value="180" />
|
||||
<el-option label="1年" :value="365" />
|
||||
</el-select>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div id="price-chart" style="height: 400px;" v-loading="historyLoading"></div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<!-- 趋势分析 -->
|
||||
<el-col :span="12">
|
||||
<el-card class="chart-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>趋势分析</span>
|
||||
<div class="chart-controls">
|
||||
<el-select v-model="trendDays" @change="loadTrendData" size="small" style="width: 120px;">
|
||||
<el-option label="7天" :value="7" />
|
||||
<el-option label="30天" :value="30" />
|
||||
<el-option label="60天" :value="60" />
|
||||
<el-option label="90天" :value="90" />
|
||||
</el-select>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div class="trend-content" v-loading="trendLoading">
|
||||
<div v-if="trendData.trend" class="trend-summary">
|
||||
<div class="trend-direction" :class="trendData.trend">
|
||||
<el-icon v-if="trendData.trend === 'up'"><CaretTop /></el-icon>
|
||||
<el-icon v-else-if="trendData.trend === 'down'"><CaretBottom /></el-icon>
|
||||
<el-icon v-else><Minus /></el-icon>
|
||||
{{ getTrendText(trendData.trend) }}
|
||||
</div>
|
||||
<div class="trend-details">
|
||||
<p><strong>趋势强度:</strong> {{ trendData.strength || '中等' }}</p>
|
||||
<p><strong>支撑位:</strong> ¥{{ trendData.supportLevel?.toFixed(2) }}</p>
|
||||
<p><strong>阻力位:</strong> ¥{{ trendData.resistanceLevel?.toFixed(2) }}</p>
|
||||
<p><strong>建议:</strong> {{ trendData.recommendation || '持续观察' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div id="trend-chart" style="height: 300px;"></div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 预测数据 -->
|
||||
<el-row class="prediction-section">
|
||||
<el-col :span="24">
|
||||
<el-card class="prediction-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>股票预测</span>
|
||||
<div class="prediction-controls">
|
||||
<el-select v-model="predictionDays" @change="loadPredictionData" size="small" style="width: 120px;">
|
||||
<el-option label="3天" :value="3" />
|
||||
<el-option label="7天" :value="7" />
|
||||
<el-option label="15天" :value="15" />
|
||||
<el-option label="30天" :value="30" />
|
||||
</el-select>
|
||||
<el-button type="primary" size="small" @click="loadPredictionData" :loading="predictionLoading">
|
||||
生成预测
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div class="prediction-content">
|
||||
<div class="prediction-chart-container">
|
||||
<div id="prediction-chart" style="height: 400px;" v-loading="predictionLoading"></div>
|
||||
</div>
|
||||
<div class="prediction-summary" v-if="predictionData.length > 0">
|
||||
<el-alert
|
||||
:title="`预测未来${predictionDays}天的股价走势`"
|
||||
type="info"
|
||||
:closable="false"
|
||||
style="margin-top: 20px;"
|
||||
/>
|
||||
<div class="prediction-stats">
|
||||
<el-row :gutter="20" style="margin-top: 20px;">
|
||||
<el-col :span="6">
|
||||
<div class="prediction-stat">
|
||||
<div class="stat-label">预测最高价</div>
|
||||
<div class="stat-value positive">¥{{ getPredictionMax() }}</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="prediction-stat">
|
||||
<div class="stat-label">预测最低价</div>
|
||||
<div class="stat-value negative">¥{{ getPredictionMin() }}</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="prediction-stat">
|
||||
<div class="stat-label">平均预测价</div>
|
||||
<div class="stat-value">¥{{ getPredictionAvg() }}</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="prediction-stat">
|
||||
<div class="stat-label">预期收益率</div>
|
||||
<div class="stat-value" :class="{ 'positive': getPredictionReturn() > 0, 'negative': getPredictionReturn() < 0 }">
|
||||
{{ getPredictionReturn() > 0 ? '+' : '' }}{{ getPredictionReturn() }}%
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, onMounted, nextTick } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { getStockHistory, getStockTrend, getStockPrediction, searchStocks } from '@/api/stock'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import * as echarts from 'echarts'
|
||||
|
||||
export default {
|
||||
name: 'StockDetail',
|
||||
setup() {
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const stockCode = ref(route.params.stockCode)
|
||||
const stockInfo = ref({})
|
||||
const historyData = ref([])
|
||||
const trendData = ref({})
|
||||
const predictionData = ref([])
|
||||
|
||||
const loading = ref(false)
|
||||
const historyLoading = ref(false)
|
||||
const trendLoading = ref(false)
|
||||
const predictionLoading = ref(false)
|
||||
|
||||
const historyDays = ref(30)
|
||||
const trendDays = ref(30)
|
||||
const predictionDays = ref(7)
|
||||
|
||||
// 加载股票基本信息
|
||||
const loadStockInfo = async () => {
|
||||
try {
|
||||
const response = await searchStocks(stockCode.value)
|
||||
if (response.data && response.data.length > 0) {
|
||||
stockInfo.value = response.data[0]
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载股票信息失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 加载历史数据
|
||||
const loadHistoryData = async () => {
|
||||
try {
|
||||
historyLoading.value = true
|
||||
const endDate = new Date()
|
||||
const startDate = new Date()
|
||||
startDate.setDate(endDate.getDate() - historyDays.value)
|
||||
|
||||
const response = await getStockHistory(
|
||||
stockCode.value,
|
||||
startDate.toISOString(),
|
||||
endDate.toISOString()
|
||||
)
|
||||
if (response.data) {
|
||||
historyData.value = response.data
|
||||
await nextTick()
|
||||
initPriceChart()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载历史数据失败:', error)
|
||||
ElMessage.error('加载历史数据失败')
|
||||
} finally {
|
||||
historyLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 加载趋势数据
|
||||
const loadTrendData = async () => {
|
||||
try {
|
||||
trendLoading.value = true
|
||||
const response = await getStockTrend(stockCode.value, trendDays.value)
|
||||
if (response.data) {
|
||||
trendData.value = response.data
|
||||
await nextTick()
|
||||
initTrendChart()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载趋势数据失败:', error)
|
||||
ElMessage.error('加载趋势数据失败')
|
||||
} finally {
|
||||
trendLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 加载预测数据
|
||||
const loadPredictionData = async () => {
|
||||
try {
|
||||
predictionLoading.value = true
|
||||
const response = await getStockPrediction(stockCode.value, predictionDays.value)
|
||||
if (response.data) {
|
||||
predictionData.value = response.data
|
||||
await nextTick()
|
||||
initPredictionChart()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载预测数据失败:', error)
|
||||
ElMessage.error('加载预测数据失败')
|
||||
} finally {
|
||||
predictionLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化价格图表
|
||||
const initPriceChart = async () => {
|
||||
const chartDom = document.getElementById('price-chart')
|
||||
if (!chartDom || historyData.value.length === 0) return
|
||||
|
||||
const myChart = echarts.init(chartDom)
|
||||
const dates = historyData.value.map(item => item.timestamp?.split('T')[0] || item.date)
|
||||
const prices = historyData.value.map(item => item.currentPrice)
|
||||
|
||||
const option = {
|
||||
title: {
|
||||
text: '价格走势',
|
||||
left: 'center'
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
formatter: (params) => {
|
||||
const dataIndex = params[0].dataIndex
|
||||
const data = historyData.value[dataIndex]
|
||||
return `
|
||||
<div>
|
||||
<p>日期: ${dates[dataIndex]}</p>
|
||||
<p>价格: ¥${data.currentPrice?.toFixed(2)}</p>
|
||||
<p>涨跌幅: ${data.changePercent?.toFixed(2)}%</p>
|
||||
<p>成交量: ${formatVolume(data.volume)}</p>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: dates
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
name: '价格(¥)',
|
||||
scale: true
|
||||
},
|
||||
series: [
|
||||
{
|
||||
data: prices,
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
lineStyle: {
|
||||
color: '#409EFF',
|
||||
width: 2
|
||||
},
|
||||
areaStyle: {
|
||||
color: 'rgba(64, 158, 255, 0.1)'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
myChart.setOption(option)
|
||||
}
|
||||
|
||||
// 初始化趋势图表
|
||||
const initTrendChart = async () => {
|
||||
const chartDom = document.getElementById('trend-chart')
|
||||
if (!chartDom || !trendData.value.priceHistory) return
|
||||
|
||||
const myChart = echarts.init(chartDom)
|
||||
const data = trendData.value.priceHistory || []
|
||||
const dates = data.map(item => item.date)
|
||||
const prices = data.map(item => item.price)
|
||||
const ma5 = data.map(item => item.ma5)
|
||||
const ma20 = data.map(item => item.ma20)
|
||||
|
||||
const option = {
|
||||
tooltip: {
|
||||
trigger: 'axis'
|
||||
},
|
||||
legend: {
|
||||
data: ['股价', 'MA5', 'MA20']
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: dates
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
name: '价格(¥)',
|
||||
scale: true
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '股价',
|
||||
data: prices,
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
lineStyle: { color: '#409EFF' }
|
||||
},
|
||||
{
|
||||
name: 'MA5',
|
||||
data: ma5,
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
lineStyle: { color: '#67C23A' }
|
||||
},
|
||||
{
|
||||
name: 'MA20',
|
||||
data: ma20,
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
lineStyle: { color: '#E6A23C' }
|
||||
}
|
||||
]
|
||||
}
|
||||
myChart.setOption(option)
|
||||
}
|
||||
|
||||
// 初始化预测图表
|
||||
const initPredictionChart = async () => {
|
||||
const chartDom = document.getElementById('prediction-chart')
|
||||
if (!chartDom || predictionData.value.length === 0) return
|
||||
|
||||
const myChart = echarts.init(chartDom)
|
||||
|
||||
// 历史数据(最近10天)
|
||||
const historyDates = historyData.value.slice(-10).map(item => item.timestamp?.split('T')[0] || item.date)
|
||||
const historyPrices = historyData.value.slice(-10).map(item => item.currentPrice)
|
||||
|
||||
// 预测数据
|
||||
const predictionDates = predictionData.value.map(item => item.timestamp?.split('T')[0] || item.date)
|
||||
const predictionPrices = predictionData.value.map(item => item.currentPrice)
|
||||
|
||||
const allDates = [...historyDates, ...predictionDates]
|
||||
const historySeries = [...historyPrices, ...new Array(predictionDates.length).fill(null)]
|
||||
const predictionSeries = [...new Array(historyDates.length).fill(null), ...predictionPrices]
|
||||
|
||||
const option = {
|
||||
title: {
|
||||
text: '股价预测',
|
||||
left: 'center'
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis'
|
||||
},
|
||||
legend: {
|
||||
data: ['历史价格', '预测价格']
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: allDates
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
name: '价格(¥)',
|
||||
scale: true
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '历史价格',
|
||||
data: historySeries,
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
lineStyle: { color: '#409EFF' },
|
||||
areaStyle: { color: 'rgba(64, 158, 255, 0.1)' }
|
||||
},
|
||||
{
|
||||
name: '预测价格',
|
||||
data: predictionSeries,
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
lineStyle: {
|
||||
color: '#F56C6C',
|
||||
type: 'dashed'
|
||||
},
|
||||
areaStyle: { color: 'rgba(245, 108, 108, 0.1)' }
|
||||
}
|
||||
]
|
||||
}
|
||||
myChart.setOption(option)
|
||||
}
|
||||
|
||||
// 格式化函数
|
||||
const formatVolume = (volume) => {
|
||||
if (!volume) return '0手'
|
||||
if (volume >= 100000000) {
|
||||
return (volume / 100000000).toFixed(1) + '亿手'
|
||||
} else if (volume >= 10000) {
|
||||
return (volume / 10000).toFixed(1) + '万手'
|
||||
} else {
|
||||
return volume + '手'
|
||||
}
|
||||
}
|
||||
|
||||
const formatMarketCap = (marketCap) => {
|
||||
if (!marketCap) return '0元'
|
||||
if (marketCap >= 100000000) {
|
||||
return (marketCap / 100000000).toFixed(1) + '亿元'
|
||||
} else if (marketCap >= 10000) {
|
||||
return (marketCap / 10000).toFixed(1) + '万元'
|
||||
} else {
|
||||
return marketCap + '元'
|
||||
}
|
||||
}
|
||||
|
||||
const getTrendText = (trend) => {
|
||||
switch (trend) {
|
||||
case 'up': return '上升趋势'
|
||||
case 'down': return '下降趋势'
|
||||
default: return '震荡整理'
|
||||
}
|
||||
}
|
||||
|
||||
// 预测统计
|
||||
const getPredictionMax = () => {
|
||||
if (predictionData.value.length === 0) return '0.00'
|
||||
return Math.max(...predictionData.value.map(item => item.currentPrice)).toFixed(2)
|
||||
}
|
||||
|
||||
const getPredictionMin = () => {
|
||||
if (predictionData.value.length === 0) return '0.00'
|
||||
return Math.min(...predictionData.value.map(item => item.currentPrice)).toFixed(2)
|
||||
}
|
||||
|
||||
const getPredictionAvg = () => {
|
||||
if (predictionData.value.length === 0) return '0.00'
|
||||
const avg = predictionData.value.reduce((sum, item) => sum + item.currentPrice, 0) / predictionData.value.length
|
||||
return avg.toFixed(2)
|
||||
}
|
||||
|
||||
const getPredictionReturn = () => {
|
||||
if (predictionData.value.length === 0 || !stockInfo.value.currentPrice) return 0
|
||||
const lastPrediction = predictionData.value[predictionData.value.length - 1]
|
||||
const returnRate = ((lastPrediction.currentPrice - stockInfo.value.currentPrice) / stockInfo.value.currentPrice) * 100
|
||||
return returnRate.toFixed(2)
|
||||
}
|
||||
|
||||
// 刷新所有数据
|
||||
const refreshData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
await Promise.all([
|
||||
loadStockInfo(),
|
||||
loadHistoryData(),
|
||||
loadTrendData(),
|
||||
loadPredictionData()
|
||||
])
|
||||
ElMessage.success('数据刷新成功')
|
||||
} catch (error) {
|
||||
ElMessage.error('数据刷新失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 返回上一页
|
||||
const goBack = () => {
|
||||
router.back()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadStockInfo()
|
||||
await loadHistoryData()
|
||||
await loadTrendData()
|
||||
await loadPredictionData()
|
||||
})
|
||||
|
||||
return {
|
||||
stockCode,
|
||||
stockInfo,
|
||||
historyData,
|
||||
trendData,
|
||||
predictionData,
|
||||
loading,
|
||||
historyLoading,
|
||||
trendLoading,
|
||||
predictionLoading,
|
||||
historyDays,
|
||||
trendDays,
|
||||
predictionDays,
|
||||
loadHistoryData,
|
||||
loadTrendData,
|
||||
loadPredictionData,
|
||||
formatVolume,
|
||||
formatMarketCap,
|
||||
getTrendText,
|
||||
getPredictionMax,
|
||||
getPredictionMin,
|
||||
getPredictionAvg,
|
||||
getPredictionReturn,
|
||||
refreshData,
|
||||
goBack
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.stock-detail {
|
||||
padding: 20px;
|
||||
background-color: #f5f7fa;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 30px;
|
||||
|
||||
.stock-info {
|
||||
h1 {
|
||||
font-size: 28px;
|
||||
color: #303133;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
p {
|
||||
color: #909399;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.stock-overview {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.overview-card {
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.overview-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
.price-section {
|
||||
.current-price {
|
||||
font-size: 36px;
|
||||
font-weight: bold;
|
||||
color: #303133;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.price-change {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
|
||||
&.positive {
|
||||
color: #f56c6c;
|
||||
}
|
||||
|
||||
&.negative {
|
||||
color: #67c23a;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.stock-metrics {
|
||||
flex: 1;
|
||||
margin-left: 40px;
|
||||
}
|
||||
|
||||
.metric-item {
|
||||
text-align: center;
|
||||
|
||||
.metric-label {
|
||||
font-size: 14px;
|
||||
color: #909399;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.charts-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.chart-card, .prediction-card {
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
span {
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
}
|
||||
|
||||
.chart-controls, .prediction-controls {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.trend-content {
|
||||
.trend-summary {
|
||||
margin-bottom: 20px;
|
||||
|
||||
.trend-direction {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 15px;
|
||||
|
||||
&.up {
|
||||
color: #f56c6c;
|
||||
}
|
||||
|
||||
&.down {
|
||||
color: #67c23a;
|
||||
}
|
||||
|
||||
&.flat {
|
||||
color: #909399;
|
||||
}
|
||||
}
|
||||
|
||||
.trend-details {
|
||||
p {
|
||||
margin: 5px 0;
|
||||
color: #606266;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.prediction-content {
|
||||
.prediction-chart-container {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.prediction-stats {
|
||||
.prediction-stat {
|
||||
text-align: center;
|
||||
padding: 15px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 6px;
|
||||
|
||||
.stat-label {
|
||||
font-size: 14px;
|
||||
color: #909399;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
|
||||
&.positive {
|
||||
color: #f56c6c;
|
||||
}
|
||||
|
||||
&.negative {
|
||||
color: #67c23a;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
542
frontend/src/views/StockSearch.vue
Normal file
542
frontend/src/views/StockSearch.vue
Normal file
@@ -0,0 +1,542 @@
|
||||
<template>
|
||||
<div class="stock-search">
|
||||
<!-- 页面标题 -->
|
||||
<div class="page-header">
|
||||
<h1>股票搜索</h1>
|
||||
<p>搜索和查看股票详细信息</p>
|
||||
</div>
|
||||
|
||||
<!-- 搜索区域 -->
|
||||
<div class="search-section">
|
||||
<el-card class="search-card">
|
||||
<div class="search-header">
|
||||
<h3>股票搜索</h3>
|
||||
</div>
|
||||
<div class="search-form">
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="18">
|
||||
<el-input
|
||||
v-model="searchKeyword"
|
||||
placeholder="请输入股票代码或股票名称"
|
||||
size="large"
|
||||
@keyup.enter="handleSearch"
|
||||
clearable
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Search /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-button
|
||||
type="primary"
|
||||
size="large"
|
||||
@click="handleSearch"
|
||||
:loading="searchLoading"
|
||||
style="width: 100%;"
|
||||
>
|
||||
<el-icon><Search /></el-icon>
|
||||
搜索
|
||||
</el-button>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<!-- 搜索结果 -->
|
||||
<div class="search-results" v-if="searchResults.length > 0">
|
||||
<el-card class="results-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>搜索结果 ({{ searchResults.length }}条)</span>
|
||||
<el-button type="text" @click="clearSearch">清空结果</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<el-table :data="searchResults" stripe @row-click="viewStockDetail">
|
||||
<el-table-column prop="stockCode" label="股票代码" width="120" />
|
||||
<el-table-column prop="stockName" label="股票名称" width="200" />
|
||||
<el-table-column prop="currentPrice" label="当前价格" width="120">
|
||||
<template #default="scope">
|
||||
¥{{ scope.row.currentPrice?.toFixed(2) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="changePercent" label="涨跌幅" width="120">
|
||||
<template #default="scope">
|
||||
<span :style="{ color: scope.row.changePercent >= 0 ? '#f56c6c' : '#67c23a' }">
|
||||
{{ scope.row.changePercent >= 0 ? '+' : '' }}{{ scope.row.changePercent?.toFixed(2) }}%
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="changeAmount" label="涨跌额" width="100">
|
||||
<template #default="scope">
|
||||
<span :style="{ color: scope.row.changeAmount >= 0 ? '#f56c6c' : '#67c23a' }">
|
||||
{{ scope.row.changeAmount >= 0 ? '+' : '' }}{{ scope.row.changeAmount?.toFixed(2) }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="volume" label="成交量" width="120">
|
||||
<template #default="scope">
|
||||
{{ formatVolume(scope.row.volume) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="marketCap" label="市值" width="120">
|
||||
<template #default="scope">
|
||||
{{ formatMarketCap(scope.row.marketCap) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="200">
|
||||
<template #default="scope">
|
||||
<el-button type="primary" size="small" @click="viewStockDetail(scope.row)">
|
||||
详情
|
||||
</el-button>
|
||||
<el-button type="success" size="small" @click="viewTrend(scope.row)">
|
||||
趋势
|
||||
</el-button>
|
||||
<el-button type="warning" size="small" @click="viewPrediction(scope.row)">
|
||||
预测
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<!-- 实时股票数据 -->
|
||||
<div class="realtime-section">
|
||||
<el-card class="realtime-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>实时股票数据</span>
|
||||
<div class="header-actions">
|
||||
<el-button type="primary" size="small" @click="loadRealtimeData" :loading="realtimeLoading">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
刷新
|
||||
</el-button>
|
||||
<el-switch
|
||||
v-model="autoRefresh"
|
||||
active-text="自动刷新"
|
||||
@change="toggleAutoRefresh"
|
||||
style="margin-left: 10px;"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="realtime-stats" v-if="realtimeData.length > 0">
|
||||
<el-row :gutter="16" class="stats-row">
|
||||
<el-col :span="6">
|
||||
<div class="stat-item up">
|
||||
<div class="stat-value">{{ getUpCount() }}</div>
|
||||
<div class="stat-label">上涨股票</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="stat-item down">
|
||||
<div class="stat-value">{{ getDownCount() }}</div>
|
||||
<div class="stat-label">下跌股票</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="stat-item volume">
|
||||
<div class="stat-value">{{ getTotalVolume() }}</div>
|
||||
<div class="stat-label">总成交量</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="stat-item market-cap">
|
||||
<div class="stat-value">{{ getTotalMarketCap() }}</div>
|
||||
<div class="stat-label">总市值</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
|
||||
<el-table :data="paginatedRealtimeData" v-loading="realtimeLoading" stripe>
|
||||
<el-table-column prop="stockCode" label="股票代码" width="100" />
|
||||
<el-table-column prop="stockName" label="股票名称" width="150" />
|
||||
<el-table-column prop="currentPrice" label="当前价格" width="100">
|
||||
<template #default="scope">
|
||||
¥{{ scope.row.currentPrice?.toFixed(2) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="changePercent" label="涨跌幅" width="100">
|
||||
<template #default="scope">
|
||||
<span :style="{ color: scope.row.changePercent >= 0 ? '#f56c6c' : '#67c23a' }">
|
||||
{{ scope.row.changePercent >= 0 ? '+' : '' }}{{ scope.row.changePercent?.toFixed(2) }}%
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="changeAmount" label="涨跌额" width="100">
|
||||
<template #default="scope">
|
||||
<span :style="{ color: scope.row.changeAmount >= 0 ? '#f56c6c' : '#67c23a' }">
|
||||
{{ scope.row.changeAmount >= 0 ? '+' : '' }}{{ scope.row.changeAmount?.toFixed(2) }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="volume" label="成交量" width="120">
|
||||
<template #default="scope">
|
||||
{{ formatVolume(scope.row.volume) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="marketCap" label="市值" width="120">
|
||||
<template #default="scope">
|
||||
{{ formatMarketCap(scope.row.marketCap) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="200">
|
||||
<template #default="scope">
|
||||
<el-button type="primary" size="small" @click="viewStockDetail(scope.row)">
|
||||
详情
|
||||
</el-button>
|
||||
<el-button type="success" size="small" @click="viewTrend(scope.row)">
|
||||
趋势
|
||||
</el-button>
|
||||
<el-button type="warning" size="small" @click="viewPrediction(scope.row)">
|
||||
预测
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="pagination-container" v-if="realtimeData.length > 0">
|
||||
<el-pagination
|
||||
v-model:current-page="currentPage"
|
||||
v-model:page-size="pageSize"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
:total="realtimeData.length"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, onMounted, onUnmounted, computed } from 'vue'
|
||||
import { searchStocks, getRealtimeStockData } from '@/api/stock'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
export default {
|
||||
name: 'StockSearch',
|
||||
setup() {
|
||||
const router = useRouter()
|
||||
const searchKeyword = ref('')
|
||||
const searchResults = ref([])
|
||||
const searchLoading = ref(false)
|
||||
const realtimeData = ref([])
|
||||
const realtimeLoading = ref(false)
|
||||
const autoRefresh = ref(false)
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(20)
|
||||
|
||||
let refreshTimer = null
|
||||
|
||||
// 搜索股票
|
||||
const handleSearch = async () => {
|
||||
if (!searchKeyword.value.trim()) {
|
||||
ElMessage.warning('请输入搜索关键词')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
searchLoading.value = true
|
||||
const response = await searchStocks(searchKeyword.value.trim())
|
||||
if (response.data) {
|
||||
searchResults.value = response.data
|
||||
if (response.data.length === 0) {
|
||||
ElMessage.info('未找到相关股票')
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('搜索股票失败:', error)
|
||||
ElMessage.error('搜索失败')
|
||||
} finally {
|
||||
searchLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 清空搜索结果
|
||||
const clearSearch = () => {
|
||||
searchResults.value = []
|
||||
searchKeyword.value = ''
|
||||
}
|
||||
|
||||
// 加载实时数据
|
||||
const loadRealtimeData = async () => {
|
||||
try {
|
||||
realtimeLoading.value = true
|
||||
const response = await getRealtimeStockData()
|
||||
if (response.data) {
|
||||
realtimeData.value = response.data
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载实时数据失败:', error)
|
||||
ElMessage.error('加载实时数据失败')
|
||||
} finally {
|
||||
realtimeLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 切换自动刷新
|
||||
const toggleAutoRefresh = (value) => {
|
||||
if (value) {
|
||||
refreshTimer = setInterval(() => {
|
||||
loadRealtimeData()
|
||||
}, 30000) // 30秒刷新一次
|
||||
ElMessage.success('已开启自动刷新,每30秒更新一次')
|
||||
} else {
|
||||
if (refreshTimer) {
|
||||
clearInterval(refreshTimer)
|
||||
refreshTimer = null
|
||||
}
|
||||
ElMessage.info('已关闭自动刷新')
|
||||
}
|
||||
}
|
||||
|
||||
// 分页数据
|
||||
const paginatedRealtimeData = computed(() => {
|
||||
const start = (currentPage.value - 1) * pageSize.value
|
||||
const end = start + pageSize.value
|
||||
return realtimeData.value.slice(start, end)
|
||||
})
|
||||
|
||||
// 分页事件
|
||||
const handleSizeChange = (val) => {
|
||||
pageSize.value = val
|
||||
currentPage.value = 1
|
||||
}
|
||||
|
||||
const handleCurrentChange = (val) => {
|
||||
currentPage.value = val
|
||||
}
|
||||
|
||||
// 统计函数
|
||||
const getUpCount = () => {
|
||||
return realtimeData.value.filter(item => item.changePercent > 0).length
|
||||
}
|
||||
|
||||
const getDownCount = () => {
|
||||
return realtimeData.value.filter(item => item.changePercent < 0).length
|
||||
}
|
||||
|
||||
const getTotalVolume = () => {
|
||||
const total = realtimeData.value.reduce((sum, item) => sum + (item.volume || 0), 0)
|
||||
return formatVolume(total)
|
||||
}
|
||||
|
||||
const getTotalMarketCap = () => {
|
||||
const total = realtimeData.value.reduce((sum, item) => sum + (item.marketCap || 0), 0)
|
||||
return formatMarketCap(total)
|
||||
}
|
||||
|
||||
// 格式化函数
|
||||
const formatVolume = (volume) => {
|
||||
if (!volume) return '0手'
|
||||
if (volume >= 100000000) {
|
||||
return (volume / 100000000).toFixed(1) + '亿手'
|
||||
} else if (volume >= 10000) {
|
||||
return (volume / 10000).toFixed(1) + '万手'
|
||||
} else {
|
||||
return volume + '手'
|
||||
}
|
||||
}
|
||||
|
||||
const formatMarketCap = (marketCap) => {
|
||||
if (!marketCap) return '0元'
|
||||
if (marketCap >= 100000000) {
|
||||
return (marketCap / 100000000).toFixed(1) + '亿元'
|
||||
} else if (marketCap >= 10000) {
|
||||
return (marketCap / 10000).toFixed(1) + '万元'
|
||||
} else {
|
||||
return marketCap + '元'
|
||||
}
|
||||
}
|
||||
|
||||
// 查看股票详情
|
||||
const viewStockDetail = (stock) => {
|
||||
router.push(`/stock/${stock.stockCode}`)
|
||||
}
|
||||
|
||||
// 查看股票趋势
|
||||
const viewTrend = (stock) => {
|
||||
router.push(`/stock/${stock.stockCode}/trend`)
|
||||
}
|
||||
|
||||
// 查看股票预测
|
||||
const viewPrediction = (stock) => {
|
||||
router.push(`/stock/${stock.stockCode}/prediction`)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadRealtimeData()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (refreshTimer) {
|
||||
clearInterval(refreshTimer)
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
searchKeyword,
|
||||
searchResults,
|
||||
searchLoading,
|
||||
realtimeData,
|
||||
realtimeLoading,
|
||||
autoRefresh,
|
||||
currentPage,
|
||||
pageSize,
|
||||
paginatedRealtimeData,
|
||||
handleSearch,
|
||||
clearSearch,
|
||||
loadRealtimeData,
|
||||
toggleAutoRefresh,
|
||||
handleSizeChange,
|
||||
handleCurrentChange,
|
||||
getUpCount,
|
||||
getDownCount,
|
||||
getTotalVolume,
|
||||
getTotalMarketCap,
|
||||
formatVolume,
|
||||
formatMarketCap,
|
||||
viewStockDetail,
|
||||
viewTrend,
|
||||
viewPrediction
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.stock-search {
|
||||
padding: 20px;
|
||||
background-color: #f5f7fa;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
|
||||
h1 {
|
||||
font-size: 28px;
|
||||
color: #303133;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
p {
|
||||
color: #909399;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.search-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.search-card, .results-card, .realtime-card {
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.search-header {
|
||||
margin-bottom: 20px;
|
||||
|
||||
h3 {
|
||||
color: #303133;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.search-results {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
span {
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.realtime-stats {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.stats-row {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
color: white;
|
||||
|
||||
&.up {
|
||||
background: linear-gradient(135deg, #f56c6c, #ff8a80);
|
||||
}
|
||||
|
||||
&.down {
|
||||
background: linear-gradient(135deg, #67c23a, #81c784);
|
||||
}
|
||||
|
||||
&.volume {
|
||||
background: linear-gradient(135deg, #409eff, #64b5f6);
|
||||
}
|
||||
|
||||
&.market-cap {
|
||||
background: linear-gradient(135deg, #e6a23c, #ffb74d);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 14px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
|
||||
.pagination-container {
|
||||
margin-top: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
:deep(.el-table) {
|
||||
.el-table__header {
|
||||
th {
|
||||
background-color: #f8f9fa;
|
||||
color: #303133;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.el-table__row {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background-color: #f5f7fa;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
74
frontend/vite.config.js
Normal file
74
frontend/vite.config.js
Normal file
@@ -0,0 +1,74 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import AutoImport from 'unplugin-auto-import/vite'
|
||||
import Components from 'unplugin-vue-components/vite'
|
||||
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
|
||||
import { resolve } from 'path'
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
AutoImport({
|
||||
resolvers: [ElementPlusResolver()],
|
||||
}),
|
||||
Components({
|
||||
resolvers: [ElementPlusResolver()],
|
||||
}),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
define: {
|
||||
// 为了兼容一些依赖包
|
||||
global: 'globalThis',
|
||||
},
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 3000,
|
||||
open: true,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
'/ws': {
|
||||
target: 'ws://localhost:8080',
|
||||
ws: true,
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
sourcemap: false,
|
||||
chunkSizeWarningLimit: 1600,
|
||||
rollupOptions: {
|
||||
input: {
|
||||
main: resolve(fileURLToPath(new URL('./', import.meta.url)), 'index.html')
|
||||
},
|
||||
output: {
|
||||
manualChunks(id) {
|
||||
if (id.includes('node_modules')) {
|
||||
return id.toString().split('node_modules/')[1].split('/')[0].toString()
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
optimizeDeps: {
|
||||
include: [
|
||||
'vue',
|
||||
'vue-router',
|
||||
'vuex',
|
||||
'axios',
|
||||
'element-plus',
|
||||
'@element-plus/icons-vue',
|
||||
'echarts',
|
||||
'dayjs'
|
||||
],
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user