ไฟล์ร่าง (draft) — generate docx จาก template แบบ stateless ไม่สร้าง
Document/version; stamp เฉพาะ custom property TemplateId เพื่อให้ add-in
เห็นเป็น untracked; Start Versioning ส่ง form field templateId ให้ backend
ผูก template ตอน auto-create — เอกสารเริ่มที่ v1 (ไม่มี v0) + field sync เต็มระบบ
- Backend: POST /api/templates/{id}/generate-draft (200 คืนไฟล์) +
DocxGenerationService.GenerateDraft + แยก TemplateFieldValidation ใช้ร่วมกับ
generate + UploadVersion รับ templateId (non-GUID/unknown → 400,
deactivated → ผูกได้, document เดิม → เฉย) + CORS expose Content-Disposition
- Add-in: readTrackedProperties อ่าน TemplateId + ส่ง templateId เฉพาะ
startVersioning + hint ไฟล์ร่างใน task pane
- Web: ปุ่ม "สร้างไฟล์ร่าง" บนฟอร์มเดิม + downloadFile รับ init (POST → blob)
- Docs: spec v8 (FR-16, AC-19) + Phase 12 + CONTEXT.md + ADR 0002 +
acceptance-review AC-19
- Tests: backend 82 / add-in 66 / web 59 + build ครบ; QA reviewer PASS
(2 rounds); ค้าง manual บน Word จริง
Co-Authored-By: Claude Code <noreply@anthropic.com>
109 lines
4.5 KiB
TypeScript
109 lines
4.5 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
|
|
}
|
|
|
|
/**
|
|
* ดาวน์โหลดไฟล์ผ่าน 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<void> {
|
|
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
|
|
}
|