From 58e6557c66b02ffc146beae52a8dfdfbc5596ce5 Mon Sep 17 00:00:00 2001
From: "2569718930@qq.com" <2569718930@qq.com>
Date: Sun, 17 May 2026 17:05:47 +0800
Subject: [PATCH] feat: implement mobile scan terminal dashboard and city
picker with backend forum automation scripts
---
frontend/components/account/AccountCenter.tsx | 39 +++
.../dashboard/ScanTerminalDashboard.tsx | 124 ++-----
.../dashboard/ScanTerminalMobile.module.css | 213 ++++++++++++
.../scan-terminal/AiEvidencePanel.tsx | 5 -
.../scan-terminal/AiPinnedCityCard.tsx | 79 +----
.../scan-terminal/CityCardHeader.tsx | 23 --
.../scan-terminal/MobileCityPicker.tsx | 315 ++++++++++++++++++
.../scan-terminal/MobileDecisionCard.tsx | 50 +--
scripts/create_forum_topics.py | 162 +++++++++
scripts/delete_forum_topic.py | 47 +++
scripts/scan_forum_topics.py | 73 ++++
src/utils/telegram_push.py | 54 ++-
12 files changed, 942 insertions(+), 242 deletions(-)
create mode 100644 frontend/components/dashboard/scan-terminal/MobileCityPicker.tsx
create mode 100644 scripts/create_forum_topics.py
create mode 100644 scripts/delete_forum_topic.py
create mode 100644 scripts/scan_forum_topics.py
diff --git a/frontend/components/account/AccountCenter.tsx b/frontend/components/account/AccountCenter.tsx
index 913eb8e6..6a44d544 100644
--- a/frontend/components/account/AccountCenter.tsx
+++ b/frontend/components/account/AccountCenter.tsx
@@ -219,6 +219,7 @@ const TELEGRAM_BOT_URL = String(
process.env.NEXT_PUBLIC_TELEGRAM_BOT_URL || "https://t.me/WeatherQuant_bot",
).trim();
const TELEGRAM_MARKET_CHANNEL_URL = "https://t.me/+hGAk7JsjtdhiOTUx";
+const TELEGRAM_TOPICS_GROUP_URL = "https://t.me/+8vel7rwjZagxODUx";
const SUBSCRIPTION_HELP_HREF = "/subscription-help";
const PAYMENT_RECOVERY_STORAGE_KEY = "polyweather:lastPaymentRecovery";
const PAYMENT_RECOVERY_TTL_MS = 6 * 60 * 60 * 1000;
@@ -708,6 +709,16 @@ export function AccountCenter() {
telegramHint: isEn
? "Send the command below to the polyweather bot to sync notifications and access."
: "将下方命令发送给polyweather机器人,实现全平台气象查询与权限同步。",
+ telegramUpgradeTitle: isEn
+ ? "Channel Upgrade: New Topics Group Live"
+ : "频道升级通知:新话题群组已上线",
+ telegramUpgradeBody: isEn
+ ? "Real-time airport temperature pushes are migrating to the new PolyWeather Topics group, organized by city for better tracking. Current channel will stop updating and be deleted in 3 days — please join the new group now."
+ : "为了更好地按城市分类推送,后续实时机场温度推送将迁移至新的 PolyWeather Topics 群组。当前频道将在 3 天后停止更新并删除,请及时迁移。",
+ telegramUpgradeFeatures: isEn
+ ? "Independent city topics:"
+ : "支持独立城市话题订阅:",
+ telegramUpgradeJoin: isEn ? "Join New Group" : "加入新群",
paymentManualSupport: isEn
? "If payment succeeds but Pro is still not activated, email yhrsc30@gmail.com. This project is currently maintained by one developer, so manual recovery may be needed in edge cases."
: "如果付款成功后 Pro 仍未开通,请发邮件到 yhrsc30@gmail.com。当前项目由我一人维护,极少数边缘情况可能需要人工补开。给你带来的不便,敬请谅解!",
@@ -2755,6 +2766,34 @@ export function AccountCenter() {
{copy.telegramHint}
+
+ {/* Channel Upgrade / Migration Notice — Pro users only */}
+ {isSubscribed ? (
+
+
+ {copy.telegramUpgradeTitle}
+
+
+ {copy.telegramUpgradeBody}
+
+
+ {copy.telegramUpgradeFeatures}
+
+
+ 🇯🇵 Tokyo · 🇰🇷 Seoul · 🇰🇷 Busan · 🇨🇳 Beijing · 🇨🇳 Shanghai · 🇨🇳 Guangzhou · 🇨🇳 Chengdu · 🇨🇳 Wuhan · 🇭🇰 Hong Kong · 🇹🇼 Taipei · 🇸🇬 Singapore · 🇹🇷 Istanbul · 🇫🇮 Helsinki · 🇳🇱 Amsterdam · 🇫🇷 Paris · 🇺🇸 New York · 🇺🇸 Los Angeles · 🇺🇸 Chicago
+
+
+ {copy.telegramUpgradeJoin}
+
+
+
+ ) : null}
+
{TELEGRAM_BOT_URL ? (
(current === "map" ? "city-list" : current));
+ } else {
+ setActiveView((current) => (current === "city-list" ? "map" : current));
}
};
syncMobileViewport();
@@ -343,72 +345,11 @@ function ScanTerminalScreen() {
const renderMainView = () => {
if (resolvedView === "city-list") {
return (
-
-
-
- {isEn ? "Lightweight mobile entry" : "移动端轻量入口"}
- {isEn ? "Pick one city to inspect" : "选择一个城市进入决策卡"}
-
-
- {isEn
- ? "Cached scan rows are used first. The map and heavier evidence load only when opened."
- : "优先使用缓存扫描结果;地图和重证据只在打开后加载。"}
-
-
-
- {timeSortedRows.slice(0, 24).map((row, index) => {
- const cityName = row.city || row.city_display_name || row.display_name || "";
- const display = row.city_display_name || row.display_name || cityName;
- const currentTemp = row.current_temp ?? row.current_max_so_far ?? null;
- const deb = row.deb_prediction ?? null;
- return (
- handleOpenDecisionRow(row)}
- >
- {String(index + 1).padStart(2, "0")}
-
- {display}
- {row.market_question || row.target_label || row.local_time || "--"}
-
-
-
- {currentTemp != null && Number.isFinite(Number(currentTemp))
- ? `${Number(currentTemp).toFixed(1)}${row.temp_symbol || "°C"}`
- : "--"}
-
-
- DEB{" "}
- {deb != null && Number.isFinite(Number(deb))
- ? `${Number(deb).toFixed(1)}${row.temp_symbol || "°C"}`
- : "--"}
-
-
-
- );
- })}
-
- {!timeSortedRows.length ? (
-
-
- {scanLoading
- ? isEn
- ? "Loading cached scan"
- : "正在读取缓存扫描"
- : isEn
- ? "No city rows yet"
- : "暂无城市列表"}
-
-
- {isEn
- ? "Use refresh only when you need a new server-side scan."
- : "只有需要新的服务端扫描时再手动刷新。"}
-
-
- ) : null}
-
+
);
}
if (resolvedView === "map") {
@@ -508,29 +449,32 @@ function ScanTerminalScreen() {
-
{
- setActiveView("city-list");
- }}
- >
- {isEn ? "City List" : "城市列表"}
-
-
{
- lastMapSelectedCityRef.current = normalizeCityKey(store.selectedCity);
- setActiveView("map");
- }}
- >
- {isEn ? "Distribution View" : "分布视图"}
-
+ {isMobileViewport ? (
+
{
+ setActiveView("city-list");
+ }}
+ >
+ {isEn ? "City List" : "城市列表"}
+
+ ) : (
+
{
+ lastMapSelectedCityRef.current = normalizeCityKey(store.selectedCity);
+ setActiveView("map");
+ }}
+ >
+ {isEn ? "Distribution View" : "分布视图"}
+
+ )}
{line}
))}
- {rawObservationText}
>
) : aiForecast.status === "ready" ? (
<>
@@ -181,7 +178,6 @@ export function AiEvidencePanel({
{fallbackAiReason ? {fallbackAiReason} : null}
{localModelSupportNote}
- {rawObservationText}
>
) : aiForecast.status === "failed" ? (
@@ -190,7 +186,6 @@ export function AiEvidencePanel({
{aiForecast.error ? {aiForecast.error} : null}
{localModelSupportNote}
- {rawObservationText}
>
) : (
diff --git a/frontend/components/dashboard/scan-terminal/AiPinnedCityCard.tsx b/frontend/components/dashboard/scan-terminal/AiPinnedCityCard.tsx
index 95ac0e06..893d119f 100644
--- a/frontend/components/dashboard/scan-terminal/AiPinnedCityCard.tsx
+++ b/frontend/components/dashboard/scan-terminal/AiPinnedCityCard.tsx
@@ -3,9 +3,7 @@
import clsx from "clsx";
import type { MouseEvent } from "react";
import { useEffect, useState } from "react";
-import { AiCityTemperatureChart } from "@/components/dashboard/scan-terminal/AiCityTemperatureChart";
import { AiEvidencePanel } from "@/components/dashboard/scan-terminal/AiEvidencePanel";
-import { AirportEvidencePanel } from "@/components/dashboard/scan-terminal/AirportEvidencePanel";
import { CityCardHeader } from "@/components/dashboard/scan-terminal/CityCardHeader";
import { MobileDecisionCard } from "@/components/dashboard/scan-terminal/MobileDecisionCard";
import { ModelEvidencePanel } from "@/components/dashboard/scan-terminal/ModelEvidencePanel";
@@ -267,27 +265,8 @@ export function AiPinnedCityCard({
const report = isHkoObservation
? ""
: detail?.current?.raw_metar || detail?.airport_current?.raw_metar || "";
- const metarReportTimeDisplay = formatMetarReportTime(detail, report, isEn);
- const observationStation = isHkoObservation
- ? detail?.current?.station_name ||
- detail?.current?.station_code ||
- detail?.settlement_station?.settlement_station_label ||
- detail?.settlement_station?.settlement_station_code ||
- "香港天文台"
- : detail?.risk?.icao ||
- detail?.current?.station_code ||
- detail?.airport_current?.station_code ||
- displayAirportPrimary?.station_code ||
- "";
const observationSourceZh = isHkoObservation ? "香港天文台观测" : "METAR 实测";
const observationSourceEn = isHkoObservation ? "HKO observations" : "METAR observations";
- const rawObservationText = isHkoObservation
- ? `${isEn ? "Observation source" : "观测来源"}:${observationStation || (isEn ? "Hong Kong Observatory" : "香港天文台")}${metarReportTimeDisplay ? `,${metarReportTimeDisplay}` : ""}`
- : report
- ? `${isEn ? "Raw METAR" : "原始 METAR"}:${`${observationStation} ${report}`.trim()}`
- : isEn
- ? "Raw METAR: unavailable."
- : "原始 METAR:暂无。";
const detailCityName = detail?.name || item.cityName;
const [refreshingDetail, setRefreshingDetail] = useState(false);
const { aiForecast, refreshAiForecast } = useAiCityForecast({
@@ -316,31 +295,16 @@ export function AiPinnedCityCard({
}, []);
const aiCityForecast = aiForecast.payload?.city_forecast || null;
- const localizedFinalJudgmentRaw =
+ const localizedFinalJudgment =
(isEn ? aiCityForecast?.final_judgment_en : aiCityForecast?.final_judgment_zh) ||
(isEn ? aiCityForecast?.reasoning_en : aiCityForecast?.reasoning_zh) ||
"";
- const localizedMetarReadRaw =
+ const localizedMetarRead =
(isEn ? aiCityForecast?.metar_read_en : aiCityForecast?.metar_read_zh) ||
"";
- const localizedReasoningRaw =
+ const localizedReasoning =
(isEn ? aiCityForecast?.reasoning_en : aiCityForecast?.reasoning_zh) ||
"";
- const localizedFinalJudgment = normalizeMetarReadTime(
- localizedFinalJudgmentRaw,
- metarReportTimeDisplay,
- isEn,
- );
- const localizedMetarRead = normalizeMetarReadTime(
- localizedMetarReadRaw,
- metarReportTimeDisplay,
- isEn,
- );
- const localizedReasoning = normalizeMetarReadTime(
- localizedReasoningRaw,
- metarReportTimeDisplay,
- isEn,
- );
const localizedModelNote =
(isEn
? aiCityForecast?.model_cluster_note_en
@@ -390,11 +354,6 @@ export function AiPinnedCityCard({
decisionExpectedHighNumber != null
? formatTemperatureValue(decisionExpectedHighNumber, tempSymbol, { digits: 1 })
: "--";
- const observedLabel = undefined;
- const currentTempText =
- currentTempNumber != null
- ? formatTemperatureValue(currentTempNumber, tempSymbol, { digits: 1 })
- : "--";
const debText =
debNumber != null
? formatTemperatureValue(debNumber, tempSymbol, { digits: 1 })
@@ -463,33 +422,20 @@ export function AiPinnedCityCard({
peakHasPassed,
});
const dataFreshnessRows = [
- {
- label: isHkoObservation ? (isEn ? "HKO" : "天文台") : "METAR",
- labelTitle: isHkoObservation
- ? (isEn ? "Hong Kong Observatory official readings" : "香港天文台官方实测")
- : (isEn ? "Meteorological Aerodrome Report — airport weather observation" : "机场气象观测报文"),
- value: buildObservationFreshnessValue({
- detail,
- displayTime: metarReportTimeDisplay,
- isEn,
- isHkoObservation,
- }),
- tone: observationStale ? "stale" : "fresh",
- },
{
label: isEn ? "Models" : "模型",
value: buildModelFreshnessValue(detail, locale, isEn),
- tone: "fresh",
+ tone: "fresh" as const,
},
{
label: isEn ? "Market" : "市场价格",
value: buildMarketFreshnessValue({ isEn, marketScan, marketStatus }),
tone:
marketDecisionView.status === "ready"
- ? "fresh"
+ ? ("fresh" as const)
: marketDecisionView.status === "loading"
- ? "loading"
- : "stale",
+ ? ("loading" as const)
+ : ("stale" as const),
},
];
const freshnessSeparator = isEn ? ": " : ":";
@@ -569,15 +515,12 @@ export function AiPinnedCityCard({
aiReadInProgressText={aiReadInProgressText}
aiRuleEvidenceMode={aiRuleEvidenceMode}
aiRuleEvidenceText={aiRuleEvidenceText}
- currentTempText={currentTempText}
- dataFreshnessRows={dataFreshnessRows}
debPrediction={debNumber}
decisionState={decisionState}
detail={detail}
displayName={displayName}
expectedHighText={expectedHighText}
fallbackAiReason={fallbackAiReason}
- freshnessSeparator={freshnessSeparator}
isEn={isEn}
isHkoObservation={isHkoObservation}
isRefreshing={isRefreshing}
@@ -588,7 +531,6 @@ export function AiPinnedCityCard({
onRefresh={handleRefresh}
onRemove={handleRemove}
peakWindow={peakWindow}
- rawObservationText={rawObservationText}
removing={removing}
tempSymbol={tempSymbol}
/>
@@ -599,14 +541,10 @@ export function AiPinnedCityCard({
aiStatusTone={decisionState.aiStatusTone}
collapseId={collapseId}
collapsed={collapsed}
- currentTempText={currentTempText}
- observedLabel={observedLabel}
- dataFreshnessRows={dataFreshnessRows}
debText={debText}
detailLocalTime={detail?.local_time}
displayName={displayName}
expectedHighText={expectedHighText}
- freshnessSeparator={freshnessSeparator}
isEn={isEn}
isRefreshing={isRefreshing}
modelRange={modelRange}
@@ -631,7 +569,6 @@ export function AiPinnedCityCard({
/>
-
) : !detail ? (
diff --git a/frontend/components/dashboard/scan-terminal/CityCardHeader.tsx b/frontend/components/dashboard/scan-terminal/CityCardHeader.tsx
index 89efc1dc..a0944bdb 100644
--- a/frontend/components/dashboard/scan-terminal/CityCardHeader.tsx
+++ b/frontend/components/dashboard/scan-terminal/CityCardHeader.tsx
@@ -7,24 +7,16 @@ import {
type CityStatusTag,
type StatusTone,
} from "@/components/dashboard/scan-terminal/CityStatusTags";
-import {
- DataFreshnessBar,
- type DataFreshnessRow,
-} from "@/components/dashboard/scan-terminal/DataFreshnessBar";
export function CityCardHeader({
aiStatusLabel,
aiStatusTone,
collapseId,
collapsed,
- currentTempText,
- observedLabel,
- dataFreshnessRows,
debText,
detailLocalTime,
displayName,
expectedHighText,
- freshnessSeparator,
isEn,
isRefreshing,
modelRange,
@@ -40,14 +32,10 @@ export function CityCardHeader({
aiStatusTone: StatusTone;
collapseId: string;
collapsed: boolean;
- currentTempText: string;
- observedLabel?: string;
- dataFreshnessRows: DataFreshnessRow[];
debText: string;
detailLocalTime?: string | null;
displayName: string;
expectedHighText: string;
- freshnessSeparator: string;
isEn: boolean;
isRefreshing: boolean;
modelRange: string;
@@ -67,20 +55,9 @@ export function CityCardHeader({
{displayName}
-
-
- {observedLabel || (isEn ? "Observed" : "当前温度")}
- {currentTempText}
-
{isEn ? "Expected high" : "预计最高温"}
{expectedHighText}
diff --git a/frontend/components/dashboard/scan-terminal/MobileCityPicker.tsx b/frontend/components/dashboard/scan-terminal/MobileCityPicker.tsx
new file mode 100644
index 00000000..c4192126
--- /dev/null
+++ b/frontend/components/dashboard/scan-terminal/MobileCityPicker.tsx
@@ -0,0 +1,315 @@
+"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: ["lagos", "cape town"],
+ },
+ {
+ key: "oceania",
+ label: { en: "Oceania", zh: "大洋洲" },
+ cities: ["wellington"],
+ },
+];
+
+const CITY_SEARCH_INDEX: Record = {
+ "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"],
+ "lagos": ["拉各斯", "DNMM", "los", "murtala"],
+ "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>(
+ () => 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 (
+
+
+
+ setSearchQuery(e.target.value)}
+ />
+ {searchQuery ? (
+ setSearchQuery("")}
+ >
+ ✕
+
+ ) : null}
+
+
+ {searchQuery ? (
+
+ {isEn
+ ? `${totalMatched} city${totalMatched !== 1 ? "ies" : "y"} found`
+ : `找到 ${totalMatched} 个城市`}
+
+ ) : null}
+
+ {filteredGroups.length === 0 ? (
+
+ {isEn ? "No cities match your search" : "没有匹配的城市"}
+
+ ) : (
+
+ {filteredGroups.map((group) => {
+ const isExpanded = expandedRegions.has(group.key) || !!searchQuery;
+ return (
+
+
toggleRegion(group.key)}
+ >
+
+ {isEn ? group.label.en : group.label.zh}
+ {group.matched.length}
+
+
+
+ {isExpanded ? (
+
+ {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 (
+ onSelectCity(row)}
+ >
+
+ {display}
+
+ {row.airport || row.local_time || ""}
+
+
+
+
+ {currentTemp != null &&
+ Number.isFinite(Number(currentTemp))
+ ? `${Number(currentTemp).toFixed(1)}${tempUnit}`
+ : "--"}
+
+
+ DEB{" "}
+ {deb != null && Number.isFinite(Number(deb))
+ ? `${Number(deb).toFixed(1)}${tempUnit}`
+ : "--"}
+
+
+
+ );
+ })}
+
+ ) : null}
+
+ );
+ })}
+
+ )}
+
+ );
+}
diff --git a/frontend/components/dashboard/scan-terminal/MobileDecisionCard.tsx b/frontend/components/dashboard/scan-terminal/MobileDecisionCard.tsx
index 44afa7bc..6d480e1e 100644
--- a/frontend/components/dashboard/scan-terminal/MobileDecisionCard.tsx
+++ b/frontend/components/dashboard/scan-terminal/MobileDecisionCard.tsx
@@ -3,18 +3,12 @@
import { RefreshCw, X } from "lucide-react";
import type { MouseEvent } from "react";
import { useState } from "react";
-import { AiCityTemperatureChart } from "@/components/dashboard/scan-terminal/AiCityTemperatureChart";
import { AiEvidencePanel } from "@/components/dashboard/scan-terminal/AiEvidencePanel";
-import { AirportEvidencePanel } from "@/components/dashboard/scan-terminal/AirportEvidencePanel";
import {
CityStatusTags,
type CityStatusTag,
type StatusTone,
} from "@/components/dashboard/scan-terminal/CityStatusTags";
-import {
- DataFreshnessBar,
- type DataFreshnessRow,
-} from "@/components/dashboard/scan-terminal/DataFreshnessBar";
import { LoadingSignal } from "@/components/dashboard/scan-terminal/LoadingSignal";
import { MarketDecisionLine } from "@/components/dashboard/scan-terminal/MarketDecisionLine";
import { ModelEvidencePanel } from "@/components/dashboard/scan-terminal/ModelEvidencePanel";
@@ -42,15 +36,12 @@ export function MobileDecisionCard({
aiReadInProgressText,
aiRuleEvidenceMode,
aiRuleEvidenceText,
- currentTempText,
- dataFreshnessRows,
debPrediction,
decisionState,
detail,
displayName,
expectedHighText,
fallbackAiReason,
- freshnessSeparator,
isEn,
isHkoObservation,
isRefreshing,
@@ -61,7 +52,6 @@ export function MobileDecisionCard({
onRefresh,
onRemove,
peakWindow,
- rawObservationText,
removing,
tempSymbol,
}: {
@@ -76,15 +66,12 @@ export function MobileDecisionCard({
aiReadInProgressText: string;
aiRuleEvidenceMode: boolean;
aiRuleEvidenceText: string;
- currentTempText: string;
- dataFreshnessRows: DataFreshnessRow[];
debPrediction: number | null;
decisionState: CityDecisionState;
detail: CityDetail | null;
displayName: string;
expectedHighText: string;
fallbackAiReason: string;
- freshnessSeparator: string;
isEn: boolean;
isHkoObservation: boolean;
isRefreshing: boolean;
@@ -95,15 +82,12 @@ export function MobileDecisionCard({
onRefresh: (event: MouseEvent) => void;
onRemove: (event: MouseEvent) => void;
peakWindow: string;
- rawObservationText: string;
removing?: boolean;
tempSymbol: string;
}) {
const copy = getMobileDecisionCopy(isEn);
const loadingCopy = getCityLoadingCopy({ isEn, isHkoObservation });
const [modelOpen, setModelOpen] = useState(false);
- const [chartOpen, setChartOpen] = useState(false);
- const [airportOpen, setAirportOpen] = useState(false);
const statusTags: CityStatusTag[] = decisionState.badges.length
? decisionState.badges
: [{ label: decisionState.aiStatusLabel, tone: decisionState.aiStatusTone as StatusTone }];
@@ -142,11 +126,7 @@ export function MobileDecisionCard({
-
- {copy.currentTemp}
- {currentTempText}
-
-
+
{copy.expectedHigh}
{expectedHighText}
@@ -158,13 +138,6 @@ export function MobileDecisionCard({
{decisionState.primaryReason}
-
- setAirportOpen(event.currentTarget.open)}
- >
-
- {isEn ? "Airport live evidence" : "机场实时证据"}
-
- {airportOpen ? : null}
-
-
{copy.modelEvidence}
{modelOpen ? : null}
-
- setChartOpen(event.currentTarget.open)}
- >
- {copy.chart}
- {chartOpen ? : null}
-
)}
>
diff --git a/scripts/create_forum_topics.py b/scripts/create_forum_topics.py
new file mode 100644
index 00000000..b9ce0329
--- /dev/null
+++ b/scripts/create_forum_topics.py
@@ -0,0 +1,162 @@
+"""
+一次性为新 Telegram 群组创建所有城市的 Forum Topics。
+
+用法:
+ python scripts/create_forum_topics.py
+
+会生成 data/city_thread_ids.json 供后续推送路由使用。
+"""
+
+from __future__ import annotations
+
+import json
+import os
+import sys
+import time
+
+import requests
+
+
+def _load_env() -> None:
+ """从项目根目录 .env 加载环境变量(仅设置尚未存在的变量)。"""
+ root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+ env_path = os.path.join(root, ".env")
+ if not os.path.isfile(env_path):
+ return
+ with open(env_path, "r", encoding="utf-8") as f:
+ for line in f:
+ line = line.strip()
+ if not line or line.startswith("#") or "=" not in line:
+ continue
+ key, _, value = line.partition("=")
+ key = key.strip()
+ if key in os.environ:
+ continue
+ value = value.strip().strip('"').strip("'")
+ os.environ[key] = value
+
+
+_load_env()
+BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN", "").strip()
+CHAT_ID = -1003965137823
+
+CITIES: list[tuple[str, str]] = [
+ ("🇨🇳 Beijing", "beijing"),
+ ("🇨🇳 Shanghai", "shanghai"),
+ ("🇨🇳 Guangzhou", "guangzhou"),
+ ("🇨🇳 Qingdao", "qingdao"),
+ ("🇨🇳 Chengdu", "chengdu"),
+ ("🇨🇳 Chongqing", "chongqing"),
+ ("🇨🇳 Wuhan", "wuhan"),
+ ("🇭🇰 Hong Kong", "hong kong"),
+ ("🇭🇰 Lau Fau Shan", "lau fau shan"),
+ ("🇹🇼 Taipei", "taipei"),
+ ("🇰🇷 Seoul", "seoul"),
+ ("🇰🇷 Busan", "busan"),
+ ("🇯🇵 Tokyo", "tokyo"),
+ ("🇸🇬 Singapore", "singapore"),
+ ("🇹🇷 Istanbul", "istanbul"),
+ ("🇹🇷 Ankara", "ankara"),
+ ("🇫🇮 Helsinki", "helsinki"),
+ ("🇳🇱 Amsterdam", "amsterdam"),
+ ("🇫🇷 Paris", "paris"),
+ ("🇺🇸 New York", "new york"),
+ ("🇺🇸 Los Angeles", "los angeles"),
+ ("🇺🇸 Chicago", "chicago"),
+ ("🇺🇸 Denver", "denver"),
+ ("🇺🇸 Atlanta", "atlanta"),
+ ("🇺🇸 Miami", "miami"),
+ ("🇺🇸 San Francisco", "san francisco"),
+ ("🇺🇸 Houston", "houston"),
+ ("🇺🇸 Dallas", "dallas"),
+ ("🇺🇸 Austin", "austin"),
+ ("🇺🇸 Seattle", "seattle"),
+]
+
+URL = f"https://api.telegram.org/bot{BOT_TOKEN}/createForumTopic"
+
+
+def _load_existing(out_path: str) -> dict:
+ if os.path.isfile(out_path):
+ try:
+ with open(out_path, "r", encoding="utf-8") as f:
+ return json.load(f)
+ except Exception:
+ pass
+ return {}
+
+
+def _save_mapping(out_path: str, mapping: dict) -> None:
+ os.makedirs(os.path.dirname(out_path), exist_ok=True)
+ with open(out_path, "w", encoding="utf-8") as f:
+ json.dump(mapping, f, ensure_ascii=False, indent=2)
+
+
+def main() -> int:
+ if not BOT_TOKEN:
+ print("未设置 TELEGRAM_BOT_TOKEN 环境变量", file=sys.stderr)
+ return 1
+
+ out_path = os.path.join(
+ os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
+ "data", "city_thread_ids.json",
+ )
+
+ mapping = _load_existing(out_path)
+ if mapping:
+ print(f"已加载 {len(mapping)} 个已有话题,将跳过已创建的城市")
+
+ for label, city_key in CITIES:
+ if city_key in mapping:
+ print(f"SKIP {label} -> 已存在 thread_id={mapping[city_key]}")
+ continue
+
+ try:
+ r = requests.post(URL, json={"chat_id": CHAT_ID, "name": label}, timeout=15)
+ data = r.json()
+ except Exception as exc:
+ print(f"ERR {label} -> network: {exc}", file=sys.stderr)
+ continue
+
+ if not data.get("ok"):
+ err_code = data.get("error_code", "")
+ err_desc = data.get("description", "")
+ retry_after = (data.get("parameters") or {}).get("retry_after", 0)
+ print(f"ERR {label} -> {err_code} {err_desc} retry_after={retry_after}s", file=sys.stderr)
+ if retry_after:
+ wait = int(retry_after) + 2
+ print(f" wait {wait}s ...")
+ time.sleep(wait)
+ try:
+ r2 = requests.post(URL, json={"chat_id": CHAT_ID, "name": label}, timeout=15)
+ data2 = r2.json()
+ if data2.get("ok"):
+ tid = (data2.get("result") or {}).get("message_thread_id")
+ if tid:
+ mapping[city_key] = int(tid)
+ print(f"OK {label} -> thread_id={tid} (retry)")
+ _save_mapping(out_path, mapping)
+ continue
+ else:
+ print(f"ERR {label} -> retry failed: {data2.get('description')}", file=sys.stderr)
+ except Exception as exc2:
+ print(f"ERR {label} -> retry network: {exc2}", file=sys.stderr)
+ continue
+
+ tid = (data.get("result") or {}).get("message_thread_id")
+ if not tid:
+ print(f"WARN {label} -> no thread_id: {data}", file=sys.stderr)
+ continue
+
+ mapping[city_key] = int(tid)
+ print(f"OK {label} -> thread_id={tid}")
+ _save_mapping(out_path, mapping)
+ time.sleep(1.2)
+
+ _save_mapping(out_path, mapping)
+ print(f"\nDone: {len(mapping)}/{len(CITIES)} cities saved to {out_path}")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/delete_forum_topic.py b/scripts/delete_forum_topic.py
new file mode 100644
index 00000000..7d12ed30
--- /dev/null
+++ b/scripts/delete_forum_topic.py
@@ -0,0 +1,47 @@
+"""删除指定 thread_id 的 Forum Topic。用法: python scripts/delete_forum_topic.py """
+from __future__ import annotations
+
+import os
+import sys
+import requests
+
+
+def _load_env() -> None:
+ root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+ env_path = os.path.join(root, ".env")
+ if not os.path.isfile(env_path):
+ return
+ with open(env_path, "r", encoding="utf-8") as f:
+ for line in f:
+ line = line.strip()
+ if not line or line.startswith("#") or "=" not in line:
+ continue
+ key, _, value = line.partition("=")
+ key = key.strip()
+ if key in os.environ:
+ continue
+ os.environ[key] = value.strip().strip('"').strip("'")
+
+
+_load_env()
+TOKEN = os.getenv("TELEGRAM_BOT_TOKEN", "").strip()
+CHAT_ID = -1003965137823
+
+if __name__ == "__main__":
+ if len(sys.argv) < 2:
+ print("用法: python scripts/delete_forum_topic.py ")
+ print("获取 thread_id: 在 Telegram 长按话题 -> 或转发该话题一条消息到 @RawDataBot")
+ sys.exit(1)
+
+ tid = int(sys.argv[1])
+ r = requests.post(
+ f"https://api.telegram.org/bot{TOKEN}/deleteForumTopic",
+ json={"chat_id": CHAT_ID, "message_thread_id": tid},
+ timeout=10,
+ )
+ data = r.json()
+ if data.get("ok"):
+ print(f"已删除 thread_id={tid}")
+ else:
+ print(f"失败: {data.get('description')} (code={data.get('error_code')})")
+ sys.exit(1)
diff --git a/scripts/scan_forum_topics.py b/scripts/scan_forum_topics.py
new file mode 100644
index 00000000..8854d48f
--- /dev/null
+++ b/scripts/scan_forum_topics.py
@@ -0,0 +1,73 @@
+"""扫描群组内所有 Forum Topic,打印名称和 thread_id。"""
+from __future__ import annotations
+
+import json
+import os
+import sys
+from concurrent.futures import ThreadPoolExecutor, as_completed
+
+import requests
+
+
+def _load_env() -> None:
+ root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+ env_path = os.path.join(root, ".env")
+ if not os.path.isfile(env_path):
+ return
+ with open(env_path, "r", encoding="utf-8") as f:
+ for line in f:
+ line = line.strip()
+ if not line or line.startswith("#") or "=" not in line:
+ continue
+ key, _, value = line.partition("=")
+ key = key.strip()
+ if key in os.environ:
+ continue
+ os.environ[key] = value.strip().strip('"').strip("'")
+
+
+_load_env()
+TOKEN = os.getenv("TELEGRAM_BOT_TOKEN", "").strip()
+CHAT_ID = -1003965137823
+
+
+def _try_get_topic(tid: int) -> dict | None:
+ try:
+ r = requests.get(
+ f"https://api.telegram.org/bot{TOKEN}/getForumTopic",
+ params={"chat_id": CHAT_ID, "message_thread_id": tid},
+ timeout=10,
+ )
+ data = r.json()
+ if data.get("ok"):
+ name = (data.get("result") or {}).get("name", "")
+ return {"thread_id": tid, "name": name}
+ except Exception:
+ pass
+ return None
+
+
+def main() -> int:
+ if not TOKEN:
+ print("未设置 TELEGRAM_BOT_TOKEN", file=sys.stderr)
+ return 1
+
+ print(f"扫描群组话题...")
+ topics = []
+ with ThreadPoolExecutor(max_workers=10) as pool:
+ futures = {pool.submit(_try_get_topic, tid): tid for tid in range(1, 101)}
+ for future in as_completed(futures):
+ result = future.result()
+ if result:
+ topics.append(result)
+ print(f" thread_id={result['thread_id']} {result['name']}")
+
+ topics.sort(key=lambda t: t["thread_id"])
+ print(f"\n共 {len(topics)} 个话题:")
+ for t in topics:
+ print(f" {t['thread_id']:>4} {t['name']}")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/src/utils/telegram_push.py b/src/utils/telegram_push.py
index 6ebe2107..a5c7ba41 100644
--- a/src/utils/telegram_push.py
+++ b/src/utils/telegram_push.py
@@ -21,6 +21,45 @@ from src.utils.telegram_chat_ids import (
get_telegram_chat_ids_from_env,
)
+# Forum topic routing: maps city_key -> message_thread_id for the push forum group.
+# Created by scripts/create_forum_topics.py, stored in the runtime data dir.
+_CITY_THREAD_IDS_PATH = os.path.join(
+ os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
+ "data", "city_thread_ids.json",
+)
+_FORUM_CHAT_ID = "-1003965137823"
+_city_thread_ids: dict = {}
+
+
+def _load_city_thread_ids() -> dict:
+ global _city_thread_ids
+ if _city_thread_ids:
+ return _city_thread_ids
+ paths = [
+ _CITY_THREAD_IDS_PATH,
+ "/var/lib/polyweather/city_thread_ids.json",
+ "/app/data/city_thread_ids.json",
+ ]
+ for path in paths:
+ if os.path.isfile(path):
+ try:
+ with open(path, "r", encoding="utf-8") as f:
+ _city_thread_ids = json.load(f)
+ logger.info("loaded city_thread_ids from {}: {} cities", path, len(_city_thread_ids))
+ return _city_thread_ids
+ except Exception as exc:
+ logger.warning("failed to load city_thread_ids from {}: {}", path, exc)
+ return {}
+
+
+def _resolve_thread_id(chat_id: str, city: str) -> int:
+ """Return message_thread_id for a given chat and city, or 0 if not a forum topic."""
+ if str(chat_id) != _FORUM_CHAT_ID:
+ return 0
+ mapping = _load_city_thread_ids()
+ city_key = str(city or "").strip().lower()
+ return int(mapping.get(city_key) or 0)
+
SEVERITY_RANK = {
"none": 0,
@@ -1186,7 +1225,11 @@ def _run_high_freq_airport_cycle(
sent = False
for chat_id in chat_ids:
try:
- bot.send_message(chat_id, message)
+ kwargs = {}
+ thread_id = _resolve_thread_id(chat_id, city)
+ if thread_id:
+ kwargs["message_thread_id"] = thread_id
+ bot.send_message(chat_id, message, **kwargs)
sent = True
except Exception as exc:
logger.warning("airport push failed city={} chat_id={}: {}", city, chat_id, exc)
@@ -1288,12 +1331,17 @@ def _run_market_monitor_cycle(bot: Any, chat_ids: List[str]) -> bool:
continue
if message is None:
continue
+ city_name = futures[future]
for chat_id in chat_ids:
try:
- bot.send_message(chat_id, message)
+ kwargs = {}
+ thread_id = _resolve_thread_id(chat_id, city_name)
+ if thread_id:
+ kwargs["message_thread_id"] = thread_id
+ bot.send_message(chat_id, message, **kwargs)
sent_any = True
except Exception as exc:
- logger.warning("market monitor push failed city={} chat_id={}: {}", futures[future], chat_id, exc)
+ logger.warning("market monitor push failed city={} chat_id={}: {}", city_name, chat_id, exc)
return sent_any