一、平台侧(账号 → 发布计划页) - 新表 video_plan(schema v9→v10):账号×发布日期×编号 → 素材 + 标题 + 发布状态 + 分享链接; 状态机 pending/ready/pushing/publishing/done/failed/unknown/skipped(**failed 与 unknown 必须分开**: 推送阶段的失败可安全重试;碰过抖音之后的岔子只能算"结果未知",绝不自动重发) - 素材上传:文件名 `手机号_日期_编号`(编号可省)解析配对;标题 txt `标题内容_手机号_日期_编号`; 内容寻址落盘 data/videos/YYYY-MM/(sha1 分块算,同名不存两份),**不进整库备份**但进 manifest 反查 - 新蓝图 web/video_plan_api.py:上传/时间线/统计/单条增删改/推送到手机/标记结果/裁决/链接导出 CSV/ 任务列表与一键新建、**就地编辑**(GET/PUT /tasks/<id>)、**一键推送**(POST /push_all,按设备分组、设备内串行) - 账号页拆子分栏(台账 / 发布计划)+ static/admin/release.js;清理 job(04:41 僵尸回收+过期行、04:47 素材文件) - 上传体积:MAX_CONTENT_LENGTH(默认 2GiB)+ 413 JSON + nginx client_max_body_size(修现有 APK 上传隐患) 二、任务侧(平台推素材,抖音流程你自己写) - 新步骤 push_release「推送发布视频」:原子占位 → adb push → **touch 改成"现在"** → 清旧目录同名副本 → 触发扫描并**按路径**校验相册索引 → 标题写进剪贴板;默认目录 /sdcard/DCIM/Camera - 新步骤 mark_release「标记发布结果」:回写 done/failed/unknown,成功时抓作品分享链接、删手机素材 - input_text 支持 text_source=release_title(自动取计划标题 + 回读校验); if_el 的候选值来源新增 release(**本机当前发布计划**的抖音号/昵称,发布前校验"登的是不是要发的号") - build_release_steps 骨架 15 步:⓪ 亮屏 → ① 打开抖音(等首页) → ② 点「我」→ ③ 等抖音号出现 → ④ 条件判断(账号) → then ⑤ 推送 ⑥⑦⑧⑨⑩⑪⑫ 抖音点击/填标题 → ⑬ 标记 / else 发通知跳过 三、修(推送这一路的检测机制) - **uiautomator2 3.x 的 d.shell() 返回 ShellResponse(tuple 子类)不是 str**:`'x' in resp` 恒 False、 `.strip()` 不存在 → "推上去的文件大小不对"每次都判失败(文件其实推上去了)、相册校验永远报没进、 删除确认永远判没删掉。新增 publish_flow._sh() 统一取 .output;大小改成解析 ls -l 的大小列 - **adb push 保留本地 mtime** → 推 3 天前上传的素材在按时间排序的相册里排不到最前, "点第一个 = 刚推的那个"不成立 → 推完 touch - 相册校验**按路径**比(MediaStore 的 _data 会把目录小写、/storage/emulated/0 ≡ /sdcard), 只比文件名会被老目录的同名残留骗过去 - 屏幕没亮就启动抖音会永远停在启动页(UI 树为空)→ 后面"点我/等抖音号"必然 miss, 最后报成误导人的"账号不符" → 骨架第一步固定加「亮屏」,open_app 等「首页」出现 四、其它 - core/ledger.serial_of():设备名 → 当前地址(设备换 IP 后快照是错的) - 通知事件 task.video.published / task.video.failed;备份清单加 video_plan 与素材统计 - 文档同步:DATA_MODEL §2.11 + schema v10、API(新接口与语义)、TASK_DEV §4.7 专章、 ARCHITECTURE(账号页子分栏/release.js/两个 job)、DEPLOY(表数/nginx)、NOTIFY、DEVELOPMENT、README
205 lines
9.7 KiB
JavaScript
205 lines
9.7 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(typeof _rpTimer!=='undefined'&&_rpTimer){clearInterval(_rpTimer);_rpTimer=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==='account'){showSubTab('account',_activeSubs.account||'ledger');}
|
||
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', account: 'ledger'};
|
||
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(tabId==='account' && name==='ledger' && typeof loadLedger==='function') loadLedger();
|
||
if(tabId==='account' && name==='plan' && typeof loadReleasePlan==='function'){
|
||
loadReleasePlan();
|
||
if(typeof loadReleaseTasks==='function') loadReleaseTasks();
|
||
}
|
||
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');}
|
||
}
|
||
|