Files
auto_control/static/admin/admin.js
T
butubb 79cdf61ed2 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 目标与
观看下拉/任务编辑器/分组弹窗/发现设置开关);设备指纹与人工认领回归
2026-09-11 11:16:24 +08:00

226 lines
10 KiB
JavaScript

// 管理 Tab:分组/日志/用户
// ================== Tab 3: 分组 ==================
async function loadGroups(){
const r=await apiGet('/api/groups');if(!r)return;
_groupsList=r.groups||[];
const dr=await apiGet('/api/devices');
// items 带名称(设备身份);老接口只有 serial 列表时兜底成对象
_devicesList=(dr&&dr.items)?dr.items
:((dr&&dr.devices)||[]).map(s=>({serial:s,name:'',model:''}));
renderGroupTable();
}
function renderGroupTable(){
const groups=_groupsList||[];
setListPager('groups',{
data:()=>groups,
filter:(g,kw)=>((g.name||'').toLowerCase().includes(kw)||(g.description||'').toLowerCase().includes(kw)||(g.serials||[]).join(' ').toLowerCase().includes(kw)),
sortValue:(g,k)=>{ if(k==='count')return (g.serials||[]).length; return g[k]||''; },
render:renderGroupRow,
tbody:'tb-groups',pager:'pager-groups',pageSize:10,
empty:'<tr><td colspan="5" class="empty">暂无分组,点击"新建分组"创建</td></tr>'
});
applyListPager('groups');
initListSearch('groups','search-groups');
}
function renderGroupRow(g){
const serials=g.serials||[];
return '<tr>'+
'<td><strong>'+esc(g.name)+'</strong></td>'+
'<td>'+esc(g.description||'-')+'</td>'+
'<td>'+serials.length+'</td>'+
'<td class="text-muted" style="max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">'+(serials.length?serials.map(esc).join(', '):'-')+'</td>'+
'<td>'+
(_can('tasks')?(
'<button class="btn btn-xs" onclick="openGroupModal(\''+esc(g.name)+'\')">编辑</button>'+
'<button class="btn btn-danger btn-xs" onclick="deleteGroup(\''+esc(g.name)+'\')">删除</button>'
):'<span class="text-muted">只读</span>')+
'</td>'+
'</tr>';
}
function openGroupModal(name){
const group=name?(_groupsList||[]).find(g=>g.name===name):null;
const isEdit=!!group;
document.getElementById('modal-title').textContent=isEdit?'编辑分组':'新建分组';
const g=group||{name:'',description:'',serials:[]};
const current= new Set(g.serials||[]);
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(nameOf[s]?devText(nameOf[s], s):s)+'</label>'
).join('');
document.getElementById('modal-body').innerHTML=
'<div class="form-group"><label>分组名</label><input type="text" class="form-control" id="g-name" value="'+esc(g.name)+'" placeholder="如:测试组" '+(isEdit?'readonly':'')+'></div>'+
'<div class="form-group"><label>描述</label><input type="text" class="form-control" id="g-description" value="'+esc(g.description||'')+'" placeholder="可选"></div>'+
'<div class="form-group"><label>设备列表(勾选属于此分组的设备)</label>'+
'<div class="device-list">'+(deviceCheckboxes||'<div class="text-muted">暂无在线设备</div>')+'</div>'+
'<div class="help">仅显示当前在线设备,已选设备即使离线也会保留</div>'+
'</div>';
document.getElementById('modal-footer').innerHTML=
'<button class="btn" onclick="closeModal()">取消</button>'+
'<button class="btn btn-primary" onclick="saveGroup('+(isEdit?'\''+esc(g.name)+'\'':'null')+')">'+(isEdit?'保存':'创建')+'</button>';
showModal();
}
async function saveGroup(name){
const newName=document.getElementById('g-name').value.trim();
if(!newName){showToast('分组名不能为空','error');return;}
const desc=document.getElementById('g-description').value;
const serials=Array.from(document.querySelectorAll('.device-list input:checked')).map(c=>c.value);
const data={name:newName,description:desc,serials:serials};
if(name){
const r=await apiPut('/api/groups/'+encodeURIComponent(name),data);
handleResult(r,()=>{closeModal();loadGroups();});
}else{
const r=await apiPost('/api/groups',data);
handleResult(r,()=>{closeModal();loadGroups();});
}
}
async function deleteGroup(name){
if(!confirm('确认删除分组"'+name+'"吗?'))return;
const r=await apiDelete('/api/groups/'+encodeURIComponent(name));
handleResult(r,()=>loadGroups());
}
// ================== Tab 4: 日志 ==================
async function loadLogs(){
const file=document.getElementById('log-file').value;
const lines=document.getElementById('log-lines').value;
const r=await apiGet('/api/logs?file='+encodeURIComponent(file)+'&lines='+lines);
if(!r||!r.ok)return;
const content=r.content||'';
// 简单着色
const colored=esc(content)
.replace(/\[ERROR\]/g,'<span class="log-err">[ERROR]</span>')
.replace(/\[WARNING\]/g,'<span class="log-warn">[WARNING]</span>')
.replace(/\[INFO\]/g,'<span class="log-info">[INFO]</span>');
document.getElementById('log-content').innerHTML=colored||'<span class="text-muted">(空)</span>';
document.getElementById('log-hint').textContent=file+' · '+content.split('\n').length+' 行';
// 自动滚动到底部
const el=document.getElementById('log-content');
el.scrollTop=el.scrollHeight;
}
function toggleLogAuto(){
if(_logTimer){clearInterval(_logTimer);_logTimer=null;}
if(document.getElementById('log-auto').checked && _activeTab==='logs'){
_logTimer=setInterval(loadLogs,3000);
}
}
// ================== Tab 5: 用户 ==================
async function loadUsers(){
const r=await apiGet('/api/users');if(!r)return;
const users=r.users||[];
setListPager('users',{
data:()=>users,
filter:(u,kw)=>(u.username||'').toLowerCase().includes(kw),
sortValue:(u,k)=>{ if(k==='is_admin')return u.is_admin?1:0; return u[k]||''; },
render:renderUserRow,
tbody:'tb-users',pager:'pager-users',pageSize:10,
empty:'<tr><td colspan="5" class="empty">暂无用户</td></tr>'
});
applyListPager('users');
initListSearch('users','search-users');
}
function renderUserRow(u){
const admin=u.is_admin?'<span class="label label-primary">管理员</span>':'<span class="label label-default">普通</span>';
const perms=(u.perms||[]).map(p=>'<span class="label label-info">'+(PERM_LABELS[p]||p)+'</span>').join(' ')
||'<span class="text-muted">-</span>';
return '<tr>'+
'<td>'+u.id+'</td>'+
'<td><strong>'+esc(u.username)+'</strong></td>'+
'<td>'+admin+'</td>'+
'<td>'+perms+'</td>'+
'<td>'+
'<button class="btn btn-xs" onclick="openUserModal('+u.id+')">编辑</button>'+
(u.username==='admin'?'':'<button class="btn btn-danger btn-xs" onclick="deleteUser('+u.id+')">删除</button>')+
'</td>'+
'</tr>';
}
function togglePermBoxes(){
// 勾选"管理员权限"时:权限位全选并禁用(管理员拥有全部权限)
const isAdmin=document.getElementById('u-is_admin').checked;
document.querySelectorAll('.u-perm').forEach(cb=>{
cb.disabled=isAdmin;
if(isAdmin)cb.checked=true;
});
}
function openUserModal(uid){
const isEdit=!!uid;
document.getElementById('modal-title').textContent=isEdit?'编辑用户':'新建用户';
const permBoxes=Object.keys(PERM_LABELS).map(p=>
'<label style="margin-right:12px"><input type="checkbox" class="u-perm" value="'+p+'">'+PERM_LABELS[p]+'</label>'
).join('');
document.getElementById('modal-body').innerHTML=
'<div class="form-group"><label>用户名</label><input type="text" class="form-control" id="u-username" '+(isEdit?'disabled':'')+' placeholder="用户名"></div>'+
'<div class="form-group"><label>密码'+(isEdit?'(留空不修改)':'')+'</label><input type="password" class="form-control" id="u-password" placeholder="密码"></div>'+
'<div class="form-group form-check"><input type="checkbox" id="u-is_admin" onchange="togglePermBoxes()" checked><label for="u-is_admin">管理员权限(拥有全部权限)</label></div>'+
'<div class="form-group"><label>业务权限(管理员自动拥有全部,无需勾选)</label>'+
'<div>'+permBoxes+'</div>'+
'<div class="help">任务管理=任务/自定义动作/分组;设备控制=停止/释放/清异常/扫描/抓元素;应用管理=APK上传安装;日志查看=日志页</div>'+
'</div>';
document.getElementById('modal-footer').innerHTML=
'<button class="btn" onclick="closeModal()">取消</button>'+
'<button class="btn btn-primary" onclick="saveUser('+(uid||'null')+')">'+(isEdit?'保存':'创建')+'</button>';
if(isEdit){
// 预填充(从表格数据获取)
apiGet('/api/users').then(r=>{
const u=(r&&r.users||[]).find(x=>x.id===uid);
if(u){
document.getElementById('u-username').value=u.username;
document.getElementById('u-is_admin').checked=u.is_admin;
document.querySelectorAll('.u-perm').forEach(cb=>{
cb.checked=(u.perms||[]).includes(cb.value);
});
togglePermBoxes();
}
});
}else{
togglePermBoxes();
}
showModal();
}
async function saveUser(uid){
const username=document.getElementById('u-username').value.trim();
const password=document.getElementById('u-password').value;
const is_admin=document.getElementById('u-is_admin').checked;
const perms=[...document.querySelectorAll('.u-perm:checked')].map(cb=>cb.value);
if(uid){
const data={is_admin:is_admin,perms:perms};
if(password)data.password=password;
const r=await apiPut('/api/users/'+uid,data);
handleResult(r,()=>{closeModal();loadUsers();});
}else{
if(!username||!password){showToast('用户名和密码不能为空','error');return;}
const r=await apiPost('/api/users',{username:username,password:password,is_admin:is_admin,perms:perms});
handleResult(r,()=>{closeModal();loadUsers();});
}
}
async function deleteUser(uid){
if(!confirm('确认删除此用户吗?'))return;
const r=await apiDelete('/api/users/'+uid);
handleResult(r,()=>loadUsers());
}
// ================== 初始化 ==================
initCsrf(); // 获取 CSRF token(非 GET 请求需携带)
loadMe(); // 获取当前用户权限,隐藏无权限的功能入口
showTab('monitor');