Compare commits

...

184 Commits

Author SHA1 Message Date
2569718930@qq.com 25ab512371 release: v1.4.0 2026-03-20 19:13:14 +08:00
2569718930@qq.com 2a19a23e4d docs: Add new READMEs for extension and frontend, create tech debt document, and update Chinese README. 2026-03-20 19:07:11 +08:00
2569718930@qq.com 6e3bc1ce75 docs: Add deep research report. 2026-03-20 19:02:46 +08:00
2569718930@qq.com 5e88e1480a feat: Implement initial PolyWeather browser extension including sidepanel, options page, and core weather display logic. 2026-03-20 18:43:01 +08:00
2569718930@qq.com e4cc5b4263 feat: Add initial web dashboard and centralize weather trend analysis logic into a shared engine. 2026-03-20 18:16:21 +08:00
2569718930@qq.com dbf10253a8 feat: Implement multi-source weather data collection with caching, rate limiting, and disk persistence. 2026-03-20 13:35:34 +08:00
2569718930@qq.com da9b0b36f7 docs: Add deep research report document. 2026-03-19 21:43:55 +08:00
2569718930@qq.com f049a69ee3 Fix mispricing alerts for bearish low-Yes signals 2026-03-19 17:38:04 +08:00
2569718930@qq.com b33a741385 Switch Ankara MGM station selection to 17128 airport 2026-03-18 21:01:31 +08:00
2569718930@qq.com 69f24ddc08 Optimize frontend performance and add route-level web vitals tracking 2026-03-18 20:38:43 +08:00
2569718930@qq.com f0fcb1e379 feat: implement initial PolyWeather browser extension with side panel and options page 2026-03-18 12:51:24 +08:00
2569718930@qq.com 5c209ceb75 Hide scrollbar in analysis modal 2026-03-18 00:47:33 +08:00
2569718930@qq.com 15a2fb91d1 Fix FutureForecast settlement info 2026-03-18 00:41:54 +08:00
2569718930@qq.com f2547e11b6 Remove RP5 multi-model block 2026-03-18 00:27:48 +08:00
2569718930@qq.com fef3097c36 Remove RP5 multi-model data 2026-03-18 00:20:04 +08:00
2569718930@qq.com 5057892255 Fix RP5 Shanghai station prediction 2026-03-18 00:05:37 +08:00
2569718930@qq.com 233112cf0a Merge branch 'main' of https://github.com/yangyuan-zhen/PolyWeather 2026-03-17 23:39:08 +08:00
2569718930@qq.com 60f83d0905 Explain git push permission error 2026-03-17 23:30:25 +08:00
2569718930@qq.com 1ae9b55509 Add RP5 forecast scraping support 2026-03-17 23:15:13 +08:00
root ebaa57b5d1 chore: ignore local env and vercel files 2026-03-17 19:24:09 +08:00
2569718930@qq.com 9ac0a13937 Assess PolyCop copytrade bot info 2026-03-17 19:18:37 +08:00
2569718930@qq.com 508c322c8b Fix wallet unlink 401 error 2026-03-17 15:38:40 +08:00
2569718930@qq.com 5a6d9eeda4 Fix wallet delete authorization 2026-03-17 15:32:09 +08:00
2569718930@qq.com 7345b70958 Fix vercel link scope error 2026-03-17 15:23:59 +08:00
2569718930@qq.com 5d2ed68cdf Adjust wallet unlink token handling 2026-03-17 15:13:57 +08:00
2569718930@qq.com 7510e0a1e0 feat: implement user account center with authentication, subscription management, and EVM wallet integration. 2026-03-17 14:32:15 +08:00
2569718930@qq.com 217414408c Handle wallet session expiration 2026-03-17 14:18:25 +08:00
2569718930@qq.com 93f7a852a8 Add Madrid METAR market note 2026-03-17 14:04:22 +08:00
2569718930@qq.com 90c998f06d feat: Introduce account center with subscription management and contract-based payment integration. 2026-03-17 13:57:44 +08:00
2569718930@qq.com 54544e40b9 feat: Implement core data models, backend services for weather and market data, and initial dashboard components. 2026-03-17 13:21:14 +08:00
2569718930@qq.com 609fc8914a feat: Implement user account center with authentication, subscription management, and cryptocurrency payment integration. 2026-03-16 20:30:46 +08:00
2569718930@qq.com 6a229a592b feat: Implement dashboard utility functions for weather data processing, localization, and chart data preparation. 2026-03-16 10:08:48 +08:00
2569718930@qq.com 1ebd07ca31 feat: introduce core bot command handlers, orchestrator, and command parser for initial bot functionality. 2026-03-15 09:03:59 +08:00
2569718930@qq.com 81604bfe8a feat: Implement basic and city command handlers for the bot, including user identity binding and unbinding. 2026-03-14 22:35:44 +08:00
2569718930@qq.com e7a5347907 feat: add new command handlers for the /city and /deb commands 2026-03-14 22:22:53 +08:00
2569718930@qq.com fd7ec30e58 feat: Add new command handlers for the /deb and /city commands. 2026-03-14 22:13:52 +08:00
2569718930@qq.com 9bff3b0683 feat: implement /deb and /city command handlers for the bot. 2026-03-14 22:05:09 +08:00
2569718930@qq.com 314cad82e6 feat: add bot handlers for activity tracking, city weather queries, and historical data queries. 2026-03-14 21:58:33 +08:00
2569718930@qq.com 1c57a6f56e feat: Add new handlers for /deb and /city commands, and a general activity tracker. 2026-03-14 21:47:21 +08:00
2569718930@qq.com 530080a75d feat: Add /city and /deb command handlers for weather data and historical weather queries. 2026-03-14 14:24:11 +08:00
2569718930@qq.com 6d1f77cac1 feat: implement user account management and dashboard components. 2026-03-14 13:33:06 +08:00
2569718930@qq.com 2397a7569b feat: add payment event loop for scanning blockchain and processing payment intents. 2026-03-14 13:06:03 +08:00
2569718930@qq.com c1fb52551d feat: Implement BotIOLayer for Telegram I/O, user points management, and message routing. 2026-03-14 11:55:57 +08:00
2569718930@qq.com d1f4a8b236 feat: add bot I/O layer for message routing and point management, updating .env.example with topic map configuration. 2026-03-14 11:51:14 +08:00
2569718930@qq.com 4bd531bbc9 feat: implement PolyWeather Telegram bot with weather and DEB analysis, a point system, and configuration. 2026-03-14 11:39:30 +08:00
2569718930@qq.com 64f8b37014 feat: Introduce open-core policy, commercialization, and payment verification documentation, and update README with new product status, payment architecture, and documentation index. 2026-03-14 10:53:53 +08:00
2569718930@qq.com 02e35a3a5f feat: Implement a new contract-based payment system with wallet binding, payment intents, and subscription plan management. 2026-03-14 10:35:30 +08:00
2569718930@qq.com be61c39795 feat: add Polymarket wallet activity watcher script. 2026-03-13 22:28:33 +08:00
2569718930@qq.com af2561d019 feat: Introduce a runtime coordinator for bot loops and add new payment checkout and confirmation modules. 2026-03-13 22:11:55 +08:00
2569718930@qq.com 4bd97cc5a5 feat: add utility script to reconcile payment transactions to user subscriptions with an emergency manual grant fallback. 2026-03-13 21:26:40 +08:00
2569718930@qq.com db2f22509b feat: Add account center with crypto payment checkout and wallet management. 2026-03-13 21:08:57 +08:00
2569718930@qq.com f27970f157 feat: Introduce Pro subscription unlock overlay, paywall, and related account and help pages. 2026-03-13 20:38:10 +08:00
2569718930@qq.com 3d9d4dcd64 feat: implement account center component for user management, subscriptions, and EVM payments. 2026-03-13 20:09:49 +08:00
2569718930@qq.com 786765e48b feat: implement SQLite database manager for user data, points, and Supabase bindings. 2026-03-13 19:48:40 +08:00
2569718930@qq.com cd0a5d519f feat: Introduce core bot components, on-chain wallet watchers, Telegram notifications, and configuration management. 2026-03-13 19:24:05 +08:00
2569718930@qq.com edf2a37976 feat: Implement core Telegram bot features including command handling, user points, and identity management, and add Polymarket wallet activity watcher. 2026-03-13 18:39:39 +08:00
2569718930@qq.com 7bde49bd5e Merge branch 'main' of https://github.com/yangyuan-zhen/PolyWeather 2026-03-13 18:06:11 +08:00
2569718930@qq.com 80acade251 feat: implement user account center with subscription and EVM wallet payment integration, along with initial project configuration files. 2026-03-13 18:06:08 +08:00
root ac5c3a2c76 chore: stop tracking runtime sqlite db file 2026-03-13 18:04:08 +08:00
2569718930@qq.com 08217deefb feat: Externalize runtime data, including SQLite and caches, to a configurable directory mounted via Docker volumes. 2026-03-13 17:18:06 +08:00
2569718930@qq.com ac9e537070 feat: Implement initial web frontend, comprehensive database management, and a bot-based weekly reward system. 2026-03-13 16:47:25 +08:00
2569718930@qq.com e780830f76 feat: Introduce web application with Supabase authentication, entitlement, and account management. 2026-03-13 15:39:25 +08:00
2569718930@qq.com aa98623cab feat: implement Pro subscription with a frontend unlock overlay and blockchain-based payment integration. 2026-03-13 13:58:41 +08:00
2569718930@qq.com f00360ed56 feat: add account center with subscription management and EVM wallet payment integration. 2026-03-13 13:13:51 +08:00
2569718930@qq.com e798a4f33a feat: Introduce account center for user management and crypto payments; add Telegram push utility. 2026-03-13 13:01:57 +08:00
2569718930@qq.com 50a2202930 feat: Add bot analysis and command services, bot settings, and a frontend account center with subscription and EVM wallet integration. 2026-03-13 12:45:39 +08:00
2569718930@qq.com c3958c29c7 feat: add UnlockProOverlay component for Pro subscription and point redemption. 2026-03-13 12:26:35 +08:00
2569718930@qq.com 663d6be506 feat: Add API routes for creating, confirming, and submitting payment intents, and verifying wallets. 2026-03-13 12:20:01 +08:00
2569718930@qq.com 2de083d53a feat: Add API route to proxy payments wallet challenge requests to the backend. 2026-03-13 12:10:07 +08:00
2569718930@qq.com 04fda60fb4 feat: implement account center for user profile management, payments, and wallet binding. 2026-03-13 11:51:01 +08:00
2569718930@qq.com c031d9caeb feat: add AccountCenter component for user profile, payments, and wallet management. 2026-03-13 11:40:50 +08:00
2569718930@qq.com 9ba0890ca9 feat: Implement PolyWeather Web Map API backend, integrating weather data, Polymarket, user authentication, and payment processing. 2026-03-13 11:18:56 +08:00
2569718930@qq.com fb630b47cb feat: implement PolyWeather Web Map API backend with FastAPI, integrating weather data, analysis, authentication, and payment processing. 2026-03-13 11:09:49 +08:00
2569718930@qq.com 5b7a257087 feat: Implement Supabase-backed authentication and entitlement checks for frontend-to-backend requests. 2026-03-13 11:01:44 +08:00
2569718930@qq.com 534eef4197 feat: implement new AccountCenter component for user profile, authentication, and payment management 2026-03-13 10:52:31 +08:00
2569718930@qq.com 25f11912dd feat: Add backend authentication utilities for constructing request headers with entitlement and user session tokens, and for applying auth response cookies. 2026-03-13 10:44:11 +08:00
2569718930@qq.com e201856700 feat: add AccountCenter component for user account management, payment configurations, and wallet binding. 2026-03-13 10:24:14 +08:00
2569718930@qq.com 74232907a9 feat: Add AccountCenter component for user profile, authentication, and payment management. 2026-03-13 10:19:59 +08:00
2569718930@qq.com 024df2dc5b feat: implement user account center with authentication, payments, and subscriptions. 2026-03-13 10:14:13 +08:00
2569718930@qq.com 9531cc4b64 feat: Implement AccountCenter component for user profile, points, and payment management. 2026-03-13 09:52:06 +08:00
2569718930@qq.com 990182e089 feat: Implement account center for user authentication, subscription, and payment management. 2026-03-13 09:50:04 +08:00
2569718930@qq.com 120f4b1261 feat: add Pro feature paywall and unlock overlay UI. 2026-03-13 09:34:58 +08:00
2569718930@qq.com f2e0940282 feat: Add UnlockProOverlay component and its styles for subscription upgrades. 2026-03-13 09:32:14 +08:00
2569718930@qq.com 3c3ffa1248 feat: add UnlockProOverlay component for displaying subscription details, features, and points-based discounts. 2026-03-13 09:25:37 +08:00
2569718930@qq.com 18a525af88 feat: add UnlockProOverlay component for managing subscriptions and displaying pricing with point-based discounts. 2026-03-13 09:20:05 +08:00
2569718930@qq.com c0d914eb88 feat: introduce UnlockProOverlay component for Pro subscription with point redemption. 2026-03-13 09:12:08 +08:00
2569718930@qq.com 0aaa09370b chore: Remove local npm/yarn cache and debug log entries from .gitignore. 2026-03-13 09:07:59 +08:00
2569718930@qq.com aa3546e2a9 feat: Add city detail panel with weather visualizations and integrate Pro feature paywall components. 2026-03-13 09:06:45 +08:00
2569718930@qq.com 003aff98d6 feat: Add dashboard, subscription, and account UI components, and update .gitignore to exclude npm cache files. 2026-03-13 08:51:06 +08:00
2569718930@qq.com 1e541433e5 feat: Implement AccountCenter component for user profile, subscription management, and payment processing. 2026-03-13 08:24:18 +08:00
2569718930@qq.com 0f51780566 feat: Introduce premium map dashboard with dark theme, pro features, and payment processing. 2026-03-13 08:15:27 +08:00
2569718930@qq.com a5948a35d4 feat: implement history and future forecast modals, alongside a new Pro feature paywall component. 2026-03-13 07:56:20 +08:00
2569718930@qq.com 5c3de0dbe0 feat: Add dashboard layout with dark theme and Pro feature paywall component. 2026-03-13 07:40:09 +08:00
2569718930@qq.com 31ee147734 feat: Implement contract checkout payment service and account management UI. 2026-03-13 07:31:47 +08:00
2569718930@qq.com 54c4a26162 feat: Implement subscription-based premium features, payment processing, and enhanced dashboard components. 2026-03-13 06:41:33 +08:00
2569718930@qq.com f7b649bb0a feat: Implement bot orchestration, command handling, and runtime coordination for background services. 2026-03-13 05:53:41 +08:00
2569718930@qq.com d5bbc43b52 feat: add AccountCenter component for user authentication, subscription, and payment management. 2026-03-13 05:25:46 +08:00
2569718930@qq.com e62a36e0bc feat: Introduce web application with payment processing and Supabase authentication. 2026-03-13 05:13:48 +08:00
2569718930@qq.com bd839d0cbe feat: Add client-side login and signup component using Supabase for email/password and Google OAuth authentication. 2026-03-13 03:54:31 +08:00
2569718930@qq.com e3bd90d186 feat: add client-side components for user login, signup, and account management. 2026-03-13 03:47:56 +08:00
2569718930@qq.com 9cc9ab93c4 feat: Implement account management center with authentication and subscription features, supported by new backend, i18n, and Supabase integration. 2026-03-13 03:27:56 +08:00
2569718930@qq.com 0a869459c4 feat: Implement Supabase authentication, account management UI, and entitlement services. 2026-03-13 02:23:01 +08:00
2569718930@qq.com 987aec2fa6 docs: Add commercialization, technical debt, and frontend documentation, and update project READMEs. 2026-03-12 12:01:29 +08:00
2569718930@qq.com f4fea03f35 feat: Implement new bot architecture including handlers, services, analysis modules, and comprehensive tests. 2026-03-12 11:44:52 +08:00
2569718930@qq.com c582015163 chore: release v1.3 2026-03-12 11:01:40 +08:00
2569718930@qq.com 27fe6ea494 feat: Introduce API and technical debt documentation, and enhance frontend caching, mispricing radar, and AI decision logic. 2026-03-12 10:43:31 +08:00
2569718930@qq.com 0877efe0c3 feat: Introduce new ai_analyzer and trend_engine modules for comprehensive weather trend and AI analysis. 2026-03-12 10:26:28 +08:00
2569718930@qq.com 20d3e940e0 feat: Add Polymarket read-only data layer, Telegram push utility, and market alert engine. 2026-03-12 10:06:09 +08:00
2569718930@qq.com c156809231 feat: Add a shell script to validate frontend caching behavior for PolyWeather API endpoints. 2026-03-12 09:41:48 +08:00
2569718930@qq.com be651cd1d7 feat: Implement HTTP caching for API routes and add a dashboard sidebar with city grouping and state persistence. 2026-03-12 09:29:29 +08:00
2569718930@qq.com 22082b9a83 feat: add Polymarket wallet activity watcher and update .env example with its configuration. 2026-03-12 08:17:11 +08:00
2569718930@qq.com 0c976ac846 feat: Introduce Polymarket wallet activity watcher with configurable user aliases and link previews. 2026-03-12 07:59:47 +08:00
2569718930@qq.com 47ff575588 docs: Add product screenshots to READMEs and include a new demo map image. 2026-03-12 03:18:07 +08:00
2569718930@qq.com b4d38590e1 feat: Add entitlement middleware, root layout, and PWA assets for the frontend. 2026-03-12 03:05:11 +08:00
2569718930@qq.com f065b67605 feat: add favicons and web manifest for PWA support. 2026-03-12 02:59:52 +08:00
2569718930@qq.com 4e5bf5a4f8 chore: release v1.2 2026-03-12 02:49:07 +08:00
2569718930@qq.com de6cf68ee7 feat: Implement reconciliation of recent actual high temperatures using METAR data and integrate it into the DEB command. 2026-03-12 02:43:19 +08:00
2569718930@qq.com 1060945d08 feat: Implement Dynamic Ensemble Blending (DEB) algorithm with historical data management, dynamic weight calculation, and accuracy tracking. 2026-03-12 02:32:10 +08:00
2569718930@qq.com ad2b1aa4b6 feat: Implement CitySidebar component with risk-level grouping and internationalization utilities. 2026-03-12 02:11:48 +08:00
2569718930@qq.com ba0916ea51 feat: add Polymarket wallet activity watcher and configuration for immediate size delta notifications. 2026-03-12 00:50:19 +08:00
2569718930@qq.com a46a98f25e feat: Add API route to fetch cities from the backend. 2026-03-12 00:40:28 +08:00
2569718930@qq.com 294e038f57 feat: Implement Polymarket wallet activity watcher and add related configuration options. 2026-03-12 00:36:33 +08:00
2569718930@qq.com eca0c7f514 feat: Introduce multi-source weather data collection, including city configuration and robust caching. 2026-03-12 00:29:34 +08:00
2569718930@qq.com 9295899aeb feat: implement PolyWeather Web Map API, a FastAPI backend integrating weather data collection and analysis to support an interactive map. 2026-03-11 12:22:06 +08:00
2569718930@qq.com db3b1c995a feat: Implement PolyWeather web map API with integrated weather data analysis, market alert engine, and Telegram push utility. 2026-03-11 12:15:09 +08:00
2569718930@qq.com e89fdb7e09 feat: enhance project documentation with new overview diagrams, commercialization roadmap, and technical debt backlog. 2026-03-11 11:45:57 +08:00
2569718930@qq.com 35345a1b04 docs: Introduce commercialization and technical debt documentation in English and Chinese, and update README diagram formatting. 2026-03-11 11:42:16 +08:00
2569718930@qq.com 025feaa96e feat: Implement PolyWeather web map API backend and integrate new frontend dashboard components for city weather details. 2026-03-11 11:37:07 +08:00
2569718930@qq.com 23e2959404 feat: implement PolyWeather web map API backend with data processing, caching, and entitlement, and add related documentation. 2026-03-11 11:29:56 +08:00
2569718930@qq.com 6273768cc2 docs: Add Chinese API documentation and a commercialization roadmap, alongside minor formatting updates to the main README. 2026-03-11 11:24:41 +08:00
2569718930@qq.com 44af26da70 docs: Add comprehensive documentation for commercialization and technical debt, and streamline the main README. 2026-03-11 11:18:48 +08:00
2569718930@qq.com 3cbef28b13 feat: Initialize PolyWeather frontend project with Next.js, including core dependencies, root layout, and a dashboard detail panel. 2026-03-11 11:14:03 +08:00
2569718930@qq.com d8cc193618 feat: Integrate Polymarket read-only data collection and display in dashboard panels. 2026-03-11 11:04:24 +08:00
2569718930@qq.com 1958b2764b feat: Implement core PolyWeather application with city weather query service, trend analysis, data collection, and dashboard UI. 2026-03-11 10:49:35 +08:00
2569718930@qq.com d2a40462c5 feat: Add city weather query service with city resolution, forecast processing, and weather summary generation. 2026-03-11 10:30:46 +08:00
2569718930@qq.com b3f46430ad feat: Introduce comprehensive weather data querying, analysis, and display services, integrate Polymarket data collection, and add Telegram notification utilities. 2026-03-11 10:23:33 +08:00
2569718930@qq.com 878e3280d1 fix: remove closed position template from wallet activity watcher 2026-03-11 09:22:07 +08:00
2569718930@qq.com 723694d77c style: translate polymarket wallet activity alerts to Chinese 2026-03-11 09:20:38 +08:00
2569718930@qq.com af4bee12f5 feat: Introduce a web frontend with new city API routes and refactor bot city query logic into a dedicated service. 2026-03-11 08:46:32 +08:00
2569718930@qq.com b1e75d13d8 feat: introduce WeatherDataCollector class for multi-source weather data retrieval, caching, and rate limiting. 2026-03-11 07:11:17 +08:00
2569718930@qq.com 8b6c66eab0 feat: Add fallback logic for future daily forecasts using multi-model medians, Meteoblue highs, and NWS periods when Open-Meteo data is unavailable. 2026-03-11 06:41:03 +08:00
2569718930@qq.com f7e7e0ea20 feat: Add multi-source weather data collection with a new Flask API and refine the bot's forecast display logic. 2026-03-11 06:33:25 +08:00
2569718930@qq.com 5d943f2aae feat: Create PolyWeather web API to serve analyzed weather and Polymarket data. 2026-03-11 05:56:40 +08:00
2569718930@qq.com 8712335ffd feat: Implement PolyWeather web map API by reusing existing analysis logic and centralize weather data collection. 2026-03-11 05:42:05 +08:00
2569718930@qq.com 41efaa1140 feat(weather_sources): parse Retry-After header from Open-Meteo 429 response to dynamically adjust cooldown instead of hardcoded 15m 2026-03-11 05:10:53 +08:00
2569718930@qq.com 2b681513ab fix(trend-engine): fallback to non-ensemble probability computation when OM drops to keep UI alive 2026-03-11 05:07:41 +08:00
2569718930@qq.com c5010a8b97 fix: safely fallback to metric unit per city instead of overwriting F to C randomly; allow trend engine to run even if Open-Meteo drops 2026-03-11 04:57:58 +08:00
2569718930@qq.com 535b2b0446 feat: add disk-based cache persistence for Open-Meteo to eliminate cold-start empty cache gaps 2026-03-11 04:39:20 +08:00
2569718930@qq.com 7f84314bcc fix: add Open-Meteo 429 shared cooldown timer to break retry death spiral on cold start 2026-03-11 04:32:32 +08:00
2569718930@qq.com 4b51e0dd15 feat: add 10min function-level cache to fetch_metar with stale fallback on error 2026-03-11 04:23:13 +08:00
2569718930@qq.com cc1a9a3b77 perf: increase Open-Meteo and Meteoblue cache TTL to 2 hours to reduce API rate limit bursts 2026-03-11 04:10:23 +08:00
2569718930@qq.com 46412511c8 fix: SyntaxError global declaration must be at function top not inside with-block 2026-03-11 03:59:55 +08:00
2569718930@qq.com c23020038e feat: add TTL cache and stale-cache fallback to Groq AI analyzer 2026-03-11 03:49:26 +08:00
2569718930@qq.com d4760dbcb2 fix: show MB/NWS/MGM as comp_parts when Open-Meteo daily data is missing 2026-03-11 03:44:10 +08:00
2569718930@qq.com ffb88ae61d feat: implement multi-source weather data collection including OpenWeatherMap, Visual Crossing, and METAR with caching. 2026-03-11 03:40:25 +08:00
2569718930@qq.com 4e6cb1071c feat: Implement Telegram push notifications for weather and market conditions and a Polymarket read-only data interface. 2026-03-11 02:13:38 +08:00
2569718930@qq.com d6434bf174 feat: Add weather alert engine, Polymarket data reader, and Telegram notification utility. 2026-03-11 01:56:22 +08:00
2569718930@qq.com 79b4708b7e revert: restore wallet activity watcher original closed-position behavior 2026-03-11 01:03:56 +08:00
2569718930@qq.com a6bef9a57f feat: wallet activity filter max_price to 0.10 and apply price filter to closed positions 2026-03-11 00:55:11 +08:00
2569718930@qq.com d08982a898 add logging for verifying price filter settings 2026-03-10 13:35:09 +08:00
2569718930@qq.com c53bfc629b feat: implement avg_price filtering for wallet activity watcher 2026-03-10 13:33:21 +08:00
2569718930@qq.com f638850048 feat: Add Polymarket wallet activity watcher and configure average price display thresholds. 2026-03-10 13:23:35 +08:00
2569718930@qq.com dd72e6a34b fix: resolve bot_listener encoding issues and syntax errors 2026-03-10 12:54:15 +08:00
2569718930@qq.com 5af63557df fix: Correct garbled Chinese characters in bot messages and logs. 2026-03-10 12:48:09 +08:00
2569718930@qq.com 35b846f1d6 feat: Add Polymarket and Polygon wallet activity watchers and integrate them into the bot. 2026-03-10 12:19:23 +08:00
2569718930@qq.com fb1c88463f feat: Add multi-source weather data collection (OpenWeatherMap, Meteoblue, Visual Crossing, METAR) and Polygon wallet watching functionality, introducing new configuration variables. 2026-03-10 11:51:43 +08:00
2569718930@qq.com d1b591106e feat: add dashboard panel components for hero summary and temperature chart 2026-03-10 10:37:14 +08:00
2569718930@qq.com 1afca8ecbb feat: Implement new dashboard panel sections, utilities, and a future forecast modal. 2026-03-10 10:17:59 +08:00
2569718930@qq.com e5011c5c8f feat: Introduce dashboard data types, utilities, market alert engine, and panel sections. 2026-03-10 10:07:18 +08:00
2569718930@qq.com 732b3c10a5 feat: Update README to detail React Quant Dashboard v2.0 features and refine Edge Analytics & Alerts descriptions. 2026-03-10 09:16:23 +08:00
2569718930@qq.com 396c373cba feat: implement PolyWeather dashboard with map UI, data collection, analysis, and comprehensive documentation. 2026-03-10 09:02:56 +08:00
2569718930@qq.com aab4477ab3 feat: Implement the PolyWeather dashboard including frontend components, data collection, analysis, and API endpoints. 2026-03-10 04:45:40 +08:00
2569718930@qq.com 020c62676e docs: Add Chinese technical debt documentation. 2026-03-09 11:04:28 +08:00
2569718930@qq.com 243bf6c33b feat: fully migrate frontend to a React component-driven architecture and expand project documentation. 2026-03-09 10:53:23 +08:00
2569718930@qq.com 283a935f24 feat: Implement initial weather dashboard with interactive map, city details, and API integration. 2026-03-09 10:36:03 +08:00
2569718930@qq.com d162b91ed9 feat: implement initial PolyWeather application frontend with map, city list, detailed weather panel, and guide modals. 2026-03-09 05:56:22 +08:00
2569718930@qq.com d45ee8a981 feat: embed the legacy PolyWeather dashboard into the main application page using an iframe. 2026-03-09 04:52:25 +08:00
2569718930@qq.com d184bc323c feat: introduce PolyWeather web map API and frontend with integrated weather data collection and analysis. 2026-03-09 04:41:24 +08:00
2569718930@qq.com b53f1f74bd feat: Add initial PolyWeather legacy dashboard application with map, detail panel, and temperature charts. 2026-03-08 13:24:34 +08:00
2569718930@qq.com 0417387c0b feat: introduce PolyWeather web API and frontend for interactive weather data display, centralizing data collection and analysis. 2026-03-08 12:59:45 +08:00
2569718930@qq.com 1655a026e8 feat: Implement multi-source weather data collection from OpenWeatherMap, Visual Crossing, and NOAA METAR sources. 2026-03-08 09:57:08 +08:00
2569718930@qq.com 2da6e6829f feat: Increase daily check-in points from 1 to 4 and update point rule descriptions. 2026-03-08 05:03:41 +08:00
2569718930@qq.com ae40f70cac feat: Lower the minimum message length from 4 to 2. 2026-03-08 05:00:29 +08:00
2569718930@qq.com d2107cbcbe feat: Implement a new terminal dashboard featuring an analytics panel, sparklines, and foundational UI components and utilities. 2026-03-08 04:53:38 +08:00
198 changed files with 42629 additions and 3274 deletions
+168 -3
View File
@@ -1,15 +1,31 @@
# Telegram Bot
TELEGRAM_BOT_TOKEN=your_bot_token_here
TELEGRAM_CHAT_ID=your_chat_id_here
# Optional multi-chat target (comma-separated). If set, it will be merged with TELEGRAM_CHAT_ID.
# Example: TELEGRAM_CHAT_IDS=-1003586303099,-1003539418691
TELEGRAM_CHAT_IDS=
# Optional: route /city and /deb outputs to a fixed forum topic.
# If TELEGRAM_QUERY_TOPIC_CHAT_ID is empty, fallback to command source chat.
TELEGRAM_QUERY_TOPIC_CHAT_ID=
TELEGRAM_QUERY_TOPIC_ID=
# Optional per-group topic routing (higher priority than fixed topic above):
# format: chat_id:topic_id,chat_id:topic_id
# Example: TELEGRAM_QUERY_TOPIC_MAP=-1003586303099:25513,-1003539418691:25514
TELEGRAM_QUERY_TOPIC_MAP=
TELEGRAM_ALERT_PUSH_ENABLED=true
TELEGRAM_ALERT_PUSH_INTERVAL_SEC=300
TELEGRAM_ALERT_PUSH_COOLDOWN_SEC=1800
TELEGRAM_ALERT_MIN_TRIGGER_COUNT=2
TELEGRAM_ALERT_MIN_SEVERITY=medium
TELEGRAM_ALERT_CITIES=ankara,london,paris,seoul,toronto,buenos aires,wellington,new york,chicago,dallas,miami,atlanta,seattle,lucknow,sao paulo,munich
# Mispricing radar: skip push when YES buy price is above this cap (10c = 0.10)
TELEGRAM_ALERT_MISPRICING_MAX_YES_BUY=0.10
TELEGRAM_ALERT_CITIES=ankara,london,paris,seoul,hong kong,shanghai,singapore,tokyo,tel aviv,toronto,buenos aires,wellington,new york,chicago,dallas,miami,atlanta,seattle,lucknow,sao paulo,munich
# AI
GROQ_API_KEY=your_groq_api_key_here
# Open-Meteo (forecast data changes ~hourly, no need to refresh more often)
OPEN_METEO_CACHE_TTL_SEC=7200
OPEN_METEO_ENSEMBLE_CACHE_TTL_SEC=7200
OPEN_METEO_MULTI_MODEL_CACHE_TTL_SEC=7200
OPEN_METEO_MULTI_MODEL_CACHE_VERSION=v2
# Proxy Setting (optional)
HTTPS_PROXY=http://127.0.0.1:7890
@@ -19,3 +35,152 @@ HTTP_PROXY=http://127.0.0.1:7890
LOG_LEVEL=INFO
ENV=production
POLYWEATHER_MAP_URL=https://polyweather-pro.vercel.app/
# Runtime data directory (host path mounted into container at /var/lib/polyweather and /app/data)
POLYWEATHER_RUNTIME_DATA_DIR=/var/lib/polyweather
# Recommended: keep SQLite outside repository workspace
POLYWEATHER_DB_PATH=/var/lib/polyweather/polyweather.db
# Recommended disk cache/state paths (optional; defaults are still /app/data/*)
OPEN_METEO_DISK_CACHE_PATH=/var/lib/polyweather/open_meteo_cache.json
# Unified Auth (Supabase + Google/Email)
POLYWEATHER_AUTH_ENABLED=false
# If true: website APIs require login; if false: guest access, login optional.
POLYWEATHER_AUTH_REQUIRED=false
# If true, authenticated users must also have an active row in `subscriptions`.
POLYWEATHER_AUTH_REQUIRE_SUBSCRIPTION=false
SUPABASE_URL=
SUPABASE_ANON_KEY=
SUPABASE_SERVICE_ROLE_KEY=
SUPABASE_HTTP_TIMEOUT_SEC=8
SUPABASE_AUTH_CACHE_TTL_SEC=30
SUPABASE_SUB_CACHE_TTL_SEC=60
# Frontend wallet connection (WalletConnect v2)
# Apply in Vercel env as NEXT_PUBLIC_*
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=
NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL=https://polygon-bor-rpc.publicnode.com
# Bot command access guard (/city, /deb):
# - Pro entitlement removed
# - user only needs to be a member of any configured group (TELEGRAM_CHAT_IDS / TELEGRAM_CHAT_ID)
POLYWEATHER_BOT_GROUP_INVITE_URL=
# Group message points (anti-spam + ranking)
POLYWEATHER_BOT_MESSAGE_POINTS=4
POLYWEATHER_BOT_MESSAGE_DAILY_CAP=40
POLYWEATHER_BOT_MESSAGE_MIN_LENGTH=3
POLYWEATHER_BOT_MESSAGE_COOLDOWN_SEC=30
POLYWEATHER_BOT_CITY_QUERY_COST=1
POLYWEATHER_BOT_DEB_QUERY_COST=1
# Weekly leaderboard reward settlement
# settle_weekday: 1=Mon ... 7=Sun
POLYWEATHER_WEEKLY_REWARD_ENABLED=true
POLYWEATHER_WEEKLY_REWARD_TIMEZONE=Asia/Shanghai
POLYWEATHER_WEEKLY_REWARD_SETTLE_WEEKDAY=1
POLYWEATHER_WEEKLY_REWARD_SETTLE_HOUR=0
POLYWEATHER_WEEKLY_REWARD_SETTLE_MINUTE=5
POLYWEATHER_WEEKLY_REWARD_CHECK_INTERVAL_SEC=300
POLYWEATHER_WEEKLY_REWARD_HTTP_TIMEOUT_SEC=10
POLYWEATHER_WEEKLY_REWARD_ANNOUNCE_ENABLED=true
# Backend entitlement guard (for /api/cities, /api/city/*, /api/history/*)
POLYWEATHER_REQUIRE_ENTITLEMENT=false
POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN=
# P1 Contract Checkout (MetaMask + Polygon USDC)
POLYWEATHER_PAYMENT_ENABLED=false
POLYWEATHER_PAYMENT_CHAIN_ID=137
POLYWEATHER_PAYMENT_RPC_URL=https://polygon-rpc.com
# Legacy single-token fallback (still supported)
POLYWEATHER_PAYMENT_RECEIVER_CONTRACT=
POLYWEATHER_PAYMENT_TOKEN_ADDRESS=0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174
POLYWEATHER_PAYMENT_TOKEN_DECIMALS=6
# Recommended multi-token config (supports USDC.e + Native USDC at the same time)
# Example:
# [
# {"code":"usdc_e","symbol":"USDC.e","name":"USDC.e (PoS)","address":"0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174","decimals":6,"receiver_contract":"0xYourCheckoutContract","is_default":true},
# {"code":"usdc","symbol":"USDC","name":"Native USDC","address":"0x3c499c542cef5e3811e1192ce70d8cc03d5c3359","decimals":6,"receiver_contract":"0xYourCheckoutContract"}
# ]
POLYWEATHER_PAYMENT_ACCEPTED_TOKENS_JSON=
POLYWEATHER_PAYMENT_CONFIRMATIONS=2
POLYWEATHER_PAYMENT_INTENT_TTL_SEC=1800
POLYWEATHER_PAYMENT_WALLET_CHALLENGE_TTL_SEC=600
POLYWEATHER_PAYMENT_HTTP_TIMEOUT_SEC=10
POLYWEATHER_PAYMENT_POLL_INTERVAL_SEC=4
POLYWEATHER_PAYMENT_MAX_WAIT_SEC=50
POLYWEATHER_PAYMENT_TELEGRAM_NOTIFY_ENABLED=true
# Payment points redemption
POLYWEATHER_PAYMENT_POINTS_ENABLED=true
POLYWEATHER_PAYMENT_POINTS_PER_USDC=500
POLYWEATHER_PAYMENT_POINTS_MAX_DISCOUNT_USDC=3
# Comma-separated allowed plans for checkout UI + backend validation.
# Default is monthly-only launch.
POLYWEATHER_PAYMENT_ALLOWED_PLAN_CODES=pro_monthly
# JSON object
# Example: {"pro_monthly":{"plan_id":101,"amount_usdc":"5","duration_days":30}}
POLYWEATHER_PAYMENT_PLAN_CATALOG_JSON=
# Polymarket P0 Read-Only Market Layer
POLYMARKET_MARKET_SCAN_ENABLED=true
POLYMARKET_GAMMA_URL=https://gamma-api.polymarket.com
POLYMARKET_CLOB_URL=https://clob.polymarket.com
POLYMARKET_CHAIN_ID=137
POLYMARKET_HTTP_TIMEOUT_SEC=8
POLYMARKET_MARKET_CACHE_TTL_SEC=180
POLYMARKET_PRICE_CACHE_TTL_SEC=10
POLYMARKET_DISCOVERY_PAGES=6
POLYMARKET_DISCOVERY_LIMIT=200
POLYMARKET_SIGNAL_MIN_LIQUIDITY=500
POLYMARKET_SIGNAL_EDGE_PCT=2
# Polygon Wallet Watcher (Single Chain P0)
POLYGON_WALLET_WATCH_ENABLED=false
POLYGON_RPC_URL=https://polygon-rpc.com
POLYGON_WALLET_WATCH_ADDRESSES=0x0000000000000000000000000000000000000000
POLYGON_WALLET_WATCH_INTERVAL_SEC=8
POLYGON_WALLET_WATCH_CONFIRMATIONS=2
POLYGON_WALLET_WATCH_MAX_BLOCKS_PER_CYCLE=30
POLYGON_WALLET_WATCH_SEEN_TTL_SEC=604800
POLYGON_WALLET_WATCH_RPC_TIMEOUT_SEC=10
POLYGON_WALLET_WATCH_TX_BASE=https://polygonscan.com/tx
POLYGON_WALLET_WATCH_ADDR_BASE=https://polygonscan.com/address
POLYGON_WALLET_WATCH_POLYMARKET_ONLY=true
POLYGON_WALLET_WATCH_INCLUDE_DEFAULT_PM_CONTRACTS=true
# Optional custom Polymarket contracts, format: LABEL:0x...,LABEL2:0x...
POLYGON_WALLET_WATCH_POLYMARKET_CONTRACTS=
# Polymarket Wallet Activity Watcher (all markets, not weather-only)
POLYMARKET_WALLET_ACTIVITY_ENABLED=false
POLYMARKET_WALLET_ACTIVITY_USERS=0x0000000000000000000000000000000000000000
# Optional dedicated chat targets for wallet activity push (recommended)
# If unset, fallback to TELEGRAM_CHAT_IDS / TELEGRAM_CHAT_ID.
POLYMARKET_WALLET_ACTIVITY_CHAT_ID=
POLYMARKET_WALLET_ACTIVITY_CHAT_IDS=
# Optional: mirror wallet activity push to a forum topic, while keeping existing chat targets unchanged.
POLYMARKET_WALLET_ACTIVITY_TOPIC_CHAT_ID=
POLYMARKET_WALLET_ACTIVITY_TOPIC_ID=
# Optional wallet nicknames:
# - CSV: 0xabc...=Whale_A,0xdef...=Main_Account
# - JSON: {"0xabc...":"Whale A","0xdef...":"Main Account"}
# - Env key: POLYMARKET_WALLET_ACTIVITY_USER_ALIASES
# (legacy typo POLYMARKET_WALLET_ACTIVITY_USERS_ALIASES is also accepted)
POLYMARKET_WALLET_ACTIVITY_USER_ALIASES=
POLYMARKET_WALLET_ACTIVITY_DATA_API_URL=https://data-api.polymarket.com
POLYMARKET_WALLET_ACTIVITY_INTERVAL_SEC=20
POLYMARKET_WALLET_ACTIVITY_TIMEOUT_SEC=10
POLYMARKET_WALLET_ACTIVITY_MIN_SIZE_ABS=0.001
POLYMARKET_WALLET_ACTIVITY_MIN_SIZE_DELTA=0.001
POLYMARKET_WALLET_ACTIVITY_MIN_AVG_PRICE_DELTA=0.002
POLYMARKET_WALLET_ACTIVITY_IMMEDIATE_ON_SIZE_DELTA=true
POLYMARKET_WALLET_ACTIVITY_IMMEDIATE_SIZE_DELTA_MIN=0.001
POLYMARKET_WALLET_ACTIVITY_IMMEDIATE_COOLDOWN_SEC=20
POLYMARKET_WALLET_ACTIVITY_MAX_CHANGES_PER_MSG=5
POLYMARKET_WALLET_ACTIVITY_NOTIFY_CLOSED=false
POLYMARKET_WALLET_ACTIVITY_BOOTSTRAP_ALERT=false
POLYMARKET_WALLET_ACTIVITY_LINK_PREVIEW=true
POLYMARKET_WALLET_ACTIVITY_UPDATE_DEBOUNCE_SEC=30
POLYMARKET_WALLET_ACTIVITY_UPDATE_MAX_HOLD_SEC=120
POLYMARKET_WALLET_ACTIVITY_AVG_PRICE_SHOW_MIN=0.01
POLYMARKET_WALLET_ACTIVITY_AVG_PRICE_SHOW_MAX=0.99
# Skip wallet activity pushes when position value is below this floor (USD).
# Set 0 to disable.
POLYMARKET_WALLET_ACTIVITY_MIN_POSITION_VALUE_USD=0
# Comma-separated wallet addresses exempt from min value floor.
# Example: 0x849d9a4dd64829b8b0141ea53e7caca7e99529ec
POLYMARKET_WALLET_ACTIVITY_MIN_VALUE_EXEMPT_USERS=
+11
View File
@@ -2,6 +2,9 @@
.env
# Data and Logs
data/*.db
data/*.db-*
data/*.db.*
data/*.json
data/logs/
data/historical/
@@ -30,3 +33,11 @@ Thumbs.db
frontend/node_modules/
frontend/.next/
frontend/.vercel/
.npm-cache/
.env.local
.vercel/
# Browser extension build artifacts
/extension.zip
/extension-*.zip
+5
View File
@@ -0,0 +1,5 @@
{
"css.validate": false,
"scss.validate": false,
"less.validate": false
}
+8
View File
@@ -0,0 +1,8 @@
# Changelog
## 1.4.0 - 2026-03-14
- 统一收费阶段产品口径,发布 PolyWeather Pro `v1.4.0`
- 前端交付覆盖账户、支付、权限展示与缓存策略
- 支付链路支持 intent -> submit -> confirm 与自动补单
- 文档统一切换到单一版本源管理
+82
View File
@@ -0,0 +1,82 @@
# 前端交付与重构报告(v1.4.0)
最后更新:`2026-03-14`
## 1. 报告目的
说明当前线上前端(`frontend/`)在收费阶段的实际交付状态。
## 2. 当前前端架构
```mermaid
flowchart LR
B["Browser"] --> N["Next.js App Router (Vercel)"]
N --> RH["Route Handlers /api/*"]
RH --> F["FastAPI (VPS)"]
N --> STORE["Dashboard Store"]
STORE --> MAP["MapCanvas"]
STORE --> SIDEBAR["CitySidebar"]
STORE --> PANEL["DetailPanel + Modal"]
STORE --> ACCOUNT["Account Center + Pro Overlay"]
```
## 3. 已落地能力
### 3.1 信息架构与交互
- 风险分组侧栏折叠(持久化)。
- 选中城市状态持久化。
- 今日分析、历史对账、未来日期分析联动。
### 3.2 收费相关
- 账户中心(登录态、积分、订阅状态、钱包管理)。
- Pro 解锁浮层(套餐、积分抵扣、FAQ、社群入口)。
- 钱包绑定:浏览器扩展钱包 + WalletConnect 扫码。
- 支付流程:create intent -> submit -> confirm。
- `confirm pending` 时自动轮询 intent 状态,确认后自动刷新订阅态。
### 3.3 缓存与性能
- BFF `ETag/304``cities` / `summary` / `history`
- `summary?force_refresh=true` => `no-store`
- `sessionStorage` + in-flight 去重。
- `localStorage`:选中城市、侧栏折叠状态。
### 3.4 可访问性与稳定性
- 详情面板 `inert + blur` 焦点冲突修复。
- 关键支付错误文案标准化(用户取消、gas 不足、pending)。
## 4. 当前明确未做
- 离线能力(Service Worker / IndexedDB
- 前端级财务报表与退款后台(后端/运营侧)
## 5. 验收建议
### 5.1 前端构建
```bash
cd frontend
npm run build
```
### 5.2 缓存验收
```bash
./scripts/validate_frontend_cache.sh "https://polyweather-pro.vercel.app"
```
### 5.3 支付验收
- 绑定钱包
- 创建 intent
- 发交易
- 验证 `intent` 状态从 `submitted -> confirmed`
- 校验账户页订阅状态更新
## 6. 结论
前端已具备收费阶段的核心能力(账户、支付、权限展示、状态回收),可支持持续商业迭代。
+102 -109
View File
@@ -1,147 +1,140 @@
# 🌡️ PolyWeather Pro
# PolyWeather Pro
> **Professional Weather Intelligence System** —— Specialized in edge data collection, DEB smart blending, and real-time decision alerts.
Production weather-intelligence stack for temperature settlement markets.
---
Official dashboard: [polyweather-pro.vercel.app](https://polyweather-pro.vercel.app/)
## 💎 Project Vision
## Product Screenshots
PolyWeather is a specialized intelligence system built for **Polymarket** high-stakes participants. We don't just provide weather forecasts; we aggregate data from top-tier global meteorological sources, apply our proprietary **DEB (Dynamic Error Balancing)** algorithm, and deliver **market-shifting alerts** at critical decision nodes.
### Global Dashboard
---
![PolyWeather global dashboard](docs/images/demo_map.png)
## 🏗️ Production Architecture
### City Analysis (Ankara)
This project utilizes a production-grade decoupled architecture for high availability:
![PolyWeather Ankara analysis](docs/images/demo_ankara.png)
- **Frontend**: A **Next.js** interactive dashboard deployed on **Vercel**.
- **Backend API**: A **FastAPI** service running on a VPS, providing low-latency data access.
- **Bot & Alert Heartbeat**: A **Telegram Bot** running on a VPS, executing minute-level global scans and push notifications.
## Product Status (2026-03)
🔗 **Official Visit**: [polyweather-pro.vercel.app](https://polyweather-pro.vercel.app/)
- Subscription live: `Pro Monthly 5 USDC`.
- Points redemption live: `500 points = 1 USDC`, max `3 USDC` off.
- Onchain checkout live: Polygon contract checkout (USDC / USDC.e).
- Auto-reconciliation live: event listener + periodic confirm loop.
---
## Open-Core Boundary (Important)
## 🖼️ Preview & Interaction
This repository follows an **Open-Core** strategy:
<p align="center">
<img src="docs/images/demo_ankara.png" alt="PolyWeather Demo - Ankara Live Analysis" width="450">
<br>
<em>📊 <b>Deep Query View</b>: DEB Blended Forecast + Settlement Probability + Groq AI Expert Advice</em>
</p>
- Public in repo: weather aggregation, core analysis, dashboard, bot baseline, standard payment flow.
- Private in production: commercial risk rules, operational thresholds, pricing strategy details, internal reconciliation policies, and growth operations tooling.
<p align="center">
<img src="./docs/images/demo_map.png" alt="PolyWeather Web Map" width="850">
<br>
<em>🗺️ <b>Omni-Dashboard</b>: Real-time global heatmaps + array-style data visualization</em>
</p>
See: [Open-Core & Commercial Boundary](docs/OPEN_CORE_POLICY.md)
---
## Core Capabilities
## 🚀 Core Features
- Aggregates observations and forecasts for 20 monitored cities.
- Uses DEB (Dynamic Error Balancing) to blend multi-model highs.
- Generates settlement-oriented probability buckets (`mu` + bucket distribution).
- Maps weather view to Polymarket quotes for mispricing scan.
- Reuses one analysis core across web dashboard and Telegram bot.
- **📡 Full-Spectrum Collection**
- **Major Models**: Real-time sync for ECMWF, GFS, ICON, GEM, and JMA high temperatures.
- **Observed Data**: Global airport METAR reports + official Turkish MGM station-level data.
- **Centralized Correction**: Integrated `17130` (Center) official data specifically for Ankara.
- **⚖️ DEB Smart Blending**
- Dynamic weighting of forecasts based on recent 7-day historical performance.
- **🔔 Alert Engine**
- **Momentum Spike**: Captures rapid temperature changes within 30 minutes.
- **Forecast Breakthrough**: Fires when observations exceed all model predictions plus a safety margin.
- **Advection Monitoring**: Simulates warm/cold advection based on lead stations and wind currents.
- **🛡️ Smart Suppression**
- **Peak Protection**: Automatically switches to snapshot mode when the daily high has likely passed.
- **Cooldown Management**: Global and city-level cooldowns to prevent notification fatigue.
---
## 🔐 Alert Logic Details
| Trigger Name | Core Logic | Trading Value |
| :--------------- | :-------------------------------------------- | :-------------------------------------------- |
| **Center Hit** | Detects DEB trigger only at Ankara HQ `17130` | **Highest priority signal**, the "truth" |
| **Momentum** | 30min temperature slope exceed threshold | Captures sudden weather fronts |
| **Breakthrough** | Pierces all model highs + margin | Captures high-volatility outlier events |
| **Advection** | Lead station rise + Wind match | Gain 20-40 minutes of lead time for execution |
---
## 🏗️ System Architecture
## Reference Architecture
```mermaid
graph TD
subgraph "Client / Terminals"
Web[Next.js Web App]
TG[Telegram Client]
end
flowchart LR
U["Users (Web / Telegram)"] --> FE["Next.js Frontend (Vercel)"]
U --> BOT["Telegram Bot (VPS)"]
FE --> API["FastAPI /web/app.py"]
BOT --> API
subgraph "Edge Deployment (Vercel)"
Web -.-> |Auth| Supa[(Supabase Auth/DB)]
Web --> |API| Fast[FastAPI API]
end
API --> WX["Weather Collector"]
WX --> METAR["Aviation Weather (METAR)"]
WX --> MGM["MGM (Turkey station network)"]
WX --> OM["Open-Meteo"]
subgraph "Core Hub (VPS)"
Fast --- |Shared Logic| Worker[Alert Engine / Worker]
Bot[Telegram Bot] --- |Shared Logic| Worker
Worker --> |Cache/Sub| Supa
end
subgraph "External Sources"
Worker --> |Pull| MGM[MGM Weather]
Worker --> |Pull| METAR[Airport METAR]
Worker --> |Pull| OM[Open-Meteo]
Worker --> |Pull| MM[Multi-Model Integration]
end
Worker --> |Push Alert| TG
Bot --> |Query| Worker
API --> ANA["DEB + Trend + Probability + Market Scan"]
ANA --> PAY["Payment State (Intent + Event + Confirm Loop)"]
ANA --> PM["Polymarket Read-only Layer"]
```
---
## Monitored Cities (20)
## 🛠️ Deployment
- Europe / Middle East: Ankara, London, Paris, Munich
- APAC: Seoul, Hong Kong, Shanghai, Singapore, Tokyo, Wellington
- Americas: Toronto, New York, Chicago, Dallas, Miami, Atlanta, Seattle, Buenos Aires, Sao Paulo
- South Asia: Lucknow
### 1. Backend / Bot (VPS)
## Quick Start
### Backend + Bot (Docker)
```bash
# Pull Source
git pull
# Environment
# Edit .env with TELEGRAM_BOT_TOKEN and other keys
# Launch
docker-compose up -d --build
docker compose up -d --build
```
### 2. Frontend (Vercel)
### Frontend (local)
Associate the `frontend` directory as the project root on Vercel for automatic CI/CD.
```bash
cd frontend
npm install
npm run dev
```
---
## Runtime Data (Recommended on VPS)
## 💬 Bot Commands
Use external runtime storage to avoid SQLite/git conflicts:
| Command | Description | Example |
| :-------- | :-------------------------------------- | :------------- |
| `/city` | Query real-time analysis for a city | `/city ankara` |
| `/deb` | View historical accuracy of DEB model | `/deb london` |
| `/points` | View your activity points & leaderboard | `/points` |
| `/help` | Get detailed instructions | `/help` |
```env
POLYWEATHER_RUNTIME_DATA_DIR=/var/lib/polyweather
POLYWEATHER_DB_PATH=/var/lib/polyweather/polyweather.db
```
---
## Ops Verification
> [!NOTE]
> **Commercialization**: This project currently offers **Web Dashboard ($5/mo)** and **Telegram Signal Channel ($1/mo)** subscriptions.
> Point-earning via group participation is active and points can be redeemed for access.
### Frontend cache headers
---
```bash
./scripts/validate_frontend_cache.sh "https://polyweather-pro.vercel.app"
```
---
### Payment auto-reconciliation logs
**📅 Last Updated**: 2026-03-08
**🚀 Status**: v1.0 Stable - Professional Quant UI Locked
```bash
docker compose logs -f polyweather | egrep "payment event loop started|payment confirm loop started|payment auto-confirmed"
```
> [!TIP]
> **Production Note**: The current dashboard utilizes the high-density "Professional Quant" UI (v1.0-legacy) which integrates real-time METAR/MGM data, DEB ensemble blending, and multi-model probability distribution in a single high-performance view.
### Wallet activity logs
```bash
docker compose logs -f polyweather | egrep "polymarket wallet activity watcher started|wallet activity pushed"
```
## Telegram Commands
| Command | Purpose |
| :-- | :-- |
| `/city <name>` | City real-time analysis |
| `/deb <name>` | DEB historical reconciliation |
| `/top` | User leaderboard |
| `/id` | Show current chat ID |
| `/diag` | Startup diagnostics |
| `/help` | Help and usage |
## Documentation Index
- Chinese overview: [README_ZH.md](README_ZH.md)
- Chinese API guide: [docs/API_ZH.md](docs/API_ZH.md)
- Commercialization: [docs/COMMERCIALIZATION.md](docs/COMMERCIALIZATION.md)
- Open-Core policy: [docs/OPEN_CORE_POLICY.md](docs/OPEN_CORE_POLICY.md)
- Supabase setup (ZH): [docs/SUPABASE_SETUP_ZH.md](docs/SUPABASE_SETUP_ZH.md)
- Tech debt (EN): [docs/TECH_DEBT.md](docs/TECH_DEBT.md)
- Tech debt (ZH): [docs/TECH_DEBT_ZH.md](docs/TECH_DEBT_ZH.md)
- Payment verification: [docs/payments/POLYGONSCAN_VERIFY.md](docs/payments/POLYGONSCAN_VERIFY.md)
- Frontend report: [FRONTEND_REDESIGN_REPORT.md](FRONTEND_REDESIGN_REPORT.md)
- Release process: [RELEASE.md](RELEASE.md)
- Changelog: [CHANGELOG.md](CHANGELOG.md)
## Version
- Version: `v1.4.0`
- Last Updated: `2026-03-14`
+102 -109
View File
@@ -1,147 +1,140 @@
# 🌡️ PolyWeather Pro
# PolyWeather Pro
> **专业级博弈情报系统** —— 专注边缘气象数据采集、DEB 智能融合与实时决策预警
面向温度结算市场的生产级气象情报系统
---
官方看板:[polyweather-pro.vercel.app](https://polyweather-pro.vercel.app/)
## 💎 项目愿景
## 产品截图
PolyWeather 是一套专为 **Polymarket** 深度博弈者设计的实时情报系统。我们不只是提供天气预报,而是通过聚合全球顶级气象源、应用自研的 **DEB (Dynamic Error Balancing)** 算法,并在关键时间节点提供**具有博弈预测价值**的异动预警。
### 全球看板
---
![PolyWeather 全球地图看板](docs/images/demo_map.png)
## 🏗️ 生产架构
### 城市分析(Ankara
本项目采用生产级解耦架构,确保高可用与实时性:
![PolyWeather Ankara 分析页](docs/images/demo_ankara.png)
- **前端**:部署在 **Vercel** 上的 **Next.js** 交互式仪表盘。
- **后端 API**:运行在 VPS 上的 **FastAPI**,提供低延迟数据服务。
- **机器人与预警心跳**:运行在 VPS 上的 **Telegram Bot**,执行每分钟级的全球扫描与推送。
## 当前产品状态(2026-03
🔗 **官方访问地址**[polyweather-pro.vercel.app](https://polyweather-pro.vercel.app/)
- 已上线订阅制:`Pro 月付 5 USDC`
- 已上线积分抵扣:`500 积分 = 1 USDC`,最多抵扣 `3 USDC`
- 已上线链上支付:Polygon 合约支付(USDC / USDC.e)。
- 已上线自动补单:事件监听 + 周期确认双链路。
---
## 开源边界(重要)
## 🖼️ 预览与交互
本项目采用 **Open-Core** 策略:
<p align="center">
<img src="docs/images/demo_ankara.png" alt="PolyWeather 效果展示 - 安卡拉实时分析" width="450">
<br>
<em>📊 <b>深度查询效果</b>DEB 融合预测 + 结算概率 + Groq AI 专家建议</em>
</p>
- 仓库公开部分:天气聚合、基础分析、前端看板、Bot 基础能力、支付标准流程示例。
- 生产私有部分:商业风控规则、运营阈值、收费策略细节、付费用户运营脚本、内部对账与审计策略。
<p align="center">
<img src="./docs/images/demo_map.png" alt="PolyWeather Web Map" width="850">
<br>
<em>🗺️ <b>全景仪表盘</b>:全球站点实时热力场 + 阵列式数据展示</em>
</p>
详细见:[Open-Core 与商用边界](docs/OPEN_CORE_POLICY.md)
---
## 核心能力
## 🚀 核心功能
- 聚合 20 个监控城市的实测与预报数据。
- DEBDynamic Error Balancing)融合多模型最高温。
- 输出结算导向概率分布(`mu` + 温度桶)。
- 将模型观点映射到 Polymarket 行情,做错价扫描。
- Web 仪表盘与 Telegram Bot 复用同一分析内核。
- **📡 多源全量采集**
- **主流模型**ECMWF, GFS, ICON, GEM, JMA 实时最高温同步。
- **实测数据**:全球机场 METAR 定时报文 + 土耳其 MGM 局点官方实测。
- **中心化纠偏**:针对安卡拉特别接入 `17130` (Center) 官方指挥中心数据。
- **⚖️ DEB 智能融合**
- 基于近期 7 天历史表现,动态调整各模型权重的博弈预测。
- **🔔 异动预警系统 (Alert Engine)**
- **动量突变**:捕捉 30 分钟内的急剧温变。
- **预测突破**:当实测击穿所有预报上限时触发告警。
- **平流监测**:基于周边前导站的风向流场模拟,预测冷/暖平流的到达。
- **🛡️ 智能压制逻辑**
- **峰值保护**:当日高温峰值大概率已过时,自动转为静默/快照模式,拒绝骚扰。
- **冷却管理**:同一信号路径支持全局与城市级双重 CD。
---
## 🔐 预警逻辑深度说明
| 触发器名称 | 核心逻辑 | 博弈价值 |
| :--------------- | :------------------------------------------- | :--------------------------------- |
| **Center Hit** | 仅识别安卡拉总部 `17130` 站点的 DEB 触发信号 | **最高级信号**,定盘星 |
| **Momentum** | 30min 温度斜率超过 | 捕捉突发天气系统(如锋面) |
| **Breakthrough** | 击穿所有预报上限 + 安全边际 | 捕捉市场极少数情况下的暴利点 |
| **Advection** | 前导站温升 + 风向匹配 | 获得 20-40 分钟的提前离场/建仓时间 |
---
## 🏗️ 架构解析
## 参考架构
```mermaid
graph TD
subgraph "客户端 / 终端"
Web[Next.js 网页端]
TG[Telegram 客户端]
end
flowchart LR
U["用户(Web / Telegram"] --> FE["Next.js 前端(Vercel"]
U --> BOT["Telegram BotVPS"]
FE --> API["FastAPI /web/app.py"]
BOT --> API
subgraph "云端部署 (Vercel)"
Web -.-> |Auth| Supa[(Supabase Auth/DB)]
Web --> |API| Fast[FastAPI API]
end
API --> WX["Weather Collector"]
WX --> METAR["Aviation WeatherMETAR"]
WX --> MGM["MGM(土耳其站网)"]
WX --> OM["Open-Meteo"]
subgraph "核心引擎 (VPS)"
Fast --- |Shared Logic| Worker[Alert Engine / Worker]
Bot[Telegram Bot] --- |Shared Logic| Worker
Worker --> |Cache/Sub| Supa
end
subgraph "外部数据源"
Worker --> |Pull| MGM[MGM 气象局]
Worker --> |Pull| METAR[机场实测]
Worker --> |Pull| OM[Open-Meteo]
Worker --> |Pull| MM[多模型集成]
end
Worker --> |Push Alert| TG
Bot --> |Query| Worker
API --> ANA["DEB + 趋势 + 概率 + 市场扫描"]
ANA --> PAY["支付状态(Intent + Event + Confirm Loop"]
ANA --> PM["Polymarket 只读层"]
```
---
## 监控城市(20
## 🛠️ 部署指南
- 欧洲/中东:Ankara、London、Paris、Munich
- 亚太:Seoul、Hong Kong、Shanghai、Singapore、Tokyo、Wellington
- 美洲:Toronto、New York、Chicago、Dallas、Miami、Atlanta、Seattle、Buenos Aires、Sao Paulo
- 南亚:Lucknow
### 1. 后端 / 机器人 (VPS)
## 快速启动
### 后端 + BotDocker
```bash
# 获取源码
git pull
# 环境配置
# 编辑 .env 文件,填入 TELEGRAM_BOT_TOKEN 等关键参数
# 一键启动
docker-compose up -d --build
docker compose up -d --build
```
### 2. 前端 (Vercel)
### 前端本地运行
直接关联本项目 `frontend` 目录作为根目录即可,享受自动 CI/CD。
```bash
cd frontend
npm install
npm run dev
```
---
## 运行数据目录(VPS 推荐)
## 💬 机器人指令
建议将运行态数据放到仓库外(避免 `git pull` 被 SQLite 卡住):
| 命令 | 说明 | 示例 |
| :-------- | :------------------------ | :------------- |
| `/city` | 查询指定城市实时分析 | `/city ankara` |
| `/deb` | 查看 DEB 模型的历史准确率 | `/deb london` |
| `/points` | 查看您的活跃积分与排行榜 | `/points` |
| `/help` | 获取详细功能说明 | `/help` |
```env
POLYWEATHER_RUNTIME_DATA_DIR=/var/lib/polyweather
POLYWEATHER_DB_PATH=/var/lib/polyweather/polyweather.db
```
---
## 运维验收
> [!NOTE]
> **商业化提示**:本项目目前提供 **Web 仪表盘 ($5/月)** 与 **Telegram 信号频道 ($1/月)** 订阅服务。
> 发言获取积分逻辑已上线,活跃用户可兑换相应权限。
### 前端缓存头
---
```bash
./scripts/validate_frontend_cache.sh "https://polyweather-pro.vercel.app"
```
---
### 支付自动补单日志
**📅 最后更新**2026-03-08
**🚀 状态**:v1.0 稳定版 - 专业量化 UI 已锁定
```bash
docker compose logs -f polyweather | egrep "payment event loop started|payment confirm loop started|payment auto-confirmed"
```
> [!TIP]
> **生产提示**:当前仪表盘采用高密度“专业量化版” UI (v1.0-legacy),深度集成了 METAR/MGM 实测数据、DEB 智能融合预报及多模型概率分布,提供最高性能的数据交互体验。
### 钱包异动监听日志
```bash
docker compose logs -f polyweather | egrep "polymarket wallet activity watcher started|wallet activity pushed"
```
## Telegram 指令
| 指令 | 用途 |
| :-- | :-- |
| `/city <name>` | 城市实时分析 |
| `/deb <name>` | DEB 历史对账 |
| `/top` | 用户积分排行 |
| `/id` | 查看聊天 Chat ID |
| `/diag` | Bot 启动诊断 |
| `/help` | 帮助与用法 |
## 文档索引
- 英文总览:[README.md](README.md)
- API 文档(中文):[docs/API_ZH.md](docs/API_ZH.md)
- 商业化说明:[docs/COMMERCIALIZATION.md](docs/COMMERCIALIZATION.md)
- Open-Core 边界:[docs/OPEN_CORE_POLICY.md](docs/OPEN_CORE_POLICY.md)
- Supabase 接入:[docs/SUPABASE_SETUP_ZH.md](docs/SUPABASE_SETUP_ZH.md)
- 技术债(中文镜像):[docs/TECH_DEBT_ZH.md](docs/TECH_DEBT_ZH.md)
- 技术债(主文档):[docs/TECH_DEBT.md](docs/TECH_DEBT.md)
- 支付合约验证:[docs/payments/POLYGONSCAN_VERIFY.md](docs/payments/POLYGONSCAN_VERIFY.md)
- 前端报告:[FRONTEND_REDESIGN_REPORT.md](FRONTEND_REDESIGN_REPORT.md)
- 发布流程:[RELEASE.md](RELEASE.md)
- 变更记录:[CHANGELOG.md](CHANGELOG.md)
## 当前版本
- 版本:`v1.4.0`
- 最后更新:`2026-03-14`
+86
View File
@@ -0,0 +1,86 @@
# 版本发布流程
本项目采用语义化版本号:`MAJOR.MINOR.PATCH`
当前单一版本源为根目录 [VERSION](E:/web/PolyWeather/VERSION) 文件,所有对外文档与前端版本号都从这里同步。
## 版本规则
- `PATCH`:修复缺陷、文档修正、兼容性不变的小改动
- `MINOR`:新增能力、接口扩展、向后兼容的功能迭代
- `MAJOR`:不兼容变更、核心架构升级、公开接口重大调整
示例:
- `1.4.0 -> 1.4.1`:告警逻辑修正、缓存修正、文档修正
- `1.4.0 -> 1.5.0`:新增支付能力、新增页面、新增 API
- `1.4.0 -> 2.0.0`:接口重构或数据结构不兼容
## 日常升版步骤
### 1. 升版本号
```bash
python scripts/bump_version.py patch
```
可选参数:
```bash
python scripts/bump_version.py minor
python scripts/bump_version.py major
python scripts/bump_version.py 1.5.0
```
### 2. 检查同步结果
```bash
python scripts/sync_version.py
git diff
```
### 3. 补充 Changelog
在 [CHANGELOG.md](E:/web/PolyWeather/CHANGELOG.md) 对应版本下补齐:
- 新增能力
- 修复项
- 兼容性说明
### 4. 验证
建议至少执行:
```bash
cd frontend
npm run build
```
如涉及后端核心逻辑,补充执行:
```bash
python -m pytest
```
### 5. 提交与打标签
工作区干净后再打标签:
```bash
git add .
git commit -m "release: v1.4.1"
git tag v1.4.1
```
### 6. 推送
```bash
git push
git push origin v1.4.1
```
## 当前约束
- 不直接手改多份文档版本号
- 不在工作区脏状态下打 release tag
- `README.md`、前端 `package.json`、文档标题版本都通过脚本同步
+1
View File
@@ -0,0 +1 @@
1.4.0
+2 -801
View File
@@ -1,811 +1,12 @@
import sys
import os
from typing import List
import telebot # type: ignore
from loguru import logger # type: ignore
import sys
# 确保项目根目录在 sys.path 中
project_root = os.path.dirname(os.path.abspath(__file__))
if project_root not in sys.path:
sys.path.insert(0, project_root)
from src.utils.config_loader import load_config # type: ignore # noqa: E402
from src.utils.telegram_push import start_trade_alert_push_loop # type: ignore # noqa: E402
from src.data_collection.weather_sources import WeatherDataCollector # type: ignore # noqa: E402
from src.data_collection.city_risk_profiles import get_city_risk_profile # type: ignore # noqa: E402
from src.analysis.deb_algorithm import calculate_dynamic_weights, update_daily_record # noqa: E402
from src.database.db_manager import DBManager
MESSAGE_POINTS = 1
MESSAGE_DAILY_CAP = 50
MESSAGE_MIN_LENGTH = 4
MESSAGE_COOLDOWN_SEC = 30
CITY_QUERY_COST = 1
DEB_QUERY_COST = 1
def analyze_weather_trend(weather_data, temp_symbol, city_name=None):
"""Thin wrapper — delegates to shared trend_engine module."""
from src.analysis.trend_engine import analyze_weather_trend as _analyze
display_str, ai_context, _structured = _analyze(weather_data, temp_symbol, city_name)
return display_str, ai_context
def start_bot():
config = load_config()
token = os.getenv("TELEGRAM_BOT_TOKEN")
if not token:
logger.error("未找到 TELEGRAM_BOT_TOKEN 环境变量")
return
bot = telebot.TeleBot(token)
db = DBManager()
weather = WeatherDataCollector(config)
start_trade_alert_push_loop(bot, config)
def _display_name(user) -> str:
return user.username or user.first_name or f"User_{user.id}"
def _ensure_query_points(message, cost: int, label: str) -> bool:
user = message.from_user
db.upsert_user(user.id, _display_name(user))
result = db.spend_points(user.id, cost)
if result.get("ok"):
return True
balance = int(result.get("balance") or 0)
required = int(result.get("required") or cost)
missing = max(0, required - balance)
bot.reply_to(
message,
(
f"❌ 积分不足,无法执行 <b>{label}</b>\n"
f"当前积分: <code>{balance}</code>\n"
f"需要积分: <code>{required}</code>\n"
f"还差积分: <code>{missing}</code>\n\n"
f"积分规则:群内有效发言满 {MESSAGE_MIN_LENGTH} 字,"
f"每次 +{MESSAGE_POINTS} 分,每日上限 {MESSAGE_DAILY_CAP} 分。"
),
parse_mode="HTML",
)
return False
@bot.message_handler(commands=["start", "help"])
def send_welcome(message):
welcome_text = (
"🌡️ <b>PolyWeather 天气查询机器人</b>\n\n"
"可用指令:\n"
f"/city [城市名] - 查询城市天气预测与实测 (消耗 {CITY_QUERY_COST} 积分)\n"
f"/deb [城市名] - 查看 DEB 融合预测准确率 (消耗 {DEB_QUERY_COST} 积分)\n"
"/top - 查看积分排行榜\n"
"/id - 获取当前聊天的 Chat ID\n\n"
"示例: <code>/city 伦敦</code>\n"
f"💡 <i>提示: 群内有效发言满 {MESSAGE_MIN_LENGTH} 字,每次 +{MESSAGE_POINTS} 分,"
f"每日上限 {MESSAGE_DAILY_CAP} 分。</i>"
)
bot.reply_to(message, welcome_text, parse_mode="HTML")
@bot.message_handler(commands=["id"])
def get_chat_id(message):
bot.reply_to(
message,
f"🎯 当前聊天的 Chat ID 是: <code>{message.chat.id}</code>",
parse_mode="HTML",
)
@bot.message_handler(commands=["top"])
def show_points(message):
"""显示当前用户的积分及排行榜"""
user = message.from_user
db.upsert_user(user.id, _display_name(user))
user_info = db.get_user(user.id)
leaderboard = db.get_leaderboard(limit=5)
rank_text = "🏆 <b>PolyWeather 活跃度排行榜</b>\n"
rank_text += "────────────────────\n"
for i, entry in enumerate(leaderboard):
medal = ["🥇", "🥈", "🥉", " ", " "][i] if i < 5 else " "
rank_text += f"{medal} {entry['username'][:12]}: <b>{entry['points']}</b> 点\n"
if user_info:
rank_text += "────────────────────\n"
rank_text += (
f"👤 <b>我的状态:</b>\n"
f"└ 积分: <code>{user_info['points']}</code>\n"
f"└ 发言: <code>{user_info['message_count']}</code> 次\n"
f"└ 今日发言积分: <code>{user_info.get('daily_points') or 0}/{MESSAGE_DAILY_CAP}</code>\n"
f"└ /city 消耗: <code>{CITY_QUERY_COST}</code> | /deb 消耗: <code>{DEB_QUERY_COST}</code>"
)
bot.send_message(message.chat.id, rank_text, parse_mode="HTML")
@bot.message_handler(commands=["deb"])
def deb_accuracy(message):
"""查询 DEB 融合预测的历史准确率"""
try:
parts = message.text.split(maxsplit=1)
if len(parts) < 2:
bot.reply_to(
message, "❓ 用法: <code>/deb ankara</code>", parse_mode="HTML"
)
return
from src.data_collection.city_registry import ALIASES, CITY_REGISTRY
city_input = parts[1].strip().lower()
city_name = ALIASES.get(city_input, city_input)
from src.analysis.deb_algorithm import load_history
import os as _os
# 获取详细历史数据
project_root = _os.path.dirname(_os.path.abspath(__file__))
history_file = _os.path.join(project_root, "data", "daily_records.json")
data = load_history(history_file)
if city_name not in data or not data[city_name]:
bot.reply_to(
message, f"❌ 暂无 {city_name} 的历史数据", parse_mode="HTML"
)
return
if not _ensure_query_points(message, DEB_QUERY_COST, "/deb"):
return
city_data = data[city_name]
from datetime import datetime as _dt
today_str = _dt.now().strftime("%Y-%m-%d")
lines = [f"📊 <b>DEB 准确率报告 - {city_name.title()}</b>\n"]
# 逐日明细
lines.append("<b>📅 逐日记录:</b>")
total_days = 0
hits = 0
deb_errors = []
signed_errors = [] # 有正负的误差 (DEB - 实测)
model_errors = {}
for date_str in sorted(city_data.keys()):
record = city_data[date_str]
actual = record.get("actual_high")
deb_pred = record.get("deb_prediction")
forecasts = record.get("forecasts", {})
if actual is None:
continue
try:
actual = float(actual)
if deb_pred is not None:
deb_pred = float(deb_pred)
except Exception:
continue
# 如果没有存 DEB 预测值,用当天各模型平均值回算
if deb_pred is None and forecasts:
valid_preds = [
float(v) for v in forecasts.values() if v is not None
]
if valid_preds:
deb_pred = round(sum(valid_preds) / len(valid_preds), 1)
actual_wu = round(actual)
# DEB 命中判断
if deb_pred is not None and date_str != today_str:
total_days += 1
deb_wu = round(deb_pred)
hit = deb_wu == actual_wu
if hit:
hits += 1
err = deb_pred - actual
deb_errors.append(abs(err))
signed_errors.append(err)
icon = "" if hit else ""
retro = "" if "deb_prediction" not in record else ""
# 错误类型标签
if not hit:
err_label = (
f" 低估{abs(err):.1f}°"
if err < 0
else f" 高估{abs(err):.1f}°"
)
else:
err_label = f" 偏差{abs(err):.1f}°"
mu_val = record.get("mu")
mu_str = f" | μ: {mu_val}" if mu_val is not None else ""
lines.append(
f" {date_str}: DEB {retro}{deb_pred}→<b>{deb_wu}</b> vs 实测 {actual}→<b>{actual_wu}</b> {icon}{err_label}{mu_str}"
)
elif date_str == today_str:
lines.append(f" {date_str}: 📍 今天进行中 (实测暂 {actual})")
# 各模型误差统计
if date_str != today_str and actual is not None:
for model, pred in forecasts.items():
if pred is not None:
if model not in model_errors:
model_errors[model] = []
model_errors[model].append(abs(float(pred) - actual))
# 汇总
if total_days > 0:
hit_rate = hits / total_days * 100
deb_mae = sum(deb_errors) / len(deb_errors)
lines.append(
f"\n🎯 <b>DEB 总战绩</b>WU命中 {hits}/{total_days} (<b>{hit_rate:.0f}%</b>) | MAE: {deb_mae:.1f}°"
)
# --- 概率引擎 μ 的战绩 ---
from src.analysis.deb_algorithm import get_mu_accuracy
mu_acc = get_mu_accuracy(city_name)
if mu_acc:
mu_mae, mu_hr, avg_brier, mu_total, _ = mu_acc
lines.append(
f"🎲 <b>概率引擎 (μ)</b>WU命中 <b>{mu_hr:.0f}%</b> | MAE: {mu_mae:.1f}°"
)
if avg_brier is not None:
# Brier Score 范围是 0 (完美) 到 2 (全错)
bs_eval = "极佳" if avg_brier < 0.2 else ("良好" if avg_brier < 0.4 else "需校准")
lines.append(f" ▪ Brier评分: {avg_brier:.3f} ({bs_eval})")
# 和各模型 MAE 对比
if model_errors:
lines.append("\n📈 <b>模型 MAE 对比</b>")
model_maes = {
m: sum(e) / len(e) for m, e in model_errors.items() if e
}
sorted_models = sorted(model_maes.items(), key=lambda x: x[1])
for m, mae in sorted_models:
tag = "" if mae <= deb_mae else ""
lines.append(f" {m}: {mae:.1f}°{tag}")
lines.append(f" <b>DEB融合: {deb_mae:.1f}°</b>")
# 偏差模式分析
mean_bias = sum(signed_errors) / len(signed_errors)
underest = sum(1 for e in signed_errors if e < -0.3)
overest = sum(1 for e in signed_errors if e > 0.3)
lines.append("\n🔍 <b>偏差分析</b>")
if abs(mean_bias) > 0.3:
bias_dir = "低估" if mean_bias < 0 else "高估"
lines.append(f" ⚠️ 系统性{bias_dir}:平均偏差 {mean_bias:+.1f}°")
else:
lines.append(f" ✅ 无明显系统偏差(平均 {mean_bias:+.1f}°)")
lines.append(
f" 低估 {underest} 次 | 高估 {overest} 次 | 准确 {total_days - underest - overest}"
)
# 可操作建议
lines.append("\n💡 <b>建议</b>")
if underest > overest and abs(mean_bias) > 0.5:
lines.append(
f" 该城市模型集体低估趋势明显({mean_bias:+.1f}°),实际最高温可能比 DEB 融合值高 {abs(mean_bias):.0f}-{abs(mean_bias) + 0.5:.0f}°。交易时建议适当看高。"
)
elif overest > underest and abs(mean_bias) > 0.5:
lines.append(
f" 该城市模型集体高估趋势明显({mean_bias:+.1f}°),实际最高温可能比 DEB 融合值低。交易时建议适当看低。"
)
elif deb_mae > 1.5:
lines.append(
f" 该城市预报波动大 (MAE {deb_mae:.1f}°),建议观望或轻仓。"
)
elif hit_rate >= 60:
lines.append(" DEB 表现良好,可作为主要参考。")
else:
lines.append(" 数据积累中,建议结合 AI 分析综合判断。")
lines.append("\n📝 MAE = 平均绝对误差,越小越准。⭐ = 优于 DEB 融合。")
else:
lines.append("\n⏳ 尚无完整的 DEB 预测记录,明天起开始统计。")
lines.append(f"\n💳 本次消耗 <code>{DEB_QUERY_COST}</code> 积分。")
bot.reply_to(message, "\n".join(lines), parse_mode="HTML")
except Exception as e:
bot.reply_to(message, f"❌ 查询失败: {e}")
@bot.message_handler(commands=["city"])
def get_city_info(message):
"""查询指定城市的天气详情"""
try:
parts = message.text.split(maxsplit=1)
if len(parts) < 2:
bot.reply_to(
message,
"❓ 请输入城市名称\n\n用法: <code>/city chicago</code>",
parse_mode="HTML",
)
return
from src.data_collection.city_registry import ALIASES, CITY_REGISTRY
city_input = parts[1].strip().lower()
# --- 使用统一注册表解析城市 ---
SUPPORTED_CITIES = list(CITY_REGISTRY.keys())
# 1. 第一优先级:全称或别名完全匹配
city_name = ALIASES.get(city_input)
if not city_name and city_input in SUPPORTED_CITIES:
city_name = city_input
# 2. 第二优先级:前缀模糊匹配
if not city_name and len(city_input) >= 2:
# 搜别名
for k, v in ALIASES.items():
if k.startswith(city_input):
city_name = v
break
# 搜城市全名
if not city_name:
for full_name in SUPPORTED_CITIES:
if full_name.startswith(city_input):
city_name = full_name
break
# 3. 未找到 → 报错
if not city_name:
city_list = ", ".join(sorted(SUPPORTED_CITIES))
bot.reply_to(
message,
f"❌ 未找到城市: <b>{city_input}</b>\n\n"
f"支持的城市: {city_list}",
parse_mode="HTML",
)
return
if not _ensure_query_points(message, CITY_QUERY_COST, "/city"):
return
bot.send_message(
message.chat.id, f"🔍 正在查询 {city_name.title()} 的天气数据..."
)
coords = weather.get_coordinates(city_name)
if not coords:
bot.reply_to(message, f"❌ 未找到城市坐标: {city_name}")
return
weather_data = weather.fetch_all_sources(
city_name, lat=coords["lat"], lon=coords["lon"]
)
open_meteo = weather_data.get("open-meteo", {})
metar = weather_data.get("metar", {})
mgm = weather_data.get("mgm") or {}
# 数值归一化
def _sf(v):
if v is None:
return None
try:
return float(v)
except Exception:
return None
temp_unit = open_meteo.get("unit", "celsius")
temp_symbol = "°F" if temp_unit == "fahrenheit" else "°C"
# --- 1. 紧凑 Header (城市 + 时间 + 风险状态) ---
local_time = open_meteo.get("current", {}).get("local_time", "")
time_str = local_time.split(" ")[1][:5] if " " in local_time else "N/A"
risk_profile = get_city_risk_profile(city_name)
risk_emoji = risk_profile.get("risk_level", "") if risk_profile else ""
msg_header = f"📍 <b>{city_name.title()}</b> ({time_str}) {risk_emoji}"
msg_lines = [msg_header]
# --- 2. 紧凑 风险提示 ---
if risk_profile:
bias = risk_profile.get("bias", "±0.0")
msg_lines.append(
f"⚠️ {risk_profile.get('airport_name', '')}: {bias}{temp_symbol} | {risk_profile.get('warning', '')}"
)
# --- 3. 紧凑 预测区 ---
daily = open_meteo.get("daily", {})
dates = daily.get("time", [])[:3]
max_temps = daily.get("temperature_2m_max", [])[:3]
nws_high = _sf(weather_data.get("nws", {}).get("today_high"))
mgm_high = _sf(mgm.get("today_high"))
mb_high = _sf(weather_data.get("meteoblue", {}).get("today_high"))
# 今天对比
today_t = max_temps[0] if max_temps else "N/A"
comp_parts = []
sources = ["Open-Meteo"]
if mb_high is not None:
sources.append("MB")
comp_parts.append(
f"MB: {mb_high:.1f}{temp_symbol}"
if isinstance(mb_high, (int, float))
else f"MB: {mb_high}"
)
if nws_high is not None:
sources.append("NWS")
comp_parts.append(
f"NWS: {nws_high:.1f}{temp_symbol}"
if isinstance(nws_high, (int, float))
else f"NWS: {nws_high}"
)
if mgm_high is not None:
sources.append("MGM")
comp_parts.append(
f"🇹🇷 MGM: {mgm_high:.1f}{temp_symbol}"
if isinstance(mgm_high, (int, float))
else f"🇹🇷 MGM: {mgm_high}"
)
# 检查是否有显著分歧 (超过 5°F 或 2.5°C)
divergence_warning = ""
if mb_high is not None and max_temps:
diff = abs(mb_high - (_sf(max_temps[0]) or 0))
threshold = 5.0 if temp_unit == "fahrenheit" else 2.5
if diff > threshold:
divergence_warning = (
f" ⚠️ <b>模型显著分歧 ({diff:.1f}{temp_symbol})</b>"
)
comp_str = f" ({' | '.join(comp_parts)})" if comp_parts else ""
sources_str = " | ".join(sources)
msg_lines.append(f"\n📊 <b>预报 ({sources_str})</b>")
msg_lines.append(
f"👉 <b>今天: {today_t}{temp_symbol}{comp_str}</b>{divergence_warning}"
)
# 明后天
if len(dates) > 1:
future_forecasts = []
mgm_daily = mgm.get("daily_forecasts", {}) or {}
for d, t in zip(dates[1:], max_temps[1:]):
# 检查 MGM 是否有该日期的预报
mgm_f = mgm_daily.get(d)
if mgm_f is not None:
future_forecasts.append(
f"{d[5:]}: {t}{temp_symbol} | 🇹🇷 <b>MGM: {mgm_f}{temp_symbol}</b>"
)
else:
future_forecasts.append(f"{d[5:]}: {t}{temp_symbol}")
msg_lines.append("📅 " + " | ".join(future_forecasts))
# --- 3.5 日出日落 + 日照时长 ---
sunrises = daily.get("sunrise", [])
sunsets = daily.get("sunset", [])
sunshine_durations = daily.get("sunshine_duration", [])
if sunrises and sunsets:
sunrise_t = (
sunrises[0].split("T")[1][:5]
if "T" in str(sunrises[0])
else sunrises[0]
)
sunset_t = (
sunsets[0].split("T")[1][:5]
if "T" in str(sunsets[0])
else sunsets[0]
)
sun_line = f"🌅 日出 {sunrise_t} | 🌇 日落 {sunset_t}"
if sunshine_durations:
sunshine_hours = sunshine_durations[0] / 3600 # 秒 -> 小时
sun_line += f" | ☀️ 日照 {sunshine_hours:.1f}h"
msg_lines.append(sun_line)
# --- 4. 核心 实测区 (合并 METAR 和 MGM) ---
# 基础数据优先用 METAR
cur_temp = _sf(
metar.get("current", {}).get("temp")
if metar
else mgm.get("current", {}).get("temp")
)
max_p = _sf(
metar.get("current", {}).get("max_temp_so_far") if metar else None
)
max_p_time = (
metar.get("current", {}).get("max_temp_time") if metar else None
)
obs_t_str = "N/A"
metar_age_min = None # METAR 数据年龄(分钟)
main_source = "METAR" if metar else "MGM"
if metar:
obs_t = metar.get("observation_time", "")
try:
if "T" in obs_t:
from datetime import datetime, timezone, timedelta
dt = datetime.fromisoformat(obs_t.replace("Z", "+00:00"))
utc_offset = open_meteo.get("utc_offset", 0)
local_dt = dt.astimezone(
timezone(timedelta(seconds=utc_offset))
)
obs_t_str = local_dt.strftime("%H:%M")
# 计算数据年龄
now_utc = datetime.now(timezone.utc)
metar_age_min = int((now_utc - dt).total_seconds() / 60)
elif " " in obs_t:
obs_t_str = obs_t.split(" ")[1][:5]
else:
obs_t_str = obs_t
except Exception:
obs_t_str = obs_t[:16]
elif mgm:
m_time = mgm.get("current", {}).get("time", "")
if "T" in m_time:
from datetime import datetime, timezone, timedelta
dt = datetime.fromisoformat(m_time.replace("Z", "+00:00"))
m_time = dt.astimezone(timezone(timedelta(hours=3))).strftime(
"%H:%M"
)
elif " " in m_time:
m_time = m_time.split(" ")[1][:5]
obs_t_str = m_time
# 数据年龄标注
age_tag = ""
if metar_age_min is not None:
if metar_age_min >= 60:
age_tag = f" ⚠️{metar_age_min}分钟前"
elif metar_age_min >= 30:
age_tag = f"{metar_age_min}分钟前"
max_str = ""
if max_p is not None:
import math
settled_val = math.floor(max_p + 0.5)
max_str = f" (最高: {max_p}{temp_symbol}"
if max_p_time:
max_str += f" @{max_p_time}"
max_str += f" → WU {settled_val}{temp_symbol})"
# --- 天气状况总结 ---
wx_summary = ""
# 优先使用 METAR 天气现象
metar_wx = metar.get("current", {}).get("wx_desc", "") if metar else ""
metar_clouds = metar.get("current", {}).get("clouds", []) if metar else []
mgm_cloud = mgm.get("current", {}).get("cloud_cover") if mgm else None
if metar_wx:
wx_upper = metar_wx.upper().strip()
wx_tokens = set(wx_upper.split())
rain_codes = {
"RA",
"DZ",
"-RA",
"+RA",
"-DZ",
"+DZ",
"TSRA",
"SHRA",
"FZRA",
}
snow_codes = {"SN", "GR", "GS", "-SN", "+SN", "BLSN"}
fog_codes = {"FG", "BR", "HZ", "FZFG"}
ts_codes = {"TS", "TSRA"}
if ts_codes & wx_tokens:
wx_summary = "⛈️ 雷暴"
elif {"+RA", "+SN"} & wx_tokens:
wx_summary = "🌧️ 大雨" if "+RA" in wx_tokens else "❄️ 大雪"
elif rain_codes & wx_tokens:
wx_summary = (
"🌧️ 小雨" if {"-RA", "-DZ", "DZ"} & wx_tokens else "🌧️ 下雨"
)
elif snow_codes & wx_tokens:
wx_summary = "❄️ 下雪"
elif fog_codes & wx_tokens:
wx_summary = "🌫️ 雾/霾"
# 如果 METAR 没有特殊现象,用云量推断
if not wx_summary:
# 优先 METAR 云层,回退 MGM
cover_code = ""
if metar_clouds:
cover_code = metar_clouds[-1].get("cover", "")
if cover_code in ("SKC", "CLR") or (
cover_code == "" and mgm_cloud is not None and mgm_cloud <= 1
):
wx_summary = "☀️ 晴"
elif cover_code == "FEW" or (
cover_code == "" and mgm_cloud is not None and mgm_cloud <= 2
):
wx_summary = "🌤️ 晴间少云"
elif cover_code == "SCT" or (
cover_code == "" and mgm_cloud is not None and mgm_cloud <= 4
):
wx_summary = "⛅ 晴间多云"
elif cover_code == "BKN" or (
cover_code == "" and mgm_cloud is not None and mgm_cloud <= 6
):
wx_summary = "🌥️ 多云"
elif cover_code == "OVC" or (
cover_code == "" and mgm_cloud is not None and mgm_cloud <= 8
):
wx_summary = "☁️ 阴天"
elif mgm_cloud is not None:
cloud_names = {
0: "☀️ 晴",
1: "🌤️ 晴",
2: "🌤️ 少云",
3: "⛅ 散云",
4: "⛅ 散云",
5: "🌥️ 多云",
6: "🌥️ 多云",
7: "☁️ 阴",
8: "☁️ 阴天",
}
wx_summary = cloud_names.get(mgm_cloud, "")
wx_display = f" {wx_summary}" if wx_summary else ""
msg_lines.append(
f"\n✈️ <b>实测 ({main_source}): {cur_temp}{temp_symbol}</b>{max_str} |{wx_display} | {obs_t_str}{age_tag}"
)
if mgm:
m_c = mgm.get("current", {})
# 翻译风向
wind_dir = m_c.get("wind_dir")
wind_speed_ms = m_c.get("wind_speed_ms")
dir_str = ""
if wind_dir is not None:
dirs = ["", "东北", "", "东南", "", "西南", "西", "西北"]
dir_str = dirs[int((float(wind_dir) + 22.5) % 360 / 45)] + ""
# 体感和湿度(跳过缺失数据)
feels_like = m_c.get("feels_like")
humidity = m_c.get("humidity")
if feels_like is not None or humidity is not None:
parts = []
if feels_like is not None:
parts.append(f"🌡️ 体感: {feels_like}°C")
# 针对安卡拉,补充市区(Center)实测值
ankara_center = next((s for s in weather_data.get("mgm_nearby", []) if "Bölge/Center" in s.get("name", "")), None)
if ankara_center:
parts.append(f"Ankara (Bölge/Center): <b>{ankara_center['temp']}°C</b>")
if humidity is not None:
parts.append(f"💧 {humidity}%")
msg_lines.append(f" [MGM] {' | '.join(parts)}")
# 风况(跳过缺失数据)
if wind_dir is not None and wind_speed_ms is not None:
msg_lines.append(
f" [MGM] 🌬️ {dir_str}{wind_dir}° ({wind_speed_ms} m/s) | 💧 降水: {m_c.get('rain_24h') or 0}mm"
)
# 新增:气压和云量
extra_parts = []
pressure = m_c.get("pressure")
if pressure is not None:
extra_parts.append(f"🌡 气压: {pressure}hPa")
cloud_cover = m_c.get("cloud_cover")
if cloud_cover is not None:
cloud_desc_map = {
0: "晴朗",
1: "少云",
2: "少云",
3: "散云",
4: "散云",
5: "多云",
6: "多云",
7: "很多云",
8: "阴天",
}
cloud_text = cloud_desc_map.get(cloud_cover, f"{cloud_cover}/8")
extra_parts.append(f"☁️ 云量: {cloud_text}({cloud_cover}/8)")
mgm_max = m_c.get("mgm_max_temp")
if mgm_max is not None:
extra_parts.append(f"🌡️ MGM最高: {mgm_max}°C")
if extra_parts:
msg_lines.append(f" [MGM] {' | '.join(extra_parts)}")
if metar:
m_c = metar.get("current", {})
wind = m_c.get("wind_speed_kt")
wind_dir = m_c.get("wind_dir")
vis = m_c.get("visibility_mi")
clouds = m_c.get("clouds", [])
cloud_desc = ""
if clouds:
c_map = {
"BKN": "多云",
"OVC": "阴天",
"FEW": "少云",
"SCT": "散云",
"SKC": "",
"CLR": "",
}
main = clouds[-1]
cloud_desc = f"☁️ {c_map.get(main.get('cover'), main.get('cover'))}"
prefix = "[METAR]" if mgm else " "
if not mgm:
msg_lines.append(
f" {prefix} 💨 {wind or 0}kt ({wind_dir or 0}°) | 👁️ {vis or 10}mi"
)
if cloud_desc:
msg_lines.append(
f" {prefix} {cloud_desc} | 👁️ {vis or 10}mi | 💨 {wind or 0}kt"
)
# --- 5. 态势特征提取 ---
feature_str, ai_context = analyze_weather_trend(
weather_data, temp_symbol, city_name
)
if feature_str:
# 仅将最核心的信息展示给用户作为"态势分析"
# 但后面会把更全的数据传给 AI
msg_lines.append("\n💡 <b>分析</b>:")
for line in feature_str.split("\n"):
if line.strip():
msg_lines.append(f"- {line.strip()}")
# --- 6. Groq AI 深度分析 ---
try:
from src.analysis.ai_analyzer import get_ai_analysis
# 构建更全的背景数据给 AI
# 补充多模型分歧
mm = weather_data.get("multi_model", {})
if mm.get("forecasts"):
mm_str = " | ".join(
[
f"{k}:{v}{temp_symbol}"
for k, v in mm["forecasts"].items()
if v
]
)
ai_context += f"\n模型分歧: {mm_str}"
ai_result = get_ai_analysis(ai_context, city_name, temp_symbol)
if ai_result:
msg_lines.append(f"\n{ai_result}")
except Exception as e:
logger.error(f"调用 Groq AI 分析失败: {e}")
msg_lines.append(f"\n💳 本次消耗 <b>{CITY_QUERY_COST}</b> 积分。")
bot.send_message(message.chat.id, "\n".join(msg_lines), parse_mode="HTML")
except Exception as e:
import traceback
logger.error(f"查询失败: {e}\n{traceback.format_exc()}")
bot.reply_to(message, f"❌ 查询失败: {e}")
@bot.message_handler(func=lambda message: True, content_types=['text'])
def track_activity(message):
"""全量监听消息,用于记录群内发言积分(非指令消息)"""
if message.text.startswith('/'):
return
if message.chat.type not in ("group", "supergroup"):
return
user = message.from_user
username = _display_name(user)
db.upsert_user(user.id, username)
result = db.add_message_activity(
user.id,
text=message.text,
points_to_add=MESSAGE_POINTS,
cooldown_sec=MESSAGE_COOLDOWN_SEC,
daily_cap=MESSAGE_DAILY_CAP,
min_text_length=MESSAGE_MIN_LENGTH,
)
if result.get("awarded"):
logger.info(
f"message points awarded user={user.id} points=+{MESSAGE_POINTS} "
f"daily_points={result.get('daily_points')}/{MESSAGE_DAILY_CAP}"
)
logger.info("🤖 Bot 启动中...")
bot.infinity_polling()
from src.bot.orchestrator import start_bot # noqa: E402
if __name__ == "__main__":
+93 -49
View File
@@ -1,53 +1,97 @@
# Weather API Configuration
weather:
meteoblue_api_key: null # Set via METEOBLUE_API_KEY env var
timeout: 30
# Target Cities
cities:
- id: "london"
city: "London"
country: "UK"
latitude: 51.5074
longitude: -0.1278
- id: "paris"
city: "Paris"
country: "France"
latitude: 48.8566
longitude: 2.3522
- id: "ankara"
city: "Ankara"
country: "Turkey"
latitude: 39.9334
longitude: 32.8597
- id: "new_york"
city: "New York"
country: "USA"
latitude: 40.7128
longitude: -74.0060
- id: "chicago"
city: "Chicago"
country: "USA"
latitude: 41.8781
longitude: -87.6298
- id: "lucknow"
city: "Lucknow"
country: "India"
latitude: 26.7606
longitude: 80.8893
- id: "sao paulo"
city: "São Paulo"
country: "Brazil"
latitude: -23.4356
longitude: -46.4731
- id: "munich"
city: "Munich"
country: "Germany"
latitude: 48.3538
longitude: 11.7861
# Logging
- id: london
city: London
country: UK
latitude: 51.5074
longitude: -0.1278
- id: paris
city: Paris
country: France
latitude: 48.8566
longitude: 2.3522
- id: ankara
city: Ankara
country: Turkey
latitude: 39.9334
longitude: 32.8597
- id: new_york
city: New York
country: USA
latitude: 40.7128
longitude: -74.006
- id: chicago
city: Chicago
country: USA
latitude: 41.8781
longitude: -87.6298
- id: lucknow
city: Lucknow
country: India
latitude: 26.7606
longitude: 80.8893
- id: sao paulo
city: São Paulo
country: Brazil
latitude: -23.4356
longitude: -46.4731
- id: munich
city: Munich
country: Germany
latitude: 48.3538
longitude: 11.7861
- id: hong_kong
city: Hong Kong
country: China
latitude: 22.3019
longitude: 114.1742
- id: shanghai
city: Shanghai
country: China
latitude: 31.1434
longitude: 121.8052
- id: singapore
city: Singapore
country: Singapore
latitude: 1.3644
longitude: 103.9915
- id: tokyo
city: Tokyo
country: Japan
latitude: 35.5523
longitude: 139.7798
- id: tel_aviv
city: Tel Aviv
country: Israel
latitude: 32.0114
longitude: 34.8867
- id: chengdu
city: Chengdu
country: China
latitude: 30.5785
longitude: 103.9471
- id: chongqing
city: Chongqing
country: China
latitude: 29.7196
longitude: 106.6416
- id: shenzhen
city: Shenzhen
country: China
latitude: 22.6393
longitude: 113.8107
- id: beijing
city: Beijing
country: China
latitude: 40.0801
longitude: 116.5846
- id: wuhan
city: Wuhan
country: China
latitude: 30.7838
longitude: 114.2081
logging:
level: "INFO"
rotation: "10 MB"
retention: "10 days"
level: INFO
rotation: 10 MB
retention: 10 days
+54
View File
@@ -0,0 +1,54 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
interface IERC20 {
function transferFrom(address from, address to, uint256 value) external returns (bool);
}
contract PolyWeatherCheckout {
address public owner;
address public treasury;
mapping(address => bool) public allowedToken;
mapping(bytes32 => bool) public paidOrder;
event OrderPaid(
bytes32 indexed orderId,
address indexed payer,
uint256 indexed planId,
address token,
uint256 amount
);
modifier onlyOwner() {
require(msg.sender == owner, "ONLY_OWNER");
_;
}
constructor(address _token, address _treasury) {
require(_token != address(0) && _treasury != address(0), "ZERO_ADDR");
owner = msg.sender;
treasury = _treasury;
allowedToken[_token] = true;
}
function setTreasury(address _treasury) external onlyOwner {
require(_treasury != address(0), "ZERO_ADDR");
treasury = _treasury;
}
function setTokenAllowed(address token, bool allowed) external onlyOwner {
require(token != address(0), "ZERO_ADDR");
allowedToken[token] = allowed;
}
function pay(bytes32 orderId, uint256 planId, uint256 amount, address token) external {
require(allowedToken[token], "TOKEN_NOT_ALLOWED");
require(amount > 0, "AMOUNT_ZERO");
require(!paidOrder[orderId], "ORDER_PAID");
paidOrder[orderId] = true;
require(IERC20(token).transferFrom(msg.sender, treasury, amount), "TRANSFER_FAILED");
emit OrderPaid(orderId, msg.sender, planId, token, amount);
}
}
Binary file not shown.
+7 -2
View File
@@ -6,7 +6,11 @@ services:
env_file:
- .env
volumes:
- ./data:/app/data # 挂载数据目录,确保历史数据持久化
# Persist runtime data outside git workspace.
# Host path defaults to /var/lib/polyweather and can be overridden by POLYWEATHER_RUNTIME_DATA_DIR.
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}:/var/lib/polyweather
# Keep /app/data compatibility for existing cache/state defaults.
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}:/app/data
- ./bot.log:/app/bot.log # 挂载日志文件
user: "${UID:-1000}:${GID:-1000}"
@@ -18,7 +22,8 @@ services:
env_file:
- .env
volumes:
- ./data:/app/data
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}:/var/lib/polyweather
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}:/app/data
ports:
- "8000:8000"
user: "${UID:-1000}:${GID:-1000}"
+116
View File
@@ -0,0 +1,116 @@
# PolyWeather API 文档(v1.4.0
最后更新:`2026-03-14`
本文档描述当前对外可用 API 口径(`web/app.py` + `frontend/app/api/*`)。
## 1. 基础信息
- 后端直连:`http://127.0.0.1:8000`
- 前端 BFF`https://polyweather-pro.vercel.app/api/*`
- 返回格式:`application/json`
## 2. 请求链路
```mermaid
flowchart LR
FE["Browser / Dashboard"] --> BFF["Next.js Route Handlers (/api/*)"]
BFF --> API["FastAPI (/web/app.py)"]
API --> WX["Weather Collector"]
API --> ANA["DEB + Trend + Probability + Market Scan"]
API --> PAY["Payment Intent + Event + Confirm Loops"]
```
## 3. 天气分析接口
| 接口 | 方法 | 用途 |
| :-- | :-- | :-- |
| `/api/cities` | GET | 监控城市列表 |
| `/api/city/{name}` | GET | 城市主分析 |
| `/api/city/{name}/summary` | GET | 轻量摘要 |
| `/api/city/{name}/detail` | GET | 聚合详情(含 market_scan |
| `/api/history/{name}` | GET | 历史对账 |
### `GET /api/city/{name}/detail`
可选参数:
- `force_refresh=true|false`
- `market_slug=<slug>`
- `target_date=YYYY-MM-DD`
重点字段:
- `market_scan.available`
- `market_scan.signal_label`
- `market_scan.anchor_model / anchor_high / anchor_settlement`
- `market_scan.yes_buy / no_buy`
- `market_scan.primary_market.tradable`
## 4. 鉴权与账户接口
| 接口 | 方法 | 用途 |
| :-- | :-- | :-- |
| `/api/auth/me` | GET | 当前登录态、积分、订阅状态 |
`/api/auth/me` 关键字段:
- `authenticated`
- `user_id`, `email`
- `points`, `weekly_points`, `weekly_rank`
- `subscription_active`, `subscription_plan_code`, `subscription_expires_at`
## 5. 支付接口(P1
| 接口 | 方法 | 用途 |
| :-- | :-- | :-- |
| `/api/payments/config` | GET | 支付配置、代币列表、套餐、积分抵扣规则 |
| `/api/payments/wallets` | GET | 当前用户已绑定钱包 |
| `/api/payments/wallets/challenge` | POST | 获取绑定签名 challenge |
| `/api/payments/wallets/verify` | POST | 提交签名并绑定钱包 |
| `/api/payments/intents` | POST | 创建支付意图(intent |
| `/api/payments/intents/{intent_id}` | GET | 查询 intent 最新状态 |
| `/api/payments/intents/{intent_id}/submit` | POST | 提交交易哈希 |
| `/api/payments/intents/{intent_id}/confirm` | POST | 手动触发确认 |
### 支付状态建议
前端流程建议:
1. `POST /intents`
2. 钱包发链上交易
3. `POST /submit`
4. `POST /confirm`
5. 若 pending,轮询 `GET /intents/{id}` 直到 `confirmed`
## 6. 缓存策略(当前)
- `cities` / `summary` / `history`BFF 支持 `ETag + 304`
- `summary?force_refresh=true``Cache-Control: no-store`
- 详情接口与支付接口:`no-store`
## 7. 调试示例
### 查询未来日期 market_scan
```bash
curl -s "http://127.0.0.1:8000/api/city/ankara/detail?force_refresh=true&target_date=2026-03-12"
```
### 校验支付配置
```bash
curl -s http://127.0.0.1:8000/api/payments/config | python3 -m json.tool
```
### 观察支付自动补单
```bash
docker compose logs -f polyweather | egrep "payment event loop started|payment confirm loop started|payment auto-confirmed"
```
## 8. 开源口径说明
对外公开文档仅覆盖通用 API 契约。生产商业策略参数不在公开文档披露。
详见:[Open-Core 与商用边界](OPEN_CORE_POLICY.md)
+46 -59
View File
@@ -1,79 +1,66 @@
# 📈 Commercialization Roadmap
# 商业化说明(Production
> **Target**: Transforming PolyWeather for paid weather intelligence delivery.
最后更新:`2026-03-14`
---
## 1. 定位
## 🎯 Product Focus
PolyWeather 是面向温度结算场景的气象决策层,不是通用天气应用。
PolyWeather is positioned as a **premium intelligence service** for weather-based prediction markets (**Polymarket**). The value proposition lies in **Ankara-specialization**, **advanced advection forecasting**, and **DEB-weighted consensus**.
核心价值:
---
- 观测优先(METAR/MGM
- 结算导向(DEB + 概率桶)
- 市场映射(行情对照 + 错价雷达)
## 💰 Pricing & Monetization
## 2. 当前收费能力状态
| Tier | Price | Primary Value Proposition |
| :------------------- | :------------ | :------------------------------------------------------------ |
| **Telegram Channel** | **$1 / mo** | High-fidelity proactive alerts, low noise. |
| **Web Dashboard** | **$5 / mo** | Comprehensive multi-model view + historical MAE benchmarking. |
| **VIP Bundle** | **$5.5 / mo** | Full access to all intelligence streams. |
| 能力 | 状态 | 备注 |
| :-- | :-- | :-- |
| 登录注册(Google + 邮箱) | 已上线 | Supabase 鉴权 |
| 订阅套餐(Pro 月付) | 已上线 | `5 USDC / 30天` |
| 积分抵扣 | 已上线 | `500分=1U`,最多 `3U` |
| 合约支付 | 已上线 | PolygonUSDC + USDC.e |
| 支付自动确认 | 已上线 | Event Loop + Confirm Loop |
| 钱包绑定 | 已上线 | 浏览器钱包 + WalletConnect |
| 私有频道推送 | 已上线 | 可拆分业务频道 |
### 🛠️ Payment Infrastructure
## 3. 权限模型(当前)
- **Currency**: Polygon / USDC.
- **Method**: Initially manual activation; migrating to automatic deposit detection (Phase 2).
- 游客:可查看基础看板与简版信息。
- 登录用户:账户中心、钱包绑定、积分同步。
- Pro 用户:
- 今日日内深度分析(含高温时段)
- 历史对账 + 未来日期分析
- 全平台智能气象推送
---
## 4. 收费与积分规则(默认)
## 🗺️ Execution Roadmap
- 套餐:`pro_monthly`5 USDC / 30 天)
- 抵扣:500 积分抵 1 USDC,最高抵 3 USDC
- 实付下限:2 USDC(当积分满额时)
```mermaid
graph LR
P1[Phase 1: Manual Beta] --> P2[Phase 2: USDC Automation]
P2 --> P3[Phase 3: Scaling & Analytics]
> 说明:具体运营策略可按阶段调整,生产参数建议放私有仓库。
subgraph P1_Detail [Manual Operations]
P1 -->|DM Bot| Pay[Manual Payment]
Pay -->|Invite| Link[One-time Link]
end
## 5. 建议的开源边界
subgraph P2_Detail [Smart Automation]
P2 -->|Monitor| Chain[Polygon/USDC]
Chain -->|Auto| Access[JWT/Sub Activation]
end
```
请按 Open-Core 执行:
### 📦 Phase 1: Manual Beta
- 开源:基础能力与通用支付流程。
- 私有:商业风控、营销策略、关键运营参数、内部审计策略。
- **Goal**: Stabilize current alert quality and build core user group.
- **Actions**:
- Manual subscription activation via Telegram DM.
- Small, focused paid Telegram channel for signal tests.
- Invitation-only Web Access (Vercel).
详见:[Open-Core 与商用边界](OPEN_CORE_POLICY.md)
### 🛠️ Phase 2: Automation (USDC)
## 6. 上线检查清单(收费前)
- **Goal**: Reduce operational friction.
- **Actions**:
- **On-chain monitoring**: Detect USDC deposits to unique addresses.
- **One-time Links**: Telegram bot automatically generates invite links with `member_limit=1`.
- **JWT Auth**: Securing the Next.js frontend with subscriber-only tokens.
1. 支付链路:创建 intent、提交 tx、确认入账、订阅开通全链路可回放。
2. 权限链路:前端/后端/Bot 对 Pro 权限判定一致。
3. 审计能力:支付日志、订阅变更、异常重试可追溯。
4. 通知策略:支付成功私发、群内通知降噪。
5. 安全边界:敏感配置不进仓库。
### 🌐 Phase 3: Scaling & Analytics
## 7. 后续路线
- **Goal**: Retention and expansion.
- **Actions**:
- **Accuracy Leaderboard**: Monthly reports of DEB vs Market outcomes.
- **Self-Serve Portal**: User dashboard for billing and alert settings.
---
## 🚧 Critical Constraints
- **Weather-First**: We focus on the **physical variable changes** rather than exchange-side order book execution.
- **Quality > Quantity**: Alert fatigue will churn subscribers. We enforce a "True Probability Shift" rule for notifications.
- **Local Niche**: Ankara is our flagship differentiator.
---
**📅 Last Updated**: 2026-03-06
- 支持更多链和稳定币。
- 引入退款与工单后台。
- 建立周/月留存与付费转化看板。
- 打通渠道分销与邀请码返利系统。
+63
View File
@@ -0,0 +1,63 @@
# Open-Core 与商用边界
最后更新:`2026-03-14`
## 1. 目标
在保持社区可用性的前提下,保护商业化阶段的核心经营资产。
## 2. 仓库公开范围(可开源)
- 天气数据采集与标准化(METAR / Open-Meteo / MGM 接口层)。
- DEB 与基础趋势分析、概率桶计算。
- Dashboard 基础体验与 API/BFF 结构。
- Telegram Bot 基础命令与基础积分机制。
- 合约支付标准流程(钱包绑定、intent、提交、确认、补单)。
## 3. 生产私有范围(建议不公开)
- 商业风控参数与规则库:
- 错价信号阈值组合、推送阈值、异常检测策略。
- 运营策略资产:
- 用户分层规则、促销规则、留存策略、活动模板。
- 付费系统敏感细节:
- 实时对账容错阈值、退款审计策略、内部财务映射规则。
- 私有运维资产:
- 生产告警路由、内部频道映射、应急脚本与排障手册。
## 4. 配置与数据安全红线
- 不提交:`.env`、私钥、API key、机器人 token。
- 不提交:生产数据库、运行时状态文件、支付流水快照。
- 不提交:用户身份信息、钱包映射、订阅原始审计日志。
## 5. 推荐发布模式
### 5.1 Community Edition(开源)
- 提供基础分析与基础看板。
- 可选保留只读市场扫描。
- 默认关闭商业化运营规则。
### 5.2 Production Edition(私有)
- 启用收费、订阅、积分抵扣、风控、私有监控。
- 仅在私有仓库维护运营策略与敏感参数。
## 6. 文档口径规范
对外文档仅描述:
- 能力边界与使用方式。
- 可公开的技术架构。
- 不包含可被直接复刻的商业参数。
不对外文档描述:
- 具体策略阈值、用户分层细则、收益归因规则。
## 7. 许可证与法务建议(简版)
- 建议保持仓库代码许可证与商标/品牌授权分离。
- 若提供商业服务,建议在官网补充服务条款与隐私政策。
- 对“订阅权益”与“可用性”做明确 SLA 与免责边界。
+122
View File
@@ -0,0 +1,122 @@
# Supabase + 登录 + 支付接入说明(v1.4.0)
最后更新:`2026-03-14`
## 1. 目标
- 前端支持 Google 一键登录 + 邮箱注册/登录。
- 后端支持 Supabase JWT 鉴权。
- 支持 Polygon 合约支付(USDC / USDC.e)并自动确认开通订阅。
## 2. Supabase 控制台配置
1. `Auth -> Providers` 打开 `Google``Email`
2. Google Cloud OAuth 回调配置:
- `https://<project-ref>.supabase.co/auth/v1/callback`
3. `Auth -> URL Configuration` 添加:
- 站点 URL(生产域名)
- 回调 URL(例如 `https://polyweather-pro.vercel.app/auth/callback`
## 3. 数据库脚本
在 Supabase SQL Editor 执行:
- `scripts/supabase/schema.sql`
会创建支付与订阅相关表:
- `subscriptions`
- `payments`
- `entitlement_events`
- `user_wallets`
- `wallet_link_challenges`
- `payment_intents`
- `payment_transactions`
## 4. 环境变量
### 4.1 前端(Vercel / frontend/.env.local
```env
NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_ANON_KEY=
POLYWEATHER_AUTH_ENABLED=true
POLYWEATHER_AUTH_REQUIRED=false
POLYWEATHER_API_BASE_URL=http://<backend-host>:8000
POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN=
# WalletConnect(支持手机钱包扫码)
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=
NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL=https://polygon-bor-rpc.publicnode.com
# Overlay 跳转
NEXT_PUBLIC_TELEGRAM_GROUP_URL=https://t.me/<your_group>
```
### 4.2 后端 / Bot.env
```env
POLYWEATHER_AUTH_ENABLED=true
POLYWEATHER_AUTH_REQUIRED=false
POLYWEATHER_AUTH_REQUIRE_SUBSCRIPTION=false
SUPABASE_URL=
SUPABASE_ANON_KEY=
SUPABASE_SERVICE_ROLE_KEY=
SUPABASE_HTTP_TIMEOUT_SEC=8
POLYWEATHER_PAYMENT_ENABLED=true
POLYWEATHER_PAYMENT_CHAIN_ID=137
POLYWEATHER_PAYMENT_RPC_URL=https://polygon-bor-rpc.publicnode.com
POLYWEATHER_PAYMENT_RECEIVER_CONTRACT=0x<receiver_contract>
POLYWEATHER_PAYMENT_CONFIRMATIONS=2
POLYWEATHER_PAYMENT_INTENT_TTL_SEC=1800
POLYWEATHER_PAYMENT_WALLET_CHALLENGE_TTL_SEC=600
POLYWEATHER_PAYMENT_POLL_INTERVAL_SEC=4
POLYWEATHER_PAYMENT_MAX_WAIT_SEC=50
# 支持双币种(示例)
POLYWEATHER_PAYMENT_ACCEPTED_TOKENS_JSON=[{"code":"usdc_e","symbol":"USDC.e","name":"USDC.e (PoS)","address":"0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174","decimals":6,"receiver_contract":"0x<receiver>","is_default":true},{"code":"usdc","symbol":"USDC","name":"Native USDC","address":"0x3c499c542cef5e3811e1192ce70d8cc03d5c3359","decimals":6,"receiver_contract":"0x<receiver>"}]
# 套餐(当前只保留月付)
POLYWEATHER_PAYMENT_PLAN_CATALOG_JSON={"pro_monthly":{"plan_id":101,"amount_usdc":"5","duration_days":30}}
POLYWEATHER_PAYMENT_ALLOWED_PLAN_CODES=pro_monthly
# 积分抵扣
POLYWEATHER_PAYMENT_POINTS_ENABLED=true
POLYWEATHER_PAYMENT_POINTS_PER_USDC=500
POLYWEATHER_PAYMENT_POINTS_MAX_DISCOUNT_USDC=3
# 支付自动补单
POLYWEATHER_PAYMENT_EVENT_LOOP_ENABLED=true
POLYWEATHER_PAYMENT_CONFIRM_LOOP_ENABLED=true
```
## 5. 钱包异动频道拆分(推荐)
如果要把“钱包异动监控”发到独立频道:
```env
POLYMARKET_WALLET_ACTIVITY_CHAT_ID=-1003821482461
```
说明:
- 设置了 `POLYMARKET_WALLET_ACTIVITY_CHAT_ID(S)` 后,钱包异动推送优先发该频道。
- 未设置时,回退到全局 `TELEGRAM_CHAT_IDS/TELEGRAM_CHAT_ID`
## 6. 验证步骤
1. 登录后请求 `/api/auth/me`,确认 `authenticated=true`
2. 请求 `/api/payments/config`,确认 `enabled=true``configured=true`
3. 钱包绑定:
- `POST /api/payments/wallets/challenge`
- `POST /api/payments/wallets/verify`
4. 支付流程:
- `POST /api/payments/intents`
- 发链上交易
- `POST /api/payments/intents/{id}/submit`
- `POST /api/payments/intents/{id}/confirm`
5. 若前端显示 pending,轮询:
- `GET /api/payments/intents/{id}`
6. 确认订阅:`/api/auth/me` 返回 `subscription_active=true`
+57 -49
View File
@@ -1,65 +1,73 @@
# 🛠️ Technical Debt & Engineering Backlog
# 技术债与工程待办(v1.4.0
> **Vision**: Moving from a research script to a production SaaS.
最后更新:`2026-03-14`
---
目标:在收费上线后,优先保证支付可靠性、权限一致性和运营可追溯性。
## 🏛️ System Health: 75%
## 1. 债务快照
当前估计:**93% 稳定 / 7% 技术债**。
```mermaid
pie title System Health & Tech Debt
"Stable Engine" : 75
"Centralized Logic Debt" : 10
"Subscription DB Debt" : 10
"Testing/Replay Debt" : 5
flowchart TD
A["技术债"]
subgraph P["支付与订阅"]
P1["异常交易自动重放策略"]
P2["退款与工单流程"]
P3["多链结算对账"]
end
subgraph E["权限与运营"]
E1["前后端/Bot 权限矩阵回归"]
E2["积分与订阅冲突策略"]
end
subgraph O["可观测性"]
O1["支付失败原因分层指标"]
O2["业务监控看板"]
end
A --> P
A --> E
A --> O
```
The core engine is stable, but several infrastructure "shortcut" decisions remain.
## 2. 近期已关闭
### Current Stable Modules
- P1 支付主链路已上线(intent -> submit -> confirm)。
- 支付自动补单已上线(Event Loop + Confirm Loop)。
- 钱包绑定支持浏览器钱包 + WalletConnect。
- 账户中心与 Pro 权限展示链路打通。
- 钱包异动支持独立频道路由。
- [x] Multi-source Weather Aggregation
- [x] DEB Blending Algorithm
- [x] Proactive Telegram Alert Engine
- [x] Vercel Dashboard Infrastructure
## 3. 高优先级技术债
---
| 项目 | 影响 | 建议动作 |
| :-- | :-- | :-- |
| 支付异常重放策略标准化 | 偶发确认失败需人工介入 | 建立 tx hash 自动回放 + 降级路径 |
| 退款与售后链路 | 商业闭环不完整 | 增加退款状态机与工单系统 |
| 订阅审计可视化 | 排障效率受限 | 建立订阅事件时间线视图 |
| 多邮箱绑定同 TG 账户策略 | 积分归属易混淆 | 引入主账号绑定策略与迁移工具 |
## 🔴 High Priority: Immediate Focus
## 4. 中优先级技术债
| Debt Item | Impact | Suggested Fix |
| :--------------------- | :-------------------------------------------------- | :--------------------------------------------------------------- |
| **Monolithic Bot** | `bot_listener.py` is hard to test and evolve. | Isolate UI interaction from business logic into `src/analysis`. |
| **Subscription Store** | No persistent record of who has paid. | Migrate from in-memory user checks to **Supabase/PostgreSQL**. |
| **Alert Transparency** | Operators cannot easily audit "why" an alert fired. | Add an `Evidence` metadata block to all internal alert payloads. |
| 项目 | 影响 | 建议动作 |
| :-- | :-- | :-- |
| 积分发放可解释性 | 用户理解成本高 | 输出积分来源明细(发言/签到/奖励) |
| 支付失败文案标准化 | 转化率受影响 | 建立错误码 -> 文案映射表 |
| 配置收敛 | 运维出错概率高 | 将支付/推送配置集中分组管理 |
---
## 5. 低优先级技术债
## 🟡 Medium Priority: Quality of Life
| 项目 | 影响 | 建议动作 |
| :-- | :-- | :-- |
| 前端离线缓存能力 | 非核心 | 评估 Service Worker + IndexedDB |
| 冷启动波动 | 首屏抖动 | 热点城市预热 |
| Debt Item | Impact | Suggested Fix |
| :------------------------ | :-------------------------------------------------- | :--------------------------------------------------------------------------- |
| **Hard-coded Thresholds** | Modification requires code changes (e.g., 5s CD). | Extract all business constants into a structured `config.yaml`. |
| **Simulation Harness** | No way to "replay" a rainy day to test alert logic. | Build a `ReplayEngine` using `data/daily_records.json`. |
| **Backend Naming** | Artifacts of "market price" logic remain in naming. | Systematic refactor of variable names to reflect weather-intelligence focus. |
## 6. 下阶段里程碑
---
## 🟢 Low Priority: Optimization
| Debt Item | Impact | Suggested Fix |
| :------------------------- | :---------------------------------------------- | :------------------------------------------------------------- |
| **Serverless Cold Starts** | Initial Vercel API calls can be slow. | Implement edge-cache or warming cron for major city endpoints. |
| **Local SQLite Files** | Not compatible with Vercel's ephemeral storage. | Full transition to a remote DB (Supabase/Redis). |
---
## 🗓️ Next Milestones
1. **DB Integration**: Connect Supabase to `src/database/db_manager.py`.
2. **Alert Transparency**: Append logic metrics (slope, lead delta) to push messages.
3. **Authentication**: Secure `/api/city` on Vercel with subscription keys.
---
**📅 Last Updated**: 2026-03-06
1. 完成支付异常自动重放与告警分层。
2. 上线退款/售后后台最小版。
3. 建立商业化运营看板(支付、续费、留存)。
4. 完成权限矩阵自动化回归测试。
+73
View File
@@ -0,0 +1,73 @@
# 技术债与工程待办(v1.4.0
最后更新:`2026-03-14`
目标:在收费上线后,优先保证支付可靠性、权限一致性和运营可追溯性。
## 1. 债务快照
当前估计:**93% 稳定 / 7% 技术债**。
```mermaid
flowchart TD
A["技术债"]
subgraph P["支付与订阅"]
P1["异常交易自动重放策略"]
P2["退款与工单流程"]
P3["多链结算对账"]
end
subgraph E["权限与运营"]
E1["前后端/Bot 权限矩阵回归"]
E2["积分与订阅冲突策略"]
end
subgraph O["可观测性"]
O1["支付失败原因分层指标"]
O2["业务监控看板"]
end
A --> P
A --> E
A --> O
```
## 2. 近期已关闭
- P1 支付主链路已上线(intent -> submit -> confirm)。
- 支付自动补单已上线(Event Loop + Confirm Loop)。
- 钱包绑定支持浏览器钱包 + WalletConnect。
- 账户中心与 Pro 权限展示链路打通。
- 钱包异动支持独立频道路由。
## 3. 高优先级技术债
| 项目 | 影响 | 建议动作 |
| :-- | :-- | :-- |
| 支付异常重放策略标准化 | 偶发确认失败需人工介入 | 建立 tx hash 自动回放 + 降级路径 |
| 退款与售后链路 | 商业闭环不完整 | 增加退款状态机与工单系统 |
| 订阅审计可视化 | 排障效率受限 | 建立订阅事件时间线视图 |
| 多邮箱绑定同 TG 账户策略 | 积分归属易混淆 | 引入主账号绑定策略与迁移工具 |
## 4. 中优先级技术债
| 项目 | 影响 | 建议动作 |
| :-- | :-- | :-- |
| 积分发放可解释性 | 用户理解成本高 | 输出积分来源明细(发言/签到/奖励) |
| 支付失败文案标准化 | 转化率受影响 | 建立错误码 -> 文案映射表 |
| 配置收敛 | 运维出错概率高 | 将支付/推送配置集中分组管理 |
## 5. 低优先级技术债
| 项目 | 影响 | 建议动作 |
| :-- | :-- | :-- |
| 前端离线缓存能力 | 非核心 | 评估 Service Worker + IndexedDB |
| 冷启动波动 | 首屏抖动 | 热点城市预热 |
## 6. 下阶段里程碑
1. 完成支付异常自动重放与告警分层。
2. 上线退款/售后后台最小版。
3. 建立商业化运营看板(支付、续费、留存)。
4. 完成权限矩阵自动化回归测试。
+260
View File
@@ -0,0 +1,260 @@
# PolyWeather 深度评估与改进提案报告
## 执行摘要
PolyWeather(仓库:`yangyuan-zhen/PolyWeather`)定位为**面向温度类结算预测市场(如 Polymarket 的温度结算合约)**的“生产级气象情报系统”,核心在于把多源天气观测/预报转化为**结算导向的概率桶(μ + bucket distribution**,并进一步映射到市场报价完成**错价扫描**;同时提供 Web 仪表盘与 Telegram Bot 两套交互入口,并包含 Polygon 链上 USDC/USDC.e 支付、自动补单与订阅/积分体系。项目 README 明确其“Open-Core”边界:仓库公开天气聚合、基础分析、看板、Bot、标准支付流程;生产私有部分包含商业风控、阈值与运营工具等。
从工程实现看,当前版本(README 标注 `v1.4.0`,最后更新 `2026-03-14`)已经落地关键业务闭环:多源天气采集(Open-Meteo、AviationWeather METAR、土耳其 MGM、香港 HKO、台湾 CWA、美国 NWS 等)、DEB 动态融合、趋势/概率引擎、Polymarket 只读行情层、前端缓存策略(ETag/304、force_refresh no-store)、以及支付事件监听与确认循环。
但它也暴露出典型的“从快速迭代走向稳态生产”的结构性问题:核心模块(如 `src/data_collection/weather_sources.py``web/app.py`)体量巨大、职责耦合;配置/依赖/可复现性仍偏脚本化;测试覆盖存在但 CI/CD 缺失;多处自定义缓存与状态文件并发一致性风险;以及对第三方 APIOpen-Meteo、AviationWeather、Supabase、Polymarket)在**配额、变更、SLA、合规**方面需要更系统的治理。
本报告给出的改进方向按收益/风险/工作量分级,优先建议聚焦三条主线:
第一,**模块化与工程化**:将采集/分析/市场层/支付/鉴权拆成清晰边界,补齐 CI、测试、类型与规范化配置;第二,**可观测性与可靠性**:统一缓存与状态管理、增加限流与退避策略、建立指标与告警;第三,**模型与评测体系**:围绕“结算命中率 + 偏差(MAE/RMSE+ 概率校准(Brier/CRPS+ 错价信号有效性(Edge/PnL 模拟)”建立基准与回归测试,并可在合规前提下评估引入更先进的气象后处理(如 EMOS)或外部 AI 预报(GraphCast/FourCastNet/Pangu-Weather 的商业许可限制需特别注意)。
## 项目概览
PolyWeather 的目标与范围在 README/README_ZH 中定义得较清楚:为温度结算市场提供气象情报(多源采集→融合→概率→对照市场报价),并提供“官方看板(Vercel 前端)+ VPS 后端 + Telegram Bot”。
项目主功能可归纳为四层:
**天气层(数据源/采集)**:聚合 20 个城市的实测与预报;支持 AviationWeather METAR(机场观测)、土耳其 MGM 站网、Open-Meteo(含多模型与集合预报)、美国 NWS(仅美国城市)、以及部分城市使用官方结算源(香港 HKO、台北 CWA)等。
**分析层(DEB/趋势/概率/结算口径)**:
DEBDynamic Error Balancing)基于过去 N 天模型误差(MAE)倒数加权,输出融合预报;同时维护 `daily_records.json` 做历史对账、命中率/MAE 统计,并支持基于 WUWeather Underground 口径)四舍五入的结算命中评估。
趋势/概率引擎在 `trend_engine.py` 中实现:综合“集合预报区间→σ/μ→高温窗口→死盘判定→温度桶概率分布→边界提示”等,用于 bot 展示与 web 结构化数据输出。
**市场层(Polymarket 行情对照)**:只读模式从 Gamma API 发现市场、从 CLOB`py-clob-client` 或 REST 回退)读取价格/盘口并计算 edge(模型概率 − 市场概率)生成信号标签。
**商业化与支付**:订阅(`Pro Monthly 5 USDC`)、积分抵扣、Polygon 链上收款合约(USDC/USDC.e),并提供“事件监听 + 周期确认”的自动补单机制。
**支持的数据集/数据源**:项目不是传统“训练数据集+模型训练”的机器学习仓库;其“数据集”本质是外部实时/预报 API 与站点观测数据。对外部数据的使用需要遵守来源方的访问与速率限制,例如 AviationWeather Data API 明确限制请求频率(含每分钟请求上限/建议降低频率与使用缓存文件)。
**许可证**:仓库根目录 `LICENSE` 为 MIT。 同时 README 强调 Open-Core 策略与生产私有组件边界,意味着“可复现/可审计”的范围以公开部分为准。
(插图:项目 README 中包含产品截图,可用于快速理解信息架构与 UI 形态)
![PolyWeather demo map](https://raw.githubusercontent.com/yangyuan-zhen/PolyWeather/main/docs/images/demo_map.png)
## 架构与代码库分析
### 代码库模块地图
从 README、Docker/Compose、入口脚本与核心模块引用关系,可以抽象出如下模块地图(按“运行时组件”与“Python 域模块”两层描述):
| 层级 | 目录/文件 | 角色定位 | 关键说明 |
| ------------- | ------------------------------------------------------------------------ | ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 运行时组件 | `frontend/` | Next.js 前端(Vercel | 前端重构报告提到 App Router、Route HandlersBFF)、缓存策略、支付与账户中心等。 |
| 运行时组件 | `web/app.py` | FastAPI 后端 API | 作为前端 BFF 与 Telegram Bot 的共同 API。 |
| 运行时组件 | `bot_listener.py` + `src/bot/*` | Telegram Bot | 入口 `bot_listener.py``start_bot()`,并由 `StartupCoordinator` 启动多个后台 loop。 |
| Python 域模块 | `src/data_collection/*` | 天气采集 + 城市注册 + 市场读取 | `WeatherDataCollector``CITY_REGISTRY``PolymarketReadOnlyLayer` 等。 |
| Python 域模块 | `src/analysis/*` | DEB/趋势/概率/结算口径 | `deb_algorithm.py``trend_engine.py``settlement_rounding.py`。 |
| Python 域模块 | `src/payments/*` + `contracts/*` | 支付合约 + 事件监听/补单 | Solidity 合约 + Python 侧事件扫描与确认循环。 |
| Python 域模块 | `src/auth/*``docs/SUPABASE_SETUP_ZH.md``scripts/supabase/schema.sql` | Supabase 鉴权/订阅/积分 | 使用 `/auth/v1/user` 校验 JWT、`/rest/v1/subscriptions` 查订阅(服务端角色 key 必须保密)。 |
| 工程与运维 | `docker-compose.yml``Dockerfile``update.sh``scripts/*` | 部署/验证脚本 | Compose 启两个容器(bot 与 web),脚本校验 ETag/缓存、更新重启等。 |
### 参考架构与关键工作流
项目 README 给出了一版 mermaid 参考架构图(Web/Telegram→FastAPI→采集→分析→支付/市场层)。 在此基础上,结合 `StartupCoordinator` 的 loop 启动与支付监听逻辑,可补充一个更“运行时视角”的架构图:
```mermaid
flowchart TB
subgraph Clients
WEB[Next.js Frontend<br/>Vercel]
TG[Telegram Bot<br/>TeleBot + Handlers]
end
subgraph API
FAST[FastAPI<br/>web/app.py]
end
subgraph Data
WX[WeatherDataCollector]
CITY[CITY_REGISTRY]
HIST[daily_records.json<br/>DEB history]
CACHE[open_meteo_cache.json<br/>disk cache]
end
subgraph ExternalAPIs
OM[Open-Meteo Forecast/Ensemble/Multi-model]
AW[AviationWeather Data API<br/>METAR]
MGM[MGM Turkey]
NWS[api.weather.gov]
HKO[data.weather.gov.hk]
CWA[opendata.cwa.gov.tw]
PM_G[Polymarket Gamma API]
PM_C[Polymarket CLOB API]
SB[Supabase Auth/REST]
RPC[Polygon RPC]
end
subgraph Payments
SOL[PolyWeatherCheckout.sol]
EVT[event_loop<br/>scan logs]
CF[confirm_loop<br/>confirm intents]
end
WEB --> FAST
TG --> FAST
TG -->|StartupCoordinator<br/>starts loops| EVT
TG --> CF
FAST --> WX
WX --> OM
WX --> AW
WX --> MGM
WX --> NWS
WX --> HKO
WX --> CWA
FAST --> PM_G
FAST --> PM_C
FAST --> SB
EVT --> RPC
CF --> RPC
RPC --> SOL
WX --> CACHE
FAST --> HIST
WX --> CITY
```
### 依赖与运行环境
**Python 依赖**`requirements.txt` 包含 `requests``loguru``pyTelegramBotAPI``python-dotenv``numpy``web3``fastapi``uvicorn` 等,符合“采集+bot+api+链上交互”的需求。
**容器环境**`Dockerfile` 基于 `python:3.11-slim`,默认启动 bot`docker-compose.yml` 通过不同 command 分别启动 bot 与 web`python bot_listener.py` / `python web/app.py`),并挂载运行态数据目录。
**前端依赖**:前端 README 描述 Next.js、Leaflet、Chart.js、Supabase Auth、WalletConnect 等;`frontend/package.json` 是前端依赖来源。
### 数据预处理、模型与“训练/推理”管线
本项目的“模型”主要是统计融合与规则/启发式引擎,而非深度网络训练:
**天气数据预处理**`WeatherDataCollector` 内部做了大量“输入清洗+缓存+退避”的工程处理:
包含 Open-Meteo 三类缓存(forecast/ensemble/multi_model)、429 冷却期、最小调用间隔、磁盘持久化缓存文件(重启后避免冷启动打爆 API)、以及 METAR/结算源缓存。
**DEBDynamic Error Balancing**:以最近 N 天各模型的 MAE 计算倒数权重并做加权融合;同时将 `forecasts / actual_high / deb_prediction / mu / prob_snapshot` 写入 `data/daily_records.json`,并提供命中率/MAE/Brier 等统计口径。
**概率引擎**`trend_engine.py` 以集合预报的 p10/p90 推 σ(并考虑历史 MAE floor、风向/云量/压强的 shock_score、以及峰值窗口 time-decay),再用正态近似把连续分布映射为 WU 整数“温度桶概率”。
**推理流水线(在线)**
Web/Telegram 请求 → FastAPI 调用采集器抓取/复用缓存 → 分析引擎输出结构化结果(μ、概率桶、趋势、死盘/窗口判定、DEB 预测、市场扫描)→ 前端渲染或 bot 消息格式化。
**检查点(checkpoints**:传统 ML checkpoint 不适用;但项目存在两类“业务状态 checkpoint”:
`daily_records.json`DEB 历史与评测快照)与 `open_meteo_cache.json`Open-Meteo 预报磁盘缓存)。
### 测试、CI/CD 与运维验证
**测试**:仓库存在 `tests/test_trend_engine.py`,覆盖 μ 计算、死盘判定、预报崩盘提示、趋势方向等核心逻辑(通过 patch 隔离外部依赖)。
**CI/CD**:从仓库检索结果看,未发现公开的 GitHub Actions 工作流(`.github/workflows` 搜索为空),需要补齐自动化质量门禁。
**运维验收**:提供 `scripts/validate_frontend_cache.sh` 校验 `/api/cities``/api/city/<city>/summary``/api/history/<city>` 的 ETag/Cache-Control,并对 `force_refresh` 期望 `no-store`
**部署/更新**:Compose 用于启动服务;另有 `update.sh` 通过 `pkill` + `nohup` 重启 bot 与 web。
## 优势与薄弱点
### 优势
**产品闭环完整、目标明确**:从“天气→结算→市场→错价信号→付费体系(订阅/积分/链上支付)”形成可商业化闭环,并在 README 清晰列出当前产品状态(订阅、积分抵扣、链上支付、自动补单等已上线)。
**复用一套分析内核服务多端**:趋势/概率/DEB 等核心逻辑被抽成分析模块,并被 web 与 bot 共用,避免“两套逻辑漂移”。
**面向外部 API 的工程防护意识较强**:Open-Meteo 429 冷却期、最小调用间隔、磁盘缓存、缓存 TTL 等措施表明作者已遭遇并处理速率限制与冷启动问题。 同时 AviationWeather 官方文档也明确建议控制频率并可使用 cache 文件降低负载,项目后续可进一步对齐最佳实践。
**支付侧有“事件监听 + 确认补单”的双通路**:支付链路天然存在“交易 pending / RPC 延迟 / 日志索引不完整”等问题,项目通过 event loop 与 confirm loop 双机制提升最终一致性。
### 薄弱点与风险
**核心文件过大导致可维护性下降**`WeatherDataCollector` 集“多源采集 + 缓存 + 限流 + 解析 + 部分业务逻辑(城市/单位/回退策略)”于一体,规模继续增长会显著提高回归风险与重构成本。 同类问题往往也会出现在“单文件 FastAPI 应用”形态(`web/app.py`)。
**可复现性仍偏“脚本+隐式约定”**:
虽然给了 Docker/Compose 快速启动,但缺少一份稳定的 `.env.example` / 配置 schema(哪些变量必需、默认值、敏感级别、环境分层),导致他人复现时容易踩坑;此外 `update.sh``pkill` 强杀进程方式更新,存在误杀与状态丢失风险,建议迁移到 systemd/容器滚动更新/健康检查。
**测试存在但依赖与 CI 缺失**:有 pytest 单测文件,但 `requirements.txt` 未体现 dev 依赖与一键运行指令,且缺少 CI 自动运行,容易出现“本地能跑、线上漂移”。
**第三方服务合规与稳定性风险**
项目强依赖外部 APIOpen-Meteo、AviationWeather、NWS、HKO、CWA、Polymarket、Supabase)。其中 AviationWeather Data API 有明确速率限制;Polymarket 官方说明 Gamma/Data/CLOB 三套 API 分属不同域,CLOB 交易端点需鉴权且策略可能变化;Supabase 明确强调 `service_role`/secret keys 绝不可暴露。若缺乏集中治理(重试/退避/熔断/降级/配额监控/密钥轮换),稳定性与合规不可控。
**许可证/商业使用的潜在冲突点**:仓库自身是 MIT,但如果未来尝试引入外部 AI 预报模型,需要非常谨慎:GraphCast 仓库代码 Apache-2.0,但权重使用 CC BY-NC-SA 4.0(非商业),Pangu-Weather 权重同样 BY-NC-SA 且明确禁止商业用途;不加区分地把这些模型用于付费产品会留下法律风险。
## 对标分析
为满足“至少 3 个相似开源项目或近期论文”对标,本报告选择三类代表:
1**AI 气象预报模型**GraphCast / FourCastNet / Pangu-Weather):用于评估“若 PolyWeather 未来扩展到更强预测能力”的技术与许可边界;
2)**概率后处理方法**EMOS):作为 PolyWeather 概率引擎的更标准化替代/对照;
3)**预测市场 API 客户端生态**Polymarket/py-clob-client、aiopolymarket):用于评估市场层的工程选型。
### 关键对比表
| 项目/论文 | 解决的问题 | 输出形态 | 性能/效果(公开描述) | 易用性与依赖 | 许可证要点 |
| --------------------------------------------------------- | ---------------------------------------------------- | ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
| **PolyWeather**(本仓库) | 温度结算市场气象情报:多源→概率桶→错价扫描→订阅/支付 | 生产级应用(Web+Bot+API+支付) | 以工程能力为主;内置 DEB、概率桶、死盘判定、市场扫描;覆盖 20 城市。 | 主要依赖外部 APIDocker Compose 一键启动。 | 仓库 MIT;Open-Core(部分生产规则私有)。 |
| **GraphCast**google-deepmind/graphcast | 10 天全球中期预报(ML 替代/增强 NWP) | 模型代码+权重+notebooks | 论文与介绍提到在大量指标上优于主流确定性系统;仓库提供预训练权重与示例数据入口,并提示 ERA5/HRES 数据条款需另行遵守。 | 完整训练需 ERA5 等;更适合科研/平台级推理,不是产品级 BFF。 | 代码 Apache-2.0;权重 CC BY-NC-SA 4.0(商业限制)。 |
| **FourCastNet**NVlabs/FourCastNet | 高分辨率 data-driven 全球预报(AFNO/ViT) | 模型训练/推理代码+数据/权重链接 | README 描述:0.25° 分辨率、周尺度推理非常快,并可做大规模集合;适合平台型预报。 | 训练/数据依赖大(ERA5 子集 TB 级);工程集成成本高。 | BSD 3-Clause(代码)。 |
| **Pangu-Weather**198808xc/Pangu-Weather + Nature 论文) | 3D Transformer 架构的中期全球预报 | ONNX 推理代码+预训练模型 | Nature 论文称在 reanalysis 上对比 IFS 有更强确定性预报表现,并强调速度优势;仓库提供 ONNX 推理与 lite 版训练说明。 | 模型文件大(多份 ~GB 级),训练资源需求高;更适合科研推理或内部平台。 | 权重 BY-NC-SA 4.0、明确禁止商业用途。 |
| **EMOS**Gneiting & Raftery 等) | 集合预报校准:纠偏与解决 underdispersion | 统计后处理方法 | 提出用回归形式输出概率分布(常见为高斯),并以 CRPS 等指标拟合,属于成熟的气象概率校准路线。 | 易落地:对 PolyWeather 而言只需“历史库+拟合器”。 | 方法论(论文);可自行实现,无额外许可约束(注意论文版权)。 |
| **Polymarket/py-clob-client** | Polymarket CLOB 读写 SDK | Python SDK | 官方 SDK,支持 read-only 与交易接口;协议与端点在官方文档中给出。 | 易用,适合增强 PolyWeather 市场层。 | MIT。 |
| **aiopolymarket** | Polymarket APIs 的 async 客户端 | Python async 客户端 | 强调类型安全(Pydantic)、自动分页、重试与 backoff,适合高并发与健壮性诉求。 | 适合替换/补强当前同步 requests 与自定义缓存。 | 以仓库许可为准(此处建议上线前核验)。 |
**对标结论**PolyWeather 与这类“全球 AI 预报模型”不在同一层级:PolyWeather 是“面向结算市场的产品化情报系统”,其价值核心是**将预测转成可交易/可结算的决策信息**。短中期内更高 ROI 的方向不是“自训大模型”,而是把现有“采集+后处理+市场映射”的链路做成**可复现、可观测、可评测、可扩展**的工程平台;在许可合规前提下,再评估引入外部模型推理作为额外信号源。
## 优先级改进建议
下表给出“高/中/低”优先级的具体改进清单,包含工作量估计、主要风险与可执行步骤(假设“无特定部署约束/性能指标约束”)。
| 优先级 | 改进项 | 预估工作量 | 主要收益 | 主要风险 | 可执行步骤(建议顺序) |
| ------ | --------------------------------------------------------------------------------------------------------------------------------- | -------------------: | ------------------------------------------------------------------- | ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 高 | **拆分 `WeatherDataCollector` 为可插拔 Provider 架构**OpenMeteoProvider / MetarProvider / NwsProvider / SettlementProvider 等) | 1–2 周 | 降低耦合、提高可测性,便于加入新城市/新源;减少回归面 | 重构期间线上行为漂移 | 1) 定义统一接口(输入:city/lat/lon/date;输出:标准 schema)→ 2) 把缓存/限流抽为中间层(decorator)→ 3) 用现有测试补齐 provider 单测 → 4) 灰度开启(仅部分城市走新链路) |
| 高 | **拆分 `web/app.py`:路由层/服务层/DTO/依赖注入** | 1–2 周 | API 可维护性、鉴权/限流/缓存策略更清晰;更易做 OpenAPI 文档与版本化 | 改路由可能影响前端 | 1) 抽出 `services/analysis_service.py``services/market_service.py``services/payment_service.py` → 2) 引入 Pydantic models 作为响应 schema → 3) 保持 URL 不变,先做内部重构 |
| 高 | **建立 CIPython + Frontend)与质量门禁**lint/format/typecheck/test/docker build | 35 天 | 防回归、提高贡献效率;让 `v1.4` 之后迭代更稳 | 初期会暴露大量历史问题 | 1) Pythonruff + mypy + pytestNodeeslint + typecheck + build → 2) GitHub Actions 两条 pipeline → 3) 给出“允许失败→逐步收紧”的迁移策略 |
| 高 | **补齐可复现配置与密钥分级**`.env.example` + 配置文档 + 敏感项隔离 | 2–4 天 | 新环境搭建更快、减少误配置;降低密钥泄露风险 | 需要梳理现有 env 变量 | 1) 盘点 envOpen-Meteo、Polymarket、Supabase、RPC、支付等)→ 2) `.env.example` 提供默认与说明 → 3) 标注敏感等级(尤其 `SUPABASE_SERVICE_ROLE_KEY`)并禁止前端/日志输出 |
| 高 | **统一“状态与缓存”方案**:把 JSON 文件状态迁移到 SQLite/Postgres/Redis(至少做到原子/锁一致) | 1–2 周 | 降低并发一致性 bug(多进程/多容器)、便于观测与回放 | 迁移会引入数据兼容与历史清理问题 | 1) 为 `daily_records``open_meteo_cache` 定义表结构 → 2) 先实现写入双写(JSON+DB)→ 3) 校验一致后切换读取 → 4) 下线 JSON 文件 |
| 中 | **市场层升级为 async + 类型安全**:引入 `aiopolymarket` 或在现有层加重试/backoff/连接池 | 4–7 天 | 行情层更稳,减少短时网络抖动;更易扩展更多市场/分页 | 依赖升级带来的行为差异 | 1) 把 requests.Session 替换为 aiohttp/httpx → 2) 在 Gamma/CLOB 调用侧实现指数退避 → 3) 引入 typed models,减少解析失败 |
| 中 | **概率引擎更标准化:引入 EMOS/CRPS 拟合做校准**(替换/增强当前正态近似与规则 σ) | 1–2 周 | 概率输出更可解释、可校准;适合做长期回归评测 | 需要足够历史样本;可能改变用户体验 | 1) 以 `daily_records` 为训练集(模型预报均值/方差→实际)→ 2) 先离线拟合并与现模型对比 → 3) 线上 shadow 输出(不影响主显示)→ 4) 达标后切换 |
| 中 | **支付合约/链上交互加强审计与防护**:事件重放、重入/授权边界、RPC 多节点容灾 | 1 周 | 提升资金链路可信度;减少链上卡单 | 合约升级需要迁移/再验证 | 1) 为 event loop 增加“最后处理区块高度”持久化与重放工具 → 2) RPC 端支持多 URL fallback → 3) 合约侧考虑 OpenZeppelin Ownable/SafeERC20(如升级)并更新验证流程 |
| 低 | **引入外部 AI 预报模型作为附加信号**GraphCast/FourCastNet/Pangu-Weather 等) | 2–6 周(取决于范围) | 可能提升极端/中期预测能力与差异化 | **商业许可限制**(多为 CC BY-NC-SA/禁止商业)与算力成本 | 1) 先做合规评审(权重许可/数据条款)→ 2) 仅在研究/非商业环境评估 → 3) 若要商用,优先选择可商用权重或自研/购买授权 |
### 文档、测试与贡献流程的具体补强建议(落到仓库层面)
1)**文档体系**:保留现有中文 API/TechDebt 文档的同时,增加三份“高价值”文档:
(a)《运行与配置手册》:按环境(本地/测试/VPS/生产)列必需变量、默认值、敏感等级;(b)《数据源与合规说明》:列出 Open-Meteo、AviationWeather、NWS、HKO、CWA、Polymarket、Supabase 的使用条款要点、速率限制与降级策略(例如 AviationWeather 明确建议降低请求频率并提供 cache 文件)。 (c)《故障排查 Runbook》:429、支付 pending、市场扫描 miss、前端缓存异常等典型故障处理。
2**测试金字塔**:在现有 `trend_engine` 单测基础上,补齐:
(a)天气 provider 的“录制回放”测试(VCR 思路:固定响应→确保解析稳定);(b)市场层的契约测试(Gamma/CLOB schema 变更时提前失败);(c)支付链路的本地链集成测试(Hardhat/Anvil + 事件扫描回放)。这些测试能把“外部依赖漂移”尽量转成可控的回归失败。
3**贡献工作流**:引入 `CONTRIBUTING.md`(分支策略、PR 模板、变更日志、版本号策略)、`CODEOWNERS`(核心模块审查人)、`SECURITY.md`(漏洞披露与密钥处理),并把静态检查(ruff/eslint)作为 pre-commit + CI 必过项。
## 建议实验与基准
PolyWeather 的评测应围绕“结算场景”而非传统数值天气预报所有变量。建议建立两类基准:**气象预测基准(结算导向)**与**市场信号基准(交易导向)**。
### 气象预测与概率校准基准
**数据集**(建议从现有生产数据演进)
1`daily_records.json` 的历史快照:已包含多模型预报、`actual_high``deb_prediction``mu` 与概率快照字段,天然可转成评测数据(建议迁移到 DB 后做版本化导出)。
2)观测“真值”统一口径:对 METAR 城市用 AviationWeather Data API;对香港/台北等按结算源(HKO/CWA)作为真值,和项目当前逻辑一致。
**指标**
1)确定性误差:MAE、RMSE(按城市、按季节、按风险等级分组);
2)结算命中率:`WU_round(pred) == WU_round(actual)`(项目已有统计口径);
3)概率质量:Brier Score(对离散温度桶),以及建议补充 CRPS(连续变量概率评分,EMOS 体系常用)。
4)校准曲线:预测概率分箱的可靠性图(reliability diagram)与 Sharpness(分布集中度)。
**基线**
- Baseline AOpen-Meteo 当日最高温(或 forecast median)作为点预测;
- Baseline B:等权平均(DEB 在历史少时也会回退此策略);
- Baseline C:当前 DEB
- Baseline DEMOS(以 ensemble 均值/方差为输入,拟合 μ 与 σ,优化 CRPS)。
**预期结果(定性)**
- 若历史样本足够,DEB 应在“系统性偏差明显”的城市提升 MAE;
- EMOS 类方法通常能在概率校准(可靠性与 CRPS)上更稳定,尤其当 ensemble 信息可用(项目已接入 Open-Meteo ensemble/p10/p90)。
**算力**:以上评测全部可在 CPU 上完成;数据量按“20 城市 × 180 天”级别,pandas/duckdb 即可。若引入更复杂拟合(如分层贝叶斯/分位数回归),也通常不需要 GPU。
### 错价信号与市场有效性基准
**数据集**
- 保存每次扫描输出:`date/city/bucket/model_prob/market_price/liquidity/edge`,并加上未来 `settled_bucket` 作为标签;Polymarket 市场发现与报价来自 Gamma/CLOB(官方文档说明三套 APIGamma/Data/CLOB)。
**指标**
- Signal 覆盖率:能否找到正确 market / bucket
- Edge 稳健性:不同流动性分位的 edge 分布;
- 交易模拟(如需):在考虑滑点/手续费/成交概率下的期望收益(即使项目当前只读,也可以离线评估“若执行”会怎样)。
**基线**
- 简单策略:仅用市场中间价(不做模型)作为概率;
- 当前策略:模型概率 vs 市场概率 edge 阈值;
- 改进策略:引入“流动性/盘口深度/波动”作为信号置信度(aiopolymarket/py-clob-client 提供更完整的盘口读取能力)。
**算力**:CPU 即可;关键在于数据采样与回放。
## 路线图与风险缓解
下面给出一个**12 周**(约 3 个月)的建议路线图,按“可稳定交付的工程里程碑”组织;人力以“1 名后端/数据工程 + 1 名前端(可兼职)+ 0.5 名链上工程(按需)”估算。
| 时间窗 | 里程碑 | 交付物 | 资源/备注 |
| ----------- | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------- |
| 第 1–2 周 | 工程地基:CI + 规范 + 配置可复现 | GitHub Actionsruff/eslintpytest 可一键跑;`.env.example`;敏感项分级说明(尤其 Supabase service role key 不可暴露)。 | 后端为主;前端补 eslint/typecheck |
| 第 3–5 周 | 核心模块解耦:采集 Provider 化 + API 分层 | provider 接口与实现;`web/app.py` 拆分路由与服务;核心 schema(Pydantic) | 风险:行为漂移;用回放测试压住 |
| 第 6–8 周 | 状态/缓存统一 + 可观测性 | `daily_records/open_meteo_cache` 迁移 DB;指标(请求量/429/延迟/命中率);告警阈值 | 可先用 SQLite/Redis,后续再上 Postgres |
| 第 9–10 周 | 评测体系上线 | 离线评测脚本(MAE/RMSE/WU-hit/Brier/CRPS);日报/周报自动生成 | 直接基于项目现有字段扩展 |
| 第 11–12 周 | 概率引擎升级(可选)+ 市场层健壮性增强 | EMOS/CRPS 拟合的 shadow 输出;Gamma/CLOB 客户端增强(async、重试、分页) | 以“小步可回滚”为原则,避免一次性替换 |
### 主要风险与缓解策略
**外部 API 速率限制/格式变更**AviationWeather 明确 rate limit 与建议使用 cache 文件;Open-Meteo 也可能在不同端点策略上变化。缓解:统一“请求预算”与退避/熔断;关键响应做 schema 校验与回放测试;对高频数据优先拉取官方 cache/批量接口(若可用)。
**密钥泄露与权限滥用**:Supabase 明确强调 `service_role` 属高权限密钥,绝不可出现在前端或公开环境。缓解:密钥分级、CI secret scan、运行时最小权限、日志脱敏。
**支付链路最终一致性与链上不确定性**:链上事件索引延迟、RPC 不稳定、交易确认数不足都会导致误判。缓解:保持“事件监听 + 确认补单”双路径,并增加“事件重放/对账工具”、多 RPC fallback、以及链上高度持久化。
**引入外部 AI 预报模型的商业合规风险**GraphCast/Pangu-Weather 的权重许可均带非商业限制(CC BY-NC-SA/BY-NC-SA);若 PolyWeather 是付费产品,必须先做法务与授权评审。缓解:只在研究环境评估;商用优先选择可商用权重/购买授权/自研。
**Open-Core 边界导致的“公开仓库与生产行为不一致”**:README 明确生产存在私有风控与阈值。缓解:把“公开核心”的可复现与评测做扎实(接口/数据 schema/测试/评测),私有策略只作为可插拔 policy layer 接入。
## 参考链接
- PolyWeather 仓库(本次评估对象):https://github.com/yangyuan-zhen/PolyWeather
- Polymarket API 文档(Gamma/Data/CLOB):https://docs.polymarket.com/api-reference
- AviationWeather Data APIMETAR 等):https://aviationweather.gov/data/api/
- Open-Meteo DocsForecast):https://open-meteo.com/en/docs
- Open-Meteo DocsEnsemble):https://open-meteo.com/en/docs/ensemble-api
- Supabase REST APIhttps://supabase.com/docs/guides/api
- Supabase API keysservice_role 风险):https://supabase.com/docs/guides/api/api-keys
- GraphCast(代码 Apache-2.0;权重 CC BY-NC-SA):https://github.com/google-deepmind/graphcast
- FourCastNetBSD-3):https://github.com/NVlabs/FourCastNet
- Pangu-Weather(权重 BY-NC-SA,禁商用):https://github.com/198808xc/Pangu-Weather
- Polymarket 官方 Python CLOB SDKMIT):https://github.com/Polymarket/py-clob-client
- aiopolymarketasync、类型安全):https://github.com/the-odds-company/aiopolymarket
Binary file not shown.

Before

Width:  |  Height:  |  Size: 507 KiB

After

Width:  |  Height:  |  Size: 947 KiB

+54
View File
@@ -0,0 +1,54 @@
# PolyWeatherCheckout PolygonScan 验证(v1.4.0
最后更新:`2026-03-14`
## 1. 目标
对生产收款合约完成源码验证,降低钱包风控误报并提升用户信任。
## 2. 当前部署参数(示例)
- 链:Polygon Mainnet`chainId=137`
- 合约:`PolyWeatherCheckout`
- 编译器:`v0.8.24+commit.e11b9ed9`
- 优化器:`Enabled``runs=200`
> 实际地址以线上配置为准:`POLYWEATHER_PAYMENT_RECEIVER_CONTRACT`
## 3. 构造参数编码
使用仓库脚本生成构造参数:
```bash
python scripts/encode_checkout_constructor.py \
--token 0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174 \
--treasury 0xe581D578EF101c80e3F32263e97E6eA28A0B170e
```
将输出填入 PolygonScan 的 `Constructor Arguments ABI-encoded`
## 4. PolygonScan 操作步骤
1. 打开合约页 -> `Contract` -> `Verify and Publish`
2. 选择 `Solidity (Single file)`
3. 粘贴 `contracts/PolyWeatherCheckout.sol` 源码。
4. 填写编译器/优化器参数。
5. 粘贴构造参数并提交。
## 5. 验证后检查
- `Read Contract`:可见 `owner / treasury / allowedToken / paidOrder`
- `Write Contract`:可见 `pay / setTreasury / setTokenAllowed`
- 标签显示 `Contract Source Code Verified`
## 6. 双币种开启(USDC + USDC.e
验证后可通过 `setTokenAllowed` 开启两种代币:
- USDC.e: `0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174`
- Native USDC: `0x3c499c542cef5e3811e1192ce70d8cc03d5c3359`
## 7. 说明
- 源码验证能显著降低“欺诈/不可信”误报,但钱包风险缓存更新存在延迟。
- 生产商用环境可使用私有升级版合约;公开仓库保留标准实现与验证流程。
+46
View File
@@ -0,0 +1,46 @@
# PolyWeather 侧边栏插件(MVP
这是一个 Chrome / Edge 侧边栏扩展的 MVP,用于把 PolyWeather 右侧城市卡片移植到浏览器侧边栏中。
## 功能
- 侧边栏展示:
- 城市选择
- 风险徽章
- 城市档案(结算源 / 距离 / 观测更新时间 / 周边站点)
- 今日日内走势(简版 Canvas)
- 多日预报
- 快捷按钮:
- 今日日内分析
- 历史对账
- 打开完整网站分析
- 自动识别城市:
- 监听当前激活标签页 URL(例如 Polymarket `.../event/highest-temperature-in-ankara-...`
- 自动将侧边栏城市切换为 URL 对应城市
- 设置页可配置:
- 网站基础地址
- API 基础地址
- Bearer Token(可选)
## 本地安装(开发者模式)
1. 打开 Chrome/Edge 扩展页面:
- Chrome`chrome://extensions`
- Edge`edge://extensions`
2. 打开“开发者模式”。
3. 选择“加载已解压的扩展程序”。
4. 选择目录:`extension/`
5. 点击扩展图标,侧边栏会打开。
## 设置
首次建议打开扩展“选项页”并确认:
- `网站基础地址`:你的前端域名(例如 `https://polyweather-pro.vercel.app`
- `API 基础地址`:你的后端 API 域名(若同域也可填前端域名)
- `Bearer Token`:后端开启鉴权时填写
## 说明
- 当前版本是 MVP,重点是“导流回站”,未接入支付链路。
- 若你的 API 做了严格鉴权,请先在设置页填写 token 再使用。
+6
View File
@@ -0,0 +1,6 @@
chrome.runtime.onInstalled.addListener(() => {
chrome.sidePanel
.setPanelBehavior({ openPanelOnActionClick: true })
.catch(() => {});
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 826 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

+29
View File
@@ -0,0 +1,29 @@
{
"manifest_version": 3,
"name": "PolyWeather Side Panel",
"description": "PolyWeather 右侧城市卡片(浏览器侧边栏)",
"version": "0.1.1",
"icons": {
"16": "icon-16.png",
"32": "icon-32.png",
"48": "icon-48.png",
"128": "icon-128.png"
},
"permissions": ["sidePanel", "storage", "tabs"],
"host_permissions": ["https://*/*", "http://*/*"],
"background": {
"service_worker": "background.js"
},
"action": {
"default_icon": {
"16": "icon-16.png",
"32": "icon-32.png",
"48": "icon-48.png"
},
"default_title": "Open PolyWeather"
},
"side_panel": {
"default_path": "sidepanel.html"
},
"options_page": "options.html"
}
+78
View File
@@ -0,0 +1,78 @@
body {
margin: 0;
background: #0b1225;
color: #e5eefb;
font-family: "Inter", "Segoe UI", -apple-system, BlinkMacSystemFont, sans-serif;
}
.wrap {
max-width: 840px;
margin: 24px auto;
padding: 0 16px;
}
h1 {
margin: 0 0 18px;
font-size: 24px;
}
.field {
display: grid;
gap: 6px;
margin-bottom: 14px;
}
.field span {
color: #9fb0c9;
font-size: 13px;
}
input,
textarea {
width: 100%;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 10px;
background: #121c38;
color: #f1f5ff;
padding: 10px 12px;
font-size: 14px;
}
textarea {
resize: vertical;
}
.actions {
display: flex;
gap: 10px;
margin-top: 8px;
}
button {
border: 1px solid rgba(34, 211, 238, 0.4);
background: rgba(34, 211, 238, 0.12);
color: #ccf7ff;
border-radius: 9px;
padding: 10px 14px;
cursor: pointer;
font-weight: 700;
}
button.ghost {
border-color: rgba(99, 102, 241, 0.4);
background: rgba(99, 102, 241, 0.14);
color: #dbe4ff;
}
.result {
margin-top: 14px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.03);
border-radius: 10px;
padding: 12px;
white-space: pre-wrap;
word-break: break-word;
min-height: 56px;
color: #9fb0c9;
}
+42
View File
@@ -0,0 +1,42 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>PolyWeather Extension Settings</title>
<link rel="stylesheet" href="./options.css" />
</head>
<body>
<main class="wrap">
<h1 id="settingsTitle">PolyWeather 侧边栏设置</h1>
<label class="field">
<span id="siteBaseLabel">Site Base URL</span>
<input id="siteBaseInput" type="text" placeholder="https://polyweather-pro.vercel.app" />
</label>
<label class="field">
<span id="apiBaseLabel">API Base URL</span>
<input id="apiBaseInput" type="text" placeholder="https://polyweather-pro.vercel.app" />
</label>
<label class="field">
<span id="tokenLabel">Bearer Token(可选)</span>
<textarea
id="tokenInput"
rows="3"
placeholder="公开模式留空即可;仅当后端返回 401 时再填写。"
></textarea>
</label>
<div class="actions">
<button id="saveBtn">保存</button>
<button id="testBtn" class="ghost">测试 /api/cities</button>
</div>
<pre id="resultBox" class="result"></pre>
</main>
<script src="./options.js"></script>
</body>
</html>
+145
View File
@@ -0,0 +1,145 @@
const DEFAULT_CONFIG = {
apiBase: "https://polyweather-pro.vercel.app",
siteBase: "https://polyweather-pro.vercel.app",
authToken: "",
selectedCity: ""
};
const locale = String(navigator.language || "en").toLowerCase().startsWith("zh")
? "zh"
: "en";
const I18N = {
zh: {
settingsTitle: "PolyWeather 侧边栏设置",
tokenLabel: "Bearer Token(可选)",
tokenPlaceholder: "公开模式留空即可;仅当后端返回 401 时再填写。",
save: "保存",
test: "测试 /api/cities",
saved: "已保存。公开模式下 Token 可留空。",
connectOk: "连接成功,返回城市数",
tokenOptional: "Token 可留空",
testFailed: "测试失败",
backendAuthHint: "说明后端仍要求鉴权;若你要公开插件,请先放开 /api/cities 与 /api/city/*/detail。"
},
en: {
settingsTitle: "PolyWeather Side Panel Settings",
tokenLabel: "Bearer Token (Optional)",
tokenPlaceholder: "Leave empty in public mode; only fill it if the backend returns 401.",
save: "Save",
test: "Test /api/cities",
saved: "Saved. Token can be empty in public mode.",
connectOk: "Connected successfully, city count",
tokenOptional: "Token can be empty",
testFailed: "Test failed",
backendAuthHint: "The backend still requires auth. If the extension should be public, allow /api/cities and /api/city/*/detail."
}
};
function t(key) {
return I18N[locale][key] || I18N.zh[key] || key;
}
const apiBaseInput = document.getElementById("apiBaseInput");
const siteBaseInput = document.getElementById("siteBaseInput");
const tokenInput = document.getElementById("tokenInput");
const resultBox = document.getElementById("resultBox");
const settingsTitle = document.getElementById("settingsTitle");
const siteBaseLabel = document.getElementById("siteBaseLabel");
const apiBaseLabel = document.getElementById("apiBaseLabel");
const tokenLabel = document.getElementById("tokenLabel");
const saveBtn = document.getElementById("saveBtn");
const testBtn = document.getElementById("testBtn");
function normalizeBase(url) {
return String(url || "").trim().replace(/\/+$/, "");
}
function writeResult(text) {
resultBox.textContent = text;
}
function getStorage() {
return new Promise((resolve) => {
chrome.storage.sync.get(DEFAULT_CONFIG, (items) => resolve(items));
});
}
function setStorage(values) {
return new Promise((resolve) => {
chrome.storage.sync.set(values, resolve);
});
}
async function loadForm() {
const cfg = await getStorage();
apiBaseInput.value = cfg.apiBase || DEFAULT_CONFIG.apiBase;
siteBaseInput.value = cfg.siteBase || cfg.apiBase || DEFAULT_CONFIG.siteBase;
tokenInput.value = cfg.authToken || "";
}
async function saveForm() {
const next = {
apiBase: normalizeBase(apiBaseInput.value),
siteBase: normalizeBase(siteBaseInput.value || apiBaseInput.value),
authToken: String(tokenInput.value || "").trim()
};
await setStorage(next);
writeResult(t("saved"));
}
async function testApi() {
const apiBase = normalizeBase(apiBaseInput.value);
const authToken = String(tokenInput.value || "").trim();
try {
const headers = { Accept: "application/json" };
if (authToken) headers.Authorization = `Bearer ${authToken}`;
const res = await fetch(`${apiBase}/api/cities`, {
headers,
cache: "no-store"
});
const text = await res.text();
let data = null;
try {
data = text ? JSON.parse(text) : null;
} catch (_e) {
data = text;
}
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${typeof data === "string" ? data : JSON.stringify(data)}`);
}
const count = Array.isArray(data)
? data.length
: Array.isArray(data?.cities)
? data.cities.length
: 0;
writeResult(`${t("connectOk")}: ${count} (${t("tokenOptional")})`);
} catch (err) {
const msg = String(err?.message || err || "");
if (msg.includes("HTTP 401")) {
writeResult(`${t("testFailed")}: ${msg}\n${t("backendAuthHint")}`);
return;
}
writeResult(`${t("testFailed")}: ${msg}`);
}
}
function applyStaticTranslations() {
document.documentElement.lang = locale === "zh" ? "zh-CN" : "en";
if (settingsTitle) settingsTitle.textContent = t("settingsTitle");
if (tokenLabel) tokenLabel.textContent = t("tokenLabel");
if (tokenInput) tokenInput.placeholder = t("tokenPlaceholder");
if (saveBtn) saveBtn.textContent = t("save");
if (testBtn) testBtn.textContent = t("test");
if (siteBaseLabel) siteBaseLabel.textContent = "Site Base URL";
if (apiBaseLabel) apiBaseLabel.textContent = "API Base URL";
}
document.getElementById("saveBtn").addEventListener("click", () => {
void saveForm();
});
document.getElementById("testBtn").addEventListener("click", () => {
void testApi();
});
applyStaticTranslations();
void loadForm();
+308
View File
@@ -0,0 +1,308 @@
:root {
--bg: #070d1f;
--panel: #0d152b;
--card: rgba(255, 255, 255, 0.03);
--border: rgba(255, 255, 255, 0.08);
--text: #e5eefb;
--muted: #8ba0be;
--cyan: #22d3ee;
--blue: #3b82f6;
--green: #34d399;
--amber: #f59e0b;
--red: #f87171;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: radial-gradient(circle at top, #0c1735, var(--bg) 45%);
color: var(--text);
font-family: "Inter", "Segoe UI", -apple-system, BlinkMacSystemFont, sans-serif;
}
.panel {
position: relative;
min-height: 100vh;
padding: 14px;
}
.loading-overlay {
position: absolute;
inset: 0;
z-index: 20;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
border-radius: 14px;
background: rgba(7, 13, 31, 0.7);
backdrop-filter: blur(2px);
}
.loading-spinner {
width: 28px;
height: 28px;
border-radius: 999px;
border: 3px solid rgba(34, 211, 238, 0.28);
border-top-color: #22d3ee;
animation: panel-loading-spin 0.75s linear infinite;
}
.loading-text {
color: #b7dcff;
font-size: 12px;
font-weight: 600;
}
.topbar {
display: grid;
grid-template-columns: auto 1fr auto;
gap: 8px;
align-items: center;
margin-bottom: 12px;
}
.risk-badge {
padding: 5px 9px;
border-radius: 10px;
font-size: 12px;
font-weight: 700;
border: 1px solid transparent;
}
.risk-badge.low {
color: #86efac;
border-color: rgba(52, 211, 153, 0.5);
background: rgba(52, 211, 153, 0.14);
}
.risk-badge.medium {
color: #fcd34d;
border-color: rgba(245, 158, 11, 0.45);
background: rgba(245, 158, 11, 0.14);
}
.risk-badge.high {
color: #fca5a5;
border-color: rgba(248, 113, 113, 0.5);
background: rgba(248, 113, 113, 0.12);
}
.city-picker-wrap {
display: grid;
gap: 4px;
}
.city-picker-wrap label {
font-size: 11px;
color: var(--muted);
}
#citySelect {
width: 100%;
height: 34px;
border-radius: 9px;
border: 1px solid var(--border);
background: #0f1b35;
color: var(--text);
padding: 0 10px;
}
.refresh-btn {
width: 34px;
height: 34px;
border-radius: 9px;
border: 1px solid var(--border);
background: #0f1b35;
color: #9cecff;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
padding: 0;
}
.refresh-btn:hover {
filter: brightness(1.08);
}
.refresh-btn:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.refresh-btn svg {
width: 16px;
height: 16px;
}
.refresh-btn.spinning svg {
animation: refresh-spin 0.8s linear infinite;
}
@keyframes refresh-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@keyframes panel-loading-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.btn {
height: 34px;
border-radius: 10px;
border: 1px solid rgba(34, 211, 238, 0.5);
color: #9cecff;
background: rgba(34, 211, 238, 0.07);
font-weight: 700;
cursor: pointer;
}
.btn:hover {
filter: brightness(1.08);
}
.section {
border: 1px solid var(--border);
border-radius: 14px;
background: var(--card);
padding: 12px;
margin-bottom: 12px;
}
.section h3 {
margin: 0 0 10px 0;
font-size: 15px;
}
.grid2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.mini-card {
border: 1px solid var(--border);
border-radius: 12px;
padding: 10px;
background: rgba(255, 255, 255, 0.02);
}
.mini-label {
display: block;
color: var(--muted);
font-size: 11px;
margin-bottom: 6px;
}
.mini-card strong {
font-size: 16px;
line-height: 1.25;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.chart-wrap {
position: relative;
border: 1px solid var(--border);
border-radius: 12px;
background: rgba(255, 255, 255, 0.01);
padding: 8px;
}
#trendCanvas {
width: 100%;
height: auto;
display: block;
}
.legend-text {
margin-top: 8px;
color: var(--muted);
font-size: 12px;
}
.forecast-row {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.forecast-card {
border: 1px solid var(--border);
border-radius: 12px;
background: rgba(255, 255, 255, 0.02);
padding: 8px;
min-height: 68px;
}
.forecast-card.today {
border-color: rgba(34, 211, 238, 0.62);
background: rgba(34, 211, 238, 0.08);
}
.f-date {
color: var(--muted);
font-size: 11px;
}
.f-temp {
margin-top: 7px;
font-size: 16px;
font-weight: 800;
line-height: 1.2;
font-variant-numeric: tabular-nums;
}
.btn-open-full {
width: 100%;
height: 40px;
border-color: rgba(59, 130, 246, 0.5);
color: #d9ebff;
background: rgba(59, 130, 246, 0.15);
}
.error {
border: 1px solid rgba(248, 113, 113, 0.45);
border-radius: 12px;
background: rgba(248, 113, 113, 0.12);
color: #fecaca;
padding: 10px;
font-size: 12px;
line-height: 1.4;
}
.hidden {
display: none;
}
.chart-tooltip {
position: absolute;
z-index: 3;
pointer-events: none;
max-width: 180px;
padding: 6px 8px;
border-radius: 8px;
border: 1px solid rgba(34, 211, 238, 0.45);
background: rgba(9, 17, 36, 0.92);
color: #dff7ff;
font-size: 11px;
font-weight: 600;
line-height: 1.3;
white-space: nowrap;
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.35);
}
+74
View File
@@ -0,0 +1,74 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>PolyWeather Panel</title>
<link rel="stylesheet" href="./sidepanel.css" />
</head>
<body>
<main class="panel">
<div id="loadingOverlay" class="loading-overlay hidden" aria-live="polite">
<div class="loading-spinner" aria-hidden="true"></div>
<div id="loadingText" class="loading-text">正在加载温度数据...</div>
</div>
<header class="topbar">
<div id="riskBadge" class="risk-badge medium">中风险</div>
<div class="city-picker-wrap">
<label id="cityLabel" for="citySelect">城市</label>
<select id="citySelect"></select>
</div>
<button id="refreshBtn" class="refresh-btn" title="刷新数据" aria-label="刷新数据">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
<path d="M3 3v5h5" />
</svg>
</button>
</header>
<section class="section">
<h3 id="profileTitle">城市档案</h3>
<div class="grid2">
<article class="mini-card">
<span class="mini-label" id="settlementLabel">结算站点</span>
<strong id="settlementValue">--</strong>
</article>
<article class="mini-card">
<span id="distanceLabel" class="mini-label">站点距离</span>
<strong id="distanceValue">--</strong>
</article>
<article class="mini-card">
<span id="obsTimeLabel" class="mini-label">观测更新</span>
<strong id="obsTimeValue">--</strong>
</article>
<article class="mini-card">
<span id="nearbyLabel" class="mini-label">周边站点</span>
<strong id="nearbyValue">--</strong>
</article>
</div>
</section>
<section class="section">
<h3 id="trendTitle">今日日内走势(简版)</h3>
<div class="chart-wrap">
<canvas id="trendCanvas" width="560" height="220"></canvas>
<div id="chartTooltip" class="chart-tooltip hidden"></div>
</div>
<div id="chartLegend" class="legend-text">--</div>
</section>
<section class="section">
<h3 id="forecastTitle">多日预报</h3>
<div id="forecastRow" class="forecast-row"></div>
</section>
<section class="section">
<button id="openFullBtn" class="btn btn-open-full">打开完整网站分析</button>
</section>
<section id="errorBox" class="error hidden"></section>
</main>
<script src="./sidepanel.js"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+11
View File
@@ -1 +1,12 @@
POLYWEATHER_API_BASE_URL=http://127.0.0.1:8000
# Supabase Auth (Google + Email)
NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_ANON_KEY=
POLYWEATHER_AUTH_ENABLED=false
# If true: middleware forces login; if false: optional login mode (guests allowed).
POLYWEATHER_AUTH_REQUIRED=false
# Optional dashboard guard (Next.js middleware)
# If set, open dashboard with: /?access_token=<token>
POLYWEATHER_DASHBOARD_ACCESS_TOKEN=
# Shared secret forwarded by Next API routes to backend
POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN=
+62 -37
View File
@@ -1,29 +1,25 @@
# PolyWeather Frontend
# PolyWeather 前端
This directory is the only web frontend in production.
PolyWeather Pro 的生产前端工程。
Production URL:
- https://polyweather-pro.vercel.app/
线上地址:
- [https://polyweather-pro.vercel.app/](https://polyweather-pro.vercel.app/)
## Stack
## 技术栈
- Next.js App Router
- Tailwind CSS
- Lucide React
- shadcn/ui base layer
- Legacy dashboard shell loaded from `public/legacy/index.html`
- React + Tailwind
- Leaflet + Chart.js
- Supabase Auth
- WalletConnect + 浏览器 EVM 钱包
## Production Model
## 运行模型
- Vercel serves the web UI
- FastAPI on VPS serves API only
- The old FastAPI static website has been removed
1. 浏览器 -> Next 应用(`frontend`
2. Next Route Handlers`/api/*`-> FastAPI 后端
3. FastAPI -> 分析服务 / 支付服务
Current request flow:
- Browser -> Vercel frontend
- Vercel route handlers -> FastAPI API
## Local Development
## 本地开发
```bash
cd frontend
@@ -32,37 +28,66 @@ npm install
npm run dev
```
Default local URL:
- http://localhost:3000
## Required Environment Variable
## 必需环境变量
```env
POLYWEATHER_API_BASE_URL=https://<your-fastapi-host>
NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_ANON_KEY=
POLYWEATHER_AUTH_ENABLED=true
POLYWEATHER_AUTH_REQUIRED=false
POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN=
```
Examples:
- `http://38.54.27.70:8000`
- `https://api.example.com`
WalletConnect
## Route Handlers
```env
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=
NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL=https://polygon-bor-rpc.publicnode.com
```
浮层链接:
```env
NEXT_PUBLIC_TELEGRAM_GROUP_URL=https://t.me/<your_group>
```
## 路由处理器
天气:
Thin BFF routes currently exposed by Next:
- `GET /api/cities`
- `GET /api/city/[name]`
- `GET /api/city/[name]/summary`
- `GET /api/city/[name]/detail`
- `GET /api/history/[name]`
## Vercel Deployment
鉴权:
1. Import the repo into Vercel
2. Set Root Directory to `frontend`
3. Set `POLYWEATHER_API_BASE_URL`
4. Deploy
- `GET /api/auth/me`
## Notes
支付:
- Backend CORS must allow `https://polyweather-pro.vercel.app`
- The page shell currently embeds the legacy dashboard HTML from `public/legacy/index.html`
- If you change files under `public/static`, deploy to Vercel to make them live
- `GET /api/payments/config`
- `GET /api/payments/wallets`
- `POST /api/payments/wallets/challenge`
- `POST /api/payments/wallets/verify`
- `POST /api/payments/intents`
- `GET /api/payments/intents/[intentId]`
- `POST /api/payments/intents/[intentId]/submit`
- `POST /api/payments/intents/[intentId]/confirm`
Last updated: 2026-03-06
## 缓存行为
- `cities` / `summary` / `history``ETag + Cache-Control`
- `summary?force_refresh=true``no-store`
- 支付相关路由:`no-store`
## 开源边界说明
此前端仓库包含通用产品界面和标准支付体验。
商业策略调优、私有运营流程和敏感生产参数不在公开文档范围内。
详见根目录策略文档:`docs/OPEN_CORE_POLICY.md`
最后更新:`2026-03-14`
+16
View File
@@ -0,0 +1,16 @@
import type { Metadata } from "next";
import { I18nProvider } from "@/hooks/useI18n";
import { AccountEntry } from "@/components/account/AccountEntry";
export const metadata: Metadata = {
title: "PolyWeather | Account Center",
description: "PolyWeather account center for identity and entitlement status.",
};
export default function AccountPage() {
return (
<I18nProvider>
<AccountEntry />
</I18nProvider>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 542 KiB

+40
View File
@@ -0,0 +1,40 @@
import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
export async function GET(req: NextRequest) {
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
}
try {
const auth = await buildBackendRequestHeaders(req);
const res = await fetch(`${API_BASE}/api/auth/me`, {
headers: auth.headers,
cache: "no-store",
});
if (!res.ok) {
const raw = await res.text();
const response = NextResponse.json(
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 300) },
{ status: res.status },
);
return applyAuthResponseCookies(response, auth.response);
}
const data = await res.json();
const response = NextResponse.json(data);
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return NextResponse.json(
{ error: "Failed to fetch auth profile", detail: String(error) },
{ status: 500 },
);
}
}
+23 -8
View File
@@ -1,33 +1,48 @@
import { NextResponse } from "next/server";
import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import { buildCachedJsonResponse } from "@/lib/http-cache";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
export const dynamic = "force-dynamic";
export async function GET() {
export async function GET(req: NextRequest) {
if (!API_BASE) {
return NextResponse.json(
const response = NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
return response;
}
try {
const auth = await buildBackendRequestHeaders(req);
const res = await fetch(`${API_BASE}/api/cities`, {
headers: { Accept: "application/json" },
next: { revalidate: 120 },
headers: auth.headers,
cache: "no-store",
});
if (!res.ok) {
const raw = await res.text();
return NextResponse.json(
const response = NextResponse.json(
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 300) },
{ status: 502 },
);
return applyAuthResponseCookies(response, auth.response);
}
const data = await res.json();
return NextResponse.json(data);
const response = buildCachedJsonResponse(
req,
data,
"public, max-age=0, s-maxage=300, stale-while-revalidate=1800",
);
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return NextResponse.json(
const response = NextResponse.json(
{ error: "Failed to fetch cities", detail: String(error) },
{ status: 500 },
);
return response;
}
}
@@ -0,0 +1,60 @@
import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
export async function GET(
req: NextRequest,
context: { params: Promise<{ name: string }> },
) {
if (!API_BASE) {
const response = NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
return response;
}
const { name } = await context.params;
const forceRefresh = req.nextUrl.searchParams.get("force_refresh") ?? "false";
const marketSlug = req.nextUrl.searchParams.get("market_slug");
const targetDate = req.nextUrl.searchParams.get("target_date");
const searchParams = new URLSearchParams({
force_refresh: forceRefresh,
});
if (marketSlug) {
searchParams.set("market_slug", marketSlug);
}
if (targetDate) {
searchParams.set("target_date", targetDate);
}
const url = `${API_BASE}/api/city/${encodeURIComponent(name)}/detail?${searchParams.toString()}`;
try {
const auth = await buildBackendRequestHeaders(req);
const res = await fetch(url, {
headers: auth.headers,
cache: "no-store",
});
if (!res.ok) {
const raw = await res.text();
const response = NextResponse.json(
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 300) },
{ status: 502 },
);
return applyAuthResponseCookies(response, auth.response);
}
const data = await res.json();
const response = NextResponse.json(data);
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
const response = NextResponse.json(
{ error: "Failed to fetch city detail aggregate", detail: String(error) },
{ status: 500 },
);
return response;
}
}
+14 -5
View File
@@ -1,4 +1,8 @@
import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -7,10 +11,11 @@ export async function GET(
context: { params: Promise<{ name: string }> },
) {
if (!API_BASE) {
return NextResponse.json(
const response = NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
return response;
}
const { name } = await context.params;
@@ -18,23 +23,27 @@ export async function GET(
const url = `${API_BASE}/api/city/${encodeURIComponent(name)}?force_refresh=${forceRefresh}`;
try {
const auth = await buildBackendRequestHeaders(req);
const res = await fetch(url, {
headers: { Accept: "application/json" },
headers: auth.headers,
cache: "no-store",
});
if (!res.ok) {
const raw = await res.text();
return NextResponse.json(
const response = NextResponse.json(
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 300) },
{ status: 502 },
);
return applyAuthResponseCookies(response, auth.response);
}
const data = await res.json();
return NextResponse.json(data);
const response = NextResponse.json(data);
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return NextResponse.json(
const response = NextResponse.json(
{ error: "Failed to fetch city detail", detail: String(error) },
{ status: 500 },
);
return response;
}
}
@@ -0,0 +1,63 @@
import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import { buildCachedJsonResponse } from "@/lib/http-cache";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
export async function GET(
req: NextRequest,
context: { params: Promise<{ name: string }> },
) {
if (!API_BASE) {
const response = NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
return response;
}
const { name } = await context.params;
const forceRefresh = req.nextUrl.searchParams.get("force_refresh") ?? "false";
const bypassCache = forceRefresh === "true";
const url = `${API_BASE}/api/city/${encodeURIComponent(name)}/summary?force_refresh=${forceRefresh}`;
try {
const auth = await buildBackendRequestHeaders(req);
const res = await fetch(url, {
headers: auth.headers,
cache: "no-store",
});
if (!res.ok) {
const raw = await res.text();
const response = NextResponse.json(
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 300) },
{ status: 502 },
);
return applyAuthResponseCookies(response, auth.response);
}
const data = await res.json();
if (bypassCache) {
const response = NextResponse.json(data, {
headers: {
"Cache-Control": "no-store",
},
});
return applyAuthResponseCookies(response, auth.response);
}
const response = buildCachedJsonResponse(
req,
data,
"public, max-age=0, s-maxage=20, stale-while-revalidate=60",
);
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
const response = NextResponse.json(
{ error: "Failed to fetch city summary", detail: String(error) },
{ status: 500 },
);
return response;
}
}
+21 -7
View File
@@ -1,39 +1,53 @@
import { NextResponse } from "next/server";
import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import { buildCachedJsonResponse } from "@/lib/http-cache";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
export async function GET(
_req: Request,
req: NextRequest,
context: { params: Promise<{ name: string }> },
) {
if (!API_BASE) {
return NextResponse.json(
const response = NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
return response;
}
const { name } = await context.params;
const url = `${API_BASE}/api/history/${encodeURIComponent(name)}`;
try {
const auth = await buildBackendRequestHeaders(req);
const res = await fetch(url, {
headers: { Accept: "application/json" },
headers: auth.headers,
cache: "no-store",
});
if (!res.ok) {
const raw = await res.text();
return NextResponse.json(
const response = NextResponse.json(
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 300) },
{ status: 502 },
);
return applyAuthResponseCookies(response, auth.response);
}
const data = await res.json();
return NextResponse.json(data);
const response = buildCachedJsonResponse(
req,
data,
"public, max-age=0, s-maxage=60, stale-while-revalidate=300",
);
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return NextResponse.json(
const response = NextResponse.json(
{ error: "Failed to fetch history", detail: String(error) },
{ status: 500 },
);
return response;
}
}
+42
View File
@@ -0,0 +1,42 @@
import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
export async function GET(req: NextRequest) {
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
}
try {
const auth = await buildBackendRequestHeaders(req);
const res = await fetch(`${API_BASE}/api/payments/config`, {
headers: auth.headers,
cache: "no-store",
});
if (!res.ok) {
const raw = await res.text();
const response = NextResponse.json(
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 350) },
{ status: res.status },
);
return applyAuthResponseCookies(response, auth.response);
}
const data = await res.json();
const response = NextResponse.json(data, {
headers: { "Cache-Control": "no-store" },
});
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return NextResponse.json(
{ error: "Failed to fetch payment config", detail: String(error) },
{ status: 500 },
);
}
}
@@ -0,0 +1,51 @@
import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
export async function POST(
req: NextRequest,
context: { params: Promise<{ intentId: string }> },
) {
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
}
const { intentId } = await context.params;
try {
const body = await req.json();
const auth = await buildBackendRequestHeaders(req);
const proxiedHeaders = new Headers(auth.headers);
proxiedHeaders.set("Content-Type", "application/json");
const res = await fetch(
`${API_BASE}/api/payments/intents/${encodeURIComponent(intentId)}/confirm`,
{
method: "POST",
headers: proxiedHeaders,
body: JSON.stringify(body ?? {}),
cache: "no-store",
},
);
if (!res.ok) {
const raw = await res.text();
const response = NextResponse.json(
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 350) },
{ status: res.status },
);
return applyAuthResponseCookies(response, auth.response);
}
const data = await res.json();
const response = NextResponse.json(data);
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return NextResponse.json(
{ error: "Failed to confirm payment tx", detail: String(error) },
{ status: 500 },
);
}
}
@@ -0,0 +1,47 @@
import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
export async function GET(
req: NextRequest,
context: { params: Promise<{ intentId: string }> },
) {
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
}
const { intentId } = await context.params;
try {
const auth = await buildBackendRequestHeaders(req);
const res = await fetch(
`${API_BASE}/api/payments/intents/${encodeURIComponent(intentId)}`,
{
method: "GET",
headers: auth.headers,
cache: "no-store",
},
);
if (!res.ok) {
const raw = await res.text();
const response = NextResponse.json(
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 350) },
{ status: res.status },
);
return applyAuthResponseCookies(response, auth.response);
}
const data = await res.json();
const response = NextResponse.json(data);
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return NextResponse.json(
{ error: "Failed to fetch payment intent", detail: String(error) },
{ status: 500 },
);
}
}
@@ -0,0 +1,51 @@
import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
export async function POST(
req: NextRequest,
context: { params: Promise<{ intentId: string }> },
) {
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
}
const { intentId } = await context.params;
try {
const body = await req.json();
const auth = await buildBackendRequestHeaders(req);
const proxiedHeaders = new Headers(auth.headers);
proxiedHeaders.set("Content-Type", "application/json");
const res = await fetch(
`${API_BASE}/api/payments/intents/${encodeURIComponent(intentId)}/submit`,
{
method: "POST",
headers: proxiedHeaders,
body: JSON.stringify(body ?? {}),
cache: "no-store",
},
);
if (!res.ok) {
const raw = await res.text();
const response = NextResponse.json(
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 350) },
{ status: res.status },
);
return applyAuthResponseCookies(response, auth.response);
}
const data = await res.json();
const response = NextResponse.json(data);
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return NextResponse.json(
{ error: "Failed to submit payment tx", detail: String(error) },
{ status: 500 },
);
}
}
@@ -0,0 +1,45 @@
import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
export async function POST(req: NextRequest) {
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
}
try {
const body = await req.json();
const auth = await buildBackendRequestHeaders(req);
const proxiedHeaders = new Headers(auth.headers);
proxiedHeaders.set("Content-Type", "application/json");
const res = await fetch(`${API_BASE}/api/payments/intents`, {
method: "POST",
headers: proxiedHeaders,
body: JSON.stringify(body ?? {}),
cache: "no-store",
});
if (!res.ok) {
const raw = await res.text();
const response = NextResponse.json(
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 350) },
{ status: res.status },
);
return applyAuthResponseCookies(response, auth.response);
}
const data = await res.json();
const response = NextResponse.json(data);
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return NextResponse.json(
{ error: "Failed to create payment intent", detail: String(error) },
{ status: 500 },
);
}
}
@@ -0,0 +1,55 @@
import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
export async function POST(req: NextRequest) {
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
}
try {
const body = await req.json();
const auth = await buildBackendRequestHeaders(req);
const proxiedHeaders = new Headers(auth.headers);
proxiedHeaders.set("Content-Type", "application/json");
const res = await fetch(`${API_BASE}/api/payments/wallets/challenge`, {
method: "POST",
headers: proxiedHeaders,
body: JSON.stringify(body ?? {}),
cache: "no-store",
});
if (!res.ok) {
const raw = await res.text();
const response = NextResponse.json(
{
error: `Backend returned ${res.status}`,
detail: raw.slice(0, 350),
proxy_debug: {
has_authorization: proxiedHeaders.has("authorization"),
has_entitlement: proxiedHeaders.has("x-polyweather-entitlement"),
has_forwarded_user_id: proxiedHeaders.has(
"x-polyweather-auth-user-id",
),
has_forwarded_email: proxiedHeaders.has("x-polyweather-auth-email"),
},
},
{ status: res.status },
);
return applyAuthResponseCookies(response, auth.response);
}
const data = await res.json();
const response = NextResponse.json(data);
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return NextResponse.json(
{ error: "Failed to create wallet challenge", detail: String(error) },
{ status: 500 },
);
}
}
+107
View File
@@ -0,0 +1,107 @@
import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
export async function GET(req: NextRequest) {
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
}
try {
const auth = await buildBackendRequestHeaders(req);
const res = await fetch(`${API_BASE}/api/payments/wallets`, {
headers: auth.headers,
cache: "no-store",
});
if (!res.ok) {
const raw = await res.text();
const response = NextResponse.json(
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 350) },
{ status: res.status },
);
return applyAuthResponseCookies(response, auth.response);
}
const data = await res.json();
const response = NextResponse.json(data, {
headers: { "Cache-Control": "no-store" },
});
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return NextResponse.json(
{ error: "Failed to fetch wallets", detail: String(error) },
{ status: 500 },
);
}
}
export async function DELETE(req: NextRequest) {
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
}
let payload: Record<string, unknown> = {};
try {
payload = (await req.json()) as Record<string, unknown>;
} catch {
payload = {};
}
try {
const auth = await buildBackendRequestHeaders(req);
const proxiedHeaders = new Headers(auth.headers);
proxiedHeaders.set("Content-Type", "application/json");
const res = await fetch(`${API_BASE}/api/payments/wallets`, {
method: "DELETE",
headers: proxiedHeaders,
body: JSON.stringify(payload),
cache: "no-store",
});
const raw = await res.text();
if (!res.ok) {
const response = NextResponse.json(
{
error: `Backend returned ${res.status}`,
detail: raw.slice(0, 350),
proxy_debug: {
incoming_has_authorization: Boolean(
String(req.headers.get("authorization") || "").trim(),
),
has_authorization: proxiedHeaders.has("authorization"),
has_entitlement: proxiedHeaders.has("x-polyweather-entitlement"),
has_forwarded_user_id: proxiedHeaders.has(
"x-polyweather-auth-user-id",
),
has_forwarded_email: proxiedHeaders.has("x-polyweather-auth-email"),
},
},
{ status: res.status },
);
return applyAuthResponseCookies(response, auth.response);
}
let data: unknown = { ok: true };
if (raw) {
try {
data = JSON.parse(raw);
} catch {
data = { ok: true, raw };
}
}
const response = NextResponse.json(data, {
headers: { "Cache-Control": "no-store" },
});
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return NextResponse.json(
{ error: "Failed to unbind wallet", detail: String(error) },
{ status: 500 },
);
}
}
@@ -0,0 +1,56 @@
import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
export async function POST(req: NextRequest) {
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
}
try {
const body = await req.json();
const auth = await buildBackendRequestHeaders(req);
const proxiedHeaders = new Headers(auth.headers);
proxiedHeaders.set("Content-Type", "application/json");
const res = await fetch(`${API_BASE}/api/payments/wallets/verify`, {
method: "POST",
headers: proxiedHeaders,
body: JSON.stringify(body ?? {}),
cache: "no-store",
});
if (!res.ok) {
const raw = await res.text();
const response = NextResponse.json(
{
error: `Backend returned ${res.status}`,
detail: raw.slice(0, 350),
proxy_debug: {
has_authorization: proxiedHeaders.has("authorization"),
has_entitlement: proxiedHeaders.has("x-polyweather-entitlement"),
has_forwarded_user_id: proxiedHeaders.has(
"x-polyweather-auth-user-id",
),
has_forwarded_email: proxiedHeaders.has("x-polyweather-auth-email"),
},
},
{ status: res.status },
);
return applyAuthResponseCookies(response, auth.response);
}
const data = await res.json();
const response = NextResponse.json(data);
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return NextResponse.json(
{ error: "Failed to verify wallet binding", detail: String(error) },
{ status: 500 },
);
}
}
+73
View File
@@ -0,0 +1,73 @@
import { NextResponse } from "next/server";
import {
getVitalsSummary,
normalizeMetricName,
recordVitalsSample,
} from "@/lib/vitals-store";
type VitalsPayload = {
id?: string;
metric?: string;
navigationType?: string;
pathname?: string;
rating?: string;
value?: number;
};
export async function POST(request: Request) {
try {
const payload = (await request.json()) as VitalsPayload;
const metric = normalizeMetricName(payload.metric);
if (!metric) {
return NextResponse.json({ ok: false, error: "metric is required" }, { status: 400 });
}
const pathname = String(payload.pathname || "/");
const rating = String(payload.rating || "unknown");
const value = Number(payload.value);
const navigationType = String(payload.navigationType || "unknown");
const id = String(payload.id || "");
const ts = Date.now();
if (!Number.isFinite(value)) {
return NextResponse.json({ ok: false, error: "value must be finite" }, { status: 400 });
}
recordVitalsSample({
id,
metric,
navigationType,
pathname,
rating,
timestamp: ts,
value,
});
// Keep this lightweight: log for now, can be wired to a persistent sink later.
console.info(
`[vitals] metric=${metric} path=${pathname} value=${Number.isFinite(value) ? value : "NaN"} rating=${rating} nav=${navigationType} id=${id}`,
);
return NextResponse.json({ ok: true });
} catch (error) {
console.warn("[vitals] failed to parse payload", error);
return NextResponse.json({ ok: false }, { status: 400 });
}
}
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const targetRoute = String(searchParams.get("route") || "").trim();
const summary = getVitalsSummary();
if (!targetRoute) {
return NextResponse.json({ ok: true, ...summary });
}
return NextResponse.json({
ok: true,
generatedAt: summary.generatedAt,
route: targetRoute,
metrics: summary.routes[targetRoute] || {},
});
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

+32
View File
@@ -0,0 +1,32 @@
import { NextRequest, NextResponse } from "next/server";
import { createSupabaseRouteClient, hasSupabaseServerEnv } from "@/lib/supabase/server";
function normalizeNextPath(input: string | null) {
const fallback = "/";
const raw = String(input || "").trim();
if (!raw) return fallback;
if (!raw.startsWith("/")) return fallback;
if (raw.startsWith("//")) return fallback;
return raw;
}
export async function GET(request: NextRequest) {
const nextPath = normalizeNextPath(request.nextUrl.searchParams.get("next"));
const redirectUrl = request.nextUrl.clone();
redirectUrl.pathname = nextPath;
redirectUrl.search = "";
if (!hasSupabaseServerEnv()) {
return NextResponse.redirect(redirectUrl);
}
const response = NextResponse.redirect(redirectUrl);
const supabase = createSupabaseRouteClient(request, response);
const code = request.nextUrl.searchParams.get("code");
if (code) {
await supabase.auth.exchangeCodeForSession(code);
}
return response;
}
+25
View File
@@ -0,0 +1,25 @@
import { LoginClient } from "@/components/auth/LoginClient";
import { I18nProvider } from "@/hooks/useI18n";
type PageProps = {
searchParams?: Promise<{ next?: string }>;
};
function normalizeNextPath(input: string | undefined) {
const fallback = "/";
const raw = String(input || "").trim();
if (!raw) return fallback;
if (!raw.startsWith("/")) return fallback;
if (raw.startsWith("//")) return fallback;
return raw;
}
export default async function LoginPage({ searchParams }: PageProps) {
const params = (await searchParams) || {};
const nextPath = normalizeNextPath(params.next);
return (
<I18nProvider>
<LoginClient nextPath={nextPath} />
</I18nProvider>
);
}
@@ -0,0 +1,54 @@
type Props = {
searchParams?: Promise<{ next?: string }>;
};
export default async function EntitlementRequiredPage({ searchParams }: Props) {
const params = (await searchParams) || {};
const nextPath = params.next || "/";
return (
<main
style={{
minHeight: "100vh",
display: "grid",
placeItems: "center",
background:
"radial-gradient(circle at 20% 20%, #13264f 0%, #071127 45%, #040812 100%)",
color: "#d6e2ff",
padding: "24px",
}}
>
<section
style={{
width: "100%",
maxWidth: 720,
border: "1px solid rgba(68, 92, 140, 0.45)",
borderRadius: 16,
padding: 24,
background: "rgba(9, 18, 36, 0.88)",
boxShadow: "0 20px 50px rgba(0, 0, 0, 0.35)",
}}
>
<h1 style={{ margin: 0, fontSize: 28, lineHeight: 1.2 }}>
Entitlement Required
</h1>
<p style={{ marginTop: 12, color: "#9fb2da", lineHeight: 1.6 }}>
This dashboard is protected. If Supabase auth is enabled, please go to{" "}
<a
href={`/auth/login?next=${encodeURIComponent(nextPath)}`}
style={{ color: "#8fc5ff" }}
>
/auth/login
</a>{" "}
to sign in first.
</p>
<p style={{ marginTop: 12, color: "#9fb2da", lineHeight: 1.6 }}>
Legacy mode still supports <code>?access_token=&lt;your-token&gt;</code>.
</p>
<p style={{ marginTop: 12, color: "#9fb2da", lineHeight: 1.6 }}>
Requested path: <code>{nextPath}</code>
</p>
</section>
</main>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 826 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+247 -100
View File
@@ -2,106 +2,253 @@
@tailwind components;
@tailwind utilities;
:root {
--background: 223 53% 4%;
--foreground: 210 40% 98%;
--card: 223 46% 8%;
--card-foreground: 210 40% 98%;
--primary: 190 95% 56%;
--primary-foreground: 222 47% 8%;
--secondary: 224 30% 14%;
--secondary-foreground: 210 40% 98%;
--accent: 217 30% 18%;
--accent-foreground: 210 40% 98%;
--border: 221 38% 22%;
}
* {
border-color: hsl(var(--border));
}
body {
font-family: "Sora", "Avenir Next", "Segoe UI", sans-serif;
background:
radial-gradient(circle at 10% -10%, rgba(34, 211, 238, 0.2), transparent 40%),
radial-gradient(circle at 90% 0%, rgba(59, 130, 246, 0.14), transparent 36%),
radial-gradient(circle at 80% 100%, rgba(8, 47, 73, 0.5), transparent 48%),
hsl(var(--background));
color: hsl(var(--foreground));
}
.leaflet-container {
width: 100%;
height: 100%;
font-family: inherit;
}
.leaflet-control-zoom a {
background: rgba(2, 6, 23, 0.82) !important;
border-color: rgba(71, 85, 105, 0.5) !important;
color: #e2e8f0 !important;
}
.leaflet-control-zoom a:hover {
background: rgba(15, 23, 42, 0.95) !important;
}
.map-pill {
min-width: 52px;
border-radius: 9999px;
border: 1px solid rgba(255, 255, 255, 0.2);
padding: 5px 10px;
color: #f8fafc;
font-size: 11px;
font-weight: 700;
line-height: 1;
letter-spacing: 0.04em;
text-transform: uppercase;
backdrop-filter: blur(8px);
box-shadow:
0 2px 14px rgba(2, 6, 23, 0.45),
inset 0 1px 0 rgba(255, 255, 255, 0.2);
}
.map-pill.high {
background: linear-gradient(135deg, #ef4444, #b91c1c);
}
.map-pill.medium {
background: linear-gradient(135deg, #f59e0b, #b45309);
}
.map-pill.low {
background: linear-gradient(135deg, #10b981, #047857);
}
.map-pill.active {
transform: translateY(-2px) scale(1.05);
box-shadow:
0 8px 24px rgba(34, 211, 238, 0.4),
inset 0 1px 0 rgba(255, 255, 255, 0.35);
}
.glass {
background: linear-gradient(
180deg,
rgba(15, 23, 42, 0.9) 0%,
rgba(2, 6, 23, 0.75) 100%
);
backdrop-filter: blur(10px);
}
.fade-up {
animation: fadeUp 450ms ease-out;
}
@keyframes fadeUp {
from {
opacity: 0;
transform: translateY(10px);
@layer base {
:root {
--background: 223 53% 4%;
--foreground: 210 40% 98%;
--card: 223 46% 8%;
--card-foreground: 210 40% 98%;
--primary: 190 95% 56%;
--primary-foreground: 222 47% 8%;
--secondary: 224 30% 14%;
--secondary-foreground: 210 40% 98%;
--accent: 217 30% 18%;
--accent-foreground: 210 40% 98%;
--border: 221 38% 22%;
}
to {
opacity: 1;
transform: translateY(0);
* {
@apply border-border;
}
body {
font-family: "Sora", "Avenir Next", "Segoe UI", sans-serif;
background:
radial-gradient(
circle at 10% -10%,
rgba(34, 211, 238, 0.2),
transparent 40%
),
radial-gradient(
circle at 90% 0%,
rgba(59, 130, 246, 0.14),
transparent 36%
),
radial-gradient(
circle at 80% 100%,
rgba(8, 47, 73, 0.5),
transparent 48%
),
hsl(var(--background));
color: hsl(var(--foreground));
}
}
@layer components {
.leaflet-container {
width: 100%;
height: 100%;
font-family: inherit;
}
.leaflet-control-zoom a {
background: rgba(2, 6, 23, 0.82) !important;
border-color: rgba(71, 85, 105, 0.5) !important;
color: #e2e8f0 !important;
}
.leaflet-control-zoom a:hover {
background: rgba(15, 23, 42, 0.95) !important;
}
.map-pill {
min-width: 52px;
border-radius: 9999px;
border: 1px solid rgba(255, 255, 255, 0.2);
padding: 5px 10px;
color: #f8fafc;
font-size: 11px;
font-weight: 700;
line-height: 1;
letter-spacing: 0.04em;
text-transform: uppercase;
backdrop-filter: blur(8px);
box-shadow:
0 2px 14px rgba(2, 6, 23, 0.45),
inset 0 1px 0 rgba(255, 255, 255, 0.2);
}
.map-pill.high {
background: linear-gradient(135deg, #ef4444, #b91c1c);
}
.map-pill.medium {
background: linear-gradient(135deg, #f59e0b, #b45309);
}
.map-pill.low {
background: linear-gradient(135deg, #10b981, #047857);
}
.map-pill.active {
transform: translateY(-2px) scale(1.05);
box-shadow:
0 8px 24px rgba(34, 211, 238, 0.4),
inset 0 1px 0 rgba(255, 255, 255, 0.35);
}
.glass {
background: linear-gradient(
180deg,
rgba(15, 23, 42, 0.9) 0%,
rgba(2, 6, 23, 0.75) 100%
);
backdrop-filter: blur(10px);
}
.fade-up {
animation: fadeUp 450ms ease-out;
}
}
@layer utilities {
@keyframes fadeUp {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
}
@layer components {
.nearby-marker {
display: flex;
align-items: center;
gap: 6px;
background: rgba(15, 23, 42, 0.9);
color: rgba(226, 232, 240, 0.92);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
padding: 4px 10px;
font-size: 11px;
font-weight: 600;
box-shadow: 0 10px 28px rgba(2, 6, 23, 0.45);
backdrop-filter: blur(10px);
white-space: nowrap;
}
.nearby-name {
color: rgba(148, 163, 184, 0.95);
}
.nearby-temp {
color: #fff;
font-weight: 800;
}
.nearby-unit {
color: rgba(148, 163, 184, 0.85);
font-size: 9px;
}
.wind-info {
display: flex;
align-items: center;
gap: 4px;
margin-left: 4px;
padding-left: 6px;
border-left: 1px solid rgba(255, 255, 255, 0.1);
color: rgba(34, 211, 238, 0.9);
font-size: 10px;
}
.wind-arrow {
display: inline-block;
transform-origin: center;
}
.city-marker {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
cursor: pointer;
}
.marker-bubble {
position: relative;
min-width: 46px;
padding: 5px 10px;
border-radius: 12px;
font-size: 13px;
font-weight: 800;
text-align: center;
color: white;
border: 1px solid transparent;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
}
.marker-bubble::after {
content: "";
position: absolute;
bottom: -6px;
left: 50%;
transform: translateX(-50%);
width: 0;
height: 0;
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-top: 6px solid transparent;
}
.marker-bubble.risk-high {
background: linear-gradient(135deg, #dc2626, #ef4444);
border-color: rgba(239, 68, 68, 0.5);
}
.marker-bubble.risk-high::after { border-top-color: #ef4444; }
.marker-bubble.risk-medium {
background: linear-gradient(135deg, #d97706, #f59e0b);
border-color: rgba(245, 158, 11, 0.5);
}
.marker-bubble.risk-medium::after { border-top-color: #f59e0b; }
.marker-bubble.risk-low {
background: linear-gradient(135deg, #059669, #10b981);
border-color: rgba(16, 185, 129, 0.5);
}
.marker-bubble.risk-low::after { border-top-color: #10b981; }
.marker-name {
margin-top: 8px;
font-size: 10px;
font-weight: 700;
color: rgba(255, 255, 255, 0.88);
text-shadow: 0 1px 4px rgba(0,0,0,0.8);
}
.city-marker.selected .marker-bubble {
animation: markerGlow 2s ease-in-out infinite;
}
.custom-scrollbar::-webkit-scrollbar {
width: 8px;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: rgba(51, 65, 85, 0.75);
border-radius: 9999px;
}
}
@keyframes markerGlow {
0%, 100% { box-shadow: 0 6px 18px rgba(0,0,0,0.4); }
50% { box-shadow: 0 10px 26px rgba(34,211,238,0.35); }
}
+22
View File
@@ -1,11 +1,23 @@
import type { Metadata } from "next";
import { Analytics } from "@vercel/analytics/react";
import { SpeedInsights } from "@vercel/speed-insights/next";
import { WebVitalsReporter } from "@/components/observability/WebVitalsReporter";
import "./globals.css";
export const metadata: Metadata = {
title: "PolyWeather | Weather Intelligence",
description:
"PolyWeather pro dashboard with global weather risk map and city analytics.",
manifest: "/site.webmanifest",
icons: {
icon: [
{ url: "/favicon.ico", type: "image/x-icon" },
{ url: "/favicon-32x32.png", sizes: "32x32", type: "image/png" },
{ url: "/favicon-16x16.png", sizes: "16x16", type: "image/png" },
],
apple: [{ url: "/apple-touch-icon.png", sizes: "180x180", type: "image/png" }],
shortcut: ["/favicon.ico"],
},
};
export default function RootLayout({
@@ -13,9 +25,19 @@ export default function RootLayout({
}: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="zh-CN" className="dark">
<head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="" />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap"
rel="stylesheet"
/>
</head>
<body className="min-h-screen font-sans antialiased">
{children}
<WebVitalsReporter />
<Analytics />
<SpeedInsights />
</body>
</html>
);
+10 -9
View File
@@ -1,11 +1,12 @@
import type { Metadata } from "next";
import { DashboardEntry } from "@/components/dashboard/DashboardEntry";
export const metadata: Metadata = {
title: "PolyWeather - Global Weather Intelligence Map",
description:
"PolyWeather dashboard with METAR, MGM, DEB fusion forecast, multi-model comparison, and history reconciliation.",
};
export default function HomePage() {
return (
<main className="h-screen w-screen overflow-hidden bg-black">
<iframe
title="PolyWeather Legacy Dashboard"
src="/legacy/index.html?v=legacy-v2"
className="h-full w-full border-0"
/>
</main>
);
return <DashboardEntry />;
}
+1
View File
@@ -0,0 +1 @@
{"background_color":"#ffffff","display":"standalone","icons":[{"sizes":"192x192","src":"/android-chrome-192x192.png","type":"image/png"},{"sizes":"512x512","src":"/android-chrome-512x512.png","type":"image/png"}],"name":"","short_name":"","theme_color":"#ffffff"}
+113
View File
@@ -0,0 +1,113 @@
import type { Metadata } from "next";
import Link from "next/link";
import {
ArrowLeft,
CheckCircle2,
Coins,
CreditCard,
MessageSquare,
ShieldCheck,
} from "lucide-react";
export const metadata: Metadata = {
title: "PolyWeather | 订阅说明",
description: "PolyWeather Pro 订阅、积分抵扣与支付方式说明。",
};
const TELEGRAM_GROUP_URL = String(
process.env.NEXT_PUBLIC_TELEGRAM_GROUP_URL ||
"https://t.me/+nMG7SjziUKYyZmM1",
).trim();
const FAQ_ITEMS = [
{
q: "Pro 包含哪些功能?",
a: "开通后可解锁:今日日内机场报文规则分析(含高温时段)、历史对账 + 未来日期分析、全平台智能气象推送。",
},
{
q: "当前订阅价格是多少?",
a: "目前仅提供月付:5 USDC / 30 天。",
},
{
q: "积分如何抵扣?",
a: "满 500 积分起兑,每 500 积分抵 1U,单次最多抵 3U。",
},
{
q: "支持哪些钱包和支付方式?",
a: "支持 EVM 浏览器钱包(MetaMask / OKX / Rabby / Bitget 等)及 WalletConnect 扫码钱包(Trust Wallet / Binance Web3 Wallet / TokenPocket 等)。",
},
];
export default function SubscriptionHelpPage() {
return (
<main className="min-h-screen bg-[#070d1d] px-4 py-10 text-slate-100">
<div className="mx-auto w-full max-w-4xl">
<Link
href="/account"
className="mb-5 inline-flex items-center gap-2 rounded-xl border border-white/10 bg-white/5 px-3 py-2 text-sm text-slate-300 transition hover:bg-white/10"
>
<ArrowLeft size={15} />
</Link>
<section className="rounded-3xl border border-blue-400/20 bg-gradient-to-b from-[#162541] to-[#0e1730] p-6 md:p-8">
<div className="mb-5 flex items-center gap-3">
<ShieldCheck className="text-cyan-300" size={22} />
<h1 className="text-2xl font-bold md:text-3xl">PolyWeather Pro </h1>
</div>
<p className="text-sm text-slate-300 md:text-base">
Pro
</p>
<div className="mt-6 grid gap-3 md:grid-cols-3">
<div className="rounded-2xl border border-white/10 bg-white/5 p-4">
<div className="mb-2 flex items-center gap-2 text-cyan-300">
<CreditCard size={16} />
<span className="text-sm font-semibold"></span>
</div>
<p className="text-xl font-bold">5 USDC / 30 </p>
</div>
<div className="rounded-2xl border border-white/10 bg-white/5 p-4">
<div className="mb-2 flex items-center gap-2 text-emerald-300">
<Coins size={16} />
<span className="text-sm font-semibold"></span>
</div>
<p className="text-xl font-bold"> 3U</p>
</div>
<div className="rounded-2xl border border-white/10 bg-white/5 p-4">
<div className="mb-2 flex items-center gap-2 text-violet-300">
<MessageSquare size={16} />
<span className="text-sm font-semibold"></span>
</div>
<Link
href={TELEGRAM_GROUP_URL}
target="_blank"
className="text-sm font-semibold text-blue-300 underline decoration-blue-500/50 underline-offset-4"
>
</Link>
</div>
</div>
</section>
<section className="mt-6 rounded-3xl border border-white/10 bg-[#0f162a]/80 p-6 md:p-8">
<h2 className="mb-4 text-lg font-bold"></h2>
<div className="space-y-4">
{FAQ_ITEMS.map((item) => (
<article
key={item.q}
className="rounded-2xl border border-white/10 bg-white/[0.03] p-4"
>
<h3 className="mb-2 flex items-center gap-2 text-sm font-semibold text-blue-300">
<CheckCircle2 size={14} />
{item.q}
</h3>
<p className="text-sm leading-6 text-slate-300">{item.a}</p>
</article>
))}
</div>
</section>
</div>
</main>
);
}
@@ -0,0 +1,418 @@
.page {
position: relative;
min-height: 100vh;
padding: 34px 20px 28px;
color: #e2ecff;
background:
radial-gradient(circle at 8% 8%, rgba(56, 189, 248, 0.24), transparent 38%),
radial-gradient(circle at 92% 10%, rgba(45, 212, 191, 0.2), transparent 34%),
radial-gradient(circle at 50% 120%, rgba(14, 165, 233, 0.26), transparent 40%),
#050b16;
overflow-x: hidden;
}
.aurora {
position: absolute;
inset: -10% -5% auto;
height: 340px;
background: linear-gradient(
95deg,
rgba(56, 189, 248, 0.2) 0%,
rgba(45, 212, 191, 0.12) 46%,
rgba(99, 102, 241, 0.08) 100%
);
filter: blur(58px);
pointer-events: none;
}
.gridNoise {
position: absolute;
inset: 0;
background-image:
linear-gradient(rgba(148, 163, 184, 0.06) 1px, transparent 1px),
linear-gradient(90deg, rgba(148, 163, 184, 0.06) 1px, transparent 1px);
background-size: 42px 42px;
mask-image: radial-gradient(circle at 50% 25%, black, transparent 72%);
pointer-events: none;
opacity: 0.22;
}
.shell {
position: relative;
width: min(1120px, 100%);
margin: 0 auto;
display: grid;
gap: 16px;
}
.topBar {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 14px;
border: 1px solid rgba(148, 163, 184, 0.2);
background: rgba(15, 23, 42, 0.76);
backdrop-filter: blur(14px);
border-radius: 16px;
padding: 16px 18px;
}
.brandBlock {
display: grid;
gap: 5px;
}
.title {
margin: 0;
font-size: clamp(24px, 2.2vw, 30px);
line-height: 1.15;
letter-spacing: -0.02em;
color: #f8fbff;
}
.subtitle {
margin: 0;
font-size: 13px;
color: #8ea3c9;
}
.actions {
display: flex;
align-items: center;
gap: 8px;
}
.ghostBtn,
.primaryBtn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
height: 36px;
border-radius: 10px;
border: 1px solid rgba(148, 163, 184, 0.35);
padding: 0 12px;
color: #dce8ff;
font-size: 12px;
font-weight: 600;
text-decoration: none;
transition: all 0.22s ease;
background: rgba(15, 23, 42, 0.5);
cursor: pointer;
}
.ghostBtn:hover,
.primaryBtn:hover {
transform: translateY(-1px);
}
.ghostBtn:hover {
border-color: rgba(45, 212, 191, 0.45);
color: #f8fcff;
background: rgba(45, 212, 191, 0.12);
}
.primaryBtn {
border-color: rgba(56, 189, 248, 0.52);
background: linear-gradient(135deg, rgba(14, 116, 144, 0.88), rgba(6, 182, 212, 0.74));
color: #f6fcff;
box-shadow: 0 12px 26px rgba(8, 47, 73, 0.35);
}
.primaryBtn:hover {
border-color: rgba(103, 232, 249, 0.72);
box-shadow: 0 16px 30px rgba(14, 116, 144, 0.42);
}
.heroCard {
display: grid;
grid-template-columns: auto 1fr auto;
gap: 16px;
align-items: center;
border: 1px solid rgba(56, 189, 248, 0.24);
background:
linear-gradient(135deg, rgba(14, 116, 144, 0.17) 0%, rgba(15, 23, 42, 0.86) 58%),
rgba(15, 23, 42, 0.8);
border-radius: 18px;
padding: 18px 20px;
}
.avatar {
width: 64px;
height: 64px;
border-radius: 18px;
display: grid;
place-items: center;
font-size: 22px;
font-weight: 800;
color: #e7fbff;
border: 1px solid rgba(103, 232, 249, 0.45);
background: linear-gradient(135deg, rgba(6, 182, 212, 0.65), rgba(59, 130, 246, 0.56));
box-shadow: 0 18px 38px rgba(2, 132, 199, 0.28);
}
.heroMain {
display: grid;
gap: 8px;
}
.heroMain h2 {
margin: 0;
font-size: clamp(20px, 1.9vw, 28px);
letter-spacing: -0.02em;
color: #f8fcff;
}
.heroMain p {
margin: 0;
color: #a7bcdd;
font-size: 13px;
}
.badges {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.badge,
.badgeWarn,
.badgeGhost {
display: inline-flex;
align-items: center;
gap: 6px;
border-radius: 999px;
padding: 5px 11px;
font-size: 12px;
font-weight: 600;
}
.badge {
border: 1px solid rgba(34, 197, 94, 0.45);
background: rgba(34, 197, 94, 0.12);
color: #86efac;
}
.badgeWarn {
border: 1px solid rgba(245, 158, 11, 0.46);
background: rgba(245, 158, 11, 0.13);
color: #fcd34d;
}
.badgeGhost {
border: 1px solid rgba(148, 163, 184, 0.34);
background: rgba(148, 163, 184, 0.12);
color: #cbd5e1;
}
.updatedText {
justify-self: end;
font-size: 12px;
color: #8ea3c9;
text-align: right;
font-variant-numeric: tabular-nums;
}
.noticeRow,
.errorRow {
display: inline-flex;
align-items: center;
gap: 8px;
border-radius: 12px;
padding: 11px 14px;
font-size: 13px;
}
.noticeRow {
border: 1px solid rgba(56, 189, 248, 0.3);
background: rgba(15, 23, 42, 0.75);
color: #bae6fd;
}
.errorRow {
border: 1px solid rgba(248, 113, 113, 0.44);
background: rgba(127, 29, 29, 0.34);
color: #fecaca;
}
.cards {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.card,
.cardWide {
border: 1px solid rgba(148, 163, 184, 0.2);
border-radius: 14px;
background:
linear-gradient(180deg, rgba(15, 23, 42, 0.84), rgba(15, 23, 42, 0.72)),
rgba(10, 15, 30, 0.78);
padding: 16px;
}
.cardWide {
grid-column: 1 / -1;
}
.card h3,
.cardWide h3 {
margin: 0 0 12px;
display: inline-flex;
align-items: center;
gap: 7px;
font-size: 15px;
color: #e6f0ff;
}
.metaList {
margin: 0;
display: grid;
gap: 10px;
}
.metaList > div {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
border-bottom: 1px dashed rgba(148, 163, 184, 0.22);
padding-bottom: 9px;
}
.metaList > div:last-child {
border-bottom: none;
padding-bottom: 0;
}
.metaList dt {
font-size: 12px;
color: #89a1c8;
}
.metaList dd {
margin: 0;
font-size: 13px;
color: #eff6ff;
max-width: 62%;
text-align: right;
word-break: break-word;
}
.mono {
font-family: "Consolas", "SFMono-Regular", "Menlo", monospace;
font-size: 12px !important;
color: #c7d2fe !important;
}
.hint {
margin: 0 0 12px;
font-size: 13px;
color: #9ab0d2;
line-height: 1.6;
}
.commandRow {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.command {
flex: 1 1 420px;
min-height: 40px;
display: flex;
align-items: center;
border-radius: 10px;
border: 1px solid rgba(103, 232, 249, 0.24);
background: rgba(8, 47, 73, 0.32);
color: #d1f9ff;
padding: 0 12px;
font-size: 12px;
white-space: pre-wrap;
word-break: break-all;
}
.copyBtn {
height: 36px;
border-radius: 9px;
border: 1px solid rgba(45, 212, 191, 0.48);
background: rgba(20, 184, 166, 0.12);
color: #99f6e4;
padding: 0 12px;
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 700;
cursor: pointer;
transition: all 0.2s ease;
}
.copyBtn:hover {
background: rgba(20, 184, 166, 0.22);
border-color: rgba(94, 234, 212, 0.78);
color: #ecfeff;
}
.spin {
animation: spin 0.9s linear infinite;
}
@keyframes spin {
100% {
transform: rotate(360deg);
}
}
@media (max-width: 900px) {
.topBar {
flex-direction: column;
align-items: stretch;
}
.actions {
flex-wrap: wrap;
}
.heroCard {
grid-template-columns: auto 1fr;
align-items: start;
}
.updatedText {
grid-column: 1 / -1;
justify-self: start;
}
.cards {
grid-template-columns: 1fr;
}
}
@media (max-width: 560px) {
.page {
padding: 18px 12px 20px;
}
.topBar,
.heroCard,
.card,
.cardWide {
border-radius: 12px;
padding: 13px;
}
.ghostBtn,
.primaryBtn {
width: 100%;
}
.actions {
flex-direction: column;
align-items: stretch;
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,29 @@
"use client";
import dynamic from "next/dynamic";
const AccountCenter = dynamic(
() =>
import("@/components/account/AccountCenter").then(
(module) => module.AccountCenter,
),
{
ssr: false,
loading: () => (
<div className="min-h-screen bg-slate-950 text-slate-300">
<div className="mx-auto w-full max-w-6xl px-6 py-10">
<div className="h-7 w-48 animate-pulse rounded bg-slate-800/80" />
<div className="mt-6 grid grid-cols-1 gap-4 md:grid-cols-3">
<div className="h-32 animate-pulse rounded-3xl bg-slate-800/70 md:col-span-2" />
<div className="h-32 animate-pulse rounded-3xl bg-slate-800/70" />
</div>
<div className="mt-6 h-72 animate-pulse rounded-3xl bg-slate-800/60" />
</div>
</div>
),
},
);
export function AccountEntry() {
return <AccountCenter />;
}
+301
View File
@@ -0,0 +1,301 @@
"use client";
import { FormEvent, useEffect, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import {
ArrowRight,
ChevronLeft,
Chrome,
Cloud,
CloudRain,
Lock,
Mail,
Sun,
} from "lucide-react";
import {
getSupabaseBrowserClient,
hasSupabasePublicEnv,
} from "@/lib/supabase/client";
import { useI18n } from "@/hooks/useI18n";
type Mode = "login" | "signup";
type LoginClientProps = {
nextPath: string;
};
export function LoginClient({ nextPath }: LoginClientProps) {
const router = useRouter();
const { locale } = useI18n();
const [mode, setMode] = useState<Mode>("login");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const [errorText, setErrorText] = useState("");
const [infoText, setInfoText] = useState("");
const supabaseReady = hasSupabasePublicEnv();
const isEn = locale === "en-US";
const copy = {
backHome: isEn ? "Back to Home" : "返回首页",
subtitle: isEn
? "Explore weather details from every corner of the world"
: "探索世界每一个角落的气象细节",
googleOneClick: isEn
? "Continue with Google"
: "使用 Google 账号一键登录",
orEmail: isEn ? "Or continue with email" : "或使用邮箱",
login: isEn ? "Sign In" : "登录",
signup: isEn ? "Sign Up" : "注册",
passwordLoginPlaceholder: isEn ? "Enter password" : "输入密码",
passwordSignupPlaceholder: isEn
? "Set at least 6 characters"
: "设置至少 6 位密码",
loginSubmit: isEn ? "Start your weather journey" : "开启天气之旅",
signupSubmit: isEn ? "Create account now" : "立即创建账号",
loginHint: isEn
? "After signing in, your homepage will be personalized."
: "登录后将为您个性化定制首页数据",
signupHint: isEn
? "By signing up, you agree to our Terms of Service."
: "注册即代表同意我们的服务条款",
realtime: isEn ? "Realtime data" : "实时数据",
highPrecision: isEn ? "High-precision forecast" : "高精度预测",
supabaseMissing: isEn
? "Supabase is not configured. Sign-in is unavailable."
: "Supabase 未配置,无法使用登录",
needEmailPassword: isEn
? "Please enter email and password."
: "请输入邮箱和密码",
signupCheckEmail: isEn
? "Sign-up successful. Please verify your email before signing in."
: "注册成功,请检查邮箱并完成验证后登录。",
} as const;
useEffect(() => {
if (!supabaseReady) return;
const run = async () => {
const supabase = getSupabaseBrowserClient();
const {
data: { session },
} = await supabase.auth.getSession();
if (session?.user) {
router.replace(nextPath);
}
};
void run();
}, [nextPath, router, supabaseReady]);
const onGoogleSignIn = async () => {
setErrorText("");
setInfoText("");
if (!supabaseReady) {
setErrorText(copy.supabaseMissing);
return;
}
setLoading(true);
try {
const supabase = getSupabaseBrowserClient();
const redirectTo = `${window.location.origin}/auth/callback?next=${encodeURIComponent(
nextPath,
)}`;
const { error } = await supabase.auth.signInWithOAuth({
provider: "google",
options: {
redirectTo,
},
});
if (error) {
setErrorText(error.message);
}
} finally {
setLoading(false);
}
};
const onEmailSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setErrorText("");
setInfoText("");
if (!supabaseReady) {
setErrorText(copy.supabaseMissing);
return;
}
if (!email.trim() || !password.trim()) {
setErrorText(copy.needEmailPassword);
return;
}
setLoading(true);
try {
const supabase = getSupabaseBrowserClient();
if (mode === "login") {
const { error } = await supabase.auth.signInWithPassword({
email: email.trim(),
password,
});
if (error) {
setErrorText(error.message);
return;
}
router.replace(nextPath);
return;
}
const emailRedirectTo = `${window.location.origin}/auth/callback?next=${encodeURIComponent(
nextPath,
)}`;
const { data, error } = await supabase.auth.signUp({
email: email.trim(),
password,
options: {
emailRedirectTo,
},
});
if (error) {
setErrorText(error.message);
return;
}
if (data.session?.user) {
router.replace(nextPath);
return;
}
setInfoText(copy.signupCheckEmail);
} finally {
setLoading(false);
}
};
const isLogin = mode === "login";
return (
<div className="relative flex min-h-screen w-full items-center justify-center overflow-hidden bg-[#0f172a] font-sans">
<div className="absolute left-[-10%] top-[-10%] h-[40vw] w-[40vw] animate-pulse rounded-full bg-blue-600/20 blur-[120px]" />
<div className="absolute bottom-[-10%] right-[-10%] h-[30vw] w-[30vw] rounded-full bg-indigo-500/20 blur-[100px]" />
<div className="relative mx-4 w-full max-w-[420px] rounded-[2rem] border border-white/10 bg-white/5 p-8 shadow-2xl backdrop-blur-xl">
<Link
href="/"
className="group absolute left-6 top-6 rounded-full border border-white/10 bg-white/5 p-2 text-slate-400 transition-all hover:bg-white/10 hover:text-white active:scale-90"
title={copy.backHome}
aria-label={copy.backHome}
>
<ChevronLeft className="h-5 w-5 transition-transform group-hover:-translate-x-0.5" />
</Link>
<div className="mb-8 flex flex-col items-center">
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-tr from-blue-500 to-indigo-400 shadow-lg shadow-blue-500/20">
<Cloud className="h-10 w-10 text-white" />
</div>
<h1 className="text-3xl font-bold tracking-tight text-white">PolyWeather</h1>
<p className="mt-2 text-sm text-slate-400">{copy.subtitle}</p>
</div>
<button
type="button"
onClick={() => void onGoogleSignIn()}
disabled={loading}
className="mb-6 flex w-full items-center justify-center rounded-xl bg-white px-4 py-3.5 font-semibold text-slate-900 shadow-lg transition-all duration-200 hover:bg-slate-100 active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-70"
>
<Chrome className="mr-3 h-5 w-5" />
{copy.googleOneClick}
</button>
<div className="my-6 flex items-center">
<div className="h-[1px] flex-grow bg-white/10" />
<span className="px-4 text-xs font-medium uppercase tracking-widest text-slate-500">
{copy.orEmail}
</span>
<div className="h-[1px] flex-grow bg-white/10" />
</div>
<div className="mb-6 flex rounded-xl bg-black/20 p-1">
<button
type="button"
onClick={() => setMode("login")}
className={`flex-1 rounded-lg py-2 text-sm font-medium transition-all ${
isLogin
? "bg-blue-600 text-white shadow-md"
: "text-slate-400 hover:text-slate-200"
}`}
>
{copy.login}
</button>
<button
type="button"
onClick={() => setMode("signup")}
className={`flex-1 rounded-lg py-2 text-sm font-medium transition-all ${
!isLogin
? "bg-blue-600 text-white shadow-md"
: "text-slate-400 hover:text-slate-200"
}`}
>
{copy.signup}
</button>
</div>
<form onSubmit={(event) => void onEmailSubmit(event)} className="space-y-4">
<div className="relative">
<Mail className="absolute left-4 top-1/2 h-5 w-5 -translate-y-1/2 text-slate-500" />
<input
type="email"
required
value={email}
onChange={(event) => setEmail(event.target.value)}
placeholder="you@example.com"
className="w-full rounded-xl border border-white/10 bg-white/5 py-3.5 pl-12 pr-4 text-white placeholder:text-slate-600 transition-all focus:border-blue-500/50 focus:outline-none focus:ring-2 focus:ring-blue-500/50"
/>
</div>
<div className="relative">
<Lock className="absolute left-4 top-1/2 h-5 w-5 -translate-y-1/2 text-slate-500" />
<input
type="password"
required
minLength={6}
value={password}
onChange={(event) => setPassword(event.target.value)}
placeholder={
isLogin
? copy.passwordLoginPlaceholder
: copy.passwordSignupPlaceholder
}
className="w-full rounded-xl border border-white/10 bg-white/5 py-3.5 pl-12 pr-4 text-white placeholder:text-slate-600 transition-all focus:border-blue-500/50 focus:outline-none focus:ring-2 focus:ring-blue-500/50"
/>
</div>
<button
type="submit"
disabled={loading}
className="group mt-8 flex w-full items-center justify-center rounded-xl bg-gradient-to-r from-blue-600 to-indigo-600 py-3.5 font-bold text-white shadow-xl shadow-blue-600/20 transition-all hover:from-blue-500 hover:to-indigo-500 active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-70"
>
{isLogin ? copy.loginSubmit : copy.signupSubmit}
<ArrowRight className="ml-2 h-5 w-5 transition-transform group-hover:translate-x-1" />
</button>
</form>
{errorText ? <p className="mt-4 text-sm text-rose-300">{errorText}</p> : null}
{infoText ? <p className="mt-4 text-sm text-emerald-300">{infoText}</p> : null}
<div className="mt-8 text-center">
<p className="text-xs text-slate-500">
{isLogin ? copy.loginHint : copy.signupHint}
</p>
</div>
{!supabaseReady ? (
<p className="mt-3 text-center text-sm text-rose-300">{copy.supabaseMissing}</p>
) : null}
</div>
<div className="absolute bottom-8 flex items-center gap-4 text-sm text-slate-600">
<span className="flex items-center">
<Sun className="mr-1 h-4 w-4" /> {copy.realtime}
</span>
<span className="flex items-center">
<CloudRain className="mr-1 h-4 w-4" /> {copy.highPrecision}
</span>
</div>
</div>
);
}
@@ -0,0 +1,215 @@
"use client";
import { startTransition, useEffect, useMemo, useState } from "react";
import clsx from "clsx";
import { useDashboardStore } from "@/hooks/useDashboardStore";
import { useI18n } from "@/hooks/useI18n";
import { CityListItem } from "@/lib/dashboard-types";
type RiskGroupKey = "high" | "medium" | "low" | "other";
const GROUP_STATE_STORAGE_KEY = "polyWeather_sidebar_groups_v1";
const DEFAULT_EXPANDED_GROUPS: Record<RiskGroupKey, boolean> = {
high: true,
medium: true,
low: false,
other: false,
};
function toRiskGroup(level?: string): RiskGroupKey {
if (level === "high" || level === "medium" || level === "low") return level;
return "other";
}
function normalizeExpandedGroups(
value: unknown,
): Record<RiskGroupKey, boolean> {
if (!value || typeof value !== "object") {
return DEFAULT_EXPANDED_GROUPS;
}
const candidate = value as Partial<Record<RiskGroupKey, unknown>>;
return {
high:
typeof candidate.high === "boolean"
? candidate.high
: DEFAULT_EXPANDED_GROUPS.high,
medium:
typeof candidate.medium === "boolean"
? candidate.medium
: DEFAULT_EXPANDED_GROUPS.medium,
low:
typeof candidate.low === "boolean"
? candidate.low
: DEFAULT_EXPANDED_GROUPS.low,
other:
typeof candidate.other === "boolean"
? candidate.other
: DEFAULT_EXPANDED_GROUPS.other,
};
}
export function CitySidebar() {
const store = useDashboardStore();
const { t } = useI18n();
const selectedCity = store.selectedCity;
const riskOrder = { high: 0, medium: 1, low: 2, other: 3 };
const [expandedGroups, setExpandedGroups] = useState<
Record<RiskGroupKey, boolean>
>(DEFAULT_EXPANDED_GROUPS);
const sortedCities = useMemo(
() =>
[...store.cities].sort((a, b) => {
const aGroup = toRiskGroup(a.risk_level);
const bGroup = toRiskGroup(b.risk_level);
return (
(riskOrder[aGroup] ?? 3) -
(riskOrder[bGroup] ?? 3) ||
a.display_name.localeCompare(b.display_name)
);
}),
[store.cities],
);
const groupedCities = useMemo(() => {
const groups: Record<RiskGroupKey, CityListItem[]> = {
high: [],
medium: [],
low: [],
other: [],
};
sortedCities.forEach((city) => {
groups[toRiskGroup(city.risk_level)].push(city);
});
return groups;
}, [sortedCities]);
useEffect(() => {
if (!selectedCity) return;
const selected = store.cities.find((city) => city.name === selectedCity);
if (!selected) return;
const groupKey = toRiskGroup(selected.risk_level);
setExpandedGroups((current) =>
current[groupKey] ? current : { ...current, [groupKey]: true },
);
}, [selectedCity, store.cities]);
useEffect(() => {
if (typeof window === "undefined") return;
const raw = window.localStorage.getItem(GROUP_STATE_STORAGE_KEY);
if (!raw) return;
try {
const parsed = JSON.parse(raw);
setExpandedGroups(normalizeExpandedGroups(parsed));
} catch {}
}, []);
useEffect(() => {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(
GROUP_STATE_STORAGE_KEY,
JSON.stringify(expandedGroups),
);
} catch {}
}, [expandedGroups]);
const groupMeta: Array<{ key: RiskGroupKey; label: string }> = [
{ key: "high", label: t("sidebar.group.high") },
{ key: "medium", label: t("sidebar.group.medium") },
{ key: "low", label: t("sidebar.group.low") },
{ key: "other", label: t("sidebar.group.other") },
];
return (
<nav className="city-list">
<div className="city-list-header">
<span>{t("sidebar.title")}</span>
<span className="city-count">{store.cities.length}</span>
</div>
<div className="city-list-items">
{groupMeta.map((group) => {
const citiesInGroup = groupedCities[group.key];
if (!citiesInGroup.length) return null;
const expanded = expandedGroups[group.key];
return (
<section
key={group.key}
className={clsx("city-group", !expanded && "collapsed")}
>
<button
type="button"
className="city-group-header"
aria-expanded={expanded}
onClick={() =>
setExpandedGroups((current) => ({
...current,
[group.key]: !current[group.key],
}))
}
>
<span className="city-group-title">{group.label}</span>
<span className="city-group-meta">
<span className="city-group-count">{citiesInGroup.length}</span>
<span className={clsx("city-group-arrow", expanded && "expanded")}>
</span>
</span>
</button>
<div className="city-group-items">
{citiesInGroup.map((city) => {
const detail = store.cityDetailsByName[city.name];
const summary = store.citySummariesByName[city.name];
const snapshot = detail || summary;
const isActive = store.selectedCity === city.name;
return (
<button
key={city.name}
type="button"
className={clsx("city-item", isActive && "active")}
onClick={() =>
startTransition(() => {
void store.selectCity(city.name);
})
}
>
<div className="city-item-main">
<span className={clsx("risk-dot", city.risk_level)} />
<span className="city-name-text">{city.display_name}</span>
<span
className={clsx(
"city-temp",
snapshot?.current?.temp != null && "loaded",
)}
>
{snapshot?.current?.temp != null
? `${snapshot.current.temp}${snapshot.temp_symbol || "°C"}`
: t("common.na")}
</span>
</div>
<div className="city-item-info">
<span className="city-local-time">
{snapshot?.local_time ? `🕒 ${snapshot.local_time}` : ""}
</span>
<span className="city-max-info">
{detail?.current?.max_temp_time
? t("sidebar.peakAt", { time: detail.current.max_temp_time })
: ""}
</span>
</div>
</button>
);
})}
</div>
</section>
);
})}
</div>
</nav>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,17 @@
"use client";
import dynamic from "next/dynamic";
const PolyWeatherDashboard = dynamic(
() =>
import("@/components/dashboard/PolyWeatherDashboard").then(
(module) => module.PolyWeatherDashboard,
),
{
ssr: false,
},
);
export function DashboardEntry() {
return <PolyWeatherDashboard />;
}
@@ -0,0 +1,354 @@
"use client";
import type { ChartConfiguration } from "chart.js";
import clsx from "clsx";
import { useEffect, useMemo, useRef, useState } from "react";
import { ForecastTable } from "@/components/dashboard/PanelSections";
import { useChart } from "@/hooks/useChart";
import { useDashboardStore } from "@/hooks/useDashboardStore";
import { useI18n } from "@/hooks/useI18n";
import { getCityScenery } from "@/lib/dashboard-scenery";
import { CityDetail } from "@/lib/dashboard-types";
import {
getCityProfileStats,
getRiskBadgeLabel,
getTemperatureChartData,
} from "@/lib/dashboard-utils";
function DetailMiniTemperatureChart({ detail }: { detail: CityDetail }) {
const { locale, t } = useI18n();
const chartData = useMemo(
() => getTemperatureChartData(detail, locale),
[detail, locale],
);
const canvasRef = useChart(() => {
if (!chartData) {
return {
data: { datasets: [], labels: [] },
type: "line",
} satisfies ChartConfiguration<"line">;
}
const forecastPoints = chartData.datasets.hasMgmHourly
? chartData.datasets.mgmHourlyPoints
: chartData.datasets.debPast.map(
(value, index) => value ?? chartData.datasets.debFuture[index],
);
return {
data: {
datasets: [
{
borderColor: chartData.datasets.hasMgmHourly
? "rgba(250, 204, 21, 0.92)"
: "rgba(52, 211, 153, 0.86)",
borderWidth: 1.8,
data: forecastPoints,
fill: false,
label: chartData.datasets.hasMgmHourly
? locale === "en-US"
? "MGM Forecast"
: "MGM 预测"
: locale === "en-US"
? "DEB Forecast"
: "DEB 预测",
pointRadius: 0,
spanGaps: true,
tension: 0.28,
},
{
backgroundColor: "#22d3ee",
borderColor: "#22d3ee",
borderWidth: 0,
data: chartData.datasets.metarPoints,
fill: false,
label:
chartData.observationLabel ||
(locale === "en-US" ? "METAR Observation" : "METAR 实况"),
pointHoverRadius: 6,
pointRadius: 3.8,
showLine: false,
},
],
labels: chartData.times,
},
options: {
interaction: { intersect: false, mode: "index" },
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: "rgba(15, 23, 42, 0.95)",
borderColor: "rgba(34, 211, 238, 0.25)",
borderWidth: 1,
},
},
responsive: true,
scales: {
x: {
grid: { color: "rgba(255,255,255,0.03)" },
ticks: {
callback: (_value, index) =>
typeof index === "number" && index % 4 === 0
? chartData.times[index]
: "",
color: "#64748b",
font: { size: 10 },
maxRotation: 0,
},
},
y: {
grid: { color: "rgba(255,255,255,0.03)" },
max: chartData.max,
min: chartData.min,
ticks: {
callback: (value) => `${value}${detail.temp_symbol || "°C"}`,
color: "#64748b",
font: { size: 10 },
},
},
},
},
type: "line",
} satisfies ChartConfiguration<"line">;
}, [chartData, detail.temp_symbol, locale]);
return (
<div className="detail-mini-chart-wrap">
<div className="detail-mini-chart">
<canvas ref={canvasRef} />
</div>
<div className="detail-mini-meta">
{chartData?.legendText || t("detail.chartLegendEmpty")}
</div>
</div>
);
}
export function DetailPanel() {
const store = useDashboardStore();
const { locale, t } = useI18n();
const detail = store.selectedDetail;
const isPro = store.proAccess.subscriptionActive;
const panelRef = useRef<HTMLElement | null>(null);
const [heavyContentReady, setHeavyContentReady] = useState(false);
const isOverlayOpen =
Boolean(store.futureModalDate) ||
store.historyState.isOpen ||
store.isGuideOpen;
const isVisible =
store.isPanelOpen &&
Boolean(store.selectedCity) &&
Boolean(detail) &&
!store.loadingState.cityDetail &&
!isOverlayOpen;
const profileStats = useMemo(
() => (detail ? getCityProfileStats(detail, locale) : []),
[detail, locale],
);
const scenery = getCityScenery(detail?.name);
const blurActiveElement = () => {
if (typeof document === "undefined") return;
const active = document.activeElement;
if (active instanceof HTMLElement) {
active.blur();
}
};
useEffect(() => {
const panel = panelRef.current;
if (!panel) return;
if (!isVisible) {
panel.setAttribute("inert", "");
if (
typeof document !== "undefined" &&
panel.contains(document.activeElement)
) {
const active = document.activeElement;
if (active instanceof HTMLElement) {
active.blur();
}
}
return;
}
panel.removeAttribute("inert");
}, [isVisible]);
useEffect(() => {
if (!isVisible || !detail) {
setHeavyContentReady(false);
return;
}
let canceled = false;
let timeoutId: number | null = null;
let idleId: number | null = null;
const win = typeof window !== "undefined" ? (window as any) : null;
const markReady = () => {
if (!canceled) {
setHeavyContentReady(true);
}
};
if (win && typeof win.requestIdleCallback === "function") {
idleId = win.requestIdleCallback(markReady, { timeout: 180 });
} else if (typeof window !== "undefined") {
timeoutId = window.setTimeout(markReady, 80);
} else {
setHeavyContentReady(true);
}
return () => {
canceled = true;
if (win && idleId != null && typeof win.cancelIdleCallback === "function") {
win.cancelIdleCallback(idleId);
}
if (timeoutId != null && typeof window !== "undefined") {
window.clearTimeout(timeoutId);
}
};
}, [detail, isVisible]);
return (
<aside
ref={panelRef}
className={clsx("detail-panel", isVisible && "visible")}
>
<div className="panel-header">
<button
type="button"
className="panel-close"
aria-label={t("detail.closeAria")}
onClick={() => {
blurActiveElement();
store.closePanel();
}}
>
×
</button>
<div className="panel-title-area">
<h2>{detail?.display_name?.toUpperCase() || "..."}</h2>
<div className="panel-meta">
<span className={clsx("risk-badge", detail?.risk?.level || "low")}>
{getRiskBadgeLabel(detail?.risk?.level, locale)}
</span>
<div className="relative group">
<button
type="button"
className={clsx("history-btn", !isPro && "pro-locked")}
title={
isPro
? t("detail.todayAnalysis")
: `${t("detail.todayAnalysis")} (Pro)`
}
onClick={() => {
blurActiveElement();
void store.openTodayModal();
}}
disabled={!detail}
>
{isPro
? t("detail.todayAnalysis")
: `${t("detail.todayAnalysis")} · Pro`}
</button>
<button
type="button"
className={clsx("history-btn", !isPro && "pro-locked")}
title={
isPro ? t("detail.history") : `${t("detail.history")} (Pro)`
}
onClick={() => {
blurActiveElement();
void store.openHistory();
}}
disabled={!detail}
>
{isPro ? t("detail.history") : `${t("detail.history")} · Pro`}
</button>
</div>
</div>
</div>
</div>
<div className="panel-body">
{!detail ? (
<section>
<div style={{ color: "var(--text-muted)", fontSize: "13px" }}>
{store.loadingState.cityDetail
? t("detail.loading")
: t("detail.emptyHint")}
</div>
</section>
) : (
<>
<section className="detail-scenery-card">
{scenery ? (
<>
<img
className="detail-scenery-image"
src={scenery.imageUrl}
alt={t("detail.sceneryAlt", { city: detail.display_name })}
/>
<div className="detail-scenery-overlay">
<div className="detail-scenery-copy">
<span className="detail-scenery-kicker">
{detail.display_name}
</span>
</div>
<a
className="detail-scenery-credit"
href={scenery.creditUrl}
target="_blank"
rel="noreferrer"
>
{scenery.creditLabel}
</a>
</div>
</>
) : (
<div className="detail-scenery-fallback">
<span className="detail-scenery-kicker">
{detail.display_name}
</span>
<strong className="detail-scenery-title">
{t("detail.sceneryTitle")}
</strong>
<span className="detail-scenery-subtitle">
{t("detail.sceneryFallback")}
</span>
</div>
)}
</section>
<section className="detail-section">
<h3>{t("detail.profile")}</h3>
<div className="detail-grid">
{profileStats.map((item) => (
<div key={item.label} className="detail-card">
<span className="detail-label">{item.label}</span>
<span className="detail-value">{item.value}</span>
</div>
))}
</div>
</section>
<section className="detail-section rounded-2xl">
<h3>{t("detail.todayMiniTrend")}</h3>
{heavyContentReady ? (
<DetailMiniTemperatureChart detail={detail} />
) : (
<div className="detail-mini-meta">{t("detail.loading")}</div>
)}
</section>
{heavyContentReady ? <ForecastTable /> : null}
</>
)}
</div>
</aside>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,105 @@
"use client";
import { useDashboardStore } from "@/hooks/useDashboardStore";
import { useI18n } from "@/hooks/useI18n";
const GUIDE_CARDS = {
"zh-CN": [
{
body: "Dynamic Ensemble Blending 是系统的核心预测层。它不是对 ECMWF、GFS、ICON、GEM、JMA 等模型的简单平均,而是结合近期样本表现、当前实况与城市偏置后得到的动态加权结果。",
title: "DEB 动态融合预测",
},
{
body: "右侧的结算概率分布基于 DEB 预测值与多模型离散度动态计算。μ 代表当前分布中心,会随着模型、实况和时间变化而变化,不是固定结算值。",
title: "结算概率引擎",
},
{
body: "结算源按城市市场定义:米兰(LIMC)、华沙(EPWA)、马德里(LEMD)使用机场 METAR;香港市场使用香港天文台(HKO);台北市场使用交通部中央气象署(CWA)。系统仍会保留 METAR/MGM 作为临近结构参考,并区分观测时间与接收时间。",
title: "结算点与主观测源",
},
{
body: "Ankara 不走通用城市逻辑。结算主站以 LTAC / Esenboğa 为准,周边领先信号优先参考 Turkish MGM 站网,其中 Ankara (Bölge/Center) 是重点监控站,不用 Etimesgut 代替。",
title: "Ankara 专属增强",
},
{
body: "点击多日预报后的模态框,主要用于分析下一个交易日。6-48 小时趋势以 weather.gov 和 Open-Meteo 为主;0-2 小时临近判断优先看 METAR 与周边站。",
title: "未来日期分析",
},
{
body: "历史准确率对账只统计已结算样本。网页端采用近 15 天滚动视图,不把当天尚未结算的样本算入胜率和 MAE。",
title: "历史对账规则",
},
],
"en-US": [
{
body: "Dynamic Ensemble Blending (DEB) is the core prediction layer. It is not a simple average across ECMWF/GFS/ICON/GEM/JMA, but a dynamically weighted blend adjusted by recent model performance, current observations, and city bias.",
title: "DEB Dynamic Fusion",
},
{
body: "Settlement probability distribution is dynamically computed from DEB forecast and model spread. μ is the current distribution center and shifts with model updates, observations, and time.",
title: "Settlement Probability Engine",
},
{
body: "Settlement source follows market rule by city: Milan (LIMC), Warsaw (EPWA), and Madrid (LEMD) settle on airport METAR, Hong Kong settles on HKO, and Taipei settles on CWA. METAR/MGM are still kept for intraday structure tracking with observation time vs receipt time separated.",
title: "Settlement Source Logic",
},
{
body: "Ankara does not follow the generic city path. LTAC / Esenboğa is the settlement station, with Turkish MGM network for leading signals. Ankara (Bölge/Center) is a key station and is not replaced by Etimesgut.",
title: "Ankara-specific Enhancement",
},
{
body: "The multi-day modal focuses on next-session analysis. 6-48h trend mainly relies on weather.gov and Open-Meteo; 0-2h nowcast prioritizes METAR and nearby stations.",
title: "Future-date Analysis",
},
{
body: "History reconciliation only uses settled samples. The web dashboard uses a rolling 15-day window and excludes same-day unsettled samples from hit-rate and MAE.",
title: "History Rules",
},
],
} as const;
export function GuideModal() {
const store = useDashboardStore();
const { locale, t } = useI18n();
if (!store.isGuideOpen) return null;
return (
<div
className="modal-overlay"
role="dialog"
aria-modal="true"
aria-labelledby="guide-modal-title"
onClick={(event) => {
if (event.target === event.currentTarget) {
store.closeGuide();
}
}}
>
<div className="modal-content large">
<div className="modal-header">
<h2 id="guide-modal-title">{t("guide.title")}</h2>
<button
type="button"
className="modal-close"
aria-label={t("guide.closeAria")}
onClick={store.closeGuide}
>
×
</button>
</div>
<div className="modal-body">
<div className="guide-grid">
{GUIDE_CARDS[locale].map((card) => (
<div key={card.title} className="guide-card">
<h3>{card.title}</h3>
<p>{card.body}</p>
</div>
))}
</div>
<div className="guide-footer">{t("guide.footer")}</div>
</div>
</div>
</div>
);
}
+118
View File
@@ -0,0 +1,118 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import clsx from "clsx";
import { LogIn, UserRound } from "lucide-react";
import { useDashboardStore } from "@/hooks/useDashboardStore";
import { useI18n } from "@/hooks/useI18n";
import {
getSupabaseBrowserClient,
hasSupabasePublicEnv,
} from "@/lib/supabase/client";
export function HeaderBar() {
const store = useDashboardStore();
const { locale, setLocale, t } = useI18n();
const [isAuthenticated, setIsAuthenticated] = useState(false);
const supabaseReady = hasSupabasePublicEnv();
useEffect(() => {
let mounted = true;
if (!supabaseReady) {
setIsAuthenticated(false);
return;
}
const supabase = getSupabaseBrowserClient();
void supabase.auth.getSession().then(({ data }) => {
if (!mounted) return;
setIsAuthenticated(Boolean(data.session?.user?.id));
});
const {
data: { subscription },
} = supabase.auth.onAuthStateChange((_event, session) => {
if (!mounted) return;
setIsAuthenticated(Boolean(session?.user?.id));
});
return () => {
mounted = false;
subscription.unsubscribe();
};
}, [supabaseReady]);
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");
return (
<header className="header">
<div className="brand">
<h1>PolyWeather</h1>
<span className="subtitle">{t("header.subtitle")}</span>
</div>
<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={accountHref}
className="account-btn"
title={accountAria}
aria-label={accountAria}
>
{isAuthenticated ? <UserRound size={14} /> : <LogIn size={14} />}
<span>{accountLabel}</span>
</Link>
<button
type="button"
className="info-btn"
title={t("header.infoAria")}
aria-label={t("header.infoAria")}
onClick={store.openGuide}
>
{t("header.info")}
</button>
<div className="live-badge" id="liveBadge">
<span className="pulse-dot" />
<span>{t("header.live")}</span>
</div>
<button
type="button"
className={clsx("refresh-btn", store.loadingState.refresh && "spinning")}
title={t("header.refreshAria")}
aria-label={t("header.refreshAria")}
onClick={() => void store.refreshAll()}
>
</button>
</div>
</header>
);
}
@@ -0,0 +1,265 @@
"use client";
import type { ChartConfiguration } from "chart.js";
import { useMemo } from "react";
import { useChart } from "@/hooks/useChart";
import { useDashboardStore, useHistoryData } from "@/hooks/useDashboardStore";
import { useI18n } from "@/hooks/useI18n";
import { ProFeaturePaywall } from "@/components/dashboard/ProFeaturePaywall";
import { getHistorySummary } from "@/lib/dashboard-utils";
function HistoryChart() {
const store = useDashboardStore();
const { locale } = useI18n();
const { data } = useHistoryData();
const summary = useMemo(
() => getHistorySummary(data, store.selectedDetail?.local_date),
[data, store.selectedDetail?.local_date],
);
const hasMgm =
store.selectedCity === "ankara" &&
summary.mgms.some((value) => value != null);
const hasBestBaseline =
Boolean(summary.bestModelName) &&
summary.bestModelName !== "MGM" &&
summary.bestModelSeries.some((value) => value != null);
const canvasRef = useChart(() => {
const datasets: NonNullable<
ChartConfiguration<"line">["data"]
>["datasets"] = [
{
backgroundColor: "rgba(248, 113, 113, 0.1)",
borderColor: "#f87171",
borderWidth: 2,
data: summary.actuals,
label: locale === "en-US" ? "Observed High" : "实测最高温",
pointBackgroundColor: "#f87171",
pointBorderColor: "#fff",
pointHoverRadius: 7,
pointRadius: 5,
tension: 0.2,
},
{
backgroundColor: "transparent",
borderColor: "#34d399",
borderDash: [5, 4],
borderWidth: 2,
data: summary.debs,
label: locale === "en-US" ? "DEB Fusion" : "DEB 融合",
pointHoverRadius: 6,
pointRadius: 4,
tension: 0.2,
},
];
if (hasMgm) {
datasets.push({
backgroundColor: "transparent",
borderColor: "#fb923c",
borderWidth: 2,
data: summary.mgms,
label: locale === "en-US" ? "MGM Official Forecast" : "MGM 官方预报",
pointHoverRadius: 6,
pointRadius: 4,
tension: 0.2,
});
}
if (hasBestBaseline) {
datasets.push({
backgroundColor: "transparent",
borderColor: "#60a5fa",
borderDash: [4, 3],
borderWidth: 2,
data: summary.bestModelSeries,
label:
locale === "en-US"
? `Best Baseline (${summary.bestModelName})`
: `最佳单模型 (${summary.bestModelName})`,
pointHoverRadius: 6,
pointRadius: 4,
tension: 0.2,
});
}
return {
data: {
datasets,
labels: summary.dates,
},
options: {
interaction: { intersect: false, mode: "index" },
maintainAspectRatio: false,
plugins: {
legend: {
labels: {
boxHeight: 12,
boxWidth: 34,
color: "#94a3b8",
font: { family: "Inter", size: 14 },
padding: 18,
},
},
tooltip: {
backgroundColor: "rgba(15, 23, 42, 0.9)",
borderColor: "rgba(255, 255, 255, 0.1)",
borderWidth: 1,
bodyFont: { family: "Inter", size: 13 },
titleFont: { family: "Inter", size: 13, weight: 600 },
callbacks: {
label: (ctx) =>
`${ctx.dataset.label}: ${ctx.parsed.y?.toFixed(1)}°`,
},
},
},
responsive: true,
scales: {
x: {
grid: { color: "rgba(255,255,255,0.04)" },
ticks: {
color: "#64748b",
font: { family: "Inter", size: 12 },
padding: 8,
},
},
y: {
grid: { color: "rgba(255,255,255,0.04)" },
ticks: {
color: "#64748b",
font: { family: "Inter", size: 12 },
padding: 8,
},
},
},
},
type: "line",
} satisfies ChartConfiguration<"line">;
}, [hasBestBaseline, hasMgm, summary, locale]);
if (!summary.recentData.length) return null;
return (
<div className="history-chart-wrapper">
<canvas ref={canvasRef} />
</div>
);
}
export function HistoryModal() {
const store = useDashboardStore();
const { t } = useI18n();
const { data, error, isLoading, isOpen } = useHistoryData();
const isPro = store.proAccess.subscriptionActive;
const isProLoading = store.proAccess.loading;
const summary = useMemo(
() => getHistorySummary(data, store.selectedDetail?.local_date),
[data, store.selectedDetail?.local_date],
);
if (!isOpen) return null;
return (
<div
className="modal-overlay"
role="dialog"
aria-modal="true"
aria-labelledby="history-modal-title"
onClick={(event) => {
if (event.target === event.currentTarget) {
store.closeHistory();
}
}}
>
{isProLoading ? (
<div
className="modal-content"
style={{ padding: "40px", textAlign: "center" }}
>
<div style={{ color: "var(--text-muted)" }}>
{t("dashboard.loading")}
</div>
</div>
) : !isPro ? (
<ProFeaturePaywall feature="history" onClose={store.closeHistory} />
) : (
<div className="modal-content history-modal">
<div className="modal-header">
<h2 id="history-modal-title">
{t("history.title", {
city: store.selectedCity?.toUpperCase() || "",
})}
</h2>
<button
type="button"
className="modal-close"
aria-label={t("history.closeAria")}
onClick={store.closeHistory}
>
×
</button>
</div>
<div className="modal-body">
<div className="history-stats">
{isLoading ? (
<span style={{ color: "var(--text-muted)" }}>
{t("history.loading")}
</span>
) : error ? (
<span style={{ color: "var(--accent-red)" }}>
{t("history.error")}
</span>
) : !summary.recentData.length ? (
<span style={{ color: "var(--text-muted)" }}>
{t("history.empty")}
</span>
) : (
<>
<div className="h-stat-card">
<span className="label">{t("history.debHitRate")}</span>
<span className="val">
{summary.hitRate != null ? `${summary.hitRate}%` : "--"}
</span>
</div>
<div className="h-stat-card">
<span className="label">{t("history.debMae")}</span>
<span className="val">
{summary.debMae != null ? `${summary.debMae}°` : "--"}
</span>
</div>
<div className="h-stat-card">
<span className="label">{t("history.bestModelMae")}</span>
<span className="val">
{summary.bestModelMae != null
? `${summary.bestModelMae}°${
summary.bestModelName
? ` (${summary.bestModelName})`
: ""
}`
: "--"}
</span>
</div>
<div className="h-stat-card">
<span className="label">{t("history.debVsBest")}</span>
<span className="val">
{summary.debWinRateVsBest != null
? `${summary.debWinRateVsBest}% (${summary.debWinDaysVsBest}/${summary.debVsBestComparableDays})`
: "--"}
</span>
</div>
<div className="h-stat-card">
<span className="label">{t("history.sample")}</span>
<span className="val">
{t("history.sampleDays", { count: summary.settledCount })}
</span>
</div>
</>
)}
</div>
{!isLoading && !error && <HistoryChart />}
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,30 @@
"use client";
import "leaflet/dist/leaflet.css";
import { useDashboardStore } from "@/hooks/useDashboardStore";
import { useLeafletMap } from "@/hooks/useLeafletMap";
export function MapCanvas() {
const store = useDashboardStore();
const { containerRef } = useLeafletMap({
cities: store.cities,
cityDetailsByName: store.cityDetailsByName,
citySummariesByName: store.citySummariesByName,
onClosePanel: store.closePanel,
onEnsureCityDetail: store.ensureCityDetail,
onRegisterStopMotion: store.registerMapStopMotion,
onSelectCity: (cityName) => {
void store.selectCity(cityName);
},
selectedCity: store.selectedCity,
selectedDetail: store.selectedDetail,
suspendMotion:
Boolean(store.futureModalDate) ||
store.historyState.isOpen ||
store.isGuideOpen,
isLoadingDetail: store.loadingState.cityDetail,
});
return <div ref={containerRef} className="map" />;
}
@@ -0,0 +1,825 @@
"use client";
import type { ChartConfiguration } from "chart.js";
import clsx from "clsx";
import { startTransition, useMemo } from "react";
import { useChart } from "@/hooks/useChart";
import { useCityData, useDashboardStore } from "@/hooks/useDashboardStore";
import { useI18n } from "@/hooks/useI18n";
import {
CityDetail,
MarketScan,
MarketTopBucket,
ProbabilityBucket,
} from "@/lib/dashboard-types";
import {
getHeroMetaItems,
getModelView,
getProbabilityView,
getRiskBadgeLabel,
getTemperatureChartData,
getWeatherSummary,
parseAiAnalysis,
} from "@/lib/dashboard-utils";
function EmptyState({ text }: { text: string }) {
return (
<div style={{ color: "var(--text-muted)", fontSize: "13px" }}>{text}</div>
);
}
function toPercent(value?: number | null) {
if (value == null) return null;
const numeric = Number(value);
if (!Number.isFinite(numeric)) return null;
return `${(numeric * 100).toFixed(1)}%`;
}
function toPriceCents(value?: number | null) {
if (value == null) return null;
const numeric = Number(value);
if (!Number.isFinite(numeric)) return null;
const normalized = numeric > 1 ? numeric / 100 : numeric;
const cents = normalized * 100;
const rounded = Math.round(cents * 10) / 10;
const text = Number.isInteger(rounded)
? String(rounded.toFixed(0))
: String(rounded);
return `${text}c`;
}
function parseTempFromText(value: unknown) {
const text = String(value || "");
const match = text.match(/(-?\d+(?:\.\d+)?)/);
if (!match) return null;
const numeric = Number(match[1]);
return Number.isFinite(numeric) ? numeric : null;
}
function getBucketTemp(bucket: ProbabilityBucket) {
if (bucket.value != null) {
const byValue = Number(bucket.value);
if (Number.isFinite(byValue)) return byValue;
}
return parseTempFromText(bucket.label || bucket.bucket || bucket.range);
}
function getMarketBucketTemp(scan?: MarketScan | null) {
if (!scan) return null;
if (scan.temperature_bucket?.value != null) {
const byBucketValue = Number(scan.temperature_bucket.value);
if (Number.isFinite(byBucketValue)) return byBucketValue;
}
const byBucketLabel = parseTempFromText(
scan.temperature_bucket?.label ||
scan.temperature_bucket?.bucket ||
scan.temperature_bucket?.range,
);
if (byBucketLabel != null) return byBucketLabel;
const slug = String(scan.selected_slug || scan.primary_market?.slug || "");
const slugMatch = slug.match(/-(-?\d+(?:\.\d+)?)c(?:$|[^a-z0-9])/i);
if (slugMatch) {
const numeric = Number(slugMatch[1]);
if (Number.isFinite(numeric)) return numeric;
}
return parseTempFromText(scan.primary_market?.question);
}
function getMarketYesPrice(scan?: MarketScan | null) {
if (scan?.market_price != null) {
const preferred = Number(scan.market_price);
if (Number.isFinite(preferred)) return preferred;
}
if (scan?.yes_token?.implied_probability != null) {
const implied = Number(scan.yes_token.implied_probability);
if (Number.isFinite(implied)) return implied;
}
return null;
}
function getMarketNoPrice(scan?: MarketScan | null) {
if (scan?.no_buy != null) {
const direct = Number(scan.no_buy);
if (Number.isFinite(direct)) return direct;
}
const marketYes = getMarketYesPrice(scan);
if (marketYes != null) return Math.max(0, Math.min(1, 1 - marketYes));
return null;
}
function normalizeMarketProbability(value?: number | null) {
if (value == null) return null;
const numeric = Number(value);
if (!Number.isFinite(numeric)) return null;
if (numeric > 1) return Math.max(0, Math.min(1, numeric / 100));
return Math.max(0, Math.min(1, numeric));
}
function getMarketTopBuckets(scan?: MarketScan | null) {
const buckets = Array.isArray(scan?.top_buckets) ? scan.top_buckets : [];
if (!buckets.length) return [];
return buckets
.map((item) => ({
...item,
probability: normalizeMarketProbability(item.probability),
}))
.filter(
(item): item is MarketTopBucket & { probability: number } =>
item.probability != null,
);
}
function getMarketTopBucketKey(bucket: MarketTopBucket) {
if (bucket?.value != null) {
const valueNum = Number(bucket.value);
if (Number.isFinite(valueNum)) return `v:${valueNum.toFixed(2)}`;
}
if (bucket?.temp != null) {
const tempNum = Number(bucket.temp);
if (Number.isFinite(tempNum)) return `t:${tempNum.toFixed(2)}`;
}
const parsed = parseTempFromText(bucket?.label);
if (parsed != null) return `l:${parsed.toFixed(2)}`;
return `s:${String(bucket?.slug || bucket?.question || bucket?.label || "")}`;
}
export function HeroSummary() {
const { data } = useCityData();
const { locale } = useI18n();
if (!data) return null;
const { weatherIcon, weatherText } = getWeatherSummary(data, locale);
const metaItems = getHeroMetaItems(data, locale);
const current = data.current || {};
const settlementSource = String(current.settlement_source || "metar").toUpperCase();
const isMax =
current.max_so_far != null &&
current.temp != null &&
current.max_so_far <= current.temp;
return (
<section className="hero-section">
<div className="hero-weather">
<span>
{weatherIcon} {weatherText}
</span>
</div>
<div className="hero-temp">
<span className="hero-value">
{current.temp != null ? current.temp.toFixed(1) : "--"}
</span>
<span className="hero-unit">{data.temp_symbol || "°C"}</span>
</div>
<div className="hero-max-time">
{isMax && current.max_temp_time
? locale === "en-US"
? `Today's peak temperature appeared at local time ${current.max_temp_time}`
: `该城市今日最高温出现在当地时间 ${current.max_temp_time}`
: ""}
</div>
<div className="hero-details">
<div className="hero-item">
<span className="label">
{locale === "en-US" ? "Current Obs" : "当前实测"}
</span>
<span className="value">
{current.temp != null
? `${current.temp}${data.temp_symbol} @${current.obs_time || "--"}`
: "--"}
</span>
</div>
<div className="hero-item">
<span className="label">
{locale === "en-US"
? `${settlementSource} Settlement Ref`
: `${settlementSource} 结算参考`}
</span>
<span className="value highlight">
{current.wu_settlement != null
? `${current.wu_settlement}${data.temp_symbol}`
: "--"}
</span>
</div>
<div className="hero-item">
<span className="label">
{locale === "en-US" ? "DEB Forecast" : "DEB 预测"}
</span>
<span className="value">
{data.deb?.prediction != null
? `${data.deb.prediction}${data.temp_symbol}`
: "--"}
</span>
</div>
</div>
<div className="hero-sub">
{metaItems.map((item) => (
<span key={item}>{item}</span>
))}
</div>
</section>
);
}
export function TemperatureChart() {
const { data } = useCityData();
const { locale, t } = useI18n();
const chartData = useMemo(
() => (data ? getTemperatureChartData(data, locale) : null),
[data, locale],
);
const canvasRef = useChart(() => {
if (!data || !chartData) {
return {
data: { datasets: [], labels: [] },
type: "line",
} satisfies ChartConfiguration<"line">;
}
const datasets: NonNullable<
ChartConfiguration<"line">["data"]
>["datasets"] = [];
if (chartData.datasets.hasMgmHourly) {
datasets.push({
backgroundColor: "rgba(234, 179, 8, 0.05)",
borderColor: "rgba(234, 179, 8, 0.8)",
borderWidth: 2,
data: chartData.datasets.mgmHourlyPoints,
fill: false,
label: locale === "en-US" ? "MGM Forecast" : "MGM 预报",
pointHoverRadius: 6,
pointRadius: 3,
spanGaps: true,
tension: 0.3,
});
} else {
datasets.push({
backgroundColor: "rgba(52, 211, 153, 0.05)",
borderColor: "rgba(52, 211, 153, 0.6)",
borderWidth: 1.5,
data: chartData.datasets.debPast,
fill: true,
label: locale === "en-US" ? "DEB Forecast" : "DEB 预报",
pointHoverRadius: 3,
pointRadius: 0,
tension: 0.3,
});
datasets.push({
borderColor: "rgba(52, 211, 153, 0.35)",
borderDash: [5, 3],
borderWidth: 1.5,
data: chartData.datasets.debFuture,
fill: false,
label: locale === "en-US" ? "DEB Forecast" : "DEB 预报",
pointRadius: 0,
tension: 0.3,
});
}
datasets.push({
backgroundColor: "#22d3ee",
borderColor: "#22d3ee",
borderWidth: 0,
data: chartData.datasets.metarPoints,
fill: false,
label:
chartData.observationLabel ||
(locale === "en-US" ? "METAR Observation" : "METAR 实况"),
order: 0,
pointHoverRadius: 7,
pointRadius: 5,
});
if (chartData.datasets.mgmPoints.some((value) => value != null)) {
datasets.push({
backgroundColor: "#facc15",
borderColor: "#facc15",
borderWidth: 0,
data: chartData.datasets.mgmPoints,
fill: false,
label: locale === "en-US" ? "MGM Observation" : "MGM 实测",
order: -1,
pointHoverRadius: 9,
pointRadius: 7,
showLine: false,
});
}
if (
!chartData.datasets.hasMgmHourly &&
Math.abs(chartData.datasets.offset) > 0.3
) {
datasets.push({
borderColor: "rgba(99, 102, 241, 0.2)",
borderDash: [2, 4],
borderWidth: 1,
data: chartData.datasets.temps,
fill: false,
label: locale === "en-US" ? "OM Raw" : "OM 原始",
pointRadius: 0,
tension: 0.3,
});
}
return {
data: {
datasets,
labels: chartData.times,
},
options: {
interaction: { intersect: false, mode: "index" },
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: "rgba(15, 23, 42, 0.9)",
borderColor: "rgba(52, 211, 153, 0.3)",
borderWidth: 1,
},
},
responsive: true,
scales: {
x: {
grid: { color: "rgba(255,255,255,0.04)" },
ticks: {
callback: (_value, index) =>
typeof index === "number" && index % 3 === 0
? chartData.times[index]
: "",
color: "#64748b",
maxRotation: 0,
},
},
y: {
grid: { color: "rgba(255,255,255,0.04)" },
max: chartData.max,
min: chartData.min,
ticks: {
callback: (value) => `${value}${data.temp_symbol || "°C"}`,
color: "#64748b",
},
},
},
},
type: "line",
} satisfies ChartConfiguration<"line">;
}, [data, chartData, locale]);
return (
<section className="chart-section">
<h3>{t("section.todayTempTrend")}</h3>
<div className="chart-wrapper">
<canvas ref={canvasRef} />
</div>
<div className="chart-legend">
{chartData?.legendText || t("section.chartEmpty")}
</div>
</section>
);
}
export function ProbabilityDistribution({
detail,
hideTitle = false,
targetDate,
marketScan,
}: {
detail: CityDetail;
hideTitle?: boolean;
targetDate?: string | null;
marketScan?: MarketScan | null;
}) {
const { locale, t } = useI18n();
const view = getProbabilityView(detail, targetDate);
const marketBucketTemp = getMarketBucketTemp(marketScan);
const marketYesPrice = getMarketYesPrice(marketScan);
const marketNoPrice = getMarketNoPrice(marketScan);
const marketYesText = toPercent(marketYesPrice);
const marketNoText = toPercent(marketNoPrice);
const isToday = !targetDate || targetDate === detail.local_date;
const marketTopBuckets = isToday ? getMarketTopBuckets(marketScan) : [];
const sortedMarketTopBuckets = useMemo(() => {
const sorted = [...marketTopBuckets].sort(
(a, b) => Number(b.probability || 0) - Number(a.probability || 0),
);
const deduped: Array<MarketTopBucket & { probability: number }> = [];
const seenKeys = new Set<string>();
for (const row of sorted) {
const key = getMarketTopBucketKey(row);
if (seenKeys.has(key)) continue;
seenKeys.add(key);
deduped.push(row);
if (deduped.length >= 4) break;
}
return deduped;
}, [marketTopBuckets]);
const useMarketTopBuckets =
marketScan?.available && sortedMarketTopBuckets.length >= 2;
const topMarketBucketText = toPercent(sortedMarketTopBuckets[0]?.probability);
return (
<section className="prob-section">
{!hideTitle && <h3>{t("section.probability")}</h3>}
<div className="prob-bars">
{view.mu != null && (
<div
style={{
color: "var(--text-muted)",
fontSize: "11px",
marginBottom: "6px",
}}
>
{t("section.mu", {
unit: detail.temp_symbol || "",
value: view.mu.toFixed(1),
})}
</div>
)}
{marketScan?.available && (topMarketBucketText || marketYesText) && (
<div
style={{
color: "var(--text-secondary)",
fontSize: "11px",
marginBottom: "6px",
}}
>
{useMarketTopBuckets
? locale === "en-US"
? `Market top-4 buckets (top): ${topMarketBucketText}`
: `市场概率(前4温度桶):最高 ${topMarketBucketText}`
: locale === "en-US"
? `Market probability (this bucket): ${marketYesText}`
: `市场概率(该温度桶): ${marketYesText}`}
</div>
)}
{useMarketTopBuckets ? (
sortedMarketTopBuckets.map((bucket, index) => {
const probability = Math.round(
Number(bucket.probability || 0) * 100,
);
let bucketLabel =
bucket.label ||
(bucket.value != null
? `${bucket.value}${detail.temp_symbol}`
: `${bucket.temp ?? "--"}${detail.temp_symbol}`);
if (bucketLabel) {
let str = String(bucketLabel).toUpperCase().replace(/\s+/g, "");
str = str.replace(/°?C($|\+|-)/g, "℃$1");
if (!str.includes("℃") && /[0-9]/.test(str)) {
str += "℃";
}
bucketLabel = str;
}
const buyYesText = toPriceCents(
bucket.yes_buy ?? bucket.market_price ?? bucket.probability,
);
const buyNoText = toPriceCents(bucket.no_buy);
const marketTag = buyYesText
? locale === "en-US"
? `Buy Yes: ${buyYesText}`
: `买 Yes: ${buyYesText}`
: buyNoText
? locale === "en-US"
? `Buy No: ${buyNoText}`
: `买 No: ${buyNoText}`
: null;
return (
<div
key={`${bucket.slug || bucket.label || index}`}
className="prob-row"
>
<div className="prob-label">{bucketLabel}</div>
<div className="prob-bar-track">
<div
className={clsx("prob-bar-fill", `rank-${index}`)}
style={{ width: `${Math.max(probability, 8)}%` }}
>
{probability}%
</div>
</div>
{marketTag && (
<div className={clsx("prob-market-inline", "yes")}>
{marketTag}
</div>
)}
</div>
);
})
) : view.probabilities.length === 0 ? (
<EmptyState text={t("section.noProb")} />
) : (
view.probabilities.slice(0, 6).map((bucket, index) => {
const probability = Math.round(
Number(bucket.probability || 0) * 100,
);
const bucketTemp = getBucketTemp(bucket);
const isMarketBucket =
marketYesText != null &&
marketBucketTemp != null &&
bucketTemp != null &&
Math.abs(bucketTemp - marketBucketTemp) < 0.26;
const marketTag = isMarketBucket
? locale === "en-US"
? `Buy Yes: ${marketYesText || "--"}`
: `买 Yes: ${marketYesText || "--"}`
: marketNoText
? locale === "en-US"
? `Buy No: ${marketNoText}`
: `买 No: ${marketNoText}`
: null;
const yesPriceText = toPriceCents(marketYesPrice);
const noPriceText = toPriceCents(marketNoPrice);
const marketTagFinal = isMarketBucket
? locale === "en-US"
? `Buy Yes: ${yesPriceText || "--"}`
: `买 Yes: ${yesPriceText || "--"}`
: noPriceText
? locale === "en-US"
? `Buy No: ${noPriceText}`
: `买 No: ${noPriceText}`
: marketTag;
let bucketLabel =
bucket.label || `${bucket.value}${detail.temp_symbol}`;
if (bucketLabel) {
let str = String(bucketLabel).toUpperCase().replace(/\s+/g, "");
str = str.replace(/°?C($|\+|-)/g, "℃$1");
if (!str.includes("℃") && /[0-9]/.test(str)) {
str += "℃";
}
bucketLabel = str;
}
return (
<div
key={`${bucket.label || bucket.value || index}`}
className="prob-row"
>
<div className="prob-label">{bucketLabel}</div>
<div className="prob-bar-track">
<div
className={clsx("prob-bar-fill", `rank-${index}`)}
style={{ width: `${Math.max(probability, 8)}%` }}
>
{probability}%
</div>
</div>
{marketTagFinal && (
<div
className={clsx(
"prob-market-inline",
isMarketBucket ? "yes" : "no",
)}
>
{marketTagFinal}
</div>
)}
</div>
);
})
)}
</div>
</section>
);
}
export function ModelForecast({
detail,
hideTitle = false,
targetDate,
}: {
detail: CityDetail;
hideTitle?: boolean;
targetDate?: string | null;
}) {
const { locale, t } = useI18n();
const view = getModelView(detail, targetDate);
const modelsMap = { ...view.models };
const modelEntries = Object.entries(modelsMap).filter(
([, value]) =>
value !== null && value !== undefined && Number.isFinite(Number(value)),
);
// 如果没有任何数值,给出提示
if (modelEntries.length === 0) {
return (
<section className="models-section">
{!hideTitle && <h3>{t("section.models")}</h3>}
<div className="model-bars">
<EmptyState text={t("section.noModels")} />
</div>
</section>
);
}
const numericValues = modelEntries.map(([, value]) => Number(value));
const comparisonValues =
view.deb != null ? [...numericValues, Number(view.deb)] : numericValues;
const minValue = comparisonValues.length
? Math.min(...comparisonValues) - 1
: 0;
const maxValue = comparisonValues.length
? Math.max(...comparisonValues) + 1
: 1;
const range = Math.max(maxValue - minValue, 1);
return (
<section className="models-section">
{!hideTitle && <h3>{t("section.models")}</h3>}
<div className="model-bars">
{modelEntries
.sort((a, b) => Number(b[1] || 0) - Number(a[1] || 0))
.map(([name, value]) => {
const numeric = Number(value);
const width = ((numeric - minValue) / range) * 100;
const debLine =
view.deb != null
? ((Number(view.deb) - minValue) / range) * 100
: null;
return (
<div key={name} className="model-row">
<div className="model-name" title={name}>
{name}
</div>
<div className="model-bar-track">
<div
className="model-bar-fill"
style={{ width: `${width}%` }}
>
{numeric}
{detail.temp_symbol}
</div>
{debLine != null && (
<div
className="model-deb-line"
style={{ left: `${debLine}%` }}
/>
)}
</div>
</div>
);
})}
{view.deb != null && (
<div
className="model-row"
style={{
borderTop: "1px solid rgba(255,255,255,0.06)",
marginTop: "6px",
paddingTop: "6px",
}}
>
<div
className="model-name"
style={{ color: "var(--accent-cyan)", fontWeight: 700 }}
>
DEB
</div>
<div className="model-bar-track">
<div
className="model-bar-fill deb"
style={{
width: `${((Number(view.deb) - minValue) / range) * 100}%`,
}}
>
{Number(view.deb)}
{detail.temp_symbol}
</div>
</div>
</div>
)}
</div>
</section>
);
}
export function ForecastTable() {
const store = useDashboardStore();
const { data } = useCityData();
const { t } = useI18n();
if (!data) return null;
const daily = data.forecast?.daily || [];
return (
<section className="forecast-section">
<h3>{t("forecast.title")}</h3>
<div className="forecast-table">
{daily.length === 0 ? (
<EmptyState text={t("forecast.empty")} />
) : (
daily.map((day, index) => {
const isToday = day.date === data.local_date || index === 0;
const isSelected =
store.futureModalDate === day.date ||
store.selectedForecastDate === day.date;
return (
<button
key={day.date}
type="button"
className={clsx(
"forecast-day",
isToday && "today",
isSelected && "selected",
)}
onClick={() => {
startTransition(() => {
store.openFutureModal(day.date);
});
}}
>
<div className="f-date">
{isToday
? t("forecast.today")
: day.date.substring(5).replace("-", "/")}
</div>
<div className="f-temp">
{day.max_temp}
{data.temp_symbol}
</div>
</button>
);
})
)}
</div>
</section>
);
}
export function AiAnalysis() {
const { data } = useCityData();
const { t } = useI18n();
if (!data) return null;
const ai = parseAiAnalysis(data.ai_analysis);
return (
<section className="ai-section">
<h3>{t("section.ai")}</h3>
<div className="ai-box">
{!ai.summary && ai.bullets.length === 0 ? (
<span className="ai-placeholder">{t("section.aiEmpty")}</span>
) : (
<>
{ai.summary && <div className="ai-summary">{ai.summary}</div>}
{ai.bullets.length > 0 && (
<ul className="ai-list">
{ai.bullets.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
)}
</>
)}
</div>
</section>
);
}
export function RiskInfo() {
const { data } = useCityData();
const { t } = useI18n();
if (!data) return null;
const risk = data.risk || {};
return (
<section className="risk-section">
<h3>{t("section.risk")}</h3>
<div className="risk-info">
{!risk.airport ? (
<span style={{ color: "var(--text-muted)" }}>
{t("section.noRiskProfile")}
</span>
) : (
<>
<div className="risk-row">
<span className="risk-label">{t("section.airport")}</span>
<span>
{risk.airport} ({risk.icao})
</span>
</div>
<div className="risk-row">
<span className="risk-label">{t("section.distance")}</span>
<span>{risk.distance_km}km</span>
</div>
{risk.warning && (
<div className="risk-row">
<span className="risk-label">{t("section.note")}</span>
<span>{risk.warning}</span>
</div>
)}
</>
)}
</div>
</section>
);
}
@@ -0,0 +1,118 @@
"use client";
import { useEffect } from "react";
import dynamic from "next/dynamic";
import styles from "./Dashboard.module.css";
import {
DashboardStoreProvider,
useDashboardStore,
} from "@/hooks/useDashboardStore";
import { I18nProvider, useI18n } from "@/hooks/useI18n";
import { CitySidebar } from "@/components/dashboard/CitySidebar";
import { DetailPanel } from "@/components/dashboard/DetailPanel";
import { HeaderBar } from "@/components/dashboard/HeaderBar";
const MapCanvas = dynamic(
() =>
import("@/components/dashboard/MapCanvas").then((module) => module.MapCanvas),
{
ssr: false,
loading: () => <div className="map" aria-hidden="true" />,
},
);
const GuideModal = dynamic(
() =>
import("@/components/dashboard/GuideModal").then((module) => module.GuideModal),
{
ssr: false,
loading: () => null,
},
);
const HistoryModal = dynamic(
() =>
import("@/components/dashboard/HistoryModal").then(
(module) => module.HistoryModal,
),
{
ssr: false,
loading: () => null,
},
);
const FutureForecastModal = dynamic(
() =>
import("@/components/dashboard/FutureForecastModal").then(
(module) => module.FutureForecastModal,
),
{
ssr: false,
loading: () => null,
},
);
function DashboardScreen() {
const store = useDashboardStore();
const { t } = useI18n();
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Escape") return;
if (store.futureModalDate) {
store.closeFutureModal();
return;
}
if (store.historyState.isOpen) {
store.closeHistory();
return;
}
if (store.isGuideOpen) {
store.closeGuide();
return;
}
if (store.isPanelOpen) {
store.closePanel();
}
};
window.addEventListener("keydown", onKeyDown);
return () => {
window.removeEventListener("keydown", onKeyDown);
};
}, [store]);
// Avoid full-page flashing on initial load; only show this overlay for manual refresh.
const showLoading =
store.loadingState.cities ||
store.loadingState.cityDetail ||
store.loadingState.refresh;
return (
<div className={styles.root}>
<MapCanvas />
<HeaderBar />
<CitySidebar />
<DetailPanel />
{store.isGuideOpen && <GuideModal />}
{store.historyState.isOpen && <HistoryModal />}
{store.futureModalDate && <FutureForecastModal />}
{showLoading && (
<div className="loading-overlay">
<div className="loading-spinner" />
<span>{t("dashboard.loading")}</span>
</div>
)}
</div>
);
}
export function PolyWeatherDashboard() {
return (
<I18nProvider>
<DashboardStoreProvider>
<DashboardScreen />
</DashboardStoreProvider>
</I18nProvider>
);
}
@@ -0,0 +1,89 @@
"use client";
import { useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { useI18n } from "@/hooks/useI18n";
import { useDashboardStore } from "@/hooks/useDashboardStore";
import { UnlockProOverlay } from "@/components/subscription/UnlockProOverlay";
const TELEGRAM_GROUP_URL = String(
process.env.NEXT_PUBLIC_TELEGRAM_GROUP_URL ||
"https://t.me/+nMG7SjziUKYyZmM1",
).trim();
const SUBSCRIPTION_HELP_HREF = "/subscription-help";
type ProFeaturePaywallProps = {
feature: "today" | "history" | "future";
onClose?: () => void;
};
export function ProFeaturePaywall({
feature,
onClose,
}: ProFeaturePaywallProps) {
const router = useRouter();
const { locale } = useI18n();
const { proAccess } = useDashboardStore();
const [usePoints, setUsePoints] = useState(true);
const isEn = locale === "en-US";
const isAuthenticated = proAccess.authenticated;
const pointsAvailable = Number(proAccess.points || 0);
const PRO_PRICE_USD = 5;
const POINTS_PER_USD = 500;
const MAX_DISCOUNT_USD = 3;
const billing = useMemo(() => {
const isEligible = pointsAvailable >= POINTS_PER_USD;
const maxRedeemablePoints = MAX_DISCOUNT_USD * POINTS_PER_USD;
const boundedPoints = isEligible
? Math.min(pointsAvailable, maxRedeemablePoints)
: 0;
const discountUnits = Math.floor(boundedPoints / POINTS_PER_USD);
const pointsUsed = discountUnits * POINTS_PER_USD;
const discountAmount = usePoints ? discountUnits : 0;
return {
pointsEnabled: true,
isEligible,
pointsPerUsd: POINTS_PER_USD,
maxDiscountUsd: MAX_DISCOUNT_USD,
pointsUsed: usePoints ? pointsUsed : 0,
discountAmount,
finalPrice: PRO_PRICE_USD - discountAmount,
};
}, [pointsAvailable, usePoints]);
const payLabel = isAuthenticated
? isEn
? "Open Pro in Account"
: "去账户中心开通 Pro"
: isEn
? "Sign In to Unlock Pro"
: "先登录再开通 Pro";
return (
<div className="flex w-full flex-col items-center justify-center py-6 md:py-10 z-30 p-4">
<UnlockProOverlay
locale={locale}
points={pointsAvailable}
planPriceUsd={PRO_PRICE_USD}
usePoints={usePoints}
onToggleUsePoints={() => setUsePoints((prev) => !prev)}
billing={billing}
onClose={onClose}
onPay={() => {
if (!isAuthenticated) {
router.push("/auth/login?next=%2Faccount");
return;
}
router.push("/account");
}}
payLabel={payLabel}
faqHref={SUBSCRIPTION_HELP_HREF}
telegramGroupUrl={TELEGRAM_GROUP_URL}
/>
</div>
);
}
@@ -0,0 +1,43 @@
"use client";
import { usePathname } from "next/navigation";
import { useReportWebVitals } from "next/web-vitals";
const TRACKED_METRICS = new Set(["INP", "LCP", "FCP"]);
export function WebVitalsReporter() {
const pathname = usePathname();
useReportWebVitals((metric) => {
if (!TRACKED_METRICS.has(metric.name)) {
return;
}
const payload = {
id: metric.id,
metric: metric.name,
navigationType: metric.navigationType,
pathname: pathname || "/",
rating: metric.rating,
value: metric.value,
};
const body = JSON.stringify(payload);
const url = "/api/vitals";
if (typeof navigator !== "undefined" && typeof navigator.sendBeacon === "function") {
const blob = new Blob([body], { type: "application/json" });
navigator.sendBeacon(url, blob);
return;
}
void fetch(url, {
body,
headers: { "Content-Type": "application/json" },
keepalive: true,
method: "POST",
});
});
return null;
}
@@ -0,0 +1,757 @@
/* ── Outer shell ── */
.modal {
position: relative;
width: 100%;
max-width: 820px;
max-height: calc(100dvh - 2.5rem);
overflow-x: hidden;
overflow-y: auto;
/* Hide scrollbar — all browsers */
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE / Edge legacy */
border-radius: 28px;
border: 1px solid rgba(255, 255, 255, 0.07);
background: linear-gradient(160deg, #111827 0%, #0d1525 40%, #090f1c 100%);
box-shadow:
0 0 0 1px rgba(255, 255, 255, 0.04),
0 32px 80px -12px rgba(0, 0, 0, 0.8),
0 0 120px -40px rgba(79, 109, 230, 0.28);
padding: 28px 24px 24px;
}
/* Chrome / Safari / WebKit */
.modal::-webkit-scrollbar {
display: none;
}
@media (min-width: 768px) {
.modal {
padding: 36px 36px 28px;
}
}
/* ── Ambient glows (not clipped by modal) ── */
.glowLeft {
pointer-events: none;
position: absolute;
left: -80px;
top: -80px;
width: 280px;
height: 280px;
border-radius: 50%;
background: radial-gradient(
circle,
rgba(59, 130, 246, 0.18) 0%,
transparent 70%
);
}
.glowRight {
pointer-events: none;
position: absolute;
right: -60px;
bottom: -60px;
width: 240px;
height: 240px;
border-radius: 50%;
background: radial-gradient(
circle,
rgba(99, 102, 241, 0.14) 0%,
transparent 70%
);
}
.topLine {
pointer-events: none;
position: absolute;
top: 0;
left: 50%;
transform: translateX(-50%);
width: 60%;
height: 1px;
background: linear-gradient(
90deg,
transparent,
rgba(96, 165, 250, 0.35),
transparent
);
}
/* ── Close button ── */
.closeBtn {
position: absolute;
top: 16px;
right: 16px;
z-index: 20;
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 50%;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.05);
color: #64748b;
cursor: pointer;
transition: all 0.2s;
}
.closeBtn:hover {
background: rgba(255, 255, 255, 0.12);
border-color: rgba(255, 255, 255, 0.2);
color: #fff;
}
.closeBtn:active {
transform: scale(0.9);
}
/* ── Header ── */
.header {
position: relative;
z-index: 10;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
margin-bottom: 28px;
}
.badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 14px;
border-radius: 999px;
border: 1px solid rgba(251, 191, 36, 0.28);
background: rgba(251, 191, 36, 0.1);
margin-bottom: 18px;
}
.badgeText {
font-size: 10px;
font-weight: 800;
letter-spacing: 0.14em;
text-transform: uppercase;
color: #fbbf24;
}
.title {
font-size: clamp(1.9rem, 5vw, 3rem);
font-weight: 900;
line-height: 1.05;
letter-spacing: -0.02em;
background: linear-gradient(175deg, #fff 20%, #94a3b8 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin: 0;
}
.subtitle {
margin-top: 10px;
font-size: 14px;
line-height: 1.6;
color: #64748b;
max-width: 420px;
}
/* ── Card grid ── */
.grid {
position: relative;
z-index: 10;
display: grid;
grid-template-columns: 1fr;
gap: 14px;
}
@media (min-width: 640px) {
.grid {
grid-template-columns: 1fr 1fr;
}
}
/* ── Plan card ── */
.planCard {
position: relative;
border-radius: 20px;
border: 1px solid rgba(59, 130, 246, 0.3);
background: linear-gradient(
135deg,
rgba(30, 58, 110, 0.55) 0%,
rgba(15, 28, 58, 0.4) 100%
);
padding: 22px;
min-height: 200px;
display: flex;
flex-direction: column;
}
.planCard::before {
content: "";
position: absolute;
inset: 0;
border-radius: 20px;
background: linear-gradient(
135deg,
rgba(59, 130, 246, 0.12) 0%,
transparent 60%
);
pointer-events: none;
}
/* ── Points card (eligible) ── */
.pointsCard {
position: relative;
border-radius: 20px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.03);
padding: 22px;
min-height: 200px;
display: flex;
flex-direction: column;
transition:
border-color 0.3s,
background 0.3s;
}
.pointsCardActive {
border-color: rgba(52, 211, 153, 0.3);
background: linear-gradient(
135deg,
rgba(16, 60, 45, 0.5) 0%,
rgba(5, 30, 22, 0.35) 100%
);
}
.pointsCardActive::before {
content: "";
position: absolute;
inset: 0;
border-radius: 20px;
background: linear-gradient(
135deg,
rgba(52, 211, 153, 0.1) 0%,
transparent 60%
);
pointer-events: none;
}
/* ── Points not-eligible card ── */
.pointsUnavailableCard {
position: relative;
border-radius: 20px;
border: 1px dashed rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.02);
padding: 22px;
min-height: 200px;
display: flex;
flex-direction: column;
transition: border-color 0.3s;
}
.pointsUnavailableCard:hover {
border-color: rgba(255, 255, 255, 0.2);
}
/* ── Plan label chip ── */
.planChip {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 4px 10px;
border-radius: 8px;
background: rgba(59, 130, 246, 0.18);
font-size: 10px;
font-weight: 800;
letter-spacing: 0.13em;
text-transform: uppercase;
color: #93c5fd;
width: fit-content;
}
/* ── Price ── */
.price {
font-size: 50px;
font-weight: 900;
line-height: 1;
letter-spacing: -0.03em;
color: #fff;
margin: 10px 0 0;
}
.priceSuffix {
font-size: 13px;
font-weight: 600;
color: #475569;
margin-left: 4px;
}
/* ── Feature list ── */
.featureList {
list-style: none;
margin: 16px 0 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.featureItem {
display: flex;
align-items: center;
gap: 10px;
font-size: 12.5px;
color: #94a3b8;
}
.featureIcon {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
flex-shrink: 0;
border-radius: 6px;
background: rgba(59, 130, 246, 0.18);
color: #60a5fa;
}
/* ── Points card header row ── */
.pointsHeader {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.pointsLabelRow {
display: flex;
align-items: center;
gap: 8px;
}
.pointsIconBox {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 8px;
background: #1e293b;
color: #475569;
transition:
background 0.2s,
color 0.2s;
}
.pointsIconBoxActive {
background: #10b981;
color: #fff;
}
.pointsLabel {
font-size: 10px;
font-weight: 800;
letter-spacing: 0.13em;
text-transform: uppercase;
color: #475569;
transition: color 0.2s;
}
.pointsLabelActive {
color: #6ee7b7;
}
/* ── Toggle ── */
.toggle {
position: relative;
width: 44px;
height: 24px;
flex-shrink: 0;
border-radius: 999px;
background: #1e293b;
cursor: pointer;
border: none;
transition: background 0.3s;
}
.toggleActive {
background: #10b981;
}
.toggleThumb {
position: absolute;
top: 4px;
left: 4px;
width: 16px;
height: 16px;
border-radius: 50%;
background: #fff;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
transition: transform 0.2s;
}
.toggleThumbActive {
transform: translateX(20px);
}
/* ── Discount amount ── */
.discount {
font-size: 48px;
font-weight: 900;
line-height: 1;
letter-spacing: -0.03em;
color: #334155;
transition: color 0.3s;
}
.discountActive {
color: #34d399;
}
.discountSuffix {
font-size: 10px;
font-weight: 800;
letter-spacing: 0.1em;
text-transform: uppercase;
color: #334155;
margin-left: 6px;
}
.pointsNote {
margin-top: 10px;
font-size: 12px;
line-height: 1.5;
color: #475569;
}
.pointsBalance {
display: flex;
align-items: center;
gap: 5px;
margin-top: 12px;
font-size: 11px;
color: #334155;
}
.balanceNum {
font-weight: 700;
color: #475569;
transition: color 0.2s;
}
.balanceNumActive {
color: #34d399;
}
/* ── Unavailable card content ── */
.unavailTitle {
font-size: 14.5px;
font-weight: 700;
color: #e2e8f0;
margin: 0 0 6px;
}
.unavailDesc {
font-size: 12px;
line-height: 1.6;
color: #475569;
margin: 0;
}
.progressWrap {
margin-top: 16px;
}
.progressHeader {
display: flex;
justify-content: space-between;
font-size: 10px;
color: #334155;
margin-bottom: 5px;
}
.progressTrack {
height: 4px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.06);
overflow: hidden;
}
.progressFill {
height: 100%;
border-radius: 999px;
background: linear-gradient(90deg, #3b82f6, #6366f1);
transition: width 0.5s ease;
}
.unavailCta {
margin-top: auto;
padding-top: 16px;
font-size: 11px;
font-weight: 600;
color: #3b82f6;
display: inline-flex;
align-items: center;
gap: 5px;
text-decoration: none;
transition: color 0.2s;
}
.unavailCta:hover {
color: #60a5fa;
}
/* ── Payment summary box ── */
.summaryBox {
position: relative;
z-index: 10;
margin-top: 18px;
border-radius: 20px;
border: 1px solid rgba(255, 255, 255, 0.07);
background: rgba(255, 255, 255, 0.03);
overflow: hidden;
}
.summaryRow {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
}
.summaryLabel {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
color: #475569;
margin: 0 0 2px;
}
.summaryOriginal {
font-size: 11px;
color: #334155;
text-decoration: line-through;
}
.summaryAmount {
display: flex;
align-items: baseline;
gap: 5px;
}
.summaryPrice {
font-family: "SF Mono", "Fira Code", "Cascadia Code", monospace;
font-size: 40px;
font-weight: 900;
line-height: 1;
color: #fff;
}
.summaryUnit {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: #334155;
}
.summaryDivider {
height: 1px;
background: rgba(255, 255, 255, 0.06);
margin: 0 20px;
}
.ctaWrap {
padding: 12px;
}
/* ── CTA Button ── */
.ctaBtn {
position: relative;
display: flex;
width: 100%;
align-items: center;
justify-content: center;
gap: 10px;
overflow: hidden;
border-radius: 14px;
border: none;
background: linear-gradient(90deg, #2563eb 0%, #4f46e5 100%);
padding: 14px 24px;
font-size: 15px;
font-weight: 700;
color: #fff;
cursor: pointer;
transition: all 0.2s;
box-shadow:
0 0 0 1px rgba(99, 102, 241, 0.5),
0 8px 32px -6px rgba(79, 86, 230, 0.55);
}
.ctaBtn:hover:not(:disabled) {
background: linear-gradient(90deg, #3b82f6 0%, #6366f1 100%);
box-shadow:
0 0 0 1px rgba(99, 102, 241, 0.7),
0 12px 40px -6px rgba(99, 102, 241, 0.65);
transform: translateY(-1px);
}
.ctaBtn:active:not(:disabled) {
transform: scale(0.99);
}
.ctaBtn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* Shimmer on hover */
.ctaBtn::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(
105deg,
transparent 35%,
rgba(255, 255, 255, 0.12) 50%,
transparent 65%
);
transform: translateX(-100%) skewX(-15deg);
transition: transform 0.6s;
}
.ctaBtn:hover::after {
transform: translateX(100%) skewX(-15deg);
}
/* ── Footer ── */
.footer {
position: relative;
z-index: 10;
margin-top: 14px;
display: flex;
align-items: center;
justify-content: center;
gap: 20px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: #1e293b;
}
.footerLink {
display: inline-flex;
align-items: center;
gap: 5px;
color: inherit;
text-decoration: none;
transition: color 0.2s;
}
.footerLink:hover {
color: #475569;
}
/* ── Alert messages ── */
.alertError {
margin-top: 14px;
display: flex;
align-items: flex-start;
gap: 10px;
border-radius: 12px;
border: 1px solid rgba(239, 68, 68, 0.2);
background: rgba(239, 68, 68, 0.07);
padding: 12px 14px;
font-size: 12px;
line-height: 1.5;
color: #f87171;
}
.alertInfo {
margin-top: 14px;
display: flex;
align-items: flex-start;
gap: 10px;
border-radius: 12px;
border: 1px solid rgba(52, 211, 153, 0.2);
background: rgba(52, 211, 153, 0.07);
padding: 12px 14px;
font-size: 12px;
line-height: 1.5;
color: #6ee7b7;
}
.txLink {
margin-top: 6px;
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 11px;
color: #93c5fd;
text-decoration: none;
}
.txLink:hover {
color: #bfdbfe;
text-decoration: underline;
}
.alertIconBox {
display: flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
flex-shrink: 0;
border-radius: 50%;
margin-top: 1px;
}
.alertIconError {
background: rgba(239, 68, 68, 0.2);
color: #f87171;
}
.alertIconInfo {
background: rgba(52, 211, 153, 0.2);
color: #6ee7b7;
}
/* ── Chip for unavail ── */
.unavailChip {
display: inline-flex;
align-items: center;
gap: 6px;
margin-bottom: 10px;
}
.unavailChipIcon {
display: flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border-radius: 7px;
background: rgba(255, 255, 255, 0.05);
color: #334155;
transition:
background 0.2s,
color 0.2s;
}
.pointsUnavailableCard:hover .unavailChipIcon {
background: rgba(255, 255, 255, 0.08);
color: #475569;
}
.unavailChipLabel {
font-size: 10px;
font-weight: 800;
letter-spacing: 0.13em;
text-transform: uppercase;
color: #334155;
}
@@ -0,0 +1,407 @@
"use client";
import Link from "next/link";
import {
ArrowRight,
BellRing,
CheckCircle2,
Coins,
Crown,
ExternalLink,
Loader2,
Lock,
MessageSquare,
Radar,
Send,
Sparkles,
TrendingUp,
Wallet,
X,
Zap,
} from "lucide-react";
import s from "./UnlockProOverlay.module.css";
const DEFAULT_FAQ_HREF = "/subscription-help";
const DEFAULT_TELEGRAM_GROUP_URL = "https://t.me/+nMG7SjziUKYyZmM1";
export type UnlockProBilling = {
pointsEnabled: boolean;
isEligible: boolean;
pointsUsed: number;
discountAmount: number;
finalPrice: number;
maxDiscountUsd: number;
pointsPerUsd: number;
};
type UnlockProOverlayProps = {
points: number;
planPriceUsd: number;
usePoints: boolean;
billing: UnlockProBilling;
onToggleUsePoints: () => void;
onPay: () => void;
onClose?: () => void;
payBusy?: boolean;
payLabel?: string;
locale?: "zh-CN" | "en-US";
errorText?: string;
infoText?: string;
faqHref?: string;
telegramGroupUrl?: string;
txHash?: string;
chainId?: number;
paymentTokenLabel?: string;
};
const FEATURES = {
"zh-CN": [
"今日日内机场报文规则分析(含高温时段)",
"历史对账 + 未来日期分析",
"全平台智能气象推送",
],
"en-US": [
"Intraday METAR rule-based analysis with peak-time window",
"Historical reconciliation + future-date analysis",
"Cross-platform alerts",
],
};
export function UnlockProOverlay({
points,
planPriceUsd,
usePoints,
billing,
onToggleUsePoints,
onPay,
onClose,
payBusy = false,
payLabel,
locale = "zh-CN",
errorText,
infoText,
faqHref = DEFAULT_FAQ_HREF,
telegramGroupUrl = DEFAULT_TELEGRAM_GROUP_URL,
txHash,
chainId = 137,
paymentTokenLabel,
}: UnlockProOverlayProps) {
const isEn = locale === "en-US";
const canUsePoints = billing.pointsEnabled && billing.isEligible;
const featureList = FEATURES[locale] ?? FEATURES["zh-CN"];
const finalPayLabel =
payLabel || (isEn ? "Subscribe & Activate" : "立即订阅并激活服务");
const maxDiscountUsdInt = Math.max(1, Math.floor(billing.maxDiscountUsd || 0));
const maxPointsForFullDiscount = Math.max(
billing.pointsPerUsd,
billing.pointsPerUsd * maxDiscountUsdInt,
);
const redeemableUsdNow = billing.pointsEnabled
? Math.min(maxDiscountUsdInt, Math.floor(points / Math.max(1, billing.pointsPerUsd)))
: 0;
const progressPct = billing.pointsEnabled
? Math.min(100, Math.round((points / maxPointsForFullDiscount) * 100))
: 0;
const resolvedTelegramGroupUrl = String(telegramGroupUrl || "").trim();
const txHref =
txHash && txHash.startsWith("0x")
? `${chainId === 137 ? "https://polygonscan.com" : "https://etherscan.io"}/tx/${txHash}`
: "";
return (
<div className={s.modal}>
{/* Ambient glows */}
<div className={s.glowLeft} />
<div className={s.glowRight} />
<div className={s.topLine} />
{/* Close button */}
{onClose && (
<button
onClick={onClose}
className={s.closeBtn}
title={isEn ? "Close" : "关闭"}
>
<X size={15} />
</button>
)}
{/* ── Header ── */}
<div className={s.header}>
<div className={s.badge}>
<Crown size={12} style={{ color: "#fbbf24" }} />
<span className={s.badgeText}>Pro</span>
</div>
<h2 className={s.title}>
{isEn ? "Unlock PolyWeather Pro" : "解锁 PolyWeather Pro"}
</h2>
<p className={s.subtitle}>
{isEn
? "High-precision weather intelligence, delivered everywhere."
: "全球最精准的高精度气象推送,全平台覆盖"}
</p>
</div>
{/* ── Cards ── */}
<div className={s.grid}>
{/* Left: Plan card */}
<div className={s.planCard}>
<span className={s.planChip}>
<Zap size={10} />
Standard Pro
</span>
<div
style={{
display: "flex",
alignItems: "baseline",
gap: 4,
marginTop: 12,
}}
>
<span className={s.price}>${planPriceUsd.toFixed(2)}</span>
<span className={s.priceSuffix}>/ {isEn ? "mo" : "月"}</span>
</div>
<ul className={s.featureList}>
{featureList.map((item, i) => (
<li key={i} className={s.featureItem}>
<span className={s.featureIcon}>
<CheckCircle2 size={11} />
</span>
{item}
</li>
))}
</ul>
</div>
{/* Right: Points card */}
{canUsePoints ? (
<div
className={`${s.pointsCard} ${usePoints ? s.pointsCardActive : ""}`}
>
{/* Header row */}
<div className={s.pointsHeader}>
<div className={s.pointsLabelRow}>
<div
className={`${s.pointsIconBox} ${usePoints ? s.pointsIconBoxActive : ""}`}
>
<Coins size={13} />
</div>
<span
className={`${s.pointsLabel} ${usePoints ? s.pointsLabelActive : ""}`}
>
{isEn ? "Points Credit" : "积分抵扣"}
</span>
</div>
<button
onClick={onToggleUsePoints}
className={`${s.toggle} ${usePoints ? s.toggleActive : ""}`}
title={isEn ? "Toggle points" : "切换积分"}
>
<div
className={`${s.toggleThumb} ${usePoints ? s.toggleThumbActive : ""}`}
/>
</button>
</div>
{/* Discount amount */}
<div style={{ display: "flex", alignItems: "baseline" }}>
<span
className={`${s.discount} ${usePoints ? s.discountActive : ""}`}
>
-${billing.discountAmount.toFixed(2)}
</span>
<span className={s.discountSuffix}>off</span>
</div>
<p className={s.pointsNote}>
{usePoints
? isEn
? `Using ${billing.pointsUsed} pts · saves $${billing.discountAmount.toFixed(2)}`
: `已消耗 ${billing.pointsUsed} 积分 · 省 $${billing.discountAmount.toFixed(2)}`
: isEn
? `Toggle to save up to $${billing.maxDiscountUsd.toFixed(2)}`
: `开启可最多抵扣 $${billing.maxDiscountUsd.toFixed(2)}`}
</p>
<p className={s.pointsNote}>
{isEn
? `Now redeemable: $${redeemableUsdNow.toFixed(0)} / $${maxDiscountUsdInt.toFixed(0)}`
: `当前可抵:${redeemableUsdNow.toFixed(0)}U / ${maxDiscountUsdInt.toFixed(0)}U`}
</p>
<div className={s.pointsBalance}>
<Sparkles size={11} />
<span>
{isEn ? "Balance:" : "当前积分:"}{" "}
<span
className={`${s.balanceNum} ${usePoints ? s.balanceNumActive : ""}`}
>
{points}
</span>
</span>
</div>
</div>
) : (
/* Not eligible */
<div className={s.pointsUnavailableCard}>
<div className={s.unavailChip}>
<div className={s.unavailChipIcon}>
<Coins size={12} />
</div>
<span className={s.unavailChipLabel}>
{!billing.pointsEnabled
? isEn
? "Points Disabled"
: "积分未开启"
: isEn
? "Insufficient Points"
: "积分不足"}
</span>
</div>
<h4 className={s.unavailTitle}>
{isEn ? "Earn Points & Save" : "赚取积分,抵扣订阅"}
</h4>
<p className={s.unavailDesc}>
{!billing.pointsEnabled
? isEn
? "Points redemption is unavailable for this plan."
: "当前套餐暂不支持积分抵扣。"
: isEn
? `Starts at ${billing.pointsPerUsd} pts, ${billing.pointsPerUsd} pts per $1, up to $${maxDiscountUsdInt}. You have: ${points}`
: `${billing.pointsPerUsd} 分起兑,每 ${billing.pointsPerUsd} 分抵 1U,最多抵 ${maxDiscountUsdInt}U。当前 ${points}`}
</p>
{billing.pointsEnabled && (
<div className={s.progressWrap}>
<div className={s.progressHeader}>
<span>
{points} / {maxPointsForFullDiscount}
</span>
<span>
{isEn
? `$${redeemableUsdNow.toFixed(0)} / $${maxDiscountUsdInt.toFixed(0)}`
: `${redeemableUsdNow.toFixed(0)}U / ${maxDiscountUsdInt.toFixed(0)}U`}
</span>
</div>
<div className={s.progressTrack}>
<div
className={s.progressFill}
style={{ width: `${progressPct}%` }}
/>
</div>
</div>
)}
<div style={{ marginTop: "auto", paddingTop: 16 }}>
{resolvedTelegramGroupUrl ? (
<Link
href={resolvedTelegramGroupUrl}
target="_blank"
className={s.unavailCta}
>
<MessageSquare size={12} />
{isEn
? "Join community to earn points"
: "加入社群即可赚取积分"}
<ArrowRight size={11} />
</Link>
) : (
<span className={s.unavailCta} style={{ cursor: "default" }}>
<MessageSquare size={12} />
{isEn
? "Join community to earn points"
: "加入社群即可赚取积分"}
</span>
)}
</div>
</div>
)}
</div>
{/* ── Payment summary ── */}
<div className={s.summaryBox}>
<div className={s.summaryRow}>
<div>
<p className={s.summaryLabel}>
{isEn ? "Total Due Today" : "今日应付"}
</p>
{paymentTokenLabel && (
<p className={s.summaryOriginal}>
{isEn ? "Token" : "支付币种"}: {paymentTokenLabel}
</p>
)}
{billing.discountAmount > 0 && usePoints && (
<p className={s.summaryOriginal}>
${planPriceUsd.toFixed(2)} USD
</p>
)}
</div>
<div className={s.summaryAmount}>
<span className={s.summaryPrice}>
${billing.finalPrice.toFixed(2)}
</span>
<span className={s.summaryUnit}>USD</span>
</div>
</div>
<div className={s.summaryDivider} />
<div className={s.ctaWrap}>
<button onClick={onPay} disabled={payBusy} className={s.ctaBtn}>
{payBusy ? (
<Loader2 size={18} className="animate-spin" />
) : (
<>
<Wallet size={17} />
{finalPayLabel}
<ArrowRight size={17} />
</>
)}
</button>
</div>
</div>
{/* ── Footer ── */}
<div className={s.footer}>
<span style={{ display: "inline-flex", alignItems: "center", gap: 5 }}>
<Lock size={11} />
{isEn ? "Secure Payment" : "安全付款"}
</span>
<span style={{ color: "#0f172a" }}>·</span>
<Link href={faqHref} className={s.footerLink}>
<BellRing size={11} />
{isEn ? "Subscription FAQ" : "订阅说明"}
</Link>
</div>
{/* ── Error / Info ── */}
{errorText && (
<div className={s.alertError}>
<div className={`${s.alertIconBox} ${s.alertIconError}`}>
<X size={10} />
</div>
{errorText}
</div>
)}
{infoText && (
<div className={s.alertInfo}>
<div className={`${s.alertIconBox} ${s.alertIconInfo}`}>
<CheckCircle2 size={10} />
</div>
<div>
<div>{infoText}</div>
{txHref && (
<Link href={txHref} target="_blank" className={s.txLink}>
<ExternalLink size={11} />
</Link>
)}
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,254 @@
"use client";
import React from "react";
import {
BarChart2,
Target,
ShieldAlert,
Zap,
Info,
Activity,
ArrowUpRight,
} from "lucide-react";
import { Badge } from "../ui/badge";
import { Sparkline } from "./Sparkline";
import { CityDetail } from "../../lib/types";
interface AnalyticsPanelProps {
data: CityDetail;
t?: any; // Optional translations
}
export function AnalyticsPanel({
data,
t = {}, // Default empty for now, can be expanded via context or props
}: AnalyticsPanelProps) {
const { overview, market_scan, models, ai_analysis } = data;
const modelEntries = Object.entries(models)
.filter(([_, v]) => v !== undefined && v !== null)
.map(([label, value]) => ({ label, value: value as number }))
.sort((a, b) => b.value - a.value);
const signalColor =
market_scan.signal_label === "BUY YES"
? "border-emerald-500/30 bg-emerald-500/5 text-emerald-400"
: market_scan.signal_label === "BUY NO"
? "border-rose-500/30 bg-rose-500/5 text-rose-400"
: "border-zinc-800 bg-zinc-900/50 text-zinc-400";
const signalIconColor =
market_scan.signal_label === "BUY YES"
? "text-emerald-500"
: market_scan.signal_label === "BUY NO"
? "text-rose-500"
: "text-zinc-500";
return (
<div className="flex relative h-full w-[400px] flex-col bg-zinc-950/20 backdrop-blur-sm border-l border-zinc-800/50 overflow-hidden">
{/* Header Stat Area */}
<div className="p-4 border-b border-zinc-800 flex flex-col gap-4">
<div className="flex items-center justify-between">
<h2 className="text-[10px] font-black uppercase tracking-[0.2em] text-zinc-500">
{t.marketAnalysis || "MARKET ANALYSIS"}
</h2>
<Badge className="bg-cyan-500/10 text-cyan-400 hover:bg-cyan-500/20 border-cyan-500/30 text-[9px] h-5">
{t.realTime || "LIVE UPLINK"}
</Badge>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="rounded-lg border border-zinc-800 bg-zinc-900/30 p-3 ring-1 ring-inset ring-white/5">
<span className="text-[9px] font-bold text-zinc-500 uppercase tracking-tighter">
{t.liveMetar || "REAL-TIME METAR"}
</span>
<div className="mt-1 flex items-baseline gap-1">
<span className="text-xl font-black text-white">
{overview.current_temp?.toFixed(1) || "--"}
</span>
<span className="text-[10px] font-bold text-zinc-500">
{overview.temp_symbol}
</span>
</div>
</div>
<div className="rounded-lg border border-zinc-800 bg-zinc-900/30 p-3 ring-1 ring-inset ring-white/5">
<span className="text-[9px] font-bold text-zinc-500 uppercase tracking-tighter">
{t.ensembleDeb || "ENSEMBLE DEB"}
</span>
<div className="mt-1 flex items-baseline gap-1">
<span className="text-xl font-black text-cyan-400">
{overview.deb_prediction?.toFixed(1) || "--"}
</span>
<span className="text-[10px] font-bold text-cyan-500">
{overview.temp_symbol}
</span>
</div>
</div>
</div>
{/* Trade Execution Signal */}
<div
className={`rounded-xl border p-4 transition-all duration-500 ${signalColor}`}
>
<div className="flex items-center justify-between mb-3">
<div className="space-y-1">
<span className="text-[10px] font-bold text-zinc-500 uppercase tracking-tighter">
{t.signalStrategy || "SIGNAL STRATEGY"}
</span>
<div className="flex items-center gap-2">
<Zap
className={`h-4 w-4 ${market_scan.available ? "animate-pulse" : ""} ${signalIconColor}`}
/>
<span className="text-sm font-black text-white italic uppercase tracking-wider">
{market_scan.signal_label}
</span>
</div>
</div>
<div className="text-right">
<span className="text-[10px] font-bold text-zinc-500 uppercase tracking-tighter">
{t.confidence || "CONFIDENCE"}
</span>
<div className="mt-1 text-xs font-black text-white opacity-80 uppercase tracking-widest">
{market_scan.confidence}
</div>
</div>
</div>
<div className="h-10 mt-2 flex items-end">
<Sparkline
data={
market_scan.sparkline.length > 0
? market_scan.sparkline
: [1, 1, 1]
}
width={360}
height={35}
color={
market_scan.signal_label === "BUY YES"
? "#10b981"
: market_scan.signal_label === "BUY NO"
? "#f43f5e"
: "#52525b"
}
/>
</div>
</div>
</div>
{/* Probability & Market Pricing */}
<div className="flex-1 overflow-y-auto p-4 space-y-6 custom-scrollbar pb-20">
<section>
<div className="flex items-center gap-2 mb-3">
<Target className="h-3 w-3 text-violet-400" />
<span className="text-[10px] font-black uppercase tracking-[0.15em] text-zinc-400">
{t.yieldArbitrage || "YIELD ARBITRAGE"}
</span>
</div>
<div className="space-y-4">
<div className="space-y-2">
<div className="flex items-center justify-between text-[10px] uppercase font-bold text-zinc-500">
<span>{t.probComparison || "PROB COMPARISON"}</span>
<span className="text-cyan-400">
{t.predictionGap || "GAP"}:{" "}
{market_scan.edge_percent?.toFixed(1) || "0.0"}%
</span>
</div>
{/* Visual Probability Bar */}
<div className="relative h-4 w-full bg-zinc-900 rounded-sm border border-zinc-800 overflow-hidden">
<div
className="absolute h-full bg-zinc-800 border-r border-zinc-700 transition-all duration-700"
style={{ width: `${(market_scan.market_price || 0) * 100}%` }}
/>
<div
className="absolute h-full bg-violet-500/40 border-r border-violet-400 shadow-[0_0_8px_rgba(167,139,250,0.5)] transition-all duration-1000"
style={{
width: `${(market_scan.model_probability || 0) * 100}%`,
}}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="flex flex-col rounded-md bg-zinc-900/40 px-3 py-2 border border-zinc-800">
<span className="text-[9px] text-zinc-500 uppercase font-black">
{t.marketPriceLabel || "MIDPOINT"}
</span>
<span className="font-mono text-zinc-100 font-bold text-sm">
${market_scan.market_price?.toFixed(2) || "--"}
</span>
</div>
<div className="flex flex-col rounded-md bg-zinc-900/40 px-3 py-2 border border-zinc-800">
<span className="text-[9px] text-violet-500 uppercase font-black">
{t.modelProbLabel || "MODEL PROB"}
</span>
<span className="font-mono text-violet-400 font-bold text-sm">
{((market_scan.model_probability || 0) * 100).toFixed(1)}%
</span>
</div>
</div>
</div>
</section>
<section>
<div className="flex items-center gap-2 mb-4">
<BarChart2 className="h-3 w-3 text-cyan-400" />
<span className="text-[10px] font-black uppercase tracking-[0.15em] text-zinc-400">
{t.multiModelDivergence || "MULTI-MODEL DIVERGENCE"}
</span>
</div>
<div className="space-y-3">
{modelEntries.map((m) => (
<div key={m.label} className="group flex flex-col gap-1 px-1">
<div className="flex justify-between items-center">
<span className="text-[10px] font-bold text-zinc-500 group-hover:text-zinc-300 transition-colors uppercase">
{m.label}
</span>
<span className="text-[10px] font-mono font-bold text-white text-right">
{m.value.toFixed(1)}°
</span>
</div>
<div className="relative h-1 w-full bg-zinc-800/50 rounded-full overflow-hidden">
<div
className={`h-full transition-all duration-500 ${
m.value > 25
? "bg-rose-500 shadow-[0_0_8px_rgba(244,63,94,0.5)]"
: m.value > 15
? "bg-amber-500"
: "bg-cyan-500 shadow-[0_0_8px_rgba(34,211,238,0.5)]"
}`}
style={{ width: `${(m.value / 40) * 100}%` }}
/>
</div>
</div>
))}
</div>
</section>
{ai_analysis && (
<section className="pt-2">
<div className="flex items-center gap-2 mb-3">
<Activity className="h-3 w-3 text-amber-500" />
<span className="text-[10px] font-black uppercase tracking-[0.15em] text-zinc-400">
AI COGNITIVE ANALYSIS
</span>
</div>
<div className="rounded-lg border border-zinc-800 bg-zinc-900/30 p-4 relative overflow-hidden group">
<div className="absolute top-0 right-0 p-2 opacity-20">
<ShieldAlert className="w-8 h-8 text-amber-500" />
</div>
<p className="text-[11px] leading-relaxed text-zinc-400 relative z-10 font-medium">
{ai_analysis}
</p>
</div>
</section>
)}
</div>
{/* Execute Scan Footer */}
<div className="absolute bottom-0 left-0 right-0 p-4 bg-zinc-950/80 backdrop-blur-md border-t border-zinc-800/50">
<button className="w-full rounded-lg bg-cyan-500 py-3 text-[11px] font-black uppercase tracking-widest text-black shadow-[0_0_20px_rgba(34,211,238,0.3)] transition-all hover:bg-cyan-400 hover:scale-[0.98] active:scale-95 disabled:opacity-50 disabled:pointer-events-none">
EXECUTE MARKET RE-SCAN
</button>
</div>
</div>
);
}
+135
View File
@@ -0,0 +1,135 @@
import React from "react";
import { Search, Globe, ChevronRight, Activity } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { CityInfo } from "@/lib/types";
import { Sparkline } from "./Sparkline";
interface CitySidebarProps {
cities: CityInfo[];
activeCity: string;
onSelectCity: (name: string) => void;
t: any;
}
export function CitySidebar({
cities,
activeCity,
onSelectCity,
t,
}: CitySidebarProps) {
return (
<aside className="flex h-full w-[300px] flex-col border-r border-terminal-border bg-terminal/95 transition-all">
{/* Search Header */}
<div className="p-4 border-b border-terminal-border">
<div className="relative group">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-500 transition-colors group-focus-within:text-neon-cyan" />
<input
type="text"
placeholder={t.lookupPlaceholder || "Search Terminal..."}
className="w-full rounded-lg border border-terminal-border bg-slate-900/50 py-2 pl-9 pr-4 text-xs text-white placeholder:text-slate-600 focus:border-neon-cyan/50 focus:outline-none focus:ring-1 focus:ring-neon-cyan/20"
/>
</div>
</div>
{/* Monitor List */}
<div className="flex-1 overflow-y-auto px-2 py-4 custom-scrollbar">
<div className="mb-4 flex items-center justify-between px-3">
<h3 className="text-[10px] font-black uppercase tracking-[0.2em] text-slate-500">
{t.cityMonitor || "MONITORING NODES"}
</h3>
<Badge
variant="outline"
className="h-5 rounded-md border-terminal-border text-[10px] text-slate-400"
>
{cities.length}
</Badge>
</div>
<div className="space-y-1">
{cities.map((city) => {
const isActive =
activeCity.toLowerCase() === city.name.toLowerCase();
return (
<button
key={city.name}
onClick={() => onSelectCity(city.name)}
className={`group relative flex w-full items-center gap-3 rounded-lg px-3 py-2.5 transition-all ${
isActive
? "bg-neon-cyan/10 ring-1 ring-neon-cyan/30"
: "hover:bg-slate-900/50"
}`}
>
{/* Active Indicator Bar */}
{isActive && (
<div className="absolute left-0 top-1/2 h-4 w-1 -translate-y-1/2 rounded-r bg-neon-cyan shadow-[0_0_8px_rgba(34,211,238,0.8)]" />
)}
<div className="flex flex-1 flex-col items-start overflow-hidden">
<div className="flex w-full items-center justify-between">
<span
className={`truncate text-[11px] font-bold uppercase tracking-wider ${isActive ? "text-neon-cyan" : "text-slate-200"}`}
>
{city.display_name}
</span>
<span className="text-[10px] font-medium text-slate-500">
{city.icao}
</span>
</div>
<div className="mt-1 flex items-center gap-2">
<div
className={`h-1.5 w-1.5 rounded-full ${
city.risk_level === "high"
? "bg-rose-500 shadow-[0_0_4px_rgba(244,63,94,0.6)]"
: city.risk_level === "medium"
? "bg-amber-500"
: "bg-emerald-500"
}`}
/>
<span className="truncate text-[10px] text-slate-500 font-medium">
{city.airport.replace("Airport", "Intl")}
</span>
</div>
</div>
<div className="flex flex-col items-end gap-1 px-1">
<Sparkline
data={[10, 12, 11, 13, 12]}
width={40}
height={15}
strokeWidth={1.5}
color="#475569"
/>
</div>
<div className="flex flex-col items-end gap-1">
<ChevronRight
className={`h-3 w-3 transition-transform ${isActive ? "translate-x-0.5 text-neon-cyan" : "text-slate-700 opacity-0 group-hover:opacity-100"}`}
/>
</div>
</button>
);
})}
</div>
</div>
{/* Terminal Status */}
<div className="border-t border-terminal-border bg-slate-950 p-4">
<div className="flex items-center justify-between mb-3">
<span className="text-[10px] font-black uppercase tracking-widest text-slate-500">
UPLINK STATUS
</span>
<div className="flex items-center gap-1.5">
<div className="h-1.5 w-1.5 animate-pulse rounded-full bg-emerald-500 shadow-[0_0_4px_rgba(16,185,129,0.8)]" />
<span className="text-[10px] font-bold text-emerald-500">LIVE</span>
</div>
</div>
<div className="rounded-md border border-terminal-border bg-slate-900/50 p-2">
<div className="flex items-center gap-2 text-[9px] font-mono text-slate-400">
<Activity className="h-3 w-3 text-neon-cyan" />
<span>LATENCY: 12ms</span>
</div>
</div>
</div>
</aside>
);
}
@@ -0,0 +1,81 @@
"use client";
import React from "react";
interface SparklineProps {
data: number[];
color?: string;
height?: number;
width?: number;
strokeWidth?: number;
className?: string;
}
export function Sparkline({
data,
color = "#22d3ee",
height = 40,
width = 120,
strokeWidth = 2,
className = "",
}: SparklineProps) {
if (!data || data.length < 2) return <div style={{ width, height }} />;
const min = Math.min(...data);
const max = Math.max(...data);
const range = max - min || 1;
const points = data
.map((val, i) => {
const x = (i / (data.length - 1)) * width;
const y = height - ((val - min) / range) * height;
return `${x},${y}`;
})
.join(" ");
return (
<div className={className}>
<svg
width={width}
height={height}
viewBox={`0 0 ${width} ${height}`}
className="overflow-visible"
>
<defs>
<filter id="glow" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur stdDeviation="1.5" result="blur" />
<feComposite in="SourceGraphic" in2="blur" operator="over" />
</filter>
</defs>
<polyline
fill="none"
stroke={color}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
points={points}
filter="url(#glow)"
style={{ opacity: 0.8 }}
/>
{/* Fill Area */}
<path
d={`M 0 ${height} L ${points} L ${width} ${height} Z`}
fill={`url(#gradient-${color.replace("#", "")})`}
style={{ opacity: 0.1 }}
/>
<defs>
<linearGradient
id={`gradient-${color.replace("#", "")}`}
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop offset="0%" stopColor={color} />
<stop offset="100%" stopColor="transparent" />
</linearGradient>
</defs>
</svg>
</div>
);
}
@@ -0,0 +1,307 @@
"use client";
import React, { useState } from "react";
import { CitySidebar } from "./Sidebar";
import { AnalyticsPanel } from "./AnalyticsPanel";
import {
RefreshCw,
LayoutGrid,
Maximize2,
Layers,
Languages,
Sun,
Moon,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
interface TerminalDashboardProps {
cities: any[];
activeCity: string;
onSelectCity: (name: string) => void;
cityData: any;
marketData: any;
officialData: any;
isLoading: boolean;
refresh: () => void;
lang: any;
setLang: (lang: any) => void;
theme: string;
toggleTheme: () => void;
t: any;
children: React.ReactNode; // For the Map
}
function NavLink({
href,
label,
active = false,
}: {
href: string;
label: string;
active?: boolean;
}) {
return (
<a
href={href}
className={`text-[10px] font-black uppercase tracking-widest transition-colors ${
active
? "text-neon-cyan shadow-[0_0_10px_rgba(34,211,238,0.3)]"
: "text-slate-500 hover:text-white"
}`}
>
{label}
</a>
);
}
export function TerminalDashboard({
cities,
activeCity,
onSelectCity,
cityData,
marketData,
officialData,
isLoading,
refresh,
lang,
setLang,
theme,
toggleTheme,
t,
children,
}: TerminalDashboardProps) {
const currentTemp = cityData?.current?.temp ?? null;
const debPrediction = cityData?.deb?.prediction ?? null;
const marketPrice = marketData?.markets?.[0]?.price ?? 0.62; // Placeholder
const modelEntries = Object.entries(cityData?.multi_model ?? {})
.map(([label, value]) => ({ label, value: value as number }))
.filter((e) => typeof e.value === "number");
return (
<div className="flex h-screen w-full flex-col overflow-hidden bg-terminal font-sans text-slate-200 antialiased">
{/* Top Global Ticker / Nav */}
<div className="flex h-14 items-center justify-between border-b border-terminal-border bg-slate-950/50 px-6 backdrop-blur-md">
<div className="flex items-center gap-6">
<NavLink href="#" label={t.marketScan} active />
<NavLink href="#" label="FORENSICS" />
<NavLink href="#" label="ARBITRAGE" />
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2 rounded-full bg-slate-900 px-3 py-1 border border-terminal-border">
<span className="text-[10px] font-bold text-slate-500 uppercase tracking-tighter">
{t.node || "Node"}:
</span>
<span className="text-[10px] font-mono font-bold text-neon-cyan tracking-widest">
CLOUD-ALPHA-01
</span>
</div>
<button
onClick={refresh}
className="flex items-center gap-2 rounded-lg px-3 py-1.5 text-[10px] font-bold text-slate-400 hover:bg-white/5 hover:text-white transition-all border border-transparent hover:border-terminal-border"
>
<RefreshCw
className={`h-3 w-3 ${isLoading ? "animate-spin" : ""}`}
/>
<span className="uppercase tracking-widest">{t.refresh}</span>
</button>
</div>
<div className="flex items-center gap-3">
<div className="flex items-center border-r border-white/10 pr-3 mr-1 gap-1">
<button
onClick={() => setLang(lang === "en" ? "zh" : "en")}
className="flex h-8 w-8 items-center justify-center rounded-md text-slate-400 hover:bg-white/5 hover:text-neon-cyan transition-all"
title={lang === "en" ? "Switch to Chinese" : "切换为英文"}
>
<Languages className="h-4 w-4" />
</button>
<button
onClick={toggleTheme}
className="flex h-8 w-8 items-center justify-center rounded-md text-slate-400 hover:bg-white/5 hover:text-neon-cyan transition-all"
>
{theme === "dark" ? (
<Sun className="h-4 w-4" />
) : (
<Moon className="h-4 w-4" />
)}
</button>
</div>
<Badge
variant="outline"
className="h-6 border-terminal-border bg-slate-900/50 text-[10px] font-mono text-slate-400"
>
NODE: CLOUD-ALPHA-01
</Badge>
<button
onClick={refresh}
className="flex h-8 items-center gap-2 rounded-md border border-terminal-border bg-slate-900/50 px-3 transition-all hover:bg-slate-800"
>
<RefreshCw
className={`h-3 w-3 text-slate-400 ${isLoading ? "animate-spin" : ""}`}
/>
<span className="text-[10px] font-bold text-slate-300">
REFRESH
</span>
</button>
</div>
</div>
{/* Main Container */}
<div className="flex flex-1 overflow-hidden">
{/* Left: Component List */}
<CitySidebar
cities={cities}
activeCity={activeCity}
onSelectCity={onSelectCity}
t={t}
/>
{/* Center: Interactive Map Area */}
<div className="relative flex-1 bg-slate-950 overflow-hidden flex flex-col">
{/* Map UI Overlay */}
<div className="absolute left-4 top-4 z-10 flex flex-col gap-2">
<div className="rounded-lg border border-terminal-border bg-terminal/80 p-2 backdrop-blur-md shadow-2xl">
<div className="flex flex-col gap-1">
<span className="text-[9px] font-bold text-slate-500 uppercase">
Current Focus
</span>
<span className="text-xs font-black text-white uppercase tracking-widest">
{activeCity}
</span>
</div>
</div>
</div>
<div className="absolute right-4 top-4 z-10 flex flex-col gap-2">
<button className="rounded-md border border-terminal-border bg-terminal/80 p-2 text-slate-400 hover:text-white backdrop-blur-md">
<Layers className="h-4 w-4" />
</button>
<button className="rounded-md border border-terminal-border bg-terminal/80 p-2 text-slate-400 hover:text-white backdrop-blur-md">
<Maximize2 className="h-4 w-4" />
</button>
</div>
{/* The Map */}
<div className="flex-1 h-full min-h-0 relative bg-zinc-900/10">
{children}
</div>
{/* Bottom Map Info Bar */}
<div className="absolute bottom-4 left-4 right-4 z-10 flex items-center justify-between pointer-events-none">
<div className="rounded-md border border-terminal-border bg-terminal/80 px-3 py-1.5 backdrop-blur-md pointer-events-auto">
<span className="text-[9px] font-bold text-slate-500 uppercase tracking-widest">
Projection: Mercator · Layer: Dynamic Thermal Ensemble
</span>
</div>
<div className="flex gap-2 pointer-events-auto">
<Badge className="bg-emerald-500/10 text-emerald-500 border-emerald-500/20">
STABLE VIBE
</Badge>
</div>
</div>
</div>
{/* Right: Intelligence Panel */}
<AnalyticsPanel
data={
{
city: activeCity,
fetched_at: cityData?.updated_at || "",
overview: {
name: activeCity,
display_name: cityData?.display_name || activeCity,
icao: officialData?.aviation_weather?.icao || "",
airport: cityData?.risk?.airport || "",
lat: cityData?.lat || 0,
lon: cityData?.lon || 0,
local_time: cityData?.local_time || "",
local_date: cityData?.local_date || "",
temp_symbol: cityData?.temp_symbol || "°C",
current_temp: currentTemp,
deb_prediction: debPrediction,
risk_level: cityData?.risk?.level || "low",
risk_warning: cityData?.risk?.warning || "",
updated_at: cityData?.updated_at || "",
},
official: {
available: !!officialData?.aviation_weather?.available,
metar: officialData?.aviation_weather?.observation,
weather_gov: officialData?.weather_gov || {},
mgm: cityData?.mgm || {},
mgm_nearby: [],
},
timeseries: {
metar_recent_obs: [],
metar_today_obs: [],
hourly: {},
mgm_hourly: [],
forecast_daily: cityData?.forecast?.multi_day || [],
},
models: cityData?.multi_model || {},
probabilities: cityData?.probabilities || {
mu: null,
distribution: [],
},
market_scan: {
available: !!marketData?.markets?.length,
reason: null,
primary_market: marketData?.markets?.[0] || null,
selected_date: marketData?.target_date || null,
selected_condition_id: null,
selected_slug: null,
temperature_bucket: null,
model_probability: null,
market_price: marketPrice,
edge_percent: null,
signal_label: "MONITOR",
confidence: "low",
yes_token: null,
no_token: null,
yes_buy: null,
yes_sell: null,
no_buy: null,
no_sell: null,
last_trade_price: null,
liquidity: null,
volume: null,
sparkline: [],
recent_trades: [],
websocket: marketData?.websocket || {},
},
risk: cityData?.risk,
ai_analysis: cityData?.ai_analysis || "",
errors: {},
} as any
}
t={t}
/>
</div>
{/* Footer Ticker */}
<footer className="h-8 border-t border-terminal-border bg-terminal-bg flex items-center px-4">
<div className="flex items-center gap-3 overflow-hidden">
<span className="text-[10px] font-bold text-neon-cyan uppercase flex-shrink-0">
TICKER:
</span>
<div className="flex items-center gap-6 animate-marquee whitespace-nowrap">
{cities.slice(0, 10).map((c) => (
<div key={c.name} className="flex items-center gap-2">
<span className="text-[10px] font-bold text-slate-300">
{c.display_name}
</span>
<span className="text-[10px] font-mono text-emerald-500">
24.5°
</span>
<span className="text-[9px] text-slate-600">LTAC</span>
</div>
))}
</div>
</div>
</footer>
</div>
);
}
+43
View File
@@ -0,0 +1,43 @@
"use client";
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "../../lib/utils";
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-1 text-[10px] font-black uppercase tracking-[0.16em] transition-colors",
{
variants: {
variant: {
default:
"border-cyan-500/30 bg-cyan-500/10 text-cyan-600 dark:text-cyan-400",
secondary:
"border-slate-200 bg-slate-100 text-slate-700 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-300",
success:
"border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400",
danger:
"border-rose-500/30 bg-rose-500/10 text-rose-600 dark:text-rose-400",
warning:
"border-amber-500/30 bg-amber-500/10 text-amber-600 dark:text-amber-400",
outline: "border-border bg-transparent text-foreground",
},
},
defaultVariants: {
variant: "default",
},
},
);
export interface BadgeProps
extends
React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
);
}
export { Badge, badgeVariants };
+57
View File
@@ -0,0 +1,57 @@
"use client";
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-xl text-sm font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-500/40 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default:
"bg-cyan-500 text-white hover:bg-cyan-400 shadow-[0_0_0_1px_rgba(34,211,238,0.2)]",
secondary:
"bg-slate-100 text-slate-900 hover:bg-slate-200 dark:bg-slate-900 dark:text-slate-100 dark:hover:bg-slate-800",
ghost:
"text-slate-500 hover:text-slate-900 hover:bg-slate-100 dark:text-slate-400 dark:hover:text-slate-100 dark:hover:bg-slate-900",
outline:
"border border-border bg-transparent hover:bg-slate-100 dark:hover:bg-slate-900",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-8 rounded-lg px-3 text-xs",
lg: "h-11 px-5",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
},
);
Button.displayName = "Button";
export { Button, buttonVariants };
+78
View File
@@ -0,0 +1,78 @@
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-2xl border border-border/80 bg-card/80 text-card-foreground shadow-xl backdrop-blur-xl",
className,
)}
{...props}
/>
));
Card.displayName = "Card";
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-5", className)}
{...props}
/>
));
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn("text-sm font-black uppercase tracking-[0.16em]", className)}
{...props}
/>
));
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-slate-500 dark:text-slate-400", className)}
{...props}
/>
));
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-5 pt-0", className)} {...props} />
));
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-5 pt-0", className)}
{...props}
/>
));
CardFooter.displayName = "CardFooter";
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
+24
View File
@@ -0,0 +1,24 @@
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-11 w-full rounded-xl border border-border bg-background/70 px-4 py-2 text-sm text-foreground shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-slate-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-500/30 disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
ref={ref}
{...props}
/>
);
},
);
Input.displayName = "Input";
export { Input };
+24
View File
@@ -0,0 +1,24 @@
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
function Separator({
className,
orientation = "horizontal",
}: React.HTMLAttributes<HTMLDivElement> & {
orientation?: "horizontal" | "vertical";
}) {
return (
<div
className={cn(
"shrink-0 bg-border/80",
orientation === "horizontal" ? "h-px w-full" : "h-full w-px",
className,
)}
/>
);
}
export { Separator };
+15
View File
@@ -0,0 +1,15 @@
import { cn } from "@/lib/utils";
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("animate-pulse rounded-md bg-zinc-800/50", className)}
{...props}
/>
);
}
export { Skeleton };
+47
View File
@@ -0,0 +1,47 @@
"use client";
import { useEffect, useRef } from "react";
import type { Chart as ChartInstance, ChartConfiguration, ChartType } from "chart.js";
export function useChart<TType extends ChartType>(
createConfig: () => ChartConfiguration<TType>,
dependencies: React.DependencyList,
) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const chartRef = useRef<ChartInstance<TType> | null>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
let disposed = false;
const setupChart = async () => {
const { Chart } = await import("chart.js/auto");
if (disposed) return;
const config = createConfig();
if (chartRef.current) {
chartRef.current.destroy();
chartRef.current = null;
}
chartRef.current = new Chart(canvas, config);
};
void setupChart();
return () => {
disposed = true;
chartRef.current?.destroy();
chartRef.current = null;
};
}, dependencies);
useEffect(() => {
return () => {
chartRef.current?.destroy();
chartRef.current = null;
};
}, []);
return canvasRef;
}
+783
View File
@@ -0,0 +1,783 @@
"use client";
import {
createContext,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import {
dashboardClient,
getCityRevision,
toCitySummary,
} from "@/lib/dashboard-client";
import {
CityDetail,
CityListItem,
CitySummary,
DashboardState,
HistoryPoint,
HistoryState,
LoadingState,
MarketScan,
ProAccessState,
} from "@/lib/dashboard-types";
interface DashboardStoreValue extends DashboardState {
closeFutureModal: () => void;
closeGuide: () => void;
closeHistory: () => void;
closePanel: () => void;
ensureCityDetail: (cityName: string, force?: boolean) => Promise<CityDetail>;
futureModalDate: string | null;
isGuideOpen: boolean;
loadCities: () => Promise<void>;
openFutureModal: (dateStr: string, forceRefresh?: boolean) => void;
openGuide: () => void;
openHistory: () => Promise<void>;
openTodayModal: (forceRefresh?: boolean) => Promise<void>;
registerMapStopMotion: (stopMotion: () => void) => void;
refreshAll: () => Promise<void>;
refreshProAccess: () => Promise<void>;
refreshSelectedCity: () => Promise<void>;
selectedMarketScan: MarketScan | null;
selectedDetail: CityDetail | null;
selectCity: (cityName: string) => Promise<void>;
setForecastDate: (dateStr: string | null) => void;
marketScanByCityName: Record<string, MarketScan>;
}
const DashboardStoreContext = createContext<DashboardStoreValue | null>(null);
function getInitialLoadingState(): LoadingState {
return {
cities: false,
cityDetail: false,
history: false,
refresh: false,
marketScan: false,
};
}
function getInitialHistoryState(): HistoryState {
return {
dataByCity: {},
error: null,
isOpen: false,
loading: false,
};
}
function getInitialProAccessState(): ProAccessState {
return {
loading: true,
authenticated: false,
subscriptionActive: false,
points: 0,
error: null,
};
}
const AI_EMPTY_PATTERNS = [
/暂无\s*AI\s*分析/i,
/当前以结构化气象与模型数据为主/i,
/No\s*AI\s*analysis\s*available/i,
/Structured\s+meteorological\s+and\s+model\s+data/i,
];
function normalizeText(value: unknown) {
return typeof value === "string" ? value.trim() : "";
}
function extractAiPayload(analysis: CityDetail["ai_analysis"]) {
if (!analysis) {
return {
bullets: [] as string[],
summary: "",
};
}
if (typeof analysis === "string") {
return {
bullets: [] as string[],
summary: normalizeText(analysis),
};
}
const summary =
normalizeText(analysis.summary) ||
normalizeText(analysis.text) ||
normalizeText(analysis.message);
const bulletsSource = Array.isArray(analysis.highlights)
? analysis.highlights
: Array.isArray(analysis.points)
? analysis.points
: [];
return {
bullets: bulletsSource.map((item) => normalizeText(item)).filter(Boolean),
summary,
};
}
function hasMeaningfulAiAnalysis(analysis: CityDetail["ai_analysis"]) {
const parsed = extractAiPayload(analysis);
const hasBullets = parsed.bullets.length > 0;
const hasSummary =
Boolean(parsed.summary) &&
!AI_EMPTY_PATTERNS.some((pattern) => pattern.test(parsed.summary));
return hasBullets || hasSummary;
}
function normalizeMetarSignature(detail?: CityDetail) {
if (!detail) return "";
const metar = normalizeText(detail.current?.raw_metar)
.replace(/\s+/g, " ")
.toUpperCase();
const obsTime = normalizeText(detail.current?.obs_time);
return [metar, obsTime].filter(Boolean).join("|");
}
function mergeAiAnalysisIfStable(
previousDetail: CityDetail | undefined,
nextDetail: CityDetail,
) {
if (!previousDetail) return nextDetail;
if (hasMeaningfulAiAnalysis(nextDetail.ai_analysis)) return nextDetail;
if (!hasMeaningfulAiAnalysis(previousDetail.ai_analysis)) return nextDetail;
const prevTemp = Number(previousDetail.current?.temp);
const nextTemp = Number(nextDetail.current?.temp);
const tempUnchanged =
Number.isFinite(prevTemp) &&
Number.isFinite(nextTemp) &&
prevTemp === nextTemp;
const prevMetar = normalizeMetarSignature(previousDetail);
const nextMetar = normalizeMetarSignature(nextDetail);
const metarUnchanged =
Boolean(prevMetar) && Boolean(nextMetar) && prevMetar === nextMetar;
if (!tempUnchanged && !metarUnchanged) {
return nextDetail;
}
return {
...nextDetail,
ai_analysis: previousDetail.ai_analysis,
};
}
function getMarketScanCacheKey(cityName: string, targetDate?: string | null) {
const normalizedDate = String(targetDate || "").trim() || "local";
return `${cityName}::${normalizedDate}`;
}
const SELECTED_CITY_STORAGE_KEY = "polyWeather_selected_city_v1";
const BACKGROUND_SUMMARY_REFRESH_MS = 30_000;
export function DashboardStoreProvider({
children,
}: {
children: React.ReactNode;
}) {
const initialCache = dashboardClient.readCityDetailCacheBundle();
const [cities, setCities] = useState<CityListItem[]>([]);
const [cityDetailsByName, setCityDetailsByName] = useState<
Record<string, CityDetail>
>(() => initialCache.details);
const [citySummariesByName, setCitySummariesByName] = useState<
Record<string, CitySummary>
>(() =>
Object.fromEntries(
Object.entries(initialCache.details).map(([cityName, detail]) => [
cityName,
toCitySummary(detail),
]),
),
);
const [cityDetailMetaByName, setCityDetailMetaByName] = useState<
Record<string, { cachedAt: number; revision: string }>
>(() => initialCache.meta);
const [marketScanByCityName, setMarketScanByCityName] = useState<
Record<string, MarketScan>
>({});
const [selectedCity, setSelectedCity] = useState<string | null>(null);
const [isPanelOpen, setIsPanelOpen] = useState(false);
const [selectedForecastDate, setSelectedForecastDate] = useState<
string | null
>(null);
const [futureModalDate, setFutureModalDate] = useState<string | null>(null);
const [loadingState, setLoadingState] = useState<LoadingState>(
getInitialLoadingState,
);
const [historyState, setHistoryState] = useState<HistoryState>(
getInitialHistoryState,
);
const [isGuideOpen, setIsGuideOpen] = useState(false);
const [proAccess, setProAccess] = useState<ProAccessState>(
getInitialProAccessState,
);
const mapStopMotionRef = useRef<() => void>(() => {});
const hydratedSelectionRef = useRef(false);
const backgroundSummaryCheckAtRef = useRef<Record<string, number>>({});
const citySummariesRef = useRef<Record<string, CitySummary>>(
Object.fromEntries(
Object.entries(initialCache.details).map(([cityName, detail]) => [
cityName,
toCitySummary(detail),
]),
),
);
const selectedDetail = selectedCity
? cityDetailsByName[selectedCity] || null
: null;
const selectedMarketDate =
futureModalDate ||
selectedForecastDate ||
selectedDetail?.local_date ||
null;
const selectedMarketScanKey = selectedCity
? getMarketScanCacheKey(selectedCity, selectedMarketDate)
: null;
const selectedMarketScan = selectedCity
? marketScanByCityName[selectedMarketScanKey || ""] || null
: null;
useEffect(() => {
dashboardClient.writeCityDetailCacheBundle(
cityDetailsByName,
cityDetailMetaByName,
);
}, [cityDetailMetaByName, cityDetailsByName]);
useEffect(() => {
citySummariesRef.current = citySummariesByName;
}, [citySummariesByName]);
const scheduleBackgroundDetailRefresh = (
cityName: string,
cached: CityDetail,
cachedMeta?: { cachedAt: number; revision: string },
) => {
const nowTs = Date.now();
const lastTs = backgroundSummaryCheckAtRef.current[cityName] || 0;
if (nowTs - lastTs < BACKGROUND_SUMMARY_REFRESH_MS) {
return;
}
backgroundSummaryCheckAtRef.current[cityName] = nowTs;
void dashboardClient
.getCitySummary(cityName)
.then(async (summary) => {
const revision = getCityRevision(summary);
if (!revision || revision === cachedMeta?.revision) {
return;
}
const latestDetail = await dashboardClient.getCityDetail(cityName, {
force: false,
});
const detail = mergeAiAnalysisIfStable(cached, latestDetail);
setCityDetailsByName((current) => ({
...current,
[cityName]: detail,
}));
setCitySummariesByName((current) => ({
...current,
[cityName]: toCitySummary(detail),
}));
setCityDetailMetaByName((current) => ({
...current,
[cityName]: {
cachedAt: Date.now(),
revision: getCityRevision(detail),
},
}));
})
.catch(() => {});
};
const ensureCityDetail = async (cityName: string, force = false) => {
const cached = cityDetailsByName[cityName];
const cachedMeta = cityDetailMetaByName[cityName];
if (!force && cached && dashboardClient.isCityDetailFresh(cachedMeta)) {
scheduleBackgroundDetailRefresh(cityName, cached, cachedMeta);
return cached;
}
if (!force && cached) {
try {
const summary = await dashboardClient.getCitySummary(cityName);
const revision = getCityRevision(summary);
if (revision && revision === cachedMeta?.revision) {
setCityDetailMetaByName((current) => ({
...current,
[cityName]: {
cachedAt: Date.now(),
revision,
},
}));
return cached;
}
} catch {
return cached;
}
}
const latestDetail = await dashboardClient.getCityDetail(cityName, {
force,
});
const detail = mergeAiAnalysisIfStable(cached, latestDetail);
setCityDetailsByName((current) => ({
...current,
[cityName]: detail,
}));
setCitySummariesByName((current) => ({
...current,
[cityName]: toCitySummary(detail),
}));
setCityDetailMetaByName((current) => ({
...current,
[cityName]: {
cachedAt: Date.now(),
revision: getCityRevision(detail),
},
}));
return detail;
};
const ensureCityMarketScan = async (
cityName: string,
force = false,
marketSlug?: string | null,
targetDate?: string | null,
) => {
const cacheKey = getMarketScanCacheKey(cityName, targetDate);
const cached = marketScanByCityName[cacheKey];
if (!force && cached && !marketSlug) {
return cached;
}
const latestScan = await dashboardClient.getCityMarketScan(cityName, {
force,
marketSlug,
targetDate,
});
if (latestScan) {
setMarketScanByCityName((current) => ({
...current,
[cacheKey]: latestScan,
}));
}
return latestScan;
};
const loadCities = async () => {
setLoadingState((current) => ({ ...current, cities: true }));
try {
const nextCities = await dashboardClient.getCities();
setCities(nextCities);
} finally {
setLoadingState((current) => ({ ...current, cities: false }));
}
};
const refreshProAccess = async () => {
setProAccess((current) => ({
...current,
loading: true,
error: null,
}));
try {
const response = await fetch("/api/auth/me", {
cache: "no-store",
headers: {
Accept: "application/json",
},
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const payload = (await response.json()) as {
authenticated?: boolean;
subscription_active?: boolean | null;
points?: number;
};
setProAccess({
loading: false,
authenticated: Boolean(payload.authenticated),
subscriptionActive: payload.subscription_active === true,
points: payload.points ?? 0,
error: null,
});
} catch (error) {
setProAccess({
loading: false,
authenticated: false,
subscriptionActive: false,
points: 0,
error: String(error),
});
}
};
useEffect(() => {
void loadCities();
}, []);
useEffect(() => {
void refreshProAccess();
}, []);
useEffect(() => {
if (!cities.length) return;
const queue = cities
.map((city) => city.name)
.filter((cityName) => !citySummariesRef.current[cityName]);
if (!queue.length) return;
let active = true;
const concurrency = 4;
let cursor = 0;
const worker = async () => {
while (active && cursor < queue.length) {
const cityName = queue[cursor];
cursor += 1;
if (citySummariesRef.current[cityName]) continue;
try {
const summary = await dashboardClient.getCitySummary(cityName);
if (!active) return;
setCitySummariesByName((current) => {
if (current[cityName]) return current;
const next = {
...current,
[cityName]: summary,
};
citySummariesRef.current = next;
return next;
});
} catch {}
}
};
void Promise.all(
Array.from({ length: Math.min(concurrency, queue.length) }, () =>
worker(),
),
);
return () => {
active = false;
};
}, [cities]);
const selectCity = async (cityName: string) => {
setSelectedCity(cityName);
setIsPanelOpen(true);
setSelectedForecastDate(null);
setFutureModalDate(null);
setLoadingState((current) => ({ ...current, cityDetail: true }));
try {
const detail = await ensureCityDetail(cityName);
setSelectedForecastDate(detail.local_date);
// 预热市场数据,不做 await 阻塞,后台静默拉取
void ensureCityMarketScan(cityName, false, null, detail.local_date).catch(
() => {},
);
} finally {
setLoadingState((current) => ({ ...current, cityDetail: false }));
}
};
useEffect(() => {
if (typeof window === "undefined") return;
if (selectedCity) {
window.localStorage.setItem(SELECTED_CITY_STORAGE_KEY, selectedCity);
} else {
window.localStorage.removeItem(SELECTED_CITY_STORAGE_KEY);
}
}, [selectedCity]);
useEffect(() => {
if (hydratedSelectionRef.current) return;
if (!cities.length) return;
if (selectedCity) {
hydratedSelectionRef.current = true;
return;
}
if (typeof window === "undefined") return;
hydratedSelectionRef.current = true;
const stored = String(
window.localStorage.getItem(SELECTED_CITY_STORAGE_KEY) || "",
)
.trim()
.toLowerCase();
if (!stored) return;
if (!cities.some((city) => city.name === stored)) return;
void selectCity(stored);
}, [cities, selectedCity, selectCity]);
const refreshSelectedCity = async () => {
if (!selectedCity) return;
setLoadingState((current) => ({ ...current, refresh: true }));
try {
const detail = await ensureCityDetail(selectedCity, true);
setSelectedForecastDate(detail.local_date);
} finally {
setLoadingState((current) => ({ ...current, refresh: false }));
}
};
const refreshAll = async () => {
const previousSelectedDetail = selectedCity
? cityDetailsByName[selectedCity]
: undefined;
dashboardClient.clearCityDetailCache();
setCityDetailsByName({});
setCityDetailMetaByName({});
if (selectedCity) {
setLoadingState((current) => ({ ...current, refresh: true }));
try {
const latestDetail = await dashboardClient.getCityDetail(selectedCity, {
force: true,
});
const detail = mergeAiAnalysisIfStable(
previousSelectedDetail,
latestDetail,
);
setCityDetailsByName({ [selectedCity]: detail });
setCitySummariesByName((current) => ({
...current,
[selectedCity]: toCitySummary(detail),
}));
setCityDetailMetaByName({
[selectedCity]: {
cachedAt: Date.now(),
revision: getCityRevision(detail),
},
});
setSelectedForecastDate(detail.local_date);
} finally {
setLoadingState((current) => ({ ...current, refresh: false }));
}
}
};
const openHistory = async () => {
if (!selectedCity) return;
if (!proAccess.subscriptionActive) {
setHistoryState((current) => ({
...current,
error: null,
isOpen: true,
loading: false,
}));
return;
}
setHistoryState((current) => ({
...current,
error: null,
isOpen: true,
loading: true,
}));
try {
const history = await dashboardClient.getHistory(selectedCity);
setHistoryState((current) => ({
...current,
dataByCity: {
...current.dataByCity,
[selectedCity]: history,
},
loading: false,
}));
} catch (error) {
setHistoryState((current) => ({
...current,
error: String(error),
loading: false,
}));
}
};
const value = useMemo<DashboardStoreValue>(
() => ({
cities,
cityDetailsByName,
citySummariesByName,
closeFutureModal: () => setFutureModalDate(null),
closeGuide: () => setIsGuideOpen(false),
closeHistory: () =>
setHistoryState((current) => ({ ...current, isOpen: false })),
closePanel: () => {
setIsPanelOpen(false);
},
ensureCityDetail,
futureModalDate,
historyState,
isPanelOpen,
isGuideOpen,
loadCities,
loadingState,
proAccess,
openFutureModal: (dateStr: string, forceRefresh = false) => {
mapStopMotionRef.current();
setFutureModalDate(dateStr);
if (!selectedCity || !proAccess.subscriptionActive) return;
const cacheKey = getMarketScanCacheKey(selectedCity, dateStr);
setLoadingState((current) => ({ ...current, marketScan: true }));
void ensureCityMarketScan(
selectedCity,
forceRefresh || !marketScanByCityName[cacheKey],
null,
dateStr,
)
.catch(() => {})
.finally(() => {
setLoadingState((current) => ({ ...current, marketScan: false }));
});
},
openGuide: () => setIsGuideOpen(true),
openHistory,
openTodayModal: async (forceRefresh?: boolean) => {
if (!selectedCity || loadingState.cityDetail) {
return;
}
mapStopMotionRef.current();
const cachedDetail = cityDetailsByName[selectedCity];
if (cachedDetail?.local_date) {
setSelectedForecastDate(cachedDetail.local_date);
setFutureModalDate(cachedDetail.local_date);
}
if (!proAccess.subscriptionActive) {
return;
}
// 乐观 UI: 有缓存则立刻秒开 modal,不阻塞显示
if (cachedDetail?.local_date) {
setSelectedForecastDate(cachedDetail.local_date);
setFutureModalDate(cachedDetail.local_date);
setLoadingState((current) => ({ ...current, marketScan: true }));
} else {
setLoadingState((current) => ({
...current,
refresh: true,
marketScan: true,
}));
}
// 异步静默拉取最新气象与市场数据
try {
const detail = await ensureCityDetail(selectedCity, true);
setSelectedForecastDate(detail.local_date);
setFutureModalDate(detail.local_date);
try {
// 如果缓存里没有或者想要强制刷新,则拉取最新市场数据
const marketKey = getMarketScanCacheKey(
selectedCity,
detail.local_date,
);
await ensureCityMarketScan(
selectedCity,
forceRefresh || !marketScanByCityName[marketKey],
null,
detail.local_date,
);
} catch {}
} catch {
if (cachedDetail?.local_date) {
setFutureModalDate(cachedDetail.local_date);
}
} finally {
setLoadingState((current) => ({
...current,
refresh: false,
marketScan: false,
}));
}
},
registerMapStopMotion: (stopMotion: () => void) => {
mapStopMotionRef.current = stopMotion;
},
refreshAll,
refreshProAccess,
refreshSelectedCity,
selectedMarketScan,
selectedCity,
selectedDetail,
selectedForecastDate,
selectCity,
setForecastDate: (dateStr: string | null) =>
setSelectedForecastDate(dateStr),
marketScanByCityName,
}),
[
cities,
cityDetailsByName,
citySummariesByName,
futureModalDate,
historyState,
isPanelOpen,
isGuideOpen,
loadingState,
proAccess,
marketScanByCityName,
selectedMarketScan,
selectedCity,
selectedDetail,
selectedForecastDate,
],
);
return (
<DashboardStoreContext.Provider value={value}>
{children}
</DashboardStoreContext.Provider>
);
}
export function useDashboardStore() {
const context = useContext(DashboardStoreContext);
if (!context) {
throw new Error(
"useDashboardStore must be used within DashboardStoreProvider",
);
}
return context;
}
export function useCityData(name?: string | null) {
const store = useDashboardStore();
const key = name || store.selectedCity;
return {
data: key ? store.cityDetailsByName[key] || null : null,
isLoading:
store.loadingState.cityDetail &&
Boolean(key) &&
store.selectedCity === key,
};
}
export function useHistoryData(name?: string | null) {
const store = useDashboardStore();
const key = name || store.selectedCity;
return {
data: key
? store.historyState.dataByCity[key] || ([] as HistoryPoint[])
: [],
error: store.historyState.error,
isLoading: store.historyState.loading,
isOpen: store.historyState.isOpen,
};
}
+57
View File
@@ -0,0 +1,57 @@
"use client";
import { createContext, useContext, useEffect, useMemo, useState } from "react";
import {
formatMessage,
getInitialLocaleFromNavigator,
Locale,
LOCALE_STORAGE_KEY,
normalizeLocale,
} from "@/lib/i18n";
interface I18nContextValue {
locale: Locale;
setLocale: (locale: Locale) => void;
toggleLocale: () => void;
t: (key: string, params?: Record<string, string | number>) => string;
}
const I18nContext = createContext<I18nContextValue | null>(null);
export function I18nProvider({ children }: { children: React.ReactNode }) {
const [locale, setLocale] = useState<Locale>(() => {
if (typeof window === "undefined") {
return "zh-CN";
}
const stored = window.localStorage.getItem(LOCALE_STORAGE_KEY);
return stored ? normalizeLocale(stored) : getInitialLocaleFromNavigator();
});
useEffect(() => {
if (typeof window === "undefined") return;
window.localStorage.setItem(LOCALE_STORAGE_KEY, locale);
document.documentElement.lang = locale;
}, [locale]);
const value = useMemo<I18nContextValue>(
() => ({
locale,
setLocale,
t: (key, params) => formatMessage(locale, key, params),
toggleLocale: () => {
setLocale((current) => (current === "zh-CN" ? "en-US" : "zh-CN"));
},
}),
[locale],
);
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
}
export function useI18n() {
const context = useContext(I18nContext);
if (!context) {
throw new Error("useI18n must be used within I18nProvider");
}
return context;
}

Some files were not shown because too many files have changed in this diff Show More