This commit is contained in:
2026-04-26 19:03:17 +08:00
parent db0d46009c
commit 44ced193d5
10 changed files with 395 additions and 173 deletions
+32
View File
@@ -10,6 +10,7 @@
"dependencies": { "dependencies": {
"@element-plus/icons-vue": "^2.3.2", "@element-plus/icons-vue": "^2.3.2",
"axios": "^1.15.2", "axios": "^1.15.2",
"echarts": "^6.0.0",
"element-plus": "^2.13.7", "element-plus": "^2.13.7",
"vue": "^3.5.32", "vue": "^3.5.32",
"vue-router": "^4.6.4" "vue-router": "^4.6.4"
@@ -765,6 +766,22 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/echarts": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/echarts/-/echarts-6.0.0.tgz",
"integrity": "sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "2.3.0",
"zrender": "6.0.0"
}
},
"node_modules/echarts/node_modules/tslib": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
"license": "0BSD"
},
"node_modules/element-plus": { "node_modules/element-plus": {
"version": "2.13.7", "version": "2.13.7",
"resolved": "https://registry.npmjs.org/element-plus/-/element-plus-2.13.7.tgz", "resolved": "https://registry.npmjs.org/element-plus/-/element-plus-2.13.7.tgz",
@@ -1642,6 +1659,21 @@
"peerDependencies": { "peerDependencies": {
"vue": "^3.5.0" "vue": "^3.5.0"
} }
},
"node_modules/zrender": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/zrender/-/zrender-6.0.0.tgz",
"integrity": "sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==",
"license": "BSD-3-Clause",
"dependencies": {
"tslib": "2.3.0"
}
},
"node_modules/zrender/node_modules/tslib": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
"license": "0BSD"
} }
} }
} }
+1
View File
@@ -11,6 +11,7 @@
"dependencies": { "dependencies": {
"@element-plus/icons-vue": "^2.3.2", "@element-plus/icons-vue": "^2.3.2",
"axios": "^1.15.2", "axios": "^1.15.2",
"echarts": "^6.0.0",
"element-plus": "^2.13.7", "element-plus": "^2.13.7",
"vue": "^3.5.32", "vue": "^3.5.32",
"vue-router": "^4.6.4" "vue-router": "^4.6.4"
+1 -9
View File
@@ -2,14 +2,6 @@
<router-view /> <router-view />
</template> </template>
<script setup>
// 全局入口,无需额外逻辑
</script>
<style> <style>
body { body { margin: 0; background-color: #f5f7fa; }
margin: 0;
background-color: #f0f2f5;
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
}
</style> </style>
+4
View File
@@ -0,0 +1,4 @@
import request from '@/utils/request'
export function getTradeCalendar() {
return request({ url: '/common/trade-calendar', method: 'get' })
}
+15 -9
View File
@@ -1,11 +1,17 @@
import axios from 'axios' import request from '@/utils/request'
const request = axios.create({ export function getAnalyzeData(data) {
baseURL: 'http://localhost:18080/api/v1', // 指向你的 Go 后端 return request({
timeout: 10000 url: '/stock/analyze',
}) method: 'post',
data
// 获取涨停回落分析数据 })
export const getAnalyzeData = (params) => { }
return request.get('/stock/analyze', { params })
// 新增:获取历史K线
export function getKlineData(code) {
return request({
url: `/stock/kline/${code}`,
method: 'get'
})
} }
+13 -2
View File
@@ -3,13 +3,24 @@ import App from './App.vue'
import router from './router' import router from './router'
import ElementPlus from 'element-plus' import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css' import 'element-plus/dist/index.css'
// 引入中文语言包
// 引入 Element Plus 中文语言包
import zhCn from 'element-plus/dist/locale/zh-cn.mjs' import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
// 引入图标
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
const app = createApp(App) const app = createApp(App)
// 全局注册 Element Plus 图标组件
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
// 注册路由
app.use(router) app.use(router)
// 全局配置 Element Plus 使用中文
// 注册 Element Plus 并配置中文国际化
app.use(ElementPlus, { app.use(ElementPlus, {
locale: zhCn, locale: zhCn,
}) })
+2 -1
View File
@@ -8,7 +8,8 @@ const routes = [
{ {
path: '/stock/analyze', path: '/stock/analyze',
name: 'StockAnalyze', name: 'StockAnalyze',
component: () => import('../views/stock/Analyze.vue') component: () => import('@/views/stock/Analyze.vue'),
meta: { title: '涨停回落分析' }
} }
] ]
+17
View File
@@ -0,0 +1,17 @@
import axios from 'axios'
import { ElMessage } from 'element-plus'
const service = axios.create({
baseURL: '/api/v1',
timeout: 15000
})
service.interceptors.response.use(
response => response.data,
error => {
ElMessage.error(error.response?.data?.error || '请求失败')
return Promise.reject(error)
}
)
export default service
+294 -141
View File
@@ -1,178 +1,331 @@
<template> <template>
<div class="analyze-container"> <div class="analyze-container">
<el-card shadow="never"> <el-row :gutter="15" class="top-row">
<template #header> <el-col :span="12">
<div class="header-title">📊 涨停后回落分析</div> <div class="glass-card filter-container">
</template> <el-form :inline="true" class="pure-form">
<el-form-item label="起算">
<div class="filter-section"> <el-date-picker
<div class="filter-item"> v-model="startDate"
<span class="label-text">选定日期:</span> type="date"
<el-date-picker value-format="YYYY-MM-DD"
v-model="startDate" :disabled-date="disableDates"
type="date" :editable="false"
placeholder="请选择" placeholder="点击选择"
format="YYYY-MM-DD" style="width: 140px"
value-format="YYYY-MM-DD" />
style="width: 140px" </el-form-item>
/> <el-form-item label="截止">
<el-date-picker
v-model="endDate"
type="date"
value-format="YYYY-MM-DD"
:disabled-date="disableDates"
:editable="false"
placeholder="点击选择"
style="width: 140px"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleQuery" :loading="loading" plain>开始分析</el-button>
</el-form-item>
</el-form>
</div> </div>
</el-col>
<div class="filter-item"> <el-col :span="12">
<span class="label-text">结束日期:</span> <div class="glass-card info-container">
<el-date-picker <div class="stat-group">
v-model="endDate" <div class="stat-box">
type="date" <span class="label">样本总数</span>
placeholder="请选择" <span class="value">{{ tableData.length }}</span>
format="YYYY-MM-DD" </div>
value-format="YYYY-MM-DD" <div class="divider"></div>
style="width: 140px" <div class="stat-box">
/> <span class="label">强势占比</span>
<span class="value" :style="{ color: parseFloat(strongPercent) > 50 ? '#f56c6c' : '#409EFF' }">
{{ strongPercent }}%
</span>
</div>
</div>
</div> </div>
</el-col>
</el-row>
<el-button <el-card class="main-card" shadow="never">
type="primary"
@click="fetchData"
:loading="loading"
style="margin-left: 12px"
>
查询数据
</el-button>
</div>
<el-table <el-table
:data="tableData" :data="tableData"
v-loading="loading" v-loading="loading"
stripe stripe
style="width: 100%; margin-top: 20px" border
:header-cell-style="{ background: '#f5f7fa', color: '#606266' }" height="calc(100vh - 180px)"
:default-sort="{ prop: 'drop_pct', order: 'descending' }" style="width: 100%"
:header-cell-style="{ background: '#f8f9fb', color: '#444', textAlign: 'center' }"
> >
<el-table-column prop="code" label="代码" width="100" /> <el-table-column type="index" label="ID" width="55" fixed="left" align="center" />
<el-table-column prop="name" label="名称" width="120" />
<el-table-column prop="day_open" label="当日开盘" align="right" />
<el-table-column
prop="max_high"
label="区间最高"
sortable
align="right"
/>
<el-table-column
prop="max_date"
label="最高日期"
width="120"
align="center"
/>
<el-table-column prop="post_min_low" label="见顶后最低" align="right" />
<el-table-column <el-table-column label="股票详情 / 7日趋势" width="260" fixed="left">
prop="drop_pct" <template #default="{ row }">
label="最大回落幅度%" <div class="stock-combined-box">
sortable <div class="meta-info clickable" @click="openKlineDialog(row)" title="点击查看历史K线">
align="right" <span class="c-code">{{ row.code.split('.')[0] }}</span>
> <span class="c-name">{{ row.name }}</span>
<template #default="scope"> </div>
<span v-if="scope.row.drop_pct !== null" class="text-success"> <div class="sparkline-container">
{{ scope.row.drop_pct.toFixed(2) }}% <svg width="110" height="34" class="sparkline-svg">
</span> <path
<span v-else class="text-gray">--</span> fill="none"
stroke="#409EFF"
stroke-width="2"
stroke-linecap="round"
:d="generateSparklinePath(row.close_prices)"
/>
</svg>
</div>
</div>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column <el-table-column label="价格参考" align="center">
prop="rebound_pct" <el-table-column prop="day_high" label="涨停价" width="100" align="center">
label="低位反弹%" <template #default="{row}">
sortable <span class="num-text">{{ row.day_high.toFixed(2) }}</span>
align="right" </template>
> </el-table-column>
<template #default="scope"> <el-table-column prop="overall_max_high" label="区间最高" width="100" align="center">
<span <template #default="{row}">
v-if="scope.row.rebound_pct !== null" <span class="num-text bold">{{ row.overall_max_high.toFixed(2) }}</span>
:class="scope.row.rebound_pct > 0 ? 'text-danger' : 'text-gray'" </template>
> </el-table-column>
{{ scope.row.rebound_pct.toFixed(2) }}% <el-table-column prop="overall_max_date" label="最高日期" width="110" align="center" />
</span> </el-table-column>
<span v-else class="text-gray">--</span>
</template> <el-table-column label="形态核心分析" align="center">
<el-table-column prop="drop_pct" label="最大回落%" sortable width="130" align="center">
<template #default="{ row }">
<div v-if="row.drop_pct !== null" class="status-tag tag-green">
{{ row.drop_pct.toFixed(2) }}%
</div>
<div v-else class="status-tag tag-blue">强势不破</div>
</template>
</el-table-column>
<el-table-column prop="rebound_pct" label="涨幅 / 反弹%" sortable min-width="130" align="center">
<template #default="{ row }">
<div :class="['status-tag', row.drop_pct === null ? 'tag-orange' : 'tag-red']">
{{ row.rebound_pct.toFixed(2) }}%
</div>
</template>
</el-table-column>
</el-table-column> </el-table-column>
</el-table> </el-table>
</el-card> </el-card>
<el-dialog
v-model="dialogVisible"
:title="dialogTitle"
width="85%"
top="5vh"
destroy-on-close
draggable
>
<div id="kline-chart" style="width: 100%; height: 550px;"></div>
</el-dialog>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref } from "vue"; import { ref, computed, onMounted, nextTick } from 'vue'
import { getAnalyzeData } from "@/api/stock"; import * as echarts from 'echarts'
import { ElMessage } from "element-plus"; import dayjs from 'dayjs'
import { getTradeCalendar } from '@/api/common'
import { getAnalyzeData, getKlineData } from '@/api/stock'
const startDate = ref("2026-03-02"); // 响应式数据
const endDate = ref("2026-04-23"); const startDate = ref('2026-04-22')
const tableData = ref([]); const endDate = ref('')
const loading = ref(false); const tableData = ref([])
const loading = ref(false)
const openDatesSet = ref(new Set())
const maxTradeDate = ref('')
const fetchData = async () => { // 弹窗与图表变量
if (!startDate.value || !endDate.value) { const dialogVisible = ref(false)
return ElMessage.warning("请选择日期"); const dialogTitle = ref('')
} let chartInstance = null
if (new Date(startDate.value) > new Date(endDate.value)) { // 计算属性:统计强势占比
return ElMessage.warning("选定日期不能晚于结束日期"); const strongPercent = computed(() => {
} if (!tableData.value.length) return '0.0'
const count = tableData.value.filter(i => i.drop_pct === null).length
return ((count / tableData.value.length) * 100).toFixed(1)
})
loading.value = true; onMounted(async () => {
try { try {
const res = await getAnalyzeData({ const res = await getTradeCalendar()
start_date: startDate.value, openDatesSet.value = new Set(res.open_dates)
end_date: endDate.value, maxTradeDate.value = res.max_date
}); if (!endDate.value) endDate.value = res.max_date
tableData.value = res.data;
ElMessage.success(`加载成功: ${res.data.length} 条数据`); // 局域网访问提示:建议 axios baseURL 使用 `http://${window.location.hostname}:8080/api/v1`
console.log("当前主机访问地址:", window.location.hostname)
} catch (err) { } catch (err) {
ElMessage.error("数据获取失败"); console.error("初始化数据失败")
} finally {
loading.value = false;
} }
}; })
// 日期禁用逻辑
const disableDates = (time) => {
const d = dayjs(time).format('YYYY-MM-DD')
if (maxTradeDate.value && d > maxTradeDate.value) return true
return openDatesSet.value.size > 0 ? !openDatesSet.value.has(d) : false
}
// 分析查询
const handleQuery = async () => {
loading.value = true
try {
const res = await getAnalyzeData({ start_date: startDate.value, end_date: endDate.value })
tableData.value = res
} finally {
loading.value = false
}
}
// 生成 Sparkline 路径
const generateSparklinePath = (prices) => {
if (!prices || prices.length < 2) return ""
const min = Math.min(...prices), max = Math.max(...prices), range = max - min || 1
const w = 110, h = 34, p = 4
const points = prices.map((val, i) => ({
x: (i / (prices.length - 1)) * w,
y: h - p - ((val - min) / range) * (h - 2 * p)
}))
return `M ${points[0].x} ${points[0].y} ` + points.slice(1).map(pt => `L ${pt.x} ${pt.y}`).join(" ")
}
// 渲染 K 线图
const openKlineDialog = async (row) => {
dialogTitle.value = `${row.name} (${row.code}) - 自 2024-09-24 以来走势`
dialogVisible.value = true
try {
const rawData = await getKlineData(row.code)
await nextTick()
const chartDom = document.getElementById('kline-chart')
if (!chartDom) return
if (chartInstance) chartInstance.dispose()
chartInstance = echarts.init(chartDom)
// 数据提取 (匹配后端 JSON 标签)
const dates = rawData.map(d => dayjs(d.trade_date).format('YYYY-MM-DD'))
const values = rawData.map(d => [
d.open,
d.close,
d.low,
d.high,
d.pct_chg // 传递给 Tooltip
])
const option = {
animation: false,
tooltip: {
trigger: 'axis',
axisPointer: { type: 'cross' },
formatter: function (params) {
const d = params[0]
const pct = d.value[5] || 0
const color = pct >= 0 ? '#f56c6c' : '#67c23a'
return `
<div style="font-size:12px; min-width:120px;">
<b>${d.name}</b><hr style="margin:5px 0;border:none;border-top:1px solid #eee"/>
涨跌幅: <span style="color:${color};font-weight:bold;">${pct > 0 ? '+' : ''}${pct.toFixed(2)}%</span><br/>
开盘价: ${d.value[1].toFixed(2)}<br/>
收盘价: ${d.value[2].toFixed(2)}<br/>
最低价: ${d.value[3].toFixed(2)}<br/>
最高价: ${d.value[4].toFixed(2)}
</div>`
}
},
grid: { left: '60', right: '30', bottom: '10%', top: '40' },
xAxis: { type: 'category', data: dates, scale: true, boundaryGap: false },
yAxis: { scale: true, splitArea: { show: true } },
dataZoom: [
{ type: 'inside', start: 80, end: 100 },
{ show: true, type: 'slider', top: '92%' }
],
series: [{
name: '日K',
type: 'candlestick',
data: values,
itemStyle: {
color: '#f56c6c', color0: '#67c23a',
borderColor: '#f56c6c', borderColor0: '#67c23a'
}
}]
}
chartInstance.setOption(option)
} catch (err) {
console.error("加载K线详情失败", err)
}
}
</script> </script>
<style scoped> <style scoped>
.analyze-container { .analyze-container { padding: 20px; background-color: #f4f7f9; min-height: 100vh; }
padding: 24px;
background-color: #f0f2f5; .top-row { margin-bottom: 20px; }
min-height: calc(100vh - 48px); .glass-card {
} background: #fff;
.header-title { height: 70px;
font-weight: 600; border-radius: 12px;
font-size: 18px;
color: #303133;
}
.filter-section {
display: flex; display: flex;
align-items: center; align-items: center;
flex-wrap: wrap; padding: 0 20px;
gap: 20px; box-shadow: 0 4px 12px rgba(0,0,0,0.03);
} }
.filter-item {
display: flex; .stat-group { display: flex; width: 100%; justify-content: space-around; align-items: center; }
align-items: center; .stat-box { display: flex; flex-direction: column; align-items: center; }
} .stat-box .label { font-size: 12px; color: #999; margin-bottom: 4px; }
.label-text { .stat-box .value { font-size: 24px; font-weight: 800; color: #333; }
font-size: 14px; .divider { width: 1px; height: 30px; background: #eee; }
color: #606266;
margin-right: 8px; .stock-combined-box { display: flex; align-items: center; justify-content: space-between; width: 100%; }
} .meta-info { display: flex; flex-direction: column; }
.text-success { .meta-info.clickable { cursor: pointer; }
color: #67c23a; /* 鼠标滑过代码名称时,给出视觉反馈 */
.meta-info.clickable:hover .c-name { color: #409EFF; text-decoration: underline; }
.c-code { font-family: 'JetBrains Mono', monospace; font-weight: bold; color: #409EFF; font-size: 14px; }
.c-name { font-size: 13px; color: #666; transition: color 0.2s; }
.sparkline-container { border-left: 1px dashed #eee; padding-left: 10px; margin-left: 10px; }
/* 日历光标强制为手型 */
:deep(.el-input__wrapper) { cursor: pointer !important; }
:deep(.el-input__inner) { cursor: pointer !important; user-select: none; }
/* 弹窗标题栏可移动提示 */
:deep(.el-dialog__header) { cursor: move; margin-right: 0; padding-bottom: 15px; border-bottom: 1px solid #f5f5f5; }
.status-tag {
display: inline-block;
padding: 4px 10px;
border-radius: 6px;
font-weight: bold; font-weight: bold;
font-size: 13px;
min-width: 85px;
} }
.text-danger { .tag-green { background: #f0f9eb; color: #67c23a; }
color: #f56c6c; .tag-blue { background: #ecf5ff; color: #409EFF; }
font-weight: bold; .tag-orange { background: #fdf6ec; color: #e6a23c; }
} .tag-red { background: #fef0f0; color: #f56c6c; }
.text-gray {
color: #909399; .num-text { font-family: 'JetBrains Mono', monospace; }
} .bold { font-weight: bold; }
:deep(.el-table .cell) { .main-card { border-radius: 12px; border: none; }
padding: 0 12px;
} :deep(.el-form-item) { margin-bottom: 0; }
</style> </style>
+9 -4
View File
@@ -5,11 +5,16 @@ import path from 'path'
export default defineConfig({ export default defineConfig({
plugins: [vue()], plugins: [vue()],
resolve: { resolve: {
alias: { alias: { '@': path.resolve(__dirname, 'src') }
'@': path.resolve(__dirname, './src')
}
}, },
server: { server: {
port: 5173 host: '0.0.0.0', // 监听所有地址,包括局域网 IP
port: 5173, // 你的端口号
proxy: {
'/api/v1': {
target: 'http://localhost:18080',
changeOrigin: true
}
}
} }
}) })