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);
headers.set("Content-Type", "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`, {
method: "POST",
headers,
@@ -46,6 +51,10 @@ export async function POST(req: NextRequest) {
});
if (!res.ok) {
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(
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 300) },
{ 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);
}
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, {
headers: {
"Cache-Control": "no-store",
@@ -10993,6 +10993,19 @@
border-radius: 18px;
background: #111a2e;
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) {
@@ -11063,7 +11076,7 @@
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-price-button) {
min-height: 36px;
@@ -11080,25 +11093,33 @@
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;
justify-content: center;
padding: 0;
}
.root :global(.scan-ai-city-pin.pinned) {
border-color: rgba(77, 163, 255, 0.72);
background: rgba(77, 163, 255, 0.2);
color: #6fb7ff;
box-shadow: inset 0 0 0 1px rgba(77, 163, 255, 0.12);
.root :global(.scan-ai-city-icon-button.danger) {
border-color: rgba(239, 68, 68, 0.34);
background: rgba(239, 68, 68, 0.1);
color: #fca5a5;
}
.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);
background: rgba(239, 68, 68, 0.14);
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) {
padding: 8px 10px;
}
@@ -11115,7 +11136,7 @@
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-price-button:hover) {
background: rgba(77, 163, 255, 0.18);
@@ -8,9 +8,10 @@ import {
ChevronDown,
LogIn,
Moon,
Pin,
RefreshCw,
Sun,
UserRound,
X,
} from "lucide-react";
import {
useCallback,
@@ -561,6 +562,7 @@ function AiPinnedCityCard({
row,
locale,
collapsed,
removing,
onRemove,
onToggleCollapsed,
}: {
@@ -569,6 +571,7 @@ function AiPinnedCityCard({
row: ScanOpportunityRow | null;
locale: string;
collapsed: boolean;
removing?: boolean;
onRemove: () => void;
onToggleCollapsed: () => void;
}) {
@@ -620,13 +623,14 @@ function AiPinnedCityCard({
const [aiForecast, setAiForecast] = useState<AiCityForecastState>({
status: "idle",
});
const [aiRefreshToken, setAiRefreshToken] = useState(0);
const detailCityName = detail?.name || item.cityName;
const aiForecastKey = detail
? `${normalizeCityKey(detailCityName)}:${detail.local_date || ""}:${report || ""}`
: "";
useEffect(() => {
if (!aiForecastKey || collapsed) return;
if (!aiForecastKey) return;
let cancelled = false;
setAiForecast({ status: "loading" });
fetch("/api/scan/terminal/ai-city", {
@@ -638,7 +642,7 @@ function AiPinnedCityCard({
cache: "no-store",
body: JSON.stringify({
city: detailCityName,
force_refresh: false,
force_refresh: aiRefreshToken > 0,
}),
})
.then(async (response) => {
@@ -660,7 +664,7 @@ function AiPinnedCityCard({
return () => {
cancelled = true;
};
}, [aiForecastKey, collapsed, detailCityName]);
}, [aiForecastKey, aiRefreshToken, detailCityName]);
const aiCityForecast = aiForecast.payload?.city_forecast || null;
const localizedFinalJudgment =
@@ -694,11 +698,11 @@ function AiPinnedCityCard({
const collapseId = `ai-city-body-${normalizeCityKey(item.cityName) || item.addedAt}`;
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">
<div>
<span className="scan-ai-city-kicker">
{isEn ? "AI city forecast" : "AI 城市预测"}
{isEn ? "Deep analysis" : "城市深度分析"}
</span>
<h3>{displayName}</h3>
<div className="scan-ai-city-pills">
@@ -725,13 +729,23 @@ function AiPinnedCityCard({
<div className="scan-ai-city-actions">
<button
type="button"
className="scan-ai-city-pin pinned"
onClick={onRemove}
aria-pressed="true"
aria-label={isEn ? `Unpin ${displayName}` : `取消固定 ${displayName}`}
title={isEn ? "Pinned. Click to unpin." : "已钉选,点击取消固定"}
className="scan-ai-city-icon-button"
onClick={() => setAiRefreshToken((current) => current + 1)}
aria-label={isEn ? `Refresh ${displayName} analysis` : `刷新 ${displayName} 深度分析`}
title={isEn ? "Refresh analysis" : "刷新深度分析"}
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
type="button"
@@ -794,8 +808,8 @@ function AiPinnedCityCard({
{aiForecast.status === "loading" ? (
<p>
{isEn
? "Deepseek V4 flash is reading the latest airport bulletin..."
: "Deepseek V4 flash 正在解读最新机场报文..."}
? "Deepseek V4 pro is reading the latest airport bulletin..."
: "Deepseek V4 pro 正在解读最新机场报文..."}
</p>
) : aiForecast.status === "ready" && aiCityForecast ? (
<>
@@ -874,6 +888,67 @@ function AiPinnedForecastView({
const [collapsedCities, setCollapsedCities] = useState<Set<string>>(
() => 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) {
return (
<div className="scan-ai-workspace empty">
@@ -883,8 +958,8 @@ function AiPinnedForecastView({
</div>
<div className="scan-empty-copy">
{isEn
? "Selected cities will appear here as pinned AI forecast blocks."
: "被点击的城市会加入 AI 预测页,并保留为可固定的城市分析区块。"}
? "Selected cities will appear here as deep analysis blocks."
: "被点击的城市会加入深度分析页,并保留为城市分析区块。"}
</div>
</div>
</div>
@@ -895,17 +970,17 @@ function AiPinnedForecastView({
<div className="scan-ai-workspace">
<div className="scan-ai-workspace-head">
<div>
<span>{isEn ? "Pinned city workspace" : "固定城市工作区"}</span>
<span>{isEn ? "Selected city workspace" : "城市分析工作区"}</span>
<strong>
{isEn
? `${items.length} cities under AI forecast`
: `${items.length} 个城市正在 AI 预测`}
? `${items.length} cities under deep analysis`
: `${items.length} 个城市正在深度分析`}
</strong>
</div>
<p>
{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>
</div>
<div className="scan-ai-city-stack">
@@ -914,6 +989,7 @@ function AiPinnedForecastView({
const row = findRowForCity(rows, item.cityName);
const key = normalizeCityKey(item.cityName);
const stableKey = key || item.cityName;
const isKnownCity = knownCityKeysRef.current.has(stableKey);
return (
<AiPinnedCityCard
key={stableKey}
@@ -921,8 +997,9 @@ function AiPinnedForecastView({
detail={detail}
row={row}
locale={locale}
collapsed={collapsedCities.has(stableKey)}
onRemove={() => onRemoveCity(item.cityName)}
collapsed={!isKnownCity || collapsedCities.has(stableKey)}
removing={removingCities.has(stableKey)}
onRemove={() => removeCityWithMotion(item, stableKey)}
onToggleCollapsed={() => {
setCollapsedCities((current) => {
const next = new Set(current);
@@ -973,9 +1050,9 @@ function AiForecastKPIBar({
"--";
const cards = [
{
label: isEn ? "AI Workspace" : "AI 工作区",
label: isEn ? "Deep Analysis" : "深度分析",
value: String(pinnedCount),
note: isEn ? "Pinned cities from map clicks" : "地图点选后固定到 AI 预测",
note: isEn ? "Cities selected from map clicks" : "地图点选后进入深度分析",
tone: "green",
},
{
@@ -1343,7 +1420,7 @@ function ScanTerminalScreen() {
<div className="scan-empty-state">
<div className="scan-empty-title">{isEn ? "Checking access" : "正在检查权限"}</div>
<div className="scan-empty-copy">
{isEn ? "Preparing your AI forecast workspace." : "正在准备 AI 预测台。"}
{isEn ? "Preparing your deep analysis workspace." : "正在准备深度分析台。"}
</div>
</div>
</main>
@@ -1358,11 +1435,11 @@ function ScanTerminalScreen() {
<main className="scan-data-grid">
<div className="scan-topbar">
<div className="scan-topbar-title">
<strong>{isEn ? "AI Forecast Terminal" : "AI 预测台"}</strong>
<strong>{isEn ? "Deep Analysis Terminal" : "深度分析台"}</strong>
<span>
{isEn
? "Click cities on the map to build an AI forecast workspace"
: "点击地图城市加入 AI 预测工作区,按城市查看 DEB / 模型 / METAR"}
? "Click cities on the map to build a deep analysis workspace"
: "点击地图城市加入深度分析工作区,按城市查看 DEB / 模型 / METAR"}
</span>
</div>
<div className="scan-topbar-actions">
@@ -1444,7 +1521,7 @@ function ScanTerminalScreen() {
setActiveView("list");
}}
>
{isEn ? "AI Forecast" : "AI 预测"}
{isEn ? "Deep Analysis" : "深度分析"}
</button>
<button
type="button"
+77 -1
View File
@@ -980,6 +980,12 @@ def build_scan_city_ai_forecast_payload(
city_name = str(city or "").strip()
if not city_name:
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(
city_name,
force_refresh=force_refresh,
@@ -992,6 +998,11 @@ def build_scan_city_ai_forecast_payload(
with _SCAN_CITY_AI_CACHE_LOCK:
cached = _SCAN_CITY_AI_CACHE.get(cache_key)
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 {
"status": "ready",
"cached": True,
@@ -1005,6 +1016,11 @@ def build_scan_city_ai_forecast_payload(
}
if not SCAN_AI_ENABLED:
logger.warning(
"scan city AI forecast disabled city={} model={}",
data.get("name") or city_name,
SCAN_AI_MODEL,
)
return {
"status": "disabled",
"model": SCAN_AI_MODEL,
@@ -1014,6 +1030,11 @@ def build_scan_city_ai_forecast_payload(
"reason": "POLYWEATHER_SCAN_AI_ENABLED is not enabled",
}
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 {
"status": "missing_key",
"model": SCAN_AI_MODEL,
@@ -1023,7 +1044,55 @@ def build_scan_city_ai_forecast_payload(
"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"
with _SCAN_CITY_AI_CACHE_LOCK:
_SCAN_CITY_AI_CACHE[cache_key] = {
@@ -1031,6 +1100,13 @@ def build_scan_city_ai_forecast_payload(
"generated_at": generated_at,
"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 {
"status": "ready",
"cached": False,