207 lines
10 KiB
HTML
207 lines
10 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="zh-CN">
|
|
<head>
|
|
<meta charset="utf-8"/>
|
|
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
|
<title>中心库商品采集器</title>
|
|
<style>
|
|
* { box-sizing: border-box; }
|
|
body { font-family: -apple-system, "Microsoft YaHei", Arial, sans-serif; margin: 0; background:#f4f6f9; color:#222; }
|
|
header { background:#1f6feb; color:#fff; padding:14px 22px; font-size:18px; font-weight:600; }
|
|
.wrap { max-width:1080px; margin:18px auto; padding:0 16px; }
|
|
.card { background:#fff; border:1px solid #e3e8ef; border-radius:10px; padding:18px 20px; margin-bottom:16px; }
|
|
.card h3 { margin:0 0 12px; font-size:15px; color:#1f6feb; }
|
|
label { display:block; font-size:13px; color:#555; margin:8px 0 4px; }
|
|
input[type=text], input[type=number], textarea, select {
|
|
width:100%; padding:8px 10px; border:1px solid #cdd5e0; border-radius:6px; font-size:14px;
|
|
}
|
|
textarea { height:90px; font-family:monospace; }
|
|
.row { display:flex; gap:14px; flex-wrap:wrap; }
|
|
.row > div { flex:1; min-width:160px; }
|
|
.tabs { display:flex; gap:8px; margin-bottom:12px; }
|
|
.tab { padding:7px 16px; border:1px solid #cdd5e0; border-radius:20px; cursor:pointer; font-size:13px; background:#fff; }
|
|
.tab.active { background:#1f6feb; color:#fff; border-color:#1f6feb; }
|
|
button.primary { background:#1f6feb; color:#fff; border:none; padding:10px 22px; border-radius:6px; font-size:14px; cursor:pointer; }
|
|
button.danger { background:#d1242f; color:#fff; border:none; padding:10px 22px; border-radius:6px; font-size:14px; cursor:pointer; }
|
|
button.ghost { background:#fff; color:#1f6feb; border:1px solid #1f6feb; padding:8px 16px; border-radius:6px; cursor:pointer; font-size:13px; }
|
|
button:disabled { opacity:.5; cursor:not-allowed; }
|
|
.stats { display:flex; gap:10px; flex-wrap:wrap; }
|
|
.stat { flex:1; min-width:90px; background:#f7f9fc; border:1px solid #e3e8ef; border-radius:8px; padding:10px; text-align:center; }
|
|
.stat .n { font-size:22px; font-weight:700; }
|
|
.stat .l { font-size:12px; color:#777; margin-top:2px; }
|
|
.bar { height:10px; background:#e3e8ef; border-radius:6px; overflow:hidden; margin:10px 0; }
|
|
.bar > div { height:100%; background:#2da44e; width:0%; transition:width .4s; }
|
|
table { width:100%; border-collapse:collapse; font-size:13px; }
|
|
th, td { text-align:left; padding:6px 8px; border-bottom:1px solid #eef1f5; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:180px; }
|
|
th { color:#888; font-weight:600; }
|
|
.hit { color:#2da44e; } .miss { color:#999; } .invalid { color:#d1242f; } .error { color:#bf8700; }
|
|
.hint { font-size:12px; color:#888; margin-top:6px; line-height:1.5; }
|
|
.est { font-size:13px; color:#1f6feb; margin-top:6px; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<header>中心库商品采集器 · bypos-collector</header>
|
|
<div class="wrap">
|
|
|
|
<div class="card">
|
|
<h3>① 规划采集范围</h3>
|
|
<div class="tabs">
|
|
<div class="tab active" data-mode="range" onclick="setMode('range')">按条码范围</div>
|
|
<div class="tab" data-mode="list" onclick="setMode('list')">按条码清单</div>
|
|
</div>
|
|
|
|
<div id="pane-range">
|
|
<div class="hint">EAN-13 国标条码共 13 位,最后一位是校验位由程序自动计算。下面填<b>前 12 位</b>(本体),程序逐个枚举并补校验位查询。常见前缀:69 开头为中国大陆。</div>
|
|
<label>快捷填充前缀(可选)</label>
|
|
<div class="row">
|
|
<div><input type="text" id="prefix" placeholder="如 690100,点下方按钮自动算区间"/></div>
|
|
<div style="flex:0"><button class="ghost" onclick="fillFromPrefix()">用前缀填充区间</button></div>
|
|
</div>
|
|
<div class="row">
|
|
<div>
|
|
<label>起始本体(12 位)</label>
|
|
<input type="text" id="start" value="690100000000" maxlength="12"/>
|
|
</div>
|
|
<div>
|
|
<label>结束本体(12 位)</label>
|
|
<input type="text" id="end" value="690100000999" maxlength="12"/>
|
|
</div>
|
|
</div>
|
|
<div class="est" id="est"></div>
|
|
</div>
|
|
|
|
<div id="pane-list" style="display:none">
|
|
<label>粘贴条码清单(每行一个,或用空格/逗号分隔)</label>
|
|
<textarea id="list" placeholder="6901028941068 6920202888883"></textarea>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h3>② 采集参数</h3>
|
|
<div class="row">
|
|
<div>
|
|
<label>并发数</label>
|
|
<input type="number" id="concurrency" value="3" min="1" max="20"/>
|
|
</div>
|
|
<div>
|
|
<label>每次请求间隔(毫秒)</label>
|
|
<input type="number" id="delay" value="300" min="0"/>
|
|
</div>
|
|
<div>
|
|
<label>输出文件名</label>
|
|
<input type="text" id="outfile" value="products.jsonl"/>
|
|
</div>
|
|
</div>
|
|
<label style="margin-top:12px"><input type="checkbox" id="logmiss"/> 同时记录未命中/无效条码(默认只存命中)</label>
|
|
<div class="hint">速度越快越容易触发上游频控。建议并发 3、间隔 300ms 起步,稳定后再调。已采过的条码会自动跳过(断点续采)。</div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h3>③ 运行</h3>
|
|
<div style="margin-bottom:12px">
|
|
<button class="primary" id="btnStart" onclick="start()">开始采集</button>
|
|
<button class="danger" id="btnStop" onclick="stop()" disabled>停止</button>
|
|
<button class="ghost" onclick="window.open('/api/download')">下载 JSONL</button>
|
|
<button class="ghost" onclick="window.open('/api/export.csv')">导出 CSV(Excel)</button>
|
|
</div>
|
|
<div class="bar"><div id="prog"></div></div>
|
|
<div class="stats">
|
|
<div class="stat"><div class="n" id="s-done">0</div><div class="l">已处理</div></div>
|
|
<div class="stat"><div class="n" id="s-total">0</div><div class="l">总计</div></div>
|
|
<div class="stat"><div class="n hit" id="s-hits">0</div><div class="l">命中</div></div>
|
|
<div class="stat"><div class="n miss" id="s-miss">0</div><div class="l">未命中</div></div>
|
|
<div class="stat"><div class="n invalid" id="s-invalid">0</div><div class="l">无效</div></div>
|
|
<div class="stat"><div class="n error" id="s-errors">0</div><div class="l">错误</div></div>
|
|
<div class="stat"><div class="n" id="s-skipped">0</div><div class="l">跳过</div></div>
|
|
</div>
|
|
<div class="hint" id="msg"></div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h3>④ 实时结果(最近 60 条)</h3>
|
|
<div style="max-height:340px; overflow:auto">
|
|
<table>
|
|
<thead><tr><th>条码</th><th>品名</th><th>规格</th><th>单位</th><th>产地</th><th>进价</th><th>零售价</th><th>状态</th></tr></thead>
|
|
<tbody id="rows"></tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
let mode = 'range';
|
|
function setMode(m){
|
|
mode = m;
|
|
document.querySelectorAll('.tab').forEach(t=>t.classList.toggle('active', t.dataset.mode===m));
|
|
document.getElementById('pane-range').style.display = m==='range'?'block':'none';
|
|
document.getElementById('pane-list').style.display = m==='list'?'block':'none';
|
|
}
|
|
function fillFromPrefix(){
|
|
let p = document.getElementById('prefix').value.replace(/[^0-9]/g,'');
|
|
if(!p){ alert('请先填前缀'); return; }
|
|
if(p.length>=12){ alert('前缀太长,应少于 12 位'); return; }
|
|
let pad = 12 - p.length;
|
|
document.getElementById('start').value = p + '0'.repeat(pad);
|
|
document.getElementById('end').value = p + '9'.repeat(pad);
|
|
updateEst();
|
|
}
|
|
function updateEst(){
|
|
let s = document.getElementById('start').value.replace(/[^0-9]/g,'');
|
|
let e = document.getElementById('end').value.replace(/[^0-9]/g,'');
|
|
if(s.length===12 && e.length===12){
|
|
let n = (BigInt(e) - BigInt(s)) + 1n;
|
|
document.getElementById('est').textContent = '本次将查询约 ' + n.toString() + ' 个条码';
|
|
} else {
|
|
document.getElementById('est').textContent = '';
|
|
}
|
|
}
|
|
document.getElementById('start').addEventListener('input', updateEst);
|
|
document.getElementById('end').addEventListener('input', updateEst);
|
|
updateEst();
|
|
|
|
async function start(){
|
|
let body = {
|
|
mode: mode,
|
|
start_body: document.getElementById('start').value.trim(),
|
|
end_body: document.getElementById('end').value.trim(),
|
|
list: document.getElementById('list').value,
|
|
concurrency: parseInt(document.getElementById('concurrency').value)||3,
|
|
delay_ms: parseInt(document.getElementById('delay').value)||0,
|
|
log_miss: document.getElementById('logmiss').checked,
|
|
out_file: document.getElementById('outfile').value.trim()
|
|
};
|
|
let r = await fetch('/api/start', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body)});
|
|
let j = await r.json();
|
|
if(j.error){ alert('启动失败: ' + j.error); return; }
|
|
}
|
|
async function stop(){ await fetch('/api/stop', {method:'POST'}); }
|
|
|
|
function esc(s){ return (s||'').replace(/[&<>]/g, c=>({'&':'&','<':'<','>':'>'}[c])); }
|
|
|
|
async function poll(){
|
|
try{
|
|
let r = await fetch('/api/stats'); let j = await r.json();
|
|
let s = j.stats;
|
|
document.getElementById('s-done').textContent = s.done;
|
|
document.getElementById('s-total').textContent = s.total;
|
|
document.getElementById('s-hits').textContent = s.hits;
|
|
document.getElementById('s-miss').textContent = s.miss;
|
|
document.getElementById('s-invalid').textContent = s.invalid;
|
|
document.getElementById('s-errors').textContent = s.errors;
|
|
document.getElementById('s-skipped').textContent = s.skipped;
|
|
let pct = s.total>0 ? Math.floor(s.done*100/s.total) : 0;
|
|
document.getElementById('prog').style.width = pct + '%';
|
|
document.getElementById('msg').textContent = (s.running? ('采集中… 当前 '+s.current) : (s.message||'空闲'));
|
|
document.getElementById('btnStart').disabled = s.running;
|
|
document.getElementById('btnStop').disabled = !s.running;
|
|
let rows = (j.recent||[]).slice().reverse().map(p=>
|
|
'<tr><td>'+esc(p.barcode)+'</td><td>'+esc(p.name)+'</td><td>'+esc(p.spec)+'</td><td>'+esc(p.unit)+'</td><td>'+esc(p.area)+'</td><td>'+esc(p.in_price)+'</td><td>'+esc(p.sell_price)+'</td><td class="'+p.status+'">'+esc(p.status)+'</td></tr>'
|
|
).join('');
|
|
document.getElementById('rows').innerHTML = rows;
|
|
}catch(e){}
|
|
}
|
|
setInterval(poll, 1000); poll();
|
|
</script>
|
|
</body>
|
|
</html>
|