Files
auto_control/static/admin/ledger.js
T
butubb 7ce4ae9016 feat(账号): 账号台账(web 页 + 任务取号 + 设备端身份页);fix(剪贴板): 注入通道改走设备端 Agent
一、账号台账(新表 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 会读到上一次的内容 → 误报"写入可能被拒"(实测内容已写入却回失败)
2026-09-24 16:12:58 +08:00

301 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 账号台账(「账号」顶级 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="设备号&#9;手机号&#9;账号名称&#9;抖音号&#9;注册时间&#9;卡在机内&#9;可发视频&#9;简介&#9;备注&#10;A01&#9;13800000000&#9;示例账号&#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>' + 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();
}