增加日志系统等

This commit is contained in:
yml2213
2026-06-19 10:57:42 +08:00
parent 107af6dd60
commit dc401afd26
4 changed files with 451 additions and 157 deletions
+226 -142
View File
@@ -18,10 +18,13 @@
.left-list .prod-item img{width:40px;height:40px;border-radius:4px;object-fit:cover;background:#0d1117}
.left-list .prod-item .pinfo{flex:1;min-width:0}
.left-list .prod-item .pname{font-size:13px;font-weight:bold;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.left-list .prod-item .pmeta{font-size:11px;color:#8b949e;display:flex;gap:8px}
.pmeta .pscore{color:#d2991d}
.pmeta .pstock{color:#7ee787}
.pmeta .pstock.low{color:#f78166}
.left-list .prod-item .pmeta{font-size:11px;color:#8b949e;display:flex;gap:8px}
.pmeta .pscore{color:#d2991d}
.pmeta .pstock{color:#7ee787}
.pmeta .pstock.low{color:#f78166}
.product-meta{padding:6px 2px 0;color:#8b949e;font-size:12px;line-height:1.5}
.product-meta.good{color:#7ee787}
.product-meta.warn{color:#f0883e}
/* ========== 右侧主区域 ========== */
.right{flex:1;display:flex;flex-direction:column;overflow:hidden;position:relative}
@@ -89,10 +92,11 @@
<div class="toast-container" id="toastContainer"></div>
<!-- ==================== 左侧商品列表 ==================== -->
<div class="left">
<div class="left-header">
<button class="btn btn-blue" onclick="loadProducts()" style="width:100%">🔄 加载商品</button>
</div>
<div class="left">
<div class="left-header">
<button class="btn btn-blue" id="btnLoadProducts" onclick="loadProducts(true)" style="width:100%">🔄 刷新商品</button>
<div class="product-meta" id="productMeta">库存未刷新</div>
</div>
<div class="left-list" id="prodList">
<div style="text-align:center;color:#8b949e;padding:40px 10px;font-size:13px">点击"加载商品"获取列表</div>
</div>
@@ -149,9 +153,10 @@
</div>
<script>
let accounts=[], products=[], selProdId=null, selProdName='', selAccs=new Set();
let scheduleActive=false, countdownTimer=null, lastResults={};
let statusMap={}; // ← 加这行
let accounts=[], products=[], productInfo=null, selProdId=null, selProdName='', selAccs=new Set();
let scheduleActive=false, countdownTimer=null, lastResults={};
let lastLogId=0;
let statusMap={}; // ← 加这行
// init 初始化页面状态:恢复本地缓存、加载账号和商品、启动时间与结果轮询。
async function init(){
@@ -161,10 +166,11 @@
let savedProdName=localStorage.getItem('selProdName');
let savedTime=localStorage.getItem('targetTime');
let savedAdv=localStorage.getItem('advance');
let savedStatus=localStorage.getItem('statusMap'); // ← 提前读取
if(savedAccs) selAccs=new Set(JSON.parse(savedAccs));
if(savedProd){ selProdId=savedProd; selProdName=savedProdName||''; document.getElementById('selProd').textContent=selProdName; }
let savedStatus=localStorage.getItem('statusMap'); // ← 提前读取
clearLegacyProductBindings();
if(savedAccs) selAccs=new Set(JSON.parse(savedAccs));
if(savedProd){ selProdId=savedProd; selProdName=savedProdName||''; document.getElementById('selProd').textContent=selProdName; }
if(savedTime) document.getElementById('targetTime').value=savedTime;
if(savedAdv) document.getElementById('advance').value=savedAdv;
if(savedStatus) statusMap=JSON.parse(savedStatus); // ← 提前恢复
@@ -172,7 +178,7 @@
await loadAccounts();
await loadProducts(); // ← 加这行,启动自动加载商品
await loadProducts(false);
updateTime();
@@ -183,11 +189,13 @@
// statusMap={}; // ← 加这行 这两行删除就保存成功失败得状态
// 清空后端旧结果
await fetch('/api/clear-results', {method:'POST'});
await apiJSON('/api/clear-results', {method:'POST'});
setInterval(updateTime,1000);
setInterval(checkResults,2000);
}
setInterval(updateTime,1000);
setInterval(checkResults,2000);
setInterval(fetchServerLogs,2000);
fetchServerLogs();
}
// updateTime 从后端获取斗鱼服务端时间,并刷新顶部时间展示。
function updateTime(){
@@ -195,15 +203,25 @@
fetch('/api/time').then(r=>r.json()).then(d=>{
document.getElementById('svrTime').textContent=d.serverTime||'--';
}).catch(()=>{});
}
}
// apiJSON 统一处理本地接口响应,HTTP 失败时抛出后端返回的错误文案。
async function apiJSON(url, options) {
let r = await fetch(url, options);
let text = await r.text();
let data = text ? JSON.parse(text) : {};
if (!r.ok) {
throw new Error(data.error || `HTTP ${r.status}`);
}
return data;
}
// reloadAccounts 通知后端重新读取 user.txt,然后刷新前端账号表格。
async function reloadAccounts(){
let r=await fetch('/api/reload-accounts');
let d=await r.json();
await loadAccounts();
toast('✅ 账号已刷新',`共 ${d.count} 个账号`,'success');
}
let d=await apiJSON('/api/reload-accounts');
await loadAccounts();
toast('✅ 账号已刷新',`共 ${d.count} 个账号`,'success');
}
// queryAllScore 并发查询所有账号积分,并把积分按账号名缓存到 localStorage。
async function queryAllScore() {
@@ -217,13 +235,12 @@
console.log('账号对象:', JSON.stringify(a));
console.log('Cookie长度:', a.cookie?.length, '前50字符:', a.cookie?.substring(0, 50));
let r = await fetch('/api/query-score', {
method: 'POST',
headers: { 'Content-Type': 'text/plain' },
body: a.cookie
});
let d = await r.json();
let score = (d.score !== undefined && d.score !== null && d.score !== '') ? d.score : 'N/A';
let d = await apiJSON('/api/query-score', {
method: 'POST',
headers: { 'Content-Type': 'text/plain' },
body: a.cookie
});
let score = (d.score !== undefined && d.score !== null && d.score !== '') ? d.score : 'N/A';
// ✅ 用账号名存积分
localStorage.setItem('accScore_' + a.name, score); // ← 改成 a.name
let el = document.getElementById('score_' + idx);
@@ -257,52 +274,89 @@
}
// ==================== 商品 ====================
// loadProducts 触发后端加载斗鱼商品接口,然后拉取缓存商品并重新渲染列表。
async function loadProducts(){
addLog('正在从斗鱼加载商品...','i');
try{
await fetch('/api/load-products');
let r=await fetch('/api/products'); products=await r.json();
renderProducts();
addLog(`✅ 加载完成,共 ${products.length} 个商品`,'s');
toast('✅ 加载成功',`共加载 ${products.length} 个商品`,'success');
}catch(e){
addLog('加载失败,请检查网络','e');
toast('❌ 加载失败','请检查网络连接','error');
}
}
// loadProducts 从后端实时刷新斗鱼商品;失败时不读取旧缓存,避免库存误判。
async function loadProducts(showToast = true){
let btn = document.getElementById('btnLoadProducts');
let meta = document.getElementById('productMeta');
btn.disabled = true;
btn.textContent = '刷新中...';
meta.className = 'product-meta';
meta.textContent = '正在请求斗鱼商品接口';
addLog('正在实时刷新商品...','i');
try{
let d = await apiJSON('/api/products?refresh=1');
products = Array.isArray(d.products) ? d.products : [];
productInfo = d;
if (selProdId && !products.some(p => p.commodityId === selProdId)) {
clearSelectedProduct();
}
renderProducts();
renderProductMeta();
renderAccounts();
addLog(`✅ 商品刷新完成,共 ${products.length} 个,库存时间 ${d.fetchedAt || '--'}`,'s');
if (showToast) toast('✅ 商品已刷新',`共 ${products.length} 个,库存时间 ${d.fetchedAt || '--'}`,'success');
}catch(e){
products = [];
productInfo = null;
clearSelectedProduct();
renderProducts();
renderProductMeta(e.message);
addLog('❌ 商品刷新失败: ' + e.message,'e');
toast('❌ 商品刷新失败', e.message || '请检查网络连接','error');
}finally{
btn.disabled = false;
btn.textContent = '🔄 刷新商品';
}
}
// renderProducts 根据当前商品数组绘制左侧商品列表,并标记已选商品。
function renderProducts(){
let h='';
// 遍历商品缓存,生成每个商品的点击项和库存、积分信息。
products.forEach(p=>{
h+=`<div class="prod-item${selProdId==p.commodityId?' active':''}" onclick="selProduct('${p.commodityId}','${escHtml(p.commodityName)}')">
<img src="${p.pic}" onerror="this.style.display='none'">
<div class="pinfo"><div class="pname">${p.commodityName}</div>
<div class="pmeta"><span class="pscore">💰${p.score}</span><span class="pstock${p.count<500?' low':''}">📦${p.count}</span></div></div>
</div>`;
});
document.getElementById('prodList').innerHTML=h||'<div style="text-align:center;color:#8b949e;padding:40px">暂无商品</div>';
}
// selProduct 记录当前选择的商品,并把它绑定到所有已勾选账号。
h+=`<div class="prod-item${selProdId==p.commodityId?' active':''}" onclick="selProduct('${jsAttr(p.commodityId)}','${jsAttr(p.commodityName)}')">
<img src="${p.pic}" onerror="this.style.display='none'">
<div class="pinfo"><div class="pname">${escHtml(p.commodityName)}</div>
<div class="pmeta"><span class="pscore">💰${p.score}</span><span class="pstock${p.count<500?' low':''}">📦${p.count}</span></div></div>
</div>`;
});
document.getElementById('prodList').innerHTML=h||'<div style="text-align:center;color:#8b949e;padding:40px 10px;font-size:13px">暂无实时商品数据</div>';
}
// renderProductMeta 显示库存来源和刷新时间,避免把旧库存当实时库存。
function renderProductMeta(errorMsg) {
let meta = document.getElementById('productMeta');
if (errorMsg) {
meta.className = 'product-meta warn';
meta.textContent = '刷新失败,未使用旧库存';
return;
}
if (!productInfo || !productInfo.fetchedAt) {
meta.className = 'product-meta';
meta.textContent = '库存未刷新';
return;
}
meta.className = 'product-meta ' + (productInfo.stale ? 'warn' : 'good');
meta.textContent = `实时库存 ${productInfo.count} 个,刷新于 ${productInfo.fetchedAt}`;
}
// selProduct 记录当前选择的商品,提交时会应用到所有已勾选账号。
function selProduct(id, name) {
selProdId = id;
selProdId = id;
selProdName = name;
document.getElementById('selProd').textContent = name;
// ✅ 用账号名存商品绑定
// 该回调把当前商品写入每个已选账号的本地绑定记录。
selAccs.forEach(i => {
let accountName = accounts[i].name; // 用账号名作为 key
localStorage.setItem('accProd_' + accountName, id);
localStorage.setItem('accProdName_' + accountName, name);
});
// selAccs.clear();
// localStorage.setItem('selAccs', JSON.stringify([...selAccs]));
renderProducts();
renderAccounts();
}
localStorage.setItem('selProd', id);
localStorage.setItem('selProdName', name);
renderProducts();
renderAccounts();
}
// clearSelectedProduct 清理已经不存在的商品选择。
function clearSelectedProduct() {
selProdId = null;
selProdName = '';
localStorage.removeItem('selProd');
localStorage.removeItem('selProdName');
document.getElementById('selProd').textContent = '未选';
}
// ==================== 账号 ====================
// async function loadAccounts(){
@@ -312,18 +366,17 @@
// loadAccounts 从后端获取账号列表,清理无效本地缓存后重新渲染账号表格。
async function loadAccounts() {
let r = await fetch('/api/accounts');
accounts = await r.json();
accounts = await apiJSON('/api/accounts');
// map 回调抽取账号名,用于判断 localStorage 中的旧缓存是否仍然有效。
// map 回调抽取账号名,用于判断 localStorage 中的旧积分缓存是否仍然有效。
let validNames = new Set(accounts.map(a => a.name));
for (let key in localStorage) {
// 只清理商品和积分缓存,保留 selAccs、statusMap、targetTime、advance
if (key.startsWith('accProd_') || key.startsWith('accProdName_') || key.startsWith('accScore_')) {
let name = key.substring(key.indexOf('_') + 1);
if (!validNames.has(name)) {
localStorage.removeItem(key);
for (let key in localStorage) {
// 只清理积分缓存,商品选择改为全局当前商品。
if (key.startsWith('accScore_')) {
let name = key.substring(key.indexOf('_') + 1);
if (!validNames.has(name)) {
localStorage.removeItem(key);
console.log('清理无效缓存:', key);
}
}
@@ -337,10 +390,9 @@
document.getElementById('accCnt').textContent = accounts.length;
// 将账号数组映射为表格行 HTML,行内状态从 localStorage 和 statusMap 读取。
let h = accounts.map((a, i) => {
let isSel = selAccs.has(i);
// ✅ 用账号名读取商品、积分、状态
let prodName = localStorage.getItem('accProdName_' + a.name) || '-';
let prodTag = prodName !== '-' ? `<span class="prod-tag">${prodName}</span>` : '-';
let isSel = selAccs.has(i);
// 商品列展示当前选中商品,提交时会应用到所有勾选账号。
let prodTag = selProdName ? `<span class="prod-tag">${escHtml(selProdName)}</span>` : '-';
let scoreVal = localStorage.getItem('accScore_' + a.name) || '-'; // ← 改成 a.name
let st = statusMap[a.name]; // ← 已经是 a.name,保持
let statusStr = '-';
@@ -390,15 +442,14 @@
if(!ap) return;
addLog(`⚡ 立即抢购: ${Object.keys(ap).length}个账号 → ${selProdName}`,'i');
toast('⚡ 抢购中...',`${Object.keys(ap).length} 个账号正在抢购 ${selProdName}`,'info',3000);
let r=await fetch('/api/seckill',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({accountProducts:ap})});
let d=await r.json();
if(d.success){
try {
let d=await apiJSON('/api/seckill',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({accountProducts:ap})});
addLog(d.msg||'已提交','i');
}else{
addLog('❌ 提交失败: '+(d.error||'未知错误'),'e');
toast('❌ 提交失败',d.error||'未知错误','error');
} catch(e) {
addLog('❌ 提交失败: '+e.message,'e');
toast('❌ 提交失败',e.message,'error');
}
}
}
// ==================== 定时抢购 ====================
// startSchedule 构建定时参数并提交给后端,由后端按服务端时间安排抢购。
@@ -414,30 +465,29 @@
let adv=parseInt(document.getElementById('advance').value)||300;
addLog(`⏰ 设置定时: ${fullTime}, 提前=${adv}ms`,'i');
let r=await fetch('/api/schedule',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({accountProducts:ap,targetTime:fullTime,advance:adv})});
let d=await r.json();
if(d.success){
scheduleActive=true;
// 保存定时信息
localStorage.setItem('targetTime',timeStr);
try {
let d=await apiJSON('/api/schedule',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({accountProducts:ap,targetTime:fullTime,advance:adv})});
scheduleActive=true;
// 保存定时信息
localStorage.setItem('targetTime',timeStr);
localStorage.setItem('advance',adv);
// 锁定:关闭自动,固定当前值
document.getElementById('btnSchedule').style.display='none';
document.getElementById('btnCancel').style.display='inline-block';
addLog(`✅ 定时已开启! 触发: ${d.triggerTime} (剩余${d.remainSecond}秒)`,'s');
toast('⏰ 定时已开启',`触发时刻: ${d.triggerTime}<br>剩余时间: <b>${formatRemain(d.remainSecond)}</b><br>商品: ${selProdName}<br>账号: ${Object.keys(ap).length} 个`,'success',5000);
startCountdown(d.remainSecond);
} else {
addLog('❌ 设置失败: '+(d.error||'未知'),'e');
toast('❌ 定时失败',d.error||'未知错误','error');
}
}
addLog(`✅ 定时已开启! 触发: ${d.triggerTime} (剩余${d.remainSecond}秒)`,'s');
toast('⏰ 定时已开启',`触发时刻: ${d.triggerTime}<br>剩余时间: <b>${formatRemain(d.remainSecond)}</b><br>商品: ${selProdName}<br>账号: ${Object.keys(ap).length} 个`,'success',5000);
startCountdown(d.remainSecond);
} catch(e) {
addLog('❌ 设置失败: '+e.message,'e');
toast('❌ 定时失败',e.message,'error');
}
}
// cancelSchedule 调用后端取消尚未触发的定时任务,并恢复前端按钮状态。
async function cancelSchedule(){
try{
await fetch('/api/cancel-schedule',{method:'POST'});
await apiJSON('/api/cancel-schedule',{method:'POST'});
}catch(e){}
scheduleActive=false;
// 清除缓存的定时
@@ -490,12 +540,13 @@
return `${h}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`;
}
// ==================== 结果刷新 ====================
// ==================== 结果刷新 ====================
// checkResults 轮询后端抢购结果,更新日志区、账号状态和本地状态缓存。
async function checkResults() {
try {
let r = await fetch('/api/results');
let d = await r.json();
let r = await fetch('/api/results');
if (!r.ok) throw new Error('结果接口异常');
let d = await r.json();
if (JSON.stringify(d) !== JSON.stringify(lastResults) && Object.keys(d).length > 0) {
lastResults = d;
let newStatusMap = {};
@@ -516,41 +567,72 @@
el.scrollTop = el.scrollHeight;
}
} catch (e) { }
}
}
// fetchServerLogs 增量拉取后端日志,把真实接口链路日志显示到前端日志区。
async function fetchServerLogs() {
try {
let d = await apiJSON('/api/logs?since=' + lastLogId);
if (!d.success || !Array.isArray(d.logs) || d.logs.length === 0) return;
d.logs.forEach(item => {
lastLogId = Math.max(lastLogId, item.id || 0);
let type = 'i';
if (item.level === 'success') type = 's';
else if (item.level === 'error') type = 'e';
else if (item.level === 'warn') type = 'w';
addServerLog(item.message, type);
});
} catch (e) { }
}
// addServerLog 追加一条后端日志,和本地操作日志共用同一个日志区域。
function addServerLog(msg, type = 'i') {
let el = document.getElementById('logBody');
el.innerHTML += `<div class="log-${type}">[后端] ${escHtml(msg)}</div>`;
el.scrollTop = el.scrollHeight;
}
// buildAP 根据已勾选账号和账号绑定商品,生成后端需要的账号到商品 ID 映射。
function buildAP() {
if (selAccs.size === 0) {
toast('⚠️ 提示', '请勾选要抢购的账号', 'warn');
if (selAccs.size === 0) {
toast('⚠️ 提示', '请勾选要抢购的账号', 'warn');
return null;
}
if (!selProdId) {
toast('⚠️ 提示', '请先选择商品', 'warn');
return null;
}
if (!productInfo || !products.some(p => p.commodityId === selProdId)) {
toast('⚠️ 提示', '请先刷新商品,确认库存是实时数据', 'warn');
return null;
}
let ap = {};
// 遍历已选账号,把存在商品绑定的账号转换成后端需要的映射。
// 遍历已选账号,把当前选中商品应用到每个账号。
selAccs.forEach(i => {
let accountName = accounts[i].name; // ← 先取账号名
let cid = localStorage.getItem('accProd_' + accountName); // ← 用账号名读
if (cid) {
ap[accountName] = cid;
}
});
if (Object.keys(ap).length === 0) {
toast('⚠️ 提示', '勾选的账号未绑定商品', 'warn');
return null;
}
return ap;
}
// hasAnyAccProd 判断当前账号列表中是否存在任意商品绑定。
function hasAnyAccProd(){
for(let i=0; i<accounts.length; i++){
if (localStorage.getItem('accProd_' + accounts[i].name)) return true;
}
return false;
}
let account = accounts[i];
if (account) {
ap[account.name] = selProdId;
}
});
if (Object.keys(ap).length === 0) {
toast('⚠️ 提示', '没有有效账号可提交', 'warn');
return null;
}
return ap;
}
// clearLegacyProductBindings 删除旧版按账号保存商品的缓存,避免残留旧商品误导界面。
function clearLegacyProductBindings() {
for (let key in localStorage) {
if (key.startsWith('accProd_') || key.startsWith('accProdName_')) {
localStorage.removeItem(key);
}
}
}
// addLog 向底部日志区追加一条带毫秒时间戳的运行日志。
function addLog(msg, type = 'i') {
let el = document.getElementById('logBody');
@@ -567,8 +649,10 @@
lastResults={};
}
// escHtml 转义商品名中的 HTML 特殊字符,避免拼接到 onclick 时破坏页面结构。
function escHtml(s){ return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
// escHtml 转义 HTML 特殊字符,避免接口数据破坏页面结构。
function escHtml(s){ return String(s ?? '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;'); }
// jsAttr 转义写入 onclick 字符串参数的数据。
function jsAttr(s){ return escHtml(String(s ?? '').replace(/\\/g,'\\\\').replace(/'/g,"\\'").replace(/\n/g,' ')); }
init();
</script>