看股功能更新

This commit is contained in:
2026-08-16 00:05:26 +08:00
parent 9cce670b74
commit fc86fe0674
28 changed files with 3823 additions and 96 deletions

View File

@@ -0,0 +1,412 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { addWatchlist, getStockFacets, getStocks, removeWatchlist } from '@/api/client';
import type { FacetItem, ScreenerItemOut, StockListItem } from '@/api/types';
import StockDetailOverlay from '@/components/StockDetailOverlay.vue';
// ---------- 筛选状态(初始值从路由 query 还原,刷新/分享链接不丢现场) ----------
const route = useRoute();
const router = useRouter();
function qStr(key: string): string | undefined {
const v = route.query[key];
return typeof v === 'string' && v ? v : undefined;
}
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];
const search = ref(qStr('q') ?? '');
const market = ref(MARKETS.includes(qStr('market') ?? '') ? (qStr('market') as string) : '全部');
const industry = ref(qStr('industry') ?? '');
const area = ref(qStr('area') ?? '');
const industries = ref<FacetItem[]>([]);
const areas = ref<FacetItem[]>([]);
const pageSize = 100;
const page = ref(Math.max(1, parseInt(qStr('page') ?? '1', 10) || 1));
const sortParam = qStr('sort');
const sort = ref<SortKey>(SORT_KEYS.includes((sortParam ?? 'symbol') as SortKey) ? ((sortParam ?? 'symbol') as SortKey) : 'symbol');
const order = ref<'asc' | 'desc'>(qStr('order') === 'desc' ? 'desc' : 'asc');
// 列表状态
const items = ref<StockListItem[]>([]);
const total = ref(0);
const loading = ref(false);
const error = ref<string | null>(null);
// 详情浮层(当前股记录在 ?code=,刷新后浮层自动重开)
const previewCode = ref<string | null>(qStr('code') ?? null);
// StockDetailOverlay 需要 ScreenerItemOut 形状;行情字段缺失时它内部有兜底
const overlayItems = computed<ScreenerItemOut[]>(() =>
items.value.map((it) => ({
ts_code: it.ts_code,
name: it.name,
close: it.close ?? null,
pct_chg: it.pct_chg ?? null,
total_mv: null,
circ_mv: null,
pe_ttm: null,
pb: null,
turnover_rate: null,
indicators: {},
})),
);
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize)));
// ---------- 加载(搜索防抖) ----------
let fetchToken = 0;
let debounceTimer: ReturnType<typeof setTimeout> | undefined;
async function load() {
const token = ++fetchToken;
loading.value = true;
error.value = null;
try {
const res = await getStocks({
search: search.value.trim(),
// 「自选」不是 stock_basic.market 的值,走 watched_only
market: market.value === '全部' || market.value === '自选' ? '' : market.value,
watched_only: market.value === '自选',
industry: industry.value,
area: area.value,
sort: sort.value,
order: order.value,
limit: pageSize,
offset: (page.value - 1) * pageSize,
});
if (token === fetchToken) {
items.value = res.items;
total.value = res.total;
}
} catch (e) {
if (token === fetchToken) error.value = e instanceof Error ? e.message : '加载失败';
} finally {
if (token === fetchToken) loading.value = false;
}
}
watch(search, () => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
page.value = 1;
load();
}, 300);
});
// 筛选/排序变化回到第一页page 的 watch 会再触发 load翻页直接加载
watch([market, industry, area, sort, order], () => {
if (page.value !== 1) page.value = 1;
else load();
});
watch(page, () => load());
load();
getStockFacets()
.then((f) => {
industries.value = f.industries;
areas.value = f.areas;
})
.catch(() => { /* 筛选项加载失败不阻塞列表 */ });
onBeforeUnmount(() => clearTimeout(debounceTimer));
function pctClass(v: number | null | undefined): string {
if (v == null) return 'text-[#9BA3AE]';
return v > 0 ? 'text-up' : v < 0 ? 'text-down' : 'text-[#A8AFB8]';
}
function fmtPct(v: number | null | undefined): string {
if (v == null) return '--';
return `${v > 0 ? '+' : ''}${v.toFixed(2)}%`;
}
function fmtDate(v: string | null | undefined): string {
if (!v) return '--';
return v.slice(0, 10);
}
function fmtNum(v: number | null | undefined, digits = 2): string {
if (v == null) return '--';
return v.toFixed(digits);
}
function fmtYi(v: number | null | undefined): string {
if (v == null) return '--';
return v >= 100 ? Math.round(v).toLocaleString() : v.toFixed(2);
}
function fmtTurnover(v: number | null | undefined): string {
if (v == null) return '--';
return `${v.toFixed(2)}%`;
}
// ---------- 列排序(后端白名单键) ----------
function toggleSort(key: SortKey) {
if (sort.value === key) {
order.value = order.value === 'asc' ? 'desc' : 'asc';
} else {
sort.value = key;
// 代码列默认升序;其余(市值/估值/换手)默认降序——先看最大/最热
order.value = key === 'symbol' ? 'asc' : 'desc';
}
}
// PE-TTM 分档着色≤15 冷绿、15-30 中性、30-60 琥珀、>60 红;亏损/无数据灰
function peClass(v: number | null | undefined): string {
if (v == null || v <= 0) return 'text-[#9BA3AE]';
if (v <= 15) return 'text-emerald-400';
if (v <= 30) return 'text-[#A8AFB8]';
if (v <= 60) return 'text-amber-400';
return 'text-red-400';
}
function go(delta: number) {
const next = page.value + delta;
if (next >= 1 && next <= totalPages.value) page.value = next;
}
// ---------- 自选股星标(服务端为唯一事实源,本地行内即时翻转) ----------
const starBusy = ref('');
async function toggleStar(it: StockListItem) {
if (starBusy.value === it.ts_code) return;
starBusy.value = it.ts_code;
const wasWatched = it.watched;
it.watched = !wasWatched; // 乐观更新
try {
const list = wasWatched ? await removeWatchlist(it.ts_code) : await addWatchlist(it.ts_code);
const set = new Set(list);
for (const row of items.value) row.watched = set.has(row.ts_code);
} catch {
it.watched = wasWatched; // 回滚
} finally {
starBusy.value = '';
}
}
// 详情浮层里增删自选后,刷新当前页星标
function onWatchedChange() {
load();
}
// ---------- 路由同步:状态 → ?q/&market/…replace 不产生历史记录) ----------
function buildQuery(): Record<string, string> {
const q: Record<string, string> = {};
if (search.value.trim()) q.q = search.value.trim();
if (market.value !== '全部') q.market = market.value;
if (industry.value) q.industry = industry.value;
if (area.value) q.area = area.value;
if (sort.value !== 'symbol') q.sort = sort.value;
if (order.value !== 'asc') q.order = order.value;
if (page.value > 1) q.page = String(page.value);
if (previewCode.value) q.code = previewCode.value;
return q;
}
let selfNav = 0; // 自己发起的导航在途数量:其 route 变化不回灌状态(防输入被旧 URL 覆盖)
function syncRoute(push = false) {
const query = 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);
}
// 列表状态变化(含搜索防抖外的输入)随手回写 URL翻页/筛选也带着当前 ?code
watch([search, market, industry, area, sort, order, page], () => syncRoute());
// 浏览器前进/后退(含返回键关掉 ?code=):把 query 应用回状态
watch(() => route.query, (q) => {
if (selfNav > 0) return;
const qOf = (k: string) => (typeof q[k] === 'string' ? (q[k] as string) : '');
search.value = qOf('q');
market.value = MARKETS.includes(qOf('market')) ? qOf('market') : '全部';
industry.value = qOf('industry');
area.value = qOf('area');
const p = parseInt(qOf('page'), 10);
page.value = Number.isFinite(p) && p >= 1 ? p : 1;
const s = qOf('sort');
sort.value = SORT_KEYS.includes(s as SortKey) ? (s as SortKey) : 'symbol';
order.value = qOf('order') === 'desc' ? 'desc' : 'asc';
previewCode.value = qOf('code') || null;
});
// ---------- 详情浮层开关(写入 ?code= ----------
function openStock(code: string) {
previewCode.value = code;
syncRoute(true); // push浏览器返回键 = 关闭浮层
}
function onOverlayChange(code: string) {
previewCode.value = code; // 浮层内切股(键盘/侧栏)同步到路由
syncRoute();
}
function closeOverlay() {
previewCode.value = null;
syncRoute();
}
</script>
<template>
<div>
<div class="mb-4 flex flex-wrap items-center gap-3">
<h1 class="text-xl font-semibold text-[#E8EAED]">全部股票</h1>
<span class="text-[13px] text-[#9BA3AE]"> {{ total.toLocaleString() }} · 点击行查看 K 线详情</span>
<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" stroke-linejoin="round"><circle cx="11" cy="11" r="8" /><path d="M21 21l-4.35-4.35" /></svg>
<input
v-model="search"
type="text"
placeholder="搜索代码 / 名称"
class="w-56 rounded-md border border-[#33353D] bg-[#16181D] py-2 pl-9 pr-3 text-sm text-[#E8EAED] outline-none transition placeholder:text-[#7A818C] focus:border-blue-500 focus:ring-2 focus:ring-blue-500/30"
/>
</div>
<select v-model="market" class="ipt !w-auto !py-1.5 text-[13px]" title="按板块筛选">
<option v-for="m in MARKETS" :key="m" :value="m">{{ m }}</option>
</select>
<select v-model="industry" class="ipt !w-auto !py-1.5 text-[13px]" title="按行业筛选">
<option value="">全部行业</option>
<option v-for="i in industries" :key="i.name" :value="i.name">{{ i.name }}{{ i.count }}</option>
</select>
<select v-model="area" class="ipt !w-auto !py-1.5 text-[13px]" title="按地域筛选">
<option value="">全部地域</option>
<option v-for="a in areas" :key="a.name" :value="a.name">{{ a.name }}{{ a.count }}</option>
</select>
</div>
<div v-if="error" class="flex items-start gap-2 rounded-xl border border-red-500/30 bg-red-500/15 px-4 py-3 text-sm text-red-400">
<svg class="mt-0.5 h-4 w-4 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.3 3.9L1.8 18a2 2 0 001.7 3h17a2 2 0 001.7-3L13.7 3.9a2 2 0 00-3.4 0z" /><path d="M12 9v4M12 17h.01" /></svg>
{{ error }}
</div>
<div class="overflow-hidden rounded-xl border border-[#26272E] bg-[#101014]">
<div class="overflow-x-auto">
<table class="w-full text-sm">
<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="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>
</button>
</th>
<th class="px-4 py-3 font-medium">名称</th>
<th class="px-4 py-3 font-medium">行业</th>
<th class="px-4 py-3 font-medium">市场</th>
<th class="px-4 py-3 text-right font-medium">最新价</th>
<th class="px-4 py-3 text-right font-medium">涨跌幅</th>
<th class="px-4 py-3 text-right font-medium" title="单位:亿元">
<button type="button" class="inline-flex items-center gap-1 transition hover:text-[#E8EAED]" :class="sort === 'total_mv' ? 'text-[#E8EAED]' : ''" @click="toggleSort('total_mv')">
总市值<span class="text-[10px] leading-none" :class="sort === 'total_mv' ? 'text-blue-400' : 'text-[#4A4D55]'">{{ sort === 'total_mv' ? (order === 'asc' ? '▲' : '▼') : '⇅' }}</span>
</button>
</th>
<th class="px-4 py-3 text-right font-medium" title="单位:亿元">
<button type="button" class="inline-flex items-center gap-1 transition hover:text-[#E8EAED]" :class="sort === 'circ_mv' ? 'text-[#E8EAED]' : ''" @click="toggleSort('circ_mv')">
流通市值<span class="text-[10px] leading-none" :class="sort === 'circ_mv' ? 'text-blue-400' : 'text-[#4A4D55]'">{{ sort === 'circ_mv' ? (order === 'asc' ? '▲' : '▼') : '⇅' }}</span>
</button>
</th>
<th class="px-4 py-3 text-right font-medium" title="≤15 绿 · 15-30 灰 · 30-60 黄 · >60 红;亏损/无数据为空">
<button type="button" class="inline-flex items-center gap-1 transition hover:text-[#E8EAED]" :class="sort === 'pe_ttm' ? 'text-[#E8EAED]' : ''" @click="toggleSort('pe_ttm')">
市盈率TTM<span class="text-[10px] leading-none" :class="sort === 'pe_ttm' ? 'text-blue-400' : 'text-[#4A4D55]'">{{ sort === 'pe_ttm' ? (order === 'asc' ? '▲' : '▼') : '⇅' }}</span>
</button>
</th>
<th class="px-4 py-3 text-right font-medium">
<button type="button" class="inline-flex items-center gap-1 transition hover:text-[#E8EAED]" :class="sort === 'pb' ? 'text-[#E8EAED]' : ''" @click="toggleSort('pb')">
市净率<span class="text-[10px] leading-none" :class="sort === 'pb' ? 'text-blue-400' : 'text-[#4A4D55]'">{{ sort === 'pb' ? (order === 'asc' ? '▲' : '▼') : '⇅' }}</span>
</button>
</th>
<th class="px-4 py-3 text-right font-medium">
<button type="button" class="inline-flex items-center gap-1 transition hover:text-[#E8EAED]" :class="sort === 'turnover_rate' ? 'text-[#E8EAED]' : ''" @click="toggleSort('turnover_rate')">
换手率<span class="text-[10px] leading-none" :class="sort === 'turnover_rate' ? 'text-blue-400' : 'text-[#4A4D55]'">{{ sort === 'turnover_rate' ? (order === 'asc' ? '▲' : '▼') : '⇅' }}</span>
</button>
</th>
<th class="px-4 py-3 text-right font-medium">数据截至</th>
</tr>
</thead>
<tbody>
<tr v-if="loading && items.length === 0">
<td colspan="13" class="px-4 py-16 text-center text-[#9BA3AE]">加载中</td>
</tr>
<tr
v-for="it in items"
:key="it.ts_code"
class="cursor-pointer border-b border-[#1E2026] transition hover:bg-blue-500/15"
@click="openStock(it.ts_code)"
>
<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.watched ? 'text-amber-500 hover:text-amber-600' : 'text-[#C3C9D2] hover:text-amber-400'"
:title="it.watched ? '移出自选' : '加入自选'"
:disabled="starBusy === it.ts_code"
@click="toggleStar(it)"
>
<svg class="h-4 w-4" viewBox="0 0 24 24" :fill="it.watched ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="2" stroke-linejoin="round">
<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>
</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>
<td class="px-4 py-2.5">
<span class="rounded bg-[#26272E] px-1.5 py-0.5 text-[13px] text-[#A8AFB8]">{{ it.market || '--' }}</span>
</td>
<td class="px-4 py-2.5 text-right font-mono text-sm font-medium tabular-nums" :class="pctClass(it.pct_chg)">{{ it.close?.toFixed(2) ?? '--' }}</td>
<td class="px-4 py-2.5 text-right font-mono text-sm tabular-nums" :class="pctClass(it.pct_chg)">{{ fmtPct(it.pct_chg) }}</td>
<td class="px-4 py-2.5 text-right font-mono text-sm tabular-nums text-[#A8AFB8]">{{ fmtYi(it.total_mv) }}</td>
<td class="px-4 py-2.5 text-right font-mono text-sm tabular-nums text-[#A8AFB8]">{{ fmtYi(it.circ_mv) }}</td>
<td class="px-4 py-2.5 text-right font-mono text-sm tabular-nums" :class="peClass(it.pe_ttm)">{{ fmtNum(it.pe_ttm) }}</td>
<td class="px-4 py-2.5 text-right font-mono text-sm tabular-nums text-[#A8AFB8]">{{ fmtNum(it.pb) }}</td>
<td class="px-4 py-2.5 text-right font-mono text-sm tabular-nums text-[#A8AFB8]">{{ fmtTurnover(it.turnover_rate) }}</td>
<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>
</tr>
</tbody>
</table>
</div>
<div class="flex items-center justify-between border-t border-[#1E2026] px-4 py-3 text-[13px] text-[#A8AFB8]">
<span v-if="loading">加载中</span>
<span v-else> {{ page }} / {{ totalPages }} </span>
<div class="flex gap-2">
<button
type="button"
class="rounded border border-[#26272E] px-3 py-1.5 transition hover:border-[#3A3D46] hover:text-white disabled:opacity-40"
:disabled="page <= 1 || loading"
@click="go(-1)"
>
上一页
</button>
<button
type="button"
class="rounded border border-[#26272E] px-3 py-1.5 transition hover:border-[#3A3D46] hover:text-white disabled:opacity-40"
:disabled="page >= totalPages || loading"
@click="go(1)"
>
下一页
</button>
</div>
</div>
</div>
<!-- 全屏个股详情与选股页同款当前股记录在 ?code= -->
<StockDetailOverlay
v-if="previewCode && overlayItems.length"
:items="overlayItems"
:initial="previewCode"
@close="closeOverlay"
@change="onOverlayChange"
@watched-change="onWatchedChange"
/>
</div>
</template>