Compare commits
62 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f32a8d990a | |||
| 6c7f3ef67f | |||
| 6273921005 | |||
| 7d1bfea8da | |||
| 961c9bd37c | |||
| b0fec20370 | |||
| 8f6925108c | |||
| 96dde3b50e | |||
| 505b75d6ce | |||
| 956cb8a8e3 | |||
| 721173aea3 | |||
| ec84243fb9 | |||
| e1054e684a | |||
| 58a2881fc8 | |||
| 4f4617ba0a | |||
| 26775693e7 | |||
| 139e5dbb06 | |||
| bbe3871b7c | |||
| 4389d4a40d | |||
| c3f9b974c3 | |||
| 39026344dc | |||
| 41e819735d | |||
| 12935f502f | |||
| c4b47a81a7 | |||
| 27e4f1efe0 | |||
| f66127da44 | |||
| fb4a144fcf | |||
| 3951735259 | |||
| a40b1ba137 | |||
| 555d3fa68f | |||
| f7625218ae | |||
| a5de604d85 | |||
| 11fd482378 | |||
| 524ae7c8cf | |||
| 98b69b180a | |||
| d354fb12dd | |||
| 2f5e4a40dd | |||
| 7a38e3588a | |||
| c2f9bc5994 | |||
| 89f77b8db4 | |||
| 2766b6463c | |||
| 2a6c826f50 | |||
| 09979f3bb5 | |||
| e6a1d14b09 | |||
| 2af1cf9cd8 | |||
| c3fd0582ad | |||
| 00f96150c5 | |||
| 92307a9f97 | |||
| a440f3d2b5 | |||
| afffacb529 | |||
| 137b0d8b3a | |||
| 876417c5ec | |||
| 35a93cbd9e | |||
| 9d136a337c | |||
| fc0a8b8ff5 | |||
| 8fc9f3ae1a | |||
| f1e577ea8a | |||
| 2fd8027a41 | |||
| 6407d0a29d | |||
| 573353421b | |||
| ac5b512a72 | |||
| 31fb1b3f28 |
+38
-38
@@ -73,9 +73,6 @@ jobs:
|
||||
- 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:
|
||||
@@ -83,40 +80,41 @@ jobs:
|
||||
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 image
|
||||
env:
|
||||
IMAGE_NAME: ${{ matrix.image.name }}
|
||||
IMAGE_CONTEXT: ${{ matrix.image.context }}
|
||||
IMAGE_FILE: ${{ matrix.image.file }}
|
||||
IMAGE_TAG: ${{ matrix.image.tag }}
|
||||
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_WALLETCONNECT_PROJECT_ID: ${{ secrets.NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID || '' }}
|
||||
NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL: ${{ secrets.NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL || 'https://polygon-bor-rpc.publicnode.com' }}
|
||||
NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS: ${{ secrets.NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS || 'polyweather.top,www.polyweather.top' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
- 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
|
||||
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=${{ secrets.NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID || '' }}
|
||||
NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL=${{ secrets.NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL || 'https://polygon-bor-rpc.publicnode.com' }}
|
||||
NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS=${{ secrets.NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS || 'polyweather.top,www.polyweather.top' }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
tags=(-t "${IMAGE_TAG}:latest" -t "${IMAGE_TAG}:${GITHUB_SHA}")
|
||||
build_args=()
|
||||
|
||||
if [ "${IMAGE_NAME}" = "frontend" ]; then
|
||||
build_args=(
|
||||
--build-arg "NEXT_PUBLIC_SUPABASE_URL=${NEXT_PUBLIC_SUPABASE_URL}"
|
||||
--build-arg "NEXT_PUBLIC_SUPABASE_ANON_KEY=${NEXT_PUBLIC_SUPABASE_ANON_KEY}"
|
||||
--build-arg "NEXT_PUBLIC_SITE_URL=${NEXT_PUBLIC_SITE_URL}"
|
||||
--build-arg "NEXT_PUBLIC_POLYWEATHER_API_BASE_URL=${NEXT_PUBLIC_POLYWEATHER_API_BASE_URL}"
|
||||
--build-arg "NEXT_PUBLIC_POLYWEATHER_LOCAL_FULL_ACCESS=false"
|
||||
--build-arg "NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=${NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID}"
|
||||
--build-arg "NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL=${NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL}"
|
||||
--build-arg "NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS=${NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS}"
|
||||
)
|
||||
fi
|
||||
|
||||
docker build -f "${IMAGE_FILE}" "${tags[@]}" "${build_args[@]}" "${IMAGE_CONTEXT}"
|
||||
docker push "${IMAGE_TAG}:latest"
|
||||
docker push "${IMAGE_TAG}:${GITHUB_SHA}"
|
||||
|
||||
deploy:
|
||||
needs: [build-and-push]
|
||||
@@ -130,11 +128,13 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Deploy to VPS
|
||||
env:
|
||||
GHCR_PAT: ${{ secrets.GHCR_PAT }}
|
||||
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 }}'
|
||||
printf '%s\n' "$GHCR_PAT" | ssh -o StrictHostKeyChecking=accept-new ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }} "
|
||||
bash /tmp/deploy.sh '${{ github.sha }}'
|
||||
"
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
# PolyWeather Agent Instructions
|
||||
|
||||
## 语言和沟通
|
||||
|
||||
- 默认用中文回复用户。
|
||||
- 直接说明正在做什么、查到了什么、下一步是什么;不要写空泛客套话。
|
||||
- 如果用户要求“提交推送”“部署”“看日志”,必须在本地验证后再提交、推送,并检查 GitHub Actions 或线上状态。
|
||||
- 不要把多个不相关任务混在一个结论里;遇到新方向时,建议用户在同一个 Project 下开新 Thread。
|
||||
|
||||
## 项目和线程使用
|
||||
|
||||
- 一个 Project 对应 PolyWeather 这个共享代码库和长期方向。
|
||||
- 每个具体任务使用一条独立 Thread / Chat,例如:
|
||||
- 落地页与产品包装
|
||||
- 观测数据采集与 SSE patch
|
||||
- Telegram 推送
|
||||
- 付款与会员
|
||||
- 部署、CI、服务器状态
|
||||
- 同一个 Project 下的 Thread 共享文件夹和 `AGENTS.md`,但上下文分开,避免旧问题影响新任务判断。
|
||||
|
||||
## 代码工作原则
|
||||
|
||||
- 先读现有代码和配置,再改动;优先沿用项目已有模式。
|
||||
- 使用 `rg` / `rg --files` 查找文件和文本。
|
||||
- 手动编辑文件使用 `apply_patch`。
|
||||
- 不要回滚用户或其他 agent 已经做过的无关改动。
|
||||
- 只改和当前任务直接相关的文件,避免顺手重构。
|
||||
- 新增复杂逻辑时补充聚焦测试;窄改动保持验证范围匹配风险。
|
||||
|
||||
## 前端约定
|
||||
|
||||
- 前端位于 `frontend/`,使用 Next.js、React、TypeScript。
|
||||
- UI 修改必须关注移动端响应式、文本不重叠、按钮和标签不溢出。
|
||||
- 图表、终端、详情面板等工作界面应保持信息密度和可扫描性,避免营销式装饰。
|
||||
- 常用验证:
|
||||
- `cd frontend && npm run test:business`
|
||||
- `cd frontend && npm run typecheck`
|
||||
- 必要时启动本地预览并用浏览器检查桌面和移动视口;检查完关闭本地端口。
|
||||
|
||||
## 后端和数据约定
|
||||
|
||||
- Python 代码主要位于 `src/`、`web/`、`tests/`。
|
||||
- 观测数据刷新应以数据源原生频率为准,避免 Web、collector、Telegram 同时强刷同一外部源。
|
||||
- Telegram 默认只读最新缓存/DB;除非完全没有缓存,才允许兜底刷新。
|
||||
- 对外部源调用要考虑 singleflight、冷却、缓存和失败降级,避免 502/408 或 Supabase/磁盘 IO 压力。
|
||||
- 常用验证:
|
||||
- `python -m ruff check .`
|
||||
- `python -m pytest`
|
||||
|
||||
## CI、提交和部署
|
||||
|
||||
- `main` push 会触发 `.github/workflows/ci.yml`:
|
||||
- `python-quality`
|
||||
- `frontend-quality`
|
||||
- `build-and-push`
|
||||
- `deploy`
|
||||
- 提交前至少运行和改动相关的验证;推送前确认 `git status --short`。
|
||||
- 推送后检查 GitHub Actions 最新 run;如果失败,先定位失败 job 和 step,再修改。
|
||||
- 线上 smoke check 优先检查:
|
||||
- `https://api.polyweather.top/healthz`
|
||||
- `https://polyweather.top/`
|
||||
- 相关页面或 API 路径
|
||||
|
||||
## 产品方向备忘
|
||||
|
||||
- PolyWeather 当前重点不是售卖 API。
|
||||
- 核心差异化是结算源优先、实时观测源、跑道/城市细粒度温度、SSE patch、Telegram 缓存读取和面向交易/预测市场的解释能力。
|
||||
- 公开包装、教育内容、图表变量完整度和付费分层可以加强,但不要把产品定位改成通用天气 API。
|
||||
|
||||
## Memory 说明
|
||||
|
||||
- `AGENTS.md` 是项目内显式规则,跟随仓库和 Project。
|
||||
- Memory 是用户账号级偏好设置,agent 不能代替用户开启。
|
||||
- 建议在 Codex / ChatGPT 设置中开启 Memory,并保存长期偏好,例如:
|
||||
- PolyWeather 项目默认中文回复。
|
||||
- 每个任务开独立 Thread。
|
||||
- 修改后优先验证、提交、推送并检查部署状态。
|
||||
- 不要主动把 PolyWeather 包装成 API 售卖产品。
|
||||
@@ -5,13 +5,13 @@ Production weather-intelligence stack for temperature settlement markets.
|
||||
Official dashboard: [polyweather.top](https://polyweather.top/)
|
||||
中文说明: [README_ZH.md](README_ZH.md)
|
||||
|
||||
Public docs center: `/docs/intro` on the main site (bilingual product documentation, including intraday analysis, calibrated probability, model stack, TAF, settlement sources, history, and extension).
|
||||
Public docs center: `/docs/intro` on the main site (bilingual product documentation for the current terminal, chart reading, realtime source cadence, settlement stations, and the browser extension).
|
||||
|
||||
## Product Screenshots
|
||||
|
||||
### Realtime Terminal
|
||||
|
||||

|
||||

|
||||
|
||||
### Telegram Runway Alerts
|
||||
|
||||
@@ -21,22 +21,21 @@ Public docs center: `/docs/intro` on the main site (bilingual product documentat
|
||||
|
||||
[](https://star-history.com/#yangyuan-zhen/PolyWeather&Date)
|
||||
|
||||
## Product Status (2026-05-28)
|
||||
## Product Status (2026-06-07)
|
||||
|
||||
- Subscription live: `Pro Monthly 10 USDC`.
|
||||
- Points system live: earn via group chat, welcome bonus (+20), first-message-of-day bonus (+2), weekly participation rewards.
|
||||
- `/city` and `/deb` now free (daily cap 10 each); points redeemable for payment discount (`500 pts = 1 USDC`, max `3 USDC`).
|
||||
- Weekly leaderboard rewards restructured: smaller point bonuses for winners (200/100/50), all active users receive participation rewards.
|
||||
- Subscription live: `Pro Monthly 29.9 USDC / 30 days` and `Pro Quarterly 79.9 USDC / 90 days`.
|
||||
- Referral pricing live: invited users can get the first monthly Pro at `20 USDC`; inviters receive `3500` points after a valid first Pro payment, capped at 10 paid invites per month.
|
||||
- `/city` and `/deb` now free (daily cap 10 each); points redeemable for payment discount (`500 pts = 1 USDC`, monthly max `3 USDC`, quarterly max `8 USDC`). Useful user feedback can also receive manual point rewards through ops.
|
||||
- Onchain checkout live: Polygon contract checkout (USDC / USDC.e) plus Ethereum mainnet USDC direct-transfer confirmation.
|
||||
- Auto-reconciliation live: event listener + periodic confirm loop.
|
||||
- Ops dashboard live: `/ops` for memberships, leaderboard, manual point grants, and payment incident triage.
|
||||
- Ops dashboard live: `/ops` for memberships, leaderboard, user feedback triage, manual point grants, and payment incident triage.
|
||||
- Lightweight observability live: `/healthz`, `/api/system/status`, `/metrics`.
|
||||
- Realtime terminal live: visible city charts subscribe through `/api/events?cities=...&since_revision=...`, receive `city_observation_patch.v1` SSE patches, and replay short gaps from Redis Stream in production or SQLite fallback in local/single-node mode.
|
||||
- Chart refresh is observation-driven: live patches merge into the current chart without a loading overlay; only visible charts run a 60s no-patch fallback, and returning from a background browser tab triggers a foreground catch-up refresh.
|
||||
- Temperature charts default to All Day, keep an optional Peak window derived from the DEB hourly path, and render all timestamps in the selected city's local time.
|
||||
- The chart core has been split into focused logic/canvas/state modules; Recharts now receives explicit measured dimensions to avoid 0x0 rendering and disappearing curves.
|
||||
- DEB hourly consensus (`deb_hourly_consensus.v1`) is now the preferred hourly forecast path for peak-window detection and chart overlays; DEB remains a forecast curve, never an observation source.
|
||||
- Legacy Gaussian probability is rendered as horizontal probability bands and a `mu` reference line on the chart, rather than as a fake time-series curve.
|
||||
- Legacy Gaussian probability stays out of the default temperature chart surface; hover tooltips show `Gaussian μ` plus the full bucket distribution by temperature range.
|
||||
- Settlement runway curves are visible by default for AMSC/AMOS cities; the configured settlement runway is highlighted and auxiliary runways are shown as secondary context.
|
||||
- Hong Kong uses CoWIN station `6087` (Po Leung Kuk Choi Kai Yau School) as the 1-minute reference-station curve, with HKO 10-minute observations kept as the official meteorological layer.
|
||||
- Telegram airport/runway pushes are bilingual by default and use settlement-endpoint runway temperatures for slope/current/summary copy.
|
||||
@@ -44,10 +43,10 @@ Public docs center: `/docs/intro` on the main site (bilingual product documentat
|
||||
- EMOS/CRPS calibration is wired and trainable, but production should stay on `legacy` or `emos_shadow`; `emos_primary` is only for candidates that pass local offline evaluation and manual rollout.
|
||||
- Intraday analysis is now positioned as a professional meteorology read: headline, confidence, base/upside/downside paths, next observation point, evidence chain, failure modes, and confirmation rules.
|
||||
- Intraday modal now blocks stale cached detail during refresh, so users do not briefly trade off old city/date data before full detail arrives.
|
||||
- Terminal city cards now combine settlement observations, DEB hourly consensus, model cluster context, calibrated probability, and market-bucket mapping without blocking the chart on AI text generation.
|
||||
- Terminal chart/detail workflow now combines settlement observations, DEB hourly consensus, model context, probability distribution tooltips, and market-bucket mapping without blocking the chart on AI text generation.
|
||||
- Terminal data uses page memory cache, browser `localStorage`, backend short-TTL cache, SSE patch replay, and foreground refresh so returning from another tab restores the latest visible chart state quickly.
|
||||
- Market bucket matching now uses the full `all_buckets` surface and strict exact / range / or-higher / or-lower direction checks, reducing bad matches to unreasonable tail buckets.
|
||||
- The card label “model-market difference” means `model probability - market-implied probability`; positive values indicate weather probability above market pricing, while negative values indicate the YES is already priced more fully.
|
||||
- The market-signal difference means `model probability - market-implied probability`; positive values indicate weather probability above market pricing, while negative values indicate the YES is already priced more fully.
|
||||
- Calibrated model probability is now the primary probability panel. It shows the active production probability engine (legacy Gaussian or EMOS), while model consensus remains a secondary reference.
|
||||
- Non-Hong Kong airport cities now ingest `TAF` and parse `FM / TEMPO / BECMG / PROB30/40`.
|
||||
- Temperature chart now overlays `TAF Timing` markers near the expected peak window.
|
||||
@@ -73,9 +72,11 @@ See: [AGPL-3.0 & Commercial Boundary](docs/OPEN_CORE_POLICY.md)
|
||||
- Uses DEB (Dynamic Error Balancing) to blend multi-model highs.
|
||||
- Builds a DEB-weighted hourly consensus path for peak-window logic and chart display.
|
||||
- Generates settlement-oriented calibrated probability buckets (`mu` + bucket distribution) via legacy Gaussian or EMOS/CRPS calibration.
|
||||
- Adds city decision cards that combine live observations, expected-high centers, full market-bucket mapping, and model-market difference in one view.
|
||||
- Adds terminal chart/detail workflows that combine live observations, DEB-centered high-temperature context, market-bucket mapping, and model-market difference.
|
||||
- Shows calibrated Gaussian context in chart tooltips as `mu` plus the full temperature-range probability distribution, without reintroducing probability bands into the main temperature view.
|
||||
- Reuses one analysis core across web dashboard and Telegram bot.
|
||||
- Adds payment audit trails, replay tooling, and incident visibility in ops.
|
||||
- Adds an in-app feedback loop with chart context, user-visible feedback status, ops triage, and manual point rewards for useful reports and suggestions.
|
||||
- Adds peak-window-oriented intraday analysis with meteorology headline, path buckets, evidence chain, invalidation rules, and confirmation rules.
|
||||
- Adds airport-side `TAF` timing overlays and airport suppression/disruption interpretation for non-Hong Kong airport cities.
|
||||
- Adds official nearby-network and runway-level enhancement layers for China, Japan, Korea (AMOS runway sensors for Seoul/Busan), Hong Kong, Taiwan, and Turkey without replacing airport settlement anchors.
|
||||
@@ -131,6 +132,8 @@ npm run dev
|
||||
|
||||
## Recent Highlights
|
||||
|
||||
- Gaussian probability tooltip now lists the full temperature-range distribution instead of only the highest-probability bucket, while the main chart remains focused on observations and forecasts.
|
||||
- User feedback is now a product loop: terminal submissions attach chart context, users can track status in-app, and ops can reward useful feedback with points.
|
||||
- Airport-linked contracts use the METAR / airport primary observing site as the settlement anchor. Wunderground pages are reference/history pages, not stations.
|
||||
- Taipei and Shenzhen retain their explicitly configured station history pages for reconciliation, but the docs avoid describing Wunderground itself as a physical station.
|
||||
- Hong Kong keeps `HKO` official readings in dashboard and history, without falling back to airport METAR lines.
|
||||
@@ -240,4 +243,4 @@ Production payment routes are configured by the backend. Polygon remains the def
|
||||
## Version
|
||||
|
||||
- Version: `v1.8.1`
|
||||
- Last Updated: `2026-05-28`
|
||||
- Last Updated: `2026-06-07`
|
||||
|
||||
+17
-10
@@ -8,22 +8,22 @@
|
||||
|
||||
### 实时终端
|
||||
|
||||

|
||||

|
||||
|
||||
### Telegram 跑道推送
|
||||
|
||||

|
||||
|
||||
## 当前产品状态(2026-05-30)
|
||||
## 当前产品状态(2026-06-07)
|
||||
|
||||
- 已上线订阅制:`Pro 月付 29.9 USDC / 30 天`,`Pro 季度 79.9 USDC / 90 天`。
|
||||
- 积分获取已切换为邀请制度:被邀请人完成首次 Pro 付款后,邀请人获得 `3500` 积分;Telegram 群发言不再获得积分。
|
||||
- `/city` 与 `/deb` 已改为免费(每日各 10 次);积分可用于支付抵扣(`500 分 = 1 USDC`,月付最多抵 `3 USDC`,季度最多抵 `8 USDC`)。
|
||||
- `/city` 与 `/deb` 已改为免费(每日各 10 次);积分可用于支付抵扣(`500 分 = 1 USDC`,月付最多抵 `3 USDC`,季度最多抵 `8 USDC`)。真实、有上下文、有价值的用户反馈也可通过运营后台人工奖励积分。
|
||||
- 邀请首月价:被邀请人首次月付 `20 USDC`;每个邀请人每月最多 10 个有效付费邀请奖励。
|
||||
- 已上线链上支付:Polygon 合约支付(USDC / USDC.e)+ Ethereum 主网 USDC 直转确认。
|
||||
- 已上线自动补单:事件监听 + 周期确认双链路。
|
||||
- 已上线支付运行态与审计接口:`/api/payments/runtime`。
|
||||
- 已上线轻量运营后台:`/ops`(会员、积分、补分、支付异常单)。
|
||||
- 已上线轻量运营后台:`/ops`(会员、用户反馈处理、积分、补分、支付异常单)。
|
||||
- 已上线轻量可观测性:`/healthz`、`/api/system/status`、`/metrics`。
|
||||
- 已补最小外部监控栈:Prometheus + Alertmanager + Grafana + Telegram 告警 relay。
|
||||
- 实时终端已切换到可重放事件流:可见城市图表通过 `/api/events?cities=...&since_revision=...` 订阅 `city_observation_patch.v1`,生产环境使用 Redis Stream 做短窗口 replay,本地/单进程可回退 SQLite event log。
|
||||
@@ -31,7 +31,7 @@
|
||||
- 城市图表默认展示“全天”,可选“高温”窗口由 DEB hourly path 推导;所有图表横轴都按城市当地时间展示,不按用户浏览器时区。
|
||||
- 核心图表组件已拆分为逻辑、状态与 canvas 渲染模块;Recharts 使用 `ResizeObserver` 后的明确宽高,规避 0x0 渲染和长时间挂页后曲线消失。
|
||||
- DEB hourly consensus(`deb_hourly_consensus.v1`)已作为峰值窗口和图表 DEB 曲线的优先小时路径;DEB 仍然是预测曲线,不作为实测来源。
|
||||
- legacy 高斯概率在图表上展示为概率温度带和 `mu` 参考线,不再伪装成一条时间序列曲线。
|
||||
- legacy 高斯概率不再占用默认温度图主视图;hover tooltip 会展示 `Gaussian μ` 和完整温度区间概率分布。
|
||||
- AMSC/AMOS 城市的结算跑道曲线默认展示并高亮,辅助跑道作为弱化曲线保留;釜山单跑道只展示 `SR/SL` 结算跑道,不再重复显示 AMOS 聚合线。
|
||||
- 香港默认展示 CoWIN `6087`(保良局陈守仁小学)1 分钟参考站曲线,HKO 10 分钟实测保留为官方气象层。
|
||||
- Telegram 机场/跑道推送默认中英文双语,并统一使用结算端点跑道温度计算当前值、15 分钟趋势和文案。
|
||||
@@ -49,10 +49,10 @@
|
||||
- `/ops` 现已展示缓存桶数量、summary cache hit/miss 与运行态 heartbeat。
|
||||
- 今日日内分析已改为“专业气象判断台”:顶部先给气象主判断、置信度、基准/上修/下修路径、下一观测点,再展示证据链、失效条件、确认条件和模型层。
|
||||
- 日内分析弹窗在 full detail / market detail 同步完成前会锁住旧内容并显示刷新状态,避免用户短暂看到上一轮缓存数据后误判。
|
||||
- 终端城市卡已改为结构化实况 + DEB hourly consensus + 多模型集群 + 校准概率 + 市场温度桶,不再让图表等待 AI 文案生成。
|
||||
- 终端图表/详情工作流已改为结构化实况 + DEB hourly consensus + 多模型集群 + 概率分布 tooltip + 市场温度桶,不再让图表等待 AI 文案生成。
|
||||
- 终端数据同时使用页面内存缓存、浏览器 `localStorage`、后端短 TTL 缓存、SSE patch replay 和前台恢复刷新;从其他选项卡切回时会优先恢复最新可见图表状态。
|
||||
- 市场温度桶匹配已改为完整 `all_buckets` 映射,按 exact / range / or higher / or lower 方向严格匹配,避免把天气中枢错配到不合理尾部桶。
|
||||
- 决策卡中的“模型-市场差”口径为 `模型概率 - 市场隐含概率`,正值表示天气概率高于市场报价,负值表示市场已经更充分计价。
|
||||
- 市场信号中的“模型-市场差”口径为 `模型概率 - 市场隐含概率`,正值表示天气概率高于市场报价,负值表示市场已经更充分计价。
|
||||
- 概率区已改为”校准模型概率”;默认展示生产概率引擎输出(legacy 高斯或 EMOS),模型共识作为辅助参考。
|
||||
- 今日日内结构解读以规则与结构化信号为主,AI 文案只作为可降级辅助层,不替代实测、DEB、TAF 或结算逻辑。
|
||||
- 前端设计系统全面重构:统一 CSS token 体系、消除 !important 滥用(134→49)、合并断点(18→10)、数百处硬编码颜色迁移至 CSS 变量、添加 ARIA 无障碍属性和键盘导航。完整审查记录见 `docs/frontend-ui-design-review.md`。
|
||||
@@ -73,9 +73,11 @@
|
||||
- DEB(Dynamic Error Balancing)融合多模型最高温。
|
||||
- 构建 DEB 加权小时共识曲线,用于峰值窗口判断和图表默认 DEB 展示。
|
||||
- 输出结算导向校准概率分布(`mu` + 温度桶),通过 legacy 高斯或 EMOS/CRPS 校准引擎。
|
||||
- 地图城市决策卡把结构化实况、最高温中枢、完整市场温度桶和模型-市场差放在同一张卡中展示。
|
||||
- 天气决策台把结构化实况、DEB 高温路径、完整市场温度桶和模型-市场差放进图表/详情工作流。
|
||||
- 图表 tooltip 展示校准高斯上下文:`mu` 加完整温度区间概率分布,不把概率温度带重新放回主图。
|
||||
- Web 仪表盘与 Telegram Bot 复用同一分析内核。
|
||||
- 支付链路具备事件重放、SQLite 审计事件与 RPC 容灾能力。
|
||||
- 已上线站内反馈闭环:提交反馈时自动附带图表上下文,用户可查看处理状态,运营后台可为有价值反馈人工奖励积分。
|
||||
- 官方增强层与跑道级传感器支持按国家 provider 统一接入(含韩国 AMOS 首尔/釜山跑道实测),不替代机场主站、METAR 或明确官方结算站。
|
||||
|
||||
## 参考架构
|
||||
@@ -126,6 +128,11 @@ npm ci
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## 近期更新
|
||||
|
||||
- 高斯概率 tooltip 已改为展示完整温度区间概率分布,不再只显示最高概率的单个区间;主图继续聚焦实测和预测曲线。
|
||||
- 用户反馈已形成产品闭环:终端提交会自动附带图表上下文,用户可在站内查看处理状态,运营侧可为真实、有建设性的反馈发放积分奖励。
|
||||
|
||||
## 运行数据目录(VPS 推荐)
|
||||
|
||||
建议将运行态数据放到仓库外(避免 `git pull` 被 SQLite 卡住):
|
||||
@@ -242,7 +249,7 @@ POLYWEATHER_OPS_ADMIN_EMAILS=yhrsc30@gmail.com
|
||||
- 支付 V2 升级方案:[docs/payments/PAYMENT_UPGRADE_V2_ZH.md](docs/payments/PAYMENT_UPGRADE_V2_ZH.md)
|
||||
- 运营后台说明:[docs/OPS_ADMIN_ZH.md](docs/OPS_ADMIN_ZH.md)
|
||||
- 外部监控说明:[docs/MONITORING_ZH.md](docs/MONITORING_ZH.md)
|
||||
- 模型栈与 DEB:[docs/MODEL_STACK_AND_DEB_ZH.md](docs/MODEL_STACK_AND_DEB_ZH.md)
|
||||
- DEB 模型家族去重规则:[docs/MODEL_STACK_AND_DEB_ZH.md](docs/MODEL_STACK_AND_DEB_ZH.md)
|
||||
- 深度评估报告:[docs/deep-research-report.md](docs/deep-research-report.md)
|
||||
- 发布流程:[RELEASE.md](RELEASE.md)
|
||||
- 变更记录:[CHANGELOG.md](CHANGELOG.md)
|
||||
@@ -250,4 +257,4 @@ POLYWEATHER_OPS_ADMIN_EMAILS=yhrsc30@gmail.com
|
||||
## 当前版本
|
||||
|
||||
- 版本:`v1.8.1`
|
||||
- 文档最后更新:`2026-05-28`
|
||||
- 文档最后更新:`2026-06-07`
|
||||
|
||||
+2
-2
@@ -19,8 +19,8 @@ cities:
|
||||
- id: new_york
|
||||
city: New York
|
||||
country: USA
|
||||
latitude: 40.7128
|
||||
longitude: -74.006
|
||||
latitude: 40.7769
|
||||
longitude: -73.874
|
||||
- id: chicago
|
||||
city: Chicago
|
||||
country: USA
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
GHCR_PAT="$1"
|
||||
NEW_TAG="${2:-latest}"
|
||||
NEW_TAG="${1:-latest}"
|
||||
TAG_FILE="/var/lib/polyweather/.current_tag"
|
||||
COMPOSE_DIR="/root/PolyWeather"
|
||||
LOCK_FILE="${POLYWEATHER_DEPLOY_LOCK_FILE:-/var/lock/polyweather-deploy.lock}"
|
||||
|
||||
GHCR_PAT=""
|
||||
if ! IFS= read -r GHCR_PAT && [ -z "$GHCR_PAT" ]; then
|
||||
echo "❌ GHCR token must be provided on stdin"
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "$GHCR_PAT" ]; then
|
||||
echo "❌ GHCR token must be provided on stdin"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$(dirname "$LOCK_FILE")"
|
||||
exec 9>"$LOCK_FILE"
|
||||
if ! flock -n 9; then
|
||||
@@ -14,7 +23,8 @@ if ! flock -n 9; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "$GHCR_PAT" | docker login ghcr.io -u yangyuan-zhen --password-stdin
|
||||
printf '%s' "$GHCR_PAT" | docker login ghcr.io -u yangyuan-zhen --password-stdin
|
||||
unset GHCR_PAT
|
||||
|
||||
cd "$COMPOSE_DIR"
|
||||
git fetch origin main && git reset --hard origin/main
|
||||
@@ -30,14 +40,40 @@ rollback_to_previous() {
|
||||
echo "Rolling back to $PREVIOUS_TAG..."
|
||||
export IMAGE_TAG="$PREVIOUS_TAG"
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
compose_up_retry "rollback" -d
|
||||
echo "✅ Rolled back to $PREVIOUS_TAG"
|
||||
else
|
||||
echo "⚠️ No previous tag to rollback to"
|
||||
fi
|
||||
}
|
||||
|
||||
compose_up_retry() {
|
||||
local name="$1"
|
||||
shift
|
||||
local output=""
|
||||
|
||||
for attempt in $(seq 1 6); do
|
||||
if output=$(docker compose up "$@" 2>&1); then
|
||||
echo "$output"
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "$output"
|
||||
if echo "$output" | grep -qi "removal of container .* is already in progress"; then
|
||||
echo "Container removal is still in progress during ${name}; retry ${attempt}/6..."
|
||||
sleep 5
|
||||
continue
|
||||
fi
|
||||
|
||||
return 1
|
||||
done
|
||||
|
||||
echo "❌ docker compose up failed for ${name} after retries"
|
||||
return 1
|
||||
}
|
||||
|
||||
export IMAGE_TAG="$NEW_TAG"
|
||||
export POLYWEATHER_API_BASE_URL="${POLYWEATHER_FRONTEND_INTERNAL_API_BASE_URL:-http://polyweather_web:8000}"
|
||||
pull_ok=0
|
||||
for pull_attempt in $(seq 1 6); do
|
||||
docker compose pull && pull_ok=1 && break
|
||||
@@ -116,11 +152,64 @@ warm_public_route() {
|
||||
return 0
|
||||
}
|
||||
|
||||
read_env_file_value() {
|
||||
local key="$1"
|
||||
if [ ! -f ".env" ]; then
|
||||
return 0
|
||||
fi
|
||||
awk -F= -v key="$key" '
|
||||
$0 ~ "^[[:space:]]*" key "[[:space:]]*=" {
|
||||
value=$0
|
||||
sub("^[^=]*=", "", value)
|
||||
gsub("^[[:space:]]+|[[:space:]]+$", "", value)
|
||||
gsub(/^["'"'"']|["'"'"']$/, "", value)
|
||||
print value
|
||||
}
|
||||
' .env | tail -n 1
|
||||
}
|
||||
|
||||
validate_frontend_api_base_url() {
|
||||
local api_base="${POLYWEATHER_API_BASE_URL:-}"
|
||||
if [ -z "$api_base" ]; then
|
||||
api_base="$(read_env_file_value "POLYWEATHER_API_BASE_URL")"
|
||||
fi
|
||||
local normalized
|
||||
normalized="$(printf '%s' "$api_base" | tr '[:upper:]' '[:lower:]' | sed 's/[[:space:]]//g; s#/*$##')"
|
||||
case "$normalized" in
|
||||
http://polyweather.top|https://polyweather.top|http://www.polyweather.top|https://www.polyweather.top)
|
||||
echo "❌ POLYWEATHER_API_BASE_URL must not point at the frontend site: $api_base"
|
||||
echo " Use the internal backend URL http://polyweather_web:8000 or the backend API host https://api.polyweather.top."
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
PUBLIC_SMOKE_RECHECK_DELAY_SEC="${POLYWEATHER_PUBLIC_SMOKE_RECHECK_DELAY_SEC:-20}"
|
||||
|
||||
run_public_smoke_checks() {
|
||||
local phase="${1:-initial}"
|
||||
local failed=0
|
||||
|
||||
if [ "$phase" = "recheck" ]; then
|
||||
smoke_check "healthz recheck" "https://api.polyweather.top/healthz" 20 6 10 || failed=1
|
||||
smoke_check "frontend cities recheck" "https://polyweather.top/api/cities" 30 8 10 || failed=1
|
||||
smoke_check "frontend recheck" "https://www.polyweather.top/" 20 6 10 || failed=1
|
||||
else
|
||||
smoke_check "healthz" "https://api.polyweather.top/healthz" 15 3 5 || failed=1
|
||||
smoke_check "frontend cities" "https://polyweather.top/api/cities" 20 5 5 || failed=1
|
||||
smoke_check "frontend" "https://www.polyweather.top/" 15 3 5 || failed=1
|
||||
fi
|
||||
|
||||
return "$failed"
|
||||
}
|
||||
|
||||
validate_frontend_api_base_url
|
||||
|
||||
echo "Updating Redis dependency..."
|
||||
docker compose up -d polyweather_redis
|
||||
compose_up_retry "redis" -d polyweather_redis
|
||||
|
||||
echo "Updating backend services..."
|
||||
docker compose up -d --no-deps polyweather_web polyweather
|
||||
compose_up_retry "backend services" -d --no-deps polyweather_web polyweather
|
||||
|
||||
echo "Waiting for backend..."
|
||||
wait_for_local_service "backend healthz" "http://127.0.0.1:8000/healthz" 5 30 5 || FAILED_BACKEND=1
|
||||
@@ -131,8 +220,11 @@ if [ "$FAILED_BACKEND" = "1" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Updating observation collector..."
|
||||
compose_up_retry "observation collector" -d --no-deps polyweather_collector
|
||||
|
||||
echo "Updating frontend..."
|
||||
docker compose up -d --no-deps polyweather_frontend
|
||||
compose_up_retry "frontend" -d --no-deps polyweather_frontend
|
||||
|
||||
echo "Waiting for frontend..."
|
||||
wait_for_local_service "frontend root" "http://127.0.0.1:3001/" 5 40 2 || FAILED_FRONTEND=1
|
||||
@@ -150,9 +242,14 @@ warm_public_route "local cities recent stats" "http://127.0.0.1:8000/api/cities?
|
||||
warm_public_route "cities" "https://polyweather.top/api/cities" 20 3 2
|
||||
|
||||
FAILED=0
|
||||
smoke_check "healthz" "https://api.polyweather.top/healthz" 15 3 5 || FAILED=1
|
||||
smoke_check "frontend cities" "https://polyweather.top/api/cities" 20 5 5 || FAILED=1
|
||||
smoke_check "frontend" "https://www.polyweather.top/" 15 3 5 || FAILED=1
|
||||
run_public_smoke_checks || FAILED=1
|
||||
|
||||
if [ "$FAILED" = "1" ]; then
|
||||
echo "⚠️ Initial public smoke failed; retrying before rollback..."
|
||||
sleep "$PUBLIC_SMOKE_RECHECK_DELAY_SEC"
|
||||
FAILED=0
|
||||
run_public_smoke_checks "recheck" || FAILED=1
|
||||
fi
|
||||
|
||||
if [ "$FAILED" = "1" ]; then
|
||||
echo "❌ Smoke tests failed. Rolling back..."
|
||||
|
||||
+68
-1
@@ -29,6 +29,8 @@ services:
|
||||
- .env
|
||||
environment:
|
||||
POLYWEATHER_REDIS_URL: ${POLYWEATHER_REDIS_URL:-redis://polyweather_redis:6379/0}
|
||||
POLYWEATHER_COLLECTOR_PATCH_ENDPOINT: ''
|
||||
POLYWEATHER_OBSERVATION_COLLECTOR_ENABLED: 'false'
|
||||
POLYWEATHER_SCAN_TERMINAL_PREWARM_ENABLED: 'false'
|
||||
POLYWEATHER_SERVICE_ROLE: bot
|
||||
TELEGRAM_AIRPORT_PUSH_INTERVAL_SEC: ${POLYWEATHER_BOT_AIRPORT_PUSH_INTERVAL_SEC:-180}
|
||||
@@ -69,7 +71,7 @@ services:
|
||||
NEXT_PUBLIC_SUPABASE_URL: ${NEXT_PUBLIC_SUPABASE_URL}
|
||||
NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL: ${NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL:-https://polygon-bor-rpc.publicnode.com}
|
||||
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID: ${NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID:-}
|
||||
POLYWEATHER_API_BASE_URL: ${POLYWEATHER_API_BASE_URL:-http://polyweather_web:8000}
|
||||
POLYWEATHER_API_BASE_URL: http://polyweather_web:8000
|
||||
POLYWEATHER_AUTH_ENABLED: ${POLYWEATHER_AUTH_ENABLED:-true}
|
||||
POLYWEATHER_AUTH_REQUIRED: ${POLYWEATHER_AUTH_REQUIRED:-true}
|
||||
POLYWEATHER_OPS_ADMIN_EMAILS: ${POLYWEATHER_OPS_ADMIN_EMAILS:-}
|
||||
@@ -98,7 +100,21 @@ services:
|
||||
env_file: *id001
|
||||
environment:
|
||||
POLYWEATHER_REDIS_URL: ${POLYWEATHER_REDIS_URL:-redis://polyweather_redis:6379/0}
|
||||
POLYWEATHER_COLLECTOR_PATCH_ENDPOINT: ''
|
||||
POLYWEATHER_CITY_DETAIL_BATCH_CONCURRENCY: ${POLYWEATHER_CITY_DETAIL_BATCH_CONCURRENCY:-1}
|
||||
POLYWEATHER_CITY_DETAIL_BATCH_GLOBAL_CONCURRENCY: ${POLYWEATHER_CITY_DETAIL_BATCH_GLOBAL_CONCURRENCY:-1}
|
||||
POLYWEATHER_CITY_DETAIL_BATCH_PARTIAL_TIMEOUT_MS: ${POLYWEATHER_CITY_DETAIL_BATCH_PARTIAL_TIMEOUT_MS:-3000}
|
||||
POLYWEATHER_OBSERVATION_COLLECTOR_AMOS_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_AMOS_SEC:-60}
|
||||
POLYWEATHER_OBSERVATION_COLLECTOR_AMSC_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_AMSC_SEC:-180}
|
||||
POLYWEATHER_OBSERVATION_COLLECTOR_COWIN_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_COWIN_SEC:-60}
|
||||
POLYWEATHER_OBSERVATION_COLLECTOR_ENABLED: 'false'
|
||||
POLYWEATHER_OBSERVATION_COLLECTOR_HKO_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_HKO_SEC:-600}
|
||||
POLYWEATHER_OBSERVATION_COLLECTOR_INITIAL_DELAY_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_INITIAL_DELAY_SEC:-5}
|
||||
POLYWEATHER_OBSERVATION_COLLECTOR_MADIS_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_MADIS_SEC:-300}
|
||||
POLYWEATHER_OBSERVATION_COLLECTOR_TICK_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_TICK_SEC:-30}
|
||||
POLYWEATHER_SCAN_TERMINAL_PREWARM_ENABLED: 'false'
|
||||
POLYWEATHER_SERVICE_ROLE: web
|
||||
UVICORN_WORKERS: ${UVICORN_WORKERS:-2}
|
||||
healthcheck:
|
||||
interval: 30s
|
||||
retries: 3
|
||||
@@ -112,6 +128,57 @@ services:
|
||||
ports:
|
||||
- "127.0.0.1:8000:8000"
|
||||
restart: unless-stopped
|
||||
ulimits:
|
||||
nofile:
|
||||
soft: 65535
|
||||
hard: 65535
|
||||
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
|
||||
polyweather_collector:
|
||||
command: python -m web.observation_collector_worker
|
||||
container_name: polyweather_collector
|
||||
depends_on:
|
||||
polyweather_redis:
|
||||
condition: service_healthy
|
||||
polyweather_web:
|
||||
condition: service_started
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "3"
|
||||
cpus: ${POLYWEATHER_COLLECTOR_CPUS:-0.75}
|
||||
env_file: *id001
|
||||
environment:
|
||||
POLYWEATHER_COLLECTOR_PATCH_ENDPOINT: ${POLYWEATHER_COLLECTOR_PATCH_ENDPOINT:-http://polyweather_web:8000/api/internal/collector-patch}
|
||||
POLYWEATHER_OBSERVATION_COLLECTOR_AMOS_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_AMOS_SEC:-60}
|
||||
POLYWEATHER_OBSERVATION_COLLECTOR_AMSC_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_AMSC_SEC:-180}
|
||||
POLYWEATHER_OBSERVATION_COLLECTOR_COWIN_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_COWIN_SEC:-60}
|
||||
POLYWEATHER_OBSERVATION_COLLECTOR_ENABLED: 'true'
|
||||
POLYWEATHER_OBSERVATION_COLLECTOR_HKO_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_HKO_SEC:-600}
|
||||
POLYWEATHER_OBSERVATION_COLLECTOR_INITIAL_DELAY_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_INITIAL_DELAY_SEC:-15}
|
||||
POLYWEATHER_OBSERVATION_COLLECTOR_MADIS_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_MADIS_SEC:-300}
|
||||
POLYWEATHER_OBSERVATION_COLLECTOR_TICK_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_TICK_SEC:-30}
|
||||
POLYWEATHER_REDIS_URL: ${POLYWEATHER_REDIS_URL:-redis://polyweather_redis:6379/0}
|
||||
POLYWEATHER_SCAN_TERMINAL_PREWARM_ENABLED: 'false'
|
||||
POLYWEATHER_SERVICE_ROLE: collector
|
||||
healthcheck:
|
||||
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_COLLECTOR_MEM_LIMIT:-768m}
|
||||
memswap_limit: ${POLYWEATHER_COLLECTOR_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
|
||||
|
||||
@@ -11,13 +11,13 @@
|
||||
| 香港 | CoWIN 6087 | 6087 | CoWIN (`cowin.hku.hk`) | 1 分钟 | 参考站温度(保良局陈守仁小学) | 免费 |
|
||||
| 香港 | HKO | HKO | HKO 官方 CSV (`data.weather.gov.hk`) | 10 分钟 | 官方气象站温度 | 免费 |
|
||||
| 台北 | 松山/中央气象署 | 466920 | CWA 开放数据 | 10 分钟 | 官方站点温度 | 免费 |
|
||||
| 北京 | 首都机场 | ZBAA | AMSC AWOS | 1 分钟 | 跑道端点气温 | 免费 |
|
||||
| 上海 | 浦东机场 | ZSPD | AMSC AWOS | 1 分钟 | 跑道端点气温 | 免费 |
|
||||
| 广州 | 白云机场 | ZGGG | AMSC AWOS | 1 分钟 | 跑道端点气温 | 免费 |
|
||||
| 成都 | 双流机场 | ZUUU | AMSC AWOS | 1 分钟 | 跑道端点气温 | 免费 |
|
||||
| 重庆 | 江北机场 | ZUCK | AMSC AWOS | 1 分钟 | 跑道端点气温 | 免费 |
|
||||
| 武汉 | 天河机场 | ZHHH | AMSC AWOS | 1 分钟 | 跑道端点气温 | 免费 |
|
||||
| 青岛 | 胶东机场 | ZSQD | AMSC AWOS | 1 分钟 | 跑道端点气温 | 免费 |
|
||||
| 北京 | 首都机场 | ZBAA | AMSC AWOS | 3 分钟 | 跑道端点气温 | 免费 |
|
||||
| 上海 | 浦东机场 | ZSPD | AMSC AWOS | 3 分钟 | 跑道端点气温 | 免费 |
|
||||
| 广州 | 白云机场 | ZGGG | AMSC AWOS | 3 分钟 | 跑道端点气温 | 免费 |
|
||||
| 成都 | 双流机场 | ZUUU | AMSC AWOS | 3 分钟 | 跑道端点气温 | 免费 |
|
||||
| 重庆 | 江北机场 | ZUCK | AMSC AWOS | 3 分钟 | 跑道端点气温 | 免费 |
|
||||
| 武汉 | 天河机场 | ZHHH | AMSC AWOS | 3 分钟 | 跑道端点气温 | 免费 |
|
||||
| 青岛 | 胶东机场 | ZSQD | AMSC AWOS | 3 分钟 | 跑道端点气温 | 免费 |
|
||||
| 东京 | 羽田 | RJTT | JMA AMeDAS (`jma.go.jp`) | 10 分钟 | 机场站点实时温度 | 免费 |
|
||||
| 安卡拉 | Esenboğa | 17128 | MGM (`servis.mgm.gov.tr`) | 5-15 分钟 | 机场站点实时温度 | 免费 |
|
||||
| 伊斯坦布尔 | 伊斯坦布尔机场 | 17058 | MGM (`servis.mgm.gov.tr`) | 5-15 分钟 | 机场站点实时温度 | 免费 |
|
||||
@@ -56,11 +56,20 @@
|
||||
> `MID_TEMP` / `END_TEMP`。这些字段是跑道观测位置气温,不是道面温度。
|
||||
> 结算跑道展示使用配置的结算端点;辅助跑道只作为背景曲线。
|
||||
|
||||
## 推送机制
|
||||
## 独立观测采集器
|
||||
|
||||
- Web/API 进程启动 `observation-collector` 后台线程,按源频率独立采集,不依赖 Telegram 推送循环
|
||||
- 默认频率:AMOS 60s、AMSC AWOS 180s、MADIS HFMETAR 300s、CoWIN 60s、HKO 600s
|
||||
- 每次采集复用 `weather_sources.py` 现有 `_attach_*` 写入逻辑,负责写 `airport_obs_log` / `runway_obs_log` / 今日观测缓存,并通过 `/api/internal/collector-patch` 写 Redis Stream 或 SQLite event log 后广播 SSE
|
||||
- 采集成功后刷新对应城市 `panel` cache;前端继续使用 HTTP snapshot + SSE patch,不需要依赖 Telegram 触发更新
|
||||
- `observation_source_gate.py` 对 AMSC、AMOS、MADIS、HKO、CoWIN 做 per-source/per-city singleflight 和 SQLite cooldown,防止 Web 请求、collector 和兜底分析同时打同一个外部源
|
||||
|
||||
## Telegram 推送机制
|
||||
|
||||
- 每城按原生频率独立推送,不捆绑
|
||||
- 首尔/釜山 60s,其余 600s
|
||||
- 首尔/釜山 60s,中国 AMSC 城市 180s,其余 600s
|
||||
- 循环轮询 60s 以匹配最快频率
|
||||
- Telegram 推送优先读取网站侧 `full`/`panel` 城市缓存;缓存缺失时只做非强制 `panel` 分析兜底,不触发 `force_refresh_observations_only`
|
||||
- 仅当当前温度距 DEB 预测最高 ≤3°C 时推送
|
||||
- 确认过峰值后自动停止
|
||||
|
||||
@@ -69,7 +78,7 @@
|
||||
为了向用户提供接近行情盘的实况响应并降低服务器负载,系统使用 **HTTP snapshot + Server-Sent Events (SSE) Patch + 可重放事件日志** 架构。生产环境推荐 Redis Stream;本地或单进程可回退 SQLite event log。
|
||||
|
||||
### 1. 数据推送链路 (Data Pipeline)
|
||||
1. **Collector 采集端触发**:在 `weather_sources.py` 中,当高频实况源(如 AMOS, CoWIN, MADIS 等)采集到温度更新或观测时间变更时,会调用 `_emit_temperature_patch_if_changed` 过滤重复值,并异步向 `/api/internal/collector-patch` 发送 POST 报文。
|
||||
1. **Observation Collector 采集端触发**:`web.observation_collector_service` 按源频率调用采集层;在 `weather_sources.py` 中,当高频实况源(如 AMOS, AMSC, CoWIN, HKO, MADIS 等)采集到温度更新或观测时间变更时,会调用 `_emit_temperature_patch_if_changed` 过滤重复值,并异步向 `/api/internal/collector-patch` 发送 POST 报文。
|
||||
2. **标准化事件**:`realtime_patch_schema.py` 将旧 `city_patch` 或新 payload 统一成 `city_observation_patch.v1`。
|
||||
3. **事件存储**:生产环境写入 Redis Stream(`stream:city_observation`)并生成全局递增 `revision`;SQLite `observation_patch_events` 保留为本地/兜底 replay。
|
||||
4. **FastAPI SSE 广播**:FastAPI 后端的 `sse_router.py` 根据城市订阅集合向匹配连接推送 patch;断线重连时按 `since_revision` replay。
|
||||
|
||||
@@ -13,13 +13,13 @@
|
||||
| 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 | 中国 |
|
||||
| beijing | AMSC AWOS (ZBAA) | 3 min | 中国 |
|
||||
| shanghai | AMSC AWOS (ZSPD) | 3 min | 中国 |
|
||||
| guangzhou | AMSC AWOS (ZGGG) | 3 min | 中国 |
|
||||
| chengdu | AMSC AWOS (ZUUU) | 3 min | 中国 |
|
||||
| chongqing | AMSC AWOS (ZUCK) | 3 min | 中国 |
|
||||
| wuhan | AMSC AWOS (ZHHH) | 3 min | 中国 |
|
||||
| qingdao | AMSC AWOS (ZSQD) | 3 min | 中国 |
|
||||
|
||||
### Tier 2 — 5 分钟高频 (MADIS)
|
||||
|
||||
@@ -123,7 +123,8 @@ ankara, istanbul, helsinki, amsterdam, paris
|
||||
|
||||
频率取决于源头:
|
||||
|
||||
- AMSC / AMOS / CoWIN / MSS:源头约 1 分钟,图表按 1 分钟粒度追加。
|
||||
- AMOS / CoWIN / MSS:源头约 1 分钟,图表按 1 分钟粒度追加。
|
||||
- AMSC:中国跑道观测城市按 3 分钟采集,不再强制 60 秒刷新。
|
||||
- MADIS:源头约 5 分钟。
|
||||
- HKO / CWA / JMA / FMI / KNMI:源头约 10 分钟。
|
||||
- METAR-only 城市:按 METAR 可用频率和缓存 TTL,不伪装成 1 分钟实测。
|
||||
|
||||
@@ -0,0 +1,345 @@
|
||||
# Feedback Reward Account Detail 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:** Let users see which submitted feedback earned points, how many points were awarded, and the reward reason in the account page.
|
||||
|
||||
**Architecture:** Store reward metadata directly on `user_feedback` rows so the existing feedback list API can expose the reward source without a separate ledger join. The account feedback panel renders reward state inline with each feedback item and stays silent for rows with no reward metadata.
|
||||
|
||||
**Tech Stack:** Python SQLite `DBManager`, FastAPI feedback service, Next.js/React account components, TypeScript source-based business tests.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- Modify `src/database/db_manager.py`: add reward columns, serialize them, and add `update_user_feedback_reward()` for future ops reward workflows.
|
||||
- Modify `tests/test_user_feedback.py`: add backend coverage for reward defaults and reward metadata round trip.
|
||||
- Modify `frontend/types/ops.ts`: add optional reward fields to `UserFeedbackEntry`.
|
||||
- Modify `frontend/components/account/AccountFeedbackPanel.tsx`: render reward labels and reasons inside each feedback row.
|
||||
- Modify `frontend/components/account/__tests__/accountFeedbackPanel.test.ts`: assert the panel handles reward display states.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Backend Feedback Reward Metadata
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/test_user_feedback.py`
|
||||
- Modify: `src/database/db_manager.py`
|
||||
|
||||
- [ ] **Step 1: Write the failing backend test**
|
||||
|
||||
Add this test to `tests/test_user_feedback.py`:
|
||||
|
||||
```python
|
||||
def test_user_feedback_reward_metadata_round_trip(tmp_path):
|
||||
db = DBManager(str(tmp_path / "polyweather-feedback-reward.db"))
|
||||
|
||||
created = db.append_user_feedback(
|
||||
category="data",
|
||||
message="Hong Kong COWIN reading was stale.",
|
||||
user_id="user-reward",
|
||||
user_email="reward@example.com",
|
||||
)
|
||||
|
||||
assert created["reward_points"] == 0
|
||||
assert created["reward_reason"] == ""
|
||||
assert created["reward_status"] == ""
|
||||
assert created["rewarded_at"] is None
|
||||
|
||||
rewarded = db.update_user_feedback_reward(
|
||||
created["id"],
|
||||
points=300,
|
||||
reason="Valid data freshness report",
|
||||
status="granted",
|
||||
)
|
||||
|
||||
assert rewarded is not None
|
||||
assert rewarded["reward_points"] == 300
|
||||
assert rewarded["reward_reason"] == "Valid data freshness report"
|
||||
assert rewarded["reward_status"] == "granted"
|
||||
assert rewarded["rewarded_at"]
|
||||
|
||||
row = db.list_user_feedback(
|
||||
limit=10,
|
||||
user_id="user-reward",
|
||||
user_email="reward@example.com",
|
||||
)[0]
|
||||
assert row["id"] == created["id"]
|
||||
assert row["reward_points"] == 300
|
||||
assert row["reward_reason"] == "Valid data freshness report"
|
||||
assert row["reward_status"] == "granted"
|
||||
assert row["rewarded_at"] == rewarded["rewarded_at"]
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the backend test and verify it fails**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
python -m pytest tests/test_user_feedback.py::test_user_feedback_reward_metadata_round_trip -q
|
||||
```
|
||||
|
||||
Expected: failure because `reward_points` is missing or `update_user_feedback_reward` is undefined.
|
||||
|
||||
- [ ] **Step 3: Add reward columns and serialization**
|
||||
|
||||
In `src/database/db_manager.py`, update `user_feedback` schema and migration:
|
||||
|
||||
```python
|
||||
reward_points INTEGER DEFAULT 0,
|
||||
reward_reason TEXT DEFAULT '',
|
||||
rewarded_at TIMESTAMP,
|
||||
reward_status TEXT DEFAULT ''
|
||||
```
|
||||
|
||||
Add `_ensure_column()` calls for the same columns in the existing migration block.
|
||||
|
||||
Update every feedback `SELECT` to include:
|
||||
|
||||
```sql
|
||||
reward_points, reward_reason, rewarded_at, reward_status
|
||||
```
|
||||
|
||||
Update `_feedback_row_to_dict()` to return:
|
||||
|
||||
```python
|
||||
"reward_points": max(0, int(row["reward_points"] or 0)),
|
||||
"reward_reason": str(row["reward_reason"] or ""),
|
||||
"rewarded_at": row["rewarded_at"],
|
||||
"reward_status": str(row["reward_status"] or ""),
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add reward update method**
|
||||
|
||||
Add this method near `update_user_feedback_status()` in `src/database/db_manager.py`:
|
||||
|
||||
```python
|
||||
def update_user_feedback_reward(
|
||||
self,
|
||||
feedback_id: int,
|
||||
*,
|
||||
points: int,
|
||||
reason: str = "",
|
||||
status: str = "granted",
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
safe_points = max(0, int(points or 0))
|
||||
normalized_reason = str(reason or "").strip()[:500]
|
||||
normalized_status = str(status or "").strip().lower()[:40]
|
||||
if not normalized_status:
|
||||
normalized_status = "granted" if safe_points > 0 else "skipped"
|
||||
now = datetime.now().isoformat()
|
||||
with self._get_connection() as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE user_feedback
|
||||
SET reward_points = ?,
|
||||
reward_reason = ?,
|
||||
reward_status = ?,
|
||||
rewarded_at = ?,
|
||||
updated_at = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(
|
||||
safe_points,
|
||||
normalized_reason,
|
||||
normalized_status,
|
||||
now,
|
||||
now,
|
||||
int(feedback_id),
|
||||
),
|
||||
)
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT id, category, message, source, status, contact, user_id,
|
||||
user_email, context_json, reward_points, reward_reason,
|
||||
rewarded_at, reward_status, created_at, updated_at
|
||||
FROM user_feedback
|
||||
WHERE id = ?
|
||||
""",
|
||||
(int(feedback_id),),
|
||||
).fetchone()
|
||||
conn.commit()
|
||||
return self._feedback_row_to_dict(row) if row else None
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run backend tests**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
python -m pytest tests/test_user_feedback.py -q
|
||||
```
|
||||
|
||||
Expected: all `test_user_feedback.py` tests pass.
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Account Feedback Reward Display
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/types/ops.ts`
|
||||
- Modify: `frontend/components/account/AccountFeedbackPanel.tsx`
|
||||
- Modify: `frontend/components/account/__tests__/accountFeedbackPanel.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing frontend test**
|
||||
|
||||
Extend the final assertion in `frontend/components/account/__tests__/accountFeedbackPanel.test.ts` or add a new assertion:
|
||||
|
||||
```ts
|
||||
assert(
|
||||
feedbackPanelSource.includes("reward_points") &&
|
||||
feedbackPanelSource.includes("reward_reason") &&
|
||||
feedbackPanelSource.includes("reward_status") &&
|
||||
feedbackPanelSource.includes("formatRewardPoints") &&
|
||||
feedbackPanelSource.includes("renderFeedbackReward") &&
|
||||
feedbackPanelSource.includes("奖励原因"),
|
||||
"account feedback panel must show per-feedback reward points and reward reasons",
|
||||
);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the frontend business test and verify it fails**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
cd frontend; npm run test:business -- accountFeedbackPanel
|
||||
```
|
||||
|
||||
Expected: failure because the panel does not yet reference the reward fields.
|
||||
|
||||
- [ ] **Step 3: Extend frontend type**
|
||||
|
||||
Add optional fields to `UserFeedbackEntry` in `frontend/types/ops.ts`:
|
||||
|
||||
```ts
|
||||
reward_points?: number;
|
||||
reward_reason?: string;
|
||||
rewarded_at?: string | null;
|
||||
reward_status?: string;
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add account reward rendering helpers**
|
||||
|
||||
In `AccountFeedbackPanel.tsx`, add helpers:
|
||||
|
||||
```tsx
|
||||
function formatRewardPoints(points?: number) {
|
||||
const value = Math.max(0, Number(points || 0));
|
||||
return `+${value.toLocaleString()} points`;
|
||||
}
|
||||
|
||||
function rewardStatusText(status?: string, isEn = false) {
|
||||
const key = String(status || "").toLowerCase();
|
||||
if (key === "pending") return isEn ? "Reward pending" : "奖励待处理";
|
||||
if (key === "skipped") return isEn ? "No points awarded" : "未发放积分";
|
||||
return isEn ? "Feedback reward" : "反馈奖励";
|
||||
}
|
||||
|
||||
function renderFeedbackReward(entry: UserFeedbackEntry, isEn: boolean) {
|
||||
const points = Math.max(0, Number(entry.reward_points || 0));
|
||||
const rewardStatus = String(entry.reward_status || "").toLowerCase();
|
||||
const reason = String(entry.reward_reason || "").trim();
|
||||
if (points <= 0 && !rewardStatus && !reason) return null;
|
||||
const granted = points > 0 || rewardStatus === "granted";
|
||||
return (
|
||||
<div className={`mt-2 rounded-lg border px-3 py-2 text-xs ${
|
||||
granted
|
||||
? "border-amber-200 bg-amber-50 text-amber-800"
|
||||
: "border-slate-200 bg-slate-50 text-slate-600"
|
||||
}`}>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="font-bold">{rewardStatusText(rewardStatus, isEn)}</span>
|
||||
{points > 0 ? <span className="font-mono font-black">{formatRewardPoints(points)}</span> : null}
|
||||
</div>
|
||||
{reason ? (
|
||||
<div className="mt-1 leading-5 text-slate-600">
|
||||
{isEn ? "Reason" : "奖励原因"}: {reason}
|
||||
</div>
|
||||
) : null}
|
||||
{entry.rewarded_at ? (
|
||||
<div className="mt-1 font-mono text-[11px] text-slate-400">
|
||||
{compactDate(entry.rewarded_at)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Render reward detail in each feedback row**
|
||||
|
||||
Inside each feedback row, after the feedback message paragraph, add:
|
||||
|
||||
```tsx
|
||||
{renderFeedbackReward(entry, isEn)}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Run frontend business test**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
cd frontend; npm run test:business -- accountFeedbackPanel
|
||||
```
|
||||
|
||||
Expected: `accountFeedbackPanel` passes.
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Verification and Commit
|
||||
|
||||
**Files:**
|
||||
- Verify all touched files.
|
||||
|
||||
- [ ] **Step 1: Run targeted backend verification**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
python -m pytest tests/test_user_feedback.py -q
|
||||
```
|
||||
|
||||
Expected: all tests pass.
|
||||
|
||||
- [ ] **Step 2: Run targeted frontend verification**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
cd frontend; npm run test:business -- accountFeedbackPanel
|
||||
```
|
||||
|
||||
Expected: `accountFeedbackPanel` test passes.
|
||||
|
||||
- [ ] **Step 3: Run broader low-cost verification**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
python -m ruff check .
|
||||
cd frontend; npm run typecheck
|
||||
```
|
||||
|
||||
Expected: both commands pass.
|
||||
|
||||
- [ ] **Step 4: Check status and commit**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
git status --short
|
||||
git diff --check
|
||||
git add docs/superpowers/plans/2026-06-08-feedback-reward-account-detail.md tests/test_user_feedback.py src/database/db_manager.py frontend/types/ops.ts frontend/components/account/AccountFeedbackPanel.tsx frontend/components/account/__tests__/accountFeedbackPanel.test.ts
|
||||
git commit -m "Show feedback reward details in account"
|
||||
```
|
||||
|
||||
Expected: commit succeeds with only planned files staged.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
- Spec coverage: reward metadata fields, existing API payload, account inline display, missing metadata fallback, and future ops reuse are covered.
|
||||
- Scope control: the plan does not implement automatic reward issuance or external notifications.
|
||||
- Type consistency: frontend fields use `reward_points`, `reward_reason`, `rewarded_at`, and `reward_status`, matching backend serialization.
|
||||
@@ -0,0 +1,124 @@
|
||||
# Feedback Reward Account Detail Design
|
||||
|
||||
## Goal
|
||||
|
||||
Show users which submitted feedback earned points, how many points were awarded, and why.
|
||||
|
||||
The first implementation focuses on account-page visibility. The later ops workflow for automatic reward issuance should reuse the same fields and display contract.
|
||||
|
||||
## Current Context
|
||||
|
||||
- `user_feedback` already stores each submitted feedback item with `status`, `message`, `user_id`, `user_email`, timestamps, and context JSON.
|
||||
- `/api/feedback` already returns the current user's feedback list.
|
||||
- `AccountFeedbackPanel` already renders the current user's feedback in the account page.
|
||||
- Current points balance is shown through `/api/auth/me`, but account UI has no per-feedback reward source detail.
|
||||
- Existing points grant helpers update `users.points`; feedback-specific rewards are not yet linked back to a feedback row.
|
||||
|
||||
## Scope
|
||||
|
||||
In scope:
|
||||
|
||||
- Add feedback reward metadata to feedback rows.
|
||||
- Return reward metadata through the existing current-user feedback API.
|
||||
- Display feedback reward details in the account page next to the matching feedback item.
|
||||
- Keep the data contract ready for future ops automation: a processed feedback item can carry reward points and a human-readable reason.
|
||||
|
||||
Out of scope for this phase:
|
||||
|
||||
- Building the ops action that decides and grants points.
|
||||
- Sending push/email/Telegram notifications after ops processing.
|
||||
- Rebuilding a general points ledger for all point sources.
|
||||
- Merging historical Supabase referral ledger entries into the account page.
|
||||
|
||||
## Data Model
|
||||
|
||||
Extend `user_feedback` with nullable columns:
|
||||
|
||||
- `reward_points INTEGER DEFAULT 0`
|
||||
- `reward_reason TEXT DEFAULT ''`
|
||||
- `rewarded_at TIMESTAMP`
|
||||
- `reward_status TEXT DEFAULT ''`
|
||||
|
||||
Recommended `reward_status` values:
|
||||
|
||||
- empty string: no reward decision recorded
|
||||
- `granted`: points were awarded for this feedback
|
||||
- `skipped`: reviewed but no points awarded
|
||||
- `pending`: reward decision is queued or awaiting processing
|
||||
|
||||
The display should treat `reward_points > 0` as the strongest signal that the user earned points.
|
||||
|
||||
## API Contract
|
||||
|
||||
`DBManager._feedback_row_to_dict()` should include:
|
||||
|
||||
- `reward_points`
|
||||
- `reward_reason`
|
||||
- `rewarded_at`
|
||||
- `reward_status`
|
||||
|
||||
`GET /api/feedback` should continue using the same response shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"feedback": [
|
||||
{
|
||||
"id": 123,
|
||||
"status": "resolved",
|
||||
"message": "Hong Kong COWIN reading looked stale",
|
||||
"reward_points": 300,
|
||||
"reward_reason": "Valid data freshness report",
|
||||
"rewarded_at": "2026-06-08T12:00:00",
|
||||
"reward_status": "granted"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
The frontend proxy does not need a new route if it transparently forwards the existing payload.
|
||||
|
||||
## Account UI
|
||||
|
||||
`AccountFeedbackPanel` should show reward detail inside each feedback row:
|
||||
|
||||
- Granted reward: `+300 points` plus reward reason.
|
||||
- Skipped reward: a muted note that no points were awarded, with reason if available.
|
||||
- Pending reward: a small pending label.
|
||||
- No reward metadata: render nothing extra to avoid noise.
|
||||
|
||||
The account page should remain dense and scannable. Reward content belongs inside the existing feedback row, not in a separate marketing-style card.
|
||||
|
||||
## Future Ops Workflow
|
||||
|
||||
The later feedback-processing feature can use a single backend operation:
|
||||
|
||||
1. Update feedback status.
|
||||
2. Grant points to the matching user.
|
||||
3. Write `reward_points`, `reward_reason`, `rewarded_at`, and `reward_status = granted` back to the same feedback row.
|
||||
|
||||
If the grant fails, the operation should not mark the feedback as granted. It should either keep `reward_status = pending` or return a clear ops error.
|
||||
|
||||
## Error Handling
|
||||
|
||||
- Missing reward fields should default to zero or empty strings in API responses.
|
||||
- If old SQLite databases do not have the new columns, `DBManager` migration should add them with `_ensure_column`.
|
||||
- Account UI should tolerate partial payloads without crashing.
|
||||
- Reward reason should be bounded in storage and display to avoid oversized rows.
|
||||
|
||||
## Testing
|
||||
|
||||
Backend tests:
|
||||
|
||||
- Existing feedback rows serialize reward fields with defaults.
|
||||
- Updating reward metadata for a feedback row returns the fields in `/api/feedback`.
|
||||
- Old rows without rewards still list normally.
|
||||
|
||||
Frontend tests:
|
||||
|
||||
- Account feedback panel displays `+N points` and reason for rewarded feedback.
|
||||
- Skipped and pending reward states render without implying points were granted.
|
||||
- Rows without reward metadata do not show an empty reward block.
|
||||
|
||||
## Rollout
|
||||
|
||||
This is a backward-compatible schema and UI change. Existing feedback rows keep working with empty reward metadata. The account page can ship before the ops reward action; it will simply show reward details once rows carry those fields.
|
||||
+10
-21
@@ -9,7 +9,7 @@ PolyWeather Pro 的生产前端工程。
|
||||
|
||||
- Next.js App Router
|
||||
- React + Tailwind
|
||||
- Leaflet + Recharts
|
||||
- 自研温度图表 + Recharts 运营图表
|
||||
- Supabase Auth
|
||||
- WalletConnect + 浏览器 EVM 钱包
|
||||
|
||||
@@ -21,30 +21,19 @@ PolyWeather Pro 的生产前端工程。
|
||||
|
||||
## 当前前端能力
|
||||
|
||||
- 主站 Dashboard 支持地图、城市详情、今日日内分析和账户中心
|
||||
- `/docs` 已提供公开双语产品文档中心,解释日内分析、校准概率、模型栈、TAF 和结算来源
|
||||
- 主站为实时天气决策台,包含 `天气决策 / 训练数据 / 使用指南` 三个主入口
|
||||
- `/docs` 提供公开双语产品文档中心,当前保留简介、图表阅读、实时数据频率、结算站点和浏览器插件说明
|
||||
- 天气决策台支持 1x1 到 3x3 图表槽位,可按区域、搜索和城市选择进行多城市巡检
|
||||
- 终端图表默认展示全天,可切换高温窗口;所有横轴与 tooltip 时间按城市当地时间渲染
|
||||
- 可见终端图表通过 SSE patch 无痛增量刷新,后台切回前台时主动补齐最新 detail,不用 loading 遮罩覆盖已有曲线
|
||||
- 可见终端图表通过 SSE patch 增量刷新,后台切回前台时主动补齐最新 detail,不用 loading 遮罩覆盖已有曲线
|
||||
- AMSC/AMOS 跑道城市默认展示结算跑道曲线并高亮,辅助跑道弱化展示;单跑道机场不重复展示聚合线
|
||||
- 香港默认展示 CoWIN 6087 参考站 1 分钟曲线,并保留 HKO 10 分钟官方气象层
|
||||
- legacy 高斯概率在图表上展示为概率温度带和 `mu` 参考线,不作为时间序列曲线
|
||||
- 今日日内分析支持:
|
||||
- `锚点状态`
|
||||
- `当前节奏`
|
||||
- `校准模型概率`
|
||||
- `模型区间与分歧`
|
||||
- `专业气象结论条`
|
||||
- `气象证据链 / 失效条件 / 确认条件`
|
||||
- 非香港机场城市的 `TAF` 时段提示与走势图联动
|
||||
- 使用指南内置图表阅读顺序、图层含义、常用操作和默认可见性规则
|
||||
- `/ops` 已支持桌面表格 + 手机端卡片化视图
|
||||
- 点击城市图标后会显示地图顶部同步提醒与详情面板内同步徽标,避免用户误判为卡住
|
||||
- 城市详情会自动识别“单模型 / 单日”的稀疏缓存并主动刷新,避免误把残缺 detail 当作完整结果
|
||||
- 右侧详情面板在多日预报仍未补齐时会显示同步占位卡,不再把“只有今天一张卡”的中间态伪装成完整数据
|
||||
- 日内分析弹窗在 full detail / market scan 同步时会锁住旧内容并显示刷新状态,避免用户短暂看到旧城市或旧日期的数据
|
||||
- 城市决策卡支持从地图点击城市进入;机会榜和日历仍按 Pro 权限控制,地图探索和城市简报可作为轻量入口
|
||||
- 城市决策卡展示结构化实况、模型区间、市场温度桶和模型-市场差,不再请求 AI 解读
|
||||
- 市场价格层使用完整 `all_buckets` 匹配温度桶,并把 `模型-市场差` 解释为 `模型概率 - 市场隐含概率`
|
||||
- 概率区展示当前生产概率引擎输出(legacy 高斯或 EMOS),模型共识只作为辅助参考
|
||||
- 城市 detail 自动识别稀疏缓存并主动刷新,避免误把残缺 detail 当作完整结果
|
||||
- 市场价格层使用完整 `all_buckets` 匹配温度桶,市场信号作为交易判断层,不替代实测结算源
|
||||
- legacy 高斯概率在图表上展示为概率温度带和 `mu` 参考线,不作为时间序列曲线
|
||||
- 账户中心支付区支持后端下发的多链网络选择;Polygon 继续走 checkout 合约,Ethereum 主网 USDC 走手动直转确认
|
||||
- 缓存桶状态与 summary cache hit/miss
|
||||
|
||||
@@ -191,7 +180,7 @@ Ops:
|
||||
- `summary?force_refresh=true`:`no-store`
|
||||
- 支付相关路由:`no-store`
|
||||
- 当 detail 缓存只返回单模型或单日 forecast 时,前端会自动强刷完整 detail,并在补齐前显示同步提示 / 占位卡
|
||||
- 今日日内分析打开时如果正在切换城市、日期或 detail 深度,弹窗会阻断旧内容点击并显示刷新锁
|
||||
- detail 正在切换城市、日期或分辨率时,图表保留已有曲线并显示同步提示,避免把旧数据当作当前状态
|
||||
- 终端图表订阅 `/api/events?cities=...&since_revision=...&replay_limit=按可见城市数动态限制`,接收 `city_observation_patch.v1`;无 patch 超过 2 分钟时,可见图表才触发 60 秒兜底刷新
|
||||
- 前端只消费 HTTP snapshot + SSE patch,不直接感知 Redis;Redis Stream / SQLite event log 都由后端统一封装
|
||||
|
||||
|
||||
@@ -92,6 +92,14 @@ function formatServerTiming(stages: AuthMeTimingStage[], totalMs: number) {
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
function buildBackendAuthMeUrl(req: NextRequest) {
|
||||
const url = new URL(`${API_BASE!.replace(/\/+$/, "")}/api/auth/me`);
|
||||
if (req.nextUrl.searchParams.get("scope") === "entitlement") {
|
||||
url.searchParams.set("scope", "entitlement");
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function finishAuthMeResponse(
|
||||
response: NextResponse,
|
||||
timer: AuthMeTimer,
|
||||
@@ -101,6 +109,7 @@ function finishAuthMeResponse(
|
||||
const total = timer.totalMs();
|
||||
const ownServerTiming = formatServerTiming(timer.stages, total);
|
||||
const backendServerTiming = String(extra?.backendServerTiming || "").trim();
|
||||
response.headers.set("Cache-Control", "no-store");
|
||||
response.headers.set(
|
||||
"Server-Timing",
|
||||
backendServerTiming
|
||||
@@ -486,7 +495,7 @@ export async function GET(req: NextRequest) {
|
||||
let res: Response;
|
||||
try {
|
||||
res = await timer.measure("backend_fetch", async () =>
|
||||
await fetch(`${API_BASE}/api/auth/me`, {
|
||||
await fetch(buildBackendAuthMeUrl(req), {
|
||||
headers: backendAuth.headers,
|
||||
cache: "no-store",
|
||||
signal: controller.signal,
|
||||
|
||||
@@ -8,9 +8,41 @@ import {
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
const DETAIL_BATCH_PROXY_TIMEOUT_MS = Number(
|
||||
process.env.POLYWEATHER_CITY_DETAIL_BATCH_PROXY_TIMEOUT_MS || "12000",
|
||||
process.env.POLYWEATHER_CITY_DETAIL_BATCH_PROXY_TIMEOUT_MS || "15000",
|
||||
);
|
||||
|
||||
function parseRequestedCities(req: NextRequest) {
|
||||
const requestedLimit = Number(req.nextUrl.searchParams.get("limit") || "12");
|
||||
const limit = Number.isFinite(requestedLimit)
|
||||
? Math.max(1, Math.min(24, requestedLimit))
|
||||
: 12;
|
||||
const seen = new Set<string>();
|
||||
const requestedCities: string[] = [];
|
||||
|
||||
for (const item of (req.nextUrl.searchParams.get("cities") || "").split(
|
||||
",",
|
||||
)) {
|
||||
const city = item.trim();
|
||||
if (!city || seen.has(city)) continue;
|
||||
seen.add(city);
|
||||
requestedCities.push(city);
|
||||
if (requestedCities.length >= limit) break;
|
||||
}
|
||||
|
||||
return requestedCities;
|
||||
}
|
||||
|
||||
function buildCityDetailBatchTimeoutPayload(requestedCities: string[]) {
|
||||
return {
|
||||
cities: requestedCities,
|
||||
details: {},
|
||||
errors: {},
|
||||
missing: requestedCities,
|
||||
partial: true,
|
||||
timeout: true,
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const timer = createProxyTimer(req, "city_detail_batch");
|
||||
if (!API_BASE) {
|
||||
@@ -25,6 +57,7 @@ export async function GET(req: NextRequest) {
|
||||
}
|
||||
|
||||
const forceRefresh = req.nextUrl.searchParams.get("force_refresh") ?? "false";
|
||||
const requestedCities = parseRequestedCities(req);
|
||||
const cachePolicy = buildCityDetailProxyCachePolicy(forceRefresh, 15);
|
||||
const searchParams = new URLSearchParams({
|
||||
cities: req.nextUrl.searchParams.get("cities") || "",
|
||||
@@ -52,6 +85,11 @@ export async function GET(req: NextRequest) {
|
||||
publicMessage: "Failed to fetch city detail batch",
|
||||
revalidateSeconds: cachePolicy.revalidateSeconds,
|
||||
signal: controller.signal,
|
||||
timeoutResponse: () =>
|
||||
NextResponse.json(buildCityDetailBatchTimeoutPayload(requestedCities), {
|
||||
headers: { "Cache-Control": "no-store, max-age=0" },
|
||||
status: 200,
|
||||
}),
|
||||
timeoutPublicMessage: "City detail batch request timed out",
|
||||
timing: timer,
|
||||
url: `${API_BASE}/api/cities/detail-batch?${searchParams.toString()}`,
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
requireBackendPaymentAuth,
|
||||
} from "@/lib/backend-auth";
|
||||
import {
|
||||
buildProxyExceptionResponse,
|
||||
buildUpstreamErrorResponse,
|
||||
} 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 },
|
||||
);
|
||||
}
|
||||
|
||||
let auth: Awaited<ReturnType<typeof buildBackendRequestHeaders>> | null = null;
|
||||
try {
|
||||
auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireBackendPaymentAuth(auth);
|
||||
if (authError) return authError;
|
||||
|
||||
const upstream = new URL(`${API_BASE}/api/feedback`);
|
||||
const limit = req.nextUrl.searchParams.get("limit");
|
||||
if (limit) upstream.searchParams.set("limit", limit);
|
||||
|
||||
const res = await fetch(upstream, {
|
||||
method: "GET",
|
||||
headers: auth.headers,
|
||||
cache: "no-store",
|
||||
});
|
||||
const raw = await res.text();
|
||||
if (!res.ok) {
|
||||
return applyAuthResponseCookies(
|
||||
buildUpstreamErrorResponse(res.status, raw, {
|
||||
detailLimit: 260,
|
||||
error: "Feedback status request failed",
|
||||
}),
|
||||
auth.response,
|
||||
);
|
||||
}
|
||||
const response = new NextResponse(raw, {
|
||||
status: res.status,
|
||||
headers: {
|
||||
"Cache-Control": "no-store",
|
||||
"Content-Type": res.headers.get("content-type") || "application/json",
|
||||
},
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
const response = buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to fetch feedback status",
|
||||
});
|
||||
return auth ? applyAuthResponseCookies(response, auth.response) : response;
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
let auth: Awaited<ReturnType<typeof buildBackendRequestHeaders>> | null = null;
|
||||
try {
|
||||
const body = await req.json();
|
||||
auth = await buildBackendRequestHeaders(req);
|
||||
const headers = new Headers(auth.headers);
|
||||
headers.set("Content-Type", "application/json");
|
||||
|
||||
const res = await fetch(`${API_BASE}/api/feedback`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
cache: "no-store",
|
||||
});
|
||||
const raw = await res.text();
|
||||
if (!res.ok) {
|
||||
return applyAuthResponseCookies(
|
||||
buildUpstreamErrorResponse(res.status, raw, {
|
||||
detailLimit: 260,
|
||||
error: "Feedback request failed",
|
||||
}),
|
||||
auth.response,
|
||||
);
|
||||
}
|
||||
const response = new NextResponse(raw, {
|
||||
status: res.status,
|
||||
headers: {
|
||||
"Cache-Control": "no-store",
|
||||
"Content-Type": res.headers.get("content-type") || "application/json",
|
||||
},
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
const response = buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to submit feedback",
|
||||
});
|
||||
return auth ? applyAuthResponseCookies(response, auth.response) : response;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
context: { params: Promise<{ feedbackId: string }> },
|
||||
) {
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireOpsProxyAuth(req, auth);
|
||||
if (authError) return authError;
|
||||
|
||||
const { feedbackId } = await context.params;
|
||||
const body = await req.json();
|
||||
const res = await fetch(
|
||||
`${API_BASE}/api/ops/feedback/${encodeURIComponent(feedbackId)}/reward`,
|
||||
{
|
||||
method: "POST",
|
||||
cache: "no-store",
|
||||
headers: {
|
||||
...Object.fromEntries(new Headers(auth.headers).entries()),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
);
|
||||
const raw = await res.text();
|
||||
const response = new NextResponse(raw, {
|
||||
headers: {
|
||||
"Cache-Control": "no-store",
|
||||
"Content-Type": res.headers.get("content-type") || "application/json",
|
||||
},
|
||||
status: res.status,
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to grant feedback reward",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
context: { params: Promise<{ feedbackId: string }> },
|
||||
) {
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireOpsProxyAuth(req, auth);
|
||||
if (authError) return authError;
|
||||
|
||||
const { feedbackId } = await context.params;
|
||||
const body = await req.json();
|
||||
const res = await fetch(
|
||||
`${API_BASE}/api/ops/feedback/${encodeURIComponent(feedbackId)}/status`,
|
||||
{
|
||||
method: "POST",
|
||||
cache: "no-store",
|
||||
headers: {
|
||||
...Object.fromEntries(new Headers(auth.headers).entries()),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
);
|
||||
const raw = await res.text();
|
||||
const response = new NextResponse(raw, {
|
||||
headers: {
|
||||
"Cache-Control": "no-store",
|
||||
"Content-Type": res.headers.get("content-type") || "application/json",
|
||||
},
|
||||
status: res.status,
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to update feedback status",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireOpsProxyAuth(req, auth);
|
||||
if (authError) return authError;
|
||||
|
||||
const upstream = new URL(`${API_BASE}/api/ops/feedback`);
|
||||
req.nextUrl.searchParams.forEach((value, key) => {
|
||||
upstream.searchParams.set(key, value);
|
||||
});
|
||||
|
||||
const res = await fetch(upstream.toString(), {
|
||||
cache: "no-store",
|
||||
headers: auth.headers,
|
||||
});
|
||||
const raw = await res.text();
|
||||
const response = new NextResponse(raw, {
|
||||
headers: {
|
||||
"Cache-Control": "no-store",
|
||||
"Content-Type": res.headers.get("content-type") || "application/json",
|
||||
},
|
||||
status: res.status,
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to fetch ops feedback",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireOpsProxyAuth(req, auth);
|
||||
if (authError) return authError;
|
||||
|
||||
const upstream = new URL(`${API_BASE}/api/ops/observation-collector-status`);
|
||||
req.nextUrl.searchParams.forEach((value, key) => {
|
||||
upstream.searchParams.set(key, value);
|
||||
});
|
||||
|
||||
const res = await fetch(upstream.toString(), {
|
||||
cache: "no-store",
|
||||
headers: auth.headers,
|
||||
});
|
||||
const raw = await res.text();
|
||||
const response = new NextResponse(raw, {
|
||||
headers: {
|
||||
"Cache-Control": "no-store",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
status: res.status,
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Observation collector status check failed",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,35 @@ import {
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
function looksLikeHtmlDocument(value: string) {
|
||||
const text = String(value || "").trim().toLowerCase();
|
||||
return (
|
||||
text.startsWith("<!doctype html") ||
|
||||
text.startsWith("<html") ||
|
||||
/<title>[^<]*(50\d|cloudflare|polyweather\.top)/i.test(String(value || ""))
|
||||
);
|
||||
}
|
||||
|
||||
function submitErrorMessage(raw: string) {
|
||||
try {
|
||||
const parsed = JSON.parse(String(raw || "")) as {
|
||||
detail?: unknown;
|
||||
error?: unknown;
|
||||
message?: unknown;
|
||||
};
|
||||
const message = [parsed.detail, parsed.error, parsed.message].find(
|
||||
(item) => typeof item === "string" && item.trim(),
|
||||
);
|
||||
if (typeof message === "string") {
|
||||
const trimmed = message.trim();
|
||||
if (!looksLikeHtmlDocument(trimmed)) return trimmed.slice(0, 350);
|
||||
}
|
||||
} catch {
|
||||
// Non-JSON upstream errors are commonly HTML 50x pages; do not expose them.
|
||||
}
|
||||
return "Payment submit upstream failed";
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
context: { params: Promise<{ intentId: string }> },
|
||||
@@ -40,14 +69,9 @@ export async function POST(
|
||||
);
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
let detail = raw.slice(0, 350);
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed.detail) detail = String(parsed.detail).slice(0, 350);
|
||||
} catch {}
|
||||
const response = buildUpstreamErrorResponse(res.status, raw, {
|
||||
detailLimit: 350,
|
||||
error: detail || undefined,
|
||||
error: submitErrorMessage(raw),
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,42 @@ import {
|
||||
} from "@/lib/api-proxy";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
const BACKEND_RETRY_DELAY_MS = 250;
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function fetchWalletChallengeWithRetry(
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
attempts = 2,
|
||||
) {
|
||||
let lastError: unknown;
|
||||
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
||||
try {
|
||||
return await fetch(url, init);
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (attempt >= attempts) break;
|
||||
await sleep(BACKEND_RETRY_DELAY_MS * attempt);
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
function logWalletChallengeProxyError(
|
||||
error: unknown,
|
||||
authDebug: Record<string, unknown>,
|
||||
) {
|
||||
const source = error as { name?: unknown; message?: unknown; cause?: unknown };
|
||||
console.error("[payment-wallet-challenge-proxy-exception]", {
|
||||
error_name: String(source?.name || "Error"),
|
||||
error_message: String(source?.message || error || "unknown"),
|
||||
error_cause: source?.cause ? String(source.cause) : "",
|
||||
...authDebug,
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
if (!API_BASE) {
|
||||
@@ -18,31 +54,45 @@ export async function POST(req: NextRequest) {
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid wallet challenge request" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
let authDebug: Record<string, unknown> = {};
|
||||
try {
|
||||
const body = await req.json();
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireBackendPaymentAuth(auth);
|
||||
if (authError) return authError;
|
||||
const proxiedHeaders = new Headers(auth.headers);
|
||||
proxiedHeaders.set("Content-Type", "application/json");
|
||||
const res = await fetch(`${API_BASE}/api/payments/wallets/challenge`, {
|
||||
method: "POST",
|
||||
headers: proxiedHeaders,
|
||||
body: JSON.stringify(body ?? {}),
|
||||
cache: "no-store",
|
||||
});
|
||||
authDebug = {
|
||||
incoming_has_authorization: Boolean(
|
||||
String(req.headers.get("authorization") || "").trim(),
|
||||
),
|
||||
has_authorization: proxiedHeaders.has("authorization"),
|
||||
has_entitlement: proxiedHeaders.has("x-polyweather-entitlement"),
|
||||
has_forwarded_user_id: proxiedHeaders.has("x-polyweather-auth-user-id"),
|
||||
has_forwarded_email: proxiedHeaders.has("x-polyweather-auth-email"),
|
||||
};
|
||||
const res = await fetchWalletChallengeWithRetry(
|
||||
`${API_BASE}/api/payments/wallets/challenge`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: proxiedHeaders,
|
||||
body: JSON.stringify(body ?? {}),
|
||||
cache: "no-store",
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
const response = buildUpstreamErrorResponse(res.status, raw, {
|
||||
detailLimit: 350,
|
||||
extraDebug: {
|
||||
has_authorization: proxiedHeaders.has("authorization"),
|
||||
has_entitlement: proxiedHeaders.has("x-polyweather-entitlement"),
|
||||
has_forwarded_user_id: proxiedHeaders.has(
|
||||
"x-polyweather-auth-user-id",
|
||||
),
|
||||
has_forwarded_email: proxiedHeaders.has("x-polyweather-auth-email"),
|
||||
},
|
||||
extraDebug: authDebug,
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
@@ -50,8 +100,12 @@ export async function POST(req: NextRequest) {
|
||||
const response = NextResponse.json(data);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
logWalletChallengeProxyError(error, authDebug);
|
||||
return buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to create wallet challenge",
|
||||
status: 502,
|
||||
publicMessage:
|
||||
"Wallet challenge service is temporarily unavailable. Please retry.",
|
||||
extra: { retryable: true },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
const SCAN_TERMINAL_PROXY_TIMEOUT_MS = Number(
|
||||
process.env.POLYWEATHER_SCAN_TERMINAL_PROXY_TIMEOUT_MS || "18000",
|
||||
process.env.POLYWEATHER_SCAN_TERMINAL_PROXY_TIMEOUT_MS || "35000",
|
||||
);
|
||||
|
||||
export const maxDuration = 45;
|
||||
|
||||
@@ -14,6 +14,31 @@ function normalizeNextPath(input: string | null) {
|
||||
return raw;
|
||||
}
|
||||
|
||||
function normalizeAuthError(input: string | null) {
|
||||
const fallback = "Authentication failed. Please sign in again.";
|
||||
const raw = String(input || "")
|
||||
.replace(/[\r\n]+/g, " ")
|
||||
.trim();
|
||||
if (!raw) return fallback;
|
||||
return raw.slice(0, 240);
|
||||
}
|
||||
|
||||
function redirectToLoginWithError({
|
||||
baseUrl,
|
||||
error,
|
||||
nextPath,
|
||||
}: {
|
||||
baseUrl: string;
|
||||
error: string | null;
|
||||
nextPath: string;
|
||||
}) {
|
||||
const loginUrl = new URL("/auth/login", baseUrl);
|
||||
loginUrl.searchParams.set("next", nextPath);
|
||||
loginUrl.searchParams.set("auth_error", "1");
|
||||
loginUrl.searchParams.set("error", normalizeAuthError(error));
|
||||
return NextResponse.redirect(loginUrl);
|
||||
}
|
||||
|
||||
async function warmSignupTrial(accessToken: string) {
|
||||
const token = String(accessToken || "").trim();
|
||||
if (!API_BASE || !token) return;
|
||||
@@ -30,7 +55,7 @@ async function warmSignupTrial(accessToken: string) {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 2500);
|
||||
try {
|
||||
await fetch(`${API_BASE}/api/auth/me`, {
|
||||
await fetch(`${API_BASE.replace(/\/+$/, "")}/api/auth/me?scope=entitlement`, {
|
||||
cache: "no-store",
|
||||
headers,
|
||||
signal: controller.signal,
|
||||
@@ -60,6 +85,16 @@ export async function GET(request: NextRequest) {
|
||||
const nextPath = normalizeNextPath(request.nextUrl.searchParams.get("next"));
|
||||
const baseUrl = siteUrl || request.nextUrl.origin;
|
||||
const redirectUrl = new URL(nextPath, baseUrl);
|
||||
const callbackError =
|
||||
request.nextUrl.searchParams.get("error_description") ||
|
||||
request.nextUrl.searchParams.get("error");
|
||||
if (callbackError) {
|
||||
return redirectToLoginWithError({
|
||||
baseUrl,
|
||||
error: callbackError,
|
||||
nextPath,
|
||||
});
|
||||
}
|
||||
|
||||
if (!hasSupabaseServerEnv()) {
|
||||
return NextResponse.redirect(redirectUrl);
|
||||
@@ -71,7 +106,22 @@ export async function GET(request: NextRequest) {
|
||||
if (code) {
|
||||
const {
|
||||
data: { session },
|
||||
error: exchangeError,
|
||||
} = await supabase.auth.exchangeCodeForSession(code);
|
||||
if (exchangeError) {
|
||||
return redirectToLoginWithError({
|
||||
baseUrl,
|
||||
error: exchangeError.message,
|
||||
nextPath,
|
||||
});
|
||||
}
|
||||
if (!session?.access_token) {
|
||||
return redirectToLoginWithError({
|
||||
baseUrl,
|
||||
error: "Authentication session was not created. Please sign in again.",
|
||||
nextPath,
|
||||
});
|
||||
}
|
||||
await warmSignupTrial(session?.access_token || "");
|
||||
}
|
||||
|
||||
|
||||
@@ -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<{ error?: string; mode?: string; next?: string }>;
|
||||
};
|
||||
|
||||
function normalizeNextPath(input: string | undefined) {
|
||||
@@ -19,13 +19,26 @@ function normalizeMode(input: string | undefined): "login" | "signup" {
|
||||
return "login";
|
||||
}
|
||||
|
||||
function normalizeAuthError(input: string | undefined) {
|
||||
const raw = String(input || "")
|
||||
.replace(/[\r\n]+/g, " ")
|
||||
.trim();
|
||||
if (!raw) return "";
|
||||
return raw.slice(0, 240);
|
||||
}
|
||||
|
||||
export default async function LoginPage({ searchParams }: PageProps) {
|
||||
const params = (await searchParams) || {};
|
||||
const nextPath = normalizeNextPath(params.next);
|
||||
const initialMode = normalizeMode(params.mode);
|
||||
const initialError = normalizeAuthError(params.error);
|
||||
return (
|
||||
<I18nProvider>
|
||||
<LoginClient nextPath={nextPath} initialMode={initialMode} />
|
||||
<LoginClient
|
||||
nextPath={nextPath}
|
||||
initialMode={initialMode}
|
||||
initialError={initialError}
|
||||
/>
|
||||
</I18nProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -285,6 +285,133 @@
|
||||
0%, 100% { opacity: 0.3; }
|
||||
50% { opacity: 0.6; }
|
||||
}
|
||||
|
||||
@keyframes landingRise {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(18px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes landingFloat {
|
||||
0%,
|
||||
100% {
|
||||
translate: 0 0;
|
||||
}
|
||||
50% {
|
||||
translate: 0 -10px;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes landingScan {
|
||||
0% {
|
||||
transform: translateY(-120%);
|
||||
opacity: 0;
|
||||
}
|
||||
18%,
|
||||
72% {
|
||||
opacity: 0.48;
|
||||
}
|
||||
100% {
|
||||
transform: translateY(120%);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes landingPulseDot {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(37, 99, 235, 0.22);
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 7px rgba(37, 99, 235, 0);
|
||||
transform: scale(1.08);
|
||||
}
|
||||
}
|
||||
|
||||
.landing-rise {
|
||||
opacity: 0;
|
||||
animation: landingRise 720ms cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
}
|
||||
|
||||
.landing-delay-1 {
|
||||
animation-delay: 100ms;
|
||||
}
|
||||
|
||||
.landing-delay-2 {
|
||||
animation-delay: 190ms;
|
||||
}
|
||||
|
||||
.landing-delay-3 {
|
||||
animation-delay: 280ms;
|
||||
}
|
||||
|
||||
.landing-float {
|
||||
animation: landingFloat 7s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.landing-float-slow {
|
||||
animation: landingFloat 9s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.landing-hover-lift {
|
||||
transition:
|
||||
transform 220ms ease,
|
||||
border-color 220ms ease,
|
||||
box-shadow 220ms ease;
|
||||
}
|
||||
|
||||
.landing-hover-lift:hover {
|
||||
transform: translateY(-4px);
|
||||
border-color: rgba(148, 163, 184, 0.78);
|
||||
box-shadow: 0 18px 44px rgba(15, 23, 42, 0.1);
|
||||
}
|
||||
|
||||
.landing-screen-glow {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.landing-screen-glow::after {
|
||||
content: "";
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
inset: 44px 8px 8px;
|
||||
border-radius: 6px;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
transparent 0%,
|
||||
rgba(37, 99, 235, 0.12) 48%,
|
||||
transparent 100%
|
||||
);
|
||||
mix-blend-mode: multiply;
|
||||
opacity: 0;
|
||||
animation: landingScan 5.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.landing-pulse-dot {
|
||||
animation: landingPulseDot 2.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.landing-rise,
|
||||
.landing-float,
|
||||
.landing-float-slow,
|
||||
.landing-screen-glow::after,
|
||||
.landing-pulse-dot {
|
||||
opacity: 1;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.landing-hover-lift,
|
||||
.landing-hover-lift:hover {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Reduced motion: disable all animations and transitions ── */
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { Metadata } from "next";
|
||||
import { requireOpsAdmin } from "@/lib/ops-admin";
|
||||
import { FeedbackPageClient } from "@/components/ops/feedback/FeedbackPageClient";
|
||||
|
||||
export const metadata: Metadata = { title: "用户反馈 — PolyWeather Ops" };
|
||||
|
||||
export default async function OpsFeedbackPage() {
|
||||
await requireOpsAdmin("/ops/feedback");
|
||||
return <FeedbackPageClient />;
|
||||
}
|
||||
@@ -16,8 +16,8 @@ const FAQ_ITEMS = [
|
||||
{
|
||||
q_zh: "Pro 包含哪些功能?",
|
||||
q_en: "What features does Pro include?",
|
||||
a_zh: "开通后可解锁:今日日内机场报文规则分析(含高温时段)、未来日期分析、城市决策卡、全平台智能气象推送。",
|
||||
a_en: "Unlocks: intraday METAR rule analysis (including peak window), future-date analysis, city decision cards, and cross-platform smart weather push.",
|
||||
a_zh: "开通后可解锁:天气决策台、多城市图表巡检、未来日期分析、全平台智能气象推送。",
|
||||
a_en: "Unlocks: weather decision terminal, multi-city chart monitoring, future-date analysis, and cross-platform smart weather push.",
|
||||
},
|
||||
{
|
||||
q_zh: "当前订阅价格是多少?",
|
||||
|
||||
@@ -15,7 +15,7 @@ const ScanTerminalDashboard = dynamic(
|
||||
export const metadata: Metadata = {
|
||||
title: "PolyWeather Terminal | Paid Product",
|
||||
description:
|
||||
"Paid PolyWeather decision terminal for weather-signal analysis and city decision cards.",
|
||||
"Paid PolyWeather decision terminal for weather-signal analysis and multi-city chart monitoring.",
|
||||
};
|
||||
|
||||
export default function TerminalPage() {
|
||||
|
||||
@@ -52,6 +52,7 @@ import {
|
||||
WALLETCONNECT_PROJECT_ID,
|
||||
} from "./constants";
|
||||
import { InfoRow, PlusIcon } from "./AccountInfoRow";
|
||||
import { AccountFeedbackPanel } from "./AccountFeedbackPanel";
|
||||
import {
|
||||
chainIdToDisplayName,
|
||||
clearStoredPaymentRecovery,
|
||||
@@ -432,6 +433,14 @@ export function AccountCenter() {
|
||||
{ plan_code: "pro_monthly", plan_id: 101, amount_usdc: "29.9", duration_days: 30 },
|
||||
{ plan_code: "pro_quarterly", plan_id: 102, amount_usdc: "79.9", duration_days: 90 },
|
||||
];
|
||||
const selectedPlanDurationDays = Number(selectedPlan?.duration_days || 30);
|
||||
const overlayPlanLabel =
|
||||
selectedPlanCode === "pro_quarterly"
|
||||
? copy.quarterlyPlan
|
||||
: copy.monthlyPlan;
|
||||
const overlayPeriodLabel = isEn
|
||||
? `/ ${selectedPlanDurationDays} days`
|
||||
: `/ ${selectedPlanDurationDays} 天`;
|
||||
const referral = backend?.referral;
|
||||
const referralCode = String(referral?.code || "").trim();
|
||||
const appliedReferralCode = String(referral?.applied_code || "").trim();
|
||||
@@ -904,6 +913,8 @@ export function AccountCenter() {
|
||||
<UnlockProOverlay
|
||||
points={totalPoints}
|
||||
planPriceUsd={billing.planAmount}
|
||||
planLabel={overlayPlanLabel}
|
||||
periodLabel={overlayPeriodLabel}
|
||||
usePoints={usePoints}
|
||||
onToggleUsePoints={() => setUsePoints((prev) => !prev)}
|
||||
billing={{
|
||||
@@ -921,6 +932,7 @@ export function AccountCenter() {
|
||||
payBusy={paymentBusy}
|
||||
payLabel={hasPayingWallet ? copy.payNow : copy.connectAndPay}
|
||||
manualPayLabel="手动转账"
|
||||
locale={locale}
|
||||
errorText={paymentError || undefined}
|
||||
infoText={paymentInfo || undefined}
|
||||
txHash={lastTxHash || undefined}
|
||||
@@ -933,6 +945,13 @@ export function AccountCenter() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AccountFeedbackPanel
|
||||
isEn={isEn}
|
||||
title={copy.accountFeedbackTitle}
|
||||
description={copy.accountFeedbackDescription}
|
||||
refreshLabel={copy.refresh}
|
||||
/>
|
||||
|
||||
{/* Telegram Bot Section & Payment Details */}
|
||||
{showSecondarySections ? (
|
||||
<div
|
||||
@@ -1108,7 +1127,9 @@ export function AccountCenter() {
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 text-xs font-bold">
|
||||
<span>
|
||||
{isQuarterly ? copy.quarterlyPlan : copy.monthlyPlan}
|
||||
{isQuarterly
|
||||
? copy.quarterlyPlan
|
||||
: copy.monthlyPlan}
|
||||
</span>
|
||||
<span>{plan.amount_usdc} USDC</span>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { MessageSquare, RefreshCw } from "lucide-react";
|
||||
import type { UserFeedbackEntry, UserFeedbackPayload } from "@/types/ops";
|
||||
import {
|
||||
feedbackStatusLabel,
|
||||
feedbackStatusTone,
|
||||
} from "@/components/dashboard/scan-terminal/feedback-status";
|
||||
|
||||
function compactDate(value?: string) {
|
||||
if (!value) return "--";
|
||||
return value.slice(0, 16).replace("T", " ");
|
||||
}
|
||||
|
||||
function categoryLabel(value?: string, isEn = false) {
|
||||
const key = String(value || "").toLowerCase();
|
||||
if (key === "bug") return "Bug";
|
||||
if (key === "data") return isEn ? "Data" : "数据";
|
||||
if (key === "idea") return isEn ? "Suggestion" : "建议";
|
||||
if (key === "payment") return isEn ? "Payment" : "支付";
|
||||
if (key === "account") return isEn ? "Account" : "账号";
|
||||
return isEn ? "Other" : "其他";
|
||||
}
|
||||
|
||||
function formatRewardPoints(points?: number, isEn = false) {
|
||||
const raw = Number(points || 0);
|
||||
const value = Number.isFinite(raw) ? Math.max(0, raw) : 0;
|
||||
return isEn
|
||||
? `+${value.toLocaleString()} points`
|
||||
: `+${value.toLocaleString()} 积分`;
|
||||
}
|
||||
|
||||
function rewardStatusText(status?: string, isEn = false) {
|
||||
const key = String(status || "").toLowerCase();
|
||||
if (key === "pending") return isEn ? "Reward pending" : "奖励待处理";
|
||||
if (key === "skipped") return isEn ? "No points awarded" : "未发放积分";
|
||||
return isEn ? "Feedback reward" : "反馈奖励";
|
||||
}
|
||||
|
||||
function renderFeedbackReward(entry: UserFeedbackEntry, isEn: boolean) {
|
||||
const rawPoints = Number(entry.reward_points || 0);
|
||||
const points = Number.isFinite(rawPoints) ? Math.max(0, rawPoints) : 0;
|
||||
const rewardStatus = String(entry.reward_status || "").toLowerCase();
|
||||
const reason = String(entry.reward_reason || "").trim();
|
||||
if (points <= 0 && !rewardStatus && !reason) return null;
|
||||
|
||||
const granted = points > 0 || rewardStatus === "granted";
|
||||
return (
|
||||
<div
|
||||
className={`mt-2 rounded-lg border px-3 py-2 text-xs ${
|
||||
granted
|
||||
? "border-amber-200 bg-amber-50 text-amber-800"
|
||||
: "border-slate-200 bg-slate-50 text-slate-600"
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="font-bold">{rewardStatusText(rewardStatus, isEn)}</span>
|
||||
{points > 0 ? (
|
||||
<span className="font-mono font-black">
|
||||
{formatRewardPoints(points, isEn)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{reason ? (
|
||||
<div className="mt-1 leading-5 text-slate-600">
|
||||
{isEn ? "Reason" : "奖励原因"}: {reason}
|
||||
</div>
|
||||
) : null}
|
||||
{entry.rewarded_at ? (
|
||||
<div className="mt-1 font-mono text-[11px] text-slate-400">
|
||||
{compactDate(entry.rewarded_at)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AccountFeedbackPanel({
|
||||
isEn,
|
||||
title,
|
||||
description,
|
||||
refreshLabel,
|
||||
}: {
|
||||
isEn: boolean;
|
||||
title: string;
|
||||
description: string;
|
||||
refreshLabel: string;
|
||||
}) {
|
||||
const [entries, setEntries] = useState<UserFeedbackEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [available, setAvailable] = useState(true);
|
||||
|
||||
const load = useCallback(async (signal?: AbortSignal) => {
|
||||
if (typeof fetch !== "function") return;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const res = await fetch("/api/feedback?limit=10", {
|
||||
cache: "no-store",
|
||||
headers: { Accept: "application/json" },
|
||||
signal,
|
||||
});
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
setAvailable(false);
|
||||
setEntries([]);
|
||||
return;
|
||||
}
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const payload = (await res.json()) as UserFeedbackPayload;
|
||||
setEntries(Array.isArray(payload.feedback) ? payload.feedback : []);
|
||||
setAvailable(true);
|
||||
} catch (err) {
|
||||
if (signal?.aborted) return;
|
||||
setError(String(err).slice(0, 140));
|
||||
} finally {
|
||||
if (!signal?.aborted) setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
void load(controller.signal);
|
||||
return () => controller.abort();
|
||||
}, [load]);
|
||||
|
||||
const emptyText = useMemo(() => {
|
||||
if (loading) return isEn ? "Loading feedback..." : "正在加载反馈...";
|
||||
return isEn ? "No submitted feedback yet." : "暂无已提交反馈。";
|
||||
}, [isEn, loading]);
|
||||
|
||||
if (!available) return null;
|
||||
|
||||
return (
|
||||
<section className="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm lg:col-span-12">
|
||||
<div className="mb-5 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<h3 className="flex items-center gap-2 text-sm font-bold uppercase text-slate-700">
|
||||
<MessageSquare size={18} className="text-blue-500" />
|
||||
{title}
|
||||
</h3>
|
||||
<p className="mt-1 text-xs leading-5 text-slate-500">{description}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void load()}
|
||||
disabled={loading}
|
||||
className="inline-flex h-9 items-center justify-center gap-2 rounded-lg border border-slate-200 bg-white px-3 text-xs font-bold text-slate-600 transition hover:bg-slate-50 disabled:cursor-wait disabled:opacity-60"
|
||||
>
|
||||
<RefreshCw size={14} className={loading ? "animate-spin" : ""} />
|
||||
{refreshLabel}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-3 rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700">
|
||||
{isEn ? "Failed to load feedback: " : "反馈加载失败:"}{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{entries.length === 0 ? (
|
||||
<div className="rounded-xl border border-slate-200 bg-slate-50 px-4 py-8 text-center text-sm text-slate-500">
|
||||
{emptyText}
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-slate-100 overflow-hidden rounded-xl border border-slate-200">
|
||||
{entries.map((entry) => (
|
||||
<div key={entry.id} className="grid gap-3 bg-white px-4 py-3 md:grid-cols-[minmax(0,1fr)_auto]">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className={`rounded border px-2 py-0.5 text-[10px] font-black ${feedbackStatusTone(entry.status)}`}>
|
||||
{feedbackStatusLabel(entry.status, isEn)}
|
||||
</span>
|
||||
<span className="text-[11px] font-bold text-slate-500">
|
||||
{categoryLabel(entry.category, isEn)}
|
||||
</span>
|
||||
<span className="text-[11px] text-slate-400">
|
||||
{compactDate(entry.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-2 line-clamp-2 text-sm font-semibold leading-5 text-slate-900">
|
||||
{entry.message || (isEn ? "Feedback" : "反馈")}
|
||||
</p>
|
||||
{renderFeedbackReward(entry, isEn)}
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 md:text-right">
|
||||
<div className="font-mono">{compactDate(entry.updated_at)}</div>
|
||||
<div className="mt-1 text-[11px] text-slate-400">
|
||||
{isEn ? "Updated" : "最近更新"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
function assert(condition: unknown, message: string): asserts condition {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
export function runTests() {
|
||||
const projectRoot = process.cwd();
|
||||
const accountDir = path.join(projectRoot, "components", "account");
|
||||
const accountCenterSource = fs.readFileSync(
|
||||
path.join(accountDir, "AccountCenter.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
const feedbackPanelPath = path.join(accountDir, "AccountFeedbackPanel.tsx");
|
||||
|
||||
assert(fs.existsSync(feedbackPanelPath), "account center must ship a My Feedback panel component");
|
||||
|
||||
const feedbackPanelSource = fs.readFileSync(feedbackPanelPath, "utf8");
|
||||
assert(
|
||||
accountCenterSource.includes('import { AccountFeedbackPanel } from "./AccountFeedbackPanel";') &&
|
||||
accountCenterSource.includes("<AccountFeedbackPanel") &&
|
||||
accountCenterSource.includes("accountFeedbackTitle"),
|
||||
"account center must mount the My Feedback panel with localized account copy",
|
||||
);
|
||||
assert(
|
||||
feedbackPanelSource.includes("/api/feedback?limit=10") &&
|
||||
feedbackPanelSource.includes("feedbackStatusLabel") &&
|
||||
feedbackPanelSource.includes("RefreshCw") &&
|
||||
feedbackPanelSource.includes("useEffect") &&
|
||||
!feedbackPanelSource.includes("setInterval"),
|
||||
"account feedback panel must load the current user's feedback once, support manual refresh, and avoid polling",
|
||||
);
|
||||
assert(
|
||||
feedbackPanelSource.includes("reward_points") &&
|
||||
feedbackPanelSource.includes("reward_reason") &&
|
||||
feedbackPanelSource.includes("reward_status") &&
|
||||
feedbackPanelSource.includes("formatRewardPoints") &&
|
||||
feedbackPanelSource.includes("renderFeedbackReward") &&
|
||||
feedbackPanelSource.includes("奖励原因"),
|
||||
"account feedback panel must show per-feedback reward points and reward reasons",
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import {
|
||||
buildAuthMePath,
|
||||
mergeAccountAuthSnapshot,
|
||||
} from "@/lib/auth-snapshot";
|
||||
import type { AuthSnapshotLike } from "@/lib/auth-snapshot";
|
||||
|
||||
function assert(condition: unknown, message: string) {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
export function runTests() {
|
||||
assert(
|
||||
buildAuthMePath({ scope: "entitlement" }) ===
|
||||
"/api/auth/me?scope=entitlement",
|
||||
"account entitlement probe must use the lightweight auth/me scope",
|
||||
);
|
||||
assert(
|
||||
buildAuthMePath({ preferSnapshot: true, scope: "entitlement" }) ===
|
||||
"/api/auth/me?prefer_snapshot=1&scope=entitlement",
|
||||
"terminal auth probe must combine snapshot preference with lightweight entitlement scope",
|
||||
);
|
||||
|
||||
const active: AuthSnapshotLike & { referral?: { code?: string } } = {
|
||||
authenticated: true,
|
||||
user_id: "user-1",
|
||||
subscription_active: true,
|
||||
subscription_plan_code: "pro_monthly",
|
||||
subscription_expires_at: "2026-07-01T00:00:00Z",
|
||||
subscription_total_expires_at: "2026-07-01T00:00:00Z",
|
||||
subscription_queued_days: 0,
|
||||
points: 120,
|
||||
referral: { code: "PW-ABC" },
|
||||
};
|
||||
|
||||
const degraded = mergeAccountAuthSnapshot(active, {
|
||||
authenticated: true,
|
||||
user_id: "user-1",
|
||||
subscription_active: null,
|
||||
subscription_plan_code: null,
|
||||
subscription_expires_at: null,
|
||||
subscription_total_expires_at: null,
|
||||
subscription_queued_days: 0,
|
||||
points: 0,
|
||||
degraded_auth_profile: true,
|
||||
});
|
||||
|
||||
assert(
|
||||
degraded.subscription_active === true &&
|
||||
degraded.subscription_plan_code === "pro_monthly" &&
|
||||
degraded.points === 120 &&
|
||||
degraded.referral?.code === "PW-ABC",
|
||||
"account snapshot merge must preserve confirmed Pro status when a later auth/me response is degraded",
|
||||
);
|
||||
|
||||
const inactive = mergeAccountAuthSnapshot(active, {
|
||||
authenticated: true,
|
||||
user_id: "user-1",
|
||||
subscription_active: false,
|
||||
subscription_plan_code: null,
|
||||
points: 0,
|
||||
});
|
||||
|
||||
assert(
|
||||
inactive.subscription_active === false,
|
||||
"account snapshot merge must still accept a confirmed inactive subscription response",
|
||||
);
|
||||
}
|
||||
@@ -23,6 +23,18 @@ export function runTests() {
|
||||
path.join(projectRoot, "components", "account", "usePaymentFlow.ts"),
|
||||
"utf8",
|
||||
);
|
||||
const useBilling = fs.readFileSync(
|
||||
path.join(projectRoot, "components", "account", "useBilling.ts"),
|
||||
"utf8",
|
||||
);
|
||||
const unlockProOverlay = fs.readFileSync(
|
||||
path.join(projectRoot, "components", "subscription", "UnlockProOverlay.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
const telegramPricing = fs.readFileSync(
|
||||
path.join(projectRoot, "components", "account", "telegram-pricing.ts"),
|
||||
"utf8",
|
||||
);
|
||||
const types = fs.readFileSync(
|
||||
path.join(projectRoot, "components", "account", "types.ts"),
|
||||
"utf8",
|
||||
@@ -59,11 +71,46 @@ export function runTests() {
|
||||
!usePaymentFlow.includes("monthlyPlanList"),
|
||||
"payment hooks must not filter checkout plans down to monthly only",
|
||||
);
|
||||
assert(
|
||||
useAccountPayment.includes("applyTelegramGroupPricingToPlanList") &&
|
||||
useAccountPayment.includes("backend?.telegram_pricing") &&
|
||||
useAccountPayment.includes("isTelegramPrivateGroupPriceEligible") &&
|
||||
telegramPricing.includes("is_private_group_member") &&
|
||||
telegramPricing.includes("telegram_private_group_member") &&
|
||||
!telegramPricing.includes("is_group_member") &&
|
||||
useAccountPayment.includes('=== "pro_monthly"') &&
|
||||
useAccountPayment.includes("amount_usdc: telegramAmountUsdc"),
|
||||
"account payment plan cards must only display the 5 USDC discounted monthly price after verified /bind eligibility",
|
||||
);
|
||||
assert(
|
||||
useBilling.includes("telegramGroupPriceApplies") &&
|
||||
useBilling.includes("isTelegramPrivateGroupPriceEligible") &&
|
||||
useBilling.includes("backend?.telegram_pricing") &&
|
||||
useBilling.includes("!telegramGroupPriceApplies"),
|
||||
"billing must not let referral first-month pricing override the lower verified monthly price",
|
||||
);
|
||||
assert(
|
||||
!accountCenter.includes(["private", "Group", "Monthly", "Plan"].join("")) &&
|
||||
!accountCopy.includes(["Private", "group", "monthly"].join(" ")) &&
|
||||
!accountCopy.includes(["私", "密", "群", "月", "付"].join("")),
|
||||
"account plan card and checkout overlay should not expose a separate discounted monthly label",
|
||||
);
|
||||
assert(
|
||||
accountCenter.includes("overlayPlanLabel") &&
|
||||
accountCenter.includes("overlayPeriodLabel") &&
|
||||
!accountCenter.includes(`copy.${["private", "Group", "Monthly", "Plan"].join("")}`) &&
|
||||
unlockProOverlay.includes("planLabel") &&
|
||||
unlockProOverlay.includes("USDC") &&
|
||||
!unlockProOverlay.includes("<span className={s.price}>${planPriceUsd.toFixed(2)}</span>") &&
|
||||
!unlockProOverlay.includes('<span className={s.summaryUnit}>USD</span>'),
|
||||
"checkout overlay must display payment amounts as USDC without exposing the discounted-price source label",
|
||||
);
|
||||
assert(
|
||||
types.includes("ReferralSummary") &&
|
||||
types.includes("referral?: ReferralSummary | null") &&
|
||||
types.includes("is_private_group_member?: boolean") &&
|
||||
types.includes("duration_days: number") &&
|
||||
types.includes("max_discount_usdc_by_plan"),
|
||||
"account auth and payment types must include referral summary and plan durations",
|
||||
"account auth and payment types must include referral summary, private Telegram pricing, and plan durations",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,6 +44,10 @@ export function runTests() {
|
||||
);
|
||||
|
||||
const accountCenterSource = fs.readFileSync(accountCenterPath, "utf8");
|
||||
const walletBindSource = fs.readFileSync(
|
||||
path.join(projectRoot, "components", "account", "useWalletBind.ts"),
|
||||
"utf8",
|
||||
);
|
||||
const hookPath = path.join(
|
||||
projectRoot,
|
||||
"components",
|
||||
@@ -62,6 +66,10 @@ export function runTests() {
|
||||
const paymentFlowSource = fs.existsSync(paymentFlowPath)
|
||||
? fs.readFileSync(paymentFlowPath, "utf8")
|
||||
: "";
|
||||
const paymentUtilsSource = fs.readFileSync(
|
||||
path.join(projectRoot, "components", "account", "payment-utils.ts"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
// The receiver validation now lives in the extracted hook file (called
|
||||
// from createManualPaymentIntent and createIntentAndPay).
|
||||
@@ -221,4 +229,55 @@ export function runTests() {
|
||||
`${route} must allow bearer-backed payment mutations while still rejecting requests with no auth context`,
|
||||
);
|
||||
}
|
||||
|
||||
const walletChallengeRouteSource = fs.readFileSync(
|
||||
path.join(projectRoot, "app/api/payments/wallets/challenge/route.ts"),
|
||||
"utf8",
|
||||
);
|
||||
assert(
|
||||
walletChallengeRouteSource.includes("fetchWalletChallengeWithRetry"),
|
||||
"wallet challenge proxy must retry a transient backend connection failure before blocking payment",
|
||||
);
|
||||
assert(
|
||||
walletChallengeRouteSource.includes("Invalid wallet challenge request"),
|
||||
"wallet challenge proxy must return a client error for malformed JSON instead of a generic payment failure",
|
||||
);
|
||||
assert(
|
||||
walletChallengeRouteSource.includes("retryable: true"),
|
||||
"wallet challenge proxy exception response must mark transient failures as retryable",
|
||||
);
|
||||
assert(
|
||||
walletBindSource.includes("readPaymentApiErrorMessage"),
|
||||
"wallet binding errors must show the API error message instead of raw JSON",
|
||||
);
|
||||
assert(
|
||||
paymentUtilsSource.includes("looksLikeHtmlDocument") &&
|
||||
paymentUtilsSource.includes("Payment service is temporarily unavailable") &&
|
||||
paymentUtilsSource.includes("支付服务暂时不可用"),
|
||||
"payment API error parsing must collapse upstream HTML/Cloudflare 50x pages into a user-safe message",
|
||||
);
|
||||
assert(
|
||||
paymentFlowSource.includes("readPaymentApiErrorMessage") &&
|
||||
!paymentFlowSource.includes("(await submitRes.text()).slice(0, 350)") &&
|
||||
!paymentFlowSource.includes("(await confirmRes.text()).slice(0, 350)"),
|
||||
"payment submit/confirm failures must use sanitized API error messages instead of raw response text",
|
||||
);
|
||||
const submitRouteSource = fs.readFileSync(
|
||||
path.join(
|
||||
projectRoot,
|
||||
"app",
|
||||
"api",
|
||||
"payments",
|
||||
"intents",
|
||||
"[intentId]",
|
||||
"submit",
|
||||
"route.ts",
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
assert(
|
||||
submitRouteSource.includes("Payment submit upstream failed") &&
|
||||
!submitRouteSource.includes("error: detail || undefined"),
|
||||
"payment submit proxy must not copy raw upstream HTML into the public error field",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -299,9 +299,14 @@ export function runTests() {
|
||||
authMeRouteSource.includes("!auth.authUserId") &&
|
||||
authMeRouteSource.includes('req.headers.get("authorization")') &&
|
||||
authMeRouteSource.indexOf("authenticated: false") <
|
||||
authMeRouteSource.indexOf("await fetch(`${API_BASE}/api/auth/me`"),
|
||||
authMeRouteSource.indexOf("await fetch(buildBackendAuthMeUrl(req)"),
|
||||
"auth profile proxy must return unauthenticated locally for no-session Supabase requests instead of forwarding the backend entitlement token",
|
||||
);
|
||||
assert(
|
||||
authMeRouteSource.includes("function buildBackendAuthMeUrl") &&
|
||||
authMeRouteSource.includes('url.searchParams.set("scope", "entitlement")'),
|
||||
"auth profile proxy must forward the lightweight entitlement scope to the backend auth/me endpoint",
|
||||
);
|
||||
assert(
|
||||
authMeRouteSource.includes('reason: "prefer_snapshot_fast_path"') &&
|
||||
authMeRouteSource.indexOf('reason: "prefer_snapshot_fast_path"') <
|
||||
|
||||
@@ -15,12 +15,16 @@ export function createAccountCopy(isEn: boolean): Record<string, string> {
|
||||
weeklyRewards: isEn ? "Referral Rewards" : "邀请奖励",
|
||||
membershipDetails: isEn ? "Membership Details" : "会员权限详情",
|
||||
identityStatus: isEn ? "Identity Status" : "身份状态",
|
||||
accountFeedbackTitle: isEn ? "My Feedback" : "我的反馈",
|
||||
accountFeedbackDescription: isEn
|
||||
? "Recent reports you submitted and their current handling status."
|
||||
: "你最近提交过的反馈及当前处理状态。",
|
||||
authMode: isEn ? "Auth Mode" : "鉴权模式",
|
||||
weatherEngine: isEn ? "Weather Engine" : "气象引擎",
|
||||
intradayAnalysis: isEn ? "Intraday Analysis" : "今日内分析",
|
||||
historyFuture: isEn
|
||||
? "Future-date + Decision Card Analysis"
|
||||
: "未来日期分析 + 城市决策卡",
|
||||
? "Future-date + Multi-city Chart Monitoring"
|
||||
: "未来日期分析 + 多城市图表巡检",
|
||||
smartPush: isEn
|
||||
? "Cross-platform Smart Weather Push"
|
||||
: "全平台智能气象查询",
|
||||
@@ -271,8 +275,8 @@ export function createAccountCopy(isEn: boolean): Record<string, string> {
|
||||
verifyUnknown: isEn ? "Unknown error" : "未知错误",
|
||||
// ── Telegram bind messages ────────────────────────────────────────
|
||||
telegramVerifySuccess: isEn
|
||||
? "Telegram group membership verified. Checkout follows the selected Pro plan."
|
||||
: "Telegram 群成员验证成功,结算金额以当前选择的 Pro 套餐为准。",
|
||||
? "Telegram group membership verified. Member monthly price {amount} USDC is active."
|
||||
: "Telegram 群成员验证成功,群友月付价 {amount} USDC 已生效。",
|
||||
telegramBindClickHint: isEn
|
||||
? "Open the Telegram Bot, click Start, and confirm binding. Then refresh this page to request group entry."
|
||||
: "已打开 Telegram Bot,请在 Bot 内点击 Start 并确认绑定;完成后刷新本页再申请入群。",
|
||||
|
||||
@@ -68,6 +68,54 @@ export type NormalizedPaymentError = {
|
||||
userRejected: boolean;
|
||||
};
|
||||
|
||||
export function looksLikeHtmlDocument(value: string) {
|
||||
const text = String(value || "").trim().toLowerCase();
|
||||
return (
|
||||
text.startsWith("<!doctype html") ||
|
||||
text.startsWith("<html") ||
|
||||
/<title>[^<]*(50\d|cloudflare|polyweather\.top)/i.test(String(value || ""))
|
||||
);
|
||||
}
|
||||
|
||||
function containsCjk(value: string) {
|
||||
return /[\u3400-\u9fff]/.test(value);
|
||||
}
|
||||
|
||||
function safePaymentServiceUnavailableMessage(fallback: string) {
|
||||
return containsCjk(fallback)
|
||||
? "支付服务暂时不可用,请稍后重试;如果已经付款,请保存 Tx Hash 联系管理员。"
|
||||
: "Payment service is temporarily unavailable. Please retry shortly; if you already paid, keep the Tx Hash and contact support.";
|
||||
}
|
||||
|
||||
export async function readPaymentApiErrorMessage(
|
||||
response: Response,
|
||||
fallback = "Request failed",
|
||||
limit = 300,
|
||||
) {
|
||||
const raw = await response.text();
|
||||
const trimmed = String(raw || "").trim();
|
||||
if (!trimmed) return fallback;
|
||||
let message = "";
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed) as {
|
||||
error?: unknown;
|
||||
detail?: unknown;
|
||||
message?: unknown;
|
||||
};
|
||||
const parsedMessage = [parsed.error, parsed.detail, parsed.message].find(
|
||||
(item) => typeof item === "string" && item.trim(),
|
||||
);
|
||||
if (typeof parsedMessage === "string") message = parsedMessage.trim();
|
||||
} catch {
|
||||
message = trimmed;
|
||||
}
|
||||
const candidate = message || trimmed;
|
||||
if (looksLikeHtmlDocument(candidate)) {
|
||||
return safePaymentServiceUnavailableMessage(fallback);
|
||||
}
|
||||
return candidate.slice(0, limit);
|
||||
}
|
||||
|
||||
export function normalizePaymentError(error: unknown): NormalizedPaymentError {
|
||||
const source = error as any;
|
||||
const code = Number(
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { TelegramPricing } from "./types";
|
||||
|
||||
export function isTelegramPrivateGroupPriceEligible(
|
||||
pricing?: TelegramPricing | null,
|
||||
) {
|
||||
return Boolean(
|
||||
pricing?.is_private_group_member ||
|
||||
pricing?.pricing_source === "telegram_private_group_member",
|
||||
);
|
||||
}
|
||||
|
||||
export function telegramPrivateGroupAmountUsdc(
|
||||
pricing?: TelegramPricing | null,
|
||||
) {
|
||||
if (!isTelegramPrivateGroupPriceEligible(pricing)) return "";
|
||||
const amount = String(pricing?.amount_usdc || "").trim();
|
||||
const numeric = Number(amount);
|
||||
return Number.isFinite(numeric) && numeric > 0 ? amount : "";
|
||||
}
|
||||
@@ -19,6 +19,10 @@ export type AuthMeResponse = {
|
||||
subscription_queued_count?: number | null;
|
||||
telegram_pricing?: TelegramPricing | null;
|
||||
referral?: ReferralSummary | null;
|
||||
degraded_auth_profile?: boolean | null;
|
||||
degraded_reason?: string | null;
|
||||
entitlement_snapshot?: boolean | null;
|
||||
entitlement_snapshot_reason?: string | null;
|
||||
};
|
||||
|
||||
export type ReferralSummary = {
|
||||
@@ -42,6 +46,7 @@ export type TelegramPricing = {
|
||||
telegram_id?: number | null;
|
||||
telegram_status?: string | null;
|
||||
is_group_member?: boolean;
|
||||
is_private_group_member?: boolean;
|
||||
amount_usdc?: string;
|
||||
pricing_source?: string;
|
||||
};
|
||||
|
||||
@@ -1,20 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import type { User } from "@supabase/supabase-js";
|
||||
import {
|
||||
buildAuthMePath,
|
||||
mergeAccountAuthSnapshot,
|
||||
} from "@/lib/auth-snapshot";
|
||||
import { getSupabaseBrowserClient } from "@/lib/supabase/client";
|
||||
|
||||
import type {
|
||||
AuthMeResponse,
|
||||
BoundWallet,
|
||||
PaymentConfig,
|
||||
PaymentPlan,
|
||||
ProviderMode,
|
||||
InjectedProviderOption,
|
||||
TelegramPricing,
|
||||
} from "./types";
|
||||
import { usePaymentState } from "./usePaymentState";
|
||||
import { useWalletBind } from "./useWalletBind";
|
||||
import { usePaymentFlow } from "./usePaymentFlow";
|
||||
import { useBilling } from "./useBilling";
|
||||
import {
|
||||
isTelegramPrivateGroupPriceEligible,
|
||||
telegramPrivateGroupAmountUsdc,
|
||||
} from "./telegram-pricing";
|
||||
|
||||
function applyTelegramGroupPricingToPlanList(
|
||||
plans: PaymentPlan[],
|
||||
pricing?: TelegramPricing | null,
|
||||
): PaymentPlan[] {
|
||||
if (!isTelegramPrivateGroupPriceEligible(pricing)) return plans;
|
||||
const telegramAmountUsdc = telegramPrivateGroupAmountUsdc(pricing);
|
||||
if (!telegramAmountUsdc) return plans;
|
||||
return plans.map((plan) =>
|
||||
String(plan.plan_code || "").toLowerCase() === "pro_monthly"
|
||||
? { ...plan, amount_usdc: telegramAmountUsdc }
|
||||
: plan,
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
export interface UseAccountPaymentParams {
|
||||
@@ -25,7 +50,7 @@ export interface UseAccountPaymentParams {
|
||||
backend: AuthMeResponse | null;
|
||||
user: User | null;
|
||||
setUser: (user: User | null) => void;
|
||||
setBackend: (backend: AuthMeResponse | null) => void;
|
||||
setBackend: Dispatch<SetStateAction<AuthMeResponse | null>>;
|
||||
setErrorText: (text: string) => void;
|
||||
setUpdatedAt: (text: string) => void;
|
||||
showOverlay: boolean;
|
||||
@@ -155,21 +180,45 @@ export function useAccountPayment(params: UseAccountPaymentParams) {
|
||||
.then(({ data: { session } }) => session?.user ?? null)
|
||||
.catch(() => null as User | null)
|
||||
: Promise.resolve(null as User | null);
|
||||
const authHeadersPromise = buildAuthedHeaders(false);
|
||||
const backendPromise = authHeadersPromise.then((headers) =>
|
||||
fetch("/api/auth/me", { cache: "no-store", headers }),
|
||||
);
|
||||
const [localUser, backendResult] = await Promise.all([userPromise, backendPromise]);
|
||||
const [localUser, authHeaders] = await Promise.all([
|
||||
userPromise,
|
||||
buildAuthedHeaders(false),
|
||||
]);
|
||||
setUser(localUser);
|
||||
if (!backendResult.ok) {
|
||||
if (retry && backendResult.status === 401) {
|
||||
|
||||
const readAuthSnapshot = async (
|
||||
headers: Record<string, string>,
|
||||
options: Parameters<typeof buildAuthMePath>[0] = {},
|
||||
) => {
|
||||
const response = await fetch(buildAuthMePath(options), {
|
||||
cache: "no-store",
|
||||
headers,
|
||||
});
|
||||
if (!response.ok) {
|
||||
const raw = (await response.text()).slice(0, 260);
|
||||
const message = copy.httpError
|
||||
.replace("{status}", String(response.status))
|
||||
.replace("{raw}", raw);
|
||||
const error = new Error(message) as Error & { status?: number };
|
||||
error.status = response.status;
|
||||
throw error;
|
||||
}
|
||||
return (await response.json()) as AuthMeResponse;
|
||||
};
|
||||
|
||||
let latestHeaders = authHeaders;
|
||||
let backendJson: AuthMeResponse;
|
||||
try {
|
||||
backendJson = await readAuthSnapshot(authHeaders, {
|
||||
scope: "entitlement",
|
||||
});
|
||||
} catch (error) {
|
||||
if (retry && (error as { status?: number }).status === 401) {
|
||||
await new Promise((r) => setTimeout(r, 1200));
|
||||
return fetchAuthSnapshot(false);
|
||||
}
|
||||
const raw = (await backendResult.text()).slice(0, 260);
|
||||
throw new Error(copy.httpError.replace("{status}", String(backendResult.status)).replace("{raw}", raw));
|
||||
throw error;
|
||||
}
|
||||
let backendJson = (await backendResult.json()) as AuthMeResponse;
|
||||
if (
|
||||
retry &&
|
||||
supabaseReady &&
|
||||
@@ -184,23 +233,31 @@ export function useAccountPayment(params: UseAccountPaymentParams) {
|
||||
refreshedSession?.access_token || "",
|
||||
).trim();
|
||||
if (refreshedToken) {
|
||||
const retriedBackendResult = await fetch("/api/auth/me", {
|
||||
cache: "no-store",
|
||||
headers: { Authorization: `Bearer ${refreshedToken}` },
|
||||
latestHeaders = { Authorization: `Bearer ${refreshedToken}` };
|
||||
const retriedBackendJson = await readAuthSnapshot(latestHeaders, {
|
||||
scope: "entitlement",
|
||||
});
|
||||
if (retriedBackendResult.ok) {
|
||||
const retriedBackendJson =
|
||||
(await retriedBackendResult.json()) as AuthMeResponse;
|
||||
backendJson = retriedBackendJson;
|
||||
}
|
||||
backendJson = retriedBackendJson;
|
||||
}
|
||||
} catch {
|
||||
// Keep the first response; the UI treats a logged-in local user with
|
||||
// an unauthenticated backend snapshot as a temporary sync state.
|
||||
}
|
||||
}
|
||||
setBackend(backendJson);
|
||||
setBackend((previous) => mergeAccountAuthSnapshot(previous, backendJson));
|
||||
setUpdatedAt(new Date().toISOString());
|
||||
if (backendJson.authenticated !== false) {
|
||||
void readAuthSnapshot(latestHeaders)
|
||||
.then((fullJson) => {
|
||||
setBackend((previous) =>
|
||||
mergeAccountAuthSnapshot(previous, fullJson),
|
||||
);
|
||||
setUpdatedAt(new Date().toISOString());
|
||||
})
|
||||
.catch(() => {
|
||||
// The lightweight entitlement snapshot already resolved the UI.
|
||||
});
|
||||
}
|
||||
return backendJson;
|
||||
},
|
||||
[
|
||||
@@ -350,7 +407,10 @@ export function useAccountPayment(params: UseAccountPaymentParams) {
|
||||
]);
|
||||
|
||||
// ── Selected plan (derived, shared across sub-hooks) ───
|
||||
const effectivePlanList = paymentConfig?.plans || [];
|
||||
const effectivePlanList = applyTelegramGroupPricingToPlanList(
|
||||
paymentConfig?.plans || [],
|
||||
backend?.telegram_pricing,
|
||||
);
|
||||
const selectedPlan = effectivePlanList.find((p) => p.plan_code === selectedPlanCode) || effectivePlanList[0];
|
||||
|
||||
// ── useWalletBind ──────────────────────────────────────
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from "./constants";
|
||||
import { clearStoredPaymentRecovery, shortAddress } from "./formatters";
|
||||
import { normalizePaymentError } from "./payment-utils";
|
||||
import { isTelegramPrivateGroupPriceEligible } from "./telegram-pricing";
|
||||
import { trackAppEvent } from "@/lib/app-analytics";
|
||||
|
||||
// ============================================================
|
||||
@@ -122,6 +123,10 @@ export function useBilling(params: UseBillingParams) {
|
||||
const listAmount =
|
||||
Number.isFinite(listAmountRaw) && listAmountRaw > 0 ? listAmountRaw : 29.9;
|
||||
const selectedPlanCode = String(selectedPlan?.plan_code || "").toLowerCase();
|
||||
const telegramGroupPriceApplies = Boolean(
|
||||
selectedPlanCode === "pro_monthly" &&
|
||||
isTelegramPrivateGroupPriceEligible(backend?.telegram_pricing),
|
||||
);
|
||||
const referral = backend?.referral;
|
||||
const referralPending = Boolean(
|
||||
referral?.applied_code ||
|
||||
@@ -137,6 +142,7 @@ export function useBilling(params: UseBillingParams) {
|
||||
const referralApplies =
|
||||
selectedPlanCode === "pro_monthly" &&
|
||||
referralPending &&
|
||||
!telegramGroupPriceApplies &&
|
||||
backend?.subscription_active !== true;
|
||||
const planAmount = referralApplies
|
||||
? Number.isFinite(discountedMonthlyRaw) && discountedMonthlyRaw > 0
|
||||
@@ -187,6 +193,7 @@ export function useBilling(params: UseBillingParams) {
|
||||
paymentConfig?.points_redemption,
|
||||
backend?.referral,
|
||||
backend?.subscription_active,
|
||||
backend?.telegram_pricing,
|
||||
selectedPlan?.plan_code,
|
||||
selectedPlan?.amount_usdc,
|
||||
totalPoints,
|
||||
@@ -399,8 +406,9 @@ export function useBilling(params: UseBillingParams) {
|
||||
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";
|
||||
const telegramPricing = data.telegram_pricing;
|
||||
if (isTelegramPrivateGroupPriceEligible(telegramPricing)) {
|
||||
const amount = telegramPricing?.amount_usdc || "10";
|
||||
setPaymentInfo(copy.telegramVerifySuccess.replace("{amount}", amount));
|
||||
}
|
||||
await loadSnapshot();
|
||||
|
||||
@@ -24,9 +24,11 @@ import {
|
||||
buildBalanceOfCalldata,
|
||||
formatTokenUnits,
|
||||
normalizePaymentError,
|
||||
readPaymentApiErrorMessage,
|
||||
requestWalletWithTimeout,
|
||||
} from "./payment-utils";
|
||||
import { trackAppEvent } from "@/lib/app-analytics";
|
||||
import { buildAuthMePath } from "@/lib/auth-snapshot";
|
||||
import {
|
||||
assertExpectedPaymentReceiver,
|
||||
EXPECTED_PAYMENT_RECEIVER_ADDRESS,
|
||||
@@ -189,7 +191,7 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
};
|
||||
const authRes = await fetch("/api/auth/me", {
|
||||
const authRes = await fetch(buildAuthMePath({ scope: "entitlement" }), {
|
||||
cache: "no-store",
|
||||
headers: { Authorization: `Bearer ${accessToken}`, Accept: "application/json" },
|
||||
});
|
||||
@@ -303,7 +305,11 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
|
||||
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);
|
||||
const raw = await readPaymentApiErrorMessage(
|
||||
configRes,
|
||||
isEn ? "Payment config request failed." : "支付配置请求失败。",
|
||||
350,
|
||||
);
|
||||
throw new Error(copy.loadConfigFailed.replace("{raw}", raw));
|
||||
}
|
||||
const configJson = (await configRes.json()) as PaymentConfig;
|
||||
@@ -372,7 +378,11 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
|
||||
await new Promise((resolve) => setTimeout(resolve, pollMs));
|
||||
continue;
|
||||
}
|
||||
const raw = (await statusRes.text()).slice(0, 260);
|
||||
const raw = await readPaymentApiErrorMessage(
|
||||
statusRes,
|
||||
isEn ? "Payment status request failed." : "支付状态请求失败。",
|
||||
260,
|
||||
);
|
||||
throw new Error(copy.queryIntentFailed.replace("{raw}", raw));
|
||||
}
|
||||
const statusJson = (await statusRes.json()) as IntentStatusResponse;
|
||||
@@ -504,7 +514,11 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
|
||||
}),
|
||||
});
|
||||
if (!createRes.ok) {
|
||||
const raw = (await createRes.text()).slice(0, 350);
|
||||
const raw = await readPaymentApiErrorMessage(
|
||||
createRes,
|
||||
isEn ? "Payment order request failed." : "支付订单请求失败。",
|
||||
350,
|
||||
);
|
||||
throw new Error(copy.createIntentFailed.replace("{raw}", raw));
|
||||
}
|
||||
|
||||
@@ -583,7 +597,11 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
|
||||
method: "POST", headers: authHeaders, body: JSON.stringify({ tx_hash: txHashNorm, from_address: payingWallet }),
|
||||
});
|
||||
if (!submitRes.ok) {
|
||||
const raw = (await submitRes.text()).slice(0, 350);
|
||||
const raw = await readPaymentApiErrorMessage(
|
||||
submitRes,
|
||||
isEn ? "Payment submit request failed." : "支付提交请求失败。",
|
||||
350,
|
||||
);
|
||||
if (submitRes.status === 409) {
|
||||
if (handleSubmit409Ref.current) await handleSubmit409Ref.current(intentId, txHashNorm, raw);
|
||||
return;
|
||||
@@ -595,7 +613,11 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
|
||||
method: "POST", headers: authHeaders, body: JSON.stringify({ tx_hash: txHashNorm }),
|
||||
});
|
||||
if (!confirmRes.ok) {
|
||||
const raw = (await confirmRes.text()).slice(0, 350);
|
||||
const raw = await readPaymentApiErrorMessage(
|
||||
confirmRes,
|
||||
isEn ? "Payment confirm request failed." : "支付确认请求失败。",
|
||||
350,
|
||||
);
|
||||
const lowerRaw = raw.toLowerCase();
|
||||
const maybePending =
|
||||
(confirmRes.status === 404 && !lowerRaw.includes("payment intent not found")) ||
|
||||
@@ -681,7 +703,11 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
|
||||
}),
|
||||
});
|
||||
if (!createRes.ok) {
|
||||
const raw = (await createRes.text()).slice(0, 350);
|
||||
const raw = await readPaymentApiErrorMessage(
|
||||
createRes,
|
||||
isEn ? "Manual payment order request failed." : "手动转账订单请求失败。",
|
||||
350,
|
||||
);
|
||||
throw new Error(copy.createManualIntentFailed.replace("{raw}", raw));
|
||||
}
|
||||
|
||||
@@ -739,7 +765,11 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
|
||||
method: "POST", headers: authHeaders, body: JSON.stringify({ tx_hash: txHashNorm }),
|
||||
});
|
||||
if (!submitRes.ok) {
|
||||
const raw = (await submitRes.text()).slice(0, 350);
|
||||
const raw = await readPaymentApiErrorMessage(
|
||||
submitRes,
|
||||
isEn ? "Payment submit request failed." : "支付提交请求失败。",
|
||||
350,
|
||||
);
|
||||
if (submitRes.status === 409) {
|
||||
if (handleSubmit409Ref.current) await handleSubmit409Ref.current(intentIdVal, txHashNorm, raw);
|
||||
return;
|
||||
@@ -750,7 +780,11 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
|
||||
method: "POST", headers: authHeaders, body: JSON.stringify({ tx_hash: txHashNorm }),
|
||||
});
|
||||
if (!confirmRes.ok) {
|
||||
const raw = (await confirmRes.text()).slice(0, 350);
|
||||
const raw = await readPaymentApiErrorMessage(
|
||||
confirmRes,
|
||||
isEn ? "Payment confirm request failed." : "支付确认请求失败。",
|
||||
350,
|
||||
);
|
||||
const lowerRaw = raw.toLowerCase();
|
||||
const maybePending =
|
||||
confirmRes.status === 408 ||
|
||||
|
||||
@@ -16,7 +16,11 @@ import {
|
||||
WALLET_TRANSACTION_REQUEST_TIMEOUT_MS,
|
||||
} from "./constants";
|
||||
import { shortAddress } from "./formatters";
|
||||
import { normalizePaymentError, requestWalletWithTimeout } from "./payment-utils";
|
||||
import {
|
||||
normalizePaymentError,
|
||||
readPaymentApiErrorMessage,
|
||||
requestWalletWithTimeout,
|
||||
} from "./payment-utils";
|
||||
import {
|
||||
eip6963Providers,
|
||||
getEvmProvider,
|
||||
@@ -300,8 +304,8 @@ export function useWalletBind(params: UseWalletBindParams) {
|
||||
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 message = await readPaymentApiErrorMessage(challengeRes);
|
||||
throw new Error(copy.challengeFailed.replace("{raw}", message));
|
||||
}
|
||||
|
||||
const challengeJson = (await challengeRes.json()) as { nonce?: string; message?: string };
|
||||
@@ -314,8 +318,8 @@ export function useWalletBind(params: UseWalletBindParams) {
|
||||
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));
|
||||
const message = await readPaymentApiErrorMessage(verifyRes);
|
||||
throw new Error(copy.verifyFailedRaw.replace("{raw}", message));
|
||||
}
|
||||
|
||||
setPaymentInfo(`${walletLabel} 绑定成功: ${shortAddress(address)}。${binanceBindHint || "现在可点击“立即订阅并激活服务”。"}`);
|
||||
|
||||
@@ -7,11 +7,8 @@ import {
|
||||
ArrowRight,
|
||||
ChevronLeft,
|
||||
Chrome,
|
||||
CloudRain,
|
||||
CloudSun,
|
||||
Lock,
|
||||
Mail,
|
||||
Sun,
|
||||
Eye,
|
||||
EyeOff,
|
||||
} from "lucide-react";
|
||||
@@ -26,17 +23,18 @@ type Mode = "login" | "signup";
|
||||
|
||||
type LoginClientProps = {
|
||||
nextPath: string;
|
||||
initialError?: string;
|
||||
initialMode?: Mode;
|
||||
};
|
||||
|
||||
export function LoginClient({ nextPath, initialMode }: LoginClientProps) {
|
||||
export function LoginClient({ nextPath, initialError, initialMode }: LoginClientProps) {
|
||||
const router = useRouter();
|
||||
const { locale } = useI18n();
|
||||
const [mode, setMode] = useState<Mode>(initialMode ?? "login");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [errorText, setErrorText] = useState("");
|
||||
const [errorText, setErrorText] = useState(initialError || "");
|
||||
const [infoText, setInfoText] = useState("");
|
||||
const [resetSent, setResetSent] = useState(false);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
@@ -50,22 +48,25 @@ export function LoginClient({ nextPath, initialMode }: LoginClientProps) {
|
||||
|
||||
const copy = {
|
||||
backHome: isEn ? "Back to Home" : "返回首页",
|
||||
subtitle: isEn
|
||||
? "Explore weather details from every corner of the world"
|
||||
: "探索世界每一个角落的气象细节",
|
||||
loginSubtitle: isEn
|
||||
? "Sign in to continue to your weather decision terminal."
|
||||
: "登录后进入你的天气决策终端。",
|
||||
signupSubtitle: isEn
|
||||
? "Create an account and get a one-time 3-day trial. No payment required first."
|
||||
: "创建账号后自动开启一次 3 天试用,无需先付款。",
|
||||
googleOneClick: isEn
|
||||
? "Continue with Google"
|
||||
: "使用 Google 账号一键登录",
|
||||
orEmail: isEn ? "Or continue with email" : "或使用邮箱",
|
||||
orGoogle: isEn ? "Or continue with Google" : "或使用 Google",
|
||||
login: isEn ? "Sign In" : "登录",
|
||||
signup: isEn ? "Sign Up" : "注册",
|
||||
passwordLoginPlaceholder: isEn ? "Enter password" : "输入密码",
|
||||
passwordSignupPlaceholder: isEn
|
||||
? "Set at least 6 characters"
|
||||
: "设置至少 6 位密码",
|
||||
loginSubmit: isEn ? "Start your weather decision journey" : "开启气象决策之旅",
|
||||
loginSubmit: isEn ? "Enter PolyWeather Terminal" : "进入 PolyWeather 终端",
|
||||
loginSubmitting: isEn ? "Signing in..." : "正在登录...",
|
||||
signupSubmit: isEn ? "Create account now" : "立即创建账号",
|
||||
signupSubmit: isEn ? "Create account and start trial" : "创建账号并领取试用",
|
||||
signupSubmitting: isEn ? "Creating account..." : "正在创建账号...",
|
||||
googleSubmitting: isEn ? "Connecting Google..." : "正在连接 Google...",
|
||||
loginHint: isEn
|
||||
@@ -96,24 +97,37 @@ 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 账户",
|
||||
welcomeBack: isEn ? "Sign in to PolyWeather" : "登录 PolyWeather",
|
||||
signUpTitle: isEn ? "Create 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 structured decision context for weather risk analysis."
|
||||
: "提供精准的机场 METAR 实况、先进的 DEB 智能融合预测和结构化决策背景,助您理清气象风险脉络。",
|
||||
? "Use the same terminal palette as the product: live temperature evidence, DEB paths, and settlement-source context in one calm workspace."
|
||||
: "沿用终端界面的配色和信息密度:实时温度证据、DEB 路径和结算源背景放在一个安静工作台里。",
|
||||
trusted: isEn ? "Trusted by industry professionals" : "深受行业决策人员信赖",
|
||||
} as const;
|
||||
const submittingLabel = isLogin ? copy.loginSubmitting : copy.signupSubmitting;
|
||||
const googleSubmittingLabel = copy.googleSubmitting;
|
||||
const formSubtitle = isLogin ? copy.loginSubtitle : copy.signupSubtitle;
|
||||
const accessHighlights = isEn
|
||||
? ["Live temperature charts", "DEB forecast path", "Runway and settlement alerts"]
|
||||
: ["实时温度图表", "DEB 预测路径", "跑道与结算提醒"];
|
||||
const sideStats = isEn
|
||||
? [
|
||||
{ label: "Trial", value: "3 days" },
|
||||
{ label: "Access", value: "Terminal" },
|
||||
{ label: "Signals", value: "Runway" },
|
||||
]
|
||||
: [
|
||||
{ label: "试用", value: "3 天" },
|
||||
{ label: "入口", value: "终端" },
|
||||
{ label: "提醒", value: "跑道" },
|
||||
];
|
||||
const loadingSpinner = (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
@@ -121,6 +135,10 @@ export function LoginClient({ nextPath, initialMode }: LoginClientProps) {
|
||||
/>
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setErrorText(initialError || "");
|
||||
}, [initialError]);
|
||||
|
||||
const onResetPassword = async () => {
|
||||
setErrorText("");
|
||||
setInfoText("");
|
||||
@@ -247,167 +265,112 @@ export function LoginClient({ nextPath, initialMode }: LoginClientProps) {
|
||||
};
|
||||
|
||||
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="flex min-h-screen w-full bg-[#e9edf3] font-sans text-slate-950">
|
||||
<aside className="hidden w-[56px] shrink-0 flex-col items-center justify-between bg-[#171d24] py-5 text-white lg:flex">
|
||||
<Link
|
||||
href="/"
|
||||
aria-label="PolyWeather"
|
||||
className="grid h-9 w-9 place-items-center rounded-lg border border-white/10 bg-white/8 text-[11px] font-black text-sky-200"
|
||||
>
|
||||
PW
|
||||
</Link>
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<span className="h-2 w-2 rounded-full bg-[#00897b]" />
|
||||
<span className="h-2 w-2 rounded-full bg-[#2563eb]" />
|
||||
<span className="h-2 w-2 rounded-full bg-slate-500" />
|
||||
</div>
|
||||
<Link
|
||||
href="/"
|
||||
className="grid h-9 w-9 place-items-center rounded-lg text-slate-400 transition hover:bg-white/8 hover:text-white"
|
||||
aria-label={copy.backHome}
|
||||
>
|
||||
<ChevronLeft size={18} />
|
||||
</Link>
|
||||
</aside>
|
||||
|
||||
<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" />
|
||||
<section className="relative hidden min-h-screen w-[44vw] max-w-[620px] shrink-0 overflow-hidden border-r border-[#d8e0ec] bg-[#171d24] p-6 text-white lg:flex lg:flex-col">
|
||||
<div className="absolute inset-0 bg-[linear-gradient(to_right,rgba(255,255,255,0.035)_1px,transparent_1px),linear-gradient(to_bottom,rgba(255,255,255,0.03)_1px,transparent_1px)] bg-[size:34px_34px]" />
|
||||
<div className="relative z-10 flex items-center justify-between">
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center gap-2 text-base font-black tracking-tight text-white transition hover:text-sky-100"
|
||||
>
|
||||
<span>PolyWeather</span>
|
||||
</Link>
|
||||
<span className="inline-flex items-center gap-2 rounded-full border border-emerald-400/20 bg-emerald-400/10 px-3 py-1 text-[11px] font-black uppercase tracking-[0.16em] text-emerald-200">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-emerald-300" />
|
||||
Terminal
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 mt-12 max-w-md">
|
||||
<p className="text-xs font-black uppercase tracking-[0.18em] text-sky-200">
|
||||
{isEn ? "Access Gate" : "终端入口"}
|
||||
</p>
|
||||
<h2 className="mt-4 text-3xl font-black leading-tight tracking-tight text-white">
|
||||
{isEn
|
||||
? "Sign in where the terminal starts."
|
||||
: "从这里进入天气决策终端。"}
|
||||
</h2>
|
||||
<p className="mt-4 text-sm leading-7 text-slate-300">
|
||||
{copy.desc}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 mt-10 overflow-hidden rounded-xl border border-white/10 bg-[#0b1220] shadow-[0_24px_60px_rgba(0,0,0,0.28)]">
|
||||
<div className="flex h-11 items-center gap-2 border-b border-white/10 bg-[#111827] px-4">
|
||||
<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-[10px] text-slate-400">
|
||||
polyweather.top/terminal
|
||||
</span>
|
||||
</div>
|
||||
<img
|
||||
src="/static/web.webp"
|
||||
width="680"
|
||||
height="340"
|
||||
alt={isEn ? "PolyWeather terminal preview" : "PolyWeather 终端预览"}
|
||||
className="aspect-[16/9] w-full object-cover object-top"
|
||||
decoding="async"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 mt-5 grid grid-cols-3 gap-2">
|
||||
{sideStats.map((item) => (
|
||||
<div
|
||||
key={item.label}
|
||||
className="rounded-lg border border-white/10 bg-white/[0.06] px-3 py-3"
|
||||
>
|
||||
<div className="font-mono text-sm font-black text-white">{item.value}</div>
|
||||
<div className="mt-1 text-[10px] font-bold text-slate-400">{item.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 mt-auto flex items-center justify-between border-t border-white/10 pt-5 text-[11px] text-slate-400">
|
||||
<span>{isEn ? "Live charts" : "实时图表"}</span>
|
||||
<span>{isEn ? "DEB path" : "DEB 路径"}</span>
|
||||
<span>{isEn ? "Runway alerts" : "跑道提醒"}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="relative flex min-h-screen flex-1 flex-col justify-between overflow-hidden bg-[#e9edf3] p-4 sm:p-6 lg:p-8">
|
||||
<div className="pointer-events-none absolute inset-0 bg-[linear-gradient(to_right,rgba(15,23,42,0.045)_1px,transparent_1px),linear-gradient(to_bottom,rgba(15,23,42,0.035)_1px,transparent_1px)] bg-[size:32px_32px]" />
|
||||
|
||||
<div className="relative z-10 flex items-center justify-between gap-3 lg:justify-end">
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center gap-2 text-sm font-black tracking-tight text-slate-950 transition-opacity hover:opacity-80 lg:hidden"
|
||||
>
|
||||
<span className="grid h-8 w-8 place-items-center rounded-lg border border-slate-300 bg-white text-[10px] font-black text-blue-700 shadow-sm">
|
||||
PW
|
||||
</span>
|
||||
<span className="hidden min-[360px]:inline">PolyWeather</span>
|
||||
</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>
|
||||
</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>
|
||||
|
||||
<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>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* 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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="relative z-10 text-[10px] text-slate-500 font-mono">
|
||||
{isEn ? "PolyWeather institutional analytics suite" : "PolyWeather 机构版天气决策系统"}
|
||||
</div>
|
||||
</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">
|
||||
<span className="hidden text-xs text-slate-500 sm:inline">
|
||||
{isLogin ? copy.newToPoly : copy.alreadyHave}
|
||||
</span>
|
||||
<button
|
||||
@@ -417,28 +380,40 @@ export function LoginClient({ nextPath, initialMode }: LoginClientProps) {
|
||||
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]"
|
||||
className="whitespace-nowrap rounded-xl border border-slate-300 bg-white px-4 py-2 text-xs font-bold text-slate-700 shadow-sm transition hover:border-blue-300 hover:text-blue-700 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="relative z-10 flex flex-1 items-start justify-center pt-10 sm:pt-12 lg:my-10 lg:items-center lg:pt-0">
|
||||
<div className="w-full max-w-[440px] rounded-xl border border-[#d8e0ec] bg-white p-6 shadow-[0_18px_50px_rgba(15,23,42,0.08)] sm:p-8">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-black tracking-tight text-slate-900 mb-2">
|
||||
<div className="mb-4 inline-flex items-center gap-2 rounded-full border border-emerald-200 bg-emerald-50 px-3 py-1 text-[11px] font-black uppercase tracking-[0.12em] text-emerald-700">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-emerald-600" />
|
||||
{isLogin ? copy.loginSubmit : isEn ? "3-day trial" : "3 天试用"}
|
||||
</div>
|
||||
<h1 className="mb-2 text-2xl font-black tracking-tight text-slate-950">
|
||||
{isLogin ? copy.welcomeBack : copy.signUpTitle}
|
||||
</h1>
|
||||
<p className="text-xs text-slate-500 leading-relaxed">
|
||||
{copy.subtitle}
|
||||
<p className="text-sm leading-6 text-slate-600">
|
||||
{formSubtitle}
|
||||
</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">
|
||||
<div className="mb-6 grid gap-2">
|
||||
{accessHighlights.map((item) => (
|
||||
<div key={item} className="flex items-center gap-2 text-xs font-semibold text-slate-600">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-blue-600" />
|
||||
{item}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<form onSubmit={(event) => void onEmailSubmit(event)} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="block text-[11px] font-black uppercase tracking-[0.12em] text-slate-500">
|
||||
{copy.workEmail}
|
||||
</label>
|
||||
<div className="relative">
|
||||
@@ -449,14 +424,14 @@ export function LoginClient({ nextPath, initialMode }: LoginClientProps) {
|
||||
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"
|
||||
className="w-full rounded-lg border border-slate-300 bg-[#f8fafc] py-3 pl-11 pr-4 text-sm text-slate-950 placeholder:text-slate-400 transition 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">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<label className="block text-[11px] font-black uppercase tracking-[0.12em] text-slate-500">
|
||||
{copy.password}
|
||||
</label>
|
||||
{isLogin && !resetSent ? (
|
||||
@@ -464,7 +439,7 @@ export function LoginClient({ nextPath, initialMode }: LoginClientProps) {
|
||||
type="button"
|
||||
onClick={() => void onResetPassword()}
|
||||
disabled={loading}
|
||||
className="text-xs font-semibold text-blue-600 hover:text-blue-700 transition-colors"
|
||||
className="text-xs font-bold text-blue-600 transition hover:text-blue-700"
|
||||
>
|
||||
{copy.reset}
|
||||
</button>
|
||||
@@ -479,42 +454,43 @@ export function LoginClient({ nextPath, initialMode }: LoginClientProps) {
|
||||
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"
|
||||
className="w-full rounded-lg border border-slate-300 bg-[#f8fafc] py-3 pl-11 pr-11 text-sm text-slate-950 placeholder:text-slate-400 transition 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"
|
||||
className="absolute right-3.5 top-1/2 -translate-y-1/2 text-slate-400 transition hover:text-slate-700"
|
||||
aria-label={showPassword ? "Hide password" : "Show password"}
|
||||
>
|
||||
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isLogin && (
|
||||
<p className="text-[11px] leading-relaxed text-slate-400">
|
||||
{!isLogin ? (
|
||||
<p className="text-[11px] leading-relaxed text-slate-500">
|
||||
{copy.termsAgreement}
|
||||
</p>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
aria-busy={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"
|
||||
className="group mt-6 flex w-full items-center justify-center gap-2 rounded-lg bg-blue-600 px-4 py-3.5 text-sm font-black text-white shadow-[0_10px_22px_rgba(37,99,235,0.22)] transition hover:bg-blue-700 active:scale-[0.99] disabled:opacity-50"
|
||||
>
|
||||
{loading ? loadingSpinner : null}
|
||||
<span>{loading ? submittingLabel : (isLogin ? copy.loginSubmit : copy.signupSubmit)}</span>
|
||||
{!loading && <ArrowRight size={16} className="transition-transform group-hover:translate-x-1" />}
|
||||
{!loading ? <ArrowRight size={16} className="transition-transform group-hover:translate-x-1" /> : null}
|
||||
</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" : "或"}
|
||||
<div className="h-px flex-grow bg-slate-200" />
|
||||
<span className="px-3 text-[10px] font-black uppercase tracking-[0.14em] text-slate-400">
|
||||
{copy.orGoogle}
|
||||
</span>
|
||||
<div className="h-px flex-grow bg-slate-200/60" />
|
||||
<div className="h-px flex-grow bg-slate-200" />
|
||||
</div>
|
||||
|
||||
<button
|
||||
@@ -522,26 +498,29 @@ export function LoginClient({ nextPath, initialMode }: LoginClientProps) {
|
||||
onClick={() => void onGoogleSignIn()}
|
||||
disabled={loading}
|
||||
aria-busy={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"
|
||||
className="flex w-full items-center justify-center gap-2 rounded-lg border border-slate-300 bg-white px-4 py-3 text-sm font-bold text-slate-700 shadow-sm transition hover:border-blue-300 hover:text-blue-700 active:scale-[0.99] disabled:opacity-50"
|
||||
>
|
||||
{loading ? loadingSpinner : <Chrome className="h-4 w-4 text-blue-600" />}
|
||||
<span>{loading ? googleSubmittingLabel : 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 ? (
|
||||
<p className="mt-4 rounded-lg border border-rose-200 bg-rose-50 px-3 py-2.5 text-xs leading-normal text-rose-700">{errorText}</p>
|
||||
) : null}
|
||||
{infoText ? (
|
||||
<p className="mt-4 rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2.5 text-xs leading-normal text-emerald-700">{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>
|
||||
<p className="mt-2 text-center text-xs leading-relaxed text-slate-500">{copy.loginFailedHint}</p>
|
||||
) : null}
|
||||
{infoText === copy.signupCheckEmail ? (
|
||||
<p className="mt-2 text-center text-xs text-slate-500 leading-relaxed">{copy.resendVerify}</p>
|
||||
<p className="mt-2 text-center text-xs leading-relaxed text-slate-500">{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 className="relative z-10 text-center font-mono text-[10px] text-slate-500">
|
||||
© {new Date().getFullYear()} PolyWeather
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
function assert(condition: unknown, message: string) {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
export function runTests() {
|
||||
const source = fs.readFileSync(
|
||||
path.join(process.cwd(), "components", "auth", "LoginClient.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
assert(
|
||||
source.includes("登录 PolyWeather") &&
|
||||
source.includes("创建 PolyWeather 账号") &&
|
||||
source.includes("创建账号并领取试用"),
|
||||
"auth entry copy must be product-specific and explain the signup trial path",
|
||||
);
|
||||
assert(
|
||||
source.includes("PW") && source.includes("PolyWeather"),
|
||||
"auth pages must show a readable PolyWeather brand mark",
|
||||
);
|
||||
assert(
|
||||
!source.includes("brightness-0 invert"),
|
||||
"auth dark panel must not invert the square logo image into a white block",
|
||||
);
|
||||
assert(
|
||||
source.includes("items-start justify-center pt-10") &&
|
||||
source.includes("lg:items-center lg:pt-0"),
|
||||
"auth form must sit higher on mobile while staying centered on desktop",
|
||||
);
|
||||
assert(
|
||||
!source.includes("hover:-translate-y-1"),
|
||||
"auth form card must not shift position on hover",
|
||||
);
|
||||
assert(
|
||||
source.includes("hidden text-xs text-slate-500 sm:inline") &&
|
||||
source.includes("whitespace-nowrap rounded-xl"),
|
||||
"auth mode switch prompt must not crowd or wrap the mobile header",
|
||||
);
|
||||
}
|
||||
@@ -33,8 +33,16 @@ export function runTests() {
|
||||
"callback",
|
||||
"route.ts",
|
||||
);
|
||||
const loginPagePath = path.join(
|
||||
projectRoot,
|
||||
"app",
|
||||
"auth",
|
||||
"login",
|
||||
"page.tsx",
|
||||
);
|
||||
|
||||
const loginClientSource = fs.readFileSync(loginClientPath, "utf8");
|
||||
const loginPageSource = fs.readFileSync(loginPagePath, "utf8");
|
||||
|
||||
assert(
|
||||
loginClientSource.includes("/auth/reset-password") &&
|
||||
@@ -80,4 +88,22 @@ export function runTests() {
|
||||
authCallbackSource.includes("Bearer"),
|
||||
"auth callback must warm /api/auth/me with the exchanged Supabase session so new users receive the signup trial immediately",
|
||||
);
|
||||
assert(
|
||||
loginPageSource.includes("error?: string") &&
|
||||
loginPageSource.includes("normalizeAuthError") &&
|
||||
loginPageSource.includes("params.error") &&
|
||||
loginPageSource.includes("initialError={initialError}") &&
|
||||
loginClientSource.includes("initialError?: string") &&
|
||||
loginClientSource.includes("useState(initialError || \"\")"),
|
||||
"login page must surface auth callback errors in the login form instead of silently returning users to the terminal",
|
||||
);
|
||||
assert(
|
||||
authCallbackSource.includes("redirectToLoginWithError") &&
|
||||
authCallbackSource.includes('request.nextUrl.searchParams.get("error_description")') &&
|
||||
authCallbackSource.includes('request.nextUrl.searchParams.get("error")') &&
|
||||
authCallbackSource.includes("exchangeError") &&
|
||||
authCallbackSource.includes("exchangeCodeForSession(code)") &&
|
||||
authCallbackSource.includes("auth_error"),
|
||||
"auth callback must redirect failed OAuth/session exchanges back to login with an error message",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,11 +11,12 @@ import {
|
||||
ChevronRight,
|
||||
GraduationCap,
|
||||
Menu,
|
||||
MessageSquare,
|
||||
Search,
|
||||
UserRound,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { Fragment, useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Fragment, memo, useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { CityListItem, ProAccessState, ScanOpportunityRow } from "@/lib/dashboard-types";
|
||||
import { getInitialLocaleFromNavigator } from "@/lib/i18n";
|
||||
import { isBrowserLocalFullAccess } from "@/lib/local-dev-access";
|
||||
@@ -55,6 +56,12 @@ import { KoyfinRowsTable } from "@/components/dashboard/scan-terminal/KoyfinRows
|
||||
import { rowName, pct, money, temp, edgeClass } from "@/components/dashboard/scan-terminal/utils";
|
||||
import { CitySelectorDropdown } from "@/components/dashboard/scan-terminal/CitySelectorDropdown";
|
||||
import { GridLayoutSelector } from "@/components/dashboard/scan-terminal/GridLayoutSelector";
|
||||
import {
|
||||
UserFeedbackModal,
|
||||
type FeedbackDraft,
|
||||
} from "@/components/dashboard/scan-terminal/UserFeedbackModal";
|
||||
import { UserFeedbackStatusButton } from "@/components/dashboard/scan-terminal/UserFeedbackStatusButton";
|
||||
import { UpdateAnnouncementButton } from "@/components/dashboard/scan-terminal/UpdateAnnouncementButton";
|
||||
import {
|
||||
mergeAccessStateWithAuthPayload,
|
||||
type AuthProfilePayload,
|
||||
@@ -63,6 +70,7 @@ import {
|
||||
createAuthProfileRequestCache,
|
||||
loadTerminalAuthProfile,
|
||||
} from "@/components/dashboard/scan-terminal/terminal-auth-bootstrap";
|
||||
import { buildAuthMePath } from "@/lib/auth-snapshot";
|
||||
import {
|
||||
cityListItemsToScanRows,
|
||||
mergeScanRowsWithCityFallbackRows,
|
||||
@@ -87,8 +95,15 @@ const TrainingDashboard = dynamic(
|
||||
);
|
||||
|
||||
const ONLINE_USERS_REFRESH_MS = 5 * 60_000;
|
||||
const TERMINAL_NAV_ITEMS = [
|
||||
{ key: "thresholds", Icon: Activity, labelEn: "Decision", labelZh: "天气决策" },
|
||||
{ key: "training", Icon: GraduationCap, labelEn: "Training", labelZh: "训练数据" },
|
||||
{ key: "guide", Icon: BookOpenCheck, labelEn: "Guide", labelZh: "使用指南" },
|
||||
] as const;
|
||||
const AUTH_PROFILE_REQUEST_TIMEOUT_MS = 4500;
|
||||
const AUTH_DECISION_RECOVERY_MS = 10_000;
|
||||
const AUTH_PROFILE_RETRY_INITIAL_DELAY_MS = 5_000;
|
||||
const AUTH_PROFILE_RETRY_POLL_MS = 30_000;
|
||||
const ACTIVE_ACCESS_CACHE_KEY = "polyweather_terminal_active_access_v1";
|
||||
const ACTIVE_ACCESS_CACHE_TTL_MS = 6 * 60 * 60 * 1000;
|
||||
|
||||
@@ -258,6 +273,7 @@ const MAX_TERMINAL_GRID_SIDE = 3;
|
||||
const MAX_TERMINAL_CHARTS = 9;
|
||||
const MOBILE_TERMINAL_CHARTS = 1;
|
||||
const DEFAULT_TERMINAL_GRID_SIDE = 2;
|
||||
const TERMINAL_SLOTS_STORAGE_KEY = "polyweather_terminal_slots";
|
||||
|
||||
function clampGridSide(value: number) {
|
||||
if (!Number.isFinite(value)) return DEFAULT_TERMINAL_GRID_SIDE;
|
||||
@@ -284,6 +300,45 @@ function normalizeSlotList(slots: Array<string | null>, totalSlots: number) {
|
||||
return [...slots, ...Array(totalSlots - slots.length).fill(null)];
|
||||
}
|
||||
|
||||
function readStoredTerminalSlots(totalSlots = MAX_TERMINAL_CHARTS) {
|
||||
const emptySlots = Array(totalSlots).fill(null) as Array<string | null>;
|
||||
if (typeof window === "undefined") {
|
||||
return { slots: emptySlots, hasPersistedSlots: false };
|
||||
}
|
||||
try {
|
||||
const stored = localStorage.getItem(TERMINAL_SLOTS_STORAGE_KEY);
|
||||
if (stored === null) return { slots: emptySlots, hasPersistedSlots: false };
|
||||
const parsed = JSON.parse(stored);
|
||||
if (!Array.isArray(parsed)) return { slots: emptySlots, hasPersistedSlots: true };
|
||||
const normalized = parsed.map((value) => {
|
||||
const text = String(value || "").toLowerCase().trim();
|
||||
return text || null;
|
||||
});
|
||||
return {
|
||||
slots: normalizeSlotList(normalized, totalSlots),
|
||||
hasPersistedSlots: true,
|
||||
};
|
||||
} catch {
|
||||
return { slots: emptySlots, hasPersistedSlots: false };
|
||||
}
|
||||
}
|
||||
|
||||
function shouldAutofillInitialSlots({
|
||||
filteredRegionRows,
|
||||
hasPersistedSlots,
|
||||
slots,
|
||||
}: {
|
||||
filteredRegionRows: ScanOpportunityRow[];
|
||||
hasPersistedSlots: boolean;
|
||||
slots: Array<string | null>;
|
||||
}) {
|
||||
return (
|
||||
!hasPersistedSlots &&
|
||||
filteredRegionRows.length > 0 &&
|
||||
slots.every((slot) => slot === null)
|
||||
);
|
||||
}
|
||||
|
||||
function t(key: keyof typeof TERM, isEn: boolean) {
|
||||
return isEn ? TERM[key].en : TERM[key].zh;
|
||||
}
|
||||
@@ -433,6 +488,120 @@ function EmptySlotCard({
|
||||
);
|
||||
}
|
||||
|
||||
const TerminalSidebar = memo(function TerminalSidebar({
|
||||
activeNavKey,
|
||||
isEn,
|
||||
onFeedbackClick,
|
||||
onSelectNav,
|
||||
}: {
|
||||
activeNavKey: string;
|
||||
isEn: boolean;
|
||||
onFeedbackClick: () => void;
|
||||
onSelectNav: (key: string) => void;
|
||||
}) {
|
||||
const [navExpanded, setNavExpanded] = useState(false);
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={clsx(
|
||||
"flex shrink-0 flex-col bg-white border-r border-[#d2d9e2] py-3 text-slate-500 transition-colors duration-150",
|
||||
navExpanded ? "w-[172px] items-start px-3" : "w-[52px] items-center gap-2",
|
||||
)}
|
||||
>
|
||||
<div className={clsx(
|
||||
"flex items-center w-full",
|
||||
navExpanded ? "gap-3 mb-3 px-1" : "justify-center mb-2",
|
||||
)}>
|
||||
<Link
|
||||
href="/"
|
||||
className="block h-7 w-7 shrink-0 overflow-hidden rounded transition hover:opacity-90"
|
||||
title="PolyWeather"
|
||||
>
|
||||
<img src="/apple-touch-icon.png" alt="PolyWeather" className="h-full w-full object-cover" />
|
||||
</Link>
|
||||
{navExpanded && (
|
||||
<span className="text-sm font-black text-slate-800 tracking-tight truncate">
|
||||
PolyWeather
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setNavExpanded((prev) => !prev)}
|
||||
className={clsx(
|
||||
"flex items-center gap-3 transition-colors hover:text-slate-800",
|
||||
navExpanded
|
||||
? "w-full h-8 px-1 mb-2"
|
||||
: "grid h-9 w-full place-items-center mb-2",
|
||||
)}
|
||||
>
|
||||
{navExpanded ? (
|
||||
<>
|
||||
<ChevronLeft size={14} />
|
||||
<span className="text-[11px] font-bold uppercase tracking-wide text-slate-400">
|
||||
{isEn ? "Collapse" : "收起"}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<Menu size={18} />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{TERMINAL_NAV_ITEMS.map(({ key, Icon, labelEn, labelZh }) => {
|
||||
const isActive = activeNavKey === key;
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={() => onSelectNav(key)}
|
||||
className={clsx(
|
||||
"flex items-center gap-3 transition-colors rounded",
|
||||
navExpanded
|
||||
? "w-full h-9 px-2 text-left"
|
||||
: "grid h-9 w-full place-items-center border-l-4",
|
||||
isActive
|
||||
? navExpanded
|
||||
? "bg-blue-50 text-blue-600 font-bold"
|
||||
: "border-blue-500 bg-blue-50/50 text-blue-600"
|
||||
: navExpanded
|
||||
? "hover:bg-slate-50 hover:text-slate-900"
|
||||
: "border-transparent hover:bg-slate-50 hover:text-slate-700",
|
||||
)}
|
||||
title={isEn ? labelEn : labelZh}
|
||||
>
|
||||
<Icon size={16} className="shrink-0" />
|
||||
{navExpanded && (
|
||||
<span className="text-xs font-semibold whitespace-nowrap">
|
||||
{isEn ? labelEn : labelZh}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className={clsx("mt-auto w-full border-t border-slate-200 pt-2", navExpanded ? "" : "px-0")}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onFeedbackClick}
|
||||
className={clsx(
|
||||
"flex items-center gap-3 rounded text-slate-500 transition-colors hover:bg-slate-50 hover:text-blue-700",
|
||||
navExpanded ? "h-9 w-full px-2 text-left" : "grid h-9 w-full place-items-center border-l-4 border-transparent",
|
||||
)}
|
||||
title={isEn ? "Feedback / Bug" : "反馈 / Bug"}
|
||||
>
|
||||
<MessageSquare size={16} className="shrink-0" />
|
||||
{navExpanded && (
|
||||
<span className="text-xs font-semibold whitespace-nowrap">
|
||||
{isEn ? "Feedback" : "反馈 / Bug"}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
});
|
||||
|
||||
function PolyWeatherTerminal({
|
||||
generatedText,
|
||||
isEn,
|
||||
@@ -495,9 +664,13 @@ function PolyWeatherTerminal({
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [searchInputRef, setSearchQuery]);
|
||||
const [navExpanded, setNavExpanded] = useState(false);
|
||||
const [activeNavKey, setActiveNavKey] = useState<string>("thresholds");
|
||||
const [onlineCount, setOnlineCount] = useState<number | null>(null);
|
||||
const [feedbackDraft, setFeedbackDraft] = useState<FeedbackDraft | null>(null);
|
||||
const [feedbackRefreshKey, setFeedbackRefreshKey] = useState(0);
|
||||
const handleSelectNav = useCallback((key: string) => {
|
||||
setActiveNavKey(key);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeNavKey !== "thresholds") return;
|
||||
@@ -534,25 +707,62 @@ function PolyWeatherTerminal({
|
||||
|
||||
const totalSlots = getSlotCount(gridCols, gridRows);
|
||||
|
||||
const hasPersistedSlots = useRef(false);
|
||||
const [slots, setSlots] = useState<Array<string | null>>(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
try {
|
||||
const stored = localStorage.getItem("polyweather_terminal_slots");
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored);
|
||||
if (Array.isArray(parsed)) {
|
||||
return normalizeSlotList(parsed, MAX_TERMINAL_CHARTS);
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
return Array(MAX_TERMINAL_CHARTS).fill(null);
|
||||
const stored = readStoredTerminalSlots();
|
||||
hasPersistedSlots.current = stored.hasPersistedSlots;
|
||||
return stored.slots;
|
||||
});
|
||||
const [activeSlotIndex, setActiveSlotIndex] = useState<number>(0);
|
||||
const [maximizedSlotIndex, setMaximizedSlotIndex] = useState<number | null>(null);
|
||||
const [activeSearchSlotIndex, setActiveSearchSlotIndex] = useState<number | null>(null);
|
||||
const visibleSlots = useMemo(() => slots.slice(0, totalSlots), [slots, totalSlots]);
|
||||
|
||||
const openTerminalFeedback = useCallback(() => {
|
||||
setFeedbackDraft({
|
||||
category: "bug",
|
||||
source: "terminal",
|
||||
context: {
|
||||
source: "terminal_sidebar",
|
||||
active_nav: activeNavKey,
|
||||
locale,
|
||||
grid: `${gridCols}x${gridRows}`,
|
||||
active_slot_index: activeSlotIndex,
|
||||
maximized_slot_index: maximizedSlotIndex,
|
||||
visible_slots: visibleSlots.filter(Boolean),
|
||||
selected_city: selectedCity || "",
|
||||
selected_region: selectedRegionKey,
|
||||
},
|
||||
});
|
||||
}, [
|
||||
activeNavKey,
|
||||
activeSlotIndex,
|
||||
gridCols,
|
||||
gridRows,
|
||||
locale,
|
||||
maximizedSlotIndex,
|
||||
selectedCity,
|
||||
selectedRegionKey,
|
||||
visibleSlots,
|
||||
]);
|
||||
|
||||
const openChartFeedback = useCallback((context: Record<string, unknown>) => {
|
||||
setFeedbackDraft({
|
||||
category: "bug",
|
||||
source: "chart",
|
||||
context: {
|
||||
...context,
|
||||
active_nav: activeNavKey,
|
||||
locale,
|
||||
grid: `${gridCols}x${gridRows}`,
|
||||
},
|
||||
});
|
||||
}, [activeNavKey, gridCols, gridRows, locale]);
|
||||
|
||||
const handleFeedbackSubmitted = useCallback(() => {
|
||||
setFeedbackRefreshKey((value) => value + 1);
|
||||
}, []);
|
||||
|
||||
const handleSetGridSize = (cols: number, rows: number) => {
|
||||
const safeCols = clampGridSide(cols);
|
||||
const safeRows = clampGridSide(rows);
|
||||
@@ -577,12 +787,6 @@ function PolyWeatherTerminal({
|
||||
}
|
||||
};
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ key: "thresholds", Icon: Activity, labelEn: "Decision", labelZh: "天气决策" },
|
||||
{ key: "training", Icon: GraduationCap, labelEn: "Training", labelZh: "训练数据" },
|
||||
{ key: "guide", Icon: BookOpenCheck, labelEn: "Guide", labelZh: "使用指南" },
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedCity(null);
|
||||
}, [selectedRegionKey, setSelectedCity]);
|
||||
@@ -595,13 +799,20 @@ function PolyWeatherTerminal({
|
||||
}, [rows, selectedRegionKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (filteredRegionRows.length && slots.every((s) => s === null)) {
|
||||
if (
|
||||
shouldAutofillInitialSlots({
|
||||
filteredRegionRows,
|
||||
hasPersistedSlots: hasPersistedSlots.current,
|
||||
slots,
|
||||
})
|
||||
) {
|
||||
const next = Array(MAX_TERMINAL_CHARTS)
|
||||
.fill(null)
|
||||
.map((_, idx) => filteredRegionRows[idx]?.city || null);
|
||||
setSlots(next);
|
||||
hasPersistedSlots.current = true;
|
||||
try {
|
||||
localStorage.setItem("polyweather_terminal_slots", JSON.stringify(next));
|
||||
localStorage.setItem(TERMINAL_SLOTS_STORAGE_KEY, JSON.stringify(next));
|
||||
} catch {}
|
||||
}
|
||||
}, [filteredRegionRows, slots]);
|
||||
@@ -611,8 +822,9 @@ function PolyWeatherTerminal({
|
||||
const next = [...slots];
|
||||
next[index] = city;
|
||||
setSlots(next);
|
||||
hasPersistedSlots.current = true;
|
||||
try {
|
||||
localStorage.setItem("polyweather_terminal_slots", JSON.stringify(next));
|
||||
localStorage.setItem(TERMINAL_SLOTS_STORAGE_KEY, JSON.stringify(next));
|
||||
} catch {}
|
||||
};
|
||||
|
||||
@@ -658,24 +870,46 @@ function PolyWeatherTerminal({
|
||||
() => buildContinentGroups(filteredRegionRows, isEn),
|
||||
[filteredRegionRows, isEn]
|
||||
);
|
||||
const mobileGroups = useMemo(
|
||||
() => buildContinentGroups(rows, isEn),
|
||||
[rows, isEn],
|
||||
);
|
||||
const [mobileTab, setMobileTab] = useState<string>("active_signals");
|
||||
const mobileActiveGroup = useMemo(
|
||||
() => continentGroups.find((g) => g.key === mobileTab) || continentGroups[0],
|
||||
[continentGroups, mobileTab]
|
||||
() => mobileGroups.find((g) => g.key === mobileTab) || mobileGroups[0],
|
||||
[mobileGroups, mobileTab],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (continentGroups.length > 0 && !continentGroups.find((g) => g.key === mobileTab)) {
|
||||
setMobileTab(continentGroups[0].key);
|
||||
if (mobileGroups.length > 0 && !mobileGroups.find((g) => g.key === mobileTab)) {
|
||||
setMobileTab(mobileGroups[0].key);
|
||||
}
|
||||
}, [continentGroups, mobileTab]);
|
||||
}, [mobileGroups, mobileTab]);
|
||||
const mobileSelectedRowInActiveGroup = useMemo(
|
||||
() =>
|
||||
selectedRow && mobileActiveGroup?.rows.some((row) => row.id === selectedRow.id)
|
||||
? selectedRow
|
||||
: null,
|
||||
[mobileActiveGroup?.rows, selectedRow],
|
||||
);
|
||||
const mobileChartRow = useMemo(
|
||||
() =>
|
||||
selectedRow ||
|
||||
mobileActiveGroup?.rows.slice(0, MOBILE_TERMINAL_CHARTS)[0] ||
|
||||
mobileSelectedRowInActiveGroup ||
|
||||
mobileActiveGroup?.rows[0] ||
|
||||
filteredRegionRows[0] ||
|
||||
null,
|
||||
[filteredRegionRows, mobileActiveGroup?.rows, selectedRow],
|
||||
[filteredRegionRows, mobileActiveGroup?.rows, mobileSelectedRowInActiveGroup],
|
||||
);
|
||||
const handleMobileSelectTab = useCallback((key: string) => {
|
||||
setMobileTab(key);
|
||||
if (selectedRegionKey !== "all") {
|
||||
setSelectedRegionKey("all");
|
||||
}
|
||||
const nextGroup = mobileGroups.find((group) => group.key === key);
|
||||
const nextRow = nextGroup?.rows[0];
|
||||
if (nextRow) {
|
||||
setSelectedRow(nextRow);
|
||||
}
|
||||
}, [mobileGroups, selectedRegionKey, setSelectedRegionKey, setSelectedRow]);
|
||||
useEffect(() => {
|
||||
if (!filteredRegionRows.length) return;
|
||||
if (!selectedRow || !filteredRegionRows.some((row) => row.id === selectedRow.id)) {
|
||||
@@ -706,87 +940,12 @@ function PolyWeatherTerminal({
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-full overflow-hidden bg-[#e9edf3] text-[#202833]">
|
||||
<aside
|
||||
className={clsx(
|
||||
"flex shrink-0 flex-col bg-white border-r border-[#d2d9e2] py-3 text-slate-500 transition-all duration-200",
|
||||
navExpanded ? "w-[172px] items-start px-3" : "w-[52px] items-center gap-2",
|
||||
)}
|
||||
>
|
||||
{/* Logo row */}
|
||||
<div className={clsx(
|
||||
"flex items-center w-full",
|
||||
navExpanded ? "gap-3 mb-3 px-1" : "justify-center mb-2",
|
||||
)}>
|
||||
<Link
|
||||
href="/"
|
||||
className="block h-7 w-7 shrink-0 overflow-hidden rounded transition hover:opacity-90"
|
||||
title="PolyWeather"
|
||||
>
|
||||
<img src="/apple-touch-icon.png" alt="PolyWeather" className="h-full w-full object-cover" />
|
||||
</Link>
|
||||
{navExpanded && (
|
||||
<span className="text-sm font-black text-slate-800 tracking-tight truncate">
|
||||
PolyWeather
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Toggle button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setNavExpanded((prev) => !prev)}
|
||||
className={clsx(
|
||||
"flex items-center gap-3 transition-colors hover:text-slate-800",
|
||||
navExpanded
|
||||
? "w-full h-8 px-1 mb-2"
|
||||
: "grid h-9 w-full place-items-center mb-2",
|
||||
)}
|
||||
>
|
||||
{navExpanded ? (
|
||||
<>
|
||||
<ChevronLeft size={14} />
|
||||
<span className="text-[11px] font-bold uppercase tracking-wide text-slate-400">
|
||||
{isEn ? "Collapse" : "收起"}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<Menu size={18} />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Nav items */}
|
||||
{NAV_ITEMS.map(({ key, Icon, labelEn, labelZh }) => {
|
||||
const isActive = activeNavKey === key;
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={() => { setActiveNavKey(key); }}
|
||||
className={clsx(
|
||||
"flex items-center gap-3 transition-colors rounded",
|
||||
navExpanded
|
||||
? "w-full h-9 px-2 text-left"
|
||||
: "grid h-9 w-full place-items-center border-l-4",
|
||||
isActive
|
||||
? navExpanded
|
||||
? "bg-blue-50 text-blue-600 font-bold"
|
||||
: "border-blue-500 bg-blue-50/50 text-blue-600"
|
||||
: navExpanded
|
||||
? "hover:bg-slate-50 hover:text-slate-900"
|
||||
: "border-transparent hover:bg-slate-50 hover:text-slate-700",
|
||||
)}
|
||||
title={isEn ? labelEn : labelZh}
|
||||
>
|
||||
<Icon size={16} className="shrink-0" />
|
||||
{navExpanded && (
|
||||
<span className="text-xs font-semibold whitespace-nowrap">
|
||||
{isEn ? labelEn : labelZh}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</aside>
|
||||
<TerminalSidebar
|
||||
activeNavKey={activeNavKey}
|
||||
isEn={isEn}
|
||||
onFeedbackClick={openTerminalFeedback}
|
||||
onSelectNav={handleSelectNav}
|
||||
/>
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<header className="flex h-12 shrink-0 items-center justify-between border-b border-[#d2d9e2] bg-white px-4 text-slate-800">
|
||||
@@ -795,6 +954,9 @@ function PolyWeatherTerminal({
|
||||
<Activity size={13} />
|
||||
{t("dashboard", isEn)}
|
||||
</div>
|
||||
<div className="hidden lg:block">
|
||||
<UpdateAnnouncementButton isEn={isEn} />
|
||||
</div>
|
||||
{onlineCount != null && (
|
||||
<div className="hidden items-center gap-1 text-[10px] font-medium text-slate-400 lg:flex">
|
||||
<Users size={12} />
|
||||
@@ -820,6 +982,7 @@ function PolyWeatherTerminal({
|
||||
>
|
||||
{isEn ? "中文" : "EN"}
|
||||
</button>
|
||||
<UserFeedbackStatusButton isEn={isEn} refreshKey={feedbackRefreshKey} />
|
||||
<Link
|
||||
href="/account"
|
||||
className="grid h-7 w-7 place-items-center rounded-full border border-slate-300 bg-white text-slate-500 transition-colors hover:bg-slate-50 hover:text-slate-900"
|
||||
@@ -839,16 +1002,18 @@ function PolyWeatherTerminal({
|
||||
<>
|
||||
{/* Mobile layout */}
|
||||
<div className="flex flex-col gap-2 lg:hidden overflow-auto flex-1 pb-6">
|
||||
<MobileRegionTabs
|
||||
activeTab={mobileTab}
|
||||
groups={continentGroups}
|
||||
isEn={isEn}
|
||||
onSelectTab={setMobileTab}
|
||||
/>
|
||||
<div className="rounded border border-blue-200 bg-blue-50 px-3 py-2 text-[11px] font-semibold leading-4 text-blue-700">
|
||||
{isEn
|
||||
? "Mobile renders one chart. Rotate to landscape to inspect the full terminal grid."
|
||||
: "手机端仅渲染 1 个图表。建议横屏查看完整终端网格。"}
|
||||
<div className="mobile-region-tabs-shell sticky top-0 z-30 -mx-2 bg-[#eef2f6] pb-2 shadow-sm">
|
||||
<MobileRegionTabs
|
||||
activeTab={mobileTab}
|
||||
groups={mobileGroups}
|
||||
isEn={isEn}
|
||||
onSelectTab={handleMobileSelectTab}
|
||||
/>
|
||||
<div className="mx-2 mt-1 rounded border border-blue-200 bg-blue-50 px-2 py-1 text-[10px] font-semibold leading-3 text-blue-700">
|
||||
{isEn
|
||||
? "Mobile renders one chart. Rotate to landscape for the full grid."
|
||||
: "手机端仅渲染 1 个图表。建议横屏查看完整终端网格。"}
|
||||
</div>
|
||||
</div>
|
||||
{mobileChartRow && (
|
||||
<div className="h-[420px] min-h-[420px] overflow-hidden rounded border border-[#d2d9e2] bg-white">
|
||||
@@ -858,6 +1023,7 @@ function PolyWeatherTerminal({
|
||||
allRows={filteredRegionRows}
|
||||
compact={false}
|
||||
disableClose={true}
|
||||
onReportIssue={openChartFeedback}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -872,16 +1038,16 @@ function PolyWeatherTerminal({
|
||||
))}
|
||||
</div>
|
||||
{/* Mobile Selected Row Detail */}
|
||||
{selectedRow && (
|
||||
{mobileChartRow && (
|
||||
<div className="rounded-lg border border-slate-200 bg-white p-4 shadow-sm">
|
||||
<h3 className="text-sm font-black text-slate-900 mb-2">{rowName(selectedRow)}</h3>
|
||||
<h3 className="text-sm font-black text-slate-900 mb-2">{rowName(mobileChartRow)}</h3>
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
{[
|
||||
["Obs", temp(selectedRow.current_temp, selectedRow.temp_symbol)],
|
||||
["High", temp(selectedRow.current_max_so_far, selectedRow.temp_symbol)],
|
||||
["DEB", temp(selectedRow.deb_prediction, selectedRow.temp_symbol)],
|
||||
["Gap", temp(selectedRow.signed_gap ?? selectedRow.gap_to_target, selectedRow.temp_symbol)],
|
||||
["Edge", pct(selectedRow.edge_percent)],
|
||||
["Obs", temp(mobileChartRow.current_temp, mobileChartRow.temp_symbol)],
|
||||
["High", temp(mobileChartRow.current_max_so_far, mobileChartRow.temp_symbol)],
|
||||
["DEB", temp(mobileChartRow.deb_prediction, mobileChartRow.temp_symbol)],
|
||||
["Gap", temp(mobileChartRow.signed_gap ?? mobileChartRow.gap_to_target, mobileChartRow.temp_symbol)],
|
||||
["Edge", pct(mobileChartRow.edge_percent)],
|
||||
["Market", "--"],
|
||||
].map(([label, value]) => (
|
||||
<div key={label} className="rounded border border-slate-200 bg-slate-50 p-2">
|
||||
@@ -917,8 +1083,8 @@ function PolyWeatherTerminal({
|
||||
handleSelectCityForSlot(maximizedSlotIndex, null);
|
||||
setMaximizedSlotIndex(null);
|
||||
}}
|
||||
onReportIssue={openChartFeedback}
|
||||
isMaximized={true}
|
||||
disableClose={visibleSlots.filter(Boolean).length <= 1}
|
||||
/>
|
||||
|
||||
{activeSearchSlotIndex === maximizedSlotIndex && (
|
||||
@@ -1005,8 +1171,8 @@ function PolyWeatherTerminal({
|
||||
onClose={() => {
|
||||
handleSelectCityForSlot(slotIndex, null);
|
||||
}}
|
||||
onReportIssue={openChartFeedback}
|
||||
isMaximized={false}
|
||||
disableClose={visibleSlots.filter(Boolean).length <= 1}
|
||||
/>
|
||||
|
||||
{activeSearchSlotIndex === slotIndex && (
|
||||
@@ -1032,6 +1198,12 @@ function PolyWeatherTerminal({
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
<UserFeedbackModal
|
||||
draft={feedbackDraft}
|
||||
isEn={isEn}
|
||||
onClose={() => setFeedbackDraft(null)}
|
||||
onSubmitted={handleFeedbackSubmitted}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1122,9 +1294,10 @@ function ScanTerminalScreen() {
|
||||
: null;
|
||||
try {
|
||||
const response = await fetch(
|
||||
options?.preferSnapshot
|
||||
? "/api/auth/me?prefer_snapshot=1"
|
||||
: "/api/auth/me",
|
||||
buildAuthMePath({
|
||||
preferSnapshot: options?.preferSnapshot,
|
||||
scope: "entitlement",
|
||||
}),
|
||||
{
|
||||
cache: "no-store",
|
||||
headers,
|
||||
@@ -1383,10 +1556,10 @@ function ScanTerminalScreen() {
|
||||
|
||||
const firstRetry = window.setTimeout(() => {
|
||||
void retryAuthProfile();
|
||||
}, 1500);
|
||||
}, AUTH_PROFILE_RETRY_INITIAL_DELAY_MS);
|
||||
const interval = window.setInterval(() => {
|
||||
void retryAuthProfile();
|
||||
}, 5000);
|
||||
}, AUTH_PROFILE_RETRY_POLL_MS);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearTimeout(firstRetry);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import clsx from "clsx";
|
||||
import { Bug, ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { ScanOpportunityRow } from "@/lib/dashboard-types";
|
||||
import { useLatestPatch, useSseResyncVersion } from "@/hooks/use-sse-patches";
|
||||
@@ -10,6 +11,7 @@ import { TemperatureChartCanvas } from "@/components/dashboard/scan-terminal/Tem
|
||||
import { TemperatureRunwayDetails } from "@/components/dashboard/scan-terminal/TemperatureRunwayDetails";
|
||||
import { TemperatureStatsBars } from "@/components/dashboard/scan-terminal/TemperatureStatsBars";
|
||||
import { rowName } from "@/components/dashboard/scan-terminal/utils";
|
||||
import { DASHBOARD_REFRESH_POLICY_MS } from "@/lib/refresh-policy";
|
||||
|
||||
import {
|
||||
HOURLY_CACHE_TTL_MS,
|
||||
@@ -30,6 +32,7 @@ import {
|
||||
normObs,
|
||||
prefersHighFrequencyRunwayResolution,
|
||||
readSessionCache,
|
||||
selectCompactSecondaryTemp,
|
||||
selectDisplayRunwayTemp,
|
||||
seedHourlyForecastFromRow,
|
||||
shouldPollLiveChart,
|
||||
@@ -56,6 +59,7 @@ const PEAK_GLOW_BADGE_CLASS = {
|
||||
|
||||
const PROBABILITY_REFRESH_AFTER_PATCH_MS = 60_000;
|
||||
const FOREGROUND_FULL_DETAIL_REFRESH_DEDUP_MS = 90_000;
|
||||
const NO_PATCH_FULL_DETAIL_FALLBACK_MS = DASHBOARD_REFRESH_POLICY_MS.metar;
|
||||
const DETAIL_LOAD_BATCH_DELAY_MS = 0;
|
||||
const INITIAL_DETAIL_LOAD_SLOTS = 3;
|
||||
const DEFERRED_DETAIL_LOAD_DELAY_MS = 1_200;
|
||||
@@ -107,6 +111,236 @@ function getWundergroundDailyHigh(hourly: HourlyForecast) {
|
||||
return validNumber(hourly?.wundergroundCurrent?.max_so_far) ?? null;
|
||||
}
|
||||
|
||||
type AdvancedWeatherVariableItem = {
|
||||
key: "wind_dir" | "wind_speed" | "dewpoint" | "humidity" | "pressure";
|
||||
label: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
type SourceCadenceSummary = {
|
||||
label: string;
|
||||
cadence: string;
|
||||
status: string | null;
|
||||
};
|
||||
|
||||
function formatCompactNumber(value: number, decimals = 1) {
|
||||
const rounded = Number(value.toFixed(decimals));
|
||||
return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(decimals);
|
||||
}
|
||||
|
||||
function fallbackCadenceSeconds(sourceText: string) {
|
||||
const value = sourceText.toLowerCase();
|
||||
if (value.includes("amos")) return 60;
|
||||
if (value.includes("amsc")) return 180;
|
||||
if (value.includes("madis") || value.includes("hfmetar")) return 300;
|
||||
if (value.includes("cowin")) return 60;
|
||||
if (value.includes("hko")) return 600;
|
||||
if (value.includes("cwa") || value.includes("jma") || value.includes("fmi") || value.includes("knmi")) return 600;
|
||||
if (value.includes("mgm")) return 900;
|
||||
if (value.includes("mss") || value.includes("singapore")) return 60;
|
||||
return null;
|
||||
}
|
||||
|
||||
function formatCadence(seconds: number) {
|
||||
return `${Math.round(seconds)}s`;
|
||||
}
|
||||
|
||||
function sourceStatusLabel(status: string | null | undefined, isEn: boolean) {
|
||||
if (!status) return null;
|
||||
const normalized = status.toLowerCase();
|
||||
if (normalized === "fresh") return isEn ? "fresh" : "新鲜";
|
||||
if (normalized === "expected_wait") return isEn ? "waiting for source" : "等待源头";
|
||||
if (normalized === "delayed") return isEn ? "delayed" : "延迟";
|
||||
if (normalized === "stale") return isEn ? "stale" : "过旧";
|
||||
if (normalized === "offline") return isEn ? "offline" : "离线";
|
||||
return status;
|
||||
}
|
||||
|
||||
function buildSourceCadenceSummary(
|
||||
row: ScanOpportunityRow | null,
|
||||
hourly: HourlyForecast,
|
||||
isEn: boolean,
|
||||
): SourceCadenceSummary | null {
|
||||
const primary = hourly?.airportPrimary || hourly?.airportCurrent || null;
|
||||
const sourceParts = [
|
||||
(primary as any)?.source,
|
||||
primary?.source_code,
|
||||
primary?.source_label,
|
||||
primary?.station_code,
|
||||
row?.metar_context?.source,
|
||||
row?.metar_context?.station_label,
|
||||
row?.airport,
|
||||
]
|
||||
.map((item) => String(item || "").trim())
|
||||
.filter(Boolean);
|
||||
const sourceText = sourceParts.join(" ");
|
||||
const nativeCadence = validNumber(primary?.freshness?.native_update_interval_sec);
|
||||
const cadenceSeconds = nativeCadence ?? fallbackCadenceSeconds(sourceText);
|
||||
if (cadenceSeconds === null) return null;
|
||||
|
||||
const label =
|
||||
primary?.source_label ||
|
||||
primary?.source_code ||
|
||||
(primary as any)?.source ||
|
||||
row?.metar_context?.station_label ||
|
||||
row?.airport ||
|
||||
(isEn ? "Source" : "数据源");
|
||||
return {
|
||||
label,
|
||||
cadence: formatCadence(cadenceSeconds),
|
||||
status: sourceStatusLabel(primary?.freshness?.freshness_status, isEn),
|
||||
};
|
||||
}
|
||||
|
||||
function buildAdvancedWeatherVariableItems(
|
||||
row: ScanOpportunityRow | null,
|
||||
hourly: HourlyForecast,
|
||||
isEn: boolean,
|
||||
): AdvancedWeatherVariableItem[] {
|
||||
const primary = hourly?.airportPrimary || hourly?.airportCurrent || null;
|
||||
const current = hourly?.current || null;
|
||||
const metarContext = row?.metar_context || null;
|
||||
const tempSymbol = row?.temp_symbol || primary?.temp_symbol || "°C";
|
||||
|
||||
const windDir =
|
||||
validNumber(primary?.wind_dir) ??
|
||||
validNumber(current?.wind_dir) ??
|
||||
validNumber(metarContext?.airport_wind_dir);
|
||||
const windSpeed =
|
||||
validNumber(primary?.wind_speed_kt) ??
|
||||
validNumber(current?.wind_speed_kt) ??
|
||||
validNumber(metarContext?.airport_wind_speed_kt);
|
||||
const dewpoint =
|
||||
validNumber(current?.dewpoint) ??
|
||||
validNumber((current as any)?.dew_point) ??
|
||||
validNumber((primary as any)?.dewpoint) ??
|
||||
validNumber((primary as any)?.dew_point);
|
||||
const humidity =
|
||||
validNumber(primary?.humidity) ??
|
||||
validNumber(current?.humidity) ??
|
||||
validNumber(metarContext?.airport_humidity);
|
||||
const pressure =
|
||||
validNumber(primary?.pressure_hpa) ??
|
||||
validNumber((current as any)?.pressure_hpa) ??
|
||||
validNumber((current as any)?.pressure);
|
||||
|
||||
const items: AdvancedWeatherVariableItem[] = [];
|
||||
if (windDir !== null) {
|
||||
items.push({
|
||||
key: "wind_dir",
|
||||
label: isEn ? "Wind Dir" : "风向",
|
||||
value: `${Math.round(windDir)}°`,
|
||||
});
|
||||
}
|
||||
if (windSpeed !== null) {
|
||||
items.push({
|
||||
key: "wind_speed",
|
||||
label: isEn ? "Wind Speed" : "风速",
|
||||
value: `${formatCompactNumber(windSpeed)} kt`,
|
||||
});
|
||||
}
|
||||
if (dewpoint !== null) {
|
||||
items.push({
|
||||
key: "dewpoint",
|
||||
label: isEn ? "Dew Point" : "露点",
|
||||
value: `${formatCompactNumber(dewpoint)}${tempSymbol}`,
|
||||
});
|
||||
}
|
||||
if (humidity !== null) {
|
||||
items.push({
|
||||
key: "humidity",
|
||||
label: isEn ? "Humidity" : "湿度",
|
||||
value: `${formatCompactNumber(humidity)}%`,
|
||||
});
|
||||
}
|
||||
if (pressure !== null) {
|
||||
items.push({
|
||||
key: "pressure",
|
||||
label: isEn ? "Pressure" : "气压",
|
||||
value: `${formatCompactNumber(pressure)} hPa`,
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
function SourceCadenceStrip({
|
||||
isEn,
|
||||
summary,
|
||||
}: {
|
||||
isEn: boolean;
|
||||
summary: SourceCadenceSummary | null;
|
||||
}) {
|
||||
if (!summary) return null;
|
||||
|
||||
return (
|
||||
<div className="shrink-0 border-b border-slate-200 bg-slate-50/70 px-4 py-2">
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-slate-600">
|
||||
<span className="font-bold text-slate-700">
|
||||
{isEn ? "Source" : "数据源"}: {summary.label}
|
||||
</span>
|
||||
<span className="font-mono font-black text-slate-900">
|
||||
{isEn ? "native cadence" : "源头频率"} {summary.cadence}
|
||||
</span>
|
||||
{summary.status && (
|
||||
<span className="rounded border border-slate-200 bg-white px-1.5 py-0.5 font-semibold text-slate-500">
|
||||
{summary.status}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AdvancedWeatherVariablesStrip({
|
||||
isEn,
|
||||
items,
|
||||
}: {
|
||||
isEn: boolean;
|
||||
items: AdvancedWeatherVariableItem[];
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
if (!items.length) return null;
|
||||
|
||||
return (
|
||||
<div className="shrink-0 border-b border-slate-200 bg-white px-4 py-2">
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={expanded}
|
||||
onClick={() => setExpanded((value) => !value)}
|
||||
className="inline-flex min-h-9 items-center gap-2 rounded-md border border-slate-200 bg-slate-50 px-3 text-[11px] font-bold text-slate-600 shadow-sm transition-colors hover:border-slate-300 hover:bg-white hover:text-slate-900"
|
||||
>
|
||||
<span>{isEn ? "Advanced Variables" : "高级气象变量"}</span>
|
||||
<span className="font-mono text-slate-400">{items.length}</span>
|
||||
{expanded ? <ChevronUp size={13} aria-hidden="true" /> : <ChevronDown size={13} aria-hidden="true" />}
|
||||
</button>
|
||||
{expanded && (
|
||||
<div className="mt-2 grid gap-2 sm:grid-cols-5">
|
||||
{items.map((item) => (
|
||||
<div
|
||||
key={item.key}
|
||||
className="rounded-md border border-slate-100 bg-slate-50 px-3 py-2"
|
||||
>
|
||||
<div className="text-[10px] font-semibold uppercase tracking-wide text-slate-400">
|
||||
{item.label}
|
||||
</div>
|
||||
<div className="mt-1 font-mono text-sm font-black text-slate-800">
|
||||
{item.value}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{expanded && (
|
||||
<p className="mt-2 text-[11px] leading-5 text-slate-500">
|
||||
{isEn
|
||||
? "Context only. These variables help explain capping and boundary-layer structure; they are not settlement-temperature curves."
|
||||
: "仅作上下文。它们用于解释压温和边界层结构,不是结算温度曲线。"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function shouldFetchCityDetailForChart({
|
||||
city,
|
||||
documentHidden,
|
||||
@@ -166,6 +400,7 @@ export function LiveTemperatureThresholdChart({
|
||||
onSearchClick,
|
||||
onMaximize,
|
||||
onClose,
|
||||
onReportIssue,
|
||||
isMaximized = false,
|
||||
disableClose = false,
|
||||
isActive = !compact,
|
||||
@@ -178,6 +413,7 @@ export function LiveTemperatureThresholdChart({
|
||||
onSearchClick?: () => void;
|
||||
onMaximize?: () => void;
|
||||
onClose?: () => void;
|
||||
onReportIssue?: (context: Record<string, unknown>) => void;
|
||||
isMaximized?: boolean;
|
||||
disableClose?: boolean;
|
||||
isActive?: boolean;
|
||||
@@ -429,7 +665,7 @@ export function LiveTemperatureThresholdChart({
|
||||
};
|
||||
}, [resyncVersion, city, targetResolution]);
|
||||
|
||||
// ── SSE fallback: only full-fetch if a visible chart has seen no patch for 2 minutes ──
|
||||
// ── SSE fallback: only full-fetch if a visible chart has seen no patch for one METAR cadence ──
|
||||
useEffect(() => {
|
||||
if (!shouldPollLiveChart({ city, compact, isActive, isMaximized })) return;
|
||||
let cancelled = false;
|
||||
@@ -453,7 +689,7 @@ export function LiveTemperatureThresholdChart({
|
||||
|
||||
const checkFallback = () => {
|
||||
if (typeof document !== "undefined" && document.visibilityState === "hidden") return;
|
||||
if (Date.now() - lastPatchAtRef.current < 2 * 60_000) return;
|
||||
if (Date.now() - lastPatchAtRef.current < NO_PATCH_FULL_DETAIL_FALLBACK_MS) return;
|
||||
|
||||
refreshFullDetail();
|
||||
};
|
||||
@@ -632,6 +868,7 @@ export function LiveTemperatureThresholdChart({
|
||||
}, [chartSeries, userToggledKeys, city, showRunwayDetails]);
|
||||
|
||||
const {
|
||||
isHKO,
|
||||
isShenzhen,
|
||||
metarHeaderLabel,
|
||||
metarHighLabel,
|
||||
@@ -639,11 +876,17 @@ export function LiveTemperatureThresholdChart({
|
||||
runwayHighLabel,
|
||||
} = useMemo(() => getLiveObservationLabels(row, chartHourly), [row, chartHourly]);
|
||||
|
||||
const { currentRunwayTemp, observedHighMetar, observedHighRunway } = useMemo(
|
||||
const { currentMetarTemp, currentRunwayTemp, observedHighMetar, observedHighRunway } = useMemo(
|
||||
() => getObservationDisplayMetrics(row, chartHourly, settlementPlate),
|
||||
[row, chartHourly, settlementPlate],
|
||||
);
|
||||
const displayRunwayTemp = selectDisplayRunwayTemp(liveTemp, currentRunwayTemp, hasRunwayData);
|
||||
const displayMetarTemp = selectCompactSecondaryTemp({
|
||||
isHKO,
|
||||
isShenzhen,
|
||||
displayMetarTemp: currentMetarTemp,
|
||||
observedHighMetar,
|
||||
});
|
||||
const wundergroundDailyHigh = getWundergroundDailyHigh(chartHourly);
|
||||
|
||||
const localDateStr = chartLocalDate || new Date().toISOString().slice(0, 10);
|
||||
@@ -661,6 +904,14 @@ export function LiveTemperatureThresholdChart({
|
||||
const spread = (modelMax !== null && modelMin !== null) ? modelMax - modelMin : null;
|
||||
const spreadLabel = spread === null ? "" : (spread <= 2.0 ? "低分歧" : (spread <= 4.0 ? "中等分歧" : "高分歧"));
|
||||
const spreadLabelEn = spread === null ? "" : (spread <= 2.0 ? "Low" : (spread <= 4.0 ? "Medium" : "High"));
|
||||
const sourceCadenceSummary = useMemo(
|
||||
() => buildSourceCadenceSummary(row, chartHourly, isEn),
|
||||
[chartHourly, isEn, row],
|
||||
);
|
||||
const advancedWeatherVariables = useMemo(
|
||||
() => buildAdvancedWeatherVariableItems(row, chartHourly, isEn),
|
||||
[chartHourly, isEn, row],
|
||||
);
|
||||
|
||||
const formattedUpdateTime = useMemo(() => {
|
||||
const nowUtc = Date.now();
|
||||
@@ -717,6 +968,7 @@ export function LiveTemperatureThresholdChart({
|
||||
);
|
||||
|
||||
const subtitle = row ? (isEn ? "Live & Forecast" : "实测与预测") : "";
|
||||
const showDetailErrorBadge = !compact || isActive || isMaximized;
|
||||
|
||||
const handleZoomReset = useCallback(() => {
|
||||
setZoomRange(null);
|
||||
@@ -780,6 +1032,56 @@ export function LiveTemperatureThresholdChart({
|
||||
setDetailRetryNonce((value) => value + 1);
|
||||
}, []);
|
||||
|
||||
const handleReportIssue = useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation();
|
||||
onReportIssue?.({
|
||||
source: "chart",
|
||||
city,
|
||||
display_city: row ? rowName(row) : "",
|
||||
row_id: row?.id || "",
|
||||
slot_index: slotIndex,
|
||||
compact,
|
||||
is_active: isActive,
|
||||
is_maximized: isMaximized,
|
||||
detail_error: detailError,
|
||||
is_hourly_loading: isHourlyLoading,
|
||||
showing_stale_detail: showingStaleDetail,
|
||||
target_resolution: targetResolution,
|
||||
view_mode: viewMode,
|
||||
loaded_local_date: chartLocalDate || "",
|
||||
has_runway_data: hasRunwayData,
|
||||
series: chartSeries.map((item) => item.key),
|
||||
visible_series: activeSeries.map((item) => item.key),
|
||||
live_temp: liveTemp,
|
||||
current_runway_temp: currentRunwayTemp,
|
||||
current_metar_temp: currentMetarTemp,
|
||||
observed_high_metar: observedHighMetar,
|
||||
observed_high_runway: observedHighRunway,
|
||||
});
|
||||
}, [
|
||||
activeSeries,
|
||||
chartLocalDate,
|
||||
chartSeries,
|
||||
city,
|
||||
compact,
|
||||
currentMetarTemp,
|
||||
currentRunwayTemp,
|
||||
detailError,
|
||||
hasRunwayData,
|
||||
isActive,
|
||||
isHourlyLoading,
|
||||
isMaximized,
|
||||
liveTemp,
|
||||
observedHighMetar,
|
||||
observedHighRunway,
|
||||
onReportIssue,
|
||||
row,
|
||||
showingStaleDetail,
|
||||
slotIndex,
|
||||
targetResolution,
|
||||
viewMode,
|
||||
]);
|
||||
|
||||
const panelTitle = row ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
@@ -842,8 +1144,18 @@ export function LiveTemperatureThresholdChart({
|
||||
))}
|
||||
</div>
|
||||
|
||||
{(onMaximize || onClose) && (
|
||||
{(onMaximize || onClose || onReportIssue) && (
|
||||
<div className="flex items-center gap-1">
|
||||
{onReportIssue && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleReportIssue}
|
||||
className="grid h-6 w-6 place-items-center rounded bg-white hover:bg-slate-50 border border-slate-200 text-slate-500 hover:text-amber-600 transition-colors shadow-sm"
|
||||
title={isEn ? "Report this chart" : "反馈此图表"}
|
||||
>
|
||||
<Bug size={13} />
|
||||
</button>
|
||||
)}
|
||||
{onMaximize && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -899,10 +1211,12 @@ export function LiveTemperatureThresholdChart({
|
||||
metarHighLabel={metarHighLabel}
|
||||
isShenzhen={isShenzhen}
|
||||
displayRunwayTemp={displayRunwayTemp}
|
||||
displayMetarTemp={displayMetarTemp}
|
||||
observedHighMetar={observedHighMetar}
|
||||
observedHighRunway={observedHighRunway}
|
||||
wundergroundDailyHigh={wundergroundDailyHigh}
|
||||
debVal={debVal}
|
||||
debQuality={chartHourly?.debQuality || null}
|
||||
modelMin={modelMin}
|
||||
modelMax={modelMax}
|
||||
spread={spread}
|
||||
@@ -919,6 +1233,18 @@ export function LiveTemperatureThresholdChart({
|
||||
<ModelCurvesSummary isEn={isEn} activeSeries={activeSeries} tempSymbol={row?.temp_symbol || "°C"} />
|
||||
)}
|
||||
|
||||
{timeframe === "1D" && !compact && (
|
||||
<SourceCadenceStrip isEn={isEn} summary={sourceCadenceSummary} />
|
||||
)}
|
||||
|
||||
{timeframe === "1D" && !compact && (
|
||||
<AdvancedWeatherVariablesStrip
|
||||
key={city || "advanced-weather"}
|
||||
isEn={isEn}
|
||||
items={advancedWeatherVariables}
|
||||
/>
|
||||
)}
|
||||
|
||||
<TemperatureChartCanvas
|
||||
isEn={isEn}
|
||||
compact={compact}
|
||||
@@ -936,6 +1262,7 @@ export function LiveTemperatureThresholdChart({
|
||||
isHourlyLoading={isHourlyLoading}
|
||||
detailError={detailError}
|
||||
showingStaleDetail={showingStaleDetail}
|
||||
showDetailErrorBadge={showDetailErrorBadge}
|
||||
refAreaLeft={refAreaLeft}
|
||||
refAreaRight={refAreaRight}
|
||||
onMouseDown={handleMouseDown}
|
||||
@@ -973,4 +1300,7 @@ export const __getInitialDetailLoadDelayMsForTest = getInitialDetailLoadDelayMs;
|
||||
export const __shouldFetchCityDetailForChartForTest = shouldFetchCityDetailForChart;
|
||||
export const __shouldPollLiveChartForTest = shouldPollLiveChart;
|
||||
export const __mergePatchIntoHourlyForTest = mergePatchIntoHourly;
|
||||
export const __selectCompactSecondaryTempForTest = selectCompactSecondaryTemp;
|
||||
export const __selectDisplayRunwayTempForTest = selectDisplayRunwayTemp;
|
||||
export const __buildAdvancedWeatherVariableItemsForTest = buildAdvancedWeatherVariableItems;
|
||||
export const __buildSourceCadenceSummaryForTest = buildSourceCadenceSummary;
|
||||
|
||||
@@ -15,7 +15,7 @@ export function MobileRegionTabs({
|
||||
onSelectTab: (key: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="mobile-region-tabs flex overflow-x-auto border-b border-slate-200 bg-white px-2">
|
||||
<div className="mobile-region-tabs flex min-h-11 snap-x gap-1 overflow-x-auto border-b border-slate-200 bg-white px-2 [-webkit-overflow-scrolling:touch] [scrollbar-width:none]">
|
||||
{groups.map((g) => {
|
||||
const label = isEn ? g.labelEn : g.labelZh;
|
||||
const isActive = activeTab === g.key;
|
||||
@@ -23,9 +23,10 @@ export function MobileRegionTabs({
|
||||
<button
|
||||
key={g.key}
|
||||
type="button"
|
||||
aria-pressed={isActive}
|
||||
onClick={() => onSelectTab(g.key)}
|
||||
className={clsx(
|
||||
"shrink-0 px-3 py-2.5 text-xs font-bold whitespace-nowrap border-b-2 transition-colors",
|
||||
"flex min-h-11 shrink-0 snap-start items-center border-b-2 px-3 text-xs font-bold whitespace-nowrap transition-colors",
|
||||
isActive
|
||||
? "border-blue-600 text-blue-700"
|
||||
: "border-transparent text-slate-500 hover:text-slate-700"
|
||||
|
||||
@@ -41,7 +41,7 @@ function hasDrawableTemperatureChartContent({
|
||||
probabilityOverlay: ProbabilityOverlay | null;
|
||||
zoomedData: Array<Record<string, any>>;
|
||||
}) {
|
||||
if (probabilityOverlay?.muLine || probabilityOverlay?.bands.length) return true;
|
||||
void probabilityOverlay;
|
||||
return activeSeries.some((series) =>
|
||||
zoomedData.some((point, index) => {
|
||||
const value = point?.[series.key] ?? series.values[index];
|
||||
@@ -112,6 +112,7 @@ function TemperatureChartCanvasComponent({
|
||||
isHourlyLoading,
|
||||
detailError,
|
||||
showingStaleDetail,
|
||||
showDetailErrorBadge = true,
|
||||
refAreaLeft,
|
||||
refAreaRight,
|
||||
onMouseDown,
|
||||
@@ -139,6 +140,7 @@ function TemperatureChartCanvasComponent({
|
||||
isHourlyLoading: boolean;
|
||||
detailError?: string | null;
|
||||
showingStaleDetail?: boolean;
|
||||
showDetailErrorBadge?: boolean;
|
||||
refAreaLeft: number | null;
|
||||
refAreaRight: number | null;
|
||||
onMouseDown: (event: any) => void;
|
||||
@@ -223,7 +225,7 @@ function TemperatureChartCanvasComponent({
|
||||
const shouldShowBackgroundRefresh = isHourlyLoading && hasDrawableChartContent;
|
||||
const shouldShowUnavailableState = Boolean(row?.city) && Boolean(detailError) && !isHourlyLoading && !hasDrawableChartContent;
|
||||
const shouldShowBackgroundError =
|
||||
Boolean(row?.city) && Boolean(detailError) && !isHourlyLoading && hasDrawableChartContent;
|
||||
showDetailErrorBadge && Boolean(row?.city) && Boolean(detailError) && !isHourlyLoading && hasDrawableChartContent;
|
||||
|
||||
return (
|
||||
<div className={clsx("relative flex flex-1 flex-col p-2", compact ? "min-h-[120px]" : "min-h-[240px]")}>
|
||||
@@ -261,29 +263,6 @@ function TemperatureChartCanvasComponent({
|
||||
</label>
|
||||
)}
|
||||
|
||||
{probabilityOverlay && (
|
||||
<span
|
||||
className={clsx(
|
||||
"inline-flex items-center gap-1.5 rounded border border-violet-200 bg-violet-50 px-1.5 py-0.5 text-[10px] font-bold text-violet-700",
|
||||
canToggleRunwayDetails ? "" : "ml-auto",
|
||||
)}
|
||||
title={
|
||||
probabilityOverlay.muLine
|
||||
? probabilityOverlay.muLine.label
|
||||
: isEn
|
||||
? "Legacy Gaussian probability bands"
|
||||
: "Legacy 高斯概率温度带"
|
||||
}
|
||||
>
|
||||
<span className="h-2 w-2 rounded-full bg-violet-500/70" />
|
||||
<span>{isEn ? "Gaussian" : "高斯概率"}</span>
|
||||
{probabilityOverlay.muLine && (
|
||||
<span className="font-mono text-violet-600">
|
||||
μ {probabilityOverlay.muLine.value.toFixed(1)}{tempSymbol}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div ref={chartHostRef} className={clsx("relative flex-1", compact ? "min-h-[120px]" : "min-h-[220px]")}>
|
||||
{!shouldRenderChart && <TemperatureChartSkeleton compact={compact} />}
|
||||
@@ -316,16 +295,6 @@ function TemperatureChartCanvasComponent({
|
||||
domain={chartDomain}
|
||||
ticks={intDegreeTicks ?? undefined}
|
||||
/>
|
||||
{timeframe === "1D" && probabilityOverlay?.bands.map((band) => (
|
||||
<ReferenceArea
|
||||
key={band.key}
|
||||
y1={band.lower}
|
||||
y2={band.upper}
|
||||
strokeOpacity={0}
|
||||
fill="#8b5cf6"
|
||||
fillOpacity={band.opacity}
|
||||
/>
|
||||
))}
|
||||
{timeframe === "1D" && cityThresholds.map((t, idx) => {
|
||||
const isSelected = row && (Number(row.target_threshold ?? row.target_value) === t.threshold);
|
||||
const labelText = isEn
|
||||
@@ -348,20 +317,6 @@ function TemperatureChartCanvasComponent({
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{timeframe === "1D" && probabilityOverlay?.muLine && (
|
||||
<ReferenceLine
|
||||
y={probabilityOverlay.muLine.value}
|
||||
stroke="#7c3aed"
|
||||
strokeDasharray="2 3"
|
||||
strokeWidth={1.4}
|
||||
label={{
|
||||
value: compact ? undefined : probabilityOverlay.muLine.label,
|
||||
fill: "#7c3aed",
|
||||
fontSize: 9,
|
||||
position: "insideTopLeft",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Tooltip
|
||||
filterNull={false}
|
||||
cursor={{ stroke: "#94a3b8", strokeWidth: 1 }}
|
||||
@@ -378,7 +333,9 @@ function TemperatureChartCanvasComponent({
|
||||
payload={props.payload as ReadonlyArray<{ payload?: Record<string, any> }> | undefined}
|
||||
data={zoomedData}
|
||||
series={activeSeries}
|
||||
probabilityOverlay={probabilityOverlay}
|
||||
tempSymbol={tempSymbol}
|
||||
isEn={isEn}
|
||||
/>
|
||||
)}
|
||||
formatter={(value: unknown) => {
|
||||
|
||||
@@ -12,6 +12,7 @@ const OBSERVATION_LABEL_EN: Record<string, string> = {
|
||||
"CWA (10分钟)": "CWA (10m)",
|
||||
"气象站实测": "Weather Station Live",
|
||||
"跑道实测 (1分钟)": "Runway Live (1m)",
|
||||
"跑道实测 (3分钟)": "Runway Live (3m)",
|
||||
"机场报文": "Airport METAR",
|
||||
"METAR 结算 (30分钟)": "METAR Settlement (30m)",
|
||||
};
|
||||
@@ -37,6 +38,50 @@ function highLabel(label: string, isEn: boolean) {
|
||||
return isEn ? (HIGH_LABEL_EN[label] || label) : label;
|
||||
}
|
||||
|
||||
type DebQuality = {
|
||||
quality_tier?: string | null;
|
||||
recommendation?: string | null;
|
||||
recent_hit_rate?: number | null;
|
||||
recent_samples?: number | null;
|
||||
};
|
||||
|
||||
function debQualityLabel(quality: DebQuality | null | undefined, isEn: boolean) {
|
||||
const recommendation = quality?.recommendation;
|
||||
if (recommendation === "primary") return isEn ? "Primary" : "主用";
|
||||
if (recommendation === "supporting") return isEn ? "Support" : "辅助";
|
||||
if (recommendation === "context_only") return isEn ? "Context" : "参考";
|
||||
if (recommendation === "insufficient") return isEn ? "Thin" : "样本少";
|
||||
return "";
|
||||
}
|
||||
|
||||
function debQualityClass(quality: DebQuality | null | undefined) {
|
||||
const tier = quality?.quality_tier;
|
||||
if (tier === "high") return "border-emerald-200 bg-emerald-50 text-emerald-700";
|
||||
if (tier === "medium") return "border-amber-200 bg-amber-50 text-amber-700";
|
||||
if (tier === "low") return "border-rose-200 bg-rose-50 text-rose-700";
|
||||
return "border-slate-200 bg-slate-50 text-slate-500";
|
||||
}
|
||||
|
||||
function DebQualityBadge({ quality, isEn }: { quality?: DebQuality | null; isEn: boolean }) {
|
||||
const label = debQualityLabel(quality, isEn);
|
||||
if (!label) return null;
|
||||
const hitRate = quality?.recent_hit_rate;
|
||||
const samples = quality?.recent_samples;
|
||||
const titleParts = [
|
||||
isEn ? `DEB recommendation: ${label}` : `DEB 建议:${label}`,
|
||||
hitRate == null ? null : `${hitRate.toFixed(0)}%`,
|
||||
samples == null ? null : `n=${samples}`,
|
||||
].filter(Boolean);
|
||||
return (
|
||||
<span
|
||||
className={clsx("ml-1.5 inline-flex items-center rounded border px-1.5 py-0.5 text-[9px] font-black uppercase leading-none", debQualityClass(quality))}
|
||||
title={titleParts.join(" · ")}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function buildStatsLabels({
|
||||
isEn,
|
||||
isShenzhen,
|
||||
@@ -76,10 +121,12 @@ export function TemperatureStatsBars({
|
||||
metarHighLabel,
|
||||
isShenzhen,
|
||||
displayRunwayTemp,
|
||||
displayMetarTemp,
|
||||
observedHighMetar,
|
||||
observedHighRunway,
|
||||
wundergroundDailyHigh,
|
||||
debVal,
|
||||
debQuality,
|
||||
modelMin,
|
||||
modelMax,
|
||||
spread,
|
||||
@@ -97,10 +144,12 @@ export function TemperatureStatsBars({
|
||||
metarHighLabel: string;
|
||||
isShenzhen: boolean;
|
||||
displayRunwayTemp: number | null;
|
||||
displayMetarTemp: number | null;
|
||||
observedHighMetar: number | null;
|
||||
observedHighRunway: number | null;
|
||||
wundergroundDailyHigh: number | null;
|
||||
debVal: number | null;
|
||||
debQuality?: DebQuality | null;
|
||||
modelMin: number | null;
|
||||
modelMax: number | null;
|
||||
spread: number | null;
|
||||
@@ -129,13 +178,14 @@ export function TemperatureStatsBars({
|
||||
<span className="text-slate-300">|</span>
|
||||
<span className="font-semibold text-slate-500">
|
||||
{labels.compactSecondary}:{" "}
|
||||
<strong className="text-blue-600 font-mono">{temp(observedHighMetar, tempSymbol)}</strong>
|
||||
<strong className="text-blue-600 font-mono">{temp(displayMetarTemp, tempSymbol)}</strong>
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-4 text-[11px]">
|
||||
<span className="font-semibold text-slate-500">
|
||||
DEB: <strong className="text-orange-600 font-mono">{temp(debVal, tempSymbol)}</strong>
|
||||
<DebQualityBadge quality={debQuality} isEn={isEn} />
|
||||
</span>
|
||||
{modelMin !== null && modelMax !== null && (
|
||||
<>
|
||||
@@ -184,6 +234,7 @@ export function TemperatureStatsBars({
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[11px] font-semibold text-slate-500 uppercase tracking-wider">
|
||||
DEB Max
|
||||
<DebQualityBadge quality={debQuality} isEn={isEn} />
|
||||
</span>
|
||||
<span className="text-2xl font-bold font-mono text-orange-600 mt-1">
|
||||
{temp(debVal, tempSymbol)}
|
||||
@@ -234,6 +285,7 @@ export function TemperatureStatsBars({
|
||||
</span>
|
||||
<strong className="text-blue-600 font-bold">
|
||||
{temp(debVal, tempSymbol)}
|
||||
<DebQualityBadge quality={debQuality} isEn={isEn} />
|
||||
</strong>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
@@ -260,3 +312,4 @@ export function TemperatureStatsBars({
|
||||
}
|
||||
|
||||
export const __buildTemperatureStatsLabelsForTest = buildStatsLabels;
|
||||
export const __buildDebQualityLabelForTest = debQualityLabel;
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { validNumber, type EvidenceSeries } from "@/components/dashboard/scan-terminal/temperature-chart-logic";
|
||||
import {
|
||||
validNumber,
|
||||
type EvidenceSeries,
|
||||
type ProbabilityOverlay,
|
||||
} from "@/components/dashboard/scan-terminal/temperature-chart-logic";
|
||||
|
||||
type TooltipSeries = Pick<EvidenceSeries, "key" | "label" | "color">;
|
||||
type TooltipRow = TooltipSeries & { value: number };
|
||||
type TooltipProbabilityRow = { key: string; label: string; value: string; color: string };
|
||||
|
||||
function isRunwayTooltipSeries(seriesKey: string) {
|
||||
return seriesKey.startsWith("runway_");
|
||||
@@ -36,38 +41,66 @@ export function TemperatureTooltipContent({
|
||||
payload,
|
||||
data,
|
||||
series,
|
||||
probabilityOverlay,
|
||||
tempSymbol = "°C",
|
||||
isEn = false,
|
||||
}: {
|
||||
active?: boolean;
|
||||
label?: string | number;
|
||||
payload?: ReadonlyArray<{ payload?: Record<string, any> }>;
|
||||
data: Array<Record<string, any>>;
|
||||
series: TooltipSeries[];
|
||||
probabilityOverlay?: ProbabilityOverlay | null;
|
||||
tempSymbol?: string;
|
||||
isEn?: boolean;
|
||||
}) {
|
||||
if (!active || !payload?.length || !series.length) return null;
|
||||
const activePoint = payload[0]?.payload || {};
|
||||
const rows = buildTooltipRows(activePoint, data, series);
|
||||
if (!rows.length) return null;
|
||||
if (!active) return null;
|
||||
const activePoint = payload?.[0]?.payload || findTooltipPointByLabel(data, label) || {};
|
||||
const rows = series.length ? buildTooltipRows(activePoint, data, series) : [];
|
||||
const probabilityRows = buildTooltipProbabilityRows(probabilityOverlay, tempSymbol, isEn);
|
||||
if (!rows.length && !probabilityRows.length) return null;
|
||||
|
||||
return (
|
||||
<div className="rounded border border-slate-200 bg-white px-2.5 py-2 text-[11px] shadow-lg">
|
||||
<div className="mb-1 font-mono text-slate-500">{label}</div>
|
||||
<div className="grid gap-1">
|
||||
{rows.slice(0, 8).map((item) => (
|
||||
<div key={item.key} className="flex min-w-[140px] items-center justify-between gap-4">
|
||||
<span className="inline-flex items-center gap-1.5 text-slate-700">
|
||||
<span className="h-2 w-2 rounded-full" style={{ backgroundColor: item.color }} />
|
||||
<span className="font-semibold">{item.label}</span>
|
||||
</span>
|
||||
<strong className="font-mono text-slate-900">{item.value.toFixed(2)}{tempSymbol}</strong>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{rows.length > 0 && (
|
||||
<div className="grid gap-1">
|
||||
{rows.slice(0, 8).map((item) => (
|
||||
<div key={item.key} className="flex min-w-[140px] items-center justify-between gap-4">
|
||||
<span className="inline-flex items-center gap-1.5 text-slate-700">
|
||||
<span className="h-2 w-2 rounded-full" style={{ backgroundColor: item.color }} />
|
||||
<span className="font-semibold">{item.label}</span>
|
||||
</span>
|
||||
<strong className="font-mono text-slate-900">{item.value.toFixed(2)}{tempSymbol}</strong>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{probabilityRows.length > 0 && (
|
||||
<div className={rows.length > 0 ? "mt-1.5 grid max-h-[260px] gap-1 overflow-y-auto border-t border-slate-100 pt-1.5 pr-1" : "grid max-h-[260px] gap-1 overflow-y-auto pr-1"}>
|
||||
{probabilityRows.map((item) => (
|
||||
<div key={item.key} className="flex min-w-[160px] items-center justify-between gap-4">
|
||||
<span className="inline-flex items-center gap-1.5 text-violet-700">
|
||||
<span className="h-2 w-2 rounded-full" style={{ backgroundColor: item.color }} />
|
||||
<span className="font-semibold">{item.label}</span>
|
||||
</span>
|
||||
<strong className="font-mono text-violet-900">{item.value}</strong>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function findTooltipPointByLabel(
|
||||
data: Array<Record<string, any>>,
|
||||
label?: string | number,
|
||||
) {
|
||||
if (label === undefined || label === null) return null;
|
||||
return data.find((point) => String(point.label) === String(label)) || null;
|
||||
}
|
||||
|
||||
function buildTooltipRows(
|
||||
activePoint: Record<string, any>,
|
||||
data: Array<Record<string, any>>,
|
||||
@@ -85,4 +118,51 @@ function buildTooltipRows(
|
||||
.filter((item): item is TooltipRow => item !== null);
|
||||
}
|
||||
|
||||
function formatProbabilityRangeValue(value: number) {
|
||||
return Number(value.toFixed(1)).toString();
|
||||
}
|
||||
|
||||
function formatProbabilityRangeLabel(
|
||||
lower: number,
|
||||
upper: number,
|
||||
tempSymbol: string,
|
||||
) {
|
||||
return `${formatProbabilityRangeValue(lower)}-${formatProbabilityRangeValue(upper)}${tempSymbol}`;
|
||||
}
|
||||
|
||||
function buildTooltipProbabilityRows(
|
||||
probabilityOverlay: ProbabilityOverlay | null | undefined,
|
||||
tempSymbol: string,
|
||||
isEn: boolean,
|
||||
): TooltipProbabilityRow[] {
|
||||
if (!probabilityOverlay) return [];
|
||||
const rows: TooltipProbabilityRow[] = [];
|
||||
const mu = validNumber(probabilityOverlay.muLine?.value);
|
||||
if (mu !== null) {
|
||||
rows.push({
|
||||
key: "legacy_probability_mu",
|
||||
label: isEn ? "Gaussian μ" : "高斯 μ",
|
||||
value: `${mu.toFixed(1)}${tempSymbol}`,
|
||||
color: "#8b5cf6",
|
||||
});
|
||||
}
|
||||
|
||||
rows.push(
|
||||
...[...probabilityOverlay.bands]
|
||||
.filter((band) => validNumber(band.probability) !== null && band.probability > 0)
|
||||
.sort((a, b) => a.lower - b.lower)
|
||||
.map((band) => {
|
||||
const probabilityPct = Math.round(band.probability * 100);
|
||||
return {
|
||||
key: band.key,
|
||||
label: formatProbabilityRangeLabel(band.lower, band.upper, tempSymbol),
|
||||
value: `${probabilityPct}%`,
|
||||
color: "#a78bfa",
|
||||
};
|
||||
}),
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
export const __buildTemperatureTooltipRowsForTest = buildTooltipRows;
|
||||
export const __buildTemperatureTooltipProbabilityRowsForTest = buildTooltipProbabilityRows;
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import { TrendingUp, Target, Thermometer, Hash, BarChart3, Crosshair } from "lucide-react";
|
||||
import { buildDebRecentRankingRows } from "@/lib/deb-training-ranking";
|
||||
|
||||
type MetricPayload = {
|
||||
hit_rate: number;
|
||||
@@ -19,13 +20,79 @@ type MetricPayload = {
|
||||
brier_score?: number;
|
||||
};
|
||||
|
||||
type DebWindowSummary = {
|
||||
start_date?: string | null;
|
||||
end_date?: string | null;
|
||||
samples?: number;
|
||||
hits?: number;
|
||||
hit_rate?: number | null;
|
||||
mae?: number | null;
|
||||
bias?: number | null;
|
||||
city_count?: number;
|
||||
};
|
||||
|
||||
type DebHistoricalSummary = {
|
||||
city_count?: number;
|
||||
avg_hit_rate?: number | null;
|
||||
weighted_hit_rate?: number | null;
|
||||
avg_mae?: number | null;
|
||||
avg_days_per_city?: number;
|
||||
sample_days?: number;
|
||||
hits?: number;
|
||||
};
|
||||
|
||||
type DebUsableRecentSummary = {
|
||||
window?: "recent_7d" | "recent_14d" | string;
|
||||
city_count?: number;
|
||||
samples?: number;
|
||||
hits?: number;
|
||||
hit_rate?: number | null;
|
||||
avg_mae?: number | null;
|
||||
recommendations?: {
|
||||
primary?: number;
|
||||
supporting?: number;
|
||||
};
|
||||
};
|
||||
|
||||
type DebVersionSummary = {
|
||||
version?: string;
|
||||
samples?: number;
|
||||
mae?: number | null;
|
||||
rmse?: number | null;
|
||||
bias?: number | null;
|
||||
bucket_hit_rate?: number | null;
|
||||
};
|
||||
|
||||
type DebSummaryPayload = {
|
||||
historical?: DebHistoricalSummary;
|
||||
usable_recent?: DebUsableRecentSummary;
|
||||
recent_7d?: DebWindowSummary;
|
||||
recent_14d?: DebWindowSummary;
|
||||
versions?: Record<string, DebVersionSummary>;
|
||||
};
|
||||
|
||||
type DebRecentStrategy = {
|
||||
recent_7d?: DebWindowSummary;
|
||||
recent_14d?: DebWindowSummary;
|
||||
trust_tier?: "high" | "medium" | "low" | "insufficient" | string;
|
||||
recommendation?: "primary" | "supporting" | "context_only" | "insufficient" | string;
|
||||
bias_direction?: "under" | "over" | "neutral" | "unknown" | string;
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
type TrainingCity = {
|
||||
city_id: string;
|
||||
name: string;
|
||||
deb?: MetricPayload;
|
||||
deb_recent?: DebRecentStrategy | null;
|
||||
mu?: MetricPayload;
|
||||
};
|
||||
|
||||
type TrainingAccuracyPayload = {
|
||||
accuracy: TrainingCity[];
|
||||
deb_summary?: DebSummaryPayload;
|
||||
};
|
||||
|
||||
const STAT_CARD_CLASSES: Record<string, string> = {
|
||||
blue: "bg-blue-50 border-blue-200",
|
||||
emerald: "bg-emerald-50 border-emerald-200",
|
||||
@@ -45,48 +112,78 @@ function barColor(hr: number) {
|
||||
return "#dc2626";
|
||||
}
|
||||
|
||||
function trustBadgeClass(tier?: string) {
|
||||
if (tier === "high") return "border-emerald-200 bg-emerald-50 text-emerald-700";
|
||||
if (tier === "medium") return "border-amber-200 bg-amber-50 text-amber-700";
|
||||
if (tier === "low") return "border-rose-200 bg-rose-50 text-rose-700";
|
||||
return "border-slate-200 bg-slate-50 text-slate-500";
|
||||
}
|
||||
|
||||
function trustLabel(tier: string | undefined, isEn: boolean) {
|
||||
if (tier === "high") return isEn ? "High" : "高";
|
||||
if (tier === "medium") return isEn ? "Medium" : "中";
|
||||
if (tier === "low") return isEn ? "Low" : "低";
|
||||
return isEn ? "Thin" : "少";
|
||||
}
|
||||
|
||||
function recommendationLabel(value: string | undefined, isEn: boolean) {
|
||||
if (value === "primary") return isEn ? "Primary" : "主用";
|
||||
if (value === "supporting") return isEn ? "Support" : "辅助";
|
||||
if (value === "context_only") return isEn ? "Context" : "参考";
|
||||
return isEn ? "Insufficient" : "样本少";
|
||||
}
|
||||
|
||||
const TRAINING_CACHE_KEY = "polyweather_training_accuracy_v1";
|
||||
const TRAINING_CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||
|
||||
function readTrainingCache(): TrainingCity[] | null {
|
||||
function readTrainingCache(): TrainingAccuracyPayload | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(TRAINING_CACHE_KEY);
|
||||
if (!raw) return null;
|
||||
const cached = JSON.parse(raw);
|
||||
if (cached.ts && Date.now() - cached.ts < TRAINING_CACHE_TTL_MS && Array.isArray(cached.data)) {
|
||||
return cached.data;
|
||||
if (cached.ts && Date.now() - cached.ts < TRAINING_CACHE_TTL_MS) {
|
||||
if (Array.isArray(cached.data)) return { accuracy: cached.data };
|
||||
if (cached.data && Array.isArray(cached.data.accuracy)) return cached.data;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return null;
|
||||
}
|
||||
|
||||
function writeTrainingCache(data: TrainingCity[]) {
|
||||
function writeTrainingCache(data: TrainingAccuracyPayload) {
|
||||
try {
|
||||
localStorage.setItem(TRAINING_CACHE_KEY, JSON.stringify({ ts: Date.now(), data }));
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
export function TrainingDashboard({ isEn }: { isEn: boolean }) {
|
||||
const [data, setData] = useState<TrainingCity[] | null>(() => readTrainingCache());
|
||||
const [payload, setPayload] = useState<TrainingAccuracyPayload | null>(() => readTrainingCache());
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
fetch("/api/ops/training/accuracy", { cache: "no-store", headers: { Accept: "application/json" } })
|
||||
.then(async (res) => {
|
||||
if (!res.ok) return null;
|
||||
return res.json() as Promise<{ accuracy: TrainingCity[] }>;
|
||||
return res.json() as Promise<TrainingAccuracyPayload>;
|
||||
})
|
||||
.then((payload) => {
|
||||
if (cancelled || !payload?.accuracy) return;
|
||||
const filtered = payload.accuracy.filter((c) => (c.deb || c.mu) && ((c.deb?.total_days ?? 0) + (c.mu?.total_days ?? 0)) >= 5);
|
||||
setData(filtered);
|
||||
writeTrainingCache(filtered);
|
||||
.then((nextPayload) => {
|
||||
if (cancelled || !nextPayload?.accuracy) return;
|
||||
const filtered = nextPayload.accuracy.filter((c) => (c.deb || c.mu) && ((c.deb?.total_days ?? 0) + (c.mu?.total_days ?? 0)) >= 5);
|
||||
const next = { ...nextPayload, accuracy: filtered };
|
||||
setPayload(next);
|
||||
writeTrainingCache(next);
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
const data = payload?.accuracy ?? null;
|
||||
const debSummary = payload?.deb_summary;
|
||||
const debSorted = useMemo(() => (data || []).filter((c) => c.deb).sort((a, b) => (b.deb?.hit_rate ?? 0) - (a.deb?.hit_rate ?? 0)), [data]);
|
||||
const debRecentRanked = useMemo(() => buildDebRecentRankingRows(data || []), [data]);
|
||||
const debRecentRankIndex = useMemo(
|
||||
() => new Map(debRecentRanked.map((row, index) => [row.cityId, index])),
|
||||
[debRecentRanked],
|
||||
);
|
||||
const muSorted = useMemo(() => (data || []).filter((c) => c.mu).sort((a, b) => (b.mu?.hit_rate ?? 0) - (a.mu?.hit_rate ?? 0)), [data]);
|
||||
|
||||
const debStats = useMemo(() => {
|
||||
@@ -94,8 +191,16 @@ export function TrainingDashboard({ isEn }: { isEn: boolean }) {
|
||||
const avgHit = debSorted.reduce((s, c) => s + (c.deb?.hit_rate ?? 0), 0) / debSorted.length;
|
||||
const avgMae = debSorted.reduce((s, c) => s + (c.deb?.mae ?? 0), 0) / debSorted.length;
|
||||
const avgDays = Math.round(debSorted.reduce((s, c) => s + (c.deb?.total_days ?? 0), 0) / Math.max(debSorted.length, 1));
|
||||
return { avgHit, avgMae, avgDays, cities: debSorted.length };
|
||||
}, [debSorted]);
|
||||
return {
|
||||
avgHit: debSummary?.historical?.avg_hit_rate ?? avgHit,
|
||||
avgMae: debSummary?.historical?.avg_mae ?? avgMae,
|
||||
avgDays: debSummary?.historical?.avg_days_per_city ?? avgDays,
|
||||
cities: debSummary?.historical?.city_count ?? debSorted.length,
|
||||
sampleDays: debSummary?.historical?.sample_days,
|
||||
weightedHit: debSummary?.historical?.weighted_hit_rate,
|
||||
usableRecent: debSummary?.usable_recent,
|
||||
};
|
||||
}, [debSorted, debSummary]);
|
||||
|
||||
const muStats = useMemo(() => {
|
||||
if (!muSorted.length) return null;
|
||||
@@ -107,12 +212,19 @@ export function TrainingDashboard({ isEn }: { isEn: boolean }) {
|
||||
}, [muSorted]);
|
||||
|
||||
const debHitChart = useMemo(
|
||||
() => debSorted.slice(0, 18).map((c) => ({ name: c.name, value: Number((c.deb?.hit_rate ?? 0).toFixed(1)) })),
|
||||
[debSorted],
|
||||
() => debRecentRanked.slice(0, 18).map((c) => ({ name: c.name, value: c.hitRate })),
|
||||
[debRecentRanked],
|
||||
);
|
||||
const debMaeChart = useMemo(
|
||||
() => [...debSorted].sort((a, b) => (a.deb?.mae ?? 99) - (b.deb?.mae ?? 99)).slice(0, 18).map((c) => ({ name: c.name, value: Number((c.deb?.mae ?? 0).toFixed(2)) })),
|
||||
[debSorted],
|
||||
() => [...debRecentRanked]
|
||||
.sort((a, b) => {
|
||||
if (a.usableScore !== b.usableScore) return b.usableScore - a.usableScore;
|
||||
if (a.trustScore !== b.trustScore) return b.trustScore - a.trustScore;
|
||||
return a.mae - b.mae;
|
||||
})
|
||||
.slice(0, 18)
|
||||
.map((c) => ({ name: c.name, value: c.mae })),
|
||||
[debRecentRanked],
|
||||
);
|
||||
const muHitChart = useMemo(
|
||||
() => muSorted.slice(0, 18).map((c) => ({ name: c.name, value: Number((c.mu?.hit_rate ?? 0).toFixed(1)) })),
|
||||
@@ -122,6 +234,22 @@ export function TrainingDashboard({ isEn }: { isEn: boolean }) {
|
||||
() => [...muSorted].sort((a, b) => (a.mu?.brier_score ?? 99) - (b.mu?.brier_score ?? 99)).slice(0, 18).map((c) => ({ name: c.name, value: Number((c.mu?.brier_score ?? 0).toFixed(3)) })),
|
||||
[muSorted],
|
||||
);
|
||||
const debVersionRows = useMemo(() => {
|
||||
const versions = debSummary?.versions || {};
|
||||
return [
|
||||
{ key: "deb_v1_raw", label: isEn ? "Raw DEB" : "原始 DEB" },
|
||||
{ key: "deb_v1_recent_bias_corrected", label: isEn ? "Mean Bias" : "均值偏差" },
|
||||
{ key: "deb_v2_bucket_calibrated", label: isEn ? "Bucket v2" : "桶校准 v2" },
|
||||
{ key: "deb_v3_guarded_calibrated", label: isEn ? "Guarded v3" : "保护 v3" },
|
||||
].map(({ key, label }) => ({ key, label, value: versions[key] })).filter((row) => row.value);
|
||||
}, [debSummary?.versions, isEn]);
|
||||
|
||||
const formatPct = (value: number | null | undefined) => value == null ? "--" : `${value.toFixed(1)}%`;
|
||||
const formatMaybeDeg = (value: number | null | undefined, digits = 1) => value == null ? "--" : `${value.toFixed(digits)}°`;
|
||||
const usableWindowLabel = (window?: string) => {
|
||||
if (window === "recent_14d") return isEn ? "Usable 14d" : "可用近14天";
|
||||
return isEn ? "Usable 7d" : "可用近7天";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto bg-[#f5f7fa]">
|
||||
@@ -143,12 +271,14 @@ export function TrainingDashboard({ isEn }: { isEn: boolean }) {
|
||||
<Thermometer size={14} className="text-amber-600" />
|
||||
{isEn ? "DEB Temperature Forecast" : "DEB 气温预报"}
|
||||
</h2>
|
||||
<div className="grid grid-cols-4 gap-2 mb-3">
|
||||
<div className="grid grid-cols-2 gap-2 mb-3 md:grid-cols-3 xl:grid-cols-6">
|
||||
{[
|
||||
{ icon: Hash, label: isEn ? "Cities" : "城市数", value: debStats.cities, tone: "blue" },
|
||||
{ icon: Target, label: isEn ? "Avg Hit" : "平均命中", value: `${debStats.avgHit.toFixed(1)}%`, tone: "emerald" },
|
||||
{ icon: Target, label: usableWindowLabel(debStats.usableRecent?.window), value: formatPct(debStats.usableRecent?.hit_rate), tone: "emerald" },
|
||||
{ icon: TrendingUp, label: isEn ? "Recent 7d" : "近7天", value: formatPct(debSummary?.recent_7d?.hit_rate), tone: "emerald" },
|
||||
{ icon: TrendingUp, label: isEn ? "Recent 14d" : "近14天", value: formatPct(debSummary?.recent_14d?.hit_rate), tone: "purple" },
|
||||
{ icon: Thermometer, label: isEn ? "Avg Error" : "平均误差", value: `${debStats.avgMae.toFixed(1)}°`, tone: "amber" },
|
||||
{ icon: TrendingUp, label: isEn ? "Avg Days/City" : "每城平均天数", value: debStats.avgDays.toLocaleString(), tone: "purple" },
|
||||
{ icon: Hash, label: isEn ? "Samples" : "样本天数", value: (debStats.sampleDays ?? debStats.avgDays).toLocaleString(), tone: "blue" },
|
||||
].map(({ icon: Icon, label, value, tone }) => (
|
||||
<div key={label} className={`flex items-center gap-3 rounded-lg border ${STAT_CARD_CLASSES[tone]} p-3`}>
|
||||
<Icon size={20} className={STAT_ICON_CLASSES[tone]} />
|
||||
@@ -159,8 +289,27 @@ export function TrainingDashboard({ isEn }: { isEn: boolean }) {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{debVersionRows.length ? (
|
||||
<div className="mb-3 grid grid-cols-1 gap-2 md:grid-cols-3">
|
||||
{debVersionRows.map((row) => {
|
||||
const bucketRate = row.value?.bucket_hit_rate == null ? null : row.value.bucket_hit_rate * 100;
|
||||
return (
|
||||
<div key={row.key} className="rounded-lg border border-slate-200 bg-white px-3 py-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-[11px] font-black uppercase text-slate-500">{row.label}</span>
|
||||
<span className="font-mono text-sm font-black text-slate-900">{formatPct(bucketRate)}</span>
|
||||
</div>
|
||||
<div className="mt-1 flex items-center justify-between text-[11px] text-slate-500">
|
||||
<span>{isEn ? "MAE" : "误差"} {formatMaybeDeg(row.value?.mae, 2)}</span>
|
||||
<span>{isEn ? "Samples" : "样本"} {row.value?.samples ?? 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="grid grid-cols-2 gap-3 mb-4">
|
||||
<ChartCard title={isEn ? "Forecast Hit Rate by City" : "预报命中率 by 城市"}>
|
||||
<ChartCard title={isEn ? "Usable Recent Hit Rate by City" : "可用近期命中率 by 城市"}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={debHitChart} layout="vertical" margin={{ top: 0, right: 16, left: 48, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" horizontal={false} />
|
||||
@@ -171,7 +320,7 @@ export function TrainingDashboard({ isEn }: { isEn: boolean }) {
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</ChartCard>
|
||||
<ChartCard title={isEn ? "Forecast Error by City (lower = better)" : "预报误差 by 城市(越低越好)"}>
|
||||
<ChartCard title={isEn ? "Usable Recent Error by City (lower = better)" : "可用近期误差 by 城市(越低越好)"}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={debMaeChart} layout="vertical" margin={{ top: 0, right: 16, left: 48, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" horizontal={false} />
|
||||
@@ -237,12 +386,15 @@ export function TrainingDashboard({ isEn }: { isEn: boolean }) {
|
||||
)}
|
||||
|
||||
{/* ── Combined Table ── */}
|
||||
<div className="rounded-lg border border-slate-200 bg-white overflow-hidden">
|
||||
<table className="w-full text-[12px] border-collapse">
|
||||
<div className="overflow-x-auto rounded-lg border border-slate-200 bg-white">
|
||||
<table className="min-w-[960px] w-full text-[12px] border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-200 bg-[#f8f9fa] text-left">
|
||||
<th className="w-10 px-3 py-2 text-center text-[11px] font-black text-slate-400">#</th>
|
||||
<th className="px-3 py-2 text-[11px] font-black uppercase text-slate-500">{isEn ? "City" : "城市"}</th>
|
||||
<th className="px-2 py-2 text-left text-[11px] font-black uppercase text-slate-500">{isEn ? "DEB Trust" : "DEB 信任"}</th>
|
||||
<th className="px-2 py-2 text-right text-[11px] font-black uppercase text-slate-500">{isEn ? "7d" : "7天"}</th>
|
||||
<th className="px-2 py-2 text-right text-[11px] font-black uppercase text-slate-500">{isEn ? "14d" : "14天"}</th>
|
||||
<th className="px-2 py-2 text-right text-[11px] font-black uppercase text-slate-500">{isEn ? "DEB Hit" : "DEB 命中"}</th>
|
||||
<th className="px-2 py-2 text-right text-[11px] font-black uppercase text-slate-500">{isEn ? "DEB Error" : "DEB 误差"}</th>
|
||||
<th className="px-2 py-2 text-right text-[11px] font-black uppercase text-slate-500">{isEn ? "μ Hit" : "μ 命中"}</th>
|
||||
@@ -253,28 +405,47 @@ export function TrainingDashboard({ isEn }: { isEn: boolean }) {
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{debSorted.length || muSorted.length ? (
|
||||
(() => {
|
||||
const cities = new Map<string, { deb?: MetricPayload; mu?: MetricPayload; name: string }>();
|
||||
for (const c of debSorted) cities.set(c.city_id, { deb: c.deb, mu: c.mu, name: c.name });
|
||||
const cities = new Map<string, { deb?: MetricPayload; debRecent?: DebRecentStrategy | null; mu?: MetricPayload; name: string }>();
|
||||
for (const c of debSorted) cities.set(c.city_id, { deb: c.deb, debRecent: c.deb_recent, mu: c.mu, name: c.name });
|
||||
for (const c of muSorted) {
|
||||
const existing = cities.get(c.city_id);
|
||||
if (existing) existing.mu = c.mu;
|
||||
else cities.set(c.city_id, { deb: c.deb, mu: c.mu, name: c.name });
|
||||
else cities.set(c.city_id, { deb: c.deb, debRecent: c.deb_recent, mu: c.mu, name: c.name });
|
||||
}
|
||||
const merged = [...cities.entries()]
|
||||
.sort((a, b) => {
|
||||
const aDebRank = debRecentRankIndex.get(a[0]) ?? 9999;
|
||||
const bDebRank = debRecentRankIndex.get(b[0]) ?? 9999;
|
||||
if (aDebRank !== bDebRank) return aDebRank - bDebRank;
|
||||
const aMax = Math.max(a[1].deb?.hit_rate ?? 0, a[1].mu?.hit_rate ?? 0);
|
||||
const bMax = Math.max(b[1].deb?.hit_rate ?? 0, b[1].mu?.hit_rate ?? 0);
|
||||
return bMax - aMax;
|
||||
})
|
||||
.slice(0, 30);
|
||||
return merged.map(([cityId, { deb, mu, name }], i) => {
|
||||
return merged.map(([cityId, { deb, debRecent, mu, name }], i) => {
|
||||
const debHit = deb?.hit_rate ?? 0;
|
||||
const muHit = mu?.hit_rate ?? 0;
|
||||
const brier = mu?.brier_score;
|
||||
const recent7 = debRecent?.recent_7d?.hit_rate;
|
||||
const recent14 = debRecent?.recent_14d?.hit_rate;
|
||||
return (
|
||||
<tr key={cityId} className="hover:bg-slate-50 transition-colors">
|
||||
<td className="px-3 py-2 text-center text-[12px] font-mono text-slate-400">{i + 1}</td>
|
||||
<td className="px-3 py-2 font-semibold text-slate-800 capitalize">{name}</td>
|
||||
<td className="px-2 py-2">
|
||||
{debRecent ? (
|
||||
<span
|
||||
className={`inline-flex min-w-[4rem] items-center justify-center rounded border px-2 py-0.5 text-[10px] font-black uppercase ${trustBadgeClass(debRecent.trust_tier)}`}
|
||||
title={debRecent.reason}
|
||||
>
|
||||
{trustLabel(debRecent.trust_tier, isEn)} · {recommendationLabel(debRecent.recommendation, isEn)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-slate-400">--</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-right font-mono text-slate-600">{recent7 == null ? "--" : `${recent7.toFixed(0)}%`}</td>
|
||||
<td className="px-2 py-2 text-right font-mono text-slate-600">{recent14 == null ? "--" : `${recent14.toFixed(0)}%`}</td>
|
||||
<td className="px-2 py-2 text-right font-mono font-bold" style={{ color: barColor(debHit) }}>
|
||||
{deb ? `${debHit.toFixed(0)}%` : "--"}
|
||||
</td>
|
||||
@@ -290,7 +461,7 @@ export function TrainingDashboard({ isEn }: { isEn: boolean }) {
|
||||
})()
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-4 py-12 text-center text-slate-400">
|
||||
<td colSpan={10} className="px-4 py-12 text-center text-slate-400">
|
||||
{data === null ? (isEn ? "Loading..." : "加载中...") : (isEn ? "No training data" : "暂无训练数据")}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
"use client";
|
||||
|
||||
import { Megaphone, X } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
type AnnouncementText = {
|
||||
title: string;
|
||||
body: string;
|
||||
};
|
||||
|
||||
type StaticUpdateAnnouncement = {
|
||||
id: string;
|
||||
publishedAt: string;
|
||||
expiresAt: string;
|
||||
zh: AnnouncementText;
|
||||
en: AnnouncementText;
|
||||
};
|
||||
|
||||
type UpdateAnnouncementButtonProps = {
|
||||
isEn: boolean;
|
||||
};
|
||||
|
||||
const STATIC_UPDATE_ANNOUNCEMENTS: StaticUpdateAnnouncement[] = [
|
||||
{
|
||||
id: "feedback-status-2026-06",
|
||||
publishedAt: "2026-06-07T00:00:00+08:00",
|
||||
expiresAt: "2026-07-15T00:00:00+08:00",
|
||||
zh: {
|
||||
title: "更新公告:终端新增公告与反馈状态",
|
||||
body:
|
||||
"PolyWeather 天气决策台新增“更新公告”入口,后续产品更新、数据源调整和重要说明会在这里同步。\n\n" +
|
||||
"用户反馈系统也已升级:提交反馈时会自动附带相关图表上下文,用户可以在终端右上角通知入口和账户页查看自己反馈的处理状态,包括已收到、已确认、处理中、已解决和已关闭。\n\n" +
|
||||
"我们也在考虑对真实、可复现、有建设性价值的反馈加入积分或 Pro 天数激励。",
|
||||
},
|
||||
en: {
|
||||
title: "Update: announcements and feedback status are now live",
|
||||
body:
|
||||
"PolyWeather Terminal now has an update announcement entry. Future product updates, data-source changes, and important notes will be shared here.\n\n" +
|
||||
"The feedback system has also been upgraded. When users submit feedback, PolyWeather automatically attaches the relevant chart context. Users can now track their own feedback status from the terminal notification entry and the account page: received, confirmed, in progress, resolved, or closed.\n\n" +
|
||||
"We are also considering small rewards such as points or Pro days for real, reproducible, and constructive feedback.",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
function isActiveAnnouncement(item: StaticUpdateAnnouncement, now = Date.now()) {
|
||||
const expiresAt = Date.parse(item.expiresAt);
|
||||
if (!Number.isFinite(expiresAt) || expiresAt <= now) return false;
|
||||
const publishedAt = Date.parse(item.publishedAt);
|
||||
return !Number.isFinite(publishedAt) || publishedAt <= now;
|
||||
}
|
||||
|
||||
function pickAnnouncementText(payload: StaticUpdateAnnouncement, isEn: boolean) {
|
||||
const primary = isEn ? payload.en : payload.zh;
|
||||
const fallback = isEn ? payload.zh : payload.en;
|
||||
return {
|
||||
title: String(primary?.title || fallback?.title || "").trim(),
|
||||
body: String(primary?.body || fallback?.body || "").trim(),
|
||||
};
|
||||
}
|
||||
|
||||
function formatUpdatedAt(value: string | undefined, isEn: boolean) {
|
||||
if (!value) return "";
|
||||
const date = new Date(value);
|
||||
if (!Number.isFinite(date.getTime())) return "";
|
||||
return date.toLocaleString(isEn ? "en-US" : "zh-CN", {
|
||||
hour12: false,
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
export function UpdateAnnouncementButton({ isEn }: UpdateAnnouncementButtonProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const shellRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
if (!shellRef.current?.contains(event.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener("pointerdown", handlePointerDown);
|
||||
return () => window.removeEventListener("pointerdown", handlePointerDown);
|
||||
}, [open]);
|
||||
|
||||
const announcement = useMemo(
|
||||
() => STATIC_UPDATE_ANNOUNCEMENTS.find((item) => isActiveAnnouncement(item)) ?? null,
|
||||
[],
|
||||
);
|
||||
const text = useMemo(
|
||||
() => (announcement ? pickAnnouncementText(announcement, isEn) : { title: "", body: "" }),
|
||||
[announcement, isEn],
|
||||
);
|
||||
const updatedAt = useMemo(
|
||||
() => formatUpdatedAt(announcement?.publishedAt, isEn),
|
||||
[announcement?.publishedAt, isEn],
|
||||
);
|
||||
|
||||
if (!announcement || (!text.title && !text.body)) return null;
|
||||
|
||||
return (
|
||||
<div ref={shellRef} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
className="inline-flex h-7 items-center gap-1.5 rounded border border-blue-200 bg-blue-50 px-2 text-[10px] font-bold uppercase tracking-wide text-blue-700 transition-colors hover:border-blue-300 hover:bg-blue-100"
|
||||
title={isEn ? "Update announcement" : "更新公告"}
|
||||
aria-expanded={open}
|
||||
>
|
||||
<Megaphone size={12} />
|
||||
{isEn ? "Updates" : "更新公告"}
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute left-0 top-8 z-50 w-[min(360px,calc(100vw-32px))] rounded-md border border-slate-200 bg-white p-3 text-left shadow-lg">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="grid h-7 w-7 shrink-0 place-items-center rounded border border-blue-100 bg-blue-50 text-blue-600">
|
||||
<Megaphone size={14} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-xs font-bold leading-5 text-slate-900">
|
||||
{text.title || (isEn ? "PolyWeather update" : "PolyWeather 更新")}
|
||||
</div>
|
||||
{updatedAt && (
|
||||
<div className="mt-0.5 font-mono text-[10px] text-slate-400">
|
||||
{isEn ? "Updated" : "更新"} {updatedAt}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(false)}
|
||||
className="grid h-6 w-6 shrink-0 place-items-center rounded border border-slate-200 text-slate-400 hover:bg-slate-50 hover:text-slate-700"
|
||||
title={isEn ? "Close" : "关闭"}
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
{text.body && (
|
||||
<p className="mt-3 whitespace-pre-line text-[12px] font-medium leading-5 text-slate-600">
|
||||
{text.body}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -38,8 +38,8 @@ const quickStart: Record<"zh" | "en", GuideCopy[]> = {
|
||||
body: "橙色 DEB Forecast 是融合模型和日内修正后的路径,用来判断后续升温或降温空间。",
|
||||
},
|
||||
{
|
||||
title: "最后看概率",
|
||||
body: "紫色区域和虚线表示高概率温度带,适合判断当前实测是否偏离主预期。",
|
||||
title: "最后看图层",
|
||||
body: "按需打开模型线或跑道明细,用来确认当前实测是否偏离 DEB 主路径。",
|
||||
},
|
||||
],
|
||||
en: [
|
||||
@@ -56,8 +56,8 @@ const quickStart: Record<"zh" | "en", GuideCopy[]> = {
|
||||
body: "The orange DEB Forecast blends model context with intraday correction to frame the remaining move.",
|
||||
},
|
||||
{
|
||||
title: "Check probability",
|
||||
body: "The purple band and dotted line show the high-probability temperature zone for fast deviation checks.",
|
||||
title: "Check layers",
|
||||
body: "Use model lines or runway details only when needed to confirm whether live observations are drifting away from the DEB path.",
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -66,7 +66,7 @@ const legendItems: Record<"zh" | "en", GuideCopy[]> = {
|
||||
zh: [
|
||||
{ title: "实测 / 结算线", body: "优先展示结算跑道、官方站或城市核心实况,用于判断已兑现温度。" },
|
||||
{ title: "DEB Forecast", body: "橙色预测路径,重点看它和实测线在峰值窗口前后的分歧。" },
|
||||
{ title: "高概率带", body: "紫色带表示当前概率分布的主要落点,虚线是概率均值附近。" },
|
||||
{ title: "市场概率", body: "概率仍用于市场判断和后台分析,但默认不再占用温度主图空间。" },
|
||||
{ title: "机场报文", body: "METAR / MGM 作为机场站参考,默认只在适合的城市自动显示。" },
|
||||
{ title: "模型线", body: "ECMWF、GFS、ICON、GEM 等提供背景,默认弱化为辅助判断。" },
|
||||
{ title: "跑道明细", body: "打开后可查看各跑道传感器,关闭后仍保留结算跑道温度。" },
|
||||
@@ -74,7 +74,7 @@ const legendItems: Record<"zh" | "en", GuideCopy[]> = {
|
||||
en: [
|
||||
{ title: "Live / settlement", body: "Settlement runway, official station, or core live observation used as the realized anchor." },
|
||||
{ title: "DEB Forecast", body: "Orange forecast path; focus on its gap versus live observations near the peak window." },
|
||||
{ title: "Probability band", body: "Purple band marks the main probability zone, with the dotted line near the probability mean." },
|
||||
{ title: "Market probability", body: "Probability remains available for market analysis, but no longer occupies the main temperature chart by default." },
|
||||
{ title: "Airport reports", body: "METAR / MGM are airport references and are auto-shown only where they are useful by default." },
|
||||
{ title: "Model lines", body: "ECMWF, GFS, ICON, GEM, and related model layers provide background context." },
|
||||
{ title: "Runway details", body: "When disabled, the chart still keeps the settlement runway temperature visible." },
|
||||
@@ -160,8 +160,8 @@ export function UsageGuideDashboard({ isEn }: { isEn: boolean }) {
|
||||
</h1>
|
||||
<p className="mt-2 max-w-3xl text-sm leading-6 text-slate-500">
|
||||
{isEn
|
||||
? "Start from live evidence, compare DEB, then use probability and layer toggles to confirm whether the city is moving away from the main path."
|
||||
: "先看实况锚点,再对照 DEB 路径,最后用概率带和图层显隐确认城市是否偏离主预期。"}
|
||||
? "Start from live evidence, compare DEB, then use layer toggles to confirm whether the city is moving away from the main path."
|
||||
: "先看实况锚点,再对照 DEB 路径,最后用图层显隐确认城市是否偏离主预期。"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 text-center text-[11px] font-bold text-slate-600">
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import clsx from "clsx";
|
||||
import { Bug, CheckCircle2, Lightbulb, MessageSquare, WalletCards, X } from "lucide-react";
|
||||
import {
|
||||
getAnalyticsClientId,
|
||||
getAnalyticsSessionId,
|
||||
} from "@/lib/app-analytics";
|
||||
import {
|
||||
getSupabaseBrowserClient,
|
||||
hasSupabasePublicEnv,
|
||||
} from "@/lib/supabase/client";
|
||||
import type { UserFeedbackEntry } from "@/types/ops";
|
||||
|
||||
export type FeedbackCategory = "bug" | "data" | "idea" | "payment" | "account" | "other";
|
||||
|
||||
export type FeedbackDraft = {
|
||||
category?: FeedbackCategory;
|
||||
source?: string;
|
||||
context?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const CATEGORY_OPTIONS: Array<{
|
||||
key: FeedbackCategory;
|
||||
labelZh: string;
|
||||
labelEn: string;
|
||||
Icon: typeof Bug;
|
||||
}> = [
|
||||
{ key: "bug", labelZh: "Bug", labelEn: "Bug", Icon: Bug },
|
||||
{ key: "data", labelZh: "数据问题", labelEn: "Data issue", Icon: MessageSquare },
|
||||
{ key: "idea", labelZh: "功能建议", labelEn: "Suggestion", Icon: Lightbulb },
|
||||
{ key: "payment", labelZh: "支付问题", labelEn: "Payment", Icon: WalletCards },
|
||||
];
|
||||
|
||||
function buildRuntimeContext(extra: Record<string, unknown>) {
|
||||
if (typeof window === "undefined") return extra;
|
||||
return {
|
||||
...extra,
|
||||
client_id: getAnalyticsClientId() || "",
|
||||
session_id: getAnalyticsSessionId() || "",
|
||||
path: window.location.pathname,
|
||||
href: window.location.href,
|
||||
language: navigator.language || "",
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || "",
|
||||
user_agent: navigator.userAgent || "",
|
||||
viewport: `${window.innerWidth || 0}x${window.innerHeight || 0}`,
|
||||
captured_at: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export function UserFeedbackModal({
|
||||
draft,
|
||||
isEn,
|
||||
onClose,
|
||||
onSubmitted,
|
||||
}: {
|
||||
draft: FeedbackDraft | null;
|
||||
isEn: boolean;
|
||||
onClose: () => void;
|
||||
onSubmitted?: (entry?: UserFeedbackEntry) => void;
|
||||
}) {
|
||||
const open = Boolean(draft);
|
||||
const [category, setCategory] = useState<FeedbackCategory>("bug");
|
||||
const [message, setMessage] = useState("");
|
||||
const [contact, setContact] = useState("");
|
||||
const [loginEmailContact, setLoginEmailContact] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setCategory(draft?.category || "bug");
|
||||
setMessage("");
|
||||
setContact("");
|
||||
setLoginEmailContact("");
|
||||
setError("");
|
||||
setSubmitted(false);
|
||||
}, [open, draft?.category]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !hasSupabasePublicEnv()) return;
|
||||
let cancelled = false;
|
||||
|
||||
const loadLoginEmail = async () => {
|
||||
try {
|
||||
const {
|
||||
data: { session },
|
||||
} = await getSupabaseBrowserClient().auth.getSession();
|
||||
const email = String(session?.user?.email || "").trim();
|
||||
if (cancelled || !email) return;
|
||||
setLoginEmailContact(email);
|
||||
setContact(email);
|
||||
} catch {
|
||||
// Feedback can still be submitted; the backend will attach auth email when present.
|
||||
}
|
||||
};
|
||||
|
||||
void loadLoginEmail();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const contextPreview = useMemo(() => {
|
||||
const context = draft?.context || {};
|
||||
const city = String(context.city || context.display_city || "").trim();
|
||||
const source = String(draft?.source || context.source || "terminal").trim();
|
||||
if (city) {
|
||||
return isEn ? `Context: ${city} · ${source}` : `上下文:${city} · ${source}`;
|
||||
}
|
||||
return isEn ? `Context: ${source}` : `上下文:${source}`;
|
||||
}, [draft, isEn]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const canSubmit = message.trim().length >= 3 && !submitting;
|
||||
|
||||
const submit = async () => {
|
||||
if (!canSubmit) return;
|
||||
setSubmitting(true);
|
||||
setError("");
|
||||
try {
|
||||
const res = await fetch("/api/feedback", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
category,
|
||||
message: message.trim(),
|
||||
contact: contact.trim() || undefined,
|
||||
source: draft?.source || "terminal",
|
||||
context: buildRuntimeContext(draft?.context || {}),
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const detail = (await res.text().catch(() => "")).slice(0, 200);
|
||||
throw new Error(detail || `HTTP ${res.status}`);
|
||||
}
|
||||
const payload = (await res.json().catch(() => null)) as {
|
||||
feedback?: UserFeedbackEntry;
|
||||
} | null;
|
||||
setSubmitted(true);
|
||||
onSubmitted?.(payload?.feedback);
|
||||
} catch (err) {
|
||||
setError(String(err).slice(0, 220));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[80] flex items-center justify-center bg-slate-950/30 px-4 backdrop-blur-[2px]">
|
||||
<div className="w-full max-w-lg overflow-hidden rounded-lg border border-slate-200 bg-white shadow-2xl">
|
||||
<div className="flex items-center justify-between border-b border-slate-200 px-4 py-3">
|
||||
<div>
|
||||
<div className="text-sm font-black text-slate-950">
|
||||
{isEn ? "Send feedback" : "提交反馈"}
|
||||
</div>
|
||||
<div className="mt-0.5 text-xs text-slate-500">{contextPreview}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="grid h-8 w-8 place-items-center rounded border border-slate-200 text-slate-500 transition hover:bg-slate-50 hover:text-slate-900"
|
||||
aria-label={isEn ? "Close feedback" : "关闭反馈"}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{submitted ? (
|
||||
<div className="px-5 py-8 text-center">
|
||||
<CheckCircle2 className="mx-auto h-8 w-8 text-emerald-500" />
|
||||
<div className="mt-3 text-sm font-black text-slate-950">
|
||||
{isEn ? "Feedback received" : "反馈已收到"}
|
||||
</div>
|
||||
<p className="mx-auto mt-2 max-w-sm text-xs leading-5 text-slate-500">
|
||||
{isEn
|
||||
? "We saved the report with the current terminal context. The bell icon will show handling updates."
|
||||
: "已附带当前终端上下文保存。右上角铃铛会显示后续处理进度。"}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="mt-5 rounded bg-blue-600 px-4 py-2 text-xs font-black text-white transition hover:bg-blue-700"
|
||||
>
|
||||
{isEn ? "Done" : "完成"}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4 p-5">
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||
{CATEGORY_OPTIONS.map(({ key, labelZh, labelEn, Icon }) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={() => setCategory(key)}
|
||||
className={clsx(
|
||||
"flex h-11 items-center justify-center gap-2 rounded border px-2 text-xs font-bold transition",
|
||||
category === key
|
||||
? "border-blue-300 bg-blue-50 text-blue-700"
|
||||
: "border-slate-200 bg-white text-slate-600 hover:bg-slate-50",
|
||||
)}
|
||||
>
|
||||
<Icon size={14} />
|
||||
<span>{isEn ? labelEn : labelZh}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<label className="block">
|
||||
<span className="text-xs font-bold text-slate-600">
|
||||
{isEn ? "What happened?" : "问题或建议"}
|
||||
</span>
|
||||
<textarea
|
||||
value={message}
|
||||
onChange={(event) => setMessage(event.target.value)}
|
||||
rows={5}
|
||||
className="mt-1 w-full resize-none rounded border border-slate-200 bg-white px-3 py-2 text-sm text-slate-900 outline-none transition focus:border-blue-400 focus:ring-2 focus:ring-blue-100"
|
||||
placeholder={
|
||||
isEn
|
||||
? "Example: This chart keeps loading after I switch to Helsinki."
|
||||
: "例如:切到 Helsinki 后图表一直加载。"
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<span className="text-xs font-bold text-slate-600">
|
||||
{loginEmailContact
|
||||
? isEn ? "Contact (login email)" : "联系方式(登录邮箱)"
|
||||
: isEn ? "Contact, optional" : "联系方式,可选"}
|
||||
</span>
|
||||
<input
|
||||
value={contact}
|
||||
onChange={(event) => {
|
||||
if (!loginEmailContact) setContact(event.target.value);
|
||||
}}
|
||||
readOnly={Boolean(loginEmailContact)}
|
||||
className={clsx(
|
||||
"mt-1 h-9 w-full rounded border border-slate-200 px-3 text-sm text-slate-900 outline-none transition focus:border-blue-400 focus:ring-2 focus:ring-blue-100",
|
||||
loginEmailContact ? "bg-slate-50 text-slate-600" : "bg-white",
|
||||
)}
|
||||
placeholder={isEn ? "Email or Telegram" : "邮箱或 Telegram"}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="rounded border border-slate-200 bg-slate-50 px-3 py-2 text-[11px] leading-4 text-slate-500">
|
||||
{isEn
|
||||
? "Terminal context is attached automatically: city, slot, source state, browser and session diagnostics."
|
||||
: "会自动附带终端上下文:城市、槽位、数据源状态、浏览器和会话诊断信息。"}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700">
|
||||
{isEn ? "Submit failed: " : "提交失败:"}{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="h-9 rounded border border-slate-200 px-4 text-xs font-bold text-slate-600 transition hover:bg-slate-50"
|
||||
>
|
||||
{isEn ? "Cancel" : "取消"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={submit}
|
||||
disabled={!canSubmit}
|
||||
className="h-9 rounded bg-blue-600 px-4 text-xs font-black text-white transition hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{submitting ? (isEn ? "Sending..." : "提交中...") : (isEn ? "Send" : "提交")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Bell, RefreshCcw } from "lucide-react";
|
||||
import type { UserFeedbackEntry, UserFeedbackPayload } from "@/types/ops";
|
||||
import {
|
||||
buildFeedbackNotificationKey,
|
||||
countUnseenFeedbackUpdates,
|
||||
FEEDBACK_STATUS_CACHE_TTL_MS,
|
||||
FEEDBACK_STATUS_POLL_MS,
|
||||
feedbackStatusLabel,
|
||||
feedbackStatusTone,
|
||||
} from "./feedback-status";
|
||||
|
||||
const FEEDBACK_STATUS_SEEN_KEY = "polyweather_feedback_status_seen_v1";
|
||||
const FEEDBACK_STATUS_CACHE_KEY = "polyweather_feedback_status_cache_v1";
|
||||
|
||||
function loadSeenKeys() {
|
||||
if (typeof window === "undefined") return new Set<string>();
|
||||
try {
|
||||
const raw = window.localStorage.getItem(FEEDBACK_STATUS_SEEN_KEY);
|
||||
const parsed = raw ? JSON.parse(raw) : [];
|
||||
return new Set(Array.isArray(parsed) ? parsed.map(String) : []);
|
||||
} catch {
|
||||
return new Set<string>();
|
||||
}
|
||||
}
|
||||
|
||||
function saveSeenKeys(keys: Set<string>) {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
const trimmed = Array.from(keys).slice(-200);
|
||||
window.localStorage.setItem(FEEDBACK_STATUS_SEEN_KEY, JSON.stringify(trimmed));
|
||||
} catch {
|
||||
// Ignore storage failures; the badge can reappear without breaking feedback status.
|
||||
}
|
||||
}
|
||||
|
||||
function readFeedbackStatusCache(): { entries: UserFeedbackEntry[]; ts: number } | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
try {
|
||||
const raw = window.localStorage.getItem(FEEDBACK_STATUS_CACHE_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw);
|
||||
const ts = Number(parsed?.ts || 0);
|
||||
const entries = Array.isArray(parsed?.entries) ? parsed.entries : [];
|
||||
if (!ts || Date.now() - ts > FEEDBACK_STATUS_CACHE_TTL_MS) return null;
|
||||
return { entries, ts };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeFeedbackStatusCache(entries: UserFeedbackEntry[]) {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.localStorage.setItem(
|
||||
FEEDBACK_STATUS_CACHE_KEY,
|
||||
JSON.stringify({ entries: entries.slice(0, 20), ts: Date.now() }),
|
||||
);
|
||||
} catch {
|
||||
// Ignore storage failures; status will still refresh over the network.
|
||||
}
|
||||
}
|
||||
|
||||
function compactDate(value?: string) {
|
||||
if (!value) return "";
|
||||
return value.slice(0, 16).replace("T", " ");
|
||||
}
|
||||
|
||||
function categoryLabel(value?: string, isEn = false) {
|
||||
const key = String(value || "").toLowerCase();
|
||||
if (key === "bug") return "Bug";
|
||||
if (key === "data") return isEn ? "Data" : "数据";
|
||||
if (key === "idea") return isEn ? "Suggestion" : "建议";
|
||||
if (key === "payment") return isEn ? "Payment" : "支付";
|
||||
if (key === "account") return isEn ? "Account" : "账号";
|
||||
return isEn ? "Other" : "其他";
|
||||
}
|
||||
|
||||
export function UserFeedbackStatusButton({
|
||||
isEn,
|
||||
refreshKey = 0,
|
||||
}: {
|
||||
isEn: boolean;
|
||||
refreshKey?: number;
|
||||
}) {
|
||||
const [available, setAvailable] = useState(true);
|
||||
const [entries, setEntries] = useState<UserFeedbackEntry[]>(() => readFeedbackStatusCache()?.entries || []);
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [seenKeys, setSeenKeys] = useState<Set<string>>(() => loadSeenKeys());
|
||||
const lastLoadedAtRef = useRef<number>(readFeedbackStatusCache()?.ts || 0);
|
||||
|
||||
const unseenCount = useMemo(
|
||||
() => countUnseenFeedbackUpdates(entries, seenKeys),
|
||||
[entries, seenKeys],
|
||||
);
|
||||
const emptyStateText = loading
|
||||
? isEn
|
||||
? "Loading..."
|
||||
: "加载中..."
|
||||
: isEn
|
||||
? "No submitted feedback yet."
|
||||
: "暂无已提交反馈。";
|
||||
|
||||
const load = useCallback(async (
|
||||
signal?: AbortSignal,
|
||||
options?: { force?: boolean },
|
||||
) => {
|
||||
if (typeof fetch !== "function") return;
|
||||
const cached = readFeedbackStatusCache();
|
||||
if (!options?.force && cached) {
|
||||
lastLoadedAtRef.current = cached.ts;
|
||||
setEntries(cached.entries);
|
||||
setAvailable(true);
|
||||
return;
|
||||
}
|
||||
const now = Date.now();
|
||||
if (
|
||||
!options?.force &&
|
||||
lastLoadedAtRef.current &&
|
||||
now - lastLoadedAtRef.current < FEEDBACK_STATUS_CACHE_TTL_MS
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/feedback?limit=12", {
|
||||
cache: "no-store",
|
||||
headers: { Accept: "application/json" },
|
||||
signal,
|
||||
});
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
setAvailable(false);
|
||||
setEntries([]);
|
||||
return;
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status}`);
|
||||
}
|
||||
const payload = (await res.json()) as UserFeedbackPayload;
|
||||
const nextEntries = Array.isArray(payload.feedback) ? payload.feedback : [];
|
||||
setEntries(nextEntries);
|
||||
writeFeedbackStatusCache(nextEntries);
|
||||
lastLoadedAtRef.current = Date.now();
|
||||
setAvailable(true);
|
||||
setError("");
|
||||
} catch (err) {
|
||||
if (signal?.aborted) return;
|
||||
setError(String(err).slice(0, 120));
|
||||
} finally {
|
||||
if (!signal?.aborted) setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const markVisibleAsSeen = useCallback((visibleEntries: UserFeedbackEntry[]) => {
|
||||
setSeenKeys((current) => {
|
||||
const next = new Set(current);
|
||||
visibleEntries.forEach((entry) => next.add(buildFeedbackNotificationKey(entry)));
|
||||
saveSeenKeys(next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
void load(controller.signal, { force: refreshKey > 0 });
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
if (
|
||||
document.visibilityState === "visible" &&
|
||||
Date.now() - lastLoadedAtRef.current >= FEEDBACK_STATUS_CACHE_TTL_MS
|
||||
) {
|
||||
void load();
|
||||
}
|
||||
};
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
const id = window.setInterval(() => {
|
||||
if (document.visibilityState === "visible") void load();
|
||||
}, FEEDBACK_STATUS_POLL_MS);
|
||||
|
||||
return () => {
|
||||
controller.abort();
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
window.clearInterval(id);
|
||||
};
|
||||
}, [load, refreshKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) markVisibleAsSeen(entries);
|
||||
}, [entries, markVisibleAsSeen, open]);
|
||||
|
||||
if (!available) return null;
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setOpen((value) => {
|
||||
const next = !value;
|
||||
if (next) markVisibleAsSeen(entries);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
className="relative grid h-7 w-7 place-items-center rounded-full border border-slate-300 bg-white text-slate-500 transition-colors hover:bg-slate-50 hover:text-slate-900"
|
||||
title={isEn ? "Feedback status" : "反馈处理状态"}
|
||||
aria-label={isEn ? "Feedback status" : "反馈处理状态"}
|
||||
>
|
||||
<Bell size={13} />
|
||||
{unseenCount > 0 && (
|
||||
<span className="absolute -right-1 -top-1 grid h-4 min-w-4 place-items-center rounded-full bg-blue-600 px-1 text-[9px] font-black leading-none text-white ring-2 ring-white">
|
||||
{unseenCount > 9 ? "9+" : unseenCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute right-0 top-9 z-[70] w-[340px] max-w-[calc(100vw-1rem)] overflow-hidden rounded-md border border-slate-200 bg-white text-slate-800 shadow-xl">
|
||||
<div className="flex items-center justify-between border-b border-slate-200 px-3 py-2">
|
||||
<div>
|
||||
<div className="text-xs font-black text-slate-950">
|
||||
{isEn ? "Feedback updates" : "反馈处理动态"}
|
||||
</div>
|
||||
<div className="mt-0.5 text-[11px] text-slate-500">
|
||||
{isEn ? "Your submitted reports only" : "仅显示你提交过的反馈"}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void load(undefined, { force: true })}
|
||||
disabled={loading}
|
||||
className="grid h-7 w-7 place-items-center rounded border border-slate-200 text-slate-500 transition hover:bg-slate-50 disabled:cursor-wait disabled:opacity-60"
|
||||
title={isEn ? "Refresh" : "刷新"}
|
||||
aria-label={isEn ? "Refresh feedback status" : "刷新反馈状态"}
|
||||
>
|
||||
<RefreshCcw size={13} className={loading ? "animate-spin" : ""} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="border-b border-amber-100 bg-amber-50 px-3 py-2 text-[11px] text-amber-700">
|
||||
{isEn ? "Status refresh failed: " : "状态刷新失败:"}{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{entries.length === 0 ? (
|
||||
<div className="px-3 py-6 text-center text-xs text-slate-500">
|
||||
{emptyStateText}
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-h-[360px] overflow-y-auto">
|
||||
{entries.map((entry) => (
|
||||
<div key={entry.id} className="border-b border-slate-100 px-3 py-2 last:border-b-0">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-xs font-bold text-slate-900">
|
||||
{entry.message || (isEn ? "Feedback" : "反馈")}
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1.5 text-[11px] text-slate-500">
|
||||
<span>{categoryLabel(entry.category, isEn)}</span>
|
||||
{entry.created_at && <span>{compactDate(entry.created_at)}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<span className={`shrink-0 rounded border px-1.5 py-0.5 text-[10px] font-black ${feedbackStatusTone(entry.status)}`}>
|
||||
{feedbackStatusLabel(entry.status, isEn)}
|
||||
</span>
|
||||
</div>
|
||||
{entry.updated_at && entry.updated_at !== entry.created_at && (
|
||||
<div className="mt-1 text-[11px] text-slate-500">
|
||||
{isEn ? "Updated " : "更新于 "}{compactDate(entry.updated_at)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
import * as Chart from "@/components/dashboard/scan-terminal/LiveTemperatureThresholdChart";
|
||||
|
||||
function assert(condition: unknown, message: string): asserts condition {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
export function runTests() {
|
||||
const buildItems = (Chart as any).__buildAdvancedWeatherVariableItemsForTest;
|
||||
assert(typeof buildItems === "function", "advanced weather variable item builder should be exported for tests");
|
||||
const buildCadence = (Chart as any).__buildSourceCadenceSummaryForTest;
|
||||
assert(typeof buildCadence === "function", "source cadence summary builder should be exported for tests");
|
||||
|
||||
const items = buildItems(
|
||||
{
|
||||
metar_context: {
|
||||
airport_wind_speed_kt: 7,
|
||||
airport_wind_dir: 220,
|
||||
airport_humidity: 68,
|
||||
},
|
||||
},
|
||||
{
|
||||
current: { dewpoint: 18.2 },
|
||||
airportPrimary: {
|
||||
wind_speed_kt: 9,
|
||||
wind_dir: 240,
|
||||
humidity: 64,
|
||||
pressure_hpa: 1009.4,
|
||||
source_label: "MADIS HFMETAR",
|
||||
},
|
||||
},
|
||||
false,
|
||||
);
|
||||
|
||||
assert(items.length === 5, `expected five advanced variable items, got ${items.length}`);
|
||||
assert(items.some((item: any) => item.key === "wind_dir" && item.value === "240°"), "wind direction should prefer airport-primary detail data");
|
||||
assert(items.some((item: any) => item.key === "wind_speed" && item.value === "9 kt"), "wind speed should include kt units");
|
||||
assert(items.some((item: any) => item.key === "dewpoint" && item.value === "18.2°C"), "dew point should render from current conditions");
|
||||
assert(items.some((item: any) => item.key === "humidity" && item.value === "64%"), "humidity should render as a percentage");
|
||||
assert(items.some((item: any) => item.key === "pressure" && item.value === "1009.4 hPa"), "pressure should render as hPa");
|
||||
|
||||
const emptyItems = buildItems({}, {}, true);
|
||||
assert(emptyItems.length === 0, "advanced variables should stay hidden when no source fields exist");
|
||||
|
||||
const backendCadence = buildCadence(
|
||||
{},
|
||||
{
|
||||
airportPrimary: {
|
||||
source_code: "custom_source",
|
||||
source_label: "Custom Feed",
|
||||
freshness: { native_update_interval_sec: 420, freshness_status: "fresh" },
|
||||
},
|
||||
},
|
||||
true,
|
||||
);
|
||||
assert(backendCadence?.cadence === "420s", "source cadence should prefer backend native_update_interval_sec");
|
||||
assert(backendCadence?.label.includes("Custom Feed"), "source cadence should include the source label");
|
||||
|
||||
const amscCadence = buildCadence({}, { airportPrimary: { source: "amsc_awos" } }, false);
|
||||
assert(amscCadence?.cadence === "180s", "AMSC AWOS should fall back to 180s source cadence");
|
||||
|
||||
const amosCadence = buildCadence({}, { airportPrimary: { source_label: "AMOS runway" } }, false);
|
||||
assert(amosCadence?.cadence === "60s", "AMOS should fall back to 60s source cadence");
|
||||
|
||||
const madisCadence = buildCadence({}, { airportPrimary: { source_code: "madis_hfmetar" } }, false);
|
||||
assert(madisCadence?.cadence === "300s", "MADIS should fall back to 300s source cadence");
|
||||
}
|
||||
@@ -34,4 +34,11 @@ export function runTests() {
|
||||
deployScript.includes("https://polyweather.top/api/auth/me?prefer_snapshot=1"),
|
||||
"deploy script must warm terminal and auth snapshot routes after container replacement",
|
||||
);
|
||||
assert(
|
||||
deployScript.includes("validate_frontend_api_base_url") &&
|
||||
deployScript.includes("POLYWEATHER_API_BASE_URL must not point at the frontend site") &&
|
||||
deployScript.indexOf("validate_frontend_api_base_url") <
|
||||
deployScript.indexOf('echo "Updating Redis dependency..."'),
|
||||
"deploy script must reject frontend POLYWEATHER_API_BASE_URL values that point at polyweather.top and would recurse through the frontend proxy",
|
||||
);
|
||||
}
|
||||
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import {
|
||||
buildFeedbackNotificationKey,
|
||||
countUnseenFeedbackUpdates,
|
||||
FEEDBACK_STATUS_CACHE_TTL_MS,
|
||||
FEEDBACK_STATUS_POLL_MS,
|
||||
feedbackStatusLabel,
|
||||
} from "@/components/dashboard/scan-terminal/feedback-status";
|
||||
|
||||
function assert(condition: unknown, message: string): asserts condition {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
export function runTests() {
|
||||
const projectRoot = process.cwd();
|
||||
const statusButtonSource = fs.readFileSync(
|
||||
path.join(projectRoot, "components", "dashboard", "scan-terminal", "UserFeedbackStatusButton.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
assert(FEEDBACK_STATUS_POLL_MS === 30 * 60 * 1000, "feedback bell background polling should run every 30 minutes");
|
||||
assert(FEEDBACK_STATUS_CACHE_TTL_MS === 10 * 60 * 1000, "feedback bell should reuse a 10 minute local status cache");
|
||||
assert(
|
||||
statusButtonSource.includes("FEEDBACK_STATUS_CACHE_KEY") &&
|
||||
statusButtonSource.includes("readFeedbackStatusCache") &&
|
||||
statusButtonSource.includes("writeFeedbackStatusCache") &&
|
||||
statusButtonSource.includes("lastLoadedAtRef") &&
|
||||
statusButtonSource.includes("FEEDBACK_STATUS_CACHE_TTL_MS"),
|
||||
"feedback status button must use a local cache and avoid refetching on every visibility resume",
|
||||
);
|
||||
assert(feedbackStatusLabel("open", false) === "已收到", "open feedback should read as received to users");
|
||||
assert(feedbackStatusLabel("triaged", false) === "已确认", "triaged feedback should read as confirmed to users");
|
||||
assert(feedbackStatusLabel("investigating", false) === "处理中", "investigating feedback should read as in progress");
|
||||
assert(feedbackStatusLabel("resolved", false) === "已解决", "resolved feedback should read as handled");
|
||||
assert(feedbackStatusLabel("closed", true) === "Closed", "closed feedback should have English copy");
|
||||
|
||||
const firstVersion = {
|
||||
id: 7,
|
||||
status: "open",
|
||||
updated_at: "2026-06-06T10:00:00",
|
||||
};
|
||||
const handledVersion = {
|
||||
...firstVersion,
|
||||
status: "resolved",
|
||||
updated_at: "2026-06-06T10:30:00",
|
||||
};
|
||||
const firstKey = buildFeedbackNotificationKey(firstVersion);
|
||||
const handledKey = buildFeedbackNotificationKey(handledVersion);
|
||||
|
||||
assert(firstKey !== handledKey, "status and updated_at changes should create a new notification key");
|
||||
assert(
|
||||
countUnseenFeedbackUpdates([firstVersion, handledVersion], new Set([firstKey])) === 1,
|
||||
"already seen feedback versions should not count, but later handled versions should",
|
||||
);
|
||||
assert(
|
||||
countUnseenFeedbackUpdates([firstVersion, handledVersion], new Set([firstKey, handledKey])) === 0,
|
||||
"opening the status panel should be able to clear all visible feedback update badges",
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
function assert(condition: unknown, message: string) {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
export function runTests() {
|
||||
const projectRoot = process.cwd();
|
||||
const dashboardSource = fs.readFileSync(
|
||||
path.join(projectRoot, "components", "dashboard", "ScanTerminalDashboard.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
const chartSource = fs.readFileSync(
|
||||
path.join(projectRoot, "components", "dashboard", "scan-terminal", "LiveTemperatureThresholdChart.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
const modalPath = path.join(projectRoot, "components", "dashboard", "scan-terminal", "UserFeedbackModal.tsx");
|
||||
const statusButtonPath = path.join(projectRoot, "components", "dashboard", "scan-terminal", "UserFeedbackStatusButton.tsx");
|
||||
const opsSidebarSource = fs.readFileSync(
|
||||
path.join(projectRoot, "components", "ops", "layout", "AdminSidebar.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
const opsApiSource = fs.readFileSync(path.join(projectRoot, "lib", "ops-api.ts"), "utf8");
|
||||
const feedbackRouteSource = fs.readFileSync(path.join(projectRoot, "app", "api", "feedback", "route.ts"), "utf8");
|
||||
|
||||
assert(fs.existsSync(modalPath), "terminal must ship a user feedback modal component");
|
||||
assert(fs.existsSync(statusButtonPath), "terminal must ship a user-facing feedback status notification component");
|
||||
const modalSource = fs.readFileSync(modalPath, "utf8");
|
||||
const statusButtonSource = fs.readFileSync(statusButtonPath, "utf8");
|
||||
const statusHelperSource = fs.readFileSync(
|
||||
path.join(projectRoot, "components", "dashboard", "scan-terminal", "feedback-status.ts"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
assert(
|
||||
dashboardSource.includes("onFeedbackClick") &&
|
||||
dashboardSource.includes("<UserFeedbackModal") &&
|
||||
dashboardSource.includes("setFeedbackDraft"),
|
||||
"terminal sidebar must expose a feedback entry that opens the shared modal",
|
||||
);
|
||||
assert(
|
||||
chartSource.includes("onReportIssue") &&
|
||||
chartSource.includes("Bug") &&
|
||||
chartSource.includes("detailError"),
|
||||
"each chart must expose a report-this-chart action with chart loading/error context",
|
||||
);
|
||||
assert(
|
||||
modalSource.includes("/api/feedback") &&
|
||||
modalSource.includes("getAnalyticsClientId") &&
|
||||
modalSource.includes("getSupabaseBrowserClient") &&
|
||||
modalSource.includes("hasSupabasePublicEnv") &&
|
||||
modalSource.includes(".auth.getSession()") &&
|
||||
modalSource.includes("readOnly={Boolean(loginEmailContact)}") &&
|
||||
modalSource.includes("onSubmitted") &&
|
||||
modalSource.includes("navigator.userAgent") &&
|
||||
modalSource.includes("type=\"textarea\"") === false,
|
||||
"feedback modal must submit to the feedback API, lock contact to the login email when available, notify the dashboard after success, and attach client/session diagnostics without using invalid textarea input types",
|
||||
);
|
||||
assert(
|
||||
feedbackRouteSource.includes("export async function GET") &&
|
||||
feedbackRouteSource.includes("method: \"GET\"") &&
|
||||
feedbackRouteSource.includes("limit"),
|
||||
"feedback API proxy must expose a GET endpoint for the current user's feedback status list",
|
||||
);
|
||||
assert(
|
||||
dashboardSource.includes("<UserFeedbackStatusButton") &&
|
||||
dashboardSource.includes("feedbackRefreshKey") &&
|
||||
dashboardSource.includes("setFeedbackRefreshKey"),
|
||||
"terminal header must expose a small feedback status notification icon and refresh it after submissions",
|
||||
);
|
||||
assert(
|
||||
statusButtonSource.includes("/api/feedback") &&
|
||||
statusButtonSource.includes("localStorage") &&
|
||||
statusButtonSource.includes("Bell") &&
|
||||
statusHelperSource.includes("triaged") &&
|
||||
statusHelperSource.includes("resolved"),
|
||||
"feedback status notification must poll the user feedback API, badge unseen status changes, and translate handled states",
|
||||
);
|
||||
assert(
|
||||
opsSidebarSource.includes("/ops/feedback") &&
|
||||
opsApiSource.includes("feedback(") &&
|
||||
opsApiSource.includes("updateFeedbackStatus"),
|
||||
"ops must expose a feedback inbox in navigation and API client",
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { getDocsPage } from "@/content/docs/docs";
|
||||
import { DOCS_PAGES } from "@/content/docs/docs";
|
||||
import { DOCS_GROUPS } from "@/content/docs/docs.config";
|
||||
|
||||
function assert(condition: unknown, message: string): asserts condition {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
function pageText(slug: string, locale: "zh-CN" | "en-US") {
|
||||
const page = getDocsPage(slug);
|
||||
assert(page, `${slug} docs page should exist`);
|
||||
return [
|
||||
page.content[locale].title,
|
||||
page.content[locale].description,
|
||||
...page.content[locale].sections.flatMap((section) => [
|
||||
section.title,
|
||||
...section.blocks.flatMap((block) => {
|
||||
if (block.type === "paragraph" || block.type === "callout") return [block.text];
|
||||
if (block.type === "bullets" || block.type === "steps") return block.items;
|
||||
if (block.type === "link") return [block.label, block.caption || ""];
|
||||
if (block.type === "image") return [block.alt, block.caption || ""];
|
||||
return [];
|
||||
}),
|
||||
]),
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function runTests() {
|
||||
const publicDocSlugs = DOCS_PAGES.map((page) => page.slug);
|
||||
assert(
|
||||
publicDocSlugs.join(",") === "intro,chart-guide,realtime-sources,settlement-sources,extension",
|
||||
"public docs navigation should only expose current shipped user-facing surfaces",
|
||||
);
|
||||
for (const group of DOCS_GROUPS) {
|
||||
assert(
|
||||
DOCS_PAGES.some((page) => page.group === group.id),
|
||||
`docs navigation group should not be empty: ${group.id}`,
|
||||
);
|
||||
}
|
||||
|
||||
const allDocsZh = publicDocSlugs.map((slug) => pageText(slug, "zh-CN")).join("\n");
|
||||
const allDocsEn = publicDocSlugs.map((slug) => pageText(slug, "en-US")).join("\n");
|
||||
for (const staleTerm of ["从地图进入", "机会榜", "日历", "EMOS", "LGBM", "城市决策卡", "付费判断台", "刷新锁"]) {
|
||||
assert(!allDocsZh.includes(staleTerm), `public Chinese docs should not expose stale term: ${staleTerm}`);
|
||||
}
|
||||
for (const staleTerm of ["map-launched", "opportunity board", "calendar", "EMOS", "LGBM", "city decision card", "paid decision workspace", "refresh lock"]) {
|
||||
assert(!allDocsEn.includes(staleTerm), `public English docs should not expose stale term: ${staleTerm}`);
|
||||
}
|
||||
|
||||
const introZh = pageText("intro", "zh-CN");
|
||||
assert(
|
||||
introZh.includes("结算源优先") && introZh.includes("天气决策台"),
|
||||
"intro should position PolyWeather as a settlement-source-first decision terminal",
|
||||
);
|
||||
assert(
|
||||
introZh.includes("1-9 个图表槽位") && introZh.includes("天气决策 / 训练数据 / 使用指南"),
|
||||
"intro should match the current terminal navigation and chart-slot workflow",
|
||||
);
|
||||
|
||||
const chartGuideZh = pageText("chart-guide", "zh-CN");
|
||||
assert(chartGuideZh.includes("如何读 PolyWeather 图表"), "chart guide title should be present");
|
||||
assert(chartGuideZh.includes("高级气象变量") && chartGuideZh.includes("默认隐藏"), "chart guide should explain advanced variables as hidden-by-default context");
|
||||
assert(chartGuideZh.includes("不要把概率温度带当成实测曲线"), "chart guide should warn against reading probability as observation");
|
||||
assert(chartGuideZh.includes("高温 / 全天"), "chart guide should document the current chart view modes");
|
||||
|
||||
const realtimeSourcesZh = pageText("realtime-sources", "zh-CN");
|
||||
assert(realtimeSourcesZh.includes("AMSC 180s"), "realtime sources should document the AMSC 180s cadence");
|
||||
assert(realtimeSourcesZh.includes("AMOS 60s"), "realtime sources should document the AMOS 60s cadence");
|
||||
assert(realtimeSourcesZh.includes("SSE patch"), "realtime sources should document the SSE patch path");
|
||||
|
||||
const settlementSourcesZh = pageText("settlement-sources", "zh-CN");
|
||||
assert(settlementSourcesZh.includes("结算站点"), "settlement source guide should exist");
|
||||
assert(settlementSourcesZh.includes("机场 METAR") && settlementSourcesZh.includes("官方结算站点"), "settlement source guide should distinguish airport and official station settlement");
|
||||
|
||||
assert(
|
||||
getDocsPage("chart-guide")?.group === "getting-started" &&
|
||||
getDocsPage("realtime-sources")?.group === "settlement" &&
|
||||
getDocsPage("settlement-sources")?.group === "settlement",
|
||||
"docs navigation should expose chart, realtime source, and settlement source guides in the right groups",
|
||||
);
|
||||
|
||||
const chartGuideEn = pageText("chart-guide", "en-US");
|
||||
assert(chartGuideEn.includes("How To Read PolyWeather Charts"), "English chart guide should exist");
|
||||
assert(chartGuideEn.includes("hidden by default"), "English chart guide should describe hidden-by-default advanced variables");
|
||||
assert(chartGuideEn.includes("Peak / All Day"), "English chart guide should document the current chart view modes");
|
||||
}
|
||||
@@ -69,8 +69,9 @@ export async function runTests() {
|
||||
assert(
|
||||
chartSource.includes("useLatestPatch") &&
|
||||
chartSource.includes("latestPatch") &&
|
||||
chartSource.includes("2 * 60_000"),
|
||||
"selected city chart should consume SSE patches and use a 2-minute no-patch fallback",
|
||||
chartSource.includes("DASHBOARD_REFRESH_POLICY_MS.metar") &&
|
||||
!chartSource.includes("2 * 60_000"),
|
||||
"selected city chart should consume SSE patches and use a METAR-cadence no-patch fallback instead of a 2-minute forced refresh",
|
||||
);
|
||||
assert(
|
||||
chartSource.includes("preloadTemperatureChartCanvas"),
|
||||
@@ -99,6 +100,16 @@ export async function runTests() {
|
||||
chartLogicSource.includes("recentlyRefreshed.data"),
|
||||
"forced chart detail refreshes should reuse a very recent full-detail payload instead of refetching repeatedly",
|
||||
);
|
||||
assert(
|
||||
chartLogicSource.includes("forceRefresh: boolean") &&
|
||||
chartLogicSource.includes('force_refresh: forceRefresh ? "true" : "false"'),
|
||||
"ignoreCache chart detail refreshes must send force_refresh=true so fresh METAR observations bypass proxy and backend chart caches",
|
||||
);
|
||||
assert(
|
||||
chartLogicSource.includes("cityDetailBatchQueueKey") &&
|
||||
chartLogicSource.includes('forceRefresh ? "force" : "cached"'),
|
||||
"forced and cached chart detail requests must use separate batch queues so a normal first-paint request cannot downgrade a live refresh",
|
||||
);
|
||||
assert(
|
||||
__shouldPollLiveChartForTest({ city: "shanghai", compact: true, isActive: false, isMaximized: false }) === true,
|
||||
"compact grid slots are visible charts and should run the no-patch fallback guard",
|
||||
@@ -148,7 +159,7 @@ export async function runTests() {
|
||||
);
|
||||
const fetchHourlyBlock = chartLogicSource.match(/async function fetchHourlyForecastForCity[\s\S]*?\r?\n}\r?\n\r?\nfunction shouldPollLiveChart/)?.[0] || "";
|
||||
assert(
|
||||
fetchHourlyBlock.includes("queueCityDetailBatch(city, resParam)") &&
|
||||
fetchHourlyBlock.includes("queueCityDetailBatch(city, resParam, forceRefresh)") &&
|
||||
!fetchHourlyBlock.includes("runQueuedHourlyDetailRequest"),
|
||||
"first-paint and background city detail refreshes should both enter the batch queue without falling back to single requests",
|
||||
);
|
||||
@@ -157,6 +168,12 @@ export async function runTests() {
|
||||
dashboardSource.includes('document.visibilityState === "hidden"'),
|
||||
"terminal online-user presence should refresh slowly and pause while the browser tab is hidden",
|
||||
);
|
||||
assert(
|
||||
dashboardSource.includes("AUTH_PROFILE_RETRY_INITIAL_DELAY_MS = 5_000") &&
|
||||
dashboardSource.includes("AUTH_PROFILE_RETRY_POLL_MS = 30_000") &&
|
||||
!dashboardSource.includes("}, 5000);"),
|
||||
"terminal auth subscription retry should back off after the first retry instead of polling auth/me every 5 seconds",
|
||||
);
|
||||
assert(
|
||||
chartSource.includes("IntersectionObserver") &&
|
||||
chartSource.includes("shouldFetchCityDetailForChart") &&
|
||||
@@ -174,6 +191,12 @@ export async function runTests() {
|
||||
chartCanvasSourceIncludes(chartSource, "handleRetryDetail"),
|
||||
"city detail charts should show stale cache first and expose a retryable unavailable state",
|
||||
);
|
||||
assert(
|
||||
chartSource.includes("const showDetailErrorBadge = !compact || isActive || isMaximized") &&
|
||||
chartSource.includes("showDetailErrorBadge={showDetailErrorBadge}") &&
|
||||
chartCanvasSourceIncludes(chartSource, "showDetailErrorBadge"),
|
||||
"compact grid charts should only show the stale-cache retry badge on the active slot or expanded chart",
|
||||
);
|
||||
assert(
|
||||
__shouldFetchCityDetailForChartForTest({ city: "paris", documentHidden: false, isChartVisible: true }) === true,
|
||||
"visible chart cards should fetch city detail",
|
||||
|
||||
@@ -28,6 +28,7 @@ export function runTests() {
|
||||
const now = new Date("2026-05-14T12:00:00Z");
|
||||
|
||||
assert.equal(getObservationSourceProfile("amos").nativeUpdateIntervalSec, 60);
|
||||
assert.equal(getObservationSourceProfile("amsc_awos").nativeUpdateIntervalSec, 180);
|
||||
assert.equal(getObservationSourceProfile("metar").nativeUpdateIntervalSec, 900);
|
||||
|
||||
const amosFresh = buildObservationFreshness({
|
||||
@@ -109,4 +110,28 @@ export function runTests() {
|
||||
},
|
||||
});
|
||||
assert.equal(getObservationFreshness(amosDetail)?.source_code, "amos");
|
||||
|
||||
const amscDetail = detail({
|
||||
amos: {
|
||||
observation_time: "2026-05-14T11:57:20Z",
|
||||
runway_obs: {
|
||||
runway_pairs: [["35R", "17L"]],
|
||||
temperatures: [[25.2, null]],
|
||||
},
|
||||
source: "amsc_awos",
|
||||
source_label: "AMSC AWOS",
|
||||
},
|
||||
});
|
||||
const amscFreshness = getObservationFreshness(amscDetail);
|
||||
assert.equal(amscFreshness?.source_code, "amsc_awos");
|
||||
assert.equal(amscFreshness?.native_update_interval_sec, 180);
|
||||
|
||||
const amscExpectedWait = buildObservationFreshness({
|
||||
now,
|
||||
observedAt: "2026-05-14T11:56:40Z",
|
||||
sourceCode: "amsc_awos",
|
||||
sourceLabel: "AMSC AWOS",
|
||||
});
|
||||
assert.equal(amscExpectedWait.native_update_interval_sec, 180);
|
||||
assert.equal(amscExpectedWait.freshness_status, "expected_wait");
|
||||
}
|
||||
|
||||
@@ -148,7 +148,11 @@ export function runTests() {
|
||||
assert(chart.includes("latestPatch"), "temperature chart must react to incoming SSE patches");
|
||||
assert(chart.includes("useSseResyncVersion"), "temperature chart must resync full detail when SSE replay is incomplete");
|
||||
assert(chartLogic.includes("runway_points"), "temperature chart must merge v1 runway_points into runway history");
|
||||
assert(chart.includes("2 * 60_000"), "temperature chart must wait two minutes without patches before full-fetch fallback");
|
||||
assert(
|
||||
chart.includes("DASHBOARD_REFRESH_POLICY_MS.metar") &&
|
||||
!chart.includes("2 * 60_000"),
|
||||
"temperature chart must wait one METAR cadence without patches before full-fetch fallback",
|
||||
);
|
||||
assert(chart.includes("TemperatureChartCanvas"), "temperature chart shell must compose the extracted chart canvas");
|
||||
assert(chart.includes("TemperatureStatsBars"), "temperature chart shell must compose the extracted stat bars");
|
||||
assert(chart.includes("TemperatureRunwayDetails"), "temperature chart shell must compose the extracted runway panel");
|
||||
@@ -258,7 +262,7 @@ export function runTests() {
|
||||
"single-runway charts must not show the runway-detail toggle because aggregate and individual views are visually redundant",
|
||||
);
|
||||
assert(
|
||||
chartLogic.includes("HOURLY_DETAIL_REQUEST_TIMEOUT_MS = 12_000") &&
|
||||
chartLogic.includes("HOURLY_DETAIL_REQUEST_TIMEOUT_MS = 16_000") &&
|
||||
chartLogic.includes("fetchCityDetailBatchWithTimeout") &&
|
||||
chartLogic.includes("signal: controller.signal") &&
|
||||
chartLogic.includes("controller.abort()"),
|
||||
|
||||
+91
-8
@@ -9,6 +9,7 @@ import {
|
||||
__getVisibleTemperatureSeriesForTest,
|
||||
__isTemperatureSeriesVisibleByDefaultForTest,
|
||||
__mergePatchIntoHourlyForTest,
|
||||
__selectCompactSecondaryTempForTest,
|
||||
__selectDisplayRunwayTempForTest,
|
||||
} from "@/components/dashboard/scan-terminal/LiveTemperatureThresholdChart";
|
||||
import { __buildTemperatureTooltipRowsForTest } from "@/components/dashboard/scan-terminal/TemperatureTooltipContent";
|
||||
@@ -976,6 +977,68 @@ export function runTests() {
|
||||
"runway header should prefer runway-history current temp even when the latest detail payload lacks runway_obs snapshot",
|
||||
);
|
||||
|
||||
const hongKongMetrics = __getObservationDisplayMetricsForTest(
|
||||
{
|
||||
city: "hong kong",
|
||||
local_date: "2026-06-06",
|
||||
local_time: "16:48",
|
||||
tz_offset_seconds: 8 * 60 * 60,
|
||||
current_temp: 30.4,
|
||||
current_max_so_far: 30.4,
|
||||
temp_symbol: "°C",
|
||||
} as any,
|
||||
{
|
||||
localTime: "16:48",
|
||||
times: ["00:00", "12:00", "18:00", "23:00"],
|
||||
temps: [28.0, 31.0, 29.0, 28.0],
|
||||
settlementTodayObs: [
|
||||
{ time: "14:00", temp: 30.4 },
|
||||
{ time: "16:40", temp: 28.1 },
|
||||
],
|
||||
airportPrimary: {
|
||||
temp: 28.7,
|
||||
obs_time: "2026-06-06T16:48:00+08:00",
|
||||
source_code: "cowin_obs",
|
||||
source_label: "CoWIN 6087",
|
||||
},
|
||||
airportPrimaryTodayObs: [
|
||||
{ time: "16:47", temp: 28.6 },
|
||||
{ time: "16:48", temp: 28.7 },
|
||||
],
|
||||
} as any,
|
||||
null,
|
||||
);
|
||||
assert(
|
||||
hongKongMetrics.currentRunwayTemp === 28.7,
|
||||
"Hong Kong primary compact stat should stay on the latest CoWIN reference-station point",
|
||||
);
|
||||
assert(
|
||||
(hongKongMetrics as any).currentMetarTemp === 28.1,
|
||||
"Hong Kong secondary compact stat should have the latest HKO point separate from the HKO daily high",
|
||||
);
|
||||
assert(
|
||||
hongKongMetrics.observedHighMetar === 30.4,
|
||||
"Hong Kong HKO daily high should still be available for expanded daily-high summaries",
|
||||
);
|
||||
assert(
|
||||
__selectCompactSecondaryTempForTest({
|
||||
isHKO: true,
|
||||
isShenzhen: false,
|
||||
displayMetarTemp: (hongKongMetrics as any).currentMetarTemp,
|
||||
observedHighMetar: hongKongMetrics.observedHighMetar,
|
||||
}) === 28.1,
|
||||
"Hong Kong compact HKO stat should render the latest HKO point, not the HKO daily high",
|
||||
);
|
||||
assert(
|
||||
__selectCompactSecondaryTempForTest({
|
||||
isHKO: false,
|
||||
isShenzhen: false,
|
||||
displayMetarTemp: 72.0,
|
||||
observedHighMetar: 73.9,
|
||||
}) === 73.9,
|
||||
"non-HKO compact secondary stat should keep the existing daily-high behavior",
|
||||
);
|
||||
|
||||
const wuhanRunwayChart = __buildTemperatureChartDataForTest(
|
||||
{
|
||||
city: "wuhan",
|
||||
@@ -1107,6 +1170,29 @@ export function runTests() {
|
||||
"Panama City high label should use airport METAR report wording, not weather-station or runway wording",
|
||||
);
|
||||
|
||||
const shanghaiLabels = __getLiveObservationLabelsForTest(
|
||||
{
|
||||
city: "shanghai",
|
||||
local_date: "2026-06-06",
|
||||
local_time: "21:03",
|
||||
tz_offset_seconds: 8 * 60 * 60,
|
||||
} as any,
|
||||
{
|
||||
amos: {
|
||||
source: "amsc_awos",
|
||||
source_label: "AMSC AWOS",
|
||||
},
|
||||
airportPrimary: {
|
||||
source_code: "amsc_awos",
|
||||
source_label: "AMSC AWOS",
|
||||
},
|
||||
} as any,
|
||||
);
|
||||
assert(
|
||||
shanghaiLabels.runwayHeaderLabel === "跑道实测 (3分钟)",
|
||||
"AMSC runway cities should advertise the 3-minute source cadence instead of the AMOS 1-minute cadence",
|
||||
);
|
||||
|
||||
const newYorkWithMadis = __buildTemperatureChartDataForTest(
|
||||
{
|
||||
city: "new york",
|
||||
@@ -1574,7 +1660,7 @@ export function runTests() {
|
||||
const firstBand = bandPoints[0].runway_band;
|
||||
assert(Array.isArray(firstBand) && firstBand[0] === 24.0 && firstBand[1] === 26.0, "runway_band tuple values should match input limits");
|
||||
|
||||
// ── Legacy Gaussian probability overlay test ──
|
||||
// ── Legacy Gaussian probability data should be available as compact tooltip context only ──
|
||||
const gaussianOverlayChart = __buildTemperatureChartDataForTest(
|
||||
{
|
||||
city: "toronto",
|
||||
@@ -1602,16 +1688,13 @@ export function runTests() {
|
||||
) as any;
|
||||
|
||||
const gaussianOverlay = gaussianOverlayChart.probabilityOverlay;
|
||||
assert(gaussianOverlay, "legacy Gaussian probabilities should be exposed as a chart overlay");
|
||||
assert(gaussianOverlay.muLine?.value === 27.4, "legacy Gaussian μ should become a reference line");
|
||||
assert(
|
||||
gaussianOverlay.bands.some(
|
||||
(band: any) => band.value === 27 && band.lower === 26.5 && band.upper === 27.5 && band.probability === 0.42,
|
||||
),
|
||||
"legacy Gaussian buckets should become horizontal probability temperature bands",
|
||||
gaussianOverlay?.muLine?.label === "Gaussian μ 27.4°C" &&
|
||||
gaussianOverlay.bands.length === 3,
|
||||
"legacy Gaussian probabilities should remain available for compact tooltip context",
|
||||
);
|
||||
assert(
|
||||
!gaussianOverlayChart.series.some((series: any) => String(series.key || "").includes("probability")),
|
||||
"legacy Gaussian probability distribution should not be rendered as a time-series line",
|
||||
"legacy Gaussian probability distribution should not be rendered as a time-series line on the main chart",
|
||||
);
|
||||
}
|
||||
|
||||
+21
-1
@@ -1,4 +1,4 @@
|
||||
import { __buildTemperatureStatsLabelsForTest } from "@/components/dashboard/scan-terminal/TemperatureStatsBars";
|
||||
import { __buildDebQualityLabelForTest, __buildTemperatureStatsLabelsForTest } from "@/components/dashboard/scan-terminal/TemperatureStatsBars";
|
||||
import { temp } from "@/components/dashboard/scan-terminal/utils";
|
||||
|
||||
function assert(condition: unknown, message: string) {
|
||||
@@ -36,6 +36,18 @@ export function runTests() {
|
||||
assert(shenzhen.runwayHigh === "HKO Live", "Shenzhen high summary should translate 天文台实测");
|
||||
assert(shenzhen.metarHigh === "HKO", "Shenzhen high summary should translate 天文台");
|
||||
|
||||
const shanghai = __buildTemperatureStatsLabelsForTest({
|
||||
isEn: true,
|
||||
isShenzhen: false,
|
||||
runwayHeaderLabel: "跑道实测 (3分钟)",
|
||||
metarHeaderLabel: "METAR 结算 (30分钟)",
|
||||
runwayHighLabel: "跑道实测",
|
||||
metarHighLabel: "METAR 官方",
|
||||
});
|
||||
|
||||
assert(shanghai.primary === "Runway Live (3m)", "AMSC English primary label should match 跑道实测 (3分钟)");
|
||||
assert(shanghai.runwayHigh === "Runway", "AMSC runway high label should remain Runway");
|
||||
|
||||
const zh = __buildTemperatureStatsLabelsForTest({
|
||||
isEn: false,
|
||||
isShenzhen: true,
|
||||
@@ -51,4 +63,12 @@ export function runTests() {
|
||||
assert(temp(null, "°C") === "--", "empty temperature values should not render as 0.0°C while city detail is loading");
|
||||
assert(temp(undefined, "°C") === "--", "undefined temperature values should not render as 0.0°C while city detail is loading");
|
||||
assert(temp("", "°C") === "--", "blank temperature values should not render as 0.0°C while city detail is loading");
|
||||
assert(
|
||||
__buildDebQualityLabelForTest({ recommendation: "context_only" }, true) === "Context",
|
||||
"low-confidence DEB should render as context-only guidance in English",
|
||||
);
|
||||
assert(
|
||||
__buildDebQualityLabelForTest({ recommendation: "insufficient" }, false) === "样本少",
|
||||
"thin-sample DEB should render a Chinese low-sample label",
|
||||
);
|
||||
}
|
||||
|
||||
+69
-1
@@ -1,4 +1,11 @@
|
||||
import { __buildTemperatureTooltipRowsForTest } from "@/components/dashboard/scan-terminal/TemperatureTooltipContent";
|
||||
import {
|
||||
readFileSync,
|
||||
} from "node:fs";
|
||||
import path from "node:path";
|
||||
import {
|
||||
__buildTemperatureTooltipProbabilityRowsForTest,
|
||||
__buildTemperatureTooltipRowsForTest,
|
||||
} from "@/components/dashboard/scan-terminal/TemperatureTooltipContent";
|
||||
|
||||
function assert(condition: unknown, message: string) {
|
||||
if (!condition) throw new Error(message);
|
||||
@@ -48,4 +55,65 @@ export function runTests() {
|
||||
rows.some((row) => row.key === "gfs" && row.value === 24.2),
|
||||
"non-runway sparse series should keep nearest-value fallback",
|
||||
);
|
||||
|
||||
const probabilityRows = __buildTemperatureTooltipProbabilityRowsForTest(
|
||||
{
|
||||
engine: "legacy",
|
||||
muLine: { value: 27.4, label: "Gaussian μ 27.4°C" },
|
||||
bands: [
|
||||
{
|
||||
key: "legacy_probability_26_0",
|
||||
value: 26,
|
||||
lower: 25.5,
|
||||
upper: 26.5,
|
||||
probability: 0.18,
|
||||
label: "26°C 18%",
|
||||
opacity: 0.08,
|
||||
},
|
||||
{
|
||||
key: "legacy_probability_27_0",
|
||||
value: 27,
|
||||
lower: 26.5,
|
||||
upper: 27.5,
|
||||
probability: 0.42,
|
||||
label: "27°C 42%",
|
||||
opacity: 0.13,
|
||||
},
|
||||
{
|
||||
key: "legacy_probability_28_0",
|
||||
value: 28,
|
||||
lower: 27.5,
|
||||
upper: 28.5,
|
||||
probability: 0.31,
|
||||
label: "28°C 31%",
|
||||
opacity: 0.11,
|
||||
},
|
||||
],
|
||||
},
|
||||
"°C",
|
||||
true,
|
||||
);
|
||||
|
||||
assert(
|
||||
probabilityRows.length === 4,
|
||||
"temperature tooltip should show Gaussian μ and every available probability bucket as compact context",
|
||||
);
|
||||
assert(
|
||||
probabilityRows.some((row) => row.key === "legacy_probability_mu" && row.value === "27.4°C") &&
|
||||
probabilityRows.some((row) => row.key === "legacy_probability_26_0" && row.label === "25.5-26.5°C" && row.value === "18%") &&
|
||||
probabilityRows.some((row) => row.key === "legacy_probability_27_0" && row.label === "26.5-27.5°C" && row.value === "42%") &&
|
||||
probabilityRows.some((row) => row.key === "legacy_probability_28_0" && row.label === "27.5-28.5°C" && row.value === "31%"),
|
||||
"temperature tooltip should format the full Gaussian probability distribution by temperature range without drawing it on the main chart",
|
||||
);
|
||||
|
||||
const projectRoot = process.cwd();
|
||||
const chartCanvasSource = readFileSync(
|
||||
path.join(projectRoot, "components", "dashboard", "scan-terminal", "TemperatureChartCanvas.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
assert(
|
||||
chartCanvasSource.includes("probabilityOverlay={probabilityOverlay}") &&
|
||||
!chartCanvasSource.includes("probabilityOverlay={null}"),
|
||||
"temperature chart canvas must pass probability overlay data into the tooltip instead of hiding Gaussian μ",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -36,6 +36,10 @@ export function runTests() {
|
||||
path.join(projectRoot, "components", "dashboard", "scan-terminal", "use-scan-terminal-query.ts"),
|
||||
"utf8",
|
||||
);
|
||||
const mobileRegionTabsSource = fs.readFileSync(
|
||||
path.join(projectRoot, "components", "dashboard", "scan-terminal", "MobileRegionTabs.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
assert(
|
||||
dashboardSource.includes("MAX_TERMINAL_CHARTS = 9"),
|
||||
@@ -57,6 +61,26 @@ export function runTests() {
|
||||
dashboardSource.includes("disableClose={true}"),
|
||||
"mobile terminal should render one selected chart and suggest landscape for the full grid",
|
||||
);
|
||||
assert(
|
||||
dashboardSource.includes("mobile-region-tabs-shell") &&
|
||||
dashboardSource.includes("handleMobileSelectTab") &&
|
||||
dashboardSource.includes("mobileSelectedRowInActiveGroup") &&
|
||||
dashboardSource.includes("mobileActiveGroup?.rows[0]") &&
|
||||
mobileRegionTabsSource.includes("min-h-11") &&
|
||||
mobileRegionTabsSource.includes("snap-x") &&
|
||||
mobileRegionTabsSource.includes("aria-pressed={isActive}"),
|
||||
"mobile region navigation must stay visible above the landscape hint, remain touch-friendly, and switch the visible chart with the active region",
|
||||
);
|
||||
assert(
|
||||
!dashboardSource.includes("disableClose={visibleSlots.filter(Boolean).length <= 1}"),
|
||||
"desktop chart slots must allow clearing the last visible city so users can close a 1x1 chart",
|
||||
);
|
||||
assert(
|
||||
dashboardSource.includes("readStoredTerminalSlots") &&
|
||||
dashboardSource.includes("hasPersistedSlots") &&
|
||||
dashboardSource.includes("shouldAutofillInitialSlots"),
|
||||
"terminal slots must distinguish first-load default fill from a user-cleared all-empty layout",
|
||||
);
|
||||
assert(
|
||||
selectorSource.includes("[1, 2, 3].map") &&
|
||||
selectorSource.includes("grid grid-cols-3"),
|
||||
@@ -165,6 +189,13 @@ export function runTests() {
|
||||
chartCanvasSource.includes("TemperatureChartCanvasComponent"),
|
||||
"temperature chart canvas must be memoized so unrelated terminal state does not remount Recharts",
|
||||
);
|
||||
assert(
|
||||
dashboardSource.includes("const TerminalSidebar = memo(function TerminalSidebar") &&
|
||||
dashboardSource.includes("const [navExpanded, setNavExpanded] = useState(false);") &&
|
||||
dashboardSource.includes("transition-colors duration-150") &&
|
||||
!dashboardSource.includes("transition-all duration-200"),
|
||||
"terminal sidebar expand state must be isolated from the chart grid and must not animate width through transition-all",
|
||||
);
|
||||
assert(
|
||||
__shouldKeepTemperatureChartLoadingForTest({
|
||||
row: { city: "Moscow" } as any,
|
||||
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
import { buildDebRecentRankingRows } from "@/lib/deb-training-ranking";
|
||||
|
||||
function assert(condition: unknown, message: string) {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
export function runTests() {
|
||||
const rows = buildDebRecentRankingRows([
|
||||
{
|
||||
city_id: "legacy",
|
||||
name: "Legacy Good",
|
||||
deb: { hit_rate: 95, mae: 0.9, total_days: 40 },
|
||||
deb_recent: {
|
||||
recent_7d: { hit_rate: 0, mae: 2.5, samples: 4 },
|
||||
recent_14d: { hit_rate: 15, mae: 2.1, samples: 8 },
|
||||
trust_tier: "low",
|
||||
recommendation: "context_only",
|
||||
},
|
||||
},
|
||||
{
|
||||
city_id: "usable",
|
||||
name: "Usable Recent",
|
||||
deb: { hit_rate: 40, mae: 1.4, total_days: 20 },
|
||||
deb_recent: {
|
||||
recent_7d: { hit_rate: 75, mae: 0.7, samples: 4 },
|
||||
recent_14d: { hit_rate: 70, mae: 0.8, samples: 8 },
|
||||
trust_tier: "high",
|
||||
recommendation: "primary",
|
||||
},
|
||||
},
|
||||
{
|
||||
city_id: "support",
|
||||
name: "Support Recent",
|
||||
deb: { hit_rate: 35, mae: 1.5, total_days: 20 },
|
||||
deb_recent: {
|
||||
recent_7d: { hit_rate: 50, mae: 1.2, samples: 4 },
|
||||
recent_14d: { hit_rate: 50, mae: 1.2, samples: 8 },
|
||||
trust_tier: "medium",
|
||||
recommendation: "supporting",
|
||||
},
|
||||
},
|
||||
] as any);
|
||||
|
||||
assert(rows[0].cityId === "usable", "high-trust recent DEB city should rank first");
|
||||
assert(rows[1].cityId === "support", "supporting recent DEB city should rank before low-trust legacy hit rate");
|
||||
assert(rows[2].cityId === "legacy", "low-trust historical performer should rank after usable recent cities");
|
||||
assert(rows[0].hitRate === 75, "ranking rows should use recent hit rate for chart value");
|
||||
assert(rows[0].mae === 0.7, "ranking rows should use recent MAE for chart value");
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
function assert(condition: unknown, message: string) {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
export function runTests() {
|
||||
const projectRoot = process.cwd();
|
||||
const repoRoot = path.resolve(projectRoot, "..");
|
||||
const dashboardSource = fs.readFileSync(
|
||||
path.join(projectRoot, "components", "dashboard", "ScanTerminalDashboard.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
const opsConfigSource = fs.readFileSync(
|
||||
path.join(projectRoot, "components", "ops", "config", "ConfigPageClient.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
const nextRoutePath = path.join(
|
||||
projectRoot,
|
||||
"app",
|
||||
"api",
|
||||
"system",
|
||||
"update-announcement",
|
||||
"route.ts",
|
||||
);
|
||||
const componentPath = path.join(
|
||||
projectRoot,
|
||||
"components",
|
||||
"dashboard",
|
||||
"scan-terminal",
|
||||
"UpdateAnnouncementButton.tsx",
|
||||
);
|
||||
const opsApiSource = fs.readFileSync(path.join(repoRoot, "web", "services", "ops_api.py"), "utf8");
|
||||
const systemApiSource = fs.readFileSync(path.join(repoRoot, "web", "services", "system_api.py"), "utf8");
|
||||
const systemRouterSource = fs.readFileSync(path.join(repoRoot, "web", "routers", "system.py"), "utf8");
|
||||
const dbSource = fs.readFileSync(path.join(repoRoot, "src", "database", "db_manager.py"), "utf8");
|
||||
const middlewareSource = fs.readFileSync(path.join(projectRoot, "middleware.ts"), "utf8");
|
||||
|
||||
assert(fs.existsSync(componentPath), "terminal must have a compact update announcement component");
|
||||
assert(!fs.existsSync(nextRoutePath), "update announcements should not depend on an admin-managed API proxy");
|
||||
|
||||
const componentSource = fs.readFileSync(componentPath, "utf8");
|
||||
|
||||
assert(
|
||||
dashboardSource.includes("UpdateAnnouncementButton") &&
|
||||
dashboardSource.includes("<UpdateAnnouncementButton") &&
|
||||
dashboardSource.includes("isEn={isEn}"),
|
||||
"terminal header must render a bilingual update announcement entry beside the dashboard title",
|
||||
);
|
||||
assert(
|
||||
componentSource.includes("STATIC_UPDATE_ANNOUNCEMENTS") &&
|
||||
componentSource.includes("expiresAt") &&
|
||||
componentSource.includes("Date.now()") &&
|
||||
componentSource.includes("Megaphone") &&
|
||||
componentSource.includes("zh") &&
|
||||
componentSource.includes("en") &&
|
||||
!componentSource.includes("fetch(") &&
|
||||
!componentSource.includes("/api/system/update-announcement") &&
|
||||
!componentSource.includes("setInterval("),
|
||||
"announcement component must use hardcoded zh/en release notes with an expiry time and no backend polling",
|
||||
);
|
||||
assert(
|
||||
!middlewareSource.includes("/api/system/update-announcement"),
|
||||
"middleware should not keep a public announcement API entry after announcements move into frontend code",
|
||||
);
|
||||
assert(
|
||||
!opsConfigSource.includes("公告类配置") &&
|
||||
!opsConfigSource.includes("multiline") &&
|
||||
!opsConfigSource.includes("<textarea"),
|
||||
"ops config page should not expose update announcement editing controls",
|
||||
);
|
||||
assert(
|
||||
!opsApiSource.includes("POLYWEATHER_UPDATE_ANNOUNCEMENT") &&
|
||||
!opsApiSource.includes("_RUNTIME_CONFIG_KEYS"),
|
||||
"ops API must not expose editable update announcement keys",
|
||||
);
|
||||
assert(
|
||||
!systemApiSource.includes("get_public_update_announcement") &&
|
||||
!systemRouterSource.includes("/api/system/update-announcement"),
|
||||
"backend must not expose a runtime update announcement endpoint",
|
||||
);
|
||||
assert(
|
||||
!dbSource.includes("CREATE TABLE IF NOT EXISTS runtime_config") &&
|
||||
!dbSource.includes("set_runtime_config") &&
|
||||
!dbSource.includes("get_runtime_config_value"),
|
||||
"database manager should not keep a runtime_config table only for update announcements",
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { UserFeedbackEntry } from "@/types/ops";
|
||||
|
||||
export const FEEDBACK_STATUS_POLL_MS = 30 * 60 * 1000;
|
||||
export const FEEDBACK_STATUS_CACHE_TTL_MS = 10 * 60 * 1000;
|
||||
|
||||
export function feedbackStatusLabel(status: string | undefined, isEn: boolean) {
|
||||
const key = String(status || "open").toLowerCase();
|
||||
if (key === "triaged") return isEn ? "Confirmed" : "已确认";
|
||||
if (key === "investigating") return isEn ? "In progress" : "处理中";
|
||||
if (key === "resolved") return isEn ? "Resolved" : "已解决";
|
||||
if (key === "closed") return isEn ? "Closed" : "已关闭";
|
||||
return isEn ? "Received" : "已收到";
|
||||
}
|
||||
|
||||
export function feedbackStatusTone(status: string | undefined) {
|
||||
const key = String(status || "open").toLowerCase();
|
||||
if (key === "triaged") return "border-amber-200 bg-amber-50 text-amber-700";
|
||||
if (key === "investigating") return "border-blue-200 bg-blue-50 text-blue-700";
|
||||
if (key === "resolved") return "border-emerald-200 bg-emerald-50 text-emerald-700";
|
||||
if (key === "closed") return "border-slate-200 bg-slate-50 text-slate-500";
|
||||
return "border-red-200 bg-red-50 text-red-700";
|
||||
}
|
||||
|
||||
export function buildFeedbackNotificationKey(
|
||||
entry: Pick<UserFeedbackEntry, "id" | "status" | "updated_at" | "created_at">,
|
||||
) {
|
||||
return [
|
||||
Number(entry.id || 0),
|
||||
String(entry.status || "open").toLowerCase(),
|
||||
String(entry.updated_at || entry.created_at || ""),
|
||||
].join(":");
|
||||
}
|
||||
|
||||
export function countUnseenFeedbackUpdates(
|
||||
entries: Array<
|
||||
Pick<UserFeedbackEntry, "id" | "status" | "updated_at" | "created_at">
|
||||
>,
|
||||
seenKeys: Set<string>,
|
||||
) {
|
||||
return entries.reduce((count, entry) => {
|
||||
return count + (seenKeys.has(buildFeedbackNotificationKey(entry)) ? 0 : 1);
|
||||
}, 0);
|
||||
}
|
||||
@@ -2,9 +2,11 @@ import type {
|
||||
AmosData,
|
||||
AirportCurrentConditions,
|
||||
CityDetail,
|
||||
CurrentConditions,
|
||||
ScanOpportunityRow,
|
||||
ForecastDay,
|
||||
DailyModelForecast,
|
||||
DebForecast,
|
||||
DebHourlyPath,
|
||||
ProbabilityBucket,
|
||||
} from "@/lib/dashboard-types";
|
||||
@@ -15,6 +17,10 @@ const ROLLING_WINDOW_BEFORE_MS = 12 * 60 * 60 * 1000;
|
||||
const ROLLING_WINDOW_AFTER_LIVE_MS = 2 * 60 * 60 * 1000;
|
||||
const ROLLING_WINDOW_AFTER_FORECAST_MS = 8 * 60 * 60 * 1000;
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const AMSC_RUNWAY_CITIES = new Set([
|
||||
"beijing", "shanghai", "guangzhou", "qingdao",
|
||||
"chengdu", "chongqing", "wuhan",
|
||||
]);
|
||||
|
||||
const SETTLEMENT_RUNWAY_PAIRS: Record<string, Array<[string, string]>> = {
|
||||
shanghai: [["17L", "35R"]],
|
||||
@@ -357,7 +363,7 @@ const HOURLY_FORCE_REFRESH_DEDUP_MS = 60_000;
|
||||
const _hourlyCache = new Map<string, { ts: number; data: HourlyForecast }>();
|
||||
const _hourlyRequestCache = new Map<string, Promise<HourlyForecast>>();
|
||||
const MAX_HOURLY_DETAIL_CONCURRENT_REQUESTS = 3;
|
||||
const HOURLY_DETAIL_REQUEST_TIMEOUT_MS = 12_000;
|
||||
const HOURLY_DETAIL_REQUEST_TIMEOUT_MS = 16_000;
|
||||
let _hourlyActiveDetailRequests = 0;
|
||||
const _hourlyDetailRequestQueue: Array<() => void> = [];
|
||||
const RUNWAY_LINE_COLORS = ["#00897b", "#d97706", "#7c3aed", "#0891b2", "#ea580c", "#64748b"];
|
||||
@@ -845,9 +851,14 @@ function getObservationDisplayMetrics(
|
||||
null;
|
||||
}
|
||||
|
||||
const currentMetarTemp =
|
||||
latestSettlement ??
|
||||
latestMetar ??
|
||||
airportCurrentTemp ??
|
||||
null;
|
||||
const observedHighMetar = airportHigh ?? highSettlement ?? highMetar ?? rowMetarHigh ?? null;
|
||||
|
||||
return { currentRunwayTemp, observedHighMetar, observedHighRunway };
|
||||
return { currentMetarTemp, currentRunwayTemp, observedHighMetar, observedHighRunway };
|
||||
}
|
||||
|
||||
function selectDisplayRunwayTemp(
|
||||
@@ -859,6 +870,23 @@ function selectDisplayRunwayTemp(
|
||||
return liveTemp;
|
||||
}
|
||||
|
||||
function selectCompactSecondaryTemp({
|
||||
isHKO,
|
||||
isShenzhen,
|
||||
displayMetarTemp,
|
||||
observedHighMetar,
|
||||
}: {
|
||||
isHKO: boolean;
|
||||
isShenzhen: boolean;
|
||||
displayMetarTemp: number | null;
|
||||
observedHighMetar: number | null;
|
||||
}) {
|
||||
if (isHKO && !isShenzhen && displayMetarTemp !== null) {
|
||||
return displayMetarTemp;
|
||||
}
|
||||
return observedHighMetar;
|
||||
}
|
||||
|
||||
function isSettlementRunway(row: ScanOpportunityRow | null, rwy: string) {
|
||||
const cityKey = normalizeCityKey(row?.city);
|
||||
const settlementPairs = SETTLEMENT_RUNWAY_PAIRS[cityKey] || [];
|
||||
@@ -913,6 +941,7 @@ function runwayPatchPointsFromRunwayObs(runwayObs: any) {
|
||||
type HourlyForecast = {
|
||||
forecastTodayHigh?: number | null;
|
||||
debPrediction?: number | null;
|
||||
debQuality?: Pick<DebForecast, "quality_tier" | "recommendation" | "recent_hit_rate" | "recent_samples" | "recent_hits" | "recent_mae"> | null;
|
||||
debHourlyPath?: DebHourlyPath | null;
|
||||
localDate?: string | null;
|
||||
localTime?: string | null;
|
||||
@@ -922,6 +951,7 @@ type HourlyForecast = {
|
||||
runwayPlateHistory?: Record<string, Array<Record<string, unknown>>>;
|
||||
runwayBandHistory?: Array<{ time: string; high_temp: number; low_temp: number; avg_temp: number }>;
|
||||
amos?: AmosData | null;
|
||||
current?: CurrentConditions | null;
|
||||
airportCurrent?: AirportCurrentConditions | null;
|
||||
airportPrimary?: AirportCurrentConditions | null;
|
||||
wundergroundCurrent?: AirportCurrentConditions | null;
|
||||
@@ -939,6 +969,7 @@ function seedHourlyForecastFromRow(row: ScanOpportunityRow | null): HourlyForeca
|
||||
return {
|
||||
forecastTodayHigh: null,
|
||||
debPrediction: validNumber(row.deb_prediction),
|
||||
debQuality: null,
|
||||
debHourlyPath: null,
|
||||
localDate: row.local_date || null,
|
||||
localTime: row.local_time || null,
|
||||
@@ -948,6 +979,7 @@ function seedHourlyForecastFromRow(row: ScanOpportunityRow | null): HourlyForeca
|
||||
runwayPlateHistory: (row as any)?.runway_plate_history || undefined,
|
||||
runwayBandHistory: undefined,
|
||||
amos: null,
|
||||
current: null,
|
||||
airportCurrent: null,
|
||||
airportPrimary: null,
|
||||
wundergroundCurrent: (row as any)?.wunderground_current || null,
|
||||
@@ -986,6 +1018,8 @@ type CityDetailBatchQueue = {
|
||||
cities: Set<string>;
|
||||
waiters: Map<string, CityDetailBatchWaiter[]>;
|
||||
timer: ReturnType<typeof setTimeout> | null;
|
||||
resolution: string;
|
||||
forceRefresh: boolean;
|
||||
};
|
||||
|
||||
const CITY_DETAIL_BATCH_WINDOW_MS = 100;
|
||||
@@ -998,6 +1032,14 @@ function parseHourlyForecastFromCityDetail(json: CityDetail | null): HourlyForec
|
||||
return {
|
||||
forecastTodayHigh: json.forecast?.today_high ?? null,
|
||||
debPrediction: json.deb?.prediction ?? (json as any)?.overview?.deb_prediction ?? null,
|
||||
debQuality: json.deb ? {
|
||||
quality_tier: json.deb.quality_tier,
|
||||
recommendation: json.deb.recommendation,
|
||||
recent_hit_rate: json.deb.recent_hit_rate,
|
||||
recent_samples: json.deb.recent_samples,
|
||||
recent_hits: json.deb.recent_hits,
|
||||
recent_mae: json.deb.recent_mae,
|
||||
} : null,
|
||||
debHourlyPath: json.deb?.hourly_path || null,
|
||||
localDate: json.local_date || (json as any)?.overview?.local_date || null,
|
||||
localTime: json.local_time || null,
|
||||
@@ -1007,6 +1049,7 @@ function parseHourlyForecastFromCityDetail(json: CityDetail | null): HourlyForec
|
||||
runwayPlateHistory: (json as any)?.runway_plate_history || (json.amos as any)?.runway_plate_history || undefined,
|
||||
runwayBandHistory: (json as any)?.runway_band_history || undefined,
|
||||
amos: json.amos || null,
|
||||
current: json.current || null,
|
||||
airportCurrent: json.airport_current || null,
|
||||
airportPrimary: json.airport_primary || null,
|
||||
wundergroundCurrent: (json as any).wunderground_current || (json as any)?.official?.wunderground_current || null,
|
||||
@@ -1033,14 +1076,25 @@ function primeCityDetailCache(
|
||||
return data;
|
||||
}
|
||||
|
||||
function queueCityDetailBatch(city: string, resolution: string): Promise<HourlyForecast> {
|
||||
function cityDetailBatchQueueKey(resolution: string, forceRefresh: boolean) {
|
||||
return `${resolution}:${forceRefresh ? "force" : "cached"}`;
|
||||
}
|
||||
|
||||
function queueCityDetailBatch(
|
||||
city: string,
|
||||
resolution: string,
|
||||
forceRefresh: boolean,
|
||||
): Promise<HourlyForecast> {
|
||||
return new Promise<HourlyForecast>((resolve, reject) => {
|
||||
const queue = _cityDetailBatchQueues.get(resolution) || {
|
||||
const queueKey = cityDetailBatchQueueKey(resolution, forceRefresh);
|
||||
const queue = _cityDetailBatchQueues.get(queueKey) || {
|
||||
cities: new Set<string>(),
|
||||
waiters: new Map<string, CityDetailBatchWaiter[]>(),
|
||||
timer: null,
|
||||
resolution,
|
||||
forceRefresh,
|
||||
};
|
||||
_cityDetailBatchQueues.set(resolution, queue);
|
||||
_cityDetailBatchQueues.set(queueKey, queue);
|
||||
|
||||
const cityWaiters = queue.waiters.get(city) || [];
|
||||
cityWaiters.push({ resolve, reject });
|
||||
@@ -1048,10 +1102,10 @@ function queueCityDetailBatch(city: string, resolution: string): Promise<HourlyF
|
||||
queue.cities.add(city);
|
||||
|
||||
if (queue.timer === null) {
|
||||
queue.timer = setTimeout(() => flushCityDetailBatch(resolution), CITY_DETAIL_BATCH_WINDOW_MS);
|
||||
queue.timer = setTimeout(() => flushCityDetailBatch(queueKey), CITY_DETAIL_BATCH_WINDOW_MS);
|
||||
}
|
||||
if (queue.cities.size >= CITY_DETAIL_BATCH_MAX_CITIES) {
|
||||
flushCityDetailBatch(resolution);
|
||||
flushCityDetailBatch(queueKey);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1087,10 +1141,10 @@ function resolveCityDetailFromBatch(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function flushCityDetailBatch(resolution: string) {
|
||||
const queue = _cityDetailBatchQueues.get(resolution);
|
||||
async function flushCityDetailBatch(queueKey: string) {
|
||||
const queue = _cityDetailBatchQueues.get(queueKey);
|
||||
if (!queue) return;
|
||||
_cityDetailBatchQueues.delete(resolution);
|
||||
_cityDetailBatchQueues.delete(queueKey);
|
||||
if (queue.timer !== null) {
|
||||
clearTimeout(queue.timer);
|
||||
queue.timer = null;
|
||||
@@ -1100,7 +1154,11 @@ async function flushCityDetailBatch(resolution: string) {
|
||||
if (!cities.length) return;
|
||||
|
||||
try {
|
||||
const payload = await fetchCityDetailBatchWithTimeout(cities, resolution);
|
||||
const payload = await fetchCityDetailBatchWithTimeout(
|
||||
cities,
|
||||
queue.resolution,
|
||||
queue.forceRefresh,
|
||||
);
|
||||
if (!payload) {
|
||||
resolveAllBatchWaitersAsNull(cities, queue);
|
||||
return;
|
||||
@@ -1115,7 +1173,7 @@ async function flushCityDetailBatch(resolution: string) {
|
||||
cities.map(async (city) => {
|
||||
const waiters = queue.waiters.get(city);
|
||||
const detail = resolveCityDetailFromBatch(details, city);
|
||||
const data = primeCityDetailCache(city, resolution, detail);
|
||||
const data = primeCityDetailCache(city, queue.resolution, detail);
|
||||
if (data) {
|
||||
resolveBatchWaiters(waiters, data);
|
||||
return;
|
||||
@@ -1141,13 +1199,17 @@ function resolveAllBatchWaitersAsNull(
|
||||
});
|
||||
}
|
||||
|
||||
function fetchCityDetailBatchWithTimeout(cities: string[], resolution: string) {
|
||||
function fetchCityDetailBatchWithTimeout(
|
||||
cities: string[],
|
||||
resolution: string,
|
||||
forceRefresh: boolean,
|
||||
) {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = globalThis.setTimeout(() => controller.abort(), HOURLY_DETAIL_REQUEST_TIMEOUT_MS);
|
||||
const params = new URLSearchParams({
|
||||
cities: cities.join(","),
|
||||
depth: "full",
|
||||
force_refresh: "false",
|
||||
force_refresh: forceRefresh ? "true" : "false",
|
||||
limit: String(Math.max(cities.length, CITY_DETAIL_BATCH_MAX_CITIES)),
|
||||
resolution,
|
||||
scope: "chart",
|
||||
@@ -1170,8 +1232,9 @@ async function fetchHourlyForecastForCity(
|
||||
): Promise<HourlyForecast> {
|
||||
const resParam = options.resolution || "10m";
|
||||
const cacheKey = `${city}:${resParam}`;
|
||||
const forceRefresh = Boolean(options.ignoreCache);
|
||||
|
||||
if (!options.ignoreCache) {
|
||||
if (!forceRefresh) {
|
||||
const cached = readHourlyCacheEntry(cacheKey);
|
||||
if (cached) {
|
||||
return cached.data;
|
||||
@@ -1189,7 +1252,7 @@ async function fetchHourlyForecastForCity(
|
||||
const pending = _hourlyRequestCache.get(requestKey);
|
||||
if (pending) return pending;
|
||||
|
||||
const request = queueCityDetailBatch(city, resParam)
|
||||
const request = queueCityDetailBatch(city, resParam, forceRefresh)
|
||||
.finally(() => {
|
||||
_hourlyRequestCache.delete(requestKey);
|
||||
});
|
||||
@@ -1250,6 +1313,9 @@ function getLiveObservationLabels(
|
||||
weatherStationCities.has(normalizedKey) ||
|
||||
/\b(mgm|turkey_mgm|jma_amedas|fmi|knmi|cowin_obs|ims|ncm|aeroweb|madis_hfmetar|singapore_mss)\b/.test(sourceTokens);
|
||||
const isRunwaySensorCity = runwaySensorCities.has(normalizedKey);
|
||||
const isAmscRunwayCity =
|
||||
AMSC_RUNWAY_CITIES.has(normalizedKey) ||
|
||||
/\bamsc(?:_awos)?\b|\bawos\b/.test(sourceTokens);
|
||||
const isWeatherStation = !runwaySensorCities.has(normalizedKey)
|
||||
&& !isHKO && !isShenzhen && !isTokyo && !isSingapore && !isParis && !isTaipei
|
||||
&& hasRealStationNetwork;
|
||||
@@ -1261,7 +1327,7 @@ function getLiveObservationLabels(
|
||||
: isParis ? "官方机场观测 (15分钟)"
|
||||
: isTaipei ? "CWA (10分钟)"
|
||||
: isWeatherStation ? "气象站实测"
|
||||
: isRunwaySensorCity ? "跑道实测 (1分钟)"
|
||||
: isRunwaySensorCity ? `跑道实测 (${isAmscRunwayCity ? "3分钟" : "1分钟"})`
|
||||
: "机场报文";
|
||||
|
||||
const metarHeaderLabel = (isShenzhen || isHKO) ? "天文台实测 (10分钟)"
|
||||
@@ -1311,6 +1377,7 @@ function mergePatchIntoHourly(
|
||||
...(prev || {
|
||||
forecastTodayHigh: null,
|
||||
debPrediction: null,
|
||||
debQuality: null,
|
||||
localDate: null,
|
||||
localTime: null,
|
||||
times: [],
|
||||
@@ -2124,6 +2191,8 @@ function buildFullDayChartData(
|
||||
return point;
|
||||
});
|
||||
|
||||
// Keep legacy Gaussian data as compact tooltip context. It is not rendered
|
||||
// as a time-series line on the live temperature chart.
|
||||
const probabilityOverlay = buildLegacyGaussianProbabilityOverlay(row, hourly);
|
||||
|
||||
return { data, series, probabilityOverlay };
|
||||
@@ -2463,6 +2532,7 @@ export {
|
||||
normalizeCityKey,
|
||||
prefersHighFrequencyRunwayResolution,
|
||||
readSessionCache,
|
||||
selectCompactSecondaryTemp,
|
||||
selectDisplayRunwayTemp,
|
||||
seedHourlyForecastFromRow,
|
||||
seriesStats,
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
.docsShell {
|
||||
min-height: 100vh;
|
||||
color: rgba(226, 232, 240, 0.94);
|
||||
background:
|
||||
linear-gradient(90deg, rgba(216, 224, 236, 0.38) 1px, transparent 1px),
|
||||
linear-gradient(180deg, #f8fbff 0%, #eef4fb 54%, #f5f8fc 100%);
|
||||
background-size: 44px 44px, auto;
|
||||
color: var(--color-text-primary, #0f172a);
|
||||
}
|
||||
|
||||
.docsHeader {
|
||||
@@ -10,10 +14,12 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 18px 24px;
|
||||
background: rgba(2, 6, 23, 0.82);
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.12);
|
||||
gap: 18px;
|
||||
min-height: 58px;
|
||||
padding: 10px 24px;
|
||||
border-bottom: 1px solid rgba(184, 196, 214, 0.74);
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
box-shadow: 0 1px 0 rgba(15, 23, 42, 0.04);
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
@@ -21,23 +27,31 @@
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.brandLink {
|
||||
color: #67e8f9;
|
||||
font-size: 1.9rem;
|
||||
font-weight: 800;
|
||||
color: #0f172a;
|
||||
font-size: 1.24rem;
|
||||
font-weight: 850;
|
||||
letter-spacing: 0;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.brandLink::first-letter {
|
||||
color: var(--color-accent-primary, #2563eb);
|
||||
}
|
||||
|
||||
.brandSubtitle {
|
||||
color: rgba(148, 163, 184, 0.88);
|
||||
font-size: 0.95rem;
|
||||
color: #64748b;
|
||||
font-size: 0.88rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.headerActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
@@ -46,87 +60,133 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
min-height: 38px;
|
||||
padding: 0 14px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||
background: rgba(15, 23, 42, 0.75);
|
||||
color: rgba(226, 232, 240, 0.95);
|
||||
padding: 0 13px;
|
||||
border: 1px solid var(--color-border-default, #d8e0ec);
|
||||
border-radius: 10px;
|
||||
background: var(--color-bg-raised, #ffffff);
|
||||
color: #1e293b;
|
||||
text-decoration: none;
|
||||
font-size: 0.92rem;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 160ms ease,
|
||||
color 160ms ease,
|
||||
background 160ms ease,
|
||||
box-shadow 160ms ease;
|
||||
}
|
||||
|
||||
.headerGhost {
|
||||
background: transparent;
|
||||
.headerButton:hover,
|
||||
.headerGhost:hover {
|
||||
border-color: #93c5fd;
|
||||
color: #1d4ed8;
|
||||
background: #eff6ff;
|
||||
box-shadow: 0 8px 18px rgba(37, 99, 235, 0.1);
|
||||
}
|
||||
|
||||
.headerGhost svg,
|
||||
.mobileMenuButton svg {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.langSwitch {
|
||||
display: flex;
|
||||
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
padding: 3px;
|
||||
border: 1px solid var(--color-border-default, #d8e0ec);
|
||||
border-radius: 10px;
|
||||
background: #e8edf5;
|
||||
}
|
||||
|
||||
.langButton {
|
||||
min-width: 54px;
|
||||
min-height: 38px;
|
||||
min-width: 48px;
|
||||
min-height: 32px;
|
||||
padding: 0 9px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: rgba(148, 163, 184, 0.92);
|
||||
font-weight: 700;
|
||||
color: #64748b;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 850;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.langButtonActive {
|
||||
background: rgba(34, 211, 238, 0.16);
|
||||
color: #67e8f9;
|
||||
background: #ffffff;
|
||||
color: #0f172a;
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.docsFrame {
|
||||
display: grid;
|
||||
grid-template-columns: 280px minmax(0, 1fr) 220px;
|
||||
gap: 28px;
|
||||
padding: 24px;
|
||||
grid-template-columns: 268px minmax(0, 850px) 226px;
|
||||
justify-content: center;
|
||||
gap: 24px;
|
||||
width: 100%;
|
||||
max-width: 1480px;
|
||||
margin: 0 auto;
|
||||
padding: 28px 24px 68px;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
position: sticky;
|
||||
top: 86px;
|
||||
top: 82px;
|
||||
align-self: start;
|
||||
max-height: calc(100vh - 110px);
|
||||
max-height: calc(100vh - 106px);
|
||||
overflow: auto;
|
||||
padding: 20px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.12);
|
||||
border-radius: 20px;
|
||||
background: rgba(15, 23, 42, 0.72);
|
||||
padding: 14px;
|
||||
border: 1px solid rgba(184, 196, 214, 0.72);
|
||||
border-radius: 10px;
|
||||
background: #ffffff;
|
||||
box-shadow: var(--shadow-elevation-2, 0 8px 24px rgba(15, 23, 42, 0.06));
|
||||
}
|
||||
|
||||
.sidebarGroup + .sidebarGroup {
|
||||
margin-top: 24px;
|
||||
margin-top: 18px;
|
||||
padding-top: 18px;
|
||||
border-top: 1px solid #e8edf5;
|
||||
}
|
||||
|
||||
.sidebarTitle {
|
||||
margin: 0 0 10px;
|
||||
color: rgba(148, 163, 184, 0.92);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
margin: 0 0 8px;
|
||||
padding: 0 8px;
|
||||
color: #64748b;
|
||||
font-size: 0.74rem;
|
||||
font-weight: 850;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.sidebarLink {
|
||||
position: relative;
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
color: rgba(226, 232, 240, 0.88);
|
||||
margin-bottom: 3px;
|
||||
padding: 9px 10px 9px 14px;
|
||||
border-radius: 8px;
|
||||
color: #334155;
|
||||
text-decoration: none;
|
||||
font-size: 0.95rem;
|
||||
font-size: 0.92rem;
|
||||
font-weight: 650;
|
||||
line-height: 1.35;
|
||||
transition:
|
||||
background 160ms ease,
|
||||
color 160ms ease,
|
||||
box-shadow 160ms ease;
|
||||
}
|
||||
|
||||
.sidebarLink:hover {
|
||||
background: #f1f5f9;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.sidebarLinkActive {
|
||||
background: rgba(34, 211, 238, 0.12);
|
||||
color: #67e8f9;
|
||||
background: #eff6ff;
|
||||
color: #1d4ed8;
|
||||
font-weight: 850;
|
||||
box-shadow: inset 3px 0 0 var(--color-accent-primary, #2563eb);
|
||||
}
|
||||
|
||||
.content {
|
||||
@@ -134,84 +194,215 @@
|
||||
}
|
||||
|
||||
.contentInner {
|
||||
padding: 12px 0 48px;
|
||||
padding: 0 0 52px;
|
||||
}
|
||||
|
||||
.pageIntro {
|
||||
padding: 28px 30px;
|
||||
border: 1px solid rgba(184, 196, 214, 0.72);
|
||||
border-radius: 12px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(248, 251, 255, 0.96));
|
||||
box-shadow: var(--shadow-elevation-2, 0 8px 24px rgba(15, 23, 42, 0.06));
|
||||
}
|
||||
|
||||
.introKicker {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 0 0 14px;
|
||||
color: #047857;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.introKicker svg {
|
||||
color: #00897b;
|
||||
}
|
||||
|
||||
.pageTitle {
|
||||
margin: 0;
|
||||
font-size: clamp(2.1rem, 4vw, 3.2rem);
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.03em;
|
||||
color: #0f172a;
|
||||
font-size: clamp(2rem, 3vw, 2.72rem);
|
||||
font-weight: 850;
|
||||
letter-spacing: 0;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.pageDescription {
|
||||
max-width: 820px;
|
||||
max-width: 760px;
|
||||
margin: 14px 0 0;
|
||||
color: rgba(191, 219, 254, 0.9);
|
||||
font-size: 1.03rem;
|
||||
color: #475569;
|
||||
font-size: 1.02rem;
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
.pageMeta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.pageMeta span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 30px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid #dbeafe;
|
||||
border-radius: 8px;
|
||||
background: #eff6ff;
|
||||
color: #1d4ed8;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-top: 40px;
|
||||
scroll-margin-top: 110px;
|
||||
margin-top: 0;
|
||||
padding: 34px 2px 30px;
|
||||
border-bottom: 1px solid rgba(216, 224, 236, 0.78);
|
||||
scroll-margin-top: 92px;
|
||||
}
|
||||
|
||||
.section:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
position: relative;
|
||||
margin: 0 0 16px;
|
||||
font-size: 1.65rem;
|
||||
font-weight: 700;
|
||||
padding-left: 14px;
|
||||
color: #172033;
|
||||
font-size: 1.34rem;
|
||||
font-weight: 850;
|
||||
letter-spacing: 0;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.sectionTitle::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0.16em;
|
||||
bottom: 0.16em;
|
||||
width: 4px;
|
||||
border-radius: 4px;
|
||||
background: linear-gradient(180deg, #2563eb, #00897b);
|
||||
}
|
||||
|
||||
.paragraph {
|
||||
margin: 0 0 16px;
|
||||
color: rgba(226, 232, 240, 0.94);
|
||||
line-height: 1.9;
|
||||
max-width: 780px;
|
||||
margin: 0 0 15px;
|
||||
color: #334155;
|
||||
font-size: 1rem;
|
||||
line-height: 1.85;
|
||||
}
|
||||
|
||||
.callout {
|
||||
margin: 18px 0;
|
||||
padding: 18px 20px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.16);
|
||||
border-radius: 18px;
|
||||
background: rgba(15, 23, 42, 0.72);
|
||||
position: relative;
|
||||
max-width: 790px;
|
||||
margin: 20px 0;
|
||||
padding: 17px 18px 17px 20px;
|
||||
border: 1px solid #d8e0ec;
|
||||
border-left-width: 4px;
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
box-shadow: 0 8px 22px rgba(15, 23, 42, 0.05);
|
||||
}
|
||||
|
||||
.calloutInfo {
|
||||
border-color: rgba(34, 211, 238, 0.24);
|
||||
background: rgba(8, 47, 73, 0.26);
|
||||
border-left-color: var(--color-accent-primary, #2563eb);
|
||||
background: #f5f9ff;
|
||||
}
|
||||
|
||||
.calloutWarning {
|
||||
border-color: rgba(251, 191, 36, 0.24);
|
||||
background: rgba(120, 53, 15, 0.2);
|
||||
border-left-color: #d97706;
|
||||
background: #fffaf0;
|
||||
}
|
||||
|
||||
.calloutSuccess {
|
||||
border-color: rgba(52, 211, 153, 0.24);
|
||||
background: rgba(6, 78, 59, 0.22);
|
||||
border-left-color: #00897b;
|
||||
background: #f0fdfa;
|
||||
}
|
||||
|
||||
.calloutTitle {
|
||||
margin: 0 0 8px;
|
||||
margin: 0 0 7px;
|
||||
color: #0f172a;
|
||||
font-size: 0.98rem;
|
||||
font-weight: 700;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.calloutText {
|
||||
margin: 0;
|
||||
line-height: 1.8;
|
||||
color: #334155;
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
.list {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
color: rgba(226, 232, 240, 0.94);
|
||||
line-height: 1.85;
|
||||
max-width: 800px;
|
||||
margin: 0 0 18px;
|
||||
padding: 0;
|
||||
color: #334155;
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
.list li + li {
|
||||
margin-top: 8px;
|
||||
margin-top: 9px;
|
||||
}
|
||||
|
||||
.bulletList {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.bulletList li {
|
||||
position: relative;
|
||||
padding-left: 22px;
|
||||
}
|
||||
|
||||
.bulletList li::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 3px;
|
||||
top: 0.72em;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 999px;
|
||||
background: #00897b;
|
||||
box-shadow: 0 0 0 4px rgba(0, 137, 123, 0.1);
|
||||
}
|
||||
|
||||
.stepList {
|
||||
list-style: none;
|
||||
counter-reset: docs-step;
|
||||
}
|
||||
|
||||
.stepList li {
|
||||
counter-increment: docs-step;
|
||||
display: grid;
|
||||
grid-template-columns: 32px minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid rgba(216, 224, 236, 0.88);
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
}
|
||||
|
||||
.stepList li::before {
|
||||
content: counter(docs-step);
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
background: #0f172a;
|
||||
color: #ffffff;
|
||||
font-size: 0.76rem;
|
||||
font-weight: 900;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.linkCard {
|
||||
@@ -219,54 +410,91 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 42px;
|
||||
padding: 0 16px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(34, 211, 238, 0.34);
|
||||
background: rgba(8, 47, 73, 0.3);
|
||||
color: #67e8f9;
|
||||
padding: 0 15px;
|
||||
border: 1px solid #93c5fd;
|
||||
border-radius: 10px;
|
||||
background: #eff6ff;
|
||||
color: #1d4ed8;
|
||||
text-decoration: none;
|
||||
font-weight: 700;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.linkCard::after {
|
||||
content: "->";
|
||||
margin-left: 8px;
|
||||
font-family: var(--font-mono, monospace);
|
||||
}
|
||||
|
||||
.linkCard:hover {
|
||||
background: rgba(34, 211, 238, 0.12);
|
||||
border-color: #60a5fa;
|
||||
background: #dbeafe;
|
||||
}
|
||||
|
||||
.linkCaption {
|
||||
margin: 10px 0 0;
|
||||
color: rgba(148, 163, 184, 0.92);
|
||||
line-height: 1.75;
|
||||
color: #64748b;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.content figure {
|
||||
max-width: 800px;
|
||||
margin: 22px 0;
|
||||
}
|
||||
|
||||
.content img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
border: 1px solid #d8e0ec;
|
||||
border-radius: 10px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.content figcaption {
|
||||
margin-top: 9px;
|
||||
color: #64748b;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.toc {
|
||||
position: sticky;
|
||||
top: 86px;
|
||||
top: 82px;
|
||||
align-self: start;
|
||||
padding: 20px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.12);
|
||||
border-radius: 20px;
|
||||
background: rgba(15, 23, 42, 0.64);
|
||||
padding: 14px;
|
||||
border: 1px solid rgba(184, 196, 214, 0.72);
|
||||
border-radius: 10px;
|
||||
background: #ffffff;
|
||||
box-shadow: var(--shadow-elevation-1, 0 1px 3px rgba(15, 23, 42, 0.05));
|
||||
}
|
||||
|
||||
.tocTitle {
|
||||
margin: 0 0 12px;
|
||||
color: rgba(148, 163, 184, 0.92);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
margin: 0 0 10px;
|
||||
color: #64748b;
|
||||
font-size: 0.74rem;
|
||||
font-weight: 850;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.tocLink {
|
||||
display: block;
|
||||
color: rgba(226, 232, 240, 0.86);
|
||||
padding: 7px 0 7px 12px;
|
||||
border-left: 2px solid #e8edf5;
|
||||
color: #475569;
|
||||
text-decoration: none;
|
||||
line-height: 1.7;
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.45;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.tocLink:hover {
|
||||
border-left-color: #2563eb;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.mobileMenuButton {
|
||||
display: none;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.mobileSidebarBackdrop {
|
||||
@@ -275,7 +503,8 @@
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.docsFrame {
|
||||
grid-template-columns: 260px minmax(0, 1fr);
|
||||
grid-template-columns: 248px minmax(0, 850px);
|
||||
justify-content: start;
|
||||
}
|
||||
|
||||
.toc {
|
||||
@@ -287,10 +516,12 @@
|
||||
.docsHeader {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.docsFrame {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
padding-top: 18px;
|
||||
}
|
||||
|
||||
.mobileMenuButton {
|
||||
@@ -302,10 +533,12 @@
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 60;
|
||||
width: min(320px, 88vw);
|
||||
width: min(326px, 88vw);
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
max-height: none;
|
||||
border-radius: 0 20px 20px 0;
|
||||
padding: 18px 14px;
|
||||
border-radius: 0 10px 10px 0;
|
||||
transform: translateX(-100%);
|
||||
transition: transform 180ms ease-out;
|
||||
}
|
||||
@@ -318,27 +551,65 @@
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 50;
|
||||
background: rgba(2, 6, 23, 0.52);
|
||||
display: block;
|
||||
border: 0;
|
||||
background: rgba(15, 23, 42, 0.48);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.docsHeader {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.docsFrame {
|
||||
padding: 16px;
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.brandWrap {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.pageDescription {
|
||||
.brandSubtitle {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.headerActions {
|
||||
width: 100%;
|
||||
align-items: stretch;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.headerGhost,
|
||||
.langSwitch {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.langSwitch {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.docsFrame {
|
||||
padding: 14px 14px 48px;
|
||||
}
|
||||
|
||||
.pageIntro {
|
||||
padding: 22px 18px;
|
||||
}
|
||||
|
||||
.pageTitle {
|
||||
font-size: 1.9rem;
|
||||
}
|
||||
|
||||
.pageDescription,
|
||||
.paragraph {
|
||||
font-size: 0.98rem;
|
||||
}
|
||||
|
||||
.section {
|
||||
padding: 28px 0 26px;
|
||||
}
|
||||
|
||||
.stepList li {
|
||||
grid-template-columns: 30px minmax(0, 1fr);
|
||||
padding: 11px 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import Link from "next/link";
|
||||
import { useMemo, useState } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import clsx from "clsx";
|
||||
import { ArrowLeft, BookOpen, Menu } from "lucide-react";
|
||||
import styles from "./DocsLayout.module.css";
|
||||
import {
|
||||
DocsLocale,
|
||||
@@ -30,6 +31,7 @@ function DocsHeader() {
|
||||
|
||||
<div className={styles.headerActions}>
|
||||
<Link href="/" className={styles.headerGhost}>
|
||||
<ArrowLeft size={16} aria-hidden="true" />
|
||||
{locale === "zh-CN" ? "返回主站" : "Back to App"}
|
||||
</Link>
|
||||
<div className={styles.langSwitch} role="group" aria-label="Language switch">
|
||||
@@ -81,6 +83,7 @@ function DocsSidebar({
|
||||
key={page.slug}
|
||||
href={href}
|
||||
className={clsx(styles.sidebarLink, currentSlug === page.slug && styles.sidebarLinkActive)}
|
||||
aria-current={currentSlug === page.slug ? "page" : undefined}
|
||||
onClick={onClose}
|
||||
>
|
||||
{title}
|
||||
@@ -114,20 +117,30 @@ function BlockRenderer({ block }: { block: DocsPageContent["sections"][number]["
|
||||
return <p className={styles.paragraph}>{block.text}</p>;
|
||||
case "callout":
|
||||
return (
|
||||
<div className={clsx(styles.callout, block.tone === "warning" && styles.calloutWarning, block.tone === "success" && styles.calloutSuccess, (!block.tone || block.tone === "info") && styles.calloutInfo)}>
|
||||
<div
|
||||
className={clsx(styles.callout, block.tone === "warning" && styles.calloutWarning, block.tone === "success" && styles.calloutSuccess, (!block.tone || block.tone === "info") && styles.calloutInfo)}
|
||||
role="note"
|
||||
>
|
||||
{block.title ? <div className={styles.calloutTitle}>{block.title}</div> : null}
|
||||
<p className={styles.calloutText}>{block.text}</p>
|
||||
</div>
|
||||
);
|
||||
case "bullets":
|
||||
case "steps":
|
||||
return (
|
||||
<ul className={styles.list}>
|
||||
<ul className={clsx(styles.list, styles.bulletList)}>
|
||||
{block.items.map((item) => (
|
||||
<li key={item}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
case "steps":
|
||||
return (
|
||||
<ol className={clsx(styles.list, styles.stepList)}>
|
||||
{block.items.map((item) => (
|
||||
<li key={item}>{item}</li>
|
||||
))}
|
||||
</ol>
|
||||
);
|
||||
case "link":
|
||||
return (
|
||||
<div>
|
||||
@@ -175,10 +188,22 @@ export function DocsScreen({ page }: { page: DocsPage }) {
|
||||
<main className={styles.content}>
|
||||
<div className={styles.contentInner}>
|
||||
<button type="button" className={clsx(styles.headerButton, styles.mobileMenuButton)} onClick={() => setMobileSidebarOpen(true)}>
|
||||
{locale === "zh-CN" ? "打开导航" : "Open navigation"}
|
||||
<Menu size={16} aria-hidden="true" />
|
||||
{locale === "zh-CN" ? "目录" : "Contents"}
|
||||
</button>
|
||||
<h1 className={styles.pageTitle}>{localizedPage.title}</h1>
|
||||
<p className={styles.pageDescription}>{localizedPage.description}</p>
|
||||
<div className={styles.pageIntro}>
|
||||
<div className={styles.introKicker}>
|
||||
<BookOpen size={16} aria-hidden="true" />
|
||||
{locale === "zh-CN" ? "PolyWeather 产品手册" : "PolyWeather Product Manual"}
|
||||
</div>
|
||||
<h1 className={styles.pageTitle}>{localizedPage.title}</h1>
|
||||
<p className={styles.pageDescription}>{localizedPage.description}</p>
|
||||
<div className={styles.pageMeta} aria-label={locale === "zh-CN" ? "文档范围" : "Document scope"}>
|
||||
<span>{locale === "zh-CN" ? "当前工作台" : "Current terminal"}</span>
|
||||
<span>{locale === "zh-CN" ? `${DOCS_PAGES.length} 篇文档` : `${DOCS_PAGES.length} docs`}</span>
|
||||
<span>{locale === "zh-CN" ? `${localizedPage.sections.length} 个章节` : `${localizedPage.sections.length} sections`}</span>
|
||||
</div>
|
||||
</div>
|
||||
{localizedPage.sections.map((section) => (
|
||||
<section key={section.id} id={section.id} className={styles.section}>
|
||||
<h2 className={styles.sectionTitle}>{section.title}</h2>
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
function assert(condition: unknown, message: string): asserts condition {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
export function runTests() {
|
||||
const cssPath = path.join(process.cwd(), "components", "docs", "DocsLayout.module.css");
|
||||
const source = fs.readFileSync(cssPath, "utf8");
|
||||
|
||||
assert(
|
||||
source.includes("background:") && source.includes("#f8fbff") && source.includes("#eef4fb"),
|
||||
"docs shell must define a light page background instead of inheriting mixed app styles",
|
||||
);
|
||||
assert(source.includes(".pageTitle") && source.includes("color: #0f172a"), "docs title must use high-contrast dark text");
|
||||
assert(source.includes(".paragraph") && source.includes("color: #334155"), "docs body copy must use readable dark text");
|
||||
assert(source.includes(".sidebar") && source.includes("background: #ffffff"), "docs sidebar must use a solid light surface");
|
||||
assert(source.includes(".toc") && source.includes("background: #ffffff"), "docs table of contents must use a solid light surface");
|
||||
assert(!source.includes("color: rgba(226, 232, 240"), "docs layout must not use dark-theme pale text on a light page");
|
||||
}
|
||||
@@ -12,25 +12,25 @@ import {
|
||||
} from "@/components/landing/landingLocale";
|
||||
|
||||
const COVERAGE_EN = [
|
||||
"Live airport observations",
|
||||
"DEB blend forecast",
|
||||
"Model-implied distribution",
|
||||
"Intraday observation windows",
|
||||
"Deviation checks and risk thresholds",
|
||||
"Paid Telegram alerts",
|
||||
"AMOS 60s runway sensors",
|
||||
"AMSC 180s runway endpoints",
|
||||
"MADIS 300s airport observations",
|
||||
"CoWIN 60s + HKO 600s",
|
||||
"Live chart updates",
|
||||
"Short Telegram alerts",
|
||||
];
|
||||
|
||||
const COVERAGE_ZH = [
|
||||
"机场实况观测数据",
|
||||
"DEB 智能融合预报",
|
||||
"模型隐含分布预测",
|
||||
"日内分段观测窗口",
|
||||
"偏差校验与风控阈值",
|
||||
"付费 Telegram 实时通知",
|
||||
"AMOS 60s 跑道传感器",
|
||||
"AMSC 180s 跑道端点",
|
||||
"MADIS 300s 机场观测",
|
||||
"CoWIN 60s + HKO 600s",
|
||||
"网页图表实时更新",
|
||||
"Telegram 简短提醒",
|
||||
];
|
||||
|
||||
const PRO_FEATURES_EN = [
|
||||
"METAR airport observations and runway-level reference data",
|
||||
"Settlement-source-first airport, official-station, and runway observations",
|
||||
"DEB blend forecast with model-spread context",
|
||||
"Model-implied distribution and probability estimates",
|
||||
"Intraday windows, deviation metrics, and settlement context",
|
||||
@@ -39,7 +39,7 @@ const PRO_FEATURES_EN = [
|
||||
];
|
||||
|
||||
const PRO_FEATURES_ZH = [
|
||||
"METAR 机场实测与跑道级参考数据",
|
||||
"结算源优先的机场、官方站与跑道实测",
|
||||
"DEB 智能融合预报与模型分歧背景",
|
||||
"模型隐含分布预测与概率估算",
|
||||
"日内观测窗口、偏差度量与结算背景",
|
||||
@@ -170,8 +170,8 @@ function WeatherWorkflowIllustration() {
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-x-0 top-16 z-0 mx-auto hidden h-[240px] max-w-6xl overflow-hidden md:block"
|
||||
>
|
||||
<div className="absolute left-8 top-14 h-24 w-24 rotate-[-7deg] rounded-lg border-2 border-slate-900 bg-[#fff3b0] shadow-[6px_6px_0_rgba(15,23,42,0.12)]" />
|
||||
<div className="absolute right-14 top-10 h-20 w-28 rotate-[6deg] rounded-lg border-2 border-slate-900 bg-[#dff8ea] shadow-[6px_6px_0_rgba(15,23,42,0.12)]" />
|
||||
<div className="landing-float absolute left-8 top-14 h-24 w-24 rotate-[-7deg] rounded-lg border-2 border-slate-900 bg-[#fff3b0] shadow-[6px_6px_0_rgba(15,23,42,0.12)]" />
|
||||
<div className="landing-float-slow absolute right-14 top-10 h-20 w-28 rotate-[6deg] rounded-lg border-2 border-slate-900 bg-[#dff8ea] shadow-[6px_6px_0_rgba(15,23,42,0.12)]" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -249,8 +249,11 @@ function InstitutionalLandingScreen({ locale }: { locale: LandingLocale }) {
|
||||
<LandingAnalytics />
|
||||
<header className="sticky top-0 z-30 border-b border-slate-200 bg-[#fbfbfa]/90 backdrop-blur-xl">
|
||||
<div className="mx-auto flex h-14 max-w-6xl items-center justify-between px-4 sm:px-6">
|
||||
<Link href="/" className="flex items-center gap-2 transition-opacity hover:opacity-80">
|
||||
<img src="/logo.png" alt="PolyWeather" className="h-7 w-auto object-contain" />
|
||||
<Link
|
||||
href="/"
|
||||
className="text-base font-black tracking-tight text-slate-950 transition-opacity hover:opacity-80 sm:text-lg"
|
||||
>
|
||||
PolyWeather
|
||||
</Link>
|
||||
|
||||
<nav className="hidden items-center gap-7 text-sm font-medium text-slate-500 md:flex">
|
||||
@@ -260,6 +263,12 @@ function InstitutionalLandingScreen({ locale }: { locale: LandingLocale }) {
|
||||
<a href="#coverage" className="hover:text-slate-950">
|
||||
{isEn ? "Data" : "数据"}
|
||||
</a>
|
||||
<a href="#screenshots" className="hover:text-slate-950">
|
||||
{isEn ? "Screens" : "截图"}
|
||||
</a>
|
||||
<Link href="/docs/chart-guide" className="hover:text-slate-950">
|
||||
{isEn ? "Guide" : "读图"}
|
||||
</Link>
|
||||
<a href="#pricing" className="hover:text-slate-950">
|
||||
{isEn ? "Pricing" : "定价"}
|
||||
</a>
|
||||
@@ -274,27 +283,29 @@ function InstitutionalLandingScreen({ locale }: { locale: LandingLocale }) {
|
||||
<WeatherWorkflowIllustration />
|
||||
<div className="relative z-10 mx-auto max-w-6xl">
|
||||
<div className="mx-auto max-w-3xl text-center">
|
||||
<h1 className="text-5xl font-black leading-[1.04] tracking-tight text-slate-950 sm:text-6xl lg:text-7xl">
|
||||
<h1 className="landing-rise text-5xl font-black leading-[1.04] tracking-tight text-slate-950 sm:text-6xl lg:text-7xl">
|
||||
PolyWeather
|
||||
</h1>
|
||||
<p className="mx-auto mt-6 max-w-2xl text-lg leading-8 text-slate-600 sm:text-xl">
|
||||
<p className="landing-rise landing-delay-1 mx-auto mt-6 max-w-2xl text-lg leading-8 text-slate-600 sm:text-xl">
|
||||
{isEn
|
||||
? "A calmer way to read airport weather, model forecasts, and intraday risk before the market moves."
|
||||
: "用更轻松的方式阅读机场天气、模型预报和日内风险,在市场变化前完成判断。"}
|
||||
? "A settlement-source-first terminal for temperature markets: live airport/runway observations, DEB, market buckets, and alerts in one workflow."
|
||||
: "面向温度市场的结算源优先终端:机场/跑道实测、DEB、市场温度桶和提醒放在同一个工作流里。"}
|
||||
</p>
|
||||
<LandingHeroActions locale={locale} />
|
||||
<p className="mt-4 text-sm text-slate-500">
|
||||
<div className="landing-rise landing-delay-2">
|
||||
<LandingHeroActions locale={locale} />
|
||||
</div>
|
||||
<p className="landing-rise landing-delay-3 mt-4 text-sm text-slate-500">
|
||||
{isEn
|
||||
? "Start with a one-time 3-day trial. Trial access matches Pro except for the paid Telegram group link."
|
||||
: "新用户可先领一次 3 天试用。试用期权益和 Pro 一致,除了不显示付费 Telegram 群链接。"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto mt-14 max-w-5xl rounded-lg border border-slate-200 bg-white p-2 shadow-[0_24px_70px_rgba(15,23,42,0.12)]">
|
||||
<div className="landing-float-slow landing-screen-glow mx-auto mt-14 max-w-5xl rounded-lg border border-slate-200 bg-white p-2 shadow-[0_24px_70px_rgba(15,23,42,0.12)]">
|
||||
<div className="flex h-9 items-center gap-2 border-b border-slate-200 px-3">
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-[#ff6b6b]" />
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-[#ffd166]" />
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-[#06d6a0]" />
|
||||
<span className="landing-pulse-dot h-2.5 w-2.5 rounded-full bg-[#06d6a0]" />
|
||||
<span className="ml-2 text-xs font-semibold text-slate-400">
|
||||
polyweather.app/terminal
|
||||
</span>
|
||||
@@ -318,7 +329,7 @@ function InstitutionalLandingScreen({ locale }: { locale: LandingLocale }) {
|
||||
{heroStats.map((item) => (
|
||||
<div
|
||||
key={item.label}
|
||||
className="rounded-lg border border-slate-200 bg-white px-4 py-4 shadow-sm"
|
||||
className="landing-hover-lift rounded-lg border border-slate-200 bg-white px-4 py-4 shadow-sm"
|
||||
>
|
||||
<div className="font-mono text-lg font-black text-slate-950">
|
||||
{item.value}
|
||||
@@ -338,14 +349,14 @@ function InstitutionalLandingScreen({ locale }: { locale: LandingLocale }) {
|
||||
</p>
|
||||
<h2 className="mt-3 text-3xl font-black tracking-tight text-slate-950 sm:text-4xl">
|
||||
{isEn
|
||||
? "Like a tidy workspace for weather decisions."
|
||||
: "像整理好的工作区一样阅读天气决策。"}
|
||||
? "Built around the station that actually matters."
|
||||
: "围绕真正会影响结算的站点构建。"}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="mt-12 grid gap-4 md:grid-cols-3">
|
||||
{platformCards.map(({ body, icon, title }) => (
|
||||
<article key={title} className="rounded-lg border border-slate-200 bg-[#fbfbfa] p-6 shadow-sm">
|
||||
<article key={title} className="landing-hover-lift rounded-lg border border-slate-200 bg-[#fbfbfa] p-6 shadow-sm">
|
||||
<div className="mb-5 inline-flex h-10 w-10 items-center justify-center rounded-md border border-slate-200 bg-white text-slate-800">
|
||||
<LandingIcon name={icon} size={19} />
|
||||
</div>
|
||||
@@ -354,6 +365,100 @@ function InstitutionalLandingScreen({ locale }: { locale: LandingLocale }) {
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-10 rounded-lg border border-slate-200 bg-[#fbfbfa] p-5">
|
||||
<div className="grid gap-4 lg:grid-cols-[0.8fr_1.2fr] lg:items-start">
|
||||
<div>
|
||||
<p className="text-xs font-bold uppercase tracking-[0.18em] text-slate-400">
|
||||
{isEn ? "Differentiation" : "差异化卖点"}
|
||||
</p>
|
||||
<h3 className="mt-3 text-2xl font-black tracking-tight text-slate-950">
|
||||
{isEn ? "Settlement-source first, not generic weather." : "结算源优先,不做泛天气看板。"}
|
||||
</h3>
|
||||
<p className="mt-3 text-sm leading-7 text-slate-600">
|
||||
{isEn
|
||||
? "The product is built around the station, runway, and update cadence that affect settlement, then layers DEB, market buckets, and alerting on top."
|
||||
: "产品围绕真正影响结算的站点、跑道和源头频率构建,再叠加 DEB、市场温度桶和提醒工作流。"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{(isEn
|
||||
? [
|
||||
"Runway-level China and Korea observation context",
|
||||
"Hong Kong CoWIN + HKO dual-source reading",
|
||||
"Live pages update as new source readings arrive",
|
||||
"Telegram sends concise alerts without noisy refresh loops",
|
||||
]
|
||||
: [
|
||||
"中国和韩国跑道级实测上下文",
|
||||
"香港 CoWIN + HKO 双源读数",
|
||||
"源头有新读数时,网页自动补上变化",
|
||||
"Telegram 只发简短提醒,避免噪音刷屏",
|
||||
]
|
||||
).map((item) => (
|
||||
<div key={item} className="landing-hover-lift rounded-md border border-slate-200 bg-white px-4 py-3 text-sm font-semibold leading-6 text-slate-700">
|
||||
{item}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="screenshots" className="border-b border-slate-200 bg-[#fbfbfa] px-4 py-20 sm:px-6">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<div className="max-w-2xl">
|
||||
<p className="text-xs font-bold uppercase tracking-[0.18em] text-slate-400">
|
||||
{isEn ? "Product Screenshots" : "产品截图"}
|
||||
</p>
|
||||
<h2 className="mt-3 text-3xl font-black tracking-tight text-slate-950 sm:text-4xl">
|
||||
{isEn ? "The terminal and alerts are the product." : "终端和提醒就是核心产品。"}
|
||||
</h2>
|
||||
<p className="mt-4 text-sm leading-7 text-slate-600">
|
||||
{isEn
|
||||
? "See what you will use before subscribing: a browser terminal for live temperature evidence, plus short Telegram alerts when runway or settlement signals change."
|
||||
: "订阅前先看清你会用到什么:网页上看实时温度证据;跑道或结算源有变化时,在 Telegram 收到简短提醒。"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-10 grid gap-5 lg:grid-cols-[1.4fr_0.8fr]">
|
||||
<figure className="landing-hover-lift rounded-lg border border-slate-200 bg-white p-2 shadow-sm">
|
||||
<div className="aspect-[16/9] overflow-hidden rounded-md border border-slate-100 bg-slate-100">
|
||||
<img
|
||||
src="/static/web.webp"
|
||||
width="680"
|
||||
height="340"
|
||||
alt={isEn ? "Realtime terminal screenshot" : "实时终端截图"}
|
||||
className="h-full w-full object-cover object-top transition duration-500 hover:scale-[1.015]"
|
||||
decoding="async"
|
||||
loading="lazy"
|
||||
sizes="(min-width: 1024px) 760px, calc(100vw - 48px)"
|
||||
/>
|
||||
</div>
|
||||
<figcaption className="px-2 py-3 text-xs font-semibold text-slate-500">
|
||||
{isEn ? "Browser terminal: settlement observations, DEB, source cadence, and market context." : "浏览器终端:结算实测、DEB、源头频率和市场上下文。"}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<figure className="landing-hover-lift rounded-lg border border-slate-200 bg-white p-2 shadow-sm">
|
||||
<div className="aspect-[9/16] max-h-[520px] overflow-hidden rounded-md border border-slate-100 bg-slate-100">
|
||||
<img
|
||||
src="/static/tel.png"
|
||||
width="420"
|
||||
height="640"
|
||||
alt={isEn ? "Telegram runway alert screenshot" : "Telegram 跑道提醒截图"}
|
||||
className="h-full w-full object-cover object-top transition duration-500 hover:scale-[1.015]"
|
||||
decoding="async"
|
||||
loading="lazy"
|
||||
sizes="(min-width: 1024px) 340px, calc(100vw - 48px)"
|
||||
/>
|
||||
</div>
|
||||
<figcaption className="px-2 py-3 text-xs font-semibold text-slate-500">
|
||||
{isEn ? "Telegram alerts: concise runway and settlement-source updates for paid users." : "Telegram 提醒:为付费用户提供简洁的跑道与结算源更新。"}
|
||||
</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -364,8 +469,27 @@ function InstitutionalLandingScreen({ locale }: { locale: LandingLocale }) {
|
||||
{isEn ? "Data Coverage" : "数据覆盖"}
|
||||
</p>
|
||||
<h2 className="mt-3 text-3xl font-black tracking-tight text-slate-950 sm:text-4xl">
|
||||
{isEn ? "Keep the signal clear and the page approachable." : "信号清楚,页面也可以亲和。"}
|
||||
{isEn ? "Refresh cadence follows the source, not a fake timer." : "刷新频率跟随源头,不伪装成统一定时器。"}
|
||||
</h2>
|
||||
<p className="mt-4 text-sm leading-7 text-slate-600">
|
||||
{isEn
|
||||
? "PolyWeather follows each source's real update rhythm. The website refreshes the visible charts as new readings arrive, while Telegram keeps alerts short and readable."
|
||||
: "PolyWeather 跟随每个数据源自己的更新节奏。网页图表会补上最新读数,Telegram 只保留短提醒,让你快速知道哪里变了。"}
|
||||
</p>
|
||||
<div className="mt-6 flex flex-wrap gap-2">
|
||||
<Link
|
||||
href="/docs/chart-guide"
|
||||
className="inline-flex h-10 items-center justify-center rounded-md border border-slate-200 bg-white px-3 text-sm font-bold text-slate-700 shadow-sm hover:border-slate-300 hover:text-slate-950"
|
||||
>
|
||||
{isEn ? "Read chart guide" : "查看读图指南"}
|
||||
</Link>
|
||||
<Link
|
||||
href="/docs/realtime-sources"
|
||||
className="inline-flex h-10 items-center justify-center rounded-md border border-slate-200 bg-white px-3 text-sm font-bold text-slate-700 shadow-sm hover:border-slate-300 hover:text-slate-950"
|
||||
>
|
||||
{isEn ? "Source cadence" : "数据源频率"}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-slate-200 bg-white p-3 shadow-sm">
|
||||
@@ -373,7 +497,7 @@ function InstitutionalLandingScreen({ locale }: { locale: LandingLocale }) {
|
||||
{coverage.map((item, index) => (
|
||||
<div
|
||||
key={item}
|
||||
className="flex items-center gap-3 rounded-md border border-slate-100 bg-[#fbfbfa] px-4 py-3"
|
||||
className="landing-hover-lift flex items-center gap-3 rounded-md border border-slate-100 bg-[#fbfbfa] px-4 py-3"
|
||||
>
|
||||
<span
|
||||
className={`grid h-8 w-8 place-items-center rounded-md ${
|
||||
@@ -408,7 +532,7 @@ function InstitutionalLandingScreen({ locale }: { locale: LandingLocale }) {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-12 grid gap-4 md:grid-cols-3">
|
||||
<div className="mt-12 grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="flex flex-col rounded-lg border border-slate-200 bg-[#fbfbfa] p-6 shadow-sm">
|
||||
<div className="flex items-center gap-2 text-sm font-bold text-emerald-700">
|
||||
<LandingIcon name="clock" />
|
||||
@@ -440,20 +564,31 @@ function InstitutionalLandingScreen({ locale }: { locale: LandingLocale }) {
|
||||
Pro
|
||||
</div>
|
||||
<h3 className="mt-5 text-2xl font-black text-slate-950">
|
||||
{isEn ? "Pro Monthly" : "Pro 月付"}
|
||||
Pro
|
||||
</h3>
|
||||
<p className="mt-3 text-sm leading-7 text-slate-600">
|
||||
{isEn
|
||||
? "Full Pro access for 30 days, including paid Telegram group eligibility."
|
||||
: "完整 Pro 权限 30 天,包含付费 Telegram 群准入资格。"}
|
||||
? "Full terminal access, chart guides, advanced context, and paid Telegram group eligibility."
|
||||
: "完整终端权限、读图指南、高级上下文和付费 Telegram 群准入资格。"}
|
||||
</p>
|
||||
<div className="mt-7 flex items-baseline gap-2">
|
||||
<span className="font-mono text-5xl font-black text-slate-950">29.9</span>
|
||||
<span className="text-sm font-semibold text-slate-500">USDC / 30 天</span>
|
||||
<div className="mt-7 space-y-2">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="font-mono text-4xl font-black text-slate-950">29.9</span>
|
||||
<span className="text-sm font-semibold text-slate-500">USDC / 30 天</span>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="font-mono text-2xl font-black text-slate-950">79.9</span>
|
||||
<span className="text-sm font-semibold text-slate-500">USDC / 90 天</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-2 text-xs font-semibold text-slate-500">
|
||||
{isEn ? "Referral first month: 20 USDC" : "使用邀请码首月 20 USDC"}
|
||||
</p>
|
||||
<div className="mt-4 rounded-md border border-slate-200 bg-slate-50 px-4 py-3 text-xs font-semibold text-slate-600">
|
||||
{isEn
|
||||
? "Invite reward: referrer gets +3500 points when invitee subscribes."
|
||||
: "邀请奖励:被邀请人付费后,邀请人 +3500 积分。"}
|
||||
</div>
|
||||
<ul className="mt-7 space-y-3 border-t border-slate-200 pt-6">
|
||||
{(isEn ? PRO_FEATURES_EN : PRO_FEATURES_ZH).map((feature) => (
|
||||
<li key={feature} className="flex items-start gap-3">
|
||||
@@ -478,30 +613,51 @@ function InstitutionalLandingScreen({ locale }: { locale: LandingLocale }) {
|
||||
<div className="flex flex-col rounded-lg border border-slate-200 bg-[#fbfbfa] p-6 shadow-sm">
|
||||
<div className="flex items-center gap-2 text-sm font-bold text-amber-700">
|
||||
<LandingIcon name="lineChart" />
|
||||
{isEn ? "Quarterly" : "季度"}
|
||||
API
|
||||
</div>
|
||||
<h3 className="mt-5 text-2xl font-black text-slate-950">
|
||||
{isEn ? "Pro Quarterly" : "Pro 季度"}
|
||||
{isEn ? "API" : "API"}
|
||||
</h3>
|
||||
<p className="mt-3 flex-1 text-sm leading-7 text-slate-600">
|
||||
{isEn
|
||||
? "90 days of Pro access for users with steady usage. Lower cost per month."
|
||||
: "90 天 Pro 权限,适合稳定使用的个人和团队,折算月成本更低。"}
|
||||
? "Not sold as a public product right now. The public site and Telegram workflow remain the supported product surface."
|
||||
: "目前不作为公开产品售卖。当前支持的产品形态仍是网站终端和 Telegram 工作流。"}
|
||||
</p>
|
||||
<div className="mt-7 flex items-baseline gap-2">
|
||||
<span className="font-mono text-5xl font-black text-slate-950">79.9</span>
|
||||
<span className="text-sm font-semibold text-slate-500">USDC / 90 天</span>
|
||||
</div>
|
||||
<div className="mt-5 rounded-md border border-slate-200 bg-white px-4 py-3 text-xs font-semibold text-slate-600">
|
||||
{isEn
|
||||
? "Invite reward: referrer gets +3500 points when invitee subscribes."
|
||||
: "邀请奖励:被邀请人付费后,邀请人 +3500 积分。"}
|
||||
<div className="mt-7 rounded-md border border-slate-200 bg-white px-4 py-3 text-xs font-semibold text-slate-600">
|
||||
{isEn ? "Not for sale. We will revisit API packaging only after endpoint docs, keys, limits, and support boundaries are ready." : "暂不售卖。只有接口文档、key、限额和支持边界准备好后,才重新评估 API 产品化。"}
|
||||
</div>
|
||||
<Link
|
||||
href="/account"
|
||||
href="/docs/realtime-sources"
|
||||
className="mt-8 inline-flex h-11 items-center justify-center gap-2 rounded-md border border-slate-200 bg-white text-sm font-bold text-slate-700 shadow-sm hover:border-slate-300 hover:text-slate-950"
|
||||
>
|
||||
{isEn ? "Choose quarterly" : "选择季度 Pro"}
|
||||
{isEn ? "Read data guide" : "查看数据指南"}
|
||||
<LandingIcon name="arrow" size={15} />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col rounded-lg border border-slate-200 bg-[#fbfbfa] p-6 shadow-sm">
|
||||
<div className="flex items-center gap-2 text-sm font-bold text-violet-700">
|
||||
<LandingIcon name="shield" />
|
||||
Team
|
||||
</div>
|
||||
<h3 className="mt-5 text-2xl font-black text-slate-950">
|
||||
{isEn ? "Team" : "团队"}
|
||||
</h3>
|
||||
<p className="mt-3 flex-1 text-sm leading-7 text-slate-600">
|
||||
{isEn
|
||||
? "For teams that need shared access, Telegram workflow support, and manual onboarding."
|
||||
: "面向需要共享权限、Telegram 工作流支持和人工开通的团队。"}
|
||||
</p>
|
||||
<div className="mt-7 rounded-md border border-slate-200 bg-white px-4 py-3 text-xs font-semibold text-slate-600">
|
||||
{isEn
|
||||
? "Custom seat and group setup. Best for teams already using the terminal together."
|
||||
: "自定义席位和群组配置,适合已经一起使用终端的团队。"}
|
||||
</div>
|
||||
<Link
|
||||
href="/subscription-help"
|
||||
className="mt-8 inline-flex h-11 items-center justify-center gap-2 rounded-md border border-slate-200 bg-white text-sm font-bold text-slate-700 shadow-sm hover:border-slate-300 hover:text-slate-950"
|
||||
>
|
||||
{isEn ? "Talk to us" : "联系开通"}
|
||||
<LandingIcon name="arrow" size={15} />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -49,6 +49,12 @@ export function runTests() {
|
||||
assert(!source.includes("高频刷新与 API 仍为 Pro 权益"), "landing page must not incorrectly exclude high-frequency refresh or API from trial access");
|
||||
assert(source.includes("bg-[#fbfbfa]"), "landing page must use a light Notion-style background");
|
||||
assert(source.includes("WeatherWorkflowIllustration"), "landing page must include a friendly illustration surface");
|
||||
assert(
|
||||
/className="text-base font-black tracking-tight text-slate-950[\s\S]*?>\s*PolyWeather\s*<\/Link>/.test(
|
||||
source,
|
||||
),
|
||||
"landing header must expose a readable PolyWeather wordmark instead of a tiny square logo image",
|
||||
);
|
||||
assert(!fs.existsSync(publicPngPath), "heavy PNG preview must not remain in public static assets");
|
||||
assert(fs.existsSync(fixturePngPath), "PNG preview may only remain as a test fixture");
|
||||
assert(fs.existsSync(webpPath), "landing page must ship a WebP preview image for the LCP product screenshot");
|
||||
@@ -57,6 +63,9 @@ export function runTests() {
|
||||
"WebP preview must be materially smaller than the PNG LCP image",
|
||||
);
|
||||
assert(source.includes("/static/web.webp"), "landing page must load the lighter WebP product preview image");
|
||||
assert(source.includes("/static/tel.png"), "landing page must include the Telegram alert screenshot");
|
||||
assert(source.includes("#screenshots"), "landing navigation must expose the product screenshot section");
|
||||
assert(source.includes("结算源优先") && source.includes("差异化卖点"), "landing page must explain the differentiated settlement-source positioning");
|
||||
assert(!source.includes('src="/static/web.png"'), "landing hero must not use the heavy PNG as its primary LCP image");
|
||||
assert(
|
||||
source.includes('width="680"') &&
|
||||
@@ -73,6 +82,10 @@ export function runTests() {
|
||||
);
|
||||
assert(source.includes("29.9") && source.includes("30 天"), "landing page must show monthly Pro pricing");
|
||||
assert(source.includes("79.9") && source.includes("90 天"), "landing page must show quarterly Pro pricing");
|
||||
assert(source.includes("API") && source.includes("暂不售卖"), "landing page must describe API as not currently for sale");
|
||||
assert(!source.includes("Request API") && !source.includes("申请 API"), "landing page must not invite users to buy or request API access");
|
||||
assert(source.includes("Team") && source.includes("团队"), "landing page must describe the Team tier");
|
||||
assert(source.includes("Trial") && source.includes("Pro") && source.includes("API") && source.includes("Team"), "landing pricing ladder must clearly name Trial / Pro / API / Team");
|
||||
assert(source.includes("20 USDC") && source.includes("+3500 积分"), "landing page must describe referral discount and reward");
|
||||
assert(!source.includes("AI 气象证据链解读"), "legacy AI evidence-chain wording must be removed");
|
||||
assert(!source.includes("AI weather evidence"), "legacy AI evidence wording must be removed");
|
||||
|
||||
@@ -41,6 +41,11 @@ export function runTests() {
|
||||
const detailBatchProxy = readFrontend("app", "api", "cities", "detail-batch", "route.ts");
|
||||
assert.match(detailBatchProxy, /createProxyTimer\(req,\s*"city_detail_batch"\)/);
|
||||
assert.match(detailBatchProxy, /timing:\s*timer/);
|
||||
assert.match(
|
||||
detailBatchProxy,
|
||||
/POLYWEATHER_CITY_DETAIL_BATCH_PROXY_TIMEOUT_MS\s*\|\|\s*"15000"/,
|
||||
"city detail batch proxy should leave room for backend partial responses instead of aborting at the chart fetch deadline",
|
||||
);
|
||||
assert.match(
|
||||
detailBatchProxy,
|
||||
/fetchCache:\s*"no-store"/,
|
||||
@@ -51,6 +56,26 @@ export function runTests() {
|
||||
/cacheControlForData/,
|
||||
"city detail batch proxy should be able to suppress response caching for partial payloads",
|
||||
);
|
||||
assert.match(
|
||||
detailBatchProxy,
|
||||
/buildCityDetailBatchTimeoutPayload/,
|
||||
"city detail batch proxy should degrade proxy timeouts into the same partial payload shape the chart client already tolerates",
|
||||
);
|
||||
assert.match(
|
||||
detailBatchProxy,
|
||||
/missing:\s*requestedCities/,
|
||||
"city detail batch proxy timeout fallback should mark requested cities as missing instead of surfacing a browser 504",
|
||||
);
|
||||
assert.match(
|
||||
detailBatchProxy,
|
||||
/partial:\s*true/,
|
||||
"city detail batch proxy timeout fallback should preserve partial response semantics",
|
||||
);
|
||||
assert.match(
|
||||
detailBatchProxy,
|
||||
/status:\s*200/,
|
||||
"city detail batch proxy timeout fallback should avoid red 504 fetch failures for optional chart enrichment",
|
||||
);
|
||||
assert.match(
|
||||
apiProxySource,
|
||||
/cacheControlForData\?:/,
|
||||
@@ -60,6 +85,16 @@ export function runTests() {
|
||||
const scanTerminalProxy = readFrontend("app", "api", "scan", "terminal", "route.ts");
|
||||
assert.match(scanTerminalProxy, /createProxyTimer\(req,\s*"scan_terminal"\)/);
|
||||
assert.match(scanTerminalProxy, /timing:\s*timer/);
|
||||
assert.match(
|
||||
scanTerminalProxy,
|
||||
/POLYWEATHER_SCAN_TERMINAL_PROXY_TIMEOUT_MS\s*\|\|\s*"35000"/,
|
||||
"scan terminal proxy should allow the production backend enough time to return before the 45 second route cap",
|
||||
);
|
||||
assert.match(
|
||||
scanTerminalProxy,
|
||||
/export const maxDuration = 45/,
|
||||
"scan terminal proxy timeout budget should remain below the Next route execution cap",
|
||||
);
|
||||
|
||||
const cityDetailProxy = readFrontend("app", "api", "city", "[name]", "detail", "route.ts");
|
||||
assert.match(cityDetailProxy, /createProxyTimer\(req,\s*"city_detail"\)/);
|
||||
|
||||
@@ -24,6 +24,12 @@ export function runTests() {
|
||||
authMeRouteSource.includes("total"),
|
||||
"/api/auth/me proxy must expose stage durations through Server-Timing for HAR inspection",
|
||||
);
|
||||
assert(
|
||||
authMeRouteSource.includes('response.headers.set("Cache-Control", "no-store")') &&
|
||||
authMeRouteSource.indexOf('response.headers.set("Cache-Control", "no-store")') >
|
||||
authMeRouteSource.indexOf("function finishAuthMeResponse"),
|
||||
"/api/auth/me proxy must mark every auth profile response no-store so anonymous state cannot be reused after login",
|
||||
);
|
||||
const finishStart = authMeRouteSource.indexOf("function finishAuthMeResponse");
|
||||
const finishEnd = authMeRouteSource.indexOf("async function trackAuthDiagnosticEvent");
|
||||
const finishSource =
|
||||
|
||||
@@ -33,7 +33,9 @@ export function runTests() {
|
||||
paymentsPage.includes("Intent 卡住") &&
|
||||
paymentsPage.includes("积分异常") &&
|
||||
paymentsPage.includes("推荐奖励结算") &&
|
||||
paymentsPage.includes("月度邀请封顶"),
|
||||
paymentsPage.includes("月度邀请封顶") &&
|
||||
paymentsPage.includes("occurrence_count") &&
|
||||
paymentsPage.includes("支付事件不匹配"),
|
||||
"ops payment page must surface trial, stuck intent, referral, points, and monthly-cap risk signals",
|
||||
);
|
||||
assert(
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
function assert(condition: unknown, message: string): asserts condition {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
export function runTests() {
|
||||
const projectRoot = process.cwd();
|
||||
const feedbackPagePath = path.join(
|
||||
projectRoot,
|
||||
"components",
|
||||
"ops",
|
||||
"feedback",
|
||||
"FeedbackPageClient.tsx",
|
||||
);
|
||||
const source = fs.readFileSync(feedbackPagePath, "utf8");
|
||||
|
||||
assert(
|
||||
source.includes("<select") &&
|
||||
source.includes("onChange={(event) =>") &&
|
||||
source.includes("changeStatus(row, event.target.value)") &&
|
||||
source.includes("disabled={updatingId === row.id}") &&
|
||||
source.includes("STATUS_UPDATE_OPTIONS.map"),
|
||||
"ops feedback action column must use a status dropdown",
|
||||
);
|
||||
assert(
|
||||
!source.includes("标为{feedbackActionLabel(row.status)}") &&
|
||||
!source.includes("advanceStatus(row)"),
|
||||
"ops feedback action column must not use one-step status buttons",
|
||||
);
|
||||
assert(
|
||||
source.includes("积分奖励标准") &&
|
||||
source.includes("REWARD_GUIDELINES") &&
|
||||
source.includes("100") &&
|
||||
source.includes("300") &&
|
||||
source.includes("500") &&
|
||||
source.includes("1000") &&
|
||||
source.includes("1500"),
|
||||
"ops feedback page must document fixed reward point guidelines",
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
function assert(condition: unknown, message: string): asserts condition {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
export function runTests() {
|
||||
const projectRoot = process.cwd();
|
||||
const feedbackPageSource = fs.readFileSync(
|
||||
path.join(projectRoot, "components", "ops", "feedback", "FeedbackPageClient.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
const opsApiSource = fs.readFileSync(path.join(projectRoot, "lib", "ops-api.ts"), "utf8");
|
||||
const rewardRoutePath = path.join(
|
||||
projectRoot,
|
||||
"app",
|
||||
"api",
|
||||
"ops",
|
||||
"feedback",
|
||||
"[feedbackId]",
|
||||
"reward",
|
||||
"route.ts",
|
||||
);
|
||||
|
||||
assert(fs.existsSync(rewardRoutePath), "ops feedback reward proxy route must exist");
|
||||
assert(
|
||||
opsApiSource.includes("grantFeedbackReward") &&
|
||||
opsApiSource.includes("/api/ops/feedback/${feedbackId}/reward") &&
|
||||
opsApiSource.includes("JSON.stringify({ points })"),
|
||||
"ops API client must expose grantFeedbackReward with fixed points only",
|
||||
);
|
||||
assert(
|
||||
feedbackPageSource.includes("REWARD_POINT_OPTIONS") &&
|
||||
feedbackPageSource.includes("handleRewardGrant") &&
|
||||
feedbackPageSource.includes("发放奖励") &&
|
||||
feedbackPageSource.includes("opsApi.grantFeedbackReward"),
|
||||
"ops feedback page must provide fixed-point reward grant controls",
|
||||
);
|
||||
assert(
|
||||
feedbackPageSource.includes('value: 100') &&
|
||||
feedbackPageSource.includes('value: 300') &&
|
||||
feedbackPageSource.includes('value: 500') &&
|
||||
feedbackPageSource.includes('value: 1000') &&
|
||||
feedbackPageSource.includes('value: 1500'),
|
||||
"ops feedback page must use fixed reward point options",
|
||||
);
|
||||
assert(
|
||||
!feedbackPageSource.includes("奖励原因") &&
|
||||
!feedbackPageSource.includes("rewardDrafts") &&
|
||||
!feedbackPageSource.includes("reason:"),
|
||||
"ops feedback page must not ask operators for reward reasons",
|
||||
);
|
||||
assert(
|
||||
feedbackPageSource.includes("已发放") &&
|
||||
feedbackPageSource.includes("reward_points"),
|
||||
"ops feedback page must show existing feedback reward details",
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,10 @@ export function runTests() {
|
||||
path.join(projectRoot, "app", "api", "ops", "source-health", "route.ts"),
|
||||
"utf8",
|
||||
);
|
||||
const collectorRoute = fs.readFileSync(
|
||||
path.join(projectRoot, "app", "api", "ops", "observation-collector-status", "route.ts"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
assert(
|
||||
opsApi.includes("sourceHealth") &&
|
||||
@@ -40,4 +44,23 @@ export function runTests() {
|
||||
nextRoute.includes("no-store"),
|
||||
"source health proxy must stay ops-admin protected and uncached",
|
||||
);
|
||||
assert(
|
||||
opsApi.includes("observationCollectorStatus") &&
|
||||
opsApi.includes("/api/ops/observation-collector-status"),
|
||||
"ops client must expose observation collector status endpoint",
|
||||
);
|
||||
assert(
|
||||
systemPage.includes("观测采集器") &&
|
||||
systemPage.includes("collectorStatus") &&
|
||||
systemPage.includes("failure_count") &&
|
||||
systemPage.includes("last_latency_ms") &&
|
||||
systemPage.includes("冷却"),
|
||||
"ops system page must show observation collector failures, latency, and cooldown status",
|
||||
);
|
||||
assert(
|
||||
collectorRoute.includes("requireOpsProxyAuth") &&
|
||||
collectorRoute.includes("/api/ops/observation-collector-status") &&
|
||||
collectorRoute.includes("no-store"),
|
||||
"observation collector proxy must stay ops-admin protected and uncached",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
function readFrontend(...parts: string[]) {
|
||||
return fs.readFileSync(path.join(process.cwd(), ...parts), "utf8");
|
||||
}
|
||||
|
||||
export function runTests() {
|
||||
const trainingPage = readFrontend("components", "ops", "training", "TrainingPageClient.tsx");
|
||||
const charts = readFrontend("components", "ops", "training", "TrainingAccuracyCharts.tsx");
|
||||
|
||||
assert.match(
|
||||
trainingPage,
|
||||
/buildDebRecentRankingRows/,
|
||||
"ops training page should share the DEB usable-recent ranking helper with the terminal dashboard",
|
||||
);
|
||||
assert.match(
|
||||
trainingPage,
|
||||
/debRecentRanked/,
|
||||
"ops training page should build chart rows from the usable-recent DEB ranking",
|
||||
);
|
||||
assert.match(
|
||||
trainingPage,
|
||||
/debRecentRankIndex/,
|
||||
"ops training detail table should follow the same usable-recent DEB order before falling back to historical scores",
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
trainingPage,
|
||||
/sort\(\s*\(a,\s*b\)\s*=>\s*\(\(b\.deb\?\.hit_rate/,
|
||||
"ops training DEB chart must not regress to sorting by long-term historical hit rate",
|
||||
);
|
||||
|
||||
assert.match(
|
||||
charts,
|
||||
/DEB 可用近期命中率 by 城市/,
|
||||
"ops DEB hit-rate chart should label the metric as usable recent accuracy",
|
||||
);
|
||||
assert.match(
|
||||
charts,
|
||||
/DEB 可用近期 MAE by 城市/,
|
||||
"ops DEB MAE chart should label the metric as usable recent MAE",
|
||||
);
|
||||
assert.match(
|
||||
charts,
|
||||
/可用近期命中率/,
|
||||
"ops DEB hit-rate tooltip should describe the usable recent metric",
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
function assert(condition: unknown, message: string): asserts condition {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
export function runTests() {
|
||||
const projectRoot = process.cwd();
|
||||
const usersPageSource = fs.readFileSync(
|
||||
path.join(projectRoot, "components", "ops", "users", "UsersPageClient.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
assert(
|
||||
usersPageSource.includes("leaderboardName") &&
|
||||
usersPageSource.includes("String(entry.username || \"\").trim()") &&
|
||||
usersPageSource.includes("TG${entry.telegram_id}"),
|
||||
"ops users leaderboard must fall back when username is blank",
|
||||
);
|
||||
assert(
|
||||
usersPageSource.includes("本周暂无积分记录") &&
|
||||
usersPageSource.includes("本周积分") &&
|
||||
!usersPageSource.includes("entry.username ?? `TG${entry.telegram_id}`"),
|
||||
"ops users leaderboard must show weekly points clearly and avoid zero-point fake rankings",
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Bug, CheckCircle2, Coins, MessageSquare, RefreshCcw } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { opsApi } from "@/lib/ops-api";
|
||||
import type { UserFeedbackEntry, UserFeedbackPayload } from "@/types/ops";
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ key: "", label: "全部" },
|
||||
{ key: "open", label: "新建" },
|
||||
{ key: "triaged", label: "已确认" },
|
||||
{ key: "investigating", label: "处理中" },
|
||||
{ key: "resolved", label: "已解决" },
|
||||
{ key: "closed", label: "关闭" },
|
||||
] as const;
|
||||
|
||||
const STATUS_UPDATE_OPTIONS = STATUS_OPTIONS.filter((item) => item.key);
|
||||
|
||||
const REWARD_POINT_OPTIONS = [
|
||||
{ value: 100, label: "100 分", title: "轻量提醒" },
|
||||
{ value: 300, label: "300 分", title: "可复现 Bug" },
|
||||
{ value: 500, label: "500 分", title: "有效数据问题" },
|
||||
{ value: 1000, label: "1000 分", title: "高影响问题" },
|
||||
{ value: 1500, label: "1500 分", title: "重大事故" },
|
||||
] as const;
|
||||
|
||||
const REWARD_GUIDELINES = [
|
||||
{ points: "0", title: "无效/重复", detail: "重复反馈、无法复现、非问题" },
|
||||
{ points: "100", title: "轻量提醒", detail: "文案、体验、小范围提示" },
|
||||
{ points: "300", title: "可复现 Bug", detail: "加载失败、操作异常、局部影响" },
|
||||
{ points: "500", title: "有效数据问题", detail: "城市数据、图表、关键变量异常" },
|
||||
{ points: "1000", title: "高影响问题", detail: "支付、账号、订阅、核心终端异常" },
|
||||
{ points: "1500", title: "重大事故", detail: "大面积不可用或严重业务损失,谨慎使用" },
|
||||
] as const;
|
||||
|
||||
function compactDate(value?: string) {
|
||||
if (!value) return "—";
|
||||
return value.slice(0, 19).replace("T", " ");
|
||||
}
|
||||
|
||||
function categoryLabel(value?: string) {
|
||||
const key = String(value || "").toLowerCase();
|
||||
if (key === "bug") return "Bug";
|
||||
if (key === "data") return "数据";
|
||||
if (key === "idea") return "建议";
|
||||
if (key === "payment") return "支付";
|
||||
if (key === "account") return "账号";
|
||||
return "其他";
|
||||
}
|
||||
|
||||
function statusLabel(value?: string) {
|
||||
const key = String(value || "open").toLowerCase();
|
||||
if (key === "open") return "新建";
|
||||
if (key === "triaged") return "已确认";
|
||||
if (key === "investigating") return "处理中";
|
||||
if (key === "resolved") return "已解决";
|
||||
if (key === "closed") return "关闭";
|
||||
return key;
|
||||
}
|
||||
|
||||
function statusTone(value?: string) {
|
||||
const key = String(value || "open").toLowerCase();
|
||||
if (key === "open") return "border-red-200 bg-red-50 text-red-700";
|
||||
if (key === "triaged") return "border-amber-200 bg-amber-50 text-amber-700";
|
||||
if (key === "investigating") return "border-blue-200 bg-blue-50 text-blue-700";
|
||||
if (key === "resolved") return "border-emerald-200 bg-emerald-50 text-emerald-700";
|
||||
return "border-slate-200 bg-slate-50 text-slate-600";
|
||||
}
|
||||
|
||||
function contextSummary(context?: Record<string, unknown>) {
|
||||
if (!context) return "—";
|
||||
const city = String(context.city || context.display_city || "").trim();
|
||||
const slot = context.slot_index != null ? `slot ${context.slot_index}` : "";
|
||||
const source = String(context.source || "").trim();
|
||||
const pieces = [city, slot, source].filter(Boolean);
|
||||
return pieces.length ? pieces.join(" · ") : "terminal";
|
||||
}
|
||||
|
||||
export function FeedbackPageClient() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [filter, setFilter] = useState("");
|
||||
const [payload, setPayload] = useState<UserFeedbackPayload | null>(null);
|
||||
const [updatingId, setUpdatingId] = useState<number | null>(null);
|
||||
const [rewardingId, setRewardingId] = useState<number | null>(null);
|
||||
const [rewardPointsById, setRewardPointsById] = useState<Record<number, string>>({});
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const data = (await opsApi.feedback(120, filter)) as UserFeedbackPayload;
|
||||
setPayload(data);
|
||||
} catch (err) {
|
||||
setError(String(err).slice(0, 220));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [filter]);
|
||||
|
||||
const rows = payload?.feedback || [];
|
||||
const counts = payload?.status_counts || {};
|
||||
const openCount = Number(counts.open || 0);
|
||||
const activeCount = Number(counts.open || 0) + Number(counts.triaged || 0) + Number(counts.investigating || 0);
|
||||
|
||||
const categoryCounts = useMemo(() => {
|
||||
const acc: Record<string, number> = {};
|
||||
rows.forEach((row) => {
|
||||
const key = String(row.category || "other");
|
||||
acc[key] = (acc[key] || 0) + 1;
|
||||
});
|
||||
return acc;
|
||||
}, [rows]);
|
||||
|
||||
const changeStatus = async (row: UserFeedbackEntry, next: string) => {
|
||||
const current = String(row.status || "open").toLowerCase();
|
||||
if (!next || next === current) return;
|
||||
setUpdatingId(row.id);
|
||||
try {
|
||||
await opsApi.updateFeedbackStatus(row.id, next);
|
||||
await load();
|
||||
} finally {
|
||||
setUpdatingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const updateRewardPoints = (rowId: number, points: string) => {
|
||||
setRewardPointsById((prev) => ({
|
||||
...prev,
|
||||
[rowId]: points,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleRewardGrant = async (row: UserFeedbackEntry) => {
|
||||
const selectedPoints = rewardPointsById[row.id] || String(REWARD_POINT_OPTIONS[1].value);
|
||||
const points = Number.parseInt(selectedPoints, 10);
|
||||
if (!row.user_email) {
|
||||
setError("这条反馈没有绑定用户邮箱,不能从反馈页直接发放积分。");
|
||||
return;
|
||||
}
|
||||
if (!Number.isFinite(points) || points <= 0) {
|
||||
setError("请输入有效的奖励积分。");
|
||||
return;
|
||||
}
|
||||
setRewardingId(row.id);
|
||||
setError("");
|
||||
try {
|
||||
await opsApi.grantFeedbackReward(row.id, points);
|
||||
setRewardPointsById((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[row.id];
|
||||
return next;
|
||||
});
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(String(err).slice(0, 220));
|
||||
} finally {
|
||||
setRewardingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading && !payload) {
|
||||
return <div className="text-slate-400 animate-pulse">加载中...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-white">用户反馈</h1>
|
||||
<Button variant="outline" size="sm" onClick={load} className="gap-1.5">
|
||||
<RefreshCcw className="h-3.5 w-3.5" /> 刷新
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
加载失败:{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-3 p-4">
|
||||
<Bug className="h-5 w-5 text-red-500" />
|
||||
<div>
|
||||
<div className="text-xs text-slate-500">新反馈</div>
|
||||
<div className="text-2xl font-black text-slate-950">{openCount}</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-3 p-4">
|
||||
<MessageSquare className="h-5 w-5 text-blue-500" />
|
||||
<div>
|
||||
<div className="text-xs text-slate-500">处理中</div>
|
||||
<div className="text-2xl font-black text-slate-950">{activeCount}</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-3 p-4">
|
||||
<CheckCircle2 className="h-5 w-5 text-emerald-500" />
|
||||
<div>
|
||||
<div className="text-xs text-slate-500">已解决</div>
|
||||
<div className="text-2xl font-black text-slate-950">{Number(counts.resolved || 0)}</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="text-xs text-slate-500">当前列表</div>
|
||||
<div className="mt-1 text-2xl font-black text-slate-950">{rows.length}</div>
|
||||
<div className="mt-1 text-xs text-slate-500">
|
||||
Bug {categoryCounts.bug || 0} · 数据 {categoryCounts.data || 0} · 建议 {categoryCounts.idea || 0}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle>积分奖励标准</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-2 md:grid-cols-3 xl:grid-cols-6">
|
||||
{REWARD_GUIDELINES.map((item) => (
|
||||
<div
|
||||
key={item.points}
|
||||
className="rounded-lg border border-slate-200 bg-slate-50 px-3 py-2"
|
||||
>
|
||||
<div className="font-mono text-sm font-black text-blue-700">
|
||||
{item.points} 分
|
||||
</div>
|
||||
<div className="mt-1 text-xs font-bold text-slate-900">
|
||||
{item.title}
|
||||
</div>
|
||||
<div className="mt-1 text-[11px] leading-4 text-slate-500">
|
||||
{item.detail}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-slate-500">
|
||||
先确认反馈是否有效;未复现的问题可先标为已确认/处理中,奖励发放后会自动记录到用户账户页。
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-3">
|
||||
<CardTitle>反馈收件箱</CardTitle>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{STATUS_OPTIONS.map((item) => (
|
||||
<button
|
||||
key={item.key || "all"}
|
||||
type="button"
|
||||
onClick={() => setFilter(item.key)}
|
||||
className={
|
||||
"rounded border px-2.5 py-1 text-xs font-bold transition " +
|
||||
(filter === item.key
|
||||
? "border-blue-300 bg-blue-50 text-blue-700"
|
||||
: "border-slate-200 bg-white text-slate-500 hover:bg-slate-50")
|
||||
}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{rows.length === 0 ? (
|
||||
<div className="rounded-lg border border-slate-200 bg-slate-50 px-4 py-8 text-center text-sm text-slate-500">
|
||||
暂无反馈。
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-200 text-left text-slate-500">
|
||||
<th className="py-2 pr-4 font-bold">状态</th>
|
||||
<th className="py-2 pr-4 font-bold">类型</th>
|
||||
<th className="py-2 pr-4 font-bold">内容</th>
|
||||
<th className="py-2 pr-4 font-bold">上下文</th>
|
||||
<th className="py-2 pr-4 font-bold">用户</th>
|
||||
<th className="py-2 pr-4 font-bold">时间</th>
|
||||
<th className="py-2 pr-4 font-bold">奖励</th>
|
||||
<th className="py-2 pr-4 font-bold">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => {
|
||||
const selectedPoints = rewardPointsById[row.id] || String(REWARD_POINT_OPTIONS[1].value);
|
||||
const rewardPoints = Number(row.reward_points || 0);
|
||||
const rewardStatus = String(row.reward_status || "").toLowerCase();
|
||||
const hasReward = rewardStatus === "granted" && rewardPoints > 0;
|
||||
return (
|
||||
<tr key={row.id} className="border-b border-slate-100 align-top">
|
||||
<td className="py-3 pr-4">
|
||||
<span className={`inline-flex rounded-md border px-2 py-1 text-xs font-bold ${statusTone(row.status)}`}>
|
||||
{statusLabel(row.status)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 pr-4 text-slate-500">{categoryLabel(row.category)}</td>
|
||||
<td className="max-w-xl py-3 pr-4">
|
||||
<div className="font-semibold leading-5 text-slate-900">{row.message || "—"}</div>
|
||||
{row.contact && <div className="mt-1 text-xs text-slate-500">联系:{row.contact}</div>}
|
||||
</td>
|
||||
<td className="py-3 pr-4">
|
||||
<div className="font-mono text-xs text-blue-700">{contextSummary(row.context)}</div>
|
||||
{Boolean(row.context?.detail_error) && (
|
||||
<div className="mt-1 max-w-xs text-xs text-amber-700">
|
||||
{String(row.context?.detail_error || "").slice(0, 120)}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3 pr-4 font-mono text-xs text-slate-500">
|
||||
{row.user_email || row.user_id || "—"}
|
||||
</td>
|
||||
<td className="whitespace-nowrap py-3 pr-4 text-xs text-slate-500">{compactDate(row.created_at)}</td>
|
||||
<td className="min-w-[170px] py-3 pr-4">
|
||||
{hasReward ? (
|
||||
<div className="rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2 text-xs">
|
||||
<div className="font-black text-emerald-700">
|
||||
已发放 +{rewardPoints.toLocaleString()} 分
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
<select
|
||||
value={selectedPoints}
|
||||
onChange={(event) => updateRewardPoints(row.id, event.target.value)}
|
||||
disabled={rewardingId === row.id || !row.user_email}
|
||||
className="h-8 w-full rounded border border-slate-200 bg-white px-2 text-xs font-bold text-slate-700 outline-none focus:border-blue-300 focus:ring-2 focus:ring-blue-100 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
aria-label="奖励积分"
|
||||
>
|
||||
{REWARD_POINT_OPTIONS.map((item) => (
|
||||
<option key={item.value} value={item.value}>
|
||||
{item.label} · {item.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleRewardGrant(row)}
|
||||
disabled={rewardingId === row.id || !row.user_email}
|
||||
className="h-8 gap-1.5"
|
||||
>
|
||||
<Coins className="h-3.5 w-3.5" />
|
||||
发放奖励
|
||||
</Button>
|
||||
{!row.user_email && (
|
||||
<div className="text-[11px] text-amber-700">无用户邮箱,无法直接发放。</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3 pr-4">
|
||||
<select
|
||||
value={String(row.status || "open").toLowerCase()}
|
||||
onChange={(event) => changeStatus(row, event.target.value)}
|
||||
disabled={updatingId === row.id}
|
||||
className="h-8 min-w-[108px] rounded border border-slate-200 bg-white px-2 text-xs font-bold text-slate-600 outline-none transition hover:bg-slate-50 focus:border-blue-300 focus:ring-2 focus:ring-blue-100 disabled:cursor-wait disabled:opacity-60"
|
||||
aria-label="更新反馈状态"
|
||||
>
|
||||
{STATUS_UPDATE_OPTIONS.map((item) => (
|
||||
<option key={item.key} value={item.key}>
|
||||
{item.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
ScrollText,
|
||||
Activity,
|
||||
ShieldAlert,
|
||||
MessageSquare,
|
||||
} from "lucide-react";
|
||||
|
||||
const navGroups = [
|
||||
@@ -33,6 +34,7 @@ const navGroups = [
|
||||
items: [
|
||||
{ href: "/ops/payments", icon: CreditCard, label: "支付管理" },
|
||||
{ href: "/ops/memberships", icon: UserCheck, label: "会员订阅" },
|
||||
{ href: "/ops/feedback", icon: MessageSquare, label: "用户反馈" },
|
||||
{ href: "/ops/telegram-audit", icon: ShieldAlert, label: "电报清理" },
|
||||
{ href: "/ops/users", icon: Users, label: "用户积分" },
|
||||
],
|
||||
|
||||
@@ -58,6 +58,8 @@ function paymentReasonLabel(reason?: string) {
|
||||
if (key === "tx_not_found") return "链上交易未找到";
|
||||
if (key === "tx_reverted") return "链上交易失败";
|
||||
if (key === "expired") return "订单已过期";
|
||||
if (key === "event_mismatch") return "支付事件不匹配";
|
||||
if (key === "direct_transfer_mismatch") return "直接转账不匹配";
|
||||
if (key === "unknown") return "未知原因";
|
||||
return key || "未知原因";
|
||||
}
|
||||
@@ -134,7 +136,7 @@ export function PaymentsPageClient() {
|
||||
const reasonCounts: Record<string, number> = {};
|
||||
incidents.forEach((inc) => {
|
||||
const r = inc.reason || "unknown";
|
||||
reasonCounts[r] = (reasonCounts[r] || 0) + 1;
|
||||
reasonCounts[r] = (reasonCounts[r] || 0) + Math.max(1, Number(inc.occurrence_count ?? 1));
|
||||
});
|
||||
|
||||
const incidentPieData = Object.entries(reasonCounts).map(([name, value]) => ({
|
||||
@@ -318,6 +320,11 @@ export function PaymentsPageClient() {
|
||||
<td className="py-2 pr-4 text-slate-500 font-mono">{inc.id}</td>
|
||||
<td className="py-2 pr-4">
|
||||
<div className="font-bold text-amber-600">{paymentReasonLabel(inc.reason)}</div>
|
||||
{Number(inc.occurrence_count ?? 1) > 1 ? (
|
||||
<div className="mt-0.5 text-[11px] font-semibold text-slate-400">
|
||||
同类重复 {Number(inc.occurrence_count).toLocaleString()} 次 · 最早 {compactDate(inc.first_seen_at)}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-0.5 max-w-xl truncate text-xs text-slate-500" title={inc.detail || inc.reason || ""}>
|
||||
{inc.detail || inc.reason || "—"}
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { AlertTriangle, RefreshCcw, ShieldCheck, Database, Cpu, HardDrive, RadioTower } from "lucide-react";
|
||||
import { Activity, AlertTriangle, RefreshCcw, ShieldCheck, Database, Cpu, HardDrive, RadioTower } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { opsApi } from "@/lib/ops-api";
|
||||
import type { SourceHealthPayload, SystemStatusPayload, HealthPayload } from "@/types/ops";
|
||||
import type {
|
||||
ObservationCollectorStatusPayload,
|
||||
SourceHealthPayload,
|
||||
SystemStatusPayload,
|
||||
HealthPayload,
|
||||
} from "@/types/ops";
|
||||
|
||||
function sourceStatusTone(status?: string) {
|
||||
if (status === "fresh") return "text-emerald-500";
|
||||
@@ -25,12 +30,48 @@ function sourceStatusLabel(status?: string) {
|
||||
return "未知";
|
||||
}
|
||||
|
||||
function collectorStatusTone(status?: string) {
|
||||
if (status === "ok") return "text-emerald-500";
|
||||
if (status === "due") return "text-blue-500";
|
||||
if (status === "cooldown") return "text-amber-500";
|
||||
if (status === "failed" || status === "never_run") return "text-red-500";
|
||||
return "text-slate-500";
|
||||
}
|
||||
|
||||
function collectorStatusLabel(status?: string) {
|
||||
if (status === "ok") return "正常";
|
||||
if (status === "due") return "到期";
|
||||
if (status === "cooldown") return "冷却";
|
||||
if (status === "failed") return "失败";
|
||||
if (status === "never_run") return "未采集";
|
||||
return "未知";
|
||||
}
|
||||
|
||||
function formatAge(ageMin?: number | null) {
|
||||
if (ageMin == null) return "—";
|
||||
if (ageMin < 60) return `${Math.round(ageMin)}m`;
|
||||
return `${(ageMin / 60).toFixed(1)}h`;
|
||||
}
|
||||
|
||||
function formatSeconds(seconds?: number | null) {
|
||||
if (seconds == null) return "—";
|
||||
if (seconds <= 0) return "已到期";
|
||||
if (seconds < 60) return `${Math.round(seconds)}s`;
|
||||
if (seconds < 3600) return `${Math.round(seconds / 60)}m`;
|
||||
return `${(seconds / 3600).toFixed(1)}h`;
|
||||
}
|
||||
|
||||
function formatLatency(ms?: number | null) {
|
||||
if (ms == null) return "—";
|
||||
if (ms < 1000) return `${Math.round(ms)}ms`;
|
||||
return `${(ms / 1000).toFixed(1)}s`;
|
||||
}
|
||||
|
||||
function formatTimestamp(value?: string | null) {
|
||||
if (!value) return "—";
|
||||
return value.replace("T", " ").replace("Z", "").slice(0, 19);
|
||||
}
|
||||
|
||||
function sourceReasonLabel(reason?: string | null) {
|
||||
const key = String(reason || "").trim().toLowerCase();
|
||||
if (key === "observation_time_missing") return "观测时间缺失";
|
||||
@@ -65,20 +106,23 @@ export function SystemPageClient() {
|
||||
const [health, setHealth] = useState<HealthPayload | null>(null);
|
||||
const [status, setStatus] = useState<SystemStatusPayload | null>(null);
|
||||
const [sourceHealth, setSourceHealth] = useState<SourceHealthPayload | null>(null);
|
||||
const [collectorStatus, setCollectorStatus] = useState<ObservationCollectorStatusPayload | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const [h, s, sh] = await Promise.all([
|
||||
const [h, s, sh, cs] = await Promise.all([
|
||||
opsApi.health(),
|
||||
opsApi.systemStatus() as Promise<SystemStatusPayload>,
|
||||
opsApi.sourceHealth(80) as Promise<SourceHealthPayload>,
|
||||
opsApi.observationCollectorStatus(200) as Promise<ObservationCollectorStatusPayload>,
|
||||
]);
|
||||
setHealth(h);
|
||||
setStatus(s);
|
||||
setSourceHealth(sh);
|
||||
setCollectorStatus(cs);
|
||||
} catch (e) {
|
||||
setError(String(e).slice(0, 200));
|
||||
} finally {
|
||||
@@ -100,6 +144,12 @@ export function SystemPageClient() {
|
||||
|
||||
const dbOk = status?.db?.ok ?? health?.db?.ok;
|
||||
const cacheAnalysis = status?.cache?.analysis;
|
||||
const collectorIssues = (collectorStatus?.entries || [])
|
||||
.filter((entry) => {
|
||||
const state = String(entry.status || "");
|
||||
return ["failed", "cooldown", "never_run", "due"].includes(state) || (entry.failure_count ?? 0) > 0;
|
||||
})
|
||||
.slice(0, 12);
|
||||
const sourceIssues = (sourceHealth?.cities || [])
|
||||
.flatMap((city) =>
|
||||
(city.sources || [])
|
||||
@@ -240,6 +290,96 @@ export function SystemPageClient() {
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Activity className="h-4 w-4 text-cyan-500" />
|
||||
观测采集器
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="mb-4 grid grid-cols-2 gap-3 text-sm sm:grid-cols-5">
|
||||
{["ok", "due", "cooldown", "failed", "never_run"].map((key) => (
|
||||
<div key={key} className="rounded-lg border border-slate-200 bg-slate-50 px-3 py-2">
|
||||
<div className="text-[11px] text-slate-500">{collectorStatusLabel(key)}</div>
|
||||
<div className={`text-lg font-black ${collectorStatusTone(key)}`}>
|
||||
{collectorStatus?.status_counts?.[key] ?? 0}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{(collectorStatus?.sources || []).length ? (
|
||||
<div className="mb-4 grid grid-cols-1 gap-3 text-xs md:grid-cols-2 xl:grid-cols-3">
|
||||
{(collectorStatus?.sources || []).map((source) => (
|
||||
<div key={source.source} className="rounded-lg border border-slate-200 bg-white px-3 py-2">
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<span className="font-mono text-sm font-black text-slate-800">{source.source}</span>
|
||||
<span className={`font-bold ${collectorStatusTone(source.worst_status)}`}>
|
||||
{collectorStatusLabel(source.worst_status)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-x-3 gap-y-1 text-slate-600">
|
||||
<span>城市 {source.city_count ?? 0}</span>
|
||||
<span>间隔 {source.min_interval_sec ?? source.interval_sec ?? "—"}s</span>
|
||||
<span>失败 {source.failure_count ?? 0}</span>
|
||||
<span>冷却 {source.cooldown_count ?? 0}</span>
|
||||
<span>延迟 {formatLatency(source.avg_latency_ms)}</span>
|
||||
<span title={source.last_success_at || ""}>成功 {formatTimestamp(source.last_success_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mb-4 rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm text-slate-500">
|
||||
暂无后台采集状态;collector 首次写入后会显示每个 source/city 的最近采集时间、失败次数、延迟和冷却状态。
|
||||
</div>
|
||||
)}
|
||||
|
||||
{collectorIssues.length ? (
|
||||
<div className="overflow-x-auto rounded-lg border border-slate-200">
|
||||
<table className="w-full min-w-[860px] text-left text-xs">
|
||||
<thead className="bg-slate-50 text-slate-500">
|
||||
<tr>
|
||||
<th className="px-3 py-2">Source</th>
|
||||
<th className="px-3 py-2">城市</th>
|
||||
<th className="px-3 py-2">状态</th>
|
||||
<th className="px-3 py-2">最近成功</th>
|
||||
<th className="px-3 py-2">失败次数</th>
|
||||
<th className="px-3 py-2">延迟</th>
|
||||
<th className="px-3 py-2">下次</th>
|
||||
<th className="px-3 py-2">错误</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{collectorIssues.map((entry) => (
|
||||
<tr key={`${entry.source}-${entry.city}`} className="border-t border-slate-100">
|
||||
<td className="px-3 py-2 font-mono font-bold text-slate-800">{entry.source}</td>
|
||||
<td className="px-3 py-2 font-mono text-slate-700">{entry.city}</td>
|
||||
<td className={`px-3 py-2 font-bold ${collectorStatusTone(entry.status)}`}>
|
||||
{collectorStatusLabel(entry.status)}
|
||||
</td>
|
||||
<td className="px-3 py-2 font-mono text-slate-600">{formatTimestamp(entry.last_success_at)}</td>
|
||||
<td className="px-3 py-2 font-mono text-slate-600">{entry.failure_count ?? 0}</td>
|
||||
<td className="px-3 py-2 font-mono text-slate-600">{formatLatency(entry.last_latency_ms)}</td>
|
||||
<td className="px-3 py-2 font-mono text-slate-600">{formatSeconds(entry.due_in_sec)}</td>
|
||||
<td className="max-w-[260px] truncate px-3 py-2 text-slate-500" title={entry.last_error || ""}>
|
||||
{entry.last_error || "—"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm font-semibold text-emerald-700">
|
||||
<ShieldCheck className="h-4 w-4" />
|
||||
当前后台采集器没有失败、冷却或到期积压。
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
|
||||
@@ -68,7 +68,7 @@ export function TrainingAccuracyCharts({
|
||||
{debChartData.length > 0 ? (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader><CardTitle>DEB 命中率 by 城市</CardTitle></CardHeader>
|
||||
<CardHeader><CardTitle>DEB 可用近期命中率 by 城市</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[400px]">
|
||||
<ResponsiveContainer>
|
||||
@@ -78,7 +78,7 @@ export function TrainingAccuracyCharts({
|
||||
<YAxis domain={[0, 100]} tick={{ fill: "#94a3b8", fontSize: 11 }} unit="%" />
|
||||
<Tooltip
|
||||
contentStyle={CHART_TOOLTIP_STYLE}
|
||||
formatter={(value: unknown) => [`${Number(value).toFixed(1)}%`, "命中率"]}
|
||||
formatter={(value: unknown) => [`${Number(value).toFixed(1)}%`, "可用近期命中率"]}
|
||||
/>
|
||||
<Bar dataKey="hitRate" radius={[4, 4, 0, 0]} maxBarSize={36}>
|
||||
{debChartData.map((entry, i) => (
|
||||
@@ -93,7 +93,7 @@ export function TrainingAccuracyCharts({
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader><CardTitle>DEB MAE by 城市</CardTitle></CardHeader>
|
||||
<CardHeader><CardTitle>DEB 可用近期 MAE by 城市</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[400px]">
|
||||
<ResponsiveContainer>
|
||||
@@ -103,7 +103,7 @@ export function TrainingAccuracyCharts({
|
||||
<YAxis tick={{ fill: "#94a3b8", fontSize: 11 }} unit="°" />
|
||||
<Tooltip
|
||||
contentStyle={CHART_TOOLTIP_STYLE}
|
||||
formatter={(value: unknown) => [`${Number(value).toFixed(1)}°`, "MAE"]}
|
||||
formatter={(value: unknown) => [`${Number(value).toFixed(1)}°`, "可用近期 MAE"]}
|
||||
/>
|
||||
<Bar dataKey="mae" radius={[4, 4, 0, 0]} maxBarSize={36}>
|
||||
{debChartData.map((entry, i) => (
|
||||
|
||||
@@ -6,6 +6,7 @@ import { RefreshCcw, TrendingUp, TrendingDown, Target, Activity } from "lucide-r
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { buildDebRecentRankingRows } from "@/lib/deb-training-ranking";
|
||||
import { opsApi } from "@/lib/ops-api";
|
||||
import type { SystemStatusPayload } from "@/types/ops";
|
||||
import Link from "next/link";
|
||||
@@ -56,6 +57,22 @@ interface CityAccuracy {
|
||||
total_days: number;
|
||||
details_str: string;
|
||||
} | null;
|
||||
deb_recent?: {
|
||||
recent_7d?: {
|
||||
hit_rate?: number | null;
|
||||
samples?: number;
|
||||
mae?: number | null;
|
||||
};
|
||||
recent_14d?: {
|
||||
hit_rate?: number | null;
|
||||
samples?: number;
|
||||
mae?: number | null;
|
||||
};
|
||||
trust_tier?: string;
|
||||
recommendation?: string;
|
||||
bias_direction?: string;
|
||||
reason?: string;
|
||||
} | null;
|
||||
mu?: {
|
||||
mae: number;
|
||||
hit_rate: number;
|
||||
@@ -65,20 +82,81 @@ interface CityAccuracy {
|
||||
} | null;
|
||||
}
|
||||
|
||||
interface DebSummary {
|
||||
historical?: {
|
||||
avg_hit_rate?: number | null;
|
||||
weighted_hit_rate?: number | null;
|
||||
avg_mae?: number | null;
|
||||
sample_days?: number;
|
||||
city_count?: number;
|
||||
};
|
||||
usable_recent?: {
|
||||
window?: string;
|
||||
city_count?: number;
|
||||
samples?: number;
|
||||
hits?: number;
|
||||
hit_rate?: number | null;
|
||||
avg_mae?: number | null;
|
||||
recommendations?: {
|
||||
primary?: number;
|
||||
supporting?: number;
|
||||
};
|
||||
};
|
||||
recent_7d?: {
|
||||
hit_rate?: number | null;
|
||||
mae?: number | null;
|
||||
samples?: number;
|
||||
hits?: number;
|
||||
};
|
||||
recent_14d?: {
|
||||
hit_rate?: number | null;
|
||||
mae?: number | null;
|
||||
samples?: number;
|
||||
hits?: number;
|
||||
};
|
||||
}
|
||||
|
||||
function debTrustBadgeClass(tier?: string) {
|
||||
if (tier === "high") return "bg-emerald-500/15 text-emerald-300 border-emerald-500/30";
|
||||
if (tier === "medium") return "bg-amber-500/15 text-amber-300 border-amber-500/30";
|
||||
if (tier === "low") return "bg-rose-500/15 text-rose-300 border-rose-500/30";
|
||||
return "bg-slate-500/15 text-slate-300 border-slate-500/30";
|
||||
}
|
||||
|
||||
function debTrustLabel(tier?: string) {
|
||||
if (tier === "high") return "高可信";
|
||||
if (tier === "medium") return "中可信";
|
||||
if (tier === "low") return "低可信";
|
||||
return "样本少";
|
||||
}
|
||||
|
||||
function debRecommendationLabel(recommendation?: string) {
|
||||
if (recommendation === "primary") return "主用";
|
||||
if (recommendation === "supporting") return "辅助";
|
||||
if (recommendation === "context_only") return "仅参考";
|
||||
return "不足";
|
||||
}
|
||||
|
||||
function formatPct(value: number | null | undefined) {
|
||||
return value == null ? "—" : `${value.toFixed(0)}%`;
|
||||
}
|
||||
|
||||
export function TrainingPageClient() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [status, setStatus] = useState<SystemStatusPayload | null>(null);
|
||||
const [accuracy, setAccuracy] = useState<CityAccuracy[] | null>(null);
|
||||
const [debSummary, setDebSummary] = useState<DebSummary | null>(null);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [s, accData] = await Promise.all([
|
||||
opsApi.systemStatus() as Promise<SystemStatusPayload>,
|
||||
opsApi.trainingAccuracy().catch(() => ({ accuracy: [] as CityAccuracy[] })),
|
||||
opsApi.trainingAccuracy().catch(() => ({ accuracy: [] as CityAccuracy[], deb_summary: null })),
|
||||
]);
|
||||
setStatus(s);
|
||||
setAccuracy((accData as { accuracy: CityAccuracy[] }).accuracy ?? []);
|
||||
setDebSummary((accData as { deb_summary?: DebSummary | null }).deb_summary ?? null);
|
||||
} catch { /* */ }
|
||||
setLoading(false);
|
||||
};
|
||||
@@ -92,22 +170,37 @@ export function TrainingPageClient() {
|
||||
const avgMae = debCities.reduce((s, c) => s + (c.deb?.mae ?? 0), 0) / debCities.length;
|
||||
const best = debCities.reduce((a, b) => ((a.deb?.hit_rate ?? 0) > (b.deb?.hit_rate ?? 0) ? a : b));
|
||||
const worst = debCities.reduce((a, b) => ((a.deb?.mae ?? 0) > (b.deb?.mae ?? 0) ? a : b));
|
||||
return { avgHit, avgMae, best, worst };
|
||||
}, [accuracy]);
|
||||
return {
|
||||
avgHit: debSummary?.historical?.avg_hit_rate ?? avgHit,
|
||||
avgMae: debSummary?.historical?.avg_mae ?? avgMae,
|
||||
usableRecent: debSummary?.usable_recent,
|
||||
recent7Hit: debSummary?.recent_7d?.hit_rate,
|
||||
recent14Hit: debSummary?.recent_14d?.hit_rate,
|
||||
best,
|
||||
worst,
|
||||
};
|
||||
}, [accuracy, debSummary]);
|
||||
|
||||
const usableWindowLabel = (window?: string) =>
|
||||
window === "recent_14d" ? "DEB 可用近 14 天命中" : "DEB 可用近 7 天命中";
|
||||
|
||||
const debRecentRanked = useMemo(() => buildDebRecentRankingRows(accuracy || []), [accuracy]);
|
||||
const debRecentRankIndex = useMemo(
|
||||
() => new Map(debRecentRanked.map((row, index) => [row.cityId, index])),
|
||||
[debRecentRanked],
|
||||
);
|
||||
|
||||
const debChartData = useMemo(() => {
|
||||
if (!accuracy?.length) return [];
|
||||
return accuracy
|
||||
.filter((c) => c.deb && c.deb.total_days >= 5)
|
||||
.sort((a, b) => (b.deb?.hit_rate ?? 0) - (a.deb?.hit_rate ?? 0))
|
||||
return debRecentRanked
|
||||
.slice(0, 24)
|
||||
.map((c) => ({
|
||||
name: c.name,
|
||||
cityId: c.city_id,
|
||||
hitRate: Number((c.deb?.hit_rate ?? 0).toFixed(1)),
|
||||
mae: Number((c.deb?.mae ?? 0).toFixed(1)),
|
||||
days: c.deb?.total_days ?? 0,
|
||||
cityId: c.cityId,
|
||||
hitRate: c.hitRate,
|
||||
mae: c.mae,
|
||||
days: c.samples,
|
||||
}));
|
||||
}, [accuracy]);
|
||||
}, [debRecentRanked]);
|
||||
|
||||
const muChartData = useMemo(() => {
|
||||
if (!accuracy?.length) return [];
|
||||
@@ -124,6 +217,18 @@ export function TrainingPageClient() {
|
||||
}));
|
||||
}, [accuracy]);
|
||||
|
||||
const sortedAccuracy = useMemo(() => {
|
||||
if (!accuracy?.length) return [];
|
||||
return [...accuracy].sort((a, b) => {
|
||||
const aDebRank = debRecentRankIndex.get(a.city_id) ?? 9999;
|
||||
const bDebRank = debRecentRankIndex.get(b.city_id) ?? 9999;
|
||||
if (aDebRank !== bDebRank) return aDebRank - bDebRank;
|
||||
const aMax = Math.max(a.deb?.hit_rate ?? 0, a.mu?.hit_rate ?? 0);
|
||||
const bMax = Math.max(b.deb?.hit_rate ?? 0, b.mu?.hit_rate ?? 0);
|
||||
return bMax - aMax;
|
||||
});
|
||||
}, [accuracy, debRecentRankIndex]);
|
||||
|
||||
if (loading) return <div className="text-slate-400 animate-pulse">加载中...</div>;
|
||||
if (!status) return <div className="text-red-400">加载失败</div>;
|
||||
|
||||
@@ -172,10 +277,20 @@ export function TrainingPageClient() {
|
||||
|
||||
{/* Accuracy KPI row */}
|
||||
{kpis ? (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="grid grid-cols-2 md:grid-cols-6 gap-4">
|
||||
<KpiCard
|
||||
icon={Target} color="bg-cyan-500/20 text-cyan-400"
|
||||
label="DEB 平均命中率" value={`${kpis.avgHit.toFixed(1)}%`}
|
||||
label={usableWindowLabel(kpis.usableRecent?.window)}
|
||||
value={kpis.usableRecent?.hit_rate == null ? "—" : `${kpis.usableRecent.hit_rate.toFixed(1)}%`}
|
||||
sub={`可用城市 ${kpis.usableRecent?.city_count ?? 0} · 样本 ${kpis.usableRecent?.samples ?? 0} · 历史 ${kpis.avgHit.toFixed(1)}%`}
|
||||
/>
|
||||
<KpiCard
|
||||
icon={Target} color="bg-emerald-500/20 text-emerald-400"
|
||||
label="近 7 天命中" value={kpis.recent7Hit == null ? "—" : `${kpis.recent7Hit.toFixed(1)}%`}
|
||||
/>
|
||||
<KpiCard
|
||||
icon={Activity} color="bg-violet-500/20 text-violet-400"
|
||||
label="近 14 天命中" value={kpis.recent14Hit == null ? "—" : `${kpis.recent14Hit.toFixed(1)}%`}
|
||||
/>
|
||||
<KpiCard
|
||||
icon={Activity} color="bg-blue-500/20 text-blue-400"
|
||||
@@ -240,6 +355,8 @@ export function TrainingPageClient() {
|
||||
<thead className="text-xs uppercase bg-slate-800/50 text-slate-400">
|
||||
<tr>
|
||||
<th scope="col" className="px-4 py-3">城市</th>
|
||||
<th scope="col" className="px-4 py-3 text-center">DEB 策略</th>
|
||||
<th scope="col" className="px-4 py-3 text-center">近 7 / 14 天</th>
|
||||
<th scope="col" className="px-4 py-3 text-center">DEB 命中</th>
|
||||
<th scope="col" className="px-4 py-3 text-center">DEB MAE</th>
|
||||
<th scope="col" className="px-4 py-3 text-center">DEB 天数</th>
|
||||
@@ -250,13 +367,36 @@ export function TrainingPageClient() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/5">
|
||||
{accuracy && accuracy.length > 0 ? (
|
||||
accuracy.map((row) => (
|
||||
{sortedAccuracy.length > 0 ? (
|
||||
sortedAccuracy.map((row) => (
|
||||
<tr key={row.city_id} className="hover:bg-white/5 transition-colors">
|
||||
<td className="px-4 py-3 font-medium text-white capitalize">
|
||||
{row.name}
|
||||
<span className="text-xs text-slate-500 block font-mono">{row.city_id}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
{row.deb_recent ? (
|
||||
<span
|
||||
title={row.deb_recent.reason}
|
||||
className={`inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-semibold ${debTrustBadgeClass(row.deb_recent.trust_tier)}`}
|
||||
>
|
||||
{debTrustLabel(row.deb_recent.trust_tier)} · {debRecommendationLabel(row.deb_recent.recommendation)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-slate-600">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center font-mono text-xs text-slate-300">
|
||||
{row.deb_recent ? (
|
||||
<span>
|
||||
{formatPct(row.deb_recent.recent_7d?.hit_rate)}
|
||||
<span className="mx-1 text-slate-600">/</span>
|
||||
{formatPct(row.deb_recent.recent_14d?.hit_rate)}
|
||||
</span>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
{row.deb ? (
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs font-semibold ${
|
||||
@@ -318,7 +458,7 @@ export function TrainingPageClient() {
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan={8} className="px-4 py-8 text-center text-slate-500">
|
||||
<td colSpan={10} className="px-4 py-8 text-center text-slate-500">
|
||||
无有效准确率记录
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -7,6 +7,13 @@ import { Button } from "@/components/ui/button";
|
||||
import { opsApi } from "@/lib/ops-api";
|
||||
import type { OpsUser, LeaderboardEntry } from "@/types/ops";
|
||||
|
||||
function leaderboardName(entry: LeaderboardEntry) {
|
||||
const username = String(entry.username || "").trim();
|
||||
if (username) return username;
|
||||
if (entry.telegram_id != null) return `TG${entry.telegram_id}`;
|
||||
return "未知用户";
|
||||
}
|
||||
|
||||
export function UsersPageClient() {
|
||||
const [query, setQuery] = useState("");
|
||||
const [searching, setSearching] = useState(false);
|
||||
@@ -130,16 +137,16 @@ export function UsersPageClient() {
|
||||
<CardHeader><CardTitle>本周排行榜 Top {leaderboard.length}</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
{leaderboard.length === 0 ? (
|
||||
<span className="text-slate-500 text-sm">无数据</span>
|
||||
<span className="text-slate-500 text-sm">本周暂无积分记录</span>
|
||||
) : (
|
||||
<ol className="space-y-1">
|
||||
{leaderboard.map((entry, i) => (
|
||||
<li key={entry.telegram_id || i} className="flex justify-between text-sm py-1.5 border-b border-white/5">
|
||||
<span>
|
||||
<span className="text-slate-500 w-6 inline-block">#{entry.rank ?? i + 1}</span>
|
||||
<span className="text-white">{entry.username ?? `TG${entry.telegram_id}`}</span>
|
||||
<span className="text-white">{leaderboardName(entry)}</span>
|
||||
</span>
|
||||
<span className="text-cyan-400 font-medium">{entry.weekly_points ?? 0} 分</span>
|
||||
<span className="text-cyan-400 font-medium">本周积分 {entry.weekly_points ?? 0} 分</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
@@ -37,6 +37,8 @@ export type UnlockProBilling = {
|
||||
type UnlockProOverlayProps = {
|
||||
points: number;
|
||||
planPriceUsd: number;
|
||||
planLabel?: string;
|
||||
periodLabel?: string;
|
||||
usePoints: boolean;
|
||||
billing: UnlockProBilling;
|
||||
onToggleUsePoints: () => void;
|
||||
@@ -56,17 +58,22 @@ type UnlockProOverlayProps = {
|
||||
paymentTokenLabel?: string;
|
||||
};
|
||||
|
||||
function formatUsdcAmount(value: number) {
|
||||
if (!Number.isFinite(value)) return "0";
|
||||
return value.toFixed(2).replace(/\.?0+$/, "");
|
||||
}
|
||||
|
||||
const FEATURES = {
|
||||
"zh-CN": [
|
||||
"市场扫描台 + V4-Pro 深度复核",
|
||||
"今日日内机场报文规则分析(含高温时段)",
|
||||
"未来日期分析 + 城市决策卡",
|
||||
"多城市图表巡检 + 未来日期分析",
|
||||
"全平台智能气象推送",
|
||||
],
|
||||
"en-US": [
|
||||
"Market Scan Terminal + V4-Pro review",
|
||||
"Intraday METAR rule-based analysis with peak-time window",
|
||||
"Future-date analysis + city decision cards",
|
||||
"Multi-city chart monitoring + future-date analysis",
|
||||
"Cross-platform alerts",
|
||||
],
|
||||
};
|
||||
@@ -74,6 +81,8 @@ const FEATURES = {
|
||||
export function UnlockProOverlay({
|
||||
points,
|
||||
planPriceUsd,
|
||||
planLabel,
|
||||
periodLabel,
|
||||
usePoints,
|
||||
billing,
|
||||
onToggleUsePoints,
|
||||
@@ -95,6 +104,13 @@ export function UnlockProOverlay({
|
||||
const isEn = locale === "en-US";
|
||||
const canUsePoints = billing.pointsEnabled && billing.isEligible;
|
||||
const featureList = FEATURES[locale] ?? FEATURES["zh-CN"];
|
||||
const currencyLabel = "USDC";
|
||||
const formattedPlanPrice = formatUsdcAmount(planPriceUsd);
|
||||
const formattedFinalPrice = formatUsdcAmount(billing.finalPrice);
|
||||
const formattedDiscount = formatUsdcAmount(billing.discountAmount);
|
||||
const formattedMaxDiscount = formatUsdcAmount(billing.maxDiscountUsd);
|
||||
const resolvedPlanLabel = planLabel || "Standard Pro";
|
||||
const resolvedPeriodLabel = periodLabel || (isEn ? "/ 30 days" : "/ 30 天");
|
||||
const finalPayLabel =
|
||||
payLabel || (isEn ? "Subscribe & Activate" : "立即订阅并激活服务");
|
||||
|
||||
@@ -106,6 +122,8 @@ export function UnlockProOverlay({
|
||||
const redeemableUsdNow = billing.pointsEnabled
|
||||
? Math.min(maxDiscountUsdInt, Math.floor(points / Math.max(1, billing.pointsPerUsd)))
|
||||
: 0;
|
||||
const formattedRedeemableNow = formatUsdcAmount(redeemableUsdNow);
|
||||
const formattedMaxDiscountInt = formatUsdcAmount(maxDiscountUsdInt);
|
||||
const progressPct = billing.pointsEnabled
|
||||
? Math.min(100, Math.round((points / maxPointsForFullDiscount) * 100))
|
||||
: 0;
|
||||
@@ -155,7 +173,7 @@ export function UnlockProOverlay({
|
||||
<div className={s.planCard}>
|
||||
<span className={s.planChip}>
|
||||
<Zap size={10} />
|
||||
Standard Pro
|
||||
{resolvedPlanLabel}
|
||||
</span>
|
||||
|
||||
<div
|
||||
@@ -166,8 +184,8 @@ export function UnlockProOverlay({
|
||||
marginTop: 12,
|
||||
}}
|
||||
>
|
||||
<span className={s.price}>${planPriceUsd.toFixed(2)}</span>
|
||||
<span className={s.priceSuffix}>/ {isEn ? "mo" : "月"}</span>
|
||||
<span className={s.price}>{formattedPlanPrice}</span>
|
||||
<span className={s.priceSuffix}>{currencyLabel} {resolvedPeriodLabel}</span>
|
||||
</div>
|
||||
|
||||
<ul className={s.featureList}>
|
||||
@@ -217,24 +235,24 @@ export function UnlockProOverlay({
|
||||
<span
|
||||
className={`${s.discount} ${usePoints ? s.discountActive : ""}`}
|
||||
>
|
||||
-${billing.discountAmount.toFixed(2)}
|
||||
-{formattedDiscount}
|
||||
</span>
|
||||
<span className={s.discountSuffix}>{isEn ? "off" : "减免"}</span>
|
||||
<span className={s.discountSuffix}>{currencyLabel}</span>
|
||||
</div>
|
||||
|
||||
<p className={s.pointsNote}>
|
||||
{usePoints
|
||||
? isEn
|
||||
? `Using ${billing.pointsUsed} pts · saves $${billing.discountAmount.toFixed(2)}`
|
||||
: `已消耗 ${billing.pointsUsed} 积分 · 省 $${billing.discountAmount.toFixed(2)}`
|
||||
? `Using ${billing.pointsUsed} pts · saves ${formattedDiscount} ${currencyLabel}`
|
||||
: `已消耗 ${billing.pointsUsed} 积分 · 省 ${formattedDiscount} ${currencyLabel}`
|
||||
: isEn
|
||||
? `Toggle to save up to $${billing.maxDiscountUsd.toFixed(2)}`
|
||||
: `开启可最多抵扣 $${billing.maxDiscountUsd.toFixed(2)}`}
|
||||
? `Toggle to save up to ${formattedMaxDiscount} ${currencyLabel}`
|
||||
: `开启可最多抵扣 ${formattedMaxDiscount} ${currencyLabel}`}
|
||||
</p>
|
||||
<p className={s.pointsNote}>
|
||||
{isEn
|
||||
? `Now redeemable: $${redeemableUsdNow.toFixed(0)} / $${maxDiscountUsdInt.toFixed(0)}`
|
||||
: `当前可抵:${redeemableUsdNow.toFixed(0)}U / ${maxDiscountUsdInt.toFixed(0)}U`}
|
||||
? `Now redeemable: ${formattedRedeemableNow} / ${formattedMaxDiscountInt} ${currencyLabel}`
|
||||
: `当前可抵:${formattedRedeemableNow} / ${formattedMaxDiscountInt} ${currencyLabel}`}
|
||||
</p>
|
||||
|
||||
<div className={s.pointsBalance}>
|
||||
@@ -276,8 +294,8 @@ export function UnlockProOverlay({
|
||||
? "Points redemption is unavailable for this plan."
|
||||
: "当前套餐暂不支持积分抵扣。"
|
||||
: isEn
|
||||
? `Starts at ${billing.pointsPerUsd} pts, ${billing.pointsPerUsd} pts per $1, up to $${maxDiscountUsdInt}. You have: ${points}`
|
||||
: `满 ${billing.pointsPerUsd} 分起兑,每 ${billing.pointsPerUsd} 分抵 1U,最多抵 ${maxDiscountUsdInt}U。当前 ${points} 分`}
|
||||
? `Starts at ${billing.pointsPerUsd} pts, ${billing.pointsPerUsd} pts per 1 ${currencyLabel}, up to ${formattedMaxDiscountInt} ${currencyLabel}. You have: ${points}`
|
||||
: `满 ${billing.pointsPerUsd} 分起兑,每 ${billing.pointsPerUsd} 分抵 1 ${currencyLabel},最多抵 ${formattedMaxDiscountInt} ${currencyLabel}。当前 ${points} 分`}
|
||||
</p>
|
||||
|
||||
{billing.pointsEnabled && (
|
||||
@@ -288,8 +306,8 @@ export function UnlockProOverlay({
|
||||
</span>
|
||||
<span>
|
||||
{isEn
|
||||
? `$${redeemableUsdNow.toFixed(0)} / $${maxDiscountUsdInt.toFixed(0)}`
|
||||
: `${redeemableUsdNow.toFixed(0)}U / ${maxDiscountUsdInt.toFixed(0)}U`}
|
||||
? `${formattedRedeemableNow} / ${formattedMaxDiscountInt} ${currencyLabel}`
|
||||
: `${formattedRedeemableNow} / ${formattedMaxDiscountInt} ${currencyLabel}`}
|
||||
</span>
|
||||
</div>
|
||||
<div className={s.progressTrack}>
|
||||
@@ -341,15 +359,15 @@ export function UnlockProOverlay({
|
||||
)}
|
||||
{billing.discountAmount > 0 && usePoints && (
|
||||
<p className={s.summaryOriginal}>
|
||||
${planPriceUsd.toFixed(2)} USD
|
||||
{formattedPlanPrice} {currencyLabel}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className={s.summaryAmount}>
|
||||
<span className={s.summaryPrice}>
|
||||
${billing.finalPrice.toFixed(2)}
|
||||
{formattedFinalPrice}
|
||||
</span>
|
||||
<span className={s.summaryUnit}>USD</span>
|
||||
<span className={s.summaryUnit}>{currencyLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -5,14 +5,11 @@ export const DOCS_GROUPS: DocsNavGroup[] = [
|
||||
id: "getting-started",
|
||||
title: { "zh-CN": "开始", "en-US": "Getting Started" },
|
||||
},
|
||||
{
|
||||
id: "analysis",
|
||||
title: { "zh-CN": "分析逻辑", "en-US": "Analysis Logic" },
|
||||
},
|
||||
{
|
||||
id: "settlement",
|
||||
title: { "zh-CN": "结算与数据", "en-US": "Settlement & Data" },
|
||||
},];
|
||||
},
|
||||
];
|
||||
|
||||
export function getDocsGroupTitle(groupId: DocsNavGroup["id"], locale: DocsLocale) {
|
||||
return DOCS_GROUPS.find((group) => group.id === groupId)?.title[locale] || groupId;
|
||||
|
||||
+301
-305
@@ -41,56 +41,108 @@ export const DOCS_PAGES: DocsPage[] = [
|
||||
content: {
|
||||
"zh-CN": {
|
||||
title: "简介",
|
||||
description: "PolyWeather 文档中心解释核心产品概念、结算口径、日内气象判断、校准概率和模型栈,帮助用户把机场锚点、官方增强层和模型判断转成可执行判断。",
|
||||
description: "PolyWeather 是结算源优先的天气决策台,帮助用户围绕真实观测、DEB 路径、市场信号和结算规则判断温度市场。",
|
||||
sections: [
|
||||
{
|
||||
id: "what-is-polyweather",
|
||||
title: "PolyWeather 是什么",
|
||||
blocks: [
|
||||
{ type: "paragraph", text: "PolyWeather 不是通用天气 App。它面向天气衍生品和温度市场,重点回答三个问题:今天最高温大概会落在哪个区间、机场或官方结算站会不会被压温、市场有没有明显错定价。" },
|
||||
{ type: "callout", tone: "info", title: "产品定位", text: "主站的核心价值不是报天气,而是把模型、机场主站实况、官方增强站网、机场预报和结算规则整合成交易可用的信息。" },
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "PolyWeather 不是通用天气 App,也不是单纯的预报展示页。它把结算跑道、官方站、机场报文、DEB Forecast、市场阈值和源头刷新状态放到同一个工作流里,服务温度市场的日内判断。",
|
||||
},
|
||||
{
|
||||
type: "callout",
|
||||
tone: "info",
|
||||
title: "产品定位",
|
||||
text: "先确认结算源和实测更新,再比较 DEB 与市场信号。市场价格是判断层,不替代真实结算站点或跑道观测。",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "core-modules",
|
||||
title: "你会在页面上看到什么",
|
||||
id: "current-terminal",
|
||||
title: "当前工作台有哪些",
|
||||
blocks: [
|
||||
{ type: "bullets", items: ["锚点状态:先确认当前机场主站实测、日内已见高点和结算时钟。", "当前节奏:把“此刻应到温度”和“机场实测”放在一张卡里,判断今天跑得快还是慢。", "专业气象结论条:先给今日主判断、置信度、基准/上修/下修路径和下一观测点。", "城市决策卡:从地图进入城市简报,读取结构化实况、最高温中枢、市场温度桶和模型-市场差。", "校准模型概率 / 模型区间与分歧:概率层看当前生产概率引擎输出;EMOS / LGBM 只有在评估通过或 shadow 对照时进入解释层,模型区间用于解释分歧。", "气象证据链 / 失效条件 / 确认条件:解释为什么这么判断,以及什么情况会让判断降级。"] },
|
||||
{
|
||||
type: "bullets",
|
||||
items: [
|
||||
"天气决策 / 训练数据 / 使用指南:左侧导航对应当前主工作台的三个入口。",
|
||||
"1-9 个图表槽位:可用 1x1 到 3x3 布局同时观察多个城市,并在每个槽位切换城市。",
|
||||
"实测锚点:优先显示结算跑道、官方站或关键机场报文,并展示当前温度、当日已见高点和更新时间。",
|
||||
"DEB Forecast:橙色路径用于判断后续升温或降温空间,以及峰值窗口附近是否偏离主路径。",
|
||||
"市场信号:阈值、价格、流动性和优势用于交易判断,但要先由天气证据确认。",
|
||||
"训练数据:用于复盘 DEB 和概率引擎近期表现,帮助判断哪些城市当前更可靠。",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "how-to-read",
|
||||
id: "quick-read",
|
||||
title: "如何快速读懂主站",
|
||||
blocks: [
|
||||
{ type: "steps", items: ["先看专业气象结论条或城市决策卡,确认今日主判断、最高温中枢和下一观测点。", "再看锚点状态和今日气温预测图,确认机场实测、DEB、峰值窗口和关键档位线。", "接着看气象证据链、失效条件和确认条件,判断这个路径有没有被新观测破坏。", "最后看校准模型概率、模型区间、市场温度桶和模型-市场差,判断概率是否已经被市场充分计价。"] },
|
||||
{
|
||||
type: "steps",
|
||||
items: [
|
||||
"先选择区域和城市,把重点城市放进图表槽位。",
|
||||
"先看青绿色实测锚点,确认当前温度、当日最高和数据新鲜度。",
|
||||
"再看 DEB Forecast 与高温 / 全天视图,判断剩余升温空间和峰值窗口。",
|
||||
"最后打开模型线、跑道明细或市场信号,确认分歧来自天气还是价格。",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
"en-US": {
|
||||
title: "Introduction",
|
||||
description: "The PolyWeather docs explain the product's core concepts, settlement logic, intraday meteorology, calibrated probability, and model stack so users can turn airport anchors, official nearby networks, and model context into actionable decisions.",
|
||||
description: "PolyWeather is a settlement-source-first weather decision terminal for reading live observations, the DEB path, market signals, and settlement rules.",
|
||||
sections: [
|
||||
{
|
||||
id: "what-is-polyweather",
|
||||
title: "What PolyWeather is",
|
||||
blocks: [
|
||||
{ type: "paragraph", text: "PolyWeather is not a generic weather app. It is built for weather derivatives and temperature markets, with one job: estimate the likely high-temperature bucket, explain whether the airport or official settlement site may get capped, and surface whether the market is mispricing that outcome." },
|
||||
{ type: "callout", tone: "info", title: "Product focus", text: "The core value is not raw weather reporting. It is the conversion of models, airport-primary observations, official nearby networks, airport forecasts, and settlement rules into usable trading context." },
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "PolyWeather is not a general weather app or a raw forecast page. It puts settlement runways, official stations, airport reports, DEB Forecast, market thresholds, and source freshness into one workflow for intraday temperature-market decisions.",
|
||||
},
|
||||
{
|
||||
type: "callout",
|
||||
tone: "info",
|
||||
title: "Product focus",
|
||||
text: "Confirm the settlement source and live update state first, then compare DEB with market signals. Market price is a decision layer, not a replacement for the actual settlement station or runway observation.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "core-modules",
|
||||
title: "What you see on the site",
|
||||
id: "current-terminal",
|
||||
title: "What the current terminal exposes",
|
||||
blocks: [
|
||||
{ type: "bullets", items: ["Anchor status: current airport-primary observation, day-high-so-far, and the settlement clock.", "Current pace: compares where the airport should be by now versus the actual observation.", "Professional meteorology read: headline, confidence, base/upside/downside path, and next observation point.", "City decision cards: map-launched city briefs with the AI airport read, expected-high center, market bucket, and model-market difference.", "Calibrated model probability / model spread: probability comes from the calibrated engine; spread explains model disagreement.", "Evidence chain / failure modes / confirmation: why the read is valid and what would downgrade it."] },
|
||||
{
|
||||
type: "bullets",
|
||||
items: [
|
||||
"Weather Decisions / Training / Guide: the three entries in the current left-side terminal navigation.",
|
||||
"1-9 chart slots: use 1x1 through 3x3 layouts to monitor several cities and switch each slot independently.",
|
||||
"Live anchors: settlement runway, official station, or useful airport report, with current temperature, day high, and freshness.",
|
||||
"DEB Forecast: the orange path for remaining upside or downside and for checking the peak window.",
|
||||
"Market signals: thresholds, price, liquidity, and edge support trade decisions after the weather evidence is checked.",
|
||||
"Training data: recent DEB and probability-engine performance for judging which cities are more reliable now.",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "how-to-read",
|
||||
title: "How to read the dashboard quickly",
|
||||
id: "quick-read",
|
||||
title: "How to read the terminal quickly",
|
||||
blocks: [
|
||||
{ type: "steps", items: ["Start with the professional meteorology read or city decision card: headline, expected-high center, base path, and next observation point.", "Use anchor status and the intraday chart to check current observations, DEB, peak window, and key bucket lines.", "Read the AI airport read, evidence chain, failure modes, and confirmation rules to see whether the path is still valid.", "Then compare calibrated probability, model spread, market bucket, and model-market difference."] },
|
||||
{
|
||||
type: "steps",
|
||||
items: [
|
||||
"Pick a region and city, then place the important cities into chart slots.",
|
||||
"Start with the teal live anchor: current temperature, day high, and freshness.",
|
||||
"Compare DEB Forecast in Peak / All Day views to judge remaining move and peak-window risk.",
|
||||
"Use model lines, runway detail, or market signals last to separate weather disagreement from price disagreement.",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -98,79 +150,139 @@ export const DOCS_PAGES: DocsPage[] = [
|
||||
},
|
||||
},
|
||||
{
|
||||
slug: "intraday-signal",
|
||||
group: "analysis",
|
||||
slug: "chart-guide",
|
||||
group: "getting-started",
|
||||
content: {
|
||||
"zh-CN": {
|
||||
title: "今日日内分析",
|
||||
description: "这页解释今日日内分析如何从气象主判断、证据链、失效条件、TAF 和校准概率组成一套付费判断台。",
|
||||
title: "如何读 PolyWeather 图表",
|
||||
description: "这页解释当前温度图的阅读顺序、图层、视图模式和常见误读。",
|
||||
sections: [
|
||||
{
|
||||
id: "professional-read",
|
||||
title: "顶部结论条怎么读",
|
||||
id: "read-order",
|
||||
title: "先读什么",
|
||||
blocks: [
|
||||
{ type: "paragraph", text: "顶部结论条先给主判断和置信度,再给基准、上修、下修路径与下一观测点。它的作用是让用户先知道今天应重点验证哪条路径,而不是先被概率条和市场价格带偏。" },
|
||||
{ type: "callout", tone: "info", title: "刷新状态", text: "如果完整 detail 或 market scan 仍在同步,日内弹窗会先显示刷新锁,旧内容会降权且不可交互,避免用户把上一轮缓存当成当前结论。" },
|
||||
{
|
||||
type: "steps",
|
||||
items: [
|
||||
"先看结算源实测:结算跑道、官方站或当前更可靠的机场报文是不是还在更新。",
|
||||
"再看 DEB Forecast:它是融合模型和日内修正后的预期路径,不是已经发生的实测。",
|
||||
"切换高温 / 全天:高温视图聚焦峰值窗口,全天视图用于复盘完整日内走势。",
|
||||
"最后看图层和市场信号:只有当实测与 DEB 或价格明显分叉时,才需要打开更多辅助层。",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "surface-vs-upper",
|
||||
title: "近地面信号和高空结构信号的区别",
|
||||
id: "layers",
|
||||
title: "图层怎么理解",
|
||||
blocks: [
|
||||
{ type: "paragraph", text: "近地面信号主要来自小时级温度、露点、气压、风向、降水概率和云量变化。它回答的是:在当前到峰值窗口这几个小时里,地面结构更支持继续升温,还是更容易被压住。" },
|
||||
{ type: "paragraph", text: "高空结构信号主要来自高空派生字段、机场 TAF 与市场侧信息的综合判断。它回答的是:峰值窗口附近,高空和机场侧有没有新的扰动把最高温封顶。" },
|
||||
{
|
||||
type: "bullets",
|
||||
items: [
|
||||
"实测 / 结算线:默认优先展示,代表最接近结算口径的实时温度。",
|
||||
"DEB Forecast:橙色预测路径,重点看它和实测线在峰值窗口前后的差距。",
|
||||
"机场报文:METAR / MGM 等作为机场站参考,只在适合的城市默认显示。",
|
||||
"模型线:ECMWF、GFS、ICON、GEM 等提供背景,默认作为辅助判断。",
|
||||
"跑道明细:打开后可看各跑道传感器;关闭后仍保留结算跑道或主参考站。",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "peak-window",
|
||||
title: "为什么总在讲峰值窗口",
|
||||
id: "advanced-variables",
|
||||
title: "高级气象变量",
|
||||
blocks: [
|
||||
{ type: "paragraph", text: "PolyWeather 不按固定下午时段做判断,而是尽量围绕当天预计最高温兑现的窗口来分析。这样不同城市的峰值时间差异才不会被硬套成同一套模板。" },
|
||||
{ type: "callout", tone: "success", title: "窗口感知", text: "页面里的“今日 12:00-16:00(约 5 小时,围绕峰值窗口)”就是在提示当前结构判断真正关注的时段。" },
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "风速、风向、露点、湿度和气压用于解释压温、海风、云雨和边界层变化,但它们不是温度结算曲线。",
|
||||
},
|
||||
{
|
||||
type: "callout",
|
||||
tone: "info",
|
||||
title: "默认隐藏",
|
||||
text: "高级气象变量默认隐藏,只在需要解释结构变化时作为上下文展开,避免主图被非温度曲线挤占。",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "trade-language",
|
||||
title: "交易语言怎么读",
|
||||
id: "common-misreads",
|
||||
title: "常见误读",
|
||||
blocks: [
|
||||
{ type: "bullets", items: ["偏支持:结构仍支持继续升温,别太早押高温见顶。", "偏压制:高温继续上冲的把握不大,别盲目追热。", "先观察:现在还看不出明确方向,先等下一步走势确认。"] },
|
||||
{
|
||||
type: "bullets",
|
||||
items: [
|
||||
"不要把概率温度带当成实测曲线。概率层用于市场判断和后台分析,不代表某一刻真实温度。",
|
||||
"不要把市场信号当成结算温度。结算仍然看规则指定的站点或跑道。",
|
||||
"不要要求所有城市固定 1 分钟刷新。图表更新频率取决于源头原生频率和当前可用数据。",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
"en-US": {
|
||||
title: "Intraday Analysis",
|
||||
description: "This page explains how intraday analysis combines the meteorology headline, evidence chain, failure modes, TAF, and calibrated probability into a paid decision workspace.",
|
||||
title: "How To Read PolyWeather Charts",
|
||||
description: "A guide to the current temperature chart reading order, layers, view modes, and common misreads.",
|
||||
sections: [
|
||||
{
|
||||
id: "professional-read",
|
||||
title: "How to read the top read",
|
||||
id: "read-order",
|
||||
title: "Read order",
|
||||
blocks: [
|
||||
{ type: "paragraph", text: "The top read gives the headline and confidence first, then the base, upside, downside path, and next observation point. Its job is to tell users what path to verify before they look at probability bars or market prices." },
|
||||
{ type: "callout", tone: "info", title: "Refresh state", text: "If full detail or market scan is still syncing, the intraday modal shows a refresh lock first. Old content is de-emphasized and non-interactive so users do not treat stale cached data as the current read." },
|
||||
{
|
||||
type: "steps",
|
||||
items: [
|
||||
"Start with the settlement-source observation: settlement runway, official station, or the most useful airport report.",
|
||||
"Read DEB Forecast next. It is the model-and-intraday adjusted path, not an observation that already happened.",
|
||||
"Switch Peak / All Day views: Peak focuses the payoff window, while All Day reviews the full intraday path.",
|
||||
"Use layers and market signals last, mainly when live observations diverge from DEB or price.",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "surface-vs-upper",
|
||||
title: "Surface versus upper-air structure",
|
||||
id: "layers",
|
||||
title: "Chart layers",
|
||||
blocks: [
|
||||
{ type: "paragraph", text: "The surface layer comes from hourly temperature, dew point, pressure, wind, precipitation probability, and cloud-cover changes. It answers a near-term question: between now and the peak window, does the local surface setup still support more warming or does it look easier to cap?" },
|
||||
{ type: "paragraph", text: "The upper-air layer combines derived profile signals, airport TAF, and market-side context. It answers a different question: around the peak window, is there a new airport-side or upper-air disturbance that could lock the high in place?" },
|
||||
{
|
||||
type: "bullets",
|
||||
items: [
|
||||
"Live / settlement line: visible by default and closest to the settlement rule.",
|
||||
"DEB Forecast: orange forecast path; focus on its gap versus live observations near the peak window.",
|
||||
"Airport reports: METAR / MGM are airport references and are auto-shown only where useful.",
|
||||
"Model lines: ECMWF, GFS, ICON, GEM, and related layers provide background context.",
|
||||
"Runway detail: enable it to inspect runway sensors; disabling it still keeps the settlement runway or primary reference.",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "peak-window",
|
||||
title: "Why everything is framed around the peak window",
|
||||
id: "advanced-variables",
|
||||
title: "Advanced weather variables",
|
||||
blocks: [
|
||||
{ type: "paragraph", text: "PolyWeather does not force every city into the same afternoon template. It centers the analysis on the expected high-temperature payoff window for that city on that day, so different cities are not interpreted through the wrong hours." },
|
||||
{ type: "callout", tone: "success", title: "Window-aware reading", text: "When you see a line such as “12:00-16:00 (~5h, around the peak window)”, that is the actual window driving the current structural read." },
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "Wind speed, wind direction, dew point, humidity, and pressure help explain suppression, sea-breeze timing, cloud/rain risk, and boundary-layer structure, but they are not settlement-temperature curves.",
|
||||
},
|
||||
{
|
||||
type: "callout",
|
||||
tone: "info",
|
||||
title: "Hidden by default",
|
||||
text: "Advanced variables stay hidden by default and appear only as context when structure needs explanation, so the main chart is not crowded by non-temperature lines.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "trade-language",
|
||||
title: "How to read the trading language",
|
||||
id: "common-misreads",
|
||||
title: "Common misreads",
|
||||
blocks: [
|
||||
{ type: "bullets", items: ["Supportive: the setup still supports more warming. Do not call the high too early.", "Suppressive: further upside looks less reliable. Do not chase the high blindly.", "Wait / confirm: the setup is still mixed. Let the next move decide first."] },
|
||||
{
|
||||
type: "bullets",
|
||||
items: [
|
||||
"Do not read probability bands as observation curves. Probability supports market analysis and background scoring, not timestamped live temperature.",
|
||||
"Do not treat market signals as settlement temperature. Settlement still comes from the station or runway named by the rule.",
|
||||
"Do not expect every city to update every minute. Refresh cadence follows source-native frequency and current data availability.",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -178,227 +290,103 @@ export const DOCS_PAGES: DocsPage[] = [
|
||||
},
|
||||
},
|
||||
{
|
||||
slug: "city-decision-cards",
|
||||
group: "analysis",
|
||||
slug: "realtime-sources",
|
||||
group: "settlement",
|
||||
content: {
|
||||
"zh-CN": {
|
||||
title: "城市决策卡",
|
||||
description: "这页解释地图城市决策卡如何把结构化实况、最高温中枢、市场温度桶和模型-市场差组合成可验证判断。",
|
||||
title: "实时数据频率",
|
||||
description: "这页解释为什么不同城市刷新频率不同,以及网站、缓存和 SSE patch 之间的关系。",
|
||||
sections: [
|
||||
{
|
||||
id: "entry-and-permission",
|
||||
title: "从地图进入决策卡",
|
||||
id: "source-cadence",
|
||||
title: "按源频率采集",
|
||||
blocks: [
|
||||
{ type: "paragraph", text: "用户可以从地图点击城市进入城市决策卡。机会榜和日历属于 Pro 能力;地图探索和城市简报仍可作为轻量入口使用。" },
|
||||
{ type: "callout", tone: "info", title: "先天气、后市场", text: "决策卡顶部的天气判断层不读取市场价格,先用结构化实况、DEB 和多模型集合确定最高温中枢,再把该中枢映射到市场温度桶。" },
|
||||
{
|
||||
type: "bullets",
|
||||
items: [
|
||||
"AMOS 60s:首尔、釜山等韩国跑道传感器。",
|
||||
"AMSC 180s:中国内地跑道端点观测城市。",
|
||||
"MADIS 300s:美国高频机场观测城市。",
|
||||
"CoWIN 60s:香港 6087 参考站。",
|
||||
"HKO 600s:香港天文台官方 10 分钟层。",
|
||||
"CWA / JMA / FMI / KNMI / MGM:按各自官方或可用频率采集。",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "structured-observations",
|
||||
title: "结构化实况包括什么",
|
||||
id: "pipeline",
|
||||
title: "网站怎么更新",
|
||||
blocks: [
|
||||
{ type: "bullets", items: ["实测锚点:当前温度、当日已见高点、观测时间和数据新鲜度。", "模型区间:DEB、多模型范围,以及模型是否明显分散。", "日内节奏:把实测路径、峰值窗口和目标温度桶放在一起对比。", "市场映射:把最高温中枢映射到 YES/NO 温度桶,并计算模型-市场差。"] },
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "观测 collector 按源头原生频率采集,并写入缓存或数据库。前端先读取完整快照,之后通过 SSE patch 合并 city_observation_patch.v1 增量更新。",
|
||||
},
|
||||
{
|
||||
type: "callout",
|
||||
tone: "info",
|
||||
title: "SSE patch",
|
||||
text: "可见图表会订阅实时 patch;如果一段时间没有收到 patch,才做轻量兜底刷新,避免多个入口同时强刷同一外部源。",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "market-layer",
|
||||
title: "信号价格层怎么读",
|
||||
id: "telegram",
|
||||
title: "Telegram 和网站的关系",
|
||||
blocks: [
|
||||
{ type: "paragraph", text: "温度桶标签来自完整信号桶列表,并会按 label / slug / question 识别 exact、or higher、or lower、range 等方向,避免把 30.5°C 错配到不合理的 16°C 或反向尾部桶。" },
|
||||
{ type: "callout", tone: "info", title: "模型-信号差", text: "模型-信号差 = 模型概率 − 信号隐含概率。正数表示天气概率高于信号报价;负数表示信号已经把该 YES 计价得更充分。它不是温度变化,也不是收益率。" },
|
||||
{ type: "paragraph", text: "YES 买入价以可执行报价为主;没有可靠模型概率或 YES 价格时,决策卡会显示报价已匹配但暂不计算模型-信号差。" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "cache-behavior",
|
||||
title: "为什么切换选项卡后不应重新空白加载",
|
||||
blocks: [
|
||||
{ type: "paragraph", text: "城市决策卡复用城市详情、市场扫描和图表数据缓存,不再单独请求 AI 解读。" },
|
||||
{ type: "bullets", items: ["城市详情缓存:保存实况、模型、概率和结算上下文。", "市场扫描缓存:完整 all_buckets 结果按城市和日期缓存,默认 TTL 为 10 分钟。", "前端图表缓存:切换城市或选项卡时优先复用已加载的结构化数据。"] },
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "Telegram 默认读取最新缓存或数据库,不主动强制刷新观测源。只有完全没有缓存时,才允许兜底分析。",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
"en-US": {
|
||||
title: "City Decision Cards",
|
||||
description: "How the city card combines structured observations, expected-high center, market bucket mapping, and model-market difference into a verifiable decision.",
|
||||
title: "Realtime Source Cadence",
|
||||
description: "Why cities update at different speeds, and how the website, cache, and SSE patches relate to each other.",
|
||||
sections: [
|
||||
{
|
||||
id: "entry-and-permission",
|
||||
title: "Opening a card from the map",
|
||||
id: "source-cadence",
|
||||
title: "Source-native cadence",
|
||||
blocks: [
|
||||
{ type: "paragraph", text: "Users can click a city on the map to open its city decision card. The opportunity board and calendar are Pro surfaces; map exploration and city briefs remain the lightweight entry point." },
|
||||
{ type: "callout", tone: "info", title: "Weather first, market second", text: "The weather decision layer does not use market price input. It first sets the expected-high center from structured observations, DEB, and the model cluster, then maps that center to the relevant market bucket." },
|
||||
{
|
||||
type: "bullets",
|
||||
items: [
|
||||
"AMOS 60s: Korean runway sensors such as Seoul and Busan.",
|
||||
"AMSC 180s: mainland China runway endpoint observations.",
|
||||
"MADIS 300s: US high-frequency airport observations.",
|
||||
"CoWIN 60s: Hong Kong 6087 reference station.",
|
||||
"HKO 600s: Hong Kong Observatory official 10-minute layer.",
|
||||
"CWA / JMA / FMI / KNMI / MGM: collected at each source's official or available cadence.",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "structured-observations",
|
||||
title: "What structured observations contain",
|
||||
id: "pipeline",
|
||||
title: "How the site updates",
|
||||
blocks: [
|
||||
{ type: "bullets", items: ["Observation anchor: current temperature, daily high so far, observation time, and freshness.", "Model range: DEB, multi-model range, and whether the model cluster is dispersed.", "Intraday pace: live path, peak window, and target temperature bucket in one comparison.", "Market mapping: maps the expected-high center to YES/NO buckets and calculates the model-market difference."] },
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "The observation collector samples each source at its native cadence and writes cache or database state. The frontend reads a full snapshot first, then merges city_observation_patch.v1 updates through SSE patch.",
|
||||
},
|
||||
{
|
||||
type: "callout",
|
||||
tone: "info",
|
||||
title: "SSE patch",
|
||||
text: "Visible charts subscribe to live patches. If patches stop for a while, the chart can make a lightweight fallback refresh instead of making every entry point force-refresh the same external source.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "market-layer",
|
||||
title: "How to read the signal layer",
|
||||
id: "telegram",
|
||||
title: "How Telegram relates",
|
||||
blocks: [
|
||||
{ type: "paragraph", text: "Bucket labels come from the full signal bucket list. The card reads label / slug / question text to distinguish exact, or-higher, or-lower, and range buckets, so a 30.5°C weather center is not matched to an unreasonable 16°C or reverse-tail bucket." },
|
||||
{ type: "callout", tone: "info", title: "Model-signal difference", text: "Model-signal difference = model probability minus signal-implied probability. A positive value means the weather probability is above signal pricing; a negative value means the YES is already priced more fully by the signal. It is not a temperature delta or return." },
|
||||
{ type: "paragraph", text: "YES buy uses executable quote data when available. If either model probability or YES price is incomplete, the card shows the quote match but withholds the model-signal difference." },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "cache-behavior",
|
||||
title: "Why tab switching should not blank the card",
|
||||
blocks: [
|
||||
{ type: "paragraph", text: "The card reuses city detail, market scan, and chart-data caches. It no longer makes a separate AI-read request." },
|
||||
{ type: "bullets", items: ["City detail cache: stores observations, models, probabilities, and settlement context.", "Market-scan cache: stores full all_buckets results by city and date for 10 minutes by default.", "Frontend chart cache: reuses already loaded structured data when switching cities or tabs."] },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
slug: "model-stack-deb",
|
||||
group: "analysis",
|
||||
content: {
|
||||
"zh-CN": {
|
||||
title: "模型栈与 DEB",
|
||||
description: "这页说明 PolyWeather 当前接入哪些开放模型,不同地区为什么看到的模型不一样,以及 DEB 如何避免重复计权。",
|
||||
sections: [
|
||||
{
|
||||
id: "model-sources",
|
||||
title: "当前接入的开放模型",
|
||||
blocks: [
|
||||
{ type: "paragraph", text: "PolyWeather 的多模型层通过 Open-Meteo 模型接口接入开放 NWP / AIFS 等预报模型。这里的“来源 Open-Meteo”表示接入接口,不表示 Open-Meteo 是单一模型,也不替代 ECMWF、DWD、ECCC、NOAA、JMA 等机构来源。" },
|
||||
{ type: "bullets", items: ["ECMWF IFS:全球传统数值模式。", "ECMWF AIFS:ECMWF AIFS 模型,作为独立 AIFS 路径保留。", "DWD ICON:全球 ICON 基准层。", "DWD ICON-EU:欧洲区域高分辨率层。", "DWD ICON-D2:欧洲短时高分辨率层。", "ECCC GEM / GDPS:加拿大系全球模式。", "ECCC RDPS / HRDPS:北美区域与短时高分辨率层。", "GFS / JMA:继续作为全球参考模型保留。"] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "regional-coverage",
|
||||
title: "为什么欧洲、亚洲、美国城市看到的模型不一样",
|
||||
blocks: [
|
||||
{ type: "paragraph", text: "模型覆盖域不同,所以同一套请求在不同坐标上返回的模型也不同。区域模型不覆盖某个城市时,不会返回空值,也不会在前端显示。" },
|
||||
{ type: "bullets", items: ["欧洲城市:通常会看到 ECMWF / AIFS / GFS / ICON / ICON-EU / ICON-D2 / GEM 或 GDPS / JMA。欧洲高分辨率重点来自 DWD ICON-EU 和 ICON-D2。", "北美城市:通常会看到 ECMWF / AIFS / GFS / ICON / GEM / GDPS / RDPS / HRDPS / JMA,并继续叠加 NWS。北美高分辨率重点来自 ECCC RDPS 和 HRDPS。", "亚洲城市:通常以 ECMWF / AIFS / GFS / ICON / GEM 或 GDPS / JMA 为主,通常不会有 ICON-EU、ICON-D2、RDPS、HRDPS;亚洲城市更依赖本地官方站、METAR、TAF、JMA、KMA、NMC、HKO、CWA 等观测增强层。"] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "deb-dedup",
|
||||
title: "DEB 如何处理新增模型",
|
||||
blocks: [
|
||||
{ type: "paragraph", text: "DEB 不会把所有新模型按“每个模型一票”直接等权加入。否则 ICON / ICON-EU / ICON-D2 会让 DWD 模型家族被重复放大,GEM / GDPS / RDPS / HRDPS 也会让加拿大模型家族被重复放大。" },
|
||||
{ type: "bullets", items: ["ICON / ICON-EU / ICON-D2 归为 DWD ICON 家族,优先级为 ICON-D2 > ICON-EU > ICON。", "GEM / GDPS / RDPS / HRDPS 归为 ECCC GEM 家族,优先级为 HRDPS > RDPS > GDPS > GEM。", "ECMWF IFS 与 ECMWF AIFS 分开保留,因为一个是传统数值模式,一个是 AIFS 模型。", "GFS、JMA、MGM、NWS、LGBM、Open-Meteo 等保持独立路径。", "DEB 权重信息中出现“家族去重”时,表示系统已经先折叠同家族模型,再进行历史 MAE 倒数加权。"] },
|
||||
{ type: "callout", tone: "info", title: "产品含义", text: "新增模型提升的是区域代表性和解释力,不是让某个地区因为模型数量更多就天然拥有更高权重。" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "display",
|
||||
title: "网页上如何展示",
|
||||
blocks: [
|
||||
{ type: "paragraph", text: "网页的“模型区间与分歧”会按全球基准、AIFS 模型、欧洲高分辨率、北美高分辨率分组显示,并展示可用模型数量、模型分歧、模型机构、接入接口、模型名称、分辨率和预报时效。没有覆盖的区域模型不会显示。" },
|
||||
{ type: "callout", tone: "info", title: "概率怎么读", text: "模型票数只解释哪些模型支持某个温度档,不等于最终概率。最终概率优先读取校准模型概率;有 LGBM 时展示 LGBM 校准概率,市场价格只作为参考。" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
"en-US": {
|
||||
title: "Model Stack & DEB",
|
||||
description: "This page explains which open models PolyWeather uses, why model coverage differs by region, and how DEB avoids duplicate family weighting.",
|
||||
sections: [
|
||||
{
|
||||
id: "model-sources",
|
||||
title: "Open models currently integrated",
|
||||
blocks: [
|
||||
{ type: "paragraph", text: "PolyWeather's multi-model layer uses the Open-Meteo model API to integrate open NWP and AIFS model forecasts. The label “source: Open-Meteo” means the integration API, not a single model source, and it does not replace ECMWF, DWD, ECCC, NOAA, or JMA attribution." },
|
||||
{ type: "bullets", items: ["ECMWF IFS: global traditional NWP.", "ECMWF AIFS: ECMWF AIFS model, kept as a separate AIFS path.", "DWD ICON: global ICON baseline.", "DWD ICON-EU: European regional high-resolution layer.", "DWD ICON-D2: European short-range high-resolution layer.", "ECCC GEM / GDPS: Canadian global model family.", "ECCC RDPS / HRDPS: North American regional and short-range high-resolution layers.", "GFS / JMA: retained as global reference models."] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "regional-coverage",
|
||||
title: "Why Europe, Asia, and US cities show different models",
|
||||
blocks: [
|
||||
{ type: "paragraph", text: "Model domains differ, so the same request can return different model fields depending on the city coordinates. If a regional model does not cover a city, it is omitted rather than shown as an empty value." },
|
||||
{ type: "bullets", items: ["European cities usually show ECMWF / AIFS / GFS / ICON / ICON-EU / ICON-D2 / GEM or GDPS / JMA. The high-resolution European layer comes mainly from DWD ICON-EU and ICON-D2.", "North American cities usually show ECMWF / AIFS / GFS / ICON / GEM / GDPS / RDPS / HRDPS / JMA, plus existing NWS context. The high-resolution North American layer comes mainly from ECCC RDPS and HRDPS.", "Asian cities usually rely on ECMWF / AIFS / GFS / ICON / GEM or GDPS / JMA. ICON-EU, ICON-D2, RDPS, and HRDPS usually do not cover Asia, so Asian reads lean more on local official stations, METAR, TAF, JMA, KMA, NMC, HKO, CWA, and other observation enhancement layers."] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "deb-dedup",
|
||||
title: "How DEB handles the new models",
|
||||
blocks: [
|
||||
{ type: "paragraph", text: "DEB does not treat every new model as one independent vote. Otherwise ICON / ICON-EU / ICON-D2 would over-amplify the DWD family, and GEM / GDPS / RDPS / HRDPS would over-amplify the Canadian family." },
|
||||
{ type: "bullets", items: ["ICON / ICON-EU / ICON-D2 are collapsed into one DWD ICON family, with priority ICON-D2 > ICON-EU > ICON.", "GEM / GDPS / RDPS / HRDPS are collapsed into one ECCC GEM family, with priority HRDPS > RDPS > GDPS > GEM.", "ECMWF IFS and ECMWF AIFS are kept separate because one is traditional NWP and the other is the AIFS model.", "GFS, JMA, MGM, NWS, LGBM, and Open-Meteo remain independent paths.", "When the DEB weight string includes “family deduplication” or “家族去重”, the system has collapsed same-family models before applying historical inverse-MAE weighting."] },
|
||||
{ type: "callout", tone: "info", title: "Product meaning", text: "The new models improve regional representativeness and explanation quality. They do not let a region gain extra weight simply because more related model variants exist there." },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "display",
|
||||
title: "How the site displays this",
|
||||
blocks: [
|
||||
{ type: "paragraph", text: "The Model Range & Spread panel groups the model stack into Global Baseline, AIFS Model, Europe High-resolution, and North America High-resolution. It shows available model count, spread, model agency, API, model name, resolution, and forecast horizon. Regional models outside their domain are simply not shown." },
|
||||
{ type: "callout", tone: "info", title: "How to read probability", text: "Model vote counts explain which models round into a bucket; they are not the final probability. The final probability should come from the calibrated engine. When LGBM is available, the site labels it as LGBM-calibrated probability, while market price remains only a reference." },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
slug: "taf-signal",
|
||||
group: "analysis",
|
||||
content: {
|
||||
"zh-CN": {
|
||||
title: "TAF 信号",
|
||||
description: "TAF 不是结算温度,但它能告诉你机场侧在峰值窗口前后会不会有云雨、雷暴或风向切换,把最高温压住。",
|
||||
sections: [
|
||||
{
|
||||
id: "what-taf-does",
|
||||
title: "TAF 在 PolyWeather 里负责什么",
|
||||
blocks: [
|
||||
{ type: "paragraph", text: "TAF 在项目里是机场侧确认层,而不是温度主预测曲线。它主要补三类信息:峰值窗口有没有云雨压温、午后扰动是不是正在增强、机场风向是否发生阶段性切换。" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "taf-periods",
|
||||
title: "图上的 TAF 时段是什么意思",
|
||||
blocks: [
|
||||
{ type: "bullets", items: ["基础时段(BASE):TAF 的默认主背景天气。", "明确切换(FM):从某个时刻开始,机场预报切换到一套新天气状态。", "临时波动(TEMPO):一段时间内可能临时出现扰动,但不代表主背景永久改变。", "逐步转变(BECMG):天气不是一下子切,而是在一段时间里渐变。", "30% / 40% 风险窗(PROB30/40):风险有概率出现,不代表一定发生。"] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "airport-suppression",
|
||||
title: "什么叫机场端压温风险偏高",
|
||||
blocks: [
|
||||
{ type: "paragraph", text: "它的意思不是整座城市一定更冷,而是作为结算依据的机场站点,在峰值窗口里更可能因为云、阵雨或雷暴扰动,冲不到本来可能达到的更高温度。" },
|
||||
{ type: "callout", tone: "warning", title: "重点区别", text: "TAF 负责告诉你机场侧未来几个小时会不会出现压温扰动,不直接等于结算温度本身。结算仍然看实际结算站点读数;页面上的官方增强站网只负责领先、偏移和空间分布判断,不会替代机场主站或官方结算站本身。" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
"en-US": {
|
||||
title: "TAF Signal",
|
||||
description: "TAF is not the settlement temperature itself, but it is useful for telling you whether the airport side may see clouds, showers, thunderstorms, or wind shifts that cap the high around the payoff window.",
|
||||
sections: [
|
||||
{
|
||||
id: "what-taf-does",
|
||||
title: "What TAF does inside PolyWeather",
|
||||
blocks: [
|
||||
{ type: "paragraph", text: "Within the product, TAF acts as an airport-side confirmation layer rather than the main temperature curve. Its job is to tell you whether clouds/rain may suppress the airport high, whether afternoon disruption is building, and whether the airport wind regime is about to shift in stages." },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "taf-periods",
|
||||
title: "What the TAF timing labels mean",
|
||||
blocks: [
|
||||
{ type: "bullets", items: ["Base regime: the default background forecast segment.", "Hard shift (FM): a new weather regime begins from an explicit time.", "Temporary swing (TEMPO): a temporary disturbance window that does not replace the background regime permanently.", "Gradual shift (BECMG): conditions transition across a window instead of flipping instantly.", "30% / 40% risk window (PROB30/40): a probabilistic risk window, not a certainty signal."] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "airport-suppression",
|
||||
title: "What airport-side suppression risk means",
|
||||
blocks: [
|
||||
{ type: "paragraph", text: "It does not mean the entire city must run cooler. It means the airport station used for settlement is more likely to get capped by clouds, showers, or thunderstorm disruption during the peak window and fail to reach the next warmer bucket." },
|
||||
{ type: "callout", tone: "warning", title: "Important distinction", text: "TAF explains whether the airport side may face suppressive weather over the next few hours. Settlement still comes from the actual settlement station reading, while the official nearby network is only an enhancement layer for lead/lag and spread, not a replacement anchor." },
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "Telegram reads the latest cache or database state by default and does not force-refresh observation sources. It only falls back to analysis when no cache exists at all.",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -411,54 +399,94 @@ export const DOCS_PAGES: DocsPage[] = [
|
||||
content: {
|
||||
"zh-CN": {
|
||||
title: "结算站点说明",
|
||||
description: "不同城市的结算口径不同。理解结算站点,比单纯看模型曲线更重要。",
|
||||
description: "不同市场的结算口径不同。先确认结算站点,再判断 DEB、实测和市场信号。",
|
||||
sections: [
|
||||
{
|
||||
id: "why-settlement-matters",
|
||||
title: "为什么先看结算站点",
|
||||
blocks: [
|
||||
{ type: "paragraph", text: "同样是“城市最高温”,市场真正结算看的往往不是城区平均温度,而是规则指定的机场或官方站点。交易上最常见的错觉,是把城市体感温度当成结算温度。" },
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "同样是城市最高温,真正结算可能看机场 METAR、机场主站、结算跑道,也可能看明确指定的官方结算站点。城区更热或体感更热,不一定等于合约会结到更高温桶。",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "city-rules",
|
||||
id: "primary-rules",
|
||||
title: "当前主要口径",
|
||||
blocks: [
|
||||
{ type: "bullets", items: ["多数机场市场:按机场 METAR 或机场主站实况结算。", "土耳其机场市场:机场主站仍以 METAR 为锚点,同时保留 Turkish MGM 作为领先结构参考。", "中国内地机场市场:机场主站仍以 METAR 为锚点,NMC 当前实况作为官方增强层,不直接替代机场结算站。", "日本 / 韩国机场市场:机场主站仍以 METAR 为锚点,同时可接入 JMA / KMA 官方增强层做领先结构参考。", "Manila、Karachi 等新增机场城市按对应 METAR / 机场主站作为锚点。", "香港 / 流浮山 / 台湾等明确官方站点市场:按规则指定的官方结算站点结算,不能拿机场 TAF 或城区体感替代。"] },
|
||||
{
|
||||
type: "bullets",
|
||||
items: [
|
||||
"多数机场市场:先看机场 METAR、机场主站或项目标记的机场主参考站。",
|
||||
"跑道城市:优先看结算跑道或项目标记的主跑道端点,同时保留辅助跑道作为空间背景。",
|
||||
"明确官方站点市场:按规则指定的官方结算站点结算,不能用通用机场逻辑替代。",
|
||||
"本地官方增强层:JMA、KMA、NMC、HKO、CWA、MGM 等用于领先结构和交叉验证,是否能做结算锚点取决于合约规则。",
|
||||
"TAF:用于判断机场未来云雨、雷暴或风向变化,不是结算温度本身。",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "common-mistakes",
|
||||
title: "最常见的误解",
|
||||
id: "how-to-check",
|
||||
title: "页面上怎么确认",
|
||||
blocks: [
|
||||
{ type: "bullets", items: ["TAF 不是结算站点,它只告诉你机场未来有没有压温扰动。", "市场按机场结算时,城区更热不代表市场就该结到更高温桶。", "Wunderground 是历史页面或参考入口,不是物理观测站;机场市场仍以 METAR / 机场主站为锚点。", "官方增强站网是领先参考层,不等于它可以替代机场主站做结算锚点。", "香港、流浮山、台湾等明确官方站点市场,不能简单套用通用机场 TAF / METAR 主链逻辑。"] },
|
||||
{
|
||||
type: "steps",
|
||||
items: [
|
||||
"先看图表里的实测 / 结算线名称和统计条,确认当前主锚点。",
|
||||
"再看更新时间和数据新鲜度,排除停更或滞后源。",
|
||||
"如果打开跑道明细,先读结算跑道,再用辅助跑道判断空间差异。",
|
||||
"最后才把 DEB、市场信号和概率判断叠上去。",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
"en-US": {
|
||||
title: "Settlement Stations",
|
||||
description: "Settlement rules differ by city. Understanding the settlement station matters more than staring only at model curves.",
|
||||
description: "Settlement rules differ by market. Confirm the station first, then read DEB, observations, and market signals.",
|
||||
sections: [
|
||||
{
|
||||
id: "why-settlement-matters",
|
||||
title: "Why the settlement station comes first",
|
||||
blocks: [
|
||||
{ type: "paragraph", text: "A market may say “city high”, but the true settlement often comes from a designated airport or official site rather than the broader urban feel. One of the most common mistakes is to trade the city feel instead of the actual settlement station." },
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "A city-high market may settle from airport METAR, an airport primary site, a settlement runway, or an explicitly named official settlement station. A hotter downtown feel does not automatically mean the contract settles into a warmer bucket.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "city-rules",
|
||||
id: "primary-rules",
|
||||
title: "Current primary rules",
|
||||
blocks: [
|
||||
{ type: "bullets", items: ["Most airport-linked markets settle on airport METAR or the airport primary observing site.", "Turkish airport markets keep METAR as the airport anchor, with Turkish MGM retained as a leading-structure reference.", "Mainland China airport markets keep METAR as the airport anchor, while NMC current observations act as an official enhancement layer rather than a direct replacement anchor.", "Japanese and Korean airport markets can keep METAR as the anchor while using JMA / KMA nearby-network observations as an official enhancement layer.", "New airport cities such as Manila and Karachi are anchored to their corresponding METAR / airport-primary station.", "Markets with explicitly designated official sites, such as Hong Kong, Lau Fau Shan, and Taiwan station-driven contracts, should be anchored to those official settlement stations rather than generic airport logic."] },
|
||||
{
|
||||
type: "bullets",
|
||||
items: [
|
||||
"Most airport-linked markets: start with airport METAR, the airport primary site, or the project's marked airport reference.",
|
||||
"Runway cities: prioritize the settlement runway or marked primary runway endpoint, while auxiliary runways remain spatial context.",
|
||||
"Explicit official-station markets: settle from the official settlement station named by the rule, not from generic airport logic.",
|
||||
"Local official enhancement layers: JMA, KMA, NMC, HKO, CWA, MGM, and similar sources help with lead/lag and cross-checks; whether they anchor settlement depends on the contract rule.",
|
||||
"TAF: useful for cloud, thunderstorm, or wind-shift risk near the airport, but not the settlement temperature itself.",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "common-mistakes",
|
||||
title: "Common mistakes",
|
||||
id: "how-to-check",
|
||||
title: "How to check on the page",
|
||||
blocks: [
|
||||
{ type: "bullets", items: ["TAF is not the settlement station. It only tells you whether airport-side suppressive weather may appear.", "If the market settles on an airport site, a hotter downtown feel does not automatically justify a warmer settlement bucket.", "Wunderground is a history/reference page, not a physical station. Airport markets still anchor to METAR or the airport primary observing site.", "The official nearby network is a lead/lag and spread layer. It should not be mistaken for the final settlement anchor unless the market explicitly names that station.", "Hong Kong, Lau Fau Shan, and Taiwan station-driven contracts should not be forced into the generic airport TAF / METAR chain."] },
|
||||
{
|
||||
type: "steps",
|
||||
items: [
|
||||
"Read the live / settlement line name and summary stats to identify the current anchor.",
|
||||
"Check timestamp and freshness so stale sources do not drive the decision.",
|
||||
"When runway detail is enabled, read the settlement runway first and use auxiliary runways for spatial spread.",
|
||||
"Only then add DEB, market signals, and probability context.",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -471,7 +499,7 @@ export const DOCS_PAGES: DocsPage[] = [
|
||||
content: {
|
||||
"zh-CN": {
|
||||
title: "浏览器插件",
|
||||
description: "PolyWeather Side Panel 是一个面向天气交易场景的浏览器侧边栏工具,负责自动识别城市、展示简版走势与城市档案,并把用户导回完整分析页面。",
|
||||
description: "PolyWeather Side Panel 是浏览器侧边栏工具,用于快速识别城市、查看简版走势,并跳回完整天气决策台。",
|
||||
sections: [
|
||||
{
|
||||
id: "extension-install",
|
||||
@@ -481,7 +509,7 @@ export const DOCS_PAGES: DocsPage[] = [
|
||||
type: "link",
|
||||
href: "https://chromewebstore.google.com/detail/mhndjbgjljjfcfkojhmhpfcbconnikne?utm_source=item-share-cb",
|
||||
label: "打开 Chrome Web Store",
|
||||
caption: "安装插件后,可在侧边栏里快速跳回主站的今日日内分析。",
|
||||
caption: "安装后可在浏览器侧边栏查看简版城市走势,并跳回主站。",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -493,9 +521,9 @@ export const DOCS_PAGES: DocsPage[] = [
|
||||
type: "bullets",
|
||||
items: [
|
||||
"自动识别当前页面中的城市,也支持手动切换。",
|
||||
"展示城市档案:结算站点、站点距离、观测更新时间、周边站点数量。",
|
||||
"展示今日日内走势(简版):DEB 走势与机场主站实况 / 官方增强站网对照,可悬停查看时间与温度。",
|
||||
"展示多日最高温预报(简版),并提供一键刷新与跳转主站入口。",
|
||||
"展示城市档案:结算站点、站点距离、观测更新时间和周边站点数量。",
|
||||
"展示简版日内走势:DEB 与机场主站或官方参考站对照,可悬停查看时间与温度。",
|
||||
"展示简版多日最高温预报,并提供刷新和跳回主站入口。",
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -507,10 +535,10 @@ export const DOCS_PAGES: DocsPage[] = [
|
||||
{
|
||||
type: "bullets",
|
||||
items: [
|
||||
"`tabs`:用于识别当前活动标签页 URL 并自动匹配城市。",
|
||||
"`tabs`:用于识别当前活动标签页 URL 并匹配城市。",
|
||||
"`storage`:用于保存插件配置与本地缓存,仅存储在本地浏览器。",
|
||||
"`sidePanel`:用于在浏览器侧边栏展示界面。",
|
||||
"插件不要求用户登录,不收集个人身份信息,不上传浏览历史,仅在必要时请求天气接口数据。",
|
||||
"插件不要求用户登录,不收集个人身份信息,不上传浏览历史,仅在需要渲染侧边栏时请求天气接口数据。",
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -521,23 +549,7 @@ export const DOCS_PAGES: DocsPage[] = [
|
||||
blocks: [
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "插件不承担完整分析体验,也不承载支付链路。复杂结构判断和完整交易语境仍以主站为准。",
|
||||
},
|
||||
{
|
||||
type: "callout",
|
||||
tone: "info",
|
||||
title: "当前定位",
|
||||
text: "插件是“监控 + 基础判断 + 导流回站”的轻量产品,而不是主站的 1:1 复制品。",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "extension-forecast",
|
||||
title: "当前多日预报口径",
|
||||
blocks: [
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "插件的多日预报已改为 DEB 优先显示。只有某一天没有 DEB 值时,才回退到原始的日最高温预报值。",
|
||||
text: "插件不承担完整分析体验,也不承载支付链路。多图表巡检、训练数据、权益状态和完整市场判断仍以主站为准。",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -545,7 +557,7 @@ export const DOCS_PAGES: DocsPage[] = [
|
||||
},
|
||||
"en-US": {
|
||||
title: "Browser Extension",
|
||||
description: "PolyWeather Side Panel is a browser side-panel tool for weather trading workflows. It auto-detects cities, shows compact intraday and city-profile context, and routes users back to the full dashboard.",
|
||||
description: "PolyWeather Side Panel is a browser side-panel tool for quick city detection, compact chart context, and returning to the full weather decision terminal.",
|
||||
sections: [
|
||||
{
|
||||
id: "extension-install",
|
||||
@@ -555,7 +567,7 @@ export const DOCS_PAGES: DocsPage[] = [
|
||||
type: "link",
|
||||
href: "https://chromewebstore.google.com/detail/mhndjbgjljjfcfkojhmhpfcbconnikne?utm_source=item-share-cb",
|
||||
label: "Open Chrome Web Store",
|
||||
caption: "Once installed, the side panel can route users back into the main intraday analysis.",
|
||||
caption: "After installation, the side panel can show compact city context and route back to the main site.",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -568,8 +580,8 @@ export const DOCS_PAGES: DocsPage[] = [
|
||||
items: [
|
||||
"Auto-detects the current page city, with manual switching also available.",
|
||||
"Shows a city profile with settlement station, station distance, observation timestamp, and nearby station count.",
|
||||
"Shows a compact intraday chart with DEB versus airport-primary observations and official nearby-network observations, including hoverable time and temperature.",
|
||||
"Shows a compact multi-day daily-high forecast, plus refresh and jump-to-site actions.",
|
||||
"Shows a compact intraday chart with DEB against the airport primary or official reference station, including hoverable time and temperature.",
|
||||
"Shows a compact multi-day high forecast, plus refresh and return-to-site actions.",
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -584,7 +596,7 @@ export const DOCS_PAGES: DocsPage[] = [
|
||||
"`tabs`: used to inspect the active tab URL and match the current city.",
|
||||
"`storage`: used for local configuration and local cache only.",
|
||||
"`sidePanel`: used to render the browser side panel UI.",
|
||||
"The extension does not require login, does not collect personally identifiable information, and does not upload browsing history. It only requests weather endpoints when needed to render the panel.",
|
||||
"The extension does not require login, does not collect personally identifiable information, and does not upload browsing history. It only requests weather endpoints when rendering the side panel.",
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -595,23 +607,7 @@ export const DOCS_PAGES: DocsPage[] = [
|
||||
blocks: [
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "The extension does not attempt to replicate the full analysis stack and does not carry the payment flow. Deeper structural reasoning and full trade context still live on the main site.",
|
||||
},
|
||||
{
|
||||
type: "callout",
|
||||
tone: "info",
|
||||
title: "Current positioning",
|
||||
text: "Think of the extension as monitoring plus lightweight bias, not as a full dashboard replacement.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "extension-forecast",
|
||||
title: "Current forecast logic",
|
||||
blocks: [
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "The extension now prefers DEB for the multi-day forecast. It falls back to the original daily max only when a DEB value is missing for that date.",
|
||||
text: "The extension does not carry the full analysis experience or payment flow. Multi-chart monitoring, training data, entitlement state, and complete market context stay on the main site.",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
+50
-10
@@ -28,6 +28,33 @@ function shouldExposeProxyErrorDetail() {
|
||||
);
|
||||
}
|
||||
|
||||
function looksLikeHtmlDocument(value: string) {
|
||||
const text = String(value || "").trim().toLowerCase();
|
||||
return (
|
||||
text.startsWith("<!doctype html") ||
|
||||
text.startsWith("<html") ||
|
||||
/<title>[^<]*(50\d|cloudflare|polyweather\.top)/i.test(String(value || ""))
|
||||
);
|
||||
}
|
||||
|
||||
function extractSafeUpstreamJsonMessage(rawDetail: string) {
|
||||
try {
|
||||
const parsed = JSON.parse(String(rawDetail || "")) as {
|
||||
detail?: unknown;
|
||||
error?: unknown;
|
||||
message?: unknown;
|
||||
};
|
||||
const message = [parsed.detail, parsed.error, parsed.message].find(
|
||||
(item) => typeof item === "string" && item.trim(),
|
||||
);
|
||||
if (typeof message !== "string") return "";
|
||||
const trimmed = message.trim();
|
||||
return looksLikeHtmlDocument(trimmed) ? "" : trimmed;
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export function clientStatusFromUpstream(status: number) {
|
||||
if (PASSTHROUGH_UPSTREAM_STATUSES.has(status)) {
|
||||
return status;
|
||||
@@ -44,8 +71,9 @@ export function buildUpstreamErrorResponse(
|
||||
extraDebug?: Record<string, unknown>;
|
||||
},
|
||||
) {
|
||||
const safeJsonMessage = extractSafeUpstreamJsonMessage(rawDetail);
|
||||
const body: Record<string, unknown> = {
|
||||
error: options?.error || "Upstream request failed",
|
||||
error: options?.error || safeJsonMessage || "Upstream request failed",
|
||||
upstream_status: upstreamStatus,
|
||||
};
|
||||
|
||||
@@ -96,6 +124,7 @@ export async function proxyBackendJsonGet(
|
||||
signal?: AbortSignal;
|
||||
statusOnException?: number;
|
||||
timeoutPublicMessage?: string;
|
||||
timeoutResponse?: () => NextResponse;
|
||||
timing?: ProxyTimer;
|
||||
url: string;
|
||||
},
|
||||
@@ -167,16 +196,27 @@ export async function proxyBackendJsonGet(
|
||||
: withCookies;
|
||||
} catch (error) {
|
||||
const timedOut = options.signal?.aborted === true;
|
||||
const response = buildProxyExceptionResponse(error, {
|
||||
publicMessage:
|
||||
timedOut && options.timeoutPublicMessage
|
||||
? options.timeoutPublicMessage
|
||||
: options.publicMessage,
|
||||
status: timedOut ? 504 : options.statusOnException,
|
||||
});
|
||||
const withCookies = auth ? applyAuthResponseCookies(response, auth.response) : response;
|
||||
const response =
|
||||
timedOut && options.timeoutResponse
|
||||
? options.timeoutResponse()
|
||||
: buildProxyExceptionResponse(error, {
|
||||
publicMessage:
|
||||
timedOut && options.timeoutPublicMessage
|
||||
? options.timeoutPublicMessage
|
||||
: options.publicMessage,
|
||||
status: timedOut ? 504 : options.statusOnException,
|
||||
});
|
||||
const withCookies = auth
|
||||
? applyAuthResponseCookies(response, auth.response)
|
||||
: response;
|
||||
const outcome =
|
||||
timedOut && options.timeoutResponse
|
||||
? "timeout_fallback"
|
||||
: timedOut
|
||||
? "timeout"
|
||||
: "exception";
|
||||
return timing
|
||||
? finishProxyTimedResponse(withCookies, timing, timedOut ? "timeout" : "exception")
|
||||
? finishProxyTimedResponse(withCookies, timing, outcome)
|
||||
: withCookies;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
export type AuthSnapshotLike = {
|
||||
authenticated?: boolean;
|
||||
user_id?: string | null;
|
||||
subscription_active?: boolean | null;
|
||||
subscription_plan_code?: string | null;
|
||||
subscription_source?: string | null;
|
||||
subscription_is_trial?: boolean | null;
|
||||
subscription_starts_at?: string | null;
|
||||
subscription_expires_at?: string | null;
|
||||
subscription_total_expires_at?: string | null;
|
||||
subscription_queued_days?: number | null;
|
||||
subscription_queued_count?: number | null;
|
||||
points?: number | null;
|
||||
referral?: unknown;
|
||||
telegram_pricing?: unknown;
|
||||
degraded_auth_profile?: boolean | null;
|
||||
entitlement_snapshot?: boolean | null;
|
||||
};
|
||||
|
||||
type AuthMePathOptions = {
|
||||
preferSnapshot?: boolean;
|
||||
scope?: "entitlement";
|
||||
};
|
||||
|
||||
export function buildAuthMePath(options: AuthMePathOptions = {}) {
|
||||
const params = new URLSearchParams();
|
||||
if (options.preferSnapshot) params.set("prefer_snapshot", "1");
|
||||
if (options.scope === "entitlement") params.set("scope", "entitlement");
|
||||
const query = params.toString();
|
||||
return query ? `/api/auth/me?${query}` : "/api/auth/me";
|
||||
}
|
||||
|
||||
export function isUnknownSubscriptionSnapshot(
|
||||
snapshot: AuthSnapshotLike | null | undefined,
|
||||
) {
|
||||
return (
|
||||
snapshot?.subscription_active === null ||
|
||||
snapshot?.subscription_active === undefined ||
|
||||
snapshot?.degraded_auth_profile === true
|
||||
);
|
||||
}
|
||||
|
||||
export function mergeAccountAuthSnapshot<T extends AuthSnapshotLike>(
|
||||
previous: T | null | undefined,
|
||||
next: T,
|
||||
): T {
|
||||
if (
|
||||
!previous?.subscription_active ||
|
||||
!next?.authenticated ||
|
||||
!isUnknownSubscriptionSnapshot(next)
|
||||
) {
|
||||
return next;
|
||||
}
|
||||
|
||||
return {
|
||||
...next,
|
||||
subscription_active: true,
|
||||
subscription_plan_code:
|
||||
next.subscription_plan_code ?? previous.subscription_plan_code ?? null,
|
||||
subscription_source: next.subscription_source ?? previous.subscription_source ?? null,
|
||||
subscription_is_trial:
|
||||
next.subscription_is_trial ?? previous.subscription_is_trial ?? null,
|
||||
subscription_starts_at:
|
||||
next.subscription_starts_at ?? previous.subscription_starts_at ?? null,
|
||||
subscription_expires_at:
|
||||
next.subscription_expires_at ?? previous.subscription_expires_at ?? null,
|
||||
subscription_total_expires_at:
|
||||
next.subscription_total_expires_at ??
|
||||
previous.subscription_total_expires_at ??
|
||||
previous.subscription_expires_at ??
|
||||
null,
|
||||
subscription_queued_days:
|
||||
next.subscription_queued_days ?? previous.subscription_queued_days ?? 0,
|
||||
subscription_queued_count:
|
||||
next.subscription_queued_count ?? previous.subscription_queued_count ?? 0,
|
||||
points:
|
||||
Number.isFinite(Number(next.points)) && Number(next.points) > 0
|
||||
? next.points
|
||||
: previous.points,
|
||||
referral: next.referral ?? previous.referral,
|
||||
telegram_pricing: next.telegram_pricing ?? previous.telegram_pricing,
|
||||
};
|
||||
}
|
||||
@@ -225,9 +225,17 @@ export interface DebForecast {
|
||||
prediction: number | null;
|
||||
raw_prediction?: number | null;
|
||||
version?: string | null;
|
||||
selected_version?: string | null;
|
||||
guard_reason?: string | null;
|
||||
weights_info?: string | null;
|
||||
bias_adjustment?: number | null;
|
||||
bias_samples?: number | null;
|
||||
quality_tier?: string | null;
|
||||
recommendation?: string | null;
|
||||
recent_hit_rate?: number | null;
|
||||
recent_samples?: number | null;
|
||||
recent_hits?: number | null;
|
||||
recent_mae?: number | null;
|
||||
intraday_adjustment?: number | null;
|
||||
hourly_path?: DebHourlyPath | null;
|
||||
hourly_correction?: Record<string, unknown> | null;
|
||||
@@ -328,6 +336,12 @@ export interface DailyModelForecast {
|
||||
models?: Record<string, number | null>;
|
||||
deb?: {
|
||||
prediction?: number | null;
|
||||
quality_tier?: string | null;
|
||||
recommendation?: string | null;
|
||||
recent_hit_rate?: number | null;
|
||||
recent_samples?: number | null;
|
||||
recent_hits?: number | null;
|
||||
recent_mae?: number | null;
|
||||
};
|
||||
probabilities?: ProbabilityBucket[];
|
||||
probabilities_all?: ProbabilityBucket[];
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
export type DebTrainingMetric = {
|
||||
hit_rate?: number | null;
|
||||
mae?: number | null;
|
||||
total_days?: number;
|
||||
};
|
||||
|
||||
export type DebTrainingWindow = {
|
||||
hit_rate?: number | null;
|
||||
mae?: number | null;
|
||||
samples?: number;
|
||||
};
|
||||
|
||||
export type DebTrainingRecent = {
|
||||
recent_7d?: DebTrainingWindow | null;
|
||||
recent_14d?: DebTrainingWindow | null;
|
||||
trust_tier?: string | null;
|
||||
recommendation?: string | null;
|
||||
};
|
||||
|
||||
export type DebTrainingCity = {
|
||||
city_id: string;
|
||||
name: string;
|
||||
deb?: DebTrainingMetric | null;
|
||||
deb_recent?: DebTrainingRecent | null;
|
||||
};
|
||||
|
||||
export type DebRecentRankingRow = {
|
||||
cityId: string;
|
||||
name: string;
|
||||
hitRate: number;
|
||||
mae: number;
|
||||
samples: number;
|
||||
trustScore: number;
|
||||
usableScore: number;
|
||||
};
|
||||
|
||||
function trustScore(tier?: string | null) {
|
||||
if (tier === "high") return 3;
|
||||
if (tier === "medium") return 2;
|
||||
if (tier === "low") return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function usableScore(recommendation?: string | null) {
|
||||
if (recommendation === "primary") return 2;
|
||||
if (recommendation === "supporting") return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function selectRecentWindow(recent?: DebTrainingRecent | null): DebTrainingWindow | null {
|
||||
const recent7 = recent?.recent_7d || null;
|
||||
if (Number(recent7?.samples || 0) > 0) return recent7;
|
||||
const recent14 = recent?.recent_14d || null;
|
||||
if (Number(recent14?.samples || 0) > 0) return recent14;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function buildDebRecentRankingRows(cities: DebTrainingCity[]): DebRecentRankingRow[] {
|
||||
return (cities || [])
|
||||
.filter((city) => city.deb)
|
||||
.map((city) => {
|
||||
const recentWindow = selectRecentWindow(city.deb_recent);
|
||||
return {
|
||||
cityId: city.city_id,
|
||||
name: city.name,
|
||||
hitRate: Number(
|
||||
(recentWindow?.hit_rate ?? city.deb?.hit_rate ?? 0).toFixed(1),
|
||||
),
|
||||
mae: Number(
|
||||
(recentWindow?.mae ?? city.deb?.mae ?? 0).toFixed(2),
|
||||
),
|
||||
samples: Number(recentWindow?.samples || 0),
|
||||
trustScore: trustScore(city.deb_recent?.trust_tier),
|
||||
usableScore: usableScore(city.deb_recent?.recommendation),
|
||||
};
|
||||
})
|
||||
.sort((a, b) => {
|
||||
if (a.usableScore !== b.usableScore) return b.usableScore - a.usableScore;
|
||||
if (a.trustScore !== b.trustScore) return b.trustScore - a.trustScore;
|
||||
if (a.hitRate !== b.hitRate) return b.hitRate - a.hitRate;
|
||||
if (a.samples !== b.samples) return b.samples - a.samples;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user