From 578f547fa72dbb1b116a37519fa7631d5f7abdf4 Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Wed, 24 Jun 2026 18:53:22 +0800 Subject: [PATCH] Complete public brief locale switching --- frontend/app/methodology/[slug]/page.tsx | 51 ++++-- frontend/app/methodology/page.tsx | 65 ++++++-- frontend/app/sources/[slug]/page.tsx | 51 ++++-- frontend/app/sources/page.tsx | 65 ++++++-- .../landing/InstitutionalLandingPage.tsx | 3 +- .../__tests__/landingPricingReferral.test.ts | 6 +- frontend/components/landing/landingLocale.ts | 7 + .../public-content/PublicContentPages.tsx | 153 ++++++++++++------ .../__tests__/publicContentAssets.test.ts | 35 ++++ frontend/content/public-content.ts | 153 ++++++++++++++++-- 10 files changed, 477 insertions(+), 112 deletions(-) diff --git a/frontend/app/methodology/[slug]/page.tsx b/frontend/app/methodology/[slug]/page.tsx index 81a35ee9..589f1c1e 100644 --- a/frontend/app/methodology/[slug]/page.tsx +++ b/frontend/app/methodology/[slug]/page.tsx @@ -1,16 +1,37 @@ import type { Metadata } from "next"; +import { cookies, headers } from "next/headers"; import { notFound } from "next/navigation"; import { MethodologyDetailPageView } from "@/components/public-content/PublicContentPages"; +import { + LANDING_LOCALE_COOKIE, + LANDING_LOCALE_QUERY_PARAM, + pickLandingLocale, + type LandingLocale, +} from "@/components/landing/landingLocale"; import { METHODOLOGY_PAGES, absolutePublicUrl, getMethodologyPage, + localizeMethodologyPage, methodologyPath, } from "@/content/public-content"; type MethodologyPageParams = { slug: string; }; +type MethodologySearchParams = Promise>; + +async function resolvePublicContentLocale(searchParams: MethodologySearchParams): Promise { + const params = await searchParams; + const rawLocale = params[LANDING_LOCALE_QUERY_PARAM]; + const queryLocale = Array.isArray(rawLocale) ? rawLocale[0] : rawLocale; + const [cookieStore, headerStore] = await Promise.all([cookies(), headers()]); + return pickLandingLocale( + queryLocale, + cookieStore.get(LANDING_LOCALE_COOKIE)?.value, + headerStore.get("accept-language"), + ); +} export function generateStaticParams() { return METHODOLOGY_PAGES.map((page) => ({ slug: page.slug })); @@ -18,10 +39,15 @@ export function generateStaticParams() { export async function generateMetadata({ params, + searchParams, }: { params: Promise; + searchParams: MethodologySearchParams; }): Promise { - const { slug } = await params; + const [{ slug }, locale] = await Promise.all([ + params, + resolvePublicContentLocale(searchParams), + ]); const page = getMethodologyPage(slug); if (!page) { @@ -29,17 +55,18 @@ export async function generateMetadata({ title: "Methodology not found", }; } + const localizedPage = localizeMethodologyPage(page, locale); return { - title: page.title, - description: page.description, + title: localizedPage.title, + description: localizedPage.description, alternates: { canonical: methodologyPath(page), }, openGraph: { type: "article", - title: page.title, - description: page.description, + title: localizedPage.title, + description: localizedPage.description, url: methodologyPath(page), modifiedTime: page.updatedAt, }, @@ -48,22 +75,28 @@ export async function generateMetadata({ export default async function MethodologyDetailPage({ params, + searchParams, }: { params: Promise; + searchParams: MethodologySearchParams; }) { - const { slug } = await params; + const [{ slug }, locale] = await Promise.all([ + params, + resolvePublicContentLocale(searchParams), + ]); const page = getMethodologyPage(slug); if (!page) { notFound(); } + const localizedPage = localizeMethodologyPage(page, locale); const pathname = methodologyPath(page); const jsonLd = { "@context": "https://schema.org", "@type": "TechArticle", - headline: page.title, - description: page.description, + headline: localizedPage.title, + description: localizedPage.description, dateModified: page.updatedAt, mainEntityOfPage: absolutePublicUrl(pathname), author: { @@ -79,7 +112,7 @@ export default async function MethodologyDetailPage({ type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} /> - + ); } diff --git a/frontend/app/methodology/page.tsx b/frontend/app/methodology/page.tsx index 7e4d4c41..c1554726 100644 --- a/frontend/app/methodology/page.tsx +++ b/frontend/app/methodology/page.tsx @@ -1,21 +1,54 @@ import type { Metadata } from "next"; +import { cookies, headers } from "next/headers"; import { MethodologyIndexPageView } from "@/components/public-content/PublicContentPages"; +import { + LANDING_LOCALE_COOKIE, + LANDING_LOCALE_QUERY_PARAM, + pickLandingLocale, + type LandingLocale, +} from "@/components/landing/landingLocale"; +import { PUBLIC_CONTENT_COPY } from "@/content/public-content"; -export const metadata: Metadata = { - title: "Methodology", - description: - "PolyWeather public methodology for DEB forecast blending, settlement-source priority, freshness, and market weather analysis.", - alternates: { - canonical: "/methodology", - }, - openGraph: { - title: "Methodology | PolyWeather", - description: - "Public methodology for DEB forecast blending, settlement-source priority, freshness, and market weather analysis.", - url: "/methodology", - }, -}; +type MethodologySearchParams = Promise>; -export default function MethodologyPage() { - return ; +async function resolvePublicContentLocale(searchParams: MethodologySearchParams): Promise { + const params = await searchParams; + const rawLocale = params[LANDING_LOCALE_QUERY_PARAM]; + const queryLocale = Array.isArray(rawLocale) ? rawLocale[0] : rawLocale; + const [cookieStore, headerStore] = await Promise.all([cookies(), headers()]); + return pickLandingLocale( + queryLocale, + cookieStore.get(LANDING_LOCALE_COOKIE)?.value, + headerStore.get("accept-language"), + ); +} + +export async function generateMetadata({ + searchParams, +}: { + searchParams: MethodologySearchParams; +}): Promise { + const locale = await resolvePublicContentLocale(searchParams); + const copy = PUBLIC_CONTENT_COPY[locale]; + return { + title: copy.methodologyIndexEyebrow, + description: copy.methodologyIndexDescription, + alternates: { + canonical: "/methodology", + }, + openGraph: { + title: `${copy.methodologyIndexEyebrow} | PolyWeather`, + description: copy.methodologyIndexDescription, + url: "/methodology", + }, + }; +} + +export default async function MethodologyPage({ + searchParams, +}: { + searchParams: MethodologySearchParams; +}) { + const locale = await resolvePublicContentLocale(searchParams); + return ; } diff --git a/frontend/app/sources/[slug]/page.tsx b/frontend/app/sources/[slug]/page.tsx index 7b95c711..df786ef4 100644 --- a/frontend/app/sources/[slug]/page.tsx +++ b/frontend/app/sources/[slug]/page.tsx @@ -1,16 +1,37 @@ import type { Metadata } from "next"; +import { cookies, headers } from "next/headers"; import { notFound } from "next/navigation"; import { SourceDetailPageView } from "@/components/public-content/PublicContentPages"; +import { + LANDING_LOCALE_COOKIE, + LANDING_LOCALE_QUERY_PARAM, + pickLandingLocale, + type LandingLocale, +} from "@/components/landing/landingLocale"; import { SOURCE_PAGES, absolutePublicUrl, getSourcePage, + localizeSourcePage, sourcePath, } from "@/content/public-content"; type SourcePageParams = { slug: string; }; +type SourceSearchParams = Promise>; + +async function resolvePublicContentLocale(searchParams: SourceSearchParams): Promise { + const params = await searchParams; + const rawLocale = params[LANDING_LOCALE_QUERY_PARAM]; + const queryLocale = Array.isArray(rawLocale) ? rawLocale[0] : rawLocale; + const [cookieStore, headerStore] = await Promise.all([cookies(), headers()]); + return pickLandingLocale( + queryLocale, + cookieStore.get(LANDING_LOCALE_COOKIE)?.value, + headerStore.get("accept-language"), + ); +} export function generateStaticParams() { return SOURCE_PAGES.map((source) => ({ slug: source.slug })); @@ -18,10 +39,15 @@ export function generateStaticParams() { export async function generateMetadata({ params, + searchParams, }: { params: Promise; + searchParams: SourceSearchParams; }): Promise { - const { slug } = await params; + const [{ slug }, locale] = await Promise.all([ + params, + resolvePublicContentLocale(searchParams), + ]); const source = getSourcePage(slug); if (!source) { @@ -29,17 +55,18 @@ export async function generateMetadata({ title: "Source not found", }; } + const localizedSource = localizeSourcePage(source, locale); return { - title: source.title, - description: source.description, + title: localizedSource.title, + description: localizedSource.description, alternates: { canonical: sourcePath(source), }, openGraph: { type: "article", - title: source.title, - description: source.description, + title: localizedSource.title, + description: localizedSource.description, url: sourcePath(source), modifiedTime: source.updatedAt, }, @@ -48,22 +75,28 @@ export async function generateMetadata({ export default async function SourceDetailPage({ params, + searchParams, }: { params: Promise; + searchParams: SourceSearchParams; }) { - const { slug } = await params; + const [{ slug }, locale] = await Promise.all([ + params, + resolvePublicContentLocale(searchParams), + ]); const source = getSourcePage(slug); if (!source) { notFound(); } + const localizedSource = localizeSourcePage(source, locale); const pathname = sourcePath(source); const jsonLd = { "@context": "https://schema.org", "@type": "Dataset", - name: source.title, - description: source.description, + name: localizedSource.title, + description: localizedSource.description, url: absolutePublicUrl(pathname), dateModified: source.updatedAt, creator: { @@ -83,7 +116,7 @@ export default async function SourceDetailPage({ type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} /> - + ); } diff --git a/frontend/app/sources/page.tsx b/frontend/app/sources/page.tsx index fb554b43..8e676443 100644 --- a/frontend/app/sources/page.tsx +++ b/frontend/app/sources/page.tsx @@ -1,21 +1,54 @@ import type { Metadata } from "next"; +import { cookies, headers } from "next/headers"; import { SourcesIndexPageView } from "@/components/public-content/PublicContentPages"; +import { + LANDING_LOCALE_COOKIE, + LANDING_LOCALE_QUERY_PARAM, + pickLandingLocale, + type LandingLocale, +} from "@/components/landing/landingLocale"; +import { PUBLIC_CONTENT_COPY } from "@/content/public-content"; -export const metadata: Metadata = { - title: "Weather Sources", - description: - "PolyWeather public source notes for MGM, METAR, HKO, NOAA, ECMWF, and settlement-source weather analysis.", - alternates: { - canonical: "/sources", - }, - openGraph: { - title: "Weather Sources | PolyWeather", - description: - "Public source notes for MGM, METAR, HKO, NOAA, ECMWF, and settlement-source weather analysis.", - url: "/sources", - }, -}; +type SourcesSearchParams = Promise>; -export default function SourcesPage() { - return ; +async function resolvePublicContentLocale(searchParams: SourcesSearchParams): Promise { + const params = await searchParams; + const rawLocale = params[LANDING_LOCALE_QUERY_PARAM]; + const queryLocale = Array.isArray(rawLocale) ? rawLocale[0] : rawLocale; + const [cookieStore, headerStore] = await Promise.all([cookies(), headers()]); + return pickLandingLocale( + queryLocale, + cookieStore.get(LANDING_LOCALE_COOKIE)?.value, + headerStore.get("accept-language"), + ); +} + +export async function generateMetadata({ + searchParams, +}: { + searchParams: SourcesSearchParams; +}): Promise { + const locale = await resolvePublicContentLocale(searchParams); + const copy = PUBLIC_CONTENT_COPY[locale]; + return { + title: copy.sourceIndexEyebrow, + description: copy.sourceIndexDescription, + alternates: { + canonical: "/sources", + }, + openGraph: { + title: `${copy.sourceIndexEyebrow} | PolyWeather`, + description: copy.sourceIndexDescription, + url: "/sources", + }, + }; +} + +export default async function SourcesPage({ + searchParams, +}: { + searchParams: SourcesSearchParams; +}) { + const locale = await resolvePublicContentLocale(searchParams); + return ; } diff --git a/frontend/components/landing/InstitutionalLandingPage.tsx b/frontend/components/landing/InstitutionalLandingPage.tsx index db64ae02..a6418f82 100644 --- a/frontend/components/landing/InstitutionalLandingPage.tsx +++ b/frontend/components/landing/InstitutionalLandingPage.tsx @@ -7,6 +7,7 @@ import { } from "@/components/landing/LandingAuthActions"; import { LANDING_LOCALE_COOKIE, + landingLocaleHref, pickLandingLocale, type LandingLocale, } from "@/components/landing/landingLocale"; @@ -324,7 +325,7 @@ function InstitutionalLandingScreen({ locale }: { locale: LandingLocale }) { {isEn ? "Guide" : "读图"} - + {isEn ? "Briefs" : "简报"} diff --git a/frontend/components/landing/__tests__/landingPricingReferral.test.ts b/frontend/components/landing/__tests__/landingPricingReferral.test.ts index 791e8bdf..e9383926 100644 --- a/frontend/components/landing/__tests__/landingPricingReferral.test.ts +++ b/frontend/components/landing/__tests__/landingPricingReferral.test.ts @@ -104,7 +104,11 @@ export function runTests() { "landing supported cities must render names and station codes from the generated city groups", ); assert(source.includes("#contact"), "landing navigation must expose the contact section"); - assert(source.includes('href="/briefs"'), "landing navigation must expose public Weather Market Brief assets"); + assert( + source.includes("landingLocaleHref") && + source.includes('href={landingLocaleHref("/briefs", locale)}'), + "landing navigation must expose public Weather Market Brief assets while preserving the current locale", + ); assert(source.includes('isEn ? "Briefs" : "简报"'), "landing page must label the public brief entry in both languages"); assert(source.includes('id="contact"'), "landing page must include a contact section"); assert(source.includes("yhrsc30@gmail.com"), "landing page must show the operator contact email"); diff --git a/frontend/components/landing/landingLocale.ts b/frontend/components/landing/landingLocale.ts index 5d60f550..7abf1729 100644 --- a/frontend/components/landing/landingLocale.ts +++ b/frontend/components/landing/landingLocale.ts @@ -38,3 +38,10 @@ export function pickLandingLocale( export function nextLandingLocale(locale: LandingLocale): LandingLocale { return locale === "zh-CN" ? "en-US" : "zh-CN"; } + +export function landingLocaleHref(pathname: string, locale: LandingLocale): string { + const [pathWithQuery, hash] = pathname.split("#", 2); + const separator = pathWithQuery.includes("?") ? "&" : "?"; + const localizedPath = `${pathWithQuery}${separator}${LANDING_LOCALE_QUERY_PARAM}=${encodeURIComponent(locale)}`; + return hash ? `${localizedPath}#${hash}` : localizedPath; +} diff --git a/frontend/components/public-content/PublicContentPages.tsx b/frontend/components/public-content/PublicContentPages.tsx index f48bda08..84ddfd98 100644 --- a/frontend/components/public-content/PublicContentPages.tsx +++ b/frontend/components/public-content/PublicContentPages.tsx @@ -1,6 +1,6 @@ import Link from "next/link"; import { LandingLocaleToggle } from "@/components/landing/LandingLocaleToggle"; -import type { LandingLocale } from "@/components/landing/landingLocale"; +import { landingLocaleHref, type LandingLocale } from "@/components/landing/landingLocale"; import { METHODOLOGY_PAGES, PUBLIC_CONTENT_COPY, @@ -44,21 +44,21 @@ function PublicHeader({ locale = "en-US" }: { locale?: LandingLocale }) { return (
- + PolyWeather
@@ -103,7 +103,7 @@ function SourceLinks({ locale = "en-US", slugs }: { locale?: LandingLocale; slug return ( {localizedSource.title} @@ -124,7 +124,7 @@ function MethodologyLinks({ locale = "en-US", slugs }: { locale?: LandingLocale; return ( {localizedPage.title} @@ -152,7 +152,7 @@ function BriefCard({ {brief.cityName} / {brief.date}

- {brief.title} + {brief.title}

@@ -170,7 +170,7 @@ function BriefCard({ ))}
- + {readBriefLabel} @@ -291,7 +291,7 @@ export function BriefDetailPageView({ > {localizedBrief.primaryCtaLabel} - + {copy.allPublicBriefs}
@@ -315,7 +315,7 @@ export function BriefDetailPageView({
{copy.shareOnX} @@ -365,26 +365,33 @@ function InfoRow({ label, value }: { label: string; value: string }) { ); } -export function MethodologyIndexPageView() { +export function MethodologyIndexPageView({ + locale = "en-US", +}: { + locale?: LandingLocale; +} = {}) { + const copy = PUBLIC_CONTENT_COPY[locale]; + const pages = METHODOLOGY_PAGES.map((page) => localizeMethodologyPage(page, locale)); + return (
- +
- {METHODOLOGY_PAGES.map((page) => ( + {pages.map((page) => (
-

{formatDate(page.updatedAt)}

+

{formatDate(page.updatedAt, locale)}

- {page.title} + {page.title}

{page.description}

- - Read methodology + + {copy.readMethodology}
))} @@ -394,7 +401,16 @@ export function MethodologyIndexPageView() { ); } -export function MethodologyDetailPageView({ page }: { page: MethodologyPage }) { +export function MethodologyDetailPageView({ + page, + locale = "en-US", +}: { + page: MethodologyPage; + locale?: LandingLocale; +}) { + const copy = PUBLIC_CONTENT_COPY[locale]; + const localizedPage = localizeMethodologyPage(page, locale); + return (
- - + +
-

{page.summary}

+

{localizedPage.summary}

- {page.sections.map((section) => ( + {localizedPage.sections.map((section) => (

{section.heading}

{section.body}

@@ -428,26 +448,33 @@ export function MethodologyDetailPageView({ page }: { page: MethodologyPage }) { ); } -export function SourcesIndexPageView() { +export function SourcesIndexPageView({ + locale = "en-US", +}: { + locale?: LandingLocale; +} = {}) { + const copy = PUBLIC_CONTENT_COPY[locale]; + const sources = SOURCE_PAGES.map((source) => localizeSourcePage(source, locale)); + return (
- +
- {SOURCE_PAGES.map((source) => ( + {sources.map((source) => (

{source.operator}

- {source.title} + {source.title}

{source.description}

- - Read source note + + {copy.readSourceNote}
))} @@ -457,32 +484,60 @@ export function SourcesIndexPageView() { ); } -export function SourceDetailPageView({ source }: { source: SourcePage }) { +export function SourceDetailPageView({ + source, + locale = "en-US", +}: { + source: SourcePage; + locale?: LandingLocale; +}) { + const localizedSource = localizeSourcePage(source, locale); + const labels = + locale === "en-US" + ? { + cadence: "Cadence", + coverage: "Coverage", + operator: "Operator", + reliability: "Reliability notes", + relatedMethodology: "Related methodology", + settlementUse: "Settlement use", + sourceNote: "Source note", + } + : { + cadence: "频率", + coverage: "覆盖范围", + operator: "运营方", + reliability: "可靠性备注", + relatedMethodology: "相关方法", + settlementUse: "结算用途", + sourceNote: "来源说明", + }; + return (
- - + +
- - - - + + + +
-

Reliability notes

+

{labels.reliability}

    - {source.reliabilityNotes.map((note) => ( + {localizedSource.reliabilityNotes.map((note) => (
  • {note}
  • ))}
-

Related methodology

+

{labels.relatedMethodology}

- +
@@ -499,8 +554,8 @@ function formatDateTime(value: string, locale: LandingLocale = "en-US") { }).format(new Date(value)); } -function formatDate(value: string) { - return new Intl.DateTimeFormat("en", { +function formatDate(value: string, locale: LandingLocale = "en-US") { + return new Intl.DateTimeFormat(locale === "en-US" ? "en" : "zh-CN", { dateStyle: "medium", }).format(new Date(value)); } diff --git a/frontend/components/public-content/__tests__/publicContentAssets.test.ts b/frontend/components/public-content/__tests__/publicContentAssets.test.ts index 26a994dd..3228c99d 100644 --- a/frontend/components/public-content/__tests__/publicContentAssets.test.ts +++ b/frontend/components/public-content/__tests__/publicContentAssets.test.ts @@ -17,6 +17,7 @@ export function runTests() { const analyticsPath = path.join(root, "lib", "app-analytics.ts"); const analyticsIslandPath = path.join(root, "components", "public-content", "PublicContentAnalytics.tsx"); const publicPagesPath = path.join(root, "components", "public-content", "PublicContentPages.tsx"); + const localeHelperPath = path.join(root, "components", "landing", "landingLocale.ts"); const sitemapPath = path.join(root, "app", "sitemap.ts"); for (const requiredPath of [ @@ -35,11 +36,14 @@ export function runTests() { const content = fs.readFileSync(contentPath, "utf8"); const briefDetail = fs.readFileSync(briefDetailPath, "utf8"); + const methodologyIndex = fs.readFileSync(methodologyIndexPath, "utf8"); const methodologyDetail = fs.readFileSync(methodologyDetailPath, "utf8"); + const sourcesIndex = fs.readFileSync(sourcesIndexPath, "utf8"); const sourceDetail = fs.readFileSync(sourceDetailPath, "utf8"); const analytics = fs.readFileSync(analyticsPath, "utf8"); const analyticsIsland = fs.readFileSync(analyticsIslandPath, "utf8"); const publicPages = fs.readFileSync(publicPagesPath, "utf8"); + const localeHelper = fs.readFileSync(localeHelperPath, "utf8"); const sitemap = fs.readFileSync(sitemapPath, "utf8"); const briefsIndex = fs.readFileSync(briefsIndexPath, "utf8"); @@ -68,6 +72,13 @@ export function runTests() { content.includes("阅读简报"), "public brief content must provide Chinese and English localized copy", ); + assert( + content.includes("METHODOLOGY_PAGE_LOCALIZATIONS") && + content.includes("SOURCE_PAGE_LOCALIZATIONS") && + content.includes("DEB 不是结算预言机") && + content.includes("官方摘要可能滞后于原始点位观测"), + "public methodology and source pages must provide Chinese body-level localization, not title-only translation", + ); assert( content.includes("24.5°C") && content.includes("27.1°C") && @@ -104,6 +115,30 @@ export function runTests() { publicPages.includes('locale === "en-US" ? "Briefs" : "简报"'), "public content pages must render the shared language toggle and localized brief copy", ); + assert( + localeHelper.includes("export function landingLocaleHref") && + localeHelper.includes("LANDING_LOCALE_QUERY_PARAM"), + "landing locale helpers must expose a shared href builder for explicit locale navigation", + ); + assert( + publicPages.includes("landingLocaleHref") && + publicPages.includes('href={landingLocaleHref("/briefs", locale)}') && + publicPages.includes("href={landingLocaleHref(briefPath(brief), locale)}") && + publicPages.includes("href={landingLocaleHref(methodologyPath(localizedPage), locale)}") && + publicPages.includes("href={landingLocaleHref(sourcePath(localizedSource), locale)}"), + "public content brief cards, nav, methodology, and source links must preserve the current locale", + ); + assert( + methodologyIndex.includes("resolvePublicContentLocale(searchParams)") && + methodologyIndex.includes("") && + methodologyDetail.includes("resolvePublicContentLocale(searchParams)") && + methodologyDetail.includes("") && + sourcesIndex.includes("resolvePublicContentLocale(searchParams)") && + sourcesIndex.includes("") && + sourceDetail.includes("resolvePublicContentLocale(searchParams)") && + sourceDetail.includes(""), + "methodology and source public routes must resolve and pass the same locale as brief routes", + ); assert( analytics.includes('"brief_view"') && analytics.includes('"brief_cta_click"') && diff --git a/frontend/content/public-content.ts b/frontend/content/public-content.ts index 84c16b89..5725b8e9 100644 --- a/frontend/content/public-content.ts +++ b/frontend/content/public-content.ts @@ -661,17 +661,143 @@ const BRIEF_LOCALIZATIONS: Record> = { }, }; -const METHODOLOGY_TITLE_LOCALIZATIONS: Record = { - deb: "DEB 预测方法", - "settlement-sources": "结算源优先级", +const METHODOLOGY_PAGE_LOCALIZATIONS: Record> = { + deb: { + title: "DEB 预测方法", + description: + "PolyWeather 如何把 DEB 融合预报用于预测市场温度判断,同时不替代结算源证据。", + summary: + "DEB 是 PolyWeather 融合预报层的公开名称。它把模型指引、实时观测动量、来源新鲜度和站点上下文放在一起,帮助用户用更少单模型误判来判断最高温区间。", + sections: [ + { + heading: "DEB 用来做什么", + body: + "DEB 不是结算预言机。它是决策辅助层,让实时观测路径和模型分歧更容易对比。", + bullets: [ + "当模型指引与结算源证据冲突时,优先看结算源证据。", + "把过期或孤立的来源值视为需要质检的候选点。", + "展示预报区间以及区间变宽或收窄的原因。", + ], + }, + { + heading: "关键输入", + body: + "融合层有用,是因为它组合了不同证据类别,而不是假设一次模型运行就足够。", + bullets: [ + "最新官方或机场观测及其新鲜度。", + "ECMWF、GFS、ICON、GEM 以及可用本地来源之间的模型共识和分歧。", + "城市特定来源行为、站点选择和日内最高温时段。", + ], + }, + { + heading: "如何复盘 DEB 偏差", + body: + "DEB 偏差应拆开来源新鲜度、模型分歧和结算源修订来复盘。", + bullets: [ + "如果晚到来源 patch 改变了观测高温,将其归类为新鲜度或 replay 问题。", + "如果所有来源都新鲜但高温落在区间外,复盘模型权重和本地站点特征。", + "如果只有单一来源跳变,先审计相邻观测,再围绕异常点重训。", + ], + }, + ], + }, + "settlement-sources": { + title: "结算源优先级", + description: + "为什么 PolyWeather 先展示官方结算相关观测,而不是通用天气 API 数值。", + summary: + "预测市场用户关心的是合约最终结算数字。因此 PolyWeather 把官方站、机场和运营方特定来源行为放在泛消费者天气均值之上。", + sections: [ + { + heading: "为什么通用天气值不够", + body: + "消费者天气应用经常平滑站点数据,或展示城市级近似值。市场结算可能依赖更窄的官方来源。", + bullets: [ + "一个城市标签可能隐藏多个日高温不同的站点。", + "机场 METAR 可能比公开摘要更新更快,但未必是最终结算源。", + "官方来源修订往往比平滑的预报曲线更重要。", + ], + }, + { + heading: "PolyWeather 展示什么", + body: + "终端会拆开来源标签、观测时间戳、预报模型和新鲜度状态,让用户审计数字形成路径。", + bullets: [ + "图表中的结算源标签和站点上下文。", + "来源新鲜度、缓存策略和 SSE patch 可见性。", + "DEB 预报上下文与实时观测并列展示,而不是替代观测。", + ], + }, + ], + }, }; -const SOURCE_TITLE_LOCALIZATIONS: Record = { - ecmwf: "ECMWF 模型指引", - hko: "HKO 官方观测", - metar: "METAR 机场观测", - mgm: "MGM 天气来源", - noaa: "NOAA 天气上下文", +const SOURCE_PAGE_LOCALIZATIONS: Record> = { + mgm: { + title: "MGM 天气来源", + description: "PolyWeather 针对土耳其 MGM 观测的来源说明,用于安卡拉类温度市场分析。", + operator: "土耳其国家气象局", + coverage: "土耳其官方站网络,包含安卡拉市场上下文。", + cadence: "来源频率会随站点和发布路径变化;终端仍需要检查新鲜度。", + settlementUse: "当安卡拉市场引用土耳其官方观测时,作为主要官方来源族使用。", + reliabilityNotes: [ + "单点尖峰在接受前应与相邻时间戳比对。", + "官方摘要可能滞后于原始点位观测。", + "数值突然出现时,应检查缓存和 SSE replay 状态。", + ], + }, + metar: { + title: "METAR 机场观测", + description: "PolyWeather 针对机场 METAR 观测的来源说明,用作温度市场工作流中的快速证据。", + operator: "机场天气观测网络", + coverage: "覆盖受支持市场的机场关联观测。", + cadence: "通常为小时级或更短,取决于机场和发布行为。", + settlementUse: "适合快速证据和机场关联合约;仍必须与合约的精确结算源校验。", + reliabilityNotes: [ + "METAR 可能比官方日摘要更新更快。", + "机场暴露环境可能不同于市区官方站。", + "临近收盘的晚些 METAR 周期可能改变最高温判断。", + ], + }, + ecmwf: { + title: "ECMWF 模型指引", + description: "PolyWeather 针对 ECMWF 模型指引的来源说明,它是 DEB 融合预报的一个输入。", + operator: "欧洲中期天气预报中心", + coverage: "全球数值天气预报指引。", + cadence: "模型运行频率取决于产品和摄取时间。", + settlementUse: "仅用于预报上下文,不替代实时官方观测的结算解释。", + reliabilityNotes: [ + "模型偏暖或偏冷都应由实时观测验证。", + "当来源证据尚未稳定时,模型运行间变化可以提供参考。", + "模型分歧应与结算源数据并列展示,而不是凌驾其上。", + ], + }, + hko: { + title: "HKO 官方观测", + description: "PolyWeather 针对香港天文台观测的来源说明,用于香港市场分析。", + operator: "香港天文台", + coverage: "香港官方观测网络和站点级上下文。", + cadence: "观测产品频率不同;终端仍应检查来源新鲜度。", + settlementUse: "作为香港站点和城市市场解释中的官方来源族使用。", + reliabilityNotes: [ + "站点选择可能显著改变最高温读数。", + "机场观测应与更广泛的 HKO 网络读数分开解释。", + "湿度、风和午后短时日照会影响最终高温。", + ], + }, + noaa: { + title: "NOAA 天气上下文", + description: "PolyWeather 针对 NOAA 上下文的来源说明,用于校验美国天气市场观测和摘要。", + operator: "美国国家海洋和大气管理局", + coverage: "美国官方天气观测、摘要和上下文产品。", + cadence: "频率取决于产品族和站点报告行为。", + settlementUse: "当合约规则引用 NOAA/NWS 数据时,用于审计和解释美国官方观测。", + reliabilityNotes: [ + "官方摘要可能晚于快速机场观测到达。", + "日高温解释应匹配合约的站点和时区规则。", + "使用 NOAA 上下文确认机场观测是否具代表性。", + ], + }, }; export function localizeBrief(brief: PublicBrief, locale: LandingLocale): PublicBrief { @@ -693,17 +819,22 @@ export function localizeBriefs(locale: LandingLocale) { export function localizeMethodologyPage(page: MethodologyPage, locale: LandingLocale): MethodologyPage { if (locale === "en-US") return page; + const localized = METHODOLOGY_PAGE_LOCALIZATIONS[page.slug] || {}; return { ...page, - title: METHODOLOGY_TITLE_LOCALIZATIONS[page.slug] || page.title, + ...localized, + sections: localized.sections || page.sections, }; } export function localizeSourcePage(page: SourcePage, locale: LandingLocale): SourcePage { if (locale === "en-US") return page; + const localized = SOURCE_PAGE_LOCALIZATIONS[page.slug] || {}; return { ...page, - title: SOURCE_TITLE_LOCALIZATIONS[page.slug] || page.title, + ...localized, + reliabilityNotes: localized.reliabilityNotes || page.reliabilityNotes, + relatedMethodologySlugs: localized.relatedMethodologySlugs || page.relatedMethodologySlugs, }; }