This commit is contained in:
aaron 2026-04-16 21:30:52 +08:00
parent d7f7ad305c
commit 3fc2d86a46
25 changed files with 1136 additions and 591 deletions

View File

@ -1,9 +1,14 @@
{
"pages": {
"/page": [
"/(public)/page": [
"static/chunks/webpack.js",
"static/chunks/main-app.js",
"static/chunks/app/page.js"
"static/chunks/app/(public)/page.js"
],
"/(public)/layout": [
"static/chunks/webpack.js",
"static/chunks/main-app.js",
"static/chunks/app/(public)/layout.js"
],
"/layout": [
"static/chunks/webpack.js",
@ -11,15 +16,20 @@
"static/css/app/layout.css",
"static/chunks/app/layout.js"
],
"/recommendations/page": [
"/(public)/login/page": [
"static/chunks/webpack.js",
"static/chunks/main-app.js",
"static/chunks/app/recommendations/page.js"
"static/chunks/app/(public)/login/page.js"
],
"/sectors/page": [
"/(auth)/dashboard/page": [
"static/chunks/webpack.js",
"static/chunks/main-app.js",
"static/chunks/app/sectors/page.js"
"static/chunks/app/(auth)/dashboard/page.js"
],
"/(auth)/layout": [
"static/chunks/webpack.js",
"static/chunks/main-app.js",
"static/chunks/app/(auth)/layout.js"
]
}
}

File diff suppressed because one or more lines are too long

View File

@ -1,8 +1 @@
{
"app/sectors/page.tsx -> echarts": {
"id": "app/sectors/page.tsx -> echarts",
"files": [
"static/chunks/_app-pages-browser_node_modules_echarts_index_js.js"
]
}
}
{}

View File

@ -1,5 +1,5 @@
{
"/page": "app/page.js",
"/recommendations/page": "app/recommendations/page.js",
"/sectors/page": "app/sectors/page.js"
"/(public)/page": "app/(public)/page.js",
"/(public)/login/page": "app/(public)/login/page.js",
"/(auth)/dashboard/page": "app/(auth)/dashboard/page.js"
}

View File

@ -1 +1 @@
self.__REACT_LOADABLE_MANIFEST="{\"app/sectors/page.tsx -> echarts\":{\"id\":\"app/sectors/page.tsx -> echarts\",\"files\":[\"static/chunks/_app-pages-browser_node_modules_echarts_index_js.js\"]}}"
self.__REACT_LOADABLE_MANIFEST="{}"

View File

@ -1,5 +1,5 @@
{
"node": {},
"edge": {},
"encryptionKey": "ep/FQ9LpllRmRWJwm9g4skQlubaCcrDQ9a9sMtJTxBw="
"encryptionKey": "m7y670mhDOo8SSKub8gVRhrD89+RG50BK5Q4DqLVZ2s="
}

View File

@ -125,7 +125,7 @@
/******/
/******/ /* webpack/runtime/getFullHash */
/******/ (() => {
/******/ __webpack_require__.h = () => ("08a4c58ab1067fd1")
/******/ __webpack_require__.h = () => ("de49d7cff76726e2")
/******/ })();
/******/
/******/ /* webpack/runtime/hasOwnProperty shorthand */

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,47 @@
import { AuthGuard } from "@/components/auth-guard";
import { UserMenu } from "@/components/user-menu";
import { SidebarNav, MobileBottomNav } from "@/components/nav";
export default function AuthLayout({ children }: { children: React.ReactNode }) {
return (
<AuthGuard>
{/* Desktop: sidebar + main */}
<div className="flex min-h-screen">
{/* Desktop sidebar */}
<aside className="hidden md:flex flex-col w-60 glass-sidebar fixed inset-y-0 left-0 z-40">
{/* Brand */}
<div className="px-6 pt-7 pb-5">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-amber-500 to-amber-600 flex items-center justify-center text-sm font-bold text-white shadow-glow-sm">
D
</div>
<div>
<h1 className="text-sm font-semibold tracking-tight">Dragon AI Agent</h1>
<p className="text-xs text-text-muted mt-0.5 font-light tracking-wide">A </p>
</div>
</div>
</div>
{/* Divider */}
<div className="mx-5 h-px bg-gradient-to-r from-transparent via-border-default to-transparent" />
{/* Nav */}
<SidebarNav />
{/* Footer */}
<div className="px-6 py-5 border-t border-border-subtle">
<UserMenu />
</div>
</aside>
{/* Main content area */}
<main className="flex-1 md:ml-60 pb-16 md:pb-0 min-h-screen">
{children}
</main>
</div>
{/* Mobile bottom nav */}
<MobileBottomNav />
</AuthGuard>
);
}

View File

@ -0,0 +1,585 @@
"use client";
import { useEffect, useState, useCallback, useMemo } from "react";
import { fetchAPI } from "@/lib/api";
import type { SectorData, LeadingStock, SectorRotationData } from "@/lib/api";
import { formatNumber } from "@/lib/utils";
import { useWebSocket } from "@/hooks/use-websocket";
import { ErrorBoundary } from "@/components/error-boundary";
function getStageInfo(stage: string) {
switch (stage) {
case "early":
return { label: "启动期", color: "text-emerald-400", bg: "bg-emerald-500/10 border-emerald-500/15", barColor: "bg-emerald-500/60" };
case "mid":
return { label: "发展期", color: "text-amber-400", bg: "bg-amber-500/10 border-amber-500/15", barColor: "bg-amber-500/60" };
case "late":
return { label: "后期", color: "text-orange-400", bg: "bg-orange-500/10 border-orange-500/15", barColor: "bg-orange-500/60" };
case "end":
return { label: "尾声", color: "text-red-400", bg: "bg-red-500/10 border-red-500/15", barColor: "bg-red-500/60" };
default:
return { label: "—", color: "text-text-muted", bg: "bg-surface-2 border-border-default", barColor: "bg-surface-3" };
}
}
function getOpportunityHint(stage: string, mainForceRatio?: number): string {
switch (stage) {
case "early": return "新行情启动,关注领涨股入场时机";
case "mid": return (mainForceRatio ?? 0) > 30 ? "行情加速中,主力资金持续流入" : "行情发展中,关注资金动向";
case "late": return "行情接近高位,注意获利回吐风险";
case "end": return "行情衰退,建议谨慎观望";
default: return "";
}
}
function normalizeValues(values: number[]): number[] {
if (!values.length) return [];
const min = Math.min(...values);
const max = Math.max(...values);
if (max === min) return values.map(() => 50);
return values.map(v => (v - min) / (max - min) * 100);
}
/** 迷你柱状图近5日涨跌幅 */
function MiniBarChart({ data }: { data: number[] }) {
if (!data.length) return null;
const maxAbs = Math.max(...data.map(Math.abs), 0.1);
return (
<div className="flex items-end gap-1 h-8">
{data.map((v, i) => {
const h = Math.max(Math.abs(v) / maxAbs * 100, 8);
return (
<div key={i} className="flex-1 flex flex-col justify-end items-center" style={{ height: "100%" }}>
<div
className={`w-full rounded-sm transition-all duration-300 ${v >= 0 ? "bg-red-500/50" : "bg-emerald-500/50"}`}
style={{ height: `${h}%` }}
/>
</div>
);
})}
</div>
);
}
/** 领涨股标签 */
function LeadingStockTag({ stock }: { stock: LeadingStock }) {
const isLimitUp = stock.pct_chg >= 9.8;
return (
<a
href={`/stock/${stock.ts_code}`}
className="inline-flex items-center gap-1 text-[11px] px-2 py-1 rounded-lg bg-surface-2 border border-border-default hover:bg-surface-4 transition-colors"
>
<span className="text-text-secondary font-medium">{stock.name}</span>
<span className={`font-mono tabular-nums ${isLimitUp ? "text-red-400 font-bold" : stock.pct_chg > 0 ? "text-red-400/80" : "text-emerald-400/80"}`}>
{stock.pct_chg > 0 ? "+" : ""}{stock.pct_chg}%
</span>
{stock.limit_times != null && stock.limit_times > 1 && (
<span className="text-[9px] px-1 py-0.5 rounded bg-red-500/15 text-red-400 font-bold">
{stock.limit_times}
</span>
)}
</a>
);
}
function HeatScoreDots({ scores: [pct, cap, lim, con] }: { scores: [number, number, number, number] }) {
const dots = [
{ score: pct, label: "涨幅" },
{ score: cap, label: "资金" },
{ score: lim, label: "涨停" },
{ score: con, label: "连续" },
];
return (
<div className="flex items-center gap-1">
{dots.map((d) => (
<span
key={d.label}
className={`w-1.5 h-1.5 rounded-full ${d.score >= 60 ? "bg-amber-400" : "bg-text-muted/30"}`}
title={`${d.label}因子: ${d.score.toFixed(0)}`}
/>
))}
</div>
);
}
function SectorDetailCard({ sector, index, factorScores }: {
sector: SectorData;
index: number;
factorScores?: [number, number, number, number];
}) {
const displayPct = sector.realtime_pct_change ?? sector.pct_change;
const isUp = displayPct > 0;
const displayLimitUp = sector.realtime_limit_up_count ?? sector.limit_up_count;
const leaders = sector.is_realtime
? (sector.leading_stocks_realtime?.length ? sector.leading_stocks_realtime : sector.leading_stocks)
: sector.leading_stocks;
const stage = getStageInfo(sector.stage ?? "");
const isTop3 = index < 3;
const cumulativePct = sector.pct_trend ? sector.pct_trend.reduce((sum, v) => sum + v, 0) : sector.pct_change;
const hint = getOpportunityHint(sector.stage ?? "", sector.main_force_ratio);
const mainForceRatio = sector.main_force_ratio ?? 0;
return (
<div
className="glass-card animate-fade-in-up overflow-hidden"
style={{ animationDelay: `${index * 60}ms` }}
>
{/* Stage color bar at top */}
<div className={`h-1 ${stage.barColor}`} />
<div className="p-5">
{/* Header row */}
<div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-2.5">
<span className={`w-7 h-7 rounded-lg flex items-center justify-center text-xs font-bold shrink-0 ${
index === 0
? "bg-gradient-to-br from-amber-500/30 to-amber-600/20 text-amber-400 border border-amber-500/20"
: index === 1
? "bg-gradient-to-br from-slate-400/20 to-slate-500/15 text-slate-300 border border-slate-400/15"
: index === 2
? "bg-gradient-to-br from-amber-700/20 to-amber-800/15 text-amber-400 border border-amber-600/15"
: "bg-surface-2 text-text-muted border border-border-subtle"
}`}>
{index + 1}
</span>
<div>
<div className="flex items-center gap-2">
<span className={`text-sm font-semibold ${isTop3 ? "text-text-primary" : "text-text-secondary"}`}>
{sector.sector_name}
</span>
{sector.is_realtime && (
<span className="text-[9px] px-1.5 py-0.5 rounded bg-emerald-500/10 text-emerald-400/80 border border-emerald-500/15">
</span>
)}
</div>
<div className="text-[11px] text-text-muted/60 mt-0.5">
{stage.label !== "—" && (
<>
<span className={stage.color}>{stage.label}</span>
<span className="text-text-muted/30 mx-1">·</span>
<span>{sector.days_continuous}</span>
<span className="text-text-muted/30 mx-1">·</span>
<span className={`font-mono tabular-nums ${cumulativePct > 0 ? "text-red-400/60" : "text-emerald-400/60"}`}>
{cumulativePct > 0 ? "+" : ""}{cumulativePct.toFixed(1)}%
</span>
</>
)}
{stage.label === "—" && sector.member_count && `${sector.member_count}只成分股`}
</div>
</div>
</div>
<div className="text-right">
<div className={`text-base font-bold font-mono tabular-nums ${isUp ? "text-red-400" : "text-emerald-400"}`}>
{displayPct > 0 ? "+" : ""}{displayPct.toFixed(2)}%
</div>
</div>
</div>
{/* Opportunity hint */}
{hint && (
<div className={`text-[11px] mb-3 px-2.5 py-1.5 rounded-lg ${stage.bg} ${stage.color}`}>
{hint}
</div>
)}
{/* Metrics row - 4 columns */}
<div className="grid grid-cols-4 gap-2 mb-3">
<div className="bg-surface-1 rounded-lg px-2.5 py-2">
<div className="text-[10px] text-text-muted/50 mb-0.5"></div>
<div className={`text-xs font-mono tabular-nums font-semibold ${sector.capital_inflow > 0 ? "text-red-400" : "text-emerald-400"}`}>
{sector.capital_inflow > 0 ? "+" : ""}{formatNumber(sector.capital_inflow)}
</div>
</div>
<div className="bg-surface-1 rounded-lg px-2.5 py-2">
<div className="text-[10px] text-text-muted/50 mb-0.5"></div>
<div className="text-xs font-mono tabular-nums font-semibold text-text-secondary">
{displayLimitUp}<span className="text-text-muted/40 text-[10px]"> </span>
</div>
</div>
<div className="bg-surface-1 rounded-lg px-2.5 py-2">
<div className="text-[10px] text-text-muted/50 mb-0.5"></div>
<div className={`text-xs font-mono tabular-nums font-semibold ${
mainForceRatio > 30 ? "text-amber-400" : mainForceRatio < 0 ? "text-red-400" : "text-text-secondary"
}`}>
{mainForceRatio.toFixed(1)}<span className="text-text-muted/40 text-[10px]">%</span>
</div>
</div>
<div className="bg-surface-1 rounded-lg px-2.5 py-2">
<div className="text-[10px] text-text-muted/50 mb-0.5"></div>
<div className="flex items-center gap-1.5">
<span className={`text-xs font-mono tabular-nums font-semibold ${
sector.heat_score >= 70 ? "text-amber-400" : sector.heat_score >= 50 ? "text-text-secondary" : "text-text-muted"
}`}>
{sector.heat_score.toFixed(0)}
<span className="text-text-muted/40 text-[10px]">/100</span>
</span>
{factorScores && <HeatScoreDots scores={factorScores} />}
</div>
</div>
</div>
{/* 5日趋势图 */}
{sector.pct_trend && sector.pct_trend.length > 1 && (
<div className="mb-3">
<div className="flex items-center justify-between mb-1.5">
<span className="text-[10px] text-text-muted/50">5</span>
<div className="flex gap-2">
{sector.pct_trend.map((v, i) => (
<span key={i} className={`text-[9px] font-mono tabular-nums ${v >= 0 ? "text-red-400/60" : "text-emerald-400/60"}`}>
{v > 0 ? "+" : ""}{v.toFixed(1)}
</span>
))}
</div>
</div>
<MiniBarChart data={sector.pct_trend} />
</div>
)}
{/* 领涨股 */}
{leaders && leaders.length > 0 && (
<div>
<span className="text-[10px] text-text-muted/50 mb-1.5 block"></span>
<div className="flex flex-wrap gap-1.5">
{leaders.map((s) => (
<LeadingStockTag key={s.ts_code} stock={s} />
))}
</div>
</div>
)}
</div>
</div>
);
}
/** 今日关注 - Top 3 摘要 */
function FocusSummary({ sectors }: { sectors: SectorData[] }) {
const top3 = sectors.slice(0, 3);
if (!top3.length) return null;
return (
<div className="glass-card-static p-4 animate-fade-in-up mb-4">
<h2 className="text-xs font-semibold text-amber-400 uppercase tracking-wider mb-3">
</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
{top3.map((sector, i) => {
const displayPct = sector.realtime_pct_change ?? sector.pct_change;
const isUp = displayPct > 0;
const stage = getStageInfo(sector.stage ?? "");
const hint = getOpportunityHint(sector.stage ?? "", sector.main_force_ratio);
const mainForceRatio = sector.main_force_ratio ?? 0;
return (
<div key={sector.sector_code} className={`rounded-xl p-3 border ${stage.bg}`}>
<div className="flex items-center justify-between mb-1.5">
<div className="flex items-center gap-2">
<span className={`w-5 h-5 rounded-md flex items-center justify-center text-[10px] font-bold ${
i === 0 ? "bg-amber-500/20 text-amber-400" : i === 1 ? "bg-slate-400/15 text-slate-300" : "bg-amber-700/15 text-amber-400/80"
}`}>
{i + 1}
</span>
<span className="text-sm font-semibold text-text-primary">{sector.sector_name}</span>
</div>
<div className="flex items-center gap-1.5">
<span className={`text-[10px] px-1.5 py-0.5 rounded-md border font-medium ${stage.bg} ${stage.color}`}>
{stage.label}
</span>
<span className={`text-sm font-bold font-mono tabular-nums ${isUp ? "text-red-400" : "text-emerald-400"}`}>
{displayPct > 0 ? "+" : ""}{displayPct.toFixed(2)}%
</span>
</div>
</div>
<div className={`text-[11px] ${stage.color} mb-2`}>
{hint}
</div>
<div className="flex items-center gap-3 text-[11px]">
<span className={`font-mono tabular-nums ${sector.capital_inflow > 0 ? "text-red-400/80" : "text-emerald-400/80"}`}>
{sector.capital_inflow > 0 ? "+" : ""}{formatNumber(sector.capital_inflow)}
</span>
{mainForceRatio !== 0 && (
<span className={`font-mono tabular-nums ${mainForceRatio > 30 ? "text-amber-400/80" : "text-text-muted/60"}`}>
{mainForceRatio.toFixed(1)}%
</span>
)}
{(sector.realtime_limit_up_count ?? sector.limit_up_count) > 0 && (
<span className="font-mono tabular-nums text-red-400/80">
{(sector.realtime_limit_up_count ?? sector.limit_up_count)}
</span>
)}
</div>
</div>
);
})}
</div>
</div>
);
}
export default function SectorsPage() {
const [sectors, setSectors] = useState<SectorData[]>([]);
const [showRotation, setShowRotation] = useState(false);
const [rotationData, setRotationData] = useState<SectorRotationData | null>(null);
const [stageFilter, setStageFilter] = useState<string>("all");
const loadData = useCallback(async () => {
try {
const data = await fetchAPI<SectorData[]>("/api/sectors/hot?limit=20");
setSectors(data);
} catch {
// ignore
}
}, []);
useEffect(() => {
loadData();
}, [loadData]);
useWebSocket(
useCallback(() => {
loadData();
}, [loadData])
);
const hasRealtime = sectors.some((s) => s.is_realtime);
const loadRotation = useCallback(async () => {
try {
const data = await fetchAPI<SectorRotationData>("/api/sectors/rotation?days=5");
setRotationData(data);
} catch {
// ignore
}
}, []);
useEffect(() => {
if (showRotation && !rotationData) {
loadRotation();
}
}, [showRotation, rotationData, loadRotation]);
// Compute factor scores for heat score visualization
const factorScoresMap = useMemo(() => {
if (!sectors.length) return new Map<string, [number, number, number, number]>();
const pctScores = normalizeValues(sectors.map(s => s.realtime_pct_change ?? s.pct_change));
const capScores = normalizeValues(sectors.map(s => s.capital_inflow));
const limScores = normalizeValues(sectors.map(s => s.realtime_limit_up_count ?? s.limit_up_count));
const conScores = normalizeValues(sectors.map(s => s.days_continuous));
const map = new Map<string, [number, number, number, number]>();
sectors.forEach((s, i) => {
map.set(s.sector_code, [pctScores[i], capScores[i], limScores[i], conScores[i]]);
});
return map;
}, [sectors]);
// Stage filter counts
const stageCounts = useMemo(() => {
const counts = { all: sectors.length, early: 0, mid: 0, late_end: 0 };
sectors.forEach(s => {
if (s.stage === "early") counts.early++;
else if (s.stage === "mid") counts.mid++;
else if (s.stage === "late" || s.stage === "end") counts.late_end++;
});
return counts;
}, [sectors]);
const filteredSectors = useMemo(() => {
if (stageFilter === "all") return sectors;
if (stageFilter === "late_end") return sectors.filter(s => s.stage === "late" || s.stage === "end");
return sectors.filter(s => s.stage === stageFilter);
}, [sectors, stageFilter]);
return (
<ErrorBoundary>
<div className="max-w-7xl mx-auto px-4 md:px-8 pt-6 pb-20 md:pb-10">
<div className="flex items-center justify-between mb-5 animate-fade-in-up">
<div>
<h1 className="text-lg font-bold tracking-tight"></h1>
<p className="text-xs text-text-muted mt-0.5">
{hasRealtime && <span className="text-emerald-400/60 ml-1">· </span>}
</p>
</div>
<button
onClick={() => setShowRotation(!showRotation)}
className={`text-xs px-4 py-2 rounded-xl font-medium transition-all ${
showRotation
? "bg-gradient-to-r from-amber-500/25 to-amber-600/20 text-amber-400 border border-amber-500/15"
: "bg-surface-2 text-text-muted hover:text-text-secondary border border-transparent"
}`}
>
{showRotation ? "隐藏轮动" : "板块轮动"}
</button>
</div>
{/* Sector Rotation Heatmap */}
{showRotation && (
<div className="mb-6 animate-fade-in-up">
{rotationData && rotationData.sectors.length > 0 ? (
<SectorRotationChart data={rotationData} />
) : (
<div className="glass-card-static p-8 text-center">
<div className="w-6 h-6 border-2 border-amber-400/30 border-t-amber-400 rounded-full animate-spin mx-auto mb-2" />
<div className="text-xs text-text-muted">...</div>
</div>
)}
</div>
)}
{/* Today's Focus - Top 3 */}
{sectors.length > 0 && <FocusSummary sectors={sectors} />}
{/* Stage filter tabs */}
{sectors.length > 0 && (
<div className="flex items-center gap-2 mb-4 animate-fade-in-up">
{[
{ key: "all", label: "全部", count: stageCounts.all },
{ key: "early", label: "启动期", count: stageCounts.early },
{ key: "mid", label: "发展期", count: stageCounts.mid },
{ key: "late_end", label: "后期/尾声", count: stageCounts.late_end },
].map(tab => (
<button
key={tab.key}
onClick={() => setStageFilter(tab.key)}
className={`text-xs px-3 py-1.5 rounded-lg font-medium transition-all ${
stageFilter === tab.key
? "bg-gradient-to-r from-amber-500/20 to-amber-600/15 text-amber-400 border border-amber-500/15"
: "bg-surface-2 text-text-muted hover:text-text-secondary border border-transparent"
}`}
>
{tab.label}
<span className="ml-1 text-[10px] text-text-muted/50">{tab.count}</span>
</button>
))}
</div>
)}
{!sectors.length ? (
<div className="glass-card-static p-12 text-center animate-fade-in-up">
<div className="text-text-muted text-sm mb-1"></div>
<div className="text-text-muted/50 text-xs"></div>
</div>
) : !filteredSectors.length ? (
<div className="glass-card-static p-12 text-center animate-fade-in-up">
<div className="text-text-muted text-sm"></div>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredSectors.map((sector) => {
const originalIndex = sectors.findIndex(s => s.sector_code === sector.sector_code);
return (
<SectorDetailCard
key={sector.sector_code}
sector={sector}
index={originalIndex}
factorScores={factorScoresMap.get(sector.sector_code)}
/>
);
})}
</div>
)}
</div>
</ErrorBoundary>
);
}
function SectorRotationChart({ data }: { data: SectorRotationData }) {
const [el, setEl] = useState<HTMLDivElement | null>(null);
const { theme } = useNextTheme();
useEffect(() => {
if (!el || !data.sectors.length) return;
let chart: ReturnType<typeof import("echarts")["init"]> | null = null;
import("echarts").then((ec) => {
if (!el) return;
const isDark = theme !== "light";
chart = ec.init(el, isDark ? "dark" : undefined);
const isLight = theme === "light";
const axisLabelColor = isLight ? "#6b7280" : "#94a3b8";
const dates = data.dates.map((d) => d.slice(4));
const sectorNames = data.sectors.map((s) => s.sector_name);
const heatData: [number, number, number][] = [];
let minVal = Infinity;
let maxVal = -Infinity;
data.sectors.forEach((sector, yi) => {
dates.forEach((_, xi) => {
const dayData = sector.daily_data.find((d) => data.dates[xi] && d.trade_date === data.dates[xi]);
const val = dayData?.pct_change ?? 0;
heatData.push([xi, yi, val]);
if (val < minVal) minVal = val;
if (val > maxVal) maxVal = val;
});
});
chart.setOption({
backgroundColor: "transparent",
tooltip: {
formatter: (params: { data: number[] }) => {
const [x, y, val] = params.data;
return `${sectorNames[y]}<br/>${dates[x]}: <b>${val > 0 ? "+" : ""}${val.toFixed(2)}%</b>`;
},
},
grid: { left: "15%", right: "5%", top: "5%", bottom: "12%" },
xAxis: {
type: "category",
data: dates,
splitArea: { show: true },
axisLabel: { fontSize: 10, color: axisLabelColor },
},
yAxis: {
type: "category",
data: sectorNames,
axisLabel: { fontSize: 10, color: axisLabelColor, width: 60, overflow: "truncate" },
},
visualMap: {
min: minVal,
max: maxVal,
calculable: true,
orient: "horizontal",
left: "center",
bottom: 0,
inRange: {
color: ["#22c55e", "#fbbf24", "#ef4444"],
},
textStyle: { fontSize: 10, color: axisLabelColor },
},
series: [{
type: "heatmap",
data: heatData,
label: {
show: true,
fontSize: 9,
formatter: (params: { data: number[] }) => {
const val = params.data[2];
return val > 0 ? `+${val.toFixed(1)}` : val.toFixed(1);
},
},
}],
});
const handleResize = () => chart?.resize();
window.addEventListener("resize", handleResize);
});
return () => { chart?.dispose(); };
}, [data, theme, el]);
return (
<div className="glass-card-static p-4">
<h2 className="text-xs font-semibold text-text-muted uppercase tracking-wider mb-3">
{data.dates.length}
</h2>
<div ref={setEl} className="w-full" style={{ height: Math.max(data.sectors.length * 28 + 60, 200) }} />
</div>
);
}
function useNextTheme() {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { useTheme } = require("next-themes");
return useTheme();
}

View File

@ -0,0 +1,3 @@
export default function PublicLayout({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}

View File

@ -0,0 +1,111 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/hooks/use-auth";
export default function LoginPage() {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [submitting, setSubmitting] = useState(false);
const { login } = useAuth();
const router = useRouter();
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError("");
if (!username.trim() || !password.trim()) {
setError("请输入用户名和密码");
return;
}
setSubmitting(true);
try {
await login(username, password);
router.push("/dashboard");
} catch (err) {
setError(err instanceof Error ? err.message : "登录失败,请重试");
} finally {
setSubmitting(false);
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-bg-primary relative overflow-hidden">
{/* Ambient glow */}
<div className="fixed top-1/3 right-1/4 w-[500px] h-[500px] bg-amber-500/5 rounded-full blur-3xl pointer-events-none" />
<div className="relative z-10 w-full max-w-sm mx-4">
{/* Brand */}
<div className="flex flex-col items-center mb-8">
<div className="relative mb-5">
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-amber-500 to-amber-600 flex items-center justify-center text-lg font-bold text-white shadow-glow" style={{ boxShadow: "0 0 30px rgba(251,191,36,0.25)" }}>
D
</div>
<div className="absolute inset-0 rounded-xl border border-amber-500/20 animate-pulse-ring" />
</div>
<h1 className="text-lg font-bold tracking-tight text-text-primary">
Dragon AI Agent
</h1>
<p className="text-xs text-text-muted mt-1">A </p>
</div>
{/* Login card */}
<div className="glass-card-static p-7 rounded-2xl">
<h2 className="text-sm font-semibold text-text-primary mb-5 text-center"></h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="text-xs text-text-muted mb-1.5 block"></label>
<input
type="text"
placeholder="输入用户名"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="w-full bg-surface-2 border border-border-default rounded-xl px-4 py-3 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-amber-500/30 focus:border-amber-500/20 placeholder-text-muted/40 transition-all"
autoComplete="username"
/>
</div>
<div>
<label className="text-xs text-text-muted mb-1.5 block"></label>
<input
type="password"
placeholder="输入密码"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full bg-surface-2 border border-border-default rounded-xl px-4 py-3 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-amber-500/30 focus:border-amber-500/20 placeholder-text-muted/40 transition-all"
autoComplete="current-password"
/>
</div>
<button
type="submit"
disabled={submitting}
className="w-full bg-gradient-to-r from-amber-500 to-amber-600 text-white rounded-xl py-3 text-sm font-semibold hover:from-amber-400 hover:to-amber-500 transition-all duration-200 shadow-glow-sm hover:shadow-glow disabled:opacity-50 disabled:cursor-not-allowed active:scale-95"
>
{submitting ? (
<span className="inline-flex items-center gap-2">
<span className="w-3.5 h-3.5 border border-white/40 border-t-white rounded-full animate-spin" />
...
</span>
) : "登录"}
</button>
</form>
{/* Error */}
{error && (
<p className="mt-4 text-center text-xs text-amber-400/80 bg-amber-500/5 rounded-lg py-2 border border-amber-500/10">
{error}
</p>
)}
</div>
{/* Footer link */}
<p className="mt-6 text-center text-xs text-text-muted/40">
<a href="/" className="hover:text-text-muted/60 transition-colors"></a>
</p>
</div>
</div>
);
}

View File

@ -0,0 +1,333 @@
"use client";
import Link from "next/link";
import { useEffect, useRef } from "react";
/* ── Animated data stream background ── */
function DataStreamCanvas() {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const stockCodes = [
"600519.SH", "000858.SZ", "601318.SH", "000001.SZ",
"600036.SH", "000333.SZ", "601166.SH", "002415.SZ",
"600276.SH", "000568.SZ", "601888.SH", "300750.SZ",
"600900.SH", "000725.SZ", "603259.SH", "002230.SZ",
];
const streams: { x: number; y: number; speed: number; code: string; pct: string; opacity: number }[] = [];
const streamCount = 18;
for (let i = 0; i < streamCount; i++) {
streams.push({
x: Math.random() * 100,
y: Math.random() * 100,
speed: 0.15 + Math.random() * 0.3,
code: stockCodes[Math.floor(Math.random() * stockCodes.length)],
pct: `${(Math.random() * 6 - 1).toFixed(2)}%`,
opacity: 0.03 + Math.random() * 0.06,
});
}
let animId: number;
function resize() {
if (!canvas) return;
canvas.width = canvas.offsetWidth * 1.5;
canvas.height = canvas.offsetHeight * 1.5;
}
resize();
window.addEventListener("resize", resize);
function draw() {
if (!ctx || !canvas) return;
ctx.clearRect(0, 0, canvas.width, canvas.height);
streams.forEach((s) => {
s.y += s.speed;
if (s.y > 105) {
s.y = -5;
s.x = Math.random() * 100;
s.code = stockCodes[Math.floor(Math.random() * stockCodes.length)];
s.pct = `${(Math.random() * 6 - 1).toFixed(2)}%`;
s.opacity = 0.03 + Math.random() * 0.06;
}
const px = (s.x / 100) * canvas.width;
const py = (s.y / 100) * canvas.height;
ctx.fillStyle = `rgba(251, 191, 36, ${s.opacity})`;
ctx.font = `${canvas.width * 0.008}px monospace`;
ctx.fillText(s.code, px, py);
const isUp = parseFloat(s.pct) > 0;
ctx.fillStyle = isUp
? `rgba(255, 107, 107, ${s.opacity * 0.7})`
: `rgba(52, 211, 153, ${s.opacity * 0.7})`;
ctx.fillText(s.pct, px, py + canvas.width * 0.012);
});
animId = requestAnimationFrame(draw);
}
draw();
return () => {
cancelAnimationFrame(animId);
window.removeEventListener("resize", resize);
};
}, []);
return (
<canvas
ref={canvasRef}
className="absolute inset-0 w-full h-full pointer-events-none"
style={{ opacity: 0.6 }}
/>
);
}
/* ── Feature icons (inline SVG) ── */
function ScanIcon() {
return (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<path d="M12 6v6l4 2" />
<path d="M3 12h1m16 0h1M12 3v1m0 16v1" />
</svg>
);
}
function HeatmapIcon() {
return (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="3" width="6" height="6" rx="1" />
<rect x="15" y="3" width="6" height="6" rx="1" />
<rect x="9" y="9" width="6" height="6" rx="1" />
<rect x="3" y="15" width="6" height="6" rx="1" />
<rect x="15" y="15" width="6" height="6" rx="1" />
</svg>
);
}
function FilterIcon() {
return (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 4h18l-7 8v6l-4 2v-8L3 4z" />
</svg>
);
}
function BrainIcon() {
return (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 2a5 5 0 0 1 5 5c0 2-1 3.5-2.5 4.5L12 13l-2.5-1.5C8 10.5 7 9 7 7a5 5 0 0 1 5-5z" />
<path d="M12 13v4" />
<path d="M8 17h8" />
<path d="M8 21h8" />
<circle cx="9" cy="7" r="0.5" fill="currentColor" />
<circle cx="15" cy="7" r="0.5" fill="currentColor" />
<circle cx="12" cy="9.5" r="0.5" fill="currentColor" />
</svg>
);
}
const features = [
{
icon: <ScanIcon />,
title: "实时行情扫描",
desc: "盘中实时获取涨跌停、市场温度数据,盘中盘后双模式运行,不遗漏任何一个交易时段。",
tags: ["市场温度", "涨跌停统计", "盘中实时"],
},
{
icon: <HeatmapIcon />,
title: "板块热度分析",
desc: "综合资金流向、涨跌幅、涨停家数、持续性四因子评分,自动判定板块阶段,识别启动期机会。",
tags: ["资金流向", "主力占比", "热度评分"],
},
{
icon: <FilterIcon />,
title: "智能选股筛选",
desc: "从市场环境→热门板块→个股精选三层递进筛选,结合供需关系、价格行为、趋势信号多维度评分。",
tags: ["三层筛选", "供需分析", "趋势突破"],
},
{
icon: <BrainIcon />,
title: "AI 深度诊断",
desc: "LLM 针对个股生成深度分析报告:趋势判断、风险提示、入场时机,从数据到决策的一步跨越。",
tags: ["LLM 分析", "风险提示", "入场信号"],
},
];
export default function LandingPage() {
return (
<div className="min-h-screen bg-bg-primary relative overflow-hidden">
{/* Ambient glow */}
<div className="fixed top-0 right-0 w-[600px] h-[600px] bg-amber-500/5 rounded-full blur-3xl pointer-events-none" />
<div className="fixed bottom-0 left-0 w-[400px] h-[400px] bg-amber-600/3 rounded-full blur-3xl pointer-events-none" />
{/* ── Hero ── */}
<section className="relative min-h-screen flex flex-col items-center justify-center px-6">
<DataStreamCanvas />
<div className="relative z-10 text-center max-w-2xl">
{/* Brand icon */}
<div className="mb-8 flex justify-center">
<div className="relative">
<div className="w-16 h-16 rounded-2xl bg-gradient-to-br from-amber-500 to-amber-600 flex items-center justify-center text-2xl font-bold text-white shadow-glow" style={{ boxShadow: "0 0 40px rgba(251,191,36,0.3), 0 0 80px rgba(251,191,36,0.15)" }}>
D
</div>
{/* Radiating rings */}
<div className="absolute inset-0 rounded-2xl border border-amber-500/20 animate-pulse-ring" />
<div className="absolute -inset-3 rounded-2xl border border-amber-500/10 animate-pulse-ring" style={{ animationDelay: "0.5s" }} />
</div>
</div>
<h1 className="text-4xl md:text-5xl font-bold tracking-tight text-text-primary mb-4">
Dragon AI Agent
</h1>
<p className="text-lg md:text-xl text-amber-400/80 font-medium tracking-wide mb-3">
A
</p>
<p className="text-sm md:text-base text-text-secondary max-w-lg mx-auto mb-10 leading-relaxed">
<br className="hidden md:block" />
AI
</p>
{/* CTA */}
<div className="flex items-center gap-4 justify-center">
<Link
href="/login"
className="px-8 py-3 bg-gradient-to-r from-amber-500 to-amber-600 text-white rounded-xl font-semibold text-sm hover:from-amber-400 hover:to-amber-500 transition-all shadow-glow-sm hover:shadow-glow active:scale-95"
style={{ boxShadow: "0 0 20px rgba(251,191,36,0.25)" }}
>
</Link>
<a
href="#features"
className="px-6 py-3 text-text-secondary text-sm font-medium rounded-xl border border-border-default hover:bg-surface-3 hover:text-text-primary transition-all"
>
</a>
</div>
</div>
{/* Scroll hint */}
<div className="absolute bottom-8 left-1/2 -translate-x-1/2 flex flex-col items-center gap-2 text-text-muted/40 animate-fade-in-up" style={{ animationDelay: "1.5s" }}>
<span className="text-xs"></span>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 5v14M5 12l7 7 7-7" />
</svg>
</div>
</section>
{/* ── Features ── */}
<section id="features" className="relative px-6 py-20 md:py-28">
<div className="max-w-5xl mx-auto">
<div className="text-center mb-14">
<h2 className="text-2xl md:text-3xl font-bold text-text-primary mb-3">
</h2>
<p className="text-sm text-text-secondary max-w-md mx-auto">
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{features.map((f, i) => (
<div
key={f.title}
className="glass-card-static p-6 animate-fade-in-up"
style={{ animationDelay: `${i * 150}ms` }}
>
<div className="flex items-start gap-4 mb-4">
<div className="w-11 h-11 rounded-xl bg-gradient-to-br from-amber-500/15 to-amber-600/10 border border-amber-500/15 flex items-center justify-center text-amber-400 shrink-0">
{f.icon}
</div>
<div>
<h3 className="text-base font-semibold text-text-primary mb-1">{f.title}</h3>
<p className="text-sm text-text-secondary leading-relaxed">{f.desc}</p>
</div>
</div>
<div className="flex gap-2 mt-2">
{f.tags.map((tag) => (
<span key={tag} className="text-[11px] px-2.5 py-1 rounded-lg bg-surface-2 text-text-muted border border-border-subtle">
{tag}
</span>
))}
</div>
</div>
))}
</div>
</div>
</section>
{/* ── Process flow ── */}
<section className="relative px-6 py-16 md:py-20">
<div className="max-w-4xl mx-auto text-center">
<h2 className="text-xl md:text-2xl font-bold text-text-primary mb-10">
</h2>
<div className="flex flex-col md:flex-row items-center justify-center gap-4 md:gap-2">
{[
{ step: "01", label: "全局扫描", sub: "市场温度", color: "text-emerald-400", bg: "bg-emerald-500/10 border-emerald-500/15" },
{ step: "02", label: "板块定位", sub: "热度排名", color: "text-amber-400", bg: "bg-amber-500/10 border-amber-500/15" },
{ step: "03", label: "个股精选", sub: "多因子评分", color: "text-amber-400", bg: "bg-amber-500/10 border-amber-500/15" },
{ step: "04", label: "AI 诊断", sub: "深度解读", color: "text-amber-400", bg: "bg-amber-500/10 border-amber-500/15" },
].map((item, i) => (
<div key={item.step} className="flex items-center gap-2 md:gap-0">
<div className={`flex flex-col items-center px-5 py-4 rounded-xl border ${item.bg}`}>
<span className={`text-xs font-mono font-bold ${item.color}`}>{item.step}</span>
<span className="text-sm font-semibold text-text-primary mt-1">{item.label}</span>
<span className="text-xs text-text-muted mt-0.5">{item.sub}</span>
</div>
{i < 3 && (
<svg className="hidden md:block text-text-muted/30 w-8 h-8 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M5 12h14M12 5l7 7-7 7" />
</svg>
)}
</div>
))}
</div>
</div>
</section>
{/* ── Final CTA ── */}
<section className="relative px-6 py-16 md:py-24">
<div className="max-w-xl mx-auto text-center">
<h2 className="text-xl md:text-2xl font-bold text-text-primary mb-4">
使
</h2>
<p className="text-sm text-text-secondary mb-8">
</p>
<Link
href="/login"
className="inline-flex px-10 py-3.5 bg-gradient-to-r from-amber-500 to-amber-600 text-white rounded-xl font-semibold text-sm hover:from-amber-400 hover:to-amber-500 transition-all shadow-glow-sm hover:shadow-glow active:scale-95"
style={{ boxShadow: "0 0 20px rgba(251,191,36,0.25)" }}
>
</Link>
</div>
</section>
{/* ── Footer ── */}
<footer className="border-t border-border-subtle px-6 py-8">
<div className="max-w-5xl mx-auto flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-6 h-6 rounded-md bg-gradient-to-br from-amber-500/30 to-amber-600/20 flex items-center justify-center text-[10px] font-bold text-amber-400">
D
</div>
<span className="text-xs text-text-muted">Dragon AI Agent</span>
</div>
<span className="text-xs text-text-muted/40">
&copy; {new Date().getFullYear()} Dragon AI Agent
</span>
</div>
</footer>
</div>
);
}

View File

@ -1,14 +1,11 @@
import type { Metadata, Viewport } from "next";
import "./globals.css";
import { AuthProvider } from "@/hooks/use-auth";
import { AuthGuard } from "@/components/auth-guard";
import { UserMenu } from "@/components/user-menu";
import { SidebarNav, MobileBottomNav } from "@/components/nav";
import { ThemeProvider } from "next-themes";
export const metadata: Metadata = {
title: "Dragon AI Agent",
description: "基于资金驱动的四层漏斗模型盘中实时分析推荐A股",
description: "A 股智能筛选引擎,盘中实时分析与推荐",
};
export const viewport: Viewport = {
@ -28,45 +25,7 @@ export default function RootLayout({
<body className="min-h-screen bg-bg-primary text-text-primary font-display">
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem={false}>
<AuthProvider>
<AuthGuard>
{/* Desktop: sidebar + main */}
<div className="flex min-h-screen">
{/* Desktop sidebar */}
<aside className="hidden md:flex flex-col w-60 glass-sidebar fixed inset-y-0 left-0 z-40">
{/* Brand */}
<div className="px-6 pt-7 pb-5">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-amber-500 to-amber-600 flex items-center justify-center text-sm font-bold text-white shadow-glow-sm">
D
</div>
<div>
<h1 className="text-sm font-semibold tracking-tight">Dragon AI Agent</h1>
<p className="text-xs text-text-muted mt-0.5 font-light tracking-wide"> · </p>
</div>
</div>
</div>
{/* Divider */}
<div className="mx-5 h-px bg-gradient-to-r from-transparent via-border-default to-transparent" />
{/* Nav */}
<SidebarNav />
{/* Footer */}
<div className="px-6 py-5 border-t border-border-subtle">
<UserMenu />
</div>
</aside>
{/* Main content area */}
<main className="flex-1 md:ml-60 pb-16 md:pb-0 min-h-screen">
{children}
</main>
</div>
{/* Mobile bottom nav */}
<MobileBottomNav />
</AuthGuard>
</AuthProvider>
</ThemeProvider>
</body>

View File

@ -1,85 +0,0 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/hooks/use-auth";
export default function LoginPage() {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [submitting, setSubmitting] = useState(false);
const { login } = useAuth();
const router = useRouter();
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError("");
if (!username.trim() || !password.trim()) {
setError("请输入用户名和密码");
return;
}
setSubmitting(true);
try {
await login(username, password);
router.push("/");
} catch (err) {
setError(err instanceof Error ? err.message : "登录失败,请重试");
} finally {
setSubmitting(false);
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-bg-primary">
<div className="max-w-sm w-full mx-4 p-8 rounded-2xl bg-surface-1 border border-border-default backdrop-blur-sm">
{/* Brand */}
<div className="flex flex-col items-center mb-8">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-amber-500 to-amber-600 flex items-center justify-center text-base font-bold text-white shadow-glow-sm mb-4">
D
</div>
<h1 className="text-lg font-semibold tracking-tight text-text-primary">
Dragon AI Agent
</h1>
<p className="text-sm text-text-muted mt-1"></p>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-4">
<input
type="text"
placeholder="用户名"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="w-full bg-surface-2 border border-border-default rounded-xl px-4 py-3 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-amber-500/30 placeholder-text-muted/40"
autoComplete="username"
/>
<input
type="password"
placeholder="密码"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full bg-surface-2 border border-border-default rounded-xl px-4 py-3 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-amber-500/30 placeholder-text-muted/40"
autoComplete="current-password"
/>
<button
type="submit"
disabled={submitting}
className="w-full bg-gradient-to-r from-amber-500/20 to-amber-600/15 text-amber-400 rounded-xl py-3 text-sm font-medium hover:from-amber-500/30 hover:to-amber-600/25 transition-all duration-200 border border-amber-500/10 disabled:opacity-50 disabled:cursor-not-allowed"
>
{submitting ? "登录中..." : "登录"}
</button>
</form>
{/* Error */}
{error && (
<p className="mt-4 text-center text-xs text-amber-400/80">
{error}
</p>
)}
</div>
</div>
);
}

View File

@ -1,380 +0,0 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { fetchAPI } from "@/lib/api";
import type { SectorData, LeadingStock, SectorRotationData } from "@/lib/api";
import { formatNumber } from "@/lib/utils";
import { useWebSocket } from "@/hooks/use-websocket";
import { ErrorBoundary } from "@/components/error-boundary";
function getStageInfo(stage: string) {
switch (stage) {
case "early":
return { label: "启动期", color: "text-emerald-400", bg: "bg-emerald-500/10 border-emerald-500/15" };
case "mid":
return { label: "发展期", color: "text-amber-400", bg: "bg-amber-500/10 border-amber-500/15" };
case "late":
return { label: "后期", color: "text-orange-400", bg: "bg-orange-500/10 border-orange-500/15" };
case "end":
return { label: "尾声", color: "text-red-400", bg: "bg-red-500/10 border-red-500/15" };
default:
return { label: "—", color: "text-text-muted", bg: "bg-surface-2 border-border-default" };
}
}
/** 迷你柱状图近5日涨跌幅 */
function MiniBarChart({ data }: { data: number[] }) {
if (!data.length) return null;
const maxAbs = Math.max(...data.map(Math.abs), 0.1);
return (
<div className="flex items-end gap-1 h-8">
{data.map((v, i) => {
const h = Math.max(Math.abs(v) / maxAbs * 100, 8);
return (
<div key={i} className="flex-1 flex flex-col justify-end items-center" style={{ height: "100%" }}>
<div
className={`w-full rounded-sm transition-all duration-300 ${v >= 0 ? "bg-red-500/50" : "bg-emerald-500/50"}`}
style={{ height: `${h}%` }}
/>
</div>
);
})}
</div>
);
}
/** 领涨股标签 */
function LeadingStockTag({ stock }: { stock: LeadingStock }) {
const isLimitUp = stock.pct_chg >= 9.8;
return (
<a
href={`/stock/${stock.ts_code}`}
className="inline-flex items-center gap-1 text-[11px] px-2 py-1 rounded-lg bg-surface-2 border border-border-default hover:bg-surface-4 transition-colors"
>
<span className="text-text-secondary font-medium">{stock.name}</span>
<span className={`font-mono tabular-nums ${isLimitUp ? "text-red-400 font-bold" : stock.pct_chg > 0 ? "text-red-400/80" : "text-emerald-400/80"}`}>
{stock.pct_chg > 0 ? "+" : ""}{stock.pct_chg}%
</span>
{stock.limit_times != null && stock.limit_times > 1 && (
<span className="text-[9px] px-1 py-0.5 rounded bg-red-500/15 text-red-400 font-bold">
{stock.limit_times}
</span>
)}
</a>
);
}
function SectorDetailCard({ sector, index }: { sector: SectorData; index: number }) {
const displayPct = sector.realtime_pct_change ?? sector.pct_change;
const isUp = displayPct > 0;
const displayLimitUp = sector.realtime_limit_up_count ?? sector.limit_up_count;
const leaders = sector.is_realtime
? (sector.leading_stocks_realtime?.length ? sector.leading_stocks_realtime : sector.leading_stocks)
: sector.leading_stocks;
const stage = getStageInfo(sector.stage ?? "");
const isTop3 = index < 3;
return (
<div
className="glass-card p-5 animate-fade-in-up"
style={{ animationDelay: `${index * 60}ms` }}
>
{/* Header row */}
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-2.5">
<span className={`w-7 h-7 rounded-lg flex items-center justify-center text-xs font-bold shrink-0 ${
index === 0
? "bg-gradient-to-br from-amber-500/30 to-amber-600/20 text-amber-400 border border-amber-500/20"
: index === 1
? "bg-gradient-to-br from-slate-400/20 to-slate-500/15 text-slate-300 border border-slate-400/15"
: index === 2
? "bg-gradient-to-br from-amber-700/20 to-amber-800/15 text-amber-400 border border-amber-600/15"
: "bg-surface-2 text-text-muted border border-border-subtle"
}`}>
{index + 1}
</span>
<div>
<div className="flex items-center gap-2">
<span className={`text-sm font-semibold ${isTop3 ? "text-text-primary" : "text-text-secondary"}`}>
{sector.sector_name}
</span>
{sector.is_realtime && (
<span className="text-[9px] px-1.5 py-0.5 rounded bg-emerald-500/10 text-emerald-400/80 border border-emerald-500/15">
</span>
)}
</div>
<div className="text-[11px] text-text-muted/60 mt-0.5">
{sector.member_count ? `${sector.member_count}只成分股` : ""}
{sector.member_count && sector.turnover_avg ? " · " : ""}
{sector.turnover_avg ? `均换手 ${sector.turnover_avg}%` : ""}
</div>
</div>
</div>
<div className="text-right">
<div className={`text-base font-bold font-mono tabular-nums ${isUp ? "text-red-400" : "text-emerald-400"}`}>
{displayPct > 0 ? "+" : ""}{displayPct.toFixed(2)}%
</div>
<div className="flex items-center gap-2 mt-0.5">
<span className={`text-[10px] px-1.5 py-0.5 rounded-md border font-medium ${stage.bg} ${stage.color}`}>
{stage.label}
</span>
<span className="text-[10px] text-text-muted/60">
{sector.days_continuous}
</span>
</div>
</div>
</div>
{/* Metrics row */}
<div className="grid grid-cols-3 gap-3 mb-3">
<div className="bg-surface-1 rounded-lg px-3 py-2">
<div className="text-[10px] text-text-muted/50 mb-0.5"></div>
<div className={`text-xs font-mono tabular-nums font-semibold ${sector.capital_inflow > 0 ? "text-red-400" : "text-emerald-400"}`}>
{sector.capital_inflow > 0 ? "+" : ""}{formatNumber(sector.capital_inflow)}
</div>
</div>
<div className="bg-surface-1 rounded-lg px-3 py-2">
<div className="text-[10px] text-text-muted/50 mb-0.5"></div>
<div className="text-xs font-mono tabular-nums font-semibold text-text-secondary">
{displayLimitUp}<span className="text-text-muted/40"> </span>
</div>
</div>
<div className="bg-surface-1 rounded-lg px-3 py-2">
<div className="text-[10px] text-text-muted/50 mb-0.5"></div>
<div className={`text-xs font-mono tabular-nums font-semibold ${
sector.heat_score >= 70 ? "text-amber-400" : sector.heat_score >= 50 ? "text-text-secondary" : "text-text-muted"
}`}>
{sector.heat_score.toFixed(0)}
<span className="text-text-muted/40 text-[10px]">/100</span>
</div>
</div>
</div>
{/* 5日趋势图 */}
{sector.pct_trend && sector.pct_trend.length > 1 && (
<div className="mb-3">
<div className="flex items-center justify-between mb-1.5">
<span className="text-[10px] text-text-muted/50">5</span>
<div className="flex gap-2">
{sector.pct_trend.map((v, i) => (
<span key={i} className={`text-[9px] font-mono tabular-nums ${v >= 0 ? "text-red-400/60" : "text-emerald-400/60"}`}>
{v > 0 ? "+" : ""}{v.toFixed(1)}
</span>
))}
</div>
</div>
<MiniBarChart data={sector.pct_trend} />
</div>
)}
{/* 领涨股 */}
{leaders && leaders.length > 0 && (
<div>
<span className="text-[10px] text-text-muted/50 mb-1.5 block"></span>
<div className="flex flex-wrap gap-1.5">
{leaders.map((s) => (
<LeadingStockTag key={s.ts_code} stock={s} />
))}
</div>
</div>
)}
</div>
);
}
export default function SectorsPage() {
const [sectors, setSectors] = useState<SectorData[]>([]);
const [showRotation, setShowRotation] = useState(false);
const [rotationData, setRotationData] = useState<SectorRotationData | null>(null);
const loadData = useCallback(async () => {
try {
const data = await fetchAPI<SectorData[]>("/api/sectors/hot?limit=20");
setSectors(data);
} catch {
// ignore
}
}, []);
useEffect(() => {
loadData();
}, [loadData]);
useWebSocket(
useCallback(() => {
loadData();
}, [loadData])
);
const hasRealtime = sectors.some((s) => s.is_realtime);
const loadRotation = useCallback(async () => {
try {
const data = await fetchAPI<SectorRotationData>("/api/sectors/rotation?days=5");
setRotationData(data);
} catch {
// ignore
}
}, []);
useEffect(() => {
if (showRotation && !rotationData) {
loadRotation();
}
}, [showRotation, rotationData, loadRotation]);
return (
<ErrorBoundary>
<div className="max-w-7xl mx-auto px-4 md:px-8 pt-6 pb-20 md:pb-10">
<div className="flex items-center justify-between mb-5 animate-fade-in-up">
<div>
<h1 className="text-lg font-bold tracking-tight"></h1>
<p className="text-xs text-text-muted mt-0.5">
{hasRealtime && <span className="text-emerald-400/60 ml-1">· </span>}
</p>
</div>
<button
onClick={() => setShowRotation(!showRotation)}
className={`text-xs px-4 py-2 rounded-xl font-medium transition-all ${
showRotation
? "bg-gradient-to-r from-amber-500/25 to-amber-600/20 text-amber-400 border border-amber-500/15"
: "bg-surface-2 text-text-muted hover:text-text-secondary border border-transparent"
}`}
>
{showRotation ? "隐藏轮动" : "板块轮动"}
</button>
</div>
{/* Sector Rotation Heatmap */}
{showRotation && (
<div className="mb-6 animate-fade-in-up">
{rotationData && rotationData.sectors.length > 0 ? (
<SectorRotationChart data={rotationData} />
) : (
<div className="glass-card-static p-8 text-center">
<div className="w-6 h-6 border-2 border-amber-400/30 border-t-amber-400 rounded-full animate-spin mx-auto mb-2" />
<div className="text-xs text-text-muted">...</div>
</div>
)}
</div>
)}
{!sectors.length ? (
<div className="glass-card-static p-12 text-center animate-fade-in-up">
<div className="text-text-muted text-sm mb-1"></div>
<div className="text-text-muted/50 text-xs"></div>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{sectors.map((sector, i) => (
<SectorDetailCard key={sector.sector_code} sector={sector} index={i} />
))}
</div>
)}
</div>
</ErrorBoundary>
);
}
function SectorRotationChart({ data }: { data: SectorRotationData }) {
const [el, setEl] = useState<HTMLDivElement | null>(null);
const { theme } = useNextTheme();
useEffect(() => {
if (!el || !data.sectors.length) return;
let chart: ReturnType<typeof import("echarts")["init"]> | null = null;
import("echarts").then((ec) => {
if (!el) return;
const isDark = theme !== "light";
chart = ec.init(el, isDark ? "dark" : undefined);
const isLight = theme === "light";
const axisLabelColor = isLight ? "#6b7280" : "#94a3b8";
const dates = data.dates.map((d) => d.slice(4));
const sectorNames = data.sectors.map((s) => s.sector_name);
const heatData: [number, number, number][] = [];
let minVal = Infinity;
let maxVal = -Infinity;
data.sectors.forEach((sector, yi) => {
dates.forEach((_, xi) => {
const dayData = sector.daily_data.find((d) => data.dates[xi] && d.trade_date === data.dates[xi]);
const val = dayData?.pct_change ?? 0;
heatData.push([xi, yi, val]);
if (val < minVal) minVal = val;
if (val > maxVal) maxVal = val;
});
});
chart.setOption({
backgroundColor: "transparent",
tooltip: {
formatter: (params: { data: number[] }) => {
const [x, y, val] = params.data;
return `${sectorNames[y]}<br/>${dates[x]}: <b>${val > 0 ? "+" : ""}${val.toFixed(2)}%</b>`;
},
},
grid: { left: "15%", right: "5%", top: "5%", bottom: "12%" },
xAxis: {
type: "category",
data: dates,
splitArea: { show: true },
axisLabel: { fontSize: 10, color: axisLabelColor },
},
yAxis: {
type: "category",
data: sectorNames,
axisLabel: { fontSize: 10, color: axisLabelColor, width: 60, overflow: "truncate" },
},
visualMap: {
min: minVal,
max: maxVal,
calculable: true,
orient: "horizontal",
left: "center",
bottom: 0,
inRange: {
color: ["#22c55e", "#fbbf24", "#ef4444"],
},
textStyle: { fontSize: 10, color: axisLabelColor },
},
series: [{
type: "heatmap",
data: heatData,
label: {
show: true,
fontSize: 9,
formatter: (params: { data: number[] }) => {
const val = params.data[2];
return val > 0 ? `+${val.toFixed(1)}` : val.toFixed(1);
},
},
}],
});
const handleResize = () => chart?.resize();
window.addEventListener("resize", handleResize);
});
return () => { chart?.dispose(); };
}, [data, theme, el]);
return (
<div className="glass-card-static p-4">
<h2 className="text-xs font-semibold text-text-muted uppercase tracking-wider mb-3">
{data.dates.length}
</h2>
<div ref={setEl} className="w-full" style={{ height: Math.max(data.sectors.length * 28 + 60, 200) }} />
</div>
);
}
function useNextTheme() {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { useTheme } = require("next-themes");
return useTheme();
}

View File

@ -4,7 +4,7 @@ import { useEffect } from "react";
import { usePathname, useRouter } from "next/navigation";
import { useAuth } from "@/hooks/use-auth";
const PUBLIC_PATHS = ["/login"];
const PUBLIC_PATHS = ["/login", "/"];
export function AuthGuard({ children }: { children: React.ReactNode }) {
const { user, loading } = useAuth();
@ -19,9 +19,10 @@ export function AuthGuard({ children }: { children: React.ReactNode }) {
}
}, [loading, user, isPublicPath, router]);
// Redirect authenticated users away from public pages to dashboard
useEffect(() => {
if (!loading && user && pathname === "/login") {
router.replace("/");
if (!loading && user && (pathname === "/login" || pathname === "/")) {
router.replace("/dashboard");
}
}, [loading, user, pathname, router]);
@ -37,7 +38,8 @@ export function AuthGuard({ children }: { children: React.ReactNode }) {
return null;
}
if (user && pathname === "/login") {
// Authenticated users on public pages are being redirected — render nothing
if (user && (pathname === "/login" || pathname === "/")) {
return null;
}

View File

@ -65,7 +65,7 @@ function UsersIcon() {
function SideNavItem({ href, icon, label }: { href: string; icon: React.ReactNode; label: string }) {
const pathname = usePathname();
const isActive = href === "/" ? pathname === "/" : pathname.startsWith(href);
const isActive = pathname === href || (href !== "/dashboard" && pathname.startsWith(href));
return (
<Link
@ -87,7 +87,7 @@ export function SidebarNav() {
return (
<nav className="flex-1 py-5 px-3 space-y-1">
<SideNavItem href="/" icon={<DashboardIcon />} label="总览" />
<SideNavItem href="/dashboard" icon={<DashboardIcon />} label="总览" />
<SideNavItem href="/recommendations" icon={<TargetIcon />} label="推荐列表" />
<SideNavItem href="/sectors" icon={<FireIcon />} label="板块分析" />
<SideNavItem href="/diagnose" icon={<DiagnoseIcon />} label="AI 诊断" />
@ -100,7 +100,7 @@ export function SidebarNav() {
function MobileNavItem({ href, label, children }: { href: string; label: string; children: React.ReactNode }) {
const pathname = usePathname();
const isActive = href === "/" ? pathname === "/" : pathname.startsWith(href);
const isActive = pathname === href;
return (
<Link
@ -119,7 +119,7 @@ export function MobileBottomNav() {
return (
<nav className="fixed bottom-0 left-0 right-0 md:hidden z-50 bg-bg-secondary/95 backdrop-blur-xl border-t border-border-subtle">
<div className="flex justify-around py-2 pb-[max(0.5rem,env(safe-area-inset-bottom))]">
<MobileNavItem href="/" label="总览">
<MobileNavItem href="/dashboard" label="总览">
<DashboardIcon />
</MobileNavItem>
<MobileNavItem href="/recommendations" label="推荐">