feat(发布计划): 改按名字发(文件名 名字_日期_编号)+ 账号管理整页下线 + 自动切号一轮发完
发布计划不再绑设备、不再依赖账号台账:上传文件名 `名字_日期_编号`(标题行 `标题 || 正文_名字_日期_编号`),运行时读本机当前抖音昵称取计划;靠循环块 「一轮把本机待发的号跑完」自动切号,一直发到发完。 · 解析/配对换键:右锚定认名字(不许含下划线、不许纯数字 —— 不合法直接拒收并说清原因), 删掉三处"手机号必须命中台账"的校验,加索引 ix_video_plan_name_date(启动时补建) · 新步骤三个:读本机账号名字 / 切换账号(**按名字点控件**、切完回读校验、遇登录页停本设备)/ 循环本机待发的号(试过不回头、跑一轮没进展就换号、停止·超时·上限三个干净出口) · 发布骨架改 18 步:读名字 → 校验账号(cmp_source=account_target)→ 推送(**推送前重读昵称核对**) → 抖音流程 → 标记结果;push_release 的 account 默认 name · 账号管理整页下线:删 core/ledger.py(serial_of 迁到 core/device_pool.py)、6 个 /api/ledger*、 「账号」页与 ledger.js、设备池「账号」列、设备端账号块;发布计划升为一级页签; device_account 表与 42 行历史数据保留、仍在备份清单内(**停用不等于删数据**) · 人工入口「按名字找设备」:新增 core/account_probe.py(只读探测、跳过在跑任务的设备) + POST /api/video_plan/push_by_account,没设备登着的号逐条报出来; 推送时把"实际是谁发的"写进 posted_serial / posted_device_name · 通知:新增 task.account.switch_failed / task.account.need_login · schema v12(posted_*)/ v13(device.cur_account_name / cur_name_at) · 文档同步:TASK_DEV / API / DATA_MODEL / ARCHITECTURE / NOTIFY / DEVICE_AGENT / DEPLOY / DEVELOPMENT / README / .env.example(两个抖音控件选择器抓一次填 .env) · 新增 scripts/tests/:6 套用例 + run_all.py(临时库 + 假设备,不碰真实数据、不连真机)
This commit is contained in:
+91
-40
@@ -1,4 +1,4 @@
|
||||
// 视频发布计划(「账号 → 发布计划」页)
|
||||
// 视频发布计划(「发布计划」页)
|
||||
// 数据在 video_plan 表,服务层 core/video_plan.py,接口 /api/video_plan/*(web/video_plan_api.py)。
|
||||
//
|
||||
// 这一页要解决的事:人员批量传视频与标题 → 自动按文件名配到账号上 → 时间线看每天谁要发、
|
||||
@@ -66,8 +66,8 @@ function renderReleaseStats() {
|
||||
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>'
|
||||
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>' : '')
|
||||
@@ -97,9 +97,10 @@ function renderReleaseTimeline() {
|
||||
}
|
||||
|
||||
function rpCard(p) {
|
||||
var acc = '<b>' + esc(p.device_name || '?') + '</b> '
|
||||
+ '<span class="text-muted" style="font-family:var(--mono)">'
|
||||
+ esc(p.douyin_id || p.phone) + '</span>';
|
||||
var acc = '<b>' + esc(p.nickname || '?') + '</b>'
|
||||
+ (p.posted_device_name
|
||||
? ' <span class="text-muted" style="font-size:12px">由 ' + esc(p.posted_device_name) + ' 发出</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>' : '')
|
||||
@@ -128,7 +129,7 @@ function rpCard(p) {
|
||||
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>');
|
||||
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>');
|
||||
}
|
||||
@@ -165,13 +166,13 @@ function renderReleaseTable() {
|
||||
setListPager('release', {
|
||||
data: function () { return _rpRows; },
|
||||
filter: function (p, kw) {
|
||||
return ((p.device_name || '') + (p.phone || '') + (p.douyin_id || '')
|
||||
return ((p.nickname || '') + (p.posted_device_name || '') + (p.device_name || '')
|
||||
+ (p.title || '') + (p.body || '') + (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.douyin_id || p.phone || '-') + '</td>'
|
||||
return '<tr><td>' + esc(p.release_date) + '</td><td><b>' + esc(p.nickname || '-') + '</b></td>'
|
||||
+ '<td>' + esc(p.posted_device_name || '-') + '</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>'
|
||||
@@ -181,7 +182,7 @@ function renderReleaseTable() {
|
||||
+ esc(p.body) + '">' + esc(p.body || '-') + '</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>';
|
||||
+ '<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="10" class="empty">这个范围里没有计划</td></tr>'
|
||||
@@ -208,22 +209,31 @@ function rpEnsurePolling() {
|
||||
|
||||
// ================== 上传:视频 ==================
|
||||
// 文件名规则与服务端一致(这里只做**本地预检**,真正的判定永远以后端为准)
|
||||
var _RP_PHONE = /^1\d{10}$/, _RP_DATE = /^\d{8}$/, _RP_SEQ = /^\d{1,3}$/;
|
||||
// `名字_日期_编号`:**右锚定** —— 末尾可省编号 → 日期 → 紧挨日期的那一段就是名字
|
||||
var _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 };
|
||||
var date = '', seq = '', bad = '';
|
||||
if (parts.length >= 3 && _RP_SEQ.test(parts[parts.length - 1])
|
||||
&& _RP_DATE.test(parts[parts.length - 2])) {
|
||||
seq = parts.pop(); date = parts.pop();
|
||||
} else if (parts.length >= 2 && _RP_DATE.test(parts[parts.length - 1])) {
|
||||
date = parts.pop();
|
||||
} else {
|
||||
bad = '结尾不是日期或「日期_编号」';
|
||||
}
|
||||
var acct = '';
|
||||
if (parts.length === 1) acct = parts[0].trim();
|
||||
else if (parts.length > 1) bad = bad || '名字前面多了片段';
|
||||
if (!bad) {
|
||||
if (!acct) bad = '名字是空的';
|
||||
else if (acct.indexOf('_') >= 0) bad = '名字里有下划线';
|
||||
else if (/^\d+$/.test(acct)) bad = '名字不能是纯数字';
|
||||
}
|
||||
return { name: acct, date: date, seq: seq, bad: bad, ok: !!acct && !!date && !bad };
|
||||
}
|
||||
|
||||
function rpPickVideos(input) {
|
||||
@@ -251,8 +261,8 @@ function rpShowUploadConfirm(rows) {
|
||||
+ 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>';
|
||||
? esc(c.name) + ' / ' + esc(c.date) + (c.seq ? ' / 编号 ' + esc(c.seq) : ' / <span class="text-muted">编号自动</span>')
|
||||
: '<span style="color:#ef4444">✗ ' + esc(c.bad || (!c.name ? '缺名字' : '缺日期')) + '(会被拒收)</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>'
|
||||
@@ -375,7 +385,7 @@ function rpShowTitleConfirm(fname, text) {
|
||||
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>'
|
||||
+ '每行 <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>';
|
||||
@@ -403,7 +413,7 @@ function rpDoTitles(dry) {
|
||||
+ '<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>' + esc(x.name) + ' / ' + 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>';
|
||||
@@ -415,25 +425,39 @@ function rpDoTitles(dry) {
|
||||
|
||||
// ================== 单条操作 ==================
|
||||
function rpPushToDevice(id) {
|
||||
if (!confirm('把这条的素材推到手机?\n\n只推送、不发布 —— 推完状态变「已推送到手机」,'
|
||||
+ '标题会同时写进手机剪贴板;之后由你(或你写的任务步骤)在手机上发布,发完点「标记已发布」。')) return;
|
||||
if (!confirm('把这条的素材推到手机?\n\n'
|
||||
+ '平台会**按这条计划的名字找设备**:实时看哪台在线设备现在登着这个号,推给它。\n'
|
||||
+ '没有设备登着就推不了(先去那台设备上切号)。\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');
|
||||
+ '平台会**按名字找设备**:哪台在线设备现在登着这个号,就推给谁(同台设备串行推)。\n'
|
||||
+ '没有设备登着的号会列出来(先去那台设备上切号)。')) return;
|
||||
apiPost('/api/video_plan/push_by_account', {}).then(function (r) {
|
||||
if (r && r.ok) {
|
||||
showToast(r.msg || '已开始推送', 'success');
|
||||
setTimeout(loadReleasePlan, 2000);
|
||||
} else if (r && r.unmatched && r.unmatched.length) {
|
||||
alert('推不了:' + (r.error || '')
|
||||
+ '\n\n逐条原因:\n' + r.unmatched.map(function (u) {
|
||||
return '· ' + u.name + '(' + u.count + ' 条):' + u.reason;
|
||||
}).join('\n'));
|
||||
} else {
|
||||
showToast((r && r.error) || '失败', 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -485,10 +509,13 @@ function rpDelete(id) {
|
||||
function rpEditTitle(id) {
|
||||
var p = null;
|
||||
for (var i = 0; i < _rpRows.length; i++) if (_rpRows[i].id === id) p = _rpRows[i];
|
||||
document.getElementById('modal-title').textContent = '改标题 / 正文';
|
||||
document.getElementById('modal-title').textContent = '改名字 / 标题 / 正文';
|
||||
document.getElementById('modal-body').innerHTML =
|
||||
'<div class="help">抖音发布页有两个框:<b>标题</b>(短,用于搜索展示)和<b>正文</b>(文案,可带 #话题)。'
|
||||
+ '两个框在任务里各由一个「输入文字」步骤填(取值来源分别选「计划的标题」/「计划的正文」)。</div>'
|
||||
+ '<div class="form-group"><label>名字(= 抖音昵称,发布时按它找号)</label>'
|
||||
+ '<input class="form-control" id="rp-ed-n" value="' + esc(p ? (p.nickname || '') : '') + '" maxlength="40">'
|
||||
+ '<div class="help">文件名写错了、或抖音上改了名,改这里对齐即可(<b>名字里不能有下划线、不能是纯数字</b>)。</div></div>'
|
||||
+ '<div class="form-group"><label>标题</label>'
|
||||
+ '<input class="form-control" id="rp-ed-t" value="' + esc(p ? (p.title || '') : '') + '" maxlength="100"></div>'
|
||||
+ '<div class="form-group"><label>正文(可留空)</label>'
|
||||
@@ -500,9 +527,10 @@ function rpEditTitle(id) {
|
||||
}
|
||||
|
||||
function rpSaveTitleBody(id) {
|
||||
var n = (document.getElementById('rp-ed-n') || {}).value || '';
|
||||
var t = (document.getElementById('rp-ed-t') || {}).value || '';
|
||||
var b = (document.getElementById('rp-ed-b') || {}).value || '';
|
||||
apiPut('/api/video_plan/' + id, { title: t, body: b }).then(function (r) {
|
||||
apiPut('/api/video_plan/' + id, { nickname: n, title: t, body: b }).then(function (r) {
|
||||
if (r && r.ok) { closeModal(); showToast(r.msg || '已改', 'success'); loadReleasePlan(); }
|
||||
else showToast((r && r.error) || '失败', 'error');
|
||||
});
|
||||
@@ -671,7 +699,7 @@ function rpNewTargetChanged() {
|
||||
sel.innerHTML = html || '<option value="">(没有可选项)</option>';
|
||||
}
|
||||
|
||||
// ============ 发布任务的**就地编辑**(就在「账号 → 发布计划」这一页,不跳任务页)============
|
||||
// ============ 发布任务的**就地编辑**(就在「发布计划」这一页,不跳任务页)============
|
||||
// 为什么单独做一套:发布任务的步骤是固定的那十几步,用通用编辑器(拖拽画布)编它要来回跳页,
|
||||
// 而这一页的人真正要干的只有一件事 —— **把抖音那几个空选择器抓一遍**。
|
||||
// 所以这里给一组紧凑表单:改名字/目标/时间/启停 + 逐步改参数(选择器带「抓取元素」);
|
||||
@@ -700,9 +728,33 @@ var _RP_FIELDS = {
|
||||
['fixed_text', '手填的文案(上面的来源选手填时才用)', 'area']],
|
||||
push_release: [['date_source', '发哪天的计划', 'sel', [['today', '今天'], ['fixed', '指定日期']]],
|
||||
['fixed_date', '指定日期(YYYY-MM-DD)', 'text'],
|
||||
['account', '按什么取计划', 'sel', [['name', '本机账号名字(推荐)'],
|
||||
['phone', '手填手机号(老路径)']]],
|
||||
['account_phone', '手机号(上面选"手填手机号"时填)', 'text'],
|
||||
['album_dir', '推到手机哪个目录', 'text'],
|
||||
['to_clipboard', '标题写进手机剪贴板', 'chk'],
|
||||
['max_same_run', '一次推几条', 'num']],
|
||||
read_account_name: [['selector_type', '选择器类型', 'seltype'],
|
||||
['selector_value', '选择器值(抖音「我」页的昵称)', 'selval'],
|
||||
['timeout', '超时(秒)', 'num'],
|
||||
['on_missing', '读不到时', 'sel', [['fail', '算失败(推荐:宁可不发)'],
|
||||
['skip', '记一句继续']]]],
|
||||
foreach_account: [['date_source', '循环哪天', 'sel', [['today', '今天'], ['fixed', '指定日期']]],
|
||||
['fixed_date', '指定日期(YYYY-MM-DD)', 'text'],
|
||||
['retry_failed', '失败过的也再试', 'chk'],
|
||||
['max_accounts', '最多跑几个号(0=不限)', 'num']],
|
||||
switch_account: [['target', '切到哪个名字', 'sel', [['var:target_name', '本次要发的那条计划的名字'],
|
||||
['var:account_name', '刚读到的本机名字'],
|
||||
['literal', '下面手填的名字']]],
|
||||
['target_value', '手填的名字(上面选"手填"时才用)', 'text'],
|
||||
['switch_selector_type', '选择器类型', 'seltype'],
|
||||
['switch_selector_value', '选择器值(进「账号切换」列表那一下)', 'selval'],
|
||||
['me_selector_value', '「我」页签的定位描述', 'text'],
|
||||
['timeout', '超时(秒)', 'num'],
|
||||
['verify', '切完回读校验(对不上就不发)', 'chk'],
|
||||
['max_attempts', '最多试几次', 'num'],
|
||||
['on_login_page', '遇到登录页', 'sel', [['stop', '停本设备(推荐)'],
|
||||
['continue', '记一句继续']]]],
|
||||
mark_release: [['result', '标记为什么结果', 'sel', [['published', '已发布(成功)'],
|
||||
['failed', '确认没发出去(可重试)'],
|
||||
['unknown', '拿不准(绝不自动重发)']]],
|
||||
@@ -715,10 +767,9 @@ var _RP_FIELDS = {
|
||||
['timeout', '超时(秒)', 'num'],
|
||||
['cmp_op', '文本比对', 'sel', [['', '不比对(只看元素在不在)'], ['等于', '等于'],
|
||||
['不等于', '不等于'], ['包含', '包含'], ['不包含', '不包含']]],
|
||||
['cmp_source', '候选值来源', 'sel', [['', '手填'], ['device', '本机台账'],
|
||||
['all', '全部台账'], ['group', '按设备分组'],
|
||||
['release', '本机当前发布计划(要发的那个号)']]],
|
||||
['cmp_group', '分组名(来源选"按设备分组"时填)', 'text'],
|
||||
['cmp_source', '候选值来源', 'sel', [['', '手填'],
|
||||
['account_target', '本次要发的名字(读名字/循环块给的)'],
|
||||
['release', '本机当前发布计划(要发的那条)']]],
|
||||
['cmp_value', '比对的值(一行一个,任一命中即可)', 'area']],
|
||||
mark_done: [['selector_type', '选择器类型', 'seltype'], ['selector_value', '选择器值', 'selval']],
|
||||
clipboard: [['text', '要放进剪贴板的文字', 'text']],
|
||||
|
||||
Reference in New Issue
Block a user