新增管理員頁面、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

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,54 +13,84 @@
<p class="eyebrow">Word to PDF</p>
<h1>租屋契約 PDF 產生器</h1>
</div>
<span id="connectionStatus" class="status-pill">連線中</span>
<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">
<label class="field">
<span>Word 範本</span>
<select id="template" name="template" required></select>
</label>
<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>
<button id="submitButton" class="primary-button" type="submit">
產生 PDF
</button>
<label class="field">
<span>繳款日期</span>
<input name="paymentDay" type="number" inputmode="numeric" min="1" max="31" step="1" value="18" required>
</label>
<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
</button>
<button id="shareButton" class="secondary-button" type="button" disabled>
分享 PDF
</button>
</div>
</div>
</div>
<label class="field">
<span>保證金</span>
<input name="deposit" type="number" inputmode="numeric" min="1" step="1" value="16000" required>
</label>
<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>
<button id="submitButton" class="primary-button" type="submit">
產生 PDF
</button>
</form>
<section class="result-panel" 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
</button>
<button id="shareButton" class="secondary-button" type="button" disabled>
分享 PDF
</button>
</div>
</section>
</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;
}
}