diff --git a/backend/routes/themes.py b/backend/routes/themes.py index eae2a1e..952b67a 100644 --- a/backend/routes/themes.py +++ b/backend/routes/themes.py @@ -52,6 +52,39 @@ async def theme_history(date: str = Query(..., description="交易日 YYYY-MM-DD conn.close() +@router.get("/{theme_code}/news", summary="题材相关新闻(分页)") +async def theme_news( + theme_code: str, + page_num: int = Query(1, ge=1, description="页码"), + page_size: int = Query(10, ge=1, le=50, description="每页条数"), + max_eu_time: str = Query("", description="分页游标(上一页返回的 maxEuTime)"), +): + result = await themes.fetch_theme_news(theme_code, page_num, max_eu_time, page_size) + if result is None: + return JSONResponse( + {"data": None, "theme_code": theme_code}, + headers=_NO_CACHE_HEADERS, + ) + return JSONResponse( + {"data": result, "theme_code": theme_code}, + headers=_NO_CACHE_HEADERS, + ) + + +@router.get("/{theme_code}/quote", summary="单题材实时行情(强度/热度/涨幅)") +async def theme_quote(theme_code: str): + result = await themes.fetch_theme_quote(theme_code) + if result is None: + return JSONResponse( + {"data": None, "theme_code": theme_code}, + headers=_NO_CACHE_HEADERS, + ) + return JSONResponse( + {"data": result, "theme_code": theme_code}, + headers=_NO_CACHE_HEADERS, + ) + + @router.get("/{theme_code}/detail", summary="题材详情") async def theme_detail(theme_code: str): data = await themes.fetch_theme_detail(theme_code) diff --git a/backend/services/themes.py b/backend/services/themes.py index 0f6fc85..df6b8bb 100644 --- a/backend/services/themes.py +++ b/backend/services/themes.py @@ -88,9 +88,14 @@ def _next_open_delta_seconds() -> int: return 0 +# 盘中题材列表短缓存:题材热点页与热点穿透聚合共用同一份缓存,避免重复拉全量列表打东财。 +# 120s 长于热点穿透图缓存(60s),图重建时必然命中且更新频率更低,两页数据更稳。 +_LIST_CACHE_SECONDS = 120 + + def _list_ttl_seconds() -> int: - """题材列表缓存秒数:交易时段 0(不缓存、实时拉取);非交易时段缓存到下次开盘前失效""" - return 0 if _is_trading_time() else _next_open_delta_seconds() + """题材列表缓存秒数:交易时段 120s 短缓存;非交易时段缓存到下次开盘前失效""" + return _LIST_CACHE_SECONDS if _is_trading_time() else _next_open_delta_seconds() # ---- 请求封装 ---- @@ -159,11 +164,10 @@ async def fetch_theme_list(sort_field: int = 1, asc: bool = False) -> list[dict] asc: True=升序, False=降序 """ cache_key = f"theme_list:{sort_field}:{asc}" - # 交易时段强制实时:跳过缓存读取,避免命中非交易时段写入的上个交易日旧数据 - if not _is_trading_time(): - cached = get_cache(cache_key) - if cached is not None: - return json.loads(cached) + # 统一读缓存(盘中 TTL=120s 短缓存,非盘中缓存到下次开盘前失效),避免重复拉全量列表打东财 + cached = get_cache(cache_key) + if cached is not None: + return json.loads(cached) sort = 1 if asc else -1 # hotRank 数值越小越热,"热度降序(最热在前)" 需反转为接口升序 @@ -443,3 +447,56 @@ async def fetch_theme_graph(sort_field: int = 1, top_n: int = 30, limit: int = 1 data = await _build_theme_graph(sort_field, top_n) _set_graph_cache(cache_key, data) return _trim_graph_result(data, limit) + + +# ---- 题材相关新闻(分页) ---- + +_NEWS_PAGE_SIZE = 10 + + +async def fetch_theme_news(theme_code: str, page_num: int = 1, max_eu_time: str = "", page_size: int = _NEWS_PAGE_SIZE) -> Optional[dict]: + """获取题材相关新闻(分页),返回 {total, maxEuTime, list} + + maxEuTime 为游标:上一页返回的 maxEuTime 作为下一页入参,首页传空串。 + 盘中 60s 短缓存(与图缓存同频);非盘中缓存到下次开盘前失效。 + """ + cache_key = f"theme_news:{theme_code}:{page_num}:{max_eu_time}:{page_size}" + cached = get_cache(cache_key) + if cached is not None: + return json.loads(cached) + + data = await _post( + "/api/themeInvest/getThemeRelatedNews", + {"themeCode": theme_code, "pageNum": page_num, "maxEuTime": max_eu_time, "pageSize": page_size}, + app_key=_APP_KEY_DETAIL, + ) + if not data: + return None + + ttl_s = _graph_ttl_seconds() + if ttl_s > 0: + set_cache(cache_key, json.dumps(data, ensure_ascii=False), ttl_seconds=ttl_s) + return data + + +# ---- 单题材实时行情(强度/热度/涨幅) ---- + +async def fetch_theme_quote(theme_code: str) -> Optional[dict]: + """获取单题材实时行情(strengthValue/hotValue/f3),盘中 60s 短缓存""" + cache_key = f"theme_quote:{theme_code}" + cached = get_cache(cache_key) + if cached is not None: + return json.loads(cached) + + data = await _post( + "/api/themeInvest/getSingleThemeQuote", + {"themeCode": theme_code}, + app_key=_APP_KEY_DETAIL, + ) + if not data: + return None + + ttl_s = _graph_ttl_seconds() + if ttl_s > 0: + set_cache(cache_key, json.dumps(data, ensure_ascii=False), ttl_seconds=ttl_s) + return data diff --git a/src/lib/theme-api.ts b/src/lib/theme-api.ts index 6a43dbc..ac44ae6 100644 --- a/src/lib/theme-api.ts +++ b/src/lib/theme-api.ts @@ -159,6 +159,75 @@ export async function fetchThemeStocks(themeCode: string): Promise { + const baseUrl = getApiBaseUrl(); + const url = `${baseUrl}/api/themes/${themeCode}/news?page_num=${pageNum}&page_size=${pageSize}&max_eu_time=${encodeURIComponent(maxEuTime)}`; + + try { + const resp = await fetch(url, { method: "GET", cache: "no-store" }); + if (!resp.ok) return null; + const result = await resp.json(); + return result.data || null; + } catch (err) { + console.error("[theme-api] 获取题材新闻失败:", err); + return null; + } +} + +/* ── 单题材实时行情(强度/热度/涨幅) ── */ + +export interface ThemeQuote { + strengthValue: number | null; + hotValueUpLimit: number; + hotValue: number; + f3: number | null; +} + +/** + * 获取单题材实时行情 + */ +export async function fetchThemeQuote(themeCode: string): Promise { + const baseUrl = getApiBaseUrl(); + const url = `${baseUrl}/api/themes/${themeCode}/quote`; + + try { + const resp = await fetch(url, { method: "GET", cache: "no-store" }); + if (!resp.ok) return null; + const result = await resp.json(); + return result.data || null; + } catch (err) { + console.error("[theme-api] 获取题材行情失败:", err); + return null; + } +} + /* ── 热点穿透:题材-股票 网状关系图 ── */ export interface GraphTheme { diff --git a/src/routes/theme.$code.tsx b/src/routes/theme.$code.tsx index 4a49d00..fda137c 100644 --- a/src/routes/theme.$code.tsx +++ b/src/routes/theme.$code.tsx @@ -1,7 +1,14 @@ import { createFileRoute, Link } from "@tanstack/react-router"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useQuery } from "@tanstack/react-query"; -import { fetchThemeDetail, fetchThemeStocks, type ThemeStock } from "@/lib/theme-api"; +import { + fetchThemeDetail, + fetchThemeNews, + fetchThemeQuote, + fetchThemeStocks, + type ThemeStock, + type ThemeNewsItem, +} from "@/lib/theme-api"; import { getStockBoard } from "@/lib/stock-api"; import { formatMoney } from "@/lib/utils"; import { Card, CardContent } from "@/components/ui/card"; @@ -36,6 +43,27 @@ function ThemeDetailPage() { staleTime: 30_000, retry: false, }); + // 相关新闻:pageNum 偏移分页(东财接口 maxEuTime 是增量游标,翻页靠 pageNum 递增) + const [newsPage, setNewsPage] = useState(1); + const [newsItems, setNewsItems] = useState([]); + const newsQ = useQuery({ + queryKey: ["themeNews", code, newsPage], + queryFn: () => fetchThemeNews(code, newsPage, 10), + staleTime: 60_000, + retry: false, + }); + const quoteQ = useQuery({ + queryKey: ["themeQuote", code], + queryFn: () => fetchThemeQuote(code), + staleTime: 30_000, + retry: false, + }); + + // 分页追加:首页重置列表,翻页拼接 + useEffect(() => { + if (!newsQ.data?.list) return; + setNewsItems((prev) => (newsPage === 1 ? newsQ.data!.list : [...prev, ...newsQ.data!.list])); + }, [newsQ.data, newsPage]); const isLoading = detailQ.isLoading || stocksQ.isLoading; const isError = detailQ.isError || stocksQ.isError; @@ -49,11 +77,13 @@ function ThemeDetailPage() { const refresh = () => { detailQ.refetch(); stocksQ.refetch(); + quoteQ.refetch(); + setNewsPage(1); + newsQ.refetch(); }; const baseInfo = detail?.baseInfo; const hotEvent = detail?.hotEvent; - const eventHistory = detail?.eventHistory ?? []; return (
@@ -139,10 +169,20 @@ function ThemeDetailPage() { flat={statistic?.f106} fex5={statistic?.fex5} total={total} + strength={quoteQ.data?.strengthValue ?? null} + hotValue={quoteQ.data?.hotValue ?? 0} + hotValueUpLimit={quoteQ.data?.hotValueUpLimit ?? 0} /> - {/* ── 相关新闻(可折叠) ── */} - {eventHistory.length > 0 && } + {/* ── 相关新闻(分页加载) ── */} + {newsItems.length > 0 && ( + 1} + onLoadMore={() => setNewsPage((p) => p + 1)} + /> + )} {/* ── 相关股票 ── */}
@@ -181,6 +221,9 @@ function StatBar({ flat, fex5, total, + strength, + hotValue, + hotValueUpLimit, }: { f3: number | null | undefined; up: number | null | undefined; @@ -188,8 +231,13 @@ function StatBar({ flat: number | null | undefined; fex5: number | null | undefined; total: number; + strength: number | null; + hotValue: number; + hotValueUpLimit: number; }) { const isPos = (f3 ?? 0) >= 0; + const hotPct = hotValueUpLimit > 0 ? Math.min((hotValue / hotValueUpLimit) * 100, 100) : 0; + const showQuote = strength != null || hotValueUpLimit > 0; return ( @@ -218,24 +266,49 @@ function StatBar({ 平盘 {flat} 只

)} + {/* 强度 + 热度(来自单题材实时行情接口) */} + {showQuote && ( +
+ {strength != null && ( + + 强度 + {strength} + + )} + {hotValueUpLimit > 0 && ( + + + + + + 热度 {hotValue}/{hotValueUpLimit} + + )} +
+ )}
); } /* ============================================================ - 相关新闻(可折叠) + 相关新闻(分页加载) ============================================================ */ -function NewsList({ items }: { items: { newsTitle: string; newsMediaName: string; newsPublishTime: number | null }[] }) { - const [expanded, setExpanded] = useState(false); - const shown = expanded ? items : items.slice(0, 2); - - const fmtTime = (ts: number | null) => { - if (!ts) return ""; - const d = new Date(ts); - const pad = (n: number) => String(n).padStart(2, "0"); - return `${d.getMonth() + 1}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`; - }; +function NewsList({ + items, + total, + loadingMore, + onLoadMore, +}: { + items: ThemeNewsItem[]; + total: number; + loadingMore: boolean; + onLoadMore: () => void; +}) { + const hasMore = items.length < total; return ( @@ -243,26 +316,28 @@ function NewsList({ items }: { items: { newsTitle: string; newsMediaName: string

相关新闻

- {items.length} 条 + 共 {total} 条
- {shown.map((n, idx) => ( + {items.map((n, idx) => (

{n.newsTitle}

{n.newsMediaName} - {n.newsPublishTime ? ` · ${fmtTime(n.newsPublishTime)}` : ""} + {n.showDateTimeFormat ? ` · ${n.showDateTimeFormat}` : ""} + {n.commentCount > 0 && · {n.commentCount} 评论}

))}
- {items.length > 2 && ( + {hasMore && ( )}