/** * fetch wrapper ของ web portal — แนบ Bearer ทุก request, * โดน 401 → force refresh token แล้ว retry ครั้งเดียว (ยัง 401/refresh พัง → throw ให้ UI กลับไปหน้า login) */ import { AuthError } from './useAuth' export class ApiError extends Error { constructor( readonly status: number, message: string, ) { super(message) } } /** ดึงข้อความ error จาก response ของ backend (string ตรงๆ หรือ ProblemDetails) */ async function toApiError(res: Response): Promise { const fallback = `เรียก API ไม่สำเร็จ (HTTP ${res.status})` try { const text = (await res.text()).trim() if (!text) return new ApiError(res.status, fallback) try { const json = JSON.parse(text) as { title?: string; detail?: string } const msg = json.detail ?? json.title return new ApiError(res.status, msg ?? text) } catch { return new ApiError(res.status, text) // backend คืน message เป็น string เปล่าๆ (เช่น BadRequest) } } catch { return new ApiError(res.status, fallback) } } export function useApi() { const config = useRuntimeConfig() const base = (config.public.apiBaseUrl as string).replace(/\/+$/, '') const { getAccessToken } = useAuth() async function request(path: string, init: RequestInit = {}): Promise { const doFetch = async (forceRefresh: boolean): Promise => { let token: string try { token = await getAccessToken(forceRefresh) } catch (err) { // refresh token ตาย — AuthError พร้อมข้อความชี้นำให้ login ใหม่ (สถานะ auth ถูกเคลียร์ไปแล้ว) throw err instanceof AuthError ? new ApiError(401, err.message) : err } return fetch(base + path, { ...init, headers: { ...(init.headers as Record | undefined), Authorization: `Bearer ${token}` }, }) } let res = await doFetch(false) if (res.status === 401) res = await doFetch(true) // retry ครั้งเดียว return res } /** เรียก API ที่คืน JSON (หรือ 204 → undefined) — !ok จะ throw ApiError */ async function apiJson(path: string, init: RequestInit = {}): Promise { const res = await request(path, init) if (!res.ok) throw await toApiError(res) if (res.status === 204) return undefined return (await res.json()) as T } /** upload multipart — ห้าม set Content-Type เอง (browser ต้องใส่ boundary ให้) */ async function apiUpload(path: string, form: FormData, method: 'POST' | 'PUT' = 'POST'): Promise { return (await apiJson(path, { method, body: form })) as T } /** * ดาวน์โหลดไฟล์ผ่าน Bearer แล้วเซฟด้วย anchor download * (เว็บรันใน browser จริง — ไม่ใช่ webview ของ Word จึงใช้ blob download ได้ ต่างจาก add-in) * init ใช้ override method/body ได้ — ไฟล์ร่าง (FR-16) เป็น POST + JSON body ที่คืนไฟล์ */ async function downloadFile(path: string, fallbackName: string, init: RequestInit = {}): Promise { const res = await request(path, init) if (!res.ok) throw await toApiError(res) const blob = await res.blob() const url = URL.createObjectURL(blob) const a = document.createElement('a') a.href = url a.download = filenameFrom(res.headers.get('Content-Disposition'), fallbackName) document.body.append(a) a.click() a.remove() setTimeout(() => URL.revokeObjectURL(url), 10_000) } return { request, apiJson, apiUpload, downloadFile } } /** เอาชื่อไฟล์จาก Content-Disposition (รองรับ filename*=UTF-8 ที่ .NET ใส่มาสำหรับชื่อไทย) */ function filenameFrom(header: string | null, fallback: string): string { if (!header) return fallback const utf8 = header.match(/filename\*=(?:UTF-8'')?([^;]+)/i) if (utf8) { try { return decodeURIComponent(utf8[1].replace(/^"|"$/g, '')) } catch { /* ผ่านต่อ */ } } const plain = header.match(/filename=(?:"([^"]+)"|([^;]+))/i) if (plain) return (plain[1] ?? plain[2]).trim() return fallback }