273 lines
11 KiB
TypeScript
273 lines
11 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((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>
|
||
);
|
||
}
|
||
|