From cdfde1624ca3385507b05de457dbb8f0dc1d30bd Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Wed, 6 May 2026 15:26:37 +0800 Subject: [PATCH] =?UTF-8?q?=E7=A7=BB=E9=99=A4=E4=BB=8A=E6=97=A5=E6=9C=BA?= =?UTF-8?q?=E4=BC=9A=E6=A6=9C=EF=BC=8C=E7=AE=80=E5=8C=96=E5=86=B3=E7=AD=96?= =?UTF-8?q?=E5=8F=B0=E4=B8=BA=E4=B8=89=E8=A7=86=E5=9B=BE=EF=BC=88=E5=9C=B0?= =?UTF-8?q?=E5=9B=BE/=E5=86=B3=E7=AD=96=E5=8D=A1/=E6=97=A5=E5=8E=86?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 机会榜作为决策卡的聚合摘要层,与决策卡视图功能重叠,增加了 不必要的代码和认知负担。移除后将地图作为默认入口,用户从 地图选城市后直接进入决策卡验证天气证据。 --- .../components/dashboard/OpportunityTable.tsx | 396 ------------------ .../dashboard/OpportunityTable.utils.ts | 87 ---- .../dashboard/ScanTerminalDashboard.tsx | 54 +-- .../scan-terminal/OpportunityOverview.tsx | 216 ---------- .../dashboard/scan-terminal/decision-utils.ts | 206 --------- 5 files changed, 7 insertions(+), 952 deletions(-) delete mode 100644 frontend/components/dashboard/OpportunityTable.tsx delete mode 100644 frontend/components/dashboard/OpportunityTable.utils.ts delete mode 100644 frontend/components/dashboard/scan-terminal/OpportunityOverview.tsx diff --git a/frontend/components/dashboard/OpportunityTable.tsx b/frontend/components/dashboard/OpportunityTable.tsx deleted file mode 100644 index c6c9e9b2..00000000 --- a/frontend/components/dashboard/OpportunityTable.tsx +++ /dev/null @@ -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; -}) { - 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>( - () => 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 ( -
-
-
{title}
-
{copy}
-
-
- ); - } - - return ( -
- {stale ? ( -
- {isEn ? "Showing delayed snapshot" : "当前显示延迟快照"} - {staleReason || (isEn ? "Latest refresh failed, fallback to the last successful scan." : "最新刷新失败,已回退到上次成功扫描结果。")} -
- ) : null} -
- {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 ( -
- - -
- {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 ( -
selectAndOpenRow(row)} - > -
-
- {isEn ? "Conclusion" : "最终判断"} - {thresholdDecision.headline} - {thresholdDecision.summary} -
-
- - {debDistanceLabel} - {debDistanceText} - - - {modelSupportLabel} - {modelSupportText} - - - {metarLabel} - {metarConflictText} - - - {paceLabel} - {paceSignalText} - -
- - {thresholdDecision.relation} - - -
-
- {isEn ? "Current read" : "当前判断"} - {shortConclusion} -
- {expanded ? ( -
-
-
- {isEn ? "Conclusion" : "结论"} -

{thresholdDecision.headline}

- {thresholdDecision.summary} -
- - {thresholdDecision.relation} - -
-
- - {isEn ? "Bucket" : "判断对象"} - {bucketLabel} - - - {isEn ? "AI forecast" : "AI 预测"} - - {cityForecast.predicted != null - ? formatTemperatureValue(cityForecast.predicted, tempSymbol, { digits: 1 }) - : "--"} - - - - {isEn ? "Forecast range" : "预测区间"} - {forecastRangeLabel} - - - {isEn ? "Confidence" : "信心"} - {cityForecast.confidence || thresholdDecision.confidence} - -
-
- - DEB - {group.debLabel} - - - {modelSupportLabel} - {modelSupportText} - - - METAR - {metarConflictText} - - - {paceLabel} - {paceSignalText} - -
-
-
- {isEn ? "Why" : "为什么"} -
    - {keyReasons.map((reason) => ( -
  • {reason}
  • - ))} -
-
-
- {isEn ? "What can change it" : "可能改变判断的因素"} -
    - {riskItems.map((reason) => ( -
  • {reason}
  • - ))} -
-
-
-
- {cityForecast.airportRead ? ( -

{cityForecast.airportRead}

- ) : null} - {cityForecast.weatherRead ? ( -

{cityForecast.weatherRead}

- ) : null} - {cityForecast.paceRead ? ( -

{cityForecast.paceRead}

- ) : null} - {cityForecast.peakWindow ? ( -

{cityForecast.peakWindow}

- ) : null} -
-
- ) : null} -
- ); - })} -
-
- ); - })} -
-
- ); -}); diff --git a/frontend/components/dashboard/OpportunityTable.utils.ts b/frontend/components/dashboard/OpportunityTable.utils.ts deleted file mode 100644 index c2f30561..00000000 --- a/frontend/components/dashboard/OpportunityTable.utils.ts +++ /dev/null @@ -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"; diff --git a/frontend/components/dashboard/ScanTerminalDashboard.tsx b/frontend/components/dashboard/ScanTerminalDashboard.tsx index d7b8aff1..b2e185ea 100644 --- a/frontend/components/dashboard/ScanTerminalDashboard.tsx +++ b/frontend/components/dashboard/ScanTerminalDashboard.tsx @@ -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(null); - const [activeView, setActiveView] = useState("opportunities"); + const [activeView, setActiveView] = useState("map"); const [mapSelectedCityName, setMapSelectedCityName] = useState(null); const [showScanPaywall, setShowScanPaywall] = useState(false); const userLocalTime = useUserLocalClock(); @@ -355,36 +354,6 @@ function ScanTerminalScreen() { }, []); const renderMainView = () => { - if (resolvedView === "opportunities") { - if (!isPro) { - return ( -
- {isEn ? "Opportunity board is Pro" : "今日机会榜需 Pro 权限"} -

- {isEn - ? "Map exploration and city briefing are still available." - : "地图探索和城市简报仍可使用。"} -

- -
- ); - } - return ( - setActiveView("map")} - /> - ); - } if (resolvedView === "map") { return (
@@ -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() { {isEn ? "AI Weather Decision Terminal" : "AI 天气交易决策台"} {isEn - ? "Start from opportunities, then open city cards to verify weather evidence" - : "先看今日机会榜,再打开城市决策卡验证天气证据"} + ? "Start from the map, then open city cards to verify weather evidence" + : "从地图选城市,再打开决策卡验证天气证据"}
@@ -562,15 +531,6 @@ function ScanTerminalScreen() {
- - - ); -}); - -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 ( -
- -
- ); - } - - if (error || rows.length === 0) { - return ( -
- {isEn ? "No opportunity snapshot yet" : "暂无机会快照"} -

- {error || - (isEn - ? "Use the map to add cities, or refresh after the scan backend is ready." - : "可以先用地图添加城市;扫描后端就绪后会显示今日机会榜。")} -

- -
- ); - } - - return ( -
-
-
- {isEn ? "Today AI opportunity board" : "今日 AI 机会榜"} - {isEn ? "Decide first, verify second" : "先看决策,再展开证据"} -

- {isEn - ? "Cards translate weather, METAR and Polymarket pricing into action states." - : "把天气、METAR 与 Polymarket 报价先翻译成行动状态,再让你展开验证。"} -

-
-
- {isEn ? "Candidates" : "候选"} {summary?.candidate_total ?? rows.length} - {isEn ? "Tradable" : "可交易市场"} {summary?.tradable_market_count ?? "--"} - {isEn ? "Avg edge" : "平均概率差"} {formatRowSignedPercent(summary?.avg_edge_percent)} - - {isEn ? "Updated" : "更新时间"}{" "} - - {terminalData?.generated_at - ? new Date(terminalData.generated_at).toLocaleTimeString(isEn ? "en-US" : "zh-CN", { - hour: "2-digit", - minute: "2-digit", - }) - : "--"} - - -
-
- -
- {visibleSections.map((section) => ( -
-
-
- {section.title} -

{section.subtitle}

-
- {section.rows.length} -
-
- {section.rows.map((row) => ( - - ))} -
-
- ))} -
-
- ); -}); - diff --git a/frontend/components/dashboard/scan-terminal/decision-utils.ts b/frontend/components/dashboard/scan-terminal/decision-utils.ts index e2e9ed1f..7f274e42 100644 --- a/frontend/components/dashboard/scan-terminal/decision-utils.ts +++ b/frontend/components/dashboard/scan-terminal/decision-utils.ts @@ -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(); - 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, - 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(); - 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, - }, - ]; -} -