This commit is contained in:
2026-04-26 20:40:08 +08:00
parent 44ced193d5
commit aea0d42d52
2 changed files with 432 additions and 122 deletions
+178 -2
View File
@@ -1,7 +1,183 @@
<template>
<div class="app-shell">
<aside :class="['sidebar', { collapsed: isSidebarCollapsed }]">
<div class="sidebar-top">
<button class="sidebar-toggle" type="button" @click="toggleSidebar">
<el-icon><component :is="isSidebarCollapsed ? Expand : Fold" /></el-icon>
</button>
<div v-if="!isSidebarCollapsed" class="brand-block">
<span class="brand-title">Stock Lab</span>
<span class="brand-subtitle">页面导航</span>
</div>
</div>
<el-menu
:default-active="activeMenu"
:collapse="isSidebarCollapsed"
:collapse-transition="false"
class="nav-menu"
router
>
<el-menu-item
v-for="item in menuItems"
:key="item.path"
:index="item.path"
@click="navigateTo(item.path)"
>
<el-icon><DataAnalysis /></el-icon>
<template #title>{{ item.title }}</template>
</el-menu-item>
</el-menu>
</aside>
<main class="page-main">
<router-view />
</main>
</div>
</template>
<style>
body { margin: 0; background-color: #f5f7fa; }
<script setup>
import { computed, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { DataAnalysis, Expand, Fold } from '@element-plus/icons-vue'
const route = useRoute()
const router = useRouter()
const isSidebarCollapsed = ref(false)
const menuItems = computed(() =>
router.options.routes
.filter((item) => item.path && item.meta?.title && !item.redirect)
.map((item) => ({
path: item.path,
title: item.meta.title
}))
)
const activeMenu = computed(() => route.path)
const toggleSidebar = () => {
isSidebarCollapsed.value = !isSidebarCollapsed.value
}
const navigateTo = (path) => {
if (route.path === path) return
router.push(path)
}
</script>
<style scoped>
.app-shell {
display: flex;
min-height: 100vh;
background:
linear-gradient(180deg, #f8fbff 0%, #f2f6fb 100%);
}
.sidebar {
width: 236px;
flex-shrink: 0;
display: flex;
flex-direction: column;
padding: 18px 14px;
box-sizing: border-box;
background: #ffffff;
border-right: 1px solid #e5edf5;
transition: width 0.25s ease, padding 0.25s ease;
}
.sidebar.collapsed {
width: 76px;
padding-inline: 10px;
}
.sidebar-top {
display: flex;
align-items: center;
gap: 12px;
min-height: 52px;
margin-bottom: 18px;
}
.sidebar-toggle {
width: 40px;
height: 40px;
border: 1px solid #dbe5f0;
border-radius: 10px;
display: inline-flex;
align-items: center;
justify-content: center;
background: #f7fbff;
color: #245d9c;
cursor: pointer;
}
.sidebar-toggle:hover {
background: #eef6ff;
}
.brand-block {
display: flex;
flex-direction: column;
min-width: 0;
}
.brand-title {
font-size: 18px;
line-height: 24px;
font-weight: 700;
color: #1f2a37;
}
.brand-subtitle {
font-size: 12px;
color: #7c8aa5;
}
.nav-menu {
border-right: none;
background: transparent;
}
.page-main {
flex: 1;
min-width: 0;
}
:deep(.nav-menu .el-menu-item) {
height: 46px;
margin-bottom: 8px;
border-radius: 12px;
color: #41536d;
}
:deep(.nav-menu .el-menu-item .el-icon) {
color: #5f7ea5;
}
:deep(.nav-menu .el-menu-item:hover) {
background: #eef6ff;
color: #1f5fa8;
}
:deep(.nav-menu .el-menu-item.is-active) {
background: #e3f0ff;
color: #13559d;
}
:deep(.nav-menu.el-menu--collapse .el-menu-item) {
padding: 0;
justify-content: center;
}
@media (max-width: 820px) {
.sidebar {
width: 88px;
padding-inline: 10px;
}
.brand-block {
display: none;
}
}
</style>
+195 -61
View File
@@ -1,7 +1,7 @@
<template>
<div class="analyze-container">
<el-row :gutter="15" class="top-row">
<el-col :span="12">
<el-col :xs="24" :sm="24" :md="24" :lg="12">
<div class="glass-card filter-container">
<el-form :inline="true" class="pure-form">
<el-form-item label="起算">
@@ -27,12 +27,12 @@
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleQuery" :loading="loading" plain>开始分析</el-button>
<el-button type="primary" @click="handleQuery" :loading="loading" :disabled="!startDate || !endDate" plain>开始分析</el-button>
</el-form-item>
</el-form>
</div>
</el-col>
<el-col :span="12">
<el-col :xs="24" :sm="24" :md="24" :lg="12">
<div class="glass-card info-container">
<div class="stat-group">
<div class="stat-box">
@@ -52,13 +52,15 @@
</el-row>
<el-card class="main-card" shadow="never">
<div class="table-shell">
<el-table
:data="tableData"
v-loading="loading"
class="analyze-table"
stripe
border
height="calc(100vh - 180px)"
style="width: 100%"
:height="tableHeight"
style="width: 100%; min-width: 980px"
:header-cell-style="{ background: '#f8f9fb', color: '#444', textAlign: 'center' }"
>
<el-table-column type="index" label="ID" width="55" fixed="left" align="center" />
@@ -67,7 +69,7 @@
<template #default="{ row }">
<div class="stock-combined-box">
<div class="meta-info clickable" @click="openKlineDialog(row)" title="点击查看历史K线">
<span class="c-code">{{ row.code.split('.')[0] }}</span>
<span class="c-code">{{ getCodePrefix(row.code) }}</span>
<span class="c-name">{{ row.name }}</span>
</div>
<div class="sparkline-container">
@@ -88,12 +90,12 @@
<el-table-column label="价格参考" align="center">
<el-table-column prop="day_high" label="涨停价" width="100" align="center">
<template #default="{row}">
<span class="num-text">{{ row.day_high.toFixed(2) }}</span>
<span class="num-text">{{ formatPrice(row.day_high) }}</span>
</template>
</el-table-column>
<el-table-column prop="overall_max_high" label="区间最高" width="100" align="center">
<template #default="{row}">
<span class="num-text bold">{{ row.overall_max_high.toFixed(2) }}</span>
<span class="num-text bold">{{ formatPrice(row.overall_max_high) }}</span>
</template>
</el-table-column>
<el-table-column prop="overall_max_date" label="最高日期" width="110" align="center" />
@@ -103,7 +105,7 @@
<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) }}%
{{ formatPrice(row.drop_pct) }}%
</div>
<div v-else class="status-tag tag-blue">强势不破</div>
</template>
@@ -112,101 +114,165 @@
<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) }}%
{{ formatPrice(row.rebound_pct) }}%
</div>
</template>
</el-table-column>
</el-table-column>
</el-table>
</div>
</el-card>
<el-dialog
v-model="dialogVisible"
:title="dialogTitle"
width="85%"
class="kline-dialog"
:width="dialogWidth"
top="5vh"
destroy-on-close
draggable
overflow
@closed="disposeChart"
>
<div id="kline-chart" style="width: 100%; height: 550px;"></div>
<div id="kline-chart" :style="{ width: '100%', height: `${chartHeight}px` }"></div>
</el-dialog>
</div>
</template>
<script setup>
import { ref, computed, onMounted, nextTick } from 'vue'
import { ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue'
import * as echarts from 'echarts'
import dayjs from 'dayjs'
import { getTradeCalendar } from '@/api/common'
import { getAnalyzeData, getKlineData } from '@/api/stock'
// 响应式数据
const startDate = ref('2026-04-22')
const DEFAULT_START_DATE = '2026-04-22'
const DESKTOP_TABLE_HEIGHT = 'calc(100vh - 214px)'
const TABLET_TABLE_HEIGHT = 'calc(100vh - 290px)'
const MOBILE_TABLE_HEIGHT = 'calc(100vh - 332px)'
const SPARKLINE_WIDTH = 110
const SPARKLINE_HEIGHT = 34
const SPARKLINE_PADDING = 4
const viewportWidth = ref(window.innerWidth)
const endDate = ref('')
const startDate = ref(DEFAULT_START_DATE)
const tableData = ref([])
const loading = ref(false)
const openDatesSet = ref(new Set())
const maxTradeDate = ref('')
// 弹窗与图表变量
const dialogVisible = ref(false)
const dialogTitle = ref('')
let chartInstance = null
// 计算属性:统计强势占比
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)
const strongCount = tableData.value.filter((item) => item.drop_pct === null).length
return ((strongCount / tableData.value.length) * 100).toFixed(1)
})
const isTabletViewport = computed(() => viewportWidth.value <= 1024)
const isPhoneViewport = computed(() => viewportWidth.value <= 820)
const tableHeight = computed(() => {
if (isPhoneViewport.value) return MOBILE_TABLE_HEIGHT
if (isTabletViewport.value) return TABLET_TABLE_HEIGHT
return DESKTOP_TABLE_HEIGHT
})
const dialogWidth = computed(() => {
if (isPhoneViewport.value) return '96%'
if (isTabletViewport.value) return '92%'
return '85%'
})
const chartHeight = computed(() => {
if (isPhoneViewport.value) return 360
if (isTabletViewport.value) return 440
return 550
})
onMounted(async () => {
window.addEventListener('resize', syncViewport)
try {
const res = await getTradeCalendar()
openDatesSet.value = new Set(res.open_dates)
maxTradeDate.value = res.max_date
if (!endDate.value) endDate.value = res.max_date
const { open_dates: openDates = [], max_date: maxDate = '' } = res || {}
// 局域网访问提示:建议 axios baseURL 使用 `http://${window.location.hostname}:8080/api/v1`
console.log("当前主机访问地址:", window.location.hostname)
openDatesSet.value = new Set(openDates)
maxTradeDate.value = maxDate
endDate.value = endDate.value || maxDate
startDate.value = clampToMaxDate(startDate.value)
} catch (err) {
console.error("初始化数据失败")
console.error('初始化分析页失败', err)
}
})
// 日期禁用逻辑
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
onBeforeUnmount(() => {
window.removeEventListener('resize', syncViewport)
disposeChart()
})
const syncViewport = () => {
viewportWidth.value = window.innerWidth
chartInstance?.resize()
}
const clampToMaxDate = (date) => {
if (!date || !maxTradeDate.value || date <= maxTradeDate.value) return date
return maxTradeDate.value
}
const disableDates = (time) => {
const date = dayjs(time).format('YYYY-MM-DD')
if (maxTradeDate.value && date > maxTradeDate.value) return true
return openDatesSet.value.size > 0 ? !openDatesSet.value.has(date) : false
}
const formatPrice = (value) => Number(value ?? 0).toFixed(2)
const getCodePrefix = (code = '') => code.split('.')[0]
const disposeChart = () => {
if (!chartInstance) return
chartInstance.dispose()
chartInstance = null
}
// 分析查询
const handleQuery = async () => {
if (!startDate.value || !endDate.value) return
loading.value = true
try {
const res = await getAnalyzeData({ start_date: startDate.value, end_date: endDate.value })
tableData.value = res
tableData.value = Array.isArray(res) ? res : []
} catch (err) {
console.error('加载分析数据失败', err)
} 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)
if (!Array.isArray(prices) || prices.length < 2) return ''
const min = Math.min(...prices)
const max = Math.max(...prices)
const range = max - min || 1
const points = prices.map((price, index) => ({
x: (index / (prices.length - 1)) * SPARKLINE_WIDTH,
y: SPARKLINE_HEIGHT - SPARKLINE_PADDING - ((price - min) / range) * (SPARKLINE_HEIGHT - 2 * SPARKLINE_PADDING)
}))
return `M ${points[0].x} ${points[0].y} ` + points.slice(1).map(pt => `L ${pt.x} ${pt.y}`).join(" ")
return `M ${points[0].x} ${points[0].y} ${points
.slice(1)
.map((point) => `L ${point.x} ${point.y}`)
.join(' ')}`
}
// 渲染 K 线图
const openKlineDialog = async (row) => {
dialogTitle.value = `${row.name} (${row.code}) - 自 2024-09-24 以来走势`
dialogTitle.value = `${row.name} (${row.code}) - 区间走势`
dialogVisible.value = true
try {
@@ -216,17 +282,16 @@ const openKlineDialog = async (row) => {
const chartDom = document.getElementById('kline-chart')
if (!chartDom) return
if (chartInstance) chartInstance.dispose()
disposeChart()
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 dates = rawData.map((item) => dayjs(item.trade_date).format('YYYY-MM-DD'))
const values = rawData.map((item) => [
item.open,
item.close,
item.low,
item.high,
item.pct_chg
])
const option = {
@@ -234,18 +299,18 @@ const openKlineDialog = async (row) => {
tooltip: {
trigger: 'axis',
axisPointer: { type: 'cross' },
formatter: function (params) {
const d = params[0]
const pct = d.value[5] || 0
formatter(params) {
const point = params[0]
const pct = point.value[4] || 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"/>
<b>${point.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)}
开盘价: ${formatPrice(point.value[0])}<br/>
收盘价: ${formatPrice(point.value[1])}<br/>
最低价: ${formatPrice(point.value[2])}<br/>
最高价: ${formatPrice(point.value[3])}
</div>`
}
},
@@ -267,14 +332,20 @@ const openKlineDialog = async (row) => {
}]
}
chartInstance.setOption(option)
chartInstance.resize()
} catch (err) {
console.error("加载K线详情失败", err)
console.error('加载K线详情失败', err)
}
}
</script>
<style scoped>
.analyze-container { padding: 20px; background-color: #f4f7f9; min-height: 100vh; }
.analyze-container {
padding: 20px;
background-color: #f4f7f9;
min-height: 100vh;
box-sizing: border-box;
}
.top-row { margin-bottom: 20px; }
.glass-card {
@@ -292,6 +363,7 @@ const openKlineDialog = async (row) => {
.stat-box .label { font-size: 12px; color: #999; margin-bottom: 4px; }
.stat-box .value { font-size: 24px; font-weight: 800; color: #333; }
.divider { width: 1px; height: 30px; background: #eee; }
.table-shell { width: 100%; overflow-x: auto; overflow-y: hidden; }
.stock-combined-box { display: flex; align-items: center; justify-content: space-between; width: 100%; }
.meta-info { display: flex; flex-direction: column; }
@@ -306,9 +378,13 @@ const openKlineDialog = async (row) => {
/* 日历光标强制为手型 */
:deep(.el-input__wrapper) { cursor: pointer !important; }
:deep(.el-input__inner) { cursor: pointer !important; user-select: none; }
:deep(.pure-form) { display: flex; flex-wrap: wrap; gap: 8px 0; }
:deep(.pure-form .el-form-item__label) { color: #5b6577; }
/* 弹窗标题栏可移动提示 */
:deep(.el-dialog__header) { cursor: move; margin-right: 0; padding-bottom: 15px; border-bottom: 1px solid #f5f5f5; }
:deep(.kline-dialog) { margin: 0 !important; max-width: 1200px; }
:deep(.kline-dialog .el-dialog) { border-radius: 18px; overflow: hidden; }
.status-tag {
display: inline-block;
@@ -328,4 +404,62 @@ const openKlineDialog = async (row) => {
.main-card { border-radius: 12px; border: none; }
:deep(.el-form-item) { margin-bottom: 0; }
:deep(.analyze-table .el-table__cell) { white-space: nowrap; }
@media (max-width: 1024px) {
.analyze-container {
padding: 16px;
}
.top-row {
margin-bottom: 16px;
}
.glass-card {
height: auto;
min-height: 70px;
padding: 14px 16px;
align-items: flex-start;
}
.info-container {
margin-top: 12px;
}
}
@media (max-width: 820px) {
.analyze-container {
padding: 12px;
}
.stat-group {
justify-content: space-between;
}
.stat-box .value {
font-size: 20px;
}
.sparkline-container {
display: none;
}
:deep(.pure-form .el-form-item) {
width: 100%;
margin-right: 0;
}
:deep(.pure-form .el-form-item__content) {
width: 100%;
}
:deep(.pure-form .el-date-editor.el-input),
:deep(.pure-form .el-button) {
width: 100% !important;
}
:deep(.kline-dialog .el-dialog) {
border-radius: 14px;
}
}
</style>