This commit is contained in:
2026-09-09 11:35:02 +08:00
parent bc1c72d558
commit d656c05b3d
35 changed files with 711 additions and 3194 deletions

View File

@@ -1,13 +1,13 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useRoute } from 'vue-router';
import { addWatchlist, getStockFacets, getStocks, removeWatchlist } from '@/api/client';
import type { FacetItem, ScreenerItemOut, StockListItem } from '@/api/types';
import { useQuerySync } from '@/composables/useQuerySync';
import StockDetailOverlay from '@/components/StockDetailOverlay.vue';
// ---------- 筛选状态(初始值从路由 query 还原,刷新/分享链接不丢现场) ----------
const route = useRoute();
const router = useRouter();
function qStr(key: string): string | undefined {
const v = route.query[key];
@@ -60,10 +60,14 @@ const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize))
// ---------- 加载(搜索防抖) ----------
let fetchToken = 0;
let fetchCtrl: AbortController | null = null;
let debounceTimer: ReturnType<typeof setTimeout> | undefined;
async function load() {
const token = ++fetchToken;
fetchCtrl?.abort();
const ctrl = new AbortController();
fetchCtrl = ctrl;
loading.value = true;
error.value = null;
try {
@@ -78,13 +82,17 @@ async function load() {
order: order.value,
limit: pageSize,
offset: (page.value - 1) * pageSize,
signal: ctrl.signal,
});
if (token === fetchToken) {
items.value = res.items;
total.value = res.total;
}
} catch (e) {
if (token === fetchToken) error.value = e instanceof Error ? e.message : '加载失败';
// AbortError = 被更新的请求取消静默token 检查通常已挡住,这里双保险)
if (token === fetchToken && !(e instanceof Error && e.name === 'AbortError')) {
error.value = e instanceof Error ? e.message : '加载失败';
}
} finally {
if (token === fetchToken) loading.value = false;
}
@@ -191,47 +199,34 @@ 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;
// ---------- 路由同步:状态 → ?q/&market/…(骨架在 useQuerySyncreplace 不产生历史记录) ----------
const { syncRoute } = useQuerySync({
sources: [search, market, industry, area, sort, order, page],
buildQuery: () => {
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;
},
// 浏览器前进/后退(含返回键关掉 ?code=):把 query 应用回状态
applyQuery: (q) => {
search.value = q.q ?? '';
market.value = MARKETS.includes(q.market ?? '') ? (q.market as typeof MARKETS[number]) : '全部';
industry.value = q.industry ?? '';
area.value = q.area ?? '';
const p = parseInt(q.page ?? '', 10);
page.value = Number.isFinite(p) && p >= 1 ? p : 1;
const s = q.sort ?? 'symbol';
sort.value = SORT_KEYS.includes(s as SortKey) ? (s as SortKey) : 'symbol';
order.value = q.order === 'desc' ? 'desc' : 'asc';
previewCode.value = q.code || null;
},
});
// ---------- 详情浮层开关(写入 ?code= ----------