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:
2026-10-12 09:02:40 +08:00
parent 75bee21ffb
commit beda0f3779
37 changed files with 2615 additions and 1747 deletions
+91 -40
View File
@@ -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']],