增强页头概率页入口并优化概率标签
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
import type { Metadata } from "next";
|
||||
import { ProbabilityHubPage } from "@/components/probability-hub/ProbabilityHubPage";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "PolyWeather - 概率判断总览",
|
||||
description: "集中查看 52 个城市的 EMOS 概率判断与市场合约桶对比。",
|
||||
};
|
||||
|
||||
export default function ProbabilitiesPage() {
|
||||
return <ProbabilityHubPage />;
|
||||
}
|
||||
@@ -3373,6 +3373,12 @@
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.root :global(.info-btn.active) {
|
||||
background: rgba(34, 211, 238, 0.14);
|
||||
border-color: rgba(34, 211, 238, 0.42);
|
||||
color: #e0fbff;
|
||||
}
|
||||
|
||||
/* ── Guide Modal Customizations ── */
|
||||
.root :global(.modal-content.large) {
|
||||
max-width: 900px;
|
||||
|
||||
@@ -3,7 +3,14 @@
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import clsx from "clsx";
|
||||
import { LogIn, UserRound, RotateCw, BookOpen, Sparkles } from "lucide-react";
|
||||
import {
|
||||
LogIn,
|
||||
UserRound,
|
||||
RotateCw,
|
||||
BookOpen,
|
||||
Sparkles,
|
||||
BarChart3,
|
||||
} from "lucide-react";
|
||||
import { useDashboardStore } from "@/hooks/useDashboardStore";
|
||||
import { useI18n } from "@/hooks/useI18n";
|
||||
|
||||
@@ -21,17 +28,32 @@ function parseExpiryInfo(raw?: string | null) {
|
||||
};
|
||||
}
|
||||
|
||||
export function HeaderBar() {
|
||||
export function HeaderBar({
|
||||
refreshAction,
|
||||
refreshSpinning,
|
||||
}: {
|
||||
refreshAction?: () => void | Promise<void>;
|
||||
refreshSpinning?: boolean;
|
||||
}) {
|
||||
const store = useDashboardStore();
|
||||
const { locale, setLocale, t } = useI18n();
|
||||
const pathname = usePathname();
|
||||
const isAuthenticated = store.proAccess.authenticated;
|
||||
const docsHref = "/docs/intro";
|
||||
const docsActive = pathname?.startsWith("/docs");
|
||||
const probabilityHubHref = "/probabilities";
|
||||
const probabilityHubActive = pathname?.startsWith("/probabilities");
|
||||
const trialPromoLabel =
|
||||
locale === "en-US"
|
||||
? "New users get 3-day Pro trial"
|
||||
: "新用户可免费体验 3 天 Pro";
|
||||
const isRefreshing = refreshSpinning ?? store.loadingState.refresh;
|
||||
const handleRefresh = () => {
|
||||
if (refreshAction) {
|
||||
return void refreshAction();
|
||||
}
|
||||
return void store.refreshAll();
|
||||
};
|
||||
|
||||
const accountHref = isAuthenticated
|
||||
? "/account"
|
||||
@@ -119,6 +141,16 @@ export function HeaderBar() {
|
||||
{t("header.docs")}
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href={probabilityHubHref}
|
||||
className={clsx("info-btn", probabilityHubActive && "active")}
|
||||
title={t("header.probabilityHubAria")}
|
||||
aria-label={t("header.probabilityHubAria")}
|
||||
>
|
||||
<BarChart3 size={14} strokeWidth={2} />
|
||||
{t("header.probabilityHub")}
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/account"
|
||||
className="trial-promo-badge"
|
||||
@@ -160,13 +192,10 @@ export function HeaderBar() {
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(
|
||||
"refresh-btn",
|
||||
store.loadingState.refresh && "spinning",
|
||||
)}
|
||||
className={clsx("refresh-btn", isRefreshing && "spinning")}
|
||||
title={t("header.refreshAria")}
|
||||
aria-label={t("header.refreshAria")}
|
||||
onClick={() => void store.refreshAll()}
|
||||
onClick={handleRefresh}
|
||||
>
|
||||
<RotateCw size={16} strokeWidth={2} />
|
||||
</button>
|
||||
|
||||
@@ -1164,12 +1164,12 @@ export function ProbabilityDistribution({
|
||||
const probability = Math.round(Number(row.probability || 0) * 100);
|
||||
const rowMarketBucket = row.marketBucket;
|
||||
const rowMarketPrice =
|
||||
rowMarketBucket?.market_price ?? rowMarketBucket?.yes_buy ?? null;
|
||||
rowMarketBucket?.yes_buy ?? rowMarketBucket?.market_price ?? null;
|
||||
const yesPriceText = toPriceCents(rowMarketPrice);
|
||||
const marketTagFinal = rowMarketBucket
|
||||
? locale === "en-US"
|
||||
? `Market ref: ${yesPriceText || "--"}`
|
||||
: `市场参考: ${yesPriceText || "--"}`
|
||||
? `YES ask: ${yesPriceText || "--"}`
|
||||
: `YES 买价: ${yesPriceText || "--"}`
|
||||
: null;
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
.pageRoot {
|
||||
min-height: 100vh;
|
||||
width: 100%;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.pageBody {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
padding: 72px 20px 24px;
|
||||
}
|
||||
|
||||
.hero {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.heroCard {
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 14px;
|
||||
padding: 16px 18px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.heroTitle {
|
||||
font-size: 22px;
|
||||
font-weight: 800;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.heroText {
|
||||
margin-top: 8px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.heroMeta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.heroPill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 30px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(34, 211, 238, 0.18);
|
||||
background: rgba(8, 47, 73, 0.24);
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.heroPill strong {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.errorCard {
|
||||
border: 1px solid rgba(239, 68, 68, 0.24);
|
||||
border-radius: 12px;
|
||||
padding: 12px 14px;
|
||||
background: rgba(127, 29, 29, 0.16);
|
||||
color: #fecaca;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.card {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 16px;
|
||||
padding: 14px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
.cardHead {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.cardTitleBlock {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.cardTitle {
|
||||
font-size: 16px;
|
||||
font-weight: 800;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.cardSubTitle {
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.cardMeta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.metaChip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 26px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.14);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.metaChip strong {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.loadingGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.loadingCard {
|
||||
height: 280px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
background:
|
||||
linear-gradient(
|
||||
90deg,
|
||||
rgba(255, 255, 255, 0.02) 0%,
|
||||
rgba(255, 255, 255, 0.05) 50%,
|
||||
rgba(255, 255, 255, 0.02) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: probabilityHubShimmer 1.6s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes probabilityHubShimmer {
|
||||
0% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.grid,
|
||||
.loadingGrid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.pageBody {
|
||||
padding: 68px 12px 18px;
|
||||
}
|
||||
|
||||
.heroCard,
|
||||
.card {
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.heroTitle {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import clsx from "clsx";
|
||||
import dashboardStyles from "@/components/dashboard/Dashboard.module.css";
|
||||
import { HeaderBar } from "@/components/dashboard/HeaderBar";
|
||||
import { ProbabilityDistribution } from "@/components/dashboard/PanelSections";
|
||||
import { dashboardClient } from "@/lib/dashboard-client";
|
||||
import type { CityDetail, CityListItem } from "@/lib/dashboard-types";
|
||||
import { I18nProvider, useI18n } from "@/hooks/useI18n";
|
||||
import { DashboardStoreProvider } from "@/hooks/useDashboardStore";
|
||||
import styles from "./ProbabilityHubPage.module.css";
|
||||
|
||||
const DETAIL_BATCH_SIZE = 6;
|
||||
|
||||
function sortCities(cities: CityListItem[]) {
|
||||
const riskOrder: Record<string, number> = {
|
||||
high: 0,
|
||||
medium: 1,
|
||||
low: 2,
|
||||
};
|
||||
|
||||
return [...cities].sort((a, b) => {
|
||||
const riskDelta =
|
||||
(riskOrder[String(a.risk_level || "").toLowerCase()] ?? 9) -
|
||||
(riskOrder[String(b.risk_level || "").toLowerCase()] ?? 9);
|
||||
if (riskDelta !== 0) return riskDelta;
|
||||
return String(a.display_name || a.name).localeCompare(
|
||||
String(b.display_name || b.name),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function ProbabilityHubScreen() {
|
||||
const { locale } = useI18n();
|
||||
const [cities, setCities] = useState<CityListItem[]>([]);
|
||||
const [detailsByName, setDetailsByName] = useState<Record<string, CityDetail>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [lastUpdatedAt, setLastUpdatedAt] = useState<string | null>(null);
|
||||
|
||||
const loadAll = useCallback(async (force = false) => {
|
||||
setError(null);
|
||||
setRefreshing(true);
|
||||
if (!cities.length || force) {
|
||||
setLoading(true);
|
||||
}
|
||||
|
||||
try {
|
||||
const cityList = sortCities(await dashboardClient.getCities());
|
||||
setCities(cityList);
|
||||
|
||||
const nextDetails: Record<string, CityDetail> = {};
|
||||
for (let index = 0; index < cityList.length; index += DETAIL_BATCH_SIZE) {
|
||||
const batch = cityList.slice(index, index + DETAIL_BATCH_SIZE);
|
||||
const results = await Promise.allSettled(
|
||||
batch.map((city) =>
|
||||
dashboardClient.getCityDetail(city.name, {
|
||||
depth: "market",
|
||||
force,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
results.forEach((result, batchIndex) => {
|
||||
if (result.status !== "fulfilled") return;
|
||||
nextDetails[batch[batchIndex].name] = result.value;
|
||||
});
|
||||
|
||||
setDetailsByName((previous) => ({
|
||||
...previous,
|
||||
...nextDetails,
|
||||
}));
|
||||
}
|
||||
|
||||
setLastUpdatedAt(new Date().toISOString());
|
||||
} catch (loadError) {
|
||||
setError(
|
||||
loadError instanceof Error
|
||||
? loadError.message
|
||||
: locale === "en-US"
|
||||
? "Failed to load probability hub"
|
||||
: "加载概率页失败",
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, [cities.length, locale]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadAll(false);
|
||||
}, [loadAll]);
|
||||
|
||||
const loadedCount = Object.keys(detailsByName).length;
|
||||
const cityCount = cities.length;
|
||||
const readyCards = useMemo(
|
||||
() => cities.filter((city) => detailsByName[city.name]).length,
|
||||
[cities, detailsByName],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={clsx(dashboardStyles.root, styles.pageRoot)}>
|
||||
<HeaderBar
|
||||
refreshAction={() => loadAll(true)}
|
||||
refreshSpinning={refreshing}
|
||||
/>
|
||||
<main className={styles.pageBody}>
|
||||
<section className={styles.hero}>
|
||||
<div className={styles.heroCard}>
|
||||
<div className={styles.heroTitle}>
|
||||
{locale === "en-US"
|
||||
? "52-city probability hub"
|
||||
: "52 城市概率判断总览"}
|
||||
</div>
|
||||
<div className={styles.heroText}>
|
||||
{locale === "en-US"
|
||||
? "This page centralizes the intraday probability block for all monitored cities. The goal is fast scanning: see calibrated EMOS probabilities, market bucket alignment, and price comparison without opening each city modal one by one."
|
||||
: "这里把 52 个监控城市的概率判断板块集中到一个页面,方便直接横向扫一遍,不用逐个打开城市弹窗。重点看 EMOS 校准概率、市场合约桶聚合,以及价格对比。"}
|
||||
</div>
|
||||
<div className={styles.heroMeta}>
|
||||
<span className={styles.heroPill}>
|
||||
{locale === "en-US" ? "Cities" : "城市数"} <strong>{cityCount || "--"}</strong>
|
||||
</span>
|
||||
<span className={styles.heroPill}>
|
||||
{locale === "en-US" ? "Ready" : "已加载"} <strong>{readyCards}</strong>
|
||||
</span>
|
||||
<span className={styles.heroPill}>
|
||||
{locale === "en-US" ? "Updated" : "更新时间"}{" "}
|
||||
<strong>
|
||||
{lastUpdatedAt
|
||||
? new Date(lastUpdatedAt).toLocaleTimeString(
|
||||
locale === "en-US" ? "en-US" : "zh-CN",
|
||||
{
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
},
|
||||
)
|
||||
: "--"}
|
||||
</strong>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? <div className={styles.errorCard}>{error}</div> : null}
|
||||
</section>
|
||||
|
||||
{loading && loadedCount === 0 ? (
|
||||
<div className={styles.loadingGrid}>
|
||||
{Array.from({ length: 6 }).map((_, index) => (
|
||||
<div key={index} className={styles.loadingCard} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.grid}>
|
||||
{cities.map((city) => {
|
||||
const detail = detailsByName[city.name];
|
||||
if (!detail) {
|
||||
return (
|
||||
<section key={city.name} className={styles.card}>
|
||||
<div className={styles.cardHead}>
|
||||
<div className={styles.cardTitleBlock}>
|
||||
<div className={styles.cardTitle}>{city.display_name}</div>
|
||||
<div className={styles.cardSubTitle}>
|
||||
{city.airport} ({city.icao})
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.cardSubTitle}>
|
||||
{locale === "en-US"
|
||||
? "Probability block is syncing..."
|
||||
: "概率板块同步中..."}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section key={city.name} className={styles.card}>
|
||||
<div className={styles.cardHead}>
|
||||
<div className={styles.cardTitleBlock}>
|
||||
<div className={styles.cardTitle}>{detail.display_name}</div>
|
||||
<div className={styles.cardSubTitle}>
|
||||
{detail.risk?.airport || city.airport} ({detail.risk?.icao || city.icao})
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.cardMeta}>
|
||||
<span className={styles.metaChip}>
|
||||
{locale === "en-US" ? "Current" : "当前"}{" "}
|
||||
<strong>
|
||||
{detail.current?.temp != null
|
||||
? `${detail.current.temp}${detail.temp_symbol}`
|
||||
: "--"}
|
||||
</strong>
|
||||
</span>
|
||||
<span className={styles.metaChip}>
|
||||
{locale === "en-US" ? "Obs" : "观测"}{" "}
|
||||
<strong>{detail.current?.obs_time || "--"}</strong>
|
||||
</span>
|
||||
<span className={styles.metaChip}>
|
||||
{locale === "en-US" ? "Updated" : "更新"}{" "}
|
||||
<strong>
|
||||
{detail.updated_at
|
||||
? new Date(detail.updated_at).toLocaleTimeString(
|
||||
locale === "en-US" ? "en-US" : "zh-CN",
|
||||
{
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
},
|
||||
)
|
||||
: "--"}
|
||||
</strong>
|
||||
</span>
|
||||
</div>
|
||||
<ProbabilityDistribution
|
||||
detail={detail}
|
||||
hideTitle
|
||||
marketScan={detail.market_scan}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProbabilityHubPage() {
|
||||
return (
|
||||
<I18nProvider>
|
||||
<DashboardStoreProvider>
|
||||
<ProbabilityHubScreen />
|
||||
</DashboardStoreProvider>
|
||||
</I18nProvider>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,8 @@ const MESSAGES: Record<Locale, Record<string, string>> = {
|
||||
"header.subtitle": "天气衍生品智能分析",
|
||||
"header.docs": "文档",
|
||||
"header.docsAria": "打开产品文档中心",
|
||||
"header.probabilityHub": "概率页",
|
||||
"header.probabilityHubAria": "打开 52 城市概率汇总页",
|
||||
"header.info": "技术说明",
|
||||
"header.infoAria": "查看系统技术说明",
|
||||
"header.account": "账户",
|
||||
@@ -173,6 +175,8 @@ const MESSAGES: Record<Locale, Record<string, string>> = {
|
||||
"header.subtitle": "Weather Derivatives Intelligence",
|
||||
"header.docs": "Docs",
|
||||
"header.docsAria": "Open product documentation",
|
||||
"header.probabilityHub": "Probabilities",
|
||||
"header.probabilityHubAria": "Open the 52-city probability hub",
|
||||
"header.info": "Tech Notes",
|
||||
"header.infoAria": "Open system technical notes",
|
||||
"header.account": "Account",
|
||||
|
||||
Reference in New Issue
Block a user