Files
dygou_go/frontend/index.html
T
yml2213 8790c4ca33 增加设置界面、debug日志等级、版本号展示、构建脚本
- 新增设置弹窗: trace开关/正文上限、日志等级(debug/info/warn/error)、并发数、重试次数
- 配置运行时生效,持久化到 config.json
- 日志系统支持 debug 级别过滤
- 工具栏展示版本号(v0.0.2),来源 wails.json + constants.go
- 抢购并发数和重试次数改为读取配置,不再写死常量
- 新增 build.sh 构建脚本,Windows exe 文件名带版本号
- config.json 加入 .gitignore
2026-06-24 20:18:33 +08:00

974 lines
49 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}
: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-d{color:var(--muted-2)}
.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)}
/* ========== 设置弹窗 ========== */
.modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:9000;display:none;align-items:center;justify-content:center}
.modal-overlay.show{display:flex}
.modal{background:var(--panel);border:1px solid var(--border);border-radius:12px;width:460px;max-width:92vw;max-height:85vh;overflow-y:auto;box-shadow:var(--shadow)}
.modal-header{display:flex;align-items:center;justify-content:space-between;padding:16px 20px;border-bottom:1px solid var(--border)}
.modal-header h3{font-size:16px;color:var(--text-strong);margin:0}
.modal-close{width:30px;height:30px;border:0;border-radius:6px;background:transparent;color:var(--muted);cursor:pointer;font-size:18px;display:flex;align-items:center;justify-content:center}
.modal-close:hover{background:var(--panel-2);color:var(--text-strong)}
.modal-body{padding:20px}
.form-group{margin-bottom:16px}
.form-group:last-child{margin-bottom:0}
.form-label{display:block;font-size:13px;font-weight:700;color:var(--muted);margin-bottom:6px}
.form-hint{font-size:12px;color:var(--muted-2);margin-top:4px}
.form-row{display:flex;gap:12px}
.form-row .form-group{flex:1}
.form-input{width:100%;height:36px;padding:0 10px;background:var(--bg);color:var(--text-strong);border:1px solid var(--border);border-radius:var(--radius-sm);font-size:13px}
.form-input:focus{outline:0;border-color:var(--accent);box-shadow:var(--focus)}
.form-select{width:100%;height:36px;padding:0 10px;background:var(--bg);color:var(--text-strong);border:1px solid var(--border);border-radius:var(--radius-sm);font-size:13px;cursor:pointer}
.form-select:focus{outline:0;border-color:var(--accent)}
.toggle-row{display:flex;align-items:center;justify-content:space-between;padding:10px 0}
.toggle-label{font-size:13px;color:var(--text-strong)}
.toggle{position:relative;width:42px;height:24px;cursor:pointer}
.toggle input{opacity:0;width:0;height:0}
.toggle-slider{position:absolute;inset:0;background:var(--border-strong);border-radius:999px;transition:.2s}
.toggle-slider::before{content:"";position:absolute;left:3px;top:3px;width:18px;height:18px;background:#fff;border-radius:50%;transition:.2s}
.toggle input:checked+.toggle-slider{background:var(--accent)}
.toggle input:checked+.toggle-slider::before{transform:translateX(18px)}
.modal-footer{padding:12px 20px;border-top:1px solid var(--border);display:flex;justify-content:flex-end;gap:8px}
@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="modal-overlay" id="settingsModal">
<div class="modal">
<div class="modal-header">
<h3>⚙ 设置</h3>
<button class="modal-close" onclick="closeSettings()">×</button>
</div>
<div class="modal-body">
<div class="form-group">
<div class="toggle-row">
<span class="toggle-label">📡 Trace 日志(记录 HTTP 请求详情)</span>
<label class="toggle"><input type="checkbox" id="setTraceEnabled"><span class="toggle-slider"></span></label>
</div>
<div class="form-hint">开启后会在 logs/ 目录生成 douyu_trace_*.log,文件较大</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">Trace 正文上限(字节)</label>
<input class="form-input" type="number" id="setTraceMaxBody" min="0" step="1024" placeholder="0=不限">
<div class="form-hint">限制单段请求/响应正文保留大小,0 表示不限</div>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">日志显示等级</label>
<select class="form-select" id="setLogLevel">
<option value="debug">Debug(调试,最详细)</option>
<option value="info">Info(全部)</option>
<option value="warn">Warn(警告+错误)</option>
<option value="error">Error(仅错误)</option>
</select>
<div class="form-hint">低于此等级的日志不输出到文件和前端</div>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">抢购并发数</label>
<input class="form-input" type="number" id="setConcurrency" min="1" max="200" step="10">
</div>
<div class="form-group">
<label class="form-label">单号重试次数</label>
<input class="form-input" type="number" id="setAttempts" min="1" max="10" step="1">
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-ghost" onclick="closeSettings()">取消</button>
<button class="btn btn-blue" onclick="saveSettings()">💾 保存</button>
</div>
</div>
</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>
<span class="stat-pill" style="opacity:.6" id="versionTag">v--</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" onclick="openSettings()" title="设置" aria-label="设置">⚙</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();
App.GetVersion().then(v => { document.getElementById('versionTag').textContent = 'v' + v; }).catch(()=>{});
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';
else if (item.level === 'debug') type = 'd';
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, '');
}
// ==================== 设置 ====================
// openSettings 打开设置弹窗,从后端读取当前配置填充表单。
async function openSettings() {
try {
let cfg = await App.GetSettings();
document.getElementById('setTraceEnabled').checked = !!cfg.traceEnabled;
document.getElementById('setTraceMaxBody').value = cfg.traceMaxBody || 0;
document.getElementById('setLogLevel').value = cfg.logLevel || 'info';
document.getElementById('setConcurrency').value = cfg.concurrency || 80;
document.getElementById('setAttempts').value = cfg.attempts || 1;
} catch(e) {
toast('❌ 读取设置失败', e.message, 'error');
}
document.getElementById('settingsModal').classList.add('show');
}
// closeSettings 关闭设置弹窗。
function closeSettings() {
document.getElementById('settingsModal').classList.remove('show');
}
// saveSettings 收集表单数据,提交给后端保存。
async function saveSettings() {
let cfg = {
traceEnabled: document.getElementById('setTraceEnabled').checked,
traceMaxBody: parseInt(document.getElementById('setTraceMaxBody').value) || 0,
logLevel: document.getElementById('setLogLevel').value,
concurrency: parseInt(document.getElementById('setConcurrency').value) || 80,
attempts: parseInt(document.getElementById('setAttempts').value) || 1,
};
try {
let d = await App.SaveSettings(cfg);
if (d.success) {
toast('✅ 设置已保存', '配置已生效', 'success');
closeSettings();
} else {
toast('❌ 保存失败', d.error || '', 'error');
}
} catch(e) {
toast('❌ 保存失败', e.message, 'error');
}
}
// 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>