astock-agent/frontend/src/app/page.tsx
2026-04-16 14:16:02 +08:00

258 lines
10 KiB
TypeScript

"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(() => {
loadData();
}, [loadData]),
["llm_analysis_ready", "sector_scan_ready", "scan_complete"]
);
const handleRefresh = async () => {
setRefreshing(true);
setRefreshResult(null);
try {
const res = await postAPI<{
status: string;
count: number;
temperature: number;
scan_mode: string;
is_trading: boolean;
}>("/api/recommendations/refresh?scan_session=manual");
const modeLabel = res.scan_mode === "intraday" ? "盘中实时" : "盘后";
setRefreshResult(`${modeLabel}扫描完成,发现 ${res.count} 只股票`);
await loadData();
} catch (e) {
console.error("刷新失败:", e);
setRefreshResult("扫描失败,请重试");
} finally {
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>
);
}