From 45836f4a307cb6d9bfbe94988f70a5e207aaaf6a Mon Sep 17 00:00:00 2001 From: Sakurasan <26715255+Sakurasan@users.noreply.github.com> Date: Thu, 6 Aug 2026 17:22:04 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E9=A2=98=E6=9D=90?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2=EF=BC=88=E5=88=97=E8=A1=A8=20+=20=E8=AF=A6?= =?UTF-8?q?=E6=83=85=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 主页增加「题材热点」入口,跳转题材列表页 - 题材列表页:展示全部题材,支持按涨幅/强度/热度/成交额排序 - 题材详情页:简介、热点事件、相关新闻、板块涨跌统计、全部相关股票(含入选理由默认展开) - 后端逆向封装东方财富题材接口 getThemeList/getDetail/getStockList,含交易时段感知缓存 Co-Authored-By: Claude --- backend/main.py | 3 +- backend/routes/themes.py | 53 ++++++ backend/services/themes.py | 223 +++++++++++++++++++++++ src/lib/theme-api.ts | 160 +++++++++++++++++ src/routeTree.gen.ts | 61 ++++++- src/routes/index.tsx | 22 ++- src/routes/theme.$code.tsx | 355 +++++++++++++++++++++++++++++++++++++ src/routes/themes.tsx | 234 ++++++++++++++++++++++++ 8 files changed, 1100 insertions(+), 11 deletions(-) create mode 100644 backend/routes/themes.py create mode 100644 backend/services/themes.py create mode 100644 src/lib/theme-api.ts create mode 100644 src/routes/theme.$code.tsx create mode 100644 src/routes/themes.tsx diff --git a/backend/main.py b/backend/main.py index e75029e..e2598a3 100644 --- a/backend/main.py +++ b/backend/main.py @@ -6,7 +6,7 @@ from contextlib import asynccontextmanager from dotenv import load_dotenv from database import init_db -from routes import stock, collections, shares, sectors +from routes import stock, collections, shares, sectors, themes load_dotenv() @@ -31,6 +31,7 @@ app.include_router(stock.router, prefix="/api/stock") app.include_router(collections.router, prefix="/api/collections") app.include_router(shares.router, prefix="/api/share") app.include_router(sectors.router, prefix="/api/sectors") +app.include_router(themes.router, prefix="/api/themes") # 生产模式:后端同时托管前端静态文件 # catch-all 路由在 API 路由之后注册,所以 API 优先级更高 diff --git a/backend/routes/themes.py b/backend/routes/themes.py new file mode 100644 index 0000000..2731e88 --- /dev/null +++ b/backend/routes/themes.py @@ -0,0 +1,53 @@ +"""题材数据路由:题材列表、题材详情、题材相关股票""" + +from fastapi import APIRouter, Query, HTTPException +from fastapi.responses import JSONResponse +from services import themes + +router = APIRouter() + +# 上游反代/CDN 可能按 path 缓存,显式禁止缓存 +_NO_CACHE_HEADERS = {"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0"} + + +@router.get("", summary="题材列表") +async def theme_list( + sort_field: int = Query(1, ge=1, le=5, description="排序字段:1=涨幅 3=强度 4=热度排名 5=成交额"), + asc: bool = Query(False, description="True=升序, False=降序"), +): + if sort_field not in (1, 3, 4, 5): + raise HTTPException(status_code=400, detail="排序字段仅支持 1/3/4/5") + + data = await themes.fetch_theme_list(sort_field, asc) + return JSONResponse( + {"data": data, "count": len(data), "sort_field": sort_field, "asc": asc}, + 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) + if not data: + return JSONResponse( + {"data": None, "theme_code": theme_code}, + headers=_NO_CACHE_HEADERS, + ) + return JSONResponse( + {"data": data, "theme_code": theme_code}, + headers=_NO_CACHE_HEADERS, + ) + + +@router.get("/{theme_code}/stocks", summary="题材相关股票(全部)") +async def theme_stocks(theme_code: str): + result = await themes.fetch_theme_stocks(theme_code) + return JSONResponse( + { + "data": result.get("stockList", []), + "statistic": result.get("statistic", {}), + "total": result.get("total", 0), + "theme_code": theme_code, + }, + headers=_NO_CACHE_HEADERS, + ) diff --git a/backend/services/themes.py b/backend/services/themes.py new file mode 100644 index 0000000..0eca9fd --- /dev/null +++ b/backend/services/themes.py @@ -0,0 +1,223 @@ +"""东方财富题材数据服务:题材列表、题材详情、题材相关股票 + +逆向自 emrnweb.eastmoney.com/investment 的 H5 接口: +- 题材列表: POST https://emcfgdata.eastmoney.com/api/themeInvest/getThemeList +- 题材详情: GET https://emcfgdata.securities.eastmoney.com/api/themeInvest/getDetail/{themeCode} +- 相关股票: POST https://emcfgdata.eastmoney.com/api/themeInvest/getStockList + +两个 POST 接口需要「移动端包装结构」: + {args:{...业务参数}, appKey, client, clientVersion, clientType, randomCode, timestamp} +""" + +import asyncio +import json +import random +import string +import time +from datetime import datetime, time as dtime, timedelta, timezone +from typing import Optional + +import httpx + +from services.cache import get_cache, set_cache + +# ---- 东方财富移动端配置中心域名 ---- + +_PZ_URL = "https://emcfgdata.eastmoney.com" +_PZ_CDN_URL = "https://emcfgdata.securities.eastmoney.com" + +# appKey 与页面场景对应:题材列表索引页 / 题材详情页 +_APP_KEY_INDEX = "rn-themeIndex" +_APP_KEY_DETAIL = "rn-themeDetail" + +_HEADERS = { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Origin": "https://emrnweb.eastmoney.com", + "Referer": "https://emrnweb.eastmoney.com/", + "Accept": "application/json", + "Accept-Language": "zh-CN,zh;q=0.9", +} + +# ---- 交易时段感知缓存 ---- + +_CST = timezone(timedelta(hours=8)) # 北京时间 +_TRADING_MORNING = (dtime(9, 30), dtime(11, 30)) +_TRADING_AFTERNOON = (dtime(13, 0), dtime(15, 0)) + + +def _is_trading_time() -> bool: + """判断当前是否为 A 股交易时段(周一至周五 9:30-11:30 / 13:00-15:00)""" + now = datetime.now(_CST) + if now.weekday() >= 5: + return False + t = now.time() + return (_TRADING_MORNING[0] <= t <= _TRADING_MORNING[1] + or _TRADING_AFTERNOON[0] <= t <= _TRADING_AFTERNOON[1]) + + +def _dynamic_ttl() -> int: + """盘中返回 2 分钟缓存 TTL,非交易时段 18 小时(覆盖到下一交易日)""" + return 0 if _is_trading_time() else 18 + + +# ---- 请求封装 ---- + +def _build_payload(args: Optional[dict] = None, app_key: str = _APP_KEY_INDEX) -> dict: + """构建东方财富移动端请求包装结构""" + return { + "args": args or {}, + "appKey": app_key, + "client": "iOS", + "clientVersion": "8.3", + "clientType": "cfw", + "randomCode": "".join(random.choices(string.ascii_uppercase + string.ascii_lowercase + string.digits, k=16)), + "timestamp": int(time.time() * 1000), + } + + +async def _post(path: str, args: dict, app_key: str = _APP_KEY_INDEX) -> Optional[dict]: + """POST 到配置中心接口,返回 data 层 JSON""" + payload = _build_payload(args, app_key) + try: + async with httpx.AsyncClient(timeout=15) as client: + resp = await client.post(_PZ_URL + path, json=payload, headers=_HEADERS) + if resp.status_code != 200: + return None + body = resp.json() + except Exception as e: + print(f"[themes] POST {path} 失败: {e}") + return None + if body.get("code") != 0: + print(f"[themes] POST {path} 返回错误: {body.get('message')}") + return None + return body.get("data") + + +async def _get_cdn(path: str, app_key: str = _APP_KEY_DETAIL) -> Optional[dict]: + """GET 到配置中心 CDN 接口(题材详情),data 包装结构放 query 参数""" + payload = _build_payload({}, app_key) + params = {"data": json.dumps(payload, ensure_ascii=False)} + try: + async with httpx.AsyncClient(timeout=15) as client: + resp = await client.get(_PZ_CDN_URL + path, params=params, headers=_HEADERS) + if resp.status_code != 200: + return None + body = resp.json() + except Exception as e: + print(f"[themes] GET {path} 失败: {e}") + return None + if body.get("code") != 0: + print(f"[themes] GET {path} 返回错误: {body.get('message')}") + return None + return body.get("data") + + +# ---- 题材列表 ---- + +# sortField 映射(题材列表页):1=涨幅(bf3) 3=强度(strengthValue) 4=热度排名(hotRank) 5=成交额(fex5) +_LIST_PAGE_SIZE = 500 + + +async def fetch_theme_list(sort_field: int = 1, asc: bool = False) -> list[dict]: + """获取全部题材列表(内部循环分页拉全,约 623 个,最多 2 页) + + Args: + sort_field: 排序字段 1/3/4/5 + asc: True=升序, False=降序 + """ + cache_key = f"theme_list:{sort_field}:{asc}" + cached = get_cache(cache_key) + if cached is not None: + return json.loads(cached) + + sort = 1 if asc else -1 + # hotRank 数值越小越热,"热度降序(最热在前)" 需反转为接口升序 + if sort_field == 4: + sort = -sort + items: list[dict] = [] + page = 1 + total = None + + for _ in range(5): # 安全上限 + data = await _post( + "/api/themeInvest/getThemeList", + {"pageSize": _LIST_PAGE_SIZE, "pageNum": page, "sort": sort, "sortField": sort_field}, + ) + if not data: + break + if total is None: + total = data.get("total", 0) + page_items = data.get("list", []) + if not page_items: + break + items.extend(page_items) + if len(items) >= total: + break + page += 1 + + if items: + ttl = _dynamic_ttl() + if ttl > 0: + set_cache(cache_key, json.dumps(items, ensure_ascii=False), ttl_hours=ttl) + return items + + +# ---- 题材详情 ---- + +async def fetch_theme_detail(theme_code: str) -> Optional[dict]: + """获取题材详情(简介 + 热点事件 + 相关新闻),缓存 1 小时""" + cache_key = f"theme_detail:{theme_code}" + cached = get_cache(cache_key) + if cached is not None: + return json.loads(cached) + + data = await _get_cdn(f"/api/themeInvest/getDetail/{theme_code}", app_key=_APP_KEY_DETAIL) + if not data: + return None + + set_cache(cache_key, json.dumps(data, ensure_ascii=False), ttl_hours=1) + return data + + +# ---- 题材相关股票 ---- + +_STOCK_PAGE_SIZE = 100 + + +async def fetch_theme_stocks(theme_code: str) -> dict: + """获取题材下全部相关股票(分页拉全),返回 {stockList, statistic, total}""" + cache_key = f"theme_stocks:{theme_code}" + cached = get_cache(cache_key) + if cached is not None: + return json.loads(cached) + + stock_list: list[dict] = [] + statistic = {} + total = 0 + page = 1 + + for _ in range(20): # 安全上限 + data = await _post( + "/api/themeInvest/getStockList", + {"themeCode": theme_code, "pageSize": _STOCK_PAGE_SIZE, "pageNum": page, "sort": -1, "sortField": "f3"}, + app_key=_APP_KEY_DETAIL, + ) + if not data: + break + if not statistic and data.get("statistic"): + statistic = data["statistic"] + page_items = data.get("stockList", []) + if not page_items: + break + stock_list.extend(page_items) + total = data.get("total", 0) + if len(stock_list) >= total: + break + page += 1 + + result = {"stockList": stock_list, "statistic": statistic, "total": total} + if stock_list: + ttl = _dynamic_ttl() + if ttl > 0: + set_cache(cache_key, json.dumps(result, ensure_ascii=False), ttl_hours=ttl) + return result diff --git a/src/lib/theme-api.ts b/src/lib/theme-api.ts new file mode 100644 index 0000000..2b3bfc0 --- /dev/null +++ b/src/lib/theme-api.ts @@ -0,0 +1,160 @@ +// 题材数据获取工具:通过 Python 后端代理调用东方财富题材接口 +import { getApiBaseUrl } from "@/lib/api-client"; + +/* ── 题材列表 ── */ + +export interface ThemeItem { + themeCode: string; + themeName: string; + securityName: string; // 领涨股名称 + securityCode: string; // 领涨股代码 + codeWithSuffix: string; + hotRank: number; // 热度排名 + f3: number | null; // 领涨股涨幅 + bf3: number | null; // 题材涨幅 + hotValue: number; // 热度值 + hotValueUpLimit: number; // 热度上限 + strengthValue: number | null; // 强度值 + fex5: number | null; // 成交额 + fex3: number | null; + label: string | null; // 标签(如"超级爆点") +} + +export type ThemeSortField = 1 | 3 | 4 | 5; // 1=涨幅 3=强度 4=热度排名 5=成交额 + +export interface ThemeListResponse { + data: ThemeItem[]; + count: number; + sort_field: number; + asc: boolean; +} + +/** + * 获取全部题材列表 + * @param sortField 1=涨幅 3=强度 4=热度排名 5=成交额 + * @param asc true=升序 + */ +export async function fetchThemes(sortField: ThemeSortField = 1, asc: boolean = false): Promise { + const baseUrl = getApiBaseUrl(); + const url = `${baseUrl}/api/themes?sort_field=${sortField}&asc=${asc}`; + + try { + const resp = await fetch(url, { method: "GET", cache: "no-store" }); + if (!resp.ok) return []; + const result: ThemeListResponse = await resp.json(); + return result.data || []; + } catch (err) { + console.error("[theme-api] 获取题材列表失败:", err); + return []; + } +} + +/* ── 题材详情 ── */ + +export interface ThemeHotEvent { + newsTitle: string | null; + newsSummary: string | null; + newsMediaName: string | null; + newsPublishTimeFormat: string | null; + newsCode: string | null; +} + +export interface ThemeNews { + newsCode: string; + newsTitle: string; + newsMediaName: string; + newsPublishTime: number | null; +} + +export interface ThemeBaseInfo { + themeCode: string; + themeName: string; + introduction: string; + explainImgUrl: string | null; + themeLevel: number; + isShowRank: number; +} + +export interface ThemeDetail { + baseInfo: ThemeBaseInfo; + hotEvent: ThemeHotEvent | null; + eventHistory: ThemeNews[]; + topicId: string | null; +} + +/** + * 获取题材详情(简介 + 热点事件 + 相关新闻) + */ +export async function fetchThemeDetail(themeCode: string): Promise { + const baseUrl = getApiBaseUrl(); + const url = `${baseUrl}/api/themes/${themeCode}/detail`; + + 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 ThemeStockKeyword { + keywordCode: string; + keyword: string; + introduction: string; +} + +export interface ThemeStock { + securityName: string; + securityCode: string; + codeSuffix: string; + f2: number; // 现价 + f3: number; // 涨跌幅% + f5: number; // 成交量 + f6: number; // 成交额 + f8: number; // 换手率% + f20: number; // 总市值 + f21: number; // 流通市值 + f62: number; // 主力净流入 + f100: string; // 所属行业 + f265: string; // 板块代码 + label: string | null; // 涨停标签 + rank: number; + dragonStockLabel: number; + keywordList: ThemeStockKeyword[]; // 入选理由 +} + +export interface ThemeStatistic { + f3: number | null; // 板块涨幅 + f104: number | null; // 上涨家数 + f105: number | null; // 下跌家数 + f106: number | null; // 平盘家数 + fex5: number | null; // 板块成交额 +} + +export interface ThemeStocksResponse { + data: ThemeStock[]; + statistic: ThemeStatistic; + total: number; +} + +/** + * 获取题材下全部相关股票 + */ +export async function fetchThemeStocks(themeCode: string): Promise { + const baseUrl = getApiBaseUrl(); + const url = `${baseUrl}/api/themes/${themeCode}/stocks`; + + try { + const resp = await fetch(url, { method: "GET", cache: "no-store" }); + if (!resp.ok) return null; + return await resp.json(); + } catch (err) { + console.error("[theme-api] 获取题材股票失败:", err); + return null; + } +} diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 059d161..344dfd2 100755 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -9,11 +9,18 @@ // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. import { Route as rootRouteImport } from './routes/__root' +import { Route as ThemesRouteImport } from './routes/themes' import { Route as SectorsRouteImport } from './routes/sectors' import { Route as IndexRouteImport } from './routes/index' +import { Route as ThemeCodeRouteImport } from './routes/theme.$code' import { Route as StockCodeRouteImport } from './routes/stock.$code' import { Route as ShareCodeRouteImport } from './routes/share.$code' +const ThemesRoute = ThemesRouteImport.update({ + id: '/themes', + path: '/themes', + getParentRoute: () => rootRouteImport, +} as any) const SectorsRoute = SectorsRouteImport.update({ id: '/sectors', path: '/sectors', @@ -24,6 +31,11 @@ const IndexRoute = IndexRouteImport.update({ path: '/', getParentRoute: () => rootRouteImport, } as any) +const ThemeCodeRoute = ThemeCodeRouteImport.update({ + id: '/theme/$code', + path: '/theme/$code', + getParentRoute: () => rootRouteImport, +} as any) const StockCodeRoute = StockCodeRouteImport.update({ id: '/stock/$code', path: '/stock/$code', @@ -38,39 +50,73 @@ const ShareCodeRoute = ShareCodeRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof IndexRoute '/sectors': typeof SectorsRoute + '/themes': typeof ThemesRoute '/share/$code': typeof ShareCodeRoute '/stock/$code': typeof StockCodeRoute + '/theme/$code': typeof ThemeCodeRoute } export interface FileRoutesByTo { '/': typeof IndexRoute '/sectors': typeof SectorsRoute + '/themes': typeof ThemesRoute '/share/$code': typeof ShareCodeRoute '/stock/$code': typeof StockCodeRoute + '/theme/$code': typeof ThemeCodeRoute } export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute '/sectors': typeof SectorsRoute + '/themes': typeof ThemesRoute '/share/$code': typeof ShareCodeRoute '/stock/$code': typeof StockCodeRoute + '/theme/$code': typeof ThemeCodeRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: '/' | '/sectors' | '/share/$code' | '/stock/$code' + fullPaths: + | '/' + | '/sectors' + | '/themes' + | '/share/$code' + | '/stock/$code' + | '/theme/$code' fileRoutesByTo: FileRoutesByTo - to: '/' | '/sectors' | '/share/$code' | '/stock/$code' - id: '__root__' | '/' | '/sectors' | '/share/$code' | '/stock/$code' + to: + | '/' + | '/sectors' + | '/themes' + | '/share/$code' + | '/stock/$code' + | '/theme/$code' + id: + | '__root__' + | '/' + | '/sectors' + | '/themes' + | '/share/$code' + | '/stock/$code' + | '/theme/$code' fileRoutesById: FileRoutesById } export interface RootRouteChildren { IndexRoute: typeof IndexRoute SectorsRoute: typeof SectorsRoute + ThemesRoute: typeof ThemesRoute ShareCodeRoute: typeof ShareCodeRoute StockCodeRoute: typeof StockCodeRoute + ThemeCodeRoute: typeof ThemeCodeRoute } declare module '@tanstack/react-router' { interface FileRoutesByPath { + '/themes': { + id: '/themes' + path: '/themes' + fullPath: '/themes' + preLoaderRoute: typeof ThemesRouteImport + parentRoute: typeof rootRouteImport + } '/sectors': { id: '/sectors' path: '/sectors' @@ -85,6 +131,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof IndexRouteImport parentRoute: typeof rootRouteImport } + '/theme/$code': { + id: '/theme/$code' + path: '/theme/$code' + fullPath: '/theme/$code' + preLoaderRoute: typeof ThemeCodeRouteImport + parentRoute: typeof rootRouteImport + } '/stock/$code': { id: '/stock/$code' path: '/stock/$code' @@ -105,8 +158,10 @@ declare module '@tanstack/react-router' { const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, SectorsRoute: SectorsRoute, + ThemesRoute: ThemesRoute, ShareCodeRoute: ShareCodeRoute, StockCodeRoute: StockCodeRoute, + ThemeCodeRoute: ThemeCodeRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/src/routes/index.tsx b/src/routes/index.tsx index d1d6f0f..25d9ea9 100755 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -10,7 +10,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; -import { Search, Plus, Share2, Trash2, TrendingUp, Loader2 } from "lucide-react"; +import { Search, Plus, Share2, Trash2, TrendingUp, Loader2, Flame } from "lucide-react"; import { toast } from "sonner"; export const Route = createFileRoute("/")({ @@ -211,12 +211,20 @@ function Index() { A股走势追踪

创建股票集合,分享历史走势

- - - +
+ + + + + + +
diff --git a/src/routes/theme.$code.tsx b/src/routes/theme.$code.tsx new file mode 100644 index 0000000..4a49d00 --- /dev/null +++ b/src/routes/theme.$code.tsx @@ -0,0 +1,355 @@ +import { createFileRoute, Link } from "@tanstack/react-router"; +import { useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { fetchThemeDetail, fetchThemeStocks, type ThemeStock } from "@/lib/theme-api"; +import { getStockBoard } from "@/lib/stock-api"; +import { formatMoney } from "@/lib/utils"; +import { Card, CardContent } from "@/components/ui/card"; +import { + ArrowLeft, + RefreshCw, + TrendingUp, + TrendingDown, + Flame, + Newspaper, + ChevronDown, + ChevronUp, + Info, +} from "lucide-react"; + +export const Route = createFileRoute("/theme/$code")({ + component: ThemeDetailPage, +}); + +function ThemeDetailPage() { + const { code } = Route.useParams(); + + const detailQ = useQuery({ + queryKey: ["themeDetail", code], + queryFn: () => fetchThemeDetail(code), + staleTime: 60_000, + retry: false, + }); + const stocksQ = useQuery({ + queryKey: ["themeStocks", code], + queryFn: () => fetchThemeStocks(code), + staleTime: 30_000, + retry: false, + }); + + const isLoading = detailQ.isLoading || stocksQ.isLoading; + const isError = detailQ.isError || stocksQ.isError; + const isFetching = detailQ.isFetching || stocksQ.isFetching; + + const detail = detailQ.data; + const stocks = stocksQ.data?.data ?? []; + const statistic = stocksQ.data?.statistic; + const total = stocksQ.data?.total ?? 0; + + const refresh = () => { + detailQ.refetch(); + stocksQ.refetch(); + }; + + const baseInfo = detail?.baseInfo; + const hotEvent = detail?.hotEvent; + const eventHistory = detail?.eventHistory ?? []; + + return ( +
+ {/* ── 顶栏 ── */} +
+
+
+ + + +

{baseInfo?.themeName ?? "题材详情"}

+
+ +
+
+ +
+ {isLoading ? ( +
+
+
+
+
+ ) : isError ? ( +
+

数据加载失败

+ +
+ ) : ( + <> + {/* ── 题材简介 ── */} + {baseInfo?.introduction && ( + + +
+ +

题材简介

+
+

+ {baseInfo.introduction} +

+
+
+ )} + + {/* ── 热点事件 ── */} + {hotEvent?.newsTitle && ( + + +
+ +

热点事件

+ {hotEvent.newsMediaName && ( + + {hotEvent.newsMediaName} + {hotEvent.newsPublishTimeFormat ? ` · ${hotEvent.newsPublishTimeFormat}` : ""} + + )} +
+

{hotEvent.newsTitle}

+ {hotEvent.newsSummary && ( +

+ {hotEvent.newsSummary} +

+ )} +
+
+ )} + + {/* ── 板块统计 ── */} + + + {/* ── 相关新闻(可折叠) ── */} + {eventHistory.length > 0 && } + + {/* ── 相关股票 ── */} +
+
+

相关股票

+ 共 {total} 只 +
+ {stocks.length === 0 ? ( + + + 暂无相关股票 + + + ) : ( +
+ {stocks.map((s) => ( + + ))} +
+ )} +
+ + )} +
+
+ ); +} + +/* ============================================================ + 板块统计条 + ============================================================ */ +function StatBar({ + f3, + up, + down, + flat, + fex5, + total, +}: { + f3: number | null | undefined; + up: number | null | undefined; + down: number | null | undefined; + flat: number | null | undefined; + fex5: number | null | undefined; + total: number; +}) { + const isPos = (f3 ?? 0) >= 0; + return ( + + +
+
+

板块涨幅

+

+ {f3 != null ? `${isPos ? "+" : ""}${f3.toFixed(2)}%` : "--"} +

+
+
+

上涨

+

{up ?? "--"}

+
+
+

下跌

+

{down ?? "--"}

+
+
+

成交额

+

{fex5 != null ? formatMoney(fex5) : "--"}

+
+
+ {(flat != null && flat > 0) && ( +

+ 平盘 {flat} 只 +

+ )} +
+
+ ); +} + +/* ============================================================ + 相关新闻(可折叠) + ============================================================ */ +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())}`; + }; + + return ( + + +
+ +

相关新闻

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

{n.newsTitle}

+

+ {n.newsMediaName} + {n.newsPublishTime ? ` · ${fmtTime(n.newsPublishTime)}` : ""} +

+
+ ))} +
+ {items.length > 2 && ( + + )} +
+
+ ); +} + +/* ============================================================ + 相关股票行 + ============================================================ */ +function ThemeStockRow({ stock }: { stock: ThemeStock }) { + const [showReason, setShowReason] = useState(true); // 入选理由默认展开 + const board = getStockBoard(stock.securityCode); + const isPos = stock.f3 >= 0; + const reasons = stock.keywordList ?? []; + + // 换手率:接口返回放大 100 倍的值(如 3733 = 37.33%) + const turnoverRate = stock.f8 > 100 ? stock.f8 / 100 : stock.f8; + + return ( + + + + {/* 名称 + 现价 + 涨幅 */} +
+
+

{stock.securityName}

+ {board.label && ( + + {board.label} + + )} + {stock.label && ( + + {stock.label} + + )} +
+
+
+

现价

+

{stock.f2.toFixed(2)}

+
+
+

涨跌

+

+ {isPos ? "+" : ""} + {stock.f3.toFixed(2)}% +

+
+
+
+ + {/* 行业 + 换手 + 主力 + 成交额 */} +
+ {stock.f100 && ( + {stock.f100} + )} + 换手 {turnoverRate.toFixed(2)}% + 主力 {formatMoney(stock.f62)} + 成交 {formatMoney(stock.f6)} +
+ + {/* 入选理由 */} + {reasons.length > 0 && ( +
+ + {showReason && ( +

+ {reasons.map((r) => r.introduction).filter(Boolean).join(" ")} +

+ )} +
+ )} +
+
+ + ); +} diff --git a/src/routes/themes.tsx b/src/routes/themes.tsx new file mode 100644 index 0000000..f9d0d5e --- /dev/null +++ b/src/routes/themes.tsx @@ -0,0 +1,234 @@ +import { createFileRoute, Link } from "@tanstack/react-router"; +import { useMemo, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { fetchThemes, type ThemeItem, type ThemeSortField } from "@/lib/theme-api"; +import { getStockBoard } from "@/lib/stock-api"; +import { formatMoney } from "@/lib/utils"; +import { Card, CardContent } from "@/components/ui/card"; +import { + ArrowLeft, + RefreshCw, + ArrowDown, + ArrowUp, + TrendingUp, + TrendingDown, + Flame, +} from "lucide-react"; + +export const Route = createFileRoute("/themes")({ + component: ThemesPage, +}); + +/* ============================================================ + 排序维度(sortField 与后端/东方财富对齐) + ============================================================ */ +const SORTS: { key: ThemeSortField; label: string }[] = [ + { key: 1, label: "涨幅" }, + { key: 3, label: "强度" }, + { key: 4, label: "热度" }, + { key: 5, label: "成交额" }, +]; + +function ThemesPage() { + const [sortField, setSortField] = useState(1); + const [asc, setAsc] = useState(false); // 默认降序 + + const { data: themes, isLoading, isFetching, isError, refetch } = useQuery({ + queryKey: ["themes", sortField, asc], + queryFn: () => fetchThemes(sortField, asc), + staleTime: 30_000, + retry: false, + }); + + const data = themes ?? []; + + const toggleSort = (key: ThemeSortField) => { + if (key === sortField) { + setAsc((v) => !v); + } else { + setSortField(key); + setAsc(false); // 切新维度默认降序 + } + }; + + return ( +
+ {/* ── 顶栏 ── */} +
+
+
+ + + +

题材热点

+
+ +
+
+ + {/* ── 排序切换 + 统计 ── */} +
+

+ 共 {data.length} 个题材 + {isFetching && ( + · 刷新中… + )} +

+
+ {SORTS.map((s) => ( + + ))} +
+
+ + {/* ── 内容区 ── */} +
+ {isLoading ? ( +
+ {Array.from({ length: 18 }).map((_, i) => ( +
+ ))} +
+ ) : isError ? ( +
+

数据加载失败

+ +
+ ) : data.length === 0 ? ( +
暂无题材数据
+ ) : ( +
+ {data.map((item) => ( + + ))} +
+ )} +
+
+ ); +} + +/* ============================================================ + 数值格式化 + ============================================================ */ +function fmt(val: number | null | undefined, digits = 2): string { + if (val == null) return "--"; + return val.toFixed(digits); +} + +/* ============================================================ + 题材卡片 + ============================================================ */ +function ThemeCard({ item }: { item: ThemeItem }) { + const change = item.bf3; + const hotPct = + item.hotValueUpLimit > 0 ? Math.min((item.hotValue / item.hotValueUpLimit) * 100, 100) : 0; + const stockBoard = getStockBoard(item.securityCode); + + return ( + + + + {/* 题材名 + 领涨标签 */} +
+

+ {item.themeName} +

+ {item.label && ( + + {item.label} + + )} +
+ + {/* 热度进度条 */} +
+ +
+
+
+ + {item.hotValue}/{item.hotValueUpLimit} + +
+ + {/* 涨幅 + 成交额 */} +
+ {change != null ? ( + = 0 ? "text-red-500" : "text-green-500" + }`} + > + {change >= 0 ? : } + {change >= 0 ? "+" : ""} + {fmt(change)}% + + ) : ( + -- + )} + + {item.fex5 != null ? formatMoney(item.fex5) : "--"} + +
+ + {/* 分割线 */} +
+ + {/* 领涨股 + 强度 */} +
+
+ 领涨 + {item.securityName || "--"} + {stockBoard.label && ( + + {stockBoard.label} + + )} +
+
+ {item.f3 != null && ( + = 0 ? "text-red-500" : "text-green-500" + }`} + > + {item.f3 >= 0 ? "+" : ""} + {fmt(item.f3)}% + + )} + + 强度 {item.strengthValue ?? "--"} + +
+
+ + + + ); +}