phase 6 เบื้องต้น
This commit is contained in:
parent
1bb850c6b8
commit
93570d38d8
20 changed files with 1715 additions and 34 deletions
108
web/app/composables/useApi.ts
Normal file
108
web/app/composables/useApi.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
/**
|
||||
* 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<ApiError> {
|
||||
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<Response> {
|
||||
const doFetch = async (forceRefresh: boolean): Promise<Response> => {
|
||||
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<string, string> | 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<T>(path: string, init: RequestInit = {}): Promise<T | undefined> {
|
||||
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<T>(path: string, form: FormData, method: 'POST' | 'PUT' = 'POST'): Promise<T> {
|
||||
return (await apiJson<T>(path, { method, body: form })) as T
|
||||
}
|
||||
|
||||
/**
|
||||
* ดาวน์โหลดไฟล์ผ่าน Bearer แล้วเซฟด้วย anchor download
|
||||
* (เว็บรันใน browser จริง — ไม่ใช่ webview ของ Word จึงใช้ blob download ได้ ต่างจาก add-in)
|
||||
*/
|
||||
async function downloadFile(path: string, fallbackName: string): Promise<void> {
|
||||
const res = await request(path)
|
||||
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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue