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 { 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) {
if (value == null || Number.isNaN(Number(value))) return "--";
const numeric = Number(value);
if (!signed) return `${numeric.toFixed(1)}%`;
return `${numeric >= 0 ? "+" : ""}${numeric.toFixed(1)}%`;
return `${signed && numeric >= 0 ? "+" : ""}${numeric.toFixed(1)}%`;
}
function formatTimeBlock(row: ScanOpportunityRow, locale: string) {
const parts: string[] = [];
if (row.local_time) {
parts.push(row.local_time);
function formatVolume(value?: number | null) {
const numeric = Number(value || 0);
if (!Number.isFinite(numeric) || numeric <= 0) return "--";
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) {
parts.push(row.selected_date);
}
return parts.join(" · ") || (locale === "en-US" ? "No time" : "暂无时间");
if (hours <= 0) return `剩余 ${remains} 分钟`;
return `剩余 ${hours}h ${remains}m`;
}
function formatAction(row: ScanOpportunityRow, locale: string) {
if (row.action) return row.action;
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") {
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) {
if (value == null || Number.isNaN(Number(value))) return "--";
return `${(Number(value) * 100).toFixed(0)}%`;
function getPhaseMeta(
row: ScanOpportunityRow,
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 }) {
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 (
<div className="scan-score-ring">
<svg viewBox="0 0 48 48" width={48} height={48}>
<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 className={`scan-score-ring tone-${scoreTone(displayScore)}`}>
<span>{displayScore.toFixed(0)}</span>
</div>
);
}
@@ -106,124 +141,111 @@ export function OpportunityTable({
const { locale } = useI18n();
const isEn = locale === "en-US";
return (
<div className="scan-table-container">
<div className="scan-table-header">
<span className="scan-th scan-th-rank">#</span>
<span className="scan-th scan-th-city">
{isEn ? "City / Market" : "城市 / 市场"}
</span>
<span className="scan-th scan-th-time">
{isEn ? "Time / Phase" : "时间 / 阶段"}
</span>
<span className="scan-th scan-th-prob">
{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>
if (!rows.length) {
return (
<div className="scan-table-shell empty">
<div className="scan-empty-state">
<div className="scan-empty-title">
{isEn ? "No main signal right now" : "当前无主信号"}
</div>
<div className="scan-empty-copy">
{isEn
? "No row passed the price, spread, liquidity, and edge thresholds."
: "当前没有机会同时满足价格、点差、流动性和 edge 过滤。"}
</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>
);
}
+100 -69
View File
@@ -2,12 +2,12 @@
import React from "react";
import {
Crosshair,
Clock,
Zap,
TrendingUp,
SlidersHorizontal,
Bolt,
CircleDot,
Clock3,
Info,
Search,
TrendingUp,
} from "lucide-react";
import { useI18n } from "@/hooks/useI18n";
import type { ScanTerminalFilters } from "@/lib/dashboard-types";
@@ -17,38 +17,41 @@ export interface FilterState extends ScanTerminalFilters {}
const SCAN_MODES = [
{
key: "tradable" as const,
icon: Crosshair,
icon: Bolt,
labelEn: "Tradable",
labelZh: "可交易机会",
descEn: "Markets with immediate trading value",
descZh: "交易价值最高的市场",
descEn: "Find the best immediate trade",
descZh: "发现当前最值得交易的市场",
},
{
key: "early" as const,
icon: Clock,
icon: Clock3,
labelEn: "Early",
labelZh: "早期机会",
descEn: "Long-horizon positions",
descZh: "长周期布局",
descEn: "Long-horizon, lower-priced setups",
descZh: "长时间布局,低价市场",
},
{
key: "touch" as const,
icon: Zap,
icon: CircleDot,
labelEn: "Touch Play",
labelZh: "触达博弈",
descEn: "Approaching settle threshold",
descZh: "触达博弈最高的市场",
descEn: "Markets approaching the settle line",
descZh: "接近决策,博弈是否触达",
},
{
key: "trend" as const,
icon: TrendingUp,
labelEn: "Trend",
labelZh: "趋势确认",
descEn: "Trend-confirmed opportunities",
descZh: "趋势确认、顺势加仓",
descEn: "Trend-confirmed follow-through",
descZh: "趋势明朗,顺势交易",
},
] as const;
const LIQUIDITY_OPTIONS = [500, 1000, 5000, 10000];
const EDGE_OPTIONS = [1, 2, 3, 5, 8];
export function ScanFilterPanel({
value,
onChange,
@@ -75,10 +78,19 @@ export function ScanFilterPanel({
return (
<aside className="scan-filter-panel">
{/* === Scan Mode Section === */}
<div className="scan-filter-section">
<div className="scan-filter-label">
{isEn ? "Scan Mode" : "扫描模式"}
<div className="scan-sidebar-brand">
<div className="scan-sidebar-brand-mark">
<Bolt size={22} strokeWidth={2.2} />
</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 className="scan-mode-tabs">
{SCAN_MODES.map((mode) => {
@@ -87,91 +99,111 @@ export function ScanFilterPanel({
return (
<button
key={mode.key}
type="button"
className={`scan-mode-tab ${isActive ? "active" : ""}`}
onClick={() => updateFilter("scan_mode", mode.key)}
title={isEn ? mode.descEn : mode.descZh}
>
<Icon size={16} />
<span className="scan-mode-tab-label">
{isEn ? mode.labelEn : mode.labelZh}
<span className="scan-mode-icon">
<Icon size={16} />
</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>
{isActive && <span className="scan-mode-tab-indicator" />}
</button>
);
})}
</div>
</div>
</section>
{/* === Filter Controls === */}
<div className="scan-filter-section">
<div className="scan-filter-label">
<SlidersHorizontal size={14} />
{isEn ? "Filter Criteria" : "筛选条件"}
<section className="scan-filter-section">
<div className="scan-filter-heading">
<span>{isEn ? "Filters" : "筛选条件"}</span>
<Info size={14} />
</div>
{/* Price Range */}
<div className="scan-filter-row">
<span className="scan-filter-row-label">
<div className="scan-range-card">
<div className="scan-filter-row-title">
{isEn ? "Price Range" : "价格范围"}
</span>
<div className="scan-range-display">
<span>{value.min_price.toFixed(2)}</span>
</div>
<div className="scan-range-track-wrap">
<input
type="range"
min={0}
max={100}
value={value.min_price * 100}
value={Math.round(value.min_price * 100)}
onChange={(e) =>
updateFilter(
"min_price",
Math.min(Number(e.target.value) / 100, value.max_price),
)
}
className="scan-range-slider"
className="scan-range-slider min"
/>
<input
type="range"
min={0}
max={100}
value={value.max_price * 100}
value={Math.round(value.max_price * 100)}
onChange={(e) =>
updateFilter(
"max_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>
</div>
</div>
{/* Min Edge */}
<div className="scan-filter-row">
<label 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">
{isEn ? "Min Edge" : "最小边际优势"}
</span>
<div className="scan-range-display">
<span>{value.min_edge_pct}%</span>
<input
type="range"
min={0}
max={20}
value={value.min_edge_pct}
onChange={(e) =>
updateFilter("min_edge_pct", Number(e.target.value))
}
className="scan-range-slider"
/>
</div>
</div>
<select
className="scan-select"
value={value.min_edge_pct}
onChange={(e) => updateFilter("min_edge_pct", Number(e.target.value))}
>
{EDGE_OPTIONS.map((option) => (
<option key={option} value={option}>
{option}%
</option>
))}
</select>
</label>
{/* High Liquidity Only */}
<div className="scan-filter-row">
<div className="scan-filter-row inline">
<span className="scan-filter-row-label">
{isEn ? "High Liquidity Only" : "只看高流动性"}
</span>
<button
type="button"
className={`scan-toggle ${value.high_liquidity_only ? "active" : ""}`}
onClick={() => {
const nextValue = !value.high_liquidity_only;
@@ -180,16 +212,16 @@ export function ScanFilterPanel({
high_liquidity_only: nextValue,
min_liquidity: nextValue
? Math.max(value.min_liquidity, 5000)
: 500,
: value.min_liquidity,
});
}}
aria-pressed={value.high_liquidity_only}
>
<span className="scan-toggle-knob" />
</button>
</div>
{/* Market Type */}
<div className="scan-filter-row">
<label className="scan-filter-row">
<span className="scan-filter-row-label">
{isEn ? "Market Type" : "市场类型"}
</span>
@@ -208,12 +240,11 @@ export function ScanFilterPanel({
</option>
<option value="all">{isEn ? "All Markets" : "所有市场"}</option>
</select>
</div>
</label>
{/* Time Range */}
<div className="scan-filter-row">
<label className="scan-filter-row">
<span className="scan-filter-row-label">
{isEn ? "Time Range" : "时间周期"}
{isEn ? "Time Range" : "时间范围"}
</span>
<select
className="scan-select"
@@ -229,11 +260,11 @@ export function ScanFilterPanel({
<option value="tomorrow">{isEn ? "Tomorrow" : "明天"}</option>
<option value="week">{isEn ? "This Week" : "本周"}</option>
</select>
</div>
</div>
</label>
</section>
{/* === CTA === */}
<button
type="button"
className="scan-cta-button"
onClick={() => onScan?.(value)}
disabled={isScanning}
+21 -36
View File
@@ -1,7 +1,6 @@
"use client";
import React from "react";
import { TrendingUp, BarChart3, Radio, DollarSign, Target } from "lucide-react";
import { useI18n } from "@/hooks/useI18n";
import type { ScanTerminalResponse } from "@/lib/dashboard-types";
@@ -17,68 +16,54 @@ export function ScanKPIBar({ data }: { data: KPIData }) {
const { locale } = useI18n();
const isEn = locale === "en-US";
const kpis = [
const cards = [
{
icon: Target,
label: isEn ? "Recommended" : "推荐机会",
value: String(data.recommended_count),
delta: `${isEn ? "Showing" : "当前展示"} ${data.visible_count}`,
accent: "green" as const,
note: `${isEn ? "Visible" : "当前展示"} ${data.visible_count}`,
tone: "green",
},
{
icon: TrendingUp,
label: isEn ? "Avg Edge" : "平均边际优势",
value:
data.avg_edge_percent != null
? `+${data.avg_edge_percent.toFixed(1)}%`
: "--",
delta: `${isEn ? "Candidates" : "候选总数"} ${data.candidate_total}`,
accent: "green" as const,
note: `${isEn ? "Candidates" : "候选总数"} ${data.candidate_total}`,
tone: "purple",
},
{
icon: BarChart3,
label: isEn ? "Avg Confidence" : "平均主信号置信度",
value:
data.avg_primary_confidence != null
? `${data.avg_primary_confidence.toFixed(0)}`
? `+${data.avg_primary_confidence.toFixed(1)}%`
: "--",
delta: isEn ? "Main signal score" : "主信号评分",
accent: "green" as const,
note: isEn ? "Main signal score" : "主信号评分",
tone: "blue",
},
{
icon: Radio,
label: isEn ? "Tradable Markets" : "可交易市场",
value: String(data.tradable_market_count),
delta: data.resolved_market_type || "maxtemp",
accent: "purple" as const,
note: `${isEn ? "Filtered" : "过滤后"} / ${data.candidate_total || 0}`,
tone: "orange",
},
{
icon: DollarSign,
label: isEn ? "Total Volume" : "总成交量",
value: formatVolume(data.total_volume),
delta: isEn ? "Past 24h" : "过去 24 小时",
accent: "purple" as const,
note: isEn ? "Past 24 hours" : "过去 24 小时",
tone: "neutral",
},
];
return (
<div className="scan-kpi-bar">
{kpis.map((kpi) => {
const Icon = kpi.icon;
return (
<div
key={kpi.label}
className={`scan-kpi-card scan-kpi-${kpi.accent}`}
>
<div className="scan-kpi-header">
<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>
<section className="scan-kpi-bar">
{cards.map((card) => (
<article key={card.label} className={`scan-kpi-card ${card.tone}`}>
<div className="scan-kpi-label">{card.label}</div>
<div className="scan-kpi-value">{card.value}</div>
<div className="scan-kpi-note">{card.note}</div>
</article>
))}
</section>
);
}
@@ -1,6 +1,12 @@
"use client";
import clsx from "clsx";
import {
Bell,
Menu,
RefreshCw,
X,
} from "lucide-react";
import {
startTransition,
useDeferredValue,
@@ -11,14 +17,13 @@ import {
import styles from "./Dashboard.module.css";
import detailChromeStyles from "./DetailPanelChrome.module.css";
import modalChromeStyles from "./ModalChrome.module.css";
import { HeaderBar } from "@/components/dashboard/HeaderBar";
import {
FilterState,
ScanFilterPanel,
} from "@/components/dashboard/ScanFilterPanel";
import { ScanKPIBar } from "@/components/dashboard/ScanKPIBar";
import { OpportunityTable } from "@/components/dashboard/OpportunityTable";
import { DashboardStoreProvider, useDashboardStore } from "@/hooks/useDashboardStore";
import { DashboardStoreProvider } from "@/hooks/useDashboardStore";
import { I18nProvider, useI18n } from "@/hooks/useI18n";
import { dashboardClient } from "@/lib/dashboard-client";
import type {
@@ -37,18 +42,19 @@ const DEFAULT_FILTERS: FilterState = {
min_price: 0.05,
max_price: 0.95,
min_edge_pct: 2,
min_liquidity: 500,
min_liquidity: 1000,
high_liquidity_only: false,
market_type: "maxtemp",
time_range: "today",
limit: 25,
limit: 28,
};
const NAV_ITEMS = ["扫描台", "市场", "分析", "组合", "监控", "设置"];
function formatPercent(value?: number | null, signed = false) {
if (value == null || Number.isNaN(Number(value))) return "--";
const numeric = Number(value);
if (!signed) return `${numeric.toFixed(1)}%`;
return `${numeric >= 0 ? "+" : ""}${numeric.toFixed(1)}%`;
return `${signed && numeric >= 0 ? "+" : ""}${numeric.toFixed(1)}%`;
}
function formatProbability(value?: number | null) {
@@ -69,28 +75,36 @@ function formatVolume(value?: number | null) {
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 "--";
const numeric = Math.max(0, Math.round(Number(value)));
if (locale === "en-US") return `${numeric}m`;
return `${numeric} 分钟`;
const hours = Math.floor(numeric / 60);
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) {
const numeric = Number(score || 0);
if (numeric >= 85) return "green";
if (numeric >= 70) return "amber";
if (numeric >= 70) return "yellow";
return "red";
}
function confidenceDotCount(score?: number | null) {
function confidenceLabel(score?: number | null, locale = "zh-CN") {
const numeric = Number(score || 0);
if (numeric >= 90) return 5;
if (numeric >= 80) return 4;
if (numeric >= 70) return 3;
if (numeric >= 60) return 2;
if (numeric > 0) return 1;
return 0;
if (locale === "en-US") {
if (numeric >= 85) return "High";
if (numeric >= 70) return "Medium";
return "Watch";
}
if (numeric >= 85) return "高";
if (numeric >= 70) return "中";
return "观察";
}
function getSideRow(
@@ -98,7 +112,7 @@ function getSideRow(
selectedRow: ScanOpportunityRow,
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(
(row) =>
row.market_slug === selectedRow.market_slug &&
@@ -110,6 +124,36 @@ function getSideRow(
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({
row,
marketScan,
@@ -125,8 +169,13 @@ function DetailPanel({
if (!row) {
return (
<aside className="scan-detail-panel">
<div className="scan-detail-empty">
{isEn ? "Select a row to inspect the main signal." : "选择一条机会,查看主信号详情。"}
<div className="scan-empty-state">
<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>
</aside>
);
@@ -137,195 +186,217 @@ function DetailPanel({
row.city_display_name || row.display_name || row.city,
locale,
);
const localizedAirport = getLocalizedAirportName(
row.city,
row.airport || "",
locale,
);
const localizedAirport = getLocalizedAirportName(row.city, row.airport || "", locale);
const detailSignal = marketScan?.primary_signal as PrimarySignal | null | undefined;
const displayRow = detailSignal && detailSignal.market_slug === row.market_slug ? detailSignal : row;
const distributionBias = marketScan?.distribution_bias || row.distribution_bias || null;
const displayRow =
detailSignal && detailSignal.market_slug === row.market_slug
? detailSignal
: row;
const yesRow = getSideRow(marketScan, row, "yes");
const noRow = getSideRow(marketScan, row, "no");
const tone = scoreTone(displayRow.final_score);
const filledDots = confidenceDotCount(displayRow.final_score);
const comparisonBuckets = buildComparisonBuckets(marketScan, row);
const maxBar = Math.max(
1,
...comparisonBuckets.flatMap((bucket) => [bucket.model, bucket.market]),
);
const scoreClass = scoreTone(displayRow.final_score);
return (
<aside className="scan-detail-panel">
<div className="scan-detail-header">
<div className="scan-detail-hero-placeholder" />
<div className="scan-detail-city-info">
<div className="scan-detail-city-name">{localizedCityName}</div>
<div className="scan-detail-city-sub">
{displayRow.market_question || displayRow.target_label || "--"}
<div className="scan-detail-top">
<div className="scan-detail-hero-placeholder" />
<div className="scan-detail-title-wrap">
<div className="scan-detail-city-name">{localizedCityName}</div>
<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 className="scan-detail-volume">
{formatVolume(displayRow.volume)}{" "}
</div>
<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 成交量"}
{loading ? ` · ${isEn ? "loading" : "载入中"}` : ""}
</div>
</div>
<button type="button" className="scan-detail-action-button">
{isEn ? "Add Watch" : "添加自选"}
</button>
</div>
<div className="scan-detail-section">
<section className="scan-detail-section">
<div className="scan-detail-section-title">
{isEn ? "Current Context" : "当前概况"}
</div>
<div className="scan-conditions-table">
<div className="scan-condition-item">
<span className="scan-condition-label">
{isEn ? "Local Time" : "当地时间"}
</span>
<span className="scan-condition-value">{row.local_time || "--"}</span>
<div className="scan-kv-list">
<div className="scan-kv">
<span>{isEn ? "Local Time" : "当前时间"}</span>
<strong>{row.local_time || "--"}</strong>
</div>
<div className="scan-condition-item">
<span className="scan-condition-label">
{isEn ? "Current Temp" : "当前温度"}
</span>
<span className="scan-condition-value">
<div className="scan-kv">
<span>{isEn ? "Current Temp" : "当前温度"}</span>
<strong>
{row.current_temp != null ? `${row.current_temp}${row.temp_symbol || ""}` : "--"}
</span>
</strong>
</div>
<div className="scan-condition-item">
<span className="scan-condition-label">
{isEn ? "Day High (So Far)" : "今日高点(至今)"}
</span>
<span className="scan-condition-value">
<div className="scan-kv">
<span>{isEn ? "Day High (So Far)" : "今日最高(至今)"}</span>
<strong>
{row.current_max_so_far != null
? `${row.current_max_so_far}${row.temp_symbol || ""}`
: "--"}
</span>
</strong>
</div>
<div className="scan-condition-item">
<span className="scan-condition-label">
{isEn ? "Target" : "目标温度"}
</span>
<span className="scan-condition-value">
{displayRow.target_label || "--"}
</span>
<div className="scan-kv">
<span>{isEn ? "Target" : "目标温度"}</span>
<strong>{displayRow.target_label || "--"}</strong>
</div>
<div className="scan-condition-item">
<span className="scan-condition-label">
{isEn ? "Gap To Target" : "距目标"}
</span>
<span
className={clsx(
"scan-condition-value",
Number(displayRow.gap_to_target || 0) <= 0
? "accent-green"
: "accent-red",
)}
>
<div className="scan-kv">
<span>{isEn ? "Gap To Target" : "距离目标"}</span>
<strong className={Number(displayRow.gap_to_target || 0) <= 0 ? "warn" : "danger"}>
{displayRow.gap_to_target != null
? `${displayRow.gap_to_target >= 0 ? "+" : ""}${displayRow.gap_to_target.toFixed(1)}${row.temp_symbol || ""}`
: "--"}
</span>
</strong>
</div>
<div className="scan-condition-item">
<span className="scan-condition-label">
{isEn ? "Window Left" : "剩余有效时间"}
</span>
<span className="scan-condition-value">
{formatMinutes(displayRow.remaining_window_minutes, locale)}
</span>
<div className="scan-kv">
<span>{isEn ? "Window Left" : "剩余有效时间"}</span>
<strong>{formatRemainingWindow(displayRow.remaining_window_minutes, locale)}</strong>
</div>
<div className="scan-condition-item">
<span className="scan-condition-label">
{isEn ? "Bias" : "分布偏移"}
</span>
<span className="scan-condition-value">
{distributionBias?.direction || "--"} ·{" "}
{distributionBias?.score != null
? distributionBias.score.toFixed(0)
<div className="scan-kv">
<span>{isEn ? "Bias" : "分布偏移"}</span>
<strong>
{displayRow.distribution_bias_direction || "--"} ·{" "}
{displayRow.distribution_bias_score != null
? displayRow.distribution_bias_score.toFixed(0)
: "--"}
</span>
</strong>
</div>
<div className="scan-condition-item">
<span className="scan-condition-label">
{isEn ? "Airport" : "机场锚点"}
</span>
<span className="scan-condition-value">{localizedAirport || "--"}</span>
<div className="scan-kv">
<span>{isEn ? "Airport" : "机场锚点"}</span>
<strong>{localizedAirport || "--"}</strong>
</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">
{isEn ? "Recommended Trade" : "推荐交易"}
{isEn ? "Probability Comparison" : "概率分布对比"}
</div>
<div className="scan-trade-cards">
<div className="scan-trade-card yes">
<div className="scan-trade-card-title">BUY YES</div>
<div className="scan-trade-card-price">
{formatPrice(yesRow?.ask ?? marketScan?.yes_buy)} / {formatProbability(yesRow?.model_probability)}
</div>
<div
className={clsx(
"scan-trade-card-edge",
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 className="scan-chart-legend">
<span>
<i className="dot green" />
{isEn ? "Model" : "模型预测"}
</span>
<span>
<i className="dot blue" />
{isEn ? "Market" : "市场隐含"}
</span>
</div>
</div>
<div className="scan-detail-score">
<div>
<div className="scan-detail-score-big">
{Number(displayRow.final_score || 0).toFixed(0)}
<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-bars">
{comparisonBuckets.map((bucket) => (
<div key={bucket.label} className="scan-chart-group">
<div
className="scan-chart-col model"
style={{ height: `${Math.max(8, (bucket.model / maxBar) * 120)}px` }}
/>
);
})}
<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>
</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>
);
}
function ScanTerminalScreen() {
const store = useDashboardStore();
const { locale } = useI18n();
const isEn = locale === "en-US";
const [draftFilters, setDraftFilters] = useState<FilterState>(DEFAULT_FILTERS);
@@ -336,15 +407,11 @@ function ScanTerminalScreen() {
const [selectedRowId, setSelectedRowId] = useState<string | null>(null);
const [detailByRowId, setDetailByRowId] = useState<Record<string, MarketScan | null>>({});
const [detailLoadingId, setDetailLoadingId] = useState<string | null>(null);
const deferredRows = useDeferredValue(terminalData?.rows || []);
const selectedRow = useMemo(() => {
if (!deferredRows.length) return null;
return (
deferredRows.find((row) => row.id === selectedRowId) ||
deferredRows[0] ||
null
);
return deferredRows.find((row) => row.id === selectedRowId) || deferredRows[0] || null;
}, [deferredRows, selectedRowId]);
const fetchTerminal = async (filters: FilterState, force = false) => {
@@ -401,9 +468,7 @@ function ScanTerminalScreen() {
const intervalId = window.setInterval(() => {
void fetchTerminal(activeFilters, false);
}, 30_000);
return () => {
window.clearInterval(intervalId);
};
return () => window.clearInterval(intervalId);
}, [activeFilters]);
useEffect(() => {
@@ -414,18 +479,7 @@ function ScanTerminalScreen() {
const selectedDetail = selectedRow ? detailByRowId[selectedRow.id] : null;
return (
<div
className={clsx(
styles.root,
detailChromeStyles.root,
modalChromeStyles.root,
)}
>
<HeaderBar
refreshAction={() => fetchTerminal(activeFilters, true)}
refreshSpinning={loading || store.loadingState.refresh}
/>
<div className={clsx(styles.root, detailChromeStyles.root, modalChromeStyles.root)}>
<div className="scan-terminal">
<ScanFilterPanel
value={draftFilters}
@@ -438,27 +492,42 @@ function ScanTerminalScreen() {
/>
<main className="scan-data-grid">
<div className="scan-data-grid-header">
<div>
<div className="scan-data-grid-title">
{isEn ? "Tradable Opportunities" : "可交易机会"}
</div>
<div className="scan-data-grid-subtitle">
{isEn
? "REST-only EMOS scan with one main signal per city/date."
: "基于 EMOS 分布与 CLOB REST 盘口,只输出每个城市/日期的单一主信号。"}
</div>
<div className="scan-topbar">
<div className="scan-topbar-tabs">
{NAV_ITEMS.map((item, index) => (
<button
key={item}
type="button"
className={`scan-topbar-tab ${index === 0 ? "active" : ""}`}
>
{item}
</button>
))}
</div>
<div className="scan-data-grid-controls">
<span className="scan-status-badge tone-neutral">
{isEn ? "Updated" : "数据时间"} ·{" "}
{terminalData?.generated_at
? terminalData.generated_at.replace("T", " ").slice(0, 19)
: "--"}
<div className="scan-topbar-actions">
<span className="scan-topbar-time">
{selectedRow?.local_time || terminalData?.generated_at?.replace("T", " ").slice(11, 19) || "--"}
</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>
<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
data={
terminalData?.summary || {
@@ -474,23 +543,51 @@ function ScanTerminalScreen() {
}
/>
<div className="scan-view-tabs">
<button type="button" className="scan-view-tab active">
{isEn ? "Opportunity List" : "机会列表"}
</button>
</div>
{error ? (
<div className="scan-detail-empty">
{isEn ? "Scan failed." : "扫描失败。"} {error}
<section className="scan-list-section">
<div className="scan-list-header">
<div className="scan-list-tabs">
<button type="button" className="active">
{isEn ? "Opportunity List" : "机会列表"}
</button>
<button type="button">{isEn ? "Distribution View" : "分布视图"}</button>
<button type="button">{isEn ? "Calendar View" : "日历视图"}</button>
</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>
) : (
<OpportunityTable
rows={deferredRows}
selectedRowId={selectedRowId}
onSelectRow={(row) => setSelectedRowId(row.id)}
/>
)}
{error ? (
<div className="scan-empty-state">
<div className="scan-empty-title">
{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>
<DetailPanel