feat: implement scan terminal dashboard with dedicated API route and service layer
This commit is contained in:
@@ -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"
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user