移除今日机会榜,简化决策台为三视图(地图/决策卡/日历)

机会榜作为决策卡的聚合摘要层,与决策卡视图功能重叠,增加了
不必要的代码和认知负担。移除后将地图作为默认入口,用户从
地图选城市后直接进入决策卡验证天气证据。
This commit is contained in:
2569718930@qq.com
2026-05-06 15:26:37 +08:00
parent 9c61a8e012
commit cdfde1624c
5 changed files with 7 additions and 952 deletions
@@ -1,216 +0,0 @@
import { memo, useMemo } from "react";
import clsx from "clsx";
import type { ScanOpportunityRow, ScanTerminalResponse } from "@/lib/dashboard-types";
import { formatTemperatureValue } from "@/lib/temperature-utils";
import { LoadingSignal } from "@/components/dashboard/scan-terminal/LoadingSignal";
import {
formatRowPrice,
formatRowProbability,
formatRowSignedPercent,
getPeakCountdownMeta,
getRowDecisionMeta,
getRowTemperatureBucket,
pickOpportunitySections,
} from "@/components/dashboard/scan-terminal/decision-utils";
const OpportunityDecisionCard = memo(function OpportunityDecisionCard({
row,
locale,
selected,
onOpenDecision,
onSelectRow,
}: {
row: ScanOpportunityRow;
locale: string;
selected: boolean;
onOpenDecision: (row: ScanOpportunityRow) => void;
onSelectRow: (row: ScanOpportunityRow) => void;
}) {
const isEn = locale === "en-US";
const displayName = row.city_display_name || row.display_name || row.city;
const unit = row.temp_symbol || row.target_unit || "°C";
const decision = getRowDecisionMeta(row, locale);
const phase = getPeakCountdownMeta(row, locale);
const modelProb = row.model_event_probability ?? row.model_probability ?? row.peak_probability ?? null;
const marketProb = row.market_event_probability ?? row.market_probability ?? null;
const price = row.yes_ask ?? row.ask ?? row.yes_bid ?? row.bid ?? row.midpoint ?? null;
const confidence =
row.ai_confidence ||
row.ai_city_confidence ||
row.ai_forecast_confidence ||
(row.signal_confidence != null ? formatRowProbability(row.signal_confidence) : "--");
const predicted =
row.ai_predicted_max ??
row.deb_prediction ??
row.cluster_center ??
row.current_max_so_far ??
null;
const reason = decision.reason.length > 128 ? `${decision.reason.slice(0, 125)}` : decision.reason;
return (
<article
className={clsx("scan-opportunity-decision-card", decision.tone, selected && "selected")}
onClick={() => onSelectRow(row)}
>
<div className="scan-opportunity-decision-head">
<div>
<span>{phase.title}</span>
<strong>{displayName}</strong>
</div>
<b>{decision.action}</b>
</div>
<div className="scan-opportunity-decision-primary">
<span>
{isEn ? "Forecast high" : "预测最高温"}
<b>{predicted != null ? formatTemperatureValue(predicted, unit, { digits: 1 }) : "--"}</b>
</span>
<span>
{isEn ? "Bucket" : "推荐温度桶"}
<b>{getRowTemperatureBucket(row)}</b>
</span>
<span>
{isEn ? "Edge" : "概率差"}
<b>{formatRowSignedPercent(row.edge_percent ?? row.gap ?? row.signed_gap)}</b>
</span>
</div>
<p>{reason}</p>
<div className="scan-opportunity-decision-foot">
<small>{isEn ? "Model" : "模型"} {formatRowProbability(modelProb)}</small>
<small>{isEn ? "Market" : "市场"} {formatRowProbability(marketProb)}</small>
<small>{isEn ? "YES" : "YES"} {formatRowPrice(price)}</small>
<small>{isEn ? "Confidence" : "信心"} {confidence}</small>
</div>
<button
type="button"
onClick={(event) => {
event.stopPropagation();
onOpenDecision(row);
}}
>
{isEn ? "Open decision card" : "打开决策卡"}
</button>
</article>
);
});
export const OpportunityOverview = memo(function OpportunityOverview({
rows,
terminalData,
loading,
error,
locale,
selectedRowId,
onOpenDecision,
onSelectRow,
onOpenMap,
}: {
rows: ScanOpportunityRow[];
terminalData: ScanTerminalResponse | null;
loading: boolean;
error: string | null;
locale: string;
selectedRowId: string | null;
onOpenDecision: (row: ScanOpportunityRow) => void;
onSelectRow: (row: ScanOpportunityRow) => void;
onOpenMap: () => void;
}) {
const isEn = locale === "en-US";
const sections = useMemo(() => pickOpportunitySections(rows, locale), [locale, rows]);
const visibleSections = useMemo(
() => sections.filter((section) => section.rows.length > 0),
[sections],
);
const summary = terminalData?.summary;
if (loading) {
return (
<div className="scan-opportunity-overview loading">
<LoadingSignal
title={isEn ? "Building todays opportunity board" : "正在生成今日机会榜"}
description={
isEn
? "Syncing market prices, model edge and peak-window timing."
: "正在同步市场价格、模型概率差和峰值窗口。"
}
compact
/>
</div>
);
}
if (error || rows.length === 0) {
return (
<div className="scan-opportunity-overview empty">
<strong>{isEn ? "No opportunity snapshot yet" : "暂无机会快照"}</strong>
<p>
{error ||
(isEn
? "Use the map to add cities, or refresh after the scan backend is ready."
: "可以先用地图添加城市;扫描后端就绪后会显示今日机会榜。")}
</p>
<button type="button" onClick={onOpenMap}>
{isEn ? "Explore map" : "去地图探索"}
</button>
</div>
);
}
return (
<div className="scan-opportunity-overview">
<div className="scan-opportunity-hero">
<div>
<span>{isEn ? "Today AI opportunity board" : "今日 AI 机会榜"}</span>
<strong>{isEn ? "Decide first, verify second" : "先看决策,再展开证据"}</strong>
<p>
{isEn
? "Cards translate weather, METAR and Polymarket pricing into action states."
: "把天气、METAR 与 Polymarket 报价先翻译成行动状态,再让你展开验证。"}
</p>
</div>
<div className="scan-opportunity-summary">
<span>{isEn ? "Candidates" : "候选"} <b>{summary?.candidate_total ?? rows.length}</b></span>
<span>{isEn ? "Tradable" : "可交易市场"} <b>{summary?.tradable_market_count ?? "--"}</b></span>
<span>{isEn ? "Avg edge" : "平均概率差"} <b>{formatRowSignedPercent(summary?.avg_edge_percent)}</b></span>
<span>
{isEn ? "Updated" : "更新时间"}{" "}
<b>
{terminalData?.generated_at
? new Date(terminalData.generated_at).toLocaleTimeString(isEn ? "en-US" : "zh-CN", {
hour: "2-digit",
minute: "2-digit",
})
: "--"}
</b>
</span>
</div>
</div>
<div className="scan-opportunity-lanes">
{visibleSections.map((section) => (
<section key={section.key} className="scan-opportunity-lane">
<div className="scan-opportunity-lane-head">
<div>
<strong>{section.title}</strong>
<p>{section.subtitle}</p>
</div>
<span>{section.rows.length}</span>
</div>
<div className="scan-opportunity-card-grid">
{section.rows.map((row) => (
<OpportunityDecisionCard
key={`${section.key}-${row.id}`}
row={row}
locale={locale}
selected={selectedRowId === row.id}
onOpenDecision={onOpenDecision}
onSelectRow={onSelectRow}
/>
))}
</div>
</section>
))}
</div>
</div>
);
});
@@ -195,50 +195,6 @@ export function normalizeCityKey(value?: string | null) {
.replace(/[\s_-]+/g, "");
}
function getOpportunityCardKey(row: ScanOpportunityRow) {
const city =
normalizeCityKey(row.city) ||
normalizeCityKey(row.city_display_name) ||
normalizeCityKey(row.display_name);
const date = String(row.selected_date || row.local_date || "").trim();
if (city || date) {
return `${city || row.id}:${date || "date-unknown"}`;
}
return row.id;
}
function getOpportunitySortScore(row: ScanOpportunityRow) {
return Number(row.final_score || 0) * 1000 + Number(row.edge_percent || 0);
}
function dedupeOpportunityCards(rows: ScanOpportunityRow[]) {
const bestByCard = new Map<string, ScanOpportunityRow>();
for (const row of rows) {
const key = getOpportunityCardKey(row);
const current = bestByCard.get(key);
if (!current || getOpportunitySortScore(row) > getOpportunitySortScore(current)) {
bestByCard.set(key, row);
}
}
return [...bestByCard.values()];
}
function takeUniqueOpportunityRows(
candidates: ScanOpportunityRow[],
usedCardKeys: Set<string>,
limit: number,
) {
const picked: ScanOpportunityRow[] = [];
for (const row of candidates) {
const key = getOpportunityCardKey(row);
if (usedCardKeys.has(key)) continue;
usedCardKeys.add(key);
picked.push(row);
if (picked.length >= limit) break;
}
return picked;
}
export function prettifyCityName(value?: string | null) {
return String(value || "")
.trim()
@@ -259,165 +215,3 @@ export function findRowForCity(rows: ScanOpportunityRow[], cityName?: string | n
if (!normalized) return null;
return rows.find((row) => rowMatchesCity(row, cityName || "")) || null;
}
export function formatRowProbability(value?: number | null) {
const numeric = Number(value);
if (!Number.isFinite(numeric)) return "--";
const normalized = numeric > 1 ? numeric / 100 : numeric;
return `${(normalized * 100).toFixed(0)}%`;
}
export function formatRowPrice(value?: number | null) {
const numeric = Number(value);
if (!Number.isFinite(numeric)) return "--";
return `${Math.round((numeric > 1 ? numeric / 100 : numeric) * 100)}¢`;
}
export function formatRowSignedPercent(value?: number | null) {
const numeric = Number(value);
if (!Number.isFinite(numeric)) return "--";
const normalized = Math.abs(numeric) <= 1 ? numeric * 100 : numeric;
return `${normalized > 0 ? "+" : ""}${normalized.toFixed(1)}%`;
}
export function normalizeRowPercentDelta(value?: number | null) {
const numeric = Number(value);
if (!Number.isFinite(numeric)) return null;
return Math.abs(numeric) <= 1 ? numeric * 100 : numeric;
}
export function getRowTemperatureBucket(row: ScanOpportunityRow) {
const direct = String(row.target_label || "").trim();
if (direct) return direct;
const unit = row.target_unit || row.temp_symbol || "°C";
const lower = Number(row.target_lower);
const upper = Number(row.target_upper);
if (Number.isFinite(lower) && Number.isFinite(upper)) {
return `${lower.toFixed(0)}-${upper.toFixed(0)}${unit}`;
}
const threshold = Number(row.target_threshold ?? row.target_value);
if (Number.isFinite(threshold)) {
const direction = String(row.temperature_direction || row.market_direction || row.side || "").toLowerCase();
if (direction.includes("below") || direction.includes("under") || direction.includes("no")) {
return `${threshold.toFixed(0)}${unit}`;
}
return `${threshold.toFixed(0)}${unit}`;
}
return "--";
}
export function getRowDecisionMeta(row: ScanOpportunityRow, locale = "zh-CN") {
const isEn = locale === "en-US";
const edge = normalizeRowPercentDelta(row.edge_percent ?? row.gap ?? row.signed_gap);
const phase = getPeakCountdownMeta(row, locale);
const metarDecision = String(row.v4_metar_decision || row.ai_decision || "").toLowerCase();
const tradable = Boolean(row.tradable || row.accepting_orders);
const closed = row.closed || (row.active === false && !tradable);
if (closed) {
return {
tone: "avoid",
action: isEn ? "Skip" : "放弃",
reason: isEn ? "Market is closed or inactive." : "市场已关闭或不活跃。",
};
}
if (metarDecision === "veto") {
return {
tone: "avoid",
action: isEn ? "Avoid" : "暂不交易",
reason:
(isEn ? row.v4_metar_reason_en || row.ai_reason_en : row.v4_metar_reason_zh || row.ai_reason_zh) ||
(isEn ? "METAR does not support the setup." : "METAR 暂不支持该方向。"),
};
}
if (edge != null && edge >= 8 && tradable) {
return {
tone: "trade",
action: isEn ? "Watch now" : "重点关注",
reason:
(isEn ? row.ai_reason_en || row.ai_city_thesis_en : row.ai_reason_zh || row.ai_city_thesis_zh) ||
(isEn ? "Weather probability is above market pricing." : "天气概率高于市场隐含概率。"),
};
}
if (phase.key === "next" || phase.key === "today") {
return {
tone: "wait",
action: isEn ? "Wait for confirmation" : "等待确认",
reason:
(isEn ? row.ai_watchlist_reason_en || row.ai_forecast_match_reason_en : row.ai_watchlist_reason_zh || row.ai_forecast_match_reason_zh) ||
phase.title,
};
}
if (metarDecision === "downgrade" || row.risk_level === "high") {
return {
tone: "risk",
action: isEn ? "Observe only" : "只观察",
reason:
(isEn ? row.v4_metar_reason_en || row.ai_reason_en : row.v4_metar_reason_zh || row.ai_reason_zh) ||
(isEn ? "Risk is elevated; require more confirmation." : "风险偏高,需要更多确认。"),
};
}
return {
tone: "neutral",
action: isEn ? "Review" : "观察",
reason:
(isEn ? row.ai_reason_en || row.ai_city_thesis_en : row.ai_reason_zh || row.ai_city_thesis_zh) ||
(isEn ? "Open the decision card to verify weather evidence." : "打开决策卡查看天气证据。"),
};
}
export function pickOpportunitySections(rows: ScanOpportunityRow[], locale = "zh-CN") {
const isEn = locale === "en-US";
const usedCardKeys = new Set<string>();
const uniqueRows = dedupeOpportunityCards(rows);
const top = takeUniqueOpportunityRows([...uniqueRows]
.sort((left, right) => {
const scoreDelta = Number(right.final_score || 0) - Number(left.final_score || 0);
if (scoreDelta !== 0) return scoreDelta;
return Number(right.edge_percent || 0) - Number(left.edge_percent || 0);
}), usedCardKeys, 4);
const peak = takeUniqueOpportunityRows(
uniqueRows.filter((row) => {
const meta = getPeakCountdownMeta(row, locale);
return meta.key === "active" || meta.key === "next";
}),
usedCardKeys,
4,
);
const model = takeUniqueOpportunityRows(
uniqueRows.filter((row) => Number(row.cluster_model_count || 0) >= 4 || Number(row.consensus_score || 0) >= 0.65),
usedCardKeys,
4,
);
const risk = takeUniqueOpportunityRows(
uniqueRows.filter((row) => row.risk_level === "high" || ["veto", "downgrade"].includes(String(row.v4_metar_decision || row.ai_decision || "").toLowerCase())),
usedCardKeys,
4,
);
return [
{
key: "top",
title: isEn ? "Best opportunities" : "最值得关注",
subtitle: isEn ? "Sorted by final score and edge." : "按综合分与概率差优先排序。",
rows: top,
},
{
key: "peak",
title: isEn ? "Peak window soon" : "即将进入峰值窗口",
subtitle: isEn ? "Timing-sensitive cities." : "需要卡时间确认的城市。",
rows: peak,
},
{
key: "model",
title: isEn ? "Model consensus" : "模型高度一致",
subtitle: isEn ? "Weather side has stronger model support." : "天气侧模型支撑更集中。",
rows: model,
},
{
key: "risk",
title: isEn ? "High risk / avoid" : "高风险 / 不要碰",
subtitle: isEn ? "Open only for post-mortem or monitoring." : "仅适合复盘或观察。",
rows: risk,
},
];
}