This commit is contained in:
2026-09-09 15:07:58 +08:00
parent d656c05b3d
commit 71a0f6e404
31 changed files with 3657 additions and 9 deletions

View File

@@ -0,0 +1,363 @@
<script setup lang="ts">
// 概念板块页(同花顺口径):左板块列表(类型过滤/搜索/排序)→ 右成分股行情表
// → 点成分股打开个股详情浮层,浮层左列表 = 该板块全部成分(板块内 ↑/↓ 切换研究)。
// 路由 ?type=&board=&code=type 过滤 / board 选中板块 / code 浮层开的股)。
import { computed, onMounted, ref } from 'vue';
import { useRoute } from 'vue-router';
import { getThsBoards, getThsBoardMembers } from '@/api/client';
import type { ScreenerItemOut, ThsBoard, ThsMember } from '@/api/types';
import { debouncedRef } from '@/composables/debouncedRef';
import { useQuerySync } from '@/composables/useQuerySync';
import StockDetailOverlay from '@/components/StockDetailOverlay.vue';
const route = useRoute();
const qStr = (k: string) => (typeof route.query[k] === 'string' ? (route.query[k] as string) : '');
// ---------- 板块列表 ----------
const TYPE_META: { key: string; label: string }[] = [
{ key: '', label: '全部' },
{ key: 'N', label: '概念' },
{ key: 'I', label: '行业' },
{ key: 'TH', label: '主题' },
{ key: 'S', label: '特色' },
{ key: 'R', label: '地域' },
{ key: 'BB', label: '宽基' },
{ key: 'ST', label: '风格' },
];
const SORTS: { key: 'pct_change' | 'turnover_rate' | 'vol' | 'count'; label: string }[] = [
{ key: 'pct_change', label: '涨跌幅' },
{ key: 'turnover_rate', label: '换手率' },
{ key: 'vol', label: '成交量' },
{ key: 'count', label: '成分数' },
];
const typeFilter = ref(qStr('type'));
// 防抖1732 板块 / 5555 成分每次击键全量 filter+sort 太重200ms 合并
const search = debouncedRef('', 200);
const sortKey = ref<'pct_change' | 'turnover_rate' | 'vol' | 'count'>('pct_change');
const boards = ref<ThsBoard[] | null>(null);
const loading = ref(false);
const error = ref<string | null>(null);
const tradeDate = ref('');
const updatedAt = ref('');
async function load() {
if (loading.value) return;
loading.value = true;
error.value = null;
try {
const res = await getThsBoards();
boards.value = res.boards;
tradeDate.value = res.trade_date ?? '';
updatedAt.value = res.updated_at
? new Date(res.updated_at).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
: '';
} catch (e) {
error.value = e instanceof Error ? e.message : String(e);
} finally {
loading.value = false;
}
}
onMounted(() => {
void load();
if (boardCode.value) void loadMembers(boardCode.value);
});
const LIST_CAP = 200; // 1732 个板块全渲染过重;过滤/搜索收敛后截断展示
const filteredBoards = computed<ThsBoard[]>(() => {
const all = boards.value ?? [];
const t = typeFilter.value;
const q = search.value.trim().toLowerCase();
const rows = all.filter(
(b) => (!t || b.type === t) && (!q || (b.name ?? '').toLowerCase().includes(q) || b.ts_code.toLowerCase().includes(q)),
);
const k = sortKey.value;
rows.sort((a, b) => (b[k] ?? -Infinity) - (a[k] ?? -Infinity)); // 缺值沉底
return rows;
});
const shownBoards = computed(() => filteredBoards.value.slice(0, LIST_CAP));
// ---------- 成分 ----------
const boardCode = ref<string | null>(qStr('board') || null);
const boardName = ref<string | null>(null);
const members = ref<ThsMember[]>([]);
const membersLoading = ref(false);
const membersError = ref<string | null>(null);
const memberSearch = debouncedRef('', 200);
let membersToken = 0;
let membersCtrl: AbortController | null = null;
async function loadMembers(code: string) {
const token = ++membersToken;
membersCtrl?.abort(); // 快速切换板块时取消在途请求(成分可达 5555 只,较重)
const ctrl = new AbortController();
membersCtrl = ctrl;
membersLoading.value = true;
membersError.value = null;
try {
const res = await getThsBoardMembers(code, { signal: ctrl.signal });
if (token !== membersToken) return;
boardName.value = res.name ?? null;
members.value = res.members;
} catch (e) {
if (token === membersToken && !(e instanceof Error && e.name === 'AbortError')) {
membersError.value = e instanceof Error ? e.message : String(e);
members.value = [];
}
} finally {
if (token === membersToken) membersLoading.value = false;
}
}
function selectBoard(code: string) {
if (boardCode.value === code) return;
boardCode.value = code;
memberSearch.value = '';
previewCode.value = null; // 换板块关掉旧浮层items 已不属于新板块)
void loadMembers(code);
syncRoute(true);
}
const selectedBoard = computed(() => (boards.value ?? []).find((b) => b.ts_code === boardCode.value) ?? null);
const shownMembers = computed(() => {
const q = memberSearch.value.trim().toLowerCase();
const rows = q
? members.value.filter((m) => (m.con_name ?? '').toLowerCase().includes(q) || m.con_code.toLowerCase().includes(q))
: [...members.value];
rows.sort((a, b) => (b.pct_chg ?? -Infinity) - (a.pct_chg ?? -Infinity));
return rows;
});
// 渲染截断:宽基板块 5555 只成分全进 DOM 会卡(同板块列表 LIST_CAP 思路),搜索可收敛
const MEMBER_CAP = 400;
const renderedMembers = computed(() => shownMembers.value.slice(0, MEMBER_CAP));
// ---------- 个股详情浮层items = 该板块全部成分,板块内 ↑/↓ 切换) ----------
const previewCode = ref<string | null>(qStr('code') || null);
const overlayItems = computed<ScreenerItemOut[]>(() =>
members.value.map((m) => ({
ts_code: m.con_code,
name: m.con_name ?? m.con_code,
close: m.close ?? null,
pct_chg: m.pct_chg ?? null,
total_mv: null,
circ_mv: null,
pe_ttm: null,
pb: null,
turnover_rate: null,
indicators: {},
})),
);
function openStock(code: string) {
previewCode.value = code;
syncRoute(true); // push浏览器返回键 = 关闭浮层
}
function onOverlayChange(code: string) {
previewCode.value = code;
syncRoute();
}
function closeOverlay() {
previewCode.value = null;
syncRoute();
}
// ---------- 路由同步(?type=&board=&code=;骨架在 useQuerySyncselfNav 防回声) ----------
const { syncRoute } = useQuerySync({
sources: [typeFilter],
buildQuery: () => {
const q: Record<string, string> = {};
if (typeFilter.value) q.type = typeFilter.value;
if (boardCode.value) q.board = boardCode.value;
if (previewCode.value) q.code = previewCode.value;
return q;
},
applyQuery: (q) => {
const t = q.type ?? '';
if (TYPE_META.some((m) => m.key === t)) typeFilter.value = t;
const b = q.board ?? '';
if (b !== (boardCode.value ?? '')) {
boardCode.value = b || null;
if (b) void loadMembers(b);
else members.value = [];
}
previewCode.value = q.code ?? null;
},
});
// ---------- 格式化 ----------
const typeLabel = (t: string | null | undefined) => TYPE_META.find((m) => m.key === t)?.label ?? t;
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 pctText = (v: number | null | undefined) => (v == null ? '—' : (v > 0 ? '+' : '') + v.toFixed(2) + '%');
const fmtVol = (v: number | null | undefined) => {
if (v == null) return '—';
return v >= 1e8 ? (v / 1e8).toFixed(2) + '亿手' : v >= 1e4 ? (v / 1e4).toFixed(0) + '万手' : String(Math.round(v));
};
</script>
<template>
<div>
<!-- 头部 -->
<div class="mb-4 flex flex-wrap items-baseline gap-3">
<h1 class="text-xl font-semibold text-[#E8EAED]">概念板块</h1>
<span class="text-[13px] text-[#9BA3AE]">
同花顺口径 · {{ boards ? boards.length.toLocaleString() : '—' }} 个板块
<template v-if="tradeDate"> · {{ tradeDate }}</template>
<template v-if="updatedAt"> · 更新于 {{ updatedAt }}</template>
</span>
</div>
<!-- 过滤行 -->
<div class="mb-4 flex flex-wrap items-center gap-2">
<div class="flex flex-wrap gap-1">
<button
v-for="t in TYPE_META" :key="t.key || 'all'"
type="button"
class="rounded border px-2 py-0.5 text-[13px] transition-colors"
:class="typeFilter === t.key
? 'border-blue-500 bg-blue-500/15 text-blue-300'
: 'border-[#33353D] text-[#A8AFB8] hover:border-[#3A3D46] hover:text-[#E5E7EB]'"
@click="typeFilter = t.key"
>{{ t.label }}</button>
</div>
<div class="relative ml-auto">
<svg class="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[#9BA3AE]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="8" /><path d="M21 21l-4.35-4.35" /></svg>
<input v-model="search" type="text" class="ipt !py-1.5 pl-9 !w-56" placeholder="搜索板块名称 / 代码" />
</div>
<div class="flex items-center gap-1 text-xs text-[#9BA3AE]">
排序
<button
v-for="s in SORTS" :key="s.key"
type="button"
class="rounded px-1.5 py-0.5 transition-colors"
:class="sortKey === s.key ? 'bg-[#26272E] text-[#E5E7EB]' : 'text-[#A8AFB8] hover:text-[#E5E7EB]'"
@click="sortKey = s.key"
>{{ s.label }}</button>
</div>
</div>
<!-- 首载 / 错误 -->
<div v-if="!boards && loading" class="flex items-center gap-2 rounded-lg border border-[#26272E] bg-[#101014] px-4 py-6 text-sm text-[#A8AFB8]">
<svg class="h-4 w-4 animate-spin text-blue-500" viewBox="0 0 24 24" fill="none"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" /><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" /></svg>
正在加载板块列表
</div>
<div v-else-if="error && !boards" 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>
<!-- 两栏左板块列表 / 右成分 -->
<div v-else-if="boards" class="flex items-start gap-4">
<!-- 板块列表 -->
<aside class="w-80 shrink-0 overflow-y-auto rounded-lg border border-[#26272E] bg-[#101014] max-h-[calc(100vh-14rem)]">
<button
v-for="b in shownBoards" :key="b.ts_code"
type="button"
class="flex w-full items-center gap-2 border-b border-[#1E2026] px-3 py-2 text-left transition-colors"
:class="boardCode === b.ts_code ? 'bg-blue-500/15' : 'hover:bg-[#1E2026]'"
@click="selectBoard(b.ts_code)"
>
<span class="min-w-0 flex-1">
<span class="block truncate text-[13px] text-[#E5E7EB]" :title="`${b.name}${b.ts_code}`">{{ b.name ?? b.ts_code }}</span>
<span class="block text-[11px] text-[#6B7280]">
{{ typeLabel(b.type) }} · {{ fmtInt(b.count) }}
</span>
</span>
<span class="shrink-0 font-mono text-xs tabular-nums" :class="pctClass(b.pct_change)">{{ pctText(b.pct_change) }}</span>
</button>
<div v-if="filteredBoards.length > LIST_CAP" class="px-3 py-2 text-center text-[11px] text-[#6B7280]">
{{ filteredBoards.length.toLocaleString() }} 仅显示前 {{ LIST_CAP }} · 输入关键词过滤
</div>
<div v-else-if="!filteredBoards.length" class="px-3 py-6 text-center text-[13px] text-[#9BA3AE]">无匹配板块</div>
</aside>
<!-- 成分 -->
<section class="min-w-0 flex-1 rounded-lg border border-[#26272E] bg-[#101014] px-4 py-3">
<template v-if="boardCode">
<!-- 板块头部 -->
<div class="flex flex-wrap items-baseline justify-between gap-3 border-b border-[#1E2026] pb-3">
<div class="flex items-baseline gap-2">
<span class="text-lg font-semibold text-[#E8EAED]">{{ selectedBoard?.name ?? boardName ?? boardCode }}</span>
<span class="font-mono text-xs text-[#6B7280]">{{ boardCode }}</span>
</div>
<div v-if="selectedBoard" class="flex items-baseline gap-4 text-[13px]">
<span class="text-[#9BA3AE]">收盘 <span class="font-mono text-[#E5E7EB]">{{ fmtNum(selectedBoard.close) }}</span></span>
<span class="font-mono" :class="pctClass(selectedBoard.pct_change)">{{ pctText(selectedBoard.pct_change) }}</span>
<span class="text-[#9BA3AE]">换手 <span class="font-mono text-[#E5E7EB]">{{ fmtNum(selectedBoard.turnover_rate) }}%</span></span>
<span class="text-[#9BA3AE]">成交 <span class="font-mono text-[#E5E7EB]">{{ fmtVol(selectedBoard.vol) }}</span></span>
</div>
</div>
<!-- 成分搜索 + -->
<div class="mt-3 flex items-center justify-between gap-2">
<span class="text-[13px] text-[#9BA3AE]">
成分股 <span class="font-mono text-[#E5E7EB]">{{ shownMembers.length }}</span> · 按涨跌幅排序
</span>
<div class="relative">
<svg class="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-[#9BA3AE]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="8" /><path d="M21 21l-4.35-4.35" /></svg>
<input v-model="memberSearch" type="text" class="ipt !py-1 pl-7 !w-48 !text-[13px]" placeholder="成分内搜索" />
</div>
</div>
<div v-if="membersLoading" class="flex items-center gap-2 py-8 text-sm text-[#A8AFB8]">
<svg class="h-4 w-4 animate-spin text-blue-500" viewBox="0 0 24 24" fill="none"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" /><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" /></svg>
正在查询{{ selectedBoard?.name ?? boardCode }}成分
</div>
<div v-else-if="membersError" class="py-6 text-sm text-[#A8AFB8]">
{{ membersError }}
<button class="ml-1 text-blue-500 hover:underline" @click="boardCode && loadMembers(boardCode)">重试</button>
</div>
<template v-else>
<table class="mt-2 w-full table-fixed font-mono text-[13px] leading-5">
<thead>
<tr class="text-[#6B7280]">
<th class="w-[38%] py-1 text-left font-normal">名称</th>
<th class="w-[20%] 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>
</table>
<div class="max-h-[calc(100vh-22rem)] overflow-y-auto">
<table class="w-full table-fixed font-mono text-[13px] leading-5">
<tbody>
<tr
v-for="m in renderedMembers" :key="m.con_code"
class="cursor-pointer border-t border-[#1E2026]/60 hover:bg-[#1E2026]"
title="点击进入个股详情(左列表为本板块全部成分)"
@click="openStock(m.con_code)"
>
<td class="break-words py-1 pr-1 font-sans" :class="m.close == null ? 'text-[#6B7280]' : 'text-[#E5E7EB]'">{{ m.con_name ?? m.con_code }}</td>
<td class="py-1 text-[#9BA3AE]">{{ m.con_code }}</td>
<td class="py-1 text-right text-[#C3C9D2]">{{ fmtNum(m.close) }}</td>
<td class="py-1 text-right" :class="pctClass(m.pct_chg)">{{ pctText(m.pct_chg) }}</td>
</tr>
</tbody>
</table>
<div v-if="!shownMembers.length" class="py-6 text-center text-[13px] text-[#9BA3AE]">无匹配成分</div>
<div v-else-if="shownMembers.length > MEMBER_CAP" class="py-2 text-center text-[11px] text-[#6B7280]">
{{ shownMembers.length.toLocaleString() }} 仅显示前 {{ MEMBER_CAP }} · 用上方搜索收敛
</div>
</div>
</template>
</template>
<div v-else class="py-10 text-center text-sm text-[#9BA3AE]"> 点击左侧板块查看成分股</div>
</section>
</div>
<!-- 个股详情浮层items = 本板块全部成分板块内 / 键切换研究 -->
<StockDetailOverlay
v-if="previewCode && overlayItems.length"
:items="overlayItems"
:initial="previewCode"
@change="onOverlayChange"
@close="closeOverlay"
/>
</div>
</template>