移除今日机会榜,简化决策台为三视图(地图/决策卡/日历)
机会榜作为决策卡的聚合摘要层,与决策卡视图功能重叠,增加了 不必要的代码和认知负担。移除后将地图作为默认入口,用户从 地图选城市后直接进入决策卡验证天气证据。
This commit is contained in:
@@ -1,396 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { BarChart3, ChevronDown, ChevronUp } from "lucide-react";
|
||||
import React from "react";
|
||||
import { useI18n } from "@/hooks/useI18n";
|
||||
import type {
|
||||
CityDetail,
|
||||
ScanOpportunityRow,
|
||||
} from "@/lib/dashboard-types";
|
||||
import {
|
||||
formatTemperatureValue,
|
||||
normalizeTemperatureSymbol,
|
||||
} from "@/lib/temperature-utils";
|
||||
import {
|
||||
buildOpportunityGroups,
|
||||
formatWindowMinutes,
|
||||
getAiMeta,
|
||||
getBucketDisplayLabel,
|
||||
getDebDistanceSummary,
|
||||
getDecisionReasonItems,
|
||||
getDetailForRow,
|
||||
getForecastRangeLabel,
|
||||
getForecastRiskItems,
|
||||
getMetarConflictSummary,
|
||||
getModelSupportSummary,
|
||||
getPaceSignalLabel,
|
||||
getThresholdDecision,
|
||||
getV4CityForecast,
|
||||
} from "./OpportunityTable.utils";
|
||||
|
||||
export { getWindowPhaseMeta } from "./OpportunityTable.utils";
|
||||
|
||||
export const OpportunityTable = React.memo(function OpportunityTable({
|
||||
rows,
|
||||
status,
|
||||
stale,
|
||||
staleReason,
|
||||
loading,
|
||||
selectedRowId,
|
||||
onSelectRow,
|
||||
cityDetailsByName,
|
||||
}: {
|
||||
rows: ScanOpportunityRow[];
|
||||
status?: string | null;
|
||||
stale?: boolean;
|
||||
staleReason?: string | null;
|
||||
loading?: boolean;
|
||||
selectedRowId?: string | null;
|
||||
onSelectRow?: (row: ScanOpportunityRow) => void;
|
||||
cityDetailsByName?: Record<string, CityDetail>;
|
||||
}) {
|
||||
const { locale } = useI18n();
|
||||
const isEn = locale === "en-US";
|
||||
const hasRows = rows.length > 0;
|
||||
const scanInProgress =
|
||||
loading || status === "partial" || status === "scanning";
|
||||
const groups = React.useMemo(
|
||||
() => buildOpportunityGroups(rows, locale, cityDetailsByName),
|
||||
[rows, locale, cityDetailsByName],
|
||||
);
|
||||
const [expandedRowIds, setExpandedRowIds] = React.useState<Set<string>>(
|
||||
() => new Set(),
|
||||
);
|
||||
|
||||
const toggleExpandedRow = React.useCallback((rowId: string) => {
|
||||
setExpandedRowIds((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(rowId)) {
|
||||
next.delete(rowId);
|
||||
} else {
|
||||
next.add(rowId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const ensureExpandedRow = React.useCallback((rowId: string) => {
|
||||
setExpandedRowIds((current) => {
|
||||
if (current.has(rowId)) return current;
|
||||
const next = new Set(current);
|
||||
next.add(rowId);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const selectAndOpenRow = React.useCallback(
|
||||
(row: ScanOpportunityRow) => {
|
||||
ensureExpandedRow(row.id);
|
||||
onSelectRow?.(row);
|
||||
},
|
||||
[ensureExpandedRow, onSelectRow],
|
||||
);
|
||||
|
||||
const toggleRowAnalysis = React.useCallback(
|
||||
(row: ScanOpportunityRow) => {
|
||||
toggleExpandedRow(row.id);
|
||||
onSelectRow?.(row);
|
||||
},
|
||||
[onSelectRow, toggleExpandedRow],
|
||||
);
|
||||
|
||||
if (!hasRows) {
|
||||
const title =
|
||||
scanInProgress
|
||||
? isEn
|
||||
? "Scanning markets"
|
||||
: "正在扫描市场"
|
||||
: status === "failed"
|
||||
? isEn
|
||||
? "Scan failed"
|
||||
: "扫描失败"
|
||||
: isEn
|
||||
? "No tradable market right now"
|
||||
: "当前暂无可交易市场";
|
||||
const copy =
|
||||
scanInProgress
|
||||
? isEn
|
||||
? "Waiting for the latest market snapshot. Existing data will stay on screen when available."
|
||||
: "正在等待最新市场快照;如果有旧数据,会继续保留在页面上。"
|
||||
: status === "failed"
|
||||
? staleReason || (isEn ? "No valid market snapshot is available." : "当前没有可用的市场快照。")
|
||||
: isEn
|
||||
? "The current snapshot does not contain a tradable main signal."
|
||||
: "当前快照里还没有可交易的主信号。";
|
||||
return (
|
||||
<div className="scan-table-shell empty">
|
||||
<div className="scan-empty-state">
|
||||
<div className="scan-empty-title">{title}</div>
|
||||
<div className="scan-empty-copy">{copy}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="scan-table-shell">
|
||||
{stale ? (
|
||||
<div className="scan-table-banner">
|
||||
<strong>{isEn ? "Showing delayed snapshot" : "当前显示延迟快照"}</strong>
|
||||
<span>{staleReason || (isEn ? "Latest refresh failed, fallback to the last successful scan." : "最新刷新失败,已回退到上次成功扫描结果。")}</span>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="scan-table-body scan-opportunity-groups scan-forecast-desk">
|
||||
{groups.map((group) => {
|
||||
const groupSelected = group.rows.some((row) => row.id === selectedRowId);
|
||||
const firstRow = group.rows[0];
|
||||
const firstTempSymbol = normalizeTemperatureSymbol(
|
||||
firstRow?.target_unit || firstRow?.temp_symbol || group.tempSymbol,
|
||||
);
|
||||
const firstDetail = firstRow
|
||||
? getDetailForRow(firstRow, cityDetailsByName)
|
||||
: null;
|
||||
const groupForecast = firstRow
|
||||
? getV4CityForecast(firstRow, group, firstDetail, locale, firstTempSymbol)
|
||||
: null;
|
||||
const groupForecastLabel =
|
||||
groupForecast?.predicted != null
|
||||
? formatTemperatureValue(groupForecast.predicted, firstTempSymbol, { digits: 1 })
|
||||
: "--";
|
||||
const groupRangeLabel = groupForecast
|
||||
? getForecastRangeLabel(groupForecast, firstTempSymbol)
|
||||
: group.peakLabel;
|
||||
return (
|
||||
<section
|
||||
key={group.key}
|
||||
className={`scan-opportunity-group scan-forecast-city-card ${groupSelected ? "selected" : ""}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="scan-opportunity-group-head scan-forecast-city-head"
|
||||
onClick={() => {
|
||||
const firstRow = group.rows[0];
|
||||
if (firstRow) selectAndOpenRow(firstRow);
|
||||
}}
|
||||
>
|
||||
<div className="scan-forecast-city-title">
|
||||
<span className="scan-forecast-kicker">
|
||||
{isEn ? "City max-temp read" : "城市最高温判断"}
|
||||
</span>
|
||||
<strong>{group.cityName}</strong>
|
||||
<div className="scan-forecast-city-chips">
|
||||
<span>{group.localTime || "--"}</span>
|
||||
<span>{formatWindowMinutes(group.remainingMinutes, locale)}</span>
|
||||
<span>DEB {group.debLabel}</span>
|
||||
<span>{isEn ? "Models" : "模型"} {group.peakLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="scan-forecast-city-read">
|
||||
<small>{isEn ? "AI expected high" : "AI 预计最高温"}</small>
|
||||
<b>{groupForecastLabel}</b>
|
||||
<span>{isEn ? "Range" : "区间"} {groupRangeLabel}</span>
|
||||
<b className={`scan-phase-badge ${group.phaseMeta.tone}`}>
|
||||
{group.phaseMeta.label}
|
||||
</b>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div className="scan-opportunity-items">
|
||||
{group.rows.map((row) => {
|
||||
const tempSymbol = normalizeTemperatureSymbol(row.target_unit || row.temp_symbol);
|
||||
const detail = getDetailForRow(row, cityDetailsByName);
|
||||
const debDistanceLabel = isEn ? "DEB distance" : "DEB 距离";
|
||||
const modelSupportLabel = isEn ? "Model support" : "模型支持";
|
||||
const metarLabel = "METAR";
|
||||
const paceLabel = isEn ? "Path vs DEB" : "路径偏差";
|
||||
const debDistanceText = getDebDistanceSummary(row, locale, tempSymbol);
|
||||
const modelSupportText = getModelSupportSummary(row, locale);
|
||||
const metarConflictText = getMetarConflictSummary(row, detail, locale);
|
||||
const cityForecast = getV4CityForecast(
|
||||
row,
|
||||
group,
|
||||
detail,
|
||||
locale,
|
||||
tempSymbol,
|
||||
);
|
||||
const paceSignalText = getPaceSignalLabel(cityForecast, locale, tempSymbol);
|
||||
const aiMeta = getAiMeta(row, locale);
|
||||
const thresholdDecision = getThresholdDecision(
|
||||
row,
|
||||
cityForecast,
|
||||
locale,
|
||||
tempSymbol,
|
||||
);
|
||||
const expanded = expandedRowIds.has(row.id);
|
||||
const shortConclusion =
|
||||
`${thresholdDecision.headline}。${thresholdDecision.summary}`;
|
||||
const keyReasons = getDecisionReasonItems(
|
||||
row,
|
||||
cityForecast,
|
||||
modelSupportText,
|
||||
locale,
|
||||
tempSymbol,
|
||||
);
|
||||
const riskItems = getForecastRiskItems(
|
||||
row,
|
||||
detail,
|
||||
cityForecast,
|
||||
locale,
|
||||
tempSymbol,
|
||||
);
|
||||
const bucketLabel = getBucketDisplayLabel(row, locale, tempSymbol);
|
||||
const forecastRangeLabel = getForecastRangeLabel(cityForecast, tempSymbol);
|
||||
return (
|
||||
<div
|
||||
key={row.id}
|
||||
className={`scan-opportunity-item scan-forecast-row ${selectedRowId === row.id ? "selected" : ""} ${expanded ? "expanded" : ""} ai-fit-${thresholdDecision.tone} ${aiMeta ? `ai-${aiMeta.tone}` : ""}`}
|
||||
onClick={() => selectAndOpenRow(row)}
|
||||
>
|
||||
<div className="scan-forecast-row-main">
|
||||
<div className="scan-forecast-bucket">
|
||||
<span>{isEn ? "Conclusion" : "最终判断"}</span>
|
||||
<strong>{thresholdDecision.headline}</strong>
|
||||
<small>{thresholdDecision.summary}</small>
|
||||
</div>
|
||||
<div className="scan-forecast-signals">
|
||||
<span>
|
||||
<small>{debDistanceLabel}</small>
|
||||
<b>{debDistanceText}</b>
|
||||
</span>
|
||||
<span>
|
||||
<small>{modelSupportLabel}</small>
|
||||
<b>{modelSupportText}</b>
|
||||
</span>
|
||||
<span>
|
||||
<small>{metarLabel}</small>
|
||||
<b>{metarConflictText}</b>
|
||||
</span>
|
||||
<span>
|
||||
<small>{paceLabel}</small>
|
||||
<b>{paceSignalText}</b>
|
||||
</span>
|
||||
</div>
|
||||
<span className={`scan-forecast-fit ${thresholdDecision.tone}`}>
|
||||
{thresholdDecision.relation}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="scan-opportunity-expand"
|
||||
aria-expanded={expanded}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
toggleRowAnalysis(row);
|
||||
}}
|
||||
>
|
||||
<BarChart3 size={14} />
|
||||
{expanded
|
||||
? isEn
|
||||
? "Hide analysis"
|
||||
: "收起分析"
|
||||
: isEn
|
||||
? "AI analysis"
|
||||
: "AI 分析"}
|
||||
{expanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
<div className={`scan-forecast-ai-line ${aiMeta?.tone || "neutral"}`}>
|
||||
<b>{isEn ? "Current read" : "当前判断"}</b>
|
||||
<small>{shortConclusion}</small>
|
||||
</div>
|
||||
{expanded ? (
|
||||
<div className="scan-ai-analysis">
|
||||
<div className="scan-ai-analysis-head">
|
||||
<div>
|
||||
<strong>{isEn ? "Conclusion" : "结论"}</strong>
|
||||
<p>{thresholdDecision.headline}</p>
|
||||
<small>{thresholdDecision.summary}</small>
|
||||
</div>
|
||||
<span className={`scan-ai-forecast-pill ${thresholdDecision.tone}`}>
|
||||
{thresholdDecision.relation}
|
||||
</span>
|
||||
</div>
|
||||
<div className="scan-ai-temperature-line">
|
||||
<span>
|
||||
<small>{isEn ? "Bucket" : "判断对象"}</small>
|
||||
<b>{bucketLabel}</b>
|
||||
</span>
|
||||
<span>
|
||||
<small>{isEn ? "AI forecast" : "AI 预测"}</small>
|
||||
<b>
|
||||
{cityForecast.predicted != null
|
||||
? formatTemperatureValue(cityForecast.predicted, tempSymbol, { digits: 1 })
|
||||
: "--"}
|
||||
</b>
|
||||
</span>
|
||||
<span>
|
||||
<small>{isEn ? "Forecast range" : "预测区间"}</small>
|
||||
<b>{forecastRangeLabel}</b>
|
||||
</span>
|
||||
<span>
|
||||
<small>{isEn ? "Confidence" : "信心"}</small>
|
||||
<b>{cityForecast.confidence || thresholdDecision.confidence}</b>
|
||||
</span>
|
||||
</div>
|
||||
<div className="scan-ai-evidence-line">
|
||||
<span>
|
||||
<small>DEB</small>
|
||||
<b>{group.debLabel}</b>
|
||||
</span>
|
||||
<span>
|
||||
<small>{modelSupportLabel}</small>
|
||||
<b>{modelSupportText}</b>
|
||||
</span>
|
||||
<span>
|
||||
<small>METAR</small>
|
||||
<b>{metarConflictText}</b>
|
||||
</span>
|
||||
<span>
|
||||
<small>{paceLabel}</small>
|
||||
<b>{paceSignalText}</b>
|
||||
</span>
|
||||
</div>
|
||||
<div className="scan-ai-brief-grid">
|
||||
<section>
|
||||
<strong>{isEn ? "Why" : "为什么"}</strong>
|
||||
<ul>
|
||||
{keyReasons.map((reason) => (
|
||||
<li key={reason}>{reason}</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
<section>
|
||||
<strong>{isEn ? "What can change it" : "可能改变判断的因素"}</strong>
|
||||
<ul>
|
||||
{riskItems.map((reason) => (
|
||||
<li key={reason}>{reason}</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
<div className="scan-ai-airport-read">
|
||||
{cityForecast.airportRead ? (
|
||||
<p>{cityForecast.airportRead}</p>
|
||||
) : null}
|
||||
{cityForecast.weatherRead ? (
|
||||
<p>{cityForecast.weatherRead}</p>
|
||||
) : null}
|
||||
{cityForecast.paceRead ? (
|
||||
<p>{cityForecast.paceRead}</p>
|
||||
) : null}
|
||||
{cityForecast.peakWindow ? (
|
||||
<p>{cityForecast.peakWindow}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -1,87 +0,0 @@
|
||||
export { getWindowPhaseMeta, type PhaseMeta } from "./opportunity-window-phase";
|
||||
export { getLocalizedRowText } from "./opportunity-copy";
|
||||
export {
|
||||
getAiMeta,
|
||||
getExclusionReasons,
|
||||
getOpportunityStrength,
|
||||
getRecommendationReasons,
|
||||
getRiskHints,
|
||||
getShortAiConclusion,
|
||||
} from "./opportunity-ai-meta";
|
||||
export {
|
||||
getDebDistanceSummary,
|
||||
getMetarConflictSummary,
|
||||
getModelSupportSummary,
|
||||
} from "./opportunity-evidence-summary";
|
||||
export type { V4CityForecast, V4TradeDecision } from "./opportunity-v4-types";
|
||||
export {
|
||||
getForecastContractFit,
|
||||
getForecastRangeLabel,
|
||||
getPaceDecisionTail,
|
||||
getPaceDeviationRead,
|
||||
getPaceSignalLabel,
|
||||
getV4CityForecast,
|
||||
median,
|
||||
} from "./opportunity-v4-forecast";
|
||||
export {
|
||||
getForecastFitMeta,
|
||||
getThresholdDecision,
|
||||
getV4DecisionLabel,
|
||||
getV4TradeDecision,
|
||||
} from "./opportunity-v4-decision";
|
||||
export {
|
||||
getDecisionReasonItems,
|
||||
getForecastRiskItems,
|
||||
} from "./opportunity-v4-risk";
|
||||
export {
|
||||
getDetailForRow,
|
||||
getDetailViewDate,
|
||||
normalizeLookupKey,
|
||||
} from "./opportunity-detail";
|
||||
export {
|
||||
bucketMatchesRow,
|
||||
buildOpportunityGroups,
|
||||
getBucketDisplayLabel,
|
||||
getBucketText,
|
||||
getDetailBucketEventProbability,
|
||||
type OpportunityGroup,
|
||||
} from "./opportunity-groups";
|
||||
export {
|
||||
formatModelClusterRange,
|
||||
formatModelSources,
|
||||
getModelSourceSummary,
|
||||
} from "./opportunity-model-summary";
|
||||
export {
|
||||
formatAction,
|
||||
formatMinuteSpan,
|
||||
formatPercent,
|
||||
formatQuoteCents,
|
||||
formatTemperatureDelta,
|
||||
formatThreshold,
|
||||
formatTradeSide,
|
||||
formatWindowMinutes,
|
||||
normalizeProbability,
|
||||
} from "./opportunity-format";
|
||||
export {
|
||||
extractNumbers,
|
||||
getTargetRange,
|
||||
normalizeBucketLabel,
|
||||
} from "./opportunity-target";
|
||||
export {
|
||||
formatPeakWindowTiming,
|
||||
getMetarGate,
|
||||
getMetarObservationContext,
|
||||
getObservationSortMinutes,
|
||||
firstNonEmptyPoints,
|
||||
normalizeObservationPoints,
|
||||
type ObservationPoint,
|
||||
} from "./opportunity-observation";
|
||||
export {
|
||||
decodeMetarWeatherToken,
|
||||
decodeRawMetarCloud,
|
||||
decodeRawMetarVisibility,
|
||||
decodeRawMetarWeather,
|
||||
formatAirportReportRead,
|
||||
formatAirportWeatherRead,
|
||||
getAirportWeatherInputs,
|
||||
} from "./opportunity-airport-read";
|
||||
@@ -54,7 +54,6 @@ import { AiPinnedForecastView } from "@/components/dashboard/scan-terminal/AiPin
|
||||
import { CalendarView } from "@/components/dashboard/scan-terminal/CalendarView";
|
||||
import { AiForecastKPIBar } from "@/components/dashboard/scan-terminal/AiForecastKPIBar";
|
||||
import { LoadingSignal } from "@/components/dashboard/scan-terminal/LoadingSignal";
|
||||
import { OpportunityOverview } from "@/components/dashboard/scan-terminal/OpportunityOverview";
|
||||
import { findDetailForCity } from "@/components/dashboard/scan-terminal/city-detail-utils";
|
||||
import {
|
||||
findRowForCity,
|
||||
@@ -69,7 +68,7 @@ import {
|
||||
useUserLocalClock,
|
||||
} from "@/components/dashboard/scan-terminal/use-scan-terminal-ui-state";
|
||||
|
||||
type ContentView = "opportunities" | "analysis" | "map" | "calendar";
|
||||
type ContentView = "analysis" | "map" | "calendar";
|
||||
|
||||
const CityDetailPanel = dynamic(
|
||||
() =>
|
||||
@@ -113,7 +112,7 @@ function ScanTerminalScreen() {
|
||||
proAccessLoading: store.proAccess.loading,
|
||||
});
|
||||
const [selectedRowId, setSelectedRowId] = useState<string | null>(null);
|
||||
const [activeView, setActiveView] = useState<ContentView>("opportunities");
|
||||
const [activeView, setActiveView] = useState<ContentView>("map");
|
||||
const [mapSelectedCityName, setMapSelectedCityName] = useState<string | null>(null);
|
||||
const [showScanPaywall, setShowScanPaywall] = useState(false);
|
||||
const userLocalTime = useUserLocalClock();
|
||||
@@ -355,36 +354,6 @@ function ScanTerminalScreen() {
|
||||
}, []);
|
||||
|
||||
const renderMainView = () => {
|
||||
if (resolvedView === "opportunities") {
|
||||
if (!isPro) {
|
||||
return (
|
||||
<div className="scan-opportunity-overview empty">
|
||||
<strong>{isEn ? "Opportunity board is Pro" : "今日机会榜需 Pro 权限"}</strong>
|
||||
<p>
|
||||
{isEn
|
||||
? "Map exploration and city briefing are still available."
|
||||
: "地图探索和城市简报仍可使用。"}
|
||||
</p>
|
||||
<button type="button" onClick={openScanPaywall}>
|
||||
{isEn ? "Unlock opportunity board" : "解锁机会榜"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<OpportunityOverview
|
||||
rows={timeSortedRows}
|
||||
terminalData={terminalData}
|
||||
loading={scanLoading}
|
||||
error={scanError}
|
||||
locale={locale}
|
||||
selectedRowId={selectedRowId}
|
||||
onOpenDecision={handleOpenDecisionRow}
|
||||
onSelectRow={handleSelectRow}
|
||||
onOpenMap={() => setActiveView("map")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (resolvedView === "map") {
|
||||
return (
|
||||
<div className="scan-map-view">
|
||||
@@ -465,7 +434,7 @@ function ScanTerminalScreen() {
|
||||
className={clsx(
|
||||
"scan-terminal",
|
||||
resolvedView === "map" && "map-view-active",
|
||||
resolvedView !== "opportunities" && "focus-view-active",
|
||||
resolvedView !== "map" && "focus-view-active",
|
||||
resolvedView === "analysis" && "analysis-view-active",
|
||||
themeMode === "light" && "light",
|
||||
)}
|
||||
@@ -476,8 +445,8 @@ function ScanTerminalScreen() {
|
||||
<strong>{isEn ? "AI Weather Decision Terminal" : "AI 天气交易决策台"}</strong>
|
||||
<span>
|
||||
{isEn
|
||||
? "Start from opportunities, then open city cards to verify weather evidence"
|
||||
: "先看今日机会榜,再打开城市决策卡验证天气证据"}
|
||||
? "Start from the map, then open city cards to verify weather evidence"
|
||||
: "从地图选城市,再打开决策卡验证天气证据"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="scan-topbar-actions">
|
||||
@@ -562,15 +531,6 @@ function ScanTerminalScreen() {
|
||||
<section className="scan-list-section">
|
||||
<div className="scan-list-header">
|
||||
<div className="scan-list-tabs">
|
||||
<button
|
||||
type="button"
|
||||
className={resolvedView === "opportunities" ? "active" : ""}
|
||||
onClick={() => {
|
||||
setActiveView("opportunities");
|
||||
}}
|
||||
>
|
||||
{isEn ? "Opportunity Board" : "今日机会榜"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={resolvedView === "map" ? "active" : ""}
|
||||
@@ -631,8 +591,8 @@ function ScanTerminalScreen() {
|
||||
disabled={scanLoading}
|
||||
title={
|
||||
isEn
|
||||
? "Force refresh opportunity board and calendar"
|
||||
: "强制刷新今日机会榜和日历视图"
|
||||
? "Force refresh decision cards and calendar"
|
||||
: "强制刷新决策卡和日历视图"
|
||||
}
|
||||
>
|
||||
<RefreshCw size={14} className={scanLoading ? "spin" : undefined} />
|
||||
|
||||
@@ -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 today’s 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,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user