| <!DOCTYPE html> |
| <html lang="zh-CN"> |
| <head> |
| <meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>Stagewise-2API</title> |
| <style> |
| :root{--bg:#0f1117;--card:#1a1d27;--border:#2a2d3a;--text:#e1e4e8;--muted:#8b949e;--accent:#58a6ff;--green:#3fb950;--red:#f85149;--yellow:#d29922} |
| *{box-sizing:border-box;margin:0;padding:0} |
| body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:var(--bg);color:var(--text);display:flex;min-height:100vh} |
| .sidebar{width:220px;background:var(--card);padding:16px 0;border-right:1px solid var(--border);display:flex;flex-direction:column;gap:2px;flex-shrink:0} |
| .sidebar .logo{font-size:18px;font-weight:700;padding:12px 16px;color:var(--accent)} |
| .sidebar a{color:var(--muted);text-decoration:none;padding:10px 16px;display:flex;align-items:center;gap:8px;font-size:14px;transition:all 0.2s;border-radius:0} |
| .sidebar a:hover,.sidebar a.active{color:var(--text);background:var(--bg)} |
| .sidebar .icon{font-size:16px;width:20px;text-align:center} |
| .main{flex:1;padding:24px;overflow-y:auto;max-width:1000px} |
| .header h1{font-size:24px;margin-bottom:16px} |
| .stats{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:12px;margin-bottom:24px} |
| .stat-card{background:var(--card);border:1px solid var(--border);border-radius:10px;padding:16px} |
| .stat-card .label{font-size:12px;color:var(--muted);margin-bottom:6px} |
| .stat-card .value{font-size:28px;font-weight:700}.stat-card .sub{font-size:11px;color:var(--muted)} |
| .green{color:var(--green)}.red{color:var(--red)}.yellow{color:var(--yellow)}.muted{color:var(--muted)}.accent{color:var(--accent)} |
| .panel{background:var(--card);border:1px solid var(--border);border-radius:10px;padding:20px;margin-bottom:16px} |
| .panel h2{font-size:16px;margin-bottom:12px} |
| table{width:100%;border-collapse:collapse;font-size:13px} |
| th,td{text-align:left;padding:8px 12px;border-bottom:1px solid var(--border)} |
| th{color:var(--muted);font-weight:600;font-size:11px;text-transform:uppercase} |
| tr:hover{background:rgba(88,166,255,0.05)} |
| .form-group{margin-bottom:12px} |
| .form-group label{display:block;font-size:13px;margin-bottom:4px;color:var(--muted)} |
| input,textarea,select{width:100%;background:var(--bg);border:1px solid var(--border);color:var(--text);padding:8px 12px;border-radius:6px;font-size:13px} |
| input:focus,textarea:focus,select:focus{outline:none;border-color:var(--accent)} |
| button{cursor:pointer;background:var(--accent);color:#fff;border:none;padding:8px 20px;border-radius:6px;font-size:13px;font-weight:600;transition:opacity 0.2s} |
| button:hover{opacity:0.85}button:disabled{opacity:0.5;cursor:default} |
| button.danger{background:var(--red)}button.small{padding:4px 10px;font-size:11px} |
| .chat-box{background:var(--bg);border:1px solid var(--border);border-radius:10px;padding:16px;min-height:300px;max-height:500px;overflow-y:auto;margin-bottom:12px;font-size:14px;line-height:1.6;white-space:pre-wrap} |
| .chat-input-row{display:flex;gap:8px}.chat-input-row input{flex:1} |
| .chat-toolbar{display:flex;gap:8px;margin-bottom:8px;align-items:center} |
| .chat-toolbar select{width:auto;min-width:180px} |
| #toast{position:fixed;top:16px;right:16px;z-index:999;opacity:0;transition:opacity 0.3s} |
| #toast.show{opacity:1}#toast .msg{padding:10px 20px;border-radius:8px;font-size:13px;background:var(--green);color:#fff} |
| #toast .msg.error{background:var(--red)} |
| .api-key-box{background:var(--bg);border:1px dashed var(--border);border-radius:8px;padding:12px;margin:8px 12px;font-size:11px} |
| .api-key-box input{font-size:11px;padding:4px 6px} |
| .usage-bar{height:4px;background:var(--border);border-radius:2px;overflow:hidden;margin-top:4px} |
| .usage-bar div{height:100%;border-radius:2px;transition:width 0.3s} |
| </style> |
| </head> |
| <body> |
|
|
| <div class="sidebar"> |
| <div class="logo">⚡ Stagewise-2API</div> |
| <div class="api-key-box"> |
| <div style="color:var(--muted);margin-bottom:4px">🔑 API Key</div> |
| <input id="apikeyInput" type="password" placeholder="sk-..." onchange="setApiKey(this.value)"> |
| <div style="display:flex;justify-content:space-between;margin-top:4px"> |
| <span style="color:var(--accent);cursor:pointer;font-size:10px" onclick="const e=document.getElementById('apikeyInput');e.type=e.type==='text'?'password':'text'">👁 显示</span> |
| <span style="color:var(--muted);font-size:10px" id="keyStatus">未设置</span> |
| </div> |
| </div> |
| <a href="#dashboard" class="active" onclick="showTab('dashboard')"><span class="icon">📊</span>仪表盘</a> |
| <a href="#accounts" onclick="showTab('accounts')"><span class="icon">👥</span>账号池</a> |
| <a href="#register" onclick="showTab('register')"><span class="icon">📧</span>注册</a> |
| <a href="#models" onclick="showTab('models')"><span class="icon">🧠</span>模型 (31)</a> |
| <a href="#playground" onclick="showTab('playground')"><span class="icon">💬</span>对话</a> |
| <a href="#keys" onclick="showTab('keys')"><span class="icon">🔑</span>API Keys</a> |
| <a href="#api" onclick="showTab('api')"><span class="icon">🔌</span>API 文档</a> |
| </div> |
|
|
| <div class="main" id="main-content"></div> |
| <div id="toast"><div class="msg"></div></div> |
|
|
| <script> |
| const API=''; |
| let API_KEY=localStorage.getItem('sw_api_key')||''; |
| function setApiKey(v){ |
| API_KEY=v;localStorage.setItem('sw_api_key',v); |
| document.getElementById('keyStatus').textContent=v?'✅ 已设置':'未设置'; |
| // re-render the active tab so data endpoints reload now that auth is set |
| if(v){const a=document.querySelector('.sidebar a.active');if(a)renderTab(a.getAttribute('href').slice(1));} |
| } |
| if(API_KEY){document.getElementById('apikeyInput').value=API_KEY;setApiKey(API_KEY)} |
| |
| async function fetchJSON(url,opts={}){ |
| const headers={...(opts.headers||{})}; |
| if(API_KEY)headers['Authorization']='Bearer '+API_KEY; |
| const r=await fetch(API+url,{...opts,headers}); |
| const t=await r.text();try{return JSON.parse(t)}catch(e){return{error:{message:t.substring(0,200)}}} |
| } |
| |
| function toast(msg,type='success'){const t=document.getElementById('toast');t.className='show';const m=t.querySelector('.msg');m.className='msg '+(type==='error'?'error':'success');m.textContent=msg;setTimeout(()=>t.className='',3000)} |
| |
| function showTab(name){ |
| document.querySelectorAll('.sidebar a').forEach(a=>a.classList.remove('active')); |
| document.querySelector('a[href="#'+name+'"]').classList.add('active'); |
| renderTab(name); |
| } |
| |
| async function renderTab(name){ |
| const m=document.getElementById('main-content'); |
| switch(name){ |
| case'dashboard':m.innerHTML=await dashHTML();break; |
| case'accounts':m.innerHTML=await acctsHTML();break; |
| case'register':m.innerHTML=regHTML();break; |
| case'models':m.innerHTML=modelsHTML();break; |
| case'playground':m.innerHTML=playHTML();initPlay();break; |
| case'keys':m.innerHTML=await keysHTML();break; |
| case'api':m.innerHTML=apiHTML();break; |
| } |
| } |
| |
| function fmtPct(v){return(v||0).toFixed(1)+'%'} |
| function fmtCtx(v){return v>=1e6?(v/1e6).toFixed(1)+'M':v>=1000?(v/1000).toFixed(0)+'K':v} |
| function barColor(v){return v>80?'var(--red)':v>50?'var(--yellow)':'var(--green)'} |
| |
| // ── Dashboard ── |
| async function dashHTML(){ |
| const[health,accts]=await Promise.all([fetchJSON('/health'),fetchJSON('/admin/accounts').catch(()=>({accounts:[]}))]); |
| const list=accts.accounts||[];const active=list.filter(a=>a.status==='active'); |
| const totalCalls=list.reduce((s,a)=>s+(a.callCount||0),0); |
| const totalFail=list.reduce((s,a)=>s+(a.failCount||0),0); |
| return `<div class="header"><h1>📊 仪表盘</h1></div> |
| <div class="stats"> |
| <div class="stat-card"><div class="label">活跃账号</div><div class="value green">${active.length}</div></div> |
| <div class="stat-card"><div class="label">总账号</div><div class="value">${list.length}</div></div> |
| <div class="stat-card"><div class="label">总调用</div><div class="value yellow">${totalCalls}</div></div> |
| <div class="stat-card"><div class="label">失败</div><div class="value ${totalFail>0?'red':''}">${totalFail}</div></div> |
| <div class="stat-card"><div class="label">网关版本</div><div class="value" style="font-size:20px">${health.version||'?'}</div></div> |
| <div class="stat-card"><div class="label">运行时间</div><div class="value" style="font-size:20px">${Math.floor((health.uptime||0)/60)}m</div></div> |
| </div> |
| <div class="panel"><h2>账号用量一览</h2> |
| <table><tr><th>邮箱</th><th>调用</th><th>失败</th><th>日配额</th><th>周配额</th><th>月配额</th><th>状态</th></tr> |
| ${list.map(a=>`<tr> |
| <td>${a.email}</td><td>${a.callCount||0}</td><td>${a.failCount||0}</td> |
| <td><div style="display:flex;align-items:center;gap:4px"><div class="usage-bar" style="flex:1"><div style="width:${a.quotaDaily||0}%;background:${barColor(a.quotaDaily||0)}"></div></div>${fmtPct(a.quotaDaily)}</div></td> |
| <td>${fmtPct(a.quotaWeekly)}</td><td>${fmtPct(a.quotaMonthly)}</td> |
| <td><span class="${a.status==='active'?'green':'red'}">${a.status}</span></td> |
| </tr>`).join('')}</table></div>`;} |
| |
| // ── Accounts ── |
| async function acctsHTML(){ |
| const data=await fetchJSON('/admin/accounts').catch(()=>({accounts:[]})); |
| const list=data.accounts||[]; |
| return `<div class="header"><h1>👥 账号池 (${list.length})</h1></div> |
| <div class="panel"><h2>📥 批量导入账号</h2> |
| <div class="form-group"><label>JSON 格式 — {"accounts":[{"email":"...","token":"..."}]} 或直接数组 [{...}]</label> |
| <textarea id="bulkImportText" rows="6" placeholder='{"accounts":[{"email":"a@x.com","token":"eyJ..."},{"email":"b@x.com","token":"eyJ..."}]}' style="font-family:monospace;font-size:12px"></textarea></div> |
| <button onclick="bulkImport()">批量导入</button> |
| <span id="bulkImportHint" class="muted" style="margin-left:12px;font-size:12px"></span> |
| </div> |
| <div class="panel"><table> |
| <tr><th>邮箱</th><th>Token</th><th>调用</th><th>失败</th><th>日配额</th><th>周配额</th><th>月配额</th><th>冷却</th><th>操作</th></tr> |
| ${list.map(a=>`<tr> |
| <td>${a.email}</td><td style="font-family:monospace;font-size:11px">${(a.token||'').substring(0,16)}...</td> |
| <td>${a.callCount||0}</td><td>${a.failCount||0}</td> |
| <td>${fmtPct(a.quotaDaily)}</td><td>${fmtPct(a.quotaWeekly)}</td><td>${fmtPct(a.quotaMonthly)}</td> |
| <td>${(data.cooldowns||{})[a.email]||0}m</td> |
| <td><button class="small danger" onclick="deleteAcct('${a.email}')">删除</button></td> |
| </tr>`).join('')}</table></div>`;} |
| async function deleteAcct(email){await fetchJSON('/admin/accounts?email='+email,{method:'DELETE'});toast('已删除');showTab('accounts')} |
| async function bulkImport(){ |
| const raw=document.getElementById('bulkImportText').value.trim(); |
| if(!raw)return toast('请粘贴 JSON','error'); |
| let parsed; |
| try{parsed=JSON.parse(raw)}catch(e){return toast('JSON 解析失败: '+e.message,'error')} |
| const body=Array.isArray(parsed)?{accounts:parsed}:parsed; |
| if(!Array.isArray(body.accounts))return toast('需要 accounts 数组','error'); |
| const r=await fetchJSON('/admin/import',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)}); |
| if(r.success){toast(`✅ 导入 ${r.imported} 个,跳过 ${r.skipped}`);showTab('accounts')} |
| else toast(r.error?.message||'导入失败','error'); |
| } |
| |
| // ── Register ── |
| function regHTML(){return `<div class="header"><h1>📧 注册账号</h1></div> |
| <div class="panel"><h2>发送验证码</h2> |
| <div class="form-group"><label>邮箱</label><input id="regEmail" placeholder="user@domain.com"></div> |
| <button onclick="sendOTP()">发送验证码</button></div> |
| <div class="panel"><h2>验证并添加</h2> |
| <div class="form-group"><label>邮箱</label><input id="verEmail"></div> |
| <div class="form-group"><label>验证码</label><input id="verOTP" placeholder="6位数字"></div> |
| <button onclick="verifyOTP()">验证并导入</button></div>`;} |
| async function sendOTP(){ |
| const e=document.getElementById('regEmail').value.trim(); |
| if(!e)return toast('请输入邮箱','error'); |
| const r=await fetchJSON('/admin/register',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({email:e})}); |
| if(r.success)toast('验证码已发送');else toast(r.error?.message||r.message,'error'); |
| } |
| async function verifyOTP(){ |
| const email=document.getElementById('verEmail').value.trim(); |
| const otp=document.getElementById('verOTP').value.trim(); |
| if(!email||!otp)return toast('请填写完整','error'); |
| const r=await fetchJSON('/admin/verify',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({email,otp})}); |
| if(r.success)toast('✅ 已添加: '+r.tokenPreview);else toast(r.error?.message||r.message,'error'); |
| } |
| |
| // ── Models ── |
| async function modelsHTML(){ |
| const data=await fetchJSON('/v1/models'); |
| const models=data.data||[]; |
| const groups={};models.forEach(m=>{const p=m.owned_by;if(!groups[p])groups[p]=[];groups[p].push(m)}); |
| return `<div class="header"><h1>🧠 模型列表 (${models.length})</h1></div> |
| ${Object.entries(groups).map(([prov,list])=>`<div class="panel"> |
| <h2>${prov.toUpperCase()} (${list.length})</h2> |
| <table><tr><th>ID</th><th>上下文</th><th>Input/M</th><th>Output/M</th><th>推理</th></tr> |
| ${list.map(m=>`<tr> |
| <td>${m.id}</td><td>${fmtCtx(m.ctx||0)}</td> |
| <td>$${m.pricing?.prompt||0}</td><td>$${m.pricing?.completion||0}</td> |
| <td>${m.reasoning?'✅':''}</td> |
| </tr>`).join('')}</table></div>`).join('')}`;} |
| |
| // ── Playground ── |
| let chatHistory=[]; |
| function playHTML(){ |
| return `<div class="header"><h1>💬 对话测试</h1></div> |
| <div class="panel"> |
| <div class="chat-toolbar"> |
| <select id="chatModel">${MODEL_IDS.map(id=>`<option value="${id}">${id}</option>`).join('')}</select> |
| <button class="small" onclick="clearChat()">清空</button> |
| </div> |
| <div class="chat-box" id="chatBox"><div class="muted" style="text-align:center;padding:40px">输入消息开始测试</div></div> |
| <div class="chat-input-row"><input id="chatInput" placeholder="输入消息..." onkeydown="if(event.key==='Enter')sendChat()"><button id="sendBtn" onclick="sendChat()">发送</button></div> |
| </div>`;} |
| function initPlay(){chatHistory=[];document.getElementById('chatModel').value='claude-haiku-4-5'} |
| async function sendChat(){ |
| const input=document.getElementById('chatInput');const text=input.value.trim();if(!text)return; |
| const model=document.getElementById('chatModel').value; |
| input.disabled=true;document.getElementById('sendBtn').disabled=true; |
| const box=document.getElementById('chatBox');const mid=Date.now(); |
| box.innerHTML+=`<div style="margin:8px 0"><span class="accent">You:</span> ${text}</div>`; |
| box.innerHTML+=`<div id="msg-${mid}" style="margin:8px 0"><span class="green">AI:</span> <span class="muted">...</span></div>`; |
| box.scrollTop=box.scrollHeight;chatHistory.push({role:'user',content:text}); |
| try{ |
| const r=await fetchJSON('/v1/chat/completions',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({model,messages:chatHistory,stream:false})}); |
| if(r.error){document.getElementById('msg-'+mid).innerHTML=`<span class="red">Error:</span> ${r.error.message}`} |
| else{ |
| const reply=r.choices?.[0]?.message?.content||'(empty)'; |
| document.getElementById('msg-'+mid).innerHTML=`<span class="green">AI:</span> ${reply.replace(/\n/g,'<br>')}`; |
| chatHistory.push({role:'assistant',content:reply}); |
| } |
| }catch(e){document.getElementById('msg-'+mid).innerHTML=`<span class="red">Error:</span> ${e.message}`} |
| box.scrollTop=box.scrollHeight;input.disabled=false;document.getElementById('sendBtn').disabled=false;input.focus(); |
| } |
| function clearChat(){chatHistory=[];document.getElementById('chatBox').innerHTML='<div class="muted" style="text-align:center;padding:40px">已清空</div>'} |
| |
| // ── API Keys ── |
| async function keysHTML(){ |
| const data=await fetchJSON('/admin/keys').catch(()=>({keys:[]})); |
| const keys=data.keys||[]; |
| return `<div class="header"><h1>🔑 API Keys (${keys.length})</h1></div> |
| <div class="panel"><h2>创建新 Key</h2> |
| <div class="form-group"><label>名称</label><input id="newKeyName" placeholder="例如: my-app"></div> |
| <button onclick="createKey()">生成 Key</button> |
| </div> |
| <div class="panel"><h2>已有 Keys</h2> |
| <table><tr><th>名称</th><th>Key</th><th>创建时间</th><th>最后使用</th><th>操作</th></tr> |
| ${keys.map(k=>`<tr> |
| <td>${k.name}</td> |
| <td style="font-family:monospace;font-size:11px">${k.key}</td> |
| <td>${(k.createdAt||'').substring(0,10)}</td> |
| <td>${k.lastUsed?k.lastUsed.substring(0,16):'未使用'}</td> |
| <td><button class="small" onclick="navigator.clipboard.writeText('${k.key}');toast('已复制')">📋</button> |
| <button class="small danger" onclick="deleteKey('${k.id}')">删除</button></td> |
| </tr>`).join('')}</table></div>`;} |
| async function createKey(){ |
| const n=document.getElementById('newKeyName').value.trim()||'api-key'; |
| const r=await fetchJSON('/admin/keys',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:n})}); |
| if(r.key){toast('✅ 已创建: '+r.key);showTab('keys')}else toast('创建失败','error'); |
| } |
| async function deleteKey(id){await fetchJSON('/admin/keys?id='+id,{method:'DELETE'});toast('已删除');showTab('keys')} |
| |
| // ── API Doc ── |
| function apiHTML(){const key=API_KEY||'(未设置)';return`<div class="header"><h1>🔌 API 文档</h1></div> |
| <div class="panel"><h2>认证</h2><p class="muted">所有 /v1 端点需要 API Key</p> |
| <pre style="background:var(--bg);padding:12px;border-radius:6px;font-size:12px;overflow-x:auto">Authorization: Bearer ${key}</pre></div> |
| <div class="panel"><h2>对话补全</h2><pre style="background:var(--bg);padding:12px;border-radius:6px;font-size:12px;overflow-x:auto">curl http://127.0.0.1:${location.port}/v1/chat/completions \\ |
| -H "Authorization: Bearer ${key}" \\ |
| -H "Content-Type: application/json" \\ |
| -d '{"model":"claude-haiku-4-5","messages":[{"role":"user","content":"Hello"}]}'</pre></div> |
| <div class="panel"><h2>模型列表</h2><pre style="background:var(--bg);padding:12px;border-radius:6px;font-size:12px;overflow-x:auto">curl http://127.0.0.1:${location.port}/v1/models -H "Authorization: Bearer ${key}"</pre></div> |
| <div class="panel"><h2>健康检查 (无需认证)</h2><pre style="background:var(--bg);padding:12px;border-radius:6px;font-size:12px;overflow-x:auto">curl http://127.0.0.1:${location.port}/health</pre></div> |
| <div class="panel"><h2>注册账号</h2><pre style="background:var(--bg);padding:12px;border-radius:6px;font-size:12px;overflow-x:auto"># 发送 OTP |
| curl -X POST http://127.0.0.1:${location.port}/admin/register \\ |
| -H "Content-Type: application/json" \\ |
| -d '{"email":"user@domain.com"}' |
| |
| # 验证并导入 |
| curl -X POST http://127.0.0.1:${location.port}/admin/verify \\ |
| -H "Content-Type: application/json" \\ |
| -d '{"email":"user@domain.com","otp":"123456"}'</pre></div> |
| <div class="panel"><h2>批量导入账号</h2><pre style="background:var(--bg);padding:12px;border-radius:6px;font-size:12px;overflow-x:auto">curl -X POST http://127.0.0.1:${location.port}/admin/import \\ |
| -H "Content-Type: application/json" \\ |
| -d '{"accounts":[{"email":"a@x.com","token":"eyJ..."},{"email":"b@x.com","token":"eyJ..."}]}' |
| # 返回: {"success":true,"imported":2,"skipped":0,"total":2}</pre></div>`} |
| |
| const MODEL_IDS=['claude-fable-5','claude-opus-4-8','claude-opus-4-7','claude-opus-4-6','claude-sonnet-4-6','claude-haiku-4-5','gpt-5.5','gpt-5.4','gpt-5.3-codex','gpt-5.3-chat','gpt-5.4-mini','gpt-5.4-nano','gemini-3.1-pro','gemini-3.5-flash','gemini-3-flash','gemini-3.1-flash-lite','deepseek-v4-pro','deepseek-v4-flash','kimi-k2.7-code','kimi-k2.6','kimi-k2.5','qwen3-32b','qwen3-coder','glm-5.2','glm-5.1','glm-5v-turbo','minimax-m3','minimax-m2.7','minimax-m2','mimo-v2.5-pro','mimo-v2.5']; |
| |
| showTab('dashboard'); |
| </script> |
| </body> |
| </html> |
|
|