Redesign the scan terminal layout for EMOS

This commit is contained in:
2569718930@qq.com
2026-04-24 00:13:11 +08:00
parent 8ebd5fa813
commit 3f7d3ddf34
5 changed files with 1825 additions and 502 deletions
File diff suppressed because it is too large Load Diff
+202 -180
View File
@@ -6,90 +6,125 @@ import { useI18n } from "@/hooks/useI18n";
import type { ScanOpportunityRow } from "@/lib/dashboard-types"; import type { ScanOpportunityRow } from "@/lib/dashboard-types";
import { getLocalizedCityName } from "@/lib/dashboard-home-copy"; import { getLocalizedCityName } from "@/lib/dashboard-home-copy";
function getStatusMeta(
row: ScanOpportunityRow,
locale: string,
): { label: string; tone: "green" | "amber" | "purple" | "neutral" } {
const phase = String(row.window_phase || "").toLowerCase();
if (phase === "active_peak" || phase === "setup_today") {
return { label: locale === "en-US" ? "Tradable" : "可交易", tone: "green" };
}
if (phase === "tomorrow" || phase === "week_ahead") {
return { label: locale === "en-US" ? "Early" : "早期机会", tone: "purple" };
}
if (phase === "post_peak") {
return { label: locale === "en-US" ? "Post Peak" : "峰后确认", tone: "amber" };
}
return { label: locale === "en-US" ? "Watching" : "观察中", tone: "neutral" };
}
function formatPercent(value?: number | null, signed = false) { function formatPercent(value?: number | null, signed = false) {
if (value == null || Number.isNaN(Number(value))) return "--"; if (value == null || Number.isNaN(Number(value))) return "--";
const numeric = Number(value); const numeric = Number(value);
if (!signed) return `${numeric.toFixed(1)}%`; return `${signed && numeric >= 0 ? "+" : ""}${numeric.toFixed(1)}%`;
return `${numeric >= 0 ? "+" : ""}${numeric.toFixed(1)}%`;
} }
function formatTimeBlock(row: ScanOpportunityRow, locale: string) { function formatVolume(value?: number | null) {
const parts: string[] = []; const numeric = Number(value || 0);
if (row.local_time) { if (!Number.isFinite(numeric) || numeric <= 0) return "--";
parts.push(row.local_time); if (numeric >= 1_000_000) return `$${(numeric / 1_000_000).toFixed(1)}M`;
if (numeric >= 1_000) return `$${(numeric / 1_000).toFixed(0)}K`;
return `$${numeric.toFixed(0)}`;
}
function formatWindowMinutes(value: number | null | undefined, locale: string) {
if (value == null || !Number.isFinite(Number(value))) return "--";
const minutes = Math.max(0, Math.round(Number(value)));
const hours = Math.floor(minutes / 60);
const remains = minutes % 60;
if (locale === "en-US") {
if (hours <= 0) return `${remains}m left`;
return `${hours}h ${remains}m left`;
} }
if (row.selected_date) { if (hours <= 0) return `剩余 ${remains} 分钟`;
parts.push(row.selected_date); return `剩余 ${hours}h ${remains}m`;
}
return parts.join(" · ") || (locale === "en-US" ? "No time" : "暂无时间");
} }
function formatAction(row: ScanOpportunityRow, locale: string) { function formatAction(row: ScanOpportunityRow, locale: string) {
if (row.action) return row.action;
if (row.side === "yes") { if (row.side === "yes") {
return `${locale === "en-US" ? "Buy" : "买入"} Yes ${row.target_label || ""}`.trim(); return `${locale === "en-US" ? "Buy Yes" : "买入 Yes"} ${row.target_label || ""}`.trim();
} }
if (row.side === "no") { if (row.side === "no") {
return `${locale === "en-US" ? "Buy" : "买入"} No ${row.target_label || ""}`.trim(); return `${locale === "en-US" ? "Buy No" : "买入 No"} ${row.target_label || ""}`.trim();
} }
return row.action || "--"; return "--";
} }
function formatProbability(value?: number | null) { function getPhaseMeta(
if (value == null || Number.isNaN(Number(value))) return "--"; row: ScanOpportunityRow,
return `${(Number(value) * 100).toFixed(0)}%`; locale: string,
): { label: string; tone: "green" | "amber" | "blue" | "red" } {
const mode = String(row.window_phase || "").toLowerCase();
if (mode === "active_peak" || mode === "setup_today") {
return {
label: locale === "en-US" ? "Touch Play" : "触达博弈",
tone: "red",
};
}
if (mode === "tomorrow" || mode === "week_ahead") {
return {
label: locale === "en-US" ? "Early" : "早期机会",
tone: "blue",
};
}
if (row.trend_alignment) {
return {
label: locale === "en-US" ? "Trend" : "趋势确认",
tone: "amber",
};
}
return {
label: locale === "en-US" ? "Tradable" : "可交易",
tone: "green",
};
}
function scoreTone(score?: number | null) {
const numeric = Number(score || 0);
if (numeric >= 85) return "green";
if (numeric >= 70) return "yellow";
return "gray";
}
function ProbabilityPreview({
row,
locale,
}: {
row: ScanOpportunityRow;
locale: string;
}) {
const targetBase =
row.target_value ??
row.target_threshold ??
row.target_lower ??
row.target_upper ??
null;
const unit = row.target_unit || row.temp_symbol || "";
const targetLabel =
targetBase != null
? `${Math.round(Number(targetBase))}${unit}`
: row.target_label || "--";
return (
<div className="scan-distribution-preview">
<div className="scan-distribution-card featured">
<strong>{targetLabel}</strong>
<span>{locale === "en-US" ? "Target" : "目标"}</span>
</div>
<div className="scan-distribution-card">
<strong>{formatPercent(row.model_event_probability != null ? row.model_event_probability * 100 : null)}</strong>
<span>{locale === "en-US" ? "Model" : "模型"}</span>
</div>
<div className="scan-distribution-card">
<strong>{formatPercent(row.market_event_probability != null ? row.market_event_probability * 100 : null)}</strong>
<span>{locale === "en-US" ? "Market" : "市场"}</span>
</div>
<div className="scan-distribution-card">
<strong>{formatPercent(row.distribution_bias_score)}</strong>
<span>{row.distribution_bias_direction || (locale === "en-US" ? "Bias" : "偏移")}</span>
</div>
</div>
);
} }
function ScoreRing({ score }: { score?: number | null }) { function ScoreRing({ score }: { score?: number | null }) {
const displayScore = Math.max(0, Math.min(100, Number(score || 0))); const displayScore = Math.max(0, Math.min(100, Number(score || 0)));
const radius = 20;
const circumference = 2 * Math.PI * radius;
const progress = (displayScore / 100) * circumference;
const color =
displayScore >= 85 ? "#00E0A4" : displayScore >= 70 ? "#FFB020" : "#FF4D6A";
return ( return (
<div className="scan-score-ring"> <div className={`scan-score-ring tone-${scoreTone(displayScore)}`}>
<svg viewBox="0 0 48 48" width={48} height={48}> <span>{displayScore.toFixed(0)}</span>
<circle
cx={24}
cy={24}
r={radius}
fill="none"
stroke="rgba(255,255,255,0.06)"
strokeWidth={3}
/>
<circle
cx={24}
cy={24}
r={radius}
fill="none"
stroke={color}
strokeWidth={3}
strokeDasharray={`${progress} ${circumference - progress}`}
strokeLinecap="round"
transform="rotate(-90 24 24)"
/>
</svg>
<span className="scan-score-value" style={{ color }}>
{displayScore.toFixed(0)}
</span>
</div> </div>
); );
} }
@@ -106,124 +141,111 @@ export function OpportunityTable({
const { locale } = useI18n(); const { locale } = useI18n();
const isEn = locale === "en-US"; const isEn = locale === "en-US";
return ( if (!rows.length) {
<div className="scan-table-container"> return (
<div className="scan-table-header"> <div className="scan-table-shell empty">
<span className="scan-th scan-th-rank">#</span> <div className="scan-empty-state">
<span className="scan-th scan-th-city"> <div className="scan-empty-title">
{isEn ? "City / Market" : "城市 / 市场"} {isEn ? "No main signal right now" : "当前无主信号"}
</span> </div>
<span className="scan-th scan-th-time"> <div className="scan-empty-copy">
{isEn ? "Time / Phase" : "时间 / 阶段"} {isEn
</span> ? "No row passed the price, spread, liquidity, and edge thresholds."
<span className="scan-th scan-th-prob"> : "当前没有机会同时满足价格、点差、流动性和 edge 过滤。"}
{isEn ? "EMOS vs Market" : "EMOS vs 市场"}
</span>
<span className="scan-th scan-th-action">
{isEn ? "Best Action" : "最佳动作"}
</span>
<span className="scan-th scan-th-edge">
{isEn ? "Edge" : "边际优势"}
</span>
<span className="scan-th scan-th-score">
{isEn ? "Score" : "综合得分"}
</span>
<span className="scan-th scan-th-fav" />
</div>
{rows.map((row, index) => {
const status = getStatusMeta(row, locale);
const localizedCityName = getLocalizedCityName(
row.city,
row.city_display_name || row.display_name || row.city,
locale,
);
const selected = selectedRowId === row.id;
const finalScore = Number(row.final_score || 0);
return (
<button
key={row.id}
type="button"
className={`scan-table-row ${selected ? "selected" : ""} ${row.tradable ? "tradable" : ""}`}
onClick={() => onSelectRow?.(row)}
>
<span className={`scan-td scan-td-rank rank-${status.tone}`}>
<span className="scan-rank-circle">{row.rank || index + 1}</span>
</span>
<span className="scan-td scan-td-city">
<span className="scan-city-thumb">
<span className="scan-city-img-placeholder" />
</span>
<span className="scan-city-info">
<span className="scan-city-name">{localizedCityName}</span>
<span className="scan-city-sub">
{row.target_label || row.market_question || "--"}
</span>
</span>
</span>
<span className="scan-td scan-td-time">
<span className="scan-time-text">{formatTimeBlock(row, locale)}</span>
<span className={`scan-status-badge tone-${status.tone}`}>
{status.label}
</span>
</span>
<span className="scan-td scan-td-prob">
<div className="scan-city-info">
<span className="scan-city-name">
{formatProbability(row.model_event_probability)} /{" "}
{formatProbability(row.market_event_probability)}
</span>
<span className="scan-city-sub">
{isEn ? "Bias" : "偏移"}{" "}
{row.distribution_bias_direction || "--"} ·{" "}
{formatPercent(row.distribution_bias_score)}
</span>
</div>
</span>
<span className="scan-td scan-td-action">
<span className="scan-action-text">
{formatAction(row, locale)}
</span>
</span>
<span className="scan-td scan-td-edge">
<span
className={`scan-edge-value ${finalScore > 0 ? "positive" : "neutral"}`}
>
{formatPercent(row.edge_percent, true)}
</span>
</span>
<span className="scan-td scan-td-score">
<ScoreRing score={row.final_score} />
</span>
<span className="scan-td scan-td-fav">
<Star size={16} className="scan-fav-icon" />
</span>
</button>
);
})}
{!rows.length ? (
<div className="scan-detail-empty">
<div>
<div className="scan-detail-section-title">
{isEn ? "No primary signal" : "当前无主信号"}
</div>
<p className="scan-city-sub">
{isEn
? "No opportunity passed the price, spread, liquidity, and edge filters."
: "当前没有机会同时满足价格、点差、流动性和 edge 过滤。"}
</p>
</div> </div>
</div> </div>
) : null} </div>
);
}
return (
<div className="scan-table-shell">
<div className="scan-table-header">
<span />
<span>{isEn ? "City / Market" : "城市 / 市场"}</span>
<span>{isEn ? "Local Time / Phase" : "当前时间 / 阶段"}</span>
<span>{isEn ? "Model vs Market" : "模型分布 vs 市场分布"}</span>
<span>{isEn ? "Best Opportunity" : "最佳机会"}</span>
<span>{isEn ? "Edge" : "边际优势"}</span>
<span>{isEn ? "Score" : "综合得分"}</span>
</div>
<div className="scan-table-body">
{rows.map((row, index) => {
const phaseMeta = getPhaseMeta(row, locale);
const localizedCityName = getLocalizedCityName(
row.city,
row.city_display_name || row.display_name || row.city,
locale,
);
const selected = selectedRowId === row.id;
const scoreClass = scoreTone(row.final_score);
return (
<button
key={row.id}
type="button"
className={`scan-table-row ${selected ? "selected" : ""}`}
onClick={() => onSelectRow?.(row)}
>
<div className="scan-rank-cell">
<div className={`scan-rank-circle ${scoreClass}`}>
{row.rank || index + 1}
</div>
</div>
<div className="scan-city-cell">
<div className="scan-city-thumb">
<div className="scan-city-thumb-fill" />
</div>
<div className="scan-city-copy">
<div className="scan-city-name">{localizedCityName}</div>
<div className="scan-city-sub">
{row.market_question || row.target_label || "--"}
</div>
<div className="scan-city-volume">{formatVolume(row.volume)}</div>
</div>
</div>
<div className="scan-time-cell">
<div className="scan-time-main">{row.local_time || "--"}</div>
<div className={`scan-phase-badge ${phaseMeta.tone}`}>
{phaseMeta.label}
</div>
<div className="scan-time-remaining">
{formatWindowMinutes(row.remaining_window_minutes, locale)}
</div>
</div>
<ProbabilityPreview row={row} locale={locale} />
<div className="scan-trade-cell">
<div className={`scan-trade-main ${row.side === "no" ? "sell" : "buy"}`}>
{formatAction(row, locale)}
</div>
<div className="scan-trade-sub">
{formatPercent(row.ask != null ? row.ask * 100 : null)} {" "}
{formatPercent(row.model_probability != null ? row.model_probability * 100 : null)}
</div>
<div className="scan-trade-note">
{row.target_label || row.market_direction || "--"}
</div>
</div>
<div className={`scan-edge-cell ${Number(row.edge_percent || 0) >= 0 ? "positive" : "negative"}`}>
{formatPercent(row.edge_percent, true)}
</div>
<div className="scan-score-cell">
<ScoreRing score={row.final_score} />
</div>
<div className="scan-row-fav">
<Star size={16} />
</div>
</button>
);
})}
</div>
</div> </div>
); );
} }
+100 -69
View File
@@ -2,12 +2,12 @@
import React from "react"; import React from "react";
import { import {
Crosshair, Bolt,
Clock, CircleDot,
Zap, Clock3,
TrendingUp, Info,
SlidersHorizontal,
Search, Search,
TrendingUp,
} from "lucide-react"; } from "lucide-react";
import { useI18n } from "@/hooks/useI18n"; import { useI18n } from "@/hooks/useI18n";
import type { ScanTerminalFilters } from "@/lib/dashboard-types"; import type { ScanTerminalFilters } from "@/lib/dashboard-types";
@@ -17,38 +17,41 @@ export interface FilterState extends ScanTerminalFilters {}
const SCAN_MODES = [ const SCAN_MODES = [
{ {
key: "tradable" as const, key: "tradable" as const,
icon: Crosshair, icon: Bolt,
labelEn: "Tradable", labelEn: "Tradable",
labelZh: "可交易机会", labelZh: "可交易机会",
descEn: "Markets with immediate trading value", descEn: "Find the best immediate trade",
descZh: "交易价值最高的市场", descZh: "发现当前最值得交易的市场",
}, },
{ {
key: "early" as const, key: "early" as const,
icon: Clock, icon: Clock3,
labelEn: "Early", labelEn: "Early",
labelZh: "早期机会", labelZh: "早期机会",
descEn: "Long-horizon positions", descEn: "Long-horizon, lower-priced setups",
descZh: "长周期布局", descZh: "长时间布局,低价市场",
}, },
{ {
key: "touch" as const, key: "touch" as const,
icon: Zap, icon: CircleDot,
labelEn: "Touch Play", labelEn: "Touch Play",
labelZh: "触达博弈", labelZh: "触达博弈",
descEn: "Approaching settle threshold", descEn: "Markets approaching the settle line",
descZh: "触达博弈最高的市场", descZh: "接近决策,博弈是否触达",
}, },
{ {
key: "trend" as const, key: "trend" as const,
icon: TrendingUp, icon: TrendingUp,
labelEn: "Trend", labelEn: "Trend",
labelZh: "趋势确认", labelZh: "趋势确认",
descEn: "Trend-confirmed opportunities", descEn: "Trend-confirmed follow-through",
descZh: "趋势确认、顺势加仓", descZh: "趋势明朗,顺势交易",
}, },
] as const; ] as const;
const LIQUIDITY_OPTIONS = [500, 1000, 5000, 10000];
const EDGE_OPTIONS = [1, 2, 3, 5, 8];
export function ScanFilterPanel({ export function ScanFilterPanel({
value, value,
onChange, onChange,
@@ -75,10 +78,19 @@ export function ScanFilterPanel({
return ( return (
<aside className="scan-filter-panel"> <aside className="scan-filter-panel">
{/* === Scan Mode Section === */} <div className="scan-sidebar-brand">
<div className="scan-filter-section"> <div className="scan-sidebar-brand-mark">
<div className="scan-filter-label"> <Bolt size={22} strokeWidth={2.2} />
{isEn ? "Scan Mode" : "扫描模式"} </div>
<div>
<div className="scan-sidebar-brand-name">PolyWeather</div>
</div>
</div>
<section className="scan-filter-section">
<div className="scan-filter-heading">
<span>{isEn ? "Scan Mode" : "扫描模式"}</span>
<Info size={14} />
</div> </div>
<div className="scan-mode-tabs"> <div className="scan-mode-tabs">
{SCAN_MODES.map((mode) => { {SCAN_MODES.map((mode) => {
@@ -87,91 +99,111 @@ export function ScanFilterPanel({
return ( return (
<button <button
key={mode.key} key={mode.key}
type="button"
className={`scan-mode-tab ${isActive ? "active" : ""}`} className={`scan-mode-tab ${isActive ? "active" : ""}`}
onClick={() => updateFilter("scan_mode", mode.key)} onClick={() => updateFilter("scan_mode", mode.key)}
title={isEn ? mode.descEn : mode.descZh}
> >
<Icon size={16} /> <span className="scan-mode-icon">
<span className="scan-mode-tab-label"> <Icon size={16} />
{isEn ? mode.labelEn : mode.labelZh} </span>
<span className="scan-mode-copy">
<span className="scan-mode-tab-label">
{isEn ? mode.labelEn : mode.labelZh}
</span>
<span className="scan-mode-tab-sub">
{isEn ? mode.descEn : mode.descZh}
</span>
</span> </span>
{isActive && <span className="scan-mode-tab-indicator" />}
</button> </button>
); );
})} })}
</div> </div>
</div> </section>
{/* === Filter Controls === */} <section className="scan-filter-section">
<div className="scan-filter-section"> <div className="scan-filter-heading">
<div className="scan-filter-label"> <span>{isEn ? "Filters" : "筛选条件"}</span>
<SlidersHorizontal size={14} /> <Info size={14} />
{isEn ? "Filter Criteria" : "筛选条件"}
</div> </div>
{/* Price Range */} <div className="scan-range-card">
<div className="scan-filter-row"> <div className="scan-filter-row-title">
<span className="scan-filter-row-label">
{isEn ? "Price Range" : "价格范围"} {isEn ? "Price Range" : "价格范围"}
</span> </div>
<div className="scan-range-display"> <div className="scan-range-track-wrap">
<span>{value.min_price.toFixed(2)}</span>
<input <input
type="range" type="range"
min={0} min={0}
max={100} max={100}
value={value.min_price * 100} value={Math.round(value.min_price * 100)}
onChange={(e) => onChange={(e) =>
updateFilter( updateFilter(
"min_price", "min_price",
Math.min(Number(e.target.value) / 100, value.max_price), Math.min(Number(e.target.value) / 100, value.max_price),
) )
} }
className="scan-range-slider" className="scan-range-slider min"
/> />
<input <input
type="range" type="range"
min={0} min={0}
max={100} max={100}
value={value.max_price * 100} value={Math.round(value.max_price * 100)}
onChange={(e) => onChange={(e) =>
updateFilter( updateFilter(
"max_price", "max_price",
Math.max(Number(e.target.value) / 100, value.min_price), Math.max(Number(e.target.value) / 100, value.min_price),
) )
} }
className="scan-range-slider" className="scan-range-slider max"
/> />
</div>
<div className="scan-range-labels">
<span>{value.min_price.toFixed(2)}</span>
<span>{value.max_price.toFixed(2)}</span> <span>{value.max_price.toFixed(2)}</span>
</div> </div>
</div> </div>
{/* Min Edge */} <label className="scan-filter-row">
<div className="scan-filter-row"> <span className="scan-filter-row-label">
{isEn ? "Min Liquidity" : "最小成交量"}
</span>
<select
className="scan-select"
value={value.min_liquidity}
onChange={(e) => updateFilter("min_liquidity", Number(e.target.value))}
>
{LIQUIDITY_OPTIONS.map((option) => (
<option key={option} value={option}>
${option.toLocaleString("en-US")}
</option>
))}
</select>
</label>
<label className="scan-filter-row">
<span className="scan-filter-row-label"> <span className="scan-filter-row-label">
{isEn ? "Min Edge" : "最小边际优势"} {isEn ? "Min Edge" : "最小边际优势"}
</span> </span>
<div className="scan-range-display"> <select
<span>{value.min_edge_pct}%</span> className="scan-select"
<input value={value.min_edge_pct}
type="range" onChange={(e) => updateFilter("min_edge_pct", Number(e.target.value))}
min={0} >
max={20} {EDGE_OPTIONS.map((option) => (
value={value.min_edge_pct} <option key={option} value={option}>
onChange={(e) => {option}%
updateFilter("min_edge_pct", Number(e.target.value)) </option>
} ))}
className="scan-range-slider" </select>
/> </label>
</div>
</div>
{/* High Liquidity Only */} <div className="scan-filter-row inline">
<div className="scan-filter-row">
<span className="scan-filter-row-label"> <span className="scan-filter-row-label">
{isEn ? "High Liquidity Only" : "只看高流动性"} {isEn ? "High Liquidity Only" : "只看高流动性"}
</span> </span>
<button <button
type="button"
className={`scan-toggle ${value.high_liquidity_only ? "active" : ""}`} className={`scan-toggle ${value.high_liquidity_only ? "active" : ""}`}
onClick={() => { onClick={() => {
const nextValue = !value.high_liquidity_only; const nextValue = !value.high_liquidity_only;
@@ -180,16 +212,16 @@ export function ScanFilterPanel({
high_liquidity_only: nextValue, high_liquidity_only: nextValue,
min_liquidity: nextValue min_liquidity: nextValue
? Math.max(value.min_liquidity, 5000) ? Math.max(value.min_liquidity, 5000)
: 500, : value.min_liquidity,
}); });
}} }}
aria-pressed={value.high_liquidity_only}
> >
<span className="scan-toggle-knob" /> <span className="scan-toggle-knob" />
</button> </button>
</div> </div>
{/* Market Type */} <label className="scan-filter-row">
<div className="scan-filter-row">
<span className="scan-filter-row-label"> <span className="scan-filter-row-label">
{isEn ? "Market Type" : "市场类型"} {isEn ? "Market Type" : "市场类型"}
</span> </span>
@@ -208,12 +240,11 @@ export function ScanFilterPanel({
</option> </option>
<option value="all">{isEn ? "All Markets" : "所有市场"}</option> <option value="all">{isEn ? "All Markets" : "所有市场"}</option>
</select> </select>
</div> </label>
{/* Time Range */} <label className="scan-filter-row">
<div className="scan-filter-row">
<span className="scan-filter-row-label"> <span className="scan-filter-row-label">
{isEn ? "Time Range" : "时间周期"} {isEn ? "Time Range" : "时间范围"}
</span> </span>
<select <select
className="scan-select" className="scan-select"
@@ -229,11 +260,11 @@ export function ScanFilterPanel({
<option value="tomorrow">{isEn ? "Tomorrow" : "明天"}</option> <option value="tomorrow">{isEn ? "Tomorrow" : "明天"}</option>
<option value="week">{isEn ? "This Week" : "本周"}</option> <option value="week">{isEn ? "This Week" : "本周"}</option>
</select> </select>
</div> </label>
</div> </section>
{/* === CTA === */}
<button <button
type="button"
className="scan-cta-button" className="scan-cta-button"
onClick={() => onScan?.(value)} onClick={() => onScan?.(value)}
disabled={isScanning} disabled={isScanning}
+21 -36
View File
@@ -1,7 +1,6 @@
"use client"; "use client";
import React from "react"; import React from "react";
import { TrendingUp, BarChart3, Radio, DollarSign, Target } from "lucide-react";
import { useI18n } from "@/hooks/useI18n"; import { useI18n } from "@/hooks/useI18n";
import type { ScanTerminalResponse } from "@/lib/dashboard-types"; import type { ScanTerminalResponse } from "@/lib/dashboard-types";
@@ -17,68 +16,54 @@ export function ScanKPIBar({ data }: { data: KPIData }) {
const { locale } = useI18n(); const { locale } = useI18n();
const isEn = locale === "en-US"; const isEn = locale === "en-US";
const kpis = [ const cards = [
{ {
icon: Target,
label: isEn ? "Recommended" : "推荐机会", label: isEn ? "Recommended" : "推荐机会",
value: String(data.recommended_count), value: String(data.recommended_count),
delta: `${isEn ? "Showing" : "当前展示"} ${data.visible_count}`, note: `${isEn ? "Visible" : "当前展示"} ${data.visible_count}`,
accent: "green" as const, tone: "green",
}, },
{ {
icon: TrendingUp,
label: isEn ? "Avg Edge" : "平均边际优势", label: isEn ? "Avg Edge" : "平均边际优势",
value: value:
data.avg_edge_percent != null data.avg_edge_percent != null
? `+${data.avg_edge_percent.toFixed(1)}%` ? `+${data.avg_edge_percent.toFixed(1)}%`
: "--", : "--",
delta: `${isEn ? "Candidates" : "候选总数"} ${data.candidate_total}`, note: `${isEn ? "Candidates" : "候选总数"} ${data.candidate_total}`,
accent: "green" as const, tone: "purple",
}, },
{ {
icon: BarChart3,
label: isEn ? "Avg Confidence" : "平均主信号置信度", label: isEn ? "Avg Confidence" : "平均主信号置信度",
value: value:
data.avg_primary_confidence != null data.avg_primary_confidence != null
? `${data.avg_primary_confidence.toFixed(0)}` ? `+${data.avg_primary_confidence.toFixed(1)}%`
: "--", : "--",
delta: isEn ? "Main signal score" : "主信号评分", note: isEn ? "Main signal score" : "主信号评分",
accent: "green" as const, tone: "blue",
}, },
{ {
icon: Radio,
label: isEn ? "Tradable Markets" : "可交易市场", label: isEn ? "Tradable Markets" : "可交易市场",
value: String(data.tradable_market_count), value: String(data.tradable_market_count),
delta: data.resolved_market_type || "maxtemp", note: `${isEn ? "Filtered" : "过滤后"} / ${data.candidate_total || 0}`,
accent: "purple" as const, tone: "orange",
}, },
{ {
icon: DollarSign,
label: isEn ? "Total Volume" : "总成交量", label: isEn ? "Total Volume" : "总成交量",
value: formatVolume(data.total_volume), value: formatVolume(data.total_volume),
delta: isEn ? "Past 24h" : "过去 24 小时", note: isEn ? "Past 24 hours" : "过去 24 小时",
accent: "purple" as const, tone: "neutral",
}, },
]; ];
return ( return (
<div className="scan-kpi-bar"> <section className="scan-kpi-bar">
{kpis.map((kpi) => { {cards.map((card) => (
const Icon = kpi.icon; <article key={card.label} className={`scan-kpi-card ${card.tone}`}>
return ( <div className="scan-kpi-label">{card.label}</div>
<div <div className="scan-kpi-value">{card.value}</div>
key={kpi.label} <div className="scan-kpi-note">{card.note}</div>
className={`scan-kpi-card scan-kpi-${kpi.accent}`} </article>
> ))}
<div className="scan-kpi-header"> </section>
<Icon size={14} className="scan-kpi-icon" />
<span className="scan-kpi-label">{kpi.label}</span>
</div>
<div className="scan-kpi-value">{kpi.value}</div>
{kpi.delta && <div className="scan-kpi-delta">{kpi.delta}</div>}
</div>
);
})}
</div>
); );
} }
@@ -1,6 +1,12 @@
"use client"; "use client";
import clsx from "clsx"; import clsx from "clsx";
import {
Bell,
Menu,
RefreshCw,
X,
} from "lucide-react";
import { import {
startTransition, startTransition,
useDeferredValue, useDeferredValue,
@@ -11,14 +17,13 @@ import {
import styles from "./Dashboard.module.css"; import styles from "./Dashboard.module.css";
import detailChromeStyles from "./DetailPanelChrome.module.css"; import detailChromeStyles from "./DetailPanelChrome.module.css";
import modalChromeStyles from "./ModalChrome.module.css"; import modalChromeStyles from "./ModalChrome.module.css";
import { HeaderBar } from "@/components/dashboard/HeaderBar";
import { import {
FilterState, FilterState,
ScanFilterPanel, ScanFilterPanel,
} from "@/components/dashboard/ScanFilterPanel"; } from "@/components/dashboard/ScanFilterPanel";
import { ScanKPIBar } from "@/components/dashboard/ScanKPIBar"; import { ScanKPIBar } from "@/components/dashboard/ScanKPIBar";
import { OpportunityTable } from "@/components/dashboard/OpportunityTable"; import { OpportunityTable } from "@/components/dashboard/OpportunityTable";
import { DashboardStoreProvider, useDashboardStore } from "@/hooks/useDashboardStore"; import { DashboardStoreProvider } from "@/hooks/useDashboardStore";
import { I18nProvider, useI18n } from "@/hooks/useI18n"; import { I18nProvider, useI18n } from "@/hooks/useI18n";
import { dashboardClient } from "@/lib/dashboard-client"; import { dashboardClient } from "@/lib/dashboard-client";
import type { import type {
@@ -37,18 +42,19 @@ const DEFAULT_FILTERS: FilterState = {
min_price: 0.05, min_price: 0.05,
max_price: 0.95, max_price: 0.95,
min_edge_pct: 2, min_edge_pct: 2,
min_liquidity: 500, min_liquidity: 1000,
high_liquidity_only: false, high_liquidity_only: false,
market_type: "maxtemp", market_type: "maxtemp",
time_range: "today", time_range: "today",
limit: 25, limit: 28,
}; };
const NAV_ITEMS = ["扫描台", "市场", "分析", "组合", "监控", "设置"];
function formatPercent(value?: number | null, signed = false) { function formatPercent(value?: number | null, signed = false) {
if (value == null || Number.isNaN(Number(value))) return "--"; if (value == null || Number.isNaN(Number(value))) return "--";
const numeric = Number(value); const numeric = Number(value);
if (!signed) return `${numeric.toFixed(1)}%`; return `${signed && numeric >= 0 ? "+" : ""}${numeric.toFixed(1)}%`;
return `${numeric >= 0 ? "+" : ""}${numeric.toFixed(1)}%`;
} }
function formatProbability(value?: number | null) { function formatProbability(value?: number | null) {
@@ -69,28 +75,36 @@ function formatVolume(value?: number | null) {
return `$${numeric.toFixed(0)}`; return `$${numeric.toFixed(0)}`;
} }
function formatMinutes(value?: number | null, locale = "zh-CN") { function formatRemainingWindow(value?: number | null, locale = "zh-CN") {
if (value == null || !Number.isFinite(Number(value))) return "--"; if (value == null || !Number.isFinite(Number(value))) return "--";
const numeric = Math.max(0, Math.round(Number(value))); const numeric = Math.max(0, Math.round(Number(value)));
if (locale === "en-US") return `${numeric}m`; const hours = Math.floor(numeric / 60);
return `${numeric} 分钟`; const minutes = numeric % 60;
if (locale === "en-US") {
if (hours <= 0) return `${minutes}m`;
return `${hours}h ${minutes}m`;
}
if (hours <= 0) return `${minutes} 分钟`;
return `${hours}h ${minutes}m`;
} }
function scoreTone(score?: number | null) { function scoreTone(score?: number | null) {
const numeric = Number(score || 0); const numeric = Number(score || 0);
if (numeric >= 85) return "green"; if (numeric >= 85) return "green";
if (numeric >= 70) return "amber"; if (numeric >= 70) return "yellow";
return "red"; return "red";
} }
function confidenceDotCount(score?: number | null) { function confidenceLabel(score?: number | null, locale = "zh-CN") {
const numeric = Number(score || 0); const numeric = Number(score || 0);
if (numeric >= 90) return 5; if (locale === "en-US") {
if (numeric >= 80) return 4; if (numeric >= 85) return "High";
if (numeric >= 70) return 3; if (numeric >= 70) return "Medium";
if (numeric >= 60) return 2; return "Watch";
if (numeric > 0) return 1; }
return 0; if (numeric >= 85) return "高";
if (numeric >= 70) return "中";
return "观察";
} }
function getSideRow( function getSideRow(
@@ -98,7 +112,7 @@ function getSideRow(
selectedRow: ScanOpportunityRow, selectedRow: ScanOpportunityRow,
side: "yes" | "no", side: "yes" | "no",
) { ) {
const rows = Array.isArray(marketScan?.scan_rows) ? marketScan?.scan_rows : []; const rows = Array.isArray(marketScan?.scan_rows) ? marketScan.scan_rows : [];
const matched = rows.find( const matched = rows.find(
(row) => (row) =>
row.market_slug === selectedRow.market_slug && row.market_slug === selectedRow.market_slug &&
@@ -110,6 +124,36 @@ function getSideRow(
return null; return null;
} }
function buildComparisonBuckets(
marketScan: MarketScan | null | undefined,
row: ScanOpportunityRow | null,
) {
const buckets = Array.isArray(marketScan?.top_buckets)
? marketScan?.top_buckets
: Array.isArray(marketScan?.all_buckets)
? marketScan?.all_buckets?.slice(0, 6)
: [];
if (buckets.length) {
return buckets
.slice(0, 6)
.map((bucket) => ({
label: String(bucket.temp ?? bucket.value ?? bucket.label ?? "--"),
model: Number(bucket.probability ?? 0) * 100,
market: Number(bucket.market_price ?? bucket.yes_buy ?? 0) * 100,
}))
.filter((bucket) => bucket.label !== "--");
}
if (!row) return [];
return [
{
label: row.target_label || "--",
model: Number(row.model_event_probability || 0) * 100,
market: Number(row.market_event_probability || 0) * 100,
},
];
}
function DetailPanel({ function DetailPanel({
row, row,
marketScan, marketScan,
@@ -125,8 +169,13 @@ function DetailPanel({
if (!row) { if (!row) {
return ( return (
<aside className="scan-detail-panel"> <aside className="scan-detail-panel">
<div className="scan-detail-empty"> <div className="scan-empty-state">
{isEn ? "Select a row to inspect the main signal." : "选择一条机会,查看主信号详情。"} <div className="scan-empty-title">
{isEn ? "Pick a market row" : "选择一条机会"}
</div>
<div className="scan-empty-copy">
{isEn ? "The right rail will show the main signal details." : "右侧会展示主信号详情。"}
</div>
</div> </div>
</aside> </aside>
); );
@@ -137,195 +186,217 @@ function DetailPanel({
row.city_display_name || row.display_name || row.city, row.city_display_name || row.display_name || row.city,
locale, locale,
); );
const localizedAirport = getLocalizedAirportName( const localizedAirport = getLocalizedAirportName(row.city, row.airport || "", locale);
row.city,
row.airport || "",
locale,
);
const detailSignal = marketScan?.primary_signal as PrimarySignal | null | undefined; const detailSignal = marketScan?.primary_signal as PrimarySignal | null | undefined;
const displayRow = detailSignal && detailSignal.market_slug === row.market_slug ? detailSignal : row; const displayRow =
const distributionBias = marketScan?.distribution_bias || row.distribution_bias || null; detailSignal && detailSignal.market_slug === row.market_slug
? detailSignal
: row;
const yesRow = getSideRow(marketScan, row, "yes"); const yesRow = getSideRow(marketScan, row, "yes");
const noRow = getSideRow(marketScan, row, "no"); const noRow = getSideRow(marketScan, row, "no");
const tone = scoreTone(displayRow.final_score); const comparisonBuckets = buildComparisonBuckets(marketScan, row);
const filledDots = confidenceDotCount(displayRow.final_score); const maxBar = Math.max(
1,
...comparisonBuckets.flatMap((bucket) => [bucket.model, bucket.market]),
);
const scoreClass = scoreTone(displayRow.final_score);
return ( return (
<aside className="scan-detail-panel"> <aside className="scan-detail-panel">
<div className="scan-detail-header"> <div className="scan-detail-header">
<div className="scan-detail-hero-placeholder" /> <div className="scan-detail-top">
<div className="scan-detail-city-info"> <div className="scan-detail-hero-placeholder" />
<div className="scan-detail-city-name">{localizedCityName}</div> <div className="scan-detail-title-wrap">
<div className="scan-detail-city-sub"> <div className="scan-detail-city-name">{localizedCityName}</div>
{displayRow.market_question || displayRow.target_label || "--"} <div className="scan-detail-city-sub">
{displayRow.market_question || displayRow.target_label || "--"}
</div>
<div className="scan-phase-badge red">
{displayRow.window_phase || (isEn ? "Main Signal" : "主信号")}
</div>
</div> </div>
<div className="scan-detail-volume"> </div>
{formatVolume(displayRow.volume)}{" "} <button type="button" className="scan-detail-icon-button" aria-label="close">
<X size={16} />
</button>
</div>
<div className="scan-detail-volume-row">
<div>
<div className="scan-detail-volume-big">{formatVolume(displayRow.volume)}</div>
<div className="scan-detail-volume-caption">
{isEn ? "24h volume" : "24h 成交量"} {isEn ? "24h volume" : "24h 成交量"}
{loading ? ` · ${isEn ? "loading" : "载入中"}` : ""} {loading ? ` · ${isEn ? "loading" : "载入中"}` : ""}
</div> </div>
</div> </div>
<button type="button" className="scan-detail-action-button">
{isEn ? "Add Watch" : "添加自选"}
</button>
</div> </div>
<div className="scan-detail-section"> <section className="scan-detail-section">
<div className="scan-detail-section-title"> <div className="scan-detail-section-title">
{isEn ? "Current Context" : "当前概况"} {isEn ? "Current Context" : "当前概况"}
</div> </div>
<div className="scan-conditions-table"> <div className="scan-kv-list">
<div className="scan-condition-item"> <div className="scan-kv">
<span className="scan-condition-label"> <span>{isEn ? "Local Time" : "当前时间"}</span>
{isEn ? "Local Time" : "当地时间"} <strong>{row.local_time || "--"}</strong>
</span>
<span className="scan-condition-value">{row.local_time || "--"}</span>
</div> </div>
<div className="scan-condition-item"> <div className="scan-kv">
<span className="scan-condition-label"> <span>{isEn ? "Current Temp" : "当前温度"}</span>
{isEn ? "Current Temp" : "当前温度"} <strong>
</span>
<span className="scan-condition-value">
{row.current_temp != null ? `${row.current_temp}${row.temp_symbol || ""}` : "--"} {row.current_temp != null ? `${row.current_temp}${row.temp_symbol || ""}` : "--"}
</span> </strong>
</div> </div>
<div className="scan-condition-item"> <div className="scan-kv">
<span className="scan-condition-label"> <span>{isEn ? "Day High (So Far)" : "今日最高(至今)"}</span>
{isEn ? "Day High (So Far)" : "今日高点(至今)"} <strong>
</span>
<span className="scan-condition-value">
{row.current_max_so_far != null {row.current_max_so_far != null
? `${row.current_max_so_far}${row.temp_symbol || ""}` ? `${row.current_max_so_far}${row.temp_symbol || ""}`
: "--"} : "--"}
</span> </strong>
</div> </div>
<div className="scan-condition-item"> <div className="scan-kv">
<span className="scan-condition-label"> <span>{isEn ? "Target" : "目标温度"}</span>
{isEn ? "Target" : "目标温度"} <strong>{displayRow.target_label || "--"}</strong>
</span>
<span className="scan-condition-value">
{displayRow.target_label || "--"}
</span>
</div> </div>
<div className="scan-condition-item"> <div className="scan-kv">
<span className="scan-condition-label"> <span>{isEn ? "Gap To Target" : "距离目标"}</span>
{isEn ? "Gap To Target" : "距目标"} <strong className={Number(displayRow.gap_to_target || 0) <= 0 ? "warn" : "danger"}>
</span>
<span
className={clsx(
"scan-condition-value",
Number(displayRow.gap_to_target || 0) <= 0
? "accent-green"
: "accent-red",
)}
>
{displayRow.gap_to_target != null {displayRow.gap_to_target != null
? `${displayRow.gap_to_target >= 0 ? "+" : ""}${displayRow.gap_to_target.toFixed(1)}${row.temp_symbol || ""}` ? `${displayRow.gap_to_target >= 0 ? "+" : ""}${displayRow.gap_to_target.toFixed(1)}${row.temp_symbol || ""}`
: "--"} : "--"}
</span> </strong>
</div> </div>
<div className="scan-condition-item"> <div className="scan-kv">
<span className="scan-condition-label"> <span>{isEn ? "Window Left" : "剩余有效时间"}</span>
{isEn ? "Window Left" : "剩余有效时间"} <strong>{formatRemainingWindow(displayRow.remaining_window_minutes, locale)}</strong>
</span>
<span className="scan-condition-value">
{formatMinutes(displayRow.remaining_window_minutes, locale)}
</span>
</div> </div>
<div className="scan-condition-item"> <div className="scan-kv">
<span className="scan-condition-label"> <span>{isEn ? "Bias" : "分布偏移"}</span>
{isEn ? "Bias" : "分布偏移"} <strong>
</span> {displayRow.distribution_bias_direction || "--"} ·{" "}
<span className="scan-condition-value"> {displayRow.distribution_bias_score != null
{distributionBias?.direction || "--"} ·{" "} ? displayRow.distribution_bias_score.toFixed(0)
{distributionBias?.score != null
? distributionBias.score.toFixed(0)
: "--"} : "--"}
</span> </strong>
</div> </div>
<div className="scan-condition-item"> <div className="scan-kv">
<span className="scan-condition-label"> <span>{isEn ? "Airport" : "机场锚点"}</span>
{isEn ? "Airport" : "机场锚点"} <strong>{localizedAirport || "--"}</strong>
</span>
<span className="scan-condition-value">{localizedAirport || "--"}</span>
</div> </div>
</div> </div>
</div> </section>
<div className="scan-detail-section"> <section className="scan-detail-section">
<div className="scan-timeline-head">
<span>00:00</span>
<span>23:59</span>
</div>
<div className="scan-timeline-bar">
<span
className="scan-timeline-knob"
style={{
left: `${Math.max(
8,
Math.min(
92,
100 - Math.min(100, Number(displayRow.remaining_window_minutes || 0) / 9),
),
)}%`,
}}
/>
</div>
<div className="scan-timeline-caption">
{displayRow.window_phase || (isEn ? "Window phase" : "窗口阶段")}
</div>
</section>
<section className="scan-detail-section">
<div className="scan-detail-section-title"> <div className="scan-detail-section-title">
{isEn ? "Recommended Trade" : "推荐交易"} {isEn ? "Probability Comparison" : "概率分布对比"}
</div> </div>
<div className="scan-trade-cards"> <div className="scan-chart-legend">
<div className="scan-trade-card yes"> <span>
<div className="scan-trade-card-title">BUY YES</div> <i className="dot green" />
<div className="scan-trade-card-price"> {isEn ? "Model" : "模型预测"}
{formatPrice(yesRow?.ask ?? marketScan?.yes_buy)} / {formatProbability(yesRow?.model_probability)} </span>
</div> <span>
<div <i className="dot blue" />
className={clsx( {isEn ? "Market" : "市场隐含"}
"scan-trade-card-edge", </span>
Number(yesRow?.edge_percent || 0) >= 0 ? "positive" : "negative",
)}
>
{formatPercent(yesRow?.edge_percent, true)}
</div>
<div className="scan-trade-card-note">
{isEn ? "Spread" : "点差"} {formatPrice(yesRow?.spread)} ·{" "}
{isEn ? "Liquidity" : "流动性"} {formatVolume(yesRow?.book_liquidity ?? yesRow?.market_liquidity)}
</div>
</div>
<div className="scan-trade-card no">
<div className="scan-trade-card-title">BUY NO</div>
<div className="scan-trade-card-price">
{formatPrice(noRow?.ask ?? marketScan?.no_buy)} / {formatProbability(noRow?.model_probability)}
</div>
<div
className={clsx(
"scan-trade-card-edge",
Number(noRow?.edge_percent || 0) >= 0 ? "positive" : "negative",
)}
>
{formatPercent(noRow?.edge_percent, true)}
</div>
<div className="scan-trade-card-note">
{isEn ? "Spread" : "点差"} {formatPrice(noRow?.spread)} ·{" "}
{isEn ? "Liquidity" : "流动性"} {formatVolume(noRow?.book_liquidity ?? noRow?.market_liquidity)}
</div>
</div>
</div> </div>
</div> <div className="scan-chart-bars">
{comparisonBuckets.map((bucket) => (
<div className="scan-detail-score"> <div key={bucket.label} className="scan-chart-group">
<div> <div
<div className="scan-detail-score-big"> className="scan-chart-col model"
{Number(displayRow.final_score || 0).toFixed(0)} style={{ height: `${Math.max(8, (bucket.model / maxBar) * 120)}px` }}
<span className="scan-detail-score-suffix">/100</span>
</div>
<div className="scan-detail-score-label">
{isEn ? "Composite signal score" : "综合主信号评分"}
</div>
</div>
<div className="scan-confidence-dots">
{Array.from({ length: 5 }).map((_, index) => {
const filled = index < filledDots;
return (
<span
key={index}
className={clsx(
"scan-confidence-dot",
filled && "filled",
filled && tone === "amber" && "amber",
filled && tone === "red" && "red",
)}
/> />
); <div
})} className="scan-chart-col market"
style={{ height: `${Math.max(8, (bucket.market / maxBar) * 120)}px` }}
/>
<div className="scan-chart-label">{bucket.label}</div>
</div>
))}
</div> </div>
</div> </section>
<section className="scan-detail-section">
<div className="scan-trade-cards">
<div className="scan-trade-card buy">
<div className="scan-trade-card-title">
{isEn ? "Buy Yes" : "买入 Yes"} {displayRow.target_label || ""}
</div>
<p>
{formatPrice(yesRow?.ask ?? marketScan?.yes_buy)} {" "}
{formatProbability(yesRow?.model_probability)}
</p>
<p className="positive">{formatPercent(yesRow?.edge_percent, true)} {isEn ? "edge" : "边际优势"}</p>
<p>{isEn ? "Spread" : "点差"} {formatPrice(yesRow?.spread)}</p>
</div>
<div className="scan-trade-card sell">
<div className="scan-trade-card-title">
{isEn ? "Buy No" : "买入 No"} {displayRow.target_label || ""}
</div>
<p>
{formatPrice(noRow?.ask ?? marketScan?.no_buy)} {" "}
{formatProbability(noRow?.model_probability)}
</p>
<p className={Number(noRow?.edge_percent || 0) >= 0 ? "positive" : "negative"}>
{formatPercent(noRow?.edge_percent, true)} {isEn ? "edge" : "边际优势"}
</p>
<p>{isEn ? "Spread" : "点差"} {formatPrice(noRow?.spread)}</p>
</div>
</div>
</section>
<section className="scan-detail-score-block">
<div className="scan-detail-score-head">
<div>
<div className="scan-detail-score-label-text">
{isEn ? "Composite Score" : "综合得分"}
</div>
<div className="scan-detail-score-meta">
{isEn ? "Confidence" : "置信度"}: {confidenceLabel(displayRow.final_score, locale)}
</div>
</div>
<div className={`scan-detail-score-value ${scoreClass}`}>
{Number(displayRow.final_score || 0).toFixed(0)}
<span>/100</span>
</div>
</div>
<div className="scan-detail-score-line">
<span style={{ width: `${Math.max(0, Math.min(100, Number(displayRow.final_score || 0)))}%` }} />
</div>
</section>
</aside> </aside>
); );
} }
function ScanTerminalScreen() { function ScanTerminalScreen() {
const store = useDashboardStore();
const { locale } = useI18n(); const { locale } = useI18n();
const isEn = locale === "en-US"; const isEn = locale === "en-US";
const [draftFilters, setDraftFilters] = useState<FilterState>(DEFAULT_FILTERS); const [draftFilters, setDraftFilters] = useState<FilterState>(DEFAULT_FILTERS);
@@ -336,15 +407,11 @@ function ScanTerminalScreen() {
const [selectedRowId, setSelectedRowId] = useState<string | null>(null); const [selectedRowId, setSelectedRowId] = useState<string | null>(null);
const [detailByRowId, setDetailByRowId] = useState<Record<string, MarketScan | null>>({}); const [detailByRowId, setDetailByRowId] = useState<Record<string, MarketScan | null>>({});
const [detailLoadingId, setDetailLoadingId] = useState<string | null>(null); const [detailLoadingId, setDetailLoadingId] = useState<string | null>(null);
const deferredRows = useDeferredValue(terminalData?.rows || []); const deferredRows = useDeferredValue(terminalData?.rows || []);
const selectedRow = useMemo(() => { const selectedRow = useMemo(() => {
if (!deferredRows.length) return null; if (!deferredRows.length) return null;
return ( return deferredRows.find((row) => row.id === selectedRowId) || deferredRows[0] || null;
deferredRows.find((row) => row.id === selectedRowId) ||
deferredRows[0] ||
null
);
}, [deferredRows, selectedRowId]); }, [deferredRows, selectedRowId]);
const fetchTerminal = async (filters: FilterState, force = false) => { const fetchTerminal = async (filters: FilterState, force = false) => {
@@ -401,9 +468,7 @@ function ScanTerminalScreen() {
const intervalId = window.setInterval(() => { const intervalId = window.setInterval(() => {
void fetchTerminal(activeFilters, false); void fetchTerminal(activeFilters, false);
}, 30_000); }, 30_000);
return () => { return () => window.clearInterval(intervalId);
window.clearInterval(intervalId);
};
}, [activeFilters]); }, [activeFilters]);
useEffect(() => { useEffect(() => {
@@ -414,18 +479,7 @@ function ScanTerminalScreen() {
const selectedDetail = selectedRow ? detailByRowId[selectedRow.id] : null; const selectedDetail = selectedRow ? detailByRowId[selectedRow.id] : null;
return ( return (
<div <div className={clsx(styles.root, detailChromeStyles.root, modalChromeStyles.root)}>
className={clsx(
styles.root,
detailChromeStyles.root,
modalChromeStyles.root,
)}
>
<HeaderBar
refreshAction={() => fetchTerminal(activeFilters, true)}
refreshSpinning={loading || store.loadingState.refresh}
/>
<div className="scan-terminal"> <div className="scan-terminal">
<ScanFilterPanel <ScanFilterPanel
value={draftFilters} value={draftFilters}
@@ -438,27 +492,42 @@ function ScanTerminalScreen() {
/> />
<main className="scan-data-grid"> <main className="scan-data-grid">
<div className="scan-data-grid-header"> <div className="scan-topbar">
<div> <div className="scan-topbar-tabs">
<div className="scan-data-grid-title"> {NAV_ITEMS.map((item, index) => (
{isEn ? "Tradable Opportunities" : "可交易机会"} <button
</div> key={item}
<div className="scan-data-grid-subtitle"> type="button"
{isEn className={`scan-topbar-tab ${index === 0 ? "active" : ""}`}
? "REST-only EMOS scan with one main signal per city/date." >
: "基于 EMOS 分布与 CLOB REST 盘口,只输出每个城市/日期的单一主信号。"} {item}
</div> </button>
))}
</div> </div>
<div className="scan-data-grid-controls"> <div className="scan-topbar-actions">
<span className="scan-status-badge tone-neutral"> <span className="scan-topbar-time">
{isEn ? "Updated" : "数据时间"} ·{" "} {selectedRow?.local_time || terminalData?.generated_at?.replace("T", " ").slice(11, 19) || "--"}
{terminalData?.generated_at
? terminalData.generated_at.replace("T", " ").slice(0, 19)
: "--"}
</span> </span>
<button type="button" className="scan-ghost-button" onClick={() => void fetchTerminal(activeFilters, true)}>
<RefreshCw size={14} className={loading ? "spin" : undefined} />
{isEn ? "Refresh" : "筛选"}
</button>
<button type="button" className="scan-cta-ghost">
<Bell size={14} />
{isEn ? "Custom Alerts" : "自定义提醒"}
</button>
</div> </div>
</div> </div>
<section className="scan-hero">
<h1>{isEn ? "Tradable Opportunities" : "可交易机会"}</h1>
<p>
{isEn
? "Use EMOS distribution, live order book, and timing windows to isolate the one actionable signal."
: "基于当前时间、实况数据和模型预测,筛选出最具交易价值的市场。"}
</p>
</section>
<ScanKPIBar <ScanKPIBar
data={ data={
terminalData?.summary || { terminalData?.summary || {
@@ -474,23 +543,51 @@ function ScanTerminalScreen() {
} }
/> />
<div className="scan-view-tabs"> <section className="scan-list-section">
<button type="button" className="scan-view-tab active"> <div className="scan-list-header">
{isEn ? "Opportunity List" : "机会列表"} <div className="scan-list-tabs">
</button> <button type="button" className="active">
</div> {isEn ? "Opportunity List" : "机会列表"}
</button>
{error ? ( <button type="button">{isEn ? "Distribution View" : "分布视图"}</button>
<div className="scan-detail-empty"> <button type="button">{isEn ? "Calendar View" : "日历视图"}</button>
{isEn ? "Scan failed." : "扫描失败。"} {error} </div>
<div className="scan-list-controls">
<div className="scan-sort-pill">
{isEn ? "Sort: Score" : "排序:综合得分"}
</div>
<button type="button" className="scan-icon-pill" aria-label="menu">
<Menu size={16} />
</button>
</div>
</div> </div>
) : (
<OpportunityTable {error ? (
rows={deferredRows} <div className="scan-empty-state">
selectedRowId={selectedRowId} <div className="scan-empty-title">
onSelectRow={(row) => setSelectedRowId(row.id)} {isEn ? "Scan failed" : "扫描失败"}
/> </div>
)} <div className="scan-empty-copy">{error}</div>
</div>
) : (
<>
<OpportunityTable
rows={deferredRows}
selectedRowId={selectedRowId}
onSelectRow={(row) => setSelectedRowId(row.id)}
/>
{deferredRows.length ? (
<div className="scan-view-all-wrap">
<button type="button" className="scan-view-all-button">
{isEn
? `View all ${deferredRows.length} opportunities`
: `查看全部 ${deferredRows.length} 个机会`}
</button>
</div>
) : null}
</>
)}
</section>
</main> </main>
<DetailPanel <DetailPanel