feat: fix frontend
This commit is contained in:
@@ -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>
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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: '健康检查' }
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
606
frontend/src/views/StockDetailView.vue
Normal file
606
frontend/src/views/StockDetailView.vue
Normal 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>
|
||||
795
frontend/src/views/StockPredictionView.vue
Normal file
795
frontend/src/views/StockPredictionView.vue
Normal 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>
|
||||
658
frontend/src/views/StockTrendView.vue
Normal file
658
frontend/src/views/StockTrendView.vue
Normal 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>
|
||||
Reference in New Issue
Block a user