Compare commits

...
3 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
9 changed files with 480 additions and 99 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 "$@"
+9 -6
View File
@@ -8,7 +8,7 @@ from dotenv import load_dotenv
from database import init_db
from routes import stock, collections, shares, sectors, themes, core_stocks
from services.daily_collector import collector_loop
from services.daily_collector import collector_loop, cache_cleanup_loop
load_dotenv()
@@ -17,14 +17,17 @@ load_dotenv()
async def lifespan(app: FastAPI):
init_db()
collector_task = asyncio.create_task(collector_loop())
cache_cleanup_task = asyncio.create_task(cache_cleanup_loop())
try:
yield
finally:
collector_task.cancel()
try:
await collector_task
except asyncio.CancelledError:
pass
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)
+33
View File
@@ -52,6 +52,39 @@ async def theme_history(date: str = Query(..., description="交易日 YYYY-MM-DD
conn.close()
@router.get("/{theme_code}/news", summary="题材相关新闻(分页)")
async def theme_news(
theme_code: str,
page_num: int = Query(1, ge=1, description="页码"),
page_size: int = Query(10, ge=1, le=50, description="每页条数"),
max_eu_time: str = Query("", description="分页游标(上一页返回的 maxEuTime)"),
):
result = await themes.fetch_theme_news(theme_code, page_num, max_eu_time, page_size)
if result is None:
return JSONResponse(
{"data": None, "theme_code": theme_code},
headers=_NO_CACHE_HEADERS,
)
return JSONResponse(
{"data": result, "theme_code": theme_code},
headers=_NO_CACHE_HEADERS,
)
@router.get("/{theme_code}/quote", summary="单题材实时行情(强度/热度/涨幅)")
async def theme_quote(theme_code: str):
result = await themes.fetch_theme_quote(theme_code)
if result is None:
return JSONResponse(
{"data": None, "theme_code": theme_code},
headers=_NO_CACHE_HEADERS,
)
return JSONResponse(
{"data": result, "theme_code": theme_code},
headers=_NO_CACHE_HEADERS,
)
@router.get("/{theme_code}/detail", summary="题材详情")
async def theme_detail(theme_code: str):
data = await themes.fetch_theme_detail(theme_code)
+10
View File
@@ -56,3 +56,13 @@ def clean_expired():
conn.commit()
finally:
conn.close()
def clear_all():
"""清空全部缓存(每日开盘后 9:31 调用,保证当日数据全新)"""
conn = get_connection()
try:
conn.execute("DELETE FROM cache")
conn.commit()
finally:
conn.close()
+46
View File
@@ -9,6 +9,7 @@ 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))
@@ -22,6 +23,9 @@ HOTMAP_TOP_N = 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:
"""仅按工作日判断:周一至周五视为交易日,不处理法定节假日"""
@@ -185,3 +189,45 @@ async def collector_loop(stop: Optional[asyncio.Event] = None) -> None:
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 分钟后再试
+64 -7
View File
@@ -88,9 +88,14 @@ def _next_open_delta_seconds() -> int:
return 0
# 盘中题材列表短缓存:题材热点页与热点穿透聚合共用同一份缓存,避免重复拉全量列表打东财。
# 120s 长于热点穿透图缓存(60s),图重建时必然命中且更新频率更低,两页数据更稳。
_LIST_CACHE_SECONDS = 120
def _list_ttl_seconds() -> int:
"""题材列表缓存秒数:交易时段 0(不缓存、实时拉取);非交易时段缓存到下次开盘前失效"""
return 0 if _is_trading_time() else _next_open_delta_seconds()
"""题材列表缓存秒数:交易时段 120s 短缓存;非交易时段缓存到下次开盘前失效"""
return _LIST_CACHE_SECONDS if _is_trading_time() else _next_open_delta_seconds()
# ---- 请求封装 ----
@@ -159,11 +164,10 @@ async def fetch_theme_list(sort_field: int = 1, asc: bool = False) -> list[dict]
asc: True=升序, False=降序
"""
cache_key = f"theme_list:{sort_field}:{asc}"
# 交易时段强制实时:跳过缓存读取,避免命中非交易时段写入的上个交易日旧数据
if not _is_trading_time():
cached = get_cache(cache_key)
if cached is not None:
return json.loads(cached)
# 统一读缓存(盘中 TTL=120s 短缓存,非盘中缓存到下次开盘前失效),避免重复拉全量列表打东财
cached = get_cache(cache_key)
if cached is not None:
return json.loads(cached)
sort = 1 if asc else -1
# hotRank 数值越小越热,"热度降序(最热在前)" 需反转为接口升序
@@ -443,3 +447,56 @@ async def fetch_theme_graph(sort_field: int = 1, top_n: int = 30, limit: int = 1
data = await _build_theme_graph(sort_field, top_n)
_set_graph_cache(cache_key, data)
return _trim_graph_result(data, limit)
# ---- 题材相关新闻(分页) ----
_NEWS_PAGE_SIZE = 10
async def fetch_theme_news(theme_code: str, page_num: int = 1, max_eu_time: str = "", page_size: int = _NEWS_PAGE_SIZE) -> Optional[dict]:
"""获取题材相关新闻(分页),返回 {total, maxEuTime, list}
maxEuTime 为游标:上一页返回的 maxEuTime 作为下一页入参,首页传空串。
盘中 60s 短缓存(与图缓存同频);非盘中缓存到下次开盘前失效。
"""
cache_key = f"theme_news:{theme_code}:{page_num}:{max_eu_time}:{page_size}"
cached = get_cache(cache_key)
if cached is not None:
return json.loads(cached)
data = await _post(
"/api/themeInvest/getThemeRelatedNews",
{"themeCode": theme_code, "pageNum": page_num, "maxEuTime": max_eu_time, "pageSize": page_size},
app_key=_APP_KEY_DETAIL,
)
if not data:
return None
ttl_s = _graph_ttl_seconds()
if ttl_s > 0:
set_cache(cache_key, json.dumps(data, ensure_ascii=False), ttl_seconds=ttl_s)
return data
# ---- 单题材实时行情(强度/热度/涨幅) ----
async def fetch_theme_quote(theme_code: str) -> Optional[dict]:
"""获取单题材实时行情(strengthValue/hotValue/f3),盘中 60s 短缓存"""
cache_key = f"theme_quote:{theme_code}"
cached = get_cache(cache_key)
if cached is not None:
return json.loads(cached)
data = await _post(
"/api/themeInvest/getSingleThemeQuote",
{"themeCode": theme_code},
app_key=_APP_KEY_DETAIL,
)
if not data:
return None
ttl_s = _graph_ttl_seconds()
if ttl_s > 0:
set_cache(cache_key, json.dumps(data, ensure_ascii=False), ttl_seconds=ttl_s)
return data
-62
View File
@@ -1,62 +0,0 @@
#!/usr/bin/env bash
# 服务器端脚本:清理指定交易日旧数据,并用新选股逻辑重新采集入库。
#
# 适用场景:核心股追踪选股逻辑更新后,服务器上当天数据仍是旧口径,
# 需要删掉重采(采集器幂等,不删会被 _has_collected 跳过)。
#
# 用法(在服务器上执行):
# ./recollect-daily.sh [日期] # 日期默认取服务器当天,格式 YYYY-MM-DD
# ./recollect-daily.sh 2026-08-10 # 显式指定交易日
#
# 依赖:docker 容器名为 auvdocker-compose.yml 中 container_name
set -euo pipefail
CONTAINER="${CONTAINER:-auv}"
DATE="${1:-$(date +%F)}"
# 校验日期格式,防止注入/拼错
if ! [[ "${DATE}" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; then
echo "❌ 日期格式错误:${DATE}(应为 YYYY-MM-DD"
exit 1
fi
echo "================================================"
echo "🚀 容器: ${CONTAINER} 交易日: ${DATE}"
echo "================================================"
# 确认容器在运行
if ! docker ps --format '{{.Names}}' | grep -qx "${CONTAINER}"; then
echo "❌ 容器 ${CONTAINER} 未在运行"
exit 1
fi
echo ""
echo "① 删除 ${DATE} 旧数据"
# 通过环境变量传日期,避免日期值混入 python -c 的字符串拼接
docker exec -e TARGET_DATE="${DATE}" "${CONTAINER}" python -c "
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()
"
echo ""
echo "② 用新选股逻辑重采 ${DATE}"
docker exec -e TARGET_DATE="${DATE}" "${CONTAINER}" python -c "
import asyncio
import os
from services.daily_collector import collect_daily
result = asyncio.run(collect_daily(os.environ['TARGET_DATE']))
print(' 结果:', result)
"
echo ""
echo "✅ 完成"
+69
View File
@@ -159,6 +159,75 @@ 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 {
+99 -24
View File
@@ -1,7 +1,14 @@
import { createFileRoute, Link } from "@tanstack/react-router";
import { useState } from "react";
import { useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { fetchThemeDetail, fetchThemeStocks, type ThemeStock } from "@/lib/theme-api";
import {
fetchThemeDetail,
fetchThemeNews,
fetchThemeQuote,
fetchThemeStocks,
type ThemeStock,
type ThemeNewsItem,
} from "@/lib/theme-api";
import { getStockBoard } from "@/lib/stock-api";
import { formatMoney } from "@/lib/utils";
import { Card, CardContent } from "@/components/ui/card";
@@ -36,6 +43,27 @@ function ThemeDetailPage() {
staleTime: 30_000,
retry: false,
});
// 相关新闻:pageNum 偏移分页(东财接口 maxEuTime 是增量游标,翻页靠 pageNum 递增)
const [newsPage, setNewsPage] = useState(1);
const [newsItems, setNewsItems] = useState<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 isError = detailQ.isError || stocksQ.isError;
@@ -49,11 +77,13 @@ function ThemeDetailPage() {
const refresh = () => {
detailQ.refetch();
stocksQ.refetch();
quoteQ.refetch();
setNewsPage(1);
newsQ.refetch();
};
const baseInfo = detail?.baseInfo;
const hotEvent = detail?.hotEvent;
const eventHistory = detail?.eventHistory ?? [];
return (
<div className="min-h-screen bg-background">
@@ -139,10 +169,20 @@ function ThemeDetailPage() {
flat={statistic?.f106}
fex5={statistic?.fex5}
total={total}
strength={quoteQ.data?.strengthValue ?? null}
hotValue={quoteQ.data?.hotValue ?? 0}
hotValueUpLimit={quoteQ.data?.hotValueUpLimit ?? 0}
/>
{/* ── 相关新闻(可折叠 ── */}
{eventHistory.length > 0 && <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>
@@ -181,6 +221,9 @@ function StatBar({
flat,
fex5,
total,
strength,
hotValue,
hotValueUpLimit,
}: {
f3: number | null | undefined;
up: number | null | undefined;
@@ -188,8 +231,13 @@ function StatBar({
flat: number | null | undefined;
fex5: number | null | undefined;
total: number;
strength: number | null;
hotValue: number;
hotValueUpLimit: number;
}) {
const isPos = (f3 ?? 0) >= 0;
const hotPct = hotValueUpLimit > 0 ? Math.min((hotValue / hotValueUpLimit) * 100, 100) : 0;
const showQuote = strength != null || hotValueUpLimit > 0;
return (
<Card>
<CardContent className="p-3">
@@ -218,24 +266,49 @@ function StatBar({
{flat}
</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>
</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())}`;
};
function NewsList({
items,
total,
loadingMore,
onLoadMore,
}: {
items: ThemeNewsItem[];
total: number;
loadingMore: boolean;
onLoadMore: () => void;
}) {
const hasMore = items.length < total;
return (
<Card>
@@ -243,26 +316,28 @@ function NewsList({ items }: { items: { newsTitle: string; newsMediaName: string
<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>
<span className="text-[10px] text-muted-foreground ml-auto"> {total} </span>
</div>
<div className="space-y-2.5">
{shown.map((n, idx) => (
{items.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)}` : ""}
{n.showDateTimeFormat ? ` · ${n.showDateTimeFormat}` : ""}
{n.commentCount > 0 && <span className="ml-1">· {n.commentCount} </span>}
</p>
</div>
))}
</div>
{items.length > 2 && (
{hasMore && (
<button
onClick={() => setExpanded((v) => !v)}
className="mt-2 text-xs text-primary hover:underline inline-flex items-center gap-0.5"
onClick={onLoadMore}
disabled={loadingMore}
className="mt-2 text-xs text-primary hover:underline inline-flex items-center gap-0.5 disabled:opacity-50"
>
{expanded ? "收起" : `展开全部 ${items.length}`}
{expanded ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
{loadingMore ? "加载中…" : "加载更多"}
{!loadingMore && <ChevronDown className="h-3 w-3" />}
</button>
)}
</CardContent>