Phase 12 v9: draft opens in Word + Thai word-breaking fix (FR-16)

แก้ตามผลใช้งานจริง 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>
This commit is contained in:
Suphonchai Phoonsawat 2026-09-10 11:49:16 +07:00
parent b3c1c667f7
commit 70f71f4c5c
23 changed files with 408 additions and 241 deletions

View file

@ -69,41 +69,5 @@ export function useApi() {
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
return { request, apiJson, apiUpload }
}

View file

@ -23,6 +23,28 @@ export function useVersionOpen() {
openNotice.value = null
}
/** เปิด presigned docx URL ใน Word ผ่าน ms-word:ofe พร้อม heuristic เตือนเมื่อไม่เห็นการสลับแอป —
* ใช้กับลิงก์ที่ถืออยู่เองได้เลย (ไฟล์ร่าง FR-16: response ของ generate-draft คือ URL แล้ว) */
function openDocxUrl(url: string) {
resetOpen()
fallbackUrl.value = url
let switched = false
const onBlur = () => { switched = true }
const onVisibility = () => { if (document.hidden) switched = true }
window.addEventListener('blur', onBlur)
document.addEventListener('visibilitychange', onVisibility)
const timer = window.setTimeout(() => {
if (!switched) openNotice.value = 'หาก Word ไม่เปิด กรุณาดาวน์โหลดไฟล์แล้วเปิดใน Word'
cleanup()
}, 1500)
cleanup = () => {
window.clearTimeout(timer)
window.removeEventListener('blur', onBlur)
window.removeEventListener('visibilitychange', onVisibility)
}
window.location.assign(wordOpenUrl(url))
}
async function openVersion(documentId: string, versionId: string, format: VersionFormat = 'docx') {
if (openingVersionId.value) return
resetOpen()
@ -45,21 +67,7 @@ export function useVersionOpen() {
}
else openNotice.value = 'เบราว์เซอร์บล็อกแท็บใหม่ กรุณากดลิงก์เปิดไฟล์ด้านล่าง'
} else {
let switched = false
const onBlur = () => { switched = true }
const onVisibility = () => { if (document.hidden) switched = true }
window.addEventListener('blur', onBlur)
document.addEventListener('visibilitychange', onVisibility)
const timer = window.setTimeout(() => {
if (!switched) openNotice.value = 'หาก Word ไม่เปิด กรุณาดาวน์โหลดไฟล์แล้วเปิดใน Word'
cleanup()
}, 1500)
cleanup = () => {
window.clearTimeout(timer)
window.removeEventListener('blur', onBlur)
document.removeEventListener('visibilitychange', onVisibility)
}
window.location.assign(wordOpenUrl(link.url))
openDocxUrl(link.url)
}
} catch (err) {
tab?.close()
@ -71,5 +79,5 @@ export function useVersionOpen() {
}
onBeforeUnmount(resetOpen)
return { openingVersionId, downloadError, fallbackUrl, openNotice, openVersion, resetOpen }
return { openingVersionId, downloadError, fallbackUrl, openNotice, openVersion, openDocxUrl, resetOpen }
}