feat: 新增题材页面(列表 + 详情)
- 主页增加「题材热点」入口,跳转题材列表页 - 题材列表页:展示全部题材,支持按涨幅/强度/热度/成交额排序 - 题材详情页:简介、热点事件、相关新闻、板块涨跌统计、全部相关股票(含入选理由默认展开) - 后端逆向封装东方财富题材接口 getThemeList/getDetail/getStockList,含交易时段感知缓存 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
+2
-1
@@ -6,7 +6,7 @@ from contextlib import asynccontextmanager
|
|||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
from database import init_db
|
from database import init_db
|
||||||
from routes import stock, collections, shares, sectors
|
from routes import stock, collections, shares, sectors, themes
|
||||||
|
|
||||||
load_dotenv()
|
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(collections.router, prefix="/api/collections")
|
||||||
app.include_router(shares.router, prefix="/api/share")
|
app.include_router(shares.router, prefix="/api/share")
|
||||||
app.include_router(sectors.router, prefix="/api/sectors")
|
app.include_router(sectors.router, prefix="/api/sectors")
|
||||||
|
app.include_router(themes.router, prefix="/api/themes")
|
||||||
|
|
||||||
# 生产模式:后端同时托管前端静态文件
|
# 生产模式:后端同时托管前端静态文件
|
||||||
# catch-all 路由在 API 路由之后注册,所以 API 优先级更高
|
# catch-all 路由在 API 路由之后注册,所以 API 优先级更高
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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
|
||||||
@@ -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<ThemeItem[]> {
|
||||||
|
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<ThemeDetail | null> {
|
||||||
|
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<ThemeStocksResponse | null> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+58
-3
@@ -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.
|
// 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 rootRouteImport } from './routes/__root'
|
||||||
|
import { Route as ThemesRouteImport } from './routes/themes'
|
||||||
import { Route as SectorsRouteImport } from './routes/sectors'
|
import { Route as SectorsRouteImport } from './routes/sectors'
|
||||||
import { Route as IndexRouteImport } from './routes/index'
|
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 StockCodeRouteImport } from './routes/stock.$code'
|
||||||
import { Route as ShareCodeRouteImport } from './routes/share.$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({
|
const SectorsRoute = SectorsRouteImport.update({
|
||||||
id: '/sectors',
|
id: '/sectors',
|
||||||
path: '/sectors',
|
path: '/sectors',
|
||||||
@@ -24,6 +31,11 @@ const IndexRoute = IndexRouteImport.update({
|
|||||||
path: '/',
|
path: '/',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const ThemeCodeRoute = ThemeCodeRouteImport.update({
|
||||||
|
id: '/theme/$code',
|
||||||
|
path: '/theme/$code',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const StockCodeRoute = StockCodeRouteImport.update({
|
const StockCodeRoute = StockCodeRouteImport.update({
|
||||||
id: '/stock/$code',
|
id: '/stock/$code',
|
||||||
path: '/stock/$code',
|
path: '/stock/$code',
|
||||||
@@ -38,39 +50,73 @@ const ShareCodeRoute = ShareCodeRouteImport.update({
|
|||||||
export interface FileRoutesByFullPath {
|
export interface FileRoutesByFullPath {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/sectors': typeof SectorsRoute
|
'/sectors': typeof SectorsRoute
|
||||||
|
'/themes': typeof ThemesRoute
|
||||||
'/share/$code': typeof ShareCodeRoute
|
'/share/$code': typeof ShareCodeRoute
|
||||||
'/stock/$code': typeof StockCodeRoute
|
'/stock/$code': typeof StockCodeRoute
|
||||||
|
'/theme/$code': typeof ThemeCodeRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/sectors': typeof SectorsRoute
|
'/sectors': typeof SectorsRoute
|
||||||
|
'/themes': typeof ThemesRoute
|
||||||
'/share/$code': typeof ShareCodeRoute
|
'/share/$code': typeof ShareCodeRoute
|
||||||
'/stock/$code': typeof StockCodeRoute
|
'/stock/$code': typeof StockCodeRoute
|
||||||
|
'/theme/$code': typeof ThemeCodeRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesById {
|
export interface FileRoutesById {
|
||||||
__root__: typeof rootRouteImport
|
__root__: typeof rootRouteImport
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/sectors': typeof SectorsRoute
|
'/sectors': typeof SectorsRoute
|
||||||
|
'/themes': typeof ThemesRoute
|
||||||
'/share/$code': typeof ShareCodeRoute
|
'/share/$code': typeof ShareCodeRoute
|
||||||
'/stock/$code': typeof StockCodeRoute
|
'/stock/$code': typeof StockCodeRoute
|
||||||
|
'/theme/$code': typeof ThemeCodeRoute
|
||||||
}
|
}
|
||||||
export interface FileRouteTypes {
|
export interface FileRouteTypes {
|
||||||
fileRoutesByFullPath: FileRoutesByFullPath
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
fullPaths: '/' | '/sectors' | '/share/$code' | '/stock/$code'
|
fullPaths:
|
||||||
|
| '/'
|
||||||
|
| '/sectors'
|
||||||
|
| '/themes'
|
||||||
|
| '/share/$code'
|
||||||
|
| '/stock/$code'
|
||||||
|
| '/theme/$code'
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to: '/' | '/sectors' | '/share/$code' | '/stock/$code'
|
to:
|
||||||
id: '__root__' | '/' | '/sectors' | '/share/$code' | '/stock/$code'
|
| '/'
|
||||||
|
| '/sectors'
|
||||||
|
| '/themes'
|
||||||
|
| '/share/$code'
|
||||||
|
| '/stock/$code'
|
||||||
|
| '/theme/$code'
|
||||||
|
id:
|
||||||
|
| '__root__'
|
||||||
|
| '/'
|
||||||
|
| '/sectors'
|
||||||
|
| '/themes'
|
||||||
|
| '/share/$code'
|
||||||
|
| '/stock/$code'
|
||||||
|
| '/theme/$code'
|
||||||
fileRoutesById: FileRoutesById
|
fileRoutesById: FileRoutesById
|
||||||
}
|
}
|
||||||
export interface RootRouteChildren {
|
export interface RootRouteChildren {
|
||||||
IndexRoute: typeof IndexRoute
|
IndexRoute: typeof IndexRoute
|
||||||
SectorsRoute: typeof SectorsRoute
|
SectorsRoute: typeof SectorsRoute
|
||||||
|
ThemesRoute: typeof ThemesRoute
|
||||||
ShareCodeRoute: typeof ShareCodeRoute
|
ShareCodeRoute: typeof ShareCodeRoute
|
||||||
StockCodeRoute: typeof StockCodeRoute
|
StockCodeRoute: typeof StockCodeRoute
|
||||||
|
ThemeCodeRoute: typeof ThemeCodeRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module '@tanstack/react-router' {
|
declare module '@tanstack/react-router' {
|
||||||
interface FileRoutesByPath {
|
interface FileRoutesByPath {
|
||||||
|
'/themes': {
|
||||||
|
id: '/themes'
|
||||||
|
path: '/themes'
|
||||||
|
fullPath: '/themes'
|
||||||
|
preLoaderRoute: typeof ThemesRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/sectors': {
|
'/sectors': {
|
||||||
id: '/sectors'
|
id: '/sectors'
|
||||||
path: '/sectors'
|
path: '/sectors'
|
||||||
@@ -85,6 +131,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof IndexRouteImport
|
preLoaderRoute: typeof IndexRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/theme/$code': {
|
||||||
|
id: '/theme/$code'
|
||||||
|
path: '/theme/$code'
|
||||||
|
fullPath: '/theme/$code'
|
||||||
|
preLoaderRoute: typeof ThemeCodeRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/stock/$code': {
|
'/stock/$code': {
|
||||||
id: '/stock/$code'
|
id: '/stock/$code'
|
||||||
path: '/stock/$code'
|
path: '/stock/$code'
|
||||||
@@ -105,8 +158,10 @@ declare module '@tanstack/react-router' {
|
|||||||
const rootRouteChildren: RootRouteChildren = {
|
const rootRouteChildren: RootRouteChildren = {
|
||||||
IndexRoute: IndexRoute,
|
IndexRoute: IndexRoute,
|
||||||
SectorsRoute: SectorsRoute,
|
SectorsRoute: SectorsRoute,
|
||||||
|
ThemesRoute: ThemesRoute,
|
||||||
ShareCodeRoute: ShareCodeRoute,
|
ShareCodeRoute: ShareCodeRoute,
|
||||||
StockCodeRoute: StockCodeRoute,
|
StockCodeRoute: StockCodeRoute,
|
||||||
|
ThemeCodeRoute: ThemeCodeRoute,
|
||||||
}
|
}
|
||||||
export const routeTree = rootRouteImport
|
export const routeTree = rootRouteImport
|
||||||
._addFileChildren(rootRouteChildren)
|
._addFileChildren(rootRouteChildren)
|
||||||
|
|||||||
+15
-7
@@ -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 { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
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";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
export const Route = createFileRoute("/")({
|
export const Route = createFileRoute("/")({
|
||||||
@@ -211,12 +211,20 @@ function Index() {
|
|||||||
A股走势追踪
|
A股走势追踪
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm md:text-base text-muted-foreground">创建股票集合,分享历史走势</p>
|
<p className="text-sm md:text-base text-muted-foreground">创建股票集合,分享历史走势</p>
|
||||||
<Link to="/sectors">
|
<div className="mt-3 flex items-center justify-center gap-2">
|
||||||
<Button variant="outline" size="sm" className="mt-3 gap-1.5 text-xs">
|
<Link to="/sectors">
|
||||||
<TrendingUp className="h-3.5 w-3.5" />
|
<Button variant="outline" size="sm" className="gap-1.5 text-xs">
|
||||||
板块资金流向
|
<TrendingUp className="h-3.5 w-3.5" />
|
||||||
</Button>
|
板块资金流向
|
||||||
</Link>
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<Link to="/themes">
|
||||||
|
<Button variant="outline" size="sm" className="gap-1.5 text-xs">
|
||||||
|
<Flame className="h-3.5 w-3.5" />
|
||||||
|
题材热点
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card className="mb-6 md:mb-8 shadow-lg">
|
<Card className="mb-6 md:mb-8 shadow-lg">
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div className="min-h-screen bg-background">
|
||||||
|
{/* ── 顶栏 ── */}
|
||||||
|
<header className="sticky top-0 z-10 bg-background/95 backdrop-blur border-b">
|
||||||
|
<div className="max-w-3xl mx-auto px-4 h-12 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
<Link to="/themes" className="hover:opacity-70 transition-opacity shrink-0">
|
||||||
|
<ArrowLeft className="h-5 w-5" />
|
||||||
|
</Link>
|
||||||
|
<h1 className="text-base font-semibold truncate">{baseInfo?.themeName ?? "题材详情"}</h1>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={refresh}
|
||||||
|
className="text-muted-foreground hover:text-foreground transition-colors shrink-0"
|
||||||
|
title="刷新"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`h-4 w-4 ${isFetching ? "animate-spin" : ""}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="max-w-3xl mx-auto px-4 py-4 pb-10 space-y-4">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="animate-pulse rounded-xl bg-muted h-32" />
|
||||||
|
<div className="animate-pulse rounded-xl bg-muted h-24" />
|
||||||
|
<div className="animate-pulse rounded-xl bg-muted h-64" />
|
||||||
|
</div>
|
||||||
|
) : isError ? (
|
||||||
|
<div className="flex flex-col items-center gap-3 py-20">
|
||||||
|
<p className="text-sm text-muted-foreground">数据加载失败</p>
|
||||||
|
<button onClick={refresh} className="text-xs text-primary hover:underline">
|
||||||
|
点击重试
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* ── 题材简介 ── */}
|
||||||
|
{baseInfo?.introduction && (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-1.5 mb-2">
|
||||||
|
<Info className="h-4 w-4 text-primary" />
|
||||||
|
<h2 className="text-sm font-semibold">题材简介</h2>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||||
|
{baseInfo.introduction}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── 热点事件 ── */}
|
||||||
|
{hotEvent?.newsTitle && (
|
||||||
|
<Card className="border-orange-500/30">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-1.5 mb-2">
|
||||||
|
<Flame className="h-4 w-4 text-orange-500" />
|
||||||
|
<h2 className="text-sm font-semibold">热点事件</h2>
|
||||||
|
{hotEvent.newsMediaName && (
|
||||||
|
<span className="text-[10px] text-muted-foreground ml-auto shrink-0">
|
||||||
|
{hotEvent.newsMediaName}
|
||||||
|
{hotEvent.newsPublishTimeFormat ? ` · ${hotEvent.newsPublishTimeFormat}` : ""}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-medium leading-snug">{hotEvent.newsTitle}</p>
|
||||||
|
{hotEvent.newsSummary && (
|
||||||
|
<p className="text-xs text-muted-foreground leading-relaxed mt-1.5 line-clamp-3">
|
||||||
|
{hotEvent.newsSummary}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── 板块统计 ── */}
|
||||||
|
<StatBar
|
||||||
|
f3={statistic?.f3}
|
||||||
|
up={statistic?.f104}
|
||||||
|
down={statistic?.f105}
|
||||||
|
flat={statistic?.f106}
|
||||||
|
fex5={statistic?.fex5}
|
||||||
|
total={total}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* ── 相关新闻(可折叠) ── */}
|
||||||
|
{eventHistory.length > 0 && <NewsList items={eventHistory} />}
|
||||||
|
|
||||||
|
{/* ── 相关股票 ── */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-2 px-1">
|
||||||
|
<h2 className="text-sm font-semibold">相关股票</h2>
|
||||||
|
<span className="text-[10px] text-muted-foreground">共 {total} 只</span>
|
||||||
|
</div>
|
||||||
|
{stocks.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-6 text-center text-sm text-muted-foreground">
|
||||||
|
暂无相关股票
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{stocks.map((s) => (
|
||||||
|
<ThemeStockRow key={s.securityCode} stock={s} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
板块统计条
|
||||||
|
============================================================ */
|
||||||
|
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 (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-3">
|
||||||
|
<div className="grid grid-cols-4 divide-x divide-border/50 text-center">
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] text-muted-foreground">板块涨幅</p>
|
||||||
|
<p className={`text-sm font-bold tabular-nums ${isPos ? "text-red-500" : "text-green-500"}`}>
|
||||||
|
{f3 != null ? `${isPos ? "+" : ""}${f3.toFixed(2)}%` : "--"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] text-muted-foreground">上涨</p>
|
||||||
|
<p className="text-sm font-bold tabular-nums text-red-500">{up ?? "--"}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] text-muted-foreground">下跌</p>
|
||||||
|
<p className="text-sm font-bold tabular-nums text-green-500">{down ?? "--"}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] text-muted-foreground">成交额</p>
|
||||||
|
<p className="text-xs font-semibold tabular-nums">{fex5 != null ? formatMoney(fex5) : "--"}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{(flat != null && flat > 0) && (
|
||||||
|
<p className="text-[10px] text-muted-foreground text-center mt-1.5">
|
||||||
|
平盘 {flat} 只
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
相关新闻(可折叠)
|
||||||
|
============================================================ */
|
||||||
|
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 (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-1.5 mb-2">
|
||||||
|
<Newspaper className="h-4 w-4 text-primary" />
|
||||||
|
<h2 className="text-sm font-semibold">相关新闻</h2>
|
||||||
|
<span className="text-[10px] text-muted-foreground ml-auto">{items.length} 条</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2.5">
|
||||||
|
{shown.map((n, idx) => (
|
||||||
|
<div key={idx} className="space-y-0.5">
|
||||||
|
<p className="text-sm leading-snug line-clamp-2">{n.newsTitle}</p>
|
||||||
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
{n.newsMediaName}
|
||||||
|
{n.newsPublishTime ? ` · ${fmtTime(n.newsPublishTime)}` : ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{items.length > 2 && (
|
||||||
|
<button
|
||||||
|
onClick={() => setExpanded((v) => !v)}
|
||||||
|
className="mt-2 text-xs text-primary hover:underline inline-flex items-center gap-0.5"
|
||||||
|
>
|
||||||
|
{expanded ? "收起" : `展开全部 ${items.length} 条`}
|
||||||
|
{expanded ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
相关股票行
|
||||||
|
============================================================ */
|
||||||
|
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 (
|
||||||
|
<Link to="/stock/$code" params={{ code: stock.securityCode }} className="block">
|
||||||
|
<Card className="rounded-xl hover:shadow-md transition-shadow">
|
||||||
|
<CardContent className="p-3 space-y-1.5">
|
||||||
|
{/* 名称 + 现价 + 涨幅 */}
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-1.5 min-w-0">
|
||||||
|
<p className="text-sm font-medium truncate">{stock.securityName}</p>
|
||||||
|
{board.label && (
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center justify-center w-3.5 h-3.5 rounded-sm text-[8px] font-bold leading-none shrink-0 ${board.className}`}
|
||||||
|
>
|
||||||
|
{board.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{stock.label && (
|
||||||
|
<span className="shrink-0 text-[9px] font-medium text-orange-500 bg-orange-500/10 border border-orange-500/30 rounded px-1 py-0.5">
|
||||||
|
{stock.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 shrink-0">
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-[10px] text-muted-foreground">现价</p>
|
||||||
|
<p className="text-sm font-semibold tabular-nums">{stock.f2.toFixed(2)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right min-w-[56px]">
|
||||||
|
<p className="text-[10px] text-muted-foreground">涨跌</p>
|
||||||
|
<p className={`text-sm font-bold tabular-nums ${isPos ? "text-red-500" : "text-green-500"}`}>
|
||||||
|
{isPos ? "+" : ""}
|
||||||
|
{stock.f3.toFixed(2)}%
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 行业 + 换手 + 主力 + 成交额 */}
|
||||||
|
<div className="flex items-center gap-2 text-[11px] text-muted-foreground">
|
||||||
|
{stock.f100 && (
|
||||||
|
<span className="truncate bg-muted rounded px-1.5 py-0.5 text-[10px]">{stock.f100}</span>
|
||||||
|
)}
|
||||||
|
<span className="shrink-0 tabular-nums">换手 {turnoverRate.toFixed(2)}%</span>
|
||||||
|
<span className="shrink-0 tabular-nums">主力 {formatMoney(stock.f62)}</span>
|
||||||
|
<span className="shrink-0 tabular-nums ml-auto">成交 {formatMoney(stock.f6)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 入选理由 */}
|
||||||
|
{reasons.length > 0 && (
|
||||||
|
<div className="border-t border-border/40 pt-1.5">
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setShowReason((v) => !v);
|
||||||
|
}}
|
||||||
|
className="text-[10px] text-primary hover:underline inline-flex items-center gap-0.5"
|
||||||
|
>
|
||||||
|
入选理由
|
||||||
|
{showReason ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
|
||||||
|
</button>
|
||||||
|
{showReason && (
|
||||||
|
<p className="text-[11px] text-muted-foreground leading-relaxed mt-1">
|
||||||
|
{reasons.map((r) => r.introduction).filter(Boolean).join(" ")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<ThemeSortField>(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 (
|
||||||
|
<div className="min-h-screen bg-background">
|
||||||
|
{/* ── 顶栏 ── */}
|
||||||
|
<header className="sticky top-0 z-10 bg-background/95 backdrop-blur border-b">
|
||||||
|
<div className="max-w-5xl mx-auto px-4 h-12 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Link to="/" className="hover:opacity-70 transition-opacity">
|
||||||
|
<ArrowLeft className="h-5 w-5" />
|
||||||
|
</Link>
|
||||||
|
<h1 className="text-base font-semibold">题材热点</h1>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => refetch()}
|
||||||
|
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
title="刷新"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`h-4 w-4 ${isFetching ? "animate-spin" : ""}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* ── 排序切换 + 统计 ── */}
|
||||||
|
<div className="max-w-5xl mx-auto px-4 mt-3 flex items-center justify-between">
|
||||||
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
共 {data.length} 个题材
|
||||||
|
{isFetching && (
|
||||||
|
<span className="ml-1 text-[10px] text-muted-foreground/60">· 刷新中…</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-0.5 text-xs border rounded-md overflow-hidden">
|
||||||
|
{SORTS.map((s) => (
|
||||||
|
<button
|
||||||
|
key={s.key}
|
||||||
|
onClick={() => toggleSort(s.key)}
|
||||||
|
className={`px-2.5 py-1 flex items-center gap-0.5 transition-colors ${
|
||||||
|
sortField === s.key
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "text-muted-foreground hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{s.label}
|
||||||
|
{sortField === s.key &&
|
||||||
|
(asc ? <ArrowUp className="h-3 w-3" /> : <ArrowDown className="h-3 w-3" />)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 内容区 ── */}
|
||||||
|
<div className="max-w-5xl mx-auto px-4 mt-3 pb-8">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||||
|
{Array.from({ length: 18 }).map((_, i) => (
|
||||||
|
<div key={i} className="animate-pulse rounded-xl bg-muted h-32" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : isError ? (
|
||||||
|
<div className="flex flex-col items-center gap-3 py-20">
|
||||||
|
<p className="text-sm text-muted-foreground">数据加载失败</p>
|
||||||
|
<button onClick={() => refetch()} className="text-xs text-primary hover:underline">
|
||||||
|
点击重试
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : data.length === 0 ? (
|
||||||
|
<div className="text-center py-20 text-sm text-muted-foreground">暂无题材数据</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||||
|
{data.map((item) => (
|
||||||
|
<ThemeCard key={item.themeCode} item={item} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
数值格式化
|
||||||
|
============================================================ */
|
||||||
|
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 (
|
||||||
|
<Link to="/theme/$code" params={{ code: item.themeCode }} className="block">
|
||||||
|
<Card className="rounded-xl hover:shadow-md transition-shadow h-full">
|
||||||
|
<CardContent className="p-3 space-y-2">
|
||||||
|
{/* 题材名 + 领涨标签 */}
|
||||||
|
<div className="flex items-center justify-between gap-1">
|
||||||
|
<p className="text-sm font-medium truncate" title={item.themeName}>
|
||||||
|
{item.themeName}
|
||||||
|
</p>
|
||||||
|
{item.label && (
|
||||||
|
<span className="shrink-0 text-[9px] font-medium text-orange-500 bg-orange-500/10 border border-orange-500/30 rounded px-1 py-0.5">
|
||||||
|
{item.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 热度进度条 */}
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Flame className="h-3 w-3 text-orange-500 shrink-0" />
|
||||||
|
<div className="flex-1 h-1.5 rounded-full bg-muted overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-gradient-to-r from-orange-400 to-red-500"
|
||||||
|
style={{ width: `${Math.max(hotPct, 2)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] text-muted-foreground tabular-nums shrink-0">
|
||||||
|
{item.hotValue}/{item.hotValueUpLimit}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 涨幅 + 成交额 */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
{change != null ? (
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center gap-0.5 text-xs font-semibold ${
|
||||||
|
change >= 0 ? "text-red-500" : "text-green-500"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{change >= 0 ? <TrendingUp className="h-3 w-3" /> : <TrendingDown className="h-3 w-3" />}
|
||||||
|
{change >= 0 ? "+" : ""}
|
||||||
|
{fmt(change)}%
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-muted-foreground">--</span>
|
||||||
|
)}
|
||||||
|
<span className="text-[10px] text-muted-foreground">
|
||||||
|
{item.fex5 != null ? formatMoney(item.fex5) : "--"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 分割线 */}
|
||||||
|
<hr className="border-border/40" />
|
||||||
|
|
||||||
|
{/* 领涨股 + 强度 */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-1 min-w-0">
|
||||||
|
<span className="text-[10px] text-muted-foreground shrink-0">领涨</span>
|
||||||
|
<span className="text-xs font-medium truncate">{item.securityName || "--"}</span>
|
||||||
|
{stockBoard.label && (
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center justify-center w-3.5 h-3.5 rounded-sm text-[8px] font-bold leading-none shrink-0 ${stockBoard.className}`}
|
||||||
|
>
|
||||||
|
{stockBoard.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
{item.f3 != null && (
|
||||||
|
<span
|
||||||
|
className={`text-[10px] tabular-nums ${
|
||||||
|
item.f3 >= 0 ? "text-red-500" : "text-green-500"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{item.f3 >= 0 ? "+" : ""}
|
||||||
|
{fmt(item.f3)}%
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-[10px] text-muted-foreground tabular-nums">
|
||||||
|
强度 {item.strengthValue ?? "--"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user