From 9ca3ee5a8efb6ce99df96ba3c569f21ca5ccfa1f Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Tue, 28 Apr 2026 10:24:40 +0800 Subject: [PATCH] Centralize scan terminal requests behind a lightweight client The scan terminal hooks were each carrying their own fetch, SSE parsing, error normalization, previous-data handling, and market request behavior. This adds a small scanTerminalClient plus RemoteData helpers so terminal data, city detail, market scans, and AI city streams share one request boundary without introducing React Query. Constraint: Do not add dependencies or change backend API contracts. Rejected: Introduce React Query immediately | too broad for this release and would force larger UI state rewrites. Rejected: Move localStorage caches in the same pass | safer to first isolate network and stream IO before cache policy migration. Confidence: high Scope-risk: moderate Reversibility: clean Tested: npm run build Not-tested: Live SSE cancellation against production latency. --- .../scan-terminal/scan-terminal-client.ts | 325 ++++++++++++++++++ .../scan-terminal/use-ai-city-card-data.ts | 169 +-------- .../scan-terminal/use-scan-terminal-query.ts | 65 ++-- 3 files changed, 368 insertions(+), 191 deletions(-) create mode 100644 frontend/components/dashboard/scan-terminal/scan-terminal-client.ts diff --git a/frontend/components/dashboard/scan-terminal/scan-terminal-client.ts b/frontend/components/dashboard/scan-terminal/scan-terminal-client.ts new file mode 100644 index 00000000..9337bbda --- /dev/null +++ b/frontend/components/dashboard/scan-terminal/scan-terminal-client.ts @@ -0,0 +1,325 @@ +"use client"; + +import { extractStreamingAirportRead } from "@/components/dashboard/scan-terminal/ai-city-stream"; +import type { AiCityForecastPayload } from "@/components/dashboard/scan-terminal/types"; +import { + buildBrowserBackendHeaders, + fetchBackendApi, +} from "@/lib/backend-api"; +import type { + CityDetail, + MarketScan, + ScanTerminalResponse, +} from "@/lib/dashboard-types"; + +export type RemoteData = + | { status: "idle" } + | { status: "loading"; previous?: T } + | { status: "success"; data: T; freshAt: number } + | { status: "error"; error: string; previous?: T }; + +export type AiCityStreamProgress = { + stage?: string | null; + message_en?: string | null; + message_zh?: string | null; + final_judgment_en?: string | null; + final_judgment_zh?: string | null; + metar_read_en?: string | null; + metar_read_zh?: string | null; + raw_length?: number | null; +}; + +type AiCityStreamEvent = { + data: Record; + event: string; +}; + +type TerminalQueryOptions = { + forceRefresh?: boolean; + signal?: AbortSignal; +}; + +type CityDetailQueryOptions = { + forceRefresh?: boolean; + marketSlug?: string | null; + signal?: AbortSignal; + targetDate?: string | null; +}; + +type MarketScanQueryOptions = CityDetailQueryOptions & { + lite?: boolean; +}; + +type AiCityReadOptions = { + city: string; + forceRefresh?: boolean; + locale: string; + onProgress?: (progress: AiCityStreamProgress) => void; + signal?: AbortSignal; +}; + +function getRemoteError(error: unknown) { + return error instanceof Error ? error.message : String(error); +} + +function readPreviousRemoteData(remote: RemoteData): T | undefined { + if (remote.status === "success") return remote.data; + if ("previous" in remote) return remote.previous; + return undefined; +} + +export function toRemoteLoading(current: RemoteData): RemoteData { + return { + status: "loading", + previous: readPreviousRemoteData(current), + }; +} + +export function toRemoteSuccess(data: T): RemoteData { + return { + data, + freshAt: Date.now(), + status: "success", + }; +} + +export function toRemoteError( + error: unknown, + current: RemoteData, +): RemoteData { + return { + error: getRemoteError(error), + previous: readPreviousRemoteData(current), + status: "error", + }; +} + +async function readJsonOrThrow(path: string, init?: RequestInit): Promise { + const response = await fetchBackendApi(path, init); + if (response.ok) return response.json() as Promise; + + let message = `HTTP ${response.status}`; + try { + const payload = await response.json(); + message = String(payload?.error || payload?.detail || message); + } catch { + try { + const raw = await response.text(); + message = raw ? `${message} · ${raw.slice(0, 240)}` : message; + } catch { + // Keep HTTP status message. + } + } + throw new Error(message); +} + +function parseAiCityStreamBlock(block: string): AiCityStreamEvent | null { + const eventLines = block + .split(/\r?\n/) + .map((line) => line.trimEnd()) + .filter(Boolean); + let event = "message"; + const dataLines: string[] = []; + eventLines.forEach((line) => { + if (line.startsWith("event:")) { + event = line.slice("event:".length).trim() || event; + } else if (line.startsWith("data:")) { + dataLines.push(line.slice("data:".length).trimStart()); + } + }); + if (!dataLines.length) return null; + try { + const data = JSON.parse(dataLines.join("\n")) as Record; + return { data, event }; + } catch { + return null; + } +} + +async function readAiCityForecastStream( + response: Response, + locale: string, + onProgress?: (progress: AiCityStreamProgress) => void, +) { + if (!response.body) { + return response.json() as Promise; + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + let accumulatedRaw = ""; + let finalPayload: AiCityForecastPayload | null = null; + + const consumeBlock = (block: string) => { + const parsed = parseAiCityStreamBlock(block); + if (!parsed) return; + const { data, event } = parsed; + if (event === "final") { + finalPayload = data as AiCityForecastPayload; + return; + } + if (event === "progress" || event === "preview") { + onProgress?.(data as AiCityStreamProgress); + return; + } + if (event !== "delta") return; + + const content = String(data.content || ""); + const rawLength = Number(data.raw_length); + if (content) { + accumulatedRaw += content; + } + const streamingAirportRead = extractStreamingAirportRead(accumulatedRaw, locale); + const progress: AiCityStreamProgress = { + raw_length: Number.isFinite(rawLength) ? rawLength : null, + }; + if (streamingAirportRead) { + if (locale === "en-US") { + progress.metar_read_en = streamingAirportRead; + } else { + progress.metar_read_zh = streamingAirportRead; + } + } + onProgress?.(progress); + }; + + for (;;) { + const { done, value } = await reader.read(); + buffer += decoder.decode(value || new Uint8Array(), { stream: !done }); + const blocks = buffer.split(/\r?\n\r?\n/); + buffer = blocks.pop() || ""; + blocks.forEach(consumeBlock); + if (done) break; + } + if (buffer.trim()) { + consumeBlock(buffer); + } + if (!finalPayload) { + throw new Error("AI stream ended before final payload"); + } + return finalPayload; +} + +async function getTerminal({ + forceRefresh = false, + signal, +}: TerminalQueryOptions = {}) { + const params = new URLSearchParams({ + scan_mode: "tradable", + min_price: "0.05", + max_price: "0.95", + min_edge_pct: "2", + min_liquidity: "500", + market_type: "maxtemp", + time_range: "today", + limit: "36", + force_refresh: String(forceRefresh), + }); + if (forceRefresh) { + params.set("_ts", String(Date.now())); + } + const headers = await buildBrowserBackendHeaders({ Accept: "application/json" }); + return readJsonOrThrow( + `/api/scan/terminal?${params.toString()}`, + { + cache: "no-store", + headers, + signal, + }, + ); +} + +async function getCityDetail(city: string, options: CityDetailQueryOptions = {}) { + const params = new URLSearchParams({ + force_refresh: String(options.forceRefresh ?? false), + }); + if (options.marketSlug) params.set("market_slug", options.marketSlug); + if (options.targetDate) params.set("target_date", options.targetDate); + const headers = await buildBrowserBackendHeaders({ Accept: "application/json" }); + return readJsonOrThrow( + `/api/city/${encodeURIComponent(city)}/detail?${params.toString()}`, + { + cache: "no-store", + headers, + signal: options.signal, + }, + ); +} + +async function getMarketScan(city: string, options: MarketScanQueryOptions = {}) { + const params = new URLSearchParams({ + force_refresh: String(options.forceRefresh ?? false), + }); + if (options.targetDate) params.set("target_date", options.targetDate); + if (options.marketSlug) params.set("market_slug", options.marketSlug); + if (options.lite != null) params.set("lite", String(options.lite)); + const headers = await buildBrowserBackendHeaders({ Accept: "application/json" }); + return readJsonOrThrow( + `/api/city/${encodeURIComponent(city)}/market-scan?${params.toString()}`, + { + cache: "no-store", + headers, + signal: options.signal, + }, + ); +} + +async function streamAiCityRead({ + city, + forceRefresh = false, + locale, + onProgress, + signal, +}: AiCityReadOptions) { + const headers = await buildBrowserBackendHeaders({ + Accept: "text/event-stream", + "Content-Type": "application/json", + }); + const response = await fetchBackendApi("/api/scan/terminal/ai-city/stream", { + method: "POST", + headers, + cache: "no-store", + body: JSON.stringify({ + city, + force_refresh: forceRefresh, + locale, + }), + signal, + }); + if (!response.ok) { + let detailMessage = ""; + try { + const raw = await response.text(); + const errorPayload = JSON.parse(raw); + const message = String(errorPayload?.error || "").trim(); + const rawDetail = String(errorPayload?.detail || "").trim(); + const elapsed = Number(errorPayload?.elapsed_ms); + const timeout = Number(errorPayload?.timeout_ms); + detailMessage = [ + message, + rawDetail, + Number.isFinite(elapsed) && Number.isFinite(timeout) + ? `elapsed ${Math.round(elapsed / 1000)}s / timeout ${Math.round(timeout / 1000)}s` + : "", + ] + .filter(Boolean) + .join(" · "); + } catch { + detailMessage = ""; + } + throw new Error( + detailMessage + ? `HTTP ${response.status} · ${detailMessage}` + : `HTTP ${response.status}`, + ); + } + return readAiCityForecastStream(response, locale, onProgress); +} + +export const scanTerminalClient = { + getCityDetail, + getMarketScan, + getTerminal, + streamAiCityRead, +}; diff --git a/frontend/components/dashboard/scan-terminal/use-ai-city-card-data.ts b/frontend/components/dashboard/scan-terminal/use-ai-city-card-data.ts index b45f5785..a74db116 100644 --- a/frontend/components/dashboard/scan-terminal/use-ai-city-card-data.ts +++ b/frontend/components/dashboard/scan-terminal/use-ai-city-card-data.ts @@ -5,13 +5,11 @@ import type { AiCityForecastPayload, AiCityForecastState, } from "@/components/dashboard/scan-terminal/types"; -import { useDashboardStore } from "@/hooks/useDashboardStore"; import { - buildBrowserBackendHeaders, - fetchBackendApi, -} from "@/lib/backend-api"; + scanTerminalClient, + type AiCityStreamProgress, +} from "@/components/dashboard/scan-terminal/scan-terminal-client"; import type { CityDetail, MarketScan } from "@/lib/dashboard-types"; -import { extractStreamingAirportRead } from "./ai-city-stream"; import { normalizeCityKey } from "./decision-utils"; const AI_CITY_FORECAST_CACHE_PREFIX = "polyWeather_aiCityForecast_v6"; @@ -30,22 +28,6 @@ const aiCityForecastStateCache = new Map< >(); let activeAiCityForecastStreams = 0; -type AiCityStreamProgress = { - stage?: string | null; - message_en?: string | null; - message_zh?: string | null; - final_judgment_en?: string | null; - final_judgment_zh?: string | null; - metar_read_en?: string | null; - metar_read_zh?: string | null; - raw_length?: number | null; -}; - -type AiCityStreamEvent = { - data: Record; - event: string; -}; - function getStorage() { if (typeof window === "undefined") return null; try { @@ -155,97 +137,6 @@ function runQueuedAiCityForecastTask( }); } -function parseAiCityStreamBlock(block: string): AiCityStreamEvent | null { - const eventLines = block - .split(/\r?\n/) - .map((line) => line.trimEnd()) - .filter(Boolean); - let event = "message"; - const dataLines: string[] = []; - eventLines.forEach((line) => { - if (line.startsWith("event:")) { - event = line.slice("event:".length).trim() || event; - } else if (line.startsWith("data:")) { - dataLines.push(line.slice("data:".length).trimStart()); - } - }); - if (!dataLines.length) return null; - try { - const data = JSON.parse(dataLines.join("\n")) as Record; - return { data, event }; - } catch { - return null; - } -} - -async function readAiCityForecastStream( - response: Response, - locale: string, - onProgress?: (progress: AiCityStreamProgress) => void, -) { - if (!response.body) { - return response.json() as Promise; - } - - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ""; - let accumulatedRaw = ""; - let finalPayload: AiCityForecastPayload | null = null; - - const consumeBlock = (block: string) => { - const parsed = parseAiCityStreamBlock(block); - if (!parsed) return; - const { data, event } = parsed; - if (event === "final") { - finalPayload = data as AiCityForecastPayload; - return; - } - if (event === "progress" || event === "preview") { - onProgress?.(data as AiCityStreamProgress); - return; - } - if (event === "delta") { - const content = String(data.content || ""); - const rawLength = Number(data.raw_length); - if (content) { - accumulatedRaw += content; - } - const streamingAirportRead = extractStreamingAirportRead( - accumulatedRaw, - locale, - ); - const progress: AiCityStreamProgress = { - raw_length: Number.isFinite(rawLength) ? rawLength : null, - }; - if (streamingAirportRead) { - if (locale === "en-US") { - progress.metar_read_en = streamingAirportRead; - } else { - progress.metar_read_zh = streamingAirportRead; - } - } - onProgress?.(progress); - } - }; - - for (;;) { - const { done, value } = await reader.read(); - buffer += decoder.decode(value || new Uint8Array(), { stream: !done }); - const blocks = buffer.split(/\r?\n\r?\n/); - buffer = blocks.pop() || ""; - blocks.forEach(consumeBlock); - if (done) break; - } - if (buffer.trim()) { - consumeBlock(buffer); - } - if (!finalPayload) { - throw new Error("AI stream ended before final payload"); - } - return finalPayload; -} - function requestAiCityForecast({ city, forceRefresh, @@ -263,48 +154,12 @@ function requestAiCityForecast({ if (pending) return pending; const request = runQueuedAiCityForecastTask(async () => { - const headers = await buildBrowserBackendHeaders({ - Accept: "text/event-stream", - "Content-Type": "application/json", + return scanTerminalClient.streamAiCityRead({ + city, + forceRefresh, + locale, + onProgress, }); - const response = await fetchBackendApi("/api/scan/terminal/ai-city/stream", { - method: "POST", - headers, - cache: "no-store", - body: JSON.stringify({ - city, - force_refresh: forceRefresh, - locale, - }), - }); - if (!response.ok) { - let detailMessage = ""; - try { - const raw = await response.text(); - const errorPayload = JSON.parse(raw); - const message = String(errorPayload?.error || "").trim(); - const rawDetail = String(errorPayload?.detail || "").trim(); - const elapsed = Number(errorPayload?.elapsed_ms); - const timeout = Number(errorPayload?.timeout_ms); - detailMessage = [ - message, - rawDetail, - Number.isFinite(elapsed) && Number.isFinite(timeout) - ? `elapsed ${Math.round(elapsed / 1000)}s / timeout ${Math.round(timeout / 1000)}s` - : "", - ] - .filter(Boolean) - .join(" · "); - } catch { - detailMessage = ""; - } - throw new Error( - detailMessage - ? `HTTP ${response.status} · ${detailMessage}` - : `HTTP ${response.status}`, - ); - } - return readAiCityForecastStream(response, locale, onProgress); }, () => { onProgress?.({ stage: "queued", @@ -614,7 +469,6 @@ export function useCityMarketScan({ detailCityName: string; enabled?: boolean; }) { - const ensureCityMarketScan = useDashboardStore().ensureCityMarketScan; const [marketScan, setMarketScan] = useState( detail?.market_scan || null, ); @@ -671,8 +525,10 @@ export function useCityMarketScan({ } else { setMarketStatus("loading"); } - void ensureCityMarketScan(detailCityName, false, { + const controller = new AbortController(); + void scanTerminalClient.getMarketScan(detailCityName, { lite: false, + signal: controller.signal, targetDate: detail.local_date || null, }) .then((payload) => { @@ -690,8 +546,9 @@ export function useCityMarketScan({ }); return () => { cancelled = true; + controller.abort(); }; - }, [detail, detailCityName, enabled, ensureCityMarketScan]); + }, [detail, detailCityName, enabled]); return { marketScan, marketStatus }; } diff --git a/frontend/components/dashboard/scan-terminal/use-scan-terminal-query.ts b/frontend/components/dashboard/scan-terminal/use-scan-terminal-query.ts index 087e0200..b1c1ccc8 100644 --- a/frontend/components/dashboard/scan-terminal/use-scan-terminal-query.ts +++ b/frontend/components/dashboard/scan-terminal/use-scan-terminal-query.ts @@ -2,9 +2,12 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { - buildBrowserBackendHeaders, - fetchBackendApi, -} from "@/lib/backend-api"; + scanTerminalClient, + toRemoteError, + toRemoteLoading, + toRemoteSuccess, + type RemoteData, +} from "@/components/dashboard/scan-terminal/scan-terminal-client"; import type { ScanTerminalResponse } from "@/lib/dashboard-types"; const SCAN_TERMINAL_AUTO_REFRESH_MS = 10 * 60_000; @@ -18,8 +21,12 @@ export function useScanTerminalQuery({ proAccessLoading: boolean; }) { const [terminalData, setTerminalData] = useState(null); + const [scanRemote, setScanRemote] = useState>({ + status: "idle", + }); const [scanLoading, setScanLoading] = useState(false); const [scanError, setScanError] = useState(null); + const scanAbortRef = useRef(null); const scanRequestSeqRef = useRef(0); const scanLoadingRef = useRef(false); const lastForcedScanRefreshAtRef = useRef(0); @@ -34,56 +41,36 @@ export function useScanTerminalQuery({ } = {}) => { if (proAccessLoading || !isPro) return; const requestSeq = ++scanRequestSeqRef.current; + scanAbortRef.current?.abort(); const controller = new AbortController(); + scanAbortRef.current = controller; if (forceRefresh) { lastForcedScanRefreshAtRef.current = Date.now(); } if (showLoading) { scanLoadingRef.current = true; setScanLoading(true); + setScanRemote((current) => toRemoteLoading(current)); } setScanError(null); - const params = new URLSearchParams({ - scan_mode: "tradable", - min_price: "0.05", - max_price: "0.95", - min_edge_pct: "2", - min_liquidity: "500", - market_type: "maxtemp", - time_range: "today", - limit: "36", - force_refresh: String(forceRefresh), - }); - if (forceRefresh) { - params.set("_ts", String(Date.now())); - } try { - const headers = await buildBrowserBackendHeaders({ - Accept: "application/json", - }); - const response = await fetchBackendApi(`/api/scan/terminal?${params.toString()}`, { - cache: "no-store", - headers, + const payload = await scanTerminalClient.getTerminal({ + forceRefresh, signal: controller.signal, }); - if (!response.ok) { - let message = `HTTP ${response.status}`; - try { - const payload = await response.json(); - message = String(payload?.error || payload?.detail || message); - } catch { - // Keep HTTP status message. - } - throw new Error(message); - } - const payload = (await response.json()) as ScanTerminalResponse; if (requestSeq !== scanRequestSeqRef.current) return; setTerminalData(payload); + setScanRemote(toRemoteSuccess(payload)); setScanError(null); } catch (error) { if (controller.signal.aborted || requestSeq !== scanRequestSeqRef.current) return; - setScanError(error instanceof Error ? error.message : String(error)); + const message = error instanceof Error ? error.message : String(error); + setScanError(message); + setScanRemote((current) => toRemoteError(error, current)); } finally { + if (scanAbortRef.current === controller) { + scanAbortRef.current = null; + } if (showLoading) { scanLoadingRef.current = false; setScanLoading(false); @@ -100,11 +87,18 @@ export function useScanTerminalQuery({ setScanLoading(false); setScanError(null); setTerminalData(null); + setScanRemote({ status: "idle" }); return; } void fetchScanTerminal({ forceRefresh: false, showLoading: true }); }, [fetchScanTerminal, isPro, proAccessLoading]); + useEffect(() => { + return () => { + scanAbortRef.current?.abort(); + }; + }, []); + const refreshScanTerminalManually = useCallback(() => { const now = Date.now(); const lastForced = lastForcedScanRefreshAtRef.current; @@ -133,6 +127,7 @@ export function useScanTerminalQuery({ refreshScanTerminalManually, scanError, scanLoading, + scanRemote, terminalData, }; }