ds2api-browser / static /index.html
nacho
feat: account detail modal — click eye icon to see full info + screenshots per account
3cb96d8
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
<title>DS2API · 控制台</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet">
<style>
:root{
--bg:#080c14;--surface:rgba(14,18,30,.85);--surface-solid:#0e121e;
--border:rgba(255,255,255,.06);--border-focus:rgba(96,165,250,.5);
--text:#c9d1d9;--text-dim:#4a5568;--text-muted:#2d3748;
--accent:hsl(217,92%,60%);--accent-glow:hsla(217,92%,60%,.12);
--green:hsl(142,71%,45%);--red:hsl(0,84%,60%);--amber:hsl(38,92%,50%);
--radius:14px;--radius-sm:10px;--radius-xs:8px;
--font-ui:'Inter',system-ui,sans-serif;--font-mono:'JetBrains Mono',monospace;
--shadow:0 1px 3px rgba(0,0,0,.3),0 1px 2px rgba(0,0,0,.2);
}
html.light-mode{
--bg:#f1f5f9;--surface:rgba(255,255,255,.9);--surface-solid:#fff;
--border:rgba(0,0,0,.08);--border-focus:rgba(59,130,246,.5);
--text:#0f172a;--text-dim:#475569;--text-muted:#64748b;
--accent:hsl(217,92%,55%);--accent-glow:hsla(217,92%,55%,.1);
--shadow:0 1px 3px rgba(0,0,0,.06),0 1px 2px rgba(0,0,0,.04);
}
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
body{
font-family:var(--font-ui);background:var(--bg);color:var(--text);
font-size:clamp(13px,1.6vw,15px);line-height:1.6;min-height:100vh;
overflow-x:hidden;-webkit-font-smoothing:antialiased;
}
body::before{
content:'';position:fixed;inset:0;z-index:0;pointer-events:none;
background:radial-gradient(ellipse 80% 50% at 50% -20%,var(--accent-glow),transparent 70%);
}
::-webkit-scrollbar{width:5px;height:5px}
::-webkit-scrollbar-track{background:transparent}
::-webkit-scrollbar-thumb{background:var(--border);border-radius:3px}
.topbar{
position:sticky;top:0;z-index:50;
backdrop-filter:blur(20px);-webkit-backdrop-filter:blur(20px);
background:rgba(8,12,20,.85);border-bottom:1px solid var(--border);
padding:0 clamp(12px,3vw,24px);height:54px;display:flex;align-items:center;gap:10px;
}
html.light-mode .topbar{background:rgba(241,245,249,.85)}
.topbar .logo{
font-weight:800;font-size:clamp(13px,2vw,15px);
background:linear-gradient(135deg,var(--accent),hsl(280,80%,65%));
-webkit-background-clip:text;-webkit-text-fill-color:transparent;letter-spacing:1px;
white-space:nowrap;flex-shrink:0;
}
.topbar .badge{
font-size:10px;color:var(--text-dim);border:1px solid var(--border);
padding:2px 8px;border-radius:20px;font-weight:500;white-space:nowrap;
display:none;
}
@media(min-width:480px){.topbar .badge{display:inline}}
.topbar .spacer{flex:1}
.topbar .stats{display:flex;gap:clamp(6px,2vw,18px);font-size:11px;font-weight:500;flex-shrink:0}
.topbar .stats .si{display:flex;align-items:center;gap:4px;color:var(--text-dim);white-space:nowrap}
.topbar .stats .si b{color:var(--text);font-weight:600}
.topbar .stats .hide-sm{display:none}
@media(min-width:640px){.topbar .stats .hide-sm{display:flex}}
.dot{width:6px;height:6px;border-radius:50%;flex-shrink:0}
.btn{
display:inline-flex;align-items:center;justify-content:center;gap:5px;
padding:clamp(6px,1vw,8px) clamp(10px,2vw,16px);
font-size:clamp(11px,1.4vw,13px);font-family:var(--font-ui);
background:var(--surface-solid);color:var(--text);
border:1px solid var(--border);border-radius:var(--radius-xs);
cursor:pointer;white-space:nowrap;transition:all .15s;
min-height:36px;min-width:36px;user-select:none;
}
.btn:hover{background:rgba(96,165,250,.1);border-color:var(--border-focus)}
.btn:active{transform:scale(.97)}
.btn:disabled{opacity:.4;pointer-events:none}
.btn-primary{background:var(--accent);color:#fff;border-color:var(--accent);font-weight:600}
.btn-primary:hover{background:hsl(217,92%,50%);border-color:hsl(217,92%,50%)}
.btn-sm{padding:4px 10px;font-size:11px;min-height:28px}
.btn-icon{padding:6px;min-width:36px;min-height:36px}
main{
position:relative;z-index:1;padding:clamp(12px,2vw,20px);
display:grid;gap:clamp(10px,2vw,16px);
grid-template-columns:1fr;
}
@media(min-width:640px){main{grid-template-columns:1fr 1fr}}
@media(min-width:1024px){main{grid-template-columns:1fr 1fr 1fr}}
@media(min-width:1400px){main{grid-template-columns:1fr 1fr 1fr 1fr}}
.card{
background:var(--surface);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);
border:1px solid var(--border);border-radius:var(--radius);
box-shadow:var(--shadow);display:flex;flex-direction:column;
overflow:hidden;animation:fadeUp .4s ease-out both;
}
.card.span-2{grid-column:span 1}
.card.span-full{grid-column:1/-1}
@media(min-width:640px){.card.span-2{grid-column:span 2}}
@media(min-width:1024px){.card.span-2{grid-column:span 2}}
.card-header{
display:flex;align-items:center;justify-content:space-between;gap:8px;
padding:clamp(10px,1.5vw,14px) clamp(12px,2vw,18px);
border-bottom:1px solid var(--border);flex-wrap:wrap;
}
.card-header h2{
font-size:clamp(12px,1.5vw,14px);font-weight:600;
display:flex;align-items:center;gap:6px;color:var(--text);
}
.card-header .icon{font-size:clamp(14px,1.8vw,16px)}
.card-body{padding:clamp(10px,1.5vw,16px);flex:1}
.form-group{margin-bottom:clamp(8px,1.2vw,12px)}
.form-group:last-child{margin-bottom:0}
label{display:block;font-size:11px;color:var(--text-dim);margin-bottom:4px;font-weight:500}
input,textarea,select{
width:100%;padding:clamp(8px,1vw,10px) clamp(10px,1.5vw,12px);
font-size:clamp(12px,1.4vw,14px);font-family:var(--font-ui);
background:rgba(0,0,0,.25);color:var(--text);
border:1px solid var(--border);border-radius:var(--radius-xs);
outline:none;transition:border-color .15s,box-shadow .15s;
}
html.light-mode input,html.light-mode textarea,html.light-mode select{background:rgba(0,0,0,.03)}
input:focus,textarea:focus,select:focus{
border-color:var(--border-focus);box-shadow:0 0 0 3px var(--accent-glow);
}
textarea{resize:vertical;min-height:80px;font-family:var(--font-mono);font-size:12px}
select{-webkit-appearance:none;appearance:none;cursor:pointer}
.check-label{
display:inline-flex;align-items:center;gap:6px;font-size:12px;cursor:pointer;color:var(--text);
white-space:nowrap;
}
.check-label input[type=checkbox]{width:16px;height:16px;accent-color:var(--accent);cursor:pointer}
.row{display:flex;align-items:center;gap:clamp(6px,1vw,10px);flex-wrap:wrap}
.tbl-wrap{
overflow-x:auto;-webkit-overflow-scrolling:touch;
margin:-1px;padding:1px;
}
.tbl-wrap::-webkit-scrollbar{height:4px}
table{width:100%;border-collapse:collapse;font-size:clamp(11px,1.3vw,13px);min-width:600px}
th{
text-align:left;padding:8px 10px;font-size:10px;text-transform:uppercase;
letter-spacing:.5px;color:var(--text-dim);font-weight:600;
background:rgba(0,0,0,.15);white-space:nowrap;
}
td{padding:clamp(6px,1vw,8px) 10px;border-bottom:1px solid var(--border);vertical-align:middle}
tr:last-child td{border-bottom:none}
.email{font-family:var(--font-mono);font-size:11px;word-break:break-all}
.badge{
display:inline-block;padding:2px 8px;border-radius:12px;font-size:10px;font-weight:600;
white-space:nowrap;
}
.badge-on{background:rgba(52,211,153,.15);color:var(--green)}
.badge-off{background:rgba(248,113,113,.12);color:var(--red)}
.badge-idle{background:rgba(255,255,255,.06);color:var(--text-dim)}
.badge-warn{background:rgba(251,191,36,.15);color:var(--amber)}
.badge-blue{background:var(--accent-glow);color:var(--accent)}
.resp-box{
background:rgba(0,0,0,.35);border:1px solid var(--border);
border-radius:var(--radius-xs);padding:clamp(8px,1vw,12px);
min-height:60px;max-height:500px;overflow-y:auto;
font-family:var(--font-mono);font-size:12px;line-height:1.55;
white-space:pre-wrap;word-break:break-word;color:var(--text);
}
html.light-mode .resp-box{background:rgba(0,0,0,.03)}
.resp-meta{display:flex;align-items:center;gap:10px;margin-top:8px;font-size:11px;color:var(--text-dim);flex-wrap:wrap}
.log-viewer{
background:rgba(0,0,0,.4);border:1px solid var(--border);
border-radius:var(--radius-xs);padding:10px 14px;
max-height:clamp(200px,40vh,400px);overflow-y:auto;
font-family:var(--font-mono);font-size:11px;line-height:1.6;
white-space:pre-wrap;word-break:break-all;color:var(--text-dim);
}
html.light-mode .log-viewer{background:rgba(0,0,0,.04);color:#475569}
.log-viewer .log-warn{color:var(--amber)}.log-viewer .log-err{color:var(--red)}
.log-viewer .log-info{color:var(--text)}.log-viewer .log-debug{color:var(--text-muted)}
.level-btns{display:flex;gap:3px;flex-wrap:wrap}
.level-btns .btn{padding:4px 8px;font-size:10px;font-family:var(--font-mono);border-radius:5px;min-height:26px}
.level-btns .btn.active{background:var(--accent);color:#fff;border-color:var(--accent)}
.login-overlay{
position:fixed;inset:0;z-index:200;background:var(--bg);
display:flex;align-items:center;justify-content:center;transition:opacity .4s;
padding:20px;
}
.login-overlay.hidden{opacity:0;pointer-events:none}
.login-box{
background:var(--surface);backdrop-filter:blur(16px);border:1px solid var(--border);
border-radius:var(--radius);padding:clamp(28px,5vw,40px);
width:100%;max-width:380px;text-align:center;animation:fadeUp .5s ease-out;
}
.login-box .logo-big{
font-size:clamp(20px,4vw,24px);font-weight:800;
background:linear-gradient(135deg,var(--accent),hsl(280,80%,65%));
-webkit-background-clip:text;-webkit-text-fill-color:transparent;margin-bottom:6px;
}
.login-box .sub{color:var(--text-dim);font-size:12px;margin-bottom:24px}
.login-box .form-group{text-align:left;margin-bottom:14px}
.login-box .btn-primary{width:100%;padding:12px;font-size:14px}
.login-box .err-msg{color:var(--red);font-size:12px;margin-top:8px;min-height:18px}
.app-wrap.blur{filter:blur(8px);pointer-events:none}
.toast{
position:fixed;top:20px;right:clamp(10px,3vw,20px);z-index:300;
padding:10px 18px;border-radius:var(--radius-xs);
font-size:12px;font-weight:500;box-shadow:0 8px 24px rgba(0,0,0,.4);
animation:slideIn .3s ease-out;
background:var(--surface-solid);color:var(--text);border:1px solid var(--border);
max-width:min(360px,90vw);
}
.toast-ok{border-left:3px solid var(--green)}
.toast-err{border-left:3px solid var(--red)}
.thinking-block{margin-bottom:10px;border-left:3px solid var(--accent);padding-left:12px;color:var(--text-dim);font-size:12px}
.thinking-block summary{cursor:pointer;user-select:none;font-weight:600;margin-bottom:6px;outline:none}
.thinking-block summary:hover{color:var(--accent)}
.thinking-content{white-space:pre-wrap;word-break:break-word;opacity:.8}
.inline-stats{display:flex;gap:12px;flex-wrap:wrap;font-size:11px;margin-bottom:8px}
.inline-stats span{display:flex;align-items:center;gap:4px;color:var(--text-dim)}
.inline-stats span b{color:var(--text)}
@keyframes fadeUp{from{transform:translateY(8px);opacity:0}to{transform:translateY(0);opacity:1}}
@keyframes slideIn{from{transform:translateX(60px);opacity:0}to{transform:translateX(0);opacity:1}}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.3}}
.poll-dot{animation:pulse 1.2s ease-in-out;width:5px;height:5px;border-radius:50%;background:var(--accent);display:inline-block;margin-left:6px}
.poll-dot.active{animation:none}
.hint{font-size:10px;color:var(--text-dim);margin-top:4px}
@media(max-width:639px){.hide-xs{display:none!important}}
@media(max-width:1023px){.hide-md{display:none!important}}
/* detail modal */
.detail-overlay{position:fixed;inset:0;z-index:250;background:rgba(0,0,0,.7);display:flex;align-items:center;justify-content:center;opacity:0;pointer-events:none;transition:opacity .25s;padding:20px}
.detail-overlay.open{opacity:1;pointer-events:auto}
.detail-box{background:var(--surface-solid);border:1px solid var(--border);border-radius:var(--radius);max-width:640px;width:100%;max-height:85vh;overflow-y:auto;box-shadow:0 20px 60px rgba(0,0,0,.5)}
.detail-header{display:flex;align-items:center;justify-content:space-between;padding:14px 18px;border-bottom:1px solid var(--border);position:sticky;top:0;background:var(--surface-solid);z-index:1}
.detail-header h2{font-size:15px;font-weight:600}
.detail-body{padding:16px 18px}
.detail-body .kv{display:grid;grid-template-columns:120px 1fr;gap:6px 12px;font-size:12px;margin-bottom:10px}
.detail-body .kv .k{color:var(--text-dim);font-weight:500}
.detail-body .kv .v{color:var(--text);word-break:break-all}
.detail-body .kv .v.err{color:var(--red)}
.detail-body h3{font-size:13px;font-weight:600;margin:16px 0 8px;color:var(--text)}
.detail-body .ss-thumbs{display:flex;flex-wrap:wrap;gap:6px}
.detail-body .ss-thumbs a{display:inline-block;padding:4px 8px;background:rgba(0,0,0,.2);border:1px solid var(--border);border-radius:6px;color:var(--text);text-decoration:none;font-size:10px;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.detail-body .ss-thumbs a:hover{border-color:var(--accent)}
</style>
</head>
<body>
<div class="login-overlay" id="loginOverlay">
<div class="login-box">
<div class="logo-big">▸ DS2API</div>
<div class="sub">输入管理密钥进入控制台</div>
<div class="form-group">
<label>Admin Key</label>
<input type="password" id="loginKey" placeholder="请输入管理密钥" autofocus>
</div>
<button class="btn btn-primary" onclick="doLogin()" id="loginBtn">进入控制台</button>
<div class="err-msg" id="loginErr"></div>
</div>
</div>
<div class="app-wrap" id="appWrap">
<div class="topbar">
<span class="logo">▸ DS2API</span>
<span class="badge">浏览器模式</span>
<span class="spacer"></span>
<button class="btn btn-sm hide-xs" onclick="toggleTheme()" id="themeBtn">🌙</button>
<div class="stats" id="topStats">
<span class="si"><span class="dot" style="background:var(--accent)"></span> <b></b></span>
<span class="si hide-sm"><span class="dot" style="background:var(--green)"></span> <b></b></span>
<span class="si hide-sm"><span class="dot" style="background:var(--amber)"></span> <b></b></span>
</div>
</div>
<main>
<div class="card span-2" style="animation-delay:0s">
<div class="card-header">
<h2><span class="icon">💬</span> 对话测试</h2>
<div class="row">
<select id="model" style="width:auto;min-width:130px">
<option value="deepseek-v4-flash">V4 Flash</option>
<option value="deepseek-v4-pro">V4 Pro</option>
</select>
<label class="check-label"><input type="checkbox" id="stream" checked> 流式</label>
</div>
</div>
<div class="card-body">
<div class="form-group">
<textarea id="prompt" placeholder="输入消息… (Ctrl+Enter 发送)" style="min-height:90px"></textarea>
</div>
<div class="row" style="justify-content:space-between">
<button class="btn btn-primary" id="sendBtn" onclick="sendMsg()">▸ 发送</button>
<button class="btn btn-sm" onclick="clearResp()">清空</button>
</div>
<div class="resp-box" id="response" style="margin-top:12px"></div>
<div class="resp-meta">
<span id="reqStatus"></span>
<span id="respTime"></span>
<span id="respLabel" style="margin-left:auto;font-weight:600"></span>
</div>
</div>
</div>
<div class="card" style="animation-delay:.1s">
<div class="card-header"><h2><span class="icon">📥</span> 导入账号</h2></div>
<div class="card-body">
<div class="hint" style="margin-bottom:6px">JSON 或 邮箱:密码[:备注] 格式,每行一个</div>
<div class="form-group">
<textarea id="inp" placeholder='[{"email":"user@gmail.com","password":"xxx"}]' style="min-height:100px"></textarea>
</div>
<div class="row" style="justify-content:space-between">
<button class="btn btn-primary" onclick="doImport()">▸ 导入</button>
<span id="msg" style="font-size:11px;color:var(--text-dim)"></span>
</div>
</div>
</div>
<div class="card span-full" style="animation-delay:.2s">
<div class="card-header">
<h2><span class="icon">👥</span> 账号列表</h2>
<div class="inline-stats hide-xs" id="cardStats">
<span><span class="dot" style="background:var(--accent)"></span> 总计 <b id="d-total"></b></span>
<span><span class="dot" style="background:var(--green)"></span> 在线 <b id="d-online"></b></span>
<span><span class="dot" style="background:var(--amber)"></span> 使用中 <b id="d-inuse"></b></span>
<span>可用 <b id="d-avail"></b></span>
<span>禁言 <b id="d-muted"></b></span>
<span>排队 <b id="d-queue"></b></span>
<span class="poll-dot" id="pollDot"></span>
</div>
</div>
<div class="card-body" style="padding:0">
<div class="tbl-wrap">
<table>
<thead><tr>
<th>邮箱</th><th class="hide-xs">备注</th><th>状态</th><th>使用</th>
<th class="hide-xs">禁言</th><th class="hide-xs">错误</th><th>操作</th>
</tr></thead>
<tbody id="tbl"><tr><td colspan="7" style="text-align:center;padding:20px;color:var(--text-muted)">加载中…</td></tr></tbody>
</table>
</div>
</div>
</div>
<div class="card" style="animation-delay:.3s">
<div class="card-header"><h2><span class="icon">⚙️</span> 系统设置</h2></div>
<div class="card-body">
<div class="form-group">
<label>API Keys(每行一个)</label>
<textarea id="setApiKeys" style="min-height:60px;font-family:var(--font-mono);font-size:11px" placeholder="sk-xxx"></textarea>
</div>
<div class="form-group">
<label>Admin Key</label>
<input type="text" id="setAdminKey">
</div>
<div class="form-group">
<label>最大活跃浏览器数</label>
<input type="number" id="setMaxBrowsers" min="1" max="200" style="width:100px">
</div>
<div class="row" style="gap:8px;margin-bottom:10px">
<label class="check-label"><input type="checkbox" id="setLogFile"> 日志写入文件</label>
<input type="text" id="setLogMaxMb" placeholder="10" style="width:50px;text-align:center"> <span style="font-size:11px;color:var(--text-dim)">MB</span>
</div>
<div class="row">
<button class="btn btn-primary" onclick="saveSettings()">▸ 保存</button>
<span id="setMsg" style="font-size:11px;color:var(--text-dim)"></span>
</div>
</div>
</div>
<div class="card span-full" style="animation-delay:.4s">
<div class="card-header">
<h2><span class="icon">📋</span> 实时日志</h2>
<div class="row" style="gap:6px">
<div class="level-btns" id="levelBtns">
<button class="btn" onclick="setLevel('DEBUG')">DEBUG</button>
<button class="btn active" onclick="setLevel('INFO')">INFO</button>
<button class="btn" onclick="setLevel('WARNING')">WARN</button>
<button class="btn" onclick="setLevel('ERROR')">ERROR</button>
</div>
<button class="btn btn-sm hide-xs" onclick="clearLogs()">清空</button>
<label class="check-label hide-xs"><input type="checkbox" id="logAutoScroll" checked> 自动滚动</label>
</div>
</div>
<div class="card-body" style="padding:10px">
<div class="log-viewer" id="logViewer">加载中…</div>
</div>
</div>
<!-- Screenshots -->
<div class="card span-full" style="animation-delay:.5s">
<div class="card-header">
<h2><span class="icon">📸</span> 调试截图</h2>
<button class="btn btn-sm" onclick="loadScreenshots()">刷新</button>
</div>
<div class="card-body" style="padding:10px">
<div id="ssList" style="display:flex;flex-wrap:wrap;gap:8px;font-size:11px;color:var(--text-dim)">
加载中…
</div>
</div>
</div>
<!-- Account Detail Modal -->
<div class="detail-overlay" id="detailOverlay" onclick="closeDetail(event)">
<div class="detail-box" onclick="event.stopPropagation()">
<div class="detail-header">
<h2 id="detailTitle">账号详情</h2>
<button class="btn btn-sm" onclick="closeDetail()"></button>
</div>
<div class="detail-body" id="detailBody">加载中…</div>
</div>
</div>
</main>
</div>
<script>
const H=location.origin;
const LS={
get(k,d){try{return localStorage.getItem('ds2_'+k)||d}catch(e){return d}},
set(k,v){try{localStorage.setItem('ds2_'+k,v)}catch(e){}}
};
function initTheme(){
if(LS.get('theme','dark')==='light') document.documentElement.classList.add('light-mode');
updateThemeBtn();
}
function toggleTheme(){
const isLight=document.documentElement.classList.toggle('light-mode');
LS.set('theme',isLight?'light':'dark');
updateThemeBtn();
}
function updateThemeBtn(){
const btn=document.getElementById('themeBtn');
if(btn) btn.textContent=document.documentElement.classList.contains('light-mode')?'☀️':'🌙';
}
initTheme();
let _adminKey='';
async function doLogin(){
const key=document.getElementById('loginKey').value.trim();
const err=document.getElementById('loginErr');
const btn=document.getElementById('loginBtn');
if(!key){err.textContent='请输入密钥';return}
btn.disabled=true;btn.textContent='验证中…';err.textContent='';
try{
const r=await fetch(H+'/admin/verify',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({key})});
if(!r.ok)throw new Error('密钥错误');
_adminKey=key;LS.set('adminKey',key);
document.getElementById('loginOverlay').classList.add('hidden');
document.getElementById('appWrap').classList.remove('blur');
initApp();
}catch(e){err.textContent=e.message}
btn.disabled=false;btn.textContent='进入控制台';
}
document.getElementById('loginKey').addEventListener('keydown',e=>{if(e.key==='Enter')doLogin()});
(async()=>{
const saved=LS.get('adminKey','');
if(saved){
document.getElementById('loginKey').value=saved;
try{
const r=await fetch(H+'/admin/verify',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({key:saved})});
if(r.ok){_adminKey=saved;document.getElementById('loginOverlay').classList.add('hidden');document.getElementById('appWrap').classList.remove('blur');initApp();return}
}catch(e){}
}
document.getElementById('appWrap').classList.add('blur');
})();
function getAdminKey(){return _adminKey||'admin'}
function toast(m,ok){
const e=document.createElement('div');
e.className='toast toast-'+(ok?'ok':'err');
e.textContent=(ok?'✓ ':'✗ ')+m;
document.body.appendChild(e);
setTimeout(()=>{e.style.opacity='0';e.style.transition='opacity .3s';setTimeout(()=>e.remove(),300)},2800);
}
function clearResp(){
document.getElementById('response').textContent='';
document.getElementById('respTime').textContent='';
document.getElementById('respLabel').textContent='';
document.getElementById('reqStatus').textContent='';
}
async function api(p,o={}){
const hd={};
if(o.json)hd['Content-Type']='application/json';
Object.assign(hd,o.headers||{});
const r=await fetch(H+p,{headers:hd,method:o.method||'GET',body:o.body});
if(!r.ok){const t=await r.text();throw new Error(t||r.status)}
return r.json();
}
async function sendMsg(){
const model=document.getElementById('model').value;
const prompt=document.getElementById('prompt').value.trim();
const stream=document.getElementById('stream').checked;
const resp=document.getElementById('response');
const status=document.getElementById('reqStatus');
const timeEl=document.getElementById('respTime');
const label=document.getElementById('respLabel');
const btn=document.getElementById('sendBtn');
if(!prompt)return toast('请输入消息',0);
btn.disabled=true;btn.textContent='发送中…';
resp.textContent='';timeEl.textContent='';status.textContent='';label.textContent='响应';
const t0=Date.now();
try{
const r=await fetch(H+'/admin/chat',{
method:'POST',
headers:{'Content-Type':'application/json','admin-key':getAdminKey()},
body:JSON.stringify({model,messages:[{role:'user',content:prompt}],stream})
});
if(!r.ok){const t=await r.text();throw new Error(t||r.status)}
if(stream){
const reader=r.body.getReader(),dec=new TextDecoder();
let fullContent='',fullThinking='';
resp.innerHTML='<details class="thinking-block" id="thinkBlock" style="display:none"><summary>深度思考</summary><div class="thinking-content" id="thinkContent"></div></details><div id="ansContent"></div>';
const thinkBlock=document.getElementById('thinkBlock');
const thinkContent=document.getElementById('thinkContent');
const ansContent=document.getElementById('ansContent');
while(1){
const{done,value}=await reader.read();
if(done)break;
for(const line of dec.decode(value,{stream:true}).split('\n')){
if(!line.startsWith('data: '))continue;
const d=line.slice(6).trim();
if(d==='[DONE]')continue;
try{
const j=JSON.parse(d);
const delta=j.choices?.[0]?.delta;
if(delta?.reasoning_content){
fullThinking+=delta.reasoning_content;
thinkBlock.style.display='block';
thinkBlock.open=true;
thinkContent.textContent=fullThinking;
}
if(delta?.content){
fullContent+=delta.content;
ansContent.textContent=fullContent;
}
}catch(e){}
}
}
timeEl.textContent=((Date.now()-t0)/1000).toFixed(1)+'s';
status.textContent='流式完成';status.style.color='var(--green)';
}else{
const d=await r.json();
const msg=d.choices?.[0]?.message;
let html='';
if(msg?.reasoning_content){
html+=`<details class="thinking-block"><summary>深度思考</summary><div class="thinking-content">${msg.reasoning_content.replace(/</g,'&lt;')}</div></details>`;
}
if(msg?.content) html+=`<div>${msg.content.replace(/</g,'&lt;')}</div>`;
resp.innerHTML=html||`<pre>${JSON.stringify(d,null,2)}</pre>`;
timeEl.textContent=((Date.now()-t0)/1000).toFixed(1)+'s';
status.textContent=r.status+' OK';status.style.color='var(--green)';
}
}catch(e){
resp.textContent='错误: '+e.message;
status.textContent='失败';status.style.color='var(--red)';
}
btn.disabled=false;btn.textContent='▸ 发送';
}
function flashPoll(){
const d=document.getElementById('pollDot');
if(!d)return;
d.classList.remove('active');void d.offsetWidth;d.classList.add('active');
}
async function loadStats(){
try{
flashPoll();
const s=await api('/readyz');
const a=s.accounts;
document.getElementById('d-total').textContent=a.total;
document.getElementById('d-online').textContent=a.logged_in;
document.getElementById('d-avail').textContent=a.available;
document.getElementById('d-inuse').textContent=a.in_use;
document.getElementById('d-muted').textContent=a.muted||0;
document.getElementById('d-queue').textContent=a.queue_size;
document.getElementById('topStats').innerHTML=
`<span class="si"><span class="dot" style="background:var(--accent)"></span> <b>${a.total}</b></span>
<span class="si hide-sm"><span class="dot" style="background:var(--green)"></span> <b>${a.logged_in}</b></span>
<span class="si hide-sm"><span class="dot" style="background:var(--amber)"></span> <b>${a.in_use}</b></span>`;
}catch(e){}
}
async function loadAccounts(){
try{
const d=await api('/admin/accounts',{headers:{'admin-key':getAdminKey()}});
let r='';
for(const a of d.accounts){
r+=`<tr>
<td><span class="email">${a.email}</span></td>
<td class="hide-xs">${a.name||'—'}</td>
<td><span class="badge ${a.logged_in?'badge-on':'badge-off'}">${a.logged_in?'在线':'离线'}</span></td>
<td><span class="badge ${a.in_use?'badge-on':'badge-idle'}">${a.in_use?'使用中':'空闲'}</span></td>
<td class="hide-xs">${a.is_muted?`<span class="badge badge-warn" title="${a.muted_until||''}">禁言</span>`:'<span class="badge badge-idle">正常</span>'}</td>
<td class="hide-xs">${a.error_count>0?`<span class="badge badge-off" title="${(a.last_error||'').replace(/"/g,'&quot;').replace(/'/g,"&#39;")}">${a.error_count}</span>`:'—'}</td>
<td><button class="btn btn-sm" onclick="doLoginAccount('${a.email}')">${a.logged_in?'重连':'登录'}</button> <button class="btn btn-sm" onclick="showDetail('${a.email}')" title="详情">👁️</button></td>
</tr>`;
}
document.getElementById('tbl').innerHTML=r||'<tr><td colspan="7" style="text-align:center;padding:20px;color:var(--text-muted)">暂无账号</td></tr>';
}catch(e){
document.getElementById('tbl').innerHTML=`<tr><td colspan="7" style="color:var(--red);text-align:center;padding:16px">${e.message}</td></tr>`;
}
}
async function doLoginAccount(email){
try{
toast('正在唤醒 '+email+'...',1);
await api('/admin/accounts/login',{method:'POST',json:true,body:JSON.stringify({email}),headers:{'admin-key':getAdminKey()}});
toast('已触发登录任务',1);
loadAll();
}catch(e){toast('触发失败: '+e.message,0)}
}
async function doImport(){
const v=document.getElementById('inp').value.trim();
if(!v)return toast('请输入账号',0);
const accts=[];
if(/^\s*[\[{]/.test(v)){
try{
let parsed=JSON.parse(v);
if(Array.isArray(parsed)){
for(const a of parsed) if(a.email&&a.password) accts.push({email:a.email.trim(),password:a.password,name:a.name||'',proxy:a.proxy||''});
}else if(parsed.accounts&&Array.isArray(parsed.accounts)){
for(const a of parsed.accounts) if(a.email&&a.password) accts.push({email:a.email.trim(),password:a.password,name:a.name||'',proxy:a.proxy||''});
}else if(parsed.email&&parsed.password){
accts.push({email:parsed.email.trim(),password:parsed.password,name:parsed.name||'',proxy:parsed.proxy||''});
}
}catch(e){return toast('JSON 解析失败: '+e.message,0)}
}else{
for(const l of v.split('\n')){
const t=l.trim();if(!t)continue;
const p=t.split(':',4);
if(p.length>=2) accts.push({email:p[0].trim(),password:p[1],name:p[2]||'',proxy:p[3]||''});
}
}
if(!accts.length)return toast('未识别到有效账号',0);
try{
const d=await api('/admin/accounts/import',{method:'POST',json:true,body:JSON.stringify({accounts:accts}),headers:{'admin-key':getAdminKey()}});
document.getElementById('inp').value='';
document.getElementById('msg').textContent='已导入 '+d.imported+' 个';
toast('成功导入 '+d.imported+' 个',1);
loadAll();
}catch(e){toast(e.message,0)}
}
async function loadAll(){await loadStats();await loadAccounts()}
document.getElementById('prompt').addEventListener('keydown',e=>{if(e.ctrlKey&&e.key==='Enter')sendMsg()});
let _pollTimer=null,_logTimer=null,_ssTimer=null;
function initApp(){
loadAll();loadSettings();loadLogs();loadScreenshots();
if(!_pollTimer)_pollTimer=setInterval(loadAll,12000);
if(!_logTimer)_logTimer=setInterval(loadLogs,3000);
if(!_ssTimer)_ssTimer=setInterval(loadScreenshots,30000);
}
async function loadSettings(){
try{
const d=await api('/admin/settings',{headers:{'admin-key':getAdminKey()}});
document.getElementById('setApiKeys').value=(d.api_keys||[]).join('\n');
document.getElementById('setAdminKey').value=d.admin_key||'';
document.getElementById('setMaxBrowsers').value=d.max_active_browsers||3;
document.getElementById('setLogFile').checked=d.log_file_enabled||false;
document.getElementById('setLogMaxMb').value=d.log_file_max_mb||10;
const lvl={10:'DEBUG',20:'INFO',30:'WARNING',40:'ERROR'}[d.log_level]||'INFO';
document.querySelectorAll('#levelBtns .btn').forEach(b=>b.classList.toggle('active',b.textContent===lvl||(b.textContent==='WARN'&&lvl==='WARNING')));
}catch(e){}
}
async function saveSettings(){
const keys=document.getElementById('setApiKeys').value.trim().split('\n').map(s=>s.trim()).filter(Boolean);
const ak=document.getElementById('setAdminKey').value.trim();
const logFile=document.getElementById('setLogFile').checked;
const logMax=parseInt(document.getElementById('setLogMaxMb').value)||10;
const maxBrowsers=parseInt(document.getElementById('setMaxBrowsers').value)||3;
if(!keys.length){toast('请至少填写一个 API Key',0);return}
try{
await api('/admin/settings',{method:'POST',json:true,body:JSON.stringify({api_keys:keys,admin_key:ak||undefined,max_active_browsers:maxBrowsers,log_file_enabled:logFile,log_file_max_mb:logMax}),headers:{'admin-key':getAdminKey()}});
if(ak&&ak!==_adminKey){_adminKey=ak;LS.set('adminKey',ak)}
document.getElementById('setMsg').textContent='已保存';
toast('设置已保存',1);
}catch(e){toast(e.message,0)}
}
async function loadLogs(){
try{
const d=await api('/admin/logs?n=200',{headers:{'admin-key':getAdminKey()}});
const el=document.getElementById('logViewer');
el.innerHTML=(d.logs||[]).map(l=>{
let cls='log-info';
if(l.includes('[DEBUG]'))cls='log-debug';
else if(l.includes('[WARNING]'))cls='log-warn';
else if(l.includes('[ERROR]'))cls='log-err';
return `<span class="${cls}">${l.replace(/</g,'&lt;')}</span>`;
}).join('\n')||'<span class="log-debug">暂无日志</span>';
if(document.getElementById('logAutoScroll').checked)el.scrollTop=el.scrollHeight;
}catch(e){}
}
async function clearLogs(){
try{
await api('/admin/logs/clear',{method:'POST',headers:{'admin-key':getAdminKey()}});
document.getElementById('logViewer').innerHTML='<span class="log-debug">已清空</span>';
}catch(e){toast(e.message,0)}
}
async function setLevel(lvl){
try{
await api('/admin/logs/level',{method:'POST',json:true,body:JSON.stringify({level:lvl}),headers:{'admin-key':getAdminKey()}});
document.querySelectorAll('#levelBtns .btn').forEach(b=>b.classList.toggle('active',b.textContent===lvl||(b.textContent==='WARN'&&lvl==='WARNING')));
toast('日志级别: '+lvl,1);
}catch(e){toast(e.message,0)}
}
async function showDetail(email){
const overlay=document.getElementById('detailOverlay');
const body=document.getElementById('detailBody');
const title=document.getElementById('detailTitle');
overlay.classList.add('open');
title.textContent='加载中…';
body.innerHTML='<span style="color:var(--text-dim)">加载中…</span>';
try{
const d=await api('/admin/accounts/'+encodeURIComponent(email),{headers:{'admin-key':getAdminKey()}});
title.textContent=d.email;
const mutedTag=d.is_muted?`<span class="badge badge-warn">禁言至 ${d.muted_until||'?'}</span>`:'<span class="badge badge-idle">正常</span>';
body.innerHTML=`
<div class="kv">
<span class="k">邮箱</span><span class="v">${d.email}</span>
<span class="k">备注</span><span class="v">${d.name||'—'}</span>
<span class="k">代理</span><span class="v">${d.proxy||'—'}</span>
<span class="k">状态</span><span class="v"><span class="badge ${d.logged_in?'badge-on':'badge-off'}">${d.logged_in?'在线':'离线'}</span> <span class="badge ${d.in_use?'badge-on':'badge-idle'}">${d.in_use?'使用中':'空闲'}</span></span>
<span class="k">禁言</span><span class="v">${mutedTag}</span>
<span class="k">错误次数</span><span class="v">${d.error_count}</span>
<span class="k">最后错误</span><span class="v err">${(d.last_error||'—').replace(/</g,'&lt;')}</span>
<span class="k">最后使用</span><span class="v">${d.last_used?new Date(d.last_used*1000).toLocaleString():'—'}</span>
</div>
<h3>📸 相关截图 (${d.screenshots.length})</h3>
<div class="ss-thumbs">${d.screenshots.length?d.screenshots.map(s=>`<a href="${s.url}" target="_blank" title="${s.error||s.name}">🖼️ ${s.name.substring(0,30)} · ${s.time}</a>`).join(''):'<span style="color:var(--text-muted);font-size:11px">暂无</span>'}</div>`;
}catch(e){
body.innerHTML=`<span style="color:var(--red)">加载失败: ${e.message}</span>`;
}
}
function closeDetail(e){
if(e&&e.target!==document.getElementById('detailOverlay'))return;
document.getElementById('detailOverlay').classList.remove('open');
}
// ESC to close
document.addEventListener('keydown',e=>{if(e.key==='Escape')closeDetail()});
async function loadScreenshots(){
try{
const d=await api('/admin/screenshots',{headers:{'admin-key':getAdminKey()}});
const el=document.getElementById('ssList');
if(!d.screenshots||!d.screenshots.length){el.innerHTML='<span style="color:var(--text-muted)">暂无截图</span>';return}
el.innerHTML=d.screenshots.map(s=>{
const label=s.name.replace('login_fail_','').replace('_at_gmail.com','');
const errPart=s.error?`<div style="font-size:10px;color:var(--red);margin-top:3px;max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${s.error.replace(/</g,'&lt;').split('\\n')[0].substring(0,80)}</div>`:'';
return `<a href="${s.url}" target="_blank" style="display:inline-block;padding:6px 10px;background:var(--surface-solid);border:1px solid var(--border);border-radius:6px;color:var(--text);text-decoration:none;font-size:11px;margin:3px" title="${s.error||s.name}">
🖼️ ${label.substring(0,20)} <span style="color:var(--text-dim);font-size:10px">${s.time} · ${s.size_kb}KB</span>
${errPart}
</a>`;
}).join('');
}catch(e){}
}
</script>
</body>
</html>