feat: implement AI city forecast and market scan integration for terminal dashboard

This commit is contained in:
2569718930@qq.com
2026-04-26 11:08:58 +08:00
parent e374ad4bf0
commit 9b77a3a0e2
7 changed files with 117 additions and 26 deletions
+8
View File
@@ -24,6 +24,9 @@ POLYWEATHER_ALERT_RELAY_PORT=9099
POLYWEATHER_GRAFANA_PORT=3001 POLYWEATHER_GRAFANA_PORT=3001
POLYWEATHER_GRAFANA_ADMIN_USER=admin POLYWEATHER_GRAFANA_ADMIN_USER=admin
POLYWEATHER_GRAFANA_ADMIN_PASSWORD=polyweather 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 # 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 NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL=https://polygon-bor-rpc.publicnode.com
# Optional: disable homepage city summary preloading. Default is enabled. # Optional: disable homepage city summary preloading. Default is enabled.
NEXT_PUBLIC_POLYWEATHER_DISABLE_EAGER_SUMMARIES=false 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 # 7) Optional modules
+7
View File
@@ -2,8 +2,15 @@
# 只部署天气看板时,先填下面 4 项即可。 # 只部署天气看板时,先填下面 4 项即可。
# 必填:后端 FastAPI 基础地址 # 必填:后端 FastAPI 基础地址
# 默认供 Next.js API Route 在服务端代理后端使用。
POLYWEATHER_API_BASE_URL=http://127.0.0.1:8000 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 前端公钥(鉴权开启时必须) # 必填:Supabase 前端公钥(鉴权开启时必须)
NEXT_PUBLIC_SUPABASE_URL= NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_ANON_KEY= NEXT_PUBLIC_SUPABASE_ANON_KEY=
@@ -11,12 +11,12 @@ const AI_CITY_GATEWAY_TIMEOUT_MS = Math.max(
process.env.POLYWEATHER_SCAN_AI_GATEWAY_TIMEOUT_MS || process.env.POLYWEATHER_SCAN_AI_GATEWAY_TIMEOUT_MS ||
process.env.POLYWEATHER_AI_CITY_GATEWAY_TIMEOUT_MS || process.env.POLYWEATHER_AI_CITY_GATEWAY_TIMEOUT_MS ||
process.env.POLYWEATHER_SCAN_AI_PROXY_TIMEOUT_MS || process.env.POLYWEATHER_SCAN_AI_PROXY_TIMEOUT_MS ||
"120000", "55000",
) || 120_000, ) || 55_000,
); );
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export const maxDuration = 130; export const maxDuration = 60;
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
if (!API_BASE) { if (!API_BASE) {
@@ -32,6 +32,10 @@ import type {
ScanOpportunityRow, ScanOpportunityRow,
ScanTerminalResponse, ScanTerminalResponse,
} from "@/lib/dashboard-types"; } from "@/lib/dashboard-types";
import {
buildBrowserBackendHeaders,
resolveBackendApiUrl,
} from "@/lib/backend-api";
import { getLocalizedCityName } from "@/lib/dashboard-home-copy"; import { getLocalizedCityName } from "@/lib/dashboard-home-copy";
import { AiPinnedForecastView } from "@/components/dashboard/scan-terminal/AiPinnedForecastView"; import { AiPinnedForecastView } from "@/components/dashboard/scan-terminal/AiPinnedForecastView";
import { CalendarView } from "@/components/dashboard/scan-terminal/CalendarView"; import { CalendarView } from "@/components/dashboard/scan-terminal/CalendarView";
@@ -240,11 +244,19 @@ function ScanTerminalScreen() {
time_range: "today", time_range: "today",
limit: "36", limit: "36",
}); });
fetch(`/api/scan/terminal?${params.toString()}`, { void buildBrowserBackendHeaders({
cache: "no-store", Accept: "application/json",
signal: controller.signal,
}) })
.then((headers) => {
if (cancelled) return null;
return fetch(resolveBackendApiUrl(`/api/scan/terminal?${params.toString()}`), {
cache: "no-store",
headers,
signal: controller.signal,
});
})
.then(async (response) => { .then(async (response) => {
if (!response) return null;
if (!response.ok) { if (!response.ok) {
let message = `HTTP ${response.status}`; let message = `HTTP ${response.status}`;
try { try {
@@ -258,6 +270,7 @@ function ScanTerminalScreen() {
return response.json() as Promise<ScanTerminalResponse>; return response.json() as Promise<ScanTerminalResponse>;
}) })
.then((payload) => { .then((payload) => {
if (!payload) return;
if (cancelled) return; if (cancelled) return;
setTerminalData(payload); setTerminalData(payload);
setScanError(null); setScanError(null);
@@ -6,6 +6,10 @@ import type {
AiCityForecastState, AiCityForecastState,
} from "@/components/dashboard/scan-terminal/types"; } from "@/components/dashboard/scan-terminal/types";
import { useDashboardStore } from "@/hooks/useDashboardStore"; import { useDashboardStore } from "@/hooks/useDashboardStore";
import {
buildBrowserBackendHeaders,
resolveBackendApiUrl,
} from "@/lib/backend-api";
import type { CityDetail, MarketScan } from "@/lib/dashboard-types"; import type { CityDetail, MarketScan } from "@/lib/dashboard-types";
import { normalizeCityKey } from "./decision-utils"; import { normalizeCityKey } from "./decision-utils";
@@ -182,21 +186,26 @@ export function useAiCityForecast({
? "DeepSeek V4-Pro is reading the latest airport bulletin..." ? "DeepSeek V4-Pro is reading the latest airport bulletin..."
: "DeepSeek V4-Pro 正在解读最新机场报文...", : "DeepSeek V4-Pro 正在解读最新机场报文...",
}); });
fetch("/api/scan/terminal/ai-city", { void buildBrowserBackendHeaders({
method: "POST",
headers: {
Accept: "application/json", Accept: "application/json",
"Content-Type": "application/json", "Content-Type": "application/json",
}, })
cache: "no-store", .then((headers) => {
signal: controller.signal, if (cancelled) return null;
body: JSON.stringify({ return fetch(resolveBackendApiUrl("/api/scan/terminal/ai-city"), {
city: detailCityName, method: "POST",
force_refresh: aiRefreshToken > 0, headers,
locale, cache: "no-store",
}), signal: controller.signal,
}) body: JSON.stringify({
city: detailCityName,
force_refresh: aiRefreshToken > 0,
locale,
}),
});
})
.then(async (response) => { .then(async (response) => {
if (!response) return null;
if (!response.ok) { if (!response.ok) {
let detailMessage = ""; let detailMessage = "";
try { try {
@@ -226,6 +235,7 @@ export function useAiCityForecast({
return response.json() as Promise<AiCityForecastPayload>; return response.json() as Promise<AiCityForecastPayload>;
}) })
.then((payload) => { .then((payload) => {
if (!payload) return;
if (!cancelled) { if (!cancelled) {
const usablePayload = const usablePayload =
payload?.city_forecast payload?.city_forecast
+45
View File
@@ -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;
}
+16 -8
View File
@@ -9,6 +9,10 @@ import {
ScanTerminalFilters, ScanTerminalFilters,
ScanTerminalResponse, ScanTerminalResponse,
} from "@/lib/dashboard-types"; } from "@/lib/dashboard-types";
import {
buildBrowserBackendHeaders,
resolveBackendApiUrl,
} from "@/lib/backend-api";
export type AssistantOpportunityContext = { export type AssistantOpportunityContext = {
city_name: string; city_name: string;
@@ -108,11 +112,15 @@ async function fetchJson<T>(url: string, options?: { timeoutMs?: number }): Prom
const timeoutId = controller const timeoutId = controller
? window.setTimeout(() => controller.abort(), timeoutMs) ? window.setTimeout(() => controller.abort(), timeoutMs)
: null; : null;
const requestUrl = resolveBackendApiUrl(url);
const headers = await buildBrowserBackendHeaders({
Accept: "application/json",
});
let response: Response; let response: Response;
try { try {
response = await fetch(url, { response = await fetch(requestUrl, {
headers: { Accept: "application/json" }, headers,
cache: "no-store", cache: "no-store",
signal: controller?.signal, signal: controller?.signal,
}); });
@@ -457,18 +465,18 @@ export const dashboardClient = {
if (existing) { if (existing) {
return 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", method: "POST",
headers: { headers,
Accept: "application/json",
"Content-Type": "application/json",
},
cache: "no-store", cache: "no-store",
body: JSON.stringify({ body: JSON.stringify({
filters: payload.filters, filters: payload.filters,
snapshot_id: snapshotId || null, snapshot_id: snapshotId || null,
}), }),
}) }))
.then(async (response) => { .then(async (response) => {
if (!response.ok) { if (!response.ok) {
throw new Error(`HTTP ${response.status}`); throw new Error(`HTTP ${response.status}`);