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

809 lines
31 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 { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { fetchAPI, postAPI } from "@/lib/api";
import type {
IndexOverview,
LatestResult,
MarketTemperatureData,
OpsStatusResponse,
RecommendationData,
SectorData,
StrategyBoard,
} from "@/lib/api";
import { ThemeToggle } from "@/components/theme-toggle";
import { useAuth } from "@/hooks/use-auth";
import { useWebSocket } from "@/hooks/use-websocket";
interface ScanStatus {
is_trading: boolean;
scan_mode: string;
description: string;
}
const SCAN_TIMEOUT_MS = 120_000;
export default function DashboardPage() {
const { user } = useAuth();
const [data, setData] = useState<LatestResult | null>(null);
const [marketTemperature, setMarketTemperature] = useState<MarketTemperatureData | 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 [latestResult, sectorResult, statusResult, overviewResult] = await Promise.allSettled([
fetchAPI<LatestResult>("/api/recommendations/latest"),
fetchAPI<SectorData[]>("/api/sectors/hot?limit=8"),
fetchAPI<ScanStatus>("/api/recommendations/status"),
fetchAPI<IndexOverview[]>("/api/market/overview"),
]);
const latest = latestResult.status === "fulfilled" ? latestResult.value : null;
const sectorData = sectorResult.status === "fulfilled" ? sectorResult.value : [];
const status = statusResult.status === "fulfilled" ? statusResult.value : null;
const overview = overviewResult.status === "fulfilled" ? overviewResult.value : [];
if (latest) {
setData(latest);
const realtimeTemp = await fetchAPI<MarketTemperatureData>("/api/market/temperature").catch(() => latest.market_temperature);
setMarketTemperature(realtimeTemp ?? latest.market_temperature ?? null);
}
setSectors(sectorData);
if (status) setScanStatus(status);
setIndices(overview);
} catch (error) {
console.error("加载数据失败:", error);
} finally {
setLoading(false);
}
}, []);
const loadSecondaryData = useCallback(async () => {
try {
const [board, ops] = await Promise.all([
fetchAPI<StrategyBoard>("/api/market/strategy-board").catch(() => null),
fetchAPI<OpsStatusResponse>("/api/market/ops-status").catch(() => null),
]);
setStrategyBoard(board);
setOpsStatus(ops);
} catch (error) {
console.error("加载次要数据失败:", error);
}
}, []);
const clearScanTimeout = useCallback(() => {
if (scanTimeoutRef.current) {
clearTimeout(scanTimeoutRef.current);
scanTimeoutRef.current = null;
}
}, []);
useEffect(() => {
loadData();
}, [loadData]);
useEffect(() => {
loadSecondaryData();
}, [loadSecondaryData]);
useWebSocket(
useCallback((msg: { type: string; count?: number; scan_mode?: string; message?: string }) => {
clearScanTimeout();
if (msg.type === "scan_update") {
const modeLabel = msg.scan_mode === "realtime_today" || msg.scan_mode === "intraday" ? "今日实时" : "历史收盘";
setRefreshResult(`${modeLabel}扫描完成,发现 ${msg.count ?? 0} 只股票`);
setRefreshing(false);
loadData();
loadSecondaryData();
setTimeout(() => setRefreshResult(null), 5000);
} else if (msg.type === "scan_error") {
setRefreshResult("扫描失败,请重试");
setRefreshing(false);
setTimeout(() => setRefreshResult(null), 5000);
} else {
loadData();
loadSecondaryData();
}
}, [clearScanTimeout, loadData, loadSecondaryData]),
["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 || "扫描正在执行中,请稍候");
} else if (res.status === "scanning") {
setRefreshResult("扫描已启动,完成后自动刷新...");
}
scanTimeoutRef.current = setTimeout(() => {
setRefreshResult("扫描超时,已自动刷新数据");
setRefreshing(false);
loadData();
loadSecondaryData();
setTimeout(() => setRefreshResult(null), 5000);
}, SCAN_TIMEOUT_MS);
} catch (error) {
console.error("触发扫描失败:", error);
setRefreshResult("触发扫描失败,请重试");
setRefreshing(false);
setTimeout(() => setRefreshResult(null), 5000);
}
};
const handleAdminAction = async (action: "update_tracking") => {
setOpsRunning(action);
setRefreshResult(null);
try {
const result = await postAPI<{ tracked?: number; win_rate?: number }>("/api/recommendations/update-tracking");
setRefreshResult(`跟踪已更新,样本 ${result.tracked ?? 0},胜率 ${clampPercent(result.win_rate ?? 0).toFixed(1)}%`);
await loadData();
await loadSecondaryData();
setTimeout(() => setRefreshResult(null), 5000);
} catch (error) {
console.error("管理员操作失败:", error);
setRefreshResult("管理员操作失败,请重试");
setTimeout(() => setRefreshResult(null), 5000);
} finally {
setOpsRunning(null);
}
};
useEffect(() => {
return () => clearScanTimeout();
}, [clearScanTimeout]);
const recommendations = data?.recommendations ?? [];
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 marketSummary = useMemo(
() => buildMarketSummary(data?.strategy_profile ?? null, marketTemperature, strategyBoard, scanStatus, actionable.length, watch.length),
[actionable.length, data?.strategy_profile, marketTemperature, scanStatus, strategyBoard, watch.length]
);
const todayActions = useMemo(
() => buildActionGuides(data?.strategy_profile ?? null, strategyBoard, marketTemperature, actionable, watch, observe, sectors),
[actionable, data?.strategy_profile, marketTemperature, observe, sectors, strategyBoard, watch]
);
const focusQueue = actionable.length ? actionable : watch.length ? watch : recommendations;
if (loading) {
return (
<div className="max-w-7xl mx-auto px-4 md:px-8 pt-6 space-y-4">
<div className="h-28 glass-card-static animate-shimmer" />
<div className="h-48 glass-card-static animate-shimmer" />
<div className="h-64 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-5">
<div className="flex items-start justify-between gap-4 animate-fade-in-up">
<div>
<div className="flex items-center gap-2">
<h1 className="text-lg font-bold tracking-tight"></h1>
{scanStatus?.is_trading ? (
<span className="inline-flex items-center gap-1.5 rounded-full bg-emerald-500/10 px-2 py-0.5 text-[10px] text-emerald-400">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-400 animate-pulse" />
</span>
) : (
<span className="inline-flex items-center gap-1.5 rounded-full bg-surface-2 px-2 py-0.5 text-[10px] text-text-muted">
</span>
)}
</div>
</div>
<div className="flex items-center gap-2">
<ThemeToggle />
{user?.role === "admin" ? (
<button
onClick={handleRefresh}
disabled={refreshing}
className="rounded-xl border border-amber-500/15 bg-amber-500/10 px-4 py-2 text-xs font-medium text-amber-400 transition-colors hover:bg-amber-500/15 disabled:opacity-40"
>
{refreshing ? "扫描中..." : scanStatus?.is_trading ? "盘中扫描" : "立即扫描"}
</button>
) : null}
</div>
</div>
{refreshResult ? (
<div className="glass-card-static animate-fade-in-up border-amber-500/15 px-4 py-2.5 text-xs text-amber-400">
{refreshResult}
</div>
) : null}
<DecisionHero
board={strategyBoard}
summary={marketSummary}
actionableCount={actionable.length}
watchCount={watch.length}
observeCount={observe.length}
/>
<div className="grid grid-cols-1 xl:grid-cols-[minmax(0,1.2fr)_minmax(300px,0.8fr)] gap-4 animate-fade-in-up">
<ActionPanel
actions={todayActions}
summary={marketSummary}
/>
<FocusPanel
focusQueue={focusQueue.slice(0, 3)}
actionableCount={actionable.length}
watchCount={watch.length}
observeCount={observe.length}
/>
</div>
<div className="grid grid-cols-1 xl:grid-cols-[minmax(0,1fr)_320px] gap-4 animate-fade-in-up">
<MarketSnapshot
marketTemperature={marketTemperature ?? data?.market_temperature ?? null}
indices={indices}
sectors={sectors}
summary={marketSummary}
/>
<AdminPanel
isAdmin={user?.role === "admin"}
opsStatus={opsStatus}
strategyProfile={data?.strategy_profile ?? null}
refreshing={refreshing}
opsRunning={opsRunning}
onRefresh={handleRefresh}
onAction={handleAdminAction}
/>
</div>
</div>
);
}
function DecisionHero({
board,
summary,
actionableCount,
watchCount,
observeCount,
}: {
board: StrategyBoard | null;
summary: ReturnType<typeof buildMarketSummary>;
actionableCount: number;
watchCount: number;
observeCount: number;
}) {
const isRealtime = board?.data_mode === "realtime_today";
return (
<div className="glass-card-static animate-fade-in-up overflow-hidden p-4 md:p-5">
<div className="grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_340px] gap-4">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="text-[10px] font-semibold uppercase tracking-[0.2em] text-amber-400"></span>
{isRealtime ? (
<span className="rounded-full bg-emerald-500/10 px-2 py-0.5 text-[10px] text-emerald-400"></span>
) : null}
</div>
<h2 className="mt-2 text-xl font-bold tracking-tight text-text-primary">{summary.headline}</h2>
<p className="mt-2 max-w-3xl text-sm leading-6 text-text-secondary line-clamp-2">
{summary.detail}
</p>
<div className="mt-4 grid grid-cols-1 md:grid-cols-2 gap-3">
<DecisionList title="动作" items={summary.canDo} tone="positive" />
<DecisionList title="边界" items={summary.cannotDo} tone="risk" />
</div>
</div>
<div className="grid grid-cols-3 gap-2 self-start">
<HeroMetric label="可操作" value={actionableCount} tone="text-red-400" />
<HeroMetric label="关注" value={watchCount} tone="text-amber-400" />
<HeroMetric label="观察" value={observeCount} tone="text-text-secondary" />
<HeroFact label="打法" value={summary.modeLabel} />
<HeroFact label="仓位" value={summary.positionLabel} />
<HeroFact label="风险" value={board?.risk_level ?? "等待更新"} />
</div>
</div>
</div>
);
}
function ActionPanel({
actions,
summary,
}: {
actions: ReturnType<typeof buildActionGuides>;
summary: ReturnType<typeof buildMarketSummary>;
}) {
return (
<div className="glass-card-static p-4 md:p-5">
<div className="flex flex-wrap items-center gap-2">
<CompactBadge label="策略" value={summary.modeLabel} />
<CompactBadge label="仓位" value={summary.positionLabel} />
<CompactBadge label="风险" value={summary.riskLabel} />
</div>
<div className="mt-4 grid grid-cols-1 md:grid-cols-3 gap-3">
<ActionBucket title="现在做" items={actions.priority} tone="priority" />
<ActionBucket title="盯住" items={actions.watch} tone="watch" />
<ActionBucket title="不要做" items={actions.avoid} tone="avoid" />
</div>
</div>
);
}
function FocusPanel({
focusQueue,
actionableCount,
watchCount,
observeCount,
}: {
focusQueue: RecommendationData[];
actionableCount: number;
watchCount: number;
observeCount: number;
}) {
return (
<div className="glass-card-static p-4 md:p-5">
<div className="flex items-start justify-between gap-3">
<div>
<h3 className="text-sm font-semibold text-text-primary"></h3>
</div>
<div className="grid grid-cols-3 gap-2 text-center">
<MiniCount label="可操作" value={actionableCount} tone="text-red-400" />
<MiniCount label="关注" value={watchCount} tone="text-amber-400" />
<MiniCount label="观察" value={observeCount} tone="text-text-secondary" />
</div>
</div>
<div className="mt-4 space-y-3">
{focusQueue.length ? (
focusQueue.map((rec) => <FocusStockCard key={rec.ts_code} rec={rec} />)
) : (
<div className="rounded-2xl border border-border-subtle bg-surface-1/70 p-6 text-center text-sm text-text-muted">
</div>
)}
</div>
</div>
);
}
function MarketSnapshot({
marketTemperature,
indices,
sectors,
summary,
}: {
marketTemperature: MarketTemperatureData | null;
indices: IndexOverview[];
sectors: SectorData[];
summary: ReturnType<typeof buildMarketSummary>;
}) {
const leadingSectors = sectors.slice(0, 4);
const majorIndices = indices.slice(0, 3);
return (
<div className="glass-card-static p-4 md:p-5">
<div className="flex items-center justify-between gap-3">
<div>
<h3 className="text-sm font-semibold text-text-primary"></h3>
</div>
<span className="rounded-xl bg-surface-1/70 px-3 py-1.5 text-xs font-mono tabular-nums text-text-secondary">
{Math.round(marketTemperature?.temperature ?? 0)}
</span>
</div>
<div className="mt-4 grid grid-cols-2 md:grid-cols-4 gap-2">
<EvidenceStat label="上涨" value={marketTemperature?.up_count ?? 0} tone="text-red-400" />
<EvidenceStat label="下跌" value={marketTemperature?.down_count ?? 0} tone="text-emerald-400" />
<EvidenceStat label="涨停" value={marketTemperature?.limit_up_count ?? 0} tone="text-amber-400" />
<EvidenceStat label="跌停" value={marketTemperature?.limit_down_count ?? 0} tone="text-cyan-400" />
</div>
{majorIndices.length ? (
<div className="mt-4 grid grid-cols-1 md:grid-cols-3 gap-2">
{majorIndices.map((item) => (
<div key={item.code} className="rounded-2xl border border-border-subtle bg-surface-1/70 px-3 py-3">
<div className="text-xs font-semibold text-text-primary">{item.name}</div>
<div className="mt-1 flex items-center justify-between gap-3">
<span className="text-xs font-mono tabular-nums text-text-secondary">{item.close.toFixed(2)}</span>
<span className={`text-xs font-mono tabular-nums ${item.pct_chg >= 0 ? "text-red-400" : "text-emerald-400"}`}>
{item.pct_chg >= 0 ? "+" : ""}{item.pct_chg.toFixed(2)}%
</span>
</div>
</div>
))}
</div>
) : null}
{leadingSectors.length ? (
<div className="mt-4">
<div className="flex items-center justify-between border-b border-border-subtle pb-2">
<div className="text-[11px] font-semibold text-text-secondary"></div>
<div className="text-[10px] text-text-muted">线</div>
</div>
<div className="divide-y divide-border-subtle">
{leadingSectors.map((sector) => {
const pct = sector.realtime_pct_change ?? sector.pct_change;
return (
<div key={sector.sector_code} className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3 py-2.5 text-sm">
<div className="min-w-0 overflow-hidden">
<div className="truncate text-[13px] font-medium text-text-primary">{sector.sector_name}</div>
<div className="mt-0.5 truncate text-[11px] text-text-muted">
{sector.limit_up_count > 0 ? `${sector.limit_up_count} 涨停` : "无涨停"} · {sector.stage || "mid"}
</div>
</div>
<span className={`min-w-[4.5rem] text-right font-mono text-xs tabular-nums ${pct >= 0 ? "text-red-400" : "text-emerald-400"}`}>
{pct >= 0 ? "+" : ""}{pct.toFixed(2)}%
</span>
</div>
);
})}
</div>
</div>
) : null}
<div className="mt-4 rounded-2xl border border-border-subtle bg-surface-1/70 px-3 py-3 text-sm text-text-secondary">
{summary.headline}
</div>
</div>
);
}
function AdminPanel({
isAdmin,
opsStatus,
strategyProfile,
refreshing,
opsRunning,
onRefresh,
onAction,
}: {
isAdmin?: boolean;
opsStatus: OpsStatusResponse | null;
strategyProfile: LatestResult["strategy_profile"];
refreshing: boolean;
opsRunning: string | null;
onRefresh: () => void;
onAction: (action: "update_tracking") => void;
}) {
if (!isAdmin || !opsStatus) return null;
return (
<div className="glass-card-static p-4">
<div className="flex items-center justify-between gap-3">
<div>
<h3 className="text-sm font-semibold text-text-primary"></h3>
<p className="mt-1 text-xs text-text-muted">{opsStatus.data_freshness.message}</p>
</div>
<span className="rounded-full bg-surface-2 px-2 py-0.5 text-[10px] text-text-muted">
{opsStatus.scan_running ? "扫描中" : "空闲"}
</span>
</div>
<div className="mt-3 grid grid-cols-2 gap-2 text-[11px] text-text-muted">
<FreshnessPill label="市场" value={opsStatus.data_freshness.market_trade_date || "暂无"} />
<FreshnessPill label="板块" value={opsStatus.data_freshness.sector_trade_date || "暂无"} />
<FreshnessPill label="跟踪" value={opsStatus.data_freshness.tracking_trade_date || "暂无"} />
<FreshnessPill label="推荐" value={opsStatus.data_freshness.last_recommendation_created_at || "暂无"} />
</div>
{strategyProfile?.feedback_applied ? (
<div className="mt-3 rounded-2xl border border-cyan-500/15 bg-cyan-500/[0.05] p-3">
<div className="text-[10px] uppercase tracking-wider text-cyan-400 font-semibold"></div>
<div className="mt-2 space-y-1.5">
{(strategyProfile.feedback_notes?.length ? strategyProfile.feedback_notes : strategyProfile.notes || []).slice(0, 3).map((note, index) => (
<div key={`${note}-${index}`} className="text-xs leading-5 text-cyan-400/85">
{note}
</div>
))}
</div>
</div>
) : null}
<div className="mt-3 flex flex-wrap gap-2">
<button
onClick={onRefresh}
disabled={refreshing || !!opsRunning}
className="rounded-xl border border-amber-500/15 bg-amber-500/10 px-3 py-2 text-xs text-amber-400 disabled:opacity-40"
>
{refreshing ? "扫描中..." : "立即扫描"}
</button>
<button
onClick={() => onAction("update_tracking")}
disabled={refreshing || !!opsRunning}
className="rounded-xl border border-border-subtle bg-surface-1 px-3 py-2 text-xs text-text-secondary disabled:opacity-40"
>
{opsRunning === "update_tracking" ? "更新中..." : "更新跟踪"}
</button>
</div>
</div>
);
}
function DecisionList({
title,
items,
tone,
}: {
title: string;
items: string[];
tone: "positive" | "risk";
}) {
const dotClass = tone === "positive" ? "bg-emerald-400" : "bg-amber-400";
return (
<div className="rounded-2xl border border-border-subtle bg-surface-1/70 p-3">
<div className="text-[11px] font-semibold text-text-secondary">{title}</div>
<div className="mt-2 space-y-2">
{items.map((item, index) => (
<div key={`${title}-${index}`} className="flex items-start gap-2 text-sm text-text-secondary">
<span className={`mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full ${dotClass}`} />
<span>{item}</span>
</div>
))}
</div>
</div>
);
}
function HeroMetric({ label, value, tone }: { label: string; value: number; tone: string }) {
return (
<div className="rounded-2xl border border-border-subtle bg-surface-1/70 px-3 py-3 text-center">
<div className="text-[10px] uppercase tracking-wider text-text-muted">{label}</div>
<div className={`mt-1 text-xl font-bold font-mono tabular-nums ${tone}`}>{value}</div>
</div>
);
}
function HeroFact({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-2xl border border-border-subtle bg-surface-1/70 px-3 py-3">
<div className="text-[10px] uppercase tracking-wider text-text-muted">{label}</div>
<div className="mt-1 text-xs font-semibold leading-5 text-text-secondary line-clamp-2">{value}</div>
</div>
);
}
function CompactBadge({ label, value }: { label: string; value: string }) {
return (
<span className="inline-flex items-center gap-2 rounded-xl border border-border-subtle bg-surface-1/70 px-3 py-1.5 text-xs text-text-secondary">
<span className="text-text-muted">{label}</span>
<span className="font-medium text-text-primary">{value}</span>
</span>
);
}
function ActionBucket({
title,
items,
tone,
}: {
title: string;
items: string[];
tone: "priority" | "watch" | "avoid";
}) {
const toneClass =
tone === "priority"
? "bg-red-500/8 text-red-400"
: tone === "watch"
? "bg-amber-500/8 text-amber-400"
: "bg-surface-2 text-text-muted";
return (
<div className="rounded-2xl border border-border-subtle bg-surface-1/70 p-3">
<div className={`inline-flex rounded-lg px-2 py-1 text-[10px] font-semibold uppercase tracking-wider ${toneClass}`}>
{title}
</div>
<div className="mt-3 space-y-2">
{items.map((item, index) => (
<div key={`${title}-${index}`} className="rounded-xl bg-surface-2/70 px-3 py-2 text-sm leading-6 text-text-secondary">
{item}
</div>
))}
</div>
</div>
);
}
function FocusStockCard({ rec }: { rec: RecommendationData }) {
const badgeClass =
rec.action_plan === "可操作"
? "border-red-500/20 bg-red-500/10 text-red-400"
: rec.action_plan === "重点关注"
? "border-amber-500/20 bg-amber-500/10 text-amber-400"
: "border-border-subtle bg-surface-2 text-text-muted";
return (
<a
href={`/stock/${rec.ts_code}`}
className="rounded-2xl border border-border-subtle bg-surface-1/70 p-4 transition-colors hover:border-amber-500/20"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-text-primary">{rec.name}</span>
<span className={`rounded-lg border px-2 py-0.5 text-[10px] ${badgeClass}`}>{rec.action_plan ?? "观察"}</span>
</div>
<div className="mt-1 flex min-w-0 flex-wrap items-center gap-1.5 text-[11px] text-text-muted">
<span className="font-mono tabular-nums">{rec.ts_code}</span>
{rec.sector ? (
<span className="inline-flex max-w-[10rem] items-center rounded-md border border-border-subtle bg-surface-2/70 px-1.5 py-0.5 font-medium text-text-secondary">
<span className="truncate">{rec.sector}</span>
</span>
) : null}
</div>
</div>
<div className="text-right">
<div className="text-xs font-mono tabular-nums text-text-secondary">{Math.round(rec.score)}</div>
<div className="mt-0.5 text-[10px] text-text-muted"></div>
</div>
</div>
<div className="mt-3 text-sm leading-6 text-text-secondary">
{rec.trigger_condition ?? rec.entry_timing ?? rec.prefilter_reason ?? rec.reasons?.[0] ?? "等待新的触发条件。"}
</div>
{(rec.invalidation_condition || rec.risk_note) ? (
<div className="mt-3 rounded-xl bg-surface-2/70 px-3 py-2 text-[11px] leading-5 text-text-muted">
: {rec.invalidation_condition ?? rec.risk_note}
</div>
) : null}
</a>
);
}
function EvidenceStat({ label, value, tone }: { label: string; value: number; tone: string }) {
return (
<div className="rounded-2xl border border-border-subtle bg-surface-1/70 px-3 py-3">
<div className="text-[10px] uppercase tracking-wider text-text-muted">{label}</div>
<div className={`mt-1 text-lg font-bold font-mono tabular-nums ${tone}`}>{value}</div>
</div>
);
}
function MiniCount({ label, value, tone }: { label: string; value: number; tone: string }) {
return (
<div className="rounded-xl bg-surface-2/70 px-2 py-2">
<div className="text-[10px] text-text-muted">{label}</div>
<div className={`mt-1 text-sm font-semibold font-mono tabular-nums ${tone}`}>{value}</div>
</div>
);
}
function FreshnessPill({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-xl bg-surface-1/70 px-2.5 py-2">
<div className="text-[10px] text-text-muted">{label}</div>
<div className="mt-1 truncate text-[11px] text-text-secondary">{value}</div>
</div>
);
}
function buildMarketSummary(
strategyProfile: LatestResult["strategy_profile"],
marketTemperature: MarketTemperatureData | null,
board: StrategyBoard | null,
scanStatus: ScanStatus | null,
actionableCount: number,
watchCount: number
) {
const temp = marketTemperature?.temperature ?? board?.metrics.temperature ?? 0;
const allowTrading = strategyProfile?.allow_trading ?? actionableCount > 0;
const headline = !allowTrading
? "防守观察"
: board?.market_regime ??
(temp >= 70 ? "主线进攻" :
temp >= 50 ? "确认机会" :
temp >= 30 ? "轻仓试错" :
"偏弱观察");
const detail =
strategyProfile?.decision_note ??
board?.summary ??
(scanStatus?.is_trading
? "盘中实时:看节奏、仓位、主线强弱。"
: "收盘快照:复盘主线,准备下一交易日。");
const canDo = [
!allowTrading
? "观察等待。"
: actionableCount > 0
? `只看 ${actionableCount} 只可操作标的。`
: "无可操作标的,保留观察。",
watchCount > 0 ? `关注 ${watchCount} 只确认信号。` : "盯最强板块前排。",
board?.position_suggestion ?? (strategyProfile?.max_position_pct ? `仓位上限 ${strategyProfile.max_position_pct}%。` : temp >= 50 ? "围绕主线试错。" : "控制仓位。"),
];
const cannotDo = [
board?.avoid_rules?.[0] ?? "不要追后排、跟风和没有板块支撑的个股。",
board?.avoid_rules?.[1] ?? (temp < 50 ? "不把个别异动当回暖。" : "不把盘中脉冲当主线。"),
!allowTrading || temp < 40 ? "不逆势加仓。" : "不随意切换题材。",
];
return {
headline,
detail,
canDo,
cannotDo,
modeLabel: strategyProfile?.market_stance || board?.recommended_mode || "等待更新",
positionLabel: board?.position_suggestion || (strategyProfile?.max_position_pct ? `${strategyProfile.max_position_pct}% 以内` : "等待更新"),
riskLabel: board?.risk_level || "等待更新",
};
}
function clampPercent(value: number) {
if (!Number.isFinite(value)) return 0;
return Math.max(0, Math.min(100, value));
}
function buildActionGuides(
strategyProfile: LatestResult["strategy_profile"],
board: StrategyBoard | null,
marketTemperature: MarketTemperatureData | null,
actionable: RecommendationData[],
watch: RecommendationData[],
observe: RecommendationData[],
sectors: SectorData[]
) {
const topSectors = (board?.watch_sectors?.slice(0, 2) ?? []).map((sector) => sector.sector_name);
const backupSectors = sectors.slice(0, 2).map((sector) => sector.sector_name);
const focusSectors = topSectors.length ? topSectors : backupSectors;
const temp = marketTemperature?.temperature ?? board?.metrics.temperature ?? 0;
const allowTrading = strategyProfile?.allow_trading ?? actionable.length > 0;
const priority = [
!allowTrading
? "等待清晰主线和承接。"
: actionable[0]
? `${actionable[0].name}${actionable[0].trigger_condition ? `${actionable[0].trigger_condition}` : " 的确认信号"}`
: focusSectors[0]
? `${focusSectors[0]} 前排强度。`
: "等待更清晰信号。",
focusSectors[1]
? `主线池:${focusSectors.slice(0, 2).join("、")}`
: "只围绕最强主线。",
!allowTrading ? "保守观察。" : temp >= 50 ? "做回流确认,不做无量冲高。" : "只看最强确认。",
];
const watchItems = [
watch[0]
? `${watch[0].name}:量能、回流、承接。`
: focusSectors[0]
? `${focusSectors[0]}:确认前不追。`
: "观察新主线聚焦。",
watch[1]
? `${watch[1].name}:观察队列。`
: "新热点先看板块扩散。",
observe.length > 0 ? `后台观察 ${observe.length} 只。` : "暂无弱候选。",
];
const avoid = [
board?.avoid_rules?.[0] ?? "回避没有主线归属、没有触发条件的个股。",
board?.avoid_rules?.[1] ?? "回避后排补涨和尾盘情绪化拉升。",
temp < 40 ? "回避逆势加仓和高频换股。" : "回避脱离计划的追涨杀跌。",
];
return { priority, watch: watchItems, avoid };
}