summaryrefslogtreecommitdiff
path: root/frontend/src/lib/instrumentSearch.ts
diff options
context:
space:
mode:
Diffstat (limited to 'frontend/src/lib/instrumentSearch.ts')
-rw-r--r--frontend/src/lib/instrumentSearch.ts120
1 files changed, 120 insertions, 0 deletions
diff --git a/frontend/src/lib/instrumentSearch.ts b/frontend/src/lib/instrumentSearch.ts
new file mode 100644
index 0000000..8a83a47
--- /dev/null
+++ b/frontend/src/lib/instrumentSearch.ts
@@ -0,0 +1,120 @@
+// Production search bug fix /instrument-search coordinator.
+//
+// Root cause of the hung spinner: the API previously did a full catalog fetch
+// server-side. Client-side we now keep the round trip bounded end to end:
+// debounce + abort of prior stale queries + a hard AbortController timer, and
+// explicit handling of the honest failure statuses the backend now returns.
+// Pure dependency injection makes stale-query cleanup unit testable.
+import { apiFetch } from './api';
+import { extractStatusMessage } from './state';
+import type { InstrumentSearchResponse } from './types';
+
+export const SEARCH_TIMEOUT_MS = 12_000;
+export const SEARCH_DEBOUNCE_MS = 350;
+
+export const SEARCH_UNAVAILABLE_MSG = '搜索数据源暂时不可用,请稍后重试';
+export const SEARCH_TIMEOUT_MSG = '搜索超时,请重试';
+export const SEARCH_EMPTY_MSG = '未找到匹配标的,可使用下方手动录入并核对身份';
+
+export type SearchFetcher = (
+ q: string,
+ signal?: AbortSignal
+) => Promise<InstrumentSearchResponse>;
+
+export function fetchSearch(q: string, signal?: AbortSignal): Promise<InstrumentSearchResponse> {
+ return apiFetch<InstrumentSearchResponse>(`/api/instruments?q=${encodeURIComponent(q)}`, { signal });
+}
+
+export interface SearchHandlers {
+ onBusy(): void;
+ onResult(res: InstrumentSearchResponse): void;
+ onError(message: string): void;
+ onFinish(): void;
+}
+
+export interface SearchRunner {
+ runNow(q: string): void;
+ requestDebounced(q: string): void;
+ dispose(): void;
+}
+
+export interface SearchRunnerOptions {
+ timeoutMs?: number;
+ debounceMs?: number;
+}
+
+export function createSearchRunner(
+ doFetch: SearchFetcher = fetchSearch,
+ handlers: SearchHandlers,
+ options: SearchRunnerOptions = {},
+): SearchRunner {
+ const timeoutMs = options.timeoutMs ?? SEARCH_TIMEOUT_MS;
+ const debounceMs = options.debounceMs ?? SEARCH_DEBOUNCE_MS;
+ let seq = 0;
+ let controller: AbortController | null = null;
+ let timer: ReturnType<typeof setTimeout> | null = null;
+
+ function clearTimer() {
+ if (timer) {
+ clearTimeout(timer);
+ timer = null;
+ }
+ }
+
+ async function run(q: string) {
+ const mySeq = ++seq;
+ // abort the previous in-flight request first: its callbacks all become
+ // stale (mySeq !== seq) and must not touch the UI
+ controller?.abort();
+ controller = new AbortController();
+ const ctl = controller;
+ const timeout = setTimeout(() => ctl.abort(), timeoutMs);
+ timer = timeout;
+ handlers.onBusy();
+ try {
+ const res = await doFetch(q, ctl.signal);
+ if (mySeq !== seq) return;
+ if (res.status === 'ok') {
+ handlers.onResult(res);
+ } else {
+ handlers.onResult({ items: [], source: 'none', status: res.status });
+ handlers.onError(SEARCH_UNAVAILABLE_MSG);
+ }
+ } catch (e) {
+ if (mySeq !== seq) return;
+ const signalAborted = ctl.signal.aborted;
+ const abortError = (e as { name?: string }).name === 'AbortError';
+ handlers.onError(
+ signalAborted || abortError ? SEARCH_TIMEOUT_MSG : extractStatusMessage(e),
+ );
+ } finally {
+ if (timer === timeout) timer = null;
+ clearTimeout(timeout);
+ // stale requests never touch busy state (their handler may already have
+ // been superseded); only the still-current query may reset the spinner
+ if (mySeq === seq) handlers.onFinish();
+ }
+ }
+
+ return {
+ runNow: (q: string) => {
+ clearTimer();
+ if (!q.trim()) return;
+ void run(q.trim());
+ },
+ requestDebounced: (q: string) => {
+ clearTimer();
+ if (!q.trim()) return;
+ timer = setTimeout(() => {
+ timer = null;
+ void run(q.trim());
+ }, debounceMs);
+ },
+ dispose: () => {
+ clearTimer();
+ seq += 1; // invalidate any in-flight callbacks
+ controller?.abort();
+ controller = null;
+ },
+ };
+}