| <!DOCTYPE html> |
| <html lang="zh-CN"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>Anuma 注册管理器</title> |
| <style> |
| * { box-sizing: border-box; } |
| body { margin: 0; background-color: #f8f9fa; padding-top: 2rem; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Arial, sans-serif; font-size: 14px; color: #212529; } |
| .container-fluid { width: 100%; margin: 0 auto; } |
| .px-4 { padding-left: 1.5rem; padding-right: 1.5rem; } |
| .row { display: flex; flex-wrap: wrap; margin-left: -0.75rem; margin-right: -0.75rem; } |
| .col-md-4, .col-md-8 { padding-left: 0.75rem; padding-right: 0.75rem; width: 100%; } |
| @media (min-width: 768px) { .col-md-4 { width: 33.333333%; } .col-md-8 { width: 66.666667%; } } |
| .card { margin-bottom: 1.5rem; background: #fff; border: 1px solid rgba(0,0,0,.125); border-radius: 0.5rem; box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075); overflow: hidden; } |
| .card-header { padding: 0.75rem 1rem; border-bottom: 1px solid rgba(0,0,0,.125); font-weight: 600; } |
| .card-body { padding: 1rem; } |
| .bg-primary { background-color: #0d6efd !important; } |
| .bg-success { background-color: #198754 !important; } |
| .text-white { color: #fff !important; } |
| .text-muted { color: #6c757d !important; } |
| .text-break { word-break: break-word; } |
| .mb-2 { margin-bottom: 0.5rem; } |
| .mb-3 { margin-bottom: 1rem; } |
| .me-2 { margin-right: 0.5rem; } |
| .d-flex { display: flex; } |
| .justify-content-between { justify-content: space-between; } |
| .align-items-center { align-items: center; } |
| .d-grid { display: grid; } |
| .gap-2 { gap: 0.5rem; } |
| .form-label { display: block; margin-bottom: 0.5rem; font-weight: 500; } |
| .form-control, .form-select { display: block; width: 100%; padding: 0.375rem 0.75rem; font-size: 1rem; line-height: 1.5; color: #212529; background-color: #fff; border: 1px solid #ced4da; border-radius: 0.375rem; } |
| textarea.form-control { min-height: 70px; resize: vertical; } |
| .btn { display: inline-block; padding: 0.375rem 0.75rem; font-size: 1rem; line-height: 1.5; text-align: center; cursor: pointer; border: 1px solid transparent; border-radius: 0.375rem; background: #fff; color: #212529; } |
| .btn:disabled { opacity: .65; cursor: not-allowed; } |
| .btn-sm { padding: 0.25rem 0.5rem; font-size: 0.875rem; border-radius: 0.25rem; } |
| .btn-success { color: #fff; background-color: #198754; border-color: #198754; } |
| .btn-danger { color: #fff; background-color: #dc3545; border-color: #dc3545; } |
| .btn-secondary { color: #fff; background-color: #6c757d; border-color: #6c757d; } |
| .btn-outline-primary { color: #0d6efd; border-color: #0d6efd; } |
| .btn-outline-success { color: #198754; border-color: #198754; } |
| .btn-outline-danger { color: #dc3545; border-color: #dc3545; } |
| .btn-outline-secondary { color: #6c757d; border-color: #6c757d; } |
| .btn-group { display: inline-flex; vertical-align: middle; gap: 0.25rem; } |
| .table-responsive { max-height: 500px; overflow-y: auto; width: 100%; } |
| .table { width: 100%; margin-bottom: 1rem; border-collapse: collapse; vertical-align: top; } |
| .table th, .table td { padding: 0.5rem; border-bottom: 1px solid #dee2e6; text-align: left; } |
| .table-light { background-color: #f8f9fa; } |
| .badge { display: inline-block; padding: 0.35em 0.65em; font-size: .75em; font-weight: 700; line-height: 1; color: #fff; text-align: center; border-radius: 0.375rem; } |
| .bg-warning { background-color: #ffc107 !important; color: #000; } |
| .bg-info { background-color: #0dcaf0 !important; color: #000; } |
| .bg-secondary { background-color: #6c757d !important; } |
| hr { border: 0; border-top: 1px solid #dee2e6; margin: 1rem 0; } |
| #log-container { background-color: #212529; color: #f8f9fa; height: 300px; overflow-y: auto; font-family: monospace; font-size: 0.85rem; padding: 1rem; border-radius: 0.375rem; } |
| .status-success { color: #198754; font-weight: bold; } |
| .status-failed { color: #dc3545; font-weight: bold; } |
| .status-pending { color: #fd7e14; } |
| </style> |
| </head> |
| <body> |
| <div class="container-fluid px-4"> |
| <div class="row"> |
| |
| <div class="col-md-4"> |
| <div class="card"> |
| <div class="card-header bg-primary text-white">注册控制</div> |
| <div class="card-body"> |
| <form id="config-form"> |
| <div class="mb-3"> |
| <label class="form-label">邮箱后缀</label> |
| <select class="form-select" name="domain"> |
| <option value="">自动 (推荐)</option> |
| {% for domain in config.domains %} |
| <option value="{{ domain }}" {% if config.domain == domain %}selected{% endif %}>{{ domain }}</option> |
| {% endfor %} |
| </select> |
| </div> |
| <div class="mb-3"> |
| <label class="form-label">注册总数</label> |
| <input type="number" class="form-control" name="total" value="{{ config.total }}"> |
| </div> |
| <div class="mb-3"> |
| <label class="form-label">并发数</label> |
| <input type="number" class="form-control" name="concurrency" value="{{ config.concurrency }}"> |
| </div> |
| <div class="mb-3"> |
| <label class="form-label">验证码超时 (秒)</label> |
| <input type="number" class="form-control" name="timeout" value="{{ config.timeout }}"> |
| </div> |
| <div class="mb-3"> |
| <label class="form-label">轮询间隔 (秒)</label> |
| <input type="number" class="form-control" name="interval" value="{{ config.interval }}"> |
| </div> |
| <div class="d-grid gap-2"> |
| <button type="button" id="start-btn" class="btn btn-success">开始任务</button> |
| <button type="button" id="stop-btn" class="btn btn-danger" disabled>停止任务</button> |
| </div> |
| </form> |
| </div> |
| </div> |
|
|
| <div class="card"> |
| <div class="card-header bg-secondary text-white">手动导入账号 Token</div> |
| <div class="card-body"> |
| <div class="mb-2"> |
| <label class="form-label">Email</label> |
| <input type="email" class="form-control" id="import-email" placeholder="登录 Anuma 的邮箱"> |
| </div> |
| <div class="mb-2"> |
| <label class="form-label">Identity Token / Authorization Bearer</label> |
| <textarea class="form-control" id="import-token" rows="3" placeholder="粘贴 Bearer 后面的 eyJ... token"></textarea> |
| </div> |
| <div class="mb-2"> |
| <label class="form-label">Refresh Token(可选,用于自动续期)</label> |
| <textarea class="form-control" id="import-refresh-token" rows="2" placeholder="浏览器里抓到的 refresh_token"></textarea> |
| </div> |
| <div class="mb-2"> |
| <label class="form-label">Privy Access Token(可选,用于自动续期)</label> |
| <textarea class="form-control" id="import-privy-access-token" rows="2" placeholder="浏览器里抓到的 privy_access_token"></textarea> |
| </div> |
| <div class="d-grid gap-2"> |
| <button type="button" class="btn btn-secondary" onclick="importToken()">导入单个 Token</button> |
| </div> |
| <hr> |
| <div class="mb-2"> |
| <label class="form-label">上传 Token JSON 文件</label> |
| <input type="file" class="form-control" id="batch-import-file" accept="application/json,.json" onchange="loadTokenJsonFile()"> |
| </div> |
| <div class="mb-2"> |
| <label class="form-label">批量导入 Token JSON</label> |
| <textarea class="form-control" id="batch-import-json" rows="4" placeholder="上传 JSON 文件后会自动填入,也可以手动粘贴导出的 JSON 内容"></textarea> |
| </div> |
| <div class="d-grid gap-2"> |
| <button type="button" class="btn btn-outline-secondary" onclick="importBatchTokens()">批量导入 Token</button> |
| </div> |
| </div> |
| </div> |
|
|
| <div class="card"> |
| <div class="card-header bg-dark text-white">运行日志</div> |
| <div class="card-body p-0"> |
| <div id="log-container"></div> |
| </div> |
| </div> |
| </div> |
|
|
| |
| <div class="col-md-8"> |
| <div class="card"> |
| <div class="card-header d-flex justify-content-between align-items-center"> |
| <span>账号列表 (统计: 成功 <span id="stat-success">{{ stats.success }}</span> / 总计 <span id="stat-total">{{ stats.total }}</span>)</span> |
| <div class="btn-group btn-group-sm"> |
| <button class="btn btn-outline-primary" onclick="queryAllCredits()">批量查询余额</button> |
| <button class="btn btn-outline-success" onclick="exportSelectedTokens()">导出选中 Token</button> |
| <button class="btn btn-outline-danger" onclick="clearAccounts()">清空列表</button> |
| </div> |
| </div> |
| <div class="card-body p-0"> |
| <div class="table-responsive"> |
| <table class="table table-hover align-middle mb-0" id="account-table"> |
| <thead class="table-light"> |
| <tr> |
| <th><input type="checkbox" id="select-all-accounts" onchange="toggleAllAccounts(this.checked)"></th> |
| <th>Email</th> |
| <th>积分</th> |
| <th>Token 过期时间</th> |
| <th>状态</th> |
| <th>创建时间</th> |
| <th>操作</th> |
| </tr> |
| </thead> |
| <tbody> |
| {% for acc in accounts %} |
| <tr id="row-{{ acc.email }}"> |
| <td><input type="checkbox" class="account-checkbox" value="{{ acc.email }}"></td> |
| <td class="text-break" style="max-width: 200px;">{{ acc.email }}</td> |
| <td class="credits-cell">{{ acc.credits }}</td> |
| <td class="{{ acc.exp_class }}">{{ acc.exp_str }}</td> |
| <td><span class="status-{{ acc.status }}">{{ acc.status }}</span></td> |
| <td class="small text-muted">{{ acc.created_at[:19].replace('T', ' ') }}</td> |
| <td> |
| <div class="btn-group btn-group-sm"> |
| <button class="btn btn-link py-0" onclick="queryCredits('{{ acc.email }}')">查询</button> |
| <button class="btn btn-link py-0 text-success" onclick="manualRefresh('{{ acc.email }}')">续期</button> |
| <button class="btn btn-link py-0 text-danger" onclick="deleteAccount('{{ acc.email }}')">删除</button> |
| </div> |
| </td> |
| </tr> |
| {% endfor %} |
| </tbody> |
| </table> |
| </div> |
| </div> |
| </div> |
| </div> |
| </div> |
| </div> |
|
|
| <script> |
| let isRunning = false; |
| |
| function appendLog(msg) { |
| const container = document.getElementById('log-container'); |
| const line = document.createElement('div'); |
| line.textContent = msg; |
| container.appendChild(line); |
| container.scrollTop = container.scrollHeight; |
| } |
| |
| async function updateStatus() { |
| if (!isRunning) return; |
| try { |
| const res = await fetch('/api/task/status'); |
| const data = await res.json(); |
| |
| |
| isRunning = data.is_running; |
| document.getElementById('start-btn').disabled = isRunning; |
| document.getElementById('stop-btn').disabled = !isRunning; |
| |
| |
| if (data.logs.length > 0) { |
| const container = document.getElementById('log-container'); |
| container.innerHTML = ''; |
| data.logs.forEach(log => { |
| const line = document.createElement('div'); |
| line.textContent = log; |
| container.appendChild(line); |
| }); |
| container.scrollTop = container.scrollHeight; |
| } |
| |
| |
| if (!isRunning) { |
| location.reload(); |
| } |
| } catch (e) { console.error(e); } |
| } |
| |
| document.getElementById('start-btn').onclick = async () => { |
| const form = document.getElementById('config-form'); |
| const formData = new FormData(form); |
| const data = Object.fromEntries(formData.entries()); |
| |
| |
| await fetch('/api/config', { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify(data) |
| }); |
| |
| const res = await fetch('/api/task/start', { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify(data) |
| }); |
| const result = await res.json(); |
| if (result.success) { |
| isRunning = true; |
| appendLog("任务已启动..."); |
| } else { |
| alert(result.error); |
| } |
| }; |
| |
| document.getElementById('stop-btn').onclick = async () => { |
| const res = await fetch('/api/task/stop', { method: 'POST' }); |
| const result = await res.json(); |
| if (result.success) { |
| isRunning = false; |
| appendLog("正在停止任务..."); |
| } |
| }; |
| |
| function toggleAllAccounts(checked) { |
| document.querySelectorAll('.account-checkbox').forEach(cb => cb.checked = checked); |
| } |
| |
| function getSelectedEmails() { |
| return Array.from(document.querySelectorAll('.account-checkbox:checked')).map(cb => cb.value); |
| } |
| |
| function downloadText(filename, text) { |
| const blob = new Blob([text], { type: 'application/json;charset=utf-8' }); |
| const url = URL.createObjectURL(blob); |
| const a = document.createElement('a'); |
| a.href = url; |
| a.download = filename; |
| a.click(); |
| URL.revokeObjectURL(url); |
| } |
| |
| async function queryCredits(email) { |
| const row = document.getElementById(`row-${email}`); |
| const cell = row.querySelector('.credits-cell'); |
| cell.innerHTML = '<span class="spinner-border spinner-border-sm"></span>'; |
| |
| try { |
| const res = await fetch('/api/accounts/query-credits', { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ emails: [email] }) |
| }); |
| const data = await res.json(); |
| const result = data.results[0]; |
| if (result.success) { |
| cell.textContent = result.data.available_credits; |
| } else { |
| cell.textContent = "失败"; |
| appendLog(`查询 ${email} 余额失败: ${result.error}`); |
| } |
| } catch (e) { |
| cell.textContent = "错误"; |
| } |
| } |
| |
| async function queryAllCredits() { |
| const rows = document.querySelectorAll('#account-table tbody tr'); |
| const emails = Array.from(rows).map(r => r.id.replace('row-', '')); |
| if (emails.length === 0) return; |
| |
| appendLog(`开始批量查询 ${emails.length} 个账号的余额...`); |
| for (const email of emails) { |
| await queryCredits(email); |
| } |
| appendLog("批量查询完成"); |
| } |
| |
| async function deleteAccount(email) { |
| if (!confirm(`确定删除 ${email}?`)) return; |
| const res = await fetch('/api/accounts', { |
| method: 'DELETE', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ email }) |
| }); |
| if ((await res.json()).success) { |
| document.getElementById(`row-${email}`).remove(); |
| } |
| } |
| |
| async function clearAccounts() { |
| if (!confirm('确定清空所有账号数据?')) return; |
| const res = await fetch('/api/accounts/clear', { method: 'DELETE' }); |
| if ((await res.json()).success) { |
| location.reload(); |
| } |
| } |
| |
| async function exportSelectedTokens() { |
| const emails = getSelectedEmails(); |
| if (emails.length === 0) { |
| alert('请先勾选要导出的账号'); |
| return; |
| } |
| const res = await fetch('/api/accounts/export-tokens', { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ emails }) |
| }); |
| const data = await res.json(); |
| if (!data.success) { |
| alert(`导出失败: ${data.error}`); |
| return; |
| } |
| const text = JSON.stringify({ version: 1, exported_at: new Date().toISOString(), accounts: data.accounts }, null, 2); |
| downloadText(`anuma_tokens_${Date.now()}.json`, text); |
| appendLog(`[OK] 已导出 ${data.accounts.length} 个账号 Token`); |
| } |
| |
| async function loadTokenJsonFile() { |
| const input = document.getElementById('batch-import-file'); |
| const file = input.files && input.files[0]; |
| if (!file) return; |
| const text = await file.text(); |
| document.getElementById('batch-import-json').value = text; |
| appendLog(`[OK] 已读取文件: ${file.name}`); |
| } |
| |
| function cleanToken(value) { |
| return (value || '') |
| .trim() |
| .replace(/^Bearer\s+/i, '') |
| .trim() |
| .replace(/^["'“”‘’]+|["'“”‘’]+$/g, '') |
| .trim(); |
| } |
| |
| function decodeJwtPayload(token) { |
| try { |
| const payload = token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/'); |
| return JSON.parse(decodeURIComponent(escape(atob(payload)))); |
| } catch (e) { |
| return {}; |
| } |
| } |
| |
| function parsePrivyTokenText(raw) { |
| const getValue = (key) => { |
| const match = raw.match(new RegExp(key.replace(':', '\\:') + '\\s*:\\s*[\\r\\n]+\\s*"?([^"\\r\\n]+)"?', 'i')); |
| return match ? cleanToken(match[1]) : ''; |
| }; |
| const identity_token = getValue('privy:id_token'); |
| const refresh_token = getValue('privy:refresh_token'); |
| const privy_access_token = getValue('privy:token'); |
| if (!identity_token) return null; |
| const payload = decodeJwtPayload(identity_token); |
| let email = ''; |
| try { |
| const linked = JSON.parse(payload.linked_accounts || '[]'); |
| const emailAccount = linked.find(item => item.type === 'email'); |
| email = emailAccount ? emailAccount.address : ''; |
| } catch (e) {} |
| if (!email) email = document.getElementById('import-email').value.trim(); |
| return { accounts: [{ email, identity_token, refresh_token, privy_access_token, credits: 'unknown' }] }; |
| } |
| |
| async function importBatchTokens() { |
| const raw = document.getElementById('batch-import-json').value.trim(); |
| if (!raw) { |
| alert('请上传或粘贴 Token JSON / token.txt 内容'); |
| return; |
| } |
| let payload; |
| try { |
| payload = JSON.parse(raw); |
| } catch (e) { |
| payload = parsePrivyTokenText(raw); |
| } |
| if (!payload) { |
| alert('无法识别导入内容,请使用导出的 JSON 或 privy:id_token 格式'); |
| return; |
| } |
| const accounts = Array.isArray(payload) ? payload : payload.accounts; |
| if (!Array.isArray(accounts) || accounts.length === 0) { |
| alert('没有找到可导入的账号'); |
| return; |
| } |
| if (accounts.some(acc => !acc.email)) { |
| alert('部分账号缺少 email,请在单个导入区域先填写 Email,或使用包含 email 的导出 JSON'); |
| return; |
| } |
| const res = await fetch('/api/accounts/import-tokens', { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ accounts }) |
| }); |
| const data = await res.json(); |
| if (data.success) { |
| appendLog(`[OK] 批量导入完成,成功 ${data.imported} 个,失败 ${data.errors.length} 个`); |
| if (data.errors.length) console.warn(data.errors); |
| setTimeout(() => location.reload(), 1000); |
| } else { |
| alert(`导入失败: ${data.error}`); |
| } |
| } |
| |
| async function manualRefresh(email) { |
| if (!confirm(`确定为 ${email} 手动刷新令牌?`)) return; |
| appendLog(`开始为 ${email} 续期...`); |
| try { |
| const res = await fetch('/api/accounts/refresh-token', { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ email }) |
| }); |
| const data = await res.json(); |
| if (data.success) { |
| appendLog(`[OK] ${email} 续期成功,新失效时间: ${new Date(data.expires_at * 1000).toLocaleString()}`); |
| setTimeout(() => location.reload(), 1000); |
| } else { |
| alert(`续期失败: ${data.error}`); |
| } |
| } catch (e) { |
| alert(`请求出错: ${e}`); |
| } |
| } |
| |
| async function importToken() { |
| const email = document.getElementById('import-email').value.trim(); |
| let identity_token = document.getElementById('import-token').value.trim(); |
| let refresh_token = document.getElementById('import-refresh-token').value.trim(); |
| let privy_access_token = document.getElementById('import-privy-access-token').value.trim(); |
| identity_token = identity_token.replace(/^Bearer\s+/i, '').replace(/^"|"$/g, ''); |
| refresh_token = refresh_token.replace(/^Bearer\s+/i, '').replace(/^"|"$/g, ''); |
| privy_access_token = privy_access_token.replace(/^Bearer\s+/i, '').replace(/^"|"$/g, ''); |
| if (!email || !identity_token) { |
| alert('请填写 Email 和 Identity Token'); |
| return; |
| } |
| try { |
| const res = await fetch('/api/accounts/import-token', { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ email, identity_token, refresh_token, privy_access_token }) |
| }); |
| const data = await res.json(); |
| if (data.success) { |
| appendLog(`[OK] ${email} 导入成功,积分: ${data.credits},自动续期: ${data.auto_refresh ? '已启用' : '未启用'}`); |
| setTimeout(() => location.reload(), 1000); |
| } else { |
| alert(`导入失败: ${data.error}`); |
| } |
| } catch (e) { |
| alert(`请求出错: ${e}`); |
| } |
| } |
| |
| setInterval(updateStatus, 2000); |
| {% if task_active %} |
| isRunning = true; |
| updateStatus(); |
| {% endif %} |
| </script> |
| </body> |
| </html> |
|
|