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: "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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user