Pārlūkot izejas kodu

feat: 店铺运营支持一次上传多个 xlsx

- 文件选择/拖拽支持多选(一次最多 30 个),逐个上传并显示每个文件的结果
- 列表可移除单个文件;上传中禁删
- 新增「覆盖已导入的文件」勾选:勾上则同文件重导替换旧批次,否则重复文件跳过并提示
- 底部汇总:成功 N 个 / 跳过 N 个 / 失败 N 个
liangzhao 2 nedēļas atpakaļ
vecāks
revīzija
c73be89651
3 mainītis faili ar 123 papildinājumiem un 49 dzēšanām
  1. 5 4
      public/index.html
  2. 108 45
      public/ops.js
  3. 10 0
      public/style.css

+ 5 - 4
public/index.html

@@ -1081,11 +1081,11 @@
     </div>
     <div class="modal-body">
       <div class="ops-drop" id="opsDrop">
-        <b>拖拽 .xlsx 文件到这里</b>
-        <small>或点击选择文件;支持商家后台导出的人工统计表</small>
+        <b>拖拽 .xlsx 文件到这里(可多选)</b>
+        <small>或点击选择文件;支持商家后台导出的人工统计表,一次最多 30 个</small>
       </div>
-      <input type="file" id="opsFile" accept=".xlsx" hidden>
-      <div class="ops-file-name" id="opsFileName"></div>
+      <input type="file" id="opsFile" accept=".xlsx" multiple hidden>
+      <div class="ops-file-list" id="opsFileList"></div>
       <div class="ops-field">
         <label for="opsYear">数据年份</label>
         <input type="number" class="ops-input" id="opsYear" min="2000" max="2100" value="2026">
@@ -1100,6 +1100,7 @@
         <input type="text" class="ops-input" id="opsSchoolCustom" placeholder="输入学校名称" hidden>
         <small>表格里没有「学校名称」列,或想把这批数据归到指定学校时,在这里选或手动填</small>
       </div>
+      <label class="ops-check"><input type="checkbox" id="opsForce"> 覆盖已导入的文件(同一文件重新导入时替换旧数据)</label>
       <div class="ops-upload-result" id="opsUploadResult"></div>
     </div>
     <div class="modal-footer">

+ 108 - 45
public/ops.js

@@ -35,7 +35,7 @@
     view: localStorage.getItem('ops.view') === 'cards' ? 'cards' : 'table',
     from: '',
     to: '',
-    upload: { file: null, busy: false },
+    upload: { files: [], busy: false },
   };
 
   function esc(value) {
@@ -417,14 +417,59 @@
     return select.value;
   }
 
+  function fileKey(f) {
+    return f.name + '|' + f.size + '|' + (f.lastModified || 0);
+  }
+
+  function addFiles(fileList) {
+    for (const f of Array.from(fileList || [])) {
+      if (!/\.xlsx$/i.test(f.name)) continue;
+      const key = fileKey(f);
+      if (state.upload.files.some((x) => fileKey(x.file) === key)) continue;
+      state.upload.files.push({ file: f, status: 'pending', message: '' });
+    }
+    renderFileList();
+  }
+
+  function renderFileList() {
+    const box = $('opsFileList');
+    if (!box) return;
+    if (!state.upload.files.length) {
+      box.innerHTML = '';
+      return;
+    }
+    box.innerHTML = state.upload.files.map((item, i) => {
+      const cls = item.status === 'error' ? 'ops-error'
+        : item.status === 'done' ? 'ops-done-text'
+          : item.status === 'duplicate' ? 'ops-muted' : '';
+      const msg = item.message ? `<em class="${cls}">${esc(item.message)}</em>` : '';
+      const removable = item.status !== 'uploading';
+      return `<div class="ops-file-item">
+        <span class="ops-file-name2" title="${esc(item.file.name)}">${esc(item.file.name)}</span>
+        <span class="ops-file-size">${(item.file.size / 1024).toFixed(0)} KB</span>
+        ${msg}
+        ${removable ? `<button type="button" class="ops-file-del" data-idx="${i}" title="移除">×</button>` : ''}
+      </div>`;
+    }).join('');
+    box.querySelectorAll('[data-idx]').forEach((btn) => {
+      btn.addEventListener('click', () => {
+        const i = Number(btn.dataset.idx);
+        if (state.upload.files[i] && state.upload.files[i].status === 'uploading') return;
+        state.upload.files.splice(i, 1);
+        renderFileList();
+      });
+    });
+  }
+
   function openUpload() {
     const modal = $('opsUploadModal');
     if (!modal) return;
-    state.upload.file = null;
-    $('opsFileName').textContent = '';
+    state.upload.files = [];
+    renderFileList();
     $('opsUploadResult').innerHTML = '';
     $('opsYear').value = String(new Date().getFullYear());
     fillSchoolOptions();
+    if ($('opsForce')) $('opsForce').checked = false;
     $('opsUploadStart').disabled = false;
     modal.hidden = false;
   }
@@ -446,53 +491,73 @@
     });
   }
 
-  async function doUpload(force) {
-    const file = state.upload.file;
-    if (!file) {
-      $('opsUploadResult').innerHTML = '<span class="ops-error">请先选择 .xlsx 文件</span>';
+  async function doUpload(forceOverride) {
+    const items = state.upload.files;
+    const result = $('opsUploadResult');
+    if (!items.length) {
+      result.innerHTML = '<span class="ops-error">请先选择 .xlsx 文件(可一次选多个)</span>';
       return;
     }
-    if (!/\.xlsx$/i.test(file.name)) {
-      $('opsUploadResult').innerHTML = '<span class="ops-error">只支持 .xlsx 文件</span>';
+    if (items.length > 30) {
+      result.innerHTML = '<span class="ops-error">一次最多 30 个文件</span>';
       return;
     }
-    const result = $('opsUploadResult');
     const year = Number($('opsYear').value) || new Date().getFullYear();
+    const school = selectedSchool();
+    const force = !!(forceOverride || ($('opsForce') && $('opsForce').checked));
     $('opsUploadStart').disabled = true;
-    result.innerHTML = '<span class="ops-muted">正在解析并入库…</span>';
-    try {
-      const base64 = await readFileBase64(file);
-      const { res, body } = await api('/api/ops/upload', {
-        method: 'POST',
-        headers: { 'Content-Type': 'application/json' },
-        body: JSON.stringify({ fileName: file.name, contentBase64: base64, year, school, force: !!force }),
-      });
-      if (res.status === 409) {
-        result.innerHTML = `<span class="ops-error">该文件已导入过(uploadId ${esc(body && body.uploadId)})</span>
-          <button type="button" class="btn btn-ghost btn-small" id="opsForceUpload" style="margin-left:10px">仍然导入</button>`;
-        $('opsForceUpload').addEventListener('click', () => doUpload(true));
-        $('opsUploadStart').disabled = false;
-        return;
-      }
-      if (!res.ok) {
-        const detail = body && (body.detail ? `${body.error}:${body.detail}` : body.error);
-        result.innerHTML = `<span class="ops-error">${esc(detail || ('导入失败 HTTP ' + res.status))}</span>`;
-        $('opsUploadStart').disabled = false;
-        return;
+    result.innerHTML = '<span class="ops-muted">正在逐个解析并入库…</span>';
+
+    let okCount = 0;
+    let skipCount = 0;
+    let failCount = 0;
+    for (const item of items) {
+      if (item.status === 'done') continue;
+      item.status = 'uploading';
+      item.message = '导入中…';
+      renderFileList();
+      try {
+        const base64 = await readFileBase64(item.file);
+        const { res, body } = await api('/api/ops/upload', {
+          method: 'POST',
+          headers: { 'Content-Type': 'application/json' },
+          body: JSON.stringify({ fileName: item.file.name, contentBase64: base64, year, school, force }),
+        });
+        if (res.status === 409) {
+          item.status = 'duplicate';
+          item.message = '已导入过(跳过)';
+          skipCount += 1;
+        } else if (!res.ok) {
+          const detail = body && (body.detail ? body.error + ':' + body.detail : body.error);
+          item.status = 'error';
+          item.message = detail || ('失败 HTTP ' + res.status);
+          failCount += 1;
+        } else {
+          const r = body.report || {};
+          item.status = 'done';
+          item.message = r.rows + ' 行 · ' + r.shops + ' 家';
+          okCount += 1;
+        }
+      } catch (e) {
+        item.status = 'error';
+        item.message = e.message || String(e);
+        failCount += 1;
       }
-      const r = body.report || {};
-      const warn = (body.warnings || []).slice(0, 5).map((w) => `<li>第 ${w.row} 行 ${esc(w.column)}:${esc(w.raw || '空')}(${esc(w.reason)})</li>`).join('');
-      result.innerHTML = `<div class="ops-ok">
-        <b>导入完成</b>:${r.rows} 行 · ${r.shops} 家门店 · ${r.dates ? shortDate(r.dates.from) + ' ~ ' + shortDate(r.dates.to) : ''} · 覆盖 ${r.overwritten} 行 · 警告 ${r.warningCount} 条
-        ${warn ? `<ul class="ops-warn-list">${warn}</ul>` : ''}
-      </div>`;
+      renderFileList();
+    }
+
+    const parts = ['成功 ' + okCount + ' 个'];
+    if (skipCount) parts.push('跳过 ' + skipCount + ' 个(已导入过)');
+    if (failCount) parts.push('失败 ' + failCount + ' 个');
+    result.innerHTML = `<div class="${failCount ? 'ops-error' : 'ops-ok'}">${parts.join(',')}${
+      skipCount && !force ? '<br><small>已存在的文件可勾选下方「覆盖已导入的文件」后重新导入</small>' : ''
+    }</div>`;
+    $('opsUploadStart').disabled = false;
+    if (okCount) {
       state.from = '';
       state.to = '';
       state.schools = null;
       await init(true);
-    } catch (e) {
-      result.innerHTML = `<span class="ops-error">${esc(e.message || e)}</span>`;
-      $('opsUploadStart').disabled = false;
     }
   }
 
@@ -579,8 +644,8 @@
     const file = $('opsFile');
     if (file) {
       file.addEventListener('change', () => {
-        state.upload.file = file.files && file.files[0] ? file.files[0] : null;
-        $('opsFileName').textContent = state.upload.file ? state.upload.file.name : '';
+        addFiles(file.files);
+        file.value = '';
       });
     }
     const drop = $('opsDrop');
@@ -588,10 +653,8 @@
       ['dragover', 'dragenter'].forEach((ev) => drop.addEventListener(ev, (e) => { e.preventDefault(); drop.classList.add('on'); }));
       ['dragleave', 'drop'].forEach((ev) => drop.addEventListener(ev, (e) => { e.preventDefault(); drop.classList.remove('on'); }));
       drop.addEventListener('drop', (e) => {
-        const f = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0];
-        if (!f) return;
-        state.upload.file = f;
-        $('opsFileName').textContent = f.name;
+        if (!e.dataTransfer || !e.dataTransfer.files) return;
+        addFiles(e.dataTransfer.files);
       });
       drop.addEventListener('click', () => { const input = $('opsFile'); if (input) input.click(); });
     }

+ 10 - 0
public/style.css

@@ -1251,3 +1251,13 @@ main.content { padding: var(--page-padding-y) var(--page-padding-x) var(--page-p
 .ops-select { width: 360px; max-width: 100%; height: 32px; }
 #opsSchoolCustom { width: 360px; max-width: 100%; }
 .ops-field .ops-input[type="number"] { width: 140px; }
+.ops-file-list { margin-top: 10px; display: flex; flex-direction: column; gap: 6px; max-height: 200px; overflow: auto; }
+.ops-file-item { display: flex; align-items: center; gap: 8px; padding: 7px 10px; font-size: 12.5px; background: var(--bg); border: 1px solid var(--border-light); border-radius: 8px; }
+.ops-file-name2 { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text); }
+.ops-file-size { flex: 0 0 auto; color: var(--text-3); font-size: 11.5px; }
+.ops-file-item em { font-style: normal; flex: 0 0 auto; font-size: 11.5px; }
+.ops-done-text { color: #2e7d4f; }
+.ops-file-del { flex: 0 0 auto; width: 20px; height: 20px; line-height: 1; padding: 0; color: var(--text-3); background: none; border: 0; border-radius: 50%; cursor: pointer; }
+.ops-file-del:hover { color: var(--text); background: var(--border-light); }
+.ops-check { display: flex; align-items: center; gap: 7px; margin-top: 14px; font-size: 12.5px; color: var(--text-2); cursor: pointer; }
+.ops-check input { accent-color: var(--accent); }