一、平台侧(账号 → 发布计划页) - 新表 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
1009 lines
53 KiB
JavaScript
1009 lines
53 KiB
JavaScript
// 视频发布计划(「账号 → 发布计划」页)
|
||
// 数据在 video_plan 表,服务层 core/video_plan.py,接口 /api/video_plan/*(web/video_plan_api.py)。
|
||
//
|
||
// 这一页要解决的事:人员批量传视频与标题 → 自动按文件名配到账号上 → 时间线看每天谁要发、
|
||
// 发到哪一步了 → 到日子由任务把视频推到手机相册、走抖音发布流程 → 发布后抓分享链接存下来
|
||
// (平台不长期囤视频,链接才是长期资产;后续铺评论也是用这个链接打开视频)。
|
||
//
|
||
// 三条与安全有关的约定(别改):
|
||
// ① 上传**串行**:并发会打乱"无编号按顺序配"的顺序,还会撞 SQLite 回退模式的写锁
|
||
// ② 上传前把队列**按文件名排序**并给用户看一遍顺序(FileList 的顺序在 Windows 上不稳定)
|
||
// ③ 界面上同时显示"视频文件名"与"标题" —— 配错对这一眼就能看出来
|
||
var _rpRows = [], _rpDays = [], _rpStats = {}, _rpDisk = {}, _rpToday = '';
|
||
var _rpTimer = null;
|
||
|
||
function loadReleasePlan() {
|
||
var rng = (document.getElementById('rp-range') || {}).value || 'today';
|
||
var st = (document.getElementById('rp-status') || {}).value || '';
|
||
var q = (document.getElementById('search-release') || {}).value || '';
|
||
var url = '/api/video_plan/timeline?range=' + encodeURIComponent(rng)
|
||
+ '&status=' + encodeURIComponent(st) + '&q=' + encodeURIComponent(q.trim()) + '&limit=1000';
|
||
apiGet(url).then(function (r) {
|
||
var box = document.getElementById('rp-timeline');
|
||
if (!r || !r.ok) {
|
||
if (box) box.innerHTML = '<div class="empty" style="padding:16px">读取失败:' + esc((r && r.error) || '') + '</div>';
|
||
return;
|
||
}
|
||
_rpDays = r.days || [];
|
||
_rpToday = r.today || '';
|
||
_rpStats = r.stats || {};
|
||
_rpDisk = r.disk || {};
|
||
_rpRows = [];
|
||
_rpDays.forEach(function (d) {
|
||
(d.plans || []).forEach(function (p) { _rpRows.push(p); });
|
||
});
|
||
renderReleaseStats();
|
||
renderReleaseTimeline();
|
||
renderReleaseTable();
|
||
rpEnsurePolling();
|
||
});
|
||
}
|
||
|
||
function rpRangeChanged() { loadReleasePlan(); }
|
||
|
||
function _fmtSize(n) {
|
||
n = n || 0;
|
||
if (n >= 1 << 30) return (n / (1 << 30)).toFixed(1) + ' GB';
|
||
if (n >= 1 << 20) return (n / (1 << 20)).toFixed(1) + ' MB';
|
||
if (n >= 1 << 10) return (n / (1 << 10)).toFixed(0) + ' KB';
|
||
return n + ' B';
|
||
}
|
||
|
||
var RP_STATUS = {
|
||
pending: ['label-default', '待标题'], ready: ['label-info', '待发布'],
|
||
pushing: ['label-warning', '已推送到手机'], publishing: ['label-warning', '发布中'],
|
||
done: ['label-success', '已发布'], failed: ['label-danger', '失败(可重试)'],
|
||
unknown: ['label-warning', '结果未知(需确认)'], skipped: ['label-default', '已跳过']
|
||
};
|
||
|
||
function rpStatusLabel(st) {
|
||
var s = RP_STATUS[st] || ['label-default', st];
|
||
return '<span class="label ' + s[0] + '">' + s[1] + '</span>';
|
||
}
|
||
|
||
function renderReleaseStats() {
|
||
var el = document.getElementById('rp-stats');
|
||
if (!el) return;
|
||
var s = _rpStats || {}, d = _rpDisk || {};
|
||
var lowDisk = d.free && d.free < 2 * (1 << 30);
|
||
el.innerHTML = '今天要发 <b>' + (s.today_ready || 0) + '</b> 个'
|
||
+ '(' + (s.today_devices || 0) + ' 台) · 今天已发 <b>' + (s.today_done || 0) + '</b>'
|
||
+ ' · 待标题 ' + (s.pending || 0)
|
||
+ ' · 过期未发 ' + (s.overdue || 0)
|
||
+ (s.unknown ? ' · <span style="color:#f59e0b">结果未知 ' + s.unknown + '</span>' : '')
|
||
+ ' | 素材 ' + _fmtSize(d.videos_bytes) + '(' + (d.videos_count || 0) + ' 个)'
|
||
+ ' · 剩余 <span' + (lowDisk ? ' style="color:#ef4444;font-weight:600"' : '') + '>'
|
||
+ _fmtSize(d.free) + '</span>';
|
||
}
|
||
|
||
function renderReleaseTimeline() {
|
||
var box = document.getElementById('rp-timeline');
|
||
if (!box) return;
|
||
if (!_rpDays.length) {
|
||
box.innerHTML = '<div class="empty" style="padding:20px;text-align:center">'
|
||
+ '这个范围里没有计划 —— 点「上传视频」把素材传进来</div>';
|
||
return;
|
||
}
|
||
box.innerHTML = _rpDays.map(function (day) {
|
||
var isToday = day.date === _rpToday;
|
||
var cards = day.plans.map(rpCard).join('');
|
||
return '<div style="margin:14px 0 6px;padding-left:8px;border-left:3px solid '
|
||
+ (isToday ? 'var(--primary)' : 'var(--border)') + '">'
|
||
+ '<div style="font-weight:600">' + esc(day.date) + ' ' + esc(day.weekday || '')
|
||
+ (isToday ? ' <b style="color:var(--primary)">今天</b>' : '')
|
||
+ '<span class="text-muted" style="font-weight:400"> 计划 ' + day.total
|
||
+ ' 个 / 已发 ' + day.done + ' 个</span></div></div>' + cards;
|
||
}).join('');
|
||
}
|
||
|
||
function rpCard(p) {
|
||
var acc = '<b>' + esc(p.device_name || '?') + '</b> ' + esc(p.nickname || '')
|
||
+ ' <span class="text-muted" style="font-family:var(--mono)">'
|
||
+ esc(p.douyin_id || p.phone) + '</span>';
|
||
var video = p.video_name
|
||
? '<span title="' + esc(p.video_name) + '">' + esc(p.video_name.slice(0, 40)) + '</span>'
|
||
+ (p.video_deleted_at ? ' <span class="label label-default">素材已清理</span>' : '')
|
||
: '<span class="label label-default">素材已清理</span>';
|
||
var title = p.title ? esc(p.title.slice(0, 30)) : '<span style="color:#f59e0b">⚠ 缺标题</span>';
|
||
var link = p.share_url
|
||
? '<a href="' + esc(p.share_url) + '" target="_blank" style="font-family:var(--mono)">'
|
||
+ esc(p.share_url.replace('https://v.douyin.com/', '…/')) + '</a>'
|
||
+ ' <button class="btn btn-xs" onclick="rpCopyLink(\'' + esc(p.share_url) + '\')">复制</button>'
|
||
: (p.status === 'done' ? '<span style="color:#f59e0b">缺链接</span>' : '');
|
||
var ops = [];
|
||
if (p.status === 'ready' || p.status === 'failed') {
|
||
ops.push('<button class="btn btn-xs btn-primary" onclick="rpPushToDevice(\'' + p.id + '\')" data-perm="devices">'
|
||
+ '推送到手机</button>');
|
||
}
|
||
if (p.status === 'pushing') {
|
||
ops.push('<button class="btn btn-xs btn-primary" onclick="rpMark(\'' + p.id + '\',\'done\')" data-perm="devices">标记已发布</button>');
|
||
ops.push('<button class="btn btn-xs" onclick="rpMark(\'' + p.id + '\',\'failed\')" data-perm="devices">标记失败</button>');
|
||
}
|
||
if (p.status === 'unknown') {
|
||
ops.push('<button class="btn btn-xs btn-primary" onclick="rpResolve(\'' + p.id + '\',true)" data-perm="devices">已发出</button>');
|
||
ops.push('<button class="btn btn-xs" onclick="rpResolve(\'' + p.id + '\',false)" data-perm="devices">未发出</button>');
|
||
}
|
||
if (p.status === 'failed' || p.status === 'unknown') {
|
||
ops.push('<button class="btn btn-xs" onclick="rpRetry(\'' + p.id + '\')" data-perm="devices">改回待发</button>');
|
||
}
|
||
ops.push('<button class="btn btn-xs" onclick="rpEditTitle(\'' + p.id + '\')" data-perm="devices">改标题</button>');
|
||
if (p.status !== 'done' && p.status !== 'skipped') {
|
||
ops.push('<button class="btn btn-xs" onclick="rpSkip(\'' + p.id + '\')" data-perm="devices">跳过</button>');
|
||
}
|
||
ops.push('<button class="btn btn-xs btn-danger" onclick="rpDelete(\'' + p.id + '\')" data-perm="devices">删除</button>');
|
||
// 推送的相册校验结果单独显示:文件推上去了 ≠ 相册里点得到它(后面的发布步骤要点相册)
|
||
var pv = p.push_verify || '';
|
||
var pushTag = !pv ? ''
|
||
: (pv === 'ok'
|
||
? ' <span class="label label-success" title="已进相册索引,抖音选视频能看到它">已推送·相册可见</span>'
|
||
: (pv === 'no_index'
|
||
? ' <span class="label label-warning" title="文件在手机上,但没进相册索引 —— 相册/抖音里可能看不到它">已推送·相册未见</span>'
|
||
: ' <span class="label label-warning">已推送·文件不在</span>'));
|
||
var warn = p.status === 'unknown'
|
||
? '<div style="color:#fbbf24;font-size:12px;margin-top:4px">'
|
||
+ '上次发布结果未知:可能已经发出去了,先去抖音里看一眼,再点「已发出 / 未发出」——平台不会自动重发</div>'
|
||
: (pv === 'no_index'
|
||
? '<div style="color:#fbbf24;font-size:12px;margin-top:4px">'
|
||
+ '素材推上去了但没进相册索引:发布步骤里的「相册」可能点不到它 ——'
|
||
+ '先到手机相册确认能选到,不行就重推一次(推送时会顺带触发扫描)</div>'
|
||
: (p.last_error ? '<div class="text-muted" style="font-size:12px">' + esc(p.last_error.slice(0, 80)) + '</div>' : ''));
|
||
return '<div class="card" style="margin:6px 0;padding:8px 10px">'
|
||
+ '<div style="display:flex;justify-content:space-between;gap:10px;flex-wrap:wrap">'
|
||
+ '<div>' + acc + ' <span class="label label-default">编号 ' + p.seq
|
||
+ (p.seq_auto ? '(自动)' : '') + '</span> ' + rpStatusLabel(p.status)
|
||
+ (p.attempts ? ' <span class="text-muted">已试 ' + p.attempts + ' 次</span>' : '') + pushTag + '</div>'
|
||
+ '<div style="white-space:nowrap">' + ops.join(' ') + '</div></div>'
|
||
+ '<div style="margin-top:4px">素材:' + video + ' | 标题:' + title
|
||
+ (link ? ' | ' + link : '') + '</div>'
|
||
+ (p.published_at ? '<div class="text-muted" style="font-size:12px">发布于 ' + esc(p.published_at) + '</div>' : '')
|
||
+ warn + '</div>';
|
||
}
|
||
|
||
function renderReleaseTable() {
|
||
setListPager('release', {
|
||
data: function () { return _rpRows; },
|
||
filter: function (p, kw) {
|
||
return ((p.device_name || '') + (p.nickname || '') + (p.phone || '') + (p.douyin_id || '')
|
||
+ (p.title || '') + (p.video_name || '')).toLowerCase().indexOf(kw) >= 0;
|
||
},
|
||
sortValue: function (p, k) { return p[k]; },
|
||
render: function (p) {
|
||
return '<tr><td>' + esc(p.release_date) + '</td><td>' + esc(p.device_name || '-') + '</td>'
|
||
+ '<td>' + esc(p.nickname || '-') + '/' + esc(p.douyin_id || p.phone) + '</td>'
|
||
+ '<td>' + p.seq + (p.seq_auto ? '*' : '') + '</td>'
|
||
+ '<td style="max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="'
|
||
+ esc(p.video_name) + '">' + esc(p.video_name || '-') + '</td>'
|
||
+ '<td style="max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="'
|
||
+ esc(p.title) + '">' + esc(p.title || '-') + '</td>'
|
||
+ '<td>' + rpStatusLabel(p.status) + '</td>'
|
||
+ '<td>' + (p.share_url ? '<button class="btn btn-xs" onclick="rpCopyLink(\'' + esc(p.share_url) + '\')">复制链接</button>' : '-') + '</td>'
|
||
+ '<td><button class="btn btn-xs" onclick="rpEditTitle(\'' + p.id + '\')" data-perm="devices">改标题</button></td></tr>';
|
||
},
|
||
tbody: 'tb-release', pager: 'pager-release', pageSize: 20,
|
||
empty: '<tr><td colspan="9" class="empty">这个范围里没有计划</td></tr>'
|
||
});
|
||
applyListPager('release');
|
||
initListSearch('release', 'search-release');
|
||
}
|
||
|
||
function rpEnsurePolling() {
|
||
// 只在"刚推完 / 正在推"的那一小段时间里盯(推送是秒级动作,推完就停在 pushing
|
||
// 等人标记 —— 那时候没必要一直刷)
|
||
var now = Date.now();
|
||
var busy = _rpRows.some(function (p) {
|
||
if (p.status !== 'pushing' && p.status !== 'publishing') return false;
|
||
var t = Date.parse((p.updated_at || '').replace(/-/g, '/'));
|
||
return !t || (now - t) < 90000;
|
||
});
|
||
if (busy && !_rpTimer) {
|
||
_rpTimer = setInterval(loadReleasePlan, 3000); // 有在跑的就盯着,跑完自动停
|
||
} else if (!busy && _rpTimer) {
|
||
clearInterval(_rpTimer); _rpTimer = null;
|
||
}
|
||
}
|
||
|
||
// ================== 上传:视频 ==================
|
||
// 文件名规则与服务端一致(这里只做**本地预检**,真正的判定永远以后端为准)
|
||
var _RP_PHONE = /^1\d{10}$/, _RP_DATE = /^\d{8}$/, _RP_SEQ = /^\d{1,3}$/;
|
||
|
||
function rpPrecheck(name) {
|
||
var dot = name.lastIndexOf('.');
|
||
var stem = dot > 0 ? name.slice(0, dot) : name;
|
||
var parts = stem.split('_');
|
||
var phone = '', date = '', seq = '', bad = '';
|
||
parts.forEach(function (p) {
|
||
if (!p) { bad = '有空片段'; return; }
|
||
if (_RP_PHONE.test(p)) { phone = phone || p; }
|
||
else if (_RP_DATE.test(p)) { date = date || p; }
|
||
else if (_RP_SEQ.test(p)) { seq = seq || p; }
|
||
else bad = bad || p;
|
||
});
|
||
return { phone: phone, date: date, seq: seq, bad: bad,
|
||
ok: !!phone && !!date && !bad };
|
||
}
|
||
|
||
function rpPickVideos(input) {
|
||
var files = Array.prototype.slice.call(input.files || []);
|
||
input.value = ''; // 允许再选同名文件
|
||
if (!files.length) return;
|
||
// **按文件名排序**再传:FileList 的顺序在 Windows 上不稳定,而"无编号按顺序配"
|
||
// 依赖上传顺序 —— 排序把顺序变成确定的
|
||
files.sort(function (a, b) { return a.name.localeCompare(b.name, 'zh-Hans-CN', { numeric: true }); });
|
||
var rows = files.map(function (f, i) {
|
||
var c = rpPrecheck(f.name);
|
||
return { file: f, idx: i, c: c };
|
||
});
|
||
rpShowUploadConfirm(rows);
|
||
}
|
||
|
||
function rpShowUploadConfirm(rows) {
|
||
document.getElementById('modal-title').textContent = '上传视频(' + rows.length + ' 个)';
|
||
document.getElementById('modal-box').classList.add('wide-modal');
|
||
var body = '<div class="help" style="margin-bottom:8px">'
|
||
+ '确认下面每个文件解析出的账号与日期对不对(<b>传上去就按这个配</b>)。'
|
||
+ '文件名不带编号的,会按这个顺序依次填进该账号当天的空位。</div>'
|
||
+ '<div style="max-height:50vh;overflow:auto"><table class="table table-sm">'
|
||
+ '<thead><tr><th>#</th><th>文件名</th><th>解析</th><th>大小</th></tr></thead><tbody>'
|
||
+ rows.map(function (r) {
|
||
var c = r.c;
|
||
var parsed = c.ok
|
||
? esc(c.phone) + ' / ' + esc(c.date) + (c.seq ? ' / 编号 ' + esc(c.seq) : ' / <span class="text-muted">编号自动</span>')
|
||
: '<span style="color:#ef4444">✗ ' + esc(c.bad ? '多了片段 ' + c.bad : (!c.phone ? '缺手机号' : '缺日期')) + '(会被拒收)</span>';
|
||
return '<tr><td>' + (r.idx + 1) + '</td><td>' + esc(r.file.name) + '</td><td>' + parsed
|
||
+ '</td><td>' + _fmtSize(r.file.size) + '</td></tr>';
|
||
}).join('') + '</tbody></table></div>'
|
||
+ '<div id="rp-upload-progress"></div>';
|
||
document.getElementById('modal-body').innerHTML = body;
|
||
document.getElementById('modal-footer').innerHTML =
|
||
'<button class="btn" onclick="closeModal()" id="rp-upload-cancel">取消</button>'
|
||
+ '<button class="btn btn-primary" id="rp-upload-go" onclick="rpStartUpload()">开始上传</button>';
|
||
window._rpQueue = rows;
|
||
showModal();
|
||
}
|
||
|
||
function rpStartUpload() {
|
||
var rows = window._rpQueue || [];
|
||
var go = document.getElementById('rp-upload-go');
|
||
var cancel = document.getElementById('rp-upload-cancel');
|
||
if (go) go.style.display = 'none';
|
||
if (cancel) cancel.style.display = 'none';
|
||
var box = document.getElementById('rp-upload-progress');
|
||
var total = rows.reduce(function (s, r) { return s + r.file.size; }, 0);
|
||
var doneBytes = 0, doneFiles = 0, okN = 0, badN = 0;
|
||
box.innerHTML = '<div style="margin:8px 0"><b id="rp-up-total">0 / ' + rows.length
|
||
+ '</b> <span class="text-muted">(' + _fmtSize(total) + ')</span>'
|
||
+ '<div class="progress" style="margin-top:4px"><div class="progress-bar" id="rp-up-bar" style="width:0%"></div></div></div>'
|
||
+ '<table class="table table-sm"><thead><tr><th>文件</th><th>进度</th><th>结果</th></tr></thead>'
|
||
+ '<tbody id="rp-up-rows">'
|
||
+ rows.map(function (r, i) {
|
||
return '<tr id="rp-up-' + i + '"><td>' + esc(r.file.name.slice(0, 40)) + '</td>'
|
||
+ '<td style="width:150px"><div class="progress"><div class="progress-bar" id="rp-bar-' + i + '" style="width:0%"></div></div></td>'
|
||
+ '<td id="rp-res-' + i + '">等待…</td></tr>';
|
||
}).join('') + '</tbody></table>'
|
||
+ '<div id="rp-up-summary" style="margin-top:8px"></div>';
|
||
|
||
(function next(i) {
|
||
if (i >= rows.length) {
|
||
var sum = document.getElementById('rp-up-summary');
|
||
sum.innerHTML = '<b>完成:成功 ' + okN + ' 个' + (badN ? ',拒收 ' + badN + ' 个' : '')
|
||
+ '</b> <button class="btn" onclick="closeModal();loadReleasePlan()">关闭并刷新</button>';
|
||
if (cancel) cancel.style.display = '';
|
||
showToast('上传完成:成功 ' + okN + ',拒收 ' + badN, badN ? 'warning' : 'success');
|
||
return;
|
||
}
|
||
rpUploadOne(rows[i].file, i, function (pct) {
|
||
doneBytes += 0; // 明细进度按每个文件单独算,总进度在完成时累加
|
||
var bar = document.getElementById('rp-bar-' + i);
|
||
if (bar) bar.style.width = pct + '%';
|
||
}, function (res) {
|
||
doneFiles++;
|
||
doneBytes += rows[i].file.size;
|
||
var resEl = document.getElementById('rp-res-' + i);
|
||
if (res && res.ok) {
|
||
okN++;
|
||
resEl.innerHTML = '<span style="color:#22c55e">✓ ' + esc(res.msg || '已录入') + '</span>'
|
||
+ (res.action === 'skip' ? ' <span class="label label-default">已存在</span>' : '');
|
||
} else {
|
||
badN++;
|
||
resEl.innerHTML = '<span style="color:#ef4444">✗ ' + esc((res && res.error) || '失败')
|
||
+ '</span> <button class="btn btn-xs" onclick="rpRetryOne(' + i + ')" id="rp-again-' + i + '">重传</button>';
|
||
}
|
||
var t = document.getElementById('rp-up-total');
|
||
if (t) t.textContent = doneFiles + ' / ' + rows.length;
|
||
var b = document.getElementById('rp-up-bar');
|
||
if (b) b.style.width = Math.round(doneBytes * 100 / Math.max(1, total)) + '%';
|
||
next(i + 1);
|
||
});
|
||
})(0);
|
||
}
|
||
|
||
function rpRetryOne(i) {
|
||
var rows = window._rpQueue || [];
|
||
var bar = document.getElementById('rp-bar-' + i);
|
||
if (bar) bar.style.width = '0%';
|
||
var resEl = document.getElementById('rp-res-' + i);
|
||
if (resEl) resEl.textContent = '重传中…';
|
||
rpUploadOne(rows[i].file, i, function (pct) {
|
||
if (bar) bar.style.width = pct + '%';
|
||
}, function (res) {
|
||
if (resEl) {
|
||
resEl.innerHTML = res && res.ok
|
||
? '<span style="color:#22c55e">✓ ' + esc(res.msg || '已录入') + '</span>'
|
||
: '<span style="color:#ef4444">✗ ' + esc((res && res.error) || '失败') + '</span>';
|
||
}
|
||
});
|
||
}
|
||
|
||
function rpUploadOne(file, i, onProgress, done) {
|
||
var fd = new FormData();
|
||
fd.append('file', file);
|
||
var xhr = new XMLHttpRequest();
|
||
xhr.open('POST', '/api/video_plan/upload_video');
|
||
var h = _csrfHeaders ? _csrfHeaders() : {};
|
||
Object.keys(h).forEach(function (k) { xhr.setRequestHeader(k, h[k]); });
|
||
xhr.upload.onprogress = function (e) {
|
||
if (e.lengthComputable && onProgress) onProgress(Math.round(e.loaded * 100 / e.total));
|
||
};
|
||
xhr.onload = function () {
|
||
var data = null;
|
||
try { data = JSON.parse(xhr.responseText); } catch (e) { data = null; }
|
||
if (!data) data = { ok: false, error: '响应解析失败(HTTP ' + xhr.status + ')' };
|
||
if (xhr.status === 401) { window.location = '/login'; return; }
|
||
done(data);
|
||
};
|
||
xhr.onerror = function () { done({ ok: false, error: '网络错误' }); };
|
||
xhr.send(fd);
|
||
}
|
||
|
||
// ================== 上传:标题 ==================
|
||
function rpPickTitles(input) {
|
||
var f = (input.files || [])[0];
|
||
input.value = '';
|
||
if (!f) return;
|
||
var rd = new FileReader();
|
||
rd.onload = function () { rpShowTitleConfirm(f.name, String(rd.result || '')); };
|
||
rd.readAsText(f, 'utf-8');
|
||
}
|
||
|
||
function rpShowTitleConfirm(fname, text) {
|
||
var lines = text.split(/\r?\n/).filter(function (s) { return s.trim() && !s.trim().startsWith('#'); });
|
||
document.getElementById('modal-title').textContent = '上传标题(' + fname + ')';
|
||
document.getElementById('modal-box').classList.add('wide-modal');
|
||
document.getElementById('modal-body').innerHTML =
|
||
'<div class="help" style="margin-bottom:6px">共 ' + lines.length + ' 行有效标题。'
|
||
+ '每行 <code>标题内容_手机号_日期_编号</code>;没写编号的按顺序配到该账号当天还没标题的视频上。</div>'
|
||
+ '<div style="max-height:50vh;overflow:auto;font-family:var(--mono);font-size:12px;white-space:pre-wrap">'
|
||
+ esc(lines.slice(0, 200).join('\n')) + (lines.length > 200 ? '\n…' : '') + '</div>'
|
||
+ '<div id="rp-title-result"></div>';
|
||
document.getElementById('modal-footer').innerHTML =
|
||
'<button class="btn" onclick="closeModal()">取消</button>'
|
||
+ '<button class="btn" onclick="rpDoTitles(true)">先预览</button>'
|
||
+ '<button class="btn btn-primary" onclick="rpDoTitles(false)">导入</button>';
|
||
window._rpTitleText = text;
|
||
showModal();
|
||
}
|
||
|
||
function rpDoTitles(dry) {
|
||
var box = document.getElementById('rp-title-result');
|
||
if (box) box.innerHTML = '<div class="text-muted">' + (dry ? '预览中…' : '导入中…') + '</div>';
|
||
apiPost('/api/video_plan/upload_titles', { text: window._rpTitleText || '', dry_run: !!dry })
|
||
.then(function (r) {
|
||
if (!r || !r.ok) {
|
||
if (box) box.innerHTML = '<div style="color:#ef4444">失败:' + esc((r && r.error) || '') + '</div>';
|
||
return;
|
||
}
|
||
var c = r.counts || {};
|
||
if (box) {
|
||
box.innerHTML = '<div style="margin:8px 0"><b>' + esc(r.msg || '') + '</b>'
|
||
+ (dry ? ' <span class="text-muted">(预览,还没写入)</span>' : '') + '</div>'
|
||
+ '<table class="table table-sm"><thead><tr><th>行</th><th>标题</th><th>账号/日期/编号</th><th>结果</th></tr></thead><tbody>'
|
||
+ (r.rows || []).map(function (x) {
|
||
return '<tr><td>' + x.line + '</td><td>' + esc((x.title || '').slice(0, 24)) + '</td>'
|
||
+ '<td>' + esc(x.phone) + ' / ' + esc(x.date) + ' / ' + (x.seq || '-') + '</td>'
|
||
+ '<td>' + (x.action === 'attach' ? '<span class="label label-success">已配</span>'
|
||
: x.action === 'skip' ? '<span class="label label-default">跳过</span>'
|
||
: '<span class="label label-danger">拒收</span>') + ' ' + esc(x.error || '') + '</td></tr>';
|
||
}).join('') + '</tbody></table>';
|
||
}
|
||
if (!dry) showToast(r.msg || '标题已导入', 'success');
|
||
});
|
||
}
|
||
|
||
// ================== 单条操作 ==================
|
||
function rpPushToDevice(id) {
|
||
if (!confirm('把这条的素材推到手机?\n\n只推送、不发布 —— 推完状态变「已推送到手机」,'
|
||
+ '标题会同时写进手机剪贴板;之后由你(或你写的任务步骤)在手机上发布,发完点「标记已发布」。')) return;
|
||
apiPost('/api/video_plan/' + id + '/push', {}).then(function (r) {
|
||
if (r && r.ok) { showToast(r.msg || '已开始推送', 'success'); setTimeout(loadReleasePlan, 1500); }
|
||
else showToast((r && r.error) || '失败', 'error');
|
||
});
|
||
}
|
||
|
||
// 一键推送:把今天「待发布/失败」的计划全部推到各自手机上(每台设备串行,设备间并行)
|
||
function rpPushAll() {
|
||
var n = (_rpRows || []).filter(function (p) {
|
||
return p.release_date === _rpToday && (p.status === 'ready' || p.status === 'failed');
|
||
}).length;
|
||
if (!confirm('把今天待推送的计划全部推到手机上?\n\n'
|
||
+ '现在视图里有 ' + n + ' 条「待发布/失败」的;平台只推今天这一天的(有标题的才推)。\n'
|
||
+ '每台手机串行推、每台之间并行;推完状态会变成「已推送·相册可见 / 已推送·相册未见」。')) return;
|
||
apiPost('/api/video_plan/push_all', {}).then(function (r) {
|
||
if (r && r.ok) { showToast(r.msg || '已开始推送', 'success'); setTimeout(loadReleasePlan, 2000); }
|
||
else showToast((r && r.error) || '失败', 'error');
|
||
});
|
||
}
|
||
|
||
function rpMark(id, status) {
|
||
var tip = status === 'done'
|
||
? '确认这条已经发出去了?(会记为已发布;如果抓得到分享链接会一起存下来)'
|
||
: '确认这条没发出去?(会记为失败、可以重推)';
|
||
var why = '';
|
||
if (status !== 'done') {
|
||
why = prompt(tip, '') ;
|
||
if (why === null) return;
|
||
} else if (!confirm(tip)) return;
|
||
apiPost('/api/video_plan/' + id + '/mark', { status: status, why: why }).then(function (r) {
|
||
if (r && r.ok) { showToast(r.msg || '已标记', 'success'); loadReleasePlan(); }
|
||
else showToast((r && r.error) || '失败', 'error');
|
||
});
|
||
}
|
||
|
||
function rpSkip(id) {
|
||
var reason = prompt('跳过这条(不发)?可以写个原因:', '');
|
||
if (reason === null) return;
|
||
apiPost('/api/video_plan/' + id + '/skip', { reason: reason }).then(function (r) {
|
||
if (r && r.ok) { showToast(r.msg, 'success'); loadReleasePlan(); } else showToast((r && r.error) || '失败', 'error');
|
||
});
|
||
}
|
||
|
||
function rpRetry(id) {
|
||
apiPost('/api/video_plan/' + id + '/retry', {}).then(function (r) {
|
||
if (r && r.ok) { showToast(r.msg, 'success'); loadReleasePlan(); } else showToast((r && r.error) || '失败', 'error');
|
||
});
|
||
}
|
||
|
||
function rpResolve(id, published) {
|
||
var tip = published ? '确认这条已经发出去了?(会记为已发布)' : '确认没发出去?(会放回可重试)';
|
||
if (!confirm(tip)) return;
|
||
apiPost('/api/video_plan/' + id + '/resolve', { published: !!published }).then(function (r) {
|
||
if (r && r.ok) { showToast(r.msg, 'success'); loadReleasePlan(); } else showToast((r && r.error) || '失败', 'error');
|
||
});
|
||
}
|
||
|
||
function rpDelete(id) {
|
||
if (!confirm('删掉这条计划?平台上的素材文件也会一起删掉。')) return;
|
||
apiDelete('/api/video_plan/' + id).then(function (r) {
|
||
if (r && r.ok) { showToast(r.msg, 'success'); loadReleasePlan(); } else showToast((r && r.error) || '失败', 'error');
|
||
});
|
||
}
|
||
|
||
function rpEditTitle(id) {
|
||
var p = null;
|
||
for (var i = 0; i < _rpRows.length; i++) if (_rpRows[i].id === id) p = _rpRows[i];
|
||
var cur = p ? (p.title || '') : '';
|
||
var t = prompt('标题(发布文案):', cur);
|
||
if (t === null) return;
|
||
apiPut('/api/video_plan/' + id, { title: t }).then(function (r) {
|
||
if (r && r.ok) { showToast(r.msg, 'success'); loadReleasePlan(); } else showToast((r && r.error) || '失败', 'error');
|
||
});
|
||
}
|
||
|
||
function rpCopyLink(url) {
|
||
if (!url) return;
|
||
if (navigator.clipboard) {
|
||
navigator.clipboard.writeText(url).then(function () { showToast('链接已复制', 'success'); },
|
||
function () { prompt('复制这个链接:', url); });
|
||
} else {
|
||
prompt('复制这个链接:', url);
|
||
}
|
||
}
|
||
|
||
function rpExportLinks() {
|
||
var from = (document.getElementById('rp-range') || {}).value === 'today' ? _rpToday : '';
|
||
window.location = '/api/video_plan/links?format=csv' + (from ? ('&from=' + from) : '');
|
||
}
|
||
|
||
// ================== 发布任务(到点自动推送 + 发视频) ==================
|
||
function loadReleaseTasks() {
|
||
var box = document.getElementById('rp-tasks');
|
||
if (!box) return;
|
||
apiGet('/api/video_plan/tasks').then(function (r) {
|
||
if (!r || !r.ok) { box.innerHTML = '<span class="text-muted">读取失败:' + esc((r && r.error) || '') + '</span>'; return; }
|
||
var ts = r.tasks || [];
|
||
window._rpTasks = ts; // 就地编辑要按 id 取回完整步骤
|
||
var rel = ts.filter(function (t) { return t.is_release; });
|
||
var other = ts.filter(function (t) { return !t.is_release; });
|
||
var html = '';
|
||
html += '<div class="text-muted" style="font-size:11px">发布任务(含「推送发布视频」/「标记发布结果」,这一页的视频就由它们推给手机)</div>';
|
||
html += rel.length
|
||
? rel.map(_rpTaskRow).join('')
|
||
: '<div class="text-muted" style="padding:2px 0">还没有发布任务 —— 点右边「一键新建发布任务」建一个骨架;'
|
||
+ '或者把下面「其它任务」里那个抖音发布任务<b>插上平台步骤</b>(推送放最前、标记放最后,你自己的步骤不动)</div>';
|
||
if (other.length) {
|
||
html += '<div style="margin-top:6px"><a href="javascript:;" onclick="rpToggleOther()" class="text-muted">'
|
||
+ (_rpShowOther ? '▾ 收起' : '▸ 展开') + '其它任务(' + other.length + ' 个,也能在这里改步骤)</a></div>';
|
||
if (_rpShowOther) {
|
||
html += '<div style="margin-top:2px">' + other.map(_rpTaskRow).join('') + '</div>';
|
||
}
|
||
}
|
||
box.innerHTML = html;
|
||
});
|
||
}
|
||
|
||
var _rpShowOther = false;
|
||
|
||
function rpToggleOther() {
|
||
_rpShowOther = !_rpShowOther;
|
||
loadReleaseTasks();
|
||
}
|
||
|
||
function _rpTaskRow(t) {
|
||
var sch = t.mode === 'cron' ? ('每天 ' + esc(_cronTime(t.cron))) : '手动';
|
||
var empty = _rpEmptySelCount(t.steps || []);
|
||
var miss = t.is_release && !t.has_push;
|
||
return '<div style="display:flex;justify-content:space-between;gap:8px;flex-wrap:wrap;padding:4px 0;border-top:1px dashed var(--border)">'
|
||
+ '<div><b>' + esc(t.name) + '</b> <span class="label ' + (t.enabled ? 'label-success' : 'label-default') + '">'
|
||
+ (t.enabled ? '启用' : '停用') + '</span>'
|
||
+ ' <span class="text-muted">' + sch + (t.next_run ? ' · 下次 ' + esc(t.next_run) : '')
|
||
+ ' · ' + t.step_count + ' 步 · ' + esc(_rpTargetText(t.target)) + '</span>'
|
||
+ (empty ? ' <span class="label label-warning">' + empty + ' 个选择器待抓</span>' : '')
|
||
+ (t.is_release && !t.has_push
|
||
? ' <span class="label label-warning" title="没有「推送发布视频」步骤:计划页的视频不会被推到手机">缺平台推送步骤</span>' : '')
|
||
+ '</div>'
|
||
+ '<div style="white-space:nowrap">'
|
||
+ '<button class="btn btn-xs btn-primary" onclick="rpEditTask(\'' + t.id + '\')" data-perm="tasks">编辑</button> '
|
||
+ (t.is_release && !t.has_push
|
||
? '<button class="btn btn-xs" onclick="rpAdoptTask(\'' + t.id + '\')" data-perm="tasks" '
|
||
+ 'title="在最前面加「推送发布视频」、最后面加「标记发布结果」,你原来的步骤一步不动">插上平台步骤</button> '
|
||
: '')
|
||
+ '<button class="btn btn-xs" onclick="rpToggleTask(\'' + t.id + '\',' + (t.enabled ? 'false' : 'true') + ')" data-perm="tasks">'
|
||
+ (t.enabled ? '停用' : '启用') + '</button></div></div>';
|
||
}
|
||
|
||
function rpAdoptTask(jobId) {
|
||
var t = (window._rpTasks || []).filter(function (x) { return x.id === jobId; })[0] || {};
|
||
if (!confirm('给「' + (t.name || jobId) + '」插上平台发布步骤?\n\n'
|
||
+ '· 最前面加一步「推送发布视频」:按发布日期把计划里的视频推到手机相册,标题写进剪贴板\n'
|
||
+ '· 最后面加一步「标记发布结果」:把发出去了没有回写到计划(成功时顺手抓分享链接)\n'
|
||
+ '· 你原来的步骤一步不动')) return;
|
||
apiPost('/api/video_plan/tasks/' + jobId + '/adopt', {}).then(function (r) {
|
||
if (r && r.ok) { showToast(r.msg || '已插入', 'success'); loadReleaseTasks(); }
|
||
else showToast((r && r.error) || '失败', 'error');
|
||
});
|
||
}
|
||
|
||
function _rpTargetText(t) {
|
||
t = t || {};
|
||
if (t.mode === 'group') return '分组 ' + (t.group_name || '?');
|
||
if (t.mode === 'serial') return '单台 ' + (t.serial || '?');
|
||
return '全部设备';
|
||
}
|
||
|
||
// 「几个空选择器待抓」:只数点击/等待类步骤(含条件判断 then/else 里的嵌套步骤)——
|
||
// 这是"任务还不能跑"的唯一信号,写清楚比让人自己去翻步骤强
|
||
function _rpEmptySelCount(steps) {
|
||
var n = 0;
|
||
(steps || []).forEach(function (s) {
|
||
var p = s.params || {};
|
||
if ((s.type === 'click' || s.type === 'wait_el') && !String(p.selector_value || '').trim()) n++;
|
||
if (s.type === 'if_el' && !String(p.selector_value || '').trim()) n++;
|
||
n += _rpEmptySelCount(p.then || []) + _rpEmptySelCount(p.else || []) + _rpEmptySelCount(p.children || []);
|
||
});
|
||
return n;
|
||
}
|
||
|
||
function _cronTime(cron) {
|
||
var m = /^(\S+)\s+(\S+)\s/.exec(cron || '');
|
||
return m ? (String(m[2]).padStart(2, '0') + ':' + String(m[1]).padStart(2, '0')) : (cron || '');
|
||
}
|
||
|
||
function rpToggleTask(jobId, enabled) {
|
||
apiPost('/api/jobs/' + jobId + '/toggle', { enabled: !!enabled }).then(function (r) {
|
||
if (r && r.ok) { showToast(r.msg || '已切换', 'success'); loadReleaseTasks(); }
|
||
else showToast((r && r.error) || '失败', 'error');
|
||
});
|
||
}
|
||
|
||
function rpNewTask() {
|
||
Promise.all([
|
||
apiGet('/api/groups').then(function (r) { return (r && r.groups) || []; }),
|
||
apiGet('/api/devices').then(function (r) { return (r && r.items) || []; })
|
||
]).then(function (res) {
|
||
var groups = res[0] || [], devs = res[1] || [];
|
||
document.getElementById('modal-title').textContent = '新建发布任务';
|
||
document.getElementById('modal-body').innerHTML =
|
||
'<div class="help">会建好骨架:<b>校验账号(抖音号对得上才发)→ 推送发布视频 → 抖音点击 → '
|
||
+ '填标题(自动取这条计划的标题)→ 标记发布结果</b>;'
|
||
+ '抖音那几个点击步骤是空选择器的占位(标签写着该抓哪个),'
|
||
+ '建好后<b>就在「发布计划」这一块点「编辑」抓选择器</b>,不用跳任务页。</div>'
|
||
+ '<div class="form-row"><div class="form-group"><label>任务名</label>'
|
||
+ '<input class="form-control" id="rp-new-name" value="发布视频"></div>'
|
||
+ '<div class="form-group"><label>每天几点跑</label>'
|
||
+ '<input class="form-control" id="rp-new-time" value="10:00" placeholder="HH:MM"></div></div>'
|
||
+ '<div class="form-row"><div class="form-group"><label>发给谁</label>'
|
||
+ '<select class="form-control" id="rp-new-mode" onchange="rpNewTargetChanged()">'
|
||
+ '<option value="all">全部设备</option><option value="group">某个设备分组</option>'
|
||
+ '<option value="serial">单台设备</option></select></div>'
|
||
+ '<div class="form-group"><label>具体</label><select class="form-control" id="rp-new-which"></select></div></div>';
|
||
window._rpGroups = groups; window._rpDevs = devs;
|
||
document.getElementById('modal-footer').innerHTML =
|
||
'<button class="btn" onclick="closeModal()">取消</button>'
|
||
+ '<button class="btn btn-primary" onclick="rpDoNewTask()">创建</button>';
|
||
rpNewTargetChanged();
|
||
showModal();
|
||
});
|
||
}
|
||
|
||
function rpNewTargetChanged() {
|
||
var mode = (document.getElementById('rp-new-mode') || {}).value;
|
||
var sel = document.getElementById('rp-new-which');
|
||
if (!sel) return;
|
||
var html = '';
|
||
if (mode === 'group') {
|
||
html = (window._rpGroups || []).map(function (g) { return '<option value="' + esc(g.name) + '">' + esc(g.name) + '</option>'; }).join('');
|
||
} else if (mode === 'serial') {
|
||
html = (window._rpDevs || []).map(function (d) {
|
||
return '<option value="' + esc(d.serial) + '">' + esc(d.name || d.serial) + '</option>';
|
||
}).join('');
|
||
} else {
|
||
html = '<option value="">(全部在线设备)</option>';
|
||
}
|
||
sel.innerHTML = html || '<option value="">(没有可选项)</option>';
|
||
}
|
||
|
||
// ============ 发布任务的**就地编辑**(就在「账号 → 发布计划」这一页,不跳任务页)============
|
||
// 为什么单独做一套:发布任务的步骤是固定的那十几步,用通用编辑器(拖拽画布)编它要来回跳页,
|
||
// 而这一页的人真正要干的只有一件事 —— **把抖音那几个空选择器抓一遍**。
|
||
// 所以这里给一组紧凑表单:改名字/目标/时间/启停 + 逐步改参数(选择器带「抓取元素」);
|
||
// 要更复杂的编排(加步骤、循环、自定义动作)走底部的「打开通用编辑器」。
|
||
//
|
||
// 保存口径:整块 steps 交给 `PUT /api/video_plan/tasks/<id>`,后端深合并进 params
|
||
// (不能整块覆盖 params —— 去重有效期、抢占那些通用参数会被抹掉)。
|
||
var _rpEdit = null; // 正在编辑的任务(steps 是深拷贝,取消就丢弃)
|
||
var _RP_SEL_TYPES = ['xpath', 'description', 'text', 'resourceId', 'descriptionContains', 'className'];
|
||
|
||
// 每种步骤给哪几个字段(其它参数原样保留,不动它们)
|
||
var _RP_FIELDS = {
|
||
open_app: [['package', '包名', 'text']],
|
||
click: [['selector_type', '选择器类型', 'seltype'], ['selector_value', '选择器值', 'selval'],
|
||
['wait_timeout', '找不到时的等待(秒)', 'num']],
|
||
wait_el: [['selector_type', '选择器类型', 'seltype'], ['selector_value', '选择器值', 'selval'],
|
||
['timeout', '超时(秒)', 'num']],
|
||
wait: [['min', '最短(秒)', 'num'], ['max', '最长(秒)', 'num']],
|
||
input_text: [['text_source', '文案从哪来', 'sel', [['manual', '手填(下面那栏)'],
|
||
['release_title', '这条发布计划的标题'],
|
||
['release_title_topics', '计划的标题 + 话题']]],
|
||
['release_topics', '话题(逗号分隔)', 'text'],
|
||
['fixed_text', '手填的文案(上面的来源选手填时才用)', 'area']],
|
||
push_release: [['date_source', '发哪天的计划', 'sel', [['today', '今天'], ['fixed', '指定日期']]],
|
||
['fixed_date', '指定日期(YYYY-MM-DD)', 'text'],
|
||
['album_dir', '推到手机哪个目录', 'text'],
|
||
['to_clipboard', '标题写进手机剪贴板', 'chk'],
|
||
['max_same_run', '一次推几条', 'num']],
|
||
mark_release: [['result', '标记为什么结果', 'sel', [['published', '已发布(成功)'],
|
||
['failed', '确认没发出去(可重试)'],
|
||
['unknown', '拿不准(绝不自动重发)']]],
|
||
['capture_link', '抓作品分享链接', 'chk'],
|
||
['delete_phone', '删掉手机上的素材', 'chk']],
|
||
notify: [['title', '通知标题', 'text'], ['message', '通知内容', 'text'],
|
||
['level', '级别', 'sel', [['info', 'info'], ['warning', 'warning'], ['error', 'error']]]],
|
||
if_el: [['selector_type', '条件类型', 'seltype'],
|
||
['selector_value', '选择器值 / 屏幕状态', 'selval'],
|
||
['timeout', '超时(秒)', 'num'],
|
||
['cmp_op', '文本比对', 'sel', [['', '不比对(只看元素在不在)'], ['等于', '等于'],
|
||
['不等于', '不等于'], ['包含', '包含'], ['不包含', '不包含']]],
|
||
['cmp_source', '候选值来源', 'sel', [['', '手填'], ['device', '本机台账'],
|
||
['all', '全部台账'], ['group', '按设备分组'],
|
||
['release', '本机当前发布计划(要发的那个号)']]],
|
||
['cmp_group', '分组名(来源选"按设备分组"时填)', 'text'],
|
||
['cmp_value', '比对的值(一行一个,任一命中即可)', 'area']],
|
||
mark_done: [['selector_type', '选择器类型', 'seltype'], ['selector_value', '选择器值', 'selval']],
|
||
clipboard: [['text', '要放进剪贴板的文字', 'text']],
|
||
// 下面这些骨架里没有,但你可能从通用编辑器加进来 —— 给了字段的就能就地改,
|
||
// 没给字段的(如 swipe/gesture 的拟人参数)原样保留、要改走通用编辑器
|
||
loop: [['loop_mode', '循环方式', 'sel', [['rounds', '固定轮次'], ['duration', '固定时长']]],
|
||
['max_iterations', '循环几轮', 'num'], ['loop_duration', '循环多少秒', 'num']],
|
||
key_event: [['key', '按键', 'sel', [['back', '返回'], ['home', '桌面'],
|
||
['recent', '最近任务'], ['volume_up', '音量+'], ['volume_down', '音量-']]]],
|
||
click_xy: [['x', 'x(%)', 'num'], ['y', 'y(%)', 'num']],
|
||
stop_app: [['package', '包名', 'text']],
|
||
stop_self: [['reason', '停止原因(写进日志/通知)', 'text']],
|
||
long_click: [['selector_type', '选择器类型', 'seltype'], ['selector_value', '选择器值', 'selval'],
|
||
['duration', '按住(秒)', 'num']]
|
||
};
|
||
|
||
function rpEditTask(jobId) {
|
||
var t = (window._rpTasks || []).filter(function (x) { return x.id === jobId; })[0];
|
||
if (!t) { showToast('任务没了,刷新一下', 'error'); return; }
|
||
Promise.all([
|
||
apiGet('/api/groups').then(function (r) { return (r && r.groups) || []; }),
|
||
apiGet('/api/devices').then(function (r) { return (r && r.items) || []; })
|
||
]).then(function (res) {
|
||
window._rpGroups = res[0] || []; window._rpDevs = res[1] || [];
|
||
_rpEdit = { id: t.id, name: t.name, enabled: !!t.enabled, time: _cronTime(t.cron),
|
||
target: { mode: (t.target || {}).mode || 'all',
|
||
group_name: (t.target || {}).group_name || '',
|
||
serial: (t.target || {}).serial || '' },
|
||
steps: JSON.parse(JSON.stringify(t.steps || [])) };
|
||
document.getElementById('modal-title').textContent = '编辑发布任务';
|
||
document.getElementById('modal-box').classList.add('wide-modal');
|
||
document.getElementById('modal-body').innerHTML = _rpEditorHtml();
|
||
document.getElementById('modal-footer').innerHTML =
|
||
'<button class="btn" onclick="closeModal()">取消</button> '
|
||
+ '<button class="btn" onclick="rpOpenFullEditor(\'' + t.id + '\')" '
|
||
+ 'title="加步骤、循环、自定义动作等,去任务页的完整编辑器">打开通用编辑器</button> '
|
||
+ '<button class="btn btn-primary" onclick="rpSaveTask()" data-perm="tasks">保存</button>';
|
||
rpEditTargetChanged(); // 把「具体」下拉按当前目标填上(不填会是空的)
|
||
showModal();
|
||
});
|
||
}
|
||
|
||
function _rpEditorHtml() {
|
||
var e = _rpEdit;
|
||
var tgt = e.target;
|
||
var which = tgt.mode === 'group' ? tgt.group_name : (tgt.mode === 'serial' ? tgt.serial : '');
|
||
return '<div class="form-row">'
|
||
+ '<div class="form-group"><label>任务名</label>'
|
||
+ '<input class="form-control" id="rp-ed-name" value="' + esc(e.name) + '"></div>'
|
||
+ '<div class="form-group"><label>每天几点跑</label>'
|
||
+ '<input class="form-control" id="rp-ed-time" value="' + esc(e.time) + '" placeholder="HH:MM"></div></div>'
|
||
+ '<div class="form-row"><div class="form-group"><label>发给谁</label>'
|
||
+ '<select class="form-control" id="rp-ed-mode" onchange="rpEditTargetChanged()">'
|
||
+ ['all', 'group', 'serial'].map(function (m) {
|
||
var txt = { all: '全部设备', group: '某个设备分组', serial: '单台设备' }[m];
|
||
return '<option value="' + m + '"' + (tgt.mode === m ? ' selected' : '') + '>' + txt + '</option>';
|
||
}).join('') + '</select></div>'
|
||
+ '<div class="form-group"><label>具体</label>'
|
||
+ '<select class="form-control" id="rp-ed-which"></select></div>'
|
||
+ '<div class="form-group form-check" style="align-self:flex-end">'
|
||
+ '<input type="checkbox" id="rp-ed-enabled"' + (e.enabled ? ' checked' : '') + '><label for="rp-ed-enabled">启用</label></div></div>'
|
||
+ '<div class="help">下面每一步都能就地改。<b>空选择器的点击步骤</b>就是还没抓过的那几个 ——'
|
||
+ '点「抓取元素」选设备后照着点一下,会自动把选择器填进来。</div>'
|
||
+ '<div id="rp-steps" style="max-height:52vh;overflow:auto">' + _rpStepRows(e.steps, '') + '</div>';
|
||
}
|
||
|
||
function rpEditTargetChanged() {
|
||
var mode = (document.getElementById('rp-ed-mode') || {}).value || 'all';
|
||
var sel = document.getElementById('rp-ed-which');
|
||
if (!sel) return;
|
||
var cur = _rpEdit ? (_rpEdit.target.mode === mode
|
||
? (_rpEdit.target.group_name || _rpEdit.target.serial || '') : '') : '';
|
||
var html = '';
|
||
if (mode === 'group') {
|
||
html = (window._rpGroups || []).map(function (g) {
|
||
return '<option value="' + esc(g.name) + '"' + (g.name === cur ? ' selected' : '') + '>'
|
||
+ esc(g.name) + '</option>';
|
||
}).join('');
|
||
} else if (mode === 'serial') {
|
||
html = (window._rpDevs || []).map(function (d) {
|
||
return '<option value="' + esc(d.serial) + '"' + (d.serial === cur ? ' selected' : '') + '>'
|
||
+ esc(d.name || d.serial) + '</option>';
|
||
}).join('');
|
||
} else {
|
||
html = '<option value="">(全部设备)</option>';
|
||
}
|
||
sel.innerHTML = html || '<option value="">(没有可选项)</option>';
|
||
}
|
||
|
||
// 步骤列表 → HTML。`path` 是"第几步的第几个分支的第几步"(如 3/then/1),
|
||
// 编辑完按同样的路径写回(见 _rpCollect)
|
||
function _rpStepRows(steps, path) {
|
||
if (!steps || !steps.length) {
|
||
return '<div class="text-muted" style="padding:6px">(这里没有步骤)</div>';
|
||
}
|
||
return steps.map(function (s, i) {
|
||
var p = path ? (path + '/' + i) : String(i);
|
||
return _rpStepRow(s, p, steps.length, i);
|
||
}).join('');
|
||
}
|
||
|
||
function _rpStepRow(s, path, total, idx) {
|
||
var p = s.params || {};
|
||
var fields = _RP_FIELDS[s.type] || [];
|
||
var body = fields.map(function (f) { return _rpField(s.type, p, f, path); }).join('');
|
||
// 条件判断:命中/未命中两个分支**照样能就地编辑**(发布流程就藏在 then 里)
|
||
var nested = '';
|
||
if (s.type === 'if_el' || s.type === 'loop' || s.type === 'group') {
|
||
[['then', '命中时(then)'], ['else', '没命中时(else)'], ['children', '里面的步骤']].forEach(function (pair) {
|
||
var k = pair[0];
|
||
if (!(p[k] instanceof Array)) return;
|
||
nested += '<div style="margin:4px 0 0 10px;border-left:3px solid #e0dcce;padding-left:8px">'
|
||
+ '<div class="text-muted" style="font-size:11px;padding:2px 0">' + pair[1]
|
||
+ ' <span style="opacity:.7">(' + p[k].length + ' 步)</span></div>'
|
||
+ _rpStepRows(p[k], path + '/' + k) + '</div>';
|
||
});
|
||
}
|
||
var lim = [];
|
||
if (total > 1 && idx > 0) lim.push('<button class="btn btn-xs" onclick="rpStepMove(\'' + path + '\',-1)" title="上移">↑</button>');
|
||
if (total > 1 && idx < total - 1) lim.push('<button class="btn btn-xs" onclick="rpStepMove(\'' + path + '\',1)" title="下移">↓</button>');
|
||
lim.push('<button class="btn btn-xs" onclick="rpStepDel(\'' + path + '\')" title="删掉这一步">✕</button>');
|
||
var empty = ((s.type === 'click' || s.type === 'wait_el') && !String(p.selector_value || '').trim());
|
||
return '<div class="rp-step" style="border:1px solid var(--border);border-radius:6px;margin-bottom:6px;'
|
||
+ (empty ? 'border-color:#e8b04b' : '') + '">'
|
||
+ '<div style="display:flex;justify-content:space-between;gap:6px;align-items:center;'
|
||
+ 'padding:4px 6px;background:#faf9f4;border-radius:5px 5px 0 0">'
|
||
+ '<div style="min-width:0"><b>' + esc(s.label || s.type) + '</b> '
|
||
+ '<span class="text-muted" style="font-size:11px">' + esc(s.type) + '</span>'
|
||
+ (empty ? ' <span class="label label-warning">待抓选择器</span>' : '') + '</div>'
|
||
+ '<div style="white-space:nowrap">' + lim.join(' ') + '</div></div>'
|
||
+ '<div style="padding:6px;display:grid;grid-template-columns:1fr 1fr;gap:6px">'
|
||
+ '<div class="form-group" style="grid-column:span 2;margin-bottom:0"><label style="font-size:11px">步骤名(只影响显示)</label>'
|
||
+ '<input class="form-control" data-path="' + path + '" data-key="label" value="' + esc(s.label || '') + '"></div>'
|
||
+ body + '</div>' + nested + '</div>';
|
||
}
|
||
|
||
function _rpField(type, p, f, path) {
|
||
var key = f[0], label = f[1], kind = f[2], opts = f[3];
|
||
var v = p[key];
|
||
var head = '<label style="font-size:11px">' + esc(label) + '</label>';
|
||
var attrs = 'data-path="' + path + '" data-key="' + key + '"';
|
||
var span = (kind === 'area' || kind === 'selval') ? ' style="grid-column:span 2"' : '';
|
||
if (kind === 'chk') {
|
||
return '<div class="form-group form-check" style="margin-bottom:0"><input type="checkbox" ' + attrs
|
||
+ (v !== false ? ' checked' : '') + '><label>' + esc(label) + '</label></div>';
|
||
}
|
||
if (kind === 'sel') {
|
||
return '<div class="form-group"' + span + ' style="margin-bottom:0">' + head
|
||
+ '<select class="form-control" ' + attrs + '>'
|
||
+ opts.map(function (o) {
|
||
return '<option value="' + esc(o[0]) + '"' + (String(v == null ? '' : v) === o[0] ? ' selected' : '')
|
||
+ '>' + esc(o[1]) + '</option>';
|
||
}).join('') + '</select></div>';
|
||
}
|
||
if (kind === 'seltype') {
|
||
var types = _RP_SEL_TYPES.slice();
|
||
if (type === 'if_el') types = types.concat(['ocr', 'screen', 'foreground']);
|
||
return '<div class="form-group" style="margin-bottom:0">' + head
|
||
+ '<select class="form-control" ' + attrs + '>'
|
||
+ types.map(function (t) {
|
||
return '<option value="' + t + '"' + (v === t ? ' selected' : '') + '>' + t + '</option>';
|
||
}).join('') + '</select></div>';
|
||
}
|
||
if (kind === 'num') {
|
||
return '<div class="form-group" style="margin-bottom:0">' + head
|
||
+ '<input type="number" step="0.1" class="form-control" ' + attrs + ' value="'
|
||
+ esc(v == null ? '' : v) + '"></div>';
|
||
}
|
||
if (kind === 'area') {
|
||
return '<div class="form-group" style="grid-column:span 2;margin-bottom:0">' + head
|
||
+ '<textarea class="form-control" rows="2" ' + attrs + '>' + esc(v == null ? '' : v) + '</textarea></div>';
|
||
}
|
||
// text / selval(选择器值):选择器值旁边给「抓取元素」——
|
||
// openPicker 取的是这个按钮的 previousElementSibling,所以按钮必须紧跟在 input 后面
|
||
// (kind 区分别改:'sel' 是"固定选项的下拉",'selval' 才是"选择器值+抓取按钮")
|
||
var pick = (kind === 'selval' && typeof _can === 'function' && _can('devices'))
|
||
? '<button class="btn btn-xs btn-pick" style="margin-left:4px" onclick="_stepEditor.openPicker(this)">抓取元素</button>'
|
||
: '';
|
||
return '<div class="form-group"' + span + ' style="margin-bottom:0">' + head
|
||
+ '<div style="display:flex"><input type="text" class="form-control" ' + attrs + ' value="'
|
||
+ esc(v == null ? '' : v) + '">' + pick + '</div></div>';
|
||
}
|
||
|
||
// 抓取元素回填后:把选择器类型下拉也切过去(通用编辑器那套要看 .sc-body,这里结构不同)
|
||
function _rpOnFill(el, input) {
|
||
var host = (input && input.closest) ? input.closest('.rp-step') : null;
|
||
if (!host || !document.getElementById('rp-steps')) {
|
||
// 不是我的编辑器在用 → 走原路(同步回通用编辑器的 _steps),别把人家的回填吞了
|
||
if (typeof _stepEditor._syncFromDom === 'function') _stepEditor._syncFromDom();
|
||
return;
|
||
}
|
||
var sel = host.querySelector('[data-key="selector_type"]');
|
||
if (sel && el && el.suggested) sel.value = el.suggested.type;
|
||
}
|
||
if (typeof _stepEditor !== 'undefined') _stepEditor._onFill = _rpOnFill;
|
||
|
||
// 路径走一段:数字 = 数组下标;then/else/children = **步骤的 params 里**那个分支数组
|
||
// (分支挂在 params 下,不是挂在步骤对象上 —— 走错这一层会把 then 当数组下标用,静默取到 undefined)
|
||
function _rpWalk(cur, tok) {
|
||
if (!cur) return undefined; // 路径走岔了(界面重画过)→ 返回空,别抛异常
|
||
if (/^\d+$/.test(tok)) return cur[+tok];
|
||
if (cur && cur.params && (tok === 'then' || tok === 'else' || tok === 'children')) {
|
||
return cur.params[tok];
|
||
}
|
||
return cur ? cur[tok] : undefined;
|
||
}
|
||
|
||
function _rpByPath(path) {
|
||
var cur = _rpEdit.steps;
|
||
path.split('/').forEach(function (t) { cur = _rpWalk(cur, t); });
|
||
return cur;
|
||
}
|
||
|
||
function _rpParent(path) {
|
||
var parts = path.split('/');
|
||
parts.pop();
|
||
var cur = _rpEdit.steps;
|
||
parts.forEach(function (t) { cur = _rpWalk(cur, t); });
|
||
return cur;
|
||
}
|
||
|
||
function rpStepMove(path, dir) {
|
||
_rpCollect();
|
||
var arr = _rpParent(path);
|
||
var i = arr.indexOf(_rpByPath(path));
|
||
var j = i + dir;
|
||
if (i < 0 || j < 0 || j >= arr.length) return;
|
||
var tmp = arr[i]; arr[i] = arr[j]; arr[j] = tmp;
|
||
_rpRedraw();
|
||
}
|
||
|
||
function rpStepDel(path) {
|
||
_rpCollect();
|
||
var arr = _rpParent(path);
|
||
var i = arr.indexOf(_rpByPath(path));
|
||
if (i < 0 || !arr[i]) return;
|
||
if (!confirm('删掉这一步「' + (arr[i].label || arr[i].type) + '」?')) return;
|
||
arr.splice(i, 1);
|
||
_rpRedraw();
|
||
}
|
||
|
||
// 界面上的改动 → 收进 _rpEdit.steps(重画 / 保存前先调它,不然刚填的值会被重画吞掉)
|
||
function _rpCollect() {
|
||
var box = document.getElementById('rp-steps');
|
||
if (!box) return;
|
||
box.querySelectorAll('[data-path][data-key]').forEach(function (el) {
|
||
var s = _rpByPath(el.getAttribute('data-path'));
|
||
if (!s) return;
|
||
var k = el.getAttribute('data-key');
|
||
if (el.type === 'checkbox') s[k] = el.checked;
|
||
else if (el.type === 'number') s[k] = el.value === '' ? '' : Number(el.value);
|
||
else s[k] = el.value;
|
||
});
|
||
}
|
||
|
||
function _rpRedraw() {
|
||
var box = document.getElementById('rp-steps');
|
||
if (box) box.innerHTML = _rpStepRows(_rpEdit.steps, '');
|
||
}
|
||
|
||
function rpSaveTask() {
|
||
_rpCollect();
|
||
var e = _rpEdit;
|
||
var name = (document.getElementById('rp-ed-name') || {}).value || '';
|
||
var mode = (document.getElementById('rp-ed-mode') || {}).value || 'all';
|
||
var which = (document.getElementById('rp-ed-which') || {}).value || '';
|
||
var target = { mode: mode };
|
||
if (mode === 'group') target.group_name = which;
|
||
if (mode === 'serial') target.serial = which;
|
||
if (mode !== 'all' && !which) { showToast('先选一个具体目标', 'error'); return; }
|
||
apiPut('/api/video_plan/tasks/' + e.id, {
|
||
name: name, time: (document.getElementById('rp-ed-time') || {}).value || '10:00',
|
||
enabled: !!(document.getElementById('rp-ed-enabled') || {}).checked,
|
||
target: target, steps: e.steps
|
||
}).then(function (r) {
|
||
if (r && r.ok) {
|
||
closeModal();
|
||
showToast('已保存', 'success');
|
||
loadReleaseTasks();
|
||
} else {
|
||
showToast((r && r.error) || '保存失败', 'error');
|
||
}
|
||
});
|
||
}
|
||
|
||
// 通用编辑器入口(要加步骤、循环、自定义动作时用):任务页那套编辑器的数据由 loadTasks 填,
|
||
// 所以先刷一次再打开,不然 _jobsData 里没有这个任务会被当成"新建"
|
||
function rpOpenFullEditor(jobId) {
|
||
closeModal();
|
||
showTab('tasks');
|
||
loadTasks().then(function () { openTaskModal(jobId); });
|
||
}
|
||
|
||
function rpDoNewTask() {
|
||
var mode = (document.getElementById('rp-new-mode') || {}).value || 'all';
|
||
var which = (document.getElementById('rp-new-which') || {}).value || '';
|
||
var target = { mode: mode };
|
||
// 分组的键名是 **group_name**(TaskJob.resolve_serials 只认它;写成 group 会静默解析出 0 台设备)
|
||
if (mode === 'group') target.group_name = which;
|
||
if (mode === 'serial') target.serial = which;
|
||
if (mode !== 'all' && !which) { showToast('先选一个具体目标', 'error'); return; }
|
||
apiPost('/api/video_plan/tasks', {
|
||
name: (document.getElementById('rp-new-name') || {}).value || '发布视频',
|
||
time: (document.getElementById('rp-new-time') || {}).value || '10:00',
|
||
target: target, enabled: true
|
||
}).then(function (r) {
|
||
if (r && r.ok) { closeModal(); showToast(r.msg || '已创建', 'success'); loadReleaseTasks(); }
|
||
else showToast((r && r.error) || '失败', 'error');
|
||
});
|
||
}
|