visionA 雲端版前端 — 沿用 local-tool 既有 UI(原則 4:先抄 local-tool)+
新增雲端特有的登入 / 配對 / 設定流程,含以下整合階段:
- Phase 0:13 頁 + 30+ 元件 + 雛形 banner
- dashboard / devices / models / workspace / clusters / settings 等頁
- AppShell + Sidebar + Header + tokens + i18n(中英雙語 96 keys)
- API client + 5 stores + 3 hooks
- Phase 0.6:OIDC redirect 改造
- login 頁改為 OIDC redirect(`window.location.href = /api/auth/login`)
- register 改說明頁、account 改唯讀(user 資料來源是 MC)
- api client 改 cookie session(credentials: include)+ 完全清掉 localStorage
- Phase 0.7:stage 部署 + nil guard
- getApiBaseUrl() 修:browser 環境視為 same-origin(與 login 頁一致)
- login 頁加「已登入 → router.replace('/')」effect
- User type email/name 改 optional(MC id_token 不一定回 email/name claim)
- header.tsx UserMenu displayName 4 層 fallback:name → email → id → i18n
- 雛形 banner 文案更新(已接 Innovedus 帳號中心)+ 版號 Phase 0.7
驗證:pnpm lint / test (125/125) / build 全綠
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
138 lines
4.9 KiB
TypeScript
138 lines
4.9 KiB
TypeScript
/**
|
||
* useFetch — 簡易 data fetching hook(不依賴 SWR / TanStack Query)
|
||
*
|
||
* 設計原則(對齊 F5 任務要求):
|
||
* - 雛形不裝第三方 server state library;自己寫個基本版
|
||
* - 支援基本的 `data / error / isLoading / refetch` 四態
|
||
* - caller 可傳 options.enabled=false 暫停自動 fetch(例如等 auth ready)
|
||
* - 使用 AbortController 防止 race condition(快速切路由 / 重新 mount 時)
|
||
* - 不做快取層(雛形夠用;Phase 1 升級 SWR / TanStack Query 再加)
|
||
*
|
||
* 用法範例:
|
||
* const { data, error, isLoading, refetch } = useFetch<Device[]>("/api/devices");
|
||
*/
|
||
|
||
"use client";
|
||
|
||
import { useCallback, useEffect, useRef, useState } from "react";
|
||
|
||
import { api, type RequestOptions } from "@/lib/api";
|
||
|
||
export interface UseFetchOptions<T> extends Omit<RequestOptions, "signal"> {
|
||
/** 是否啟用自動 fetch(預設 true)。設 false 可手動呼叫 refetch */
|
||
enabled?: boolean;
|
||
/** 初始 data(SSR hydrate 或 fallback) */
|
||
initialData?: T;
|
||
/**
|
||
* 依賴陣列 — 任一值變動會觸發 refetch。
|
||
* 通常放 path 的動態段,例如 `[deviceId]`。path 本身已包含在 deps 所以不需要重複傳。
|
||
*/
|
||
deps?: ReadonlyArray<unknown>;
|
||
}
|
||
|
||
export interface UseFetchResult<T> {
|
||
data: T | undefined;
|
||
error: Error | null;
|
||
isLoading: boolean;
|
||
/** 手動觸發 refetch;回傳新的 data 或 throw */
|
||
refetch: () => Promise<T | undefined>;
|
||
}
|
||
|
||
/**
|
||
* 呼叫 `api.get<T>(path)` 的 React hook。
|
||
*
|
||
* ⚠️ 要點:
|
||
* - path 作為主 key;path 變動自動重 fetch
|
||
* - 支援取消:unmount 或 path 變動時,前一次請求會被 abort,state 不會被覆寫
|
||
* - 不做 stale-while-revalidate / dedup — 雛形不需要
|
||
*/
|
||
export function useFetch<T>(
|
||
path: string,
|
||
options: UseFetchOptions<T> = {},
|
||
): UseFetchResult<T> {
|
||
const { enabled = true, initialData, deps = [], ...requestOptions } = options;
|
||
|
||
const [data, setData] = useState<T | undefined>(initialData);
|
||
const [error, setError] = useState<Error | null>(null);
|
||
/**
|
||
* fetchState 追蹤目前請求階段:
|
||
* - "idle":尚未或被 disable,不顯示 loading
|
||
* - "loading":請求中
|
||
* - "success" / "error":已結束
|
||
*
|
||
* 為何不用 boolean `isLoading`:
|
||
* React 19 `react-hooks/set-state-in-effect` 禁止在 effect body 直接 setState。
|
||
* 改為每個狀態只會由「特定動作」觸發 setState(doFetch 動作、disable 時直接讀 enabled),
|
||
* 避免 effect 內做派生。
|
||
*
|
||
* 對 caller 仍回傳 boolean `isLoading`,對外 API 不變。
|
||
*/
|
||
const [fetchState, setFetchState] = useState<"idle" | "loading" | "success" | "error">(
|
||
enabled ? "loading" : "idle",
|
||
);
|
||
|
||
// 保存 options ref 避免 useCallback deps 抖動。於 effect 內更新以符合 React 19 lint。
|
||
const optionsRef = useRef(requestOptions);
|
||
useEffect(() => {
|
||
optionsRef.current = requestOptions;
|
||
});
|
||
|
||
/** 目前在途的 abort controller(用來取消 stale 請求) */
|
||
const activeCtrlRef = useRef<AbortController | null>(null);
|
||
|
||
const doFetch = useCallback(async (): Promise<T | undefined> => {
|
||
// 取消前一次
|
||
activeCtrlRef.current?.abort();
|
||
const ctrl = new AbortController();
|
||
activeCtrlRef.current = ctrl;
|
||
|
||
setFetchState("loading");
|
||
setError(null);
|
||
|
||
try {
|
||
const result = await api.get<T>(path, {
|
||
...optionsRef.current,
|
||
signal: ctrl.signal,
|
||
});
|
||
// 若這個 request 已被取消,不要更新 state(另一個 request 正在跑)
|
||
if (ctrl.signal.aborted) return undefined;
|
||
setData(result);
|
||
setFetchState("success");
|
||
return result;
|
||
} catch (err) {
|
||
if (ctrl.signal.aborted) return undefined;
|
||
const e = err instanceof Error ? err : new Error(String(err));
|
||
setError(e);
|
||
setFetchState("error");
|
||
throw e;
|
||
}
|
||
}, [path]);
|
||
|
||
useEffect(() => {
|
||
if (!enabled) {
|
||
// enabled=false 時不發 API;fetchState 仍保持原值(若原先已是 success 的資料仍然顯示)
|
||
return;
|
||
}
|
||
// 推到下一個 microtask 才開始 fetch:
|
||
// - 避免 React 19 lint `react-hooks/set-state-in-effect`(effect body 同步 setState)
|
||
// - 不影響行為,只是把 setFetchState("loading") 排到 microtask queue
|
||
// - cleanup 時若 effect 在尚未啟動前被解除,由 ctrl.abort() 統一處理
|
||
queueMicrotask(() => {
|
||
void doFetch().catch(() => {
|
||
// error state 已在 doFetch 設置,這裡 catch 只是為了避免 unhandled rejection
|
||
});
|
||
});
|
||
|
||
return () => {
|
||
activeCtrlRef.current?.abort();
|
||
};
|
||
// deps: path + caller 提供的 extra deps + enabled
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [path, enabled, ...deps]);
|
||
|
||
// 對外仍提供 boolean `isLoading`,由 fetchState 派生
|
||
const isLoading = enabled && fetchState === "loading";
|
||
|
||
return { data, error, isLoading, refetch: doFetch };
|
||
}
|