Files
auto_control/static/admin/release.js
T
butubb a800d051ae feat(发布计划): 标题行两种写法都认 + 行首 # 不再无条件当注释
一、标题行解析(core/video_plan.parse_title_line)
- 原来只认「标题内容_手机号_日期_编号」(右锚定)。现在**两种都认**:
   ① 标题内容_手机号_日期_编号        (推荐,右锚定 —— 标题里带下划线也不会错配)
   ② 手机号_日期_编号_标题            (左锚定,跟视频文件名同序 = "文件名去掉扩展名 + 标题")
  两种里都**先试带编号的**,保证同一行永远只有一种解释;编号都可省略。
- 新拒收一条:`手机号_日期_1`(只有编号、没标题)**直接拒** ——
  不能把它当标题"1"(静默生成一条标题是"1"的文案,比拒收危险得多);
  标题真是纯数字的用写法①。

二、`#` 开头的行(parse_titles_text)
- 原来是"以 `#` 开头就整行忽略" → **`#中秋快乐_...` 这种正常标题会被静默丢掉**。
- 改成**先按标题行解析,解析得出就当标题;解析不出且以 `#` 开头才算注释**。
  `# 这是注释` 照样忽略,`#话题` 开头的标题照收(`#` 保留在标题里)。

三、其它
- 文案同步:上传标题面板/帮助文案、doc/API.md 的 upload_titles 语义(两种写法、`#` 规则、拒收条件)
- 测试:新增 8 条(两种写法 × 带/不带编号 × 标题含下划线与 #话题、只有编号要拒收、整段注释与报错)
2026-09-29 09:00:43 +08:00

1009 lines
53 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 视频发布计划(「账号 → 发布计划」页)
// 数据在 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> 或 <code>手机号_日期_编号_标题</code>(都认,编号可省,标题里可以有 <code>#话题</code>);<br>没写编号的按顺序配到该账号当天还没标题的视频上。</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');
});
}