jim800121chen 99dea42239 feat(visionA-frontend): Phase 0 → 0.7 雲端前端(Next.js + OIDC redirect 流程)
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>
2026-05-01 11:21:36 +08:00

138 lines
4.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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;
/** 初始 dataSSR 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 作為主 keypath 變動自動重 fetch
* - 支援取消unmount 或 path 變動時,前一次請求會被 abortstate 不會被覆寫
* - 不做 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。
* 改為每個狀態只會由「特定動作」觸發 setStatedoFetch 動作、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 時不發 APIfetchState 仍保持原值(若原先已是 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 };
}