2026-04-23 23:49:52 +08:00
|
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
|
|
import clsx from "clsx";
|
2026-04-24 00:34:09 +08:00
|
|
|
|
import Link from "next/link";
|
2026-04-24 00:13:11 +08:00
|
|
|
|
import {
|
|
|
|
|
|
RefreshCw,
|
2026-04-24 05:39:14 +08:00
|
|
|
|
Moon,
|
|
|
|
|
|
Sun,
|
2026-04-24 00:34:09 +08:00
|
|
|
|
UserRound,
|
2026-04-24 00:13:11 +08:00
|
|
|
|
} from "lucide-react";
|
2026-04-23 23:49:52 +08:00
|
|
|
|
import {
|
|
|
|
|
|
startTransition,
|
2026-04-24 05:39:14 +08:00
|
|
|
|
useCallback,
|
2026-04-23 23:49:52 +08:00
|
|
|
|
useDeferredValue,
|
|
|
|
|
|
useEffect,
|
|
|
|
|
|
useMemo,
|
2026-04-24 12:46:44 +08:00
|
|
|
|
useRef,
|
2026-04-23 23:49:52 +08:00
|
|
|
|
useState,
|
|
|
|
|
|
} from "react";
|
|
|
|
|
|
import styles from "./Dashboard.module.css";
|
|
|
|
|
|
import detailChromeStyles from "./DetailPanelChrome.module.css";
|
|
|
|
|
|
import modalChromeStyles from "./ModalChrome.module.css";
|
|
|
|
|
|
import {
|
|
|
|
|
|
FilterState,
|
|
|
|
|
|
ScanFilterPanel,
|
|
|
|
|
|
} from "@/components/dashboard/ScanFilterPanel";
|
2026-04-24 06:31:29 +08:00
|
|
|
|
import { FutureForecastModal } from "@/components/dashboard/FutureForecastModal";
|
2026-04-24 03:30:55 +08:00
|
|
|
|
import { MapCanvas } from "@/components/dashboard/MapCanvas";
|
2026-04-24 00:34:09 +08:00
|
|
|
|
import { getWindowPhaseMeta } from "@/components/dashboard/OpportunityTable";
|
2026-04-23 23:49:52 +08:00
|
|
|
|
import { ScanKPIBar } from "@/components/dashboard/ScanKPIBar";
|
|
|
|
|
|
import { OpportunityTable } from "@/components/dashboard/OpportunityTable";
|
2026-04-24 00:34:09 +08:00
|
|
|
|
import {
|
|
|
|
|
|
DashboardStoreProvider,
|
|
|
|
|
|
useDashboardStore,
|
|
|
|
|
|
} from "@/hooks/useDashboardStore";
|
2026-04-23 23:49:52 +08:00
|
|
|
|
import { I18nProvider, useI18n } from "@/hooks/useI18n";
|
2026-04-24 12:46:44 +08:00
|
|
|
|
import {
|
|
|
|
|
|
dashboardClient,
|
|
|
|
|
|
type AssistantContextPayload,
|
|
|
|
|
|
type AssistantOpportunityContext,
|
|
|
|
|
|
} from "@/lib/dashboard-client";
|
2026-04-23 23:49:52 +08:00
|
|
|
|
import type {
|
2026-04-24 00:34:09 +08:00
|
|
|
|
DistributionPreviewPoint,
|
2026-04-23 23:49:52 +08:00
|
|
|
|
MarketScan,
|
|
|
|
|
|
PrimarySignal,
|
|
|
|
|
|
ScanOpportunityRow,
|
|
|
|
|
|
ScanTerminalResponse,
|
|
|
|
|
|
} from "@/lib/dashboard-types";
|
|
|
|
|
|
import {
|
|
|
|
|
|
getLocalizedAirportName,
|
|
|
|
|
|
getLocalizedCityName,
|
|
|
|
|
|
} from "@/lib/dashboard-home-copy";
|
2026-04-24 02:14:20 +08:00
|
|
|
|
import {
|
|
|
|
|
|
formatTemperatureValue,
|
|
|
|
|
|
normalizeTemperatureLabel,
|
|
|
|
|
|
} from "@/lib/dashboard-utils";
|
2026-04-23 23:49:52 +08:00
|
|
|
|
|
|
|
|
|
|
const DEFAULT_FILTERS: FilterState = {
|
|
|
|
|
|
scan_mode: "tradable",
|
|
|
|
|
|
min_price: 0.05,
|
|
|
|
|
|
max_price: 0.95,
|
|
|
|
|
|
min_edge_pct: 2,
|
2026-04-24 00:13:11 +08:00
|
|
|
|
min_liquidity: 1000,
|
2026-04-23 23:49:52 +08:00
|
|
|
|
high_liquidity_only: false,
|
|
|
|
|
|
market_type: "maxtemp",
|
|
|
|
|
|
time_range: "today",
|
2026-04-24 00:13:11 +08:00
|
|
|
|
limit: 28,
|
2026-04-23 23:49:52 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-04-24 03:30:55 +08:00
|
|
|
|
type ContentView = "list" | "map" | "calendar";
|
2026-04-24 05:39:14 +08:00
|
|
|
|
type ThemeMode = "dark" | "light";
|
2026-04-24 12:46:44 +08:00
|
|
|
|
type AssistantMessage = {
|
|
|
|
|
|
id: string;
|
|
|
|
|
|
role: "assistant" | "user";
|
|
|
|
|
|
content: string;
|
|
|
|
|
|
};
|
2026-04-24 03:30:55 +08:00
|
|
|
|
|
2026-04-23 23:49:52 +08:00
|
|
|
|
function formatPercent(value?: number | null, signed = false) {
|
|
|
|
|
|
if (value == null || Number.isNaN(Number(value))) return "--";
|
|
|
|
|
|
const numeric = Number(value);
|
2026-04-24 00:13:11 +08:00
|
|
|
|
return `${signed && numeric >= 0 ? "+" : ""}${numeric.toFixed(1)}%`;
|
2026-04-23 23:49:52 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function formatProbability(value?: number | null) {
|
|
|
|
|
|
if (value == null || Number.isNaN(Number(value))) return "--";
|
|
|
|
|
|
return `${(Number(value) * 100).toFixed(0)}%`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function formatPrice(value?: number | null) {
|
|
|
|
|
|
if (value == null || Number.isNaN(Number(value))) return "--";
|
|
|
|
|
|
return `${Math.round(Number(value) * 100)}¢`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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)}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-24 00:13:11 +08:00
|
|
|
|
function formatRemainingWindow(value?: number | null, locale = "zh-CN") {
|
2026-04-23 23:49:52 +08:00
|
|
|
|
if (value == null || !Number.isFinite(Number(value))) return "--";
|
|
|
|
|
|
const numeric = Math.max(0, Math.round(Number(value)));
|
2026-04-24 00:13:11 +08:00
|
|
|
|
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`;
|
2026-04-23 23:49:52 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function scoreTone(score?: number | null) {
|
|
|
|
|
|
const numeric = Number(score || 0);
|
|
|
|
|
|
if (numeric >= 85) return "green";
|
2026-04-24 00:13:11 +08:00
|
|
|
|
if (numeric >= 70) return "yellow";
|
2026-04-23 23:49:52 +08:00
|
|
|
|
return "red";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-24 00:13:11 +08:00
|
|
|
|
function confidenceLabel(score?: number | null, locale = "zh-CN") {
|
2026-04-23 23:49:52 +08:00
|
|
|
|
const numeric = Number(score || 0);
|
2026-04-24 00:13:11 +08:00
|
|
|
|
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 "观察";
|
2026-04-23 23:49:52 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-24 03:30:55 +08:00
|
|
|
|
function formatShortDate(value?: string | null, locale = "zh-CN") {
|
|
|
|
|
|
const text = String(value || "").trim();
|
|
|
|
|
|
if (!text) return "--";
|
|
|
|
|
|
const date = new Date(`${text}T00:00:00`);
|
|
|
|
|
|
if (Number.isNaN(date.getTime())) return text;
|
|
|
|
|
|
return locale === "en-US"
|
|
|
|
|
|
? date.toLocaleDateString("en-US", { month: "short", day: "numeric" })
|
|
|
|
|
|
: date.toLocaleDateString("zh-CN", { month: "numeric", day: "numeric" });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-24 06:31:29 +08:00
|
|
|
|
function formatUserLocalTime() {
|
|
|
|
|
|
const now = new Date();
|
|
|
|
|
|
return `${String(now.getHours()).padStart(2, "0")}:${String(
|
|
|
|
|
|
now.getMinutes(),
|
|
|
|
|
|
).padStart(2, "0")}`;
|
2026-04-24 05:39:14 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-24 05:09:19 +08:00
|
|
|
|
function getLocalDateIndex(value?: string | null) {
|
|
|
|
|
|
const text = String(value || "").trim();
|
|
|
|
|
|
if (!text) return 0;
|
|
|
|
|
|
const date = new Date(`${text}T00:00:00`);
|
|
|
|
|
|
if (Number.isNaN(date.getTime())) return 0;
|
|
|
|
|
|
const today = new Date();
|
|
|
|
|
|
today.setHours(0, 0, 0, 0);
|
|
|
|
|
|
return Math.round((date.getTime() - today.getTime()) / 86_400_000);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getPhaseUrgency(row: ScanOpportunityRow) {
|
|
|
|
|
|
const phase = String(row.window_phase || "").toLowerCase();
|
|
|
|
|
|
if (phase === "active_peak") return 0;
|
|
|
|
|
|
if (phase === "setup_today") return 1;
|
|
|
|
|
|
if (phase === "early_today") return 2;
|
|
|
|
|
|
if (phase === "post_peak") return 3;
|
|
|
|
|
|
if (phase === "tomorrow") return 4;
|
|
|
|
|
|
if (phase === "week_ahead") return 5;
|
|
|
|
|
|
return 6;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function sortRowsByUserTime(rows: ScanOpportunityRow[]) {
|
|
|
|
|
|
return [...rows].sort((left, right) => {
|
|
|
|
|
|
const leftDateIndex = getLocalDateIndex(left.selected_date || left.local_date);
|
|
|
|
|
|
const rightDateIndex = getLocalDateIndex(right.selected_date || right.local_date);
|
|
|
|
|
|
if (leftDateIndex !== rightDateIndex) return leftDateIndex - rightDateIndex;
|
|
|
|
|
|
|
|
|
|
|
|
const leftRemaining = Number.isFinite(Number(left.remaining_window_minutes))
|
|
|
|
|
|
? Number(left.remaining_window_minutes)
|
|
|
|
|
|
: Number.POSITIVE_INFINITY;
|
|
|
|
|
|
const rightRemaining = Number.isFinite(Number(right.remaining_window_minutes))
|
|
|
|
|
|
? Number(right.remaining_window_minutes)
|
|
|
|
|
|
: Number.POSITIVE_INFINITY;
|
|
|
|
|
|
if (leftRemaining !== rightRemaining) return leftRemaining - rightRemaining;
|
|
|
|
|
|
|
|
|
|
|
|
const leftPhase = getPhaseUrgency(left);
|
|
|
|
|
|
const rightPhase = getPhaseUrgency(right);
|
|
|
|
|
|
if (leftPhase !== rightPhase) return leftPhase - rightPhase;
|
|
|
|
|
|
|
|
|
|
|
|
const scoreDelta = Number(right.final_score || 0) - Number(left.final_score || 0);
|
|
|
|
|
|
if (scoreDelta !== 0) return scoreDelta;
|
|
|
|
|
|
return Number(right.edge_percent || 0) - Number(left.edge_percent || 0);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-23 23:49:52 +08:00
|
|
|
|
function getSideRow(
|
|
|
|
|
|
marketScan: MarketScan | null | undefined,
|
|
|
|
|
|
selectedRow: ScanOpportunityRow,
|
|
|
|
|
|
side: "yes" | "no",
|
|
|
|
|
|
) {
|
2026-04-24 00:13:11 +08:00
|
|
|
|
const rows = Array.isArray(marketScan?.scan_rows) ? marketScan.scan_rows : [];
|
2026-04-23 23:49:52 +08:00
|
|
|
|
const matched = rows.find(
|
|
|
|
|
|
(row) =>
|
|
|
|
|
|
row.market_slug === selectedRow.market_slug &&
|
|
|
|
|
|
row.selected_date === selectedRow.selected_date &&
|
|
|
|
|
|
row.side === side,
|
|
|
|
|
|
);
|
|
|
|
|
|
if (matched) return matched;
|
|
|
|
|
|
if (selectedRow.side === side) return selectedRow;
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-24 03:18:01 +08:00
|
|
|
|
function getDetailSideAsk(
|
|
|
|
|
|
scanRow: ScanOpportunityRow | null,
|
|
|
|
|
|
marketScan: MarketScan | null | undefined,
|
|
|
|
|
|
side: "yes" | "no",
|
|
|
|
|
|
) {
|
|
|
|
|
|
if (scanRow?.ask != null) return scanRow.ask;
|
|
|
|
|
|
if (side === "yes") {
|
|
|
|
|
|
if (scanRow?.yes_ask != null) return scanRow.yes_ask;
|
|
|
|
|
|
return marketScan?.yes_buy ?? null;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (scanRow?.no_ask != null) return scanRow.no_ask;
|
|
|
|
|
|
return marketScan?.no_buy ?? null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getDetailSideBid(
|
|
|
|
|
|
scanRow: ScanOpportunityRow | null,
|
|
|
|
|
|
marketScan: MarketScan | null | undefined,
|
|
|
|
|
|
side: "yes" | "no",
|
|
|
|
|
|
) {
|
|
|
|
|
|
if (scanRow?.bid != null) return scanRow.bid;
|
|
|
|
|
|
if (side === "yes") {
|
|
|
|
|
|
if (scanRow?.yes_bid != null) return scanRow.yes_bid;
|
|
|
|
|
|
return marketScan?.yes_sell ?? null;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (scanRow?.no_bid != null) return scanRow.no_bid;
|
|
|
|
|
|
return marketScan?.no_sell ?? null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-24 00:13:11 +08:00
|
|
|
|
function buildComparisonBuckets(
|
|
|
|
|
|
marketScan: MarketScan | null | undefined,
|
|
|
|
|
|
row: ScanOpportunityRow | null,
|
|
|
|
|
|
) {
|
2026-04-24 00:34:09 +08:00
|
|
|
|
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),
|
|
|
|
|
|
}));
|
|
|
|
|
|
}
|
2026-04-24 00:13:11 +08:00
|
|
|
|
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,
|
2026-04-24 00:34:09 +08:00
|
|
|
|
highlighted: false,
|
2026-04-24 00:13:11 +08:00
|
|
|
|
}))
|
|
|
|
|
|
.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,
|
2026-04-24 00:34:09 +08:00
|
|
|
|
highlighted: true,
|
2026-04-24 00:13:11 +08:00
|
|
|
|
},
|
|
|
|
|
|
];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-24 12:46:44 +08:00
|
|
|
|
function hashClientText(value: string) {
|
|
|
|
|
|
let hash = 0;
|
|
|
|
|
|
for (let index = 0; index < value.length; index += 1) {
|
|
|
|
|
|
hash = (hash * 31 + value.charCodeAt(index)) | 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
return Math.abs(hash).toString(16).padStart(8, "0").slice(0, 8);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function normalizeAssistantProbability(value?: number | null) {
|
|
|
|
|
|
if (!Number.isFinite(Number(value))) return null;
|
|
|
|
|
|
const numeric = Number(value);
|
|
|
|
|
|
return Math.abs(numeric) <= 1 ? numeric : numeric / 100;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getAssistantSidePrice(row: ScanOpportunityRow, side: "yes" | "no") {
|
|
|
|
|
|
if (side === "yes") {
|
|
|
|
|
|
return row.yes_ask ?? (String(row.side || "").toLowerCase() === "yes" ? row.ask : null);
|
|
|
|
|
|
}
|
|
|
|
|
|
return row.no_ask ?? (String(row.side || "").toLowerCase() === "no" ? row.ask : null);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function buildAssistantOpportunity(
|
|
|
|
|
|
row: ScanOpportunityRow,
|
|
|
|
|
|
locale: string,
|
|
|
|
|
|
): AssistantOpportunityContext {
|
|
|
|
|
|
const tempSymbol = row.temp_symbol || "°C";
|
|
|
|
|
|
const marketLabel =
|
|
|
|
|
|
normalizeTemperatureLabel(row.target_label, tempSymbol) ||
|
|
|
|
|
|
row.market_question ||
|
|
|
|
|
|
row.target_label ||
|
|
|
|
|
|
null;
|
|
|
|
|
|
const bestSide = String(row.side || "").toUpperCase();
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
city_name: row.city,
|
|
|
|
|
|
city_display_name: getLocalizedCityName(
|
|
|
|
|
|
row.city,
|
|
|
|
|
|
row.city_display_name || row.display_name || row.city,
|
|
|
|
|
|
locale,
|
|
|
|
|
|
),
|
|
|
|
|
|
airport: getLocalizedAirportName(row.city, row.airport || "", locale),
|
|
|
|
|
|
risk_level: row.risk_level || "low",
|
|
|
|
|
|
tradable:
|
|
|
|
|
|
row.tradable === true &&
|
|
|
|
|
|
row.closed !== true &&
|
|
|
|
|
|
row.active !== false &&
|
|
|
|
|
|
row.accepting_orders !== false,
|
|
|
|
|
|
local_time: row.local_time || null,
|
|
|
|
|
|
current_temperature: Number.isFinite(Number(row.current_temp))
|
|
|
|
|
|
? Number(row.current_temp)
|
|
|
|
|
|
: null,
|
|
|
|
|
|
deb_prediction: Number.isFinite(Number(row.deb_prediction))
|
|
|
|
|
|
? Number(row.deb_prediction)
|
|
|
|
|
|
: null,
|
|
|
|
|
|
temp_symbol: tempSymbol,
|
|
|
|
|
|
today_high: Number.isFinite(Number(row.current_max_so_far))
|
|
|
|
|
|
? Number(row.current_max_so_far)
|
|
|
|
|
|
: Number.isFinite(Number(row.deb_prediction))
|
|
|
|
|
|
? Number(row.deb_prediction)
|
|
|
|
|
|
: null,
|
|
|
|
|
|
market_question: row.market_question || null,
|
|
|
|
|
|
market_label: marketLabel,
|
|
|
|
|
|
selected_date: row.selected_date || row.local_date || null,
|
|
|
|
|
|
best_side: bestSide === "YES" || bestSide === "NO" ? bestSide : null,
|
|
|
|
|
|
yes_price: getAssistantSidePrice(row, "yes"),
|
|
|
|
|
|
no_price: getAssistantSidePrice(row, "no"),
|
|
|
|
|
|
edge_percent: Number.isFinite(Number(row.edge_percent))
|
|
|
|
|
|
? Number(row.edge_percent)
|
|
|
|
|
|
: null,
|
|
|
|
|
|
market_probability: normalizeAssistantProbability(
|
|
|
|
|
|
row.market_event_probability ?? row.market_probability,
|
|
|
|
|
|
),
|
|
|
|
|
|
model_probability: normalizeAssistantProbability(
|
|
|
|
|
|
row.model_event_probability ?? row.model_probability,
|
|
|
|
|
|
),
|
|
|
|
|
|
status: row.closed ? "closed" : row.tradable ? "tradable" : "watch",
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function buildAssistantContext(params: {
|
|
|
|
|
|
terminalData: ScanTerminalResponse | null;
|
|
|
|
|
|
rows: ScanOpportunityRow[];
|
|
|
|
|
|
selectedRow: ScanOpportunityRow | null;
|
|
|
|
|
|
locale: string;
|
|
|
|
|
|
totalCities: number;
|
|
|
|
|
|
}): AssistantContextPayload {
|
|
|
|
|
|
const opportunities = params.rows
|
|
|
|
|
|
.slice(0, 52)
|
|
|
|
|
|
.map((row) => buildAssistantOpportunity(row, params.locale));
|
|
|
|
|
|
const selectedCity = params.selectedRow
|
|
|
|
|
|
? buildAssistantOpportunity(params.selectedRow, params.locale)
|
|
|
|
|
|
: opportunities[0] || null;
|
|
|
|
|
|
const source = JSON.stringify({
|
|
|
|
|
|
generated_at: params.terminalData?.generated_at || "",
|
|
|
|
|
|
rows: params.rows.slice(0, 20).map((row) => [
|
|
|
|
|
|
row.id,
|
|
|
|
|
|
row.city,
|
|
|
|
|
|
row.market_slug,
|
|
|
|
|
|
row.side,
|
|
|
|
|
|
row.edge_percent,
|
|
|
|
|
|
row.yes_ask,
|
|
|
|
|
|
row.no_ask,
|
|
|
|
|
|
]),
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
snapshot_id:
|
|
|
|
|
|
params.terminalData?.snapshot_id ||
|
|
|
|
|
|
`home-${hashClientText(source)}`,
|
|
|
|
|
|
locale: params.locale,
|
|
|
|
|
|
generated_at: params.terminalData?.generated_at || new Date().toISOString(),
|
|
|
|
|
|
totals: {
|
|
|
|
|
|
cities: params.totalCities || params.rows.length,
|
|
|
|
|
|
tradable_markets:
|
|
|
|
|
|
params.terminalData?.summary?.tradable_market_count ??
|
|
|
|
|
|
opportunities.filter((item) => item.tradable).length,
|
|
|
|
|
|
high_risk: params.rows.filter((row) => row.risk_level === "high").length,
|
|
|
|
|
|
medium_risk: params.rows.filter((row) => row.risk_level === "medium").length,
|
|
|
|
|
|
low_risk: params.rows.filter((row) => row.risk_level === "low").length,
|
|
|
|
|
|
},
|
|
|
|
|
|
selected_city: selectedCity,
|
|
|
|
|
|
opportunities,
|
|
|
|
|
|
glossary:
|
|
|
|
|
|
params.locale === "en-US"
|
|
|
|
|
|
? [
|
|
|
|
|
|
{
|
|
|
|
|
|
term: "edge",
|
|
|
|
|
|
meaning:
|
|
|
|
|
|
"Edge is model probability minus market-implied probability. Positive edge means the model is more favorable than the market price.",
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
term: "EMOS",
|
|
|
|
|
|
meaning:
|
|
|
|
|
|
"EMOS is the calibrated probability distribution for max-temperature buckets.",
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
term: "DEB",
|
|
|
|
|
|
meaning:
|
|
|
|
|
|
"DEB is PolyWeather's forecast anchor for the 24-hour maximum temperature.",
|
|
|
|
|
|
},
|
|
|
|
|
|
]
|
|
|
|
|
|
: [
|
|
|
|
|
|
{
|
|
|
|
|
|
term: "edge",
|
|
|
|
|
|
meaning:
|
|
|
|
|
|
"edge 是模型概率减去市场隐含概率。正 edge 表示模型判断比市场价格更有利。",
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
term: "EMOS",
|
|
|
|
|
|
meaning: "EMOS 是最高温分桶的校准概率分布。",
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
term: "DEB",
|
|
|
|
|
|
meaning: "DEB 是 PolyWeather 对 24 小时最高温的预测锚点。",
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-23 23:49:52 +08:00
|
|
|
|
function DetailPanel({
|
|
|
|
|
|
row,
|
|
|
|
|
|
marketScan,
|
|
|
|
|
|
loading,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
row: ScanOpportunityRow | null;
|
|
|
|
|
|
marketScan?: MarketScan | null;
|
|
|
|
|
|
loading?: boolean;
|
|
|
|
|
|
}) {
|
2026-04-24 04:14:52 +08:00
|
|
|
|
const store = useDashboardStore();
|
2026-04-23 23:49:52 +08:00
|
|
|
|
const { locale } = useI18n();
|
|
|
|
|
|
const isEn = locale === "en-US";
|
2026-04-24 04:14:52 +08:00
|
|
|
|
const isPro = store.proAccess.subscriptionActive;
|
2026-04-23 23:49:52 +08:00
|
|
|
|
|
|
|
|
|
|
if (!row) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<aside className="scan-detail-panel">
|
2026-04-24 00:13:11 +08:00
|
|
|
|
<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>
|
2026-04-23 23:49:52 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</aside>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const localizedCityName = getLocalizedCityName(
|
|
|
|
|
|
row.city,
|
|
|
|
|
|
row.city_display_name || row.display_name || row.city,
|
|
|
|
|
|
locale,
|
|
|
|
|
|
);
|
2026-04-24 00:13:11 +08:00
|
|
|
|
const localizedAirport = getLocalizedAirportName(row.city, row.airport || "", locale);
|
2026-04-23 23:49:52 +08:00
|
|
|
|
const detailSignal = marketScan?.primary_signal as PrimarySignal | null | undefined;
|
2026-04-24 00:13:11 +08:00
|
|
|
|
const displayRow =
|
|
|
|
|
|
detailSignal && detailSignal.market_slug === row.market_slug
|
|
|
|
|
|
? detailSignal
|
|
|
|
|
|
: row;
|
2026-04-24 02:14:20 +08:00
|
|
|
|
const tempSymbol = row.temp_symbol || displayRow.temp_symbol || "°C";
|
2026-04-23 23:49:52 +08:00
|
|
|
|
const yesRow = getSideRow(marketScan, row, "yes");
|
|
|
|
|
|
const noRow = getSideRow(marketScan, row, "no");
|
2026-04-24 00:13:11 +08:00
|
|
|
|
const comparisonBuckets = buildComparisonBuckets(marketScan, row);
|
|
|
|
|
|
const maxBar = Math.max(
|
|
|
|
|
|
1,
|
|
|
|
|
|
...comparisonBuckets.flatMap((bucket) => [bucket.model, bucket.market]),
|
|
|
|
|
|
);
|
|
|
|
|
|
const scoreClass = scoreTone(displayRow.final_score);
|
2026-04-24 00:34:09 +08:00
|
|
|
|
const phaseMeta = getWindowPhaseMeta(displayRow, locale);
|
2026-04-24 06:31:29 +08:00
|
|
|
|
const cityDetail =
|
2026-04-24 05:09:19 +08:00
|
|
|
|
store.selectedDetail?.name?.toLowerCase() === row.city.toLowerCase()
|
|
|
|
|
|
? store.selectedDetail
|
|
|
|
|
|
: store.cityDetailsByName[row.city] || null;
|
2026-04-23 23:49:52 +08:00
|
|
|
|
|
2026-04-24 04:14:52 +08:00
|
|
|
|
const openTodayAnalysis = async () => {
|
|
|
|
|
|
if (!row.city) return;
|
|
|
|
|
|
await store.selectCity(row.city);
|
2026-04-24 06:31:29 +08:00
|
|
|
|
await store.openTodayModal();
|
2026-04-24 04:14:52 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-04-23 23:49:52 +08:00
|
|
|
|
return (
|
|
|
|
|
|
<aside className="scan-detail-panel">
|
|
|
|
|
|
<div className="scan-detail-header">
|
2026-04-24 00:13:11 +08:00
|
|
|
|
<div className="scan-detail-top">
|
|
|
|
|
|
<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>
|
2026-04-24 00:34:09 +08:00
|
|
|
|
<div className={`scan-phase-badge ${phaseMeta.tone}`}>
|
|
|
|
|
|
{phaseMeta.label}
|
2026-04-24 00:13:11 +08:00
|
|
|
|
</div>
|
2026-04-23 23:49:52 +08:00
|
|
|
|
</div>
|
2026-04-24 00:13:11 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="scan-detail-volume-row">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<div className="scan-detail-volume-big">{formatVolume(displayRow.volume)}</div>
|
|
|
|
|
|
<div className="scan-detail-volume-caption">
|
2026-04-23 23:49:52 +08:00
|
|
|
|
{isEn ? "24h volume" : "24h 成交量"}
|
|
|
|
|
|
{loading ? ` · ${isEn ? "loading" : "载入中"}` : ""}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-04-24 04:14:52 +08:00
|
|
|
|
<div className="scan-detail-primary-actions">
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="scan-detail-analysis-button"
|
|
|
|
|
|
onClick={() => void openTodayAnalysis()}
|
|
|
|
|
|
>
|
|
|
|
|
|
{isPro
|
|
|
|
|
|
? isEn
|
|
|
|
|
|
? "Today's Intraday Analysis"
|
|
|
|
|
|
: "今日日内分析"
|
|
|
|
|
|
: isEn
|
|
|
|
|
|
? "Today's Intraday Analysis · Pro"
|
|
|
|
|
|
: "今日日内分析 · Pro"}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-04-24 00:13:11 +08:00
|
|
|
|
<section className="scan-detail-section">
|
2026-04-23 23:49:52 +08:00
|
|
|
|
<div className="scan-detail-section-title">
|
|
|
|
|
|
{isEn ? "Current Context" : "当前概况"}
|
|
|
|
|
|
</div>
|
2026-04-24 00:13:11 +08:00
|
|
|
|
<div className="scan-kv-list">
|
|
|
|
|
|
<div className="scan-kv">
|
|
|
|
|
|
<span>{isEn ? "Local Time" : "当前时间"}</span>
|
|
|
|
|
|
<strong>{row.local_time || "--"}</strong>
|
2026-04-23 23:49:52 +08:00
|
|
|
|
</div>
|
2026-04-24 00:13:11 +08:00
|
|
|
|
<div className="scan-kv">
|
|
|
|
|
|
<span>{isEn ? "Current Temp" : "当前温度"}</span>
|
|
|
|
|
|
<strong>
|
2026-04-24 02:14:20 +08:00
|
|
|
|
{row.current_temp != null
|
|
|
|
|
|
? formatTemperatureValue(row.current_temp, tempSymbol)
|
|
|
|
|
|
: "--"}
|
2026-04-24 00:13:11 +08:00
|
|
|
|
</strong>
|
2026-04-23 23:49:52 +08:00
|
|
|
|
</div>
|
2026-04-24 00:13:11 +08:00
|
|
|
|
<div className="scan-kv">
|
|
|
|
|
|
<span>{isEn ? "Day High (So Far)" : "今日最高(至今)"}</span>
|
|
|
|
|
|
<strong>
|
2026-04-23 23:49:52 +08:00
|
|
|
|
{row.current_max_so_far != null
|
2026-04-24 02:14:20 +08:00
|
|
|
|
? formatTemperatureValue(row.current_max_so_far, tempSymbol)
|
2026-04-23 23:49:52 +08:00
|
|
|
|
: "--"}
|
2026-04-24 00:13:11 +08:00
|
|
|
|
</strong>
|
2026-04-23 23:49:52 +08:00
|
|
|
|
</div>
|
2026-04-24 00:13:11 +08:00
|
|
|
|
<div className="scan-kv">
|
|
|
|
|
|
<span>{isEn ? "Target" : "目标温度"}</span>
|
2026-04-24 02:14:20 +08:00
|
|
|
|
<strong>
|
|
|
|
|
|
{normalizeTemperatureLabel(displayRow.target_label, tempSymbol) || "--"}
|
|
|
|
|
|
</strong>
|
2026-04-23 23:49:52 +08:00
|
|
|
|
</div>
|
2026-04-24 00:13:11 +08:00
|
|
|
|
<div className="scan-kv">
|
|
|
|
|
|
<span>{isEn ? "Gap To Target" : "距离目标"}</span>
|
|
|
|
|
|
<strong className={Number(displayRow.gap_to_target || 0) <= 0 ? "warn" : "danger"}>
|
2026-04-23 23:49:52 +08:00
|
|
|
|
{displayRow.gap_to_target != null
|
2026-04-24 02:14:20 +08:00
|
|
|
|
? formatTemperatureValue(displayRow.gap_to_target, tempSymbol, {
|
|
|
|
|
|
signed: true,
|
|
|
|
|
|
digits: 1,
|
|
|
|
|
|
})
|
2026-04-23 23:49:52 +08:00
|
|
|
|
: "--"}
|
2026-04-24 00:13:11 +08:00
|
|
|
|
</strong>
|
2026-04-23 23:49:52 +08:00
|
|
|
|
</div>
|
2026-04-24 00:13:11 +08:00
|
|
|
|
<div className="scan-kv">
|
2026-04-24 10:19:16 +08:00
|
|
|
|
<span>{isEn ? "Time To Peak" : "距离预测峰值"}</span>
|
2026-04-24 00:13:11 +08:00
|
|
|
|
<strong>{formatRemainingWindow(displayRow.remaining_window_minutes, locale)}</strong>
|
2026-04-23 23:49:52 +08:00
|
|
|
|
</div>
|
2026-04-24 00:13:11 +08:00
|
|
|
|
<div className="scan-kv">
|
|
|
|
|
|
<span>{isEn ? "Airport" : "机场锚点"}</span>
|
|
|
|
|
|
<strong>{localizedAirport || "--"}</strong>
|
2026-04-23 23:49:52 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-04-24 00:13:11 +08:00
|
|
|
|
</section>
|
2026-04-23 23:49:52 +08:00
|
|
|
|
|
2026-04-24 00:13:11 +08:00
|
|
|
|
<section className="scan-detail-section">
|
|
|
|
|
|
<div className="scan-timeline-head">
|
2026-04-24 06:31:29 +08:00
|
|
|
|
<span>{cityDetail?.forecast?.sunrise || "--"}</span>
|
|
|
|
|
|
<span>{cityDetail?.forecast?.sunset || "--"}</span>
|
2026-04-24 00:13:11 +08:00
|
|
|
|
</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">
|
2026-04-24 06:31:29 +08:00
|
|
|
|
{isEn ? "Sunrise / Sunset" : "日出 / 日落"} · {phaseMeta.label}
|
2026-04-24 00:13:11 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
|
|
<section className="scan-detail-section">
|
2026-04-23 23:49:52 +08:00
|
|
|
|
<div className="scan-detail-section-title">
|
2026-04-24 00:13:11 +08:00
|
|
|
|
{isEn ? "Probability Comparison" : "概率分布对比"}
|
2026-04-23 23:49:52 +08:00
|
|
|
|
</div>
|
2026-04-24 00:13:11 +08:00
|
|
|
|
<div className="scan-chart-legend">
|
|
|
|
|
|
<span>
|
|
|
|
|
|
<i className="dot green" />
|
|
|
|
|
|
{isEn ? "Model" : "模型预测"}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span>
|
|
|
|
|
|
<i className="dot blue" />
|
|
|
|
|
|
{isEn ? "Market" : "市场隐含"}
|
|
|
|
|
|
</span>
|
2026-04-23 23:49:52 +08:00
|
|
|
|
</div>
|
2026-04-24 00:13:11 +08:00
|
|
|
|
<div className="scan-chart-bars">
|
|
|
|
|
|
{comparisonBuckets.map((bucket) => (
|
2026-04-24 00:34:09 +08:00
|
|
|
|
<div
|
|
|
|
|
|
key={bucket.label}
|
|
|
|
|
|
className={`scan-chart-group ${bucket.highlighted ? "highlighted" : ""}`}
|
|
|
|
|
|
>
|
2026-04-24 00:13:11 +08:00
|
|
|
|
<div
|
|
|
|
|
|
className="scan-chart-col model"
|
|
|
|
|
|
style={{ height: `${Math.max(8, (bucket.model / maxBar) * 120)}px` }}
|
2026-04-23 23:49:52 +08:00
|
|
|
|
/>
|
2026-04-24 00:13:11 +08:00
|
|
|
|
<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>
|
|
|
|
|
|
))}
|
2026-04-23 23:49:52 +08:00
|
|
|
|
</div>
|
2026-04-24 00:13:11 +08:00
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
|
|
<section className="scan-detail-section">
|
2026-04-24 10:19:16 +08:00
|
|
|
|
<div className="scan-detail-section-title">
|
|
|
|
|
|
{isEn ? "Main Signal" : "主信号概况"}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="scan-kv-list compact">
|
|
|
|
|
|
<div className="scan-kv">
|
|
|
|
|
|
<span>{isEn ? "Best Side" : "主方向"}</span>
|
|
|
|
|
|
<strong>{displayRow.side === "no" ? "NO" : "YES"}</strong>
|
2026-04-24 00:13:11 +08:00
|
|
|
|
</div>
|
2026-04-24 10:19:16 +08:00
|
|
|
|
<div className="scan-kv">
|
|
|
|
|
|
<span>{isEn ? "Best Buy" : "最优买价"}</span>
|
|
|
|
|
|
<strong>
|
|
|
|
|
|
{displayRow.side === "no"
|
|
|
|
|
|
? formatPrice(getDetailSideAsk(noRow, marketScan, "no"))
|
|
|
|
|
|
: formatPrice(getDetailSideAsk(yesRow, marketScan, "yes"))}
|
|
|
|
|
|
</strong>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="scan-kv">
|
|
|
|
|
|
<span>{isEn ? "EMOS" : "EMOS 概率"}</span>
|
|
|
|
|
|
<strong>
|
|
|
|
|
|
{displayRow.side === "no"
|
|
|
|
|
|
? formatProbability(noRow?.model_probability)
|
|
|
|
|
|
: formatProbability(yesRow?.model_probability)}
|
|
|
|
|
|
</strong>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="scan-kv">
|
|
|
|
|
|
<span>{isEn ? "Edge" : "边际优势"}</span>
|
|
|
|
|
|
<strong className={Number(displayRow.edge_percent || 0) >= 0 ? "positive" : "negative"}>
|
|
|
|
|
|
{formatPercent(displayRow.edge_percent, true)}
|
|
|
|
|
|
</strong>
|
2026-04-24 00:13:11 +08:00
|
|
|
|
</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>
|
2026-04-23 23:49:52 +08:00
|
|
|
|
</aside>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-24 03:30:55 +08:00
|
|
|
|
function CalendarView({
|
|
|
|
|
|
rows,
|
|
|
|
|
|
locale,
|
|
|
|
|
|
selectedRowId,
|
|
|
|
|
|
onSelectRow,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
rows: ScanOpportunityRow[];
|
|
|
|
|
|
locale: string;
|
|
|
|
|
|
selectedRowId: string | null;
|
|
|
|
|
|
onSelectRow: (row: ScanOpportunityRow) => void;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
const groups = useMemo(() => {
|
|
|
|
|
|
const byDate = new Map<string, ScanOpportunityRow[]>();
|
|
|
|
|
|
rows.forEach((row) => {
|
|
|
|
|
|
const key = String(row.selected_date || row.local_date || "unknown");
|
|
|
|
|
|
const list = byDate.get(key) || [];
|
|
|
|
|
|
list.push(row);
|
|
|
|
|
|
byDate.set(key, list);
|
|
|
|
|
|
});
|
|
|
|
|
|
return Array.from(byDate.entries()).sort(([left], [right]) => left.localeCompare(right));
|
|
|
|
|
|
}, [rows]);
|
|
|
|
|
|
|
|
|
|
|
|
if (!groups.length) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="scan-empty-state compact">
|
|
|
|
|
|
<div className="scan-empty-title">
|
|
|
|
|
|
{locale === "en-US" ? "No dated opportunities" : "当前没有日期机会"}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="scan-calendar-view">
|
|
|
|
|
|
{groups.map(([date, items]) => (
|
|
|
|
|
|
<section key={date} className="scan-calendar-group">
|
|
|
|
|
|
<div className="scan-calendar-group-head">
|
|
|
|
|
|
<div className="scan-calendar-date">{formatShortDate(date, locale)}</div>
|
|
|
|
|
|
<div className="scan-calendar-count">
|
|
|
|
|
|
{locale === "en-US" ? `${items.length} rows` : `${items.length} 条`}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="scan-calendar-grid">
|
|
|
|
|
|
{items.map((row) => (
|
|
|
|
|
|
<button
|
|
|
|
|
|
key={row.id}
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className={`scan-calendar-card ${selectedRowId === row.id ? "selected" : ""}`}
|
|
|
|
|
|
onClick={() => onSelectRow(row)}
|
|
|
|
|
|
>
|
2026-04-24 10:19:16 +08:00
|
|
|
|
{(() => {
|
|
|
|
|
|
const tempSymbol = row.temp_symbol || "°C";
|
|
|
|
|
|
const phaseMeta = getWindowPhaseMeta(row, locale);
|
|
|
|
|
|
return (
|
|
|
|
|
|
<>
|
2026-04-24 03:30:55 +08:00
|
|
|
|
<div className="scan-calendar-city">
|
|
|
|
|
|
{getLocalizedCityName(
|
|
|
|
|
|
row.city,
|
|
|
|
|
|
row.city_display_name || row.display_name || row.city,
|
|
|
|
|
|
locale,
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
2026-04-24 10:19:16 +08:00
|
|
|
|
<div className="scan-calendar-action">
|
|
|
|
|
|
{locale === "en-US" ? "DEB high" : "DEB 预测高点"} ·{" "}
|
|
|
|
|
|
{row.deb_prediction != null
|
|
|
|
|
|
? formatTemperatureValue(row.deb_prediction, tempSymbol)
|
|
|
|
|
|
: "--"}
|
2026-04-24 03:30:55 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<div className="scan-calendar-meta">
|
|
|
|
|
|
<span>{row.local_time || "--"}</span>
|
2026-04-24 10:19:16 +08:00
|
|
|
|
<span>{phaseMeta.label}</span>
|
2026-04-24 03:30:55 +08:00
|
|
|
|
</div>
|
2026-04-24 10:19:16 +08:00
|
|
|
|
</>
|
|
|
|
|
|
);
|
|
|
|
|
|
})()}
|
2026-04-24 03:30:55 +08:00
|
|
|
|
</button>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function OverviewMapView({ locale }: { locale: string }) {
|
|
|
|
|
|
const store = useDashboardStore();
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="scan-map-view">
|
|
|
|
|
|
<div className="scan-map-shell">
|
|
|
|
|
|
<MapCanvas />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="scan-map-caption">
|
|
|
|
|
|
{locale === "en-US"
|
|
|
|
|
|
? `Monitoring ${store.cities.length} cities on the original map canvas.`
|
|
|
|
|
|
: `正在用原地图画布监控 ${store.cities.length} 个城市。`}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-24 12:46:44 +08:00
|
|
|
|
function AssistantWidget({
|
|
|
|
|
|
terminalData,
|
|
|
|
|
|
rows,
|
|
|
|
|
|
selectedRow,
|
|
|
|
|
|
locale,
|
|
|
|
|
|
totalCities,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
terminalData: ScanTerminalResponse | null;
|
|
|
|
|
|
rows: ScanOpportunityRow[];
|
|
|
|
|
|
selectedRow: ScanOpportunityRow | null;
|
|
|
|
|
|
locale: string;
|
|
|
|
|
|
totalCities: number;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
const isEn = locale === "en-US";
|
|
|
|
|
|
const [open, setOpen] = useState(false);
|
|
|
|
|
|
const [question, setQuestion] = useState("");
|
|
|
|
|
|
const [submitting, setSubmitting] = useState(false);
|
|
|
|
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
|
|
const [messages, setMessages] = useState<AssistantMessage[]>(() => [
|
|
|
|
|
|
{
|
|
|
|
|
|
id: "initial",
|
|
|
|
|
|
role: "assistant",
|
|
|
|
|
|
content: isEn
|
|
|
|
|
|
? "Ask about current opportunities, edge ranking, one city, or the forecast high. I only use the current scan data."
|
|
|
|
|
|
: "可以问当前机会、edge 排序、某个城市是否值得参与,或今天预测最高温。我只使用当前扫描数据。",
|
|
|
|
|
|
},
|
|
|
|
|
|
]);
|
|
|
|
|
|
const [position, setPosition] = useState<{ x: number; y: number } | null>(null);
|
|
|
|
|
|
const dragStateRef = useRef<{
|
|
|
|
|
|
pointerId: number;
|
|
|
|
|
|
startX: number;
|
|
|
|
|
|
startY: number;
|
|
|
|
|
|
originX: number;
|
|
|
|
|
|
originY: number;
|
|
|
|
|
|
moved: boolean;
|
|
|
|
|
|
} | null>(null);
|
|
|
|
|
|
const suppressClickRef = useRef(false);
|
|
|
|
|
|
const [dragging, setDragging] = useState(false);
|
|
|
|
|
|
|
|
|
|
|
|
const context = useMemo(
|
|
|
|
|
|
() =>
|
|
|
|
|
|
buildAssistantContext({
|
|
|
|
|
|
terminalData,
|
|
|
|
|
|
rows,
|
|
|
|
|
|
selectedRow,
|
|
|
|
|
|
locale,
|
|
|
|
|
|
totalCities,
|
|
|
|
|
|
}),
|
|
|
|
|
|
[locale, rows, selectedRow, terminalData, totalCities],
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
setMessages((current) =>
|
|
|
|
|
|
current[0]?.id === "initial"
|
|
|
|
|
|
? [
|
|
|
|
|
|
{
|
|
|
|
|
|
id: "initial",
|
|
|
|
|
|
role: "assistant",
|
|
|
|
|
|
content: isEn
|
|
|
|
|
|
? "Ask about current opportunities, edge ranking, one city, or the forecast high. I only use the current scan data."
|
|
|
|
|
|
: "可以问当前机会、edge 排序、某个城市是否值得参与,或今天预测最高温。我只使用当前扫描数据。",
|
|
|
|
|
|
},
|
|
|
|
|
|
...current.slice(1),
|
|
|
|
|
|
]
|
|
|
|
|
|
: current,
|
|
|
|
|
|
);
|
|
|
|
|
|
}, [isEn]);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const stored = window.localStorage.getItem("polyweather_ai_position_v1");
|
|
|
|
|
|
if (stored) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const parsed = JSON.parse(stored) as { x?: number; y?: number };
|
|
|
|
|
|
if (Number.isFinite(parsed.x) && Number.isFinite(parsed.y)) {
|
|
|
|
|
|
setPosition({
|
|
|
|
|
|
x: Math.min(Math.max(Number(parsed.x), 12), window.innerWidth - 72),
|
|
|
|
|
|
y: Math.min(Math.max(Number(parsed.y), 76), window.innerHeight - 72),
|
|
|
|
|
|
});
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// Ignore invalid localStorage state.
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
setPosition({
|
|
|
|
|
|
x: Math.max(16, window.innerWidth - 88),
|
|
|
|
|
|
y: Math.max(88, window.innerHeight - 96),
|
|
|
|
|
|
});
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!position) return;
|
|
|
|
|
|
window.localStorage.setItem(
|
|
|
|
|
|
"polyweather_ai_position_v1",
|
|
|
|
|
|
JSON.stringify(position),
|
|
|
|
|
|
);
|
|
|
|
|
|
}, [position]);
|
|
|
|
|
|
|
|
|
|
|
|
const clampPosition = useCallback((nextX: number, nextY: number, isPanel: boolean) => {
|
|
|
|
|
|
const width = isPanel ? Math.min(380, window.innerWidth - 32) : 56;
|
|
|
|
|
|
const height = isPanel ? Math.min(520, window.innerHeight - 96) : 56;
|
|
|
|
|
|
return {
|
|
|
|
|
|
x: Math.min(Math.max(nextX, 12), Math.max(12, window.innerWidth - width - 12)),
|
|
|
|
|
|
y: Math.min(Math.max(nextY, 76), Math.max(76, window.innerHeight - height - 12)),
|
|
|
|
|
|
};
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
const startDrag = useCallback(
|
|
|
|
|
|
(event: React.PointerEvent<HTMLElement>) => {
|
|
|
|
|
|
if (!position) return;
|
|
|
|
|
|
dragStateRef.current = {
|
|
|
|
|
|
pointerId: event.pointerId,
|
|
|
|
|
|
startX: event.clientX,
|
|
|
|
|
|
startY: event.clientY,
|
|
|
|
|
|
originX: position.x,
|
|
|
|
|
|
originY: position.y,
|
|
|
|
|
|
moved: false,
|
|
|
|
|
|
};
|
|
|
|
|
|
setDragging(true);
|
|
|
|
|
|
event.currentTarget.setPointerCapture?.(event.pointerId);
|
|
|
|
|
|
},
|
|
|
|
|
|
[position],
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const updateDrag = useCallback(
|
|
|
|
|
|
(event: React.PointerEvent<HTMLElement>) => {
|
|
|
|
|
|
const dragState = dragStateRef.current;
|
|
|
|
|
|
if (!dragState || dragState.pointerId !== event.pointerId) return;
|
|
|
|
|
|
const deltaX = event.clientX - dragState.startX;
|
|
|
|
|
|
const deltaY = event.clientY - dragState.startY;
|
|
|
|
|
|
if (Math.abs(deltaX) + Math.abs(deltaY) > 4) {
|
|
|
|
|
|
dragState.moved = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
setPosition(
|
|
|
|
|
|
clampPosition(dragState.originX + deltaX, dragState.originY + deltaY, open),
|
|
|
|
|
|
);
|
|
|
|
|
|
},
|
|
|
|
|
|
[clampPosition, open],
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const endDrag = useCallback((event: React.PointerEvent<HTMLElement>) => {
|
|
|
|
|
|
const dragState = dragStateRef.current;
|
|
|
|
|
|
if (dragState?.pointerId === event.pointerId) {
|
|
|
|
|
|
event.currentTarget.releasePointerCapture?.(event.pointerId);
|
|
|
|
|
|
setDragging(false);
|
|
|
|
|
|
if (dragState.moved) {
|
|
|
|
|
|
suppressClickRef.current = true;
|
|
|
|
|
|
window.setTimeout(() => {
|
|
|
|
|
|
suppressClickRef.current = false;
|
|
|
|
|
|
}, 160);
|
|
|
|
|
|
}
|
|
|
|
|
|
window.setTimeout(() => {
|
|
|
|
|
|
dragStateRef.current = null;
|
|
|
|
|
|
}, 0);
|
|
|
|
|
|
}
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
const starterQuestions = useMemo(() => {
|
|
|
|
|
|
const cityName =
|
|
|
|
|
|
context.selected_city?.city_display_name ||
|
|
|
|
|
|
(isEn ? "the focus city" : "当前焦点城市");
|
|
|
|
|
|
return isEn
|
|
|
|
|
|
? [
|
|
|
|
|
|
"Which market is worth buying now?",
|
|
|
|
|
|
"Rank opportunities by edge",
|
|
|
|
|
|
`What is today's forecast high for ${cityName}?`,
|
|
|
|
|
|
]
|
|
|
|
|
|
: [
|
|
|
|
|
|
"当前有哪些值得参与的市场?",
|
|
|
|
|
|
"按 edge 排序",
|
|
|
|
|
|
`${cityName} 今天预测最高温是多少?`,
|
|
|
|
|
|
];
|
|
|
|
|
|
}, [context.selected_city?.city_display_name, isEn]);
|
|
|
|
|
|
|
|
|
|
|
|
const submitQuestion = useCallback(
|
|
|
|
|
|
async (rawQuestion?: string) => {
|
|
|
|
|
|
const nextQuestion = String(rawQuestion ?? question).trim();
|
|
|
|
|
|
if (!nextQuestion || submitting) return;
|
|
|
|
|
|
const userMessage: AssistantMessage = {
|
|
|
|
|
|
id: `user-${Date.now()}`,
|
|
|
|
|
|
role: "user",
|
|
|
|
|
|
content: nextQuestion,
|
|
|
|
|
|
};
|
|
|
|
|
|
setMessages((current) => [...current, userMessage]);
|
|
|
|
|
|
setQuestion("");
|
|
|
|
|
|
setError(null);
|
|
|
|
|
|
setSubmitting(true);
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await dashboardClient.askAssistant({
|
|
|
|
|
|
question: nextQuestion,
|
|
|
|
|
|
locale,
|
|
|
|
|
|
snapshotId: context.snapshot_id,
|
|
|
|
|
|
context,
|
|
|
|
|
|
});
|
|
|
|
|
|
setMessages((current) => [
|
|
|
|
|
|
...current,
|
|
|
|
|
|
{
|
|
|
|
|
|
id: `assistant-${Date.now()}`,
|
|
|
|
|
|
role: "assistant",
|
|
|
|
|
|
content: response.answer,
|
|
|
|
|
|
},
|
|
|
|
|
|
]);
|
|
|
|
|
|
} catch (submitError) {
|
|
|
|
|
|
const message = String(submitError);
|
|
|
|
|
|
const paywall =
|
|
|
|
|
|
message.includes("402") || message.includes("assistant_requires_pro");
|
|
|
|
|
|
setError(
|
|
|
|
|
|
paywall
|
|
|
|
|
|
? isEn
|
|
|
|
|
|
? "PolyWeather AI assistant is a Pro feature."
|
|
|
|
|
|
: "AI 对话助手属于 Pro 功能,请先开通后使用。"
|
|
|
|
|
|
: isEn
|
|
|
|
|
|
? "Assistant request failed. Try again after the market scan refreshes."
|
|
|
|
|
|
: "AI 请求失败。请等市场扫描刷新后再试。",
|
|
|
|
|
|
);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setSubmitting(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
[context, isEn, locale, question, submitting],
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const handleLauncherClick = useCallback(() => {
|
|
|
|
|
|
if (suppressClickRef.current || dragStateRef.current?.moved) return;
|
|
|
|
|
|
setOpen(true);
|
|
|
|
|
|
setPosition((current) => {
|
|
|
|
|
|
const next = current || {
|
|
|
|
|
|
x: window.innerWidth - 420,
|
|
|
|
|
|
y: window.innerHeight - 520,
|
|
|
|
|
|
};
|
|
|
|
|
|
return clampPosition(next.x, next.y, true);
|
|
|
|
|
|
});
|
|
|
|
|
|
}, [clampPosition]);
|
|
|
|
|
|
|
|
|
|
|
|
if (!position) return null;
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div
|
|
|
|
|
|
className={clsx("home-ai-assistant", !open && "collapsed", dragging && "dragging")}
|
|
|
|
|
|
style={{
|
|
|
|
|
|
left: position.x,
|
|
|
|
|
|
top: position.y,
|
|
|
|
|
|
right: "auto",
|
|
|
|
|
|
bottom: "auto",
|
|
|
|
|
|
width: open ? "min(380px, calc(100vw - 32px))" : 56,
|
|
|
|
|
|
}}
|
|
|
|
|
|
onPointerMove={updateDrag}
|
|
|
|
|
|
onPointerUp={endDrag}
|
|
|
|
|
|
onPointerCancel={endDrag}
|
|
|
|
|
|
>
|
|
|
|
|
|
{!open ? (
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="home-ai-launcher"
|
|
|
|
|
|
aria-label={isEn ? "Open AI assistant" : "打开 AI 对话助手"}
|
|
|
|
|
|
title={isEn ? "Open AI assistant" : "打开 AI 对话助手"}
|
|
|
|
|
|
onPointerDown={startDrag}
|
|
|
|
|
|
onClick={handleLauncherClick}
|
|
|
|
|
|
>
|
|
|
|
|
|
<img src="/favicon-32x32.png" alt="" className="home-ai-launcher-icon" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<section className="home-ai-panel" aria-label={isEn ? "AI assistant" : "AI 对话助手"}>
|
|
|
|
|
|
<div className="home-ai-header">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<strong>{isEn ? "AI Assistant" : "AI 对话助手"}</strong>
|
|
|
|
|
|
<span>
|
|
|
|
|
|
{isEn ? "Current market snapshot only" : "仅基于当前市场快照"}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="home-ai-header-actions">
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="home-ai-drag-handle"
|
|
|
|
|
|
aria-label={isEn ? "Move assistant" : "移动助手"}
|
|
|
|
|
|
title={isEn ? "Move assistant" : "移动助手"}
|
|
|
|
|
|
onPointerDown={startDrag}
|
|
|
|
|
|
>
|
|
|
|
|
|
⋮⋮
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="home-ai-close"
|
|
|
|
|
|
aria-label={isEn ? "Collapse assistant" : "收起助手"}
|
|
|
|
|
|
onClick={() => setOpen(false)}
|
|
|
|
|
|
>
|
|
|
|
|
|
×
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="home-ai-disclaimer">
|
|
|
|
|
|
{isEn
|
|
|
|
|
|
? "Uses only scan data already provided by PolyWeather; no invented prices, probabilities, or cities."
|
|
|
|
|
|
: "只使用 PolyWeather 当前扫描数据,不编造价格、概率或城市。"}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="home-ai-messages" aria-live="polite">
|
|
|
|
|
|
{messages.map((message) => (
|
|
|
|
|
|
<div key={message.id} className={clsx("home-ai-message", message.role)}>
|
|
|
|
|
|
<p>{message.content}</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
{submitting ? (
|
|
|
|
|
|
<div className="home-ai-message assistant loading">
|
|
|
|
|
|
<p>{isEn ? "Reading the current scan..." : "正在读取当前扫描数据..."}</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="home-ai-starters">
|
|
|
|
|
|
{starterQuestions.map((starter) => (
|
|
|
|
|
|
<button
|
|
|
|
|
|
key={starter}
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="home-ai-starter"
|
|
|
|
|
|
onClick={() => void submitQuestion(starter)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{starter}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="home-ai-composer">
|
|
|
|
|
|
<textarea
|
|
|
|
|
|
className="home-ai-input"
|
|
|
|
|
|
value={question}
|
|
|
|
|
|
onChange={(event) => setQuestion(event.target.value)}
|
|
|
|
|
|
placeholder={
|
|
|
|
|
|
isEn
|
|
|
|
|
|
? "Ask about current opportunities, one city, or edge..."
|
|
|
|
|
|
: "询问当前机会、某个城市、edge 排序..."
|
|
|
|
|
|
}
|
|
|
|
|
|
onKeyDown={(event) => {
|
|
|
|
|
|
if (event.key === "Enter" && (event.ctrlKey || event.metaKey)) {
|
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
|
void submitQuestion();
|
|
|
|
|
|
}
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<div className="home-ai-composer-actions">
|
|
|
|
|
|
<span className="home-ai-error">{error}</span>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="home-ai-send"
|
|
|
|
|
|
disabled={!question.trim() || submitting}
|
|
|
|
|
|
onClick={() => void submitQuestion()}
|
|
|
|
|
|
>
|
|
|
|
|
|
{isEn ? "Send" : "发送"}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-23 23:49:52 +08:00
|
|
|
|
function ScanTerminalScreen() {
|
2026-04-24 00:34:09 +08:00
|
|
|
|
const store = useDashboardStore();
|
|
|
|
|
|
const { locale, toggleLocale } = useI18n();
|
2026-04-23 23:49:52 +08:00
|
|
|
|
const isEn = locale === "en-US";
|
2026-04-24 00:34:09 +08:00
|
|
|
|
const accountHref = store.proAccess.authenticated
|
|
|
|
|
|
? "/account"
|
|
|
|
|
|
: "/auth/login?next=%2Faccount";
|
2026-04-23 23:49:52 +08:00
|
|
|
|
const [draftFilters, setDraftFilters] = useState<FilterState>(DEFAULT_FILTERS);
|
|
|
|
|
|
const [activeFilters, setActiveFilters] = useState<FilterState>(DEFAULT_FILTERS);
|
|
|
|
|
|
const [terminalData, setTerminalData] = useState<ScanTerminalResponse | null>(null);
|
|
|
|
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
|
|
const [selectedRowId, setSelectedRowId] = useState<string | null>(null);
|
|
|
|
|
|
const [detailByRowId, setDetailByRowId] = useState<Record<string, MarketScan | null>>({});
|
|
|
|
|
|
const [detailLoadingId, setDetailLoadingId] = useState<string | null>(null);
|
2026-04-24 03:30:55 +08:00
|
|
|
|
const [activeView, setActiveView] = useState<ContentView>("list");
|
2026-04-24 05:09:19 +08:00
|
|
|
|
const [mapSelectedCityName, setMapSelectedCityName] = useState<string | null>(null);
|
2026-04-24 05:39:14 +08:00
|
|
|
|
const [userLocalTime, setUserLocalTime] = useState("--");
|
|
|
|
|
|
const [themeMode, setThemeMode] = useState<ThemeMode>("dark");
|
2026-04-23 23:49:52 +08:00
|
|
|
|
const deferredRows = useDeferredValue(terminalData?.rows || []);
|
2026-04-24 05:09:19 +08:00
|
|
|
|
const timeSortedRows = useMemo(
|
|
|
|
|
|
() => sortRowsByUserTime(deferredRows),
|
|
|
|
|
|
[deferredRows],
|
|
|
|
|
|
);
|
2026-04-24 00:13:11 +08:00
|
|
|
|
|
2026-04-23 23:49:52 +08:00
|
|
|
|
const selectedRow = useMemo(() => {
|
2026-04-24 05:09:19 +08:00
|
|
|
|
if (!timeSortedRows.length) return null;
|
|
|
|
|
|
return timeSortedRows.find((row) => row.id === selectedRowId) || timeSortedRows[0] || null;
|
|
|
|
|
|
}, [timeSortedRows, selectedRowId]);
|
2026-04-23 23:49:52 +08:00
|
|
|
|
|
2026-04-24 05:39:14 +08:00
|
|
|
|
const mapFocusedRow = useMemo(() => {
|
|
|
|
|
|
const cityName = String(mapSelectedCityName || store.selectedCity || "").toLowerCase();
|
|
|
|
|
|
if (!cityName) return null;
|
|
|
|
|
|
return (
|
|
|
|
|
|
timeSortedRows.find(
|
|
|
|
|
|
(row) =>
|
|
|
|
|
|
row.city.toLowerCase() === cityName ||
|
|
|
|
|
|
String(row.city_display_name || row.display_name || "").toLowerCase() === cityName,
|
|
|
|
|
|
) || null
|
|
|
|
|
|
);
|
|
|
|
|
|
}, [mapSelectedCityName, store.selectedCity, timeSortedRows]);
|
|
|
|
|
|
|
2026-04-23 23:54:06 +08:00
|
|
|
|
const fetchTerminal = async (filters: FilterState, force = false) => {
|
2026-04-23 23:49:52 +08:00
|
|
|
|
setLoading(true);
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await dashboardClient.getScanTerminal(filters, { force });
|
|
|
|
|
|
startTransition(() => {
|
|
|
|
|
|
setTerminalData(response);
|
|
|
|
|
|
setActiveFilters(filters);
|
2026-04-24 10:19:16 +08:00
|
|
|
|
setError(response.status === "failed" ? response.stale_reason || null : null);
|
2026-04-23 23:49:52 +08:00
|
|
|
|
setSelectedRowId((current) => {
|
|
|
|
|
|
if (current && response.rows.some((row) => row.id === current)) {
|
|
|
|
|
|
return current;
|
|
|
|
|
|
}
|
2026-04-24 05:09:19 +08:00
|
|
|
|
return sortRowsByUserTime(response.rows)[0]?.id || response.top_signal?.id || null;
|
2026-04-23 23:49:52 +08:00
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
} catch (fetchError) {
|
|
|
|
|
|
setError(String(fetchError));
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setLoading(false);
|
|
|
|
|
|
}
|
2026-04-23 23:54:06 +08:00
|
|
|
|
};
|
2026-04-23 23:49:52 +08:00
|
|
|
|
|
2026-04-23 23:54:06 +08:00
|
|
|
|
const fetchDetail = async (row: ScanOpportunityRow) => {
|
2026-04-24 10:19:16 +08:00
|
|
|
|
if (!row.market_slug || !row.selected_date || row.closed) return;
|
2026-04-23 23:49:52 +08:00
|
|
|
|
if (detailByRowId[row.id] !== undefined) return;
|
|
|
|
|
|
setDetailLoadingId(row.id);
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await dashboardClient.getCityMarketScan(row.city, {
|
|
|
|
|
|
force: false,
|
|
|
|
|
|
marketSlug: row.market_slug,
|
|
|
|
|
|
targetDate: row.selected_date,
|
|
|
|
|
|
});
|
|
|
|
|
|
setDetailByRowId((current) => ({
|
|
|
|
|
|
...current,
|
|
|
|
|
|
[row.id]: response.market_scan || null,
|
|
|
|
|
|
}));
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
setDetailByRowId((current) => ({
|
|
|
|
|
|
...current,
|
|
|
|
|
|
[row.id]: null,
|
|
|
|
|
|
}));
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setDetailLoadingId((current) => (current === row.id ? null : current));
|
|
|
|
|
|
}
|
2026-04-23 23:54:06 +08:00
|
|
|
|
};
|
2026-04-23 23:49:52 +08:00
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
void fetchTerminal(DEFAULT_FILTERS, false);
|
2026-04-23 23:54:06 +08:00
|
|
|
|
}, []);
|
2026-04-23 23:49:52 +08:00
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const intervalId = window.setInterval(() => {
|
|
|
|
|
|
void fetchTerminal(activeFilters, false);
|
|
|
|
|
|
}, 30_000);
|
2026-04-24 00:13:11 +08:00
|
|
|
|
return () => window.clearInterval(intervalId);
|
2026-04-23 23:54:06 +08:00
|
|
|
|
}, [activeFilters]);
|
2026-04-23 23:49:52 +08:00
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
2026-04-24 06:31:29 +08:00
|
|
|
|
setUserLocalTime(formatUserLocalTime());
|
2026-04-24 05:39:14 +08:00
|
|
|
|
const intervalId = window.setInterval(() => {
|
2026-04-24 06:31:29 +08:00
|
|
|
|
setUserLocalTime(formatUserLocalTime());
|
|
|
|
|
|
}, 10_000);
|
2026-04-24 05:39:14 +08:00
|
|
|
|
return () => window.clearInterval(intervalId);
|
2026-04-24 06:31:29 +08:00
|
|
|
|
}, []);
|
2026-04-24 05:39:14 +08:00
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const stored = window.localStorage.getItem("polyweather_scan_theme");
|
|
|
|
|
|
if (stored === "light") {
|
|
|
|
|
|
setThemeMode("light");
|
|
|
|
|
|
}
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
window.localStorage.setItem("polyweather_scan_theme", themeMode);
|
|
|
|
|
|
}, [themeMode]);
|
2026-04-23 23:49:52 +08:00
|
|
|
|
|
2026-04-24 10:19:16 +08:00
|
|
|
|
const resolvedView: ContentView = activeView;
|
2026-04-24 05:09:19 +08:00
|
|
|
|
const mapFocusedCity = mapSelectedCityName || store.selectedCity;
|
2026-04-24 05:39:14 +08:00
|
|
|
|
const activeDetailRow = resolvedView === "map" && mapFocusedCity ? mapFocusedRow : selectedRow;
|
|
|
|
|
|
const selectedDetail = activeDetailRow ? detailByRowId[activeDetailRow.id] : null;
|
2026-04-24 10:19:16 +08:00
|
|
|
|
const scanStatus = terminalData?.status || (loading ? "loading" : error ? "failed" : "ready");
|
|
|
|
|
|
const staleReason =
|
|
|
|
|
|
terminalData?.stale_reason || error || null;
|
2026-04-24 05:39:14 +08:00
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!activeDetailRow) return;
|
|
|
|
|
|
void fetchDetail(activeDetailRow);
|
2026-04-24 06:31:29 +08:00
|
|
|
|
if (!store.cityDetailsByName[activeDetailRow.city]) {
|
|
|
|
|
|
void store.ensureCityDetail(activeDetailRow.city, false, "panel").catch(() => {});
|
|
|
|
|
|
}
|
2026-04-24 05:39:14 +08:00
|
|
|
|
}, [activeDetailRow, detailByRowId]);
|
|
|
|
|
|
|
|
|
|
|
|
const handleMapCitySelect = useCallback((cityName: string) => {
|
|
|
|
|
|
setMapSelectedCityName(cityName);
|
|
|
|
|
|
const matchedRow =
|
|
|
|
|
|
timeSortedRows.find(
|
|
|
|
|
|
(row) =>
|
|
|
|
|
|
row.city.toLowerCase() === cityName.toLowerCase() ||
|
|
|
|
|
|
String(row.city_display_name || row.display_name || "").toLowerCase() === cityName.toLowerCase(),
|
|
|
|
|
|
) || null;
|
|
|
|
|
|
setSelectedRowId(matchedRow?.id || null);
|
|
|
|
|
|
}, [timeSortedRows]);
|
|
|
|
|
|
|
|
|
|
|
|
const handleSelectRow = useCallback((row: ScanOpportunityRow) => {
|
|
|
|
|
|
setSelectedRowId(row.id);
|
|
|
|
|
|
}, []);
|
2026-04-24 03:30:55 +08:00
|
|
|
|
|
|
|
|
|
|
const renderMainView = () => {
|
|
|
|
|
|
if (resolvedView === "map") {
|
2026-04-24 05:09:19 +08:00
|
|
|
|
return (
|
|
|
|
|
|
<div className="scan-map-view">
|
|
|
|
|
|
<div className="scan-map-shell">
|
2026-04-24 05:39:14 +08:00
|
|
|
|
<MapCanvas onCitySelect={handleMapCitySelect} />
|
2026-04-24 05:09:19 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<div className="scan-map-caption">
|
|
|
|
|
|
{locale === "en-US"
|
|
|
|
|
|
? `Monitoring ${store.cities.length} cities on the original map canvas.`
|
|
|
|
|
|
: `正在用原地图画布监控 ${store.cities.length} 个城市。`}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
2026-04-24 03:30:55 +08:00
|
|
|
|
}
|
|
|
|
|
|
if (resolvedView === "calendar") {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<CalendarView
|
2026-04-24 05:09:19 +08:00
|
|
|
|
rows={timeSortedRows}
|
2026-04-24 03:30:55 +08:00
|
|
|
|
locale={locale}
|
|
|
|
|
|
selectedRowId={selectedRowId}
|
2026-04-24 05:39:14 +08:00
|
|
|
|
onSelectRow={handleSelectRow}
|
2026-04-24 03:30:55 +08:00
|
|
|
|
/>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
return (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<OpportunityTable
|
2026-04-24 05:09:19 +08:00
|
|
|
|
rows={timeSortedRows}
|
2026-04-24 10:19:16 +08:00
|
|
|
|
status={scanStatus}
|
|
|
|
|
|
stale={Boolean(terminalData?.stale)}
|
|
|
|
|
|
staleReason={staleReason}
|
|
|
|
|
|
loading={loading}
|
2026-04-24 03:30:55 +08:00
|
|
|
|
selectedRowId={selectedRowId}
|
2026-04-24 05:39:14 +08:00
|
|
|
|
onSelectRow={handleSelectRow}
|
2026-04-24 03:30:55 +08:00
|
|
|
|
/>
|
|
|
|
|
|
</>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
2026-04-23 23:49:52 +08:00
|
|
|
|
|
|
|
|
|
|
return (
|
2026-04-24 00:13:11 +08:00
|
|
|
|
<div className={clsx(styles.root, detailChromeStyles.root, modalChromeStyles.root)}>
|
2026-04-24 05:39:14 +08:00
|
|
|
|
<div className={clsx("scan-terminal", themeMode === "light" && "light")}>
|
2026-04-23 23:49:52 +08:00
|
|
|
|
<ScanFilterPanel
|
|
|
|
|
|
value={draftFilters}
|
|
|
|
|
|
onChange={setDraftFilters}
|
|
|
|
|
|
onScan={(filters) => {
|
|
|
|
|
|
setDraftFilters(filters);
|
|
|
|
|
|
void fetchTerminal(filters, true);
|
|
|
|
|
|
}}
|
|
|
|
|
|
isScanning={loading}
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
|
|
<main className="scan-data-grid">
|
2026-04-24 00:13:11 +08:00
|
|
|
|
<div className="scan-topbar">
|
2026-04-24 10:19:16 +08:00
|
|
|
|
<div className="scan-topbar-title">
|
|
|
|
|
|
<strong>{isEn ? "Market Scan Terminal" : "市场扫描台"}</strong>
|
|
|
|
|
|
<span>
|
|
|
|
|
|
{loading
|
|
|
|
|
|
? isEn
|
|
|
|
|
|
? "Refreshing current market snapshot"
|
|
|
|
|
|
: "正在刷新当前市场快照"
|
|
|
|
|
|
: terminalData?.stale
|
|
|
|
|
|
? isEn
|
|
|
|
|
|
? "Showing the last successful snapshot"
|
|
|
|
|
|
: "当前显示上次成功快照"
|
|
|
|
|
|
: isEn
|
|
|
|
|
|
? "Read-only market scan with peak-first main signal"
|
|
|
|
|
|
: "只读市场扫描,主信号按 EMOS 主峰优先"}
|
|
|
|
|
|
</span>
|
2026-04-23 23:49:52 +08:00
|
|
|
|
</div>
|
2026-04-24 00:13:11 +08:00
|
|
|
|
<div className="scan-topbar-actions">
|
2026-04-24 00:34:09 +08:00
|
|
|
|
<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>
|
2026-04-24 00:13:11 +08:00
|
|
|
|
<span className="scan-topbar-time">
|
2026-04-24 05:39:14 +08:00
|
|
|
|
{userLocalTime}
|
2026-04-23 23:49:52 +08:00
|
|
|
|
</span>
|
2026-04-24 05:39:14 +08:00
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="scan-theme-button"
|
|
|
|
|
|
aria-label={themeMode === "light" ? "切换到暗色模式" : "切换到明亮模式"}
|
|
|
|
|
|
title={themeMode === "light" ? "切换到暗色模式" : "切换到明亮模式"}
|
|
|
|
|
|
onClick={() => setThemeMode((current) => (current === "light" ? "dark" : "light"))}
|
|
|
|
|
|
>
|
|
|
|
|
|
{themeMode === "light" ? <Moon size={15} /> : <Sun size={15} />}
|
|
|
|
|
|
</button>
|
2026-04-24 00:13:11 +08:00
|
|
|
|
<button type="button" className="scan-ghost-button" onClick={() => void fetchTerminal(activeFilters, true)}>
|
|
|
|
|
|
<RefreshCw size={14} className={loading ? "spin" : undefined} />
|
2026-04-24 10:19:16 +08:00
|
|
|
|
{isEn ? "Refresh" : "刷新"}
|
2026-04-24 00:13:11 +08:00
|
|
|
|
</button>
|
2026-04-24 00:34:09 +08:00
|
|
|
|
<Link
|
|
|
|
|
|
href={accountHref}
|
|
|
|
|
|
className="scan-account-button"
|
|
|
|
|
|
aria-label={isEn ? "Account" : "账户"}
|
|
|
|
|
|
title={isEn ? "Account" : "账户"}
|
|
|
|
|
|
>
|
|
|
|
|
|
<UserRound size={15} />
|
|
|
|
|
|
</Link>
|
2026-04-23 23:49:52 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<ScanKPIBar
|
2026-04-24 10:19:16 +08:00
|
|
|
|
response={terminalData}
|
|
|
|
|
|
rows={timeSortedRows}
|
|
|
|
|
|
totalCities={store.cities.length}
|
|
|
|
|
|
loading={loading}
|
2026-04-23 23:49:52 +08:00
|
|
|
|
/>
|
|
|
|
|
|
|
2026-04-24 00:13:11 +08:00
|
|
|
|
<section className="scan-list-section">
|
|
|
|
|
|
<div className="scan-list-header">
|
|
|
|
|
|
<div className="scan-list-tabs">
|
2026-04-24 03:30:55 +08:00
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
2026-04-24 10:19:16 +08:00
|
|
|
|
className={resolvedView === "list" ? "active" : ""}
|
2026-04-24 03:30:55 +08:00
|
|
|
|
onClick={() => {
|
|
|
|
|
|
setActiveView("list");
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
2026-04-24 00:13:11 +08:00
|
|
|
|
{isEn ? "Opportunity List" : "机会列表"}
|
|
|
|
|
|
</button>
|
2026-04-24 03:30:55 +08:00
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className={resolvedView === "map" ? "active" : ""}
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
setActiveView("map");
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
{isEn ? "Distribution View" : "分布视图"}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className={resolvedView === "calendar" ? "active" : ""}
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
setActiveView("calendar");
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
{isEn ? "Calendar View" : "日历视图"}
|
|
|
|
|
|
</button>
|
2026-04-24 00:13:11 +08:00
|
|
|
|
</div>
|
2026-04-24 10:19:16 +08:00
|
|
|
|
<div className="scan-list-status">
|
|
|
|
|
|
{terminalData?.stale ? (
|
|
|
|
|
|
<span className="scan-status-chip stale">
|
|
|
|
|
|
{isEn ? "Delayed snapshot" : "延迟快照"}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
{loading ? (
|
|
|
|
|
|
<span className="scan-status-chip live">
|
|
|
|
|
|
{isEn ? "Refreshing" : "刷新中"}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
) : null}
|
2026-04-24 00:13:11 +08:00
|
|
|
|
</div>
|
2026-04-23 23:49:52 +08:00
|
|
|
|
</div>
|
2026-04-24 00:13:11 +08:00
|
|
|
|
|
2026-04-24 10:19:16 +08:00
|
|
|
|
{scanStatus === "failed" && !terminalData ? (
|
2026-04-24 00:13:11 +08:00
|
|
|
|
<div className="scan-empty-state">
|
|
|
|
|
|
<div className="scan-empty-title">
|
|
|
|
|
|
{isEn ? "Scan failed" : "扫描失败"}
|
|
|
|
|
|
</div>
|
2026-04-24 10:19:16 +08:00
|
|
|
|
<div className="scan-empty-copy">{staleReason}</div>
|
2026-04-24 00:13:11 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
2026-04-24 03:30:55 +08:00
|
|
|
|
renderMainView()
|
2026-04-24 00:13:11 +08:00
|
|
|
|
)}
|
|
|
|
|
|
</section>
|
2026-04-23 23:49:52 +08:00
|
|
|
|
</main>
|
|
|
|
|
|
|
2026-04-24 05:39:14 +08:00
|
|
|
|
<DetailPanel
|
|
|
|
|
|
row={activeDetailRow}
|
|
|
|
|
|
marketScan={selectedDetail}
|
|
|
|
|
|
loading={detailLoadingId === activeDetailRow?.id}
|
|
|
|
|
|
/>
|
2026-04-24 12:46:44 +08:00
|
|
|
|
<AssistantWidget
|
|
|
|
|
|
terminalData={terminalData}
|
|
|
|
|
|
rows={timeSortedRows}
|
|
|
|
|
|
selectedRow={activeDetailRow}
|
|
|
|
|
|
locale={locale}
|
|
|
|
|
|
totalCities={store.cities.length}
|
|
|
|
|
|
/>
|
2026-04-23 23:49:52 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function ScanTerminalDashboard() {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<I18nProvider>
|
|
|
|
|
|
<DashboardStoreProvider>
|
|
|
|
|
|
<ScanTerminalScreen />
|
2026-04-24 06:31:29 +08:00
|
|
|
|
<FutureForecastModal />
|
2026-04-23 23:49:52 +08:00
|
|
|
|
</DashboardStoreProvider>
|
|
|
|
|
|
</I18nProvider>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|