feat: implement scan terminal dashboard with dedicated API route and service layer

This commit is contained in:
2569718930@qq.com
2026-04-26 02:22:56 +08:00
parent e6dbef1ece
commit 9bbb713d45
4 changed files with 229 additions and 40 deletions
@@ -37,6 +37,11 @@ export async function POST(req: NextRequest) {
const headers = new Headers(auth.headers); const headers = new Headers(auth.headers);
headers.set("Content-Type", "application/json"); headers.set("Content-Type", "application/json");
headers.set("Accept", "application/json"); headers.set("Accept", "application/json");
const requestBody = body && typeof body === "object" ? body as Record<string, unknown> : {};
console.info("[scan-ai-city] proxy request", {
city: requestBody.city,
force_refresh: requestBody.force_refresh === true,
});
const res = await fetch(`${API_BASE}/api/scan/terminal/ai-city`, { const res = await fetch(`${API_BASE}/api/scan/terminal/ai-city`, {
method: "POST", method: "POST",
headers, headers,
@@ -46,6 +51,10 @@ export async function POST(req: NextRequest) {
}); });
if (!res.ok) { if (!res.ok) {
const raw = await res.text(); const raw = await res.text();
console.warn("[scan-ai-city] backend returned non-ok", {
status: res.status,
detail: raw.slice(0, 180),
});
const response = NextResponse.json( const response = NextResponse.json(
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 300) }, { error: `Backend returned ${res.status}`, detail: raw.slice(0, 300) },
{ status: res.status === 402 || res.status === 403 ? res.status : 502 }, { status: res.status === 402 || res.status === 403 ? res.status : 502 },
@@ -53,6 +62,12 @@ export async function POST(req: NextRequest) {
return applyAuthResponseCookies(response, auth.response); return applyAuthResponseCookies(response, auth.response);
} }
const data = await res.json(); const data = await res.json();
console.info("[scan-ai-city] proxy complete", {
status: data?.status,
city: data?.city,
model: data?.model,
cached: data?.cached === true,
});
const response = NextResponse.json(data, { const response = NextResponse.json(data, {
headers: { headers: {
"Cache-Control": "no-store", "Cache-Control": "no-store",
@@ -10993,6 +10993,19 @@
border-radius: 18px; border-radius: 18px;
background: #111a2e; background: #111a2e;
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.24); box-shadow: 0 18px 48px rgba(0, 0, 0, 0.24);
opacity: 1;
transform: translateX(0);
transition:
opacity 0.24s ease,
transform 0.24s ease,
border-color 0.18s ease,
background 0.18s ease;
}
.root :global(.scan-ai-city-card.removing) {
pointer-events: none;
opacity: 0;
transform: translateX(44px);
} }
.root :global(.scan-ai-city-hero) { .root :global(.scan-ai-city-hero) {
@@ -11063,7 +11076,7 @@
margin-top: 4px; margin-top: 4px;
} }
.root :global(.scan-ai-city-pin), .root :global(.scan-ai-city-icon-button),
.root :global(.scan-ai-city-collapse), .root :global(.scan-ai-city-collapse),
.root :global(.scan-ai-city-price-button) { .root :global(.scan-ai-city-price-button) {
min-height: 36px; min-height: 36px;
@@ -11080,25 +11093,33 @@
transition: background 0.18s ease, border-color 0.18s ease; transition: background 0.18s ease, border-color 0.18s ease;
} }
.root :global(.scan-ai-city-pin) { .root :global(.scan-ai-city-icon-button) {
width: 36px; width: 36px;
justify-content: center; justify-content: center;
padding: 0; padding: 0;
} }
.root :global(.scan-ai-city-pin.pinned) { .root :global(.scan-ai-city-icon-button.danger) {
border-color: rgba(77, 163, 255, 0.72); border-color: rgba(239, 68, 68, 0.34);
background: rgba(77, 163, 255, 0.2); background: rgba(239, 68, 68, 0.1);
color: #6fb7ff; color: #fca5a5;
box-shadow: inset 0 0 0 1px rgba(77, 163, 255, 0.12);
} }
.root :global(.scan-ai-city-pin.pinned:hover) { .root :global(.scan-ai-city-icon-button.danger:hover) {
border-color: rgba(239, 68, 68, 0.52); border-color: rgba(239, 68, 68, 0.52);
background: rgba(239, 68, 68, 0.14); background: rgba(239, 68, 68, 0.14);
color: #ff9aa4; color: #ff9aa4;
} }
.root :global(.scan-ai-city-icon-button:disabled) {
cursor: wait;
opacity: 0.68;
}
.root :global(.scan-ai-city-icon-button .spin) {
animation: spin 1s linear infinite;
}
.root :global(.scan-ai-city-collapse) { .root :global(.scan-ai-city-collapse) {
padding: 8px 10px; padding: 8px 10px;
} }
@@ -11115,7 +11136,7 @@
padding: 9px 12px; padding: 9px 12px;
} }
.root :global(.scan-ai-city-pin:hover), .root :global(.scan-ai-city-icon-button:hover),
.root :global(.scan-ai-city-collapse:hover), .root :global(.scan-ai-city-collapse:hover),
.root :global(.scan-ai-city-price-button:hover) { .root :global(.scan-ai-city-price-button:hover) {
background: rgba(77, 163, 255, 0.18); background: rgba(77, 163, 255, 0.18);
@@ -8,9 +8,10 @@ import {
ChevronDown, ChevronDown,
LogIn, LogIn,
Moon, Moon,
Pin, RefreshCw,
Sun, Sun,
UserRound, UserRound,
X,
} from "lucide-react"; } from "lucide-react";
import { import {
useCallback, useCallback,
@@ -561,6 +562,7 @@ function AiPinnedCityCard({
row, row,
locale, locale,
collapsed, collapsed,
removing,
onRemove, onRemove,
onToggleCollapsed, onToggleCollapsed,
}: { }: {
@@ -569,6 +571,7 @@ function AiPinnedCityCard({
row: ScanOpportunityRow | null; row: ScanOpportunityRow | null;
locale: string; locale: string;
collapsed: boolean; collapsed: boolean;
removing?: boolean;
onRemove: () => void; onRemove: () => void;
onToggleCollapsed: () => void; onToggleCollapsed: () => void;
}) { }) {
@@ -620,13 +623,14 @@ function AiPinnedCityCard({
const [aiForecast, setAiForecast] = useState<AiCityForecastState>({ const [aiForecast, setAiForecast] = useState<AiCityForecastState>({
status: "idle", status: "idle",
}); });
const [aiRefreshToken, setAiRefreshToken] = useState(0);
const detailCityName = detail?.name || item.cityName; const detailCityName = detail?.name || item.cityName;
const aiForecastKey = detail const aiForecastKey = detail
? `${normalizeCityKey(detailCityName)}:${detail.local_date || ""}:${report || ""}` ? `${normalizeCityKey(detailCityName)}:${detail.local_date || ""}:${report || ""}`
: ""; : "";
useEffect(() => { useEffect(() => {
if (!aiForecastKey || collapsed) return; if (!aiForecastKey) return;
let cancelled = false; let cancelled = false;
setAiForecast({ status: "loading" }); setAiForecast({ status: "loading" });
fetch("/api/scan/terminal/ai-city", { fetch("/api/scan/terminal/ai-city", {
@@ -638,7 +642,7 @@ function AiPinnedCityCard({
cache: "no-store", cache: "no-store",
body: JSON.stringify({ body: JSON.stringify({
city: detailCityName, city: detailCityName,
force_refresh: false, force_refresh: aiRefreshToken > 0,
}), }),
}) })
.then(async (response) => { .then(async (response) => {
@@ -660,7 +664,7 @@ function AiPinnedCityCard({
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [aiForecastKey, collapsed, detailCityName]); }, [aiForecastKey, aiRefreshToken, detailCityName]);
const aiCityForecast = aiForecast.payload?.city_forecast || null; const aiCityForecast = aiForecast.payload?.city_forecast || null;
const localizedFinalJudgment = const localizedFinalJudgment =
@@ -694,11 +698,11 @@ function AiPinnedCityCard({
const collapseId = `ai-city-body-${normalizeCityKey(item.cityName) || item.addedAt}`; const collapseId = `ai-city-body-${normalizeCityKey(item.cityName) || item.addedAt}`;
return ( return (
<article className={clsx("scan-ai-city-card", collapsed && "collapsed")}> <article className={clsx("scan-ai-city-card", collapsed && "collapsed", removing && "removing")}>
<header className="scan-ai-city-hero"> <header className="scan-ai-city-hero">
<div> <div>
<span className="scan-ai-city-kicker"> <span className="scan-ai-city-kicker">
{isEn ? "AI city forecast" : "AI 城市预测"} {isEn ? "Deep analysis" : "城市深度分析"}
</span> </span>
<h3>{displayName}</h3> <h3>{displayName}</h3>
<div className="scan-ai-city-pills"> <div className="scan-ai-city-pills">
@@ -725,13 +729,23 @@ function AiPinnedCityCard({
<div className="scan-ai-city-actions"> <div className="scan-ai-city-actions">
<button <button
type="button" type="button"
className="scan-ai-city-pin pinned" className="scan-ai-city-icon-button"
onClick={onRemove} onClick={() => setAiRefreshToken((current) => current + 1)}
aria-pressed="true" aria-label={isEn ? `Refresh ${displayName} analysis` : `刷新 ${displayName} 深度分析`}
aria-label={isEn ? `Unpin ${displayName}` : `取消固定 ${displayName}`} title={isEn ? "Refresh analysis" : "刷新深度分析"}
title={isEn ? "Pinned. Click to unpin." : "已钉选,点击取消固定"} disabled={aiForecast.status === "loading"}
> >
<Pin size={15} fill="currentColor" /> <RefreshCw size={15} className={aiForecast.status === "loading" ? "spin" : undefined} />
</button>
<button
type="button"
className="scan-ai-city-icon-button danger"
onClick={onRemove}
aria-label={isEn ? `Remove ${displayName}` : `移除 ${displayName}`}
title={isEn ? "Remove city" : "移除城市"}
disabled={removing}
>
<X size={15} />
</button> </button>
<button <button
type="button" type="button"
@@ -794,8 +808,8 @@ function AiPinnedCityCard({
{aiForecast.status === "loading" ? ( {aiForecast.status === "loading" ? (
<p> <p>
{isEn {isEn
? "Deepseek V4 flash is reading the latest airport bulletin..." ? "Deepseek V4 pro is reading the latest airport bulletin..."
: "Deepseek V4 flash 正在解读最新机场报文..."} : "Deepseek V4 pro 正在解读最新机场报文..."}
</p> </p>
) : aiForecast.status === "ready" && aiCityForecast ? ( ) : aiForecast.status === "ready" && aiCityForecast ? (
<> <>
@@ -874,6 +888,67 @@ function AiPinnedForecastView({
const [collapsedCities, setCollapsedCities] = useState<Set<string>>( const [collapsedCities, setCollapsedCities] = useState<Set<string>>(
() => new Set(), () => new Set(),
); );
const [removingCities, setRemovingCities] = useState<Set<string>>(
() => new Set(),
);
const knownCityKeysRef = useRef<Set<string>>(new Set());
const removeTimersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
useEffect(() => {
const activeKeys = new Set(
items.map((item) => normalizeCityKey(item.cityName) || item.cityName),
);
setCollapsedCities((current) => {
const next = new Set<string>();
let changed = false;
current.forEach((key) => {
if (activeKeys.has(key)) {
next.add(key);
} else {
changed = true;
}
});
items.forEach((item) => {
const stableKey = normalizeCityKey(item.cityName) || item.cityName;
if (!knownCityKeysRef.current.has(stableKey)) {
next.add(stableKey);
changed = true;
}
});
return changed ? next : current;
});
knownCityKeysRef.current = activeKeys;
}, [items]);
useEffect(() => {
return () => {
removeTimersRef.current.forEach((timer) => clearTimeout(timer));
removeTimersRef.current.clear();
};
}, []);
const removeCityWithMotion = useCallback(
(item: AiPinnedCity, stableKey: string) => {
if (removeTimersRef.current.has(stableKey)) return;
setRemovingCities((current) => {
const next = new Set(current);
next.add(stableKey);
return next;
});
const timer = setTimeout(() => {
onRemoveCity(item.cityName);
setRemovingCities((current) => {
const next = new Set(current);
next.delete(stableKey);
return next;
});
removeTimersRef.current.delete(stableKey);
}, 260);
removeTimersRef.current.set(stableKey, timer);
},
[onRemoveCity],
);
if (!items.length) { if (!items.length) {
return ( return (
<div className="scan-ai-workspace empty"> <div className="scan-ai-workspace empty">
@@ -883,8 +958,8 @@ function AiPinnedForecastView({
</div> </div>
<div className="scan-empty-copy"> <div className="scan-empty-copy">
{isEn {isEn
? "Selected cities will appear here as pinned AI forecast blocks." ? "Selected cities will appear here as deep analysis blocks."
: "被点击的城市会加入 AI 预测页,并保留为可固定的城市分析区块。"} : "被点击的城市会加入深度分析页,并保留为城市分析区块。"}
</div> </div>
</div> </div>
</div> </div>
@@ -895,17 +970,17 @@ function AiPinnedForecastView({
<div className="scan-ai-workspace"> <div className="scan-ai-workspace">
<div className="scan-ai-workspace-head"> <div className="scan-ai-workspace-head">
<div> <div>
<span>{isEn ? "Pinned city workspace" : "固定城市工作区"}</span> <span>{isEn ? "Selected city workspace" : "城市分析工作区"}</span>
<strong> <strong>
{isEn {isEn
? `${items.length} cities under AI forecast` ? `${items.length} cities under deep analysis`
: `${items.length} 个城市正在 AI 预测`} : `${items.length} 个城市正在深度分析`}
</strong> </strong>
</div> </div>
<p> <p>
{isEn {isEn
? "Map clicks add cities here. Pinned city analysis stays here until you remove it." ? "Map clicks add cities here. City analysis stays here until you remove it."
: "地图点击会把城市加入这里;已固定的城市分析会保留,直到你手动移除。"} : "地图点击会把城市加入这里;城市分析会保留,直到你手动移除。"}
</p> </p>
</div> </div>
<div className="scan-ai-city-stack"> <div className="scan-ai-city-stack">
@@ -914,6 +989,7 @@ function AiPinnedForecastView({
const row = findRowForCity(rows, item.cityName); const row = findRowForCity(rows, item.cityName);
const key = normalizeCityKey(item.cityName); const key = normalizeCityKey(item.cityName);
const stableKey = key || item.cityName; const stableKey = key || item.cityName;
const isKnownCity = knownCityKeysRef.current.has(stableKey);
return ( return (
<AiPinnedCityCard <AiPinnedCityCard
key={stableKey} key={stableKey}
@@ -921,8 +997,9 @@ function AiPinnedForecastView({
detail={detail} detail={detail}
row={row} row={row}
locale={locale} locale={locale}
collapsed={collapsedCities.has(stableKey)} collapsed={!isKnownCity || collapsedCities.has(stableKey)}
onRemove={() => onRemoveCity(item.cityName)} removing={removingCities.has(stableKey)}
onRemove={() => removeCityWithMotion(item, stableKey)}
onToggleCollapsed={() => { onToggleCollapsed={() => {
setCollapsedCities((current) => { setCollapsedCities((current) => {
const next = new Set(current); const next = new Set(current);
@@ -973,9 +1050,9 @@ function AiForecastKPIBar({
"--"; "--";
const cards = [ const cards = [
{ {
label: isEn ? "AI Workspace" : "AI 工作区", label: isEn ? "Deep Analysis" : "深度分析",
value: String(pinnedCount), value: String(pinnedCount),
note: isEn ? "Pinned cities from map clicks" : "地图点选后固定到 AI 预测", note: isEn ? "Cities selected from map clicks" : "地图点选后进入深度分析",
tone: "green", tone: "green",
}, },
{ {
@@ -1343,7 +1420,7 @@ function ScanTerminalScreen() {
<div className="scan-empty-state"> <div className="scan-empty-state">
<div className="scan-empty-title">{isEn ? "Checking access" : "正在检查权限"}</div> <div className="scan-empty-title">{isEn ? "Checking access" : "正在检查权限"}</div>
<div className="scan-empty-copy"> <div className="scan-empty-copy">
{isEn ? "Preparing your AI forecast workspace." : "正在准备 AI 预测台。"} {isEn ? "Preparing your deep analysis workspace." : "正在准备深度分析台。"}
</div> </div>
</div> </div>
</main> </main>
@@ -1358,11 +1435,11 @@ function ScanTerminalScreen() {
<main className="scan-data-grid"> <main className="scan-data-grid">
<div className="scan-topbar"> <div className="scan-topbar">
<div className="scan-topbar-title"> <div className="scan-topbar-title">
<strong>{isEn ? "AI Forecast Terminal" : "AI 预测台"}</strong> <strong>{isEn ? "Deep Analysis Terminal" : "深度分析台"}</strong>
<span> <span>
{isEn {isEn
? "Click cities on the map to build an AI forecast workspace" ? "Click cities on the map to build a deep analysis workspace"
: "点击地图城市加入 AI 预测工作区,按城市查看 DEB / 模型 / METAR"} : "点击地图城市加入深度分析工作区,按城市查看 DEB / 模型 / METAR"}
</span> </span>
</div> </div>
<div className="scan-topbar-actions"> <div className="scan-topbar-actions">
@@ -1444,7 +1521,7 @@ function ScanTerminalScreen() {
setActiveView("list"); setActiveView("list");
}} }}
> >
{isEn ? "AI Forecast" : "AI 预测"} {isEn ? "Deep Analysis" : "深度分析"}
</button> </button>
<button <button
type="button" type="button"
+77 -1
View File
@@ -980,6 +980,12 @@ def build_scan_city_ai_forecast_payload(
city_name = str(city or "").strip() city_name = str(city or "").strip()
if not city_name: if not city_name:
return {"status": "failed", "reason": "city is required"} return {"status": "failed", "reason": "city is required"}
logger.info(
"scan city AI forecast requested city={} force_refresh={} model={}",
city_name,
force_refresh,
SCAN_AI_MODEL,
)
data = _analyze( data = _analyze(
city_name, city_name,
force_refresh=force_refresh, force_refresh=force_refresh,
@@ -992,6 +998,11 @@ def build_scan_city_ai_forecast_payload(
with _SCAN_CITY_AI_CACHE_LOCK: with _SCAN_CITY_AI_CACHE_LOCK:
cached = _SCAN_CITY_AI_CACHE.get(cache_key) cached = _SCAN_CITY_AI_CACHE.get(cache_key)
if cached and cached.get("expires_at", 0) >= time.time(): if cached and cached.get("expires_at", 0) >= time.time():
logger.info(
"scan city AI forecast cache hit city={} model={}",
data.get("name") or city_name,
SCAN_AI_MODEL,
)
return { return {
"status": "ready", "status": "ready",
"cached": True, "cached": True,
@@ -1005,6 +1016,11 @@ def build_scan_city_ai_forecast_payload(
} }
if not SCAN_AI_ENABLED: if not SCAN_AI_ENABLED:
logger.warning(
"scan city AI forecast disabled city={} model={}",
data.get("name") or city_name,
SCAN_AI_MODEL,
)
return { return {
"status": "disabled", "status": "disabled",
"model": SCAN_AI_MODEL, "model": SCAN_AI_MODEL,
@@ -1014,6 +1030,11 @@ def build_scan_city_ai_forecast_payload(
"reason": "POLYWEATHER_SCAN_AI_ENABLED is not enabled", "reason": "POLYWEATHER_SCAN_AI_ENABLED is not enabled",
} }
if not str(os.getenv("POLYWEATHER_DEEPSEEK_API_KEY") or "").strip(): if not str(os.getenv("POLYWEATHER_DEEPSEEK_API_KEY") or "").strip():
logger.warning(
"scan city AI forecast missing DeepSeek key city={} model={}",
data.get("name") or city_name,
SCAN_AI_MODEL,
)
return { return {
"status": "missing_key", "status": "missing_key",
"model": SCAN_AI_MODEL, "model": SCAN_AI_MODEL,
@@ -1023,7 +1044,55 @@ def build_scan_city_ai_forecast_payload(
"reason": "POLYWEATHER_DEEPSEEK_API_KEY is not configured", "reason": "POLYWEATHER_DEEPSEEK_API_KEY is not configured",
} }
ai_raw = _call_deepseek_city_ai(ai_input) try:
logger.info(
"scan city AI forecast calling provider city={} station={} model={} raw_metar_present={}",
data.get("name") or city_name,
((ai_input.get("airport") or {}).get("icao") if isinstance(ai_input.get("airport"), dict) else None),
SCAN_AI_MODEL,
bool(
(ai_input.get("airport_current") or {}).get("raw_metar")
if isinstance(ai_input.get("airport_current"), dict)
else False
),
)
ai_raw = _call_deepseek_city_ai(ai_input)
except httpx.TimeoutException as exc:
duration_ms = int((time.time() - started_at) * 1000)
logger.warning(
"scan city AI forecast timeout city={} duration_ms={} model={} error={}",
data.get("name") or city_name,
duration_ms,
SCAN_AI_MODEL,
exc,
)
return {
"status": "timeout",
"model": SCAN_AI_MODEL,
"provider": "deepseek",
"city": data.get("name") or city_name,
"city_display_name": data.get("display_name") or city_name,
"duration_ms": duration_ms,
"reason": f"V4 provider timed out after {SCAN_AI_TIMEOUT_SEC}s",
}
except Exception as exc:
duration_ms = int((time.time() - started_at) * 1000)
logger.warning(
"scan city AI forecast failed city={} duration_ms={} model={} error={}",
data.get("name") or city_name,
duration_ms,
SCAN_AI_MODEL,
exc,
)
return {
"status": "failed",
"model": SCAN_AI_MODEL,
"provider": "deepseek",
"city": data.get("name") or city_name,
"city_display_name": data.get("display_name") or city_name,
"duration_ms": duration_ms,
"reason": str(exc),
}
generated_at = datetime.utcnow().isoformat() + "Z" generated_at = datetime.utcnow().isoformat() + "Z"
with _SCAN_CITY_AI_CACHE_LOCK: with _SCAN_CITY_AI_CACHE_LOCK:
_SCAN_CITY_AI_CACHE[cache_key] = { _SCAN_CITY_AI_CACHE[cache_key] = {
@@ -1031,6 +1100,13 @@ def build_scan_city_ai_forecast_payload(
"generated_at": generated_at, "generated_at": generated_at,
"payload": ai_raw, "payload": ai_raw,
} }
logger.info(
"scan city AI forecast complete city={} duration_ms={} model={} confidence={}",
data.get("name") or city_name,
int((time.time() - started_at) * 1000),
SCAN_AI_MODEL,
ai_raw.get("confidence") if isinstance(ai_raw, dict) else None,
)
return { return {
"status": "ready", "status": "ready",
"cached": False, "cached": False,