Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 022d4b8743 | |||
| 58e6404987 |
@@ -3,14 +3,7 @@
|
||||
"allow": [
|
||||
"Bash(npm --prefix \"/e/web/PolyWeather/frontend\" run build)",
|
||||
"mcp__Claude_Preview__preview_start",
|
||||
"Bash(git:*)",
|
||||
"Bash(python -c \"from web.services.city_payloads import build_city_detail_payload; print\\('OK'\\)\")",
|
||||
"Bash(python -m ruff check web/services/city_runtime.py web/services/city_api.py)",
|
||||
"Bash(python -m ruff check .)",
|
||||
"Bash(python -m pytest tests/ -q)",
|
||||
"Bash(python -m ruff check . --fix)",
|
||||
"Bash(ssh *)",
|
||||
"Bash(curl *)"
|
||||
"Bash(git:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+44
-11
@@ -40,20 +40,10 @@ TELEGRAM_QUERY_TOPIC_ID=
|
||||
TELEGRAM_QUERY_TOPIC_MAP=
|
||||
POLYWEATHER_BOT_GROUP_INVITE_URL=
|
||||
POLYWEATHER_APP_URL=https://polyweather-pro.vercel.app
|
||||
# Global Telegram auto-push copy: both, en, or zh. Module-specific vars can override it.
|
||||
TELEGRAM_PUSH_LANGUAGE=both
|
||||
# High-frequency airport push loop. Keep workers at 1 on shared VPS.
|
||||
# High-frequency airport push loop. Keep this at 1 on shared 1CPU VPS.
|
||||
TELEGRAM_AIRPORT_PUSH_ENABLED=true
|
||||
TELEGRAM_AIRPORT_PUSH_INTERVAL_SEC=60
|
||||
TELEGRAM_AIRPORT_PUSH_MAX_WORKERS=1
|
||||
# Optional airport-only override.
|
||||
TELEGRAM_AIRPORT_PUSH_LANGUAGE=both
|
||||
# Docker-only safety limits for the bot service.
|
||||
POLYWEATHER_BOT_CPUS=0.75
|
||||
POLYWEATHER_BOT_MEM_LIMIT=768m
|
||||
POLYWEATHER_BOT_MEMSWAP_LIMIT=1g
|
||||
POLYWEATHER_BOT_AIRPORT_PUSH_INTERVAL_SEC=180
|
||||
POLYWEATHER_BOT_AIRPORT_PUSH_MAX_WORKERS=1
|
||||
|
||||
########################################
|
||||
# 3) Weather + cache
|
||||
@@ -104,6 +94,7 @@ SUPABASE_HTTP_TIMEOUT_SEC=8
|
||||
SUPABASE_AUTH_CACHE_TTL_SEC=30
|
||||
SUPABASE_SUB_CACHE_TTL_SEC=60
|
||||
POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN=
|
||||
POLYWEATHER_SIGNUP_TRIAL_ENABLED=false
|
||||
POLYWEATHER_TELEGRAM_JOIN_INELIGIBLE_ACTION=decline
|
||||
|
||||
########################################
|
||||
@@ -138,6 +129,28 @@ KNMI_API_KEY=
|
||||
########################################
|
||||
# 8) Optional modules
|
||||
########################################
|
||||
|
||||
# Optional OpenAI-compatible market scan review for Pro users
|
||||
# Temporary default provider: MiMo via https://token-plan-cn.xiaomimimo.com/v1.
|
||||
POLYWEATHER_SCAN_AI_ENABLED=false
|
||||
POLYWEATHER_SCAN_AI_API_KEY=
|
||||
POLYWEATHER_SCAN_AI_PROVIDER=mimo
|
||||
POLYWEATHER_SCAN_AI_PROVIDER_LABEL=MiMo
|
||||
POLYWEATHER_SCAN_AI_BASE_URL=https://token-plan-cn.xiaomimimo.com/v1
|
||||
POLYWEATHER_SCAN_AI_MODEL=mimo-v2.5-pro
|
||||
POLYWEATHER_SCAN_CITY_AI_MODEL=mimo-v2.5-pro
|
||||
# Backward-compatible legacy DeepSeek variables are still read if the generic
|
||||
# POLYWEATHER_SCAN_AI_* variables are unset.
|
||||
# POLYWEATHER_DEEPSEEK_API_KEY=
|
||||
# POLYWEATHER_DEEPSEEK_BASE_URL=https://api.deepseek.com
|
||||
POLYWEATHER_SCAN_AI_TIMEOUT_SEC=18
|
||||
POLYWEATHER_SCAN_CITY_AI_TIMEOUT_SEC=30
|
||||
POLYWEATHER_SCAN_CITY_AI_RETRY_ON_STREAM_PARSE_ERROR=false
|
||||
POLYWEATHER_SCAN_AI_CACHE_TTL_SEC=1800
|
||||
POLYWEATHER_SCAN_AI_MAX_ROWS=40
|
||||
POLYWEATHER_SCAN_AI_MAX_TOKENS=3200
|
||||
POLYWEATHER_SCAN_CITY_AI_MAX_TOKENS=900
|
||||
POLYWEATHER_SCAN_AI_PROXY_TIMEOUT_MS=55000
|
||||
POLYWEATHER_CITY_SUMMARY_CACHE_TTL_SEC=1800
|
||||
POLYWEATHER_CITY_PANEL_CACHE_TTL_SEC=1800
|
||||
POLYWEATHER_CITY_NEARBY_CACHE_TTL_SEC=1800
|
||||
@@ -184,6 +197,26 @@ POLYWEATHER_PAYMENT_POINTS_PER_USDC=500
|
||||
POLYWEATHER_PAYMENT_POINTS_MAX_DISCOUNT_USDC=3
|
||||
POLYWEATHER_PAYMENT_ALLOWED_PLAN_CODES=pro_monthly
|
||||
POLYWEATHER_PAYMENT_PLAN_CATALOG_JSON=
|
||||
|
||||
# Polymarket market scan
|
||||
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=20
|
||||
POLYMARKET_MARKET_CACHE_TTL_SEC=60
|
||||
POLYMARKET_PRICE_CACHE_TTL_SEC=30
|
||||
# false = fetch CLOB book/depth for orderbook analysis; true = price-only, lighter but no book levels
|
||||
POLYMARKET_FAST_PRICE_ONLY=false
|
||||
POLYWEATHER_MARKET_SCAN_PAYLOAD_TTL_SEC=30
|
||||
POLYMARKET_WS_PRICE_ENABLED=false
|
||||
POLYMARKET_WS_MARKET_URL=wss://ws-subscriptions-clob.polymarket.com/ws/market
|
||||
POLYMARKET_WS_QUOTE_TTL_SEC=8
|
||||
POLYMARKET_WS_MAX_ASSETS=256
|
||||
POLYMARKET_WS_RECONNECT_DELAY_SEC=3
|
||||
POLYMARKET_DISCOVERY_PAGES=6
|
||||
POLYMARKET_DISCOVERY_LIMIT=200
|
||||
POLYMARKET_SIGNAL_MIN_LIQUIDITY=500
|
||||
POLYMARKET_SIGNAL_EDGE_PCT=2
|
||||
|
||||
# Polygon watcher
|
||||
|
||||
+12
-64
@@ -51,84 +51,32 @@ jobs:
|
||||
- name: Business state tests
|
||||
run: npm run test:business
|
||||
|
||||
build-and-push:
|
||||
needs: [python-quality, frontend-quality]
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
docker-build:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
image:
|
||||
- name: backend
|
||||
context: .
|
||||
file: Dockerfile
|
||||
tag: ghcr.io/yangyuan-zhen/polyweather-backend
|
||||
- name: frontend
|
||||
context: ./frontend
|
||||
file: ./frontend/Dockerfile
|
||||
tag: ghcr.io/yangyuan-zhen/polyweather-frontend
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push backend
|
||||
if: matrix.image.name == 'backend'
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ${{ matrix.image.context }}
|
||||
file: ${{ matrix.image.file }}
|
||||
push: true
|
||||
tags: |
|
||||
${{ matrix.image.tag }}:latest
|
||||
${{ matrix.image.tag }}:${{ github.sha }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Build and push frontend
|
||||
if: matrix.image.name == 'frontend'
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ${{ matrix.image.context }}
|
||||
file: ${{ matrix.image.file }}
|
||||
push: true
|
||||
tags: |
|
||||
${{ matrix.image.tag }}:latest
|
||||
${{ matrix.image.tag }}:${{ github.sha }}
|
||||
build-args: |
|
||||
NEXT_PUBLIC_SUPABASE_URL=${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}
|
||||
NEXT_PUBLIC_SITE_URL=${{ secrets.NEXT_PUBLIC_SITE_URL || 'https://polyweather.top' }}
|
||||
NEXT_PUBLIC_POLYWEATHER_API_BASE_URL=${{ secrets.NEXT_PUBLIC_POLYWEATHER_API_BASE_URL || '' }}
|
||||
NEXT_PUBLIC_POLYWEATHER_LOCAL_FULL_ACCESS=false
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
- name: Build Docker image
|
||||
run: docker build -t polyweather-ci .
|
||||
|
||||
deploy:
|
||||
needs: [build-and-push]
|
||||
needs: [python-quality, frontend-quality, docker-build]
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Deploy to VPS
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.VPS_SSH_KEY }}" > ~/.ssh/id_rsa
|
||||
chmod 600 ~/.ssh/id_rsa
|
||||
scp -o StrictHostKeyChecking=accept-new deploy.sh ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }}:/tmp/deploy.sh
|
||||
ssh -o StrictHostKeyChecking=accept-new ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }} "
|
||||
bash /tmp/deploy.sh '${{ secrets.GHCR_PAT }}' '${{ github.sha }}'
|
||||
cd /root/PolyWeather
|
||||
git fetch origin main && git reset --hard origin/main
|
||||
docker compose up -d --build
|
||||
sleep 10
|
||||
curl -s http://localhost:8000/healthz
|
||||
"
|
||||
|
||||
Vendored
+1
-4
@@ -1,8 +1,5 @@
|
||||
{
|
||||
"css.validate": false,
|
||||
"scss.validate": false,
|
||||
"less.validate": false,
|
||||
"python.analysis.extraPaths": [
|
||||
"./"
|
||||
]
|
||||
"less.validate": false
|
||||
}
|
||||
|
||||
@@ -1,23 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## 1.8.0 - 2026-05-27
|
||||
|
||||
### 新增与重构
|
||||
- **终端大洲区域过滤与分组**:终端重构支持按大洲/区域过滤与分组,添加移动端大洲 Tab 与卡片流响应式布局。
|
||||
- **巨鲸盯盘面板**:对接 Polymarket Data API `/holders`,按区域展示 Polymarket 成交量最大的城市、温度合约及真实巨鲸持仓数据。
|
||||
- **气温走势图升级**:使用 Recharts 交互式图表,支持双向概率分布对比柱状图,并在图表底部渲染 Polymarket 市场点击直达链接。
|
||||
- **日内偏差动态修正**:引入实时偏差修正算法,用实况观测与多模型小时预报的偏差来动态修正 DEB 预报中枢以及 Mu 概率分布,极大提高了预报和校准的精度。
|
||||
- **多数据源气温监控图表**:引入 `LiveTemperatureThresholdChart` 组件,展示实时跑道观测、DEB 预报中枢、多模型区间及目标阈值。
|
||||
- **全站中文化与多语言 (i18n)**:全站支持中英文一键切换,硬编码字符串彻底清理并接入翻译词条。
|
||||
- **机构落地页与鉴权优化**:首页重构为专业的机构落地页,添加了基于中间件的双层终端门控(/terminal 路由和 landing page 登录态感知)。
|
||||
- **超大组件拆分与解耦**:`AccountCenter` 组件彻底重构拆分为多个细粒度 Hook(`useWalletBind`、`usePaymentFlow`、`useBilling`),主组件代码缩减 60%,提升可维护性。
|
||||
- **Telegram 高频推送与内存优化**:机场观测推送重构,限制 LRU 缓存避免内存膨胀,并针对 Bot 动作和 API 接入进行连接复用与速率限制。
|
||||
|
||||
### 修复与优化
|
||||
- **类型异常修复**:修复在 `_in_peak_time_window` 决策卡时间窗口计算中 `last_h` 为 `None` 导致 `NoneType` 异常报错的问题。
|
||||
- **清理冗余类型转换**:移除 `src/utils/telegram_push.py` 中 8 处冗余的 `str()` 显式包装,精简 Python 代码。
|
||||
|
||||
|
||||
## 1.7.0 - 2026-05-23
|
||||
|
||||
### 新增能力
|
||||
|
||||
@@ -4,141 +4,168 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
## Project Overview
|
||||
|
||||
PolyWeather Pro — a paid institutional weather-intelligence terminal. 50 monitored cities with real-time METAR/AMOS/MADIS observations, DEB multi-model temperature blending, Mu probability calibration, and intraday bias correction. Pure meteorological decision workspace; no market/price layer. Next.js 15 + React 19 (Vercel) frontend, FastAPI backend (VPS), Telegram bot.
|
||||
PolyWeather Pro — a production weather-intelligence stack for temperature settlement markets. Aggregates observations and forecasts for 52 monitored cities globally, blends multi-model highs using DEB (Dynamic Error Balancing), generates calibrated probability buckets for settlement, and serves both a Next.js dashboard (Vercel) and a Telegram bot.
|
||||
|
||||
**Business model**: Paid-only, $10/month, no free tier, no trial. Landing page is public; `/terminal` requires login + active subscription.
|
||||
## Environment & Preferences (ALWAYS follow)
|
||||
|
||||
## Environment & Preferences
|
||||
### Working Directory
|
||||
- All commands run from the repo root
|
||||
- Python virtual env: `venv\Scripts\activate` (Windows) / `source venv/bin/activate` (Linux/macOS)
|
||||
- Frontend dev server: `cd frontend && npm run dev` → http://localhost:3000
|
||||
- Backend API server: `uvicorn web.app:app --reload --host 0.0.0.0 --port 8000` → http://localhost:8000
|
||||
- When I say "start the server", assume the working directory is the repo root
|
||||
|
||||
- Working directory: repo root
|
||||
- Python: `python` (not python3), venv at `venv/`
|
||||
- Frontend: `cd frontend && npm run dev` → localhost:3000
|
||||
- Backend: `uvicorn web.app:app --reload --host 0.0.0.0 --port 8000`
|
||||
- Package manager: **npm** (not yarn/pnpm)
|
||||
### Git Conventions
|
||||
- **Commit language: Chinese (简体中文) ONLY**
|
||||
- **NEVER start commit messages with `@`** — Chinese directly, no prefix
|
||||
- Format: Lore Commit Protocol — intent line in Chinese, trailers in English
|
||||
- Examples: `重构城市决策卡 hero 布局` or `统一 DEB 数据源为单一计算路径`
|
||||
- **NEVER** use English for commit subject lines
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Frontend
|
||||
cd frontend
|
||||
npm run dev # dev server :3000
|
||||
npm run build # production build
|
||||
npm run typecheck # tsc --noEmit
|
||||
npm run test:business # 19 business state tests
|
||||
|
||||
# Backend
|
||||
uvicorn web.app:app --reload --host 0.0.0.0 --port 8000
|
||||
python bot_listener.py # Telegram bot
|
||||
|
||||
# Python tests
|
||||
python -m pytest tests/
|
||||
python -m pytest tests/test_supabase_entitlement.py
|
||||
|
||||
# Lint
|
||||
ruff check .
|
||||
ruff format .
|
||||
|
||||
# Docker (VPS)
|
||||
docker compose down && docker compose up -d --build
|
||||
```
|
||||
### Tooling
|
||||
- Package manager: **npm** (not yarn/pnpm)
|
||||
- Python: `python` (not python3), venv at `venv/`
|
||||
- Lint: `ruff check .` (Python) + `npx tsc --noEmit` (TypeScript)
|
||||
- NEVER ask me about these preferences again — commit to memory
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Users → Next.js (Vercel) → FastAPI :8000 (VPS)
|
||||
/terminal (paid gate) Weather Collector
|
||||
/ (landing page) Analysis (DEB + Mu)
|
||||
Payment Layer (USDC on Polygon)
|
||||
Telegram Bot → bot_listener.py
|
||||
Users (Web / Telegram) → Next.js Frontend (Vercel) → FastAPI /web/app.py
|
||||
↓
|
||||
Weather Collector (METAR, TAF, Open-Meteo, country networks)
|
||||
↓
|
||||
Analysis (DEB + Trend + Probability + Market Scan)
|
||||
↓
|
||||
Payment Layer (Intent + Event + Confirm Loop)
|
||||
```
|
||||
|
||||
### Frontend Structure
|
||||
- **Backend**: FastAPI on port 8000 (`web/app.py` → `web/app_factory.py` → `web/routers/` (8 route modules: `system`, `city`, `auth`, `analytics`, `scan`, `payments`, `ops`, `routes` (legacy)) + `web/services/` (14 service modules) + `web/core.py`)
|
||||
- **Frontend**: Next.js 15 + React 19 + TypeScript + Tailwind CSS 3 + shadcn/ui (new-york style) on port 3000 (dev)
|
||||
- **Bot**: Telegram bot via `bot_listener.py` → `src/bot/`
|
||||
- **Shared analysis core** in `src/` is used by both web API and bot
|
||||
- **Scan Terminal**: Real-time city opportunity scanning (`web/scan_terminal_service.py` and `frontend/components/dashboard/scan-terminal/`)
|
||||
- **Dashboard**: Main dashboard with interactive map, city sidebar, detail panels, and probability views
|
||||
- **Market Monitor** (`MonitorPanel`): Real-time temperature monitoring board for 22 trading cities. Uses a temperature resolution chain (AMOS runway → AMOS → `airport_primary` → `airport_current` → `current`) defined in `frontend/components/dashboard/monitoring/monitor-temperature.ts`. Per-city refresh decisions driven by source-aware freshness (`source-freshness.ts`) instead of uniform `obs_age_min`. Seoul/Busan display runway surface temperature from AMOS; US cities get 5-min MADIS HFMETAR via `airport_primary`; others fall back to METAR.
|
||||
- **High-Freq Airport Pipeline**: 19 of 22 monitor cities have dedicated realtime sources (AMOS, MADIS, JMA, MGM, FMI, KNMI, AROME). Data flows: `weather_sources.py` (fetch) → `country_networks.py` (`_airport_primary_from_raw`, per-country providers) → API `airport_primary` field. Plain METAR stays in `airport_current`. Documented in `docs/AIRPORT_REALTIME_SOURCES.md`.
|
||||
- **Country Network Providers**: `country_networks.py` routes per-city to the right provider (Turkey→MGM, Korea→KMA, Japan→JMA, etc.) via `get_country_network_provider()`. Each provider controls `airport_primary_current`, `official_nearby_current`, and `official_network_status`. US cities use the default `GlobalMetarNetworkProvider` but get MADIS overrides injected via `results["madis_hfmetar_current"]`.
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `app/page.tsx` | Landing page (`InstitutionalLandingPage`) |
|
||||
| `app/terminal/page.tsx` | Paid terminal (`ScanTerminalDashboard`) |
|
||||
| `app/account/` | Account center with payment/subscription |
|
||||
| `app/auth/` | Supabase login/signup |
|
||||
| `components/dashboard/scan-terminal/` | Terminal sub-components |
|
||||
| `components/account/` | Account + payment hooks |
|
||||
| `components/landing/` | Institutional landing page |
|
||||
| `components/subscription/` | `UnlockProOverlay` payment overlay |
|
||||
| `lib/dashboard-types.ts` | All TypeScript types |
|
||||
## Commands
|
||||
|
||||
### Terminal Component Map
|
||||
### Frontend (dev on port 3000)
|
||||
```bash
|
||||
cd frontend
|
||||
npm ci
|
||||
npm run dev # Next.js dev server (runs sync-next-server-chunks.mjs first)
|
||||
npm run build # Production build (runs sync-next-server-chunks.mjs after)
|
||||
npm run start # Production server
|
||||
npm run lint # ESLint via next lint
|
||||
npm run typecheck # tsc --noEmit
|
||||
npm run test:business # Business state tests via scripts/run-business-state-tests.mjs (also runs in CI)
|
||||
```
|
||||
|
||||
- `ScanTerminalDashboard.tsx` — entry, auth gate, `ProductAccessRequired`
|
||||
- `PolyWeatherTerminal` — main layout: sidebar + region tabs + 2-column grid
|
||||
- `CityRegionList` — city list panel (left top)
|
||||
- `CityContractDetail` — contract table panel (left bottom)
|
||||
- `LiveTemperatureThresholdChart` — multi-source overlay: obs + DEB + model curves + thresholds
|
||||
- `RealtimeScrollChart` — lightweight realtime scrolling temperature + threshold bars
|
||||
- `TrainingDashboard` — DEB + Mu accuracy charts (sidebar "训练数据" tab)
|
||||
- `continent-grouping.ts` — 7 trading regions (`TRADING_REGIONS`), city-to-region fallback (`CITY_REGION_FALLBACK`), timezone detection (`detectLocalRegion`)
|
||||
### Backend (dev on port 8000)
|
||||
```bash
|
||||
uvicorn web.app:app --reload --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
### Account Module
|
||||
### Telegram Bot
|
||||
```bash
|
||||
python bot_listener.py
|
||||
# or via wrapper:
|
||||
python run.py
|
||||
```
|
||||
|
||||
- `AccountCenter.tsx` (~1280 lines) — main component
|
||||
- `useAccountPayment.ts` — master payment hook, composes sub-hooks
|
||||
- `useWalletBind.ts` — EVM/WalletConnect binding
|
||||
- `usePaymentFlow.ts` — intent creation, payment, confirmation
|
||||
- `useBilling.ts` — subscription recovery, billing computation
|
||||
### Docker (production-like stack)
|
||||
```bash
|
||||
docker compose up -d --build # bot + web API (polyweather + polyweather_web)
|
||||
```
|
||||
The compose file defines two services: `polyweather` (bot) and `polyweather_web` (FastAPI on :8000). Prewarm worker and monitoring profiles were removed in v1.6.0.
|
||||
|
||||
### Backend Key Files
|
||||
### Python tests
|
||||
```bash
|
||||
python -m pytest tests/ # all tests
|
||||
python -m pytest tests/test_web_observability.py # single test file
|
||||
```
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `web/routers/city.py` | City detail/summary/realtime-stream endpoints |
|
||||
| `web/routers/scan.py` | Scan terminal aggregation |
|
||||
| `web/services/city_payloads.py` | City detail and summary payload builders |
|
||||
| `web/scan_terminal_city_row.py` | Builds terminal rows from analysis data |
|
||||
| `src/data_collection/city_registry.py` | 50-city registry with tz_offset |
|
||||
| `src/analysis/deb_algorithm.py` | DEB prediction + Mu calibration + accuracy |
|
||||
| `web/services/analysis_utils.py` | Clock helpers, bucket labeling, time parsing |
|
||||
| `web/services/observation_freshness.py` | Source profiles and freshness computation |
|
||||
| `web/services/scan_ai_config.py` | Scan terminal and AI configuration constants |
|
||||
### Version bump (see RELEASE.md)
|
||||
```bash
|
||||
python scripts/bump_version.py patch # or minor / major / 1.5.0
|
||||
python scripts/sync_version.py # verify sync across files
|
||||
```
|
||||
`VERSION` file is the single source of truth; frontend `package.json` and docs sync from it.
|
||||
|
||||
## Auth Gating
|
||||
### Lint & Format
|
||||
```bash
|
||||
ruff check . # Python lint (pycodestyle + Pyflakes, line-length 88)
|
||||
ruff format . # Python format (Black-compatible, double quotes)
|
||||
```
|
||||
|
||||
Middleware (`middleware.ts`) handles two layers:
|
||||
1. **Terminal gate** (`handleTerminalGate`): `/terminal/*` → redirect to `/auth/login` if no Supabase session
|
||||
2. **Global auth** (`handleSupabaseAuthGate`): enforced when `POLYWEATHER_AUTH_REQUIRED=true`
|
||||
### Health & Ops checks
|
||||
```bash
|
||||
curl http://127.0.0.1:8000/healthz
|
||||
curl http://127.0.0.1:8000/api/system/status
|
||||
curl http://127.0.0.1:8000/metrics
|
||||
```
|
||||
|
||||
Client-side gate (`ProductAccessRequired`): `/terminal` checks auth + subscription via `/api/auth/me`, shows paywall if needed.
|
||||
## Key Directories
|
||||
|
||||
Local dev bypass: set `NEXT_PUBLIC_POLYWEATHER_LOCAL_FULL_ACCESS=false` to test auth locally.
|
||||
| Directory | Purpose |
|
||||
|-----------|---------|
|
||||
| `src/analysis/` | DEB algorithm, trend engine, market alert engine, settlement rounding |
|
||||
| `src/auth/` | Supabase entitlement checks, Telegram group pricing |
|
||||
| `src/bot/` | Telegram bot handlers and orchestrator |
|
||||
| `src/database/` | SQLite-based runtime state, DB manager, daily/truth/training feature repositories |
|
||||
| `src/data_collection/` | Weather sources (METAR, TAF, Open-Meteo, JMA, KMA, MGM, NMC, Russia stations, settlement sources), city registry (52 cities), Polymarket readonly layer. Also: `madis_sources.py` (NOAA 5-min NetCDF), `amos_station_sources.py` (Korean runway sensors), `country_networks.py` (per-country provider routing + `_airport_primary_from_raw`) |
|
||||
| `src/data_mining/` | Historical data fetch utilities |
|
||||
| `src/onchain/` | Polygon wallet watcher |
|
||||
| `src/payments/` | Onchain checkout, event listener, confirm loop, contract audit |
|
||||
| `src/strategy/` | Trading strategy modules |
|
||||
| `src/trading/` | Trading execution modules |
|
||||
| `src/utils/` | Shared utilities: config loader, logging, metrics, Telegram push, chat ID helpers |
|
||||
| `web/` | FastAPI app (`app.py` → `app_factory.py`), `routers/` (8 route modules), `services/` (14 service modules), `core.py`, scan terminal modules (AI fallback, AI prompts, METAR gate, city rows, ranker, cache) |
|
||||
| `frontend/app/` | Next.js App Router pages (dashboard, account, auth, docs, ops, probabilities, scan) |
|
||||
| `frontend/components/dashboard/` | Dashboard UI components (map, sidebar, detail panel, modals, charts, scan terminal). `scan-root-styles.ts` is the CSS Module barrel, combining 22 module roots into one pre-composed className. `monitoring/` subdirectory: `MonitorPanel`, `monitor-temperature.ts` (temp resolution chain), `monitor-refresh-policy.ts`. |
|
||||
| `frontend/lib/` | Shared client logic: types (`dashboard-types.ts`, including `AirportCurrentConditions`, `CityDetail`), API client, chart utils, i18n, `source-freshness.ts` (per-source freshness with `expected_next_update_at`), dashboard utils |
|
||||
| `frontend/hooks/` | React hooks: dashboard store (global state), Leaflet map, chart helper |
|
||||
| `scripts/` | Operational scripts: backfills, payment reconciliation. `supabase/` subdirectory: DB schema and migration SQL. |
|
||||
| `config/` | YAML config (city list, weather settings, logging) |
|
||||
| `docs/` | Bilingual product & technical docs |
|
||||
|
||||
## Polymarket Integration
|
||||
## Key Technical Details
|
||||
|
||||
**Removed.** No Polymarket price fetching, no market scan, no WS cache. Terminal operates on weather data only (Live observations + DEB predictions + model probabilities). All `polymarket_readonly.py`, `polymarket_ws_cache.py`, and market-scan API routes have been deleted.
|
||||
- **Python version**: 3.11 (target), type hints use `from __future__ import annotations` in most modules
|
||||
- **Package manager**: pip (requirements.txt) + uv cache is present but not the primary tool; no pyproject.toml build system defined
|
||||
- **Frontend package manager**: npm
|
||||
- **State storage**: SQLite primary path (set via `POLYWEATHER_STATE_STORAGE_MODE=sqlite` + `POLYWEATHER_DB_PATH`). Legacy JSON/JSONL files are migration/fallback only.
|
||||
- **Runtime data**: External dir recommended (`POLYWEATHER_RUNTIME_DATA_DIR=/var/lib/polyweather`) to avoid git conflicts
|
||||
- **Configuration**: `.env.example` is the comprehensive reference (8 config sections: runtime, Telegram, weather cache, auth, ops, frontend, optional modules, Polygon monitor). Copy to `.env` and fill in secrets.
|
||||
- **Auth gating** (frontend middleware): Three-tier priority in `middleware.ts` — (1) local dev hosts (localhost / 127.0.0.1 / ::1) bypass auth entirely, (2) Supabase session-based when `POLYWEATHER_AUTH_ENABLED=true` via `handleSupabaseAuthGate` or `handleSupabaseOptionalSession`, (3) legacy token fallback via `POLYWEATHER_DASHBOARD_ACCESS_TOKEN` cookie/query-param. Public pages (`/`, `/docs`, `/auth/*`, `/entitlement-required`) and public API routes are always accessible.
|
||||
- **CORS**: Allowed origins from `WEB_CORS_ORIGINS` env var (defaults: localhost:3000, polyweather-pro.vercel.app)
|
||||
- **API proxy**: Frontend uses Next.js rewrites to proxy `/api/*` to the FastAPI backend; see `frontend/lib/api-proxy.ts` and `frontend/lib/backend-api.ts`
|
||||
|
||||
## Trading Regions
|
||||
## Commit Convention
|
||||
|
||||
7 regions: east_asia, southeast_asia, central_asia, west_asia, europe_africa, south_america, north_america. Mappings in `continent-grouping.ts` (`CITY_REGION_FALLBACK` — all 50 cities hardcoded) and `scan_terminal_filters.py` (`market_region_from_tz_offset`). Default region auto-detected from browser timezone.
|
||||
This repo uses the **Lore Commit Protocol** — structured decision records with git trailers (`Constraint:`, `Rejected:`, `Confidence:`, `Scope-risk:`, `Directive:`, `Tested:`, `Not-tested:`). Intent line first (why, not what).
|
||||
|
||||
## Scan Terminal Performance
|
||||
|
||||
- **Region lazy-loading**: `region=east_asia` filters cities server-side before scanning (see `_market_region_from_tz_offset`)
|
||||
- **Weather-only**: Terminal returns 1 row per city with Live/DEB/probability data; no market contract matching
|
||||
- **DB**: SQLite WAL mode + `busy_timeout=5000` enabled in `db_manager.py` (fixes "database is locked" with parallel workers)
|
||||
- **VPS env**: `POLYWEATHER_SCAN_TERMINAL_MAX_WORKERS=2`, `POLYWEATHER_SCAN_TERMINAL_BUILD_TIMEOUT_SEC=180`
|
||||
- **Caching**: `_cache` is `LRUDict(256)` with `_CACHE_LOCK`; `_SUMMARY_CACHE` is `LRUDict(128)`; weather caches trimmed every 200 writes
|
||||
|
||||
## Intraday Bias Correction
|
||||
|
||||
`analysis_service.py:_analyze()` applies intraday correction after probability generation:
|
||||
- Compares current observed temp vs model hourly forecast for current hour
|
||||
- Time-of-day weight: 0.15↗0.35 pre-peak, 0.40↗0.75 during peak, 0.80 post-peak
|
||||
- Also checks if max-so-far already exceeds DEB prediction (strong upward nudge)
|
||||
- Correction capped at ±5°F / ±3°C, applied to both `deb_val` and `mu`
|
||||
- Always write git commit messages in **Chinese (简体中文)**.
|
||||
|
||||
## Code Style
|
||||
|
||||
- No `\uXXXX` escapes — write characters directly in UTF-8
|
||||
- Use `var(--color-*)` CSS tokens, not hardcoded hex
|
||||
- Minimum font size: 10px (`text-[10px]`)
|
||||
- Avoid `!important` except Leaflet map overrides
|
||||
- Remove dead code immediately when features are removed
|
||||
- Never use Unicode escape sequences (`\uXXXX`) in source code; write characters directly in UTF-8 encoding.
|
||||
- When modifying UI components, update both **dark-mode and light-mode CSS files** in the same edit batch.
|
||||
- **CSS Variables First**: Prefer `var(--color-*)` / `var(--color-signal-*)` tokens over hardcoded hex values. The token system is defined in `globals.css` with light-theme overrides under `html.light`.
|
||||
- **Avoid `!important`**: Only use it for Leaflet map overrides (inline style conflict) and chart canvas sizing. For light-theme overrides, use `html.light .root` prefix for higher specificity.
|
||||
- **Monitoring CSS note**: `MonitorPanel.module.css` scopes its light-theme overrides to `.scan-terminal.light` (the terminal's built-in toggle), NOT `html.light`. When adding light styles for monitoring components, match this scoping.
|
||||
- **New CSS Modules**: Add the module root class to `scan-root-styles.ts` barrel file instead of importing it separately in `ScanTerminalDashboard.tsx`.
|
||||
|
||||
## Quality Gates (MANDATORY)
|
||||
|
||||
Before marking any task as complete, you MUST:
|
||||
|
||||
1. **Type check** — Run `npx tsc --noEmit` (frontend) or `python -m ruff check .` (backend) on modified files
|
||||
2. **No Unicode escapes** — Verify that NO `\uXXXX` sequences were introduced; if found, revert and fix
|
||||
3. **Dual-theme CSS** — For any UI change, confirm BOTH dark and light styles. Most components need `ScanTerminalLightTheme.module.css` updated; monitoring components (`MonitorPanel.module.css`) contain their own `.scan-terminal.light` blocks inline.
|
||||
4. **No new hardcoded palette colors** — Use `var(--color-*)` token references instead of `#4DA3FF` / `#E6EDF3` / `#9FB2C7` / `#6B7A90` hex values
|
||||
5. **Show the diff** — Output `git diff --stat` and test results before declaring success
|
||||
|
||||
If any gate fails, fix it BEFORE reporting success.
|
||||
|
||||
@@ -214,5 +214,5 @@ curl http://127.0.0.1:8000/api/payments/runtime
|
||||
|
||||
## Version
|
||||
|
||||
- Version: `v1.8.0`
|
||||
- Version: `v1.7.0`
|
||||
- Last Updated: `2026-05-23`
|
||||
|
||||
+1
-1
@@ -230,5 +230,5 @@ POLYWEATHER_OPS_ADMIN_EMAILS=yhrsc30@gmail.com
|
||||
|
||||
## 当前版本
|
||||
|
||||
- 版本:`v1.8.0`
|
||||
- 版本:`v1.7.0`
|
||||
- 文档最后更新:`2026-05-23`
|
||||
|
||||
@@ -1,56 +1,12 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
#!/bin/bash
|
||||
set -e
|
||||
VPS="root@38.54.27.70"
|
||||
PROJECT="/root/PolyWeather"
|
||||
|
||||
GHCR_PAT="$1"
|
||||
NEW_TAG="${2:-latest}"
|
||||
TAG_FILE="/var/lib/polyweather/.current_tag"
|
||||
COMPOSE_DIR="/root/PolyWeather"
|
||||
echo "🚀 Deploying to $VPS..."
|
||||
|
||||
echo "$GHCR_PAT" | docker login ghcr.io -u yangyuan-zhen --password-stdin
|
||||
ssh "$VPS" "cd $PROJECT && git pull && docker compose up -d --build"
|
||||
|
||||
cd "$COMPOSE_DIR"
|
||||
git fetch origin main && git reset --hard origin/main
|
||||
|
||||
PREVIOUS_TAG=""
|
||||
if [ -f "$TAG_FILE" ]; then
|
||||
PREVIOUS_TAG=$(cat "$TAG_FILE")
|
||||
echo "Previous tag: $PREVIOUS_TAG"
|
||||
fi
|
||||
|
||||
export IMAGE_TAG="$NEW_TAG"
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
|
||||
# Wait for backend to be ready (retry up to 150s)
|
||||
echo "Waiting for backend..."
|
||||
for i in $(seq 1 30); do
|
||||
sleep 5
|
||||
if curl -fsSo /dev/null --max-time 5 "https://api.polyweather.top/healthz"; then
|
||||
echo "✅ healthz ready after ${i}x5s"
|
||||
break
|
||||
fi
|
||||
echo " retry $i/30..."
|
||||
done
|
||||
|
||||
FAILED=0
|
||||
curl -fsSo /dev/null --max-time 15 "https://api.polyweather.top/healthz" && echo "✅ healthz" || { echo "❌ healthz"; FAILED=1; }
|
||||
curl -fsSo /dev/null --max-time 10 "https://api.polyweather.top/api/cities" && echo "✅ cities" || { echo "❌ cities"; FAILED=1; }
|
||||
curl -fsSo /dev/null --max-time 10 "https://www.polyweather.top/" && echo "✅ frontend" || { echo "❌ frontend"; FAILED=1; }
|
||||
|
||||
if [ "$FAILED" = "1" ]; then
|
||||
echo "❌ Smoke tests failed. Rolling back..."
|
||||
if [ -n "$PREVIOUS_TAG" ]; then
|
||||
export IMAGE_TAG="$PREVIOUS_TAG"
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
echo "✅ Rolled back to $PREVIOUS_TAG"
|
||||
else
|
||||
echo "⚠️ No previous tag to rollback to"
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$(dirname "$TAG_FILE")"
|
||||
echo "$NEW_TAG" > "$TAG_FILE"
|
||||
docker image prune -af
|
||||
echo "✅ Deployed $NEW_TAG"
|
||||
echo "✅ Deploy complete. Checking health..."
|
||||
sleep 8
|
||||
ssh "$VPS" "curl -s http://localhost:8000/healthz"
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name polyweather.top www.polyweather.top;
|
||||
|
||||
# Supabase auth can set multiple chunked session cookies during OAuth
|
||||
# callback. Nginx defaults are too small and can raise:
|
||||
# "upstream sent too big header while reading response header from upstream".
|
||||
proxy_buffer_size 16k;
|
||||
proxy_buffers 8 16k;
|
||||
proxy_busy_buffers_size 32k;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:3001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name api.polyweather.top;
|
||||
|
||||
proxy_buffer_size 16k;
|
||||
proxy_buffers 8 16k;
|
||||
proxy_busy_buffers_size 32k;
|
||||
|
||||
location /api/events {
|
||||
proxy_pass http://127.0.0.1:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
proxy_read_timeout 86400s;
|
||||
proxy_set_header Connection '';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
+28
-81
@@ -1,92 +1,39 @@
|
||||
x-polyweather-base: &polyweather-base
|
||||
build: .
|
||||
image: polyweather-app:latest
|
||||
env_file:
|
||||
- .env
|
||||
|
||||
services:
|
||||
polyweather:
|
||||
<<: *polyweather-base
|
||||
container_name: polyweather_bot
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "3"
|
||||
cpus: ${POLYWEATHER_BOT_CPUS:-0.75}
|
||||
env_file: &id001
|
||||
- .env
|
||||
environment:
|
||||
TELEGRAM_AIRPORT_PUSH_INTERVAL_SEC: ${POLYWEATHER_BOT_AIRPORT_PUSH_INTERVAL_SEC:-180}
|
||||
TELEGRAM_AIRPORT_PUSH_MAX_WORKERS: ${POLYWEATHER_BOT_AIRPORT_PUSH_MAX_WORKERS:-1}
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}:/var/lib/polyweather
|
||||
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}:/app/data
|
||||
- ./bot.log:/app/bot.log
|
||||
user: "${UID:-1000}:${GID:-1000}"
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import sqlite3; c=sqlite3.connect('/var/lib/polyweather/polyweather.db'); c.execute('SELECT 1'); c.close()"]
|
||||
interval: 60s
|
||||
retries: 3
|
||||
test:
|
||||
- CMD
|
||||
- python
|
||||
- -c
|
||||
- import sqlite3; c=sqlite3.connect('/var/lib/polyweather/polyweather.db');
|
||||
c.execute('SELECT 1'); c.close()
|
||||
timeout: 10s
|
||||
image: ghcr.io/yangyuan-zhen/polyweather-backend:${IMAGE_TAG:-latest}
|
||||
mem_limit: ${POLYWEATHER_BOT_MEM_LIMIT:-768m}
|
||||
memswap_limit: ${POLYWEATHER_BOT_MEMSWAP_LIMIT:-1g}
|
||||
pids_limit: 256
|
||||
restart: unless-stopped
|
||||
user: ${UID:-1000}:${GID:-1000}
|
||||
volumes:
|
||||
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}:/var/lib/polyweather
|
||||
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}:/app/data
|
||||
- ./bot.log:/app/bot.log
|
||||
polyweather_frontend:
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "3"
|
||||
container_name: polyweather_frontend
|
||||
environment:
|
||||
NEXT_PUBLIC_POLYWEATHER_API_BASE_URL: ${NEXT_PUBLIC_POLYWEATHER_API_BASE_URL:-}
|
||||
NEXT_PUBLIC_POLYWEATHER_LOCAL_FULL_ACCESS: 'false'
|
||||
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-https://polyweather.top}
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${NEXT_PUBLIC_SUPABASE_ANON_KEY}
|
||||
NEXT_PUBLIC_SUPABASE_URL: ${NEXT_PUBLIC_SUPABASE_URL}
|
||||
POLYWEATHER_API_BASE_URL: ${POLYWEATHER_API_BASE_URL:-http://polyweather_web:8000}
|
||||
POLYWEATHER_AUTH_ENABLED: ${POLYWEATHER_AUTH_ENABLED:-true}
|
||||
POLYWEATHER_AUTH_REQUIRED: ${POLYWEATHER_AUTH_REQUIRED:-true}
|
||||
healthcheck:
|
||||
interval: 30s
|
||||
retries: 3
|
||||
test:
|
||||
- CMD
|
||||
- wget
|
||||
- -qO-
|
||||
- http://localhost:3000
|
||||
timeout: 5s
|
||||
image: ghcr.io/yangyuan-zhen/polyweather-frontend:${IMAGE_TAG:-latest}
|
||||
ports:
|
||||
- 3001:3000
|
||||
restart: unless-stopped
|
||||
|
||||
polyweather_web:
|
||||
command: python web/app.py
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "3"
|
||||
<<: *polyweather-base
|
||||
container_name: polyweather_web
|
||||
env_file: *id001
|
||||
healthcheck:
|
||||
interval: 30s
|
||||
retries: 3
|
||||
test:
|
||||
- CMD
|
||||
- python
|
||||
- -c
|
||||
- from urllib.request import urlopen; urlopen('http://localhost:8000/healthz')
|
||||
timeout: 5s
|
||||
image: ghcr.io/yangyuan-zhen/polyweather-backend:${IMAGE_TAG:-latest}
|
||||
ports:
|
||||
- 8000:8000
|
||||
restart: unless-stopped
|
||||
user: ${UID:-1000}:${GID:-1000}
|
||||
command: python web/app.py
|
||||
volumes:
|
||||
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}:/var/lib/polyweather
|
||||
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}:/app/data
|
||||
x-polyweather-base:
|
||||
env_file: *id001
|
||||
image: ghcr.io/yangyuan-zhen/polyweather-backend:${IMAGE_TAG:-latest}
|
||||
- ${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}"
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "from urllib.request import urlopen; urlopen('http://localhost:8000/healthz')"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
|
||||
@@ -44,24 +44,32 @@
|
||||
- 仅当当前温度距 DEB 预测最高 ≤3°C 时推送
|
||||
- 确认过峰值后自动停止
|
||||
|
||||
## 前端实时同步与 SSE Patch 机制
|
||||
## 前端市场监控 freshness 契约
|
||||
|
||||
为了向用户提供秒级实况响应并降低服务器负载,系统已从定时轮询架构全面迁移至 **Server-Sent Events (SSE) 增量更新(SSE Patch)** 架构。
|
||||
后端城市详情接口会在 `current.freshness` / `airport_current.freshness` 返回源感知更新时间信息,前端市场监控不再用统一的 `obs_age_min` 判断所有城市。
|
||||
|
||||
### 1. 数据推送链路 (Data Pipeline)
|
||||
1. **Collector 采集端触发**:在 `weather_sources.py` 中,当高频实况源(如 AMOS, CoWIN, MADIS 等)采集到温度更新或观测时间变更时,会调用 `_emit_temperature_patch_if_changed` 过滤重复值,并异步向 `/api/internal/collector-patch` 发送 POST 报文。
|
||||
2. **FastAPI SSE 广播**:FastAPI 后端的 `sse_router.py` 接收到 Patch 后,将其推入 `sse_manager` 进行全局广播,事件被包装为 `city_patch` 增量包,包含自增的全局 `revision` 和最新的 `changes`。
|
||||
3. **BFF 代理流**:浏览器前端通过 BFF (Next.js rewrites) 建立与 `/api/events` 的持久连接,从而无需定时轮询。
|
||||
关键字段:
|
||||
|
||||
### 2. 前端消费与刷新规则 (Frontend Freshness Rules)
|
||||
- **扫描列表免轮询更新**:`use-scan-terminal-query.ts` 通过 `useSsePatchVersion` 钩子订阅全局 SSE 版本。当有任何城市产生更新时,列表将触发按需重绘,之前固定的 5 分钟 `setInterval` 定时轮询已被彻底禁用。
|
||||
- **详情图表增量合并**:`LiveTemperatureThresholdChart.tsx` 使用 `useLatestPatch(city)` 钩子订阅当前选中城市的增量 Patch。当收到 Patch 时,前端会将最新温度与时间戳以增量形式直接合并(Merge)入本地的 `hourly` 状态中,避免重新加载完整的 City Detail JSON。
|
||||
- **双重降级兜底 (Safe Fallback Guard)**:
|
||||
- **无 Patch 轮询兜底**:为了防止 SSE 连接断开或长时间无 patch 导致界面卡死,所有**可见图表**(即 active 槽位、compact 栅格槽位或 maximized 视图)会启动一个 60 秒的检测定时器。
|
||||
- **触发条件**:若当前可见城市在连续 **2 分钟** 内没有收到任何 SSE patch,前端将自动发起主动请求:
|
||||
1. 调用轻量级的 `/api/city/{city}/summary` 快速拉取最新实况温度。
|
||||
2. 调用 `fetchHourlyForecastForCity(city, { ignoreCache: true })` 强刷完整的城市详情数据,确保数据一致性。
|
||||
- **按需加载与 Stagger 优化**:在加载城市详情时,前端会优先加载 Active 状态的图表,而处于 Background/非活动状态的图表则通过 staggered timer (按槽位索引延迟 300ms~1500ms) 异步获取,以分流请求峰值。
|
||||
```json
|
||||
{
|
||||
"source_code": "amos",
|
||||
"source_label": "AMOS",
|
||||
"observed_at": "2026-05-14T11:59:10+00:00",
|
||||
"observed_at_local": "20:59",
|
||||
"native_update_interval_sec": 60,
|
||||
"expected_next_update_at": "2026-05-14T12:00:10+00:00",
|
||||
"freshness_status": "fresh",
|
||||
"freshness_reason": "within_native_fresh_window",
|
||||
"age_sec": 50
|
||||
}
|
||||
```
|
||||
|
||||
前端刷新规则:
|
||||
|
||||
- 首次进入市场监控:强制刷新全部城市,绕过 30 分钟前端缓存。
|
||||
- 定时轮询:仍以 60s tick 检查,但只刷新已到 `expected_next_update_at`、`delayed`、`stale` 或缺失的城市。
|
||||
- BFF 代理:`force_refresh=true` 时使用 `no-store`,避免 Next fetch revalidate 缓存吞掉强刷。
|
||||
- 展示:卡片 tooltip 显示源端名称、原生更新间隔和当前 freshness 状态。
|
||||
|
||||
## 消息模板
|
||||
|
||||
@@ -78,10 +86,8 @@ Seoul / Incheon 16:03
|
||||
|
||||
| 变量 | 说明 | 默认值 |
|
||||
|------|------|--------|
|
||||
| `TELEGRAM_PUSH_LANGUAGE` | Telegram 自动推送的全局语言,可选 `both`/`en`/`zh` | `both` |
|
||||
| `TELEGRAM_AIRPORT_PUSH_ENABLED` | 启用机场推送 | `true` |
|
||||
| `TELEGRAM_AIRPORT_PUSH_INTERVAL_SEC` | 循环轮询间隔 | `60` |
|
||||
| `TELEGRAM_AIRPORT_PUSH_LANGUAGE` | 机场推送语言覆盖,可选 `both`/`en`/`zh` | `both` |
|
||||
| `KNMI_API_KEY` | KNMI API 密钥(阿姆斯特丹必填) | — |
|
||||
|
||||
## 未接入城市
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
# PolyWeather API 文档(v1.8.0)
|
||||
# PolyWeather API 文档(v1.7.0)
|
||||
|
||||
最后更新:`2026-04-27`
|
||||
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
# 城市实时数据源总览
|
||||
|
||||
> 最后更新: 2026-05-26 | 51 城市
|
||||
|
||||
## 数据源分级
|
||||
|
||||
### Tier 1 — ≤1 分钟高频
|
||||
|
||||
| 城市 | 来源 | 频率 | 备注 |
|
||||
|------|------|------|------|
|
||||
| seoul | AMOS 跑道传感器 (RKSI) | ~1 min | global.amo.go.kr, 站号 113 |
|
||||
| busan | AMOS 跑道传感器 (RKPK) | ~1 min | global.amo.go.kr, 站号 153 |
|
||||
| hong kong | CoWIN 6087 | ~1 min | cowin.hku.hk, 保良局陳守仁小學,前端图表默认展示 |
|
||||
| hong kong | HKO 官方 CSV | ~10 min | data.weather.gov.hk(文件名虽含 1min,实际 10min 一报) |
|
||||
| singapore | MSS 官方 API | ~1 min | api.data.gov.sg, 站号 S24 |
|
||||
| beijing | AMSC AWOS (ZBAA) | ~1 min | 中国 |
|
||||
| shanghai | AMSC AWOS (ZSPD) | ~1 min | 中国 |
|
||||
| guangzhou | AMSC AWOS (ZGGG) | ~1 min | 中国 |
|
||||
| chengdu | AMSC AWOS (ZUUU) | ~1 min | 中国 |
|
||||
| chongqing | AMSC AWOS (ZUCK) | ~1 min | 中国 |
|
||||
| wuhan | AMSC AWOS (ZHHH) | ~1 min | 中国 |
|
||||
| qingdao | AMSC AWOS (ZSQD) | ~1 min | 中国 |
|
||||
|
||||
### Tier 2 — 5 分钟高频 (MADIS)
|
||||
|
||||
| 城市 | 来源 | 频率 | 备注 |
|
||||
|------|------|------|------|
|
||||
| new york | MADIS HFMETAR (KLGA) | 5 min | madis-data.ncep.noaa.gov |
|
||||
| los angeles | MADIS HFMETAR (KLAX) | 5 min | |
|
||||
| san francisco | MADIS HFMETAR (KSFO) | 5 min | |
|
||||
| denver | MADIS HFMETAR (KBKF) | 5 min | |
|
||||
| austin | MADIS HFMETAR (KAUS) | 5 min | |
|
||||
| houston | MADIS HFMETAR (KHOU) | 5 min | |
|
||||
| chicago | MADIS HFMETAR (KORD) | 5 min | |
|
||||
| dallas | MADIS HFMETAR (KDAL) | 5 min | |
|
||||
| miami | MADIS HFMETAR (KMIA) | 5 min | |
|
||||
| atlanta | MADIS HFMETAR (KATL) | 5 min | |
|
||||
| seattle | MADIS HFMETAR (KSEA) | 5 min | |
|
||||
|
||||
### Tier 3 — 准实时国家级站网
|
||||
|
||||
| 城市 | 来源 | 频率 | 国家/地区 |
|
||||
|------|------|------|------|
|
||||
| tokyo | JMA AMeDAS (44166) | 10 min | 日本 |
|
||||
| ankara | MGM (17128) | 5-15 min | 土耳其 |
|
||||
| istanbul | MGM (17058) | 5-15 min | 土耳其 |
|
||||
| helsinki | FMI 开放数据 | 10 min | 芬兰 |
|
||||
| amsterdam | KNMI 数据平台 | 10 min | 荷兰 |
|
||||
| shenzhen | HKO 官方 CSV (LFS) | ~10 min | 香港天文台流浮山自动站 |
|
||||
| taipei | CWA 开放数据 (466920) | ~10 min | 台湾 |
|
||||
| tel aviv | IMS Lod (225) | 实时 | 以色列 |
|
||||
| paris | AEROWEB 实况 / AROME HD | 实时/15min | 法国 (AROME是15分钟临近预报) |
|
||||
|
||||
### Tier 4 — 仅 METAR(10 分钟缓存)
|
||||
|
||||
| 城市 | ICAO | 备注 |
|
||||
|------|------|------|
|
||||
| london | EGLC | Met Office 仅 1 小时更新 |
|
||||
| jeddah | OEJN | NCM 数据源目前不可用 |
|
||||
| moscow | UUWW | 仅 UUWW METAR 单站 |
|
||||
| shenzhen | ZGSZ | 已接入 HKO 流浮山 10 分钟数据,见 Tier 3 |
|
||||
| munich | EDDM | DWD 延迟约 1 小时 |
|
||||
| milan | LIMC | 无已知实时源 |
|
||||
| warsaw | EPWA | 含 IMGW 附近站 |
|
||||
| madrid | LEMD | AEMET 注册已失效 |
|
||||
| toronto | CYYZ | |
|
||||
| mexico city | MMMX | |
|
||||
| buenos aires | SAEZ | |
|
||||
| sao paulo | SBGR | |
|
||||
| panama city | MPMG | |
|
||||
| kuala lumpur | WMKK | |
|
||||
| jakarta | WIHH | |
|
||||
| manila | RPLL | |
|
||||
| karachi | OPKC | |
|
||||
| lucknow | VILK | |
|
||||
| wellington | NZWN | |
|
||||
| cape town | FACT | |
|
||||
|
||||
## 高频推送覆盖
|
||||
|
||||
31 个城市在 `HIGH_FREQ_AIRPORT_CITIES`(Telegram 推送循环):
|
||||
所有 Tier 1-3 城市 + shenzhen
|
||||
|
||||
19 个城市在 `HIGH_FREQ_AIRPORT_ANALYSIS_CITIES`(日内分析):
|
||||
seoul, busan, hong kong, lau fau shan, singapore, beijing, shanghai,
|
||||
guangzhou, chengdu, chongqing, wuhan, qingdao, shenzhen, tokyo,
|
||||
ankara, istanbul, helsinki, amsterdam, paris
|
||||
|
||||
## 温度观测优先级链
|
||||
|
||||
`country_networks.py:_airport_primary_from_raw()` 按以下顺序解析:
|
||||
|
||||
1. MADIS HFMETAR(美国 11 城)
|
||||
2. AMOS 跑道传感器(首尔/釜山)
|
||||
3. MGM current(安卡拉/伊斯坦布尔)
|
||||
4. JMA AMeDAS current(东京)
|
||||
5. FMI current(赫尔辛基)
|
||||
6. KNMI current(阿姆斯特丹)
|
||||
7. CoWIN 6087(香港 1min 参考站)
|
||||
8. AEROWEB current(巴黎)
|
||||
9. IMS current(特拉维夫)
|
||||
10. NCM current(吉达)
|
||||
11. Singapore MSS current(新加坡)
|
||||
12. 纯 METAR(默认兜底)
|
||||
|
||||
## 对日内偏差修正的影响
|
||||
|
||||
- **Tier 1 城市**(1 分钟级):修正权重可以更激进,数据噪声低
|
||||
- **Tier 2 城市**(5 分钟级):修正效果良好,MADIS 更新稳定
|
||||
- **Tier 3 城市**(10-15 分钟级):修正可用但滞后较大
|
||||
- **Tier 4 城市**(仅 METAR):修正效果有限,不建议依赖
|
||||
|
||||
|
||||
## 关于网站终端图表的数据曲线展示逻辑
|
||||
|
||||
### 1. 实测数据(默认全开,突出核心)
|
||||
|
||||
- **跑道全量展示**:北京、上海、广州、成都、重庆、武汉、首尔等城市的跑道实测数据,默认全量开启,无需手动勾选。
|
||||
- **结算跑道高亮**:系统内置了各大机场的官方结算跑道映射。命中的跑道将被**重点强调**(加粗的青色实线 #009688,线宽 2.8),并标记为“[跑道号] 结算跑道”。具体的跑道映射如下:
|
||||
- 北京:19/01
|
||||
- 上海:17L/35R
|
||||
- 广州:02L/20R
|
||||
- 成都:02L/20R
|
||||
- 重庆:20R/02L
|
||||
- 武汉:04/22
|
||||
- 首尔:15R/33L
|
||||
- **辅助跑道弱化**:同一机场下的其他非结算跑道,也会同时展示,但采用较细的虚线(线宽 1.2)以作陪衬区分。
|
||||
- **其他实测展示**:所有城市的 METAR 报文曲线、官方气象站实测(如 Hong Kong / Lau Fau Shan 的香港天文台曲线)均默认展示。
|
||||
|
||||
### 2. 核心预测数据(默认展示)
|
||||
|
||||
- **DEB 模型融合**:作为平台核心的高精度智能融合预测曲线,默认始终展示给用户。
|
||||
|
||||
### 3. 多模型原始数据(默认隐藏,按需自选)
|
||||
|
||||
- **保持整洁**:为了防止图表线缆过于杂乱,各大原始模型(ECMWF, GFS, ICON, GEM 等)的数据曲线在初次加载时**默认隐藏**。
|
||||
- **特例**:仅针对巴黎(Paris),由于其 AROME HD 是高精度的 15 分钟级临近预报,极具参考价值,因此默认开启。
|
||||
- **自由交互**:用户可通过图表底部的图例交互按钮,随时自由勾选、叠加或隐藏任意所需的数据曲线。
|
||||
@@ -1,4 +1,4 @@
|
||||
# Supabase + 登录 + 支付接入说明(v1.8.0)
|
||||
# Supabase + 登录 + 支付接入说明(v1.7.0)
|
||||
|
||||
最后更新:`2026-03-14`
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# 技术债与工程待办(v1.8.0)
|
||||
# 技术债与工程待办(v1.7.0)
|
||||
|
||||
最后更新:`2026-05-10`
|
||||
|
||||
|
||||
@@ -78,8 +78,6 @@ Polymarket Gamma/CLOB ─┤
|
||||
|
||||
**已修复:TTL 匹配** — `SCAN_TERMINAL_PAYLOAD_TTL_SEC` 30s → 120s,匹配 ThreadPoolExecutor(4)×60 城的实际重算耗时。
|
||||
|
||||
**已实现:SSE 增量推送(SSE Patch)与按需刷新** — 引入 FastAPI SSE 广播通道 (`/api/events`)。数据采集端更新时自动向 `/api/internal/collector-patch` 推送最新温度;前端扫描终端订阅该流,不再执行固定的 5 分钟定时轮询,而是根据 Patch 变化即时更新列表;当前选中图表基于 `useLatestPatch` 实现 1 分钟级温度的增量合并与实时曲线绘制。
|
||||
|
||||
| # | 问题 | 优先级 |
|
||||
|---|------|------|
|
||||
| 5 | 缓存键过粗(city::mode),微小变化也触发完整重算 | 🟡 |
|
||||
@@ -94,8 +92,6 @@ Polymarket Gamma/CLOB ─┤
|
||||
|
||||
**已修复:扫描数据复用** — `preloadCityFromRow()` 从扫描终端行预填充城市详情缓存,选城市后详情面板立即显示。
|
||||
|
||||
**已实现:SSE 订阅与 2 分钟无 Patch 兜底机制** — 引入 `useLatestPatch` 与 `useSsePatchVersion` 管理实况数据的准实时合并。若长连接中断或 2 分钟内未收到任何增量 Patch,可见图表自动触发 60s 降级轮询(从 `/api/city/{city}/summary` 获取最新实况,并以 ignoreCache 强刷 full detail)。
|
||||
|
||||
## 六、待办
|
||||
|
||||
| # | 问题 | 优先级 | 说明 |
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# PolyWeatherCheckout PolygonScan 验证(v1.8.0)
|
||||
# PolyWeatherCheckout PolygonScan 验证(v1.7.0)
|
||||
|
||||
最后更新:`2026-03-20`
|
||||
|
||||
|
||||
@@ -1,929 +0,0 @@
|
||||
# 终端城市表格按大洲分组 — 实施计划
|
||||
|
||||
> **For agentic workers:** 使用 superpowers:subagent-driven-development 或 superpowers:executing-plans 按任务逐步实施。
|
||||
|
||||
**目标:** 将扫描终端的平铺城市表格重构为按时区分组 + Active Signals 虚拟分组的金融终端风格,桌面端 9 列可折叠分组表格,移动端 Tab 切换 + 卡片流。
|
||||
|
||||
**架构:** 抽取分组逻辑到 `continent-grouping.ts`,新增 `ContinentGroupHeader`、`MobileCityCard`、`MobileRegionTabs` 三个子组件,重构 `ScanTerminalDashboard.tsx` 中的 `KoyfinWeatherTerminal` 和 `MarketTable`。数据字段全部就绪,后端零改动。
|
||||
|
||||
**技术栈:** React 19 + TypeScript + Tailwind CSS 3 + CSS Modules
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 创建 continent-grouping.ts 分组逻辑
|
||||
|
||||
**文件:**
|
||||
- 创建: `frontend/components/dashboard/scan-terminal/continent-grouping.ts`
|
||||
|
||||
这个文件负责所有分组逻辑:按 `trading_region` 分桶、Active Signals 筛选、折叠状态管理、Gap 颜色映射。
|
||||
|
||||
- [ ] **Step 1: 写入 continent-grouping.ts**
|
||||
|
||||
```typescript
|
||||
import type { ScanOpportunityRow } from "@/lib/dashboard-types";
|
||||
|
||||
// 7 trading regions from backend scan_terminal_filters.py
|
||||
export const TRADING_REGIONS = [
|
||||
{ key: "east_asia", labelEn: "East Asia", labelZh: "东亚", sort: 1 },
|
||||
{ key: "southeast_asia", labelEn: "Southeast Asia", labelZh: "东南亚", sort: 2 },
|
||||
{ key: "central_asia", labelEn: "Central / South Asia", labelZh: "中亚 / 南亚", sort: 3 },
|
||||
{ key: "west_asia", labelEn: "West Asia / Middle East", labelZh: "西亚 / 中东", sort: 4 },
|
||||
{ key: "europe_africa", labelEn: "Europe / Africa", labelZh: "欧洲 / 非洲", sort: 5 },
|
||||
{ key: "south_america", labelEn: "Latin America", labelZh: "拉美", sort: 6 },
|
||||
{ key: "north_america", labelEn: "North America", labelZh: "北美", sort: 7 },
|
||||
] as const;
|
||||
|
||||
export type TradingRegionKey = (typeof TRADING_REGIONS)[number]["key"];
|
||||
|
||||
export interface ContinentGroup {
|
||||
key: string; // "active_signals" | TradingRegionKey
|
||||
labelEn: string;
|
||||
labelZh: string;
|
||||
sort: number;
|
||||
rows: ScanOpportunityRow[];
|
||||
activeCount: number;
|
||||
watchCount: number;
|
||||
hotCity: string | null;
|
||||
localTimeRange: string | null;
|
||||
}
|
||||
|
||||
export function isActiveSignal(row: ScanOpportunityRow): boolean {
|
||||
const decision = String(row.ai_decision || row.v4_metar_decision || "").toLowerCase();
|
||||
if (decision.includes("approve")) return true;
|
||||
if (row.tradable && row.active) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function isWatchSignal(row: ScanOpportunityRow): boolean {
|
||||
const decision = String(row.ai_decision || row.v4_metar_decision || row.signal_status || "").toLowerCase();
|
||||
if (decision.includes("watch")) return true;
|
||||
if (decision.includes("monitor")) return true;
|
||||
if (!row.tradable && row.active) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function isDeadSignal(row: ScanOpportunityRow): boolean {
|
||||
if (row.closed) return true;
|
||||
const decision = String(row.ai_decision || row.v4_metar_decision || "").toLowerCase();
|
||||
if (decision.includes("veto")) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function getSignalState(row: ScanOpportunityRow): "active" | "watch" | "closed" | "data" {
|
||||
if (isDeadSignal(row)) return "closed";
|
||||
if (isActiveSignal(row)) return "active";
|
||||
if (isWatchSignal(row)) return "watch";
|
||||
return "data";
|
||||
}
|
||||
|
||||
export function getSignalLabel(state: ReturnType<typeof getSignalState>, isEn: boolean): string {
|
||||
switch (state) {
|
||||
case "active": return isEn ? "◆ Active" : "◆ 活跃";
|
||||
case "watch": return isEn ? "● Watch" : "● 观察";
|
||||
case "closed": return isEn ? "○ Closed" : "○ 关闭";
|
||||
case "data": return isEn ? "! Data" : "! 数据";
|
||||
}
|
||||
}
|
||||
|
||||
export type GapColor = "green" | "orange" | "slate" | "gray" | "red";
|
||||
|
||||
export function getGapColor(row: ScanOpportunityRow): GapColor {
|
||||
const gap = Number(row.signed_gap ?? row.gap_to_target);
|
||||
const edge = Number(row.edge_percent || 0);
|
||||
const spread = Number(row.spread || 0);
|
||||
const liq = Number(row.book_liquidity || row.market_liquidity || 0);
|
||||
|
||||
if (!Number.isFinite(gap)) return "gray";
|
||||
if (liq <= 0 || spread > 20) return "red";
|
||||
if (gap >= 2) return "green";
|
||||
if (gap >= 0 && edge > 5) return "orange";
|
||||
if (gap >= 0) return "slate";
|
||||
if (gap < -5 || edge < -10) return "gray";
|
||||
return "slate";
|
||||
}
|
||||
|
||||
export const GAP_COLOR_MAP: Record<GapColor, string> = {
|
||||
green: "text-emerald-600",
|
||||
orange: "text-amber-600",
|
||||
slate: "text-slate-500",
|
||||
gray: "text-slate-400",
|
||||
red: "text-red-500",
|
||||
};
|
||||
|
||||
export function formatPrice(midpoint?: number | null, ask?: number | null, bid?: number | null): string {
|
||||
const m = Number(midpoint);
|
||||
if (Number.isFinite(m) && m > 0) {
|
||||
const cents = Math.round(m * 100);
|
||||
return `Y ${cents}¢`;
|
||||
}
|
||||
const a = Number(ask);
|
||||
if (Number.isFinite(a) && a > 0) {
|
||||
const cents = Math.round(a * 100);
|
||||
return `Y ${cents}¢`;
|
||||
}
|
||||
return "--";
|
||||
}
|
||||
|
||||
export function formatSpreadLiquidity(spread?: number | null, liquidity?: number | null): string {
|
||||
const sp = Number(spread);
|
||||
const liq = Number(liquidity);
|
||||
const spStr = Number.isFinite(sp) ? `${Math.round(sp)}¢` : "--";
|
||||
const liqStr = Number.isFinite(liq)
|
||||
? liq >= 1000
|
||||
? `$${(liq / 1000).toFixed(1)}K`
|
||||
: `$${Math.round(liq)}`
|
||||
: "--";
|
||||
return `${spStr} / ${liqStr}`;
|
||||
}
|
||||
|
||||
export function buildContinentGroups(rows: ScanOpportunityRow[], isEn: boolean): ContinentGroup[] {
|
||||
const regionMap = new Map<string, ScanOpportunityRow[]>();
|
||||
|
||||
for (const row of rows) {
|
||||
const region = String(row.trading_region || "unknown").toLowerCase();
|
||||
if (!regionMap.has(region)) regionMap.set(region, []);
|
||||
regionMap.get(region)!.push(row);
|
||||
}
|
||||
|
||||
const groups: ContinentGroup[] = [];
|
||||
|
||||
// Active Signals virtual group
|
||||
const activeRows = rows.filter((r) => isActiveSignal(r));
|
||||
if (activeRows.length > 0) {
|
||||
const hotRow = activeRows.reduce((best, r) =>
|
||||
Number(r.edge_percent || 0) > Number(best.edge_percent || 0) ? r : best
|
||||
);
|
||||
groups.push({
|
||||
key: "active_signals",
|
||||
labelEn: "Active Signals",
|
||||
labelZh: "活跃信号",
|
||||
sort: 0,
|
||||
rows: activeRows,
|
||||
activeCount: activeRows.filter((r) => isActiveSignal(r)).length,
|
||||
watchCount: activeRows.filter((r) => isWatchSignal(r)).length,
|
||||
hotCity: hotRow?.city_display_name || hotRow?.city || null,
|
||||
localTimeRange: null,
|
||||
});
|
||||
}
|
||||
|
||||
for (const region of TRADING_REGIONS) {
|
||||
const regionRows = regionMap.get(region.key) || [];
|
||||
if (regionRows.length === 0) continue;
|
||||
|
||||
const activeCount = regionRows.filter((r) => isActiveSignal(r)).length;
|
||||
const watchCount = regionRows.filter((r) => isWatchSignal(r)).length;
|
||||
const sorted = [...regionRows].sort((a, b) =>
|
||||
Number(b.final_score || 0) - Number(a.final_score || 0)
|
||||
);
|
||||
const hotCity = sorted[0]?.city_display_name || sorted[0]?.city || null;
|
||||
|
||||
// Compute local time range for this region
|
||||
const times = regionRows.map((r) => String(r.local_time || "").trim()).filter(Boolean);
|
||||
const ltRange = times.length >= 2
|
||||
? `${times[0]}-${times[times.length - 1]}`
|
||||
: times[0] || null;
|
||||
|
||||
groups.push({
|
||||
key: region.key,
|
||||
labelEn: region.labelEn,
|
||||
labelZh: region.labelZh,
|
||||
sort: region.sort,
|
||||
rows: regionRows,
|
||||
activeCount,
|
||||
watchCount,
|
||||
hotCity,
|
||||
localTimeRange: ltRange,
|
||||
});
|
||||
}
|
||||
|
||||
// Sort: active_signals first, then by trading_region_sort
|
||||
groups.sort((a, b) => a.sort - b.sort);
|
||||
return groups;
|
||||
}
|
||||
|
||||
export function getDefaultExpanded(groups: ContinentGroup[]): Set<string> {
|
||||
const expanded = new Set<string>();
|
||||
for (const g of groups) {
|
||||
if (g.key === "active_signals") {
|
||||
expanded.add(g.key);
|
||||
} else if (g.activeCount > 0 || g.watchCount > 0) {
|
||||
expanded.add(g.key);
|
||||
}
|
||||
}
|
||||
return expanded;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 验证 TypeScript 类型**
|
||||
|
||||
```bash
|
||||
cd frontend && npx tsc --noEmit src/components/dashboard/scan-terminal/continent-grouping.ts 2>&1 || true
|
||||
```
|
||||
|
||||
预期:类型通过(可能有模块路径的错误,这些在组件集成后解决)。
|
||||
|
||||
- [ ] **Step 3: 提交**
|
||||
|
||||
```bash
|
||||
git add frontend/components/dashboard/scan-terminal/continent-grouping.ts
|
||||
git commit -m "新增终端大洲分组逻辑模块"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 创建 ContinentGroupHeader.tsx 分组标题行组件
|
||||
|
||||
**文件:**
|
||||
- 创建: `frontend/components/dashboard/scan-terminal/ContinentGroupHeader.tsx`
|
||||
|
||||
- [ ] **Step 1: 写入组件**
|
||||
|
||||
```tsx
|
||||
"use client";
|
||||
|
||||
import { ChevronDown, ChevronRight } from "lucide-react";
|
||||
import type { ContinentGroup } from "@/components/dashboard/scan-terminal/continent-grouping";
|
||||
|
||||
export function ContinentGroupHeader({
|
||||
group,
|
||||
isExpanded,
|
||||
isEn,
|
||||
onToggle,
|
||||
}: {
|
||||
group: ContinentGroup;
|
||||
isExpanded: boolean;
|
||||
isEn: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const label = isEn ? group.labelEn : group.labelZh;
|
||||
const parts: string[] = [`${group.rows.length}`];
|
||||
|
||||
if (group.activeCount > 0) {
|
||||
parts.push(isEn ? `Active ${group.activeCount}` : `活跃 ${group.activeCount}`);
|
||||
}
|
||||
if (group.watchCount > 0) {
|
||||
parts.push(isEn ? `Watch ${group.watchCount}` : `观察 ${group.watchCount}`);
|
||||
}
|
||||
if (group.localTimeRange) {
|
||||
parts.push(`LT ${group.localTimeRange}`);
|
||||
}
|
||||
if (group.hotCity) {
|
||||
parts.push(isEn ? `Hot: ${group.hotCity}` : `热门: ${group.hotCity}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className="group flex w-full items-center gap-2 border-b border-slate-200 bg-[#eef2f6] px-3 py-2 text-left hover:bg-[#e2e8f0] transition-colors"
|
||||
>
|
||||
<span className="grid h-5 w-5 shrink-0 place-items-center text-slate-400 group-hover:text-slate-600">
|
||||
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
</span>
|
||||
<span className="text-xs font-black uppercase tracking-wide text-slate-600">
|
||||
{label}
|
||||
</span>
|
||||
<span className="text-[11px] text-slate-400">
|
||||
{parts.join(" · ")}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 提交**
|
||||
|
||||
```bash
|
||||
git add frontend/components/dashboard/scan-terminal/ContinentGroupHeader.tsx
|
||||
git commit -m "新增终端大洲分组标题行组件"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 创建 MobileCityCard.tsx 移动端卡片组件
|
||||
|
||||
**文件:**
|
||||
- 创建: `frontend/components/dashboard/scan-terminal/MobileCityCard.tsx`
|
||||
|
||||
- [ ] **Step 1: 写入组件**
|
||||
|
||||
```tsx
|
||||
"use client";
|
||||
|
||||
import type { ScanOpportunityRow } from "@/lib/dashboard-types";
|
||||
import {
|
||||
formatPrice,
|
||||
formatSpreadLiquidity,
|
||||
GAP_COLOR_MAP,
|
||||
getGapColor,
|
||||
getSignalLabel,
|
||||
getSignalState,
|
||||
} from "@/components/dashboard/scan-terminal/continent-grouping";
|
||||
|
||||
function rowName(row: ScanOpportunityRow) {
|
||||
return row.city_display_name || row.display_name || row.city || "--";
|
||||
}
|
||||
|
||||
function tempVal(value?: number | null, symbol?: string | null) {
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n)) return "--";
|
||||
return `${n.toFixed(1)}${symbol || "°"}`;
|
||||
}
|
||||
|
||||
export function MobileCityCard({
|
||||
isEn,
|
||||
onClick,
|
||||
row,
|
||||
}: {
|
||||
isEn: boolean;
|
||||
onClick: (row: ScanOpportunityRow) => void;
|
||||
row: ScanOpportunityRow;
|
||||
}) {
|
||||
const signal = getSignalState(row);
|
||||
const gapColor = GAP_COLOR_MAP[getGapColor(row)];
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onClick(row)}
|
||||
className="w-full rounded-lg border border-slate-200 bg-white p-3 text-left shadow-sm hover:bg-blue-50/70 transition-colors"
|
||||
>
|
||||
{/* Row 1: City + Signal */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="truncate text-sm font-bold text-slate-900">
|
||||
{rowName(row)}
|
||||
</span>
|
||||
<span className="shrink-0 text-xs font-black">
|
||||
<span className={signal === "active" ? "text-emerald-600" : signal === "watch" ? "text-amber-600" : signal === "closed" ? "text-slate-400" : "text-red-500"}>
|
||||
{getSignalLabel(signal, isEn)}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Row 2: Obs · Gap · Market */}
|
||||
<div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
|
||||
<span className="font-mono font-bold">
|
||||
Obs {tempVal(row.current_temp, row.temp_symbol)}
|
||||
</span>
|
||||
<span className={gapColor}>
|
||||
Gap {tempVal(row.signed_gap ?? row.gap_to_target, row.temp_symbol)}
|
||||
</span>
|
||||
<span className="text-slate-600">
|
||||
{formatPrice(row.midpoint, row.ask, row.bid)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Row 3: High · DEB */}
|
||||
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-slate-500">
|
||||
<span>
|
||||
High {tempVal(row.current_max_so_far, row.temp_symbol)}
|
||||
</span>
|
||||
<span>
|
||||
DEB {tempVal(row.deb_prediction, row.temp_symbol)}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 提交**
|
||||
|
||||
```bash
|
||||
git add frontend/components/dashboard/scan-terminal/MobileCityCard.tsx
|
||||
git commit -m "新增终端移动端城市卡片组件"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 创建 MobileRegionTabs.tsx 移动端 Tab 栏
|
||||
|
||||
**文件:**
|
||||
- 创建: `frontend/components/dashboard/scan-terminal/MobileRegionTabs.tsx`
|
||||
|
||||
- [ ] **Step 1: 写入组件**
|
||||
|
||||
```tsx
|
||||
"use client";
|
||||
|
||||
import clsx from "clsx";
|
||||
import type { ContinentGroup } from "@/components/dashboard/scan-terminal/continent-grouping";
|
||||
|
||||
export function MobileRegionTabs({
|
||||
activeTab,
|
||||
groups,
|
||||
isEn,
|
||||
onSelectTab,
|
||||
}: {
|
||||
activeTab: string;
|
||||
groups: ContinentGroup[];
|
||||
isEn: boolean;
|
||||
onSelectTab: (key: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex overflow-x-auto border-b border-slate-200 bg-white px-2 no-scrollbar">
|
||||
{groups.map((g) => {
|
||||
const label = isEn ? g.labelEn : g.labelZh;
|
||||
const isActive = activeTab === g.key;
|
||||
return (
|
||||
<button
|
||||
key={g.key}
|
||||
type="button"
|
||||
onClick={() => onSelectTab(g.key)}
|
||||
className={clsx(
|
||||
"shrink-0 px-3 py-2.5 text-xs font-bold whitespace-nowrap border-b-2 transition-colors",
|
||||
isActive
|
||||
? "border-blue-600 text-blue-700"
|
||||
: "border-transparent text-slate-500 hover:text-slate-700"
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
<span className="ml-1 text-[10px] text-slate-400">
|
||||
{g.activeCount > 0 ? `${g.activeCount}A` : g.rows.length}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 提交**
|
||||
|
||||
```bash
|
||||
git add frontend/components/dashboard/scan-terminal/MobileRegionTabs.tsx
|
||||
git commit -m "新增终端移动端大洲 Tab 栏组件"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: 重构 ScanTerminalDashboard.tsx — 重写 MarketTable 为分组表格
|
||||
|
||||
**文件:**
|
||||
- 修改: `frontend/components/dashboard/ScanTerminalDashboard.tsx` (替换 `MarketTable` 函数)
|
||||
|
||||
这个步骤将现有的 `MarketTable` 替换为支持分组标题行的新版本 `GroupedMarketTable`。
|
||||
|
||||
- [ ] **Step 1: 替换 MarketTable 组件 (第 245-323 行)**
|
||||
|
||||
删除旧 `MarketTable`,替换为:
|
||||
|
||||
```tsx
|
||||
import { ChevronDown, ChevronRight } from "lucide-react";
|
||||
import {
|
||||
ContinentGroup,
|
||||
buildContinentGroups,
|
||||
formatPrice,
|
||||
formatSpreadLiquidity,
|
||||
GAP_COLOR_MAP,
|
||||
getDefaultExpanded,
|
||||
getGapColor,
|
||||
getSignalLabel,
|
||||
getSignalState,
|
||||
} from "@/components/dashboard/scan-terminal/continent-grouping";
|
||||
|
||||
function GroupedMarketTable({
|
||||
groups,
|
||||
isEn,
|
||||
onSelect,
|
||||
selectedId,
|
||||
}: {
|
||||
groups: ContinentGroup[];
|
||||
isEn: boolean;
|
||||
onSelect: (row: ScanOpportunityRow) => void;
|
||||
selectedId?: string | null;
|
||||
}) {
|
||||
const [collapsed, setCollapsed] = useState<Set<string>>(() => {
|
||||
const c = new Set<string>();
|
||||
const defaultExpanded = getDefaultExpanded(groups);
|
||||
for (const g of groups) {
|
||||
if (!defaultExpanded.has(g.key)) c.add(g.key);
|
||||
}
|
||||
return c;
|
||||
});
|
||||
|
||||
const toggleGroup = (key: string) => {
|
||||
setCollapsed((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="overflow-auto">
|
||||
<table className="w-full min-w-[800px] border-collapse text-[13px]">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-200 bg-[#f5f7fa] text-left text-[11px] uppercase text-slate-500">
|
||||
<th className="px-3 py-2 font-black">City</th>
|
||||
<th className="px-2 py-2 text-right font-black">Obs</th>
|
||||
<th className="px-2 py-2 text-right font-black">High</th>
|
||||
<th className="px-2 py-2 text-right font-black">DEB</th>
|
||||
<th className="px-2 py-2 text-right font-black">Gap</th>
|
||||
<th className="px-2 py-2 text-right font-black">Market</th>
|
||||
<th className="px-2 py-2 text-right font-black">Edge</th>
|
||||
<th className="px-2 py-2 text-right font-black">Spr/Liq</th>
|
||||
<th className="px-3 py-2 font-black">Signal</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{groups.map((group) => {
|
||||
const isExpanded = !collapsed.has(group.key);
|
||||
const label = isEn ? group.labelEn : group.labelZh;
|
||||
return (
|
||||
<Fragment key={group.key}>
|
||||
{/* Group header row */}
|
||||
<tr className="border-b border-slate-200 bg-[#eef2f6]">
|
||||
<td colSpan={9} className="p-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleGroup(group.key)}
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-[#e2e8f0] transition-colors"
|
||||
>
|
||||
<span className="grid h-4 w-4 place-items-center text-slate-400">
|
||||
{isExpanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
||||
</span>
|
||||
<span className="text-[11px] font-black uppercase tracking-wide text-slate-600">
|
||||
{label}
|
||||
</span>
|
||||
<span className="text-[10px] text-slate-400">
|
||||
{group.rows.length} · {isEn ? "Active" : "活跃"} {group.activeCount} · {isEn ? "Watch" : "观察"} {group.watchCount}
|
||||
{group.localTimeRange ? ` · LT ${group.localTimeRange}` : ""}
|
||||
{group.hotCity ? ` · Hot: ${group.hotCity}` : ""}
|
||||
</span>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{/* Data rows */}
|
||||
{isExpanded &&
|
||||
group.rows.map((row) => {
|
||||
const signal = getSignalState(row);
|
||||
const gapColor = GAP_COLOR_MAP[getGapColor(row)];
|
||||
return (
|
||||
<tr
|
||||
key={row.id}
|
||||
className={clsx(
|
||||
"cursor-pointer border-b border-slate-100 hover:bg-blue-50/70",
|
||||
selectedId === row.id && "bg-blue-50"
|
||||
)}
|
||||
onClick={() => onSelect(row)}
|
||||
>
|
||||
<td className="px-3 py-2">
|
||||
<div className="font-bold text-slate-900">{rowName(row)}</div>
|
||||
<div className="truncate text-[11px] text-slate-500">
|
||||
{row.airport || ""}{row.local_time ? ` · ${row.local_time}` : ""}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-2 py-2 text-right font-mono font-bold">
|
||||
{temp(row.current_temp, row.temp_symbol)}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-right font-mono">
|
||||
{temp(row.current_max_so_far, row.temp_symbol)}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-right font-mono">
|
||||
{temp(row.deb_prediction, row.temp_symbol)}
|
||||
</td>
|
||||
<td className={clsx("px-2 py-2 text-right font-mono font-bold", gapColor)}>
|
||||
{temp(row.signed_gap ?? row.gap_to_target, row.temp_symbol)}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-right font-mono">
|
||||
{formatPrice(row.midpoint, row.ask, row.bid)}
|
||||
</td>
|
||||
<td className={clsx("px-2 py-2 text-right font-mono font-bold", edgeClass(row.edge_percent))}>
|
||||
{pct(row.edge_percent)}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-right font-mono text-[11px]">
|
||||
{formatSpreadLiquidity(row.spread, row.book_liquidity ?? row.market_liquidity)}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<span className={clsx(
|
||||
"text-[11px] font-black",
|
||||
signal === "active" ? "text-emerald-600" :
|
||||
signal === "watch" ? "text-amber-600" :
|
||||
signal === "closed" ? "text-slate-400" : "text-red-500"
|
||||
)}>
|
||||
{getSignalLabel(signal, isEn)}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
需要在本文件顶部新增 `import { Fragment, ... }` from react。
|
||||
|
||||
- [ ] **Step 2: 更新 import 语句 (文件第 1 行)**
|
||||
|
||||
将 `import { useEffect, useMemo, useState } from "react";` 改为:
|
||||
```tsx
|
||||
import { Fragment, useEffect, useMemo, useState } from "react";
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 验证 TypeScript**
|
||||
|
||||
```bash
|
||||
cd frontend && npx tsc --noEmit 2>&1 | head -30
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 提交**
|
||||
|
||||
```bash
|
||||
git add frontend/components/dashboard/ScanTerminalDashboard.tsx
|
||||
git commit -m "终端表格重构为9列时区分组布局:新增 City/Obs/High/DEB/Gap/Market/Edge/SprLiq/Signal 列"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: 重构 KoyfinWeatherTerminal — 接入分组逻辑
|
||||
|
||||
**文件:**
|
||||
- 修改: `frontend/components/dashboard/ScanTerminalDashboard.tsx` (修改 `KoyfinWeatherTerminal`)
|
||||
|
||||
在 `KoyfinWeatherTerminal` 中引入 `buildContinentGroups`,将左列 `MarketTable` 替换为 `GroupedMarketTable`,中列 `MarketTable` 替换为 `GroupedMarketTable`,右列 Watchlist 保留列表展示。
|
||||
|
||||
- [ ] **Step 1: 修改 KoyfinWeatherTerminal 中的左列表格部分 (第 460-491 行)**
|
||||
|
||||
找到左列 `<Panel title="Weather Contracts">` 内的 `<MarketTable>`,替换为:
|
||||
|
||||
```tsx
|
||||
<Panel title={isEn ? "Weather Contracts" : "天气合约"}>
|
||||
<div className="grid grid-cols-3 border-b border-slate-200 text-center">
|
||||
<div className="p-3">
|
||||
<div className="text-[11px] font-black uppercase text-slate-500">
|
||||
{isEn ? "Rows" : "行数"}
|
||||
</div>
|
||||
<div className="font-mono text-xl font-black">{rows.length}</div>
|
||||
</div>
|
||||
<div className="border-x border-slate-200 p-3">
|
||||
<div className="text-[11px] font-black uppercase text-slate-500">
|
||||
Avg Edge
|
||||
</div>
|
||||
<div className={clsx("font-mono text-xl font-black", edgeClass(avgEdge))}>
|
||||
{pct(avgEdge)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3">
|
||||
<div className="text-[11px] font-black uppercase text-slate-500">
|
||||
Liquidity
|
||||
</div>
|
||||
<div className="font-mono text-xl font-black">
|
||||
{money(totalLiquidity)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<GroupedMarketTable
|
||||
groups={continentGroups}
|
||||
isEn={isEn}
|
||||
selectedId={selectedRow?.id}
|
||||
onSelect={setSelectedRow}
|
||||
/>
|
||||
</Panel>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 替换中列 MarketTable (第 600-606 行)**
|
||||
|
||||
```tsx
|
||||
<Panel title={isEn ? "All Contracts" : "全部合约"}>
|
||||
<GroupedMarketTable
|
||||
groups={continentGroups}
|
||||
isEn={isEn}
|
||||
selectedId={selectedRow?.id}
|
||||
onSelect={setSelectedRow}
|
||||
/>
|
||||
</Panel>
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 在 KoyfinWeatherTerminal 中计算 continentGroups**
|
||||
|
||||
在组件内部,紧接 `const selectedLabel = ...` 之后添加:
|
||||
|
||||
```tsx
|
||||
const continentGroups = useMemo(
|
||||
() => buildContinentGroups(rows, isEn),
|
||||
[rows, isEn]
|
||||
);
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 提交**
|
||||
|
||||
```bash
|
||||
git add frontend/components/dashboard/ScanTerminalDashboard.tsx
|
||||
git commit -m "终端三列布局接入大洲分组数据"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: 实现移动端响应式布局 — Tab + 卡片流
|
||||
|
||||
**文件:**
|
||||
- 修改: `frontend/components/dashboard/ScanTerminalDashboard.tsx` (修改 `KoyfinWeatherTerminal` main 区域)
|
||||
|
||||
在 `<main>` 内,用 `useMediaQuery` 或 Tailwind 响应式类区分桌面/移动端布局。移动端显示 `MobileRegionTabs` + `MobileCityCard` 列表。
|
||||
|
||||
- [ ] **Step 1: 在 KoyfinWeatherTerminal 中添加移动端状态和渲染**
|
||||
|
||||
在组件顶部添加:
|
||||
```tsx
|
||||
const [mobileTab, setMobileTab] = useState<string>("active_signals");
|
||||
```
|
||||
|
||||
在 `continentGroups` 计算后添加:
|
||||
```tsx
|
||||
const mobileActiveGroup = useMemo(
|
||||
() => continentGroups.find((g) => g.key === mobileTab) || continentGroups[0],
|
||||
[continentGroups, mobileTab]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (continentGroups.length > 0 && !continentGroups.find((g) => g.key === mobileTab)) {
|
||||
setMobileTab(continentGroups[0].key);
|
||||
}
|
||||
}, [continentGroups, mobileTab]);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 修改 main 区域为响应式双布局 (第 458-673 行)**
|
||||
|
||||
在 `<main>` 中包裹条件渲染:
|
||||
|
||||
```tsx
|
||||
<main className="min-h-0 flex-1 overflow-auto p-2">
|
||||
{/* Mobile layout */}
|
||||
<div className="flex flex-col gap-2 lg:hidden">
|
||||
<MobileRegionTabs
|
||||
activeTab={mobileTab}
|
||||
groups={continentGroups}
|
||||
isEn={isEn}
|
||||
onSelectTab={setMobileTab}
|
||||
/>
|
||||
<div className="space-y-2 px-1">
|
||||
{mobileActiveGroup?.rows.map((row) => (
|
||||
<MobileCityCard
|
||||
key={row.id}
|
||||
row={row}
|
||||
isEn={isEn}
|
||||
onClick={setSelectedRow}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop layout (existing 3-column grid) */}
|
||||
<div className="hidden min-h-full grid-cols-1 gap-2 lg:grid xl:grid-cols-[1.12fr_1.6fr_1.1fr]">
|
||||
{/* ... existing 3 columns unchanged ... */}
|
||||
</div>
|
||||
</main>
|
||||
```
|
||||
|
||||
现有的三列布局代码放入 `{/* Desktop layout */}` 块中,用 `hidden lg:grid` 控制显示。
|
||||
|
||||
- [ ] **Step 3: 验证 TypeScript**
|
||||
|
||||
```bash
|
||||
cd frontend && npx tsc --noEmit 2>&1 | head -40
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 提交**
|
||||
|
||||
```bash
|
||||
git add frontend/components/dashboard/ScanTerminalDashboard.tsx
|
||||
git commit -m "终端新增移动端 Tab+卡片流响应式布局"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: 添加 CSS 样式 — 分组行和移动端卡片
|
||||
|
||||
**文件:**
|
||||
- 创建: `frontend/components/dashboard/ScanTerminalContinent.module.css`
|
||||
- 修改: `frontend/components/dashboard/scan-root-styles.ts`
|
||||
|
||||
- [ ] **Step 1: 写入 CSS Module**
|
||||
|
||||
```css
|
||||
/* ScanTerminalContinent.module.css */
|
||||
|
||||
.root {
|
||||
--gap-green: #16a34a;
|
||||
--gap-orange: #ea580c;
|
||||
--gap-slate: #475569;
|
||||
--gap-gray: #94a3b8;
|
||||
--gap-red: #dc2626;
|
||||
}
|
||||
|
||||
/* Group header */
|
||||
.groupHeader {
|
||||
background: #eef2f6;
|
||||
border-bottom: 1px solid #cbd5e1;
|
||||
}
|
||||
.groupHeader:hover {
|
||||
background: #e2e8f0;
|
||||
}
|
||||
|
||||
/* Mobile: hide scrollbar on tab bar */
|
||||
.mobileTabs {
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
.mobileTabs::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Mobile card */
|
||||
.mobileCard {
|
||||
border: 1px solid #e2e8f0;
|
||||
background: #ffffff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
.mobileCard:hover {
|
||||
background: #f0f7ff;
|
||||
}
|
||||
|
||||
/* Signal badge */
|
||||
.signalActive {
|
||||
color: #059669;
|
||||
}
|
||||
.signalWatch {
|
||||
color: #d97706;
|
||||
}
|
||||
.signalClosed {
|
||||
color: #94a3b8;
|
||||
}
|
||||
.signalData {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
/* Light theme */
|
||||
:global(html.light) .groupHeader {
|
||||
background: #f8fafc;
|
||||
border-bottom-color: #e2e8f0;
|
||||
}
|
||||
:global(html.light) .groupHeader:hover {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
:global(html.light) .mobileCard {
|
||||
background: #ffffff;
|
||||
border-color: #e2e8f0;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 注册到 scan-root-styles.ts barrel**
|
||||
|
||||
读取 `frontend/components/dashboard/scan-root-styles.ts`,追加:
|
||||
```typescript
|
||||
import sContinent from "@/components/dashboard/ScanTerminalContinent.module.css";
|
||||
// ... add sContinent.root to the barrel export
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 提交**
|
||||
|
||||
```bash
|
||||
git add frontend/components/dashboard/ScanTerminalContinent.module.css frontend/components/dashboard/scan-root-styles.ts
|
||||
git commit -m "新增终端大洲分组与移动端卡片样式"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 9: 最终验证与构建
|
||||
|
||||
- [ ] **Step 1: TypeScript 类型检查**
|
||||
|
||||
```bash
|
||||
cd frontend && npx tsc --noEmit
|
||||
```
|
||||
预期:0 errors。
|
||||
|
||||
- [ ] **Step 2: 生产构建**
|
||||
|
||||
```bash
|
||||
cd frontend && npm run build
|
||||
```
|
||||
预期:✓ Compiled successfully,66 pages generated。
|
||||
|
||||
- [ ] **Step 3: 启动 dev server 验证 UI**
|
||||
|
||||
```bash
|
||||
cd frontend && npm run dev
|
||||
```
|
||||
|
||||
打开 http://localhost:3000/terminal 验证:
|
||||
- 桌面端:Active Signals 展开显示,时区分组折叠/展开,9 列表格
|
||||
- 移动端:Tab 切换流畅,卡片流显示正常
|
||||
- 双主题切换无样式错误
|
||||
|
||||
- [ ] **Step 4: 提交**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "终端大洲分组功能验证通过"
|
||||
```
|
||||
@@ -1,26 +0,0 @@
|
||||
# Live Temperature Chart Split Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Split `LiveTemperatureThresholdChart.tsx` so chart data generation and SSE patch merge logic live in focused pure modules, while the visible chart behavior stays unchanged.
|
||||
|
||||
**Architecture:** Keep the React component as the orchestration/rendering layer. Move types, constants, time helpers, series builders, fallback rules, and patch merge into adjacent modules under `frontend/components/dashboard/scan-terminal/`. Preserve existing test exports through the component file during the first split to avoid changing test callers.
|
||||
|
||||
**Tech Stack:** Next.js/React, TypeScript, Recharts, existing business-state test runner.
|
||||
|
||||
---
|
||||
|
||||
## Files
|
||||
|
||||
- Create: `frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts`
|
||||
- Modify: `frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx`
|
||||
- Modify: `frontend/components/dashboard/scan-terminal/__tests__/ssePatchArchitecture.test.ts`
|
||||
- Verify: `frontend/components/dashboard/scan-terminal/__tests__/temperatureDefaultVisibilityPolicy.test.ts`
|
||||
|
||||
## Tasks
|
||||
|
||||
- [ ] Add a failing architecture test that rejects keeping all chart data builders inside `LiveTemperatureThresholdChart.tsx`.
|
||||
- [ ] Extract pure chart logic into `temperature-chart-logic.ts`.
|
||||
- [ ] Import extracted functions/types back into `LiveTemperatureThresholdChart.tsx`.
|
||||
- [ ] Keep the current test-only exports stable from `LiveTemperatureThresholdChart.tsx`.
|
||||
- [ ] Run `npm run test:business`, `npm run typecheck`, and `npm run build`.
|
||||
@@ -1,71 +0,0 @@
|
||||
# Production Realtime SSE Patch Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Upgrade PolyWeather terminal charts from in-process best-effort SSE patches to a replayable, city-scoped, versioned realtime observation stream for PM highest-temperature prediction workflows.
|
||||
|
||||
**Architecture:** Keep HTTP APIs as the full snapshot/source-of-truth layer. Add a short-window SQLite event log for SSE replay, version observations as `city_observation_patch.v1`, fan out live events through the existing SSE manager, and let the frontend subscribe only to visible cities with `since_revision` reconnect replay.
|
||||
|
||||
**Tech Stack:** FastAPI, SQLite/WAL via `DBManager`, in-process `asyncio.Queue` SSE fanout, React/Next.js `EventSource`, TypeScript external store hooks.
|
||||
|
||||
---
|
||||
|
||||
## Constraints
|
||||
|
||||
- Default replay retention is 6 hours because this event log is not the business history store.
|
||||
- The retention can be raised with `POLYWEATHER_PATCH_EVENT_RETENTION_HOURS`, but the product does not need all-day patch retention.
|
||||
- First production step is SQLite-only; Redis/Postgres pub/sub remains a later multi-instance extension.
|
||||
- Existing legacy `city_patch` ingest and frontend handling must keep working during rollout.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [ ] Backend schema tests
|
||||
- Add tests proving legacy collector payloads normalize to `city_observation_patch.v1`.
|
||||
- Cover runway point conversion from `amos.runway_obs.point_temperatures`.
|
||||
- Cover invalid payload rejection when city and useful observation data are missing.
|
||||
|
||||
- [ ] Backend schema implementation
|
||||
- Add `web/realtime_patch_schema.py`.
|
||||
- Normalize city/source/obs time/temp/max/runway payload fields.
|
||||
- Keep payload small and JSON-serializable.
|
||||
|
||||
- [ ] Event store tests
|
||||
- Add tests for monotonic SQLite revisions.
|
||||
- Add city-filtered replay tests.
|
||||
- Add retention cleanup tests for stale replay rows.
|
||||
|
||||
- [ ] Event store implementation
|
||||
- Add `observation_patch_events` table and indexes in `DBManager`.
|
||||
- Add `web/realtime_event_store.py` for append, replay, latest revision, and cleanup.
|
||||
- Use `POLYWEATHER_PATCH_EVENT_RETENTION_HOURS=6` as the default.
|
||||
|
||||
- [ ] SSE replay tests
|
||||
- Add tests for `/api/events?cities=...&since_revision=...`.
|
||||
- Verify replay only returns subscribed cities.
|
||||
- Verify replay over limit emits `resync_required`.
|
||||
|
||||
- [ ] SSE implementation
|
||||
- Update router to parse `cities`, `since_revision`, and bounded `replay_limit`.
|
||||
- Write normalized events to SQLite before broadcasting.
|
||||
- Update manager to track per-connection city subscriptions while keeping heartbeat behavior.
|
||||
|
||||
- [ ] Frontend SSE tests
|
||||
- Extend architecture tests for v1 schema, `cities`, `since_revision`, replay/resync handling.
|
||||
- Add chart merge coverage for v1 runway point payloads.
|
||||
|
||||
- [ ] Frontend SSE implementation
|
||||
- Update `use-sse-patches.ts` to normalize v1 and legacy patch events.
|
||||
- Track global `lastRevision`.
|
||||
- Reconnect with visible-city `cities` and `since_revision`.
|
||||
- Expose a resync signal for charts when the server cannot replay.
|
||||
|
||||
- [ ] Chart/list integration
|
||||
- Ensure visible charts register their city subscription.
|
||||
- Keep terminal row patching lightweight: current temp, current max, local time, revision.
|
||||
- Append v1 temp/runway points into existing chart series without forcing full-detail polling.
|
||||
|
||||
- [ ] Verification
|
||||
- Run targeted backend pytest files.
|
||||
- Run `npm run test:business`.
|
||||
- Run `npm run typecheck`.
|
||||
- Run `npm run build`.
|
||||
@@ -1,137 +0,0 @@
|
||||
# 终端城市表格按大洲分组 — 设计文档
|
||||
|
||||
日期:2026-05-25
|
||||
状态:已确认
|
||||
|
||||
## 目标
|
||||
|
||||
将扫描终端(Scan Terminal)的城市列表从平铺表格改造为按时区分组、按交易信号优先的金融终端风格布局。
|
||||
|
||||
## 数据
|
||||
|
||||
所有字段已在 `/api/scan/terminal` 响应的 `ScanOpportunityRow` 中就绪,无需后端改动。
|
||||
|
||||
### 列 → 字段映射
|
||||
|
||||
| 列名 | 字段 | 类型 |
|
||||
|------|------|------|
|
||||
| City | `city`, `airport`, `local_time` | `string` |
|
||||
| Obs | `current_temp` | `number \| null` |
|
||||
| High | `current_max_so_far` | `number \| null` |
|
||||
| DEB | `deb_prediction` | `number \| null` |
|
||||
| Gap | `signed_gap` / `gap_to_target` | `number \| null` |
|
||||
| Market | `midpoint`, `bid`, `ask` | `number \| null` |
|
||||
| Edge | `edge_percent` | `number \| null` |
|
||||
| Spr/Liq | `spread`, `book_liquidity` | `number \| null` |
|
||||
| Signal | `signal_status`, `ai_decision` | `string \| null` |
|
||||
|
||||
## 时区分组
|
||||
|
||||
后端 `scan_terminal_filters.py` 按 UTC 偏移量划分 7 个交易区域:
|
||||
|
||||
| 排序 | 区域键 | 英文 | 中文 | UTC 条件 |
|
||||
|------|--------|------|------|----------|
|
||||
| 1 | east_asia | East Asia | 东亚 | >= +8 |
|
||||
| 2 | southeast_asia | Southeast Asia | 东南亚 | >= +7 |
|
||||
| 3 | central_asia | Central / South Asia | 中亚/南亚 | >= +5 |
|
||||
| 4 | west_asia | West Asia / Middle East | 西亚/中东 | >= +3 |
|
||||
| 5 | europe_africa | Europe / Africa | 欧洲/非洲 | >= 0 |
|
||||
| 6 | south_america | Latin America | 拉美 | >= -5 |
|
||||
| 7 | north_america | North America | 北美 | < -5 |
|
||||
|
||||
## 桌面端布局(≥ 768px)
|
||||
|
||||
### 虚拟分组:"Active Signals"
|
||||
|
||||
在 7 个时区分组上方插入一个虚拟分组,聚合当前最重要的信号:
|
||||
|
||||
- 筛选条件:`ai_decision === "approve"`、`tradable === true`、`peak`、`rising`、`超预期`
|
||||
- 标题行显示:城市数量、Hot 城市、更新时间
|
||||
- 格式:`▼ Active Signals 活跃信号 6 · Hot: Tokyo · Updated 14:32`
|
||||
|
||||
### 时区分组标题行
|
||||
|
||||
- 显示:折叠箭头、中英文名、城市数、active 数、watch 数、本地时间
|
||||
- 热门城市标注
|
||||
- 格式:`▼ East Asia 东亚 5 · Active 2 · Watch 1 · LT 14:20`
|
||||
- 可折叠,默认规则:
|
||||
- Active Signals 始终展开
|
||||
- 包含 active/watch 的时区展开
|
||||
- 无机会的时区折叠
|
||||
|
||||
### 表格行
|
||||
|
||||
- 9 列:City | Obs | High | DEB | Gap | Market | Edge | Spr/Liq | Signal
|
||||
- Market 列显示价格格式:`Y 72¢` / `N 28¢`
|
||||
- Signal 列显示:`◆ Active` / `● Watch` / `○ Closed` (文字+图标)
|
||||
|
||||
### Gap 列颜色语义
|
||||
|
||||
| 状态 | 颜色 | 含义 |
|
||||
|------|------|------|
|
||||
| 已突破/超预期 | 绿色 `#22c55e` | 对市场方向有利 |
|
||||
| 接近目标未突破 | 橙色 `#f59e0b` | Peak Watch |
|
||||
| 明显低于目标 | 蓝灰 `#64748b` | 尚不到位 |
|
||||
| 追不上/时间不够 | 灰色 `#94a3b8` | 机会减弱 |
|
||||
| 数据异常/风险 | 红色 `#ef4444` | 需关注 |
|
||||
|
||||
## 移动端布局(< 768px)
|
||||
|
||||
### Tab 栏
|
||||
|
||||
横向滚动,包含:`All | Active | 东亚 | 东南亚 | 中亚南亚 | 西亚 | 欧洲非洲 | 北美 | 拉美`
|
||||
|
||||
当前时区 Tab 默认选中。
|
||||
|
||||
### 信号卡片流
|
||||
|
||||
每张卡片两行信息:
|
||||
|
||||
```
|
||||
┌──────────────────────────────┐
|
||||
│ Tokyo · ◆ Active │
|
||||
│ Obs 28.1°C · Gap -0.8°C · Y 72¢ │
|
||||
│ High 29.3°C · DEB 30.0°C │
|
||||
└──────────────────────────────┘
|
||||
```
|
||||
|
||||
卡片字段:City、Signal、Obs、Gap、Market(第一行),High、DEB(第二行)
|
||||
|
||||
## 文件变更范围
|
||||
|
||||
### 主要文件
|
||||
|
||||
| 文件 | 变更 | 说明 |
|
||||
|------|------|------|
|
||||
| `components/dashboard/ScanTerminalDashboard.tsx` | 重构 | 拆分 `MarketTable`、`KoyfinWeatherTerminal`,新增分组和卡片组件 |
|
||||
| `components/dashboard/scan-terminal/` | 新增 | `ContinentGroupHeader.tsx`、`ActiveSignalsGroup.tsx`、`MobileCityCard.tsx`、`MobileRegionTabs.tsx` |
|
||||
| `components/dashboard/scan-terminal/continent-grouping.ts` | 新增 | 分组逻辑:按 `trading_region` 分桶、Active Signals 筛选、折叠状态管理 |
|
||||
| `components/dashboard/scan-terminal/decision-utils.ts` | 修改 | 补充排序逻辑,Active Signals 置顶 |
|
||||
| `components/dashboard/ScanTerminalDashboard.module.css` | 修改 | 新增分组标题行、卡片流、Tab 栏样式 |
|
||||
| `components/dashboard/ScanTerminalLightTheme.module.css` | 新增/修改 | 浅色主题适配 |
|
||||
|
||||
### 不改的文件
|
||||
|
||||
- 后端 API:所有字段已就绪
|
||||
- `ScanOpportunityRow` 类型定义:字段充足
|
||||
- 数据获取 hooks:`useScanTerminalQuery` 保持不变
|
||||
|
||||
## 信号状态枚举
|
||||
|
||||
```
|
||||
◆ Active — approve / tradable + active
|
||||
● Watch — watch / monitor / peak_watch
|
||||
○ Closed — veto / closed / inactive
|
||||
! Data — 数据异常/缺失/延迟
|
||||
```
|
||||
|
||||
## 验收标准
|
||||
|
||||
1. 桌面端:7 时区 + Active Signals 分组,可折叠,9 列表格
|
||||
2. 移动端:Tab 切换 + 卡片流
|
||||
3. Gap 颜色按语义映射,非简单正负色
|
||||
4. Market 列显示价格格式(Y XX¢)
|
||||
5. 默认折叠规则:Active Signals 展开、有机遇展开、无机遇折叠
|
||||
6. 双主题(dark/light)CSS 适配
|
||||
7. TypeScript 类型检查通过
|
||||
8. `npm run build` 通过
|
||||
@@ -1,319 +0,0 @@
|
||||
# Production Realtime SSE Patch Design
|
||||
|
||||
> 日期: 2026-05-26
|
||||
> 范围: PolyWeather 网站终端图表的生产级实时观测增量层
|
||||
> 目标服务器: 2 vCPU / 8 GB RAM / 50 GB 系统盘
|
||||
|
||||
## 背景
|
||||
|
||||
当前网站已经有 SSE patch 通道,但它更像一个进程内即时广播层:
|
||||
|
||||
- 后端 `web/sse_manager.py` 用进程内 `asyncio.Queue` 管连接。
|
||||
- 采集器通过 `/api/internal/collector-patch` 推送城市温度变化。
|
||||
- 前端 `use-sse-patches.ts` 用 `EventSource` 接收 patch,并把温度合并到终端列表和图表。
|
||||
|
||||
这个结构能做轻量实时更新,但还不是生产级“像股票行情一样”的实时架构。主要缺口是:
|
||||
|
||||
- 断线后没有 replay event log。
|
||||
- 连接不能按城市订阅,只能广播后让前端筛选。
|
||||
- patch schema 没有版本化,后续扩展跑道、站点、最高温字段风险较高。
|
||||
- 多实例共享 pub/sub 还没有抽象边界。
|
||||
|
||||
## 目标
|
||||
|
||||
把当前实时层升级为“HTTP 快照基线 + SSE 增量观测事件 + 可重放事件日志”的架构:
|
||||
|
||||
- 页面首屏仍通过 HTTP snapshot 加载完整 terminal rows / city detail。
|
||||
- 实测温度、站点观测、跑道观测通过 SSE 增量追加到图表。
|
||||
- 浏览器断线重连后可以通过 `since_revision` 补发错过的事件。
|
||||
- 前端只订阅当前可见城市,例如 2x2 图表中的四个城市。
|
||||
- patch payload 明确使用 `city_observation_patch.v1` schema。
|
||||
- 第一版用 SQLite event log 适配当前服务器规格,预留 Redis/Postgres event bus 接口。
|
||||
|
||||
## 非目标
|
||||
|
||||
第一版不做这些事:
|
||||
|
||||
- 不把全部 terminal state 改成 event sourcing。
|
||||
- 不用 SSE 取代 `/api/scan/terminal` 或 `/api/city/{name}/detail`。
|
||||
- 不在第一版强依赖 Redis。
|
||||
- 不把 DEB、多模型、概率分布改成分钟级实时更新。
|
||||
- 不做 WebSocket;继续使用 SSE,因为浏览器端简单、代理友好、适合单向行情流。
|
||||
|
||||
## 推荐架构
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Collector["WeatherDataCollector"] --> Ingest["POST /api/internal/collector-patch"]
|
||||
Ingest --> Normalize["Patch schema normalizer"]
|
||||
Normalize --> Log["SQLite observation_patch_events"]
|
||||
Normalize --> Bus["PatchEventBus"]
|
||||
Bus --> SSE["GET /api/events?cities=...&since_revision=..."]
|
||||
API["HTTP snapshot APIs"] --> FE["Frontend terminal/chart state"]
|
||||
SSE --> FE
|
||||
FE -->|gap / stale / reconnect| API
|
||||
```
|
||||
|
||||
## 事件存储
|
||||
|
||||
新增 SQLite 表 `observation_patch_events`,由 `DBManager` 初始化。
|
||||
|
||||
字段:
|
||||
|
||||
- `revision INTEGER PRIMARY KEY AUTOINCREMENT`
|
||||
- `schema_type TEXT NOT NULL`
|
||||
- `schema_version INTEGER NOT NULL`
|
||||
- `city TEXT NOT NULL`
|
||||
- `source TEXT NOT NULL`
|
||||
- `obs_time TEXT`
|
||||
- `payload_json TEXT NOT NULL`
|
||||
- `created_at TEXT NOT NULL`
|
||||
|
||||
索引:
|
||||
|
||||
- `idx_observation_patch_events_city_revision(city, revision)`
|
||||
- `idx_observation_patch_events_created_at(created_at)`
|
||||
|
||||
保留策略:
|
||||
|
||||
- 默认保留 6 小时。
|
||||
- event log 只用于 SSE 断线 replay,不作为日内历史曲线归档。
|
||||
- 完整日内曲线仍由 `/api/city/{name}/detail` 从现有观测存储和 city cache 构建。
|
||||
- 6 小时窗口覆盖浏览器短线重连、页面休眠恢复和用户午后交易窗口内的 replay;超过窗口直接触发 HTTP resync。
|
||||
- 每次写入后低频触发清理,避免每条事件都扫表。
|
||||
- 环境变量:`POLYWEATHER_PATCH_EVENT_RETENTION_HOURS=6`。
|
||||
|
||||
## Patch Schema v1
|
||||
|
||||
事件类型固定为:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "city_observation_patch.v1",
|
||||
"revision": 12345,
|
||||
"city": "shanghai",
|
||||
"source": "amsc_awos",
|
||||
"obs_time": "2026-05-26T10:02:00Z",
|
||||
"ts": 1780000000000,
|
||||
"payload": {
|
||||
"temp": 30.4,
|
||||
"max_so_far": 31.0,
|
||||
"station_code": "ZSPD",
|
||||
"station_label": "Shanghai Pudong",
|
||||
"series_key": "airport_primary",
|
||||
"unit": "celsius",
|
||||
"runway_points": [
|
||||
{
|
||||
"runway": "17L/35R",
|
||||
"temp": 30.8,
|
||||
"tdz_temp": 30.5,
|
||||
"mid_temp": 30.6,
|
||||
"end_temp": 30.8,
|
||||
"is_settlement": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
兼容要求:
|
||||
|
||||
- 前端仍接受旧 `city_patch` 一段时间,避免部署顺序问题。
|
||||
- 新事件统一转换成前端内部 `CityPatch`。
|
||||
- 后端 ingest 接受旧字段 `changes`,但写入 event log 时规范化成 v1。
|
||||
|
||||
## SSE API
|
||||
|
||||
`GET /api/events`
|
||||
|
||||
查询参数:
|
||||
|
||||
- `cities`: 逗号分隔城市 key,例如 `shanghai,hong kong,taipei`
|
||||
- `since_revision`: 客户端上次处理过的最大 revision
|
||||
- `replay_limit`: 最大补发数量,默认 500,上限 2000
|
||||
|
||||
连接行为:
|
||||
|
||||
1. 建立连接。
|
||||
2. 返回 `connected` event,包含当前 server revision。
|
||||
3. 如果传入 `since_revision`,先从 SQLite 补发匹配城市的历史事件。
|
||||
4. 进入 live stream,只推送匹配城市的事件。
|
||||
5. 每 30 秒发送 heartbeat。
|
||||
|
||||
gap 处理:
|
||||
|
||||
- 如果 `since_revision` 太旧,超过保留窗口或补发上限,服务端发送:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "resync_required",
|
||||
"reason": "replay_window_exceeded",
|
||||
"latest_revision": 12345
|
||||
}
|
||||
```
|
||||
|
||||
- 前端收到后对当前可见城市调用 HTTP full detail resync。
|
||||
|
||||
## 后端模块边界
|
||||
|
||||
新增或调整模块:
|
||||
|
||||
- `web/realtime_patch_schema.py`
|
||||
- 负责规范化旧 `city_patch` 输入为 `city_observation_patch.v1`。
|
||||
- 负责校验城市、温度、时间、source。
|
||||
|
||||
- `web/realtime_event_store.py`
|
||||
- 封装 SQLite event log 写入、查询、保留清理。
|
||||
- 使用 `DBManager` 的 DB path。
|
||||
|
||||
- `web/sse_manager.py`
|
||||
- 从纯进程队列升级为基于 `PatchEventBus` 的连接管理。
|
||||
- 保留进程内 live fanout。
|
||||
- 每个连接记录订阅城市集合。
|
||||
|
||||
- `web/routers/sse_router.py`
|
||||
- 解析 `cities`、`since_revision`、`replay_limit`。
|
||||
- ingest 后先写 event log,再广播 live event。
|
||||
|
||||
第一版 `PatchEventBus`:
|
||||
|
||||
- `SQLitePatchEventBus`
|
||||
- 写入 SQLite event log。
|
||||
- 进程内广播给当前 worker 的连接。
|
||||
|
||||
后续可新增:
|
||||
|
||||
- `RedisPatchEventBus`
|
||||
- `PostgresPatchEventBus`
|
||||
|
||||
## 前端模块边界
|
||||
|
||||
调整:
|
||||
|
||||
- `frontend/hooks/use-sse-patches.ts`
|
||||
- 支持 `city_observation_patch.v1`。
|
||||
- 记录 `lastRevision`。
|
||||
- 支持按可见城市集合建立 SSE URL。
|
||||
- 收到 `resync_required` 时通知图表重拉。
|
||||
|
||||
- `frontend/components/dashboard/scan-terminal/use-scan-terminal-query.ts`
|
||||
- 继续负责 terminal rows 的 HTTP snapshot。
|
||||
- 消费 patch 时只更新轻量字段:当前温度、最高温、观测时间。
|
||||
|
||||
- `frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx`
|
||||
- 消费 patch 时追加分钟级观测点。
|
||||
- 对 runway patch 更新对应 runway series。
|
||||
- resync required 或长时间无 patch 时重拉 `/api/city/{name}/detail`。
|
||||
|
||||
城市订阅策略:
|
||||
|
||||
- 2x2 / 3x3 图表只订阅当前 slots 中的城市。
|
||||
- 移动端只订阅当前主图城市。
|
||||
- 切换布局或城市后重建 SSE 连接。
|
||||
|
||||
## 数据流
|
||||
|
||||
### 首屏加载
|
||||
|
||||
1. 前端调用 `/api/scan/terminal` 得到 terminal rows。
|
||||
2. 每个可见图表调用 `/api/city/{name}/detail` 得到完整基线曲线。
|
||||
3. 前端用可见城市列表连接 `/api/events?cities=...&since_revision=...`。
|
||||
|
||||
### 新观测进入
|
||||
|
||||
1. `WeatherDataCollector` 发现温度或跑道观测变化。
|
||||
2. POST `/api/internal/collector-patch`。
|
||||
3. 后端规范化为 `city_observation_patch.v1`。
|
||||
4. 写入 SQLite event log,生成 revision。
|
||||
5. 广播给订阅该城市的 SSE 连接。
|
||||
6. 前端追加图表点并更新 terminal row。
|
||||
|
||||
### 断线重连
|
||||
|
||||
1. 前端保留 `lastRevision`。
|
||||
2. EventSource 断线后指数退避重连。
|
||||
3. 重连 URL 带 `since_revision=lastRevision`。
|
||||
4. 后端 replay missed events。
|
||||
5. 如果 replay 不完整,返回 `resync_required`,前端 HTTP 重拉。
|
||||
|
||||
## 错误处理
|
||||
|
||||
- ingest payload 无 city 或无有效 temp/runway point:返回 400。
|
||||
- event log 写入失败:返回 500,不广播,避免前端看到不可 replay 的事件。
|
||||
- SSE replay 查询失败:发送 `resync_required`,然后继续 heartbeat/live stream。
|
||||
- 前端收到未知 schema:忽略并记录 debug log。
|
||||
- 前端 revision 倒退:忽略。
|
||||
- 前端 revision 跳号:标记 gap,并触发 HTTP resync。
|
||||
|
||||
## 测试策略
|
||||
|
||||
后端:
|
||||
|
||||
- `tests/test_realtime_patch_schema.py`
|
||||
- 旧 `city_patch` 输入能规范化成 `city_observation_patch.v1`。
|
||||
- 跑道 payload 保留 runway point。
|
||||
- 无效 city/temp 返回校验失败。
|
||||
|
||||
- `tests/test_realtime_event_store.py`
|
||||
- append event 生成单调 revision。
|
||||
- replay 按 city 过滤。
|
||||
- retention cleanup 删除旧事件。
|
||||
|
||||
- `tests/test_sse_replay.py`
|
||||
- `/api/events?cities=...&since_revision=...` 只 replay 匹配城市。
|
||||
- replay window 超限返回 `resync_required`。
|
||||
|
||||
前端:
|
||||
|
||||
- `frontend/components/dashboard/scan-terminal/__tests__/ssePatchArchitecture.test.ts`
|
||||
- 确认 v1 schema、city subscription、since_revision URL 存在。
|
||||
|
||||
- `frontend/components/dashboard/scan-terminal/__tests__/temperatureDefaultVisibilityPolicy.test.ts`
|
||||
- 确认 patch 追加分钟级观测点。
|
||||
- 确认 runway patch 更新对应 runway series。
|
||||
|
||||
验收命令:
|
||||
|
||||
```powershell
|
||||
pytest tests/test_realtime_patch_schema.py tests/test_realtime_event_store.py tests/test_sse_replay.py
|
||||
cd frontend
|
||||
npm run test:business
|
||||
npm run typecheck
|
||||
npm run build
|
||||
```
|
||||
|
||||
## 部署与容量
|
||||
|
||||
当前服务器 2 vCPU / 8 GB / 50 GB 足够第一版 SQLite-first:
|
||||
|
||||
- 30 个高频城市,每分钟 1 条事件,约 43,200 条/天。
|
||||
- 默认只保留 6 小时,约 10,800 条事件。
|
||||
- payload 控制在 1-3 KB,SQLite event log 体积预计维持在几十 MB 内。
|
||||
- PM 最高温预测市场不需要把当天所有 patch 都保留在 event log;超过 replay 窗口时用 HTTP detail 重建当前画面。
|
||||
|
||||
运行建议:
|
||||
|
||||
- SQLite 使用 WAL。
|
||||
- event log 与现有 `POLYWEATHER_DB_PATH` 同库。
|
||||
- 先保持单 backend worker,避免进程内 fanout 跨 worker 不一致。
|
||||
- 多实例或多 worker 时再启用 Redis bus。
|
||||
|
||||
## 后续 Redis 扩展
|
||||
|
||||
当部署变成多实例或多 worker 后,新增 `RedisPatchEventBus`:
|
||||
|
||||
- SQLite 仍作为 replay log。
|
||||
- Redis Pub/Sub 或 Redis Stream 负责跨实例 live fanout。
|
||||
- 每个 worker 从 Redis 订阅 live event,再投递给本进程 SSE 连接。
|
||||
|
||||
这个扩展不改变前端协议。
|
||||
|
||||
## 验收标准
|
||||
|
||||
- 页面加载后可见图表只订阅当前城市。
|
||||
- 高频城市新观测进入后,图表在无需手动刷新下追加新点。
|
||||
- 浏览器断线重连后,能补齐断线期间的事件。
|
||||
- replay 超窗时,前端自动 HTTP resync。
|
||||
- 未订阅城市的事件不会推送到该连接。
|
||||
- 旧 `city_patch` 输入在迁移期仍可用。
|
||||
- 所有新增行为有自动化测试覆盖。
|
||||
@@ -30,6 +30,10 @@ POLYWEATHER_AUTH_ENABLED=false
|
||||
# false: 登录可选,访客可浏览
|
||||
POLYWEATHER_AUTH_REQUIRED=false
|
||||
|
||||
# 可选:分享式看板访问令牌
|
||||
# 设置后,可通过 /?access_token=<token> 打开受保护看板
|
||||
POLYWEATHER_DASHBOARD_ACCESS_TOKEN=
|
||||
|
||||
# 可选:前端 API Route 转发到后端时附带的共享令牌
|
||||
# 仅当后端启用了 entitlement / 订阅校验时需要
|
||||
POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN=
|
||||
|
||||
+6
-10
@@ -9,30 +9,26 @@ POLYWEATHER_API_BASE_URL=http://127.0.0.1:8000
|
||||
# 在 Vercel 免费额度下建议配置为 VPS HTTPS 域名,让 AI / METAR / scan 等
|
||||
# 长耗时请求绕过 Vercel Functions / Fluid Compute。
|
||||
# 例如:https://api.example.com
|
||||
# 本地开发时注释掉,让 Next.js API Route 代理请求,避免 CORS 问题
|
||||
# NEXT_PUBLIC_POLYWEATHER_API_BASE_URL=http://38.54.27.70:8080
|
||||
NEXT_PUBLIC_POLYWEATHER_API_BASE_URL=
|
||||
|
||||
# 必填:Supabase 前端公钥(鉴权开启时必须)
|
||||
NEXT_PUBLIC_SUPABASE_URL=https://bttgfgupldyowkdhriqb.supabase.co
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=sb_publishable_1z0DR7nZ1Juf_HGTASA8WA_uxcHJnby
|
||||
NEXT_PUBLIC_SUPABASE_URL=
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=
|
||||
|
||||
# 必填:生产环境站点 URL(OAuth 回调强制使用此域名)
|
||||
# 设置后,所有登录回调将始终跳转到此域名,而非当前浏览器地址。
|
||||
# 生产环境必须设为 https://polyweather-pro.vercel.app
|
||||
NEXT_PUBLIC_SITE_URL=https://polyweather.top
|
||||
NEXT_PUBLIC_SITE_URL=https://polyweather-pro.vercel.app
|
||||
|
||||
# 常用:前端鉴权开关
|
||||
# true: 启用 Supabase 登录
|
||||
# false: 关闭登录能力,访客模式
|
||||
POLYWEATHER_AUTH_ENABLED=true
|
||||
POLYWEATHER_AUTH_ENABLED=false
|
||||
|
||||
# 常用:是否强制登录
|
||||
# true: middleware 强制登录后才能访问主页面
|
||||
# false: 登录可选,访客可浏览
|
||||
POLYWEATHER_AUTH_REQUIRED=true
|
||||
|
||||
# 关闭本地开发鉴权绕过
|
||||
NEXT_PUBLIC_POLYWEATHER_LOCAL_FULL_ACCESS=false
|
||||
POLYWEATHER_AUTH_REQUIRED=false
|
||||
|
||||
# 可选:分享式看板访问令牌
|
||||
# 设置后,可通过 /?access_token=<token> 打开受保护看板
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
NEXT_PUBLIC_SUPABASE_URL=https://bttgfgupldyowkdhriqb.supabase.co
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=sb_publishable_1z0DR7nZ1Juf_HGTASA8WA_uxcHJnby
|
||||
NEXT_PUBLIC_SITE_URL=https://polyweather.top
|
||||
NEXT_PUBLIC_POLYWEATHER_API_BASE_URL=https://api.polyweather.top
|
||||
NEXT_PUBLIC_POLYWEATHER_LOCAL_FULL_ACCESS=false
|
||||
@@ -1,36 +0,0 @@
|
||||
FROM node:20-alpine AS deps
|
||||
|
||||
WORKDIR /app/frontend
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
ARG NEXT_PUBLIC_SUPABASE_URL
|
||||
ARG NEXT_PUBLIC_SUPABASE_ANON_KEY
|
||||
ARG NEXT_PUBLIC_SITE_URL
|
||||
ARG NEXT_PUBLIC_POLYWEATHER_API_BASE_URL
|
||||
ARG NEXT_PUBLIC_POLYWEATHER_LOCAL_FULL_ACCESS
|
||||
|
||||
ENV NEXT_PUBLIC_SUPABASE_URL=$NEXT_PUBLIC_SUPABASE_URL
|
||||
ENV NEXT_PUBLIC_SUPABASE_ANON_KEY=$NEXT_PUBLIC_SUPABASE_ANON_KEY
|
||||
ENV NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL
|
||||
ENV NEXT_PUBLIC_POLYWEATHER_API_BASE_URL=$NEXT_PUBLIC_POLYWEATHER_API_BASE_URL
|
||||
ENV NEXT_PUBLIC_POLYWEATHER_LOCAL_FULL_ACCESS=$NEXT_PUBLIC_POLYWEATHER_LOCAL_FULL_ACCESS
|
||||
|
||||
WORKDIR /app/frontend
|
||||
COPY --from=deps /app/frontend/node_modules ./node_modules
|
||||
COPY . ./
|
||||
RUN npm run build && npm prune --omit=dev
|
||||
|
||||
FROM node:20-alpine AS runner
|
||||
|
||||
ENV NODE_ENV=production
|
||||
WORKDIR /app/frontend
|
||||
|
||||
COPY --from=builder /app/frontend/public ./public
|
||||
COPY --from=builder /app/frontend/.next/standalone ./
|
||||
COPY --from=builder /app/frontend/.next/static ./.next/static
|
||||
|
||||
EXPOSE 3000
|
||||
CMD ["node", "server.js"]
|
||||
@@ -2,18 +2,14 @@
|
||||
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { I18nProvider, useI18n } from "@/hooks/useI18n";
|
||||
|
||||
function AccountErrorContent({
|
||||
export default function AccountErrorPage({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
const { locale } = useI18n();
|
||||
const isEn = locale === "en-US";
|
||||
|
||||
useEffect(() => {
|
||||
console.error("Account page error:", error);
|
||||
}, [error]);
|
||||
@@ -42,7 +38,7 @@ function AccountErrorContent({
|
||||
color: "var(--color-accent-primary, #4DA3FF)",
|
||||
}}
|
||||
>
|
||||
{isEn ? "Account Page Error" : "账户页面出错"}
|
||||
账户页面出错
|
||||
</h1>
|
||||
<p
|
||||
style={{
|
||||
@@ -53,9 +49,21 @@ function AccountErrorContent({
|
||||
lineHeight: 1.7,
|
||||
}}
|
||||
>
|
||||
{isEn
|
||||
? "If this happened during payment or wallet binding, the most common cause is conflicting wallet extensions (e.g. MetaMask and Rabby open at the same time). Try disabling other wallet extensions and refresh."
|
||||
: "如果是在支付或绑定钱包时出现此问题,常见原因是钱包插件冲突(例如同时开启了 MetaMask 和 Rabby)。请尝试关闭其他钱包插件后刷新页面重试。"}
|
||||
如果是在支付或绑定钱包时出现此问题,常见原因是钱包插件冲突(例如同时开启了 MetaMask 和
|
||||
Rabby)。请尝试关闭其他钱包插件后刷新页面重试。
|
||||
</p>
|
||||
<p
|
||||
style={{
|
||||
color: "var(--color-text-muted, #7D8FA3)",
|
||||
fontSize: "0.8rem",
|
||||
margin: 0,
|
||||
maxWidth: 420,
|
||||
lineHeight: 1.6,
|
||||
}}
|
||||
>
|
||||
If this happened during payment or wallet binding, the most common cause is
|
||||
conflicting wallet extensions. Try disabling other wallet extensions (e.g.
|
||||
MetaMask + Rabby) and refresh.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
@@ -76,22 +84,8 @@ function AccountErrorContent({
|
||||
}}
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
{isEn ? "Retry" : "重试"}
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AccountErrorPage({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
return (
|
||||
<I18nProvider>
|
||||
<AccountErrorContent error={error} reset={reset} />
|
||||
</I18nProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,35 +1,9 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import {
|
||||
buildProxyExceptionResponse,
|
||||
buildUpstreamErrorResponse,
|
||||
} from "@/lib/api-proxy";
|
||||
import { buildCachedJsonResponse } from "@/lib/http-cache";
|
||||
import { proxyBackendJsonGet } from "@/lib/api-proxy";
|
||||
import { buildCityDetailProxyCachePolicy } from "@/lib/proxy-cache-policy";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
function normalizeCityDetailPayload(data: unknown) {
|
||||
if (!data || typeof data !== "object") return data;
|
||||
const payload = data as Record<string, any>;
|
||||
|
||||
// Backend v2 nests hourly under timeseries; chart expects it at top level.
|
||||
if (!payload.hourly && payload.timeseries?.hourly) {
|
||||
payload.hourly = payload.timeseries.hourly;
|
||||
}
|
||||
|
||||
if (!payload.market_scan && payload.market_scan_payload) {
|
||||
return {
|
||||
...payload,
|
||||
market_scan: payload.market_scan_payload,
|
||||
};
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
context: { params: Promise<{ name: string }> },
|
||||
@@ -48,7 +22,6 @@ export async function GET(
|
||||
const depth = req.nextUrl.searchParams.get("depth");
|
||||
const marketSlug = req.nextUrl.searchParams.get("market_slug");
|
||||
const targetDate = req.nextUrl.searchParams.get("target_date");
|
||||
const resolution = req.nextUrl.searchParams.get("resolution");
|
||||
const searchParams = new URLSearchParams({
|
||||
force_refresh: forceRefresh,
|
||||
});
|
||||
@@ -61,37 +34,14 @@ export async function GET(
|
||||
if (targetDate) {
|
||||
searchParams.set("target_date", targetDate);
|
||||
}
|
||||
if (resolution) {
|
||||
searchParams.set("resolution", resolution);
|
||||
}
|
||||
const url = `${API_BASE}/api/city/${encodeURIComponent(name)}/detail?${searchParams.toString()}`;
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req, {
|
||||
includeSupabaseIdentity: false,
|
||||
});
|
||||
const res = await fetch(url, {
|
||||
headers: auth.headers,
|
||||
...(cachePolicy.fetchMode === "no-store"
|
||||
? { cache: "no-store" as const }
|
||||
: { next: { revalidate: cachePolicy.revalidateSeconds ?? 15 } }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
const response = buildUpstreamErrorResponse(res.status, raw);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
const data = normalizeCityDetailPayload(await res.json());
|
||||
const response = buildCachedJsonResponse(
|
||||
req,
|
||||
data,
|
||||
cachePolicy.responseCacheControl,
|
||||
);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
const response = buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to fetch city detail aggregate",
|
||||
});
|
||||
return response;
|
||||
}
|
||||
return proxyBackendJsonGet(req, {
|
||||
cacheControl: cachePolicy.responseCacheControl,
|
||||
fetchCache:
|
||||
cachePolicy.fetchMode === "no-store" ? "no-store" : undefined,
|
||||
publicMessage: "Failed to fetch city detail aggregate",
|
||||
revalidateSeconds: cachePolicy.revalidateSeconds,
|
||||
url,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { proxyBackendJsonGet } from "@/lib/api-proxy";
|
||||
import { buildForceRefreshProxyCachePolicy } from "@/lib/proxy-cache-policy";
|
||||
|
||||
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 params = new URLSearchParams();
|
||||
const forceRefresh = req.nextUrl.searchParams.get("force_refresh") ?? "false";
|
||||
params.set("force_refresh", forceRefresh);
|
||||
const cachePolicy = buildForceRefreshProxyCachePolicy(forceRefresh, 20);
|
||||
|
||||
const targetDate = req.nextUrl.searchParams.get("target_date");
|
||||
if (targetDate) {
|
||||
params.set("target_date", targetDate);
|
||||
}
|
||||
|
||||
const marketSlug = req.nextUrl.searchParams.get("market_slug");
|
||||
if (marketSlug) {
|
||||
params.set("market_slug", marketSlug);
|
||||
}
|
||||
|
||||
const lite = req.nextUrl.searchParams.get("lite");
|
||||
if (lite) {
|
||||
params.set("lite", lite);
|
||||
}
|
||||
|
||||
const url = `${API_BASE}/api/city/${encodeURIComponent(name)}/market-scan?${params.toString()}`;
|
||||
|
||||
return proxyBackendJsonGet(req, {
|
||||
cacheControl: cachePolicy.responseCacheControl,
|
||||
detailLimit: 800,
|
||||
error: "Backend city market scan failed",
|
||||
fetchCache:
|
||||
cachePolicy.fetchMode === "no-store" ? "no-store" : undefined,
|
||||
publicMessage: "Failed to fetch city market scan",
|
||||
revalidateSeconds: cachePolicy.revalidateSeconds,
|
||||
statusOnException: 502,
|
||||
url,
|
||||
});
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
const upstreamUrl = new URL(`${API_BASE.replace(/\/+$/, "")}/api/events`);
|
||||
req.nextUrl.searchParams.forEach((value, key) => {
|
||||
upstreamUrl.searchParams.append(key, value);
|
||||
});
|
||||
|
||||
const upstream = await fetch(upstreamUrl.toString(), {
|
||||
cache: "no-store",
|
||||
headers: {
|
||||
Accept: "text/event-stream",
|
||||
Cookie: req.headers.get("cookie") || "",
|
||||
},
|
||||
});
|
||||
|
||||
if (!upstream.ok || !upstream.body) {
|
||||
return NextResponse.json(
|
||||
{ error: `SSE upstream failed with HTTP ${upstream.status}` },
|
||||
{ status: upstream.status || 502 },
|
||||
);
|
||||
}
|
||||
|
||||
return new Response(upstream.body, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
Connection: "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
|
||||
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/ops/online-users`, {
|
||||
headers: auth.headers,
|
||||
cache: "no-store",
|
||||
});
|
||||
const raw = await res.text();
|
||||
const response = new NextResponse(raw, {
|
||||
status: res.status,
|
||||
headers: {
|
||||
"Content-Type": res.headers.get("content-type") || "application/json",
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to fetch online users",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import {
|
||||
buildProxyExceptionResponse,
|
||||
buildUpstreamErrorResponse,
|
||||
} from "@/lib/api-proxy";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
const AI_CITY_GATEWAY_TIMEOUT_MS = Math.max(
|
||||
10_000,
|
||||
Number(
|
||||
process.env.POLYWEATHER_SCAN_AI_GATEWAY_TIMEOUT_MS ||
|
||||
process.env.POLYWEATHER_AI_CITY_GATEWAY_TIMEOUT_MS ||
|
||||
process.env.POLYWEATHER_SCAN_AI_PROXY_TIMEOUT_MS ||
|
||||
"55000",
|
||||
) || 55_000,
|
||||
);
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const maxDuration = 60;
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
let body: unknown = {};
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
body = {};
|
||||
}
|
||||
|
||||
let auth: Awaited<ReturnType<typeof buildBackendRequestHeaders>> | null = null;
|
||||
const controller = new AbortController();
|
||||
const startedAt = Date.now();
|
||||
const timeoutId = setTimeout(() => controller.abort(), AI_CITY_GATEWAY_TIMEOUT_MS);
|
||||
const requestBody = body && typeof body === "object" ? body as Record<string, unknown> : {};
|
||||
|
||||
try {
|
||||
auth = await buildBackendRequestHeaders(req);
|
||||
const headers = new Headers(auth.headers);
|
||||
headers.set("Content-Type", "application/json");
|
||||
headers.set("Accept", "application/json");
|
||||
console.info("[scan-ai-city] gateway request", {
|
||||
city: requestBody.city,
|
||||
force_refresh: requestBody.force_refresh === true,
|
||||
locale: requestBody.locale,
|
||||
timeout_ms: AI_CITY_GATEWAY_TIMEOUT_MS,
|
||||
});
|
||||
const res = await fetch(`${API_BASE}/api/scan/terminal/ai-city`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
cache: "no-store",
|
||||
signal: controller.signal,
|
||||
body: JSON.stringify(body || {}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
console.warn("[scan-ai-city] backend returned non-ok", {
|
||||
status: res.status,
|
||||
detail: raw.slice(0, 180),
|
||||
});
|
||||
const response = buildUpstreamErrorResponse(res.status, raw);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
const data = await res.json();
|
||||
console.info("[scan-ai-city] gateway complete", {
|
||||
status: data?.status,
|
||||
city: data?.city,
|
||||
model: data?.model,
|
||||
cached: data?.cached === true,
|
||||
elapsed_ms: Date.now() - startedAt,
|
||||
});
|
||||
const response = NextResponse.json(data, {
|
||||
headers: {
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
const timedOut = controller.signal.aborted;
|
||||
const elapsedMs = Date.now() - startedAt;
|
||||
console.warn("[scan-ai-city] gateway failed", {
|
||||
city: requestBody.city,
|
||||
timed_out: timedOut,
|
||||
elapsed_ms: elapsedMs,
|
||||
timeout_ms: AI_CITY_GATEWAY_TIMEOUT_MS,
|
||||
error: String(error),
|
||||
});
|
||||
const response = buildProxyExceptionResponse(error, {
|
||||
publicMessage: timedOut
|
||||
? "City AI gateway timed out before backend responded"
|
||||
: "Failed to fetch city AI data",
|
||||
status: timedOut ? 504 : 500,
|
||||
extra: {
|
||||
elapsed_ms: elapsedMs,
|
||||
timeout_ms: AI_CITY_GATEWAY_TIMEOUT_MS,
|
||||
city: requestBody.city,
|
||||
},
|
||||
});
|
||||
return auth ? applyAuthResponseCookies(response, auth.response) : response;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import {
|
||||
buildProxyExceptionResponse,
|
||||
buildUpstreamErrorResponse,
|
||||
} from "@/lib/api-proxy";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const maxDuration = 90;
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
let body: unknown = {};
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
body = {};
|
||||
}
|
||||
|
||||
const requestBody =
|
||||
body && typeof body === "object" ? (body as Record<string, unknown>) : {};
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const headers = new Headers(auth.headers);
|
||||
headers.set("Content-Type", "application/json");
|
||||
headers.set("Accept", "text/event-stream");
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/scan/terminal/ai-city/stream`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
cache: "no-store",
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
if (!res.ok || !res.body) {
|
||||
const raw = await res.text();
|
||||
const response = buildUpstreamErrorResponse(res.status, raw);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
|
||||
const response = new NextResponse(res.body, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream; charset=utf-8",
|
||||
"Cache-Control": "no-store, no-transform",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
const response = buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to stream city AI data",
|
||||
extra: { city: requestBody.city },
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
}
|
||||
@@ -30,17 +30,12 @@ export async function GET(req: NextRequest) {
|
||||
"time_range",
|
||||
"limit",
|
||||
"force_refresh",
|
||||
"timezone_offset_seconds",
|
||||
]) {
|
||||
const value = req.nextUrl.searchParams.get(key);
|
||||
if (value != null && value !== "") {
|
||||
params.set(key, value);
|
||||
}
|
||||
}
|
||||
const tradingRegion = req.nextUrl.searchParams.get("trading_region");
|
||||
if (tradingRegion != null && tradingRegion !== "") {
|
||||
params.set("region", tradingRegion);
|
||||
}
|
||||
const cachePolicy = buildForceRefreshProxyCachePolicy(forceRefresh, 10);
|
||||
|
||||
const url = `${API_BASE}/api/scan/terminal?${params.toString()}`;
|
||||
|
||||
@@ -12,23 +12,20 @@ function normalizeNextPath(input: string | null) {
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const siteUrl = getConfiguredSiteUrl();
|
||||
if (siteUrl) {
|
||||
const expectedHost = new URL(siteUrl).host;
|
||||
const requestHost =
|
||||
request.headers.get("x-forwarded-host") ||
|
||||
request.headers.get("host") ||
|
||||
"";
|
||||
if (requestHost !== expectedHost) {
|
||||
const canonicalCallbackUrl = new URL(request.nextUrl.pathname, siteUrl);
|
||||
const configuredSiteUrl = getConfiguredSiteUrl();
|
||||
if (configuredSiteUrl) {
|
||||
const canonicalOrigin = new URL(configuredSiteUrl).origin;
|
||||
if (request.nextUrl.origin !== canonicalOrigin) {
|
||||
const canonicalCallbackUrl = new URL(request.nextUrl.pathname, canonicalOrigin);
|
||||
canonicalCallbackUrl.search = request.nextUrl.search;
|
||||
return NextResponse.redirect(canonicalCallbackUrl);
|
||||
}
|
||||
}
|
||||
|
||||
const nextPath = normalizeNextPath(request.nextUrl.searchParams.get("next"));
|
||||
const baseUrl = siteUrl || request.nextUrl.origin;
|
||||
const redirectUrl = new URL(nextPath, baseUrl);
|
||||
const redirectUrl = request.nextUrl.clone();
|
||||
redirectUrl.pathname = nextPath;
|
||||
redirectUrl.search = "";
|
||||
|
||||
if (!hasSupabaseServerEnv()) {
|
||||
return NextResponse.redirect(redirectUrl);
|
||||
|
||||
@@ -2,7 +2,7 @@ import { LoginClient } from "@/components/auth/LoginClient";
|
||||
import { I18nProvider } from "@/hooks/useI18n";
|
||||
|
||||
type PageProps = {
|
||||
searchParams?: Promise<{ next?: string; mode?: string }>;
|
||||
searchParams?: Promise<{ next?: string }>;
|
||||
};
|
||||
|
||||
function normalizeNextPath(input: string | undefined) {
|
||||
@@ -14,18 +14,12 @@ function normalizeNextPath(input: string | undefined) {
|
||||
return raw;
|
||||
}
|
||||
|
||||
function normalizeMode(input: string | undefined): "login" | "signup" {
|
||||
if (input === "signup") return "signup";
|
||||
return "login";
|
||||
}
|
||||
|
||||
export default async function LoginPage({ searchParams }: PageProps) {
|
||||
const params = (await searchParams) || {};
|
||||
const nextPath = normalizeNextPath(params.next);
|
||||
const initialMode = normalizeMode(params.mode);
|
||||
return (
|
||||
<I18nProvider>
|
||||
<LoginClient nextPath={nextPath} initialMode={initialMode} />
|
||||
<LoginClient nextPath={nextPath} />
|
||||
</I18nProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useI18n } from "@/hooks/useI18n";
|
||||
|
||||
export function EntitlementRequiredClient({ nextPath }: { nextPath: string }) {
|
||||
const { locale } = useI18n();
|
||||
const isEn = locale === "en-US";
|
||||
|
||||
const copy = {
|
||||
title: isEn ? "Sign in required" : "需要登录方可访问此页面",
|
||||
desc: isEn
|
||||
? "This page requires you to sign in. Please sign in and try again."
|
||||
: "本页面需要登录权限。请登录后重试。",
|
||||
signIn: isEn ? "Sign in" : "去登录",
|
||||
backHome: isEn ? "Back to Home" : "返回首页",
|
||||
legacyNote: isEn ? "Legacy token mode still supported" : "传统令牌模式仍支持",
|
||||
requestedPath: isEn ? "Requested path" : "请求路径",
|
||||
};
|
||||
|
||||
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: 480,
|
||||
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)",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
<h1
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: 22,
|
||||
lineHeight: 1.3,
|
||||
fontWeight: 800,
|
||||
}}
|
||||
>
|
||||
{copy.title}
|
||||
</h1>
|
||||
<p style={{ marginTop: 12, color: "#9fb2da", lineHeight: 1.6 }}>
|
||||
{copy.desc}
|
||||
</p>
|
||||
<div
|
||||
style={{
|
||||
marginTop: 20,
|
||||
display: "flex",
|
||||
gap: 12,
|
||||
justifyContent: "center",
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<Link
|
||||
href={`/auth/login?next=${encodeURIComponent(nextPath)}`}
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
minHeight: 40,
|
||||
padding: "8px 20px",
|
||||
borderRadius: 12,
|
||||
background: "linear-gradient(135deg, #2563EB, #4F46E5)",
|
||||
color: "#fff",
|
||||
fontWeight: 700,
|
||||
textDecoration: "none",
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
{copy.signIn}
|
||||
</Link>
|
||||
<Link
|
||||
href="/"
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
minHeight: 40,
|
||||
padding: "8px 20px",
|
||||
borderRadius: 12,
|
||||
border: "1px solid rgba(68, 92, 140, 0.45)",
|
||||
background: "rgba(68, 92, 140, 0.2)",
|
||||
color: "#d6e2ff",
|
||||
fontWeight: 600,
|
||||
textDecoration: "none",
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
{copy.backHome}
|
||||
</Link>
|
||||
</div>
|
||||
<p
|
||||
style={{
|
||||
marginTop: 20,
|
||||
fontSize: 12,
|
||||
color: "#7891b5",
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
{copy.legacyNote}{" "}
|
||||
<code
|
||||
style={{
|
||||
background: "rgba(255,255,255,0.06)",
|
||||
padding: "2px 6px",
|
||||
borderRadius: 4,
|
||||
}}
|
||||
>
|
||||
?access_token=<your-token>
|
||||
</code>
|
||||
<br />
|
||||
<span style={{ marginTop: 4, display: "inline-block" }}>
|
||||
{copy.requestedPath}: <code>{nextPath}</code>
|
||||
</span>
|
||||
</p>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { EntitlementRequiredClient } from "./EntitlementRequiredClient";
|
||||
import { I18nProvider } from "@/hooks/useI18n";
|
||||
import Link from "next/link";
|
||||
|
||||
type Props = {
|
||||
searchParams?: Promise<{ next?: string }>;
|
||||
@@ -10,8 +9,98 @@ export default async function EntitlementRequiredPage({ searchParams }: Props) {
|
||||
const nextPath = params.next || "/";
|
||||
|
||||
return (
|
||||
<I18nProvider>
|
||||
<EntitlementRequiredClient nextPath={nextPath} />
|
||||
</I18nProvider>
|
||||
<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: 480,
|
||||
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)",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
<h1
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: 22,
|
||||
lineHeight: 1.3,
|
||||
fontWeight: 800,
|
||||
}}
|
||||
>
|
||||
需要登录方可访问此页面
|
||||
</h1>
|
||||
<p style={{ marginTop: 12, color: "#9fb2da", lineHeight: 1.6 }}>
|
||||
本页面需要登录权限。请登录后重试。
|
||||
<br />
|
||||
Sign in required to access this page.
|
||||
</p>
|
||||
<div style={{ marginTop: 20, display: "flex", gap: 12, justifyContent: "center", flexWrap: "wrap" }}>
|
||||
<Link
|
||||
href={`/auth/login?next=${encodeURIComponent(nextPath)}`}
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
minHeight: 40,
|
||||
padding: "8px 20px",
|
||||
borderRadius: 12,
|
||||
background: "linear-gradient(135deg, #2563EB, #4F46E5)",
|
||||
color: "#fff",
|
||||
fontWeight: 700,
|
||||
textDecoration: "none",
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
去登录 / Sign in
|
||||
</Link>
|
||||
<Link
|
||||
href="/"
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
minHeight: 40,
|
||||
padding: "8px 20px",
|
||||
borderRadius: 12,
|
||||
border: "1px solid rgba(68, 92, 140, 0.45)",
|
||||
background: "rgba(68, 92, 140, 0.2)",
|
||||
color: "#d6e2ff",
|
||||
fontWeight: 600,
|
||||
textDecoration: "none",
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
返回首页 / Back to Home
|
||||
</Link>
|
||||
</div>
|
||||
<p
|
||||
style={{
|
||||
marginTop: 20,
|
||||
fontSize: 12,
|
||||
color: "#7891b5",
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
传统令牌模式仍支持 <code style={{ background: "rgba(255,255,255,0.06)", padding: "2px 6px", borderRadius: 4 }}>?access_token=<your-token></code>
|
||||
<br />
|
||||
<span style={{ marginTop: 4, display: "inline-block" }}>
|
||||
请求路径 / Requested path: <code>{nextPath}</code>
|
||||
</span>
|
||||
</p>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -174,18 +174,9 @@
|
||||
|
||||
body {
|
||||
font-family: var(--font-data);
|
||||
font-size: 15px;
|
||||
background: var(--color-bg-base);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
/* Global font-size bump for data-dense terminal panels */
|
||||
.text-\[9px\] { font-size: 10px; }
|
||||
.text-\[10px\] { font-size: 11px; }
|
||||
.text-\[11px\] { font-size: 12px; }
|
||||
.text-xs { font-size: 0.8rem; line-height: 1.35rem; }
|
||||
.text-sm { font-size: 0.9rem; line-height: 1.45rem; }
|
||||
table { font-size: inherit; }
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════
|
||||
@@ -438,47 +429,6 @@
|
||||
animation: markerGlow 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.peak-glow-card {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.peak-glow-card::after {
|
||||
content: "";
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 20;
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
.peak-glow-watch::after {
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(245, 158, 11, 0.42),
|
||||
inset 0 0 18px rgba(245, 158, 11, 0.12);
|
||||
}
|
||||
|
||||
.peak-glow-near::after {
|
||||
animation: peakGlowNear 3.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.peak-glow-breakout::after {
|
||||
animation: peakGlowBreakout 2.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.peak-glow-cooling::after {
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(100, 116, 139, 0.38),
|
||||
inset 0 0 18px rgba(148, 163, 184, 0.16);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.peak-glow-near::after,
|
||||
.peak-glow-breakout::after {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Scrollbar ── */
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
@@ -501,35 +451,3 @@
|
||||
box-shadow: 0 10px 26px rgba(0, 224, 164, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes peakGlowNear {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(249, 115, 22, 0.48),
|
||||
inset 0 0 18px rgba(249, 115, 22, 0.12),
|
||||
0 0 0 rgba(249, 115, 22, 0);
|
||||
}
|
||||
50% {
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(249, 115, 22, 0.7),
|
||||
inset 0 0 28px rgba(249, 115, 22, 0.22),
|
||||
0 0 22px rgba(249, 115, 22, 0.18);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes peakGlowBreakout {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(244, 63, 94, 0.54),
|
||||
inset 0 0 22px rgba(244, 63, 94, 0.16),
|
||||
0 0 0 rgba(244, 63, 94, 0);
|
||||
}
|
||||
50% {
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(244, 63, 94, 0.78),
|
||||
inset 0 0 32px rgba(244, 63, 94, 0.26),
|
||||
0 0 24px rgba(244, 63, 94, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
+2
-39
@@ -18,47 +18,10 @@ const jetbrainsMono = JetBrains_Mono({
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
default: "PolyWeather | Institutional Weather Signal Intelligence",
|
||||
template: "%s | PolyWeather",
|
||||
},
|
||||
title: "PolyWeather | Weather Intelligence",
|
||||
description:
|
||||
"PolyWeather is a paid professional weather-signal intelligence terminal with METAR evidence, DEB forecast blending, and AI decision cards. Real-time observations for 51 global cities.",
|
||||
"PolyWeather pro dashboard with global weather risk map and city analytics.",
|
||||
manifest: "/site.webmanifest",
|
||||
metadataBase: new URL("https://polyweather.top"),
|
||||
alternates: {
|
||||
canonical: "/",
|
||||
},
|
||||
openGraph: {
|
||||
type: "website",
|
||||
siteName: "PolyWeather",
|
||||
title: "PolyWeather | Institutional Weather Signal Intelligence",
|
||||
description:
|
||||
"Paid professional weather-signal intelligence terminal. METAR evidence, DEB forecast blending, AI decision cards. 51 cities, real-time.",
|
||||
url: "https://polyweather.top",
|
||||
images: [
|
||||
{
|
||||
url: "/apple-touch-icon.png",
|
||||
width: 180,
|
||||
height: 180,
|
||||
alt: "PolyWeather",
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: "summary",
|
||||
title: "PolyWeather | Weather Signal Intelligence",
|
||||
description:
|
||||
"Paid professional weather-signal intelligence terminal. 51 cities, real-time observations.",
|
||||
images: ["/apple-touch-icon.png"],
|
||||
},
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
"max-video-preview": -1,
|
||||
"max-image-preview": "large",
|
||||
"max-snippet": -1,
|
||||
},
|
||||
icons: {
|
||||
icon: [
|
||||
{ url: "/favicon.ico", type: "image/x-icon" },
|
||||
|
||||
+4
-34
@@ -1,15 +1,11 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { PreloadTerminalData } from "@/components/landing/PreloadTerminalData";
|
||||
import { InstitutionalLandingPage } from "@/components/landing/InstitutionalLandingPage";
|
||||
import { DashboardEntry } from "@/components/dashboard/DashboardEntry";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "PolyWeather | Institutional Weather Signal Intelligence",
|
||||
title: "PolyWeather - Global Weather Intelligence Map",
|
||||
description:
|
||||
"PolyWeather is a paid professional weather-signal intelligence terminal with METAR evidence, DEB forecast blending, and AI decision cards.",
|
||||
other: {
|
||||
preconnect: "https://api.polyweather.top",
|
||||
},
|
||||
"PolyWeather dashboard with METAR, MGM, DEB fusion forecast, multi-model comparison, and AI weather decision cards.",
|
||||
};
|
||||
|
||||
export default async function HomePage({
|
||||
@@ -33,31 +29,5 @@ export default async function HomePage({
|
||||
const qs = usp.toString();
|
||||
redirect(`/auth/callback${qs ? `?${qs}` : ""}`);
|
||||
}
|
||||
const jsonLd = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebApplication",
|
||||
name: "PolyWeather",
|
||||
description:
|
||||
"Paid professional weather-signal intelligence terminal with METAR evidence, DEB forecast blending, and AI decision cards.",
|
||||
url: "https://polyweather.top",
|
||||
applicationCategory: "BusinessApplication",
|
||||
operatingSystem: "Web",
|
||||
offers: {
|
||||
"@type": "Offer",
|
||||
price: "10.00",
|
||||
priceCurrency: "USD",
|
||||
description: "Monthly subscription",
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||
/>
|
||||
<PreloadTerminalData />
|
||||
<InstitutionalLandingPage />
|
||||
</>
|
||||
);
|
||||
return <DashboardEntry />;
|
||||
}
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
return {
|
||||
rules: [
|
||||
{
|
||||
userAgent: "*",
|
||||
allow: "/",
|
||||
disallow: ["/terminal/", "/account/", "/ops/", "/api/"],
|
||||
},
|
||||
],
|
||||
sitemap: "https://polyweather.top/sitemap.xml",
|
||||
};
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
|
||||
export default function sitemap(): MetadataRoute.Sitemap {
|
||||
const baseUrl = "https://polyweather.top";
|
||||
|
||||
return [
|
||||
{
|
||||
url: baseUrl,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "weekly",
|
||||
priority: 1.0,
|
||||
},
|
||||
{
|
||||
url: `${baseUrl}/auth/login`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "monthly",
|
||||
priority: 0.6,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -64,47 +64,47 @@ export function SubscriptionHelpClient() {
|
||||
}), [isEn]);
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-[#f4f7fb] px-4 py-10 text-slate-900">
|
||||
<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-lg border border-slate-200 bg-white px-3 py-2 text-sm font-semibold text-slate-600 shadow-sm transition hover:border-slate-300 hover:text-slate-950"
|
||||
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} />
|
||||
{copy.back}
|
||||
</Link>
|
||||
|
||||
<section className="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm md:p-8">
|
||||
<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-blue-700" size={22} />
|
||||
<ShieldCheck className="text-cyan-300" size={22} />
|
||||
<h1 className="text-2xl font-bold md:text-3xl">{copy.title}</h1>
|
||||
</div>
|
||||
<p className="text-sm text-slate-600 md:text-base">{copy.description}</p>
|
||||
<p className="text-sm text-slate-300 md:text-base">{copy.description}</p>
|
||||
|
||||
<div className="mt-6 grid gap-3 md:grid-cols-3">
|
||||
<div className="rounded-xl border border-slate-200 bg-slate-50 p-4">
|
||||
<div className="mb-2 flex items-center gap-2 text-blue-700">
|
||||
<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">{copy.priceLabel}</span>
|
||||
</div>
|
||||
<p className="text-xl font-bold">{copy.priceText}</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-slate-200 bg-slate-50 p-4">
|
||||
<div className="mb-2 flex items-center gap-2 text-emerald-700">
|
||||
<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">{copy.discountLabel}</span>
|
||||
</div>
|
||||
<p className="text-xl font-bold">{copy.discountText}</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-slate-200 bg-slate-50 p-4">
|
||||
<div className="mb-2 flex items-center gap-2 text-indigo-700">
|
||||
<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">{copy.communityLabel}</span>
|
||||
</div>
|
||||
<Link
|
||||
href={TELEGRAM_GROUP_URL}
|
||||
target="_blank"
|
||||
className="inline-flex min-h-9 items-center text-sm font-semibold text-blue-700 underline decoration-blue-500/50 underline-offset-4"
|
||||
className="inline-flex min-h-9 items-center text-sm font-semibold text-blue-300 underline decoration-blue-500/50 underline-offset-4"
|
||||
>
|
||||
{copy.communityLink}
|
||||
</Link>
|
||||
@@ -112,19 +112,19 @@ export function SubscriptionHelpClient() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-6 rounded-2xl border border-slate-200 bg-white p-6 shadow-sm md:p-8">
|
||||
<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">{copy.faqTitle}</h2>
|
||||
<div className="space-y-4">
|
||||
{FAQ_ITEMS.map((item) => (
|
||||
<article
|
||||
key={item.q_en}
|
||||
className="rounded-xl border border-slate-200 bg-slate-50 p-4"
|
||||
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-700">
|
||||
<h3 className="mb-2 flex items-center gap-2 text-sm font-semibold text-blue-300">
|
||||
<CheckCircle2 size={14} />
|
||||
{isEn ? item.q_en : item.q_zh}
|
||||
</h3>
|
||||
<p className="text-sm leading-6 text-slate-600">
|
||||
<p className="text-sm leading-6 text-slate-300">
|
||||
{isEn ? item.a_en : item.a_zh}
|
||||
</p>
|
||||
</article>
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import type { Metadata } from "next";
|
||||
import { ScanTerminalDashboard } from "@/components/dashboard/ScanTerminalDashboard";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "PolyWeather Terminal | Paid Product",
|
||||
description:
|
||||
"Paid PolyWeather decision terminal for weather-signal analysis and city decision cards.",
|
||||
};
|
||||
|
||||
export default function TerminalPage() {
|
||||
return <ScanTerminalDashboard />;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,14 +10,14 @@ const AccountCenter = dynamic(
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="min-h-screen bg-[#f4f7fb] text-slate-700">
|
||||
<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-200" />
|
||||
<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-2xl border border-slate-200 bg-white md:col-span-2" />
|
||||
<div className="h-32 animate-pulse rounded-2xl border border-slate-200 bg-white" />
|
||||
<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-2xl border border-slate-200 bg-white" />
|
||||
<div className="mt-6 h-72 animate-pulse rounded-3xl bg-slate-800/60" />
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
|
||||
@@ -13,17 +13,17 @@ export const InfoRow = ({
|
||||
value,
|
||||
isPrimary = false,
|
||||
}: InfoRowProps) => (
|
||||
<div className="flex min-w-0 flex-col gap-3 rounded-xl border border-slate-200 bg-white p-4 transition-all hover:border-slate-300 hover:bg-slate-50 group sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex min-w-0 flex-col gap-3 p-4 bg-white/5 rounded-2xl border border-white/5 hover:bg-white/10 transition-all group sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div className="shrink-0 rounded-lg border border-slate-200 bg-slate-50 p-2 text-slate-500 transition-colors group-hover:text-blue-600">
|
||||
<div className="shrink-0 p-2 bg-slate-800 rounded-lg text-slate-400 group-hover:text-blue-400 transition-colors">
|
||||
{Icon && <Icon size={18} />}
|
||||
</div>
|
||||
<span className="min-w-0 text-sm font-medium leading-5 text-slate-500">
|
||||
<span className="min-w-0 text-slate-400 text-sm font-medium leading-5">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className={`min-w-0 break-all text-left font-mono text-sm font-semibold sm:text-right ${isPrimary ? "text-blue-700" : "text-slate-900"}`}
|
||||
className={`min-w-0 break-all text-left text-sm font-semibold font-mono sm:text-right ${isPrimary ? "text-blue-400" : "text-slate-200"}`}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
|
||||
@@ -44,37 +44,12 @@ export function runTests() {
|
||||
);
|
||||
|
||||
const accountCenterSource = fs.readFileSync(accountCenterPath, "utf8");
|
||||
const hookPath = path.join(
|
||||
projectRoot,
|
||||
"components",
|
||||
"account",
|
||||
"useAccountPayment.ts",
|
||||
);
|
||||
const hookSource = fs.existsSync(hookPath)
|
||||
? fs.readFileSync(hookPath, "utf8")
|
||||
: "";
|
||||
const paymentFlowPath = path.join(
|
||||
projectRoot,
|
||||
"components",
|
||||
"account",
|
||||
"usePaymentFlow.ts",
|
||||
);
|
||||
const paymentFlowSource = fs.existsSync(paymentFlowPath)
|
||||
? fs.readFileSync(paymentFlowPath, "utf8")
|
||||
: "";
|
||||
|
||||
// The receiver validation now lives in the extracted hook file (called
|
||||
// from createManualPaymentIntent and createIntentAndPay).
|
||||
assert(
|
||||
accountCenterSource.includes("assertExpectedPaymentReceiver") ||
|
||||
hookSource.includes("assertExpectedPaymentReceiver") ||
|
||||
paymentFlowSource.includes("assertExpectedPaymentReceiver"),
|
||||
accountCenterSource.includes("assertExpectedPaymentReceiver"),
|
||||
"AccountCenter must validate backend-returned manual payment receiver before displaying it",
|
||||
);
|
||||
assert(
|
||||
accountCenterSource.includes("EXPECTED_PAYMENT_RECEIVER_ADDRESS") ||
|
||||
hookSource.includes("EXPECTED_PAYMENT_RECEIVER_ADDRESS") ||
|
||||
paymentFlowSource.includes("EXPECTED_PAYMENT_RECEIVER_ADDRESS"),
|
||||
accountCenterSource.includes("EXPECTED_PAYMENT_RECEIVER_ADDRESS"),
|
||||
"AccountCenter must show the pinned payment receiver address in its payment guard",
|
||||
);
|
||||
|
||||
|
||||
@@ -39,9 +39,6 @@ export function runTests() {
|
||||
"wallet.ts",
|
||||
"payment-utils.ts",
|
||||
"usePaymentState.ts",
|
||||
"useWalletBind.ts",
|
||||
"usePaymentFlow.ts",
|
||||
"useBilling.ts",
|
||||
]
|
||||
.map((file) => fs.readFileSync(path.join(accountDir, file), "utf8"))
|
||||
.join("\n");
|
||||
@@ -54,17 +51,10 @@ export function runTests() {
|
||||
accountCenterSource.includes('const copy = useMemo(() => createAccountCopy(isEn), [isEn]);'),
|
||||
"AccountCenter copy text must be centralized in account-copy.ts instead of an inline 170+ line object",
|
||||
);
|
||||
const hookPath = path.join(accountDir, "useAccountPayment.ts");
|
||||
const hookSource = fs.existsSync(hookPath)
|
||||
? fs.readFileSync(hookPath, "utf8")
|
||||
: "";
|
||||
assert(
|
||||
(accountCenterSource.includes('import { usePaymentState } from "./usePaymentState";') ||
|
||||
hookSource.includes('import { usePaymentState } from "./usePaymentState";')) &&
|
||||
(accountCenterSource.includes("clearPaymentState") ||
|
||||
hookSource.includes("clearPaymentState")) &&
|
||||
(accountCenterSource.includes("clearPaymentMessages") ||
|
||||
hookSource.includes("clearPaymentMessages")),
|
||||
accountCenterSource.includes('import { usePaymentState } from "./usePaymentState";') &&
|
||||
accountCenterSource.includes("clearPaymentState") &&
|
||||
accountCenterSource.includes("clearPaymentMessages"),
|
||||
"payment UI state reset/message helpers must be centralized in usePaymentState.ts",
|
||||
);
|
||||
const serviceWorkerSource = fs.readFileSync(serviceWorkerPath, "utf8");
|
||||
|
||||
@@ -7,7 +7,7 @@ export function createAccountCopy(isEn: boolean): Record<string, string> {
|
||||
signOut: isEn ? "Sign Out" : "退出",
|
||||
signIn: isEn ? "Sign In" : "登录",
|
||||
upgradePro: isEn ? "Upgrade Pro" : "升级 Pro",
|
||||
guestUser: isEn ? "Signed-out account" : "未登录账户",
|
||||
guestUser: isEn ? "Guest User" : "游客用户",
|
||||
joinedAt: isEn ? "Joined" : "加入时间",
|
||||
totalPoints: isEn ? "Total Points" : "总积分 (荣誉)",
|
||||
weeklyPoints: isEn ? "Weekly Points" : "本周积分 (竞技)",
|
||||
@@ -129,6 +129,7 @@ export function createAccountCopy(isEn: boolean): Record<string, string> {
|
||||
? "Wallet bound. Creating order and sending payment..."
|
||||
: "钱包已绑定,正在创建订单并发起支付...",
|
||||
proMember: "PRO MEMBER",
|
||||
freeTier: "FREE TIER",
|
||||
proPendingSync: isEn ? "Activated (pending sync)" : "已开通(待同步)",
|
||||
noProSubscription: isEn ? "No Pro subscription" : "暂无 Pro 订阅",
|
||||
proEndsSoonTitle: isEn ? "Pro renewal due soon" : "Pro 即将到期",
|
||||
@@ -176,151 +177,5 @@ export function createAccountCopy(isEn: boolean): Record<string, string> {
|
||||
chainSwitchPrompt: isEn
|
||||
? "Please manually switch to Polygon network in your wallet and try again."
|
||||
: "请在钱包中手动切换到 Polygon 网络后再试。",
|
||||
// ── Payment status messages ──────────────────────────────────────
|
||||
orderAlreadyPaid: isEn
|
||||
? "This order is already paid. Restoring subscription..."
|
||||
: "该订单已支付,正在恢复订阅...",
|
||||
orderExpired: isEn
|
||||
? "Payment order expired (valid for 30 minutes). Please create a new order."
|
||||
: "支付订单已过期(30分钟有效),请重新创建订单。",
|
||||
paymentConfirmed: isEn
|
||||
? "Payment confirmed. Transaction: {txHash}"
|
||||
: "支付确认成功,交易: {txHash}",
|
||||
txSubmitted: isEn
|
||||
? "Transaction submitted: {txHash}, confirming on chain (status: {status})..."
|
||||
: "交易已提交: {txHash},正在链上确认(状态: {status})...",
|
||||
paymentPendingTimeout: isEn
|
||||
? "Payment pending timeout."
|
||||
: "支付等待超时。",
|
||||
approvalDetected: isEn
|
||||
? "Insufficient allowance detected. Requesting {symbol} approval..."
|
||||
: "检测到授权不足,正在发起 {symbol} 授权...",
|
||||
approvalDone: isEn
|
||||
? "{symbol} approval successful. Sending payment..."
|
||||
: "{symbol} 授权成功,正在发起支付...",
|
||||
approvalSufficient: isEn
|
||||
? "Allowance sufficient. Sending payment..."
|
||||
: "授权额度充足,正在发起支付...",
|
||||
approvalDoneCancelled: isEn
|
||||
? "{symbol} approval completed. This payment was cancelled. You can pay again."
|
||||
: "{symbol} 授权已完成,本次支付已取消,可直接再次点击支付。",
|
||||
approvalDoneRetry: isEn
|
||||
? "{symbol} approval completed but payment incomplete. Please retry."
|
||||
: "{symbol} 授权已完成,但支付未完成,请重试。",
|
||||
paymentConfigUpdated: isEn
|
||||
? "Payment config updated. Switched to latest address {address}."
|
||||
: "检测到支付配置已更新,已切换到最新地址 {address}。",
|
||||
currentReceiver: isEn
|
||||
? "Current receiver contract: {address}"
|
||||
: "当前收款合约: {address}",
|
||||
manualOrderCreated: isEn
|
||||
? "Manual transfer order created. Please send {amount} USDC to the receiver on Polygon network. Then submit your tx hash below."
|
||||
: "手动转账订单已创建:请在 Polygon 网络转 {amount} USDC 到收款地址。完成后在下方提交 tx hash。",
|
||||
manualOrderRequired: isEn
|
||||
? "Please create a manual transfer order first."
|
||||
: "请先创建手动转账订单。",
|
||||
txHashRequired: isEn
|
||||
? "Please enter a valid tx hash."
|
||||
: "请输入有效的 tx hash。",
|
||||
verifying: isEn ? "Verifying..." : "验证中...",
|
||||
verifyAddressMatch: isEn
|
||||
? "Receiver address and amount match"
|
||||
: "收款地址和金额匹配",
|
||||
verifyTxNotMined: isEn
|
||||
? "Transaction not yet mined. Please wait."
|
||||
: "交易未上链,请等待",
|
||||
verifyAddressMismatch: isEn
|
||||
? "Receiver address mismatch! Please transfer to the correct receiver address."
|
||||
: "收款地址不匹配!请转账到正确的收款地址。",
|
||||
verifyAmountLow: isEn
|
||||
? "Transfer amount is insufficient."
|
||||
: "转账金额不足",
|
||||
verifyTxReverted: isEn
|
||||
? "This transaction was reverted."
|
||||
: "该交易已回滚",
|
||||
verifyFailed: isEn ? "Verification failed: " : "验证失败: ",
|
||||
verifyUnknown: isEn ? "Unknown error" : "未知错误",
|
||||
// ── Telegram bind messages ────────────────────────────────────────
|
||||
telegramVerifySuccess: isEn
|
||||
? "Telegram group membership verified. Current membership price: {amount} U."
|
||||
: "Telegram 群成员验证成功,当前会员价 {amount}U。",
|
||||
telegramBindClickHint: isEn
|
||||
? "Open the Telegram Bot, click Start, and confirm binding. Then refresh this page to request group entry."
|
||||
: "已打开 Telegram Bot,请在 Bot 内点击 Start 并确认绑定;完成后刷新本页再申请入群。",
|
||||
telegramPopupBlocked: isEn
|
||||
? "Popup blocked. Please click the link below to complete binding:"
|
||||
: "弹窗被拦截,请点击下方链接完成绑定:",
|
||||
telegramBindFailed: isEn
|
||||
? "Failed to create Telegram bind link"
|
||||
: "创建 Telegram 绑定链接失败",
|
||||
telegramBindLinkMissing: isEn
|
||||
? "Telegram bind link missing"
|
||||
: "Telegram 绑定链接缺失",
|
||||
// ── Wallet errors ──────────────────────────────────────────────────
|
||||
noWalletProvider: isEn
|
||||
? "No EVM wallet provider found"
|
||||
: "未检测到 EVM 钱包插件",
|
||||
txReverted: isEn
|
||||
? "Transaction reverted: {txHash}"
|
||||
: "交易已回滚: {txHash}",
|
||||
txConfirmTimeout: isEn
|
||||
? "Transaction confirmation timeout: {txHash}"
|
||||
: "交易确认超时: {txHash}",
|
||||
queryIntentFailed: isEn
|
||||
? "Query intent failed: {raw}"
|
||||
: "查询支付意向失败: {raw}",
|
||||
paymentStatus: isEn ? "Payment {status}" : "支付状态: {status}",
|
||||
createIntentFailed: isEn
|
||||
? "Create intent failed: {raw}"
|
||||
: "创建支付意向失败: {raw}",
|
||||
intentPayloadInvalid: isEn
|
||||
? "Intent payload invalid"
|
||||
: "支付意向数据无效",
|
||||
intentTokenInvalid: isEn
|
||||
? "Intent token/amount invalid"
|
||||
: "支付币种或金额无效",
|
||||
submitTxFailed: isEn
|
||||
? "Submit transaction failed: {raw}"
|
||||
: "提交交易失败: {raw}",
|
||||
confirmFailed: isEn
|
||||
? "Confirm failed: {raw}"
|
||||
: "确认失败: {raw}",
|
||||
createManualIntentFailed: isEn
|
||||
? "Create manual intent failed: {raw}"
|
||||
: "创建手动转账订单失败: {raw}",
|
||||
manualPaymentInvalid: isEn
|
||||
? "Manual payment payload invalid"
|
||||
: "手动转账数据无效",
|
||||
bindFailed: isEn ? "Bind failed: {raw}" : "绑定失败: {raw}",
|
||||
challengeFailed: isEn
|
||||
? "Challenge failed: {raw}"
|
||||
: "钱包验证挑战失败: {raw}",
|
||||
challengeInvalid: isEn
|
||||
? "Challenge payload invalid"
|
||||
: "验证挑战数据无效",
|
||||
verifyFailedRaw: isEn ? "Verify failed: {raw}" : "钱包验证失败: {raw}",
|
||||
httpError: isEn ? "HTTP {status} {raw}" : "HTTP 错误 {status}: {raw}",
|
||||
loadConfigFailed: isEn
|
||||
? "Load payment config failed: {raw}"
|
||||
: "加载支付配置失败: {raw}",
|
||||
// ── Points / Footer ────────────────────────────────────────────────
|
||||
pointsRule: isEn
|
||||
? "Points rule: active messages in group (anti-spam) + daily first message bonus. Weekly leaderboard settles every Monday midnight. All active members receive participation awards."
|
||||
: "积分规则:群内有效发言(自动防刷检测)+ 每日首条发言额外奖励。每周一零点结算周榜,所有活跃用户均享参与奖。",
|
||||
pointsDiscount: isEn
|
||||
? "Available: {points} points discounting ${amount}. Applied automatically on renewal."
|
||||
: "当前可用 {points} 积分抵扣 ${amount},续费时会自动生效。",
|
||||
footerEngine: isEn
|
||||
? "PolyWeather Global Meteorological Engine · Powered by AI"
|
||||
: "PolyWeather 全球气象引擎 · AI 驱动",
|
||||
binanceBindHint: isEn
|
||||
? "Binance extension bound. If payment is stuck, please use WalletConnect QR payment."
|
||||
: "Binance 扩展已绑定;如支付卡住,请优先使用 WalletConnect 扫码支付。",
|
||||
walletBound: isEn
|
||||
? "{label} bound: {address}. You can now pay to activate your subscription."
|
||||
: "{label} 已绑定: {address}。现在可点击「立即订阅并激活服务」。",
|
||||
walletBoundSuccess: isEn
|
||||
? "{label} bound: {address}."
|
||||
: "{label} 绑定成功: {address}。",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,470 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import type { User } from "@supabase/supabase-js";
|
||||
import { getSupabaseBrowserClient } from "@/lib/supabase/client";
|
||||
|
||||
import type {
|
||||
AuthMeResponse,
|
||||
BoundWallet,
|
||||
PaymentConfig,
|
||||
ProviderMode,
|
||||
InjectedProviderOption,
|
||||
} from "./types";
|
||||
import { usePaymentState } from "./usePaymentState";
|
||||
import { useWalletBind } from "./useWalletBind";
|
||||
import { usePaymentFlow } from "./usePaymentFlow";
|
||||
import { useBilling } from "./useBilling";
|
||||
|
||||
// ============================================================
|
||||
export interface UseAccountPaymentParams {
|
||||
isEn: boolean;
|
||||
supabaseReady: boolean;
|
||||
walletConnectEnabled: boolean;
|
||||
copy: Record<string, string>;
|
||||
backend: AuthMeResponse | null;
|
||||
user: User | null;
|
||||
setUser: (user: User | null) => void;
|
||||
setBackend: (backend: AuthMeResponse | null) => void;
|
||||
setErrorText: (text: string) => void;
|
||||
setUpdatedAt: (text: string) => void;
|
||||
showOverlay: boolean;
|
||||
setShowOverlay: (v: boolean) => void;
|
||||
usePoints: boolean;
|
||||
setUsePoints: (v: boolean) => void;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
export function useAccountPayment(params: UseAccountPaymentParams) {
|
||||
const {
|
||||
isEn,
|
||||
supabaseReady,
|
||||
walletConnectEnabled,
|
||||
copy,
|
||||
backend,
|
||||
user,
|
||||
setUser,
|
||||
setBackend,
|
||||
setErrorText,
|
||||
setUpdatedAt,
|
||||
showOverlay,
|
||||
setShowOverlay,
|
||||
usePoints,
|
||||
setUsePoints,
|
||||
} = params;
|
||||
|
||||
// ── Base payment state (from usePaymentState) ──────────────
|
||||
const {
|
||||
paymentBusy,
|
||||
setPaymentBusy,
|
||||
paymentInfo,
|
||||
setPaymentInfo,
|
||||
paymentError,
|
||||
setPaymentError,
|
||||
lastIntentId,
|
||||
setLastIntentId,
|
||||
lastTxHash,
|
||||
setLastTxHash,
|
||||
telegramBindOpening,
|
||||
setTelegramBindOpening,
|
||||
manualPayment,
|
||||
setManualPayment,
|
||||
manualTxHash,
|
||||
setManualTxHash,
|
||||
txValidation,
|
||||
setTxValidation,
|
||||
paymentMethodTab,
|
||||
setPaymentMethodTab,
|
||||
lastPaymentStartedAt,
|
||||
setLastPaymentStartedAt,
|
||||
clearPaymentMessages,
|
||||
clearPaymentState,
|
||||
} = usePaymentState();
|
||||
|
||||
// ── Derived values ──────────────────────────────────────
|
||||
const authUserId = backend?.user_id || user?.id || "";
|
||||
const authIsAuthenticated = Boolean(authUserId);
|
||||
|
||||
// ── getValidAccessToken ──────────────────────────────────
|
||||
const getValidAccessToken = useCallback(async (): Promise<string> => {
|
||||
if (!supabaseReady)
|
||||
throw new Error(
|
||||
isEn
|
||||
? "Supabase is not configured. Unable to get auth token."
|
||||
: "Supabase 未配置,无法获取登录凭证。",
|
||||
);
|
||||
const client = getSupabaseBrowserClient();
|
||||
const { data: { session: cached } } = await client.auth.getSession();
|
||||
const cachedToken = String(cached?.access_token || "").trim();
|
||||
const expiresAtSec = Number(cached?.expires_at || 0);
|
||||
const nowSec = Math.floor(Date.now() / 1000);
|
||||
const refreshLeadSec = 90;
|
||||
if (cachedToken && Number.isFinite(expiresAtSec) && expiresAtSec > nowSec + refreshLeadSec) {
|
||||
return cachedToken;
|
||||
}
|
||||
if (cachedToken && (!Number.isFinite(expiresAtSec) || expiresAtSec <= 0)) {
|
||||
return cachedToken;
|
||||
}
|
||||
const { data: { session: refreshed }, error } = await client.auth.refreshSession();
|
||||
const refreshedToken = String(refreshed?.access_token || "").trim();
|
||||
if (refreshedToken) return refreshedToken;
|
||||
if (cachedToken && Number.isFinite(expiresAtSec) && expiresAtSec > nowSec) {
|
||||
return cachedToken;
|
||||
}
|
||||
throw new Error(
|
||||
error?.message
|
||||
? isEn
|
||||
? `Session expired (${error.message}). Please sign out and sign in again.`
|
||||
: `登录会话已失效 (${error.message}),请退出后重新登录。`
|
||||
: isEn
|
||||
? "Session expired. Please sign out and sign in again."
|
||||
: "登录会话已失效,请退出后重新登录。",
|
||||
);
|
||||
}, [isEn, supabaseReady]);
|
||||
|
||||
// ── buildAuthedHeaders ──────────────────────────────────
|
||||
const buildAuthedHeaders = useCallback(
|
||||
async (withJson = false, requireAuth = false): Promise<Record<string, string>> => {
|
||||
const headers: Record<string, string> = {};
|
||||
if (withJson) headers["Content-Type"] = "application/json";
|
||||
if (!supabaseReady) return headers;
|
||||
try {
|
||||
const token = await getValidAccessToken();
|
||||
headers.Authorization = `Bearer ${token}`;
|
||||
} catch (error) {
|
||||
if (requireAuth) throw error;
|
||||
try {
|
||||
const { data: { session } } = await getSupabaseBrowserClient().auth.getSession();
|
||||
const fallbackToken = String(session?.access_token || "").trim();
|
||||
if (fallbackToken) headers.Authorization = `Bearer ${fallbackToken}`;
|
||||
} catch {
|
||||
// Non-authenticated page load — silently skip.
|
||||
}
|
||||
}
|
||||
return headers;
|
||||
},
|
||||
[supabaseReady, getValidAccessToken],
|
||||
);
|
||||
|
||||
// ── loadSnapshot ──────────────────────────────────────────
|
||||
const loadSnapshot = useCallback(async () => {
|
||||
setErrorText("");
|
||||
const attempt = async (retry: boolean): Promise<void> => {
|
||||
const userPromise = supabaseReady
|
||||
? getSupabaseBrowserClient().auth.getUser()
|
||||
: Promise.resolve({ data: { user: null as User | null } });
|
||||
const authHeadersPromise = buildAuthedHeaders(false);
|
||||
const backendPromise = authHeadersPromise.then((headers) =>
|
||||
fetch("/api/auth/me", { cache: "no-store", headers }),
|
||||
);
|
||||
const [userResult, backendResult] = await Promise.all([userPromise, backendPromise]);
|
||||
setUser(userResult.data?.user ?? null);
|
||||
if (!backendResult.ok) {
|
||||
if (retry && backendResult.status === 401) {
|
||||
await new Promise((r) => setTimeout(r, 1200));
|
||||
return attempt(false);
|
||||
}
|
||||
const raw = (await backendResult.text()).slice(0, 260);
|
||||
throw new Error(copy.httpError.replace("{status}", String(backendResult.status)).replace("{raw}", raw));
|
||||
}
|
||||
const backendJson = (await backendResult.json()) as AuthMeResponse;
|
||||
setBackend(backendJson);
|
||||
setUpdatedAt(new Date().toISOString());
|
||||
};
|
||||
try { await attempt(true); }
|
||||
catch (error) { setErrorText(String(error)); }
|
||||
}, [buildAuthedHeaders, supabaseReady, copy.httpError]);
|
||||
|
||||
// ── Shared state for sub-hooks ───────────────────────────
|
||||
// These state variables are managed in the master hook and passed
|
||||
// to the relevant sub-hooks. loadPaymentSnapshot crosses domains.
|
||||
const [paymentConfig, setPaymentConfig] = useState<PaymentConfig | null>(null);
|
||||
const [boundWallets, setBoundWallets] = useState<BoundWallet[]>([]);
|
||||
const [walletAddress, setWalletAddress] = useState("");
|
||||
const [selectedPlanCode, setSelectedPlanCode] = useState("pro_monthly");
|
||||
const [selectedTokenAddress, setSelectedTokenAddress] = useState("");
|
||||
const [selectedWallet, setSelectedWallet] = useState("");
|
||||
const [providerMode, setProviderMode] = useState<ProviderMode>("auto");
|
||||
const [injectedProviderOptions, setInjectedProviderOptions] = useState<InjectedProviderOption[]>([]);
|
||||
const [selectedInjectedProviderKey, setSelectedInjectedProviderKey] = useState("");
|
||||
|
||||
// ── Chain ID derived from payment config ────────────────
|
||||
const chainId = paymentConfig?.chain_id ?? 137;
|
||||
|
||||
// ── loadPaymentSnapshot ──────────────────────────────────
|
||||
// Defined in master because it sets state across multiple sub-hook domains.
|
||||
const loadPaymentSnapshotImpl = useCallback(async () => {
|
||||
if (!backend?.authenticated) {
|
||||
setPaymentConfig(null);
|
||||
setBoundWallets([]);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const authHeadersPromise = buildAuthedHeaders(false);
|
||||
const [configRes, walletsRes] = await Promise.all([
|
||||
authHeadersPromise.then((headers) => fetch("/api/payments/config", { cache: "no-store", headers })),
|
||||
authHeadersPromise.then((headers) => fetch("/api/payments/wallets", { cache: "no-store", headers })),
|
||||
]);
|
||||
if (configRes.ok) {
|
||||
const configJson = (await configRes.json()) as PaymentConfig;
|
||||
setPaymentConfig(configJson);
|
||||
if (!selectedPlanCode && configJson.plans?.length) {
|
||||
setSelectedPlanCode(configJson.plans[0].plan_code);
|
||||
}
|
||||
const tokenOptions = Array.isArray(configJson.tokens)
|
||||
? configJson.tokens.filter((row) => typeof row?.address === "string" && String(row.address).startsWith("0x"))
|
||||
: [];
|
||||
const defaultTokenAddress = String(
|
||||
configJson.default_token_address ||
|
||||
tokenOptions.find((row) => row.is_default)?.address ||
|
||||
tokenOptions[0]?.address ||
|
||||
configJson.token_address || "",
|
||||
).toLowerCase();
|
||||
if (defaultTokenAddress) {
|
||||
setSelectedTokenAddress((prev: string) => prev || defaultTokenAddress);
|
||||
}
|
||||
}
|
||||
if (walletsRes.ok) {
|
||||
const walletsJson = (await walletsRes.json()) as { wallets?: BoundWallet[] };
|
||||
const wallets = (Array.isArray(walletsJson.wallets) ? walletsJson.wallets : [])
|
||||
.filter((row) => {
|
||||
const status = String(row?.status || "active").toLowerCase();
|
||||
const address = String(row?.address || "");
|
||||
return status === "active" && address.startsWith("0x");
|
||||
})
|
||||
.map((row) => ({ ...row, address: String(row.address || "").toLowerCase() }));
|
||||
setBoundWallets(wallets);
|
||||
if (wallets.length) {
|
||||
const currentSelected = String(selectedWallet || "").toLowerCase();
|
||||
const hasCurrent = wallets.some((row) => String(row.address || "").toLowerCase() === currentSelected);
|
||||
const fallback = wallets.find((row) => Boolean(row.is_primary))?.address || wallets[0].address;
|
||||
if (!currentSelected || !hasCurrent) setSelectedWallet(fallback);
|
||||
const currentWalletAddress = String(walletAddress || "").toLowerCase();
|
||||
const hasWalletAddress = wallets.some((row) => String(row.address || "").toLowerCase() === currentWalletAddress);
|
||||
if (!currentWalletAddress || !hasWalletAddress) setWalletAddress(fallback);
|
||||
} else {
|
||||
setSelectedWallet("");
|
||||
setWalletAddress("");
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, [
|
||||
backend?.authenticated,
|
||||
buildAuthedHeaders,
|
||||
selectedPlanCode,
|
||||
selectedWallet,
|
||||
walletAddress,
|
||||
]);
|
||||
|
||||
// ── Selected plan (derived, shared across sub-hooks) ───
|
||||
const monthlyPlanList = (paymentConfig?.plans || []).filter(
|
||||
(p) => String(p.plan_code || "").trim().toLowerCase() === "pro_monthly",
|
||||
);
|
||||
const effectivePlanList = monthlyPlanList.length ? monthlyPlanList : (paymentConfig?.plans || []);
|
||||
const selectedPlan = effectivePlanList.find((p) => p.plan_code === selectedPlanCode) || effectivePlanList[0];
|
||||
|
||||
// ── useWalletBind ──────────────────────────────────────
|
||||
const walletBind = useWalletBind({
|
||||
isEn,
|
||||
walletConnectEnabled,
|
||||
copy,
|
||||
chainId,
|
||||
boundWallets,
|
||||
setBoundWallets,
|
||||
walletAddress,
|
||||
setWalletAddress,
|
||||
selectedWallet,
|
||||
setSelectedWallet,
|
||||
providerMode,
|
||||
setProviderMode,
|
||||
injectedProviderOptions,
|
||||
setInjectedProviderOptions,
|
||||
selectedInjectedProviderKey,
|
||||
setSelectedInjectedProviderKey,
|
||||
setPaymentBusy,
|
||||
setPaymentInfo,
|
||||
setPaymentError,
|
||||
setShowOverlay,
|
||||
clearPaymentMessages,
|
||||
authIsAuthenticated,
|
||||
getValidAccessToken,
|
||||
buildAuthedHeaders,
|
||||
loadSnapshot,
|
||||
loadPaymentSnapshot: loadPaymentSnapshotImpl,
|
||||
});
|
||||
|
||||
// Ref to break circular dependency: usePaymentFlow needs connectAndBindWallet from useWalletBind
|
||||
const connectAndBindWalletRef = useRef(walletBind.connectAndBindWallet);
|
||||
connectAndBindWalletRef.current = walletBind.connectAndBindWallet;
|
||||
|
||||
// ── useBilling ──────────────────────────────────────────
|
||||
const billing = useBilling({
|
||||
isEn,
|
||||
copy,
|
||||
backend,
|
||||
supabaseReady,
|
||||
paymentConfig,
|
||||
authUserId,
|
||||
authIsAuthenticated,
|
||||
usePoints,
|
||||
setUsePoints,
|
||||
lastIntentId,
|
||||
lastTxHash,
|
||||
lastPaymentStartedAt,
|
||||
setLastIntentId,
|
||||
setLastTxHash,
|
||||
setLastPaymentStartedAt,
|
||||
setPaymentBusy,
|
||||
setPaymentInfo,
|
||||
setPaymentError,
|
||||
setTelegramBindOpening,
|
||||
clearPaymentState,
|
||||
selectedPlan,
|
||||
getValidAccessToken,
|
||||
buildAuthedHeaders,
|
||||
loadSnapshot,
|
||||
loadPaymentSnapshot: loadPaymentSnapshotImpl,
|
||||
user,
|
||||
});
|
||||
|
||||
// Ref to break circular dependency: usePaymentFlow needs handleSubmit409 from useBilling
|
||||
const handleSubmit409Ref = useRef(billing.handleSubmit409);
|
||||
handleSubmit409Ref.current = billing.handleSubmit409;
|
||||
|
||||
// ── usePaymentFlow ──────────────────────────────────────
|
||||
const paymentFlow = usePaymentFlow({
|
||||
isEn,
|
||||
copy,
|
||||
backend,
|
||||
paymentConfig,
|
||||
setPaymentConfig,
|
||||
selectedPlanCode,
|
||||
setSelectedPlanCode,
|
||||
selectedTokenAddress,
|
||||
setSelectedTokenAddress,
|
||||
boundWallets,
|
||||
selectedWallet,
|
||||
walletAddress,
|
||||
providerMode,
|
||||
selectedInjectedProviderKey,
|
||||
manualTxHash,
|
||||
manualPayment,
|
||||
lastIntentId,
|
||||
setPaymentBusy,
|
||||
setPaymentInfo,
|
||||
setPaymentError,
|
||||
setLastIntentId,
|
||||
setLastTxHash,
|
||||
setLastPaymentStartedAt,
|
||||
setShowOverlay,
|
||||
setManualPayment,
|
||||
setManualTxHash,
|
||||
setTxValidation,
|
||||
setPaymentMethodTab,
|
||||
clearPaymentMessages,
|
||||
clearPaymentState,
|
||||
setSelectedWallet,
|
||||
setProviderMode,
|
||||
selectedPlan,
|
||||
billing: billing.billing,
|
||||
usePoints,
|
||||
currentPaymentHost: billing.currentPaymentHost,
|
||||
paymentHostAllowed: billing.paymentHostAllowed,
|
||||
allowedPaymentHosts: billing.allowedPaymentHosts,
|
||||
authIsAuthenticated,
|
||||
hasPayingWallet: walletBind.hasPayingWallet,
|
||||
getValidAccessToken,
|
||||
buildAuthedHeaders,
|
||||
loadSnapshot,
|
||||
loadPaymentSnapshot: loadPaymentSnapshotImpl,
|
||||
waitForReceipt: walletBind.waitForReceipt,
|
||||
ensureTargetChain: walletBind.ensureTargetChain,
|
||||
resolvePaymentProvider: walletBind.resolvePaymentProvider,
|
||||
connectAndBindWalletRef,
|
||||
handleSubmit409Ref,
|
||||
});
|
||||
|
||||
// ==========================================================
|
||||
return {
|
||||
// State from usePaymentState
|
||||
paymentBusy,
|
||||
paymentInfo,
|
||||
paymentError,
|
||||
lastIntentId,
|
||||
lastTxHash,
|
||||
lastPaymentStartedAt,
|
||||
telegramBindOpening,
|
||||
telegramBindUrl: billing.telegramBindUrl,
|
||||
manualPayment,
|
||||
manualTxHash,
|
||||
txValidation,
|
||||
paymentMethodTab,
|
||||
clearPaymentMessages,
|
||||
clearPaymentState,
|
||||
|
||||
// Setters from usePaymentState (needed by component render)
|
||||
setPaymentBusy,
|
||||
setPaymentInfo,
|
||||
setPaymentError,
|
||||
setLastIntentId,
|
||||
setLastTxHash,
|
||||
setLastPaymentStartedAt,
|
||||
setTelegramBindOpening,
|
||||
setPaymentMethodTab,
|
||||
setManualPayment,
|
||||
setManualTxHash,
|
||||
setTxValidation,
|
||||
|
||||
// Additional state
|
||||
paymentConfig,
|
||||
boundWallets,
|
||||
walletAddress,
|
||||
selectedPlanCode,
|
||||
selectedTokenAddress,
|
||||
selectedWallet,
|
||||
providerMode,
|
||||
injectedProviderOptions,
|
||||
selectedInjectedProviderKey,
|
||||
reconcileBusy: billing.reconcileBusy,
|
||||
|
||||
// Setters for shared state
|
||||
setSelectedTokenAddress,
|
||||
setSelectedWallet,
|
||||
setSelectedInjectedProviderKey,
|
||||
setProviderMode,
|
||||
|
||||
// Derived values
|
||||
authUserId,
|
||||
authIsAuthenticated,
|
||||
paymentReadyForRecovery: billing.paymentReadyForRecovery,
|
||||
hasRecentPaymentRecovery: billing.hasRecentPaymentRecovery,
|
||||
allowedPaymentHosts: billing.allowedPaymentHosts,
|
||||
currentPaymentHost: billing.currentPaymentHost,
|
||||
paymentHostAllowed: billing.paymentHostAllowed,
|
||||
selectedPlan,
|
||||
selectedPaymentToken: paymentFlow.selectedPaymentToken,
|
||||
selectedTokenLabel: paymentFlow.selectedTokenLabel,
|
||||
availableTokenList: paymentFlow.availableTokenList,
|
||||
effectivePlanList,
|
||||
resolvedSelectedTokenAddress: paymentFlow.resolvedSelectedTokenAddress,
|
||||
paymentReceiverAddress: paymentFlow.paymentReceiverAddress,
|
||||
paymentWalletLabel: walletBind.paymentWalletLabel,
|
||||
hasPayingWallet: walletBind.hasPayingWallet,
|
||||
totalPoints: billing.totalPoints,
|
||||
billing: billing.billing,
|
||||
|
||||
// Callbacks
|
||||
loadSnapshot,
|
||||
loadPaymentSnapshot: loadPaymentSnapshotImpl,
|
||||
connectAndBindWallet: walletBind.connectAndBindWallet,
|
||||
handleUnbindWallet: walletBind.handleUnbindWallet,
|
||||
createIntentAndPay: paymentFlow.createIntentAndPay,
|
||||
createManualPaymentIntent: paymentFlow.createManualPaymentIntent,
|
||||
submitManualPaymentTx: paymentFlow.submitManualPaymentTx,
|
||||
validateTxHash: paymentFlow.validateTxHash,
|
||||
handleOverlayCheckout: paymentFlow.handleOverlayCheckout,
|
||||
openTelegramBotBindLink: billing.openTelegramBotBindLink,
|
||||
};
|
||||
}
|
||||
@@ -1,398 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import type { User } from "@supabase/supabase-js";
|
||||
import {
|
||||
getAllowedPaymentHosts,
|
||||
getCurrentPaymentHost,
|
||||
isPaymentHostAllowed,
|
||||
} from "@/lib/payment-host";
|
||||
import type {
|
||||
AuthMeResponse,
|
||||
PaymentConfig,
|
||||
PaymentRecoveryState,
|
||||
TelegramPricing,
|
||||
} from "./types";
|
||||
import {
|
||||
PAYMENT_RECOVERY_STORAGE_KEY,
|
||||
PAYMENT_RECOVERY_TTL_MS,
|
||||
} from "./constants";
|
||||
import { clearStoredPaymentRecovery, shortAddress } from "./formatters";
|
||||
import { normalizePaymentError } from "./payment-utils";
|
||||
import { trackAppEvent } from "@/lib/app-analytics";
|
||||
|
||||
// ============================================================
|
||||
export interface UseBillingParams {
|
||||
isEn: boolean;
|
||||
copy: Record<string, string>;
|
||||
backend: AuthMeResponse | null;
|
||||
supabaseReady: boolean;
|
||||
|
||||
// Shared state from master
|
||||
paymentConfig: PaymentConfig | null;
|
||||
authUserId: string;
|
||||
authIsAuthenticated: boolean;
|
||||
usePoints: boolean;
|
||||
setUsePoints: (v: boolean) => void;
|
||||
|
||||
// Payment UI state from usePaymentState
|
||||
lastIntentId: string;
|
||||
lastTxHash: string;
|
||||
lastPaymentStartedAt: number;
|
||||
setLastIntentId: (v: string) => void;
|
||||
setLastTxHash: (v: string) => void;
|
||||
setLastPaymentStartedAt: (v: number) => void;
|
||||
setPaymentBusy: (v: boolean) => void;
|
||||
setPaymentInfo: (v: string) => void;
|
||||
setPaymentError: (v: string) => void;
|
||||
setTelegramBindOpening: (v: boolean) => void;
|
||||
clearPaymentState: () => void;
|
||||
|
||||
// Derived / callbacks from master
|
||||
selectedPlan?: { plan_code?: string; amount_usdc?: string } | undefined;
|
||||
getValidAccessToken: () => Promise<string>;
|
||||
buildAuthedHeaders: (withJson?: boolean, requireAuth?: boolean) => Promise<Record<string, string>>;
|
||||
loadSnapshot: () => Promise<void>;
|
||||
loadPaymentSnapshot: () => Promise<void>;
|
||||
|
||||
// User for metadata points
|
||||
user: User | null;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
export function useBilling(params: UseBillingParams) {
|
||||
const {
|
||||
isEn,
|
||||
copy,
|
||||
backend,
|
||||
supabaseReady,
|
||||
paymentConfig,
|
||||
authUserId,
|
||||
authIsAuthenticated,
|
||||
usePoints,
|
||||
setUsePoints,
|
||||
lastIntentId,
|
||||
lastTxHash,
|
||||
lastPaymentStartedAt,
|
||||
setLastIntentId,
|
||||
setLastTxHash,
|
||||
setLastPaymentStartedAt,
|
||||
setPaymentBusy,
|
||||
setPaymentInfo,
|
||||
setPaymentError,
|
||||
setTelegramBindOpening,
|
||||
clearPaymentState,
|
||||
selectedPlan,
|
||||
getValidAccessToken,
|
||||
buildAuthedHeaders,
|
||||
loadSnapshot,
|
||||
loadPaymentSnapshot,
|
||||
user,
|
||||
} = params;
|
||||
|
||||
// ── Billing-specific state ────────────────────────────────
|
||||
const [reconcileBusy, setReconcileBusy] = useState(false);
|
||||
const [telegramBindUrl, setTelegramBindUrl] = useState("");
|
||||
|
||||
// ── Derived values ──────────────────────────────────────
|
||||
const paymentReadyForRecovery = Boolean(paymentConfig?.enabled && paymentConfig?.configured);
|
||||
const hasRecentPaymentRecovery =
|
||||
Boolean(lastIntentId && lastTxHash && authUserId && lastPaymentStartedAt) &&
|
||||
Date.now() - lastPaymentStartedAt <= PAYMENT_RECOVERY_TTL_MS;
|
||||
const allowedPaymentHosts = useMemo(() => getAllowedPaymentHosts(), []);
|
||||
const currentPaymentHost = useMemo(() => getCurrentPaymentHost(), []);
|
||||
const paymentHostAllowed = useMemo(() => isPaymentHostAllowed(currentPaymentHost), [currentPaymentHost]);
|
||||
|
||||
// ── Points computation ──────────────────────────────────
|
||||
const backendPointsRaw = Number(backend?.points);
|
||||
const metadataPointsRaw = Number(
|
||||
user?.user_metadata?.points ?? user?.user_metadata?.total_points ?? 0,
|
||||
);
|
||||
const metadataPointsSafe = Number.isFinite(metadataPointsRaw) ? metadataPointsRaw : 0;
|
||||
const pointsRaw = Number.isFinite(backendPointsRaw)
|
||||
? Math.max(backendPointsRaw, metadataPointsSafe)
|
||||
: metadataPointsSafe;
|
||||
const totalPoints = Number.isFinite(pointsRaw) ? Math.max(0, pointsRaw) : 0;
|
||||
|
||||
// ── Billing ──────────────────────────────────────────────
|
||||
const billing = useMemo(() => {
|
||||
const parsedPlanAmount = Number(
|
||||
backend?.telegram_pricing?.amount_usdc ?? selectedPlan?.amount_usdc ?? 10,
|
||||
);
|
||||
const planAmount = Number.isFinite(parsedPlanAmount) && parsedPlanAmount > 0 ? parsedPlanAmount : 10;
|
||||
|
||||
const pointsCfg = paymentConfig?.points_redemption || {};
|
||||
const pointsEnabled = pointsCfg.enabled !== false;
|
||||
const pointsPerUsdcRaw = Number(pointsCfg.points_per_usdc ?? 500);
|
||||
const pointsPerUsdc =
|
||||
Number.isFinite(pointsPerUsdcRaw) && pointsPerUsdcRaw > 0 ? Math.floor(pointsPerUsdcRaw) : 500;
|
||||
|
||||
const maxDiscountRaw = Number(pointsCfg.max_discount_usdc ?? 3);
|
||||
const maxDiscountUsdc = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
Math.floor(Number.isFinite(maxDiscountRaw) ? maxDiscountRaw : 3),
|
||||
Math.floor(planAmount),
|
||||
),
|
||||
);
|
||||
|
||||
const maxRedeemablePoints = pointsPerUsdc * maxDiscountUsdc;
|
||||
const actualRedeem = pointsEnabled ? Math.min(totalPoints, maxRedeemablePoints) : 0;
|
||||
const discountUnits = Math.floor(actualRedeem / pointsPerUsdc);
|
||||
const pointsUsed = discountUnits * pointsPerUsdc;
|
||||
const canRedeem = pointsEnabled && maxDiscountUsdc > 0 && totalPoints >= pointsPerUsdc;
|
||||
const applyDiscount = usePoints && canRedeem && pointsUsed > 0;
|
||||
|
||||
return {
|
||||
planAmount,
|
||||
pointsEnabled,
|
||||
pointsPerUsdc,
|
||||
maxDiscountUsdc,
|
||||
pointsUsed,
|
||||
discountAmount: discountUnits,
|
||||
payAmount: planAmount - (applyDiscount ? discountUnits : 0),
|
||||
canRedeem,
|
||||
};
|
||||
}, [
|
||||
paymentConfig?.points_redemption,
|
||||
backend?.telegram_pricing?.amount_usdc,
|
||||
selectedPlan?.amount_usdc,
|
||||
totalPoints,
|
||||
usePoints,
|
||||
]);
|
||||
|
||||
// ── reconcileLatestPayment ──────────────────────────────
|
||||
const reconcileLatestPayment = useCallback(async () => {
|
||||
if (!authIsAuthenticated || reconcileBusy) return false;
|
||||
setReconcileBusy(true);
|
||||
try {
|
||||
const headers = await buildAuthedHeaders(true, true);
|
||||
const res = await fetch("/api/payments/reconcile-latest", {
|
||||
method: "POST", headers,
|
||||
});
|
||||
if (!res.ok) return false;
|
||||
const json = (await res.json()) as {
|
||||
ok?: boolean; action?: string; subscription?: { plan_code?: string | null } | null;
|
||||
};
|
||||
if (json.ok) {
|
||||
setPaymentInfo(copy.walletRecoveryDone);
|
||||
setPaymentError("");
|
||||
await loadSnapshot();
|
||||
await loadPaymentSnapshot();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
setReconcileBusy(false);
|
||||
}
|
||||
}, [
|
||||
authIsAuthenticated, buildAuthedHeaders, copy.walletRecoveryDone,
|
||||
loadPaymentSnapshot, loadSnapshot, reconcileBusy,
|
||||
]);
|
||||
|
||||
// ── handleSubmit409 ────────────────────────────────────
|
||||
const handleSubmit409 = useCallback(
|
||||
async (intentId: string, txHashNorm: string, raw: string) => {
|
||||
const lowerRaw = raw.toLowerCase();
|
||||
if (lowerRaw.includes("已支付") || lowerRaw.includes("already confirmed") || lowerRaw.includes("already paid")) {
|
||||
const ok = await reconcileLatestPayment();
|
||||
if (ok) return;
|
||||
setPaymentInfo(copy.orderAlreadyPaid);
|
||||
await loadSnapshot();
|
||||
await loadPaymentSnapshot();
|
||||
return;
|
||||
}
|
||||
if (lowerRaw.includes("expired")) {
|
||||
throw new Error(copy.orderExpired);
|
||||
}
|
||||
try {
|
||||
const headers = await buildAuthedHeaders(true, false);
|
||||
const intentRes = await fetch(`/api/payments/intents/${intentId}`, { headers, cache: "no-store" });
|
||||
if (intentRes.ok) {
|
||||
const intentJson = (await intentRes.json()) as {
|
||||
intent?: { status?: string; tx_hash?: string };
|
||||
};
|
||||
const status = intentJson.intent?.status;
|
||||
if (status === "confirmed") {
|
||||
await reconcileLatestPayment();
|
||||
const txHash = intentJson.intent?.tx_hash || txHashNorm;
|
||||
setPaymentInfo(`支付已确认,交易: ${shortAddress(txHash)}`);
|
||||
setPaymentError("");
|
||||
await loadSnapshot();
|
||||
await loadPaymentSnapshot();
|
||||
return;
|
||||
}
|
||||
if (status === "expired") throw new Error(copy.orderExpired);
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.message !== raw) throw e;
|
||||
}
|
||||
throw new Error(copy.submitTxFailed.replace("{raw}", raw));
|
||||
},
|
||||
[buildAuthedHeaders, copy, loadPaymentSnapshot, loadSnapshot, reconcileLatestPayment],
|
||||
);
|
||||
|
||||
// ── openTelegramBotBindLink ──────────────────────────────
|
||||
const openTelegramBotBindLink = async () => {
|
||||
setTelegramBindOpening(true);
|
||||
setPaymentError("");
|
||||
setTelegramBindUrl("");
|
||||
const popup = window.open("about:blank", "_blank", "noopener,noreferrer");
|
||||
try {
|
||||
const authHeaders = await buildAuthedHeaders(true, false);
|
||||
const res = await fetch("/api/auth/telegram/bot-bind-link", {
|
||||
method: "POST", headers: authHeaders,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const raw = (await res.text()).slice(0, 300);
|
||||
throw new Error(raw || copy.telegramBindFailed);
|
||||
}
|
||||
const data = (await res.json()) as { bot_url?: string };
|
||||
const botUrl = String(data.bot_url || "").trim();
|
||||
if (!botUrl) throw new Error(copy.telegramBindLinkMissing);
|
||||
if (popup && !popup.closed) {
|
||||
popup.location.href = botUrl;
|
||||
setPaymentInfo(copy.telegramBindClickHint);
|
||||
} else {
|
||||
setPaymentInfo(copy.telegramPopupBlocked);
|
||||
setTelegramBindUrl(botUrl);
|
||||
}
|
||||
} catch (error) {
|
||||
if (popup && !popup.closed) popup.close();
|
||||
setPaymentError(normalizePaymentError(error).message);
|
||||
} finally {
|
||||
setTelegramBindOpening(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ── loadPaymentSnapshot effect ────────────────────────────
|
||||
useEffect(() => {
|
||||
void loadPaymentSnapshot();
|
||||
}, [loadPaymentSnapshot]);
|
||||
|
||||
// ── Recovery save effect ────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
if (!(lastIntentId && lastTxHash && authUserId && lastPaymentStartedAt)) {
|
||||
clearStoredPaymentRecovery();
|
||||
return;
|
||||
}
|
||||
const payload: PaymentRecoveryState = {
|
||||
intentId: lastIntentId,
|
||||
txHash: lastTxHash,
|
||||
userId: authUserId,
|
||||
createdAt: lastPaymentStartedAt,
|
||||
};
|
||||
window.sessionStorage.setItem(PAYMENT_RECOVERY_STORAGE_KEY, JSON.stringify(payload));
|
||||
}, [authUserId, lastIntentId, lastPaymentStartedAt, lastTxHash]);
|
||||
|
||||
// ── Recovery restore effect ──────────────────────────────
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
if (!authUserId) return;
|
||||
if (lastIntentId && lastTxHash && lastPaymentStartedAt) return;
|
||||
const raw = window.sessionStorage.getItem(PAYMENT_RECOVERY_STORAGE_KEY);
|
||||
if (!raw) return;
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as PaymentRecoveryState;
|
||||
const userId = String(parsed?.userId || "").trim();
|
||||
const intentId = String(parsed?.intentId || "").trim();
|
||||
const txHash = String(parsed?.txHash || "").trim().toLowerCase();
|
||||
const createdAt = Number(parsed?.createdAt || 0);
|
||||
const expired = !createdAt || Date.now() - createdAt > PAYMENT_RECOVERY_TTL_MS;
|
||||
if (expired || !intentId || !txHash || !userId || userId !== authUserId) {
|
||||
clearStoredPaymentRecovery();
|
||||
return;
|
||||
}
|
||||
setLastIntentId(intentId);
|
||||
setLastTxHash(txHash);
|
||||
setLastPaymentStartedAt(createdAt);
|
||||
} catch {
|
||||
clearStoredPaymentRecovery();
|
||||
}
|
||||
}, [authUserId, lastIntentId, lastPaymentStartedAt, lastTxHash]);
|
||||
|
||||
// ── Subscription cleanup effect ──────────────────────────
|
||||
useEffect(() => {
|
||||
if (!backend?.subscription_active) return;
|
||||
clearPaymentState();
|
||||
clearStoredPaymentRecovery();
|
||||
}, [backend?.subscription_active]);
|
||||
|
||||
// ── Auto-reconcile effect ──────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!authIsAuthenticated) return;
|
||||
if (backend?.subscription_active) return;
|
||||
if (!paymentReadyForRecovery) return;
|
||||
if (!hasRecentPaymentRecovery) return;
|
||||
let cancelled = false;
|
||||
const run = async () => {
|
||||
setPaymentInfo(copy.walletRecoveryBusy);
|
||||
const repaired = await reconcileLatestPayment();
|
||||
if (cancelled) return;
|
||||
if (!repaired && !backend?.subscription_active) {
|
||||
setPaymentInfo("");
|
||||
setPaymentError(copy.walletRecoveryFailed);
|
||||
}
|
||||
};
|
||||
void run();
|
||||
return () => { cancelled = true; };
|
||||
}, [
|
||||
backend?.subscription_active, authIsAuthenticated, copy.walletRecoveryBusy,
|
||||
copy.walletRecoveryFailed, hasRecentPaymentRecovery,
|
||||
paymentReadyForRecovery, reconcileLatestPayment,
|
||||
]);
|
||||
|
||||
// ── bind_token URL effect ──────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!authIsAuthenticated) return;
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const token = params.get("bind_token");
|
||||
if (!token) return;
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete("bind_token");
|
||||
window.history.replaceState(null, "", url.toString());
|
||||
(async () => {
|
||||
setPaymentError("");
|
||||
setPaymentInfo("");
|
||||
try {
|
||||
const authHeaders = await buildAuthedHeaders(true, false);
|
||||
const res = await fetch("/api/auth/telegram/bind-by-token", {
|
||||
method: "POST", headers: authHeaders, body: JSON.stringify({ token }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const raw = (await res.text()).slice(0, 350);
|
||||
throw new Error(copy.bindFailed.replace("{raw}", raw));
|
||||
}
|
||||
const data = (await res.json()) as { telegram_pricing?: TelegramPricing | null };
|
||||
if (data.telegram_pricing?.is_group_member) {
|
||||
const amount = data.telegram_pricing.amount_usdc || "10";
|
||||
setPaymentInfo(copy.telegramVerifySuccess.replace("{amount}", amount));
|
||||
}
|
||||
await loadSnapshot();
|
||||
await loadPaymentSnapshot();
|
||||
} catch (error) {
|
||||
setPaymentError(normalizePaymentError(error).message);
|
||||
}
|
||||
})();
|
||||
}, [authIsAuthenticated, buildAuthedHeaders, loadPaymentSnapshot, loadSnapshot]);
|
||||
|
||||
// ==========================================================
|
||||
return {
|
||||
reconcileBusy,
|
||||
telegramBindUrl,
|
||||
setTelegramBindUrl,
|
||||
reconcileLatestPayment,
|
||||
handleSubmit409,
|
||||
openTelegramBotBindLink,
|
||||
paymentReadyForRecovery,
|
||||
hasRecentPaymentRecovery,
|
||||
allowedPaymentHosts,
|
||||
currentPaymentHost,
|
||||
paymentHostAllowed,
|
||||
totalPoints,
|
||||
billing,
|
||||
};
|
||||
}
|
||||
@@ -1,708 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo } from "react";
|
||||
import type {
|
||||
AuthMeResponse,
|
||||
BoundWallet,
|
||||
ConnectBindOptions,
|
||||
CreatedIntent,
|
||||
EvmProvider,
|
||||
IntentStatusResponse,
|
||||
PaymentConfig,
|
||||
PaymentTokenOption,
|
||||
ProviderMode,
|
||||
ProviderSelection,
|
||||
} from "./types";
|
||||
import {
|
||||
WALLET_TRANSACTION_REQUEST_TIMEOUT_MS,
|
||||
} from "./constants";
|
||||
import { clearStoredPaymentRecovery, shortAddress } from "./formatters";
|
||||
import {
|
||||
buildAllowanceCalldata,
|
||||
buildApproveCalldata,
|
||||
buildBalanceOfCalldata,
|
||||
formatTokenUnits,
|
||||
normalizePaymentError,
|
||||
requestWalletWithTimeout,
|
||||
} from "./payment-utils";
|
||||
import { trackAppEvent } from "@/lib/app-analytics";
|
||||
import {
|
||||
assertExpectedPaymentReceiver,
|
||||
EXPECTED_PAYMENT_RECEIVER_ADDRESS,
|
||||
} from "@/lib/payment-receiver";
|
||||
import type { PaymentTxValidationState } from "./usePaymentState";
|
||||
|
||||
// ============================================================
|
||||
export interface UsePaymentFlowParams {
|
||||
isEn: boolean;
|
||||
copy: Record<string, string>;
|
||||
backend: AuthMeResponse | null;
|
||||
|
||||
// Shared payment config state
|
||||
paymentConfig: PaymentConfig | null;
|
||||
setPaymentConfig: React.Dispatch<React.SetStateAction<PaymentConfig | null>>;
|
||||
selectedPlanCode: string;
|
||||
setSelectedPlanCode: React.Dispatch<React.SetStateAction<string>>;
|
||||
selectedTokenAddress: string;
|
||||
setSelectedTokenAddress: React.Dispatch<React.SetStateAction<string>>;
|
||||
|
||||
// Shared wallet state (read-only)
|
||||
boundWallets: BoundWallet[];
|
||||
selectedWallet: string;
|
||||
walletAddress: string;
|
||||
providerMode: ProviderMode;
|
||||
selectedInjectedProviderKey: string;
|
||||
|
||||
// Payment UI state + setters
|
||||
manualTxHash: string;
|
||||
manualPayment: CreatedIntent["direct_payment"] | null;
|
||||
lastIntentId: string;
|
||||
setPaymentBusy: (v: boolean) => void;
|
||||
setPaymentInfo: (v: string) => void;
|
||||
setPaymentError: (v: string) => void;
|
||||
setLastIntentId: (v: string) => void;
|
||||
setLastTxHash: (v: string) => void;
|
||||
setLastPaymentStartedAt: (v: number) => void;
|
||||
setShowOverlay: (v: boolean) => void;
|
||||
setManualPayment: (v: CreatedIntent["direct_payment"] | null) => void;
|
||||
setManualTxHash: (v: string) => void;
|
||||
setTxValidation: (v: PaymentTxValidationState) => void;
|
||||
setPaymentMethodTab: (v: "wallet" | "manual") => void;
|
||||
clearPaymentMessages: () => void;
|
||||
clearPaymentState: () => void;
|
||||
|
||||
// Setters for shared state
|
||||
setSelectedWallet: React.Dispatch<React.SetStateAction<string>>;
|
||||
setProviderMode: React.Dispatch<React.SetStateAction<ProviderMode>>;
|
||||
|
||||
// Derived values from master
|
||||
selectedPlan?: { plan_code?: string; amount_usdc?: string };
|
||||
billing: {
|
||||
planAmount: number;
|
||||
pointsEnabled: boolean;
|
||||
pointsPerUsdc: number;
|
||||
maxDiscountUsdc: number;
|
||||
pointsUsed: number;
|
||||
discountAmount: number;
|
||||
payAmount: number;
|
||||
canRedeem: boolean;
|
||||
};
|
||||
usePoints: boolean;
|
||||
currentPaymentHost: string;
|
||||
paymentHostAllowed: boolean;
|
||||
allowedPaymentHosts: string[];
|
||||
authIsAuthenticated: boolean;
|
||||
hasPayingWallet: boolean;
|
||||
|
||||
// Callbacks from master
|
||||
getValidAccessToken: () => Promise<string>;
|
||||
buildAuthedHeaders: (withJson?: boolean, requireAuth?: boolean) => Promise<Record<string, string>>;
|
||||
loadSnapshot: () => Promise<void>;
|
||||
loadPaymentSnapshot: () => Promise<void>;
|
||||
waitForReceipt: (txHash: string, provider?: EvmProvider, timeoutMs?: number, pollMs?: number) => Promise<any>;
|
||||
ensureTargetChain: (eth: EvmProvider, targetChainId: number) => Promise<void>;
|
||||
|
||||
// Ref-wrapped cross-hook callbacks
|
||||
connectAndBindWalletRef: React.MutableRefObject<((mode?: ProviderMode, options?: ConnectBindOptions) => Promise<boolean>) | null>;
|
||||
handleSubmit409Ref: React.MutableRefObject<((intentId: string, txHashNorm: string, raw: string) => Promise<void>) | null>;
|
||||
|
||||
// Wallet provider helpers
|
||||
resolvePaymentProvider: (mode?: ProviderMode, preferredInjectedKey?: string) => Promise<ProviderSelection>;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
export function usePaymentFlow(params: UsePaymentFlowParams) {
|
||||
const {
|
||||
isEn,
|
||||
copy,
|
||||
backend,
|
||||
paymentConfig,
|
||||
setPaymentConfig,
|
||||
selectedPlanCode,
|
||||
setSelectedPlanCode,
|
||||
selectedTokenAddress,
|
||||
setSelectedTokenAddress,
|
||||
boundWallets,
|
||||
selectedWallet,
|
||||
walletAddress,
|
||||
providerMode,
|
||||
selectedInjectedProviderKey,
|
||||
setPaymentBusy,
|
||||
setPaymentInfo,
|
||||
setPaymentError,
|
||||
setLastIntentId,
|
||||
setLastTxHash,
|
||||
setLastPaymentStartedAt,
|
||||
setShowOverlay,
|
||||
setManualPayment,
|
||||
setManualTxHash,
|
||||
setTxValidation,
|
||||
manualTxHash,
|
||||
manualPayment,
|
||||
lastIntentId,
|
||||
setPaymentMethodTab,
|
||||
clearPaymentMessages,
|
||||
clearPaymentState,
|
||||
setSelectedWallet,
|
||||
setProviderMode,
|
||||
billing,
|
||||
usePoints,
|
||||
currentPaymentHost,
|
||||
paymentHostAllowed,
|
||||
allowedPaymentHosts,
|
||||
authIsAuthenticated,
|
||||
hasPayingWallet,
|
||||
getValidAccessToken,
|
||||
buildAuthedHeaders,
|
||||
loadSnapshot,
|
||||
loadPaymentSnapshot,
|
||||
waitForReceipt,
|
||||
ensureTargetChain,
|
||||
connectAndBindWalletRef,
|
||||
handleSubmit409Ref,
|
||||
resolvePaymentProvider,
|
||||
} = params;
|
||||
|
||||
// ── Derived payment values ──────────────────────────────
|
||||
const planList = paymentConfig?.plans || [];
|
||||
const monthlyPlanList = planList.filter(
|
||||
(plan) => String(plan.plan_code || "").trim().toLowerCase() === "pro_monthly",
|
||||
);
|
||||
const effectivePlanList = monthlyPlanList.length ? monthlyPlanList : planList;
|
||||
const selectedPlan = effectivePlanList.find((plan) => plan.plan_code === selectedPlanCode) || effectivePlanList[0];
|
||||
|
||||
const availableTokenList: PaymentTokenOption[] = useMemo(() => {
|
||||
const configured = Array.isArray(paymentConfig?.tokens) ? paymentConfig?.tokens || [] : [];
|
||||
const clean = configured
|
||||
.filter((row) => row && typeof row.address === "string" && row.address.startsWith("0x"))
|
||||
.map((row) => ({
|
||||
...row,
|
||||
address: String(row.address).toLowerCase(),
|
||||
symbol: String(row.symbol || "USDC"),
|
||||
name: String(row.name || row.symbol || "USDC"),
|
||||
code: String(row.code || "usdc"),
|
||||
decimals: Number.isFinite(Number(row.decimals))
|
||||
? Number(row.decimals)
|
||||
: Number(paymentConfig?.token_decimals ?? 6),
|
||||
}));
|
||||
if (clean.length) return clean;
|
||||
const fallbackAddress = String(paymentConfig?.token_address || "").toLowerCase();
|
||||
if (!fallbackAddress.startsWith("0x")) return [];
|
||||
return [{
|
||||
code: "usdc", symbol: "USDC", name: "USDC", address: fallbackAddress,
|
||||
decimals: Number(paymentConfig?.token_decimals ?? 6),
|
||||
receiver_contract: paymentConfig?.receiver_contract, is_default: true,
|
||||
}];
|
||||
}, [paymentConfig]);
|
||||
|
||||
const resolvedSelectedTokenAddress = String(
|
||||
selectedTokenAddress || paymentConfig?.default_token_address ||
|
||||
availableTokenList.find((row) => row.is_default)?.address ||
|
||||
availableTokenList[0]?.address || paymentConfig?.token_address || "",
|
||||
).toLowerCase();
|
||||
|
||||
const selectedPaymentToken = availableTokenList.find(
|
||||
(row) => row.address === resolvedSelectedTokenAddress,
|
||||
) || availableTokenList[0];
|
||||
|
||||
const selectedTokenLabel = selectedPaymentToken?.symbol ||
|
||||
(resolvedSelectedTokenAddress.startsWith("0x") ? shortAddress(resolvedSelectedTokenAddress) : "USDC");
|
||||
|
||||
const paymentReceiverAddress = EXPECTED_PAYMENT_RECEIVER_ADDRESS;
|
||||
|
||||
// ── fetchLatestPaymentConfig ────────────────────────────
|
||||
const fetchLatestPaymentConfig = useCallback(
|
||||
async (authHeaders?: Record<string, string>, syncState = true): Promise<PaymentConfig> => {
|
||||
const headers = authHeaders || (await buildAuthedHeaders(false));
|
||||
const configRes = await fetch("/api/payments/config", { cache: "no-store", headers });
|
||||
if (!configRes.ok) {
|
||||
const raw = (await configRes.text()).slice(0, 350);
|
||||
throw new Error(copy.loadConfigFailed.replace("{raw}", raw));
|
||||
}
|
||||
const configJson = (await configRes.json()) as PaymentConfig;
|
||||
if (syncState) {
|
||||
setPaymentConfig(configJson);
|
||||
if (!selectedPlanCode && configJson.plans?.length) {
|
||||
setSelectedPlanCode(configJson.plans[0].plan_code);
|
||||
}
|
||||
const tokenOptions = Array.isArray(configJson.tokens)
|
||||
? configJson.tokens.filter((row) => typeof row?.address === "string" && String(row.address).startsWith("0x"))
|
||||
: [];
|
||||
const defaultTokenAddress = String(
|
||||
configJson.default_token_address ||
|
||||
tokenOptions.find((row) => row.is_default)?.address ||
|
||||
tokenOptions[0]?.address || configJson.token_address || "",
|
||||
).toLowerCase();
|
||||
if (defaultTokenAddress) {
|
||||
setSelectedTokenAddress((prev: string) => prev || defaultTokenAddress);
|
||||
}
|
||||
}
|
||||
return configJson;
|
||||
},
|
||||
[buildAuthedHeaders, selectedPlanCode],
|
||||
);
|
||||
|
||||
// ── pollIntentUntilConfirmed ────────────────────────────
|
||||
const pollIntentUntilConfirmed = useCallback(
|
||||
async (intentId: string, authHeaders: Record<string, string>, txHashHint = "", timeoutMs = 180000, pollMs = 5000) => {
|
||||
const startedAt = Date.now();
|
||||
const shortTx = shortAddress(txHashHint);
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
const statusRes = await fetch(`/api/payments/intents/${intentId}`, {
|
||||
method: "GET", headers: authHeaders, cache: "no-store",
|
||||
});
|
||||
if (!statusRes.ok) {
|
||||
if (statusRes.status >= 500 || statusRes.status === 429) {
|
||||
await new Promise((resolve) => setTimeout(resolve, pollMs));
|
||||
continue;
|
||||
}
|
||||
const raw = (await statusRes.text()).slice(0, 260);
|
||||
throw new Error(copy.queryIntentFailed.replace("{raw}", raw));
|
||||
}
|
||||
const statusJson = (await statusRes.json()) as IntentStatusResponse;
|
||||
const intent = statusJson.intent || {};
|
||||
const status = String(intent.status || "").toLowerCase();
|
||||
const txHash = String(intent.tx_hash || txHashHint || "").toLowerCase();
|
||||
if (status === "confirmed") {
|
||||
setPaymentError("");
|
||||
setPaymentInfo(copy.paymentConfirmed.replace("{txHash}", shortAddress(txHash)));
|
||||
trackAppEvent("checkout_succeeded", {
|
||||
entry: "account_center",
|
||||
plan_code: selectedPlan?.plan_code || "pro_monthly",
|
||||
intent_id: intentId,
|
||||
tx_hash: txHash || null,
|
||||
});
|
||||
await loadSnapshot();
|
||||
await loadPaymentSnapshot();
|
||||
return;
|
||||
}
|
||||
if (status === "failed" || status === "cancelled" || status === "expired") {
|
||||
throw new Error(copy.paymentStatus.replace("{status}", status));
|
||||
}
|
||||
setPaymentInfo(copy.txSubmitted.replace("{txHash}", shortTx).replace("{status}", status || "submitted"));
|
||||
await new Promise((resolve) => setTimeout(resolve, pollMs));
|
||||
}
|
||||
throw new Error(copy.paymentPendingTimeout);
|
||||
},
|
||||
[loadPaymentSnapshot, loadSnapshot, selectedPlan?.plan_code],
|
||||
);
|
||||
|
||||
// ── createIntentAndPay ──────────────────────────────────
|
||||
const createIntentAndPay = async () => {
|
||||
clearPaymentMessages();
|
||||
clearPaymentState();
|
||||
clearStoredPaymentRecovery();
|
||||
if (!paymentHostAllowed) {
|
||||
setPaymentError(copy.paymentHostBlocked.replace("{host}", allowedPaymentHosts[0] || "polyweather.top"));
|
||||
return;
|
||||
}
|
||||
if (!authIsAuthenticated) {
|
||||
setPaymentError(copy.loginBeforePay);
|
||||
return;
|
||||
}
|
||||
if (!paymentConfig?.configured) {
|
||||
setPaymentError(copy.payNotReady);
|
||||
return;
|
||||
}
|
||||
|
||||
const fallbackWallet = String(selectedWallet || walletAddress || boundWallets[0]?.address || "").toLowerCase();
|
||||
if (!fallbackWallet) {
|
||||
setPaymentError(copy.bindFirstBeforePay);
|
||||
return;
|
||||
}
|
||||
|
||||
setPaymentBusy(true);
|
||||
let approvedInThisRun = false;
|
||||
try {
|
||||
const providerSelection = await resolvePaymentProvider(providerMode, selectedInjectedProviderKey);
|
||||
const eth = providerSelection.provider;
|
||||
const activeAccounts = await requestWalletWithTimeout<string[]>(
|
||||
eth, { method: "eth_requestAccounts" }, "连接付款钱包",
|
||||
);
|
||||
const activeAddress = String(activeAccounts?.[0] || "").toLowerCase();
|
||||
if (!activeAddress) throw new Error(isEn ? "Wallet account is empty." : "钱包账户为空");
|
||||
|
||||
const boundAddrSet = new Set(boundWallets.map((row) => String(row.address || "").toLowerCase()));
|
||||
if (boundAddrSet.size > 0 && !boundAddrSet.has(activeAddress)) {
|
||||
throw new Error(`当前连接钱包 ${shortAddress(activeAddress)} 未绑定,请先绑定该地址后支付。`);
|
||||
}
|
||||
const payingWallet = boundAddrSet.has(activeAddress) ? activeAddress : fallbackWallet;
|
||||
|
||||
setSelectedWallet(payingWallet);
|
||||
setProviderMode(providerSelection.mode);
|
||||
|
||||
let accessToken: string;
|
||||
try { accessToken = await getValidAccessToken(); }
|
||||
catch (tokenErr) {
|
||||
setPaymentError(normalizePaymentError(tokenErr).message);
|
||||
setPaymentBusy(false);
|
||||
return;
|
||||
}
|
||||
const authHeaders: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
};
|
||||
|
||||
const latestConfig = await fetchLatestPaymentConfig(authHeaders, true);
|
||||
if (!latestConfig?.enabled || !latestConfig?.configured) throw new Error(copy.payNotReady);
|
||||
const expectedReceiver = String(latestConfig.receiver_contract || "").toLowerCase();
|
||||
assertExpectedPaymentReceiver(expectedReceiver, "payment receiver contract");
|
||||
if (paymentConfig?.receiver_contract && String(paymentConfig.receiver_contract).toLowerCase() !== expectedReceiver) {
|
||||
setPaymentInfo(copy.paymentConfigUpdated.replace("{address}", shortAddress(expectedReceiver)));
|
||||
} else {
|
||||
setPaymentInfo(copy.currentReceiver.replace("{address}", shortAddress(expectedReceiver)));
|
||||
}
|
||||
|
||||
const targetChainId = Number(latestConfig.chain_id || 137);
|
||||
await ensureTargetChain(eth, targetChainId);
|
||||
|
||||
const createRes = await fetch("/api/payments/intents", {
|
||||
method: "POST",
|
||||
headers: authHeaders,
|
||||
body: JSON.stringify({
|
||||
plan_code: selectedPlan?.plan_code || "pro_monthly",
|
||||
payment_mode: "strict",
|
||||
allowed_wallet: payingWallet,
|
||||
token_address: resolvedSelectedTokenAddress || undefined,
|
||||
use_points: billing.canRedeem && usePoints,
|
||||
points_to_consume: billing.canRedeem && usePoints ? billing.pointsUsed : 0,
|
||||
metadata: {
|
||||
source: "account_center",
|
||||
frontend_host: currentPaymentHost || null,
|
||||
account_email: backend?.email || null,
|
||||
},
|
||||
}),
|
||||
});
|
||||
if (!createRes.ok) {
|
||||
const raw = (await createRes.text()).slice(0, 350);
|
||||
throw new Error(copy.createIntentFailed.replace("{raw}", raw));
|
||||
}
|
||||
|
||||
const created = (await createRes.json()) as CreatedIntent;
|
||||
const intentId = String(created.intent?.intent_id || "");
|
||||
const txPayload = created.tx_payload;
|
||||
if (!intentId || !txPayload?.to || !txPayload?.data) throw new Error(copy.intentPayloadInvalid);
|
||||
trackAppEvent("checkout_started", {
|
||||
entry: "account_center",
|
||||
plan_code: selectedPlan?.plan_code || "pro_monthly",
|
||||
intent_id: intentId,
|
||||
use_points: billing.canRedeem && usePoints,
|
||||
pay_amount_usd: billing.payAmount,
|
||||
});
|
||||
const intentReceiver = String(txPayload.to || "").toLowerCase();
|
||||
if (intentReceiver !== expectedReceiver) {
|
||||
throw new Error(`payment receiver changed: expected ${expectedReceiver}, got ${intentReceiver}. 请刷新页面后重试。`);
|
||||
}
|
||||
setLastIntentId(intentId);
|
||||
|
||||
const tokenAddress = String(txPayload.token_address || "").toLowerCase();
|
||||
const amountUnits = BigInt(String(txPayload.amount_units || "0"));
|
||||
if (!tokenAddress.startsWith("0x") || amountUnits <= 0n) throw new Error(copy.intentTokenInvalid);
|
||||
const tokenSymbol = String(txPayload.token_symbol || selectedPaymentToken?.symbol || selectedTokenLabel || "USDC");
|
||||
const tokenDecimals = Number(txPayload.token_decimals ?? selectedPaymentToken?.decimals ?? latestConfig?.token_decimals ?? 6);
|
||||
|
||||
const balanceHex = await requestWalletWithTimeout<string>(
|
||||
eth, { method: "eth_call", params: [{ to: tokenAddress, data: buildBalanceOfCalldata(payingWallet) }, "latest"] },
|
||||
`读取 ${tokenSymbol} 余额`,
|
||||
);
|
||||
const tokenBalance = BigInt(String(balanceHex || "0x0"));
|
||||
if (tokenBalance < amountUnits) {
|
||||
const need = formatTokenUnits(amountUnits, tokenDecimals);
|
||||
const have = formatTokenUnits(tokenBalance, tokenDecimals);
|
||||
throw new Error(`支付代币余额不足:需要 ${need} ${tokenSymbol},当前 ${have} ${tokenSymbol}。请确认你钱包里持有该支付币种。`);
|
||||
}
|
||||
|
||||
const allowanceHex = await requestWalletWithTimeout<string>(
|
||||
eth, { method: "eth_call", params: [{ to: tokenAddress, data: buildAllowanceCalldata(payingWallet, txPayload.to) }, "latest"] },
|
||||
`读取 ${tokenSymbol} 授权额度`,
|
||||
);
|
||||
const allowance = BigInt(String(allowanceHex || "0x0"));
|
||||
|
||||
if (allowance < amountUnits) {
|
||||
setPaymentInfo(copy.approvalDetected.replace("{symbol}", tokenSymbol));
|
||||
const approveParams: Record<string, any> = {
|
||||
from: payingWallet, to: tokenAddress, data: buildApproveCalldata(txPayload.to, amountUnits),
|
||||
};
|
||||
const approveHash = await requestWalletWithTimeout<string>(
|
||||
eth, { method: "eth_sendTransaction", params: [approveParams] },
|
||||
`发起 ${tokenSymbol} 授权`, WALLET_TRANSACTION_REQUEST_TIMEOUT_MS,
|
||||
);
|
||||
await waitForReceipt(String(approveHash || ""), eth);
|
||||
approvedInThisRun = true;
|
||||
setPaymentInfo(copy.approvalDone.replace("{symbol}", tokenSymbol));
|
||||
} else {
|
||||
setPaymentInfo(copy.approvalSufficient);
|
||||
}
|
||||
|
||||
const payParams: Record<string, any> = { from: payingWallet, to: txPayload.to, data: txPayload.data };
|
||||
if (txPayload.value && txPayload.value !== "0x0" && txPayload.value !== "0") {
|
||||
payParams.value = txPayload.value;
|
||||
}
|
||||
|
||||
const txHash = await requestWalletWithTimeout<string>(
|
||||
eth, { method: "eth_sendTransaction", params: [payParams] },
|
||||
"发起支付交易", WALLET_TRANSACTION_REQUEST_TIMEOUT_MS,
|
||||
);
|
||||
const txHashNorm = String(txHash || "").toLowerCase();
|
||||
setLastTxHash(txHashNorm);
|
||||
setLastPaymentStartedAt(Date.now());
|
||||
|
||||
const submitRes = await fetch(`/api/payments/intents/${intentId}/submit`, {
|
||||
method: "POST", headers: authHeaders, body: JSON.stringify({ tx_hash: txHashNorm, from_address: payingWallet }),
|
||||
});
|
||||
if (!submitRes.ok) {
|
||||
const raw = (await submitRes.text()).slice(0, 350);
|
||||
if (submitRes.status === 409) {
|
||||
if (handleSubmit409Ref.current) await handleSubmit409Ref.current(intentId, txHashNorm, raw);
|
||||
return;
|
||||
}
|
||||
throw new Error(copy.submitTxFailed.replace("{raw}", raw));
|
||||
}
|
||||
|
||||
const confirmRes = await fetch(`/api/payments/intents/${intentId}/confirm`, {
|
||||
method: "POST", headers: authHeaders, body: JSON.stringify({ tx_hash: txHashNorm }),
|
||||
});
|
||||
if (!confirmRes.ok) {
|
||||
const raw = (await confirmRes.text()).slice(0, 350);
|
||||
const lowerRaw = raw.toLowerCase();
|
||||
const maybePending =
|
||||
(confirmRes.status === 404 && !lowerRaw.includes("payment intent not found")) ||
|
||||
confirmRes.status === 408 ||
|
||||
(confirmRes.status === 409 && (lowerRaw.includes("confirmations not enough") || lowerRaw.includes("tx indexed partially")));
|
||||
if (maybePending) {
|
||||
setPaymentInfo(`交易已提交: ${shortAddress(txHashNorm)},等待链上确认中...`);
|
||||
await pollIntentUntilConfirmed(intentId, authHeaders, txHashNorm);
|
||||
return;
|
||||
}
|
||||
throw new Error(copy.confirmFailed.replace("{raw}", raw));
|
||||
}
|
||||
|
||||
setPaymentInfo(copy.paymentConfirmed.replace("{txHash}", shortAddress(txHashNorm)));
|
||||
trackAppEvent("checkout_succeeded", {
|
||||
entry: "account_center",
|
||||
plan_code: selectedPlan?.plan_code || "pro_monthly",
|
||||
intent_id: intentId,
|
||||
tx_hash: txHashNorm,
|
||||
});
|
||||
await loadSnapshot();
|
||||
await loadPaymentSnapshot();
|
||||
} catch (error) {
|
||||
const normalized = normalizePaymentError(error);
|
||||
if (normalized.pending) {
|
||||
setPaymentError(normalized.message);
|
||||
} else if (normalized.userRejected) {
|
||||
setPaymentInfo(approvedInThisRun
|
||||
? `${selectedTokenLabel} 授权已完成,本次支付已取消,可直接再次点击支付。`
|
||||
: ""
|
||||
);
|
||||
setPaymentError(normalized.message);
|
||||
} else {
|
||||
setPaymentInfo(approvedInThisRun
|
||||
? `${selectedTokenLabel} 授权已完成,但支付未完成,请重试。`
|
||||
: ""
|
||||
);
|
||||
setPaymentError(normalized.message);
|
||||
}
|
||||
} finally {
|
||||
setPaymentBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ── createManualPaymentIntent ────────────────────────────
|
||||
const createManualPaymentIntent = async () => {
|
||||
clearPaymentMessages();
|
||||
setManualPayment(null);
|
||||
setManualTxHash("");
|
||||
setLastIntentId("");
|
||||
setLastTxHash("");
|
||||
if (!paymentHostAllowed) {
|
||||
setPaymentError(copy.paymentHostBlocked.replace("{host}", allowedPaymentHosts[0] || "polyweather.top"));
|
||||
return;
|
||||
}
|
||||
if (!authIsAuthenticated) {
|
||||
setPaymentError(copy.loginBeforePay);
|
||||
return;
|
||||
}
|
||||
if (!paymentConfig?.configured) {
|
||||
setPaymentError(copy.payNotReady);
|
||||
return;
|
||||
}
|
||||
|
||||
setPaymentBusy(true);
|
||||
try {
|
||||
const authHeaders = await buildAuthedHeaders(true, false);
|
||||
const createRes = await fetch("/api/payments/intents", {
|
||||
method: "POST",
|
||||
headers: authHeaders,
|
||||
body: JSON.stringify({
|
||||
plan_code: selectedPlan?.plan_code || "pro_monthly",
|
||||
payment_mode: "direct",
|
||||
token_address: resolvedSelectedTokenAddress || undefined,
|
||||
use_points: billing.canRedeem && usePoints,
|
||||
points_to_consume: billing.canRedeem && usePoints ? billing.pointsUsed : 0,
|
||||
metadata: {
|
||||
source: "account_center_manual_transfer",
|
||||
frontend_host: currentPaymentHost || null,
|
||||
account_email: backend?.email || null,
|
||||
},
|
||||
}),
|
||||
});
|
||||
if (!createRes.ok) {
|
||||
const raw = (await createRes.text()).slice(0, 350);
|
||||
throw new Error(copy.createManualIntentFailed.replace("{raw}", raw));
|
||||
}
|
||||
|
||||
const created = (await createRes.json()) as CreatedIntent;
|
||||
const direct = created.direct_payment;
|
||||
const intentId = String(created.intent?.intent_id || direct?.intent_id || "");
|
||||
if (!intentId || !direct?.receiver_address || !direct?.amount_usdc) {
|
||||
throw new Error(copy.manualPaymentInvalid);
|
||||
}
|
||||
assertExpectedPaymentReceiver(direct.receiver_address, "manual payment receiver");
|
||||
setLastIntentId(intentId);
|
||||
setManualPayment(direct);
|
||||
setPaymentMethodTab("manual");
|
||||
setShowOverlay(false);
|
||||
setPaymentInfo(`手动转账订单已创建:请在 Polygon 网络转 ${direct.amount_usdc} ${direct.token_symbol || selectedTokenLabel} 到 ${direct.receiver_address},请在下方【手动转账】面板中查看详情并复制地址,完成后提交 tx hash。`);
|
||||
trackAppEvent("checkout_started", {
|
||||
entry: "account_center_manual_transfer",
|
||||
plan_code: selectedPlan?.plan_code || "pro_monthly",
|
||||
intent_id: intentId,
|
||||
payment_mode: "direct",
|
||||
use_points: billing.canRedeem && usePoints,
|
||||
pay_amount_usd: billing.payAmount,
|
||||
});
|
||||
} catch (error) {
|
||||
setPaymentError(normalizePaymentError(error).message);
|
||||
} finally {
|
||||
setPaymentBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ── submitManualPaymentTx ──────────────────────────────
|
||||
const submitManualPaymentTx = async () => {
|
||||
const txHashNorm = String(manualTxHash || "").trim().toLowerCase();
|
||||
const intentIdVal = String(lastIntentId || manualPayment?.intent_id || "").trim();
|
||||
if (!intentIdVal || !manualPayment) {
|
||||
setPaymentError(copy.manualOrderRequired);
|
||||
return;
|
||||
}
|
||||
if (!txHashNorm.startsWith("0x") || txHashNorm.length !== 66) {
|
||||
setPaymentError(copy.txHashRequired);
|
||||
return;
|
||||
}
|
||||
setPaymentBusy(true);
|
||||
setPaymentError("");
|
||||
try {
|
||||
const authHeaders = await buildAuthedHeaders(true, false);
|
||||
const submitRes = await fetch(`/api/payments/intents/${intentIdVal}/submit`, {
|
||||
method: "POST", headers: authHeaders, body: JSON.stringify({ tx_hash: txHashNorm }),
|
||||
});
|
||||
if (!submitRes.ok) {
|
||||
const raw = (await submitRes.text()).slice(0, 350);
|
||||
if (submitRes.status === 409) {
|
||||
if (handleSubmit409Ref.current) await handleSubmit409Ref.current(intentIdVal, txHashNorm, raw);
|
||||
return;
|
||||
}
|
||||
throw new Error(copy.submitTxFailed.replace("{raw}", raw));
|
||||
}
|
||||
const confirmRes = await fetch(`/api/payments/intents/${intentIdVal}/confirm`, {
|
||||
method: "POST", headers: authHeaders, body: JSON.stringify({ tx_hash: txHashNorm }),
|
||||
});
|
||||
if (!confirmRes.ok) {
|
||||
const raw = (await confirmRes.text()).slice(0, 350);
|
||||
const lowerRaw = raw.toLowerCase();
|
||||
const maybePending =
|
||||
confirmRes.status === 408 ||
|
||||
(confirmRes.status === 409 && (lowerRaw.includes("confirmations not enough") || lowerRaw.includes("tx indexed partially")));
|
||||
if (maybePending) {
|
||||
setPaymentInfo(`交易已提交: ${shortAddress(txHashNorm)},等待链上确认中...`);
|
||||
await pollIntentUntilConfirmed(intentIdVal, authHeaders, txHashNorm);
|
||||
return;
|
||||
}
|
||||
throw new Error(copy.confirmFailed.replace("{raw}", raw));
|
||||
}
|
||||
setLastTxHash(txHashNorm);
|
||||
setPaymentInfo(copy.paymentConfirmed.replace("{txHash}", shortAddress(txHashNorm)));
|
||||
setManualPayment(null);
|
||||
setManualTxHash("");
|
||||
setTxValidation({ loading: false, checked: false });
|
||||
trackAppEvent("checkout_succeeded", {
|
||||
entry: "account_center_manual_transfer",
|
||||
plan_code: selectedPlan?.plan_code || "pro_monthly",
|
||||
intent_id: intentIdVal,
|
||||
tx_hash: txHashNorm,
|
||||
});
|
||||
await loadSnapshot();
|
||||
await loadPaymentSnapshot();
|
||||
} catch (error) {
|
||||
setPaymentError(normalizePaymentError(error).message);
|
||||
} finally {
|
||||
setPaymentBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ── validateTxHash ────────────────────────────────────
|
||||
const validateTxHash = useCallback(
|
||||
async (intentId: string, hash: string) => {
|
||||
const hashNorm = String(hash || "").trim().toLowerCase();
|
||||
if (!hashNorm.startsWith("0x") || hashNorm.length !== 66) {
|
||||
setTxValidation({ loading: false, checked: false });
|
||||
return;
|
||||
}
|
||||
setTxValidation({ loading: true, checked: false });
|
||||
try {
|
||||
const headers = await buildAuthedHeaders(true, false);
|
||||
const res = await fetch(`/api/payments/intents/${intentId}/validate`, {
|
||||
method: "POST", headers, body: JSON.stringify({ tx_hash: hashNorm }),
|
||||
});
|
||||
const json = (await res.json()) as {
|
||||
valid?: boolean; reason?: string; detail?: string; checks?: Record<string, unknown>;
|
||||
};
|
||||
setTxValidation({ loading: false, checked: true, valid: Boolean(json.valid), reason: json.reason, detail: json.detail, checks: json.checks });
|
||||
} catch {
|
||||
setTxValidation({ loading: false, checked: false });
|
||||
}
|
||||
},
|
||||
[buildAuthedHeaders],
|
||||
);
|
||||
|
||||
// ── handleOverlayCheckout ──────────────────────────────
|
||||
const handleOverlayCheckout = async () => {
|
||||
if (!paymentHostAllowed) {
|
||||
setPaymentError(copy.paymentHostBlocked.replace("{host}", allowedPaymentHosts[0] || "polyweather.top"));
|
||||
return;
|
||||
}
|
||||
if (!authIsAuthenticated) {
|
||||
setPaymentError(copy.loginBeforePay);
|
||||
return;
|
||||
}
|
||||
if (!hasPayingWallet) {
|
||||
setPaymentInfo(copy.openBindFlow);
|
||||
if (connectAndBindWalletRef.current) {
|
||||
const bound = await connectAndBindWalletRef.current(providerMode, { openOverlayAfterBind: true });
|
||||
if (!bound) return;
|
||||
}
|
||||
setPaymentInfo(copy.walletBoundCreatingOrder);
|
||||
await createIntentAndPay();
|
||||
return;
|
||||
}
|
||||
await createIntentAndPay();
|
||||
};
|
||||
|
||||
// ==========================================================
|
||||
return {
|
||||
fetchLatestPaymentConfig,
|
||||
pollIntentUntilConfirmed,
|
||||
createIntentAndPay,
|
||||
createManualPaymentIntent,
|
||||
submitManualPaymentTx,
|
||||
validateTxHash,
|
||||
handleOverlayCheckout,
|
||||
availableTokenList,
|
||||
resolvedSelectedTokenAddress,
|
||||
selectedPaymentToken,
|
||||
selectedTokenLabel,
|
||||
paymentReceiverAddress,
|
||||
};
|
||||
}
|
||||
@@ -1,386 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect } from "react";
|
||||
import type {
|
||||
BoundWallet,
|
||||
ConnectBindOptions,
|
||||
Eip6963ProviderDetail,
|
||||
EvmProvider,
|
||||
InjectedProviderOption,
|
||||
ProviderMode,
|
||||
ProviderSelection,
|
||||
} from "./types";
|
||||
import {
|
||||
WALLETCONNECT_POLYGON_RPC_URL,
|
||||
WALLET_TRANSACTION_REQUEST_TIMEOUT_MS,
|
||||
} from "./constants";
|
||||
import { shortAddress } from "./formatters";
|
||||
import { normalizePaymentError, requestWalletWithTimeout } from "./payment-utils";
|
||||
import {
|
||||
eip6963Providers,
|
||||
getEvmProvider,
|
||||
getEvmWalletLabel,
|
||||
getWalletConnectProvider,
|
||||
isWalletConnectResetError,
|
||||
listInjectedProviders,
|
||||
resetWalletConnectProvider,
|
||||
} from "./wallet";
|
||||
|
||||
// ============================================================
|
||||
export interface UseWalletBindParams {
|
||||
isEn: boolean;
|
||||
walletConnectEnabled: boolean;
|
||||
copy: Record<string, string>;
|
||||
chainId: number;
|
||||
|
||||
// Shared wallet state + setters
|
||||
boundWallets: BoundWallet[];
|
||||
setBoundWallets: React.Dispatch<React.SetStateAction<BoundWallet[]>>;
|
||||
walletAddress: string;
|
||||
setWalletAddress: React.Dispatch<React.SetStateAction<string>>;
|
||||
selectedWallet: string;
|
||||
setSelectedWallet: React.Dispatch<React.SetStateAction<string>>;
|
||||
providerMode: ProviderMode;
|
||||
setProviderMode: React.Dispatch<React.SetStateAction<ProviderMode>>;
|
||||
injectedProviderOptions: InjectedProviderOption[];
|
||||
setInjectedProviderOptions: React.Dispatch<React.SetStateAction<InjectedProviderOption[]>>;
|
||||
selectedInjectedProviderKey: string;
|
||||
setSelectedInjectedProviderKey: React.Dispatch<React.SetStateAction<string>>;
|
||||
|
||||
// Payment UI state setters
|
||||
setPaymentBusy: (v: boolean) => void;
|
||||
setPaymentInfo: (v: string) => void;
|
||||
setPaymentError: (v: string) => void;
|
||||
setShowOverlay: (v: boolean) => void;
|
||||
clearPaymentMessages: () => void;
|
||||
|
||||
// Derived values from master
|
||||
authIsAuthenticated: boolean;
|
||||
|
||||
// Callbacks from master (must be stable via useCallback)
|
||||
getValidAccessToken: () => Promise<string>;
|
||||
buildAuthedHeaders: (withJson?: boolean, requireAuth?: boolean) => Promise<Record<string, string>>;
|
||||
loadSnapshot: () => Promise<void>;
|
||||
loadPaymentSnapshot: () => Promise<void>;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
export function useWalletBind(params: UseWalletBindParams) {
|
||||
const {
|
||||
isEn,
|
||||
walletConnectEnabled,
|
||||
copy,
|
||||
chainId,
|
||||
boundWallets,
|
||||
setBoundWallets,
|
||||
walletAddress,
|
||||
setWalletAddress,
|
||||
selectedWallet,
|
||||
setSelectedWallet,
|
||||
providerMode,
|
||||
setProviderMode,
|
||||
injectedProviderOptions,
|
||||
setInjectedProviderOptions,
|
||||
selectedInjectedProviderKey,
|
||||
setSelectedInjectedProviderKey,
|
||||
setPaymentBusy,
|
||||
setPaymentInfo,
|
||||
setPaymentError,
|
||||
setShowOverlay,
|
||||
clearPaymentMessages,
|
||||
authIsAuthenticated,
|
||||
getValidAccessToken,
|
||||
buildAuthedHeaders,
|
||||
loadSnapshot,
|
||||
loadPaymentSnapshot,
|
||||
} = params;
|
||||
|
||||
// ── EIP-6963 Provider sync ──────────────────────────────
|
||||
useEffect(() => {
|
||||
const syncProviders = () => {
|
||||
const nextOptions = listInjectedProviders();
|
||||
setInjectedProviderOptions(nextOptions);
|
||||
setSelectedInjectedProviderKey((current) => {
|
||||
if (current && nextOptions.some((row) => row.key === current)) {
|
||||
return current;
|
||||
}
|
||||
return nextOptions[0]?.key || "";
|
||||
});
|
||||
};
|
||||
|
||||
const handleAnnounce = (event: Event) => {
|
||||
const customEvent = event as CustomEvent<Eip6963ProviderDetail>;
|
||||
const detail = customEvent.detail;
|
||||
if (!detail?.provider || typeof detail.provider.request !== "function") return;
|
||||
const uuid = String(detail.info?.uuid || "").trim();
|
||||
const fallbackKey = `${String(detail.info?.rdns || "wallet").toLowerCase()}:${String(detail.info?.name || "wallet").toLowerCase()}`;
|
||||
eip6963Providers.set(uuid || fallbackKey, detail);
|
||||
syncProviders();
|
||||
};
|
||||
|
||||
syncProviders();
|
||||
if (typeof window === "undefined") return;
|
||||
window.addEventListener("eip6963:announceProvider", handleAnnounce as EventListener);
|
||||
window.dispatchEvent(new Event("eip6963:requestProvider"));
|
||||
window.addEventListener("ethereum#initialized", syncProviders as EventListener, { once: false });
|
||||
return () => {
|
||||
window.removeEventListener("eip6963:announceProvider", handleAnnounce as EventListener);
|
||||
window.removeEventListener("ethereum#initialized", syncProviders as EventListener);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// ── resolvePaymentProvider ──────────────────────────────
|
||||
const resolvePaymentProvider = useCallback(
|
||||
async (mode: ProviderMode = "auto", preferredInjectedKey = ""): Promise<ProviderSelection> => {
|
||||
const targetChainId = Number(chainId || 137);
|
||||
if (mode !== "walletconnect") {
|
||||
const injectedOptions = listInjectedProviders();
|
||||
const injected = injectedOptions.find((row) => row.key === preferredInjectedKey)?.provider || getEvmProvider();
|
||||
const label = injectedOptions.find((row) => row.key === preferredInjectedKey)?.label || getEvmWalletLabel(injected);
|
||||
if (injected) return { provider: injected, label, mode: "auto" };
|
||||
}
|
||||
if (!walletConnectEnabled) {
|
||||
throw new Error("未检测到浏览器扩展钱包,且 WalletConnect 未启用。请配置 NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID 或安装 EVM 钱包扩展。");
|
||||
}
|
||||
const wcProvider = await getWalletConnectProvider(targetChainId, WALLETCONNECT_POLYGON_RPC_URL);
|
||||
const existingAccounts = (await wcProvider.request({ method: "eth_accounts" }).catch(() => [])) as string[];
|
||||
if (!Array.isArray(existingAccounts) || existingAccounts.length === 0) {
|
||||
if (typeof wcProvider.connect === "function") {
|
||||
try { await wcProvider.connect({ chains: [targetChainId] }); }
|
||||
catch (err) {
|
||||
if (!isWalletConnectResetError(err)) throw err;
|
||||
await resetWalletConnectProvider();
|
||||
const freshProvider = await getWalletConnectProvider(targetChainId, WALLETCONNECT_POLYGON_RPC_URL);
|
||||
if (typeof freshProvider.connect === "function") await freshProvider.connect({ chains: [targetChainId] });
|
||||
return { provider: freshProvider, label: "WalletConnect", mode: "walletconnect" };
|
||||
}
|
||||
}
|
||||
}
|
||||
return { provider: wcProvider, label: "WalletConnect", mode: "walletconnect" };
|
||||
},
|
||||
[chainId, walletConnectEnabled],
|
||||
);
|
||||
|
||||
// ── Low-level helpers ──────────────────────────────────
|
||||
const waitForReceipt = async (txHash: string, provider?: EvmProvider, timeoutMs = 120000, pollMs = 3000) => {
|
||||
const eth = provider || getEvmProvider();
|
||||
if (!eth) throw new Error(copy.noWalletProvider);
|
||||
const started = Date.now();
|
||||
while (Date.now() - started < timeoutMs) {
|
||||
const receipt = await requestWalletWithTimeout<{ status?: string } | null>(
|
||||
eth,
|
||||
{ method: "eth_getTransactionReceipt", params: [txHash] },
|
||||
"查询授权交易确认",
|
||||
15_000,
|
||||
);
|
||||
if (receipt && receipt.status) {
|
||||
if (receipt.status === "0x1") return receipt;
|
||||
throw new Error(copy.txReverted.replace("{txHash}", txHash));
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, pollMs));
|
||||
}
|
||||
throw new Error(copy.txConfirmTimeout.replace("{txHash}", txHash));
|
||||
};
|
||||
|
||||
const signBindMessage = async (eth: EvmProvider, address: string, message: string): Promise<string> => {
|
||||
try {
|
||||
return (await eth.request({ method: "personal_sign", params: [message, address] })) as string;
|
||||
} catch {
|
||||
return (await eth.request({ method: "personal_sign", params: [address, message] })) as string;
|
||||
}
|
||||
};
|
||||
|
||||
const ensureTargetChain = async (eth: EvmProvider, targetChainId: number): Promise<void> => {
|
||||
const currentChainIdHex = String(
|
||||
(await requestWalletWithTimeout<string>(eth, { method: "eth_chainId" }, copy.chainReadError)) || "",
|
||||
);
|
||||
const targetChainHex = `0x${targetChainId.toString(16)}`;
|
||||
if (currentChainIdHex.toLowerCase() === targetChainHex.toLowerCase()) return;
|
||||
try {
|
||||
await requestWalletWithTimeout(
|
||||
eth,
|
||||
{ method: "wallet_switchEthereumChain", params: [{ chainId: targetChainHex }] },
|
||||
copy.chainSwitchError,
|
||||
);
|
||||
} catch (err: any) {
|
||||
const code = Number(err?.code);
|
||||
if (code === 4902 || targetChainId === 137) {
|
||||
try {
|
||||
await requestWalletWithTimeout(
|
||||
eth,
|
||||
{
|
||||
method: "wallet_addEthereumChain",
|
||||
params: [{
|
||||
chainId: "0x89",
|
||||
chainName: "Polygon Mainnet",
|
||||
nativeCurrency: { name: "POL", symbol: "POL", decimals: 18 },
|
||||
rpcUrls: ["https://polygon-rpc.com"],
|
||||
blockExplorerUrls: ["https://polygonscan.com"],
|
||||
}],
|
||||
},
|
||||
copy.chainAddPolygon,
|
||||
);
|
||||
return;
|
||||
} catch (addErr: any) { err = addErr; }
|
||||
}
|
||||
throw new Error(`${copy.chainSwitchPrompt} (${err?.message || (isEn ? "Network switch failed" : "网络切换失败")})`);
|
||||
}
|
||||
};
|
||||
|
||||
// ── connectAndBindWallet ────────────────────────────────
|
||||
const connectAndBindWallet = async (mode: ProviderMode = "auto", options: ConnectBindOptions = {}): Promise<boolean> => {
|
||||
clearPaymentMessages();
|
||||
if (!authIsAuthenticated) {
|
||||
setPaymentError(copy.loginBeforeBind);
|
||||
return false;
|
||||
}
|
||||
|
||||
setPaymentBusy(true);
|
||||
try {
|
||||
const providerSelection = await resolvePaymentProvider(mode, selectedInjectedProviderKey);
|
||||
const eth = providerSelection.provider;
|
||||
const walletLabel = providerSelection.label;
|
||||
const binanceBindHint = walletLabel.toLowerCase().includes("binance") ? copy.binanceBindHint : "";
|
||||
|
||||
let accessToken: string;
|
||||
try { accessToken = await getValidAccessToken(); }
|
||||
catch (tokenErr) {
|
||||
setPaymentError(normalizePaymentError(tokenErr).message);
|
||||
setPaymentBusy(false);
|
||||
return false;
|
||||
}
|
||||
const authHeaders: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
};
|
||||
|
||||
const accounts = await requestWalletWithTimeout<string[]>(eth, { method: "eth_requestAccounts" }, "连接绑定钱包");
|
||||
const address = String(accounts?.[0] || "").toLowerCase();
|
||||
if (!address) throw new Error(isEn ? "Wallet account is empty." : "钱包账户为空");
|
||||
|
||||
const existingWallet = boundWallets.find((w) => String(w.address || "").toLowerCase() === address);
|
||||
if (existingWallet) {
|
||||
setWalletAddress(address);
|
||||
setSelectedWallet(address);
|
||||
setPaymentInfo(`${walletLabel} 已绑定: ${shortAddress(address)}。${binanceBindHint || "现在可点击“立即订阅并激活服务”。"}`);
|
||||
await Promise.all([loadSnapshot(), loadPaymentSnapshot()]);
|
||||
if (options.openOverlayAfterBind) setShowOverlay(true);
|
||||
setPaymentBusy(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
setWalletAddress(address);
|
||||
const challengeRes = await fetch("/api/payments/wallets/challenge", {
|
||||
method: "POST", headers: authHeaders, body: JSON.stringify({ address }),
|
||||
});
|
||||
if (!challengeRes.ok) {
|
||||
const raw = (await challengeRes.text()).slice(0, 300);
|
||||
throw new Error(copy.challengeFailed.replace("{raw}", raw));
|
||||
}
|
||||
|
||||
const challengeJson = (await challengeRes.json()) as { nonce?: string; message?: string };
|
||||
const message = String(challengeJson.message || "");
|
||||
const nonce = String(challengeJson.nonce || "");
|
||||
if (!message || !nonce) throw new Error(copy.challengeInvalid);
|
||||
|
||||
const signature = await signBindMessage(eth, address, message);
|
||||
const verifyRes = await fetch("/api/payments/wallets/verify", {
|
||||
method: "POST", headers: authHeaders, body: JSON.stringify({ address, nonce, signature }),
|
||||
});
|
||||
if (!verifyRes.ok) {
|
||||
const raw = (await verifyRes.text()).slice(0, 300);
|
||||
throw new Error(copy.verifyFailedRaw.replace("{raw}", raw));
|
||||
}
|
||||
|
||||
setPaymentInfo(`${walletLabel} 绑定成功: ${shortAddress(address)}。${binanceBindHint || "现在可点击“立即订阅并激活服务”。"}`);
|
||||
setProviderMode(providerSelection.mode);
|
||||
if (options.openOverlayAfterBind) setShowOverlay(true);
|
||||
await Promise.all([loadSnapshot(), loadPaymentSnapshot()]);
|
||||
return true;
|
||||
} catch (error) {
|
||||
setPaymentInfo("");
|
||||
setPaymentError(normalizePaymentError(error).message);
|
||||
return false;
|
||||
} finally {
|
||||
setPaymentBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ── handleUnbindWallet ──────────────────────────────────
|
||||
const handleUnbindWallet = async (address: string) => {
|
||||
const target = String(address || "").toLowerCase();
|
||||
if (!target) return;
|
||||
if (!authIsAuthenticated) {
|
||||
setPaymentError(copy.loginBeforeBind);
|
||||
return;
|
||||
}
|
||||
const confirmed = window.confirm(copy.unbindConfirm.replace("{address}", shortAddress(target)));
|
||||
if (!confirmed) return;
|
||||
|
||||
setPaymentBusy(true);
|
||||
clearPaymentMessages();
|
||||
try {
|
||||
const headers = await buildAuthedHeaders(true, false);
|
||||
const res = await fetch("/api/payments/wallets", {
|
||||
method: "DELETE", headers, body: JSON.stringify({ address: target }),
|
||||
});
|
||||
const raw = await res.text();
|
||||
if (!res.ok) {
|
||||
let detail = raw;
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
detail = String(parsed?.detail || parsed?.error || raw);
|
||||
if (detail.trim().startsWith("{")) {
|
||||
try {
|
||||
const nested = JSON.parse(detail);
|
||||
detail = String(nested?.detail || nested?.error || detail);
|
||||
} catch { /* ignore nested parse failure */ }
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
throw new Error(detail || copy.httpError.replace("{status}", String(res.status)).replace("{raw}", ""));
|
||||
}
|
||||
|
||||
let data: Record<string, unknown> = {};
|
||||
try { data = raw ? (JSON.parse(raw) as Record<string, unknown>) : {}; }
|
||||
catch { data = {}; }
|
||||
|
||||
const newPrimary = String(data?.new_primary || "").toLowerCase();
|
||||
const selectedWalletNorm = String(selectedWallet || "").toLowerCase();
|
||||
const walletAddressNorm = String(walletAddress || "").toLowerCase();
|
||||
if (selectedWalletNorm === target) setSelectedWallet(newPrimary || "");
|
||||
if (walletAddressNorm === target) setWalletAddress(newPrimary || "");
|
||||
setBoundWallets((prev) => prev.filter((row) => String(row.address || "").toLowerCase() !== String(target)));
|
||||
await loadPaymentSnapshot();
|
||||
setPaymentInfo(newPrimary ? copy.unbindDonePrimary.replace("{address}", shortAddress(newPrimary)) : copy.unbindDone);
|
||||
} catch (error) {
|
||||
const message = normalizePaymentError(error).message;
|
||||
const lower = String(message || "").toLowerCase();
|
||||
if (lower.includes("unauthorized") || lower.includes("session required") || lower.includes("401")) {
|
||||
setPaymentError(`${copy.unbindFailed}: ${copy.authExpired}`);
|
||||
return;
|
||||
}
|
||||
setPaymentError(`${copy.unbindFailed}: ${message}`);
|
||||
} finally {
|
||||
setPaymentBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ── Derived values ──────────────────────────────────────
|
||||
const paymentWalletLabel = String(
|
||||
selectedWallet || walletAddress || boundWallets.find((row) => row.is_primary)?.address || boundWallets[0]?.address || "",
|
||||
).toLowerCase();
|
||||
|
||||
const hasPayingWallet = Boolean(String(selectedWallet || walletAddress || boundWallets[0]?.address || "").trim());
|
||||
|
||||
// ==========================================================
|
||||
return {
|
||||
resolvePaymentProvider,
|
||||
connectAndBindWallet,
|
||||
handleUnbindWallet,
|
||||
waitForReceipt,
|
||||
signBindMessage,
|
||||
ensureTargetChain,
|
||||
paymentWalletLabel,
|
||||
hasPayingWallet,
|
||||
};
|
||||
}
|
||||
@@ -204,7 +204,7 @@ export async function getWalletConnectProvider(
|
||||
const origin =
|
||||
typeof window !== "undefined"
|
||||
? window.location.origin
|
||||
: "https://polyweather.top";
|
||||
: "https://polyweather-pro.vercel.app";
|
||||
const provider = (await EthereumProvider.init({
|
||||
projectId: WALLETCONNECT_PROJECT_ID,
|
||||
chains: [chainId],
|
||||
|
||||
@@ -7,13 +7,11 @@ import {
|
||||
ArrowRight,
|
||||
ChevronLeft,
|
||||
Chrome,
|
||||
Cloud,
|
||||
CloudRain,
|
||||
CloudSun,
|
||||
Lock,
|
||||
Mail,
|
||||
Sun,
|
||||
Eye,
|
||||
EyeOff,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
getSupabaseBrowserClient,
|
||||
@@ -26,28 +24,24 @@ type Mode = "login" | "signup";
|
||||
|
||||
type LoginClientProps = {
|
||||
nextPath: string;
|
||||
initialMode?: Mode;
|
||||
};
|
||||
|
||||
export function LoginClient({ nextPath, initialMode }: LoginClientProps) {
|
||||
export function LoginClient({ nextPath }: LoginClientProps) {
|
||||
const router = useRouter();
|
||||
const { locale } = useI18n();
|
||||
const [mode, setMode] = useState<Mode>(initialMode ?? "login");
|
||||
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 [resetSent, setResetSent] = useState(false);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
const supabaseReady = hasSupabasePublicEnv();
|
||||
const isLogin = mode === "login";
|
||||
const siteOrigin =
|
||||
getConfiguredSiteUrl() ||
|
||||
(typeof window !== "undefined" ? window.location.origin : PRODUCTION_SITE_URL);
|
||||
const isEn = locale === "en-US";
|
||||
|
||||
const copy = {
|
||||
backHome: isEn ? "Back to Home" : "返回首页",
|
||||
subtitle: isEn
|
||||
@@ -63,7 +57,7 @@ export function LoginClient({ nextPath, initialMode }: LoginClientProps) {
|
||||
passwordSignupPlaceholder: isEn
|
||||
? "Set at least 6 characters"
|
||||
: "设置至少 6 位密码",
|
||||
loginSubmit: isEn ? "Start your weather decision journey" : "开启气象决策之旅",
|
||||
loginSubmit: isEn ? "Start your weather journey" : "开启天气之旅",
|
||||
signupSubmit: isEn ? "Create account now" : "立即创建账号",
|
||||
loginHint: isEn
|
||||
? "After signing in, your homepage will be personalized."
|
||||
@@ -93,21 +87,6 @@ export function LoginClient({ nextPath, initialMode }: LoginClientProps) {
|
||||
loginFailedHint: isEn
|
||||
? "If you just signed up, please verify your email first. Check your inbox or spam folder."
|
||||
: "如果刚注册,请先点击邮箱中的验证链接。检查收件箱或垃圾邮件。",
|
||||
|
||||
// New translations for Koyfin-style layouts
|
||||
workEmail: isEn ? "Work email" : "工作邮箱",
|
||||
password: isEn ? "Password" : "密码",
|
||||
welcomeBack: isEn ? "Welcome Back" : "欢迎回来",
|
||||
signUpTitle: isEn ? "Sign up for your PolyWeather account" : "注册您的 PolyWeather 账户",
|
||||
newToPoly: isEn ? "New to PolyWeather?" : "还没有 PolyWeather 账号?",
|
||||
alreadyHave: isEn ? "Already have an account?" : "已经有账号了?",
|
||||
termsAgreement: isEn
|
||||
? "By proceeding, you agree to the Privacy Policy and Terms & Conditions."
|
||||
: "继续操作即代表您同意隐私政策与服务条款。",
|
||||
desc: isEn
|
||||
? "Access robust METAR observations, advanced DEB forecast blends, and real-time AI decision cards that bring clarity to your weather risk analyses."
|
||||
: "提供精准的机场 METAR 实况、先进的 DEB 智能融合预测和实时 AI 决策卡片,助您理清气象风险脉络。",
|
||||
trusted: isEn ? "Trusted by industry professionals" : "深受行业决策人员信赖",
|
||||
} as const;
|
||||
|
||||
const onResetPassword = async () => {
|
||||
@@ -235,300 +214,152 @@ export function LoginClient({ nextPath, initialMode }: LoginClientProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const isLogin = mode === "login";
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen w-full bg-[#f8fafc] font-sans text-slate-900">
|
||||
{/* Left Column (Shared Dark Column with Illustrative Widget) */}
|
||||
<div className="relative hidden lg:flex lg:w-[460px] xl:w-[500px] 2xl:w-[560px] flex-col justify-between bg-gradient-to-br from-[#060913] via-[#0f1527] to-[#040815] p-10 text-white shrink-0 overflow-hidden border-r border-white/5">
|
||||
{/* Ambient Glows */}
|
||||
<div className="absolute -left-20 -top-20 h-96 w-96 rounded-full bg-blue-600/10 blur-[130px] pointer-events-none" />
|
||||
<div className="absolute -right-20 -bottom-20 h-[360px] w-[360px] rounded-full bg-purple-500/10 blur-[120px] pointer-events-none" />
|
||||
<div className="absolute top-1/2 right-0 h-[240px] w-[240px] -translate-y-1/2 rounded-full bg-amber-500/5 blur-[100px] pointer-events-none" />
|
||||
|
||||
{/* Grid overlay */}
|
||||
<div className="absolute inset-0 bg-[linear-gradient(to_right,rgba(255,255,255,0.015)_1px,transparent_1px),linear-gradient(to_bottom,rgba(255,255,255,0.015)_1px,transparent_1px)] bg-[size:32px_32px] pointer-events-none" />
|
||||
<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 z-10 flex flex-col gap-14">
|
||||
<Link href="/" className="flex items-center hover:opacity-90 transition-opacity">
|
||||
<img src="/logo.png" alt="PolyWeather" className="h-8 w-auto object-contain brightness-0 invert" />
|
||||
</Link>
|
||||
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-3xl font-black leading-[1.25] tracking-tight text-white animate-fade-up [animation-delay:150ms] opacity-0">
|
||||
{isEn ? (
|
||||
<>
|
||||
Weather intelligence and risk management{" "}
|
||||
<span className="inline-block px-2.5 py-0.5 mt-1 rounded bg-gradient-to-r from-blue-600 to-indigo-500 text-white font-bold text-[0.9em] shadow-lg shadow-blue-600/25 animate-gradient bg-[length:200%_auto]">
|
||||
simplified.
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
天气信息与风险管理{" "}
|
||||
<span className="inline-block px-2.5 py-0.5 mt-1 rounded bg-gradient-to-r from-blue-600 to-indigo-500 text-white font-bold text-[0.9em] shadow-lg shadow-blue-600/25 animate-gradient bg-[length:200%_auto]">
|
||||
化繁为简。
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</h2>
|
||||
<p className="text-sm leading-7 text-slate-400 max-w-md animate-fade-up [animation-delay:300ms] opacity-0">
|
||||
{copy.desc}
|
||||
</p>
|
||||
<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>
|
||||
|
||||
{/* High-Fidelity Mock Terminal Preview Widget */}
|
||||
<div className="relative z-10 my-auto p-[1px] bg-gradient-to-b from-white/15 to-transparent rounded-2xl shadow-2xl overflow-hidden hover:scale-[1.01] hover:shadow-blue-500/10 transition-all duration-500 max-w-[420px] w-full animate-fade-up [animation-delay:450ms] opacity-0">
|
||||
<div className="bg-[#0b0f19]/80 backdrop-blur-xl rounded-2xl p-6">
|
||||
{/* Terminal Top Window Controls */}
|
||||
<div className="flex items-center gap-1.5 mb-5">
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-[#ff5f56]" />
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-[#ffbd2e]" />
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-[#27c93f]" />
|
||||
<span className="ml-2 font-mono text-[9px] text-slate-500 tracking-wider">POLYWEATHER_CONSOLE_v1.7</span>
|
||||
</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="flex items-center justify-between border-b border-white/5 pb-3.5 mb-4">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="h-2 w-2 rounded-full bg-emerald-500 animate-pulse" />
|
||||
<span className="font-mono text-[10px] uppercase tracking-wider text-slate-300">
|
||||
{isEn ? "Runway 02L Consensus" : "跑道 02L 实测校验"}
|
||||
</span>
|
||||
</div>
|
||||
<span className="font-mono text-[9px] font-black text-emerald-400 bg-emerald-500/10 px-2 py-0.5 rounded border border-emerald-500/20 tracking-wider">LIVE</span>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
{/* Terminal metrics grid */}
|
||||
<div className="grid grid-cols-2 gap-3 mb-5">
|
||||
<div className="p-2.5 rounded-xl bg-white/[0.02] border border-white/[0.04]">
|
||||
<span className="block text-[9px] uppercase tracking-wider text-slate-400 mb-0.5">{isEn ? "Current Temp" : "当前温度"}</span>
|
||||
<span className="font-mono text-base font-bold text-white tracking-tight">28.8°C</span>
|
||||
</div>
|
||||
<div className="p-2.5 rounded-xl bg-white/[0.02] border border-white/[0.04]">
|
||||
<span className="block text-[9px] uppercase tracking-wider text-slate-400 mb-0.5">{isEn ? "Target Threshold" : "监控阈值"}</span>
|
||||
<span className="font-mono text-base font-bold text-rose-400 tracking-tight">30.0°C</span>
|
||||
</div>
|
||||
<div className="p-2.5 rounded-xl bg-white/[0.02] border border-white/[0.04]">
|
||||
<span className="block text-[9px] uppercase tracking-wider text-slate-400 mb-0.5">{isEn ? "Model Blend" : "模型融合"}</span>
|
||||
<span className="font-mono text-base font-bold text-blue-400 tracking-tight">88.5%</span>
|
||||
</div>
|
||||
<div className="p-2.5 rounded-xl bg-white/[0.02] border border-white/[0.04]">
|
||||
<span className="block text-[9px] uppercase tracking-wider text-slate-400 mb-0.5">{isEn ? "Observed Peak" : "今日最高"}</span>
|
||||
<span className="font-mono text-base font-bold text-emerald-400 tracking-tight">29.2°C</span>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
{/* SVG Interactive Line Chart Preview */}
|
||||
<div className="mb-2">
|
||||
<div className="flex items-center justify-between text-[9px] text-slate-400 mb-2 font-mono">
|
||||
<span>{isEn ? "TEMP TREND (24H)" : "气温趋势 (24小时)"}</span>
|
||||
<span className="text-blue-400 font-bold">Blend vs Obs</span>
|
||||
</div>
|
||||
<div className="relative h-28 w-full bg-slate-950/60 rounded-xl p-2 border border-white/5">
|
||||
<svg className="w-full h-full" viewBox="0 0 340 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
{/* Grid Lines */}
|
||||
<line x1="0" y1="20" x2="340" y2="20" stroke="rgba(255,255,255,0.03)" strokeWidth="1" />
|
||||
<line x1="0" y1="50" x2="340" y2="50" stroke="rgba(255,255,255,0.03)" strokeWidth="1" />
|
||||
<line x1="0" y1="80" x2="340" y2="80" stroke="rgba(255,255,255,0.03)" strokeWidth="1" />
|
||||
<line x1="85" y1="0" x2="85" y2="100" stroke="rgba(255,255,255,0.03)" strokeWidth="1" />
|
||||
<line x1="170" y1="0" x2="170" y2="100" stroke="rgba(255,255,255,0.03)" strokeWidth="1" />
|
||||
<line x1="255" y1="0" x2="255" y2="100" stroke="rgba(255,255,255,0.03)" strokeWidth="1" />
|
||||
|
||||
{/* Threshold Line (30.0°C) */}
|
||||
<line x1="0" y1="40" x2="340" y2="40" stroke="#f43f5e" strokeWidth="1" strokeDasharray="3 3" opacity="0.8" />
|
||||
<text x="5" y="36" fill="#f43f5e" className="text-[8px] font-mono font-semibold">30.0°C Target</text>
|
||||
|
||||
{/* Gradient Area under Forecast */}
|
||||
<path
|
||||
d="M 0 85 Q 40 75 85 60 T 170 35 T 255 45 T 340 55 L 340 100 L 0 100 Z"
|
||||
fill="url(#chartGradient)"
|
||||
opacity="0.15"
|
||||
/>
|
||||
|
||||
{/* Forecast Line (Blue) */}
|
||||
<path
|
||||
d="M 0 85 Q 40 75 85 60 T 170 35 T 255 45 T 340 55"
|
||||
stroke="#3b82f6"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
|
||||
{/* Observation Line (Green Solid, ending at current 170px) */}
|
||||
<path
|
||||
d="M 0 87 Q 40 78 85 63 T 170 33"
|
||||
stroke="#10b981"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
|
||||
{/* Current Temp Point */}
|
||||
<circle cx="170" cy="33" r="4" fill="#10b981" />
|
||||
<circle cx="170" cy="33" r="8" stroke="#10b981" strokeWidth="1.5" className="animate-ping" opacity="0.5" />
|
||||
|
||||
{/* Definitions for Gradients */}
|
||||
<defs>
|
||||
<linearGradient id="chartGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#3b82f6" />
|
||||
<stop offset="100%" stopColor="#3b82f6" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
{isLogin && !resetSent ? (
|
||||
<div className="mt-2 text-right">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void onResetPassword()}
|
||||
disabled={loading}
|
||||
className="text-xs text-slate-500 underline-offset-2 transition-all hover:text-blue-400 hover:underline disabled:opacity-50"
|
||||
>
|
||||
{copy.reset}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<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}
|
||||
{errorText && isLogin && errorText.includes("Invalid login") ? (
|
||||
<p className="mt-1 text-xs text-slate-500">{copy.loginFailedHint}</p>
|
||||
) : null}
|
||||
{infoText === copy.signupCheckEmail ? (
|
||||
<p className="mt-1 text-xs text-slate-500">{copy.resendVerify}</p>
|
||||
) : null}
|
||||
|
||||
<div className="mt-8 text-center">
|
||||
<p className="text-xs text-slate-500">
|
||||
{isLogin ? copy.loginHint : copy.signupHint}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="relative z-10 text-[10px] text-slate-500 font-mono">
|
||||
{isEn ? "PolyWeather institutional analytics suite" : "PolyWeather 机构版天气决策系统"}
|
||||
</div>
|
||||
{!supabaseReady ? (
|
||||
<p className="mt-3 text-center text-sm text-rose-300">{copy.supabaseMissing}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Right Column (Forms) */}
|
||||
<div className="flex flex-1 flex-col justify-between p-6 sm:p-10 bg-gradient-to-br from-[#f8fafc] via-[#ffffff] to-[#eff4f9] min-h-screen relative overflow-hidden">
|
||||
{/* Subtle mesh background */}
|
||||
<div className="absolute inset-0 bg-[linear-gradient(to_right,rgba(15,23,42,0.01)_1px,transparent_1px),linear-gradient(to_bottom,rgba(15,23,42,0.01)_1px,transparent_1px)] bg-[size:24px_24px] pointer-events-none animate-pulse" style={{ animationDuration: '4s' }} />
|
||||
|
||||
{/* Top Header Switch */}
|
||||
<div className="relative z-10 flex justify-between lg:justify-end items-center gap-3">
|
||||
{/* Logo on top-left for mobile only */}
|
||||
<Link href="/" className="flex items-center hover:opacity-90 transition-opacity lg:hidden">
|
||||
<img src="/logo.png" alt="PolyWeather" className="h-7 w-auto object-contain" />
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-slate-500">
|
||||
{isLogin ? copy.newToPoly : copy.alreadyHave}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setErrorText("");
|
||||
setInfoText("");
|
||||
setMode(isLogin ? "signup" : "login");
|
||||
}}
|
||||
className="rounded-xl border border-slate-200 bg-white px-4 py-2 text-xs font-bold text-slate-700 shadow-sm transition-all hover:border-slate-300 hover:bg-slate-50 hover:text-slate-900 active:scale-[0.98]"
|
||||
>
|
||||
{isLogin ? copy.signup : copy.login}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Center Form Card */}
|
||||
<div className="relative z-10 flex flex-1 items-center justify-center my-10">
|
||||
<div className="w-full max-w-[440px] bg-white/90 backdrop-blur-xl border border-slate-200/50 rounded-2xl p-6 sm:p-10 shadow-[0_24px_60px_rgba(8,16,36,0.06)] animate-fade-up [animation-delay:200ms] opacity-0 transition-transform hover:-translate-y-1 hover:shadow-[0_32px_80px_rgba(8,16,36,0.08)] duration-500">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-black tracking-tight text-slate-900 mb-2">
|
||||
{isLogin ? copy.welcomeBack : copy.signUpTitle}
|
||||
</h1>
|
||||
<p className="text-xs text-slate-500 leading-relaxed">
|
||||
{copy.subtitle}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={(event) => void onEmailSubmit(event)} className="space-y-5">
|
||||
<div className="space-y-2 animate-fade-up [animation-delay:350ms] opacity-0">
|
||||
<label className="text-[10px] font-bold text-slate-400 uppercase tracking-wider block">
|
||||
{copy.workEmail}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(event) => setEmail(event.target.value)}
|
||||
placeholder="yourname@email.com"
|
||||
className="w-full rounded-xl border border-slate-200 bg-slate-50/50 py-3 pl-11 pr-4 text-sm text-slate-900 placeholder:text-slate-400 transition-all duration-200 focus:border-blue-500 focus:bg-white focus:outline-none focus:ring-4 focus:ring-blue-500/10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 animate-fade-up [animation-delay:450ms] opacity-0">
|
||||
<div className="flex justify-between items-center">
|
||||
<label className="text-[10px] font-bold text-slate-400 uppercase tracking-wider block">
|
||||
{copy.password}
|
||||
</label>
|
||||
{isLogin && !resetSent ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void onResetPassword()}
|
||||
disabled={loading}
|
||||
className="text-xs font-semibold text-blue-600 hover:text-blue-700 transition-colors"
|
||||
>
|
||||
{copy.reset}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
|
||||
<input
|
||||
type={showPassword ? "text" : "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-slate-200 bg-slate-50/50 py-3 pl-11 pr-11 text-sm text-slate-900 placeholder:text-slate-400 transition-all duration-200 focus:border-blue-500 focus:bg-white focus:outline-none focus:ring-4 focus:ring-blue-500/10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3.5 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 transition-colors"
|
||||
>
|
||||
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isLogin && (
|
||||
<p className="text-[11px] leading-relaxed text-slate-400">
|
||||
{copy.termsAgreement}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-3.5 px-4 rounded-xl bg-gradient-to-r from-slate-900 to-slate-800 hover:from-blue-600 hover:to-indigo-600 text-sm font-bold text-white shadow-lg shadow-slate-950/10 hover:shadow-blue-600/25 active:scale-[0.98] transition-all duration-300 disabled:opacity-50 mt-8 flex items-center justify-center gap-2 group animate-fade-up [animation-delay:550ms] opacity-0"
|
||||
>
|
||||
<span>{isLogin ? copy.loginSubmit : copy.signupSubmit}</span>
|
||||
<ArrowRight size={16} className="transition-transform group-hover:translate-x-1" />
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="my-6 flex items-center">
|
||||
<div className="h-px flex-grow bg-slate-200/60" />
|
||||
<span className="px-3 text-[10px] font-bold uppercase tracking-wider text-slate-400">
|
||||
{isEn ? "or" : "或"}
|
||||
</span>
|
||||
<div className="h-px flex-grow bg-slate-200/60" />
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void onGoogleSignIn()}
|
||||
disabled={loading}
|
||||
className="w-full py-3 px-4 rounded-xl border border-slate-200 bg-white text-sm font-semibold text-slate-700 shadow-sm hover:bg-slate-50 active:scale-[0.99] transition-all duration-150 flex items-center justify-center gap-2 disabled:opacity-50"
|
||||
>
|
||||
<Chrome className="h-4 w-4 text-blue-600" />
|
||||
<span>{copy.googleOneClick}</span>
|
||||
</button>
|
||||
|
||||
{errorText ? <p className="mt-4 rounded-xl border border-rose-200 bg-rose-50 px-3 py-2.5 text-xs text-rose-700 leading-normal">{errorText}</p> : null}
|
||||
{infoText ? <p className="mt-4 rounded-xl border border-emerald-200 bg-emerald-50 px-3 py-2.5 text-xs text-emerald-700 leading-normal">{infoText}</p> : null}
|
||||
{errorText && isLogin && errorText.includes("Invalid login") ? (
|
||||
<p className="mt-2 text-center text-xs text-slate-500 leading-relaxed">{copy.loginFailedHint}</p>
|
||||
) : null}
|
||||
{infoText === copy.signupCheckEmail ? (
|
||||
<p className="mt-2 text-center text-xs text-slate-500 leading-relaxed">{copy.resendVerify}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="relative z-10 text-[10px] text-slate-400 text-center font-mono">
|
||||
© {new Date().getFullYear()} PolyWeather. All rights reserved.
|
||||
</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,378 @@
|
||||
"use client";
|
||||
|
||||
import { startTransition, useEffect, useMemo, useRef, useState } from "react";
|
||||
import clsx from "clsx";
|
||||
import { Clock, Search } from "lucide-react";
|
||||
import { useDashboardStore } from "@/hooks/useDashboardStore";
|
||||
import { useI18n } from "@/hooks/useI18n";
|
||||
import { getLocalizedCityName } from "@/lib/dashboard-home-copy";
|
||||
import { CityListItem, DeviationMonitor } 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 toPerformanceGroup(city: CityListItem): RiskGroupKey {
|
||||
return toRiskGroup(city.deb_recent_tier);
|
||||
}
|
||||
|
||||
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 { locale, 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 [searchQuery, setSearchQuery] = useState("");
|
||||
const cityItemRefs = useRef<Record<string, HTMLButtonElement | null>>({});
|
||||
const normalizedQuery = searchQuery.trim().toLowerCase();
|
||||
|
||||
const sortedCities = useMemo(
|
||||
() =>
|
||||
[...store.cities].sort((a, b) => {
|
||||
const aGroup = toPerformanceGroup(a);
|
||||
const bGroup = toPerformanceGroup(b);
|
||||
const aHitRate = Number(a.deb_recent_hit_rate ?? -1);
|
||||
const bHitRate = Number(b.deb_recent_hit_rate ?? -1);
|
||||
const aSamples = Number(a.deb_recent_sample_count ?? 0);
|
||||
const bSamples = Number(b.deb_recent_sample_count ?? 0);
|
||||
return (
|
||||
(riskOrder[aGroup] ?? 3) - (riskOrder[bGroup] ?? 3) ||
|
||||
bHitRate - aHitRate ||
|
||||
bSamples - aSamples ||
|
||||
a.display_name.localeCompare(b.display_name)
|
||||
);
|
||||
}),
|
||||
[store.cities],
|
||||
);
|
||||
|
||||
const groupedCities = useMemo(() => {
|
||||
const groups: Record<RiskGroupKey, CityListItem[]> = {
|
||||
high: [],
|
||||
medium: [],
|
||||
low: [],
|
||||
other: [],
|
||||
};
|
||||
sortedCities.forEach((city) => {
|
||||
const summary = store.citySummariesByName[city.name];
|
||||
const detail = store.cityDetailsByName[city.name];
|
||||
const localizedName = getLocalizedCityName(
|
||||
city.name,
|
||||
summary?.display_name || detail?.display_name || city.display_name,
|
||||
locale,
|
||||
);
|
||||
if (normalizedQuery) {
|
||||
const searchCorpus = [
|
||||
city.name,
|
||||
city.display_name,
|
||||
city.airport,
|
||||
city.icao,
|
||||
localizedName,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
if (!searchCorpus.includes(normalizedQuery)) return;
|
||||
}
|
||||
groups[toPerformanceGroup(city)].push(city);
|
||||
});
|
||||
return groups;
|
||||
}, [
|
||||
locale,
|
||||
normalizedQuery,
|
||||
sortedCities,
|
||||
store.cityDetailsByName,
|
||||
store.citySummariesByName,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedCity) return;
|
||||
const selected = store.cities.find((city) => city.name === selectedCity);
|
||||
if (!selected) return;
|
||||
const groupKey = toPerformanceGroup(selected);
|
||||
setExpandedGroups((current) =>
|
||||
current[groupKey] ? current : { ...current, [groupKey]: true },
|
||||
);
|
||||
}, [selectedCity, store.cities]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedCity) return;
|
||||
const selected = store.cities.find((city) => city.name === selectedCity);
|
||||
if (!selected) return;
|
||||
const groupKey = toPerformanceGroup(selected);
|
||||
if (!expandedGroups[groupKey]) return;
|
||||
|
||||
const frameId = window.requestAnimationFrame(() => {
|
||||
cityItemRefs.current[selectedCity]?.scrollIntoView({
|
||||
block: "center",
|
||||
inline: "nearest",
|
||||
behavior: "smooth",
|
||||
});
|
||||
});
|
||||
return () => window.cancelAnimationFrame(frameId);
|
||||
}, [expandedGroups, 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 formatDeviationText = (monitor?: DeviationMonitor | null) => {
|
||||
if (!monitor?.available) return "";
|
||||
const label = locale === "en-US" ? monitor.label_en : monitor.label_zh;
|
||||
const trendLabel =
|
||||
locale === "en-US" ? monitor.trend_label_en : monitor.trend_label_zh;
|
||||
if (!label) return "";
|
||||
return trendLabel ? `${label} · ${trendLabel}` : label;
|
||||
};
|
||||
|
||||
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") },
|
||||
];
|
||||
const syncTime = useMemo(
|
||||
() =>
|
||||
new Intl.DateTimeFormat(locale === "en-US" ? "en-US" : "zh-CN", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: false,
|
||||
}).format(new Date()),
|
||||
[locale],
|
||||
);
|
||||
|
||||
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-search">
|
||||
<Search size={14} strokeWidth={2} aria-hidden="true" />
|
||||
<input
|
||||
type="search"
|
||||
value={searchQuery}
|
||||
onChange={(event) => setSearchQuery(event.target.value)}
|
||||
placeholder={locale === "en-US" ? "Search city" : "搜索城市"}
|
||||
aria-label={locale === "en-US" ? "Search city" : "搜索城市"}
|
||||
/>
|
||||
</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">
|
||||
<span
|
||||
className={clsx("city-group-indicator", group.key)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{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 localizedCityName = getLocalizedCityName(
|
||||
city.name,
|
||||
snapshot?.display_name || city.display_name,
|
||||
locale,
|
||||
);
|
||||
const isActive = store.selectedCity === city.name;
|
||||
const tempSymbol = snapshot?.temp_symbol || "°C";
|
||||
const currentTempText =
|
||||
snapshot?.current?.temp != null
|
||||
? t("sidebar.currentTemp", {
|
||||
temp: `${snapshot.current.temp}${tempSymbol}`,
|
||||
})
|
||||
: t("common.na");
|
||||
const deviationText = formatDeviationText(
|
||||
snapshot?.deviation_monitor,
|
||||
);
|
||||
const peakTempText =
|
||||
detail?.current?.max_so_far != null &&
|
||||
detail.current.max_temp_time
|
||||
? t("sidebar.peakTempAt", {
|
||||
temp: `${detail.current.max_so_far}${tempSymbol}`,
|
||||
time: detail.current.max_temp_time,
|
||||
})
|
||||
: detail?.current?.max_temp_time
|
||||
? t("sidebar.peakAt", {
|
||||
time: detail.current.max_temp_time,
|
||||
})
|
||||
: "";
|
||||
const deviationDirection =
|
||||
snapshot?.deviation_monitor?.direction || "normal";
|
||||
const deviationSeverity =
|
||||
snapshot?.deviation_monitor?.severity || "normal";
|
||||
const secondaryText = deviationText || peakTempText;
|
||||
const performanceTier = toPerformanceGroup(city);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={city.name}
|
||||
ref={(node) => {
|
||||
cityItemRefs.current[city.name] = node;
|
||||
}}
|
||||
type="button"
|
||||
className={clsx("city-item", isActive && "active")}
|
||||
onClick={() =>
|
||||
startTransition(() => {
|
||||
void store.focusCity(city.name);
|
||||
})
|
||||
}
|
||||
>
|
||||
<div className="city-item-main">
|
||||
<span className={clsx("risk-dot", performanceTier)} />
|
||||
<span className="city-name-text">
|
||||
{localizedCityName}
|
||||
</span>
|
||||
<span
|
||||
className={clsx(
|
||||
"city-temp",
|
||||
snapshot?.current?.temp != null && "loaded",
|
||||
)}
|
||||
>
|
||||
{currentTempText}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="city-item-info">
|
||||
<span className="city-local-time">
|
||||
{snapshot?.local_time ? (
|
||||
<>
|
||||
<Clock
|
||||
size={10}
|
||||
strokeWidth={2}
|
||||
className="city-clock-icon"
|
||||
/>
|
||||
{snapshot.local_time}
|
||||
</>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
className={clsx(
|
||||
"city-max-info",
|
||||
deviationText && "city-deviation-info",
|
||||
deviationText &&
|
||||
`city-deviation-${deviationDirection}`,
|
||||
deviationText &&
|
||||
deviationSeverity === "strong" &&
|
||||
"strong",
|
||||
)}
|
||||
>
|
||||
{secondaryText}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="sidebar-footer">
|
||||
<div>
|
||||
{locale === "en-US" ? "Data sync" : "数据更新"} {syncTime}
|
||||
</div>
|
||||
<div>
|
||||
{locale === "en-US"
|
||||
? "Sources: METAR · Open-Meteo · DEB · Market"
|
||||
: "数据来源:METAR · Open-Meteo · DEB · 市场"}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { DashboardShellSkeleton } from "@/components/dashboard/DashboardShellSkeleton";
|
||||
|
||||
const ScanTerminalDashboard = dynamic(
|
||||
() =>
|
||||
import("@/components/dashboard/ScanTerminalDashboard").then(
|
||||
(module) => module.ScanTerminalDashboard,
|
||||
),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => <DashboardShellSkeleton />,
|
||||
},
|
||||
);
|
||||
|
||||
export function DashboardEntry() {
|
||||
return <ScanTerminalDashboard />;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,840 @@
|
||||
/* ── Map ── */
|
||||
.root :global(.map) {
|
||||
position: absolute;
|
||||
top: calc(var(--header-height) + 16px);
|
||||
right: 392px;
|
||||
bottom: 164px;
|
||||
left: calc(var(--sidebar-width) + 22px);
|
||||
z-index: 1;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(115, 137, 161, 0.16);
|
||||
border-radius: 24px;
|
||||
background: #040912;
|
||||
box-shadow:
|
||||
0 32px 100px rgba(0, 0, 0, 0.5),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.04),
|
||||
inset 0 0 0 1px rgba(0, 224, 164, 0.04);
|
||||
}
|
||||
|
||||
.weatherAura {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 2;
|
||||
pointer-events: none;
|
||||
opacity: 0.96;
|
||||
mix-blend-mode: screen;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.weatherAura :global(canvas) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
opacity: 0.94;
|
||||
filter: saturate(1.05) blur(0.3px);
|
||||
}
|
||||
|
||||
.weatherAuraScrim {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
linear-gradient(
|
||||
180deg,
|
||||
rgba(3, 8, 19, 0.64) 0%,
|
||||
rgba(5, 10, 20, 0.16) 24%,
|
||||
rgba(4, 8, 18, 0.1) 54%,
|
||||
rgba(3, 6, 14, 0.42) 100%
|
||||
),
|
||||
radial-gradient(
|
||||
circle at 50% 60%,
|
||||
rgba(0, 224, 164, 0.08) 0%,
|
||||
rgba(123, 97, 255, 0) 48%
|
||||
);
|
||||
}
|
||||
|
||||
.weatherAura[data-reduced-motion="true"] :global(canvas) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.root :global(.map .leaflet-tile),
|
||||
.root :global(.map .leaflet-marker-icon),
|
||||
.root :global(.map .leaflet-marker-shadow),
|
||||
.root :global(.map .leaflet-container img),
|
||||
.root :global(.map .leaflet-container svg) {
|
||||
max-width: none !important;
|
||||
max-height: none !important;
|
||||
}
|
||||
|
||||
.root :global(.map .leaflet-tile) {
|
||||
width: 256px !important;
|
||||
height: 256px !important;
|
||||
}
|
||||
|
||||
/* Remove Leaflet default styling for cleaner look */
|
||||
.root :global(.leaflet-control-attribution) {
|
||||
background: var(--bg-glass) !important;
|
||||
color: var(--text-muted) !important;
|
||||
backdrop-filter: blur(8px);
|
||||
border: 1px solid var(--border-subtle) !important;
|
||||
font-size: 10px !important;
|
||||
border-radius: 6px !important;
|
||||
padding: 2px 8px !important;
|
||||
}
|
||||
.root :global(.leaflet-control-attribution a) {
|
||||
color: var(--text-secondary) !important;
|
||||
}
|
||||
.root :global(.leaflet-control-zoom) {
|
||||
border: none !important;
|
||||
box-shadow: var(--shadow-lg) !important;
|
||||
}
|
||||
.root :global(.leaflet-control-zoom a) {
|
||||
background: var(--bg-glass) !important;
|
||||
color: var(--text-primary) !important;
|
||||
backdrop-filter: blur(12px) !important;
|
||||
border: 1px solid var(--border-glass) !important;
|
||||
width: 36px !important;
|
||||
height: 36px !important;
|
||||
line-height: 36px !important;
|
||||
font-size: 16px !important;
|
||||
border-radius: 8px !important;
|
||||
transition: var(--transition);
|
||||
}
|
||||
.root :global(.leaflet-control-zoom a:hover) {
|
||||
background: rgba(99, 102, 241, 0.2) !important;
|
||||
border-color: var(--accent-blue) !important;
|
||||
}
|
||||
|
||||
/* ── Custom Map Markers ── */
|
||||
.root :global(.city-marker) {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
/* Remove transition: transform to avoid fighting with Leaflet's own positioning */
|
||||
}
|
||||
.root :global(.city-marker:hover) {
|
||||
transform: scale(1.15);
|
||||
z-index: 1000 !important;
|
||||
}
|
||||
|
||||
.root :global(.marker-bubble) {
|
||||
min-width: 44px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 14px;
|
||||
font-family: "Inter", sans-serif;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
color: white;
|
||||
white-space: nowrap;
|
||||
position: relative;
|
||||
box-shadow:
|
||||
0 6px 18px rgba(0, 0, 0, 0.42),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
}
|
||||
|
||||
.root :global(.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;
|
||||
border-top-color: inherit;
|
||||
}
|
||||
|
||||
.root :global(.marker-bubble.risk-high) {
|
||||
background: linear-gradient(135deg, #991b1b, #fb7185);
|
||||
border-color: rgba(251, 113, 133, 0.42);
|
||||
box-shadow:
|
||||
0 6px 18px rgba(0, 0, 0, 0.42),
|
||||
0 0 22px rgba(251, 113, 133, 0.18);
|
||||
}
|
||||
.root :global(.marker-bubble.risk-high::after) {
|
||||
border-top-color: var(--color-signal-danger);
|
||||
}
|
||||
|
||||
.root :global(.marker-bubble.risk-medium) {
|
||||
background: linear-gradient(135deg, #9a3412, #f59e0b);
|
||||
border-color: rgba(245, 158, 11, 0.42);
|
||||
box-shadow:
|
||||
0 6px 18px rgba(0, 0, 0, 0.42),
|
||||
0 0 22px rgba(245, 158, 11, 0.16);
|
||||
}
|
||||
.root :global(.marker-bubble.risk-medium::after) {
|
||||
border-top-color: var(--color-signal-warning);
|
||||
}
|
||||
|
||||
.root :global(.marker-bubble.risk-low) {
|
||||
background: linear-gradient(135deg, #065f46, #22c55e);
|
||||
border-color: rgba(74, 222, 128, 0.42);
|
||||
box-shadow:
|
||||
0 6px 18px rgba(0, 0, 0, 0.42),
|
||||
0 0 22px rgba(74, 222, 128, 0.14);
|
||||
}
|
||||
.root :global(.marker-bubble.risk-low::after) {
|
||||
border-top-color: var(--color-signal-success);
|
||||
}
|
||||
|
||||
.root :global(.marker-name) {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.8);
|
||||
margin-top: 2px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Marker glow animation for selected city */
|
||||
.root :global(.city-marker.selected .marker-bubble) {
|
||||
animation: markerGlow 2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes markerGlow {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow:
|
||||
0 6px 18px rgba(0, 0, 0, 0.42),
|
||||
0 0 24px rgba(34, 211, 238, 0.12);
|
||||
}
|
||||
50% {
|
||||
box-shadow:
|
||||
0 6px 26px rgba(0, 0, 0, 0.46),
|
||||
0 0 20px rgba(34, 211, 238, 0.24),
|
||||
0 0 46px rgba(34, 211, 238, 0.14);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Loading Overlay ── */
|
||||
.root :global(.loading-overlay) {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 2000;
|
||||
background: radial-gradient(
|
||||
circle at 50% 50%,
|
||||
rgba(8, 14, 32, 0.76) 0%,
|
||||
rgba(4, 8, 22, 0.9) 44%,
|
||||
rgba(2, 6, 20, 0.96) 100%
|
||||
);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.root :global(.loading-card) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 22px;
|
||||
min-width: 240px;
|
||||
padding: 28px 32px;
|
||||
border-radius: 28px;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(10, 18, 36, 0.78) 0%,
|
||||
rgba(8, 14, 30, 0.62) 100%
|
||||
);
|
||||
border: 1px solid rgba(92, 142, 255, 0.16);
|
||||
box-shadow:
|
||||
0 24px 90px rgba(0, 0, 0, 0.42),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.root :global(.loading-clouds) {
|
||||
position: relative;
|
||||
width: 220px;
|
||||
height: 26px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.root :global(.loading-cloud) {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
height: 18px;
|
||||
border-radius: 999px;
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at 30% 50%,
|
||||
rgba(186, 230, 253, 0.22),
|
||||
transparent 54%
|
||||
),
|
||||
linear-gradient(
|
||||
180deg,
|
||||
rgba(148, 163, 184, 0.28) 0%,
|
||||
rgba(71, 85, 105, 0.1) 100%
|
||||
);
|
||||
filter: blur(0.3px);
|
||||
box-shadow:
|
||||
0 6px 24px rgba(56, 189, 248, 0.06),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
||||
animation: cloud-drift 6.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.root :global(.loading-cloud-1) {
|
||||
left: 12px;
|
||||
width: 76px;
|
||||
}
|
||||
|
||||
.root :global(.loading-cloud-2) {
|
||||
right: 8px;
|
||||
top: 7px;
|
||||
width: 98px;
|
||||
opacity: 0.75;
|
||||
animation-delay: -2.6s;
|
||||
}
|
||||
|
||||
.root :global(.loading-windfield) {
|
||||
position: relative;
|
||||
width: 220px;
|
||||
height: 30px;
|
||||
overflow: hidden;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.root :global(.loading-windline) {
|
||||
position: absolute;
|
||||
left: -28%;
|
||||
height: 1px;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(34, 211, 238, 0) 0%,
|
||||
rgba(103, 232, 249, 0.5) 22%,
|
||||
rgba(125, 211, 252, 0.9) 55%,
|
||||
rgba(34, 211, 238, 0) 100%
|
||||
);
|
||||
filter: blur(0.2px);
|
||||
animation: wind-shift 2.8s linear infinite;
|
||||
}
|
||||
|
||||
.root :global(.loading-windline-1) {
|
||||
top: 4px;
|
||||
width: 124px;
|
||||
}
|
||||
|
||||
.root :global(.loading-windline-2) {
|
||||
top: 13px;
|
||||
width: 176px;
|
||||
animation-delay: -0.9s;
|
||||
opacity: 0.82;
|
||||
}
|
||||
|
||||
.root :global(.loading-windline-3) {
|
||||
top: 22px;
|
||||
width: 142px;
|
||||
animation-delay: -1.6s;
|
||||
opacity: 0.66;
|
||||
}
|
||||
|
||||
.root :global(.loading-copy) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.root :global(.loading-copy strong) {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.22em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(103, 232, 249, 0.92);
|
||||
}
|
||||
|
||||
.root :global(.loading-copy span) {
|
||||
max-width: 240px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
line-height: 1.55;
|
||||
color: rgba(203, 213, 225, 0.82);
|
||||
}
|
||||
|
||||
.root :global(.loading-radar) {
|
||||
position: relative;
|
||||
width: 108px;
|
||||
height: 108px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(
|
||||
circle,
|
||||
rgba(22, 78, 99, 0.16) 0%,
|
||||
rgba(7, 18, 36, 0.08) 48%,
|
||||
rgba(5, 10, 22, 0.02) 76%,
|
||||
transparent 100%
|
||||
);
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(56, 189, 248, 0.12),
|
||||
0 0 40px rgba(34, 211, 238, 0.08);
|
||||
}
|
||||
|
||||
.root :global(.loading-radar-core) {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, var(--color-accent-secondary) 0%, #0891b2 100%);
|
||||
transform: translate(-50%, -50%);
|
||||
box-shadow:
|
||||
0 0 12px rgba(34, 211, 238, 0.65),
|
||||
0 0 28px rgba(59, 130, 246, 0.25);
|
||||
}
|
||||
|
||||
.root :global(.loading-radar-ring) {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(56, 189, 248, 0.14);
|
||||
}
|
||||
|
||||
.root :global(.loading-radar-ring-1) {
|
||||
transform: scale(0.62);
|
||||
}
|
||||
|
||||
.root :global(.loading-radar-ring-2) {
|
||||
transform: scale(0.88);
|
||||
}
|
||||
|
||||
.root :global(.loading-radar-sweep) {
|
||||
position: absolute;
|
||||
inset: 6px;
|
||||
border-radius: 50%;
|
||||
background: conic-gradient(
|
||||
from 90deg,
|
||||
rgba(34, 211, 238, 0) 0deg,
|
||||
rgba(34, 211, 238, 0) 260deg,
|
||||
rgba(34, 211, 238, 0.3) 312deg,
|
||||
rgba(96, 165, 250, 0.06) 360deg
|
||||
);
|
||||
mask: radial-gradient(circle, transparent 0 22px, #000 23px);
|
||||
animation: radar-sweep 2.4s linear infinite;
|
||||
}
|
||||
|
||||
.root :global(.loading-radar-blip) {
|
||||
position: absolute;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: rgba(253, 224, 71, 0.92);
|
||||
box-shadow: 0 0 12px rgba(253, 224, 71, 0.45);
|
||||
animation: radar-blip 1.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.root :global(.loading-radar-blip-1) {
|
||||
top: 26px;
|
||||
right: 24px;
|
||||
animation-delay: 0.15s;
|
||||
}
|
||||
|
||||
.root :global(.loading-radar-blip-2) {
|
||||
bottom: 25px;
|
||||
left: 20px;
|
||||
background: rgba(74, 222, 128, 0.9);
|
||||
box-shadow: 0 0 12px rgba(74, 222, 128, 0.4);
|
||||
animation-delay: 0.9s;
|
||||
}
|
||||
|
||||
.root :global(.loading-thermals) {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
gap: 8px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.root :global(.loading-thermal) {
|
||||
width: 10px;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(251, 191, 36, 0.15) 0%,
|
||||
rgba(251, 146, 60, 0.8) 55%,
|
||||
rgba(239, 68, 68, 0.9) 100%
|
||||
);
|
||||
box-shadow: 0 0 18px rgba(251, 146, 60, 0.18);
|
||||
animation: thermal-rise 1.5s ease-in-out infinite;
|
||||
transform-origin: center bottom;
|
||||
}
|
||||
|
||||
.root :global(.loading-thermal-1) {
|
||||
height: 11px;
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
.root :global(.loading-thermal-2) {
|
||||
height: 22px;
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
|
||||
.root :global(.loading-thermal-3) {
|
||||
height: 16px;
|
||||
animation-delay: 0.42s;
|
||||
}
|
||||
|
||||
.root :global(.loading-thermal-4) {
|
||||
height: 25px;
|
||||
animation-delay: 0.68s;
|
||||
}
|
||||
|
||||
.root :global(.loading-drizzle) {
|
||||
position: relative;
|
||||
width: 220px;
|
||||
height: 26px;
|
||||
overflow: hidden;
|
||||
opacity: 0.88;
|
||||
}
|
||||
|
||||
.root :global(.loading-drizzle-drop) {
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
width: 2px;
|
||||
height: 16px;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(125, 211, 252, 0) 0%,
|
||||
rgba(125, 211, 252, 0.9) 100%
|
||||
);
|
||||
box-shadow: 0 0 8px rgba(56, 189, 248, 0.12);
|
||||
animation: drizzle-fall 1.35s linear infinite;
|
||||
}
|
||||
|
||||
.root :global(.loading-drizzle-drop-1) {
|
||||
left: 38px;
|
||||
animation-delay: -0.1s;
|
||||
}
|
||||
|
||||
.root :global(.loading-drizzle-drop-2) {
|
||||
left: 76px;
|
||||
height: 18px;
|
||||
animation-delay: -0.52s;
|
||||
}
|
||||
|
||||
.root :global(.loading-drizzle-drop-3) {
|
||||
left: 112px;
|
||||
height: 14px;
|
||||
animation-delay: -0.92s;
|
||||
}
|
||||
|
||||
.root :global(.loading-drizzle-drop-4) {
|
||||
left: 156px;
|
||||
height: 17px;
|
||||
animation-delay: -0.38s;
|
||||
}
|
||||
|
||||
.root :global(.loading-drizzle-drop-5) {
|
||||
left: 192px;
|
||||
height: 13px;
|
||||
animation-delay: -0.74s;
|
||||
}
|
||||
|
||||
.root :global(.loading-overlay.hidden) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.root :global(.loading-spinner) {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 2px solid rgba(34, 211, 238, 0.1);
|
||||
border-top-color: var(--accent-cyan);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s cubic-bezier(0.4, 0, 0.2, 1) infinite;
|
||||
box-shadow: 0 0 15px rgba(34, 211, 238, 0.1);
|
||||
}
|
||||
|
||||
|
||||
@keyframes city-loading-pulse {
|
||||
0% {
|
||||
transform: scale(0.92);
|
||||
box-shadow: 0 0 0 0 rgba(34, 211, 238, 0.44);
|
||||
}
|
||||
70% {
|
||||
transform: scale(1);
|
||||
box-shadow: 0 0 0 10px rgba(34, 211, 238, 0);
|
||||
}
|
||||
100% {
|
||||
transform: scale(0.92);
|
||||
box-shadow: 0 0 0 0 rgba(34, 211, 238, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes radar-sweep {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes radar-blip {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.2;
|
||||
transform: scale(0.72);
|
||||
}
|
||||
40% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
60% {
|
||||
opacity: 0.9;
|
||||
transform: scale(1.18);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes wind-shift {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
opacity: 0;
|
||||
}
|
||||
18% {
|
||||
opacity: 0.9;
|
||||
}
|
||||
82% {
|
||||
opacity: 0.78;
|
||||
}
|
||||
to {
|
||||
transform: translateX(145%);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes thermal-rise {
|
||||
0%,
|
||||
100% {
|
||||
transform: scaleY(0.78);
|
||||
opacity: 0.72;
|
||||
}
|
||||
50% {
|
||||
transform: scaleY(1.14);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes cloud-drift {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
opacity: 0.7;
|
||||
}
|
||||
50% {
|
||||
transform: translateX(12px);
|
||||
opacity: 0.95;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes drizzle-fall {
|
||||
0% {
|
||||
transform: translateY(-3px);
|
||||
opacity: 0;
|
||||
}
|
||||
20% {
|
||||
opacity: 0.9;
|
||||
}
|
||||
100% {
|
||||
transform: translateY(18px);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Nearby Stations (Premium Glassmorphism) ── */
|
||||
.root :global(.nearby-marker-premium) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
background: rgba(10, 14, 26, 0.75);
|
||||
backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 999px; /* Pill shape */
|
||||
padding: 5px 14px 5px 10px;
|
||||
box-shadow:
|
||||
0 8px 32px rgba(0, 0, 0, 0.4),
|
||||
inset 0 1px 1px rgba(255, 255, 255, 0.05);
|
||||
pointer-events: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.root :global(.nearby-marker-shell) {
|
||||
display: inline-block;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
@keyframes nearby-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.9) translateY(4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.root :global(.nearby-pulse) {
|
||||
position: relative;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.root :global(.pulse-core) {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background: var(--accent-cyan);
|
||||
border-radius: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
box-shadow: 0 0 8px var(--accent-cyan);
|
||||
}
|
||||
|
||||
.root :global(.pulse-ring) {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 1.5px solid var(--accent-cyan);
|
||||
border-radius: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
opacity: 0.3;
|
||||
box-shadow: 0 0 10px rgba(34, 211, 238, 0.18);
|
||||
}
|
||||
|
||||
.root :global(.nearby-content) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.root :global(.nearby-label) {
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.root :global(.nearby-stats) {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.root :global(.nearby-temp-val) {
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
color: var(--text-primary);
|
||||
text-shadow: 0 0 12px rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.root :global(.nearby-temp-unit) {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.root :global(.nearby-time) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-top: 3px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: rgba(203, 213, 225, 0.95);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.root :global(.nearby-time span + span)::before {
|
||||
content: "·";
|
||||
margin-right: 4px;
|
||||
color: rgba(148, 163, 184, 0.55);
|
||||
}
|
||||
|
||||
.root :global(.nearby-time.is-stale) {
|
||||
color: var(--color-signal-warning);
|
||||
}
|
||||
|
||||
.root :global(.nearby-wind) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding-left: 10px;
|
||||
margin-left: 2px;
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.root :global(.nearby-wind .wind-arrow) {
|
||||
font-size: 12px;
|
||||
color: var(--accent-cyan);
|
||||
text-shadow: 0 0 8px rgba(34, 211, 238, 0.4);
|
||||
}
|
||||
|
||||
.root :global(.nearby-wind .wind-val) {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* ── Trend badge ── */
|
||||
.root :global(.trend-badge) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 3px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.root :global(.trend-badge.rising) {
|
||||
background: rgba(34, 197, 94, 0.12);
|
||||
color: var(--accent-green);
|
||||
border: 1px solid rgba(34, 197, 94, 0.25);
|
||||
}
|
||||
.root :global(.trend-badge.falling) {
|
||||
background: rgba(248, 113, 113, 0.12);
|
||||
color: var(--accent-red);
|
||||
border: 1px solid rgba(248, 113, 113, 0.25);
|
||||
}
|
||||
.root :global(.trend-badge.stagnant) {
|
||||
background: rgba(251, 191, 36, 0.12);
|
||||
color: var(--accent-yellow);
|
||||
border: 1px solid rgba(251, 191, 36, 0.25);
|
||||
}
|
||||
.root :global(.trend-badge.mixed) {
|
||||
background: rgba(167, 139, 250, 0.12);
|
||||
color: var(--accent-purple);
|
||||
border: 1px solid rgba(167, 139, 250, 0.25);
|
||||
}
|
||||
|
||||
.root :global(.dead-market) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 3px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
background: rgba(248, 113, 113, 0.15);
|
||||
color: var(--accent-red);
|
||||
border: 1px solid rgba(248, 113, 113, 0.3);
|
||||
animation: deadPulse 2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes deadPulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
@@ -0,0 +1,299 @@
|
||||
.root :global(.scan-select) {
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(135deg, var(--color-accent-primary), #00b383);
|
||||
color: #000;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
box-shadow: 0 4px 12px rgba(0, 224, 164, 0.2);
|
||||
}
|
||||
|
||||
.root :global(.scan-cta-button:hover:not(:disabled)) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 6px 20px rgba(0, 224, 164, 0.4);
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.root :global(.scan-cta-button:active:not(:disabled)) {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.root :global(.modal-overlay) {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(2, 6, 23, 0.75);
|
||||
backdrop-filter: blur(12px);
|
||||
z-index: 10000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.root :global(.modal-content) {
|
||||
background: #16213A;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 16px;
|
||||
width: 100%;
|
||||
max-width: 700px;
|
||||
max-height: calc(100vh - 48px);
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.root :global(.modal-header) {
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.root :global(.modal-header h2) {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
.root :global(.future-modal-title-with-actions) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.root :global(.future-refresh-btn) {
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 6px;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.root :global(.future-refresh-btn:hover) {
|
||||
color: var(--accent-cyan);
|
||||
background: rgba(34, 211, 238, 0.1);
|
||||
}
|
||||
|
||||
.root :global(.future-refresh-btn.spinning svg) {
|
||||
animation: spin 1s linear infinite;
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.root :global(.modal-close) {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 20px;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
.root :global(.modal-close:hover) {
|
||||
color: var(--accent-red);
|
||||
}
|
||||
|
||||
.root :global(.modal-body) {
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Keep scroll behavior but hide visual scrollbar in today's/future analysis modal */
|
||||
.root :global(.modal-content.large.future-modal .modal-body) {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.root
|
||||
:global(.modal-content.large.future-modal .modal-body::-webkit-scrollbar) {
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.root :global(.modal-content) {
|
||||
border-radius: 14px;
|
||||
max-height: calc(100vh - 16px);
|
||||
}
|
||||
|
||||
.root :global(.modal-header) {
|
||||
padding: 14px 14px 12px;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.root :global(.modal-header h2) {
|
||||
font-size: 15px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.root :global(.modal-body) {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/* ── Info Button ── */
|
||||
.root :global(.account-btn) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(34, 211, 238, 0.34);
|
||||
background: rgba(34, 211, 238, 0.08);
|
||||
color: var(--accent-cyan);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
text-decoration: none;
|
||||
transition: var(--transition);
|
||||
}
|
||||
.root :global(.account-btn:hover) {
|
||||
background: rgba(34, 211, 238, 0.16);
|
||||
border-color: rgba(34, 211, 238, 0.62);
|
||||
color: #f8fbff;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.root :global(.account-renew-badge) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(245, 158, 11, 0.34);
|
||||
background: rgba(245, 158, 11, 0.1);
|
||||
color: #fbbf24;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.01em;
|
||||
text-decoration: none;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.root :global(.account-renew-badge:hover) {
|
||||
background: rgba(245, 158, 11, 0.18);
|
||||
border-color: rgba(245, 158, 11, 0.6);
|
||||
color: #fff6d5;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.root :global(.account-renew-badge.expired) {
|
||||
border-color: rgba(239, 68, 68, 0.34);
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
.root :global(.trial-promo-badge) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 6px 12px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(34, 211, 238, 0.28);
|
||||
background: rgba(34, 211, 238, 0.1);
|
||||
color: #a5f3fc;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.01em;
|
||||
text-decoration: none;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.root :global(.trial-promo-badge:hover) {
|
||||
background: rgba(34, 211, 238, 0.16);
|
||||
border-color: rgba(34, 211, 238, 0.45);
|
||||
color: #ecfeff;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.root :global(.info-btn) {
|
||||
background: rgba(99, 102, 241, 0.1);
|
||||
border: 1px solid rgba(99, 102, 241, 0.3);
|
||||
color: var(--accent-blue);
|
||||
padding: 6px 14px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.root :global(.info-btn:hover) {
|
||||
background: rgba(99, 102, 241, 0.2);
|
||||
border-color: var(--accent-blue);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.root :global(.info-btn.active) {
|
||||
background: rgba(34, 211, 238, 0.14);
|
||||
border-color: rgba(34, 211, 238, 0.42);
|
||||
color: #e0fbff;
|
||||
}
|
||||
|
||||
/* ── Guide Modal Customizations ── */
|
||||
.root :global(.modal-content.large) {
|
||||
max-width: 900px;
|
||||
}
|
||||
|
||||
|
||||
.root :global(.guide-grid) {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 16px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.root :global(.guide-card) {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
transition: var(--transition);
|
||||
}
|
||||
.root :global(.guide-card:hover) {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-color: var(--border-glass);
|
||||
}
|
||||
.root :global(.guide-card h3) {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: var(--accent-cyan);
|
||||
margin-bottom: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.root :global(.guide-card p) {
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.root :global(.guide-card b) {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.root :global(.guide-footer) {
|
||||
margin-top: 24px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
@@ -0,0 +1,821 @@
|
||||
/* ── Header ── */
|
||||
.root :global(.header) {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: var(--header-height);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding: 0 16px;
|
||||
background: rgba(7, 11, 18, 0.94);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border-bottom: 1px solid var(--border-glass);
|
||||
}
|
||||
|
||||
.root :global(.brand) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.root :global(.brand-mark) {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(0, 224, 164, 0.22);
|
||||
background: rgba(8, 15, 28, 0.78);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(148, 163, 184, 0.08),
|
||||
0 0 20px rgba(0, 224, 164, 0.12);
|
||||
}
|
||||
|
||||
.root :global(.brand-mark img) {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: block;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.root :global(.brand h1) {
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.03em;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--accent-cyan) 0%,
|
||||
var(--accent-blue) 100%
|
||||
);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.root :global(.subtitle) {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.04em;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.root :global(.header-nav) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.root :global(.header-nav-link) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 12px 18px;
|
||||
border-radius: 10px;
|
||||
color: rgba(203, 213, 225, 0.82);
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: var(--transition);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.root :global(.header-nav-link:hover) {
|
||||
color: #f8fafc;
|
||||
background: rgba(30, 41, 59, 0.44);
|
||||
}
|
||||
|
||||
.root :global(.header-nav-link.active) {
|
||||
color: #f8fafc;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.root :global(.header-nav-link.active::after) {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 18px;
|
||||
right: 18px;
|
||||
bottom: 4px;
|
||||
height: 2px;
|
||||
border-radius: 999px;
|
||||
background: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.root :global(.header-right) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.root :global(.lang-switch) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 3px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border-glass);
|
||||
background: var(--bg-glass);
|
||||
}
|
||||
|
||||
.root :global(.lang-btn) {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
padding: 6px 8px;
|
||||
border-radius: 7px;
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.root :global(.lang-btn:hover) {
|
||||
color: var(--text-primary);
|
||||
background: rgba(99, 102, 241, 0.12);
|
||||
}
|
||||
|
||||
.root :global(.lang-btn.active) {
|
||||
color: var(--text-primary);
|
||||
background: rgba(0, 224, 164, 0.12);
|
||||
box-shadow: inset 0 0 0 1px rgba(0, 224, 164, 0.2);
|
||||
}
|
||||
|
||||
.root :global(.live-badge) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 9999px;
|
||||
background: rgba(0, 224, 164, 0.08);
|
||||
border: 1px solid rgba(0, 224, 164, 0.22);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: var(--accent-green);
|
||||
letter-spacing: 1.2px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.root :global(.pulse-dot) {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-green);
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
box-shadow: 0 0 0 0 rgba(0, 224, 164, 0.4);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.7;
|
||||
box-shadow: 0 0 0 6px rgba(0, 224, 164, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.root :global(.refresh-btn) {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-glass);
|
||||
background: rgba(13, 19, 33, 0.6);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.root :global(.refresh-btn:hover) {
|
||||
background: rgba(0, 224, 164, 0.1);
|
||||
border-color: rgba(0, 224, 164, 0.3);
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
.root :global(.refresh-btn.spinning) {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.root :global(.locale-switch) {
|
||||
height: 32px;
|
||||
padding: 3px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(0, 224, 164, 0.2);
|
||||
background: rgba(8, 15, 27, 0.82);
|
||||
color: rgba(148, 163, 184, 0.86);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.root :global(.locale-switch span) {
|
||||
min-width: 32px;
|
||||
height: 24px;
|
||||
border-radius: 7px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.root :global(.locale-switch span.active) {
|
||||
color: #f8fafc;
|
||||
background: rgba(0, 224, 164, 0.16);
|
||||
box-shadow: inset 0 0 0 1px rgba(0, 224, 164, 0.24);
|
||||
}
|
||||
|
||||
.root :global(.locale-switch:hover) {
|
||||
border-color: rgba(0, 224, 164, 0.34);
|
||||
background: rgba(11, 22, 40, 0.94);
|
||||
}
|
||||
|
||||
.root :global(.header-utility-btn) {
|
||||
min-width: 32px;
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(115, 137, 161, 0.18);
|
||||
background: rgba(8, 15, 27, 0.82);
|
||||
color: rgba(226, 232, 240, 0.8);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
text-decoration: none;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.root :global(.header-utility-btn:hover) {
|
||||
color: #f8fafc;
|
||||
border-color: rgba(0, 224, 164, 0.34);
|
||||
background: rgba(11, 22, 40, 0.94);
|
||||
}
|
||||
|
||||
.root :global(.header-utility-btn.active) {
|
||||
color: #f8fafc;
|
||||
border-color: rgba(123, 97, 255, 0.34);
|
||||
}
|
||||
|
||||
.root :global(.header-utility-btn.more) {
|
||||
padding: 0;
|
||||
width: 32px;
|
||||
}
|
||||
|
||||
/* ── City List Sidebar ── */
|
||||
.root :global(.city-list) {
|
||||
position: fixed;
|
||||
top: calc(var(--header-height) + 12px);
|
||||
left: 12px;
|
||||
width: var(--sidebar-width);
|
||||
max-height: calc(100vh - var(--header-height) - 24px);
|
||||
z-index: 900;
|
||||
background: var(--bg-glass);
|
||||
backdrop-filter: blur(var(--glass-blur));
|
||||
border: 1px solid var(--border-glass);
|
||||
border-radius: 18px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.root :global(.city-list-header) {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 14px 16px 10px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.root :global(.city-search) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 0 14px 12px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(115, 137, 161, 0.14);
|
||||
background: rgba(6, 12, 22, 0.82);
|
||||
color: rgba(148, 163, 184, 0.86);
|
||||
}
|
||||
|
||||
.root :global(.city-search input) {
|
||||
width: 100%;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
color: #f8fafc;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.root :global(.city-search input::placeholder) {
|
||||
color: rgba(148, 163, 184, 0.72);
|
||||
}
|
||||
|
||||
.root :global(.city-count) {
|
||||
background: rgba(123, 97, 255, 0.2);
|
||||
color: var(--accent-blue);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
padding: 2px 8px;
|
||||
border-radius: 9999px;
|
||||
border: 1px solid rgba(123, 97, 255, 0.15);
|
||||
}
|
||||
|
||||
.root :global(.city-list-items) {
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
padding: 0 6px 8px;
|
||||
}
|
||||
.root :global(.city-list-items::-webkit-scrollbar) {
|
||||
width: 4px;
|
||||
}
|
||||
.root :global(.city-list-items::-webkit-scrollbar-track) {
|
||||
background: transparent;
|
||||
}
|
||||
.root :global(.city-list-items::-webkit-scrollbar-thumb) {
|
||||
background: var(--border-glass);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.root :global(.sidebar-footer) {
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
padding: 10px 12px;
|
||||
color: rgba(148, 163, 184, 0.72);
|
||||
font-size: 10px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.root :global(.city-group) {
|
||||
border: 1px solid rgba(115, 137, 161, 0.12);
|
||||
border-radius: 14px;
|
||||
background: rgba(10, 17, 30, 0.48);
|
||||
margin-bottom: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.root :global(.city-group:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.root :global(.city-group-header) {
|
||||
width: 100%;
|
||||
border: none;
|
||||
background: rgba(15, 28, 47, 0.56);
|
||||
color: var(--text-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 7px 10px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: background 150ms ease;
|
||||
}
|
||||
|
||||
.root :global(.city-group-header:hover) {
|
||||
background: rgba(18, 34, 55, 0.82);
|
||||
}
|
||||
|
||||
.root :global(.city-group-title) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.4px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.root :global(.city-group-indicator) {
|
||||
width: 3px;
|
||||
height: 14px;
|
||||
border-radius: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.root :global(.city-group-indicator.high) {
|
||||
background: var(--risk-high);
|
||||
box-shadow: 0 0 6px rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
.root :global(.city-group-indicator.medium) {
|
||||
background: var(--risk-medium);
|
||||
box-shadow: 0 0 6px rgba(245, 158, 11, 0.3);
|
||||
}
|
||||
.root :global(.city-group-indicator.low) {
|
||||
background: var(--risk-low);
|
||||
box-shadow: 0 0 6px rgba(34, 197, 94, 0.3);
|
||||
}
|
||||
.root :global(.city-group-indicator.other) {
|
||||
background: var(--text-muted);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.root :global(.city-group-meta) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.root :global(.city-group-count) {
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 9px;
|
||||
padding: 0 6px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(123, 97, 255, 0.26);
|
||||
color: var(--text-primary);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.root :global(.city-group-arrow) {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
transform: rotate(-90deg);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.root :global(.city-group-arrow.expanded) {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
.root :global(.city-group-items) {
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.root :global(.city-group.collapsed .city-group-items) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.root :global(.city-item) {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font-family: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
.root :global(.city-item:hover) {
|
||||
background: rgba(10, 26, 44, 0.84);
|
||||
border-color: rgba(0, 224, 164, 0.18);
|
||||
box-shadow: inset 0 0 0 1px rgba(0, 224, 164, 0.03);
|
||||
}
|
||||
.root :global(.city-item.active) {
|
||||
background:
|
||||
linear-gradient(135deg, rgba(0, 224, 164, 0.12), rgba(123, 97, 255, 0.1)),
|
||||
rgba(9, 18, 32, 0.92);
|
||||
border-color: rgba(0, 224, 164, 0.24);
|
||||
box-shadow:
|
||||
0 0 20px rgba(0, 224, 164, 0.08),
|
||||
inset 0 0 0 1px rgba(0, 224, 164, 0.08);
|
||||
}
|
||||
|
||||
.root :global(.city-item-main) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.root :global(.city-item .city-name-text) {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.root :global(.city-item-info) {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-left: 20px; /* Align with name text, after the dot */
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.root :global(.city-item .city-max-info) {
|
||||
color: var(--accent-blue);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.root :global(.city-item .city-deviation-info) {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.root :global(.city-item .city-deviation-cold) {
|
||||
color: var(--color-accent-primary);
|
||||
}
|
||||
|
||||
.root :global(.city-item .city-deviation-hot) {
|
||||
color: var(--color-signal-warning);
|
||||
}
|
||||
|
||||
.root :global(.city-item .city-deviation-normal) {
|
||||
color: #22d3ee;
|
||||
}
|
||||
|
||||
.root :global(.city-item .city-deviation-info.strong) {
|
||||
text-shadow: 0 0 10px rgba(56, 189, 248, 0.18);
|
||||
}
|
||||
|
||||
.root :global(.city-item .city-deviation-hot.strong) {
|
||||
text-shadow: 0 0 10px rgba(245, 158, 11, 0.24);
|
||||
}
|
||||
|
||||
.root :global(.city-item .risk-dot) {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.root :global(.city-item .risk-dot.high) {
|
||||
background: var(--risk-high);
|
||||
box-shadow: 0 0 6px var(--risk-high);
|
||||
}
|
||||
.root :global(.city-item .risk-dot.medium) {
|
||||
background: var(--risk-medium);
|
||||
box-shadow: 0 0 6px var(--risk-medium);
|
||||
}
|
||||
.root :global(.city-item .risk-dot.low) {
|
||||
background: var(--risk-low);
|
||||
box-shadow: 0 0 6px var(--risk-low);
|
||||
}
|
||||
|
||||
.root :global(.city-clock-icon) {
|
||||
display: inline-block;
|
||||
vertical-align: -1px;
|
||||
margin-right: 3px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.root :global(.city-item .city-temp) {
|
||||
margin-left: auto;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--accent-cyan);
|
||||
opacity: 0;
|
||||
transition: opacity 200ms ease;
|
||||
}
|
||||
.root :global(.city-item .city-temp.loaded) {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* ── Responsive ── */
|
||||
@media (max-width: 1024px) {
|
||||
.root {
|
||||
--panel-width: 460px;
|
||||
}
|
||||
.root :global(.hero-value) {
|
||||
font-size: 52px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.root :global(.header) {
|
||||
height: auto;
|
||||
min-height: var(--header-height);
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.root :global(.brand) {
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.root :global(.header-right) {
|
||||
flex: 1 1 auto;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.root :global(.lang-switch) {
|
||||
order: 1;
|
||||
}
|
||||
|
||||
.root :global(.account-btn),
|
||||
.root :global(.info-btn) {
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.root :global(.live-badge) {
|
||||
order: 4;
|
||||
padding: 4px 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.root :global(.refresh-btn) {
|
||||
order: 5;
|
||||
}
|
||||
|
||||
.root :global(.city-list) {
|
||||
display: flex;
|
||||
top: auto;
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
bottom: 8px;
|
||||
width: auto;
|
||||
max-height: min(36vh, 320px);
|
||||
border-radius: 14px;
|
||||
z-index: 920;
|
||||
}
|
||||
.root :global(.detail-panel) {
|
||||
width: 100%;
|
||||
}
|
||||
.root {
|
||||
--panel-width: 100%;
|
||||
}
|
||||
|
||||
.root :global(.city-list-header) {
|
||||
padding: 12px 14px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.root :global(.city-list-items) {
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.root :global(.city-group-header) {
|
||||
padding: 8px 9px;
|
||||
}
|
||||
|
||||
.root :global(.city-item) {
|
||||
padding: 8px 9px;
|
||||
}
|
||||
|
||||
.root :global(.city-item .city-name-text),
|
||||
.root :global(.city-item .city-temp) {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.root :global(.city-item-info) {
|
||||
padding-left: 18px;
|
||||
flex-wrap: wrap;
|
||||
row-gap: 4px;
|
||||
}
|
||||
|
||||
.root :global(.panel-header) {
|
||||
padding: 16px 16px 14px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.root :global(.header) {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.root :global(.brand) {
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.root :global(.subtitle) {
|
||||
display: none;
|
||||
}
|
||||
.root :global(.brand h1) {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.root :global(.header-right) {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.root :global(.lang-switch) {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.root :global(.account-btn),
|
||||
.root :global(.info-btn) {
|
||||
min-height: 34px;
|
||||
padding: 6px 9px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.root :global(.account-btn span),
|
||||
.root :global(.info-btn span) {
|
||||
max-width: 88px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.root :global(.live-badge) {
|
||||
padding: 4px 8px;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.6px;
|
||||
}
|
||||
|
||||
.root :global(.refresh-btn) {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.root :global(.hero-value) {
|
||||
font-size: 42px;
|
||||
}
|
||||
.root :global(.hero-details) {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 4px;
|
||||
}
|
||||
.root :global(.hero-item .value) {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.root :global(.city-list) {
|
||||
left: 6px;
|
||||
right: 6px;
|
||||
bottom: 6px;
|
||||
max-height: min(40vh, 300px);
|
||||
}
|
||||
|
||||
.root :global(.city-list-header) {
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.root :global(.city-group-title) {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.root :global(.city-item-main) {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.root :global(.city-item-info) {
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.root :global(.panel-header) {
|
||||
padding: 14px 14px 12px;
|
||||
}
|
||||
|
||||
.root :global(.city-marker:hover) {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.root :global(.marker-bubble) {
|
||||
min-width: 48px;
|
||||
min-height: 32px;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
.root :global(.marker-name) {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.root :global(.prob-price-grid) {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.root :global(.prob-price-head) {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
@@ -4,148 +4,101 @@ import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
export function DashboardShellSkeleton() {
|
||||
return (
|
||||
<div className="flex h-screen w-screen overflow-hidden bg-[#e9edf3] font-sans text-slate-900">
|
||||
{/* Dark Sidebar */}
|
||||
<aside className="w-[52px] shrink-0 bg-[#171d24] flex flex-col items-center gap-4 py-4">
|
||||
{/* Logo Placeholder */}
|
||||
<div className="w-8 h-8 rounded bg-blue-600/30 animate-pulse" />
|
||||
{/* Menu Icon */}
|
||||
<div className="w-6 h-6 rounded bg-slate-700/50" />
|
||||
{/* Navigation Items */}
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="w-8 h-8 rounded bg-slate-700/20" />
|
||||
))}
|
||||
</aside>
|
||||
<div
|
||||
style={{
|
||||
background:
|
||||
"radial-gradient(circle at top, rgba(30,41,59,0.45), rgba(2,6,23,0.98) 55%)",
|
||||
height: "100vh",
|
||||
overflow: "hidden",
|
||||
position: "relative",
|
||||
width: "100vw",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
alignItems: "center",
|
||||
backdropFilter: "blur(16px)",
|
||||
background: "rgba(10,14,26,0.78)",
|
||||
borderBottom: "1px solid rgba(99,102,241,0.15)",
|
||||
display: "flex",
|
||||
height: 56,
|
||||
justifyContent: "space-between",
|
||||
left: 0,
|
||||
padding: "0 24px",
|
||||
position: "fixed",
|
||||
right: 0,
|
||||
top: 0,
|
||||
zIndex: 20,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
<Skeleton className="h-6 w-40 bg-zinc-700/60" />
|
||||
<Skeleton className="h-3 w-28 bg-zinc-800/70" />
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 10 }}>
|
||||
<Skeleton className="h-8 w-20 rounded-full bg-zinc-800/70" />
|
||||
<Skeleton className="h-8 w-28 rounded-full bg-zinc-800/70" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content Area */}
|
||||
<div className="flex flex-1 flex-col min-w-0">
|
||||
{/* Dark Top Header */}
|
||||
<header className="h-[64px] shrink-0 bg-[#171d24] flex items-center justify-between px-4">
|
||||
<div className="flex items-center gap-4 w-1/3">
|
||||
{/* Search Box Skeleton */}
|
||||
<div className="h-10 w-full max-w-[320px] rounded bg-slate-700/40" />
|
||||
{/* Title Skeleton */}
|
||||
<div className="h-4 w-32 rounded bg-slate-700/30 hidden md:block" />
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Clock & Profile Skeletons */}
|
||||
<div className="h-4 w-16 rounded bg-slate-700/30 font-mono" />
|
||||
<div className="h-9 w-20 rounded bg-slate-700/40" />
|
||||
<div className="h-9 w-9 rounded-full bg-slate-700/40" />
|
||||
</div>
|
||||
</header>
|
||||
<div
|
||||
style={{
|
||||
bottom: 24,
|
||||
display: "flex",
|
||||
gap: 24,
|
||||
left: 24,
|
||||
position: "absolute",
|
||||
right: 24,
|
||||
top: 80,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 14,
|
||||
maxWidth: 280,
|
||||
width: "22vw",
|
||||
}}
|
||||
>
|
||||
<Skeleton className="h-10 w-40 rounded-xl bg-zinc-800/80" />
|
||||
{Array.from({ length: 6 }).map((_, index) => (
|
||||
<Skeleton
|
||||
key={index}
|
||||
className="h-14 w-full rounded-2xl bg-zinc-900/75"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Workspace Body Skeletons (3 Columns) */}
|
||||
<main className="flex-1 overflow-hidden p-2 grid grid-cols-1 gap-2 xl:grid-cols-[1.12fr_1.6fr_1.1fr]">
|
||||
{/* Column 1 */}
|
||||
<div className="flex flex-col gap-2 min-h-0">
|
||||
{/* Card 1 */}
|
||||
<div className="flex-1 rounded border border-[#cfd6df] bg-white p-4 flex flex-col gap-3">
|
||||
<div className="flex justify-between items-center border-b border-slate-100 pb-2">
|
||||
<Skeleton className="h-4 w-28 bg-slate-200" />
|
||||
<div className="w-4 h-4 rounded bg-slate-100" />
|
||||
</div>
|
||||
<Skeleton className="h-12 w-full bg-slate-100/80 rounded animate-pulse" />
|
||||
<div className="flex-1 flex flex-col gap-2 mt-2">
|
||||
{Array.from({ length: 6 }).map((_, idx) => (
|
||||
<div key={idx} className="flex justify-between items-center">
|
||||
<Skeleton className="h-4 w-32 bg-slate-100" />
|
||||
<Skeleton className="h-4 w-12 bg-slate-100" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* Card 2 */}
|
||||
<div className="h-1/3 rounded border border-[#cfd6df] bg-white p-4 flex flex-col gap-3">
|
||||
<div className="flex justify-between items-center border-b border-slate-100 pb-2">
|
||||
<Skeleton className="h-4 w-32 bg-slate-200" />
|
||||
<div className="w-4 h-4 rounded bg-slate-100" />
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col gap-2">
|
||||
{Array.from({ length: 3 }).map((_, idx) => (
|
||||
<Skeleton key={idx} className="h-8 w-full bg-slate-100" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ flex: 1, position: "relative" }}>
|
||||
<Skeleton className="h-full w-full rounded-[28px] bg-zinc-950/55" />
|
||||
{Array.from({ length: 8 }).map((_, index) => (
|
||||
<Skeleton
|
||||
key={index}
|
||||
className="absolute rounded-full bg-cyan-500/20"
|
||||
style={{
|
||||
height: 18,
|
||||
left: `${10 + index * 10}%`,
|
||||
top: `${20 + ((index * 9) % 45)}%`,
|
||||
width: 18,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Column 2 */}
|
||||
<div className="flex flex-col gap-2 min-h-0">
|
||||
{/* Card 1 */}
|
||||
<div className="h-[200px] rounded border border-[#cfd6df] bg-white p-4 flex flex-col gap-3">
|
||||
<div className="flex justify-between items-center border-b border-slate-100 pb-2">
|
||||
<Skeleton className="h-4 w-40 bg-slate-200" />
|
||||
<div className="w-4 h-4 rounded bg-slate-100" />
|
||||
</div>
|
||||
<div className="flex items-center gap-4 mt-2">
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-6 w-32 bg-slate-200" />
|
||||
<Skeleton className="h-3 w-full bg-slate-100" />
|
||||
<Skeleton className="h-3 w-2/3 bg-slate-100" />
|
||||
</div>
|
||||
<Skeleton className="w-32 h-16 bg-slate-100 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
{/* Card 2 */}
|
||||
<div className="h-[120px] rounded border border-[#cfd6df] bg-white p-4 flex flex-col justify-between">
|
||||
<Skeleton className="h-4 w-32 bg-slate-200" />
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<Skeleton className="h-10 w-full bg-slate-100" />
|
||||
<Skeleton className="h-10 w-full bg-slate-100" />
|
||||
<Skeleton className="h-10 w-full bg-slate-100" />
|
||||
</div>
|
||||
</div>
|
||||
{/* Card 3 */}
|
||||
<div className="flex-1 rounded border border-[#cfd6df] bg-white p-4 flex flex-col gap-3">
|
||||
<div className="flex justify-between items-center border-b border-slate-100 pb-2">
|
||||
<Skeleton className="h-4 w-28 bg-slate-200" />
|
||||
<div className="w-4 h-4 rounded bg-slate-100" />
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col gap-2">
|
||||
{Array.from({ length: 5 }).map((_, idx) => (
|
||||
<Skeleton key={idx} className="h-10 w-full bg-slate-100" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Column 3 */}
|
||||
<div className="flex flex-col gap-2 min-h-0">
|
||||
{/* Card 1 */}
|
||||
<div className="flex-1 rounded border border-[#cfd6df] bg-white p-4 flex flex-col gap-3">
|
||||
<div className="flex justify-between items-center border-b border-slate-100 pb-2">
|
||||
<Skeleton className="h-4 w-24 bg-slate-200" />
|
||||
<div className="w-4 h-4 rounded bg-slate-100" />
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col gap-3">
|
||||
{Array.from({ length: 6 }).map((_, idx) => (
|
||||
<Skeleton key={idx} className="h-10 w-full bg-slate-100" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* Card 2 */}
|
||||
<div className="h-1/3 rounded border border-[#cfd6df] bg-white p-4 flex flex-col justify-between">
|
||||
<div className="flex justify-between items-center border-b border-slate-100 pb-2">
|
||||
<Skeleton className="h-4 w-36 bg-slate-200" />
|
||||
<div className="w-4 h-4 rounded bg-slate-100" />
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div className="h-12 bg-slate-100 rounded flex flex-col justify-center items-center gap-1">
|
||||
<Skeleton className="h-3 w-6 bg-slate-200" />
|
||||
<Skeleton className="h-2 w-8 bg-slate-200/50" />
|
||||
</div>
|
||||
<div className="h-12 bg-slate-100 rounded flex flex-col justify-center items-center gap-1">
|
||||
<Skeleton className="h-3 w-6 bg-slate-200" />
|
||||
<Skeleton className="h-2 w-8 bg-slate-200/50" />
|
||||
</div>
|
||||
<div className="h-12 bg-slate-100 rounded flex flex-col justify-center items-center gap-1">
|
||||
<Skeleton className="h-3 w-6 bg-slate-200" />
|
||||
<Skeleton className="h-2 w-8 bg-slate-200/50" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 14,
|
||||
maxWidth: 420,
|
||||
width: "30vw",
|
||||
}}
|
||||
>
|
||||
<Skeleton className="h-16 w-full rounded-3xl bg-zinc-900/80" />
|
||||
<Skeleton className="h-48 w-full rounded-3xl bg-zinc-900/70" />
|
||||
<Skeleton className="h-32 w-full rounded-3xl bg-zinc-900/70" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,472 @@
|
||||
"use client";
|
||||
|
||||
import clsx from "clsx";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { ForecastTable } from "@/components/dashboard/PanelSections";
|
||||
import {
|
||||
useDashboardModal,
|
||||
useDashboardStore,
|
||||
useProAccess,
|
||||
} from "@/hooks/useDashboardStore";
|
||||
import { useI18n } from "@/hooks/useI18n";
|
||||
import { useRelativeTime } from "@/hooks/useRelativeTime";
|
||||
import { getOfficialSourceLinks } from "@/lib/dashboard-official-sources";
|
||||
import { trackAppEvent } from "@/lib/app-analytics";
|
||||
import { getTodayPolymarketUrl } from "@/lib/polymarket-market-links";
|
||||
import { getCityProfileStats } from "@/lib/dashboard-utils";
|
||||
import { normalizeObservationSourceLabel } from "@/lib/source-labels";
|
||||
import { getRiskBadgeLabel } from "@/lib/weather-summary-utils";
|
||||
|
||||
const DetailMiniTemperatureChart = dynamic(
|
||||
() =>
|
||||
import("@/components/dashboard/DetailMiniTemperatureChart").then(
|
||||
(module) => module.DetailMiniTemperatureChart,
|
||||
),
|
||||
{
|
||||
loading: () => <div className="detail-mini-chart detail-mini-chart-loading" />,
|
||||
ssr: false,
|
||||
},
|
||||
);
|
||||
|
||||
export function DetailPanel({
|
||||
variant = "overlay",
|
||||
}: {
|
||||
variant?: "overlay" | "rail";
|
||||
} = {}) {
|
||||
const store = useDashboardStore();
|
||||
const modal = useDashboardModal();
|
||||
const { proAccess } = useProAccess();
|
||||
const { locale, t } = useI18n();
|
||||
const router = useRouter();
|
||||
const isRail = variant === "rail";
|
||||
const detail = store.selectedDetail;
|
||||
const selectedCityItem = useMemo(
|
||||
() =>
|
||||
store.selectedCity
|
||||
? store.cities.find((city) => city.name === store.selectedCity) || null
|
||||
: null,
|
||||
[store.cities, store.selectedCity],
|
||||
);
|
||||
const selectedSummary = useMemo(
|
||||
() =>
|
||||
store.selectedCity
|
||||
? store.citySummariesByName[store.selectedCity] || null
|
||||
: null,
|
||||
[store.citySummariesByName, store.selectedCity],
|
||||
);
|
||||
const isPro = proAccess.subscriptionActive;
|
||||
const isAuthenticated = proAccess.authenticated;
|
||||
const panelRef = useRef<HTMLElement | null>(null);
|
||||
const lastDetailFetchedAtRef = useRef<number>(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (detail?.name) {
|
||||
lastDetailFetchedAtRef.current = Date.now();
|
||||
}
|
||||
}, [detail?.name]);
|
||||
|
||||
const detailAgeText = useRelativeTime(
|
||||
lastDetailFetchedAtRef.current
|
||||
? new Date(lastDetailFetchedAtRef.current).toISOString()
|
||||
: null,
|
||||
);
|
||||
const [heavyContentReady, setHeavyContentReady] = useState(false);
|
||||
const isOverlayOpen =
|
||||
Boolean(modal.futureModalDate);
|
||||
const isVisible = isRail
|
||||
? Boolean(store.selectedCity) && !isOverlayOpen
|
||||
: store.isPanelOpen && Boolean(store.selectedCity) && !isOverlayOpen;
|
||||
const hasBasicPanelContent = Boolean(
|
||||
detail || selectedSummary || selectedCityItem,
|
||||
);
|
||||
const panelDisplayName =
|
||||
detail?.display_name ||
|
||||
selectedSummary?.display_name ||
|
||||
selectedCityItem?.display_name ||
|
||||
store.selectedCity ||
|
||||
"...";
|
||||
const panelRiskLevel =
|
||||
detail?.risk?.level ||
|
||||
selectedSummary?.risk?.level ||
|
||||
selectedCityItem?.risk_level ||
|
||||
"low";
|
||||
const profileStats = useMemo(
|
||||
() => (detail ? getCityProfileStats(detail, locale) : []),
|
||||
[detail, locale],
|
||||
);
|
||||
const officialLinks = useMemo(
|
||||
() => (detail ? getOfficialSourceLinks(detail) : []),
|
||||
[detail],
|
||||
);
|
||||
const marketUrl = useMemo(
|
||||
() => getTodayPolymarketUrl(detail, locale),
|
||||
[detail, locale],
|
||||
);
|
||||
const basicSettlementLabel = normalizeObservationSourceLabel(
|
||||
selectedSummary?.current?.settlement_source_label ||
|
||||
selectedCityItem?.settlement_source_label ||
|
||||
selectedCityItem?.settlement_source,
|
||||
locale === "en-US" ? "Settlement source pending" : "结算口径待确认",
|
||||
);
|
||||
const basicAirportLabel =
|
||||
selectedCityItem?.airport ||
|
||||
selectedSummary?.icao ||
|
||||
(locale === "en-US" ? "Airport pending" : "机场待确认");
|
||||
const heroSettlementLabel = normalizeObservationSourceLabel(
|
||||
detail?.current?.settlement_source_label,
|
||||
basicSettlementLabel,
|
||||
);
|
||||
const heroAirportLabel = detail?.risk?.airport || basicAirportLabel;
|
||||
const isSparsePanelDetail = Boolean(
|
||||
detail &&
|
||||
(detail.detail_depth !== "full" ||
|
||||
(detail.forecast?.daily?.length ?? 0) <= 1),
|
||||
);
|
||||
const isPanelSyncing = store.loadingState.cityDetail;
|
||||
const [panelSyncTimedOut, setPanelSyncTimedOut] = useState(false);
|
||||
const showPanelSyncing = isPanelSyncing && !panelSyncTimedOut;
|
||||
const isShowingCachedDetailDuringSync = false;
|
||||
|
||||
const blurActiveElement = () => {
|
||||
if (typeof document === "undefined") return;
|
||||
const active = document.activeElement;
|
||||
if (active instanceof HTMLElement) {
|
||||
active.blur();
|
||||
}
|
||||
};
|
||||
|
||||
const handleTodayAccess = () => {
|
||||
blurActiveElement();
|
||||
|
||||
if (isPro) {
|
||||
void modal.openTodayModal();
|
||||
return;
|
||||
}
|
||||
|
||||
trackAppEvent("paywall_feature_clicked", {
|
||||
entry: "detail_panel",
|
||||
feature: "today",
|
||||
city: store.selectedCity,
|
||||
user_state: isAuthenticated ? "logged_in" : "guest",
|
||||
});
|
||||
router.push("/account");
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPanelSyncing || !store.selectedCity) {
|
||||
setPanelSyncTimedOut(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setPanelSyncTimedOut(false);
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
setPanelSyncTimedOut(true);
|
||||
}, 12_000);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timeoutId);
|
||||
};
|
||||
}, [isPanelSyncing, store.selectedCity]);
|
||||
|
||||
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",
|
||||
isRail && "scan-city-detail-rail",
|
||||
(isVisible || isRail) && "visible",
|
||||
)}
|
||||
>
|
||||
<div className="panel-header">
|
||||
{!isRail ? (
|
||||
<button
|
||||
type="button"
|
||||
className="panel-close"
|
||||
aria-label={t("detail.closeAria")}
|
||||
onClick={() => {
|
||||
blurActiveElement();
|
||||
store.closePanel();
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
) : null}
|
||||
<div className="panel-title-area">
|
||||
<div className="panel-title-stack">
|
||||
<div className="panel-overline">
|
||||
<span>{locale === "en-US" ? "City briefing" : "城市简报"}</span>
|
||||
<span className="panel-overline-sep">•</span>
|
||||
<span>{panelDisplayName.toUpperCase()}</span>
|
||||
</div>
|
||||
<h2>{panelDisplayName}</h2>
|
||||
</div>
|
||||
{showPanelSyncing && (
|
||||
<div
|
||||
className="panel-loading-hint"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<span className="panel-loading-spinner" aria-hidden="true" />
|
||||
<span>
|
||||
{locale === "en-US"
|
||||
? isSparsePanelDetail
|
||||
? `Completing ${panelDisplayName} cards...`
|
||||
: `Syncing ${panelDisplayName}...`
|
||||
: isSparsePanelDetail
|
||||
? `正在补齐 ${panelDisplayName} 卡片...`
|
||||
: `正在同步 ${panelDisplayName}...`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{panelSyncTimedOut && (
|
||||
<div className="panel-loading-hint timed-out" role="alert">
|
||||
<span>
|
||||
{locale === "en-US"
|
||||
? "Sync is taking longer than expected. Data shown may be incomplete."
|
||||
: "同步时间较长,当前展示的数据可能不完整。"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="panel-meta">
|
||||
<span className={clsx("risk-badge", panelRiskLevel)}>
|
||||
{getRiskBadgeLabel(panelRiskLevel, locale)}
|
||||
</span>
|
||||
{detailAgeText ? (
|
||||
<span
|
||||
className="panel-meta-chip panel-meta-chip-muted"
|
||||
title={
|
||||
locale === "en-US"
|
||||
? "Time since last data refresh"
|
||||
: "上次数据更新时间"
|
||||
}
|
||||
>
|
||||
{locale === "en-US" ? "Updated " : "更新于 "}
|
||||
{detailAgeText}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="panel-meta-chip panel-meta-chip-strong">
|
||||
{heroSettlementLabel}
|
||||
</span>
|
||||
<span className="panel-meta-chip panel-meta-chip-muted">
|
||||
{heroAirportLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div className="panel-actions">
|
||||
{marketUrl ? (
|
||||
<a
|
||||
className="panel-action-button panel-action-button-ghost"
|
||||
href={marketUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title={
|
||||
locale === "en-US"
|
||||
? "Open today's Polymarket market"
|
||||
: "打开今日 Polymarket 题目页"
|
||||
}
|
||||
>
|
||||
{locale === "en-US" ? "Market" : "市场页"}
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={clsx(
|
||||
"panel-body",
|
||||
isShowingCachedDetailDuringSync && "is-syncing",
|
||||
)}
|
||||
>
|
||||
{isShowingCachedDetailDuringSync ? (
|
||||
<div className="panel-sync-blocker" role="status" aria-live="polite">
|
||||
<span className="panel-loading-spinner" aria-hidden="true" />
|
||||
<span>
|
||||
{locale === "en-US"
|
||||
? `Refreshing ${panelDisplayName}. Current cards are paused until the latest data arrives.`
|
||||
: `正在刷新 ${panelDisplayName},最新数据返回前暂停展示旧卡片。`}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
<div
|
||||
className={clsx(
|
||||
isShowingCachedDetailDuringSync && "panel-content-stale",
|
||||
)}
|
||||
>
|
||||
{!hasBasicPanelContent ? (
|
||||
<section className="detail-summary-shell detail-empty-state">
|
||||
<div className="detail-section-head">
|
||||
<div>
|
||||
<div className="detail-section-kicker">
|
||||
{locale === "en-US" ? "No city selected" : "尚未选择城市"}
|
||||
</div>
|
||||
<h3>
|
||||
{locale === "en-US"
|
||||
? "Pick a city to start."
|
||||
: "先选择一个城市。"}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ color: "var(--text-muted)", fontSize: "13px" }}>
|
||||
{showPanelSyncing
|
||||
? t("detail.loading")
|
||||
: t("detail.emptyHint")}
|
||||
</div>
|
||||
</section>
|
||||
) : !detail ? (
|
||||
<div className="detail-mini-meta" role="status" aria-live="polite">
|
||||
{showPanelSyncing
|
||||
? locale === "en-US"
|
||||
? "Loading city cards..."
|
||||
: "正在加载城市卡片..."
|
||||
: locale === "en-US"
|
||||
? "City cards will appear here."
|
||||
: "城市卡片会显示在这里。"}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<section className="detail-structured-section">
|
||||
<div className="detail-section-head">
|
||||
<div>
|
||||
<div className="detail-section-kicker">
|
||||
{locale === "en-US" ? "Profile" : "城市画像"}
|
||||
</div>
|
||||
<h3>{t("detail.profile")}</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div className="detail-grid">
|
||||
{profileStats.map((item) => (
|
||||
<article key={item.label} className="detail-card">
|
||||
<span className="detail-label">{item.label}</span>
|
||||
<span className="detail-value">{item.value}</span>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{officialLinks.length > 0 ? (
|
||||
<section className="detail-structured-section">
|
||||
<div className="detail-section-head">
|
||||
<div>
|
||||
<div className="detail-section-kicker">
|
||||
{locale === "en-US" ? "Primary references" : "官方参考"}
|
||||
</div>
|
||||
<h3>
|
||||
{locale === "en-US"
|
||||
? "Settlement and observation references"
|
||||
: "结算与观测参考来源"}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
<p className="detail-source-note">
|
||||
{locale === "en-US"
|
||||
? "AGENCY = national meteorological service, METAR = airport observation, AIRPORT = airport official page."
|
||||
: "AGENCY = 国家气象机构,METAR = 机场实测报文,AIRPORT = 机场官网页面。"}
|
||||
</p>
|
||||
<div className="detail-source-list">
|
||||
{officialLinks.map((link) => (
|
||||
<a
|
||||
key={`${link.label}-${link.href}`}
|
||||
className="detail-source-link"
|
||||
href={link.href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<span className="detail-source-kind">
|
||||
{link.kind.toUpperCase()}
|
||||
</span>
|
||||
<span className="detail-source-label">
|
||||
{link.label}
|
||||
</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="detail-structured-section rounded-2xl">
|
||||
<div className="detail-section-head">
|
||||
<div>
|
||||
<div className="detail-section-kicker">
|
||||
{locale === "en-US" ? "Mini trend" : "温度微趋势"}
|
||||
</div>
|
||||
<h3>{t("detail.todayMiniTrend")}</h3>
|
||||
</div>
|
||||
</div>
|
||||
{heavyContentReady ? (
|
||||
<DetailMiniTemperatureChart detail={detail} />
|
||||
) : (
|
||||
<div className="detail-mini-meta">{t("detail.loading")}</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{heavyContentReady ? <ForecastTable /> : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
.root :global(.panel-title-stack) {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.root :global(.panel-overline) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--text-muted);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.root :global(.panel-overline-sep) {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.root :global(.panel-subtitle) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.root :global(.panel-weather-chip) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
color: var(--text-secondary);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.root :global(.panel-meta-chip) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 28px;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: var(--text-secondary);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.root :global(.panel-meta-chip-strong) {
|
||||
color: var(--text-primary);
|
||||
background: rgba(34, 211, 238, 0.1);
|
||||
border-color: rgba(34, 211, 238, 0.2);
|
||||
}
|
||||
|
||||
.root :global(.panel-meta-chip-muted) {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.root :global(.panel-actions) {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.root :global(.panel-action-button) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 38px;
|
||||
padding: 0 14px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.01em;
|
||||
transition: background 0.18s ease, border-color 0.18s ease, transform 0.18s ease, color 0.18s ease;
|
||||
}
|
||||
|
||||
.root :global(.panel-action-button:hover) {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.root :global(.panel-action-button-primary) {
|
||||
color: #FFFFFF;
|
||||
background: linear-gradient(135deg, var(--color-accent-primary), #3B82F6);
|
||||
border-color: rgba(77, 163, 255, 0.34);
|
||||
}
|
||||
|
||||
.root :global(.panel-action-button-secondary) {
|
||||
color: var(--text-primary);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.root :global(.panel-action-button-ghost) {
|
||||
color: var(--accent-cyan);
|
||||
background: rgba(34, 211, 238, 0.08);
|
||||
border-color: rgba(34, 211, 238, 0.16);
|
||||
}
|
||||
|
||||
.root :global(.detail-summary-shell) {
|
||||
background: linear-gradient(180deg, rgba(255,255,255,0.035), rgba(255,255,255,0.018));
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 18px;
|
||||
padding: 16px;
|
||||
box-shadow: 0 18px 42px rgba(2, 6, 23, 0.18);
|
||||
}
|
||||
|
||||
.root :global(.panel-body section.detail-summary-shell) {
|
||||
padding: 16px;
|
||||
margin: 18px 0 0;
|
||||
}
|
||||
|
||||
.root :global(.detail-summary-shell-live) {
|
||||
background: linear-gradient(180deg, rgba(22, 78, 99, 0.18), rgba(15, 23, 42, 0.3));
|
||||
}
|
||||
|
||||
.root :global(.detail-section-head) {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.root :global(.detail-section-kicker) {
|
||||
display: inline-block;
|
||||
margin-bottom: 6px;
|
||||
color: var(--accent-cyan);
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.root :global(.detail-summary-main) {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.root :global(.detail-summary-hero) {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.root :global(.detail-summary-temp) {
|
||||
color: var(--text-primary);
|
||||
font-size: 38px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.04em;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.root :global(.detail-summary-supporting) {
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.root :global(.detail-summary-grid) {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.root :global(.detail-card) {
|
||||
background: rgba(255, 255, 255, 0.035);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 14px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.root :global(.detail-card-hero) {
|
||||
background: rgba(34, 211, 238, 0.08);
|
||||
border-color: rgba(34, 211, 238, 0.16);
|
||||
}
|
||||
|
||||
.root :global(.detail-card-wide) {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.root :global(.detail-callout-card) {
|
||||
background: rgba(255, 255, 255, 0.025);
|
||||
}
|
||||
|
||||
.root :global(.detail-value-muted) {
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.root :global(.detail-structured-section) {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
border-radius: 18px;
|
||||
padding: 16px 16px 18px;
|
||||
}
|
||||
|
||||
.root :global(.detail-empty-state) {
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.root :global(.panel-loading-hint) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 10px;
|
||||
background: rgba(77, 163, 255, 0.06);
|
||||
border: 1px solid rgba(77, 163, 255, 0.1);
|
||||
color: var(--color-accent-secondary);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.root :global(.panel-loading-hint.timed-out) {
|
||||
background: rgba(245, 158, 11, 0.06);
|
||||
border-color: rgba(245, 158, 11, 0.16);
|
||||
color: var(--color-signal-warning);
|
||||
font-size: 12px;
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
/* Detail panel content styles. */
|
||||
|
||||
.root :global(.detail-grid) {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.root :global(.detail-card) {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.root :global(.detail-source-list) {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.root :global(.detail-source-note) {
|
||||
margin: 8px 0 12px;
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.root :global(.detail-source-link) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
color: var(--text-main);
|
||||
text-decoration: none;
|
||||
transition:
|
||||
background 0.18s ease,
|
||||
border-color 0.18s ease,
|
||||
transform 0.18s ease;
|
||||
}
|
||||
|
||||
.root :global(.detail-source-link:hover) {
|
||||
background: rgba(34, 211, 238, 0.08);
|
||||
border-color: rgba(34, 211, 238, 0.28);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.root :global(.detail-source-kind) {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--accent-cyan);
|
||||
text-transform: uppercase;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.root :global(.detail-source-label) {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-main);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.root :global(.detail-label) {
|
||||
display: block;
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.root :global(.detail-value) {
|
||||
display: block;
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
line-height: 1.5;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.root :global(.profile-lead) {
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.root :global(.detail-mini-chart-wrap) {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.root :global(.detail-mini-chart) {
|
||||
position: relative;
|
||||
height: 210px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.root :global(.scan-city-detail-rail .detail-mini-chart) {
|
||||
height: 230px;
|
||||
}
|
||||
|
||||
.root :global(.detail-mini-chart canvas) {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.root :global(.detail-mini-chart-legend) {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 12px;
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.root :global(.detail-mini-chart-legend span) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.root :global(.detail-mini-chart-legend i) {
|
||||
width: 16px;
|
||||
height: 3px;
|
||||
border-radius: 999px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.root :global(.detail-mini-chart-legend i.forecast) {
|
||||
background: rgba(100, 116, 139, 0.72);
|
||||
}
|
||||
|
||||
.root :global(.detail-mini-chart-legend i.forecast.calibrated) {
|
||||
background: #38bdf8;
|
||||
}
|
||||
|
||||
.root :global(.detail-mini-chart-legend i.observation) {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
background: #22c55e;
|
||||
}
|
||||
|
||||
.root :global(.detail-mini-meta) {
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.root :global(.insight-list) {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.root :global(.insight-item) {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.root :global(.insight-title) {
|
||||
color: var(--accent-cyan);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.root :global(.insight-text) {
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.root :global(.detail-scenery-card) {
|
||||
position: relative;
|
||||
min-height: 210px;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border-subtle);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(15, 23, 42, 0.18), rgba(15, 23, 42, 0.78)),
|
||||
rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.root :global(.detail-scenery-image) {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.root :global(.detail-scenery-overlay) {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
min-height: 210px;
|
||||
padding: 18px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(2, 6, 23, 0.08) 0%,
|
||||
rgba(2, 6, 23, 0.86) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.root :global(.detail-scenery-copy),
|
||||
.root :global(.detail-scenery-fallback) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.root :global(.detail-scenery-fallback) {
|
||||
min-height: 210px;
|
||||
padding: 18px;
|
||||
justify-content: flex-end;
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at top left,
|
||||
rgba(34, 211, 238, 0.12),
|
||||
transparent 34%
|
||||
),
|
||||
linear-gradient(180deg, rgba(15, 23, 42, 0.92), rgba(2, 6, 23, 0.96));
|
||||
}
|
||||
|
||||
.root :global(.detail-scenery-kicker) {
|
||||
color: rgba(226, 232, 240, 0.78);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.root :global(.detail-scenery-title) {
|
||||
color: #fff;
|
||||
font-size: 22px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.03em;
|
||||
text-shadow: 0 6px 22px rgba(2, 6, 23, 0.42);
|
||||
}
|
||||
|
||||
.root :global(.detail-scenery-subtitle) {
|
||||
max-width: 320px;
|
||||
color: rgba(226, 232, 240, 0.9);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
text-shadow: 0 2px 10px rgba(2, 6, 23, 0.45);
|
||||
}
|
||||
|
||||
.root :global(.detail-scenery-credit) {
|
||||
align-self: flex-start;
|
||||
color: rgba(226, 232, 240, 0.9);
|
||||
font-size: 11px;
|
||||
text-decoration: none;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
background: rgba(2, 6, 23, 0.35);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.root :global(.detail-scenery-credit:hover) {
|
||||
border-color: rgba(34, 211, 238, 0.4);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.root :global(.detail-grid) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.root :global(.detail-mini-chart) {
|
||||
height: 170px;
|
||||
}
|
||||
|
||||
.root :global(.detail-source-link) {
|
||||
padding: 10px 12px;
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.root :global(.detail-source-label) {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,80 @@
|
||||
"use client";
|
||||
|
||||
import type { useDashboardStore } from "@/hooks/useDashboardStore";
|
||||
import type { useI18n } from "@/hooks/useI18n";
|
||||
import type { getFutureModalView } from "@/lib/dashboard-utils";
|
||||
import {
|
||||
FutureModelForecastPanel,
|
||||
FutureProbabilityPanel,
|
||||
FutureTemperaturePathChart,
|
||||
} from "./FutureForecastModalPanels";
|
||||
|
||||
type DashboardDetail = NonNullable<
|
||||
ReturnType<typeof useDashboardStore>["selectedDetail"]
|
||||
>;
|
||||
type FutureModalView = ReturnType<typeof getFutureModalView>;
|
||||
type TranslationFn = ReturnType<typeof useI18n>["t"];
|
||||
|
||||
export function FutureForecastForwardView({
|
||||
dateStr,
|
||||
detail,
|
||||
t,
|
||||
view,
|
||||
}: {
|
||||
dateStr: string;
|
||||
detail: DashboardDetail;
|
||||
t: TranslationFn;
|
||||
view: FutureModalView;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div className="future-forward-stats">
|
||||
<div className="future-forward-stat-card">
|
||||
<span className="label">{t("future.targetForecast")}</span>
|
||||
<span className="val">
|
||||
{view.forecastEntry?.max_temp ?? "--"}
|
||||
{detail.temp_symbol}
|
||||
</span>
|
||||
</div>
|
||||
<div className="future-forward-stat-card">
|
||||
<span className="label">{t("future.deb")}</span>
|
||||
<span className="val">
|
||||
{view.deb ?? "--"}
|
||||
{detail.temp_symbol}
|
||||
</span>
|
||||
</div>
|
||||
<div className="future-forward-stat-card">
|
||||
<span className="label">{t("future.mu")}</span>
|
||||
<span className="val">
|
||||
{view.mu != null
|
||||
? `${view.mu.toFixed(1)}${detail.temp_symbol}`
|
||||
: "--"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="future-forward-stat-card">
|
||||
<span className="label">{t("future.score")}</span>
|
||||
<span className="val">
|
||||
{view.front.score > 0 ? "+" : ""}
|
||||
{view.front.score}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="future-modal-section">
|
||||
<h3>{t("future.targetTempTrend")}</h3>
|
||||
<FutureTemperaturePathChart dateStr={dateStr} forceToday={false} />
|
||||
</section>
|
||||
|
||||
<div className="future-modal-grid">
|
||||
<section className="future-modal-section">
|
||||
<h3>{t("future.probability")}</h3>
|
||||
<FutureProbabilityPanel detail={detail} targetDate={dateStr} hideTitle />
|
||||
</section>
|
||||
<section className="future-modal-section">
|
||||
<h3>{t("future.models")}</h3>
|
||||
<FutureModelForecastPanel detail={detail} targetDate={dateStr} hideTitle />
|
||||
</section>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
useDashboardModal,
|
||||
useDashboardSelection,
|
||||
useProAccess,
|
||||
} from "@/hooks/useDashboardStore";
|
||||
import { useI18n } from "@/hooks/useI18n";
|
||||
import { FutureForecastModalContent } from "./FutureForecastModalContent";
|
||||
|
||||
export function FutureForecastModal() {
|
||||
const modal = useDashboardModal();
|
||||
const selection = useDashboardSelection();
|
||||
const proAccess = useProAccess();
|
||||
const { locale, t } = useI18n();
|
||||
const detail = selection.selectedDetail;
|
||||
const dateStr = modal.futureModalDate;
|
||||
|
||||
if (!detail || !dateStr) return null;
|
||||
|
||||
return (
|
||||
<FutureForecastModalContent
|
||||
modal={modal}
|
||||
proAccess={proAccess.proAccess}
|
||||
locale={locale}
|
||||
t={t}
|
||||
detail={detail}
|
||||
dateStr={dateStr}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
import type { IntradayMeteorologySignal } from "@/lib/dashboard-types";
|
||||
|
||||
export const TODAY_MARKET_SCAN_AUTO_REFRESH_MS = 5 * 60 * 1000;
|
||||
|
||||
export function normalizeMarketValue(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));
|
||||
}
|
||||
|
||||
export function formatMinuteAxisLabel(value: number) {
|
||||
if (!Number.isFinite(value)) return "";
|
||||
const total = Math.max(0, Math.round(value));
|
||||
const hour = Math.floor(total / 60) % 24;
|
||||
const minute = total % 60;
|
||||
return `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export function formatMarketPercent(value?: number | null) {
|
||||
const normalized = normalizeMarketValue(value);
|
||||
if (normalized == null) return "--";
|
||||
return `${(normalized * 100).toFixed(1)}%`;
|
||||
}
|
||||
|
||||
export function formatBucketLabel(
|
||||
bucket?: {
|
||||
label?: string | null;
|
||||
bucket?: string | null;
|
||||
range?: string | null;
|
||||
value?: number | null;
|
||||
temp?: number | null;
|
||||
} | null,
|
||||
) {
|
||||
if (!bucket) return "--";
|
||||
const direct =
|
||||
String(bucket.label || "").trim() ||
|
||||
String(bucket.bucket || "").trim() ||
|
||||
String(bucket.range || "").trim();
|
||||
if (direct) {
|
||||
let str = direct.toUpperCase().replace(/\s+/g, "");
|
||||
str = str.replace(/°?C($|\+|-)/g, "℃$1");
|
||||
if (!str.includes("℃") && /[0-9]/.test(str)) {
|
||||
str += "℃";
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
const temp = Number(bucket.value ?? bucket.temp);
|
||||
if (Number.isFinite(temp)) {
|
||||
return `${Math.round(temp)}℃`;
|
||||
}
|
||||
return "--";
|
||||
}
|
||||
|
||||
export function parseBucketBoundaries(
|
||||
bucket?: {
|
||||
label?: string | null;
|
||||
bucket?: string | null;
|
||||
range?: string | null;
|
||||
value?: number | null;
|
||||
temp?: number | null;
|
||||
} | null,
|
||||
) {
|
||||
if (!bucket) return null;
|
||||
const raw =
|
||||
String(bucket.label || "").trim() ||
|
||||
String(bucket.bucket || "").trim() ||
|
||||
String(bucket.range || "").trim();
|
||||
if (!raw) return null;
|
||||
const numbers = Array.from(raw.matchAll(/-?\d+(?:\.\d+)?/g)).map((match) =>
|
||||
Number(match[0]),
|
||||
);
|
||||
if (!numbers.length) return null;
|
||||
if (raw.includes("+")) {
|
||||
return {
|
||||
lower: numbers[0] ?? null,
|
||||
upper: null as number | null,
|
||||
boundaryLabel: `${numbers[0]}°C`,
|
||||
};
|
||||
}
|
||||
if (numbers.length >= 2) {
|
||||
return {
|
||||
lower: numbers[0],
|
||||
upper: numbers[1],
|
||||
boundaryLabel: null as string | null,
|
||||
};
|
||||
}
|
||||
return {
|
||||
lower: numbers[0],
|
||||
upper: null as number | null,
|
||||
boundaryLabel: `${numbers[0]}°C`,
|
||||
};
|
||||
}
|
||||
|
||||
export function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
export function parseClockMinutes(value?: string | null) {
|
||||
const text = String(value || "").trim();
|
||||
const match = text.match(/^(\d{1,2}):(\d{2})$/);
|
||||
if (!match) return null;
|
||||
const hours = Number(match[1]);
|
||||
const minutes = Number(match[2]);
|
||||
if (!Number.isFinite(hours) || !Number.isFinite(minutes)) return null;
|
||||
return hours * 60 + minutes;
|
||||
}
|
||||
|
||||
export function parseLeadingNumber(value?: string | number | null) {
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
const text = String(value || "").trim();
|
||||
const match = text.match(/[-+]?\d+(?:\.\d+)?/);
|
||||
if (!match) return null;
|
||||
const numeric = Number(match[0]);
|
||||
return Number.isFinite(numeric) ? numeric : null;
|
||||
}
|
||||
|
||||
export function parsePercentFromText(value?: string | number | null) {
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return clamp(value, 0, 100);
|
||||
}
|
||||
const text = String(value || "").trim();
|
||||
const percentMatch = text.match(/([-+]?\d+(?:\.\d+)?)\s*%/);
|
||||
if (percentMatch) {
|
||||
const numeric = Number(percentMatch[1]);
|
||||
return Number.isFinite(numeric) ? clamp(numeric, 0, 100) : null;
|
||||
}
|
||||
return parseLeadingNumber(text);
|
||||
}
|
||||
|
||||
export function formatConfidenceLabel(value?: string | null, locale = "zh-CN") {
|
||||
const normalized = String(value || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (normalized === "high") return locale === "en-US" ? "High" : "高";
|
||||
if (normalized === "medium") return locale === "en-US" ? "Medium" : "中";
|
||||
if (normalized === "low") return locale === "en-US" ? "Low" : "低";
|
||||
return locale === "en-US" ? "Pending" : "待确认";
|
||||
}
|
||||
|
||||
export function formatSignalDirection(value?: string | null, locale = "zh-CN") {
|
||||
const normalized = String(value || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (normalized === "support")
|
||||
return locale === "en-US" ? "Support" : "支持升温";
|
||||
if (normalized === "suppress")
|
||||
return locale === "en-US" ? "Suppress" : "压制峰值";
|
||||
return locale === "en-US" ? "Neutral" : "中性";
|
||||
}
|
||||
|
||||
export function formatSignalStrength(value?: string | null, locale = "zh-CN") {
|
||||
const normalized = String(value || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (normalized === "strong") return locale === "en-US" ? "Strong" : "强";
|
||||
if (normalized === "medium") return locale === "en-US" ? "Medium" : "中";
|
||||
return locale === "en-US" ? "Weak" : "弱";
|
||||
}
|
||||
|
||||
export function signalTone(signal?: IntradayMeteorologySignal | null) {
|
||||
const direction = String(signal?.direction || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (direction === "support") return "cyan";
|
||||
if (direction === "suppress") return "amber";
|
||||
return "blue";
|
||||
}
|
||||
|
||||
export function localizedText(
|
||||
locale: string,
|
||||
primary?: string | null,
|
||||
english?: string | null,
|
||||
) {
|
||||
const en = String(english || "").trim();
|
||||
const value = String(primary || "").trim();
|
||||
if (locale === "en-US" && en) return en;
|
||||
return value || en;
|
||||
}
|
||||
|
||||
export function localizedList(
|
||||
locale: string,
|
||||
primary?: string[] | null,
|
||||
english?: string[] | null,
|
||||
) {
|
||||
const en = Array.isArray(english)
|
||||
? english.filter((item) => String(item || "").trim())
|
||||
: [];
|
||||
const value = Array.isArray(primary)
|
||||
? primary.filter((item) => String(item || "").trim())
|
||||
: [];
|
||||
if (locale === "en-US" && en.length) return en;
|
||||
return value.length ? value : en;
|
||||
}
|
||||
|
||||
export function getTrendMetricVisual(metric: {
|
||||
label?: string;
|
||||
value?: string;
|
||||
tone?: string;
|
||||
}) {
|
||||
const label = String(metric.label || "").toLowerCase();
|
||||
const value = String(metric.value || "");
|
||||
const numeric = parseLeadingNumber(value);
|
||||
|
||||
if (label.includes("降水") || label.includes("precip")) {
|
||||
const precipPercent = parsePercentFromText(value);
|
||||
if (precipPercent == null) return null;
|
||||
return {
|
||||
mode: "fill" as const,
|
||||
percent: precipPercent,
|
||||
tone: "cold" as const,
|
||||
};
|
||||
}
|
||||
|
||||
if (numeric == null) return null;
|
||||
|
||||
if (label.includes("温度") || label.includes("temp")) {
|
||||
return {
|
||||
mode: "center" as const,
|
||||
percent: clamp(50 + (numeric / 4) * 50, 0, 100),
|
||||
tone: numeric >= 0 ? ("warm" as const) : ("cold" as const),
|
||||
};
|
||||
}
|
||||
|
||||
if (label.includes("露点") || label.includes("dew")) {
|
||||
return {
|
||||
mode: "center" as const,
|
||||
percent: clamp(50 + (numeric / 3) * 50, 0, 100),
|
||||
tone: numeric >= 0 ? ("warm" as const) : ("cold" as const),
|
||||
};
|
||||
}
|
||||
|
||||
if (label.includes("气压") || label.includes("pressure")) {
|
||||
return {
|
||||
mode: "center" as const,
|
||||
percent: clamp(50 + (numeric / 4) * 50, 0, 100),
|
||||
tone: numeric >= 0 ? ("warm" as const) : ("cold" as const),
|
||||
};
|
||||
}
|
||||
|
||||
if (label.includes("云量") || label.includes("cloud")) {
|
||||
return {
|
||||
mode: "center" as const,
|
||||
percent: clamp(50 + (numeric / 40) * 50, 0, 100),
|
||||
tone: numeric >= 0 ? ("cold" as const) : ("warm" as const),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
"use client";
|
||||
|
||||
import type { ChartConfiguration } from "chart.js";
|
||||
import { useMemo } from "react";
|
||||
import { useChart } from "@/hooks/useChart";
|
||||
import { useDashboardSelection } from "@/hooks/useDashboardStore";
|
||||
import { useI18n } from "@/hooks/useI18n";
|
||||
import { getTemperatureChartData } from "@/lib/chart-utils";
|
||||
import { getFutureModalView } from "@/lib/dashboard-utils";
|
||||
import { formatMinuteAxisLabel } from "./FutureForecastModal.utils";
|
||||
|
||||
export function DailyTemperatureChart({
|
||||
dateStr,
|
||||
forceToday = false,
|
||||
}: {
|
||||
dateStr: string;
|
||||
forceToday?: boolean;
|
||||
}) {
|
||||
const { selectedDetail: detail } = useDashboardSelection();
|
||||
const { locale, t } = useI18n();
|
||||
const view = detail ? getFutureModalView(detail, dateStr, locale) : null;
|
||||
const isToday =
|
||||
forceToday || (detail ? dateStr === detail.local_date : false);
|
||||
const todayChartData = useMemo(
|
||||
() => (detail && isToday ? getTemperatureChartData(detail, locale) : null),
|
||||
[detail, isToday, locale],
|
||||
);
|
||||
|
||||
const canvasRef = useChart(() => {
|
||||
if (!detail || !view) {
|
||||
return {
|
||||
data: { datasets: [], labels: [] },
|
||||
type: "line",
|
||||
} satisfies ChartConfiguration<"line">;
|
||||
}
|
||||
|
||||
if (isToday && todayChartData) {
|
||||
const datasets: NonNullable<
|
||||
ChartConfiguration<"line">["data"]
|
||||
>["datasets"] = [];
|
||||
|
||||
datasets.push({
|
||||
borderColor: "rgba(100, 116, 139, 0.72)",
|
||||
borderDash: [6, 4],
|
||||
borderWidth: 1.6,
|
||||
data: todayChartData.datasets.debSeries,
|
||||
fill: false,
|
||||
label: locale === "en-US" ? "DEB baseline" : "DEB 原始路径",
|
||||
parsing: false,
|
||||
pointRadius: 0,
|
||||
tension: 0.1,
|
||||
});
|
||||
|
||||
if (todayChartData.datasets.calibratedFutureSeries.length > 0) {
|
||||
datasets.push({
|
||||
borderColor: "#38bdf8",
|
||||
borderWidth: 2.4,
|
||||
data: todayChartData.datasets.calibratedFutureSeries,
|
||||
fill: false,
|
||||
label:
|
||||
locale === "en-US"
|
||||
? "METAR-calibrated path"
|
||||
: "METAR 修正路径",
|
||||
parsing: false,
|
||||
pointHoverRadius: 5,
|
||||
pointRadius: 0,
|
||||
tension: 0.12,
|
||||
});
|
||||
}
|
||||
|
||||
if (todayChartData.datasets.hasMgmHourly) {
|
||||
datasets.push({
|
||||
backgroundColor: "rgba(234, 179, 8, 0.05)",
|
||||
borderColor: "rgba(234, 179, 8, 0.8)",
|
||||
borderDash: [3, 3],
|
||||
borderWidth: 1.2,
|
||||
data: todayChartData.datasets.mgmHourlySeries,
|
||||
fill: false,
|
||||
label: locale === "en-US" ? "MGM Forecast" : "MGM 预测",
|
||||
parsing: false,
|
||||
pointHoverRadius: 6,
|
||||
pointRadius: 0,
|
||||
spanGaps: true,
|
||||
tension: 0.1,
|
||||
});
|
||||
}
|
||||
|
||||
datasets.push({
|
||||
backgroundColor: "#22c55e",
|
||||
borderColor: "#22c55e",
|
||||
borderWidth: 0,
|
||||
data: todayChartData.datasets.metarSeries,
|
||||
fill: false,
|
||||
label:
|
||||
todayChartData.observationLabel ||
|
||||
(locale === "en-US" ? "Observation" : "观测实况"),
|
||||
order: 0,
|
||||
parsing: false,
|
||||
pointHoverRadius: 7,
|
||||
pointRadius: 5,
|
||||
showLine: false,
|
||||
});
|
||||
|
||||
if (todayChartData.datasets.airportMetarSeries?.length > 0) {
|
||||
datasets.push({
|
||||
backgroundColor: "#86efac",
|
||||
borderColor: "#86efac",
|
||||
borderWidth: 1,
|
||||
data: todayChartData.datasets.airportMetarSeries,
|
||||
fill: false,
|
||||
label: locale === "en-US" ? "Airport METAR" : "机场 METAR",
|
||||
order: 0,
|
||||
parsing: false,
|
||||
pointHoverRadius: 6,
|
||||
pointRadius: 4,
|
||||
showLine: false,
|
||||
});
|
||||
}
|
||||
|
||||
if (todayChartData.datasets.mgmSeries?.length > 0) {
|
||||
datasets.push({
|
||||
backgroundColor: "#facc15",
|
||||
borderColor: "#facc15",
|
||||
borderWidth: 0,
|
||||
data: todayChartData.datasets.mgmSeries,
|
||||
fill: false,
|
||||
label: locale === "en-US" ? "MGM Observation" : "MGM 实测",
|
||||
order: -1,
|
||||
parsing: false,
|
||||
pointHoverRadius: 9,
|
||||
pointRadius: 7,
|
||||
showLine: false,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
!todayChartData.datasets.hasMgmHourly &&
|
||||
Math.abs(todayChartData.datasets.offset) > 0.3
|
||||
) {
|
||||
datasets.push({
|
||||
borderColor: "rgba(77, 163, 255, 0.22)",
|
||||
borderDash: [2, 4],
|
||||
borderWidth: 1,
|
||||
data: todayChartData.datasets.tempsSeries,
|
||||
fill: false,
|
||||
label: locale === "en-US" ? "OM Raw" : "OM 原始",
|
||||
parsing: false,
|
||||
pointRadius: 0,
|
||||
tension: 0.1,
|
||||
});
|
||||
}
|
||||
if ((todayChartData.tafMarkers || []).length > 0) {
|
||||
datasets.push({
|
||||
backgroundColor: "#f59e0b",
|
||||
borderColor: "#f59e0b",
|
||||
borderWidth: 0,
|
||||
data: todayChartData.datasets.tafCurrentMarkerSeries,
|
||||
fill: false,
|
||||
label: locale === "en-US" ? "Current TAF" : "当前 TAF",
|
||||
order: -3,
|
||||
parsing: false,
|
||||
pointHoverRadius: 8,
|
||||
pointRadius: 6,
|
||||
showLine: false,
|
||||
});
|
||||
datasets.push({
|
||||
backgroundColor: "rgba(250, 204, 21, 0.72)",
|
||||
borderColor: "rgba(250, 204, 21, 0.72)",
|
||||
borderWidth: 0,
|
||||
data: todayChartData.datasets.tafPeakWindowMarkerSeries,
|
||||
fill: false,
|
||||
label: locale === "en-US" ? "Peak-window TAF" : "峰值窗口 TAF",
|
||||
order: -2,
|
||||
parsing: false,
|
||||
pointHoverRadius: 7,
|
||||
pointRadius: 4,
|
||||
showLine: false,
|
||||
});
|
||||
datasets.push({
|
||||
backgroundColor: "#f59e0b",
|
||||
borderColor: "#f59e0b",
|
||||
borderWidth: 0,
|
||||
data: todayChartData.datasets.tafMarkerSeries,
|
||||
fill: false,
|
||||
label: locale === "en-US" ? "TAF Timing" : "TAF 时段",
|
||||
order: -4,
|
||||
parsing: false,
|
||||
pointHoverRadius: 0,
|
||||
pointRadius: 0,
|
||||
showLine: false,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
data: {
|
||||
datasets,
|
||||
labels: [],
|
||||
},
|
||||
options: {
|
||||
interaction: { intersect: false, mode: "nearest" },
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
labels: {
|
||||
color: "#9FB2C7",
|
||||
filter: (legendItem, chartData) => {
|
||||
const text = String(legendItem.text || "");
|
||||
if (!text) return false;
|
||||
if (text === "TAF Timing" || text === "TAF 时段")
|
||||
return false;
|
||||
if (!text.includes("DEB")) return true;
|
||||
|
||||
const firstDebIndex = (chartData.datasets || []).findIndex(
|
||||
(dataset) => String(dataset.label || "").includes("DEB"),
|
||||
);
|
||||
return legendItem.datasetIndex === firstDebIndex;
|
||||
},
|
||||
font: { family: "Inter", size: 11 },
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: "rgba(15, 23, 42, 0.96)",
|
||||
borderColor: "rgba(77, 163, 255, 0.24)",
|
||||
borderWidth: 1,
|
||||
callbacks: {
|
||||
title: (items) => {
|
||||
const rawX = items?.[0]?.parsed?.x;
|
||||
return rawX != null
|
||||
? formatMinuteAxisLabel(Number(rawX))
|
||||
: "";
|
||||
},
|
||||
label: (ctx) => {
|
||||
const label = String(ctx.dataset.label || "");
|
||||
const raw = ctx.raw as
|
||||
| {
|
||||
marker?: {
|
||||
summary?: string;
|
||||
markerType?: string;
|
||||
displayType?: string;
|
||||
isCurrent?: boolean;
|
||||
isPeakWindow?: boolean;
|
||||
};
|
||||
}
|
||||
| undefined;
|
||||
if (
|
||||
label === "TAF Timing" ||
|
||||
label === "TAF 时段" ||
|
||||
label === "Current TAF" ||
|
||||
label === "当前 TAF" ||
|
||||
label === "Peak-window TAF" ||
|
||||
label === "峰值窗口 TAF"
|
||||
) {
|
||||
const marker = raw?.marker;
|
||||
if (!marker) return label;
|
||||
const markerType = String(marker.markerType || "");
|
||||
const displayType = String(
|
||||
marker.displayType || marker.markerType || "",
|
||||
);
|
||||
const summary = String(marker.summary || "");
|
||||
const prefix =
|
||||
marker.isCurrent && marker.isPeakWindow
|
||||
? locale === "en-US"
|
||||
? "Current / peak-window TAF"
|
||||
: "当前 / 峰值窗口 TAF"
|
||||
: marker.isCurrent
|
||||
? locale === "en-US"
|
||||
? "Current TAF"
|
||||
: "当前 TAF"
|
||||
: marker.isPeakWindow
|
||||
? locale === "en-US"
|
||||
? "Peak-window TAF"
|
||||
: "峰值窗口 TAF"
|
||||
: label;
|
||||
return `${prefix}: ${
|
||||
markerType
|
||||
? summary.replace(markerType, displayType)
|
||||
: summary
|
||||
}`;
|
||||
}
|
||||
const value = ctx.parsed.y;
|
||||
if (value == null) return label;
|
||||
return `${label}: ${value.toFixed(1)}${detail.temp_symbol || "°C"}`;
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responsive: true,
|
||||
scales: {
|
||||
x: {
|
||||
max: todayChartData.xMax,
|
||||
min: todayChartData.xMin,
|
||||
grid: { color: "rgba(255,255,255,0.04)" },
|
||||
type: "linear",
|
||||
ticks: {
|
||||
callback: (value) => {
|
||||
const num = Number(value);
|
||||
if (!Number.isFinite(num)) return "";
|
||||
const minutes = Math.round(num);
|
||||
if (
|
||||
minutes !== todayChartData.xMin &&
|
||||
minutes !== todayChartData.xMax &&
|
||||
minutes % 120 !== 0
|
||||
) {
|
||||
return "";
|
||||
}
|
||||
return formatMinuteAxisLabel(minutes);
|
||||
},
|
||||
color: "#6B7A90",
|
||||
font: { family: "Inter", size: 10 },
|
||||
maxRotation: 0,
|
||||
},
|
||||
},
|
||||
y: {
|
||||
grid: { color: "rgba(255,255,255,0.04)" },
|
||||
max: todayChartData.max,
|
||||
min: todayChartData.min,
|
||||
ticks: {
|
||||
callback: (value) =>
|
||||
`${Number(value).toFixed(todayChartData.yTickStep < 1 ? 1 : 0)}${detail.temp_symbol || "°C"}`,
|
||||
color: "#6B7A90",
|
||||
font: { family: "Inter", size: 10 },
|
||||
stepSize: todayChartData.yTickStep,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
type: "line",
|
||||
} satisfies ChartConfiguration<"line">;
|
||||
}
|
||||
|
||||
const labels = view.slice.map((point) => point.label);
|
||||
const unit = detail.temp_symbol || "°C";
|
||||
|
||||
return {
|
||||
data: {
|
||||
datasets: [
|
||||
{
|
||||
backgroundColor: "rgba(77, 163, 255, 0.08)",
|
||||
borderColor: "#4DA3FF",
|
||||
data: view.slice.map((point) => point.temp),
|
||||
fill: false,
|
||||
label:
|
||||
locale === "en-US" ? "Open-Meteo Temperature" : "Open-Meteo 温度",
|
||||
pointRadius: 2,
|
||||
tension: 0.1,
|
||||
},
|
||||
{
|
||||
backgroundColor: "transparent",
|
||||
borderColor: "#93C5FD",
|
||||
borderDash: [5, 4],
|
||||
data: view.slice.map((point) => point.dewPoint),
|
||||
fill: false,
|
||||
label: locale === "en-US" ? "Dew Point" : "露点",
|
||||
pointRadius: 0,
|
||||
tension: 0.1,
|
||||
},
|
||||
],
|
||||
labels,
|
||||
},
|
||||
options: {
|
||||
interaction: { intersect: false, mode: "index" },
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
labels: {
|
||||
color: "#9FB2C7",
|
||||
font: { family: "Inter", size: 11 },
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: "rgba(15, 23, 42, 0.96)",
|
||||
borderColor: "rgba(77, 163, 255, 0.24)",
|
||||
borderWidth: 1,
|
||||
callbacks: {
|
||||
label: (ctx) =>
|
||||
`${ctx.dataset.label}: ${ctx.parsed.y?.toFixed(1)}${unit}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
responsive: true,
|
||||
scales: {
|
||||
x: {
|
||||
grid: { color: "rgba(255,255,255,0.04)" },
|
||||
ticks: {
|
||||
color: "#6B7A90",
|
||||
font: { family: "Inter", size: 10 },
|
||||
maxRotation: 0,
|
||||
},
|
||||
},
|
||||
y: {
|
||||
grid: { color: "rgba(255,255,255,0.04)" },
|
||||
ticks: {
|
||||
callback: (value) => `${value}${unit}`,
|
||||
color: "#6B7A90",
|
||||
font: { family: "Inter", size: 10 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
type: "line",
|
||||
} satisfies ChartConfiguration<"line">;
|
||||
}, [detail, isToday, locale, todayChartData, view]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="history-chart-wrapper future-chart-wrapper">
|
||||
<canvas ref={canvasRef} />
|
||||
</div>
|
||||
{isToday && (
|
||||
<div className="chart-legend">
|
||||
{todayChartData?.legendText || t("future.chartLegendEmpty")}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,999 @@
|
||||
"use client";
|
||||
|
||||
import clsx from "clsx";
|
||||
import { CSSProperties, useEffect, useMemo, useState } from "react";
|
||||
import type { Locale } from "@/lib/i18n";
|
||||
import { ProFeaturePaywall } from "@/components/dashboard/ProFeaturePaywall";
|
||||
import { getFutureModalView } from "@/lib/dashboard-utils";
|
||||
import { getModelView, getProbabilityView } from "@/lib/model-utils";
|
||||
import { getTodayPaceView } from "@/lib/pace-utils";
|
||||
import { dashboardClient } from "@/lib/dashboard-client";
|
||||
import { getWeatherSummary } from "@/lib/weather-summary-utils";
|
||||
import {
|
||||
normalizeObservationSourceCode,
|
||||
normalizeObservationSourceLabel,
|
||||
} from "@/lib/source-labels";
|
||||
import type {
|
||||
CityDetail,
|
||||
ForecastModalMode,
|
||||
LoadingState,
|
||||
MarketScan,
|
||||
ProAccessState,
|
||||
} from "@/lib/dashboard-types";
|
||||
import { FutureForecastForwardView } from "./FutureForecastForwardView";
|
||||
import { FutureForecastTodayDecisionBrief } from "./FutureForecastTodayDecisionBrief";
|
||||
import { FutureForecastTodayLayout } from "./FutureForecastTodayLayout";
|
||||
import { FutureForecastModalHeader } from "./FutureForecastModalHeader";
|
||||
import {
|
||||
FutureRefreshLock,
|
||||
FutureSyncStatusStrip,
|
||||
type FutureSyncStatusItem,
|
||||
} from "./FutureForecastModalStatus";
|
||||
import { type FuturePaceSignalItem } from "./FutureForecastTodayCards";
|
||||
import {
|
||||
TODAY_MARKET_SCAN_AUTO_REFRESH_MS,
|
||||
clamp,
|
||||
formatBucketLabel,
|
||||
formatMarketPercent,
|
||||
getTrendMetricVisual,
|
||||
localizedList,
|
||||
localizedText,
|
||||
parseBucketBoundaries,
|
||||
parseClockMinutes,
|
||||
parseLeadingNumber,
|
||||
} from "./FutureForecastModal.utils";
|
||||
|
||||
type FutureForecastModalControls = {
|
||||
closeFutureModal: () => void;
|
||||
forecastModalMode: ForecastModalMode | null;
|
||||
futureModalDate: string | null;
|
||||
loadingState: LoadingState;
|
||||
openFutureModal: (dateStr: string, forceRefresh?: boolean) => Promise<void>;
|
||||
openTodayModal: (forceRefresh?: boolean) => Promise<void>;
|
||||
};
|
||||
|
||||
export function FutureForecastModalContent({
|
||||
modal,
|
||||
proAccess,
|
||||
locale,
|
||||
t,
|
||||
detail,
|
||||
dateStr,
|
||||
}: {
|
||||
modal: FutureForecastModalControls;
|
||||
proAccess: ProAccessState;
|
||||
locale: Locale;
|
||||
t: (key: string, params?: Record<string, string | number>) => string;
|
||||
detail: CityDetail;
|
||||
dateStr: string;
|
||||
}) {
|
||||
const isPro = proAccess.subscriptionActive;
|
||||
const isProLoading = proAccess.loading;
|
||||
const hasModalContext = true;
|
||||
const [showDeferredTodaySections, setShowDeferredTodaySections] =
|
||||
useState(false);
|
||||
const [freshMarketScan, setFreshMarketScan] = useState<MarketScan | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasModalContext) {
|
||||
setShowDeferredTodaySections(false);
|
||||
return;
|
||||
}
|
||||
setShowDeferredTodaySections(false);
|
||||
if (typeof window === "undefined") {
|
||||
setShowDeferredTodaySections(true);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
let idleId: number | null = null;
|
||||
const reveal = () => {
|
||||
if (!cancelled) {
|
||||
setShowDeferredTodaySections(true);
|
||||
}
|
||||
};
|
||||
|
||||
if ("requestIdleCallback" in window) {
|
||||
idleId = window.requestIdleCallback(reveal, { timeout: 600 });
|
||||
} else {
|
||||
timeoutId = setTimeout(reveal, 120);
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (idleId != null && "cancelIdleCallback" in window) {
|
||||
window.cancelIdleCallback(idleId);
|
||||
}
|
||||
if (timeoutId != null) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
};
|
||||
}, [dateStr, detail, hasModalContext]);
|
||||
|
||||
const isToday =
|
||||
modal.forecastModalMode === "today" ||
|
||||
(modal.forecastModalMode == null && dateStr === detail?.local_date);
|
||||
const detailDepth = detail?.detail_depth || "full";
|
||||
const isFullDetailReady = detailDepth === "full";
|
||||
const isStructureSyncing =
|
||||
modal.loadingState.futureDeep || !isFullDetailReady;
|
||||
const isAnyLayerSyncing = isStructureSyncing;
|
||||
const isTodayBlockingRefresh = isToday && isStructureSyncing;
|
||||
const activeMarketScan = freshMarketScan || detail?.market_scan || null;
|
||||
|
||||
useEffect(() => {
|
||||
setFreshMarketScan(null);
|
||||
if (!hasModalContext || !isToday || !isFullDetailReady || !isPro) return;
|
||||
const cityName = String(detail?.name || detail?.display_name || "").trim();
|
||||
if (!cityName || !dateStr) return;
|
||||
|
||||
let cancelled = false;
|
||||
let intervalId: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const refreshMarketScan = () => {
|
||||
dashboardClient
|
||||
.getCityMarketScan(cityName, {
|
||||
force: false,
|
||||
lite: false,
|
||||
marketSlug: detail?.market_scan?.primary_market?.slug || null,
|
||||
targetDate: dateStr,
|
||||
})
|
||||
.then((payload) => {
|
||||
if (cancelled) return;
|
||||
setFreshMarketScan(payload.market_scan || null);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setFreshMarketScan(null);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
refreshMarketScan();
|
||||
intervalId = setInterval(() => {
|
||||
if (
|
||||
typeof document !== "undefined" &&
|
||||
document.visibilityState === "hidden"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
refreshMarketScan();
|
||||
}, TODAY_MARKET_SCAN_AUTO_REFRESH_MS);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (intervalId != null) {
|
||||
clearInterval(intervalId);
|
||||
}
|
||||
};
|
||||
}, [
|
||||
dateStr,
|
||||
detail?.display_name,
|
||||
detail?.local_date,
|
||||
detail?.market_scan?.primary_market?.slug,
|
||||
detail?.name,
|
||||
detail?.updated_at,
|
||||
hasModalContext,
|
||||
isFullDetailReady,
|
||||
isPro,
|
||||
isToday,
|
||||
]);
|
||||
|
||||
const view = getFutureModalView(detail, dateStr, locale);
|
||||
const scorePosition = `${50 + view.front.score / 2}%`;
|
||||
const barStyle = {
|
||||
"--score-position": scorePosition,
|
||||
} as CSSProperties & { "--score-position": string };
|
||||
const weatherSummary = getWeatherSummary(detail, locale);
|
||||
const paceView = useMemo(
|
||||
() =>
|
||||
isToday && showDeferredTodaySections
|
||||
? getTodayPaceView(detail, locale)
|
||||
: null,
|
||||
[detail, isToday, locale, showDeferredTodaySections],
|
||||
);
|
||||
const probabilityView = useMemo(
|
||||
() => getProbabilityView(detail, dateStr),
|
||||
[dateStr, detail],
|
||||
);
|
||||
const modelView = useMemo(
|
||||
() => getModelView(detail, dateStr),
|
||||
[dateStr, detail],
|
||||
);
|
||||
const probabilityEngineKey = String(probabilityView?.engine || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const probabilityCalibrationMode = String(
|
||||
probabilityView?.calibrationMode || "",
|
||||
)
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const hasLgbmProbability = useMemo(
|
||||
() =>
|
||||
Object.keys(modelView?.models || {}).some((name) =>
|
||||
String(name || "")
|
||||
.toLowerCase()
|
||||
.replace(/[\s_/-]/g, "")
|
||||
.includes("lgbm"),
|
||||
),
|
||||
[modelView],
|
||||
);
|
||||
const hasEmosProbability =
|
||||
probabilityEngineKey === "emos" ||
|
||||
probabilityCalibrationMode.includes("emos");
|
||||
const probabilityEngineLabel = hasLgbmProbability
|
||||
? locale === "en-US"
|
||||
? "LGBM"
|
||||
: "LGBM"
|
||||
: hasEmosProbability
|
||||
? "EMOS"
|
||||
: locale === "en-US"
|
||||
? "Calibrated model"
|
||||
: "校准模型";
|
||||
const probabilityTitle = hasLgbmProbability
|
||||
? locale === "en-US"
|
||||
? "LGBM-Calibrated Probability"
|
||||
: "LGBM 校准概率"
|
||||
: hasEmosProbability
|
||||
? locale === "en-US"
|
||||
? "EMOS-Calibrated Probability"
|
||||
: "EMOS 校准概率"
|
||||
: locale === "en-US"
|
||||
? "Calibrated Model Probability"
|
||||
: "校准模型概率";
|
||||
const topProbabilityBucket = useMemo(() => {
|
||||
const buckets = Array.isArray(probabilityView?.probabilities)
|
||||
? probabilityView.probabilities
|
||||
: [];
|
||||
return [...buckets]
|
||||
.filter((bucket) => Number.isFinite(Number(bucket?.probability)))
|
||||
.sort((a, b) => Number(b?.probability) - Number(a?.probability))[0];
|
||||
}, [probabilityView]);
|
||||
const modelSpreadView = useMemo(() => {
|
||||
const values = Object.values(modelView?.models || {})
|
||||
.map((value) => Number(value))
|
||||
.filter((value) => Number.isFinite(value));
|
||||
if (!values.length) return null;
|
||||
const min = Math.min(...values);
|
||||
const max = Math.max(...values);
|
||||
const spread = max - min;
|
||||
return {
|
||||
count: values.length,
|
||||
max,
|
||||
min,
|
||||
spread,
|
||||
};
|
||||
}, [modelView]);
|
||||
const boundaryRiskView = useMemo(() => {
|
||||
if (!showDeferredTodaySections) return null;
|
||||
if (!isToday || !paceView) return null;
|
||||
const selectedBucket = topProbabilityBucket || null;
|
||||
const bounds = parseBucketBoundaries(selectedBucket);
|
||||
if (!bounds) return null;
|
||||
const projected =
|
||||
paceView.paceAdjustedHigh ??
|
||||
(detail.deb?.prediction != null ? Number(detail.deb.prediction) : null);
|
||||
if (projected == null || !Number.isFinite(projected)) return null;
|
||||
|
||||
const distances = [bounds.lower, bounds.upper]
|
||||
.filter(
|
||||
(value): value is number => value != null && Number.isFinite(value),
|
||||
)
|
||||
.map((value) => ({
|
||||
boundary: value,
|
||||
gap: Math.abs(projected - value),
|
||||
}))
|
||||
.sort((a, b) => a.gap - b.gap);
|
||||
if (!distances.length) return null;
|
||||
|
||||
const nearest = distances[0];
|
||||
const tone =
|
||||
nearest.gap <= 0.4 ? "amber" : nearest.gap <= 0.8 ? "blue" : "cyan";
|
||||
const status =
|
||||
nearest.gap <= 0.4
|
||||
? locale === "en-US"
|
||||
? "High boundary risk"
|
||||
: "边界风险高"
|
||||
: nearest.gap <= 0.8
|
||||
? locale === "en-US"
|
||||
? "Watch boundary"
|
||||
: "边界需观察"
|
||||
: locale === "en-US"
|
||||
? "Boundary buffer"
|
||||
: "边界缓冲";
|
||||
const note =
|
||||
locale === "en-US"
|
||||
? `${projected.toFixed(1)}${detail.temp_symbol} is ${nearest.gap.toFixed(1)}${detail.temp_symbol} from the nearest boundary ${nearest.boundary.toFixed(1)}°C.`
|
||||
: `${projected.toFixed(1)}${detail.temp_symbol} 距最近边界 ${nearest.boundary.toFixed(1)}°C 还有 ${nearest.gap.toFixed(1)}${detail.temp_symbol}。`;
|
||||
return {
|
||||
label: locale === "en-US" ? "Boundary risk" : "边界风险",
|
||||
note,
|
||||
status,
|
||||
tone,
|
||||
value: `${nearest.gap.toFixed(1)}${detail.temp_symbol}`,
|
||||
};
|
||||
}, [
|
||||
detail.deb?.prediction,
|
||||
detail.temp_symbol,
|
||||
isToday,
|
||||
locale,
|
||||
paceView,
|
||||
showDeferredTodaySections,
|
||||
topProbabilityBucket,
|
||||
]);
|
||||
const peakWindowStateView = useMemo(() => {
|
||||
if (!showDeferredTodaySections) return null;
|
||||
if (!isToday || !paceView) return null;
|
||||
const firstHour = Number(detail.peak?.first_h);
|
||||
const lastHour = Number(detail.peak?.last_h);
|
||||
if (
|
||||
!Number.isFinite(firstHour) ||
|
||||
!Number.isFinite(lastHour) ||
|
||||
firstHour < 0 ||
|
||||
lastHour < firstHour
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const currentMinutes = parseClockMinutes(detail.local_time);
|
||||
const startMinutes = firstHour * 60;
|
||||
const endMinutes = (lastHour + 1) * 60;
|
||||
let status = locale === "en-US" ? "Awaiting peak" : "未进入峰值";
|
||||
let tone: "amber" | "blue" | "cyan" = "blue";
|
||||
if (currentMinutes != null && currentMinutes >= endMinutes) {
|
||||
status = locale === "en-US" ? "Past peak" : "已过峰值";
|
||||
tone = "cyan";
|
||||
} else if (currentMinutes != null && currentMinutes >= startMinutes) {
|
||||
status = locale === "en-US" ? "Peak window live" : "峰值窗口进行中";
|
||||
tone = "amber";
|
||||
}
|
||||
const note =
|
||||
locale === "en-US"
|
||||
? `Primary peak window ${paceView.peakWindowText}.`
|
||||
: `核心峰值窗口 ${paceView.peakWindowText}。`;
|
||||
return {
|
||||
label: locale === "en-US" ? "Peak window" : "峰值窗口状态",
|
||||
note,
|
||||
status,
|
||||
tone,
|
||||
value: paceView.peakWindowText,
|
||||
};
|
||||
}, [
|
||||
detail.local_time,
|
||||
detail.peak?.first_h,
|
||||
detail.peak?.last_h,
|
||||
isToday,
|
||||
locale,
|
||||
paceView,
|
||||
showDeferredTodaySections,
|
||||
]);
|
||||
const networkLeadView = useMemo(() => {
|
||||
if (!showDeferredTodaySections) return null;
|
||||
if (!isToday) return null;
|
||||
const delta = Number(detail.airport_vs_network_delta);
|
||||
const leadSignal = detail.network_lead_signal;
|
||||
if (!Number.isFinite(delta)) return null;
|
||||
const leaderLabel =
|
||||
String(leadSignal?.leader_station_label || "").trim() ||
|
||||
String(leadSignal?.leader_station_code || "").trim();
|
||||
const leaderSyncStatus = String(leadSignal?.leader_sync_status || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const leaderSyncDelta = Number(
|
||||
leadSignal?.leader_time_delta_vs_anchor_minutes,
|
||||
);
|
||||
const syncNote =
|
||||
leaderSyncStatus === "near_realtime" || leaderSyncStatus === "lagged"
|
||||
? Number.isFinite(leaderSyncDelta)
|
||||
? locale === "en-US"
|
||||
? ` Timing offset versus the airport anchor is about ${Math.round(leaderSyncDelta)} minutes.`
|
||||
: ` 与机场锚点存在约 ${Math.round(leaderSyncDelta)} 分钟时间差。`
|
||||
: locale === "en-US"
|
||||
? " Nearby observations are not fully synchronized."
|
||||
: " 周边观测并非完全同步。"
|
||||
: leaderSyncStatus === "unknown"
|
||||
? locale === "en-US"
|
||||
? " Nearby station timing is not fully verified."
|
||||
: " 周边站观测时间尚未完全校验。"
|
||||
: "";
|
||||
const absDelta = Math.abs(delta);
|
||||
const status =
|
||||
delta <= -0.4
|
||||
? locale === "en-US"
|
||||
? "Airport trailing"
|
||||
: "机场落后"
|
||||
: delta >= 0.4
|
||||
? locale === "en-US"
|
||||
? "Airport leading"
|
||||
: "机场领先"
|
||||
: locale === "en-US"
|
||||
? "Tracking network"
|
||||
: "与站网齐平";
|
||||
const tone = delta <= -0.4 ? "amber" : delta >= 0.4 ? "cyan" : "blue";
|
||||
const note =
|
||||
delta <= -0.4
|
||||
? locale === "en-US"
|
||||
? `Airport anchor is ${absDelta.toFixed(1)}${detail.temp_symbol} cooler than the nearby official network${leaderLabel ? `, led by ${leaderLabel}` : ""}.${syncNote}`
|
||||
: `机场主站当前比周边官方站网低 ${absDelta.toFixed(1)}${detail.temp_symbol}${leaderLabel ? `,领先点位是 ${leaderLabel}` : ""}。${syncNote}`
|
||||
: delta >= 0.4
|
||||
? locale === "en-US"
|
||||
? `Airport anchor is ${absDelta.toFixed(1)}${detail.temp_symbol} hotter than the nearby official network.${syncNote}`
|
||||
: `机场主站当前比周边官方站网高 ${absDelta.toFixed(1)}${detail.temp_symbol}。${syncNote}`
|
||||
: locale === "en-US"
|
||||
? "Airport anchor and nearby official network are broadly aligned."
|
||||
: "机场主站与周边官方站网当前大体齐平。";
|
||||
return {
|
||||
label: locale === "en-US" ? "Airport vs network" : "机场 vs 周边站",
|
||||
note,
|
||||
status,
|
||||
tone,
|
||||
value: `${delta > 0 ? "+" : ""}${delta.toFixed(1)}${detail.temp_symbol}`,
|
||||
};
|
||||
}, [
|
||||
detail.airport_vs_network_delta,
|
||||
detail.network_lead_signal,
|
||||
detail.temp_symbol,
|
||||
isToday,
|
||||
locale,
|
||||
showDeferredTodaySections,
|
||||
]);
|
||||
const paceSignalItems = useMemo(
|
||||
() =>
|
||||
[boundaryRiskView, peakWindowStateView, networkLeadView]
|
||||
.filter((item) => item != null)
|
||||
.map((item) => ({
|
||||
label: item.label,
|
||||
note: item.note,
|
||||
status: item.status,
|
||||
tone: item.tone,
|
||||
value: item.value,
|
||||
})) as FuturePaceSignalItem[],
|
||||
[boundaryRiskView, networkLeadView, peakWindowStateView],
|
||||
);
|
||||
const isNoaaSettlement =
|
||||
detail.current?.settlement_source === "noaa" ||
|
||||
detail.current?.settlement_source_label === "NOAA";
|
||||
const noaaStationCode = String(
|
||||
detail.current?.station_code || detail.risk?.icao || "NOAA",
|
||||
)
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
const noaaStationName =
|
||||
String(detail.current?.station_name || "").trim() ||
|
||||
String(detail.risk?.airport || "").trim() ||
|
||||
noaaStationCode;
|
||||
const hottestBucketLabel = formatBucketLabel(topProbabilityBucket);
|
||||
const probabilitySummary = (() => {
|
||||
if (!topProbabilityBucket) {
|
||||
return locale === "en-US"
|
||||
? "Probability mass is still too dispersed; avoid over-reading a single bracket."
|
||||
: "当前概率还比较分散,不要只盯单一区间。";
|
||||
}
|
||||
const bucketLabel = formatBucketLabel(topProbabilityBucket);
|
||||
const bucketProb = formatMarketPercent(topProbabilityBucket.probability);
|
||||
if (!isToday) {
|
||||
return locale === "en-US"
|
||||
? `Target-day model probability reference puts the leading bucket at ${bucketLabel} (${bucketProb}). EMOS is reserved for intraday analysis after live anchor observations arrive.`
|
||||
: `目标日模型概率参考显示领先温度桶为 ${bucketLabel}(${bucketProb})。EMOS 仅用于有实时锚点观测后的日内分析。`;
|
||||
}
|
||||
if (hasLgbmProbability) {
|
||||
return locale === "en-US"
|
||||
? `LGBM-calibrated read puts the leading bucket at ${bucketLabel} (${bucketProb}). Treat this as the base case, not the final settlement.`
|
||||
: `LGBM 校准后领先温度桶为 ${bucketLabel}(${bucketProb})。可作为基准情形,但不要直接等同于最终结算。`;
|
||||
}
|
||||
if (hasEmosProbability) {
|
||||
return locale === "en-US"
|
||||
? `EMOS-calibrated probability puts the leading bucket at ${bucketLabel} (${bucketProb}). It is the primary calibrated probability layer, not the final settlement.`
|
||||
: `EMOS 校准概率显示领先温度桶为 ${bucketLabel}(${bucketProb})。这是当前主概率层,但不要直接等同于最终结算。`;
|
||||
}
|
||||
return locale === "en-US"
|
||||
? `Calibrated model probability puts the leading bucket at ${bucketLabel} (${bucketProb}). Treat this as the base case, not the final settlement.`
|
||||
: `校准模型概率显示领先温度桶为 ${bucketLabel}(${bucketProb})。可作为基准情形,但不要直接等同于最终结算。`;
|
||||
})();
|
||||
const modelSummary = (() => {
|
||||
if (!modelSpreadView) {
|
||||
return locale === "en-US"
|
||||
? "Model spread is unavailable right now."
|
||||
: "当前拿不到可用的模型分歧。";
|
||||
}
|
||||
const modelEntries = Object.entries(modelView?.models || {}).filter(
|
||||
([, value]) =>
|
||||
value !== null && value !== undefined && Number.isFinite(Number(value)),
|
||||
);
|
||||
if (modelEntries.length === 1) {
|
||||
const [singleModelName, singleModelValue] = modelEntries[0];
|
||||
return locale === "en-US"
|
||||
? `Only ${singleModelName} is available right now at ${Number(singleModelValue).toFixed(1)}${detail.temp_symbol}; multi-model spread is temporarily unavailable.`
|
||||
: `当前只收到 ${singleModelName} ${Number(singleModelValue).toFixed(1)}${detail.temp_symbol},其他多模型暂未回传,所以这里先不判断模型分歧。`;
|
||||
}
|
||||
return locale === "en-US"
|
||||
? `Model range runs from ${modelSpreadView.min.toFixed(1)}${detail.temp_symbol} to ${modelSpreadView.max.toFixed(1)}${detail.temp_symbol}; spread ${modelSpreadView.spread.toFixed(1)}${detail.temp_symbol}.`
|
||||
: `当前模型区间在 ${modelSpreadView.min.toFixed(1)}${detail.temp_symbol} 到 ${modelSpreadView.max.toFixed(1)}${detail.temp_symbol},分歧 ${modelSpreadView.spread.toFixed(1)}${detail.temp_symbol}。`;
|
||||
})();
|
||||
const upperAirSignal = detail.vertical_profile_signal || {};
|
||||
const tafSignal = detail.taf?.signal || {};
|
||||
const upperAirCue = useMemo(() => {
|
||||
if (!showDeferredTodaySections) return null;
|
||||
if (!isToday || (!upperAirSignal.source && !tafSignal.available))
|
||||
return null;
|
||||
|
||||
const setup = String(
|
||||
upperAirSignal.heating_setup || "neutral",
|
||||
).toLowerCase();
|
||||
const tafSuppression = String(
|
||||
tafSignal.suppression_level || "low",
|
||||
).toLowerCase();
|
||||
const tafDisruption = String(
|
||||
tafSignal.disruption_level || "low",
|
||||
).toLowerCase();
|
||||
const reasons: string[] = [];
|
||||
let score = 0;
|
||||
|
||||
if (setup === "supportive") {
|
||||
score += 2;
|
||||
reasons.push(
|
||||
locale === "en-US"
|
||||
? "upper-air structure still supports daytime heating"
|
||||
: "高空结构仍偏支持白天冲高",
|
||||
);
|
||||
} else if (setup === "suppressed") {
|
||||
score -= 2;
|
||||
reasons.push(
|
||||
locale === "en-US"
|
||||
? "upper-air structure still leans toward capping the peak"
|
||||
: "高空结构更偏向压住峰值",
|
||||
);
|
||||
}
|
||||
|
||||
if (tafSuppression === "high") {
|
||||
score -= 2;
|
||||
reasons.push(
|
||||
locale === "en-US"
|
||||
? "TAF flags meaningful cloud/rain suppression near the peak window"
|
||||
: "TAF 在峰值窗口提示云雨压温风险偏高",
|
||||
);
|
||||
} else if (tafSuppression === "medium") {
|
||||
score -= 1;
|
||||
reasons.push(
|
||||
locale === "en-US"
|
||||
? "TAF keeps some cloud/rain suppression risk on the table"
|
||||
: "TAF 仍提示一定的云雨压温风险",
|
||||
);
|
||||
}
|
||||
|
||||
if (tafDisruption === "high") {
|
||||
score -= 1;
|
||||
reasons.push(
|
||||
locale === "en-US"
|
||||
? "TAF also suggests a noisier afternoon regime"
|
||||
: "TAF 还提示午后扰动偏强",
|
||||
);
|
||||
} else if (tafDisruption === "medium") {
|
||||
score -= 0.5;
|
||||
reasons.push(
|
||||
locale === "en-US"
|
||||
? "TAF keeps some afternoon timing noise in play"
|
||||
: "TAF 提示午后仍可能有时段性扰动",
|
||||
);
|
||||
}
|
||||
|
||||
if (score >= 1.5) {
|
||||
return {
|
||||
summary:
|
||||
locale === "en-US"
|
||||
? "The combined upper-air and TAF read still leans warmer. Do not fade lower buckets too early."
|
||||
: "高空和 TAF 两层信号合并后仍偏暖侧,不宜过早做更低温区间。",
|
||||
note:
|
||||
locale === "en-US"
|
||||
? `${reasons.slice(0, 2).join("; ")}.`
|
||||
: `${reasons.slice(0, 2).join(";")}。`,
|
||||
tone: "warm",
|
||||
value: locale === "en-US" ? "Lean warmer" : "偏暖侧",
|
||||
};
|
||||
}
|
||||
|
||||
if (score <= -1.5) {
|
||||
return {
|
||||
summary:
|
||||
locale === "en-US"
|
||||
? "The combined upper-air and TAF read leans more defensive. Be more careful chasing higher buckets."
|
||||
: "高空和 TAF 两层信号合并后更偏防守,追更高温区间要更谨慎。",
|
||||
note:
|
||||
locale === "en-US"
|
||||
? `${reasons.slice(0, 2).join("; ")}.`
|
||||
: `${reasons.slice(0, 2).join(";")}。`,
|
||||
tone: "cold",
|
||||
value: locale === "en-US" ? "Lean cautious" : "偏谨慎",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
summary:
|
||||
locale === "en-US"
|
||||
? "The combined upper-air and TAF read is mixed. Let surface structure decide before taking a side."
|
||||
: "高空和 TAF 两层信号目前偏混合,先看近地面结构变化,不急着站边。",
|
||||
note:
|
||||
locale === "en-US"
|
||||
? `${reasons.slice(0, 2).join("; ") || "No clean edge from the upper-air layer alone"}.`
|
||||
: `${reasons.slice(0, 2).join(";") || "单看高空层还没有干净的交易边"}。`,
|
||||
tone: "",
|
||||
value: locale === "en-US" ? "Wait / confirm" : "先观察",
|
||||
};
|
||||
}, [
|
||||
tafSignal.available,
|
||||
tafSignal.disruption_level,
|
||||
tafSignal.suppression_level,
|
||||
isToday,
|
||||
locale,
|
||||
upperAirSignal.heating_setup,
|
||||
upperAirSignal.source,
|
||||
showDeferredTodaySections,
|
||||
]);
|
||||
const topObservedTemp =
|
||||
detail.current?.max_so_far != null
|
||||
? detail.current.max_so_far
|
||||
: detail.current?.temp;
|
||||
const currentTempText =
|
||||
detail.current?.temp != null
|
||||
? `${detail.current.temp}${detail.temp_symbol}`
|
||||
: "--";
|
||||
const daylightProgress = (() => {
|
||||
const now = parseClockMinutes(detail.current?.obs_time);
|
||||
const sunrise = parseClockMinutes(detail.forecast?.sunrise);
|
||||
const sunset = parseClockMinutes(detail.forecast?.sunset);
|
||||
if (now == null || sunrise == null || sunset == null || sunset <= sunrise) {
|
||||
return null;
|
||||
}
|
||||
const percent = clamp(((now - sunrise) / (sunset - sunrise)) * 100, 0, 100);
|
||||
const phase =
|
||||
now < sunrise ? "夜间" : now > sunset ? "已日落" : "白昼进行中";
|
||||
return {
|
||||
phase,
|
||||
percent,
|
||||
};
|
||||
})();
|
||||
const displayedUpperAirSummary = showDeferredTodaySections
|
||||
? upperAirCue?.summary || view.front.upperAirSummary
|
||||
: "";
|
||||
const displayedUpperAirMetrics = showDeferredTodaySections
|
||||
? (view.front.upperAirMetrics || []).map((metric, index) =>
|
||||
index === 0 &&
|
||||
(metric.label === "Trade cue" || metric.label === "交易动作") &&
|
||||
upperAirCue
|
||||
? {
|
||||
...metric,
|
||||
note: upperAirCue.note,
|
||||
tone: upperAirCue.tone,
|
||||
value: upperAirCue.value,
|
||||
}
|
||||
: metric,
|
||||
)
|
||||
: [];
|
||||
const localizedAiCommentaryLines = useMemo(() => {
|
||||
if (!showDeferredTodaySections) return [] as string[];
|
||||
const commentary = detail.dynamic_commentary || {};
|
||||
const headline = String(
|
||||
locale === "en-US"
|
||||
? commentary.headline_en || ""
|
||||
: commentary.headline_zh || "",
|
||||
).trim();
|
||||
const bullets = (
|
||||
locale === "en-US" ? commentary.bullets_en : commentary.bullets_zh
|
||||
) as string[] | null | undefined;
|
||||
const cleanedBullets = Array.isArray(bullets)
|
||||
? bullets.map((item) => String(item || "").trim()).filter(Boolean)
|
||||
: [];
|
||||
return [headline, ...cleanedBullets].filter(Boolean).slice(0, 3);
|
||||
}, [detail.dynamic_commentary, locale, showDeferredTodaySections]);
|
||||
const todayTradeSummaryLines = useMemo(() => {
|
||||
if (!showDeferredTodaySections) return [] as string[];
|
||||
if (!isToday) return [] as string[];
|
||||
if (localizedAiCommentaryLines.length > 0) {
|
||||
return localizedAiCommentaryLines;
|
||||
}
|
||||
const lines: string[] = [];
|
||||
if (paceView) {
|
||||
const headline =
|
||||
paceView.biasTone === "warm"
|
||||
? locale === "en-US"
|
||||
? `Pace is running hot by ${paceView.deltaText}; the day high still leans above the base curve.`
|
||||
: `节奏偏热 ${paceView.deltaText},日高仍偏向落在基础曲线之上。`
|
||||
: paceView.biasTone === "cold"
|
||||
? locale === "en-US"
|
||||
? `Pace is trailing by ${paceView.deltaText}; chasing higher buckets needs caution.`
|
||||
: `节奏落后 ${paceView.deltaText},继续追更高温区间要更谨慎。`
|
||||
: locale === "en-US"
|
||||
? "Pace is still on curve; the next move depends on the peak-window push."
|
||||
: "节奏目前贴着曲线走,下一步主要看峰值窗口还有没有上冲。";
|
||||
lines.push(headline);
|
||||
}
|
||||
if (boundaryRiskView) {
|
||||
lines.push(
|
||||
locale === "en-US"
|
||||
? `${boundaryRiskView.label}: ${boundaryRiskView.note}`
|
||||
: `${boundaryRiskView.label}:${boundaryRiskView.note}`,
|
||||
);
|
||||
}
|
||||
if (networkLeadView) {
|
||||
lines.push(
|
||||
locale === "en-US"
|
||||
? `${networkLeadView.label}: ${networkLeadView.note}`
|
||||
: `${networkLeadView.label}:${networkLeadView.note}`,
|
||||
);
|
||||
}
|
||||
return lines.slice(0, 3);
|
||||
}, [
|
||||
boundaryRiskView,
|
||||
isToday,
|
||||
locale,
|
||||
localizedAiCommentaryLines,
|
||||
networkLeadView,
|
||||
paceView,
|
||||
showDeferredTodaySections,
|
||||
]);
|
||||
const intradayMeteorology = detail.intraday_meteorology || {};
|
||||
const meteorologySignals = Array.isArray(
|
||||
intradayMeteorology.signal_contributions,
|
||||
)
|
||||
? intradayMeteorology.signal_contributions
|
||||
: [];
|
||||
const invalidationRules = localizedList(
|
||||
locale,
|
||||
intradayMeteorology.invalidation_rules,
|
||||
intradayMeteorology.invalidation_rules_en,
|
||||
);
|
||||
const confirmationRules = localizedList(
|
||||
locale,
|
||||
intradayMeteorology.confirmation_rules,
|
||||
intradayMeteorology.confirmation_rules_en,
|
||||
);
|
||||
const meteorologyHeadline =
|
||||
localizedText(
|
||||
locale,
|
||||
intradayMeteorology.headline,
|
||||
intradayMeteorology.headline_en,
|
||||
) ||
|
||||
todayTradeSummaryLines[0] ||
|
||||
(locale === "en-US"
|
||||
? "Intraday meteorology layers are still syncing; use the next observation as the anchor."
|
||||
: "关键日内气象层仍在同步,先以下一次观测作为判断锚点。");
|
||||
const baseCaseBucket =
|
||||
String(intradayMeteorology.base_case_bucket || "").trim() ||
|
||||
formatBucketLabel(topProbabilityBucket);
|
||||
const nextObservationTime =
|
||||
String(intradayMeteorology.next_observation_time || "").trim() || "--";
|
||||
const baseBucketNumber = parseLeadingNumber(baseCaseBucket);
|
||||
const referenceObservedTemp =
|
||||
topObservedTemp != null && Number.isFinite(Number(topObservedTemp))
|
||||
? Number(topObservedTemp)
|
||||
: detail.current?.temp != null
|
||||
? Number(detail.current.temp)
|
||||
: null;
|
||||
const gapToBaseBucket =
|
||||
baseBucketNumber != null && referenceObservedTemp != null
|
||||
? Math.max(0, baseBucketNumber - referenceObservedTemp)
|
||||
: null;
|
||||
const pathStatus =
|
||||
gapToBaseBucket == null
|
||||
? locale === "en-US"
|
||||
? "Awaiting anchor"
|
||||
: "等待锚点"
|
||||
: gapToBaseBucket <= 0.05
|
||||
? locale === "en-US"
|
||||
? "Base path touched"
|
||||
: "基准路径已触达"
|
||||
: gapToBaseBucket <= 1.0
|
||||
? locale === "en-US"
|
||||
? "Base path open"
|
||||
: "基准路径开放"
|
||||
: locale === "en-US"
|
||||
? "Needs peak push"
|
||||
: "需要峰值推动";
|
||||
const peakWindowText =
|
||||
String(intradayMeteorology.peak_window || "").trim() ||
|
||||
paceView?.peakWindowText ||
|
||||
"--";
|
||||
const settlementSourceCode = normalizeObservationSourceCode(
|
||||
detail.current?.settlement_source || "",
|
||||
);
|
||||
const settlementStationCode = String(
|
||||
detail.current?.station_code || detail.risk?.icao || "",
|
||||
)
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
const settlementStationName =
|
||||
String(detail.current?.station_name || detail.risk?.airport || "").trim() ||
|
||||
settlementStationCode ||
|
||||
(locale === "en-US" ? "Anchor station" : "锚点站");
|
||||
const airportMetarAnchor =
|
||||
settlementSourceCode === "metar" ||
|
||||
Boolean(settlementStationCode && /^[A-Z]{4}$/.test(settlementStationCode));
|
||||
const anchorSourceLabel = airportMetarAnchor
|
||||
? settlementStationCode
|
||||
? `${settlementStationCode} METAR`
|
||||
: "METAR"
|
||||
: normalizeObservationSourceLabel(
|
||||
detail.current?.settlement_source_label ||
|
||||
detail.current?.settlement_source,
|
||||
locale === "en-US" ? "Official observation" : "官方观测",
|
||||
);
|
||||
const anchorRuleText = airportMetarAnchor
|
||||
? locale === "en-US"
|
||||
? `Airport contract anchor: use the ${anchorSourceLabel} reports. Third-party history pages are display-only when present.`
|
||||
: `机场合约锚点:以 ${anchorSourceLabel} 报文为准;第三方历史页只作为展示入口。`
|
||||
: locale === "en-US"
|
||||
? `Official anchor: use ${anchorSourceLabel} observations for this contract.`
|
||||
: `官方锚点:该合约按 ${anchorSourceLabel} 观测口径判断。`;
|
||||
const nextObservationLabel = airportMetarAnchor
|
||||
? locale === "en-US"
|
||||
? "Next METAR watch"
|
||||
: "下一次 METAR 观察"
|
||||
: locale === "en-US"
|
||||
? "Next anchor watch"
|
||||
: "下一次锚点观察";
|
||||
const gapToBaseText =
|
||||
gapToBaseBucket == null
|
||||
? "--"
|
||||
: `${gapToBaseBucket.toFixed(1)}${detail.temp_symbol || "°C"}`;
|
||||
const syncStatusItems = [
|
||||
{
|
||||
key: "base",
|
||||
state: isAnyLayerSyncing ? "syncing" : "ready",
|
||||
label: isAnyLayerSyncing
|
||||
? locale === "en-US"
|
||||
? "Refreshing base analysis"
|
||||
: "正在刷新基础分析"
|
||||
: locale === "en-US"
|
||||
? "Base analysis ready"
|
||||
: "基础分析已加载",
|
||||
note: isAnyLayerSyncing
|
||||
? locale === "en-US"
|
||||
? "Latest anchor readings and forecast curve are being rebuilt."
|
||||
: "正在重建最新锚点读数和预测曲线。"
|
||||
: locale === "en-US"
|
||||
? "Forecast curve, anchor state, and the core intraday view are available."
|
||||
: "预测曲线、锚点状态和核心日内视图已经可用。",
|
||||
},
|
||||
{
|
||||
key: "market",
|
||||
state: isAnyLayerSyncing ? "syncing" : "ready",
|
||||
label: isAnyLayerSyncing
|
||||
? locale === "en-US"
|
||||
? "Refreshing probability layer"
|
||||
: "正在刷新概率层"
|
||||
: locale === "en-US"
|
||||
? "Probability layer ready"
|
||||
: "概率层已加载",
|
||||
note: isAnyLayerSyncing
|
||||
? locale === "en-US"
|
||||
? "Model spread and calibrated buckets are updating."
|
||||
: "模型分歧和校准概率桶正在更新。"
|
||||
: locale === "en-US"
|
||||
? `Probability buckets are derived from the ${probabilityEngineLabel} layer.`
|
||||
: `概率桶当前由 ${probabilityEngineLabel} 层推导。`,
|
||||
},
|
||||
] satisfies FutureSyncStatusItem[];
|
||||
|
||||
return (
|
||||
<div
|
||||
className="modal-overlay"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="future-modal-title"
|
||||
onClick={(event) => {
|
||||
if (event.target === event.currentTarget) {
|
||||
modal.closeFutureModal();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isProLoading ? (
|
||||
<div
|
||||
className="modal-content large"
|
||||
style={{ padding: "40px", textAlign: "center" }}
|
||||
>
|
||||
<div style={{ color: "var(--text-muted)" }}>
|
||||
{t("dashboard.loading")}
|
||||
</div>
|
||||
</div>
|
||||
) : !isPro ? (
|
||||
<ProFeaturePaywall
|
||||
feature={isToday ? "today" : "future"}
|
||||
onClose={modal.closeFutureModal}
|
||||
/>
|
||||
) : (
|
||||
<div className="modal-content large future-modal">
|
||||
<FutureForecastModalHeader
|
||||
cityDisplayName={detail.display_name}
|
||||
dateStr={dateStr}
|
||||
isAnyLayerSyncing={isAnyLayerSyncing}
|
||||
isPro={isPro}
|
||||
isProLoading={isProLoading}
|
||||
isToday={isToday}
|
||||
locale={locale}
|
||||
onClose={modal.closeFutureModal}
|
||||
onRefresh={() => {
|
||||
if (isToday) {
|
||||
void modal.openTodayModal(true);
|
||||
return;
|
||||
}
|
||||
modal.openFutureModal(dateStr, true);
|
||||
}}
|
||||
t={t}
|
||||
/>
|
||||
<div
|
||||
className={clsx(
|
||||
"modal-body future-modal-body",
|
||||
isTodayBlockingRefresh && "future-modal-body-refreshing",
|
||||
)}
|
||||
>
|
||||
{isTodayBlockingRefresh && <FutureRefreshLock locale={locale} />}
|
||||
{isToday && (
|
||||
<FutureForecastTodayDecisionBrief
|
||||
anchorRuleText={anchorRuleText}
|
||||
anchorSourceLabel={anchorSourceLabel}
|
||||
baseCaseBucket={baseCaseBucket}
|
||||
confidence={intradayMeteorology.confidence}
|
||||
displayName={detail.display_name}
|
||||
downsideBucket={intradayMeteorology.downside_bucket}
|
||||
gapToBaseText={gapToBaseText}
|
||||
locale={locale}
|
||||
meteorologyHeadline={meteorologyHeadline}
|
||||
nextObservationLabel={nextObservationLabel}
|
||||
nextObservationTime={nextObservationTime}
|
||||
pathStatus={pathStatus}
|
||||
settlementStationName={settlementStationName}
|
||||
upsideBucket={intradayMeteorology.upside_bucket}
|
||||
/>
|
||||
)}
|
||||
<FutureSyncStatusStrip items={syncStatusItems} compact={isToday} />
|
||||
{isNoaaSettlement && (
|
||||
<div className="modal-callout modal-callout-info">
|
||||
{locale === "en-US"
|
||||
? `${detail.display_name} now settles against NOAA ${noaaStationCode} (${noaaStationName}). The market uses the highest rounded whole-degree Celsius reading in the Temp column after the day is finalized.`
|
||||
: `${detail.display_name} 当前按 NOAA ${noaaStationCode}(${noaaStationName})结算。市场最终采用该日 Temp 列完成质控后的最高整度摄氏值,不按小数温度结算。`}
|
||||
</div>
|
||||
)}
|
||||
{isToday ? (
|
||||
<FutureForecastTodayLayout
|
||||
locale={locale}
|
||||
isToday={isToday}
|
||||
dateStr={dateStr}
|
||||
detail={detail}
|
||||
currentTempText={currentTempText}
|
||||
weatherSummary={weatherSummary}
|
||||
daylightProgress={daylightProgress}
|
||||
topObservedTemp={topObservedTemp}
|
||||
gapToBaseBucket={gapToBaseBucket}
|
||||
pathStatus={pathStatus}
|
||||
showDeferredTodaySections={showDeferredTodaySections}
|
||||
paceView={paceView}
|
||||
paceSignalItems={paceSignalItems}
|
||||
baseCaseBucket={baseCaseBucket}
|
||||
upsideBucket={intradayMeteorology.upside_bucket}
|
||||
nextObservationTime={nextObservationTime}
|
||||
airportMetarAnchor={airportMetarAnchor}
|
||||
confirmationRules={confirmationRules}
|
||||
invalidationRules={invalidationRules}
|
||||
meteorologySignals={meteorologySignals}
|
||||
modelSummary={modelSummary}
|
||||
probabilityTitle={probabilityTitle}
|
||||
probabilitySummary={probabilitySummary}
|
||||
activeMarketScan={activeMarketScan}
|
||||
/>
|
||||
) : (
|
||||
<FutureForecastForwardView
|
||||
dateStr={dateStr}
|
||||
detail={detail}
|
||||
t={t}
|
||||
view={view}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
"use client";
|
||||
|
||||
import clsx from "clsx";
|
||||
import type { Locale } from "@/lib/i18n";
|
||||
|
||||
type FutureForecastModalHeaderProps = {
|
||||
cityDisplayName: string;
|
||||
dateStr: string;
|
||||
isAnyLayerSyncing: boolean;
|
||||
isPro: boolean;
|
||||
isProLoading: boolean;
|
||||
isToday: boolean;
|
||||
locale: Locale;
|
||||
onClose: () => void;
|
||||
onRefresh: () => void;
|
||||
t: (key: string, params?: Record<string, string | number>) => string;
|
||||
};
|
||||
|
||||
export function FutureForecastModalHeader({
|
||||
cityDisplayName,
|
||||
dateStr,
|
||||
isAnyLayerSyncing,
|
||||
isPro,
|
||||
isProLoading,
|
||||
isToday,
|
||||
locale,
|
||||
onClose,
|
||||
onRefresh,
|
||||
t,
|
||||
}: FutureForecastModalHeaderProps) {
|
||||
const cityLabel = cityDisplayName.toUpperCase();
|
||||
return (
|
||||
<div className="modal-header">
|
||||
<div className="modal-title-stack">
|
||||
<div className="modal-overline">
|
||||
<span>{locale === "en-US" ? "Analysis workspace" : "分析工作台"}</span>
|
||||
<span className="modal-overline-sep">•</span>
|
||||
<span>{cityLabel}</span>
|
||||
</div>
|
||||
<h2 id="future-modal-title" className="future-modal-title-with-actions">
|
||||
<span>
|
||||
{isToday
|
||||
? t("future.todayTitle", {
|
||||
city: cityLabel,
|
||||
})
|
||||
: t("future.dateTitle", {
|
||||
city: cityLabel,
|
||||
date: dateStr,
|
||||
})}
|
||||
</span>
|
||||
<button
|
||||
className={clsx("future-refresh-btn", isAnyLayerSyncing && "spinning")}
|
||||
disabled={!isPro || isProLoading}
|
||||
onClick={onRefresh}
|
||||
title={
|
||||
!isPro
|
||||
? locale === "en-US"
|
||||
? "Pro subscription required"
|
||||
: "需要 Pro 订阅"
|
||||
: locale === "en-US"
|
||||
? "Refresh Data"
|
||||
: "刷新数据"
|
||||
}
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
|
||||
<path d="M3 3v5h5" />
|
||||
</svg>
|
||||
</button>
|
||||
</h2>
|
||||
<div className="modal-subtitle">
|
||||
{isToday
|
||||
? locale === "en-US"
|
||||
? "Base signal first, then probability and model layers."
|
||||
: "先看基础信号,再看概率层和模型层。"
|
||||
: locale === "en-US"
|
||||
? "Forward date view with phased model and structure sync."
|
||||
: "未来日期视图,模型层与结构层分阶段补齐。"}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="modal-close"
|
||||
aria-label={isToday ? t("future.closeTodayAria") : t("future.closeDateAria")}
|
||||
onClick={onClose}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import type { useDashboardStore } from "@/hooks/useDashboardStore";
|
||||
import type { MarketScan } from "@/lib/dashboard-types";
|
||||
|
||||
const DailyTemperatureChart = dynamic(
|
||||
() =>
|
||||
import("./FutureForecastModalChart").then(
|
||||
(module) => module.DailyTemperatureChart,
|
||||
),
|
||||
{
|
||||
loading: () => <div className="history-chart-wrapper future-chart-wrapper" />,
|
||||
ssr: false,
|
||||
},
|
||||
);
|
||||
|
||||
const ProbabilityDistribution = dynamic(
|
||||
() =>
|
||||
import("@/components/dashboard/PanelSections").then(
|
||||
(module) => module.ProbabilityDistribution,
|
||||
),
|
||||
{
|
||||
loading: () => <div className="future-v2-panel-loading" />,
|
||||
ssr: false,
|
||||
},
|
||||
);
|
||||
|
||||
const ModelForecast = dynamic(
|
||||
() =>
|
||||
import("@/components/dashboard/PanelSections").then(
|
||||
(module) => module.ModelForecast,
|
||||
),
|
||||
{
|
||||
loading: () => <div className="future-v2-panel-loading" />,
|
||||
ssr: false,
|
||||
},
|
||||
);
|
||||
|
||||
type DashboardDetail = NonNullable<
|
||||
ReturnType<typeof useDashboardStore>["selectedDetail"]
|
||||
>;
|
||||
|
||||
export function FutureTemperaturePathChart({
|
||||
dateStr,
|
||||
forceToday,
|
||||
}: {
|
||||
dateStr: string;
|
||||
forceToday: boolean;
|
||||
}) {
|
||||
return <DailyTemperatureChart dateStr={dateStr} forceToday={forceToday} />;
|
||||
}
|
||||
|
||||
export function FutureProbabilityPanel({
|
||||
detail,
|
||||
targetDate,
|
||||
marketScan,
|
||||
hideTitle = true,
|
||||
}: {
|
||||
detail: DashboardDetail;
|
||||
targetDate: string;
|
||||
marketScan?: MarketScan | null;
|
||||
hideTitle?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<ProbabilityDistribution
|
||||
detail={detail}
|
||||
targetDate={targetDate}
|
||||
marketScan={marketScan}
|
||||
hideTitle={hideTitle}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function FutureModelForecastPanel({
|
||||
detail,
|
||||
targetDate,
|
||||
hideTitle = true,
|
||||
}: {
|
||||
detail: DashboardDetail;
|
||||
targetDate: string;
|
||||
hideTitle?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<ModelForecast detail={detail} targetDate={targetDate} hideTitle={hideTitle} />
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import clsx from "clsx";
|
||||
|
||||
export type FutureSyncStatusItem = {
|
||||
key: string;
|
||||
state: "ready" | "syncing";
|
||||
label: string;
|
||||
note: string;
|
||||
};
|
||||
|
||||
export function FutureRefreshLock({ locale }: { locale: string }) {
|
||||
return (
|
||||
<div
|
||||
className="future-v2-refresh-lock"
|
||||
role="status"
|
||||
aria-live="assertive"
|
||||
>
|
||||
<span className="future-v2-refresh-spinner" aria-hidden="true" />
|
||||
<div>
|
||||
<strong>
|
||||
{locale === "en-US"
|
||||
? "Refreshing latest intraday data"
|
||||
: "正在刷新最新日内数据"}
|
||||
</strong>
|
||||
<p>
|
||||
{locale === "en-US"
|
||||
? "Old cached readings are temporarily locked to prevent misjudgement. The analysis will unlock after the latest anchor observation, model layer, and probability layer are ready."
|
||||
: "旧缓存读数已临时锁定,避免误判。最新锚点观测、模型层和概率层就绪后会自动解锁。"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FutureSyncStatusStrip({
|
||||
items,
|
||||
compact = false,
|
||||
}: {
|
||||
items: readonly FutureSyncStatusItem[];
|
||||
compact?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<section
|
||||
className={clsx("future-v2-sync-strip", compact && "future-v2-sync-strip-compact")}
|
||||
aria-live="polite"
|
||||
>
|
||||
{items.map((item) => (
|
||||
<div
|
||||
key={item.key}
|
||||
className={clsx(
|
||||
"future-v2-sync-chip",
|
||||
item.state === "syncing" && "syncing",
|
||||
)}
|
||||
>
|
||||
<span className="future-v2-sync-dot" aria-hidden="true" />
|
||||
<div className="future-v2-sync-copy">
|
||||
<strong>{item.label}</strong>
|
||||
<span>{item.note}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import {
|
||||
Cloud,
|
||||
CloudFog,
|
||||
CloudLightning,
|
||||
CloudRain,
|
||||
CloudSnow,
|
||||
CloudSun,
|
||||
Search,
|
||||
Sun,
|
||||
Wind,
|
||||
} from "lucide-react";
|
||||
|
||||
export function WeatherIcon({ emoji, size = 32 }: { emoji: string; size?: number }) {
|
||||
if (emoji === "☀️") return <Sun size={size} color="#facc15" />;
|
||||
if (emoji === "⛅" || emoji === "🌤️")
|
||||
return <CloudSun size={size} color="#4DA3FF" />;
|
||||
if (emoji === "☁️") return <Cloud size={size} color="#9FB2C7" />;
|
||||
if (emoji === "🌧️" || emoji === "🌦️")
|
||||
return <CloudRain size={size} color="#60a5fa" />;
|
||||
if (emoji === "⛈️") return <CloudLightning size={size} color="#c084fc" />;
|
||||
if (emoji === "❄️" || emoji === "🌨️")
|
||||
return <CloudSnow size={size} color="#7dd3fc" />;
|
||||
if (emoji === "🌫️") return <CloudFog size={size} color="#a1a1aa" />;
|
||||
if (emoji === "💨") return <Wind size={size} color="#cbd5e1" />;
|
||||
return <Search size={size} color="#6B7A90" />;
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
import clsx from "clsx";
|
||||
import type { CSSProperties } from "react";
|
||||
import type { getTodayPaceView } from "@/lib/pace-utils";
|
||||
import { WeatherIcon } from "./FutureForecastModalWeatherIcon";
|
||||
|
||||
type Locale = string;
|
||||
type TodayPaceView = NonNullable<ReturnType<typeof getTodayPaceView>>;
|
||||
|
||||
type WeatherSummaryView = {
|
||||
weatherIcon: string;
|
||||
weatherText: string;
|
||||
};
|
||||
|
||||
type DaylightProgressView = {
|
||||
phase: string;
|
||||
percent: number;
|
||||
};
|
||||
|
||||
export type FuturePaceSignalItem = {
|
||||
label: string;
|
||||
tone: "cyan" | "blue" | "amber";
|
||||
status: string;
|
||||
value: string;
|
||||
note: string;
|
||||
};
|
||||
|
||||
export function FutureAnchorStatusCard({
|
||||
locale,
|
||||
currentTempText,
|
||||
weatherSummary,
|
||||
obsTime,
|
||||
topObservedTemp,
|
||||
tempSymbol,
|
||||
gapToBaseBucket,
|
||||
pathStatus,
|
||||
}: {
|
||||
locale: Locale;
|
||||
currentTempText: string;
|
||||
weatherSummary: WeatherSummaryView;
|
||||
obsTime?: string | null;
|
||||
topObservedTemp: number | string | null | undefined;
|
||||
tempSymbol: string;
|
||||
gapToBaseBucket: number | null;
|
||||
pathStatus: string;
|
||||
}) {
|
||||
const observedHigh = topObservedTemp ?? "--";
|
||||
|
||||
return (
|
||||
<section className="future-v2-card future-v2-hero-card">
|
||||
<div className="future-v2-card-head">
|
||||
<h3 className="future-v2-hero-title">
|
||||
{locale === "en-US" ? "Anchor Status" : "锚点状态"}
|
||||
</h3>
|
||||
<div className="future-v2-card-kicker">
|
||||
{locale === "en-US"
|
||||
? "Settlement anchor and current clock"
|
||||
: "结算锚点与当前时钟"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="future-v2-hero-main">
|
||||
<div className="future-v2-hero-temp">{currentTempText}</div>
|
||||
<div className="future-v2-hero-divider" />
|
||||
<div className="future-v2-hero-weather">
|
||||
<span className="future-v2-hero-icon">
|
||||
<WeatherIcon emoji={weatherSummary.weatherIcon} size={42} />
|
||||
</span>
|
||||
<span>{weatherSummary.weatherText}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="future-v2-hero-obs">@{obsTime || "--"}</div>
|
||||
<div className="future-v2-mini-grid">
|
||||
<FutureMiniMetric
|
||||
label={locale === "en-US" ? "High so far" : "日内已见高点"}
|
||||
value={`${observedHigh}${tempSymbol}`}
|
||||
/>
|
||||
<FutureMiniMetric
|
||||
label={locale === "en-US" ? "Anchor clock" : "锚点时钟"}
|
||||
value={obsTime || "--"}
|
||||
/>
|
||||
<FutureMiniMetric
|
||||
label={locale === "en-US" ? "Gap to base" : "距基准档"}
|
||||
value={
|
||||
gapToBaseBucket != null
|
||||
? `${gapToBaseBucket.toFixed(1)}${tempSymbol}`
|
||||
: "--"
|
||||
}
|
||||
/>
|
||||
<FutureMiniMetric
|
||||
label={locale === "en-US" ? "Path state" : "路径状态"}
|
||||
value={pathStatus}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function FuturePaceCard({
|
||||
locale,
|
||||
paceView,
|
||||
tempSymbol,
|
||||
signalItems,
|
||||
}: {
|
||||
locale: Locale;
|
||||
paceView: TodayPaceView;
|
||||
tempSymbol: string;
|
||||
signalItems: FuturePaceSignalItem[];
|
||||
}) {
|
||||
return (
|
||||
<section className="future-v2-card future-v2-pace-card future-v2-focus-card">
|
||||
<div className="future-v2-card-head">
|
||||
<h4 className="future-v2-card-title">
|
||||
{locale === "en-US" ? "Current Pace" : "当前节奏"}
|
||||
</h4>
|
||||
<div className="future-v2-card-kicker">
|
||||
{locale === "en-US"
|
||||
? "Expected now vs airport anchor"
|
||||
: "此刻应到 vs 机场锚点"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="future-v2-pace-head">
|
||||
<span className="future-v2-pace-kicker">{paceView.kicker}</span>
|
||||
<FutureSignalTag tone={paceView.biasTone}>{paceView.badge}</FutureSignalTag>
|
||||
</div>
|
||||
<div
|
||||
className={clsx(
|
||||
"future-v2-pace-delta",
|
||||
paceView.biasTone === "cold" && "cold",
|
||||
paceView.biasTone === "neutral" && "neutral",
|
||||
paceView.biasTone === "warm" && "warm",
|
||||
)}
|
||||
>
|
||||
{paceView.deltaText}
|
||||
</div>
|
||||
<div className="future-v2-pace-summary">{paceView.summary}</div>
|
||||
<div className="future-v2-pace-meter">
|
||||
<span className="future-v2-pace-meter-midline" />
|
||||
<span
|
||||
className={clsx(
|
||||
"future-v2-pace-meter-fill",
|
||||
paceView.biasTone === "cold" && "cold",
|
||||
paceView.biasTone === "neutral" && "neutral",
|
||||
paceView.biasTone === "warm" && "warm",
|
||||
)}
|
||||
style={
|
||||
{
|
||||
"--pace-left": `${paceView.meterLeft}%`,
|
||||
"--pace-width": `${paceView.meterWidth}%`,
|
||||
} as CSSProperties & {
|
||||
"--pace-left": string;
|
||||
"--pace-width": string;
|
||||
}
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="future-v2-mini-grid future-v2-mini-grid-tight">
|
||||
<FutureMiniMetric
|
||||
label={locale === "en-US" ? "Expected now" : "预期此刻"}
|
||||
value={`${paceView.expectedNow.toFixed(1)}${tempSymbol}`}
|
||||
/>
|
||||
<FutureMiniMetric
|
||||
label={paceView.observedLabel}
|
||||
value={`${paceView.observedNow.toFixed(1)}${tempSymbol}`}
|
||||
/>
|
||||
<FutureMiniMetric
|
||||
label={paceView.paceAdjustedLabel}
|
||||
value={
|
||||
paceView.paceAdjustedHigh != null
|
||||
? `${paceView.paceAdjustedHigh.toFixed(1)}${tempSymbol}`
|
||||
: "--"
|
||||
}
|
||||
/>
|
||||
<FutureMiniMetric
|
||||
label={locale === "en-US" ? "Peak window" : "峰值窗口"}
|
||||
value={paceView.peakWindowText}
|
||||
/>
|
||||
</div>
|
||||
{signalItems.length ? (
|
||||
<div className="future-v2-pace-signal-grid">
|
||||
{signalItems.map((item) => (
|
||||
<div key={item.label} className="future-v2-pace-signal-card">
|
||||
<div className="future-v2-signal-head">
|
||||
<span>{item.label}</span>
|
||||
<FutureSignalTag tone={item.tone}>{item.status}</FutureSignalTag>
|
||||
</div>
|
||||
<strong>{item.value}</strong>
|
||||
<div className="future-v2-pace-signal-note">{item.note}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function FuturePaceLoadingCard({ locale }: { locale: Locale }) {
|
||||
return (
|
||||
<section className="future-v2-card future-v2-support-card">
|
||||
<div className="future-v2-card-head">
|
||||
<h4 className="future-v2-card-title">
|
||||
{locale === "en-US" ? "Current Pace" : "当前节奏"}
|
||||
</h4>
|
||||
<div className="future-v2-card-kicker">
|
||||
{locale === "en-US"
|
||||
? "Backfilling intraday pace context"
|
||||
: "正在补齐日内节奏上下文"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="future-trend-summary future-trend-summary-muted">
|
||||
{locale === "en-US"
|
||||
? "Expected-now pace, boundary risk, and airport-vs-network cues are loading in the background."
|
||||
: "预期此刻节奏、边界风险和机场对比站网信号正在后台补齐。"}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function FutureMiniMetric({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="future-v2-mini-item">
|
||||
<span>{label}</span>
|
||||
<strong>{value}</strong>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FutureSignalTag({
|
||||
tone,
|
||||
children,
|
||||
}: {
|
||||
tone: string;
|
||||
children: string;
|
||||
}) {
|
||||
return (
|
||||
<em
|
||||
className={clsx(
|
||||
"future-v2-signal-tag",
|
||||
(tone === "cold" || tone === "cyan") && "cyan",
|
||||
(tone === "neutral" || tone === "blue") && "blue",
|
||||
(tone === "warm" || tone === "amber") && "amber",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</em>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
|
||||
import { formatConfidenceLabel } from "./FutureForecastModal.utils";
|
||||
|
||||
export function FutureForecastTodayDecisionBrief({
|
||||
anchorRuleText,
|
||||
anchorSourceLabel,
|
||||
baseCaseBucket,
|
||||
confidence,
|
||||
displayName,
|
||||
downsideBucket,
|
||||
gapToBaseText,
|
||||
locale,
|
||||
meteorologyHeadline,
|
||||
nextObservationLabel,
|
||||
nextObservationTime,
|
||||
pathStatus,
|
||||
settlementStationName,
|
||||
upsideBucket,
|
||||
}: {
|
||||
anchorRuleText: string;
|
||||
anchorSourceLabel: string;
|
||||
baseCaseBucket: string;
|
||||
confidence: string | null | undefined;
|
||||
displayName: string;
|
||||
downsideBucket: string | null | undefined;
|
||||
gapToBaseText: string;
|
||||
locale: string;
|
||||
meteorologyHeadline: string;
|
||||
nextObservationLabel: string;
|
||||
nextObservationTime: string;
|
||||
pathStatus: string;
|
||||
settlementStationName: string;
|
||||
upsideBucket: string | null | undefined;
|
||||
}) {
|
||||
return (
|
||||
<section className="future-v2-meteorology-brief">
|
||||
<div className="future-v2-meteorology-copy">
|
||||
<div className="future-v2-anchor-row">
|
||||
<div className="modal-section-kicker">
|
||||
{locale === "en-US" ? "Professional meteorology read" : "专业气象判断"}
|
||||
</div>
|
||||
<span className="future-v2-anchor-source">{anchorSourceLabel}</span>
|
||||
</div>
|
||||
<h3>{meteorologyHeadline}</h3>
|
||||
<p className="future-v2-anchor-rule">{anchorRuleText}</p>
|
||||
<div className="future-v2-meteorology-meta">
|
||||
<span>
|
||||
{locale === "en-US" ? "Confidence" : "置信度"} ·{" "}
|
||||
{formatConfidenceLabel(confidence, locale)}
|
||||
</span>
|
||||
<span>
|
||||
{locale === "en-US" ? "Path state" : "路径状态"} · {pathStatus}
|
||||
</span>
|
||||
<span>
|
||||
{nextObservationLabel} · {nextObservationTime}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="future-v2-decision-rail" aria-label={displayName}>
|
||||
<div className="future-v2-decision-anchor">
|
||||
<span>{locale === "en-US" ? "Anchor" : "锚点"}</span>
|
||||
<strong>{settlementStationName}</strong>
|
||||
<small>{anchorSourceLabel}</small>
|
||||
</div>
|
||||
<div className="future-v2-decision-grid">
|
||||
<div>
|
||||
<span>{locale === "en-US" ? "Base" : "基准"}</span>
|
||||
<strong>{baseCaseBucket || "--"}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>{locale === "en-US" ? "Upside" : "上修"}</span>
|
||||
<strong>{upsideBucket || "--"}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>{locale === "en-US" ? "Downside" : "下修"}</span>
|
||||
<strong>{downsideBucket || "--"}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>{locale === "en-US" ? "Gap to base" : "距基准还差"}</span>
|
||||
<strong>{gapToBaseText}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
"use client";
|
||||
|
||||
import clsx from "clsx";
|
||||
import {
|
||||
formatSignalDirection,
|
||||
formatSignalStrength,
|
||||
localizedText,
|
||||
signalTone,
|
||||
} from "./FutureForecastModal.utils";
|
||||
|
||||
type MeteorologySignal = {
|
||||
direction?: string | null;
|
||||
label?: string | null;
|
||||
label_en?: string | null;
|
||||
strength?: string | null;
|
||||
summary?: string | null;
|
||||
summary_en?: string | null;
|
||||
};
|
||||
|
||||
export function FutureForecastTodayEvidenceGrid({
|
||||
airportMetarAnchor,
|
||||
confirmationRules,
|
||||
invalidationRules,
|
||||
locale,
|
||||
meteorologySignals,
|
||||
modelSummary,
|
||||
}: {
|
||||
airportMetarAnchor: boolean;
|
||||
confirmationRules: string[];
|
||||
invalidationRules: string[];
|
||||
locale: string;
|
||||
meteorologySignals: MeteorologySignal[];
|
||||
modelSummary: string;
|
||||
}) {
|
||||
const fallbackInvalidationRule =
|
||||
locale === "en-US"
|
||||
? "If observations stop tracking the expected curve, wait for the next refresh."
|
||||
: "若实测不再贴近预期曲线,等待下一次刷新确认。";
|
||||
const fallbackConfirmationRule =
|
||||
locale === "en-US"
|
||||
? airportMetarAnchor
|
||||
? "Keep watching the next anchor METAR report."
|
||||
: "Keep watching the next official anchor observation."
|
||||
: airportMetarAnchor
|
||||
? "继续观察下一次锚点 METAR 报文。"
|
||||
: "继续观察下一次官方锚点观测。";
|
||||
|
||||
return (
|
||||
<div className="future-v2-meteorology-grid">
|
||||
<section className="future-modal-section future-v2-evidence-panel">
|
||||
<div className="modal-section-heading">
|
||||
<div className="modal-section-kicker">
|
||||
{locale === "en-US" ? "Evidence chain" : "气象证据链"}
|
||||
</div>
|
||||
<h3>{locale === "en-US" ? "Signal Contributions" : "信号贡献"}</h3>
|
||||
</div>
|
||||
<div className="future-v2-evidence-list">
|
||||
{meteorologySignals.length > 0 ? (
|
||||
meteorologySignals.map((signal, index) => (
|
||||
<div
|
||||
key={`${signal.label || "signal"}-${index}`}
|
||||
className={clsx("future-v2-evidence-row", signalTone(signal))}
|
||||
>
|
||||
<div className="future-v2-evidence-head">
|
||||
<strong>
|
||||
{localizedText(locale, signal.label, signal.label_en) || "--"}
|
||||
</strong>
|
||||
<span>
|
||||
{formatSignalDirection(signal.direction, locale)} ·{" "}
|
||||
{formatSignalStrength(signal.strength, locale)}
|
||||
</span>
|
||||
</div>
|
||||
<p>
|
||||
{localizedText(locale, signal.summary, signal.summary_en) ||
|
||||
"--"}
|
||||
</p>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="future-text-block">
|
||||
{locale === "en-US"
|
||||
? "Meteorology signals are still loading."
|
||||
: "气象信号仍在加载。"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="future-modal-section future-v2-rule-panel">
|
||||
<div className="modal-section-heading">
|
||||
<div className="modal-section-kicker">
|
||||
{locale === "en-US" ? "Failure modes" : "失效条件"}
|
||||
</div>
|
||||
<h3>
|
||||
{locale === "en-US" ? "What Downgrades the Read" : "什么会让判断降级"}
|
||||
</h3>
|
||||
</div>
|
||||
<ul className="future-v2-rule-list">
|
||||
{(invalidationRules.length > 0
|
||||
? invalidationRules
|
||||
: [fallbackInvalidationRule]
|
||||
).map((rule, index) => (
|
||||
<li key={`${rule}-${index}`}>{rule}</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section className="future-modal-section future-v2-rule-panel">
|
||||
<div className="modal-section-heading">
|
||||
<div className="modal-section-kicker">
|
||||
{locale === "en-US" ? "Confirmation" : "确认条件"}
|
||||
</div>
|
||||
<h3>
|
||||
{locale === "en-US" ? "What Confirms the Path" : "什么会确认主路径"}
|
||||
</h3>
|
||||
</div>
|
||||
<ul className="future-v2-rule-list">
|
||||
{(confirmationRules.length > 0
|
||||
? confirmationRules
|
||||
: [fallbackConfirmationRule]
|
||||
).map((rule, index) => (
|
||||
<li key={`${rule}-${index}`}>{rule}</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="future-v2-model-note">{modelSummary}</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
"use client";
|
||||
|
||||
import type { MarketScan, CityDetail } from "@/lib/dashboard-types";
|
||||
import type { FuturePaceSignalItem } from "./FutureForecastTodayCards";
|
||||
import { FutureForecastTodayEvidenceGrid } from "./FutureForecastTodayEvidenceGrid";
|
||||
import {
|
||||
FutureModelForecastPanel,
|
||||
FutureProbabilityPanel,
|
||||
FutureTemperaturePathChart,
|
||||
} from "./FutureForecastModalPanels";
|
||||
import {
|
||||
FutureAnchorStatusCard,
|
||||
FuturePaceCard,
|
||||
FuturePaceLoadingCard,
|
||||
} from "./FutureForecastTodayCards";
|
||||
|
||||
type Locale = string;
|
||||
|
||||
interface WeatherSummaryView {
|
||||
weatherIcon: string;
|
||||
weatherText: string;
|
||||
}
|
||||
|
||||
interface DaylightProgressView {
|
||||
phase: string;
|
||||
percent: number;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type PaceView = any;
|
||||
|
||||
export interface FutureForecastTodayLayoutProps {
|
||||
locale: Locale;
|
||||
isToday: boolean;
|
||||
dateStr: string;
|
||||
detail: CityDetail;
|
||||
currentTempText: string;
|
||||
weatherSummary: WeatherSummaryView;
|
||||
daylightProgress: DaylightProgressView | null;
|
||||
topObservedTemp: number | string | null | undefined;
|
||||
gapToBaseBucket: number | null;
|
||||
pathStatus: string;
|
||||
showDeferredTodaySections: boolean;
|
||||
paceView: PaceView | null;
|
||||
paceSignalItems: FuturePaceSignalItem[];
|
||||
baseCaseBucket: string | undefined;
|
||||
upsideBucket: string | null | undefined;
|
||||
nextObservationTime: string;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
airportMetarAnchor: any;
|
||||
confirmationRules: string[];
|
||||
invalidationRules: string[];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
meteorologySignals: any[];
|
||||
modelSummary: string;
|
||||
probabilityTitle: string;
|
||||
probabilitySummary: string;
|
||||
activeMarketScan: MarketScan | null | undefined;
|
||||
}
|
||||
|
||||
export function FutureForecastTodayLayout(props: FutureForecastTodayLayoutProps) {
|
||||
const {
|
||||
locale,
|
||||
isToday,
|
||||
dateStr,
|
||||
detail,
|
||||
currentTempText,
|
||||
weatherSummary,
|
||||
daylightProgress,
|
||||
topObservedTemp,
|
||||
gapToBaseBucket,
|
||||
pathStatus,
|
||||
showDeferredTodaySections,
|
||||
paceView,
|
||||
paceSignalItems,
|
||||
baseCaseBucket,
|
||||
upsideBucket,
|
||||
nextObservationTime,
|
||||
airportMetarAnchor,
|
||||
confirmationRules,
|
||||
invalidationRules,
|
||||
meteorologySignals,
|
||||
modelSummary,
|
||||
probabilityTitle,
|
||||
probabilitySummary,
|
||||
activeMarketScan,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<div className="future-v2-layout">
|
||||
<aside className="future-v2-left">
|
||||
<FutureAnchorStatusCard
|
||||
locale={locale}
|
||||
currentTempText={currentTempText}
|
||||
weatherSummary={weatherSummary}
|
||||
obsTime={detail.current?.obs_time}
|
||||
topObservedTemp={topObservedTemp}
|
||||
tempSymbol={detail.temp_symbol}
|
||||
gapToBaseBucket={gapToBaseBucket}
|
||||
pathStatus={pathStatus}
|
||||
/>
|
||||
|
||||
{showDeferredTodaySections && paceView ? (
|
||||
<FuturePaceCard
|
||||
locale={locale}
|
||||
paceView={paceView}
|
||||
tempSymbol={detail.temp_symbol}
|
||||
signalItems={paceSignalItems}
|
||||
/>
|
||||
) : isToday ? (
|
||||
<FuturePaceLoadingCard locale={locale} />
|
||||
) : null}
|
||||
</aside>
|
||||
|
||||
<main className="future-v2-right">
|
||||
<section className="future-modal-section future-v2-main-chart">
|
||||
<div className="modal-section-heading">
|
||||
<div className="modal-section-kicker">
|
||||
{locale === "en-US" ? "Primary view" : "主视图"}
|
||||
</div>
|
||||
<h3>
|
||||
{locale === "en-US"
|
||||
? "Today's temperature path (anchor obs + models)"
|
||||
: "今日气温路径(锚点观测 + 模型)"}
|
||||
</h3>
|
||||
</div>
|
||||
<FutureTemperaturePathChart dateStr={dateStr} forceToday={isToday} />
|
||||
<div className="future-v2-chart-thresholds">
|
||||
<span>
|
||||
{locale === "en-US" ? "Base" : "基准"} ·{" "}
|
||||
{baseCaseBucket || "--"}
|
||||
</span>
|
||||
<span>
|
||||
{locale === "en-US" ? "Upside" : "上修"} ·{" "}
|
||||
{upsideBucket || "--"}
|
||||
</span>
|
||||
<span>
|
||||
{locale === "en-US" ? "Invalidates at" : "失效观察"} ·{" "}
|
||||
{nextObservationTime}
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<FutureForecastTodayEvidenceGrid
|
||||
airportMetarAnchor={airportMetarAnchor}
|
||||
confirmationRules={confirmationRules}
|
||||
invalidationRules={invalidationRules}
|
||||
locale={locale}
|
||||
meteorologySignals={meteorologySignals}
|
||||
modelSummary={modelSummary}
|
||||
/>
|
||||
|
||||
<div className="future-modal-grid">
|
||||
<section className="future-modal-section">
|
||||
<div className="modal-section-heading">
|
||||
<div className="modal-section-kicker">
|
||||
{locale === "en-US" ? "Probability read" : "概率判断"}
|
||||
</div>
|
||||
<h3>{probabilityTitle}</h3>
|
||||
</div>
|
||||
<div className="future-text-block" style={{ marginBottom: "12px" }}>
|
||||
{probabilitySummary}
|
||||
</div>
|
||||
<div style={{ position: "relative", minHeight: "120px" }}>
|
||||
<FutureProbabilityPanel
|
||||
detail={detail}
|
||||
targetDate={dateStr}
|
||||
marketScan={activeMarketScan}
|
||||
hideTitle
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
<section className="future-modal-section">
|
||||
<div className="modal-section-heading">
|
||||
<div className="modal-section-kicker">
|
||||
{locale === "en-US" ? "Model layer" : "模型层"}
|
||||
</div>
|
||||
<h3>
|
||||
{locale === "en-US"
|
||||
? "Model Range & Spread"
|
||||
: "模型区间与分歧"}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="future-text-block" style={{ marginBottom: "12px" }}>
|
||||
{modelSummary}
|
||||
</div>
|
||||
<FutureModelForecastPanel detail={detail} targetDate={dateStr} hideTitle />
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { usePathname } from "next/navigation";
|
||||
import clsx from "clsx";
|
||||
import {
|
||||
LogIn,
|
||||
UserRound,
|
||||
RotateCw,
|
||||
BookOpen,
|
||||
MoreHorizontal,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useDashboardStore, useProAccess } from "@/hooks/useDashboardStore";
|
||||
import { useI18n } from "@/hooks/useI18n";
|
||||
|
||||
function parseExpiryInfo(raw?: string | null) {
|
||||
const text = String(raw || "").trim();
|
||||
if (!text) return null;
|
||||
const dt = new Date(text);
|
||||
if (Number.isNaN(dt.getTime())) return null;
|
||||
const diffMs = dt.getTime() - Date.now();
|
||||
const daysLeft = Math.ceil(diffMs / 86_400_000);
|
||||
return {
|
||||
date: dt,
|
||||
daysLeft,
|
||||
expired: diffMs <= 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function HeaderBar({
|
||||
refreshAction,
|
||||
refreshSpinning,
|
||||
}: {
|
||||
refreshAction?: () => void | Promise<void>;
|
||||
refreshSpinning?: boolean;
|
||||
}) {
|
||||
const store = useDashboardStore();
|
||||
const { proAccess } = useProAccess();
|
||||
const { locale, t, toggleLocale } = useI18n();
|
||||
const pathname = usePathname();
|
||||
const isAuthenticated = proAccess.authenticated;
|
||||
const docsHref = "/docs/intro";
|
||||
const docsActive = pathname?.startsWith("/docs");
|
||||
const navItems = [
|
||||
{
|
||||
href: "/",
|
||||
label: locale === "en-US" ? "Dashboard" : "总览",
|
||||
active: pathname === "/",
|
||||
},
|
||||
];
|
||||
const isRefreshing = refreshSpinning ?? store.loadingState.refresh;
|
||||
const handleRefresh = () => {
|
||||
if (refreshAction) {
|
||||
return void refreshAction();
|
||||
}
|
||||
return void store.refreshAll();
|
||||
};
|
||||
|
||||
const accountHref = isAuthenticated
|
||||
? "/account"
|
||||
: "/auth/login?next=%2Faccount";
|
||||
const accountAria = isAuthenticated
|
||||
? t("header.accountAria")
|
||||
: t("header.signInAria");
|
||||
const effectiveExpiry = proAccess.subscriptionActive
|
||||
? proAccess.subscriptionTotalExpiresAt ||
|
||||
proAccess.subscriptionExpiresAt
|
||||
: proAccess.subscriptionExpiresAt;
|
||||
const expiryInfo = parseExpiryInfo(effectiveExpiry);
|
||||
const hasQueuedExtension = Boolean(
|
||||
proAccess.subscriptionActive &&
|
||||
proAccess.subscriptionQueuedDays > 0,
|
||||
);
|
||||
const showRenewReminder =
|
||||
isAuthenticated &&
|
||||
!proAccess.loading &&
|
||||
!hasQueuedExtension &&
|
||||
((proAccess.subscriptionActive &&
|
||||
expiryInfo &&
|
||||
expiryInfo.daysLeft <= 3) ||
|
||||
(!proAccess.subscriptionActive && Boolean(expiryInfo)));
|
||||
const renewReminderLabel = !showRenewReminder
|
||||
? ""
|
||||
: !proAccess.subscriptionActive
|
||||
? locale === "en-US"
|
||||
? "Pro expired"
|
||||
: "Pro 已到期"
|
||||
: locale === "en-US"
|
||||
? `Pro ${Math.max(expiryInfo?.daysLeft || 0, 0)}d left`
|
||||
: `Pro 还剩 ${Math.max(expiryInfo?.daysLeft || 0, 0)} 天`;
|
||||
|
||||
|
||||
return (
|
||||
<header className="header">
|
||||
<div className="brand">
|
||||
<span className="brand-mark" aria-hidden="true">
|
||||
<Image src="/favicon-32x32.png" alt="" width={24} height={24} priority />
|
||||
</span>
|
||||
<h1>PolyWeather</h1>
|
||||
<span className="subtitle">{t("header.subtitle")}</span>
|
||||
</div>
|
||||
|
||||
<nav
|
||||
className="header-nav"
|
||||
aria-label={locale === "en-US" ? "Primary" : "主导航"}
|
||||
>
|
||||
{navItems.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={clsx("header-nav-link", item.active && "active")}
|
||||
>
|
||||
<span>{item.label}</span>
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="header-right">
|
||||
<button
|
||||
type="button"
|
||||
className="locale-switch"
|
||||
aria-label={locale === "en-US" ? "Switch to Chinese" : "切换到英文"}
|
||||
title={locale === "en-US" ? "Switch to Chinese" : "切换到英文"}
|
||||
onClick={toggleLocale}
|
||||
>
|
||||
<span className={clsx(locale === "zh-CN" && "active")}>中文</span>
|
||||
<span className={clsx(locale === "en-US" && "active")}>EN</span>
|
||||
</button>
|
||||
|
||||
<div className="live-badge" id="liveBadge">
|
||||
<span className="pulse-dot" />
|
||||
<span>{t("header.live")}</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={clsx("refresh-btn", isRefreshing && "spinning")}
|
||||
title={t("header.refreshAria")}
|
||||
aria-label={t("header.refreshAria")}
|
||||
onClick={handleRefresh}
|
||||
>
|
||||
<RotateCw size={16} strokeWidth={2} />
|
||||
</button>
|
||||
|
||||
|
||||
<Link
|
||||
href={docsHref}
|
||||
className={clsx("header-utility-btn", docsActive && "active")}
|
||||
title={t("header.docsAria")}
|
||||
aria-label={t("header.docsAria")}
|
||||
>
|
||||
<BookOpen size={14} strokeWidth={2} />
|
||||
<span>{t("header.docs")}</span>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href={accountHref}
|
||||
className="header-utility-btn"
|
||||
title={accountAria}
|
||||
aria-label={accountAria}
|
||||
>
|
||||
{isAuthenticated ? <UserRound size={14} /> : <LogIn size={14} />}
|
||||
</Link>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="header-utility-btn more"
|
||||
aria-label={locale === "en-US" ? "More actions" : "更多操作"}
|
||||
title={locale === "en-US" ? "More actions" : "更多操作"}
|
||||
>
|
||||
<MoreHorizontal size={16} strokeWidth={2} />
|
||||
</button>
|
||||
|
||||
{showRenewReminder ? (
|
||||
<Link
|
||||
href="/account"
|
||||
className={clsx(
|
||||
"account-renew-badge",
|
||||
!proAccess.subscriptionActive && "expired",
|
||||
)}
|
||||
title={renewReminderLabel}
|
||||
aria-label={renewReminderLabel}
|
||||
>
|
||||
<span>{renewReminderLabel}</span>
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
"use client";
|
||||
|
||||
import { CSSProperties, useEffect, useRef } from "react";
|
||||
import { usePrefersReducedMotion } from "@/hooks/usePrefersReducedMotion";
|
||||
|
||||
export interface IntradaySignalMetric {
|
||||
key: string;
|
||||
label: string;
|
||||
value: string;
|
||||
hint: string;
|
||||
fill: number | null;
|
||||
tone: string;
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
function getToneColor(tone: string) {
|
||||
if (tone === "cyan") return "#4DA3FF";
|
||||
if (tone === "blue") return "#60a5fa";
|
||||
if (tone === "amber") return "#f59e0b";
|
||||
return "#9FB2C7";
|
||||
}
|
||||
|
||||
function hexToRgba(hex: string, alpha: number) {
|
||||
const sanitized = hex.replace("#", "");
|
||||
const numeric = Number.parseInt(sanitized.padEnd(6, "0"), 16);
|
||||
const r = (numeric >> 16) & 255;
|
||||
const g = (numeric >> 8) & 255;
|
||||
const b = numeric & 255;
|
||||
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
||||
}
|
||||
|
||||
/* ── Draw a rounded-rect path (polyfill-safe) ── */
|
||||
|
||||
function roundRect(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
w: number,
|
||||
h: number,
|
||||
r: number | [number, number, number, number],
|
||||
) {
|
||||
const corners = typeof r === "number" ? [r, r, r, r] : r;
|
||||
const [tl, tr, br, bl] = corners;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + tl, y);
|
||||
ctx.lineTo(x + w - tr, y);
|
||||
ctx.quadraticCurveTo(x + w, y, x + w, y + tr);
|
||||
ctx.lineTo(x + w, y + h - br);
|
||||
ctx.quadraticCurveTo(x + w, y + h, x + w - br, y + h);
|
||||
ctx.lineTo(x + bl, y + h);
|
||||
ctx.quadraticCurveTo(x, y + h, x, y + h - bl);
|
||||
ctx.lineTo(x, y + tl);
|
||||
ctx.quadraticCurveTo(x, y, x + tl, y);
|
||||
ctx.closePath();
|
||||
}
|
||||
|
||||
/* ── Component ── */
|
||||
|
||||
export function IntradaySignalScene({
|
||||
metrics,
|
||||
score,
|
||||
}: {
|
||||
metrics: IntradaySignalMetric[];
|
||||
score: number;
|
||||
}) {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const prefersReducedMotion = usePrefersReducedMotion();
|
||||
|
||||
useEffect(() => {
|
||||
const host = containerRef.current;
|
||||
if (!host) return;
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.style.cssText = "width:100%;height:100%;display:block;";
|
||||
host.appendChild(canvas);
|
||||
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
let animationId = 0;
|
||||
|
||||
const resize = () => {
|
||||
const rect = host.getBoundingClientRect();
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 1.5);
|
||||
canvas.width = rect.width * dpr;
|
||||
canvas.height = rect.height * dpr;
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
};
|
||||
|
||||
resize();
|
||||
|
||||
const observer = new ResizeObserver(resize);
|
||||
observer.observe(host);
|
||||
|
||||
let startTime = performance.now();
|
||||
|
||||
const render = (now: number) => {
|
||||
animationId = requestAnimationFrame(render);
|
||||
|
||||
const W = host.clientWidth;
|
||||
const H = host.clientHeight;
|
||||
if (W < 1 || H < 1) return;
|
||||
|
||||
ctx.clearRect(0, 0, W, H);
|
||||
|
||||
const elapsed = prefersReducedMotion ? 0 : (now - startTime) / 1000;
|
||||
|
||||
/* ── Layout ── */
|
||||
const cx = W / 2;
|
||||
const floorY = Math.max(H - 28, H * 0.78);
|
||||
const rx = clamp(Math.min(W / 2 - 24, 160), 60, 160);
|
||||
const ry = clamp(rx * 0.28, 12, 26);
|
||||
|
||||
const isPositive = score >= 0;
|
||||
const accentColor = isPositive ? "#4DA3FF" : "#F59E0B";
|
||||
const floorGlowColor = isPositive ? "#0e7490" : "#b45309";
|
||||
const floorBaseColor = isPositive ? "#10263b" : "#2c1d12";
|
||||
|
||||
/* ── Floor ── */
|
||||
const floorGrad = ctx.createRadialGradient(cx, floorY, 0, cx, floorY, rx);
|
||||
floorGrad.addColorStop(0, hexToRgba(floorGlowColor, 0.55));
|
||||
floorGrad.addColorStop(0.5, floorBaseColor);
|
||||
floorGrad.addColorStop(1, "#060e1a");
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(cx, floorY, rx, ry, 0, 0, Math.PI * 2);
|
||||
ctx.fillStyle = floorGrad;
|
||||
ctx.fill();
|
||||
|
||||
/* Floor glow overlay */
|
||||
const floorGlow = ctx.createRadialGradient(cx, floorY, 0, cx, floorY, rx * 0.6);
|
||||
floorGlow.addColorStop(0, hexToRgba(accentColor, 0.08));
|
||||
floorGlow.addColorStop(1, "transparent");
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(cx, floorY, rx * 0.6, ry * 0.6, 0, 0, Math.PI * 2);
|
||||
ctx.fillStyle = floorGlow;
|
||||
ctx.fill();
|
||||
|
||||
/* ── Ring (oscillating opacity) ── */
|
||||
const ringOpacity = 0.38 + Math.sin(elapsed * 0.8) * 0.1;
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(cx, floorY, rx - 10, Math.max(ry - 3, 8), 0, 0, Math.PI * 2);
|
||||
ctx.strokeStyle = hexToRgba(accentColor, ringOpacity);
|
||||
ctx.lineWidth = 2.5;
|
||||
ctx.stroke();
|
||||
|
||||
/* ── Bar positions ── */
|
||||
const barSpacing = rx * 0.48;
|
||||
const xPositions = [
|
||||
cx - barSpacing,
|
||||
cx - barSpacing / 3,
|
||||
cx + barSpacing / 3,
|
||||
cx + barSpacing,
|
||||
];
|
||||
const barWidth = Math.min(26, rx * 0.16);
|
||||
const limitedMetrics = metrics.slice(0, 4);
|
||||
|
||||
/* ── Draw bars ── */
|
||||
limitedMetrics.forEach((metric, i) => {
|
||||
const fill = metric.fill ?? 20;
|
||||
const height = Math.max(12, 14 + (fill / 100) * (floorY - 55));
|
||||
const bx = xPositions[i];
|
||||
const by = floorY - height;
|
||||
const color = getToneColor(metric.tone);
|
||||
const baseGlow = prefersReducedMotion
|
||||
? 1
|
||||
: 1 + Math.sin(elapsed * 1.2 + i) * 0.06;
|
||||
|
||||
/* Base glow ellipse */
|
||||
const glowGrad = ctx.createRadialGradient(bx, floorY, 0, bx, floorY, barWidth * 1.2);
|
||||
glowGrad.addColorStop(0, hexToRgba(color, 0.35));
|
||||
glowGrad.addColorStop(1, "transparent");
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(bx, floorY, barWidth * baseGlow, 7 * baseGlow, 0, 0, Math.PI * 2);
|
||||
ctx.fillStyle = glowGrad;
|
||||
ctx.fill();
|
||||
|
||||
/* Bar body (rounded top) */
|
||||
const barGrad = ctx.createLinearGradient(bx, by, bx, floorY);
|
||||
barGrad.addColorStop(0, color);
|
||||
barGrad.addColorStop(0.55, color);
|
||||
barGrad.addColorStop(1, hexToRgba(color, 0.3));
|
||||
roundRect(ctx, bx - barWidth / 2, by, barWidth, height, [4, 4, 0, 0]);
|
||||
ctx.fillStyle = barGrad;
|
||||
ctx.fill();
|
||||
|
||||
/* Bar glow outline */
|
||||
ctx.strokeStyle = hexToRgba(color, 0.15);
|
||||
ctx.lineWidth = 1;
|
||||
roundRect(ctx, bx - barWidth / 2 - 1, by - 1, barWidth + 2, height + 2, [5, 5, 0, 0]);
|
||||
ctx.stroke();
|
||||
|
||||
/* Cap (pulse offset per bar) */
|
||||
const pulse = prefersReducedMotion
|
||||
? 0
|
||||
: Math.sin(elapsed * 1.5 + i * 0.8) * 3;
|
||||
const capY = by + pulse;
|
||||
const capR = Math.max(5, barWidth * 0.22);
|
||||
|
||||
/* Cap outer glow */
|
||||
const capGlow = ctx.createRadialGradient(bx, capY, 0, bx, capY, capR * 3);
|
||||
capGlow.addColorStop(0, hexToRgba(color, 0.2));
|
||||
capGlow.addColorStop(1, "transparent");
|
||||
ctx.beginPath();
|
||||
ctx.arc(bx, capY, capR * 3, 0, Math.PI * 2);
|
||||
ctx.fillStyle = capGlow;
|
||||
ctx.fill();
|
||||
|
||||
/* Cap body */
|
||||
ctx.beginPath();
|
||||
ctx.arc(bx, capY, capR, 0, Math.PI * 2);
|
||||
ctx.fillStyle = color;
|
||||
ctx.shadowColor = color;
|
||||
ctx.shadowBlur = capR * 2;
|
||||
ctx.fill();
|
||||
ctx.shadowBlur = 0;
|
||||
});
|
||||
|
||||
/* ── Ambient sparkle dots (only when not reduced motion) ── */
|
||||
if (!prefersReducedMotion) {
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const angle = elapsed * 0.25 + i * 1.05;
|
||||
const dist = rx * 0.7 + Math.sin(elapsed * 0.15 + i * 2.3) * 10;
|
||||
const sx = cx + Math.cos(angle) * dist;
|
||||
const sy = floorY - ry * 0.4 + Math.sin(angle) * dist * 0.35;
|
||||
const sparkleSize = 1.5 + Math.sin(elapsed * 2 + i * 1.7) * 0.8;
|
||||
ctx.beginPath();
|
||||
ctx.arc(sx, sy, Math.max(0.5, sparkleSize), 0, Math.PI * 2);
|
||||
ctx.fillStyle = hexToRgba(accentColor, 0.12 + Math.sin(elapsed * 1.3 + i) * 0.06);
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
animationId = requestAnimationFrame(render);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
cancelAnimationFrame(animationId);
|
||||
if (canvas.parentNode === host) {
|
||||
host.removeChild(canvas);
|
||||
}
|
||||
};
|
||||
}, [metrics, prefersReducedMotion, score]);
|
||||
|
||||
return (
|
||||
<div className="intraday-scene-shell">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="intraday-scene-frame"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="intraday-scene-legend">
|
||||
{metrics.slice(0, 4).map((metric) => (
|
||||
<div key={metric.key} className="intraday-scene-chip">
|
||||
<span
|
||||
className="intraday-scene-chip-dot"
|
||||
style={{ backgroundColor: getToneColor(metric.tone) } as CSSProperties}
|
||||
/>
|
||||
<div className="intraday-scene-chip-copy">
|
||||
<strong>{metric.label}</strong>
|
||||
<span>
|
||||
{metric.value} · {metric.hint}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import "leaflet/dist/leaflet.css";
|
||||
|
||||
import { useDashboardStore } from "@/hooks/useDashboardStore";
|
||||
import { useLeafletMap } from "@/hooks/useLeafletMap";
|
||||
|
||||
export function MapCanvas({
|
||||
onCitySelect,
|
||||
selectionMode = "focus",
|
||||
}: {
|
||||
onCitySelect?: (cityName: string) => void;
|
||||
selectionMode?: "focus" | "select";
|
||||
} = {}) {
|
||||
const store = useDashboardStore();
|
||||
const { containerRef } = useLeafletMap({
|
||||
cities: store.cities,
|
||||
cityDetailsByName: store.cityDetailsByName,
|
||||
citySummariesByName: store.citySummariesByName,
|
||||
onClosePanel: store.closePanel,
|
||||
onEnsureCityDetail: store.ensureCityDetail,
|
||||
onMapInteractionChange: store.setMapInteractionActive,
|
||||
onRegisterStopMotion: store.registerMapStopMotion,
|
||||
onSelectCity: (cityName) => {
|
||||
onCitySelect?.(cityName);
|
||||
if (selectionMode === "select") {
|
||||
void store.selectCity(cityName);
|
||||
return;
|
||||
}
|
||||
void store.focusCity(cityName);
|
||||
},
|
||||
selectedCity: store.selectedCity,
|
||||
selectedDetail: store.selectedDetail,
|
||||
suspendMotion: Boolean(store.futureModalDate),
|
||||
isLoadingDetail: store.loadingState.cityDetail,
|
||||
});
|
||||
|
||||
return <div ref={containerRef} className="map" />;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
.root :global(.modal-title-stack) {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.root :global(.modal-overline) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--text-muted);
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.root :global(.modal-overline-sep) {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.root :global(.modal-subtitle) {
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.root :global(.modal-header-meta) {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.root :global(.modal-meta-pill) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 28px;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(34, 211, 238, 0.08);
|
||||
color: var(--text-secondary);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.root :global(.modal-callout) {
|
||||
margin-bottom: 16px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.16);
|
||||
background: rgba(255, 255, 255, 0.035);
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.root :global(.modal-callout-info) {
|
||||
border-color: rgba(56, 189, 248, 0.24);
|
||||
background: rgba(14, 165, 233, 0.08);
|
||||
}
|
||||
|
||||
.root :global(.modal-section-heading) {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.root :global(.modal-section-kicker) {
|
||||
color: var(--accent-cyan);
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.root :global(.modal-section-note) {
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,450 @@
|
||||
"use client";
|
||||
|
||||
function getTempExtremeClass(temp: number | null | undefined, symbol?: string | null) {
|
||||
if (temp == null || !Number.isFinite(temp)) return "";
|
||||
const isF = String(symbol || "").toUpperCase().includes("F");
|
||||
if (isF ? temp >= 104 : temp >= 40) return "temp-extreme-hot";
|
||||
if (isF ? temp <= 23 : temp <= -5) return "temp-extreme-cold";
|
||||
return "";
|
||||
}
|
||||
|
||||
import type { ChartConfiguration } from "chart.js";
|
||||
import clsx from "clsx";
|
||||
import { startTransition, useMemo } from "react";
|
||||
import { useChart } from "@/hooks/useChart";
|
||||
import {
|
||||
useCityData,
|
||||
useCityDetails,
|
||||
useDashboardModal,
|
||||
} from "@/hooks/useDashboardStore";
|
||||
import { useI18n } from "@/hooks/useI18n";
|
||||
import { CityDetail } from "@/lib/dashboard-types";
|
||||
import { getTemperatureChartData } from "@/lib/chart-utils";
|
||||
import {
|
||||
normalizeObservationSourceCode,
|
||||
normalizeObservationSourceLabel,
|
||||
} from "@/lib/source-labels";
|
||||
import {
|
||||
getHeroMetaItems,
|
||||
getRiskBadgeLabel,
|
||||
getWeatherSummary,
|
||||
} from "@/lib/weather-summary-utils";
|
||||
|
||||
function EmptyState({ text }: { text: string }) {
|
||||
return (
|
||||
<div style={{ color: "var(--text-muted)", fontSize: "13px" }}>{text}</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 settlementSourceCode = normalizeObservationSourceCode(
|
||||
current.settlement_source || "metar",
|
||||
);
|
||||
const settlementIcao = String(current.station_code || data.risk?.icao || "")
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
const settlementSource =
|
||||
settlementSourceCode === "metar" && settlementIcao
|
||||
? `${settlementIcao} METAR`
|
||||
: normalizeObservationSourceLabel(
|
||||
current.settlement_source_label || current.settlement_source,
|
||||
"METAR",
|
||||
).toUpperCase();
|
||||
const isMax =
|
||||
current.max_so_far != null &&
|
||||
current.temp != null &&
|
||||
current.max_so_far <= current.temp;
|
||||
const currentObsText =
|
||||
current.temp != null
|
||||
? `${current.temp}${data.temp_symbol} @${current.obs_time || "--"}`
|
||||
: data.metar_status?.stale_for_today
|
||||
? locale === "en-US"
|
||||
? "No same-day METAR"
|
||||
: "今日暂无 METAR"
|
||||
: "--";
|
||||
|
||||
return (
|
||||
<section className="hero-section">
|
||||
<div className="hero-weather">
|
||||
<span>
|
||||
{weatherIcon} {weatherText}
|
||||
</span>
|
||||
</div>
|
||||
<div className={`hero-temp ${getTempExtremeClass(current.temp, data.temp_symbol)}`}>
|
||||
<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">{currentObsText}</span>
|
||||
</div>
|
||||
<div className="hero-item">
|
||||
<span className="label">
|
||||
{locale === "en-US"
|
||||
? `${settlementSource} Anchor`
|
||||
: `${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(77, 163, 255, 0.06)",
|
||||
borderColor: "rgba(77, 163, 255, 0.66)",
|
||||
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(77, 163, 255, 0.36)",
|
||||
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: "#4DA3FF",
|
||||
borderColor: "#4DA3FF",
|
||||
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(77, 163, 255, 0.22)",
|
||||
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(77, 163, 255, 0.28)",
|
||||
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: "#6B7A90",
|
||||
maxRotation: 0,
|
||||
},
|
||||
},
|
||||
y: {
|
||||
grid: { color: "rgba(255,255,255,0.04)" },
|
||||
max: chartData.max,
|
||||
min: chartData.min,
|
||||
ticks: {
|
||||
callback: (value) =>
|
||||
`${Number(value).toFixed(chartData.yTickStep < 1 ? 1 : 0)}${data.temp_symbol || "°C"}`,
|
||||
color: "#6B7A90",
|
||||
stepSize: chartData.yTickStep,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
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 { ProbabilityDistribution } from "./ProbabilityDistribution";
|
||||
|
||||
export { ModelForecast } from "./ModelForecast";
|
||||
|
||||
export function ForecastTable() {
|
||||
const modal = useDashboardModal();
|
||||
const { loadingState } = useCityDetails();
|
||||
const { data } = useCityData();
|
||||
const { locale, t } = useI18n();
|
||||
const daily = useMemo(() => {
|
||||
if (!data) return [];
|
||||
const rawDaily = Array.isArray(data.forecast?.daily)
|
||||
? data.forecast?.daily || []
|
||||
: [];
|
||||
const seen = new Set<string>();
|
||||
return rawDaily.filter((day) => {
|
||||
const date = String(day?.date || "").trim();
|
||||
if (!date || seen.has(date)) return false;
|
||||
seen.add(date);
|
||||
return true;
|
||||
});
|
||||
}, [data]);
|
||||
if (!data) return null;
|
||||
const isSparseDaily = daily.length <= 1;
|
||||
const isForecastCompleting =
|
||||
loadingState.cityDetail &&
|
||||
(data.detail_depth !== "full" || isSparseDaily);
|
||||
const resolveForecastTemp = (
|
||||
date: string,
|
||||
fallback: number | null | undefined,
|
||||
) => {
|
||||
const debPrediction = data.multi_model_daily?.[date]?.deb?.prediction;
|
||||
return debPrediction ?? fallback ?? null;
|
||||
};
|
||||
return (
|
||||
<section className="forecast-section">
|
||||
<h3>{t("forecast.title")}</h3>
|
||||
{isSparseDaily && (
|
||||
<div className="forecast-inline-note">
|
||||
{isForecastCompleting
|
||||
? locale === "en-US"
|
||||
? "Multi-day forecast is syncing. Only the current-day card has arrived."
|
||||
: "多日预报同步中,当前只到达当日卡片。"
|
||||
: locale === "en-US"
|
||||
? "Only the current-day forecast is available right now."
|
||||
: "当前只收到当日预报,其他日期结果暂未回传。"}
|
||||
</div>
|
||||
)}
|
||||
<div className="forecast-table">
|
||||
{daily.length === 0 ? (
|
||||
<EmptyState text={t("forecast.empty")} />
|
||||
) : (
|
||||
daily
|
||||
.map((day, index) => {
|
||||
const isToday = data.local_date
|
||||
? day.date === data.local_date
|
||||
: index === 0;
|
||||
const isSelected =
|
||||
(isToday &&
|
||||
modal.forecastModalMode === "today" &&
|
||||
Boolean(modal.futureModalDate)) ||
|
||||
(modal.forecastModalMode !== "today" &&
|
||||
modal.futureModalDate === day.date) ||
|
||||
modal.selectedForecastDate === day.date;
|
||||
return (
|
||||
<button
|
||||
key={day.date}
|
||||
type="button"
|
||||
className={clsx(
|
||||
"forecast-day",
|
||||
isToday && "today",
|
||||
isSelected && "selected",
|
||||
)}
|
||||
onClick={() => {
|
||||
startTransition(() => {
|
||||
if (isToday) {
|
||||
modal.openTodayModal();
|
||||
return;
|
||||
}
|
||||
modal.openFutureModal(day.date);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<div className="f-date">
|
||||
{isToday
|
||||
? t("forecast.today")
|
||||
: day.date.substring(5).replace("-", "/")}
|
||||
</div>
|
||||
<div className="f-temp">
|
||||
{resolveForecastTemp(day.date, day.max_temp)}
|
||||
{data.temp_symbol}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
.concat(
|
||||
isForecastCompleting
|
||||
? Array.from({ length: Math.max(0, 5 - daily.length) }).map(
|
||||
(_, index) => (
|
||||
<button
|
||||
key={`forecast-sync-${index}`}
|
||||
type="button"
|
||||
className="forecast-day forecast-day-sync"
|
||||
disabled
|
||||
>
|
||||
<div className="f-date">
|
||||
{locale === "en-US" ? "Syncing" : "同步中"}
|
||||
</div>
|
||||
<div className="f-temp">--</div>
|
||||
</button>
|
||||
),
|
||||
)
|
||||
: [],
|
||||
)
|
||||
)}
|
||||
</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,7 @@
|
||||
"use client";
|
||||
|
||||
import { ScanTerminalDashboard } from "@/components/dashboard/ScanTerminalDashboard";
|
||||
|
||||
export function PolyWeatherDashboard() {
|
||||
return <ScanTerminalDashboard />;
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useI18n } from "@/hooks/useI18n";
|
||||
import { useProAccess } from "@/hooks/useDashboardStore";
|
||||
import { UnlockProOverlay } from "@/components/subscription/UnlockProOverlay";
|
||||
import { trackAppEvent } from "@/lib/app-analytics";
|
||||
|
||||
const TELEGRAM_GROUP_URL = String(
|
||||
process.env.NEXT_PUBLIC_TELEGRAM_GROUP_URL ||
|
||||
"https://t.me/+Io5H9oVHFmVjOTQ5",
|
||||
).trim();
|
||||
const SUBSCRIPTION_HELP_HREF = "/subscription-help";
|
||||
|
||||
type ProFeaturePaywallProps = {
|
||||
feature: "today" | "history" | "future" | "assistant" | "scan";
|
||||
onClose?: () => void;
|
||||
};
|
||||
|
||||
export function ProFeaturePaywall({
|
||||
feature,
|
||||
onClose,
|
||||
}: ProFeaturePaywallProps) {
|
||||
const router = useRouter();
|
||||
const { locale } = useI18n();
|
||||
const { proAccess } = useProAccess();
|
||||
const [usePoints, setUsePoints] = useState(true);
|
||||
|
||||
const isEn = locale === "en-US";
|
||||
const isAuthenticated = proAccess.authenticated;
|
||||
const pointsAvailable = Number(proAccess.points || 0);
|
||||
|
||||
const PRO_PRICE_USD = 10;
|
||||
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";
|
||||
|
||||
useEffect(() => {
|
||||
trackAppEvent("paywall_viewed", {
|
||||
entry: "feature_gate",
|
||||
feature,
|
||||
user_state: isAuthenticated ? "logged_in" : "guest",
|
||||
});
|
||||
}, [feature, isAuthenticated]);
|
||||
|
||||
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,955 @@
|
||||
"use client";
|
||||
|
||||
import clsx from "clsx";
|
||||
import { useMemo } from "react";
|
||||
import { useI18n } from "@/hooks/useI18n";
|
||||
import {
|
||||
CityDetail,
|
||||
MarketScan,
|
||||
MarketTopBucket,
|
||||
ProbabilityBucket,
|
||||
} from "@/lib/dashboard-types";
|
||||
import { getModelView, getProbabilityView } from "@/lib/model-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 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 isFahrenheitSymbol(symbol?: string | null) {
|
||||
return String(symbol || "")
|
||||
.toUpperCase()
|
||||
.includes("F");
|
||||
}
|
||||
|
||||
function displayTempToMarketCelsius(
|
||||
value: number | null,
|
||||
detail: Pick<CityDetail, "temp_symbol">,
|
||||
) {
|
||||
if (value == null || !Number.isFinite(value)) return null;
|
||||
if (isFahrenheitSymbol(detail.temp_symbol)) {
|
||||
return ((value - 32) * 5) / 9;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function formatBucketDisplayLabel(
|
||||
bucket: ProbabilityBucket,
|
||||
detail: Pick<CityDetail, "temp_symbol">,
|
||||
) {
|
||||
let bucketLabel = bucket.label || `${bucket.value}${detail.temp_symbol}`;
|
||||
if (!bucketLabel) return "";
|
||||
let str = String(bucketLabel).toUpperCase().replace(/\s+/g, "");
|
||||
const symbol = detail.temp_symbol || "°C";
|
||||
if (isFahrenheitSymbol(symbol)) {
|
||||
str = str.replace(/℃/g, "°F").replace(/°C/g, "°F");
|
||||
} else {
|
||||
str = str.replace(/℃/g, "°C").replace(/°F/g, "°C");
|
||||
}
|
||||
str = str.replace(/°?C($|\+|-)/g, "°C$1");
|
||||
str = str.replace(/°?F($|\+|-)/g, "°F$1");
|
||||
if (!/[°℃][CF]/.test(str) && /[0-9]/.test(str)) {
|
||||
str += symbol;
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
function getMarketBucketUnit(bucket?: MarketTopBucket | null) {
|
||||
return String(bucket?.unit || "").toUpperCase();
|
||||
}
|
||||
|
||||
function isMarketBucketAbove(bucket?: MarketTopBucket | null) {
|
||||
const text =
|
||||
`${bucket?.label || ""} ${bucket?.slug || ""} ${bucket?.question || ""}`
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, "");
|
||||
return (
|
||||
text.includes("+") ||
|
||||
text.includes("orhigher") ||
|
||||
text.includes("or-higher")
|
||||
);
|
||||
}
|
||||
|
||||
function isMarketBucketBelow(bucket?: MarketTopBucket | null) {
|
||||
const text =
|
||||
`${bucket?.label || ""} ${bucket?.slug || ""} ${bucket?.question || ""}`
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, "");
|
||||
return (
|
||||
text.includes("<=") || text.includes("orlower") || text.includes("or-lower")
|
||||
);
|
||||
}
|
||||
|
||||
function findMarketBucketForDisplayTemp(
|
||||
buckets: MarketTopBucket[],
|
||||
displayTemp: number | null,
|
||||
detail: Pick<CityDetail, "temp_symbol">,
|
||||
) {
|
||||
if (displayTemp == null || !Number.isFinite(displayTemp)) return null;
|
||||
|
||||
let best: MarketTopBucket | null = null;
|
||||
let bestDelta = Number.POSITIVE_INFINITY;
|
||||
for (const bucket of buckets) {
|
||||
const bucketUnit = String(bucket.unit || "").toUpperCase();
|
||||
const compareTemp =
|
||||
bucketUnit === "F"
|
||||
? displayTemp
|
||||
: displayTempToMarketCelsius(displayTemp, detail);
|
||||
if (compareTemp == null) continue;
|
||||
|
||||
const lower = bucket.lower != null ? Number(bucket.lower) : null;
|
||||
const upper = bucket.upper != null ? Number(bucket.upper) : null;
|
||||
if (
|
||||
lower != null &&
|
||||
upper != null &&
|
||||
Number.isFinite(lower) &&
|
||||
Number.isFinite(upper) &&
|
||||
compareTemp >= lower - 0.01 &&
|
||||
compareTemp <= upper + 0.01
|
||||
) {
|
||||
return bucket;
|
||||
}
|
||||
|
||||
const rawTemp = bucket.temp ?? bucket.value ?? null;
|
||||
if (rawTemp == null) continue;
|
||||
const candidateTemp = Number(rawTemp);
|
||||
if (!Number.isFinite(candidateTemp)) continue;
|
||||
const delta = Math.abs(candidateTemp - compareTemp);
|
||||
if (delta < bestDelta) {
|
||||
best = bucket;
|
||||
bestDelta = delta;
|
||||
}
|
||||
}
|
||||
const tolerance = isFahrenheitSymbol(detail.temp_symbol) ? 0.56 : 0.26;
|
||||
return best && bestDelta <= tolerance ? best : null;
|
||||
}
|
||||
|
||||
function marketBucketContainsDisplayTemp(
|
||||
bucket: MarketTopBucket | null,
|
||||
displayTemp: number | null,
|
||||
detail: Pick<CityDetail, "temp_symbol">,
|
||||
) {
|
||||
if (!bucket || displayTemp == null || !Number.isFinite(displayTemp))
|
||||
return false;
|
||||
|
||||
const bucketUnit = getMarketBucketUnit(bucket);
|
||||
const compareTemp =
|
||||
bucketUnit === "F"
|
||||
? displayTemp
|
||||
: displayTempToMarketCelsius(displayTemp, detail);
|
||||
if (compareTemp == null) return false;
|
||||
|
||||
const lower = bucket.lower != null ? Number(bucket.lower) : null;
|
||||
const upper = bucket.upper != null ? Number(bucket.upper) : null;
|
||||
if (lower != null && !Number.isFinite(lower)) return false;
|
||||
if (upper != null && !Number.isFinite(upper)) return false;
|
||||
|
||||
if (lower != null && upper != null) {
|
||||
return compareTemp >= lower - 0.01 && compareTemp <= upper + 0.01;
|
||||
}
|
||||
if (lower != null && isMarketBucketAbove(bucket)) {
|
||||
return compareTemp >= lower - 0.01;
|
||||
}
|
||||
if (lower != null && isMarketBucketBelow(bucket)) {
|
||||
return compareTemp <= lower + 0.01;
|
||||
}
|
||||
const reference = bucket.temp ?? bucket.value ?? lower;
|
||||
const numeric = reference != null ? Number(reference) : null;
|
||||
if (numeric == null || !Number.isFinite(numeric)) return false;
|
||||
const tolerance = bucketUnit === "F" ? 0.56 : 0.26;
|
||||
return Math.abs(compareTemp - numeric) <= tolerance;
|
||||
}
|
||||
|
||||
function getAggregatedModelProbabilityForMarketBucket(
|
||||
probabilities: ProbabilityBucket[],
|
||||
bucket: MarketTopBucket | null,
|
||||
detail: Pick<CityDetail, "temp_symbol">,
|
||||
) {
|
||||
if (!bucket) return null;
|
||||
|
||||
let total = 0;
|
||||
let matched = 0;
|
||||
for (const probabilityBucket of probabilities) {
|
||||
const temp = getBucketTemp(probabilityBucket);
|
||||
if (!marketBucketContainsDisplayTemp(bucket, temp, detail)) continue;
|
||||
const probability = Number(probabilityBucket.probability);
|
||||
if (!Number.isFinite(probability)) continue;
|
||||
total += probability;
|
||||
matched += 1;
|
||||
}
|
||||
|
||||
return matched > 0 ? Math.max(0, Math.min(1, total)) : null;
|
||||
}
|
||||
|
||||
type ProbabilityDisplayRow = {
|
||||
key: string;
|
||||
label: string;
|
||||
probability: number;
|
||||
marketBucket?: MarketTopBucket | null;
|
||||
};
|
||||
|
||||
function formatMarketBucketDisplayLabel(
|
||||
bucket: MarketTopBucket,
|
||||
detail: Pick<CityDetail, "temp_symbol">,
|
||||
) {
|
||||
const label = String(bucket.label || "").trim();
|
||||
if (label) {
|
||||
const unit = getMarketBucketUnit(bucket);
|
||||
let normalized = label.toUpperCase().replace(/\s+/g, "");
|
||||
if (unit === "F" || isFahrenheitSymbol(detail.temp_symbol)) {
|
||||
normalized = normalized
|
||||
.replace(/ORHIGHER/g, "+")
|
||||
.replace(/ORLOWER/g, "-")
|
||||
.replace(/℃/g, "°F")
|
||||
.replace(/°C/g, "°F")
|
||||
.replace(/(?<=\d)F/g, "°F");
|
||||
} else {
|
||||
normalized = normalized
|
||||
.replace(/ORHIGHER/g, "+")
|
||||
.replace(/ORLOWER/g, "-")
|
||||
.replace(/℃/g, "°C")
|
||||
.replace(/°F/g, "°C")
|
||||
.replace(/(?<=\d)C/g, "°C");
|
||||
}
|
||||
return normalized.replace(/\+/g, "+");
|
||||
}
|
||||
|
||||
const unit =
|
||||
getMarketBucketUnit(bucket) === "F" ||
|
||||
isFahrenheitSymbol(detail.temp_symbol)
|
||||
? "°F"
|
||||
: "°C";
|
||||
const lower = bucket.lower != null ? Number(bucket.lower) : null;
|
||||
const upper = bucket.upper != null ? Number(bucket.upper) : null;
|
||||
if (
|
||||
lower != null &&
|
||||
upper != null &&
|
||||
Number.isFinite(lower) &&
|
||||
Number.isFinite(upper)
|
||||
) {
|
||||
return `${lower}-${upper}${unit}`;
|
||||
}
|
||||
const value = bucket.value ?? bucket.temp ?? lower;
|
||||
const numeric = value != null ? Number(value) : null;
|
||||
if (numeric != null && Number.isFinite(numeric)) {
|
||||
return isMarketBucketAbove(bucket)
|
||||
? `${numeric}${unit}+`
|
||||
: `${numeric}${unit}`;
|
||||
}
|
||||
return "--";
|
||||
}
|
||||
|
||||
type ModelMetadata = NonNullable<
|
||||
NonNullable<CityDetail["source_forecasts"]>["open_meteo_multi_model"]
|
||||
>["model_metadata"];
|
||||
|
||||
|
||||
function normalizeModelNameForVote(name: string) {
|
||||
return String(name || "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[\s_/-]/g, "");
|
||||
}
|
||||
|
||||
function getModelVoteFamily(name: string) {
|
||||
const normalized = normalizeModelNameForVote(name);
|
||||
if (["icon", "iconeu", "icond2"].includes(normalized)) return "dwd_icon";
|
||||
if (["gem", "gdps", "rdps", "hrdps"].includes(normalized)) return "eccc_gem";
|
||||
if (["ecmwfaifs", "aifs"].includes(normalized)) return "ecmwf_aifs";
|
||||
if (normalized === "ecmwf") return "ecmwf_ifs";
|
||||
return normalized || name;
|
||||
}
|
||||
|
||||
function getModelVotePriority(name: string) {
|
||||
const normalized = normalizeModelNameForVote(name);
|
||||
return (
|
||||
{
|
||||
icond2: 40,
|
||||
iconeu: 30,
|
||||
icon: 20,
|
||||
hrdps: 40,
|
||||
rdps: 35,
|
||||
gdps: 30,
|
||||
gem: 20,
|
||||
ecmwfaifs: 30,
|
||||
ecmwf: 30,
|
||||
gfs: 30,
|
||||
jma: 30,
|
||||
mgm: 45,
|
||||
nws: 45,
|
||||
openmeteo: 15,
|
||||
}[normalized] || 10
|
||||
);
|
||||
}
|
||||
|
||||
function getRoundedModelVoteDistribution(
|
||||
detail: CityDetail,
|
||||
targetDate?: string | null,
|
||||
) {
|
||||
const view = getModelView(detail, targetDate);
|
||||
const representatives = new Map<
|
||||
string,
|
||||
{ name: string; priority: number; value: number }
|
||||
>();
|
||||
|
||||
Object.entries(view.models || {}).forEach(([name, rawValue]) => {
|
||||
const normalized = normalizeModelNameForVote(name);
|
||||
const value = Number(rawValue);
|
||||
if (!Number.isFinite(value)) return;
|
||||
const family = getModelVoteFamily(name);
|
||||
const priority = getModelVotePriority(name);
|
||||
const current = representatives.get(family);
|
||||
if (!current || priority > current.priority) {
|
||||
representatives.set(family, { name, priority, value });
|
||||
}
|
||||
});
|
||||
|
||||
const bucketMap = new Map<number, { count: number; models: string[] }>();
|
||||
representatives.forEach(({ name, value }) => {
|
||||
const rounded = Math.round(value);
|
||||
const row = bucketMap.get(rounded) || { count: 0, models: [] };
|
||||
row.count += 1;
|
||||
row.models.push(name);
|
||||
bucketMap.set(rounded, row);
|
||||
});
|
||||
|
||||
const total = representatives.size;
|
||||
const rows = Array.from(bucketMap.entries())
|
||||
.map(([value, row]) => ({
|
||||
count: row.count,
|
||||
models: row.models,
|
||||
percent: total > 0 ? row.count / total : 0,
|
||||
value,
|
||||
}))
|
||||
.sort((a, b) => b.count - a.count || b.value - a.value);
|
||||
|
||||
return {
|
||||
rows,
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
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 normalizeSignedProbability(value?: number | null) {
|
||||
if (value == null) return null;
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric)) return null;
|
||||
if (Math.abs(numeric) > 1) return numeric / 100;
|
||||
return numeric;
|
||||
}
|
||||
|
||||
function formatSignedPercent(value?: number | null, digits = 1) {
|
||||
const normalized = normalizeSignedProbability(value);
|
||||
if (normalized == null) return "--";
|
||||
const percent = normalized * 100;
|
||||
const sign = percent > 0 ? "+" : "";
|
||||
return `${sign}${percent.toFixed(digits)}%`;
|
||||
}
|
||||
|
||||
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 getMarketAllBuckets(scan?: MarketScan | null) {
|
||||
const buckets = Array.isArray(scan?.all_buckets)
|
||||
? scan.all_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 || "")}`;
|
||||
}
|
||||
|
||||
function hasLgbmModel(detail: CityDetail, targetDate?: string | null) {
|
||||
const view = getModelView(detail, targetDate);
|
||||
return Object.keys(view.models || {}).some((name) =>
|
||||
normalizeModelNameForVote(name).includes("lgbm"),
|
||||
);
|
||||
}
|
||||
|
||||
function formatProbabilityEngineLabel(
|
||||
detail: CityDetail,
|
||||
targetDate: string | null | undefined,
|
||||
locale: string,
|
||||
) {
|
||||
const view = getProbabilityView(detail, targetDate);
|
||||
if (hasLgbmModel(detail, targetDate)) {
|
||||
return locale === "en-US" ? "LGBM-calibrated probability" : "LGBM 校准概率";
|
||||
}
|
||||
const engine = String(view.engine || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const calibrationMode = String(view.calibrationMode || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (engine === "emos" || calibrationMode.includes("emos")) {
|
||||
return locale === "en-US" ? "EMOS-calibrated probability" : "EMOS 校准概率";
|
||||
}
|
||||
return locale === "en-US" ? "Model probability" : "模型概率";
|
||||
}
|
||||
|
||||
|
||||
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 modelView = getModelView(detail, targetDate);
|
||||
const marketYesPrice = getMarketYesPrice(marketScan);
|
||||
const marketYesText = toPercent(marketYesPrice);
|
||||
const isToday = !targetDate || targetDate === detail.local_date;
|
||||
const probabilityEngineLabel = formatProbabilityEngineLabel(
|
||||
detail,
|
||||
targetDate,
|
||||
locale,
|
||||
);
|
||||
const hasLgbmProbability = hasLgbmModel(detail, targetDate);
|
||||
const modelVoteView = useMemo(
|
||||
() => getRoundedModelVoteDistribution(detail, targetDate),
|
||||
[detail, targetDate],
|
||||
);
|
||||
const modelVoteHint = modelVoteView.rows
|
||||
.slice(0, 2)
|
||||
.map(
|
||||
(row) =>
|
||||
`${row.value}${detail.temp_symbol} ${row.count}/${modelVoteView.total}`,
|
||||
)
|
||||
.join(" · ");
|
||||
const marketTopBuckets = isToday ? getMarketTopBuckets(marketScan) : [];
|
||||
const marketAllBuckets = isToday ? getMarketAllBuckets(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);
|
||||
const topProbability = [...(view.probabilities || [])].sort(
|
||||
(a, b) => Number(b.probability || 0) - Number(a.probability || 0),
|
||||
)[0];
|
||||
const topProbabilityText = toPercent(topProbability?.probability);
|
||||
const topProbabilityLabel = topProbability
|
||||
? formatBucketDisplayLabel(topProbability, detail)
|
||||
: null;
|
||||
const topProbabilityTemp = topProbability
|
||||
? getBucketTemp(topProbability)
|
||||
: null;
|
||||
const probabilitiesForMarketContracts =
|
||||
view.probabilitiesAll?.length > 0
|
||||
? view.probabilitiesAll
|
||||
: view.probabilities || [];
|
||||
const marketContractRows = useMemo<ProbabilityDisplayRow[]>(() => {
|
||||
if (!isToday || !marketScan?.available || marketAllBuckets.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const rows: ProbabilityDisplayRow[] = [];
|
||||
const seenKeys = new Set<string>();
|
||||
for (const marketBucket of marketAllBuckets) {
|
||||
const probability = getAggregatedModelProbabilityForMarketBucket(
|
||||
probabilitiesForMarketContracts,
|
||||
marketBucket,
|
||||
detail,
|
||||
);
|
||||
|
||||
const key =
|
||||
marketBucket.slug ||
|
||||
marketBucket.label ||
|
||||
`${marketBucket.lower ?? marketBucket.value ?? marketBucket.temp}-${marketBucket.upper ?? ""}`;
|
||||
if (seenKeys.has(key)) continue;
|
||||
seenKeys.add(key);
|
||||
rows.push({
|
||||
key,
|
||||
label: formatMarketBucketDisplayLabel(marketBucket, detail),
|
||||
probability: probability ?? 0,
|
||||
marketBucket,
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}, [
|
||||
detail,
|
||||
isToday,
|
||||
marketAllBuckets,
|
||||
marketScan?.available,
|
||||
probabilitiesForMarketContracts,
|
||||
]);
|
||||
const modelProbabilityRows = useMemo<ProbabilityDisplayRow[]>(
|
||||
() =>
|
||||
(view.probabilities || []).slice(0, 6).map((bucket, index) => {
|
||||
const bucketTemp = getBucketTemp(bucket);
|
||||
return {
|
||||
key: `${bucket.label || bucket.value || index}`,
|
||||
label: formatBucketDisplayLabel(bucket, detail),
|
||||
probability: Number(bucket.probability || 0),
|
||||
marketBucket: findMarketBucketForDisplayTemp(
|
||||
marketAllBuckets,
|
||||
bucketTemp,
|
||||
detail,
|
||||
),
|
||||
};
|
||||
}),
|
||||
[detail, marketAllBuckets, view.probabilities],
|
||||
);
|
||||
const probabilityRows =
|
||||
marketContractRows.length > 0
|
||||
? marketContractRows.slice(0, 8)
|
||||
: modelProbabilityRows;
|
||||
const topContractRow =
|
||||
marketContractRows.length > 0
|
||||
? marketContractRows.reduce((best, row) =>
|
||||
row.probability > best.probability ? row : best,
|
||||
)
|
||||
: null;
|
||||
const displayTopLabel = topContractRow?.label || topProbabilityLabel || null;
|
||||
const displayTopProbability =
|
||||
topContractRow?.probability ??
|
||||
(topProbability?.probability != null
|
||||
? Number(topProbability.probability)
|
||||
: null);
|
||||
const displayTopProbabilityText = toPercent(displayTopProbability);
|
||||
const displayUsesMarketBuckets = marketContractRows.length > 0;
|
||||
const linkedMarketBucket = useMemo(() => {
|
||||
if (topContractRow?.marketBucket) return topContractRow.marketBucket;
|
||||
if (topProbabilityTemp == null) return null;
|
||||
return findMarketBucketForDisplayTemp(
|
||||
marketAllBuckets,
|
||||
topProbabilityTemp,
|
||||
detail,
|
||||
);
|
||||
}, [detail, marketAllBuckets, topContractRow, topProbabilityTemp]);
|
||||
const priceAnalysis = marketScan?.price_analysis;
|
||||
const yesPriceView = priceAnalysis?.yes;
|
||||
const noPriceView = priceAnalysis?.no;
|
||||
const linkedMarketAsk =
|
||||
linkedMarketBucket?.yes_buy ??
|
||||
linkedMarketBucket?.market_price ??
|
||||
yesPriceView?.ask ??
|
||||
null;
|
||||
const linkedNoAsk = linkedMarketBucket?.no_buy ?? noPriceView?.ask ?? null;
|
||||
const linkedContractLabel =
|
||||
topContractRow?.label ||
|
||||
(linkedMarketBucket
|
||||
? formatMarketBucketDisplayLabel(linkedMarketBucket, detail)
|
||||
: null) ||
|
||||
topProbabilityLabel ||
|
||||
null;
|
||||
const aggregatedMarketProbability =
|
||||
getAggregatedModelProbabilityForMarketBucket(
|
||||
probabilitiesForMarketContracts,
|
||||
linkedMarketBucket,
|
||||
detail,
|
||||
);
|
||||
const linkedMarketProbability =
|
||||
topContractRow?.probability ??
|
||||
aggregatedMarketProbability ??
|
||||
(topProbability?.probability != null
|
||||
? Number(topProbability.probability)
|
||||
: null);
|
||||
const linkedMarketProbabilityText = toPercent(linkedMarketProbability);
|
||||
const linkedMarketEdge =
|
||||
linkedMarketProbability != null && linkedMarketAsk != null
|
||||
? linkedMarketProbability - Number(linkedMarketAsk)
|
||||
: null;
|
||||
const linkedNoEdge =
|
||||
linkedMarketProbability != null && linkedNoAsk != null
|
||||
? 1 - linkedMarketProbability - Number(linkedNoAsk)
|
||||
: null;
|
||||
const linkedBestSide =
|
||||
linkedMarketBucket && linkedNoEdge != null && linkedMarketEdge != null
|
||||
? linkedNoEdge > linkedMarketEdge
|
||||
? "no"
|
||||
: "yes"
|
||||
: null;
|
||||
const linkedBestAsk = linkedBestSide === "no" ? linkedNoAsk : linkedMarketAsk;
|
||||
const linkedBestEdge =
|
||||
linkedBestSide === "no" ? linkedNoEdge : linkedMarketEdge;
|
||||
const preferredPriceView = linkedMarketBucket
|
||||
? {
|
||||
ask: linkedBestAsk,
|
||||
edge: linkedBestEdge,
|
||||
}
|
||||
: priceAnalysis?.best_side === "no"
|
||||
? noPriceView
|
||||
: yesPriceView;
|
||||
const preferredSideLabel = linkedMarketBucket
|
||||
? linkedBestSide === "no"
|
||||
? "NO"
|
||||
: "YES"
|
||||
: priceAnalysis?.best_side === "no"
|
||||
? locale === "en-US"
|
||||
? "NO"
|
||||
: "NO"
|
||||
: locale === "en-US"
|
||||
? "YES"
|
||||
: "YES";
|
||||
const yesDisplayPrice = linkedMarketBucket
|
||||
? linkedMarketAsk
|
||||
: yesPriceView?.ask;
|
||||
const noDisplayPrice = linkedMarketBucket ? linkedNoAsk : noPriceView?.ask;
|
||||
const yesDisplayEdge = linkedMarketBucket
|
||||
? linkedMarketEdge
|
||||
: yesPriceView?.edge;
|
||||
const noDisplayEdge = linkedMarketBucket ? linkedNoEdge : noPriceView?.edge;
|
||||
const hasPriceAnalysis =
|
||||
isToday &&
|
||||
(Boolean(priceAnalysis?.available) ||
|
||||
Boolean(marketScan) ||
|
||||
Boolean(topProbability));
|
||||
const lockEdge = normalizeSignedProbability(priceAnalysis?.lock?.edge);
|
||||
const lockAvailable = Boolean(
|
||||
priceAnalysis?.lock?.available && lockEdge != null,
|
||||
);
|
||||
const quoteSource =
|
||||
linkedMarketBucket?.quote_source ||
|
||||
marketScan?.yes_token?.quote_source ||
|
||||
marketScan?.no_token?.quote_source ||
|
||||
null;
|
||||
const quoteAgeMs =
|
||||
linkedMarketBucket?.quote_age_ms ??
|
||||
marketScan?.yes_token?.quote_age_ms ??
|
||||
marketScan?.no_token?.quote_age_ms;
|
||||
const quoteSourceLabel =
|
||||
quoteSource === "polymarket_ws"
|
||||
? locale === "en-US"
|
||||
? `WS live${quoteAgeMs != null ? ` · ${Math.max(0, Math.round(Number(quoteAgeMs) / 1000))}s` : ""}`
|
||||
: `WS 实时${quoteAgeMs != null ? ` · ${Math.max(0, Math.round(Number(quoteAgeMs) / 1000))}秒` : ""}`
|
||||
: locale === "en-US"
|
||||
? "CLOB fallback"
|
||||
: "CLOB 兜底";
|
||||
const actionableEdge = normalizeSignedProbability(preferredPriceView?.edge);
|
||||
const linkedContractOverpriced =
|
||||
Boolean(linkedMarketBucket) &&
|
||||
linkedBestSide === "no" &&
|
||||
linkedMarketProbability != null &&
|
||||
linkedMarketAsk != null &&
|
||||
linkedMarketEdge != null &&
|
||||
linkedMarketEdge < 0 &&
|
||||
linkedNoEdge != null &&
|
||||
linkedNoEdge > 0;
|
||||
const linkedContractOverpay =
|
||||
linkedContractOverpriced &&
|
||||
linkedMarketProbability != null &&
|
||||
linkedMarketAsk != null
|
||||
? Number(linkedMarketAsk) - linkedMarketProbability
|
||||
: null;
|
||||
const actionText = !marketScan
|
||||
? locale === "en-US"
|
||||
? "Waiting"
|
||||
: "等待"
|
||||
: !marketScan.available
|
||||
? locale === "en-US"
|
||||
? "No market"
|
||||
: "无盘口"
|
||||
: actionableEdge == null
|
||||
? locale === "en-US"
|
||||
? "No quote"
|
||||
: "无报价"
|
||||
: actionableEdge >= 0.02
|
||||
? linkedContractOverpriced
|
||||
? locale === "en-US"
|
||||
? "Overpriced"
|
||||
: "市场偏贵"
|
||||
: locale === "en-US"
|
||||
? `Watch ${preferredSideLabel}`
|
||||
: `可关注 ${preferredSideLabel}`
|
||||
: actionableEdge > 0
|
||||
? linkedContractOverpriced
|
||||
? locale === "en-US"
|
||||
? "Slightly overpriced"
|
||||
: "略偏贵"
|
||||
: locale === "en-US"
|
||||
? `Small ${preferredSideLabel}`
|
||||
: `${preferredSideLabel} 优势较小`
|
||||
: locale === "en-US"
|
||||
? "No clear edge"
|
||||
: "暂无优势";
|
||||
const actionNote =
|
||||
linkedContractOverpriced && linkedContractOverpay != null
|
||||
? locale === "en-US"
|
||||
? `YES above model by ${formatSignedPercent(linkedContractOverpay)}`
|
||||
: `YES 高于模型 ${formatSignedPercent(linkedContractOverpay)}`
|
||||
: actionableEdge != null && actionableEdge >= 0.02
|
||||
? locale === "en-US"
|
||||
? `${formatSignedPercent(actionableEdge)} vs ask`
|
||||
: `相对买价 ${formatSignedPercent(actionableEdge)}`
|
||||
: locale === "en-US"
|
||||
? `${preferredSideLabel} ${formatSignedPercent(actionableEdge)}`
|
||||
: `${preferredSideLabel} ${formatSignedPercent(actionableEdge)}`;
|
||||
|
||||
return (
|
||||
<section className="prob-section">
|
||||
{!hideTitle && <h3>{t("section.probability")}</h3>}
|
||||
<div className="prob-bars">
|
||||
<div className="prob-calibration-head">
|
||||
<div>
|
||||
<span className="prob-source-chip">{probabilityEngineLabel}</span>
|
||||
<strong>
|
||||
{displayTopLabel && displayTopProbabilityText
|
||||
? locale === "en-US"
|
||||
? displayUsesMarketBuckets
|
||||
? `${displayTopLabel} is the top displayed contract bucket at ${displayTopProbabilityText}`
|
||||
: `${displayTopLabel} is the top single bucket at ${displayTopProbabilityText}`
|
||||
: displayUsesMarketBuckets
|
||||
? `${displayTopLabel} 为当前显示分布最高,${displayTopProbabilityText}`
|
||||
: `${displayTopLabel} 单点最高,${displayTopProbabilityText}`
|
||||
: locale === "en-US"
|
||||
? "Awaiting calibrated buckets"
|
||||
: "等待校准概率桶"}
|
||||
</strong>
|
||||
</div>
|
||||
<p>
|
||||
{hasLgbmProbability
|
||||
? locale === "en-US"
|
||||
? "LGBM is the learned intraday adjustment; raw model points below are only diagnostic."
|
||||
: "LGBM 作为日内学习校准项;下方原始模型落点仅用于诊断。"
|
||||
: locale === "en-US"
|
||||
? "Using the calibrated probability distribution; raw model points below are not probabilities."
|
||||
: "使用校准后的概率分布;下方原始模型落点不是概率。"}
|
||||
</p>
|
||||
</div>
|
||||
{marketScan?.available && (topMarketBucketText || marketYesText) && (
|
||||
<div
|
||||
style={{
|
||||
color: "var(--text-secondary)",
|
||||
fontSize: "11px",
|
||||
marginBottom: "6px",
|
||||
}}
|
||||
>
|
||||
{useMarketTopBuckets
|
||||
? locale === "en-US"
|
||||
? `Market reference only: top traded bucket ${topMarketBucketText}`
|
||||
: `市场仅作参考:最高交易温度桶 ${topMarketBucketText}`
|
||||
: locale === "en-US"
|
||||
? `Market reference only: this bucket ${marketYesText}`
|
||||
: `市场仅作参考:该温度桶 ${marketYesText}`}
|
||||
</div>
|
||||
)}
|
||||
<div className="prob-distribution-panel">
|
||||
<div className="prob-distribution-head">
|
||||
<span>
|
||||
{locale === "en-US"
|
||||
? "Probability distribution"
|
||||
: "概率分布"}
|
||||
</span>
|
||||
<em>
|
||||
{marketContractRows.length > 0
|
||||
? locale === "en-US"
|
||||
? "market buckets are aggregated from single-degree probability buckets"
|
||||
: "市场合约桶由单点概率分布聚合"
|
||||
: locale === "en-US"
|
||||
? "temperature probability buckets"
|
||||
: "温度概率桶"}
|
||||
</em>
|
||||
</div>
|
||||
{probabilityRows.length === 0 ? (
|
||||
<EmptyState text={t("section.noProb")} />
|
||||
) : (
|
||||
probabilityRows.map((row, index) => {
|
||||
const probability = Math.round(
|
||||
Number(row.probability || 0) * 100,
|
||||
);
|
||||
|
||||
return (
|
||||
<div key={`${row.key || index}`} className="prob-row">
|
||||
<div className="prob-label">{row.label}</div>
|
||||
<div className="prob-bar-track">
|
||||
<div
|
||||
className={clsx("prob-bar-fill", `rank-${index}`)}
|
||||
style={{ width: `${Math.max(probability, 8)}%` }}
|
||||
>
|
||||
{probability}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
{hasPriceAnalysis && (
|
||||
<div className="prob-price-card">
|
||||
<div className="prob-price-head">
|
||||
<span>
|
||||
{locale === "en-US" ? "Win-rate reference" : "胜率参考"}
|
||||
</span>
|
||||
<strong>
|
||||
{!marketScan
|
||||
? locale === "en-US"
|
||||
? "Waiting for market context"
|
||||
: "等待市场参照"
|
||||
: !marketScan.available
|
||||
? locale === "en-US"
|
||||
? "No matched active market"
|
||||
: "未匹配到活跃盘口"
|
||||
: locale === "en-US"
|
||||
? `${linkedContractLabel || topProbabilityLabel || "Temperature bucket"} · model ${linkedMarketProbabilityText || topProbabilityText || "--"}`
|
||||
: `${linkedContractLabel || topProbabilityLabel || "温度桶"} · 模型 ${linkedMarketProbabilityText || topProbabilityText || "--"}`}
|
||||
</strong>
|
||||
</div>
|
||||
<div className="prob-price-grid">
|
||||
<div>
|
||||
<span>
|
||||
{locale === "en-US" ? "Bucket" : "温度桶"}
|
||||
</span>
|
||||
<strong>
|
||||
{linkedContractLabel || topProbabilityLabel || "--"}
|
||||
</strong>
|
||||
<em>
|
||||
{linkedMarketProbabilityText || topProbabilityText || "--"}
|
||||
</em>
|
||||
</div>
|
||||
<div>
|
||||
<span>{locale === "en-US" ? "DEB" : "DEB"}</span>
|
||||
<strong>
|
||||
{modelView.deb != null && Number.isFinite(Number(modelView.deb))
|
||||
? `${Number(modelView.deb).toFixed(1)}${detail.temp_symbol}`
|
||||
: "--"}
|
||||
</strong>
|
||||
<em>{locale === "en-US" ? "final fused forecast" : "最终融合预测"}</em>
|
||||
</div>
|
||||
<div>
|
||||
<span>{locale === "en-US" ? "Model support" : "模型支持"}</span>
|
||||
<strong>{modelVoteHint || "--"}</strong>
|
||||
<em>{locale === "en-US" ? "raw model agreement" : "原始模型一致性"}</em>
|
||||
</div>
|
||||
<div>
|
||||
<span>{locale === "en-US" ? "Market role" : "盘口角色"}</span>
|
||||
<strong>{locale === "en-US" ? "Reference only" : "仅作参考"}</strong>
|
||||
<em>{quoteSourceLabel}</em>
|
||||
</div>
|
||||
</div>
|
||||
<p>
|
||||
{locale === "en-US"
|
||||
? "This card follows the same rule as AI forecast: DEB first, model agreement second, METAR conflict check before settlement."
|
||||
: "该卡片与 AI 预测口径一致:先看 DEB,再看模型支持,最后检查 METAR 是否冲突。"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{modelVoteHint && (
|
||||
<div className="prob-model-hint">
|
||||
<span>
|
||||
{locale === "en-US" ? "Raw model points" : "原始模型落点"}
|
||||
</span>
|
||||
<strong>{modelVoteHint}</strong>
|
||||
<em>
|
||||
{locale === "en-US"
|
||||
? "diagnostic only; EMOS and contract rows use calibrated probabilities"
|
||||
: "仅作诊断;EMOS 与合约行使用校准概率"}
|
||||
</em>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -4,19 +4,7 @@ import { useEffect } from "react";
|
||||
|
||||
export function RegisterSW() {
|
||||
useEffect(() => {
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
if (typeof navigator !== "undefined" && "serviceWorker" in navigator) {
|
||||
navigator.serviceWorker
|
||||
.getRegistrations()
|
||||
.then((registrations) =>
|
||||
Promise.all(registrations.map((registration) => registration.unregister())),
|
||||
)
|
||||
.catch(() => {});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof navigator !== "undefined" && "serviceWorker" in navigator) {
|
||||
if ("serviceWorker" in navigator) {
|
||||
navigator.serviceWorker.register("/sw.js").catch(() => {});
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import {
|
||||
CircleDot,
|
||||
Clock3,
|
||||
Info,
|
||||
TrendingUp,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { useI18n } from "@/hooks/useI18n";
|
||||
import type { ScanTerminalFilters } from "@/lib/dashboard-types";
|
||||
|
||||
export interface FilterState extends ScanTerminalFilters {}
|
||||
|
||||
const SCAN_MODES = [
|
||||
{
|
||||
key: "tradable" as const,
|
||||
icon: Zap,
|
||||
labelEn: "Tradable",
|
||||
labelZh: "可交易机会",
|
||||
descEn: "Find the best immediate trade",
|
||||
descZh: "发现当前最值得交易的市场",
|
||||
},
|
||||
{
|
||||
key: "early" as const,
|
||||
icon: Clock3,
|
||||
labelEn: "Early",
|
||||
labelZh: "早期机会",
|
||||
descEn: "Long-horizon, lower-priced setups",
|
||||
descZh: "长时间布局,低价市场",
|
||||
},
|
||||
{
|
||||
key: "touch" as const,
|
||||
icon: CircleDot,
|
||||
labelEn: "Touch Play",
|
||||
labelZh: "触达博弈",
|
||||
descEn: "Markets approaching the settle line",
|
||||
descZh: "接近决策,博弈是否触达",
|
||||
},
|
||||
{
|
||||
key: "trend" as const,
|
||||
icon: TrendingUp,
|
||||
labelEn: "Trend",
|
||||
labelZh: "趋势确认",
|
||||
descEn: "Trend-confirmed follow-through",
|
||||
descZh: "趋势明朗,顺势交易",
|
||||
},
|
||||
] as const;
|
||||
|
||||
export function ScanFilterPanel({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: FilterState;
|
||||
onChange?: (filters: FilterState) => void;
|
||||
}) {
|
||||
const { locale } = useI18n();
|
||||
const isEn = locale === "en-US";
|
||||
|
||||
const updateFilter = <K extends keyof FilterState>(
|
||||
key: K,
|
||||
nextValue: FilterState[K],
|
||||
) => {
|
||||
onChange?.({
|
||||
...value,
|
||||
[key]: nextValue,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<aside className="scan-filter-panel">
|
||||
<div className="scan-sidebar-brand">
|
||||
<div>
|
||||
<div className="scan-sidebar-brand-name">PolyWeather</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="scan-filter-section">
|
||||
<div className="scan-filter-heading">
|
||||
<span>{isEn ? "Scan Mode" : "扫描模式"}</span>
|
||||
<Info size={14} />
|
||||
</div>
|
||||
<div className="scan-mode-tabs" role="tablist" aria-label={isEn ? "Scan mode" : "扫描模式"}>
|
||||
{SCAN_MODES.map((mode) => {
|
||||
const Icon = mode.icon;
|
||||
const isActive = value.scan_mode === mode.key;
|
||||
return (
|
||||
<button
|
||||
key={mode.key}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
className={`scan-mode-tab ${isActive ? "active" : ""}`}
|
||||
onClick={() => updateFilter("scan_mode", mode.key)}
|
||||
>
|
||||
<span className="scan-mode-icon">
|
||||
<Icon size={16} />
|
||||
</span>
|
||||
<span className="scan-mode-copy">
|
||||
<span className="scan-mode-tab-label">
|
||||
{isEn ? mode.labelEn : mode.labelZh}
|
||||
</span>
|
||||
<span className="scan-mode-tab-sub">
|
||||
{isEn ? mode.descEn : mode.descZh}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -714,55 +714,6 @@
|
||||
background: var(--bg-card);
|
||||
}
|
||||
|
||||
/* ── Segmented Control: Locale Switcher in Light Mode ── */
|
||||
.root :global(.scan-terminal.light .scan-locale-switch) {
|
||||
border-color: #cbd5e1 !important;
|
||||
background: #e2e8f0 !important;
|
||||
color: #64748b !important;
|
||||
}
|
||||
|
||||
.root :global(.scan-terminal.light .scan-locale-switch span) {
|
||||
color: #64748b !important;
|
||||
}
|
||||
|
||||
.root :global(.scan-terminal.light .scan-locale-switch span.active) {
|
||||
color: #0f172a !important;
|
||||
background: #ffffff !important;
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.08) !important;
|
||||
}
|
||||
|
||||
/* ── Status Chips and Manual Refresh button in Light Mode ── */
|
||||
.root :global(.scan-terminal.light .scan-status-chip) {
|
||||
border-color: #cbd5e1 !important;
|
||||
background: #ffffff !important;
|
||||
color: #475569 !important;
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05) !important;
|
||||
}
|
||||
|
||||
.root :global(.scan-terminal.light .scan-status-chip.live) {
|
||||
border-color: rgba(16, 185, 129, 0.22) !important;
|
||||
background: rgba(16, 185, 129, 0.12) !important;
|
||||
color: #065f46 !important;
|
||||
}
|
||||
|
||||
.root :global(.scan-terminal.light .scan-status-chip.stale) {
|
||||
border-color: rgba(245, 158, 11, 0.24) !important;
|
||||
background: rgba(245, 158, 11, 0.12) !important;
|
||||
color: #92400e !important;
|
||||
}
|
||||
|
||||
.root :global(.scan-terminal.light button.scan-status-chip.refresh) {
|
||||
border-color: #cbd5e1 !important;
|
||||
background: #ffffff !important;
|
||||
color: #1e293b !important;
|
||||
}
|
||||
|
||||
.root :global(.scan-terminal.light button.scan-status-chip.refresh:hover) {
|
||||
background: #f8fafc !important;
|
||||
border-color: #94a3b8 !important;
|
||||
}
|
||||
|
||||
|
||||
.root :global(.scan-terminal.light .scan-mode-tab.active) {
|
||||
color: #1D4ED8;
|
||||
background: #DBEAFE;
|
||||
@@ -863,7 +814,9 @@
|
||||
background: linear-gradient(180deg, rgba(224, 247, 255, 0.82), rgba(241, 248, 255, 0.92));
|
||||
}
|
||||
|
||||
.root :global(.scan-terminal.light .scan-locale-switch),
|
||||
.root :global(.scan-terminal.light .scan-ghost-button),
|
||||
.root :global(.scan-terminal.light .scan-theme-button),
|
||||
.root :global(.scan-terminal.light .scan-sort-pill),
|
||||
.root :global(.scan-terminal.light .scan-icon-pill),
|
||||
.root :global(.scan-terminal.light .scan-account-button),
|
||||
@@ -1268,128 +1221,128 @@
|
||||
|
||||
/* ── Leaflet map light overrides (needs !important to beat Leaflet inline styles) ── */
|
||||
.root :global(.map),
|
||||
:global(html.light) .root :global(.scan-terminal.light .scan-map-shell .map),
|
||||
:global(html.light) .root :global(.scan-terminal.light .scan-map-shell .leaflet-container),
|
||||
:global(html.light) .root :global(.leaflet-container),
|
||||
:global(html.light) .root :global(.leaflet-pane),
|
||||
:global(html.light) .root :global(.leaflet-map-pane),
|
||||
:global(html.light) .root :global(.leaflet-tile-pane) {
|
||||
html.light .root :global(.scan-terminal.light .scan-map-shell .map),
|
||||
html.light .root :global(.scan-terminal.light .scan-map-shell .leaflet-container),
|
||||
html.light .root :global(.leaflet-container),
|
||||
html.light .root :global(.leaflet-pane),
|
||||
html.light .root :global(.leaflet-map-pane),
|
||||
html.light .root :global(.leaflet-tile-pane) {
|
||||
background: #eef7ff !important;
|
||||
}
|
||||
|
||||
/* ── City detail rail light overrides ── */
|
||||
:global(html.light) .root :global(.scan-city-detail-rail),
|
||||
:global(html.light) .root :global(.scan-terminal.light > .detail-panel.scan-city-detail-rail) {
|
||||
html.light .root :global(.scan-city-detail-rail),
|
||||
html.light .root :global(.scan-terminal.light > .detail-panel.scan-city-detail-rail) {
|
||||
background: linear-gradient(180deg, rgba(238, 247, 255, 0.98), rgba(255, 255, 255, 0.96));
|
||||
border-color: rgba(37, 99, 235, 0.16);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
:global(html.light) .root :global(.scan-city-detail-rail .panel-header),
|
||||
:global(html.light) .root :global(.scan-city-detail-rail .detail-structured-section),
|
||||
:global(html.light) .root :global(.scan-city-detail-rail .detail-summary-shell),
|
||||
:global(html.light) .root :global(.scan-city-detail-rail .detail-card) {
|
||||
html.light .root :global(.scan-city-detail-rail .panel-header),
|
||||
html.light .root :global(.scan-city-detail-rail .detail-structured-section),
|
||||
html.light .root :global(.scan-city-detail-rail .detail-summary-shell),
|
||||
html.light .root :global(.scan-city-detail-rail .detail-card) {
|
||||
background: rgba(255, 255, 255, 0.74);
|
||||
border-color: rgba(125, 171, 214, 0.28);
|
||||
}
|
||||
|
||||
:global(html.light) .root :global(.scan-city-detail-rail h2),
|
||||
:global(html.light) .root :global(.scan-city-detail-rail h3),
|
||||
:global(html.light) .root :global(.scan-city-detail-rail strong),
|
||||
:global(html.light) .root :global(.scan-city-detail-rail .detail-value),
|
||||
:global(html.light) .root :global(.scan-city-detail-rail .detail-summary-temp),
|
||||
:global(html.light) .root :global(.scan-city-detail-rail .detail-section-head h3),
|
||||
:global(html.light) .root :global(.scan-city-detail-rail .panel-title-area h2) {
|
||||
html.light .root :global(.scan-city-detail-rail h2),
|
||||
html.light .root :global(.scan-city-detail-rail h3),
|
||||
html.light .root :global(.scan-city-detail-rail strong),
|
||||
html.light .root :global(.scan-city-detail-rail .detail-value),
|
||||
html.light .root :global(.scan-city-detail-rail .detail-summary-temp),
|
||||
html.light .root :global(.scan-city-detail-rail .detail-section-head h3),
|
||||
html.light .root :global(.scan-city-detail-rail .panel-title-area h2) {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
:global(html.light) .root :global(.scan-city-detail-rail p),
|
||||
:global(html.light) .root :global(.scan-city-detail-rail li),
|
||||
:global(html.light) .root :global(.scan-city-detail-rail small),
|
||||
:global(html.light) .root :global(.scan-city-detail-rail .panel-overline),
|
||||
:global(html.light) .root :global(.scan-city-detail-rail .panel-loading-hint),
|
||||
:global(html.light) .root :global(.scan-city-detail-rail .panel-meta-chip),
|
||||
:global(html.light) .root :global(.scan-city-detail-rail .detail-section-kicker),
|
||||
:global(html.light) .root :global(.scan-city-detail-rail .detail-label),
|
||||
:global(html.light) .root :global(.scan-city-detail-rail .detail-value-muted),
|
||||
:global(html.light) .root :global(.scan-city-detail-rail .detail-source-note),
|
||||
:global(html.light) .root :global(.scan-city-detail-rail .detail-source-kind),
|
||||
:global(html.light) .root :global(.scan-city-detail-rail .detail-mini-meta),
|
||||
:global(html.light) .root :global(.scan-city-detail-rail .detail-summary-supporting),
|
||||
:global(html.light) .root :global(.scan-city-detail-rail .scan-detail-city-sub),
|
||||
:global(html.light) .root :global(.scan-city-detail-rail .scan-detail-volume-caption),
|
||||
:global(html.light) .root :global(.scan-city-detail-rail .scan-detail-score-label),
|
||||
:global(html.light) .root :global(.scan-city-detail-rail .scan-detail-empty) {
|
||||
html.light .root :global(.scan-city-detail-rail p),
|
||||
html.light .root :global(.scan-city-detail-rail li),
|
||||
html.light .root :global(.scan-city-detail-rail small),
|
||||
html.light .root :global(.scan-city-detail-rail .panel-overline),
|
||||
html.light .root :global(.scan-city-detail-rail .panel-loading-hint),
|
||||
html.light .root :global(.scan-city-detail-rail .panel-meta-chip),
|
||||
html.light .root :global(.scan-city-detail-rail .detail-section-kicker),
|
||||
html.light .root :global(.scan-city-detail-rail .detail-label),
|
||||
html.light .root :global(.scan-city-detail-rail .detail-value-muted),
|
||||
html.light .root :global(.scan-city-detail-rail .detail-source-note),
|
||||
html.light .root :global(.scan-city-detail-rail .detail-source-kind),
|
||||
html.light .root :global(.scan-city-detail-rail .detail-mini-meta),
|
||||
html.light .root :global(.scan-city-detail-rail .detail-summary-supporting),
|
||||
html.light .root :global(.scan-city-detail-rail .scan-detail-city-sub),
|
||||
html.light .root :global(.scan-city-detail-rail .scan-detail-volume-caption),
|
||||
html.light .root :global(.scan-city-detail-rail .scan-detail-score-label),
|
||||
html.light .root :global(.scan-city-detail-rail .scan-detail-empty) {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
:global(html.light) .root :global(.scan-city-detail-rail .panel-meta-chip),
|
||||
:global(html.light) .root :global(.scan-city-detail-rail .panel-weather-chip),
|
||||
:global(html.light) .root :global(.scan-city-detail-rail .detail-source-link),
|
||||
:global(html.light) .root :global(.scan-city-detail-rail .panel-action-button-secondary),
|
||||
:global(html.light) .root :global(.scan-city-detail-rail .panel-action-button-ghost) {
|
||||
html.light .root :global(.scan-city-detail-rail .panel-meta-chip),
|
||||
html.light .root :global(.scan-city-detail-rail .panel-weather-chip),
|
||||
html.light .root :global(.scan-city-detail-rail .detail-source-link),
|
||||
html.light .root :global(.scan-city-detail-rail .panel-action-button-secondary),
|
||||
html.light .root :global(.scan-city-detail-rail .panel-action-button-ghost) {
|
||||
background: #eef7ff;
|
||||
border-color: rgba(37, 99, 235, 0.18);
|
||||
color: #1f3654;
|
||||
}
|
||||
|
||||
/* ── AI city card light overrides ── */
|
||||
:global(html.light) .root :global(.scan-terminal.light .scan-ai-city-card),
|
||||
:global(html.light) .root :global(.scan-terminal.light .scan-ai-city-section),
|
||||
:global(html.light) .root :global(.scan-terminal.light .scan-ai-decision-band),
|
||||
:global(html.light) .root :global(.scan-terminal.light .scan-ai-market-decision),
|
||||
:global(html.light) .root :global(.scan-terminal.light .scan-ai-decision-metrics span),
|
||||
:global(html.light) .root :global(.scan-terminal.light .scan-ai-city-pills span),
|
||||
:global(html.light) .root :global(.scan-terminal.light .scan-ai-city-freshness span),
|
||||
:global(html.light) .root :global(.scan-terminal.light .scan-ai-city-metrics > span),
|
||||
:global(html.light) .root :global(.scan-terminal.light .scan-mobile-decision-metrics span),
|
||||
:global(html.light) .root :global(.scan-terminal.light .scan-mobile-decision-reason) {
|
||||
html.light .root :global(.scan-terminal.light .scan-ai-city-card),
|
||||
html.light .root :global(.scan-terminal.light .scan-ai-city-section),
|
||||
html.light .root :global(.scan-terminal.light .scan-ai-decision-band),
|
||||
html.light .root :global(.scan-terminal.light .scan-ai-market-decision),
|
||||
html.light .root :global(.scan-terminal.light .scan-ai-decision-metrics span),
|
||||
html.light .root :global(.scan-terminal.light .scan-ai-city-pills span),
|
||||
html.light .root :global(.scan-terminal.light .scan-ai-city-freshness span),
|
||||
html.light .root :global(.scan-terminal.light .scan-ai-city-metrics > span),
|
||||
html.light .root :global(.scan-terminal.light .scan-mobile-decision-metrics span),
|
||||
html.light .root :global(.scan-terminal.light .scan-mobile-decision-reason) {
|
||||
background: #eef7ff;
|
||||
border-color: rgba(37, 99, 235, 0.16);
|
||||
}
|
||||
|
||||
:global(html.light) .root :global(.scan-terminal.light .scan-ai-city-metrics > span.primary) {
|
||||
html.light .root :global(.scan-terminal.light .scan-ai-city-metrics > span.primary) {
|
||||
background: #dbeafe;
|
||||
border-color: rgba(59, 130, 246, 0.34);
|
||||
}
|
||||
|
||||
:global(html.light) .root :global(.scan-terminal.light .scan-ai-city-metrics b) {
|
||||
html.light .root :global(.scan-terminal.light .scan-ai-city-metrics b) {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
:global(html.light) .root :global(.scan-terminal.light .scan-ai-city-metrics > span.primary b) {
|
||||
html.light .root :global(.scan-terminal.light .scan-ai-city-metrics > span.primary b) {
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
:global(html.light) .root :global(.scan-terminal.light .scan-ai-city-metrics small) {
|
||||
html.light .root :global(.scan-terminal.light .scan-ai-city-metrics small) {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
:global(html.light) .root :global(.scan-terminal.light .scan-ai-city-card p),
|
||||
:global(html.light) .root :global(.scan-terminal.light .scan-ai-city-card li),
|
||||
:global(html.light) .root :global(.scan-terminal.light .scan-ai-city-card small),
|
||||
:global(html.light) .root :global(.scan-terminal.light .scan-ai-city-card span:not(.scan-ai-city-kicker)) {
|
||||
html.light .root :global(.scan-terminal.light .scan-ai-city-card p),
|
||||
html.light .root :global(.scan-terminal.light .scan-ai-city-card li),
|
||||
html.light .root :global(.scan-terminal.light .scan-ai-city-card small),
|
||||
html.light .root :global(.scan-terminal.light .scan-ai-city-card span:not(.scan-ai-city-kicker)) {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
:global(html.light) .root :global(.scan-terminal.light .scan-ai-city-status-tag.green) {
|
||||
html.light .root :global(.scan-terminal.light .scan-ai-city-status-tag.green) {
|
||||
color: #047857;
|
||||
}
|
||||
|
||||
:global(html.light) .root :global(.scan-terminal.light .scan-ai-city-status-tag.blue) {
|
||||
html.light .root :global(.scan-terminal.light .scan-ai-city-status-tag.blue) {
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
:global(html.light) .root :global(.scan-terminal.light .scan-ai-city-status-tag.amber) {
|
||||
html.light .root :global(.scan-terminal.light .scan-ai-city-status-tag.amber) {
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
:global(html.light) .root :global(.scan-terminal.light .scan-ai-city-status-tag.red) {
|
||||
html.light .root :global(.scan-terminal.light .scan-ai-city-status-tag.red) {
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
/* ── Upgrade announcement light overrides ── */
|
||||
:global(html.light) .root :global(.scan-terminal.light .scan-upgrade-announcement) {
|
||||
html.light .root :global(.scan-terminal.light .scan-upgrade-announcement) {
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(37, 99, 235, 0.12), transparent 36%),
|
||||
var(--bg-card);
|
||||
@@ -1397,188 +1350,188 @@
|
||||
box-shadow: 0 16px 36px rgba(61, 100, 145, 0.12);
|
||||
}
|
||||
|
||||
:global(html.light) .root :global(.scan-terminal.light .scan-upgrade-announcement-copy strong),
|
||||
:global(html.light) .root :global(.scan-terminal.light .scan-ai-city-mobile-priority b),
|
||||
:global(html.light) .root :global(.scan-terminal.light .scan-mobile-decision-metrics b),
|
||||
:global(html.light) .root :global(.scan-terminal.light .scan-mobile-decision-reason),
|
||||
:global(html.light) .root :global(.scan-terminal.light .scan-mobile-decision-head h3),
|
||||
:global(html.light) .root :global(.scan-terminal.light .scan-ai-market-mobile-line b) {
|
||||
html.light .root :global(.scan-terminal.light .scan-upgrade-announcement-copy strong),
|
||||
html.light .root :global(.scan-terminal.light .scan-ai-city-mobile-priority b),
|
||||
html.light .root :global(.scan-terminal.light .scan-mobile-decision-metrics b),
|
||||
html.light .root :global(.scan-terminal.light .scan-mobile-decision-reason),
|
||||
html.light .root :global(.scan-terminal.light .scan-mobile-decision-head h3),
|
||||
html.light .root :global(.scan-terminal.light .scan-ai-market-mobile-line b) {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
:global(html.light) .root :global(.scan-terminal.light .scan-upgrade-announcement-copy p),
|
||||
:global(html.light) .root :global(.scan-terminal.light .scan-upgrade-announcement li),
|
||||
:global(html.light) .root :global(.scan-terminal.light .scan-ai-city-mobile-priority small),
|
||||
:global(html.light) .root :global(.scan-terminal.light .scan-mobile-decision-metrics small),
|
||||
:global(html.light) .root :global(.scan-terminal.light .scan-ai-market-mobile-line span) {
|
||||
html.light .root :global(.scan-terminal.light .scan-upgrade-announcement-copy p),
|
||||
html.light .root :global(.scan-terminal.light .scan-upgrade-announcement li),
|
||||
html.light .root :global(.scan-terminal.light .scan-ai-city-mobile-priority small),
|
||||
html.light .root :global(.scan-terminal.light .scan-mobile-decision-metrics small),
|
||||
html.light .root :global(.scan-terminal.light .scan-ai-market-mobile-line span) {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
:global(html.light) .root :global(.scan-terminal.light .scan-upgrade-announcement-copy span) {
|
||||
html.light .root :global(.scan-terminal.light .scan-upgrade-announcement-copy span) {
|
||||
color: #047857;
|
||||
}
|
||||
|
||||
:global(html.light) .root :global(.scan-terminal.light .scan-upgrade-announcement li),
|
||||
:global(html.light) .root :global(.scan-terminal.light .scan-ai-city-mobile-priority span),
|
||||
:global(html.light) .root :global(.scan-terminal.light .scan-ai-decision-why),
|
||||
:global(html.light) .root :global(.scan-terminal.light .scan-ai-market-mobile-line) {
|
||||
html.light .root :global(.scan-terminal.light .scan-upgrade-announcement li),
|
||||
html.light .root :global(.scan-terminal.light .scan-ai-city-mobile-priority span),
|
||||
html.light .root :global(.scan-terminal.light .scan-ai-decision-why),
|
||||
html.light .root :global(.scan-terminal.light .scan-ai-market-mobile-line) {
|
||||
background: #eef7ff;
|
||||
border-color: rgba(37, 99, 235, 0.16);
|
||||
}
|
||||
|
||||
:global(html.light) .root :global(.scan-terminal.light .scan-ai-decision-why) {
|
||||
html.light .root :global(.scan-terminal.light .scan-ai-decision-why) {
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
/* ── Section titles ── */
|
||||
:global(html.light) .root :global(.scan-terminal.light .scan-ai-city-section-title) {
|
||||
html.light .root :global(.scan-terminal.light .scan-ai-city-section-title) {
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
:global(html.light) .root :global(.scan-terminal.light .scan-ai-decision-band span) {
|
||||
html.light .root :global(.scan-terminal.light .scan-ai-decision-band span) {
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
:global(html.light) .root :global(.scan-terminal.light .scan-ai-decision-band p) {
|
||||
html.light .root :global(.scan-terminal.light .scan-ai-decision-band p) {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
:global(html.light) .root :global(.scan-terminal.light .scan-ai-decision-band strong) {
|
||||
html.light .root :global(.scan-terminal.light .scan-ai-decision-band strong) {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* ── Scrollbar light overrides ── */
|
||||
:global(html.light) .root :global(.scan-terminal.light .scan-ai-city-body) {
|
||||
html.light .root :global(.scan-terminal.light .scan-ai-city-body) {
|
||||
scrollbar-color: rgba(37, 99, 235, 0.18) transparent;
|
||||
}
|
||||
|
||||
:global(html.light) .root :global(.scan-ai-city-body::-webkit-scrollbar-thumb) {
|
||||
html.light .root :global(.scan-ai-city-body::-webkit-scrollbar-thumb) {
|
||||
background: rgba(37, 99, 235, 0.22);
|
||||
}
|
||||
|
||||
/* ── AI prediction dual-card light mode ── */
|
||||
:global(html.light) .root :global(.scan-terminal.light .scan-ai-prediction-card) {
|
||||
html.light .root :global(.scan-terminal.light .scan-ai-prediction-card) {
|
||||
background: var(--bg-card);
|
||||
border-color: var(--border-glass);
|
||||
}
|
||||
|
||||
:global(html.light) .root :global(.scan-terminal.light .scan-ai-prediction-card.ai) {
|
||||
html.light .root :global(.scan-terminal.light .scan-ai-prediction-card.ai) {
|
||||
background: linear-gradient(180deg, rgba(219, 234, 254, 0.52), var(--bg-card));
|
||||
border-color: rgba(14, 165, 233, 0.28);
|
||||
}
|
||||
|
||||
:global(html.light) .root :global(.scan-terminal.light .scan-ai-prediction-card.deb) {
|
||||
html.light .root :global(.scan-terminal.light .scan-ai-prediction-card.deb) {
|
||||
background: var(--bg-card);
|
||||
border-color: var(--border-glass);
|
||||
}
|
||||
|
||||
:global(html.light) .root :global(.scan-terminal.light .scan-ai-prediction-card small) {
|
||||
html.light .root :global(.scan-terminal.light .scan-ai-prediction-card small) {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
:global(html.light) .root :global(.scan-terminal.light .scan-ai-prediction-card strong) {
|
||||
html.light .root :global(.scan-terminal.light .scan-ai-prediction-card strong) {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
:global(html.light) .root :global(.scan-terminal.light .scan-ai-prediction-card.ai strong) {
|
||||
html.light .root :global(.scan-terminal.light .scan-ai-prediction-card.ai strong) {
|
||||
color: #0369a1;
|
||||
}
|
||||
|
||||
:global(html.light) .root :global(.scan-terminal.light .scan-ai-prediction-card em) {
|
||||
html.light .root :global(.scan-terminal.light .scan-ai-prediction-card em) {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
:global(html.light) .root :global(.scan-terminal.light .scan-ai-prediction-card.ai.pending) {
|
||||
html.light .root :global(.scan-terminal.light .scan-ai-prediction-card.ai.pending) {
|
||||
background: rgba(219, 234, 254, 0.28);
|
||||
border-color: rgba(14, 165, 233, 0.16);
|
||||
}
|
||||
|
||||
:global(html.light) .root :global(.scan-terminal.light .scan-ai-prediction-card.ai.pending strong) {
|
||||
html.light .root :global(.scan-terminal.light .scan-ai-prediction-card.ai.pending strong) {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
:global(html.light) .root :global(.scan-terminal.light .scan-ai-confidence) {
|
||||
html.light .root :global(.scan-terminal.light .scan-ai-confidence) {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
:global(html.light) .root :global(.scan-terminal.light .scan-ai-confidence.high) {
|
||||
html.light .root :global(.scan-terminal.light .scan-ai-confidence.high) {
|
||||
background: rgba(16, 185, 129, 0.12);
|
||||
color: #047857;
|
||||
}
|
||||
|
||||
:global(html.light) .root :global(.scan-terminal.light .scan-ai-confidence.medium) {
|
||||
html.light .root :global(.scan-terminal.light .scan-ai-confidence.medium) {
|
||||
background: rgba(245, 158, 11, 0.12);
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
:global(html.light) .root :global(.scan-terminal.light .scan-ai-confidence.low) {
|
||||
html.light .root :global(.scan-terminal.light .scan-ai-confidence.low) {
|
||||
background: rgba(148, 163, 184, 0.12);
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
:global(html.light) .root :global(.scan-terminal.light .scan-ai-confidence.neutral) {
|
||||
html.light .root :global(.scan-terminal.light .scan-ai-confidence.neutral) {
|
||||
background: rgba(148, 163, 184, 0.08);
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
/* ── Empty / Error / Retry light overrides ── */
|
||||
:global(html.light) .root :global(.scan-terminal.light .scan-empty-title),
|
||||
:global(html.light) .root :global(.scan-terminal.light .scan-error-title) {
|
||||
html.light .root :global(.scan-terminal.light .scan-empty-title),
|
||||
html.light .root :global(.scan-terminal.light .scan-error-title) {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
:global(html.light) .root :global(.scan-terminal.light .scan-empty-copy),
|
||||
:global(html.light) .root :global(.scan-terminal.light .scan-error-copy) {
|
||||
html.light .root :global(.scan-terminal.light .scan-empty-copy),
|
||||
html.light .root :global(.scan-terminal.light .scan-error-copy) {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
:global(html.light) .root :global(.scan-terminal.light .scan-empty-icon) {
|
||||
html.light .root :global(.scan-terminal.light .scan-empty-icon) {
|
||||
background: rgba(37, 99, 235, 0.08);
|
||||
border-color: rgba(37, 99, 235, 0.2);
|
||||
color: #2563EB;
|
||||
}
|
||||
|
||||
:global(html.light) .root :global(.scan-terminal.light .scan-error-icon) {
|
||||
html.light .root :global(.scan-terminal.light .scan-error-icon) {
|
||||
background: rgba(239, 68, 68, 0.06);
|
||||
border-color: rgba(239, 68, 68, 0.18);
|
||||
color: #DC2626;
|
||||
}
|
||||
|
||||
:global(html.light) .root :global(.scan-terminal.light .scan-retry-button) {
|
||||
html.light .root :global(.scan-terminal.light .scan-retry-button) {
|
||||
background: rgba(37, 99, 235, 0.08);
|
||||
border-color: rgba(37, 99, 235, 0.24);
|
||||
color: #2563EB;
|
||||
}
|
||||
|
||||
:global(html.light) .root :global(.scan-terminal.light .scan-retry-button:hover) {
|
||||
html.light .root :global(.scan-terminal.light .scan-retry-button:hover) {
|
||||
background: rgba(37, 99, 235, 0.14);
|
||||
}
|
||||
|
||||
/* ── AMOS runway panel light overrides ── */
|
||||
:global(html.light) .root :global(.scan-terminal.light .scan-amos-runway-panel) {
|
||||
html.light .root :global(.scan-terminal.light .scan-amos-runway-panel) {
|
||||
background: rgba(37, 99, 235, 0.03);
|
||||
border-color: rgba(37, 99, 235, 0.12);
|
||||
}
|
||||
|
||||
:global(html.light) .root :global(.scan-terminal.light .scan-amos-runway-card) {
|
||||
html.light .root :global(.scan-terminal.light .scan-amos-runway-card) {
|
||||
background: var(--bg-card);
|
||||
border-color: rgba(37, 99, 235, 0.08);
|
||||
}
|
||||
|
||||
:global(html.light) .root :global(.scan-terminal.light .scan-amos-runway-label) {
|
||||
html.light .root :global(.scan-terminal.light .scan-amos-runway-label) {
|
||||
color: #2563EB;
|
||||
}
|
||||
|
||||
:global(html.light) .root :global(.scan-terminal.light .scan-amos-runway-temp) {
|
||||
html.light .root :global(.scan-terminal.light .scan-amos-runway-temp) {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
:global(html.light) .root :global(.scan-terminal.light .scan-amos-source-tag) {
|
||||
html.light .root :global(.scan-terminal.light .scan-amos-source-tag) {
|
||||
background: rgba(37, 99, 235, 0.08);
|
||||
color: #2563EB;
|
||||
}
|
||||
|
||||
:global(html.light) .root :global(.scan-terminal.light .scan-amos-runway-detail) {
|
||||
html.light .root :global(.scan-terminal.light .scan-amos-runway-detail) {
|
||||
color: #64748B;
|
||||
}
|
||||
|
||||
@@ -1337,60 +1337,3 @@
|
||||
}
|
||||
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* ── AI prediction dual-card light mode ── */
|
||||
:global(html.light) .root :global(.scan-ai-prediction-card) {
|
||||
background: var(--color-bg-raised);
|
||||
border-color: var(--color-border-default);
|
||||
}
|
||||
|
||||
:global(html.light) .root :global(.scan-ai-prediction-card.ai) {
|
||||
background: linear-gradient(180deg, rgba(37, 99, 235, 0.08), var(--color-bg-raised));
|
||||
border-color: rgba(37, 99, 235, 0.22);
|
||||
}
|
||||
|
||||
:global(html.light) .root :global(.scan-ai-prediction-card.deb) {
|
||||
background: var(--color-bg-raised);
|
||||
border-color: var(--color-border-default);
|
||||
}
|
||||
|
||||
:global(html.light) .root :global(.scan-ai-prediction-card small) {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
:global(html.light) .root :global(.scan-ai-prediction-card strong) {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
:global(html.light) .root :global(.scan-ai-prediction-card.ai strong) {
|
||||
color: var(--color-accent-primary);
|
||||
}
|
||||
|
||||
:global(html.light) .root :global(.scan-ai-prediction-card em) {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
:global(html.light) .root :global(.scan-ai-prediction-card.ai.pending) {
|
||||
background: rgba(37, 99, 235, 0.04);
|
||||
border-color: rgba(37, 99, 235, 0.12);
|
||||
}
|
||||
|
||||
:global(html.light) .root :global(.scan-ai-prediction-card.ai.pending strong) {
|
||||
color: var(--color-text-disabled);
|
||||
}
|
||||
|
||||
:global(html.light) .root :global(.scan-ai-confidence) {
|
||||
background: rgba(37, 99, 235, 0.1);
|
||||
color: var(--color-accent-primary);
|
||||
}
|
||||
|
||||
:global(html.light) .root :global(.scan-ai-confidence.high) {
|
||||
background: rgba(16, 185, 129, 0.12);
|
||||
color: #047857;
|
||||
}
|
||||
|
||||
:global(html.light) .root :global(.scan-ai-confidence.medium) {
|
||||
background: rgba(245, 158, 11, 0.12);
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
/* ScanTerminalContinent — continent group headers and mobile cards */
|
||||
|
||||
.root {
|
||||
--gap-green: #16a34a;
|
||||
--gap-orange: #ea580c;
|
||||
--gap-slate: #475569;
|
||||
--gap-gray: #94a3b8;
|
||||
--gap-red: #dc2626;
|
||||
}
|
||||
|
||||
/* Group header row */
|
||||
.groupHeader {
|
||||
background: #eef2f6;
|
||||
border-bottom: 1px solid #cbd5e1;
|
||||
}
|
||||
.groupHeader:hover {
|
||||
background: #e2e8f0;
|
||||
}
|
||||
|
||||
/* Mobile: hide scrollbar on tab bar */
|
||||
.mobileTabs {
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
.mobileTabs::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Mobile card */
|
||||
.mobileCard {
|
||||
border: 1px solid #e2e8f0;
|
||||
background: #ffffff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
.mobileCard:hover {
|
||||
background: #f0f7ff;
|
||||
}
|
||||
|
||||
/* Signal badge colors */
|
||||
.signalActive {
|
||||
color: #059669;
|
||||
}
|
||||
.signalWatch {
|
||||
color: #d97706;
|
||||
}
|
||||
.signalClosed {
|
||||
color: #94a3b8;
|
||||
}
|
||||
.signalData {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
/* Light theme overrides — match scan-terminal's .scan-terminal.light scope */
|
||||
:global(.scan-terminal.light) .groupHeader {
|
||||
background: #f8fafc;
|
||||
border-bottom-color: #e2e8f0;
|
||||
}
|
||||
:global(.scan-terminal.light) .groupHeader:hover {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
:global(.scan-terminal.light) .mobileCard {
|
||||
background: #ffffff;
|
||||
border-color: #e2e8f0;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user