Redesign the scan terminal layout for EMOS
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user