Files
JianMiau ef70576f4b Word 預覽:修正手機只能看第一頁、載入中遮罩無法關閉
- 預覽面板新增「開新分頁」按鈕,手機可完整瀏覽所有頁面
- 修正 [hidden] 被 CSS display:flex 覆蓋導致載入中遮罩卡住的問題
- 改用 generation counter 取代 AbortController signal 判斷載入狀態

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 23:11:41 +08:00

245 lines
7.6 KiB
JavaScript

const form = document.querySelector('#contractForm');
const templateSelect = document.querySelector('#template');
const submitButton = document.querySelector('#submitButton');
const downloadButton = document.querySelector('#downloadButton');
const shareButton = document.querySelector('#shareButton');
const message = document.querySelector('#message');
const resultTitle = document.querySelector('#resultTitle');
const connectionStatus = document.querySelector('#connectionStatus');
const previewFrame = document.querySelector('#previewFrame');
const previewLoading = document.querySelector('#previewLoading');
const previewOpenBtn = document.querySelector('#previewOpenBtn');
let currentPdfBlob = null;
let currentPdfFileName = '租屋契約.pdf';
loadTemplates();
setDefaultLeaseDates();
document.getElementById('leaseStart').addEventListener('change', (e) => {
const [y, m, d] = e.target.value.split('-').map(Number);
if (!y || !m || !d) return;
document.getElementById('leaseEnd').value = toDateInputValue(computeLeaseEnd(new Date(y, m - 1, d)));
schedulePreview();
});
templateSelect.addEventListener('change', () => loadPreview());
let previewTimer = null;
function schedulePreview() {
clearTimeout(previewTimer);
previewTimer = setTimeout(loadPreview, 600);
}
form.querySelectorAll('input').forEach((el) => el.addEventListener('input', schedulePreview));
form.addEventListener('submit', async (event) => {
event.preventDefault();
setBusy(true);
resetPdf();
try {
const formData = new FormData(form);
const response = await fetch('/api/contracts/pdf', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
template: formData.get('template'),
monthlyRent: formData.get('monthlyRent'),
paymentDay: formData.get('paymentDay'),
deposit: formData.get('deposit'),
leaseStart: formData.get('leaseStart'),
leaseEnd: formData.get('leaseEnd'),
}),
});
if (!response.ok) {
const errorBody = await response.json().catch(() => ({}));
throw new Error(errorBody.error || 'PDF 產生失敗。');
}
currentPdfBlob = await response.blob();
currentPdfFileName = getFileNameFromDisposition(response.headers.get('Content-Disposition'))
|| buildDefaultFileName(formData.get('template'));
resultTitle.textContent = 'PDF 已產生';
message.textContent = currentPdfFileName;
downloadButton.disabled = false;
shareButton.disabled = !canShareCurrentPdf();
} catch (error) {
resultTitle.textContent = '產生失敗';
message.textContent = error.message;
} finally {
setBusy(false);
}
});
downloadButton.addEventListener('click', () => {
if (!currentPdfBlob) return;
downloadPdf();
});
shareButton.addEventListener('click', async () => {
if (!currentPdfBlob) return;
const file = new File([currentPdfBlob], currentPdfFileName, { type: 'application/pdf' });
if (!navigator.canShare || !navigator.canShare({ files: [file] })) {
downloadPdf();
return;
}
try {
await navigator.share({ title: '租屋契約 PDF', files: [file] });
} catch (error) {
if (error.name !== 'AbortError') {
message.textContent = '分享失敗,已保留下載按鈕。';
}
}
});
async function loadTemplates() {
try {
const response = await fetch('/api/templates');
if (!response.ok) throw new Error('無法讀取範本。');
const body = await response.json();
templateSelect.replaceChildren(
...body.templates.map((name) => new Option(name, name)),
);
if (body.templates.length === 0) {
templateSelect.append(new Option('templates 資料夾沒有範本', ''));
templateSelect.disabled = true;
submitButton.disabled = true;
connectionStatus.textContent = '缺少範本';
message.textContent = '請先將 .docx 範本放進 templates 資料夾。';
return;
}
connectionStatus.textContent = '可使用';
loadPreview();
} catch (error) {
templateSelect.append(new Option('讀取失敗', ''));
templateSelect.disabled = true;
submitButton.disabled = true;
connectionStatus.textContent = '離線';
message.textContent = error.message;
}
}
let previewGen = 0;
let previewAbortController = null;
let previewBlobUrl = null;
async function loadPreview() {
const name = templateSelect.value;
if (!name) {
previewFrame.src = 'about:blank';
previewLoading.hidden = true;
return;
}
if (previewAbortController) previewAbortController.abort();
previewAbortController = new AbortController();
const { signal } = previewAbortController;
const gen = ++previewGen;
const formData = new FormData(form);
const params = new URLSearchParams({
monthlyRent: formData.get('monthlyRent') || '',
paymentDay: formData.get('paymentDay') || '',
deposit: formData.get('deposit') || '',
leaseStart: formData.get('leaseStart') || '',
leaseEnd: formData.get('leaseEnd') || '',
});
previewLoading.hidden = false;
try {
const res = await fetch(
`/api/templates/${encodeURIComponent(name)}/preview?${params}`,
{ signal },
);
if (!res.ok) throw new Error('preview failed');
const blob = await res.blob();
if (gen !== previewGen) return;
if (previewBlobUrl) URL.revokeObjectURL(previewBlobUrl);
previewBlobUrl = URL.createObjectURL(blob);
previewFrame.src = `${previewBlobUrl}#toolbar=0`;
previewOpenBtn.href = previewBlobUrl;
previewOpenBtn.hidden = false;
} catch {
// AbortError 或其他錯誤都忽略,交給 finally 處理
} finally {
if (gen === previewGen) previewLoading.hidden = true;
}
}
function setBusy(isBusy) {
submitButton.disabled = isBusy || templateSelect.disabled;
submitButton.textContent = isBusy ? '產生中' : '產生 PDF';
}
function resetPdf() {
currentPdfBlob = null;
downloadButton.disabled = true;
shareButton.disabled = true;
resultTitle.textContent = '產生中';
message.textContent = '正在建立 PDF。';
}
function canShareCurrentPdf() {
if (!currentPdfBlob || !navigator.canShare) return false;
const file = new File([currentPdfBlob], currentPdfFileName, { type: 'application/pdf' });
return navigator.canShare({ files: [file] });
}
function downloadPdf() {
const url = URL.createObjectURL(currentPdfBlob);
const link = document.createElement('a');
link.href = url;
link.download = currentPdfFileName;
document.body.append(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
}
function getFileNameFromDisposition(disposition) {
if (!disposition) return '';
const utf8Match = disposition.match(/filename\*=UTF-8''([^;]+)/i);
if (utf8Match) return decodeURIComponent(utf8Match[1]);
const asciiMatch = disposition.match(/filename="([^"]+)"/i);
return asciiMatch ? asciiMatch[1] : '';
}
function buildDefaultFileName(templateName) {
const baseName = String(templateName || '租屋契約').replace(/\.[^.]+$/, '');
return `${baseName}.pdf`;
}
function setDefaultLeaseDates() {
const start = new Date();
start.setDate(1);
start.setMonth(start.getMonth() + 1);
document.getElementById('leaseStart').value = toDateInputValue(start);
document.getElementById('leaseEnd').value = toDateInputValue(computeLeaseEnd(start));
}
function computeLeaseEnd(startDate) {
const end = new Date(startDate);
end.setFullYear(end.getFullYear() + 1);
end.setDate(end.getDate() - 1);
return end;
}
function toDateInputValue(date) {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
}