Refine homepage to match dashboard reference

This commit is contained in:
2569718930@qq.com
2026-04-23 03:16:30 +08:00
parent 81f7e62ac5
commit 558a0031be
4 changed files with 510 additions and 179 deletions
@@ -186,6 +186,16 @@ export function CitySidebar() {
{ key: "low", label: t("sidebar.group.low") }, { key: "low", label: t("sidebar.group.low") },
{ key: "other", label: t("sidebar.group.other") }, { key: "other", label: t("sidebar.group.other") },
]; ];
const syncTime = useMemo(
() =>
new Intl.DateTimeFormat(locale === "en-US" ? "en-US" : "zh-CN", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
}).format(new Date()),
[locale],
);
return ( return (
<nav className="city-list"> <nav className="city-list">
@@ -352,6 +362,17 @@ export function CitySidebar() {
); );
})} })}
</div> </div>
<div className="sidebar-footer">
<div>
{locale === "en-US" ? "Data sync" : "数据更新"} {syncTime}
</div>
<div>
{locale === "en-US"
? "Sources: METAR · Open-Meteo · DEB · Market"
: "数据来源:METAR · Open-Meteo · DEB · 市场"}
</div>
</div>
</nav> </nav>
); );
} }
@@ -97,6 +97,33 @@
gap: 10px; gap: 10px;
} }
.root :global(.home-map-header) {
position: fixed;
top: calc(var(--header-height) + 28px);
left: calc(var(--sidebar-width) + 42px);
z-index: 930;
display: flex;
align-items: center;
}
.root :global(.home-map-title) {
display: flex;
flex-direction: column;
gap: 4px;
}
.root :global(.home-map-title strong) {
color: #f8fafc;
font-size: 15px;
font-weight: 750;
}
.root :global(.home-map-title span) {
color: rgba(148, 163, 184, 0.76);
font-size: 11px;
font-weight: 600;
}
.root :global(.home-map-modes) { .root :global(.home-map-modes) {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -152,6 +179,46 @@
justify-content: center; justify-content: center;
} }
.root :global(.home-map-legend) {
position: fixed;
left: 50%;
bottom: 196px;
transform: translateX(-50%);
z-index: 930;
display: flex;
align-items: center;
gap: 12px;
padding: 8px 14px;
border-radius: 14px;
border: 1px solid rgba(115, 137, 161, 0.18);
background: rgba(6, 12, 22, 0.86);
backdrop-filter: blur(16px);
}
.root :global(.home-map-legend span) {
color: rgba(203, 213, 225, 0.82);
font-size: 10px;
font-weight: 700;
}
.root :global(.home-map-legend-bar) {
width: 178px;
height: 8px;
border-radius: 999px;
background: linear-gradient(90deg, #2563eb, #06b6d4, #22c55e, #eab308, #ef4444);
}
.root :global(.home-map-legend-values) {
display: flex;
align-items: center;
gap: 8px;
}
.root :global(.home-map-legend-values span) {
color: rgba(148, 163, 184, 0.72);
font-size: 9px;
}
.weatherAura { .weatherAura {
position: absolute; position: absolute;
inset: 0; inset: 0;
@@ -262,10 +329,46 @@
.root :global(.brand) { .root :global(.brand) {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 14px; gap: 12px;
min-width: 0; min-width: 0;
} }
.root :global(.brand-mark) {
width: 28px;
height: 28px;
border-radius: 9px;
border: 1px solid rgba(34, 211, 238, 0.28);
background:
radial-gradient(circle at 30% 30%, rgba(34, 211, 238, 0.24), transparent 52%),
linear-gradient(180deg, rgba(11, 22, 40, 0.96), rgba(6, 13, 24, 0.96));
display: inline-flex;
align-items: center;
justify-content: center;
box-shadow: inset 0 0 0 1px rgba(148, 163, 184, 0.08);
}
.root :global(.brand-mark i) {
width: 13px;
height: 13px;
border-radius: 999px;
border: 2px solid #22d3ee;
position: relative;
}
.root :global(.brand-mark i::before),
.root :global(.brand-mark i::after) {
content: "";
position: absolute;
inset: -4px;
border-radius: 999px;
border: 1px solid rgba(34, 211, 238, 0.34);
}
.root :global(.brand-mark i::after) {
inset: -8px;
border-color: rgba(59, 130, 246, 0.18);
}
.root :global(.brand h1) { .root :global(.brand h1) {
font-size: 18px; font-size: 18px;
font-weight: 800; font-weight: 800;
@@ -297,13 +400,14 @@
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 7px; gap: 7px;
padding: 10px 12px; padding: 12px 18px;
border-radius: 10px; border-radius: 10px;
color: rgba(203, 213, 225, 0.82); color: rgba(203, 213, 225, 0.82);
text-decoration: none; text-decoration: none;
font-size: 13px; font-size: 14px;
font-weight: 650; font-weight: 550;
transition: var(--transition); transition: var(--transition);
position: relative;
} }
.root :global(.header-nav-link:hover) { .root :global(.header-nav-link:hover) {
@@ -313,8 +417,19 @@
.root :global(.header-nav-link.active) { .root :global(.header-nav-link.active) {
color: #f8fafc; color: #f8fafc;
background: rgba(8, 145, 178, 0.12); background: transparent;
box-shadow: inset 0 -2px 0 var(--accent-cyan); box-shadow: none;
}
.root :global(.header-nav-link.active::after) {
content: "";
position: absolute;
left: 18px;
right: 18px;
bottom: 4px;
height: 2px;
border-radius: 999px;
background: var(--accent-cyan);
} }
.root :global(.header-right) { .root :global(.header-right) {
@@ -414,6 +529,40 @@
.root :global(.refresh-btn.spinning) { .root :global(.refresh-btn.spinning) {
animation: spin 1s linear infinite; animation: spin 1s linear infinite;
} }
.root :global(.header-utility-btn) {
min-width: 32px;
height: 32px;
padding: 0 12px;
border-radius: 8px;
border: 1px solid rgba(115, 137, 161, 0.18);
background: rgba(8, 15, 27, 0.82);
color: rgba(226, 232, 240, 0.8);
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
text-decoration: none;
font-size: 12px;
font-weight: 650;
transition: var(--transition);
}
.root :global(.header-utility-btn:hover) {
color: #f8fafc;
border-color: rgba(34, 211, 238, 0.34);
background: rgba(11, 22, 40, 0.94);
}
.root :global(.header-utility-btn.active) {
color: #f8fafc;
border-color: rgba(59, 130, 246, 0.34);
}
.root :global(.header-utility-btn.more) {
padding: 0;
width: 32px;
}
@keyframes spin { @keyframes spin {
from { from {
transform: rotate(0deg); transform: rotate(0deg);
@@ -506,6 +655,14 @@
border-radius: 2px; border-radius: 2px;
} }
.root :global(.sidebar-footer) {
border-top: 1px solid var(--border-subtle);
padding: 10px 12px;
color: rgba(148, 163, 184, 0.72);
font-size: 10px;
line-height: 1.55;
}
.root :global(.city-group) { .root :global(.city-group) {
border: 1px solid rgba(115, 137, 161, 0.12); border: 1px solid rgba(115, 137, 161, 0.12);
border-radius: 14px; border-radius: 14px;
@@ -811,16 +968,16 @@
flex-shrink: 0; flex-shrink: 0;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 5px; gap: 6px;
min-height: 24px; min-height: 26px;
border-radius: 999px; border-radius: 999px;
padding: 4px 9px 4px 7px; padding: 5px 10px 5px 8px;
border: 1px solid rgba(148, 163, 184, 0.16); border: 1px solid rgba(148, 163, 184, 0.2);
background: rgba(9, 18, 36, 0.38); background: rgba(9, 18, 36, 0.58);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03);
color: rgba(226, 232, 240, 0.82); color: rgba(226, 232, 240, 0.88);
font-size: 11px; font-size: 10px;
font-weight: 800; font-weight: 900;
letter-spacing: 0.01em; letter-spacing: 0.01em;
white-space: nowrap; white-space: nowrap;
} }
@@ -836,21 +993,21 @@
} }
.root :global(.home-risk-badge.high) { .root :global(.home-risk-badge.high) {
border-color: rgba(248, 113, 113, 0.2); border-color: rgba(248, 113, 113, 0.34);
color: #fda4af; color: #fda4af;
background: rgba(69, 10, 10, 0.18); background: rgba(69, 10, 10, 0.32);
} }
.root :global(.home-risk-badge.medium) { .root :global(.home-risk-badge.medium) {
border-color: rgba(251, 191, 36, 0.18); border-color: rgba(251, 191, 36, 0.3);
color: #fbbf24; color: #fcd34d;
background: rgba(67, 36, 6, 0.16); background: rgba(67, 36, 6, 0.28);
} }
.root :global(.home-risk-badge.low) { .root :global(.home-risk-badge.low) {
border-color: rgba(52, 211, 153, 0.18); border-color: rgba(52, 211, 153, 0.28);
color: #34d399; color: #86efac;
background: rgba(7, 47, 35, 0.16); background: rgba(7, 47, 35, 0.28);
} }
.root :global(.home-panel-subtitle) { .root :global(.home-panel-subtitle) {
@@ -1044,6 +1201,29 @@
background: rgba(148, 163, 184, 0.1); background: rgba(148, 163, 184, 0.1);
} }
.root :global(.home-panel-header) {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.root :global(.home-focus-title) {
color: #f8fafc;
font-size: 12px;
font-weight: 700;
}
.root :global(.home-why-link) {
border: none;
background: transparent;
color: #60a5fa;
cursor: pointer;
font: inherit;
font-size: 11px;
font-weight: 650;
}
.root :global(.home-top-opportunity-label) { .root :global(.home-top-opportunity-label) {
display: inline-flex; display: inline-flex;
align-self: flex-start; align-self: flex-start;
@@ -1774,7 +1954,7 @@
} }
.root :global(.home-summary-card) { .root :global(.home-summary-card) {
min-height: 92px; min-height: 118px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: space-between; justify-content: space-between;
@@ -1784,23 +1964,57 @@
background: rgba(8, 15, 28, 0.7); background: rgba(8, 15, 28, 0.7);
} }
.root :global(.home-summary-card span) { .root :global(.home-summary-card-head) {
color: rgba(148, 163, 184, 0.88); display: flex;
font-size: 12px; align-items: center;
font-weight: 650; justify-content: space-between;
gap: 12px;
} }
.root :global(.home-summary-card strong) { .root :global(.home-summary-card-head strong) {
color: #f8fafc;
font-size: 14px;
font-weight: 760;
line-height: 1.2;
}
.root :global(.home-summary-card-head span) {
color: #60a5fa;
font-size: 10px;
font-weight: 700;
}
.root :global(.home-summary-card-body) {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
align-items: end;
}
.root :global(.home-summary-stat) {
display: flex;
flex-direction: column;
gap: 4px;
}
.root :global(.home-summary-stat b) {
color: #f8fafc; color: #f8fafc;
font-size: 28px; font-size: 28px;
font-weight: 850; font-weight: 850;
line-height: 1;
letter-spacing: -0.04em; letter-spacing: -0.04em;
} }
.root :global(.home-summary-card.accent-red strong) { color: #fb7185; } .root :global(.home-summary-stat span) {
.root :global(.home-summary-card.accent-amber strong) { color: #fbbf24; } color: rgba(148, 163, 184, 0.82);
.root :global(.home-summary-card.accent-green strong) { color: #4ade80; } font-size: 10px;
.root :global(.home-summary-card.accent-cyan strong) { color: #22d3ee; } font-weight: 650;
}
.root :global(.home-summary-stat b.accent-red) { color: #fb7185; }
.root :global(.home-summary-stat b.accent-amber) { color: #fbbf24; }
.root :global(.home-summary-stat b.accent-green) { color: #4ade80; }
.root :global(.home-summary-stat b.accent-cyan) { color: #22d3ee; }
.root :global(.opportunity-strip-heading) { .root :global(.opportunity-strip-heading) {
display: flex; display: flex;
@@ -1875,6 +2089,12 @@
gap: 8px; gap: 8px;
} }
.root :global(.opportunity-price-pair) {
display: inline-flex;
align-items: center;
gap: 10px;
}
.root :global(.opportunity-rank) { .root :global(.opportunity-rank) {
width: 24px; width: 24px;
height: 24px; height: 24px;
@@ -1937,6 +2157,12 @@
font-weight: 800; font-weight: 800;
} }
.root :global(.opportunity-no) {
color: #fda4af;
font-size: 12px;
font-weight: 800;
}
.root :global(.opportunity-edge) { .root :global(.opportunity-edge) {
color: rgba(34, 211, 238, 0.9); color: rgba(34, 211, 238, 0.9);
font-size: 18px; font-size: 18px;
+57 -97
View File
@@ -8,11 +8,7 @@ import {
UserRound, UserRound,
RotateCw, RotateCw,
BookOpen, BookOpen,
Sparkles, MoreHorizontal,
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";
@@ -39,7 +35,7 @@ export function HeaderBar({
refreshSpinning?: boolean; refreshSpinning?: boolean;
}) { }) {
const store = useDashboardStore(); const store = useDashboardStore();
const { locale, setLocale, t } = useI18n(); const { locale, t } = useI18n();
const pathname = usePathname(); const pathname = usePathname();
const isAuthenticated = store.proAccess.authenticated; const isAuthenticated = store.proAccess.authenticated;
const docsHref = "/docs/intro"; const docsHref = "/docs/intro";
@@ -49,37 +45,28 @@ export function HeaderBar({
href: "/", href: "/",
label: locale === "en-US" ? "Dashboard" : "总览", label: locale === "en-US" ? "Dashboard" : "总览",
active: pathname === "/", active: pathname === "/",
icon: House,
}, },
{ {
href: "/docs/intraday-signal", href: "/docs/intraday-signal",
label: locale === "en-US" ? "Markets" : "市场", label: locale === "en-US" ? "Markets" : "市场",
active: pathname?.startsWith("/docs/intraday-signal"), active: pathname?.startsWith("/docs/intraday-signal"),
icon: CandlestickChart,
}, },
{ {
href: "/docs/model-stack-deb", href: "/docs/model-stack-deb",
label: locale === "en-US" ? "Analytics" : "分析", label: locale === "en-US" ? "Analytics" : "分析",
active: pathname?.startsWith("/docs/model-stack-deb"), active: pathname?.startsWith("/docs/model-stack-deb"),
icon: BarChart3,
}, },
{ {
href: "/docs/history-review", href: "/docs/history-review",
label: locale === "en-US" ? "History" : "历史", label: locale === "en-US" ? "History" : "历史",
active: pathname?.startsWith("/docs/history-review"), active: pathname?.startsWith("/docs/history-review"),
icon: BookOpen,
}, },
{ {
href: "/docs/alert-playbook", href: "/docs/alert-playbook",
label: locale === "en-US" ? "Alerts" : "提醒", label: locale === "en-US" ? "Alerts" : "预警",
active: pathname?.startsWith("/docs/alert-playbook"), active: pathname?.startsWith("/docs/alert-playbook"),
icon: Bell,
}, },
]; ];
const trialPromoLabel =
locale === "en-US"
? "New users get 3-day Pro trial"
: "新用户可免费体验 3 天 Pro";
const isRefreshing = refreshSpinning ?? store.loadingState.refresh; const isRefreshing = refreshSpinning ?? store.loadingState.refresh;
const handleRefresh = () => { const handleRefresh = () => {
if (refreshAction) { if (refreshAction) {
@@ -91,9 +78,6 @@ export function HeaderBar({
const accountHref = isAuthenticated const accountHref = isAuthenticated
? "/account" ? "/account"
: "/auth/login?next=%2Faccount"; : "/auth/login?next=%2Faccount";
const accountLabel = isAuthenticated
? t("header.account")
: t("header.signIn");
const accountAria = isAuthenticated const accountAria = isAuthenticated
? t("header.accountAria") ? t("header.accountAria")
: t("header.signInAria"); : t("header.signInAria");
@@ -138,6 +122,9 @@ export function HeaderBar({
return ( return (
<header className="header"> <header className="header">
<div className="brand"> <div className="brand">
<span className="brand-mark" aria-hidden="true">
<i />
</span>
<h1>PolyWeather</h1> <h1>PolyWeather</h1>
<span className="subtitle">{t("header.subtitle")}</span> <span className="subtitle">{t("header.subtitle")}</span>
</div> </div>
@@ -146,87 +133,18 @@ export function HeaderBar({
className="header-nav" className="header-nav"
aria-label={locale === "en-US" ? "Primary" : "主导航"} aria-label={locale === "en-US" ? "Primary" : "主导航"}
> >
{navItems.map((item) => { {navItems.map((item) => (
const Icon = item.icon; <Link
return ( key={item.href}
<Link href={item.href}
key={item.href} className={clsx("header-nav-link", item.active && "active")}
href={item.href} >
className={clsx("header-nav-link", item.active && "active")} <span>{item.label}</span>
> </Link>
<Icon size={14} strokeWidth={2} /> ))}
<span>{item.label}</span>
</Link>
);
})}
</nav> </nav>
<div className="header-right"> <div className="header-right">
<div
className="lang-switch"
role="group"
aria-label={t("header.langAria")}
>
<button
type="button"
className={clsx("lang-btn", locale === "zh-CN" && "active")}
onClick={() => setLocale("zh-CN")}
>
{t("header.langZh")}
</button>
<button
type="button"
className={clsx("lang-btn", locale === "en-US" && "active")}
onClick={() => setLocale("en-US")}
>
{t("header.langEn")}
</button>
</div>
<Link
href={docsHref}
className={clsx("info-btn", docsActive && "active")}
title={t("header.docsAria")}
aria-label={t("header.docsAria")}
>
<BookOpen size={14} strokeWidth={2} />
{t("header.docs")}
</Link>
<Link
href="/account"
className="trial-promo-badge"
title={trialPromoLabel}
aria-label={trialPromoLabel}
>
<Sparkles size={12} strokeWidth={2} />
<span>{trialPromoLabel}</span>
</Link>
<Link
href={accountHref}
className="account-btn"
title={accountAria}
aria-label={accountAria}
>
{isAuthenticated ? <UserRound size={14} /> : <LogIn size={14} />}
<span>{accountLabel}</span>
</Link>
{showRenewReminder ? (
<Link
href="/account"
className={clsx(
"account-renew-badge",
!store.proAccess.subscriptionActive && "expired",
)}
title={renewReminderLabel}
aria-label={renewReminderLabel}
>
<span>{renewReminderLabel}</span>
</Link>
) : null}
<div className="live-badge" id="liveBadge"> <div className="live-badge" id="liveBadge">
<span className="pulse-dot" /> <span className="pulse-dot" />
<span>{t("header.live")}</span> <span>{t("header.live")}</span>
@@ -241,6 +159,48 @@ export function HeaderBar({
> >
<RotateCw size={16} strokeWidth={2} /> <RotateCw size={16} strokeWidth={2} />
</button> </button>
<Link
href={docsHref}
className={clsx("header-utility-btn", docsActive && "active")}
title={t("header.docsAria")}
aria-label={t("header.docsAria")}
>
<BookOpen size={14} strokeWidth={2} />
<span>{t("header.docs")}</span>
</Link>
<Link
href={accountHref}
className="header-utility-btn"
title={accountAria}
aria-label={accountAria}
>
{isAuthenticated ? <UserRound size={14} /> : <LogIn size={14} />}
</Link>
<button
type="button"
className="header-utility-btn more"
aria-label={locale === "en-US" ? "More actions" : "更多操作"}
title={locale === "en-US" ? "More actions" : "更多操作"}
>
<MoreHorizontal size={16} strokeWidth={2} />
</button>
{showRenewReminder ? (
<Link
href="/account"
className={clsx(
"account-renew-badge",
!store.proAccess.subscriptionActive && "expired",
)}
title={renewReminderLabel}
aria-label={renewReminderLabel}
>
<span>{renewReminderLabel}</span>
</Link>
) : null}
</div> </div>
</header> </header>
); );
@@ -238,21 +238,36 @@ type HomeForecastDay = {
maxTemp: number; maxTemp: number;
}; };
type HomeSummaryMetric = { type HomeSummaryCard = {
key: string; key: string;
label: string; title: string;
value: string; linkLabel?: string;
accent?: "green" | "amber" | "red" | "cyan"; items: Array<{
label: string;
value: string;
accent?: "green" | "amber" | "red" | "cyan";
}>;
}; };
function buildDashboardSummaryMetrics( function buildDashboardSummaryCards(
snapshots: CitySnapshot[], snapshots: CitySnapshot[],
locale: string, locale: string,
): HomeSummaryMetric[] { ): HomeSummaryCard[] {
const topTradable = snapshots.filter((snapshot) => snapshot.tradableOpportunity); const topTradable = snapshots.filter((snapshot) => snapshot.tradableOpportunity);
const highCount = snapshots.filter((snapshot) => snapshot.city.deb_recent_tier === "high").length; 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 mediumCount = snapshots.filter((snapshot) => snapshot.city.deb_recent_tier === "medium").length;
const lowCount = snapshots.filter((snapshot) => snapshot.city.deb_recent_tier === "low").length; const lowCount = snapshots.filter((snapshot) => snapshot.city.deb_recent_tier === "low").length;
const strongAgreement = snapshots.filter(
(snapshot) => Number(snapshot.city.deb_recent_hit_rate ?? 0) >= 0.66,
).length;
const mediumAgreement = snapshots.filter((snapshot) => {
const rate = Number(snapshot.city.deb_recent_hit_rate ?? 0);
return rate >= 0.4 && rate < 0.66;
}).length;
const weakAgreement = Math.max(
snapshots.length - strongAgreement - mediumAgreement,
0,
);
const avgEdge = topTradable.length const avgEdge = topTradable.length
? topTradable.reduce((sum, snapshot) => { ? topTradable.reduce((sum, snapshot) => {
const edgeValue = Number(getMarketEdgeValue(snapshot.detail)); const edgeValue = Number(getMarketEdgeValue(snapshot.detail));
@@ -266,28 +281,92 @@ function buildDashboardSummaryMetrics(
return [ return [
{ {
key: "high", key: "opportunities",
label: locale === "en-US" ? "High risk" : "高风险", title: locale === "en-US" ? "Today's Opportunities" : "今日机会分布",
value: String(highCount), items: [
accent: "red", {
label: locale === "en-US" ? "High risk" : "高风险",
value: String(highCount),
accent: "red",
},
{
label: locale === "en-US" ? "Medium risk" : "中风险",
value: String(mediumCount),
accent: "amber",
},
{
label: locale === "en-US" ? "Low risk" : "低风险",
value: String(lowCount),
accent: "green",
},
],
}, },
{ {
key: "medium", key: "market-summary",
label: locale === "en-US" ? "Medium risk" : "中风险", title: locale === "en-US" ? "Market Summary" : "市场概览",
value: String(mediumCount), items: [
accent: "amber", {
label: locale === "en-US" ? "Total markets" : "总市场数",
value: String(snapshots.length),
},
{
label: locale === "en-US" ? "Active" : "活跃市场",
value: String(topTradable.length),
accent: "cyan",
},
{
label: locale === "en-US" ? "Avg. edge" : "平均优势",
value: formatEdge(avgEdge),
accent: "green",
},
],
}, },
{ {
key: "low", key: "model-agreement",
label: locale === "en-US" ? "Low risk" : "低风险", title: locale === "en-US" ? "Model Agreement" : "模型一致性",
value: String(lowCount), items: [
accent: "green", {
label: locale === "en-US" ? "High" : "高",
value: `${strongAgreement}`,
accent: "green",
},
{
label: locale === "en-US" ? "Medium" : "中",
value: `${mediumAgreement}`,
accent: "amber",
},
{
label: locale === "en-US" ? "Low" : "低",
value: `${weakAgreement}`,
accent: "red",
},
],
}, },
{ {
key: "edge", key: "impact-window",
label: locale === "en-US" ? "Avg. edge" : "平均优势", title: locale === "en-US" ? "High Impact Window" : "高影响窗口",
value: formatEdge(avgEdge), items: [
accent: "cyan", {
label: locale === "en-US" ? "Next 6 hours" : "未来 6 小时",
value:
locale === "en-US"
? `${Math.min(topTradable.length, 12)} markets`
: `${Math.min(topTradable.length, 12)} 个市场`,
},
{
label: locale === "en-US" ? "Focus city" : "焦点城市",
value: topTradable[0]?.city.display_name || "--",
accent: "cyan",
},
{
label: locale === "en-US" ? "Best edge" : "最佳优势",
value:
topTradable.length > 0
? formatEdge(getMarketEdgeValue(topTradable[0]?.detail))
: "--",
accent: "green",
},
],
}, },
]; ];
} }
@@ -295,29 +374,50 @@ function buildDashboardSummaryMetrics(
function HomeMapToolbar() { function HomeMapToolbar() {
const { locale } = useI18n(); const { locale } = useI18n();
return ( return (
<div className="home-map-toolbar" aria-label={locale === "en-US" ? "Map layer controls" : "地图图层控件"}> <>
<div className="home-map-modes"> <div className="home-map-header">
<button type="button" className="active"> <div className="home-map-title">
<Thermometer size={14} strokeWidth={2} /> <strong>{locale === "en-US" ? "Global weather regime" : "全球天气态势"}</strong>
<span>{locale === "en-US" ? "Temperature" : "温度"}</span> <span>{locale === "en-US" ? "Live monitored weather derivatives board" : "天气衍生品实时监控面板"}</span>
</button> </div>
<button type="button"> </div>
<CloudRain size={14} strokeWidth={2} /> <div className="home-map-toolbar" aria-label={locale === "en-US" ? "Map layer controls" : "地图图层控件"}>
<span>{locale === "en-US" ? "Precipitation" : "降水"}</span> <div className="home-map-modes">
</button> <button type="button" className="active">
<button type="button"> <Thermometer size={14} strokeWidth={2} />
<Wind size={14} strokeWidth={2} /> <span>{locale === "en-US" ? "Temperature" : "温度"}</span>
<span>{locale === "en-US" ? "Wind" : "风场"}</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> </button>
</div> </div>
<button <div className="home-map-legend" aria-hidden="true">
type="button" <span>{locale === "en-US" ? "Temperature (°F)" : "温度 (°F)"}</span>
className="home-map-expand" <div className="home-map-legend-bar" />
aria-label={locale === "en-US" ? "Expand map" : "放大地图"} <div className="home-map-legend-values">
> <span>-4</span>
<Expand size={15} strokeWidth={2} /> <span>14</span>
</button> <span>32</span>
</div> <span>50</span>
<span>68</span>
<span>86</span>
<span>104</span>
</div>
</div>
</>
); );
} }
@@ -771,6 +871,15 @@ function HomeIntelligencePanel({ snapshots }: { snapshots: CitySnapshot[] }) {
× ×
</button> </button>
<div className="home-panel-header">
<span className="home-focus-title">
{locale === "en-US" ? "Focus city" : "焦点城市"}
</span>
<button type="button" className="home-why-link">
{locale === "en-US" ? "Why this city?" : "为什么是它?"}
</button>
</div>
<div className="home-top-opportunity-label"> <div className="home-top-opportunity-label">
{showOpportunityLabel {showOpportunityLabel
? locale === "en-US" ? locale === "en-US"
@@ -1052,8 +1161,8 @@ function OpportunityStrip({
}) { }) {
const { locale } = useI18n(); const { locale } = useI18n();
const store = useDashboardStore(); const store = useDashboardStore();
const summaryMetrics = useMemo( const summaryCards = useMemo(
() => buildDashboardSummaryMetrics(snapshots, locale), () => buildDashboardSummaryCards(snapshots, locale),
[locale, snapshots], [locale, snapshots],
); );
const items = useMemo( const items = useMemo(
@@ -1069,10 +1178,22 @@ function OpportunityStrip({
aria-label={locale === "en-US" ? "Opportunity strip" : "机会条"} aria-label={locale === "en-US" ? "Opportunity strip" : "机会条"}
> >
<div className="home-summary-grid"> <div className="home-summary-grid">
{summaryMetrics.map((metric) => ( {summaryCards.map((card) => (
<div key={metric.key} className={clsx("home-summary-card", metric.accent && `accent-${metric.accent}`)}> <div key={card.key} className="home-summary-card">
<span>{metric.label}</span> <div className="home-summary-card-head">
<strong>{metric.value}</strong> <strong>{card.title}</strong>
<span>{locale === "en-US" ? "View all" : "查看全部"}</span>
</div>
<div className="home-summary-card-body">
{card.items.map((item) => (
<div key={`${card.key}-${item.label}`} className="home-summary-stat">
<b className={item.accent ? `accent-${item.accent}` : undefined}>
{item.value}
</b>
<span>{item.label}</span>
</div>
))}
</div>
</div> </div>
))} ))}
</div> </div>
@@ -1118,7 +1239,10 @@ function OpportunityStrip({
{locale === "en-US" ? "target" : "目标"} {locale === "en-US" ? "target" : "目标"}
</span> </span>
<div className="opportunity-card-footer"> <div className="opportunity-card-footer">
<span className="opportunity-yes">YES {formatCents(detail?.market_scan?.yes_buy)}</span> <div className="opportunity-price-pair">
<span className="opportunity-yes">YES {formatCents(detail?.market_scan?.yes_buy)}</span>
<span className="opportunity-no">NO {formatCents(detail?.market_scan?.no_buy)}</span>
</div>
<span className="opportunity-edge">{formatEdge(getMarketEdgeValue(detail))}</span> <span className="opportunity-edge">{formatEdge(getMarketEdgeValue(detail))}</span>
</div> </div>
</button> </button>