refactor: 前端JS拆分为8个模块(base/list/monitor/editor/tasks/tools/apps/admin,按依赖顺序加载)+ 删除废弃文件(start_web.bat/main.py/fix_web.bat/cleanup.py,web已全覆盖)+ 文档同步 + 修复/api/summary缺导入

This commit is contained in:
2026-08-11 14:53:04 +08:00
parent 2e491b19e2
commit 060157ea1a
19 changed files with 2584 additions and 2767 deletions
+388
View File
@@ -0,0 +1,388 @@
// 工具 Tab:剪贴板注入/应用版本/STF设备管理 + 维护(重启STF) + adb终端 + Tailscale管理
// ================== 工具:剪贴板注入 ==================
let _clipDevices = []; // [{serial, state}],来源 /api/adb/devices(本地 adb + STF 池)
let _clipSelected = new Set();
// 本地 adb 设备列表共享缓存(剪贴板/终端两个面板同源,10 秒内复用避免重复请求;force 强制刷新)
let _adbDevicesCache = null; // {ts, data}
async function _fetchAdbDevices(force){
const now = Date.now();
if(!force && _adbDevicesCache && now - _adbDevicesCache.ts < 10000){
return _adbDevicesCache.data;
}
const r = await apiGet('/api/adb/devices');
if(!r || !r.ok) return null;
_adbDevicesCache = {ts: now, data: r.devices || []};
return _adbDevicesCache.data;
}
async function loadToolsDevices(force){
const list = await _fetchAdbDevices(force);
const status = document.getElementById('clip-status');
if(list === null){status.textContent='加载失败';showToast('加载设备失败','error');return;}
_clipDevices = list;
const wrap = document.getElementById('clip-devices');
if(!_clipDevices.length){
wrap.innerHTML='<div class="empty" style="padding:20px">无可用设备(本地 adb 或 STF 池均无)</div>';
status.textContent='';
return;
}
wrap.innerHTML = _clipDevices.map(d=>{
const st = d.state==='stf' ? 'STF池' : (d.state==='device' ? '已连接' : d.state);
const stCls = (d.state==='device'||d.state==='stf') ? 'label-success' : 'label-warning';
return '<label data-serial="'+esc(d.serial)+'">'
+'<input type="checkbox" value="'+esc(d.serial)+'"'+( _clipSelected.has(d.serial)?' checked':'')+' onchange="toggleClipDevice(this)">'
+'<span style="font-family:monospace;flex:1;overflow:hidden;text-overflow:ellipsis">'+esc(d.serial)+'</span>'
+'<span class="label '+stCls+'">'+st+'</span></label>';
}).join('');
status.textContent = '共 '+_clipDevices.length+' 台设备';
}
function toggleClipDevice(cb){
if(cb.checked)_clipSelected.add(cb.value);
else _clipSelected.delete(cb.value);
}
// ================== 工具:应用版本管理 ==================
let _appVerRows = []; // [{serial, installed, version_name, version_code, error}]
async function queryAppVers(){
const pkg = document.getElementById('appver-pkg').value.trim();
if(!pkg){showToast('请输入包名','error');return;}
const btn = document.querySelector('#tools-sub-appver .btn-primary');
btn.disabled = true; const old = btn.textContent; btn.textContent = '查询中...';
try{
const r = await apiPost('/api/tools/appver', {pkg});
if(!r || !r.ok){showToast('查询失败: '+(r&&r.error||''),'error');return;}
_appVerRows = Object.entries(r.results||{}).map(([serial,v])=>({serial, ...v}));
setListPager('appver',{
data:()=>_appVerRows,
filter:(x,kw)=>((x.serial||'').toLowerCase().includes(kw)||(x.version_name||'').toLowerCase().includes(kw)||(x.version_code||'').toLowerCase().includes(kw)),
sortValue:(x,k)=>{
if(k==='state'){ if(x.error)return 2; return x.installed?0:1; }
if(k==='code')return parseInt(x.version_code)||0;
if(k==='version')return x.version_name||'';
return x.serial||'';
},
render:renderAppVerRow,
tbody:'tb-appver',pager:'pager-appver',pageSize:10,
empty:'<tr><td colspan="4" class="empty">无可用设备</td></tr>'
});
applyListPager('appver');
initListSearch('appver','search-appver');
document.getElementById('appver-status').textContent = '共 '+(r.total||0)+' 台设备'+(r.fail?','+r.fail+' 台查询失败':'');
if(r.fail)showToast(r.fail+' 台设备查询失败,详见表格','error');
} finally {
btn.disabled = false; btn.textContent = old;
}
}
function renderAppVerRow(x){
if(x.error)return '<tr><td style="font-family:monospace">'+esc(x.serial)+'</td>'+
'<td><span class="label label-warning">查询失败</span></td><td colspan="2">'+esc(x.error)+'</td></tr>';
if(x.installed)return '<tr><td style="font-family:monospace">'+esc(x.serial)+'</td>'+
'<td><span class="label label-success">已安装</span></td>'+
'<td>'+esc(x.version_name||'')+'</td><td style="font-family:monospace">'+esc(x.version_code||'')+'</td></tr>';
return '<tr><td style="font-family:monospace">'+esc(x.serial)+'</td>'+
'<td><span class="label label-default">未安装</span></td><td>-</td><td>-</td></tr>';
}
// ================== 工具:STF 设备管理 ==================
let _stfDevRows = []; // [{ip, inCfg, state}]
async function loadStfDevMgmt(){
const status = document.getElementById('stfdev-status');
const r = await apiGet('/api/stf/devmgmt/status');
if(!r || !r.ok){status.textContent='加载失败';showToast('加载失败: '+(r&&r.error||''),'error');return;}
const configured = r.configured || []; // 脚本 DEVICES 里的 IP(裸 IP)
const connected = {}; // 裸 IP -> state(220 adb devices 的 serial 带 :5555,归一化)
(r.connected || []).forEach(d=>{
const s = d.serial;
connected[s.endsWith(':5555') ? s.slice(0,-5) : s] = d.state;
});
_stfDevRows = Array.from(new Set(configured.concat(Object.keys(connected)))).map(ip=>({
ip, inCfg: configured.includes(ip), state: connected[ip]||''
}));
setListPager('stfdev',{
data:()=>_stfDevRows,
filter:(x,kw)=>x.ip.toLowerCase().includes(kw),
sortValue:(x,k)=>{
if(k==='cfg')return x.inCfg?1:0;
if(k==='state'){ const o={device:0,offline:1,unauthorized:2,'':9}; return (o[x.state]!==undefined)?o[x.state]:9; }
return x.ip||'';
},
render:renderStfDevRow,
tbody:'tb-stfdev',pager:'pager-stfdev',pageSize:10,
empty:'<tr><td colspan="4" class="empty">无设备(脚本未配置、220 adb 也无连接)</td></tr>'
});
applyListPager('stfdev');
initListSearch('stfdev','search-stfdev');
status.textContent = '脚本配置 '+configured.length+' 台,已连接 '+Object.keys(connected).length+' 台';
}
function renderStfDevRow(x){
const stHtml = x.state==='device'
? '<span class="label label-success">已连接</span>'
: (x.state ? '<span class="label label-warning">'+esc(x.state)+'</span>' : '<span class="label label-default">未连接</span>');
return '<tr><td style="font-family:monospace">'+esc(x.ip)+'</td>'+
'<td>'+(x.inCfg?'<span class="label label-info">脚本中</span>':'<span class="label label-default">未配置</span>')+'</td>'+
'<td>'+stHtml+'</td>'+
'<td><button class="btn btn-xs btn-danger" onclick="removeStfDev(\''+esc(x.ip)+'\')">移除(断开+删脚本)</button></td></tr>';
}
async function addStfDev(){
const ip = document.getElementById('stfdev-ip').value.trim();
if(!ip){showToast('请输入设备 IP','error');return;}
const r = await apiPost('/api/stf/devmgmt/add', {ip:ip});
if(!r)return;
if(r.ok){
showToast((r.msgs||[]).join(';'),'success');
document.getElementById('stfdev-ip').value='';
loadStfDevMgmt();
}else showToast('添加失败: '+(r.error||''),'error');
}
async function removeStfDev(ip){
if(!confirm('确定移除 '+ip+'?\n\n将从 connect_devices.sh 删除并断开 220 的 adb 连接,\n设备会从 OpenSTF 设备池下线(任务不再分配到它)。'))return;
const r = await apiPost('/api/stf/devmgmt/remove', {ip:ip});
if(!r)return;
if(r.ok){
showToast((r.msgs||[]).join(';'),'success');
loadStfDevMgmt();
}else showToast('移除失败: '+(r.error||''),'error');
}
async function injectClipboard(){
const text = document.getElementById('clip-text').value.trim();
if(!text){showToast('请输入要注入的文字','error');return;}
if(!_clipSelected.size){showToast('请先勾选目标设备','error');return;}
const btn = document.querySelector('#tools-sub-clipboard .btn-primary');
btn.disabled = true; const old = btn.textContent; btn.textContent = '注入中...';
try{
const r = await apiPost('/api/tools/clipboard/set', {serials:[..._clipSelected], text});
if(!r||!r.ok){showToast('注入失败: '+(r&&r.error||''),'error');return;}
const rows = Object.entries(r.results).map(([s,v])=>
'<tr><td style="font-family:monospace">'+esc(s)+'</td><td>'+(v.ok?'<span class="label label-success">成功</span>':'<span class="label label-danger">失败</span> '+esc(v.msg))+'</td></tr>').join('');
document.getElementById('clip-result').innerHTML = rows;
document.getElementById('clip-result-wrap').style.display = 'block';
showToast('注入完成:成功 '+r.ok_count+' 台,失败 '+r.fail_count+' 台', r.fail_count?'error':'success');
} finally {
btn.disabled = false; btn.textContent = old;
}
}
// ================== Tab: 维护(仅管理员) ==================
// 服务器类命令(不需要 -s 目标设备)
const _NO_DEVICE_CMDS=['devices','version','help','start-server','connect','kill-server','disconnect'];
function _selectedSerial(){
const sel=document.getElementById('adb-device');
return sel?sel.value:'';
}
// 选中目标设备时自动附加 -s <serial>;服务器类命令保持原样
function _attachDevice(cmd){
const serial=_selectedSerial();
if(!serial)return cmd;
if(/^-s\s/.test(cmd))return cmd; // 已手写 -s 不再重复附加
const first=(cmd.split(/\s+/)[0]||'').toLowerCase();
if(_NO_DEVICE_CMDS.indexOf(first)>=0)return cmd;
return '-s '+serial+' '+cmd;
}
async function loadAdbDevices(force){
const sel=document.getElementById('adb-device');
if(!sel)return;
const list=await _fetchAdbDevices(force);
if(list===null)return;
const cur=sel.value;
const stateTxt={device:'在线',offline:'离线',stf:'STF池(未连接)'};
const opts=['<option value="">(不指定设备)</option>'].concat(
list.map(d=>
'<option value="'+esc(d.serial)+'">'+esc(d.serial)+' ('+(stateTxt[d.state]||d.state)+')</option>'
)
);
sel.innerHTML=opts.join('');
if(cur)sel.value=cur;
}
function fillAdbCmd(cmd){
document.getElementById('adb-cmd-input').value=cmd;
}
async function _runAdbFull(full){
const out=document.getElementById('adb-output');
out.textContent='$ '+full+'\n执行中...';
const r=await apiPost('/api/adb/cmd',{cmd:full});
if(!r)return;
if(!r.ok){out.textContent='$ '+full+'\n⚠ '+r.error;return;}
out.textContent='$ '+full+'\n\n'+(r.stdout||'')+(r.stderr?'\n[stderr] '+r.stderr:'')+'\n\n[exit '+r.code+']';
out.scrollTop=out.scrollHeight;
}
async function runAdbCmd(){
const input=document.getElementById('adb-cmd-input');
let cmd=(input.value||'').trim();
if(!cmd){showToast('请输入 adb 命令','error');return;}
cmd=cmd.replace(/^adb\s+/i,''); // 兼容手输/快捷按钮带的 "adb " 前缀,避免拼成 "adb adb devices"
cmd=_attachDevice(cmd);
await _runAdbFull('adb '+cmd);
}
function uninstallStfAgent(){
const serial=_selectedSerial();
if(!serial){showToast('请先选择目标设备','error');return;}
if(!confirm('确定卸载 '+serial+' 上的 STF agent(jp.co.cyberagent.stf)?\n\n卸载后 STF 会在设备重连时自动重新推送安装。'))return;
_runAdbFull('adb -s '+serial+' uninstall jp.co.cyberagent.stf');
}
function reconnectAdbDevice(){
const serial=_selectedSerial();
if(!serial){showToast('请先选择目标设备','error');return;}
_runAdbFull('adb connect '+serial);
}
function checkStfAgent(){
const serial=_selectedSerial();
if(!serial){showToast('请先选择目标设备','error');return;}
_runAdbFull('adb -s '+serial+' shell pm list packages | grep cyberagent');
}
async function restartStf(){
if(!confirm('确定重启 STF 容器?\n\n影响:STF 约 30 秒不可用,期间所有设备占用状态重置,正在执行的任务会受影响。\n\n确认继续吗?'))return;
const st=document.getElementById('stf-restart-status');
st.textContent='正在重启 STF 容器(SSH 执行中,最长 60s)...';
const r=await apiPost('/api/stf/restart',{});
if(!r)return;
if(r.ok){st.textContent='✅ '+r.msg+(r.detail?' ['+r.detail+']':'');showToast(r.msg,'success');}
else{st.textContent='❌ '+r.error;showToast(r.error,'error');}
}
// 测试此步骤弹窗关闭
function closeTestModal(){
const ov=document.getElementById('test-overlay');
if(ov)ov.classList.remove('show');
}
// ================== Tab: 维护 - Tailscale 管理 ==================
// 与其他表格共用 setListPager/applyListPager 统一逻辑(搜索 + 分页)
let _tsDevices=[];
async function loadTailscaleDevices(){
const tb=document.getElementById('tb-tailscale');
const st=document.getElementById('ts-status');
const hint=document.getElementById('ts-hint');
if(!tb)return;
tb.innerHTML='<tr><td colspan="8" class="empty">加载中...</td></tr>';
const r=await apiGet('/api/tailscale/devices');
if(!r)return;
if(!r.ok){
tb.innerHTML='<tr><td colspan="8" class="empty">'+esc(r.error||'获取失败')+'</td></tr>';
if(st)st.textContent='';
const s=await apiGet('/api/tailscale/status');
if(s&&s.ok&&!s.configured&&hint)hint.textContent='⚠ 未配置:'+esc(s.hint||'')+'(写入 .env 后重启服务生效)';
return;
}
if(hint)hint.textContent='';
_tsDevices=r.devices||[];
setListPager('tailscale',{
data:()=>_tsDevices,
filter:(d,kw)=>((d.hostname||'').toLowerCase().includes(kw)||(d.name||'').toLowerCase().includes(kw)||(d.addresses||[]).join(',').includes(kw)),
sortValue:(d,k)=>{
if(k==='ip')return (d.addresses||[])[0]||'';
if(k==='online'){ if(d.online===true)return 0; if(d.online===false)return 2; return 1; } // null=最近12h在线
if(k==='authorized')return d.authorized?1:0;
if(k==='key_expiry')return d.key_expiry_disabled?1:0;
return d[k]||'';
},
render:renderTsRow,
tbody:'tb-tailscale',pager:'pager-tailscale',pageSize:10,
empty:'<tr><td colspan="8" class="empty">tailnet 暂无设备</td></tr>'
});
applyListPager('tailscale');
initListSearch('tailscale','search-tailscale');
if(st)st.textContent='共 '+_tsDevices.length+' 台设备';
}
function renderTsRow(d){
const ip=(d.addresses||[])[0]||'-';
return '<tr>'+
'<td><code>'+esc(d.hostname||'-')+'</code></td>'+
'<td>'+esc(d.name||'-')+'</td>'+
'<td style="font-family:monospace">'+esc(ip)+'</td>'+
'<td>'+esc(d.os||'-')+'</td>'+
'<td>'+((d.online===true)?'<span class="label label-success">在线</span>':(d.online===false?'<span class="label label-default">离线</span>':'<span class="label label-warning" title="12 小时内见过但当前未上报">最近在线</span>'))+'</td>'+
'<td>'+(d.authorized?'<span class="label label-success">已授权</span>':'<span class="label label-danger">未授权</span>')+'</td>'+
'<td>'+(d.key_expiry_disabled?'<span class="label label-warning">永不过期</span>':'<span class="label label-default">会过期</span>')+'</td>'+
'<td style="white-space:nowrap">'+
'<button class="btn btn-xs" onclick="renameTailscaleDevice(\''+esc(d.id)+'\',\''+esc(d.name||'')+'\')">改名</button> '+
'<button class="btn btn-xs" onclick="setTsIp(\''+esc(d.id)+'\',\''+esc((d.addresses||[])[0]||'')+'\')" title="修改设备的 Tailscale IPv4(会断开当前连接,平台 serial 随之变化)">设置IP</button> '+
'<button class="btn btn-xs" onclick="toggleTsAuth(\''+esc(d.id)+'\','+(!d.authorized)+')">'+(d.authorized?'取消授权':'授权')+'</button> '+
'<button class="btn btn-xs" onclick="toggleTsKeyExpiry(\''+esc(d.id)+'\','+(!d.key_expiry_disabled)+')">'+(d.key_expiry_disabled?'恢复过期':'密钥不过期')+'</button> '+
'<button class="btn btn-danger btn-xs" onclick="deleteTailscaleDevice(\''+esc(d.id)+'\')">移除</button>'+
'</td></tr>';
}
function renameTailscaleDevice(id, current){
const name=prompt('修改显示名(只改名称,不影响主机名):', current||'');
if(name===null)return;
apiPost('/api/tailscale/devices/'+id,{name:name}).then(r=>{
if(!r)return;
if(r.ok){showToast(r.msg,'success');loadTailscaleDevices();}
else showToast(r.error,'error');
});
}
function setTsIp(id, current){
const ip=prompt('设置新的 Tailscale IPv4 地址(如 100.100.10.16):\n\n⚠ 改 IP 会断开设备当前 tailscale 连接,且平台设备池(STF serial)随之变化,改完记得同步 STF 设备池/分组/任务目标。', (current||'').split(':')[0]||'');
if(ip===null)return;
const v=ip.trim();
if(!v){showToast('IP 不能为空','error');return;}
apiPost('/api/tailscale/devices/'+id+'/ip',{ipv4:v}).then(r=>{
if(!r)return;
if(r.ok){showToast(r.msg,'success');loadTailscaleDevices();}
else showToast(r.error,'error');
});
}
function toggleTsAuth(id, authorized){
apiPost('/api/tailscale/devices/'+id,{authorized:authorized}).then(r=>{
if(!r)return;
if(r.ok){showToast(authorized?'已授权':'已取消授权','success');loadTailscaleDevices();}
else showToast(r.error,'error');
});
}
function toggleTsKeyExpiry(id, disabled){
apiPost('/api/tailscale/devices/'+id,{key_expiry_disabled:disabled}).then(r=>{
if(!r)return;
if(r.ok){showToast(disabled?'已设置密钥永不过期(设备不再被踢下线)':'已恢复密钥定期过期','success');loadTailscaleDevices();}
else showToast(r.error,'error');
});
}
function deleteTailscaleDevice(id){
if(!confirm('确定从 tailnet 移除该设备?\n设备下次上线需重新授权。'))return;
apiDelete('/api/tailscale/devices/'+id).then(r=>{
if(!r)return;
if(r.ok){showToast(r.msg,'success');loadTailscaleDevices();}
else showToast(r.error,'error');
});
}
function openTailscaleAuthKey(){
const desc=prompt('auth key 用途说明(可空,仅支持英文/数字):','auto_control');
if(desc===null)return;
const reusable=confirm('允许复用(同一 key 给多台设备用)?\n确定=可复用,取消=一次性');
const secs=prompt('有效期(秒,默认 3600=1 小时):','3600');
if(secs===null)return;
const st=document.getElementById('ts-status');
if(st)st.textContent='正在生成 auth key...';
apiPost('/api/tailscale/authkey',{description:desc||'',reusable:reusable,expiry_seconds:parseInt(secs)||3600}).then(r=>{
if(!r)return;
if(st)st.textContent='';
if(r.ok){
const ov=document.getElementById('test-overlay');
document.getElementById('test-modal-title').textContent='Tailscale Auth Key(只显示一次)';
document.getElementById('test-modal-body').innerHTML=
'<div style="margin-bottom:10px">新设备执行 <code>tailscale up --authkey=...</code> 接入(默认已预授权)</div>'+
'<textarea id="ts-key-box" class="form-control" rows="4" style="font-family:monospace" readonly>'+esc(r.key||'')+'</textarea>'+
'<button class="btn btn-primary" style="margin-top:10px" onclick="copyTsKey()">复制</button>'+
' <span id="ts-key-status" class="text-muted"></span>';
ov.classList.add('show');
showToast('Auth Key 已生成','success');
}else{
showToast(r.error,'error');
}
});
}
function copyTsKey(){
const box=document.getElementById('ts-key-box');
if(!box)return;
box.select();
try{document.execCommand('copy');}catch(e){}
const s=document.getElementById('ts-key-status');
if(s)s.textContent='已复制';
}