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
+6 -7
View File
@@ -117,7 +117,11 @@ function showTab(name){
showSubTab('agent', _activeSubs.agent||'chat'); // 聊天 / AI 建任务
if(typeof initAgentChat==='function') initAgentChat();
}
if(name==='account'){showSubTab('account',_activeSubs.account||'ledger');}
// 发布计划(原「发布计划」子分栏,账号台账下线后升为一级页签)
if(name==='plan'){
if(typeof loadReleasePlan==='function')loadReleasePlan();
if(typeof loadReleaseTasks==='function')loadReleaseTasks();
}
if(name==='users')loadUsers();
if(name==='system'){showSubTab('system',_activeSubs.system||'backup');}
}
@@ -125,7 +129,7 @@ function showTab(name){
// ================== 页内子分栏(任务/工具 通用) ==================
// 每个带子分栏的 Tab 记住上次选中的子分栏,切走再切回来保持原位
let _activeSubs = {tasks: 'plan', tools: 'clipboard', system: 'backup', agent: 'chat',
logs: 'files', account: 'ledger'};
logs: 'files'};
let _discoveryTimer = null; // 设备自动发现 10s 轮询(仅 devpool 子分栏激活时)
function showSubTab(tabId, name){
@@ -139,11 +143,6 @@ function showSubTab(tabId, name){
if(tabId==='logs' && name==='steps' && typeof loadStepLogs==='function') loadStepLogs(true);
if(tabId==='tasks' && name==='actioncfg' && typeof loadActionConfig==='function') loadActionConfig();
if(tabId==='tasks' && name==='dedup' && typeof loadDedup==='function') loadDedup();
if(tabId==='account' && name==='ledger' && typeof loadLedger==='function') loadLedger();
if(tabId==='account' && name==='plan' && typeof loadReleasePlan==='function'){
loadReleasePlan();
if(typeof loadReleaseTasks==='function') loadReleaseTasks();
}
if(name==='groups' && typeof loadGroups==='function') loadGroups();
if(name==='apks' && typeof loadAgentStore==='function') loadAgentStore();
if(name==='devpool' && typeof loadDevPool==='function'){
+24 -25
View File
@@ -21,6 +21,9 @@ var STEP_LIB=[
{type:'group',label:'动作组',icon:'📦',cat:'flow',params:{children:[]}},
{type:'if_el',label:'条件判断',icon:'❓',cat:'flow',params:{selector_type:'xpath',selector_value:'',timeout:3,ocr_click:false,cmp_op:'',cmp_value:'',cmp_source:'',cmp_group:'',ident_type:'text',ident_value:'',then:[],else:[]}},
{type:'mark_done',label:'记为已做',icon:'✅',cat:'flow',params:{selector_type:'text',selector_value:''}},
{type:'read_account_name',label:'读取本机账号名字',icon:'🏷',cat:'flow',params:{selector_type:'xpath',selector_value:'',timeout:8,on_missing:'fail'}},
{type:'switch_account',label:'切换账号',icon:'🔁',cat:'flow',params:{target:'var:target_name',target_value:'',switch_selector_type:'xpath',switch_selector_value:'',name_selector_type:'',name_selector_value:'',me_selector_type:'descriptionContains',me_selector_value:'我',timeout:8,verify:true,max_attempts:2,on_login_page:'stop'}},
{type:'foreach_account',label:'循环本机待发的号',icon:'🔂',cat:'flow',params:{date_source:'today',fixed_date:'',retry_failed:true,max_accounts:0,children:[]}},
{type:'notify',label:'发通知',icon:'🔔',cat:'flow',params:{title:'',message:'',level:'info'}},
{type:'stop_self',label:'停止本设备',icon:'⛔',cat:'flow',params:{reason:''}},
{type:'gesture',label:'录制手势',icon:'⏺',cat:'interact',params:{points:[],speed:1.0}}
@@ -478,9 +481,9 @@ var _stepEditor={
var isDedup=(p.selector_type==='dedup');
var dyn=(p.selector_type==='screen'||p.selector_type==='foreground'||isDedup);
// 文本比对:屏幕状态没有文本可比(亮/熄)、去重也不比文本,只有元素/OCR/前台 App 才给这块
// 候选值来源:默认"手填"(行为与以前完全一致);也可以直接吃「账号」页的台账
// 候选值来源:默认"手填"(行为与以前完全一致);也可以取"本次要发的名字"/发布计划
var cmpSrc=p.cmp_source||'';
var fromLedger=(cmpSrc==='device'||cmpSrc==='all'||cmpSrc==='group');
var fromPlatform=(cmpSrc==='account_target'||cmpSrc==='release');
var cmpRow=dyn?'':'<div class="form-row"><div class="form-group"><label>文本比对</label>'+
'<select class="form-control" data-key="cmp_op">'+
[['','不比对(只看元素在不在)'],['等于','等于'],['不等于','不等于'],['包含','包含'],['不包含','不包含']]
@@ -488,28 +491,24 @@ var _stepEditor={
'</select></div>'+
'<div class="form-group"><label>候选值来源</label>'+
'<select class="form-control" data-key="cmp_source" onchange="_stepEditor._cmpSourceChanged(this)">'+
[['','手填(默认)'],['device','本机台账'],['all','全部台账'],['group','按设备分组'],
['release','本机当前发布计划(要发的那个号)']]
[['','手填(默认)'],['account_target','本次要发的名字(读名字/循环块给的)'],
['release','本机当前发布计划(要发的那条)']]
.map(function(o){return '<option value="'+o[0]+'"'+((p.cmp_source||'')===o[0]?' selected':'')+'>'+o[1]+'</option>';}).join('')+
'</select><div class="help">'+(p.cmp_source==='release'
? '取<b>本机下一条要发的发布计划</b>的抖音号 + 账号名称(推送前校验"当前登录的是不是要发的号"就用它)'
: '台账在「账号」页维护')+'</div></div>'+
(cmpSrc==='group'?
'<div class="form-group"><label>设备分组名</label>'+
'<input class="form-control" data-key="cmp_group" value="'+esc(p.cmp_group||'')+'" '+
'placeholder="分组名(工具→设备分组)"></div>':'')+
// 选台账时**只隐藏、不删**这个输入框:_syncParams 是按 DOM 存在与否收集的,
? '取<b>本机下一条要发的发布计划</b>的<b>名字</b>(推送前校验"当前登的是不是要发的号"就用它)'
: (p.cmp_source==='account_target'
? '取<b>本次要发的那个名字</b>(「读取本机账号名字」读到的,或循环块设的目标)。'
+'<b>读不到名字时候选为空 → 不命中 → 走 else(不发)</b>,这是故意的'
: '手填要比对的文本;要用本机账号名就用上面的两个来源'))+'</div></div>'+
// 候选值来自平台时**只隐藏、不删**这个输入框:_syncParams 是按 DOM 存在与否收集的,
// 删了就等于把用户手填的值悄悄抹掉(同类坑见本文件 927 行附近的注释)
'<div class="form-group" style="grid-column:span 2'+(fromLedger?';display:none':'')+'">'+
'<div class="form-group" style="grid-column:span 2'+(fromPlatform?';display:none':'')+'">'+
'<label>比对的值(可多行,任一命中即可)</label>'+
'<textarea class="form-control" data-key="cmp_value" rows="2" style="font-family:var(--mono)" '+
'placeholder="如 35377983067&#10;35377983068">'+esc(p.cmp_value||'')+'</textarea>'+
'<div class="help">一行一个(也认 <code>|</code> 分隔):<b>任意一个命中就算命中</b>——多个号的场景把号都列上。'+
'<div class="help">一行一个(也认 <code>|</code> 分隔):<b>任意一个命中就算命中</b>——多个值的场景把值都列上。'+
'<b>逗号不当分隔符</b>,因为要比对的文本本身常带逗号'
+(fromLedger?'<br><b>候选值来自台账</b>:这台设备在台账里登记的抖音号会作为候选,'
+'手填的值<b>追加</b>在后面(台账取不到号时只用手填值,不会把任务打哑)。'
+'<br>⚠ 台账只提供<b>比对用的号</b>;<b>不要</b>把台账号填到「去重」的身份元素里'
+'(身份是元素原文、逐字比,格式不同会让去重静默失效)。':'')
+(fromPlatform?'<br>候选值由平台给(上面选的来源),手填的值会<b>追加</b>在后面。':'')
+'</div></div></div>';
// 「去重」条件:身份元素用 ident_type/ident_value(主选择器那行只剩类型下拉)
var identRow=isDedup?_selRowHtml(p,
@@ -857,7 +856,7 @@ var _stepEditor={
var self=this;
setTimeout(function(){self._renderCanvas();},0);
},
// 条件判断:候选值来源切到台账时,把"比对的值"藏起来(按分组还要显示分组名输入框)
// 条件判断:候选值来源切到"平台给"(本次要发的名字 / 发布计划)时,把"比对的值"藏起来
_cmpSourceChanged:function(sel){
// 同 _condTypeChanged:先把 DOM 的值同步回 _steps,否则重渲染会吞掉刚改的东西
this._syncFromDom();
@@ -981,17 +980,17 @@ var _stepEditor={
warnings.push(name+' "'+label+'": 条件判断的选择器为空,会直接走"未找到"分支。');
}
var cmpSrcV=(s.params.cmp_source||'');
var fromLedger=(cmpSrcV==='device'||cmpSrcV==='all'||cmpSrcV==='group');
// 候选值"从别处取"的那两档(台账 / 发布计划):手填可以留空,别报"没填值"
var fromOther=(fromLedger||cmpSrcV==='release');
var fromPlatform=(cmpSrcV==='account_target'||cmpSrcV==='release');
// 候选值"平台给"的那两档(本次要发的名字 / 发布计划):手填可以留空,别报"没填值"
var fromOther=fromPlatform;
if(cmpUsed&&s.params.cmp_op&&!String(s.params.cmp_value||'').trim()&&!fromOther){
warnings.push(name+' "'+label+'": 选了"文本比对('+s.params.cmp_op+')"但没填比对的值,'
+'执行时永远不命中(相当于每次都走"未找到时"分支)。');
}
if(cmpUsed&&fromLedger&&s.params.cmp_op){
warnings.push(name+' "'+label+'": 候选值来自台账 —— 台账里没有这台设备'
+'(或设备池里的设备名与台账「设备号」对不上)时会取不到号,'
+'这一步将永远走"未找到"分支。请到「账号」页核对设备号。');
if(cmpUsed&&cmpSrcV==='account_target'&&s.params.cmp_op){
warnings.push(name+' "'+label+'": 候选值来自"本次要发的名字" —— 前面要有'
+'「读取本机账号名字」步骤(或循环块设的目标名字),名字读不到时候选为空、'
+'这一步会走"未找到"分支。这是设计如此(读不到名字 = 不发)。');
}
if(cmpUsed&&cmpSrcV==='release'&&s.params.cmp_op){
warnings.push(name+' "'+label+'": 候选值来自"本机当前发布计划" —— 本机没有待发的计划'
-297
View File
@@ -1,297 +0,0 @@
// 账号台账(「账号」顶级 Tab)
// 数据在 device_account 表,服务层 core/ledger.py,接口 /api/ledger*(web/devices_api.py)。
//
// 这一页的三重价值:
// ① 一台设备登好几个号、十几台设备几十个号 —— 换 IP / 加号时不用再翻电子表格;
// ② 任务的「条件判断」可以直接从这里取号(cmp_source),不用手写一串抖音号;
// ③ 手机端 Agent 的「身份大字页」会显示本机账号(平台推 accounts_b64)。
//
// ⚠ 台账里的抖音号是**纯号**,只当"比对用的候选值";**别**拿它去填「去重」的身份元素
// ——身份是元素原文、逐字算 key,格式不同会让去重静默失效(core/ledger.py 顶部有警告)。
var _ledgerAll = []; // 全部账号(一次拉回,搜索/筛选在前端做:几十条量级)
var _ledgerStats = {};
var _ledgerDeviceNames = [];
var _ledgerCounts = {}; // {设备号: 号数}(设备池那一列用)
var _ledgerDevices = {}; // {设备号: {count, accounts[]}}(点开看明细用)
function loadLedger() {
var sel = document.getElementById('ledger-device');
var keep = sel ? sel.value : '';
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="9" class="empty">读取失败:' + esc((r && r.error) || '') + '</td></tr>';
return;
}
_ledgerAll = r.accounts || [];
_ledgerStats = r.stats || {};
_ledgerDeviceNames = r.device_names || [];
_renderLedgerDeviceFilter(keep);
_renderLedgerStats();
renderLedger();
});
}
function _renderLedgerDeviceFilter(keep) {
var sel = document.getElementById('ledger-device');
if (!sel) return;
var html = '<option value="">全部设备</option>';
_ledgerDeviceNames.forEach(function (n) {
html += '<option value="' + esc(n) + '">' + esc(n) + '</option>';
});
sel.innerHTML = html;
sel.value = keep || '';
}
function _renderLedgerStats() {
var el = document.getElementById('ledger-stats');
if (!el) return;
var s = _ledgerStats || {};
el.textContent = '共 ' + (s.total || 0) + ' 个号 · ' + (s.devices || 0) + ' 台设备 · '
+ (s.can_post_video || 0) + ' 个可发视频';
}
function ledgerDeviceChanged() { renderLedger(); }
function renderLedger() {
var sel = document.getElementById('ledger-device');
var dev = sel ? sel.value : '';
var rows = dev ? _ledgerAll.filter(function (a) { return a.device_name === dev; }) : _ledgerAll;
setListPager('ledger', {
data: function () { return rows; },
filter: function (a, kw) {
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="9" class="empty">还没有账号 —— 点「粘贴导入」把你那张表贴进来</td></tr>'
});
applyListPager('ledger');
initListSearch('ledger', 'search-ledger');
}
function _yn(v) {
return v ? '<span class="label label-success">是</span>'
: '<span class="label label-default">否</span>';
}
function _clip(s, n) {
var t = String(s || '');
if (!t) return '';
return '<span title="' + esc(t) + '">' + esc(t.length > n ? t.slice(0, n) + '…' : t) + '</span>';
}
function renderLedgerRow(a) {
return '<tr>' +
'<td><strong>' + esc(a.device_name || '-') + '</strong></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>' +
'<td>' + _yn(a.sim_in_device) + '</td>' +
'<td>' + _yn(a.can_post_video) + '</td>' +
'<td>' + _clip(a.bio, 12) + '</td>' +
'<td>' + _clip(a.note, 12) + '</td>' +
'<td>' +
'<button class="btn btn-xs" onclick="openLedgerModal(\'' + a.id + '\')" data-perm="devices">编辑</button> ' +
'<button class="btn btn-xs btn-danger" data-id="' + a.id + '" ' +
'onclick="deleteLedger(this.dataset.id)" data-perm="devices">删除</button>' +
'</td></tr>';
}
function _ledgerById(id) {
for (var i = 0; i < _ledgerAll.length; i++) {
if (_ledgerAll[i].id === id) return _ledgerAll[i];
}
return null;
}
// ================== 单条新增 / 编辑 ==================
function _ledgerField(label, key, val, ph) {
return '<div class="form-group"><label>' + label + '</label>' +
'<input class="form-control" data-lf="' + key + '" value="' + esc(val || '') + '" ' +
'placeholder="' + (ph || '') + '"></div>';
}
function openLedgerModal(id) {
var a = id ? _ledgerById(id) : null;
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(要和设备池里的设备名一致)') +
'<div class="form-row">' +
_ledgerField('抖音号', 'douyin_id', a && a.douyin_id, '纯号,如 35377983067(不带「抖音号:」前缀)') +
_ledgerField('手机号', 'phone', a && a.phone, '') + '</div>' +
'<div class="form-row">' +
_ledgerField('注册时间', 'registered_at', a && a.registered_at, '如 2026/9/24(原样存)') +
'<div class="form-group"><label>卡在机内 / 可发视频</label>' +
'<div style="display:flex;gap:16px;align-items:center;padding-top:4px">' +
'<label style="display:inline-flex;gap:4px;align-items:center"><input type="checkbox" data-lf="sim_in_device" '
+ (a && a.sim_in_device ? 'checked' : '') + '> 卡在机内</label>' +
'<label style="display:inline-flex;gap:4px;align-items:center"><input type="checkbox" data-lf="can_post_video" '
+ (a && a.can_post_video ? 'checked' : '') + '> 可发视频</label>' +
'</div></div></div>' +
'<div class="form-group"><label>简介</label><input class="form-control" data-lf="bio" value="'
+ esc(a && a.bio || '') + '"></div>' +
'<div class="form-group"><label>备注</label><input class="form-control" data-lf="note" value="'
+ esc(a && a.note || '') + '"></div>' +
'<div class="help">抖音号在整个台账里必须唯一(一个号只能有一条记录)。' +
'<b>设备号</b>要和「工具 → 设备池」里的设备名一致 —— 任务的「本机台账」取号、' +
'设备端身份页显示本机账号都靠它匹配。</div>';
document.getElementById('modal-body').innerHTML = h;
document.getElementById('modal-footer').innerHTML =
'<button class="btn" onclick="closeModal()">取消</button>' +
'<button class="btn btn-primary" onclick="saveLedger(\'' + (a ? a.id : '') + '\')">保存</button>';
showModal();
}
function _ledgerFormValues() {
var out = {};
document.querySelectorAll('#modal-body [data-lf]').forEach(function (el) {
var k = el.dataset.lf;
out[k] = (el.type === 'checkbox') ? !!el.checked : el.value;
});
return out;
}
function saveLedger(id) {
var body = _ledgerFormValues();
var p = id ? apiPut('/api/ledger/' + id, body) : apiPost('/api/ledger', body);
p.then(function (r) {
if (r && r.ok) { closeModal(); showToast(r.msg || '已保存', 'success'); loadLedger(); }
else showToast('保存失败: ' + ((r && r.error) || ''), 'error');
});
}
function deleteLedger(id) {
var a = _ledgerById(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(); }
else showToast('删除失败: ' + ((r && r.error) || ''), 'error');
});
}
// ================== 粘贴导入 ==================
function openLedgerImport() {
document.getElementById('modal-title').textContent = '粘贴导入账号台账';
document.getElementById('modal-box').classList.add('wide-modal');
document.getElementById('modal-body').innerHTML =
'<div class="help" style="margin-bottom:8px">' +
'把 Excel 里的表格<b>连表头一起</b>复制,粘到下面。首行是列名,顺序随意、缺列留空。<br>' +
'认得的列名:<code>设备号 / 手机号 / 抖音号 / 注册时间 / 卡在机内 / 可发视频 / 简介 / 备注</code>(<b>账号名称列会被忽略</b>)' +
'(认不出的列会被忽略)。<b>没有表头</b>时按上面这个顺序解析。<br>' +
'「卡在机内 / 可发视频」认 <code>是/有/1</code> 与 <code>否/空</code>。' +
'</div>' +
'<div class="form-row" style="align-items:end">' +
'<div class="form-group"><label>分隔符</label>' +
'<select class="form-control" id="ledger-import-delim">' +
'<option value="tab">Tab(Excel 直接粘贴)</option>' +
'<option value="|">竖线 |</option><option value=",">逗号 ,</option></select></div>' +
'<div class="form-group"><label>遇到已存在的抖音号</label>' +
'<select class="form-control" id="ledger-import-mode">' +
'<option value="skip">跳过(不动已有记录)</option>' +
'<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="设备号&#9;手机号&#9;抖音号&#9;注册时间&#9;卡在机内&#9;可发视频&#9;简介&#9;备注&#10;A01&#9;13800000000&#9;35000000001&#9;2026/9/24&#9;否&#9;是&#9;&#9;"></textarea></div>' +
'<div id="ledger-import-result"></div>';
document.getElementById('modal-footer').innerHTML =
'<button class="btn" onclick="closeModal()">取消</button>' +
'<button class="btn" onclick="ledgerImport(true)">先预览</button>' +
'<button class="btn btn-primary" onclick="ledgerImport(false)">直接导入</button>';
showModal();
}
function ledgerImport(dryRun) {
var text = (document.getElementById('ledger-import-text') || {}).value || '';
if (!text.trim()) { showToast('先粘贴表格内容', 'error'); return; }
var delim = (document.getElementById('ledger-import-delim') || {}).value || 'tab';
var mode = (document.getElementById('ledger-import-mode') || {}).value || 'skip';
var box = document.getElementById('ledger-import-result');
if (box) box.innerHTML = '<div class="text-muted">' + (dryRun ? '解析中…' : '导入中…') + '</div>';
apiPost('/api/ledger/import', { text: text, delimiter: delim, mode: mode, dry_run: !!dryRun })
.then(function (r) {
if (!r || !r.ok) {
if (box) box.innerHTML = '<div class="text-danger">失败:' + esc((r && r.error) || '') + '</div>';
return;
}
renderImportResult(r);
if (!dryRun) { loadLedger(); loadLedgerCounts(); }
});
}
var _IMP_ACTION = {
add: ['label-success', '新增'], update: ['label-warning', '覆盖'],
skip: ['label-default', '跳过'], fail: ['label-danger', '失败']
};
function renderImportResult(r) {
var box = document.getElementById('ledger-import-result');
if (!box) return;
var c = r.counts || {};
var head = '<div style="margin:10px 0 6px"><b>' + (r.dry_run ? '预览结果(还没有写入)' : '导入结果')
+ '</b> 共 ' + (c.total || 0) + ' 行:新增 ' + (c.added || 0) + ' · 覆盖 ' + (c.updated || 0)
+ ' · 跳过 ' + (c.skipped || 0) + ' · 失败 ' + (c.failed || 0) + '</div>';
var rows = (r.rows || []).map(function (x) {
var a = _IMP_ACTION[x.action] || ['label-default', x.action];
var msg = x.error || (x.warns && x.warns.length ? x.warns.join(';') : '');
return '<tr><td>' + esc(x.line) + '</td>' +
'<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 style="color:#f59e0b">' + esc(msg) + '</td></tr>';
}).join('');
var errs = (r.errors || []).map(function (e) {
return '<li>第 ' + esc(e.line) + ' 行:' + esc(e.reason) + '</li>';
}).join('');
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></tr></thead>' +
'<tbody>' + (rows || '<tr><td colspan="6" class="empty">没有可导入的行</td></tr>') + '</tbody></table></div>' +
(r.dry_run ? '<div class="help">确认没问题后点「直接导入」。</div>' : '');
}
function _importErrorsNote(errs) {
return errs ? ('<div class="help" style="color:#f59e0b">这些行被跳过了(不会导入):<ul>' + errs + '</ul></div>') : '';
}
// ================== 设备维度:设备池那一列的"账号 N" ==================
function loadLedgerCounts() {
return apiGet('/api/ledger/by_device').then(function (r) {
if (r && r.ok) { _ledgerCounts = r.counts || {}; _ledgerDevices = r.devices || {}; }
return _ledgerCounts;
}).catch(function () { return _ledgerCounts; });
}
function ledgerCell(serial, name) {
var n = _ledgerCounts[name] || 0;
if (!n) return '<span class="label label-default">未登记</span>';
return '<button class="btn btn-xs" onclick="showDeviceAccounts(\'' + esc(name) + '\')">' + n + ' 个号</button>';
}
function showDeviceAccounts(name) {
var g = _ledgerDevices[name];
if (!g) {
// 没缓存过就现拉一次(例如直接打开设备池)
loadLedgerCounts().then(function () { showDeviceAccounts(name); });
return;
}
document.getElementById('modal-title').textContent = name + ' 上的账号(' + (g.count || 0) + ' 个)';
var rows = (g.accounts || []).map(function (a) {
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>' +
'<td>' + esc(a.registered_at || '-') + '</td><td>' + _clip(a.note, 16) + '</td></tr>';
}).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></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();
}
+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']],
+2 -10
View File
@@ -517,13 +517,9 @@ async function loadDevPool(){
const tb=document.getElementById('tb-devpool');
const status=document.getElementById('devpool-status');
if(!tb)return;
tb.innerHTML='<tr><td colspan="9" class="empty">加载中...</td></tr>';
tb.innerHTML='<tr><td colspan="8" class="empty">加载中...</td></tr>';
const r=await apiGet('/api/devices/pool');
if(!r||!r.ok){tb.innerHTML='<tr><td colspan="9" class="empty">加载失败</td></tr>';return;}
// 账号台账:这台设备登记了几个号(顺手把明细也缓存下来,点开就弹)
if(typeof loadLedgerCounts==='function'){
try{ await loadLedgerCounts(); }catch(e){}
}
if(!r||!r.ok){tb.innerHTML='<tr><td colspan="8" class="empty">加载失败</td></tr>';return;}
let rows=r.devices||[];
// 在线优先排序;「只看在线」勾选时过滤掉离线(IP 换了/长期离线的旧条目不再碍眼)
const onlyOnline=document.getElementById('devpool-filter-online')
@@ -556,10 +552,6 @@ async function loadDevPool(){
'<td>'+online+'</td>'+
'<td>'+st+'</td>'+
'<td style="text-align:center">'+fp+'</td>'+
// 账号台账(「账号」页维护):0 个号显示"未登记"而不是空白 ——
// 空白会让人以为功能坏了,而"未登记"是明确信息
'<td style="white-space:nowrap">'+(typeof ledgerCell==='function'
? ledgerCell(d.serial, d.name||'') : '—')+'</td>'+
'<td style="color:var(--text-light)">'+esc(d.created_at||'')+'</td>'+
'<td style="white-space:nowrap">'+
'<button class="btn btn-xs" onclick="renamePoolDev(\''+esc(d.serial)+'\')">改名</button> '+