配合设备端 Agent 新增的「扫码配置」:人在机架前、设备还没接进平台时,
不用插线也不用在手机上打字输地址/令牌(还容易漏指纹)。
- `GET /api/devices/qrcode?serial=`:生成配置二维码(PNG data-url),内容为
{"v":1,"server":…,"token":…,"fingerprint":…,"name":…}
* 前置:设备端商店已启用(令牌从那儿来),否则 400 并说明去哪儿开
* `warn` 非空必须显示:①用 localhost 打开平台时,二维码里的地址手机会连不上
(提示改用局域网地址,_lan_ip() 优先挑 192.168/10.x,**不用** gethostbyname ——
那会返回 Tailscale 的 100.x 或 docker 的 172.17,手机根本连不上)
②设备还没采到指纹 → 扫完平台认不出是哪台
- 设备池每行加「二维码」按钮 → 弹窗显示二维码 + 设备名/地址 + 可展开看原始内容
- requirements 加 qrcode(纯 Python,配合已有的 Pillow 出 PNG,不引前端二维码库)
- 文档:API.md §6.2.2 新增、§6.2.1 名称呈现表补齐(剪贴板/安装弹窗/版本管理/
安装进度四处);DEVICE_AGENT.md §5.3 新增二维码内容格式(设备端契约)
验证:二维码用 cv2.QRCodeDetector 解回来与 payload 完全一致;手机上实测——
扫 A07 的码 → 相机启动 → 解码 → 保存 → 立刻拉清单,平台认出「A07」。
610 lines
31 KiB
JavaScript
610 lines
31 KiB
JavaScript
// 工具 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='<div class="empty" style="padding:20px">无可用设备(本地 adb 或设备池均无)</div>';
|
||
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 '<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(devText(d.name,d.serial))+'</span>'
|
||
+'<span class="label '+stCls+'">'+st+'</span></label>';
|
||
}).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='<tr><td colspan="7" class="empty">待连接池为空(扫描到的设备会出现在这里)</td></tr>';
|
||
}else{
|
||
tb.innerHTML=rows.map(x=>{
|
||
const src=x.source==='tailscale'
|
||
?'<span class="label label-info">Tailscale</span>'
|
||
:'<span class="label label-warning">局域网</span>';
|
||
const onl=x.online
|
||
?'<span class="label label-success">在线</span>'
|
||
:'<span class="label label-warning">离线</span>';
|
||
// 指纹命中池中已有设备 → 提示"这台是 X 换了地址",确认即认领(不需要起新名)
|
||
const m=x.match;
|
||
const ident=m
|
||
?'<span class="label label-info" title="指纹一致:同一台设备换了地址;确认后会迁移原记录并同步分组/任务">≈ '
|
||
+esc(m.name||m.serial)+'(原 '+esc(m.serial)+')</span>'
|
||
:'<span class="text-muted">新设备</span>';
|
||
const btnTxt=m?'认领为『'+esc(m.name||'原设备')+'』':'确认添加';
|
||
return '<tr><td style="font-family:monospace">'+esc(x.serial)+'</td>'
|
||
+'<td>'+ident+'</td>'
|
||
+'<td>'+src+'</td><td>'+esc(x.first_seen)+'</td><td>'+esc(x.last_seen)+'</td><td>'+onl+'</td>'
|
||
+'<td><button class="btn btn-primary btn-xs" data-serial="'+esc(x.serial)+'" onclick="confirmDiscoveryDev(this.dataset.serial)">'+btnTxt+'</button> '
|
||
+'<button class="btn btn-xs" data-serial="'+esc(x.serial)+'" onclick="ignoreDiscoveryDev(this.dataset.serial)">忽略</button></td></tr>';
|
||
}).join('');
|
||
}
|
||
// 正式池断联设备:仍在设备池(不删除),扫描线程自动重连,可手动立即重连
|
||
const offTb=document.getElementById('tb-pool-offline');
|
||
if(offTb){
|
||
const off=(r.pool_offline||[]);
|
||
if(!off.length){
|
||
offTb.innerHTML='<tr><td colspan="5" class="empty">全部在线</td></tr>';
|
||
}else{
|
||
offTb.innerHTML=off.map(x=>{
|
||
return '<tr><td style="font-family:monospace">'+esc(x.serial)+'</td>'
|
||
+'<td>'+(x.name ? esc(x.name) :
|
||
'<span class="label label-warning">未命名</span>')+'</td>'
|
||
+'<td>'+esc(x.model||'-')+'</td>'
|
||
+'<td><span class="label label-danger">断联·自动重连中</span></td>'
|
||
+'<td style="white-space:nowrap">'
|
||
+'<button class="btn btn-xs" data-serial="'+esc(x.serial)+'" onclick="reconnectPoolDev(this.dataset.serial)">立即重连</button> '
|
||
// 设备换了 IP 时旧地址永远连不上——给一条"换地址"的出路(迁移记录 + 同步引用)
|
||
+'<button class="btn btn-xs" data-serial="'+esc(x.serial)+'" title="设备换了 IP、这个旧地址已经连不上时:把记录迁到新地址(名称与分组/任务引用自动保留)" onclick="relocatePoolDev(this.dataset.serial)">换地址</button>'
|
||
+'</td></tr>';
|
||
}).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 '<tr><td style="font-family:monospace">'+esc(s)+'</td>'
|
||
+'<td><span class="label '+(ok?'label-success':'label-danger')+'">'
|
||
+(ok?'成功':'失败')+'</span> '+esc(v.msg||'')+'</td></tr>';
|
||
}).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:'<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){
|
||
// 设备列显示「名称 · 地址」(名称来自设备池;没名字就只显示地址)
|
||
const dev='<td style="font-family:monospace">'+esc(devText(x.name,x.serial))+'</td>';
|
||
if(x.error)return '<tr>'+dev+
|
||
'<td><span class="label label-warning">查询失败</span></td><td colspan="2">'+esc(x.error)+'</td></tr>';
|
||
if(x.installed)return '<tr>'+dev+
|
||
'<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>'+dev+
|
||
'<td><span class="label label-default">未安装</span></td><td>-</td><td>-</td></tr>';
|
||
}
|
||
|
||
// ================== 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:'离线',pool:'设备池(未连接)'};
|
||
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 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='<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 连接,且平台设备池(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=
|
||
'<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='已复制';
|
||
}
|
||
|
||
|
||
// ================== 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='<tr><td colspan="8" class="empty">加载中...</td></tr>';
|
||
const r=await apiGet('/api/devices/pool');
|
||
if(!r||!r.ok){tb.innerHTML='<tr><td colspan="8" class="empty">加载失败</td></tr>';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
|
||
? '<span class="label label-success">启用</span>'
|
||
: '<span class="label label-default">停用</span>';
|
||
const online= d.online
|
||
? '<span class="label label-success">在线</span>'
|
||
: '<span class="label label-warning">离线</span>';
|
||
// 名称是设备身份:老数据可能有空的 → 标出来提示补
|
||
const nameCell = d.name
|
||
? esc(d.name)
|
||
: '<span class="label label-warning" title="设备名称必填且唯一(分组/任务/日志按名称认设备);点右侧「改名」补上">未命名</span>';
|
||
const fp = d.fingerprint
|
||
? '<span title="设备指纹 '+esc(d.fingerprint)+'(ro.serialno):换 IP 后靠它自动认领">🔑</span>'
|
||
: '<span class="text-muted" title="尚未采集到指纹:设备离线时采不到;采集到之后换 IP 才能自动认领">—</span>';
|
||
return '<tr>'+
|
||
'<td style="font-family:monospace">'+esc(d.serial)+'</td>'+
|
||
'<td>'+nameCell+'</td>'+
|
||
'<td>'+esc(d.model||'-')+'</td>'+
|
||
'<td>'+online+'</td>'+
|
||
'<td>'+st+'</td>'+
|
||
'<td style="text-align:center">'+fp+'</td>'+
|
||
'<td style="color:var(--text-light)">'+esc(d.created_at||'')+'</td>'+
|
||
'<td style="white-space:nowrap">'+
|
||
'<button class="btn btn-xs" onclick="renamePoolDev(\''+esc(d.serial)+'\')">改名</button> '+
|
||
'<button class="btn btn-xs" title="生成配置二维码:设备端 Agent 扫一下即配好(平台地址/令牌/指纹)" onclick="showDevQrcode(\''+esc(d.serial)+'\')">二维码</button> '+
|
||
(d.online ? '' :
|
||
'<button class="btn btn-xs" title="设备换了 IP、旧地址连不上时:把这条记录迁到新地址(名称与分组/任务引用自动保留)" onclick="relocatePoolDev(\''+esc(d.serial)+'\')">换地址</button> ') +
|
||
'<button class="btn btn-xs" onclick="togglePoolDev(\''+esc(d.serial)+'\','+(!d.enabled)+')">'+(d.enabled?'停用':'启用')+'</button> '+
|
||
'<button class="btn btn-xs btn-danger" onclick="removePoolDev(\''+esc(d.serial)+'\')">删除</button>'+
|
||
'</td></tr>';
|
||
}).join('')||'<tr><td colspan="8" class="empty">设备池为空,添加第一台设备开始</td></tr>';
|
||
}
|
||
|
||
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');
|
||
}
|
||
|
||
// ================== 设备配置二维码(设备端 Agent 扫码即配好) ==================
|
||
// 二维码内容:平台地址 + 设备令牌 + 这台设备的指纹(JSON)。
|
||
// 手机上的 Agent 扫一下 → 保存这三项 → 不用在手机上打字、也不用连 adb。
|
||
async function showDevQrcode(serial){
|
||
document.getElementById('modal-title').textContent='设备配置二维码';
|
||
document.getElementById('modal-body').innerHTML='<p class="text-muted">生成中...</p>';
|
||
document.getElementById('modal-footer').innerHTML=
|
||
'<button class="btn" onclick="closeModal()">关闭</button>';
|
||
showModal();
|
||
const r=await apiGet('/api/devices/qrcode?serial='+encodeURIComponent(serial));
|
||
const box=document.getElementById('modal-body');
|
||
if(!r||!r.ok){
|
||
box.innerHTML='<div style="background:#fdecea;border:1px solid #f5c6cb;color:#b42318;'+
|
||
'padding:10px 12px;border-radius:6px">'+esc((r&&r.error)||'生成失败')+'</div>';
|
||
return;
|
||
}
|
||
const warn=r.warn?'<div style="background:#fff3cd;border:1px solid #ffda6a;color:#7a5b00;'+
|
||
'padding:8px 12px;border-radius:6px;margin-bottom:10px;font-size:13px">⚠️ '+esc(r.warn)+'</div>':'';
|
||
box.innerHTML=
|
||
warn+
|
||
'<div style="text-align:center;padding:8px 0">'+
|
||
'<img src="'+r.png+'" style="width:240px;height:240px;image-rendering:pixelated;'+
|
||
'background:#fff;padding:8px;border:1px solid #e5e7eb;border-radius:8px" alt="配置二维码">'+
|
||
'<div style="margin-top:10px;font-size:15px"><strong>'+esc(r.name||'(未命名)')+
|
||
'</strong> <span style="font-family:monospace;color:#6b7280">'+esc(r.serial)+'</span></div>'+
|
||
'</div>'+
|
||
'<div class="help" style="margin-top:6px">在设备上打开 <strong>设备 Agent</strong> → 点「扫码配置」→ 扫这个二维码,'+
|
||
'平台地址、令牌、指纹一次写入,不用在手机上打字。</div>'+
|
||
'<details style="margin-top:8px"><summary style="cursor:pointer;color:#6b7280;font-size:12px">'+
|
||
'查看二维码内容(排查用)</summary>'+
|
||
'<div style="font-family:monospace;font-size:11px;color:#64748b;word-break:break-all;'+
|
||
'background:#f8fafc;padding:8px;border-radius:6px;margin-top:6px">'+esc(r.payload)+'</div></details>';
|
||
}
|