แก้ตามผลใช้งานจริง 2 ประเด็น: 1. เปิดใน Word ทันที — generate-draft เปลี่ยนจากคืนไฟล์ตรงๆ เป็นเก็บ ชั่วคราวใต้ drafts/ (lifecycle ลบรายวัน, config Minio__DraftRetentionDays) + คืน presigned URL ให้เว็บเปิดผ่าน ms-word:ofe พร้อมลิงก์สำรอง (useVersionOpen.openDocxUrl แยกแกนเปิด docx URL ใช้ร่วมกับ flow v0) 2. ตัดคำไทย — T14 แตก placeholder เป็น [ / ข้อความ / ] โดย run ข้อความ ถือ w:hint="cs" + w:cs เท่านั้น; เดิมเขียนค่าลง run ปีกกาทำให้ Word จัดหน้าไทยผิด — แก้เป็นเขียนที่ run ข้อความยาวสุด + SDT เปล่าสืบทอด rPr จาก sdtPr (ใช้ร่วมทั้ง generate และ generate-draft) - ถอดของที่เลิกใช้: CORS expose Content-Disposition, downloadFile init, useApi.spec · Spec v9 + ADR 0002 Amendment + acceptance-review AC-19 - Tests: backend 84 / web 56 + build; QA reviewer PASS (2 rounds, 10 findings แก้ครบ — รวม bug จริงที่ test ใหม่จับได้: lifecycle Rules null ทำ startup ตั้ง rule ล้มแบบเงียบ) · ค้าง manual บน Word จริง Co-Authored-By: Claude Code <noreply@anthropic.com>
73 lines
2.9 KiB
TypeScript
73 lines
2.9 KiB
TypeScript
/**
|
|
* 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
|
|
}
|
|
|
|
return { request, apiJson, apiUpload }
|
|
}
|