refactor: 前端JS拆分为8个模块(base/list/monitor/editor/tasks/tools/apps/admin,按依赖顺序加载)+ 删除废弃文件(start_web.bat/main.py/fix_web.bat/cleanup.py,web已全覆盖)+ 文档同步 + 修复/api/summary缺导入

This commit is contained in:
2026-08-11 14:53:04 +08:00
parent 2e491b19e2
commit 060157ea1a
19 changed files with 2584 additions and 2767 deletions
+220
View File
@@ -0,0 +1,220 @@
// 管理 Tab:分组/日志/用户
// ================== Tab 3: 分组 ==================
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||[]:[];
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 allSerials=[...new Set([..._devicesList, ...(g.serials||[])])].sort();
const deviceCheckboxes=allSerials.map(s=>
'<label><input type="checkbox" value="'+esc(s)+'" '+(current.has(s)?'checked':'')+'>'+esc(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');
+264
View File
@@ -0,0 +1,264 @@
// 工具 Tab-应用管理:APK 上传/安装/删除 + 设备已装应用
// ================== Tab: 应用 ==================
let _apksCache=[];
let _installTimer=null;
let _devAppsData=[];
function fmtSize(b){
if(!b)return '0 B';
if(b<1024)return b+' B';
if(b<1048576)return (b/1024).toFixed(1)+' KB';
return (b/1048576).toFixed(1)+' MB';
}
async function loadApks(){
const r=await apiGet('/api/apks');if(!r||!r.ok)return;
const apks=r.apks||[];
_apksCache=apks;
document.getElementById('apk-hint').textContent='共 '+apks.length+' 个应用';
setListPager('apks',{
data:()=>apks,
sortValue:(a,k)=>{ if(k==='size')return a.size||0; if(k==='upload_time')return a.upload_time||''; if(k==='package_name')return a.package_name||''; if(k==='version_name')return a.version_name||''; return a.display_name||''; },
filter:(a,kw)=>((a.display_name||'').toLowerCase().includes(kw)||(a.package_name||'').toLowerCase().includes(kw)),
render:renderApkRow,
tbody:'tb-apks',pager:'pager-apks',pageSize:10,
empty:'<tr><td colspan="6" class="empty">暂无APK,点击"上传APK"上传</td></tr>'
});
applyListPager('apks');
initListSearch('apks','search-apks');
await fillAppsDeviceList();
}
function renderApkRow(a){
return '<tr>'+
'<td><strong>'+esc(a.display_name||'(未命名)')+'</strong></td>'+
'<td style="font-family:monospace;font-size:12px;color:#64748b">'+esc(a.package_name||'-')+'</td>'+
'<td>'+(a.version_name?esc(a.version_name):'-')+'</td>'+
'<td>'+fmtSize(a.size)+'</td>'+
'<td style="font-size:12px;color:#94a3b8">'+esc(a.upload_time)+'</td>'+
'<td>'+
'<button class="btn btn-primary btn-xs" onclick="openInstallModal(\''+a.id+'\')">安装</button> '+
'<button class="btn btn-danger btn-xs" onclick="deleteApk(\''+a.id+'\')">删除</button>'+
'</td>'+
'</tr>';
}
// 填充"设备已装应用"的设备下拉(保留当前选择)
async function fillAppsDeviceList(){
const sr=await apiGet('/api/status');if(!sr||!sr.ok)return;
const sel=document.getElementById('apps-device');
if(!sel)return;
const cur=sel.value;
const devices=(sr.devices||[]).filter(d=>d.present);
sel.innerHTML='<option value="">选择设备...</option>'+devices.map(d=>
'<option value="'+esc(d.serial)+'">'+esc((d.serial||'').replace(/:5555$/,'')+' ('+(d.device_name||d.model||'未知设备')+')')+'</option>'
).join('');
if(cur)sel.value=cur;
}
// 查看指定设备已安装应用
async function loadDeviceApps(){
const sel=document.getElementById('apps-device');
const serial=sel.value;
if(!serial){showToast('请先选择设备','error');return;}
const hint=document.getElementById('apps-hint');
const tb=document.getElementById('tb-device-apps');
hint.textContent='加载中...';
const r=await apiGet('/api/devices/'+encodeURIComponent(serial)+'/apps');
hint.textContent='';
if(!r||!r.ok){
tb.innerHTML='<tr><td colspan="4" class="empty">'+(r&&r.error?esc(r.error):'获取失败')+'</td></tr>';
showToast('获取失败','error');return;
}
const apps=r.apps||[];
if(!apps.length){tb.innerHTML='<tr><td colspan="4" class="empty">无应用</td></tr>';return;}
const userApps=apps.filter(a=>/^\/data\//.test(a.path||'')).length;
hint.textContent='共 '+apps.length+' 个应用(用户安装 '+userApps+')';
_devAppsData=apps;
setListPager('devapps',{
data:()=>_devAppsData||[],
filter:(a,kw)=>((a.label||a.package||'').toLowerCase().includes(kw)||(a.package||'').toLowerCase().includes(kw)||(a.path||'').toLowerCase().includes(kw)),
sortValue:(a,k)=>{ if(k==='version_code')return parseInt(a.version_code)||0; if(k==='version_name')return a.version_name||''; if(k==='label')return (a.label||a.package||''); return a[k]||''; },
render:renderDevAppRow,
tbody:'tb-device-apps',pager:'pager-devapps',pageSize:20,
empty:'<tr><td colspan="4" class="empty">无应用</td></tr>'
});
applyListPager('devapps');
initListSearch('devapps','search-devapps');
}
function renderDevAppRow(a){
return '<tr>'+
'<td><strong>'+esc(a.label||a.package)+'</strong>'+(a.label?' <span style="font-size:11px;color:#94a3b8;font-family:monospace">'+esc(a.package)+'</span>':'')+'</td>'+
'<td>'+(a.version_name?esc(a.version_name):'-')+'</td>'+
'<td style="font-size:12px;color:#94a3b8">'+(a.version_code!=null?esc(a.version_code):'-')+'</td>'+
'<td style="font-size:11px;color:#94a3b8;word-break:break-all">'+esc(a.path||'-')+'</td>'+
'</tr>';
}
async function uploadApk(input){
if(!input.files||!input.files[0])return;
const file=input.files[0];
if(!file.name.toLowerCase().endsWith('.apk')){
showToast('请选择 .apk 文件','error');return;
}
const hint=document.getElementById('apk-hint');
const fd=new FormData();
fd.append('file',file);
// 用 XMLHttpRequest 才能拿到上传进度(fetch 不支持上传进度事件)
const xhr=new XMLHttpRequest();
xhr.open('POST','/api/apks/upload');
if(_csrfToken)xhr.setRequestHeader('X-CSRF-Token',_csrfToken);
xhr.upload.onprogress=function(e){
if(e.lengthComputable){
const pct=Math.round(e.loaded*100/e.total);
const loaded=(e.loaded/1048576).toFixed(1);
const total=(e.total/1048576).toFixed(1);
hint.innerHTML='<span style="color:#f59e0b">正在上传 '+esc(file.name)+' '+pct+'% ('+loaded+'/'+total+' MB)</span>'+
'<div class="progress" style="width:200px;height:6px;margin-top:4px"><div class="progress-bar" style="width:'+pct+'%"></div></div>';
}
};
xhr.onload=function(){
if(xhr.status===401){window.location='/login';return;}
try{
const data=JSON.parse(xhr.responseText);
if(data.ok){
showToast(data.msg||'上传成功','success');
loadApks();
}else{
showToast(data.error||'上传失败','error');
hint.textContent='';
}
}catch(e){
showToast('解析响应失败','error');
hint.textContent='';
}
};
xhr.onerror=function(){
showToast('上传失败:网络错误','error');
hint.textContent='';
};
xhr.send(fd);
input.value='';
}
async function deleteApk(id){
if(!confirm('确认删除此APK文件?'))return;
const r=await apiDelete('/api/apks/'+id);
handleResult(r,loadApks);
}
async function openInstallModal(apkId){
const apk=_apksCache.find(a=>a.id===apkId);
const apkName=apk?(apk.display_name||'APK'):'APK';
const r=await apiGet('/api/apks/install/devices');
const online=(r&&r.ok)?(r.devices||[]):[];
if(!online.length){
showToast('没有可用设备(STF 池或本机 adb 均无)','error');return;
}
const body=
'<div style="margin-bottom:12px">'+
'<strong>安装应用:</strong><span class="label label-primary">'+esc(apkName)+'</span>'+
' <span style="font-size:12px;color:#94a3b8">运行中设备将自动跳过</span>'+
'</div>'+
'<div style="margin-bottom:8px">'+
'<button class="btn btn-sm" onclick="document.querySelectorAll(\'#install-dev-list input[type=checkbox]\').forEach(c=>c.checked=true)">全选</button> '+
'<button class="btn btn-sm" onclick="document.querySelectorAll(\'#install-dev-list input[type=checkbox]\').forEach(c=>c.checked=false)">全不选</button> '+
'<button class="btn btn-sm btn-primary" onclick="selectIdleForInstall()">全选空闲</button>'+
'</div>'+
'<div id="install-dev-list" class="device-list">'+
online.map(d=>{
const isUsb=d.source==='usb';
const st='<span class="label label-'+(isUsb?'primary':'default')+'" style="margin-left:6px">'+(isUsb?'本地USB':(d.source==='stf'?'STF':'adb'))+'</span>';
// serial 唯一,显示 IP + 型号便于区分同型号设备
const label=(d.serial||'').replace(/:5555$/,'')+' · '+(d.model||(isUsb?'USB 有线设备':'设备'));
return '<label><input type="checkbox" value="'+esc(d.serial)+'" checked>'+esc(label)+st+'</label>';
}).join('')+
'</div>';
document.getElementById('modal-title').textContent='安装应用到设备';
document.getElementById('modal-body').innerHTML=body;
document.getElementById('modal-footer').innerHTML=
'<button class="btn" onclick="closeModal()">取消</button>'+
'<button class="btn btn-primary" onclick="startInstall(\''+apkId+'\')">开始安装</button>';
document.getElementById('modal-overlay').classList.add('show');
}
function selectIdleForInstall(){
document.querySelectorAll('#install-dev-list label').forEach(lbl=>{
const cb=lbl.querySelector('input[type=checkbox]');
const isRunning=lbl.querySelector('.label-success');
cb.checked=!isRunning;
});
}
async function startInstall(apkId){
const checked=document.querySelectorAll('#install-dev-list input[type=checkbox]:checked');
const serials=Array.from(checked).map(c=>c.value);
if(!serials.length){
showToast('请至少选择一台设备','error');return;
}
const r=await apiPost('/api/apks/install',{apk_id:apkId,serials:serials});
if(!r||!r.ok){showToast(r?r.error:'请求失败','error');return;}
showToast(r.msg||'安装已启动','success');
showInstallProgress();
}
function showInstallProgress(){
document.getElementById('modal-title').textContent='安装进度';
document.getElementById('modal-body').innerHTML=
'<div id="install-progress" style="min-height:200px"><p class="text-muted">加载中...</p></div>';
document.getElementById('modal-footer').innerHTML=
'<button class="btn" onclick="closeInstallModal()" id="install-close-btn" style="display:none">关闭</button>';
pollInstallStatus();
if(_installTimer)clearInterval(_installTimer);
_installTimer=setInterval(pollInstallStatus,3000);
}
async function pollInstallStatus(){
const r=await apiGet('/api/apks/install/status');
if(!r||!r.ok)return;
const st=r.status;
if(!st){
document.getElementById('install-progress').innerHTML='<p class="text-muted">无安装任务</p>';
if(_installTimer){clearInterval(_installTimer);_installTimer=null;}
return;
}
const SS={pending:{t:'等待',c:'default'},installing:{t:'安装中',c:'warning'},success:{t:'成功',c:'success'},failed:{t:'失败',c:'danger'},skipped:{t:'跳过',c:'default'}};
const items=st.items||{};
const itemRows=Object.entries(items).map(([serial,info])=>{
const s=SS[info.status]||{t:info.status,c:'default'};
return '<tr><td>'+esc(info.name||serial)+'</td>'+
'<td style="font-family:monospace;font-size:11px;color:#94a3b8">'+esc(serial)+'</td>'+
'<td><span class="label label-'+s.c+'">'+s.t+'</span></td>'+
'<td style="font-size:12px;color:#64748b">'+esc(info.msg||'')+'</td></tr>';
}).join('');
const pct=st.total>0?Math.round((st.success+st.failed+st.skipped)*100/st.total):0;
const summary=
'<div style="margin-bottom:12px">'+
'<div style="display:flex;gap:8px;margin-bottom:8px;font-size:13px;flex-wrap:wrap">'+
'<span>应用:<strong>'+esc(st.apk_name)+'</strong></span>'+
'<span class="label label-success">成功 '+st.success+'</span>'+
'<span class="label label-danger">失败 '+st.failed+'</span>'+
'<span class="label label-default">跳过 '+st.skipped+'</span>'+
'<span class="label label-warning">安装中 '+st.installing+'</span>'+
'<span class="label label-default">等待 '+st.pending+'</span>'+
'</div>'+
'<div class="progress" style="width:100%;height:8px"><div class="progress-bar" style="width:'+pct+'%"></div></div>'+
'</div>';
document.getElementById('install-progress').innerHTML=
summary+'<table class="table" style="margin-bottom:0"><thead><tr><th>设备</th><th>Serial</th><th>状态</th><th>详情</th></tr></thead><tbody>'+itemRows+'</tbody></table>';
if(st.finished){
if(_installTimer){clearInterval(_installTimer);_installTimer=null;}
document.getElementById('install-close-btn').style.display='';
showToast('安装完成:成功'+st.success+' 失败'+st.failed+' 跳过'+st.skipped,'success');
}
}
function closeInstallModal(){
if(_installTimer){clearInterval(_installTimer);_installTimer=null;}
closeModal();
}
+154
View File
@@ -0,0 +1,154 @@
// 通用基础:esc/CSRF/权限/API/Toast、Tab 切换与页内子分栏、模态框、行内按钮操作
// ================== 通用工具 ==================
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:'指定设备'};
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]));}
// ===== CSRF =====
let _csrfToken='';
// 页面加载后调用:获取 CSRF token,非 GET 请求需携带
async function initCsrf(){
try{
const r=await fetch('/api/csrf');
const d=await r.json();
_csrfToken=d.token||'';
}catch(e){}
}
function _csrfHeaders(){
return _csrfToken?{'X-CSRF-Token':_csrfToken}:{};
}
// ===== 当前用户与权限 =====
// _me 来自 /api/me:{username, is_admin, perms:[tasks/devices/apks/logs]}
// 管理员 perms 恒为全部。无权限的功能入口(tab/按钮)用 data-perm 标记或 _can() 判断隐藏。
let _me={is_admin:false,perms:[],username:''};
async function loadMe(){
try{
const r=await apiGet('/api/me');
if(r&&r.ok)_me=r.user||{is_admin:false,perms:[],username:''};
const un=document.getElementById('me-username');
if(un)un.textContent=_me.username||'';
// 隐藏无权限的 tab 和按钮(data-perm="admin" 表示仅管理员)
document.querySelectorAll('[data-perm]').forEach(function(el){
const p=el.dataset.perm;
const ok=(p==='admin')?_me.is_admin:(_me.perms||[]).includes(p);
if(!ok)el.style.display='none';
});
}catch(e){}
}
function _can(perm){
return _me.is_admin||(_me.perms||[]).includes(perm);
}
const PERM_LABELS={tasks:'任务管理',devices:'设备控制',apks:'应用管理',logs:'日志查看'};
async function apiGet(url){
const r=await fetch(url);
if(r.status===401){window.location='/login';return null;}
return r.json();
}
async function apiPost(url,body){
const r=await fetch(url,{method:'POST',headers:{'Content-Type':'application/json',..._csrfHeaders()},body:body?JSON.stringify(body):'{}'});
if(r.status===401){window.location='/login';return null;}
return r.json();
}
async function apiPut(url,body){
const r=await fetch(url,{method:'PUT',headers:{'Content-Type':'application/json',..._csrfHeaders()},body:body?JSON.stringify(body):'{}'});
if(r.status===401){window.location='/login';return null;}
return r.json();
}
async function apiDelete(url){
const r=await fetch(url,{method:'DELETE',headers:_csrfHeaders()});
if(r.status===401){window.location='/login';return null;}
return r.json();
}
function showToast(msg,type){
const t=document.getElementById('toast');
t.className='toast show toast-'+(type||'success');
t.textContent=msg;
setTimeout(()=>t.className='toast',3000);
}
function handleResult(r,callback){
if(!r)return;
if(r.ok){showToast(r.msg||'操作成功','success');if(callback)callback();}
else{showToast(r.error||'操作失败','error');}
}
// ================== Tab 切换 ==================
let _activeTab='monitor';
let _monitorTimer=null;
let _logTimer=null;
function showTab(name){
_activeTab=name;
document.querySelectorAll('.tab-panel').forEach(p=>p.classList.remove('active'));
document.querySelectorAll('.navbar .tab').forEach(t=>t.classList.remove('active'));
document.getElementById('tab-'+name).classList.add('active');
document.querySelectorAll('.navbar .tab')[['monitor','tasks','groups','logs','users','maintenance','tools'].indexOf(name)].classList.add('active');
// 停止所有定时器
if(_monitorTimer){clearInterval(_monitorTimer);_monitorTimer=null;}
if(_logTimer){clearInterval(_logTimer);_logTimer=null;}
// 按需加载数据
if(name==='monitor'){loadMonitor();_monitorTimer=setInterval(loadMonitor,5000);}
if(name==='tasks'){showSubTab('tasks',_activeSubs.tasks);loadTasks();loadCustomActions();}
if(name==='tools'){showSubTab('tools',_activeSubs.tools);loadToolsDevices();loadAdbDevices();loadTailscaleDevices();loadApks();loadStfDevMgmt();}
if(name==='groups')loadGroups();
if(name==='logs'){loadLogs();if(document.getElementById('log-auto').checked)_logTimer=setInterval(loadLogs,3000);}
if(name==='users')loadUsers();
}
// ================== 页内子分栏(任务/工具 通用) ==================
// 每个带子分栏的 Tab 记住上次选中的子分栏,切走再切回来保持原位
let _activeSubs = {tasks: 'plan', tools: 'clipboard'};
function showSubTab(tabId, name){
_activeSubs[tabId] = name;
const tab = document.getElementById('tab-'+tabId);
tab.querySelectorAll('.sub-tab').forEach(b=>b.classList.toggle('active', b.dataset.sub===name));
tab.querySelectorAll('.sub-panel').forEach(p=>p.classList.toggle('active', p.id===tabId+'-sub-'+name));
}
// ================== 模态框 ==================
function showModal(){
document.getElementById('modal-overlay').classList.add('show');
}
function closeModal(){
// 先关闭弹窗(最重要的一步,确保一定执行)
const ov=document.getElementById('modal-overlay');
if(ov)ov.classList.remove('show');
// 再做清理工作,任何异常都不影响弹窗关闭
try{
stopShotTimer();
_shotSerial='';
const box=document.getElementById('modal-box');
if(box){box.classList.remove('shot-modal');box.classList.remove('wide-modal');}
}catch(e){console.warn('closeModal cleanup error:',e);}
}
// 关闭元素抓取弹窗(不影响任务编辑窗口)
function closeElPicker(){
var ov=document.getElementById('el-picker-overlay');
if(ov)ov.classList.remove('show');
// 清理状态
try{ _stepEditor._elData=null; _stepEditor._elSerial=''; }catch(e){}
}
// ================== 行内按钮操作(停止/执行/启停等,自动 toast + 刷新)==================
async function apiAction(url,body){
const r=await apiPost(url,body);
if(!r)return;
if(r.ok){showToast(r.msg||'操作成功','success');if(_activeTab==='monitor')loadMonitor();if(_activeTab==='tasks')loadTasks();}
else{showToast(r.error||'操作失败','error');}
}
async function scanForeground(){
const r=await apiPost('/api/scan_foreground');
if(!r)return;
if(r.ok){showToast('扫描已启动,请等待几秒...','success');loadMonitor();}
else{showToast(r.error||'扫描失败','error');}
}
File diff suppressed because it is too large Load Diff
+94
View File
@@ -0,0 +1,94 @@
// 统一列表组件:setListPager 搜索 + 分页 + 排序(_lpSort)
// ================== 通用列表:搜索 + 分页 + 排序 ==================
// 用法:
// setListPager(key,{data:()=>array, filter:(item,kw)=>bool, render:(item)=>html,
// tbody:'tb-id', pager:'pager-id', pageSize:N,
// sortValue:(item,colKey)=>值, // 可排序列的取值函数
// empty:'<tr>...'});
// applyListPager(key); // 数据变化后重渲染(含搜索/翻页/排序状态)
// initListSearch(key,'search-id'); // 搜索框绑定
// _lpSort(key,'colKey'); // 表头排序:th 加 data-sort="colKey" + onclick,
// // 再点切换升降序,第三次取消;排序指示器自动更新
var _LIST_PAGERS={};
function _lp(key){ return _LIST_PAGERS[key] || (_LIST_PAGERS[key]={page:1,kw:'',cfg:null,sort:null}); }
function setListPager(key,cfg){
// 只更新配置,不重置 page/kw/sort —— 监控列表 5 秒自动刷新时保持用户当前状态
var st=_lp(key); st.cfg=cfg;
}
function initListSearch(key,inputId){
var input=document.getElementById(inputId);
if(!input||input.dataset.bound)return;
input.dataset.bound='1';
input.addEventListener('input',function(){
var st=_lp(key); st.kw=this.value.trim(); st.page=1; applyListPager(key);
});
}
function _lpSort(key,colKey){
var st=_lp(key);
if(st.sort&&st.sort.key===colKey){
if(st.sort.dir===1)st.sort.dir=-1; // 升序 → 降序
else if(st.sort.dir===-1)st.sort=null; // 降序 → 取消排序
else st.sort={key:colKey,dir:1};
}else{
st.sort={key:colKey,dir:1};
}
applyListPager(key);
}
function applyListPager(key){
var st=_lp(key);
if(!st.cfg)return;
var cfg=st.cfg;
var all=(cfg.data&&cfg.data())||[];
var kw=st.kw.toLowerCase();
var filtered=kw?all.filter(function(x){return cfg.filter(x,kw);}):all;
// 排序:cfg.sortValue 取列值;空值/缺失恒排最后;字符串用中文数字感知比较(10 排在 9 后)
if(st.sort&&cfg.sortValue){
var sk=st.sort.key, dir=st.sort.dir;
filtered=filtered.slice().sort(function(a,b){
var va=cfg.sortValue(a,sk), vb=cfg.sortValue(b,sk);
if(va===vb)return 0;
if(va==null||va==='')return 1;
if(vb==null||vb==='')return -1;
if(typeof va==='number'&&typeof vb==='number')return (va-vb)*dir;
return String(va).localeCompare(String(vb),'zh-Hans-CN',{numeric:true})*dir;
});
}
var pageSize=cfg.pageSize||10;
var pages=Math.max(1,Math.ceil(filtered.length/pageSize));
if(st.page>pages)st.page=pages;
if(st.page<1)st.page=1;
var start=(st.page-1)*pageSize;
var slice=filtered.slice(start,start+pageSize);
var tb=document.getElementById(cfg.tbody);
if(tb){
tb.innerHTML=slice.map(cfg.render).join('')||(cfg.empty||'<tr><td colspan="20" class="empty">无数据</td></tr>');
// 表头排序指示器(th[data-sort],所有表格统一)
if(cfg.sortValue){
var table=tb.closest('table');
if(table)table.querySelectorAll('thead th[data-sort]').forEach(function(th){
th.classList.add('sortable');
th.classList.toggle('sort-asc',!!st.sort&&st.sort.key===th.dataset.sort&&st.sort.dir===1);
th.classList.toggle('sort-desc',!!st.sort&&st.sort.key===th.dataset.sort&&st.sort.dir===-1);
});
}
}
var pg=document.getElementById(cfg.pager);
if(pg)pg.innerHTML=_lpPagerHtml(key,pages,st.page,filtered.length,pageSize);
}
function _lpPagerHtml(key,pages,cur,total,pageSize){
if(pages<=1)return total>0?'<div class="list-pager"><span class="lp-total">共 '+total+' 条</span></div>':'';
var h='<div class="list-pager"><span class="lp-total">共 '+total+' 条</span>';
h+='<button class="btn btn-xs" onclick="_lpGo(\''+key+'\','+(cur-1)+')"'+(cur<=1?' disabled':'')+'>‹ 上一页</button>';
for(var p=Math.max(1,cur-2);p<=Math.min(pages,cur+2);p++){
h+='<button class="btn btn-xs'+(p===cur?' btn-primary':'')+'" onclick="_lpGo(\''+key+'\','+p+')">'+p+'</button>';
}
h+='<button class="btn btn-xs" onclick="_lpGo(\''+key+'\','+(cur+1)+')"'+(cur>=pages?' disabled':'')+'>下一页 ›</button>';
h+='</div>';
return h;
}
function _lpGo(key,p){
var st=_lp(key); st.page=p; applyListPager(key);
}
+1 -2531
View File
File diff suppressed because it is too large Load Diff
+323
View File
@@ -0,0 +1,323 @@
// 任务 Tab:任务计划 CRUD/调度解析/运行窗口 + 自定义动作管理
// ================== Tab 2: 任务 ==================
let _taskTypes=[];
let _groupsList=[];
let _devicesList=[];
async function loadTasks(){
const [jr,gr,dr,tr]=await Promise.all([
apiGet('/api/jobs'),apiGet('/api/groups'),apiGet('/api/devices'),apiGet('/api/task_types')
]);
if(!jr)return;
_jobsData=jr.jobs||[];
_taskTypes=tr?tr.task_types||[]:[];
_groupsList=gr?gr.groups||[]:[];
_devicesList=dr?dr.devices||[]:[];
renderTaskTable();
}
function renderTaskTable(){
const jobs=_jobsData||[];
setListPager('tasks',{
data:()=>jobs,
filter:(j,kw)=>((j.name||'').toLowerCase().includes(kw)||(j.task_type||'').toLowerCase().includes(kw)||(j.id||'').toLowerCase().includes(kw)),
sortValue:(j,k)=>{ if(k==='id')return j.id||''; if(k==='enabled')return j.enabled?1:0; if(k==='next_run')return j.next_run||''; return j[k]||''; },
render:renderTaskRow,
tbody:'tb-tasks',pager:'pager-tasks',pageSize:10,
empty:'<tr><td colspan="9" class="empty">暂无任务,点击"新建任务"创建</td></tr>'
});
applyListPager('tasks');
initListSearch('tasks','search-tasks');
}
function renderTaskRow(j){
const tgt=TARGET_MAP[j.target.mode]||j.target.mode;
const tgtDetail=j.target.mode==='group'?(j.target.group_name||''):(j.target.mode==='serial'?(j.target.serial||''):'');
const sched=j.schedule.mode==='cron_stop'?('启停 '+cronToText(j.schedule.cron||'')+' → '+cronToText(j.schedule.stop_cron||'')):
j.schedule.mode==='cron'?('定时 '+cronToText(j.schedule.cron||'')):'手动';
const schedTxt=sched+((j.schedule.window&&j.schedule.window.start&&j.schedule.window.end)
?' [窗口 '+esc(j.schedule.window.start)+'-'+esc(j.schedule.window.end)+']':'');
const schedOut='<td>'+schedTxt+'</td>';
// 下次运行时间(后端已按运行窗口跳过窗口外触发点)
const nextRun=fmtNextRun(j.next_run);
const nextOut='<td>'+(nextRun||'<span class="text-muted">-</span>')+'</td>';
const retry=j.retry.max_attempts>1?(j.retry.max_attempts+'次/'+j.retry.delay+'s'):'不重试';
const en=j.enabled?'<span class="label label-success">启用</span>':'<span class="label label-default">停用</span>';
return '<tr>'+
'<td><code>'+esc(j.id)+'</code></td>'+
'<td><strong>'+esc(j.name)+'</strong></td>'+
'<td>'+esc(j.task_type)+'</td>'+
'<td>'+en+'</td>'+
'<td>'+esc(tgt)+(tgtDetail?'('+esc(tgtDetail)+')':'')+'</td>'+
schedOut+
nextOut+
'<td>'+retry+'</td>'+
'<td>'+
(_can('tasks')?(
'<button class="btn btn-primary btn-xs" onclick="apiAction(\'/api/jobs/'+j.id+'/run\')">执行</button>'+
'<button class="btn btn-xs" onclick="apiAction(\'/api/jobs/'+j.id+'/toggle\',{enabled:'+(!j.enabled)+'})">'+(j.enabled?'停用':'启用')+'</button>'+
'<button class="btn btn-xs" onclick="openTaskModal(\''+j.id+'\')">编辑</button>'+
'<button class="btn btn-danger btn-xs" onclick="deleteTask(\''+j.id+'\')">删除</button>'
):'<span class="text-muted">只读</span>')+
'</td>'+
'</tr>';
}
function openTaskModal(jobId){
const job=jobId?(_jobsData||[]).find(j=>j.id===jobId):null;
const isEdit=!!job;
document.getElementById('modal-title').textContent=isEdit?'编辑任务':'新建任务';
// 任务编辑器含步骤编辑,加宽弹窗;关闭时在 closeModal 移除
document.getElementById('modal-box').classList.add('wide-modal');
const j=job||{name:'',task_type:'douyin_nurture',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 sm=j.schedule.mode;
const startCron=(sm==='cron'||sm==='cron_stop')?(j.schedule.cron||''):'';
const stopCron=sm==='cron_stop'?(j.schedule.stop_cron||''):'';
// 运行窗口(每天时间段,窗口外不启动)
const win=j.schedule.window||null;
const winStart=win?(win.start||'09:00'):'09:00';
const winEnd=win?(win.end||'18:00'):'18:00';
// 简单调度:把已有 cron 解析回"频率+时间",编辑时回填;解析不了落到自定义
const sc=parseCronToFreq(startCron);
const stopF=parseCronToFreq(stopCron).time;
const weekdayOpts=[0,1,2,3,4,5,6].map(w=>
'<option value="'+w+'"'+(sc.weekday===w?' selected':'')+'>'+WEEKDAY_NAMES[w]+'</option>').join('');
document.getElementById('modal-body').innerHTML=
'<div class="form-row">'+
'<div class="form-group"><label>任务名</label><input type="text" class="form-control" id="f-name" value="'+esc(j.name)+'" placeholder="如:抖音养号-上午"></div>'+
'<div class="form-group"><label>任务类型</label><select class="form-control" id="f-task_type" onchange="onTaskTypeChange()">'+tt+'</select></div>'+
'</div>'+
'<div class="form-group form-check"><input type="checkbox" id="f-enabled" '+(j.enabled?'checked':'')+'><label for="f-enabled">启用此任务</label></div>'+
'<div class="form-group"><label>目标设备</label>'+
'<select class="form-control" id="f-target_mode" onchange="toggleTargetFields()">'+
'<option value="all"'+(j.target.mode==='all'?' selected':'')+'>全部空闲设备</option>'+
'<option value="group"'+(j.target.mode==='group'?' selected':'')+'>按分组</option>'+
'<option value="serial"'+(j.target.mode==='serial'?' selected':'')+'>指定设备</option>'+
'</select>'+
'<div id="f-target_group_wrap" class="form-group" style="margin-top:8px;margin-bottom:0">'+
'<select class="form-control" id="f-target_group"><option value="">选择分组...</option>'+gl+'</select>'+
'</div>'+
'<div id="f-target_serial_wrap" class="form-group" style="margin-top:8px;margin-bottom:0">'+
'<select class="form-control" id="f-target_serial"><option value="">选择设备...</option>'+dl+'</select>'+
'</div>'+
'</div>'+
'<div class="form-row">'+
'<div class="form-group"><label>调度方式</label>'+
'<select class="form-control" id="f-schedule_mode" onchange="toggleScheduleFields()">'+
'<option value="once"'+(sm==='once'?' selected':'')+'>手动(不自动启动)</option>'+
'<option value="cron"'+(sm==='cron'?' selected':'')+'>定时启动</option>'+
'<option value="cron_stop"'+(sm==='cron_stop'?' selected':'')+'>定时启动+停止</option>'+
'</select>'+
'</div>'+
'<div class="form-group" id="f-freq_wrap" style="display:none"><label>频率</label>'+
'<select class="form-control" id="f-schedule_freq" onchange="toggleScheduleFields()">'+
'<option value="daily"'+((sc.freq==='daily')?' selected':'')+'>每天</option>'+
'<option value="hourly"'+((sc.freq==='hourly')?' selected':'')+'>每小时(整点)</option>'+
'<option value="everyN"'+((sc.freq==='everyN')?' selected':'')+'>每隔 N 小时</option>'+
'<option value="weekly"'+((sc.freq==='weekly')?' selected':'')+'>每周</option>'+
'<option value="custom"'+((sc.freq==='custom')?' selected':'')+'>自定义 cron(高级)</option>'+
'</select>'+
'</div>'+
'<div class="form-group" id="f-time_wrap" style="display:none"><label>启动时间</label><input type="time" class="form-control" id="f-schedule_time" value="'+esc(sc.time||'09:00')+'"></div>'+
'<div class="form-group" id="f-interval_wrap" style="display:none"><label>间隔(小时)</label><input type="number" class="form-control" id="f-schedule_interval" value="'+(sc.interval||2)+'" min="1" max="12"><div class="help">从 0 点起算,如 2 = 0/2/4/6...点启动</div></div>'+
'<div class="form-group" id="f-weekday_wrap" style="display:none"><label>星期</label><select class="form-control" id="f-schedule_weekday">'+weekdayOpts+'</select></div>'+
'<div class="form-group" id="f-cron_wrap" style="display:none"><label>启动 Cron 表达式</label><input type="text" class="form-control" id="f-schedule_cron" value="'+esc(startCron)+'" placeholder="如 0 9 * * * = 每天9点启动" style="font-family:monospace"><div class="help">格式:分 时 日 月 周(0 0 * * 0 = 每周日零点)</div></div>'+
'<div class="form-group" id="f-stop_wrap" style="display:none"><label>停止时间(每天)</label><input type="time" class="form-control" id="f-schedule_stop_time" value="'+esc(stopF||'18:00')+'"><div class="help">到点自动停止所有运行中的 worker</div></div>'+
'</div>'+
'<div class="form-group form-check" style="margin-top:4px"><input type="checkbox" id="f-window_enable" '+(win?'checked':'')+' onchange="toggleWindowFields()"><label for="f-window_enable">启用运行窗口(只在指定时间段内运行)</label></div>'+
'<div class="form-row" id="f-window_wrap" style="display:'+(win?'flex':'none')+'">'+
'<div class="form-group"><label>允许运行·开始</label><input type="time" class="form-control" id="f-window_start" value="'+esc(winStart)+'"></div>'+
'<div class="form-group"><label>允许运行·结束</label><input type="time" class="form-control" id="f-window_end" value="'+esc(winEnd)+'"></div>'+
'</div>'+
'<div class="help" id="f-window_help" style="display:'+(win?'block':'none')+'">窗口外任务不会启动(定时触发和手动执行都受约束)。跨午夜示例:21:00-09:00 = 晚 9 点到次日早 9 点允许运行</div>'+
'<div class="form-row">'+
'<div class="form-group"><label>重试次数</label><input type="number" class="form-control" id="f-retry_attempts" value="'+j.retry.max_attempts+'" min="1" max="10"></div>'+
'<div class="form-group"><label>重试间隔(秒)</label><input type="number" class="form-control" id="f-retry_delay" value="'+j.retry.delay+'" min="10" max="3600"></div>'+
'</div>'+
'<div class="form-group" id="f-params_wrap"><label>任务参数 (JSON)</label><textarea class="form-control" id="f-params" rows="6">'+esc(JSON.stringify(j.params||{},null,2))+'</textarea><div class="help">可留空使用默认参数。抖音任务可设 max_duration(秒,如 1800=30分钟)限制单设备运行时长</div></div>'+
'<div class="step-editor" id="step-editor">'+
'<div class="se-head"><span>步骤编辑器</span><span style="font-size:11px;color:var(--text-light);flex:1;margin-left:12px">用步骤卡片上的 ☑ 选中多个步骤,可打包成自定义动作</span>'+
(_can('tasks')?'<button class="btn btn-xs btn-primary" id="se-pack-btn" disabled onclick="_stepEditor.saveAsCustomAction()">打包选中步骤</button>':'')+
'</div>'+
'<div class="se-body">'+
'<div class="se-lib" id="se-lib"><div class="lib-title">操作库</div></div>'+
'<div class="se-canvas" id="se-canvas"></div>'+
'</div>'+
'</div>';
// 初始化步骤编辑器(generic_steps 时显示,其他隐藏);jobId 作为草稿 key
_stepEditor.init(j.params&&j.params.steps?j.params.steps:[],j.params&&j.params.max_duration?j.params.max_duration:0,jobId||'');
document.getElementById('modal-footer').innerHTML=
'<button class="btn" onclick="closeModal()">取消</button>'+
'<button class="btn btn-primary" onclick="saveTask('+(isEdit?'\''+jobId+'\'':'null')+')">'+(isEdit?'保存':'创建')+'</button>';
toggleTargetFields();
toggleScheduleFields();
onTaskTypeChange();
showModal();
}
function toggleTargetFields(){
const mode=document.getElementById('f-target_mode').value;
document.getElementById('f-target_group_wrap').style.display=mode==='group'?'block':'none';
document.getElementById('f-target_serial_wrap').style.display=mode==='serial'?'block':'none';
}
// ===== 简单调度:cron(5段) ↔ 频率+时间 互转 =====
const WEEKDAY_NAMES=['周日','周一','周二','周三','周四','周五','周六'];
// cron → 简单设置 {freq: daily|hourly|everyN|weekly|custom, time:"HH:MM", interval, weekday(0-6)}
function parseCronToFreq(cron){
const r={freq:'custom',time:'',interval:2,weekday:1};
const p=(cron||'').trim().split(/\s+/);
if(p.length!==5)return r;
const [mi,h,d,m,w]=p;
if(mi==='0'&&h==='*'&&d==='*'&&m==='*'&&w==='*'){r.freq='hourly';return r;}
const n=(h.match(/^\*\/(\d+)$/)||[])[1]; // 每隔N小时:时位是 */N
if(n&&mi==='0'&&d==='*'&&m==='*'&&w==='*'){r.freq='everyN';r.interval=parseInt(n)||2;return r;}
if(/^\d{1,2}$/.test(mi)&&/^\d{1,2}$/.test(h)&&d==='*'&&m==='*'&&w==='*'){
r.freq='daily';r.time=h.padStart(2,'0')+':'+mi.padStart(2,'0');return r;
}
if(/^\d{1,2}$/.test(mi)&&/^\d{1,2}$/.test(h)&&d==='*'&&m==='*'&&/^[0-7]$/.test(w)){
r.freq='weekly';r.time=h.padStart(2,'0')+':'+mi.padStart(2,'0');r.weekday=parseInt(w)%7;return r;
}
return r; // 解析不了 → 落到自定义
}
// 表单 → cron(5段)
function buildCronFromFreq(){
const freq=document.getElementById('f-schedule_freq').value;
if(freq==='custom')return document.getElementById('f-schedule_cron').value.trim();
if(freq==='hourly')return '0 * * * *';
if(freq==='everyN'){
const n=parseInt(document.getElementById('f-schedule_interval').value)||2;
return '0 */'+Math.min(Math.max(n,1),12)+' * * *';
}
const t=(document.getElementById('f-schedule_time').value||'09:00').split(':');
const mi=parseInt(t[1])||0, h=parseInt(t[0])||0;
if(freq==='weekly'){
const w=parseInt(document.getElementById('f-schedule_weekday').value)||0;
return mi+' '+h+' * * '+w;
}
return mi+' '+h+' * * *'; // daily
}
// cron(5段) → 中文描述(任务列表显示)
function cronToText(cron){
const r=parseCronToFreq(cron||'');
if(r.freq==='hourly')return '每小时';
if(r.freq==='everyN')return '每'+r.interval+'小时';
if(r.freq==='daily')return '每天 '+r.time;
if(r.freq==='weekly')return WEEKDAY_NAMES[r.weekday]+' '+r.time;
return 'cron: '+(cron||'-');
}
function toggleScheduleFields(){
const mode=document.getElementById('f-schedule_mode').value;
const showSched=(mode==='cron'||mode==='cron_stop');
document.getElementById('f-freq_wrap').style.display=showSched?'block':'none';
document.getElementById('f-stop_wrap').style.display=mode==='cron_stop'?'block':'none';
if(showSched)showFreqFields();
}
function toggleWindowFields(){
const on=document.getElementById('f-window_enable').checked;
document.getElementById('f-window_wrap').style.display=on?'flex':'none';
document.getElementById('f-window_help').style.display=on?'block':'none';
}
function showFreqFields(){
const freq=document.getElementById('f-schedule_freq').value;
document.getElementById('f-time_wrap').style.display=(freq==='daily'||freq==='weekly')?'block':'none';
document.getElementById('f-interval_wrap').style.display=freq==='everyN'?'block':'none';
document.getElementById('f-weekday_wrap').style.display=freq==='weekly'?'block':'none';
document.getElementById('f-cron_wrap').style.display=freq==='custom'?'block':'none';
}
function onTaskTypeChange(){
const tt=document.getElementById('f-task_type').value;
const isGeneric=tt==='generic_steps';
document.getElementById('step-editor').classList.toggle('show',isGeneric);
document.getElementById('f-params_wrap').style.display=isGeneric?'none':'block';
}
// ================== 自定义动作管理 ==================
async function loadCustomActions(){
const r=await apiGet('/api/custom_actions'); if(!r||!r.ok)return;
_customActions=r.actions||[];
setListPager('actions',{
data:()=>_customActions,
filter:(a,kw)=>((a.name||'').toLowerCase().includes(kw)),
sortValue:(a,k)=>{ if(k==='steps')return (a.steps||[]).length; if(k==='created_at')return a.created_at||''; return a[k]||''; },
render:renderActionRow,
tbody:'tb-actions',pager:'pager-actions',pageSize:10,
empty:'<tr><td colspan="5" class="empty">暂无自定义动作,可点击"新建动作"或用任务编辑器☑选中步骤打包</td></tr>'
});
applyListPager('actions');
initListSearch('actions','search-actions');
_stepEditor._renderLib(); // 刷新步骤编辑器左侧动作库
}
function renderActionRow(a){
const n=(a.steps||[]).length;
return '<tr>'+
'<td style="font-size:20px">'+esc(a.icon||'📦')+'</td>'+
'<td><strong>'+esc(a.name)+'</strong></td>'+
'<td>'+n+'</td>'+
'<td style="font-size:12px;color:#94a3b8">'+esc(a.created_at||'-')+'</td>'+
'<td>'+
(_can('tasks')?(
'<button class="btn btn-xs" onclick="openActionModal(\''+a.id+'\')">编辑</button> '+
'<button class="btn btn-danger btn-xs" onclick="deleteAction(\''+a.id+'\')">删除</button>'
):'<span class="text-muted">只读</span>')+
'</td>'+
'</tr>';
}
function openActionModal(actionId){
const action=actionId?_customActions.find(a=>a.id===actionId):null;
const isEdit=!!action;
document.getElementById('action-modal-title').textContent=isEdit?'编辑动作':'新建动作';
document.getElementById('action-modal-body').innerHTML=
'<div class="form-row">'+
'<div class="form-group"><label>动作名称</label><input type="text" class="form-control" id="a-name" value="'+esc(action?action.name:'')+'" placeholder="如:点赞动作"></div>'+
'<div class="form-group"><label>图标</label><input type="text" class="form-control" id="a-icon" value="'+esc(action?(action.icon&&action.icon!=='📦'?action.icon:''):'')+'" placeholder="📦" style="width:80px"></div>'+
'</div>'+
'<div class="help" style="margin-bottom:8px">用下方步骤编辑器编排动作内容,保存后可拖入任务步骤编辑器复用</div>'+
'<div class="step-editor" style="display:block">'+
'<div class="se-head"><span>动作步骤</span><span style="font-size:11px;color:var(--text-light);margin-left:12px">支持循环/分组/概率触发/拖拽排序</span>'+
(_can('tasks')?'<button class="btn btn-xs btn-primary" id="se-pack-btn2" disabled onclick="_stepEditor.saveAsCustomAction()">打包选中步骤</button>':'')+'</div>'+
'<div class="se-body">'+
'<div class="se-lib" id="action-lib"><div class="lib-title">操作库</div></div>'+
'<div class="se-canvas" id="action-canvas"></div>'+
'</div>'+
'</div>';
document.getElementById('action-modal-footer').innerHTML=
'<button class="btn" onclick="closeActionModal()">取消</button>'+
'<button class="btn btn-primary" onclick="saveAction('+(isEdit?'\''+actionId+'\'':'null')+')">保存动作</button>';
// 用独立画布初始化步骤编辑器(不与任务编辑器冲突)
_stepEditor.init(action&&action.steps?action.steps:[], 0, 'action:'+(actionId||'new'), 'action-canvas', 'action-lib');
document.getElementById('action-modal-overlay').classList.add('show');
}
async function saveAction(actionId){
const name=document.getElementById('a-name').value.trim();
if(!name){showToast('动作名不能为空','error');return;}
const icon=document.getElementById('a-icon').value.trim()||'📦';
const steps=_stepEditor.collect();
_stripIds(steps); // 入库前去掉 id,避免重复使用后 id 冲突
if(!steps.length){showToast('至少需要1个步骤','error');return;}
const data={name:name,icon:icon,steps:steps};
if(actionId){
const r=await apiPut('/api/custom_actions/'+actionId,data);
handleResult(r,()=>{_stepEditor.clearDraft();closeActionModal();loadCustomActions();});
}else{
const r=await apiPost('/api/custom_actions',data);
handleResult(r,()=>{_stepEditor.clearDraft();closeActionModal();loadCustomActions();});
}
}
async function deleteAction(id){
if(!confirm('确认删除此动作?'))return;
const r=await apiDelete('/api/custom_actions/'+id);
handleResult(r,()=>loadCustomActions());
}
function closeActionModal(){
document.getElementById('action-modal-overlay').classList.remove('show');
}
+388
View File
@@ -0,0 +1,388 @@
// 工具 Tab:剪贴板注入/应用版本/STF设备管理 + 维护(重启STF) + adb终端 + Tailscale管理
// ================== 工具:剪贴板注入 ==================
let _clipDevices = []; // [{serial, state}],来源 /api/adb/devices(本地 adb + STF 池)
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;}
_clipDevices = list;
const wrap = document.getElementById('clip-devices');
if(!_clipDevices.length){
wrap.innerHTML='<div class="empty" style="padding:20px">无可用设备(本地 adb 或 STF 池均无)</div>';
status.textContent='';
return;
}
wrap.innerHTML = _clipDevices.map(d=>{
const st = d.state==='stf' ? 'STF池' : (d.state==='device' ? '已连接' : d.state);
const stCls = (d.state==='device'||d.state==='stf') ? '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(d.serial)+'</span>'
+'<span class="label '+stCls+'">'+st+'</span></label>';
}).join('');
status.textContent = '共 '+_clipDevices.length+' 台设备';
}
function toggleClipDevice(cb){
if(cb.checked)_clipSelected.add(cb.value);
else _clipSelected.delete(cb.value);
}
// ================== 工具:应用版本管理 ==================
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){
if(x.error)return '<tr><td style="font-family:monospace">'+esc(x.serial)+'</td>'+
'<td><span class="label label-warning">查询失败</span></td><td colspan="2">'+esc(x.error)+'</td></tr>';
if(x.installed)return '<tr><td style="font-family:monospace">'+esc(x.serial)+'</td>'+
'<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><td style="font-family:monospace">'+esc(x.serial)+'</td>'+
'<td><span class="label label-default">未安装</span></td><td>-</td><td>-</td></tr>';
}
// ================== 工具:STF 设备管理 ==================
let _stfDevRows = []; // [{ip, inCfg, state}]
async function loadStfDevMgmt(){
const status = document.getElementById('stfdev-status');
const r = await apiGet('/api/stf/devmgmt/status');
if(!r || !r.ok){status.textContent='加载失败';showToast('加载失败: '+(r&&r.error||''),'error');return;}
const configured = r.configured || []; // 脚本 DEVICES 里的 IP(裸 IP)
const connected = {}; // 裸 IP -> state(220 adb devices 的 serial 带 :5555,归一化)
(r.connected || []).forEach(d=>{
const s = d.serial;
connected[s.endsWith(':5555') ? s.slice(0,-5) : s] = d.state;
});
_stfDevRows = Array.from(new Set(configured.concat(Object.keys(connected)))).map(ip=>({
ip, inCfg: configured.includes(ip), state: connected[ip]||''
}));
setListPager('stfdev',{
data:()=>_stfDevRows,
filter:(x,kw)=>x.ip.toLowerCase().includes(kw),
sortValue:(x,k)=>{
if(k==='cfg')return x.inCfg?1:0;
if(k==='state'){ const o={device:0,offline:1,unauthorized:2,'':9}; return (o[x.state]!==undefined)?o[x.state]:9; }
return x.ip||'';
},
render:renderStfDevRow,
tbody:'tb-stfdev',pager:'pager-stfdev',pageSize:10,
empty:'<tr><td colspan="4" class="empty">无设备(脚本未配置、220 adb 也无连接)</td></tr>'
});
applyListPager('stfdev');
initListSearch('stfdev','search-stfdev');
status.textContent = '脚本配置 '+configured.length+' 台,已连接 '+Object.keys(connected).length+' 台';
}
function renderStfDevRow(x){
const stHtml = x.state==='device'
? '<span class="label label-success">已连接</span>'
: (x.state ? '<span class="label label-warning">'+esc(x.state)+'</span>' : '<span class="label label-default">未连接</span>');
return '<tr><td style="font-family:monospace">'+esc(x.ip)+'</td>'+
'<td>'+(x.inCfg?'<span class="label label-info">脚本中</span>':'<span class="label label-default">未配置</span>')+'</td>'+
'<td>'+stHtml+'</td>'+
'<td><button class="btn btn-xs btn-danger" onclick="removeStfDev(\''+esc(x.ip)+'\')">移除(断开+删脚本)</button></td></tr>';
}
async function addStfDev(){
const ip = document.getElementById('stfdev-ip').value.trim();
if(!ip){showToast('请输入设备 IP','error');return;}
const r = await apiPost('/api/stf/devmgmt/add', {ip:ip});
if(!r)return;
if(r.ok){
showToast((r.msgs||[]).join(';'),'success');
document.getElementById('stfdev-ip').value='';
loadStfDevMgmt();
}else showToast('添加失败: '+(r.error||''),'error');
}
async function removeStfDev(ip){
if(!confirm('确定移除 '+ip+'?\n\n将从 connect_devices.sh 删除并断开 220 的 adb 连接,\n设备会从 OpenSTF 设备池下线(任务不再分配到它)。'))return;
const r = await apiPost('/api/stf/devmgmt/remove', {ip:ip});
if(!r)return;
if(r.ok){
showToast((r.msgs||[]).join(';'),'success');
loadStfDevMgmt();
}else showToast('移除失败: '+(r.error||''),'error');
}
async function injectClipboard(){
const text = document.getElementById('clip-text').value.trim();
if(!text){showToast('请输入要注入的文字','error');return;}
if(!_clipSelected.size){showToast('请先勾选目标设备','error');return;}
const btn = document.querySelector('#tools-sub-clipboard .btn-primary');
btn.disabled = true; const old = btn.textContent; btn.textContent = '注入中...';
try{
const r = await apiPost('/api/tools/clipboard/set', {serials:[..._clipSelected], text});
if(!r||!r.ok){showToast('注入失败: '+(r&&r.error||''),'error');return;}
const rows = Object.entries(r.results).map(([s,v])=>
'<tr><td style="font-family:monospace">'+esc(s)+'</td><td>'+(v.ok?'<span class="label label-success">成功</span>':'<span class="label label-danger">失败</span> '+esc(v.msg))+'</td></tr>').join('');
document.getElementById('clip-result').innerHTML = rows;
document.getElementById('clip-result-wrap').style.display = 'block';
showToast('注入完成:成功 '+r.ok_count+' 台,失败 '+r.fail_count+' 台', r.fail_count?'error':'success');
} finally {
btn.disabled = false; btn.textContent = old;
}
}
// ================== 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:'离线',stf:'STF池(未连接)'};
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 uninstallStfAgent(){
const serial=_selectedSerial();
if(!serial){showToast('请先选择目标设备','error');return;}
if(!confirm('确定卸载 '+serial+' 上的 STF agent(jp.co.cyberagent.stf)?\n\n卸载后 STF 会在设备重连时自动重新推送安装。'))return;
_runAdbFull('adb -s '+serial+' uninstall jp.co.cyberagent.stf');
}
function reconnectAdbDevice(){
const serial=_selectedSerial();
if(!serial){showToast('请先选择目标设备','error');return;}
_runAdbFull('adb connect '+serial);
}
function checkStfAgent(){
const serial=_selectedSerial();
if(!serial){showToast('请先选择目标设备','error');return;}
_runAdbFull('adb -s '+serial+' shell pm list packages | grep cyberagent');
}
async function restartStf(){
if(!confirm('确定重启 STF 容器?\n\n影响:STF 约 30 秒不可用,期间所有设备占用状态重置,正在执行的任务会受影响。\n\n确认继续吗?'))return;
const st=document.getElementById('stf-restart-status');
st.textContent='正在重启 STF 容器(SSH 执行中,最长 60s)...';
const r=await apiPost('/api/stf/restart',{});
if(!r)return;
if(r.ok){st.textContent='✅ '+r.msg+(r.detail?' ['+r.detail+']':'');showToast(r.msg,'success');}
else{st.textContent='❌ '+r.error;showToast(r.error,'error');}
}
// 测试此步骤弹窗关闭
function closeTestModal(){
const ov=document.getElementById('test-overlay');
if(ov)ov.classList.remove('show');
}
// ================== 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 连接,且平台设备池(STF serial)随之变化,改完记得同步 STF 设备池/分组/任务目标。', (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='已复制';
}