diff --git a/Dockerfile b/Dockerfile index 119459e..6320369 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -# sudo docker run -d -p 3005:3005 --name rental-contract-pdf rental-contract-pdf +# sudo docker compose up -d --build FROM node:20-bookworm-slim @@ -22,11 +22,11 @@ RUN if [ -d fonts ] && ls fonts/*.{ttf,otf,ttc} 2>/dev/null | grep -q .; then \ fi ENV NODE_ENV=production -ENV PORT=3005 +ENV PORT=3001 ENV TEMPLATE_DIR=/app/templates ENV TEMP_DIR=/tmp/rental-contracts ENV SOFFICE_BIN=soffice -EXPOSE 3005 +EXPOSE 3001 CMD ["npm", "start"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..05a3634 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,35 @@ +services: + rental-contract-pdf: + container_name: rental-contract-pdf + image: rental-contract-pdf + build: . + restart: always + expose: + - "3001" + environment: + ADMIN_PASSWORD: "123456" + volumes: + - ./templates:/app/templates + + rental-contract-pdf-web: + container_name: rental-contract-pdf-web + build: + context: . + dockerfile: docker/nginx/Dockerfile + image: rental-contract-pdf-web:latest + restart: always + depends_on: + - rental-contract-pdf + ports: + - "3001:3001" + environment: + NGINX_PORT: 3001 + NGINX_SERVER_NAME: ${NGINX_SERVER_NAME:-_} + SSL_CERT_DIR: /etc/nginx/certs + SSL_CERT_FILE_NAME: ${SSL_CERT_FILE_NAME:-RSA-cert.pem} + SSL_CHAIN_FILE_NAME: ${SSL_CHAIN_FILE_NAME:-RSA-chain.pem} + SSL_KEY_FILE_NAME: ${SSL_KEY_FILE_NAME:-RSA-privkey.pem} + UPSTREAM_HOST: rental-contract-pdf + UPSTREAM_PORT: 3001 + volumes: + - /volume1/homes/JianMiau/www/certificate:/etc/nginx/certs:ro diff --git a/docker/nginx/Dockerfile b/docker/nginx/Dockerfile new file mode 100644 index 0000000..e050910 --- /dev/null +++ b/docker/nginx/Dockerfile @@ -0,0 +1,9 @@ +FROM nginx:1.27-alpine + +RUN apk add --no-cache inotify-tools + +COPY docker/nginx/entrypoint.sh /entrypoint.sh + +RUN chmod +x /entrypoint.sh + +ENTRYPOINT ["/entrypoint.sh"] diff --git a/docker/nginx/entrypoint.sh b/docker/nginx/entrypoint.sh new file mode 100644 index 0000000..2cd6851 --- /dev/null +++ b/docker/nginx/entrypoint.sh @@ -0,0 +1,114 @@ +#!/bin/sh +set -eu + +NGINX_PORT="${NGINX_PORT:-3001}" +NGINX_SERVER_NAME="${NGINX_SERVER_NAME:-_}" +SSL_CERT_DIR="${SSL_CERT_DIR:-/etc/nginx/certs}" +SSL_CERT_FILE_NAME="${SSL_CERT_FILE_NAME:-RSA-cert.pem}" +SSL_CHAIN_FILE_NAME="${SSL_CHAIN_FILE_NAME:-RSA-chain.pem}" +SSL_KEY_FILE_NAME="${SSL_KEY_FILE_NAME:-RSA-privkey.pem}" +UPSTREAM_HOST="${UPSTREAM_HOST:-rental-contract-pdf}" +UPSTREAM_PORT="${UPSTREAM_PORT:-3001}" + +GENERATED_DIR="/etc/nginx/generated" +GENERATED_CERT_PATH="${GENERATED_DIR}/fullchain.pem" +GENERATED_KEY_PATH="${GENERATED_DIR}/privkey.pem" + +mkdir -p "${GENERATED_DIR}" + +build_cert_bundle() { + cert_path="${SSL_CERT_DIR}/${SSL_CERT_FILE_NAME}" + chain_path="${SSL_CERT_DIR}/${SSL_CHAIN_FILE_NAME}" + key_path="${SSL_CERT_DIR}/${SSL_KEY_FILE_NAME}" + + if [ ! -f "${cert_path}" ]; then + echo "Missing certificate file: ${cert_path}" >&2 + exit 1 + fi + + if [ ! -f "${chain_path}" ]; then + echo "Missing chain file: ${chain_path}" >&2 + exit 1 + fi + + if [ ! -f "${key_path}" ]; then + echo "Missing key file: ${key_path}" >&2 + exit 1 + fi + + normalize_pem_file "${cert_path}" > "${GENERATED_CERT_PATH}" + normalize_pem_file "${chain_path}" >> "${GENERATED_CERT_PATH}" + cp "${key_path}" "${GENERATED_KEY_PATH}" +} + +normalize_pem_file() { + pem_path="$1" + + awk ' + { + sub(/\r$/, "") + print + has_content = 1 + } + END { + if (has_content) { + print "" + } + } + ' "${pem_path}" +} + +write_nginx_config() { + cat > /etc/nginx/conf.d/default.conf </dev/null || true +} + +trap cleanup INT TERM + +nginx -g 'daemon off;' & +NGINX_PID=$! + +wait "${NGINX_PID}" diff --git a/package-lock.json b/package-lock.json index 64fb85f..29e76f4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,8 @@ "dependencies": { "adm-zip": "^0.5.17", "express": "^4.21.2", - "jszip": "^3.10.1" + "jszip": "^3.10.1", + "multer": "^2.0.0" }, "engines": { "node": ">=20" @@ -38,6 +39,12 @@ "node": ">=12.0" } }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", @@ -68,6 +75,23 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -106,6 +130,35 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/concat-stream/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -569,6 +622,25 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/multer": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz", + "integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "type-is": "^1.6.18" + }, + "engines": { + "node": ">= 10.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/negotiator": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", @@ -866,6 +938,14 @@ "node": ">= 0.8" } }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/string_decoder": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", @@ -903,6 +983,12 @@ "node": ">= 0.6" } }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", diff --git a/package.json b/package.json index 9845e9b..d57d2ec 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,8 @@ "dependencies": { "adm-zip": "^0.5.17", "express": "^4.21.2", - "jszip": "^3.10.1" + "jszip": "^3.10.1", + "multer": "^2.0.0" }, "engines": { "node": ">=20" diff --git a/public/admin.html b/public/admin.html new file mode 100644 index 0000000..58970dc --- /dev/null +++ b/public/admin.html @@ -0,0 +1,62 @@ + + + + + + 管理員 — 租屋契約範本 + + + +
+
+
+

Admin

+

範本管理

+
+ ← 返回 +
+ +
+
+
+

管理員登入

+

輸入密碼

+
+ + +

+
+
+ + +
+ + + + diff --git a/public/admin.js b/public/admin.js new file mode 100644 index 0000000..e8a3387 --- /dev/null +++ b/public/admin.js @@ -0,0 +1,125 @@ +let adminPassword = sessionStorage.getItem('adminPassword'); + +const loginSection = document.getElementById('loginSection'); +const adminSection = document.getElementById('adminSection'); +const loginError = document.getElementById('loginError'); +const uploadMessage = document.getElementById('uploadMessage'); + +if (adminPassword) { + checkAuth(); +} else { + showLogin(); +} + +document.getElementById('loginButton').addEventListener('click', () => { + adminPassword = document.getElementById('passwordInput').value; + checkAuth(); +}); + +document.getElementById('passwordInput').addEventListener('keydown', (e) => { + if (e.key === 'Enter') document.getElementById('loginButton').click(); +}); + +document.getElementById('uploadButton').addEventListener('click', async () => { + const file = document.getElementById('uploadInput').files[0]; + uploadMessage.textContent = ''; + if (!file) { uploadMessage.textContent = '請選擇檔案。'; return; } + + const formData = new FormData(); + formData.append('template', file); + + const res = await fetchAdmin('/api/admin/templates', { method: 'POST', body: formData }); + const body = await res.json().catch(() => ({})); + + if (res.ok) { + uploadMessage.textContent = `已上傳:${body.name}`; + document.getElementById('uploadInput').value = ''; + loadTemplates(); + } else { + uploadMessage.textContent = body.error || '上傳失敗。'; + } +}); + +async function checkAuth() { + loginError.textContent = ''; + try { + const res = await fetchAdmin('/api/admin/check'); + if (res.ok) { + sessionStorage.setItem('adminPassword', adminPassword); + showAdmin(); + } else { + loginError.textContent = '密碼錯誤。'; + adminPassword = null; + sessionStorage.removeItem('adminPassword'); + showLogin(); + } + } catch { + loginError.textContent = '連線失敗。'; + } +} + +function showLogin() { + loginSection.hidden = false; + adminSection.hidden = true; +} + +function showAdmin() { + loginSection.hidden = true; + adminSection.hidden = false; + loadTemplates(); +} + +async function loadTemplates() { + const list = document.getElementById('templateList'); + try { + const res = await fetch('/api/templates'); + const { templates } = await res.json(); + + if (templates.length === 0) { + list.innerHTML = '

尚無範本。

'; + return; + } + + list.replaceChildren(...templates.map((name) => { + const row = document.createElement('div'); + row.style.cssText = 'display:flex;align-items:center;gap:12px;padding:8px 0;border-bottom:1px solid var(--border)'; + + const label = document.createElement('span'); + label.textContent = name; + label.style.flex = '1'; + label.style.fontSize = '0.9rem'; + + const btn = document.createElement('button'); + btn.className = 'secondary-button'; + btn.textContent = '刪除'; + btn.style.minHeight = '34px'; + btn.style.padding = '0 14px'; + btn.addEventListener('click', () => deleteTemplate(name, row)); + + row.append(label, btn); + return row; + })); + } catch { + list.innerHTML = '

載入失敗。

'; + } +} + +async function deleteTemplate(name, row) { + if (!confirm(`確定要刪除「${name}」?此動作無法復原。`)) return; + const res = await fetchAdmin(`/api/admin/templates/${encodeURIComponent(name)}`, { method: 'DELETE' }); + if (res.ok) { + row.remove(); + if (!document.getElementById('templateList').children.length) { + document.getElementById('templateList').innerHTML = '

尚無範本。

'; + } + } else { + const body = await res.json().catch(() => ({})); + alert(body.error || '刪除失敗。'); + } +} + +function fetchAdmin(url, options = {}) { + const headers = new Headers(options.headers); + headers.set('Authorization', `Basic ${btoa('admin:' + adminPassword)}`); + return fetch(url, { ...options, headers }); +} diff --git a/public/app.js b/public/app.js index 048516c..59ae8fe 100644 --- a/public/app.js +++ b/public/app.js @@ -6,11 +6,31 @@ 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'); 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(); @@ -29,6 +49,8 @@ form.addEventListener('submit', async (event) => { monthlyRent: formData.get('monthlyRent'), paymentDay: formData.get('paymentDay'), deposit: formData.get('deposit'), + leaseStart: formData.get('leaseStart'), + leaseEnd: formData.get('leaseEnd'), }), }); @@ -54,16 +76,12 @@ form.addEventListener('submit', async (event) => { }); downloadButton.addEventListener('click', () => { - if (!currentPdfBlob) { - return; - } + if (!currentPdfBlob) return; downloadPdf(); }); shareButton.addEventListener('click', async () => { - if (!currentPdfBlob) { - return; - } + if (!currentPdfBlob) return; const file = new File([currentPdfBlob], currentPdfFileName, { type: 'application/pdf' }); if (!navigator.canShare || !navigator.canShare({ files: [file] })) { @@ -72,10 +90,7 @@ shareButton.addEventListener('click', async () => { } try { - await navigator.share({ - title: '租屋契約 PDF', - files: [file], - }); + await navigator.share({ title: '租屋契約 PDF', files: [file] }); } catch (error) { if (error.name !== 'AbortError') { message.textContent = '分享失敗,已保留下載按鈕。'; @@ -86,9 +101,7 @@ shareButton.addEventListener('click', async () => { async function loadTemplates() { try { const response = await fetch('/api/templates'); - if (!response.ok) { - throw new Error('無法讀取範本。'); - } + if (!response.ok) throw new Error('無法讀取範本。'); const body = await response.json(); templateSelect.replaceChildren( @@ -96,15 +109,16 @@ async function loadTemplates() { ); if (body.templates.length === 0) { - templateSelect.append(new Option('templates 資料夾沒有 .doc 範本', '')); + templateSelect.append(new Option('templates 資料夾沒有範本', '')); templateSelect.disabled = true; submitButton.disabled = true; connectionStatus.textContent = '缺少範本'; - message.textContent = '請先將 .doc 範本放進 templates 資料夾。'; + message.textContent = '請先將 .docx 範本放進 templates 資料夾。'; return; } connectionStatus.textContent = '可使用'; + loadPreview(); } catch (error) { templateSelect.append(new Option('讀取失敗', '')); templateSelect.disabled = true; @@ -114,6 +128,53 @@ async function loadTemplates() { } } +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`; + } catch { + // AbortError 或其他錯誤都忽略,交給 finally 處理 + } finally { + if (gen === previewGen) previewLoading.hidden = true; + } +} + function setBusy(isBusy) { submitButton.disabled = isBusy || templateSelect.disabled; submitButton.textContent = isBusy ? '產生中' : '產生 PDF'; @@ -128,9 +189,7 @@ function resetPdf() { } function canShareCurrentPdf() { - if (!currentPdfBlob || !navigator.canShare) { - return false; - } + if (!currentPdfBlob || !navigator.canShare) return false; const file = new File([currentPdfBlob], currentPdfFileName, { type: 'application/pdf' }); return navigator.canShare({ files: [file] }); } @@ -147,15 +206,9 @@ function downloadPdf() { } function getFileNameFromDisposition(disposition) { - if (!disposition) { - return ''; - } - + if (!disposition) return ''; const utf8Match = disposition.match(/filename\*=UTF-8''([^;]+)/i); - if (utf8Match) { - return decodeURIComponent(utf8Match[1]); - } - + if (utf8Match) return decodeURIComponent(utf8Match[1]); const asciiMatch = disposition.match(/filename="([^"]+)"/i); return asciiMatch ? asciiMatch[1] : ''; } @@ -164,3 +217,25 @@ 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}`; +} diff --git a/public/index.html b/public/index.html index 77328af..08d2cdc 100644 --- a/public/index.html +++ b/public/index.html @@ -13,54 +13,84 @@

Word to PDF

租屋契約 PDF 產生器

- 連線中 +
+ 管理員 + 連線中 +
-
- +
+ +
+ -
- + - +
+
+

PDF

+

尚未產生

+

請輸入資料後產生 PDF。

+
+
+ + +
+
+
- +
+
+ + + +
+ +
+ + +
+
+ +
+ +
+
+

Word 預覽

+
+ + +
- - - - -
-
-

PDF

-

尚未產生

-

請輸入資料後產生 PDF。

-
- -
- - -
-
+
diff --git a/public/styles.css b/public/styles.css index c79a3d1..b574c34 100644 --- a/public/styles.css +++ b/public/styles.css @@ -8,7 +8,6 @@ --muted: #64716b; --accent: #0f766e; --accent-hover: #0b5f59; - --warning: #9a5b00; --shadow: 0 18px 45px rgba(15, 35, 30, 0.08); } @@ -16,6 +15,10 @@ box-sizing: border-box; } +[hidden] { + display: none !important; +} + body { margin: 0; min-height: 100vh; @@ -33,7 +36,7 @@ select { } .app-shell { - width: min(1080px, calc(100% - 32px)); + width: min(1280px, calc(100% - 32px)); margin: 0 auto; padding: 32px 0; } @@ -82,31 +85,46 @@ h2 { .workspace { display: grid; - grid-template-columns: minmax(0, 1.35fr) minmax(280px, 0.65fr); + grid-template-columns: 360px 1fr; gap: 20px; align-items: start; } -.tool-panel, -.result-panel { +.left-col { + display: flex; + flex-direction: column; + gap: 16px; +} + +.right-col { + position: sticky; + top: 20px; +} + +.tool-panel { border: 1px solid var(--border); border-radius: 8px; background: var(--surface); box-shadow: var(--shadow); -} - -.tool-panel { display: grid; gap: 18px; padding: 22px; } +.fields-panel { + gap: 14px; +} + .field-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; } +.field-grid--2 { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + .field { display: grid; gap: 8px; @@ -166,16 +184,16 @@ h2 { opacity: 0.55; } -.result-panel { +.result-inline { display: grid; - gap: 24px; - min-height: 216px; - padding: 22px; + gap: 16px; + padding-top: 4px; + border-top: 1px solid var(--border); } .message { - min-height: 46px; - margin: 10px 0 0; + min-height: 40px; + margin: 8px 0 0; color: var(--muted); line-height: 1.6; overflow-wrap: anywhere; @@ -185,21 +203,87 @@ h2 { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; - align-self: end; } -@media (max-width: 760px) { +.preview-panel { + border: 1px solid var(--border); + border-radius: 8px; + background: var(--surface); + box-shadow: var(--shadow); + padding: 22px; + display: flex; + flex-direction: column; + gap: 12px; + height: calc(100vh - 120px); + min-height: 400px; +} + +.preview-panel .eyebrow { + margin: 0; +} + +.preview-wrap { + flex: 1; + position: relative; + min-height: 0; +} + +#previewFrame { + width: 100%; + height: 100%; + border: 1px solid var(--border); + border-radius: 4px; + background: #fafafa; +} + +.preview-loading { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 14px; + background: rgba(246, 248, 247, 0.85); + border-radius: 4px; + color: var(--muted); + font-size: 0.9rem; + font-weight: 600; +} + +.preview-spinner { + width: 32px; + height: 32px; + border: 3px solid var(--border); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 0.75s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +@media (max-width: 900px) { + .workspace { + grid-template-columns: 1fr; + } + + .right-col { + position: static; + } + + .preview-panel { + height: 480px; + } +} + +@media (max-width: 600px) { .app-shell { width: min(100% - 24px, 560px); padding: 20px 0; } - .topbar, - .workspace, - .field-grid { - grid-template-columns: 1fr; - } - .topbar { display: grid; } @@ -211,4 +295,9 @@ h2 { .primary-button { width: 100%; } + + .field-grid, + .field-grid--2 { + grid-template-columns: 1fr; + } } diff --git a/server.js b/server.js index 0c1f181..cee47fb 100644 --- a/server.js +++ b/server.js @@ -1,16 +1,54 @@ const express = require('express'); const path = require('node:path'); +const multer = require('multer'); const { createContractPdf, listTemplates, + getTemplatePreviewPdf, + saveTemplate, + deleteTemplate, } = require('./src/contractService'); const app = express(); -const port = Number(process.env.PORT || 3005); +const port = Number(process.env.PORT || 3001); +const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'admin'; + +if (!process.env.ADMIN_PASSWORD) { + console.warn('[warn] ADMIN_PASSWORD 未設定,使用預設密碼 "admin"'); +} app.use(express.json({ limit: '1mb' })); app.use(express.static(path.join(__dirname, 'public'))); +function adminAuth(req, res, next) { + const auth = req.headers.authorization || ''; + if (!auth.startsWith('Basic ')) { + return res.status(401).json({ error: '需要管理員驗證。' }); + } + const decoded = Buffer.from(auth.slice(6), 'base64').toString('utf-8'); + const password = decoded.slice(decoded.indexOf(':') + 1); + if (password !== ADMIN_PASSWORD) { + return res.status(401).json({ error: '密碼錯誤。' }); + } + next(); +} + +const upload = multer({ + storage: multer.memoryStorage(), + fileFilter: (_req, file, cb) => { + const name = Buffer.from(file.originalname, 'latin1').toString('utf8'); + file.originalname = name; + const ext = path.extname(name).toLowerCase(); + if (ext === '.doc' || ext === '.docx') return cb(null, true); + const err = new Error('僅接受 .doc / .docx 檔案。'); + err.exposeToClient = true; + cb(err); + }, + limits: { fileSize: 10 * 1024 * 1024 }, +}); + +// ── Public routes ────────────────────────────────────────── + app.get('/api/health', (_req, res) => { res.json({ ok: true }); }); @@ -23,6 +61,20 @@ app.get('/api/templates', async (_req, res, next) => { } }); +app.get('/api/templates/:name/preview', async (req, res) => { + try { + const pdfBuffer = await getTemplatePreviewPdf(req.params.name, req.query); + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader('Cache-Control', 'no-store'); + res.send(pdfBuffer); + } catch (error) { + console.error(error); + const msg = error.exposeToClient ? error.message : '預覽失敗'; + res.status(error.exposeToClient ? 400 : 500) + .send(`${msg}`); + } +}); + app.post('/api/contracts/pdf', async (req, res) => { try { const result = await createContractPdf({ @@ -30,6 +82,8 @@ app.post('/api/contracts/pdf', async (req, res) => { monthlyRent: req.body.monthlyRent, paymentDay: req.body.paymentDay, deposit: req.body.deposit, + leaseStart: req.body.leaseStart, + leaseEnd: req.body.leaseEnd, }); res.setHeader('Content-Type', 'application/pdf'); @@ -50,9 +104,48 @@ app.post('/api/contracts/pdf', async (req, res) => { } }); +// ── Admin routes ─────────────────────────────────────────── + +app.get('/api/admin/check', adminAuth, (_req, res) => { + res.json({ ok: true }); +}); + +app.post('/api/admin/templates', adminAuth, (req, res) => { + upload.single('template')(req, res, async (err) => { + if (err) { + const status = err.exposeToClient || err instanceof multer.MulterError ? 400 : 500; + return res.status(status).json({ error: err.message }); + } + try { + if (!req.file) return res.status(400).json({ error: '請選擇檔案。' }); + const name = await saveTemplate(req.file.originalname, req.file.buffer); + res.json({ ok: true, name }); + } catch (error) { + console.error(error); + res.status(error.exposeToClient ? 400 : 500).json({ + error: error.exposeToClient ? error.message : '上傳失敗。', + }); + } + }); +}); + +app.delete('/api/admin/templates/:name', adminAuth, async (req, res, next) => { + try { + await deleteTemplate(req.params.name); + res.json({ ok: true }); + } catch (error) { + next(error); + } +}); + +// ── Error handler ────────────────────────────────────────── + app.use((error, _req, res, _next) => { console.error(error); - res.status(500).json({ error: '伺服器發生錯誤。' }); + const status = error.exposeToClient ? 400 : 500; + res.status(status).json({ + error: error.exposeToClient ? error.message : '伺服器發生錯誤。', + }); }); app.listen(port, () => { diff --git a/src/contractService.js b/src/contractService.js index acbd772..4c7a518 100644 --- a/src/contractService.js +++ b/src/contractService.js @@ -17,9 +17,16 @@ const SOFFICE_BIN = process.env.SOFFICE_BIN || ( const SUPPORTED_TEMPLATE_EXTENSIONS = new Set(['.doc', '.docx']); const PLACEHOLDERS = [ - ['{{每月租金}}', 'monthlyRent', '每月租金'], - ['{{繳款日期}}', 'paymentDay', '繳款日期'], - ['{{保證金}}', 'deposit', '保證金'], + ['{{每月租金}}', 'monthlyRent', '每月租金', true], + ['{{繳款日期}}', 'paymentDay', '繳款日期', true], + ['{{保證金}}', 'deposit', '保證金', true], + ['{{租賃開始年}}', 'leaseStartYear', '租賃開始年', false], + ['{{租賃開始月}}', 'leaseStartMonth', '租賃開始月', false], + ['{{租賃開始日}}', 'leaseStartDay', '租賃開始日', false], + ['{{租賃結束年}}', 'leaseEndYear', '租賃結束年', false], + ['{{租賃結束月}}', 'leaseEndMonth', '租賃結束月', false], + ['{{租賃結束日}}', 'leaseEndDay', '租賃結束日', false], + ['{{租期年數}}', 'leaseDurationYears', '租期年數', false], ]; async function listTemplates() { @@ -62,13 +69,43 @@ async function createContractPdf(input) { } function normalizeContractInput(input) { + const start = normalizeLeaseDate(input.leaseStart, '租賃開始日'); + const end = normalizeLeaseDate(input.leaseEnd, '租賃結束日'); + + if (end.gregorianYear < start.gregorianYear || + (end.gregorianYear === start.gregorianYear && end.month < start.month) || + (end.gregorianYear === start.gregorianYear && end.month === start.month && end.day <= start.day)) { + throwClientError('租賃結束日必須晚於開始日。'); + } + + const leaseDurationYears = end.gregorianYear - start.gregorianYear; + return { monthlyRent: normalizePositiveInteger(input.monthlyRent, '每月租金'), paymentDay: normalizePaymentDay(input.paymentDay), deposit: normalizePositiveInteger(input.deposit, '保證金'), + leaseStartYear: String(start.rocYear), + leaseStartMonth: String(start.month), + leaseStartDay: String(start.day), + leaseEndYear: String(end.rocYear), + leaseEndMonth: String(end.month), + leaseEndDay: String(end.day), + leaseDurationYears: String(leaseDurationYears), }; } +function normalizeLeaseDate(value, label) { + const text = String(value ?? '').trim(); + if (!/^\d{4}-\d{2}-\d{2}$/.test(text)) { + throwClientError(`${label}格式不正確。`); + } + const [y, m, d] = text.split('-').map(Number); + if (y < 1912 || m < 1 || m > 12 || d < 1 || d > 31) { + throwClientError(`${label}日期不正確。`); + } + return { gregorianYear: y, rocYear: y - 1911, month: m, day: d }; +} + function normalizePositiveInteger(value, label) { const text = String(value ?? '').trim(); if (!/^[1-9]\d*$/.test(text)) { @@ -131,7 +168,8 @@ async function removeWorkDir(workDir) { } // Word 會把佔位符拆成多個 run,用這個 pattern 允許相鄰字元之間出現 run 邊界 XML -const RUN_BOUNDARY = '(?:]*>(?:|)*]*>(?:[\\s\\S]*?)?]*>)?'; +// [\s\S]*? 改用負向預查避免跨越 c.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); @@ -197,8 +235,12 @@ function replaceUtf16Le(buffer, placeholder, value) { } function assertAllPlaceholdersWereFound(replacementCounts) { + const requiredPlaceholders = new Set( + PLACEHOLDERS.filter(([, , , required]) => required).map(([p]) => p), + ); + const missing = [...replacementCounts.entries()] - .filter(([, count]) => count === 0) + .filter(([placeholder, count]) => count === 0 && requiredPlaceholders.has(placeholder)) .map(([placeholder]) => placeholder); if (missing.length > 0) { @@ -314,7 +356,95 @@ function throwClientError(message) { throw error; } +function buildPreviewValues(input) { + const values = {}; + + if (input.monthlyRent) values.monthlyRent = String(input.monthlyRent); + if (input.paymentDay) values.paymentDay = String(input.paymentDay); + if (input.deposit) values.deposit = String(input.deposit); + + if (input.leaseStart && /^\d{4}-\d{2}-\d{2}$/.test(input.leaseStart)) { + const [y, m, d] = input.leaseStart.split('-').map(Number); + values.leaseStartYear = String(y - 1911); + values.leaseStartMonth = String(m); + values.leaseStartDay = String(d); + } + + if (input.leaseEnd && /^\d{4}-\d{2}-\d{2}$/.test(input.leaseEnd)) { + const [y, m, d] = input.leaseEnd.split('-').map(Number); + values.leaseEndYear = String(y - 1911); + values.leaseEndMonth = String(m); + values.leaseEndDay = String(d); + } + + if (values.leaseStartYear && values.leaseEndYear) { + values.leaseDurationYears = String( + Number(input.leaseEnd.slice(0, 4)) - Number(input.leaseStart.slice(0, 4)), + ); + } + + return values; +} + +async function getTemplatePreviewPdf(templateName, input = {}) { + const values = buildPreviewValues(input); + const templatePath = await resolveTemplatePath(templateName); + const workDir = await createWorkDir(); + + try { + const extension = path.extname(templatePath); + const copiedDocPath = path.join(workDir, `preview${extension}`); + await fs.copyFile(templatePath, copiedDocPath); + + if (extension.toLowerCase() === '.docx') { + const data = await fs.readFile(copiedDocPath); + const zip = await JSZip.loadAsync(data); + const xmlFiles = ['word/document.xml', 'word/header1.xml', 'word/footer1.xml']; + + for (const xmlFile of xmlFiles) { + const entry = zip.file(xmlFile); + if (!entry) continue; + let xml = await entry.async('string'); + for (const [placeholder, key] of PLACEHOLDERS) { + if (values[key] === undefined) continue; + const pattern = buildPlaceholderPattern(placeholder); + xml = xml.replace(pattern, () => values[key]); + } + zip.file(xmlFile, xml); + } + + const out = await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' }); + await fs.writeFile(copiedDocPath, out); + } + + const pdfPath = await convertToPdf(copiedDocPath, workDir); + return await fs.readFile(pdfPath); + } finally { + await removeWorkDir(workDir); + } +} + +async function saveTemplate(originalName, buffer) { + const name = path.basename(String(originalName)); + if (!name) throwClientError('檔名不正確。'); + const ext = path.extname(name).toLowerCase(); + if (!SUPPORTED_TEMPLATE_EXTENSIONS.has(ext)) throwClientError('僅接受 .doc / .docx 檔案。'); + const dest = path.resolve(TEMPLATE_DIR, name); + if (!isInsideDirectory(TEMPLATE_DIR, dest)) throwClientError('路徑不正確。'); + await fs.mkdir(TEMPLATE_DIR, { recursive: true }); + await fs.writeFile(dest, buffer); + return name; +} + +async function deleteTemplate(templateName) { + const templatePath = await resolveTemplatePath(templateName); + await fs.unlink(templatePath); +} + module.exports = { createContractPdf, listTemplates, + getTemplatePreviewPdf, + saveTemplate, + deleteTemplate, }; diff --git a/templates/租屋契約-內容_逢甲 A.docx b/templates/租屋契約-內容_逢甲 A.docx index 2aea854..df20585 100644 Binary files a/templates/租屋契約-內容_逢甲 A.docx and b/templates/租屋契約-內容_逢甲 A.docx differ diff --git a/templates/租屋契約-內容_逢甲 B.docx b/templates/租屋契約-內容_逢甲 B.docx index cd4ea59..e7cb3cc 100644 Binary files a/templates/租屋契約-內容_逢甲 B.docx and b/templates/租屋契約-內容_逢甲 B.docx differ diff --git a/templates/租屋契約-內容_逢甲 C.docx b/templates/租屋契約-內容_逢甲 C.docx index 63fe5d4..45b140a 100644 Binary files a/templates/租屋契約-內容_逢甲 C.docx and b/templates/租屋契約-內容_逢甲 C.docx differ diff --git a/templates/租屋契約-內容_逢甲 D.docx b/templates/租屋契約-內容_逢甲 D.docx index 1059e3a..4f578e9 100644 Binary files a/templates/租屋契約-內容_逢甲 D.docx and b/templates/租屋契約-內容_逢甲 D.docx differ