用户场景(他原话):一台手机登录 5 个抖音号、一共 5 台手机,每个任务只让其中一个
目标号评论;每天跑一次但不知道什么时候跑完,于是"一直重复跑" → 结果
"一个手机还没评论到,一个手机都评论两次了"。
**根因不是"单设备重复",是跨设备没有共享的判断 + 进度不可见。** 所以做两件事:
① 幂等;② 把"谁做过了、还差谁"摆到台面上(不然只能靠重跑确认,而重跑又在制造重复)。
- `core/models.py`:新表 `done_mark`(迁移账本补 v7)。**判据只有 `scope_key` 的
唯一索引**——多台设备会同时判断"没做过","先查后插"有竞态(两台都插),
唯一索引 + `INSERT ... ON DUPLICATE KEY`/`INSERT OR IGNORE` 的**受影响行数**才原子。
- `core/dedup.py`(新):`build_key`(`任务|身份|时间桶`)/ `check` / `mark` /
`list_marks`(带"今天做了几台/几个号"统计)/ `delete_mark` / `clear_job` / `purge_old`。
自建 app context(照 device_pool 的 `_ctx()`),任务线程/Web/清理都不用关心。
- 任务侧两个部件(**检查在前、记账在后**):
· `if_el` 新增条件类型 `selector_type="dedup"`:命中=这个身份做过了 → 走 then 分支。
身份元素在 `ident_type`/`ident_value`(留空 = 用设备 serial,一号一机场景)。
· 新步骤 `mark_done`「记为已做」(22 种步骤):放动作**成功之后**。
拆两步的用意:动作失败就不记账,下次重跑还会重试该设备 —— 失败不丢。
- 有效期(`dedup_reset` = day/all/hours)放**任务级**:检查与记账两处各填一份的话,
填不一致就算出两个 key、去重会**静默失效**,所以强制只配一处(编辑器顶部下拉)。
- 三条防误伤规则(都有测试兜着):
· 身份读不到 / 身份值过长 → **不去重、当没做过照常执行**。绝不能把"读不到"
当成空身份——那会让所有设备共用一个 key、第一台记账后其余全被误判成"做过"。
· `kind='all'`(只做一次)的记录**永不清理**(清了等于语义失效);清理只删 day/hours。
· 去重的两个易错点在保存时直接告警:身份元素两边不一致、有检查没记账/有记账没检查。
- 「任务 → 去重记录」新子分栏(`static/admin/dedup.js`):统计行 + 明细表 +
删单条(那个号重跑)/ 清空任务(整批重跑)。接口 3 个(GET/delete/clear,PERM_TASKS)。
- 每日 04:23 清理(挂现有 APScheduler),`TABLE_LABELS` 补中文名(备份覆盖自动派生)。
- AI 建任务草稿校验同步:`dedup` 走自己的规则(要 ident_value、xpath 前缀校验),
没填身份元素只警告不拦(用设备当身份是合法用法);普通条件空选择器仍然拦。
- 文档:TASK_DEV §4.6(去重专章 + App 内检测的兜底配方与它的三个局限)、
DATA_MODEL §2.9、API 三个接口、ARCHITECTURE(分层/装配/子分栏/JS 分工/清理)、
DEPLOY §5.2(15 张表)、步骤数 21→22 全库同步。
自测:单元 + 集成 33 项(**含 8 线程抢同一个身份、恰好一个成功**的原子性断言,
以及"all 记录不被清理""身份读不到不去重""清了能重跑")、
**真机端到端**(cs1 上"检查→动作→记账"跑两遍:第二遍被拦、换 serial 的"另一台设备"
同样被拦、删记录后能重跑)、草稿校验 5 项、GET 冒烟 56 路由 0 个 500。
(注:本分支基于 feat/if-el-multi-value,因为它俩都要改 task.py 的 STEP_TYPES 与
editor.js 的 STEP_LIB 同一区域,分开从 dev 拉必然冲突——这份是超集,合一次两份都进。)
198 lines
9.3 KiB
JavaScript
198 lines
9.3 KiB
JavaScript
// 通用基础: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:'指定设备'};
|
||
|
||
// 设备显示文案:**名称是设备身份**,地址只是当前连接位置。
|
||
// 有名称 → 「名称 · 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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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'));
|
||
const panel=document.getElementById('tab-'+name);
|
||
if(panel)panel.classList.add('active');
|
||
const btn=document.querySelector('.navbar .tab[data-tab="'+name+'"]');
|
||
if(btn)btn.classList.add('active');
|
||
// 停止所有定时器
|
||
if(_monitorTimer){clearInterval(_monitorTimer);_monitorTimer=null;}
|
||
if(_logTimer){clearInterval(_logTimer);_logTimer=null;}
|
||
if(_discoveryTimer){clearInterval(_discoveryTimer);_discoveryTimer=null;}
|
||
// 按需加载数据
|
||
if(name==='monitor'){loadMonitor();_monitorTimer=setInterval(loadMonitor,5000);}
|
||
if(name==='tasks'){showSubTab('tasks',_activeSubs.tasks);loadTasks();loadCustomActions();
|
||
if(typeof loadStepDefaults==='function')loadStepDefaults();}
|
||
if(name==='tools'){showSubTab('tools',_activeSubs.tools);loadToolsDevices();loadAdbDevices();loadTailscaleDevices();loadApks();}
|
||
if(name==='logs'){
|
||
showSubTab('logs', _activeSubs.logs||'files'); // 文件日志 / 步骤明细
|
||
if((_activeSubs.logs||'files')==='files'
|
||
&& document.getElementById('log-auto').checked)_logTimer=setInterval(loadLogs,3000);
|
||
}
|
||
if(name==='agent'){
|
||
showSubTab('agent', _activeSubs.agent||'chat'); // 聊天 / AI 建任务
|
||
if(typeof initAgentChat==='function') initAgentChat();
|
||
}
|
||
if(name==='users')loadUsers();
|
||
if(name==='system'){showSubTab('system',_activeSubs.system||'backup');}
|
||
}
|
||
|
||
// ================== 页内子分栏(任务/工具 通用) ==================
|
||
// 每个带子分栏的 Tab 记住上次选中的子分栏,切走再切回来保持原位
|
||
let _activeSubs = {tasks: 'plan', tools: 'clipboard', system: 'backup', agent: 'chat',
|
||
logs: 'files'};
|
||
let _discoveryTimer = null; // 设备自动发现 10s 轮询(仅 devpool 子分栏激活时)
|
||
|
||
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));
|
||
if(tabId==='agent' && name==='taskgen' && typeof initTaskGen==='function') initTaskGen();
|
||
if(tabId==='system' && name==='notify' && typeof loadNotifyPanel==='function') loadNotifyPanel();
|
||
if(tabId==='logs' && name==='files' && typeof loadLogs==='function') loadLogs();
|
||
if(tabId==='logs' && name==='steps' && typeof loadStepLogs==='function') loadStepLogs(true);
|
||
if(tabId==='tasks' && name==='actioncfg' && typeof loadActionConfig==='function') loadActionConfig();
|
||
if(tabId==='tasks' && name==='dedup' && typeof loadDedup==='function') loadDedup();
|
||
if(name==='groups' && typeof loadGroups==='function') loadGroups();
|
||
if(name==='apks' && typeof loadAgentStore==='function') loadAgentStore();
|
||
if(name==='devpool' && typeof loadDevPool==='function'){
|
||
loadDevPool();
|
||
if(typeof loadDiscovery==='function'){
|
||
loadDiscovery();
|
||
if(_discoveryTimer){clearInterval(_discoveryTimer);_discoveryTimer=null;}
|
||
_discoveryTimer=setInterval(loadDiscovery,10000); // 10s 轮询:扫完自动出新结果
|
||
}
|
||
} else if(_discoveryTimer){clearInterval(_discoveryTimer);_discoveryTimer=null;}
|
||
}
|
||
|
||
// ================== 模态框 ==================
|
||
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);}
|
||
}
|
||
|
||
// 测试此步骤弹窗关闭(editor.js 的测试按钮用;曾随 STF 清理误删)
|
||
function closeTestModal(){
|
||
const ov=document.getElementById('test-overlay');
|
||
if(ov)ov.classList.remove('show');
|
||
}
|
||
|
||
// 关闭元素抓取弹窗(不影响任务编辑窗口)
|
||
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');}
|
||
}
|
||
|