astock-agent/frontend/src/app/(auth)/recommendations/page.tsx
2026-06-08 11:11:05 +08:00

705 lines
30 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, 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>
);
}