feat: 核心股追踪行点击展开显示所属题材列表

- active 接口返回每只核心股 themes(窗口内按题材去重)
- 页面行点击展开题材标签,chevron 指示展开状态

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
SakurasanandClaude committed 2026-08-10 21:50:16 +08:00
1 parent 23644a9c77
commit 6d3c470b7b
3 files changed
+77 -29

No files matched your search

+13
View File
@@ -59,6 +59,19 @@ async def active_core_stocks():
stocks.sort(key=lambda x: x.get("lastAppear") or "", reverse=True) stocks.sort(key=lambda x: x.get("lastAppear") or "", reverse=True)
stocks.sort(key=lambda x: -x["appearCount"]) 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:最近上榜距窗口最新交易日的自然日差(简单口径) # daysSinceLastAppear:最近上榜距窗口最新交易日的自然日差(简单口径)
latest = dates[-1] if dates else None latest = dates[-1] if dates else None
for s in stocks: for s in stocks:
+1
View File
@@ -9,6 +9,7 @@ export interface ActiveCoreStock {
appearCount: number; appearCount: number;
lastAppear: string | null; lastAppear: string | null;
daysSinceLastAppear: number; // 最近上榜距窗口最新交易日的自然日差 daysSinceLastAppear: number; // 最近上榜距窗口最新交易日的自然日差
themes: { theme_code: string; theme_name: string }[]; // 所属题材列表
} }
export interface ActiveCoreStocksResponse { export interface ActiveCoreStocksResponse {
+63 -29
View File
@@ -1,7 +1,8 @@
import { createFileRoute, Link } from "@tanstack/react-router"; import { createFileRoute, Link } from "@tanstack/react-router";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Fragment, useState } from "react";
import { fetchActiveCoreStocks } from "@/lib/core-stock-api"; import { fetchActiveCoreStocks } from "@/lib/core-stock-api";
import { ArrowLeft, RefreshCw, Flame } from "lucide-react"; import { ArrowLeft, RefreshCw, Flame, ChevronDown, ChevronRight } from "lucide-react";
export const Route = createFileRoute("/core-stocks")({ export const Route = createFileRoute("/core-stocks")({
component: CoreStocksPage, component: CoreStocksPage,
@@ -24,6 +25,7 @@ function CoreStocksPage() {
const dates = data?.dates ?? []; const dates = data?.dates ?? [];
const stocks = data?.stocks ?? []; const stocks = data?.stocks ?? [];
const [expanded, setExpanded] = useState<string | null>(null);
return ( return (
<div className="min-h-screen bg-background"> <div className="min-h-screen bg-background">
@@ -74,35 +76,67 @@ function CoreStocksPage() {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{stocks.map((s) => ( {stocks.map((s) => {
<tr key={s.stockCode} className="border-b last:border-0 hover:bg-muted/30"> const isOpen = expanded === s.stockCode;
<td className="px-3 py-1.5 whitespace-nowrap"> return (
<span className="font-medium">{s.stockName}</span> <Fragment key={s.stockCode}>
<span className="ml-1 text-[10px] text-muted-foreground">{s.stockCode}</span> <tr
</td> onClick={() => setExpanded(isOpen ? null : s.stockCode)}
{dates.map((d) => { className="border-b last:border-0 hover:bg-muted/30 cursor-pointer"
const g = s.dailyGains[d]; >
const cls = g == null ? "text-muted-foreground/40" : g >= 0 ? "text-red-500" : "text-green-500"; <td className="px-3 py-1.5 whitespace-nowrap">
return ( <span className="inline-flex items-center gap-1">
<td key={d} className={`px-2 py-1.5 text-right tabular-nums whitespace-nowrap ${cls}`}> {isOpen ? <ChevronDown className="h-3.5 w-3.5 text-muted-foreground" /> : <ChevronRight className="h-3.5 w-3.5 text-muted-foreground" />}
{formatGain(g)} <span className="font-medium">{s.stockName}</span>
<span className="ml-1 text-[10px] text-muted-foreground">{s.stockCode}</span>
</span>
</td> </td>
); {dates.map((d) => {
})} const g = s.dailyGains[d];
<td className="px-2 py-1.5 text-right tabular-nums whitespace-nowrap text-muted-foreground"> const cls = g == null ? "text-muted-foreground/40" : g >= 0 ? "text-red-500" : "text-green-500";
{s.coverCount ?? "·"} return (
</td> <td key={d} className={`px-2 py-1.5 text-right tabular-nums whitespace-nowrap ${cls}`}>
<td className="px-2 py-1.5 text-right tabular-nums whitespace-nowrap text-muted-foreground"> {formatGain(g)}
{s.lastAppear ? s.lastAppear.slice(5) : "·"} </td>
</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"> <td className="px-2 py-1.5 text-right tabular-nums whitespace-nowrap text-muted-foreground">
<Flame className="h-3 w-3" /> {s.coverCount ?? "·"}
{s.appearCount} </td>
</span> <td className="px-2 py-1.5 text-right tabular-nums whitespace-nowrap text-muted-foreground">
</td> {s.lastAppear ? s.lastAppear.slice(5) : "·"}
</tr> </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>
{isOpen && (
<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) => (
<span
key={t.theme_code}
className="px-1.5 py-0.5 rounded bg-primary/10 text-primary text-[11px]"
>
{t.theme_name}
</span>
))}
</div>
) : (
<span className="text-xs text-muted-foreground">暂无题材数据</span>
)}
</td>
</tr>
)}
</Fragment>
);
})}
</tbody> </tbody>
</table> </table>
</div> </div>