diff --git a/frontend/app/api/scan/terminal/ai-city/route.ts b/frontend/app/api/scan/terminal/ai-city/route.ts index 4ab87390..e0f905fb 100644 --- a/frontend/app/api/scan/terminal/ai-city/route.ts +++ b/frontend/app/api/scan/terminal/ai-city/route.ts @@ -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 : {}; + 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", diff --git a/frontend/components/dashboard/Dashboard.module.css b/frontend/components/dashboard/Dashboard.module.css index a67c3336..7168371b 100644 --- a/frontend/components/dashboard/Dashboard.module.css +++ b/frontend/components/dashboard/Dashboard.module.css @@ -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); diff --git a/frontend/components/dashboard/ScanTerminalDashboard.tsx b/frontend/components/dashboard/ScanTerminalDashboard.tsx index 271ff35a..4569e3c6 100644 --- a/frontend/components/dashboard/ScanTerminalDashboard.tsx +++ b/frontend/components/dashboard/ScanTerminalDashboard.tsx @@ -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({ 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 ( -
+
- {isEn ? "AI city forecast" : "AI 城市预测"} + {isEn ? "Deep analysis" : "城市深度分析"}

{displayName}

@@ -725,13 +729,23 @@ function AiPinnedCityCard({
+
@@ -895,17 +970,17 @@ function AiPinnedForecastView({
- {isEn ? "Pinned city workspace" : "固定城市工作区"} + {isEn ? "Selected city workspace" : "城市分析工作区"} {isEn - ? `${items.length} cities under AI forecast` - : `${items.length} 个城市正在 AI 预测`} + ? `${items.length} cities under deep analysis` + : `${items.length} 个城市正在深度分析`}

{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." + : "地图点击会把城市加入这里;城市分析会保留,直到你手动移除。"}

@@ -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 ( 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() {
{isEn ? "Checking access" : "正在检查权限"}
- {isEn ? "Preparing your AI forecast workspace." : "正在准备 AI 预测台。"} + {isEn ? "Preparing your deep analysis workspace." : "正在准备深度分析台。"}
@@ -1358,11 +1435,11 @@ function ScanTerminalScreen() {
- {isEn ? "AI Forecast Terminal" : "AI 预测台"} + {isEn ? "Deep Analysis Terminal" : "深度分析台"} {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"}
@@ -1444,7 +1521,7 @@ function ScanTerminalScreen() { setActiveView("list"); }} > - {isEn ? "AI Forecast" : "AI 预测"} + {isEn ? "Deep Analysis" : "深度分析"}