705 lines
30 KiB
TypeScript
705 lines
30 KiB
TypeScript
"use client";
|
||
|
||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||
import { fetchAPI, postAPI } from "@/lib/api";
|
||
import type {
|
||
DayGroup,
|
||
LatestResult,
|
||
OpsStatusResponse,
|
||
PerformanceStats,
|
||
RecommendationData,
|
||
TrackedRecommendation,
|
||
} from "@/lib/api";
|
||
import StockCard from "@/components/stock-card";
|
||
import { useAuth } from "@/hooks/use-auth";
|
||
|
||
function formatDate(dateStr: string): string {
|
||
const d = new Date(dateStr);
|
||
const today = new Date();
|
||
const yesterday = new Date(today);
|
||
yesterday.setDate(yesterday.getDate() - 1);
|
||
|
||
if (dateStr === today.toISOString().slice(0, 10)) return "今日";
|
||
if (dateStr === yesterday.toISOString().slice(0, 10)) return "昨日";
|
||
|
||
const weekDays = ["周日", "周一", "周二", "周三", "周四", "周五", "周六"];
|
||
return `${d.getMonth() + 1}月${d.getDate()}日 ${weekDays[d.getDay()]}`;
|
||
}
|
||
|
||
function formatScanTime(value?: string): string {
|
||
if (!value) return "暂无扫描";
|
||
const normalized = value.includes("T") ? value : value.replace(" ", "T");
|
||
const d = new Date(normalized);
|
||
if (Number.isNaN(d.getTime())) return value;
|
||
const today = new Date();
|
||
const isToday = d.toDateString() === today.toDateString();
|
||
const time = d.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" });
|
||
if (isToday) return `今日 ${time}`;
|
||
return d.toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" });
|
||
}
|
||
|
||
type RecommendationWithDate = RecommendationData & { groupDate: string };
|
||
|
||
type FocusTab = "actionable" | "watch" | "observe" | "tracking" | "closed";
|
||
|
||
const SIGNAL_FILTERS = [
|
||
{ key: "all", label: "全部" },
|
||
{ key: "breakout", label: "突破" },
|
||
{ key: "pullback", label: "回踩" },
|
||
{ key: "launch", label: "启动" },
|
||
{ key: "buy", label: "买入" },
|
||
];
|
||
|
||
export default function RecommendationsPage() {
|
||
const { user } = useAuth();
|
||
const [dayGroups, setDayGroups] = useState<DayGroup[]>([]);
|
||
const [latest, setLatest] = useState<LatestResult | null>(null);
|
||
const [expandedDays, setExpandedDays] = useState<Set<string>>(new Set());
|
||
const [historyFilter, setHistoryFilter] = useState<string>("all");
|
||
const [focusTab, setFocusTab] = useState<FocusTab>("actionable");
|
||
const [opsStatus, setOpsStatus] = useState<OpsStatusResponse | null>(null);
|
||
const [performance, setPerformance] = useState<PerformanceStats | null>(null);
|
||
const [trackingUpdating, setTrackingUpdating] = useState(false);
|
||
|
||
const loadData = useCallback(async () => {
|
||
try {
|
||
const [history, latestResult, ops, perf] = await Promise.all([
|
||
fetchAPI<DayGroup[]>("/api/recommendations/history?days=14"),
|
||
fetchAPI<LatestResult>("/api/recommendations/latest").catch(() => null),
|
||
user?.role === "admin" ? fetchAPI<OpsStatusResponse>("/api/market/ops-status").catch(() => null) : Promise.resolve(null),
|
||
fetchAPI<PerformanceStats>("/api/recommendations/performance").catch(() => null),
|
||
]);
|
||
|
||
setDayGroups(history);
|
||
setLatest(latestResult);
|
||
setOpsStatus(ops);
|
||
setPerformance(perf);
|
||
|
||
setExpandedDays((prev) => {
|
||
if (prev.size || history.length === 0) return prev;
|
||
return new Set([history[0].date]);
|
||
});
|
||
} catch (error) {
|
||
console.error("加载推荐失败:", error);
|
||
}
|
||
}, [user?.role]);
|
||
|
||
useEffect(() => {
|
||
loadData();
|
||
}, [loadData]);
|
||
|
||
const updateTracking = async () => {
|
||
setTrackingUpdating(true);
|
||
try {
|
||
await postAPI("/api/recommendations/update-tracking");
|
||
await loadData();
|
||
} finally {
|
||
setTrackingUpdating(false);
|
||
}
|
||
};
|
||
|
||
const toggleDay = (date: string) => {
|
||
setExpandedDays((prev) => {
|
||
const next = new Set(prev);
|
||
if (next.has(date)) next.delete(date);
|
||
else next.add(date);
|
||
return next;
|
||
});
|
||
};
|
||
|
||
const applyHistoryFilter = useCallback((recs: RecommendationData[]) => {
|
||
if (historyFilter === "all") return recs;
|
||
if (historyFilter === "buy") return recs.filter((r) => r.signal === "BUY");
|
||
return recs.filter((r) => r.entry_signal_type === historyFilter);
|
||
}, [historyFilter]);
|
||
|
||
const allRecommendations: RecommendationWithDate[] = useMemo(
|
||
() => dayGroups.flatMap((group) => group.recommendations.map((rec) => ({ ...rec, groupDate: group.date }))),
|
||
[dayGroups]
|
||
);
|
||
|
||
const latestDate = dayGroups[0]?.date ?? "";
|
||
const latestRecommendations = useMemo(() => {
|
||
if (latest?.recommendations?.length) return latest.recommendations.map((rec) => ({ ...rec, groupDate: latestDate || "latest" }));
|
||
return latestDate ? allRecommendations.filter((rec) => rec.groupDate === latestDate) : [];
|
||
}, [allRecommendations, latest, latestDate]);
|
||
|
||
const actionable = latestRecommendations.filter((rec) => rec.action_plan === "可操作" || rec.lifecycle_status === "actionable");
|
||
const watch = latestRecommendations.filter((rec) => rec.action_plan === "重点关注");
|
||
const observe = latestRecommendations.filter((rec) => (rec.action_plan ?? "观察") === "观察");
|
||
const tracking = allRecommendations.filter((rec) => rec.lifecycle_status === "tracking");
|
||
const closed = allRecommendations.filter((rec) => ["closed_win", "closed_loss", "expired", "invalidated"].includes(rec.lifecycle_status ?? ""));
|
||
|
||
const focusTabs: Array<{ key: FocusTab; label: string; count: number; description: string }> = [
|
||
{ key: "actionable", label: "可操作", count: actionable.length, description: "执行名单" },
|
||
{ key: "watch", label: "重点关注", count: watch.length, description: "等待确认" },
|
||
{ key: "observe", label: "观察池", count: observe.length, description: "不急处理" },
|
||
{ key: "tracking", label: "跟踪中", count: tracking.length, description: "兑现进度" },
|
||
{ key: "closed", label: "已结束", count: closed.length, description: "复盘样本" },
|
||
];
|
||
|
||
const focusItems = useMemo(() => {
|
||
if (focusTab === "actionable") return actionable;
|
||
if (focusTab === "watch") return watch;
|
||
if (focusTab === "observe") return observe.slice(0, 12);
|
||
if (focusTab === "tracking") return tracking.slice(0, 8);
|
||
return closed.slice(0, 8);
|
||
}, [actionable, closed, focusTab, observe, tracking, watch]);
|
||
|
||
const themeFocus = useMemo(() => {
|
||
const source = latestRecommendations.filter((rec) => ["可操作", "重点关注"].includes(rec.action_plan ?? ""));
|
||
const pool = source.length ? source : latestRecommendations;
|
||
const counts = new Map<string, number>();
|
||
pool.forEach((rec) => {
|
||
const key = rec.sector || "未归类";
|
||
counts.set(key, (counts.get(key) ?? 0) + 1);
|
||
});
|
||
return Array.from(counts.entries())
|
||
.sort((a, b) => b[1] - a[1])
|
||
.slice(0, 3);
|
||
}, [latestRecommendations]);
|
||
|
||
const todayCount = applyHistoryFilter(dayGroups[0]?.recommendations ?? []).length;
|
||
const totalCount = dayGroups.reduce((sum, group) => sum + applyHistoryFilter(group.recommendations).length, 0);
|
||
const latestScanLabel = latest?.latest_scan?.created_at || dayGroups[0]?.scanned_at;
|
||
const focusSummary = buildFocusSummary({
|
||
strategyProfile: latest?.strategy_profile ?? null,
|
||
actionable,
|
||
watch,
|
||
observe,
|
||
tracking,
|
||
closed,
|
||
});
|
||
|
||
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="animate-fade-in-up">
|
||
<h1 className="text-base sm:text-lg font-bold tracking-tight">今日决策池</h1>
|
||
</div>
|
||
|
||
<div className="glass-card-static overflow-hidden animate-fade-in-up">
|
||
<div className="grid grid-cols-1 xl:grid-cols-[minmax(0,1fr)_340px]">
|
||
<div className="p-4 md:p-5">
|
||
<div className="text-[10px] uppercase tracking-[0.22em] text-amber-400 font-semibold">今日结论</div>
|
||
<h2 className="mt-2 text-2xl md:text-3xl font-bold tracking-tight text-text-primary">{focusSummary.headline}</h2>
|
||
<p className="mt-2 max-w-3xl text-sm leading-6 text-text-secondary">{focusSummary.detail}</p>
|
||
|
||
{latest?.strategy_profile ? (
|
||
<div className="mt-3 flex flex-wrap gap-2">
|
||
<SummaryChip label="打法" value={latest.strategy_profile.name} />
|
||
<SummaryChip label="立场" value={latest.strategy_profile.market_stance || (latest.strategy_profile.allow_trading ? "谨慎进攻" : "防守观察")} />
|
||
<SummaryChip label="仓位边界" value={`${latest.strategy_profile.max_position_pct ?? 0}%`} />
|
||
</div>
|
||
) : null}
|
||
|
||
{themeFocus.length ? (
|
||
<div className="mt-3 flex flex-wrap gap-2">
|
||
{themeFocus.map(([theme, count]) => (
|
||
<span
|
||
key={theme}
|
||
className="inline-flex items-center gap-2 rounded-xl border border-amber-500/15 bg-amber-500/[0.05] px-3 py-1.5 text-xs text-text-secondary"
|
||
>
|
||
<span className="font-medium text-text-primary">{theme}</span>
|
||
<span className="font-mono tabular-nums text-amber-400">{count}只</span>
|
||
</span>
|
||
))}
|
||
</div>
|
||
) : null}
|
||
|
||
<div className="mt-4 grid grid-cols-1 md:grid-cols-2 gap-3">
|
||
<KeyList title="执行原则" items={focusSummary.now} />
|
||
<KeyList title="回避原则" items={focusSummary.later} />
|
||
</div>
|
||
</div>
|
||
|
||
<div className="border-t border-border-subtle bg-surface-1/50 p-4 md:p-5 xl:border-l xl:border-t-0">
|
||
<div className="grid grid-cols-2 gap-2">
|
||
<SummaryMetric label="今日保留" value={latestRecommendations.length} tone="text-text-primary" />
|
||
<SummaryMetric label="可操作" value={actionable.length} tone="text-red-400" />
|
||
<SummaryMetric label="重点关注" value={watch.length} tone="text-amber-400" />
|
||
<SummaryMetric label="观察池" value={observe.length} tone="text-text-secondary" />
|
||
</div>
|
||
<div className="mt-3 grid grid-cols-2 gap-2">
|
||
<SummaryFact label="历史记录" value={`${totalCount} 只`} />
|
||
<SummaryFact label="已复盘" value={`${closed.length} 只`} />
|
||
<SummaryFact label="最近扫描" value={formatScanTime(latestScanLabel)} />
|
||
<SummaryFact label="扫描状态" value={latest?.latest_scan?.status || dayGroups[0]?.scan_status || "暂无"} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{user?.role === "admin" && opsStatus ? (
|
||
<div className="glass-card-static p-3.5 animate-fade-in-up">
|
||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-3">
|
||
<div className="text-xs text-text-secondary">
|
||
{opsStatus.data_freshness.message}
|
||
</div>
|
||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 text-[11px]">
|
||
<FreshnessCell label="市场" value={opsStatus.data_freshness.market_trade_date || "暂无"} />
|
||
<FreshnessCell label="板块" value={opsStatus.data_freshness.sector_trade_date || "暂无"} />
|
||
<FreshnessCell label="跟踪" value={opsStatus.data_freshness.tracking_trade_date || "暂无"} />
|
||
<FreshnessCell label="状态" value={opsStatus.scan_running ? "扫描中" : "空闲"} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
<PerformanceReviewCard
|
||
performance={performance}
|
||
canUpdate={user?.role === "admin"}
|
||
updating={trackingUpdating}
|
||
onUpdate={updateTracking}
|
||
/>
|
||
|
||
<div className="glass-card-static p-4 md:p-5 animate-fade-in-up">
|
||
<div className="flex flex-col gap-4">
|
||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||
<div>
|
||
<h2 className="text-base font-bold tracking-tight text-text-primary">
|
||
{focusTabs.find((tab) => tab.key === focusTab)?.label ?? "焦点标的"}
|
||
</h2>
|
||
<div className="mt-1 text-xs text-text-muted">
|
||
{focusTabs.find((tab) => tab.key === focusTab)?.description ?? "按当前结论处理"}
|
||
</div>
|
||
</div>
|
||
<div className="flex gap-2 overflow-x-auto pb-1">
|
||
{focusTabs.map((tab) => (
|
||
<button
|
||
key={tab.key}
|
||
onClick={() => setFocusTab(tab.key)}
|
||
className={`shrink-0 rounded-xl border px-3 py-2 text-left transition-all ${
|
||
focusTab === tab.key
|
||
? "border-amber-500/20 bg-amber-500/[0.06]"
|
||
: "border-border-subtle bg-surface-1 hover:bg-surface-2"
|
||
}`}
|
||
>
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-xs font-semibold text-text-primary">{tab.label}</span>
|
||
<span className="font-mono text-xs tabular-nums text-text-muted">{tab.count}</span>
|
||
</div>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{focusItems.length ? (
|
||
<div className="mt-4 grid grid-cols-1 md:grid-cols-2 gap-3">
|
||
{focusItems.map((rec) => (
|
||
<StockCard key={`${focusTab}-${rec.groupDate}-${rec.ts_code}`} rec={rec} compact />
|
||
))}
|
||
</div>
|
||
) : (
|
||
<div className="mt-4 rounded-2xl border border-border-subtle bg-surface-1/70 p-8 text-center text-sm text-text-muted">
|
||
当前分组暂无标的。
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="glass-card-static p-4 md:p-5 animate-fade-in-up">
|
||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-3">
|
||
<div>
|
||
<h2 className="text-sm font-semibold text-text-primary">历史记录</h2>
|
||
</div>
|
||
<div className="flex gap-2 overflow-x-auto pb-1">
|
||
{SIGNAL_FILTERS.map((item) => (
|
||
<button
|
||
key={item.key}
|
||
onClick={() => setHistoryFilter(item.key)}
|
||
className={`rounded-xl border px-3 py-1.5 text-xs whitespace-nowrap transition-all ${
|
||
historyFilter === item.key
|
||
? "border-amber-500/20 bg-amber-500/[0.06] text-amber-400"
|
||
: "border-border-subtle bg-surface-1 text-text-muted hover:text-text-secondary"
|
||
}`}
|
||
>
|
||
{item.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{dayGroups.length === 0 ? (
|
||
<div className="mt-4 rounded-2xl border border-border-subtle bg-surface-1/70 p-8 text-center text-sm text-text-muted">
|
||
暂无推荐记录。
|
||
</div>
|
||
) : (
|
||
<div className="mt-4 space-y-3">
|
||
{dayGroups.map((group, index) => {
|
||
const filtered = applyHistoryFilter(group.recommendations);
|
||
|
||
const isExpanded = expandedDays.has(group.date);
|
||
const isToday = index === 0;
|
||
const reasons = group.elimination_reasons ? Object.entries(group.elimination_reasons).slice(0, 3) : [];
|
||
|
||
return (
|
||
<div key={group.date} className="rounded-2xl border border-border-subtle bg-surface-1/40">
|
||
<button
|
||
onClick={() => toggleDay(group.date)}
|
||
className="flex w-full items-center justify-between gap-3 px-4 py-3 text-left"
|
||
>
|
||
<div className="min-w-0">
|
||
<div className="flex items-center gap-2">
|
||
<span className={`text-sm font-semibold ${isToday ? "text-amber-400" : "text-text-primary"}`}>
|
||
{formatDate(group.date)}
|
||
</span>
|
||
<span className="text-[11px] text-text-muted font-mono tabular-nums">{group.date}</span>
|
||
</div>
|
||
<div className="mt-1 text-[11px] text-text-muted">
|
||
扫描 {formatScanTime(group.scanned_at)} · {filtered.length} 只入选
|
||
{group.scan_input_count ? ` / 候选 ${group.scan_input_count}` : ""}
|
||
</div>
|
||
{group.scan_summary ? (
|
||
<div className="mt-1 line-clamp-1 text-[11px] text-text-secondary">{group.scan_summary}</div>
|
||
) : null}
|
||
</div>
|
||
<span className="text-xs text-text-muted">{isExpanded ? "收起" : "展开"}</span>
|
||
</button>
|
||
|
||
{isExpanded ? (
|
||
<div className="border-t border-border-subtle px-4 py-4">
|
||
{filtered.length ? (
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||
{filtered.map((rec) => (
|
||
<StockCard key={`${group.date}-${rec.ts_code}`} rec={rec} compact />
|
||
))}
|
||
</div>
|
||
) : (
|
||
<div className="rounded-2xl border border-border-subtle bg-surface-1/60 p-5 text-sm text-text-muted">
|
||
<div className="font-medium text-text-secondary">
|
||
{historyFilter === "all" ? "本日扫描无入选标的" : "本日扫描存在,但当前筛选条件下无标的"}
|
||
</div>
|
||
<div className="mt-2 text-xs leading-5">
|
||
{group.scan_summary || "系统已完成扫描,但没有标的进入历史推荐池。"}
|
||
</div>
|
||
{reasons.length ? (
|
||
<div className="mt-3 flex flex-wrap gap-1.5">
|
||
{reasons.map(([reason, count]) => (
|
||
<span key={reason} className="rounded-lg bg-surface-2/70 px-2 py-1 text-[10px] text-text-muted">
|
||
{reason} {count}
|
||
</span>
|
||
))}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
)}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function buildFocusSummary({
|
||
strategyProfile,
|
||
actionable,
|
||
watch,
|
||
observe,
|
||
tracking,
|
||
closed,
|
||
}: {
|
||
strategyProfile: LatestResult["strategy_profile"];
|
||
actionable: RecommendationData[];
|
||
watch: RecommendationData[];
|
||
observe: RecommendationData[];
|
||
tracking: RecommendationData[];
|
||
closed: RecommendationData[];
|
||
}) {
|
||
const allowTrading = strategyProfile?.allow_trading ?? actionable.length > 0;
|
||
const headline =
|
||
!allowTrading
|
||
? "防守观察"
|
||
: actionable.length > 0
|
||
? `处理 ${actionable.length} 只可操作标的`
|
||
: watch.length > 0
|
||
? `关注 ${watch.length} 只确认信号`
|
||
: "暂无优势机会";
|
||
|
||
const detail =
|
||
strategyProfile?.decision_note
|
||
?? (!allowTrading
|
||
? "防守观察,等待确认。"
|
||
: actionable.length > 0
|
||
? "执行名单已收敛。"
|
||
: watch.length > 0
|
||
? "等待确认,不扩池。"
|
||
: "观察为主。");
|
||
|
||
const now = [
|
||
!allowTrading
|
||
? "等待主线扩散和回流。"
|
||
: actionable[0]
|
||
? `看 ${actionable[0].name}${actionable[0].trigger_condition ? `:${actionable[0].trigger_condition}` : " 的确认信号"}。`
|
||
: watch[0]
|
||
? `盯住 ${watch[0].name} 是否从观察转成可操作。`
|
||
: "只留主线候选。",
|
||
watch.length > 0
|
||
? `${watch.length} 只重点关注,等待确认。`
|
||
: allowTrading
|
||
? "不从观察池强挑。"
|
||
: "确认前不提级。",
|
||
tracking.length > 0
|
||
? `${tracking.length} 只跟踪兑现。`
|
||
: "关注今日新结论。",
|
||
];
|
||
|
||
const later = [
|
||
observe.length > 0 ? `${observe.length} 只观察池标的。` : "不堆弱标的。",
|
||
closed.length > 0 ? `${closed.length} 只已结束样本。` : "暂无结束样本。",
|
||
"不追无触发标的。",
|
||
];
|
||
|
||
return { headline, detail, now, later };
|
||
}
|
||
|
||
function PerformanceReviewCard({
|
||
performance,
|
||
canUpdate,
|
||
updating,
|
||
onUpdate,
|
||
}: {
|
||
performance: PerformanceStats | null;
|
||
canUpdate: boolean;
|
||
updating: boolean;
|
||
onUpdate: () => void;
|
||
}) {
|
||
const details = performance?.details ?? [];
|
||
const topRoutes = (performance?.route_breakdown ?? []).slice(0, 4);
|
||
const conclusion = buildPerformanceConclusion(performance);
|
||
|
||
return (
|
||
<div className="glass-card-static p-4 md:p-5 animate-fade-in-up">
|
||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||
<div>
|
||
<div className="text-[10px] uppercase tracking-[0.22em] text-amber-400 font-semibold">推荐复盘</div>
|
||
<h2 className="mt-2 text-base font-bold tracking-tight text-text-primary">{conclusion.headline}</h2>
|
||
<p className="mt-1 max-w-3xl text-sm leading-6 text-text-secondary">{conclusion.detail}</p>
|
||
</div>
|
||
{canUpdate ? (
|
||
<button
|
||
onClick={onUpdate}
|
||
disabled={updating}
|
||
className="shrink-0 rounded-xl border border-amber-500/20 bg-amber-500/[0.06] px-3 py-2 text-xs font-semibold text-amber-400 transition-colors hover:bg-amber-500/[0.1] disabled:cursor-not-allowed disabled:opacity-50"
|
||
>
|
||
{updating ? "更新中" : "更新跟踪"}
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
|
||
<div className="mt-4 grid grid-cols-2 lg:grid-cols-5 gap-2">
|
||
<SummaryMetric label="已跟踪" value={performance?.tracked ?? 0} tone="text-text-primary" />
|
||
<SummaryMetric label="胜率" value={performance?.win_rate ?? 0} tone="text-amber-400" suffix="%" />
|
||
<SummaryMetric
|
||
label="平均收益"
|
||
value={performance?.avg_return ?? 0}
|
||
tone={(performance?.avg_return ?? 0) >= 0 ? "text-red-400" : "text-emerald-400"}
|
||
suffix="%"
|
||
signed
|
||
/>
|
||
<SummaryMetric label="平均浮盈" value={performance?.avg_max_return ?? 0} tone="text-red-400" suffix="%" signed />
|
||
<SummaryMetric label="平均回撤" value={performance?.avg_max_drawdown ?? 0} tone="text-amber-400" suffix="%" />
|
||
</div>
|
||
|
||
<div className="mt-4 grid grid-cols-1 xl:grid-cols-[320px_minmax(0,1fr)] gap-3">
|
||
<div className="rounded-2xl border border-border-subtle bg-surface-1/60 p-3">
|
||
<div className="text-[11px] font-semibold text-text-secondary">有效路线</div>
|
||
<div className="mt-3 space-y-2">
|
||
{topRoutes.length ? topRoutes.map((route) => (
|
||
<div key={route.route} className="flex items-center justify-between gap-3 rounded-xl bg-surface-2/70 px-3 py-2">
|
||
<div className="min-w-0">
|
||
<div className="truncate text-xs font-medium text-text-primary">{formatRouteLabel(route.route)}</div>
|
||
<div className="mt-0.5 text-[11px] text-text-muted">{route.count} 个样本</div>
|
||
</div>
|
||
<div className="text-right font-mono text-xs tabular-nums">
|
||
<div className="text-amber-400">{route.win_rate}%</div>
|
||
<div className={route.avg_return >= 0 ? "text-red-400" : "text-emerald-400"}>
|
||
{route.avg_return > 0 ? "+" : ""}{route.avg_return}%
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)) : (
|
||
<div className="rounded-xl bg-surface-2/70 px-3 py-6 text-center text-xs text-text-muted">暂无路线样本。</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="rounded-2xl border border-border-subtle bg-surface-1/60 p-3">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<div className="text-[11px] font-semibold text-text-secondary">最近复盘样本</div>
|
||
<div className="text-[11px] text-text-muted">按推荐时间倒序</div>
|
||
</div>
|
||
<div className="mt-3 overflow-x-auto">
|
||
{details.length ? (
|
||
<table className="w-full min-w-[620px] border-separate border-spacing-y-2 text-left text-xs">
|
||
<thead className="text-[10px] uppercase tracking-wider text-text-muted">
|
||
<tr>
|
||
<th className="px-3 py-2 font-medium">标的</th>
|
||
<th className="px-3 py-2 font-medium">推荐日</th>
|
||
<th className="px-3 py-2 font-medium">现价/入场</th>
|
||
<th className="px-3 py-2 font-medium">涨跌</th>
|
||
<th className="px-3 py-2 font-medium">过程</th>
|
||
<th className="px-3 py-2 font-medium">结论</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{details.slice(0, 8).map((item) => (
|
||
<ReviewRow key={`${item.ts_code}-${item.created_at}-${item.track_date}`} item={item} />
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
) : (
|
||
<div className="rounded-xl bg-surface-2/70 px-3 py-8 text-center text-sm text-text-muted">
|
||
暂无跟踪记录。先执行一次跟踪更新后再看复盘。
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ReviewRow({ item }: { item: TrackedRecommendation }) {
|
||
const pct = item.pct_from_entry ?? 0;
|
||
const pctTone = pct >= 0 ? "text-red-400" : "text-emerald-400";
|
||
const cellClass = "bg-surface-2/30 px-3 py-3 transition-colors group-hover:bg-surface-2/55";
|
||
return (
|
||
<tr className="group">
|
||
<td className={`${cellClass} rounded-l-xl`}>
|
||
<div className="font-medium text-text-primary">{item.name}</div>
|
||
<div className="mt-0.5 font-mono text-[11px] text-text-muted">{item.ts_code}</div>
|
||
</td>
|
||
<td className={`${cellClass} font-mono text-text-muted`}>{item.created_at || "-"}</td>
|
||
<td className={`${cellClass} font-mono tabular-nums text-text-secondary`}>
|
||
{formatNumber(item.current_price)} / {formatNumber(item.entry_price)}
|
||
</td>
|
||
<td className={`${cellClass} font-mono tabular-nums ${pctTone}`}>{pct > 0 ? "+" : ""}{pct}%</td>
|
||
<td className={`${cellClass} font-mono tabular-nums text-text-secondary`}>
|
||
<span className="text-red-400">{formatSigned(item.max_return_pct)}</span>
|
||
<span className="mx-1 text-text-muted">/</span>
|
||
<span className="text-amber-400">{formatSigned(item.max_drawdown_pct)}</span>
|
||
</td>
|
||
<td className={`${cellClass} rounded-r-xl text-text-secondary`}>{item.review_note || formatCloseReason(item.close_reason)}</td>
|
||
</tr>
|
||
);
|
||
}
|
||
|
||
function buildPerformanceConclusion(performance: PerformanceStats | null) {
|
||
if (!performance || performance.tracked === 0) {
|
||
return {
|
||
headline: "等待形成有效复盘样本",
|
||
detail: "当前还没有可统计的推荐跟踪记录。更新跟踪后,会按推荐后的价格表现判断是否兑现预期。",
|
||
};
|
||
}
|
||
|
||
const avg = performance.avg_return ?? 0;
|
||
const direction = avg > 0 ? "整体兑现为正" : avg < 0 ? "整体仍在回撤" : "整体接近持平";
|
||
return {
|
||
headline: `${performance.tracked} 个样本,胜率 ${performance.win_rate}%`,
|
||
detail: `${direction},平均收益 ${avg > 0 ? "+" : ""}${avg.toFixed(2)}%,平均最大浮盈 ${performance.avg_max_return.toFixed(2)}%,平均最大回撤 ${performance.avg_max_drawdown.toFixed(2)}%。`,
|
||
};
|
||
}
|
||
|
||
function formatRouteLabel(route: string) {
|
||
const labels: Record<string, string> = {
|
||
hot_theme_core: "主线核心",
|
||
theme_leader: "板块龙头",
|
||
top_theme_member: "强主题成员",
|
||
sector_recall: "板块召回",
|
||
};
|
||
return labels[route] ?? route;
|
||
}
|
||
|
||
function formatCloseReason(reason?: string) {
|
||
const labels: Record<string, string> = {
|
||
hit_target: "命中目标",
|
||
hit_stop_loss: "触发止损",
|
||
review_expired_profit: "到期盈利",
|
||
review_expired_loss: "到期亏损",
|
||
review_expired_flat: "到期震荡",
|
||
};
|
||
return labels[reason ?? ""] ?? "跟踪中";
|
||
}
|
||
|
||
function formatNumber(value: number | null | undefined) {
|
||
return value == null ? "-" : Number(value).toFixed(2);
|
||
}
|
||
|
||
function formatSigned(value: number | null | undefined) {
|
||
if (value == null) return "-";
|
||
return `${value > 0 ? "+" : ""}${Number(value).toFixed(2)}%`;
|
||
}
|
||
|
||
function KeyList({ title, items }: { title: string; items: string[] }) {
|
||
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 leading-6 text-text-secondary">
|
||
<span className="mt-2 h-1.5 w-1.5 shrink-0 rounded-full bg-amber-400" />
|
||
<span>{item}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function SummaryMetric({
|
||
label,
|
||
value,
|
||
tone,
|
||
suffix,
|
||
signed,
|
||
}: {
|
||
label: string;
|
||
value: number;
|
||
tone: string;
|
||
suffix?: string;
|
||
signed?: boolean;
|
||
}) {
|
||
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}`}>
|
||
{signed && value > 0 ? "+" : ""}
|
||
{value}
|
||
{suffix ?? ""}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function SummaryFact({ 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">{value}</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function FreshnessCell({ label, value }: { label: string; value: string }) {
|
||
return (
|
||
<div className="rounded-xl bg-surface-1/70 px-3 py-2">
|
||
<div className="text-[10px] text-text-muted">{label}</div>
|
||
<div className="mt-1 text-xs font-mono tabular-nums text-text-secondary">{value}</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function SummaryChip({ 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>
|
||
);
|
||
}
|