// 工具 Tab:剪贴板注入/应用版本/设备池管理 + adb终端 + Tailscale管理 + 远程看屏 // ================== 工具:剪贴板注入 ================== let _clipDevices = []; // [{serial, state}],来源 /api/adb/devices(本地 adb + 设备池) 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;} // 剪贴板注入只对在线设备有意义:过滤掉设备池未连接(pool)/离线条目; // adb 终端面板保留全部(重连设备需要),它用自己的渲染逻辑不受影响 _clipDevices = list.filter(d=>d.state==='device'); const wrap = document.getElementById('clip-devices'); if(!_clipDevices.length){ wrap.innerHTML='
无可用设备(本地 adb 或设备池均无)
'; status.textContent=''; return; } wrap.innerHTML = _clipDevices.map(d=>{ const st = d.state==='device' ? '已连接' : (d.state==='pool' ? '设备池' : d.state); const stCls = d.state==='device' ? 'label-success' : 'label-warning'; return ''; }).join(''); status.textContent = '共 '+_clipDevices.length+' 台设备'; } // ================== 设备自动发现(扫描 → 待连接池 → 确认) ================== function loadDiscovery(){ apiGet('/api/devices/discovery').then(r=>{ if(!r||!r.ok)return; const status=document.getElementById('discovery-status'); const enabled=document.getElementById('discovery-enabled'); const interval=document.getElementById('discovery-interval'); const subnets=document.getElementById('discovery-subnets'); if(!enabled)return; // 面板未渲染(离开子分栏) enabled.checked=!!r.enabled; const ac=document.getElementById('discovery-autoclaim'); if(ac)ac.checked=!!r.auto_claim; interval.value=r.interval||60; subnets.value=(r.subnets||[]).join(', '); // 状态行 if(r.scanning){status.textContent='扫描中...';} else if(r.last_scan){ let txt='上次扫描 '+esc(r.last_scan)+' · 开放 '+r.last_result.found+' · 可连 ' +(r.last_result.verified||0)+' · 新增 '+(r.last_result.new||0) +(r.last_result.claimed?' · 自动认领 '+r.last_result.claimed:''); if(r.last_error)txt+=' · ⚠ '+esc(r.last_error); status.textContent=txt; }else{status.textContent='尚未扫描';} // 待连接表格:只显示在线设备(离线候选不可确认连接,隐藏;恢复在线后自动出现) const rows=(r.pending||[]).filter(x=>x.online); _discoveryRows=rows; // 供确认时判断"是不是已有设备换了地址" const tb=document.getElementById('tb-discovery'); if(!tb)return; if(!rows.length){ tb.innerHTML='待连接池为空(扫描到的设备会出现在这里)'; }else{ tb.innerHTML=rows.map(x=>{ const src=x.source==='tailscale' ?'Tailscale' :'局域网'; const onl=x.online ?'在线' :'离线'; // 指纹命中池中已有设备 → 提示"这台是 X 换了地址",确认即认领(不需要起新名) const m=x.match; const ident=m ?'≈ ' +esc(m.name||m.serial)+'(原 '+esc(m.serial)+')' :'新设备'; const btnTxt=m?'认领为『'+esc(m.name||'原设备')+'』':'确认添加'; return ''+esc(x.serial)+'' +''+ident+'' +''+src+''+esc(x.first_seen)+''+esc(x.last_seen)+''+onl+'' +' ' +''; }).join(''); } // 正式池断联设备:仍在设备池(不删除),扫描线程自动重连,可手动立即重连 const offTb=document.getElementById('tb-pool-offline'); if(offTb){ const off=(r.pool_offline||[]); if(!off.length){ offTb.innerHTML='全部在线'; }else{ offTb.innerHTML=off.map(x=>{ return ''+esc(x.serial)+'' +''+(x.name ? esc(x.name) : '未命名')+'' +''+esc(x.model||'-')+'' +'断联·自动重连中' +'' +' ' // 设备换了 IP 时旧地址永远连不上——给一条"换地址"的出路(迁移记录 + 同步引用) +'' +''; }).join(''); } } }); } function reconnectPoolDev(serial){ apiPost('/api/devices/discovery/reconnect',{serial}).then(r=>{ if(r&&r.ok){ showToast('重连已启动(约 5-15 秒生效)','success'); setTimeout(loadDiscovery,8000); }else showToast('重连失败: '+((r&&r.error)||''),'error'); }); } // 指纹匹配自动认领开关(默认关:认领会改写分组/任务引用,交人工确认更稳妥) function toggleAutoClaim(checked){ const tip=checked ? '已开启自动认领:扫描到"同一台设备换了 IP"时会自动迁移记录并同步分组/任务引用' : '已关闭自动认领:扫描到换 IP 的设备会提示,由你点确认'; apiPost('/api/devices/discovery/settings',{auto_claim:checked}).then(r=>{ if(r&&r.ok)showToast(tip,'success'); else{showToast('设置失败: '+((r&&r.error)||''),'error');loadDiscovery();} }); } function toggleDiscovery(checked){ apiPost('/api/devices/discovery/settings',{enabled:checked}).then(r=>{ if(r&&r.ok)showToast('定时扫描已'+(checked?'开启':'关闭'),'success'); else showToast('设置失败: '+((r&&r.error)||''),'error'); }); } function saveDiscoverySettings(){ const subnets=(document.getElementById('discovery-subnets').value||'') .split(',').map(s=>s.trim()).filter(Boolean); const interval=parseInt(document.getElementById('discovery-interval').value)||60; apiPost('/api/devices/discovery/settings',{subnets,interval}).then(r=>{ if(r&&r.ok)showToast('设置已保存','success'); else showToast('保存失败: '+((r&&r.error)||''),'error'); }); } function runDiscoveryScan(){ apiPost('/api/devices/discovery/scan',{}).then(r=>{ if(r&&r.ok){showToast(r.msg||'扫描已启动','success');setTimeout(loadDiscovery,3000);} else showToast('扫描失败: '+((r&&r.error)||''),'error'); }); } function confirmDiscoveryDev(serial){ const row=(_discoveryRows||[]).find(x=>x.serial===serial)||{}; const m=row.match; let name=''; if(m){ // 指纹命中:这是池中某台设备换了地址 → 认领(保留原名与引用),无需再起名 if(!confirm('这台就是『'+(m.name||m.serial)+'』(原地址 '+m.serial+')。\n\n' +'确认认领到 '+serial+' 吗?\n' +'名称、型号、备注都会保留,分组与任务里的旧地址会自动同步到新地址。'))return; }else{ const input=prompt('给这台设备起个名称(必填,平台内唯一):',''); if(input===null)return; name=(input||'').trim(); if(!name){showToast('必须填写设备名称','error');return;} } apiPost('/api/devices/discovery/confirm',{serial,name}).then(r=>{ if(r&&r.ok){ showToast(r.msg||'已加入设备池','success'); loadDevPool();loadDiscovery(); }else showToast('确认失败: '+((r&&r.error)||''),'error'); }); } function ignoreDiscoveryDev(serial){ if(!confirm('忽略 '+serial+'?下轮扫描可能再次发现'))return; apiPost('/api/devices/discovery/ignore',{serial}).then(r=>{ if(r&&r.ok){showToast('已忽略','success');loadDiscovery();} else showToast('操作失败: '+((r&&r.error)||''),'error'); }); } function toggleClipDevice(cb){ if(cb.checked)_clipSelected.add(cb.value); else _clipSelected.delete(cb.value); } // 注入剪贴板(ClipInject APK 通道,Android 10+ 可用): // am start 透明 Activity 写入 + u2 读回比对验证;结果逐台渲染 + toast 汇总 function injectClipboard(){ const text = document.getElementById('clip-text').value; const serials = [..._clipSelected]; if(!serials.length){showToast('请先勾选目标设备','error');return;} if(!text.trim()){showToast('请输入注入内容','error');return;} const btn = Array.from(document.querySelectorAll('#tools-sub-clipboard .btn-primary')) .find(b=>b.textContent.includes('注入剪贴板')); if(btn){btn.disabled = true; const old = btn.textContent; btn.textContent = '注入中...'; btn._old = old;} apiPost('/api/tools/clipboard/set', {serials, text}).then(r=>{ if(!r){showToast('请求失败','error');return;} const rows = Object.entries(r.results||{}).map(([s,v])=>{ const ok = !!v.ok; return ''+esc(s)+'' +'' +(ok?'成功':'失败')+' '+esc(v.msg||'')+''; }).join(''); document.getElementById('clip-result').innerHTML = rows; document.getElementById('clip-result-wrap').style.display='block'; const okc = r.ok_count||0, fail = r.fail_count||0; if(fail>0) showToast(okc+' 台成功,'+fail+' 台失败','error'); else showToast(okc+' 台设备注入成功','success'); }).catch(e=>{showToast('注入异常: '+e,'error');}) .finally(()=>{ if(btn){btn.disabled=false; btn.textContent=btn._old;} }); } // ================== 工具:应用版本管理 ================== 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:'无可用设备' }); 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){ // 设备列显示「名称 · 地址」(名称来自设备池;没名字就只显示地址) const dev=''+esc(devText(x.name,x.serial))+''; if(x.error)return ''+dev+ '查询失败'+esc(x.error)+''; if(x.installed)return ''+dev+ '已安装'+ ''+esc(x.version_name||'')+''+esc(x.version_code||'')+''; return ''+dev+ '未安装--'; } // ================== 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 ;服务器类命令保持原样 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:'离线',pool:'设备池(未连接)'}; const opts=[''].concat( list.map(d=> '' ) ); 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 reconnectAdbDevice(){ const serial=_selectedSerial(); if(!serial){showToast('请先选择目标设备','error');return;} _runAdbFull('adb connect '+serial); } // ================== 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='加载中...'; const r=await apiGet('/api/tailscale/devices'); if(!r)return; if(!r.ok){ tb.innerHTML=''+esc(r.error||'获取失败')+''; 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:'tailnet 暂无设备' }); applyListPager('tailscale'); initListSearch('tailscale','search-tailscale'); if(st)st.textContent='共 '+_tsDevices.length+' 台设备'; } function renderTsRow(d){ const ip=(d.addresses||[])[0]||'-'; return ''+ ''+esc(d.hostname||'-')+''+ ''+esc(d.name||'-')+''+ ''+esc(ip)+''+ ''+esc(d.os||'-')+''+ ''+((d.online===true)?'在线':(d.online===false?'离线':'最近在线'))+''+ ''+(d.authorized?'已授权':'未授权')+''+ ''+(d.key_expiry_disabled?'永不过期':'会过期')+''+ ''+ ' '+ ' '+ ' '+ ' '+ ''+ ''; } 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 连接,且平台设备池(serial)随之变化,改完记得同步设备池/分组/任务目标。', (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= '
新设备执行 tailscale up --authkey=... 接入(默认已预授权)
'+ ''+ ''+ ' '; 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='已复制'; } // ================== Tab: 工具 - 设备池管理(本地 SQLite 清单) ================== let _devPoolRows=[]; // 设备池最近一次结果(改名时取当前名称) let _discoveryRows=[]; // 待连接池最近一次结果(确认时判断是否为已有设备换了地址) async function loadDevPool(){ const tb=document.getElementById('tb-devpool'); const status=document.getElementById('devpool-status'); if(!tb)return; tb.innerHTML='加载中...'; const r=await apiGet('/api/devices/pool'); if(!r||!r.ok){tb.innerHTML='加载失败';return;} let rows=r.devices||[]; // 在线优先排序;「只看在线」勾选时过滤掉离线(IP 换了/长期离线的旧条目不再碍眼) const onlyOnline=document.getElementById('devpool-filter-online') ? document.getElementById('devpool-filter-online').checked : false; rows.sort((a,b)=>(b.online?1:0)-(a.online?1:0)); if(onlyOnline)rows=rows.filter(x=>x.online); _devPoolRows=rows; const onlineCnt=rows.filter(x=>x.online).length; status.textContent=onlyOnline ? '在线 '+rows.length+' 台(共 '+(r.devices||[]).length+' 台配置)' : '共 '+rows.length+' 台,在线 '+onlineCnt+' 台'; tb.innerHTML=rows.map(d=>{ const st= d.enabled ? '启用' : '停用'; const online= d.online ? '在线' : '离线'; // 名称是设备身份:老数据可能有空的 → 标出来提示补 const nameCell = d.name ? esc(d.name) : '未命名'; const fp = d.fingerprint ? '🔑' : '—'; return ''+ ''+esc(d.serial)+''+ ''+nameCell+''+ ''+esc(d.model||'-')+''+ ''+online+''+ ''+st+''+ ''+fp+''+ ''+esc(d.created_at||'')+''+ ''+ ' '+ (d.online ? '' : ' ') + ' '+ ''+ ''; }).join('')||'设备池为空,添加第一台设备开始'; } async function addPoolDev(){ const serial=document.getElementById('devpool-serial').value.trim(); const name=document.getElementById('devpool-name').value.trim(); if(!serial){showToast('请输入 serial','error');return;} if(!name){showToast('请填写设备名称(平台内唯一,用于分组/任务/日志认设备)','error');return;} const r=await apiPost('/api/devices/pool/add',{serial,name}); if(!r)return; if(r.ok){ showToast(r.msg+(serial.includes(':')?'(已尝试连接)':''),'success'); document.getElementById('devpool-serial').value=''; document.getElementById('devpool-name').value=''; loadDevPool(); }else showToast(r.error,'error'); } async function renamePoolDev(serial){ const cur=(((_devPoolRows||[]).find(x=>x.serial===serial))||{}).name||''; const nw=(prompt('设备名称(必填,平台内唯一):', cur)||'').trim(); if(!nw || nw===cur)return; const r=await apiPost('/api/devices/pool/rename',{serial,name:nw}); if(!r)return; if(r.ok){showToast(r.msg,'success');loadDevPool();} else showToast(r.error,'error'); } // 换地址(人工认领):设备换了 IP、旧地址已连不上时用;名称与分组/任务引用都保留 async function relocatePoolDev(serial){ if(!serial){showToast('没取到设备地址,请刷新页面后重试','error');return;} const nw=(prompt('把记录 ' + serial + ' 迁到新地址(设备换 IP、旧地址连不上时用):\n\n' +'名称、型号、备注都会保留,分组与任务里的旧地址会自动同步。','')||'').trim(); if(!nw || nw===serial)return; const r=await apiPost('/api/devices/pool/relocate',{old_serial:serial,new_serial:nw}); if(!r)return; if(r.ok){showToast(r.msg,'success');loadDevPool();loadDiscovery();} else showToast(r.error,'error'); } async function removePoolDev(serial){ if(!confirm('从设备池删除 '+serial+'?\n\n删除后不再参与任务调度(不影响设备本身与其他功能)。'))return; const r=await apiPost('/api/devices/pool/remove',{serial}); if(!r)return; if(r.ok){showToast(r.msg,'success');loadDevPool();} else showToast(r.error,'error'); } async function togglePoolDev(serial,enabled){ const r=await apiPost('/api/devices/pool/toggle',{serial,enabled}); if(!r)return; if(r.ok){showToast(r.msg,'success');loadDevPool();} else showToast(r.error,'error'); } async function reconnectPoolDevices(){ if(!confirm('并发 adb connect 设备池内全部设备?\n\n已连接的自动跳过。'))return; const r=await apiPost('/api/devices/pool/reconnect',{}); if(!r)return; if(r.ok){showToast(r.msg,'success');setTimeout(loadDevPool,12000);} else showToast(r.error,'error'); } async function refreshPoolModels(){ const r=await apiPost('/api/devices/pool/refresh_models',{}); if(!r)return; if(r.ok){showToast(r.msg,'success');setTimeout(loadDevPool,12000);} else showToast(r.error,'error'); }