364 lines
17 KiB
Vue
364 lines
17 KiB
Vue
<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=;骨架在 useQuerySync,selfNav 防回声) ----------
|
||
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>
|