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

@@ -288,6 +288,7 @@ export async function getStocks(params: {
industry?: string;
area?: string;
watched_only?: boolean;
held_only?: boolean;
sort?: string;
order?: 'asc' | 'desc';
limit?: number;
@@ -300,6 +301,7 @@ export async function getStocks(params: {
if (params.industry) q.set('industry', params.industry);
if (params.area) q.set('area', params.area);
if (params.watched_only) q.set('watched_only', 'true');
if (params.held_only) q.set('held_only', 'true');
if (params.sort) q.set('sort', params.sort);
if (params.order) q.set('order', params.order);
q.set('limit', String(params.limit ?? 100));
@@ -384,6 +386,24 @@ export async function removeWatchlist(tsCode: string): Promise<string[]> {
return (await res.json()) as string[];
}
export async function getHoldings(): Promise<string[]> {
const res = await apiFetch('/api/holdings');
if (!res.ok) throw new ApiError(await readError(res, `获取持仓股失败 (HTTP ${res.status})`), res.status);
return (await res.json()) as string[];
}
export async function addHolding(tsCode: string): Promise<string[]> {
const res = await apiFetch('/api/holdings', { method: 'POST', body: JSON.stringify({ ts_code: tsCode }) });
if (!res.ok) throw new ApiError(await readError(res, `加持仓失败 (HTTP ${res.status})`), res.status);
return (await res.json()) as string[];
}
export async function removeHolding(tsCode: string): Promise<string[]> {
const res = await apiFetch(`/api/holdings/${encodeURIComponent(tsCode)}`, { method: 'DELETE' });
if (!res.ok) throw new ApiError(await readError(res, `移除持仓失败 (HTTP ${res.status})`), res.status);
return (await res.json()) as string[];
}
export async function getScreenerQueries(limit = 20): Promise<ScreenerQueryItem[]> {
const res = await apiFetch(`/api/screener/queries?limit=${limit}`);
if (!res.ok) throw new ApiError(await readError(res, `获取提问历史失败 (HTTP ${res.status})`), res.status);

View File

@@ -260,6 +260,7 @@ export interface StockListItem {
total_mv?: number | null; // 总市值(亿元)
circ_mv?: number | null; // 流通市值(亿元)
watched: boolean;
held: boolean; // 是否持仓(当前用户)
}
export interface StockListResponse {

View 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>

View File

@@ -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>

View File

@@ -0,0 +1,22 @@
import { customRef } from 'vue';
/** 防抖 refv-model 绑定它即时回显输入,读取值延迟 delay 才更新(大列表过滤用)。 */
export function debouncedRef<T>(initial: T, delay = 200) {
return customRef<T>((track, trigger) => {
let value = initial;
let timer: ReturnType<typeof setTimeout> | null = null;
return {
get() {
track();
return value;
},
set(v: T) {
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
value = v;
trigger();
}, delay);
},
};
});
}

View File

@@ -0,0 +1,41 @@
import { watch, type WatchSource } from 'vue';
import { useRoute, useRouter } from 'vue-router';
/**
* 列表页「状态 ↔ ?query」双向同步StocksView / EtfsView / ConceptsView 共用骨架)。
*
* - buildQuery状态 → query 对象(视图自定义,只放非默认值)
* - applyQueryquery → 状态(浏览器前进/后退触发;自己发起的导航被 selfNav 计数防回声挡住)
* - sources变化时随手回写 URL 的响应式源(翻页/筛选等)
*
* 返回 syncRoute(push):显式同步用(浮层开关传 true 产生历史记录,返回键=关浮层)。
*/
export function useQuerySync(opts: {
buildQuery: () => Record<string, string>;
applyQuery: (q: Record<string, string>) => void;
sources: WatchSource[];
}) {
const route = useRoute();
const router = useRouter();
let selfNav = 0; // 自己发起的导航在途数量:其 route 变化不回灌状态(防输入被旧 URL 覆盖)
function syncRoute(push = false) {
const query = opts.buildQuery();
// 与当前 URL 一致就跳过,避免 state→route→state 回声
if (JSON.stringify(query) === JSON.stringify(route.query)) return;
selfNav++;
const done = () => { selfNav--; };
void (push ? router.push({ query }) : router.replace({ query })).then(done, done);
}
watch(opts.sources, () => syncRoute());
watch(() => route.query, (q) => {
if (selfNav > 0) return;
const flat: Record<string, string> = {};
for (const [k, v] of Object.entries(q)) if (typeof v === 'string') flat[k] = v;
opts.applyQuery(flat);
});
return { syncRoute };
}

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>

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import { addWatchlist, getStockFacets, getStocks, removeWatchlist } from '@/api/client';
import { addHolding, addWatchlist, getStockFacets, getStocks, removeHolding, removeWatchlist } from '@/api/client';
import type { FacetItem, ScreenerItemOut, StockListItem } from '@/api/types';
import { useQuerySync } from '@/composables/useQuerySync';
import StockDetailOverlay from '@/components/StockDetailOverlay.vue';
@@ -14,7 +14,7 @@ function qStr(key: string): string | undefined {
return typeof v === 'string' && v ? v : undefined;
}
const MARKETS = ['全部', '自选', '主板', '创业板', '科创板', '北交所'];
const MARKETS = ['全部', '自选', '持仓', '主板', '创业板', '科创板', '北交所'];
// 排序键后端白名单symbol/total_mv/circ_mv/pe_ttm/pb/turnover_rate
const SORT_KEYS = ['symbol', 'total_mv', 'circ_mv', 'pe_ttm', 'pb', 'turnover_rate'] as const;
type SortKey = (typeof SORT_KEYS)[number];
@@ -73,9 +73,10 @@ async function load() {
try {
const res = await getStocks({
search: search.value.trim(),
// 「自选」不是 stock_basic.market 的值,走 watched_only
market: market.value === '全部' || market.value === '自选' ? '' : market.value,
// 「自选」「持仓」不是 stock_basic.market 的值,分别走 watched_only / held_only
market: market.value === '全部' || market.value === '自选' || market.value === '持仓' ? '' : market.value,
watched_only: market.value === '自选',
held_only: market.value === '持仓',
industry: industry.value,
area: area.value,
sort: sort.value,
@@ -194,10 +195,31 @@ async function toggleStar(it: StockListItem) {
}
}
// 详情浮层里增删自选后,刷新当前页星标
// ---------- 持仓股标记(服务端为唯一事实源,本地行内即时翻转) ----------
const heldBusy = ref('');
async function toggleHeld(it: StockListItem) {
if (heldBusy.value === it.ts_code) return;
heldBusy.value = it.ts_code;
const wasHeld = it.held;
it.held = !wasHeld; // 乐观更新
try {
const list = wasHeld ? await removeHolding(it.ts_code) : await addHolding(it.ts_code);
const set = new Set(list);
for (const row of items.value) row.held = set.has(row.ts_code);
} catch {
it.held = wasHeld; // 回滚
} finally {
heldBusy.value = '';
}
}
// 详情浮层里增删自选/持仓后,刷新当前页标记
function onWatchedChange() {
load();
}
function onHeldChange() {
load();
}
// ---------- 路由同步:状态 → ?q/&market/…(骨架在 useQuerySyncreplace 不产生历史记录) ----------
const { syncRoute } = useQuerySync({
@@ -286,6 +308,9 @@ function closeOverlay() {
<thead>
<tr class="border-b border-[#1E2026] text-left text-[13px] text-[#A8AFB8]">
<th class="w-10 px-2 py-3 font-medium" title="自选"></th>
<th class="w-10 px-2 py-3 font-medium" title="持仓">
<svg class="mx-auto h-4 w-4 text-[#A8AFB8]" 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>
</th>
<th class="px-4 py-3 font-medium">
<button type="button" class="inline-flex items-center gap-1 transition hover:text-[#E8EAED]" :class="sort === 'symbol' ? 'text-[#E8EAED]' : ''" @click="toggleSort('symbol')">
代码<span class="text-[10px] leading-none" :class="sort === 'symbol' ? 'text-blue-400' : 'text-[#4A4D55]'">{{ sort === 'symbol' ? (order === 'asc' ? '▲' : '▼') : '⇅' }}</span>
@@ -326,7 +351,7 @@ function closeOverlay() {
</thead>
<tbody>
<tr v-if="loading && items.length === 0">
<td colspan="13" class="px-4 py-16 text-center text-[#9BA3AE]">加载中</td>
<td colspan="14" class="px-4 py-16 text-center text-[#9BA3AE]">加载中</td>
</tr>
<tr
v-for="it in items"
@@ -348,6 +373,20 @@ function closeOverlay() {
</svg>
</button>
</td>
<td class="px-2 py-2.5 text-center" @click.stop>
<button
type="button"
class="rounded p-0.5 transition-colors disabled:opacity-50"
:class="it.held ? 'text-emerald-500 hover:text-emerald-600' : 'text-[#C3C9D2] hover:text-emerald-400'"
:title="it.held ? '移出持仓' : '加入持仓'"
:disabled="heldBusy === it.ts_code"
@click="toggleHeld(it)"
>
<svg class="h-4 w-4" 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>
</td>
<td class="px-4 py-2.5 font-mono text-sm text-[#E8EAED]">{{ it.symbol }}</td>
<td class="px-4 py-2.5 font-medium text-[#E8EAED]">{{ it.name }}</td>
<td class="px-4 py-2.5 text-[#A8AFB8]">{{ it.industry || '--' }}</td>
@@ -364,7 +403,7 @@ function closeOverlay() {
<td class="px-4 py-2.5 text-right font-mono text-sm text-[#9BA3AE]">{{ fmtDate(it.last_ts) }}</td>
</tr>
<tr v-if="!loading && items.length === 0">
<td colspan="13" class="px-4 py-16 text-center text-[#9BA3AE]">没有匹配的股票</td>
<td colspan="14" class="px-4 py-16 text-center text-[#9BA3AE]">没有匹配的股票</td>
</tr>
</tbody>
</table>
@@ -402,6 +441,7 @@ function closeOverlay() {
@close="closeOverlay"
@change="onOverlayChange"
@watched-change="onWatchedChange"
@held-change="onHeldChange"
/>
</div>
</template>