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
@@ -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