745 lines
29 KiB
TypeScript
745 lines
29 KiB
TypeScript
"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);
|
||
setMarketTemperature(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 = await fetchAPI<StrategyBoard>("/api/market/strategy-board").catch(() => null);
|
||
const ops = user?.role === "admin"
|
||
? await fetchAPI<OpsStatusResponse>("/api/market/ops-status").catch(() => null)
|
||
: null;
|
||
setStrategyBoard(board);
|
||
setOpsStatus(ops);
|
||
} catch (error) {
|
||
console.error("加载次要数据失败:", error);
|
||
}
|
||
}, [user?.role]);
|
||
|
||
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}
|
||
|
||
<div className="animate-fade-in-up">
|
||
<DecisionHero
|
||
board={strategyBoard}
|
||
summary={marketSummary}
|
||
actions={todayActions}
|
||
marketTemperature={marketTemperature ?? data?.market_temperature ?? null}
|
||
sectors={sectors}
|
||
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,
|
||
actions,
|
||
marketTemperature,
|
||
sectors,
|
||
focusQueue,
|
||
actionableCount,
|
||
watchCount,
|
||
observeCount,
|
||
}: {
|
||
board: StrategyBoard | null;
|
||
summary: ReturnType<typeof buildMarketSummary>;
|
||
actions: ReturnType<typeof buildActionGuides>;
|
||
marketTemperature: MarketTemperatureData | null;
|
||
sectors: SectorData[];
|
||
focusQueue: RecommendationData[];
|
||
actionableCount: number;
|
||
watchCount: number;
|
||
observeCount: number;
|
||
}) {
|
||
const leadingSectors = sectors.slice(0, 3);
|
||
|
||
return (
|
||
<div className="glass-card-static overflow-hidden">
|
||
<div className="grid grid-cols-1 xl:grid-cols-[minmax(0,1.2fr)_360px]">
|
||
<div className="p-5 md:p-6">
|
||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||
<span className="text-[10px] font-semibold uppercase tracking-[0.22em] text-amber-400">今日作战台</span>
|
||
<div className="flex flex-wrap gap-2">
|
||
<CompactBadge label="打法" value={summary.modeLabel} />
|
||
<CompactBadge label="仓位" value={summary.positionLabel} />
|
||
<CompactBadge label="风险" value={board?.risk_level ?? "等待更新"} />
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mt-5 grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_160px] gap-4">
|
||
<div>
|
||
<h2 className="text-3xl md:text-4xl font-bold tracking-tight text-text-primary">{summary.headline}</h2>
|
||
<p className="mt-3 max-w-3xl text-sm leading-6 text-text-secondary">{summary.detail}</p>
|
||
</div>
|
||
<div className="rounded-2xl border border-border-subtle bg-surface-1/70 p-4 text-center">
|
||
<div className="text-[10px] uppercase tracking-wider text-text-muted">市场温度</div>
|
||
<div className="mt-1 text-3xl font-bold font-mono tabular-nums text-amber-400">
|
||
{Math.round(marketTemperature?.temperature ?? 0)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mt-5 grid grid-cols-1 md:grid-cols-3 gap-3">
|
||
<ActionBucket title="现在做" items={actions.priority.slice(0, 2)} tone="do" />
|
||
<ActionBucket title="等待确认" items={actions.watch.slice(0, 2)} tone="wait" />
|
||
<ActionBucket title="不要做" items={actions.avoid.slice(0, 2)} tone="avoid" />
|
||
</div>
|
||
|
||
{leadingSectors.length ? (
|
||
<div className="mt-5 flex flex-wrap gap-2">
|
||
{leadingSectors.map((sector) => (
|
||
<SectorChip key={sector.sector_code} sector={sector} />
|
||
))}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
|
||
<div className="border-t border-border-subtle bg-surface-1/50 p-5 xl:border-l xl:border-t-0">
|
||
<div className="grid grid-cols-3 gap-2">
|
||
<HeroMetric label="可操作" value={actionableCount} tone="text-red-400" />
|
||
<HeroMetric label="关注" value={watchCount} tone="text-amber-400" />
|
||
<HeroMetric label="观察" value={observeCount} tone="text-text-secondary" />
|
||
</div>
|
||
|
||
<div className="mt-5">
|
||
<div className="text-[11px] font-semibold text-text-secondary">焦点标的</div>
|
||
<div className="mt-3 space-y-2.5">
|
||
{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>
|
||
</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 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 ActionBucket({ title, items, tone }: { title: string; items: string[]; tone: "do" | "wait" | "avoid" }) {
|
||
const toneClass =
|
||
tone === "do"
|
||
? "text-emerald-400 bg-emerald-500/[0.06] border-emerald-500/10"
|
||
: tone === "wait"
|
||
? "text-amber-400 bg-amber-500/[0.06] border-amber-500/10"
|
||
: "text-text-muted bg-surface-2/70 border-border-subtle";
|
||
|
||
return (
|
||
<div className={`rounded-2xl border p-3 ${toneClass}`}>
|
||
<div className="text-[11px] font-semibold">{title}</div>
|
||
<div className="mt-2 space-y-2">
|
||
{items.map((item, index) => (
|
||
<div key={`${title}-${index}`} className="text-sm leading-6 text-text-secondary">
|
||
{item}
|
||
</div>
|
||
))}
|
||
</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 SectorChip({ sector }: { sector: SectorData }) {
|
||
const pct = sector.realtime_pct_change ?? sector.pct_change;
|
||
return (
|
||
<span className="inline-flex items-center gap-2 rounded-xl border border-border-subtle bg-surface-2/70 px-3 py-1.5 text-xs">
|
||
<span className="max-w-[8rem] truncate text-text-secondary">{sector.sector_name}</span>
|
||
<span className={`font-mono tabular-nums ${pct >= 0 ? "text-red-400" : "text-emerald-400"}`}>
|
||
{pct >= 0 ? "+" : ""}{pct.toFixed(2)}%
|
||
</span>
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function FocusStockCard({ rec }: { rec: RecommendationData }) {
|
||
const stripeClass =
|
||
rec.action_plan === "可操作"
|
||
? "bg-red-400"
|
||
: rec.action_plan === "重点关注"
|
||
? "bg-amber-400"
|
||
: "bg-text-muted";
|
||
const labelClass =
|
||
rec.action_plan === "可操作"
|
||
? "text-red-400"
|
||
: rec.action_plan === "重点关注"
|
||
? "text-amber-400"
|
||
: "text-text-muted";
|
||
|
||
return (
|
||
<a
|
||
href={`/stock/${rec.ts_code}`}
|
||
className="group grid grid-cols-[3px_minmax(0,1fr)] gap-3 rounded-lg px-1 py-2 transition-colors hover:bg-surface-2/60"
|
||
>
|
||
<span className={`mt-1 h-[calc(100%-0.5rem)] rounded-full ${stripeClass}`} />
|
||
<div className="min-w-0">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<div className="min-w-0 flex items-center gap-2">
|
||
<span className="truncate text-sm font-semibold text-text-primary">{rec.name}</span>
|
||
<span className={`shrink-0 text-[10px] font-medium ${labelClass}`}>{rec.action_plan ?? "观察"}</span>
|
||
</div>
|
||
{rec.suggested_position_pct != null ? (
|
||
<span className="shrink-0 font-mono text-[11px] tabular-nums text-text-muted">{rec.suggested_position_pct}%</span>
|
||
) : null}
|
||
</div>
|
||
|
||
<div className="mt-1 flex min-w-0 items-center gap-1.5 text-[11px] text-text-muted">
|
||
<span className="font-mono tabular-nums">{rec.ts_code}</span>
|
||
{rec.sector ? (
|
||
<>
|
||
<span className="text-text-muted/50">/</span>
|
||
<span className="truncate text-text-secondary">{rec.sector}</span>
|
||
</>
|
||
) : null}
|
||
</div>
|
||
|
||
<div className="mt-1.5 text-xs leading-5 text-text-secondary line-clamp-2">
|
||
{rec.decision_trace?.headline ?? rec.trigger_condition ?? rec.entry_timing ?? rec.reasons?.[0] ?? "等待新的触发条件。"}
|
||
</div>
|
||
|
||
{(rec.invalidation_condition || rec.risk_note) ? (
|
||
<div className="mt-1 text-[11px] leading-5 text-text-muted line-clamp-1">
|
||
失效: {rec.invalidation_condition ?? rec.risk_note}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</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 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}% 以内` : "等待更新"),
|
||
};
|
||
}
|
||
|
||
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 };
|
||
}
|