Add locale controls and distribution previews to scan terminal
This commit is contained in:
@@ -9288,6 +9288,37 @@
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.root :global(.scan-locale-switch) {
|
||||||
|
height: 36px;
|
||||||
|
padding: 3px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid rgba(99, 132, 180, 0.18);
|
||||||
|
background: rgba(8, 19, 34, 0.9);
|
||||||
|
color: #a8bdd8;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root :global(.scan-locale-switch span) {
|
||||||
|
min-width: 34px;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root :global(.scan-locale-switch span.active) {
|
||||||
|
color: #eef7ff;
|
||||||
|
background: rgba(63, 140, 255, 0.16);
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(63, 140, 255, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
.root :global(.scan-topbar-time) {
|
.root :global(.scan-topbar-time) {
|
||||||
color: #b4c8e2;
|
color: #b4c8e2;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
@@ -9321,6 +9352,24 @@
|
|||||||
background: rgba(23, 217, 139, 0.12);
|
background: rgba(23, 217, 139, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.root :global(.scan-account-button) {
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #d7e5f7;
|
||||||
|
text-decoration: none;
|
||||||
|
border: 1px solid rgba(99, 132, 180, 0.18);
|
||||||
|
background: rgba(8, 19, 34, 0.9);
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root :global(.scan-account-button:hover) {
|
||||||
|
border-color: rgba(63, 140, 255, 0.28);
|
||||||
|
background: rgba(12, 26, 44, 0.96);
|
||||||
|
}
|
||||||
|
|
||||||
.root :global(.scan-ghost-button .spin) {
|
.root :global(.scan-ghost-button .spin) {
|
||||||
animation: spin 1s linear infinite;
|
animation: spin 1s linear infinite;
|
||||||
}
|
}
|
||||||
@@ -9532,27 +9581,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.root :global(.scan-city-cell) {
|
.root :global(.scan-city-cell) {
|
||||||
display: flex;
|
display: block;
|
||||||
align-items: center;
|
|
||||||
gap: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.scan-city-thumb) {
|
|
||||||
width: 78px;
|
|
||||||
height: 78px;
|
|
||||||
border-radius: 14px;
|
|
||||||
background: linear-gradient(135deg, #3269a9, #0e2946 55%, #09121e);
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.scan-city-thumb-fill) {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
background:
|
|
||||||
linear-gradient(180deg, rgba(255, 255, 255, 0.18), transparent 35%, rgba(0, 0, 0, 0.22)),
|
|
||||||
linear-gradient(135deg, #5f8fd6, #112842 60%, #09111d);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.root :global(.scan-city-copy) {
|
.root :global(.scan-city-copy) {
|
||||||
@@ -9626,7 +9655,7 @@
|
|||||||
|
|
||||||
.root :global(.scan-distribution-preview) {
|
.root :global(.scan-distribution-preview) {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, 1fr);
|
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -9752,7 +9781,7 @@
|
|||||||
|
|
||||||
.root :global(.scan-detail-top) {
|
.root :global(.scan-detail-top) {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 14px;
|
gap: 0;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -9931,6 +9960,11 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.root :global(.scan-chart-group.highlighted .scan-chart-label) {
|
||||||
|
color: #22eb98;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
.root :global(.scan-chart-col) {
|
.root :global(.scan-chart-col) {
|
||||||
width: 20px;
|
width: 20px;
|
||||||
border-radius: 8px 8px 0 0;
|
border-radius: 8px 8px 0 0;
|
||||||
|
|||||||
@@ -3,9 +3,17 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { Star } from "lucide-react";
|
import { Star } from "lucide-react";
|
||||||
import { useI18n } from "@/hooks/useI18n";
|
import { useI18n } from "@/hooks/useI18n";
|
||||||
import type { ScanOpportunityRow } from "@/lib/dashboard-types";
|
import type {
|
||||||
|
DistributionPreviewPoint,
|
||||||
|
ScanOpportunityRow,
|
||||||
|
} from "@/lib/dashboard-types";
|
||||||
import { getLocalizedCityName } from "@/lib/dashboard-home-copy";
|
import { getLocalizedCityName } from "@/lib/dashboard-home-copy";
|
||||||
|
|
||||||
|
type PhaseMeta = {
|
||||||
|
label: string;
|
||||||
|
tone: "green" | "amber" | "blue" | "red";
|
||||||
|
};
|
||||||
|
|
||||||
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);
|
||||||
@@ -44,12 +52,18 @@ function formatAction(row: ScanOpportunityRow, locale: string) {
|
|||||||
return "--";
|
return "--";
|
||||||
}
|
}
|
||||||
|
|
||||||
function getPhaseMeta(
|
export function getWindowPhaseMeta(
|
||||||
row: ScanOpportunityRow,
|
row: Pick<ScanOpportunityRow, "window_phase" | "trend_alignment">,
|
||||||
locale: string,
|
locale: string,
|
||||||
): { label: string; tone: "green" | "amber" | "blue" | "red" } {
|
): PhaseMeta {
|
||||||
const mode = String(row.window_phase || "").toLowerCase();
|
const mode = String(row.window_phase || "").toLowerCase();
|
||||||
if (mode === "active_peak" || mode === "setup_today") {
|
if (mode === "active_peak") {
|
||||||
|
return {
|
||||||
|
label: locale === "en-US" ? "Peak Window" : "峰值窗口",
|
||||||
|
tone: "red",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (mode === "setup_today" || mode === "early_today") {
|
||||||
return {
|
return {
|
||||||
label: locale === "en-US" ? "Touch Play" : "触达博弈",
|
label: locale === "en-US" ? "Touch Play" : "触达博弈",
|
||||||
tone: "red",
|
tone: "red",
|
||||||
@@ -61,6 +75,12 @@ function getPhaseMeta(
|
|||||||
tone: "blue",
|
tone: "blue",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (mode === "post_peak") {
|
||||||
|
return {
|
||||||
|
label: locale === "en-US" ? "Post Peak" : "峰后确认",
|
||||||
|
tone: "amber",
|
||||||
|
};
|
||||||
|
}
|
||||||
if (row.trend_alignment) {
|
if (row.trend_alignment) {
|
||||||
return {
|
return {
|
||||||
label: locale === "en-US" ? "Trend" : "趋势确认",
|
label: locale === "en-US" ? "Trend" : "趋势确认",
|
||||||
@@ -87,35 +107,58 @@ function ProbabilityPreview({
|
|||||||
row: ScanOpportunityRow;
|
row: ScanOpportunityRow;
|
||||||
locale: string;
|
locale: string;
|
||||||
}) {
|
}) {
|
||||||
const targetBase =
|
const preview = Array.isArray(row.distribution_preview)
|
||||||
row.target_value ??
|
? row.distribution_preview.filter(
|
||||||
row.target_threshold ??
|
(item): item is DistributionPreviewPoint =>
|
||||||
row.target_lower ??
|
Boolean(item && (item.label || item.value != null)),
|
||||||
row.target_upper ??
|
)
|
||||||
null;
|
: [];
|
||||||
const unit = row.target_unit || row.temp_symbol || "";
|
|
||||||
const targetLabel =
|
if (!preview.length) {
|
||||||
targetBase != null
|
const targetBase =
|
||||||
? `${Math.round(Number(targetBase))}${unit}`
|
row.target_value ??
|
||||||
: row.target_label || "--";
|
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 || "--";
|
||||||
|
preview.push({
|
||||||
|
label: targetLabel,
|
||||||
|
model_probability: row.model_event_probability,
|
||||||
|
market_probability: row.market_event_probability,
|
||||||
|
highlighted: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="scan-distribution-preview">
|
<div className="scan-distribution-preview">
|
||||||
<div className="scan-distribution-card featured">
|
{preview.slice(0, 6).map((item) => (
|
||||||
<strong>{targetLabel}</strong>
|
<div
|
||||||
<span>{locale === "en-US" ? "Target" : "目标"}</span>
|
key={`${item.label}-${item.value ?? ""}`}
|
||||||
</div>
|
className={`scan-distribution-card ${item.highlighted ? "featured" : ""}`}
|
||||||
<div className="scan-distribution-card">
|
>
|
||||||
<strong>{formatPercent(row.model_event_probability != null ? row.model_event_probability * 100 : null)}</strong>
|
<strong>{item.label || "--"}</strong>
|
||||||
<span>{locale === "en-US" ? "Model" : "模型"}</span>
|
<span>
|
||||||
</div>
|
{locale === "en-US" ? "Model" : "模型"}
|
||||||
<div className="scan-distribution-card">
|
<br />
|
||||||
<strong>{formatPercent(row.market_event_probability != null ? row.market_event_probability * 100 : null)}</strong>
|
{formatPercent(
|
||||||
<span>{locale === "en-US" ? "Market" : "市场"}</span>
|
item.model_probability != null ? item.model_probability * 100 : null,
|
||||||
</div>
|
)}
|
||||||
<div className="scan-distribution-card">
|
</span>
|
||||||
<strong>{formatPercent(row.distribution_bias_score)}</strong>
|
<br />
|
||||||
<span>{row.distribution_bias_direction || (locale === "en-US" ? "Bias" : "偏移")}</span>
|
<span>
|
||||||
</div>
|
{locale === "en-US" ? "Market" : "市场"}
|
||||||
|
<br />
|
||||||
|
{formatPercent(
|
||||||
|
item.market_probability != null ? item.market_probability * 100 : null,
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -172,7 +215,7 @@ export function OpportunityTable({
|
|||||||
|
|
||||||
<div className="scan-table-body">
|
<div className="scan-table-body">
|
||||||
{rows.map((row, index) => {
|
{rows.map((row, index) => {
|
||||||
const phaseMeta = getPhaseMeta(row, locale);
|
const phaseMeta = getWindowPhaseMeta(row, locale);
|
||||||
const localizedCityName = getLocalizedCityName(
|
const localizedCityName = getLocalizedCityName(
|
||||||
row.city,
|
row.city,
|
||||||
row.city_display_name || row.display_name || row.city,
|
row.city_display_name || row.display_name || row.city,
|
||||||
@@ -194,9 +237,6 @@ export function OpportunityTable({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="scan-city-cell">
|
<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-copy">
|
||||||
<div className="scan-city-name">{localizedCityName}</div>
|
<div className="scan-city-name">{localizedCityName}</div>
|
||||||
<div className="scan-city-sub">
|
<div className="scan-city-sub">
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
import Link from "next/link";
|
||||||
import {
|
import {
|
||||||
Bell,
|
Bell,
|
||||||
Menu,
|
Menu,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
|
UserRound,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
@@ -21,12 +23,17 @@ import {
|
|||||||
FilterState,
|
FilterState,
|
||||||
ScanFilterPanel,
|
ScanFilterPanel,
|
||||||
} from "@/components/dashboard/ScanFilterPanel";
|
} from "@/components/dashboard/ScanFilterPanel";
|
||||||
|
import { getWindowPhaseMeta } from "@/components/dashboard/OpportunityTable";
|
||||||
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 } from "@/hooks/useDashboardStore";
|
import {
|
||||||
|
DashboardStoreProvider,
|
||||||
|
useDashboardStore,
|
||||||
|
} 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 {
|
||||||
|
DistributionPreviewPoint,
|
||||||
MarketScan,
|
MarketScan,
|
||||||
PrimarySignal,
|
PrimarySignal,
|
||||||
ScanOpportunityRow,
|
ScanOpportunityRow,
|
||||||
@@ -49,7 +56,14 @@ const DEFAULT_FILTERS: FilterState = {
|
|||||||
limit: 28,
|
limit: 28,
|
||||||
};
|
};
|
||||||
|
|
||||||
const NAV_ITEMS = ["扫描台", "市场", "分析", "组合", "监控", "设置"];
|
const NAV_ITEMS = [
|
||||||
|
{ zh: "扫描台", en: "Terminal" },
|
||||||
|
{ zh: "市场", en: "Markets" },
|
||||||
|
{ zh: "分析", en: "Analysis" },
|
||||||
|
{ zh: "组合", en: "Portfolio" },
|
||||||
|
{ zh: "监控", en: "Monitor" },
|
||||||
|
{ zh: "设置", en: "Settings" },
|
||||||
|
];
|
||||||
|
|
||||||
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 "--";
|
||||||
@@ -128,6 +142,20 @@ function buildComparisonBuckets(
|
|||||||
marketScan: MarketScan | null | undefined,
|
marketScan: MarketScan | null | undefined,
|
||||||
row: ScanOpportunityRow | null,
|
row: ScanOpportunityRow | null,
|
||||||
) {
|
) {
|
||||||
|
const rowPreview = Array.isArray(row?.distribution_preview)
|
||||||
|
? row.distribution_preview.filter(
|
||||||
|
(item): item is DistributionPreviewPoint =>
|
||||||
|
Boolean(item && (item.label || item.value != null)),
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
if (rowPreview.length) {
|
||||||
|
return rowPreview.slice(0, 6).map((item) => ({
|
||||||
|
label: String(item.label ?? item.value ?? "--"),
|
||||||
|
model: Number(item.model_probability ?? 0) * 100,
|
||||||
|
market: Number(item.market_probability ?? 0) * 100,
|
||||||
|
highlighted: Boolean(item.highlighted),
|
||||||
|
}));
|
||||||
|
}
|
||||||
const buckets = Array.isArray(marketScan?.top_buckets)
|
const buckets = Array.isArray(marketScan?.top_buckets)
|
||||||
? marketScan?.top_buckets
|
? marketScan?.top_buckets
|
||||||
: Array.isArray(marketScan?.all_buckets)
|
: Array.isArray(marketScan?.all_buckets)
|
||||||
@@ -140,6 +168,7 @@ function buildComparisonBuckets(
|
|||||||
label: String(bucket.temp ?? bucket.value ?? bucket.label ?? "--"),
|
label: String(bucket.temp ?? bucket.value ?? bucket.label ?? "--"),
|
||||||
model: Number(bucket.probability ?? 0) * 100,
|
model: Number(bucket.probability ?? 0) * 100,
|
||||||
market: Number(bucket.market_price ?? bucket.yes_buy ?? 0) * 100,
|
market: Number(bucket.market_price ?? bucket.yes_buy ?? 0) * 100,
|
||||||
|
highlighted: false,
|
||||||
}))
|
}))
|
||||||
.filter((bucket) => bucket.label !== "--");
|
.filter((bucket) => bucket.label !== "--");
|
||||||
}
|
}
|
||||||
@@ -150,6 +179,7 @@ function buildComparisonBuckets(
|
|||||||
label: row.target_label || "--",
|
label: row.target_label || "--",
|
||||||
model: Number(row.model_event_probability || 0) * 100,
|
model: Number(row.model_event_probability || 0) * 100,
|
||||||
market: Number(row.market_event_probability || 0) * 100,
|
market: Number(row.market_event_probability || 0) * 100,
|
||||||
|
highlighted: true,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -200,19 +230,19 @@ function DetailPanel({
|
|||||||
...comparisonBuckets.flatMap((bucket) => [bucket.model, bucket.market]),
|
...comparisonBuckets.flatMap((bucket) => [bucket.model, bucket.market]),
|
||||||
);
|
);
|
||||||
const scoreClass = scoreTone(displayRow.final_score);
|
const scoreClass = scoreTone(displayRow.final_score);
|
||||||
|
const phaseMeta = getWindowPhaseMeta(displayRow, locale);
|
||||||
|
|
||||||
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-top">
|
<div className="scan-detail-top">
|
||||||
<div className="scan-detail-hero-placeholder" />
|
|
||||||
<div className="scan-detail-title-wrap">
|
<div className="scan-detail-title-wrap">
|
||||||
<div className="scan-detail-city-name">{localizedCityName}</div>
|
<div className="scan-detail-city-name">{localizedCityName}</div>
|
||||||
<div className="scan-detail-city-sub">
|
<div className="scan-detail-city-sub">
|
||||||
{displayRow.market_question || displayRow.target_label || "--"}
|
{displayRow.market_question || displayRow.target_label || "--"}
|
||||||
</div>
|
</div>
|
||||||
<div className="scan-phase-badge red">
|
<div className={`scan-phase-badge ${phaseMeta.tone}`}>
|
||||||
{displayRow.window_phase || (isEn ? "Main Signal" : "主信号")}
|
{phaseMeta.label}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -309,7 +339,7 @@ function DetailPanel({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="scan-timeline-caption">
|
<div className="scan-timeline-caption">
|
||||||
{displayRow.window_phase || (isEn ? "Window phase" : "窗口阶段")}
|
{phaseMeta.label}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -329,7 +359,10 @@ function DetailPanel({
|
|||||||
</div>
|
</div>
|
||||||
<div className="scan-chart-bars">
|
<div className="scan-chart-bars">
|
||||||
{comparisonBuckets.map((bucket) => (
|
{comparisonBuckets.map((bucket) => (
|
||||||
<div key={bucket.label} className="scan-chart-group">
|
<div
|
||||||
|
key={bucket.label}
|
||||||
|
className={`scan-chart-group ${bucket.highlighted ? "highlighted" : ""}`}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
className="scan-chart-col model"
|
className="scan-chart-col model"
|
||||||
style={{ height: `${Math.max(8, (bucket.model / maxBar) * 120)}px` }}
|
style={{ height: `${Math.max(8, (bucket.model / maxBar) * 120)}px` }}
|
||||||
@@ -397,8 +430,12 @@ function DetailPanel({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ScanTerminalScreen() {
|
function ScanTerminalScreen() {
|
||||||
const { locale } = useI18n();
|
const store = useDashboardStore();
|
||||||
|
const { locale, toggleLocale } = useI18n();
|
||||||
const isEn = locale === "en-US";
|
const isEn = locale === "en-US";
|
||||||
|
const accountHref = store.proAccess.authenticated
|
||||||
|
? "/account"
|
||||||
|
: "/auth/login?next=%2Faccount";
|
||||||
const [draftFilters, setDraftFilters] = useState<FilterState>(DEFAULT_FILTERS);
|
const [draftFilters, setDraftFilters] = useState<FilterState>(DEFAULT_FILTERS);
|
||||||
const [activeFilters, setActiveFilters] = useState<FilterState>(DEFAULT_FILTERS);
|
const [activeFilters, setActiveFilters] = useState<FilterState>(DEFAULT_FILTERS);
|
||||||
const [terminalData, setTerminalData] = useState<ScanTerminalResponse | null>(null);
|
const [terminalData, setTerminalData] = useState<ScanTerminalResponse | null>(null);
|
||||||
@@ -496,15 +533,25 @@ function ScanTerminalScreen() {
|
|||||||
<div className="scan-topbar-tabs">
|
<div className="scan-topbar-tabs">
|
||||||
{NAV_ITEMS.map((item, index) => (
|
{NAV_ITEMS.map((item, index) => (
|
||||||
<button
|
<button
|
||||||
key={item}
|
key={item.zh}
|
||||||
type="button"
|
type="button"
|
||||||
className={`scan-topbar-tab ${index === 0 ? "active" : ""}`}
|
className={`scan-topbar-tab ${index === 0 ? "active" : ""}`}
|
||||||
>
|
>
|
||||||
{item}
|
{isEn ? item.en : item.zh}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="scan-topbar-actions">
|
<div className="scan-topbar-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="scan-locale-switch"
|
||||||
|
aria-label={isEn ? "Switch to Chinese" : "切换到英文"}
|
||||||
|
title={isEn ? "Switch to Chinese" : "切换到英文"}
|
||||||
|
onClick={toggleLocale}
|
||||||
|
>
|
||||||
|
<span className={clsx(locale === "zh-CN" && "active")}>中文</span>
|
||||||
|
<span className={clsx(locale === "en-US" && "active")}>EN</span>
|
||||||
|
</button>
|
||||||
<span className="scan-topbar-time">
|
<span className="scan-topbar-time">
|
||||||
{selectedRow?.local_time || terminalData?.generated_at?.replace("T", " ").slice(11, 19) || "--"}
|
{selectedRow?.local_time || terminalData?.generated_at?.replace("T", " ").slice(11, 19) || "--"}
|
||||||
</span>
|
</span>
|
||||||
@@ -516,6 +563,14 @@ function ScanTerminalScreen() {
|
|||||||
<Bell size={14} />
|
<Bell size={14} />
|
||||||
{isEn ? "Custom Alerts" : "自定义提醒"}
|
{isEn ? "Custom Alerts" : "自定义提醒"}
|
||||||
</button>
|
</button>
|
||||||
|
<Link
|
||||||
|
href={accountHref}
|
||||||
|
className="scan-account-button"
|
||||||
|
aria-label={isEn ? "Account" : "账户"}
|
||||||
|
title={isEn ? "Account" : "账户"}
|
||||||
|
>
|
||||||
|
<UserRound size={15} />
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -400,6 +400,7 @@ export interface MarketScan {
|
|||||||
scan_scope?: "lite" | "full" | string | null;
|
scan_scope?: "lite" | "full" | string | null;
|
||||||
websocket?: Record<string, unknown>;
|
websocket?: Record<string, unknown>;
|
||||||
distribution_bias?: DistributionBias | null;
|
distribution_bias?: DistributionBias | null;
|
||||||
|
distribution_preview?: DistributionPreviewPoint[] | null;
|
||||||
window_phase?: string | null;
|
window_phase?: string | null;
|
||||||
window_score?: number | null;
|
window_score?: number | null;
|
||||||
primary_signal?: PrimarySignal | null;
|
primary_signal?: PrimarySignal | null;
|
||||||
@@ -416,6 +417,15 @@ export interface DistributionBias {
|
|||||||
direction?: "hotter" | "colder" | "balanced" | string | null;
|
direction?: "hotter" | "colder" | "balanced" | string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DistributionPreviewPoint {
|
||||||
|
label?: string | null;
|
||||||
|
value?: number | null;
|
||||||
|
unit?: string | null;
|
||||||
|
model_probability?: number | null;
|
||||||
|
market_probability?: number | null;
|
||||||
|
highlighted?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ScanTerminalFilters {
|
export interface ScanTerminalFilters {
|
||||||
scan_mode: "tradable" | "early" | "touch" | "trend";
|
scan_mode: "tradable" | "early" | "touch" | "trend";
|
||||||
min_price: number;
|
min_price: number;
|
||||||
@@ -483,6 +493,7 @@ export interface ScanOpportunityRow {
|
|||||||
edge_score?: number | null;
|
edge_score?: number | null;
|
||||||
bias_score?: number | null;
|
bias_score?: number | null;
|
||||||
distribution_bias?: DistributionBias | null;
|
distribution_bias?: DistributionBias | null;
|
||||||
|
distribution_preview?: DistributionPreviewPoint[] | null;
|
||||||
distribution_bias_direction?: string | null;
|
distribution_bias_direction?: string | null;
|
||||||
distribution_bias_score?: number | null;
|
distribution_bias_score?: number | null;
|
||||||
distribution_bias_available?: boolean;
|
distribution_bias_available?: boolean;
|
||||||
|
|||||||
@@ -2623,7 +2623,6 @@ class PolymarketReadOnlyLayer:
|
|||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
filters = self._normalize_scan_filters(scan_filters)
|
filters = self._normalize_scan_filters(scan_filters)
|
||||||
window_meta = self._build_window_meta(target_date, scan_context)
|
window_meta = self._build_window_meta(target_date, scan_context)
|
||||||
rows: List[Dict[str, Any]] = []
|
|
||||||
related_markets = self._collect_related_temperature_markets(
|
related_markets = self._collect_related_temperature_markets(
|
||||||
city_key=city_key,
|
city_key=city_key,
|
||||||
target_date=target_date,
|
target_date=target_date,
|
||||||
@@ -2772,6 +2771,44 @@ class PolymarketReadOnlyLayer:
|
|||||||
"score": distribution_bias_score,
|
"score": distribution_bias_score,
|
||||||
"valid_markets": len(bias_inputs),
|
"valid_markets": len(bias_inputs),
|
||||||
}
|
}
|
||||||
|
distribution_preview: List[Dict[str, Any]] = []
|
||||||
|
for entry in market_entries:
|
||||||
|
label = str(entry.get("target_label") or "").strip()
|
||||||
|
if not label:
|
||||||
|
continue
|
||||||
|
preview_item = {
|
||||||
|
"label": label,
|
||||||
|
"value": _safe_float(entry.get("bucket_temp")),
|
||||||
|
"unit": (
|
||||||
|
entry.get("bucket_range")[2]
|
||||||
|
if isinstance(entry.get("bucket_range"), tuple)
|
||||||
|
and len(entry.get("bucket_range")) >= 3
|
||||||
|
else ("F" if self._is_fahrenheit_symbol(temp_symbol) else "C")
|
||||||
|
),
|
||||||
|
"model_probability": _clamp_probability(
|
||||||
|
_safe_float(entry.get("model_event_probability"))
|
||||||
|
),
|
||||||
|
"market_probability": _clamp_probability(
|
||||||
|
_safe_float(entry.get("market_event_probability"))
|
||||||
|
),
|
||||||
|
"highlighted": False,
|
||||||
|
}
|
||||||
|
distribution_preview.append(preview_item)
|
||||||
|
|
||||||
|
distribution_preview.sort(
|
||||||
|
key=lambda item: (
|
||||||
|
_safe_float(item.get("value"))
|
||||||
|
if _safe_float(item.get("value")) is not None
|
||||||
|
else float("inf"),
|
||||||
|
str(item.get("label") or ""),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if distribution_preview:
|
||||||
|
highlighted_index = max(
|
||||||
|
range(len(distribution_preview)),
|
||||||
|
key=lambda index: _safe_float(distribution_preview[index].get("model_probability")) or 0.0,
|
||||||
|
)
|
||||||
|
distribution_preview[highlighted_index]["highlighted"] = True
|
||||||
|
|
||||||
current_reference_raw = _safe_float(
|
current_reference_raw = _safe_float(
|
||||||
(scan_context or {}).get("current_max_so_far")
|
(scan_context or {}).get("current_max_so_far")
|
||||||
@@ -2936,6 +2973,7 @@ class PolymarketReadOnlyLayer:
|
|||||||
"distribution_bias_direction": distribution_bias_direction,
|
"distribution_bias_direction": distribution_bias_direction,
|
||||||
"distribution_bias_score": distribution_bias_score,
|
"distribution_bias_score": distribution_bias_score,
|
||||||
"distribution_bias_available": distribution_bias["available"],
|
"distribution_bias_available": distribution_bias["available"],
|
||||||
|
"distribution_preview": distribution_preview[:6],
|
||||||
"current_reference": current_reference,
|
"current_reference": current_reference,
|
||||||
"gap_to_target": gap_to_target,
|
"gap_to_target": gap_to_target,
|
||||||
"touch_distance": abs(gap_to_target) if gap_to_target is not None else None,
|
"touch_distance": abs(gap_to_target) if gap_to_target is not None else None,
|
||||||
@@ -3073,5 +3111,6 @@ class PolymarketReadOnlyLayer:
|
|||||||
"candidate_count": len(filtered_rows),
|
"candidate_count": len(filtered_rows),
|
||||||
"window_phase": window_meta.get("phase"),
|
"window_phase": window_meta.get("phase"),
|
||||||
"window_score": window_meta.get("score"),
|
"window_score": window_meta.get("score"),
|
||||||
|
"distribution_preview": distribution_preview[:6],
|
||||||
"resolved_market_type": "maxtemp",
|
"resolved_market_type": "maxtemp",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -191,6 +191,7 @@ def _build_terminal_row(
|
|||||||
"airport": ((data.get("risk") or {}).get("airport") if isinstance(data.get("risk"), dict) else None),
|
"airport": ((data.get("risk") or {}).get("airport") if isinstance(data.get("risk"), dict) else None),
|
||||||
"risk_level": ((data.get("risk") or {}).get("level") if isinstance(data.get("risk"), dict) else None),
|
"risk_level": ((data.get("risk") or {}).get("level") if isinstance(data.get("risk"), dict) else None),
|
||||||
"distribution_bias": scan.get("distribution_bias"),
|
"distribution_bias": scan.get("distribution_bias"),
|
||||||
|
"distribution_preview": scan.get("distribution_preview") or row.get("distribution_preview") or [],
|
||||||
"window_phase": row.get("window_phase") or scan.get("window_phase"),
|
"window_phase": row.get("window_phase") or scan.get("window_phase"),
|
||||||
"window_score": row.get("window_score") if row.get("window_score") is not None else scan.get("window_score"),
|
"window_score": row.get("window_score") if row.get("window_score") is not None else scan.get("window_score"),
|
||||||
"signal_status": scan.get("signal_status"),
|
"signal_status": scan.get("signal_status"),
|
||||||
|
|||||||
Reference in New Issue
Block a user