feat: implement mobile scan terminal dashboard and city picker with backend forum automation scripts
This commit is contained in:
@@ -219,6 +219,7 @@ const TELEGRAM_BOT_URL = String(
|
|||||||
process.env.NEXT_PUBLIC_TELEGRAM_BOT_URL || "https://t.me/WeatherQuant_bot",
|
process.env.NEXT_PUBLIC_TELEGRAM_BOT_URL || "https://t.me/WeatherQuant_bot",
|
||||||
).trim();
|
).trim();
|
||||||
const TELEGRAM_MARKET_CHANNEL_URL = "https://t.me/+hGAk7JsjtdhiOTUx";
|
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 SUBSCRIPTION_HELP_HREF = "/subscription-help";
|
||||||
const PAYMENT_RECOVERY_STORAGE_KEY = "polyweather:lastPaymentRecovery";
|
const PAYMENT_RECOVERY_STORAGE_KEY = "polyweather:lastPaymentRecovery";
|
||||||
const PAYMENT_RECOVERY_TTL_MS = 6 * 60 * 60 * 1000;
|
const PAYMENT_RECOVERY_TTL_MS = 6 * 60 * 60 * 1000;
|
||||||
@@ -708,6 +709,16 @@ export function AccountCenter() {
|
|||||||
telegramHint: isEn
|
telegramHint: isEn
|
||||||
? "Send the command below to the polyweather bot to sync notifications and access."
|
? "Send the command below to the polyweather bot to sync notifications and access."
|
||||||
: "将下方命令发送给polyweather机器人,实现全平台气象查询与权限同步。",
|
: "将下方命令发送给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
|
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."
|
? "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。当前项目由我一人维护,极少数边缘情况可能需要人工补开。给你带来的不便,敬请谅解!",
|
: "如果付款成功后 Pro 仍未开通,请发邮件到 yhrsc30@gmail.com。当前项目由我一人维护,极少数边缘情况可能需要人工补开。给你带来的不便,敬请谅解!",
|
||||||
@@ -2755,6 +2766,34 @@ export function AccountCenter() {
|
|||||||
<p className="text-slate-400 text-sm mb-6">
|
<p className="text-slate-400 text-sm mb-6">
|
||||||
{copy.telegramHint}
|
{copy.telegramHint}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{/* Channel Upgrade / Migration Notice — Pro users only */}
|
||||||
|
{isSubscribed ? (
|
||||||
|
<div className="mb-6 rounded-2xl border border-cyan-400/30 bg-gradient-to-br from-cyan-500/12 to-blue-500/8 px-5 py-4">
|
||||||
|
<h4 className="text-sm font-bold text-cyan-300 mb-2 flex items-center gap-1.5">
|
||||||
|
<Sparkles size={15} /> {copy.telegramUpgradeTitle}
|
||||||
|
</h4>
|
||||||
|
<p className="text-slate-300 text-xs leading-relaxed mb-3">
|
||||||
|
{copy.telegramUpgradeBody}
|
||||||
|
</p>
|
||||||
|
<p className="text-slate-400 text-[11px] font-semibold mb-1.5">
|
||||||
|
{copy.telegramUpgradeFeatures}
|
||||||
|
</p>
|
||||||
|
<p className="text-slate-300 text-xs leading-relaxed mb-3">
|
||||||
|
🇯🇵 Tokyo · 🇰🇷 Seoul · 🇰🇷 Busan · 🇨🇳 Beijing · 🇨🇳 Shanghai · 🇨🇳 Guangzhou · 🇨🇳 Chengdu · 🇨🇳 Wuhan · 🇭🇰 Hong Kong · 🇹🇼 Taipei · 🇸🇬 Singapore · 🇹🇷 Istanbul · 🇫🇮 Helsinki · 🇳🇱 Amsterdam · 🇫🇷 Paris · 🇺🇸 New York · 🇺🇸 Los Angeles · 🇺🇸 Chicago
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href={TELEGRAM_TOPICS_GROUP_URL}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="inline-flex min-h-9 items-center gap-1.5 rounded-lg border border-cyan-400/40 bg-cyan-500/15 px-4 py-2 text-xs font-bold text-cyan-200 hover:bg-cyan-500/25 transition-colors"
|
||||||
|
>
|
||||||
|
{copy.telegramUpgradeJoin}
|
||||||
|
<ExternalLink size={12} />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="mb-4 flex flex-wrap gap-2">
|
<div className="mb-4 flex flex-wrap gap-2">
|
||||||
{TELEGRAM_BOT_URL ? (
|
{TELEGRAM_BOT_URL ? (
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@@ -19,10 +19,10 @@ import {
|
|||||||
} from "@/hooks/useDashboardStore";
|
} from "@/hooks/useDashboardStore";
|
||||||
import { I18nProvider, useI18n } from "@/hooks/useI18n";
|
import { I18nProvider, useI18n } from "@/hooks/useI18n";
|
||||||
import type {
|
import type {
|
||||||
CityDetail,
|
|
||||||
ScanOpportunityRow,
|
ScanOpportunityRow,
|
||||||
} from "@/lib/dashboard-types";
|
} from "@/lib/dashboard-types";
|
||||||
import { AiPinnedForecastView } from "@/components/dashboard/scan-terminal/AiPinnedForecastView";
|
import { AiPinnedForecastView } from "@/components/dashboard/scan-terminal/AiPinnedForecastView";
|
||||||
|
import { MobileCityPicker } from "@/components/dashboard/scan-terminal/MobileCityPicker";
|
||||||
import { WelcomeOverlay } from "@/components/dashboard/scan-terminal/WelcomeOverlay";
|
import { WelcomeOverlay } from "@/components/dashboard/scan-terminal/WelcomeOverlay";
|
||||||
import {
|
import {
|
||||||
ScanPaywallModal,
|
ScanPaywallModal,
|
||||||
@@ -102,6 +102,8 @@ function ScanTerminalScreen() {
|
|||||||
setIsMobileViewport(media.matches);
|
setIsMobileViewport(media.matches);
|
||||||
if (media.matches) {
|
if (media.matches) {
|
||||||
setActiveView((current) => (current === "map" ? "city-list" : current));
|
setActiveView((current) => (current === "map" ? "city-list" : current));
|
||||||
|
} else {
|
||||||
|
setActiveView((current) => (current === "city-list" ? "map" : current));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
syncMobileViewport();
|
syncMobileViewport();
|
||||||
@@ -343,72 +345,11 @@ function ScanTerminalScreen() {
|
|||||||
const renderMainView = () => {
|
const renderMainView = () => {
|
||||||
if (resolvedView === "city-list") {
|
if (resolvedView === "city-list") {
|
||||||
return (
|
return (
|
||||||
<div className="scan-mobile-city-list-view">
|
<MobileCityPicker
|
||||||
<div className="scan-mobile-city-list-head">
|
isEn={isEn}
|
||||||
<div>
|
rows={timeSortedRows}
|
||||||
<span>{isEn ? "Lightweight mobile entry" : "移动端轻量入口"}</span>
|
onSelectCity={handleOpenDecisionRow}
|
||||||
<strong>{isEn ? "Pick one city to inspect" : "选择一个城市进入决策卡"}</strong>
|
/>
|
||||||
</div>
|
|
||||||
<p>
|
|
||||||
{isEn
|
|
||||||
? "Cached scan rows are used first. The map and heavier evidence load only when opened."
|
|
||||||
: "优先使用缓存扫描结果;地图和重证据只在打开后加载。"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="scan-mobile-city-list">
|
|
||||||
{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 (
|
|
||||||
<button
|
|
||||||
key={row.id || `${cityName}-${index}`}
|
|
||||||
type="button"
|
|
||||||
className="scan-mobile-city-row"
|
|
||||||
onClick={() => handleOpenDecisionRow(row)}
|
|
||||||
>
|
|
||||||
<span className="scan-mobile-city-rank">{String(index + 1).padStart(2, "0")}</span>
|
|
||||||
<span className="scan-mobile-city-main">
|
|
||||||
<b>{display}</b>
|
|
||||||
<small>{row.market_question || row.target_label || row.local_time || "--"}</small>
|
|
||||||
</span>
|
|
||||||
<span className="scan-mobile-city-metrics">
|
|
||||||
<b>
|
|
||||||
{currentTemp != null && Number.isFinite(Number(currentTemp))
|
|
||||||
? `${Number(currentTemp).toFixed(1)}${row.temp_symbol || "°C"}`
|
|
||||||
: "--"}
|
|
||||||
</b>
|
|
||||||
<small>
|
|
||||||
DEB{" "}
|
|
||||||
{deb != null && Number.isFinite(Number(deb))
|
|
||||||
? `${Number(deb).toFixed(1)}${row.temp_symbol || "°C"}`
|
|
||||||
: "--"}
|
|
||||||
</small>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
{!timeSortedRows.length ? (
|
|
||||||
<div className="scan-empty-state">
|
|
||||||
<div className="scan-empty-title">
|
|
||||||
{scanLoading
|
|
||||||
? isEn
|
|
||||||
? "Loading cached scan"
|
|
||||||
: "正在读取缓存扫描"
|
|
||||||
: isEn
|
|
||||||
? "No city rows yet"
|
|
||||||
: "暂无城市列表"}
|
|
||||||
</div>
|
|
||||||
<div className="scan-empty-copy">
|
|
||||||
{isEn
|
|
||||||
? "Use refresh only when you need a new server-side scan."
|
|
||||||
: "只有需要新的服务端扫描时再手动刷新。"}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (resolvedView === "map") {
|
if (resolvedView === "map") {
|
||||||
@@ -508,29 +449,32 @@ function ScanTerminalScreen() {
|
|||||||
<section className="scan-list-section">
|
<section className="scan-list-section">
|
||||||
<div className="scan-list-header">
|
<div className="scan-list-header">
|
||||||
<div className="scan-list-tabs" role="tablist" aria-label={isEn ? "Content view" : "内容视图"}>
|
<div className="scan-list-tabs" role="tablist" aria-label={isEn ? "Content view" : "内容视图"}>
|
||||||
<button
|
{isMobileViewport ? (
|
||||||
type="button"
|
<button
|
||||||
role="tab"
|
type="button"
|
||||||
aria-selected={resolvedView === "city-list"}
|
role="tab"
|
||||||
className={resolvedView === "city-list" ? "active" : ""}
|
aria-selected={resolvedView === "city-list"}
|
||||||
onClick={() => {
|
className={resolvedView === "city-list" ? "active" : ""}
|
||||||
setActiveView("city-list");
|
onClick={() => {
|
||||||
}}
|
setActiveView("city-list");
|
||||||
>
|
}}
|
||||||
{isEn ? "City List" : "城市列表"}
|
>
|
||||||
</button>
|
{isEn ? "City List" : "城市列表"}
|
||||||
<button
|
</button>
|
||||||
type="button"
|
) : (
|
||||||
role="tab"
|
<button
|
||||||
aria-selected={resolvedView === "map"}
|
type="button"
|
||||||
className={resolvedView === "map" ? "active" : ""}
|
role="tab"
|
||||||
onClick={() => {
|
aria-selected={resolvedView === "map"}
|
||||||
lastMapSelectedCityRef.current = normalizeCityKey(store.selectedCity);
|
className={resolvedView === "map" ? "active" : ""}
|
||||||
setActiveView("map");
|
onClick={() => {
|
||||||
}}
|
lastMapSelectedCityRef.current = normalizeCityKey(store.selectedCity);
|
||||||
>
|
setActiveView("map");
|
||||||
{isEn ? "Distribution View" : "分布视图"}
|
}}
|
||||||
</button>
|
>
|
||||||
|
{isEn ? "Distribution View" : "分布视图"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
role="tab"
|
role="tab"
|
||||||
|
|||||||
@@ -629,3 +629,216 @@
|
|||||||
height: 38px;
|
height: 38px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Mobile City Picker ── */
|
||||||
|
|
||||||
|
.root :global(.scan-mobile-city-picker) {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 0;
|
||||||
|
min-height: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root :global(.scan-mobile-picker-search) {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
border: 1px solid rgba(77, 163, 255, 0.22);
|
||||||
|
border-radius: 16px;
|
||||||
|
background: rgba(12, 24, 42, 0.88);
|
||||||
|
padding: 0 14px;
|
||||||
|
transition: border-color 0.18s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root :global(.scan-mobile-picker-search:focus-within) {
|
||||||
|
border-color: rgba(77, 163, 255, 0.48);
|
||||||
|
box-shadow: 0 0 0 3px rgba(77, 163, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.root :global(.scan-mobile-picker-search-icon) {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
color: #7a9cc6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root :global(.scan-mobile-picker-search-input) {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
height: 48px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 0 10px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root :global(.scan-mobile-picker-search-input::placeholder) {
|
||||||
|
color: #5b7a9e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root :global(.scan-mobile-picker-search-clear) {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(148, 163, 184, 0.12);
|
||||||
|
color: #8fa4c3;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root :global(.scan-mobile-picker-result-count) {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
padding: 0 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root :global(.scan-mobile-picker-empty) {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 48px 16px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root :global(.scan-mobile-picker-regions) {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root :global(.scan-mobile-picker-regions::-webkit-scrollbar) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root :global(.scan-mobile-picker-region) {
|
||||||
|
border: 1px solid rgba(90, 123, 166, 0.1);
|
||||||
|
border-radius: 16px;
|
||||||
|
background: rgba(8, 18, 35, 0.68);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root :global(.scan-mobile-picker-region-head) {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
padding: 13px 15px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root :global(.scan-mobile-picker-region-head span) {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.root :global(.scan-mobile-picker-region-head small) {
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
min-width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(77, 163, 255, 0.14);
|
||||||
|
color: var(--color-accent-primary);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
padding: 0 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root :global(.scan-mobile-picker-region-head svg) {
|
||||||
|
color: #5b7a9e;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root :global(.scan-mobile-picker-region-head svg.expanded) {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.root :global(.scan-mobile-picker-region-cities) {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 0 12px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root :global(.scan-mobile-picker-city-row) {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid rgba(118, 150, 192, 0.12);
|
||||||
|
border-radius: 14px;
|
||||||
|
background: rgba(14, 26, 45, 0.64);
|
||||||
|
color: inherit;
|
||||||
|
padding: 11px 13px;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root :global(.scan-mobile-picker-city-row:active) {
|
||||||
|
border-color: rgba(77, 163, 255, 0.32);
|
||||||
|
background: rgba(77, 163, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.root :global(.scan-mobile-picker-city-name),
|
||||||
|
.root :global(.scan-mobile-picker-city-temp) {
|
||||||
|
display: grid;
|
||||||
|
gap: 3px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root :global(.scan-mobile-picker-city-name b) {
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root :global(.scan-mobile-picker-city-name small) {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root :global(.scan-mobile-picker-city-temp) {
|
||||||
|
justify-items: end;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root :global(.scan-mobile-picker-city-temp b) {
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root :global(.scan-mobile-picker-city-temp small) {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ export function AiEvidencePanel({
|
|||||||
isHkoObservation,
|
isHkoObservation,
|
||||||
localModelSupportNote,
|
localModelSupportNote,
|
||||||
localizedFinalJudgment,
|
localizedFinalJudgment,
|
||||||
rawObservationText,
|
|
||||||
tempSymbol,
|
tempSymbol,
|
||||||
}: {
|
}: {
|
||||||
aiBullets: string[];
|
aiBullets: string[];
|
||||||
@@ -64,7 +63,6 @@ export function AiEvidencePanel({
|
|||||||
isHkoObservation: boolean;
|
isHkoObservation: boolean;
|
||||||
localModelSupportNote: string;
|
localModelSupportNote: string;
|
||||||
localizedFinalJudgment: string;
|
localizedFinalJudgment: string;
|
||||||
rawObservationText: string;
|
|
||||||
tempSymbol: string;
|
tempSymbol: string;
|
||||||
}) {
|
}) {
|
||||||
const aiConfidenceMeta = confidenceBadge(aiConfidence, isEn);
|
const aiConfidenceMeta = confidenceBadge(aiConfidence, isEn);
|
||||||
@@ -173,7 +171,6 @@ export function AiEvidencePanel({
|
|||||||
<li key={`${line}-${index}`}>{line}</li>
|
<li key={`${line}-${index}`}>{line}</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
<p className="scan-ai-raw-metar">{rawObservationText}</p>
|
|
||||||
</>
|
</>
|
||||||
) : aiForecast.status === "ready" ? (
|
) : aiForecast.status === "ready" ? (
|
||||||
<>
|
<>
|
||||||
@@ -181,7 +178,6 @@ export function AiEvidencePanel({
|
|||||||
<ul className="scan-ai-weather-bullets">
|
<ul className="scan-ai-weather-bullets">
|
||||||
{fallbackAiReason ? <li>{fallbackAiReason}</li> : null}
|
{fallbackAiReason ? <li>{fallbackAiReason}</li> : null}
|
||||||
<li>{localModelSupportNote}</li>
|
<li>{localModelSupportNote}</li>
|
||||||
<li>{rawObservationText}</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
</>
|
</>
|
||||||
) : aiForecast.status === "failed" ? (
|
) : aiForecast.status === "failed" ? (
|
||||||
@@ -190,7 +186,6 @@ export function AiEvidencePanel({
|
|||||||
<ul className="scan-ai-weather-bullets">
|
<ul className="scan-ai-weather-bullets">
|
||||||
{aiForecast.error ? <li>{aiForecast.error}</li> : null}
|
{aiForecast.error ? <li>{aiForecast.error}</li> : null}
|
||||||
<li>{localModelSupportNote}</li>
|
<li>{localModelSupportNote}</li>
|
||||||
<li>{rawObservationText}</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -3,9 +3,7 @@
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import type { MouseEvent } from "react";
|
import type { MouseEvent } from "react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { AiCityTemperatureChart } from "@/components/dashboard/scan-terminal/AiCityTemperatureChart";
|
|
||||||
import { AiEvidencePanel } from "@/components/dashboard/scan-terminal/AiEvidencePanel";
|
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 { CityCardHeader } from "@/components/dashboard/scan-terminal/CityCardHeader";
|
||||||
import { MobileDecisionCard } from "@/components/dashboard/scan-terminal/MobileDecisionCard";
|
import { MobileDecisionCard } from "@/components/dashboard/scan-terminal/MobileDecisionCard";
|
||||||
import { ModelEvidencePanel } from "@/components/dashboard/scan-terminal/ModelEvidencePanel";
|
import { ModelEvidencePanel } from "@/components/dashboard/scan-terminal/ModelEvidencePanel";
|
||||||
@@ -267,27 +265,8 @@ export function AiPinnedCityCard({
|
|||||||
const report = isHkoObservation
|
const report = isHkoObservation
|
||||||
? ""
|
? ""
|
||||||
: detail?.current?.raw_metar || detail?.airport_current?.raw_metar || "";
|
: 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 observationSourceZh = isHkoObservation ? "香港天文台观测" : "METAR 实测";
|
||||||
const observationSourceEn = isHkoObservation ? "HKO observations" : "METAR observations";
|
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 detailCityName = detail?.name || item.cityName;
|
||||||
const [refreshingDetail, setRefreshingDetail] = useState(false);
|
const [refreshingDetail, setRefreshingDetail] = useState(false);
|
||||||
const { aiForecast, refreshAiForecast } = useAiCityForecast({
|
const { aiForecast, refreshAiForecast } = useAiCityForecast({
|
||||||
@@ -316,31 +295,16 @@ export function AiPinnedCityCard({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const aiCityForecast = aiForecast.payload?.city_forecast || null;
|
const aiCityForecast = aiForecast.payload?.city_forecast || null;
|
||||||
const localizedFinalJudgmentRaw =
|
const localizedFinalJudgment =
|
||||||
(isEn ? aiCityForecast?.final_judgment_en : aiCityForecast?.final_judgment_zh) ||
|
(isEn ? aiCityForecast?.final_judgment_en : aiCityForecast?.final_judgment_zh) ||
|
||||||
(isEn ? aiCityForecast?.reasoning_en : aiCityForecast?.reasoning_zh) ||
|
(isEn ? aiCityForecast?.reasoning_en : aiCityForecast?.reasoning_zh) ||
|
||||||
"";
|
"";
|
||||||
const localizedMetarReadRaw =
|
const localizedMetarRead =
|
||||||
(isEn ? aiCityForecast?.metar_read_en : aiCityForecast?.metar_read_zh) ||
|
(isEn ? aiCityForecast?.metar_read_en : aiCityForecast?.metar_read_zh) ||
|
||||||
"";
|
"";
|
||||||
const localizedReasoningRaw =
|
const localizedReasoning =
|
||||||
(isEn ? aiCityForecast?.reasoning_en : aiCityForecast?.reasoning_zh) ||
|
(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 =
|
const localizedModelNote =
|
||||||
(isEn
|
(isEn
|
||||||
? aiCityForecast?.model_cluster_note_en
|
? aiCityForecast?.model_cluster_note_en
|
||||||
@@ -390,11 +354,6 @@ export function AiPinnedCityCard({
|
|||||||
decisionExpectedHighNumber != null
|
decisionExpectedHighNumber != null
|
||||||
? formatTemperatureValue(decisionExpectedHighNumber, tempSymbol, { digits: 1 })
|
? formatTemperatureValue(decisionExpectedHighNumber, tempSymbol, { digits: 1 })
|
||||||
: "--";
|
: "--";
|
||||||
const observedLabel = undefined;
|
|
||||||
const currentTempText =
|
|
||||||
currentTempNumber != null
|
|
||||||
? formatTemperatureValue(currentTempNumber, tempSymbol, { digits: 1 })
|
|
||||||
: "--";
|
|
||||||
const debText =
|
const debText =
|
||||||
debNumber != null
|
debNumber != null
|
||||||
? formatTemperatureValue(debNumber, tempSymbol, { digits: 1 })
|
? formatTemperatureValue(debNumber, tempSymbol, { digits: 1 })
|
||||||
@@ -463,33 +422,20 @@ export function AiPinnedCityCard({
|
|||||||
peakHasPassed,
|
peakHasPassed,
|
||||||
});
|
});
|
||||||
const dataFreshnessRows = [
|
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" : "模型",
|
label: isEn ? "Models" : "模型",
|
||||||
value: buildModelFreshnessValue(detail, locale, isEn),
|
value: buildModelFreshnessValue(detail, locale, isEn),
|
||||||
tone: "fresh",
|
tone: "fresh" as const,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: isEn ? "Market" : "市场价格",
|
label: isEn ? "Market" : "市场价格",
|
||||||
value: buildMarketFreshnessValue({ isEn, marketScan, marketStatus }),
|
value: buildMarketFreshnessValue({ isEn, marketScan, marketStatus }),
|
||||||
tone:
|
tone:
|
||||||
marketDecisionView.status === "ready"
|
marketDecisionView.status === "ready"
|
||||||
? "fresh"
|
? ("fresh" as const)
|
||||||
: marketDecisionView.status === "loading"
|
: marketDecisionView.status === "loading"
|
||||||
? "loading"
|
? ("loading" as const)
|
||||||
: "stale",
|
: ("stale" as const),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
const freshnessSeparator = isEn ? ": " : ":";
|
const freshnessSeparator = isEn ? ": " : ":";
|
||||||
@@ -569,15 +515,12 @@ export function AiPinnedCityCard({
|
|||||||
aiReadInProgressText={aiReadInProgressText}
|
aiReadInProgressText={aiReadInProgressText}
|
||||||
aiRuleEvidenceMode={aiRuleEvidenceMode}
|
aiRuleEvidenceMode={aiRuleEvidenceMode}
|
||||||
aiRuleEvidenceText={aiRuleEvidenceText}
|
aiRuleEvidenceText={aiRuleEvidenceText}
|
||||||
currentTempText={currentTempText}
|
|
||||||
dataFreshnessRows={dataFreshnessRows}
|
|
||||||
debPrediction={debNumber}
|
debPrediction={debNumber}
|
||||||
decisionState={decisionState}
|
decisionState={decisionState}
|
||||||
detail={detail}
|
detail={detail}
|
||||||
displayName={displayName}
|
displayName={displayName}
|
||||||
expectedHighText={expectedHighText}
|
expectedHighText={expectedHighText}
|
||||||
fallbackAiReason={fallbackAiReason}
|
fallbackAiReason={fallbackAiReason}
|
||||||
freshnessSeparator={freshnessSeparator}
|
|
||||||
isEn={isEn}
|
isEn={isEn}
|
||||||
isHkoObservation={isHkoObservation}
|
isHkoObservation={isHkoObservation}
|
||||||
isRefreshing={isRefreshing}
|
isRefreshing={isRefreshing}
|
||||||
@@ -588,7 +531,6 @@ export function AiPinnedCityCard({
|
|||||||
onRefresh={handleRefresh}
|
onRefresh={handleRefresh}
|
||||||
onRemove={handleRemove}
|
onRemove={handleRemove}
|
||||||
peakWindow={peakWindow}
|
peakWindow={peakWindow}
|
||||||
rawObservationText={rawObservationText}
|
|
||||||
removing={removing}
|
removing={removing}
|
||||||
tempSymbol={tempSymbol}
|
tempSymbol={tempSymbol}
|
||||||
/>
|
/>
|
||||||
@@ -599,14 +541,10 @@ export function AiPinnedCityCard({
|
|||||||
aiStatusTone={decisionState.aiStatusTone}
|
aiStatusTone={decisionState.aiStatusTone}
|
||||||
collapseId={collapseId}
|
collapseId={collapseId}
|
||||||
collapsed={collapsed}
|
collapsed={collapsed}
|
||||||
currentTempText={currentTempText}
|
|
||||||
observedLabel={observedLabel}
|
|
||||||
dataFreshnessRows={dataFreshnessRows}
|
|
||||||
debText={debText}
|
debText={debText}
|
||||||
detailLocalTime={detail?.local_time}
|
detailLocalTime={detail?.local_time}
|
||||||
displayName={displayName}
|
displayName={displayName}
|
||||||
expectedHighText={expectedHighText}
|
expectedHighText={expectedHighText}
|
||||||
freshnessSeparator={freshnessSeparator}
|
|
||||||
isEn={isEn}
|
isEn={isEn}
|
||||||
isRefreshing={isRefreshing}
|
isRefreshing={isRefreshing}
|
||||||
modelRange={modelRange}
|
modelRange={modelRange}
|
||||||
@@ -631,7 +569,6 @@ export function AiPinnedCityCard({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="scan-ai-city-analysis-grid">
|
<div className="scan-ai-city-analysis-grid">
|
||||||
<AiCityTemperatureChart detail={detail} />
|
|
||||||
<AiEvidencePanel
|
<AiEvidencePanel
|
||||||
aiBullets={aiBullets}
|
aiBullets={aiBullets}
|
||||||
aiCityForecast={aiCityForecast}
|
aiCityForecast={aiCityForecast}
|
||||||
@@ -650,12 +587,10 @@ export function AiPinnedCityCard({
|
|||||||
isHkoObservation={isHkoObservation}
|
isHkoObservation={isHkoObservation}
|
||||||
localModelSupportNote={localModelSupportNote}
|
localModelSupportNote={localModelSupportNote}
|
||||||
localizedFinalJudgment={localizedFinalJudgment}
|
localizedFinalJudgment={localizedFinalJudgment}
|
||||||
rawObservationText={rawObservationText}
|
|
||||||
tempSymbol={tempSymbol}
|
tempSymbol={tempSymbol}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AirportEvidencePanel detail={detail} isEn={isEn} />
|
|
||||||
<ModelEvidencePanel detail={detail} isEn={isEn} />
|
<ModelEvidencePanel detail={detail} isEn={isEn} />
|
||||||
</div>
|
</div>
|
||||||
) : !detail ? (
|
) : !detail ? (
|
||||||
|
|||||||
@@ -7,24 +7,16 @@ import {
|
|||||||
type CityStatusTag,
|
type CityStatusTag,
|
||||||
type StatusTone,
|
type StatusTone,
|
||||||
} from "@/components/dashboard/scan-terminal/CityStatusTags";
|
} from "@/components/dashboard/scan-terminal/CityStatusTags";
|
||||||
import {
|
|
||||||
DataFreshnessBar,
|
|
||||||
type DataFreshnessRow,
|
|
||||||
} from "@/components/dashboard/scan-terminal/DataFreshnessBar";
|
|
||||||
|
|
||||||
export function CityCardHeader({
|
export function CityCardHeader({
|
||||||
aiStatusLabel,
|
aiStatusLabel,
|
||||||
aiStatusTone,
|
aiStatusTone,
|
||||||
collapseId,
|
collapseId,
|
||||||
collapsed,
|
collapsed,
|
||||||
currentTempText,
|
|
||||||
observedLabel,
|
|
||||||
dataFreshnessRows,
|
|
||||||
debText,
|
debText,
|
||||||
detailLocalTime,
|
detailLocalTime,
|
||||||
displayName,
|
displayName,
|
||||||
expectedHighText,
|
expectedHighText,
|
||||||
freshnessSeparator,
|
|
||||||
isEn,
|
isEn,
|
||||||
isRefreshing,
|
isRefreshing,
|
||||||
modelRange,
|
modelRange,
|
||||||
@@ -40,14 +32,10 @@ export function CityCardHeader({
|
|||||||
aiStatusTone: StatusTone;
|
aiStatusTone: StatusTone;
|
||||||
collapseId: string;
|
collapseId: string;
|
||||||
collapsed: boolean;
|
collapsed: boolean;
|
||||||
currentTempText: string;
|
|
||||||
observedLabel?: string;
|
|
||||||
dataFreshnessRows: DataFreshnessRow[];
|
|
||||||
debText: string;
|
debText: string;
|
||||||
detailLocalTime?: string | null;
|
detailLocalTime?: string | null;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
expectedHighText: string;
|
expectedHighText: string;
|
||||||
freshnessSeparator: string;
|
|
||||||
isEn: boolean;
|
isEn: boolean;
|
||||||
isRefreshing: boolean;
|
isRefreshing: boolean;
|
||||||
modelRange: string;
|
modelRange: string;
|
||||||
@@ -67,20 +55,9 @@ export function CityCardHeader({
|
|||||||
</span>
|
</span>
|
||||||
<h3>{displayName}</h3>
|
<h3>{displayName}</h3>
|
||||||
<CityStatusTags tags={statusTags} />
|
<CityStatusTags tags={statusTags} />
|
||||||
<DataFreshnessBar
|
|
||||||
aiStatusLabel={aiStatusLabel}
|
|
||||||
aiStatusTone={aiStatusTone}
|
|
||||||
freshnessSeparator={freshnessSeparator}
|
|
||||||
isEn={isEn}
|
|
||||||
rows={dataFreshnessRows}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="scan-ai-city-hero-side">
|
<div className="scan-ai-city-hero-side">
|
||||||
<div className="scan-ai-city-metrics">
|
<div className="scan-ai-city-metrics">
|
||||||
<span>
|
|
||||||
<small>{observedLabel || (isEn ? "Observed" : "当前温度")}</small>
|
|
||||||
<b>{currentTempText}</b>
|
|
||||||
</span>
|
|
||||||
<span className="primary">
|
<span className="primary">
|
||||||
<small>{isEn ? "Expected high" : "预计最高温"}</small>
|
<small>{isEn ? "Expected high" : "预计最高温"}</small>
|
||||||
<b>{expectedHighText}</b>
|
<b>{expectedHighText}</b>
|
||||||
|
|||||||
@@ -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<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"],
|
||||||
|
"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<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,18 +3,12 @@
|
|||||||
import { RefreshCw, X } from "lucide-react";
|
import { RefreshCw, X } from "lucide-react";
|
||||||
import type { MouseEvent } from "react";
|
import type { MouseEvent } from "react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { AiCityTemperatureChart } from "@/components/dashboard/scan-terminal/AiCityTemperatureChart";
|
|
||||||
import { AiEvidencePanel } from "@/components/dashboard/scan-terminal/AiEvidencePanel";
|
import { AiEvidencePanel } from "@/components/dashboard/scan-terminal/AiEvidencePanel";
|
||||||
import { AirportEvidencePanel } from "@/components/dashboard/scan-terminal/AirportEvidencePanel";
|
|
||||||
import {
|
import {
|
||||||
CityStatusTags,
|
CityStatusTags,
|
||||||
type CityStatusTag,
|
type CityStatusTag,
|
||||||
type StatusTone,
|
type StatusTone,
|
||||||
} from "@/components/dashboard/scan-terminal/CityStatusTags";
|
} 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 { LoadingSignal } from "@/components/dashboard/scan-terminal/LoadingSignal";
|
||||||
import { MarketDecisionLine } from "@/components/dashboard/scan-terminal/MarketDecisionLine";
|
import { MarketDecisionLine } from "@/components/dashboard/scan-terminal/MarketDecisionLine";
|
||||||
import { ModelEvidencePanel } from "@/components/dashboard/scan-terminal/ModelEvidencePanel";
|
import { ModelEvidencePanel } from "@/components/dashboard/scan-terminal/ModelEvidencePanel";
|
||||||
@@ -42,15 +36,12 @@ export function MobileDecisionCard({
|
|||||||
aiReadInProgressText,
|
aiReadInProgressText,
|
||||||
aiRuleEvidenceMode,
|
aiRuleEvidenceMode,
|
||||||
aiRuleEvidenceText,
|
aiRuleEvidenceText,
|
||||||
currentTempText,
|
|
||||||
dataFreshnessRows,
|
|
||||||
debPrediction,
|
debPrediction,
|
||||||
decisionState,
|
decisionState,
|
||||||
detail,
|
detail,
|
||||||
displayName,
|
displayName,
|
||||||
expectedHighText,
|
expectedHighText,
|
||||||
fallbackAiReason,
|
fallbackAiReason,
|
||||||
freshnessSeparator,
|
|
||||||
isEn,
|
isEn,
|
||||||
isHkoObservation,
|
isHkoObservation,
|
||||||
isRefreshing,
|
isRefreshing,
|
||||||
@@ -61,7 +52,6 @@ export function MobileDecisionCard({
|
|||||||
onRefresh,
|
onRefresh,
|
||||||
onRemove,
|
onRemove,
|
||||||
peakWindow,
|
peakWindow,
|
||||||
rawObservationText,
|
|
||||||
removing,
|
removing,
|
||||||
tempSymbol,
|
tempSymbol,
|
||||||
}: {
|
}: {
|
||||||
@@ -76,15 +66,12 @@ export function MobileDecisionCard({
|
|||||||
aiReadInProgressText: string;
|
aiReadInProgressText: string;
|
||||||
aiRuleEvidenceMode: boolean;
|
aiRuleEvidenceMode: boolean;
|
||||||
aiRuleEvidenceText: string;
|
aiRuleEvidenceText: string;
|
||||||
currentTempText: string;
|
|
||||||
dataFreshnessRows: DataFreshnessRow[];
|
|
||||||
debPrediction: number | null;
|
debPrediction: number | null;
|
||||||
decisionState: CityDecisionState;
|
decisionState: CityDecisionState;
|
||||||
detail: CityDetail | null;
|
detail: CityDetail | null;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
expectedHighText: string;
|
expectedHighText: string;
|
||||||
fallbackAiReason: string;
|
fallbackAiReason: string;
|
||||||
freshnessSeparator: string;
|
|
||||||
isEn: boolean;
|
isEn: boolean;
|
||||||
isHkoObservation: boolean;
|
isHkoObservation: boolean;
|
||||||
isRefreshing: boolean;
|
isRefreshing: boolean;
|
||||||
@@ -95,15 +82,12 @@ export function MobileDecisionCard({
|
|||||||
onRefresh: (event: MouseEvent<HTMLButtonElement>) => void;
|
onRefresh: (event: MouseEvent<HTMLButtonElement>) => void;
|
||||||
onRemove: (event: MouseEvent<HTMLButtonElement>) => void;
|
onRemove: (event: MouseEvent<HTMLButtonElement>) => void;
|
||||||
peakWindow: string;
|
peakWindow: string;
|
||||||
rawObservationText: string;
|
|
||||||
removing?: boolean;
|
removing?: boolean;
|
||||||
tempSymbol: string;
|
tempSymbol: string;
|
||||||
}) {
|
}) {
|
||||||
const copy = getMobileDecisionCopy(isEn);
|
const copy = getMobileDecisionCopy(isEn);
|
||||||
const loadingCopy = getCityLoadingCopy({ isEn, isHkoObservation });
|
const loadingCopy = getCityLoadingCopy({ isEn, isHkoObservation });
|
||||||
const [modelOpen, setModelOpen] = useState(false);
|
const [modelOpen, setModelOpen] = useState(false);
|
||||||
const [chartOpen, setChartOpen] = useState(false);
|
|
||||||
const [airportOpen, setAirportOpen] = useState(false);
|
|
||||||
const statusTags: CityStatusTag[] = decisionState.badges.length
|
const statusTags: CityStatusTag[] = decisionState.badges.length
|
||||||
? decisionState.badges
|
? decisionState.badges
|
||||||
: [{ label: decisionState.aiStatusLabel, tone: decisionState.aiStatusTone as StatusTone }];
|
: [{ label: decisionState.aiStatusLabel, tone: decisionState.aiStatusTone as StatusTone }];
|
||||||
@@ -142,11 +126,7 @@ export function MobileDecisionCard({
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="scan-mobile-decision-metrics">
|
<div className="scan-mobile-decision-metrics">
|
||||||
<span>
|
<span className="primary">
|
||||||
<small>{copy.currentTemp}</small>
|
|
||||||
<b>{currentTempText}</b>
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
<small>{copy.expectedHigh}</small>
|
<small>{copy.expectedHigh}</small>
|
||||||
<b>{expectedHighText}</b>
|
<b>{expectedHighText}</b>
|
||||||
</span>
|
</span>
|
||||||
@@ -158,13 +138,6 @@ export function MobileDecisionCard({
|
|||||||
|
|
||||||
<p className="scan-mobile-decision-reason">{decisionState.primaryReason}</p>
|
<p className="scan-mobile-decision-reason">{decisionState.primaryReason}</p>
|
||||||
<CityStatusTags tags={statusTags} />
|
<CityStatusTags tags={statusTags} />
|
||||||
<DataFreshnessBar
|
|
||||||
aiStatusLabel={decisionState.aiStatusLabel}
|
|
||||||
aiStatusTone={decisionState.aiStatusTone}
|
|
||||||
freshnessSeparator={freshnessSeparator}
|
|
||||||
isEn={isEn}
|
|
||||||
rows={dataFreshnessRows}
|
|
||||||
/>
|
|
||||||
<MarketDecisionLine
|
<MarketDecisionLine
|
||||||
isEn={isEn}
|
isEn={isEn}
|
||||||
marketDecisionView={marketDecisionView}
|
marketDecisionView={marketDecisionView}
|
||||||
@@ -199,21 +172,9 @@ export function MobileDecisionCard({
|
|||||||
isHkoObservation={isHkoObservation}
|
isHkoObservation={isHkoObservation}
|
||||||
localModelSupportNote={localModelSupportNote}
|
localModelSupportNote={localModelSupportNote}
|
||||||
localizedFinalJudgment={localizedFinalJudgment}
|
localizedFinalJudgment={localizedFinalJudgment}
|
||||||
rawObservationText={rawObservationText}
|
|
||||||
tempSymbol={tempSymbol}
|
tempSymbol={tempSymbol}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<details
|
|
||||||
className="scan-ai-city-section scan-mobile-fold"
|
|
||||||
open={airportOpen}
|
|
||||||
onToggle={(event) => setAirportOpen(event.currentTarget.open)}
|
|
||||||
>
|
|
||||||
<summary className="scan-ai-city-section-title">
|
|
||||||
{isEn ? "Airport live evidence" : "机场实时证据"}
|
|
||||||
</summary>
|
|
||||||
{airportOpen ? <AirportEvidencePanel detail={detail} isEn={isEn} /> : null}
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details
|
<details
|
||||||
className="scan-ai-city-section scan-mobile-fold"
|
className="scan-ai-city-section scan-mobile-fold"
|
||||||
open={modelOpen}
|
open={modelOpen}
|
||||||
@@ -222,15 +183,6 @@ export function MobileDecisionCard({
|
|||||||
<summary className="scan-ai-city-section-title">{copy.modelEvidence}</summary>
|
<summary className="scan-ai-city-section-title">{copy.modelEvidence}</summary>
|
||||||
{modelOpen ? <ModelEvidencePanel detail={detail} isEn={isEn} /> : null}
|
{modelOpen ? <ModelEvidencePanel detail={detail} isEn={isEn} /> : null}
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details
|
|
||||||
className="scan-ai-city-section scan-mobile-fold"
|
|
||||||
open={chartOpen}
|
|
||||||
onToggle={(event) => setChartOpen(event.currentTarget.open)}
|
|
||||||
>
|
|
||||||
<summary className="scan-ai-city-section-title">{copy.chart}</summary>
|
|
||||||
{chartOpen ? <AiCityTemperatureChart detail={detail} /> : null}
|
|
||||||
</details>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"""删除指定 thread_id 的 Forum Topic。用法: python scripts/delete_forum_topic.py <thread_id>"""
|
||||||
|
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 <thread_id>")
|
||||||
|
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)
|
||||||
@@ -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())
|
||||||
@@ -21,6 +21,45 @@ from src.utils.telegram_chat_ids import (
|
|||||||
get_telegram_chat_ids_from_env,
|
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 = {
|
SEVERITY_RANK = {
|
||||||
"none": 0,
|
"none": 0,
|
||||||
@@ -1186,7 +1225,11 @@ def _run_high_freq_airport_cycle(
|
|||||||
sent = False
|
sent = False
|
||||||
for chat_id in chat_ids:
|
for chat_id in chat_ids:
|
||||||
try:
|
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
|
sent = True
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("airport push failed city={} chat_id={}: {}", city, chat_id, 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
|
continue
|
||||||
if message is None:
|
if message is None:
|
||||||
continue
|
continue
|
||||||
|
city_name = futures[future]
|
||||||
for chat_id in chat_ids:
|
for chat_id in chat_ids:
|
||||||
try:
|
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
|
sent_any = True
|
||||||
except Exception as exc:
|
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
|
return sent_any
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user