// 账号台账(「账号」顶级 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 = '读取失败:' + esc((r && r.error) || '') + ''; 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 = ''; _ledgerDeviceNames.forEach(function (n) { html += ''; }); 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: '还没有账号 —— 点「粘贴导入」把你那张表贴进来' }); applyListPager('ledger'); initListSearch('ledger', 'search-ledger'); } function _yn(v) { return v ? '是' : '否'; } function _clip(s, n) { var t = String(s || ''); if (!t) return ''; return '' + esc(t.length > n ? t.slice(0, n) + '…' : t) + ''; } function renderLedgerRow(a) { return '' + '' + esc(a.device_name || '-') + '' + '' + esc(a.nickname || '-') + '' + '' + esc(a.douyin_id || '-') + '' + '' + esc(a.phone || '-') + '' + '' + esc(a.registered_at || '-') + '' + '' + _yn(a.sim_in_device) + '' + '' + _yn(a.can_post_video) + '' + '' + _clip(a.bio, 12) + '' + '' + _clip(a.note, 12) + '' + '' + ' ' + '' + ''; } 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 '
' + '
'; } function openLedgerModal(id) { var a = id ? _ledgerById(id) : null; document.getElementById('modal-title').textContent = a ? ('编辑账号:' + (a.nickname || a.douyin_id)) : '新增账号'; var h = '
' + _ledgerField('设备号', 'device_name', a && a.device_name, '如 A01(要和设备池里的设备名一致)') + _ledgerField('账号名称', 'nickname', a && a.nickname, '如 AA建材王总') + '
' + '
' + _ledgerField('抖音号', 'douyin_id', a && a.douyin_id, '纯号,如 35377983067(不带「抖音号:」前缀)') + _ledgerField('手机号', 'phone', a && a.phone, '') + '
' + '
' + _ledgerField('注册时间', 'registered_at', a && a.registered_at, '如 2026/9/24(原样存)') + '
' + '
' + '' + '' + '
' + '
' + '
' + '
抖音号在整个台账里必须唯一(一个号只能有一条记录)。' + '设备号要和「工具 → 设备池」里的设备名一致 —— 任务的「本机台账」取号、' + '设备端身份页显示本机账号都靠它匹配。
'; document.getElementById('modal-body').innerHTML = h; document.getElementById('modal-footer').innerHTML = '' + ''; 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 = '
' + '把 Excel 里的表格连表头一起复制,粘到下面。首行是列名,顺序随意、缺列留空。
' + '认得的列名:设备号 / 手机号 / 账号名称 / 抖音号 / 注册时间 / 卡在机内 / 可发视频 / 简介 / 备注' + '(认不出的列会被忽略)。没有表头时按上面这个顺序解析。
' + '「卡在机内 / 可发视频」认 是/有/1 与 否/空。' + '
' + '
' + '
' + '
' + '
' + '
' + '
' + '
' + '
'; document.getElementById('modal-footer').innerHTML = '' + '' + ''; 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 = '
' + (dryRun ? '解析中…' : '导入中…') + '
'; apiPost('/api/ledger/import', { text: text, delimiter: delim, mode: mode, dry_run: !!dryRun }) .then(function (r) { if (!r || !r.ok) { if (box) box.innerHTML = '
失败:' + esc((r && r.error) || '') + '
'; 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 = '
' + (r.dry_run ? '预览结果(还没有写入)' : '导入结果') + ' 共 ' + (c.total || 0) + ' 行:新增 ' + (c.added || 0) + ' · 覆盖 ' + (c.updated || 0) + ' · 跳过 ' + (c.skipped || 0) + ' · 失败 ' + (c.failed || 0) + '
'; 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 '' + esc(x.line) + '' + '' + a[1] + '' + '' + esc(x.device_name || '') + '' + '' + esc(x.douyin_id || '') + '' + '' + esc(x.nickname || '') + '' + '' + esc(msg) + ''; }).join(''); var errs = (r.errors || []).map(function (e) { return '
  • 第 ' + esc(e.line) + ' 行:' + esc(e.reason) + '
  • '; }).join(''); box.innerHTML = head + _importErrorsNote(errs) + '
    ' + '' + '' + (rows || '') + '
    行动作设备号抖音号账号名称说明
    没有可导入的行
    ' + (r.dry_run ? '
    确认没问题后点「直接导入」。
    ' : ''); } function _importErrorsNote(errs) { return errs ? ('
    这些行被跳过了(不会导入):
    ') : ''; } // ================== 设备维度:设备池那一列的"账号 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 '未登记'; return ''; } 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 '' + esc(a.nickname || '-') + '' + '' + esc(a.douyin_id || '-') + '' + '' + esc(a.phone || '-') + '' + '' + _yn(a.sim_in_device) + '' + _yn(a.can_post_video) + '' + '' + esc(a.registered_at || '-') + '' + _clip(a.note, 16) + ''; }).join(''); document.getElementById('modal-body').innerHTML = '
    ' + '' + '' + (rows || '') + '
    账号名称抖音号手机号卡在机内可发视频注册时间备注
    这台设备还没有登记账号
    ' + '
    在「账号」页可以增删改;设备端 Agent 的「身份大字页」显示的就是这些。
    '; document.getElementById('modal-footer').innerHTML = ''; showModal(); }