diff --git a/README.md b/README.md index 73d1ad20..14b250a8 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ 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 @@ -21,12 +21,11 @@ Public docs center: `/docs/intro` on the main site (bilingual product documentat [![Star History Chart](https://api.star-history.com/svg?repos=yangyuan-zhen/PolyWeather&type=Date)](https://star-history.com/#yangyuan-zhen/PolyWeather&Date) -## Product Status (2026-05-28) +## Product Status (2026-06-06) -- 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`). - 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. @@ -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 bands, 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,7 +72,7 @@ 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. - Reuses one analysis core across web dashboard and Telegram bot. - Adds payment audit trails, replay tooling, and incident visibility in ops. - Adds peak-window-oriented intraday analysis with meteorology headline, path buckets, evidence chain, invalidation rules, and confirmation rules. diff --git a/README_ZH.md b/README_ZH.md index 26fe8a7e..3a73fd4e 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -49,10 +49,10 @@ - `/ops` 现已展示缓存桶数量、summary cache hit/miss 与运行态 heartbeat。 - 今日日内分析已改为“专业气象判断台”:顶部先给气象主判断、置信度、基准/上修/下修路径、下一观测点,再展示证据链、失效条件、确认条件和模型层。 - 日内分析弹窗在 full detail / market detail 同步完成前会锁住旧内容并显示刷新状态,避免用户短暂看到上一轮缓存数据后误判。 -- 终端城市卡已改为结构化实况 + DEB hourly consensus + 多模型集群 + 校准概率 + 市场温度桶,不再让图表等待 AI 文案生成。 +- 终端图表/详情工作流已改为结构化实况 + DEB hourly consensus + 多模型集群 + 概率温度带 + 市场温度桶,不再让图表等待 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,7 +73,7 @@ - DEB(Dynamic Error Balancing)融合多模型最高温。 - 构建 DEB 加权小时共识曲线,用于峰值窗口判断和图表默认 DEB 展示。 - 输出结算导向校准概率分布(`mu` + 温度桶),通过 legacy 高斯或 EMOS/CRPS 校准引擎。 -- 地图城市决策卡把结构化实况、最高温中枢、完整市场温度桶和模型-市场差放在同一张卡中展示。 +- 天气决策台把结构化实况、DEB 高温路径、完整市场温度桶和模型-市场差放进图表/详情工作流。 - Web 仪表盘与 Telegram Bot 复用同一分析内核。 - 支付链路具备事件重放、SQLite 审计事件与 RPC 容灾能力。 - 官方增强层与跑道级传感器支持按国家 provider 统一接入(含韩国 AMOS 首尔/釜山跑道实测),不替代机场主站、METAR 或明确官方结算站。 @@ -242,7 +242,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) diff --git a/frontend/README.md b/frontend/README.md index 9b77260d..850ba7d2 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -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 都由后端统一封装 diff --git a/frontend/app/subscription-help/SubscriptionHelpClient.tsx b/frontend/app/subscription-help/SubscriptionHelpClient.tsx index 068d4a5d..826f289c 100644 --- a/frontend/app/subscription-help/SubscriptionHelpClient.tsx +++ b/frontend/app/subscription-help/SubscriptionHelpClient.tsx @@ -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: "当前订阅价格是多少?", diff --git a/frontend/app/terminal/page.tsx b/frontend/app/terminal/page.tsx index 55715d6b..9be7606f 100644 --- a/frontend/app/terminal/page.tsx +++ b/frontend/app/terminal/page.tsx @@ -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() { diff --git a/frontend/components/account/account-copy.ts b/frontend/components/account/account-copy.ts index 04521b61..268b0d3a 100644 --- a/frontend/components/account/account-copy.ts +++ b/frontend/components/account/account-copy.ts @@ -19,8 +19,8 @@ export function createAccountCopy(isEn: boolean): Record { 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" : "全平台智能气象查询", diff --git a/frontend/components/dashboard/scan-terminal/TemperatureStatsBars.tsx b/frontend/components/dashboard/scan-terminal/TemperatureStatsBars.tsx index 4698f3ef..1429a568 100644 --- a/frontend/components/dashboard/scan-terminal/TemperatureStatsBars.tsx +++ b/frontend/components/dashboard/scan-terminal/TemperatureStatsBars.tsx @@ -12,6 +12,7 @@ const OBSERVATION_LABEL_EN: Record = { "CWA (10分钟)": "CWA (10m)", "气象站实测": "Weather Station Live", "跑道实测 (1分钟)": "Runway Live (1m)", + "跑道实测 (3分钟)": "Runway Live (3m)", "机场报文": "Airport METAR", "METAR 结算 (30分钟)": "METAR Settlement (30m)", }; diff --git a/frontend/components/dashboard/scan-terminal/__tests__/productizationDocs.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/productizationDocs.test.ts index ca373305..3fe03349 100644 --- a/frontend/components/dashboard/scan-terminal/__tests__/productizationDocs.test.ts +++ b/frontend/components/dashboard/scan-terminal/__tests__/productizationDocs.test.ts @@ -1,4 +1,6 @@ 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); @@ -24,16 +26,42 @@ function pageText(slug: string, locale: "zh-CN" | "en-US") { } 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 live terminal", + 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"); @@ -54,4 +82,5 @@ export function runTests() { 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"); } diff --git a/frontend/components/dashboard/scan-terminal/__tests__/sourceFreshness.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/sourceFreshness.test.ts index 0714b3d2..12611e51 100644 --- a/frontend/components/dashboard/scan-terminal/__tests__/sourceFreshness.test.ts +++ b/frontend/components/dashboard/scan-terminal/__tests__/sourceFreshness.test.ts @@ -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"); } diff --git a/frontend/components/dashboard/scan-terminal/__tests__/temperatureDefaultVisibilityPolicy.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/temperatureDefaultVisibilityPolicy.test.ts index effe1204..50be50d4 100644 --- a/frontend/components/dashboard/scan-terminal/__tests__/temperatureDefaultVisibilityPolicy.test.ts +++ b/frontend/components/dashboard/scan-terminal/__tests__/temperatureDefaultVisibilityPolicy.test.ts @@ -1170,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", diff --git a/frontend/components/dashboard/scan-terminal/__tests__/temperatureStatsLabels.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/temperatureStatsLabels.test.ts index d86a8893..a1c8e137 100644 --- a/frontend/components/dashboard/scan-terminal/__tests__/temperatureStatsLabels.test.ts +++ b/frontend/components/dashboard/scan-terminal/__tests__/temperatureStatsLabels.test.ts @@ -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, diff --git a/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts b/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts index ca2afd95..5c1b3719 100644 --- a/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts +++ b/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts @@ -16,6 +16,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> = { shanghai: [["17L", "35R"]], @@ -1298,6 +1302,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; @@ -1309,7 +1316,7 @@ function getLiveObservationLabels( : isParis ? "官方机场观测 (15分钟)" : isTaipei ? "CWA (10分钟)" : isWeatherStation ? "气象站实测" - : isRunwaySensorCity ? "跑道实测 (1分钟)" + : isRunwaySensorCity ? `跑道实测 (${isAmscRunwayCity ? "3分钟" : "1分钟"})` : "机场报文"; const metarHeaderLabel = (isShenzhen || isHKO) ? "天文台实测 (10分钟)" diff --git a/frontend/components/docs/DocsLayout.module.css b/frontend/components/docs/DocsLayout.module.css index f665ab43..01362d77 100644 --- a/frontend/components/docs/DocsLayout.module.css +++ b/frontend/components/docs/DocsLayout.module.css @@ -1,8 +1,10 @@ .docsShell { min-height: 100vh; background: - linear-gradient(180deg, #f8fbff 0%, #eef4fb 100%); - color: #0f172a; + 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 { @@ -12,35 +14,44 @@ display: flex; align-items: center; justify-content: space-between; - gap: 16px; - padding: 16px 24px; - background: rgba(255, 255, 255, 0.94); - border-bottom: 1px solid #d9e3ef; + 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(14px); + backdrop-filter: blur(16px); } .brandWrap { display: flex; align-items: baseline; gap: 12px; + min-width: 0; } .brandLink { - color: #0891b2; - font-size: 1.55rem; - 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: #64748b; - font-size: 0.92rem; + font-size: 0.88rem; + white-space: nowrap; } .headerActions { display: flex; align-items: center; + justify-content: flex-end; gap: 10px; } @@ -49,101 +60,121 @@ display: inline-flex; align-items: center; justify-content: center; + gap: 8px; min-height: 38px; - padding: 0 14px; - border-radius: 999px; - border: 1px solid #cbd5e1; - background: #ffffff; - color: #334155; + 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: 700; + font-size: 0.9rem; + font-weight: 800; cursor: pointer; transition: border-color 160ms ease, color 160ms ease, - background 160ms ease; + background 160ms ease, + box-shadow 160ms ease; } .headerButton:hover, .headerGhost:hover { - border-color: #38bdf8; - color: #075985; - background: #f0f9ff; + 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 #cbd5e1; - border-radius: 999px; - overflow: hidden; - background: #ffffff; + 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: #64748b; - font-weight: 800; + font-size: 0.82rem; + font-weight: 850; cursor: pointer; } .langButtonActive { - background: #e0f2fe; - color: #0369a1; + background: #ffffff; + color: #0f172a; + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.08); } .docsFrame { display: grid; - grid-template-columns: 260px minmax(0, 820px) 220px; + grid-template-columns: 268px minmax(0, 850px) 226px; justify-content: center; - gap: 28px; + gap: 24px; width: 100%; - max-width: 1440px; + max-width: 1480px; margin: 0 auto; - padding: 24px; + padding: 28px 24px 68px; } .sidebar { position: sticky; - top: 84px; + top: 82px; align-self: start; - max-height: calc(100vh - 108px); + max-height: calc(100vh - 106px); overflow: auto; - padding: 16px; - border: 1px solid #d8e3ef; - border-radius: 16px; + padding: 14px; + border: 1px solid rgba(184, 196, 214, 0.72); + border-radius: 10px; background: #ffffff; - box-shadow: 0 12px 32px rgba(15, 23, 42, 0.06); + box-shadow: var(--shadow-elevation-2, 0 8px 24px rgba(15, 23, 42, 0.06)); } .sidebarGroup + .sidebarGroup { - margin-top: 22px; + margin-top: 18px; + padding-top: 18px; + border-top: 1px solid #e8edf5; } .sidebarTitle { margin: 0 0 8px; + padding: 0 8px; color: #64748b; - font-size: 0.78rem; - font-weight: 800; + font-size: 0.74rem; + font-weight: 850; letter-spacing: 0.06em; text-transform: uppercase; } .sidebarLink { + position: relative; display: block; - margin-bottom: 4px; - padding: 9px 10px; + margin-bottom: 3px; + padding: 9px 10px 9px 14px; border-radius: 8px; color: #334155; text-decoration: none; - font-size: 0.94rem; + font-size: 0.92rem; + font-weight: 650; line-height: 1.35; transition: background 160ms ease, - color 160ms ease; + color 160ms ease, + box-shadow 160ms ease; } .sidebarLink:hover { @@ -152,9 +183,10 @@ } .sidebarLinkActive { - background: #e0f2fe; - color: #0369a1; - font-weight: 800; + background: #eff6ff; + color: #1d4ed8; + font-weight: 850; + box-shadow: inset 3px 0 0 var(--color-accent-primary, #2563eb); } .content { @@ -162,39 +194,106 @@ } .contentInner { - padding: 16px 0 56px; + 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; color: #0f172a; - font-size: clamp(2rem, 4vw, 3.05rem); + font-size: clamp(2rem, 3vw, 2.72rem); font-weight: 850; - line-height: 1.12; + letter-spacing: 0; + line-height: 1.1; } .pageDescription { max-width: 760px; margin: 14px 0 0; color: #475569; - font-size: 1.03rem; + 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: 104px; + 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 { - margin: 0 0 14px; - color: #1e293b; - font-size: 1.45rem; - font-weight: 800; - line-height: 1.25; + position: relative; + margin: 0 0 16px; + 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 { + max-width: 780px; margin: 0 0 15px; color: #334155; font-size: 1rem; @@ -202,34 +301,37 @@ } .callout { + position: relative; + max-width: 790px; margin: 20px 0; - padding: 18px 20px; - border: 1px solid #d8e3ef; - border-radius: 12px; - background: #ffffff; - box-shadow: 0 8px 24px rgba(15, 23, 42, 0.05); + 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: #bae6fd; - background: #f0f9ff; + border-left-color: var(--color-accent-primary, #2563eb); + background: #f5f9ff; } .calloutWarning { - border-color: #fde68a; - background: #fffbeb; + border-left-color: #d97706; + background: #fffaf0; } .calloutSuccess { - border-color: #bbf7d0; - background: #f0fdf4; + border-left-color: #00897b; + background: #f0fdfa; } .calloutTitle { - margin: 0 0 8px; + margin: 0 0 7px; color: #0f172a; font-size: 0.98rem; - font-weight: 800; + font-weight: 850; } .calloutText { @@ -239,14 +341,68 @@ } .list { - margin: 0 0 16px; - padding-left: 22px; + max-width: 800px; + margin: 0 0 18px; + padding: 0; color: #334155; - line-height: 1.8; + 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 { @@ -254,17 +410,24 @@ align-items: center; justify-content: center; min-height: 42px; - padding: 0 16px; - border-radius: 999px; - border: 1px solid #38bdf8; - background: #e0f2fe; - color: #0369a1; + padding: 0 15px; + border: 1px solid #93c5fd; + border-radius: 10px; + background: #eff6ff; + color: #1d4ed8; text-decoration: none; - font-weight: 800; + font-weight: 850; +} + +.linkCard::after { + content: "->"; + margin-left: 8px; + font-family: var(--font-mono, monospace); } .linkCard:hover { - background: #bae6fd; + border-color: #60a5fa; + background: #dbeafe; } .linkCaption { @@ -273,42 +436,65 @@ 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: 84px; + top: 82px; align-self: start; - padding: 16px; - border: 1px solid #d8e3ef; - border-radius: 16px; + padding: 14px; + border: 1px solid rgba(184, 196, 214, 0.72); + border-radius: 10px; background: #ffffff; - box-shadow: 0 12px 32px rgba(15, 23, 42, 0.06); + box-shadow: var(--shadow-elevation-1, 0 1px 3px rgba(15, 23, 42, 0.05)); } .tocTitle { margin: 0 0 10px; color: #64748b; - font-size: 0.78rem; - font-weight: 800; + font-size: 0.74rem; + font-weight: 850; letter-spacing: 0.06em; text-transform: uppercase; } .tocLink { display: block; - padding: 5px 0; + padding: 7px 0 7px 12px; + border-left: 2px solid #e8edf5; color: #475569; text-decoration: none; - line-height: 1.55; - font-size: 0.92rem; + line-height: 1.45; + font-size: 0.88rem; } .tocLink:hover { - color: #0369a1; + border-left-color: #2563eb; + color: #1d4ed8; } .mobileMenuButton { display: none; - margin-bottom: 18px; + margin-bottom: 14px; } .mobileSidebarBackdrop { @@ -317,7 +503,7 @@ @media (max-width: 1200px) { .docsFrame { - grid-template-columns: 240px minmax(0, 820px); + grid-template-columns: 248px minmax(0, 850px); justify-content: start; } @@ -330,10 +516,12 @@ .docsHeader { align-items: flex-start; flex-direction: column; + gap: 10px; } .docsFrame { grid-template-columns: minmax(0, 1fr); + padding-top: 18px; } .mobileMenuButton { @@ -345,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 16px 16px 0; + padding: 18px 14px; + border-radius: 0 10px 10px 0; transform: translateX(-100%); transition: transform 180ms ease-out; } @@ -361,37 +551,65 @@ position: fixed; inset: 0; z-index: 50; - background: rgba(15, 23, 42, 0.48); display: block; + border: 0; + background: rgba(15, 23, 42, 0.48); } } @media (max-width: 640px) { .docsHeader { - padding: 14px 16px; - } - - .docsFrame { - padding: 16px; + padding: 12px 14px; } .brandWrap { flex-direction: column; align-items: flex-start; - gap: 4px; + gap: 2px; + } + + .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: 2rem; + font-size: 1.9rem; } .pageDescription, .paragraph { - font-size: 1rem; + font-size: 0.98rem; + } + + .section { + padding: 28px 0 26px; + } + + .stepList li { + grid-template-columns: 30px minmax(0, 1fr); + padding: 11px 12px; } } diff --git a/frontend/components/docs/DocsScreen.tsx b/frontend/components/docs/DocsScreen.tsx index 5150cb02..64764995 100644 --- a/frontend/components/docs/DocsScreen.tsx +++ b/frontend/components/docs/DocsScreen.tsx @@ -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() {
+