Compare commits
184 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 25ab512371 | |||
| 2a19a23e4d | |||
| 6e3bc1ce75 | |||
| 5e88e1480a | |||
| e4cc5b4263 | |||
| dbf10253a8 | |||
| da9b0b36f7 | |||
| f049a69ee3 | |||
| b33a741385 | |||
| 69f24ddc08 | |||
| f0fcb1e379 | |||
| 5c209ceb75 | |||
| 15a2fb91d1 | |||
| f2547e11b6 | |||
| fef3097c36 | |||
| 5057892255 | |||
| 233112cf0a | |||
| 60f83d0905 | |||
| 1ae9b55509 | |||
| ebaa57b5d1 | |||
| 9ac0a13937 | |||
| 508c322c8b | |||
| 5a6d9eeda4 | |||
| 7345b70958 | |||
| 5d2ed68cdf | |||
| 7510e0a1e0 | |||
| 217414408c | |||
| 93f7a852a8 | |||
| 90c998f06d | |||
| 54544e40b9 | |||
| 609fc8914a | |||
| 6a229a592b | |||
| 1ebd07ca31 | |||
| 81604bfe8a | |||
| e7a5347907 | |||
| fd7ec30e58 | |||
| 9bff3b0683 | |||
| 314cad82e6 | |||
| 1c57a6f56e | |||
| 530080a75d | |||
| 6d1f77cac1 | |||
| 2397a7569b | |||
| c1fb52551d | |||
| d1f4a8b236 | |||
| 4bd531bbc9 | |||
| 64f8b37014 | |||
| 02e35a3a5f | |||
| be61c39795 | |||
| af2561d019 | |||
| 4bd97cc5a5 | |||
| db2f22509b | |||
| f27970f157 | |||
| 3d9d4dcd64 | |||
| 786765e48b | |||
| cd0a5d519f | |||
| edf2a37976 | |||
| 7bde49bd5e | |||
| 80acade251 | |||
| ac5c3a2c76 | |||
| 08217deefb | |||
| ac9e537070 | |||
| e780830f76 | |||
| aa98623cab | |||
| f00360ed56 | |||
| e798a4f33a | |||
| 50a2202930 | |||
| c3958c29c7 | |||
| 663d6be506 | |||
| 2de083d53a | |||
| 04fda60fb4 | |||
| c031d9caeb | |||
| 9ba0890ca9 | |||
| fb630b47cb | |||
| 5b7a257087 | |||
| 534eef4197 | |||
| 25f11912dd | |||
| e201856700 | |||
| 74232907a9 | |||
| 024df2dc5b | |||
| 9531cc4b64 | |||
| 990182e089 | |||
| 120f4b1261 | |||
| f2e0940282 | |||
| 3c3ffa1248 | |||
| 18a525af88 | |||
| c0d914eb88 | |||
| 0aaa09370b | |||
| aa3546e2a9 | |||
| 003aff98d6 | |||
| 1e541433e5 | |||
| 0f51780566 | |||
| a5948a35d4 | |||
| 5c3de0dbe0 | |||
| 31ee147734 | |||
| 54c4a26162 | |||
| f7b649bb0a | |||
| d5bbc43b52 | |||
| e62a36e0bc | |||
| bd839d0cbe | |||
| e3bd90d186 | |||
| 9cc9ab93c4 | |||
| 0a869459c4 | |||
| 987aec2fa6 | |||
| f4fea03f35 | |||
| c582015163 | |||
| 27fe6ea494 | |||
| 0877efe0c3 | |||
| 20d3e940e0 | |||
| c156809231 | |||
| be651cd1d7 | |||
| 22082b9a83 | |||
| 0c976ac846 | |||
| 47ff575588 | |||
| b4d38590e1 | |||
| f065b67605 | |||
| 4e5bf5a4f8 | |||
| de6cf68ee7 | |||
| 1060945d08 | |||
| ad2b1aa4b6 | |||
| ba0916ea51 | |||
| a46a98f25e | |||
| 294e038f57 | |||
| eca0c7f514 | |||
| 9295899aeb | |||
| db3b1c995a | |||
| e89fdb7e09 | |||
| 35345a1b04 | |||
| 025feaa96e | |||
| 23e2959404 | |||
| 6273768cc2 | |||
| 44af26da70 | |||
| 3cbef28b13 | |||
| d8cc193618 | |||
| 1958b2764b | |||
| d2a40462c5 | |||
| b3f46430ad | |||
| 878e3280d1 | |||
| 723694d77c | |||
| af4bee12f5 | |||
| b1e75d13d8 | |||
| 8b6c66eab0 | |||
| f7e7e0ea20 | |||
| 5d943f2aae | |||
| 8712335ffd | |||
| 41efaa1140 | |||
| 2b681513ab | |||
| c5010a8b97 | |||
| 535b2b0446 | |||
| 7f84314bcc | |||
| 4b51e0dd15 | |||
| cc1a9a3b77 | |||
| 46412511c8 | |||
| c23020038e | |||
| d4760dbcb2 | |||
| ffb88ae61d | |||
| 4e6cb1071c | |||
| d6434bf174 | |||
| 79b4708b7e | |||
| a6bef9a57f | |||
| d08982a898 | |||
| c53bfc629b | |||
| f638850048 | |||
| dd72e6a34b | |||
| 5af63557df | |||
| 35b846f1d6 | |||
| fb1c88463f | |||
| d1b591106e | |||
| 1afca8ecbb | |||
| e5011c5c8f | |||
| 732b3c10a5 | |||
| 396c373cba | |||
| aab4477ab3 | |||
| 020c62676e | |||
| 243bf6c33b | |||
| 283a935f24 | |||
| d162b91ed9 | |||
| d45ee8a981 | |||
| d184bc323c | |||
| b53f1f74bd | |||
| 0417387c0b | |||
| 1655a026e8 | |||
| 2da6e6829f | |||
| ae40f70cac | |||
| d2107cbcbe |
@@ -1,15 +1,31 @@
|
||||
# Telegram Bot
|
||||
TELEGRAM_BOT_TOKEN=your_bot_token_here
|
||||
TELEGRAM_CHAT_ID=your_chat_id_here
|
||||
# Optional multi-chat target (comma-separated). If set, it will be merged with TELEGRAM_CHAT_ID.
|
||||
# Example: TELEGRAM_CHAT_IDS=-1003586303099,-1003539418691
|
||||
TELEGRAM_CHAT_IDS=
|
||||
# Optional: route /city and /deb outputs to a fixed forum topic.
|
||||
# If TELEGRAM_QUERY_TOPIC_CHAT_ID is empty, fallback to command source chat.
|
||||
TELEGRAM_QUERY_TOPIC_CHAT_ID=
|
||||
TELEGRAM_QUERY_TOPIC_ID=
|
||||
# Optional per-group topic routing (higher priority than fixed topic above):
|
||||
# format: chat_id:topic_id,chat_id:topic_id
|
||||
# Example: TELEGRAM_QUERY_TOPIC_MAP=-1003586303099:25513,-1003539418691:25514
|
||||
TELEGRAM_QUERY_TOPIC_MAP=
|
||||
TELEGRAM_ALERT_PUSH_ENABLED=true
|
||||
TELEGRAM_ALERT_PUSH_INTERVAL_SEC=300
|
||||
TELEGRAM_ALERT_PUSH_COOLDOWN_SEC=1800
|
||||
TELEGRAM_ALERT_MIN_TRIGGER_COUNT=2
|
||||
TELEGRAM_ALERT_MIN_SEVERITY=medium
|
||||
TELEGRAM_ALERT_CITIES=ankara,london,paris,seoul,toronto,buenos aires,wellington,new york,chicago,dallas,miami,atlanta,seattle,lucknow,sao paulo,munich
|
||||
# Mispricing radar: skip push when YES buy price is above this cap (10c = 0.10)
|
||||
TELEGRAM_ALERT_MISPRICING_MAX_YES_BUY=0.10
|
||||
TELEGRAM_ALERT_CITIES=ankara,london,paris,seoul,hong kong,shanghai,singapore,tokyo,tel aviv,toronto,buenos aires,wellington,new york,chicago,dallas,miami,atlanta,seattle,lucknow,sao paulo,munich
|
||||
|
||||
# AI
|
||||
GROQ_API_KEY=your_groq_api_key_here
|
||||
# Open-Meteo (forecast data changes ~hourly, no need to refresh more often)
|
||||
OPEN_METEO_CACHE_TTL_SEC=7200
|
||||
OPEN_METEO_ENSEMBLE_CACHE_TTL_SEC=7200
|
||||
OPEN_METEO_MULTI_MODEL_CACHE_TTL_SEC=7200
|
||||
OPEN_METEO_MULTI_MODEL_CACHE_VERSION=v2
|
||||
|
||||
# Proxy Setting (optional)
|
||||
HTTPS_PROXY=http://127.0.0.1:7890
|
||||
@@ -19,3 +35,152 @@ HTTP_PROXY=http://127.0.0.1:7890
|
||||
LOG_LEVEL=INFO
|
||||
ENV=production
|
||||
POLYWEATHER_MAP_URL=https://polyweather-pro.vercel.app/
|
||||
# Runtime data directory (host path mounted into container at /var/lib/polyweather and /app/data)
|
||||
POLYWEATHER_RUNTIME_DATA_DIR=/var/lib/polyweather
|
||||
# Recommended: keep SQLite outside repository workspace
|
||||
POLYWEATHER_DB_PATH=/var/lib/polyweather/polyweather.db
|
||||
# Recommended disk cache/state paths (optional; defaults are still /app/data/*)
|
||||
OPEN_METEO_DISK_CACHE_PATH=/var/lib/polyweather/open_meteo_cache.json
|
||||
# Unified Auth (Supabase + Google/Email)
|
||||
POLYWEATHER_AUTH_ENABLED=false
|
||||
# If true: website APIs require login; if false: guest access, login optional.
|
||||
POLYWEATHER_AUTH_REQUIRED=false
|
||||
# If true, authenticated users must also have an active row in `subscriptions`.
|
||||
POLYWEATHER_AUTH_REQUIRE_SUBSCRIPTION=false
|
||||
SUPABASE_URL=
|
||||
SUPABASE_ANON_KEY=
|
||||
SUPABASE_SERVICE_ROLE_KEY=
|
||||
SUPABASE_HTTP_TIMEOUT_SEC=8
|
||||
SUPABASE_AUTH_CACHE_TTL_SEC=30
|
||||
SUPABASE_SUB_CACHE_TTL_SEC=60
|
||||
# Frontend wallet connection (WalletConnect v2)
|
||||
# Apply in Vercel env as NEXT_PUBLIC_*
|
||||
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=
|
||||
NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL=https://polygon-bor-rpc.publicnode.com
|
||||
# Bot command access guard (/city, /deb):
|
||||
# - Pro entitlement removed
|
||||
# - user only needs to be a member of any configured group (TELEGRAM_CHAT_IDS / TELEGRAM_CHAT_ID)
|
||||
POLYWEATHER_BOT_GROUP_INVITE_URL=
|
||||
# Group message points (anti-spam + ranking)
|
||||
POLYWEATHER_BOT_MESSAGE_POINTS=4
|
||||
POLYWEATHER_BOT_MESSAGE_DAILY_CAP=40
|
||||
POLYWEATHER_BOT_MESSAGE_MIN_LENGTH=3
|
||||
POLYWEATHER_BOT_MESSAGE_COOLDOWN_SEC=30
|
||||
POLYWEATHER_BOT_CITY_QUERY_COST=1
|
||||
POLYWEATHER_BOT_DEB_QUERY_COST=1
|
||||
# Weekly leaderboard reward settlement
|
||||
# settle_weekday: 1=Mon ... 7=Sun
|
||||
POLYWEATHER_WEEKLY_REWARD_ENABLED=true
|
||||
POLYWEATHER_WEEKLY_REWARD_TIMEZONE=Asia/Shanghai
|
||||
POLYWEATHER_WEEKLY_REWARD_SETTLE_WEEKDAY=1
|
||||
POLYWEATHER_WEEKLY_REWARD_SETTLE_HOUR=0
|
||||
POLYWEATHER_WEEKLY_REWARD_SETTLE_MINUTE=5
|
||||
POLYWEATHER_WEEKLY_REWARD_CHECK_INTERVAL_SEC=300
|
||||
POLYWEATHER_WEEKLY_REWARD_HTTP_TIMEOUT_SEC=10
|
||||
POLYWEATHER_WEEKLY_REWARD_ANNOUNCE_ENABLED=true
|
||||
# Backend entitlement guard (for /api/cities, /api/city/*, /api/history/*)
|
||||
POLYWEATHER_REQUIRE_ENTITLEMENT=false
|
||||
POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN=
|
||||
|
||||
# P1 Contract Checkout (MetaMask + Polygon USDC)
|
||||
POLYWEATHER_PAYMENT_ENABLED=false
|
||||
POLYWEATHER_PAYMENT_CHAIN_ID=137
|
||||
POLYWEATHER_PAYMENT_RPC_URL=https://polygon-rpc.com
|
||||
# Legacy single-token fallback (still supported)
|
||||
POLYWEATHER_PAYMENT_RECEIVER_CONTRACT=
|
||||
POLYWEATHER_PAYMENT_TOKEN_ADDRESS=0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174
|
||||
POLYWEATHER_PAYMENT_TOKEN_DECIMALS=6
|
||||
# Recommended multi-token config (supports USDC.e + Native USDC at the same time)
|
||||
# Example:
|
||||
# [
|
||||
# {"code":"usdc_e","symbol":"USDC.e","name":"USDC.e (PoS)","address":"0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174","decimals":6,"receiver_contract":"0xYourCheckoutContract","is_default":true},
|
||||
# {"code":"usdc","symbol":"USDC","name":"Native USDC","address":"0x3c499c542cef5e3811e1192ce70d8cc03d5c3359","decimals":6,"receiver_contract":"0xYourCheckoutContract"}
|
||||
# ]
|
||||
POLYWEATHER_PAYMENT_ACCEPTED_TOKENS_JSON=
|
||||
POLYWEATHER_PAYMENT_CONFIRMATIONS=2
|
||||
POLYWEATHER_PAYMENT_INTENT_TTL_SEC=1800
|
||||
POLYWEATHER_PAYMENT_WALLET_CHALLENGE_TTL_SEC=600
|
||||
POLYWEATHER_PAYMENT_HTTP_TIMEOUT_SEC=10
|
||||
POLYWEATHER_PAYMENT_POLL_INTERVAL_SEC=4
|
||||
POLYWEATHER_PAYMENT_MAX_WAIT_SEC=50
|
||||
POLYWEATHER_PAYMENT_TELEGRAM_NOTIFY_ENABLED=true
|
||||
# Payment points redemption
|
||||
POLYWEATHER_PAYMENT_POINTS_ENABLED=true
|
||||
POLYWEATHER_PAYMENT_POINTS_PER_USDC=500
|
||||
POLYWEATHER_PAYMENT_POINTS_MAX_DISCOUNT_USDC=3
|
||||
# Comma-separated allowed plans for checkout UI + backend validation.
|
||||
# Default is monthly-only launch.
|
||||
POLYWEATHER_PAYMENT_ALLOWED_PLAN_CODES=pro_monthly
|
||||
# JSON object
|
||||
# Example: {"pro_monthly":{"plan_id":101,"amount_usdc":"5","duration_days":30}}
|
||||
POLYWEATHER_PAYMENT_PLAN_CATALOG_JSON=
|
||||
|
||||
# Polymarket P0 Read-Only Market Layer
|
||||
POLYMARKET_MARKET_SCAN_ENABLED=true
|
||||
POLYMARKET_GAMMA_URL=https://gamma-api.polymarket.com
|
||||
POLYMARKET_CLOB_URL=https://clob.polymarket.com
|
||||
POLYMARKET_CHAIN_ID=137
|
||||
POLYMARKET_HTTP_TIMEOUT_SEC=8
|
||||
POLYMARKET_MARKET_CACHE_TTL_SEC=180
|
||||
POLYMARKET_PRICE_CACHE_TTL_SEC=10
|
||||
POLYMARKET_DISCOVERY_PAGES=6
|
||||
POLYMARKET_DISCOVERY_LIMIT=200
|
||||
POLYMARKET_SIGNAL_MIN_LIQUIDITY=500
|
||||
POLYMARKET_SIGNAL_EDGE_PCT=2
|
||||
|
||||
# Polygon Wallet Watcher (Single Chain P0)
|
||||
POLYGON_WALLET_WATCH_ENABLED=false
|
||||
POLYGON_RPC_URL=https://polygon-rpc.com
|
||||
POLYGON_WALLET_WATCH_ADDRESSES=0x0000000000000000000000000000000000000000
|
||||
POLYGON_WALLET_WATCH_INTERVAL_SEC=8
|
||||
POLYGON_WALLET_WATCH_CONFIRMATIONS=2
|
||||
POLYGON_WALLET_WATCH_MAX_BLOCKS_PER_CYCLE=30
|
||||
POLYGON_WALLET_WATCH_SEEN_TTL_SEC=604800
|
||||
POLYGON_WALLET_WATCH_RPC_TIMEOUT_SEC=10
|
||||
POLYGON_WALLET_WATCH_TX_BASE=https://polygonscan.com/tx
|
||||
POLYGON_WALLET_WATCH_ADDR_BASE=https://polygonscan.com/address
|
||||
POLYGON_WALLET_WATCH_POLYMARKET_ONLY=true
|
||||
POLYGON_WALLET_WATCH_INCLUDE_DEFAULT_PM_CONTRACTS=true
|
||||
# Optional custom Polymarket contracts, format: LABEL:0x...,LABEL2:0x...
|
||||
POLYGON_WALLET_WATCH_POLYMARKET_CONTRACTS=
|
||||
|
||||
# Polymarket Wallet Activity Watcher (all markets, not weather-only)
|
||||
POLYMARKET_WALLET_ACTIVITY_ENABLED=false
|
||||
POLYMARKET_WALLET_ACTIVITY_USERS=0x0000000000000000000000000000000000000000
|
||||
# Optional dedicated chat targets for wallet activity push (recommended)
|
||||
# If unset, fallback to TELEGRAM_CHAT_IDS / TELEGRAM_CHAT_ID.
|
||||
POLYMARKET_WALLET_ACTIVITY_CHAT_ID=
|
||||
POLYMARKET_WALLET_ACTIVITY_CHAT_IDS=
|
||||
# Optional: mirror wallet activity push to a forum topic, while keeping existing chat targets unchanged.
|
||||
POLYMARKET_WALLET_ACTIVITY_TOPIC_CHAT_ID=
|
||||
POLYMARKET_WALLET_ACTIVITY_TOPIC_ID=
|
||||
# Optional wallet nicknames:
|
||||
# - CSV: 0xabc...=Whale_A,0xdef...=Main_Account
|
||||
# - JSON: {"0xabc...":"Whale A","0xdef...":"Main Account"}
|
||||
# - Env key: POLYMARKET_WALLET_ACTIVITY_USER_ALIASES
|
||||
# (legacy typo POLYMARKET_WALLET_ACTIVITY_USERS_ALIASES is also accepted)
|
||||
POLYMARKET_WALLET_ACTIVITY_USER_ALIASES=
|
||||
POLYMARKET_WALLET_ACTIVITY_DATA_API_URL=https://data-api.polymarket.com
|
||||
POLYMARKET_WALLET_ACTIVITY_INTERVAL_SEC=20
|
||||
POLYMARKET_WALLET_ACTIVITY_TIMEOUT_SEC=10
|
||||
POLYMARKET_WALLET_ACTIVITY_MIN_SIZE_ABS=0.001
|
||||
POLYMARKET_WALLET_ACTIVITY_MIN_SIZE_DELTA=0.001
|
||||
POLYMARKET_WALLET_ACTIVITY_MIN_AVG_PRICE_DELTA=0.002
|
||||
POLYMARKET_WALLET_ACTIVITY_IMMEDIATE_ON_SIZE_DELTA=true
|
||||
POLYMARKET_WALLET_ACTIVITY_IMMEDIATE_SIZE_DELTA_MIN=0.001
|
||||
POLYMARKET_WALLET_ACTIVITY_IMMEDIATE_COOLDOWN_SEC=20
|
||||
POLYMARKET_WALLET_ACTIVITY_MAX_CHANGES_PER_MSG=5
|
||||
POLYMARKET_WALLET_ACTIVITY_NOTIFY_CLOSED=false
|
||||
POLYMARKET_WALLET_ACTIVITY_BOOTSTRAP_ALERT=false
|
||||
POLYMARKET_WALLET_ACTIVITY_LINK_PREVIEW=true
|
||||
POLYMARKET_WALLET_ACTIVITY_UPDATE_DEBOUNCE_SEC=30
|
||||
POLYMARKET_WALLET_ACTIVITY_UPDATE_MAX_HOLD_SEC=120
|
||||
POLYMARKET_WALLET_ACTIVITY_AVG_PRICE_SHOW_MIN=0.01
|
||||
POLYMARKET_WALLET_ACTIVITY_AVG_PRICE_SHOW_MAX=0.99
|
||||
# Skip wallet activity pushes when position value is below this floor (USD).
|
||||
# Set 0 to disable.
|
||||
POLYMARKET_WALLET_ACTIVITY_MIN_POSITION_VALUE_USD=0
|
||||
# Comma-separated wallet addresses exempt from min value floor.
|
||||
# Example: 0x849d9a4dd64829b8b0141ea53e7caca7e99529ec
|
||||
POLYMARKET_WALLET_ACTIVITY_MIN_VALUE_EXEMPT_USERS=
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"css.validate": false,
|
||||
"scss.validate": false,
|
||||
"less.validate": false
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
# Changelog
|
||||
|
||||
## 1.4.0 - 2026-03-14
|
||||
|
||||
- 统一收费阶段产品口径,发布 PolyWeather Pro `v1.4.0`
|
||||
- 前端交付覆盖账户、支付、权限展示与缓存策略
|
||||
- 支付链路支持 intent -> submit -> confirm 与自动补单
|
||||
- 文档统一切换到单一版本源管理
|
||||
@@ -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. 结论
|
||||
|
||||
前端已具备收费阶段的核心能力(账户、支付、权限展示、状态回收),可支持持续商业迭代。
|
||||
@@ -1,147 +1,140 @@
|
||||
# 🌡️ PolyWeather Pro
|
||||
# PolyWeather Pro
|
||||
|
||||
> **Professional Weather Intelligence System** —— Specialized in edge data collection, DEB smart blending, and real-time decision alerts.
|
||||
Production weather-intelligence stack for temperature settlement markets.
|
||||
|
||||
---
|
||||
Official dashboard: [polyweather-pro.vercel.app](https://polyweather-pro.vercel.app/)
|
||||
|
||||
## 💎 Project Vision
|
||||
## Product Screenshots
|
||||
|
||||
PolyWeather is a specialized intelligence system built for **Polymarket** high-stakes participants. We don't just provide weather forecasts; we aggregate data from top-tier global meteorological sources, apply our proprietary **DEB (Dynamic Error Balancing)** algorithm, and deliver **market-shifting alerts** at critical decision nodes.
|
||||
### Global Dashboard
|
||||
|
||||
---
|
||||

|
||||
|
||||
## 🏗️ Production Architecture
|
||||
### City Analysis (Ankara)
|
||||
|
||||
This project utilizes a production-grade decoupled architecture for high availability:
|
||||

|
||||
|
||||
- **Frontend**: A **Next.js** interactive dashboard deployed on **Vercel**.
|
||||
- **Backend API**: A **FastAPI** service running on a VPS, providing low-latency data access.
|
||||
- **Bot & Alert Heartbeat**: A **Telegram Bot** running on a VPS, executing minute-level global scans and push notifications.
|
||||
## Product Status (2026-03)
|
||||
|
||||
🔗 **Official Visit**: [polyweather-pro.vercel.app](https://polyweather-pro.vercel.app/)
|
||||
- Subscription live: `Pro Monthly 5 USDC`.
|
||||
- Points redemption live: `500 points = 1 USDC`, max `3 USDC` off.
|
||||
- Onchain checkout live: Polygon contract checkout (USDC / USDC.e).
|
||||
- Auto-reconciliation live: event listener + periodic confirm loop.
|
||||
|
||||
---
|
||||
## Open-Core Boundary (Important)
|
||||
|
||||
## 🖼️ Preview & Interaction
|
||||
This repository follows an **Open-Core** strategy:
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/demo_ankara.png" alt="PolyWeather Demo - Ankara Live Analysis" width="450">
|
||||
<br>
|
||||
<em>📊 <b>Deep Query View</b>: DEB Blended Forecast + Settlement Probability + Groq AI Expert Advice</em>
|
||||
</p>
|
||||
- Public in repo: weather aggregation, core analysis, dashboard, bot baseline, standard payment flow.
|
||||
- Private in production: commercial risk rules, operational thresholds, pricing strategy details, internal reconciliation policies, and growth operations tooling.
|
||||
|
||||
<p align="center">
|
||||
<img src="./docs/images/demo_map.png" alt="PolyWeather Web Map" width="850">
|
||||
<br>
|
||||
<em>🗺️ <b>Omni-Dashboard</b>: Real-time global heatmaps + array-style data visualization</em>
|
||||
</p>
|
||||
See: [Open-Core & Commercial Boundary](docs/OPEN_CORE_POLICY.md)
|
||||
|
||||
---
|
||||
## Core Capabilities
|
||||
|
||||
## 🚀 Core Features
|
||||
- Aggregates observations and forecasts for 20 monitored cities.
|
||||
- Uses DEB (Dynamic Error Balancing) to blend multi-model highs.
|
||||
- Generates settlement-oriented probability buckets (`mu` + bucket distribution).
|
||||
- Maps weather view to Polymarket quotes for mispricing scan.
|
||||
- Reuses one analysis core across web dashboard and Telegram bot.
|
||||
|
||||
- **📡 Full-Spectrum Collection**
|
||||
- **Major Models**: Real-time sync for ECMWF, GFS, ICON, GEM, and JMA high temperatures.
|
||||
- **Observed Data**: Global airport METAR reports + official Turkish MGM station-level data.
|
||||
- **Centralized Correction**: Integrated `17130` (Center) official data specifically for Ankara.
|
||||
- **⚖️ DEB Smart Blending**
|
||||
- Dynamic weighting of forecasts based on recent 7-day historical performance.
|
||||
- **🔔 Alert Engine**
|
||||
- **Momentum Spike**: Captures rapid temperature changes within 30 minutes.
|
||||
- **Forecast Breakthrough**: Fires when observations exceed all model predictions plus a safety margin.
|
||||
- **Advection Monitoring**: Simulates warm/cold advection based on lead stations and wind currents.
|
||||
- **🛡️ Smart Suppression**
|
||||
- **Peak Protection**: Automatically switches to snapshot mode when the daily high has likely passed.
|
||||
- **Cooldown Management**: Global and city-level cooldowns to prevent notification fatigue.
|
||||
|
||||
---
|
||||
|
||||
## 🔐 Alert Logic Details
|
||||
|
||||
| Trigger Name | Core Logic | Trading Value |
|
||||
| :--------------- | :-------------------------------------------- | :-------------------------------------------- |
|
||||
| **Center Hit** | Detects DEB trigger only at Ankara HQ `17130` | **Highest priority signal**, the "truth" |
|
||||
| **Momentum** | 30min temperature slope exceed threshold | Captures sudden weather fronts |
|
||||
| **Breakthrough** | Pierces all model highs + margin | Captures high-volatility outlier events |
|
||||
| **Advection** | Lead station rise + Wind match | Gain 20-40 minutes of lead time for execution |
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ System Architecture
|
||||
## Reference Architecture
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph "Client / Terminals"
|
||||
Web[Next.js Web App]
|
||||
TG[Telegram Client]
|
||||
end
|
||||
flowchart LR
|
||||
U["Users (Web / Telegram)"] --> FE["Next.js Frontend (Vercel)"]
|
||||
U --> BOT["Telegram Bot (VPS)"]
|
||||
FE --> API["FastAPI /web/app.py"]
|
||||
BOT --> API
|
||||
|
||||
subgraph "Edge Deployment (Vercel)"
|
||||
Web -.-> |Auth| Supa[(Supabase Auth/DB)]
|
||||
Web --> |API| Fast[FastAPI API]
|
||||
end
|
||||
API --> WX["Weather Collector"]
|
||||
WX --> METAR["Aviation Weather (METAR)"]
|
||||
WX --> MGM["MGM (Turkey station network)"]
|
||||
WX --> OM["Open-Meteo"]
|
||||
|
||||
subgraph "Core Hub (VPS)"
|
||||
Fast --- |Shared Logic| Worker[Alert Engine / Worker]
|
||||
Bot[Telegram Bot] --- |Shared Logic| Worker
|
||||
Worker --> |Cache/Sub| Supa
|
||||
end
|
||||
|
||||
subgraph "External Sources"
|
||||
Worker --> |Pull| MGM[MGM Weather]
|
||||
Worker --> |Pull| METAR[Airport METAR]
|
||||
Worker --> |Pull| OM[Open-Meteo]
|
||||
Worker --> |Pull| MM[Multi-Model Integration]
|
||||
end
|
||||
|
||||
Worker --> |Push Alert| TG
|
||||
Bot --> |Query| Worker
|
||||
API --> ANA["DEB + Trend + Probability + Market Scan"]
|
||||
ANA --> PAY["Payment State (Intent + Event + Confirm Loop)"]
|
||||
ANA --> PM["Polymarket Read-only Layer"]
|
||||
```
|
||||
|
||||
---
|
||||
## Monitored Cities (20)
|
||||
|
||||
## 🛠️ Deployment
|
||||
- Europe / Middle East: Ankara, London, Paris, Munich
|
||||
- APAC: Seoul, Hong Kong, Shanghai, Singapore, Tokyo, Wellington
|
||||
- Americas: Toronto, New York, Chicago, Dallas, Miami, Atlanta, Seattle, Buenos Aires, Sao Paulo
|
||||
- South Asia: Lucknow
|
||||
|
||||
### 1. Backend / Bot (VPS)
|
||||
## Quick Start
|
||||
|
||||
### Backend + Bot (Docker)
|
||||
|
||||
```bash
|
||||
# Pull Source
|
||||
git pull
|
||||
|
||||
# Environment
|
||||
# Edit .env with TELEGRAM_BOT_TOKEN and other keys
|
||||
|
||||
# Launch
|
||||
docker-compose up -d --build
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
### 2. Frontend (Vercel)
|
||||
### Frontend (local)
|
||||
|
||||
Associate the `frontend` directory as the project root on Vercel for automatic CI/CD.
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
---
|
||||
## Runtime Data (Recommended on VPS)
|
||||
|
||||
## 💬 Bot Commands
|
||||
Use external runtime storage to avoid SQLite/git conflicts:
|
||||
|
||||
| Command | Description | Example |
|
||||
| :-------- | :-------------------------------------- | :------------- |
|
||||
| `/city` | Query real-time analysis for a city | `/city ankara` |
|
||||
| `/deb` | View historical accuracy of DEB model | `/deb london` |
|
||||
| `/points` | View your activity points & leaderboard | `/points` |
|
||||
| `/help` | Get detailed instructions | `/help` |
|
||||
```env
|
||||
POLYWEATHER_RUNTIME_DATA_DIR=/var/lib/polyweather
|
||||
POLYWEATHER_DB_PATH=/var/lib/polyweather/polyweather.db
|
||||
```
|
||||
|
||||
---
|
||||
## Ops Verification
|
||||
|
||||
> [!NOTE]
|
||||
> **Commercialization**: This project currently offers **Web Dashboard ($5/mo)** and **Telegram Signal Channel ($1/mo)** subscriptions.
|
||||
> Point-earning via group participation is active and points can be redeemed for access.
|
||||
### Frontend cache headers
|
||||
|
||||
---
|
||||
```bash
|
||||
./scripts/validate_frontend_cache.sh "https://polyweather-pro.vercel.app"
|
||||
```
|
||||
|
||||
---
|
||||
### Payment auto-reconciliation logs
|
||||
|
||||
**📅 Last Updated**: 2026-03-08
|
||||
**🚀 Status**: v1.0 Stable - Professional Quant UI Locked
|
||||
```bash
|
||||
docker compose logs -f polyweather | egrep "payment event loop started|payment confirm loop started|payment auto-confirmed"
|
||||
```
|
||||
|
||||
> [!TIP]
|
||||
> **Production Note**: The current dashboard utilizes the high-density "Professional Quant" UI (v1.0-legacy) which integrates real-time METAR/MGM data, DEB ensemble blending, and multi-model probability distribution in a single high-performance view.
|
||||
### Wallet activity logs
|
||||
|
||||
```bash
|
||||
docker compose logs -f polyweather | egrep "polymarket wallet activity watcher started|wallet activity pushed"
|
||||
```
|
||||
|
||||
## Telegram Commands
|
||||
|
||||
| Command | Purpose |
|
||||
| :-- | :-- |
|
||||
| `/city <name>` | City real-time analysis |
|
||||
| `/deb <name>` | DEB historical reconciliation |
|
||||
| `/top` | User leaderboard |
|
||||
| `/id` | Show current chat ID |
|
||||
| `/diag` | Startup diagnostics |
|
||||
| `/help` | Help and usage |
|
||||
|
||||
## Documentation Index
|
||||
|
||||
- Chinese overview: [README_ZH.md](README_ZH.md)
|
||||
- Chinese API guide: [docs/API_ZH.md](docs/API_ZH.md)
|
||||
- Commercialization: [docs/COMMERCIALIZATION.md](docs/COMMERCIALIZATION.md)
|
||||
- Open-Core policy: [docs/OPEN_CORE_POLICY.md](docs/OPEN_CORE_POLICY.md)
|
||||
- Supabase setup (ZH): [docs/SUPABASE_SETUP_ZH.md](docs/SUPABASE_SETUP_ZH.md)
|
||||
- Tech debt (EN): [docs/TECH_DEBT.md](docs/TECH_DEBT.md)
|
||||
- Tech debt (ZH): [docs/TECH_DEBT_ZH.md](docs/TECH_DEBT_ZH.md)
|
||||
- Payment verification: [docs/payments/POLYGONSCAN_VERIFY.md](docs/payments/POLYGONSCAN_VERIFY.md)
|
||||
- Frontend report: [FRONTEND_REDESIGN_REPORT.md](FRONTEND_REDESIGN_REPORT.md)
|
||||
- Release process: [RELEASE.md](RELEASE.md)
|
||||
- Changelog: [CHANGELOG.md](CHANGELOG.md)
|
||||
|
||||
## Version
|
||||
|
||||
- Version: `v1.4.0`
|
||||
- Last Updated: `2026-03-14`
|
||||
|
||||
@@ -1,147 +1,140 @@
|
||||
# 🌡️ PolyWeather Pro
|
||||
# PolyWeather Pro
|
||||
|
||||
> **专业级博弈情报系统** —— 专注边缘气象数据采集、DEB 智能融合与实时决策预警。
|
||||
面向温度结算市场的生产级气象情报系统。
|
||||
|
||||
---
|
||||
官方看板:[polyweather-pro.vercel.app](https://polyweather-pro.vercel.app/)
|
||||
|
||||
## 💎 项目愿景
|
||||
## 产品截图
|
||||
|
||||
PolyWeather 是一套专为 **Polymarket** 深度博弈者设计的实时情报系统。我们不只是提供天气预报,而是通过聚合全球顶级气象源、应用自研的 **DEB (Dynamic Error Balancing)** 算法,并在关键时间节点提供**具有博弈预测价值**的异动预警。
|
||||
### 全球看板
|
||||
|
||||
---
|
||||

|
||||
|
||||
## 🏗️ 生产架构
|
||||
### 城市分析(Ankara)
|
||||
|
||||
本项目采用生产级解耦架构,确保高可用与实时性:
|
||||

|
||||
|
||||
- **前端**:部署在 **Vercel** 上的 **Next.js** 交互式仪表盘。
|
||||
- **后端 API**:运行在 VPS 上的 **FastAPI**,提供低延迟数据服务。
|
||||
- **机器人与预警心跳**:运行在 VPS 上的 **Telegram Bot**,执行每分钟级的全球扫描与推送。
|
||||
## 当前产品状态(2026-03)
|
||||
|
||||
🔗 **官方访问地址**:[polyweather-pro.vercel.app](https://polyweather-pro.vercel.app/)
|
||||
- 已上线订阅制:`Pro 月付 5 USDC`。
|
||||
- 已上线积分抵扣:`500 积分 = 1 USDC`,最多抵扣 `3 USDC`。
|
||||
- 已上线链上支付:Polygon 合约支付(USDC / USDC.e)。
|
||||
- 已上线自动补单:事件监听 + 周期确认双链路。
|
||||
|
||||
---
|
||||
## 开源边界(重要)
|
||||
|
||||
## 🖼️ 预览与交互
|
||||
本项目采用 **Open-Core** 策略:
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/demo_ankara.png" alt="PolyWeather 效果展示 - 安卡拉实时分析" width="450">
|
||||
<br>
|
||||
<em>📊 <b>深度查询效果</b>:DEB 融合预测 + 结算概率 + Groq AI 专家建议</em>
|
||||
</p>
|
||||
- 仓库公开部分:天气聚合、基础分析、前端看板、Bot 基础能力、支付标准流程示例。
|
||||
- 生产私有部分:商业风控规则、运营阈值、收费策略细节、付费用户运营脚本、内部对账与审计策略。
|
||||
|
||||
<p align="center">
|
||||
<img src="./docs/images/demo_map.png" alt="PolyWeather Web Map" width="850">
|
||||
<br>
|
||||
<em>🗺️ <b>全景仪表盘</b>:全球站点实时热力场 + 阵列式数据展示</em>
|
||||
</p>
|
||||
详细见:[Open-Core 与商用边界](docs/OPEN_CORE_POLICY.md)
|
||||
|
||||
---
|
||||
## 核心能力
|
||||
|
||||
## 🚀 核心功能
|
||||
- 聚合 20 个监控城市的实测与预报数据。
|
||||
- DEB(Dynamic Error Balancing)融合多模型最高温。
|
||||
- 输出结算导向概率分布(`mu` + 温度桶)。
|
||||
- 将模型观点映射到 Polymarket 行情,做错价扫描。
|
||||
- Web 仪表盘与 Telegram Bot 复用同一分析内核。
|
||||
|
||||
- **📡 多源全量采集**
|
||||
- **主流模型**:ECMWF, GFS, ICON, GEM, JMA 实时最高温同步。
|
||||
- **实测数据**:全球机场 METAR 定时报文 + 土耳其 MGM 局点官方实测。
|
||||
- **中心化纠偏**:针对安卡拉特别接入 `17130` (Center) 官方指挥中心数据。
|
||||
- **⚖️ DEB 智能融合**
|
||||
- 基于近期 7 天历史表现,动态调整各模型权重的博弈预测。
|
||||
- **🔔 异动预警系统 (Alert Engine)**
|
||||
- **动量突变**:捕捉 30 分钟内的急剧温变。
|
||||
- **预测突破**:当实测击穿所有预报上限时触发告警。
|
||||
- **平流监测**:基于周边前导站的风向流场模拟,预测冷/暖平流的到达。
|
||||
- **🛡️ 智能压制逻辑**
|
||||
- **峰值保护**:当日高温峰值大概率已过时,自动转为静默/快照模式,拒绝骚扰。
|
||||
- **冷却管理**:同一信号路径支持全局与城市级双重 CD。
|
||||
|
||||
---
|
||||
|
||||
## 🔐 预警逻辑深度说明
|
||||
|
||||
| 触发器名称 | 核心逻辑 | 博弈价值 |
|
||||
| :--------------- | :------------------------------------------- | :--------------------------------- |
|
||||
| **Center Hit** | 仅识别安卡拉总部 `17130` 站点的 DEB 触发信号 | **最高级信号**,定盘星 |
|
||||
| **Momentum** | 30min 温度斜率超过 | 捕捉突发天气系统(如锋面) |
|
||||
| **Breakthrough** | 击穿所有预报上限 + 安全边际 | 捕捉市场极少数情况下的暴利点 |
|
||||
| **Advection** | 前导站温升 + 风向匹配 | 获得 20-40 分钟的提前离场/建仓时间 |
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ 架构解析
|
||||
## 参考架构
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph "客户端 / 终端"
|
||||
Web[Next.js 网页端]
|
||||
TG[Telegram 客户端]
|
||||
end
|
||||
flowchart LR
|
||||
U["用户(Web / Telegram)"] --> FE["Next.js 前端(Vercel)"]
|
||||
U --> BOT["Telegram Bot(VPS)"]
|
||||
FE --> API["FastAPI /web/app.py"]
|
||||
BOT --> API
|
||||
|
||||
subgraph "云端部署 (Vercel)"
|
||||
Web -.-> |Auth| Supa[(Supabase Auth/DB)]
|
||||
Web --> |API| Fast[FastAPI API]
|
||||
end
|
||||
API --> WX["Weather Collector"]
|
||||
WX --> METAR["Aviation Weather(METAR)"]
|
||||
WX --> MGM["MGM(土耳其站网)"]
|
||||
WX --> OM["Open-Meteo"]
|
||||
|
||||
subgraph "核心引擎 (VPS)"
|
||||
Fast --- |Shared Logic| Worker[Alert Engine / Worker]
|
||||
Bot[Telegram Bot] --- |Shared Logic| Worker
|
||||
Worker --> |Cache/Sub| Supa
|
||||
end
|
||||
|
||||
subgraph "外部数据源"
|
||||
Worker --> |Pull| MGM[MGM 气象局]
|
||||
Worker --> |Pull| METAR[机场实测]
|
||||
Worker --> |Pull| OM[Open-Meteo]
|
||||
Worker --> |Pull| MM[多模型集成]
|
||||
end
|
||||
|
||||
Worker --> |Push Alert| TG
|
||||
Bot --> |Query| Worker
|
||||
API --> ANA["DEB + 趋势 + 概率 + 市场扫描"]
|
||||
ANA --> PAY["支付状态(Intent + Event + Confirm Loop)"]
|
||||
ANA --> PM["Polymarket 只读层"]
|
||||
```
|
||||
|
||||
---
|
||||
## 监控城市(20)
|
||||
|
||||
## 🛠️ 部署指南
|
||||
- 欧洲/中东:Ankara、London、Paris、Munich
|
||||
- 亚太:Seoul、Hong Kong、Shanghai、Singapore、Tokyo、Wellington
|
||||
- 美洲:Toronto、New York、Chicago、Dallas、Miami、Atlanta、Seattle、Buenos Aires、Sao Paulo
|
||||
- 南亚:Lucknow
|
||||
|
||||
### 1. 后端 / 机器人 (VPS)
|
||||
## 快速启动
|
||||
|
||||
### 后端 + Bot(Docker)
|
||||
|
||||
```bash
|
||||
# 获取源码
|
||||
git pull
|
||||
|
||||
# 环境配置
|
||||
# 编辑 .env 文件,填入 TELEGRAM_BOT_TOKEN 等关键参数
|
||||
|
||||
# 一键启动
|
||||
docker-compose up -d --build
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
### 2. 前端 (Vercel)
|
||||
### 前端本地运行
|
||||
|
||||
直接关联本项目 `frontend` 目录作为根目录即可,享受自动 CI/CD。
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
---
|
||||
## 运行数据目录(VPS 推荐)
|
||||
|
||||
## 💬 机器人指令
|
||||
建议将运行态数据放到仓库外(避免 `git pull` 被 SQLite 卡住):
|
||||
|
||||
| 命令 | 说明 | 示例 |
|
||||
| :-------- | :------------------------ | :------------- |
|
||||
| `/city` | 查询指定城市实时分析 | `/city ankara` |
|
||||
| `/deb` | 查看 DEB 模型的历史准确率 | `/deb london` |
|
||||
| `/points` | 查看您的活跃积分与排行榜 | `/points` |
|
||||
| `/help` | 获取详细功能说明 | `/help` |
|
||||
```env
|
||||
POLYWEATHER_RUNTIME_DATA_DIR=/var/lib/polyweather
|
||||
POLYWEATHER_DB_PATH=/var/lib/polyweather/polyweather.db
|
||||
```
|
||||
|
||||
---
|
||||
## 运维验收
|
||||
|
||||
> [!NOTE]
|
||||
> **商业化提示**:本项目目前提供 **Web 仪表盘 ($5/月)** 与 **Telegram 信号频道 ($1/月)** 订阅服务。
|
||||
> 发言获取积分逻辑已上线,活跃用户可兑换相应权限。
|
||||
### 前端缓存头
|
||||
|
||||
---
|
||||
```bash
|
||||
./scripts/validate_frontend_cache.sh "https://polyweather-pro.vercel.app"
|
||||
```
|
||||
|
||||
---
|
||||
### 支付自动补单日志
|
||||
|
||||
**📅 最后更新**:2026-03-08
|
||||
**🚀 状态**:v1.0 稳定版 - 专业量化 UI 已锁定
|
||||
```bash
|
||||
docker compose logs -f polyweather | egrep "payment event loop started|payment confirm loop started|payment auto-confirmed"
|
||||
```
|
||||
|
||||
> [!TIP]
|
||||
> **生产提示**:当前仪表盘采用高密度“专业量化版” UI (v1.0-legacy),深度集成了 METAR/MGM 实测数据、DEB 智能融合预报及多模型概率分布,提供最高性能的数据交互体验。
|
||||
### 钱包异动监听日志
|
||||
|
||||
```bash
|
||||
docker compose logs -f polyweather | egrep "polymarket wallet activity watcher started|wallet activity pushed"
|
||||
```
|
||||
|
||||
## Telegram 指令
|
||||
|
||||
| 指令 | 用途 |
|
||||
| :-- | :-- |
|
||||
| `/city <name>` | 城市实时分析 |
|
||||
| `/deb <name>` | DEB 历史对账 |
|
||||
| `/top` | 用户积分排行 |
|
||||
| `/id` | 查看聊天 Chat ID |
|
||||
| `/diag` | Bot 启动诊断 |
|
||||
| `/help` | 帮助与用法 |
|
||||
|
||||
## 文档索引
|
||||
|
||||
- 英文总览:[README.md](README.md)
|
||||
- API 文档(中文):[docs/API_ZH.md](docs/API_ZH.md)
|
||||
- 商业化说明:[docs/COMMERCIALIZATION.md](docs/COMMERCIALIZATION.md)
|
||||
- Open-Core 边界:[docs/OPEN_CORE_POLICY.md](docs/OPEN_CORE_POLICY.md)
|
||||
- Supabase 接入:[docs/SUPABASE_SETUP_ZH.md](docs/SUPABASE_SETUP_ZH.md)
|
||||
- 技术债(中文镜像):[docs/TECH_DEBT_ZH.md](docs/TECH_DEBT_ZH.md)
|
||||
- 技术债(主文档):[docs/TECH_DEBT.md](docs/TECH_DEBT.md)
|
||||
- 支付合约验证:[docs/payments/POLYGONSCAN_VERIFY.md](docs/payments/POLYGONSCAN_VERIFY.md)
|
||||
- 前端报告:[FRONTEND_REDESIGN_REPORT.md](FRONTEND_REDESIGN_REPORT.md)
|
||||
- 发布流程:[RELEASE.md](RELEASE.md)
|
||||
- 变更记录:[CHANGELOG.md](CHANGELOG.md)
|
||||
|
||||
## 当前版本
|
||||
|
||||
- 版本:`v1.4.0`
|
||||
- 最后更新:`2026-03-14`
|
||||
|
||||
@@ -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,811 +1,12 @@
|
||||
import sys
|
||||
import os
|
||||
from typing import List
|
||||
import telebot # type: ignore
|
||||
from loguru import logger # type: ignore
|
||||
import sys
|
||||
|
||||
# 确保项目根目录在 sys.path 中
|
||||
project_root = os.path.dirname(os.path.abspath(__file__))
|
||||
if project_root not in sys.path:
|
||||
sys.path.insert(0, project_root)
|
||||
|
||||
from src.utils.config_loader import load_config # type: ignore # noqa: E402
|
||||
from src.utils.telegram_push import start_trade_alert_push_loop # type: ignore # noqa: E402
|
||||
from src.data_collection.weather_sources import WeatherDataCollector # type: ignore # noqa: E402
|
||||
from src.data_collection.city_risk_profiles import get_city_risk_profile # type: ignore # noqa: E402
|
||||
from src.analysis.deb_algorithm import calculate_dynamic_weights, update_daily_record # noqa: E402
|
||||
from src.database.db_manager import DBManager
|
||||
|
||||
MESSAGE_POINTS = 1
|
||||
MESSAGE_DAILY_CAP = 50
|
||||
MESSAGE_MIN_LENGTH = 4
|
||||
MESSAGE_COOLDOWN_SEC = 30
|
||||
CITY_QUERY_COST = 1
|
||||
DEB_QUERY_COST = 1
|
||||
|
||||
|
||||
def analyze_weather_trend(weather_data, temp_symbol, city_name=None):
|
||||
"""Thin wrapper — delegates to shared trend_engine module."""
|
||||
from src.analysis.trend_engine import analyze_weather_trend as _analyze
|
||||
display_str, ai_context, _structured = _analyze(weather_data, temp_symbol, city_name)
|
||||
return display_str, ai_context
|
||||
|
||||
|
||||
def start_bot():
|
||||
config = load_config()
|
||||
token = os.getenv("TELEGRAM_BOT_TOKEN")
|
||||
if not token:
|
||||
logger.error("未找到 TELEGRAM_BOT_TOKEN 环境变量")
|
||||
return
|
||||
|
||||
bot = telebot.TeleBot(token)
|
||||
db = DBManager()
|
||||
weather = WeatherDataCollector(config)
|
||||
start_trade_alert_push_loop(bot, config)
|
||||
|
||||
def _display_name(user) -> str:
|
||||
return user.username or user.first_name or f"User_{user.id}"
|
||||
|
||||
def _ensure_query_points(message, cost: int, label: str) -> bool:
|
||||
user = message.from_user
|
||||
db.upsert_user(user.id, _display_name(user))
|
||||
result = db.spend_points(user.id, cost)
|
||||
if result.get("ok"):
|
||||
return True
|
||||
|
||||
balance = int(result.get("balance") or 0)
|
||||
required = int(result.get("required") or cost)
|
||||
missing = max(0, required - balance)
|
||||
bot.reply_to(
|
||||
message,
|
||||
(
|
||||
f"❌ 积分不足,无法执行 <b>{label}</b>\n"
|
||||
f"当前积分: <code>{balance}</code>\n"
|
||||
f"需要积分: <code>{required}</code>\n"
|
||||
f"还差积分: <code>{missing}</code>\n\n"
|
||||
f"积分规则:群内有效发言满 {MESSAGE_MIN_LENGTH} 字,"
|
||||
f"每次 +{MESSAGE_POINTS} 分,每日上限 {MESSAGE_DAILY_CAP} 分。"
|
||||
),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
return False
|
||||
|
||||
@bot.message_handler(commands=["start", "help"])
|
||||
def send_welcome(message):
|
||||
welcome_text = (
|
||||
"🌡️ <b>PolyWeather 天气查询机器人</b>\n\n"
|
||||
"可用指令:\n"
|
||||
f"/city [城市名] - 查询城市天气预测与实测 (消耗 {CITY_QUERY_COST} 积分)\n"
|
||||
f"/deb [城市名] - 查看 DEB 融合预测准确率 (消耗 {DEB_QUERY_COST} 积分)\n"
|
||||
"/top - 查看积分排行榜\n"
|
||||
"/id - 获取当前聊天的 Chat ID\n\n"
|
||||
"示例: <code>/city 伦敦</code>\n"
|
||||
f"💡 <i>提示: 群内有效发言满 {MESSAGE_MIN_LENGTH} 字,每次 +{MESSAGE_POINTS} 分,"
|
||||
f"每日上限 {MESSAGE_DAILY_CAP} 分。</i>"
|
||||
)
|
||||
bot.reply_to(message, welcome_text, parse_mode="HTML")
|
||||
|
||||
@bot.message_handler(commands=["id"])
|
||||
def get_chat_id(message):
|
||||
bot.reply_to(
|
||||
message,
|
||||
f"🎯 当前聊天的 Chat ID 是: <code>{message.chat.id}</code>",
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
@bot.message_handler(commands=["top"])
|
||||
def show_points(message):
|
||||
"""显示当前用户的积分及排行榜"""
|
||||
user = message.from_user
|
||||
db.upsert_user(user.id, _display_name(user))
|
||||
user_info = db.get_user(user.id)
|
||||
|
||||
leaderboard = db.get_leaderboard(limit=5)
|
||||
rank_text = "🏆 <b>PolyWeather 活跃度排行榜</b>\n"
|
||||
rank_text += "────────────────────\n"
|
||||
for i, entry in enumerate(leaderboard):
|
||||
medal = ["🥇", "🥈", "🥉", " ", " "][i] if i < 5 else " "
|
||||
rank_text += f"{medal} {entry['username'][:12]}: <b>{entry['points']}</b> 点\n"
|
||||
|
||||
if user_info:
|
||||
rank_text += "────────────────────\n"
|
||||
rank_text += (
|
||||
f"👤 <b>我的状态:</b>\n"
|
||||
f"└ 积分: <code>{user_info['points']}</code>\n"
|
||||
f"└ 发言: <code>{user_info['message_count']}</code> 次\n"
|
||||
f"└ 今日发言积分: <code>{user_info.get('daily_points') or 0}/{MESSAGE_DAILY_CAP}</code>\n"
|
||||
f"└ /city 消耗: <code>{CITY_QUERY_COST}</code> | /deb 消耗: <code>{DEB_QUERY_COST}</code>"
|
||||
)
|
||||
|
||||
bot.send_message(message.chat.id, rank_text, parse_mode="HTML")
|
||||
|
||||
@bot.message_handler(commands=["deb"])
|
||||
def deb_accuracy(message):
|
||||
"""查询 DEB 融合预测的历史准确率"""
|
||||
try:
|
||||
parts = message.text.split(maxsplit=1)
|
||||
if len(parts) < 2:
|
||||
bot.reply_to(
|
||||
message, "❓ 用法: <code>/deb ankara</code>", parse_mode="HTML"
|
||||
)
|
||||
return
|
||||
|
||||
from src.data_collection.city_registry import ALIASES, CITY_REGISTRY
|
||||
city_input = parts[1].strip().lower()
|
||||
city_name = ALIASES.get(city_input, city_input)
|
||||
|
||||
from src.analysis.deb_algorithm import load_history
|
||||
import os as _os
|
||||
|
||||
# 获取详细历史数据
|
||||
project_root = _os.path.dirname(_os.path.abspath(__file__))
|
||||
history_file = _os.path.join(project_root, "data", "daily_records.json")
|
||||
data = load_history(history_file)
|
||||
|
||||
if city_name not in data or not data[city_name]:
|
||||
bot.reply_to(
|
||||
message, f"❌ 暂无 {city_name} 的历史数据", parse_mode="HTML"
|
||||
)
|
||||
return
|
||||
|
||||
if not _ensure_query_points(message, DEB_QUERY_COST, "/deb"):
|
||||
return
|
||||
|
||||
city_data = data[city_name]
|
||||
from datetime import datetime as _dt
|
||||
|
||||
today_str = _dt.now().strftime("%Y-%m-%d")
|
||||
|
||||
lines = [f"📊 <b>DEB 准确率报告 - {city_name.title()}</b>\n"]
|
||||
|
||||
# 逐日明细
|
||||
lines.append("<b>📅 逐日记录:</b>")
|
||||
total_days = 0
|
||||
hits = 0
|
||||
deb_errors = []
|
||||
signed_errors = [] # 有正负的误差 (DEB - 实测)
|
||||
model_errors = {}
|
||||
|
||||
for date_str in sorted(city_data.keys()):
|
||||
record = city_data[date_str]
|
||||
actual = record.get("actual_high")
|
||||
deb_pred = record.get("deb_prediction")
|
||||
forecasts = record.get("forecasts", {})
|
||||
|
||||
if actual is None:
|
||||
continue
|
||||
try:
|
||||
actual = float(actual)
|
||||
if deb_pred is not None:
|
||||
deb_pred = float(deb_pred)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# 如果没有存 DEB 预测值,用当天各模型平均值回算
|
||||
if deb_pred is None and forecasts:
|
||||
valid_preds = [
|
||||
float(v) for v in forecasts.values() if v is not None
|
||||
]
|
||||
if valid_preds:
|
||||
deb_pred = round(sum(valid_preds) / len(valid_preds), 1)
|
||||
|
||||
actual_wu = round(actual)
|
||||
|
||||
# DEB 命中判断
|
||||
if deb_pred is not None and date_str != today_str:
|
||||
total_days += 1
|
||||
deb_wu = round(deb_pred)
|
||||
hit = deb_wu == actual_wu
|
||||
if hit:
|
||||
hits += 1
|
||||
err = deb_pred - actual
|
||||
deb_errors.append(abs(err))
|
||||
signed_errors.append(err)
|
||||
icon = "✅" if hit else "❌"
|
||||
retro = "≈" if "deb_prediction" not in record else ""
|
||||
# 错误类型标签
|
||||
if not hit:
|
||||
err_label = (
|
||||
f" 低估{abs(err):.1f}°"
|
||||
if err < 0
|
||||
else f" 高估{abs(err):.1f}°"
|
||||
)
|
||||
else:
|
||||
err_label = f" 偏差{abs(err):.1f}°"
|
||||
mu_val = record.get("mu")
|
||||
mu_str = f" | μ: {mu_val}" if mu_val is not None else ""
|
||||
lines.append(
|
||||
f" {date_str}: DEB {retro}{deb_pred}→<b>{deb_wu}</b> vs 实测 {actual}→<b>{actual_wu}</b> {icon}{err_label}{mu_str}"
|
||||
)
|
||||
elif date_str == today_str:
|
||||
lines.append(f" {date_str}: 📍 今天进行中 (实测暂 {actual})")
|
||||
|
||||
# 各模型误差统计
|
||||
if date_str != today_str and actual is not None:
|
||||
for model, pred in forecasts.items():
|
||||
if pred is not None:
|
||||
if model not in model_errors:
|
||||
model_errors[model] = []
|
||||
model_errors[model].append(abs(float(pred) - actual))
|
||||
|
||||
# 汇总
|
||||
if total_days > 0:
|
||||
hit_rate = hits / total_days * 100
|
||||
deb_mae = sum(deb_errors) / len(deb_errors)
|
||||
lines.append(
|
||||
f"\n🎯 <b>DEB 总战绩</b>:WU命中 {hits}/{total_days} (<b>{hit_rate:.0f}%</b>) | MAE: {deb_mae:.1f}°"
|
||||
)
|
||||
|
||||
# --- 概率引擎 μ 的战绩 ---
|
||||
from src.analysis.deb_algorithm import get_mu_accuracy
|
||||
mu_acc = get_mu_accuracy(city_name)
|
||||
if mu_acc:
|
||||
mu_mae, mu_hr, avg_brier, mu_total, _ = mu_acc
|
||||
lines.append(
|
||||
f"🎲 <b>概率引擎 (μ)</b>:WU命中 <b>{mu_hr:.0f}%</b> | MAE: {mu_mae:.1f}°"
|
||||
)
|
||||
if avg_brier is not None:
|
||||
# Brier Score 范围是 0 (完美) 到 2 (全错)
|
||||
bs_eval = "极佳" if avg_brier < 0.2 else ("良好" if avg_brier < 0.4 else "需校准")
|
||||
lines.append(f" ▪ Brier评分: {avg_brier:.3f} ({bs_eval})")
|
||||
|
||||
|
||||
# 和各模型 MAE 对比
|
||||
if model_errors:
|
||||
lines.append("\n📈 <b>模型 MAE 对比</b>:")
|
||||
model_maes = {
|
||||
m: sum(e) / len(e) for m, e in model_errors.items() if e
|
||||
}
|
||||
sorted_models = sorted(model_maes.items(), key=lambda x: x[1])
|
||||
for m, mae in sorted_models:
|
||||
tag = " ⭐" if mae <= deb_mae else ""
|
||||
lines.append(f" {m}: {mae:.1f}°{tag}")
|
||||
lines.append(f" <b>DEB融合: {deb_mae:.1f}°</b>")
|
||||
|
||||
# 偏差模式分析
|
||||
mean_bias = sum(signed_errors) / len(signed_errors)
|
||||
underest = sum(1 for e in signed_errors if e < -0.3)
|
||||
overest = sum(1 for e in signed_errors if e > 0.3)
|
||||
|
||||
lines.append("\n🔍 <b>偏差分析</b>:")
|
||||
if abs(mean_bias) > 0.3:
|
||||
bias_dir = "低估" if mean_bias < 0 else "高估"
|
||||
lines.append(f" ⚠️ 系统性{bias_dir}:平均偏差 {mean_bias:+.1f}°")
|
||||
else:
|
||||
lines.append(f" ✅ 无明显系统偏差(平均 {mean_bias:+.1f}°)")
|
||||
lines.append(
|
||||
f" 低估 {underest} 次 | 高估 {overest} 次 | 准确 {total_days - underest - overest} 次"
|
||||
)
|
||||
|
||||
# 可操作建议
|
||||
lines.append("\n💡 <b>建议</b>:")
|
||||
if underest > overest and abs(mean_bias) > 0.5:
|
||||
lines.append(
|
||||
f" 该城市模型集体低估趋势明显({mean_bias:+.1f}°),实际最高温可能比 DEB 融合值高 {abs(mean_bias):.0f}-{abs(mean_bias) + 0.5:.0f}°。交易时建议适当看高。"
|
||||
)
|
||||
elif overest > underest and abs(mean_bias) > 0.5:
|
||||
lines.append(
|
||||
f" 该城市模型集体高估趋势明显({mean_bias:+.1f}°),实际最高温可能比 DEB 融合值低。交易时建议适当看低。"
|
||||
)
|
||||
elif deb_mae > 1.5:
|
||||
lines.append(
|
||||
f" 该城市预报波动大 (MAE {deb_mae:.1f}°),建议观望或轻仓。"
|
||||
)
|
||||
elif hit_rate >= 60:
|
||||
lines.append(" DEB 表现良好,可作为主要参考。")
|
||||
else:
|
||||
lines.append(" 数据积累中,建议结合 AI 分析综合判断。")
|
||||
|
||||
lines.append("\n📝 MAE = 平均绝对误差,越小越准。⭐ = 优于 DEB 融合。")
|
||||
else:
|
||||
lines.append("\n⏳ 尚无完整的 DEB 预测记录,明天起开始统计。")
|
||||
|
||||
lines.append(f"\n💳 本次消耗 <code>{DEB_QUERY_COST}</code> 积分。")
|
||||
bot.reply_to(message, "\n".join(lines), parse_mode="HTML")
|
||||
except Exception as e:
|
||||
bot.reply_to(message, f"❌ 查询失败: {e}")
|
||||
|
||||
@bot.message_handler(commands=["city"])
|
||||
def get_city_info(message):
|
||||
"""查询指定城市的天气详情"""
|
||||
try:
|
||||
parts = message.text.split(maxsplit=1)
|
||||
if len(parts) < 2:
|
||||
bot.reply_to(
|
||||
message,
|
||||
"❓ 请输入城市名称\n\n用法: <code>/city chicago</code>",
|
||||
parse_mode="HTML",
|
||||
)
|
||||
return
|
||||
|
||||
from src.data_collection.city_registry import ALIASES, CITY_REGISTRY
|
||||
city_input = parts[1].strip().lower()
|
||||
|
||||
# --- 使用统一注册表解析城市 ---
|
||||
SUPPORTED_CITIES = list(CITY_REGISTRY.keys())
|
||||
|
||||
# 1. 第一优先级:全称或别名完全匹配
|
||||
city_name = ALIASES.get(city_input)
|
||||
if not city_name and city_input in SUPPORTED_CITIES:
|
||||
city_name = city_input
|
||||
|
||||
# 2. 第二优先级:前缀模糊匹配
|
||||
if not city_name and len(city_input) >= 2:
|
||||
# 搜别名
|
||||
for k, v in ALIASES.items():
|
||||
if k.startswith(city_input):
|
||||
city_name = v
|
||||
break
|
||||
# 搜城市全名
|
||||
if not city_name:
|
||||
for full_name in SUPPORTED_CITIES:
|
||||
if full_name.startswith(city_input):
|
||||
city_name = full_name
|
||||
break
|
||||
|
||||
# 3. 未找到 → 报错
|
||||
if not city_name:
|
||||
city_list = ", ".join(sorted(SUPPORTED_CITIES))
|
||||
bot.reply_to(
|
||||
message,
|
||||
f"❌ 未找到城市: <b>{city_input}</b>\n\n"
|
||||
f"支持的城市: {city_list}",
|
||||
parse_mode="HTML",
|
||||
)
|
||||
return
|
||||
|
||||
if not _ensure_query_points(message, CITY_QUERY_COST, "/city"):
|
||||
return
|
||||
|
||||
bot.send_message(
|
||||
message.chat.id, f"🔍 正在查询 {city_name.title()} 的天气数据..."
|
||||
)
|
||||
|
||||
coords = weather.get_coordinates(city_name)
|
||||
if not coords:
|
||||
bot.reply_to(message, f"❌ 未找到城市坐标: {city_name}")
|
||||
return
|
||||
|
||||
weather_data = weather.fetch_all_sources(
|
||||
city_name, lat=coords["lat"], lon=coords["lon"]
|
||||
)
|
||||
open_meteo = weather_data.get("open-meteo", {})
|
||||
metar = weather_data.get("metar", {})
|
||||
mgm = weather_data.get("mgm") or {}
|
||||
|
||||
# 数值归一化
|
||||
def _sf(v):
|
||||
if v is None:
|
||||
return None
|
||||
try:
|
||||
return float(v)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
temp_unit = open_meteo.get("unit", "celsius")
|
||||
temp_symbol = "°F" if temp_unit == "fahrenheit" else "°C"
|
||||
|
||||
# --- 1. 紧凑 Header (城市 + 时间 + 风险状态) ---
|
||||
local_time = open_meteo.get("current", {}).get("local_time", "")
|
||||
time_str = local_time.split(" ")[1][:5] if " " in local_time else "N/A"
|
||||
|
||||
risk_profile = get_city_risk_profile(city_name)
|
||||
risk_emoji = risk_profile.get("risk_level", "⚪") if risk_profile else "⚪"
|
||||
|
||||
msg_header = f"📍 <b>{city_name.title()}</b> ({time_str}) {risk_emoji}"
|
||||
msg_lines = [msg_header]
|
||||
|
||||
# --- 2. 紧凑 风险提示 ---
|
||||
if risk_profile:
|
||||
bias = risk_profile.get("bias", "±0.0")
|
||||
msg_lines.append(
|
||||
f"⚠️ {risk_profile.get('airport_name', '')}: {bias}{temp_symbol} | {risk_profile.get('warning', '')}"
|
||||
)
|
||||
|
||||
# --- 3. 紧凑 预测区 ---
|
||||
daily = open_meteo.get("daily", {})
|
||||
dates = daily.get("time", [])[:3]
|
||||
max_temps = daily.get("temperature_2m_max", [])[:3]
|
||||
|
||||
nws_high = _sf(weather_data.get("nws", {}).get("today_high"))
|
||||
mgm_high = _sf(mgm.get("today_high"))
|
||||
mb_high = _sf(weather_data.get("meteoblue", {}).get("today_high"))
|
||||
|
||||
# 今天对比
|
||||
today_t = max_temps[0] if max_temps else "N/A"
|
||||
comp_parts = []
|
||||
sources = ["Open-Meteo"]
|
||||
|
||||
if mb_high is not None:
|
||||
sources.append("MB")
|
||||
comp_parts.append(
|
||||
f"MB: {mb_high:.1f}{temp_symbol}"
|
||||
if isinstance(mb_high, (int, float))
|
||||
else f"MB: {mb_high}"
|
||||
)
|
||||
if nws_high is not None:
|
||||
sources.append("NWS")
|
||||
comp_parts.append(
|
||||
f"NWS: {nws_high:.1f}{temp_symbol}"
|
||||
if isinstance(nws_high, (int, float))
|
||||
else f"NWS: {nws_high}"
|
||||
)
|
||||
if mgm_high is not None:
|
||||
sources.append("MGM")
|
||||
comp_parts.append(
|
||||
f"🇹🇷 MGM: {mgm_high:.1f}{temp_symbol}"
|
||||
if isinstance(mgm_high, (int, float))
|
||||
else f"🇹🇷 MGM: {mgm_high}"
|
||||
)
|
||||
|
||||
# 检查是否有显著分歧 (超过 5°F 或 2.5°C)
|
||||
divergence_warning = ""
|
||||
if mb_high is not None and max_temps:
|
||||
diff = abs(mb_high - (_sf(max_temps[0]) or 0))
|
||||
threshold = 5.0 if temp_unit == "fahrenheit" else 2.5
|
||||
if diff > threshold:
|
||||
divergence_warning = (
|
||||
f" ⚠️ <b>模型显著分歧 ({diff:.1f}{temp_symbol})</b>"
|
||||
)
|
||||
|
||||
comp_str = f" ({' | '.join(comp_parts)})" if comp_parts else ""
|
||||
sources_str = " | ".join(sources)
|
||||
|
||||
msg_lines.append(f"\n📊 <b>预报 ({sources_str})</b>")
|
||||
msg_lines.append(
|
||||
f"👉 <b>今天: {today_t}{temp_symbol}{comp_str}</b>{divergence_warning}"
|
||||
)
|
||||
|
||||
# 明后天
|
||||
if len(dates) > 1:
|
||||
future_forecasts = []
|
||||
mgm_daily = mgm.get("daily_forecasts", {}) or {}
|
||||
for d, t in zip(dates[1:], max_temps[1:]):
|
||||
# 检查 MGM 是否有该日期的预报
|
||||
mgm_f = mgm_daily.get(d)
|
||||
if mgm_f is not None:
|
||||
future_forecasts.append(
|
||||
f"{d[5:]}: {t}{temp_symbol} | 🇹🇷 <b>MGM: {mgm_f}{temp_symbol}</b>"
|
||||
)
|
||||
else:
|
||||
future_forecasts.append(f"{d[5:]}: {t}{temp_symbol}")
|
||||
msg_lines.append("📅 " + " | ".join(future_forecasts))
|
||||
|
||||
# --- 3.5 日出日落 + 日照时长 ---
|
||||
sunrises = daily.get("sunrise", [])
|
||||
sunsets = daily.get("sunset", [])
|
||||
sunshine_durations = daily.get("sunshine_duration", [])
|
||||
if sunrises and sunsets:
|
||||
sunrise_t = (
|
||||
sunrises[0].split("T")[1][:5]
|
||||
if "T" in str(sunrises[0])
|
||||
else sunrises[0]
|
||||
)
|
||||
sunset_t = (
|
||||
sunsets[0].split("T")[1][:5]
|
||||
if "T" in str(sunsets[0])
|
||||
else sunsets[0]
|
||||
)
|
||||
sun_line = f"🌅 日出 {sunrise_t} | 🌇 日落 {sunset_t}"
|
||||
if sunshine_durations:
|
||||
sunshine_hours = sunshine_durations[0] / 3600 # 秒 -> 小时
|
||||
sun_line += f" | ☀️ 日照 {sunshine_hours:.1f}h"
|
||||
msg_lines.append(sun_line)
|
||||
|
||||
# --- 4. 核心 实测区 (合并 METAR 和 MGM) ---
|
||||
# 基础数据优先用 METAR
|
||||
cur_temp = _sf(
|
||||
metar.get("current", {}).get("temp")
|
||||
if metar
|
||||
else mgm.get("current", {}).get("temp")
|
||||
)
|
||||
max_p = _sf(
|
||||
metar.get("current", {}).get("max_temp_so_far") if metar else None
|
||||
)
|
||||
max_p_time = (
|
||||
metar.get("current", {}).get("max_temp_time") if metar else None
|
||||
)
|
||||
obs_t_str = "N/A"
|
||||
metar_age_min = None # METAR 数据年龄(分钟)
|
||||
main_source = "METAR" if metar else "MGM"
|
||||
|
||||
if metar:
|
||||
obs_t = metar.get("observation_time", "")
|
||||
try:
|
||||
if "T" in obs_t:
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
dt = datetime.fromisoformat(obs_t.replace("Z", "+00:00"))
|
||||
utc_offset = open_meteo.get("utc_offset", 0)
|
||||
local_dt = dt.astimezone(
|
||||
timezone(timedelta(seconds=utc_offset))
|
||||
)
|
||||
obs_t_str = local_dt.strftime("%H:%M")
|
||||
# 计算数据年龄
|
||||
now_utc = datetime.now(timezone.utc)
|
||||
metar_age_min = int((now_utc - dt).total_seconds() / 60)
|
||||
elif " " in obs_t:
|
||||
obs_t_str = obs_t.split(" ")[1][:5]
|
||||
else:
|
||||
obs_t_str = obs_t
|
||||
except Exception:
|
||||
obs_t_str = obs_t[:16]
|
||||
elif mgm:
|
||||
m_time = mgm.get("current", {}).get("time", "")
|
||||
if "T" in m_time:
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
dt = datetime.fromisoformat(m_time.replace("Z", "+00:00"))
|
||||
m_time = dt.astimezone(timezone(timedelta(hours=3))).strftime(
|
||||
"%H:%M"
|
||||
)
|
||||
elif " " in m_time:
|
||||
m_time = m_time.split(" ")[1][:5]
|
||||
obs_t_str = m_time
|
||||
|
||||
# 数据年龄标注
|
||||
age_tag = ""
|
||||
if metar_age_min is not None:
|
||||
if metar_age_min >= 60:
|
||||
age_tag = f" ⚠️{metar_age_min}分钟前"
|
||||
elif metar_age_min >= 30:
|
||||
age_tag = f" ⏳{metar_age_min}分钟前"
|
||||
|
||||
max_str = ""
|
||||
if max_p is not None:
|
||||
import math
|
||||
|
||||
settled_val = math.floor(max_p + 0.5)
|
||||
max_str = f" (最高: {max_p}{temp_symbol}"
|
||||
if max_p_time:
|
||||
max_str += f" @{max_p_time}"
|
||||
max_str += f" → WU {settled_val}{temp_symbol})"
|
||||
|
||||
# --- 天气状况总结 ---
|
||||
wx_summary = ""
|
||||
# 优先使用 METAR 天气现象
|
||||
metar_wx = metar.get("current", {}).get("wx_desc", "") if metar else ""
|
||||
metar_clouds = metar.get("current", {}).get("clouds", []) if metar else []
|
||||
mgm_cloud = mgm.get("current", {}).get("cloud_cover") if mgm else None
|
||||
|
||||
if metar_wx:
|
||||
wx_upper = metar_wx.upper().strip()
|
||||
wx_tokens = set(wx_upper.split())
|
||||
rain_codes = {
|
||||
"RA",
|
||||
"DZ",
|
||||
"-RA",
|
||||
"+RA",
|
||||
"-DZ",
|
||||
"+DZ",
|
||||
"TSRA",
|
||||
"SHRA",
|
||||
"FZRA",
|
||||
}
|
||||
snow_codes = {"SN", "GR", "GS", "-SN", "+SN", "BLSN"}
|
||||
fog_codes = {"FG", "BR", "HZ", "FZFG"}
|
||||
ts_codes = {"TS", "TSRA"}
|
||||
if ts_codes & wx_tokens:
|
||||
wx_summary = "⛈️ 雷暴"
|
||||
elif {"+RA", "+SN"} & wx_tokens:
|
||||
wx_summary = "🌧️ 大雨" if "+RA" in wx_tokens else "❄️ 大雪"
|
||||
elif rain_codes & wx_tokens:
|
||||
wx_summary = (
|
||||
"🌧️ 小雨" if {"-RA", "-DZ", "DZ"} & wx_tokens else "🌧️ 下雨"
|
||||
)
|
||||
elif snow_codes & wx_tokens:
|
||||
wx_summary = "❄️ 下雪"
|
||||
elif fog_codes & wx_tokens:
|
||||
wx_summary = "🌫️ 雾/霾"
|
||||
|
||||
# 如果 METAR 没有特殊现象,用云量推断
|
||||
if not wx_summary:
|
||||
# 优先 METAR 云层,回退 MGM
|
||||
cover_code = ""
|
||||
if metar_clouds:
|
||||
cover_code = metar_clouds[-1].get("cover", "")
|
||||
|
||||
if cover_code in ("SKC", "CLR") or (
|
||||
cover_code == "" and mgm_cloud is not None and mgm_cloud <= 1
|
||||
):
|
||||
wx_summary = "☀️ 晴"
|
||||
elif cover_code == "FEW" or (
|
||||
cover_code == "" and mgm_cloud is not None and mgm_cloud <= 2
|
||||
):
|
||||
wx_summary = "🌤️ 晴间少云"
|
||||
elif cover_code == "SCT" or (
|
||||
cover_code == "" and mgm_cloud is not None and mgm_cloud <= 4
|
||||
):
|
||||
wx_summary = "⛅ 晴间多云"
|
||||
elif cover_code == "BKN" or (
|
||||
cover_code == "" and mgm_cloud is not None and mgm_cloud <= 6
|
||||
):
|
||||
wx_summary = "🌥️ 多云"
|
||||
elif cover_code == "OVC" or (
|
||||
cover_code == "" and mgm_cloud is not None and mgm_cloud <= 8
|
||||
):
|
||||
wx_summary = "☁️ 阴天"
|
||||
elif mgm_cloud is not None:
|
||||
cloud_names = {
|
||||
0: "☀️ 晴",
|
||||
1: "🌤️ 晴",
|
||||
2: "🌤️ 少云",
|
||||
3: "⛅ 散云",
|
||||
4: "⛅ 散云",
|
||||
5: "🌥️ 多云",
|
||||
6: "🌥️ 多云",
|
||||
7: "☁️ 阴",
|
||||
8: "☁️ 阴天",
|
||||
}
|
||||
wx_summary = cloud_names.get(mgm_cloud, "")
|
||||
|
||||
wx_display = f" {wx_summary}" if wx_summary else ""
|
||||
msg_lines.append(
|
||||
f"\n✈️ <b>实测 ({main_source}): {cur_temp}{temp_symbol}</b>{max_str} |{wx_display} | {obs_t_str}{age_tag}"
|
||||
)
|
||||
|
||||
if mgm:
|
||||
m_c = mgm.get("current", {})
|
||||
# 翻译风向
|
||||
wind_dir = m_c.get("wind_dir")
|
||||
wind_speed_ms = m_c.get("wind_speed_ms")
|
||||
dir_str = ""
|
||||
if wind_dir is not None:
|
||||
dirs = ["北", "东北", "东", "东南", "南", "西南", "西", "西北"]
|
||||
dir_str = dirs[int((float(wind_dir) + 22.5) % 360 / 45)] + "风 "
|
||||
|
||||
# 体感和湿度(跳过缺失数据)
|
||||
feels_like = m_c.get("feels_like")
|
||||
humidity = m_c.get("humidity")
|
||||
if feels_like is not None or humidity is not None:
|
||||
parts = []
|
||||
if feels_like is not None:
|
||||
parts.append(f"🌡️ 体感: {feels_like}°C")
|
||||
|
||||
# 针对安卡拉,补充市区(Center)实测值
|
||||
ankara_center = next((s for s in weather_data.get("mgm_nearby", []) if "Bölge/Center" in s.get("name", "")), None)
|
||||
if ankara_center:
|
||||
parts.append(f"Ankara (Bölge/Center): <b>{ankara_center['temp']}°C</b>")
|
||||
|
||||
if humidity is not None:
|
||||
parts.append(f"💧 {humidity}%")
|
||||
msg_lines.append(f" [MGM] {' | '.join(parts)}")
|
||||
|
||||
# 风况(跳过缺失数据)
|
||||
if wind_dir is not None and wind_speed_ms is not None:
|
||||
msg_lines.append(
|
||||
f" [MGM] 🌬️ {dir_str}{wind_dir}° ({wind_speed_ms} m/s) | 💧 降水: {m_c.get('rain_24h') or 0}mm"
|
||||
)
|
||||
|
||||
# 新增:气压和云量
|
||||
extra_parts = []
|
||||
pressure = m_c.get("pressure")
|
||||
if pressure is not None:
|
||||
extra_parts.append(f"🌡 气压: {pressure}hPa")
|
||||
cloud_cover = m_c.get("cloud_cover")
|
||||
if cloud_cover is not None:
|
||||
cloud_desc_map = {
|
||||
0: "晴朗",
|
||||
1: "少云",
|
||||
2: "少云",
|
||||
3: "散云",
|
||||
4: "散云",
|
||||
5: "多云",
|
||||
6: "多云",
|
||||
7: "很多云",
|
||||
8: "阴天",
|
||||
}
|
||||
cloud_text = cloud_desc_map.get(cloud_cover, f"{cloud_cover}/8")
|
||||
extra_parts.append(f"☁️ 云量: {cloud_text}({cloud_cover}/8)")
|
||||
mgm_max = m_c.get("mgm_max_temp")
|
||||
if mgm_max is not None:
|
||||
extra_parts.append(f"🌡️ MGM最高: {mgm_max}°C")
|
||||
if extra_parts:
|
||||
msg_lines.append(f" [MGM] {' | '.join(extra_parts)}")
|
||||
|
||||
if metar:
|
||||
m_c = metar.get("current", {})
|
||||
wind = m_c.get("wind_speed_kt")
|
||||
wind_dir = m_c.get("wind_dir")
|
||||
vis = m_c.get("visibility_mi")
|
||||
clouds = m_c.get("clouds", [])
|
||||
|
||||
cloud_desc = ""
|
||||
if clouds:
|
||||
c_map = {
|
||||
"BKN": "多云",
|
||||
"OVC": "阴天",
|
||||
"FEW": "少云",
|
||||
"SCT": "散云",
|
||||
"SKC": "晴",
|
||||
"CLR": "晴",
|
||||
}
|
||||
main = clouds[-1]
|
||||
cloud_desc = f"☁️ {c_map.get(main.get('cover'), main.get('cover'))}"
|
||||
|
||||
prefix = "[METAR]" if mgm else " "
|
||||
if not mgm:
|
||||
msg_lines.append(
|
||||
f" {prefix} 💨 {wind or 0}kt ({wind_dir or 0}°) | 👁️ {vis or 10}mi"
|
||||
)
|
||||
|
||||
if cloud_desc:
|
||||
msg_lines.append(
|
||||
f" {prefix} {cloud_desc} | 👁️ {vis or 10}mi | 💨 {wind or 0}kt"
|
||||
)
|
||||
|
||||
# --- 5. 态势特征提取 ---
|
||||
feature_str, ai_context = analyze_weather_trend(
|
||||
weather_data, temp_symbol, city_name
|
||||
)
|
||||
if feature_str:
|
||||
# 仅将最核心的信息展示给用户作为"态势分析"
|
||||
# 但后面会把更全的数据传给 AI
|
||||
msg_lines.append("\n💡 <b>分析</b>:")
|
||||
for line in feature_str.split("\n"):
|
||||
if line.strip():
|
||||
msg_lines.append(f"- {line.strip()}")
|
||||
|
||||
# --- 6. Groq AI 深度分析 ---
|
||||
try:
|
||||
from src.analysis.ai_analyzer import get_ai_analysis
|
||||
# 构建更全的背景数据给 AI
|
||||
|
||||
# 补充多模型分歧
|
||||
mm = weather_data.get("multi_model", {})
|
||||
if mm.get("forecasts"):
|
||||
mm_str = " | ".join(
|
||||
[
|
||||
f"{k}:{v}{temp_symbol}"
|
||||
for k, v in mm["forecasts"].items()
|
||||
if v
|
||||
]
|
||||
)
|
||||
ai_context += f"\n模型分歧: {mm_str}"
|
||||
|
||||
ai_result = get_ai_analysis(ai_context, city_name, temp_symbol)
|
||||
if ai_result:
|
||||
msg_lines.append(f"\n{ai_result}")
|
||||
except Exception as e:
|
||||
logger.error(f"调用 Groq AI 分析失败: {e}")
|
||||
|
||||
msg_lines.append(f"\n💳 本次消耗 <b>{CITY_QUERY_COST}</b> 积分。")
|
||||
bot.send_message(message.chat.id, "\n".join(msg_lines), parse_mode="HTML")
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
|
||||
logger.error(f"查询失败: {e}\n{traceback.format_exc()}")
|
||||
bot.reply_to(message, f"❌ 查询失败: {e}")
|
||||
|
||||
@bot.message_handler(func=lambda message: True, content_types=['text'])
|
||||
def track_activity(message):
|
||||
"""全量监听消息,用于记录群内发言积分(非指令消息)"""
|
||||
if message.text.startswith('/'):
|
||||
return
|
||||
if message.chat.type not in ("group", "supergroup"):
|
||||
return
|
||||
|
||||
user = message.from_user
|
||||
username = _display_name(user)
|
||||
db.upsert_user(user.id, username)
|
||||
|
||||
result = db.add_message_activity(
|
||||
user.id,
|
||||
text=message.text,
|
||||
points_to_add=MESSAGE_POINTS,
|
||||
cooldown_sec=MESSAGE_COOLDOWN_SEC,
|
||||
daily_cap=MESSAGE_DAILY_CAP,
|
||||
min_text_length=MESSAGE_MIN_LENGTH,
|
||||
)
|
||||
if result.get("awarded"):
|
||||
logger.info(
|
||||
f"message points awarded user={user.id} points=+{MESSAGE_POINTS} "
|
||||
f"daily_points={result.get('daily_points')}/{MESSAGE_DAILY_CAP}"
|
||||
)
|
||||
|
||||
logger.info("🤖 Bot 启动中...")
|
||||
bot.infinity_polling()
|
||||
from src.bot.orchestrator import start_bot # noqa: E402
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -1,53 +1,97 @@
|
||||
# Weather API Configuration
|
||||
weather:
|
||||
meteoblue_api_key: null # Set via METEOBLUE_API_KEY env var
|
||||
timeout: 30
|
||||
|
||||
# Target Cities
|
||||
cities:
|
||||
- id: "london"
|
||||
city: "London"
|
||||
country: "UK"
|
||||
latitude: 51.5074
|
||||
longitude: -0.1278
|
||||
- id: "paris"
|
||||
city: "Paris"
|
||||
country: "France"
|
||||
latitude: 48.8566
|
||||
longitude: 2.3522
|
||||
- id: "ankara"
|
||||
city: "Ankara"
|
||||
country: "Turkey"
|
||||
latitude: 39.9334
|
||||
longitude: 32.8597
|
||||
- id: "new_york"
|
||||
city: "New York"
|
||||
country: "USA"
|
||||
latitude: 40.7128
|
||||
longitude: -74.0060
|
||||
- id: "chicago"
|
||||
city: "Chicago"
|
||||
country: "USA"
|
||||
latitude: 41.8781
|
||||
longitude: -87.6298
|
||||
- id: "lucknow"
|
||||
city: "Lucknow"
|
||||
country: "India"
|
||||
latitude: 26.7606
|
||||
longitude: 80.8893
|
||||
- id: "sao paulo"
|
||||
city: "São Paulo"
|
||||
country: "Brazil"
|
||||
latitude: -23.4356
|
||||
longitude: -46.4731
|
||||
- id: "munich"
|
||||
city: "Munich"
|
||||
country: "Germany"
|
||||
latitude: 48.3538
|
||||
longitude: 11.7861
|
||||
|
||||
# Logging
|
||||
- id: london
|
||||
city: London
|
||||
country: UK
|
||||
latitude: 51.5074
|
||||
longitude: -0.1278
|
||||
- id: paris
|
||||
city: Paris
|
||||
country: France
|
||||
latitude: 48.8566
|
||||
longitude: 2.3522
|
||||
- id: ankara
|
||||
city: Ankara
|
||||
country: Turkey
|
||||
latitude: 39.9334
|
||||
longitude: 32.8597
|
||||
- id: new_york
|
||||
city: New York
|
||||
country: USA
|
||||
latitude: 40.7128
|
||||
longitude: -74.006
|
||||
- id: chicago
|
||||
city: Chicago
|
||||
country: USA
|
||||
latitude: 41.8781
|
||||
longitude: -87.6298
|
||||
- id: lucknow
|
||||
city: Lucknow
|
||||
country: India
|
||||
latitude: 26.7606
|
||||
longitude: 80.8893
|
||||
- id: sao paulo
|
||||
city: São Paulo
|
||||
country: Brazil
|
||||
latitude: -23.4356
|
||||
longitude: -46.4731
|
||||
- id: munich
|
||||
city: Munich
|
||||
country: Germany
|
||||
latitude: 48.3538
|
||||
longitude: 11.7861
|
||||
- id: hong_kong
|
||||
city: Hong Kong
|
||||
country: China
|
||||
latitude: 22.3019
|
||||
longitude: 114.1742
|
||||
- id: shanghai
|
||||
city: Shanghai
|
||||
country: China
|
||||
latitude: 31.1434
|
||||
longitude: 121.8052
|
||||
- id: singapore
|
||||
city: Singapore
|
||||
country: Singapore
|
||||
latitude: 1.3644
|
||||
longitude: 103.9915
|
||||
- id: tokyo
|
||||
city: Tokyo
|
||||
country: Japan
|
||||
latitude: 35.5523
|
||||
longitude: 139.7798
|
||||
- id: tel_aviv
|
||||
city: Tel Aviv
|
||||
country: Israel
|
||||
latitude: 32.0114
|
||||
longitude: 34.8867
|
||||
- id: chengdu
|
||||
city: Chengdu
|
||||
country: China
|
||||
latitude: 30.5785
|
||||
longitude: 103.9471
|
||||
- id: chongqing
|
||||
city: Chongqing
|
||||
country: China
|
||||
latitude: 29.7196
|
||||
longitude: 106.6416
|
||||
- id: shenzhen
|
||||
city: Shenzhen
|
||||
country: China
|
||||
latitude: 22.6393
|
||||
longitude: 113.8107
|
||||
- id: beijing
|
||||
city: Beijing
|
||||
country: China
|
||||
latitude: 40.0801
|
||||
longitude: 116.5846
|
||||
- id: wuhan
|
||||
city: Wuhan
|
||||
country: China
|
||||
latitude: 30.7838
|
||||
longitude: 114.2081
|
||||
logging:
|
||||
level: "INFO"
|
||||
rotation: "10 MB"
|
||||
retention: "10 days"
|
||||
level: INFO
|
||||
rotation: 10 MB
|
||||
retention: 10 days
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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}"
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
# PolyWeather API 文档(v1.4.0)
|
||||
|
||||
最后更新:`2026-03-14`
|
||||
|
||||
本文档描述当前对外可用 API 口径(`web/app.py` + `frontend/app/api/*`)。
|
||||
|
||||
## 1. 基础信息
|
||||
|
||||
- 后端直连:`http://127.0.0.1:8000`
|
||||
- 前端 BFF:`https://polyweather-pro.vercel.app/api/*`
|
||||
- 返回格式:`application/json`
|
||||
|
||||
## 2. 请求链路
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
FE["Browser / Dashboard"] --> BFF["Next.js Route Handlers (/api/*)"]
|
||||
BFF --> API["FastAPI (/web/app.py)"]
|
||||
API --> WX["Weather Collector"]
|
||||
API --> ANA["DEB + Trend + Probability + Market Scan"]
|
||||
API --> PAY["Payment Intent + Event + Confirm Loops"]
|
||||
```
|
||||
|
||||
## 3. 天气分析接口
|
||||
|
||||
| 接口 | 方法 | 用途 |
|
||||
| :-- | :-- | :-- |
|
||||
| `/api/cities` | GET | 监控城市列表 |
|
||||
| `/api/city/{name}` | GET | 城市主分析 |
|
||||
| `/api/city/{name}/summary` | GET | 轻量摘要 |
|
||||
| `/api/city/{name}/detail` | GET | 聚合详情(含 market_scan) |
|
||||
| `/api/history/{name}` | GET | 历史对账 |
|
||||
|
||||
### `GET /api/city/{name}/detail`
|
||||
|
||||
可选参数:
|
||||
|
||||
- `force_refresh=true|false`
|
||||
- `market_slug=<slug>`
|
||||
- `target_date=YYYY-MM-DD`
|
||||
|
||||
重点字段:
|
||||
|
||||
- `market_scan.available`
|
||||
- `market_scan.signal_label`
|
||||
- `market_scan.anchor_model / anchor_high / anchor_settlement`
|
||||
- `market_scan.yes_buy / no_buy`
|
||||
- `market_scan.primary_market.tradable`
|
||||
|
||||
## 4. 鉴权与账户接口
|
||||
|
||||
| 接口 | 方法 | 用途 |
|
||||
| :-- | :-- | :-- |
|
||||
| `/api/auth/me` | GET | 当前登录态、积分、订阅状态 |
|
||||
|
||||
`/api/auth/me` 关键字段:
|
||||
|
||||
- `authenticated`
|
||||
- `user_id`, `email`
|
||||
- `points`, `weekly_points`, `weekly_rank`
|
||||
- `subscription_active`, `subscription_plan_code`, `subscription_expires_at`
|
||||
|
||||
## 5. 支付接口(P1)
|
||||
|
||||
| 接口 | 方法 | 用途 |
|
||||
| :-- | :-- | :-- |
|
||||
| `/api/payments/config` | GET | 支付配置、代币列表、套餐、积分抵扣规则 |
|
||||
| `/api/payments/wallets` | GET | 当前用户已绑定钱包 |
|
||||
| `/api/payments/wallets/challenge` | POST | 获取绑定签名 challenge |
|
||||
| `/api/payments/wallets/verify` | POST | 提交签名并绑定钱包 |
|
||||
| `/api/payments/intents` | POST | 创建支付意图(intent) |
|
||||
| `/api/payments/intents/{intent_id}` | GET | 查询 intent 最新状态 |
|
||||
| `/api/payments/intents/{intent_id}/submit` | POST | 提交交易哈希 |
|
||||
| `/api/payments/intents/{intent_id}/confirm` | POST | 手动触发确认 |
|
||||
|
||||
### 支付状态建议
|
||||
|
||||
前端流程建议:
|
||||
|
||||
1. `POST /intents`
|
||||
2. 钱包发链上交易
|
||||
3. `POST /submit`
|
||||
4. `POST /confirm`
|
||||
5. 若 pending,轮询 `GET /intents/{id}` 直到 `confirmed`
|
||||
|
||||
## 6. 缓存策略(当前)
|
||||
|
||||
- `cities` / `summary` / `history`:BFF 支持 `ETag + 304`
|
||||
- `summary?force_refresh=true`:`Cache-Control: no-store`
|
||||
- 详情接口与支付接口:`no-store`
|
||||
|
||||
## 7. 调试示例
|
||||
|
||||
### 查询未来日期 market_scan
|
||||
|
||||
```bash
|
||||
curl -s "http://127.0.0.1:8000/api/city/ankara/detail?force_refresh=true&target_date=2026-03-12"
|
||||
```
|
||||
|
||||
### 校验支付配置
|
||||
|
||||
```bash
|
||||
curl -s http://127.0.0.1:8000/api/payments/config | python3 -m json.tool
|
||||
```
|
||||
|
||||
### 观察支付自动补单
|
||||
|
||||
```bash
|
||||
docker compose logs -f polyweather | egrep "payment event loop started|payment confirm loop started|payment auto-confirmed"
|
||||
```
|
||||
|
||||
## 8. 开源口径说明
|
||||
|
||||
对外公开文档仅覆盖通用 API 契约。生产商业策略参数不在公开文档披露。
|
||||
|
||||
详见:[Open-Core 与商用边界](OPEN_CORE_POLICY.md)
|
||||
@@ -1,79 +1,66 @@
|
||||
# 📈 Commercialization Roadmap
|
||||
# 商业化说明(Production)
|
||||
|
||||
> **Target**: Transforming PolyWeather for paid weather intelligence delivery.
|
||||
最后更新:`2026-03-14`
|
||||
|
||||
---
|
||||
## 1. 定位
|
||||
|
||||
## 🎯 Product Focus
|
||||
PolyWeather 是面向温度结算场景的气象决策层,不是通用天气应用。
|
||||
|
||||
PolyWeather is positioned as a **premium intelligence service** for weather-based prediction markets (**Polymarket**). The value proposition lies in **Ankara-specialization**, **advanced advection forecasting**, and **DEB-weighted consensus**.
|
||||
核心价值:
|
||||
|
||||
---
|
||||
- 观测优先(METAR/MGM)
|
||||
- 结算导向(DEB + 概率桶)
|
||||
- 市场映射(行情对照 + 错价雷达)
|
||||
|
||||
## 💰 Pricing & Monetization
|
||||
## 2. 当前收费能力状态
|
||||
|
||||
| Tier | Price | Primary Value Proposition |
|
||||
| :------------------- | :------------ | :------------------------------------------------------------ |
|
||||
| **Telegram Channel** | **$1 / mo** | High-fidelity proactive alerts, low noise. |
|
||||
| **Web Dashboard** | **$5 / mo** | Comprehensive multi-model view + historical MAE benchmarking. |
|
||||
| **VIP Bundle** | **$5.5 / mo** | Full access to all intelligence streams. |
|
||||
| 能力 | 状态 | 备注 |
|
||||
| :-- | :-- | :-- |
|
||||
| 登录注册(Google + 邮箱) | 已上线 | Supabase 鉴权 |
|
||||
| 订阅套餐(Pro 月付) | 已上线 | `5 USDC / 30天` |
|
||||
| 积分抵扣 | 已上线 | `500分=1U`,最多 `3U` |
|
||||
| 合约支付 | 已上线 | Polygon,USDC + USDC.e |
|
||||
| 支付自动确认 | 已上线 | Event Loop + Confirm Loop |
|
||||
| 钱包绑定 | 已上线 | 浏览器钱包 + WalletConnect |
|
||||
| 私有频道推送 | 已上线 | 可拆分业务频道 |
|
||||
|
||||
### 🛠️ Payment Infrastructure
|
||||
## 3. 权限模型(当前)
|
||||
|
||||
- **Currency**: Polygon / USDC.
|
||||
- **Method**: Initially manual activation; migrating to automatic deposit detection (Phase 2).
|
||||
- 游客:可查看基础看板与简版信息。
|
||||
- 登录用户:账户中心、钱包绑定、积分同步。
|
||||
- Pro 用户:
|
||||
- 今日日内深度分析(含高温时段)
|
||||
- 历史对账 + 未来日期分析
|
||||
- 全平台智能气象推送
|
||||
|
||||
---
|
||||
## 4. 收费与积分规则(默认)
|
||||
|
||||
## 🗺️ Execution Roadmap
|
||||
- 套餐:`pro_monthly`(5 USDC / 30 天)
|
||||
- 抵扣:500 积分抵 1 USDC,最高抵 3 USDC
|
||||
- 实付下限:2 USDC(当积分满额时)
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
P1[Phase 1: Manual Beta] --> P2[Phase 2: USDC Automation]
|
||||
P2 --> P3[Phase 3: Scaling & Analytics]
|
||||
> 说明:具体运营策略可按阶段调整,生产参数建议放私有仓库。
|
||||
|
||||
subgraph P1_Detail [Manual Operations]
|
||||
P1 -->|DM Bot| Pay[Manual Payment]
|
||||
Pay -->|Invite| Link[One-time Link]
|
||||
end
|
||||
## 5. 建议的开源边界
|
||||
|
||||
subgraph P2_Detail [Smart Automation]
|
||||
P2 -->|Monitor| Chain[Polygon/USDC]
|
||||
Chain -->|Auto| Access[JWT/Sub Activation]
|
||||
end
|
||||
```
|
||||
请按 Open-Core 执行:
|
||||
|
||||
### 📦 Phase 1: Manual Beta
|
||||
- 开源:基础能力与通用支付流程。
|
||||
- 私有:商业风控、营销策略、关键运营参数、内部审计策略。
|
||||
|
||||
- **Goal**: Stabilize current alert quality and build core user group.
|
||||
- **Actions**:
|
||||
- Manual subscription activation via Telegram DM.
|
||||
- Small, focused paid Telegram channel for signal tests.
|
||||
- Invitation-only Web Access (Vercel).
|
||||
详见:[Open-Core 与商用边界](OPEN_CORE_POLICY.md)
|
||||
|
||||
### 🛠️ Phase 2: Automation (USDC)
|
||||
## 6. 上线检查清单(收费前)
|
||||
|
||||
- **Goal**: Reduce operational friction.
|
||||
- **Actions**:
|
||||
- **On-chain monitoring**: Detect USDC deposits to unique addresses.
|
||||
- **One-time Links**: Telegram bot automatically generates invite links with `member_limit=1`.
|
||||
- **JWT Auth**: Securing the Next.js frontend with subscriber-only tokens.
|
||||
1. 支付链路:创建 intent、提交 tx、确认入账、订阅开通全链路可回放。
|
||||
2. 权限链路:前端/后端/Bot 对 Pro 权限判定一致。
|
||||
3. 审计能力:支付日志、订阅变更、异常重试可追溯。
|
||||
4. 通知策略:支付成功私发、群内通知降噪。
|
||||
5. 安全边界:敏感配置不进仓库。
|
||||
|
||||
### 🌐 Phase 3: Scaling & Analytics
|
||||
## 7. 后续路线
|
||||
|
||||
- **Goal**: Retention and expansion.
|
||||
- **Actions**:
|
||||
- **Accuracy Leaderboard**: Monthly reports of DEB vs Market outcomes.
|
||||
- **Self-Serve Portal**: User dashboard for billing and alert settings.
|
||||
|
||||
---
|
||||
|
||||
## 🚧 Critical Constraints
|
||||
|
||||
- **Weather-First**: We focus on the **physical variable changes** rather than exchange-side order book execution.
|
||||
- **Quality > Quantity**: Alert fatigue will churn subscribers. We enforce a "True Probability Shift" rule for notifications.
|
||||
- **Local Niche**: Ankara is our flagship differentiator.
|
||||
|
||||
---
|
||||
|
||||
**📅 Last Updated**: 2026-03-06
|
||||
- 支持更多链和稳定币。
|
||||
- 引入退款与工单后台。
|
||||
- 建立周/月留存与付费转化看板。
|
||||
- 打通渠道分销与邀请码返利系统。
|
||||
|
||||
@@ -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 与免责边界。
|
||||
@@ -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`。
|
||||
@@ -1,65 +1,73 @@
|
||||
# 🛠️ Technical Debt & Engineering Backlog
|
||||
# 技术债与工程待办(v1.4.0)
|
||||
|
||||
> **Vision**: Moving from a research script to a production SaaS.
|
||||
最后更新:`2026-03-14`
|
||||
|
||||
---
|
||||
目标:在收费上线后,优先保证支付可靠性、权限一致性和运营可追溯性。
|
||||
|
||||
## 🏛️ System Health: 75%
|
||||
## 1. 债务快照
|
||||
|
||||
当前估计:**93% 稳定 / 7% 技术债**。
|
||||
|
||||
```mermaid
|
||||
pie title System Health & Tech Debt
|
||||
"Stable Engine" : 75
|
||||
"Centralized Logic Debt" : 10
|
||||
"Subscription DB Debt" : 10
|
||||
"Testing/Replay Debt" : 5
|
||||
flowchart TD
|
||||
A["技术债"]
|
||||
|
||||
subgraph P["支付与订阅"]
|
||||
P1["异常交易自动重放策略"]
|
||||
P2["退款与工单流程"]
|
||||
P3["多链结算对账"]
|
||||
end
|
||||
|
||||
subgraph E["权限与运营"]
|
||||
E1["前后端/Bot 权限矩阵回归"]
|
||||
E2["积分与订阅冲突策略"]
|
||||
end
|
||||
|
||||
subgraph O["可观测性"]
|
||||
O1["支付失败原因分层指标"]
|
||||
O2["业务监控看板"]
|
||||
end
|
||||
|
||||
A --> P
|
||||
A --> E
|
||||
A --> O
|
||||
```
|
||||
|
||||
The core engine is stable, but several infrastructure "shortcut" decisions remain.
|
||||
## 2. 近期已关闭
|
||||
|
||||
### Current Stable Modules
|
||||
- P1 支付主链路已上线(intent -> submit -> confirm)。
|
||||
- 支付自动补单已上线(Event Loop + Confirm Loop)。
|
||||
- 钱包绑定支持浏览器钱包 + WalletConnect。
|
||||
- 账户中心与 Pro 权限展示链路打通。
|
||||
- 钱包异动支持独立频道路由。
|
||||
|
||||
- [x] Multi-source Weather Aggregation
|
||||
- [x] DEB Blending Algorithm
|
||||
- [x] Proactive Telegram Alert Engine
|
||||
- [x] Vercel Dashboard Infrastructure
|
||||
## 3. 高优先级技术债
|
||||
|
||||
---
|
||||
| 项目 | 影响 | 建议动作 |
|
||||
| :-- | :-- | :-- |
|
||||
| 支付异常重放策略标准化 | 偶发确认失败需人工介入 | 建立 tx hash 自动回放 + 降级路径 |
|
||||
| 退款与售后链路 | 商业闭环不完整 | 增加退款状态机与工单系统 |
|
||||
| 订阅审计可视化 | 排障效率受限 | 建立订阅事件时间线视图 |
|
||||
| 多邮箱绑定同 TG 账户策略 | 积分归属易混淆 | 引入主账号绑定策略与迁移工具 |
|
||||
|
||||
## 🔴 High Priority: Immediate Focus
|
||||
## 4. 中优先级技术债
|
||||
|
||||
| Debt Item | Impact | Suggested Fix |
|
||||
| :--------------------- | :-------------------------------------------------- | :--------------------------------------------------------------- |
|
||||
| **Monolithic Bot** | `bot_listener.py` is hard to test and evolve. | Isolate UI interaction from business logic into `src/analysis`. |
|
||||
| **Subscription Store** | No persistent record of who has paid. | Migrate from in-memory user checks to **Supabase/PostgreSQL**. |
|
||||
| **Alert Transparency** | Operators cannot easily audit "why" an alert fired. | Add an `Evidence` metadata block to all internal alert payloads. |
|
||||
| 项目 | 影响 | 建议动作 |
|
||||
| :-- | :-- | :-- |
|
||||
| 积分发放可解释性 | 用户理解成本高 | 输出积分来源明细(发言/签到/奖励) |
|
||||
| 支付失败文案标准化 | 转化率受影响 | 建立错误码 -> 文案映射表 |
|
||||
| 配置收敛 | 运维出错概率高 | 将支付/推送配置集中分组管理 |
|
||||
|
||||
---
|
||||
## 5. 低优先级技术债
|
||||
|
||||
## 🟡 Medium Priority: Quality of Life
|
||||
| 项目 | 影响 | 建议动作 |
|
||||
| :-- | :-- | :-- |
|
||||
| 前端离线缓存能力 | 非核心 | 评估 Service Worker + IndexedDB |
|
||||
| 冷启动波动 | 首屏抖动 | 热点城市预热 |
|
||||
|
||||
| Debt Item | Impact | Suggested Fix |
|
||||
| :------------------------ | :-------------------------------------------------- | :--------------------------------------------------------------------------- |
|
||||
| **Hard-coded Thresholds** | Modification requires code changes (e.g., 5s CD). | Extract all business constants into a structured `config.yaml`. |
|
||||
| **Simulation Harness** | No way to "replay" a rainy day to test alert logic. | Build a `ReplayEngine` using `data/daily_records.json`. |
|
||||
| **Backend Naming** | Artifacts of "market price" logic remain in naming. | Systematic refactor of variable names to reflect weather-intelligence focus. |
|
||||
## 6. 下阶段里程碑
|
||||
|
||||
---
|
||||
|
||||
## 🟢 Low Priority: Optimization
|
||||
|
||||
| Debt Item | Impact | Suggested Fix |
|
||||
| :------------------------- | :---------------------------------------------- | :------------------------------------------------------------- |
|
||||
| **Serverless Cold Starts** | Initial Vercel API calls can be slow. | Implement edge-cache or warming cron for major city endpoints. |
|
||||
| **Local SQLite Files** | Not compatible with Vercel's ephemeral storage. | Full transition to a remote DB (Supabase/Redis). |
|
||||
|
||||
---
|
||||
|
||||
## 🗓️ Next Milestones
|
||||
|
||||
1. **DB Integration**: Connect Supabase to `src/database/db_manager.py`.
|
||||
2. **Alert Transparency**: Append logic metrics (slope, lead delta) to push messages.
|
||||
3. **Authentication**: Secure `/api/city` on Vercel with subscription keys.
|
||||
|
||||
---
|
||||
|
||||
**📅 Last Updated**: 2026-03-06
|
||||
1. 完成支付异常自动重放与告警分层。
|
||||
2. 上线退款/售后后台最小版。
|
||||
3. 建立商业化运营看板(支付、续费、留存)。
|
||||
4. 完成权限矩阵自动化回归测试。
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
# 技术债与工程待办(v1.4.0)
|
||||
|
||||
最后更新:`2026-03-14`
|
||||
|
||||
目标:在收费上线后,优先保证支付可靠性、权限一致性和运营可追溯性。
|
||||
|
||||
## 1. 债务快照
|
||||
|
||||
当前估计:**93% 稳定 / 7% 技术债**。
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["技术债"]
|
||||
|
||||
subgraph P["支付与订阅"]
|
||||
P1["异常交易自动重放策略"]
|
||||
P2["退款与工单流程"]
|
||||
P3["多链结算对账"]
|
||||
end
|
||||
|
||||
subgraph E["权限与运营"]
|
||||
E1["前后端/Bot 权限矩阵回归"]
|
||||
E2["积分与订阅冲突策略"]
|
||||
end
|
||||
|
||||
subgraph O["可观测性"]
|
||||
O1["支付失败原因分层指标"]
|
||||
O2["业务监控看板"]
|
||||
end
|
||||
|
||||
A --> P
|
||||
A --> E
|
||||
A --> O
|
||||
```
|
||||
|
||||
## 2. 近期已关闭
|
||||
|
||||
- P1 支付主链路已上线(intent -> submit -> confirm)。
|
||||
- 支付自动补单已上线(Event Loop + Confirm Loop)。
|
||||
- 钱包绑定支持浏览器钱包 + WalletConnect。
|
||||
- 账户中心与 Pro 权限展示链路打通。
|
||||
- 钱包异动支持独立频道路由。
|
||||
|
||||
## 3. 高优先级技术债
|
||||
|
||||
| 项目 | 影响 | 建议动作 |
|
||||
| :-- | :-- | :-- |
|
||||
| 支付异常重放策略标准化 | 偶发确认失败需人工介入 | 建立 tx hash 自动回放 + 降级路径 |
|
||||
| 退款与售后链路 | 商业闭环不完整 | 增加退款状态机与工单系统 |
|
||||
| 订阅审计可视化 | 排障效率受限 | 建立订阅事件时间线视图 |
|
||||
| 多邮箱绑定同 TG 账户策略 | 积分归属易混淆 | 引入主账号绑定策略与迁移工具 |
|
||||
|
||||
## 4. 中优先级技术债
|
||||
|
||||
| 项目 | 影响 | 建议动作 |
|
||||
| :-- | :-- | :-- |
|
||||
| 积分发放可解释性 | 用户理解成本高 | 输出积分来源明细(发言/签到/奖励) |
|
||||
| 支付失败文案标准化 | 转化率受影响 | 建立错误码 -> 文案映射表 |
|
||||
| 配置收敛 | 运维出错概率高 | 将支付/推送配置集中分组管理 |
|
||||
|
||||
## 5. 低优先级技术债
|
||||
|
||||
| 项目 | 影响 | 建议动作 |
|
||||
| :-- | :-- | :-- |
|
||||
| 前端离线缓存能力 | 非核心 | 评估 Service Worker + IndexedDB |
|
||||
| 冷启动波动 | 首屏抖动 | 热点城市预热 |
|
||||
|
||||
## 6. 下阶段里程碑
|
||||
|
||||
1. 完成支付异常自动重放与告警分层。
|
||||
2. 上线退款/售后后台最小版。
|
||||
3. 建立商业化运营看板(支付、续费、留存)。
|
||||
4. 完成权限矩阵自动化回归测试。
|
||||
@@ -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 缺失;多处自定义缓存与状态文件并发一致性风险;以及对第三方 API(Open-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/趋势/概率/结算口径)**:
|
||||
DEB(Dynamic Error Balancing)基于过去 N 天模型误差(MAE)倒数加权,输出融合预报;同时维护 `daily_records.json` 做历史对账、命中率/MAE 统计,并支持基于 WU(Weather 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 形态)
|
||||

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