提交
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -260,6 +260,7 @@ export interface StockListItem {
|
||||
total_mv?: number | null; // 总市值(亿元)
|
||||
circ_mv?: number | null; // 流通市值(亿元)
|
||||
watched: boolean;
|
||||
held: boolean; // 是否持仓(当前用户)
|
||||
}
|
||||
|
||||
export interface StockListResponse {
|
||||
|
||||
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>
|
||||
|
||||
22
frontend/src/composables/debouncedRef.ts
Normal file
22
frontend/src/composables/debouncedRef.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { customRef } from 'vue';
|
||||
|
||||
/** 防抖 ref:v-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);
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
41
frontend/src/composables/useQuerySync.ts
Normal file
41
frontend/src/composables/useQuerySync.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { watch, type WatchSource } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
/**
|
||||
* 列表页「状态 ↔ ?query」双向同步(StocksView / EtfsView / ConceptsView 共用骨架)。
|
||||
*
|
||||
* - buildQuery:状态 → query 对象(视图自定义,只放非默认值)
|
||||
* - applyQuery:query → 状态(浏览器前进/后退触发;自己发起的导航被 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 };
|
||||
}
|
||||
363
frontend/src/views/ConceptsView.vue
Normal file
363
frontend/src/views/ConceptsView.vue
Normal 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=;骨架在 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>
|
||||
@@ -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/…(骨架在 useQuerySync,replace 不产生历史记录) ----------
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user