新增管理員頁面、Word PDF 預覽、租賃日期欄位、SSL docker-compose

- 新增 /admin.html:上傳/刪除範本,HTTP Basic Auth 保護
- Word 預覽改用 LibreOffice PDF 轉換,帶入表單參數即時顯示
- 新增租賃開始/結束年月日、租期年數佔位符支援
- 預覽 loading 遮罩,修正 hidden 被 CSS display:flex 覆蓋的問題
- 左右欄 UI 重構,右欄固定顯示 Word 預覽
- 新增 docker-compose.yml + nginx SSL reverse proxy
- admin 密碼改由 ADMIN_PASSWORD 環境變數設定

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-15 23:02:33 +08:00
parent a318c18214
commit 89a4f891c2
17 changed files with 950 additions and 101 deletions

View File

@@ -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"]

35
docker-compose.yml Normal file
View File

@@ -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

9
docker/nginx/Dockerfile Normal file
View File

@@ -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"]

114
docker/nginx/entrypoint.sh Normal file
View File

@@ -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 <<EOF
server {
listen ${NGINX_PORT} ssl;
server_name ${NGINX_SERVER_NAME};
ssl_certificate ${GENERATED_CERT_PATH};
ssl_certificate_key ${GENERATED_KEY_PATH};
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers on;
client_max_body_size 20m;
location / {
proxy_pass http://${UPSTREAM_HOST}:${UPSTREAM_PORT};
proxy_http_version 1.1;
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header Upgrade \$http_upgrade;
proxy_set_header Connection "upgrade";
}
}
EOF
}
watch_cert_updates() {
while inotifywait -qq -e close_write,create,delete,move "${SSL_CERT_DIR}"; do
echo "Certificate files changed, reloading nginx..."
build_cert_bundle
nginx -s reload
done
}
build_cert_bundle
write_nginx_config
nginx -t
watch_cert_updates &
WATCHER_PID=$!
cleanup() {
kill "${WATCHER_PID}" 2>/dev/null || true
}
trap cleanup INT TERM
nginx -g 'daemon off;' &
NGINX_PID=$!
wait "${NGINX_PID}"

88
package-lock.json generated
View File

@@ -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",

View File

@@ -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"

62
public/admin.html Normal file
View File

@@ -0,0 +1,62 @@
<!doctype html>
<html lang="zh-Hant">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>管理員 — 租屋契約範本</title>
<link rel="stylesheet" href="/styles.css">
</head>
<body>
<main class="app-shell">
<header class="topbar">
<div>
<p class="eyebrow">Admin</p>
<h1>範本管理</h1>
</div>
<a href="/" class="status-pill" style="text-decoration:none">← 返回</a>
</header>
<div id="loginSection">
<div class="tool-panel" style="max-width:360px">
<div>
<p class="eyebrow">管理員登入</p>
<h2>輸入密碼</h2>
</div>
<label class="field">
<span>密碼</span>
<input id="passwordInput" type="password" autocomplete="current-password">
</label>
<button id="loginButton" class="primary-button">登入</button>
<p id="loginError" class="message"></p>
</div>
</div>
<div id="adminSection" hidden>
<section class="workspace" style="grid-template-columns: 1fr 1fr">
<div class="tool-panel">
<div>
<p class="eyebrow">上傳範本</p>
<h2>新增 / 覆蓋</h2>
</div>
<label class="field">
<span>選擇檔案(.docx / .doc</span>
<input id="uploadInput" type="file" accept=".doc,.docx">
</label>
<button id="uploadButton" class="primary-button">上傳</button>
<p id="uploadMessage" class="message"></p>
</div>
<div class="tool-panel">
<div>
<p class="eyebrow">現有範本</p>
<h2>刪除</h2>
</div>
<div id="templateList"><p class="message">載入中…</p></div>
</div>
</section>
</div>
</main>
<script src="/admin.js" defer></script>
</body>
</html>

125
public/admin.js Normal file
View File

@@ -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 = '<p class="message">尚無範本。</p>';
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 = '<p class="message">載入失敗。</p>';
}
}
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 = '<p class="message">尚無範本。</p>';
}
} 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 });
}

View File

@@ -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}`;
}

View File

@@ -13,45 +13,31 @@
<p class="eyebrow">Word to PDF</p>
<h1>租屋契約 PDF 產生器</h1>
</div>
<div style="display:flex;gap:10px;align-items:center">
<a href="/admin.html" class="status-pill" style="text-decoration:none">管理員</a>
<span id="connectionStatus" class="status-pill">連線中</span>
</div>
</header>
<section class="workspace" aria-label="租屋契約資料">
<form id="contractForm" class="tool-panel">
<div class="left-col">
<form id="contractForm">
<div class="tool-panel">
<label class="field">
<span>Word 範本</span>
<select id="template" name="template" required></select>
</label>
<div class="field-grid">
<label class="field">
<span>每月租金</span>
<input name="monthlyRent" type="number" inputmode="numeric" min="1" step="1" value="8000" required>
</label>
<label class="field">
<span>繳款日期</span>
<input name="paymentDay" type="number" inputmode="numeric" min="1" max="31" step="1" value="18" required>
</label>
<label class="field">
<span>保證金</span>
<input name="deposit" type="number" inputmode="numeric" min="1" step="1" value="16000" required>
</label>
</div>
<button id="submitButton" class="primary-button" type="submit">
產生 PDF
</button>
</form>
<section class="result-panel" aria-label="PDF 結果">
<div class="result-inline" aria-label="PDF 結果">
<div>
<p class="eyebrow">PDF</p>
<h2 id="resultTitle">尚未產生</h2>
<p id="message" class="message">請輸入資料後產生 PDF。</p>
</div>
<div class="result-actions">
<button id="downloadButton" class="secondary-button" type="button" disabled>
下載 PDF
@@ -60,7 +46,51 @@
分享 PDF
</button>
</div>
</section>
</div>
</div>
<div class="tool-panel fields-panel">
<div class="field-grid">
<label class="field">
<span>每月租金</span>
<input name="monthlyRent" type="number" inputmode="numeric" min="1" step="1" value="8000" required>
</label>
<label class="field">
<span>繳款日期</span>
<input name="paymentDay" type="number" inputmode="numeric" min="1" max="31" step="1" value="18" required>
</label>
<label class="field">
<span>保證金</span>
<input name="deposit" type="number" inputmode="numeric" min="1" step="1" value="16000" required>
</label>
</div>
<div class="field-grid field-grid--2">
<label class="field">
<span>租賃開始日</span>
<input id="leaseStart" name="leaseStart" type="date" required>
</label>
<label class="field">
<span>租賃結束日</span>
<input id="leaseEnd" name="leaseEnd" type="date" required>
</label>
</div>
</div>
</form>
</div>
<div class="right-col">
<div class="preview-panel">
<p class="eyebrow">Word 預覽</p>
<div class="preview-wrap">
<iframe id="previewFrame" src="about:blank" title="Word 範本預覽"></iframe>
<div id="previewLoading" class="preview-loading" hidden>
<div class="preview-spinner"></div>
<span>載入中</span>
</div>
</div>
</div>
</div>
</section>
</main>

View File

@@ -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;
}
}

View File

@@ -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(`<!DOCTYPE html><html><body style="padding:20px;color:#666;font-family:sans-serif">${msg}</body></html>`);
}
});
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, () => {

View File

@@ -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 = '(?:</w:t></w:r[^>]*>(?:<w:bookmarkStart[^/]*/?>|<w:bookmarkEnd[^/]*/?>)*<w:r[^>]*>(?:<w:rPr>[\\s\\S]*?</w:rPr>)?<w:t[^>]*>)?';
// [\s\S]*? 改用負向預查避免跨越 <w:t 邊界,防止 backtracking 吃掉多個佔位符
const RUN_BOUNDARY = '(?:</w:t></w:r[^>]*>(?:<w:bookmarkStart[^/]*/?>|<w:bookmarkEnd[^/]*/?>)*<w:r[^>]*>(?:<w:rPr>(?:(?!<w:t)[\\s\\S])*?</w:rPr>)?<w:t[^>]*>)?';
function buildPlaceholderPattern(placeholder) {
const chars = [...placeholder].map((c) => 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,
};