feat: implement PolyWeather dashboard core components, state management, and API integration

This commit is contained in:
2569718930@qq.com
2026-04-23 19:40:38 +08:00
parent 73eeb11b88
commit 4f57d4ed1a
6 changed files with 296 additions and 129 deletions
@@ -1637,14 +1637,50 @@
.root :global(.home-intraday-chart) {
position: relative;
padding: 6px 0 0;
padding: 6px 0 18px 34px;
}
.root :global(.home-intraday-chart svg) {
width: 100%;
width: calc(100% - 34px);
margin-left: 34px;
height: 78px;
}
.root :global(.home-intraday-y-axis) {
position: absolute;
inset: 0 auto 18px 0;
width: 30px;
pointer-events: none;
}
.root :global(.home-intraday-y-label) {
position: absolute;
left: 0;
transform: translateY(-50%);
color: rgba(148, 163, 184, 0.72);
font-size: 10px;
font-weight: 700;
}
.root :global(.home-intraday-x-axis) {
position: absolute;
left: 34px;
right: 10px;
bottom: 0;
height: 14px;
pointer-events: none;
}
.root :global(.home-intraday-x-label) {
position: absolute;
bottom: 0;
transform: translateX(-50%);
color: rgba(148, 163, 184, 0.72);
font-size: 10px;
font-weight: 700;
white-space: nowrap;
}
.root :global(.home-intraday-chart line) {
stroke: rgba(115, 137, 161, 0.14);
stroke-width: 0.9;
@@ -1845,16 +1881,6 @@
box-shadow: 0 0 10px rgba(34, 211, 238, 0.14);
}
.root :global(.home-probability-row:nth-child(3) i),
.root :global(.home-probability-ladder-row:nth-child(3) i) {
background: linear-gradient(90deg, #f59e0b, #fbbf24);
}
.root :global(.home-probability-row:nth-child(n + 4) i),
.root :global(.home-probability-ladder-row:nth-child(n + 4) i) {
background: linear-gradient(90deg, #ef4444, #fb7185);
}
.root :global(.home-probability-row strong),
.root :global(.home-probability-ladder-row strong) {
position: relative;
@@ -7266,10 +7292,14 @@
.root :global(.home-ai-assistant) {
position: fixed;
right: 408px;
bottom: 210px;
right: 24px;
bottom: 24px;
z-index: 910;
width: min(380px, calc(100vw - var(--sidebar-width) - 72px));
width: min(340px, calc(100vw - 48px));
}
.root :global(.home-ai-assistant.collapsed) {
width: 56px;
}
.root :global(.home-ai-assistant.dragging) {
@@ -7295,11 +7325,12 @@
}
.root :global(.home-ai-launcher) {
display: grid;
grid-template-columns: auto 1fr auto;
display: inline-flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
justify-content: center;
width: 56px;
height: 56px;
padding: 0;
cursor: grab;
touch-action: none;
user-select: none;
@@ -7309,6 +7340,13 @@
background 160ms ease;
}
.root :global(.home-ai-launcher-icon) {
width: 22px;
height: 22px;
display: block;
object-fit: contain;
}
.root :global(.home-ai-launcher:hover) {
transform: translateY(-1px);
border-color: rgba(34, 211, 238, 0.32);
@@ -7577,15 +7615,19 @@
.root :global(.home-ai-assistant) {
right: 24px;
bottom: 24px;
width: min(380px, calc(100vw - 48px));
width: min(340px, calc(100vw - 48px));
}
}
@media (max-width: 960px) {
.root :global(.home-ai-assistant) {
left: 16px;
left: auto;
right: 16px;
width: auto;
width: min(340px, calc(100vw - 32px));
bottom: 16px;
}
.root :global(.home-ai-assistant.collapsed) {
width: 56px;
}
}
@@ -1,6 +1,7 @@
"use client";
import clsx from "clsx";
import dynamic from "next/dynamic";
import Image from "next/image";
import Link from "next/link";
import {
useEffect,
@@ -8,7 +9,6 @@ import {
useRef,
useState,
type FormEvent,
type KeyboardEvent as ReactKeyboardEvent,
type PointerEvent as ReactPointerEvent,
} from "react";
import styles from "./Dashboard.module.css";
@@ -167,14 +167,24 @@ function getProbabilityLabel(
},
symbol: string,
) {
if (bucket.label) return bucket.label;
if (bucket.bucket) return bucket.bucket;
if (bucket.label) return normalizeTemperatureBucketLabel(bucket.label, symbol);
if (bucket.bucket) return normalizeTemperatureBucketLabel(bucket.bucket, symbol);
if (Number.isFinite(Number(bucket.value))) {
return `${Math.round(Number(bucket.value))}${symbol}`;
}
return "--";
}
function normalizeTemperatureBucketLabel(label: string, symbol: string) {
const normalizedSymbol = symbol.includes("F") ? "°F" : "°C";
return String(label || "")
.trim()
.replace(
/(-?\d+(?:\.\d+)?)\s*°?\s*[CF]\b/gi,
(_, value) => `${Math.round(Number(value))}${normalizedSymbol}`,
);
}
function parseBucketThreshold(bucket?: {
label?: string | null;
value?: number | null;
@@ -352,7 +362,7 @@ type AssistantDockDragState = {
};
const HOME_AI_DOCK_POSITION_STORAGE_KEY =
"polyweather_home_ai_dock_position_v1";
"polyweather_home_ai_dock_position_v2";
function readAssistantDockPosition() {
if (typeof window === "undefined") return null;
@@ -392,21 +402,9 @@ function getDefaultAssistantDockPosition() {
if (typeof window === "undefined" || window.innerWidth <= 960) {
return null;
}
const detailPanel = document.querySelector(
".home-intelligence-panel.full",
) as HTMLElement | null;
const opportunityStrip = document.querySelector(
".home-opportunity-strip",
) as HTMLElement | null;
const panelRect = detailPanel?.getBoundingClientRect();
const stripRect = opportunityStrip?.getBoundingClientRect();
return {
right: panelRect
? Math.max(24, window.innerWidth - panelRect.left + 18)
: 408,
bottom: stripRect
? Math.max(24, window.innerHeight - stripRect.top + 18)
: 340,
right: 24,
bottom: 24,
};
}
@@ -418,7 +416,7 @@ function clampAssistantDockPosition(
return null;
}
const rect = dockElement?.getBoundingClientRect();
const width = rect?.width || 380;
const width = rect?.width || 340;
const height = rect?.height || 88;
const minOffset = 24;
const maxRight = Math.max(minOffset, window.innerWidth - width - minOffset);
@@ -500,6 +498,12 @@ function buildAssistantOpportunityContext(
current_temperature:
summary?.current?.temp ?? detail?.current?.temp ?? null,
deb_prediction: summary?.deb?.prediction ?? detail?.deb?.prediction ?? null,
temp_symbol: symbol,
today_high:
detail?.forecast?.today_high ??
detail?.current?.max_so_far ??
detail?.airport_current?.max_so_far ??
null,
market_question: marketQuestion,
market_label: marketBucket ? getProbabilityLabel(marketBucket, symbol) : null,
selected_date: marketScan?.selected_date || detail?.local_date || null,
@@ -609,12 +613,12 @@ function buildAssistantContextPayload(
function buildAssistantGreeting(locale: string, selectedCityName?: string | null) {
if (locale === "en-US") {
return selectedCityName
? `Ask about ${selectedCityName}, current opportunities, edge ranking, or how the metrics should be read.`
: "Ask about current opportunities, edge ranking, or how the metrics should be read.";
? `Ask about ${selectedCityName}'s current temperature, today's forecast high, market edge, or live opportunities.`
: "Ask about current temperature, today's forecast high, market edge, or live opportunities.";
}
return selectedCityName
? `可以直接问我 ${selectedCityName}、当前值得参与的市场edge 排序,或者指标怎么解读`
: "可以直接问我当前值得参与的市场edge 排序,或者指标怎么解读。";
? `可以直接问我 ${selectedCityName} 的当前温度、今日最高温预测、市场 edge 或实时机会`
: "可以直接问我当前温度、今日最高温预测、市场 edge 或实时机会。";
}
type HomeWeatherIconKind =
@@ -642,6 +646,8 @@ type HomeTrendChart = {
label: string;
temperatureText: string;
}>;
yAxisLabels: Array<{ key: string; label: string; y: number }>;
xAxisLabels: Array<{ key: string; label: string; x: number }>;
};
type HomeForecastDay = {
@@ -926,12 +932,61 @@ function buildHomeTrendChart(
temperatureText: formatTemperature(point.y, detail.temp_symbol || "°C"),
}));
const yAxisLabels = [
{
key: "max",
label: formatTemperature(chartData.max, detail.temp_symbol || "°C"),
y: 14,
},
{
key: "mid",
label: formatTemperature(
(chartData.max + chartData.min) / 2,
detail.temp_symbol || "°C",
),
y: 36,
},
{
key: "min",
label: formatTemperature(chartData.min, detail.temp_symbol || "°C"),
y: 58,
},
];
const xAxisSource =
hoverSource.length <= 4
? hoverSource
: [
hoverSource[0],
hoverSource[Math.floor((hoverSource.length - 1) / 3)],
hoverSource[Math.floor(((hoverSource.length - 1) * 2) / 3)],
hoverSource[hoverSource.length - 1],
];
const xAxisLabels = xAxisSource.map((point, index) => {
const projected = projectHomeTrendPoint(
point.x,
point.y,
chartData.xMin,
chartData.xMax,
chartData.min,
chartData.max,
);
return {
key: `axis-${point.labelTime}-${index}`,
label: point.labelTime,
x: Number(((projected.cx / 296) * 100).toFixed(2)),
};
});
return {
forecastPath,
legendText: chartData.legendText,
observationDots,
hourlyReports,
hoverPoints,
yAxisLabels,
xAxisLabels,
};
}
@@ -1243,15 +1298,13 @@ function HomeIntelligencePanel({ snapshots }: { snapshots: CitySnapshot[] }) {
<div className={clsx("home-pro-card", isPro && "active")}>
<div>
<span>{proLabel}</span>
<strong>
{isPro
? locale === "en-US"
? "Today intraday analysis is the primary paid workflow."
: "今日日内分析是当前主要付费工作流。"
: locale === "en-US"
{!isPro ? (
<strong>
{locale === "en-US"
? "History review and future dates stay paid."
: "历史复盘和未来日期保持付费。"}
</strong>
</strong>
) : null}
</div>
{isPro ? (
<button type="button" onClick={() => void store.openTodayModal()}>
@@ -1319,10 +1372,6 @@ function HomeIntelligencePanel({ snapshots }: { snapshots: CitySnapshot[] }) {
{locale === "en-US" ? "Observed now" : "当前实况"}
</span>
<strong>{formatTemperature(currentTemp, symbol)}</strong>
<span className="home-weather-sub">
{locale === "en-US" ? "Feels near" : "体感接近"}{" "}
{formatTemperature(currentTemp, symbol)}
</span>
</div>
<div className="home-weather-side">
<div
@@ -1366,6 +1415,17 @@ function HomeIntelligencePanel({ snapshots }: { snapshots: CitySnapshot[] }) {
<small>{locale === "en-US" ? "1h cadence" : "1h 级别"}</small>
</h3>
<div className="home-intraday-chart">
<div className="home-intraday-y-axis" aria-hidden="true">
{trendChart.yAxisLabels.map((axisLabel) => (
<span
key={axisLabel.key}
className="home-intraday-y-label"
style={{ top: `${axisLabel.y}px` }}
>
{axisLabel.label}
</span>
))}
</div>
<svg viewBox="0 0 296 78" aria-hidden="true">
<line x1="10" y1="14" x2="286" y2="14" />
<line x1="10" y1="36" x2="286" y2="36" />
@@ -1377,6 +1437,17 @@ function HomeIntelligencePanel({ snapshots }: { snapshots: CitySnapshot[] }) {
<circle key={point.key} cx={point.cx} cy={point.cy} r="3.2" />
))}
</svg>
<div className="home-intraday-x-axis" aria-hidden="true">
{trendChart.xAxisLabels.map((axisLabel) => (
<span
key={axisLabel.key}
className="home-intraday-x-label"
style={{ left: `${axisLabel.x}%` }}
>
{axisLabel.label}
</span>
))}
</div>
{trendChart.hoverPoints.map((point) => (
<button
key={point.key}
@@ -1952,17 +2023,19 @@ function HomeAssistantDock({ snapshots }: { snapshots: CitySnapshot[] }) {
const starterPrompts = useMemo(() => {
if (locale === "en-US") {
return [
"Which market is worth buying now?",
"Rank the current markets by edge",
selectedCityName
? `Why is ${selectedCityName} not recommended?`
: "Explain what edge means",
? `What is today's forecast high for ${selectedCityName}?`
: "What is today's forecast high for the focus city?",
"Which market is worth buying now?",
"Rank current opportunities by edge",
];
}
return [
selectedCityName
? `${selectedCityName} 今天预测最高温是多少?`
: "当前焦点城市今天预测最高温是多少?",
"当前有哪些值得参与的市场?",
"按 edge 排序",
selectedCityName ? `为什么 ${selectedCityName} 不建议参与?` : "解释一下 edge 是什么",
];
}, [locale, selectedCityName]);
@@ -2099,14 +2172,6 @@ function HomeAssistantDock({ snapshots }: { snapshots: CitySnapshot[] }) {
setIsDragging(false);
};
const handleLauncherKeyDown = (
event: ReactKeyboardEvent<HTMLDivElement>,
) => {
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
openAssistant();
};
const sendQuestion = async (rawQuestion?: string) => {
const question = String(rawQuestion ?? input).trim();
if (!question || loading) return;
@@ -2169,7 +2234,11 @@ function HomeAssistantDock({ snapshots }: { snapshots: CitySnapshot[] }) {
<>
<div
ref={dockRef}
className={clsx("home-ai-assistant", isDragging && "dragging")}
className={clsx(
"home-ai-assistant",
!isOpen && "collapsed",
isDragging && "dragging",
)}
style={
dockPosition
? {
@@ -2180,40 +2249,24 @@ function HomeAssistantDock({ snapshots }: { snapshots: CitySnapshot[] }) {
}
>
{!isOpen ? (
<div
<button
type="button"
className="home-ai-launcher"
role="button"
tabIndex={0}
onClick={openAssistant}
onKeyDown={handleLauncherKeyDown}
onPointerDown={beginDockDrag}
onPointerMove={updateDockDrag}
onPointerUp={endDockDrag}
onPointerCancel={endDockDrag}
aria-label={locale === "en-US" ? "Open AI assistant" : "打开 AI 助手"}
>
<span className="home-ai-launcher-badge">AI</span>
<div className="home-ai-launcher-copy">
<strong>
{locale === "en-US" ? "Market Assistant" : "AI 对话助手"}
</strong>
<span>
{store.proAccess.subscriptionActive
? locale === "en-US"
? "Ask using the current market snapshot"
: "基于当前市场快照直接提问"
: locale === "en-US"
? "Pro only"
: "Pro 专属"}
</span>
</div>
<span
className="home-ai-drag-handle"
title={locale === "en-US" ? "Move assistant" : "拖动助手"}
aria-hidden="true"
>
</span>
</div>
<Image
src="/favicon-32x32.png"
alt=""
width={22}
height={22}
className="home-ai-launcher-icon"
/>
</button>
) : (
<section
className="home-ai-panel"
@@ -2224,8 +2277,8 @@ function HomeAssistantDock({ snapshots }: { snapshots: CitySnapshot[] }) {
<strong>{locale === "en-US" ? "AI assistant" : "AI 对话助手"}</strong>
<span>
{locale === "en-US"
? `Snapshot ${assistantContext.snapshot_id}`
: `快照 ${assistantContext.snapshot_id}`}
? "Ask about cities, forecast highs, edge, and live opportunities"
: "可直接问城市、最高温预测、edge 和实时市场机会"}
</span>
</div>
<div className="home-ai-header-actions">
@@ -2253,8 +2306,8 @@ function HomeAssistantDock({ snapshots }: { snapshots: CitySnapshot[] }) {
<div className="home-ai-disclaimer">
{locale === "en-US"
? "Only explains the current system snapshot. It does not scan markets or calculate probabilities."
: "只解释当前系统快照,不参与市场扫描、概率计算或核心决策。"}
? "You can ask about current temperature, today's forecast high, market opportunities, edge, and risk reasons."
: "可直接问当前温度、今日最高温、市场机会、edge 和风险原因。"}
</div>
<div className="home-ai-messages">
@@ -2301,8 +2354,8 @@ function HomeAssistantDock({ snapshots }: { snapshots: CitySnapshot[] }) {
onChange={(event) => setInput(event.target.value)}
placeholder={
locale === "en-US"
? "Ask about current opportunities, a city, or edge..."
: "问当前机会、某个城市,或者 edge 怎么看..."
? "Ask about a city's current temperature, today's forecast high, or market edge..."
: "可以问某个城市当前温度、今日最高温,或者市场 edge..."
}
/>
<div className="home-ai-composer-actions">
@@ -2461,10 +2514,9 @@ function DashboardScreen() {
[cityName]: "pending",
}));
try {
const detail = await store.ensureCityDetail(cityName, false, "panel");
if (cancelled) return;
const existingDetail = store.cityDetailsByName[cityName];
const marketScan =
detail.market_scan ||
existingDetail?.market_scan ||
(await store.ensureCityMarketScan(cityName, false));
if (cancelled) return;
setMarketScanStatusByCity((current) => ({
@@ -2484,7 +2536,7 @@ function DashboardScreen() {
};
void Promise.allSettled(
Array.from({ length: Math.min(4, queue.length) }, () => runWorker()),
Array.from({ length: Math.min(2, queue.length) }, () => runWorker()),
);
return () => {
@@ -2494,7 +2546,10 @@ function DashboardScreen() {
marketScanStatusByCity,
marketScanTargetNames,
showHomepageChrome,
store,
store.cityDetailsByName,
store.ensureCityMarketScan,
store.proAccess.authenticated,
store.proAccess.loading,
]);
return (