Files
auto_control/static/admin/tools.js
T
butubb 7ce4ae9016 feat(账号): 账号台账(web 页 + 任务取号 + 设备端身份页);fix(剪贴板): 注入通道改走设备端 Agent
一、账号台账(新表 device_account,schema v8)
- core/ledger.py:CRUD、Excel 粘贴解析(Tab 分隔 / 表头乱序缺列 / 是·否布尔 / 逐行留痕)、
  按范围取号(本机台账 / 全部 / 按设备分组)、设备端账号块、dry_run 预览
- 新「账号」顶级 Tab(权限 devices):列表 + 搜索 + 设备筛选 + 增删改 +
  粘贴导入(默认先预览再写,逐行显示 新增/覆盖/跳过/失败 + 留痕)
- 设备池加「账号 N」列(0 个显示"未登记"),点开看该设备账号明细
- 任务「条件判断」新增 cmp_source / cmp_group:候选值可直接来自台账,
  与手填值**合并(OR)**;取不到号时回落手填值,并把原因写进步骤明细与日志
  (否则"永远走 else 分支"而任务照样显示成功,最难查)
- 设备端契约:身份页多推 accounts_b64(base64 JSON;默认不含手机号;
  超预算按整条丢且绝不算字节切),见 doc/DEVICE_AGENT.md §5.2.1
- 备份/文档红线:TABLE_LABELS 加「账号台账」;DATA_MODEL/API/ARCHITECTURE/DEPLOY/
  TASK_DEV/DEVICE_AGENT/DEVELOPMENT/README 同步;顺手补上 DATA_MODEL 漏列的 done_mark

⚠ 台账里的抖音号是**纯号**,只当"比对用的候选值":绝不能拿去填「去重」的身份元素
  (身份是元素原文逐字算 key,格式不同会让去重静默失效)。代码与文档都写明了。

二、剪贴板注入通道(修:平台还在调早期的独立 APK)
- 改为按顺序尝试:设备端 Agent(com.example.deviceagent/.ClipActivity)→
  旧版独立 ClipInject 兜底;两个都没有时报"需要设备端 Agent"(不再只说 ClipInject)
- 读回验证改成**轮询到 3 秒**:透明 Activity 要等窗口拿到焦点才写,
  原来只睡 0.5s 会读到上一次的内容 → 误报"写入可能被拒"(实测内容已写入却回失败)
2026-09-24 16:12:58 +08:00

675 lines
34 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 工具 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(){
loadBattery(); // 电量监控与发现同住一个子面板,跟着它的 10s 轮询一起刷新状态行
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('');
}
}
});
}
// ================== 设备电量监控(定时采集 + 低电量告警) ==================
// 电量本身随 /api/status 下发(监控页电量列 / 大屏卡片),这里只管配置与采集器状态。
let _batteryLoaded=false; // 表单只回填一次:10s 轮询不能把用户正在输的值冲掉
function loadBattery(){
apiGet('/api/devices/battery').then(r=>{
if(!r||!r.ok)return;
const en=document.getElementById('battery-enabled');
if(!en)return; // 面板不在页面上
const s=r.settings||{};
if(!_batteryLoaded){
en.checked=!!s.enabled;
document.getElementById('battery-low').value=s.low;
document.getElementById('battery-critical').value=s.critical;
document.getElementById('battery-skip-charging').checked=!!s.skip_charging;
document.getElementById('battery-interval').value=s.interval;
_batteryLoaded=true;
}
const st=document.getElementById('battery-status');
if(r.scanning)st.textContent='采集中...';
else if(r.last_scan)st.textContent='上次采集 '+r.last_scan[0]+' · 成功 '+r.last_scan[1]+'/'+r.last_scan[2]+' 台';
else st.textContent='尚未采集';
});
}
function saveBatterySettings(){
const body={
enabled:document.getElementById('battery-enabled').checked,
low:document.getElementById('battery-low').value,
critical:document.getElementById('battery-critical').value,
skip_charging:document.getElementById('battery-skip-charging').checked,
interval:document.getElementById('battery-interval').value,
};
apiPost('/api/devices/battery/settings',body).then(r=>{
if(!r||!r.ok){showToast('保存失败: '+((r&&r.error)||''),'error');return;}
showToast(r.msg||'已保存','success');
// 回填后端钳制后的真实值(填 999 会被钳到 100,严重阈值高于低电量会被压回来)
const s=r.settings;
if(s){
document.getElementById('battery-low').value=s.low;
document.getElementById('battery-critical').value=s.critical;
document.getElementById('battery-interval').value=s.interval;
}
});
}
function scanBatteryNow(){
apiPost('/api/devices/battery/scan',{}).then(r=>{
if(r&&r.ok){
showToast(r.msg||'采集已启动','success');
setTimeout(loadBattery,3000);
if(typeof loadMonitor==='function')setTimeout(loadMonitor,3000); // 监控页设备表跟着更新
}else showToast('采集失败: '+((r&&r.error)||''),'error');
});
}
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="9" class="empty">加载中...</td></tr>';
const r=await apiGet('/api/devices/pool');
if(!r||!r.ok){tb.innerHTML='<tr><td colspan="9" class="empty">加载失败</td></tr>';return;}
// 账号台账:这台设备登记了几个号(顺手把明细也缓存下来,点开就弹)
if(typeof loadLedgerCounts==='function'){
try{ await loadLedgerCounts(); }catch(e){}
}
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>'+
// 账号台账(「账号」页维护):0 个号显示"未登记"而不是空白 ——
// 空白会让人以为功能坏了,而"未登记"是明确信息
'<td style="white-space:nowrap">'+(typeof ledgerCell==='function'
? ledgerCell(d.serial, d.name||'') : '—')+'</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="9" 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>';
}