Files
dygou_go/static/index.html
T
2026-06-19 12:10:55 +08:00

719 lines
35 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>斗鱼抢购系统</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:'Microsoft YaHei',sans-serif;background:#0d1117;color:#c9d1d9;display:flex;height:100vh}
/* ========== 左侧商品列表 ========== */
.left{width:280px;min-width:280px;background:#161b22;border-right:1px solid#30363d;display:flex;flex-direction:column}
.left-header{padding:10px;border-bottom:1px solid#30363d}
.product-search{display:flex;align-items:center;gap:6px;margin-top:8px;background:#0d1117;border:1px solid#30363d;border-radius:5px;padding:0 6px}
.product-search:focus-within{border-color:#58a6ff}
.product-search input{flex:1;min-width:0;height:32px;border:0;outline:0;background:transparent;color:#c9d1d9;font-size:13px}
.product-search input::placeholder{color:#6e7681}
.product-search .search-clear{width:22px;height:22px;border:0;border-radius:4px;background:transparent;color:#8b949e;cursor:pointer;font-size:16px;line-height:20px;display:none}
.product-search .search-clear:hover{background:#21262d;color:#c9d1d9}
.left-list{flex:1;overflow-y:auto;padding:8px}
.left-list .prod-item{display:flex;align-items:center;gap:8px;padding:8px;margin:4px 0;background:#21262d;border-radius:6px;cursor:pointer;border:2px solid transparent;transition:.2s}
.left-list .prod-item:hover{border-color:#58a6ff}
.left-list .prod-item.active{border-color:#f78166;background:#1a1f2b}
.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}
.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}
/* 工具栏 */
.toolbar{padding:8px 14px;background:#161b22;border-bottom:1px solid#30363d;display:flex;gap:8px;align-items:center;flex-wrap:wrap}
.btn{padding:6px 12px;border:1px solid#30363d;border-radius:5px;background:#21262d;color:#c9d1d9;cursor:pointer;font-size:13px;white-space:nowrap;transition:.2s}
.btn:hover{background:#30363d}
.btn-red{background:#da3633;border-color:#da3633;color:#fff;font-size:14px;padding:8px 16px}
.btn-red:hover{background:#c62828}
.btn-green{background:#238636;border-color:#238636;color:#fff;font-size:14px;padding:8px 16px}
.btn-green:hover{background:#1b6b2a}
.btn-blue{background:#1f6feb;border-color:#1f6feb;color:#fff;font-size:14px;padding:8px 16px}
.btn-blue:hover{background:#1158c7}
.btn-cancel{background:#6e7681;border-color:#6e7681;color:#fff;font-size:14px;padding:8px 16px}
.btn-cancel:hover{background:#555}
/* 定时信息栏 */
.schedule-bar{padding:10px 16px;background:#161b22;border-bottom:1px solid#30363d;display:flex;gap:16px;align-items:center;flex-wrap:wrap;font-size:14px}
.schedule-bar label{color:#8b949e;font-size:14px;font-weight:bold;white-space:nowrap}
.schedule-bar .time-box{background:#0d1117;padding:6px 14px;border-radius:4px;font-family:'Consolas',monospace;font-size:16px;font-weight:bold}
.schedule-bar .time-box.server{color:#58a6ff}
.schedule-bar .time-box.offset{color:#f0883e}
.schedule-bar input{padding:6px 12px;background:#0d1117;color:#fff;border:2px solid#30363d;border-radius:4px;font-size:16px;font-weight:bold}
.schedule-bar input#targetTime{width:150px;text-align:center}
.schedule-bar input#advance{width:80px;text-align:center}
.schedule-bar .hint{color:#8b949e;font-size:12px}
.countdown-badge{background:#da3633;color:#fff;padding:6px 16px;border-radius:4px;font-weight:bold;font-size:16px}
/* 账号表格 */
.table-wrap{flex:1;overflow-y:auto;padding:8px 14px;min-height:150px}
table{width:100%;border-collapse:collapse;font-size:13px}
th{background:#21262d;padding:8px;text-align:left;border-bottom:2px solid#30363d;position:sticky;top:0;z-index:1;color:#8b949e;font-size:13px}
td{padding:6px 8px;border-bottom:1px solid#21262d;vertical-align:middle}
tr:hover{background:#1a1f2b}
.cookie-td{max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#6e7681;font-size:11px}
.prod-tag{display:inline-block;background:#238636;color:#fff;padding:3px 10px;border-radius:3px;font-size:12px}
.status-td{font-size:16px}
/* 日志区 */
.log-wrap{border-top:2px solid#30363d;background:#0d1117;display:flex;flex-direction:column;height:200px}
.log-header{padding:8px 16px;background:#161b22;border-bottom:1px solid#30363d;display:flex;justify-content:space-between;align-items:center;font-size:13px}
.log-body{flex:1;overflow-y:auto;padding:10px 16px;font-family:'Consolas','Courier New',monospace;font-size:13px;line-height:1.8}
.log-s{color:#7ee787}
.log-e{color:#f78166}
.log-i{color:#58a6ff}
.log-w{color:#f0883e}
.log-result{background:#1a1f2b;padding:2px 8px;margin:2px 0;border-radius:3px;word-break:break-all}
.log-cat{display:inline-block;min-width:34px;text-align:center;margin-right:6px;padding:0 5px;border:1px solid#30363d;border-radius:4px;color:#8b949e;font-size:11px;line-height:1.4}
/* ========== 右侧滑入提示 ========== */
.toast-container{position:fixed;top:20px;right:-420px;z-index:9999;transition:right .4s ease}
.toast-container.show{right:20px}
.toast{background:#161b22;border:2px solid#30363d;border-radius:10px;padding:16px 20px;margin-bottom:10px;min-width:350px;box-shadow:0 8px 30px rgba(0,0,0,.5)}
.toast-title{font-size:16px;font-weight:bold;margin-bottom:6px}
.toast-body{font-size:13px;color:#8b949e;line-height:1.6}
.toast-success{border-color:#238636}.toast-success .toast-title{color:#7ee787}
.toast-info{border-color:#1f6feb}.toast-info .toast-title{color:#58a6ff}
.toast-warn{border-color:#d2991d}.toast-warn .toast-title{color:#d2991d}
.toast-error{border-color:#da3633}.toast-error .toast-title{color:#f78166}
</style>
</head>
<body>
<!-- 右侧滑入提示容器 -->
<div class="toast-container" id="toastContainer"></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-search">
<span style="color:#6e7681;font-size:13px">🔍</span>
<input id="productSearch" type="search" placeholder="搜索商品名 / ID / 积分 / 库存" autocomplete="off" oninput="setProductSearch(this.value)">
<button class="search-clear" id="btnClearProductSearch" type="button" onclick="clearProductSearch()" title="清空搜索">×</button>
</div>
<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>
</div>
<!-- ==================== 右侧 ==================== -->
<div class="right">
<div class="toolbar">
<button class="btn btn-red" onclick="startNow()">⚡ 立即抢购</button>
<button class="btn btn-green" id="btnSchedule" onclick="startSchedule()">⏰ 开启定时</button>
<button class="btn btn-cancel" id="btnCancel" onclick="cancelSchedule()" style="display:none">⏹ 停止定时</button>
<span style="color:#8b949e;font-size:14px;margin-left:8px">账号:<b id="accCnt" style="color:#58a6ff">0</b></span>
<span style="color:#8b949e;font-size:14px"> 选中商品:<b id="selProd" style="color:#f78166"> 未选</b></span>
<button class="btn btn-blue" onclick="reloadAccounts()" style="font-size:12px;padding:4px 10px">🔄 加载账号</button>
<button class="btn btn-blue" onclick="queryAllScore()" style="font-size:12px;padding:4px 10px">💰 查询积分</button>
<span id="cdBadge" class="countdown-badge" style="display:none"></span>
</div>
<div class="schedule-bar">
<label>服务器时间:</label>
<span class="time-box server" id="svrTime">--</span>
<label>目标时间:</label>
<input type="time" id="targetTime" value="18:00:00" step="1">
<label>提前(ms):</label>
<input type="number" id="advance" value="300" min="0" max="5000" step="100" style="width:90px;text-align:center">
<span class="hint">(定时触发=目标-提前量)</span>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th style="width:36px"><input type="checkbox" id="chkAll" onchange="toggleAll()"></th>
<th>账号名</th>
<th>Cookie</th>
<th>商品</th>
<th>积分</th>
<th style="width:80px">状态</th>
</tr>
</thead>
<tbody id="accTable"></tbody>
</table>
</div>
<div class="log-wrap">
<div class="log-header">
<span>📋 运行日志(自动刷新)</span>
<button class="btn" onclick="clearLog()" style="font-size:12px;padding:4px 10px">清空</button>
</div>
<div class="log-body" id="logBody">
<div class="log-i">[系统] 就绪,等待操作...</div>
</div>
</div>
</div>
<script>
let accounts=[], products=[], productInfo=null, selProdId=null, selProdName='', selAccs=new Set();
let productSearch='';
let scheduleActive=false, countdownTimer=null, lastResults={};
let lastLogId=0;
let statusMap={}; // ← 加这行
// init 初始化页面状态:恢复本地缓存、加载账号和商品、启动时间与结果轮询。
async function init(){
// 恢复缓存的选择
let savedAccs=localStorage.getItem('selAccs');
let savedProd=localStorage.getItem('selProd');
let savedProdName=localStorage.getItem('selProdName');
let savedTime=localStorage.getItem('targetTime');
let savedAdv=localStorage.getItem('advance');
let savedStatus=localStorage.getItem('statusMap'); // ← 提前读取
let savedProductSearch=localStorage.getItem('productSearch');
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); // ← 提前恢复
if(savedProductSearch) {
productSearch=savedProductSearch;
document.getElementById('productSearch').value=productSearch;
}
await loadAccounts();
await loadProducts(false);
updateTime();
clearLog(); // ← 加这行,启动时清空日志区
//localStorage.removeItem('statusMap'); // ← 加这行
// statusMap={}; // ← 加这行 这两行删除就保存成功失败得状态
// 清空后端旧结果
await apiJSON('/api/clear-results', {method:'POST'});
setInterval(updateTime,1000);
setInterval(checkResults,2000);
setInterval(fetchServerLogs,2000);
fetchServerLogs();
}
// setProductSearch 更新商品搜索词,并即时过滤左侧商品列表。
function setProductSearch(value) {
productSearch = String(value || '').trim();
if (productSearch) localStorage.setItem('productSearch', productSearch);
else localStorage.removeItem('productSearch');
renderProducts();
renderProductMeta();
}
// clearProductSearch 清空商品搜索词。
function clearProductSearch() {
productSearch = '';
localStorage.removeItem('productSearch');
document.getElementById('productSearch').value = '';
renderProducts();
renderProductMeta();
}
// filteredProducts 根据搜索词过滤商品,支持名称、ID、积分和库存。
function filteredProducts() {
let keyword = productSearch.toLowerCase();
if (!keyword) return products;
return products.filter(p => [
p.commodityName,
p.commodityId,
p.score,
p.count
].some(v => String(v ?? '').toLowerCase().includes(keyword)));
}
// updateTime 从后端获取斗鱼服务端时间,并刷新顶部时间展示。
function updateTime(){
// then 回调依次负责解析 JSON 和更新 DOM;catch 回调忽略短暂网络失败。
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 d=await apiJSON('/api/reload-accounts');
await loadAccounts();
toast('✅ 账号已刷新',`共 ${d.count} 个账号`,'success');
}
// queryAllScore 并发查询所有账号积分,并把积分按账号名缓存到 localStorage。
async function queryAllScore() {
addLog('正在并发查询积分...', 'i');
let tasks = [];
for (let i = 0; i < accounts.length; i++) {
// 每个账号单独启动一个异步查询任务,避免账号多时串行等待过久。
tasks.push((async (idx) => {
let a = accounts[idx];
try {
let d = await apiJSON('/api/query-score', {
method: 'POST',
headers: { 'Content-Type': 'text/plain' },
body: a.name
});
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);
if (el) el.textContent = score;
} catch (e) { }
})(i));
}
await Promise.all(tasks);
addLog('积分查询完成', 's');
toast('✅ 积分查询完成', '', 'success');
}
// ==================== Toast提示 ====================
// toast 创建右侧滑入提示,按类型控制颜色,并在指定时间后自动移除。
function toast(title, body, type, duration){
type=type||'info'; duration=duration||4000;
let container=document.getElementById('toastContainer');
let div=document.createElement('div');
div.className='toast toast-'+type;
div.innerHTML=`<div class="toast-title">${title}</div><div class="toast-body">${body}</div>`;
container.appendChild(div);
// 短延迟回调用于触发 CSS 过渡,让提示从右侧滑入。
setTimeout(()=>container.classList.add('show'),10);
// 外层回调控制停留时长,内层回调等待淡出动画结束后移除节点。
setTimeout(()=>{
div.style.opacity='0';
div.style.transition='opacity .3s';
setTimeout(()=>{div.remove();if(container.children.length===0)container.classList.remove('show');},300);
},duration);
}
// ==================== 商品 ====================
// 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='';
let list = filteredProducts();
document.getElementById('btnClearProductSearch').style.display = productSearch ? 'block' : 'none';
// 遍历商品缓存,生成每个商品的点击项和库存、积分信息。
list.forEach(p=>{
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>`;
});
let emptyText = productSearch ? `没有匹配“${escHtml(productSearch)}”的商品` : '暂无实时商品数据';
document.getElementById('prodList').innerHTML=h||`<div style="text-align:center;color:#8b949e;padding:40px 10px;font-size:13px">${emptyText}</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');
if (productSearch) {
meta.textContent = `实时库存 ${filteredProducts().length}/${productInfo.count} 个,刷新于 ${productInfo.fetchedAt}`;
} else {
meta.textContent = `实时库存 ${productInfo.count} 个,刷新于 ${productInfo.fetchedAt}`;
}
}
// selProduct 记录当前选择的商品,提交时会应用到所有已勾选账号。
function selProduct(id, name) {
selProdId = id;
selProdName = name;
document.getElementById('selProd').textContent = name;
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(){
// let r=await fetch('/api/accounts'); accounts=await r.json();
// renderAccounts();
// }
// loadAccounts 从后端获取账号列表,清理无效本地缓存后重新渲染账号表格。
async function loadAccounts() {
accounts = await apiJSON('/api/accounts');
// map 回调抽取账号名,用于判断 localStorage 中的旧积分缓存是否仍然有效。
let validNames = new Set(accounts.map(a => a.name));
for (let key in localStorage) {
// 只清理积分缓存,商品选择改为全局当前商品。
if (key.startsWith('accScore_')) {
let name = key.substring(key.indexOf('_') + 1);
if (!validNames.has(name)) {
localStorage.removeItem(key);
}
}
}
renderAccounts();
}
// renderAccounts 根据账号、选择状态、商品绑定、积分和抢购结果绘制账号表格。
function renderAccounts() {
document.getElementById('accCnt').textContent = accounts.length;
// 将账号数组映射为表格行 HTML,行内状态从 localStorage 和 statusMap 读取。
let h = accounts.map((a, i) => {
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 = '-';
if (st === true) statusStr = '✅';
else if (st === false) statusStr = '❌';
return `<tr>
<td>
<label style="display:flex;align-items:center;cursor:pointer" onclick="togAcc(${i})">
<div style="width:18px;height:18px;border-radius:4px;border:2px solid ${isSel ? '#238636' : '#30363d'};background:${isSel ? '#238636' : 'transparent'};display:flex;align-items:center;justify-content:center;transition:.2s">
${isSel ? '<span style="color:#fff;font-size:12px">✓</span>' : ''}
</div>
</label>
</td>
<td style="font-weight:bold">${escHtml(a.name)}</td>
<td class="cookie-td" title="${escHtml(a.cookie)}">${escHtml(a.cookie)}</td>
<td>${prodTag}</td>
<td id="score_${i}">${scoreVal}</td>
<td class="status-td" id="st_${i}">${statusStr}</td>
</tr>`;
});
document.getElementById('accTable').innerHTML = h.join('');
}
// toggleAll 根据表头复选框批量选择或取消所有账号,并持久化选择结果。
function toggleAll(){
let ck=document.getElementById('chkAll').checked;
// 批量选择时的回调把每个账号下标加入选择集合。
if(ck) accounts.forEach((_,i)=>selAccs.add(i));
else selAccs.clear();
localStorage.setItem('selAccs',JSON.stringify([...selAccs]));
renderAccounts();
}
// togAcc 切换单个账号的选择状态,并同步更新 localStorage。
function togAcc(i){
if(selAccs.has(i)) selAccs.delete(i); else selAccs.add(i);
localStorage.setItem('selAccs',JSON.stringify([...selAccs]));
renderAccounts();
}
// ==================== 立即抢购(不弹窗) ====================
// startNow 构建账号商品映射后,调用后端立即启动抢购。
async function startNow(){
let ap=buildAP();
if(!ap) return;
addLog(`⚡ 立即抢购: ${Object.keys(ap).length}个账号 → ${selProdName}`,'i');
toast('⚡ 抢购中...',`${Object.keys(ap).length} 个账号正在抢购 ${selProdName}`,'info',3000);
try {
let d=await apiJSON('/api/seckill',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({accountProducts:ap})});
addLog(d.msg||'已提交','i');
} catch(e) {
addLog('❌ 提交失败: '+e.message,'e');
toast('❌ 提交失败',e.message,'error');
}
}
// ==================== 定时抢购 ====================
// startSchedule 构建定时参数并提交给后端,由后端按服务端时间安排抢购。
async function startSchedule(){
let ap=buildAP();
if(!ap) return;
let timeStr=document.getElementById('targetTime').value.trim();
if(!timeStr){ toast('⚠️ 提示','请选择目标时间','warn'); return; }
if(timeStr.split(':').length===2) timeStr+=':00';
let today=new Intl.DateTimeFormat('zh-CN',{year:'numeric',month:'2-digit',day:'2-digit'})
.format(new Date()).replace(/\//g,'-');
let fullTime=today+' '+timeStr;
let adv=parseInt(document.getElementById('advance').value)||300;
addLog(`⏰ 设置定时: ${fullTime}, 提前=${adv}ms`,'i');
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);
} catch(e) {
addLog('❌ 设置失败: '+e.message,'e');
toast('❌ 定时失败',e.message,'error');
}
}
// cancelSchedule 调用后端取消尚未触发的定时任务,并恢复前端按钮状态。
async function cancelSchedule(){
try{
await apiJSON('/api/cancel-schedule',{method:'POST'});
}catch(e){}
scheduleActive=false;
// 清除缓存的定时
localStorage.removeItem('targetTime');
localStorage.removeItem('advance');
// 恢复自动
document.getElementById('btnSchedule').style.display='inline-block';
document.getElementById('btnCancel').style.display='none';
stopCountdown();
addLog('⚠️ 定时已手动停止','w');
toast('⏹ 定时已停止','抢购任务已取消','warn');
}
// startCountdown 启动前端倒计时徽标,用后端返回的剩余秒数做展示。
function startCountdown(sec) {
stopCountdown();
let badge = document.getElementById('cdBadge');
badge.style.display = 'inline-block';
let startTime = Date.now();
let endTime = startTime + sec * 1000;
// 每 500ms 刷新一次显示,确保用户能看到接近触发时的状态变化。
countdownTimer = setInterval(() => {
let now = Date.now();
let remain = Math.max(0, Math.ceil((endTime - now) / 1000));
if (remain <= 0) {
badge.textContent = '🚀 触发中...';
clearInterval(countdownTimer);
countdownTimer = null;
scheduleActive = false;
document.getElementById('btnSchedule').style.display = 'inline-block';
document.getElementById('btnCancel').style.display = 'none';
// 延迟回调让“触发中”状态短暂停留,避免用户看不到最终状态。
setTimeout(() => badge.style.display = 'none', 5000);
} else {
badge.textContent = `⏱ ${formatRemain(remain)}`;
}
}, 500);
}
// stopCountdown 停止倒计时定时器,并隐藏倒计时徽标。
function stopCountdown(){
clearInterval(countdownTimer);
document.getElementById('cdBadge').style.display='none';
}
// formatRemain 把秒数格式化为 h:mm:ss,供定时提示和倒计时徽标使用。
function formatRemain(sec){
let h=Math.floor(sec/3600),m=Math.floor((sec%3600)/60),s=sec%60;
return `${h}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`;
}
// ==================== 结果刷新 ====================
// checkResults 轮询后端抢购结果,更新日志区、账号状态和本地状态缓存。
async function checkResults() {
try {
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 = {};
let h = '<div class="log-i">=== 最新结果 ===</div>';
for (let [k, v] of Object.entries(d)) {
let cls = v.includes('成功') ? 'log-s' : 'log-e';
h += `<div class="log-result ${cls}"><b>${k}</b>: ${v}</div>`;
let name = k.split('_')[0];
// ✅ 直接用账号名作为 key,不再用 findIndex
if (!(name in newStatusMap)) newStatusMap[name] = false;
if (v.includes('成功')) newStatusMap[name] = true;
}
statusMap = newStatusMap;
localStorage.setItem('statusMap', JSON.stringify(statusMap));
renderAccounts();
let el = document.getElementById('logBody');
el.innerHTML += h;
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, item.category);
});
} catch (e) { }
}
// addServerLog 追加一条后端日志,和本地操作日志共用同一个日志区域。
function addServerLog(msg, type = 'i', category = '') {
let el = document.getElementById('logBody');
let cat = category ? `<span class="log-cat">${escHtml(category)}</span>` : '';
el.innerHTML += `<div class="log-${type}">[后端] ${cat}${escHtml(cleanServerLogMessage(msg))}</div>`;
el.scrollTop = el.scrollHeight;
}
// cleanServerLogMessage 去掉后端结构化日志前缀,分类由单独标签展示。
function cleanServerLogMessage(msg) {
return String(msg ?? '').replace(/\[[^\]]+\]\[(info|success|warn|error)\]\s*/i, '');
}
// buildAP 根据已勾选账号和账号绑定商品,生成后端需要的账号到商品 ID 映射。
function buildAP() {
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 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');
let now = new Date();
let timeStr = now.toLocaleTimeString() + '.' + String(now.getMilliseconds()).padStart(3, '0');
el.innerHTML += `<div class="log-${type}">[${timeStr}] ${msg}</div>`;
el.scrollTop = el.scrollHeight;
}
// clearLog 清空前端日志显示,并重置最近一次结果快照。
function clearLog(){
document.getElementById('logBody').innerHTML='<div class="log-i">[系统] 就绪,等待操作...</div>';
lastResults={};
}
// 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>
</body>
</html>