feat(发布计划): 标题 + 正文(标题 || 正文 _手机号_日期_编号)+ 发布流程多一步填正文;账号名称全面下线
一、正文(video_plan.body,schema v10→v11)
- 标题 txt 一行里可带正文,用 `||` 分隔(标题/正文都常带下划线,不能再用下划线分列;
只切第一处,正文里再出现 `||` 原样保留):`标题 || 正文 _手机号_日期_编号`
正文可省(老文件照收);两种写法(标题在前 / 手机号在前)都能带正文
- 长度:标题 ≤100 字、正文 ≤1000 字,超了**拒收不截断**
- 「待发布」判据从"有没有标题"改成"**标题或正文有没有内容**"(`_sync_content_status` 一处定义,
改标题/改正文/批量导入三处共用),只给正文的行也照样待发
- 导入配对:同标题补一条新正文**要收下**(不能当重复跳过);已有别的标题仍然拒收(不静默覆盖)
二、发布流程多一步
- 骨架 15 → **17 步**:⑪ 输入文字(计划的标题) → ⑫ 点击正文框 → ⑬ 输入文字(计划的正文) → ⑭ 发布 → ⑮ 标记
- 「输入文字」取值来源集中到 `RELEASE_TEXT_SOURCES`:release_title(_topics) / release_body(_topics) /
release_title_body(标题+正文,只有一个输入框的抖音版本用);`build_caption(key=…)` 支持三种来源
- 任务编辑器与发布计划就地编辑器的下拉都补齐这 6 档;待抓的空选择器 5 → 6 个(多了正文框)
三、账号名称下线(用户要求:只要抖音号)
- 发布计划:卡片 / 明细表 / 搜索 / 导出 CSV 都不再有账号名称(CSV 改为
发布日期/设备号/手机号/抖音号/编号/标题/正文/分享链接/发布时间)
- 账号台账:列表列 / 编辑弹窗 / 粘贴导入预览 / 设备维度弹窗 / 导入说明与示例都去掉;
**粘贴导入忽略这一列**(不报错、不落库),库里的历史值保留(不删列)
- 设备端 Agent 身份页:平台不再下发名称(传空串,**不改 APK 也能生效**,避免老版本解析失败)
四、文档 / 测试
- DATA_MODEL:body 列 + schema 11;API:upload_titles(`||` 语义)、PUT {title,body}、CSV 列、骨架 17 步
- TASK_DEV:骨架 17 步、`input_text` 六个取值来源表、正文长度上限
- ARCHITECTURE:release.js 说明(正文、不显示账号名称)
- 测试(%TEMP% 被系统清理后重建):test_plan_body.py 33 项(解析/取内容/骨架/入库/导入配对)、
smoke_app_body.py 10 项(整机装配:页面、PUT 改正文、时间线、CSV 表头)—— 全过
This commit is contained in:
@@ -444,11 +444,13 @@ var _stepEditor={
|
||||
const fromPlan=(tsrc!=='manual');
|
||||
// 取值来源:默认手填(行为与以前一致);也可以直接吃「发布计划」里那一条的标题
|
||||
h+='<div class="form-group"><label>取值来源</label><select class="form-control" data-key="text_source" onchange="_stepEditor._inputSourceChanged(this)">'+
|
||||
[['manual','手填 / 随机候选(默认)'],['release_title','发布计划这一条的标题'],['release_title_topics','发布计划标题 + 话题']]
|
||||
[['manual','手填 / 随机候选(默认)'],['release_title','发布计划这一条的标题'],
|
||||
['release_title_topics','发布计划标题 + 话题'],['release_body','发布计划这一条的正文'],
|
||||
['release_body_topics','发布计划正文 + 话题'],['release_title_body','标题 + 正文(一个框时用)']]
|
||||
.map(function(o){return '<option value="'+o[0]+'"'+(tsrc===o[0]?' selected':'')+'>'+o[1]+'</option>';}).join('')+
|
||||
'</select><div class="help">选"发布计划"时,标题由前面的「推送发布视频」那一步取出来(同一次运行里传递;没取到会跳过并告警)—— 你就不用把同一句文案再抄一遍了</div></div>';
|
||||
if(fromPlan){
|
||||
h+='<div class="form-group" data-key-wrap="release_topics" style="display:'+(tsrc==='release_title_topics'?'block':'none')+'"><label>话题(逗号分隔,不带 # 会自动补)</label><input type="text" class="form-control" data-key="release_topics" value="'+esc(p.release_topics||'')+'" placeholder="如 苏州,日常"></div>';
|
||||
h+='<div class="form-group" data-key-wrap="release_topics" style="display:'+(/topics$/.test(tsrc)?'block':'none')+'"><label>话题(逗号分隔,不带 # 会自动补)</label><input type="text" class="form-control" data-key="release_topics" value="'+esc(p.release_topics||'')+'" placeholder="如 苏州,日常"></div>';
|
||||
h+='<div class="help" style="color:#f59e0b">取计划标题时<b>会回读输入框校验</b>:抖音的输入框有时不吃键盘输入,不校验就可能发出一篇空文案;校验不过会自动改走剪贴板粘贴再验一次</div>';
|
||||
}
|
||||
// 手填那两块:**只隐藏不删**(_syncParams 按 DOM 存在与否收集,删了会把用户填的值抹掉)
|
||||
|
||||
+11
-14
@@ -20,7 +20,7 @@ function loadLedger() {
|
||||
apiGet('/api/ledger?limit=2000').then(function (r) {
|
||||
var tb = document.getElementById('tb-ledger');
|
||||
if (!r || !r.ok) {
|
||||
if (tb) tb.innerHTML = '<tr><td colspan="10" class="empty">读取失败:' + esc((r && r.error) || '') + '</td></tr>';
|
||||
if (tb) tb.innerHTML = '<tr><td colspan="9" class="empty">读取失败:' + esc((r && r.error) || '') + '</td></tr>';
|
||||
return;
|
||||
}
|
||||
_ledgerAll = r.accounts || [];
|
||||
@@ -60,13 +60,13 @@ function renderLedger() {
|
||||
setListPager('ledger', {
|
||||
data: function () { return rows; },
|
||||
filter: function (a, kw) {
|
||||
return ((a.douyin_id || '') + (a.phone || '') + (a.nickname || '')
|
||||
return ((a.douyin_id || '') + (a.phone || '')
|
||||
+ (a.device_name || '') + (a.note || '')).toLowerCase().indexOf(kw) >= 0;
|
||||
},
|
||||
sortValue: function (a, k) { return a[k]; },
|
||||
render: renderLedgerRow,
|
||||
tbody: 'tb-ledger', pager: 'pager-ledger', pageSize: 20,
|
||||
empty: '<tr><td colspan="10" class="empty">还没有账号 —— 点「粘贴导入」把你那张表贴进来</td></tr>'
|
||||
empty: '<tr><td colspan="9" class="empty">还没有账号 —— 点「粘贴导入」把你那张表贴进来</td></tr>'
|
||||
});
|
||||
applyListPager('ledger');
|
||||
initListSearch('ledger', 'search-ledger');
|
||||
@@ -86,7 +86,6 @@ function _clip(s, n) {
|
||||
function renderLedgerRow(a) {
|
||||
return '<tr>' +
|
||||
'<td><strong>' + esc(a.device_name || '-') + '</strong></td>' +
|
||||
'<td>' + esc(a.nickname || '-') + '</td>' +
|
||||
'<td style="font-family:var(--mono)">' + esc(a.douyin_id || '-') + '</td>' +
|
||||
'<td style="font-family:var(--mono)">' + esc(a.phone || '-') + '</td>' +
|
||||
'<td>' + esc(a.registered_at || '-') + '</td>' +
|
||||
@@ -117,10 +116,9 @@ function _ledgerField(label, key, val, ph) {
|
||||
|
||||
function openLedgerModal(id) {
|
||||
var a = id ? _ledgerById(id) : null;
|
||||
document.getElementById('modal-title').textContent = a ? ('编辑账号:' + (a.nickname || a.douyin_id)) : '新增账号';
|
||||
document.getElementById('modal-title').textContent = a ? ('编辑账号:' + (a.douyin_id || a.phone)) : '新增账号';
|
||||
var h = '<div class="form-row">' +
|
||||
_ledgerField('设备号', 'device_name', a && a.device_name, '如 A01(要和设备池里的设备名一致)') +
|
||||
_ledgerField('账号名称', 'nickname', a && a.nickname, '如 AA建材王总') + '</div>' +
|
||||
'<div class="form-row">' +
|
||||
_ledgerField('抖音号', 'douyin_id', a && a.douyin_id, '纯号,如 35377983067(不带「抖音号:」前缀)') +
|
||||
_ledgerField('手机号', 'phone', a && a.phone, '') + '</div>' +
|
||||
@@ -167,7 +165,7 @@ function saveLedger(id) {
|
||||
|
||||
function deleteLedger(id) {
|
||||
var a = _ledgerById(id);
|
||||
var who = a ? ((a.device_name || '') + ' / ' + (a.nickname || '') + ' / ' + (a.douyin_id || '')) : id;
|
||||
var who = a ? ((a.device_name || '') + ' / ' + (a.douyin_id || '')) : id;
|
||||
if (!confirm('删掉这条账号?\n\n' + who + '\n\n删掉之后:任务从台账取号就取不到它了。')) return;
|
||||
apiDelete('/api/ledger/' + id).then(function (r) {
|
||||
if (r && r.ok) { showToast(r.msg || '已删除', 'success'); loadLedger(); loadLedgerCounts(); }
|
||||
@@ -182,7 +180,7 @@ function openLedgerImport() {
|
||||
document.getElementById('modal-body').innerHTML =
|
||||
'<div class="help" style="margin-bottom:8px">' +
|
||||
'把 Excel 里的表格<b>连表头一起</b>复制,粘到下面。首行是列名,顺序随意、缺列留空。<br>' +
|
||||
'认得的列名:<code>设备号 / 手机号 / 账号名称 / 抖音号 / 注册时间 / 卡在机内 / 可发视频 / 简介 / 备注</code>' +
|
||||
'认得的列名:<code>设备号 / 手机号 / 抖音号 / 注册时间 / 卡在机内 / 可发视频 / 简介 / 备注</code>(<b>账号名称列会被忽略</b>)' +
|
||||
'(认不出的列会被忽略)。<b>没有表头</b>时按上面这个顺序解析。<br>' +
|
||||
'「卡在机内 / 可发视频」认 <code>是/有/1</code> 与 <code>否/空</code>。' +
|
||||
'</div>' +
|
||||
@@ -197,7 +195,7 @@ function openLedgerImport() {
|
||||
'<option value="overwrite">覆盖(整行替换,空单元格会覆盖掉原值)</option></select></div>' +
|
||||
'</div>' +
|
||||
'<div class="form-group"><textarea id="ledger-import-text" class="form-control" rows="10" ' +
|
||||
'style="font-family:var(--mono);font-size:12px" placeholder="设备号	手机号	账号名称	抖音号	注册时间	卡在机内	可发视频	简介	备注 A01	13800000000	示例账号	35000000001	2026/9/24	否	是		"></textarea></div>' +
|
||||
'style="font-family:var(--mono);font-size:12px" placeholder="设备号	手机号	抖音号	注册时间	卡在机内	可发视频	简介	备注 A01	13800000000	35000000001	2026/9/24	否	是		"></textarea></div>' +
|
||||
'<div id="ledger-import-result"></div>';
|
||||
document.getElementById('modal-footer').innerHTML =
|
||||
'<button class="btn" onclick="closeModal()">取消</button>' +
|
||||
@@ -243,7 +241,6 @@ function renderImportResult(r) {
|
||||
'<td><span class="label ' + a[0] + '">' + a[1] + '</span></td>' +
|
||||
'<td>' + esc(x.device_name || '') + '</td>' +
|
||||
'<td style="font-family:var(--mono)">' + esc(x.douyin_id || '') + '</td>' +
|
||||
'<td>' + esc(x.nickname || '') + '</td>' +
|
||||
'<td style="color:#f59e0b">' + esc(msg) + '</td></tr>';
|
||||
}).join('');
|
||||
var errs = (r.errors || []).map(function (e) {
|
||||
@@ -252,7 +249,7 @@ function renderImportResult(r) {
|
||||
box.innerHTML = head +
|
||||
_importErrorsNote(errs) +
|
||||
'<div style="max-height:320px;overflow:auto"><table class="table table-sm">' +
|
||||
'<thead><tr><th>行</th><th>动作</th><th>设备号</th><th>抖音号</th><th>账号名称</th><th>说明</th></tr></thead>' +
|
||||
'<thead><tr><th>行</th><th>动作</th><th>设备号</th><th>抖音号</th><th>说明</th></tr></thead>' +
|
||||
'<tbody>' + (rows || '<tr><td colspan="6" class="empty">没有可导入的行</td></tr>') + '</tbody></table></div>' +
|
||||
(r.dry_run ? '<div class="help">确认没问题后点「直接导入」。</div>' : '');
|
||||
}
|
||||
@@ -284,7 +281,7 @@ function showDeviceAccounts(name) {
|
||||
}
|
||||
document.getElementById('modal-title').textContent = name + ' 上的账号(' + (g.count || 0) + ' 个)';
|
||||
var rows = (g.accounts || []).map(function (a) {
|
||||
return '<tr><td>' + esc(a.nickname || '-') + '</td>' +
|
||||
return '<tr>' +
|
||||
'<td style="font-family:var(--mono)">' + esc(a.douyin_id || '-') + '</td>' +
|
||||
'<td style="font-family:var(--mono)">' + esc(a.phone || '-') + '</td>' +
|
||||
'<td>' + _yn(a.sim_in_device) + '</td><td>' + _yn(a.can_post_video) + '</td>' +
|
||||
@@ -292,8 +289,8 @@ function showDeviceAccounts(name) {
|
||||
}).join('');
|
||||
document.getElementById('modal-body').innerHTML =
|
||||
'<div style="max-height:60vh;overflow:auto"><table class="table table-sm">' +
|
||||
'<thead><tr><th>账号名称</th><th>抖音号</th><th>手机号</th><th>卡在机内</th><th>可发视频</th><th>注册时间</th><th>备注</th></tr></thead>' +
|
||||
'<tbody>' + (rows || '<tr><td colspan="7" class="empty">这台设备还没有登记账号</td></tr>') + '</tbody></table></div>' +
|
||||
'<thead><tr><th>抖音号</th><th>手机号</th><th>卡在机内</th><th>可发视频</th><th>注册时间</th><th>备注</th></tr></thead>' +
|
||||
'<tbody>' + (rows || '<tr><td colspan="6" class="empty">这台设备还没有登记账号</td></tr>') + '</tbody></table></div>' +
|
||||
'<div class="help">在「账号」页可以增删改;设备端 Agent 的「身份大字页」显示的就是这些。</div>';
|
||||
document.getElementById('modal-footer').innerHTML = '<button class="btn" onclick="closeModal()">关闭</button>';
|
||||
showModal();
|
||||
|
||||
+40
-17
@@ -97,14 +97,16 @@ function renderReleaseTimeline() {
|
||||
}
|
||||
|
||||
function rpCard(p) {
|
||||
var acc = '<b>' + esc(p.device_name || '?') + '</b> ' + esc(p.nickname || '')
|
||||
+ ' <span class="text-muted" style="font-family:var(--mono)">'
|
||||
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 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 title = p.title ? esc(p.title.slice(0, 30))
|
||||
: (p.body ? '' : '<span style="color:#f59e0b">⚠ 缺标题/正文</span>');
|
||||
var body = p.body ? esc(p.body.slice(0, 60)) : '<span class="text-muted">(无正文)</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>'
|
||||
@@ -126,7 +128,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>');
|
||||
}
|
||||
@@ -153,8 +155,8 @@ function rpCard(p) {
|
||||
+ (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>'
|
||||
+ '<div style="margin-top:4px">素材:' + video + ' | 标题:' + title + '</div>'
|
||||
+ '<div style="margin-top:2px">正文:' + body + (link ? ' | ' + link : '') + '</div>'
|
||||
+ (p.published_at ? '<div class="text-muted" style="font-size:12px">发布于 ' + esc(p.published_at) + '</div>' : '')
|
||||
+ warn + '</div>';
|
||||
}
|
||||
@@ -163,24 +165,26 @@ 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;
|
||||
return ((p.device_name || '') + (p.phone || '') + (p.douyin_id || '')
|
||||
+ (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.nickname || '-') + '/' + esc(p.douyin_id || p.phone) + '</td>'
|
||||
+ '<td>' + 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 style="max-width:260px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="'
|
||||
+ 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="9" class="empty">这个范围里没有计划</td></tr>'
|
||||
empty: '<tr><td colspan="10" class="empty">这个范围里没有计划</td></tr>'
|
||||
});
|
||||
applyListPager('release');
|
||||
initListSearch('release', 'search-release');
|
||||
@@ -477,14 +481,30 @@ 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];
|
||||
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');
|
||||
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-t" value="' + esc(p ? (p.title || '') : '') + '" maxlength="100"></div>'
|
||||
+ '<div class="form-group"><label>正文(可留空)</label>'
|
||||
+ '<textarea class="form-control" id="rp-ed-b" rows="4">' + esc(p ? (p.body || '') : '') + '</textarea></div>';
|
||||
document.getElementById('modal-footer').innerHTML =
|
||||
'<button class="btn" onclick="closeModal()">取消</button>'
|
||||
+ '<button class="btn btn-primary" onclick="rpSaveTitleBody(\'' + id + '\')">保存</button>';
|
||||
showModal();
|
||||
}
|
||||
|
||||
function rpSaveTitleBody(id) {
|
||||
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) {
|
||||
if (r && r.ok) { closeModal(); showToast(r.msg || '已改', 'success'); loadReleasePlan(); }
|
||||
else showToast((r && r.error) || '失败', 'error');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -672,7 +692,10 @@ var _RP_FIELDS = {
|
||||
wait: [['min', '最短(秒)', 'num'], ['max', '最长(秒)', 'num']],
|
||||
input_text: [['text_source', '文案从哪来', 'sel', [['manual', '手填(下面那栏)'],
|
||||
['release_title', '这条发布计划的标题'],
|
||||
['release_title_topics', '计划的标题 + 话题']]],
|
||||
['release_title_topics', '计划的标题 + 话题'],
|
||||
['release_body', '这条发布计划的正文'],
|
||||
['release_body_topics', '计划的正文 + 话题'],
|
||||
['release_title_body', '标题 + 正文(一个框时用)']]],
|
||||
['release_topics', '话题(逗号分隔)', 'text'],
|
||||
['fixed_text', '手填的文案(上面的来源选手填时才用)', 'area']],
|
||||
push_release: [['date_source', '发哪天的计划', 'sel', [['today', '今天'], ['fixed', '指定日期']]],
|
||||
|
||||
Reference in New Issue
Block a user