astock-agent/frontend/src/app/(auth)/dashboard/page.tsx
2026-04-22 11:02:19 +08:00

507 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useEffect, useState, useCallback, useRef } from "react";
import { fetchAPI, postAPI } from "@/lib/api";
import type { LatestResult, SectorData, IndexOverview, OpsStatusResponse, StrategyBoard } from "@/lib/api";
import MarketTemp from "@/components/market-temp";
import SectorHeatmap from "@/components/sector-heatmap";
import { useWebSocket } from "@/hooks/use-websocket";
import { useAuth } from "@/hooks/use-auth";
import { ThemeToggle } from "@/components/theme-toggle";
interface ScanStatus {
is_trading: boolean;
scan_mode: string;
description: string;
}
const SCAN_TIMEOUT_MS = 120_000; // 扫描超时120秒后自动刷新数据
export default function DashboardPage() {
const { user } = useAuth();
const [data, setData] = useState<LatestResult | null>(null);
const [sectors, setSectors] = useState<SectorData[]>([]);
const [scanStatus, setScanStatus] = useState<ScanStatus | null>(null);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [refreshResult, setRefreshResult] = useState<string | null>(null);
const [indices, setIndices] = useState<IndexOverview[]>([]);
const [strategyBoard, setStrategyBoard] = useState<StrategyBoard | null>(null);
const [opsStatus, setOpsStatus] = useState<OpsStatusResponse | null>(null);
const [opsRunning, setOpsRunning] = useState<string | null>(null);
const scanTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const loadData = useCallback(async () => {
try {
const [latest, sectorData, status, overview, board, ops] = await Promise.all([
fetchAPI<LatestResult>("/api/recommendations/latest"),
fetchAPI<SectorData[]>("/api/sectors/hot?limit=8"),
fetchAPI<ScanStatus>("/api/recommendations/status"),
fetchAPI<IndexOverview[]>("/api/market/overview").catch(() => []),
fetchAPI<StrategyBoard>("/api/market/strategy-board").catch(() => null),
fetchAPI<OpsStatusResponse>("/api/market/ops-status").catch(() => null),
]);
setData(latest);
setSectors(sectorData);
setScanStatus(status);
setIndices(overview);
setStrategyBoard(board);
setOpsStatus(ops);
} catch (e) {
console.error("加载数据失败:", e);
} finally {
setLoading(false);
}
}, []);
// 清除扫描超时计时器
const clearScanTimeout = useCallback(() => {
if (scanTimeoutRef.current) {
clearTimeout(scanTimeoutRef.current);
scanTimeoutRef.current = null;
}
}, []);
useEffect(() => {
loadData();
}, [loadData]);
useWebSocket(
useCallback((msg: { type: string; count?: number; scan_mode?: string; message?: string }) => {
clearScanTimeout();
if (msg.type === "scan_update") {
const modeLabel = msg.scan_mode === "intraday" ? "盘中实时" : "盘后";
setRefreshResult(`${modeLabel}扫描完成,发现 ${msg.count ?? 0} 只股票`);
setRefreshing(false);
loadData();
setTimeout(() => setRefreshResult(null), 5000);
} else if (msg.type === "scan_error") {
setRefreshResult("扫描失败,请重试");
setRefreshing(false);
setTimeout(() => setRefreshResult(null), 5000);
} else {
// 其他消息类型(如 llm_analysis_ready刷新数据
loadData();
}
}, [loadData, clearScanTimeout]),
["scan_update", "scan_error", "llm_analysis_ready", "sector_scan_ready", "scan_complete"]
);
const handleRefresh = async () => {
setRefreshing(true);
setRefreshResult(null);
try {
const res = await postAPI<{
status: string;
message?: string;
is_trading: boolean;
}>("/api/recommendations/refresh?scan_session=manual");
if (res.status === "already_running") {
setRefreshResult(res.message || "扫描正在执行中,请稍候");
// 保持 refreshing等待 WS 推送完成
} else if (res.status === "scanning") {
setRefreshResult("扫描已启动,完成后自动刷新...");
// 保持 refreshing等待 WS 推送
}
// 设置超时:如果 120 秒内没收到 WebSocket 消息,自动停止加载状态并刷新数据
scanTimeoutRef.current = setTimeout(() => {
setRefreshResult("扫描超时,已自动刷新数据");
setRefreshing(false);
loadData();
setTimeout(() => setRefreshResult(null), 5000);
}, SCAN_TIMEOUT_MS);
} catch (e) {
console.error("触发扫描失败:", e);
setRefreshResult("触发扫描失败,请重试");
setRefreshing(false);
setTimeout(() => setRefreshResult(null), 5000);
}
};
const handleAdminAction = async (action: "update_tracking" | "generate_strategy_board" | "generate_strategy_iteration") => {
setOpsRunning(action);
setRefreshResult(null);
try {
if (action === "update_tracking") {
const result = await postAPI<{ tracked?: number; win_rate?: number; avg_return?: number }>("/api/recommendations/update-tracking");
setRefreshResult(`跟踪已更新,样本 ${result.tracked ?? 0},胜率 ${(result.win_rate ?? 0).toFixed(1)}%`);
} else if (action === "generate_strategy_board") {
await postAPI("/api/market/generate-strategy-board");
setRefreshResult("策略板已生成");
} else {
await postAPI("/api/market/generate-strategy-iteration");
setRefreshResult("策略复盘已生成");
}
await loadData();
setTimeout(() => setRefreshResult(null), 5000);
} catch (error) {
console.error("管理员操作失败:", error);
setRefreshResult("管理员操作失败,请重试");
setTimeout(() => setRefreshResult(null), 5000);
} finally {
setOpsRunning(null);
}
};
// 清理超时计时器
useEffect(() => {
return () => clearScanTimeout();
}, [clearScanTimeout]);
if (loading) {
return (
<div className="max-w-7xl mx-auto px-4 md:px-8 pt-6 space-y-5">
<div className="h-32 glass-card-static animate-shimmer" />
<div className="h-48 glass-card-static animate-shimmer" />
<div className="h-48 glass-card-static animate-shimmer" />
</div>
);
}
return (
<div className="max-w-7xl mx-auto px-4 md:px-8 pt-6 pb-20 md:pb-10 space-y-6">
{/* Header bar */}
<div className="flex items-center justify-between animate-fade-in-up">
<div>
<h1 className="text-lg font-bold tracking-tight"></h1>
{scanStatus && (
<p className="text-xs text-text-muted mt-1">
{scanStatus.is_trading ? (
<span className="inline-flex items-center gap-1.5">
<span className="w-1.5 h-1.5 bg-emerald-400 rounded-full animate-pulse" />
<span className="text-emerald-400/80"></span>
<span className="text-text-muted/40">·</span>
</span>
) : (
<span className="inline-flex items-center gap-1.5">
<span className="w-1.5 h-1.5 bg-text-muted/40 rounded-full" />
<span className="text-text-muted/40">·</span>
Tushare
</span>
)}
</p>
)}
</div>
<div className="flex items-center gap-2">
<ThemeToggle />
{user?.role === "admin" && (
<button
onClick={handleRefresh}
disabled={refreshing}
className="text-xs px-4 py-2 bg-gradient-to-r from-amber-500/20 to-amber-600/15 text-amber-400 rounded-xl hover:from-amber-500/30 hover:to-amber-600/25 disabled:opacity-40 transition-all duration-200 border border-amber-500/10 font-medium"
>
{refreshing ? (
<span className="inline-flex items-center gap-1.5">
<span className="w-3 h-3 border border-amber-400/40 border-t-amber-400 rounded-full animate-spin" />
...
</span>
) : scanStatus?.is_trading ? (
"盘中扫描"
) : (
"立即扫描"
)}
</button>
)}
</div>
</div>
{/* Scan result toast */}
{refreshResult && (
<div className="glass-card-static border-amber-500/15 px-4 py-2.5 text-xs text-amber-400 animate-fade-in-up flex items-center gap-2">
<span className="w-1 h-1 rounded-full bg-amber-400" />
{refreshResult}
</div>
)}
<MissionControl
board={strategyBoard}
recommendations={data?.recommendations ?? []}
sectors={sectors}
strategyProfile={data?.strategy_profile ?? null}
/>
{opsStatus && (
user?.role === "admin" ? (
<OpsPanel
opsStatus={opsStatus}
isAdmin={true}
refreshing={refreshing}
onRefresh={handleRefresh}
opsRunning={opsRunning}
onAction={handleAdminAction}
/>
) : null
)}
<div className="animate-fade-in-up delay-100">
<EvidenceHeader title="决策证据" />
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-3">
<MarketTemp data={data?.market_temperature ?? null} indices={indices} />
<SectorHeatmap sectors={sectors} />
</div>
</div>
</div>
);
}
function OpsPanel({
opsStatus,
isAdmin,
refreshing,
onRefresh,
opsRunning,
onAction,
}: {
opsStatus: OpsStatusResponse;
isAdmin: boolean;
refreshing: boolean;
onRefresh: () => void;
opsRunning: string | null;
onAction: (action: "update_tracking" | "generate_strategy_board" | "generate_strategy_iteration") => void;
}) {
return (
<div className="glass-card-static p-4 animate-fade-in-up">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<div className="text-[10px] uppercase tracking-[0.22em] text-cyan-400 font-semibold">Admin Ops</div>
<div className="text-xs text-text-muted mt-2">
{opsStatus.data_freshness.message}
</div>
</div>
<div className="flex flex-wrap items-center gap-2 text-[11px] text-text-muted">
<span> {opsStatus.data_freshness.market_trade_date || "暂无"}</span>
<span> {opsStatus.data_freshness.sector_trade_date || "暂无"}</span>
<span> {opsStatus.data_freshness.tracking_trade_date || "暂无"}</span>
<span>{opsStatus.scan_running ? "扫描中" : "空闲"}</span>
</div>
</div>
{isAdmin ? (
<div className="flex flex-wrap gap-2 mt-3">
<button
onClick={onRefresh}
disabled={refreshing || !!opsRunning}
className="text-xs px-3 py-2 bg-gradient-to-r from-amber-500/20 to-amber-600/15 text-amber-400 rounded-xl hover:from-amber-500/30 hover:to-amber-600/25 disabled:opacity-40 transition-all border border-amber-500/10"
>
{refreshing ? "扫描中..." : "立即扫描"}
</button>
<button
onClick={() => onAction("update_tracking")}
disabled={refreshing || !!opsRunning}
className="text-xs px-3 py-2 rounded-xl border border-border-subtle bg-surface-1 text-text-secondary hover:text-cyan-400 transition-colors disabled:opacity-40"
>
{opsRunning === "update_tracking" ? "更新中..." : "更新跟踪"}
</button>
<button
onClick={() => onAction("generate_strategy_board")}
disabled={refreshing || !!opsRunning}
className="text-xs px-3 py-2 rounded-xl border border-border-subtle bg-surface-1 text-text-secondary hover:text-cyan-400 transition-colors disabled:opacity-40"
>
{opsRunning === "generate_strategy_board" ? "生成中..." : "生成策略板"}
</button>
<button
onClick={() => onAction("generate_strategy_iteration")}
disabled={refreshing || !!opsRunning}
className="text-xs px-3 py-2 rounded-xl border border-border-subtle bg-surface-1 text-text-secondary hover:text-cyan-400 transition-colors disabled:opacity-40"
>
{opsRunning === "generate_strategy_iteration" ? "生成中..." : "生成策略复盘"}
</button>
<a href="/strategy" className="text-xs px-3 py-2 rounded-xl border border-border-subtle bg-surface-1 text-text-secondary hover:text-cyan-400 transition-colors">
</a>
<a href="/recommendations" className="text-xs px-3 py-2 rounded-xl border border-border-subtle bg-surface-1 text-text-secondary hover:text-amber-400 transition-colors">
</a>
</div>
) : null}
</div>
);
}
function EvidenceHeader({ title }: { title: string }) {
return (
<div>
<h2 className="text-xs font-semibold text-text-muted uppercase tracking-wider">
{title}
</h2>
</div>
);
}
function MissionControl({
board,
recommendations,
sectors,
strategyProfile,
}: {
board: StrategyBoard | null;
recommendations: LatestResult["recommendations"];
sectors: SectorData[];
strategyProfile: LatestResult["strategy_profile"];
}) {
const actionable = recommendations.filter((rec) => rec.action_plan === "可操作");
const watch = recommendations.filter((rec) => rec.action_plan === "重点关注");
const observe = recommendations.filter((rec) => !["可操作", "重点关注"].includes(rec.action_plan ?? ""));
const topSectors = sectors.slice(0, 3);
const risks = board?.avoid_rules?.length ? board.avoid_rules : ["等待市场状态和推荐结果更新后生成风险约束。"];
const primaryQueue = (actionable.length ? actionable : watch).slice(0, 3);
const laneTitle = actionable.length ? "优先执行" : watch.length ? "候选观察" : "等待信号";
const strategyName = strategyProfile?.name ?? board?.recommended_mode ?? "待定";
const strategyHint = strategyProfile?.description ?? "系统判断今天更适合采用的出手方式";
const riskText = risks.slice(0, 2).join(" / ");
return (
<div className="glass-card-static px-4 py-4 md:px-5 md:py-5 overflow-hidden relative animate-fade-in-up">
<div className="absolute right-[-100px] top-[-120px] w-72 h-72 rounded-full bg-amber-500/[0.04] blur-3xl pointer-events-none" />
<div className="relative grid grid-cols-1 xl:grid-cols-[minmax(0,1fr)_300px] gap-4">
<div className="min-w-0 space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex flex-wrap items-center gap-2">
<span className="text-[10px] uppercase tracking-[0.22em] text-amber-400 font-semibold"></span>
{board?.generated_by === "rules+llm" && (
<span className="text-[10px] px-2 py-0.5 rounded-full border border-cyan-500/15 bg-cyan-500/10 text-cyan-400">AI增强</span>
)}
</div>
<a href="/recommendations" className="text-[11px] text-text-muted hover:text-amber-400 transition-colors">
</a>
</div>
<div className="grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_220px] gap-3 items-start">
<div className="min-w-0">
<h2 className="text-lg md:text-[1.2rem] font-bold tracking-tight truncate">
{board?.market_regime ?? "等待市场状态"}
</h2>
<p className="text-[12px] text-text-secondary leading-relaxed mt-1.5 max-w-3xl line-clamp-2">
{board?.summary ?? "系统尚未生成今日作战结论。触发扫描后,将基于市场温度、板块主线、推荐生命周期和策略复盘生成操作框架。"}
</p>
</div>
<div className="grid grid-cols-3 lg:grid-cols-1 gap-1.5">
<CommandMetric label="可操作" value={actionable.length} description="盯盘" />
<CommandMetric label="关注" value={watch.length} description="等确认" />
<CommandMetric label="观察" value={observe.length} description="不追" />
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-2">
<CompactDecision label="今日打法" value={strategyName} extra={strategyHint} />
<CompactDecision label="仓位建议" value={board?.position_suggestion ?? "等待判断"} extra="今天建议的进攻上限" />
<CompactDecision label="市场倾向" value={board?.action_bias ?? "等待确认"} extra={`风险 ${board?.risk_level ?? "-"}`} />
<CompactDecision label="主线板块" value={topSectors.length ? topSectors.map((sector) => sector.sector_name).join(" / ") : "暂无"} extra={riskText || "暂无风险约束"} />
</div>
<div className="flex flex-wrap items-center gap-2">
{topSectors.length ? (
topSectors.map((sector) => (
<span key={sector.sector_code} className="inline-flex items-center gap-1.5 rounded-full bg-surface-1/80 border border-border-subtle px-2.5 py-1 text-[11px]">
<span className="font-medium text-text-primary">{sector.sector_name}</span>
<span className={`font-mono tabular-nums ${sector.pct_change >= 0 ? "text-red-400" : "text-emerald-400"}`}>
{sector.pct_change > 0 ? "+" : ""}{sector.pct_change.toFixed(2)}%
</span>
</span>
))
) : (
<span className="text-xs text-text-muted">线</span>
)}
</div>
</div>
<div className="rounded-2xl bg-surface-1/70 border border-border-subtle p-3">
<div className="flex items-center justify-between gap-3">
<SectionTitle title={laneTitle} />
<span className="text-[10px] text-text-muted">{primaryQueue.length}</span>
</div>
<div className="mt-2.5 space-y-1.5">
{primaryQueue.length ? (
primaryQueue.map((rec) => (
<CompactMissionStock key={`mission-${rec.ts_code}`} rec={rec} />
))
) : (
<div className="rounded-lg bg-surface-2/60 border border-border-subtle p-3 text-center">
<div className="text-xs text-text-muted"></div>
<div className="text-[11px] text-text-muted/50 mt-0.5"></div>
</div>
)}
</div>
<a
href="/strategy"
className="inline-flex items-center justify-center mt-3 w-full rounded-xl border border-border-subtle bg-surface-2/60 px-3 py-2 text-[11px] text-text-muted hover:text-amber-400 hover:border-amber-500/20 transition-colors"
>
</a>
</div>
</div>
</div>
);
}
function CompactDecision({ label, value, extra }: { label: string; value: string; extra: string }) {
return (
<div className="rounded-lg bg-surface-2 px-3 py-2">
<div className="text-[10px] text-text-muted uppercase tracking-wider font-semibold">{label}</div>
<div className="text-sm font-semibold text-text-primary mt-1 truncate">{value}</div>
{extra ? <div className="text-[10px] text-text-muted mt-1 truncate">{extra}</div> : null}
</div>
);
}
function CompactMissionStock({ rec }: { rec: LatestResult["recommendations"][number] }) {
const actionStyle: Record<string, string> = {
"可操作": "bg-red-500/15 text-red-400 border-red-500/20",
"重点关注": "bg-amber-500/15 text-amber-400 border-amber-500/20",
"观察": "bg-surface-3 text-text-muted border-border-default",
};
return (
<a
href={`/stock/${rec.ts_code}`}
className="block rounded-lg bg-surface-2/60 border border-border-subtle px-3 py-2 hover:border-amber-500/20 transition-colors"
>
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-1.5 min-w-0">
<span className="text-sm font-semibold truncate">{rec.name}</span>
<span className={`shrink-0 text-[10px] px-1.5 py-0.5 rounded-md border ${actionStyle[rec.action_plan ?? "观察"] ?? actionStyle["观察"]}`}>
{rec.action_plan ?? "观察"}
</span>
</div>
<div className="text-[10px] text-text-muted font-mono tabular-nums mt-0.5 truncate">
{rec.ts_code} · {rec.sector}
</div>
</div>
<div className="shrink-0 text-right">
<div className="text-base font-bold font-mono tabular-nums text-text-primary">{rec.score}</div>
<div className="text-[10px] text-text-muted"></div>
</div>
</div>
<div className="mt-1.5 text-[11px] text-text-secondary leading-relaxed line-clamp-1">
{rec.trigger_condition ?? rec.reasons?.[0] ?? "等待触发条件确认"}
</div>
</a>
);
}
function CommandMetric({ label, value, description }: { label: string; value: number; description: string }) {
return (
<div className="rounded-lg bg-surface-1/70 border border-border-subtle px-2.5 py-2">
<div className="text-[10px] text-text-muted uppercase tracking-wider font-semibold">{label}</div>
<div className="flex items-baseline justify-between gap-2 mt-0.5">
<div className="text-lg font-bold font-mono tabular-nums text-text-primary">{value}</div>
<div className="text-[10px] text-text-muted">{description}</div>
</div>
</div>
);
}
function SectionTitle({ title }: { title: string }) {
return (
<div className="text-[10px] text-text-muted uppercase tracking-wider font-semibold">
{title}
</div>
);
}