Rework homepage toward reference dashboard

This commit is contained in:
2569718930@qq.com
2026-04-23 02:17:31 +08:00
parent 040313175f
commit 2589e11415
4 changed files with 487 additions and 150 deletions
+41 -2
View File
@@ -2,7 +2,7 @@
import { startTransition, useEffect, useMemo, useRef, useState } from "react"; import { startTransition, useEffect, useMemo, useRef, useState } from "react";
import clsx from "clsx"; import clsx from "clsx";
import { Clock } from "lucide-react"; import { Clock, Search } from "lucide-react";
import { useDashboardStore } from "@/hooks/useDashboardStore"; import { useDashboardStore } from "@/hooks/useDashboardStore";
import { useI18n } from "@/hooks/useI18n"; import { useI18n } from "@/hooks/useI18n";
import { getLocalizedCityName } from "@/lib/dashboard-home-copy"; import { getLocalizedCityName } from "@/lib/dashboard-home-copy";
@@ -62,7 +62,9 @@ export function CitySidebar() {
const [expandedGroups, setExpandedGroups] = useState< const [expandedGroups, setExpandedGroups] = useState<
Record<RiskGroupKey, boolean> Record<RiskGroupKey, boolean>
>(DEFAULT_EXPANDED_GROUPS); >(DEFAULT_EXPANDED_GROUPS);
const [searchQuery, setSearchQuery] = useState("");
const cityItemRefs = useRef<Record<string, HTMLButtonElement | null>>({}); const cityItemRefs = useRef<Record<string, HTMLButtonElement | null>>({});
const normalizedQuery = searchQuery.trim().toLowerCase();
const sortedCities = useMemo( const sortedCities = useMemo(
() => () =>
@@ -91,10 +93,36 @@ export function CitySidebar() {
other: [], other: [],
}; };
sortedCities.forEach((city) => { 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); groups[toPerformanceGroup(city)].push(city);
}); });
return groups; return groups;
}, [sortedCities]); }, [
locale,
normalizedQuery,
sortedCities,
store.cityDetailsByName,
store.citySummariesByName,
]);
useEffect(() => { useEffect(() => {
if (!selectedCity) return; if (!selectedCity) return;
@@ -166,6 +194,17 @@ export function CitySidebar() {
<span className="city-count">{store.cities.length}</span> <span className="city-count">{store.cities.length}</span>
</div> </div>
<div className="city-search">
<Search size={14} strokeWidth={2} aria-hidden="true" />
<input
type="search"
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
placeholder={locale === "en-US" ? "Search city" : "搜索城市"}
aria-label={locale === "en-US" ? "Search city" : "搜索城市"}
/>
</div>
<div className="city-list-items"> <div className="city-list-items">
{groupMeta.map((group) => { {groupMeta.map((group) => {
const citiesInGroup = groupedCities[group.key]; const citiesInGroup = groupedCities[group.key];
@@ -32,8 +32,8 @@
/* Spacing — aligned with global layout tokens */ /* Spacing — aligned with global layout tokens */
--panel-width: 560px; --panel-width: 560px;
--header-height: 52px; --header-height: 72px;
--sidebar-width: 260px; --sidebar-width: 240px;
/* Effects — using global elevation tokens */ /* Effects — using global elevation tokens */
--glass-blur: 20px; --glass-blur: 20px;
@@ -69,9 +69,9 @@
.root :global(.map) { .root :global(.map) {
position: absolute; position: absolute;
top: calc(var(--header-height) + 16px); top: calc(var(--header-height) + 16px);
right: 418px; right: 392px;
bottom: 128px; bottom: 164px;
left: calc(var(--sidebar-width) + 30px); left: calc(var(--sidebar-width) + 22px);
z-index: 1; z-index: 1;
overflow: hidden; overflow: hidden;
border: 1px solid rgba(34, 211, 238, 0.12); border: 1px solid rgba(34, 211, 238, 0.12);
@@ -82,6 +82,71 @@
inset 0 1px 0 rgba(255, 255, 255, 0.05); 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 { .weatherAura {
position: absolute; position: absolute;
inset: 0; inset: 0;
@@ -181,12 +246,9 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 0 20px; gap: 24px;
background: linear-gradient( padding: 0 16px;
180deg, background: rgba(7, 11, 18, 0.94);
rgba(8, 12, 20, 0.96) 0%,
rgba(8, 12, 20, 0.82) 100%
);
backdrop-filter: blur(20px); backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px);
border-bottom: 1px solid var(--border-glass); border-bottom: 1px solid var(--border-glass);
@@ -194,8 +256,9 @@
.root :global(.brand) { .root :global(.brand) {
display: flex; display: flex;
align-items: baseline; align-items: center;
gap: 10px; gap: 14px;
min-width: 0;
} }
.root :global(.brand h1) { .root :global(.brand h1) {
@@ -212,15 +275,48 @@
font-size: 11px; font-size: 11px;
font-weight: 500; font-weight: 500;
color: var(--text-muted); color: var(--text-muted);
letter-spacing: 0.6px; letter-spacing: 0.04em;
text-transform: uppercase;
opacity: 0.8; 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) { .root :global(.header-right) {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
flex-shrink: 0;
} }
.root :global(.lang-switch) { .root :global(.lang-switch) {
@@ -344,7 +440,7 @@
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 14px 16px; padding: 14px 16px 10px;
border-bottom: 1px solid var(--border-subtle); border-bottom: 1px solid var(--border-subtle);
font-size: 13px; font-size: 13px;
font-weight: 700; font-weight: 700;
@@ -352,6 +448,32 @@
letter-spacing: -0.01em; 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) { .root :global(.city-count) {
background: rgba(99, 102, 241, 0.2); background: rgba(99, 102, 241, 0.2);
color: var(--accent-blue); color: var(--accent-blue);
@@ -366,7 +488,7 @@
.root :global(.city-list-items) { .root :global(.city-list-items) {
overflow-y: auto; overflow-y: auto;
flex: 1; flex: 1;
padding: 6px 6px 8px; padding: 0 6px 8px;
} }
.root :global(.city-list-items::-webkit-scrollbar) { .root :global(.city-list-items::-webkit-scrollbar) {
width: 4px; width: 4px;
@@ -599,10 +721,10 @@
/* ── Homepage Intelligence Layer ── */ /* ── Homepage Intelligence Layer ── */
.root :global(.home-intelligence-panel) { .root :global(.home-intelligence-panel) {
position: fixed; position: fixed;
top: calc(var(--header-height) + 18px); top: calc(var(--header-height) + 16px);
right: 18px; right: 18px;
bottom: 132px; bottom: 20px;
width: min(382px, calc(100vw - var(--sidebar-width) - 64px)); width: min(360px, calc(100vw - var(--sidebar-width) - 56px));
z-index: 890; z-index: 890;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -875,7 +997,7 @@
.root :global(.home-intelligence-panel.full) { .root :global(.home-intelligence-panel.full) {
bottom: 18px; bottom: 18px;
width: min(372px, calc(100vw - var(--sidebar-width) - 64px)); width: min(360px, calc(100vw - var(--sidebar-width) - 56px));
gap: 12px; gap: 12px;
overflow-y: auto; overflow-y: auto;
border-color: rgba(34, 211, 238, 0.34); border-color: rgba(34, 211, 238, 0.34);
@@ -948,10 +1070,10 @@
.root :global(.home-card-titlebar h2) { .root :global(.home-card-titlebar h2) {
color: #f8fafc; color: #f8fafc;
font-size: 28px; font-size: 22px;
font-weight: 900; font-weight: 900;
line-height: 1; line-height: 1;
letter-spacing: -0.055em; letter-spacing: -0.045em;
} }
.root :global(.home-card-titlebar p), .root :global(.home-card-titlebar p),
@@ -1615,14 +1737,13 @@
.root :global(.home-opportunity-strip) { .root :global(.home-opportunity-strip) {
position: fixed; position: fixed;
left: calc(var(--sidebar-width) + 30px); left: calc(var(--sidebar-width) + 22px);
right: 418px; right: 392px;
bottom: 18px; bottom: 18px;
z-index: 880; z-index: 880;
display: grid; display: flex;
grid-template-columns: 170px 1fr; flex-direction: column;
gap: 14px; gap: 12px;
align-items: stretch;
padding: 12px; padding: 12px;
border: 1px solid rgba(148, 163, 184, 0.14); border: 1px solid rgba(148, 163, 184, 0.14);
border-radius: 22px; border-radius: 22px;
@@ -1634,12 +1755,53 @@
animation: opportunity-strip-enter 580ms cubic-bezier(0.16, 1, 0.3, 1) 120ms both; 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; display: flex;
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: space-between;
gap: 7px; padding: 14px 16px;
padding: 4px 8px; 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) { .root :global(.opportunity-strip-heading strong) {
@@ -1648,28 +1810,36 @@
line-height: 1.25; 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) { .root :global(.opportunity-card-grid) {
display: grid; display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr)); grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px; gap: 10px;
} }
.root :global(.opportunity-card-grid.top-opportunities) {
grid-template-columns: repeat(5, minmax(0, 1fr));
}
.root :global(.opportunity-card) { .root :global(.opportunity-card) {
position: relative; position: relative;
min-width: 0; min-width: 0;
display: grid; display: flex;
grid-template-columns: 10px 1fr; flex-direction: column;
grid-template-areas: gap: 12px;
"dot city" min-height: 122px;
"dot meta" padding: 14px;
"dot hit"; border: 1px solid rgba(148, 163, 184, 0.14);
gap: 3px 9px;
align-content: center;
min-height: 76px;
padding: 12px;
border: 1px solid rgba(148, 163, 184, 0.12);
border-radius: 16px; 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; color: inherit;
cursor: pointer; cursor: pointer;
font: inherit; font: inherit;
@@ -1680,32 +1850,59 @@
.root :global(.opportunity-card:hover) { .root :global(.opportunity-card:hover) {
transform: translateY(-2px); transform: translateY(-2px);
border-color: rgba(34, 211, 238, 0.32); 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) { .root :global(.opportunity-card-header),
grid-area: dot; .root :global(.opportunity-card-footer) {
align-self: center; display: flex;
width: 8px; align-items: center;
height: 42px; justify-content: space-between;
gap: 8px;
}
.root :global(.opportunity-rank) {
width: 24px;
height: 24px;
border-radius: 999px; 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) { .root :global(.opportunity-pill) {
background: linear-gradient(180deg, #fb7185, #f97316); 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) { .root :global(.opportunity-pill.high) {
background: linear-gradient(180deg, #facc15, #f97316); color: #fda4af;
border-color: rgba(251, 113, 133, 0.26);
} }
.root :global(.opportunity-risk-dot.low) { .root :global(.opportunity-pill.medium) {
background: linear-gradient(180deg, #34d399, #22d3ee); 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) { .root :global(.opportunity-city) {
grid-area: city;
overflow: hidden; overflow: hidden;
color: #f8fafc; color: #f8fafc;
font-size: 13px; font-size: 13px;
@@ -1715,7 +1912,6 @@
} }
.root :global(.opportunity-meta) { .root :global(.opportunity-meta) {
grid-area: meta;
overflow: hidden; overflow: hidden;
color: rgba(203, 213, 225, 0.88); color: rgba(203, 213, 225, 0.88);
font-size: 11px; font-size: 11px;
@@ -1723,10 +1919,15 @@
white-space: nowrap; white-space: nowrap;
} }
.root :global(.opportunity-hit) { .root :global(.opportunity-yes) {
grid-area: hit; color: #22d3ee;
font-size: 12px;
font-weight: 800;
}
.root :global(.opportunity-edge) {
color: rgba(34, 211, 238, 0.9); color: rgba(34, 211, 238, 0.9);
font-size: 11px; font-size: 18px;
font-weight: 800; font-weight: 800;
} }
+54 -12
View File
@@ -10,6 +10,9 @@ import {
BookOpen, BookOpen,
Sparkles, Sparkles,
House, House,
Bell,
BarChart3,
CandlestickChart,
} from "lucide-react"; } from "lucide-react";
import { useDashboardStore } from "@/hooks/useDashboardStore"; import { useDashboardStore } from "@/hooks/useDashboardStore";
import { useI18n } from "@/hooks/useI18n"; import { useI18n } from "@/hooks/useI18n";
@@ -41,8 +44,38 @@ export function HeaderBar({
const isAuthenticated = store.proAccess.authenticated; const isAuthenticated = store.proAccess.authenticated;
const docsHref = "/docs/intro"; const docsHref = "/docs/intro";
const docsActive = pathname?.startsWith("/docs"); const docsActive = pathname?.startsWith("/docs");
const homeHref = "/"; const navItems = [
const homeActive = pathname === "/"; {
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 = const trialPromoLabel =
locale === "en-US" locale === "en-US"
? "New users get 3-day Pro trial" ? "New users get 3-day Pro trial"
@@ -109,6 +142,25 @@ export function HeaderBar({
<span className="subtitle">{t("header.subtitle")}</span> <span className="subtitle">{t("header.subtitle")}</span>
</div> </div>
<nav
className="header-nav"
aria-label={locale === "en-US" ? "Primary" : "主导航"}
>
{navItems.map((item) => {
const Icon = item.icon;
return (
<Link
key={item.href}
href={item.href}
className={clsx("header-nav-link", item.active && "active")}
>
<Icon size={14} strokeWidth={2} />
<span>{item.label}</span>
</Link>
);
})}
</nav>
<div className="header-right"> <div className="header-right">
<div <div
className="lang-switch" className="lang-switch"
@@ -131,16 +183,6 @@ export function HeaderBar({
</button> </button>
</div> </div>
<Link
href={homeHref}
className={clsx("info-btn", homeActive && "active")}
title={t("header.homeAria")}
aria-label={t("header.homeAria")}
>
<House size={14} strokeWidth={2} />
{t("header.home")}
</Link>
<Link <Link
href={docsHref} href={docsHref}
className={clsx("info-btn", docsActive && "active")} className={clsx("info-btn", docsActive && "active")}
@@ -28,6 +28,7 @@ import {
getWeatherSummary, getWeatherSummary,
} from "@/lib/dashboard-utils"; } from "@/lib/dashboard-utils";
import { normalizeObservationSourceLabel } from "@/lib/source-labels"; import { normalizeObservationSourceLabel } from "@/lib/source-labels";
import { Expand, Thermometer, CloudRain, Wind } from "lucide-react";
const loadHistoryModal = () => const loadHistoryModal = () =>
import("@/components/dashboard/HistoryModal").then( import("@/components/dashboard/HistoryModal").then(
@@ -237,7 +238,88 @@ type HomeForecastDay = {
maxTemp: number; 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 (
<div className="home-map-toolbar" aria-label={locale === "en-US" ? "Map layer controls" : "地图图层控件"}>
<div className="home-map-modes">
<button type="button" className="active">
<Thermometer size={14} strokeWidth={2} />
<span>{locale === "en-US" ? "Temperature" : "温度"}</span>
</button>
<button type="button">
<CloudRain size={14} strokeWidth={2} />
<span>{locale === "en-US" ? "Precipitation" : "降水"}</span>
</button>
<button type="button">
<Wind size={14} strokeWidth={2} />
<span>{locale === "en-US" ? "Wind" : "风场"}</span>
</button>
</div>
<button
type="button"
className="home-map-expand"
aria-label={locale === "en-US" ? "Expand map" : "放大地图"}
>
<Expand size={15} strokeWidth={2} />
</button>
</div>
);
}
function projectHomeTrendPoint( function projectHomeTrendPoint(
x: number, x: number,
@@ -965,24 +1047,18 @@ function HomeIntelligencePanel({ snapshots }: { snapshots: CitySnapshot[] }) {
function OpportunityStrip({ function OpportunityStrip({
snapshots, snapshots,
recentCityNames,
}: { }: {
snapshots: CitySnapshot[]; snapshots: CitySnapshot[];
recentCityNames: string[];
}) { }) {
const store = useDashboardStore();
const { locale } = useI18n(); const { locale } = useI18n();
const snapshotByName = useMemo( const store = useDashboardStore();
() => new Map(snapshots.map((snapshot) => [snapshot.city.name, snapshot])), const summaryMetrics = useMemo(
[snapshots], () => buildDashboardSummaryMetrics(snapshots, locale),
[locale, snapshots],
); );
const items = useMemo( const items = useMemo(
() => () => snapshots.filter((snapshot) => snapshot.tradableOpportunity).slice(0, 5),
recentCityNames [snapshots],
.map((cityName) => snapshotByName.get(cityName) || null)
.filter((snapshot): snapshot is CitySnapshot => snapshot != null)
.slice(0, 4),
[recentCityNames, snapshotByName],
); );
if (!items.length) return null; if (!items.length) return null;
@@ -992,14 +1068,28 @@ function OpportunityStrip({
className="home-opportunity-strip" className="home-opportunity-strip"
aria-label={locale === "en-US" ? "Opportunity strip" : "机会条"} aria-label={locale === "en-US" ? "Opportunity strip" : "机会条"}
> >
<div className="opportunity-strip-heading"> <div className="home-summary-grid">
<span>{locale === "en-US" ? "Recently opened" : "最近打开"}</span> {summaryMetrics.map((metric) => (
<strong> <div key={metric.key} className={clsx("home-summary-card", metric.accent && `accent-${metric.accent}`)}>
{locale === "en-US" ? "Recently opened cities" : "最近打开的城市"} <span>{metric.label}</span>
</strong> <strong>{metric.value}</strong>
</div>
))}
</div> </div>
<div className="opportunity-card-grid">
{items.map(({ city, detail, summary }) => { <div className="opportunity-strip-heading">
<div>
<span>{locale === "en-US" ? "Today's Top Opportunities" : "今日最佳机会"}</span>
<strong>
{locale === "en-US" ? "Sorted by edge and tradability" : "按优势与可交易性排序"}
</strong>
</div>
<Link href="/docs/intraday-signal" className="opportunity-view-all">
{locale === "en-US" ? "View all" : "查看全部"}
</Link>
</div>
<div className="opportunity-card-grid top-opportunities">
{items.map(({ city, detail, summary }, index) => {
const symbol = getTempSymbol(city, summary, detail); const symbol = getTempSymbol(city, summary, detail);
const currentTemp = summary?.current?.temp ?? detail?.current?.temp; const currentTemp = summary?.current?.temp ?? detail?.current?.temp;
const debPrediction = summary?.deb?.prediction ?? detail?.deb?.prediction; const debPrediction = summary?.deb?.prediction ?? detail?.deb?.prediction;
@@ -1016,16 +1106,21 @@ function OpportunityStrip({
className="opportunity-card" className="opportunity-card"
onClick={() => void store.focusCity(city.name)} onClick={() => void store.focusCity(city.name)}
> >
<span className={clsx("opportunity-risk-dot", String(tier || "other"))} /> <div className="opportunity-card-header">
<span className="opportunity-city">{localizedCityName}</span> <span className="opportunity-rank">{index + 1}</span>
<span className="opportunity-city">{localizedCityName}</span>
<span className={clsx("opportunity-pill", String(tier || "other"))}>
{getRiskCopy(tier as RiskLevel | undefined, locale)}
</span>
</div>
<span className="opportunity-meta"> <span className="opportunity-meta">
{formatTemperature(currentTemp, symbol)} / DEB{" "} &gt; {formatTemperature(debPrediction, symbol)}{" "}
{formatTemperature(debPrediction, symbol)} {locale === "en-US" ? "target" : "目标"}
</span>
<span className="opportunity-hit">
{locale === "en-US" ? "Hit" : "命中"}{" "}
{formatPercent(city.deb_recent_hit_rate)}
</span> </span>
<div className="opportunity-card-footer">
<span className="opportunity-yes">YES {formatCents(detail?.market_scan?.yes_buy)}</span>
<span className="opportunity-edge">{formatEdge(getMarketEdgeValue(detail))}</span>
</div>
</button> </button>
); );
})} })}
@@ -1039,7 +1134,6 @@ function DashboardScreen() {
const { t } = useI18n(); const { t } = useI18n();
const didAutoFocusRef = useRef(false); const didAutoFocusRef = useRef(false);
const preloadedOpportunityRef = useRef<Set<string>>(new Set()); const preloadedOpportunityRef = useRef<Set<string>>(new Set());
const [recentCityNames, setRecentCityNames] = useState<string[]>([]);
const activeSummary = store.selectedCity const activeSummary = store.selectedCity
? store.citySummariesByName[store.selectedCity] || null ? store.citySummariesByName[store.selectedCity] || null
: null; : null;
@@ -1098,26 +1192,9 @@ function DashboardScreen() {
), ),
[store.cities, store.cityDetailsByName, store.citySummariesByName], [store.cities, store.cityDetailsByName, store.citySummariesByName],
); );
const fallbackRecentCityNames = useMemo(
() => homepageSnapshots.slice(0, 4).map((snapshot) => snapshot.city.name),
[homepageSnapshots],
);
const showHomepageChrome = const showHomepageChrome =
!store.historyState.isOpen && !store.futureModalDate; !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(() => { useEffect(() => {
if (didAutoFocusRef.current) return; if (didAutoFocusRef.current) return;
if (!showHomepageChrome) return; if (!showHomepageChrome) return;
@@ -1141,44 +1218,22 @@ function DashboardScreen() {
}); });
}, [homepageSnapshots, showHomepageChrome, store]); }, [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 ( return (
<div <div
className={clsx( className={clsx(
styles.root, styles.root,
detailChromeStyles.root, detailChromeStyles.root,
modalChromeStyles.root, modalChromeStyles.root,
)} )}
> >
<MapCanvas /> <MapCanvas />
<HomeMapToolbar />
<HeaderBar /> <HeaderBar />
<CitySidebar /> <CitySidebar />
{showHomepageChrome ? ( {showHomepageChrome ? (
<> <>
<HomeIntelligencePanel snapshots={homepageSnapshots} /> <HomeIntelligencePanel snapshots={homepageSnapshots} />
<OpportunityStrip <OpportunityStrip snapshots={homepageSnapshots} />
snapshots={homepageSnapshots}
recentCityNames={recentCityNames.length ? recentCityNames : fallbackRecentCityNames}
/>
</> </>
) : null} ) : null}
{showCitySyncToast ? ( {showCitySyncToast ? (