初步优化成全部go+wails

This commit is contained in:
yml2213
2026-06-24 19:11:02 +08:00
parent 86374fdd4d
commit 154e55d484
27 changed files with 2222 additions and 1798 deletions
+843
View File
@@ -0,0 +1,843 @@
<!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}
:root{
color-scheme:dark;
--bg:#0b1017;
--bg-soft:#111821;
--panel:#151d27;
--panel-2:#1c2632;
--panel-3:#222e3b;
--text:#d8dee9;
--text-strong:#f5f7fb;
--muted:#8d99a8;
--muted-2:#697586;
--border:#2d3948;
--border-strong:#405167;
--accent:#4f8cff;
--accent-soft:rgba(79,140,255,.16);
--danger:#ef4444;
--danger-soft:rgba(239,68,68,.14);
--success:#22c55e;
--success-soft:rgba(34,197,94,.16);
--warning:#f59e0b;
--warning-soft:rgba(245,158,11,.15);
--orange:#ff7a45;
--shadow:0 18px 50px rgba(0,0,0,.28);
--radius:8px;
--radius-sm:6px;
--focus:0 0 0 3px rgba(79,140,255,.18);
}
body[data-theme="light"]{
color-scheme:light;
--bg:#f4f7fb;
--bg-soft:#eef3f9;
--panel:#ffffff;
--panel-2:#f7f9fc;
--panel-3:#edf2f8;
--text:#202938;
--text-strong:#111827;
--muted:#667085;
--muted-2:#8a94a6;
--border:#d9e1ec;
--border-strong:#b9c7d8;
--accent:#2563eb;
--accent-soft:rgba(37,99,235,.1);
--danger:#dc2626;
--danger-soft:rgba(220,38,38,.1);
--success:#16a34a;
--success-soft:rgba(22,163,74,.12);
--warning:#d97706;
--warning-soft:rgba(217,119,6,.12);
--orange:#ea580c;
--shadow:0 18px 45px rgba(31,41,55,.12);
--focus:0 0 0 3px rgba(37,99,235,.16);
}
body{
font-family:'Microsoft YaHei','PingFang SC','Segoe UI',sans-serif;
background:var(--bg);
color:var(--text);
display:flex;
height:100vh;
overflow:hidden;
letter-spacing:0;
}
button,input{font:inherit}
::selection{background:var(--accent-soft);color:var(--text-strong)}
::-webkit-scrollbar{width:10px;height:10px}
::-webkit-scrollbar-track{background:transparent}
::-webkit-scrollbar-thumb{background:var(--border-strong);border:3px solid transparent;border-radius:999px;background-clip:content-box}
/* ========== 左侧商品列表 ========== */
.left{width:320px;min-width:300px;background:var(--panel);border-right:1px solid var(--border);display:flex;flex-direction:column;box-shadow:var(--shadow);z-index:2}
.left-header{padding:14px;border-bottom:1px solid var(--border);background:linear-gradient(180deg,var(--panel),var(--bg-soft))}
.product-search{display:flex;align-items:center;gap:8px;margin-top:10px;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius-sm);padding:0 9px;transition:.18s}
.product-search:focus-within{border-color:var(--accent);box-shadow:var(--focus)}
.product-search input{flex:1;min-width:0;height:36px;border:0;outline:0;background:transparent;color:var(--text);font-size:13px}
.product-search input::placeholder{color:var(--muted-2)}
.search-icon{color:var(--muted-2);font-size:13px}
.product-search .search-clear{width:24px;height:24px;border:0;border-radius:5px;background:transparent;color:var(--muted);cursor:pointer;font-size:16px;line-height:20px;display:none}
.product-search .search-clear:hover{background:var(--panel-2);color:var(--text-strong)}
.left-list{flex:1;overflow-y:auto;padding:10px}
.left-list .prod-item{display:flex;align-items:center;gap:10px;padding:10px;margin:6px 0;background:var(--panel-2);border-radius:var(--radius);cursor:pointer;border:1px solid transparent;transition:.18s;position:relative}
.left-list .prod-item:hover{border-color:var(--accent);background:var(--panel-3);transform:translateY(-1px)}
.left-list .prod-item.active{border-color:var(--orange);background:linear-gradient(90deg,var(--danger-soft),var(--panel-2))}
.left-list .prod-item.active::before{content:"";position:absolute;left:-1px;top:10px;bottom:10px;width:3px;border-radius:999px;background:var(--orange)}
.left-list .prod-item img{width:48px;height:48px;border-radius:6px;object-fit:cover;background:var(--bg);border:1px solid var(--border)}
.left-list .prod-item .pinfo{flex:1;min-width:0}
.left-list .prod-item .pname{font-size:13px;font-weight:700;color:var(--text-strong);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.left-list .prod-item .pmeta{font-size:12px;color:var(--muted);display:flex;gap:8px;margin-top:4px}
.pmeta .pscore,.pmeta .pstock{display:inline-flex;align-items:center;gap:3px;padding:2px 6px;border-radius:999px;background:var(--bg)}
.pmeta .pscore{color:var(--warning)}
.pmeta .pstock{color:var(--success)}
.pmeta .pstock.low{color:var(--danger)}
.product-meta{padding:8px 2px 0;color:var(--muted);font-size:12px;line-height:1.5}
.product-meta.good{color:var(--success)}
.product-meta.warn{color:var(--warning)}
.empty-state{text-align:center;color:var(--muted);padding:42px 14px;font-size:13px;line-height:1.7}
/* ========== 右侧主区域 ========== */
.right{flex:1;display:flex;flex-direction:column;overflow:hidden;position:relative;background:var(--bg)}
/* 工具栏 */
.toolbar{padding:12px 18px;background:var(--panel);border-bottom:1px solid var(--border);display:flex;gap:12px;align-items:center;flex-wrap:wrap}
.toolbar-spacer{flex:1;min-width:20px}
.action-group,.summary-group,.utility-group{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
.btn{min-height:34px;padding:7px 12px;border:1px solid var(--border);border-radius:var(--radius-sm);background:var(--panel-2);color:var(--text);cursor:pointer;font-size:13px;white-space:nowrap;transition:.18s;display:inline-flex;align-items:center;justify-content:center;gap:6px}
.btn:hover{background:var(--panel-3);border-color:var(--border-strong)}
.btn:focus-visible{outline:0;box-shadow:var(--focus)}
.btn:disabled{opacity:.65;cursor:wait}
.btn-red{background:var(--danger);border-color:var(--danger);color:#fff;font-size:14px;font-weight:700;padding:8px 16px}
.btn-red:hover{background:#c81e1e;border-color:#c81e1e}
.btn-green{background:var(--success);border-color:var(--success);color:#fff;font-size:14px;font-weight:700;padding:8px 16px}
.btn-green:hover{background:#15803d;border-color:#15803d}
.btn-blue{background:var(--accent);border-color:var(--accent);color:#fff;font-size:14px;font-weight:700;padding:8px 16px}
.btn-blue:hover{filter:brightness(.92)}
.btn-cancel{background:var(--muted);border-color:var(--muted);color:#fff;font-size:14px;font-weight:700;padding:8px 16px}
.btn-cancel:hover{filter:brightness(.9)}
.btn-ghost{background:transparent;color:var(--muted)}
.btn-ghost:hover{color:var(--text-strong);background:var(--panel-2)}
.icon-btn{width:36px;padding:0;font-size:15px}
.btn-compact{min-height:30px;font-size:12px;padding:4px 10px}
.w-full{width:100%}
.stat-pill{display:inline-flex;align-items:center;gap:6px;min-height:32px;padding:5px 10px;border:1px solid var(--border);border-radius:999px;background:var(--bg-soft);color:var(--muted);font-size:13px}
.stat-pill b{color:var(--accent);font-size:14px}
.stat-pill .selected-product{max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--orange)}
/* 定时信息栏 */
.schedule-bar{padding:12px 18px;background:var(--bg-soft);border-bottom:1px solid var(--border);display:flex;gap:10px;align-items:center;flex-wrap:wrap;font-size:14px}
.schedule-item{display:flex;align-items:center;gap:8px;padding:8px 10px;background:var(--panel);border:1px solid var(--border);border-radius:var(--radius)}
.schedule-bar label{color:var(--muted);font-size:13px;font-weight:700;white-space:nowrap}
.schedule-bar .time-box{background:var(--bg);padding:5px 11px;border-radius:var(--radius-sm);font-family:'Consolas','SFMono-Regular',monospace;font-size:16px;font-weight:800;border:1px solid var(--border);letter-spacing:.2px}
.schedule-bar .time-box.server{color:var(--accent)}
.schedule-bar .time-box.offset{color:var(--warning)}
.schedule-bar input{height:36px;padding:6px 10px;background:var(--bg);color:var(--text-strong);border:1px solid var(--border);border-radius:var(--radius-sm);font-size:16px;font-weight:800}
.schedule-bar input:focus{outline:0;border-color:var(--accent);box-shadow:var(--focus)}
.schedule-bar input#targetTime{width:150px;text-align:center}
.schedule-bar input#advance{width:80px;text-align:center}
.schedule-bar .hint{color:var(--muted);font-size:12px}
.countdown-badge{background:var(--danger);color:#fff;padding:7px 14px;border-radius:999px;font-weight:800;font-size:14px}
/* 账号表格 */
.table-wrap{flex:1;overflow-y:auto;padding:12px 18px;min-height:180px}
table{width:100%;border-collapse:separate;border-spacing:0;font-size:13px;background:var(--panel);border:1px solid var(--border);border-radius:var(--radius);overflow:hidden}
th{background:var(--panel-2);padding:10px 12px;text-align:left;border-bottom:1px solid var(--border);position:sticky;top:0;z-index:1;color:var(--muted);font-size:13px;font-weight:700}
td{padding:9px 12px;border-bottom:1px solid var(--border);vertical-align:middle}
tbody tr:last-child td{border-bottom:0}
tbody tr{transition:.15s}
tbody tr:hover{background:var(--bg-soft)}
.cookie-td{max-width:360px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--muted-2);font-size:12px;font-family:'Consolas','SFMono-Regular',monospace}
.prod-tag{display:inline-flex;max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;background:var(--success-soft);color:var(--success);border:1px solid rgba(34,197,94,.35);padding:4px 10px;border-radius:999px;font-size:12px;font-weight:700}
.status-td{font-size:16px}
.account-name{font-weight:700;color:var(--text-strong)}
.account-check{display:flex;align-items:center;cursor:pointer}
.check-box{width:18px;height:18px;border-radius:5px;border:2px solid var(--border-strong);background:transparent;display:flex;align-items:center;justify-content:center;transition:.18s;color:#fff;font-size:12px;font-weight:800}
.check-box.checked{border-color:var(--success);background:var(--success)}
.check-box span{color:#fff;font-size:12px}
.select-cell{width:36px}
.status-col{width:80px}
/* 日志区 */
.log-wrap{border-top:1px solid var(--border);background:var(--panel);display:flex;flex-direction:column;height:230px}
.log-header{padding:9px 18px;background:var(--panel);border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center;font-size:13px;color:var(--text-strong)}
.log-body{flex:1;overflow-y:auto;padding:10px 18px;font-family:'Consolas','Courier New',monospace;font-size:13px;line-height:1.75;background:var(--bg)}
.log-s{color:var(--success)}
.log-e{color:var(--danger)}
.log-i{color:var(--accent)}
.log-w{color:var(--warning)}
.log-result{background:var(--panel-2);padding:5px 9px;margin:4px 0;border-radius:var(--radius-sm);word-break:break-all;border:1px solid var(--border)}
.log-cat{display:inline-block;min-width:34px;text-align:center;margin-right:6px;padding:0 5px;border:1px solid var(--border);border-radius:5px;color:var(--muted);font-size:11px;line-height:1.4;background:var(--panel)}
/* ========== 右侧滑入提示 ========== */
.toast-container{position:fixed;top:20px;right:-420px;z-index:9999;transition:right .4s ease}
.toast-container.show{right:20px}
.toast{background:var(--panel);border:1px solid var(--border);border-left-width:4px;border-radius:var(--radius);padding:15px 18px;margin-bottom:10px;min-width:350px;box-shadow:var(--shadow)}
.toast-title{font-size:15px;font-weight:800;margin-bottom:6px;color:var(--text-strong)}
.toast-body{font-size:13px;color:var(--muted);line-height:1.6}
.toast-success{border-left-color:var(--success)}.toast-success .toast-title{color:var(--success)}
.toast-info{border-left-color:var(--accent)}.toast-info .toast-title{color:var(--accent)}
.toast-warn{border-left-color:var(--warning)}.toast-warn .toast-title{color:var(--warning)}
.toast-error{border-left-color:var(--danger)}.toast-error .toast-title{color:var(--danger)}
@media (max-width: 900px){
body{display:block;overflow:auto;height:auto;min-height:100vh}
.left{width:100%;min-width:0;height:42vh;border-right:0;border-bottom:1px solid var(--border)}
.right{min-height:58vh}
.toolbar,.schedule-bar{padding:10px 12px}
.table-wrap{padding:10px 12px}
.log-wrap{height:220px}
.toast-container{left:12px;right:12px;top:12px}
.toast-container.show{right:12px}
.toast{min-width:0;width:100%}
}
</style>
</head>
<body data-theme="dark">
<!-- 右侧滑入提示容器 -->
<div class="toast-container" id="toastContainer"></div>
<!-- ==================== 左侧商品列表 ==================== -->
<div class="left">
<div class="left-header">
<button class="btn btn-blue w-full" id="btnLoadProducts" onclick="loadProducts(true)">🔄 刷新商品</button>
<div class="product-search">
<span class="search-icon">🔍</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 class="empty-state">点击刷新商品获取列表</div>
</div>
</div>
<!-- ==================== 右侧 ==================== -->
<div class="right">
<div class="toolbar">
<div class="action-group">
<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>
</div>
<div class="summary-group">
<span class="stat-pill">账号 <b id="accCnt">0</b></span>
<span class="stat-pill">选中商品 <b class="selected-product" id="selProd">未选</b></span>
</div>
<div class="toolbar-spacer"></div>
<div class="utility-group">
<button class="btn btn-blue" onclick="reloadAccounts()">🔄 加载账号</button>
<button class="btn btn-blue" onclick="queryAllScore()">💰 查询积分</button>
<button class="btn btn-ghost icon-btn theme-toggle" id="btnTheme" onclick="toggleTheme()" title="切换亮色/暗色" aria-label="切换亮色/暗色">☾</button>
</div>
<span id="cdBadge" class="countdown-badge" style="display:none"></span>
</div>
<div class="schedule-bar">
<div class="schedule-item">
<label>服务器时间</label>
<span class="time-box server" id="svrTime">--</span>
</div>
<div class="schedule-item">
<label>目标时间</label>
<input type="time" id="targetTime" value="18:00:00" step="1">
</div>
<div class="schedule-item">
<label>提前(ms)</label>
<input type="number" id="advance" value="300" min="0" max="5000" step="100">
</div>
<span class="hint">(定时触发=目标-提前量)</span>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th class="select-cell"><input type="checkbox" id="chkAll" onchange="toggleAll()"></th>
<th>账号名</th>
<th>Cookie</th>
<th>商品</th>
<th>积分</th>
<th class="status-col">状态</th>
</tr>
</thead>
<tbody id="accTable"></tbody>
</table>
</div>
<div class="log-wrap">
<div class="log-header">
<span>📋 运行日志(自动刷新)</span>
<button class="btn btn-compact" onclick="clearLog()">清空</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={}; // ← 加这行
// applyTheme 应用亮色/暗色主题,并把选择保存到本地。
function applyTheme(theme) {
let nextTheme = theme === 'light' ? 'light' : 'dark';
document.body.dataset.theme = nextTheme;
localStorage.setItem('uiTheme', nextTheme);
let btn = document.getElementById('btnTheme');
if (btn) {
btn.textContent = nextTheme === 'light' ? '☀' : '☾';
btn.title = nextTheme === 'light' ? '切换到暗色' : '切换到亮色';
btn.setAttribute('aria-label', btn.title);
}
}
// toggleTheme 在亮色和暗色之间切换。
function toggleTheme() {
applyTheme(document.body.dataset.theme === 'light' ? 'dark' : 'light');
}
// init 初始化页面状态:恢复本地缓存、加载账号和商品、启动时间与结果轮询。
async function init(){
let savedTheme=localStorage.getItem('uiTheme') || (window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark');
applyTheme(savedTheme);
// 恢复缓存的选择
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 App.ClearResults();
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(){
App.GetTime().then(d=>{
document.getElementById('svrTime').textContent=d.serverTime||'--';
}).catch(()=>{});
}
// apiJSON 统一处理 Wails 绑定调用,失败时抛出后端返回的错误文案。
async function apiJSON(fn) {
let data = await fn();
if (data && data.success === false && data.error) {
throw new Error(data.error);
}
return data;
}
// Wails 绑定快捷引用
const App = window.go.main.App;
// reloadAccounts 通知后端重新读取 user.txt,然后刷新前端账号表格。
async function reloadAccounts(){
let d=await App.ReloadAccounts();
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 App.QueryScore(a.name);
let score = (d.score !== undefined && d.score !== null && d.score !== '') ? d.score : 'N/A';
localStorage.setItem('accScore_' + a.name, score);
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 App.LoadProducts(true);
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 class="empty-state">${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 = '未选';
}
// ==================== 账号 ====================
// loadAccounts 从后端获取账号列表,清理无效本地缓存后重新渲染账号表格。
async function loadAccounts() {
accounts = await App.GetAccounts();
// 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 class="account-check" onclick="togAcc(${i})">
<div class="check-box${isSel ? ' checked' : ''}">
${isSel ? '<span>✓</span>' : ''}
</div>
</label>
</td>
<td class="account-name">${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 App.StartSeckill(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 App.SetSchedule({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 App.CancelSchedule();
}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 d = await App.GetResults();
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 App.GetLogs(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>