Refine homepage to match dashboard reference
This commit is contained in:
@@ -186,6 +186,16 @@ export function CitySidebar() {
|
||||
{ key: "low", label: t("sidebar.group.low") },
|
||||
{ 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 (
|
||||
<nav className="city-list">
|
||||
@@ -352,6 +362,17 @@ export function CitySidebar() {
|
||||
);
|
||||
})}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -97,6 +97,33 @@
|
||||
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) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -152,6 +179,46 @@
|
||||
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 {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
@@ -262,10 +329,46 @@
|
||||
.root :global(.brand) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
gap: 12px;
|
||||
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) {
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
@@ -297,13 +400,14 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 10px 12px;
|
||||
padding: 12px 18px;
|
||||
border-radius: 10px;
|
||||
color: rgba(203, 213, 225, 0.82);
|
||||
text-decoration: none;
|
||||
font-size: 13px;
|
||||
font-weight: 650;
|
||||
font-size: 14px;
|
||||
font-weight: 550;
|
||||
transition: var(--transition);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.root :global(.header-nav-link:hover) {
|
||||
@@ -313,8 +417,19 @@
|
||||
|
||||
.root :global(.header-nav-link.active) {
|
||||
color: #f8fafc;
|
||||
background: rgba(8, 145, 178, 0.12);
|
||||
box-shadow: inset 0 -2px 0 var(--accent-cyan);
|
||||
background: transparent;
|
||||
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) {
|
||||
@@ -414,6 +529,40 @@
|
||||
.root :global(.refresh-btn.spinning) {
|
||||
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 {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
@@ -506,6 +655,14 @@
|
||||
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) {
|
||||
border: 1px solid rgba(115, 137, 161, 0.12);
|
||||
border-radius: 14px;
|
||||
@@ -811,16 +968,16 @@
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
min-height: 24px;
|
||||
gap: 6px;
|
||||
min-height: 26px;
|
||||
border-radius: 999px;
|
||||
padding: 4px 9px 4px 7px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.16);
|
||||
background: rgba(9, 18, 36, 0.38);
|
||||
padding: 5px 10px 5px 8px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||
background: rgba(9, 18, 36, 0.58);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03);
|
||||
color: rgba(226, 232, 240, 0.82);
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
color: rgba(226, 232, 240, 0.88);
|
||||
font-size: 10px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.01em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -836,21 +993,21 @@
|
||||
}
|
||||
|
||||
.root :global(.home-risk-badge.high) {
|
||||
border-color: rgba(248, 113, 113, 0.2);
|
||||
border-color: rgba(248, 113, 113, 0.34);
|
||||
color: #fda4af;
|
||||
background: rgba(69, 10, 10, 0.18);
|
||||
background: rgba(69, 10, 10, 0.32);
|
||||
}
|
||||
|
||||
.root :global(.home-risk-badge.medium) {
|
||||
border-color: rgba(251, 191, 36, 0.18);
|
||||
color: #fbbf24;
|
||||
background: rgba(67, 36, 6, 0.16);
|
||||
border-color: rgba(251, 191, 36, 0.3);
|
||||
color: #fcd34d;
|
||||
background: rgba(67, 36, 6, 0.28);
|
||||
}
|
||||
|
||||
.root :global(.home-risk-badge.low) {
|
||||
border-color: rgba(52, 211, 153, 0.18);
|
||||
color: #34d399;
|
||||
background: rgba(7, 47, 35, 0.16);
|
||||
border-color: rgba(52, 211, 153, 0.28);
|
||||
color: #86efac;
|
||||
background: rgba(7, 47, 35, 0.28);
|
||||
}
|
||||
|
||||
.root :global(.home-panel-subtitle) {
|
||||
@@ -1044,6 +1201,29 @@
|
||||
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) {
|
||||
display: inline-flex;
|
||||
align-self: flex-start;
|
||||
@@ -1774,7 +1954,7 @@
|
||||
}
|
||||
|
||||
.root :global(.home-summary-card) {
|
||||
min-height: 92px;
|
||||
min-height: 118px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
@@ -1784,23 +1964,57 @@
|
||||
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-head) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
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;
|
||||
font-size: 28px;
|
||||
font-weight: 850;
|
||||
line-height: 1;
|
||||
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(.home-summary-stat span) {
|
||||
color: rgba(148, 163, 184, 0.82);
|
||||
font-size: 10px;
|
||||
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) {
|
||||
display: flex;
|
||||
@@ -1875,6 +2089,12 @@
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.root :global(.opportunity-price-pair) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.root :global(.opportunity-rank) {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
@@ -1937,6 +2157,12 @@
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.root :global(.opportunity-no) {
|
||||
color: #fda4af;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.root :global(.opportunity-edge) {
|
||||
color: rgba(34, 211, 238, 0.9);
|
||||
font-size: 18px;
|
||||
|
||||
@@ -8,11 +8,7 @@ import {
|
||||
UserRound,
|
||||
RotateCw,
|
||||
BookOpen,
|
||||
Sparkles,
|
||||
House,
|
||||
Bell,
|
||||
BarChart3,
|
||||
CandlestickChart,
|
||||
MoreHorizontal,
|
||||
} from "lucide-react";
|
||||
import { useDashboardStore } from "@/hooks/useDashboardStore";
|
||||
import { useI18n } from "@/hooks/useI18n";
|
||||
@@ -39,7 +35,7 @@ export function HeaderBar({
|
||||
refreshSpinning?: boolean;
|
||||
}) {
|
||||
const store = useDashboardStore();
|
||||
const { locale, setLocale, t } = useI18n();
|
||||
const { locale, t } = useI18n();
|
||||
const pathname = usePathname();
|
||||
const isAuthenticated = store.proAccess.authenticated;
|
||||
const docsHref = "/docs/intro";
|
||||
@@ -49,37 +45,28 @@ export function HeaderBar({
|
||||
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" : "提醒",
|
||||
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"
|
||||
: "新用户可免费体验 3 天 Pro";
|
||||
const isRefreshing = refreshSpinning ?? store.loadingState.refresh;
|
||||
const handleRefresh = () => {
|
||||
if (refreshAction) {
|
||||
@@ -91,9 +78,6 @@ export function HeaderBar({
|
||||
const accountHref = isAuthenticated
|
||||
? "/account"
|
||||
: "/auth/login?next=%2Faccount";
|
||||
const accountLabel = isAuthenticated
|
||||
? t("header.account")
|
||||
: t("header.signIn");
|
||||
const accountAria = isAuthenticated
|
||||
? t("header.accountAria")
|
||||
: t("header.signInAria");
|
||||
@@ -138,6 +122,9 @@ export function HeaderBar({
|
||||
return (
|
||||
<header className="header">
|
||||
<div className="brand">
|
||||
<span className="brand-mark" aria-hidden="true">
|
||||
<i />
|
||||
</span>
|
||||
<h1>PolyWeather</h1>
|
||||
<span className="subtitle">{t("header.subtitle")}</span>
|
||||
</div>
|
||||
@@ -146,87 +133,18 @@ export function HeaderBar({
|
||||
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>
|
||||
);
|
||||
})}
|
||||
{navItems.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={clsx("header-nav-link", item.active && "active")}
|
||||
>
|
||||
<span>{item.label}</span>
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<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">
|
||||
<span className="pulse-dot" />
|
||||
<span>{t("header.live")}</span>
|
||||
@@ -241,6 +159,48 @@ export function HeaderBar({
|
||||
>
|
||||
<RotateCw size={16} strokeWidth={2} />
|
||||
</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>
|
||||
</header>
|
||||
);
|
||||
|
||||
@@ -238,21 +238,36 @@ type HomeForecastDay = {
|
||||
maxTemp: number;
|
||||
};
|
||||
|
||||
type HomeSummaryMetric = {
|
||||
type HomeSummaryCard = {
|
||||
key: string;
|
||||
label: string;
|
||||
value: string;
|
||||
accent?: "green" | "amber" | "red" | "cyan";
|
||||
title: string;
|
||||
linkLabel?: string;
|
||||
items: Array<{
|
||||
label: string;
|
||||
value: string;
|
||||
accent?: "green" | "amber" | "red" | "cyan";
|
||||
}>;
|
||||
};
|
||||
|
||||
function buildDashboardSummaryMetrics(
|
||||
function buildDashboardSummaryCards(
|
||||
snapshots: CitySnapshot[],
|
||||
locale: string,
|
||||
): HomeSummaryMetric[] {
|
||||
): HomeSummaryCard[] {
|
||||
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 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
|
||||
? topTradable.reduce((sum, snapshot) => {
|
||||
const edgeValue = Number(getMarketEdgeValue(snapshot.detail));
|
||||
@@ -266,28 +281,92 @@ function buildDashboardSummaryMetrics(
|
||||
|
||||
return [
|
||||
{
|
||||
key: "high",
|
||||
label: locale === "en-US" ? "High risk" : "高风险",
|
||||
value: String(highCount),
|
||||
accent: "red",
|
||||
key: "opportunities",
|
||||
title: locale === "en-US" ? "Today's Opportunities" : "今日机会分布",
|
||||
items: [
|
||||
{
|
||||
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",
|
||||
label: locale === "en-US" ? "Medium risk" : "中风险",
|
||||
value: String(mediumCount),
|
||||
accent: "amber",
|
||||
key: "market-summary",
|
||||
title: locale === "en-US" ? "Market Summary" : "市场概览",
|
||||
items: [
|
||||
{
|
||||
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",
|
||||
label: locale === "en-US" ? "Low risk" : "低风险",
|
||||
value: String(lowCount),
|
||||
accent: "green",
|
||||
key: "model-agreement",
|
||||
title: locale === "en-US" ? "Model Agreement" : "模型一致性",
|
||||
items: [
|
||||
{
|
||||
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",
|
||||
label: locale === "en-US" ? "Avg. edge" : "平均优势",
|
||||
value: formatEdge(avgEdge),
|
||||
accent: "cyan",
|
||||
key: "impact-window",
|
||||
title: locale === "en-US" ? "High Impact Window" : "高影响窗口",
|
||||
items: [
|
||||
{
|
||||
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() {
|
||||
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>
|
||||
<>
|
||||
<div className="home-map-header">
|
||||
<div className="home-map-title">
|
||||
<strong>{locale === "en-US" ? "Global weather regime" : "全球天气态势"}</strong>
|
||||
<span>{locale === "en-US" ? "Live monitored weather derivatives board" : "天气衍生品实时监控面板"}</span>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
<button
|
||||
type="button"
|
||||
className="home-map-expand"
|
||||
aria-label={locale === "en-US" ? "Expand map" : "放大地图"}
|
||||
>
|
||||
<Expand size={15} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="home-map-legend" aria-hidden="true">
|
||||
<span>{locale === "en-US" ? "Temperature (°F)" : "温度 (°F)"}</span>
|
||||
<div className="home-map-legend-bar" />
|
||||
<div className="home-map-legend-values">
|
||||
<span>-4</span>
|
||||
<span>14</span>
|
||||
<span>32</span>
|
||||
<span>50</span>
|
||||
<span>68</span>
|
||||
<span>86</span>
|
||||
<span>104</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -771,6 +871,15 @@ function HomeIntelligencePanel({ snapshots }: { snapshots: CitySnapshot[] }) {
|
||||
×
|
||||
</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">
|
||||
{showOpportunityLabel
|
||||
? locale === "en-US"
|
||||
@@ -1052,8 +1161,8 @@ function OpportunityStrip({
|
||||
}) {
|
||||
const { locale } = useI18n();
|
||||
const store = useDashboardStore();
|
||||
const summaryMetrics = useMemo(
|
||||
() => buildDashboardSummaryMetrics(snapshots, locale),
|
||||
const summaryCards = useMemo(
|
||||
() => buildDashboardSummaryCards(snapshots, locale),
|
||||
[locale, snapshots],
|
||||
);
|
||||
const items = useMemo(
|
||||
@@ -1069,10 +1178,22 @@ function OpportunityStrip({
|
||||
aria-label={locale === "en-US" ? "Opportunity strip" : "机会条"}
|
||||
>
|
||||
<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>
|
||||
{summaryCards.map((card) => (
|
||||
<div key={card.key} className="home-summary-card">
|
||||
<div className="home-summary-card-head">
|
||||
<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>
|
||||
@@ -1118,7 +1239,10 @@ function OpportunityStrip({
|
||||
{locale === "en-US" ? "target" : "目标"}
|
||||
</span>
|
||||
<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>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user