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:
parent
b3c1c667f7
commit
70f71f4c5c
23 changed files with 408 additions and 241 deletions
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@
|
|||
import type { DocumentDetail, GenerateDocumentResponse, TemplateDetail } from '~/types/api'
|
||||
|
||||
const route = useRoute()
|
||||
const { apiJson, downloadFile } = useApi()
|
||||
const { openingVersionId, downloadError, fallbackUrl, openNotice, openVersion, resetOpen } = useVersionOpen()
|
||||
const { apiJson } = useApi()
|
||||
const { openingVersionId, downloadError, fallbackUrl, openNotice, openVersion, openDocxUrl, resetOpen } = useVersionOpen()
|
||||
|
||||
const templateId = computed(() => String(route.params.templateId))
|
||||
const detail = ref<TemplateDetail | null>(null)
|
||||
|
|
@ -82,7 +82,7 @@ async function load() {
|
|||
/** validate ฝั่ง client (required/type) ก่อนยิง backend — backend ตรวจซ้ำอีกชั้นเสมอ */
|
||||
async function submit() {
|
||||
submitError.value = null
|
||||
downloadError.value = null
|
||||
resetOpen() // เคลียร์ state ของการเปิดไฟล์ครั้งก่อน (รวม fallbackUrl/openNotice ของไฟล์ร่างที่อาจค้าง)
|
||||
draftSuccess.value = null
|
||||
draftError.value = null
|
||||
issues.value = Object.fromEntries(
|
||||
|
|
@ -105,7 +105,8 @@ async function submit() {
|
|||
}
|
||||
}
|
||||
|
||||
/** FR-16 — สร้างไฟล์ร่าง (draft): validate ชุดเดียวกันแล้ว POST ค่าไปขอไฟล์กลับมาดาวน์โหลดทันที
|
||||
/** FR-16 — สร้างไฟล์ร่าง (draft): validate ชุดเดียวกันแล้ว POST ค่าไปขอ presigned URL
|
||||
* แล้วเปิดใน Word ทันทีผ่าน ms-word:ofe พร้อมลิงก์ดาวน์โหลดสำรอง (เหมือน flow เปิด v0)
|
||||
* backend ไม่สร้าง Document/version — เอกสารจะเกิดตอนกด Start Versioning ใน Word (เป็น v1 พร้อมผูก template นี้) */
|
||||
async function submitDraft() {
|
||||
draftSuccess.value = null
|
||||
|
|
@ -118,13 +119,17 @@ async function submitDraft() {
|
|||
|
||||
draftSubmitting.value = true
|
||||
try {
|
||||
await downloadFile(`/api/templates/${templateId.value}/generate-draft`, `${detail.value?.name ?? 'เอกสาร'}.docx`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ values: values.value }),
|
||||
})
|
||||
const link = (await apiJson<{ url: string; expiresAt: string }>(
|
||||
`/api/templates/${templateId.value}/generate-draft`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ values: values.value }),
|
||||
},
|
||||
))!
|
||||
openDocxUrl(link.url)
|
||||
draftSuccess.value =
|
||||
'ดาวน์โหลดไฟล์ร่างแล้ว — เปิดไฟล์ใน Word แล้วกด Start Versioning This Document เพื่อเริ่ม track (เอกสารจะถูกผูกกับ template นี้และบันทึกเป็น version 1)'
|
||||
'กำลังเปิดไฟล์ร่างใน Word — เปิด task pane ของ add-in แล้วกด Start Versioning This Document เพื่อเริ่ม track (เอกสารจะถูกผูกกับ template นี้และบันทึกเป็น version 1)'
|
||||
} catch (err) {
|
||||
draftError.value = err instanceof Error ? err.message : 'สร้างไฟล์ร่างไม่สำเร็จ'
|
||||
} finally {
|
||||
|
|
@ -301,8 +306,13 @@ onMounted(load)
|
|||
|
||||
<AlertBox v-if="submitError" type="error">{{ submitError }}</AlertBox>
|
||||
|
||||
<!-- FR-16 — ไฟล์ร่าง: ดาวน์โหลดสำเร็จแล้วฟอร์มอยู่ต่อ ผู้ใช้เปิดไฟล์ใน Word แล้วค่อย Start Versioning -->
|
||||
<!-- FR-16 — ไฟล์ร่าง: เปิดใน Word แล้วฟอร์มอยู่ต่อ พร้อมลิงก์สำรอง (อายุสั้นตาม presigned URL) -->
|
||||
<AlertBox v-if="draftSuccess" type="success">{{ draftSuccess }}</AlertBox>
|
||||
<AlertBox v-if="fallbackUrl && (draftSuccess || openNotice)">
|
||||
<p v-if="openNotice">{{ openNotice }}</p>
|
||||
<a :href="fallbackUrl" target="_blank" rel="noopener noreferrer" class="link">ดาวน์โหลดไฟล์ร่างสำรอง (.docx)</a>
|
||||
<p class="text-xs text-muted">ลิงก์มีอายุสั้น หากหมดอายุให้กดสร้างไฟล์ร่างอีกครั้ง</p>
|
||||
</AlertBox>
|
||||
<AlertBox v-if="draftError" type="error">{{ draftError }}</AlertBox>
|
||||
|
||||
<div class="flex flex-col-reverse gap-2 pt-2 sm:flex-row sm:justify-end">
|
||||
|
|
@ -323,7 +333,7 @@ onMounted(load)
|
|||
</button>
|
||||
</div>
|
||||
<p class="text-xs text-muted">
|
||||
ไฟล์ร่าง = ได้ .docx ทันทีโดยยังไม่เกิดเอกสารในระบบ — เริ่ม versioning ตอนเปิดใน Word (version 1)
|
||||
ไฟล์ร่าง = เปิดใน Word ทันทีโดยยังไม่เกิดเอกสารในระบบ — เริ่ม versioning ตอนกด Start Versioning ใน Word (version 1)
|
||||
</p>
|
||||
</form>
|
||||
</template>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue