Files
stock/frontend/src/views/ConceptsView.vue
2026-09-09 15:07:58 +08:00

364 lines
17 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>