From 2589e114155d5ccbb128981e042a4c66f82b6c5f Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Thu, 23 Apr 2026 02:17:31 +0800 Subject: [PATCH] Rework homepage toward reference dashboard --- frontend/components/dashboard/CitySidebar.tsx | 43 ++- .../components/dashboard/Dashboard.module.css | 327 ++++++++++++++---- frontend/components/dashboard/HeaderBar.tsx | 66 +++- .../dashboard/PolyWeatherDashboard.tsx | 201 +++++++---- 4 files changed, 487 insertions(+), 150 deletions(-) diff --git a/frontend/components/dashboard/CitySidebar.tsx b/frontend/components/dashboard/CitySidebar.tsx index 5f1e9e5d..1061330c 100644 --- a/frontend/components/dashboard/CitySidebar.tsx +++ b/frontend/components/dashboard/CitySidebar.tsx @@ -2,7 +2,7 @@ import { startTransition, useEffect, useMemo, useRef, useState } from "react"; import clsx from "clsx"; -import { Clock } from "lucide-react"; +import { Clock, Search } from "lucide-react"; import { useDashboardStore } from "@/hooks/useDashboardStore"; import { useI18n } from "@/hooks/useI18n"; import { getLocalizedCityName } from "@/lib/dashboard-home-copy"; @@ -62,7 +62,9 @@ export function CitySidebar() { const [expandedGroups, setExpandedGroups] = useState< Record >(DEFAULT_EXPANDED_GROUPS); + const [searchQuery, setSearchQuery] = useState(""); const cityItemRefs = useRef>({}); + const normalizedQuery = searchQuery.trim().toLowerCase(); const sortedCities = useMemo( () => @@ -91,10 +93,36 @@ export function CitySidebar() { other: [], }; sortedCities.forEach((city) => { + const summary = store.citySummariesByName[city.name]; + const detail = store.cityDetailsByName[city.name]; + const localizedName = getLocalizedCityName( + city.name, + summary?.display_name || detail?.display_name || city.display_name, + locale, + ); + if (normalizedQuery) { + const searchCorpus = [ + city.name, + city.display_name, + city.airport, + city.icao, + localizedName, + ] + .filter(Boolean) + .join(" ") + .toLowerCase(); + if (!searchCorpus.includes(normalizedQuery)) return; + } groups[toPerformanceGroup(city)].push(city); }); return groups; - }, [sortedCities]); + }, [ + locale, + normalizedQuery, + sortedCities, + store.cityDetailsByName, + store.citySummariesByName, + ]); useEffect(() => { if (!selectedCity) return; @@ -166,6 +194,17 @@ export function CitySidebar() { {store.cities.length} +
+
+
{groupMeta.map((group) => { const citiesInGroup = groupedCities[group.key]; diff --git a/frontend/components/dashboard/Dashboard.module.css b/frontend/components/dashboard/Dashboard.module.css index 1c7d8088..f9b588ca 100644 --- a/frontend/components/dashboard/Dashboard.module.css +++ b/frontend/components/dashboard/Dashboard.module.css @@ -32,8 +32,8 @@ /* Spacing — aligned with global layout tokens */ --panel-width: 560px; - --header-height: 52px; - --sidebar-width: 260px; + --header-height: 72px; + --sidebar-width: 240px; /* Effects — using global elevation tokens */ --glass-blur: 20px; @@ -69,9 +69,9 @@ .root :global(.map) { position: absolute; top: calc(var(--header-height) + 16px); - right: 418px; - bottom: 128px; - left: calc(var(--sidebar-width) + 30px); + right: 392px; + bottom: 164px; + left: calc(var(--sidebar-width) + 22px); z-index: 1; overflow: hidden; border: 1px solid rgba(34, 211, 238, 0.12); @@ -82,6 +82,71 @@ inset 0 1px 0 rgba(255, 255, 255, 0.05); } +.root :global(.home-map-toolbar) { + position: fixed; + top: calc(var(--header-height) + 30px); + right: 412px; + z-index: 930; + display: flex; + align-items: center; + gap: 10px; +} + +.root :global(.home-map-modes) { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px; + border-radius: 14px; + border: 1px solid rgba(148, 163, 184, 0.16); + background: rgba(9, 16, 28, 0.84); + backdrop-filter: blur(16px); +} + +.root :global(.home-map-modes button), +.root :global(.home-map-expand) { + border: none; + color: rgba(203, 213, 225, 0.88); + background: transparent; + cursor: pointer; + font: inherit; + transition: var(--transition); +} + +.root :global(.home-map-modes button) { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 10px 14px; + border-radius: 10px; + font-size: 12px; + font-weight: 700; +} + +.root :global(.home-map-modes button.active) { + background: linear-gradient(180deg, rgba(28, 100, 242, 0.76), rgba(31, 72, 184, 0.76)); + color: #f8fafc; + box-shadow: 0 10px 24px rgba(37, 99, 235, 0.24); +} + +.root :global(.home-map-modes button:hover), +.root :global(.home-map-expand:hover) { + color: #f8fafc; + background: rgba(30, 41, 59, 0.72); +} + +.root :global(.home-map-expand) { + width: 38px; + height: 38px; + border-radius: 12px; + border: 1px solid rgba(148, 163, 184, 0.16); + background: rgba(9, 16, 28, 0.84); + backdrop-filter: blur(16px); + display: inline-flex; + align-items: center; + justify-content: center; +} + .weatherAura { position: absolute; inset: 0; @@ -181,12 +246,9 @@ display: flex; align-items: center; justify-content: space-between; - padding: 0 20px; - background: linear-gradient( - 180deg, - rgba(8, 12, 20, 0.96) 0%, - rgba(8, 12, 20, 0.82) 100% - ); + gap: 24px; + padding: 0 16px; + background: rgba(7, 11, 18, 0.94); backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px); border-bottom: 1px solid var(--border-glass); @@ -194,8 +256,9 @@ .root :global(.brand) { display: flex; - align-items: baseline; - gap: 10px; + align-items: center; + gap: 14px; + min-width: 0; } .root :global(.brand h1) { @@ -212,15 +275,48 @@ font-size: 11px; font-weight: 500; color: var(--text-muted); - letter-spacing: 0.6px; - text-transform: uppercase; + letter-spacing: 0.04em; opacity: 0.8; } +.root :global(.header-nav) { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + flex: 1 1 auto; + min-width: 0; +} + +.root :global(.header-nav-link) { + display: inline-flex; + align-items: center; + gap: 7px; + padding: 10px 12px; + border-radius: 10px; + color: rgba(203, 213, 225, 0.82); + text-decoration: none; + font-size: 13px; + font-weight: 650; + transition: var(--transition); +} + +.root :global(.header-nav-link:hover) { + color: #f8fafc; + background: rgba(30, 41, 59, 0.44); +} + +.root :global(.header-nav-link.active) { + color: #f8fafc; + background: rgba(8, 145, 178, 0.12); + box-shadow: inset 0 -2px 0 var(--accent-cyan); +} + .root :global(.header-right) { display: flex; align-items: center; gap: 8px; + flex-shrink: 0; } .root :global(.lang-switch) { @@ -344,7 +440,7 @@ display: flex; justify-content: space-between; align-items: center; - padding: 14px 16px; + padding: 14px 16px 10px; border-bottom: 1px solid var(--border-subtle); font-size: 13px; font-weight: 700; @@ -352,6 +448,32 @@ letter-spacing: -0.01em; } +.root :global(.city-search) { + display: flex; + align-items: center; + gap: 8px; + margin: 0 14px 12px; + padding: 10px 12px; + border-radius: 12px; + border: 1px solid rgba(148, 163, 184, 0.12); + background: rgba(8, 15, 28, 0.72); + color: rgba(148, 163, 184, 0.86); +} + +.root :global(.city-search input) { + width: 100%; + border: none; + outline: none; + background: transparent; + color: #f8fafc; + font: inherit; + font-size: 13px; +} + +.root :global(.city-search input::placeholder) { + color: rgba(148, 163, 184, 0.72); +} + .root :global(.city-count) { background: rgba(99, 102, 241, 0.2); color: var(--accent-blue); @@ -366,7 +488,7 @@ .root :global(.city-list-items) { overflow-y: auto; flex: 1; - padding: 6px 6px 8px; + padding: 0 6px 8px; } .root :global(.city-list-items::-webkit-scrollbar) { width: 4px; @@ -599,10 +721,10 @@ /* ── Homepage Intelligence Layer ── */ .root :global(.home-intelligence-panel) { position: fixed; - top: calc(var(--header-height) + 18px); + top: calc(var(--header-height) + 16px); right: 18px; - bottom: 132px; - width: min(382px, calc(100vw - var(--sidebar-width) - 64px)); + bottom: 20px; + width: min(360px, calc(100vw - var(--sidebar-width) - 56px)); z-index: 890; display: flex; flex-direction: column; @@ -875,7 +997,7 @@ .root :global(.home-intelligence-panel.full) { bottom: 18px; - width: min(372px, calc(100vw - var(--sidebar-width) - 64px)); + width: min(360px, calc(100vw - var(--sidebar-width) - 56px)); gap: 12px; overflow-y: auto; border-color: rgba(34, 211, 238, 0.34); @@ -948,10 +1070,10 @@ .root :global(.home-card-titlebar h2) { color: #f8fafc; - font-size: 28px; + font-size: 22px; font-weight: 900; line-height: 1; - letter-spacing: -0.055em; + letter-spacing: -0.045em; } .root :global(.home-card-titlebar p), @@ -1615,14 +1737,13 @@ .root :global(.home-opportunity-strip) { position: fixed; - left: calc(var(--sidebar-width) + 30px); - right: 418px; + left: calc(var(--sidebar-width) + 22px); + right: 392px; bottom: 18px; z-index: 880; - display: grid; - grid-template-columns: 170px 1fr; - gap: 14px; - align-items: stretch; + display: flex; + flex-direction: column; + gap: 12px; padding: 12px; border: 1px solid rgba(148, 163, 184, 0.14); border-radius: 22px; @@ -1634,12 +1755,53 @@ animation: opportunity-strip-enter 580ms cubic-bezier(0.16, 1, 0.3, 1) 120ms both; } -.root :global(.opportunity-strip-heading) { +.root :global(.home-summary-grid) { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; +} + +.root :global(.home-summary-card) { + min-height: 92px; display: flex; flex-direction: column; - justify-content: center; - gap: 7px; - padding: 4px 8px; + justify-content: space-between; + padding: 14px 16px; + border-radius: 16px; + border: 1px solid rgba(148, 163, 184, 0.14); + background: rgba(8, 15, 28, 0.7); +} + +.root :global(.home-summary-card span) { + color: rgba(148, 163, 184, 0.88); + font-size: 12px; + font-weight: 650; +} + +.root :global(.home-summary-card strong) { + color: #f8fafc; + font-size: 28px; + font-weight: 850; + letter-spacing: -0.04em; +} + +.root :global(.home-summary-card.accent-red strong) { color: #fb7185; } +.root :global(.home-summary-card.accent-amber strong) { color: #fbbf24; } +.root :global(.home-summary-card.accent-green strong) { color: #4ade80; } +.root :global(.home-summary-card.accent-cyan strong) { color: #22d3ee; } + +.root :global(.opportunity-strip-heading) { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 2px 4px; +} + +.root :global(.opportunity-strip-heading > div) { + display: flex; + flex-direction: column; + gap: 5px; } .root :global(.opportunity-strip-heading strong) { @@ -1648,28 +1810,36 @@ line-height: 1.25; } +.root :global(.opportunity-view-all) { + color: #60a5fa; + text-decoration: none; + font-size: 12px; + font-weight: 700; +} + .root :global(.opportunity-card-grid) { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; } +.root :global(.opportunity-card-grid.top-opportunities) { + grid-template-columns: repeat(5, minmax(0, 1fr)); +} + .root :global(.opportunity-card) { position: relative; min-width: 0; - display: grid; - grid-template-columns: 10px 1fr; - grid-template-areas: - "dot city" - "dot meta" - "dot hit"; - gap: 3px 9px; - align-content: center; - min-height: 76px; - padding: 12px; - border: 1px solid rgba(148, 163, 184, 0.12); + display: flex; + flex-direction: column; + gap: 12px; + min-height: 122px; + padding: 14px; + border: 1px solid rgba(148, 163, 184, 0.14); border-radius: 16px; - background: rgba(15, 23, 42, 0.5); + background: + radial-gradient(circle at 100% 0%, rgba(30, 64, 175, 0.12), transparent 32%), + rgba(10, 16, 28, 0.84); color: inherit; cursor: pointer; font: inherit; @@ -1680,32 +1850,59 @@ .root :global(.opportunity-card:hover) { transform: translateY(-2px); border-color: rgba(34, 211, 238, 0.32); - background: rgba(15, 23, 42, 0.72); + background: + radial-gradient(circle at 100% 0%, rgba(30, 64, 175, 0.18), transparent 32%), + rgba(15, 23, 42, 0.92); } -.root :global(.opportunity-risk-dot) { - grid-area: dot; - align-self: center; - width: 8px; - height: 42px; +.root :global(.opportunity-card-header), +.root :global(.opportunity-card-footer) { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.root :global(.opportunity-rank) { + width: 24px; + height: 24px; border-radius: 999px; - background: rgba(148, 163, 184, 0.55); + border: 1px solid rgba(148, 163, 184, 0.24); + display: inline-flex; + align-items: center; + justify-content: center; + color: #f8fafc; + font-size: 12px; + font-weight: 800; + flex-shrink: 0; } -.root :global(.opportunity-risk-dot.high) { - background: linear-gradient(180deg, #fb7185, #f97316); +.root :global(.opportunity-pill) { + margin-left: auto; + padding: 4px 8px; + border-radius: 999px; + font-size: 10px; + font-weight: 800; + border: 1px solid rgba(148, 163, 184, 0.18); + color: rgba(226, 232, 240, 0.86); } -.root :global(.opportunity-risk-dot.medium) { - background: linear-gradient(180deg, #facc15, #f97316); +.root :global(.opportunity-pill.high) { + color: #fda4af; + border-color: rgba(251, 113, 133, 0.26); } -.root :global(.opportunity-risk-dot.low) { - background: linear-gradient(180deg, #34d399, #22d3ee); +.root :global(.opportunity-pill.medium) { + color: #fbbf24; + border-color: rgba(251, 191, 36, 0.24); +} + +.root :global(.opportunity-pill.low) { + color: #4ade80; + border-color: rgba(74, 222, 128, 0.24); } .root :global(.opportunity-city) { - grid-area: city; overflow: hidden; color: #f8fafc; font-size: 13px; @@ -1715,7 +1912,6 @@ } .root :global(.opportunity-meta) { - grid-area: meta; overflow: hidden; color: rgba(203, 213, 225, 0.88); font-size: 11px; @@ -1723,10 +1919,15 @@ white-space: nowrap; } -.root :global(.opportunity-hit) { - grid-area: hit; +.root :global(.opportunity-yes) { + color: #22d3ee; + font-size: 12px; + font-weight: 800; +} + +.root :global(.opportunity-edge) { color: rgba(34, 211, 238, 0.9); - font-size: 11px; + font-size: 18px; font-weight: 800; } diff --git a/frontend/components/dashboard/HeaderBar.tsx b/frontend/components/dashboard/HeaderBar.tsx index 53866184..33e3565a 100644 --- a/frontend/components/dashboard/HeaderBar.tsx +++ b/frontend/components/dashboard/HeaderBar.tsx @@ -10,6 +10,9 @@ import { BookOpen, Sparkles, House, + Bell, + BarChart3, + CandlestickChart, } from "lucide-react"; import { useDashboardStore } from "@/hooks/useDashboardStore"; import { useI18n } from "@/hooks/useI18n"; @@ -41,8 +44,38 @@ export function HeaderBar({ const isAuthenticated = store.proAccess.authenticated; const docsHref = "/docs/intro"; const docsActive = pathname?.startsWith("/docs"); - const homeHref = "/"; - const homeActive = pathname === "/"; + const navItems = [ + { + href: "/", + label: locale === "en-US" ? "Dashboard" : "总览", + active: pathname === "/", + icon: House, + }, + { + href: "/docs/intraday-signal", + label: locale === "en-US" ? "Markets" : "市场", + active: pathname?.startsWith("/docs/intraday-signal"), + icon: CandlestickChart, + }, + { + href: "/docs/model-stack-deb", + label: locale === "en-US" ? "Analytics" : "分析", + active: pathname?.startsWith("/docs/model-stack-deb"), + icon: BarChart3, + }, + { + href: "/docs/history-review", + label: locale === "en-US" ? "History" : "历史", + active: pathname?.startsWith("/docs/history-review"), + icon: BookOpen, + }, + { + href: "/docs/alert-playbook", + label: locale === "en-US" ? "Alerts" : "提醒", + active: pathname?.startsWith("/docs/alert-playbook"), + icon: Bell, + }, + ]; const trialPromoLabel = locale === "en-US" ? "New users get 3-day Pro trial" @@ -109,6 +142,25 @@ export function HeaderBar({ {t("header.subtitle")}
+ +
- - - {t("header.home")} - - import("@/components/dashboard/HistoryModal").then( @@ -237,7 +238,88 @@ type HomeForecastDay = { maxTemp: number; }; -const RECENT_OPENED_CITIES_STORAGE_KEY = "polyWeather_recent_opened_cities_v1"; +type HomeSummaryMetric = { + key: string; + label: string; + value: string; + accent?: "green" | "amber" | "red" | "cyan"; +}; + +function buildDashboardSummaryMetrics( + snapshots: CitySnapshot[], + locale: string, +): HomeSummaryMetric[] { + const topTradable = snapshots.filter((snapshot) => snapshot.tradableOpportunity); + 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 avgEdge = topTradable.length + ? topTradable.reduce((sum, snapshot) => { + const edgeValue = Number(getMarketEdgeValue(snapshot.detail)); + const normalized = + Number.isFinite(edgeValue) && Math.abs(edgeValue) <= 1 + ? edgeValue * 100 + : edgeValue; + return sum + (Number.isFinite(normalized) ? normalized : 0); + }, 0) / topTradable.length + : 0; + + return [ + { + key: "high", + label: locale === "en-US" ? "High risk" : "高风险", + value: String(highCount), + accent: "red", + }, + { + key: "medium", + label: locale === "en-US" ? "Medium risk" : "中风险", + value: String(mediumCount), + accent: "amber", + }, + { + key: "low", + label: locale === "en-US" ? "Low risk" : "低风险", + value: String(lowCount), + accent: "green", + }, + { + key: "edge", + label: locale === "en-US" ? "Avg. edge" : "平均优势", + value: formatEdge(avgEdge), + accent: "cyan", + }, + ]; +} + +function HomeMapToolbar() { + const { locale } = useI18n(); + return ( +
+
+ + + +
+ +
+ ); +} function projectHomeTrendPoint( x: number, @@ -965,24 +1047,18 @@ function HomeIntelligencePanel({ snapshots }: { snapshots: CitySnapshot[] }) { function OpportunityStrip({ snapshots, - recentCityNames, }: { snapshots: CitySnapshot[]; - recentCityNames: string[]; }) { - const store = useDashboardStore(); const { locale } = useI18n(); - const snapshotByName = useMemo( - () => new Map(snapshots.map((snapshot) => [snapshot.city.name, snapshot])), - [snapshots], + const store = useDashboardStore(); + const summaryMetrics = useMemo( + () => buildDashboardSummaryMetrics(snapshots, locale), + [locale, snapshots], ); const items = useMemo( - () => - recentCityNames - .map((cityName) => snapshotByName.get(cityName) || null) - .filter((snapshot): snapshot is CitySnapshot => snapshot != null) - .slice(0, 4), - [recentCityNames, snapshotByName], + () => snapshots.filter((snapshot) => snapshot.tradableOpportunity).slice(0, 5), + [snapshots], ); if (!items.length) return null; @@ -992,14 +1068,28 @@ function OpportunityStrip({ className="home-opportunity-strip" aria-label={locale === "en-US" ? "Opportunity strip" : "机会条"} > -
- {locale === "en-US" ? "Recently opened" : "最近打开"} - - {locale === "en-US" ? "Recently opened cities" : "最近打开的城市"} - +
+ {summaryMetrics.map((metric) => ( +
+ {metric.label} + {metric.value} +
+ ))}
-
- {items.map(({ city, detail, summary }) => { + +
+
+ {locale === "en-US" ? "Today's Top Opportunities" : "今日最佳机会"} + + {locale === "en-US" ? "Sorted by edge and tradability" : "按优势与可交易性排序"} + +
+ + {locale === "en-US" ? "View all" : "查看全部"} + +
+
+ {items.map(({ city, detail, summary }, index) => { const symbol = getTempSymbol(city, summary, detail); const currentTemp = summary?.current?.temp ?? detail?.current?.temp; const debPrediction = summary?.deb?.prediction ?? detail?.deb?.prediction; @@ -1016,16 +1106,21 @@ function OpportunityStrip({ className="opportunity-card" onClick={() => void store.focusCity(city.name)} > - - {localizedCityName} +
+ {index + 1} + {localizedCityName} + + {getRiskCopy(tier as RiskLevel | undefined, locale)} + +
- {formatTemperature(currentTemp, symbol)} / DEB{" "} - {formatTemperature(debPrediction, symbol)} - - - {locale === "en-US" ? "Hit" : "命中"}{" "} - {formatPercent(city.deb_recent_hit_rate)} + > {formatTemperature(debPrediction, symbol)}{" "} + {locale === "en-US" ? "target" : "目标"} +
+ YES {formatCents(detail?.market_scan?.yes_buy)} + {formatEdge(getMarketEdgeValue(detail))} +
); })} @@ -1039,7 +1134,6 @@ function DashboardScreen() { const { t } = useI18n(); const didAutoFocusRef = useRef(false); const preloadedOpportunityRef = useRef>(new Set()); - const [recentCityNames, setRecentCityNames] = useState([]); const activeSummary = store.selectedCity ? store.citySummariesByName[store.selectedCity] || null : null; @@ -1098,26 +1192,9 @@ function DashboardScreen() { ), [store.cities, store.cityDetailsByName, store.citySummariesByName], ); - const fallbackRecentCityNames = useMemo( - () => homepageSnapshots.slice(0, 4).map((snapshot) => snapshot.city.name), - [homepageSnapshots], - ); const showHomepageChrome = !store.historyState.isOpen && !store.futureModalDate; - useEffect(() => { - if (typeof window === "undefined") return; - try { - const raw = window.localStorage.getItem(RECENT_OPENED_CITIES_STORAGE_KEY); - if (!raw) return; - const parsed = JSON.parse(raw); - if (!Array.isArray(parsed)) return; - setRecentCityNames( - parsed.map((item) => String(item || "").trim()).filter(Boolean).slice(0, 8), - ); - } catch {} - }, []); - useEffect(() => { if (didAutoFocusRef.current) return; if (!showHomepageChrome) return; @@ -1141,44 +1218,22 @@ function DashboardScreen() { }); }, [homepageSnapshots, showHomepageChrome, store]); - useEffect(() => { - const selectedCity = store.selectedCity; - if (!selectedCity) return; - setRecentCityNames((current) => { - const next = [ - selectedCity, - ...current.filter((name) => name !== selectedCity), - ].slice(0, 8); - if (typeof window !== "undefined") { - try { - window.localStorage.setItem( - RECENT_OPENED_CITIES_STORAGE_KEY, - JSON.stringify(next), - ); - } catch {} - } - return next; - }); - }, [store.selectedCity]); - return ( -
+ {showHomepageChrome ? ( <> - + ) : null} {showCitySyncToast ? (