astock-agent/frontend/src/app/(auth)/dashboard/page.tsx
2026-04-16 21:30:52 +08:00

273 lines
11 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 } from "react";
import { fetchAPI, postAPI } from "@/lib/api";
import type { LatestResult, SectorData, IndexOverview, DailyReviewResponse } from "@/lib/api";
import MarketTemp from "@/components/market-temp";
import StockCard from "@/components/stock-card";
import SectorHeatmap from "@/components/sector-heatmap";
import { useWebSocket } from "@/hooks/use-websocket";
import { useAuth } from "@/hooks/use-auth";
import { ThemeToggle } from "@/components/theme-toggle";
import { markdownToHtml } from "@/lib/markdown";
interface ScanStatus {
is_trading: boolean;
scan_mode: string;
description: string;
}
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 [llmEnabled, setLlmEnabled] = useState(false);
const [indices, setIndices] = useState<IndexOverview[]>([]);
const [dailyReview, setDailyReview] = useState<string | null>(null);
const [generatingReview, setGeneratingReview] = useState(false);
const loadData = useCallback(async () => {
try {
const [latest, sectorData, status, health, overview, reviewData] = await Promise.all([
fetchAPI<LatestResult>("/api/recommendations/latest"),
fetchAPI<SectorData[]>("/api/sectors/hot?limit=8"),
fetchAPI<ScanStatus>("/api/recommendations/status"),
fetchAPI<{ llm_enabled: boolean }>("/api/health"),
fetchAPI<IndexOverview[]>("/api/market/overview").catch(() => []),
fetchAPI<DailyReviewResponse>("/api/market/daily-review").catch(() => ({ reviews: [] })),
]);
setData(latest);
setSectors(sectorData);
setScanStatus(status);
setLlmEnabled(health.llm_enabled);
setIndices(overview);
if (reviewData.reviews?.length > 0) {
setDailyReview(reviewData.reviews[0].content);
}
} catch (e) {
console.error("加载数据失败:", e);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadData();
}, [loadData]);
useWebSocket(
useCallback((msg: { type: string; count?: number; scan_mode?: string; message?: string }) => {
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]),
["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 推送
}
} catch (e) {
console.error("触发扫描失败:", e);
setRefreshResult("触发扫描失败,请重试");
setRefreshing(false);
setTimeout(() => setRefreshResult(null), 5000);
}
};
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 md:hidden tracking-tight">Dragon AI Agent</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>
)}
{/* Market temp + Sector heatmap */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<MarketTemp data={data?.market_temperature ?? null} indices={indices} />
<SectorHeatmap sectors={sectors} />
</div>
{/* Daily Review */}
<div className="animate-fade-in-up delay-100">
<div className="flex items-center justify-between mb-3">
<h2 className="text-xs font-semibold text-text-muted uppercase tracking-wider">
</h2>
{llmEnabled && (
<button
onClick={async () => {
setGeneratingReview(true);
setRefreshResult(null);
try {
const res = await postAPI<{ status: string; content?: string; message?: string }>("/api/market/generate-review");
if (res.status === "ok" && res.content) {
setDailyReview(res.content);
} else if (res.message) {
setRefreshResult(res.message);
}
} catch (e) {
console.error("生成复盘失败:", e);
setRefreshResult("生成复盘失败,请重试");
} finally {
setGeneratingReview(false);
setTimeout(() => setRefreshResult(null), 5000);
}
}}
disabled={generatingReview}
className="text-[10px] px-3 py-1.5 bg-surface-2 text-text-secondary rounded-lg hover:bg-surface-4 disabled:opacity-40 transition-all font-medium"
>
{generatingReview ? (
<span className="inline-flex items-center gap-1">
<span className="w-2.5 h-2.5 border border-text-muted/40 border-t-text-muted rounded-full animate-spin" />
...
</span>
) : (
dailyReview ? "重新生成" : "生成复盘"
)}
</button>
)}
</div>
{dailyReview ? (
<div className="glass-card-static p-5">
<div
className="text-xs text-text-secondary leading-relaxed prose prose-sm prose-invert max-w-none [&_h2]:text-sm [&_h2]:font-semibold [&_h2]:text-amber-400 [&_h2]:mt-4 [&_h2]:mb-2 [&_h2]:first:mt-0 [&_p]:text-text-secondary [&_p]:mb-2 [&_ul]:text-text-secondary [&_li]:mb-1"
dangerouslySetInnerHTML={{ __html: markdownToHtml(dailyReview) }}
/>
</div>
) : (
<div className="glass-card-static p-6 text-center">
<div className="text-text-muted text-sm mb-1"></div>
<div className="text-text-muted/50 text-xs">
{llmEnabled ? "点击「生成复盘」AI自动分析" : "配置LLM后自动生成"}
</div>
</div>
)}
</div>
{/* Recommendations */}
<div className="animate-fade-in-up delay-150">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xs font-semibold text-text-muted uppercase tracking-wider">
{data?.recommendations?.length ? (
<span className="text-text-primary ml-1.5 font-mono tabular-nums">{data.recommendations.length}</span>
) : ""}
</h2>
<a href="/recommendations" className="text-xs text-text-muted hover:text-amber-400 transition-colors flex items-center gap-1">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M5 12h14M12 5l7 7-7 7" />
</svg>
</a>
</div>
{!data?.recommendations?.length ? (
<div className="glass-card-static p-10 text-center">
<div className="text-text-muted text-sm mb-1"></div>
<div className="text-text-muted/60 text-xs">
{user?.role === "admin" ? `点击 ${scanStatus?.is_trading ? "「盘中扫描」" : "「立即扫描」"} 开始分析` : "等待系统自动扫描更新"}
</div>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{data.recommendations.slice(0, 6).map((rec) => (
<StockCard key={rec.ts_code} rec={rec} showLLMLoading={llmEnabled} />
))}
</div>
)}
</div>
</div>
);
}