一、账号台账(新表 device_account,schema v8) - core/ledger.py:CRUD、Excel 粘贴解析(Tab 分隔 / 表头乱序缺列 / 是·否布尔 / 逐行留痕)、 按范围取号(本机台账 / 全部 / 按设备分组)、设备端账号块、dry_run 预览 - 新「账号」顶级 Tab(权限 devices):列表 + 搜索 + 设备筛选 + 增删改 + 粘贴导入(默认先预览再写,逐行显示 新增/覆盖/跳过/失败 + 留痕) - 设备池加「账号 N」列(0 个显示"未登记"),点开看该设备账号明细 - 任务「条件判断」新增 cmp_source / cmp_group:候选值可直接来自台账, 与手填值**合并(OR)**;取不到号时回落手填值,并把原因写进步骤明细与日志 (否则"永远走 else 分支"而任务照样显示成功,最难查) - 设备端契约:身份页多推 accounts_b64(base64 JSON;默认不含手机号; 超预算按整条丢且绝不算字节切),见 doc/DEVICE_AGENT.md §5.2.1 - 备份/文档红线:TABLE_LABELS 加「账号台账」;DATA_MODEL/API/ARCHITECTURE/DEPLOY/ TASK_DEV/DEVICE_AGENT/DEVELOPMENT/README 同步;顺手补上 DATA_MODEL 漏列的 done_mark ⚠ 台账里的抖音号是**纯号**,只当"比对用的候选值":绝不能拿去填「去重」的身份元素 (身份是元素原文逐字算 key,格式不同会让去重静默失效)。代码与文档都写明了。 二、剪贴板注入通道(修:平台还在调早期的独立 APK) - 改为按顺序尝试:设备端 Agent(com.example.deviceagent/.ClipActivity)→ 旧版独立 ClipInject 兜底;两个都没有时报"需要设备端 Agent"(不再只说 ClipInject) - 读回验证改成**轮询到 3 秒**:透明 Activity 要等窗口拿到焦点才写, 原来只睡 0.5s 会读到上一次的内容 → 误报"写入可能被拒"(实测内容已写入却回失败)
301 lines
15 KiB
JavaScript
301 lines
15 KiB
JavaScript
// 账号台账(「账号」顶级 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="10" 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.nickname || '')
|
||
+ (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>'
|
||
});
|
||
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>' + 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>' +
|
||
'<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.nickname || a.douyin_id)) : '新增账号';
|
||
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>' +
|
||
'<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.nickname || '') + ' / ' + (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>时按上面这个顺序解析。<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="设备号	手机号	账号名称	抖音号	注册时间	卡在机内	可发视频	简介	备注 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>' +
|
||
'<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>' + esc(x.nickname || '') + '</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><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>' + 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>' + _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><th>备注</th></tr></thead>' +
|
||
'<tbody>' + (rows || '<tr><td colspan="7" class="empty">这台设备还没有登记账号</td></tr>') + '</tbody></table></div>' +
|
||
'<div class="help">在「账号」页可以增删改;设备端 Agent 的「身份大字页」显示的就是这些。</div>';
|
||
document.getElementById('modal-footer').innerHTML = '<button class="btn" onclick="closeModal()">关闭</button>';
|
||
showModal();
|
||
}
|