diff --git a/backend/routes/themes.py b/backend/routes/themes.py index 08551c8..145e872 100644 --- a/backend/routes/themes.py +++ b/backend/routes/themes.py @@ -1,4 +1,4 @@ -"""题材数据路由:题材列表、题材详情、题材相关股票""" +"""题材数据路由:题材列表、题材详情、题材相关股票、题材历史""" from fastapi import APIRouter, Query, HTTPException from fastapi.responses import JSONResponse @@ -52,6 +52,55 @@ async def theme_history(date: str = Query(..., description="交易日 YYYY-MM-DD conn.close() +@router.get("/active", summary="活跃题材 + 最近10日涨幅矩阵") +async def active_themes(): + conn = get_connection() + try: + # 最近 10 个有数据的交易日(升序) + rows = conn.execute( + "SELECT DISTINCT trade_date FROM daily_top_themes ORDER BY trade_date DESC LIMIT 10" + ).fetchall() + dates = [r["trade_date"] for r in reversed(rows)] + if not dates: + return JSONResponse({"dates": [], "themes": []}, headers=_NO_CACHE_HEADERS) + + # 窗口内全部题材上榜记录,按天分组排列(当日 rank 即排名) + placeholders = ",".join("?" * len(dates)) + rows = conn.execute( + f"""SELECT trade_date, theme_code, theme_name, bf3, rank FROM daily_top_themes + WHERE trade_date IN ({placeholders}) + ORDER BY trade_date DESC, rank ASC""", + dates, + ).fetchall() + + # 按题材聚合:每日涨幅矩阵 + 上榜次数 + 最近上榜日 + 最佳排名 + theme_map: dict[str, dict] = {} + for r in rows: + code = r["theme_code"] + t = theme_map.setdefault(code, { + "themeCode": code, + "themeName": r["theme_name"], + "dailyGains": {}, + "appearCount": 0, + "lastAppear": None, + "bestRank": None, + }) + t["dailyGains"][r["trade_date"]] = r["bf3"] + t["appearCount"] += 1 + if t["lastAppear"] is None or r["trade_date"] > t["lastAppear"]: + t["lastAppear"] = r["trade_date"] + if t["bestRank"] is None or (r["rank"] or 0) < t["bestRank"]: + t["bestRank"] = r["rank"] or 0 + + themes_list = list(theme_map.values()) + # 稳定排序:先按上榜次数降序,再按最佳排名升序 + themes_list.sort(key=lambda x: x["bestRank"] if x["bestRank"] is not None else 10**9) + themes_list.sort(key=lambda x: -x["appearCount"]) + return JSONResponse({"dates": dates, "themes": themes_list}, headers=_NO_CACHE_HEADERS) + finally: + conn.close() + + @router.get("/{theme_code}/news", summary="题材相关新闻(分页)") async def theme_news( theme_code: str, diff --git a/src/lib/theme-api.ts b/src/lib/theme-api.ts index 88d9b6b..3dd25a3 100644 --- a/src/lib/theme-api.ts +++ b/src/lib/theme-api.ts @@ -3,6 +3,35 @@ import { getApiBaseUrl } from "@/lib/api-client"; /* ── 题材列表 ── */ +export interface ActiveTheme { + themeCode: string; + themeName: string; + dailyGains: Record; // trade_date -> 当日题材涨幅 bf3(可空) + appearCount: number; // 窗口内上榜次数 + lastAppear: string | null; // 最近上榜交易日 + bestRank: number | null; // 窗口内最佳排名(rank 最小值) +} + +export interface ActiveThemesResponse { + dates: string[]; + themes: ActiveTheme[]; +} + +/** + * 获取最近 10 个交易日的活跃题材涨幅矩阵 + */ +export async function fetchActiveThemes(): Promise { + const baseUrl = getApiBaseUrl(); + try { + const resp = await fetch(`${baseUrl}/api/themes/active`, { method: "GET", cache: "no-store" }); + if (!resp.ok) return { dates: [], themes: [] }; + return resp.json(); + } catch (err) { + console.error("[theme-api] 获取活跃题材历史失败:", err); + return { dates: [], themes: [] }; + } +} + export interface ThemeItem { themeCode: string; themeName: string; diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index ef2a2ec..069b5f0 100755 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -10,6 +10,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as ThemesRouteImport } from './routes/themes' +import { Route as ThemeHistoryRouteImport } from './routes/theme-history' import { Route as HotMapRouteImport } from './routes/hot-map' import { Route as CoreStocksRouteImport } from './routes/core-stocks' import { Route as IndexRouteImport } from './routes/index' @@ -22,6 +23,11 @@ const ThemesRoute = ThemesRouteImport.update({ path: '/themes', getParentRoute: () => rootRouteImport, } as any) +const ThemeHistoryRoute = ThemeHistoryRouteImport.update({ + id: '/theme-history', + path: '/theme-history', + getParentRoute: () => rootRouteImport, +} as any) const HotMapRoute = HotMapRouteImport.update({ id: '/hot-map', path: '/hot-map', @@ -57,6 +63,7 @@ export interface FileRoutesByFullPath { '/': typeof IndexRoute '/core-stocks': typeof CoreStocksRoute '/hot-map': typeof HotMapRoute + '/theme-history': typeof ThemeHistoryRoute '/themes': typeof ThemesRoute '/share/$code': typeof ShareCodeRoute '/stock/$code': typeof StockCodeRoute @@ -66,6 +73,7 @@ export interface FileRoutesByTo { '/': typeof IndexRoute '/core-stocks': typeof CoreStocksRoute '/hot-map': typeof HotMapRoute + '/theme-history': typeof ThemeHistoryRoute '/themes': typeof ThemesRoute '/share/$code': typeof ShareCodeRoute '/stock/$code': typeof StockCodeRoute @@ -76,6 +84,7 @@ export interface FileRoutesById { '/': typeof IndexRoute '/core-stocks': typeof CoreStocksRoute '/hot-map': typeof HotMapRoute + '/theme-history': typeof ThemeHistoryRoute '/themes': typeof ThemesRoute '/share/$code': typeof ShareCodeRoute '/stock/$code': typeof StockCodeRoute @@ -87,6 +96,7 @@ export interface FileRouteTypes { | '/' | '/core-stocks' | '/hot-map' + | '/theme-history' | '/themes' | '/share/$code' | '/stock/$code' @@ -96,6 +106,7 @@ export interface FileRouteTypes { | '/' | '/core-stocks' | '/hot-map' + | '/theme-history' | '/themes' | '/share/$code' | '/stock/$code' @@ -105,6 +116,7 @@ export interface FileRouteTypes { | '/' | '/core-stocks' | '/hot-map' + | '/theme-history' | '/themes' | '/share/$code' | '/stock/$code' @@ -115,6 +127,7 @@ export interface RootRouteChildren { IndexRoute: typeof IndexRoute CoreStocksRoute: typeof CoreStocksRoute HotMapRoute: typeof HotMapRoute + ThemeHistoryRoute: typeof ThemeHistoryRoute ThemesRoute: typeof ThemesRoute ShareCodeRoute: typeof ShareCodeRoute StockCodeRoute: typeof StockCodeRoute @@ -130,6 +143,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ThemesRouteImport parentRoute: typeof rootRouteImport } + '/theme-history': { + id: '/theme-history' + path: '/theme-history' + fullPath: '/theme-history' + preLoaderRoute: typeof ThemeHistoryRouteImport + parentRoute: typeof rootRouteImport + } '/hot-map': { id: '/hot-map' path: '/hot-map' @@ -179,6 +199,7 @@ const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, CoreStocksRoute: CoreStocksRoute, HotMapRoute: HotMapRoute, + ThemeHistoryRoute: ThemeHistoryRoute, ThemesRoute: ThemesRoute, ShareCodeRoute: ShareCodeRoute, StockCodeRoute: StockCodeRoute, diff --git a/src/routes/theme-history.tsx b/src/routes/theme-history.tsx new file mode 100644 index 0000000..ee6ba44 --- /dev/null +++ b/src/routes/theme-history.tsx @@ -0,0 +1,110 @@ +import { createFileRoute, Link } from "@tanstack/react-router"; +import { useQuery } from "@tanstack/react-query"; +import { fetchActiveThemes } from "@/lib/theme-api"; +import { ArrowLeft, RefreshCw, Flame } from "lucide-react"; + +export const Route = createFileRoute("/theme-history")({ + component: ThemeHistoryPage, +}); + +/** 格式化涨幅,红涨绿跌 */ +function formatGain(v: number | null | undefined): string { + if (v == null) return "·"; + const s = v >= 0 ? `+${v.toFixed(2)}%` : `${v.toFixed(2)}%`; + return s; +} + +function ThemeHistoryPage() { + const { data, isLoading, isFetching, refetch } = useQuery({ + queryKey: ["themes", "active"], + queryFn: fetchActiveThemes, + staleTime: 60_000, + retry: false, + }); + + const dates = data?.dates ?? []; + const themes = data?.themes ?? []; + + return ( +
+ {/* 顶栏 */} +
+
+
+ + + +

题材热点历史

+
+ +
+
+ +
+

+ 活跃题材(最近 10 个交易日内上榜涨幅前20)· 按上榜次数排序 · 共 {themes.length} 个 +

+ + {isLoading ? ( +
+ ) : dates.length === 0 || themes.length === 0 ? ( +
+ 暂无数据,数据将在每日收盘后自动采集 +
+ ) : ( +
+ + + + + {dates.map((d) => ( + + ))} + + + + + {themes.map((t) => ( + + + {dates.map((d) => { + const g = t.dailyGains[d]; + const cls = g == null ? "text-muted-foreground/40" : g >= 0 ? "text-red-500" : "text-green-500"; + return ( + + ); + })} + + + ))} + +
题材 + {d.slice(5)} + 上榜
+ + {t.themeName} + + + {formatGain(g)} + + + + {t.appearCount} + +
+
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/src/routes/themes.tsx b/src/routes/themes.tsx index 23278fe..0de62d7 100644 --- a/src/routes/themes.tsx +++ b/src/routes/themes.tsx @@ -14,6 +14,7 @@ import { TrendingDown, Flame, Network, + History, } from "lucide-react"; export const Route = createFileRoute("/themes")({ @@ -78,6 +79,13 @@ function ThemesPage() { 热点股 + + + 历史 +