From 9b77a3a0e2abc43daa7edf5b849ce4d228899af5 Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Sun, 26 Apr 2026 11:08:58 +0800 Subject: [PATCH] feat: implement AI city forecast and market scan integration for terminal dashboard --- .env.example | 8 ++++ frontend/.env.example | 7 +++ .../app/api/scan/terminal/ai-city/route.ts | 6 +-- .../dashboard/ScanTerminalDashboard.tsx | 19 ++++++-- .../scan-terminal/use-ai-city-card-data.ts | 34 +++++++++----- frontend/lib/backend-api.ts | 45 +++++++++++++++++++ frontend/lib/dashboard-client.ts | 24 ++++++---- 7 files changed, 117 insertions(+), 26 deletions(-) create mode 100644 frontend/lib/backend-api.ts diff --git a/.env.example b/.env.example index a4c71a9f..247996a4 100644 --- a/.env.example +++ b/.env.example @@ -24,6 +24,9 @@ POLYWEATHER_ALERT_RELAY_PORT=9099 POLYWEATHER_GRAFANA_PORT=3001 POLYWEATHER_GRAFANA_ADMIN_USER=admin POLYWEATHER_GRAFANA_ADMIN_PASSWORD=polyweather +# Backend CORS allowlist. Add your Vercel production/preview domains when +# NEXT_PUBLIC_POLYWEATHER_API_BASE_URL points browsers directly at this backend. +WEB_CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000,https://polyweather-pro.vercel.app ######################################## # 2) Telegram bot minimal @@ -116,6 +119,11 @@ NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID= NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL=https://polygon-bor-rpc.publicnode.com # Optional: disable homepage city summary preloading. Default is enabled. NEXT_PUBLIC_POLYWEATHER_DISABLE_EAGER_SUMMARIES=false +# Optional: browser-visible FastAPI base URL for Vercel deployments. +# Set this to your VPS HTTPS origin to let AI / METAR / scan dashboard calls +# bypass Vercel Functions / Fluid Compute instead of going through Next.js API proxies. +# Example: NEXT_PUBLIC_POLYWEATHER_API_BASE_URL=https://api.example.com +NEXT_PUBLIC_POLYWEATHER_API_BASE_URL= ######################################## # 7) Optional modules diff --git a/frontend/.env.example b/frontend/.env.example index 7b49ebfe..bcbd1cb8 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -2,8 +2,15 @@ # 只部署天气看板时,先填下面 4 项即可。 # 必填:后端 FastAPI 基础地址 +# 默认供 Next.js API Route 在服务端代理后端使用。 POLYWEATHER_API_BASE_URL=http://127.0.0.1:8000 +# 可选:浏览器直连后端 FastAPI 基础地址。 +# 在 Vercel 免费额度下建议配置为 VPS HTTPS 域名,让 AI / METAR / scan 等 +# 长耗时请求绕过 Vercel Functions / Fluid Compute。 +# 例如:https://api.example.com +NEXT_PUBLIC_POLYWEATHER_API_BASE_URL= + # 必填:Supabase 前端公钥(鉴权开启时必须) NEXT_PUBLIC_SUPABASE_URL= NEXT_PUBLIC_SUPABASE_ANON_KEY= diff --git a/frontend/app/api/scan/terminal/ai-city/route.ts b/frontend/app/api/scan/terminal/ai-city/route.ts index 3d7a0149..e404f409 100644 --- a/frontend/app/api/scan/terminal/ai-city/route.ts +++ b/frontend/app/api/scan/terminal/ai-city/route.ts @@ -11,12 +11,12 @@ const AI_CITY_GATEWAY_TIMEOUT_MS = Math.max( process.env.POLYWEATHER_SCAN_AI_GATEWAY_TIMEOUT_MS || process.env.POLYWEATHER_AI_CITY_GATEWAY_TIMEOUT_MS || process.env.POLYWEATHER_SCAN_AI_PROXY_TIMEOUT_MS || - "120000", - ) || 120_000, + "55000", + ) || 55_000, ); export const dynamic = "force-dynamic"; -export const maxDuration = 130; +export const maxDuration = 60; export async function POST(req: NextRequest) { if (!API_BASE) { diff --git a/frontend/components/dashboard/ScanTerminalDashboard.tsx b/frontend/components/dashboard/ScanTerminalDashboard.tsx index 6a28068a..cbd58db2 100644 --- a/frontend/components/dashboard/ScanTerminalDashboard.tsx +++ b/frontend/components/dashboard/ScanTerminalDashboard.tsx @@ -32,6 +32,10 @@ import type { ScanOpportunityRow, ScanTerminalResponse, } from "@/lib/dashboard-types"; +import { + buildBrowserBackendHeaders, + resolveBackendApiUrl, +} from "@/lib/backend-api"; import { getLocalizedCityName } from "@/lib/dashboard-home-copy"; import { AiPinnedForecastView } from "@/components/dashboard/scan-terminal/AiPinnedForecastView"; import { CalendarView } from "@/components/dashboard/scan-terminal/CalendarView"; @@ -240,11 +244,19 @@ function ScanTerminalScreen() { time_range: "today", limit: "36", }); - fetch(`/api/scan/terminal?${params.toString()}`, { - cache: "no-store", - signal: controller.signal, + void buildBrowserBackendHeaders({ + Accept: "application/json", }) + .then((headers) => { + if (cancelled) return null; + return fetch(resolveBackendApiUrl(`/api/scan/terminal?${params.toString()}`), { + cache: "no-store", + headers, + signal: controller.signal, + }); + }) .then(async (response) => { + if (!response) return null; if (!response.ok) { let message = `HTTP ${response.status}`; try { @@ -258,6 +270,7 @@ function ScanTerminalScreen() { return response.json() as Promise; }) .then((payload) => { + if (!payload) return; if (cancelled) return; setTerminalData(payload); setScanError(null); diff --git a/frontend/components/dashboard/scan-terminal/use-ai-city-card-data.ts b/frontend/components/dashboard/scan-terminal/use-ai-city-card-data.ts index 85f601c3..ab1e1a99 100644 --- a/frontend/components/dashboard/scan-terminal/use-ai-city-card-data.ts +++ b/frontend/components/dashboard/scan-terminal/use-ai-city-card-data.ts @@ -6,6 +6,10 @@ import type { AiCityForecastState, } from "@/components/dashboard/scan-terminal/types"; import { useDashboardStore } from "@/hooks/useDashboardStore"; +import { + buildBrowserBackendHeaders, + resolveBackendApiUrl, +} from "@/lib/backend-api"; import type { CityDetail, MarketScan } from "@/lib/dashboard-types"; import { normalizeCityKey } from "./decision-utils"; @@ -182,21 +186,26 @@ export function useAiCityForecast({ ? "DeepSeek V4-Pro is reading the latest airport bulletin..." : "DeepSeek V4-Pro 正在解读最新机场报文...", }); - fetch("/api/scan/terminal/ai-city", { - method: "POST", - headers: { + void buildBrowserBackendHeaders({ Accept: "application/json", "Content-Type": "application/json", - }, - cache: "no-store", - signal: controller.signal, - body: JSON.stringify({ - city: detailCityName, - force_refresh: aiRefreshToken > 0, - locale, - }), - }) + }) + .then((headers) => { + if (cancelled) return null; + return fetch(resolveBackendApiUrl("/api/scan/terminal/ai-city"), { + method: "POST", + headers, + cache: "no-store", + signal: controller.signal, + body: JSON.stringify({ + city: detailCityName, + force_refresh: aiRefreshToken > 0, + locale, + }), + }); + }) .then(async (response) => { + if (!response) return null; if (!response.ok) { let detailMessage = ""; try { @@ -226,6 +235,7 @@ export function useAiCityForecast({ return response.json() as Promise; }) .then((payload) => { + if (!payload) return; if (!cancelled) { const usablePayload = payload?.city_forecast diff --git a/frontend/lib/backend-api.ts b/frontend/lib/backend-api.ts new file mode 100644 index 00000000..f2d0a028 --- /dev/null +++ b/frontend/lib/backend-api.ts @@ -0,0 +1,45 @@ +"use client"; + +import { + getSupabaseBrowserClient, + hasSupabasePublicEnv, +} from "@/lib/supabase/client"; + +const PUBLIC_BACKEND_API_BASE_URL = + process.env.NEXT_PUBLIC_POLYWEATHER_API_BASE_URL?.trim() || ""; + +export function hasDirectBackendApiBaseUrl() { + return Boolean(PUBLIC_BACKEND_API_BASE_URL); +} + +export function resolveBackendApiUrl(path: string) { + if (!PUBLIC_BACKEND_API_BASE_URL || /^https?:\/\//i.test(path)) { + return path; + } + const base = PUBLIC_BACKEND_API_BASE_URL.replace(/\/+$/, ""); + const suffix = path.startsWith("/") ? path : `/${path}`; + return `${base}${suffix}`; +} + +export async function buildBrowserBackendHeaders(init?: HeadersInit) { + const headers = new Headers(init); + if (!headers.has("Accept")) { + headers.set("Accept", "application/json"); + } + + if (!headers.has("Authorization") && hasSupabasePublicEnv()) { + try { + const { + data: { session }, + } = await getSupabaseBrowserClient().auth.getSession(); + const accessToken = session?.access_token || ""; + if (accessToken) { + headers.set("Authorization", `Bearer ${accessToken}`); + } + } catch { + // Direct backend mode must also work for public/optional-auth dashboards. + } + } + + return headers; +} diff --git a/frontend/lib/dashboard-client.ts b/frontend/lib/dashboard-client.ts index 4740121e..cf89338e 100644 --- a/frontend/lib/dashboard-client.ts +++ b/frontend/lib/dashboard-client.ts @@ -9,6 +9,10 @@ import { ScanTerminalFilters, ScanTerminalResponse, } from "@/lib/dashboard-types"; +import { + buildBrowserBackendHeaders, + resolveBackendApiUrl, +} from "@/lib/backend-api"; export type AssistantOpportunityContext = { city_name: string; @@ -108,11 +112,15 @@ async function fetchJson(url: string, options?: { timeoutMs?: number }): Prom const timeoutId = controller ? window.setTimeout(() => controller.abort(), timeoutMs) : null; + const requestUrl = resolveBackendApiUrl(url); + const headers = await buildBrowserBackendHeaders({ + Accept: "application/json", + }); let response: Response; try { - response = await fetch(url, { - headers: { Accept: "application/json" }, + response = await fetch(requestUrl, { + headers, cache: "no-store", signal: controller?.signal, }); @@ -457,18 +465,18 @@ export const dashboardClient = { if (existing) { return existing; } - const request = fetch("/api/scan/terminal/ai", { + const request = buildBrowserBackendHeaders({ + Accept: "application/json", + "Content-Type": "application/json", + }).then((headers) => fetch(resolveBackendApiUrl("/api/scan/terminal/ai"), { method: "POST", - headers: { - Accept: "application/json", - "Content-Type": "application/json", - }, + headers, cache: "no-store", body: JSON.stringify({ filters: payload.filters, snapshot_id: snapshotId || null, }), - }) + })) .then(async (response) => { if (!response.ok) { throw new Error(`HTTP ${response.status}`);