From af636146104f60583ab6fafd116cc7b559511c22 Mon Sep 17 00:00:00 2001 From: shy3130 <415333856@qq.com> Date: Mon, 5 Oct 2026 15:16:16 +0800 Subject: [PATCH] =?UTF-8?q?perf(front,back):=20=E6=9F=A5=E8=AF=A2=E7=BC=93?= =?UTF-8?q?=E5=AD=98=E6=8C=81=E4=B9=85=E5=8C=96=20+=20staleTime=20?= =?UTF-8?q?=E5=88=86=E5=B1=82=20=E2=80=94=20=E9=87=8D=E5=BC=80=E5=BA=94?= =?UTF-8?q?=E7=94=A8=E7=A7=92=E5=87=BA=E4=B8=8A=E6=AC=A1=E6=95=B0=E6=8D=AE?= =?UTF-8?q?,=20=E4=B8=8D=E5=86=8D=E5=85=A8=E9=87=8F=E9=87=8D=E6=8B=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 lib/queryPersist.ts: PersistQueryClientProvider 接入 localStorage 持久化, 白名单 13 个慢变查询族 (设置/偏好/能力矩阵/自选清单与分组/策略池/regime 全家/自定义信号), 键首段精确匹配不误伤 watchlist-enriched 等实时行情族; 24h maxAge + 版本 buster; 快变数据 (实时价/分钟K/告警) 刻意不持久化, 避免陈旧价格误导 - staleTime 分层: settings/preferences 30s, capability-matrix 5min, 自选清单 30s, 策略池 60s, 自定义信号 60s — 切页不再对慢变数据反复请求 - regime_builder.load_regime_history 加 (path,mtime,size) 签名缓存: Regime 页 三个端点原先每请求各自全量重读同一 parquet, 现共享一份, 写入后 mtime 失效 验证: pnpm build 通过; 浏览器实测持久化恢复路径 (注入缓存刷新后守卫直接放行, 不再整屏 loading); regime 相关 pytest 22 passed; ruff 零新增; git diff --check 干净 --- backend/app/services/regime_builder.py | 22 +++++++++--- frontend/package.json | 2 ++ frontend/pnpm-lock.yaml | 38 ++++++++++++++++++++ frontend/src/lib/queryPersist.ts | 49 ++++++++++++++++++++++++++ frontend/src/lib/useSharedQueries.ts | 4 +++ frontend/src/main.tsx | 17 +++++++-- frontend/src/pages/AbnormalMoves.tsx | 1 + frontend/src/pages/Screener.tsx | 2 ++ frontend/src/pages/Signals.tsx | 4 +-- frontend/src/pages/Watchlist.tsx | 1 + 10 files changed, 131 insertions(+), 9 deletions(-) create mode 100644 frontend/src/lib/queryPersist.ts diff --git a/backend/app/services/regime_builder.py b/backend/app/services/regime_builder.py index 322094db..5fc21b86 100644 --- a/backend/app/services/regime_builder.py +++ b/backend/app/services/regime_builder.py @@ -512,13 +512,27 @@ def regime_path(data_dir: Path) -> Path: return data_dir / REGIME_DIR / "part.parquet" +# (path, mtime_ns, size) 签名缓存 — regime 三个端点每请求各读一次全量 parquet, +# 重算/导入改变文件后 mtime 变化自动失效; Polars 帧由调用方只读 (操作均为函数式) +_REGIME_HISTORY_CACHE: pl.DataFrame | None = None +_REGIME_HISTORY_SIG: tuple[str, int, int] | None = None + + def load_regime_history(data_dir: Path) -> pl.DataFrame: - """读取全部 regime 时序; 不存在返回空 DataFrame。""" + """读取全部 regime 时序; 不存在返回空 DataFrame (带 mtime 签名缓存)。""" + global _REGIME_HISTORY_CACHE, _REGIME_HISTORY_SIG p = regime_path(data_dir) - if not p.exists(): - return pl.DataFrame() try: - return pl.read_parquet(p) + sig = (str(p), p.stat().st_mtime_ns, p.stat().st_size) + except OSError: + return pl.DataFrame() + if _REGIME_HISTORY_CACHE is not None and sig == _REGIME_HISTORY_SIG: + return _REGIME_HISTORY_CACHE + try: + df = pl.read_parquet(p) + _REGIME_HISTORY_CACHE = df + _REGIME_HISTORY_SIG = sig + return df except Exception as e: # noqa: BLE001 logger.warning("load_regime_history failed: %s", e) return pl.DataFrame() diff --git a/frontend/package.json b/frontend/package.json index 04666b56..355ec3fb 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -16,7 +16,9 @@ "@dnd-kit/utilities": "^3.2.2", "@fontsource/inter": "^5.3.0", "@fontsource/jetbrains-mono": "^5.3.0", + "@tanstack/query-sync-storage-persister": "^5.104.1", "@tanstack/react-query": "^5.55.0", + "@tanstack/react-query-persist-client": "^5.104.1", "@tanstack/react-virtual": "^3.13.12", "clsx": "^2.1.1", "echarts": "^5.5.0", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 67dcabb8..907fb257 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -23,9 +23,15 @@ importers: '@fontsource/jetbrains-mono': specifier: ^5.3.0 version: 5.3.0 + '@tanstack/query-sync-storage-persister': + specifier: ^5.104.1 + version: 5.104.1 '@tanstack/react-query': specifier: ^5.55.0 version: 5.100.11(react@18.3.1) + '@tanstack/react-query-persist-client': + specifier: ^5.104.1 + version: 5.104.1(@tanstack/react-query@5.100.11(react@18.3.1))(react@18.3.1) '@tanstack/react-virtual': specifier: ^3.13.12 version: 3.14.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -559,6 +565,21 @@ packages: '@tanstack/query-core@5.100.11': resolution: {integrity: sha512-lmE0994apShXPj8CUxgx4ch5yUJhE9k/+tVwihBvPOyerACWdBocfFg24t8+0RhtlTd7tEgchDkhlCxNssvDxw==} + '@tanstack/query-core@5.104.1': + resolution: {integrity: sha512-TiRghcrGkUTE+M4JBxvpPSpBhxvIzQairw+cksrHYGr+2uwC77yW1SY9nYxnYRYOppvucf8rEg6b5byoz4rTpw==} + + '@tanstack/query-persist-client-core@5.104.1': + resolution: {integrity: sha512-ONiadXqe56iNrw6RESqKILpTIVG9ZMI/DageTQnrBDt/nZ6WiP4G13iIiMwJEsiO12gQ3Gychx8tcFIPw4k25g==} + + '@tanstack/query-sync-storage-persister@5.104.1': + resolution: {integrity: sha512-eDsBMNds48+sjXILn0dQKu+uYwfh9tGIB+Rb0cKRdoxD22HHBMzJes5DJOhJVLUhBQbxtoFq+Hpha/Vohh14NQ==} + + '@tanstack/react-query-persist-client@5.104.1': + resolution: {integrity: sha512-kU3YaqjkwEUzUUfPF0JVg56d6gJKWHaFVqOlQztwc4C2IT0P5sv/23orrSoQqjxKaUe6Ejw5IIQtA/G12uNmow==} + peerDependencies: + '@tanstack/react-query': ^5.104.1 + react: ^18 || ^19 + '@tanstack/react-query@5.100.11': resolution: {integrity: sha512-J0f9s5x3LE1450nNNfYx+e/n0DMa0uOBdFJUy5r0RvmsXd4nB/n0rbHtHI1vYXhikNFan+wf51p6Tmp4c8ucrg==} peerDependencies: @@ -1765,6 +1786,23 @@ snapshots: '@tanstack/query-core@5.100.11': {} + '@tanstack/query-core@5.104.1': {} + + '@tanstack/query-persist-client-core@5.104.1': + dependencies: + '@tanstack/query-core': 5.104.1 + + '@tanstack/query-sync-storage-persister@5.104.1': + dependencies: + '@tanstack/query-core': 5.104.1 + '@tanstack/query-persist-client-core': 5.104.1 + + '@tanstack/react-query-persist-client@5.104.1(@tanstack/react-query@5.100.11(react@18.3.1))(react@18.3.1)': + dependencies: + '@tanstack/query-persist-client-core': 5.104.1 + '@tanstack/react-query': 5.100.11(react@18.3.1) + react: 18.3.1 + '@tanstack/react-query@5.100.11(react@18.3.1)': dependencies: '@tanstack/query-core': 5.100.11 diff --git a/frontend/src/lib/queryPersist.ts b/frontend/src/lib/queryPersist.ts new file mode 100644 index 00000000..02856ecb --- /dev/null +++ b/frontend/src/lib/queryPersist.ts @@ -0,0 +1,49 @@ +/** + * 查询缓存持久化 — 解决「每次打开应用都全量重新拉数据、等很久」。 + * + * 仅白名单内的慢变查询族落地 localStorage: 重开应用时先渲染上一次的数据 + * (SWR: 挂载后仍按各自 staleTime 后台刷新), 秒开有内容。快变行情类 + * (watchlist-enriched 实时价 / 分钟K / 告警 / overview-market 等) 不持久化, + * 避免陈旧价格误导。白名单外的查询仍是纯内存缓存, 行为与原先完全一致。 + * + * 键首段精确匹配 (不是前缀匹配): 'watchlist' 不会误命中 'watchlist-enriched'。 + */ +import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister' + +/** 持久化结构版本: 序列化结构变化时递增, 旧缓存整体作废 */ +export const PERSIST_BUSTER = 'v1' + +/** 允许持久化的查询键首段 (对齐 lib/queryKeys.ts 各工厂首段与个别内联键) */ +const PERSISTABLE_KEY_ROOTS = new Set([ + 'settings', // 应用设置 (OnboardingGuard 依赖; 恢复后打开不再整屏 logo) + 'preferences', // 用户偏好 + 'capability-matrix', // 能力路由矩阵 (慢变) + 'watchlist', // 自选清单 (不含 enriched/quotes 等行情族) + 'watchlist-groups', + 'screener-strategies', // 策略池清单 + 'regime-history', // 以下 regime 族一天最多变一次 + 'regime-states', + 'regime-coverage', + 'regime-phases', + 'regime-mainline', + 'custom-signals', // 自定义信号库 + 'custom-signals-options', +]) + +function isPersistableQueryKey(key: readonly unknown[]): boolean { + return typeof key[0] === 'string' && PERSISTABLE_KEY_ROOTS.has(key[0]) +} + +/** dehydrate 过滤: 只落地白名单族的成功查询 (默认规则已排除 pending/error) */ +export function shouldPersistQuery(query: { queryKey: readonly unknown[]; state: { status: string } }): boolean { + return query.state.status === 'success' && isPersistableQueryKey(query.queryKey) +} + +/** localStorage 同步 persister (throttle 2s 合并高频写) */ +export function createAppPersister() { + return createSyncStoragePersister({ + storage: window.localStorage, + key: 'tf-query-cache', + throttleTime: 2_000, + }) +} diff --git a/frontend/src/lib/useSharedQueries.ts b/frontend/src/lib/useSharedQueries.ts index 9734224e..34e7dce4 100644 --- a/frontend/src/lib/useSharedQueries.ts +++ b/frontend/src/lib/useSharedQueries.ts @@ -28,6 +28,8 @@ export function useCapabilityMatrix() { return useQuery({ queryKey: QK.capabilityMatrix, queryFn: api.capabilityMatrix, + // 矩阵仅在路由偏好/插件装卸时变化 (设置页会失效): 5 分钟新鲜度减少切页重拉 + staleTime: 5 * 60_000, }) } @@ -36,6 +38,7 @@ export function useSettings() { return useQuery({ queryKey: QK.settings, queryFn: api.settings, + staleTime: 30_000, }) } @@ -44,6 +47,7 @@ export function usePreferences() { return useQuery({ queryKey: QK.preferences, queryFn: api.preferences, + staleTime: 30_000, }) } diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 304b5605..996d386b 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,8 +1,10 @@ import React from 'react' import ReactDOM from 'react-dom/client' import { RouterProvider } from 'react-router-dom' -import { QueryClient, QueryClientProvider, QueryCache } from '@tanstack/react-query' +import { QueryClient, QueryCache } from '@tanstack/react-query' +import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client' import { initializeFrontendExtensions } from './extensions/bootstrap' +import { createAppPersister, shouldPersistQuery, PERSIST_BUSTER } from './lib/queryPersist' // 字体自托管 (@fontsource): 替代 rsms.me / Google Fonts 渲染阻塞外链, // 内网/离线部署不再白屏等字体。权重覆盖 tailwind 全部用量 (300-900)。 import '@fontsource/inter/300.css' @@ -59,9 +61,18 @@ async function bootstrap() { const { router } = await import('./router') ReactDOM.createRoot(document.getElementById('root')!).render( - + - + , ) } diff --git a/frontend/src/pages/AbnormalMoves.tsx b/frontend/src/pages/AbnormalMoves.tsx index 5393ce16..f767cd3e 100644 --- a/frontend/src/pages/AbnormalMoves.tsx +++ b/frontend/src/pages/AbnormalMoves.tsx @@ -644,6 +644,7 @@ function DeviationView({ onPreview }: { queryKey: QK.watchlist, queryFn: api.watchlistList, enabled: watchlistOnly, + staleTime: 30_000, }) const toggleEnabled = (v: boolean) => { diff --git a/frontend/src/pages/Screener.tsx b/frontend/src/pages/Screener.tsx index 7f25ca05..79eabd0e 100644 --- a/frontend/src/pages/Screener.tsx +++ b/frontend/src/pages/Screener.tsx @@ -156,6 +156,7 @@ export function Screener() { const strategies = useQuery({ queryKey: [...QK.screenerStrategies('all'), 'all'], queryFn: () => api.screenerStrategies(undefined, 'all'), + staleTime: 60_000, // 策略池清单慢变 (与 Paper 同款), 减少切页重拉 }) // 激活策略自身的执行周期 (决定走缓存还是分钟实时跑)。 @@ -646,6 +647,7 @@ export function Screener() { const watchlist = useQuery({ queryKey: QK.watchlist, queryFn: api.watchlistList, + staleTime: 30_000, }) const watchlistSet = useMemo(() => { const symbols = watchlist.data?.symbols ?? [] diff --git a/frontend/src/pages/Signals.tsx b/frontend/src/pages/Signals.tsx index eb075a82..945c6691 100644 --- a/frontend/src/pages/Signals.tsx +++ b/frontend/src/pages/Signals.tsx @@ -38,8 +38,8 @@ export function Signals() { function SignalsBody({ highlight }: { highlight: string }) { const qc = useQueryClient() - const list = useQuery({ queryKey: QK.customSignals, queryFn: api.customSignalsList }) - const options = useQuery({ queryKey: QK.customSignalsOptions, queryFn: api.customSignalsOptions }) + const list = useQuery({ queryKey: QK.customSignals, queryFn: api.customSignalsList, staleTime: 60_000 }) + const options = useQuery({ queryKey: QK.customSignalsOptions, queryFn: api.customSignalsOptions, staleTime: 60_000 }) const [activeSection, setActiveSection] = useState('custom') const [showForm, setShowForm] = useState(false) diff --git a/frontend/src/pages/Watchlist.tsx b/frontend/src/pages/Watchlist.tsx index aed158c8..8b5c7891 100644 --- a/frontend/src/pages/Watchlist.tsx +++ b/frontend/src/pages/Watchlist.tsx @@ -785,6 +785,7 @@ export function Watchlist() { const list = useQuery({ queryKey: QK.watchlist, queryFn: api.watchlistList, + staleTime: 30_000, // 清单慢变 (增删后 mutation 会失效), 减少切页重拉 }) const groupList = useQuery({