| <!DOCTYPE html> |
| <html lang="zh-TW"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>批次新增資料</title> |
| <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet"> |
| <style> |
| body { background: #f5f7fa; } |
| .drop-zone { border: 2px dashed #adb5bd; border-radius: 12px; padding: 2rem; text-align: center; cursor: pointer; background: #fff; } |
| .drop-zone:hover, .drop-zone.dragover { border-color: #0d6efd; background: #e8f0fe; } |
| table.preview td, table.preview th { font-size: .88rem; } |
| .status-ok { color: #198754; font-weight: bold; } |
| .status-err { color: #dc3545; font-weight: bold; } |
| .status-dup { color: #fd7e14; font-weight: bold; } |
| </style> |
| </head> |
| <body> |
| <div class="container py-4" style="max-width: 1100px;"> |
| <div class="d-flex justify-content-between align-items-center mb-3"> |
| <h2 class="mb-0">批次新增資料</h2> |
| <div> |
| <a href="/" class="btn btn-dark btn-sm me-1">首頁</a> |
| <a href="/history" class="btn btn-outline-secondary btn-sm me-1">歷史紀錄</a> |
| <a href="/new" class="btn btn-outline-primary btn-sm">單筆新增</a> |
| </div> |
| </div> |
|
|
| <div class="alert alert-info"> |
| <strong>檔名格式</strong>:必須包含<strong>病歷號</strong>與<strong>姓名</strong>,用底線 <code>_</code> 或空格分隔。<br> |
| 範例:<code>40091041_王小明_PRA2_20240605.xls</code>、<code>王小明_40091041 PRA1.xls</code>。<br> |
| 系統會自動抓取(姓名在前或病歷號在前都可以),日期與 PRA1 / PRA2 之類描述會被忽略。 |
| </div> |
|
|
| <div class="card mb-3"> |
| <div class="card-body"> |
| <div class="drop-zone" id="dropZone" onclick="document.getElementById('fileInput').click()"> |
| <div style="font-size:2rem;">📁</div> |
| <div class="fw-bold">點擊或拖曳選取多個 XLS 檔</div> |
| <input type="file" id="fileInput" multiple accept=".xls,.xlsx" style="display:none" onchange="onPick(this)"> |
| </div> |
| </div> |
| </div> |
|
|
| <div id="previewWrap" style="display:none;"> |
| <h5>預覽 <span class="text-muted small">(可手動修正病歷號 / 姓名)</span></h5> |
| <table class="table table-bordered table-sm bg-white preview"> |
| <thead class="table-light"> |
| <tr><th>檔名</th><th style="width:140px;">病歷號</th><th style="width:140px;">姓名</th><th style="width:100px;">狀態</th><th style="width:72px;"></th></tr> |
| </thead> |
| <tbody id="previewBody"></tbody> |
| </table> |
| <div class="mb-3"> |
| <div class="form-check form-check-inline"> |
| <input class="form-check-input" type="radio" name="dupmode" id="mode-overwrite" value="overwrite"> |
| <label class="form-check-label" for="mode-overwrite">覆寫</label> |
| </div> |
| <div class="form-check form-check-inline"> |
| <input class="form-check-input" type="radio" name="dupmode" id="mode-new" value="new" checked> |
| <label class="form-check-label" for="mode-new">可多筆存在</label> |
| </div> |
| <div class="form-check form-check-inline"> |
| <input class="form-check-input" type="radio" name="dupmode" id="mode-skip" value="skip"> |
| <label class="form-check-label" for="mode-skip">跳過</label> |
| </div> |
| </div> |
| <button class="btn btn-primary" id="submitBtn" onclick="submitBatch()">全部上傳</button> |
| <div id="results" class="mt-3"></div> |
| </div> |
| </div> |
| <script> |
| let selectedFiles = []; |
| |
| function parseFilename(fname) { |
| const stem = fname.replace(/\.(xls|xlsx)$/i, ''); |
| |
| const tokens = stem.split(/[_\-. ]+/).filter(x => x); |
| |
| |
| function isDate(t) { |
| if (!/^\d{8}$/.test(t)) return false; |
| const y = +t.slice(0,4), m = +t.slice(4,6), d = +t.slice(6,8); |
| return y >= 1900 && y <= 2099 && m >= 1 && m <= 12 && d >= 1 && d <= 31; |
| } |
| |
| function isDesc(t) { |
| return /^(PRA\d*|Class\d*|LS\w*|I|II|III)$/i.test(t); |
| } |
| |
| function isChartNo(t) { |
| return /^\d{6,8}$/.test(t) && !isDate(t); |
| } |
| |
| let chart = '', name = ''; |
| for (const t of tokens) { |
| if (isDate(t) || isDesc(t)) continue; |
| if (isChartNo(t) && !chart) { chart = t; continue; } |
| |
| if (!/^\d+$/.test(t) && !name) { name = t; } |
| } |
| return { name, chart }; |
| } |
| |
| function onPick(input) { |
| selectedFiles = Array.from(input.files); |
| if (!selectedFiles.length) return; |
| const tbody = document.getElementById('previewBody'); |
| tbody.innerHTML = ''; |
| selectedFiles.forEach((f, i) => { |
| const p = parseFilename(f.name); |
| const tr = document.createElement('tr'); |
| tr.dataset.idx = i; |
| const missing = !p.chart || !p.name; |
| tr.innerHTML = ` |
| <td class="small">${f.name}</td> |
| <td><input type="text" class="form-control form-control-sm" data-field="chart" value="${p.chart}"></td> |
| <td><input type="text" class="form-control form-control-sm" data-field="name" value="${p.name}"></td> |
| <td class="${missing ? 'status-err' : 'status-ok'}">${missing ? '缺欄位' : '待上傳'}</td> |
| <td><button type="button" class="btn btn-sm btn-outline-danger" onclick="removeRow(this)">刪除</button></td> |
| `; |
| tbody.appendChild(tr); |
| }); |
| document.getElementById('previewWrap').style.display = 'block'; |
| } |
| |
| function removeRow(btn) { |
| const tr = btn.closest('tr'); |
| if (tr) tr.remove(); |
| |
| if (!document.querySelectorAll('#previewBody tr').length) { |
| document.getElementById('previewWrap').style.display = 'none'; |
| } |
| } |
| |
| function submitBatch() { |
| const btn = document.getElementById('submitBtn'); |
| const mode = document.querySelector('input[name="dupmode"]:checked').value; |
| const rows = document.querySelectorAll('#previewBody tr'); |
| btn.disabled = true; |
| btn.textContent = '上傳中...'; |
| const results = document.getElementById('results'); |
| results.innerHTML = '<div class="text-muted small">處理中...</div>'; |
| |
| const fd = new FormData(); |
| fd.append('mode', mode); |
| rows.forEach(tr => { |
| const idx = parseInt(tr.dataset.idx); |
| const chart = tr.querySelector('[data-field="chart"]').value.trim(); |
| const name = tr.querySelector('[data-field="name"]').value.trim(); |
| const f = selectedFiles[idx]; |
| fd.append('file', f); |
| fd.append('chart', chart); |
| fd.append('name', name); |
| }); |
| |
| fetch('/batch_upload', { method: 'POST', body: fd }) |
| .then(r => r.json()) |
| .then(d => { |
| btn.disabled = false; |
| btn.textContent = '全部上傳'; |
| let html = '<table class="table table-sm table-bordered"><thead><tr><th>檔名</th><th>結果</th></tr></thead><tbody>'; |
| (d.results || []).forEach(r => { |
| let cls = 'status-ok', msg = '成功新增'; |
| if (r.status === 'error') { cls = 'status-err'; msg = r.msg || '失敗'; } |
| else if (r.status === 'duplicate') { cls = 'status-dup'; msg = '重複,已複寫'; } |
| else if (r.status === 'skipped') { cls = 'status-dup'; msg = '重複,跳過不新增'; } |
| html += `<tr><td class="small">${r.file}</td><td class="${cls}">${msg}</td></tr>`; |
| }); |
| html += '</tbody></table>'; |
| html += `<div class="mt-2">成功 ${d.summary.ok}、重複 ${d.summary.dup || 0}、失敗 ${d.summary.err}(共 ${d.summary.total})</div>`; |
| if (d.summary.ok > 0) html += '<a href="/history" class="btn btn-sm btn-outline-primary mt-2">查看歷史紀錄</a>'; |
| results.innerHTML = html; |
| }) |
| .catch(e => { |
| btn.disabled = false; |
| btn.textContent = '全部上傳'; |
| results.innerHTML = '<div class="alert alert-danger">上傳失敗:' + e + '</div>'; |
| }); |
| } |
| |
| |
| const dz = document.getElementById('dropZone'); |
| ['dragenter', 'dragover'].forEach(ev => dz.addEventListener(ev, e => { e.preventDefault(); dz.classList.add('dragover'); })); |
| ['dragleave', 'drop'].forEach(ev => dz.addEventListener(ev, e => { e.preventDefault(); dz.classList.remove('dragover'); })); |
| dz.addEventListener('drop', e => { |
| const dt = new DataTransfer(); |
| Array.from(e.dataTransfer.files).forEach(f => dt.items.add(f)); |
| document.getElementById('fileInput').files = dt.files; |
| onPick(document.getElementById('fileInput')); |
| }); |
| </script> |
| </body> |
| </html> |
|
|