AnumaAI / templates /index.html
Trae Assistant
Inline manager styles for Spaces
0cfd00d
Raw
History Blame Contribute Delete
24.7 kB
<!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>