// 视频发布计划(「账号 → 发布计划」页) // 数据在 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 ''; }).join('') + '
#文件名解析大小
' + (r.idx + 1) + '' + esc(r.file.name) + '' + parsed + '' + _fmtSize(r.file.size) + '
' + '
'; 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 '' + '' + ''; }).join('') + '
文件进度结果
' + esc(r.file.name.slice(0, 40)) + '
等待…
' + '
'; (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 '' + '' + ''; }).join('') + '
行标题账号/日期/编号结果
' + 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 || '') + '
'; } 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 += '
' + (_rpShowOther ? '▾ 收起' : '▸ 展开') + '其它任务(' + other.length + ' 个,也能在这里改步骤)
'; 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 '
' + head + '
' + pick + '
'; } // 抓取元素回填后:把选择器类型下拉也切过去(通用编辑器那套要看 .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'); }); }