Spaces:
Running
Running
nacho
feat: account detail modal — click eye icon to see full info + screenshots per account
3cb96d8 | <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,'<')}</div></details>`; | |
| } | |
| if(msg?.content) html+=`<div>${msg.content.replace(/</g,'<')}</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,'"').replace(/'/g,"'")}">${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,'<')}</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,'<')}</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,'<').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> |