Rework homepage toward reference dashboard
This commit is contained in:
@@ -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<RiskGroupKey, boolean>
|
||||
>(DEFAULT_EXPANDED_GROUPS);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const cityItemRefs = useRef<Record<string, HTMLButtonElement | null>>({});
|
||||
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() {
|
||||
<span className="city-count">{store.cities.length}</span>
|
||||
</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">
|
||||
{groupMeta.map((group) => {
|
||||
const citiesInGroup = groupedCities[group.key];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
<span className="subtitle">{t("header.subtitle")}</span>
|
||||
</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="lang-switch"
|
||||
@@ -131,16 +183,6 @@ export function HeaderBar({
|
||||
</button>
|
||||
</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
|
||||
href={docsHref}
|
||||
className={clsx("info-btn", docsActive && "active")}
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
getWeatherSummary,
|
||||
} from "@/lib/dashboard-utils";
|
||||
import { normalizeObservationSourceLabel } from "@/lib/source-labels";
|
||||
import { Expand, Thermometer, CloudRain, Wind } from "lucide-react";
|
||||
|
||||
const loadHistoryModal = () =>
|
||||
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 (
|
||||
<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(
|
||||
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" : "机会条"}
|
||||
>
|
||||
<div className="opportunity-strip-heading">
|
||||
<span>{locale === "en-US" ? "Recently opened" : "最近打开"}</span>
|
||||
<strong>
|
||||
{locale === "en-US" ? "Recently opened cities" : "最近打开的城市"}
|
||||
</strong>
|
||||
<div className="home-summary-grid">
|
||||
{summaryMetrics.map((metric) => (
|
||||
<div key={metric.key} className={clsx("home-summary-card", metric.accent && `accent-${metric.accent}`)}>
|
||||
<span>{metric.label}</span>
|
||||
<strong>{metric.value}</strong>
|
||||
</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 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)}
|
||||
>
|
||||
<span className={clsx("opportunity-risk-dot", String(tier || "other"))} />
|
||||
<span className="opportunity-city">{localizedCityName}</span>
|
||||
<div className="opportunity-card-header">
|
||||
<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">
|
||||
{formatTemperature(currentTemp, symbol)} / DEB{" "}
|
||||
{formatTemperature(debPrediction, symbol)}
|
||||
</span>
|
||||
<span className="opportunity-hit">
|
||||
{locale === "en-US" ? "Hit" : "命中"}{" "}
|
||||
{formatPercent(city.deb_recent_hit_rate)}
|
||||
> {formatTemperature(debPrediction, symbol)}{" "}
|
||||
{locale === "en-US" ? "target" : "目标"}
|
||||
</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>
|
||||
);
|
||||
})}
|
||||
@@ -1039,7 +1134,6 @@ function DashboardScreen() {
|
||||
const { t } = useI18n();
|
||||
const didAutoFocusRef = useRef(false);
|
||||
const preloadedOpportunityRef = useRef<Set<string>>(new Set());
|
||||
const [recentCityNames, setRecentCityNames] = useState<string[]>([]);
|
||||
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 (
|
||||
<div
|
||||
className={clsx(
|
||||
styles.root,
|
||||
<div
|
||||
className={clsx(
|
||||
styles.root,
|
||||
detailChromeStyles.root,
|
||||
modalChromeStyles.root,
|
||||
)}
|
||||
>
|
||||
<MapCanvas />
|
||||
<HomeMapToolbar />
|
||||
<HeaderBar />
|
||||
<CitySidebar />
|
||||
{showHomepageChrome ? (
|
||||
<>
|
||||
<HomeIntelligencePanel snapshots={homepageSnapshots} />
|
||||
<OpportunityStrip
|
||||
snapshots={homepageSnapshots}
|
||||
recentCityNames={recentCityNames.length ? recentCityNames : fallbackRecentCityNames}
|
||||
/>
|
||||
<OpportunityStrip snapshots={homepageSnapshots} />
|
||||
</>
|
||||
) : null}
|
||||
{showCitySyncToast ? (
|
||||
|
||||
Reference in New Issue
Block a user