// 视频发布计划(「账号 → 发布计划」页)
// 数据在 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 = '
读取失败:' + esc((r && r.error) || '') + '
';
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 '' + s[1] + '';
}
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 = '今天要发 ' + (s.today_ready || 0) + ' 个'
+ '(' + (s.today_devices || 0) + ' 台) · 今天已发 ' + (s.today_done || 0) + ''
+ ' · 待标题 ' + (s.pending || 0)
+ ' · 过期未发 ' + (s.overdue || 0)
+ (s.unknown ? ' · 结果未知 ' + s.unknown + '' : '')
+ ' | 素材 ' + _fmtSize(d.videos_bytes) + '(' + (d.videos_count || 0) + ' 个)'
+ ' · 剩余 '
+ _fmtSize(d.free) + '';
}
function renderReleaseTimeline() {
var box = document.getElementById('rp-timeline');
if (!box) return;
if (!_rpDays.length) {
box.innerHTML = ''
+ '这个范围里没有计划 —— 点「上传视频」把素材传进来
';
return;
}
box.innerHTML = _rpDays.map(function (day) {
var isToday = day.date === _rpToday;
var cards = day.plans.map(rpCard).join('');
return ''
+ '
' + esc(day.date) + ' ' + esc(day.weekday || '')
+ (isToday ? ' 今天' : '')
+ ' 计划 ' + day.total
+ ' 个 / 已发 ' + day.done + ' 个
' + cards;
}).join('');
}
function rpCard(p) {
var acc = '' + esc(p.device_name || '?') + ' ' + esc(p.nickname || '')
+ ' '
+ esc(p.douyin_id || p.phone) + '';
var video = p.video_name
? '' + esc(p.video_name.slice(0, 40)) + ''
+ (p.video_deleted_at ? ' 素材已清理' : '')
: '素材已清理';
var title = p.title ? esc(p.title.slice(0, 30)) : '⚠ 缺标题';
var link = p.share_url
? ''
+ esc(p.share_url.replace('https://v.douyin.com/', '…/')) + ''
+ ' '
: (p.status === 'done' ? '缺链接' : '');
var ops = [];
if (p.status === 'ready' || p.status === 'failed') {
ops.push('');
}
if (p.status === 'pushing') {
ops.push('');
ops.push('');
}
if (p.status === 'unknown') {
ops.push('');
ops.push('');
}
if (p.status === 'failed' || p.status === 'unknown') {
ops.push('');
}
ops.push('');
if (p.status !== 'done' && p.status !== 'skipped') {
ops.push('');
}
ops.push('');
// 推送的相册校验结果单独显示:文件推上去了 ≠ 相册里点得到它(后面的发布步骤要点相册)
var pv = p.push_verify || '';
var pushTag = !pv ? ''
: (pv === 'ok'
? ' 已推送·相册可见'
: (pv === 'no_index'
? ' 已推送·相册未见'
: ' 已推送·文件不在'));
var warn = p.status === 'unknown'
? ''
+ '上次发布结果未知:可能已经发出去了,先去抖音里看一眼,再点「已发出 / 未发出」——平台不会自动重发
'
: (pv === 'no_index'
? ''
+ '素材推上去了但没进相册索引:发布步骤里的「相册」可能点不到它 ——'
+ '先到手机相册确认能选到,不行就重推一次(推送时会顺带触发扫描)
'
: (p.last_error ? '' + esc(p.last_error.slice(0, 80)) + '
' : ''));
return ''
+ '
'
+ '
' + acc + ' 编号 ' + p.seq
+ (p.seq_auto ? '(自动)' : '') + ' ' + rpStatusLabel(p.status)
+ (p.attempts ? ' 已试 ' + p.attempts + ' 次' : '') + pushTag + '
'
+ '
' + ops.join(' ') + '
'
+ '
素材:' + video + ' | 标题:' + title
+ (link ? ' | ' + link : '') + '
'
+ (p.published_at ? '
发布于 ' + esc(p.published_at) + '
' : '')
+ warn + '
';
}
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 '| ' + esc(p.release_date) + ' | ' + esc(p.device_name || '-') + ' | '
+ '' + esc(p.nickname || '-') + '/' + esc(p.douyin_id || p.phone) + ' | '
+ '' + p.seq + (p.seq_auto ? '*' : '') + ' | '
+ '' + esc(p.video_name || '-') + ' | '
+ '' + esc(p.title || '-') + ' | '
+ '' + rpStatusLabel(p.status) + ' | '
+ '' + (p.share_url ? '' : '-') + ' | '
+ ' |
';
},
tbody: 'tb-release', pager: 'pager-release', pageSize: 20,
empty: '| 这个范围里没有计划 |
'
});
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 = ''
+ '确认下面每个文件解析出的账号与日期对不对(传上去就按这个配)。'
+ '文件名不带编号的,会按这个顺序依次填进该账号当天的空位。
'
+ ''
+ '| # | 文件名 | 解析 | 大小 |
'
+ rows.map(function (r) {
var c = r.c;
var parsed = c.ok
? esc(c.phone) + ' / ' + esc(c.date) + (c.seq ? ' / 编号 ' + esc(c.seq) : ' / 编号自动')
: '✗ ' + esc(c.bad ? '多了片段 ' + c.bad : (!c.phone ? '缺手机号' : '缺日期')) + '(会被拒收)';
return '| ' + (r.idx + 1) + ' | ' + esc(r.file.name) + ' | ' + parsed
+ ' | ' + _fmtSize(r.file.size) + ' |
';
}).join('') + '
'
+ '';
document.getElementById('modal-body').innerHTML = body;
document.getElementById('modal-footer').innerHTML =
''
+ '';
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 = '0 / ' + rows.length
+ ' (' + _fmtSize(total) + ')'
+ '
'
+ '| 文件 | 进度 | 结果 |
'
+ ''
+ rows.map(function (r, i) {
return '| ' + esc(r.file.name.slice(0, 40)) + ' | '
+ ' | '
+ '等待… |
';
}).join('') + '
'
+ '';
(function next(i) {
if (i >= rows.length) {
var sum = document.getElementById('rp-up-summary');
sum.innerHTML = '完成:成功 ' + okN + ' 个' + (badN ? ',拒收 ' + badN + ' 个' : '')
+ ' ';
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 = '✓ ' + esc(res.msg || '已录入') + ''
+ (res.action === 'skip' ? ' 已存在' : '');
} else {
badN++;
resEl.innerHTML = '✗ ' + esc((res && res.error) || '失败')
+ ' ';
}
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
? '✓ ' + esc(res.msg || '已录入') + ''
: '✗ ' + esc((res && res.error) || '失败') + '';
}
});
}
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 =
'共 ' + lines.length + ' 行有效标题。'
+ '每行 标题内容_手机号_日期_编号 或 手机号_日期_编号_标题(都认,编号可省,标题里可以有 #话题);
没写编号的按顺序配到该账号当天还没标题的视频上。
'
+ ''
+ esc(lines.slice(0, 200).join('\n')) + (lines.length > 200 ? '\n…' : '') + '
'
+ '';
document.getElementById('modal-footer').innerHTML =
''
+ ''
+ '';
window._rpTitleText = text;
showModal();
}
function rpDoTitles(dry) {
var box = document.getElementById('rp-title-result');
if (box) box.innerHTML = '' + (dry ? '预览中…' : '导入中…') + '
';
apiPost('/api/video_plan/upload_titles', { text: window._rpTitleText || '', dry_run: !!dry })
.then(function (r) {
if (!r || !r.ok) {
if (box) box.innerHTML = '失败:' + esc((r && r.error) || '') + '
';
return;
}
var c = r.counts || {};
if (box) {
box.innerHTML = '' + esc(r.msg || '') + ''
+ (dry ? ' (预览,还没写入)' : '') + '
'
+ '| 行 | 标题 | 账号/日期/编号 | 结果 |
'
+ (r.rows || []).map(function (x) {
return '| ' + x.line + ' | ' + esc((x.title || '').slice(0, 24)) + ' | '
+ '' + esc(x.phone) + ' / ' + esc(x.date) + ' / ' + (x.seq || '-') + ' | '
+ '' + (x.action === 'attach' ? '已配'
: x.action === 'skip' ? '跳过'
: '拒收') + ' ' + esc(x.error || '') + ' |
';
}).join('') + '
';
}
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 = '读取失败:' + esc((r && r.error) || '') + ''; 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 += '发布任务(含「推送发布视频」/「标记发布结果」,这一页的视频就由它们推给手机)
';
html += rel.length
? rel.map(_rpTaskRow).join('')
: '还没有发布任务 —— 点右边「一键新建发布任务」建一个骨架;'
+ '或者把下面「其它任务」里那个抖音发布任务插上平台步骤(推送放最前、标记放最后,你自己的步骤不动)
';
if (other.length) {
html += '';
if (_rpShowOther) {
html += '' + other.map(_rpTaskRow).join('') + '
';
}
}
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 ''
+ '
' + esc(t.name) + ' '
+ (t.enabled ? '启用' : '停用') + ''
+ ' ' + sch + (t.next_run ? ' · 下次 ' + esc(t.next_run) : '')
+ ' · ' + t.step_count + ' 步 · ' + esc(_rpTargetText(t.target)) + ''
+ (empty ? ' ' + empty + ' 个选择器待抓' : '')
+ (t.is_release && !t.has_push
? ' 缺平台推送步骤' : '')
+ '
'
+ '
'
+ ' '
+ (t.is_release && !t.has_push
? ' '
: '')
+ '
';
}
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 =
'会建好骨架:校验账号(抖音号对得上才发)→ 推送发布视频 → 抖音点击 → '
+ '填标题(自动取这条计划的标题)→ 标记发布结果;'
+ '抖音那几个点击步骤是空选择器的占位(标签写着该抓哪个),'
+ '建好后就在「发布计划」这一块点「编辑」抓选择器,不用跳任务页。
'
+ ''
+ '';
window._rpGroups = groups; window._rpDevs = devs;
document.getElementById('modal-footer').innerHTML =
''
+ '';
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 ''; }).join('');
} else if (mode === 'serial') {
html = (window._rpDevs || []).map(function (d) {
return '';
}).join('');
} else {
html = '';
}
sel.innerHTML = html || '';
}
// ============ 发布任务的**就地编辑**(就在「账号 → 发布计划」这一页,不跳任务页)============
// 为什么单独做一套:发布任务的步骤是固定的那十几步,用通用编辑器(拖拽画布)编它要来回跳页,
// 而这一页的人真正要干的只有一件事 —— **把抖音那几个空选择器抓一遍**。
// 所以这里给一组紧凑表单:改名字/目标/时间/启停 + 逐步改参数(选择器带「抓取元素」);
// 要更复杂的编排(加步骤、循环、自定义动作)走底部的「打开通用编辑器」。
//
// 保存口径:整块 steps 交给 `PUT /api/video_plan/tasks/`,后端深合并进 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 =
' '
+ ' '
+ '';
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 ''
+ ''
+ '下面每一步都能就地改。空选择器的点击步骤就是还没抓过的那几个 ——'
+ '点「抓取元素」选设备后照着点一下,会自动把选择器填进来。
'
+ '' + _rpStepRows(e.steps, '') + '
';
}
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 '';
}).join('');
} else if (mode === 'serial') {
html = (window._rpDevs || []).map(function (d) {
return '';
}).join('');
} else {
html = '';
}
sel.innerHTML = html || '';
}
// 步骤列表 → HTML。`path` 是"第几步的第几个分支的第几步"(如 3/then/1),
// 编辑完按同样的路径写回(见 _rpCollect)
function _rpStepRows(steps, path) {
if (!steps || !steps.length) {
return '(这里没有步骤)
';
}
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 += ''
+ '
' + pair[1]
+ ' (' + p[k].length + ' 步)
'
+ _rpStepRows(p[k], path + '/' + k) + '
';
});
}
var lim = [];
if (total > 1 && idx > 0) lim.push('');
if (total > 1 && idx < total - 1) lim.push('');
lim.push('');
var empty = ((s.type === 'click' || s.type === 'wait_el') && !String(p.selector_value || '').trim());
return ''
+ '
'
+ '
' + esc(s.label || s.type) + ' '
+ '' + esc(s.type) + ''
+ (empty ? ' 待抓选择器' : '') + '
'
+ '
' + lim.join(' ') + '
'
+ '
'
+ '
'
+ '
'
+ body + '
' + nested + '
';
}
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 = '';
var attrs = 'data-path="' + path + '" data-key="' + key + '"';
var span = (kind === 'area' || kind === 'selval') ? ' style="grid-column:span 2"' : '';
if (kind === 'chk') {
return '';
}
if (kind === 'sel') {
return '' + head
+ '
';
}
if (kind === 'seltype') {
var types = _RP_SEL_TYPES.slice();
if (type === 'if_el') types = types.concat(['ocr', 'screen', 'foreground']);
return '' + head
+ '
';
}
if (kind === 'num') {
return '' + head
+ '
';
}
if (kind === 'area') {
return '' + head
+ '
';
}
// text / selval(选择器值):选择器值旁边给「抓取元素」——
// openPicker 取的是这个按钮的 previousElementSibling,所以按钮必须紧跟在 input 后面
// (kind 区分别改:'sel' 是"固定选项的下拉",'selval' 才是"选择器值+抓取按钮")
var pick = (kind === 'selval' && typeof _can === 'function' && _can('devices'))
? ''
: '';
return '';
}
// 抓取元素回填后:把选择器类型下拉也切过去(通用编辑器那套要看 .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');
});
}