删掉旧的代码
This commit is contained in:
@@ -1,722 +0,0 @@
|
||||
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=`<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="chip chip-score">${p.score} 积分</span><span class="chip chip-stock${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>`;
|
||||
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 ? `<span class="prod-tag">${escHtml(selProdName)}</span>` : '-';
|
||||
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 ? '<span class="status-badge status-success">已加载</span>' : '<span class="status-badge status-fail">缺失</span>';
|
||||
let lastMessage = resultMessage(st);
|
||||
return `<tr>
|
||||
<td>
|
||||
<label class="account-check" onclick="togAcc(${i})">
|
||||
<div class="check-box${isSel ? ' checked' : ''}">
|
||||
${isSel ? '<span>✓</span>' : ''}
|
||||
</div>
|
||||
</label>
|
||||
</td>
|
||||
<td><div class="account-name">${escHtml(a.name)}</div><div class="account-sub">${isSel ? '已加入任务' : '未选择'}</div></td>
|
||||
<td>${credential}</td>
|
||||
<td>${prodTag}</td>
|
||||
<td id="score_${i}">${scoreVal}</td>
|
||||
<td class="status-td" id="st_${i}"><span class="status-badge ${status.cls}">${status.text}</span></td>
|
||||
<td>${lastMessage}</td>
|
||||
</tr>`;
|
||||
});
|
||||
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}<br>剩余时间: <b>${formatRemain(d.remainSecond)}</b><br>商品: ${selProdName}<br>账号: ${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 ? '<span class="chip">等待刷新</span>' : '<span class="chip">未绑定任务</span>';
|
||||
return;
|
||||
}
|
||||
metaEl.innerHTML = `<span class="chip chip-score">${product.score} 积分</span><span class="chip chip-stock${product.count<500?' low':''}">库存 ${product.count}</span>`;
|
||||
}
|
||||
|
||||
// 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 '<span class="account-sub">-</span>';
|
||||
let short = msg.length > 46 ? msg.slice(0, 46) + '...' : msg;
|
||||
return `<span class="account-sub" title="${escHtml(msg)}">${escHtml(short)}</span>`;
|
||||
}
|
||||
|
||||
// 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 = `<div class="log-result log-s">成功账号: ${success}</div>
|
||||
<div class="log-result log-e">异常账号: ${fail}</div>
|
||||
<div class="log-result log-i">待处理账号: ${pending}</div>
|
||||
<div class="log-result">已选账号: ${selectedNames.size} / 总账号: ${accounts.length}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
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 = '<div class="result-empty">暂无抢购结果</div>';
|
||||
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 = '<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];
|
||||
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 ? `<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>';
|
||||
document.getElementById('resultBody').innerHTML='<div class="result-empty">暂无抢购结果</div>';
|
||||
lastResults={};
|
||||
setRunState('等待操作');
|
||||
updateDashboardStats();
|
||||
}
|
||||
|
||||
// escHtml 转义 HTML 特殊字符,避免接口数据破坏页面结构。
|
||||
function escHtml(s){ return String(s ?? '').replace(/&/g,'&').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();
|
||||
@@ -1,204 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>斗鱼抢购系统</title>
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
</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 日志</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 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>
|
||||
</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-primary" onclick="saveSettings()">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="app-shell">
|
||||
<aside class="left">
|
||||
<div class="brand">
|
||||
<div>
|
||||
<h1>斗鱼抢购</h1>
|
||||
<div class="brand-sub">实时库存 · 多账号 · 定时触发</div>
|
||||
</div>
|
||||
<span class="stat-pill" id="versionTag">v--</span>
|
||||
</div>
|
||||
<div class="left-header">
|
||||
<div class="product-actions">
|
||||
<button class="btn btn-primary" id="btnLoadProducts" onclick="loadProducts(true)">刷新商品</button>
|
||||
</div>
|
||||
<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="selected-product-card" id="selectedProductCard">
|
||||
<div class="selected-product-label">当前商品</div>
|
||||
<div class="selected-product-name" id="selectedProductName">未选</div>
|
||||
<div class="selected-product-meta" id="selectedProductMeta"></div>
|
||||
</div>
|
||||
<div class="product-meta" id="productMeta">库存未刷新</div>
|
||||
</div>
|
||||
<div class="left-list" id="prodList">
|
||||
<div class="empty-state">点击「刷新商品」获取列表</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="right">
|
||||
<header class="console-head">
|
||||
<div class="headline">
|
||||
<div>
|
||||
<div class="headline-title">抢购控制台</div>
|
||||
<div class="headline-meta" id="runStateText">等待操作</div>
|
||||
</div>
|
||||
<span id="cdBadge" class="countdown-badge" style="display:none"></span>
|
||||
</div>
|
||||
<div class="top-actions">
|
||||
<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>
|
||||
</header>
|
||||
|
||||
<div class="status-grid">
|
||||
<div class="metric">
|
||||
<span class="metric-label">服务器</span>
|
||||
<span class="metric-value server" id="svrTime">--</span>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span class="metric-label">账号</span>
|
||||
<span class="metric-value"><span id="selectedCnt">0</span>/<span id="accCnt">0</span></span>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span class="metric-label">成功</span>
|
||||
<span class="metric-value success-num" id="successCnt">0</span>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span class="metric-label">异常</span>
|
||||
<span class="metric-value fail-num" id="failCnt">0</span>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span class="metric-label">待处理</span>
|
||||
<span class="metric-value" id="pendingCnt">0</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="control-strip">
|
||||
<div class="action-group">
|
||||
<button class="btn btn-danger" onclick="startNow()">立即抢购</button>
|
||||
<button class="btn btn-success" id="btnSchedule" onclick="startSchedule()">开启定时</button>
|
||||
<button class="btn btn-cancel" id="btnCancel" onclick="cancelSchedule()" style="display:none">停止定时</button>
|
||||
<button class="btn" onclick="reloadAccounts()">加载账号</button>
|
||||
<button class="btn" onclick="queryAllScore()">查询积分</button>
|
||||
</div>
|
||||
<div class="schedule-bar">
|
||||
<div class="schedule-item">
|
||||
<label>目标</label>
|
||||
<input type="time" id="targetTime" value="18:00:00" step="1">
|
||||
</div>
|
||||
<div class="schedule-item">
|
||||
<label>提前</label>
|
||||
<input type="number" id="advance" value="300" min="0" max="5000" step="100">
|
||||
<span style="color:var(--muted);font-size:11.5px">ms</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="workspace">
|
||||
<div class="panel">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<span class="panel-title">账号作战表</span>
|
||||
<span class="panel-sub" id="accountPanelSub">0 个账号</span>
|
||||
</div>
|
||||
<label class="stat-pill"><input type="checkbox" id="chkAll" onchange="toggleAll()"> 全选</label>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="select-cell"></th>
|
||||
<th>账号</th>
|
||||
<th>凭据</th>
|
||||
<th>商品</th>
|
||||
<th>积分</th>
|
||||
<th class="status-col">状态</th>
|
||||
<th>最后结果</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="accTable"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside class="side-stack">
|
||||
<div class="panel log-wrap">
|
||||
<div class="log-header">
|
||||
<div class="log-tabs">
|
||||
<button class="log-tab active" data-log-tab="logBody" onclick="switchLogTab('logBody')">实时日志</button>
|
||||
<button class="log-tab" data-log-tab="resultBody" onclick="switchLogTab('resultBody')">抢购结果</button>
|
||||
<button class="log-tab" data-log-tab="statsBody" onclick="switchLogTab('statsBody')">统计</button>
|
||||
</div>
|
||||
<button class="btn btn-compact" onclick="clearLog()">清空</button>
|
||||
</div>
|
||||
<div class="log-panel log-body active" id="logBody">
|
||||
<div class="log-i">[系统] 就绪,等待操作...</div>
|
||||
</div>
|
||||
<div class="log-panel" id="resultBody">
|
||||
<div class="result-empty">暂无抢购结果</div>
|
||||
</div>
|
||||
<div class="log-panel" id="statsBody">
|
||||
<div class="result-empty">暂无统计数据</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,801 +0,0 @@
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
:root{
|
||||
color-scheme:dark;
|
||||
--bg:#0f1419;
|
||||
--surface:#161b22;
|
||||
--surface-2:#1c232c;
|
||||
--surface-3:#232b36;
|
||||
--field:#0d1117;
|
||||
--text:#c9d1d9;
|
||||
--text-strong:#f0f6fc;
|
||||
--muted:#8b949e;
|
||||
--muted-2:#6e7681;
|
||||
--line:#21262d;
|
||||
--line-strong:#30363d;
|
||||
--accent:#388bfd;
|
||||
--accent-soft:rgba(56,139,253,.12);
|
||||
--accent-border:rgba(56,139,253,.35);
|
||||
--danger:#f85149;
|
||||
--danger-soft:rgba(248,81,73,.12);
|
||||
--success:#3fb950;
|
||||
--success-soft:rgba(63,185,80,.12);
|
||||
--warning:#d29922;
|
||||
--warning-soft:rgba(210,153,34,.12);
|
||||
--orange:#f78166;
|
||||
--orange-soft:rgba(247,129,102,.12);
|
||||
--shadow:0 1px 0 rgba(0,0,0,.04),0 8px 24px rgba(0,0,0,.18);
|
||||
--radius:8px;
|
||||
--radius-sm:6px;
|
||||
--focus:0 0 0 3px rgba(56,139,253,.3);
|
||||
}
|
||||
body[data-theme="light"]{
|
||||
color-scheme:light;
|
||||
--bg:#f6f8fa;
|
||||
--surface:#ffffff;
|
||||
--surface-2:#f6f8fa;
|
||||
--surface-3:#eaeef2;
|
||||
--field:#ffffff;
|
||||
--text:#1f2328;
|
||||
--text-strong:#0d1117;
|
||||
--muted:#656d76;
|
||||
--muted-2:#8c959f;
|
||||
--line:#d0d7de;
|
||||
--line-strong:#afb8c1;
|
||||
--accent:#0969da;
|
||||
--accent-soft:rgba(9,105,218,.08);
|
||||
--accent-border:rgba(9,105,218,.3);
|
||||
--danger:#cf222e;
|
||||
--danger-soft:rgba(207,34,46,.08);
|
||||
--success:#1a7f37;
|
||||
--success-soft:rgba(26,127,55,.08);
|
||||
--warning:#9a6700;
|
||||
--warning-soft:rgba(154,103,0,.1);
|
||||
--orange:#bc4c00;
|
||||
--orange-soft:rgba(188,76,0,.08);
|
||||
--shadow:0 1px 0 rgba(31,35,40,.04),0 8px 24px rgba(140,149,159,.15);
|
||||
--focus:0 0 0 3px rgba(9,105,218,.3);
|
||||
}
|
||||
|
||||
html,body{height:100vh;overflow:hidden}
|
||||
body{
|
||||
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','PingFang SC','Microsoft YaHei',sans-serif;
|
||||
background:var(--bg);
|
||||
color:var(--text);
|
||||
font-size:13px;
|
||||
line-height:1.5;
|
||||
-webkit-font-smoothing:antialiased;
|
||||
-moz-osx-font-smoothing:grayscale;
|
||||
}
|
||||
button,input,select{font:inherit;color:inherit}
|
||||
::selection{background:var(--accent-soft);color:var(--text-strong)}
|
||||
::-webkit-scrollbar{width:8px;height:8px}
|
||||
::-webkit-scrollbar-track{background:transparent}
|
||||
::-webkit-scrollbar-thumb{background:var(--line-strong);border-radius:999px}
|
||||
::-webkit-scrollbar-thumb:hover{background:var(--muted-2)}
|
||||
|
||||
/* ============ 主布局 ============ */
|
||||
.app-shell{
|
||||
display:grid;
|
||||
grid-template-columns:300px minmax(0,1fr);
|
||||
height:100vh;
|
||||
min-width:1080px;
|
||||
overflow:hidden;
|
||||
}
|
||||
.left{
|
||||
background:var(--surface);
|
||||
border-right:1px solid var(--line);
|
||||
display:flex;
|
||||
flex-direction:column;
|
||||
min-width:0;
|
||||
min-height:0;
|
||||
overflow:hidden;
|
||||
}
|
||||
.right{
|
||||
display:flex;
|
||||
flex-direction:column;
|
||||
min-width:0;
|
||||
min-height:0;
|
||||
overflow:hidden;
|
||||
}
|
||||
|
||||
/* ============ 品牌区 ============ */
|
||||
.brand{
|
||||
display:flex;
|
||||
align-items:center;
|
||||
justify-content:space-between;
|
||||
gap:12px;
|
||||
padding:16px 18px;
|
||||
border-bottom:1px solid var(--line);
|
||||
}
|
||||
.brand h1{
|
||||
font-size:15px;
|
||||
font-weight:600;
|
||||
color:var(--text-strong);
|
||||
letter-spacing:-.01em;
|
||||
}
|
||||
.brand-sub{
|
||||
font-size:11px;
|
||||
color:var(--muted);
|
||||
margin-top:3px;
|
||||
letter-spacing:.02em;
|
||||
}
|
||||
|
||||
/* ============ 商品侧栏 ============ */
|
||||
.left-header{
|
||||
padding:14px 16px;
|
||||
border-bottom:1px solid var(--line);
|
||||
display:flex;
|
||||
flex-direction:column;
|
||||
gap:10px;
|
||||
}
|
||||
.product-actions{display:flex;gap:6px;align-items:center}
|
||||
.product-actions .btn-primary{flex:1}
|
||||
.product-search{
|
||||
display:flex;
|
||||
align-items:center;
|
||||
gap:8px;
|
||||
background:var(--field);
|
||||
border:1px solid var(--line);
|
||||
border-radius:var(--radius-sm);
|
||||
padding:0 10px;
|
||||
transition:.15s;
|
||||
}
|
||||
.product-search:focus-within{border-color:var(--accent);box-shadow:var(--focus)}
|
||||
.product-search input{
|
||||
flex:1;
|
||||
min-width:0;
|
||||
height:32px;
|
||||
border:0;
|
||||
outline:0;
|
||||
background:transparent;
|
||||
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:20px;height:20px;
|
||||
border:0;border-radius:4px;
|
||||
background:transparent;color:var(--muted);
|
||||
cursor:pointer;font-size:14px;
|
||||
display:none;
|
||||
align-items:center;justify-content:center;
|
||||
}
|
||||
.product-search .search-clear:hover{background:var(--surface-3);color:var(--text-strong)}
|
||||
|
||||
.selected-product-card{
|
||||
padding:12px;
|
||||
border:1px solid var(--line);
|
||||
border-radius:var(--radius);
|
||||
background:var(--surface-2);
|
||||
}
|
||||
.selected-product-card.has-product{
|
||||
border-color:var(--accent-border);
|
||||
background:var(--accent-soft);
|
||||
}
|
||||
.selected-product-label{
|
||||
font-size:10px;
|
||||
color:var(--muted);
|
||||
font-weight:600;
|
||||
text-transform:uppercase;
|
||||
letter-spacing:.08em;
|
||||
margin-bottom:6px;
|
||||
}
|
||||
.selected-product-name{
|
||||
font-size:13px;
|
||||
color:var(--text-strong);
|
||||
font-weight:600;
|
||||
line-height:1.4;
|
||||
word-break:break-all;
|
||||
}
|
||||
.selected-product-meta{
|
||||
display:flex;
|
||||
gap:6px;
|
||||
flex-wrap:wrap;
|
||||
margin-top:8px;
|
||||
}
|
||||
|
||||
.product-meta{
|
||||
font-size:11px;
|
||||
color:var(--muted);
|
||||
padding:0 2px;
|
||||
}
|
||||
.product-meta.good{color:var(--success)}
|
||||
.product-meta.warn{color:var(--warning)}
|
||||
|
||||
.left-list{
|
||||
flex:1;
|
||||
overflow-y:auto;
|
||||
padding:6px 8px 12px;
|
||||
}
|
||||
.prod-item{
|
||||
display:grid;
|
||||
grid-template-columns:40px minmax(0,1fr);
|
||||
gap:10px;
|
||||
padding:8px 10px;
|
||||
margin:2px 0;
|
||||
border-radius:var(--radius-sm);
|
||||
cursor:pointer;
|
||||
border:1px solid transparent;
|
||||
transition:.12s;
|
||||
position:relative;
|
||||
}
|
||||
.prod-item:hover{background:var(--surface-2)}
|
||||
.prod-item.active{
|
||||
background:var(--accent-soft);
|
||||
border-color:var(--accent-border);
|
||||
}
|
||||
.prod-item img{
|
||||
width:40px;height:40px;
|
||||
border-radius:var(--radius-sm);
|
||||
object-fit:cover;
|
||||
background:var(--field);
|
||||
border:1px solid var(--line);
|
||||
}
|
||||
.pinfo{min-width:0;display:flex;flex-direction:column;justify-content:center;gap:4px}
|
||||
.pname{
|
||||
font-size:12.5px;
|
||||
font-weight:500;
|
||||
color:var(--text-strong);
|
||||
white-space:nowrap;
|
||||
overflow:hidden;
|
||||
text-overflow:ellipsis;
|
||||
line-height:1.3;
|
||||
}
|
||||
.pmeta{
|
||||
font-size:11px;
|
||||
color:var(--muted);
|
||||
display:flex;
|
||||
gap:6px;
|
||||
flex-wrap:wrap;
|
||||
}
|
||||
.chip{
|
||||
display:inline-flex;
|
||||
align-items:center;
|
||||
gap:3px;
|
||||
padding:1px 7px;
|
||||
border-radius:999px;
|
||||
background:var(--surface-3);
|
||||
font-size:11px;
|
||||
font-weight:500;
|
||||
white-space:nowrap;
|
||||
color:var(--muted);
|
||||
}
|
||||
.chip-score{color:var(--warning);background:var(--warning-soft)}
|
||||
.chip-stock{color:var(--success);background:var(--success-soft)}
|
||||
.chip-stock.low{color:var(--danger);background:var(--danger-soft)}
|
||||
.empty-state{
|
||||
text-align:center;
|
||||
color:var(--muted);
|
||||
padding:48px 16px;
|
||||
font-size:12px;
|
||||
line-height:1.7;
|
||||
}
|
||||
|
||||
/* ============ 顶部控制台 ============ */
|
||||
.console-head{
|
||||
padding:14px 20px;
|
||||
border-bottom:1px solid var(--line);
|
||||
background:var(--surface);
|
||||
display:flex;
|
||||
align-items:center;
|
||||
justify-content:space-between;
|
||||
gap:16px;
|
||||
}
|
||||
.headline{display:flex;align-items:center;gap:14px;min-width:0}
|
||||
.headline-title{
|
||||
font-size:15px;
|
||||
font-weight:600;
|
||||
color:var(--text-strong);
|
||||
letter-spacing:-.01em;
|
||||
}
|
||||
.headline-meta{
|
||||
font-size:12px;
|
||||
color:var(--muted);
|
||||
margin-top:2px;
|
||||
}
|
||||
.top-actions{display:flex;align-items:center;gap:6px}
|
||||
.countdown-badge{
|
||||
display:inline-flex;
|
||||
align-items:center;
|
||||
padding:4px 10px;
|
||||
border-radius:999px;
|
||||
background:var(--danger-soft);
|
||||
color:var(--danger);
|
||||
border:1px solid var(--danger);
|
||||
font-weight:600;
|
||||
font-size:12px;
|
||||
font-variant-numeric:tabular-nums;
|
||||
}
|
||||
|
||||
/* ============ 状态指标条 ============ */
|
||||
.status-grid{
|
||||
display:flex;
|
||||
gap:24px;
|
||||
padding:12px 20px;
|
||||
background:var(--surface);
|
||||
border-bottom:1px solid var(--line);
|
||||
align-items:center;
|
||||
}
|
||||
.metric{
|
||||
display:flex;
|
||||
align-items:baseline;
|
||||
gap:6px;
|
||||
min-width:0;
|
||||
}
|
||||
.metric-label{
|
||||
font-size:11px;
|
||||
color:var(--muted);
|
||||
text-transform:uppercase;
|
||||
letter-spacing:.06em;
|
||||
font-weight:500;
|
||||
}
|
||||
.metric-value{
|
||||
color:var(--text-strong);
|
||||
font-weight:600;
|
||||
font-size:14px;
|
||||
font-variant-numeric:tabular-nums;
|
||||
}
|
||||
.metric-value.server{color:var(--accent)}
|
||||
.metric-value.success-num{color:var(--success)}
|
||||
.metric-value.fail-num{color:var(--danger)}
|
||||
|
||||
/* ============ 操作条 ============ */
|
||||
.control-strip{
|
||||
padding:12px 20px;
|
||||
background:var(--surface);
|
||||
border-bottom:1px solid var(--line);
|
||||
display:flex;
|
||||
align-items:center;
|
||||
justify-content:space-between;
|
||||
gap:16px;
|
||||
flex-wrap:wrap;
|
||||
}
|
||||
.action-group{display:flex;gap:6px;align-items:center;flex-wrap:wrap}
|
||||
.schedule-bar{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
|
||||
.schedule-item{
|
||||
display:flex;
|
||||
align-items:center;
|
||||
gap:6px;
|
||||
}
|
||||
.schedule-bar label{
|
||||
color:var(--muted);
|
||||
font-size:12px;
|
||||
white-space:nowrap;
|
||||
}
|
||||
.schedule-bar input{
|
||||
height:30px;
|
||||
padding:0 10px;
|
||||
background:var(--field);
|
||||
color:var(--text-strong);
|
||||
border:1px solid var(--line);
|
||||
border-radius:var(--radius-sm);
|
||||
font-size:12.5px;
|
||||
font-variant-numeric:tabular-nums;
|
||||
transition:.15s;
|
||||
}
|
||||
.schedule-bar input:focus{outline:0;border-color:var(--accent);box-shadow:var(--focus)}
|
||||
.schedule-bar input#targetTime{width:108px;text-align:center}
|
||||
.schedule-bar input#advance{width:72px;text-align:center}
|
||||
|
||||
/* ============ 按钮 ============ */
|
||||
.btn{
|
||||
height:30px;
|
||||
padding:0 12px;
|
||||
border:1px solid var(--line);
|
||||
border-radius:var(--radius-sm);
|
||||
background:var(--surface-2);
|
||||
color:var(--text);
|
||||
cursor:pointer;
|
||||
font-size:12.5px;
|
||||
font-weight:500;
|
||||
white-space:nowrap;
|
||||
transition:.12s;
|
||||
display:inline-flex;
|
||||
align-items:center;
|
||||
justify-content:center;
|
||||
gap:6px;
|
||||
}
|
||||
.btn:hover{background:var(--surface-3);border-color:var(--line-strong)}
|
||||
.btn:focus-visible{outline:0;box-shadow:var(--focus)}
|
||||
.btn:disabled{opacity:.55;cursor:not-allowed}
|
||||
.btn-primary{
|
||||
background:var(--accent);
|
||||
border-color:var(--accent);
|
||||
color:#fff;
|
||||
}
|
||||
.btn-primary:hover{filter:brightness(1.08);background:var(--accent)}
|
||||
.btn-danger{
|
||||
background:var(--danger);
|
||||
border-color:var(--danger);
|
||||
color:#fff;
|
||||
}
|
||||
.btn-danger:hover{filter:brightness(1.08);background:var(--danger)}
|
||||
.btn-success{
|
||||
background:var(--success);
|
||||
border-color:var(--success);
|
||||
color:#fff;
|
||||
}
|
||||
.btn-success:hover{filter:brightness(1.08);background:var(--success)}
|
||||
.btn-cancel{
|
||||
background:transparent;
|
||||
color:var(--danger);
|
||||
border-color:var(--danger);
|
||||
}
|
||||
.btn-cancel:hover{background:var(--danger-soft)}
|
||||
.btn-ghost{background:transparent;border-color:transparent;color:var(--muted)}
|
||||
.btn-ghost:hover{color:var(--text-strong);background:var(--surface-2);border-color:var(--line)}
|
||||
.icon-btn{width:30px;padding:0;font-size:14px}
|
||||
.btn-compact{height:26px;font-size:11.5px;padding:0 10px}
|
||||
|
||||
.stat-pill{
|
||||
display:inline-flex;
|
||||
align-items:center;
|
||||
gap:6px;
|
||||
height:24px;
|
||||
padding:0 10px;
|
||||
border:1px solid var(--line);
|
||||
border-radius:999px;
|
||||
background:var(--surface-2);
|
||||
color:var(--muted);
|
||||
font-size:11.5px;
|
||||
font-variant-numeric:tabular-nums;
|
||||
}
|
||||
|
||||
/* ============ 工作区 ============ */
|
||||
.workspace{
|
||||
display:grid;
|
||||
grid-template-columns:minmax(0,1fr) 380px;
|
||||
gap:14px;
|
||||
padding:14px 20px 20px;
|
||||
align-items:stretch;
|
||||
flex:1;
|
||||
min-height:0;
|
||||
overflow:hidden;
|
||||
}
|
||||
.panel{
|
||||
background:var(--surface);
|
||||
border:1px solid var(--line);
|
||||
border-radius:var(--radius);
|
||||
min-width:0;
|
||||
min-height:0;
|
||||
display:flex;
|
||||
flex-direction:column;
|
||||
overflow:hidden;
|
||||
height:100%;
|
||||
}
|
||||
.panel-head{
|
||||
padding:10px 14px;
|
||||
border-bottom:1px solid var(--line);
|
||||
display:flex;
|
||||
align-items:center;
|
||||
justify-content:space-between;
|
||||
gap:10px;
|
||||
background:var(--surface);
|
||||
}
|
||||
.panel-title{
|
||||
font-size:13px;
|
||||
font-weight:600;
|
||||
color:var(--text-strong);
|
||||
}
|
||||
.panel-sub{
|
||||
font-size:11.5px;
|
||||
color:var(--muted);
|
||||
margin-left:8px;
|
||||
font-weight:400;
|
||||
}
|
||||
.panel-head label.stat-pill{cursor:pointer}
|
||||
.panel-head label.stat-pill input{margin:0;cursor:pointer}
|
||||
|
||||
/* ============ 账号表 ============ */
|
||||
.table-wrap{flex:1;overflow:auto;min-height:0}
|
||||
table{width:100%;border-collapse:separate;border-spacing:0;font-size:12.5px}
|
||||
th{
|
||||
background:var(--surface-2);
|
||||
padding:8px 12px;
|
||||
text-align:left;
|
||||
border-bottom:1px solid var(--line);
|
||||
position:sticky;
|
||||
top:0;
|
||||
z-index:1;
|
||||
color:var(--muted);
|
||||
font-size:11px;
|
||||
font-weight:600;
|
||||
text-transform:uppercase;
|
||||
letter-spacing:.04em;
|
||||
white-space:nowrap;
|
||||
}
|
||||
td{padding:9px 12px;border-bottom:1px solid var(--line);vertical-align:middle}
|
||||
tbody tr:last-child td{border-bottom:0}
|
||||
tbody tr{transition:.1s}
|
||||
tbody tr:hover{background:var(--surface-2)}
|
||||
.select-cell{width:36px}
|
||||
.status-col{width:96px}
|
||||
.account-name{font-weight:500;color:var(--text-strong)}
|
||||
.account-sub{font-size:11px;color:var(--muted);margin-top:2px}
|
||||
.account-check{display:inline-flex;align-items:center;cursor:pointer}
|
||||
.check-box{
|
||||
width:16px;height:16px;
|
||||
border-radius:4px;
|
||||
border:1.5px solid var(--line-strong);
|
||||
background:transparent;
|
||||
display:flex;align-items:center;justify-content:center;
|
||||
transition:.15s;
|
||||
color:#fff;
|
||||
font-size:10px;
|
||||
font-weight:700;
|
||||
}
|
||||
.check-box.checked{
|
||||
border-color:var(--accent);
|
||||
background:var(--accent);
|
||||
}
|
||||
.prod-tag{
|
||||
display:inline-flex;
|
||||
max-width:200px;
|
||||
overflow:hidden;
|
||||
text-overflow:ellipsis;
|
||||
white-space:nowrap;
|
||||
background:var(--surface-2);
|
||||
color:var(--text);
|
||||
border:1px solid var(--line);
|
||||
padding:2px 8px;
|
||||
border-radius:999px;
|
||||
font-size:11.5px;
|
||||
}
|
||||
.status-badge{
|
||||
display:inline-flex;
|
||||
align-items:center;
|
||||
padding:2px 8px;
|
||||
border-radius:999px;
|
||||
border:1px solid var(--line);
|
||||
background:var(--surface-2);
|
||||
font-size:11px;
|
||||
font-weight:500;
|
||||
white-space:nowrap;
|
||||
}
|
||||
.status-idle{color:var(--muted)}
|
||||
.status-ready{color:var(--accent);background:var(--accent-soft);border-color:var(--accent-border)}
|
||||
.status-success{color:var(--success);background:var(--success-soft);border-color:var(--success)}
|
||||
.status-fail{color:var(--danger);background:var(--danger-soft);border-color:var(--danger)}
|
||||
.status-warn{color:var(--warning);background:var(--warning-soft);border-color:var(--warning)}
|
||||
.cred-ok{color:var(--success)}
|
||||
.cred-miss{color:var(--danger)}
|
||||
|
||||
/* ============ 日志区 ============ */
|
||||
.side-stack{
|
||||
display:flex;
|
||||
flex-direction:column;
|
||||
gap:14px;
|
||||
min-height:0;
|
||||
height:100%;
|
||||
}
|
||||
.log-wrap{flex:1;min-height:0}
|
||||
.log-header{
|
||||
padding:8px 12px;
|
||||
border-bottom:1px solid var(--line);
|
||||
display:flex;
|
||||
justify-content:space-between;
|
||||
align-items:center;
|
||||
gap:8px;
|
||||
background:var(--surface);
|
||||
}
|
||||
.log-tabs{display:flex;gap:2px;align-items:center}
|
||||
.log-tab{
|
||||
height:26px;
|
||||
padding:0 10px;
|
||||
border-radius:var(--radius-sm);
|
||||
border:0;
|
||||
background:transparent;
|
||||
color:var(--muted);
|
||||
cursor:pointer;
|
||||
font-size:11.5px;
|
||||
font-weight:500;
|
||||
transition:.12s;
|
||||
}
|
||||
.log-tab:hover{color:var(--text-strong);background:var(--surface-2)}
|
||||
.log-tab.active{
|
||||
background:var(--accent-soft);
|
||||
color:var(--accent);
|
||||
}
|
||||
.log-panel{
|
||||
display:none;
|
||||
flex:1;
|
||||
min-height:0;
|
||||
overflow:auto;
|
||||
padding:10px 12px;
|
||||
font-family:'SF Mono','Monaco','Consolas','Courier New',monospace;
|
||||
font-size:11.5px;
|
||||
line-height:1.7;
|
||||
background:var(--field);
|
||||
}
|
||||
.log-panel.active{display:block}
|
||||
.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(--surface);
|
||||
padding:6px 9px;
|
||||
margin:4px 0;
|
||||
border-radius:var(--radius-sm);
|
||||
word-break:break-all;
|
||||
border:1px solid var(--line);
|
||||
}
|
||||
.log-cat{
|
||||
display:inline-block;
|
||||
min-width:32px;
|
||||
text-align:center;
|
||||
margin-right:6px;
|
||||
padding:0 5px;
|
||||
border:1px solid var(--line);
|
||||
border-radius:3px;
|
||||
color:var(--muted);
|
||||
font-size:10px;
|
||||
line-height:1.5;
|
||||
background:var(--surface);
|
||||
}
|
||||
.result-empty{color:var(--muted);padding:14px 4px;font-style:italic}
|
||||
|
||||
/* ============ Toast ============ */
|
||||
.toast-container{
|
||||
position:fixed;
|
||||
top:20px;
|
||||
right:-420px;
|
||||
z-index:9999;
|
||||
transition:right .35s cubic-bezier(.16,1,.3,1);
|
||||
}
|
||||
.toast-container.show{right:20px}
|
||||
.toast{
|
||||
background:var(--surface);
|
||||
border:1px solid var(--line);
|
||||
border-left-width:3px;
|
||||
border-radius:var(--radius);
|
||||
padding:12px 14px;
|
||||
margin-bottom:8px;
|
||||
min-width:300px;
|
||||
max-width:380px;
|
||||
box-shadow:var(--shadow);
|
||||
}
|
||||
.toast-title{
|
||||
font-size:12.5px;
|
||||
font-weight:600;
|
||||
margin-bottom:3px;
|
||||
color:var(--text-strong);
|
||||
}
|
||||
.toast-body{
|
||||
font-size:12px;
|
||||
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 ============ */
|
||||
.modal-overlay{
|
||||
position:fixed;
|
||||
inset:0;
|
||||
background:rgba(0,0,0,.5);
|
||||
z-index:9000;
|
||||
display:none;
|
||||
align-items:center;
|
||||
justify-content:center;
|
||||
backdrop-filter:blur(4px);
|
||||
}
|
||||
.modal-overlay.show{display:flex}
|
||||
.modal{
|
||||
background:var(--surface);
|
||||
border:1px solid var(--line);
|
||||
border-radius:var(--radius);
|
||||
width:480px;
|
||||
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:14px 18px;
|
||||
border-bottom:1px solid var(--line);
|
||||
}
|
||||
.modal-header h3{
|
||||
font-size:14px;
|
||||
font-weight:600;
|
||||
color:var(--text-strong);
|
||||
}
|
||||
.modal-close{
|
||||
width:28px;height:28px;
|
||||
border:0;border-radius:var(--radius-sm);
|
||||
background:transparent;color:var(--muted);
|
||||
cursor:pointer;font-size:18px;
|
||||
display:flex;align-items:center;justify-content:center;
|
||||
}
|
||||
.modal-close:hover{background:var(--surface-2);color:var(--text-strong)}
|
||||
.modal-body{padding:18px}
|
||||
.form-group{margin-bottom:14px}
|
||||
.form-group:last-child{margin-bottom:0}
|
||||
.form-label{
|
||||
display:block;
|
||||
font-size:12px;
|
||||
font-weight:500;
|
||||
color:var(--text);
|
||||
margin-bottom:6px;
|
||||
}
|
||||
.form-hint{
|
||||
font-size:11px;
|
||||
color:var(--muted);
|
||||
margin-top:4px;
|
||||
line-height:1.5;
|
||||
}
|
||||
.form-row{display:flex;gap:12px}
|
||||
.form-row .form-group{flex:1}
|
||||
.form-input,.form-select{
|
||||
width:100%;
|
||||
height:32px;
|
||||
padding:0 10px;
|
||||
background:var(--field);
|
||||
color:var(--text-strong);
|
||||
border:1px solid var(--line);
|
||||
border-radius:var(--radius-sm);
|
||||
font-size:12.5px;
|
||||
transition:.15s;
|
||||
}
|
||||
.form-input:focus,.form-select:focus{outline:0;border-color:var(--accent);box-shadow:var(--focus)}
|
||||
.toggle-row{
|
||||
display:flex;
|
||||
align-items:center;
|
||||
justify-content:space-between;
|
||||
padding:6px 0;
|
||||
}
|
||||
.toggle-label{font-size:12.5px;color:var(--text-strong);font-weight:500}
|
||||
.toggle{position:relative;width:36px;height:20px;cursor:pointer}
|
||||
.toggle input{opacity:0;width:0;height:0}
|
||||
.toggle-slider{
|
||||
position:absolute;inset:0;
|
||||
background:var(--line-strong);
|
||||
border-radius:999px;
|
||||
transition:.2s;
|
||||
}
|
||||
.toggle-slider::before{
|
||||
content:"";
|
||||
position:absolute;
|
||||
left:2px;top:2px;
|
||||
width:16px;height:16px;
|
||||
background:#fff;
|
||||
border-radius:50%;
|
||||
transition:.2s;
|
||||
}
|
||||
.toggle input:checked+.toggle-slider{background:var(--accent)}
|
||||
.toggle input:checked+.toggle-slider::before{transform:translateX(16px)}
|
||||
.modal-footer{
|
||||
padding:12px 18px;
|
||||
border-top:1px solid var(--line);
|
||||
display:flex;
|
||||
justify-content:flex-end;
|
||||
gap:8px;
|
||||
}
|
||||
|
||||
/* ============ 响应式 ============ */
|
||||
@media (max-width: 1180px){
|
||||
html,body{height:auto;overflow:auto}
|
||||
.app-shell{display:block;min-width:0;height:auto;overflow:visible}
|
||||
.left{height:auto;overflow:visible;border-right:0;border-bottom:1px solid var(--line)}
|
||||
.left-list{max-height:280px}
|
||||
.right{overflow:visible;min-height:auto}
|
||||
.status-grid{flex-wrap:wrap;gap:14px 24px}
|
||||
.control-strip{flex-direction:column;align-items:stretch}
|
||||
.schedule-bar{justify-content:flex-end}
|
||||
.workspace{grid-template-columns:1fr;padding:14px;overflow:visible}
|
||||
.table-wrap{max-height:520px}
|
||||
.log-wrap{height:360px}
|
||||
.toast-container{left:12px;right:12px;top:12px}
|
||||
.toast-container.show{right:12px}
|
||||
.toast{min-width:0;width:100%;max-width:none}
|
||||
}
|
||||
Reference in New Issue
Block a user