diff --git a/frontend/app.js b/frontend/app.js
new file mode 100644
index 0000000..9d88824
--- /dev/null
+++ b/frontend/app.js
@@ -0,0 +1,722 @@
+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||''; }
+ 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;
+ }
+ updateSelectedProductCard();
+ updateDashboardStats();
+
+
+ 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=`
${title}
${body}
`;
+ 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+=`
+

+
${escHtml(p.commodityName)}
+
${p.score} 积分库存 ${p.count}
+
`;
+ });
+ let emptyText = productSearch ? `没有匹配“${escHtml(productSearch)}”的商品` : '暂无实时商品数据';
+ document.getElementById('prodList').innerHTML=h||`${emptyText}
`;
+ updateSelectedProductCard();
+}
+// 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;
+ localStorage.setItem('selProd', id);
+ localStorage.setItem('selProdName', name);
+ renderProducts();
+ renderAccounts();
+ updateSelectedProductCard();
+ updateDashboardStats();
+}
+
+// clearSelectedProduct 清理已经不存在的商品选择。
+function clearSelectedProduct() {
+ selProdId = null;
+ selProdName = '';
+ localStorage.removeItem('selProd');
+ localStorage.removeItem('selProdName');
+ updateSelectedProductCard();
+ updateDashboardStats();
+}
+
+// ==================== 账号 ====================
+
+// loadAccounts 从后端获取账号列表,清理无效本地缓存后重新渲染账号表格。
+async function loadAccounts() {
+ accounts = await App.GetAccounts();
+ selAccs = new Set([...selAccs].filter(i => i >= 0 && i < accounts.length));
+ localStorage.setItem('selAccs', JSON.stringify([...selAccs]));
+
+ // 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;
+ document.getElementById('selectedCnt').textContent = selAccs.size;
+ document.getElementById('accountPanelSub').textContent = `${accounts.length} 个账号,已选 ${selAccs.size} 个`;
+ let chkAll = document.getElementById('chkAll');
+ if (chkAll) chkAll.checked = accounts.length > 0 && selAccs.size === accounts.length;
+ // 将账号数组映射为表格行 HTML,行内状态从 localStorage 和 statusMap 读取。
+ let h = accounts.map((a, i) => {
+ let isSel = selAccs.has(i);
+ // 商品列展示当前选中商品,提交时会应用到所有勾选账号。
+ let prodTag = selProdName ? `${escHtml(selProdName)}` : '-';
+ let scoreVal = localStorage.getItem('accScore_' + a.name) || '-'; // ← 改成 a.name
+ let st = statusMap[a.name]; // ← 已经是 a.name,保持
+ let status = statusInfo(st, isSel);
+ let credential = a.cookie ? '已加载' : '缺失';
+ let lastMessage = resultMessage(st);
+ return `
+ |
+
+ |
+ ${escHtml(a.name)} ${isSel ? '已加入任务' : '未选择'} |
+ ${credential} |
+ ${prodTag} |
+ ${scoreVal} |
+ ${status.text} |
+ ${lastMessage} |
+
`;
+ });
+ document.getElementById('accTable').innerHTML = h.join('');
+ updateDashboardStats();
+}
+
+
+// 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();
+ updateDashboardStats();
+}
+// togAcc 切换单个账号的选择状态,并同步更新 localStorage。
+function togAcc(i){
+ if(selAccs.has(i)) selAccs.delete(i); else selAccs.add(i);
+ localStorage.setItem('selAccs',JSON.stringify([...selAccs]));
+ renderAccounts();
+ updateDashboardStats();
+}
+
+// ==================== 立即抢购(不弹窗) ====================
+// startNow 构建账号商品映射后,调用后端立即启动抢购。
+async function startNow(){
+ let ap=buildAP();
+ if(!ap) return;
+ resetRunResults();
+ setRunState('立即抢购已提交');
+ 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) {
+ setRunState('提交失败');
+ addLog('提交失败: '+e.message,'e');
+ toast('提交失败',e.message,'error');
+ }
+}
+
+// ==================== 定时抢购 ====================
+// startSchedule 构建定时参数并提交给后端,由后端按服务端时间安排抢购。
+async function startSchedule(){
+ let ap=buildAP();
+ if(!ap) return;
+ resetRunResults();
+ 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;
+
+ setRunState('定时配置中');
+ 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';
+ setRunState(`定时已开启,触发 ${d.triggerTime}`);
+ addLog(`定时已开启: 触发 ${d.triggerTime},剩余 ${d.remainSecond} 秒`,'s');
+ toast('定时已开启',`触发时刻: ${d.triggerTime}
剩余时间: ${formatRemain(d.remainSecond)}
商品: ${selProdName}
账号: ${Object.keys(ap).length} 个`,'success',5000);
+ startCountdown(d.remainSecond);
+ } catch(e) {
+ setRunState('定时设置失败');
+ 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();
+ setRunState('定时已停止');
+ 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';
+ setRunState('定时已触发');
+ // 延迟回调让“触发中”状态短暂停留,避免用户看不到最终状态。
+ 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')}`;
+}
+
+// setRunState 更新顶部运行状态。
+function setRunState(text) {
+ let el = document.getElementById('runStateText');
+ if (el) el.textContent = text || '等待操作';
+}
+
+// switchLogTab 切换右侧日志/结果/统计面板。
+function switchLogTab(id) {
+ document.querySelectorAll('.log-tab').forEach(btn => btn.classList.toggle('active', btn.dataset.logTab === id));
+ document.querySelectorAll('.log-panel').forEach(panel => panel.classList.toggle('active', panel.id === id));
+}
+
+// updateSelectedProductCard 刷新左侧当前商品卡片。
+function updateSelectedProductCard() {
+ let card = document.getElementById('selectedProductCard');
+ let nameEl = document.getElementById('selectedProductName');
+ let metaEl = document.getElementById('selectedProductMeta');
+ if (!nameEl || !metaEl) return;
+ let product = products.find(p => p.commodityId === selProdId);
+ nameEl.textContent = selProdName || '未选';
+ if (card) card.classList.toggle('has-product', !!selProdId);
+ if (!product) {
+ metaEl.innerHTML = selProdId ? '等待刷新' : '未绑定任务';
+ return;
+ }
+ metaEl.innerHTML = `${product.score} 积分库存 ${product.count}`;
+}
+
+// statusInfo 将后端结果转换为账号状态标签。
+function statusInfo(st, selected) {
+ if (st === true) return {text:'成功', cls:'status-success'};
+ if (st === false) return {text:'失败', cls:'status-fail'};
+ if (st && typeof st === 'object') {
+ if (st.ok) return {text:'成功', cls:'status-success'};
+ let msg = String(st.message || '');
+ if (msg.includes('库存已耗完')) return {text:'库存耗尽', cls:'status-warn'};
+ if (msg.includes('兑换上限')) return {text:'已达上限', cls:'status-warn'};
+ if (msg.includes('未绑定')) return {text:'未绑定', cls:'status-fail'};
+ if (msg.includes('精英手册')) return {text:'资格不足', cls:'status-fail'};
+ return {text:selected ? '未命中' : '失败', cls:selected ? 'status-warn' : 'status-fail'};
+ }
+ return selected ? {text:'待命', cls:'status-ready'} : {text:'未选择', cls:'status-idle'};
+}
+
+// resultMessage 返回账号最后一次结果摘要。
+function resultMessage(st) {
+ let msg = '';
+ if (st && typeof st === 'object') msg = st.message || '';
+ else if (st === true) msg = '成功';
+ else if (st === false) msg = '失败';
+ if (!msg) return '-';
+ let short = msg.length > 46 ? msg.slice(0, 46) + '...' : msg;
+ return `${escHtml(short)}`;
+}
+
+// updateDashboardStats 刷新顶部统计指标。
+function updateDashboardStats() {
+ let selectedNames = new Set([...selAccs].map(i => accounts[i]?.name).filter(Boolean));
+ let success = 0, fail = 0;
+ for (let name of selectedNames) {
+ let st = statusMap[name];
+ if (st === true || (st && typeof st === 'object' && st.ok)) success++;
+ else if (st === false || (st && typeof st === 'object' && st.message)) fail++;
+ }
+ let pending = Math.max(0, selectedNames.size - success - fail);
+ setText('selectedCnt', selectedNames.size);
+ setText('successCnt', success);
+ setText('failCnt', fail);
+ setText('pendingCnt', pending);
+ let stats = document.getElementById('statsBody');
+ if (stats) {
+ stats.innerHTML = `成功账号: ${success}
+ 异常账号: ${fail}
+ 待处理账号: ${pending}
+ 已选账号: ${selectedNames.size} / 总账号: ${accounts.length}
`;
+ }
+}
+
+function setText(id, value) {
+ let el = document.getElementById(id);
+ if (el) el.textContent = value;
+}
+
+// resetRunResults 清理上一轮前端结果,避免新任务被旧状态干扰。
+function resetRunResults() {
+ lastResults = {};
+ statusMap = {};
+ localStorage.removeItem('statusMap');
+ let resultEl = document.getElementById('resultBody');
+ if (resultEl) resultEl.innerHTML = '暂无抢购结果
';
+ renderAccounts();
+ updateDashboardStats();
+}
+
+// ==================== 结果刷新 ====================
+// 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 = '=== 最新结果 ===
';
+ for (let [k, v] of Object.entries(d)) {
+ let cls = v.includes('成功') ? 'log-s' : 'log-e';
+ h += `${k}: ${v}
`;
+ let name = k.split('_')[0];
+ if (!(name in newStatusMap)) newStatusMap[name] = {ok:false,message:v};
+ if (v.includes('成功')) newStatusMap[name] = {ok:true,message:v};
+ }
+ statusMap = newStatusMap;
+ localStorage.setItem('statusMap', JSON.stringify(statusMap));
+ renderAccounts();
+ let resultEl = document.getElementById('resultBody');
+ resultEl.innerHTML = h;
+ resultEl.scrollTop = resultEl.scrollHeight;
+ updateDashboardStats();
+ }
+ } 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 ? `${escHtml(category)}` : '';
+ el.innerHTML += `[后端] ${cat}${escHtml(cleanServerLogMessage(msg))}
`;
+ 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 += `[${timeStr}] ${msg}
`;
+ el.scrollTop = el.scrollHeight;
+}
+
+
+// clearLog 清空前端日志显示,并重置最近一次结果快照。
+function clearLog(){
+ document.getElementById('logBody').innerHTML='[系统] 就绪,等待操作...
';
+ document.getElementById('resultBody').innerHTML='暂无抢购结果
';
+ lastResults={};
+ setRunState('等待操作');
+ updateDashboardStats();
+}
+
+// escHtml 转义 HTML 特殊字符,避免接口数据破坏页面结构。
+function escHtml(s){ return String(s ?? '').replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"').replace(/'/g,'''); }
+// jsAttr 转义写入 onclick 字符串参数的数据。
+function jsAttr(s){ return escHtml(String(s ?? '').replace(/\\/g,'\\\\').replace(/'/g,"\\'").replace(/\n/g,' ')); }
+
+init();
diff --git a/frontend/index.html b/frontend/index.html
index 82a933b..f6d8121 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -4,245 +4,22 @@
斗鱼抢购系统
-
+
-
-