提交
This commit is contained in:
@@ -1,7 +1,5 @@
|
||||
import type {
|
||||
AdjustMode,
|
||||
BacktestRequest,
|
||||
BacktestResponse,
|
||||
Candle,
|
||||
CurrentUser,
|
||||
EventBacktestRequest,
|
||||
@@ -11,6 +9,9 @@ import type {
|
||||
GlobalIndexList,
|
||||
IndexDetail,
|
||||
IndexWeights,
|
||||
LimitBoard,
|
||||
ThsBoardList,
|
||||
ThsBoardMembers,
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
MarketOverview,
|
||||
@@ -27,8 +28,6 @@ import type {
|
||||
StockFinanceOut,
|
||||
StockReferenceOut,
|
||||
StockListResponse,
|
||||
SyncRequest,
|
||||
SyncResponse,
|
||||
Timeframe,
|
||||
TradesClearResponse,
|
||||
TradesImportResponse,
|
||||
@@ -96,18 +95,6 @@ export async function logout(): Promise<void> {
|
||||
if (!res.ok && res.status !== 401) throw new ApiError(await readError(res, '退出登录失败'), res.status);
|
||||
}
|
||||
|
||||
export async function postBacktest(req: BacktestRequest): Promise<BacktestResponse> {
|
||||
const res = await apiFetch('/api/backtest', { method: 'POST', body: JSON.stringify(req) });
|
||||
if (!res.ok) throw new ApiError(`回测请求失败 (HTTP ${res.status}): ${await res.text()}`, res.status);
|
||||
return (await res.json()) as BacktestResponse;
|
||||
}
|
||||
|
||||
export async function syncData(req: SyncRequest): Promise<SyncResponse> {
|
||||
const res = await apiFetch('/api/data/sync', { method: 'POST', body: JSON.stringify(req) });
|
||||
if (!res.ok) throw new ApiError(`数据拉取失败 (HTTP ${res.status}): ${await res.text()}`, res.status);
|
||||
return (await res.json()) as SyncResponse;
|
||||
}
|
||||
|
||||
/** 自然语言事件回测。全市场扫描较慢,timeout 放宽到 15 分钟。 */
|
||||
export async function postEventBacktest(req: EventBacktestRequest): Promise<EventBacktestResponse> {
|
||||
const ctrl = new AbortController();
|
||||
@@ -172,7 +159,7 @@ export async function startScreenerSync(req: ScreenerSyncRequest = {}): Promise<
|
||||
|
||||
export async function getScreenerSyncStatus(): Promise<ScreenerSyncStatus> {
|
||||
const res = await apiFetch('/api/screener/sync/status');
|
||||
if (!res.ok) throw new ApiError(`获取同步状态失败 (HTTP ${res.status})`, res.status);
|
||||
if (!res.ok) throw new ApiError(await readError(res, `获取同步状态失败 (HTTP ${res.status})`), res.status);
|
||||
return (await res.json()) as ScreenerSyncStatus;
|
||||
}
|
||||
|
||||
@@ -185,9 +172,10 @@ export async function getStockPreview(
|
||||
mas?: number[];
|
||||
/** 向前翻页:返回该日期(不含)之前的 limit 根 K 线 + 预热好的指标 */
|
||||
end?: string;
|
||||
signal?: AbortSignal;
|
||||
} = {},
|
||||
): Promise<PreviewResponse> {
|
||||
const { limit = 500, adjust = 'qfq', timeframe = '1d', mas, end } = opts;
|
||||
const { limit = 500, adjust = 'qfq', timeframe = '1d', mas, end, signal } = opts;
|
||||
const q = new URLSearchParams({
|
||||
limit: String(limit),
|
||||
adjust,
|
||||
@@ -195,7 +183,7 @@ export async function getStockPreview(
|
||||
...(mas?.length ? { mas: mas.join(',') } : {}),
|
||||
...(end ? { end } : {}),
|
||||
});
|
||||
const res = await apiFetch(`/api/screener/preview/${encodeURIComponent(tsCode)}?${q.toString()}`);
|
||||
const res = await apiFetch(`/api/screener/preview/${encodeURIComponent(tsCode)}?${q.toString()}`, { signal });
|
||||
if (!res.ok) throw new ApiError(await readError(res, `获取个股详情失败 (HTTP ${res.status})`), res.status);
|
||||
return (await res.json()) as PreviewResponse;
|
||||
}
|
||||
@@ -235,6 +223,26 @@ export async function getMarketOverview(): Promise<MarketOverview> {
|
||||
return (await res.json()) as MarketOverview;
|
||||
}
|
||||
|
||||
// ---------- 打板专题(主页) ----------
|
||||
export async function getLimitBoard(): Promise<LimitBoard> {
|
||||
const res = await apiFetch('/api/market/limit-board');
|
||||
if (!res.ok) throw new ApiError(await readError(res, '获取打板数据失败'), res.status);
|
||||
return (await res.json()) as LimitBoard;
|
||||
}
|
||||
|
||||
// ---------- 概念板块(THS) ----------
|
||||
export async function getThsBoards(opts: { signal?: AbortSignal } = {}): Promise<ThsBoardList> {
|
||||
const res = await apiFetch('/api/market/boards', { signal: opts.signal });
|
||||
if (!res.ok) throw new ApiError(await readError(res, '获取板块列表失败'), res.status);
|
||||
return (await res.json()) as ThsBoardList;
|
||||
}
|
||||
|
||||
export async function getThsBoardMembers(code: string, opts: { signal?: AbortSignal } = {}): Promise<ThsBoardMembers> {
|
||||
const res = await apiFetch(`/api/market/boards/${encodeURIComponent(code)}/members`, { signal: opts.signal });
|
||||
if (!res.ok) throw new ApiError(await readError(res, '获取板块成分失败'), res.status);
|
||||
return (await res.json()) as ThsBoardMembers;
|
||||
}
|
||||
|
||||
/** 上证指数全量 K 线(日线基底聚合到目标周期;收盘口径) */
|
||||
export async function getIndexCandles(timeframe: Timeframe): Promise<Candle[]> {
|
||||
const res = await apiFetch(`/api/market/index-candles?timeframe=${encodeURIComponent(timeframe)}`);
|
||||
@@ -256,9 +264,10 @@ export async function getIndexDetail(code: string): Promise<IndexDetail> {
|
||||
}
|
||||
|
||||
/** 白名单指数全量 K 线(国内 index_daily / 国际 index_global,日线基底聚合) */
|
||||
export async function getAnyIndexCandles(code: string, timeframe: Timeframe): Promise<Candle[]> {
|
||||
export async function getAnyIndexCandles(code: string, timeframe: Timeframe, opts: { signal?: AbortSignal } = {}): Promise<Candle[]> {
|
||||
const res = await apiFetch(
|
||||
`/api/market/indexes/${encodeURIComponent(code)}/candles?timeframe=${encodeURIComponent(timeframe)}`,
|
||||
{ signal: opts.signal },
|
||||
);
|
||||
if (!res.ok) throw new ApiError(await readError(res, `获取指数K线失败 (HTTP ${res.status})`), res.status);
|
||||
return (await res.json()) as Candle[];
|
||||
@@ -283,6 +292,7 @@ export async function getStocks(params: {
|
||||
order?: 'asc' | 'desc';
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<StockListResponse> {
|
||||
const q = new URLSearchParams();
|
||||
if (params.search) q.set('search', params.search);
|
||||
@@ -294,7 +304,7 @@ export async function getStocks(params: {
|
||||
if (params.order) q.set('order', params.order);
|
||||
q.set('limit', String(params.limit ?? 100));
|
||||
q.set('offset', String(params.offset ?? 0));
|
||||
const res = await apiFetch(`/api/stocks?${q.toString()}`);
|
||||
const res = await apiFetch(`/api/stocks?${q.toString()}`, { signal: params.signal });
|
||||
if (!res.ok) throw new ApiError(await readError(res, `获取股票列表失败 (HTTP ${res.status})`), res.status);
|
||||
return (await res.json()) as StockListResponse;
|
||||
}
|
||||
@@ -314,6 +324,7 @@ export async function getEtfs(params: {
|
||||
order?: 'asc' | 'desc';
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<EtfListResponse> {
|
||||
const q = new URLSearchParams();
|
||||
if (params.search) q.set('search', params.search);
|
||||
@@ -323,7 +334,7 @@ export async function getEtfs(params: {
|
||||
if (params.order) q.set('order', params.order);
|
||||
q.set('limit', String(params.limit ?? 100));
|
||||
q.set('offset', String(params.offset ?? 0));
|
||||
const res = await apiFetch(`/api/etfs?${q.toString()}`);
|
||||
const res = await apiFetch(`/api/etfs?${q.toString()}`, { signal: params.signal });
|
||||
if (!res.ok) throw new ApiError(await readError(res, `获取 ETF 列表失败 (HTTP ${res.status})`), res.status);
|
||||
return (await res.json()) as EtfListResponse;
|
||||
}
|
||||
|
||||
@@ -27,71 +27,6 @@ export interface Candle {
|
||||
turnover?: number | null; // 换手率(%);daily_basic 缺失时为 null
|
||||
}
|
||||
|
||||
export interface BacktestRequest {
|
||||
symbol: string;
|
||||
timeframe: string;
|
||||
strategy: string;
|
||||
params: Record<string, number>;
|
||||
initial_cash: number;
|
||||
fast_mode: boolean;
|
||||
start?: string;
|
||||
end?: string;
|
||||
}
|
||||
|
||||
export interface SignalOut {
|
||||
ts: string;
|
||||
side: 'buy' | 'sell';
|
||||
price: number;
|
||||
qty: number;
|
||||
}
|
||||
|
||||
export interface EquityPoint {
|
||||
ts: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface IndicatorOut {
|
||||
strategy: string;
|
||||
data: Record<string, (number | null)[]>;
|
||||
}
|
||||
|
||||
export interface MetricsOut {
|
||||
total_return: number;
|
||||
max_drawdown: number;
|
||||
sharpe: number;
|
||||
volatility: number;
|
||||
num_trades: number;
|
||||
win_rate: number;
|
||||
}
|
||||
|
||||
export interface BacktestResponse {
|
||||
symbol: string;
|
||||
timeframe: string;
|
||||
strategy: string;
|
||||
candles: Candle[];
|
||||
indicators: IndicatorOut;
|
||||
signals: SignalOut[];
|
||||
equity: EquityPoint[];
|
||||
metrics: MetricsOut;
|
||||
final_cash: number;
|
||||
final_position: number;
|
||||
initial_cash: number;
|
||||
}
|
||||
|
||||
export interface SyncRequest {
|
||||
symbol: string;
|
||||
start?: string;
|
||||
end?: string;
|
||||
source?: string; // auto | tushare | akshare
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
export interface SyncResponse {
|
||||
symbol: string;
|
||||
bars: number;
|
||||
source: string;
|
||||
}
|
||||
|
||||
// ---------- 智能选股(镜像 app/schemas.py) ----------
|
||||
export type Op = 'gt' | 'ge' | 'lt' | 'le' | 'between';
|
||||
|
||||
@@ -532,6 +467,94 @@ export interface MarketOverview {
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
// ---------- 打板专题(主页,同花顺口径) ----------
|
||||
export interface LimitStock {
|
||||
ts_code: string;
|
||||
name?: string | null;
|
||||
price?: number | null; // 收盘价(元)
|
||||
pct_chg?: number | null; // 涨跌幅 %
|
||||
tag?: string | null; // 首板 / 2天2板(仅涨停池)
|
||||
status?: string | null; // 一字板 / 换手板(仅涨停池)
|
||||
lu_desc?: string | null; // 涨停原因(仅涨停池)
|
||||
open_num?: number | null; // 打开次数
|
||||
limit_amount_yi?: number | null; // 封单额(亿元,仅涨停池)
|
||||
turnover_yi?: number | null; // 成交额(亿元,仅涨停池)
|
||||
first_lu_time?: string | null; // 首次涨停时间
|
||||
last_lu_time?: string | null; // 最后涨停时间(仅炸板池)
|
||||
limit_up_suc_rate?: number | null; // 近一年封板率 %(仅涨停池)
|
||||
}
|
||||
|
||||
export interface LimitLadder {
|
||||
ts_code: string;
|
||||
name?: string | null;
|
||||
nums: number; // 连板数
|
||||
}
|
||||
|
||||
export interface LimitBlock {
|
||||
name?: string | null; // 同花顺概念板块名
|
||||
days?: number | null;
|
||||
up_stat?: string | null; // 如「6天3板」
|
||||
cons_nums?: number | null; // 连板家数
|
||||
up_nums?: number | null; // 涨停家数
|
||||
pct_chg?: number | null;
|
||||
}
|
||||
|
||||
export interface LimitBoardSummary {
|
||||
up_count: number;
|
||||
broken_count: number;
|
||||
down_count: number;
|
||||
first_board_count: number;
|
||||
max_ladder?: LimitLadder | null;
|
||||
ladder_dist: { nums: number; count: number }[]; // 2板起升序
|
||||
}
|
||||
|
||||
export interface LimitBoard {
|
||||
trade_date: string; // YYYY-MM-DD
|
||||
updated_at: string;
|
||||
summary: LimitBoardSummary;
|
||||
up: LimitStock[]; // 涨停池(封单额降序)
|
||||
broken: LimitStock[];
|
||||
down: LimitStock[];
|
||||
ladder: LimitLadder[]; // 连板数降序
|
||||
blocks: LimitBlock[];
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
// ---------- 概念板块(THS:ths_index + ths_daily + ths_member) ----------
|
||||
export type ThsBoardType = 'N' | 'I' | 'TH' | 'S' | 'R' | 'BB' | 'ST'; // 概念/行业/主题/特色/地域/宽基/风格
|
||||
|
||||
export interface ThsBoard {
|
||||
ts_code: string; // 885835.TI / 700001.TI
|
||||
name?: string | null;
|
||||
type?: ThsBoardType | string | null;
|
||||
count?: number | null; // 成分个数
|
||||
list_date?: string | null;
|
||||
close?: number | null; // 板块指数收盘(当日快照)
|
||||
pct_change?: number | null; // 涨跌幅 %
|
||||
vol?: number | null; // 成交量(手)
|
||||
turnover_rate?: number | null; // 换手率 %
|
||||
}
|
||||
|
||||
export interface ThsBoardList {
|
||||
trade_date?: string | null;
|
||||
updated_at?: string | null;
|
||||
boards: ThsBoard[];
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
export interface ThsMember {
|
||||
con_code: string; // 000016.SZ
|
||||
con_name?: string | null;
|
||||
close?: number | null; // 现价(北交所等无底座为空)
|
||||
pct_chg?: number | null; // 涨跌幅 %
|
||||
}
|
||||
|
||||
export interface ThsBoardMembers {
|
||||
code: string;
|
||||
name?: string | null;
|
||||
members: ThsMember[];
|
||||
}
|
||||
|
||||
// ---------- 指数专题(国际指数卡片 + 指数详情) ----------
|
||||
export type GlobalRegion = 'americas' | 'europe' | 'asia';
|
||||
|
||||
|
||||
@@ -790,9 +790,11 @@ function teardown() {
|
||||
|
||||
onMounted(build);
|
||||
onBeforeUnmount(teardown);
|
||||
watch(() => [props.candles, props.indicators, props.subPanes, props.showBoll, props.showZhixing, props.zhixingBlocks, props.maPeriods, props.timeframe], () => { teardown(); build(); }, { deep: true });
|
||||
// 浅 watch 即可:父组件对 data 是整体替换(新数组引用),props 引用变化必触发;
|
||||
// deep 反而每次深遍历几百根 K 线的嵌套数组(父组件从无原地改写)
|
||||
watch(() => [props.candles, props.indicators, props.subPanes, props.showBoll, props.showZhixing, props.zhixingBlocks, props.maPeriods, props.timeframe], () => { teardown(); build(); });
|
||||
// 事件标记数据变化(导入/清空/开关显示/分红数据到达):只重画标记,不重建图表(保留滚动位置与用户画线)
|
||||
watch(() => [props.tradeMarkers, props.dividendMarkers], renderMarkers, { deep: true });
|
||||
watch(() => [props.tradeMarkers, props.dividendMarkers], renderMarkers);
|
||||
// 涨跌配色切换:重建图表以应用新颜色
|
||||
watch(() => settings.priceTone, () => { teardown(); build(); });
|
||||
// 副图高度变化:仅调 pane 高度,不重建(保留滚动/画线状态)
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import { dispose, init, type Chart, type KLineData } from 'klinecharts';
|
||||
|
||||
import { getIndexCandles } from '@/api/client';
|
||||
import type { Candle, Timeframe } from '@/api/types';
|
||||
import { useSettingsStore } from '@/stores/settings';
|
||||
import { darkStyles } from '@/chartStyles';
|
||||
|
||||
// 首页上证指数 K 线图:轻量版(无翻页/画线/副图配置)。
|
||||
// v10 无 applyNewData,数据只进 dataLoader——每次到新数据整图重建(切周期/换配色同款,
|
||||
// 与详情页 teardown+build 模式一致;全量 ≤9000 根,init 开销毫秒级)。
|
||||
// 周期随用户偏好持久化(chartLayout.indexTimeframe)。
|
||||
const settings = useSettingsStore();
|
||||
|
||||
const PERIODS: { key: Timeframe; label: string }[] = [
|
||||
{ key: '1d', label: '日K' },
|
||||
{ key: '1w', label: '周K' },
|
||||
{ key: '1M', label: '月K' },
|
||||
{ key: '1y', label: '年K' },
|
||||
];
|
||||
|
||||
const timeframe = ref<Timeframe>(settings.chartLayout.indexTimeframe ?? '1d');
|
||||
function setTimeframe(tf: Timeframe) {
|
||||
if (tf === timeframe.value) return;
|
||||
timeframe.value = tf;
|
||||
settings.setChartLayout({ indexTimeframe: tf });
|
||||
void load();
|
||||
}
|
||||
|
||||
const container = ref<HTMLDivElement | null>(null);
|
||||
const loading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
const lastDate = ref(''); // 数据末根交易日(收盘口径)
|
||||
let chart: Chart | null = null;
|
||||
let loadToken = 0;
|
||||
let lastCandles: Candle[] | null = null; // 配色切换重建图表时免重拉
|
||||
|
||||
function rebuild(candles: Candle[]) {
|
||||
if (!container.value) return;
|
||||
if (chart) { dispose(container.value); chart = null; }
|
||||
const ch = init(container.value, { styles: darkStyles(settings.upHex, settings.downHex) });
|
||||
if (!ch) return;
|
||||
chart = ch;
|
||||
const data: KLineData[] = candles.map((c) => ({
|
||||
timestamp: new Date(c.ts).getTime(),
|
||||
open: c.open, high: c.high, low: c.low, close: c.close, volume: c.volume,
|
||||
}));
|
||||
// 全量已在手:init 一次给足,forward(更早历史)/backward(更新端)都无更多
|
||||
ch.setDataLoader({
|
||||
getBars: ({ type, callback }) => {
|
||||
if (type === 'init') callback(data, { forward: false, backward: false });
|
||||
else callback([], { forward: false, backward: false });
|
||||
},
|
||||
});
|
||||
// v10 要求 symbol+period+dataLoader 三者齐备才触发 'init' 加载
|
||||
ch.setSymbol({ ticker: '000001.SH' });
|
||||
ch.setPeriod({ type: 'day', span: 1 });
|
||||
// 主图 MA(周期与详情页默认一致)+ VOL 副图;右侧留白与详情页同款
|
||||
ch.createIndicator({ name: 'MA', paneId: 'candle_pane', calcParams: [5, 10, 20, 60] });
|
||||
ch.createIndicator('VOL');
|
||||
const volPane = ch.getIndicators().find((i) => i.name === 'VOL')?.paneId;
|
||||
ch.setPaneOptions({ id: 'candle_pane', height: 252, minHeight: 160 });
|
||||
if (volPane) ch.setPaneOptions({ id: volPane, height: 76, minHeight: 56 });
|
||||
ch.setOffsetRightDistance(28);
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const token = ++loadToken;
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const candles = await getIndexCandles(timeframe.value);
|
||||
if (token !== loadToken) return; // 期间已切换周期,旧响应丢弃
|
||||
lastCandles = candles;
|
||||
rebuild(candles);
|
||||
lastDate.value = candles.length ? candles[candles.length - 1].ts.slice(0, 10) : '';
|
||||
} catch (e) {
|
||||
if (token === loadToken) error.value = e instanceof Error ? e.message : '获取指数K线失败';
|
||||
} finally {
|
||||
if (token === loadToken) loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
onBeforeUnmount(() => {
|
||||
if (container.value) dispose(container.value);
|
||||
chart = null;
|
||||
});
|
||||
|
||||
// 涨跌配色切换:重建图表应用新颜色,数据用已拉到的直接重放
|
||||
watch(() => settings.priceTone, () => {
|
||||
if (lastCandles) rebuild(lastCandles);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rounded-lg border border-[#26272E] bg-[#101014] p-3">
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<div class="flex items-baseline gap-2">
|
||||
<span class="text-sm font-medium text-[#E5E7EB]">上证指数</span>
|
||||
<span v-if="lastDate" class="font-mono text-xs tabular-nums text-[#6B7280]">收盘口径 · {{ lastDate }}</span>
|
||||
<span v-else class="text-xs text-[#6B7280]">收盘口径</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
v-for="p in PERIODS"
|
||||
:key="p.key"
|
||||
type="button"
|
||||
class="rounded-md border px-2.5 py-1 text-[13px] transition-colors"
|
||||
:class="timeframe === p.key
|
||||
? 'border-blue-600 bg-blue-600 text-white'
|
||||
: 'border-[#26272E] bg-[#101014] text-[#9BA3AE] hover:text-[#E5E7EB]'"
|
||||
@click="setTimeframe(p.key)"
|
||||
>{{ p.label }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative h-[340px]">
|
||||
<div ref="container" class="h-full w-full" />
|
||||
<div
|
||||
v-if="loading"
|
||||
class="absolute inset-0 z-10 flex flex-col items-center justify-center bg-black/70 text-sm text-[#9BA3AE]"
|
||||
>
|
||||
<svg class="mb-2 h-6 w-6 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>
|
||||
指数K线加载中…
|
||||
</div>
|
||||
<div v-else-if="error" class="flex h-full items-center justify-center text-sm text-[#A8AFB8]">
|
||||
{{ error }}
|
||||
<button type="button" class="ml-2 text-blue-500 hover:underline" @click="load">重试</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -420,6 +420,9 @@ const filteredItems = computed(() => {
|
||||
(it) => it.ts_code.toLowerCase().includes(q) || it.name.toLowerCase().includes(q),
|
||||
);
|
||||
});
|
||||
// 渲染截断:概念板块宽基可到 5555 只成分,全进 DOM 会卡;搜索可收敛,键盘 ↑/↓ 不受影响
|
||||
const ITEM_RENDER_CAP = 300;
|
||||
const shownItems = computed(() => filteredItems.value.slice(0, ITEM_RENDER_CAP));
|
||||
|
||||
const activeItem = computed(
|
||||
() => props.items.find((it) => it.ts_code === active.value) ?? null,
|
||||
@@ -628,7 +631,7 @@ const fmtListDate = (s?: string | null) => (s && s.length === 8 ? `${s.slice(0,
|
||||
</div>
|
||||
<div class="min-h-0 flex-1 overflow-y-auto">
|
||||
<button
|
||||
v-for="it in filteredItems"
|
||||
v-for="it in shownItems"
|
||||
:key="it.ts_code"
|
||||
type="button"
|
||||
class="flex w-full items-center gap-2 border-b border-[#1E2026] px-3 py-2 text-left transition-colors"
|
||||
@@ -647,6 +650,9 @@ const fmtListDate = (s?: string | null) => (s && s.length === 8 ? `${s.slice(0,
|
||||
</span>
|
||||
</button>
|
||||
<div v-if="filteredItems.length === 0" class="px-3 py-8 text-center text-[13px] text-[#9BA3AE]">无匹配</div>
|
||||
<div v-else-if="filteredItems.length > ITEM_RENDER_CAP" class="px-3 py-2 text-center text-[11px] text-[#6B7280]">
|
||||
共 {{ filteredItems.length.toLocaleString() }} 只,仅显示前 {{ ITEM_RENDER_CAP }} · 用上方搜索收敛
|
||||
</div>
|
||||
</div>
|
||||
<div class="border-t border-[#1E2026] px-3 py-2 text-xs text-[#9BA3AE]">共 {{ filteredItems.length }} 只</div>
|
||||
</aside>
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router';
|
||||
import HomeView from '@/views/HomeView.vue';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{ path: '/login', name: 'login', component: () => import('@/views/LoginView.vue'), meta: { public: true } },
|
||||
{ path: '/', name: 'home', component: HomeView },
|
||||
{ path: '/', name: 'home', component: () => import('@/views/HomeView.vue') },
|
||||
{ path: '/screener', name: 'screener', component: () => import('@/views/ScreenerView.vue') },
|
||||
{ path: '/stocks', name: 'stocks', component: () => import('@/views/StocksView.vue') },
|
||||
{ path: '/etfs', name: 'etfs', component: () => import('@/views/EtfsView.vue') },
|
||||
{ path: '/concepts', name: 'concepts', component: () => import('@/views/ConceptsView.vue') },
|
||||
{ path: '/indexes', name: 'indexes', component: () => import('@/views/IndexesView.vue') },
|
||||
{ path: '/indexes/:code', name: 'index-detail', component: () => import('@/views/IndexDetailView.vue') },
|
||||
{ path: '/backtest', name: 'backtest', component: () => import('@/views/BacktestView.vue') },
|
||||
|
||||
@@ -38,6 +38,8 @@ function normTipFields(v: unknown): TooltipField[] | undefined {
|
||||
}
|
||||
|
||||
const TF_VALID = new Set(['1d', '1w', '1M', '1y']);
|
||||
const TONE_VALID = new Set(['red-up', 'green-up']);
|
||||
const ADJUST_VALID = new Set(['bfq', 'qfq', 'hfq']);
|
||||
/** K线周期合法化;非法/缺失返回 undefined(调用方回退默认值) */
|
||||
function normTimeframe(v: unknown): Timeframe | undefined {
|
||||
return typeof v === 'string' && TF_VALID.has(v) ? (v as Timeframe) : undefined;
|
||||
@@ -154,16 +156,16 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||
const prefs = await getPreferences();
|
||||
if (dirty.has('priceTone')) {
|
||||
schedulePush('priceTone', priceTone.value);
|
||||
} else if (typeof prefs.priceTone === 'string' && prefs.priceTone !== priceTone.value) {
|
||||
priceTone.value = prefs.priceTone as PriceTone;
|
||||
} else if (typeof prefs.priceTone === 'string' && TONE_VALID.has(prefs.priceTone) && prefs.priceTone !== priceTone.value) {
|
||||
priceTone.value = prefs.priceTone as PriceTone; // 成员已校验
|
||||
saveLocal(STORAGE_KEY, prefs.priceTone);
|
||||
} else if (prefs.priceTone === undefined) {
|
||||
schedulePush('priceTone', priceTone.value);
|
||||
}
|
||||
if (dirty.has('priceAdjust')) {
|
||||
schedulePush('priceAdjust', priceAdjust.value);
|
||||
} else if (typeof prefs.priceAdjust === 'string' && prefs.priceAdjust !== priceAdjust.value) {
|
||||
priceAdjust.value = prefs.priceAdjust as PriceAdjust;
|
||||
} else if (typeof prefs.priceAdjust === 'string' && ADJUST_VALID.has(prefs.priceAdjust) && prefs.priceAdjust !== priceAdjust.value) {
|
||||
priceAdjust.value = prefs.priceAdjust as PriceAdjust; // 成员已校验
|
||||
saveLocal(ADJUST_KEY, prefs.priceAdjust);
|
||||
} else if (prefs.priceAdjust === undefined) {
|
||||
schedulePush('priceAdjust', priceAdjust.value);
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { addWatchlist, getEtfs, removeWatchlist } from '@/api/client';
|
||||
import type { EtfListItem, ScreenerItemOut } from '@/api/types';
|
||||
import { useQuerySync } from '@/composables/useQuerySync';
|
||||
import EtfSyncBar from '@/components/EtfSyncBar.vue';
|
||||
import StockDetailOverlay from '@/components/StockDetailOverlay.vue';
|
||||
|
||||
// ---------- 筛选状态(初始值从路由 query 还原,刷新/分享链接不丢现场) ----------
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
function qStr(key: string): string | undefined {
|
||||
const v = route.query[key];
|
||||
@@ -58,10 +58,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 {
|
||||
@@ -74,13 +78,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;
|
||||
}
|
||||
@@ -169,43 +177,30 @@ 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 (sort.value !== 'total_mv') q.sort = sort.value;
|
||||
if (order.value !== 'desc') 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, 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') : '全部';
|
||||
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) : 'total_mv';
|
||||
order.value = qOf('order') === 'asc' ? 'asc' : 'desc';
|
||||
previewCode.value = qOf('code') || null;
|
||||
// ---------- 路由同步:状态 → ?q/&market/…(骨架在 useQuerySync,replace 不产生历史记录) ----------
|
||||
const { syncRoute } = useQuerySync({
|
||||
sources: [search, market, 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 (sort.value !== 'total_mv') q.sort = sort.value;
|
||||
if (order.value !== 'desc') 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]) : '全部';
|
||||
const p = parseInt(q.page ?? '', 10);
|
||||
page.value = Number.isFinite(p) && p >= 1 ? p : 1;
|
||||
const s = q.sort ?? 'total_mv';
|
||||
sort.value = SORT_KEYS.includes(s as SortKey) ? (s as SortKey) : 'total_mv';
|
||||
order.value = q.order === 'asc' ? 'asc' : 'desc';
|
||||
previewCode.value = q.code || null;
|
||||
},
|
||||
});
|
||||
|
||||
// ---------- 详情浮层开关(写入 ?code=) ----------
|
||||
|
||||
@@ -3,8 +3,9 @@ import { RouterLink } from 'vue-router';
|
||||
|
||||
import MarketOverview from '@/components/MarketOverview.vue';
|
||||
import MarketSyncBar from '@/components/MarketSyncBar.vue';
|
||||
import LimitBoard from '@/components/LimitBoard.vue';
|
||||
|
||||
// 首页:大盘行情总览 + 功能入口(看股 / ETF / 选股 / 回测)
|
||||
// 首页:功能入口置顶(看股 / ETF / 选股 / 回测 / 概念板块)+ 大盘行情总览 + 打板专题
|
||||
const features = [
|
||||
{
|
||||
to: '/stocks',
|
||||
@@ -27,6 +28,13 @@ const features = [
|
||||
title: '选股',
|
||||
desc: '用自然语言描述选股条件,快速完成全市场筛选。',
|
||||
},
|
||||
{
|
||||
to: '/concepts',
|
||||
icon: 'M7 7h.01M7 3h5c1.4 0 2.6.6 3.4 1.6l2 2.8c.6.8.6 2 0 2.8l-2 2.8c-.8 1-2 1.6-3.4 1.6H7c-2.2 0-4-1.8-4-4V7c0-2.2 1.8-4 4-4zM17 21v-4M14 21h6',
|
||||
accent: 'bg-rose-500/15 text-rose-300',
|
||||
title: '概念板块',
|
||||
desc: '同花顺概念/行业分类,看成分股行情并进入个股研究。',
|
||||
},
|
||||
{
|
||||
to: '/backtest',
|
||||
icon: 'M3 17l6-6 4 4 8-8M21 7v6h-6',
|
||||
@@ -39,11 +47,8 @@ const features = [
|
||||
|
||||
<template>
|
||||
<div class="w-full max-w-6xl">
|
||||
<MarketOverview />
|
||||
|
||||
<MarketSyncBar />
|
||||
|
||||
<div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<!-- 功能入口置顶:一屏内即可点进看股/选股等页面 -->
|
||||
<div class="mb-12 grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5">
|
||||
<RouterLink
|
||||
v-for="f in features"
|
||||
:key="f.to"
|
||||
@@ -51,19 +56,25 @@ const features = [
|
||||
class="group flex flex-1 flex-col rounded-lg border border-[#26272E] bg-[#101014] p-6 transition-all hover:-translate-y-1 hover:border-[#3A3D46] hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 focus-visible:ring-offset-black sm:p-7"
|
||||
>
|
||||
<div class="flex items-center gap-4">
|
||||
<span :class="['flex h-14 w-14 shrink-0 items-center justify-center rounded-lg', f.accent]">
|
||||
<svg class="h-6 w-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<span :class="['flex h-12 w-12 shrink-0 items-center justify-center rounded-lg', f.accent]">
|
||||
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path :d="f.icon" />
|
||||
</svg>
|
||||
</span>
|
||||
<div class="text-2xl font-semibold">{{ f.title }}</div>
|
||||
<div class="text-xl font-semibold">{{ f.title }}</div>
|
||||
</div>
|
||||
<p class="mt-4 max-w-sm text-sm leading-6 text-[#A8AFB8]">{{ f.desc }}</p>
|
||||
<div class="mt-auto flex items-center justify-end gap-1.5 pt-5 text-sm font-medium text-blue-600">
|
||||
<p class="mt-3 text-[13px] leading-5 text-[#A8AFB8]">{{ f.desc }}</p>
|
||||
<div class="mt-auto flex items-center justify-end gap-1.5 pt-4 text-sm font-medium text-blue-600">
|
||||
进入
|
||||
<svg class="h-4 w-4 transition-transform group-hover:translate-x-0.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M13 6l6 6-6 6" /></svg>
|
||||
</div>
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<MarketOverview />
|
||||
|
||||
<MarketSyncBar />
|
||||
|
||||
<LimitBoard />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -22,6 +22,7 @@ const code = computed(() => String(route.params.code ?? ''));
|
||||
const detail = ref<IndexDetail | null>(null);
|
||||
const weights = ref<IndexWeights | null>(null);
|
||||
const weightsError = ref('');
|
||||
let weightsToken = 0;
|
||||
const loading = ref(false);
|
||||
const error = ref('');
|
||||
|
||||
@@ -40,11 +41,13 @@ async function loadDetail(c: string) {
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
// 成分权重仅国内指数有数据,且独立加载(失败不阻塞页面)
|
||||
// 成分权重仅国内指数有数据,且独立加载(失败不阻塞页面)。
|
||||
// 带 token 防乱序:快速切换指数时旧请求晚返回不能覆盖新指数的权重表
|
||||
if (detail.value?.region === 'cn') {
|
||||
const wToken = ++weightsToken;
|
||||
getIndexWeights(c, 50)
|
||||
.then((w) => { weights.value = w; })
|
||||
.catch((e) => { weightsError.value = e instanceof Error ? e.message : '获取成分权重失败'; });
|
||||
.then((w) => { if (wToken === weightsToken) weights.value = w; })
|
||||
.catch((e) => { if (wToken === weightsToken) weightsError.value = e instanceof Error ? e.message : '获取成分权重失败'; });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,6 +66,7 @@ const kError = ref<string | null>(null);
|
||||
const lastDate = ref('');
|
||||
let chart: Chart | null = null;
|
||||
let loadToken = 0;
|
||||
let kCtrl: AbortController | null = null;
|
||||
let lastCandles: Candle[] | null = null;
|
||||
|
||||
function rebuild(candles: Candle[]) {
|
||||
@@ -94,16 +98,21 @@ function rebuild(candles: Candle[]) {
|
||||
|
||||
async function loadCandles(c: string, tf: Timeframe) {
|
||||
const token = ++loadToken;
|
||||
kCtrl?.abort(); // 快速切指数/切周期时取消在途请求
|
||||
const ctrl = new AbortController();
|
||||
kCtrl = ctrl;
|
||||
kLoading.value = true;
|
||||
kError.value = null;
|
||||
try {
|
||||
const candles = await getAnyIndexCandles(c, tf);
|
||||
const candles = await getAnyIndexCandles(c, tf, { signal: ctrl.signal });
|
||||
if (token !== loadToken) return;
|
||||
lastCandles = candles;
|
||||
rebuild(candles);
|
||||
lastDate.value = candles.length ? candles[candles.length - 1].ts.slice(0, 10) : '';
|
||||
} catch (e) {
|
||||
if (token === loadToken) kError.value = e instanceof Error ? e.message : '获取指数K线失败';
|
||||
if (token === loadToken && !(e instanceof Error && e.name === 'AbortError')) {
|
||||
kError.value = e instanceof Error ? e.message : '获取指数K线失败';
|
||||
}
|
||||
} finally {
|
||||
if (token === loadToken) kLoading.value = false;
|
||||
}
|
||||
|
||||
@@ -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/…(骨架在 useQuerySync,replace 不产生历史记录) ----------
|
||||
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=) ----------
|
||||
|
||||
Reference in New Issue
Block a user