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,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;
}

View File

@@ -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[];
}
// ---------- 概念板块THSths_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';

View File

@@ -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 高度,不重建(保留滚动/画线状态)

View File

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

View File

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

View File

@@ -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') },

View File

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

View File

@@ -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/…(骨架在 useQuerySyncreplace 不产生历史记录) ----------
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= ----------

View File

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

View File

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

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