Add locale controls and distribution previews to scan terminal

This commit is contained in:
2569718930@qq.com
2026-04-24 00:34:09 +08:00
parent 3f7d3ddf34
commit 79b58a8b98
6 changed files with 250 additions and 70 deletions
@@ -9288,6 +9288,37 @@
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) {
color: #b4c8e2;
font-variant-numeric: tabular-nums;
@@ -9321,6 +9352,24 @@
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) {
animation: spin 1s linear infinite;
}
@@ -9532,27 +9581,7 @@
}
.root :global(.scan-city-cell) {
display: flex;
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);
display: block;
}
.root :global(.scan-city-copy) {
@@ -9626,7 +9655,7 @@
.root :global(.scan-distribution-preview) {
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 8px;
}
@@ -9752,7 +9781,7 @@
.root :global(.scan-detail-top) {
display: flex;
gap: 14px;
gap: 0;
align-items: center;
}
@@ -9931,6 +9960,11 @@
position: relative;
}
.root :global(.scan-chart-group.highlighted .scan-chart-label) {
color: #22eb98;
font-weight: 800;
}
.root :global(.scan-chart-col) {
width: 20px;
border-radius: 8px 8px 0 0;
@@ -3,9 +3,17 @@
import React from "react";
import { Star } from "lucide-react";
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";
type PhaseMeta = {
label: string;
tone: "green" | "amber" | "blue" | "red";
};
function formatPercent(value?: number | null, signed = false) {
if (value == null || Number.isNaN(Number(value))) return "--";
const numeric = Number(value);
@@ -44,12 +52,18 @@ function formatAction(row: ScanOpportunityRow, locale: string) {
return "--";
}
function getPhaseMeta(
row: ScanOpportunityRow,
export function getWindowPhaseMeta(
row: Pick<ScanOpportunityRow, "window_phase" | "trend_alignment">,
locale: string,
): { label: string; tone: "green" | "amber" | "blue" | "red" } {
): PhaseMeta {
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 {
label: locale === "en-US" ? "Touch Play" : "触达博弈",
tone: "red",
@@ -61,6 +75,12 @@ function getPhaseMeta(
tone: "blue",
};
}
if (mode === "post_peak") {
return {
label: locale === "en-US" ? "Post Peak" : "峰后确认",
tone: "amber",
};
}
if (row.trend_alignment) {
return {
label: locale === "en-US" ? "Trend" : "趋势确认",
@@ -87,35 +107,58 @@ function ProbabilityPreview({
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 || "--";
const preview = Array.isArray(row.distribution_preview)
? row.distribution_preview.filter(
(item): item is DistributionPreviewPoint =>
Boolean(item && (item.label || item.value != null)),
)
: [];
if (!preview.length) {
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 || "--";
preview.push({
label: targetLabel,
model_probability: row.model_event_probability,
market_probability: row.market_event_probability,
highlighted: true,
});
}
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>
{preview.slice(0, 6).map((item) => (
<div
key={`${item.label}-${item.value ?? ""}`}
className={`scan-distribution-card ${item.highlighted ? "featured" : ""}`}
>
<strong>{item.label || "--"}</strong>
<span>
{locale === "en-US" ? "Model" : "模型"}
<br />
{formatPercent(
item.model_probability != null ? item.model_probability * 100 : null,
)}
</span>
<br />
<span>
{locale === "en-US" ? "Market" : "市场"}
<br />
{formatPercent(
item.market_probability != null ? item.market_probability * 100 : null,
)}
</span>
</div>
))}
</div>
);
}
@@ -172,7 +215,7 @@ export function OpportunityTable({
<div className="scan-table-body">
{rows.map((row, index) => {
const phaseMeta = getPhaseMeta(row, locale);
const phaseMeta = getWindowPhaseMeta(row, locale);
const localizedCityName = getLocalizedCityName(
row.city,
row.city_display_name || row.display_name || row.city,
@@ -194,9 +237,6 @@ export function OpportunityTable({
</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">
@@ -1,10 +1,12 @@
"use client";
import clsx from "clsx";
import Link from "next/link";
import {
Bell,
Menu,
RefreshCw,
UserRound,
X,
} from "lucide-react";
import {
@@ -21,12 +23,17 @@ import {
FilterState,
ScanFilterPanel,
} from "@/components/dashboard/ScanFilterPanel";
import { getWindowPhaseMeta } from "@/components/dashboard/OpportunityTable";
import { ScanKPIBar } from "@/components/dashboard/ScanKPIBar";
import { OpportunityTable } from "@/components/dashboard/OpportunityTable";
import { DashboardStoreProvider } from "@/hooks/useDashboardStore";
import {
DashboardStoreProvider,
useDashboardStore,
} from "@/hooks/useDashboardStore";
import { I18nProvider, useI18n } from "@/hooks/useI18n";
import { dashboardClient } from "@/lib/dashboard-client";
import type {
DistributionPreviewPoint,
MarketScan,
PrimarySignal,
ScanOpportunityRow,
@@ -49,7 +56,14 @@ const DEFAULT_FILTERS: FilterState = {
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) {
if (value == null || Number.isNaN(Number(value))) return "--";
@@ -128,6 +142,20 @@ function buildComparisonBuckets(
marketScan: MarketScan | null | undefined,
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)
? marketScan?.top_buckets
: Array.isArray(marketScan?.all_buckets)
@@ -140,6 +168,7 @@ function buildComparisonBuckets(
label: String(bucket.temp ?? bucket.value ?? bucket.label ?? "--"),
model: Number(bucket.probability ?? 0) * 100,
market: Number(bucket.market_price ?? bucket.yes_buy ?? 0) * 100,
highlighted: false,
}))
.filter((bucket) => bucket.label !== "--");
}
@@ -150,6 +179,7 @@ function buildComparisonBuckets(
label: row.target_label || "--",
model: Number(row.model_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]),
);
const scoreClass = scoreTone(displayRow.final_score);
const phaseMeta = getWindowPhaseMeta(displayRow, locale);
return (
<aside className="scan-detail-panel">
<div className="scan-detail-header">
<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 className={`scan-phase-badge ${phaseMeta.tone}`}>
{phaseMeta.label}
</div>
</div>
</div>
@@ -309,7 +339,7 @@ function DetailPanel({
/>
</div>
<div className="scan-timeline-caption">
{displayRow.window_phase || (isEn ? "Window phase" : "窗口阶段")}
{phaseMeta.label}
</div>
</section>
@@ -329,7 +359,10 @@ function DetailPanel({
</div>
<div className="scan-chart-bars">
{comparisonBuckets.map((bucket) => (
<div key={bucket.label} className="scan-chart-group">
<div
key={bucket.label}
className={`scan-chart-group ${bucket.highlighted ? "highlighted" : ""}`}
>
<div
className="scan-chart-col model"
style={{ height: `${Math.max(8, (bucket.model / maxBar) * 120)}px` }}
@@ -397,8 +430,12 @@ function DetailPanel({
}
function ScanTerminalScreen() {
const { locale } = useI18n();
const store = useDashboardStore();
const { locale, toggleLocale } = useI18n();
const isEn = locale === "en-US";
const accountHref = store.proAccess.authenticated
? "/account"
: "/auth/login?next=%2Faccount";
const [draftFilters, setDraftFilters] = useState<FilterState>(DEFAULT_FILTERS);
const [activeFilters, setActiveFilters] = useState<FilterState>(DEFAULT_FILTERS);
const [terminalData, setTerminalData] = useState<ScanTerminalResponse | null>(null);
@@ -496,15 +533,25 @@ function ScanTerminalScreen() {
<div className="scan-topbar-tabs">
{NAV_ITEMS.map((item, index) => (
<button
key={item}
key={item.zh}
type="button"
className={`scan-topbar-tab ${index === 0 ? "active" : ""}`}
>
{item}
{isEn ? item.en : item.zh}
</button>
))}
</div>
<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">
{selectedRow?.local_time || terminalData?.generated_at?.replace("T", " ").slice(11, 19) || "--"}
</span>
@@ -516,6 +563,14 @@ function ScanTerminalScreen() {
<Bell size={14} />
{isEn ? "Custom Alerts" : "自定义提醒"}
</button>
<Link
href={accountHref}
className="scan-account-button"
aria-label={isEn ? "Account" : "账户"}
title={isEn ? "Account" : "账户"}
>
<UserRound size={15} />
</Link>
</div>
</div>