新增终端城市大洲分组设计文档

覆盖桌面端 9 列分区分组表格、移动端 Tab+卡片流布局方案。所有数据字段已就绪,无需后端改动。
This commit is contained in:
2569718930@qq.com
2026-05-25 01:35:24 +08:00
parent 637b580bbd
commit 6672f02989
12 changed files with 6 additions and 481 deletions
+5 -2
View File
@@ -23,12 +23,15 @@ NEXT_PUBLIC_SITE_URL=https://polyweather-pro.vercel.app
# 常用:前端鉴权开关
# true: 启用 Supabase 登录
# false: 关闭登录能力,访客模式
POLYWEATHER_AUTH_ENABLED=false
POLYWEATHER_AUTH_ENABLED=true
# 常用:是否强制登录
# true: middleware 强制登录后才能访问主页面
# false: 登录可选,访客可浏览
POLYWEATHER_AUTH_REQUIRED=false
POLYWEATHER_AUTH_REQUIRED=true
# 关闭本地开发鉴权绕过
NEXT_PUBLIC_POLYWEATHER_LOCAL_FULL_ACCESS=false
# 可选:分享式看板访问令牌
# 设置后,可通过 /?access_token=<token> 打开受保护看板
@@ -1,19 +0,0 @@
"use client";
import dynamic from "next/dynamic";
import { DashboardShellSkeleton } from "@/components/dashboard/DashboardShellSkeleton";
const ScanTerminalDashboard = dynamic(
() =>
import("@/components/dashboard/ScanTerminalDashboard").then(
(module) => module.ScanTerminalDashboard,
),
{
ssr: false,
loading: () => <DashboardShellSkeleton />,
},
);
export function DashboardEntry() {
return <ScanTerminalDashboard />;
}
@@ -1,314 +0,0 @@
"use client";
import { Search, ChevronDown } from "lucide-react";
import { useMemo, useState } from "react";
import type { ScanOpportunityRow } from "@/lib/dashboard-types";
type Locale = "zh-CN" | "en-US";
type CityRegionGroup = {
key: string;
label: { en: string; zh: string };
cities: string[];
};
const CITY_REGION_GROUPS: CityRegionGroup[] = [
{
key: "eastAsia",
label: { en: "East Asia", zh: "东亚" },
cities: [
"tokyo", "seoul", "busan", "beijing", "shanghai", "chengdu",
"chongqing", "wuhan", "shenzhen", "guangzhou", "qingdao",
"hong kong", "lau fau shan", "taipei",
],
},
{
key: "southeastAsia",
label: { en: "Southeast Asia", zh: "东南亚" },
cities: ["singapore", "kuala lumpur", "jakarta", "manila"],
},
{
key: "southAsia",
label: { en: "South Asia", zh: "南亚" },
cities: ["karachi", "lucknow"],
},
{
key: "middleEast",
label: { en: "Middle East", zh: "中东" },
cities: ["tel aviv", "jeddah", "istanbul", "ankara"],
},
{
key: "europe",
label: { en: "Europe", zh: "欧洲" },
cities: [
"london", "paris", "moscow", "munich", "milan", "warsaw",
"helsinki", "amsterdam", "madrid",
],
},
{
key: "northAmerica",
label: { en: "North America", zh: "北美" },
cities: [
"new york", "los angeles", "san francisco", "denver", "austin",
"houston", "chicago", "dallas", "miami", "atlanta", "seattle",
"toronto", "mexico city", "panama city",
],
},
{
key: "southAmerica",
label: { en: "South America", zh: "南美" },
cities: ["buenos aires", "são paulo"],
},
{
key: "africa",
label: { en: "Africa", zh: "非洲" },
cities: ["cape town"],
},
{
key: "oceania",
label: { en: "Oceania", zh: "大洋洲" },
cities: ["wellington"],
},
];
const CITY_SEARCH_INDEX: Record<string, string[]> = {
"tokyo": ["东京", "東京", "RJTT", "tok", "tyo", "haneda", "羽田"],
"seoul": ["首尔", "RKSI", "sel", "seo", "incheon", "仁川"],
"busan": ["釜山", "RKPK", "pus", "bus", "gimhae", "金海"],
"beijing": ["北京", "ZBAA", "pek", "bjs", "bj", "capital", "首都"],
"shanghai": ["上海", "ZSPD", "sha", "sh", "pudong", "浦东"],
"chengdu": ["成都", "ZUUU", "ctu", "cd", "shuangliu", "双流"],
"chongqing": ["重庆", "ZUCK", "ckg", "cq", "jiangbei", "江北"],
"wuhan": ["武汉", "ZHHH", "wuh", "wh", "tianhe", "天河"],
"shenzhen": ["深圳", "ZGSZ", "szx", "sz", "baoan", "宝安"],
"guangzhou": ["广州", "ZGGG", "can", "gz", "baiyun", "白云"],
"qingdao": ["青岛", "青島", "ZSQD", "tao", "qdo", "jiaodong", "胶东"],
"hong kong": ["香港", "VHHH", "hkg", "hk", "observatory", "天文台"],
"lau fau shan": ["流浮山", "LFS", "lfs"],
"taipei": ["台北", "臺北", "RCSS", "tpe", "tp", "台湾", "臺灣"],
"singapore": ["新加坡", "WSSS", "sin", "sg", "changi", "樟宜"],
"kuala lumpur": ["吉隆坡", "WMKK", "kul", "sepang", "雪邦"],
"jakarta": ["雅加达", "雅加達", "WIHH", "jkt", "halim"],
"manila": ["马尼拉", "馬尼拉", "RPLL", "mnl", "ninoy"],
"karachi": ["卡拉奇", "OPKC", "khi", "jinnah", "真纳"],
"lucknow": ["勒克瑙", "VILK", "luc"],
"tel aviv": ["特拉维夫", "LLBG", "tlv", "ben gurion"],
"jeddah": ["吉达", "吉達", "OEJN", "jed", "king abdulaziz", "阿卜杜勒阿齐兹"],
"istanbul": ["伊斯坦布尔", "LTFM", "ist", "ltfm"],
"ankara": ["安卡拉", "LTAC", "ank", "esenboğa"],
"london": ["伦敦", "EGLC", "lon", "city airport"],
"paris": ["巴黎", "LFPB", "par", "le bourget"],
"moscow": ["莫斯科", "UUWW", "mos", "mow", "vnukovo"],
"munich": ["慕尼黑", "EDDM", "mun"],
"milan": ["米兰", "米蘭", "LIMC", "mil", "mxp", "malpensa", "马尔彭萨"],
"warsaw": ["华沙", "華沙", "EPWA", "waw", "war", "chopin", "肖邦"],
"helsinki": ["赫尔辛基", "赫爾辛基", "EFHK", "hel", "vantaa"],
"amsterdam": ["阿姆斯特丹", "EHAM", "ams", "schiphol", "史基浦"],
"madrid": ["马德里", "馬德里", "LEMD", "mad", "barajas"],
"new york": ["纽约", "KLGA", "nyc", "ny", "laguardia"],
"los angeles": ["洛杉矶", "KLAX", "la", "lax"],
"san francisco": ["旧金山", "KSFO", "sf", "sfo"],
"denver": ["丹佛", "奥罗拉", "KBKF", "aur", "buckley"],
"austin": ["奥斯汀", "KAUS", "aus"],
"houston": ["休斯顿", "KHOU", "hou", "hobby"],
"chicago": ["芝加哥", "KORD", "chi", "ohare"],
"dallas": ["达拉斯", "KDAL", "dal", "love field"],
"miami": ["迈阿密", "KMIA", "mia"],
"atlanta": ["亚特兰大", "KATL", "atl", "hartsfield"],
"seattle": ["西雅图", "KSEA", "sea", "seatac"],
"toronto": ["多伦多", "CYYZ", "tor", "pearson"],
"mexico city": ["墨西哥城", "MMMX", "cdmx"],
"panama city": ["巴拿马城", "MPMG", "pty"],
"buenos aires": ["布宜诺斯艾利斯", "SAEZ", "ba", "ezeiza"],
"são paulo": ["圣保罗", "SBGR", "sp", "guarulhos"],
"cape town": ["开普敦", "開普敦", "FACT", "cpt"],
"wellington": ["惠灵顿", "NZWN", "wel"],
};
function normalizeCityKey(name: string): string {
return String(name || "").trim().toLowerCase();
}
function matchesCitySearch(
cityKey: string,
displayName: string,
query: string,
): boolean {
const q = query.trim().toLowerCase();
if (!q) return true;
if (cityKey.includes(q)) return true;
if (displayName.toLowerCase().includes(q)) return true;
const aliases = CITY_SEARCH_INDEX[cityKey];
if (!aliases) return false;
return aliases.some((alias) => alias.toLowerCase().includes(q));
}
function pickCityRow(
rows: ScanOpportunityRow[],
cityKey: string,
): ScanOpportunityRow | null {
return (
rows.find(
(row) => normalizeCityKey(row.city) === cityKey,
) || null
);
}
export function MobileCityPicker({
isEn,
rows,
onSelectCity,
}: {
isEn: boolean;
rows: ScanOpportunityRow[];
onSelectCity: (row: ScanOpportunityRow) => void;
}) {
const [searchQuery, setSearchQuery] = useState("");
const [expandedRegions, setExpandedRegions] = useState<Set<string>>(
() => new Set(CITY_REGION_GROUPS.map((g) => g.key)),
);
const filteredGroups = useMemo(() => {
const q = searchQuery.trim().toLowerCase();
return CITY_REGION_GROUPS
.map((group) => {
const matched = group.cities.filter((cityKey) => {
const row = pickCityRow(rows, cityKey);
if (!row) return false;
const display =
row.city_display_name || row.display_name || cityKey;
return matchesCitySearch(cityKey, display, q);
});
return { ...group, matched };
})
.filter((group) => group.matched.length > 0);
}, [rows, searchQuery]);
const totalMatched = useMemo(
() => filteredGroups.reduce((sum, g) => sum + g.matched.length, 0),
[filteredGroups],
);
const toggleRegion = (key: string) => {
setExpandedRegions((prev) => {
const next = new Set(prev);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
return next;
});
};
return (
<div className="scan-mobile-city-picker">
<div className="scan-mobile-picker-search">
<Search size={16} className="scan-mobile-picker-search-icon" />
<input
type="text"
className="scan-mobile-picker-search-input"
placeholder={
isEn
? "Search city, airport code, or Chinese name..."
: "搜索城市、机场代码或中文名..."
}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
{searchQuery ? (
<button
type="button"
className="scan-mobile-picker-search-clear"
onClick={() => setSearchQuery("")}
>
</button>
) : null}
</div>
{searchQuery ? (
<div className="scan-mobile-picker-result-count">
{isEn
? `${totalMatched} city${totalMatched !== 1 ? "ies" : "y"} found`
: `找到 ${totalMatched} 个城市`}
</div>
) : null}
{filteredGroups.length === 0 ? (
<div className="scan-mobile-picker-empty">
{isEn ? "No cities match your search" : "没有匹配的城市"}
</div>
) : (
<div className="scan-mobile-picker-regions">
{filteredGroups.map((group) => {
const isExpanded = expandedRegions.has(group.key) || !!searchQuery;
return (
<div key={group.key} className="scan-mobile-picker-region">
<button
type="button"
className="scan-mobile-picker-region-head"
onClick={() => toggleRegion(group.key)}
>
<span>
{isEn ? group.label.en : group.label.zh}
<small>{group.matched.length}</small>
</span>
<ChevronDown
size={16}
className={isExpanded ? "expanded" : ""}
/>
</button>
{isExpanded ? (
<div className="scan-mobile-picker-region-cities">
{group.matched.map((cityKey) => {
const row = pickCityRow(rows, cityKey);
if (!row) return null;
const display =
row.city_display_name ||
row.display_name ||
cityKey;
const currentTemp =
row.current_temp ?? row.current_max_so_far ?? null;
const deb = row.deb_prediction ?? null;
const tempUnit = row.temp_symbol || "°C";
return (
<button
key={cityKey}
type="button"
className="scan-mobile-picker-city-row"
onClick={() => onSelectCity(row)}
>
<span className="scan-mobile-picker-city-name">
<b>{display}</b>
<small>
{row.airport || row.local_time || ""}
</small>
</span>
<span className="scan-mobile-picker-city-temp">
<b>
{currentTemp != null &&
Number.isFinite(Number(currentTemp))
? `${Number(currentTemp).toFixed(1)}${tempUnit}`
: "--"}
</b>
<small>
DEB{" "}
{deb != null && Number.isFinite(Number(deb))
? `${Number(deb).toFixed(1)}${tempUnit}`
: "--"}
</small>
</span>
</button>
);
})}
</div>
) : null}
</div>
);
})}
</div>
)}
</div>
);
}
@@ -4,7 +4,6 @@ import clsx from "clsx";
import Link from "next/link";
import type { Dispatch, SetStateAction } from "react";
import { LogIn, UserRound } from "lucide-react";
import { ProFeaturePaywall } from "@/components/dashboard/ProFeaturePaywall";
import { LoadingSignal } from "@/components/dashboard/scan-terminal/LoadingSignal";
import type { Locale } from "@/lib/i18n";
@@ -137,27 +136,3 @@ export function ScanTerminalTopBar({
</div>
);
}
export function ScanPaywallModal({
isEn,
onClose,
}: {
isEn: boolean;
onClose: () => void;
}) {
return (
<div
className="modal-overlay"
role="dialog"
aria-modal="true"
aria-label={isEn ? "Unlock market scan" : "解锁市场扫描"}
onClick={(event) => {
if (event.target === event.currentTarget) {
onClose();
}
}}
>
<ProFeaturePaywall feature="scan" onClose={onClose} />
</div>
);
}
@@ -1,120 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { MapPin, FileText, Crown } from "lucide-react";
const STORAGE_KEY = "polyweather_welcome_seen";
type Props = {
locale: string;
onDismiss: () => void;
};
const steps = [
{
icon: MapPin,
titleEn: "Pick a city on the map",
titleZh: "从地图选城市",
descEn: "Click any colored bubble to view real-time weather and forecasts.",
descZh: "点击地图上的彩色气泡,查看实时天气和预报。",
},
{
icon: FileText,
titleEn: "Read the city briefing",
titleZh: "查看城市简报",
descEn: "The right panel shows current temperature, DEB forecast, and detailed metrics.",
descZh: "右侧面板展示当前温度、DEB 预报和各项指标。",
},
{
icon: Crown,
titleEn: "Unlock Pro for deep analysis",
titleZh: "解锁 Pro 深入分析",
descEn: "Pro unlocks AI-powered METAR reads, model evidence, market layer, and intraday analysis.",
descZh: "Pro 解锁 AI 机场报文解读、模型证据、市场层、日内分析。",
},
];
export function WelcomeOverlay({ locale, onDismiss }: Props) {
const [visible, setVisible] = useState(false);
const [step, setStep] = useState(0);
const isEn = locale === "en-US";
useEffect(() => {
const seen = localStorage.getItem(STORAGE_KEY);
if (!seen) {
setVisible(true);
}
}, []);
if (!visible) return null;
const current = steps[step];
const isLast = step === steps.length - 1;
const handleDismiss = () => {
localStorage.setItem(STORAGE_KEY, "1");
setVisible(false);
onDismiss();
};
const handleNext = () => {
if (isLast) {
handleDismiss();
} else {
setStep(step + 1);
}
};
return (
<div
className="scan-welcome-overlay"
role="dialog"
aria-label={isEn ? "Welcome guide" : "新手指引"}
onClick={handleDismiss}
>
<div
className="scan-welcome-card"
onClick={(e) => e.stopPropagation()}
>
<div className="scan-welcome-steps">
{steps.map((_, i) => (
<span
key={i}
className={`scan-welcome-dot ${i === step ? "active" : ""} ${i < step ? "done" : ""}`}
/>
))}
</div>
<div className="scan-welcome-icon-wrap">
<current.icon size={32} className="scan-welcome-icon" />
</div>
<h2 className="scan-welcome-title">
{isEn ? current.titleEn : current.titleZh}
</h2>
<p className="scan-welcome-desc">
{isEn ? current.descEn : current.descZh}
</p>
<div className="scan-welcome-actions">
<button
type="button"
className="scan-welcome-skip"
onClick={handleDismiss}
>
{isEn ? "Skip" : "跳过"}
</button>
<button
type="button"
className="scan-primary-button"
onClick={handleNext}
>
{isLast
? isEn ? "Got it" : "知道了"
: isEn ? "Next" : "下一步"}
</button>
</div>
</div>
</div>
);
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 193 KiB

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 481 B

After

Width:  |  Height:  |  Size: 541 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

+1 -1
View File
@@ -1 +1 @@
{"background_color":"#ffffff","display":"standalone","icons":[{"sizes":"192x192","src":"/android-chrome-192x192.png","type":"image/png"},{"sizes":"512x512","src":"/android-chrome-512x512.png","type":"image/png"}],"name":"","short_name":"","theme_color":"#ffffff"}
{"background_color":"#ffffff","display":"standalone","icons":[{"sizes":"192x192","src":"/android-chrome-192x192.png","type":"image/png"},{"sizes":"512x512","src":"/android-chrome-512x512.png","type":"image/png"}],"name":"PolyWeather","short_name":"PolyWeather","theme_color":"#171d24"}