From 697563047160092e183eab27223ce8953ae39667 Mon Sep 17 00:00:00 2001 From: yangfan <784310601@qq.com> Date: Thu, 30 Apr 2026 17:12:47 +0800 Subject: [PATCH] =?UTF-8?q?=E9=A1=B5=E9=9D=A2=E5=8A=9F=E8=83=BD=E4=BC=98?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.vue | 19 +++ src/style.css | 9 +- src/views/stock/LimitBoard.vue | 270 +++++++++++++++++++++++++++++++-- 3 files changed, 279 insertions(+), 19 deletions(-) diff --git a/src/App.vue b/src/App.vue index f9019ee..d2acd5f 100644 --- a/src/App.vue +++ b/src/App.vue @@ -87,7 +87,11 @@ const navigateTo = (path) => { } .sidebar { + position: fixed; + inset: 0 auto 0 0; + z-index: 20; width: 236px; + height: 100vh; flex-shrink: 0; display: flex; flex-direction: column; @@ -95,6 +99,7 @@ const navigateTo = (path) => { box-sizing: border-box; background: #ffffff; border-right: 1px solid #e5edf5; + overflow-y: auto; transition: width 0.25s ease, padding 0.25s ease; } @@ -169,6 +174,12 @@ const navigateTo = (path) => { .page-main { flex: 1; min-width: 0; + margin-left: 236px; + transition: margin-left 0.25s ease; +} + +.sidebar.collapsed + .page-main { + margin-left: 76px; } :deep(.nav-menu .el-menu-item) { @@ -209,6 +220,14 @@ const navigateTo = (path) => { padding-inline: 10px; } + .page-main { + margin-left: 88px; + } + + .sidebar.collapsed + .page-main { + margin-left: 76px; + } + .brand-block { display: none; } diff --git a/src/style.css b/src/style.css index 527d4fb..f442dc3 100644 --- a/src/style.css +++ b/src/style.css @@ -157,11 +157,10 @@ code { } #app { - width: 1126px; - max-width: 100%; - margin: 0 auto; - text-align: center; - border-inline: 1px solid var(--border); + width: 100%; + min-width: 0; + margin: 0; + text-align: initial; min-height: 100svh; display: flex; flex-direction: column; diff --git a/src/views/stock/LimitBoard.vue b/src/views/stock/LimitBoard.vue index 38021e7..a31f635 100644 --- a/src/views/stock/LimitBoard.vue +++ b/src/views/stock/LimitBoard.vue @@ -67,13 +67,16 @@

涨停分组明细 {{ detailTradeDateLabel }}

-

按连板高度从高到低展示当日涨停股票。

+

{{ detailModeDescription }}

当日无涨停数据。

+ + {{ detailModeButtonText }} +
-
-
+
+
{{ group.label }} @@ -104,6 +107,10 @@ {{ formatPct(stock.pct_chg) }}
+
+ 首次涨停 + {{ formatLimitTime(stock.ak_first_limit_time) }} +
@@ -157,6 +164,7 @@ const overview = ref({ const strengthSeries = ref([]) const detailTradeDate = ref('') const detailGroups = ref([]) +const detailGroupMode = ref('board') const dialogVisible = ref(false) const dialogTitle = ref('') @@ -168,6 +176,61 @@ const isPhoneViewport = computed(() => viewportWidth.value <= PHONE_BREAKPOINT) const isIpadMiniViewport = computed(() => viewportWidth.value <= IPAD_MINI_BREAKPOINT) const isTabletViewport = computed(() => viewportWidth.value <= TABLET_BREAKPOINT) const detailTradeDateLabel = computed(() => detailTradeDate.value || overview.value.trade_date || '--') +const allDetailStocks = computed(() => detailGroups.value.flatMap((group) => group.stocks || [])) +const sectorDetailGroups = computed(() => { + const groupMap = new Map() + + allDetailStocks.value.forEach((stock) => { + const sectorName = stock.ak_industry || stock.market || '未分类' + if (!groupMap.has(sectorName)) { + groupMap.set(sectorName, { + key: `sector-${sectorName}`, + label: sectorName, + count: 0, + stocks: [] + }) + } + + const sectorGroup = groupMap.get(sectorName) + sectorGroup.count += 1 + sectorGroup.stocks.push(stock) + }) + + return Array.from(groupMap.values()) + .map((group) => ({ + ...group, + maxBoardCount: Math.max(...group.stocks.map((stock) => Number(stock.board_count || 0)), 0), + stocks: group.stocks.sort((left, right) => { + const timeDiff = getLimitTimeOrder(left.ak_first_limit_time) - getLimitTimeOrder(right.ak_first_limit_time) + if (timeDiff !== 0) return timeDiff + const boardDiff = Number(right.board_count || 0) - Number(left.board_count || 0) + if (boardDiff !== 0) return boardDiff + const pctDiff = Number(right.pct_chg || 0) - Number(left.pct_chg || 0) + if (pctDiff !== 0) return pctDiff + return String(left.ts_code || left.symbol || '').localeCompare(String(right.ts_code || right.symbol || '')) + }) + })) + .sort((left, right) => { + if (right.count !== left.count) return right.count - left.count + if (right.maxBoardCount !== left.maxBoardCount) return right.maxBoardCount - left.maxBoardCount + return left.label.localeCompare(right.label, 'zh-Hans-CN') + }) +}) +const displayDetailGroups = computed(() => { + if (detailGroupMode.value === 'sector') return sectorDetailGroups.value + return detailGroups.value.map((group) => ({ + ...group, + key: `board-${group.board_count ?? group.label}` + })) +}) +const detailModeDescription = computed(() => ( + detailGroupMode.value === 'sector' + ? '按所属行业或板块汇总展示当日涨停股票。' + : '按连板高度从高到低展示当日涨停股票。' +)) +const detailModeButtonText = computed(() => ( + detailGroupMode.value === 'sector' ? '切换为按板数' : '切换为按板块' +)) const dialogWidth = computed(() => { if (isPhoneViewport.value) return '96%' if (isIpadMiniViewport.value) return '94%' @@ -289,6 +352,61 @@ const formatPct = (value) => { return `${amount > 0 ? '+' : ''}${amount.toFixed(2)}%` } +const getLimitTimeOrder = (value) => { + const matched = parseLimitTime(value) + if (!matched) return Number.POSITIVE_INFINITY + const { hour, minute, second = '0' } = matched + return Number(hour) * 3600 + Number(minute) * 60 + Number(second) +} + +const formatLimitTime = (value) => { + const matched = parseLimitTime(value) + if (!matched) return '--' + const { hour, minute, second } = matched + return `${hour.padStart(2, '0')}:${minute}${second ? `:${second}` : ''}` +} + +const parseLimitTime = (value) => { + if (value === null || value === undefined || value === '') return null + + const rawValue = String(value).trim() + const colonMatched = rawValue.match(/(\d{1,2}):(\d{2})(?::(\d{2}))?/) + if (colonMatched) { + const [, hour, minute, second] = colonMatched + return { hour, minute, second } + } + + const digits = rawValue.replace(/\D/g, '') + if (!digits) return null + + if (digits.length <= 4) { + const normalized = digits.padStart(4, '0') + return { + hour: normalized.slice(0, 2), + minute: normalized.slice(2, 4), + second: '' + } + } + + const normalized = digits.padStart(6, '0').slice(0, 6) + return { + hour: normalized.slice(0, 2), + minute: normalized.slice(2, 4), + second: normalized.slice(4, 6) + } +} + +const formatVolume = (value) => { + const amount = Number(value ?? 0) + if (amount >= 100000000) return `${(amount / 100000000).toFixed(2)}亿` + if (amount >= 10000) return `${(amount / 10000).toFixed(2)}万` + return amount.toLocaleString('zh-CN') +} + +const toggleDetailMode = () => { + detailGroupMode.value = detailGroupMode.value === 'sector' ? 'board' : 'sector' +} + const disposeStrengthChart = () => { if (!strengthChartInstance) return const chartDom = strengthChartInstance.getDom() @@ -410,6 +528,17 @@ const openKlineDialog = async (stock) => { try { const rawData = await getKlineData(stock.ts_code || stock.symbol) + const klineData = Array.isArray(rawData) + ? rawData.map((item) => ({ + trade_date: item.trade_date, + open: item.open, + close: item.close, + low: item.low, + high: item.high, + pct_chg: item.pct_chg, + vol: item.vol + })) + : [] await nextTick() const chartDom = document.getElementById('limit-board-kline-chart') @@ -418,14 +547,20 @@ const openKlineDialog = async (stock) => { disposeChart() chartInstance = echarts.init(chartDom) - const dates = rawData.map((item) => dayjs(item.trade_date).format('YYYY-MM-DD')) - const values = rawData.map((item) => [ + const dates = klineData.map((item) => dayjs(item.trade_date).format('YYYY-MM-DD')) + const values = klineData.map((item) => [ item.open, item.close, item.low, item.high, item.pct_chg ]) + const volumes = klineData.map((item) => ({ + value: Number(item.vol ?? 0), + itemStyle: { + color: Number(item.pct_chg || 0) >= 0 ? '#e85d75' : '#45b37b' + } + })) chartInstance.setOption({ animation: false, @@ -433,8 +568,8 @@ const openKlineDialog = async (stock) => { trigger: 'axis', axisPointer: { type: 'cross' }, formatter(params) { - const point = params[0] - const currentItem = rawData[point.dataIndex] || {} + const point = params.find((item) => item.seriesType === 'candlestick') || params[0] + const currentItem = klineData[point.dataIndex] || {} const pct = Number(currentItem.pct_chg || 0) const color = pct >= 0 ? '#d9485f' : '#2f9e62' return ` @@ -444,21 +579,59 @@ const openKlineDialog = async (stock) => { 开盘价: ${formatPrice(currentItem.open)}
收盘价: ${formatPrice(currentItem.close)}
最低价: ${formatPrice(currentItem.low)}
- 最高价: ${formatPrice(currentItem.high)} + 最高价: ${formatPrice(currentItem.high)}
+ 成交量: ${formatVolume(currentItem.vol)}
` } }, - grid: { left: 56, right: 28, top: 42, bottom: '12%' }, - xAxis: { type: 'category', data: dates, scale: true, boundaryGap: false }, - yAxis: { scale: true, splitArea: { show: true } }, + axisPointer: { + link: [{ xAxisIndex: 'all' }] + }, + grid: [ + { left: 56, right: 28, top: 42, height: '58%' }, + { left: 56, right: 28, top: '72%', height: '14%' } + ], + xAxis: [ + { type: 'category', data: dates, scale: true, boundaryGap: false, axisLine: { onZero: false } }, + { + type: 'category', + gridIndex: 1, + data: dates, + scale: true, + boundaryGap: false, + axisLine: { onZero: false }, + axisLabel: { show: false }, + axisTick: { show: false } + } + ], + yAxis: [ + { scale: true, splitArea: { show: true } }, + { + scale: true, + gridIndex: 1, + splitNumber: 2, + axisLabel: { + formatter(value) { + return formatVolume(value) + } + }, + splitLine: { + lineStyle: { + color: '#eef2f7' + } + } + } + ], dataZoom: [ - { type: 'inside', start: 80, end: 100 }, - { show: true, type: 'slider', top: '92%' } + { type: 'inside', xAxisIndex: [0, 1], start: 80, end: 100 }, + { show: true, type: 'slider', xAxisIndex: [0, 1], top: '90%' } ], series: [ { name: '日K', type: 'candlestick', + xAxisIndex: 0, + yAxisIndex: 0, data: values, itemStyle: { color: '#e85d75', @@ -466,6 +639,14 @@ const openKlineDialog = async (stock) => { borderColor: '#e85d75', borderColor0: '#45b37b' } + }, + { + name: '成交量', + type: 'bar', + xAxisIndex: 1, + yAxisIndex: 1, + barMaxWidth: 12, + data: volumes } ] }) @@ -493,8 +674,12 @@ const openKlineDialog = async (stock) => { } .toolbar-card { + position: sticky; + top: 0; + z-index: 12; padding: 22px; margin-bottom: 18px; + border: 1px solid rgba(226, 235, 247, 0.9); } .toolbar-main { @@ -584,22 +769,52 @@ const openKlineDialog = async (stock) => { margin-bottom: 18px; } +.groups-card { + display: flex; + flex-direction: column; + max-height: calc(100vh - 40px); +} + .section-head { display: flex; justify-content: space-between; align-items: flex-start; + gap: 16px; margin-bottom: 16px; } +.detail-mode-button { + flex-shrink: 0; +} + .strength-chart { width: 100%; height: 280px; } .group-list { + flex: 1 1 auto; display: flex; flex-direction: column; gap: 16px; + min-height: 0; + overflow-y: auto; + padding-right: 6px; + scrollbar-gutter: stable; +} + +.group-list::-webkit-scrollbar { + width: 8px; +} + +.group-list::-webkit-scrollbar-thumb { + background: #c9d7e8; + border-radius: 999px; +} + +.group-list::-webkit-scrollbar-track { + background: #edf3fa; + border-radius: 999px; } .group-panel { @@ -702,7 +917,8 @@ const openKlineDialog = async (stock) => { } .stock-meta, -.stock-metrics { +.stock-metrics, +.stock-limit-time { color: #607289; font-size: 13px; } @@ -711,6 +927,31 @@ const openKlineDialog = async (stock) => { margin-bottom: 8px; } +.stock-metrics { + margin-bottom: 10px; +} + +.stock-limit-time { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 8px 10px; + border-radius: 10px; + background: #f6f9fd; + border: 1px solid #e7eef7; +} + +.stock-limit-time span { + color: #7a8aa1; +} + +.stock-limit-time strong { + color: #22456f; + font-size: 14px; + font-weight: 700; +} + .pct.up { color: #d9485f; } @@ -865,6 +1106,7 @@ const openKlineDialog = async (stock) => { .toolbar-main, .toolbar-actions, + .section-head, .group-head { flex-direction: column; align-items: stretch;