feat: 自动认领开关 + 全站按设备名称显示

一、指纹匹配自动认领(可选,默认关)
- 发现设置新增「指纹匹配自动认领」勾选(app_meta: discovery_auto_claim,默认 0)
- 打开后:扫描发现某设备指纹与池中已有记录一致(同一台换了 IP)→ 自动迁移记录到新地址
  并同步分组/任务引用,零点击;关闭时维持"识别自动 + 人工点一次确认"
- 默认关的原因:认领会改写分组/任务引用(数据结构变动),交人工确认更稳妥
- 扫描结果与状态行会显示本轮自动认领了几台

二、设备名称在界面上呈现(凡选择/展示设备处都显示名称)
- 新增前端 helper `devText(name, serial)`(base.js):有名称→「名称 · serial」
- 监控页设备表:名称加粗为主、地址作副行(未命名显示橙色提醒);任务概况的覆盖设备
  chip 也优先显示名称(tooltip 保留完整地址)
- AI 控制台:目标设备下拉、实时画面设备下拉、目标/运行中提示都带名称(serial→name 映射)
- 任务编辑器「指定设备」下拉、分组编辑的设备勾选列表:带名称
- 后端 `/api/devices` 新增 `items`([{serial,name,model}],`devices` 保持兼容);
  `/api/agent/devices` 增加 `name` 字段
- MCP `de_list_devices` 返回 `name`,并在工具说明与 Agent 系统提示里要求"汇报用名称、
  调工具用 serial"

文档:API.md(items/name/auto_claim + §6.2.1 名称呈现表)、MCP.md(工具返回)

自测(全通过):自动认领端到端(开开关→扫描→自动迁址 + 名称保留 + 分组/任务引用同步 +
待连接池清理 + 开关默认关且可持久化);名称显示浏览器验证(监控页/覆盖设备 chip/AI 目标与
观看下拉/任务编辑器/分组弹窗/发现设置开关);设备指纹与人工认领回归
This commit is contained in:
2026-09-11 11:16:24 +08:00
parent 0a1b4d6122
commit 79cdf61ed2
15 changed files with 161 additions and 30 deletions
+8 -3
View File
@@ -4,7 +4,9 @@ async function loadGroups(){
const r=await apiGet('/api/groups');if(!r)return;
_groupsList=r.groups||[];
const dr=await apiGet('/api/devices');
_devicesList=dr?dr.devices||[]:[];
// items 带名称(设备身份);老接口只有 serial 列表时兜底成对象
_devicesList=(dr&&dr.items)?dr.items
:((dr&&dr.devices)||[]).map(s=>({serial:s,name:'',model:''}));
renderGroupTable();
}
@@ -43,9 +45,12 @@ function openGroupModal(name){
document.getElementById('modal-title').textContent=isEdit?'编辑分组':'新建分组';
const g=group||{name:'',description:'',serials:[]};
const current= new Set(g.serials||[]);
const allSerials=[...new Set([..._devicesList, ...(g.serials||[])])].sort();
const nameOf={};
(_devicesList||[]).forEach(x=>{ if(x&&x.serial) nameOf[x.serial]=x.name||''; });
const allSerials=[...new Set([...(_devicesList||[]).map(x=>x.serial||x), ...(g.serials||[])])].sort();
const deviceCheckboxes=allSerials.map(s=>
'<label><input type="checkbox" value="'+esc(s)+'" '+(current.has(s)?'checked':'')+'>'+esc(s)+'</label>'
'<label><input type="checkbox" value="'+esc(s)+'" '+(current.has(s)?'checked':'')+'>'
+esc(nameOf[s]?devText(nameOf[s], s):s)+'</label>'
).join('');
document.getElementById('modal-body').innerHTML=
+19 -6
View File
@@ -16,6 +16,12 @@ function _friendlyAgentError(msg){
// ================== 初始化 ==================
let _agentDefaultSerial = '';
// 目标设备显示文案:名称优先(设备身份),没有名称退地址
let _agentTargets={}; // serial -> name
function _agentTargetText(serial){
const nm=_agentTargets[serial];
return nm ? (nm + ' · ' + serial) : String(serial||'');
}
function initAgentChat(){
if(_agentCfgLoaded)return;
_agentCfgLoaded = true;
@@ -81,9 +87,11 @@ function loadAgentTargetDevices(){
if(!sel)return;
const cur = sel.value;
const devs = (r.devices||[]).filter(x=>x.online);
_agentTargets = {};
devs.forEach(d=>{ if(d.name) _agentTargets[d.serial]=d.name; });
sel.innerHTML = '<option value="">请选择设备(AI 只操作选定设备)…</option>'
+ devs.map(d=>{
const label = d.serial + (d.model ? ' · ' + d.model : '')
const label = devText(d.name, d.serial) + (d.model ? ' · ' + d.model : '')
+ (d.busy ? ' ⛔ 任务中:' + d.task_job : '');
return '<option value="'+esc(d.serial)+'"' + (d.busy ? ' disabled' : '')
+ '>' + esc(label) + '</option>';
@@ -93,7 +101,7 @@ function loadAgentTargetDevices(){
else if(_agentDefaultSerial && [...sel.options].some(o=>o.value===_agentDefaultSerial))
sel.value = _agentDefaultSerial;
const hint = document.getElementById('agent-target-hint');
if(hint && sel.value) hint.textContent = '将操作:' + sel.value;
if(hint && sel.value) hint.textContent = '将操作:' + _agentTargetText(sel.value);
});
}
@@ -114,10 +122,11 @@ function pollRunState(){
if(hint){
hint.textContent = running
? ('⏳ 运行中' + (r.started ? ' ' + r.started + ' 起' : '')
+ (r.serial ? ' · ' + r.serial : '')
+ (r.serial ? ' · ' + _agentTargetText(r.serial) : '')
+ ':' + (r.prompt||'').slice(0,70))
: (document.getElementById('agent-target-select').value
? '将操作:' + document.getElementById('agent-target-select').value : '');
? '将操作:' + _agentTargetText(document.getElementById('agent-target-select').value)
: '');
}
document.getElementById('btn-agent-send').disabled = running;
// 运行中同步 token(本窗口没订阅事件流时也能看到进度)
@@ -608,9 +617,13 @@ function loadLiveDevices(){
const sel = document.getElementById('agent-live-select');
if(!sel)return;
const cur = sel.value;
const devs = (r.devices||[]).filter(x=>x.indexOf(':')>=0);
// items 带设备名称(设备身份);老接口只有 serial 列表时兜底
const items = (r.items && r.items.length ? r.items
: (r.devices||[]).map(x=>({serial:x, name:''})))
.filter(x=>String(x.serial||'').indexOf(':')>=0);
sel.innerHTML = '<option value="">选择设备观看…</option>'
+ devs.map(s=>'<option value="'+esc(s)+'"'+(s===cur?' selected':'')+'>'+esc(s)+'</option>').join('');
+ items.map(x=>'<option value="'+esc(x.serial)+'"'+(x.serial===cur?' selected':'')+'>'
+esc(devText(x.name, x.serial))+'</option>').join('');
});
}
+7
View File
@@ -3,6 +3,13 @@
const ACTION_LABELS={like:'点赞',comment:'评论',follow:'关注',share:'分享'};
const STATUS_MAP={idle:{t:'未启动',c:'default'},connecting:{t:'连接中',c:'warning'},running:{t:'运行中',c:'success'},done:{t:'完成',c:'primary'},error:{t:'异常',c:'danger'},released:{t:'已释放',c:'default'},failed:{t:'失败',c:'danger'}};
const TARGET_MAP={all:'全部空闲',group:'按分组',serial:'指定设备'};
// 设备显示文案:**名称是设备身份**,地址只是当前连接位置。
// 有名称 → 「名称 · serial」;没名称(历史数据)→ 只显示 serial
function devText(name, serial){
const n=String(name||'').trim();
return n ? (n + ' · ' + serial) : String(serial||'');
}
const APP_NAMES={'com.ss.android.ugc.aweme':'抖音','com.ss.android.ugc.aweme.lite':'抖音极速版','com.smile.gifmaker':'快手','com.kuaishou.nebula':'快手极速版','com.tencent.mm':'微信','com.tencent.mobileqq':'QQ','com.tencent.qqlive':'腾讯视频','com.eg.android.AlipayGphone':'支付宝','com.taobao.taobao':'淘宝','com.tmall.wireless':'天猫','com.jingdong.app.mall':'京东','com.xunmeng.pinduoduo':'拼多多','com.ss.android.article.news':'今日头条','com.zhihu.android':'知乎','com.miui.home':'桌面','com.android.launcher':'桌面','com.android.settings':'设置','com.android.systemui':'系统','com.github.uiautomator':'U2','com.github.uiautomator.test':'U2测试'};
function esc(s){return String(s||'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));}
+10 -4
View File
@@ -355,8 +355,12 @@ function renderDeviceRow(dev){
const clearBtn=canDev&&(dev.worker_status==='error'||dev.worker_status==='failed')
?'<button class="btn btn-xs" onclick="clearDeviceError(\''+esc(dev.serial)+'\')" title="清除该设备的异常状态,恢复为未启动">清除异常</button>':'';
const btns='<div style="display:flex;gap:4px;flex-wrap:wrap">'+shotBtn+locateBtn+clearBtn+stopBtn+'</div>';
const devName=dev.device_name||dev.model||'';
const devLabel=(devName?'<strong>'+esc(dev.serial)+'</strong><br><span style="font-size:11px;color:#94a3b8">'+esc(devName)+'</span>':'<strong>'+esc(dev.serial)+'</strong>')+offline;
// 名称优先(设备身份),地址作副标题;未命名的历史数据显示提醒
const nm=(dev.device_name||'').trim();
const devLabel=(nm
? '<strong>'+esc(nm)+'</strong><br><span style="font-size:11px;color:#94a3b8;font-family:var(--mono)">'+esc(dev.serial)+'</span>'
: '<strong style="font-family:var(--mono)">'+esc(dev.serial)+'</strong>'
+'<br><span style="font-size:11px;color:#d97706">未命名</span>') + offline;
// 复选框列
const checked=_selectedSerials.has(dev.serial)?'checked':'';
const rowCls=_selectedSerials.has(dev.serial)?'row-checked':'';
@@ -462,11 +466,13 @@ function _jobCoverageHtml(j){
if(online)onlineN++;
if(busy)busyN++;
const cls=busy?'running':(online?'online':'offline');
const title=s+(d.model?' · '+d.model:'')
// chip 优先显示设备名称(身份),没有名称才退回地址
const nm=(d.device_name||'').trim();
const title=s+(nm?' · '+nm:'')+(d.model?' · '+d.model:'')
+(busy?' · 任务中:'+(d.task_job||''):'')
+(online?'':' · 离线或不在设备池');
return '<span class="cov-chip '+cls+'" title="'+esc(title)+'">'
+esc(s.replace(/:5555$/,''))+(busy?' ⏳':'')+(online?'':' ✕')+'</span>';
+esc(nm || s.replace(/:5555$/,''))+(busy?' ⏳':'')+(online?'':' ✕')+'</span>';
}).join('');
return '<div class="job-cov"><span class="kv">覆盖 '+serials.length+' 台 · 在线 '+onlineN
+(busyN?' · 运行中 '+busyN:'')+'</span>'
+5 -2
View File
@@ -12,7 +12,9 @@ async function loadTasks(){
_jobsData=jr.jobs||[];
_taskTypes=tr?tr.task_types||[]:[];
_groupsList=gr?gr.groups||[]:[];
_devicesList=dr?dr.devices||[]:[];
// items 带名称(设备身份);老接口只有 serial 列表时兜底成对象
_devicesList=(dr&&dr.items)?dr.items
:((dr&&dr.devices)||[]).map(s=>({serial:s,name:'',model:''}));
renderTaskTable();
}
@@ -72,7 +74,8 @@ function openTaskModal(jobId){
const j=job||{name:'',task_type:'generic_steps',enabled:true,target:{mode:'all'},params:{},schedule:{mode:'once'},retry:{max_attempts:1,delay:60}};
const tt=_taskTypes.map(t=>'<option value="'+t.task_type+'"'+(t.task_type===j.task_type?' selected':'')+'>'+esc(t.name)+'</option>').join('');
const gl=_groupsList.map(g=>'<option value="'+g.name+'"'+(g.name===j.target.group_name?' selected':'')+'>'+esc(g.name)+'</option>').join('');
const dl=_devicesList.map(s=>'<option value="'+s+'"'+(s===j.target.serial?' selected':'')+'>'+esc(s)+'</option>').join('');
const dl=_devicesList.map(x=>'<option value="'+esc(x.serial)+'"'+(x.serial===j.target.serial?' selected':'')+'>'
+esc(devText(x.name, x.serial))+'</option>').join('');
const sm=j.schedule.mode;
const startCron=(sm==='cron'||sm==='cron_stop')?(j.schedule.cron||''):'';
const stopCron=sm==='cron_stop'?(j.schedule.stop_cron||''):'';
+15 -1
View File
@@ -51,13 +51,16 @@ function loadDiscovery(){
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.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='尚未扫描';}
@@ -121,6 +124,17 @@ function reconnectPoolDev(serial){
}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');