feat: fix frontend

This commit is contained in:
2025-06-21 16:55:45 +08:00
parent b52a9befee
commit 7cf5070288
17 changed files with 3071 additions and 393 deletions

View File

@@ -34,14 +34,9 @@
</el-menu-item>
<el-menu-item index="/market-analysis">
<el-icon><Pie /></el-icon>
<el-icon><DataBoard /></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>

View File

@@ -77,6 +77,14 @@ export function getStockPrediction(stockCode, days = 7) {
})
}
// 获取股票详情
export function getStockDetail(stockCode) {
return request({
url: `/api/stock/detail/${stockCode}`,
method: 'get'
})
}
// 搜索股票
export function searchStocks(keyword) {
return request({

View File

@@ -1,10 +1,12 @@
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'
import StockDetailView from '@/views/StockDetailView.vue'
import StockTrendView from '@/views/StockTrendView.vue'
import StockPredictionView from '@/views/StockPredictionView.vue'
const routes = [
{
@@ -31,29 +33,29 @@ const routes = [
component: StockDetail,
meta: { title: '股票详情' }
},
{
path: '/stock/:stockCode/detail',
name: 'StockDetailView',
component: StockDetailView,
meta: { title: '股票详情分析' }
},
{
path: '/stock/:stockCode/trend',
name: 'StockTrend',
component: StockDetail,
meta: { title: '股票趋势' }
name: 'StockTrendView',
component: StockTrendView,
meta: { title: '股票趋势分析' }
},
{
path: '/stock/:stockCode/prediction',
name: 'StockPrediction',
component: StockDetail,
meta: { title: '股票预测' }
name: 'StockPredictionView',
component: StockPredictionView,
meta: { title: '股票预测分析' }
},
{
path: '/market-analysis',
name: 'MarketAnalysis',
component: MarketAnalysis,
meta: { title: '市场分析' }
},
{
path: '/health',
name: 'HealthCheck',
component: HealthCheck,
meta: { title: '健康检查' }
}
]

View File

@@ -94,7 +94,7 @@
<!-- 快速导航 -->
<el-row :gutter="20" class="quick-nav-section">
<el-col :span="6">
<el-col :span="8">
<el-card class="nav-card" @click="navigateTo('/search')">
<div class="nav-content">
<div class="nav-icon search">
@@ -107,7 +107,7 @@
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-col :span="8">
<el-card class="nav-card" @click="navigateTo('/rankings')">
<div class="nav-content">
<div class="nav-icon rankings">
@@ -120,11 +120,11 @@
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-col :span="8">
<el-card class="nav-card" @click="navigateTo('/market-analysis')">
<div class="nav-content">
<div class="nav-icon analysis">
<el-icon><Pie /></el-icon>
<el-icon><DataBoard /></el-icon>
</div>
<div class="nav-text">
<h3>市场分析</h3>
@@ -133,32 +133,15 @@
</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%">
@@ -203,7 +186,7 @@
import { ref, onMounted, nextTick } from 'vue'
import * as echarts from 'echarts'
import { getLatestMarketAnalysis, getRecentMarketAnalysis } from '@/api/market'
import { getMarketAnalysis, getRealtimeStockData } from '@/api/stock'
import { getMarketAnalysis } from '@/api/stock'
import { ElMessage } from 'element-plus'
import { useRouter } from 'vue-router'
@@ -215,6 +198,7 @@ export default {
const recentData = ref([])
const loading = ref(false)
// 加载最新市场数据
const loadLatestData = async () => {
try {
@@ -245,6 +229,8 @@ export default {
}
}
// 初始化饼图
const initPieChart = async () => {
await nextTick()
@@ -336,6 +322,8 @@ export default {
return (num / 10000).toFixed(1)
}
// 刷新数据
const refreshData = () => {
loadLatestData()
@@ -347,19 +335,13 @@ export default {
loadRecentData()
}
// 模拟运行Spark分析
const runSparkAnalysis = () => {
ElMessage.success('Spark分析任务已提交请稍候查看结果')
setTimeout(() => {
refreshData()
}, 2000)
}
// 导航到指定页面
const navigateTo = (path) => {
router.push(path)
}
onMounted(() => {
loadLatestData()
loadRecentData()
@@ -371,7 +353,6 @@ export default {
loading,
refreshData,
loadTrendData,
runSparkAnalysis,
formatNumber,
navigateTo
}

View File

@@ -1,108 +0,0 @@
<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

@@ -28,7 +28,7 @@
</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) }})
({{ stockInfo.changeAmount?.toFixed(2) }})
</div>
</div>
<div class="stock-metrics">
@@ -195,7 +195,7 @@
<script>
import { ref, onMounted, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { getStockHistory, getStockTrend, getStockPrediction, searchStocks } from '@/api/stock'
import { getStockHistory, getStockTrend, getStockPrediction, searchStocks, getStockDetail } from '@/api/stock'
import { ElMessage } from 'element-plus'
import * as echarts from 'echarts'
@@ -222,12 +222,41 @@ export default {
// 加载股票基本信息
const loadStockInfo = async () => {
try {
const response = await searchStocks(stockCode.value)
if (response.data && response.data.length > 0) {
stockInfo.value = response.data[0]
// 优先使用详情API
const detailResponse = await getStockDetail(stockCode.value)
if (detailResponse.data) {
stockInfo.value = {
stockCode: detailResponse.data.stockCode,
stockName: detailResponse.data.stockName,
currentPrice: detailResponse.data.closePrice,
changePercent: detailResponse.data.changePercent,
changeAmount: detailResponse.data.changeAmount,
volume: detailResponse.data.volume,
marketCap: detailResponse.data.marketCap,
highPrice: detailResponse.data.highPrice,
lowPrice: detailResponse.data.lowPrice
}
} else {
// 如果详情API无数据尝试搜索API
const searchResponse = await searchStocks(stockCode.value)
if (searchResponse.data && searchResponse.data.length > 0) {
const stockData = searchResponse.data[0]
stockInfo.value = {
stockCode: stockData.stockCode,
stockName: stockData.stockName,
currentPrice: stockData.closePrice,
changePercent: stockData.changePercent,
changeAmount: stockData.changeAmount,
volume: stockData.volume,
marketCap: stockData.marketCap,
highPrice: stockData.highPrice,
lowPrice: stockData.lowPrice
}
}
}
} catch (error) {
console.error('加载股票信息失败:', error)
ElMessage.error('加载股票信息失败')
}
}
@@ -296,52 +325,117 @@ export default {
// 初始化价格图表
const initPriceChart = async () => {
const chartDom = document.getElementById('price-chart')
if (!chartDom || historyData.value.length === 0) return
if (!chartDom) 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)
if (historyData.value.length === 0) {
// 如果没有数据,显示空状态
const option = {
title: {
text: '暂无历史数据',
left: 'center',
top: 'center',
textStyle: {
color: '#999',
fontSize: 16
}
}
}
myChart.setOption(option)
return
}
// 处理日期格式
const dates = historyData.value.map(item => {
if (item.tradeDate) {
const date = new Date(item.tradeDate)
return date.toISOString().split('T')[0]
}
return item.timestamp?.split('T')[0] || item.date || '未知日期'
})
// 处理价格数据
const prices = historyData.value.map(item => {
return item.closePrice || item.currentPrice || 0
})
const option = {
title: {
text: '价格走势',
left: 'center'
text: '历史价格走势',
left: 'left',
textStyle: {
fontSize: 16,
color: '#303133'
}
},
tooltip: {
trigger: 'axis',
formatter: (params) => {
const dataIndex = params[0].dataIndex
const data = historyData.value[dataIndex]
const price = data.closePrice || data.currentPrice || 0
const changePercent = data.changePercent || 0
const volume = data.volume || 0
return `
<div>
<p>日期: ${dates[dataIndex]}</p>
<p>价格: ¥${data.currentPrice?.toFixed(2)}</p>
<p>涨跌幅: ${data.changePercent?.toFixed(2)}%</p>
<p>成交量: ${formatVolume(data.volume)}</p>
<div style="padding: 10px;">
<p><strong>日期:</strong> ${dates[dataIndex]}</p>
<p><strong>收盘价:</strong> ¥${price.toFixed(2)}</p>
<p><strong>涨跌幅:</strong> ${changePercent.toFixed(2)}%</p>
<p><strong>成交量:</strong> ${formatVolume(volume)}</p>
</div>
`
}
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
top: '15%',
containLabel: true
},
xAxis: {
type: 'category',
data: dates
data: dates,
axisLine: {
lineStyle: {
color: '#8392A5'
}
}
},
yAxis: {
type: 'value',
name: '价格(¥)',
scale: true
scale: true,
axisLine: {
lineStyle: {
color: '#8392A5'
}
},
splitLine: {
show: true,
lineStyle: {
color: '#E6E8EB'
}
}
},
series: [
{
data: prices,
type: 'line',
smooth: true,
symbol: 'circle',
symbolSize: 4,
lineStyle: {
color: '#409EFF',
width: 2
},
areaStyle: {
color: 'rgba(64, 158, 255, 0.1)'
},
itemStyle: {
color: '#409EFF'
}
}
]
@@ -352,30 +446,89 @@ export default {
// 初始化趋势图表
const initTrendChart = async () => {
const chartDom = document.getElementById('trend-chart')
if (!chartDom || !trendData.value.priceHistory) return
if (!chartDom) return
const myChart = echarts.init(chartDom)
if (!trendData.value || !trendData.value.priceHistory || trendData.value.priceHistory.length === 0) {
// 如果没有趋势数据,显示空状态
const option = {
title: {
text: '暂无趋势数据',
left: 'center',
top: 'center',
textStyle: {
color: '#999',
fontSize: 14
}
}
}
myChart.setOption(option)
return
}
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 dates = data.map(item => {
if (item.date) return item.date
if (item.tradeDate) {
const date = new Date(item.tradeDate)
return date.toISOString().split('T')[0]
}
return '未知日期'
})
const prices = data.map(item => item.price || item.closePrice || 0)
const ma5 = data.map(item => item.ma5 || null)
const ma20 = data.map(item => item.ma20 || null)
const option = {
tooltip: {
trigger: 'axis'
trigger: 'axis',
formatter: (params) => {
let result = `<div style="padding: 10px;"><strong>${params[0].axisValue}</strong><br/>`
params.forEach(param => {
if (param.value !== null) {
result += `<span style="color: ${param.color};">●</span> ${param.seriesName}: ¥${param.value.toFixed(2)}<br/>`
}
})
result += '</div>'
return result
}
},
legend: {
data: ['股价', 'MA5', 'MA20']
data: ['股价', 'MA5', 'MA20'],
top: 10
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
top: '15%',
containLabel: true
},
xAxis: {
type: 'category',
data: dates
data: dates,
axisLine: {
lineStyle: {
color: '#8392A5'
}
}
},
yAxis: {
type: 'value',
name: '价格(¥)',
scale: true
scale: true,
axisLine: {
lineStyle: {
color: '#8392A5'
}
},
splitLine: {
show: true,
lineStyle: {
color: '#E6E8EB'
}
}
},
series: [
{
@@ -383,21 +536,28 @@ export default {
data: prices,
type: 'line',
smooth: true,
lineStyle: { color: '#409EFF' }
symbol: 'circle',
symbolSize: 4,
lineStyle: { color: '#409EFF', width: 2 },
itemStyle: { color: '#409EFF' }
},
{
name: 'MA5',
data: ma5,
type: 'line',
smooth: true,
lineStyle: { color: '#67C23A' }
symbol: 'none',
lineStyle: { color: '#67C23A', width: 1 },
itemStyle: { color: '#67C23A' }
},
{
name: 'MA20',
data: ma20,
type: 'line',
smooth: true,
lineStyle: { color: '#E6A23C' }
symbol: 'none',
lineStyle: { color: '#E6A23C', width: 1 },
itemStyle: { color: '#E6A23C' }
}
]
}
@@ -407,17 +567,47 @@ export default {
// 初始化预测图表
const initPredictionChart = async () => {
const chartDom = document.getElementById('prediction-chart')
if (!chartDom || predictionData.value.length === 0) return
if (!chartDom) return
const myChart = echarts.init(chartDom)
if (predictionData.value.length === 0) {
// 如果没有预测数据,显示空状态
const option = {
title: {
text: '暂无预测数据,请点击"生成预测"按钮',
left: 'center',
top: 'center',
textStyle: {
color: '#999',
fontSize: 14
}
}
}
myChart.setOption(option)
return
}
// 历史数据最近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 historySlice = historyData.value.slice(-10)
const historyDates = historySlice.map(item => {
if (item.tradeDate) {
const date = new Date(item.tradeDate)
return date.toISOString().split('T')[0]
}
return item.timestamp?.split('T')[0] || item.date || '历史'
})
const historyPrices = historySlice.map(item => item.closePrice || item.currentPrice || 0)
// 预测数据
const predictionDates = predictionData.value.map(item => item.timestamp?.split('T')[0] || item.date)
const predictionPrices = predictionData.value.map(item => item.currentPrice)
const predictionDates = predictionData.value.map(item => {
if (item.tradeDate) {
const date = new Date(item.tradeDate)
return date.toISOString().split('T')[0]
}
return item.timestamp?.split('T')[0] || item.date || '预测'
})
const predictionPrices = predictionData.value.map(item => item.closePrice || item.currentPrice || 0)
const allDates = [...historyDates, ...predictionDates]
const historySeries = [...historyPrices, ...new Array(predictionDates.length).fill(null)]
@@ -425,23 +615,61 @@ export default {
const option = {
title: {
text: '股价预测',
left: 'center'
text: '股价预测分析',
left: 'left',
textStyle: {
fontSize: 16,
color: '#303133'
}
},
tooltip: {
trigger: 'axis'
trigger: 'axis',
formatter: (params) => {
let result = `<div style="padding: 10px;"><strong>${params[0].axisValue}</strong><br/>`
params.forEach(param => {
if (param.value !== null) {
result += `<span style="color: ${param.color};">●</span> ${param.seriesName}: ¥${param.value.toFixed(2)}<br/>`
}
})
result += '</div>'
return result
}
},
legend: {
data: ['历史价格', '预测价格']
data: ['历史价格', '预测价格'],
top: 30
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
top: '20%',
containLabel: true
},
xAxis: {
type: 'category',
data: allDates
data: allDates,
axisLine: {
lineStyle: {
color: '#8392A5'
}
}
},
yAxis: {
type: 'value',
name: '价格(¥)',
scale: true
scale: true,
axisLine: {
lineStyle: {
color: '#8392A5'
}
},
splitLine: {
show: true,
lineStyle: {
color: '#E6E8EB'
}
}
},
series: [
{
@@ -449,19 +677,26 @@ export default {
data: historySeries,
type: 'line',
smooth: true,
lineStyle: { color: '#409EFF' },
areaStyle: { color: 'rgba(64, 158, 255, 0.1)' }
symbol: 'circle',
symbolSize: 4,
lineStyle: { color: '#409EFF', width: 2 },
areaStyle: { color: 'rgba(64, 158, 255, 0.1)' },
itemStyle: { color: '#409EFF' }
},
{
name: '预测价格',
data: predictionSeries,
type: 'line',
smooth: true,
symbol: 'diamond',
symbolSize: 5,
lineStyle: {
color: '#F56C6C',
type: 'dashed'
type: 'dashed',
width: 2
},
areaStyle: { color: 'rgba(245, 108, 108, 0.1)' }
areaStyle: { color: 'rgba(245, 108, 108, 0.1)' },
itemStyle: { color: '#F56C6C' }
}
]
}
@@ -502,24 +737,32 @@ export default {
// 预测统计
const getPredictionMax = () => {
if (predictionData.value.length === 0) return '0.00'
return Math.max(...predictionData.value.map(item => item.currentPrice)).toFixed(2)
const prices = predictionData.value.map(item => item.closePrice || item.currentPrice || 0)
return Math.max(...prices).toFixed(2)
}
const getPredictionMin = () => {
if (predictionData.value.length === 0) return '0.00'
return Math.min(...predictionData.value.map(item => item.currentPrice)).toFixed(2)
const prices = predictionData.value.map(item => item.closePrice || item.currentPrice || 0)
return Math.min(...prices).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
const prices = predictionData.value.map(item => item.closePrice || item.currentPrice || 0)
const avg = prices.reduce((sum, price) => sum + price, 0) / prices.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
const lastPrice = lastPrediction.closePrice || lastPrediction.currentPrice || 0
const currentPrice = stockInfo.value.currentPrice || 0
if (currentPrice === 0) return 0
const returnRate = ((lastPrice - currentPrice) / currentPrice) * 100
return returnRate.toFixed(2)
}

View File

@@ -0,0 +1,606 @@
<template>
<div class="stock-detail-view">
<!-- 页面标题 -->
<div class="page-header">
<div class="stock-info">
<h1>{{ stockInfo.stockName || stockCode }}</h1>
<p class="stock-code">{{ 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>
<!-- 股票基本信息 -->
<el-row :gutter="20" class="stock-overview">
<el-col :span="8">
<el-card class="price-card">
<div class="price-section">
<div class="current-price">
¥{{ stockInfo.closePrice?.toFixed(2) || '0.00' }}
</div>
<div class="price-change" :class="getPriceChangeClass()">
<el-icon v-if="stockInfo.changePercent > 0"><CaretTop /></el-icon>
<el-icon v-else-if="stockInfo.changePercent < 0"><CaretBottom /></el-icon>
<el-icon v-else><Minus /></el-icon>
{{ formatChangePercent(stockInfo.changePercent) }}%
({{ formatChangeAmount(stockInfo.changeAmount) }})
</div>
</div>
</el-card>
</el-col>
<el-col :span="16">
<el-card class="metrics-card">
<el-row :gutter="20">
<el-col :span="6">
<div class="metric-item">
<div class="metric-label">开盘价</div>
<div class="metric-value">¥{{ stockInfo.openPrice?.toFixed(2) || '--' }}</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-col :span="6">
<div class="metric-item">
<div class="metric-label">昨收价</div>
<div class="metric-value">¥{{ stockInfo.preClosePrice?.toFixed(2) || '--' }}</div>
</div>
</el-col>
</el-row>
<el-row :gutter="20" style="margin-top: 20px;">
<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">{{ formatTurnover(stockInfo.turnover) }}</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.turnoverRate?.toFixed(2) || '--' }}%</div>
</div>
</el-col>
</el-row>
</el-card>
</el-col>
</el-row>
<!-- 分时图 -->
<el-row :gutter="20" class="chart-section">
<el-col :span="24">
<el-card class="chart-card">
<template #header>
<div class="card-header">
<span>实时分时图</span>
<div class="chart-controls">
<el-button-group size="small">
<el-button :type="chartType === 'minute' ? 'primary' : ''" @click="changeChartType('minute')">
分时
</el-button>
<el-button :type="chartType === 'kline' ? 'primary' : ''" @click="changeChartType('kline')">
K线
</el-button>
</el-button-group>
</div>
</div>
</template>
<div id="detail-chart" style="height: 500px;" v-loading="chartLoading"></div>
</el-card>
</el-col>
</el-row>
<!-- 详细信息表格 -->
<el-row>
<el-col :span="24">
<el-card class="info-card">
<template #header>
<span>详细信息</span>
</template>
<el-descriptions :column="3" border>
<el-descriptions-item label="股票代码">{{ stockInfo.stockCode }}</el-descriptions-item>
<el-descriptions-item label="股票名称">{{ stockInfo.stockName }}</el-descriptions-item>
<el-descriptions-item label="交易日期">{{ formatDate(stockInfo.tradeDate) }}</el-descriptions-item>
<el-descriptions-item label="开盘价">¥{{ stockInfo.openPrice?.toFixed(2) || '--' }}</el-descriptions-item>
<el-descriptions-item label="收盘价">¥{{ stockInfo.closePrice?.toFixed(2) || '--' }}</el-descriptions-item>
<el-descriptions-item label="最高价">¥{{ stockInfo.highPrice?.toFixed(2) || '--' }}</el-descriptions-item>
<el-descriptions-item label="最低价">¥{{ stockInfo.lowPrice?.toFixed(2) || '--' }}</el-descriptions-item>
<el-descriptions-item label="昨收价">¥{{ stockInfo.preClosePrice?.toFixed(2) || '--' }}</el-descriptions-item>
<el-descriptions-item label="涨跌额">{{ formatChangeAmount(stockInfo.changeAmount) }}</el-descriptions-item>
<el-descriptions-item label="涨跌幅">{{ formatChangePercent(stockInfo.changePercent) }}%</el-descriptions-item>
<el-descriptions-item label="成交量">{{ formatVolume(stockInfo.volume) }}</el-descriptions-item>
<el-descriptions-item label="成交额">{{ formatTurnover(stockInfo.turnover) }}</el-descriptions-item>
<el-descriptions-item label="总市值">{{ formatMarketCap(stockInfo.marketCap) }}</el-descriptions-item>
<el-descriptions-item label="流通市值">{{ formatMarketCap(stockInfo.circulationMarketCap) }}</el-descriptions-item>
<el-descriptions-item label="换手率">{{ stockInfo.turnoverRate?.toFixed(2) || '--' }}%</el-descriptions-item>
</el-descriptions>
</el-card>
</el-col>
</el-row>
</div>
</template>
<script>
import { ref, onMounted, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { getStockDetail } from '@/api/stock'
import { ElMessage } from 'element-plus'
import * as echarts from 'echarts'
export default {
name: 'StockDetailView',
setup() {
const route = useRoute()
const router = useRouter()
const stockCode = ref(route.params.stockCode)
const stockInfo = ref({})
const loading = ref(false)
const chartLoading = ref(false)
const chartType = ref('minute')
// 加载股票详情
const loadStockDetail = async () => {
try {
loading.value = true
const response = await getStockDetail(stockCode.value)
if (response.code === 200 && response.data) {
stockInfo.value = response.data
await nextTick()
initChart()
} else {
ElMessage.error(response.message || '获取股票详情失败')
}
} catch (error) {
console.error('获取股票详情失败:', error)
ElMessage.error('获取股票详情失败')
} finally {
loading.value = false
}
}
// 初始化图表
const initChart = async () => {
await nextTick()
const chartDom = document.getElementById('detail-chart')
if (!chartDom) return
const myChart = echarts.init(chartDom)
if (chartType.value === 'minute') {
initMinuteChart(myChart)
} else {
initKLineChart(myChart)
}
}
// 初始化分时图
const initMinuteChart = (chart) => {
const option = {
title: {
text: `${stockInfo.value.stockName}(${stockInfo.value.stockCode})`,
left: 'left'
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'cross'
}
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true
},
xAxis: {
type: 'category',
data: generateTimeData(),
axisLine: {
lineStyle: {
color: '#8392A5'
}
}
},
yAxis: {
type: 'value',
scale: true,
axisLine: {
lineStyle: {
color: '#8392A5'
}
},
splitLine: {
show: true,
lineStyle: {
color: '#E6E8EB'
}
}
},
series: [
{
name: '价格',
type: 'line',
data: generatePriceData(),
smooth: true,
lineStyle: {
color: stockInfo.value.changePercent >= 0 ? '#f56c6c' : '#67c23a'
},
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [{
offset: 0,
color: stockInfo.value.changePercent >= 0 ? 'rgba(245, 108, 108, 0.3)' : 'rgba(103, 194, 58, 0.3)'
}, {
offset: 1,
color: stockInfo.value.changePercent >= 0 ? 'rgba(245, 108, 108, 0.1)' : 'rgba(103, 194, 58, 0.1)'
}]
}
}
}
]
}
chart.setOption(option)
}
// 初始化K线图
const initKLineChart = (chart) => {
const option = {
title: {
text: `${stockInfo.value.stockName}(${stockInfo.value.stockCode}) K线图`,
left: 'left'
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'cross'
}
},
grid: {
left: '3%',
right: '4%',
bottom: '15%',
containLabel: true
},
xAxis: {
type: 'category',
data: generateDateData(),
axisLine: {
lineStyle: {
color: '#8392A5'
}
}
},
yAxis: {
type: 'value',
scale: true,
axisLine: {
lineStyle: {
color: '#8392A5'
}
}
},
series: [
{
name: 'K线',
type: 'candlestick',
data: generateKLineData(),
itemStyle: {
color: '#f56c6c',
color0: '#67c23a',
borderColor: '#f56c6c',
borderColor0: '#67c23a'
}
}
]
}
chart.setOption(option)
}
// 生成模拟时间数据
const generateTimeData = () => {
const times = []
for (let i = 9; i <= 15; i++) {
for (let j = 0; j < 60; j += 5) {
if (i === 9 && j < 30) continue
if (i === 15 && j > 0) break
if (i === 11 && j >= 30) continue
if (i === 12) continue
if (i === 13 && j === 0) continue
times.push(`${i.toString().padStart(2, '0')}:${j.toString().padStart(2, '0')}`)
}
}
return times
}
// 生成模拟价格数据
const generatePriceData = () => {
const basePrice = stockInfo.value.closePrice || 10
const data = []
const times = generateTimeData()
for (let i = 0; i < times.length; i++) {
const fluctuation = (Math.random() - 0.5) * 0.1
const price = basePrice * (1 + fluctuation)
data.push(price.toFixed(2))
}
return data
}
// 生成模拟日期数据
const generateDateData = () => {
const dates = []
const today = new Date()
for (let i = 29; i >= 0; i--) {
const date = new Date(today)
date.setDate(date.getDate() - i)
dates.push(date.toISOString().split('T')[0])
}
return dates
}
// 生成模拟K线数据
const generateKLineData = () => {
const basePrice = stockInfo.value.closePrice || 10
const data = []
let currentPrice = basePrice
for (let i = 0; i < 30; i++) {
const change = (Math.random() - 0.5) * 0.2
const open = currentPrice
const close = currentPrice * (1 + change)
const high = Math.max(open, close) * (1 + Math.random() * 0.05)
const low = Math.min(open, close) * (1 - Math.random() * 0.05)
data.push([open.toFixed(2), close.toFixed(2), low.toFixed(2), high.toFixed(2)])
currentPrice = close
}
return data
}
// 切换图表类型
const changeChartType = (type) => {
chartType.value = type
initChart()
}
// 刷新数据
const refreshData = () => {
loadStockDetail()
}
// 返回
const goBack = () => {
router.go(-1)
}
// 格式化函数
const getPriceChangeClass = () => {
if (!stockInfo.value.changePercent) return ''
return stockInfo.value.changePercent > 0 ? 'positive' : stockInfo.value.changePercent < 0 ? 'negative' : ''
}
const formatChangePercent = (value) => {
if (!value) return '0.00'
return (value > 0 ? '+' : '') + value.toFixed(2)
}
const formatChangeAmount = (value) => {
if (!value) return '0.00'
return (value > 0 ? '+' : '') + value.toFixed(2)
}
const formatVolume = (volume) => {
if (!volume) return '0'
if (volume >= 100000000) {
return (volume / 100000000).toFixed(2) + '亿'
} else if (volume >= 10000) {
return (volume / 10000).toFixed(2) + '万'
}
return volume.toString()
}
const formatTurnover = (turnover) => {
if (!turnover) return '0'
if (turnover >= 100000000) {
return (turnover / 100000000).toFixed(2) + '亿元'
} else if (turnover >= 10000) {
return (turnover / 10000).toFixed(2) + '万元'
}
return turnover.toFixed(2) + '元'
}
const formatMarketCap = (marketCap) => {
if (!marketCap) return '0'
if (marketCap >= 100000000) {
return (marketCap / 100000000).toFixed(2) + '亿元'
} else if (marketCap >= 10000) {
return (marketCap / 10000).toFixed(2) + '万元'
}
return marketCap.toFixed(2) + '元'
}
const formatDate = (date) => {
if (!date) return '--'
return new Date(date).toLocaleDateString()
}
onMounted(() => {
loadStockDetail()
})
return {
stockCode,
stockInfo,
loading,
chartLoading,
chartType,
loadStockDetail,
changeChartType,
refreshData,
goBack,
getPriceChangeClass,
formatChangePercent,
formatChangeAmount,
formatVolume,
formatTurnover,
formatMarketCap,
formatDate
}
}
}
</script>
<style lang="scss" scoped>
.stock-detail-view {
padding: 20px;
background-color: #f5f7fa;
min-height: 100vh;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
.stock-info {
h1 {
margin: 0;
color: #303133;
font-size: 24px;
}
.stock-code {
margin: 5px 0 0 0;
color: #909399;
font-size: 14px;
}
}
.action-buttons {
display: flex;
gap: 10px;
}
}
.stock-overview {
margin-bottom: 20px;
}
.price-card {
.price-section {
text-align: center;
padding: 20px;
.current-price {
font-size: 36px;
font-weight: bold;
color: #303133;
margin-bottom: 10px;
}
.price-change {
font-size: 16px;
display: flex;
align-items: center;
justify-content: center;
gap: 5px;
&.positive {
color: #f56c6c;
}
&.negative {
color: #67c23a;
}
}
}
}
.metrics-card {
.metric-item {
text-align: center;
.metric-label {
font-size: 12px;
color: #909399;
margin-bottom: 5px;
}
.metric-value {
font-size: 16px;
font-weight: 500;
color: #303133;
}
}
}
.chart-section {
margin-bottom: 20px;
}
.chart-card, .info-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 {
display: flex;
align-items: center;
gap: 10px;
}
:deep(.el-descriptions__label) {
font-weight: 600;
}
:deep(.el-descriptions__content) {
color: #303133;
}
</style>

View File

@@ -0,0 +1,795 @@
<template>
<div class="stock-prediction-view">
<!-- 页面标题 -->
<div class="page-header">
<div class="stock-info">
<h1>{{ stockCode }} - 股票预测分析</h1>
<p class="stock-code">预测模型基于历史数据和技术指标</p>
</div>
<div class="action-buttons">
<el-button @click="goBack">
<el-icon><ArrowLeft /></el-icon>
返回
</el-button>
<el-button type="primary" @click="generatePrediction" :loading="loading">
<el-icon><Refresh /></el-icon>
生成预测
</el-button>
</div>
</div>
<!-- 预测配置 -->
<el-row :gutter="20" class="prediction-config">
<el-col :span="24">
<el-card class="config-card">
<template #header>
<span>预测配置</span>
</template>
<el-row :gutter="20">
<el-col :span="6">
<div class="config-item">
<label>预测天数:</label>
<el-select v-model="predictionDays" @change="generatePrediction" style="width: 100%;">
<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>
</div>
</el-col>
<el-col :span="6">
<div class="config-item">
<label>预测模型:</label>
<el-select v-model="predictionModel" style="width: 100%;">
<el-option label="线性回归" value="linear" />
<el-option label="移动平均" value="ma" />
<el-option label="ARIMA" value="arima" />
<el-option label="神经网络" value="nn" />
</el-select>
</div>
</el-col>
<el-col :span="6">
<div class="config-item">
<label>置信区间:</label>
<el-select v-model="confidenceLevel" style="width: 100%;">
<el-option label="90%" value="0.9" />
<el-option label="95%" value="0.95" />
<el-option label="99%" value="0.99" />
</el-select>
</div>
</el-col>
<el-col :span="6">
<div class="config-item">
<label>历史数据:</label>
<el-select v-model="historyDays" style="width: 100%;">
<el-option label="30天" :value="30" />
<el-option label="60天" :value="60" />
<el-option label="90天" :value="90" />
<el-option label="180天" :value="180" />
</el-select>
</div>
</el-col>
</el-row>
</el-card>
</el-col>
</el-row>
<!-- 预测结果概览 -->
<el-row :gutter="20" class="prediction-overview" v-if="predictionData.length > 0">
<el-col :span="6">
<el-card class="prediction-card">
<div class="prediction-info">
<div class="prediction-value positive">¥{{ getPredictionMax() }}</div>
<div class="prediction-subtitle">预测最高价</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card class="prediction-card">
<div class="prediction-info">
<div class="prediction-value negative">¥{{ getPredictionMin() }}</div>
<div class="prediction-subtitle">预测最低价</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card class="prediction-card">
<div class="prediction-info">
<div class="prediction-value">¥{{ getPredictionAvg() }}</div>
<div class="prediction-subtitle">平均预测价</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card class="prediction-card">
<div class="prediction-info">
<div class="prediction-value" :class="{ 'positive': getPredictionReturn() > 0, 'negative': getPredictionReturn() < 0 }">
{{ formatChangePercent(getPredictionReturn()) }}%
</div>
<div class="prediction-subtitle">预期收益率</div>
</div>
</el-card>
</el-col>
</el-row>
<!-- 预测图表 -->
<el-row :gutter="20" class="chart-section">
<el-col :span="16">
<el-card class="chart-card">
<template #header>
<div class="card-header">
<span>价格预测图</span>
<div class="chart-controls">
<el-switch
v-model="showConfidenceInterval"
@change="updateChart"
active-text="显示置信区间"
inactive-text="隐藏置信区间"
/>
</div>
</div>
</template>
<div id="prediction-chart" style="height: 500px;" v-loading="chartLoading"></div>
</el-card>
</el-col>
<el-col :span="8">
<el-card class="analysis-card">
<template #header>
<span>预测分析</span>
</template>
<div class="analysis-content">
<div class="analysis-item">
<div class="analysis-label">预测准确度</div>
<div class="analysis-value">
<el-progress :percentage="predictionAccuracy" :color="getAccuracyColor()" />
<span class="accuracy-text">{{ predictionAccuracy }}%</span>
</div>
</div>
<div class="analysis-item">
<div class="analysis-label">风险等级</div>
<div class="analysis-value">
<el-tag :type="getRiskTagType()">{{ getRiskLevel() }}</el-tag>
</div>
</div>
<div class="analysis-item">
<div class="analysis-label">投资建议</div>
<div class="analysis-value">
<el-tag :type="getRecommendationTagType()">{{ getRecommendation() }}</el-tag>
</div>
</div>
<div class="analysis-item">
<div class="analysis-label">支撑位</div>
<div class="analysis-value">¥{{ getSupportLevel() }}</div>
</div>
<div class="analysis-item">
<div class="analysis-label">阻力位</div>
<div class="analysis-value">¥{{ getResistanceLevel() }}</div>
</div>
<div class="analysis-item">
<div class="analysis-label">波动率</div>
<div class="analysis-value">{{ getVolatility() }}%</div>
</div>
</div>
</el-card>
</el-col>
</el-row>
<!-- 预测数据表格 -->
<el-row>
<el-col :span="24">
<el-card class="table-card">
<template #header>
<span>预测数据详情</span>
</template>
<el-table :data="predictionTableData" style="width: 100%" v-loading="loading">
<el-table-column prop="date" label="预测日期" width="120">
<template #default="scope">
{{ formatDate(scope.row.date) }}
</template>
</el-table-column>
<el-table-column prop="predictedPrice" label="预测价格" width="120">
<template #default="scope">
¥{{ scope.row.predictedPrice?.toFixed(2) }}
</template>
</el-table-column>
<el-table-column prop="lowerBound" label="下限价格" width="120">
<template #default="scope">
¥{{ scope.row.lowerBound?.toFixed(2) }}
</template>
</el-table-column>
<el-table-column prop="upperBound" label="上限价格" width="120">
<template #default="scope">
¥{{ scope.row.upperBound?.toFixed(2) }}
</template>
</el-table-column>
<el-table-column prop="changePercent" label="预测涨跌幅" width="120">
<template #default="scope">
<span :class="{ 'positive': scope.row.changePercent > 0, 'negative': scope.row.changePercent < 0 }">
{{ formatChangePercent(scope.row.changePercent) }}%
</span>
</template>
</el-table-column>
<el-table-column prop="confidence" label="置信度" width="100">
<template #default="scope">
{{ (scope.row.confidence * 100).toFixed(1) }}%
</template>
</el-table-column>
<el-table-column prop="riskLevel" label="风险等级" width="100">
<template #default="scope">
<el-tag :type="getRiskTagTypeByLevel(scope.row.riskLevel)" size="small">
{{ scope.row.riskLevel }}
</el-tag>
</template>
</el-table-column>
</el-table>
</el-card>
</el-col>
</el-row>
</div>
</template>
<script>
import { ref, onMounted, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { getStockPrediction, getStockDetail } from '@/api/stock'
import { ElMessage } from 'element-plus'
import * as echarts from 'echarts'
export default {
name: 'StockPredictionView',
setup() {
const route = useRoute()
const router = useRouter()
const stockCode = ref(route.params.stockCode)
const predictionData = ref([])
const predictionTableData = ref([])
const loading = ref(false)
const chartLoading = ref(false)
// 配置参数
const predictionDays = ref(7)
const predictionModel = ref('linear')
const confidenceLevel = ref('0.95')
const historyDays = ref(60)
const showConfidenceInterval = ref(true)
// 预测分析结果
const predictionAccuracy = ref(85)
const currentPrice = ref(0)
// 生成预测数据
const generatePrediction = async () => {
try {
loading.value = true
chartLoading.value = true
// 获取当前价格
const detailResponse = await getStockDetail(stockCode.value)
if (detailResponse.code === 200 && detailResponse.data) {
currentPrice.value = detailResponse.data.closePrice || 10
}
// 获取预测数据这里使用模拟数据实际应该调用预测API
const response = await getStockPrediction(stockCode.value, predictionDays.value)
// 生成模拟预测数据
const mockPredictionData = generateMockPredictionData()
predictionData.value = mockPredictionData
predictionTableData.value = generateTableData(mockPredictionData)
await nextTick()
initPredictionChart()
ElMessage.success('预测数据生成成功')
} catch (error) {
console.error('生成预测数据失败:', error)
ElMessage.error('生成预测数据失败')
} finally {
loading.value = false
chartLoading.value = false
}
}
// 生成模拟预测数据
const generateMockPredictionData = () => {
const data = []
let basePrice = currentPrice.value
const today = new Date()
for (let i = 1; i <= predictionDays.value; i++) {
const date = new Date(today)
date.setDate(date.getDate() + i)
// 模拟价格波动
const trend = (Math.random() - 0.5) * 0.1 // -5% 到 +5%
const noise = (Math.random() - 0.5) * 0.05 // 噪声
const predictedPrice = basePrice * (1 + trend + noise)
// 置信区间
const volatility = 0.05
const lowerBound = predictedPrice * (1 - volatility)
const upperBound = predictedPrice * (1 + volatility)
data.push({
date: date,
predictedPrice: predictedPrice,
lowerBound: lowerBound,
upperBound: upperBound,
changePercent: ((predictedPrice - basePrice) / basePrice) * 100,
confidence: parseFloat(confidenceLevel.value),
riskLevel: getRiskLevelByVolatility(Math.abs(trend + noise) * 100)
})
basePrice = predictedPrice
}
return data
}
// 生成表格数据
const generateTableData = (data) => {
return data.map(item => ({
...item,
date: item.date.toISOString().split('T')[0]
}))
}
// 初始化预测图表
const initPredictionChart = () => {
const chartDom = document.getElementById('prediction-chart')
if (!chartDom) return
const myChart = echarts.init(chartDom)
// 历史数据(模拟)
const historyDates = []
const historyPrices = []
const today = new Date()
for (let i = 30; i >= 1; i--) {
const date = new Date(today)
date.setDate(date.getDate() - i)
historyDates.push(date.toISOString().split('T')[0])
const fluctuation = (Math.random() - 0.5) * 0.1
const price = currentPrice.value * (1 + fluctuation)
historyPrices.push(price.toFixed(2))
}
// 预测数据
const predictionDates = predictionData.value.map(item => item.date.toISOString().split('T')[0])
const predictionPrices = predictionData.value.map(item => item.predictedPrice.toFixed(2))
const lowerBounds = predictionData.value.map(item => item.lowerBound.toFixed(2))
const upperBounds = predictionData.value.map(item => item.upperBound.toFixed(2))
const allDates = [...historyDates, ...predictionDates]
const allPrices = [...historyPrices, ...predictionPrices]
const series = [
{
name: '历史价格',
type: 'line',
data: historyPrices.map((price, index) => [historyDates[index], price]),
lineStyle: {
color: '#409EFF',
width: 2
},
symbol: 'circle',
symbolSize: 4
},
{
name: '预测价格',
type: 'line',
data: predictionPrices.map((price, index) => [predictionDates[index], price]),
lineStyle: {
color: '#F56C6C',
width: 2,
type: 'dashed'
},
symbol: 'diamond',
symbolSize: 6
}
]
if (showConfidenceInterval.value) {
series.push({
name: '置信区间',
type: 'line',
data: lowerBounds.map((price, index) => [predictionDates[index], price]),
lineStyle: {
color: '#E6A23C',
width: 1,
opacity: 0.6
},
symbol: 'none',
areaStyle: {
color: 'rgba(230, 162, 60, 0.2)'
},
stack: 'confidence'
})
series.push({
name: '',
type: 'line',
data: upperBounds.map((price, index) => [predictionDates[index], price]),
lineStyle: {
color: '#E6A23C',
width: 1,
opacity: 0.6
},
symbol: 'none',
areaStyle: {
color: 'rgba(230, 162, 60, 0.2)'
},
stack: 'confidence'
})
}
const option = {
title: {
text: `${stockCode.value} 股价预测分析`,
left: 'left'
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'cross'
}
},
legend: {
data: showConfidenceInterval.value ? ['历史价格', '预测价格', '置信区间'] : ['历史价格', '预测价格'],
top: 30
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
top: '15%',
containLabel: true
},
xAxis: {
type: 'time',
axisLine: {
lineStyle: {
color: '#8392A5'
}
}
},
yAxis: {
type: 'value',
scale: true,
axisLine: {
lineStyle: {
color: '#8392A5'
}
},
splitLine: {
show: true,
lineStyle: {
color: '#E6E8EB'
}
}
},
series: series
}
myChart.setOption(option)
}
// 更新图表
const updateChart = () => {
initPredictionChart()
}
// 返回
const goBack = () => {
router.go(-1)
}
// 计算预测统计
const getPredictionMax = () => {
if (predictionData.value.length === 0) return '0.00'
const max = Math.max(...predictionData.value.map(item => item.predictedPrice))
return max.toFixed(2)
}
const getPredictionMin = () => {
if (predictionData.value.length === 0) return '0.00'
const min = Math.min(...predictionData.value.map(item => item.predictedPrice))
return min.toFixed(2)
}
const getPredictionAvg = () => {
if (predictionData.value.length === 0) return '0.00'
const sum = predictionData.value.reduce((acc, item) => acc + item.predictedPrice, 0)
return (sum / predictionData.value.length).toFixed(2)
}
const getPredictionReturn = () => {
if (predictionData.value.length === 0) return 0
const lastPrice = predictionData.value[predictionData.value.length - 1].predictedPrice
return ((lastPrice - currentPrice.value) / currentPrice.value * 100)
}
// 分析函数
const getAccuracyColor = () => {
if (predictionAccuracy.value >= 80) return '#67c23a'
if (predictionAccuracy.value >= 60) return '#e6a23c'
return '#f56c6c'
}
const getRiskLevel = () => {
const returnRate = Math.abs(getPredictionReturn())
if (returnRate > 10) return '高风险'
if (returnRate > 5) return '中风险'
return '低风险'
}
const getRiskTagType = () => {
const level = getRiskLevel()
if (level === '高风险') return 'danger'
if (level === '中风险') return 'warning'
return 'success'
}
const getRiskLevelByVolatility = (volatility) => {
if (volatility > 5) return '高风险'
if (volatility > 2) return '中风险'
return '低风险'
}
const getRiskTagTypeByLevel = (level) => {
if (level === '高风险') return 'danger'
if (level === '中风险') return 'warning'
return 'success'
}
const getRecommendation = () => {
const returnRate = getPredictionReturn()
if (returnRate > 5) return '买入'
if (returnRate > 0) return '持有'
if (returnRate > -5) return '观望'
return '卖出'
}
const getRecommendationTagType = () => {
const recommendation = getRecommendation()
if (recommendation === '买入') return 'success'
if (recommendation === '持有') return 'primary'
if (recommendation === '观望') return 'warning'
return 'danger'
}
const getSupportLevel = () => {
return (currentPrice.value * 0.95).toFixed(2)
}
const getResistanceLevel = () => {
return (currentPrice.value * 1.05).toFixed(2)
}
const getVolatility = () => {
if (predictionData.value.length === 0) return '0.00'
const prices = predictionData.value.map(item => item.predictedPrice)
const avg = prices.reduce((sum, price) => sum + price, 0) / prices.length
const variance = prices.reduce((sum, price) => sum + Math.pow(price - avg, 2), 0) / prices.length
const volatility = Math.sqrt(variance) / avg * 100
return volatility.toFixed(2)
}
// 格式化函数
const formatChangePercent = (value) => {
if (!value) return '0.00'
return (value > 0 ? '+' : '') + value.toFixed(2)
}
const formatDate = (date) => {
if (!date) return '--'
return new Date(date).toLocaleDateString()
}
onMounted(() => {
generatePrediction()
})
return {
stockCode,
predictionData,
predictionTableData,
loading,
chartLoading,
predictionDays,
predictionModel,
confidenceLevel,
historyDays,
showConfidenceInterval,
predictionAccuracy,
generatePrediction,
updateChart,
goBack,
getPredictionMax,
getPredictionMin,
getPredictionAvg,
getPredictionReturn,
getAccuracyColor,
getRiskLevel,
getRiskTagType,
getRiskTagTypeByLevel,
getRecommendation,
getRecommendationTagType,
getSupportLevel,
getResistanceLevel,
getVolatility,
formatChangePercent,
formatDate
}
}
}
</script>
<style lang="scss" scoped>
.stock-prediction-view {
padding: 20px;
background-color: #f5f7fa;
min-height: 100vh;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
.stock-info {
h1 {
margin: 0;
color: #303133;
font-size: 24px;
}
.stock-code {
margin: 5px 0 0 0;
color: #909399;
font-size: 14px;
}
}
.action-buttons {
display: flex;
gap: 10px;
}
}
.prediction-config {
margin-bottom: 20px;
}
.config-card {
border: none;
border-radius: 8px;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
.config-item {
label {
display: block;
margin-bottom: 8px;
font-size: 14px;
color: #606266;
font-weight: 500;
}
}
}
.prediction-overview {
margin-bottom: 20px;
}
.prediction-card {
border: none;
border-radius: 8px;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
.prediction-info {
text-align: center;
padding: 20px;
.prediction-value {
font-size: 24px;
font-weight: bold;
color: #303133;
margin-bottom: 8px;
&.positive {
color: #f56c6c;
}
&.negative {
color: #67c23a;
}
}
.prediction-subtitle {
font-size: 14px;
color: #909399;
}
}
}
.chart-section {
margin-bottom: 20px;
}
.chart-card, .analysis-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;
}
}
.chart-controls {
display: flex;
align-items: center;
gap: 10px;
}
.analysis-content {
.analysis-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px 0;
border-bottom: 1px solid #f0f0f0;
&:last-child {
border-bottom: none;
}
.analysis-label {
font-size: 14px;
color: #606266;
font-weight: 500;
}
.analysis-value {
display: flex;
align-items: center;
gap: 10px;
.accuracy-text {
font-size: 14px;
color: #303133;
font-weight: 500;
}
}
}
}
.positive {
color: #f56c6c;
}
.negative {
color: #67c23a;
}
:deep(.el-table) {
.positive {
color: #f56c6c;
}
.negative {
color: #67c23a;
}
}
</style>

View File

@@ -0,0 +1,658 @@
<template>
<div class="stock-trend-view">
<!-- 页面标题 -->
<div class="page-header">
<div class="stock-info">
<h1>{{ trendData.stockName || stockCode }} - 趋势分析</h1>
<p class="stock-code">{{ 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>
<!-- 趋势概览 -->
<el-row :gutter="20" class="trend-overview">
<el-col :span="6">
<el-card class="trend-card">
<div class="trend-info">
<div class="trend-icon" :class="getTrendClass()">
<el-icon v-if="trendData.trendDirection === 'UP'"><CaretTop /></el-icon>
<el-icon v-else-if="trendData.trendDirection === 'DOWN'"><CaretBottom /></el-icon>
<el-icon v-else><Minus /></el-icon>
</div>
<div class="trend-text">
<div class="trend-title">{{ getTrendText() }}</div>
<div class="trend-subtitle">趋势方向</div>
</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card class="trend-card">
<div class="trend-info">
<div class="trend-value">{{ trendData.trendStrength?.toFixed(2) || '0.00' }}%</div>
<div class="trend-subtitle">趋势强度</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card class="trend-card">
<div class="trend-info">
<div class="trend-value">¥{{ trendData.currentPrice?.toFixed(2) || '0.00' }}</div>
<div class="trend-subtitle">当前价格</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card class="trend-card">
<div class="trend-info">
<div class="trend-value" :class="{ 'positive': trendData.totalChangePercent > 0, 'negative': trendData.totalChangePercent < 0 }">
{{ formatChangePercent(trendData.totalChangePercent) }}%
</div>
<div class="trend-subtitle">总涨跌幅</div>
</div>
</el-card>
</el-col>
</el-row>
<!-- 趋势图表 -->
<el-row :gutter="20" class="chart-section">
<el-col :span="16">
<el-card class="chart-card">
<template #header>
<div class="card-header">
<span>价格趋势图</span>
<div class="chart-controls">
<el-select v-model="days" @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: 500px;" v-loading="chartLoading"></div>
</el-card>
</el-col>
<el-col :span="8">
<el-card class="stats-card">
<template #header>
<span>统计信息</span>
</template>
<div class="stats-content">
<div class="stat-item">
<div class="stat-label">最高价</div>
<div class="stat-value">¥{{ trendData.highestPrice?.toFixed(2) || '--' }}</div>
</div>
<div class="stat-item">
<div class="stat-label">最低价</div>
<div class="stat-value">¥{{ trendData.lowestPrice?.toFixed(2) || '--' }}</div>
</div>
<div class="stat-item">
<div class="stat-label">平均价</div>
<div class="stat-value">¥{{ trendData.averagePrice?.toFixed(2) || '--' }}</div>
</div>
<div class="stat-item">
<div class="stat-label">平均涨跌幅</div>
<div class="stat-value" :class="{ 'positive': trendData.avgChangePercent > 0, 'negative': trendData.avgChangePercent < 0 }">
{{ formatChangePercent(trendData.avgChangePercent) }}%
</div>
</div>
<div class="stat-item">
<div class="stat-label">总成交量</div>
<div class="stat-value">{{ formatVolume(trendData.totalVolume) }}</div>
</div>
<div class="stat-item">
<div class="stat-label">平均成交量</div>
<div class="stat-value">{{ formatVolume(trendData.avgVolume) }}</div>
</div>
</div>
</el-card>
</el-col>
</el-row>
<!-- 成交量图表 -->
<el-row class="volume-section">
<el-col :span="24">
<el-card class="chart-card">
<template #header>
<span>成交量分析</span>
</template>
<div id="volume-chart" style="height: 300px;" v-loading="chartLoading"></div>
</el-card>
</el-col>
</el-row>
<!-- 价格历史数据表格 -->
<el-row>
<el-col :span="24">
<el-card class="table-card">
<template #header>
<span>历史价格数据</span>
</template>
<el-table :data="trendData.priceHistory || []" style="width: 100%" max-height="400">
<el-table-column prop="tradeDate" label="交易日期" width="120">
<template #default="scope">
{{ formatDate(scope.row.tradeDate) }}
</template>
</el-table-column>
<el-table-column prop="openPrice" label="开盘价" width="100">
<template #default="scope">
¥{{ scope.row.openPrice?.toFixed(2) }}
</template>
</el-table-column>
<el-table-column prop="closePrice" label="收盘价" width="100">
<template #default="scope">
¥{{ scope.row.closePrice?.toFixed(2) }}
</template>
</el-table-column>
<el-table-column prop="highPrice" label="最高价" width="100">
<template #default="scope">
¥{{ scope.row.highPrice?.toFixed(2) }}
</template>
</el-table-column>
<el-table-column prop="lowPrice" label="最低价" width="100">
<template #default="scope">
¥{{ scope.row.lowPrice?.toFixed(2) }}
</template>
</el-table-column>
<el-table-column prop="changePercent" label="涨跌幅" width="100">
<template #default="scope">
<span :class="{ 'positive': scope.row.changePercent > 0, 'negative': scope.row.changePercent < 0 }">
{{ formatChangePercent(scope.row.changePercent) }}%
</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>
</el-card>
</el-col>
</el-row>
</div>
</template>
<script>
import { ref, onMounted, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { getStockTrend } from '@/api/stock'
import { ElMessage } from 'element-plus'
import * as echarts from 'echarts'
export default {
name: 'StockTrendView',
setup() {
const route = useRoute()
const router = useRouter()
const stockCode = ref(route.params.stockCode)
const trendData = ref({})
const loading = ref(false)
const chartLoading = ref(false)
const days = ref(30)
// 加载趋势数据
const loadTrendData = async () => {
try {
loading.value = true
chartLoading.value = true
const response = await getStockTrend(stockCode.value, days.value)
if (response.code === 200 && response.data) {
trendData.value = response.data
await nextTick()
initCharts()
} else {
ElMessage.error(response.message || '获取趋势数据失败')
}
} catch (error) {
console.error('获取趋势数据失败:', error)
ElMessage.error('获取趋势数据失败')
} finally {
loading.value = false
chartLoading.value = false
}
}
// 初始化图表
const initCharts = async () => {
await nextTick()
initTrendChart()
initVolumeChart()
}
// 初始化趋势图表
const initTrendChart = () => {
const chartDom = document.getElementById('trend-chart')
if (!chartDom) return
const myChart = echarts.init(chartDom)
const priceHistory = trendData.value.priceHistory || []
const dates = priceHistory.map(item => formatDate(item.tradeDate))
const closePrices = priceHistory.map(item => item.closePrice)
const openPrices = priceHistory.map(item => item.openPrice)
const highPrices = priceHistory.map(item => item.highPrice)
const lowPrices = priceHistory.map(item => item.lowPrice)
const option = {
title: {
text: `${trendData.value.stockName}(${stockCode.value}) 价格趋势`,
left: 'left'
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'cross'
}
},
legend: {
data: ['收盘价', '开盘价', '最高价', '最低价'],
top: 30
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
top: '15%',
containLabel: true
},
xAxis: {
type: 'category',
data: dates,
axisLine: {
lineStyle: {
color: '#8392A5'
}
}
},
yAxis: {
type: 'value',
scale: true,
axisLine: {
lineStyle: {
color: '#8392A5'
}
},
splitLine: {
show: true,
lineStyle: {
color: '#E6E8EB'
}
}
},
series: [
{
name: '收盘价',
type: 'line',
data: closePrices,
smooth: true,
lineStyle: {
color: '#409EFF',
width: 2
},
symbol: 'circle',
symbolSize: 4
},
{
name: '开盘价',
type: 'line',
data: openPrices,
smooth: true,
lineStyle: {
color: '#67C23A',
width: 1
},
symbol: 'none'
},
{
name: '最高价',
type: 'line',
data: highPrices,
smooth: true,
lineStyle: {
color: '#F56C6C',
width: 1,
type: 'dashed'
},
symbol: 'none'
},
{
name: '最低价',
type: 'line',
data: lowPrices,
smooth: true,
lineStyle: {
color: '#E6A23C',
width: 1,
type: 'dashed'
},
symbol: 'none'
}
]
}
myChart.setOption(option)
}
// 初始化成交量图表
const initVolumeChart = () => {
const chartDom = document.getElementById('volume-chart')
if (!chartDom) return
const myChart = echarts.init(chartDom)
const priceHistory = trendData.value.priceHistory || []
const dates = priceHistory.map(item => formatDate(item.tradeDate))
const volumes = priceHistory.map(item => item.volume)
const option = {
title: {
text: '成交量趋势',
left: 'left'
},
tooltip: {
trigger: 'axis',
formatter: function(params) {
return `${params[0].name}<br/>成交量: ${formatVolume(params[0].value)}`
}
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
top: '15%',
containLabel: true
},
xAxis: {
type: 'category',
data: dates,
axisLine: {
lineStyle: {
color: '#8392A5'
}
}
},
yAxis: {
type: 'value',
axisLine: {
lineStyle: {
color: '#8392A5'
}
},
splitLine: {
show: true,
lineStyle: {
color: '#E6E8EB'
}
}
},
series: [
{
name: '成交量',
type: 'bar',
data: volumes,
itemStyle: {
color: '#409EFF'
}
}
]
}
myChart.setOption(option)
}
// 刷新数据
const refreshData = () => {
loadTrendData()
}
// 返回
const goBack = () => {
router.go(-1)
}
// 获取趋势类名
const getTrendClass = () => {
const direction = trendData.value.trendDirection
if (direction === 'UP') return 'trend-up'
if (direction === 'DOWN') return 'trend-down'
return 'trend-flat'
}
// 获取趋势文本
const getTrendText = () => {
const direction = trendData.value.trendDirection
if (direction === 'UP') return '上涨趋势'
if (direction === 'DOWN') return '下跌趋势'
return '震荡趋势'
}
// 格式化函数
const formatChangePercent = (value) => {
if (!value) return '0.00'
return (value > 0 ? '+' : '') + value.toFixed(2)
}
const formatVolume = (volume) => {
if (!volume) return '0'
if (volume >= 100000000) {
return (volume / 100000000).toFixed(2) + '亿'
} else if (volume >= 10000) {
return (volume / 10000).toFixed(2) + '万'
}
return volume.toString()
}
const formatDate = (date) => {
if (!date) return '--'
return new Date(date).toLocaleDateString()
}
onMounted(() => {
loadTrendData()
})
return {
stockCode,
trendData,
loading,
chartLoading,
days,
loadTrendData,
refreshData,
goBack,
getTrendClass,
getTrendText,
formatChangePercent,
formatVolume,
formatDate
}
}
}
</script>
<style lang="scss" scoped>
.stock-trend-view {
padding: 20px;
background-color: #f5f7fa;
min-height: 100vh;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
.stock-info {
h1 {
margin: 0;
color: #303133;
font-size: 24px;
}
.stock-code {
margin: 5px 0 0 0;
color: #909399;
font-size: 14px;
}
}
.action-buttons {
display: flex;
gap: 10px;
}
}
.trend-overview {
margin-bottom: 20px;
}
.trend-card {
border: none;
border-radius: 8px;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
.trend-info {
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
padding: 20px;
text-align: center;
.trend-icon {
font-size: 36px;
margin-bottom: 10px;
&.trend-up {
color: #f56c6c;
}
&.trend-down {
color: #67c23a;
}
&.trend-flat {
color: #909399;
}
}
.trend-value {
font-size: 24px;
font-weight: bold;
color: #303133;
margin-bottom: 5px;
&.positive {
color: #f56c6c;
}
&.negative {
color: #67c23a;
}
}
.trend-title {
font-size: 16px;
font-weight: 600;
color: #303133;
margin-bottom: 5px;
}
.trend-subtitle {
font-size: 12px;
color: #909399;
}
}
}
.chart-section, .volume-section {
margin-bottom: 20px;
}
.chart-card, .stats-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;
}
}
.chart-controls {
display: flex;
align-items: center;
gap: 10px;
}
.stats-content {
.stat-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px 0;
border-bottom: 1px solid #f0f0f0;
&:last-child {
border-bottom: none;
}
.stat-label {
font-size: 14px;
color: #909399;
}
.stat-value {
font-size: 16px;
font-weight: 500;
color: #303133;
&.positive {
color: #f56c6c;
}
&.negative {
color: #67c23a;
}
}
}
}
.positive {
color: #f56c6c;
}
.negative {
color: #67c23a;
}
:deep(.el-table) {
.positive {
color: #f56c6c;
}
.negative {
color: #67c23a;
}
}
</style>