Compare commits

...

115 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
170 changed files with 27122 additions and 2572 deletions
+110 -4
View File
@@ -1,6 +1,17 @@
# 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
@@ -8,15 +19,13 @@ TELEGRAM_ALERT_MIN_TRIGGER_COUNT=2
TELEGRAM_ALERT_MIN_SEVERITY=medium
# 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,toronto,buenos aires,wellington,new york,chicago,dallas,miami,atlanta,seattle,lucknow,sao paulo,munich
# AI
GROQ_API_KEY=your_groq_api_key_here
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
# 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
@@ -26,10 +35,86 @@ 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
@@ -62,6 +147,19 @@ 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
@@ -74,7 +172,15 @@ 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. 结论
前端已具备收费阶段的核心能力(账户、支付、权限展示、状态回收),可支持持续商业迭代。
+100 -109
View File
@@ -4,107 +4,69 @@ Production weather-intelligence stack for temperature settlement markets.
Official dashboard: [polyweather-pro.vercel.app](https://polyweather-pro.vercel.app/)
## What This Project Does
## Product Screenshots
- Aggregates weather observations and forecasts for monitored cities.
- Blends multi-model forecasts with DEB (Dynamic Error Balancing).
- Computes settlement-oriented probability buckets (mu-centered distribution).
- Maps model view to Polymarket read-only market data for mispricing/risk scan.
- Delivers the same core logic to web dashboard and Telegram bot.
### Global Dashboard
## Overview Diagram
![PolyWeather global dashboard](docs/images/demo_map.png)
### City Analysis (Ankara)
![PolyWeather Ankara analysis](docs/images/demo_ankara.png)
## Product Status (2026-03)
- 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)
This repository follows an **Open-Core** strategy:
- 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.
See: [Open-Core & Commercial Boundary](docs/OPEN_CORE_POLICY.md)
## Core Capabilities
- 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.
## Reference Architecture
```mermaid
flowchart TD
A["PolyWeather Pro"]
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 DL["Data Layer"]
DL1["METAR (Aviation Weather / METAR)"]
DL2["MGM (Turkey MGM)"]
DL3["Station 17130 (Ankara Center 17130)"]
DL4["Open-Meteo"]
DL5["weather.gov (US cities)"]
DL6["Polymarket (P0 Read-only)"]
end
API --> WX["Weather Collector"]
WX --> METAR["Aviation Weather (METAR)"]
WX --> MGM["MGM (Turkey station network)"]
WX --> OM["Open-Meteo"]
subgraph AL["Analysis Layer"]
AL1["DEB (Dynamic Error Balancing)"]
AL2["Probability Engine (mu + buckets)"]
AL3["Trend Engine"]
AL4["Risk Profiles"]
AL5["Mispricing Radar"]
end
subgraph DEL["Delivery Layer"]
DEL1["FastAPI"]
DEL2["Next.js Dashboard"]
DEL3["Telegram Bot"]
DEL4["Alert Push"]
end
subgraph OL["Ops Layer"]
OL1["Docker Compose (VPS backend + bot)"]
OL2["Vercel (frontend)"]
OL3["Cache + force_refresh"]
OL4["Speed Insights"]
end
A --> DL
A --> AL
A --> DEL
A --> OL
API --> ANA["DEB + Trend + Probability + Market Scan"]
ANA --> PAY["Payment State (Intent + Event + Confirm Loop)"]
ANA --> PM["Polymarket Read-only Layer"]
```
## Architecture
## Monitored Cities (20)
```mermaid
graph TD
User[Web / Telegram User] --> FE[Next.js Frontend on Vercel]
User --> Bot[Telegram Bot on VPS]
FE --> API[FastAPI Service]
Bot --> API
API --> WX[Weather Data Collector]
WX --> METAR[METAR / Aviation Weather]
WX --> MGM[MGM API / nearby stations]
WX --> OM[Open-Meteo]
WX --> NWS[weather.gov]
API --> DEB[DEB + Trend + Probability Engines]
API --> PM[Polymarket Read-only Layer]
PM --> Gamma[Gamma API]
PM --> CLOB[CLOB / py-clob-client]
```
## Current Source Policy
| Domain | Source Policy |
| :------------------ | :--------------------------------------------------- |
| Primary observation | Aviation Weather / METAR |
| Ankara enhancement | MGM + nearby stations, lead station fixed to `17130` |
| Forecast baseline | Open-Meteo |
| US official context | weather.gov |
| Market layer | Polymarket P0 read-only discovery + quotes |
| Removed source | Meteoblue (fully removed from code and docs) |
## Recent Changes (2026-03-11)
- Removed all Meteoblue API integration and references.
- Fixed market top-bucket rendering path by deduplicating repeated temperature buckets.
- Added frontend fallback guard when market top buckets collapse to low-quality duplicates.
- Fixed detail panel accessibility issue (`aria-hidden` focus conflict) using `inert` + active-element blur.
- Added Vercel Speed Insights integration in `frontend/app/layout.tsx`.
## Repositories and Runtime Paths
- Frontend: `frontend/` (Next.js App Router)
- Backend API: `web/app.py` and `src/`
- Telegram runtime: `bot_listener.py` + `src/analysis/*`
- Docs: `docs/`
- 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
## Quick Start
### Backend + Bot (VPS / Docker)
### Backend + Bot (Docker)
```bash
docker compose up -d --build
@@ -118,32 +80,61 @@ npm install
npm run dev
```
### Frontend production build check
## Runtime Data (Recommended on VPS)
```bash
cd frontend
npm run build
Use external runtime storage to avoid SQLite/git conflicts:
```env
POLYWEATHER_RUNTIME_DATA_DIR=/var/lib/polyweather
POLYWEATHER_DB_PATH=/var/lib/polyweather/polyweather.db
```
## Command Surface (Telegram)
## Ops Verification
| Command | Purpose |
| :------------- | :---------------------------- |
| `/city <name>` | City real-time analysis |
| `/deb <name>` | DEB historical reconciliation |
| `/top` | User leaderboard |
| `/help` | Help and command usage |
### Frontend cache headers
```bash
./scripts/validate_frontend_cache.sh "https://polyweather-pro.vercel.app"
```
### Payment auto-reconciliation logs
```bash
docker compose logs -f polyweather | egrep "payment event loop started|payment confirm loop started|payment auto-confirmed"
```
### 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 API guide: `docs/API_ZH.md`
- Commercial roadmap: `docs/COMMERCIALIZATION.md`
- Tech debt (EN): `docs/TECH_DEBT.md`
- Tech debt (ZH): `docs/TECH_DEBT_ZH.md`
- Chinese overview: `README_ZH.md`
- 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)
## Status
## Version
- Version: `v1.3`
- Last Updated: `2026-03-11`
- Runtime: Stable (web + bot + market read-only layer in production)
- Version: `v1.4.0`
- Last Updated: `2026-03-14`
+99 -107
View File
@@ -4,106 +4,69 @@
官方看板:[polyweather-pro.vercel.app](https://polyweather-pro.vercel.app/)
## 这个项目在做什么
## 产品截图
- 聚合监控城市的实测与预报数据。
- 用 DEBDynamic Error Balancing)做动态融合预测。
- 计算结算导向的温度概率分布(`μ` + 温度桶)。
- 将模型概率与 Polymarket 只读市场数据对齐,输出错价/风险信号。
- Web 仪表盘与 Telegram 机器人共用同一套核心逻辑。
### 全球看板
## 概览图
![PolyWeather 全球地图看板](docs/images/demo_map.png)
### 城市分析(Ankara
![PolyWeather Ankara 分析页](docs/images/demo_ankara.png)
## 当前产品状态(2026-03
- 已上线订阅制:`Pro 月付 5 USDC`
- 已上线积分抵扣:`500 积分 = 1 USDC`,最多抵扣 `3 USDC`
- 已上线链上支付:Polygon 合约支付(USDC / USDC.e)。
- 已上线自动补单:事件监听 + 周期确认双链路。
## 开源边界(重要)
本项目采用 **Open-Core** 策略:
- 仓库公开部分:天气聚合、基础分析、前端看板、Bot 基础能力、支付标准流程示例。
- 生产私有部分:商业风控规则、运营阈值、收费策略细节、付费用户运营脚本、内部对账与审计策略。
详细见:[Open-Core 与商用边界](docs/OPEN_CORE_POLICY.md)
## 核心能力
- 聚合 20 个监控城市的实测与预报数据。
- DEBDynamic Error Balancing)融合多模型最高温。
- 输出结算导向概率分布(`mu` + 温度桶)。
- 将模型观点映射到 Polymarket 行情,做错价扫描。
- Web 仪表盘与 Telegram Bot 复用同一分析内核。
## 参考架构
```mermaid
flowchart TD
A["PolyWeather Pro"]
flowchart LR
U["用户(Web / Telegram"] --> FE["Next.js 前端(Vercel"]
U --> BOT["Telegram BotVPS"]
FE --> API["FastAPI /web/app.py"]
BOT --> API
subgraph DL["数据层"]
DL1["METAR (Aviation Weather / METAR)"]
DL2["MGM (土耳其 MGM)"]
DL3["安卡拉主站 (17130 Center)"]
DL4["Open-Meteo"]
DL5["weather.gov (美国城市)"]
DL6["Polymarket (P0 只读)"]
end
API --> WX["Weather Collector"]
WX --> METAR["Aviation WeatherMETAR"]
WX --> MGM["MGM土耳其站网)"]
WX --> OM["Open-Meteo"]
subgraph AL["分析层"]
AL1["DEB (动态误差平衡)"]
AL2["概率引擎 (mu + 桶分布)"]
AL3["趋势引擎"]
AL4["城市风险档案"]
AL5["错价雷达"]
end
subgraph DEL["交付层"]
DEL1["FastAPI"]
DEL2["Next.js 仪表盘"]
DEL3["Telegram Bot"]
DEL4["预警推送"]
end
subgraph OL["运维层"]
OL1["Docker Compose (VPS)"]
OL2["Vercel (前端)"]
OL3["缓存 + force_refresh"]
OL4["Speed Insights"]
end
A --> DL
A --> AL
A --> DEL
A --> OL
API --> ANA["DEB + 趋势 + 概率 + 市场扫描"]
ANA --> PAY["支付状态(Intent + Event + Confirm Loop"]
ANA --> PM["Polymarket 只读层"]
```
## 系统架构
## 监控城市(20
```mermaid
graph TD
User[Web / Telegram 用户] --> FE[Vercel Next.js 前端]
User --> Bot[VPS Telegram Bot]
FE --> API[FastAPI 服务]
Bot --> API
API --> WX[Weather Collector]
WX --> METAR[METAR / Aviation Weather]
WX --> MGM[MGM API / 周边站]
WX --> OM[Open-Meteo]
WX --> NWS[weather.gov]
API --> DEB[DEB + 趋势 + 概率引擎]
API --> PM[Polymarket 只读层]
PM --> Gamma[Gamma API]
PM --> CLOB[CLOB / py-clob-client]
```
## 当前数据源口径
| 领域 | 当前口径 |
| :------------- | :-------------------------------- |
| 主观测源 | Aviation Weather / METAR |
| Ankara 增强 | MGM + 周边站,领先站固定 `17130` |
| 预报基线 | Open-Meteo |
| 美国官方语义层 | weather.gov |
| 市场层 | Polymarket P0 只读发现 + 报价 |
| 已移除 | Meteoblue(代码与文档已全部移除) |
## 最近更新(2026-03-11
- 完整移除 Meteoblue API 及全部引用。
- 修复市场“最热温度桶”重复温度刷屏问题(后端按温度去重 + 前端兜底去重)。
- 修复详情面板可访问性告警(`aria-hidden` 焦点冲突),改为 `inert + blur`
- 集成 Vercel Speed Insights`frontend/app/layout.tsx`)。
## 目录说明
- 前端:`frontend/`Next.js App Router
- 后端:`web/app.py``src/`
- 机器人:`bot_listener.py` + `src/analysis/*`
- 文档:`docs/`
- 欧洲/中东:Ankara、London、Paris、Munich
- 亚太:Seoul、Hong Kong、Shanghai、Singapore、Tokyo、Wellington
- 美洲:Toronto、New York、Chicago、Dallas、Miami、Atlanta、Seattle、Buenos Aires、Sao Paulo
- 南亚:Lucknow
## 快速启动
### 后端 + 机器人(VPS / Docker
### 后端 + BotDocker
```bash
docker compose up -d --build
@@ -117,32 +80,61 @@ npm install
npm run dev
```
### 前端构建校验
## 运行数据目录(VPS 推荐)
```bash
cd frontend
npm run build
建议将运行态数据放到仓库外(避免 `git pull` 被 SQLite 卡住):
```env
POLYWEATHER_RUNTIME_DATA_DIR=/var/lib/polyweather
POLYWEATHER_DB_PATH=/var/lib/polyweather/polyweather.db
```
## Telegram 命令
## 运维验收
| 命令 | 用途 |
| :------------- | :----------- |
### 前端缓存头
```bash
./scripts/validate_frontend_cache.sh "https://polyweather-pro.vercel.app"
```
### 支付自动补单日志
```bash
docker compose logs -f polyweather | egrep "payment event loop started|payment confirm loop started|payment auto-confirmed"
```
### 钱包异动监听日志
```bash
docker compose logs -f polyweather | egrep "polymarket wallet activity watcher started|wallet activity pushed"
```
## Telegram 指令
| 指令 | 用途 |
| :-- | :-- |
| `/city <name>` | 城市实时分析 |
| `/deb <name>` | DEB 历史对账 |
| `/top` | 用户排行榜 |
| `/help` | 帮助说明 |
| `/deb <name>` | DEB 历史对账 |
| `/top` | 用户积分排行 |
| `/id` | 查看聊天 Chat ID |
| `/diag` | Bot 启动诊断 |
| `/help` | 帮助与用法 |
## 文档索引
- API 文档(中文):`docs/API_ZH.md`
- 商业化路线:`docs/COMMERCIALIZATION.md`
- 技术债(英文):`docs/TECH_DEBT.md`
- 技术债(中文):`docs/TECH_DEBT_ZH.md`
- 英文总览:`README.md`
- 英文总览:[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.3`
- 最后更新:`2026-03-11`
- 状态:稳定运行(Web + Bot + 市场只读层)
- 版本:`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
View File
View File
+2 -405
View File
@@ -1,416 +1,13 @@
import sys
import os
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.onchain.polygon_wallet_watcher import start_polygon_wallet_watch_loop # type: ignore # noqa: E402
from src.onchain.polymarket_wallet_activity_watcher import start_polymarket_wallet_activity_loop # type: ignore # noqa: E402
from src.data_collection.weather_sources import WeatherDataCollector # type: ignore # noqa: E402
from src.database.db_manager import DBManager
from src.analysis.city_query_service import (
resolve_city_name,
build_city_query_report,
)
MESSAGE_POINTS = 4
MESSAGE_DAILY_CAP = 50
MESSAGE_MIN_LENGTH = 2
MESSAGE_COOLDOWN_SEC = 30
CITY_QUERY_COST = 1
DEB_QUERY_COST = 1
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)
start_polygon_wallet_watch_loop(bot)
start_polymarket_wallet_activity_loop(bot)
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} 字)获得 <b>{MESSAGE_POINTS}</b> 积分,"
f"每日上限 {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} 字)获得 <b>{MESSAGE_POINTS}</b> 积分,"
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 融合预测的近 7 天准确率。"""
try:
parts = message.text.split(maxsplit=1)
if len(parts) < 2:
bot.reply_to(
message,
"❌ 用法: <code>/deb ankara</code>",
parse_mode="HTML",
)
return
from datetime import datetime as _dt, timedelta as _td
import os as _os
from src.analysis.deb_algorithm import (
load_history,
_is_excluded_model_name,
reconcile_recent_actual_highs,
)
from src.data_collection.city_registry import ALIASES
city_input = parts[1].strip().lower()
city_name = ALIASES.get(city_input, city_input)
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
reconcile_info = reconcile_recent_actual_highs(city_name, lookback_days=7)
# Reload in case reconciliation updated the file
data = load_history(history_file)
city_data = data[city_name]
today = _dt.now().date()
today_str = today.strftime("%Y-%m-%d")
cutoff_date = today - _td(days=6)
recent_items = []
for date_str, record in city_data.items():
try:
row_date = _dt.strptime(date_str, "%Y-%m-%d").date()
except Exception:
continue
if row_date >= cutoff_date:
recent_items.append((date_str, record, row_date))
recent_items.sort(key=lambda item: item[0])
lines = [
f"📊 <b>DEB 准确率报告 - {city_name.title()}</b>",
"",
"📅 <b>近日记录:</b>",
]
if (
isinstance(reconcile_info, dict)
and reconcile_info.get("ok")
and int(reconcile_info.get("updated") or 0) > 0
):
lines.extend(
[
f"🔁 已用 METAR 历史回填修正 {int(reconcile_info.get('updated'))} 天实测最高温",
"",
]
)
total_days = 0
hits = 0
deb_errors = []
signed_errors = []
model_errors = {}
for date_str, record, _row_date in recent_items:
actual = record.get("actual_high")
deb_pred = record.get("deb_prediction")
forecasts = record.get("forecasts", {}) or {}
if actual is None:
continue
try:
actual = float(actual)
if deb_pred is not None:
deb_pred = float(deb_pred)
except Exception:
continue
if deb_pred is None and forecasts:
valid_preds = [
float(v)
for k, v in forecasts.items()
if v is not None and not _is_excluded_model_name(k)
]
if valid_preds:
deb_pred = round(sum(valid_preds) / len(valid_preds), 1)
actual_wu = round(actual)
if date_str == today_str:
lines.append(f" {date_str}: 📍 今天进行中 (实测暂 {actual:.1f})")
elif deb_pred is not None:
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)
if hit:
result_icon = ""
err_text = f"偏差{abs(err):.1f}°"
elif err < 0:
result_icon = ""
err_text = f"低估{abs(err):.1f}°"
else:
result_icon = ""
err_text = f"高估{abs(err):.1f}°"
retro = "" if "deb_prediction" not in record else ""
lines.append(
f" {date_str}: DEB {retro}{deb_pred:.1f}{deb_wu} vs 实测 {actual:.1f}{actual_wu} "
f"{result_icon} {err_text}"
)
if date_str != today_str and actual is not None:
for model, pred in forecasts.items():
if _is_excluded_model_name(model):
continue
if pred is None:
continue
try:
model_errors.setdefault(model, []).append(abs(float(pred) - actual))
except Exception:
continue
if total_days > 0:
hit_rate = hits / total_days * 100
deb_mae = sum(deb_errors) / len(deb_errors)
lines.append("")
lines.append(
f"🏁 <b>DEB 总战绩:</b>WU命中 {hits}/{total_days} (<b>{hit_rate:.0f}%</b>) | MAE: {deb_mae:.1f}°"
)
if model_errors:
lines.append("")
lines.append("📈 <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 item: item[1])
for model, mae in sorted_models:
tag = "" if mae <= deb_mae else ""
lines.append(f" {model}: {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)
accurate = total_days - underest - overest
lines.append("")
lines.append("🔍 <b>偏差分析:</b>")
if abs(mean_bias) > 0.3:
bias_label = "系统性低估" if mean_bias < 0 else "系统性高估"
lines.append(f" ⚠️ {bias_label}:平均偏差 {mean_bias:+.1f}°")
else:
lines.append(f" ✅ 整体无明显系统偏差:平均偏差 {mean_bias:+.1f}°")
lines.append(f" (低估 {underest} 次 | 高估 {overest} 次 | 准确 {accurate} 次)")
lines.append("")
lines.append("💡 <b>建议:</b>")
if underest > overest and abs(mean_bias) > 0.5:
lines.append(
f" 该城市模型集体低估趋势明显({mean_bias:+.1f}°),实际最高温可能比 DEB 融合值高 "
f"{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(" 近期准确率一般,建议结合主站实测与周边站点共同判断。")
lines.append("")
lines.append("📝 MAE = 平均绝对误差,越小越准。⭐ = 优于 DEB 融合。")
lines.append("📅 统计窗口:近7天滚动样本。")
else:
lines.append("")
lines.append("🔔 近 7 天尚无完整的 DEB 预测记录。")
lines.append("")
lines.append(f"💸 本次消耗 <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
city_input = parts[1].strip().lower()
city_name, supported_cities = resolve_city_name(city_input)
if not city_name:
city_list = ", ".join(supported_cities)
bot.reply_to(
message,
f"❌ 未找到城市: <b>{city_input}</b>\n\n支持的城市: {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"],
force_refresh=True,
)
city_report = build_city_query_report(
city_name=city_name,
weather_data=weather_data,
city_query_cost=CITY_QUERY_COST,
)
bot.send_message(message.chat.id, city_report, 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__":
start_bot()
+93 -67
View File
@@ -1,71 +1,97 @@
# Weather API Configuration
weather:
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
- id: "hong_kong"
city: "Hong Kong"
country: "China"
latitude: 22.3080
longitude: 113.9185
- 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
# 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}"
+74 -212
View File
@@ -1,254 +1,116 @@
# PolyWeather API 文档(v1.2
# PolyWeather API 文档(v1.4.0
本文档描述当前后端真实可用接口(`web/app.py`)。
前端一般通过 Next.js BFF 路由代理访问这些接口。
最后更新:`2026-03-14`
---
本文档描述当前对外可用 API 口径(`web/app.py` + `frontend/app/api/*`)。
## 1. 基础信息
- 本地地址`http://127.0.0.1:8000`
- 生产地址`http://<vps-ip>:8000` 或你绑定的 HTTPS 域名
- 后端直连`http://127.0.0.1:8000`
- 前端 BFF`https://polyweather-pro.vercel.app/api/*`
- 返回格式:`application/json`
- 缓存策略:
- 后端分析缓存:默认 5 分钟(Ankara 特殊口径 60 秒)
- 前端详情缓存:5 分钟 + revision 检查
- 手动刷新:`force_refresh=true` 强制绕过缓存
---
## 2. API 思维导图
## 2. 请求链路
```mermaid
flowchart TD
A["PolyWeather API"]
subgraph E["接口分组"]
E1["GET /api/cities"]
E2["GET /api/city/{name}"]
E3["GET /api/city/{name}/summary"]
E4["GET /api/city/{name}/detail"]
E5["GET /api/history/{name}"]
end
subgraph O["关键对象"]
O1["current"]
O2["forecast"]
O3["probabilities (mu + distribution)"]
O4["multi_model / multi_model_daily"]
O5["market_scan (P0 只读)"]
end
A --> E
A --> O
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. 天气分析接口
## 3. 接口总览
| 接口 | 方法 | 用途 |
| :-- | :-- | :-- |
| `/api/cities` | GET | 监控城市列表 |
| `/api/city/{name}` | GET | 城市主分析 |
| `/api/city/{name}/summary` | GET | 轻量摘要 |
| `/api/city/{name}/detail` | GET | 聚合详情(含 market_scan |
| `/api/history/{name}` | GET | 历史对账 |
| 接口 | 方法 | 用途 |
| :------------------------- | :--- | :------------------------------------ |
| `/api/cities` | GET | 城市清单与地图基础信息 |
| `/api/city/{name}` | GET | 城市主分析数据(侧栏/今日分析主来源) |
| `/api/city/{name}/summary` | GET | 轻量摘要(首屏预热/低开销更新) |
| `/api/city/{name}/detail` | GET | 聚合详情 + Polymarket P0 只读市场层 |
| `/api/history/{name}` | GET | 历史对账数据 |
---
## 4. 关键接口详解
### 4.1 `GET /api/cities`
返回监控城市列表(地图 Marker 与侧边栏基础数据)。
示例:
```json
{
"cities": [
{
"name": "ankara",
"display_name": "Ankara",
"lat": 39.9334,
"lon": 32.8597,
"risk_level": "medium",
"risk_emoji": "🟠",
"airport": "Esenboğa",
"icao": "LTAC",
"temp_unit": "celsius",
"is_major": true
}
]
}
```
### 4.2 `GET /api/city/{name}`
主数据接口,前端详情面板和今日分析最常用。
### `GET /api/city/{name}/detail`
可选参数:
- `force_refresh=true|false`
- `market_slug=<slug>`
- `target_date=YYYY-MM-DD`
核心字段:
重点字段:
- `name`, `display_name`, `local_date`, `local_time`, `temp_symbol`
- `risk`
- `current`
- `forecast`
- `mgm`, `mgm_nearby`
- `multi_model`, `multi_model_daily`
- `deb`
- `ensemble`
- `probabilities``mu` + `distribution`
- `trend`, `peak`
- `hourly`, `hourly_next_48h`
- `source_forecasts`(当前只保留 `weather_gov`
- `market_scan`
- `updated_at`
- `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. 鉴权与账户接口
- `current.raw_metar` 是原始 METAR 报文。
- Ankara 专项增强使用 MGM 站网,领先站固定 `17130`
- Meteoblue 已彻底移除,不再出现在接口字段中。
| 接口 | 方法 | 用途 |
| :-- | :-- | :-- |
| `/api/auth/me` | GET | 当前登录态、积分、订阅状态 |
### 4.3 `GET /api/city/{name}/summary`
`/api/auth/me` 关键字段:
轻量温度摘要,用于地图首屏预热和低成本刷新。
- `authenticated`
- `user_id`, `email`
- `points`, `weekly_points`, `weekly_rank`
- `subscription_active`, `subscription_plan_code`, `subscription_expires_at`
典型字段:
## 5. 支付接口(P1
- `name`, `display_name`, `icao`
- `local_time`, `temp_symbol`
- `current.temp`, `current.obs_time`
- `deb.prediction`
- `risk.level`, `risk.warning`
- `updated_at`
| 接口 | 方法 | 用途 |
| :-- | :-- | :-- |
| `/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 | 手动触发确认 |
### 4.4 `GET /api/city/{name}/detail`
### 支付状态建议
聚合视图接口,包含天气分析和市场只读层。
前端流程建议:
可选参数:
1. `POST /intents`
2. 钱包发链上交易
3. `POST /submit`
4. `POST /confirm`
5. 若 pending,轮询 `GET /intents/{id}` 直到 `confirmed`
- `force_refresh=true|false`
- `market_slug=<slug>`(调试/定向市场匹配)
## 6. 缓存策略(当前)
关键结构:
- `cities` / `summary` / `history`BFF 支持 `ETag + 304`
- `summary?force_refresh=true``Cache-Control: no-store`
- 详情接口与支付接口:`no-store`
- `overview`
- `official`
- `timeseries`
- `models`
- `probabilities`
- `market_scan`
- `risk`
- `ai_analysis`
## 7. 调试示例
`market_scan`P0 只读)重点字段:
### 查询未来日期 market_scan
- `primary_market`, `selected_condition_id`, `selected_slug`
- `yes_token`, `no_token`
- `yes_buy`, `yes_sell`, `no_buy`, `no_sell`
- `market_price`, `model_probability`, `edge_percent`
- `temperature_bucket`
- `top_buckets`(前端展示前会再去重)
- `signal_label``BUY YES` / `BUY NO` / `MONITOR`
- `websocket.asset_ids`, `websocket.condition_ids`(订阅标识,不涉及下单)
注意:
- 后端已做温度桶去重与方向优先(优先与主市场同方向的 `or higher`/`or lower` 桶)。
- 前端还有二次去重兜底,避免重复温度桶刷屏。
### 4.5 `GET /api/history/{name}`
历史对账数据来源。
示例:
```json
{
"history": [
{
"date": "2026-03-07",
"actual": 7.0,
"deb": 6.5,
"mu": 7.2,
"mgm": 8.0
}
]
}
```bash
curl -s "http://127.0.0.1:8000/api/city/ankara/detail?force_refresh=true&target_date=2026-03-12"
```
---
### 校验支付配置
## 5. 请求链路(以 `/api/city/{name}` 为例)
```mermaid
sequenceDiagram
participant FE as Frontend
participant API as FastAPI
participant WX as Weather Collector
participant PM as Polymarket RO Layer
FE->>API: GET /api/city/{name}?force_refresh=...
API->>WX: fetch_all_sources(city)
WX-->>API: METAR / MGM / Open-Meteo / weather.gov / Multi-model
API->>API: DEB + trend + probability
API->>PM: build_market_scan(...)
PM-->>API: market_scan (read-only)
API-->>FE: merged city payload
```bash
curl -s http://127.0.0.1:8000/api/payments/config | python3 -m json.tool
```
---
### 观察支付自动补单
## 6. 数据口径
```bash
docker compose logs -f polyweather | egrep "payment event loop started|payment confirm loop started|payment auto-confirmed"
```
### 6.1 主观测
## 8. 开源口径说明
- Aviation Weather / METAR 是全局主观测源
- Ankara:结算主站仍是 `LTAC`,领先信号强化使用 MGM`17130`)。
对外公开文档仅覆盖通用 API 契约。生产商业策略参数不在公开文档披露
### 6.2 预测源
- Open-Meteo
- weather.gov(美国城市)
- 多模型:ECMWF / GFS / ICON / GEM / JMA
### 6.3 概率口径
- `mu`:动态分布中心,不是固定结算值。
- `distribution`:按温度桶输出概率分布,面向结算决策而非通用天气展示。
---
## 7. 常见问题
### 7.1 接口 500
- 先检查容器是否启动:`docker compose ps`
- 查看日志:`docker compose logs -f polyweather_web`
### 7.2 METAR 看起来“延迟”
优先核对:
- `current.obs_time`
- `current.report_time`
- `current.receipt_time`
通常是上游发布节奏,不一定是本地轮询问题。
### 7.3 前端仍显示旧内容
- 确认 Vercel 已部署最新构建
- 浏览器强刷(`Ctrl+F5`
- 检查是否命中前端 5 分钟 TTL
---
最后更新:`2026-03-11`
详见:[Open-Core 与商用边界](OPEN_CORE_POLICY.md)
+46 -100
View File
@@ -1,120 +1,66 @@
# Commercialization Roadmap
# 商业化说明(Production
Target: make PolyWeather a sustainable paid weather-intelligence product.
最后更新:`2026-03-14`
---
## 1. 定位
## 1. Product Positioning
PolyWeather 是面向温度结算场景的气象决策层,不是通用天气应用。
PolyWeather is not a generic weather app.
It is a decision-support layer for temperature-settlement markets:
核心价值:
- observation-first (METAR/MGM),
- settlement-aware probability modeling (DEB + mu/buckets),
- market mapping (Polymarket read-only) for actionable edge detection.
- 观测优先(METAR/MGM
- 结算导向(DEB + 概率桶)
- 市场映射(行情对照 + 错价雷达)
---
## 2. 当前收费能力状态
## 2. Business Overview Diagram
| 能力 | 状态 | 备注 |
| :-- | :-- | :-- |
| 登录注册(Google + 邮箱) | 已上线 | Supabase 鉴权 |
| 订阅套餐(Pro 月付) | 已上线 | `5 USDC / 30天` |
| 积分抵扣 | 已上线 | `500分=1U`,最多 `3U` |
| 合约支付 | 已上线 | PolygonUSDC + USDC.e |
| 支付自动确认 | 已上线 | Event Loop + Confirm Loop |
| 钱包绑定 | 已上线 | 浏览器钱包 + WalletConnect |
| 私有频道推送 | 已上线 | 可拆分业务频道 |
```mermaid
flowchart TD
A["PolyWeather Monetization"]
## 3. 权限模型(当前)
subgraph P["Product"]
P1["Telegram Signal Channel"]
P2["Web Dashboard"]
P3["VIP Bundle"]
end
- 游客:可查看基础看板与简版信息。
- 登录用户:账户中心、钱包绑定、积分同步。
- Pro 用户:
- 今日日内深度分析(含高温时段)
- 历史对账 + 未来日期分析
- 全平台智能气象推送
subgraph R["Pricing"]
R1["Entry 1 USD"]
R2["Dashboard 5 USD"]
R3["Bundle 5.5 USD"]
end
## 4. 收费与积分规则(默认)
subgraph AC["Access Control"]
AC1["Manual activation (P1)"]
AC2["Wallet/USDC detection (P2)"]
AC3["Entitlement middleware"]
end
- 套餐:`pro_monthly`5 USDC / 30 天)
- 抵扣:500 积分抵 1 USDC,最高抵 3 USDC
- 实付下限:2 USDC(当积分满额时)
subgraph G["Growth"]
G1["Accuracy reports"]
G2["Retention analytics"]
G3["User preference center"]
end
> 说明:具体运营策略可按阶段调整,生产参数建议放私有仓库。
A --> P
A --> R
A --> AC
A --> G
```
## 5. 建议的开源边界
---
请按 Open-Core 执行:
## 3. Packaging and Pricing
- 开源:基础能力与通用支付流程。
- 私有:商业风控、营销策略、关键运营参数、内部审计策略。
| Tier | Price | Value |
| :--------------- | :----------- | :---------------------------------------- |
| Telegram Channel | $1 / month | Low-noise proactive signal feed |
| Web Dashboard | $5 / month | Full multi-model context + reconciliation |
| VIP Bundle | $5.5 / month | Dashboard + signal stream |
详见:[Open-Core 与商用边界](OPEN_CORE_POLICY.md)
Payment direction:
## 6. 上线检查清单(收费前)
- Currency: Polygon USDC
- Phasing: manual activation first, then automated entitlement sync
1. 支付链路:创建 intent、提交 tx、确认入账、订阅开通全链路可回放。
2. 权限链路:前端/后端/Bot 对 Pro 权限判定一致。
3. 审计能力:支付日志、订阅变更、异常重试可追溯。
4. 通知策略:支付成功私发、群内通知降噪。
5. 安全边界:敏感配置不进仓库。
---
## 7. 后续路线
## 4. Execution Phases
```mermaid
graph LR
P1[Phase 1 Manual Beta] --> P2[Phase 2 Payment Automation]
P2 --> P3[Phase 3 Growth and B2B]
```
### Phase 1: Manual Beta
- Keep paid channel small, optimize signal quality first.
- Manual payment confirmation + manual entitlement grant.
- Invite-gated dashboard while access control hardens.
### Phase 2: Payment Automation
- Detect wallet payment events (USDC).
- Auto-issue/refresh entitlement.
- Enforce route-level and API-level access guards.
### Phase 3: Growth and Expansion
- Self-serve billing and subscription panel.
- Operator analytics and feature usage telemetry.
- Optional B2B API package for quant teams.
---
## 5. Technical Dependencies for Revenue
| Dependency | Why it matters |
| :------------------- | :--------------------------------------------------------------- |
| Entitlement guard | Prevents unpaid dashboard/API access |
| Subscriber store | Persistent paid user state |
| Audit trail | Explains why each alert fired |
| Observability | Detects degradation before churn |
| Frontend performance | Impacts conversion and retention (Speed Insights now integrated) |
---
## 6. Immediate Commercial Priorities
1. Finish robust entitlement middleware in frontend and backend.
2. Persist subscriber/payment state in managed DB.
3. Publish transparent monthly accuracy and signal-quality reports.
4. Add support playbooks for false-alert and stale-data incidents.
---
Last Updated: `2026-03-11`
- 支持更多链和稳定币。
- 引入退款与工单后台。
- 建立周/月留存与付费转化看板。
- 打通渠道分销与邀请码返利系统。
+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`
+45 -64
View File
@@ -1,92 +1,73 @@
# Technical Debt Backlog
# 技术债与工程待办(v1.4.0
Purpose: keep engineering debt explicit while shipping production features.
最后更新:`2026-03-14`
---
目标:在收费上线后,优先保证支付可靠性、权限一致性和运营可追溯性。
## 1. Debt Landscape
## 1. 债务快照
当前估计:**93% 稳定 / 7% 技术债**。
```mermaid
flowchart TD
A["Tech Debt"]
A["技术债"]
subgraph AR["Architecture"]
AR1["Monolithic bot entry"]
AR2["Shared runtime coupling"]
subgraph P["支付与订阅"]
P1["异常交易自动重放策略"]
P2["退款与工单流程"]
P3["多链结算对账"]
end
subgraph PI["Product Infra"]
PI1["Entitlement hardening"]
PI2["Subscription persistence"]
subgraph E["权限与运营"]
E1["前后端/Bot 权限矩阵回归"]
E2["积分与订阅冲突策略"]
end
subgraph Q["Quality"]
Q1["Replay harness"]
Q2["Broader regression tests"]
subgraph O["可观测性"]
O1["支付失败原因分层指标"]
O2["业务监控看板"]
end
subgraph O["Observability"]
O1["Alert evidence trace"]
O2["SLO dashboards"]
end
A --> AR
A --> PI
A --> Q
A --> P
A --> E
A --> O
```
Current system health estimate: **84% stable / 16% debt**.
## 2. 近期已关闭
---
- P1 支付主链路已上线(intent -> submit -> confirm)。
- 支付自动补单已上线(Event Loop + Confirm Loop)。
- 钱包绑定支持浏览器钱包 + WalletConnect。
- 账户中心与 Pro 权限展示链路打通。
- 钱包异动支持独立频道路由。
## 2. Recently Closed (2026-03-11)
## 3. 高优先级技术债
- Meteoblue API path fully removed from backend, frontend, config and docs.
- Market top-bucket duplicate temperature issue fixed (backend dedupe + frontend guard).
- Detail panel a11y conflict fixed (`aria-hidden` focus conflict resolved with `inert` + blur).
- Vercel Speed Insights integrated for frontend performance telemetry.
---
## 3. High Priority Debt
| Item | Impact | Suggested Work |
| 项目 | 影响 | 建议动作 |
| :-- | :-- | :-- |
| Monolithic bot entry (`bot_listener.py`) | Hard to test and safely refactor | Split orchestration, IO and analysis modules |
| Entitlement enforcement consistency | Revenue leakage risk | Align frontend middleware and backend enforcement |
| Subscriber persistence model | Manual operations do not scale | Move to managed PostgreSQL/Supabase state |
| Alert explainability | Operator trust and debugging cost | Standardize evidence payload per alert |
| 支付异常重放策略标准化 | 偶发确认失败需人工介入 | 建立 tx hash 自动回放 + 降级路径 |
| 退款与售后链路 | 商业闭环不完整 | 增加退款状态机与工单系统 |
| 订阅审计可视化 | 排障效率受限 | 建立订阅事件时间线视图 |
| 多邮箱绑定同 TG 账户策略 | 积分归属易混淆 | 引入主账号绑定策略与迁移工具 |
---
## 4. 中优先级技术债
## 4. Medium Priority Debt
| Item | Impact | Suggested Work |
| 项目 | 影响 | 建议动作 |
| :-- | :-- | :-- |
| Replay simulation harness | Hard to reproduce edge cases | Build deterministic replay over stored records |
| Chart/UI regression coverage | Visual regressions can slip | Add snapshot + interaction test coverage |
| Config centralization | Threshold changes are error-prone | Consolidate runtime knobs into structured config |
| Naming cleanup | Legacy terms reduce clarity | Refactor naming in market/weather boundary layer |
| 积分发放可解释性 | 用户理解成本高 | 输出积分来源明细(发言/签到/奖励) |
| 支付失败文案标准化 | 转化率受影响 | 建立错误码 -> 文案映射表 |
| 配置收敛 | 运维出错概率高 | 将支付/推送配置集中分组管理 |
---
## 5. 低优先级技术债
## 5. Low Priority Debt
| Item | Impact | Suggested Work |
| 项目 | 影响 | 建议动作 |
| :-- | :-- | :-- |
| Cold-start behavior | First request latency variance | Add warming strategy for top city routes |
| Storage abstraction | Local file assumptions remain | Continue moving state to remote services |
| 前端离线缓存能力 | 非核心 | 评估 Service Worker + IndexedDB |
| 冷启动波动 | 首屏抖动 | 热点城市预热 |
---
## 6. 下阶段里程碑
## 6. Next Milestones
1. Entitlement parity: one policy across frontend and backend.
2. Subscriber DB integration and migration scripts.
3. Alert evidence schema + tooling for quick operator audit.
4. Replay runner for weather/market mixed regression scenarios.
---
Last Updated: `2026-03-11`
1. 完成支付异常自动重放与告警分层。
2. 上线退款/售后后台最小版。
3. 建立商业化运营看板(支付、续费、留存)。
4. 完成权限矩阵自动化回归测试。
+36 -55
View File
@@ -1,92 +1,73 @@
# 技术债与工程待办
# 技术债与工程待办v1.4.0
目标:在持续交付的同时,把关键技术债显式化、可追踪化。
最后更新:`2026-03-14`
---
目标:在收费上线后,优先保证支付可靠性、权限一致性和运营可追溯性。
## 1. 技术债全景
## 1. 债务快照
当前估计:**93% 稳定 / 7% 技术债**。
```mermaid
flowchart TD
A["技术债"]
subgraph AR["架构层"]
AR1["机器人入口过于集中"]
AR2["共享运行时耦合"]
subgraph P["支付与订阅"]
P1["异常交易自动重放策略"]
P2["退款与工单流程"]
P3["多链结算对账"]
end
subgraph PI["产品基础设施"]
PI1["订阅权限一致性"]
PI2["付费用户持久化"]
end
subgraph Q["质量保障"]
Q1["回放测试能力"]
Q2["UI 回归覆盖不足"]
subgraph E["权限与运营"]
E1["前后端/Bot 权限矩阵回归"]
E2["积分与订阅冲突策略"]
end
subgraph O["可观测性"]
O1["告警证据链"]
O2["SLO 看板"]
O1["支付失败原因分层指标"]
O2["业务监控看板"]
end
A --> AR
A --> PI
A --> Q
A --> P
A --> E
A --> O
```
当前系统健康度估计:**84% 稳定 / 16% 技术债**。
## 2. 近期已关闭
---
## 2. 最近已关闭项(2026-03-11
- Meteoblue API 全链路移除(后端/前端/配置/文档)
- 市场温度桶重复刷屏问题修复(后端去重 + 前端兜底)。
- 详情面板可访问性告警修复(`aria-hidden` 焦点冲突改为 `inert + blur`)。
- 前端已接入 Vercel Speed Insights。
---
- P1 支付主链路已上线(intent -> submit -> confirm)。
- 支付自动补单已上线(Event Loop + Confirm Loop)。
- 钱包绑定支持浏览器钱包 + WalletConnect。
- 账户中心与 Pro 权限展示链路打通。
- 钱包异动支持独立频道路由
## 3. 高优先级技术债
| 项目 | 影响 | 建议动作 |
| :-- | :-- | :-- |
| 机器人入口单体化(`bot_listener.py`) | 测试和重构风险高 | 拆分为编排层、IO 层、分析层 |
| 订阅权限策略不完全统一 | 可能造成付费泄露 | 前后端统一权限校验策略 |
| 付费用户状态持久化不足 | 人工运营不可扩展 | 迁移到托管 DBPostgreSQL/Supabase |
| 告警可解释性不足 | 运维排障成本高 | 统一告警证据字段(Evidence Schema |
---
| 支付异常重放策略标准化 | 偶发确认失败需人工介入 | 建立 tx hash 自动回放 + 降级路径 |
| 退款与售后链路 | 商业闭环不完整 | 增加退款状态机与工单系统 |
| 订阅审计可视化 | 排障效率受限 | 建立订阅事件时间线视图 |
| 多邮箱绑定同 TG 账户策略 | 积分归属易混淆 | 引入主账号绑定策略与迁移工具 |
## 4. 中优先级技术债
| 项目 | 影响 | 建议动作 |
| :-- | :-- | :-- |
| 回放仿真能力不足 | 边缘场景难复现 | 基于历史记录构建可重复 Replay |
| 图表/UI 回归覆盖不足 | 视觉回归风险 | 增加快照与交互自动化测试 |
| 阈值配置分散 | 改动成本高且易错 | 统一收口到结构化配置 |
| 命名历史包袱 | 认知成本高 | 系统化命名治理 |
---
| 积分发放可解释性 | 用户理解成本高 | 输出积分来源明细(发言/签到/奖励) |
| 支付失败文案标准化 | 转化率受影响 | 建立错误码 -> 文案映射表 |
| 配置收敛 | 运维出错概率高 | 将支付/推送配置集中分组管理 |
## 5. 低优先级技术债
| 项目 | 影响 | 建议动作 |
| :-- | :-- | :-- |
| 冷启动波动 | 首次请求延迟不稳定 | 热点城市路由预热 |
| 本地文件状态依赖 | 云端弹性场景受限 | 持续迁移到远程存储 |
---
| 前端离线缓存能力 | 非核心 | 评估 Service Worker + IndexedDB |
| 冷启动波动 | 首屏抖动 | 热点城市预热 |
## 6. 下阶段里程碑
1. 完成前后端订阅权限一致化
2. 上线付费用户持久化与迁移脚本
3. 建立告警证据标准并接入运维排障流
4. 落地天气+市场混合回放回归测试。
---
最后更新:`2026-03-11`
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
+6
View File
@@ -1,4 +1,10 @@
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=
+60 -45
View File
@@ -1,32 +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
- React (component-driven dashboard)
- Tailwind CSS
- Leaflet (map runtime)
- Chart.js (charts with manual lifecycle wrapper)
- Typed store + typed data client
- React + Tailwind
- Leaflet + Chart.js
- Supabase Auth
- WalletConnect + 浏览器 EVM 钱包
## Production Model
## 运行模型
- Vercel serves the web UI and BFF route handlers
- FastAPI on VPS serves weather APIs only
- The old FastAPI static website has been removed
- The production page shell is React-driven (`components/dashboard/*`), with no runtime dependency on `public/static/app.js`
1. 浏览器 -> Next 应用(`frontend`
2. Next Route Handlers`/api/*`-> FastAPI 后端
3. FastAPI -> 分析服务 / 支付服务
Current request flow:
- Browser -> Vercel frontend
- React store/client -> Next route handlers
- Next route handlers -> FastAPI API
## Local Development
## 本地开发
```bash
cd frontend
@@ -35,44 +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]`
Current frontend behavior:
- `/` keeps the world overview layout and initial city temperatures preload
- Marker click: focus map + open right city card + render nearby stations
- Right-card "今日日内分析": opens modal and freezes map motion
- Blank-map click: closes right card only, without resetting camera
鉴权:
## Vercel Deployment
- `GET /api/auth/me`
1. Import the repo into Vercel
2. Set Root Directory to `frontend`
3. Set `POLYWEATHER_API_BASE_URL`
4. Deploy
支付:
## Notes
- `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`
- Backend CORS must allow `https://polyweather-pro.vercel.app`
- City detail cache TTL is 5 minutes with revision probe; manual refresh bypasses cache
- UI layout and sizing remain aligned with the legacy visual contract after React migration
## 缓存行为
Last updated: 2026-03-09
- `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 },
);
}
}
+21 -8
View File
@@ -1,35 +1,48 @@
import { NextResponse } from "next/server";
import { buildBackendRequestHeaders } from "@/lib/backend-auth";
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: buildBackendRequestHeaders(),
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;
}
}
+14 -6
View File
@@ -1,5 +1,8 @@
import { NextRequest, NextResponse } from "next/server";
import { buildBackendRequestHeaders } from "@/lib/backend-auth";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -8,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;
@@ -30,23 +34,27 @@ export async function GET(
const url = `${API_BASE}/api/city/${encodeURIComponent(name)}/detail?${searchParams.toString()}`;
try {
const auth = await buildBackendRequestHeaders(req);
const res = await fetch(url, {
headers: buildBackendRequestHeaders(),
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 aggregate", detail: String(error) },
{ status: 500 },
);
return response;
}
}
+14 -6
View File
@@ -1,5 +1,8 @@
import { NextRequest, NextResponse } from "next/server";
import { buildBackendRequestHeaders } from "@/lib/backend-auth";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -8,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;
@@ -19,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: buildBackendRequestHeaders(),
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;
}
}
+28 -6
View File
@@ -1,5 +1,9 @@
import { NextRequest, NextResponse } from "next/server";
import { buildBackendRequestHeaders } from "@/lib/backend-auth";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import { buildCachedJsonResponse } from "@/lib/http-cache";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -8,34 +12,52 @@ 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;
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: buildBackendRequestHeaders(),
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);
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) {
return NextResponse.json(
const response = NextResponse.json(
{ error: "Failed to fetch city summary", detail: String(error) },
{ status: 500 },
);
return response;
}
}
+21 -8
View File
@@ -1,40 +1,53 @@
import { NextResponse } from "next/server";
import { buildBackendRequestHeaders } from "@/lib/backend-auth";
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: buildBackendRequestHeaders(),
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>
);
}
+11 -3
View File
@@ -33,9 +33,17 @@ export default async function EntitlementRequiredPage({ searchParams }: Props) {
Entitlement Required
</h1>
<p style={{ marginTop: 12, color: "#9fb2da", lineHeight: 1.6 }}>
This dashboard is protected. Append{" "}
<code>?access_token=&lt;your-token&gt;</code> to the URL once, and
the session cookie will be set automatically.
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>
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

+12 -4
View File
@@ -1,12 +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({
@@ -15,10 +26,6 @@ export default function RootLayout({
return (
<html lang="zh-CN" className="dark">
<head>
<link
rel="stylesheet"
href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
/>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="" />
<link
@@ -28,6 +35,7 @@ export default function RootLayout({
</head>
<body className="min-h-screen font-sans antialiased">
{children}
<WebVitalsReporter />
<Analytics />
<SpeedInsights />
</body>
+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>
);
}
+77 -20
View File
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { startTransition, useEffect, useMemo, useState } from "react";
import clsx from "clsx";
import { useDashboardStore } from "@/hooks/useDashboardStore";
import { useI18n } from "@/hooks/useI18n";
@@ -8,35 +8,68 @@ 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>>({
high: true,
medium: true,
low: false,
other: false,
});
const [expandedGroups, setExpandedGroups] = useState<
Record<RiskGroupKey, boolean>
>(DEFAULT_EXPANDED_GROUPS);
const sortedCities = useMemo(() => [...store.cities].sort((a, b) => {
const aSelected = a.name === selectedCity;
const bSelected = b.name === selectedCity;
if (aSelected !== bSelected) return aSelected ? -1 : 1;
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, selectedCity]);
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[]> = {
@@ -61,6 +94,26 @@ export function CitySidebar() {
);
}, [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") },
@@ -118,7 +171,11 @@ export function CitySidebar() {
key={city.name}
type="button"
className={clsx("city-item", isActive && "active")}
onClick={() => void store.selectCity(city.name)}
onClick={() =>
startTransition(() => {
void store.selectCity(city.name);
})
}
>
<div className="city-item-main">
<span className={clsx("risk-dot", city.risk_level)} />
@@ -549,6 +549,25 @@
flex-wrap: wrap;
}
.root :global(.pro-locked) {
filter: grayscale(0.8) opacity(0.7);
position: relative;
}
.root :global(.pro-locked::after) {
content: "PRO";
position: absolute;
top: -4px;
right: -4px;
background: var(--accent-blue);
color: white;
font-size: 7px;
padding: 1px 3px;
border-radius: 3px;
font-weight: 900;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
}
.root :global(.risk-badge) {
font-size: 11px;
font-weight: 600;
@@ -1431,8 +1450,8 @@
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
background: rgba(2, 6, 23, 0.75);
backdrop-filter: blur(12px);
z-index: 10000;
display: flex;
align-items: center;
@@ -1524,6 +1543,17 @@
overflow-y: auto;
}
/* Keep scroll behavior but hide visual scrollbar in today's/future analysis modal */
.root :global(.modal-content.large.future-modal .modal-body) {
-ms-overflow-style: none;
scrollbar-width: none;
}
.root :global(.modal-content.large.future-modal .modal-body::-webkit-scrollbar) {
width: 0;
height: 0;
}
.root :global(.history-modal .modal-body) {
padding: 24px 28px 28px;
}
@@ -1643,6 +1673,29 @@
}
/* ── Info Button ── */
.root :global(.account-btn) {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 6px 12px;
border-radius: 8px;
border: 1px solid rgba(34, 211, 238, 0.34);
background: rgba(34, 211, 238, 0.08);
color: var(--accent-cyan);
font-size: 12px;
font-weight: 700;
letter-spacing: 0.02em;
text-decoration: none;
transition: var(--transition);
}
.root :global(.account-btn:hover) {
background: rgba(34, 211, 238, 0.16);
border-color: rgba(34, 211, 238, 0.62);
color: #f8fbff;
transform: translateY(-1px);
}
.root :global(.info-btn) {
background: rgba(99, 102, 241, 0.1);
border: 1px solid rgba(99, 102, 241, 0.3);
+180 -126
View File
@@ -1,8 +1,8 @@
"use client";
import { ChartConfiguration } from "chart.js/auto";
import type { ChartConfiguration } from "chart.js";
import clsx from "clsx";
import { useEffect, useRef } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { ForecastTable } from "@/components/dashboard/PanelSections";
import { useChart } from "@/hooks/useChart";
import { useDashboardStore } from "@/hooks/useDashboardStore";
@@ -17,100 +17,102 @@ import {
function DetailMiniTemperatureChart({ detail }: { detail: CityDetail }) {
const { locale, t } = useI18n();
const chartData = getTemperatureChartData(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],
);
const chartData = useMemo(
() => getTemperatureChartData(detail, locale),
[detail, locale],
);
const canvasRef = useChart(() => {
if (!chartData) {
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: 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 },
},
},
},
},
data: { datasets: [], labels: [] },
type: "line",
} satisfies ChartConfiguration<"line">;
},
[chartData, detail.temp_symbol, locale],
);
}
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">
@@ -128,7 +130,9 @@ 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 ||
@@ -139,7 +143,10 @@ export function DetailPanel() {
Boolean(detail) &&
!store.loadingState.cityDetail &&
!isOverlayOpen;
const profileStats = detail ? getCityProfileStats(detail, locale) : [];
const profileStats = useMemo(
() => (detail ? getCityProfileStats(detail, locale) : []),
[detail, locale],
);
const scenery = getCityScenery(detail?.name);
const blurActiveElement = () => {
if (typeof document === "undefined") return;
@@ -170,6 +177,42 @@ export function DetailPanel() {
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}
@@ -193,35 +236,40 @@ export function DetailPanel() {
<span className={clsx("risk-badge", detail?.risk?.level || "low")}>
{getRiskBadgeLabel(detail?.risk?.level, locale)}
</span>
<span className="local-time">
{detail
? `${detail.local_date} ${detail.local_time}`
: t("detail.waitSelect")}
</span>
<button
type="button"
className="history-btn"
title={t("detail.todayAnalysis")}
onClick={() => {
blurActiveElement();
void store.openTodayModal();
}}
disabled={!detail}
>
{t("detail.todayAnalysis")}
</button>
<button
type="button"
className="history-btn"
title={t("detail.history")}
onClick={() => {
blurActiveElement();
void store.openHistory();
}}
disabled={!detail}
>
{t("detail.history")}
</button>
<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>
@@ -263,7 +311,9 @@ export function DetailPanel() {
</>
) : (
<div className="detail-scenery-fallback">
<span className="detail-scenery-kicker">{detail.display_name}</span>
<span className="detail-scenery-kicker">
{detail.display_name}
</span>
<strong className="detail-scenery-title">
{t("detail.sceneryTitle")}
</strong>
@@ -286,12 +336,16 @@ export function DetailPanel() {
</div>
</section>
<section className="detail-section">
<section className="detail-section rounded-2xl">
<h3>{t("detail.todayMiniTrend")}</h3>
<DetailMiniTemperatureChart detail={detail} />
{heavyContentReady ? (
<DetailMiniTemperatureChart detail={detail} />
) : (
<div className="detail-mini-meta">{t("detail.loading")}</div>
)}
</section>
<ForecastTable />
{heavyContentReady ? <ForecastTable /> : null}
</>
)}
</div>
@@ -12,24 +12,22 @@ import {
Wind,
} from "lucide-react";
import { ChartConfiguration } from "chart.js/auto";
import type { ChartConfiguration } from "chart.js";
import clsx from "clsx";
import { CSSProperties } from "react";
import { CSSProperties, useMemo } from "react";
import { useChart } from "@/hooks/useChart";
import { useDashboardStore } from "@/hooks/useDashboardStore";
import { useI18n } from "@/hooks/useI18n";
import { ProFeaturePaywall } from "@/components/dashboard/ProFeaturePaywall";
import {
ModelForecast,
ProbabilityDistribution,
} from "@/components/dashboard/PanelSections";
import {
getClimateDrivers,
getFutureModalView,
getSettlementRiskNarrative,
getShortTermNowcastLines,
parseAiAnalysis,
getTemperatureChartData,
getWeatherSummary,
parseAiAnalysis,
} from "@/lib/dashboard-utils";
function normalizeMarketValue(value?: number | null) {
@@ -188,8 +186,10 @@ function DailyTemperatureChart({ dateStr }: { dateStr: string }) {
const detail = store.selectedDetail;
const view = detail ? getFutureModalView(detail, dateStr, locale) : null;
const isToday = detail ? dateStr === detail.local_date : false;
const todayChartData =
detail && isToday ? getTemperatureChartData(detail, locale) : null;
const todayChartData = useMemo(
() => (detail && isToday ? getTemperatureChartData(detail, locale) : null),
[detail, isToday, locale],
);
const canvasRef = useChart(() => {
if (!detail || !view) {
@@ -247,7 +247,9 @@ function DailyTemperatureChart({ dateStr }: { dateStr: string }) {
borderWidth: 0,
data: todayChartData.datasets.metarPoints,
fill: false,
label: locale === "en-US" ? "METAR Observation" : "METAR 实测",
label:
todayChartData.observationLabel ||
(locale === "en-US" ? "Observation" : "观测实况"),
order: 0,
pointHoverRadius: 7,
pointRadius: 5,
@@ -438,15 +440,13 @@ export function FutureForecastModal() {
const detail = store.selectedDetail;
const marketScan = store.selectedMarketScan;
const dateStr = store.futureModalDate;
const isPro = store.proAccess.subscriptionActive;
const isProLoading = store.proAccess.loading;
if (!detail || !dateStr) return null;
const isToday = dateStr === detail.local_date;
const view = getFutureModalView(detail, dateStr, locale);
const nowcastRows = getShortTermNowcastLines(detail, dateStr, locale);
const riskLines = getSettlementRiskNarrative(detail, locale);
const climateDrivers = getClimateDrivers(detail, locale);
const ai = parseAiAnalysis(detail.ai_analysis);
const scorePosition = `${50 + view.front.score / 2}%`;
const barStyle = {
"--score-position": scorePosition,
@@ -534,6 +534,30 @@ export function FutureForecastModal() {
detail.current?.raw_metar,
detail.current?.visibility_mi,
);
const ai = parseAiAnalysis(detail.ai_analysis);
const risk = detail.risk || {};
const settlementSourceCode = String(
detail.current?.settlement_source || "",
).toLowerCase();
const isOfficialSettlementSource =
settlementSourceCode === "hko" || settlementSourceCode === "cwa";
const settlementProfileLabel = isOfficialSettlementSource
? locale === "en-US"
? "Settlement source"
: "结算源"
: t("section.airport");
const settlementProfileValue =
settlementSourceCode === "hko"
? locale === "en-US"
? "Hong Kong Observatory (HKO)"
: "香港天文台 (HKO)"
: settlementSourceCode === "cwa"
? locale === "en-US"
? "Central Weather Administration (CWA)"
: "交通部中央气象署 (CWA)"
: risk.airport
? `${risk.airport}${risk.icao ? ` (${risk.icao})` : ""}`
: "--";
return (
<div
@@ -547,224 +571,180 @@ export function FutureForecastModal() {
}
}}
>
<div className="modal-content large future-modal">
<div className="modal-header">
<h2
id="future-modal-title"
className="future-modal-title-with-actions"
>
<span>
{isToday
? t("future.todayTitle", {
city: detail.display_name.toUpperCase(),
})
: t("future.dateTitle", {
city: detail.display_name.toUpperCase(),
date: dateStr,
})}
</span>
<button
className={clsx(
"future-refresh-btn",
store.loadingState.marketScan && "spinning",
)}
onClick={() => {
if (isToday) {
void store.openTodayModal(true);
return;
}
store.openFutureModal(dateStr, true);
}}
title={locale === "en-US" ? "Refresh Data" : "刷新数据"}
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="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>
</h2>
<button
type="button"
className="modal-close"
aria-label={
isToday ? t("future.closeTodayAria") : t("future.closeDateAria")
}
onClick={store.closeFutureModal}
>
×
</button>
{isProLoading ? (
<div
className="modal-content large"
style={{ padding: "40px", textAlign: "center" }}
>
<div style={{ color: "var(--text-muted)" }}>
{t("dashboard.loading")}
</div>
</div>
<div className="modal-body future-modal-body">
{isToday ? (
<div className="future-v2-layout">
<aside className="future-v2-left">
<section className="future-v2-card future-v2-hero-card">
<h3 className="future-v2-hero-title">
{locale === "en-US"
? "Current Conditions"
: "实况与气象特征"}
</h3>
<div className="future-v2-hero-main">
<div className="future-v2-hero-temp">{currentTempText}</div>
<div className="future-v2-hero-divider" />
<div className="future-v2-hero-weather">
<span className="future-v2-hero-icon">
<WeatherIcon
emoji={weatherSummary.weatherIcon}
size={42}
/>
</span>
<span>{weatherSummary.weatherText}</span>
</div>
</div>
<div className="future-v2-hero-obs">
@{detail.current?.obs_time || "--"}
</div>
<div className="future-v2-mini-grid">
<div className="future-v2-mini-item">
<span>
{locale === "en-US" ? "High So Far" : "目前最高温"}
</span>
<strong>
{topObservedTemp ?? "--"}
{detail.temp_symbol}
</strong>
</div>
<div className="future-v2-mini-item">
<span>{locale === "en-US" ? "Sunrise" : "日出时间"}</span>
<strong>{detail.forecast?.sunrise || "--"}</strong>
</div>
<div className="future-v2-mini-item">
<span>{locale === "en-US" ? "Sunset" : "日落时间"}</span>
<strong>{detail.forecast?.sunset || "--"}</strong>
</div>
<div className="future-v2-mini-item">
<span>
{locale === "en-US" ? "Sunshine" : "日照时长"}
</span>
<strong>
{detail.forecast?.sunshine_hours != null
? `${detail.forecast.sunshine_hours}h`
: "--"}
</strong>
</div>
</div>
</section>
<section className="future-v2-card">
<h4 className="future-v2-card-title">
{locale === "en-US" ? "Current Metrics" : "当前指标"}
</h4>
<div className="future-v2-mini-grid future-v2-mini-grid-tight">
<div className="future-v2-mini-item">
<span>{locale === "en-US" ? "Humidity" : "湿度"}</span>
<strong>{humidityText}</strong>
</div>
<div className="future-v2-mini-item">
<span>{locale === "en-US" ? "Dew Point" : "露点"}</span>
<strong>{dewpointText}</strong>
</div>
<div className="future-v2-mini-item">
<span>{locale === "en-US" ? "Wind" : "风速"}</span>
<strong>{windText}</strong>
</div>
<div className="future-v2-mini-item">
<span>
{locale === "en-US" ? "Visibility" : "能见度"}
</span>
<strong>{visibilityText}</strong>
</div>
</div>
</section>
<section className="future-v2-card">
<h4 className="future-v2-card-title">
{locale === "en-US" ? "Market Alignment" : "市场对照"}
</h4>
<div className="future-v2-market-v3">
{/* Loading Overlay */}
{store.loadingState.marketScan && (
<div className="market-layer-loading-overlay">
<div
className="loading-spinner"
style={{
marginBottom: "8px",
width: "24px",
height: "24px",
borderWidth: "2px",
}}
/>
{locale === "en-US"
? "Crunching Polymarket Edges..."
: "正在计算市场对手盘..."}
) : !isPro ? (
<ProFeaturePaywall
feature={isToday ? "today" : "future"}
onClose={store.closeFutureModal}
/>
) : (
<div className="modal-content large future-modal">
<div className="modal-header">
<h2
id="future-modal-title"
className="future-modal-title-with-actions"
>
<span>
{isToday
? t("future.todayTitle", {
city: detail.display_name.toUpperCase(),
})
: t("future.dateTitle", {
city: detail.display_name.toUpperCase(),
date: dateStr,
})}
</span>
<button
className={clsx(
"future-refresh-btn",
store.loadingState.marketScan && "spinning",
)}
disabled={!isPro || isProLoading}
onClick={() => {
if (isToday) {
void store.openTodayModal(true);
return;
}
store.openFutureModal(dateStr, true);
}}
title={
!isPro
? locale === "en-US"
? "Pro subscription required"
: "需要 Pro 订阅"
: locale === "en-US"
? "Refresh Data"
: "刷新数据"
}
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="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>
</h2>
<button
type="button"
className="modal-close"
aria-label={
isToday ? t("future.closeTodayAria") : t("future.closeDateAria")
}
onClick={store.closeFutureModal}
>
×
</button>
</div>
<div className="modal-body future-modal-body">
{isToday ? (
<div className="future-v2-layout">
<aside className="future-v2-left">
<section className="future-v2-card future-v2-hero-card">
<h3 className="future-v2-hero-title">
{locale === "en-US"
? "Current Conditions"
: "实况与气象特征"}
</h3>
<div className="future-v2-hero-main">
<div className="future-v2-hero-temp">
{currentTempText}
</div>
)}
{/* Layer 1: Target & Edge */}
<div className="market-layer-target">
<div className="market-target-header">
<span>
{locale === "en-US"
? "Target Bucket:"
: "结算温度区间:"}
<div className="future-v2-hero-divider" />
<div className="future-v2-hero-weather">
<span className="future-v2-hero-icon">
<WeatherIcon
emoji={weatherSummary.weatherIcon}
size={42}
/>
</span>
<strong className="market-target-bucket">
{settlementBucketLabel}
</strong>
<span>{weatherSummary.weatherText}</span>
</div>
</div>
{/* Layer 3: Context */}
<div className="market-layer-context">
<div className="market-sub-title">
👀 {locale === "en-US" ? "Market Radar" : "情绪雷达"}
</div>
<div className="market-context-row">
<div className="future-v2-hero-obs">
@{detail.current?.obs_time || "--"}
</div>
<div className="future-v2-mini-grid">
<div className="future-v2-mini-item">
<span>
{locale === "en-US"
? "Top Volume Bucket:"
: "市场当前押注最热:"}
{locale === "en-US" ? "High So Far" : "目前最高温"}
</span>
<strong>
{hottestBucketLabel}{" "}
{hottestBucketProb !== "--"
? `(${hottestBucketProb})`
: ""}
{topObservedTemp ?? "--"}
{detail.temp_symbol}
</strong>
</div>
<div className="future-v2-mini-item">
<span>
{locale === "en-US" ? "Sunrise" : "日出时间"}
</span>
<strong>{detail.forecast?.sunrise || "--"}</strong>
</div>
<div className="future-v2-mini-item">
<span>
{locale === "en-US" ? "Sunset" : "日落时间"}
</span>
<strong>{detail.forecast?.sunset || "--"}</strong>
</div>
<div className="future-v2-mini-item">
<span>
{locale === "en-US" ? "Sunshine" : "日照时长"}
</span>
<strong>
{detail.forecast?.sunshine_hours != null
? `${detail.forecast.sunshine_hours}h`
: "--"}
</strong>
</div>
</div>
</div>
<div className="future-v2-market-signal mt-3">
{locale === "en-US" ? "Signal" : "信号"}:{" "}
<strong>{marketSignal}</strong>
</div>
</section>
</aside>
</section>
<main className="future-v2-right">
<section className="future-modal-section future-v2-main-chart">
<h3>
{locale === "en-US"
? "Today's temperature forecast (obs + market)"
: "今日气温预测(观测 + 市场)"}
</h3>
<DailyTemperatureChart dateStr={dateStr} />
</section>
<section className="future-v2-card">
<h4 className="future-v2-card-title">
{locale === "en-US" ? "Current Metrics" : "当前指标"}
</h4>
<div className="future-v2-mini-grid future-v2-mini-grid-tight">
<div className="future-v2-mini-item">
<span>{locale === "en-US" ? "Humidity" : "湿度"}</span>
<strong>{humidityText}</strong>
</div>
<div className="future-v2-mini-item">
<span>{locale === "en-US" ? "Dew Point" : "露点"}</span>
<strong>{dewpointText}</strong>
</div>
<div className="future-v2-mini-item">
<span>{locale === "en-US" ? "Wind" : "风速"}</span>
<strong>{windText}</strong>
</div>
<div className="future-v2-mini-item">
<span>
{locale === "en-US" ? "Visibility" : "能见度"}
</span>
<strong>{visibilityText}</strong>
</div>
</div>
</section>
<div className="future-modal-grid">
<section className="future-modal-section">
<h3>{t("future.probability")}</h3>
<div style={{ position: "relative", minHeight: "120px" }}>
<section className="future-v2-card">
<h4 className="future-v2-card-title">
{locale === "en-US" ? "Market Alignment" : "市场对照"}
</h4>
<div className="future-v2-market-v3">
{/* Loading Overlay */}
{store.loadingState.marketScan && (
<div className="market-layer-loading-overlay">
@@ -779,28 +759,100 @@ export function FutureForecastModal() {
/>
{locale === "en-US"
? "Crunching Polymarket Edges..."
: "正在同步市场挂单..."}
: "正在计算市场对手盘..."}
</div>
)}
<ProbabilityDistribution
{/* Layer 1: Target & Edge */}
<div className="market-layer-target">
<div className="market-target-header">
<span>
{locale === "en-US"
? "Target Bucket:"
: "结算温度区间:"}
</span>
<strong className="market-target-bucket">
{settlementBucketLabel}
</strong>
</div>
</div>
{/* Layer 3: Context */}
<div className="market-layer-context">
<div className="market-sub-title">
👀 {locale === "en-US" ? "Market Radar" : "情绪雷达"}
</div>
<div className="market-context-row">
<span>
{locale === "en-US"
? "Top Volume Bucket:"
: "市场当前押注最热:"}
</span>
<strong>
{hottestBucketLabel}{" "}
{hottestBucketProb !== "--"
? `(${hottestBucketProb})`
: ""}
</strong>
</div>
</div>
</div>
<div className="future-v2-market-signal mt-3">
{locale === "en-US" ? "Signal" : "信号"}:{" "}
<strong>{marketSignal}</strong>
</div>
</section>
</aside>
<main className="future-v2-right">
<section className="future-modal-section future-v2-main-chart">
<h3>
{locale === "en-US"
? "Today's temperature forecast (obs + market)"
: "今日气温预测(观测 + 市场)"}
</h3>
<DailyTemperatureChart dateStr={dateStr} />
</section>
<div className="future-modal-grid">
<section className="future-modal-section">
<h3>{t("future.probability")}</h3>
<div style={{ position: "relative", minHeight: "120px" }}>
{/* Loading Overlay */}
{store.loadingState.marketScan && (
<div className="market-layer-loading-overlay">
<div
className="loading-spinner"
style={{
marginBottom: "8px",
width: "24px",
height: "24px",
borderWidth: "2px",
}}
/>
{locale === "en-US"
? "Crunching Polymarket Edges..."
: "正在同步市场挂单..."}
</div>
)}
<ProbabilityDistribution
detail={detail}
targetDate={dateStr}
hideTitle
marketScan={marketScan}
/>
</div>
</section>
<section className="future-modal-section">
<h3>{t("future.models")}</h3>
<ModelForecast
detail={detail}
targetDate={dateStr}
hideTitle
marketScan={marketScan}
/>
</div>
</section>
<section className="future-modal-section">
<h3>{t("future.models")}</h3>
<ModelForecast
detail={detail}
targetDate={dateStr}
hideTitle
/>
</section>
</div>
</section>
</div>
<div className="future-modal-grid">
<section className="future-modal-section">
<h3>{t("future.structureToday")}</h3>
<div className="future-front-score">
@@ -835,9 +887,7 @@ export function FutureForecastModal() {
<div className="future-trend-grid">
{view.front.metrics.slice(0, 6).map((metric) => (
<div key={metric.label} className="future-trend-card">
<div className="future-trend-label">
{metric.label}
</div>
<div className="future-trend-label">{metric.label}</div>
<div
className={clsx(
"future-trend-value",
@@ -854,111 +904,137 @@ export function FutureForecastModal() {
</section>
<section className="future-modal-section">
<h3>{t("future.ai")}</h3>
<div className="future-text-block">
{ai.summary ? (
<div>{ai.summary}</div>
) : (
<div>{t("future.noAi")}</div>
)}
{ai.bullets.length > 0 && (
<div style={{ marginTop: "10px" }}>
{ai.bullets.slice(0, 3).map((item) => (
<div key={item}>{item}</div>
))}
<h3>
{locale === "en-US"
? "City Risk Profile & Airport Narrative"
: "城市风险档案与机场报文解读"}
</h3>
<div className="future-modal-grid">
<div>
<h4 className="future-v2-card-title">
{locale === "en-US" ? "City Risk Profile" : "城市风险档案"}
</h4>
<div className="risk-info" style={{ marginTop: "10px" }}>
{!risk.airport ? (
<span style={{ color: "var(--text-muted)" }}>
{t("section.noRiskProfile")}
</span>
) : (
<>
<div className="risk-row">
<span className="risk-label">
{settlementProfileLabel}
</span>
<span>{settlementProfileValue}</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>
) : null}
</>
)}
</div>
)}
<div style={{ marginTop: "12px" }}>
{nowcastRows.slice(0, 4).map(([label, value]) => (
<div key={label}>
<strong>{label}: </strong>
{value}
</div>
))}
</div>
{riskLines.length > 0 && (
<div style={{ marginTop: "12px" }}>
<strong>
{locale === "en-US" ? "Risk" : "风险"}:{" "}
</strong>
{riskLines[0]}
<div>
<h4 className="future-v2-card-title">{t("future.ai")}</h4>
<div className="ai-box" style={{ marginTop: "10px" }}>
{!ai.summary && ai.bullets.length === 0 ? (
<span className="ai-placeholder">
{t("future.noAi")}
</span>
) : (
<>
{ai.summary ? (
<div className="ai-summary">{ai.summary}</div>
) : null}
{ai.bullets.length > 0 ? (
<ul className="ai-list">
{ai.bullets.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
) : null}
</>
)}
</div>
)}
{climateDrivers.length > 0 && (
<div style={{ marginTop: "8px" }}>
<strong>
{locale === "en-US" ? "Climate" : "气候"}:{" "}
</strong>
{climateDrivers[0].text}
</div>
)}
</div>
</div>
</section>
</div>
</main>
</div>
) : (
<>
<div className="history-stats">
<div className="h-stat-card">
<span className="label">{t("future.targetForecast")}</span>
<span className="val">
{view.forecastEntry?.max_temp ?? "--"}
{detail.temp_symbol}
</span>
</div>
<div className="h-stat-card">
<span className="label">{t("future.deb")}</span>
<span className="val">
{view.deb ?? "--"}
{detail.temp_symbol}
</span>
</div>
<div className="h-stat-card">
<span className="label">{t("future.mu")}</span>
<span className="val">
{view.mu != null
? `${view.mu.toFixed(1)}${detail.temp_symbol}`
: "--"}
</span>
</div>
<div className="h-stat-card">
<span className="label">{t("future.score")}</span>
<span className="val">
{view.front.score > 0 ? "+" : ""}
{view.front.score}
</span>
</div>
</main>
</div>
) : (
<>
<div className="history-stats">
<div className="h-stat-card">
<span className="label">{t("future.targetForecast")}</span>
<span className="val">
{view.forecastEntry?.max_temp ?? "--"}
{detail.temp_symbol}
</span>
</div>
<div className="h-stat-card">
<span className="label">{t("future.deb")}</span>
<span className="val">
{view.deb ?? "--"}
{detail.temp_symbol}
</span>
</div>
<div className="h-stat-card">
<span className="label">{t("future.mu")}</span>
<span className="val">
{view.mu != null
? `${view.mu.toFixed(1)}${detail.temp_symbol}`
: "--"}
</span>
</div>
<div className="h-stat-card">
<span className="label">{t("future.score")}</span>
<span className="val">
{view.front.score > 0 ? "+" : ""}
{view.front.score}
</span>
</div>
</div>
<section className="future-modal-section">
<h3>{t("future.targetTempTrend")}</h3>
<DailyTemperatureChart dateStr={dateStr} />
</section>
<section className="future-modal-section">
<h3>{t("future.targetTempTrend")}</h3>
<DailyTemperatureChart dateStr={dateStr} />
</section>
<div className="future-modal-grid">
<section className="future-modal-section">
<h3>{t("future.probability")}</h3>
<ProbabilityDistribution
detail={detail}
targetDate={dateStr}
hideTitle
marketScan={marketScan}
/>
</section>
<section className="future-modal-section">
<h3>{t("future.models")}</h3>
<ModelForecast
detail={detail}
targetDate={dateStr}
hideTitle
/>
</section>
</div>
</>
)}
<div className="future-modal-grid">
<section className="future-modal-section">
<h3>{t("future.probability")}</h3>
<ProbabilityDistribution
detail={detail}
targetDate={dateStr}
hideTitle
marketScan={marketScan}
/>
</section>
<section className="future-modal-section">
<h3>{t("future.models")}</h3>
<ModelForecast
detail={detail}
targetDate={dateStr}
hideTitle
/>
</section>
</div>
</>
)}
</div>
</div>
</div>
)}
</div>
);
}
+2 -2
View File
@@ -14,7 +14,7 @@ const GUIDE_CARDS = {
title: "结算概率引擎",
},
{
body: "Polymarket 结算逻辑以机场 METAR 为主。系统优先使用 Aviation Weather API 的机场报文与原始 METAR,并区分观测时间与接收时间,避免把发布延迟误认为温度变化。",
body: "结算源按城市市场定义:米兰(LIMC)、华沙(EPWA)、马德里(LEMD)使用机场 METAR;香港市场使用香港天文台(HKO);台北市场使用交通部中央气象署(CWA)。系统仍会保留 METAR/MGM 作为临近结构参考,并区分观测时间与接收时间。",
title: "结算点与主观测源",
},
{
@@ -40,7 +40,7 @@ const GUIDE_CARDS = {
title: "Settlement Probability Engine",
},
{
body: "Polymarket settlement follows airport METAR observations. The system prioritizes Aviation Weather API raw METAR and distinguishes observation time from receipt time to avoid misreading publication delay as temperature change.",
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",
},
{
+56 -1
View File
@@ -1,12 +1,57 @@
"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">
@@ -33,6 +78,16 @@ export function HeaderBar() {
</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"
@@ -60,4 +115,4 @@ export function HeaderBar() {
</div>
</header>
);
}
}
+200 -135
View File
@@ -1,10 +1,11 @@
"use client";
import { ChartConfiguration } from "chart.js/auto";
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() {
@@ -18,102 +19,123 @@ function HistoryChart() {
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,
},
];
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 (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,
});
}
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)}°`,
},
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,
},
},
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,
},
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)}°`,
},
},
},
type: "line",
} satisfies ChartConfiguration<"line">;
},
[hasMgm, summary, locale],
);
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;
@@ -128,6 +150,8 @@ 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],
@@ -147,54 +171,95 @@ export function HistoryModal() {
}
}}
>
<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.hitRate")}</span>
<span className="val">
{summary.hitRate != null ? `${summary.hitRate}%` : "--"}
</span>
</div>
<div className="h-stat-card">
<span className="label">{t("history.mae")}</span>
<span className="val">
{summary.debMae != null ? `${summary.debMae}°` : "--"}
</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>
</>
)}
{isProLoading ? (
<div
className="modal-content"
style={{ padding: "40px", textAlign: "center" }}
>
<div style={{ color: "var(--text-muted)" }}>
{t("dashboard.loading")}
</div>
{!isLoading && !error && <HistoryChart />}
</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>
);
}
@@ -1,5 +1,7 @@
"use client";
import "leaflet/dist/leaflet.css";
import { useDashboardStore } from "@/hooks/useDashboardStore";
import { useLeafletMap } from "@/hooks/useLeafletMap";
@@ -1,7 +1,8 @@
"use client";
import { ChartConfiguration } from "chart.js/auto";
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";
@@ -158,6 +159,7 @@ export function HeroSummary() {
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 &&
@@ -196,7 +198,9 @@ export function HeroSummary() {
</div>
<div className="hero-item">
<span className="label">
{locale === "en-US" ? "WU Settlement Ref" : "WU 结算参考"}
{locale === "en-US"
? `${settlementSource} Settlement Ref`
: `${settlementSource} 结算参考`}
</span>
<span className="value highlight">
{current.wu_settlement != null
@@ -227,7 +231,10 @@ export function HeroSummary() {
export function TemperatureChart() {
const { data } = useCityData();
const { locale, t } = useI18n();
const chartData = data ? getTemperatureChartData(data, locale) : null;
const chartData = useMemo(
() => (data ? getTemperatureChartData(data, locale) : null),
[data, locale],
);
const canvasRef = useChart(() => {
if (!data || !chartData) {
@@ -284,7 +291,9 @@ export function TemperatureChart() {
borderWidth: 0,
data: chartData.datasets.metarPoints,
fill: false,
label: locale === "en-US" ? "METAR Observation" : "METAR 实测",
label:
chartData.observationLabel ||
(locale === "en-US" ? "METAR Observation" : "METAR 实况"),
order: 0,
pointHoverRadius: 7,
pointRadius: 5,
@@ -398,7 +407,7 @@ export function ProbabilityDistribution({
const marketNoText = toPercent(marketNoPrice);
const isToday = !targetDate || targetDate === detail.local_date;
const marketTopBuckets = isToday ? getMarketTopBuckets(marketScan) : [];
const sortedMarketTopBuckets = (() => {
const sortedMarketTopBuckets = useMemo(() => {
const sorted = [...marketTopBuckets].sort(
(a, b) => Number(b.probability || 0) - Number(a.probability || 0),
);
@@ -412,7 +421,7 @@ export function ProbabilityDistribution({
if (deduped.length >= 4) break;
}
return deduped;
})();
}, [marketTopBuckets]);
const useMarketTopBuckets =
marketScan?.available && sortedMarketTopBuckets.length >= 2;
const topMarketBucketText = toPercent(sortedMarketTopBuckets[0]?.probability);
@@ -724,7 +733,9 @@ export function ForecastTable() {
isSelected && "selected",
)}
onClick={() => {
store.openFutureModal(day.date);
startTransition(() => {
store.openFutureModal(day.date);
});
}}
>
<div className="f-date">
@@ -1,6 +1,7 @@
"use client";
import { useEffect } from "react";
import dynamic from "next/dynamic";
import styles from "./Dashboard.module.css";
import {
DashboardStoreProvider,
@@ -9,11 +10,47 @@ import {
import { I18nProvider, useI18n } from "@/hooks/useI18n";
import { CitySidebar } from "@/components/dashboard/CitySidebar";
import { DetailPanel } from "@/components/dashboard/DetailPanel";
import { FutureForecastModal } from "@/components/dashboard/FutureForecastModal";
import { GuideModal } from "@/components/dashboard/GuideModal";
import { HeaderBar } from "@/components/dashboard/HeaderBar";
import { HistoryModal } from "@/components/dashboard/HistoryModal";
import { MapCanvas } from "@/components/dashboard/MapCanvas";
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();
@@ -57,9 +94,9 @@ function DashboardScreen() {
<HeaderBar />
<CitySidebar />
<DetailPanel />
<GuideModal />
<HistoryModal />
<FutureForecastModal />
{store.isGuideOpen && <GuideModal />}
{store.historyState.isOpen && <HistoryModal />}
{store.futureModalDate && <FutureForecastModal />}
{showLoading && (
<div className="loading-overlay">
<div className="loading-spinner" />
@@ -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>
);
}
+17 -8
View File
@@ -1,27 +1,36 @@
"use client";
import { useEffect, useRef } from "react";
import { Chart, ChartConfiguration, ChartType } from "chart.js/auto";
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<Chart<TType> | null>(null);
const chartRef = useRef<ChartInstance<TType> | null>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
let disposed = false;
const config = createConfig();
if (chartRef.current) {
chartRef.current.destroy();
chartRef.current = null;
}
const setupChart = async () => {
const { Chart } = await import("chart.js/auto");
if (disposed) return;
chartRef.current = new Chart(canvas, config);
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;
};
+161 -2
View File
@@ -22,6 +22,7 @@ import {
HistoryState,
LoadingState,
MarketScan,
ProAccessState,
} from "@/lib/dashboard-types";
interface DashboardStoreValue extends DashboardState {
@@ -39,6 +40,7 @@ interface DashboardStoreValue extends DashboardState {
openTodayModal: (forceRefresh?: boolean) => Promise<void>;
registerMapStopMotion: (stopMotion: () => void) => void;
refreshAll: () => Promise<void>;
refreshProAccess: () => Promise<void>;
refreshSelectedCity: () => Promise<void>;
selectedMarketScan: MarketScan | null;
selectedDetail: CityDetail | null;
@@ -68,6 +70,16 @@ function getInitialHistoryState(): HistoryState {
};
}
function getInitialProAccessState(): ProAccessState {
return {
loading: true,
authenticated: false,
subscriptionActive: false,
points: 0,
error: null,
};
}
const AI_EMPTY_PATTERNS = [
/暂无\s*AI\s*分析/i,
/当前以结构化气象与模型数据为主/i,
@@ -163,6 +175,9 @@ function getMarketScanCacheKey(cityName: string, targetDate?: string | null) {
return `${cityName}::${normalizedDate}`;
}
const SELECTED_CITY_STORAGE_KEY = "polyWeather_selected_city_v1";
const BACKGROUND_SUMMARY_REFRESH_MS = 30_000;
export function DashboardStoreProvider({
children,
}: {
@@ -202,8 +217,13 @@ export function DashboardStoreProvider({
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]) => [
@@ -216,7 +236,10 @@ export function DashboardStoreProvider({
? cityDetailsByName[selectedCity] || null
: null;
const selectedMarketDate =
futureModalDate || selectedForecastDate || selectedDetail?.local_date || null;
futureModalDate ||
selectedForecastDate ||
selectedDetail?.local_date ||
null;
const selectedMarketScanKey = selectedCity
? getMarketScanCacheKey(selectedCity, selectedMarketDate)
: null;
@@ -235,10 +258,55 @@ export function DashboardStoreProvider({
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;
}
@@ -319,10 +387,53 @@ export function DashboardStoreProvider({
}
};
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;
@@ -387,6 +498,35 @@ export function DashboardStoreProvider({
}
};
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 }));
@@ -435,6 +575,15 @@ export function DashboardStoreProvider({
const openHistory = async () => {
if (!selectedCity) return;
if (!proAccess.subscriptionActive) {
setHistoryState((current) => ({
...current,
error: null,
isOpen: true,
loading: false,
}));
return;
}
setHistoryState((current) => ({
...current,
error: null,
@@ -479,10 +628,11 @@ export function DashboardStoreProvider({
isGuideOpen,
loadCities,
loadingState,
proAccess,
openFutureModal: (dateStr: string, forceRefresh = false) => {
mapStopMotionRef.current();
setFutureModalDate(dateStr);
if (!selectedCity) return;
if (!selectedCity || !proAccess.subscriptionActive) return;
const cacheKey = getMarketScanCacheKey(selectedCity, dateStr);
setLoadingState((current) => ({ ...current, marketScan: true }));
void ensureCityMarketScan(
@@ -505,6 +655,13 @@ export function DashboardStoreProvider({
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) {
@@ -554,6 +711,7 @@ export function DashboardStoreProvider({
mapStopMotionRef.current = stopMotion;
},
refreshAll,
refreshProAccess,
refreshSelectedCity,
selectedMarketScan,
selectedCity,
@@ -573,6 +731,7 @@ export function DashboardStoreProvider({
isPanelOpen,
isGuideOpen,
loadingState,
proAccess,
marketScanByCityName,
selectedMarketScan,
selectedCity,
+62 -48
View File
@@ -216,59 +216,73 @@ export function useLeafletMap({
useEffect(() => {
const map = mapRef.current;
if (!map || !cities.length) return;
let canceled = false;
const frameId =
typeof window !== "undefined"
? window.requestAnimationFrame(() => {
if (canceled) return;
const currentMarkers = markersRef.current;
const nextMarkers: typeof currentMarkers = {};
const nextLastData: typeof lastCityDataRef.current = {};
const currentMarkers = markersRef.current;
const nextMarkers: typeof currentMarkers = {};
const nextLastData: typeof lastCityDataRef.current = {};
cities.forEach((city) => {
const detail = cityDetailsByName[city.name];
const summary = citySummariesByName[city.name];
const snapshot = detail || summary;
const existing = currentMarkers[city.name];
cities.forEach((city) => {
const detail = cityDetailsByName[city.name];
const summary = citySummariesByName[city.name];
const snapshot = detail || summary;
const existing = currentMarkers[city.name];
const currentTemp = snapshot?.current?.temp;
const currentRisk = city.risk_level;
const lastData = lastCityDataRef.current[city.name];
const dataChanged =
!lastData ||
lastData.temp !== currentTemp ||
lastData.risk !== currentRisk;
const currentTemp = snapshot?.current?.temp;
const currentRisk = city.risk_level;
const lastData = lastCityDataRef.current[city.name];
const dataChanged =
!lastData ||
lastData.temp !== currentTemp ||
lastData.risk !== currentRisk;
if (existing) {
if (dataChanged) {
existing.marker.setIcon(createMarkerIcon(city, snapshot));
}
nextMarkers[city.name] = { city, marker: existing.marker };
nextLastData[city.name] = { temp: currentTemp, risk: currentRisk };
return;
if (existing) {
if (dataChanged) {
existing.marker.setIcon(createMarkerIcon(city, snapshot));
}
nextMarkers[city.name] = { city, marker: existing.marker };
nextLastData[city.name] = { temp: currentTemp, risk: currentRisk };
return;
}
// Create new marker
const marker = L.marker([city.lat, city.lon], {
icon: createMarkerIcon(city, snapshot),
}).addTo(map);
marker.on("click", () => {
map.stop();
// Reset lastMovedCity so we can re-fly if needed
lastMovedCityRef.current = null;
onSelectCityRef.current(city.name);
});
nextMarkers[city.name] = { city, marker };
nextLastData[city.name] = { temp: currentTemp, risk: currentRisk };
});
// Cleanup removed markers
Object.entries(currentMarkers).forEach(([name, entry]) => {
if (!nextMarkers[name]) {
map.removeLayer(entry.marker);
}
});
markersRef.current = nextMarkers;
lastCityDataRef.current = nextLastData;
})
: null;
return () => {
canceled = true;
if (frameId != null && typeof window !== "undefined") {
window.cancelAnimationFrame(frameId);
}
// Create new marker
const marker = L.marker([city.lat, city.lon], {
icon: createMarkerIcon(city, snapshot),
}).addTo(map);
marker.on("click", () => {
map.stop();
// Reset lastMovedCity so we can re-fly if needed
lastMovedCityRef.current = null;
onSelectCityRef.current(city.name);
});
nextMarkers[city.name] = { city, marker };
nextLastData[city.name] = { temp: currentTemp, risk: currentRisk };
});
// Cleanup removed markers
Object.entries(currentMarkers).forEach(([name, entry]) => {
if (!nextMarkers[name]) {
map.removeLayer(entry.marker);
}
});
markersRef.current = nextMarkers;
lastCityDataRef.current = nextLastData;
};
}, [cities, cityDetailsByName, citySummariesByName]);
useEffect(() => {
+77 -8
View File
@@ -1,14 +1,83 @@
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { createSupabaseRouteClient, hasSupabaseServerEnv } from "@/lib/supabase/server";
export const BACKEND_ENTITLEMENT_HEADER = "x-polyweather-entitlement";
export const FORWARDED_SUPABASE_USER_ID_HEADER = "x-polyweather-auth-user-id";
export const FORWARDED_SUPABASE_EMAIL_HEADER = "x-polyweather-auth-email";
export function buildBackendRequestHeaders(): HeadersInit {
const headers: HeadersInit = {
type HeaderBuildResult = {
headers: HeadersInit;
response: NextResponse | null;
};
function extractBearerToken(headerValue: string | null) {
if (!headerValue) return "";
const parts = headerValue.trim().split(/\s+/);
if (parts.length === 2 && parts[0].toLowerCase() === "bearer") {
return parts[1];
}
return "";
}
export async function buildBackendRequestHeaders(
request: NextRequest,
): Promise<HeaderBuildResult> {
const headers = new Headers({
Accept: "application/json",
};
const token = process.env.POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN?.trim();
if (token) {
headers[BACKEND_ENTITLEMENT_HEADER] = token;
});
const backendToken = process.env.POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN?.trim();
if (backendToken) {
headers.set(BACKEND_ENTITLEMENT_HEADER, backendToken);
}
return headers;
const incomingAuth = extractBearerToken(request.headers.get("authorization"));
if (hasSupabaseServerEnv()) {
const passthroughResponse = new NextResponse(null, { status: 200 });
const supabase = createSupabaseRouteClient(request, passthroughResponse);
const {
data: { session },
} = await supabase.auth.getSession();
const {
data: { user },
} = await supabase.auth.getUser();
const forwardedUserId = String(user?.id || session?.user?.id || "").trim();
const forwardedEmail = String(user?.email || session?.user?.email || "").trim();
if (forwardedUserId) {
headers.set(FORWARDED_SUPABASE_USER_ID_HEADER, forwardedUserId);
}
if (forwardedEmail) {
headers.set(FORWARDED_SUPABASE_EMAIL_HEADER, forwardedEmail);
}
if (incomingAuth) {
headers.set("Authorization", `Bearer ${incomingAuth}`);
return { headers, response: passthroughResponse };
}
const accessToken = session?.access_token || "";
if (accessToken) {
// Fallback to cookie-backed session when request does not carry bearer.
headers.set("Authorization", `Bearer ${accessToken}`);
}
return { headers, response: passthroughResponse };
}
if (incomingAuth) {
headers.set("Authorization", `Bearer ${incomingAuth}`);
}
return { headers, response: null };
}
export function applyAuthResponseCookies(
target: NextResponse,
source: NextResponse | null,
) {
if (!source) return target;
for (const [name, value] of source.headers.entries()) {
if (name.toLowerCase() === "set-cookie") {
target.headers.append(name, value);
}
}
return target;
}
+19
View File
@@ -11,6 +11,8 @@ export interface CityListItem {
icao: string;
temp_unit: "celsius" | "fahrenheit";
is_major?: boolean;
settlement_source?: string;
settlement_source_label?: string;
}
export interface ProbabilityBucket {
@@ -46,6 +48,8 @@ export interface CurrentConditions {
max_so_far: number | null;
max_temp_time: string | null;
wu_settlement: number | null;
settlement_source?: string | null;
settlement_source_label?: string | null;
obs_time: string | null;
obs_age_min: number | null;
wind_speed_kt: number | null;
@@ -283,6 +287,10 @@ export interface CityDetail {
time?: string;
temp?: number | null;
}>;
settlement_today_obs?: Array<{
time?: string;
temp?: number | null;
}>;
trend?: TrendInfo;
peak?: PeakInfo;
ai_analysis?: string | AiAnalysisStructured | null;
@@ -296,7 +304,9 @@ export interface HistoryPoint {
date: string;
actual: number | null;
deb: number | null;
mu?: number | null;
mgm?: number | null;
forecasts?: Record<string, number | null>;
}
export interface LoadingState {
@@ -314,6 +324,14 @@ export interface HistoryState {
dataByCity: Record<string, HistoryPoint[]>;
}
export interface ProAccessState {
loading: boolean;
authenticated: boolean;
subscriptionActive: boolean;
points: number;
error: string | null;
}
export interface DashboardState {
cities: CityListItem[];
cityDetailsByName: Record<string, CityDetail>;
@@ -323,4 +341,5 @@ export interface DashboardState {
selectedForecastDate: string | null;
loadingState: LoadingState;
historyState: HistoryState;
proAccess: ProAccessState;
}
+274 -40
View File
@@ -30,6 +30,32 @@ function isEnglish(locale: Locale) {
return locale === "en-US";
}
function getObservationSourceCode(detail: CityDetail): string {
const source = String(detail.current?.settlement_source || "")
.trim()
.toLowerCase();
if (source) return source;
const city = String(detail.name || detail.display_name || "")
.trim()
.toLowerCase();
if (city === "hong kong") return "hko";
if (city === "taipei") return "cwa";
return "metar";
}
function getObservationSourceTag(detail: CityDetail): string {
const label = String(detail.current?.settlement_source_label || "")
.trim()
.toUpperCase();
if (label) return label;
const code = getObservationSourceCode(detail);
if (code === "hko") return "HKO";
if (code === "cwa") return "CWA";
if (code === "mgm") return "MGM";
return "METAR";
}
function normalizeCloudSummary(
cloudDesc: string | null | undefined,
locale: Locale,
@@ -119,6 +145,7 @@ export function getWeatherSummary(detail: CityDetail, locale: Locale = "zh-CN")
export function getHeroMetaItems(detail: CityDetail, locale: Locale = "zh-CN") {
const current = detail.current || {};
const parts: string[] = [];
const sourceTag = getObservationSourceTag(detail);
if (current.obs_time) {
const ageText =
@@ -127,7 +154,7 @@ export function getHeroMetaItems(detail: CityDetail, locale: Locale = "zh-CN") {
? ` (${current.obs_age_min} min ago)`
: `${current.obs_age_min} 分钟前)`
: "";
parts.push(`✈️ METAR ${current.obs_time}${ageText}`);
parts.push(`✈️ ${sourceTag} ${current.obs_time}${ageText}`);
}
if (current.wx_desc) {
@@ -209,12 +236,22 @@ export function getTemperatureChartData(
currentIndex < 0 || index >= currentIndex ? temp : null,
);
const metarPoints = new Array(times.length).fill(null);
const metarSource = detail.metar_today_obs?.length
? detail.metar_today_obs
: detail.trend?.recent || [];
const observationTag = getObservationSourceTag(detail);
const observationCode = getObservationSourceCode(detail);
const settlementSource =
observationCode === "hko" || observationCode === "cwa";
const observationSource = settlementSource
? detail.settlement_today_obs?.length
? detail.settlement_today_obs
: detail.current?.obs_time && detail.current?.temp != null
? [{ time: detail.current.obs_time, temp: detail.current.temp }]
: []
: detail.metar_today_obs?.length
? detail.metar_today_obs
: detail.trend?.recent || [];
metarSource.forEach((item) => {
const metarPoints = new Array(times.length).fill(null);
observationSource.forEach((item) => {
const parts = String(item.time || "").split(":");
let hour = Number.parseInt(parts[0], 10);
const minute = Number.parseInt(parts[1] || "0", 10);
@@ -286,13 +323,17 @@ export function getTemperatureChartData(
: "已使用 MGM 小时预报替代 DEB 曲线",
);
}
if (detail.trend?.recent?.length) {
const recentText = [...detail.trend.recent]
if ((detail.trend?.recent?.length || 0) > 0 || observationSource.length > 0) {
const recentData =
observationSource.length > 0
? [...observationSource]
: [...(detail.trend?.recent || [])];
const recentText = recentData
.slice(0, 4)
.reverse()
.map((item) => `${item.temp}${detail.temp_symbol}@${item.time}`)
.join(" -> ");
legendParts.push(`METAR: ${recentText}`);
legendParts.push(`${observationTag}: ${recentText}`);
}
return {
@@ -306,6 +347,9 @@ export function getTemperatureChartData(
offset,
temps,
},
observationLabel: isEnglish(locale)
? `${observationTag} Observation`
: `${observationTag} 实况`,
legendText: legendParts.join(" | "),
max,
min,
@@ -501,14 +545,96 @@ export function computeFrontTrendSignal(
precipMax: 0,
score: 0,
summary: isEnglish(locale)
? "Insufficient 48h structured data. Keep baseline monitoring."
: "未来 48 小时结构化数据不足,暂时只保留基础监控。",
? "Insufficient intraday structured data. Keep baseline monitoring."
: "当日日内结构化数据不足,暂时只保留基础监控。",
weatherGovPeriods: [] as ReturnType<typeof getForecastTextForDate>,
};
}
const first = slice[0];
const last = slice[slice.length - 1];
const normalizeHm = (value: unknown): string | null => {
const match = String(value || "").match(/(\d{1,2}):(\d{2})/);
if (!match) return null;
const hour = Number.parseInt(match[1], 10);
const minute = Number.parseInt(match[2], 10);
if (
!Number.isFinite(hour) ||
!Number.isFinite(minute) ||
hour < 0 ||
hour > 23 ||
minute < 0 ||
minute > 59
) {
return null;
}
return `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`;
};
const hmToMinutes = (value: string | null) => {
if (!value) return null;
const [hourPart, minutePart] = value.split(":");
const hour = Number.parseInt(hourPart || "", 10);
const minute = Number.parseInt(minutePart || "", 10);
if (
!Number.isFinite(hour) ||
!Number.isFinite(minute) ||
hour < 0 ||
hour > 23 ||
minute < 0 ||
minute > 59
) {
return null;
}
return hour * 60 + minute;
};
const pointMinutes = (point: { label?: string }) =>
hmToMinutes(normalizeHm(point.label));
const isTargetToday = dateStr === detail.local_date;
const currentHm = normalizeHm(detail.local_time);
const sunsetHm = normalizeHm(detail.forecast?.sunset);
const currentMinutes = hmToMinutes(currentHm);
const sunsetMinutes = hmToMinutes(sunsetHm);
const canUseSunsetWindow =
isTargetToday &&
currentMinutes !== null &&
sunsetMinutes !== null &&
sunsetMinutes > currentMinutes;
const futureSlice =
isTargetToday && currentMinutes !== null
? slice.filter((point) => {
const minutes = pointMinutes(point);
return minutes === null ? true : minutes >= currentMinutes;
})
: slice;
const untilSunsetSlice =
canUseSunsetWindow && sunsetMinutes !== null
? futureSlice.filter((point) => {
const minutes = pointMinutes(point);
return minutes === null ? false : minutes <= sunsetMinutes;
})
: futureSlice;
const workingSlice =
untilSunsetSlice.length >= 2
? untilSunsetSlice
: futureSlice.length >= 2
? futureSlice
: slice;
const usingSunsetWindow = canUseSunsetWindow && untilSunsetSlice.length >= 2;
const first = workingSlice[0] || slice[0];
const last = workingSlice[workingSlice.length - 1] || slice[slice.length - 1];
const effectiveHours = Math.max(1, workingSlice.length);
const windowLabel = `${first?.label || "--"}-${last?.label || "--"}`;
const windowText = isEnglish(locale)
? usingSunsetWindow
? `today ${windowLabel} (~${effectiveHours}h, now -> sunset)`
: isTargetToday
? `today ${windowLabel} (~${effectiveHours}h)`
: `daily ${windowLabel} (~${effectiveHours}h)`
: usingSunsetWindow
? `今日 ${windowLabel}(约 ${effectiveHours} 小时,当前至日落)`
: isTargetToday
? `今日 ${windowLabel}(约 ${effectiveHours} 小时)`
: `当日日内 ${windowLabel}(约 ${effectiveHours} 小时)`;
const firstTemp = Number.isFinite(Number(first.temp)) ? Number(first.temp) : currentTemp;
const lastTemp = Number.isFinite(Number(last.temp)) ? Number(last.temp) : firstTemp;
const tempDelta =
@@ -604,8 +730,8 @@ export function computeFrontTrendSignal(
{
label: isEnglish(locale) ? "Temperature delta" : "温度变化",
note: isEnglish(locale)
? "Open-Meteo upcoming hourly temperature change"
: "Open-Meteo 未来小时温度变化",
? `Official Open-Meteo hourly data; window: ${windowText}`
: `官方 Open-Meteo 小时数据;计算窗口:${windowText}`,
tone: tempDelta >= 0.8 ? "warm" : tempDelta <= -0.8 ? "cold" : "",
value: formatDelta(tempDelta, detail.temp_symbol),
},
@@ -628,8 +754,8 @@ export function computeFrontTrendSignal(
{
label: isEnglish(locale) ? "Wind-direction evolution" : "风向演变",
note: isEnglish(locale)
? "Focus on switch to southerly or northerly flow"
: "关注是否转南风或转北风",
? "Warming-favor: S/SW; cooling-favor: N/NW"
: "增温有利:南/西南风;降温有利:北/西北风",
value: `${bucketLabel(firstBucket, locale)} -> ${bucketLabel(lastBucket, locale)}`,
},
{
@@ -657,15 +783,15 @@ export function computeFrontTrendSignal(
summary:
label === warmLabel
? isEnglish(locale)
? "Southerly flow strengthens with rising dew point and temperature. Next 6-48h leans warm advection."
: "风向更偏南 / 西南,露点与温度整体抬升,未来 6-48 小时偏向暖平流。"
? `Southerly flow strengthens with rising dew point and temperature. ${windowText} leans warm advection.`
: `风向更偏南 / 西南,露点与温度整体抬升,${windowText}偏向暖平流。`
: label === coldLabel
? isEnglish(locale)
? "Temperature declines with pressure rebound and/or northerly shift. Next 6-48h leans cold-front suppression."
: "温度下滑、气压回升或风向转北,未来 6-48 小时更像冷锋或冷平流压制。"
? `Temperature declines with pressure rebound and/or northerly shift. ${windowText} leans cold-front suppression.`
: `温度下滑、气压回升或风向转北,${windowText}更像冷锋或冷平流压制。`
: isEnglish(locale)
? "Structured trend layer mainly uses weather.gov and Open-Meteo for 6-48h warm/cold flow judgement."
: "结构化来源以 weather.gov 和 Open-Meteo 为主,用于判断未来 6-48 小时冷暖平流趋势。",
? `Structured trend uses weather.gov + Open-Meteo, with core judgement focused on ${windowText}.`
: `结构化来源以 weather.gov 和 Open-Meteo 为主,核心判断窗口为${windowText}`,
weatherGovPeriods,
};
}
@@ -891,6 +1017,13 @@ export function getHistorySummary(
history: HistoryPoint[],
cityLocalDate?: string | null,
) {
const toFinite = (value: unknown): number | null => {
const numeric = Number(value);
return Number.isFinite(numeric) ? numeric : null;
};
const isExcludedModel = (name: string) =>
String(name || "").toLowerCase().includes("meteoblue");
const cutoff = new Date();
cutoff.setHours(0, 0, 0, 0);
cutoff.setDate(cutoff.getDate() - 14);
@@ -910,15 +1043,65 @@ export function getHistorySummary(
let hits = 0;
const debErrors: number[] = [];
const modelErrors: Record<string, number[]> = {};
settledData.forEach((row) => {
if (row.actual != null && row.deb != null) {
debErrors.push(Math.abs(row.actual - row.deb));
if (wuRound(row.actual) === wuRound(row.deb)) {
const actual = toFinite(row.actual);
const deb = toFinite(row.deb);
if (actual != null && deb != null) {
debErrors.push(Math.abs(actual - deb));
if (wuRound(actual) === wuRound(deb)) {
hits += 1;
}
}
const forecasts = row.forecasts || {};
Object.entries(forecasts).forEach(([modelName, modelValue]) => {
if (isExcludedModel(modelName)) return;
const mv = toFinite(modelValue);
if (actual == null || mv == null) return;
if (!modelErrors[modelName]) {
modelErrors[modelName] = [];
}
modelErrors[modelName].push(Math.abs(actual - mv));
});
});
const modelMaeList = Object.entries(modelErrors)
.map(([name, errors]) => ({
mae:
errors.length > 0
? errors.reduce((sum, value) => sum + value, 0) / errors.length
: Number.POSITIVE_INFINITY,
model: name,
sampleCount: errors.length,
}))
.filter((row) => Number.isFinite(row.mae) && row.sampleCount > 0)
.sort((a, b) => a.mae - b.mae);
const primaryModelMaeList = modelMaeList.filter((row) => row.sampleCount >= 2);
const bestModel = (primaryModelMaeList[0] || modelMaeList[0]) ?? null;
const bestModelName = bestModel?.model || null;
const bestModelMae = bestModel ? Number(bestModel.mae.toFixed(1)) : null;
const bestModelSeries = recentData.map((row) =>
bestModelName ? toFinite(row.forecasts?.[bestModelName]) : null,
);
let debWinDaysVsBest = 0;
let debVsBestComparableDays = 0;
if (bestModelName) {
settledData.forEach((row) => {
const actual = toFinite(row.actual);
const deb = toFinite(row.deb);
const bestModelVal = toFinite(row.forecasts?.[bestModelName]);
if (actual == null || deb == null || bestModelVal == null) return;
debVsBestComparableDays += 1;
if (Math.abs(deb - actual) <= Math.abs(bestModelVal - actual)) {
debWinDaysVsBest += 1;
}
});
}
return {
dates: recentData.map((row) => row.date),
debMae: debErrors.length
@@ -929,6 +1112,20 @@ export function getHistorySummary(
)
: null,
debs: recentData.map((row) => row.deb),
bestModelName,
bestModelMae,
bestModelSeries,
modelMaeRanks: modelMaeList.map((row) => ({
model: row.model,
mae: Number(row.mae.toFixed(1)),
sampleCount: row.sampleCount,
})),
debWinDaysVsBest,
debVsBestComparableDays,
debWinRateVsBest:
debVsBestComparableDays > 0
? Number(((debWinDaysVsBest / debVsBestComparableDays) * 100).toFixed(0))
: null,
hitRate: debErrors.length
? Number(((hits / debErrors.length) * 100).toFixed(0))
: null,
@@ -943,19 +1140,48 @@ export function getCityProfileStats(detail: CityDetail, locale: Locale = "zh-CN"
const risk = detail.risk || {};
const current = detail.current || {};
const nearbyCount = Array.isArray(detail.mgm_nearby) ? detail.mgm_nearby.length : 0;
const sourceCode = getObservationSourceCode(detail);
const isOfficialSource = sourceCode === "hko" || sourceCode === "cwa";
const sourceDisplay = (() => {
if (sourceCode === "hko") {
return isEnglish(locale)
? "Hong Kong Observatory (HKO)"
: "香港天文台 (HKO)";
}
if (sourceCode === "cwa") {
return isEnglish(locale)
? "Central Weather Administration (CWA)"
: "交通部中央气象署 (CWA)";
}
const tag = getObservationSourceTag(detail);
if (sourceCode === "mgm") {
return isEnglish(locale) ? `MGM (${tag})` : `MGM (${tag})`;
}
if (risk.airport && risk.icao) return `${risk.airport} (${risk.icao})`;
if (risk.airport) return String(risk.airport);
return isEnglish(locale) ? "No profile" : "暂无档案";
})();
return [
{
label: isEnglish(locale) ? "Settlement airport" : "结算机场",
value:
risk.airport && risk.icao
? `${risk.airport} (${risk.icao})`
: isEnglish(locale)
? "No profile"
: "暂无档案",
label: isOfficialSource
? isEnglish(locale)
? "Settlement source"
: "结算源"
: isEnglish(locale)
? "Settlement airport"
: "结算机场",
value: sourceDisplay,
},
{
label: isEnglish(locale) ? "Station distance" : "站点距离",
label: isOfficialSource
? isEnglish(locale)
? "Reference distance"
: "参考距离"
: isEnglish(locale)
? "Station distance"
: "站点距离",
value:
risk.distance_km != null && Number.isFinite(Number(risk.distance_km))
? `${risk.distance_km} km`
@@ -989,6 +1215,14 @@ export function getSettlementRiskNarrative(
locale: Locale = "zh-CN",
) {
const risk = detail.risk || {};
const sourceCode = getObservationSourceCode(detail);
const stationTerm = sourceCode === "hko" || sourceCode === "cwa"
? isEnglish(locale)
? "settlement reference station"
: "结算参考站"
: isEnglish(locale)
? "settlement airport"
: "结算机场";
const lines: string[] = [];
if (risk.warning) {
@@ -1003,20 +1237,20 @@ export function getSettlementRiskNarrative(
if (risk.distance_km >= 60) {
lines.push(
isEnglish(locale)
? "Settlement airport is far from urban core; market feel and settlement value may diverge significantly."
: "结算机场与城市核心区域距离偏大,盘面温度与结算值可能出现明显背离。",
? `The ${stationTerm} is far from urban core; market feel and settlement value may diverge significantly.`
: `${stationTerm}与城市核心区域距离偏大,盘面温度与结算值可能出现明显背离。`,
);
} else if (risk.distance_km >= 25) {
lines.push(
isEnglish(locale)
? "Settlement airport has material distance from downtown; peak/overnight rhythm should prioritize airport station."
: "结算机场与城区存在可感知距离,午后峰值和夜间降温节奏需要优先看机场站。",
? `The ${stationTerm} has material distance from downtown; peak/overnight rhythm should prioritize the settlement station.`
: `${stationTerm}与城区存在可感知距离,午后峰值和夜间降温节奏需要优先看结算站。`,
);
} else {
lines.push(
isEnglish(locale)
? "Settlement airport is close enough; city feel and settlement temperature are usually more synchronized."
: "结算机场距离较近,城市体感与结算温度通常更同步。",
? `The ${stationTerm} is close enough; city feel and settlement temperature are usually more synchronized.`
: `${stationTerm}距离较近,城市体感与结算温度通常更同步。`,
);
}
}
+40
View File
@@ -0,0 +1,40 @@
import { createHash } from "node:crypto";
import { NextRequest, NextResponse } from "next/server";
function normalizeTag(tag: string) {
return tag.trim().replace(/^W\//i, "");
}
function ifNoneMatchHit(ifNoneMatch: string | null, etag: string) {
if (!ifNoneMatch) return false;
const target = normalizeTag(etag);
return ifNoneMatch
.split(",")
.map((part) => normalizeTag(part))
.some((part) => part === "*" || part === target);
}
function buildEtag(body: string) {
const hash = createHash("sha1").update(body).digest("hex");
return `"${hash}"`;
}
export function buildCachedJsonResponse(
req: NextRequest,
payload: unknown,
cacheControl: string,
) {
const body = JSON.stringify(payload);
const etag = buildEtag(body);
const headers = new Headers({
"Cache-Control": cacheControl,
ETag: etag,
"Content-Type": "application/json; charset=utf-8",
});
if (ifNoneMatchHit(req.headers.get("if-none-match"), etag)) {
return new NextResponse(null, { status: 304, headers });
}
return new NextResponse(body, { status: 200, headers });
}
+122 -12
View File
@@ -10,6 +10,10 @@ const MESSAGES: Record<Locale, Record<string, string>> = {
"header.subtitle": "天气衍生品智能分析",
"header.info": "技术说明",
"header.infoAria": "查看系统技术说明",
"header.account": "账户",
"header.accountAria": "打开账户页",
"header.signIn": "登录 / 注册",
"header.signInAria": "打开登录注册页",
"header.live": "实时",
"header.refreshAria": "刷新所有数据",
"header.langAria": "切换语言",
@@ -46,7 +50,7 @@ const MESSAGES: Record<Locale, Record<string, string>> = {
"guide.title": "📎 PolyWeather 系统技术说明",
"guide.closeAria": "关闭技术说明",
"guide.footer":
"数据源以 Aviation Weather / METAR、Turkish MGM、Open-Meteo、weather.gov 为主。",
"数据源以 METAR、香港天文台(HKO)、中央气象署(CWA)、Turkish MGM、Open-Meteo、weather.gov 为主。",
"history.title": "📊 历史准确率对账 - {city}",
"history.closeAria": "关闭历史对账",
@@ -55,6 +59,11 @@ const MESSAGES: Record<Locale, Record<string, string>> = {
"history.empty": "近 15 天暂无该城市历史数据",
"history.hitRate": "DEB 结算胜率 (WU)",
"history.mae": "DEB MAE",
"history.debHitRate": "DEB 结算胜率 (WU)",
"history.debMae": "DEB MAE",
"history.muMae": "μ MAE",
"history.bestModelMae": "最佳单模型 MAE",
"history.debVsBest": "DEB 优于最佳模型",
"history.sample": "近 15 天已结算样本",
"history.sampleDays": "{count} 天",
@@ -64,7 +73,7 @@ const MESSAGES: Record<Locale, Record<string, string>> = {
"future.closeDateAria": "关闭未来日期分析",
"future.currentObs": "当前实测",
"future.currentWeather": "当前天气",
"future.wuRef": "WU 结算参考",
"future.wuRef": "结算参考",
"future.sunrise": "日出时间",
"future.sunset": "日落时间",
"future.sunshine": "日照时长",
@@ -82,8 +91,8 @@ const MESSAGES: Record<Locale, Record<string, string>> = {
"future.judgement": "判断",
"future.confidence": "置信度",
"future.maxPrecip": "最大降水概率",
"future.ai": "AI 深度分析",
"future.noAi": "暂无 AI 分析,当前以结构化气象与模型数据为主。",
"future.ai": "机场报文解读",
"future.noAi": "暂无机场报文解读,当前以结构化气象与模型数据为主。",
"future.weatherGov": "weather.gov 文本",
"future.risk": "结算与偏差风险",
"future.climate": "当地气候主要受什么影响",
@@ -100,20 +109,70 @@ const MESSAGES: Record<Locale, Record<string, string>> = {
"section.noProb": "暂无概率数据",
"section.models": "多模型预报",
"section.noModels": "暂无多模型预报",
"section.ai": "AI 深度分析",
"section.aiEmpty": "暂无 AI 分析,当前以结构化气象与模型数据为主。",
"section.ai": "机场报文解读",
"section.aiEmpty": "暂无机场报文解读,当前以结构化气象与模型数据为主。",
"section.risk": "数据偏差风险",
"section.noRiskProfile": "暂无风险档案",
"section.airport": "机场",
"section.distance": "距离",
"section.note": "注意",
"account.title": "账户中心",
"account.subtitle": "查看身份、权限与 Bot 绑定信息",
"account.backDashboard": "返回看板",
"account.refresh": "刷新",
"account.signIn": "登录 / 注册",
"account.signOut": "退出登录",
"account.loading": "正在同步账户信息...",
"account.error": "加载失败: {message}",
"account.updatedAt": "最近同步: {time}",
"account.guestName": "PolyWeather 用户",
"account.authenticated": "已登录",
"account.guest": "游客模式",
"account.subscriptionActive": "订阅有效",
"account.subscriptionRequired": "需要订阅",
"account.subscriptionUnknown": "订阅状态未知",
"account.card.membership": "会员与权限",
"account.card.identity": "身份信息",
"account.card.backend": "后端鉴权",
"account.card.bot": "Bot 绑定",
"account.field.email": "邮箱",
"account.field.userId": "用户 ID",
"account.field.provider": "登录方式",
"account.field.lastSignIn": "最近登录",
"account.field.mode": "鉴权模式",
"account.field.backendStatus": "后端状态",
"account.field.subscription": "订阅结果",
"account.field.requirement": "订阅要求",
"account.field.bindCommand": "绑定命令",
"account.field.bindHint":
"将下面命令发送到 Telegram Bot,可把网页账户与机器人权限绑定。",
"account.mode.supabaseRequired": "Supabase 强制登录",
"account.mode.supabaseOptional": "Supabase 可选登录",
"account.mode.supabase": "Supabase 会话鉴权",
"account.mode.legacy": "Legacy Token 鉴权",
"account.mode.disabled": "未启用权限校验",
"account.mode.unknown": "未知模式",
"account.backend.ok": "通过",
"account.backend.fail": "失败",
"account.subscription.active": "有效",
"account.subscription.inactive": "无效/已过期",
"account.subscription.notRequired": "当前未强制订阅",
"account.subscription.unknown": "未知",
"account.copy": "复制",
"account.copied": "已复制",
"account.na": "--",
"common.na": "--",
},
"en-US": {
"header.subtitle": "Weather Derivatives Intelligence",
"header.info": "Tech Notes",
"header.infoAria": "Open system technical notes",
"header.account": "Account",
"header.accountAria": "Open account center",
"header.signIn": "Sign In / Register",
"header.signInAria": "Open sign-in page",
"header.live": "LIVE",
"header.refreshAria": "Refresh all data",
"header.langAria": "Switch language",
@@ -151,7 +210,7 @@ const MESSAGES: Record<Locale, Record<string, string>> = {
"guide.title": "📎 PolyWeather Technical Overview",
"guide.closeAria": "Close technical overview",
"guide.footer":
"Primary data sources are Aviation Weather / METAR, Turkish MGM, Open-Meteo, and weather.gov.",
"Primary data sources are METAR, Hong Kong Observatory (HKO), CWA (Taiwan), Turkish MGM, Open-Meteo, and weather.gov.",
"history.title": "📊 Historical Reconciliation - {city}",
"history.closeAria": "Close history reconciliation",
@@ -160,6 +219,11 @@ const MESSAGES: Record<Locale, Record<string, string>> = {
"history.empty": "No historical records for this city in the last 15 days",
"history.hitRate": "DEB Settlement Hit Rate (WU)",
"history.mae": "DEB MAE",
"history.debHitRate": "DEB Settlement Hit Rate (WU)",
"history.debMae": "DEB MAE",
"history.muMae": "μ MAE",
"history.bestModelMae": "Best Single-model MAE",
"history.debVsBest": "DEB vs Best Model",
"history.sample": "Settled Samples (Last 15 Days)",
"history.sampleDays": "{count} days",
@@ -169,7 +233,7 @@ const MESSAGES: Record<Locale, Record<string, string>> = {
"future.closeDateAria": "Close future-date analysis",
"future.currentObs": "Current Observation",
"future.currentWeather": "Current Weather",
"future.wuRef": "WU Settlement Ref",
"future.wuRef": "Settlement Ref",
"future.sunrise": "Sunrise",
"future.sunset": "Sunset",
"future.sunshine": "Sunshine Duration",
@@ -187,9 +251,9 @@ const MESSAGES: Record<Locale, Record<string, string>> = {
"future.judgement": "Judgement",
"future.confidence": "Confidence",
"future.maxPrecip": "Max Precip Probability",
"future.ai": "AI Deep Analysis",
"future.ai": "Airport METAR Narrative",
"future.noAi":
"No AI analysis available. Structured meteorological and model data are used as baseline.",
"No airport bulletin narrative is available. Structured meteorological and model data are used as baseline.",
"future.weatherGov": "weather.gov text",
"future.risk": "Settlement & Deviation Risk",
"future.climate": "What Mainly Drives Local Climate",
@@ -207,15 +271,61 @@ const MESSAGES: Record<Locale, Record<string, string>> = {
"section.noProb": "No probability data available",
"section.models": "Multi-model Forecast",
"section.noModels": "No multi-model forecast available",
"section.ai": "AI Deep Analysis",
"section.ai": "Airport METAR Narrative",
"section.aiEmpty":
"No AI analysis available. Structured meteorological and model data are currently used.",
"No airport bulletin narrative is available. Structured meteorological data are currently used.",
"section.risk": "Data Deviation Risk",
"section.noRiskProfile": "No risk profile available",
"section.airport": "Airport",
"section.distance": "Distance",
"section.note": "Note",
"account.title": "Account Center",
"account.subtitle": "Review identity, access status, and bot binding info",
"account.backDashboard": "Back to Dashboard",
"account.refresh": "Refresh",
"account.signIn": "Sign in / Sign up",
"account.signOut": "Sign out",
"account.loading": "Syncing account snapshot...",
"account.error": "Failed to load: {message}",
"account.updatedAt": "Last synced: {time}",
"account.guestName": "PolyWeather User",
"account.authenticated": "Authenticated",
"account.guest": "Guest Mode",
"account.subscriptionActive": "Subscription active",
"account.subscriptionRequired": "Subscription required",
"account.subscriptionUnknown": "Subscription unknown",
"account.card.membership": "Membership & Access",
"account.card.identity": "Identity",
"account.card.backend": "Backend Auth",
"account.card.bot": "Bot Binding",
"account.field.email": "Email",
"account.field.userId": "User ID",
"account.field.provider": "Sign-in method",
"account.field.lastSignIn": "Last sign-in",
"account.field.mode": "Entitlement mode",
"account.field.backendStatus": "Backend status",
"account.field.subscription": "Subscription result",
"account.field.requirement": "Subscription policy",
"account.field.bindCommand": "Binding command",
"account.field.bindHint":
"Send this command to the Telegram bot to bind web account identity.",
"account.mode.supabaseRequired": "Supabase required auth",
"account.mode.supabaseOptional": "Supabase optional auth",
"account.mode.supabase": "Supabase session auth",
"account.mode.legacy": "Legacy token auth",
"account.mode.disabled": "Auth guard disabled",
"account.mode.unknown": "Unknown mode",
"account.backend.ok": "Passed",
"account.backend.fail": "Failed",
"account.subscription.active": "Active",
"account.subscription.inactive": "Inactive/Expired",
"account.subscription.notRequired": "Not required now",
"account.subscription.unknown": "Unknown",
"account.copy": "Copy",
"account.copied": "Copied",
"account.na": "--",
"common.na": "--",
},
};
+29
View File
@@ -0,0 +1,29 @@
import { createBrowserClient } from "@supabase/ssr";
import type { SupabaseClient } from "@supabase/supabase-js";
let cachedClient: SupabaseClient | null = null;
function readSupabasePublicEnv() {
const url = process.env.NEXT_PUBLIC_SUPABASE_URL?.trim();
const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY?.trim();
return { anonKey, url };
}
export function hasSupabasePublicEnv() {
const { anonKey, url } = readSupabasePublicEnv();
return Boolean(url && anonKey);
}
export function getSupabaseBrowserClient(): SupabaseClient {
if (cachedClient) {
return cachedClient;
}
const { anonKey, url } = readSupabasePublicEnv();
if (!url || !anonKey) {
throw new Error("Supabase public env is not configured");
}
cachedClient = createBrowserClient(url, anonKey);
return cachedClient;
}
+70
View File
@@ -0,0 +1,70 @@
import { createServerClient, type CookieOptions } from "@supabase/ssr";
import type { NextRequest, NextResponse } from "next/server";
type CookieAdapter = {
getAll: () => { name: string; value: string }[];
setAll: (cookies: { name: string; value: string; options?: CookieOptions }[]) => void;
};
function readSupabasePublicEnv() {
const url = process.env.NEXT_PUBLIC_SUPABASE_URL?.trim();
const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY?.trim();
return { anonKey, url };
}
export function hasSupabaseServerEnv() {
const { anonKey, url } = readSupabasePublicEnv();
return Boolean(url && anonKey);
}
export function createSupabaseServerClient(
cookieAdapter: CookieAdapter,
) {
const { anonKey, url } = readSupabasePublicEnv();
if (!url || !anonKey) {
throw new Error("Supabase env is not configured");
}
return createServerClient(url, anonKey, {
cookies: cookieAdapter,
});
}
export function createSupabaseMiddlewareClient(
request: NextRequest,
response: NextResponse,
) {
return createSupabaseServerClient({
getAll() {
return request.cookies.getAll().map((item) => ({
name: item.name,
value: item.value,
}));
},
setAll(cookiesToSet) {
for (const cookie of cookiesToSet) {
response.cookies.set(cookie.name, cookie.value, cookie.options);
}
},
});
}
export function createSupabaseRouteClient(
request: NextRequest,
response: NextResponse,
) {
return createSupabaseServerClient({
getAll() {
return request.cookies.getAll().map((item) => ({
name: item.name,
value: item.value,
}));
},
setAll(cookiesToSet) {
for (const cookie of cookiesToSet) {
response.cookies.set(cookie.name, cookie.value, cookie.options);
}
},
});
}
+5
View File
@@ -168,6 +168,8 @@ export interface CityInfo {
icao: string;
temp_unit: "celsius" | "fahrenheit";
is_major: boolean;
settlement_source?: string;
settlement_source_label?: string;
}
export interface CitiesResponse {
@@ -228,6 +230,8 @@ export interface CityAnalysis {
max_so_far: number | null;
max_temp_time: string;
wu_settlement: number | null;
settlement_source?: string | null;
settlement_source_label?: string | null;
obs_time: string;
obs_age_min: number | null;
wind_speed_kt: number | null;
@@ -339,6 +343,7 @@ export interface CityDetail {
timeseries: {
metar_recent_obs: any[];
metar_today_obs: any[];
settlement_today_obs?: any[];
hourly: any;
mgm_hourly: any[];
forecast_daily: any[];
+88
View File
@@ -0,0 +1,88 @@
type MetricName = "INP" | "LCP" | "FCP";
export type VitalsSample = {
id: string;
metric: MetricName;
navigationType: string;
pathname: string;
rating: string;
timestamp: number;
value: number;
};
type AggregatedMetric = {
count: number;
p75: number;
};
export type VitalsSummary = {
generatedAt: string;
routes: Record<string, Partial<Record<MetricName, AggregatedMetric>>>;
};
const MAX_SAMPLES = 3000;
const trackedMetrics = new Set<MetricName>(["INP", "LCP", "FCP"]);
declare global {
// eslint-disable-next-line no-var
var __polyweatherVitalsStore: VitalsSample[] | undefined;
}
function getStore() {
if (!globalThis.__polyweatherVitalsStore) {
globalThis.__polyweatherVitalsStore = [];
}
return globalThis.__polyweatherVitalsStore;
}
function toPercentile(values: number[], percentile: number) {
if (!values.length) return 0;
const sorted = [...values].sort((a, b) => a - b);
const position = Math.ceil(percentile * sorted.length) - 1;
const index = Math.max(0, Math.min(sorted.length - 1, position));
return sorted[index];
}
export function normalizeMetricName(input: unknown): MetricName | null {
const value = String(input || "").trim().toUpperCase() as MetricName;
return trackedMetrics.has(value) ? value : null;
}
export function recordVitalsSample(sample: VitalsSample) {
const store = getStore();
store.push(sample);
if (store.length > MAX_SAMPLES) {
store.splice(0, store.length - MAX_SAMPLES);
}
}
export function getVitalsSummary(): VitalsSummary {
const store = getStore();
const grouped: Record<string, Record<MetricName, number[]>> = {};
for (const item of store) {
const route = item.pathname || "/";
const metric = item.metric;
grouped[route] ||= { FCP: [], INP: [], LCP: [] };
grouped[route][metric].push(item.value);
}
const routes: VitalsSummary["routes"] = {};
Object.entries(grouped).forEach(([route, byMetric]) => {
const routeStats: Partial<Record<MetricName, AggregatedMetric>> = {};
(["INP", "LCP", "FCP"] as const).forEach((metric) => {
const values = byMetric[metric];
if (!values.length) return;
routeStats[metric] = {
count: values.length,
p75: Number(toPercentile(values, 0.75).toFixed(2)),
};
});
routes[route] = routeStats;
});
return {
generatedAt: new Date().toISOString(),
routes,
};
}
+93 -2
View File
@@ -1,11 +1,32 @@
import { NextRequest, NextResponse } from "next/server";
import {
createSupabaseMiddlewareClient,
hasSupabaseServerEnv,
} from "@/lib/supabase/server";
const SESSION_COOKIE = "polyweather_entitlement";
function readEnvBool(name: string, fallback: boolean) {
const raw = process.env[name];
if (raw == null) return fallback;
return String(raw).trim().toLowerCase() === "true";
}
const SUPABASE_AUTH_ENABLED =
readEnvBool("POLYWEATHER_AUTH_ENABLED", false);
const SUPABASE_AUTH_REQUIRED = readEnvBool(
"POLYWEATHER_AUTH_REQUIRED",
SUPABASE_AUTH_ENABLED,
);
function isStaticAsset(pathname: string) {
return (
pathname.startsWith("/_next/") ||
pathname.startsWith("/favicon") ||
pathname === "/apple-touch-icon.png" ||
pathname === "/manifest.webmanifest" ||
pathname === "/site.webmanifest" ||
pathname.startsWith("/android-chrome-") ||
pathname.startsWith("/robots.txt") ||
pathname.startsWith("/sitemap.xml") ||
pathname.startsWith("/icons/") ||
@@ -15,10 +36,14 @@ function isStaticAsset(pathname: string) {
}
function isPublicPage(pathname: string) {
return pathname === "/entitlement-required";
return (
pathname === "/entitlement-required" ||
pathname.startsWith("/auth/login") ||
pathname.startsWith("/auth/callback")
);
}
export function middleware(request: NextRequest) {
function handleLegacyTokenGate(request: NextRequest) {
const requiredToken = process.env.POLYWEATHER_DASHBOARD_ACCESS_TOKEN?.trim();
if (!requiredToken) {
return NextResponse.next();
@@ -64,6 +89,72 @@ export function middleware(request: NextRequest) {
return NextResponse.redirect(deniedUrl);
}
async function handleSupabaseAuthGate(request: NextRequest) {
const { pathname } = request.nextUrl;
if (isPublicPage(pathname)) {
return NextResponse.next();
}
if (pathname.startsWith("/api/")) {
const authHeader = String(request.headers.get("authorization") || "").trim();
if (/^bearer\s+\S+/i.test(authHeader)) {
return NextResponse.next();
}
}
const response = NextResponse.next({
request: {
headers: request.headers,
},
});
const supabase = createSupabaseMiddlewareClient(request, response);
const {
data: { user },
} = await supabase.auth.getUser();
if (user) {
return response;
}
if (pathname.startsWith("/api/")) {
return NextResponse.json(
{ error: "Unauthorized", detail: "Supabase session required" },
{ status: 401 },
);
}
const loginUrl = request.nextUrl.clone();
loginUrl.pathname = "/auth/login";
loginUrl.search = "";
loginUrl.searchParams.set("next", pathname);
return NextResponse.redirect(loginUrl);
}
async function handleSupabaseOptionalSession(request: NextRequest) {
const response = NextResponse.next({
request: {
headers: request.headers,
},
});
const supabase = createSupabaseMiddlewareClient(request, response);
await supabase.auth.getUser();
return response;
}
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
if (isStaticAsset(pathname)) {
return NextResponse.next();
}
if (SUPABASE_AUTH_ENABLED && hasSupabaseServerEnv()) {
if (SUPABASE_AUTH_REQUIRED) {
return handleSupabaseAuthGate(request);
}
return handleSupabaseOptionalSession(request);
}
return handleLegacyTokenGate(request);
}
export const config = {
matcher: ["/((?!_next/static|_next/image).*)"],
};
+7498 -20
View File
File diff suppressed because it is too large Load Diff

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