stagewise-2api / admin.html
looknicemm1's picture
Admin panel: re-render active tab after API key entry
8f6f551 verified
Raw
History Blame Contribute Delete
20 kB
<!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>