diff --git a/.gitignore b/.gitignore index c12f04da..4d57e657 100644 --- a/.gitignore +++ b/.gitignore @@ -41,6 +41,8 @@ frontend/.next/ frontend/.vercel/ frontend/*.tsbuildinfo frontend/.codex-next-dev*.log +frontend/.codex-next-start*.log +.codex-backend-*.log .npm-cache/ .codex-tmp/ diff --git a/frontend/components/dashboard/Dashboard.module.css b/frontend/components/dashboard/Dashboard.module.css index ededd61d..57660fa3 100644 --- a/frontend/components/dashboard/Dashboard.module.css +++ b/frontend/components/dashboard/Dashboard.module.css @@ -9781,7 +9781,10 @@ .root :global(.scan-opportunity-item) { display: grid; - grid-template-columns: 16px minmax(180px, 1fr) max-content max-content max-content; + grid-template-columns: + 16px minmax(96px, 0.7fr) minmax(82px, 0.45fr) + minmax(114px, 0.52fr) minmax(96px, 0.46fr) + minmax(92px, 0.42fr) max-content max-content; gap: 10px; align-items: center; width: 100%; @@ -9792,7 +9795,7 @@ background: rgba(4, 12, 22, 0.48); color: #b8c9e1; text-align: left; - cursor: default; + cursor: pointer; transition: background 0.18s ease, border-color 0.18s ease, @@ -9916,6 +9919,10 @@ justify-content: flex-end; } +.root :global(.scan-opportunity-stat.threshold) { + min-width: 82px; +} + .root :global(.scan-opportunity-stat b.positive) { color: #1de28f; } @@ -9961,6 +9968,160 @@ color: #ff8585; } +.root :global(.scan-opportunity-ai.neutral b) { + color: #9fc5ff; +} + +.root :global(.scan-opportunity-strength) { + display: inline-flex; + justify-self: end; + align-items: center; + justify-content: center; + min-width: 66px; + min-height: 32px; + padding: 0 10px; + border-radius: 999px; + border: 1px solid rgba(90, 123, 166, 0.16); + background: rgba(13, 28, 48, 0.6); + color: #91a7c4; + font-size: 12px; + font-weight: 900; + white-space: nowrap; +} + +.root :global(.scan-opportunity-strength.strong) { + color: #ff8585; + border-color: rgba(248, 113, 113, 0.32); + background: rgba(248, 113, 113, 0.1); +} + +.root :global(.scan-opportunity-strength.medium) { + color: #ffd166; + border-color: rgba(245, 158, 11, 0.3); + background: rgba(245, 158, 11, 0.1); +} + +.root :global(.scan-opportunity-strength.watch) { + color: #7aa8ff; + border-color: rgba(96, 165, 250, 0.28); + background: rgba(59, 130, 246, 0.1); +} + +.root :global(.scan-opportunity-expand) { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + min-height: 34px; + padding: 0 10px; + border: 1px solid rgba(65, 118, 216, 0.34); + border-radius: 9px; + background: rgba(10, 30, 58, 0.8); + color: #7fb4ff; + font-size: 12px; + font-weight: 900; + white-space: nowrap; + cursor: pointer; +} + +.root :global(.scan-opportunity-expand:hover) { + border-color: rgba(96, 165, 250, 0.5); + background: rgba(20, 47, 86, 0.86); +} + +.root :global(.scan-opportunity-expand:focus-visible) { + outline: 2px solid rgba(34, 211, 238, 0.72); + outline-offset: 2px; +} + +.root :global(.scan-v4-analysis) { + grid-column: 2 / -1; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + padding: 12px; + border: 1px solid rgba(69, 116, 178, 0.18); + border-radius: 12px; + background: + linear-gradient(180deg, rgba(9, 23, 38, 0.9), rgba(6, 16, 28, 0.92)), + radial-gradient(circle at 0 0, rgba(34, 211, 238, 0.1), transparent 30%); +} + +.root :global(.scan-v4-analysis section) { + min-width: 0; + padding: 10px; + border: 1px solid rgba(90, 123, 166, 0.12); + border-radius: 10px; + background: rgba(6, 16, 28, 0.52); +} + +.root :global(.scan-v4-analysis section:first-child), +.root :global(.scan-v4-analysis .scan-v4-evidence) { + grid-column: 1 / -1; +} + +.root :global(.scan-v4-analysis strong) { + display: block; + color: #72f3d1; + font-size: 12px; + font-weight: 900; + letter-spacing: 0.02em; + text-transform: none; +} + +.root :global(.scan-v4-analysis p) { + margin: 7px 0 0; + color: #c2d2e7; + font-size: 13px; + font-weight: 700; + line-height: 1.5; +} + +.root :global(.scan-v4-analysis ul) { + display: grid; + gap: 7px; + margin: 8px 0 0; + padding-left: 17px; + color: #aabdd6; + font-size: 12px; + font-weight: 700; + line-height: 1.45; +} + +.root :global(.scan-v4-evidence > div) { + display: flex; + flex-wrap: wrap; + gap: 7px; + margin-top: 8px; +} + +.root :global(.scan-v4-evidence > div > span), +.root :global(.scan-v4-model-sources span) { + display: inline-flex; + align-items: baseline; + gap: 5px; + min-height: 28px; + padding: 5px 8px; + border: 1px solid rgba(122, 154, 196, 0.14); + border-radius: 8px; + background: rgba(13, 28, 48, 0.58); + color: #d4e4f8; + font-size: 12px; + font-weight: 800; +} + +.root :global(.scan-v4-model-sources em) { + color: #839abd; + font-style: normal; + font-weight: 900; +} + +.root :global(.scan-v4-model-sources b) { + color: #eef7ff; + font-size: 12px; + font-weight: 900; +} + .root :global(.scan-table-body::-webkit-scrollbar), .root :global(.scan-calendar-view::-webkit-scrollbar), .root :global(.scan-detail-panel::-webkit-scrollbar) { @@ -10399,6 +10560,48 @@ radial-gradient(circle at 0 0, rgba(34, 211, 238, 0.12), transparent 34%); } +.root :global(.scan-ai-log-panel.compact) { + flex: 0 0 auto; + display: block; + margin-top: 10px; + padding: 0; + overflow: hidden; + border-radius: 14px; + background: rgba(7, 17, 30, 0.72); +} + +.root :global(.scan-ai-log-panel.compact .scan-ai-log-summary) { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + min-height: 42px; + padding: 0 14px; + color: #9fb4cf; + font-size: 12px; + font-weight: 900; + cursor: pointer; + list-style: none; +} + +.root :global(.scan-ai-log-panel.compact .scan-ai-log-summary::-webkit-details-marker) { + display: none; +} + +.root :global(.scan-ai-log-panel.compact .scan-ai-log-summary span) { + color: #c7d8ef; +} + +.root :global(.scan-ai-log-panel.compact .scan-ai-log-summary strong) { + color: #7fb4ff; + font-size: 12px; + font-weight: 900; +} + +.root :global(.scan-ai-log-panel.compact[open] .scan-ai-log-list) { + padding: 0 12px 12px; +} + .root :global(.scan-ai-analysis-view) { display: flex; flex-direction: column; @@ -11289,11 +11492,60 @@ color: #647a98; } +.root :global(.scan-terminal.light .scan-opportunity-strength) { + border-color: rgba(35, 72, 118, 0.12); + background: rgba(248, 252, 255, 0.78); +} + +.root :global(.scan-terminal.light .scan-opportunity-expand) { + color: #1d4ed8; + border-color: rgba(37, 99, 235, 0.18); + background: rgba(239, 246, 255, 0.84); +} + +.root :global(.scan-terminal.light .scan-v4-analysis), +.root :global(.scan-terminal.light .scan-v4-analysis section), +.root :global(.scan-terminal.light .scan-v4-evidence > div > span), +.root :global(.scan-terminal.light .scan-v4-model-sources span) { + border-color: rgba(35, 72, 118, 0.12); + background: rgba(255, 255, 255, 0.76); +} + +.root :global(.scan-terminal.light .scan-v4-analysis strong) { + color: #057a6b; +} + +.root :global(.scan-terminal.light .scan-v4-analysis p), +.root :global(.scan-terminal.light .scan-v4-analysis ul), +.root :global(.scan-terminal.light .scan-v4-evidence > div > span) { + color: #475569; +} + +.root :global(.scan-terminal.light .scan-v4-model-sources em) { + color: #64748b; +} + +.root :global(.scan-terminal.light .scan-v4-model-sources b) { + color: #0f172a; +} + .root :global(.scan-terminal.light .scan-ai-log-panel) { border-color: rgba(35, 72, 118, 0.12); background: linear-gradient(180deg, rgba(255, 255, 255, 0.8), rgba(246, 250, 255, 0.9)); } +.root :global(.scan-terminal.light .scan-ai-log-panel.compact) { + background: rgba(255, 255, 255, 0.82); +} + +.root :global(.scan-terminal.light .scan-ai-log-panel.compact .scan-ai-log-summary span) { + color: #334155; +} + +.root :global(.scan-terminal.light .scan-ai-log-panel.compact .scan-ai-log-summary strong) { + color: #2563eb; +} + .root :global(.scan-terminal.light .scan-ai-log-head strong), .root :global(.scan-terminal.light .scan-ai-log-item b) { color: #122033; @@ -11460,6 +11712,15 @@ justify-items: start; } + .root :global(.scan-opportunity-ai), + .root :global(.scan-v4-analysis) { + grid-column: 1 / -1; + } + + .root :global(.scan-v4-analysis) { + grid-template-columns: 1fr; + } + .root :global(.scan-trade-cards) { grid-template-columns: 1fr; } diff --git a/frontend/components/dashboard/OpportunityTable.tsx b/frontend/components/dashboard/OpportunityTable.tsx index 8e932e6e..fb99a1c4 100644 --- a/frontend/components/dashboard/OpportunityTable.tsx +++ b/frontend/components/dashboard/OpportunityTable.tsx @@ -1,5 +1,6 @@ "use client"; +import { BarChart3, ChevronDown, ChevronUp } from "lucide-react"; import React from "react"; import { useI18n } from "@/hooks/useI18n"; import type { @@ -130,6 +131,243 @@ function formatQuoteCents(value?: number | null) { return `${text.replace(/\.0$/, "")}¢`; } +function formatTradeSide(row: ScanOpportunityRow, locale: string) { + const side = String(row.side || "").toLowerCase(); + if (side === "yes") return "BUY YES"; + if (side === "no") return "BUY NO"; + if (row.action) { + return String(row.action) + .replace(String(row.target_label || ""), "") + .replace(/\s+/g, " ") + .trim() + .toUpperCase(); + } + return locale === "en-US" ? "WATCH" : "观察"; +} + +function formatThreshold(row: ScanOpportunityRow, tempSymbol?: string | null) { + const targetLabel = normalizeTemperatureLabel(row.target_label, tempSymbol); + if (targetLabel) return targetLabel; + if (row.target_lower != null && row.target_upper != null) { + return `${formatTemperatureValue(Number(row.target_lower), tempSymbol)} ~ ${formatTemperatureValue(Number(row.target_upper), tempSymbol)}`; + } + if (row.target_threshold != null) { + return formatTemperatureValue(Number(row.target_threshold), tempSymbol); + } + if (row.target_value != null) { + return formatTemperatureValue(Number(row.target_value), tempSymbol); + } + return "--"; +} + +function getOpportunityStrength(edgePercent?: number | null, locale = "zh-CN") { + const edge = Number(edgePercent); + const normalized = Number.isFinite(edge) ? edge : 0; + if (normalized >= 20) { + return { + label: locale === "en-US" ? "Strong" : "强机会", + tone: "strong", + }; + } + if (normalized >= 10) { + return { + label: locale === "en-US" ? "Medium" : "中机会", + tone: "medium", + }; + } + return { + label: locale === "en-US" ? "Watch" : "观察", + tone: "watch", + }; +} + +function getLocalizedRowText( + row: ScanOpportunityRow, + locale: string, + zh?: string | null, + en?: string | null, +) { + return locale === "en-US" ? en || zh || null : zh || en || null; +} + +function formatModelSources(row: ScanOpportunityRow, tempSymbol?: string | null) { + const sources = row.model_cluster_sources || {}; + return Object.entries(sources) + .filter(([, value]) => value != null && Number.isFinite(Number(value))) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([name, value]) => ({ + name, + value: formatTemperatureValue(Number(value), tempSymbol, { digits: 1 }), + })); +} + +function getModelSourceSummary( + row: ScanOpportunityRow, + locale: string, + tempSymbol?: string | null, +) { + const sources = formatModelSources(row, tempSymbol); + if (!sources.length) { + return locale === "en-US" + ? "model cluster pending" + : "模型集群暂未回传"; + } + const shown = sources.map((item) => `${item.name} ${item.value}`).join(" / "); + return locale === "en-US" + ? `all models: ${shown}` + : `全部模型:${shown}`; +} + +function getShortAiConclusion( + row: ScanOpportunityRow, + locale: string, + edgePercent?: number | null, + strengthLabel?: string, +) { + const directReason = + getLocalizedRowText(row, locale, row.ai_reason_zh, row.ai_reason_en) || + getLocalizedRowText( + row, + locale, + row.ai_watchlist_reason_zh, + row.ai_watchlist_reason_en, + ); + if (directReason) return directReason; + const cityThesis = getLocalizedRowText( + row, + locale, + row.ai_city_thesis_zh, + row.ai_city_thesis_en, + ); + if (cityThesis) return cityThesis; + + const edgeText = formatPercent(edgePercent, true); + const modelBasis = getModelSourceSummary(row, locale, row.target_unit || row.temp_symbol); + if (locale === "en-US") { + return `${strengthLabel || "Watch"} setup: edge ${edgeText}; V4 should validate against ${modelBasis}.`; + } + return `${strengthLabel || "观察"}:edge ${edgeText},V4 需结合${modelBasis}确认。`; +} + +function getRiskHints( + row: ScanOpportunityRow, + locale: string, + modelProbability?: number | null, +) { + const hints: string[] = []; + const spread = Number(row.spread); + if (Number.isFinite(spread) && spread > 0.03) { + hints.push( + locale === "en-US" + ? `Wide spread ${formatQuoteCents(spread)} may distort executable edge.` + : `盘口价差 ${formatQuoteCents(spread)} 偏宽,可能扭曲可执行 edge。`, + ); + } + const quoteAgeSeconds = + row.quote_age_ms != null && Number.isFinite(Number(row.quote_age_ms)) + ? Math.round(Number(row.quote_age_ms) / 1000) + : null; + if (quoteAgeSeconds != null && quoteAgeSeconds > 60) { + hints.push( + locale === "en-US" + ? `Quote age ${quoteAgeSeconds}s; refresh before acting.` + : `报价已 ${quoteAgeSeconds}s,执行前需要刷新。`, + ); + } + if (row.trend_alignment === false) { + hints.push( + locale === "en-US" + ? "Intraday trend does not fully support this direction." + : "日内趋势未完全支持该方向。", + ); + } + if (row.cluster_adjusted) { + hints.push( + locale === "en-US" + ? "Tail bucket was cluster-adjusted; raw edge may be overstated." + : "尾部桶已做模型集群折扣,原始 edge 可能偏乐观。", + ); + } + if (modelProbability != null && modelProbability < 10) { + hints.push( + locale === "en-US" + ? "Low model probability makes the setup sensitive to calibration error." + : "模型概率偏低,校准误差会显著影响判断。", + ); + } + if (!hints.length) { + hints.push( + locale === "en-US" + ? "Main residual risk is late observation updates or a shifted peak window." + : "主要残余风险是后续实测升温或峰值窗口漂移。", + ); + } + return hints; +} + +function getRecommendationReasons( + row: ScanOpportunityRow, + locale: string, + modelProbability?: number | null, + edgePercent?: number | null, + price?: number | null, +) { + const reasons: string[] = []; + const aiReason = getLocalizedRowText(row, locale, row.ai_reason_zh, row.ai_reason_en); + if (aiReason && String(row.ai_decision || "").toLowerCase() === "approve") { + reasons.push(aiReason); + } + reasons.push( + locale === "en-US" + ? `EMOS probability ${formatPercent(modelProbability)} vs market price ${formatQuoteCents(price)} gives edge ${formatPercent(edgePercent, true)}.` + : `EMOS 概率 ${formatPercent(modelProbability)} 对比市场价格 ${formatQuoteCents(price)},edge ${formatPercent(edgePercent, true)}。`, + ); + if (row.peak_alignment_score != null) { + reasons.push( + locale === "en-US" + ? `Peak alignment score ${Number(row.peak_alignment_score).toFixed(2)} supports checking this bucket.` + : `峰值对齐分 ${Number(row.peak_alignment_score).toFixed(2)},支持把该桶纳入检查。`, + ); + } + return reasons.slice(0, 3); +} + +function getExclusionReasons( + row: ScanOpportunityRow, + locale: string, + edgePercent?: number | null, +) { + const decision = String(row.ai_decision || "").toLowerCase(); + const aiReason = + getLocalizedRowText(row, locale, row.ai_reason_zh, row.ai_reason_en) || + getLocalizedRowText( + row, + locale, + row.ai_watchlist_reason_zh, + row.ai_watchlist_reason_en, + ); + if (decision === "veto" || decision === "downgrade" || decision === "watchlist") { + return [ + aiReason || + (locale === "en-US" + ? "V4 did not classify this row as a primary recommendation." + : "V4 未把该合约列为主推荐。"), + ]; + } + if (edgePercent != null && Number(edgePercent) < 10) { + return [ + locale === "en-US" + ? "Edge is below the medium-opportunity threshold." + : "edge 低于中机会阈值。", + ]; + } + return [ + locale === "en-US" + ? "No hard veto in the current V4/rule snapshot." + : "当前 V4/规则快照没有硬性排除项。", + ]; +} + function getAiMeta(row: ScanOpportunityRow, locale: string) { const decision = String(row.ai_decision || "").toLowerCase(); if (decision === "veto") { @@ -463,6 +701,21 @@ export const OpportunityTable = React.memo(function OpportunityTable({ () => 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; + }); + }, []); if (!hasRows) { const title = @@ -524,6 +777,14 @@ export const OpportunityTable = React.memo(function OpportunityTable({
{group.cityName}
+ + {isEn ? "Local time" : "当前时间"} + {group.localTime || "--"} + + + {isEn ? "Settlement left" : "剩余结算时间"} + {formatWindowMinutes(group.remainingMinutes, locale)} + DEB {group.debLabel} @@ -541,16 +802,14 @@ export const OpportunityTable = React.memo(function OpportunityTable({
- {group.localTime || "--"} {group.phaseMeta.label} - {formatWindowMinutes(group.remainingMinutes, locale)}
- {group.rows.map((row, rowIndex) => { + {group.rows.map((row) => { const tempSymbol = normalizeTemperatureSymbol(row.target_unit || row.temp_symbol); const side = String(row.side || "").toLowerCase(); const detail = getDetailForRow(row, cityDetailsByName); @@ -577,23 +836,57 @@ export const OpportunityTable = React.memo(function OpportunityTable({ modelProbability != null && row.ask != null ? modelProbability - Number(row.ask) * 100 : row.edge_percent; - const modelLabel = "EMOS"; - const priceLabel = side === "no" ? "NO" : isEn ? "Market" : "市场"; + const modelLabel = isEn ? "EMOS prob" : "EMOS 概率"; + const priceLabel = isEn ? "Market price" : "市场价格"; const edgePositive = Number(edgePercent || 0) >= 0; const aiMeta = getAiMeta(row, locale); + const strength = getOpportunityStrength(edgePercent, locale); + const expanded = expandedRowIds.has(row.id); + const shortConclusion = getShortAiConclusion( + row, + locale, + edgePercent, + strength.label, + ); + const recommendationReasons = getRecommendationReasons( + row, + locale, + modelProbability, + edgePercent, + row.ask, + ); + const exclusionReasons = getExclusionReasons(row, locale, edgePercent); + const riskHints = getRiskHints(row, locale, modelProbability); + const thesis = + getLocalizedRowText( + row, + locale, + row.ai_city_thesis_zh, + row.ai_city_thesis_en, + ) || + getLocalizedRowText(row, locale, row.ai_reason_zh, row.ai_reason_en) || + (isEn + ? `${group.cityName} thesis: validate this ${formatTradeSide(row, locale)} against the full model cluster before sizing.` + : `${group.cityName} thesis:该 ${formatTradeSide(row, locale)} 需要先结合全部模型集群确认,再考虑仓位。`); + const modelSources = formatModelSources(row, tempSymbol); return (
onSelectRow?.(row)} > - {formatAction(row, locale, tempSymbol)} + {formatTradeSide(row, locale)} + + {isEn ? "Threshold" : "阈值"} + {formatThreshold(row, tempSymbol)} + {modelLabel} {formatPercent(modelProbability)} @@ -608,11 +901,103 @@ export const OpportunityTable = React.memo(function OpportunityTable({ {formatPercent(edgePercent, true)} - {aiMeta ? ( - - {aiMeta.label} - {aiMeta.reason ? {aiMeta.reason} : null} - + + {strength.label} + + + + {isEn ? "AI take" : "AI 结论"} + {shortConclusion} + + {expanded ? ( +
+
+ thesis +

{thesis}

+
+
+ {isEn ? "Recommendation reasons" : "推荐理由"} +
    + {recommendationReasons.map((reason) => ( +
  • {reason}
  • + ))} +
+
+
+ {isEn ? "Exclusion reasons" : "排除理由"} +
    + {exclusionReasons.map((reason) => ( +
  • {reason}
  • + ))} +
+
+
+ {isEn ? "Risk notes" : "风险提示"} +
    + {riskHints.map((reason) => ( +
  • {reason}
  • + ))} +
+
+
+ {isEn ? "Data basis" : "数据依据"} +
+ DEB {group.debLabel} + EMOS peak {group.peakLabel} + + {isEn ? "Peak prob" : "峰值概率"}{" "} + {formatPercent(group.peakProbability)} + + + {isEn ? "Ask" : "买价"} {formatQuoteCents(row.ask)} + + edge {formatPercent(edgePercent, true)} + {row.kelly_fraction != null ? ( + + Kelly {formatPercent(Number(row.kelly_fraction) * 100)} + + ) : null} +
+
+ {modelSources.length ? ( + modelSources.map((source) => ( + + {source.name} + {source.value} + + )) + ) : ( + + {isEn ? "Models" : "模型"} + + {isEn + ? "waiting for cluster" + : "等待模型集群"} + + + )} +
+
+
) : null}
); diff --git a/frontend/components/dashboard/PanelSections.tsx b/frontend/components/dashboard/PanelSections.tsx index c0259dc2..1fd943f9 100644 --- a/frontend/components/dashboard/PanelSections.tsx +++ b/frontend/components/dashboard/PanelSections.tsx @@ -1195,17 +1195,6 @@ export function ProbabilityDistribution({ const probability = Math.round( Number(row.probability || 0) * 100, ); - const rowMarketBucket = row.marketBucket; - const rowMarketPrice = - rowMarketBucket?.yes_buy ?? - rowMarketBucket?.market_price ?? - null; - const yesPriceText = toPriceCents(rowMarketPrice); - const marketTagFinal = rowMarketBucket - ? locale === "en-US" - ? `YES ask: ${yesPriceText || "--"}` - : `YES 买价: ${yesPriceText || "--"}` - : null; return (
@@ -1218,16 +1207,6 @@ export function ProbabilityDistribution({ {probability}%
- {marketTagFinal && ( -
- {marketTagFinal} -
- )} ); }) diff --git a/frontend/components/dashboard/ScanTerminalDashboard.tsx b/frontend/components/dashboard/ScanTerminalDashboard.tsx index a940d0a6..d02d8f6b 100644 --- a/frontend/components/dashboard/ScanTerminalDashboard.tsx +++ b/frontend/components/dashboard/ScanTerminalDashboard.tsx @@ -59,7 +59,7 @@ const SCAN_AUTO_REFRESH_MS = 5 * 60 * 1000; interface FilterState extends ScanTerminalFilters {} -type ContentView = "list" | "map" | "ai" | "calendar"; +type ContentView = "list" | "map" | "calendar"; type ThemeMode = "dark" | "light"; type ScanAiLogTone = "info" | "success" | "warning" | "error"; @@ -370,6 +370,64 @@ function ScanAiAnalysisView({ ); } +function ScanAiLogPanel({ + response, + logs, + locale, + loading, + error, +}: { + response: ScanTerminalResponse | null; + logs: ScanAiLogEntry[]; + locale: string; + loading: boolean; + error?: string | null; +}) { + const isEn = locale === "en-US"; + const aiScan = response?.ai_scan || null; + return ( +
+ + {isEn ? "Debug log" : "调试日志"} + + {loading + ? isEn + ? "V4 running" + : "V4 运行中" + : error + ? isEn + ? "V4 failed" + : "V4 失败" + : aiScan?.status === "ready" + ? isEn + ? `${aiScan.sent_cities ?? "--"} cities · ${aiScan.sent_contracts ?? aiScan.sent_rows ?? "--"} contracts` + : `${aiScan.sent_cities ?? "--"} 城 · ${aiScan.sent_contracts ?? aiScan.sent_rows ?? "--"} 合约` + : isEn + ? "Collapsed" + : "已折叠"} + + +
+ {logs.length ? ( + logs.map((entry) => ( +
+ {entry.time} +
+ {entry.title} + {entry.detail ? {entry.detail} : null} +
+
+ )) + ) : ( +
+ {isEn ? "No request has been sent." : "还没有发起请求。"} +
+ )} +
+
+ ); +} + function CalendarView({ rows, locale, @@ -469,7 +527,7 @@ function ScanTerminalScreen() { const [aiError, setAiError] = useState(null); const [error, setError] = useState(null); const [selectedRowId, setSelectedRowId] = useState(null); - const [activeView, setActiveView] = useState("map"); + const [activeView, setActiveView] = useState("list"); const [mapSelectedCityName, setMapSelectedCityName] = useState(null); const [showScanPaywall, setShowScanPaywall] = useState(false); const [aiLogs, setAiLogs] = useState([]); @@ -659,7 +717,7 @@ function ScanTerminalScreen() { : "每城包含 EMOS 分布、模型集群和候选合约。", }, ]); - setActiveView("ai"); + setActiveView("list"); setAiLoading(true); setAiError(null); try { @@ -778,10 +836,10 @@ function ScanTerminalScreen() { }, [activeFilters, isPro]); useEffect(() => { - if (!isPro && activeView !== "map") { + if (!store.proAccess.loading && !isPro && activeView !== "map") { setActiveView("map"); } - }, [activeView, isPro]); + }, [activeView, isPro, store.proAccess.loading]); useEffect(() => { setUserLocalTime(formatUserLocalTime()); @@ -896,19 +954,6 @@ function ScanTerminalScreen() { /> ); } - if (resolvedView === "ai") { - return ( - void runAiReview()} - /> - ); - } return ( <> {isEn ? "Opportunity List" : "机会列表"} -