提交
This commit is contained in:
253
frontend/src/components/LimitBoard.vue
Normal file
253
frontend/src/components/LimitBoard.vue
Normal file
@@ -0,0 +1,253 @@
|
||||
<script setup lang="ts">
|
||||
// 首页打板专题(同花顺口径):涨跌停三池 + 连板天梯 + 涨停最强板块。
|
||||
// 数据层整包 SWR(盘中 5 分钟 / 盘后 4 小时),进页面拉一次 + 手动刷新即可(同 MarketOverview 约定)。
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { getLimitBoard } from '@/api/client';
|
||||
import type { LimitBoard, LimitStock } from '@/api/types';
|
||||
|
||||
const router = useRouter();
|
||||
const board = ref<LimitBoard | null>(null);
|
||||
const loading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
|
||||
async function load() {
|
||||
if (loading.value) return;
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
board.value = await getLimitBoard();
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : String(e);
|
||||
} finally {
|
||||
loading.value = false; // 组件卸载后写 ref 无害(Vue3 no-op)
|
||||
}
|
||||
}
|
||||
onMounted(load);
|
||||
|
||||
const updatedAt = computed(() =>
|
||||
board.value ? new Date(board.value.updated_at).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }) : '',
|
||||
);
|
||||
|
||||
// ---------- 池子 tab ----------
|
||||
const POOLS = [
|
||||
{ key: 'up', label: '涨停池' },
|
||||
{ key: 'broken', label: '炸板池' },
|
||||
{ key: 'down', label: '跌停池' },
|
||||
] as const;
|
||||
const poolKey = ref<(typeof POOLS)[number]['key']>('up');
|
||||
const pool = computed<LimitStock[]>(() => (board.value ? board.value[poolKey.value] : []));
|
||||
|
||||
// ---------- 连板天梯 ----------
|
||||
// 分布条含 1 板(= 首板数)补齐直觉;最高板高亮
|
||||
const ladderBars = computed(() => {
|
||||
const s = board.value?.summary;
|
||||
if (!s) return [];
|
||||
const rows = [...s.ladder_dist.map((d) => ({ ...d }))];
|
||||
if (s.first_board_count > 0) rows.unshift({ nums: 1, count: s.first_board_count });
|
||||
const max = Math.max(1, ...rows.map((r) => r.count));
|
||||
return rows.map((r) => ({ ...r, pct: (r.count / max) * 100 }));
|
||||
});
|
||||
|
||||
function openStock(tsCode: string) {
|
||||
// StocksView 支持 ?code= 直接打开个股详情浮层
|
||||
void router.push({ path: '/stocks', query: { code: tsCode } });
|
||||
}
|
||||
|
||||
// ---------- 格式化 ----------
|
||||
const fmtNum = (v: number | null | undefined, d = 2) => (v == null ? '—' : v.toFixed(d));
|
||||
const fmtInt = (v: number | null | undefined) => (v == null ? '—' : Math.round(v).toLocaleString('zh-CN'));
|
||||
const pctClass = (v: number | null | undefined) => (v == null ? '' : v > 0 ? 'text-up' : v < 0 ? 'text-down' : '');
|
||||
const fmtTime = (s: string | null | undefined) => (s && s.length >= 5 ? s.slice(0, 5) : s ?? '—'); // HHMM -> HH:MM 显示对齐
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="mb-12" aria-label="打板专题">
|
||||
<!-- 头部(对齐 MarketOverview) -->
|
||||
<div class="mb-3 flex items-baseline justify-between">
|
||||
<h2 class="text-sm font-medium text-[#A8AFB8]">打板专题
|
||||
<span class="ml-2 text-xs font-normal text-[#6B7280]">同花顺口径 · 涨跌停池 / 连板天梯 / 最强板块</span>
|
||||
</h2>
|
||||
<div class="flex items-center gap-3 text-xs text-[#6B7280]">
|
||||
<span v-if="board" class="font-mono tabular-nums">{{ board.trade_date }}</span>
|
||||
<span v-if="updatedAt">更新于 {{ updatedAt }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded p-1 transition-colors hover:bg-[#26272E] hover:text-[#A8AFB8] focus-visible:ring-2 focus-visible:ring-blue-500"
|
||||
:disabled="loading"
|
||||
title="刷新打板数据"
|
||||
@click="load"
|
||||
>
|
||||
<svg class="h-4 w-4" :class="loading && 'animate-spin'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 12a9 9 0 11-2.64-6.36" /><path d="M21 3v6h-6" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 首载骨架 -->
|
||||
<div v-if="!board && loading" class="h-40 animate-pulse rounded-lg border border-[#26272E] bg-[#101014]"></div>
|
||||
<!-- 错误(不阻塞整页) -->
|
||||
<div v-else-if="error && !board" class="rounded-lg border border-[#26272E] bg-[#101014] px-4 py-3 text-sm text-[#A8AFB8]">
|
||||
{{ error }}
|
||||
<button class="ml-1 text-blue-500 hover:underline" @click="load">重试</button>
|
||||
</div>
|
||||
|
||||
<template v-else-if="board">
|
||||
<!-- 摘要统计条 -->
|
||||
<div class="flex flex-wrap items-center gap-x-8 gap-y-2 rounded-lg border border-[#26272E] bg-[#101014] px-4 py-3 text-sm">
|
||||
<span class="text-[#A8AFB8]">涨停 <span class="font-mono text-base font-semibold text-up">{{ board.summary.up_count }}</span></span>
|
||||
<span class="text-[#A8AFB8]">炸板 <span class="font-mono text-base font-semibold text-[#E5E7EB]">{{ board.summary.broken_count }}</span></span>
|
||||
<span class="text-[#A8AFB8]">跌停 <span class="font-mono text-base font-semibold text-down">{{ board.summary.down_count }}</span></span>
|
||||
<span class="text-[#A8AFB8]">首板 <span class="font-mono text-[#E5E7EB]">{{ board.summary.first_board_count }}</span></span>
|
||||
<span v-if="board.summary.max_ladder" class="text-[#A8AFB8]">
|
||||
最高板
|
||||
<button class="ml-1 font-mono text-base font-semibold text-up hover:underline" @click="openStock(board.summary.max_ladder!.ts_code)">
|
||||
{{ board.summary.max_ladder.nums }}板 {{ board.summary.max_ladder.name }}
|
||||
</button>
|
||||
</span>
|
||||
<span v-if="board.errors.length" class="text-[10px] text-[#6B7280]" :title="board.errors.join(';')">部分数据源失败</span>
|
||||
</div>
|
||||
|
||||
<!-- 主体两栏:左池子表格 / 右天梯+板块 -->
|
||||
<div class="mt-3 grid gap-3 lg:grid-cols-3">
|
||||
<!-- 左:池子 -->
|
||||
<div class="rounded-lg border border-[#26272E] bg-[#101014] px-4 py-3 lg:col-span-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex gap-1">
|
||||
<button
|
||||
v-for="p in POOLS"
|
||||
:key="p.key"
|
||||
type="button"
|
||||
class="rounded border px-2 py-0.5 text-xs transition-colors"
|
||||
:class="poolKey === p.key
|
||||
? 'border-blue-500 bg-blue-500/15 text-blue-300'
|
||||
: 'border-[#33353D] text-[#A8AFB8] hover:border-[#3A3D46] hover:text-[#E5E7EB]'"
|
||||
@click="poolKey = p.key"
|
||||
>{{ p.label }}{{ board[p.key].length ? ` ${board[p.key].length}` : '' }}</button>
|
||||
</div>
|
||||
<span class="text-[10px] text-[#6B7280]">点击行看个股</span>
|
||||
</div>
|
||||
|
||||
<!-- 涨停池 -->
|
||||
<table v-if="poolKey === 'up'" class="mt-2 w-full table-fixed font-mono text-xs leading-4">
|
||||
<thead>
|
||||
<tr class="text-[#6B7280]">
|
||||
<th class="w-[26%] py-1 text-left font-normal">名称</th>
|
||||
<th class="w-[14%] text-left font-normal">标签</th>
|
||||
<th class="w-[12%] text-left font-normal">状态</th>
|
||||
<th class="text-left font-normal">涨停原因</th>
|
||||
<th class="w-[10%] text-right font-normal">封单亿</th>
|
||||
<th class="w-[10%] text-right font-normal">开板</th>
|
||||
<th class="w-[10%] text-right font-normal">成交亿</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="max-h-80 overflow-y-auto">
|
||||
<tr
|
||||
v-for="r in pool" :key="r.ts_code"
|
||||
class="cursor-pointer border-t border-[#1E2026]/60 hover:bg-[#1E2026]"
|
||||
:title="`${r.ts_code}|首次涨停 ${r.first_lu_time ?? '—'}|封板率 ${r.limit_up_suc_rate == null ? '—' : r.limit_up_suc_rate + '%'}`"
|
||||
@click="openStock(r.ts_code)"
|
||||
>
|
||||
<td class="break-words py-1 font-sans text-[#E5E7EB]">{{ r.name ?? r.ts_code }}</td>
|
||||
<td class="break-words py-1 text-[#C3C9D2]">{{ r.tag ?? '—' }}</td>
|
||||
<td class="break-words py-1 text-[#A8AFB8]">{{ r.status ?? '—' }}</td>
|
||||
<td class="break-words py-1 pr-1 font-sans text-[#A8AFB8]">{{ r.lu_desc ?? '—' }}</td>
|
||||
<td class="py-1 text-right text-up">{{ r.limit_amount_yi == null ? '—' : r.limit_amount_yi.toFixed(2) }}</td>
|
||||
<td class="py-1 text-right text-[#C3C9D2]">{{ r.open_num == null || r.open_num === 0 ? '—' : r.open_num }}</td>
|
||||
<td class="py-1 text-right text-[#C3C9D2]">{{ r.turnover_yi == null ? '—' : r.turnover_yi.toFixed(1) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- 炸板池 -->
|
||||
<table v-else-if="poolKey === 'broken'" class="mt-2 w-full table-fixed font-mono text-xs leading-4">
|
||||
<thead>
|
||||
<tr class="text-[#6B7280]">
|
||||
<th class="w-[30%] py-1 text-left font-normal">名称</th>
|
||||
<th class="w-[12%] text-right font-normal">价</th>
|
||||
<th class="w-[12%] text-right font-normal">涨幅%</th>
|
||||
<th class="w-[12%] text-right font-normal">开板次数</th>
|
||||
<th class="text-right font-normal">首停</th>
|
||||
<th class="text-right font-normal">末停</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="max-h-80 overflow-y-auto">
|
||||
<tr
|
||||
v-for="r in pool" :key="r.ts_code"
|
||||
class="cursor-pointer border-t border-[#1E2026]/60 hover:bg-[#1E2026]"
|
||||
:title="r.ts_code"
|
||||
@click="openStock(r.ts_code)"
|
||||
>
|
||||
<td class="break-words py-1 font-sans text-[#E5E7EB]">{{ r.name ?? r.ts_code }}</td>
|
||||
<td class="py-1 text-right text-[#C3C9D2]">{{ fmtNum(r.price) }}</td>
|
||||
<td class="py-1 text-right" :class="pctClass(r.pct_chg)">{{ fmtNum(r.pct_chg) }}</td>
|
||||
<td class="py-1 text-right text-[#C3C9D2]">{{ fmtInt(r.open_num) }}</td>
|
||||
<td class="py-1 text-right text-[#A8AFB8]">{{ fmtTime(r.first_lu_time) }}</td>
|
||||
<td class="py-1 text-right text-[#A8AFB8]">{{ fmtTime(r.last_lu_time) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- 跌停池 -->
|
||||
<table v-else class="mt-2 w-full table-fixed font-mono text-xs leading-4">
|
||||
<thead>
|
||||
<tr class="text-[#6B7280]">
|
||||
<th class="py-1 text-left font-normal">名称</th>
|
||||
<th class="w-[16%] text-right font-normal">价</th>
|
||||
<th class="w-[16%] text-right font-normal">跌幅%</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="max-h-80 overflow-y-auto">
|
||||
<tr
|
||||
v-for="r in pool" :key="r.ts_code"
|
||||
class="cursor-pointer border-t border-[#1E2026]/60 hover:bg-[#1E2026]"
|
||||
:title="r.ts_code"
|
||||
@click="openStock(r.ts_code)"
|
||||
>
|
||||
<td class="break-words py-1 font-sans text-[#E5E7EB]">{{ r.name ?? r.ts_code }}</td>
|
||||
<td class="py-1 text-right text-[#C3C9D2]">{{ fmtNum(r.price) }}</td>
|
||||
<td class="py-1 text-right" :class="pctClass(r.pct_chg)">{{ fmtNum(r.pct_chg) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-if="!pool.length" class="py-6 text-center text-xs text-[#6B7280]">暂无数据</div>
|
||||
</div>
|
||||
|
||||
<!-- 右:连板天梯 + 最强板块 -->
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="rounded-lg border border-[#26272E] bg-[#101014] px-4 py-3">
|
||||
<div class="mb-2 text-xs text-[#6B7280]">连板天梯</div>
|
||||
<div v-if="ladderBars.length" class="space-y-1.5">
|
||||
<div v-for="b in ladderBars" :key="b.nums" class="flex items-center gap-2">
|
||||
<span class="w-8 shrink-0 font-mono text-xs text-[#A8AFB8]">{{ b.nums }}板</span>
|
||||
<div class="h-3.5 min-w-0 flex-1 overflow-hidden rounded-sm bg-[#1E2026]">
|
||||
<div class="h-full rounded-sm bg-up/70" :style="{ width: b.pct + '%' }"></div>
|
||||
</div>
|
||||
<span class="w-6 shrink-0 text-right font-mono text-xs text-[#E5E7EB]">{{ b.count }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-xs text-[#6B7280]">今日无 2 连板以上</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-[#26272E] bg-[#101014] px-4 py-3">
|
||||
<div class="mb-2 text-xs text-[#6B7280]">涨停最强板块</div>
|
||||
<div v-if="board.blocks.length" class="space-y-1">
|
||||
<div v-for="(b, i) in board.blocks" :key="i" class="flex items-baseline justify-between gap-2 text-xs leading-5">
|
||||
<span class="flex min-w-0 items-baseline gap-1.5">
|
||||
<span class="w-4 shrink-0 text-right font-mono text-[10px] text-[#6B7280]">{{ i + 1 }}</span>
|
||||
<span class="truncate font-sans text-[#E5E7EB]" :title="`${b.name}|${b.up_stat ?? ''}`">{{ b.name }}</span>
|
||||
</span>
|
||||
<span class="flex shrink-0 items-baseline gap-1.5 font-mono">
|
||||
<span class="text-[#A8AFB8]" title="涨停家数">{{ b.up_nums }}板</span>
|
||||
<span :class="pctClass(b.pct_chg)">{{ fmtNum(b.pct_chg, 1) }}%</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-xs text-[#6B7280]">暂无数据</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,8 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import {
|
||||
addWatchlist, clearTrades, getStockDividends, getStockPreview, getTrades, getWatchlist as getWatchlistApi,
|
||||
importTrades, removeWatchlist,
|
||||
addHolding, addWatchlist, clearTrades, getHoldings as getHoldingsApi, getStockDividends, getStockPreview,
|
||||
getTrades, getWatchlist as getWatchlistApi, importTrades, removeHolding, removeWatchlist,
|
||||
} from '@/api/client';
|
||||
import type {
|
||||
ChartLayoutPrefs, PreviewResponse, ScreenerItemOut, StockDividendRecord, StockFinanceRecord,
|
||||
@@ -21,6 +21,7 @@ const props = defineProps<{
|
||||
const emit = defineEmits<{
|
||||
(e: 'close'): void;
|
||||
(e: 'watched-change'): void;
|
||||
(e: 'held-change'): void;
|
||||
/** 浮层内切股(键盘 ↑/↓、侧栏点击)时上报当前 ts_code,父组件据此同步路由 */
|
||||
(e: 'change', code: string): void;
|
||||
}>();
|
||||
@@ -215,6 +216,31 @@ async function toggleWatch() {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 持仓股(标记进「持仓」分类) ----------
|
||||
const held = ref(false);
|
||||
const heldBusy = ref(false);
|
||||
const heldSet = ref<Set<string>>(new Set());
|
||||
async function refreshHeld() {
|
||||
try {
|
||||
heldSet.value = new Set(await getHoldingsApi());
|
||||
} catch { /* 未登录等场景忽略 */ }
|
||||
held.value = heldSet.value.has(active.value);
|
||||
}
|
||||
async function toggleHeld() {
|
||||
if (heldBusy.value) return;
|
||||
heldBusy.value = true;
|
||||
try {
|
||||
const list = held.value
|
||||
? await removeHolding(active.value)
|
||||
: await addHolding(active.value);
|
||||
heldSet.value = new Set(list);
|
||||
held.value = heldSet.value.has(active.value);
|
||||
emit('held-change');
|
||||
} catch { /* 忽略 */ } finally {
|
||||
heldBusy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 实盘交易点(交割单导入) ----------
|
||||
const trades = ref<UserTrade[]>([]);
|
||||
const showTrades = ref(true);
|
||||
@@ -504,7 +530,12 @@ watch(timeframe, () => load(active.value));
|
||||
watch(active, (code) => {
|
||||
watched.value = watchedSet.value.has(code);
|
||||
}, { immediate: true });
|
||||
// 切股时同步持仓状态
|
||||
watch(active, (code) => {
|
||||
held.value = heldSet.value.has(code);
|
||||
}, { immediate: true });
|
||||
void refreshWatched();
|
||||
void refreshHeld();
|
||||
|
||||
function moveActive(delta: number) {
|
||||
const list = filteredItems.value;
|
||||
@@ -584,6 +615,19 @@ const fmtListDate = (s?: string | null) => (s && s.length === 8 ? `${s.slice(0,
|
||||
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
|
||||
</svg>
|
||||
</button>
|
||||
<!-- 持仓标记 -->
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 rounded p-1 transition-colors hover:bg-[#26272E] hover:text-white disabled:opacity-50"
|
||||
:class="held ? 'text-emerald-500' : 'text-[#C3C9D2]'"
|
||||
:title="held ? '移出持仓' : '加入持仓'"
|
||||
:disabled="heldBusy"
|
||||
@click="toggleHeld"
|
||||
>
|
||||
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="2" y="7" width="20" height="14" rx="2" ry="2" /><path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16" />
|
||||
</svg>
|
||||
</button>
|
||||
<div class="flex items-baseline gap-2">
|
||||
<span class="text-base font-semibold text-[#E8EAED]">{{ header.name }}</span>
|
||||
<span class="text-[13px] text-[#9BA3AE]">{{ active }}</span>
|
||||
|
||||
Reference in New Issue
Block a user