Phase 11: version PDF (FR-15)

- เลข version ใหม่: เว็บ-generate = v0 ฉบับตั้งต้น (ไม่มี PDF), add-in save (Start Versioning + Save & Version) = v1+ มี PDF เสมอ
- PDF ผลิตจาก Word จริงผ่าน task pane เท่านั้น (getFileAsync Pdf + runtime guard, manifest ไม่เพิ่ม requirement); capture/upload เป็น best-effort — docx ผ่านแม้ PDF ล้ม + MessageBar เตือนตาม hasPdf
- Backend: DocumentVersion.PdfObjectKey (nullable, migration AddVersionPdf) + sibling .pdf ใน bucket docx-versions · multipart part pdf (ผิด type → 400) · /download + /download-link รับ ?format=pdf (ไม่มี → 404) · presigned ฝังชื่อไฟล์ไทย + _vN (RFC 5987)
- Web: เปิดไฟล์แทน blob-download — PDF แท็บใหม่, docx ผ่าน ms-word:ofe|u| พร้อม heuristic fallback ลิงก์สำรอง (useVersionOpen, versionDownload) · ปุ่ม "เปิดใน Word" ที่หน้า success ของฟอร์มสร้างเอกสาร
- Spec v7 + CONTEXT.md + ADR 0001 + แผน docs/plans/version-pdf.md
- Automated QA: backend 68 / add-in 63 / web 55 ผ่าน · manual acceptance ผ่านบน Word for Mac (2026-09-08) รวม fidelity + track changes — ค้างเฉพาะ Windows / Word on the web

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
Suphonchai Phoonsawat 2026-09-08 14:01:13 +07:00
parent 618328ffa7
commit 39d610a781
39 changed files with 1387 additions and 212 deletions

View file

@ -0,0 +1,75 @@
import { onBeforeUnmount, ref } from 'vue'
import { versionDownloadPath, wordOpenUrl, type VersionFormat } from '../utils/versionDownload'
/** Shared by history and generate success. Links stay available even if the focus heuristic fires. */
export function useVersionOpen() {
const { apiJson } = useApi()
const openingVersionId = ref<string | null>(null)
const downloadError = ref<string | null>(null)
const fallbackUrl = ref<string | null>(null)
const openNotice = ref<string | null>(null)
let cleanup = () => {}
let generation = 0
let pendingTab: Window | null = null
function resetOpen() {
generation++
pendingTab?.close()
pendingTab = null
cleanup()
openingVersionId.value = null
downloadError.value = null
fallbackUrl.value = null
openNotice.value = null
}
async function openVersion(documentId: string, versionId: string, format: VersionFormat = 'docx') {
if (openingVersionId.value) return
resetOpen()
const current = generation
openingVersionId.value = versionId
// Reserve a tab during the user gesture, before the authenticated request loses activation.
let tab: Window | null = null
try {
tab = format === 'pdf' ? window.open('about:blank', '_blank') : null
if (tab) tab.opener = null
pendingTab = tab
const link = await apiJson<{ url: string; expiresAt: string }>(versionDownloadPath(documentId, versionId, format))
if (current !== generation) { tab?.close(); return }
if (!link) throw new Error('ขอลิงก์เปิดไฟล์ไม่สำเร็จ')
fallbackUrl.value = link.url
if (format === 'pdf') {
if (tab && !tab.closed) {
tab.location.replace(link.url)
pendingTab = null
}
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))
}
} catch (err) {
tab?.close()
if (pendingTab === tab) pendingTab = null
if (current === generation) downloadError.value = err instanceof Error ? err.message : 'เปิดไฟล์ไม่สำเร็จ'
} finally {
if (current === generation) openingVersionId.value = null
}
}
onBeforeUnmount(resetOpen)
return { openingVersionId, downloadError, fallbackUrl, openNotice, openVersion, resetOpen }
}

View file

@ -1,10 +1,11 @@
<script setup lang="ts">
/** FR-12 UI — detail ของเอกสาร: ค่าล่าสุดของทุก field + version history (ขยายแถวเห็นค่าตอนนั้น)
* + ปุ่ม re-generate: พาไปฟอร์มสร้างเอกสารแบบ prefill ค่าปัจจุบัน → ได้เอกสารใหม่ v1 (ไม่แตะเอกสารเดิม) */
* + ปุ่ม re-generate: พาไปฟอร์มสร้างเอกสารแบบ prefill ค่าปัจจุบัน → ได้เอกสารใหม่ v0 (ไม่แตะเอกสารเดิม) */
import type { DocumentDetail, VersionListItem } from '~/types/api'
const route = useRoute()
const { apiJson, downloadFile } = useApi()
const { apiJson } = useApi()
const { openingVersionId, downloadError, fallbackUrl, openNotice, openVersion } = useVersionOpen()
const documentId = computed(() => String(route.params.documentId))
@ -14,8 +15,6 @@ const loading = ref(true)
const loadError = ref<string | null>(null)
const expandedVersionId = ref<string | null>(null)
const downloadingVersionId = ref<string | null>(null)
const downloadError = ref<string | null>(null)
const fieldOrderKeys = computed(() => detail.value?.fields.filter((f) => f.label !== null).map((f) => f.key) ?? [])
@ -52,19 +51,6 @@ function regenerate() {
navigateTo(`/documents/create/${detail.value.templateId}?from=${detail.value.id}`)
}
async function download(v: VersionListItem) {
downloadingVersionId.value = v.versionId
downloadError.value = null
try {
const base = (detail.value?.fileName ?? 'document').replace(/\.docx$/i, '')
await downloadFile(`/api/documents/${documentId.value}/versions/${v.versionId}/download`, `${base}_v${v.versionNumber}.docx`)
} catch (err) {
downloadError.value = err instanceof Error ? err.message : 'ดาวน์โหลดไม่สำเร็จ'
} finally {
downloadingVersionId.value = null
}
}
onMounted(load)
</script>
@ -190,12 +176,14 @@ onMounted(load)
<div class="flex shrink-0 flex-col items-end gap-2">
<button
type="button"
:disabled="downloadingVersionId === v.versionId"
:disabled="openingVersionId !== null"
class="btn btn-secondary btn-xs"
@click.stop="download(v)"
@click.stop="openVersion(documentId, v.versionId)"
>
{{ downloadingVersionId === v.versionId ? '…' : '⬇ ดาวน์โหลด' }}
{{ openingVersionId !== null ? '…' : 'เปิดใน Word' }}
</button>
<button v-if="v.hasPdf" type="button" class="btn btn-secondary btn-xs"
:disabled="openingVersionId !== null" @click.stop="openVersion(documentId, v.versionId, 'pdf')">PDF</button>
<button
type="button"
class="text-xs text-muted"
@ -257,12 +245,14 @@ onMounted(load)
<td class="px-4 py-3 text-right whitespace-nowrap">
<button
type="button"
:disabled="downloadingVersionId === v.versionId"
:disabled="openingVersionId !== null"
class="btn btn-secondary btn-xs"
@click.stop="download(v)"
@click.stop="openVersion(documentId, v.versionId)"
>
{{ downloadingVersionId === v.versionId ? '…' : '⬇ ดาวน์โหลด' }}
{{ openingVersionId !== null ? '…' : 'เปิดใน Word' }}
</button>
<button v-if="v.hasPdf" type="button" class="btn btn-secondary btn-xs ml-2"
:disabled="openingVersionId !== null" @click.stop="openVersion(documentId, v.versionId, 'pdf')">PDF</button>
<span class="ml-2 text-xs text-muted">
{{ expandedVersionId === v.versionId ? '▲ ซ่อน fields' : '▼ ดู fields' }}
</span>
@ -292,6 +282,11 @@ onMounted(load)
</table>
</div>
</template>
<AlertBox v-if="fallbackUrl">
<p v-if="openNotice">{{ openNotice }}</p>
<a :href="fallbackUrl" target="_blank" rel="noopener noreferrer" class="link">เปิด / ดาวน์โหลดไฟล์สำรอง</a>
<p class="text-xs text-muted">ลิงก์มีอายุสั้น หากหมดอายุให้กดเปิดไฟล์อีกครั้ง</p>
</AlertBox>
<AlertBox v-if="downloadError" type="error">{{ downloadError }}</AlertBox>
</div>
</template>

View file

@ -1,10 +1,11 @@
<script setup lang="ts">
/** FR-10 UI — dynamic form สร้างเอกสารจาก field definitions ของ template → generate เป็น v1 + ดาวน์โหลด
/** FR-10 UI — dynamic form สร้างเอกสารจาก field definitions ของ template → generate เป็น v0 + ดาวน์โหลด
* FR-12 — รองรับ re-generate: ?from={documentId} → prefill ค่าล่าสุดของเอกสารนั้นให้แก้ก่อนสร้าง */
import type { DocumentDetail, GenerateDocumentResponse, TemplateDetail } from '~/types/api'
const route = useRoute()
const { apiJson, downloadFile } = useApi()
const { apiJson } = useApi()
const { openingVersionId, downloadError, fallbackUrl, openNotice, openVersion, resetOpen } = useVersionOpen()
const templateId = computed(() => String(route.params.templateId))
const detail = ref<TemplateDetail | null>(null)
@ -23,8 +24,6 @@ const loadError = ref<string | null>(null)
const submitting = ref(false)
const submitError = ref<string | null>(null)
const result = ref<GenerateDocumentResponse | null>(null)
const downloading = ref(false)
const downloadError = ref<string | null>(null)
const sortedFields = computed(() => [...(detail.value?.fields ?? [])].sort((a, b) => a.sortOrder - b.sortOrder))
@ -98,22 +97,9 @@ async function submit() {
}
}
async function download() {
if (!result.value) return
downloading.value = true
downloadError.value = null
try {
const name = detail.value ? `${detail.value.name}_v${result.value.versionNumber}.docx` : 'document_v1.docx'
await downloadFile(result.value.downloadUrl, name)
} catch (err) {
downloadError.value = err instanceof Error ? err.message : 'ดาวน์โหลดไม่สำเร็จ'
} finally {
downloading.value = false
}
}
/** กลับไปกรอกฟอร์มใหม่เพื่อสร้างอีกฉบับจาก template เดิม (ถ้าเป็น re-generate → คืนค่า prefill ไม่ใช่ฟอร์มว่าง) */
function resetForm() {
resetOpen()
result.value = null
submitError.value = null
downloadError.value = null
@ -139,7 +125,7 @@ onMounted(load)
</AlertBox>
<template v-else-if="detail">
<!-- ผลลัพธ์: สร้างสำเร็จเป็น v1 แล้ว -->
<!-- ผลลัพธ์: สร้างสำเร็จเป็น v0 แล้ว -->
<template v-if="result">
<AlertBox type="success">
สร้างเอกสารสำเร็จ — บันทึกเป็น <strong>version {{ result.versionNumber }}</strong> แล้ว
@ -165,13 +151,18 @@ onMounted(load)
</button>
<button
type="button"
:disabled="downloading"
:disabled="openingVersionId !== null"
class="btn btn-primary w-full sm:w-auto"
@click="download"
@click="openVersion(result.documentId, result.versionId)"
>
{{ downloading ? 'กำลังเตรียมไฟล์…' : '⬇ ดาวน์โหลด .docx' }}
{{ openingVersionId !== null ? 'กำลังเตรียมไฟล์…' : 'เปิดใน Word' }}
</button>
</div>
<AlertBox v-if="fallbackUrl" class="mt-4">
<p v-if="openNotice">{{ openNotice }}</p>
<a :href="fallbackUrl" target="_blank" rel="noopener noreferrer" class="link">ดาวน์โหลด .docx สำรอง</a>
<p class="text-xs text-muted">ลิงก์มีอายุสั้น หากหมดอายุให้กดเปิดใน Word อีกครั้ง</p>
</AlertBox>
<AlertBox v-if="downloadError" type="error" class="mt-4">{{ downloadError }}</AlertBox>
</div>
@ -181,7 +172,7 @@ onMounted(load)
<li>เปิดไฟล์ที่ดาวน์โหลดใน Word (Desktop หรือ Online)</li>
<li>
เปิด task pane ของ add-in <strong>e-Court Documents Versioning</strong> — ระบบจะตรวจพบว่าเป็น
tracked document และแสดง <strong>version 1</strong> ทันที โดยไม่ต้องกด Start Versioning
tracked document และแสดง <strong>version 0</strong> ทันที โดยไม่ต้องกด Start Versioning
</li>
<li>แก้ไขเอกสารต่อแล้วกด <strong>Save &amp; Version</strong> เพื่อบันทึก version ถัดไป</li>
</ol>
@ -278,7 +269,7 @@ onMounted(load)
:disabled="submitting || sortedFields.length === 0 || !detail.isActive"
class="btn btn-primary w-full sm:w-auto"
>
{{ submitting ? 'กำลังสร้างเอกสาร…' : 'สร้างเอกสาร (version 1)' }}
{{ submitting ? 'กำลังสร้างเอกสาร…' : 'สร้างเอกสาร (version 0)' }}
</button>
</div>
</form>

View file

@ -97,6 +97,7 @@ export interface DocumentDetail {
/** item ของ GET /api/documents/{documentId}/versions — fields คือ snapshot ที่ parse แล้ว
* (2 รูปแบบ — ผ่าน normalizeSnapshot ก่อนใช้) */
export interface VersionListItem {
hasPdf: boolean;
versionId: string
versionNumber: number
createdAt: string

View file

@ -0,0 +1,14 @@
export type VersionFormat = 'docx' | 'pdf'
export function versionFileName(fileName: string, versionNumber: number, format: VersionFormat = 'docx'): string {
return `${fileName.replace(/\.docx$/i, '')}_v${versionNumber}.${format}`
}
export function versionDownloadPath(documentId: string, versionId: string, format: VersionFormat = 'docx'): string {
return `/api/documents/${documentId}/versions/${versionId}/download-link${format === 'pdf' ? '?format=pdf' : ''}`
}
/** Office URI Schemes: preserve the signed URL, including its existing percent escapes. */
export function wordOpenUrl(presignedUrl: string): string {
return `ms-word:ofe|u|${presignedUrl}`
}