feat: implement PolyWeather dashboard components and state management store
This commit is contained in:
@@ -45,3 +45,4 @@ frontend/*.tsbuildinfo
|
||||
# Browser extension build artifacts
|
||||
/extension.zip
|
||||
/extension-*.zip
|
||||
|
||||
|
||||
@@ -2023,10 +2023,10 @@
|
||||
z-index: 880;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
gap: 14px;
|
||||
padding: 14px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.14);
|
||||
border-radius: 22px;
|
||||
border-radius: 24px;
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at 8% 50%,
|
||||
@@ -2113,24 +2113,84 @@
|
||||
|
||||
.root :global(.opportunity-strip-heading) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 2px 4px;
|
||||
gap: 16px;
|
||||
padding: 4px 4px 0;
|
||||
}
|
||||
|
||||
.root :global(.opportunity-strip-heading > div) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.root :global(.opportunity-strip-heading > div:first-child) {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.root :global(.opportunity-strip-heading span) {
|
||||
color: rgba(148, 163, 184, 0.82);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.root :global(.opportunity-strip-heading strong) {
|
||||
color: #f8fafc;
|
||||
font-size: 15px;
|
||||
font-size: 17px;
|
||||
font-weight: 820;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.root :global(.opportunity-strip-status) {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.root :global(.opportunity-status-chip) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 28px;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||
background: rgba(8, 15, 28, 0.78);
|
||||
color: rgba(226, 232, 240, 0.88);
|
||||
font-size: 11px;
|
||||
font-weight: 780;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.root :global(.opportunity-status-chip.live) {
|
||||
border-color: rgba(74, 222, 128, 0.24);
|
||||
color: #86efac;
|
||||
}
|
||||
|
||||
.root :global(.opportunity-status-chip.pending) {
|
||||
border-color: rgba(251, 191, 36, 0.24);
|
||||
color: #fcd34d;
|
||||
}
|
||||
|
||||
.root :global(.opportunity-status-chip.tradable) {
|
||||
border-color: rgba(34, 211, 238, 0.26);
|
||||
color: #67e8f9;
|
||||
}
|
||||
|
||||
.root :global(.opportunity-status-chip.side) {
|
||||
color: rgba(191, 219, 254, 0.9);
|
||||
}
|
||||
|
||||
.root :global(.opportunity-status-chip.muted) {
|
||||
color: rgba(148, 163, 184, 0.88);
|
||||
}
|
||||
|
||||
.root :global(.opportunity-card-grid) {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
@@ -2146,6 +2206,7 @@
|
||||
min-height: 132px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 18px;
|
||||
border: 1px dashed rgba(34, 211, 238, 0.2);
|
||||
border-radius: 16px;
|
||||
background:
|
||||
@@ -2155,10 +2216,25 @@
|
||||
transparent 44%
|
||||
),
|
||||
rgba(7, 12, 22, 0.72);
|
||||
color: rgba(148, 163, 184, 0.9);
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.root :global(.opportunity-empty-copy) {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
justify-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.root :global(.opportunity-empty-copy strong) {
|
||||
color: rgba(241, 245, 249, 0.94);
|
||||
font-size: 15px;
|
||||
font-weight: 840;
|
||||
}
|
||||
|
||||
.root :global(.opportunity-empty-copy span) {
|
||||
color: rgba(148, 163, 184, 0.88);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.root :global(.opportunity-card) {
|
||||
@@ -7196,6 +7272,10 @@
|
||||
width: min(380px, calc(100vw - var(--sidebar-width) - 72px));
|
||||
}
|
||||
|
||||
.root :global(.home-ai-assistant.dragging) {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.root :global(.home-ai-launcher),
|
||||
.root :global(.home-ai-panel) {
|
||||
width: 100%;
|
||||
@@ -7216,11 +7296,13 @@
|
||||
|
||||
.root :global(.home-ai-launcher) {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
cursor: pointer;
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
transition:
|
||||
transform 160ms ease,
|
||||
border-color 160ms ease,
|
||||
@@ -7232,6 +7314,10 @@
|
||||
border-color: rgba(34, 211, 238, 0.32);
|
||||
}
|
||||
|
||||
.root :global(.home-ai-launcher-copy) {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.root :global(.home-ai-launcher-badge) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -7276,6 +7362,41 @@
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.root :global(.home-ai-header-actions) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.root :global(.home-ai-drag-handle) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.16);
|
||||
background: rgba(15, 23, 42, 0.42);
|
||||
color: rgba(191, 219, 254, 0.84);
|
||||
cursor: grab;
|
||||
flex-shrink: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 900;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.2em;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.root :global(.home-ai-assistant.dragging .home-ai-drag-handle) {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.root :global(.home-ai-assistant.dragging .home-ai-launcher) {
|
||||
cursor: grabbing;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.root :global(.home-ai-close) {
|
||||
border: 1px solid rgba(148, 163, 184, 0.14);
|
||||
border-radius: 999px;
|
||||
|
||||
@@ -2,7 +2,15 @@
|
||||
import clsx from "clsx";
|
||||
import dynamic from "next/dynamic";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useMemo, useRef, useState, type FormEvent } from "react";
|
||||
import {
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type FormEvent,
|
||||
type KeyboardEvent as ReactKeyboardEvent,
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
} from "react";
|
||||
import styles from "./Dashboard.module.css";
|
||||
import detailChromeStyles from "./DetailPanelChrome.module.css";
|
||||
import modalChromeStyles from "./ModalChrome.module.css";
|
||||
@@ -272,6 +280,37 @@ function formatEdge(value: number | null | undefined) {
|
||||
return `${sign}${normalized.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
function normalizeEdgePercent(value: number | null | undefined) {
|
||||
if (!Number.isFinite(Number(value))) return null;
|
||||
const numeric = Number(value);
|
||||
return Math.abs(numeric) <= 1 ? numeric * 100 : numeric;
|
||||
}
|
||||
|
||||
function getBestSide(detail?: CityDetail | null) {
|
||||
const bestSide = String(
|
||||
detail?.market_scan?.price_analysis?.best_side || "",
|
||||
).toLowerCase();
|
||||
if (bestSide.includes("yes")) return "yes";
|
||||
if (bestSide.includes("no")) return "no";
|
||||
return null;
|
||||
}
|
||||
|
||||
function isLiveMarketScan(detail?: CityDetail | null) {
|
||||
const marketScan = detail?.market_scan;
|
||||
if (!marketScan) return false;
|
||||
if (marketScan.available === false) return false;
|
||||
if (marketScan.primary_market?.closed === true) return false;
|
||||
if (marketScan.primary_market?.active === false) return false;
|
||||
if (marketScan.selected_date && detail?.local_date) {
|
||||
if (marketScan.selected_date < detail.local_date) return false;
|
||||
}
|
||||
const endDateMs = marketScan.primary_market?.end_date
|
||||
? Date.parse(marketScan.primary_market.end_date)
|
||||
: Number.NaN;
|
||||
if (Number.isFinite(endDateMs) && endDateMs < Date.now()) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function buildSparklinePoints(values: number[] | undefined) {
|
||||
if (!values?.length) return "";
|
||||
const width = 92;
|
||||
@@ -296,6 +335,105 @@ type AssistantMessage = {
|
||||
cached?: boolean;
|
||||
};
|
||||
|
||||
type OpportunityScanState = "pending" | "complete" | "empty" | "error";
|
||||
|
||||
type AssistantDockPosition = {
|
||||
right: number;
|
||||
bottom: number;
|
||||
};
|
||||
|
||||
type AssistantDockDragState = {
|
||||
pointerId: number;
|
||||
startX: number;
|
||||
startY: number;
|
||||
right: number;
|
||||
bottom: number;
|
||||
hasMoved: boolean;
|
||||
};
|
||||
|
||||
const HOME_AI_DOCK_POSITION_STORAGE_KEY =
|
||||
"polyweather_home_ai_dock_position_v1";
|
||||
|
||||
function readAssistantDockPosition() {
|
||||
if (typeof window === "undefined") return null;
|
||||
try {
|
||||
const raw = window.localStorage.getItem(HOME_AI_DOCK_POSITION_STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as AssistantDockPosition | null;
|
||||
if (
|
||||
parsed &&
|
||||
Number.isFinite(Number(parsed.right)) &&
|
||||
Number.isFinite(Number(parsed.bottom))
|
||||
) {
|
||||
return {
|
||||
right: Number(parsed.right),
|
||||
bottom: Number(parsed.bottom),
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed dock position cache.
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function writeAssistantDockPosition(position: AssistantDockPosition | null) {
|
||||
if (typeof window === "undefined") return;
|
||||
if (!position) {
|
||||
window.localStorage.removeItem(HOME_AI_DOCK_POSITION_STORAGE_KEY);
|
||||
return;
|
||||
}
|
||||
window.localStorage.setItem(
|
||||
HOME_AI_DOCK_POSITION_STORAGE_KEY,
|
||||
JSON.stringify(position),
|
||||
);
|
||||
}
|
||||
|
||||
function getDefaultAssistantDockPosition() {
|
||||
if (typeof window === "undefined" || window.innerWidth <= 960) {
|
||||
return null;
|
||||
}
|
||||
const detailPanel = document.querySelector(
|
||||
".home-intelligence-panel.full",
|
||||
) as HTMLElement | null;
|
||||
const opportunityStrip = document.querySelector(
|
||||
".home-opportunity-strip",
|
||||
) as HTMLElement | null;
|
||||
const panelRect = detailPanel?.getBoundingClientRect();
|
||||
const stripRect = opportunityStrip?.getBoundingClientRect();
|
||||
return {
|
||||
right: panelRect
|
||||
? Math.max(24, window.innerWidth - panelRect.left + 18)
|
||||
: 408,
|
||||
bottom: stripRect
|
||||
? Math.max(24, window.innerHeight - stripRect.top + 18)
|
||||
: 340,
|
||||
};
|
||||
}
|
||||
|
||||
function clampAssistantDockPosition(
|
||||
position: AssistantDockPosition | null,
|
||||
dockElement?: HTMLElement | null,
|
||||
) {
|
||||
if (typeof window === "undefined" || window.innerWidth <= 960) {
|
||||
return null;
|
||||
}
|
||||
const rect = dockElement?.getBoundingClientRect();
|
||||
const width = rect?.width || 380;
|
||||
const height = rect?.height || 88;
|
||||
const minOffset = 24;
|
||||
const maxRight = Math.max(minOffset, window.innerWidth - width - minOffset);
|
||||
const maxBottom = Math.max(
|
||||
minOffset,
|
||||
window.innerHeight - height - minOffset,
|
||||
);
|
||||
const base = position || getDefaultAssistantDockPosition();
|
||||
if (!base) return null;
|
||||
return {
|
||||
right: Math.min(maxRight, Math.max(minOffset, Number(base.right))),
|
||||
bottom: Math.min(maxBottom, Math.max(minOffset, Number(base.bottom))),
|
||||
};
|
||||
}
|
||||
|
||||
function hashSnapshotText(source: string) {
|
||||
let hash = 5381;
|
||||
for (let index = 0; index < source.length; index += 1) {
|
||||
@@ -1395,23 +1533,199 @@ function HomeIntelligencePanel({ snapshots }: { snapshots: CitySnapshot[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
function OpportunityStrip({ snapshots }: { snapshots: CitySnapshot[] }) {
|
||||
function OpportunityStrip({
|
||||
snapshots,
|
||||
marketScanStatusByCity,
|
||||
scanTargetNames,
|
||||
}: {
|
||||
snapshots: CitySnapshot[];
|
||||
marketScanStatusByCity: Record<string, OpportunityScanState>;
|
||||
scanTargetNames: string[];
|
||||
}) {
|
||||
const { locale } = useI18n();
|
||||
const store = useDashboardStore();
|
||||
const summaryCards = useMemo(
|
||||
() => buildDashboardSummaryCards(snapshots, locale),
|
||||
[locale, snapshots],
|
||||
);
|
||||
const stripState = useMemo(() => {
|
||||
const targetSet = new Set(scanTargetNames);
|
||||
const targetedSnapshots = (scanTargetNames.length
|
||||
? snapshots.filter((snapshot) => targetSet.has(snapshot.city.name))
|
||||
: snapshots
|
||||
).slice(0, Math.max(scanTargetNames.length, 1));
|
||||
const effectiveStatusByCity = Object.fromEntries(
|
||||
targetedSnapshots.map((snapshot) => [
|
||||
snapshot.city.name,
|
||||
marketScanStatusByCity[snapshot.city.name] ||
|
||||
(snapshot.detail?.market_scan ? "complete" : undefined),
|
||||
]),
|
||||
) as Record<string, OpportunityScanState | undefined>;
|
||||
const completedCount = scanTargetNames.filter((cityName) => {
|
||||
const status = effectiveStatusByCity[cityName];
|
||||
return status === "complete" || status === "empty";
|
||||
}).length;
|
||||
const pendingCount = scanTargetNames.filter(
|
||||
(cityName) => effectiveStatusByCity[cityName] === "pending",
|
||||
).length;
|
||||
const errorCount = scanTargetNames.filter(
|
||||
(cityName) => effectiveStatusByCity[cityName] === "error",
|
||||
).length;
|
||||
const liveSnapshots = targetedSnapshots.filter((snapshot) =>
|
||||
isLiveMarketScan(snapshot.detail),
|
||||
);
|
||||
const tradableSnapshots = targetedSnapshots
|
||||
.filter((snapshot) => snapshot.tradableOpportunity)
|
||||
.sort((left, right) => {
|
||||
const leftEdge = normalizeEdgePercent(getMarketEdgeValue(left.detail));
|
||||
const rightEdge = normalizeEdgePercent(getMarketEdgeValue(right.detail));
|
||||
return (
|
||||
Number(rightEdge ?? Number.NEGATIVE_INFINITY) -
|
||||
Number(leftEdge ?? Number.NEGATIVE_INFINITY) || right.score - left.score
|
||||
);
|
||||
});
|
||||
const yesCount = liveSnapshots.filter(
|
||||
(snapshot) => getBestSide(snapshot.detail) === "yes",
|
||||
).length;
|
||||
const noCount = liveSnapshots.filter(
|
||||
(snapshot) => getBestSide(snapshot.detail) === "no",
|
||||
).length;
|
||||
const avgTradableEdge = tradableSnapshots.length
|
||||
? tradableSnapshots.reduce((sum, snapshot) => {
|
||||
const edge = normalizeEdgePercent(getMarketEdgeValue(snapshot.detail));
|
||||
return sum + (edge ?? 0);
|
||||
}, 0) / tradableSnapshots.length
|
||||
: null;
|
||||
const bestTradableSnapshot = tradableSnapshots[0] || null;
|
||||
const bestTradableCityName = bestTradableSnapshot
|
||||
? getLocalizedCityDisplay(
|
||||
bestTradableSnapshot.city,
|
||||
locale,
|
||||
bestTradableSnapshot.summary,
|
||||
bestTradableSnapshot.detail,
|
||||
)
|
||||
: "--";
|
||||
const highCount = snapshots.filter(
|
||||
(snapshot) => snapshot.city.deb_recent_tier === "high",
|
||||
).length;
|
||||
const mediumCount = snapshots.filter(
|
||||
(snapshot) => snapshot.city.deb_recent_tier === "medium",
|
||||
).length;
|
||||
const lowCount = snapshots.filter(
|
||||
(snapshot) => snapshot.city.deb_recent_tier === "low",
|
||||
).length;
|
||||
|
||||
const items = useMemo(
|
||||
() =>
|
||||
snapshots
|
||||
.filter((snapshot) => snapshot.tradableOpportunity)
|
||||
.slice(0, 5),
|
||||
[snapshots],
|
||||
);
|
||||
return {
|
||||
items: tradableSnapshots.slice(0, 5),
|
||||
completedCount,
|
||||
pendingCount,
|
||||
errorCount,
|
||||
liveCount: liveSnapshots.length,
|
||||
tradableCount: tradableSnapshots.length,
|
||||
yesCount,
|
||||
noCount,
|
||||
summaryCards: [
|
||||
{
|
||||
key: "scan-progress",
|
||||
title: locale === "en-US" ? "Scan Progress" : "扫描进度",
|
||||
items: [
|
||||
{
|
||||
label: locale === "en-US" ? "Completed" : "已完成",
|
||||
value: `${completedCount}/${scanTargetNames.length || snapshots.length}`,
|
||||
accent: "cyan" as const,
|
||||
},
|
||||
{
|
||||
label: locale === "en-US" ? "Scanning" : "扫描中",
|
||||
value: String(pendingCount),
|
||||
accent: "amber" as const,
|
||||
},
|
||||
{
|
||||
label: locale === "en-US" ? "Errors" : "异常",
|
||||
value: String(errorCount),
|
||||
accent: errorCount > 0 ? ("red" as const) : ("green" as const),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "market-live",
|
||||
title: locale === "en-US" ? "Market Status" : "盘口状态",
|
||||
items: [
|
||||
{
|
||||
label: locale === "en-US" ? "Live" : "在线",
|
||||
value: String(liveSnapshots.length),
|
||||
accent: "green" as const,
|
||||
},
|
||||
{
|
||||
label: locale === "en-US" ? "Tradable" : "可交易",
|
||||
value: String(tradableSnapshots.length),
|
||||
accent: "cyan" as const,
|
||||
},
|
||||
{
|
||||
label: locale === "en-US" ? "No edge" : "无机会",
|
||||
value: String(Math.max(completedCount - tradableSnapshots.length, 0)),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "market-quality",
|
||||
title: locale === "en-US" ? "Opportunity Quality" : "机会质量",
|
||||
items: [
|
||||
{
|
||||
label: locale === "en-US" ? "Avg. edge" : "平均优势",
|
||||
value: formatEdge(avgTradableEdge),
|
||||
accent: "green" as const,
|
||||
},
|
||||
{
|
||||
label: locale === "en-US" ? "Best edge" : "最高优势",
|
||||
value: bestTradableSnapshot
|
||||
? formatEdge(getMarketEdgeValue(bestTradableSnapshot.detail))
|
||||
: "--",
|
||||
accent: "cyan" as const,
|
||||
},
|
||||
{
|
||||
label: locale === "en-US" ? "Focus" : "最佳城市",
|
||||
value: bestTradableCityName,
|
||||
accent: "amber" as const,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "risk-summary",
|
||||
title: locale === "en-US" ? "Risk Layer" : "风险层",
|
||||
items: [
|
||||
{
|
||||
label: locale === "en-US" ? "High" : "高",
|
||||
value: String(highCount),
|
||||
accent: "red" as const,
|
||||
},
|
||||
{
|
||||
label: locale === "en-US" ? "Medium" : "中",
|
||||
value: String(mediumCount),
|
||||
accent: "amber" as const,
|
||||
},
|
||||
{
|
||||
label: locale === "en-US" ? "Low" : "低",
|
||||
value: String(lowCount),
|
||||
accent: "green" as const,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
headingTitle:
|
||||
tradableSnapshots.length > 0
|
||||
? locale === "en-US"
|
||||
? `${tradableSnapshots.length} tradable markets · focus ${bestTradableCityName}`
|
||||
: `发现 ${tradableSnapshots.length} 个可交易市场 · 当前最佳 ${bestTradableCityName}`
|
||||
: pendingCount > 0
|
||||
? locale === "en-US"
|
||||
? `Scanning ${completedCount}/${scanTargetNames.length || snapshots.length} cities`
|
||||
: `正在扫描 ${completedCount}/${scanTargetNames.length || snapshots.length} 个城市市场层`
|
||||
: locale === "en-US"
|
||||
? `Completed ${completedCount} scans · no tradable market`
|
||||
: `已完成 ${completedCount} 个城市扫描 · 当前无可交易市场`,
|
||||
yesCountLabel:
|
||||
locale === "en-US" ? `YES bias ${yesCount}` : `YES 倾向 ${yesCount}`,
|
||||
noCountLabel:
|
||||
locale === "en-US" ? `NO bias ${noCount}` : `NO 倾向 ${noCount}`,
|
||||
};
|
||||
}, [locale, marketScanStatusByCity, scanTargetNames, snapshots]);
|
||||
|
||||
// Always render: summary cards should be visible even without tradable opportunities
|
||||
if (!snapshots.length) return null;
|
||||
|
||||
return (
|
||||
@@ -1420,7 +1734,7 @@ function OpportunityStrip({ snapshots }: { snapshots: CitySnapshot[] }) {
|
||||
aria-label={locale === "en-US" ? "Opportunity strip" : "机会条"}
|
||||
>
|
||||
<div className="home-summary-grid">
|
||||
{summaryCards.map((card) => (
|
||||
{stripState.summaryCards.map((card) => (
|
||||
<div key={card.key} className="home-summary-card">
|
||||
<div className="home-summary-card-head">
|
||||
<strong>{card.title}</strong>
|
||||
@@ -1450,19 +1764,48 @@ function OpportunityStrip({ snapshots }: { snapshots: CitySnapshot[] }) {
|
||||
<div>
|
||||
<span>
|
||||
{locale === "en-US"
|
||||
? "Current Best Markets"
|
||||
: "当前最佳机会市场"}
|
||||
? "Live Market Scan"
|
||||
: "实时市场扫描"}
|
||||
</span>
|
||||
<strong>
|
||||
<strong>{stripState.headingTitle}</strong>
|
||||
</div>
|
||||
<div className="opportunity-strip-status" aria-live="polite">
|
||||
<span className={clsx("opportunity-status-chip", "live")}>
|
||||
{locale === "en-US"
|
||||
? "Live market question · YES/NO · Edge · Trend"
|
||||
: "当前市场问题 · YES/NO 价格 · Edge · 小趋势线"}
|
||||
</strong>
|
||||
? `Live ${stripState.liveCount}`
|
||||
: `在线 ${stripState.liveCount}`}
|
||||
</span>
|
||||
<span
|
||||
className={clsx(
|
||||
"opportunity-status-chip",
|
||||
stripState.pendingCount > 0 ? "pending" : "muted",
|
||||
)}
|
||||
>
|
||||
{locale === "en-US"
|
||||
? `Pending ${stripState.pendingCount}`
|
||||
: `待补齐 ${stripState.pendingCount}`}
|
||||
</span>
|
||||
<span
|
||||
className={clsx(
|
||||
"opportunity-status-chip",
|
||||
stripState.tradableCount > 0 ? "tradable" : "muted",
|
||||
)}
|
||||
>
|
||||
{locale === "en-US"
|
||||
? `Tradable ${stripState.tradableCount}`
|
||||
: `可交易 ${stripState.tradableCount}`}
|
||||
</span>
|
||||
<span className={clsx("opportunity-status-chip", "side")}>
|
||||
{stripState.yesCountLabel}
|
||||
</span>
|
||||
<span className={clsx("opportunity-status-chip", "side")}>
|
||||
{stripState.noCountLabel}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{items.length > 0 ? (
|
||||
{stripState.items.length > 0 ? (
|
||||
<div className="opportunity-card-grid top-opportunities">
|
||||
{items.map(({ city, detail, summary }, index) => {
|
||||
{stripState.items.map(({ city, detail, summary }, index) => {
|
||||
const symbol = getTempSymbol(city, summary, detail);
|
||||
const debPrediction =
|
||||
summary?.deb?.prediction ?? detail?.deb?.prediction;
|
||||
@@ -1546,9 +1889,22 @@ function OpportunityStrip({ snapshots }: { snapshots: CitySnapshot[] }) {
|
||||
</div>
|
||||
) : (
|
||||
<div className="opportunity-empty-state">
|
||||
{locale === "en-US"
|
||||
? "No active opportunity market right now."
|
||||
: "当前暂无可交易机会市场"}
|
||||
<div className="opportunity-empty-copy">
|
||||
<strong>
|
||||
{stripState.pendingCount > 0
|
||||
? locale === "en-US"
|
||||
? "Scanning current market layers..."
|
||||
: "正在扫描当前市场层..."
|
||||
: locale === "en-US"
|
||||
? "No tradable market at the moment"
|
||||
: "当前没有满足条件的可交易市场"}
|
||||
</strong>
|
||||
<span>
|
||||
{locale === "en-US"
|
||||
? `Completed ${stripState.completedCount} cities, live ${stripState.liveCount}, tradable ${stripState.tradableCount}.`
|
||||
: `已完成 ${stripState.completedCount} 个城市扫描,在线盘口 ${stripState.liveCount} 个,可交易机会 ${stripState.tradableCount} 个。`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
@@ -1564,6 +1920,13 @@ function HomeAssistantDock({ snapshots }: { snapshots: CitySnapshot[] }) {
|
||||
const [input, setInput] = useState("");
|
||||
const [messages, setMessages] = useState<AssistantMessage[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [dockPosition, setDockPosition] =
|
||||
useState<AssistantDockPosition | null>(null);
|
||||
const dockRef = useRef<HTMLDivElement | null>(null);
|
||||
const dockPositionRef = useRef<AssistantDockPosition | null>(null);
|
||||
const dragStateRef = useRef<AssistantDockDragState | null>(null);
|
||||
const suppressLauncherClickRef = useRef(false);
|
||||
const messagesEndRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const selectedSnapshot = useMemo(
|
||||
@@ -1621,7 +1984,38 @@ function HomeAssistantDock({ snapshots }: { snapshots: CitySnapshot[] }) {
|
||||
});
|
||||
}, [loading, messages]);
|
||||
|
||||
useEffect(() => {
|
||||
dockPositionRef.current = dockPosition;
|
||||
}, [dockPosition]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
const syncDockPosition = () => {
|
||||
if (window.innerWidth <= 960) {
|
||||
setDockPosition(null);
|
||||
return;
|
||||
}
|
||||
setDockPosition((current) =>
|
||||
clampAssistantDockPosition(
|
||||
current || readAssistantDockPosition() || getDefaultAssistantDockPosition(),
|
||||
dockRef.current,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
syncDockPosition();
|
||||
window.addEventListener("resize", syncDockPosition);
|
||||
return () => {
|
||||
window.removeEventListener("resize", syncDockPosition);
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
const openAssistant = () => {
|
||||
if (suppressLauncherClickRef.current) {
|
||||
suppressLauncherClickRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (!store.proAccess.subscriptionActive) {
|
||||
setShowPaywall(true);
|
||||
return;
|
||||
@@ -1629,6 +2023,90 @@ function HomeAssistantDock({ snapshots }: { snapshots: CitySnapshot[] }) {
|
||||
setIsOpen(true);
|
||||
};
|
||||
|
||||
const beginDockDrag = (event: ReactPointerEvent<HTMLElement>) => {
|
||||
if (typeof window === "undefined" || window.innerWidth <= 960) return;
|
||||
const basePosition = clampAssistantDockPosition(
|
||||
dockPositionRef.current ||
|
||||
readAssistantDockPosition() ||
|
||||
getDefaultAssistantDockPosition(),
|
||||
dockRef.current,
|
||||
);
|
||||
if (!basePosition) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
dragStateRef.current = {
|
||||
pointerId: event.pointerId,
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
right: basePosition.right,
|
||||
bottom: basePosition.bottom,
|
||||
hasMoved: false,
|
||||
};
|
||||
event.currentTarget.setPointerCapture?.(event.pointerId);
|
||||
dockPositionRef.current = basePosition;
|
||||
setDockPosition(basePosition);
|
||||
setIsDragging(true);
|
||||
};
|
||||
|
||||
const updateDockDrag = (event: ReactPointerEvent<HTMLElement>) => {
|
||||
const dragState = dragStateRef.current;
|
||||
if (!dragState || dragState.pointerId !== event.pointerId) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const deltaX = event.clientX - dragState.startX;
|
||||
const deltaY = event.clientY - dragState.startY;
|
||||
if (!dragState.hasMoved && Math.abs(deltaX) + Math.abs(deltaY) >= 6) {
|
||||
dragState.hasMoved = true;
|
||||
}
|
||||
const nextPosition = clampAssistantDockPosition(
|
||||
{
|
||||
right: dragState.right - deltaX,
|
||||
bottom: dragState.bottom - deltaY,
|
||||
},
|
||||
dockRef.current,
|
||||
);
|
||||
if (!nextPosition) return;
|
||||
dockPositionRef.current = nextPosition;
|
||||
setDockPosition(nextPosition);
|
||||
};
|
||||
|
||||
const endDockDrag = (event: ReactPointerEvent<HTMLElement>) => {
|
||||
const dragState = dragStateRef.current;
|
||||
if (!dragState || dragState.pointerId !== event.pointerId) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
try {
|
||||
if (event.currentTarget.hasPointerCapture?.(event.pointerId)) {
|
||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
}
|
||||
} catch {
|
||||
// Pointer capture may already be released by the browser.
|
||||
}
|
||||
const finalPosition = clampAssistantDockPosition(
|
||||
dockPositionRef.current || {
|
||||
right: dragState.right,
|
||||
bottom: dragState.bottom,
|
||||
},
|
||||
dockRef.current,
|
||||
);
|
||||
if (dragState.hasMoved) {
|
||||
suppressLauncherClickRef.current = true;
|
||||
}
|
||||
dockPositionRef.current = finalPosition;
|
||||
setDockPosition(finalPosition);
|
||||
writeAssistantDockPosition(finalPosition);
|
||||
dragStateRef.current = null;
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
const handleLauncherKeyDown = (
|
||||
event: ReactKeyboardEvent<HTMLDivElement>,
|
||||
) => {
|
||||
if (event.key !== "Enter" && event.key !== " ") return;
|
||||
event.preventDefault();
|
||||
openAssistant();
|
||||
};
|
||||
|
||||
const sendQuestion = async (rawQuestion?: string) => {
|
||||
const question = String(rawQuestion ?? input).trim();
|
||||
if (!question || loading) return;
|
||||
@@ -1689,15 +2167,32 @@ function HomeAssistantDock({ snapshots }: { snapshots: CitySnapshot[] }) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="home-ai-assistant">
|
||||
<div
|
||||
ref={dockRef}
|
||||
className={clsx("home-ai-assistant", isDragging && "dragging")}
|
||||
style={
|
||||
dockPosition
|
||||
? {
|
||||
right: `${dockPosition.right}px`,
|
||||
bottom: `${dockPosition.bottom}px`,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{!isOpen ? (
|
||||
<button
|
||||
type="button"
|
||||
<div
|
||||
className="home-ai-launcher"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={openAssistant}
|
||||
onKeyDown={handleLauncherKeyDown}
|
||||
onPointerDown={beginDockDrag}
|
||||
onPointerMove={updateDockDrag}
|
||||
onPointerUp={endDockDrag}
|
||||
onPointerCancel={endDockDrag}
|
||||
>
|
||||
<span className="home-ai-launcher-badge">AI</span>
|
||||
<div>
|
||||
<div className="home-ai-launcher-copy">
|
||||
<strong>
|
||||
{locale === "en-US" ? "Market Assistant" : "AI 对话助手"}
|
||||
</strong>
|
||||
@@ -1711,7 +2206,14 @@ function HomeAssistantDock({ snapshots }: { snapshots: CitySnapshot[] }) {
|
||||
: "Pro 专属"}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
<span
|
||||
className="home-ai-drag-handle"
|
||||
title={locale === "en-US" ? "Move assistant" : "拖动助手"}
|
||||
aria-hidden="true"
|
||||
>
|
||||
⋮⋮
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<section
|
||||
className="home-ai-panel"
|
||||
@@ -1726,14 +2228,27 @@ function HomeAssistantDock({ snapshots }: { snapshots: CitySnapshot[] }) {
|
||||
: `快照 ${assistantContext.snapshot_id}`}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="home-ai-close"
|
||||
onClick={() => setIsOpen(false)}
|
||||
aria-label={locale === "en-US" ? "Close assistant" : "关闭 AI 助手"}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<div className="home-ai-header-actions">
|
||||
<span
|
||||
className="home-ai-drag-handle"
|
||||
title={locale === "en-US" ? "Move assistant" : "拖动助手"}
|
||||
aria-hidden="true"
|
||||
onPointerDown={beginDockDrag}
|
||||
onPointerMove={updateDockDrag}
|
||||
onPointerUp={endDockDrag}
|
||||
onPointerCancel={endDockDrag}
|
||||
>
|
||||
⋮⋮
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="home-ai-close"
|
||||
onClick={() => setIsOpen(false)}
|
||||
aria-label={locale === "en-US" ? "Close assistant" : "关闭 AI 助手"}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="home-ai-disclaimer">
|
||||
@@ -1826,7 +2341,10 @@ function DashboardScreen() {
|
||||
const store = useDashboardStore();
|
||||
const { t } = useI18n();
|
||||
const didAutoFocusRef = useRef(false);
|
||||
const preloadedOpportunityRef = useRef<Set<string>>(new Set());
|
||||
const marketScanInflightRef = useRef<Set<string>>(new Set());
|
||||
const [marketScanStatusByCity, setMarketScanStatusByCity] = useState<
|
||||
Record<string, OpportunityScanState>
|
||||
>({});
|
||||
const activeSummary = store.selectedCity
|
||||
? store.citySummariesByName[store.selectedCity] || null
|
||||
: null;
|
||||
@@ -1884,6 +2402,10 @@ function DashboardScreen() {
|
||||
),
|
||||
[store.cities, store.cityDetailsByName, store.citySummariesByName],
|
||||
);
|
||||
const marketScanTargetNames = useMemo(
|
||||
() => homepageSnapshots.map((snapshot) => snapshot.city.name),
|
||||
[homepageSnapshots],
|
||||
);
|
||||
const showHomepageChrome =
|
||||
!store.historyState.isOpen && !store.futureModalDate;
|
||||
|
||||
@@ -1898,19 +2420,82 @@ function DashboardScreen() {
|
||||
void store.focusCity(topOpportunity);
|
||||
}, [homepageSnapshots, showHomepageChrome, store]);
|
||||
|
||||
useEffect(() => {
|
||||
if (store.loadingState.refresh) {
|
||||
marketScanInflightRef.current.clear();
|
||||
setMarketScanStatusByCity({});
|
||||
}
|
||||
}, [store.loadingState.refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
if (store.proAccess.loading) return;
|
||||
if (store.proAccess.authenticated) return;
|
||||
marketScanInflightRef.current.clear();
|
||||
setMarketScanStatusByCity({});
|
||||
}, [store.proAccess.authenticated, store.proAccess.loading]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showHomepageChrome) return;
|
||||
const targets = homepageSnapshots
|
||||
.slice(0, 4)
|
||||
.map((snapshot) => snapshot.city.name);
|
||||
targets.forEach((cityName) => {
|
||||
if (preloadedOpportunityRef.current.has(cityName)) return;
|
||||
preloadedOpportunityRef.current.add(cityName);
|
||||
void store.ensureCityDetail(cityName, false, "panel").catch(() => {
|
||||
preloadedOpportunityRef.current.delete(cityName);
|
||||
});
|
||||
if (store.proAccess.loading || !store.proAccess.authenticated) return;
|
||||
if (!marketScanTargetNames.length) return;
|
||||
|
||||
let cancelled = false;
|
||||
const queue = marketScanTargetNames.filter((cityName) => {
|
||||
const status = marketScanStatusByCity[cityName];
|
||||
const existingMarketScan = store.cityDetailsByName[cityName]?.market_scan;
|
||||
return (
|
||||
!status &&
|
||||
!existingMarketScan &&
|
||||
!marketScanInflightRef.current.has(cityName)
|
||||
);
|
||||
});
|
||||
}, [homepageSnapshots, showHomepageChrome, store]);
|
||||
if (!queue.length) return;
|
||||
|
||||
const runWorker = async () => {
|
||||
while (!cancelled) {
|
||||
const cityName = queue.shift();
|
||||
if (!cityName) return;
|
||||
marketScanInflightRef.current.add(cityName);
|
||||
setMarketScanStatusByCity((current) => ({
|
||||
...current,
|
||||
[cityName]: "pending",
|
||||
}));
|
||||
try {
|
||||
const detail = await store.ensureCityDetail(cityName, false, "panel");
|
||||
if (cancelled) return;
|
||||
const marketScan =
|
||||
detail.market_scan ||
|
||||
(await store.ensureCityMarketScan(cityName, false));
|
||||
if (cancelled) return;
|
||||
setMarketScanStatusByCity((current) => ({
|
||||
...current,
|
||||
[cityName]: marketScan ? "complete" : "empty",
|
||||
}));
|
||||
} catch {
|
||||
if (cancelled) return;
|
||||
setMarketScanStatusByCity((current) => ({
|
||||
...current,
|
||||
[cityName]: "error",
|
||||
}));
|
||||
} finally {
|
||||
marketScanInflightRef.current.delete(cityName);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void Promise.allSettled(
|
||||
Array.from({ length: Math.min(4, queue.length) }, () => runWorker()),
|
||||
);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [
|
||||
marketScanStatusByCity,
|
||||
marketScanTargetNames,
|
||||
showHomepageChrome,
|
||||
store,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -1926,7 +2511,11 @@ function DashboardScreen() {
|
||||
{showHomepageChrome ? (
|
||||
<>
|
||||
<HomeIntelligencePanel snapshots={homepageSnapshots} />
|
||||
<OpportunityStrip snapshots={homepageSnapshots} />
|
||||
<OpportunityStrip
|
||||
snapshots={homepageSnapshots}
|
||||
marketScanStatusByCity={marketScanStatusByCity}
|
||||
scanTargetNames={marketScanTargetNames}
|
||||
/>
|
||||
<HomeAssistantDock snapshots={homepageSnapshots} />
|
||||
</>
|
||||
) : null}
|
||||
|
||||
@@ -42,6 +42,10 @@ interface DashboardStoreValue extends DashboardState {
|
||||
force?: boolean,
|
||||
depth?: "panel" | "market" | "nearby" | "full",
|
||||
) => Promise<CityDetail>;
|
||||
ensureCityMarketScan: (
|
||||
cityName: string,
|
||||
force?: boolean,
|
||||
) => Promise<CityDetail["market_scan"] | null>;
|
||||
focusCity: (cityName: string) => Promise<void>;
|
||||
forecastModalMode: ForecastModalMode | null;
|
||||
futureModalDate: string | null;
|
||||
@@ -1113,6 +1117,7 @@ export function DashboardStoreProvider({
|
||||
setIsPanelOpen(false);
|
||||
},
|
||||
ensureCityDetail,
|
||||
ensureCityMarketScan,
|
||||
focusCity,
|
||||
forecastModalMode,
|
||||
futureModalDate,
|
||||
|
||||
-487
@@ -1,487 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>PolyWeather - 天气衍生品智能分析平台</title>
|
||||
<meta name="description" content="PolyWeather 天气衍生品智能分析平台,提供全球天气数据、市场分析与交易信号">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&family=Noto+Sans+SC:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
*,*::before,*::after{margin:0;padding:0;box-sizing:border-box}
|
||||
:root{
|
||||
--bg-base:#0a0e1a;--bg-card:#111827;--bg-card-hover:#1a2235;--bg-surface:#0f1525;
|
||||
--border:#1e2a3a;--border-hover:#2a3a50;
|
||||
--text-primary:#e8ecf4;--text-secondary:#8899aa;--text-muted:#556677;
|
||||
--accent:#00bcd4;--accent-glow:rgba(0,188,212,.15);--accent-dim:#0097a7;
|
||||
--red:#ef4444;--red-bg:rgba(239,68,68,.12);--red-border:rgba(239,68,68,.3);
|
||||
--orange:#f59e0b;--orange-bg:rgba(245,158,11,.12);--orange-border:rgba(245,158,11,.3);
|
||||
--green:#22c55e;--green-bg:rgba(34,197,94,.12);--green-border:rgba(34,197,94,.3);
|
||||
--blue:#3b82f6;
|
||||
--radius:10px;--radius-lg:14px;--radius-sm:6px;
|
||||
--font-sans:'Inter','Noto Sans SC',system-ui,sans-serif;
|
||||
}
|
||||
html{font-size:13px}
|
||||
body{font-family:var(--font-sans);background:var(--bg-base);color:var(--text-primary);overflow:hidden;height:100vh;display:flex;flex-direction:column}
|
||||
::-webkit-scrollbar{width:4px}::-webkit-scrollbar-track{background:transparent}::-webkit-scrollbar-thumb{background:#2a3a50;border-radius:4px}
|
||||
|
||||
/* === HEADER === */
|
||||
.header{display:flex;align-items:center;height:46px;padding:0 16px;background:linear-gradient(180deg,#111827 0%,#0f1420 100%);border-bottom:1px solid var(--border);flex-shrink:0;gap:8px;z-index:100}
|
||||
.logo{display:flex;align-items:center;gap:8px;margin-right:12px}
|
||||
.logo-icon{width:28px;height:28px;background:linear-gradient(135deg,#00bcd4,#0288d1);border-radius:8px;display:flex;align-items:center;justify-content:center;font-size:14px;font-weight:800;color:#fff}
|
||||
.logo-text{font-size:16px;font-weight:700;color:#fff;letter-spacing:-.5px}
|
||||
.logo-sub{font-size:11px;color:var(--text-muted);margin-left:4px;white-space:nowrap}
|
||||
.nav{display:flex;gap:2px;margin:0 auto}
|
||||
.nav-item{padding:6px 14px;border-radius:var(--radius-sm);font-size:12px;color:var(--text-secondary);cursor:pointer;display:flex;align-items:center;gap:5px;transition:.2s}
|
||||
.nav-item:hover{color:var(--text-primary);background:rgba(255,255,255,.05)}
|
||||
.nav-item.active{color:var(--accent);background:var(--accent-glow)}
|
||||
.header-right{display:flex;align-items:center;gap:6px;margin-left:auto}
|
||||
.live-badge{display:flex;align-items:center;gap:5px;padding:4px 10px;border-radius:20px;border:1px solid var(--green-border);background:var(--green-bg);font-size:11px;color:var(--green);font-weight:600}
|
||||
.live-dot{width:6px;height:6px;border-radius:50%;background:var(--green);animation:pulse 2s infinite}
|
||||
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.4}}
|
||||
.hdr-btn{width:32px;height:32px;border-radius:var(--radius-sm);border:1px solid var(--border);background:transparent;color:var(--text-secondary);cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:13px;transition:.2s}
|
||||
.hdr-btn:hover{border-color:var(--border-hover);color:var(--text-primary);background:rgba(255,255,255,.04)}
|
||||
|
||||
/* === MAIN LAYOUT === */
|
||||
.main{display:grid;grid-template-columns:220px 1fr 280px;flex:1;overflow:hidden;min-height:0}
|
||||
|
||||
/* === SIDEBAR === */
|
||||
.sidebar{background:var(--bg-surface);border-right:1px solid var(--border);display:flex;flex-direction:column;overflow:hidden}
|
||||
.sidebar-header{padding:10px 12px 6px}
|
||||
.search-box{display:flex;align-items:center;gap:6px;padding:6px 10px;border-radius:var(--radius-sm);border:1px solid var(--border);background:rgba(255,255,255,.03);color:var(--text-secondary);font-size:12px}
|
||||
.search-box input{background:none;border:none;color:var(--text-primary);font-size:12px;outline:none;width:100%;font-family:inherit}
|
||||
.sidebar-title{display:flex;align-items:center;justify-content:space-between;padding:8px 12px 4px;font-size:11px;font-weight:600;color:var(--text-secondary)}
|
||||
.sidebar-title .count{background:rgba(255,255,255,.08);padding:1px 7px;border-radius:10px;font-size:10px}
|
||||
.city-list{flex:1;overflow-y:auto;padding:0 6px 8px}
|
||||
.risk-group{margin-bottom:4px}
|
||||
.risk-label{display:flex;align-items:center;gap:6px;padding:5px 8px;font-size:11px;font-weight:600;cursor:pointer;color:var(--text-secondary)}
|
||||
.risk-label .arrow{font-size:8px;transition:.2s}
|
||||
.risk-label .dot{width:6px;height:6px;border-radius:50%}
|
||||
.risk-label .r-count{margin-left:auto;font-size:10px;color:var(--text-muted)}
|
||||
.city-item{display:flex;align-items:center;padding:5px 8px 5px 22px;border-radius:var(--radius-sm);cursor:pointer;transition:.15s;gap:6px;font-size:12px}
|
||||
.city-item:hover{background:rgba(255,255,255,.04)}
|
||||
.city-item.active{background:var(--accent-glow);border:1px solid rgba(0,188,212,.25)}
|
||||
.city-item .name{flex:1;white-space:nowrap}
|
||||
.city-item .temp{font-weight:600;font-size:12px;min-width:36px;text-align:right}
|
||||
.city-item .trend{font-size:10px;margin-left:2px}
|
||||
.city-item .time{font-size:10px;color:var(--text-muted);min-width:34px;text-align:right}
|
||||
.sidebar-footer{padding:8px 12px;border-top:1px solid var(--border);font-size:10px;color:var(--text-muted);line-height:1.5}
|
||||
|
||||
/* === CENTER === */
|
||||
.center{display:flex;flex-direction:column;overflow:hidden;min-width:0}
|
||||
.map-panel{flex:1;position:relative;background:var(--bg-base);overflow:hidden;min-height:0}
|
||||
.map-header{position:absolute;top:12px;left:16px;right:16px;display:flex;align-items:center;justify-content:space-between;z-index:10}
|
||||
.map-title{font-size:14px;font-weight:600;display:flex;align-items:center;gap:8px}
|
||||
.map-title .sub{font-size:11px;color:var(--text-muted);font-weight:400}
|
||||
.map-tabs{display:flex;gap:2px;background:rgba(17,24,39,.8);border:1px solid var(--border);border-radius:var(--radius-sm);padding:2px}
|
||||
.map-tab{padding:4px 14px;border-radius:4px;font-size:11px;color:var(--text-secondary);cursor:pointer;transition:.2s}
|
||||
.map-tab.active{background:var(--accent);color:#fff}
|
||||
.map-tab:hover:not(.active){color:var(--text-primary)}
|
||||
.map-expand{width:28px;height:28px;border-radius:var(--radius-sm);border:1px solid var(--border);background:rgba(17,24,39,.8);color:var(--text-secondary);cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:12px}
|
||||
|
||||
/* Map Canvas */
|
||||
.map-canvas{width:100%;height:100%;position:relative}
|
||||
.map-bg{position:absolute;inset:0;background:radial-gradient(ellipse at 50% 50%,#0d1a2f 0%,#080e1a 70%);overflow:hidden}
|
||||
.map-bg::before{content:'';position:absolute;inset:-20%;background:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='800' height='400'%3E%3Cdefs%3E%3CradialGradient id='a'%3E%3Cstop offset='0' stop-color='%23112240'/%3E%3Cstop offset='1' stop-color='transparent'/%3E%3C/radialGradient%3E%3C/defs%3E%3Crect fill='%230a0e18' width='800' height='400'/%3E%3Ccircle cx='400' cy='200' r='250' fill='url(%23a)' opacity='.3'/%3E%3C/svg%3E") center/cover;opacity:.5}
|
||||
.map-grid{position:absolute;inset:0;background-image:linear-gradient(rgba(0,188,212,.03) 1px,transparent 1px),linear-gradient(90deg,rgba(0,188,212,.03) 1px,transparent 1px);background-size:60px 60px}
|
||||
.continent{position:absolute;border-radius:40%;opacity:.12;filter:blur(15px)}
|
||||
.c-na{width:280px;height:180px;background:#1a5276;top:18%;left:12%}
|
||||
.c-sa{width:140px;height:200px;background:#1a5276;top:48%;left:22%}
|
||||
.c-eu{width:200px;height:120px;background:#1a5276;top:14%;left:42%}
|
||||
.c-af{width:160px;height:200px;background:#1a5276;top:32%;left:42%}
|
||||
.c-as{width:280px;height:180px;background:#1a5276;top:16%;left:55%}
|
||||
.heat-spot{position:absolute;border-radius:50%;filter:blur(25px);animation:heatPulse 4s ease-in-out infinite alternate}
|
||||
@keyframes heatPulse{0%{opacity:.4;transform:scale(1)}100%{opacity:.7;transform:scale(1.1)}}
|
||||
.city-pin{position:absolute;z-index:5;cursor:pointer;transition:.2s}
|
||||
.city-pin:hover{z-index:10;transform:scale(1.15)}
|
||||
.pin-dot{width:8px;height:8px;border-radius:50%;border:2px solid;position:relative}
|
||||
.pin-dot::after{content:'';position:absolute;inset:-4px;border-radius:50%;border:1px solid;opacity:.3;animation:ripple 2.5s infinite}
|
||||
@keyframes ripple{0%{transform:scale(1);opacity:.4}100%{transform:scale(2.2);opacity:0}}
|
||||
.pin-label{position:absolute;top:-22px;left:50%;transform:translateX(-50%);white-space:nowrap;font-size:10px;padding:2px 6px;border-radius:4px;background:rgba(10,14,26,.85);border:1px solid var(--border);backdrop-filter:blur(4px);display:flex;align-items:center;gap:4px}
|
||||
.pin-label .pin-temp{font-weight:600;color:var(--accent)}
|
||||
.pin-highlight{position:absolute;z-index:4}
|
||||
.pin-highlight .ring{width:60px;height:60px;border-radius:50%;border:2px solid var(--red);opacity:.4;position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);animation:ringPulse 2s infinite}
|
||||
.pin-highlight .ring2{width:90px;height:90px;border-radius:50%;border:1px solid var(--red);opacity:.2;position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);animation:ringPulse 2s .5s infinite}
|
||||
@keyframes ringPulse{0%{opacity:.4;transform:translate(-50%,-50%) scale(1)}100%{opacity:0;transform:translate(-50%,-50%) scale(1.5)}}
|
||||
.pin-highlight .glow{width:40px;height:40px;border-radius:50%;background:radial-gradient(var(--red),transparent 70%);opacity:.25;position:absolute;top:50%;left:50%;transform:translate(-50%,-50%)}
|
||||
|
||||
/* Map controls */
|
||||
.map-zoom{position:absolute;right:16px;top:50%;transform:translateY(-50%);display:flex;flex-direction:column;gap:2px;z-index:10}
|
||||
.map-zoom button{width:30px;height:30px;border-radius:var(--radius-sm);border:1px solid var(--border);background:rgba(17,24,39,.85);color:var(--text-secondary);cursor:pointer;font-size:14px;display:flex;align-items:center;justify-content:center;transition:.2s;backdrop-filter:blur(4px)}
|
||||
.map-zoom button:hover{border-color:var(--accent);color:var(--accent)}
|
||||
.map-legend{position:absolute;bottom:12px;left:50%;transform:translateX(-50%);display:flex;align-items:center;gap:8px;padding:6px 14px;border-radius:var(--radius);background:rgba(17,24,39,.85);border:1px solid var(--border);z-index:10;backdrop-filter:blur(4px)}
|
||||
.map-legend .label{font-size:10px;color:var(--text-secondary)}
|
||||
.legend-bar{width:200px;height:8px;border-radius:4px;background:linear-gradient(90deg,#2563eb,#06b6d4,#22c55e,#eab308,#ef4444);margin:0 4px}
|
||||
.legend-vals{display:flex;justify-content:space-between;width:200px;font-size:9px;color:var(--text-muted)}
|
||||
|
||||
/* === Bottom Summary === */
|
||||
.bottom-section{border-top:1px solid var(--border);flex-shrink:0}
|
||||
.summary-row{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;padding:10px 16px;grid-column:1/-1}
|
||||
.summary-card{background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);padding:12px 14px;transition:.2s}
|
||||
.summary-card:hover{border-color:var(--border-hover);background:var(--bg-card-hover)}
|
||||
.sc-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:8px}
|
||||
.sc-title{font-size:12px;font-weight:600}
|
||||
.sc-link{font-size:10px;color:var(--accent);cursor:pointer}
|
||||
.sc-body{display:flex;align-items:flex-end;gap:16px}
|
||||
.sc-stat{text-align:center}
|
||||
.sc-stat .num{font-size:22px;font-weight:700;line-height:1}
|
||||
.sc-stat .lbl{font-size:10px;color:var(--text-muted);margin-top:3px}
|
||||
.sc-divider{width:1px;height:30px;background:var(--border)}
|
||||
.sc-bar-row{display:flex;align-items:center;gap:6px;flex:1}
|
||||
.sc-bar{height:6px;border-radius:3px;flex:1}
|
||||
.sc-bar-label{font-size:11px;font-weight:700;min-width:30px}
|
||||
|
||||
/* === Opportunity Cards === */
|
||||
.opp-section{padding:8px 16px 12px;border-top:1px solid var(--border);grid-column:1/-1}
|
||||
.opp-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:8px}
|
||||
.opp-title{font-size:13px;font-weight:600;display:flex;align-items:center;gap:6px}
|
||||
.opp-title .badge{font-size:10px;color:var(--text-muted);background:rgba(255,255,255,.06);padding:2px 8px;border-radius:10px}
|
||||
.opp-nav{display:flex;gap:4px}
|
||||
.opp-nav button{width:26px;height:26px;border-radius:50%;border:1px solid var(--border);background:transparent;color:var(--text-secondary);cursor:pointer;font-size:11px;transition:.2s}
|
||||
.opp-nav button:hover{border-color:var(--accent);color:var(--accent)}
|
||||
.opp-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:10px}
|
||||
.opp-card{background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);padding:12px;transition:.2s;cursor:pointer}
|
||||
.opp-card:hover{border-color:var(--accent);background:var(--bg-card-hover);transform:translateY(-1px);box-shadow:0 4px 20px rgba(0,188,212,.08)}
|
||||
.opp-card-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:6px}
|
||||
.opp-rank{font-size:12px;font-weight:700;color:var(--accent);margin-right:6px}
|
||||
.opp-city{font-size:12px;font-weight:600}
|
||||
.risk-tag{font-size:9px;padding:2px 6px;border-radius:4px;font-weight:600}
|
||||
.risk-tag.high{color:var(--red);background:var(--red-bg);border:1px solid var(--red-border)}
|
||||
.risk-tag.mid{color:var(--orange);background:var(--orange-bg);border:1px solid var(--orange-border)}
|
||||
.risk-tag.low{color:var(--green);background:var(--green-bg);border:1px solid var(--green-border)}
|
||||
.opp-question{font-size:10px;color:var(--text-muted);margin-bottom:6px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.opp-prices{display:flex;align-items:center;gap:8px;margin-bottom:6px}
|
||||
.opp-yes,.opp-no{font-size:11px;font-weight:600}
|
||||
.opp-yes{color:var(--green)}.opp-no{color:var(--red)}
|
||||
.opp-edge{font-size:11px;color:var(--accent);font-weight:600}
|
||||
.opp-chart{height:28px;margin-top:4px;position:relative}
|
||||
.opp-chart svg{width:100%;height:100%}
|
||||
|
||||
/* === RIGHT PANEL === */
|
||||
.right-panel{background:var(--bg-surface);border-left:1px solid var(--border);overflow-y:auto;display:flex;flex-direction:column}
|
||||
.rp-header{display:flex;align-items:center;justify-content:space-between;padding:10px 14px;border-bottom:1px solid var(--border)}
|
||||
.rp-title{font-size:12px;font-weight:600;display:flex;align-items:center;gap:5px}
|
||||
.rp-link{font-size:10px;color:var(--accent);cursor:pointer}
|
||||
.rp-city{padding:12px 14px}
|
||||
.rp-city-name{font-size:22px;font-weight:700;display:flex;align-items:center;gap:6px}
|
||||
.rp-city-name .star{color:var(--orange);font-size:14px}
|
||||
.rp-meta{font-size:10px;color:var(--text-muted);margin-top:4px;display:flex;align-items:center;gap:6px;flex-wrap:wrap}
|
||||
.rp-meta .code{color:var(--text-secondary);font-weight:500}
|
||||
.rp-time{display:flex;gap:12px;font-size:10px;color:var(--text-muted);margin-top:6px}
|
||||
.rp-time .live-dot-sm{width:5px;height:5px;border-radius:50%;background:var(--green);display:inline-block;margin-right:3px}
|
||||
.rp-temp-block{padding:8px 14px;display:flex;align-items:center;justify-content:space-between}
|
||||
.rp-temp-main{display:flex;align-items:flex-start}
|
||||
.rp-temp-num{font-size:52px;font-weight:800;line-height:1;letter-spacing:-2px}
|
||||
.rp-temp-unit{font-size:18px;font-weight:300;margin-top:4px}
|
||||
.rp-temp-sub{font-size:11px;color:var(--text-muted);margin-top:4px}
|
||||
.rp-temp-right{text-align:right}
|
||||
.rp-weather-icon{font-size:36px;opacity:.7}
|
||||
.rp-forecast{font-size:10px;color:var(--text-muted);margin-top:4px}
|
||||
.rp-forecast .val{color:var(--text-primary);font-weight:600}
|
||||
.rp-card{margin:4px 14px;padding:10px 12px;background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius)}
|
||||
.rp-card-title{font-size:11px;font-weight:600;margin-bottom:6px;display:flex;align-items:center;gap:5px}
|
||||
.rp-card-title .tag{font-size:9px;padding:1px 5px;border-radius:3px;background:var(--accent-glow);color:var(--accent)}
|
||||
.rp-deb{display:flex;align-items:center;justify-content:space-between}
|
||||
.rp-deb-temp{font-size:26px;font-weight:700}
|
||||
.rp-deb-delta{font-size:13px;color:var(--red);font-weight:600;margin-left:6px}
|
||||
.rp-mini-chart{width:140px;height:32px}
|
||||
.rp-market{margin-top:2px}
|
||||
.rp-market-q{font-size:11px;color:var(--text-secondary);margin-bottom:6px}
|
||||
.rp-market-prices{display:flex;gap:8px;margin-bottom:8px}
|
||||
.rp-price-box{flex:1;padding:6px;border-radius:var(--radius-sm);text-align:center;font-weight:700;font-size:13px}
|
||||
.rp-price-yes{background:var(--green-bg);border:1px solid var(--green-border);color:var(--green)}
|
||||
.rp-price-no{background:var(--red-bg);border:1px solid var(--red-border);color:var(--red)}
|
||||
.rp-market-stats{display:grid;grid-template-columns:repeat(3,1fr);gap:6px;text-align:center}
|
||||
.rp-market-stats .stat-label{font-size:9px;color:var(--text-muted)}
|
||||
.rp-market-stats .stat-val{font-size:12px;font-weight:600;margin-top:1px}
|
||||
.rp-signals{margin:4px 14px;padding:10px 12px;background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius)}
|
||||
.rp-signals .title{font-size:11px;font-weight:600;margin-bottom:6px}
|
||||
.signal-item{display:flex;align-items:center;gap:6px;padding:4px 0;font-size:11px}
|
||||
.signal-dot{width:5px;height:5px;border-radius:50%;flex-shrink:0}
|
||||
.rp-cta{margin:8px 14px;padding:10px;text-align:center;background:linear-gradient(135deg,rgba(0,188,212,.15),rgba(0,188,212,.05));border:1px solid rgba(0,188,212,.25);border-radius:var(--radius);color:var(--accent);font-size:12px;font-weight:600;cursor:pointer;transition:.2s}
|
||||
.rp-cta:hover{background:linear-gradient(135deg,rgba(0,188,212,.25),rgba(0,188,212,.1));transform:translateY(-1px)}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- HEADER -->
|
||||
<header class="header">
|
||||
<div class="logo">
|
||||
<div class="logo-icon">P</div>
|
||||
<span class="logo-text">PolyWeather</span>
|
||||
<span class="logo-sub">天气衍生品智能分析</span>
|
||||
</div>
|
||||
<nav class="nav">
|
||||
<div class="nav-item active">🏠 首页</div>
|
||||
<div class="nav-item">🗺️ 地图</div>
|
||||
<div class="nav-item">📊 市场</div>
|
||||
<div class="nav-item">📈 分析</div>
|
||||
<div class="nav-item">📅 历史</div>
|
||||
<div class="nav-item">⚠️ 预警</div>
|
||||
</nav>
|
||||
<div class="header-right">
|
||||
<div class="live-badge"><span class="live-dot"></span>LIVE</div>
|
||||
<button class="hdr-btn" title="刷新">🔄</button>
|
||||
<button class="hdr-btn" title="文档">📄</button>
|
||||
<button class="hdr-btn" title="通知">🔔</button>
|
||||
<button class="hdr-btn" title="账户">👤</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- MAIN -->
|
||||
<div class="main">
|
||||
<!-- SIDEBAR -->
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<div class="search-box">🔍 <input type="text" placeholder="搜索城市..."></div>
|
||||
</div>
|
||||
<div class="sidebar-title">监控城市 <span class="count">52</span></div>
|
||||
<div class="city-list">
|
||||
<!-- High Risk -->
|
||||
<div class="risk-group">
|
||||
<div class="risk-label"><span class="arrow">▼</span><span class="dot" style="background:var(--red)"></span> 高风险城市 <span class="r-count">6</span></div>
|
||||
<div class="city-item active"><span class="name">芝加哥</span><span class="temp" style="color:var(--red)">72°F</span><span class="trend">📈</span><span class="time"></span></div>
|
||||
<div class="city-item"><span class="name">达拉斯</span><span class="temp">63°F</span><span class="time">09:18</span></div>
|
||||
<div class="city-item"><span class="name">休斯顿</span><span class="temp">69°F</span><span class="time">09:25</span></div>
|
||||
<div class="city-item"><span class="name">马尼拉</span><span class="temp">84°F</span><span class="time">08:59</span></div>
|
||||
<div class="city-item"><span class="name">曼谷</span><span class="temp">83°F</span><span class="time">09:47</span></div>
|
||||
<div class="city-item"><span class="name">迈阿密</span><span class="temp">82°F</span><span class="time">09:02</span></div>
|
||||
</div>
|
||||
<!-- Mid Risk -->
|
||||
<div class="risk-group">
|
||||
<div class="risk-label"><span class="arrow">▼</span><span class="dot" style="background:var(--orange)"></span> 中等风险城市 <span class="r-count">18</span></div>
|
||||
<div class="city-item"><span class="name">纽约</span><span class="temp">66°F</span><span class="time">09:10</span></div>
|
||||
<div class="city-item"><span class="name">洛杉矶</span><span class="temp">68°F</span><span class="time">09:10</span></div>
|
||||
<div class="city-item"><span class="name">墨西哥城</span><span class="temp">64°F</span><span class="time">08:50</span></div>
|
||||
<div class="city-item"><span class="name">伦敦</span><span class="temp">57°F</span><span class="time">09:35</span></div>
|
||||
<div class="city-item"><span class="name">巴黎</span><span class="temp">59°F</span><span class="time">08:33</span></div>
|
||||
</div>
|
||||
<!-- Low Risk -->
|
||||
<div class="risk-group">
|
||||
<div class="risk-label"><span class="arrow">▼</span><span class="dot" style="background:var(--green)"></span> 低风险城市 <span class="r-count">28</span></div>
|
||||
<div class="city-item"><span class="name">旧金山</span><span class="temp">61°F</span><span class="time">09:11</span></div>
|
||||
<div class="city-item"><span class="name">西雅图</span><span class="temp">58°F</span><span class="time">09:07</span></div>
|
||||
<div class="city-item"><span class="name">温哥华</span><span class="temp">56°F</span><span class="time">09:12</span></div>
|
||||
<div class="city-item"><span class="name">多伦多</span><span class="temp">60°F</span><span class="time">09:08</span></div>
|
||||
<div class="city-item"><span class="name">柏林</span><span class="temp">55°F</span><span class="time">09:30</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sidebar-footer">📊 数据更新 09:41:12 (北京)<br>数据来源:METAR · ECMWF · DEB · 市场</div>
|
||||
</aside>
|
||||
|
||||
<!-- CENTER -->
|
||||
<main class="center">
|
||||
<div class="map-panel">
|
||||
<div class="map-header">
|
||||
<div class="map-title">全球天气态势 <span class="sub">更新时间:09:41 北京</span></div>
|
||||
<div style="display:flex;gap:8px;align-items:center">
|
||||
<div class="map-tabs">
|
||||
<div class="map-tab active">温度</div>
|
||||
<div class="map-tab">降水</div>
|
||||
<div class="map-tab">风场</div>
|
||||
</div>
|
||||
<button class="map-expand">⛶</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="map-canvas">
|
||||
<div class="map-bg"></div>
|
||||
<div class="map-grid"></div>
|
||||
<!-- Continents -->
|
||||
<div class="continent c-na"></div>
|
||||
<div class="continent c-sa"></div>
|
||||
<div class="continent c-eu"></div>
|
||||
<div class="continent c-af"></div>
|
||||
<div class="continent c-as"></div>
|
||||
<!-- Heat spots -->
|
||||
<div class="heat-spot" style="width:120px;height:120px;background:radial-gradient(var(--red),transparent 70%);top:22%;left:22%;opacity:.3"></div>
|
||||
<div class="heat-spot" style="width:80px;height:80px;background:radial-gradient(var(--orange),transparent 70%);top:35%;left:27%;opacity:.25;animation-delay:1s"></div>
|
||||
<div class="heat-spot" style="width:60px;height:60px;background:radial-gradient(#22c55e,transparent 70%);top:20%;left:50%;opacity:.2;animation-delay:2s"></div>
|
||||
<div class="heat-spot" style="width:100px;height:100px;background:radial-gradient(var(--red),transparent 70%);top:30%;left:70%;opacity:.25;animation-delay:.5s"></div>
|
||||
<!-- City pins -->
|
||||
<div class="city-pin pin-highlight" style="left:24%;top:28%"><div class="ring"></div><div class="ring2"></div><div class="glow"></div><div class="pin-dot" style="border-color:var(--red);background:var(--red)"></div><div class="pin-label">芝加哥 <span class="pin-temp">72°F</span></div></div>
|
||||
<div class="city-pin" style="left:20%;top:22%"><div class="pin-dot" style="border-color:var(--green);background:transparent"></div><div class="pin-label">温哥华 <span class="pin-temp">56°F</span></div></div>
|
||||
<div class="city-pin" style="left:22%;top:36%"><div class="pin-dot" style="border-color:var(--red);background:transparent"></div><div class="pin-label">达拉斯 <span class="pin-temp">63°F</span></div></div>
|
||||
<div class="city-pin" style="left:19%;top:26%"><div class="pin-dot" style="border-color:var(--green);background:transparent"></div><div class="pin-label">旧金山 <span class="pin-temp">61°F</span></div></div>
|
||||
<div class="city-pin" style="left:27%;top:30%"><div class="pin-dot" style="border-color:var(--orange);background:transparent"></div><div class="pin-label">纽约 <span class="pin-temp">66°F</span></div></div>
|
||||
<div class="city-pin" style="left:22%;top:44%"><div class="pin-dot" style="border-color:var(--orange);background:transparent"></div><div class="pin-label">墨西哥城 <span class="pin-temp">64°F</span></div></div>
|
||||
<div class="city-pin" style="left:46%;top:22%"><div class="pin-dot" style="border-color:var(--orange);background:transparent"></div><div class="pin-label">伦敦 <span class="pin-temp">57°F</span></div></div>
|
||||
<div class="city-pin" style="left:48%;top:24%"><div class="pin-dot" style="border-color:var(--orange);background:transparent"></div><div class="pin-label">巴黎 <span class="pin-temp">59°F</span></div></div>
|
||||
<div class="city-pin" style="left:44%;top:22%"><div class="pin-dot" style="border-color:var(--orange);background:transparent"></div><div class="pin-label">西雅图 <span class="pin-temp">58°F</span></div></div>
|
||||
<div class="city-pin" style="left:30%;top:38%"><div class="pin-dot" style="border-color:var(--red);background:transparent"></div><div class="pin-label">迈阿密 <span class="pin-temp">82°F</span></div></div>
|
||||
<div class="city-pin" style="left:70%;top:30%"><div class="pin-dot" style="border-color:var(--orange);background:transparent"></div><div class="pin-label">北京 <span class="pin-temp">61°F</span></div></div>
|
||||
<div class="city-pin" style="left:72%;top:42%"><div class="pin-dot" style="border-color:var(--red);background:transparent"></div><div class="pin-label">马尼拉 <span class="pin-temp">84°F</span></div></div>
|
||||
<div class="city-pin" style="left:68%;top:38%"><div class="pin-dot" style="border-color:var(--red);background:transparent"></div><div class="pin-label">曼谷 <span class="pin-temp">83°F</span></div></div>
|
||||
<div class="city-pin" style="left:65%;top:44%"><div class="pin-dot" style="border-color:var(--orange);background:transparent"></div><div class="pin-label">圣保罗 <span class="pin-temp">70°F</span></div></div>
|
||||
</div>
|
||||
<div class="map-zoom"><button>+</button><button>−</button><button>◎</button></div>
|
||||
<div class="map-legend">
|
||||
<span class="label">温度 (°F)</span>
|
||||
<div>
|
||||
<div class="legend-bar"></div>
|
||||
<div class="legend-vals"><span>-4</span><span>14</span><span>32</span><span>50</span><span>68</span><span>86</span><span>104</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom area -->
|
||||
<div class="bottom-section">
|
||||
<div class="summary-row">
|
||||
<div class="summary-card">
|
||||
<div class="sc-header"><span class="sc-title">今日机会概览</span><span class="sc-link">查看全部</span></div>
|
||||
<div class="sc-body">
|
||||
<div class="sc-stat"><div class="num" style="color:var(--red)">6</div><div class="lbl">高风险</div></div>
|
||||
<div class="sc-stat"><div class="num" style="color:var(--orange)">14</div><div class="lbl">中等风险</div></div>
|
||||
<div class="sc-stat"><div class="num" style="color:var(--green)">28</div><div class="lbl">低风险</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<div class="sc-header"><span class="sc-title">市场总览</span><span class="sc-link">查看全部</span></div>
|
||||
<div class="sc-body" style="flex-direction:column;gap:4px">
|
||||
<div style="display:flex;justify-content:space-between;font-size:11px"><span>交易市场数</span><span style="font-weight:700">1,248</span></div>
|
||||
<div style="display:flex;justify-content:space-between;font-size:11px"><span>活跃合约数</span><span style="font-weight:700">892</span></div>
|
||||
<div style="display:flex;justify-content:space-between;font-size:11px"><span>24h 成交量</span><span style="font-weight:700">$2.34M</span></div>
|
||||
<div style="display:flex;justify-content:space-between;font-size:11px"><span>平均 Edge</span><span style="font-weight:700;color:var(--green)">+6.7%</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<div class="sc-header"><span class="sc-title">模型一致性</span><span class="sc-link">查看全部</span></div>
|
||||
<div class="sc-body">
|
||||
<div class="sc-stat"><div class="sc-bar-label" style="color:var(--green)">48%</div><div class="lbl">高</div></div>
|
||||
<div class="sc-stat"><div class="sc-bar-label" style="color:var(--orange)">32%</div><div class="lbl">中</div></div>
|
||||
<div class="sc-stat"><div class="sc-bar-label" style="color:var(--red)">20%</div><div class="lbl">低</div></div>
|
||||
</div>
|
||||
<div style="display:flex;height:6px;border-radius:3px;overflow:hidden;margin-top:8px">
|
||||
<div style="width:48%;background:var(--green)"></div>
|
||||
<div style="width:32%;background:var(--orange)"></div>
|
||||
<div style="width:20%;background:var(--red)"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<div class="sc-header"><span class="sc-title">高影响窗口 (未来6小时)</span></div>
|
||||
<div class="sc-body" style="flex-direction:column;gap:4px">
|
||||
<div style="display:flex;justify-content:space-between;font-size:11px"><span>影响市场数</span><span style="font-weight:700">12</span></div>
|
||||
<div style="display:flex;justify-content:space-between;font-size:11px"><span>预计成交量</span><span style="font-weight:700">$1.26M</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Opportunity Cards -->
|
||||
<div class="opp-section">
|
||||
<div class="opp-header">
|
||||
<div class="opp-title">今日最佳机会 <span class="badge">Top 机会</span></div>
|
||||
<div class="opp-nav"><button>‹</button><button>›</button></div>
|
||||
</div>
|
||||
<div class="opp-grid">
|
||||
<div class="opp-card">
|
||||
<div class="opp-card-head"><span><span class="opp-rank">1</span><span class="opp-city">芝加哥</span></span><span class="risk-tag high">高风险</span></div>
|
||||
<div class="opp-question">> 76°F 出现时间 5月22日</div>
|
||||
<div class="opp-prices"><span class="opp-yes">YES 62¢</span><span style="color:var(--text-muted);font-size:10px">·</span><span class="opp-edge">Edge +12.4%</span></div>
|
||||
<div class="opp-chart"><svg viewBox="0 0 160 28"><polyline points="5,22 20,18 40,20 60,14 80,16 100,10 120,12 140,6 155,8" fill="none" stroke="#22c55e" stroke-width="1.5"/></svg></div>
|
||||
</div>
|
||||
<div class="opp-card">
|
||||
<div class="opp-card-head"><span><span class="opp-rank">2</span><span class="opp-city">达拉斯</span></span><span class="risk-tag high">高风险</span></div>
|
||||
<div class="opp-question">> 76°F 出现时间 5月22日</div>
|
||||
<div class="opp-prices"><span class="opp-yes">YES 58¢</span><span style="color:var(--text-muted);font-size:10px">·</span><span class="opp-edge">Edge +10.1%</span></div>
|
||||
<div class="opp-chart"><svg viewBox="0 0 160 28"><polyline points="5,20 25,22 45,18 65,20 85,14 105,16 125,10 145,8 155,12" fill="none" stroke="#f59e0b" stroke-width="1.5"/></svg></div>
|
||||
</div>
|
||||
<div class="opp-card">
|
||||
<div class="opp-card-head"><span><span class="opp-rank">3</span><span class="opp-city">伦敦</span></span><span class="risk-tag mid">中等风险</span></div>
|
||||
<div class="opp-question">> 76°F 出现时间 5月22日</div>
|
||||
<div class="opp-prices"><span class="opp-yes">YES 54¢</span><span style="color:var(--text-muted);font-size:10px">·</span><span class="opp-edge">Edge +6.8%</span></div>
|
||||
<div class="opp-chart"><svg viewBox="0 0 160 28"><polyline points="5,16 25,18 45,14 65,16 85,12 105,14 125,10 145,12 155,8" fill="none" stroke="#3b82f6" stroke-width="1.5"/></svg></div>
|
||||
</div>
|
||||
<div class="opp-card">
|
||||
<div class="opp-card-head"><span><span class="opp-rank">4</span><span class="opp-city">墨西哥城</span></span><span class="risk-tag low">低风险</span></div>
|
||||
<div class="opp-question">> 76°F 出现时间 5月22日</div>
|
||||
<div class="opp-prices"><span class="opp-yes">YES 53¢</span><span style="color:var(--text-muted);font-size:10px">·</span><span class="opp-edge">Edge +6.8%</span></div>
|
||||
<div class="opp-chart"><svg viewBox="0 0 160 28"><polyline points="5,14 25,16 45,12 65,18 85,16 105,20 125,14 145,10 155,12" fill="none" stroke="#22c55e" stroke-width="1.5"/></svg></div>
|
||||
</div>
|
||||
<div class="opp-card">
|
||||
<div class="opp-card-head"><span><span class="opp-rank">5</span><span class="opp-city">马尼拉</span></span><span class="risk-tag high">高风险</span></div>
|
||||
<div class="opp-question">> 76°F 出现时间 5月22日</div>
|
||||
<div class="opp-prices"><span class="opp-yes">YES 51¢</span><span style="color:var(--text-muted);font-size:10px">·</span><span class="opp-edge">Edge +4.7%</span></div>
|
||||
<div class="opp-chart"><svg viewBox="0 0 160 28"><polyline points="5,20 25,16 45,18 65,12 85,14 105,10 125,16 145,14 155,10" fill="none" stroke="#ef4444" stroke-width="1.5"/></svg></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- RIGHT PANEL -->
|
||||
<aside class="right-panel">
|
||||
<div class="rp-header"><span class="rp-title">焦点城市 ℹ️</span><span class="rp-link">为什么是它?</span></div>
|
||||
<div class="rp-city">
|
||||
<div class="rp-city-name">芝加哥 <span class="star">⭐</span></div>
|
||||
<div class="rp-meta"><span class="code">KORD</span> · 芝加哥奥黑尔国际机场 <span class="risk-tag high" style="margin-left:4px">高风险</span></div>
|
||||
<div class="rp-time"><span>本地时间 09:41</span><span><span class="live-dot-sm"></span>1 分钟前更新</span></div>
|
||||
</div>
|
||||
<div class="rp-temp-block">
|
||||
<div>
|
||||
<div class="rp-temp-main"><span class="rp-temp-num">72</span><span class="rp-temp-unit">°F</span></div>
|
||||
<div class="rp-temp-sub">体感 70°F</div>
|
||||
</div>
|
||||
<div class="rp-temp-right">
|
||||
<div class="rp-weather-icon">🌧️</div>
|
||||
<div class="rp-forecast">最大预计<br><span class="val">76°F</span> 14:33</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rp-card">
|
||||
<div class="rp-card-title"><span class="tag">DEB 预测</span> (24小时最高)</div>
|
||||
<div class="rp-deb">
|
||||
<div><span class="rp-deb-temp">76°F</span><span class="rp-deb-delta">+2°F</span></div>
|
||||
<div class="rp-mini-chart">
|
||||
<svg viewBox="0 0 140 32" width="140" height="32">
|
||||
<polyline points="0,28 15,24 30,22 45,18 60,14 75,10 90,8 105,10 120,12 135,14" fill="none" stroke="var(--accent)" stroke-width="1.5"/>
|
||||
<circle cx="90" cy="8" r="3" fill="var(--accent)"/>
|
||||
<text x="85" y="6" font-size="7" fill="var(--accent)">76°F</text>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rp-card rp-market" style="margin-top:6px">
|
||||
<div class="rp-card-title">市场问题</div>
|
||||
<div class="rp-market-q">> 76°F 出现时间 5月22日</div>
|
||||
<div class="rp-market-prices">
|
||||
<div class="rp-price-box rp-price-yes">YES 62¢</div>
|
||||
<div class="rp-price-box rp-price-no">NO 38¢</div>
|
||||
</div>
|
||||
<div class="rp-market-stats">
|
||||
<div><div class="stat-label">Edge</div><div class="stat-val" style="color:var(--green)">+12.4%</div></div>
|
||||
<div><div class="stat-label">隐含概率</div><div class="stat-val">62%</div></div>
|
||||
<div><div class="stat-label">模型概率</div><div class="stat-val">74%</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rp-signals">
|
||||
<div class="title">关键信号</div>
|
||||
<div class="signal-item"><span class="signal-dot" style="background:var(--accent)"></span> DEB > 市场隐含值</div>
|
||||
<div class="signal-item"><span class="signal-dot" style="background:var(--orange)"></span> 气温上升趋势</div>
|
||||
<div class="signal-item"><span class="signal-dot" style="background:var(--red)"></span> 高影响窗口 11:00-17:00 (北京)</div>
|
||||
</div>
|
||||
<div class="rp-cta">查看完整分析 →</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Hover effects for city items
|
||||
document.querySelectorAll('.city-item').forEach(item=>{
|
||||
item.addEventListener('click',()=>{
|
||||
document.querySelectorAll('.city-item').forEach(i=>i.classList.remove('active'));
|
||||
item.classList.add('active');
|
||||
});
|
||||
});
|
||||
// Map tab switching
|
||||
document.querySelectorAll('.map-tab').forEach(tab=>{
|
||||
tab.addEventListener('click',()=>{
|
||||
document.querySelectorAll('.map-tab').forEach(t=>t.classList.remove('active'));
|
||||
tab.classList.add('active');
|
||||
});
|
||||
});
|
||||
// Nav switching
|
||||
document.querySelectorAll('.nav-item').forEach(n=>{
|
||||
n.addEventListener('click',()=>{
|
||||
document.querySelectorAll('.nav-item').forEach(i=>i.classList.remove('active'));
|
||||
n.classList.add('active');
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user