新增管理員頁面、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:
@@ -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
|
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
|
fi
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
ENV PORT=3005
|
ENV PORT=3001
|
||||||
ENV TEMPLATE_DIR=/app/templates
|
ENV TEMPLATE_DIR=/app/templates
|
||||||
ENV TEMP_DIR=/tmp/rental-contracts
|
ENV TEMP_DIR=/tmp/rental-contracts
|
||||||
ENV SOFFICE_BIN=soffice
|
ENV SOFFICE_BIN=soffice
|
||||||
|
|
||||||
EXPOSE 3005
|
EXPOSE 3001
|
||||||
|
|
||||||
CMD ["npm", "start"]
|
CMD ["npm", "start"]
|
||||||
|
|||||||
35
docker-compose.yml
Normal file
35
docker-compose.yml
Normal 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
9
docker/nginx/Dockerfile
Normal 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
114
docker/nginx/entrypoint.sh
Normal 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
88
package-lock.json
generated
@@ -10,7 +10,8 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"adm-zip": "^0.5.17",
|
"adm-zip": "^0.5.17",
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
"jszip": "^3.10.1"
|
"jszip": "^3.10.1",
|
||||||
|
"multer": "^2.0.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
@@ -38,6 +39,12 @@
|
|||||||
"node": ">=12.0"
|
"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": {
|
"node_modules/array-flatten": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
||||||
@@ -68,6 +75,23 @@
|
|||||||
"npm": "1.2.8000 || >= 1.4.16"
|
"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": {
|
"node_modules/bytes": {
|
||||||
"version": "3.1.2",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||||
@@ -106,6 +130,35 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/content-disposition": {
|
||||||
"version": "0.5.4",
|
"version": "0.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
||||||
@@ -569,6 +622,25 @@
|
|||||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/negotiator": {
|
||||||
"version": "0.6.3",
|
"version": "0.6.3",
|
||||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
|
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
|
||||||
@@ -866,6 +938,14 @@
|
|||||||
"node": ">= 0.8"
|
"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": {
|
"node_modules/string_decoder": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||||
@@ -903,6 +983,12 @@
|
|||||||
"node": ">= 0.6"
|
"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": {
|
"node_modules/unpipe": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
||||||
|
|||||||
@@ -10,7 +10,8 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"adm-zip": "^0.5.17",
|
"adm-zip": "^0.5.17",
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
"jszip": "^3.10.1"
|
"jszip": "^3.10.1",
|
||||||
|
"multer": "^2.0.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
|
|||||||
62
public/admin.html
Normal file
62
public/admin.html
Normal 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
125
public/admin.js
Normal 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 });
|
||||||
|
}
|
||||||
127
public/app.js
127
public/app.js
@@ -6,11 +6,31 @@ const shareButton = document.querySelector('#shareButton');
|
|||||||
const message = document.querySelector('#message');
|
const message = document.querySelector('#message');
|
||||||
const resultTitle = document.querySelector('#resultTitle');
|
const resultTitle = document.querySelector('#resultTitle');
|
||||||
const connectionStatus = document.querySelector('#connectionStatus');
|
const connectionStatus = document.querySelector('#connectionStatus');
|
||||||
|
const previewFrame = document.querySelector('#previewFrame');
|
||||||
|
const previewLoading = document.querySelector('#previewLoading');
|
||||||
|
|
||||||
let currentPdfBlob = null;
|
let currentPdfBlob = null;
|
||||||
let currentPdfFileName = '租屋契約.pdf';
|
let currentPdfFileName = '租屋契約.pdf';
|
||||||
|
|
||||||
loadTemplates();
|
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) => {
|
form.addEventListener('submit', async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -29,6 +49,8 @@ form.addEventListener('submit', async (event) => {
|
|||||||
monthlyRent: formData.get('monthlyRent'),
|
monthlyRent: formData.get('monthlyRent'),
|
||||||
paymentDay: formData.get('paymentDay'),
|
paymentDay: formData.get('paymentDay'),
|
||||||
deposit: formData.get('deposit'),
|
deposit: formData.get('deposit'),
|
||||||
|
leaseStart: formData.get('leaseStart'),
|
||||||
|
leaseEnd: formData.get('leaseEnd'),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -54,16 +76,12 @@ form.addEventListener('submit', async (event) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
downloadButton.addEventListener('click', () => {
|
downloadButton.addEventListener('click', () => {
|
||||||
if (!currentPdfBlob) {
|
if (!currentPdfBlob) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
downloadPdf();
|
downloadPdf();
|
||||||
});
|
});
|
||||||
|
|
||||||
shareButton.addEventListener('click', async () => {
|
shareButton.addEventListener('click', async () => {
|
||||||
if (!currentPdfBlob) {
|
if (!currentPdfBlob) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const file = new File([currentPdfBlob], currentPdfFileName, { type: 'application/pdf' });
|
const file = new File([currentPdfBlob], currentPdfFileName, { type: 'application/pdf' });
|
||||||
if (!navigator.canShare || !navigator.canShare({ files: [file] })) {
|
if (!navigator.canShare || !navigator.canShare({ files: [file] })) {
|
||||||
@@ -72,10 +90,7 @@ shareButton.addEventListener('click', async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await navigator.share({
|
await navigator.share({ title: '租屋契約 PDF', files: [file] });
|
||||||
title: '租屋契約 PDF',
|
|
||||||
files: [file],
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.name !== 'AbortError') {
|
if (error.name !== 'AbortError') {
|
||||||
message.textContent = '分享失敗,已保留下載按鈕。';
|
message.textContent = '分享失敗,已保留下載按鈕。';
|
||||||
@@ -86,9 +101,7 @@ shareButton.addEventListener('click', async () => {
|
|||||||
async function loadTemplates() {
|
async function loadTemplates() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/templates');
|
const response = await fetch('/api/templates');
|
||||||
if (!response.ok) {
|
if (!response.ok) throw new Error('無法讀取範本。');
|
||||||
throw new Error('無法讀取範本。');
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = await response.json();
|
const body = await response.json();
|
||||||
templateSelect.replaceChildren(
|
templateSelect.replaceChildren(
|
||||||
@@ -96,15 +109,16 @@ async function loadTemplates() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (body.templates.length === 0) {
|
if (body.templates.length === 0) {
|
||||||
templateSelect.append(new Option('templates 資料夾沒有 .doc 範本', ''));
|
templateSelect.append(new Option('templates 資料夾沒有範本', ''));
|
||||||
templateSelect.disabled = true;
|
templateSelect.disabled = true;
|
||||||
submitButton.disabled = true;
|
submitButton.disabled = true;
|
||||||
connectionStatus.textContent = '缺少範本';
|
connectionStatus.textContent = '缺少範本';
|
||||||
message.textContent = '請先將 .doc 範本放進 templates 資料夾。';
|
message.textContent = '請先將 .docx 範本放進 templates 資料夾。';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
connectionStatus.textContent = '可使用';
|
connectionStatus.textContent = '可使用';
|
||||||
|
loadPreview();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
templateSelect.append(new Option('讀取失敗', ''));
|
templateSelect.append(new Option('讀取失敗', ''));
|
||||||
templateSelect.disabled = true;
|
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) {
|
function setBusy(isBusy) {
|
||||||
submitButton.disabled = isBusy || templateSelect.disabled;
|
submitButton.disabled = isBusy || templateSelect.disabled;
|
||||||
submitButton.textContent = isBusy ? '產生中' : '產生 PDF';
|
submitButton.textContent = isBusy ? '產生中' : '產生 PDF';
|
||||||
@@ -128,9 +189,7 @@ function resetPdf() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function canShareCurrentPdf() {
|
function canShareCurrentPdf() {
|
||||||
if (!currentPdfBlob || !navigator.canShare) {
|
if (!currentPdfBlob || !navigator.canShare) return false;
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const file = new File([currentPdfBlob], currentPdfFileName, { type: 'application/pdf' });
|
const file = new File([currentPdfBlob], currentPdfFileName, { type: 'application/pdf' });
|
||||||
return navigator.canShare({ files: [file] });
|
return navigator.canShare({ files: [file] });
|
||||||
}
|
}
|
||||||
@@ -147,15 +206,9 @@ function downloadPdf() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getFileNameFromDisposition(disposition) {
|
function getFileNameFromDisposition(disposition) {
|
||||||
if (!disposition) {
|
if (!disposition) return '';
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
const utf8Match = disposition.match(/filename\*=UTF-8''([^;]+)/i);
|
const utf8Match = disposition.match(/filename\*=UTF-8''([^;]+)/i);
|
||||||
if (utf8Match) {
|
if (utf8Match) return decodeURIComponent(utf8Match[1]);
|
||||||
return decodeURIComponent(utf8Match[1]);
|
|
||||||
}
|
|
||||||
|
|
||||||
const asciiMatch = disposition.match(/filename="([^"]+)"/i);
|
const asciiMatch = disposition.match(/filename="([^"]+)"/i);
|
||||||
return asciiMatch ? asciiMatch[1] : '';
|
return asciiMatch ? asciiMatch[1] : '';
|
||||||
}
|
}
|
||||||
@@ -164,3 +217,25 @@ function buildDefaultFileName(templateName) {
|
|||||||
const baseName = String(templateName || '租屋契約').replace(/\.[^.]+$/, '');
|
const baseName = String(templateName || '租屋契約').replace(/\.[^.]+$/, '');
|
||||||
return `${baseName}.pdf`;
|
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}`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,45 +13,31 @@
|
|||||||
<p class="eyebrow">Word to PDF</p>
|
<p class="eyebrow">Word to PDF</p>
|
||||||
<h1>租屋契約 PDF 產生器</h1>
|
<h1>租屋契約 PDF 產生器</h1>
|
||||||
</div>
|
</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>
|
<span id="connectionStatus" class="status-pill">連線中</span>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="workspace" aria-label="租屋契約資料">
|
<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">
|
<label class="field">
|
||||||
<span>Word 範本</span>
|
<span>Word 範本</span>
|
||||||
<select id="template" name="template" required></select>
|
<select id="template" name="template" required></select>
|
||||||
</label>
|
</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">
|
<button id="submitButton" class="primary-button" type="submit">
|
||||||
產生 PDF
|
產生 PDF
|
||||||
</button>
|
</button>
|
||||||
</form>
|
|
||||||
|
|
||||||
<section class="result-panel" aria-label="PDF 結果">
|
<div class="result-inline" aria-label="PDF 結果">
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">PDF</p>
|
<p class="eyebrow">PDF</p>
|
||||||
<h2 id="resultTitle">尚未產生</h2>
|
<h2 id="resultTitle">尚未產生</h2>
|
||||||
<p id="message" class="message">請輸入資料後產生 PDF。</p>
|
<p id="message" class="message">請輸入資料後產生 PDF。</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="result-actions">
|
<div class="result-actions">
|
||||||
<button id="downloadButton" class="secondary-button" type="button" disabled>
|
<button id="downloadButton" class="secondary-button" type="button" disabled>
|
||||||
下載 PDF
|
下載 PDF
|
||||||
@@ -60,7 +46,51 @@
|
|||||||
分享 PDF
|
分享 PDF
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,6 @@
|
|||||||
--muted: #64716b;
|
--muted: #64716b;
|
||||||
--accent: #0f766e;
|
--accent: #0f766e;
|
||||||
--accent-hover: #0b5f59;
|
--accent-hover: #0b5f59;
|
||||||
--warning: #9a5b00;
|
|
||||||
--shadow: 0 18px 45px rgba(15, 35, 30, 0.08);
|
--shadow: 0 18px 45px rgba(15, 35, 30, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -16,6 +15,10 @@
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[hidden] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
@@ -33,7 +36,7 @@ select {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.app-shell {
|
.app-shell {
|
||||||
width: min(1080px, calc(100% - 32px));
|
width: min(1280px, calc(100% - 32px));
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 32px 0;
|
padding: 32px 0;
|
||||||
}
|
}
|
||||||
@@ -82,31 +85,46 @@ h2 {
|
|||||||
|
|
||||||
.workspace {
|
.workspace {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1.35fr) minmax(280px, 0.65fr);
|
grid-template-columns: 360px 1fr;
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
align-items: start;
|
align-items: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tool-panel,
|
.left-col {
|
||||||
.result-panel {
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.right-col {
|
||||||
|
position: sticky;
|
||||||
|
top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-panel {
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
box-shadow: var(--shadow);
|
box-shadow: var(--shadow);
|
||||||
}
|
|
||||||
|
|
||||||
.tool-panel {
|
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 18px;
|
gap: 18px;
|
||||||
padding: 22px;
|
padding: 22px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.fields-panel {
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
.field-grid {
|
.field-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.field-grid--2 {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
.field {
|
.field {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
@@ -166,16 +184,16 @@ h2 {
|
|||||||
opacity: 0.55;
|
opacity: 0.55;
|
||||||
}
|
}
|
||||||
|
|
||||||
.result-panel {
|
.result-inline {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 24px;
|
gap: 16px;
|
||||||
min-height: 216px;
|
padding-top: 4px;
|
||||||
padding: 22px;
|
border-top: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.message {
|
.message {
|
||||||
min-height: 46px;
|
min-height: 40px;
|
||||||
margin: 10px 0 0;
|
margin: 8px 0 0;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
@@ -185,21 +203,87 @@ h2 {
|
|||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
gap: 10px;
|
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 {
|
.app-shell {
|
||||||
width: min(100% - 24px, 560px);
|
width: min(100% - 24px, 560px);
|
||||||
padding: 20px 0;
|
padding: 20px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topbar,
|
|
||||||
.workspace,
|
|
||||||
.field-grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.topbar {
|
.topbar {
|
||||||
display: grid;
|
display: grid;
|
||||||
}
|
}
|
||||||
@@ -211,4 +295,9 @@ h2 {
|
|||||||
.primary-button {
|
.primary-button {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.field-grid,
|
||||||
|
.field-grid--2 {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
97
server.js
97
server.js
@@ -1,16 +1,54 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
|
const multer = require('multer');
|
||||||
const {
|
const {
|
||||||
createContractPdf,
|
createContractPdf,
|
||||||
listTemplates,
|
listTemplates,
|
||||||
|
getTemplatePreviewPdf,
|
||||||
|
saveTemplate,
|
||||||
|
deleteTemplate,
|
||||||
} = require('./src/contractService');
|
} = require('./src/contractService');
|
||||||
|
|
||||||
const app = express();
|
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.json({ limit: '1mb' }));
|
||||||
app.use(express.static(path.join(__dirname, 'public')));
|
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) => {
|
app.get('/api/health', (_req, res) => {
|
||||||
res.json({ ok: true });
|
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) => {
|
app.post('/api/contracts/pdf', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const result = await createContractPdf({
|
const result = await createContractPdf({
|
||||||
@@ -30,6 +82,8 @@ app.post('/api/contracts/pdf', async (req, res) => {
|
|||||||
monthlyRent: req.body.monthlyRent,
|
monthlyRent: req.body.monthlyRent,
|
||||||
paymentDay: req.body.paymentDay,
|
paymentDay: req.body.paymentDay,
|
||||||
deposit: req.body.deposit,
|
deposit: req.body.deposit,
|
||||||
|
leaseStart: req.body.leaseStart,
|
||||||
|
leaseEnd: req.body.leaseEnd,
|
||||||
});
|
});
|
||||||
|
|
||||||
res.setHeader('Content-Type', 'application/pdf');
|
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) => {
|
app.use((error, _req, res, _next) => {
|
||||||
console.error(error);
|
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, () => {
|
app.listen(port, () => {
|
||||||
|
|||||||
@@ -17,9 +17,16 @@ const SOFFICE_BIN = process.env.SOFFICE_BIN || (
|
|||||||
const SUPPORTED_TEMPLATE_EXTENSIONS = new Set(['.doc', '.docx']);
|
const SUPPORTED_TEMPLATE_EXTENSIONS = new Set(['.doc', '.docx']);
|
||||||
|
|
||||||
const PLACEHOLDERS = [
|
const PLACEHOLDERS = [
|
||||||
['{{每月租金}}', 'monthlyRent', '每月租金'],
|
['{{每月租金}}', 'monthlyRent', '每月租金', true],
|
||||||
['{{繳款日期}}', 'paymentDay', '繳款日期'],
|
['{{繳款日期}}', 'paymentDay', '繳款日期', true],
|
||||||
['{{保證金}}', 'deposit', '保證金'],
|
['{{保證金}}', 'deposit', '保證金', true],
|
||||||
|
['{{租賃開始年}}', 'leaseStartYear', '租賃開始年', false],
|
||||||
|
['{{租賃開始月}}', 'leaseStartMonth', '租賃開始月', false],
|
||||||
|
['{{租賃開始日}}', 'leaseStartDay', '租賃開始日', false],
|
||||||
|
['{{租賃結束年}}', 'leaseEndYear', '租賃結束年', false],
|
||||||
|
['{{租賃結束月}}', 'leaseEndMonth', '租賃結束月', false],
|
||||||
|
['{{租賃結束日}}', 'leaseEndDay', '租賃結束日', false],
|
||||||
|
['{{租期年數}}', 'leaseDurationYears', '租期年數', false],
|
||||||
];
|
];
|
||||||
|
|
||||||
async function listTemplates() {
|
async function listTemplates() {
|
||||||
@@ -62,13 +69,43 @@ async function createContractPdf(input) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function normalizeContractInput(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 {
|
return {
|
||||||
monthlyRent: normalizePositiveInteger(input.monthlyRent, '每月租金'),
|
monthlyRent: normalizePositiveInteger(input.monthlyRent, '每月租金'),
|
||||||
paymentDay: normalizePaymentDay(input.paymentDay),
|
paymentDay: normalizePaymentDay(input.paymentDay),
|
||||||
deposit: normalizePositiveInteger(input.deposit, '保證金'),
|
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) {
|
function normalizePositiveInteger(value, label) {
|
||||||
const text = String(value ?? '').trim();
|
const text = String(value ?? '').trim();
|
||||||
if (!/^[1-9]\d*$/.test(text)) {
|
if (!/^[1-9]\d*$/.test(text)) {
|
||||||
@@ -131,7 +168,8 @@ async function removeWorkDir(workDir) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Word 會把佔位符拆成多個 run,用這個 pattern 允許相鄰字元之間出現 run 邊界 XML
|
// 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) {
|
function buildPlaceholderPattern(placeholder) {
|
||||||
const chars = [...placeholder].map((c) => c.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
|
const chars = [...placeholder].map((c) => c.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
|
||||||
@@ -197,8 +235,12 @@ function replaceUtf16Le(buffer, placeholder, value) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function assertAllPlaceholdersWereFound(replacementCounts) {
|
function assertAllPlaceholdersWereFound(replacementCounts) {
|
||||||
|
const requiredPlaceholders = new Set(
|
||||||
|
PLACEHOLDERS.filter(([, , , required]) => required).map(([p]) => p),
|
||||||
|
);
|
||||||
|
|
||||||
const missing = [...replacementCounts.entries()]
|
const missing = [...replacementCounts.entries()]
|
||||||
.filter(([, count]) => count === 0)
|
.filter(([placeholder, count]) => count === 0 && requiredPlaceholders.has(placeholder))
|
||||||
.map(([placeholder]) => placeholder);
|
.map(([placeholder]) => placeholder);
|
||||||
|
|
||||||
if (missing.length > 0) {
|
if (missing.length > 0) {
|
||||||
@@ -314,7 +356,95 @@ function throwClientError(message) {
|
|||||||
throw error;
|
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 = {
|
module.exports = {
|
||||||
createContractPdf,
|
createContractPdf,
|
||||||
listTemplates,
|
listTemplates,
|
||||||
|
getTemplatePreviewPdf,
|
||||||
|
saveTemplate,
|
||||||
|
deleteTemplate,
|
||||||
};
|
};
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user