Files
2026-07-17 09:24:47 +08:00

373 lines
13 KiB
HTML

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>抖音一键采集器</title>
<style>
:root {
--bg: #0f1020;
--panel: #1a1b2e;
--border: #2c2e4a;
--text: #e6e6f0;
--muted: #9aa0c0;
--accent: #8b5cf6;
--accent2: #a855f7;
--ok: #34d399;
--warn: #fbbf24;
--err: #f87171;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
background: radial-gradient(1200px 600px at 70% -10%, #2a1c4a55, transparent), var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", sans-serif;
line-height: 1.6;
padding: 32px 16px 60px;
}
.wrap { max-width: 880px; margin: 0 auto; }
h1 { font-size: 26px; margin: 0 0 6px; }
.sub { color: var(--muted); font-size: 14px; margin: 0 0 24px; }
.pill {
font-size: 11px; color: var(--muted); border: 1px solid var(--border);
padding: 2px 10px; border-radius: 999px; vertical-align: middle;
}
.card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 14px;
padding: 22px 24px;
margin-bottom: 18px;
}
.card-accent { border-color: #5b3f8a; }
.steps { margin: 0; padding-left: 20px; color: var(--muted); font-size: 14px; }
.steps li { margin: 6px 0; }
.steps strong { color: var(--text); }
.spinner-wrap { text-align: center; padding: 28px 0 12px; }
.spinner {
width: 48px; height: 48px; margin: 0 auto 16px;
border: 3px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin .8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.status-msg { text-align: center; color: var(--muted); font-size: 14px; min-height: 24px; }
.checks { display: grid; gap: 8px; margin: 16px 0; }
.chk { display: flex; align-items: center; gap: 10px; font-size: 14px; }
.dot { width: 9px; height: 9px; border-radius: 50%; flex: none; }
.ok .dot { background: var(--ok); } .ok { color: var(--ok); }
.warn .dot { background: var(--warn); } .warn { color: var(--warn); }
.err .dot { background: var(--err); } .err { color: var(--err); }
textarea {
width: 100%; min-height: 220px; resize: vertical;
background: #0c0d1a; color: #cbd5e1;
border: 1px solid var(--border); border-radius: 10px;
padding: 14px; font-family: ui-monospace, Consolas, monospace; font-size: 12px;
outline: none; line-height: 1.5;
}
textarea:focus { border-color: var(--accent); }
button {
background: linear-gradient(135deg, var(--accent), var(--accent2));
color: #fff; border: none; border-radius: 10px;
padding: 12px 24px; font-size: 15px; font-weight: 600; cursor: pointer;
}
button:hover { opacity: .92; }
button.ghost {
background: transparent; border: 1px solid var(--border); color: var(--muted);
font-weight: 500; font-size: 13px; padding: 10px 16px;
}
button.big { padding: 14px 32px; font-size: 16px; }
.row { display: flex; gap: 12px; flex-wrap: wrap; align-items: center; margin-top: 14px; }
.hint {
background: #1e1b3a; border: 1px solid #3b2f6b; border-radius: 10px;
padding: 12px 16px; font-size: 13px; color: #cbd5e1; margin-top: 16px;
}
.hint ol { margin: 8px 0 0; padding-left: 18px; }
.banner-ok {
background: linear-gradient(135deg, #1c3b2e, #1a2b24);
border: 1px solid #2f5b39; border-radius: 12px;
padding: 16px 20px; margin-bottom: 18px;
}
.banner-ok h2 { margin: 0 0 4px; font-size: 18px; color: var(--ok); }
.banner-warn h2 { color: var(--warn); }
.banner-err h2 { color: var(--err); }
.hidden { display: none !important; }
.toast {
position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
background: #111; border: 1px solid var(--accent); color: #fff;
padding: 10px 20px; border-radius: 10px; opacity: 0; transition: opacity .2s;
pointer-events: none; font-size: 14px; z-index: 99;
}
.toast.show { opacity: 1; }
.uid-tag {
display: inline-block; background: #2a1c4a; border: 1px solid #5b3f8a;
padding: 2px 10px; border-radius: 8px; font-size: 13px; color: #c4b5fd;
margin-left: 8px;
}
.steps-bar {
display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 18px;
}
.step-item {
flex: 1; min-width: 120px; text-align: center; padding: 10px 8px;
border-radius: 10px; border: 1px solid var(--border); color: var(--muted);
font-size: 13px; background: #121325;
}
.step-item.active {
border-color: var(--accent); color: var(--text);
background: linear-gradient(135deg, #2a1c4a, #1a1b2e);
}
.step-item.done { border-color: #2f5b39; color: var(--ok); }
.log-box {
margin-top: 16px; max-height: 140px; overflow: auto;
background: #0c0d1a; border: 1px solid var(--border); border-radius: 10px;
padding: 10px 12px; font-size: 12px; color: #94a3b8;
font-family: ui-monospace, Consolas, monospace; line-height: 1.5;
}
.log-box div { margin: 2px 0; }
.top-bar {
display: flex; justify-content: space-between; align-items: center;
flex-wrap: wrap; gap: 10px; margin-bottom: 8px;
}
</style>
</head>
<body>
<div class="wrap">
<div class="top-bar">
<div>
<h1 style="margin:0;">抖音一键采集器 <span class="pill">无痕窗口 · 不上传</span></h1>
<p class="sub" style="margin:8px 0 0;">登录与复制凭证均在同一无痕 Chrome 内完成。<span id="runTag" class="pill" style="margin-left:6px;"></span></p>
</div>
<button class="ghost" id="btnQuit" title="关闭采集器">退出</button>
</div>
<div class="steps-bar" id="stepsBar">
<div class="step-item active" data-step="1">① 启动</div>
<div class="step-item" data-step="2">② 扫码登录</div>
<div class="step-item" data-step="3">③ 自动采集</div>
<div class="step-item" data-step="4">④ 复制凭证</div>
</div>
<!-- 采集中 -->
<div id="phaseWaiting" class="card card-accent">
<div class="spinner-wrap">
<div class="spinner"></div>
<p class="status-msg" id="waitMsg">正在启动…</p>
</div>
<ol class="steps">
<li>切换到<strong>「② 抖音登录」</strong>标签页扫码登录</li>
<li>登录后会<strong>自动打开私信页</strong>,等消息列表出现即可</li>
<li>回到<strong>本标签页(① 凭证采集)</strong>复制凭证,去客服系统粘贴</li>
</ol>
<div class="row" style="justify-content:center;margin-top:20px;">
<button class="big" id="btnStart">我已打开私信页,立即采集</button>
</div>
<p style="text-align:center;color:var(--muted);font-size:12px;margin:10px 0 0;">一般无需点击,约 15 秒后会自动开始</p>
<div class="log-box" id="logBox"></div>
</div>
<!-- 采集完成 -->
<div id="phaseDone" class="hidden">
<div id="banner" class="banner-ok">
<h2 id="bannerTitle">✓ 采集完成</h2>
<p id="bannerSub" style="margin:0;font-size:14px;color:#cbd5e1;"></p>
</div>
<div class="card">
<div style="font-weight:600;margin-bottom:8px;">凭证检查</div>
<div class="checks" id="checks"></div>
</div>
<div class="card">
<div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:8px;margin-bottom:10px;">
<span style="font-weight:600;">凭证 JSON <span id="uidTag" class="uid-tag hidden"></span></span>
<div class="row" style="margin:0;">
<button class="big" id="btnCopy">📋 一键复制凭证</button>
<button class="ghost" id="btnSelect">全选</button>
</div>
</div>
<textarea id="output" readonly placeholder="凭证将显示在这里…"></textarea>
<div class="hint">
<strong>下一步:</strong>
<ol>
<li>点击「一键复制凭证」(凭证齐全时会自动复制)</li>
<li>打开客服系统 → <strong>账号管理</strong> → 编辑账号 → 粘贴到 Cookie 框 → 保存 → 启动托管</li>
</ol>
</div>
</div>
</div>
<!-- 失败 -->
<div id="phaseError" class="card hidden" style="border-color:#7f1d1d;">
<h2 style="margin:0 0 8px;color:var(--err);">采集失败</h2>
<p id="errorMsg" style="color:var(--muted);margin:0;"></p>
</div>
</div>
<div class="toast" id="toast"></div>
<script>
const $ = id => document.getElementById(id);
function toast(msg) {
const t = $('toast');
t.textContent = msg;
t.classList.add('show');
clearTimeout(t._t);
t._t = setTimeout(() => t.classList.remove('show'), 2000);
}
function renderChecks(list) {
$('checks').innerHTML = (list || []).map(c =>
`<div class="chk ${c.s}"><span class="dot"></span>${c.t}</div>`
).join('');
}
function renderSteps(step) {
const n = Number(step) || 1;
document.querySelectorAll('.step-item').forEach(el => {
const s = Number(el.dataset.step);
el.classList.remove('active', 'done');
if (s < n) el.classList.add('done');
else if (s === n) el.classList.add('active');
});
}
function renderLogs(lines) {
const box = $('logBox');
if (!box) return;
const list = lines || [];
box.innerHTML = list.slice(-12).map(l => `<div>${l.replace(/</g,'&lt;')}</div>`).join('') || '<div style="opacity:.5">等待日志…</div>';
box.scrollTop = box.scrollHeight;
}
function copyText(text) {
if (!text) { toast('没有可复制的内容'); return; }
navigator.clipboard.writeText(text).then(() => toast('✓ 已复制到剪贴板,可去系统粘贴'))
.catch(() => {
$('output').select();
document.execCommand('copy');
toast('✓ 已复制到剪贴板');
});
}
$('btnCopy').onclick = () => copyText($('output').value);
$('btnSelect').onclick = () => { $('output').select(); };
async function requestStart() {
const btn = $('btnStart');
btn.disabled = true;
btn.textContent = '正在开始…';
try {
const r = await fetch('/api/start', { method: 'POST' });
const data = await r.json();
if (data.ok) {
toast('已开始采集');
btn.textContent = '采集中…';
} else {
btn.disabled = false;
btn.textContent = '我已打开私信页,立即采集';
toast('请稍候,采集器还在启动');
}
} catch (e) {
btn.disabled = false;
btn.textContent = '我已打开私信页,立即采集';
toast('连接失败,请稍候或切换到「② 抖音登录」标签页');
}
}
$('btnStart').onclick = requestStart;
async function requestQuit() {
try { await fetch('/api/quit', { method: 'POST' }); } catch (e) {}
toast('正在退出…');
setTimeout(() => window.close(), 800);
}
$('btnQuit').onclick = requestQuit;
function showDone(data) {
$('phaseWaiting').classList.add('hidden');
$('phaseDone').classList.remove('hidden');
renderSteps(4);
renderChecks(data.checks || []);
const json = data.result_text || '';
$('output').value = json;
const ok = data.ok_core;
const banner = $('banner');
banner.className = ok ? 'banner-ok' : 'banner-ok banner-warn';
$('bannerTitle').textContent = ok ? '✓ 采集完成,凭证齐全' : '⚠ 采集完成,部分凭证缺失';
$('bannerSub').textContent = data.summary || '';
if (data.my_uid) {
$('uidTag').textContent = 'UID: ' + data.my_uid;
$('uidTag').classList.remove('hidden');
}
// 凭证齐全时自动复制
if (ok && json) {
setTimeout(() => copyText(json), 400);
}
}
function showError(msg) {
$('phaseWaiting').classList.add('hidden');
$('phaseError').classList.remove('hidden');
$('errorMsg').textContent = msg || '未知错误';
}
let lastRunId = '';
function resetUiForNewRun() {
$('phaseWaiting').classList.remove('hidden');
$('phaseDone').classList.add('hidden');
$('phaseError').classList.add('hidden');
$('waitMsg').textContent = '正在启动…';
renderSteps(1);
renderLogs([]);
const btn = $('btnStart');
if (btn) { btn.disabled = false; btn.textContent = '我已打开私信页,立即采集'; }
}
async function poll() {
try {
const r = await fetch('/api/status', { cache: 'no-store' });
const data = await r.json();
if (data.run_id && data.run_id !== lastRunId) {
lastRunId = data.run_id;
resetUiForNewRun();
}
if (data.port) {
$('runTag').textContent = '会话 ' + (data.run_id || '') + ' · 端口 ' + data.port;
}
renderSteps(data.step);
renderLogs(data.logs);
if (data.phase === 'waiting') {
$('waitMsg').textContent = data.message || '等待登录并采集…';
} else if (data.phase === 'collecting') {
$('waitMsg').textContent = data.message || '正在采集凭证…';
} else if (data.phase === 'done') {
showDone(data);
return;
} else if (data.phase === 'error') {
const msg = data.message || '未知错误';
if (/cursor-sandbox-cache/i.test(msg)) {
showError(msg + '\n\n请关闭本页后重新双击「一键采集.bat」。');
} else {
showError(msg);
}
return;
}
} catch (e) {
$('waitMsg').textContent = '正在连接采集器…';
}
setTimeout(poll, 800);
}
poll();
</script>
</body>
</html>