first commit

This commit is contained in:
2025-06-12 19:37:54 +08:00
parent bb2eb010f7
commit 1c6093fa9a
87 changed files with 18432 additions and 0 deletions

203
frontend/src/App.vue Normal file
View 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>

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

View 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
View 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')

View 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

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

View 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;
}

View 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;

View 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

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>