Compare commits

...
Author SHA1 Message Date
SakurasanandClaude b0dbeef3fd feat: 容器内统一运维脚本auvops.sh,recollect-daily并入
- 新增 backend/auvops.sh:容器内直接执行的统一运维工具
  (cache-clear/clean-expired/cache-count/recollect/sh/help)
- heredoc传python代码,日期等参数走环境变量防注入
- 后续运维操作收敛到该文件,加 cmd_xxx + case 注册一行即可
- 删除 recollect-daily.sh,其能力并入 auvops.sh recollect

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 16:57:27 +08:00
SakurasanandClaude eb66ab02d0 feat: 交易日9:31清空全部缓存,保证开盘后数据全新
- services/cache.py 新增 clear_all() 清空整个 cache 表
- daily_collector.py 新增 cache_cleanup_loop() 后台循环:精确 sleep 到最近一个
  工作日 9:31 清空缓存,跳过周末,出错1分钟重试
- main.py lifespan 启动清理任务,与采集任务一同优雅退出

与既有"非交易时段 TTL 截止到下次开盘前"的惰性过期形成双保险

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 16:56:56 +08:00
SakurasanandClaude 7fe8074e22 feat: 题材列表盘中120s缓存共用,题材详情新增全量新闻分页+强度热度行情
- 题材热点与热点穿透共用题材列表缓存:盘中由"不缓存实时拉取"改为120s短缓存,
  东财全量列表拉取降到每120s一次,图重建时涨幅/热度两榜直接命中缓存
- 详情页相关新闻升级为全量分页(getThemeRelatedNews):支持翻页+评论数,
  替换原 getDetail 固定3条
- 详情页统计条新增强度+热度(getSingleThemeQuote)实时指标

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 16:56:44 +08:00
SakurasanandClaude f98a9255a5 feat: 股票球按涨幅绝对值调整大小
半径 = 覆盖数基础 + 涨幅绝对值增量(|f3|/10*4, 封顶20),
涨得猛/跌得深球更大。同覆盖下 0%vs10% 球径差约4px。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 01:07:59 +08:00
SakurasanandClaude 776ab55fc3 feat: 股票球跌幅改为绿色空心球体
涨: 保留分级实心(覆盖数越多越红) + 红色光晕;
跌: 绿色空心球(绿描边+内部淡绿留白) + 绿色光晕。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 00:59:07 +08:00
SakurasanandClaude dfcce3abaf feat: 所有股票小球统一光晕强度,涨红光晕/跌绿光晕更明显
光晕透明度不再乘覆盖数 alpha(覆盖1的股票 alpha=0.45 导致光晕过淡),
改为所有股票统一 0.5 强度,高亮淡出时降为 0.06。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 00:50:52 +08:00
SakurasanandClaude d473bbad6b feat: 股票小球心跳动画(覆盖≥5题材) + 涨幅正红光晕/负绿光晕
- 覆盖≥5题材股票球按正弦脉动(周期900ms, 幅度22%), 越大越醒目
- 涨幅≥0红色径向渐变光晕, <0绿色光晕, 随心跳一起脉动
- 架构: 静态层不再画股票节点(只画边+题材), 股票常驻动态层重绘
  以支撑持续动画; 新增常驻rAF动画循环驱动 timeRef
- 高亮态邻居淡出时, 光晕/心跳随 alpha 同步淡出

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 00:44:42 +08:00
SakurasanandClaude c4a0c50ed0 feat: 题材小球正涨幅蓝实心、负涨幅蓝空心
负涨幅球改为仅描边空心(内部淡蓝留白),边框加粗;
图例同步改为空心蓝球示意。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 00:10:31 +08:00
SakurasanandClaude 0a32bfad78 feat: 题材小球整体放大(半径 6~30)
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 23:53:47 +08:00
SakurasanandClaude 69535bc782 feat: 题材小球改平方根映射,拉大中小涨幅区分度
线性映射被极端涨幅拉平均,多数 0~3% 题材球挤在最小值附近。
改平方根映射:零涨幅 4px、1%≈10px、3%≈15px、极值 22px,
中等涨幅区间球径差显著放大。碰撞力半径动态跟随节点,无需额外适配。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 23:51:58 +08:00
SakurasanandClaude 254deb6a80 feat: 热点穿透题材节点按涨幅正蓝负绿、球大小随涨幅绝对值
- 后端题材列表下发 bf3(题材涨幅)
- 题材节点: 正涨幅蓝色/负涨幅绿色, 半径按 |bf3| 在 6~20 映射
- 题材标签文字颜色跟随正负
- 信息卡/底部浮层题材分支显示涨幅
- 图例拆分涨/跌两项并说明球大小含义

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 23:34:38 +08:00
SakurasanandClaude b29ca8413c feat: 核心股追踪改名热点股追踪
页面标题、页面内'活跃核心股'描述、入口链接文案同步更名;
热点穿透页内'穿透核心股'列表概念(覆盖题材数)保留原名。
底层路由/表名/代码标识符 core_stocks 不变。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 23:02:39 +08:00
SakurasanandClaude c610faa4ad chore: 新增服务器端清理重采脚本 recollect-daily.sh
选股逻辑变更后,服务器上当天旧数据需删除重采(幂等会跳过)。
脚本默认容器 auv、日期取服务器当天,支持显式传参;含日期格式与容器存在性校验。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 22:33:13 +08:00
SakurasanandClaude f93ffd381b feat: 核心股追踪选股并入热点穿透核心股(覆盖题材数降序前100,去重)
选股口径由仅'题材领涨股涨幅前100'改为:
- 热点穿透核心股(覆盖题材数≥2,按覆盖数降序前100)
- 合并题材领涨股涨幅前100,按股票代码去重

复用 _build_theme_graph 计算每股覆盖题材数;热点穿透构建失败
时降级为仅领涨股前100,不影响当日采集。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 22:31:38 +08:00
SakurasanandClaude 1d3ec3194c feat: 核心股追踪题材默认展开显示
移除展开/收起交互,题材标签区始终展示

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 21:56:18 +08:00
SakurasanandClaude b4d48180b7 feat: 核心股追踪题材标签跳题材详情、股票名跳股票详情
- 题材标签 → /theme/$code
- 股票名 → /stock/$code(阻止冒泡避免触发行展开)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 21:53:47 +08:00
SakurasanandClaude 6d3c470b7b feat: 核心股追踪行点击展开显示所属题材列表
- active 接口返回每只核心股 themes(窗口内按题材去重)
- 页面行点击展开题材标签,chevron 指示展开状态

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 21:50:16 +08:00
SakurasanandClaude 23644a9c77 feat: 题材涨幅前20存历史(原前10)
- daily_collector TOP_THEME_LIMIT 10 → 20
- 同步 routes/collector 注释与 spec/plan 文档
- 实测 2026-08-10 题材前20入库 20 条,themes/history 返回 20 条

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 21:27:54 +08:00
SakurasanandClaude 1c434a208a merge: 每日核心股/题材历史 + 活跃核心股滚动表格功能
将 worktree-core-stock-history 分支合并回 main:
- 每日采集核心股前100+所属题材、题材涨幅前10 存历史
- /api/core-stocks/active|history、/api/themes/history 接口
- /core-stocks 展示页(10日涨幅矩阵 + 题材数/最近上榜/上榜次数)
- 后端 asyncio 定时采集 + 前端入口链接

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 21:17:35 +08:00
SakurasanandClaude 036a87bac9 feat: 核心股页面补最近上榜日期列
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 21:13:58 +08:00
SakurasanandClaude 258fc9184d feat: 补齐 spec 遗漏 — themes/history 接口 + cover_count/coverCount/daysSinceLastAppear
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 21:08:39 +08:00
SakurasanandClaude ac985a2140 feat: 题材/热点穿透页加入核心股追踪入口
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 20:59:12 +08:00
SakurasanandClaude a7cff0930d style: 核心股页面 formatGain 符号一致 + 表格 a11y 增强
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 20:56:38 +08:00
SakurasanandClaude 5e7f0c4f13 feat: 活跃核心股 10 日涨幅矩阵页面
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 20:50:39 +08:00
Sakurasan 77f6e36c13 fix: dailyGains 类型精度 + 补 try/catch 与 theme-api 对齐 2026-08-10 20:40:33 +08:00
Sakurasan cd56f6c158 feat: 核心股历史前端 API 客户端 2026-08-10 20:30:28 +08:00
SakurasanandClaude c58bd990fd fix: lifespan 关闭时 await 取消的采集任务,避免 pending 警告
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 20:23:17 +08:00
SakurasanandClaude 6b12b04cab feat: 挂载核心股路由并启动每日采集任务
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 20:20:55 +08:00
SakurasanandClaude ffabf3d396 perf: /history 消除题材查询 N+1 并修正排序注释
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 20:17:39 +08:00
SakurasanandClaude f5ba61e3db feat: 核心股历史/活跃接口
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 20:00:29 +08:00
SakurasanandClaude 526b182051 fix: 修复 collector_loop 常量名(NameError)并填充核心股所属题材
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 19:49:36 +08:00
SakurasanandClaude 14c31e3f06 fix: 修正 collector_loop 常量名拼写 _COLLECT_AFTER_TIME → COLLECT_AFTER_TIME
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 19:38:24 +08:00
SakurasanandClaude aed3eea739 feat: 每日核心股/题材采集服务(幂等)
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 19:30:35 +08:00
Sakurasan 5ffa5c07b2 feat: 新增每日核心股/题材历史 3 张表 2026-08-10 19:27:51 +08:00
Sakurasan 84d237de1f docs: 每日核心股/题材历史功能实现计划 2026-08-10 19:26:20 +08:00
SakurasanandClaude f354da108d docs: 每日热点核心股/题材历史记录 + 活跃核心股滚动表格设计
- 每日采集核心股前100(按涨幅)+所属题材,题材涨幅前10,存历史
- asyncio 后台定时采集,幂等去重
- 新页面:股票×最近10个A股交易日涨幅矩阵,超10日未出现踢出

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 19:26:20 +08:00
Sakurasan 8687bda331 docs: 每日核心股/题材历史功能实现计划 2026-08-10 19:25:31 +08:00
SakurasanandClaude 75c7ff3670 docs: 每日热点核心股/题材历史记录 + 活跃核心股滚动表格设计
- 每日采集核心股前100(按涨幅)+所属题材,题材涨幅前10,存历史
- asyncio 后台定时采集,幂等去重
- 新页面:股票×最近10个A股交易日涨幅矩阵,超10日未出现踢出

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 19:20:11 +08:00
SakurasanandClaude 84a01fb7b1 fix: 补齐完整浏览器请求头并改用 web client,修复生产 403 拿不到题材数据
生产服务器请求东财题材列表/相关股票返回 403(缺 sec-* 头 + iOS client
被风控识别为爬虫),导致热点穿透重建失败、数据冻结在上个交易日。

- 请求头补齐 sec-ch-ua/sec-fetch-*/priority/dnt 等完整 Chrome 头
- payload client 由 iOS 改为 web(生产实测该组合正常返回)
- content-type 补 charset=UTF-8

生产实测:完整头 + web client 正常返回数据。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 18:36:27 +08:00
SakurasanandClaude ce131b2867 fix: 热点穿透盘中缓存过期改为同步重建,避免显示上个交易日旧图
fetch_theme_graph 原用 stale-while-revalidate:缓存过期先返回旧数据、
后台异步重建。缓存里若是上个交易日数据,过期后用户一直看到旧图,
重建完成前不会更新。

- 交易时段缓存过期/无缓存 → 同步重建(加锁去重),绝不返回旧数据
- 非交易时段过期 → 保留 stale-while-revalidate(行情无实时变化,秒开无害)

实测盘中同步重建约 7s,可接受。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 13:47:21 +08:00
17 changed files with 2114 additions and 91 deletions
+150
View File
@@ -0,0 +1,150 @@
#!/usr/bin/env bash
# auvops.sh - AUV 容器内运维工具(在容器内直接执行,非宿主机 docker exec
#
# 拷贝进容器 /app(backend 根)后运行。后续新增运维能力都收敛到这个文件:
# 加一个 cmd_xxx 函数 + 在 main 的 case 里注册一行即可。
#
# 用法(容器内):
# ./auvops.sh cache-clear # 清空全部缓存(当日数据全新)
# ./auvops.sh clean-expired # 只清理已过期的缓存
# ./auvops.sh cache-count # 查看缓存条数
# ./auvops.sh recollect [DATE] # 删除并重采指定交易日(默认当天)
# ./auvops.sh sh # 进入交互式 shell
# ./auvops.sh help # 查看帮助
#
# 依赖: 容器内 python 可用(能 import services.*),工作目录自动切到脚本所在目录。
#
# 示例:
# ./auvops.sh cache-clear
# ./auvops.sh recollect 2026-08-10
# ./auvops.sh cache-count
set -euo pipefail
# 容器内 backend 根:脚本所在目录(/app)
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "${ROOT}"
# 容器内用 python;本机验证时可 PY=./venv/bin/python ./auvops.sh ...
PY="${PY:-python}"
"${PY}" -c "import services" >/dev/null 2>&1 || {
echo "❌ 无法在 ${ROOT} 下 import services(确认已在容器内 /app 且 python 可用)" >&2
exit 1
}
# 在容器内跑 python 代码。日期等参数通过环境变量传,避免拼接进代码字符串。
# 用法: py_env "KEY1=val1" "KEY2=val2" <<'EOF'
# <python 代码>
# EOF
py_env() {
local env_args=()
while [[ "$#" -gt 0 ]]; do
env_args+=("${1%%=*}=${1#*=}"); shift
done
env "${env_args[@]}" "${PY}" -
}
# ---- 子命令实现 ----
# 清空全部缓存(当日数据全新)
cmd_cache_clear() {
echo "① 清空全部缓存"
"${PY}" - <<'EOF'
from services.cache import clear_all
clear_all()
print(" cache 表已清空")
EOF
echo "✅ 缓存已清空"
}
# 只清理已过期缓存
cmd_clean_expired() {
echo "① 清理过期缓存"
"${PY}" - <<'EOF'
from services.cache import clean_expired
clean_expired()
print(" 过期缓存已清理")
EOF
echo "✅ 完成"
}
# 查看缓存条数
cmd_cache_count() {
"${PY}" - <<'EOF'
from database import get_connection
conn = get_connection()
try:
n = conn.execute("SELECT COUNT(*) FROM cache").fetchone()[0]
print(f"缓存条数: {n}")
finally:
conn.close()
EOF
}
# 删除指定交易日旧数据,并用新选股逻辑重新采集入库
cmd_recollect() {
local date="${1:-$(date +%F)}"
if ! [[ "${date}" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; then
echo "❌ 日期格式错误:${date}(应为 YYYY-MM-DD" >&2
exit 1
fi
echo "================================================"
echo "🚀 容器: ${HOSTNAME:-unknown} 交易日: ${date}"
echo "================================================"
echo "① 删除 ${date} 旧数据"
py_env "TARGET_DATE=${date}" <<'EOF'
import os
from database import get_connection
date = os.environ["TARGET_DATE"]
conn = get_connection()
try:
for tbl in ("daily_core_stocks", "daily_core_stock_themes", "daily_top_themes"):
cur = conn.execute("DELETE FROM " + tbl + " WHERE trade_date = ?", (date,))
print(f" {tbl}: 删除 {cur.rowcount} 行")
conn.commit()
finally:
conn.close()
EOF
echo "② 用新选股逻辑重采 ${date}"
py_env "TARGET_DATE=${date}" <<'EOF'
import asyncio
import os
from services.daily_collector import collect_daily
result = asyncio.run(collect_daily(os.environ["TARGET_DATE"]))
print(" 结果:", result)
EOF
echo "✅ 完成"
}
# 进入交互式 shell
cmd_sh() {
"${PY}" || true
}
# 帮助
usage() {
awk 'NR >= 2 && /^#/ { sub(/^# ?/, ""); print; next } NR >= 2 && !/^#/ { exit }' "${BASH_SOURCE[0]}"
}
# ---- 入口 ----
main() {
if [[ "$#" -eq 0 ]]; then
usage
exit 1
fi
local cmd="$1"; shift
case "${cmd}" in
cache-clear|cc) cmd_cache_clear "$@" ;;
clean-expired) cmd_clean_expired "$@" ;;
cache-count) cmd_cache_count "$@" ;;
recollect) cmd_recollect "$@" ;;
sh|shell|python) cmd_sh "$@" ;;
help|-h|--help) usage ;;
*) echo "❌ 未知命令: ${cmd}./auvops.sh help 查看用法)" >&2; exit 1 ;;
esac
}
main "$@"
+34
View File
@@ -37,6 +37,40 @@ CREATE TABLE IF NOT EXISTS cache (
value TEXT NOT NULL, value TEXT NOT NULL,
expires_at TEXT NOT NULL expires_at TEXT NOT NULL
); );
CREATE TABLE IF NOT EXISTS daily_core_stocks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trade_date TEXT NOT NULL,
stock_code TEXT NOT NULL,
stock_name TEXT NOT NULL,
f3 REAL,
cover_count INTEGER,
rank INTEGER,
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
UNIQUE(trade_date, stock_code)
);
CREATE TABLE IF NOT EXISTS daily_core_stock_themes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trade_date TEXT NOT NULL,
stock_code TEXT NOT NULL,
theme_code TEXT NOT NULL,
theme_name TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
UNIQUE(trade_date, stock_code, theme_code)
);
CREATE TABLE IF NOT EXISTS daily_top_themes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trade_date TEXT NOT NULL,
theme_code TEXT NOT NULL,
theme_name TEXT NOT NULL,
bf3 REAL,
hot_rank INTEGER,
rank INTEGER,
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
UNIQUE(trade_date, theme_code)
);
""" """
+16 -2
View File
@@ -1,3 +1,4 @@
import asyncio
import os import os
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
@@ -6,7 +7,8 @@ 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, themes from routes import stock, collections, shares, sectors, themes, core_stocks
from services.daily_collector import collector_loop, cache_cleanup_loop
load_dotenv() load_dotenv()
@@ -14,7 +16,18 @@ load_dotenv()
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
init_db() init_db()
yield collector_task = asyncio.create_task(collector_loop())
cache_cleanup_task = asyncio.create_task(cache_cleanup_loop())
try:
yield
finally:
for t in (collector_task, cache_cleanup_task):
t.cancel()
for t in (collector_task, cache_cleanup_task):
try:
await t
except asyncio.CancelledError:
pass
app = FastAPI(title="AUV API", version="1.0.0", lifespan=lifespan) app = FastAPI(title="AUV API", version="1.0.0", lifespan=lifespan)
@@ -32,6 +45,7 @@ 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") app.include_router(themes.router, prefix="/api/themes")
app.include_router(core_stocks.router, prefix="/api/core-stocks")
# 生产模式:后端同时托管前端静态文件 # 生产模式:后端同时托管前端静态文件
# catch-all 路由在 API 路由之后注册,所以 API 优先级更高 # catch-all 路由在 API 路由之后注册,所以 API 优先级更高
+117
View File
@@ -0,0 +1,117 @@
"""核心股历史接口:活跃核心股 + 指定日核心股/题材前20"""
from datetime import date as date_cls
from fastapi import APIRouter, Query
from fastapi.responses import JSONResponse
from database import get_connection, dict_from_row
router = APIRouter()
_NO_CACHE_HEADERS = {"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0"}
def _recent_trade_dates(conn, n: int = 10) -> list[str]:
"""最近 n 个有数据的交易日(升序)"""
rows = conn.execute(
"SELECT DISTINCT trade_date FROM daily_core_stocks ORDER BY trade_date DESC LIMIT ?",
(n,),
).fetchall()
return [r["trade_date"] for r in reversed(rows)]
@router.get("/active", summary="活跃核心股 + 最近10日涨幅矩阵")
async def active_core_stocks():
conn = get_connection()
try:
dates = _recent_trade_dates(conn, 10)
if not dates:
return JSONResponse({"dates": [], "stocks": []}, headers=_NO_CACHE_HEADERS)
# 窗口内出现过且最近一次出现距今天数 <= 10 个交易日
placeholders = ",".join("?" * len(dates))
rows = conn.execute(
f"""SELECT trade_date, stock_code, stock_name, f3, cover_count FROM daily_core_stocks
WHERE trade_date IN ({placeholders})
ORDER BY trade_date DESC, rank ASC""",
dates,
).fetchall()
# 组装 per-stock:每日涨幅 + 出现次数 + 最近上榜
stock_days: dict[str, dict] = {}
for r in rows:
code = r["stock_code"]
s = stock_days.setdefault(code, {
"stockCode": code,
"stockName": r["stock_name"],
"coverCount": r["cover_count"],
"dailyGains": {},
"appearCount": 0,
"lastAppear": None,
})
s["dailyGains"][r["trade_date"]] = r["f3"]
s["appearCount"] += 1
if s["lastAppear"] is None or r["trade_date"] > s["lastAppear"]:
s["lastAppear"] = r["trade_date"]
stocks = list(stock_days.values())
# 稳定排序:先按出现次数降序,再按最近上榜日降序
stocks.sort(key=lambda x: x.get("lastAppear") or "", reverse=True)
stocks.sort(key=lambda x: -x["appearCount"])
# 一次性取窗口内全部题材关联,按 stock_code 分组(跨天题材去重)
themes_rows = conn.execute(
f"""SELECT stock_code, theme_code, theme_name FROM daily_core_stock_themes
WHERE trade_date IN ({placeholders})""",
dates,
).fetchall()
themes_by_stock: dict[str, dict[str, dict]] = {}
for t in themes_rows:
per = themes_by_stock.setdefault(t["stock_code"], {})
per.setdefault(t["theme_code"], {"theme_code": t["theme_code"], "theme_name": t["theme_name"]})
for s in stocks:
s["themes"] = list(themes_by_stock.get(s["stockCode"], {}).values())
# daysSinceLastAppear:最近上榜距窗口最新交易日的自然日差(简单口径)
latest = dates[-1] if dates else None
for s in stocks:
if s.get("lastAppear") and latest:
try:
d1 = date_cls.fromisoformat(latest)
d2 = date_cls.fromisoformat(s["lastAppear"])
s["daysSinceLastAppear"] = (d1 - d2).days
except ValueError:
s["daysSinceLastAppear"] = 0
else:
s["daysSinceLastAppear"] = 0
return JSONResponse({"dates": dates, "stocks": stocks}, headers=_NO_CACHE_HEADERS)
finally:
conn.close()
@router.get("/history", summary="指定交易日核心股(含所属题材)")
async def core_stock_history(date: str = Query(..., description="交易日 YYYY-MM-DD")):
conn = get_connection()
try:
rows = conn.execute(
"SELECT * FROM daily_core_stocks WHERE trade_date = ? ORDER BY rank ASC", (date,)
).fetchall()
# 一次性取该日全部题材关联,按 stock_code 分组,避免逐股 N+1 查询
themes_rows = conn.execute(
"SELECT stock_code, theme_code, theme_name FROM daily_core_stock_themes WHERE trade_date = ?",
(date,),
).fetchall()
themes_by_stock: dict[str, list] = {}
for t in themes_rows:
themes_by_stock.setdefault(t["stock_code"], []).append(
{"theme_code": t["theme_code"], "theme_name": t["theme_name"]}
)
items = []
for r in rows:
d = dict_from_row(r)
d["themes"] = themes_by_stock.get(d["stock_code"], [])
items.append(d)
return JSONResponse({"date": date, "items": items}, headers=_NO_CACHE_HEADERS)
finally:
conn.close()
+47
View File
@@ -2,6 +2,7 @@
from fastapi import APIRouter, Query, HTTPException from fastapi import APIRouter, Query, HTTPException
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from database import get_connection, dict_from_row
from services import themes from services import themes
router = APIRouter() router = APIRouter()
@@ -38,6 +39,52 @@ async def theme_graph(
return JSONResponse(result, headers=_NO_CACHE_HEADERS) return JSONResponse(result, headers=_NO_CACHE_HEADERS)
@router.get("/history", summary="指定交易日题材涨幅前20")
async def theme_history(date: str = Query(..., description="交易日 YYYY-MM-DD")):
conn = get_connection()
try:
rows = conn.execute(
"SELECT * FROM daily_top_themes WHERE trade_date = ? ORDER BY rank ASC", (date,)
).fetchall()
items = [dict_from_row(r) for r in rows]
return JSONResponse({"date": date, "items": items}, headers=_NO_CACHE_HEADERS)
finally:
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="题材详情") @router.get("/{theme_code}/detail", summary="题材详情")
async def theme_detail(theme_code: str): async def theme_detail(theme_code: str):
data = await themes.fetch_theme_detail(theme_code) data = await themes.fetch_theme_detail(theme_code)
+10
View File
@@ -56,3 +56,13 @@ def clean_expired():
conn.commit() conn.commit()
finally: finally:
conn.close() conn.close()
def clear_all():
"""清空全部缓存(每日开盘后 9:31 调用,保证当日数据全新)"""
conn = get_connection()
try:
conn.execute("DELETE FROM cache")
conn.commit()
finally:
conn.close()
+233
View File
@@ -0,0 +1,233 @@
"""每日热点数据采集:核心股前100 + 题材涨幅前20,收盘后自动入库
由 main.lifespan 启动后台任务;幂等(按交易日 UNIQUE 去重)。
"""
import asyncio
import traceback
from datetime import datetime, time as dtime, timezone, timedelta
from typing import Optional
from database import get_connection
from services.cache import clear_all
from services.themes import fetch_theme_list, _build_theme_graph
_CST = timezone(timedelta(hours=8))
# 每天采集的后台任务:每 300 秒(5 分钟)检查一次
CHECK_INTERVAL_SECONDS = 300
COLLECT_AFTER_TIME = dtime(15, 0) # 收盘后 15:00 开始允许采集
CORE_STOCK_LIMIT = 100 # 题材领涨股涨幅前100
TOP_THEME_LIMIT = 20 # 题材涨幅前20
HOTMAP_TOP_N = 50 # 热点穿透采样题材数:涨幅榜+热度榜各取前50,合并(与热点穿透页一致)
HOTMAP_CORE_LIMIT = 100 # 热点穿透核心股前100(按覆盖题材数降序)
CORE_COVER_THRESHOLD = 2 # 热点穿透核心股门槛:覆盖题材数 ≥2
# 每日缓存清理:交易日 9:31 清空全部缓存,保证开盘后数据全新
CLEANUP_TIME = dtime(9, 31)
def _is_trading_day(d: datetime) -> bool:
"""仅按工作日判断:周一至周五视为交易日,不处理法定节假日"""
return d.weekday() < 5
def _has_collected(trade_date: str) -> bool:
"""当日核心股是否已采集"""
conn = get_connection()
try:
row = conn.execute(
"SELECT 1 FROM daily_core_stocks WHERE trade_date = ? LIMIT 1",
(trade_date,),
).fetchone()
return row is not None
finally:
conn.close()
async def collect_daily(trade_date: str, dry_run: bool = False) -> dict:
"""采集指定交易日数据并入库。
核心股 = 题材领涨股涨幅前100 ∪ 热点穿透核心股(覆盖题材数≥2、按覆盖数降序前100),按股票代码去重。
热点穿透构建失败时降级为仅领涨股前100,不影响当日采集。
Args:
trade_date: YYYY-MM-DD
dry_run: True 只打印不写库(用于验证)
Returns:
{"core_count": int, "theme_count": int, "skipped": bool}
"""
if _has_collected(trade_date):
print(f"[collector] {trade_date} 已采集,跳过")
return {"core_count": 0, "theme_count": 0, "skipped": True}
# 1. 拉取全部题材列表(含领涨股,作为当日全部股票的采样来源)
themes = await fetch_theme_list(1, False)
if not themes:
print(f"[collector] {trade_date} 题材列表为空(东财失败),跳过")
return {"core_count": 0, "theme_count": 0, "skipped": True}
# 领涨题材映射:securityCode -> [(themeCode, themeName), ...](用于核心股"所属题材")
lead_themes: dict[str, list[tuple[str, str]]] = {}
for t in themes:
code = t.get("securityCode")
if not code:
continue
lead_themes.setdefault(code, []).append((t["themeCode"], t["themeName"]))
# 2. 热点穿透核心股:覆盖题材数≥2,按覆盖题材数降序前100(去重)
# _build_theme_graph 采样 涨幅榜+热度榜 各前 HOTMAP_TOP_N 个题材并拉每股覆盖题材数,
# 已按 (-coverCount, -f3) 降序;失败时降级为空集,仅保留领涨股。
hotmap_core: list[dict] = []
graph_theme_name: dict[str, str] = {}
try:
graph = await _build_theme_graph(1, HOTMAP_TOP_N)
graph_theme_name = {t["themeCode"]: t["themeName"] for t in graph.get("themes", [])}
hotmap_core = [
s for s in graph.get("stocks", [])
if s.get("coverCount", 0) >= CORE_COVER_THRESHOLD
][:HOTMAP_CORE_LIMIT]
except Exception as e:
print(f"[collector] 热点穿透核心股构建失败,降级为仅领涨股: {e}")
# 3. 题材领涨股涨幅前100:按领涨股 f3 降序,去重(同一股票可能是多个题材领涨股)
stock_map: dict[str, dict] = {}
theme_count: dict[str, int] = {} # securityCode -> 领涨题材数
for t in themes:
code = t.get("securityCode")
if not code:
continue
theme_count[code] = theme_count.get(code, 0) + 1
if code not in stock_map or (t.get("f3") or 0) > (stock_map[code].get("f3") or 0):
stock_map[code] = {
"stock_code": code,
"stock_name": t.get("securityName", ""),
"f3": t.get("f3"),
}
f3_core = sorted(stock_map.values(), key=lambda x: -(x["f3"] or 0))[:CORE_STOCK_LIMIT]
# 4. 题材涨幅前20bf3 降序
top_themes = sorted(themes, key=lambda x: -(x.get("bf3") or 0))[:TOP_THEME_LIMIT]
# 5. 合并去重:热点穿透核心股在前(覆盖数降序,rank 优先),随后补领涨股涨幅前100
core_stocks: list[dict] = []
seen: set[str] = set()
theme_pairs: set[tuple[str, str, str]] = set() # (stock_code, theme_code, theme_name)
for s in hotmap_core:
code = s["securityCode"]
seen.add(code)
core_stocks.append({
"stock_code": code,
"stock_name": s.get("securityName", ""),
"f3": s.get("f3"),
"cover_count": s.get("coverCount", 0),
})
# 所属题材:采样榜内覆盖的题材 + 全量题材列表中的领涨题材
for tc in s.get("themeCodes", []):
theme_pairs.add((code, tc, graph_theme_name.get(tc, tc)))
for tc, tn in lead_themes.get(code, []):
theme_pairs.add((code, tc, tn))
for s in f3_core:
code = s["stock_code"]
if code in seen:
continue
seen.add(code)
core_stocks.append({
"stock_code": code,
"stock_name": s["stock_name"],
"f3": s["f3"],
"cover_count": theme_count.get(code, 0),
})
for tc, tn in lead_themes.get(code, []):
theme_pairs.add((code, tc, tn))
if dry_run:
print(f"[collector] {trade_date} 核心股 {len(core_stocks)} 只(热点穿透 {len(hotmap_core)} + 领涨股 {len(f3_core)}),题材前20 {len(top_themes)}")
return {"core_count": len(core_stocks), "theme_count": len(top_themes), "skipped": False}
# 6. 入库(事务,UNIQUE 幂等)
conn = get_connection()
try:
for i, s in enumerate(core_stocks, start=1):
conn.execute(
"INSERT OR IGNORE INTO daily_core_stocks (trade_date, stock_code, stock_name, f3, cover_count, rank) VALUES (?,?,?,?,?,?)",
(trade_date, s["stock_code"], s["stock_name"], s["f3"], s["cover_count"], i),
)
for code, tc, tn in theme_pairs:
conn.execute(
"INSERT OR IGNORE INTO daily_core_stock_themes (trade_date, stock_code, theme_code, theme_name) VALUES (?,?,?,?)",
(trade_date, code, tc, tn),
)
for i, t in enumerate(top_themes, start=1):
conn.execute(
"INSERT OR IGNORE INTO daily_top_themes (trade_date, theme_code, theme_name, bf3, hot_rank, rank) VALUES (?,?,?,?,?,?)",
(trade_date, t["themeCode"], t["themeName"], t.get("bf3"), t.get("hotRank"), i),
)
conn.commit()
finally:
conn.close()
print(f"[collector] {trade_date} 已采集:核心股 {len(core_stocks)} 只,题材前20 {len(top_themes)}")
return {"core_count": len(core_stocks), "theme_count": len(top_themes), "skipped": False}
async def collector_loop(stop: Optional[asyncio.Event] = None) -> None:
"""后台循环:每个交易日 15:00 后自动采集当日数据(幂等)"""
while True:
try:
now = datetime.now(_CST)
if _is_trading_day(now) and now.time() >= COLLECT_AFTER_TIME:
trade_date = now.strftime("%Y-%m-%d")
if not _has_collected(trade_date):
await collect_daily(trade_date)
except Exception:
print("[collector] 采集异常:")
traceback.print_exc()
if stop is not None and stop.is_set():
break
await asyncio.sleep(CHECK_INTERVAL_SECONDS)
def _next_cleanup_dt(now: datetime) -> datetime:
"""计算下一个缓存清理时刻:最近一个工作日 9:31(今天已过则取下一个工作日)"""
for days in range(0, 8):
d = (now + timedelta(days=days)).date()
if d.weekday() >= 5: # 跳过周末
continue
dt = datetime(d.year, d.month, d.day, CLEANUP_TIME.hour, CLEANUP_TIME.minute, tzinfo=_CST)
if dt > now:
return dt
return now + timedelta(days=1) # 兜底:理论不可达
async def cache_cleanup_loop(stop: Optional[asyncio.Event] = None) -> None:
"""后台循环:每个交易日 9:31 清空全部缓存,保证开盘后读到全新数据
到点前精确 sleep 至 9:31;若进程在 9:31 后启动,则等下一个交易日。
"""
while True:
try:
now = datetime.now(_CST)
next_dt = _next_cleanup_dt(now)
delay = max(0, int((next_dt - now).total_seconds()))
if stop is not None:
try:
await asyncio.wait_for(stop.wait(), timeout=delay)
except asyncio.TimeoutError:
pass # 到点
if stop.is_set():
break
else:
await asyncio.sleep(delay)
clear_all()
print(f"[cache-cleanup] 已清空全部缓存: {datetime.now(_CST).strftime('%Y-%m-%d %H:%M:%S')}")
except asyncio.CancelledError:
raise
except Exception:
print("[cache-cleanup] 清理异常:")
traceback.print_exc()
await asyncio.sleep(60) # 出错 1 分钟后再试
+95 -25
View File
@@ -30,12 +30,23 @@ _PZ_CDN_URL = "https://emcfgdata.securities.eastmoney.com"
_APP_KEY_INDEX = "rn-themeIndex" _APP_KEY_INDEX = "rn-themeIndex"
_APP_KEY_DETAIL = "rn-themeDetail" _APP_KEY_DETAIL = "rn-themeDetail"
# 完整浏览器请求头:生产实测东财对缺 sec-* 头的移动端包装结构请求会 403,
# 补齐真实 Chrome 头 + client="web" 后正常返回
_HEADERS = { _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", "Accept": "application/json, text/plain, */*",
"Origin": "https://emrnweb.eastmoney.com",
"Referer": "https://emrnweb.eastmoney.com/",
"Accept": "application/json",
"Accept-Language": "zh-CN,zh;q=0.9", "Accept-Language": "zh-CN,zh;q=0.9",
"Content-Type": "application/json;charset=UTF-8",
"DNT": "1",
"Origin": "https://emrnweb.eastmoney.com",
"Priority": "u=1, i",
"Referer": "https://emrnweb.eastmoney.com/",
"Sec-Ch-Ua": '"Not=A?Brand";v="99", "Google Chrome";v="151", "Chromium";v="151"',
"Sec-Ch-Ua-Mobile": "?0",
"Sec-Ch-Ua-Platform": '"macOS"',
"Sec-Fetch-Dest": "empty",
"Sec-Fetch-Mode": "cors",
"Sec-Fetch-Site": "same-site",
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36",
} }
# ---- 交易时段感知缓存 ---- # ---- 交易时段感知缓存 ----
@@ -77,9 +88,14 @@ def _next_open_delta_seconds() -> int:
return 0 return 0
# 盘中题材列表短缓存:题材热点页与热点穿透聚合共用同一份缓存,避免重复拉全量列表打东财。
# 120s 长于热点穿透图缓存(60s),图重建时必然命中且更新频率更低,两页数据更稳。
_LIST_CACHE_SECONDS = 120
def _list_ttl_seconds() -> int: def _list_ttl_seconds() -> int:
"""题材列表缓存秒数:交易时段 0(不缓存、实时拉取);非交易时段缓存到下次开盘前失效""" """题材列表缓存秒数:交易时段 120s 短缓存;非交易时段缓存到下次开盘前失效"""
return 0 if _is_trading_time() else _next_open_delta_seconds() return _LIST_CACHE_SECONDS if _is_trading_time() else _next_open_delta_seconds()
# ---- 请求封装 ---- # ---- 请求封装 ----
@@ -89,7 +105,7 @@ def _build_payload(args: Optional[dict] = None, app_key: str = _APP_KEY_INDEX) -
return { return {
"args": args or {}, "args": args or {},
"appKey": app_key, "appKey": app_key,
"client": "iOS", "client": "web", # 生产实测 iOS client 会 403web + 完整浏览器头正常
"clientVersion": "8.3", "clientVersion": "8.3",
"clientType": "cfw", "clientType": "cfw",
"randomCode": "".join(random.choices(string.ascii_uppercase + string.ascii_lowercase + string.digits, k=16)), "randomCode": "".join(random.choices(string.ascii_uppercase + string.ascii_lowercase + string.digits, k=16)),
@@ -148,11 +164,10 @@ async def fetch_theme_list(sort_field: int = 1, asc: bool = False) -> list[dict]
asc: True=升序, False=降序 asc: True=升序, False=降序
""" """
cache_key = f"theme_list:{sort_field}:{asc}" cache_key = f"theme_list:{sort_field}:{asc}"
# 交易时段强制实时:跳过缓存读取,避免命中非交易时段写入的上个交易日旧数据 # 统一读缓存(盘中 TTL=120s 短缓存,非盘中缓存到下次开盘前失效),避免重复拉全量列表打东财
if not _is_trading_time(): cached = get_cache(cache_key)
cached = get_cache(cache_key) if cached is not None:
if cached is not None: return json.loads(cached)
return json.loads(cached)
sort = 1 if asc else -1 sort = 1 if asc else -1
# hotRank 数值越小越热,"热度降序(最热在前)" 需反转为接口升序 # hotRank 数值越小越热,"热度降序(最热在前)" 需反转为接口升序
@@ -359,7 +374,7 @@ async def _build_theme_graph(sort_field: int, top_n: int) -> dict:
return { return {
"themes": [ "themes": [
{"themeCode": t["themeCode"], "themeName": t["themeName"], "stockCount": t["stockCount"]} {"themeCode": t["themeCode"], "themeName": t["themeName"], "stockCount": t["stockCount"], "bf3": t.get("bf3")}
for t in theme_map.values() for t in theme_map.values()
], ],
"stocks": stocks, "stocks": stocks,
@@ -399,10 +414,11 @@ async def _rebuild_task(cache_key: str, sort_field: int, top_n: int, lock: async
async def fetch_theme_graph(sort_field: int = 1, top_n: int = 30, limit: int = 1000) -> dict: async def fetch_theme_graph(sort_field: int = 1, top_n: int = 30, limit: int = 1000) -> dict:
"""获取热点穿透图数据(盘中 60s 缓存 + stale-while-revalidate """获取热点穿透图数据(盘中 60s 缓存,盘中过期同步重建,非盘中 stale-while-revalidate
缓存命中且未过期 → 直接返回;已过期 → 返回旧数据并后台异步重建(秒开); 缓存新鲜 → 直接返回;
无缓存 → 同步构建(并发下加锁去重)。返回前按 limit 裁剪 stocks。 非交易时段过期 → 返回旧数据并后台异步重建(秒开,非盘中行情无实时变化,旧值可接受);
交易时段过期 / 无缓存 → 同步重建(加锁去重),绝不返回上个交易日的旧图。
Args: Args:
sort_field: 题材排序 1=涨幅, 4=热度 sort_field: 题材排序 1=涨幅, 4=热度
@@ -412,21 +428,75 @@ async def fetch_theme_graph(sort_field: int = 1, top_n: int = 30, limit: int = 1
cache_key = f"theme_graph:{sort_field}:{top_n}" cache_key = f"theme_graph:{sort_field}:{top_n}"
data, expires_at = _get_graph_cache(cache_key) data, expires_at = _get_graph_cache(cache_key)
if data is not None: if data is not None and expires_at and expires_at > time.time():
# 缓存新鲜直接返回;过期返回旧数据并后台刷新 # 缓存新鲜直接返回
if not (expires_at and expires_at > time.time()):
_spawn_rebuild(cache_key, sort_field, top_n)
return _trim_graph_result(data, limit) return _trim_graph_result(data, limit)
# 无缓存:同步构建(并发下加锁去重) # 非交易时段过期:行情无实时变化,先返回旧值(秒开),后台异步重建
if data is not None and not _is_trading_time():
_spawn_rebuild(cache_key, sort_field, top_n)
return _trim_graph_result(data, limit)
# 交易时段过期 / 无缓存:同步重建,加锁去重
lock = _REBUILD_LOCKS.setdefault(cache_key, asyncio.Lock()) lock = _REBUILD_LOCKS.setdefault(cache_key, asyncio.Lock())
async with lock: async with lock:
data, expires_at = _get_graph_cache(cache_key) data, expires_at = _get_graph_cache(cache_key)
if data is not None: if data is not None and expires_at and expires_at > time.time():
# 等待锁期间已被其他请求写入 # 等待锁期间已被其他请求刷新
if not (expires_at and expires_at > time.time()):
_spawn_rebuild(cache_key, sort_field, top_n)
return _trim_graph_result(data, limit) return _trim_graph_result(data, limit)
data = await _build_theme_graph(sort_field, top_n) data = await _build_theme_graph(sort_field, top_n)
_set_graph_cache(cache_key, data) _set_graph_cache(cache_key, data)
return _trim_graph_result(data, limit) 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
@@ -0,0 +1,704 @@
# 每日核心股/题材历史 + 活跃核心股滚动表格 — 实现计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 每日收盘后采集核心股前100(按涨幅)+所属题材、题材涨幅前20存历史,并提供「活跃核心股×最近10个A股交易日」涨幅矩阵展示页。
**Architecture:** 后端新增 asyncio 后台定时采集任务(`lifespan` 启动),复用现有 `services/themes.py``fetch_theme_list`/`fetch_theme_graph` 拿数据,写入 3 张新表;新增 `GET /api/core-stocks/active` 接口计算活跃窗口;前端新增 `/core-stocks` 路由渲染股票×10日涨幅矩阵。
**Tech Stack:** Python 3.11 / FastAPI / SQLite(现有) / React 18 / TanStack Router + Query / TypeScript
**参考现有代码:**
- 数据源封装:`backend/services/themes.py`(`fetch_theme_list` 返回 `[{themeCode, themeName, securityName, securityCode, f3, bf3, hotRank, hotValue, hotValueUpLimit, strengthValue, fex5, label}]`,约 623 个题材)
- 数据库:`backend/database.py`(`SCHEMA_SQL` + `init_db()` + `get_connection()`)
- 路由范式:`backend/routes/shares.py`(`get_connection()` + `dict_from_row`)
- 前端 API 范式:`src/lib/theme-api.ts`(`getApiBaseUrl()` + `fetch`)
- 前端页面范式:`src/routes/themes.tsx`(顶栏 + 卡片网格 + React Query)
- 涨跌配色:红涨绿跌 `text-red-500` / `text-green-500`
---
## 文件结构
| 文件 | 动作 | 职责 |
|---|---|---|
| `backend/database.py` | 修改 | 追加 3 张表 DDL |
| `backend/services/daily_collector.py` | 新建 | 后台定时采集 + 幂等入库 |
| `backend/routes/core_stocks.py` | 新建 | `/api/core-stocks/active` 等历史接口 |
| `backend/main.py` | 修改 | lifespan 启动采集任务 + 挂载路由 |
| `src/lib/core-stock-api.ts` | 新建 | 前端 API 客户端 |
| `src/routes/core-stocks.tsx` | 新建 | 展示页 |
| `src/routes/themes.tsx` | 修改 | 加入口链接 |
| `src/routes/hot-map.tsx` | 修改 | 加入口链接 |
| `backend/verify_collector.py` | 新建(临时) | 验证脚本,跑完删除 |
---
### Task 1: 数据库 — 追加 3 张表
**Files:**
- Modify: `backend/database.py`(在 `SCHEMA_SQL` 末尾追加)
- [ ] **Step 1: 修改 `SCHEMA_SQL`,追加建表语句**
`backend/database.py``SCHEMA_SQL` 字符串中,`cache` 表定义后追加:
```python
CREATE TABLE IF NOT EXISTS daily_core_stocks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trade_date TEXT NOT NULL,
stock_code TEXT NOT NULL,
stock_name TEXT NOT NULL,
f3 REAL,
cover_count INTEGER,
rank INTEGER,
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
UNIQUE(trade_date, stock_code)
);
CREATE TABLE IF NOT EXISTS daily_core_stock_themes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trade_date TEXT NOT NULL,
stock_code TEXT NOT NULL,
theme_code TEXT NOT NULL,
theme_name TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
UNIQUE(trade_date, stock_code, theme_code)
);
CREATE TABLE IF NOT EXISTS daily_top_themes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trade_date TEXT NOT NULL,
theme_code TEXT NOT NULL,
theme_name TEXT NOT NULL,
bf3 REAL,
hot_rank INTEGER,
rank INTEGER,
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
UNIQUE(trade_date, theme_code)
);
```
- [ ] **Step 2: 运行验证建表**
```bash
cd backend && ./venv/bin/python -c "from database import init_db; init_db(); from database import get_connection; c=get_connection(); t=[r['name'] for r in c.execute(\"SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'daily_%'\")]; print(t); c.close()"
```
Expected: `['daily_core_stocks', 'daily_core_stock_themes', 'daily_top_themes']`
- [ ] **Step 3: 提交**
```bash
git add backend/database.py && git commit -m "feat: 新增每日核心股/题材历史 3 张表"
```
---
### Task 2: 采集服务 `backend/services/daily_collector.py`
**Files:**
- Create: `backend/services/daily_collector.py`
- [ ] **Step 1: 新建采集服务**
```python
"""每日热点数据采集:核心股前100 + 题材涨幅前20,收盘后自动入库
由 main.lifespan 启动后台任务;幂等(按交易日 UNIQUE 去重)。
"""
import asyncio
from datetime import datetime, time as dtime, timezone, timedelta
from typing import Optional
from database import get_connection
from services.themes import fetch_theme_list
_CST = timezone(timedelta(hours=8))
# 每天采集的后台任务:每 CHECK_INTERVAL 分钟检查一次
CHECK_INTERVAL_SECONDS = 300
COLLECT_AFTER_TIME = dtime(15, 0) # 收盘后 15:00 开始允许采集
CORE_STOCK_LIMIT = 100 # 核心股前100
TOP_THEME_LIMIT = 20 # 题材涨幅前20
def _is_trading_day(d: datetime) -> bool:
"""周一至周五视为交易日(与 themes._is_trading_time 一致,不处理法定节假日)"""
return d.weekday() < 5
def _has_collected(trade_date: str) -> bool:
"""当日核心股是否已采集"""
conn = get_connection()
try:
row = conn.execute(
"SELECT 1 FROM daily_core_stocks WHERE trade_date = ? LIMIT 1",
(trade_date,),
).fetchone()
return row is not None
finally:
conn.close()
async def collect_daily(trade_date: str, dry_run: bool = False) -> dict:
"""采集指定交易日数据并入库。
Args:
trade_date: YYYY-MM-DD
dry_run: True 只打印不写库(用于验证)
Returns:
{"core_count": int, "theme_count": int, "skipped": bool}
"""
if _has_collected(trade_date):
print(f"[collector] {trade_date} 已采集,跳过")
return {"core_count": 0, "theme_count": 0, "skipped": True}
# 1. 拉取全部题材列表(含领涨股,作为当日全部股票的采样来源)
themes = await fetch_theme_list(1, False)
if not themes:
print(f"[collector] {trade_date} 题材列表为空(东财失败),跳过")
return {"core_count": 0, "theme_count": 0, "skipped": True}
# 2. 核心股前100:按领涨股 f3 降序,去重(同一股票可能是多个题材领涨股)
stock_map: dict[str, dict] = {}
for t in themes:
code = t.get("securityCode")
if not code:
continue
if code not in stock_map or (t.get("f3") or 0) > (stock_map[code].get("f3") or 0):
stock_map[code] = {
"stock_code": code,
"stock_name": t.get("securityName", ""),
"f3": t.get("f3"),
}
core_stocks = sorted(
stock_map.values(), key=lambda x: -(x["f3"] or 0)
)[:_CORE_STOCK_LIMIT]
# 3. 题材涨幅前20bf3 降序
top_themes = sorted(themes, key=lambda x: -(x.get("bf3") or 0))[:_TOP_THEME_LIMIT]
if dry_run:
print(f"[collector] {trade_date} 核心股 {len(core_stocks)} 只,题材前20 {len(top_themes)}")
return {"core_count": len(core_stocks), "theme_count": len(top_themes), "skipped": False}
# 4. 入库(事务,UNIQUE 幂等)
conn = get_connection()
try:
for i, s in enumerate(core_stocks, start=1):
conn.execute(
"INSERT OR IGNORE INTO daily_core_stocks (trade_date, stock_code, stock_name, f3, rank) VALUES (?,?,?,?,?)",
(trade_date, s["stock_code"], s["stock_name"], s["f3"], i),
)
for i, t in enumerate(top_themes, start=1):
conn.execute(
"INSERT OR IGNORE INTO daily_top_themes (trade_date, theme_code, theme_name, bf3, hot_rank, rank) VALUES (?,?,?,?,?,?)",
(trade_date, t["themeCode"], t["themeName"], t.get("bf3"), t.get("hotRank"), i),
)
conn.commit()
finally:
conn.close()
print(f"[collector] {trade_date} 已采集:核心股 {len(core_stocks)} 只,题材前20 {len(top_themes)}")
return {"core_count": len(core_stocks), "theme_count": len(top_themes), "skipped": False}
async def collector_loop(stop: Optional[asyncio.Event] = None) -> None:
"""后台循环:每个交易日 15:00 后自动采集当日数据(幂等)"""
while True:
try:
now = datetime.now(_CST)
if _is_trading_day(now) and now.time() >= COLLECT_AFTER_TIME:
trade_date = now.strftime("%Y-%m-%d")
if not _has_collected(trade_date):
await collect_daily(trade_date)
except Exception as e:
print(f"[collector] 采集异常: {e}")
if stop is not None and stop.is_set():
break
await asyncio.sleep(CHECK_INTERVAL_SECONDS)
```
> 注:上面代码中 `_CORE_STOCK_LIMIT` 应为 `CORE_STOCK_LIMIT`(变量名一致),下面 Step 2 统一修正。
- [ ] **Step 2: 修正变量名并运行验证脚本**
`collect_daily``[_CORE_STOCK_LIMIT]` 改为 `[CORE_STOCK_LIMIT]`
验证(dry_run 不写库):
```bash
cd backend && ./venv/bin/python -c "
import asyncio, sys
sys.path.insert(0, '.')
from services.daily_collector import collect_daily
async def main():
r = await collect_daily('2026-08-10', dry_run=True)
print(r)
asyncio.run(main())
"
```
Expected: 输出核心股数量(几十只)与题材数 10,`skipped: False`(首次)。
- [ ] **Step 3: 验证幂等(入库后再跑应 skipped)**
```bash
cd backend && ./venv/bin/python -c "
import asyncio, sys
sys.path.insert(0, '.')
from services.daily_collector import collect_daily
async def main():
r = await collect_daily('2026-08-10', dry_run=False) # 真实入库
print(r)
r2 = await collect_daily('2026-08-10', dry_run=False) # 再次 → skipped
print(r2)
asyncio.run(main())
"
```
Expected: 第一次入库,第二次 `skipped: True`
- [ ] **Step 4: 提交**
```bash
git add backend/services/daily_collector.py && git commit -m "feat: 每日核心股/题材采集服务(幂等)"
```
---
### Task 3: 历史接口 `backend/routes/core_stocks.py`
**Files:**
- Create: `backend/routes/core_stocks.py`
- [ ] **Step 1: 新建路由**
```python
"""核心股历史接口:活跃核心股 + 指定日核心股/题材前20"""
from fastapi import APIRouter, Query
from fastapi.responses import JSONResponse
from database import get_connection, dict_from_row
router = APIRouter()
_NO_CACHE_HEADERS = {"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0"}
def _recent_trade_dates(conn, n: int = 10) -> list[str]:
"""最近 n 个有数据的交易日(升序)"""
rows = conn.execute(
"SELECT DISTINCT trade_date FROM daily_core_stocks ORDER BY trade_date DESC LIMIT ?",
(n,),
).fetchall()
return [r["trade_date"] for r in reversed(rows)]
@router.get("/active", summary="活跃核心股 + 最近10日涨幅矩阵")
async def active_core_stocks():
conn = get_connection()
try:
dates = _recent_trade_dates(conn, 10)
if not dates:
return JSONResponse({"dates": [], "stocks": []}, headers=_NO_CACHE_HEADERS)
# 窗口内出现过且最近一次出现距今天数 <= 10 个交易日
placeholders = ",".join("?" * len(dates))
rows = conn.execute(
f"""SELECT trade_date, stock_code, stock_name, f3 FROM daily_core_stocks
WHERE trade_date IN ({placeholders})
ORDER BY trade_date DESC, rank ASC""",
dates,
).fetchall()
# 组装 per-stock:每日涨幅 + 出现次数 + 最近上榜
from collections import OrderedDict
stock_days: dict[str, dict] = {}
for r in rows:
code = r["stock_code"]
s = stock_days.setdefault(code, {
"stockCode": code,
"stockName": r["stock_name"],
"dailyGains": {},
"appearCount": 0,
"lastAppear": None,
})
s["dailyGains"][r["trade_date"]] = r["f3"]
s["appearCount"] += 1
if s["lastAppear"] is None or r["trade_date"] > s["lastAppear"]:
s["lastAppear"] = r["trade_date"]
stocks = list(stock_days.values())
stocks.sort(key=lambda x: (-x["appearCount"], -(x.get("lastAppear") or "")))
return JSONResponse({"dates": dates, "stocks": stocks}, headers=_NO_CACHE_HEADERS)
finally:
conn.close()
@router.get("/history", summary="指定交易日核心股(含所属题材)")
async def core_stock_history(date: str = Query(..., description="交易日 YYYY-MM-DD")):
conn = get_connection()
try:
rows = conn.execute(
"SELECT * FROM daily_core_stocks WHERE trade_date = ? ORDER BY rank ASC", (date,)
).fetchall()
items = []
for r in rows:
d = dict_from_row(r)
themes = conn.execute(
"SELECT theme_code, theme_name FROM daily_core_stock_themes WHERE trade_date = ? AND stock_code = ?",
(date, d["stock_code"]),
).fetchall()
d["themes"] = [dict(t) for t in themes]
items.append(d)
return JSONResponse({"date": date, "items": items}, headers=_NO_CACHE_HEADERS)
finally:
conn.close()
```
- [ ] **Step 2: 验证 active 接口(用 Task 2 入库的数据)**
```bash
cd backend && ./venv/bin/python -c "
import sys; sys.path.insert(0, '.')
from routes.core_stocks import active_core_stocks
import asyncio
async def main():
r = await active_core_stocks()
print('dates:', r.body[:200] if hasattr(r,'body') else r)
asyncio.run(main())
"
```
> 上面直接调函数拿的是 JSONResponse,验证方式见 Step 3(直接查库验证逻辑更直观)。
- [ ] **Step 3: 验证窗口计算(直接查库,核对数据结构)**
```bash
cd backend && ./venv/bin/python -c "
import sys; sys.path.insert(0, '.')
from database import get_connection
conn = get_connection()
dates = [r['trade_date'] for r in conn.execute('SELECT DISTINCT trade_date FROM daily_core_stocks ORDER BY trade_date DESC LIMIT 10').fetchall()]
print('最近日期:', dates)
rows = conn.execute('SELECT COUNT(*) AS n FROM daily_core_stocks').fetchone()
print('总记录:', rows['n'])
conn.close()
"
```
Expected: 打印最近日期列表(1 个日期)和总记录数(与核心股数一致)。
- [ ] **Step 4: 提交**
```bash
git add backend/routes/core_stocks.py && git commit -m "feat: 核心股历史/活跃接口"
```
---
### Task 4: 挂载路由 + 启动采集任务 `backend/main.py`
**Files:**
- Modify: `backend/main.py`
- [ ] **Step 1: 修改 main.py**
`from routes import ...``core_stocks`,lifespan 里启动采集任务,注册路由:
```python
from routes import stock, collections, shares, sectors, themes, core_stocks
from services.daily_collector import collector_loop
@asynccontextmanager
async def lifespan(app: FastAPI):
init_db()
task = asyncio.create_task(collector_loop())
try:
yield
finally:
task.cancel()
```
并在 `app.include_router` 区加:
```python
app.include_router(core_stocks.router, prefix="/api/core-stocks")
```
> 注意:需确保 `import asyncio` 在文件顶部。lifespan 里 `yield` 前启动任务,`finally` 里 cancel,符合 FastAPI 生命周期。
- [ ] **Step 2: 语法检查**
```bash
cd backend && ./venv/bin/python -c "import ast; ast.parse(open('main.py').read()); print('语法 OK')"
```
Expected: `语法 OK`
- [ ] **Step 3: 启动服务冒烟测试**
```bash
cd backend && (./venv/bin/python -m uvicorn main:app --port 8000 &) && sleep 3 && curl -s "http://localhost:8000/api/core-stocks/active" | head -c 300; echo; kill %1 2>/dev/null
```
Expected: 返回 `{"dates": [...], "stocks": [...]}` JSON(至少含 Task 2 入库的当日数据)。
- [ ] **Step 4: 提交**
```bash
git add backend/main.py && git commit -m "feat: 挂载核心股路由并启动采集任务"
```
---
### Task 5: 前端 API 客户端 `src/lib/core-stock-api.ts`
**Files:**
- Create: `src/lib/core-stock-api.ts`
- [ ] **Step 1: 新建 API 客户端**
```typescript
// 核心股历史数据获取工具
import { getApiBaseUrl } from "@/lib/api-client";
export interface ActiveCoreStock {
stockCode: string;
stockName: string;
dailyGains: Record<string, number>; // 日期 -> 当日涨幅
appearCount: number;
lastAppear: string | null;
}
export interface ActiveCoreStocksResponse {
dates: string[];
stocks: ActiveCoreStock[];
}
export interface CoreStockHistoryItem {
id: number;
trade_date: string;
stock_code: string;
stock_name: string;
f3: number | null;
cover_count: number | null;
rank: number;
themes: { theme_code: string; theme_name: string }[];
}
export interface CoreStockHistoryResponse {
date: string;
items: CoreStockHistoryItem[];
}
/** 获取活跃核心股 + 最近10日涨幅矩阵 */
export async function fetchActiveCoreStocks(): Promise<ActiveCoreStocksResponse> {
const baseUrl = getApiBaseUrl();
const resp = await fetch(`${baseUrl}/api/core-stocks/active`, { method: "GET", cache: "no-store" });
if (!resp.ok) return { dates: [], stocks: [] };
return resp.json();
}
/** 获取指定交易日核心股(含所属题材) */
export async function fetchCoreStockHistory(date: string): Promise<CoreStockHistoryResponse | null> {
const baseUrl = getApiBaseUrl();
const resp = await fetch(`${baseUrl}/api/core-stocks/history?date=${date}`, { method: "GET", cache: "no-store" });
if (!resp.ok) return null;
return resp.json();
}
```
- [ ] **Step 2: 提交**
```bash
git add src/lib/core-stock-api.ts && git commit -m "feat: 核心股历史前端 API 客户端"
```
---
### Task 6: 展示页 `src/routes/core-stocks.tsx`
**Files:**
- Create: `src/routes/core-stocks.tsx`
- [ ] **Step 1: 新建展示页**
```tsx
import { createFileRoute, Link } from "@tanstack/react-router";
import { useQuery } from "@tanstack/react-query";
import { fetchActiveCoreStocks } from "@/lib/core-stock-api";
import { ArrowLeft, RefreshCw, Flame } from "lucide-react";
export const Route = createFileRoute("/core-stocks")({
component: CoreStocksPage,
});
/** 格式化涨幅,红涨绿跌 */
function formatGain(v: number | null | undefined): string {
if (v == null) return "·";
const s = v > 0 ? `+${v.toFixed(2)}%` : `${v.toFixed(2)}%`;
return s;
}
function CoreStocksPage() {
const { data, isLoading, isFetching, refetch } = useQuery({
queryKey: ["core-stocks", "active"],
queryFn: fetchActiveCoreStocks,
staleTime: 60_000,
retry: false,
});
const dates = data?.dates ?? [];
const stocks = data?.stocks ?? [];
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="/hot-map" 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 pb-8">
<p className="text-[10px] text-muted-foreground mb-2">
10 · · {stocks.length}
</p>
{isLoading ? (
<div className="animate-pulse rounded-xl bg-muted h-32" />
) : dates.length === 0 ? (
<div className="text-center text-sm text-muted-foreground py-16">
</div>
) : (
<div className="overflow-x-auto rounded-xl border bg-card">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="px-3 py-2 text-left font-medium whitespace-nowrap"></th>
{dates.map((d) => (
<th key={d} className="px-2 py-2 text-right font-medium tabular-nums whitespace-nowrap">
{d.slice(5)}
</th>
))}
<th className="px-2 py-2 text-right font-medium"></th>
</tr>
</thead>
<tbody>
{stocks.map((s) => (
<tr key={s.stockCode} className="border-b last:border-0 hover:bg-muted/30">
<td className="px-3 py-1.5 whitespace-nowrap">
<span className="font-medium">{s.stockName}</span>
<span className="ml-1 text-[10px] text-muted-foreground">{s.stockCode}</span>
</td>
{dates.map((d) => {
const g = s.dailyGains[d];
const cls = g == null ? "text-muted-foreground/40" : g >= 0 ? "text-red-500" : "text-green-500";
return (
<td key={d} className={`px-2 py-1.5 text-right tabular-nums whitespace-nowrap ${cls}`}>
{formatGain(g)}
</td>
);
})}
<td className="px-2 py-1.5 text-right tabular-nums whitespace-nowrap">
<span className="inline-flex items-center gap-0.5 text-orange-500">
<Flame className="h-3 w-3" />
{s.appearCount}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
);
}
```
- [ ] **Step 2: 提交**
```bash
git add src/routes/core-stocks.tsx && git commit -m "feat: 活跃核心股 10 日涨幅矩阵页面"
```
---
### Task 7: 入口链接 + 验证 + 清理
**Files:**
- Modify: `src/routes/themes.tsx`
- Modify: `src/routes/hot-map.tsx`
- Delete(临时): `backend/verify_collector.py`
- [ ] **Step 1: 在题材页顶栏加「核心股」入口**
`src/routes/themes.tsx` 顶栏,在「热点穿透」链接旁加:
```tsx
<Link to="/core-stocks" className="text-xs text-primary flex items-center gap-1 hover:opacity-80 transition-opacity">
<Flame className="h-3.5 w-3.5" />
</Link>
```
需在 import 区加 `Flame`(若已从 `lucide-react` 引入则复用)。同时 `Link to="/core-stocks"` 需要路由存在(Task 6 已建)。
- [ ] **Step 2: 在热点穿透页顶栏加「核心股」入口**
`src/routes/hot-map.tsx` 顶栏加同类链接(参考 Step 1)。
- [ ] **Step 3: 前端构建验证**
```bash
cd /Users/cjun/Code/github/auv && pnpm build
```
Expected: 构建成功,无 TS 错误。若报 `Link to="/core-stocks"` 类型错误,确认路由文件 `core-stocks.tsx``createFileRoute` 路径与 `to` 一致。
- [ ] **Step 4: 清理临时验证文件**
```bash
rm -f backend/verify_collector.py
```
- [ ] **Step 5: 提交**
```bash
git add src/routes/themes.tsx src/routes/hot-map.tsx
git commit -m "feat: 题材/热点穿透页加入核心股追踪入口"
```
---
## 自检结果
- **Spec 覆盖:** 3 张表(Task1)、采集服务(Task2)、3 接口(Task3: active/history/themes-history)、页面(Task6)、入口(Task7)、定时采集(Task4)全部有对应任务 ✓
- **无占位符:** 每步含完整代码与命令 ✓
- **类型一致性:** `trade_date`/`stock_code`/`bf3`/`hot_rank` 等字段全 plan 统一;`fetchActiveCoreStocks` 返回结构与后端 `active_core_stocks` 一致 ✓
- **边界:** 核心股不足100 存实际数(Task2 取 slice)、当日重复采集幂等(Task2 `_has_collected`)、东财失败跳过(Task2 空列表判断)、空态(Task6)均已覆盖 ✓
## 注意
- Task 2 的 `collect_daily` 从题材列表的领涨股构建股票池(约 623 个题材的领涨股去重,通常几十到上百只),而非热点穿透的完整股票池——若需含非领涨股,需改用 `fetch_theme_graph` 的 stocks。当前实现符合"全部股票按涨幅取前100"的目标口径(题材领涨股 + 去重)。
@@ -0,0 +1,156 @@
# 每日热点核心股 / 题材历史记录 + 活跃核心股滚动表格
日期:2026-08-10
## 1. 目标
为数据分析和发掘积累历史数据,并提供活跃核心股的滚动展示:
1. 每个交易日收盘后,采集**核心股前 100**(当日全部股票按涨幅降序取前 100)及其**所属题材**,保存历史。
2. 每个交易日收盘后,采集**题材(概念)板块涨幅前 20**,保存历史。
3. 新展示页:**活跃核心股 × 最近 10 个 A 股交易日**涨幅矩阵表格;新核心股加入,**超过 10 个 A 股交易日未出现则踢出**。
## 2. 已确认的决策
| 决策点 | 选择 |
|---|---|
| 核心股口径 | 当日全部股票按涨幅(f3)降序取前 100(不限覆盖题材数) |
| 板块口径 | 题材/概念板块,按涨幅(bf3)降序取前 20 |
| 采集触发 | 方案 A:asyncio 后台任务,每日收盘后自动采集 |
| 表格布局 | 股票 × 最近 10 个 A 股交易日列矩阵 |
| 10 日窗口 | 10 个 A 股交易日(跳过节假/周末) |
| 所属题材完整度 | 折中:以热点穿透采样题材为主,东财压力允许时尽力补全 |
## 3. 数据模型(新增 3 张表)
```sql
CREATE TABLE daily_core_stocks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trade_date TEXT NOT NULL, -- 交易日 YYYY-MM-DD
stock_code TEXT NOT NULL, -- 股票代码
stock_name TEXT NOT NULL, -- 股票名称
f3 REAL, -- 当日涨幅%
cover_count INTEGER, -- 覆盖题材数(采样)
rank INTEGER, -- 当日涨幅排名 1-100
created_at TEXT DEFAULT (datetime('now','localtime')),
UNIQUE(trade_date, stock_code)
);
CREATE TABLE daily_core_stock_themes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trade_date TEXT NOT NULL,
stock_code TEXT NOT NULL,
theme_code TEXT NOT NULL,
theme_name TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now','localtime')),
UNIQUE(trade_date, stock_code, theme_code)
);
CREATE TABLE daily_top_themes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trade_date TEXT NOT NULL,
theme_code TEXT NOT NULL,
theme_name TEXT NOT NULL,
bf3 REAL, -- 题材涨幅%
hot_rank INTEGER, -- 热度排名
rank INTEGER, -- 当日板块涨幅排名 1-20
created_at TEXT DEFAULT (datetime('now','localtime')),
UNIQUE(trade_date, theme_code)
);
```
## 4. 采集服务 `backend/services/daily_collector.py`
### 4.1 触发机制(方案 A
- `lifespan` 启动时拉起一个 asyncio 后台任务协程。
- 协程循环(如每 5 分钟检查一次):
- 是否**交易日**(周一至周五,非节假日)。
- 是否**收盘后**(北京时间 > 15:00)。
- 当日数据是否**已采集**(按 `trade_date` 查库,幂等去重)。
- 条件满足 → 触发采集。
### 4.2 采集逻辑
1. **核心股前 100**:调用 `fetch_theme_list(1, False)``fetch_theme_graph` 得到当日全部股票,按 `f3` 降序取前 100。若不足 100 只,存实际数量。
2. **所属题材**:以热点穿透采样的 `themeCodes` 为主存入 `daily_core_stock_themes`
3. **题材前 20**:从 `fetch_theme_list(1, False)``bf3` 降序前 20,连同 `themeName`/`hotRank` 存入 `daily_top_themes`
4. 同一交易日重复触发不重复写入(UNIQUE 去重 + 检查)。
5. 采集失败(东财 403/网络)→ 跳过当日,下轮重试;记录日志。
### 4.3 补全(折中方案)
- 东财压力允许时,对核心股调用个股题材接口尽力补全。
- 优先保证核心股前 100 与题材前 20 的完整性,补全为附加增强,失败不影响主流程。
## 5. 新接口(`backend/routes/`
| 接口 | 说明 |
|---|---|
| `GET /api/core-stocks/active` | 活跃核心股 + 最近 10 日涨幅矩阵 |
| `GET /api/core-stocks/history?date=` | 指定交易日的核心股(含所属题材) |
| `GET /api/themes/history?date=` | 指定交易日的题材前 20 |
### active 接口返回结构
```json
{
"dates": ["2026-08-03", "...", "2026-08-10"],
"stocks": [
{
"stockCode": "601606",
"stockName": "长城军工",
"coverCount": 9,
"lastAppear": "2026-08-10",
"daysSinceLastAppear": 0,
"appearCount": 5,
"dailyGains": { "2026-08-03": 10.0, "2026-08-10": 10.0 }
}
]
}
```
- `dates`:最近 10 个 A 股交易日(升序,最右为最新)。
- `stocks`10 日窗口内出现过的活跃核心股。
- `dailyGains`:日期 → 当日涨幅;未上榜日无该键。
## 6. 新展示页(前端 `/core-stocks`
### 6.1 页面结构
- 顶栏:返回、标题「核心股追踪」、刷新按钮(与 `/hot-map` 一致风格)。
- 表格:**股票 × 最近 10 个 A 股交易日**列矩阵。
- 行:活跃核心股(10 个 A 股交易日内出现过),按 `appearCount` 降序、`lastAppear` 降序排列。
- 列:最近 10 个 A 股交易日,最右为最新。
- 单元格:当日涨幅(红涨绿跌,A 股惯例);未上榜留空(`·`)。
- 每只股票显示累计出现次数、最近上榜日期、所属题材数。
### 6.2 数据获取
- 前端 `useQuery``GET /api/core-stocks/active`
- `staleTime` 与题材页一致(30s 或 60s),盘中可手动刷新。
### 6.3 路由
- 新建 `src/routes/core-stocks.tsx`,路由 `/core-stocks`
- 从题材页 `/themes` 和热点穿透页 `/hot-map` 顶部加入口链接。
## 7. 错误处理与边界
- **东财 403/采集失败**:跳过当日采集,下轮重试;不影响已存历史。
- **当日重复采集**:UNIQUE 约束 + 入库前检查,幂等。
- **核心股不足 100**:存实际数量,不补齐。
- **无历史数据**:部署后开始累积;active 接口在无数据时返回空 `stocks` 与空 `dates`
- **交易日历**:以自然周一到周五判定交易日(不处理法定节假日调休的深度历法),与现有 `_is_trading_time` 一致。
## 8. 测试
- 采集服务:幂等(重复触发不重复写)、去重、失败重试逻辑。
- active 接口:窗口计算、踢出规则(>10 个交易日未出现不返回)、涨幅矩阵正确性。
- 前端页面:空态、有数据态、10 日窗口滚动。
## 9. 范围外(YAGNI
- 不做法定的深度交易日历(节假日调休)。
- 不做个股涨幅的增量更新(历史数据一次性采集,之后不补更)。
- 不做板块成分股的每日存储。
+60
View File
@@ -0,0 +1,60 @@
// 核心股历史数据获取工具
import { getApiBaseUrl } from "@/lib/api-client";
export interface ActiveCoreStock {
stockCode: string;
stockName: string;
coverCount: number | null; // 覆盖题材数
dailyGains: Record<string, number | null>; // 日期 -> 当日涨幅(可空)
appearCount: number;
lastAppear: string | null;
daysSinceLastAppear: number; // 最近上榜距窗口最新交易日的自然日差
themes: { theme_code: string; theme_name: string }[]; // 所属题材列表
}
export interface ActiveCoreStocksResponse {
dates: string[];
stocks: ActiveCoreStock[];
}
export interface CoreStockHistoryItem {
id: number;
trade_date: string;
stock_code: string;
stock_name: string;
f3: number | null;
cover_count: number | null;
rank: number;
themes: { theme_code: string; theme_name: string }[];
}
export interface CoreStockHistoryResponse {
date: string;
items: CoreStockHistoryItem[];
}
/** 获取活跃核心股 + 最近10日涨幅矩阵 */
export async function fetchActiveCoreStocks(): Promise<ActiveCoreStocksResponse> {
const baseUrl = getApiBaseUrl();
try {
const resp = await fetch(`${baseUrl}/api/core-stocks/active`, { method: "GET", cache: "no-store" });
if (!resp.ok) return { dates: [], stocks: [] };
return resp.json();
} catch (err) {
console.error("[core-stock-api] 获取活跃核心股失败:", err);
return { dates: [], stocks: [] };
}
}
/** 获取指定交易日核心股(含所属题材) */
export async function fetchCoreStockHistory(date: string): Promise<CoreStockHistoryResponse | null> {
const baseUrl = getApiBaseUrl();
try {
const resp = await fetch(`${baseUrl}/api/core-stocks/history?date=${date}`, { method: "GET", cache: "no-store" });
if (!resp.ok) return null;
return resp.json();
} catch (err) {
console.error("[core-stock-api] 获取核心股历史失败:", err);
return null;
}
}
+70
View File
@@ -159,12 +159,82 @@ export async function fetchThemeStocks(themeCode: string): Promise<ThemeStocksRe
} }
} }
/* ── 题材相关新闻(分页) ── */
export interface ThemeNewsItem {
newsCode: string;
newsTitle: string;
newsMediaName: string;
showDateTime: number | null;
showDateTimeFormat: string | null;
commentCount: number;
themeCode: string;
themeName: string;
}
export interface ThemeNewsResponse {
total: number;
maxEuTime: string;
list: ThemeNewsItem[];
}
/**
* 获取题材相关新闻(分页,maxEuTime 为翻页游标)
*/
export async function fetchThemeNews(
themeCode: string,
pageNum: number = 1,
pageSize: number = 10,
maxEuTime: string = "",
): Promise<ThemeNewsResponse | null> {
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<ThemeQuote | null> {
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 { export interface GraphTheme {
themeCode: string; themeCode: string;
themeName: string; themeName: string;
stockCount: number; // 题材内股票数 stockCount: number; // 题材内股票数
bf3: number | null; // 题材涨幅
} }
export interface GraphStock { export interface GraphStock {
+21
View File
@@ -12,6 +12,7 @@ import { Route as rootRouteImport } from './routes/__root'
import { Route as ThemesRouteImport } from './routes/themes' import { Route as ThemesRouteImport } from './routes/themes'
import { Route as SectorsRouteImport } from './routes/sectors' import { Route as SectorsRouteImport } from './routes/sectors'
import { Route as HotMapRouteImport } from './routes/hot-map' import { Route as HotMapRouteImport } from './routes/hot-map'
import { Route as CoreStocksRouteImport } from './routes/core-stocks'
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 ThemeCodeRouteImport } from './routes/theme.$code'
import { Route as StockCodeRouteImport } from './routes/stock.$code' import { Route as StockCodeRouteImport } from './routes/stock.$code'
@@ -32,6 +33,11 @@ const HotMapRoute = HotMapRouteImport.update({
path: '/hot-map', path: '/hot-map',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const CoreStocksRoute = CoreStocksRouteImport.update({
id: '/core-stocks',
path: '/core-stocks',
getParentRoute: () => rootRouteImport,
} as any)
const IndexRoute = IndexRouteImport.update({ const IndexRoute = IndexRouteImport.update({
id: '/', id: '/',
path: '/', path: '/',
@@ -55,6 +61,7 @@ const ShareCodeRoute = ShareCodeRouteImport.update({
export interface FileRoutesByFullPath { export interface FileRoutesByFullPath {
'/': typeof IndexRoute '/': typeof IndexRoute
'/core-stocks': typeof CoreStocksRoute
'/hot-map': typeof HotMapRoute '/hot-map': typeof HotMapRoute
'/sectors': typeof SectorsRoute '/sectors': typeof SectorsRoute
'/themes': typeof ThemesRoute '/themes': typeof ThemesRoute
@@ -64,6 +71,7 @@ export interface FileRoutesByFullPath {
} }
export interface FileRoutesByTo { export interface FileRoutesByTo {
'/': typeof IndexRoute '/': typeof IndexRoute
'/core-stocks': typeof CoreStocksRoute
'/hot-map': typeof HotMapRoute '/hot-map': typeof HotMapRoute
'/sectors': typeof SectorsRoute '/sectors': typeof SectorsRoute
'/themes': typeof ThemesRoute '/themes': typeof ThemesRoute
@@ -74,6 +82,7 @@ export interface FileRoutesByTo {
export interface FileRoutesById { export interface FileRoutesById {
__root__: typeof rootRouteImport __root__: typeof rootRouteImport
'/': typeof IndexRoute '/': typeof IndexRoute
'/core-stocks': typeof CoreStocksRoute
'/hot-map': typeof HotMapRoute '/hot-map': typeof HotMapRoute
'/sectors': typeof SectorsRoute '/sectors': typeof SectorsRoute
'/themes': typeof ThemesRoute '/themes': typeof ThemesRoute
@@ -85,6 +94,7 @@ export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: fullPaths:
| '/' | '/'
| '/core-stocks'
| '/hot-map' | '/hot-map'
| '/sectors' | '/sectors'
| '/themes' | '/themes'
@@ -94,6 +104,7 @@ export interface FileRouteTypes {
fileRoutesByTo: FileRoutesByTo fileRoutesByTo: FileRoutesByTo
to: to:
| '/' | '/'
| '/core-stocks'
| '/hot-map' | '/hot-map'
| '/sectors' | '/sectors'
| '/themes' | '/themes'
@@ -103,6 +114,7 @@ export interface FileRouteTypes {
id: id:
| '__root__' | '__root__'
| '/' | '/'
| '/core-stocks'
| '/hot-map' | '/hot-map'
| '/sectors' | '/sectors'
| '/themes' | '/themes'
@@ -113,6 +125,7 @@ export interface FileRouteTypes {
} }
export interface RootRouteChildren { export interface RootRouteChildren {
IndexRoute: typeof IndexRoute IndexRoute: typeof IndexRoute
CoreStocksRoute: typeof CoreStocksRoute
HotMapRoute: typeof HotMapRoute HotMapRoute: typeof HotMapRoute
SectorsRoute: typeof SectorsRoute SectorsRoute: typeof SectorsRoute
ThemesRoute: typeof ThemesRoute ThemesRoute: typeof ThemesRoute
@@ -144,6 +157,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof HotMapRouteImport preLoaderRoute: typeof HotMapRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/core-stocks': {
id: '/core-stocks'
path: '/core-stocks'
fullPath: '/core-stocks'
preLoaderRoute: typeof CoreStocksRouteImport
parentRoute: typeof rootRouteImport
}
'/': { '/': {
id: '/' id: '/'
path: '/' path: '/'
@@ -177,6 +197,7 @@ declare module '@tanstack/react-router' {
const rootRouteChildren: RootRouteChildren = { const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute, IndexRoute: IndexRoute,
CoreStocksRoute: CoreStocksRoute,
HotMapRoute: HotMapRoute, HotMapRoute: HotMapRoute,
SectorsRoute: SectorsRoute, SectorsRoute: SectorsRoute,
ThemesRoute: ThemesRoute, ThemesRoute: ThemesRoute,
+143
View File
@@ -0,0 +1,143 @@
import { createFileRoute, Link } from "@tanstack/react-router";
import { useQuery } from "@tanstack/react-query";
import { Fragment } from "react";
import { fetchActiveCoreStocks } from "@/lib/core-stock-api";
import { ArrowLeft, RefreshCw, Flame } from "lucide-react";
export const Route = createFileRoute("/core-stocks")({
component: CoreStocksPage,
});
/** 格式化涨幅,红涨绿跌 */
function formatGain(v: number | null | undefined): string {
if (v == null) return "·";
const s = v >= 0 ? `+${v.toFixed(2)}%` : `${v.toFixed(2)}%`;
return s;
}
function CoreStocksPage() {
const { data, isLoading, isFetching, refetch } = useQuery({
queryKey: ["core-stocks", "active"],
queryFn: fetchActiveCoreStocks,
staleTime: 60_000,
retry: false,
});
const dates = data?.dates ?? [];
const stocks = data?.stocks ?? [];
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="/hot-map" className="hover:opacity-70 transition-opacity" aria-label="返回">
<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 pb-8">
<p className="text-[10px] text-muted-foreground mb-2">
10 · · {stocks.length}
</p>
{isLoading ? (
<div className="animate-pulse rounded-xl bg-muted h-32" />
) : dates.length === 0 || stocks.length === 0 ? (
<div className="text-center text-sm text-muted-foreground py-16">
</div>
) : (
<div className="overflow-x-auto rounded-xl border bg-card">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th scope="col" className="px-3 py-2 text-left font-medium whitespace-nowrap"></th>
{dates.map((d) => (
<th key={d} scope="col" title={d} className="px-2 py-2 text-right font-medium tabular-nums whitespace-nowrap">
{d.slice(5)}
</th>
))}
<th scope="col" className="px-2 py-2 text-right font-medium" title="覆盖题材数"></th>
<th scope="col" className="px-2 py-2 text-right font-medium whitespace-nowrap"></th>
<th scope="col" className="px-2 py-2 text-right font-medium"></th>
</tr>
</thead>
<tbody>
{stocks.map((s) => (
<Fragment key={s.stockCode}>
<tr className="border-b last:border-0 hover:bg-muted/30">
<td className="px-3 py-1.5 whitespace-nowrap">
<Link
to="/stock/$code"
params={{ code: s.stockCode }}
className="font-medium hover:text-primary hover:underline"
>
{s.stockName}
</Link>
<span className="ml-1 text-[10px] text-muted-foreground">{s.stockCode}</span>
</td>
{dates.map((d) => {
const g = s.dailyGains[d];
const cls = g == null ? "text-muted-foreground/40" : g >= 0 ? "text-red-500" : "text-green-500";
return (
<td key={d} className={`px-2 py-1.5 text-right tabular-nums whitespace-nowrap ${cls}`}>
{formatGain(g)}
</td>
);
})}
<td className="px-2 py-1.5 text-right tabular-nums whitespace-nowrap text-muted-foreground">
{s.coverCount ?? "·"}
</td>
<td className="px-2 py-1.5 text-right tabular-nums whitespace-nowrap text-muted-foreground">
{s.lastAppear ? s.lastAppear.slice(5) : "·"}
</td>
<td className="px-2 py-1.5 text-right tabular-nums whitespace-nowrap">
<span className="inline-flex items-center gap-0.5 text-orange-500">
<Flame className="h-3 w-3" />
{s.appearCount}
</span>
</td>
</tr>
<tr className="border-b bg-muted/20">
<td colSpan={dates.length + 4} className="px-3 py-2">
<div className="text-[11px] text-muted-foreground mb-1"></div>
{s.themes && s.themes.length > 0 ? (
<div className="flex flex-wrap gap-1">
{s.themes.map((t) => (
<Link
key={t.theme_code}
to="/theme/$code"
params={{ code: t.theme_code }}
className="px-1.5 py-0.5 rounded bg-primary/10 text-primary text-[11px] hover:bg-primary/20 transition-colors"
>
{t.theme_name}
</Link>
))}
</div>
) : (
<span className="text-xs text-muted-foreground"></span>
)}
</td>
</tr>
</Fragment>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
);
}
+152 -40
View File
@@ -35,6 +35,10 @@ const MODES: { key: 1 | 4; label: string }[] = [
/* 图视图股票节点上限:按覆盖题材数降序保留最高穿透度的核心股 */ /* 图视图股票节点上限:按覆盖题材数降序保留最高穿透度的核心股 */
const MAX_STOCK_NODES = 800; const MAX_STOCK_NODES = 800;
/* 题材节点半径范围:按题材涨幅绝对值平方根映射(涨得越猛球越大,小涨幅区分更明显) */
const THEME_MIN_RADIUS = 6;
const THEME_MAX_RADIUS = 30;
/* 视图切换:关系图 / 核心股列表 */ /* 视图切换:关系图 / 核心股列表 */
type ViewMode = "graph" | "list"; type ViewMode = "graph" | "list";
@@ -114,6 +118,13 @@ function HotMapPage() {
> >
<RefreshCw className={`h-4 w-4 ${isFetching ? "animate-spin" : ""}`} /> <RefreshCw className={`h-4 w-4 ${isFetching ? "animate-spin" : ""}`} />
</button> </button>
<Link
to="/core-stocks"
className="text-xs text-primary flex items-center gap-1 hover:opacity-80 transition-opacity whitespace-nowrap"
>
<Flame className="h-3.5 w-3.5" />
</Link>
</div> </div>
</div> </div>
@@ -265,6 +276,8 @@ interface SimNode extends SimulationNodeDatum {
f100?: string; f100?: string;
themeCodes?: string[]; themeCodes?: string[];
code?: string; code?: string;
// theme 专属
bf3?: number | null; // 题材涨幅
} }
interface SimEdge extends SimulationLinkDatum<SimNode> { interface SimEdge extends SimulationLinkDatum<SimNode> {
@@ -351,12 +364,20 @@ function drawEdges(ctx: CanvasRenderingContext2D, edges: SimEdge[], activeSet: S
ctx.globalAlpha = 1; ctx.globalAlpha = 1;
} }
/** 股票节点:覆盖数分级颜色 + 外圈淡填充 + 描边 + 内实心 */ /* 心跳周期 ms;0~1 相位,0 最小 / 0.5 最大 */
const PULSE_PERIOD = 900;
const PULSE_AMPLITUDE = 0.22; // 心跳半径脉动幅度(相对半径)
/**
* 股票节点:覆盖数分级颜色 + 心跳(≥5题材) + 涨跌光晕 + 描边 + 内实心
* time(ms) 由持续动画循环驱动;非动画源(t=0)时心跳相位取 0 的静止态。
*/
function drawStockNodes( function drawStockNodes(
ctx: CanvasRenderingContext2D, ctx: CanvasRenderingContext2D,
nodes: SimNode[], nodes: SimNode[],
activeSet: Set<string> | null, activeSet: Set<string> | null,
activeId: string | null, activeId: string | null,
time: number,
) { ) {
for (const n of nodes) { for (const n of nodes) {
if (n.type !== "stock") continue; if (n.type !== "stock") continue;
@@ -364,30 +385,59 @@ function drawStockNodes(
const isActive = activeId === n.id; const isActive = activeId === n.id;
const dim = activeSet ? !activeSet.has(n.id) : false; const dim = activeSet ? !activeSet.has(n.id) : false;
const alpha = dim ? 0.08 : cover === 1 && !isActive ? 0.45 : 1; const alpha = dim ? 0.08 : cover === 1 && !isActive ? 0.45 : 1;
const r = isActive ? n.radius + 3 : n.radius;
const fill = cover >= 7 ? "#dc2626" : cover >= 5 ? "#ef4444" : "#f97316";
const x = n.x ?? 0; const x = n.x ?? 0;
const y = n.y ?? 0; const y = n.y ?? 0;
// 外圈淡填充 const isPos = (n.f3 ?? 0) >= 0;
// 心跳:覆盖≥5 的核心股按正弦脉动半径(相位在 0 时静止,避免动画源缺省导致闪烁)
const heartbeat = cover >= 5 ? 0.5 + 0.5 * Math.sin((time / PULSE_PERIOD) * TAU - Math.PI / 2) : 0;
const r = isActive ? n.radius + 3 : n.radius;
const pr = r * (1 + heartbeat * PULSE_AMPLITUDE);
// 涨跌光晕:所有股票统一强度(不随覆盖数 alpha 衰减),随心跳脉动,正=红 / 负=绿
const glowColor = isPos ? "rgba(239,68,68," : "rgba(34,197,94,"; // 红/绿
const glowR = pr * 1.9;
const glow = ctx.createRadialGradient(x, y, pr * 0.2, x, y, glowR);
glow.addColorStop(0, `${glowColor}${dim ? 0.06 : 0.5})`);
glow.addColorStop(1, `${glowColor}0)`);
ctx.beginPath(); ctx.beginPath();
ctx.arc(x, y, r, 0, TAU); ctx.arc(x, y, glowR, 0, TAU);
ctx.fillStyle = fill; ctx.fillStyle = glow;
ctx.globalAlpha = 0.35 * alpha; ctx.globalAlpha = 1;
ctx.fill();
// 描边
ctx.strokeStyle = cover >= 2 ? "#f59e0b" : "#94a3b8";
ctx.lineWidth = cover >= 2 ? (cover >= 4 ? 2 : 1.5) : 0.5;
ctx.globalAlpha = alpha;
ctx.stroke();
// 内实心
ctx.beginPath();
ctx.arc(x, y, n.radius, 0, TAU);
ctx.globalAlpha = (cover >= 2 ? 0.9 : 0.55) * alpha;
ctx.fill(); ctx.fill();
if (isPos) {
// 涨:外圈淡填充 + 分级描边 + 内实心(覆盖数越多越红)
const fill = cover >= 7 ? "#dc2626" : cover >= 5 ? "#ef4444" : "#f97316";
ctx.beginPath();
ctx.arc(x, y, pr, 0, TAU);
ctx.fillStyle = fill;
ctx.globalAlpha = 0.35 * alpha;
ctx.fill();
ctx.strokeStyle = cover >= 2 ? "#f59e0b" : "#94a3b8";
ctx.lineWidth = cover >= 2 ? (cover >= 4 ? 2 : 1.5) : 0.5;
ctx.globalAlpha = alpha;
ctx.stroke();
ctx.beginPath();
ctx.arc(x, y, pr, 0, TAU);
ctx.globalAlpha = (cover >= 2 ? 0.9 : 0.55) * alpha;
ctx.fill();
} else {
// 跌:绿色空心球(仅描边,内部淡绿留白)
ctx.beginPath();
ctx.arc(x, y, pr, 0, TAU);
ctx.strokeStyle = "#22c55e";
ctx.lineWidth = 2;
ctx.globalAlpha = dim ? 0.15 : 1;
ctx.stroke();
ctx.fillStyle = "#22c55e";
ctx.globalAlpha = dim ? 0.02 : 0.1;
ctx.fill();
}
// 选中外环 // 选中外环
if (isActive) { if (isActive) {
ctx.beginPath(); ctx.beginPath();
ctx.arc(x, y, n.radius + 3, 0, TAU); ctx.arc(x, y, pr + 3, 0, TAU);
ctx.globalAlpha = alpha; ctx.globalAlpha = alpha;
ctx.strokeStyle = cover >= 2 ? "#f59e0b" : "#94a3b8"; ctx.strokeStyle = cover >= 2 ? "#f59e0b" : "#94a3b8";
ctx.lineWidth = 1; ctx.lineWidth = 1;
@@ -397,7 +447,7 @@ function drawStockNodes(
ctx.globalAlpha = 1; ctx.globalAlpha = 1;
} }
/** 题材节点:蓝色圆点 */ /** 题材节点:涨幅正蓝负绿,大小按涨幅绝对值映射 */
function drawThemeNodes( function drawThemeNodes(
ctx: CanvasRenderingContext2D, ctx: CanvasRenderingContext2D,
nodes: SimNode[], nodes: SimNode[],
@@ -409,14 +459,32 @@ function drawThemeNodes(
const isActive = activeId === n.id; const isActive = activeId === n.id;
const dim = activeSet ? !activeSet.has(n.id) : false; const dim = activeSet ? !activeSet.has(n.id) : false;
const r = isActive ? n.radius + 3 : n.radius; const r = isActive ? n.radius + 3 : n.radius;
const isPos = (n.bf3 ?? 0) >= 0;
ctx.beginPath(); ctx.beginPath();
ctx.arc(n.x ?? 0, n.y ?? 0, r, 0, TAU); ctx.arc(n.x ?? 0, n.y ?? 0, r, 0, TAU);
ctx.fillStyle = isActive ? "#2563eb" : "#3b82f6"; // 正涨幅:蓝色实心;负涨幅:蓝色空心
ctx.globalAlpha = dim ? 0.15 : 1; ctx.strokeStyle = "#3b82f6";
ctx.fill(); ctx.lineWidth = isPos ? 1.5 : 2;
ctx.strokeStyle = "#1d4ed8"; if (isPos) {
ctx.lineWidth = 1.5; ctx.fillStyle = "#3b82f6";
ctx.globalAlpha = dim ? 0.15 : 1;
ctx.fill();
} else {
// 空心:仅描边,内部留白
ctx.fillStyle = "#3b82f6";
ctx.globalAlpha = dim ? 0.05 : 0.15;
ctx.fill();
ctx.globalAlpha = dim ? 0.15 : 0.6;
}
ctx.stroke(); ctx.stroke();
// 选中外环
if (isActive) {
ctx.beginPath();
ctx.arc(n.x ?? 0, n.y ?? 0, r + 3, 0, TAU);
ctx.strokeStyle = "#60a5fa";
ctx.lineWidth = 1;
ctx.stroke();
}
} }
ctx.globalAlpha = 1; ctx.globalAlpha = 1;
} }
@@ -453,7 +521,8 @@ function drawThemeLabels(
if (n.type !== "theme") continue; if (n.type !== "theme") continue;
if (!showAll && activeId !== n.id) continue; if (!showAll && activeId !== n.id) continue;
const label = isCoarse ? (n.name.length > 5 ? n.name.slice(0, 5) + "…" : n.name) : n.name; const label = isCoarse ? (n.name.length > 5 ? n.name.slice(0, 5) + "…" : n.name) : n.name;
haloText(ctx, label, n.x ?? 0, (n.y ?? 0) + n.radius + 11, isCoarse ? 7.5 : 9, "#1e40af"); const labelColor = (n.bf3 ?? 0) >= 0 ? "#1e40af" : "#15803d";
haloText(ctx, label, n.x ?? 0, (n.y ?? 0) + n.radius + 11, isCoarse ? 7.5 : 9, labelColor);
} }
} }
@@ -486,6 +555,8 @@ function HotMapGraph({ graph }: { graph: ThemeGraph }) {
const simRunningRef = useRef(false); const simRunningRef = useRef(false);
const fitOnceRef = useRef(false); const fitOnceRef = useRef(false);
const rafRef = useRef(0); const rafRef = useRef(0);
const timeRef = useRef(0); // 动画时钟(ms),心跳/光晕脉动用
const pulseStartRef = useRef(0); // 持续动画起始时间戳(ms)
const pointersRef = useRef(new Map<number, { x: number; y: number }>()); const pointersRef = useRef(new Map<number, { x: number; y: number }>());
const gestureRef = useRef<Gesture>({ kind: "none" }); const gestureRef = useRef<Gesture>({ kind: "none" });
@@ -511,19 +582,20 @@ function HotMapGraph({ graph }: { graph: ThemeGraph }) {
if (activeSet) { if (activeSet) {
// 高亮态:全量重绘(邻居亮、非邻居淡出) // 高亮态:全量重绘(邻居亮、非邻居淡出)
drawEdges(ctx, edges, activeSet); drawEdges(ctx, edges, activeSet);
drawStockNodes(ctx, nodes, activeSet, activeId); drawStockNodes(ctx, nodes, activeSet, activeId, timeRef.current);
drawThemeNodes(ctx, nodes, activeSet, activeId); drawThemeNodes(ctx, nodes, activeSet, activeId);
drawActiveNodeLabel(ctx, activeNodeRef.current, isCoarse); drawActiveNodeLabel(ctx, activeNodeRef.current, isCoarse);
} else { } else {
const b = boundsRef.current; const b = boundsRef.current;
const sc = staticCanvasRef.current; const sc = staticCanvasRef.current;
if (staticReadyRef.current && sc && b.w > 0 && b.h > 0 && v.k < 1.5) { if (staticReadyRef.current && sc && b.w > 0 && b.h > 0 && v.k < 1.5) {
// 静止态:drawImage 静态层 + 动态层 // 静止态:静态层(边+题材)+ 动态层重绘股票(心跳/光晕动画)
ctx.drawImage(sc, b.minX, b.minY, b.w, b.h); ctx.drawImage(sc, b.minX, b.minY, b.w, b.h);
drawStockNodes(ctx, nodes, null, null, timeRef.current);
} else { } else {
// 模拟期或大比例放大:直接全量绘制(保证清晰) // 模拟期或大比例放大:直接全量绘制(保证清晰)
drawEdges(ctx, edges, null); drawEdges(ctx, edges, null);
drawStockNodes(ctx, nodes, null, null); drawStockNodes(ctx, nodes, null, null, timeRef.current);
drawThemeNodes(ctx, nodes, null, null); drawThemeNodes(ctx, nodes, null, null);
} }
} }
@@ -570,7 +642,7 @@ function HotMapGraph({ graph }: { graph: ThemeGraph }) {
sctx.setTransform(sw / w, 0, 0, sh / h, 0, 0); sctx.setTransform(sw / w, 0, 0, sh / h, 0, 0);
sctx.translate(-minX2, -minY2); sctx.translate(-minX2, -minY2);
drawEdges(sctx, edges, null); drawEdges(sctx, edges, null);
drawStockNodes(sctx, nodes, null, null); // 股票节点不在静态层:需常驻重绘以支持心跳/光晕动画
drawThemeNodes(sctx, nodes, null, null); drawThemeNodes(sctx, nodes, null, null);
staticReadyRef.current = true; staticReadyRef.current = true;
boundsRef.current = { minX: minX2, minY: minY2, w, h }; boundsRef.current = { minX: minX2, minY: minY2, w, h };
@@ -627,6 +699,19 @@ function HotMapGraph({ graph }: { graph: ThemeGraph }) {
return () => ro.disconnect(); return () => ro.disconnect();
}, [requestRender]); }, [requestRender]);
/* 持续动画循环:心跳 + 光晕脉动。力收敛后常驻 rAF,仅更新时间与触发重绘 */
useEffect(() => {
pulseStartRef.current = performance.now();
let anim = 0;
const loop = () => {
timeRef.current = performance.now() - pulseStartRef.current;
requestRender();
anim = requestAnimationFrame(loop);
};
anim = requestAnimationFrame(loop);
return () => cancelAnimationFrame(anim);
}, [requestRender]);
/* 节点过滤 + 边重建(后端不再下发 edges,由 stocks[].themeCodes 重建) */ /* 节点过滤 + 边重建(后端不再下发 edges,由 stocks[].themeCodes 重建) */
const { nodes, edges, stockById } = useMemo(() => { const { nodes, edges, stockById } = useMemo(() => {
if (!graph) return { nodes: [] as SimNode[], edges: [] as SimEdge[], stockById: new Map<string, GraphStock>() }; if (!graph) return { nodes: [] as SimNode[], edges: [] as SimEdge[], stockById: new Map<string, GraphStock>() };
@@ -645,16 +730,24 @@ function HotMapGraph({ graph }: { graph: ThemeGraph }) {
f62: s.f62, f62: s.f62,
f100: s.f100, f100: s.f100,
themeCodes: s.themeCodes, themeCodes: s.themeCodes,
radius: Math.min(18, 4 + s.coverCount * 1.3), // 基础半径来自覆盖数,涨幅绝对值作增量:涨得猛/跌得深球更大
})); radius: Math.min(20, 3 + s.coverCount * 1.2 + (Math.abs(s.f3 ?? 0) / 10) * 4),
const themeNodes: SimNode[] = graph.themes.map((t) => ({
id: `t:${t.themeCode}`,
type: "theme" as const,
name: t.themeName,
code: t.themeCode,
coverCount: t.stockCount,
radius: 10,
})); }));
// 题材涨幅绝对值作为球大小的归一化基准(兜底 ≥1 防除零)
const maxAbsBf3 = Math.max(1, ...graph.themes.map((t) => Math.abs(t.bf3 ?? 0)));
const themeNodes: SimNode[] = graph.themes.map((t) => {
const bf3 = t.bf3 ?? 0;
return {
id: `t:${t.themeCode}`,
type: "theme" as const,
name: t.themeName,
code: t.themeCode,
coverCount: t.stockCount,
bf3: t.bf3,
// 平方根映射:同一个小涨幅区间内球径差异更大(线性映射被极端涨幅拉平均)
radius: THEME_MIN_RADIUS + Math.sqrt(Math.abs(bf3) / maxAbsBf3) * (THEME_MAX_RADIUS - THEME_MIN_RADIUS),
};
});
const edgeList: SimEdge[] = buildEdgesFromStocks(kept); const edgeList: SimEdge[] = buildEdgesFromStocks(kept);
const stockByIdMap = new Map<string, GraphStock>(); const stockByIdMap = new Map<string, GraphStock>();
@@ -1000,6 +1093,11 @@ function InfoCard({ node, stockById }: { node: SimNode; stockById: Map<string, G
) : ( ) : (
<> <>
<p className="font-semibold">{node.name}</p> <p className="font-semibold">{node.name}</p>
{node.bf3 != null && (
<p className={`text-xs font-bold ${node.bf3 >= 0 ? "text-blue-600" : "text-green-600"}`}>
{node.bf3 >= 0 ? "+" : ""}{node.bf3.toFixed(2)}%
</p>
)}
<p className="text-[10px] text-muted-foreground"> {node.code} · {node.coverCount} </p> <p className="text-[10px] text-muted-foreground"> {node.code} · {node.coverCount} </p>
</> </>
)} )}
@@ -1058,6 +1156,11 @@ function BottomSheet({
) : ( ) : (
<> <>
<p className="font-semibold text-sm">{node.name}</p> <p className="font-semibold text-sm">{node.name}</p>
{node.bf3 != null && (
<p className={`text-xs font-bold mt-0.5 ${node.bf3 >= 0 ? "text-blue-600" : "text-green-600"}`}>
{node.bf3 >= 0 ? "+" : ""}{node.bf3.toFixed(2)}%
</p>
)}
<p className="text-[10px] text-muted-foreground mt-0.5"> <p className="text-[10px] text-muted-foreground mt-0.5">
{node.code} · {node.coverCount} {node.code} · {node.coverCount}
</p> </p>
@@ -1104,7 +1207,11 @@ function Legend({ maxCover, compact }: { maxCover: number; compact: boolean }) {
</span> </span>
<span className="inline-flex items-center gap-1"> <span className="inline-flex items-center gap-1">
<span className="inline-block rounded-full bg-blue-500" style={{ width: 8, height: 8 }} /> <span className="inline-block rounded-full bg-blue-500" style={{ width: 8, height: 8 }} />
</span>
<span className="inline-flex items-center gap-1">
<span className="inline-block rounded-full border-2 border-blue-500" style={{ width: 8, height: 8 }} />
</span> </span>
</div> </div>
); );
@@ -1124,8 +1231,13 @@ function Legend({ maxCover, compact }: { maxCover: number; compact: boolean }) {
</div> </div>
<div className="flex items-center gap-2 pt-1 border-t border-border/40"> <div className="flex items-center gap-2 pt-1 border-t border-border/40">
<span className="inline-block rounded-full bg-blue-500" style={{ width: 10, height: 10 }} /> <span className="inline-block rounded-full bg-blue-500" style={{ width: 10, height: 10 }} />
<span></span> <span> · </span>
</div> </div>
<div className="flex items-center gap-2">
<span className="inline-block rounded-full border-2 border-blue-500" style={{ width: 10, height: 10 }} />
<span> · </span>
</div>
<div className="text-[9px] text-muted-foreground pt-0.5"></div>
</div> </div>
); );
} }
+99 -24
View File
@@ -1,7 +1,14 @@
import { createFileRoute, Link } from "@tanstack/react-router"; import { createFileRoute, Link } from "@tanstack/react-router";
import { useState } from "react"; import { useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query"; 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 { getStockBoard } from "@/lib/stock-api";
import { formatMoney } from "@/lib/utils"; import { formatMoney } from "@/lib/utils";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
@@ -36,6 +43,27 @@ function ThemeDetailPage() {
staleTime: 30_000, staleTime: 30_000,
retry: false, retry: false,
}); });
// 相关新闻:pageNum 偏移分页(东财接口 maxEuTime 是增量游标,翻页靠 pageNum 递增)
const [newsPage, setNewsPage] = useState(1);
const [newsItems, setNewsItems] = useState<ThemeNewsItem[]>([]);
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 isLoading = detailQ.isLoading || stocksQ.isLoading;
const isError = detailQ.isError || stocksQ.isError; const isError = detailQ.isError || stocksQ.isError;
@@ -49,11 +77,13 @@ function ThemeDetailPage() {
const refresh = () => { const refresh = () => {
detailQ.refetch(); detailQ.refetch();
stocksQ.refetch(); stocksQ.refetch();
quoteQ.refetch();
setNewsPage(1);
newsQ.refetch();
}; };
const baseInfo = detail?.baseInfo; const baseInfo = detail?.baseInfo;
const hotEvent = detail?.hotEvent; const hotEvent = detail?.hotEvent;
const eventHistory = detail?.eventHistory ?? [];
return ( return (
<div className="min-h-screen bg-background"> <div className="min-h-screen bg-background">
@@ -139,10 +169,20 @@ function ThemeDetailPage() {
flat={statistic?.f106} flat={statistic?.f106}
fex5={statistic?.fex5} fex5={statistic?.fex5}
total={total} total={total}
strength={quoteQ.data?.strengthValue ?? null}
hotValue={quoteQ.data?.hotValue ?? 0}
hotValueUpLimit={quoteQ.data?.hotValueUpLimit ?? 0}
/> />
{/* ── 相关新闻(可折叠 ── */} {/* ── 相关新闻(分页加载 ── */}
{eventHistory.length > 0 && <NewsList items={eventHistory} />} {newsItems.length > 0 && (
<NewsList
items={newsItems}
total={newsQ.data?.total ?? 0}
loadingMore={newsQ.isFetching && newsPage > 1}
onLoadMore={() => setNewsPage((p) => p + 1)}
/>
)}
{/* ── 相关股票 ── */} {/* ── 相关股票 ── */}
<div> <div>
@@ -181,6 +221,9 @@ function StatBar({
flat, flat,
fex5, fex5,
total, total,
strength,
hotValue,
hotValueUpLimit,
}: { }: {
f3: number | null | undefined; f3: number | null | undefined;
up: number | null | undefined; up: number | null | undefined;
@@ -188,8 +231,13 @@ function StatBar({
flat: number | null | undefined; flat: number | null | undefined;
fex5: number | null | undefined; fex5: number | null | undefined;
total: number; total: number;
strength: number | null;
hotValue: number;
hotValueUpLimit: number;
}) { }) {
const isPos = (f3 ?? 0) >= 0; const isPos = (f3 ?? 0) >= 0;
const hotPct = hotValueUpLimit > 0 ? Math.min((hotValue / hotValueUpLimit) * 100, 100) : 0;
const showQuote = strength != null || hotValueUpLimit > 0;
return ( return (
<Card> <Card>
<CardContent className="p-3"> <CardContent className="p-3">
@@ -218,24 +266,49 @@ function StatBar({
{flat} {flat}
</p> </p>
)} )}
{/* 强度 + 热度(来自单题材实时行情接口) */}
{showQuote && (
<div className="mt-2 pt-2 border-t border-border/40 flex flex-wrap items-center gap-x-4 gap-y-1 text-[10px] text-muted-foreground">
{strength != null && (
<span className="inline-flex items-center gap-1">
<b className="font-bold text-foreground tabular-nums">{strength}</b>
</span>
)}
{hotValueUpLimit > 0 && (
<span className="inline-flex items-center gap-1.5">
<Flame className="h-3 w-3 text-orange-500" />
<span className="w-16 h-1 rounded-full bg-muted overflow-hidden">
<span
className="block h-full rounded-full bg-gradient-to-r from-orange-400 to-red-500"
style={{ width: `${Math.max(hotPct, 2)}%` }}
/>
</span>
{hotValue}/{hotValueUpLimit}
</span>
)}
</div>
)}
</CardContent> </CardContent>
</Card> </Card>
); );
} }
/* ============================================================ /* ============================================================
相关新闻(可折叠 相关新闻(分页加载
============================================================ */ ============================================================ */
function NewsList({ items }: { items: { newsTitle: string; newsMediaName: string; newsPublishTime: number | null }[] }) { function NewsList({
const [expanded, setExpanded] = useState(false); items,
const shown = expanded ? items : items.slice(0, 2); total,
loadingMore,
const fmtTime = (ts: number | null) => { onLoadMore,
if (!ts) return ""; }: {
const d = new Date(ts); items: ThemeNewsItem[];
const pad = (n: number) => String(n).padStart(2, "0"); total: number;
return `${d.getMonth() + 1}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`; loadingMore: boolean;
}; onLoadMore: () => void;
}) {
const hasMore = items.length < total;
return ( return (
<Card> <Card>
@@ -243,26 +316,28 @@ function NewsList({ items }: { items: { newsTitle: string; newsMediaName: string
<div className="flex items-center gap-1.5 mb-2"> <div className="flex items-center gap-1.5 mb-2">
<Newspaper className="h-4 w-4 text-primary" /> <Newspaper className="h-4 w-4 text-primary" />
<h2 className="text-sm font-semibold"></h2> <h2 className="text-sm font-semibold"></h2>
<span className="text-[10px] text-muted-foreground ml-auto">{items.length} </span> <span className="text-[10px] text-muted-foreground ml-auto"> {total} </span>
</div> </div>
<div className="space-y-2.5"> <div className="space-y-2.5">
{shown.map((n, idx) => ( {items.map((n, idx) => (
<div key={idx} className="space-y-0.5"> <div key={idx} className="space-y-0.5">
<p className="text-sm leading-snug line-clamp-2">{n.newsTitle}</p> <p className="text-sm leading-snug line-clamp-2">{n.newsTitle}</p>
<p className="text-[10px] text-muted-foreground"> <p className="text-[10px] text-muted-foreground">
{n.newsMediaName} {n.newsMediaName}
{n.newsPublishTime ? ` · ${fmtTime(n.newsPublishTime)}` : ""} {n.showDateTimeFormat ? ` · ${n.showDateTimeFormat}` : ""}
{n.commentCount > 0 && <span className="ml-1">· {n.commentCount} </span>}
</p> </p>
</div> </div>
))} ))}
</div> </div>
{items.length > 2 && ( {hasMore && (
<button <button
onClick={() => setExpanded((v) => !v)} onClick={onLoadMore}
className="mt-2 text-xs text-primary hover:underline inline-flex items-center gap-0.5" disabled={loadingMore}
className="mt-2 text-xs text-primary hover:underline inline-flex items-center gap-0.5 disabled:opacity-50"
> >
{expanded ? "收起" : `展开全部 ${items.length}`} {loadingMore ? "加载中…" : "加载更多"}
{expanded ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />} {!loadingMore && <ChevronDown className="h-3 w-3" />}
</button> </button>
)} )}
</CardContent> </CardContent>
+7
View File
@@ -71,6 +71,13 @@ function ThemesPage() {
<Network className="h-3.5 w-3.5" /> <Network className="h-3.5 w-3.5" />
穿 穿
</Link> </Link>
<Link
to="/core-stocks"
className="text-xs text-primary flex items-center gap-1 hover:opacity-80 transition-opacity"
>
<Flame className="h-3.5 w-3.5" />
</Link>
<button <button
onClick={() => refetch()} onClick={() => refetch()}
className="text-muted-foreground hover:text-foreground transition-colors" className="text-muted-foreground hover:text-foreground transition-colors"