|
@@ -35,7 +35,7 @@
|
|
|
view: localStorage.getItem('ops.view') === 'cards' ? 'cards' : 'table',
|
|
view: localStorage.getItem('ops.view') === 'cards' ? 'cards' : 'table',
|
|
|
from: '',
|
|
from: '',
|
|
|
to: '',
|
|
to: '',
|
|
|
- upload: { file: null, busy: false },
|
|
|
|
|
|
|
+ upload: { files: [], busy: false },
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
function esc(value) {
|
|
function esc(value) {
|
|
@@ -417,14 +417,59 @@
|
|
|
return select.value;
|
|
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() {
|
|
function openUpload() {
|
|
|
const modal = $('opsUploadModal');
|
|
const modal = $('opsUploadModal');
|
|
|
if (!modal) return;
|
|
if (!modal) return;
|
|
|
- state.upload.file = null;
|
|
|
|
|
- $('opsFileName').textContent = '';
|
|
|
|
|
|
|
+ state.upload.files = [];
|
|
|
|
|
+ renderFileList();
|
|
|
$('opsUploadResult').innerHTML = '';
|
|
$('opsUploadResult').innerHTML = '';
|
|
|
$('opsYear').value = String(new Date().getFullYear());
|
|
$('opsYear').value = String(new Date().getFullYear());
|
|
|
fillSchoolOptions();
|
|
fillSchoolOptions();
|
|
|
|
|
+ if ($('opsForce')) $('opsForce').checked = false;
|
|
|
$('opsUploadStart').disabled = false;
|
|
$('opsUploadStart').disabled = false;
|
|
|
modal.hidden = 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;
|
|
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;
|
|
return;
|
|
|
}
|
|
}
|
|
|
- const result = $('opsUploadResult');
|
|
|
|
|
const year = Number($('opsYear').value) || new Date().getFullYear();
|
|
const year = Number($('opsYear').value) || new Date().getFullYear();
|
|
|
|
|
+ const school = selectedSchool();
|
|
|
|
|
+ const force = !!(forceOverride || ($('opsForce') && $('opsForce').checked));
|
|
|
$('opsUploadStart').disabled = true;
|
|
$('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.from = '';
|
|
|
state.to = '';
|
|
state.to = '';
|
|
|
state.schools = null;
|
|
state.schools = null;
|
|
|
await init(true);
|
|
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');
|
|
const file = $('opsFile');
|
|
|
if (file) {
|
|
if (file) {
|
|
|
file.addEventListener('change', () => {
|
|
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');
|
|
const drop = $('opsDrop');
|
|
@@ -588,10 +653,8 @@
|
|
|
['dragover', 'dragenter'].forEach((ev) => drop.addEventListener(ev, (e) => { e.preventDefault(); drop.classList.add('on'); }));
|
|
['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'); }));
|
|
['dragleave', 'drop'].forEach((ev) => drop.addEventListener(ev, (e) => { e.preventDefault(); drop.classList.remove('on'); }));
|
|
|
drop.addEventListener('drop', (e) => {
|
|
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(); });
|
|
drop.addEventListener('click', () => { const input = $('opsFile'); if (input) input.click(); });
|
|
|
}
|
|
}
|