Files
91-api-server/web/static/index.html
yuqianhe 3e27017e42 feat: add auth, db, gateway, email, ratelimit, geoblock modules and new handlers
- New CLI commands: key, user
- New internal modules: auth (middleware, password, user), db (db, logs, products), email, gateway (gateway, hooks, middlewares), handler (admin, auth, dev, log, product, settings), middleware/geoblock, ratelimit
- New demo frontend page
- Updated config, server, analytics, stats, and existing frontend pages
2026-06-26 21:32:51 +09:00

1345 lines
100 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>Sol148 API 开放平台</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<style>
:root{--bg:#f0f2f5;--bg2:#fff;--card:#fff;--border:#e4e7ed;--text:#1a2332;--text2:#6b7280;--primary:#1677ff;--primary-light:#e6f4ff;--success:#22c55e;--warning:#f59e0b;--error:#ef4444;--radius:8px;--shadow:0 1px 3px rgba(0,0,0,0.06)}
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:var(--bg);color:var(--text);min-height:100vh}
a{color:var(--primary);text-decoration:none}
a:hover{color:#4096ff}
.hidden{display:none!important}
/* Top Nav */
#topnav{background:var(--bg2);border-bottom:1px solid var(--border);height:56px;display:flex;align-items:center;padding:0 24px;position:sticky;top:0;z-index:100;box-shadow:var(--shadow)}
#topnav .logo{color:var(--primary);font-weight:700;font-size:1.1rem;margin-right:32px;cursor:pointer}
#topnav .nav-links{display:flex;gap:4px;flex:1}
#topnav .nav-links a{padding:6px 16px;border-radius:6px;color:var(--text2);font-size:0.85rem;transition:.2s}
#topnav .nav-links a:hover{color:var(--text);background:var(--bg)}
#topnav .nav-links a.active{color:var(--primary);background:var(--primary-light);font-weight:600}
#topnav .user-menu{display:flex;align-items:center;gap:12px;position:relative}
#topnav .user-dropdown-btn{display:flex;align-items:center;gap:6px;cursor:pointer;padding:6px 10px;border-radius:6px;transition:.2s;font-size:0.85rem;color:var(--text);border:1px solid transparent}
#topnav .user-dropdown-btn:hover{background:var(--bg);border-color:var(--border)}
#topnav .user-dropdown-btn .arrow{margin-left:2px;font-size:0.6rem;color:var(--text2)}
#topnav .user-dropdown{position:absolute;top:100%;right:0;margin-top:4px;background:var(--bg2);border:1px solid var(--border);border-radius:var(--radius);box-shadow:0 4px 12px rgba(0,0,0,0.1);min-width:180px;z-index:200;padding:4px 0}
#topnav .user-dropdown a{display:flex;align-items:center;gap:8px;padding:9px 16px;color:var(--text);font-size:0.85rem;transition:.1s}
#topnav .user-dropdown a:hover{background:var(--bg)}
#topnav .user-dropdown .divider{height:1px;background:var(--border);margin:4px 0}
#topnav .user-dropdown .email-hint{padding:8px 16px 4px;font-size:0.72rem;color:var(--text2)}
/* Layout */
#app-shell{display:flex;min-height:calc(100vh - 56px)}
#sidebar{width:220px;background:var(--bg2);border-right:1px solid var(--border);padding:12px 0;flex-shrink:0}
#sidebar .side-title{padding:8px 20px 4px;font-size:0.7rem;color:var(--text2);text-transform:uppercase;letter-spacing:.5px}
#sidebar a{display:flex;align-items:center;gap:8px;padding:9px 20px;color:var(--text2);font-size:0.85rem;border-left:3px solid transparent;transition:.2s}
#sidebar a:hover{color:var(--text);background:var(--bg)}
#sidebar a.active{color:var(--primary);background:var(--primary-light);border-left-color:var(--primary);font-weight:600}
#main-content{flex:1;padding:24px;overflow-x:auto;width:100%}
/* Cards */
.card{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);padding:20px;margin-bottom:16px;box-shadow:var(--shadow)}
.card h2{font-size:1.1rem;color:var(--text);margin-bottom:12px}
.card h3{font-size:1rem;margin-bottom:8px}
/* Buttons */
.btn{padding:8px 20px;border:none;border-radius:6px;cursor:pointer;font-size:0.85rem;font-weight:600;display:inline-block;text-decoration:none;transition:.2s}
.btn-primary{background:var(--primary);color:#fff}.btn-primary:hover{background:#4096ff}
.btn-outline{background:transparent;color:var(--primary);border:1px solid var(--primary)}.btn-outline:hover{background:var(--primary);color:#fff}
.btn-sm{padding:4px 12px;font-size:0.78rem}.btn-danger{background:transparent;color:var(--error);border:1px solid var(--error)}.btn-danger:hover{background:var(--error);color:#fff}
.btn:disabled{opacity:.4;cursor:not-allowed}
/* Forms */
input,textarea,select{width:100%;padding:8px 12px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:0.85rem;margin-bottom:12px}
input:focus,textarea:focus,select:focus{outline:none;border-color:var(--primary);box-shadow:0 0 0 2px var(--primary-light)}
label{display:block;font-size:0.78rem;color:var(--text2);margin-bottom:4px;font-weight:500}
.form-row{display:flex;gap:10px;align-items:center}.form-row input{flex:1;margin-bottom:0}
/* Tables */
table{width:100%;border-collapse:collapse;font-size:0.84rem}
th,td{text-align:left;padding:10px 12px;border-bottom:1px solid var(--border)}
th{color:var(--text2);font-weight:600;background:var(--bg);font-size:0.78rem}
td{font-size:0.8rem;font-family:monospace}
tr:hover td{background:var(--bg)}
.tag{padding:2px 8px;border-radius:10px;font-size:0.7rem;display:inline-block;font-weight:500}
.tag-ok{background:#22c55e15;color:var(--success)}.tag-warn{background:#f59e0b15;color:var(--warning)}.tag-err{background:#ef444415;color:var(--error)}
/* Hero */
.hero{text-align:center;padding:60px 20px 40px;background:linear-gradient(135deg,var(--primary-light),#fff)}
.hero h1{font-size:2rem;margin-bottom:8px;color:var(--text)}
.hero p{color:var(--text2);font-size:1rem;max-width:500px;margin:0 auto 24px}
/* Grids */
.features{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:16px;margin:24px 0}
.stat-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:12px}
.stat-card{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);padding:16px;text-align:center;box-shadow:var(--shadow)}
.stat-card .val{font-size:1.5rem;font-weight:700;color:var(--primary)}
.stat-card .lbl{font-size:0.75rem;color:var(--text2);margin-top:4px}
/* Progress */
.progress{height:8px;background:var(--bg);border-radius:4px;overflow:hidden;margin-top:4px}
.progress-fill{height:100%;border-radius:4px}
/* Alert */
.alert{padding:12px 16px;border-radius:6px;margin-bottom:12px;font-size:0.85rem}
.alert-info{background:var(--primary-light);color:var(--primary);border:1px solid #1677ff30}
.alert-success{background:#22c55e15;color:var(--success);border:1px solid #22c55e30}
.alert-error{background:#ef444415;color:var(--error);border:1px solid #ef444430}
/* Modal */
.modal-overlay{position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.45);z-index:200;display:flex;align-items:center;justify-content:center}
.modal{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);padding:24px;max-width:520px;width:90%;box-shadow:0 8px 24px rgba(0,0,0,0.12)}
.modal h2{margin-bottom:12px;font-size:1.1rem}
code{background:var(--bg);padding:2px 6px;border-radius:4px;font-size:0.8rem}
pre{background:var(--bg);padding:12px;border-radius:6px;overflow-x:auto;font-size:0.78rem;line-height:1.5;position:relative}
.copy-btn{position:absolute;top:6px;right:8px;background:var(--card);border:1px solid var(--border);color:var(--text2);padding:2px 8px;border-radius:4px;cursor:pointer;font-size:0.7rem}
.copy-btn:hover{background:var(--primary);color:#fff;border-color:var(--primary)}
/* API Market */
.method-badge{padding:2px 8px;border-radius:4px;font-size:0.7rem;font-weight:700;display:inline-block;margin-right:6px}
.method-get{background:#22c55e15;color:var(--success)}.method-post{background:#1677ff15;color:var(--primary)}
.method-put{background:#f59e0b15;color:var(--warning)}.method-delete{background:#ef444415;color:var(--error)}
.api-card{cursor:pointer;transition:transform .15s,box-shadow .15s}
.api-card:hover{transform:translateY(-2px);box-shadow:0 4px 12px rgba(0,0,0,0.08)}
.api-card-header{display:flex;align-items:center;gap:8px;margin-bottom:8px}
.api-card-meta{font-size:0.75rem;color:var(--text2);margin-top:10px;display:flex;gap:14px;flex-wrap:wrap}
.catalog-group{margin-bottom:24px}
.catalog-group-title{color:var(--text2);font-size:0.82rem;margin-bottom:10px;padding-bottom:6px;border-bottom:1px solid var(--border);font-weight:600}
.catalog-search{margin-bottom:20px;max-width:400px}
.detail-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin:16px 0}
.detail-grid .dl{display:flex;flex-direction:column}.detail-grid .dt{font-size:0.72rem;color:var(--text2)}.detail-grid .dd{font-size:0.85rem;color:var(--text);font-family:monospace}
/* Page headings */
.page-heading{font-size:1.2rem;font-weight:700;margin-bottom:4px}
.page-desc{color:var(--text2);font-size:0.82rem;margin-bottom:20px}
.chart-container{position:relative;width:100%}
.cfg-tab{padding:8px 20px;color:var(--text2);font-size:0.85rem;border-bottom:2px solid transparent;margin-bottom:-2px;transition:.2s;cursor:pointer}
.cfg-tab:hover{color:var(--text)}
.cfg-tab.active{color:var(--primary);border-bottom-color:var(--primary);font-weight:600}
/* Section cards */
.section-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:12px}
.section-header h3{font-size:1rem}
</style>
</head>
<body>
<nav id="topnav">
<a class="logo" onclick="nav('/');return false">Sol148 API</a>
<div class="nav-links" id="navLinks"></div>
<div class="user-menu" id="userMenu"></div>
</nav>
<div id="app-shell">
<aside id="sidebar" class="hidden"></aside>
<main id="main-content"></main>
</div>
<div id="modalOverlay" class="modal-overlay hidden" onclick="closeModal(event)">
<div class="modal" id="modalBox" onclick="event.stopPropagation()"></div>
</div>
<script>
// ═══ Global State ═══
let state={user:null,page:'home',subPage:null};
const API='/api';
function esc(s){if(!s)return '';const d=document.createElement('div');d.textContent=s;return d.innerHTML;}
async function api(path,opts={}){
const r=await fetch(API+path,opts);
const j=await r.json();
if(j.code===401)state.user=null;
return j;
}
// ═══ Router ═══
async function init(){
await loadUser();
nav(window.location.pathname||'/');
window.addEventListener('popstate',onPop);
}
function onPop(){nav(window.location.pathname);}
function nav(path){
const parts=path.split('/').filter(Boolean);
const page=parts[0]||'home';
state.page=page;
state.subPage=parts[1]||null;
window.history.pushState({},'',path);
showPage(page);
}
// ═══ Auth ═══
async function loadUser(){
try{const j=await api('/auth/me');if(j.code===0)state.user=j.data}catch(e){}
renderNav();
}
function renderNav(){
const l=document.getElementById('navLinks');
const u=document.getElementById('userMenu');
const cur=state.page;
const topItems=[{id:'',label:'首页'},{id:'apimarket',label:'API 市场'}];
if(state.user){
topItems.push({id:'dashboard',label:'工作台'});
if(state.user.role==='admin')topItems.push({id:'console',label:'控制台'});
}
l.innerHTML=topItems.map(i=>`<a href="#" onclick="nav('/${i.id}');return false" class="${cur===i.id||(i.id===''&&cur==='home')?'active':''}">${i.label}</a>`).join('')+`<span style="margin-left:auto"></span>`+(state.user?'<a href="#" onclick="nav(\'/demo\');return false">Demo</a>':'')+'<a href="/swagger/index.html" target="_blank">文档</a>';
if(state.user){
u.innerHTML=`
<div class="user-dropdown-btn" onclick="toggleUserMenu(event)">
<span>${esc(state.user.name||state.user.email)}</span><span class="arrow">▼</span>
</div>
<div class="user-dropdown hidden" id="userDropdown">
<div class="email-hint">${esc(state.user.email)}</div>
<a href="#" onclick="closeUserMenu();nav('/profile');return false">👤 个人设置</a>
<div class="divider"></div>
<a href="#" onclick="closeUserMenu();logout();return false" style="color:var(--error)">🚪 退出登录</a>
</div>`;
}else{
u.innerHTML='<a href="#" onclick="nav(\'/login\');return false" class="btn btn-outline btn-sm">登录</a><a href="#" onclick="nav(\'/register\');return false" class="btn btn-primary btn-sm">注册</a>';
}
}
function toggleUserMenu(e){e.stopPropagation();const d=document.getElementById('userDropdown');d.classList.toggle('hidden');if(!d.classList.contains('hidden'))setTimeout(()=>document.addEventListener('click',closeUserMenu,{once:true}),0);}
function closeUserMenu(){document.getElementById('userDropdown').classList.add('hidden');}
async function logout(){await api('/auth/logout',{method:'POST'});state.user=null;renderNav();nav('/');}
// ═══ Sidebar + Page Switcher ═══
function renderSidebar(name){
const s=document.getElementById('sidebar');
s.classList.add('hidden');s.innerHTML='';
if(!state.user)return;
if(name==='dashboard'){
s.classList.remove('hidden');
const subs=[{id:'overview',label:'📊 概览'},{id:'keys',label:'🔑 API Keys'},{id:'subs',label:'📌 我的订阅'},{id:'points',label:'💰 积分记录'},{id:'logs',label:'📋 调用历史'}];
const cur=state.subPage||'overview';
s.innerHTML=`<div class="side-title">工作台</div>${subs.map(i=>`<a href="#" onclick="nav('/dashboard/${i.id}');return false" class="${cur===i.id?'active':''}">${i.label}</a>`).join('')}`;
}else if(name==='console'){
s.classList.remove('hidden');
const subs=[{id:'monitor',label:'📈 运营监控'},{id:'users',label:'👥 用户管理'},{id:'roles',label:'🎭 角色管理'},{id:'keys',label:'🔑 Keys 管理'},{id:'products',label:'📦 API 产品'},{id:'logs',label:'📋 调用日志'},{id:'subs',label:'📌 订阅管理'},{id:'points',label:'💰 积分定价'},{id:'settings',label:'⚙️ 系统设置'},{id:'config',label:'📄 页面配置'}];
const cur=state.subPage||'users';
s.innerHTML=`<div class="side-title">控制台</div>${subs.map(i=>`<a href="#" onclick="nav('/console/${i.id}');return false" class="${cur===i.id?'active':''}">${i.label}</a>`).join('')}`;
}else if(name==='apimarket'){
s.classList.remove('hidden');
s.innerHTML=`<div class="side-title">API 市场</div><a href="#" onclick="nav('/apimarket');return false" class="active">📋 全部产品</a>`;
}
}
function showPage(name){
state.page=name;
const m=document.getElementById('main-content');m.innerHTML='';
renderSidebar(name);
renderNav();
switch(name){
case 'login':if(state.user){nav('/dashboard');return}loginPage(m);break;
case 'register':if(state.user){nav('/dashboard');return}registerPage(m);break;
case 'forgot-password':if(state.user){nav('/dashboard');return}forgotPasswordPage(m);break;
case 'reset-password':if(state.user){nav('/dashboard');return}resetPasswordPage(m);break;
case 'dashboard':if(!state.user){nav('/login');return}dashSub(state.subPage||'overview');break;
case 'demo':if(!state.user){nav('/login');return}demoPage(m);break;
case 'console':if(!state.user||state.user.role!=='admin'){nav('/dashboard');return}consoleSub(state.subPage||'users');break;
case 'profile':if(!state.user){nav('/login');return}profilePage(m);break;
case 'apimarket':apiMarketPage(m);break;
default:homePage(m);
}
}
// ═══ Forgot Password ═══
function forgotPasswordPage(m){
m.innerHTML=`
<div class="card" style="max-width:400px;margin:40px auto">
<h2>🔑 找回密码</h2>
<div id="fpMsg" class="hidden"></div>
<form onsubmit="return doForgotPassword(event)">
<label>邮箱</label><input type="email" id="fpEmail" required>
<button class="btn btn-primary" style="width:100%">发送重置邮件</button>
</form>
<p style="text-align:center;margin-top:12px;font-size:0.85rem"><a href="#" onclick="nav('/login');return false">返回登录</a></p>
</div>`;
}
async function doForgotPassword(e){
e.preventDefault();
const el=document.getElementById('fpMsg');el.classList.add('hidden');
const j=await api('/auth/forgot-password',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({email:document.getElementById('fpEmail').value})});
el.className='alert '+(j.code===0?'alert-success':'alert-error');el.classList.remove('hidden');el.textContent=j.msg;
return false;
}
// ═══ Reset Password ═══
function resetPasswordPage(m){
m.innerHTML=`
<div class="card" style="max-width:400px;margin:40px auto">
<h2>🔐 重置密码</h2>
<div id="rpMsg" class="hidden"></div>
<form onsubmit="return doResetPassword(event)">
<label>重置令牌</label><input type="text" id="rpToken" required placeholder="输入邮件中的令牌">
<label>新密码</label><input type="password" id="rpPwd" required minlength="6" placeholder="至少6位">
<button class="btn btn-primary" style="width:100%">重置密码</button>
</form>
<p style="text-align:center;margin-top:12px;font-size:0.85rem"><a href="#" onclick="nav('/login');return false">返回登录</a></p>
</div>`;
}
async function doResetPassword(e){
e.preventDefault();
const el=document.getElementById('rpMsg');el.classList.add('hidden');
const j=await api('/auth/reset-password',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token:document.getElementById('rpToken').value,new_password:document.getElementById('rpPwd').value})});
el.className='alert '+(j.code===0?'alert-success':'alert-error');el.classList.remove('hidden');el.textContent=j.msg;
if(j.code===0)setTimeout(()=>nav('/login'),2000);
return false;
}
// ═══ Profile ═══
function profilePage(m){
const verified=state.user.email_verified;
m.innerHTML=`
<div class="page-heading">👤 个人设置</div>
<p class="page-desc">管理你的个人信息和密码</p>
<div class="card" style="max-width:500px">
<h3>基本信息</h3>
<div id="profileMsg" class="hidden"></div>
<label>用户名</label><input type="text" id="pfName" value="${esc(state.user.name||'')}">
<label>邮箱</label>
<div class="form-row"><input type="email" id="pfEmail" value="${esc(state.user.email)}"><button class="btn btn-outline btn-sm" onclick="saveEmail()" style="margin-bottom:12px;white-space:nowrap">修改邮箱</button></div>
<div style="margin-bottom:12px">邮箱状态:${verified?'<span class="tag tag-ok">已验证</span>':'<span class="tag tag-err">未验证</span>'} ${!verified?'<button class="btn btn-outline btn-sm" onclick="sendVerifyEmail()">发送验证</button>':''}</div>
<button class="btn btn-primary" onclick="saveProfile()">保存资料</button>
</div>
<div class="card" style="max-width:500px">
<h3>修改密码</h3>
<div id="pwdMsg" class="hidden"></div>
<label>原密码</label><input type="password" id="pfOldPwd">
<label>新密码</label><input type="password" id="pfNewPwd" minlength="6" placeholder="至少6位">
<button class="btn btn-primary" onclick="savePassword()">修改</button>
</div>`;
}
async function saveProfile(){
const name=document.getElementById('pfName').value.trim();
const j=await api('/auth/profile',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({name})});
const el=document.getElementById('profileMsg');
if(j.code!==0){el.className='alert alert-error';el.classList.remove('hidden');el.textContent=j.msg;return;}
state.user=j.data;
renderNav();
el.className='alert alert-success';el.classList.remove('hidden');el.textContent='已保存';
}
async function saveEmail(){
const email=document.getElementById('pfEmail').value.trim();
if(!email){showAlert('请输入邮箱');return;}
const j=await api('/auth/email',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({email})});
const el=document.getElementById('profileMsg');
if(j.code!==0){el.className='alert alert-error';el.classList.remove('hidden');el.textContent=j.msg;return;}
state.user=j.data;
profilePage(document.getElementById('main-content'));
el.className='alert alert-success';el.classList.remove('hidden');el.textContent='邮箱已更新';
}
async function sendVerifyEmail(){
const j=await api('/auth/send-verification',{method:'POST'});
showAlert(j.msg);
}
async function savePassword(){
const old=document.getElementById('pfOldPwd').value;
const pwd=document.getElementById('pfNewPwd').value;
if(pwd.length<6){const el=document.getElementById('pwdMsg');el.className='alert alert-error';el.classList.remove('hidden');el.textContent='新密码至少6位';return;}
const j=await api('/auth/password',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({old_password:old,new_password:pwd})});
const el=document.getElementById('pwdMsg');
if(j.code!==0){el.className='alert alert-error';el.classList.remove('hidden');el.textContent=j.msg;return;}
el.className='alert alert-success';el.classList.remove('hidden');el.textContent='密码已修改';
document.getElementById('pfOldPwd').value='';document.getElementById('pfNewPwd').value='';
}
// ═══ Dashboard Sub-views ═══
function dashSub(tab){
state.subPage=tab;
const m=document.getElementById('main-content');
// re-render sidebar active state
document.querySelectorAll('#sidebar a').forEach(a=>a.classList.toggle('active',a.textContent.trim().includes({overview:'概览',keys:'API Keys',subs:'订阅',points:'积分',logs:'调用历史'}[tab])));
if(tab==='overview')dashboardPage(m);
else if(tab==='keys')dashKeysPage(m);
else if(tab==='subs')dashSubsPage(m);
else if(tab==='points')dashPointsPage(m);
else if(tab==='logs')dashLogsPage(m);
}
let dashQuotaChart = null;
function dashboardPage(m){
m.innerHTML=`
<div class="page-heading">👋 欢迎,${esc(state.user.name||state.user.email)}</div>
<div class="stat-grid" id="ds"></div>
<div id="dashQuickStart"></div>
<div id="dashExtra"></div>`;
loadDash();
}
async function loadDash(){
const j=await api('/developer/usage');
if(j.code===0){const d=j.data;const p=d.usage_percent||0;const lvl=p>85?'err':p>50?'warn':'ok';const rem=d.remaining||0;document.getElementById('ds').innerHTML=`<div class="stat-card"><div class="val">${state.user.points??0}</div><div class="lbl">💰 当前积分</div></div><div class="stat-card"><div class="val">${d.total_used.toLocaleString()}</div><div class="lbl">已用配额</div></div><div class="stat-card"><div class="val">${rem.toLocaleString()}</div><div class="lbl">剩余配额</div></div><div class="stat-card"><div class="val">${d.total_quota<0?'∞':d.total_quota.toLocaleString()}</div><div class="lbl">总配额</div></div><div class="stat-card"><div class="val">${p.toFixed(1)}%</div><div class="lbl">使用率 <div class="progress" style="width:80px;margin:4px auto 0"><div class="progress-fill" style="width:${Math.min(p,100)}%;background:${lvl==='err'?'var(--error)':lvl==='warn'?'var(--warning)':'var(--success)'}"></div></div></div></div><div class="stat-card"><div class="val">${d.key_count}</div><div class="lbl">API Keys</div></div>`;
renderQuotaChart(d);
}
const k=await api('/developer/keys');
api('/admin/config').then(r=>{if(r.code!==0)return;const c=r.data;const md=v=>{try{return window.marked?marked.parse(v):v}catch(e){return v}};
// 快速开始
let qs=null;if(c.dashboard_quickstart_content)qs=md(c.dashboard_quickstart_content);else if(c.dashboard_quickstart_file_content)qs=md(c.dashboard_quickstart_file_content);
const qel=document.getElementById('dashQuickStart');
if(qs&&qel)qel.innerHTML='<div class="section-header" style="margin-top:20px"><h3>🚀 快速开始</h3></div><div class="card">'+qs+'</div>';
else if(qel)qel.innerHTML='<div class="section-header" style="margin-top:20px"><h3>🚀 快速开始</h3></div><div class="card"><pre id="dq">curl -X POST http://localhost:8080/api/extract \\\n -H "X-API-Key: YOUR_KEY" \\\n -H "Content-Type: application/json" \\\n -d \'{"url":"..."}\'<span class="copy-btn" onclick="copyCode()">复制</span></pre></div>';
// 填充默认 key
if(!qs&&k.code===0&&k.data.length>0)document.getElementById('dq').innerHTML=document.getElementById('dq').innerHTML.replace('YOUR_KEY',k.data[0].key)+'<span class="copy-btn" onclick="copyCode()">复制</span>';
// 底部内容
let extra=null;if(c.dashboard_custom_content)extra=md(c.dashboard_custom_content);else if(c.dashboard_content_file_content)extra=md(c.dashboard_content_file_content);
const ex=document.getElementById('dashExtra');if(extra&&ex)ex.innerHTML='<div class="section-header" style="margin-top:20px"><h3>📋 更多信息</h3></div><div class="card">'+extra+'</div>';
});
}
let dashDailyChart = null;
let dashChartMonth = new Date();
function renderQuotaChart(d){
const container=document.getElementById('dashQuickStart');
if(!container)return;
if(dashQuotaChart){dashQuotaChart.destroy();dashQuotaChart=null;}
if(typeof Chart==='undefined')return;
const wrapper=document.createElement('div');
wrapper.id='dashQuotaChartWrap';
wrapper.innerHTML='<div class="section-header" style="margin-top:20px"><h3>📊 配额使用情况</h3></div>'
+'<div style="display:flex;gap:16px;flex-wrap:wrap">'
+'<div class="card" style="flex:1;min-width:200px;padding:16px"><div class="chart-container" style="position:relative;height:220px"><canvas id="quotaChart"></canvas></div></div>'
+'<div class="card" style="flex:2;min-width:300px;padding:16px">'
+'<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px">'
+'<button class="btn btn-outline btn-sm" onclick="dashPrevMonth()"></button>'
+'<span id="dashMonthLabel" style="font-weight:600;font-size:0.9rem;min-width:80px;text-align:center"></span>'
+'<button class="btn btn-outline btn-sm" onclick="dashNextMonth()"></button>'
+'</div>'
+'<div class="chart-container" style="position:relative;height:200px"><canvas id="dashDailyChart"></canvas></div>'
+'</div></div>';
container.parentNode.insertBefore(wrapper,container);
const used=d.total_used||0;
const rem=Math.max(0,(d.remaining||0));
const ctx=document.getElementById('quotaChart');
if(ctx){
dashQuotaChart=new Chart(ctx.getContext('2d'),{
type:'doughnut',
data:{
labels:['已用配额 ('+used.toLocaleString()+')','剩余配额 ('+rem.toLocaleString()+')'],
datasets:[{
data:[used,rem],
backgroundColor:['#3b82f6','#e5e7eb'],
borderWidth:0,
hoverOffset:6
}]
},
options:{
responsive:true,
maintainAspectRatio:false,
cutout:'65%',
plugins:{
legend:{position:'bottom',labels:{padding:16,usePointStyle:true,font:{size:13}}},
tooltip:{callbacks:{label:function(tt){return tt.parsed+' 次'}}}
}
}
});
}
loadDashDaily();
}
async function loadDashDaily(){
const y=dashChartMonth.getFullYear();
const m=dashChartMonth.getMonth()+1;
document.getElementById('dashMonthLabel').textContent=y+'年'+m+'月';
const j=await api('/developer/usage/daily?year='+y+'&month='+m);
if(j.code!==0)return;
const days=j.data||[];
if(dashDailyChart){dashDailyChart.destroy();dashDailyChart=null;}
const canvas=document.getElementById('dashDailyChart');
if(!canvas||typeof Chart==='undefined')return;
const daysInMonth=new Date(y,m,0).getDate();
const labels=[],data=[];
const dayMap={};
days.forEach(d=>{dayMap[parseInt(d.date.substring(8,10))]=d.count});
for(let i=1;i<=daysInMonth;i++){
labels.push(i+'日');
data.push(dayMap[i]||0);
}
dashDailyChart=new Chart(canvas.getContext('2d'),{
type:'line',
data:{
labels:labels,
datasets:[{
label:'调用次数',
data:data,
borderColor:'#3b82f6',
backgroundColor:'rgba(59,130,246,0.08)',
fill:true,
tension:0.4,
pointRadius:3,
pointHoverRadius:6,
pointBackgroundColor:'#3b82f6',
borderWidth:2
}]
},
options:{
responsive:true,
maintainAspectRatio:false,
scales:{
x:{grid:{display:false},ticks:{font:{size:11}}},
y:{beginAtZero:true,ticks:{font:{size:11}}}
},
plugins:{
legend:{display:false},
tooltip:{callbacks:{label:function(tt){return tt.parsed+' 次'}}}
}
}
});
}
function dashPrevMonth(){dashChartMonth.setMonth(dashChartMonth.getMonth()-1);loadDashDaily();}
function dashNextMonth(){dashChartMonth.setMonth(dashChartMonth.getMonth()+1);loadDashDaily();}
function dashKeysPage(m){
m.innerHTML=`
<div class="page-heading">🔑 API Keys</div>
<p class="page-desc">管理你的 API 访问密钥</p>
<div style="margin-bottom:12px"><button class="btn btn-primary btn-sm" onclick="createKey2()">+ 创建 Key</button></div>
<div class="card" id="dk2"></div>`;
loadKeys2();
}
async function loadKeys2(){
const k=await api('/developer/keys');
if(k.code===0&&k.data.length>0){
document.getElementById('dk2').innerHTML=`<table><tr><th>Key</th><th>名称</th><th>用量</th><th>配额</th><th>状态</th><th></th></tr>${k.data.map(x=>`<tr><td><code>${x.key}</code></td><td>${esc(x.name)}</td><td>${x.used}</td><td>${x.quota<0?'无限制':x.quota}${x.quota_locked?' <span class="tag tag-err" style="font-size:0.75rem">锁定</span>':''}</td><td><span class="tag ${x.active?'tag-ok':'tag-err'}">${x.active?'启用':'禁用'}</span></td><td style="display:flex;gap:4px">${x.active?`<button class="btn btn-outline btn-sm" onclick="toggleKey2('${x.key}',false)">禁用</button>`:`<button class="btn btn-outline btn-sm" onclick="toggleKey2('${x.key}',true)">启用</button>`}${!x.quota_locked?`<button class="btn btn-outline btn-sm" onclick="editQuota2('${x.key}',${x.quota})">配额</button>`:''}<button class="btn btn-danger btn-sm" onclick="delKey2('${x.key}')">删除</button></td></tr>`).join('')}</table>`;
}else document.getElementById('dk2').innerHTML='<p style="color:var(--text2)">暂无 Key</p>';
}
async function createKey2(){
const o=document.getElementById('modalOverlay'),b=document.getElementById('modalBox');
b.innerHTML=`<h2>创建 API Key</h2>
<label>名称</label><input type="text" id="keyNameInput" value="Default" style="width:100%;margin-bottom:12px">
<label style="display:flex;align-items:center;gap:6px;margin-bottom:8px"><input type="checkbox" id="keyQuotaEnabledCb" checked onchange="document.getElementById('keyQuotaSettings').style.display=this.checked?'block':'none'"> 启用配额限制</label>
<div id="keyQuotaSettings">
<div style="display:flex;gap:8px;align-items:center;margin-bottom:8px"><input type="number" id="keyQuotaInput" value="1000" min="1" style="flex:1"><label style="display:flex;align-items:center;gap:4px;white-space:nowrap"><input type="checkbox" id="keyUnlimitedCb" onchange="document.getElementById('keyQuotaInput').disabled=this.checked"> 不限</label></div>
</div>
<label style="display:flex;align-items:center;gap:6px;margin-bottom:12px"><input type="checkbox" id="keyQuotaLockedCb"> 创建后不可调整配额</label>
<div style="display:flex;gap:8px"><button class="btn btn-primary" id="createKeySubmit" style="flex:1">创建</button><button class="btn btn-outline" onclick="closeModal()">取消</button></div>`;
o.classList.remove('hidden');
document.getElementById('createKeySubmit').onclick=async function(){
const name=document.getElementById('keyNameInput').value.trim()||'Default';
const enabled=document.getElementById('keyQuotaEnabledCb').checked;
const unlimited=document.getElementById('keyUnlimitedCb').checked;
const quota=enabled?(unlimited?-1:parseInt(document.getElementById('keyQuotaInput').value)||1000):-1;
const quota_locked=document.getElementById('keyQuotaLockedCb').checked;
closeModal();
const j=await api('/developer/keys',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name,quota,quota_locked})});
if(j.code===0){showModal(`<h2>✅ Key 创建成功</h2><pre style="margin:12px 0">${j.data.key}<span class="copy-btn" onclick="copyText('${j.data.key}')">复制</span></pre><div class="alert alert-info">⚠️ 仅显示一次!</div><button class="btn btn-primary" onclick="closeModal()" style="width:100%">关闭</button>`,false);loadKeys2();}else showAlert(j.msg||'创建失败');
};
}
async function delKey2(key){if(await showConfirm('删除?')){await api('/developer/keys/'+encodeURIComponent(key),{method:'DELETE'});loadKeys2();}}
async function toggleKey2(key,active){const label=active?'启用':'禁用';if(await showConfirm(label+'此 Key')){await api('/developer/keys/'+encodeURIComponent(key),{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify({active})});loadKeys2();}}
async function editQuota2(key,curQuota){
const o=document.getElementById('modalOverlay'),b=document.getElementById('modalBox');
const unlimited=curQuota<0;
b.innerHTML=`<h2>修改配额</h2>
<div style="display:flex;gap:8px;align-items:center;margin-bottom:12px"><input type="number" id="eqQuotaInput" value="${unlimited?1000:curQuota}" min="1" style="flex:1" ${unlimited?'disabled':''}><label style="display:flex;align-items:center;gap:4px;white-space:nowrap"><input type="checkbox" id="eqUnlimitedCb" ${unlimited?'checked':''} onchange="document.getElementById('eqQuotaInput').disabled=this.checked"> 不限</label></div>
<div style="display:flex;gap:8px"><button class="btn btn-primary" id="eqSubmit" style="flex:1">保存</button><button class="btn btn-outline" onclick="closeModal()">取消</button></div>`;
o.classList.remove('hidden');
document.getElementById('eqSubmit').onclick=async function(){
const u=document.getElementById('eqUnlimitedCb').checked;
const q=u?-1:parseInt(document.getElementById('eqQuotaInput').value)||1000;
closeModal();
const j=await api('/developer/keys/'+encodeURIComponent(key)+'/quota',{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify({quota:q})});
if(j.code===0)loadKeys2();else showAlert(j.msg||'修改失败');
};
}
function dashSubsPage(m){
m.innerHTML=`
<div class="page-heading">📌 我的订阅</div>
<p class="page-desc">已订阅的 API 产品</p>
<div class="card" id="ds2"><p style="color:var(--text2)">加载中...</p></div>`;
loadSubs2();
}
async function loadSubs2(){
const s=await api('/developer/subscriptions');
const el=document.getElementById('ds2');
if(s.code!==0){el.innerHTML='<p style="color:var(--text2)">加载失败</p>';return;}
const subs=s.data||[];
if(subs.length===0){el.innerHTML='<p style="color:var(--text2)">暂无订阅,去 <a href="#" onclick="nav(\'/apimarket\');return false">API 市场</a> 看看</p>';return;}
const cat=await api('/api-catalog');
const prodMap={};if(cat.code===0&&cat.data)cat.data.forEach(p=>prodMap[p.id]=p.name);
el.innerHTML=`<table><tr><th>API 产品</th><th>用量</th><th>配额</th><th>到期时间</th><th>状态</th><th>订阅时间</th><th></th></tr>
${subs.map(sb=>`<tr><td>${esc(prodMap[sb.product_id]||'ID:'+sb.product_id)}</td><td>${sb.used}</td><td>${sb.quota}</td><td>${sb.expires_at?esc(sb.expires_at.substring(0,10)):'无限'}</td><td><span class="tag ${sb.status==='active'?'tag-ok':'tag-err'}">${sb.status==='active'?'活跃':sb.status}</span></td><td>${esc(sb.created_at||'-')}</td><td>${sb.status==='active'?`<button class="btn btn-danger btn-sm" onclick="cancelSub2(${sb.id})">取消</button>`:''}</td></tr>`).join('')}</table>`;
}
async function cancelSub2(id){if(!await showConfirm('确定取消?'))return;const j=await api('/developer/subscribe/'+id,{method:'DELETE'});if(j.code===0)loadSubs2();else showAlert('取消失败: '+j.msg);}
function dashLogsPage(m){
m.innerHTML=`
<div class="page-heading">📋 调用历史</div>
<p class="page-desc">最近 API 调用记录</p>
<div class="card" id="dl2"><p style="color:var(--text2)">加载中...</p></div>`;
loadLogs2();
}
async function loadLogs2(){
const j=await api('/developer/logs?limit=20');
const el=document.getElementById('dl2');
if(j.code!==0||!j.data.items||j.data.items.length===0){el.innerHTML='<p style="color:var(--text2)">暂无调用记录</p>';return;}
let h='<table><tr><th>时间</th><th>产品</th><th>方法</th><th>状态</th><th>延迟</th></tr>';
j.data.items.forEach(x=>{h+=`<tr><td>${(x.created_at||'').replace('T',' ').substring(0,19)}</td><td><code>${esc(x.product_slug||'-')}</code></td><td><span class="method-badge method-${(x.method||'get').toLowerCase()}">${x.method||'GET'}</span></td><td><span class="tag ${x.status_code>=400?'tag-err':'tag-ok'}">${x.status_code}</span></td><td>${x.latency_ms}ms</td></tr>`;});
h+='</table>';el.innerHTML=h;
}
function dashPointsPage(m){
m.innerHTML=`
<div class="page-heading">💰 积分记录</div>
<p class="page-desc">积分交易流水</p>
<div class="stat-grid" style="grid-template-columns:repeat(3,1fr);margin-bottom:16px">
<div class="stat-card"><div class="val">${state.user.points??0}</div><div class="lbl">当前积分</div></div>
</div>
<div class="card" id="dpList"><p style="color:var(--text2)">加载中...</p></div>`;
loadPointsTx();
}
async function loadPointsTx(){
const j=await api('/developer/points/transactions');
const el=document.getElementById('dpList');
if(j.code!==0||!j.data||j.data.length===0){el.innerHTML='<p style="color:var(--text2)">暂无交易记录</p>';return;}
el.innerHTML=`<table><tr><th>时间</th><th>类型</th><th>变动</th><th>余额</th><th>备注</th></tr>
${j.data.map(t=>`<tr><td>${(t.created_at||'').replace('T',' ').substring(0,19)}</td><td>${txTypeLabel(t.reason)}</td><td style="color:${t.amount>0?'var(--success)':'var(--error)'}">${t.amount>0?'+':''}${t.amount}</td><td>${t.balance_after}</td><td style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(t.reason||'-')}</td></tr>`).join('')}</table>`;
}
function txTypeLabel(t){
const map={admin_adjust:'管理员调整',subscription:'订阅扣费',direct_call:'直接调用',refund:'退款',recharge:'充值'};
return map[t]||t;
}
// ═══ HOME ═══
function homePage(m){
m.innerHTML=`
<div class="hero">
<h1 id="ht">Sol148 API 开放平台</h1>
<p id="hs">高性能视频源提取 API为您的应用提供稳定、快速的视频数据服务</p>
<div style="display:flex;gap:12px;justify-content:center">
${state.user?'<a href="#" onclick="nav(\'/dashboard\');return false" class="btn btn-primary">进入工作台</a>':'<a href="#" onclick="nav(\'/register\');return false" class="btn btn-primary">免费注册</a>'}
<a href="#" onclick="nav(\'/demo\');return false" class="btn btn-outline">在线体验</a>
</div>
</div>
<div style="max-width:900px;margin:0 auto;padding:20px" class="features">
<div class="card" style="text-align:center"><h3>⚡ 毫秒级响应</h3><p style="color:var(--text2);font-size:0.85rem" id="f1">99.9% 可用性</p></div>
<div class="card" style="text-align:center"><h3>🔌 5 分钟集成</h3><p style="color:var(--text2);font-size:0.85rem" id="f2">RESTful 设计,快速上手</p></div>
<div class="card" style="text-align:center"><h3>📊 灵活配额</h3><p style="color:var(--text2);font-size:0.85rem" id="f3">按需扩展,用量透明</p></div>
</div>`;
fetch('/api/admin/config').then(r=>r.json()).then(j=>{
if(j.code!=0)return;const c=j.data;
const md=v=>{try{return window.marked?marked.parse(v):v}catch(e){return v}};
if(c.hero_title)document.getElementById('ht').innerHTML=md(c.hero_title);
if(c.hero_subtitle)document.getElementById('hs').innerHTML=md(c.hero_subtitle);
if(c.feature_1)document.getElementById('f1').innerHTML=md(c.feature_1);
if(c.feature_2)document.getElementById('f2').innerHTML=md(c.feature_2);
if(c.feature_3)document.getElementById('f3').innerHTML=md(c.feature_3);
let extra=null;
if(c.custom_content)extra=md(c.custom_content);
else if(c.content_file_content)extra=md(c.content_file_content);
if(extra){
let ex=document.getElementById('extraContent');
if(!ex){
ex=document.createElement('div');
ex.id='extraContent';
ex.style.cssText='max-width:900px;margin:20px auto;padding:0 20px';
document.querySelector('.features').after(ex);
}
ex.innerHTML=extra;
}
}).catch(()=>{});
}
// ═══ LOGIN ═══
function loginPage(m){
m.innerHTML=`
<div class="card" style="max-width:400px;margin:40px auto">
<h2>🔐 登录</h2>
<div id="loginErr" class="hidden"></div>
<form onsubmit="return doLogin(event)">
<label>邮箱</label><input type="email" id="loginEmail" required>
<label>密码</label><input type="password" id="loginPwd" required>
<button class="btn btn-primary" style="width:100%">登录</button>
</form>
<p style="text-align:center;margin-top:12px;font-size:0.85rem"><a href="#" onclick="nav('/register');return false">注册新账号</a> · <a href="#" onclick="nav('/forgot-password');return false">忘记密码</a></p>
</div>`;
}
async function doLogin(e){
e.preventDefault();
const j=await api('/auth/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({email:document.getElementById('loginEmail').value,password:document.getElementById('loginPwd').value})});
if(j.code!==0){let el=document.getElementById('loginErr');el.className='alert alert-error';el.classList.remove('hidden');el.textContent=j.msg;return false}
await loadUser();nav('/dashboard');return false;
}
// ═══ REGISTER ═══
function registerPage(m){
m.innerHTML=`
<div class="card" style="max-width:400px;margin:40px auto" id="regCard">
<h2>📝 注册</h2>
<div id="regErr" class="hidden"></div>
<p style="color:var(--text2)">检查注册状态...</p>
</div>`;
api('/admin/settings').then(j=>{
if(j.code===0&&j.data?.registration_enabled==='0'){
document.getElementById('regCard').innerHTML='<h2>📝 注册</h2><div class="alert alert-error">注册已关闭</div><p style="text-align:center;margin-top:12px;font-size:0.85rem"><a href="#" onclick="nav(\'/login\');return false">已有账号?</a></p>';
return;
}
document.getElementById('regCard').innerHTML=`
<h2>📝 注册</h2>
<div id="regErr" class="hidden"></div>
<form onsubmit="return doRegister(event)">
<label>用户名</label><input type="text" id="regName" required>
<label>邮箱</label><input type="email" id="regEmail" required>
<label>密码</label><input type="password" id="regPwd" required minlength="6" placeholder="至少6位">
<button class="btn btn-primary" style="width:100%">注册</button>
</form>
<p style="text-align:center;margin-top:12px;font-size:0.85rem"><a href="#" onclick="nav('/login');return false">已有账号?</a></p>`;
});
}
async function doRegister(e){
e.preventDefault();
const pwd=document.getElementById('regPwd').value;
if(pwd.length<6){let el=document.getElementById('regErr');el.className='alert alert-error';el.classList.remove('hidden');el.textContent='密码至少6位';return false}
const j=await api('/auth/register',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:document.getElementById('regName').value,email:document.getElementById('regEmail').value,password:pwd})});
if(j.code!==0){let el=document.getElementById('regErr');el.className='alert alert-error';el.classList.remove('hidden');el.textContent=j.msg;return false}
await loadUser();
showModal(`<h2>🎉 注册成功!</h2><p style="color:var(--text2);margin-bottom:16px">首次创建 API Key——请立即复制保存</p><div id="obKey"></div><div class="alert alert-info">⚠️ 此 Key <b>仅显示一次</b></div><button class="btn btn-primary" onclick="closeModal();nav('/dashboard');" style="width:100%;margin-top:12px">进入工作台</button>`,false);
api('/developer/keys',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:'Default'})}).then(j=>{if(j.code===0)document.getElementById('obKey').innerHTML=`<pre style="margin-bottom:12px">${j.data.key}<span class="copy-btn" onclick="copyText('${j.data.key}')">复制</span></pre>`;});
return false;
}
// ═══ API MARKET ═══
async function loadCatalog(filter){
const j=await api('/api-catalog');
const el=document.getElementById('catalogContent');
if(j.code!==0){el.innerHTML='<p style="color:var(--error)">加载失败: '+esc(j.msg)+'</p>';return;}
const prods=j.data||[];
const f=(filter||'').toLowerCase();
const filtered=f?prods.filter(p=>(p.name||'').toLowerCase().includes(f)||(p.description||'').toLowerCase().includes(f)||(p.path||'').toLowerCase().includes(f)):prods;
if(filtered.length===0){el.innerHTML='<div class="card" style="text-align:center;padding:40px"><p style="color:var(--text2)">暂无 API 产品</p></div>';return;}
const groups={};const ungrouped=[];
filtered.forEach(p=>{if(p.group_name){if(!groups[p.group_name])groups[p.group_name]=[];groups[p.group_name].push(p);}else ungrouped.push(p);});
let h='';const groupNames=Object.keys(groups);if(ungrouped.length>0)groupNames.unshift('全部 API');
groupNames.forEach(gn=>{
const items=gn==='全部 API'?ungrouped:groups[gn];
if(gn!=='全部 API')h+=`<div class="catalog-group"><div class="catalog-group-title">📁 ${esc(gn)}</div>`;else h+='<div class="catalog-group"><div class="catalog-group-title">📁 全部 API</div>';
items.forEach(p=>{
const methodClass='method-'+p.method.toLowerCase();
h+=`<div class="card api-card" onclick="showProductDetail('${esc(p.slug)}')">
<div class="api-card-header"><span class="method-badge ${methodClass}">${esc(p.method)}</span><strong>${esc(p.name)}</strong><span style="font-size:0.75rem;color:var(--text2)">v${esc(p.version||'1')}</span></div>
<p style="color:var(--text2);font-size:0.85rem;margin-bottom:6px">${esc(p.description||'暂无描述')}</p>
<div class="api-card-meta"><span>路径: <code>${esc(p.path)}</code></span><span>💰 ${p.price_points>0?p.price_points+' 积分':'免费'}</span></div>
</div>`;
});h+='</div>';
});el.innerHTML=h;
}
function apiMarketPage(m){
m.innerHTML=`
<div class="page-heading">🛒 API 市场</div>
<p class="page-desc">浏览并订阅可用的 API 产品</p>
<input class="catalog-search" placeholder="🔍 搜索 API 名称、描述或路径..." oninput="loadCatalog(this.value)" id="catalogSearch">
<div id="catalogContent"><p style="color:var(--text2)">加载中...</p></div>`;
loadCatalog();
}
async function showProductDetail(slug){
const j=await api('/api-catalog/'+encodeURIComponent(slug));
if(j.code!==0){showAlert('API 不存在');return;}
const p=j.data;const methodClass='method-'+(p.method||'get').toLowerCase();const methodUpper=(p.method||'GET').toUpperCase();
let html=`<h2 style="margin-bottom:4px"><span class="method-badge ${methodClass}">${methodUpper}</span>${esc(p.name)}</h2>
<p style="color:var(--text2);margin-bottom:16px">${esc(p.description||'暂无描述')}</p>
<div class="detail-grid">
<div class="dl"><span class="dt">路径</span><span class="dd">${esc(p.path||'-')}</span></div>
<div class="dl"><span class="dt">版本</span><span class="dd">v${esc(p.version||'1')}</span></div>
<div class="dl"><span class="dt">分组</span><span class="dd">${esc(p.group_name||'未分组')}</span></div>
<div class="dl"><span class="dt">状态</span><span class="dd"><span class="tag tag-ok">${esc(p.status||'published')}</span></span></div>
<div class="dl"><span class="dt">超时</span><span class="dd">${p.timeout_ms||10000}ms</span></div>
<div class="dl"><span class="dt">重试次数</span><span class="dd">${p.retry_count||0}</span></div>
<div class="dl"><span class="dt">缓存 TTL</span><span class="dd">${p.cache_ttl_seconds||0}s</span></div>
<div class="dl"><span class="dt">需要认证</span><span class="dd">${p.auth_required?'<span class="tag tag-warn">需要</span>':'<span class="tag tag-ok">不需要</span>'}</span></div>
<div class="dl"><span class="dt">上游地址</span><span class="dd" style="word-break:break-all">${esc(p.upstream_url||'-')}</span></div>
<div class="dl"><span class="dt">默认配额</span><span class="dd">${p.default_quota||1000} 次</span></div>
<div class="dl"><span class="dt">积分价格</span><span class="dd">${p.price_points>0?p.price_points+' 积分':'免费'}</span></div>
<div class="dl"><span class="dt">有效时长</span><span class="dd">${p.duration_days>0?p.duration_days+' 天':'无限'}</span></div>
<div class="dl"><span class="dt">重复订阅</span><span class="dd">${p.resubscribe_interval===0?'<span class="tag tag-ok">不限</span>':p.resubscribe_interval<0?'<span class="tag tag-err">禁止</span>':'<span class="tag tag-warn">'+p.resubscribe_interval+'天内不可重复</span>'}</span></div>
<div class="dl"><span class="dt">创建时间</span><span class="dd">${esc(p.created_at||'-')}</span></div>
</div>`;
if(state.user){
const btnLabel=p.resubscribe_interval===0?'📌 订阅/购买此 API':'📌 订阅此 API';
html+=`<div style="margin-top:16px;padding-top:12px;border-top:1px solid var(--border)"><button class="btn btn-primary" onclick="subscribeAPI(${p.id})" style="width:100%">${btnLabel}</button></div>`;
}else html+=`<div style="margin-top:16px;padding-top:12px;border-top:1px solid var(--border)"><p style="color:var(--text2)">请<a href="#" onclick="closeModal();nav('/login');return false">登录</a>后订阅</p></div>`;
html+='<button class="btn btn-outline" onclick="closeModal()" style="width:100%;margin-top:12px">关闭</button>';showModal(html,true);
}
async function subscribeAPI(productId){
const j=await api('/developer/subscribe',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({product_id:productId})});
if(j.code===0){closeModal();showModal('<h2>✅ 订阅成功</h2><p style="color:var(--text2);margin:12px 0">已成功订阅</p><button class="btn btn-primary" onclick="closeModal();if(state.page===\'dashboard\')nav(\'/dashboard/subs\')" style="width:100%">关闭</button>',true);}
else showAlert('订阅失败: '+j.msg);
}
// ═══ DEMO ═══
function demoPage(m){m.innerHTML=`<iframe src="/demo/index.html" style="width:100%;height:calc(100vh - 120px);border:none;border-radius:var(--radius)"></iframe>`;}
// ═══ CONSOLE ═══
async function consoleSub(tab){
if(tab!=='monitor')clearMonitorRefresh();
state.subPage=tab;
document.querySelectorAll('#sidebar a').forEach(a=>a.classList.toggle('active',a.textContent.trim().includes({users:'用户',roles:'角色',keys:'Keys 管理',products:'API 产品',logs:'调用日志',subs:'订阅管理',monitor:'运营监控',points:'积分',settings:'系统设置',config:'首页配置'}[tab])));
const el=document.getElementById('main-content');
if(tab==='users'){
el.innerHTML=`<div class="page-heading">👥 用户管理</div>
<p class="page-desc">所有注册用户</p>
<div style="margin-bottom:12px"><button class="btn btn-primary btn-sm" onclick="showCreateUser()">+ 创建用户</button></div>
<div class="card" id="cu"></div>`;
loadUsers();
}else if(tab==='roles'){
el.innerHTML=`<div class="page-heading">🎭 角色管理</div><p class="page-desc">系统角色及其权限说明</p><div class="card" id="cr"></div>`;
loadRoles();
}else if(tab==='keys'){
el.innerHTML=`<div class="page-heading">🔑 Keys 管理</div><p class="page-desc">所有 API Key</p><div class="card" id="ck"></div>`;
const j=await api('/admin/keys');document.getElementById('ck').innerHTML=j.code===0?`<table><tr><th>UID</th><th>Key</th><th>名称</th><th>用量</th><th>配额</th><th>状态</th><th></th></tr>${j.data.map(k=>`<tr><td>${k.user_id}</td><td><code>${k.key}</code></td><td>${esc(k.name)}</td><td>${k.used}</td><td>${k.quota<0?'无限':k.quota}${k.quota_locked?' <span class="tag tag-err" style="font-size:0.75rem">锁定</span>':''}</td><td><span class="tag ${k.active?'tag-ok':'tag-err'}">${k.active?'启用':'禁用'}</span></td><td style="display:flex;gap:4px">${k.active?`<button class="btn btn-outline btn-sm" onclick="adminToggleKey('${k.key}',false)">禁用</button>`:`<button class="btn btn-outline btn-sm" onclick="adminToggleKey('${k.key}',true)">启用</button>`}<button class="btn btn-outline btn-sm" onclick="adminEditQuota('${k.key}',${k.quota})">配额</button><button class="btn btn-danger btn-sm" onclick="adminDelKey2('${k.key}')">删除</button></td></tr>`).join('')}</table>`:'加载失败';
}else if(tab==='products'){
el.innerHTML=`<div class="page-heading">📦 API 产品</div><p class="page-desc">管理 API 产品和分组</p>
<div style="display:flex;gap:0;margin-bottom:16px;border-bottom:2px solid var(--border)">
<a href="#" class="cfg-tab" id="cProdTabProducts" onclick="switchCProdTab('products');return false">🔌 API 产品</a>
<a href="#" class="cfg-tab active" id="cProdTabGroups" onclick="switchCProdTab('groups');return false">📁 API 分组</a>
</div>
<div id="cp"></div>`;
switchCProdTab('products');
}else if(tab==='logs'){
el.innerHTML=`<div class="page-heading">📋 调用日志</div><p class="page-desc">24h API 调用摘要与最近调用</p><div id="cl"></div>`;cLogs(document.getElementById('cl'));
}else if(tab==='subs'){
el.innerHTML=`<div class="page-heading">📌 订阅管理</div><p class="page-desc">管理所有用户的 API 订阅</p><div id="cs2"></div>`;cSubs(document.getElementById('cs2'));
}else if(tab==='monitor'){
el.innerHTML='';monitorPage(el);
}else if(tab==='points'){
el.innerHTML=`<div class="page-heading">💰 积分定价</div><p class="page-desc">配置 API 调用消耗积分的价格</p>
<div class="card"><h3>无订阅直接调用定价</h3>
<div id="ppMsg" class="hidden"></div>
<label>每次 API 调用消耗积分</label>
<input type="number" id="ppDirectPrice" min="0">
<button class="btn btn-primary" onclick="savePointsConfig()">💾 保存</button></div>`;
loadPointsConfig();
}else if(tab==='settings'){
el.innerHTML=`<div class="page-heading">⚙️ 系统设置</div><p class="page-desc">管理平台全局配置</p><div id="settingsRoot"></div>`;
settingsPage(document.getElementById('settingsRoot'));
}else if(tab==='config'){
el.innerHTML=`<div class="page-heading">📄 页面配置</div><p class="page-desc">自定义各页面内容,支持 Markdown 和 HTML 格式</p>
<div style="display:flex;gap:0;margin-bottom:16px;border-bottom:2px solid var(--border)">
<a href="#" id="cfgTabHome" class="cfg-tab active" onclick="switchCfgTab('home');return false">🏠 首页配置</a>
<a href="#" id="cfgTabDash" class="cfg-tab" onclick="switchCfgTab('dash');return false">📋 工作台页面配置</a>
</div>
<div id="cc"></div>`;
loadCfgTab('home');
}
}
let cfgTabData=null;
async function loadCfgTab(tab){
const j=await api('/admin/config');if(j.code!=0)return;
cfgTabData=j.data;
document.querySelectorAll('.cfg-tab').forEach(t=>t.classList.toggle('active',t.id==='cfgTabHome'?tab==='home':tab==='dash'));
const skipKeys=['file_content','dashboard_content_file_content','dashboard_quickstart_file_content'];
let h='<div id="settingsMsg" class="hidden"></div><p style="color:var(--text2);font-size:0.82rem;margin-bottom:12px">所有内容支持 Markdown 和 HTML</p>';
if(tab==='home'){
const keys=['hero_title','hero_subtitle','hero_cta','feature_1','feature_2','feature_3','custom_content','content_file'];
h+=`<div class="card">`;
keys.forEach(k=>{const v=j.data[k]||'';h+=`<div style="margin-bottom:12px"><label>${esc(k)}</label><textarea id="cfg_${k}" rows="3" style="resize:vertical">${esc(v)}</textarea></div>`;});
h+=`</div>`;
}else{
const keys={bottom:['dashboard_custom_content','dashboard_content_file'],quickstart:['dashboard_quickstart_content','dashboard_quickstart_file']};
h+=`<div class="card" style="margin-bottom:16px"><h3>📋 概览底部内容</h3>`;
keys.bottom.forEach(k=>{const v=j.data[k]||'';h+=`<div style="margin-bottom:12px"><label>${esc(k)}</label><textarea id="cfg_${k}" rows="3" style="resize:vertical">${esc(v)}</textarea></div>`;});
h+=`</div><div class="card"><h3>🚀 快速开始</h3>`;
keys.quickstart.forEach(k=>{const v=j.data[k]||'';h+=`<div style="margin-bottom:12px"><label>${esc(k)}</label><textarea id="cfg_${k}" rows="3" style="resize:vertical">${esc(v)}</textarea></div>`;});
h+=`</div>`;
}
h+='<button class="btn btn-primary" onclick="saveCfgAll()" style="margin-top:4px">💾 保存</button>';
document.getElementById('cc').innerHTML=h;
}
function switchCfgTab(tab){loadCfgTab(tab);}
async function saveCfgAll(){const cfg={};const skipKeys=['file_content','dashboard_content_file_content','dashboard_quickstart_file_content'];document.querySelectorAll('#cc textarea').forEach(i=>{const key=i.id.replace('cfg_','');if(!skipKeys.includes(key))cfg[key]=i.value;});const j=await api('/admin/config',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(cfg)});const el=document.getElementById('settingsMsg');el.className='alert '+(j.code===0?'alert-success':'alert-error');el.classList.remove('hidden');el.textContent=j.msg||(j.code===0?'已保存':'保存失败');}
// ── 用户管理 ──
async function loadUsers(){
const j=await api('/admin/users');
const cu=document.getElementById('cu');
if(j.code!==0){cu.innerHTML='<p style="color:var(--error)">加载失败</p>';return;}
const users=j.data||[];
if(users.length===0){cu.innerHTML='<p style="color:var(--text2)">暂无用户</p>';return;}
cu.innerHTML=`<table><tr><th>ID</th><th>邮箱</th><th>名称</th><th>角色</th><th>积分</th><th>状态</th><th>注册</th><th>操作</th></tr>
${users.map(u=>{
const me=state.user&&state.user.id===u.id;
const statusHtml=u.disabled?'<span class="tag tag-err">已禁用</span>':'<span class="tag tag-ok">正常</span>';
let ops='<div style="display:flex;gap:4px;flex-wrap:wrap">';
ops+=`<button class="btn btn-outline btn-sm" onclick="adjustUserPoints(${u.id},'${esc(u.name||u.email)}')">积分</button>`;
if(!me){
ops+=`<button class="btn btn-outline btn-sm" onclick="editUser(${u.id},'${esc(u.name)}','${esc(u.email)}')">编辑</button>`;
ops+=`<select onchange="changeUserRole(${u.id},this.value)" style="width:auto;margin:0;padding:2px 6px;font-size:0.75rem"><option value="user" ${u.role==='user'?'selected':''}>user</option><option value="admin" ${u.role==='admin'?'selected':''}>admin</option></select>`;
ops+=`<button class="btn btn-outline btn-sm" onclick="resetUserPwd(${u.id})">改密</button>`;
ops+=u.disabled
?`<button class="btn btn-outline btn-sm" onclick="toggleUserDisable(${u.id},false)">启用</button>`
:`<button class="btn btn-outline btn-sm" onclick="toggleUserDisable(${u.id},true)">禁用</button>`;
ops+=`<button class="btn btn-danger btn-sm" onclick="deleteUser(${u.id})">删除</button>`;
}else{
ops+='<span style="color:var(--text2);font-size:0.78rem">当前用户</span>';
}
ops+='</div>';
return `<tr><td>${u.id}</td><td>${esc(u.email)}</td><td>${esc(u.name)}</td><td><span class="tag ${u.role==='admin'?'tag-warn':'tag-ok'}">${u.role}</span></td><td><strong>${u.points}</strong></td><td>${statusHtml}</td><td style="font-size:0.75rem">${(u.created_at||'').replace('T',' ').substring(0,16)}</td><td>${ops}</td></tr>`;
}).join('')}</table>`;
}
function showCreateUser(){
showModal(`
<h2>创建用户</h2>
<div id="cuMsg" class="hidden"></div>
<label>邮箱</label><input type="email" id="cuEmail" required>
<label>密码</label><input type="password" id="cuPwd" required minlength="6" placeholder="至少6位">
<label>用户名</label><input type="text" id="cuName" placeholder="选填">
<label>角色</label>
<select id="cuRole"><option value="user">user</option><option value="admin">admin</option></select>
<button class="btn btn-primary" onclick="doCreateUser()" style="width:100%;margin-top:8px">创建</button>
<button class="btn btn-outline" onclick="closeModal()" style="width:100%;margin-top:8px">取消</button>`,false);
}
async function doCreateUser(){
const email=document.getElementById('cuEmail').value.trim();
const password=document.getElementById('cuPwd').value;
const name=document.getElementById('cuName').value.trim();
const role=document.getElementById('cuRole').value;
if(!email||!password){showAlert('请填写邮箱和密码');return;}
const j=await api('/admin/users',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({email,password,name,role})});
if(j.code===0){closeModal();showAlert('✅ 用户创建成功');consoleSub('users');}
else showAlert('创建失败: '+j.msg);
}
async function editUser(id,name,email){
showModal(`
<h2>编辑用户 #${id}</h2>
<div id="euMsg" class="hidden"></div>
<label>用户名</label><input type="text" id="euName" value="${esc(name)}">
<label>邮箱</label><input type="email" id="euEmail" value="${esc(email)}">
<button class="btn btn-primary" onclick="doEditUser(${id})" style="width:100%;margin-top:8px">保存</button>
<button class="btn btn-outline" onclick="closeModal()" style="width:100%;margin-top:8px">取消</button>`,false);
}
async function doEditUser(id){
const name=document.getElementById('euName').value.trim();
const email=document.getElementById('euEmail').value.trim();
const j=await api('/admin/users/'+id,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({name,email})});
if(j.code===0){closeModal();showAlert('✅ 已更新');consoleSub('users');}
else showAlert('更新失败: '+j.msg);
}
async function deleteUser(id){
if(!await showConfirm('确定删除此用户?此操作不可恢复!'))return;
const j=await api('/admin/users/'+id,{method:'DELETE'});
if(j.code===0){showAlert('✅ 用户已删除');consoleSub('users');}
else showAlert('删除失败: '+j.msg);
}
async function adjustUserPoints(id,name){
showModal(`
<h2>调整积分 — ${esc(name)}</h2>
<div id="apMsg" class="hidden"></div>
<label>调整数量(正数增加,负数扣除)</label><input type="number" id="apAmount" value="0">
<label>原因</label><input type="text" id="apReason" placeholder="如: 充值、退款、奖励">
<button class="btn btn-primary" onclick="doAdjustPoints(${id})" style="width:100%;margin-top:8px">确认调整</button>
<button class="btn btn-outline" onclick="closeModal();showPointTx(${id},'${esc(name)}')" style="width:100%;margin-top:4px">查看交易记录</button>
<button class="btn btn-outline" onclick="closeModal()" style="width:100%;margin-top:4px">取消</button>`,false);
}
async function doAdjustPoints(id){
const amount=parseInt(document.getElementById('apAmount').value)||0;
const reason=document.getElementById('apReason').value.trim()||'管理员调整';
if(amount===0){showAlert('调整数量不能为0');return;}
const j=await api('/admin/users/'+id+'/points',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({amount,reason})});
if(j.code===0){closeModal();showAlert('✅ 积分已更新 ('+(amount>0?'+':'')+amount+'),当前: '+j.data.points);consoleSub('users');}
else showAlert('操作失败: '+j.msg);
}
async function showPointTx(id,name){
const j=await api('/admin/users/'+id+'/points/transactions');
if(j.code!==0){showAlert('加载失败');return;}
const txs=j.data||[];
showModal(`
<h2>积分交易记录 — ${esc(name)}</h2>
${txs.length===0?'<p style="color:var(--text2)">暂无记录</p>':
`<table style="font-size:0.78rem"><tr><th>时间</th><th>数量</th><th>余额</th><th>原因</th></tr>
${txs.map(t=>`<tr><td>${(t.created_at||'').replace('T',' ').substring(0,16)}</td><td style="color:${t.amount>=0?'var(--success)':'var(--error)'}">${t.amount>=0?'+':''}${t.amount}</td><td>${t.balance_after}</td><td>${esc(t.reason)}</td></tr>`).join('')}</table>`}
<button class="btn btn-outline" onclick="closeModal()" style="width:100%;margin-top:12px">关闭</button>`,true);
}
async function toggleUserDisable(id,disabled){
const label=disabled?'禁用':'启用';
if(!await showConfirm(label+'此用户?'))return;
const j=await api('/admin/users/'+id+'/disable',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({disabled})});
if(j.code===0){showAlert('✅ 用户已'+label);consoleSub('users');}
else showAlert('操作失败: '+j.msg);
}
async function resetUserPwd(id){
showModal(`
<h2>重置用户密码</h2>
<div id="rpMsg" class="hidden"></div>
<label>新密码</label><input type="password" id="rpNewPwd" required minlength="6" placeholder="至少6位">
<button class="btn btn-primary" onclick="doResetUserPwd(${id})" style="width:100%;margin-top:8px">重置</button>
<button class="btn btn-outline" onclick="closeModal()" style="width:100%;margin-top:8px">取消</button>`,false);
}
async function doResetUserPwd(id){
const password=document.getElementById('rpNewPwd').value;
if(password.length<6){showAlert('密码至少6位');return;}
const j=await api('/admin/users/'+id+'/password',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({password})});
if(j.code===0){closeModal();showAlert('✅ 密码已重置');}
else showAlert('重置失败: '+j.msg);
}
async function changeUserRole(id,role){const label=role==='admin'?'提升为管理员':'降级为普通用户';if(!await showConfirm(label+''))return;const j=await api('/admin/users/'+id+'/role',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({role})});if(j.code===0)consoleSub('users');else showAlert('操作失败: '+j.msg);}
// ── 角色管理 ──
// ── 积分定价 ──
async function loadPointsConfig(){
const j=await api('/admin/points/config');
if(j.code===0){document.getElementById('ppDirectPrice').value=j.data.direct_call_price||'1';}
}
async function savePointsConfig(){
const direct_call_price=document.getElementById('ppDirectPrice').value||'1';
const j=await api('/admin/points/config',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({direct_call_price})});
const el=document.getElementById('ppMsg');
el.className='alert '+(j.code===0?'alert-success':'alert-error');el.classList.remove('hidden');el.textContent=j.msg||(j.code===0?'已保存':'保存失败');
}
async function loadRoles(){
const j=await api('/admin/users/roles');
const el=document.getElementById('cr');
if(j.code!==0){el.innerHTML='<p style="color:var(--error)">加载失败</p>';return;}
const roles=j.data||[];
if(roles.length===0){el.innerHTML='<p style="color:var(--text2)">暂无角色数据</p>';return;}
el.innerHTML=`
<table><tr><th>角色</th><th>用户数</th><th>权限说明</th></tr>
${roles.map(r=>`<tr><td><span class="tag ${r.role==='admin'?'tag-warn':'tag-ok'}">${esc(r.role)}</span></td><td>${r.user_count}</td><td>${esc(r.permission)}</td></tr>`).join('')}</table>
<p style="color:var(--text2);font-size:0.78rem;margin-top:12px">💡 在用户管理中可为用户分配不同角色</p>`;
}
async function adminToggleKey(key,active){const label=active?'启用':'禁用';if(await showConfirm(label+'此 Key')){await api('/admin/keys/'+encodeURIComponent(key),{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify({active})});consoleSub('keys');}}
async function adminDelKey2(key){if(await showConfirm('删除?')){await api('/admin/keys/'+encodeURIComponent(key)+'/force',{method:'DELETE'});consoleSub('keys');}}
async function adminEditQuota(key,curQuota){
const o=document.getElementById('modalOverlay'),b=document.getElementById('modalBox');
const unlimited=curQuota<0;
b.innerHTML=`<h2>修改配额</h2>
<div style="display:flex;gap:8px;align-items:center;margin-bottom:12px"><input type="number" id="aeqQuotaInput" value="${unlimited?1000:curQuota}" min="1" style="flex:1" ${unlimited?'disabled':''}><label style="display:flex;align-items:center;gap:4px;white-space:nowrap"><input type="checkbox" id="aeqUnlimitedCb" ${unlimited?'checked':''} onchange="document.getElementById('aeqQuotaInput').disabled=this.checked"> 不限</label></div>
<div style="display:flex;gap:8px"><button class="btn btn-primary" id="aeqSubmit" style="flex:1">保存</button><button class="btn btn-outline" onclick="closeModal()">取消</button></div>`;
o.classList.remove('hidden');
document.getElementById('aeqSubmit').onclick=async function(){
const u=document.getElementById('aeqUnlimitedCb').checked;
const q=u?-1:parseInt(document.getElementById('aeqQuotaInput').value)||1000;
closeModal();
const j=await api('/admin/keys/'+encodeURIComponent(key)+'/quota',{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify({quota:q})});
if(j.code===0)consoleSub('keys');else showAlert(j.msg||'修改失败');
};
}
function copyCode(){navigator.clipboard.writeText(document.getElementById('dq')?.textContent.replace('复制','')||'');showAlert('已复制');}
// ── Console: 调用日志 ──────────────────────
async function cLogs(el){
el.innerHTML='<p style="color:var(--text2)">加载中...</p>';
const j=await api('/admin/logs/summary');
if(j.code===0&&j.data.length>0){
let h='<h3 style="font-size:0.95rem;margin-bottom:10px">📊 24h API 调用摘要</h3><table><tr><th>产品</th><th>标识</th><th>调用次数</th><th>平均延迟</th><th>错误数</th><th>错误率</th></tr>';
j.data.forEach(s=>{h+=`<tr><td style="font-family:sans-serif">${esc(s.product_slug||'N/A')}</td><td><code>${esc(s.product_slug||'-')}</code></td><td>${s.total_calls.toLocaleString()}</td><td>${s.avg_latency_ms.toFixed(1)}ms</td><td>${s.error_count}</td><td><span class="tag ${s.error_rate>10?'tag-err':s.error_rate>2?'tag-warn':'tag-ok'}">${s.error_rate.toFixed(1)}%</span></td></tr>`;});
h+='</table>';el.innerHTML=h;
}else el.innerHTML='<div class="card"><p style="color:var(--text2);text-align:center">暂无调用数据</p></div>';
const l=await api('/admin/logs?limit=20');
if(l.code===0&&l.data.items&&l.data.items.length>0){
let t='<h3 style="font-size:0.95rem;margin-top:20px;margin-bottom:10px">📋 最近调用</h3><table><tr><th>时间</th><th>产品</th><th>方法</th><th>路径</th><th>状态</th><th>延迟</th><th>IP</th></tr>';
l.data.items.forEach(x=>{t+=`<tr><td>${(x.created_at||'').replace('T',' ').substring(0,19)}</td><td><code>${esc(x.product_slug||'-')}</code></td><td><span class="method-badge method-${(x.method||'get').toLowerCase()}">${x.method||'GET'}</span></td><td style="max-width:180px;overflow:hidden;text-overflow:ellipsis"><code>${esc(x.path||'-')}</code></td><td><span class="tag ${x.status_code>=400?'tag-err':x.status_code>=200?'tag-ok':'tag-warn'}">${x.status_code}</span></td><td>${x.latency_ms}ms</td><td>${esc(x.client_ip||'-')}</td></tr>`;});
t+='</table>';el.innerHTML+=t;
}
}
// ── Console: 订阅管理 ──────────────────────
async function cSubs(el){
el.innerHTML='<p style="color:var(--text2)">加载中...</p>';
const j=await api('/admin/subscriptions');
if(j.code!==0){el.innerHTML='<p style="color:var(--error)">加载失败</p>';return;}
const subs=j.data||[];
if(subs.length===0){el.innerHTML='<div class="card"><p style="color:var(--text2);text-align:center">暂无订阅</p></div>';return;}
let h='<table><tr><th>ID</th><th>产品ID</th><th>用量</th><th>配额</th><th>到期</th><th>状态</th><th>订阅时间</th><th></th></tr>';
subs.forEach(s=>{h+=`<tr><td>${s.id}</td><td>${s.product_id}</td><td>${s.used||0}</td><td><input type="number" id="sq_${s.id}" value="${s.quota||1000}" style="width:70px;display:inline-block;margin:0;padding:4px 6px;font-size:0.78rem"></td><td>${s.expires_at?esc(s.expires_at.substring(0,10)):'无限'}</td><td><span class="tag ${s.status==='active'?'tag-ok':s.status==='cancelled'?'tag-err':'tag-warn'}">${s.status||'active'}</span></td><td>${(s.created_at||'').substring(0,10)}</td><td><button class="btn btn-outline btn-sm" onclick="setSubQuota2(${s.id})">保存</button></td></tr>`;});
h+='</table>';el.innerHTML=h;
}
async function setSubQuota2(id){const q=parseInt(document.getElementById('sq_'+id).value)||1000;await api('/admin/subscriptions/'+id+'/quota',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({quota:q})});cSubs(document.getElementById('cs2'));}
// ── Console: API 产品管理 ──────────────────
let cProdTab='products';let cProductsMode='list';let cEditGroup=null;let cEditProduct=null;
function switchCProdTab(tab){
cProdTab=tab;cProductsMode='list';cEditGroup=null;cEditProduct=null;
document.getElementById('cProdTabGroups').parentElement.querySelectorAll('.cfg-tab').forEach(t=>t.classList.toggle('active',t.id==='cProdTab'+(tab==='groups'?'Groups':'Products')));
cProducts(document.getElementById('cp'));
}
async function cProducts(el){
const [gs,ps]=await Promise.all([api('/admin/api-groups'),api('/admin/api-products')]);
const groups=gs.code===0?gs.data:[];const products=ps.code===0?ps.data:[];const gMap={};groups.forEach(g=>gMap[g.id]=g.name);
if(cProdTab==='groups'){
if(cProductsMode==='list'){
let h=`<div class="card"><div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px"><h3 style="font-size:0.95rem">📁 API 分组</h3><button class="btn btn-primary btn-sm" onclick="cProductsMode='groupForm';cEditGroup=null;cProducts(document.getElementById('cp'))">+ 新建分组</button></div>`;
if(groups.length===0)h+='<p style="color:var(--text2)">暂无分组</p>';
else h+=`<table><tr><th>ID</th><th>名称</th><th>标识</th><th>描述</th><th>排序</th><th></th></tr>${groups.map(g=>`<tr><td>${g.id}</td><td>${esc(g.name)}</td><td><code>${esc(g.slug)}</code></td><td style="font-family:sans-serif">${esc(g.description||'-')}</td><td>${g.sort_order||0}</td><td style="display:flex;gap:6px"><button class="btn btn-outline btn-sm" onclick="cProductsMode='groupForm';cEditGroup=${g.id};cProducts(document.getElementById('cp'))">编辑</button><button class="btn btn-danger btn-sm" onclick="deleteGroup2(${g.id})">删除</button></td></tr>`).join('')}</table>`;
h+='</div>';el.innerHTML=h;
}else{
const g=cEditGroup?groups.find(x=>x.id===cEditGroup):null;
el.innerHTML=`<div class="card"><h3 style="margin-bottom:12px">${g?'编辑':'新建'}分组</h3><label>名称</label><input id="gf_name" value="${esc(g?g.name:'')}"><label>标识</label><input id="gf_slug" value="${esc(g?g.slug:'')}"><label>描述</label><input id="gf_desc" value="${esc(g?g.description:'')}"><label>排序</label><input type="number" id="gf_sort" value="${g?g.sort_order:0}"><div style="display:flex;gap:8px;margin-top:8px"><button class="btn btn-primary" onclick="saveGroup2()">保存</button><button class="btn btn-outline" onclick="switchCProdTab('groups')">取消</button></div></div>`;
}
}else{
if(cProductsMode==='list'){
let h=`<div class="card"><div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px"><h3 style="font-size:0.95rem">🔌 API 产品</h3><button class="btn btn-primary btn-sm" onclick="cProductsMode='productForm';cEditProduct=null;cProducts(document.getElementById('cp'))">+ 新建产品</button></div>`;
if(products.length===0)h+='<p style="color:var(--text2)">暂无产品</p>';
else h+=`<table><tr><th>ID</th><th>名称</th><th>标识</th><th>分组</th><th>方法</th><th>路径</th><th>状态</th><th>重复订阅</th><th></th></tr>${products.map(p=>`<tr><td>${p.id}</td><td>${esc(p.name)}</td><td><code>${esc(p.slug)}</code></td><td>${esc(gMap[p.group_id]||'-')}</td><td><span class="method-badge method-${(p.method||'get').toLowerCase()}">${p.method||'GET'}</span></td><td style="max-width:130px;overflow:hidden;text-overflow:ellipsis"><code>${esc(p.path)}</code></td><td>${p.status==='published'?'<span class="tag tag-ok">已发布</span>':p.status==='draft'?(p.publish_at?'<span class="tag tag-warn">定时:'+(p.publish_at.substring(0,16).replace('T',' '))+'\u200b</span>':'<span class="tag tag-err">待发布</span>'):'<span class="tag tag-err">弃用</span>'}</td><td>${p.resubscribe_interval===0?'<span class="tag tag-ok">不限</span>':p.resubscribe_interval<0?'<span class="tag tag-err">禁止</span>':'<span class="tag tag-warn">'+p.resubscribe_interval+'天</span>'}</td><td style="display:flex;gap:6px"><button class="btn btn-outline btn-sm" onclick="cProductsMode='productForm';cEditProduct=${p.id};cProducts(document.getElementById('cp'))">编辑</button><button class="btn btn-danger btn-sm" onclick="deleteProduct2(${p.id})">删除</button></td></tr>`).join('')}</table>`;
h+='</div>';el.innerHTML=h;
}else{
const p=cEditProduct?products.find(x=>x.id===cEditProduct):null;
const methodOpts=['GET','POST','PUT','DELETE'].map(m=>`<option ${p&&p.method===m?'selected':''}>${m}</option>`).join('');
const ri=p?p.resubscribe_interval:0;
const resubEnabled=ri>=0;
const resubDays=ri>0?ri:0;
const st=p?p.status:'draft';
const pa=p&&p.publish_at?p.publish_at.substring(0,16):'';
el.innerHTML=`<div class="card"><h3 style="margin-bottom:12px">${p?'编辑':'新建'}API 产品</h3><label>名称</label><input id="pf_name" value="${esc(p?p.name:'')}"><label>标识</label><input id="pf_slug" value="${esc(p?p.slug:'')}"><label>描述</label><input id="pf_desc" value="${esc(p?p.description:'')}"><label>路径</label><input id="pf_path" value="${esc(p?p.path:'')}"><label>请求方法</label><select id="pf_method">${methodOpts}</select><label>超时(ms)</label><input type="number" id="pf_timeout" value="${p?p.timeout_ms:10000}"><label>分组</label><select id="pf_group"><option value="">无分组</option>${groups.map(g=>`<option value="${g.id}" ${p&&p.group_id===g.id?'selected':''}>${esc(g.name)}</option>`).join('')}</select><label>上游地址</label><input id="pf_upstream" value="${esc(p?p.upstream_url:'')}"><label>状态</label><select id="pf_status" onchange="var e=document.getElementById('pf_publish_at_wrap');if(e)e.style.display=this.value==='draft'?'':'none'"><option value="draft" ${st==='draft'?'selected':''}>待发布</option><option value="published" ${st==='published'?'selected':''}>已发布</option><option value="deprecated" ${st==='deprecated'?'selected':''}>弃用</option></select><div id="pf_publish_at_wrap" style="${st==='draft'?'':'display:none'};margin:8px 0 0 0"><label>定时发布</label><input type="datetime-local" id="pf_publish_at" value="${pa}"></div><label>需要认证</label><input type="checkbox" id="pf_auth" ${p&&p.auth_required?'checked':''} style="width:auto;margin-left:4px"><label>缓存 TTL (秒)</label><input type="number" id="pf_cache" value="${p?p.cache_ttl_seconds:0}"><label>重试次数</label><input type="number" id="pf_retry" value="${p?p.retry_count:0}"><label>默认配额</label><input type="number" id="pf_default_quota" value="${p?p.default_quota:1000}"><label>有效天数 (0=无限)</label><input type="number" id="pf_duration_days" value="${p?p.duration_days:0}"><label>积分价格 (订阅消耗)</label><input type="number" id="pf_price_points" value="${p?p.price_points:0}" min="0"><label>允许重复订阅</label><input type="checkbox" id="pf_resub_enabled" ${resubEnabled?'checked':''} style="width:auto;margin-left:4px" onchange="document.getElementById('pf_resub_days_wrap').style.display=this.checked?'':'none'"><div id="pf_resub_days_wrap" style="${resubEnabled?'':'display:none'};margin-top:4px"><label style="font-size:0.82rem;color:var(--text2)">间隔天数0=不限)</label><input type="number" id="pf_resub_days" value="${resubDays}" min="0" style="margin-top:2px"></div><div style="display:flex;gap:8px;margin-top:8px"><button class="btn btn-primary" onclick="saveProduct2()">保存</button><button class="btn btn-outline" onclick="switchCProdTab('products')">取消</button></div></div>`;
}
}
}
async function saveGroup2(){const data={name:document.getElementById('gf_name').value,slug:document.getElementById('gf_slug').value,description:document.getElementById('gf_desc').value,sort_order:parseInt(document.getElementById('gf_sort').value)||0};const url=cEditGroup?'/admin/api-groups/'+cEditGroup:'/admin/api-groups';const method=cEditGroup?'PUT':'POST';const j=await api(url,{method,headers:{'Content-Type':'application/json'},body:JSON.stringify(data)});if(j.code===0){cProductsMode='list';cProducts(document.getElementById('cp'));}else showAlert('保存失败: '+j.msg);}
async function saveProduct2(){const enabled=document.getElementById('pf_resub_enabled').checked;const days=parseInt(document.getElementById('pf_resub_days').value)||0;const st=document.getElementById('pf_status').value;const pa=st==='draft'?document.getElementById('pf_publish_at').value:'';const data={name:document.getElementById('pf_name').value,slug:document.getElementById('pf_slug').value,description:document.getElementById('pf_desc').value,path:document.getElementById('pf_path').value,method:document.getElementById('pf_method').value,timeout_ms:parseInt(document.getElementById('pf_timeout').value)||10000,group_id:parseInt(document.getElementById('pf_group').value)||0,upstream_url:document.getElementById('pf_upstream').value,status:st,auth_required:document.getElementById('pf_auth').checked,cache_ttl_seconds:parseInt(document.getElementById('pf_cache').value)||0,retry_count:parseInt(document.getElementById('pf_retry').value)||0,default_quota:parseInt(document.getElementById('pf_default_quota').value)||1000,duration_days:parseInt(document.getElementById('pf_duration_days').value)||0,price_points:parseInt(document.getElementById('pf_price_points').value)||0,resubscribe_interval:enabled?days:-1,publish_at:pa?pa+':00Z':''};const url=cEditProduct?'/admin/api-products/'+cEditProduct:'/admin/api-products';const method=cEditProduct?'PUT':'POST';const j=await api(url,{method,headers:{'Content-Type':'application/json'},body:JSON.stringify(data)});if(j.code===0){cProductsMode='list';cProducts(document.getElementById('cp'));}else showAlert('保存失败: '+j.msg);}
async function deleteGroup2(id){if(await showConfirm('确定删除?')){const j=await api('/admin/api-groups/'+id,{method:'DELETE'});if(j.code===0)cProducts(document.getElementById('cp'));else showAlert('删除失败: '+j.msg);}}
async function deleteProduct2(id){if(await showConfirm('确定删除?')){const j=await api('/admin/api-products/'+id,{method:'DELETE'});if(j.code===0)cProducts(document.getElementById('cp'));else showAlert('删除失败: '+j.msg);}}
// ── Console: 运营监控 ──────────────────────
let monitorRefreshTimer=null;
const monitorPeriods=[{v:'1h',l:'1小时'},{v:'12h',l:'12小时'},{v:'1d',l:'1天'},{v:'7d',l:'7天'},{v:'30d',l:'30天'},{v:'90d',l:'90天'}];
const monitorPeriodBuckets={'1h':12,'12h':24,'1d':24,'7d':28,'30d':30,'90d':30};
function monitorPeriodLabel(p){const m=monitorPeriods.find(x=>x.v===p);return m?m.l:p;}
const monitorCharts={};
function monitorDestroyCharts(){Object.values(monitorCharts).forEach(c=>{try{c.destroy()}catch(e){}});Object.keys(monitorCharts).forEach(k=>delete monitorCharts[k]);}
async function monitorPage(el){
const refreshVal=state.monitorRefresh||'0';
const periodVal=state.monitorPeriod||'1h';
const refreshOpts=[{v:'0',l:'关闭'},{v:'5',l:'5s'},{v:'10',l:'10s'},{v:'15',l:'15s'},{v:'30',l:'30s'}];
el.innerHTML=`
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
<div class="page-heading" style="margin:0">📈 运营监控</div>
<div style="display:flex;align-items:center;gap:8px;font-size:0.82rem;color:var(--text2)">
<span>时间范围</span>
<select id="monitorPeriodSelect" onchange="state.monitorPeriod=this.value;loadMonitorData()" style="width:auto;margin:0;padding:4px 8px;font-size:0.82rem">
${monitorPeriods.map(o=>`<option value="${o.v}" ${o.v===periodVal?'selected':''}>${o.l}</option>`).join('')}
</select>
<span>自动刷新</span>
<select id="monitorRefreshSelect" onchange="setMonitorRefresh(this.value)" style="width:auto;margin:0;padding:4px 8px;font-size:0.82rem">
${refreshOpts.map(o=>`<option value="${o.v}" ${o.v===refreshVal?'selected':''}>${o.l}</option>`).join('')}
</select>
</div>
</div>
<div id="monitorContent"></div>`;
loadMonitorData();
setMonitorRefresh(refreshVal);
}
function setMonitorRefresh(sec){
state.monitorRefresh=sec;
if(monitorRefreshTimer){clearInterval(monitorRefreshTimer);monitorRefreshTimer=null;}
const n=parseInt(sec);
if(n>0)monitorRefreshTimer=setInterval(loadMonitorData,n*1000);
}
function clearMonitorRefresh(){monitorDestroyCharts();if(monitorRefreshTimer){clearInterval(monitorRefreshTimer);monitorRefreshTimer=null;}state.monitorRefresh='0';}
function monitorQ(p){const b=monitorPeriodBuckets[p]||12;return `period=${p}&buckets=${b}`;}
const chartColors=['#1677ff','#22c55e','#f59e0b','#ef4444','#8b5cf6','#ec4899','#14b8a6','#f97316','#06b6d4','#84cc16'];
async function loadMonitorData(){
monitorDestroyCharts();
const c=document.getElementById('monitorContent');if(!c)return;
const period=state.monitorPeriod||'1h';
const [ov,ts,st,la,ep,ti]=await Promise.all([
api('/stats/overview'),api('/stats/requests?'+monitorQ(period)),
api('/stats/status?period='+period),api('/stats/latency'),
api('/stats/endpoints?top=10&period='+period),api('/stats/top-ips?top=10&period='+period)
]);
let h='';
const pLabel=monitorPeriodLabel(period);
// 概览统计卡片
if(ov.code===0){const d=ov.data;
h+=`<div class="stat-grid" style="margin-bottom:16px">${[
{v:d.total_requests?.toLocaleString(),l:'总请求数'},
{v:d.requests_per_sec?.toFixed(1),l:'当前 QPS'},
{v:d.active_conn??'-',l:'活跃连接'},
{v:d.avg_latency_ms?.toFixed(1)+'ms',l:'平均延迟'},
{v:(d.error_rate??0).toFixed(2)+'%',l:'错误率'},
{v:d.unique_ips?.toLocaleString(),l:'独立 IP'},
{v:d.total_traffic_mb!=null?d.total_traffic_mb.toFixed(1)+'MB':'0MB',l:'总流量'},
{v:d.uptime_seconds?Math.floor(d.uptime_seconds/1e9/3600)+'h':'0h',l:'运行时长'}
].map(s=>`<div class="stat-card"><div class="val">${s.v}</div><div class="lbl">${s.l}</div></div>`).join('')}</div>`;
}
// 请求量柱状图
if(ts.code===0&&Array.isArray(ts.data)&&ts.data.length>0){
const d=ts.data;
h+=`<div class="card"><h3>📊 请求量 (最近${pLabel})</h3><div class="chart-container" style="height:220px"><canvas id="mcReq"></canvas></div></div>`;
}
// 状态码圆环图 & 延迟柱状图 并排
h+=`<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:16px">`;
if(st.code===0){const arr=st.data||[];const m={};arr.forEach(x=>m[x.code]=x.count);
h+=`<div class="card" style="margin:0"><h3>📊 状态码 (最近${pLabel})</h3><div class="chart-container" style="height:220px"><canvas id="mcStatus"></canvas></div></div>`;
}
if(la.code===0){const d=la.data;
h+=`<div class="card" style="margin:0"><h3>⏱ 延迟 (最近5min)</h3><div class="chart-container" style="height:220px"><canvas id="mcLatency"></canvas></div></div>`;
}
h+=`</div>`;
// 端点表格 + 柱状图
if(ep.code===0&&Array.isArray(ep.data)&&ep.data.length>0){
h+=`<div class="card"><h3>🔝 Top 端点 (最近${pLabel})</h3><div style="display:grid;grid-template-columns:1fr 1fr;gap:16px"><div><table><tr><th>端点</th><th>请求数</th><th>延迟</th><th>错误率</th></tr>${ep.data.map(e=>`<tr><td style="font-family:sans-serif"><code>${esc(e.path||'-')}</code></td><td>${e.count||0}</td><td>${(e.avg_latency_ms??0).toFixed(1)}ms</td><td><span class="tag ${(e.error_rate||0)>20?'tag-err':(e.error_rate||0)>5?'tag-warn':'tag-ok'}">${(e.error_rate||0).toFixed(1)}%</span></td></tr>`).join('')}</table></div><div class="chart-container" style="height:250px"><canvas id="mcEndpoints"></canvas></div></div></div>`;
}
// IP 表格 + 柱状图
if(ti.code===0&&Array.isArray(ti.data)&&ti.data.length>0){
h+=`<div class="card"><h3>🔝 Top IP (最近${pLabel})</h3><div style="display:grid;grid-template-columns:1fr 1fr;gap:16px"><div><table><tr><th>IP</th><th>请求数</th><th>地区</th></tr>${ti.data.map(i=>`<tr><td><code>${esc(i.ip||'-')}</code></td><td>${(i.count||0).toLocaleString()}</td><td>${esc(i.region||'-')}</td></tr>`).join('')}</table></div><div class="chart-container" style="height:250px"><canvas id="mcIps"></canvas></div></div></div>`;
}
c.innerHTML=h||'<p style="color:var(--text2)">暂无数据</p>';
requestAnimationFrame(()=>{monitorDrawCharts(ts,st,la,ep,ti,period,pLabel);});
}
function monitorDrawCharts(ts,st,la,ep,ti,period,pLabel){
const baseOpts={responsive:true,maintainAspectRatio:false,plugins:{legend:{labels:{boxWidth:12,boxHeight:12,font:{size:11},padding:8}}}};
// 请求量柱状图
if(ts.code===0&&Array.isArray(ts.data)&&ts.data.length>0){
const d=ts.data;
const labels=d.map((x,i)=>x.time?x.time.replace(/^(\d{1,2}):/,'$1:'):'');
const vals=d.map(x=>x.count||0);
const ctx=document.getElementById('mcReq');
if(ctx)monitorCharts.req=new Chart(ctx,{type:'line',data:{labels,datasets:[{label:'请求数',data:vals,borderColor:'#1677ff',backgroundColor:'#1677ff20',fill:true,tension:0.5,pointRadius:3,pointHitRadius:8,borderWidth:2}]},options:{...baseOpts,scales:{x:{grid:{display:false},ticks:{font:{size:9},maxRotation:0}},y:{beginAtZero:true,ticks:{font:{size:10}}}}}});
}
// 状态码圆环图
if(st.code===0){const arr=st.data||[];const m={};arr.forEach(x=>m[x.code]=x.count);
const labels=Object.keys(m);const vals=Object.values(m);const colors=labels.map(l=>l.startsWith('2')?'#22c55e':l.startsWith('3')?'#1677ff':l.startsWith('4')?'#f59e0b':'#ef4444');
const ctx=document.getElementById('mcStatus');
if(ctx)monitorCharts.status=new Chart(ctx,{type:'doughnut',data:{labels,datasets:[{data:vals,backgroundColor:colors,borderWidth:1}]},options:{...baseOpts,plugins:{...baseOpts.plugins,legend:{...baseOpts.plugins.legend,position:'right'}}}});
}
// 延迟柱状图
if(la.code===0){const d=la.data;
const items=[{l:'平均',v:d.avg_ms},{l:'P50',v:d.p50_ms},{l:'P90',v:d.p90_ms},{l:'P95',v:d.p95_ms},{l:'P99',v:d.p99_ms},{l:'最大',v:d.max_ms}].filter(x=>x.v!=null);
const ctx=document.getElementById('mcLatency');
if(ctx)monitorCharts.latency=new Chart(ctx,{type:'bar',data:{labels:items.map(x=>x.l),datasets:[{label:'ms',data:items.map(x=>x.v),backgroundColor:['#1677ff80','#22c55e80','#f59e0b80','#8b5cf680','#ef444480','#ec489980'],borderColor:['#1677ff','#22c55e','#f59e0b','#8b5cf6','#ef4444','#ec4899'],borderWidth:1,borderRadius:2}]},options:{...baseOpts,scales:{x:{grid:{display:false}},y:{beginAtZero:true,ticks:{font:{size:10},callback:v=>v+'ms'}}}}});
}
// 端点水平条形图
if(ep.code===0&&Array.isArray(ep.data)&&ep.data.length>0){
const d=ep.data.slice(0,10).reverse();
const ctx=document.getElementById('mcEndpoints');
if(ctx)monitorCharts.endpoints=new Chart(ctx,{type:'bar',data:{labels:d.map(x=>x.path?.length>20?x.path.substring(0,20)+'…':x.path||''),datasets:[{label:'请求数',data:d.map(x=>x.count||0),backgroundColor:chartColors[0]+'80',borderColor:chartColors[0],borderWidth:1,borderRadius:2}]},options:{...baseOpts,indexAxis:'y',scales:{x:{beginAtZero:true,ticks:{font:{size:10}}},y:{grid:{display:false},ticks:{font:{size:9}}}}}});
}
// IP 水平条形图
if(ti.code===0&&Array.isArray(ti.data)&&ti.data.length>0){
const d=ti.data.slice(0,10).reverse();
const ctx=document.getElementById('mcIps');
if(ctx)monitorCharts.ips=new Chart(ctx,{type:'bar',data:{labels:d.map(x=>x.ip||''),datasets:[{label:'请求数',data:d.map(x=>x.count||0),backgroundColor:chartColors[3]+'80',borderColor:chartColors[3],borderWidth:1,borderRadius:2}]},options:{...baseOpts,indexAxis:'y',scales:{x:{beginAtZero:true,ticks:{font:{size:10}}},y:{grid:{display:false},ticks:{font:{size:9}}}}}});
}
}
// ── Console: 系统设置 ──────────────────────
const SysSettingsMeta={
// 平台功能
registration_enabled:{label:'开放注册',type:'switch',group:'平台功能'},
email_verification_required:{label:'要求邮箱验证',type:'switch',group:'平台功能'},
maintenance_mode:{label:'维护模式(仅管理员可访问)',type:'switch',group:'平台功能'},
api_key_auth_required:{label:'API Key 强制认证',type:'switch',group:'平台功能',note:'重启生效,开启后请求必须带有效 API Key'},
// 站点信息
site_name:{label:'站点名称',type:'text',group:'站点信息'},
site_description:{label:'站点描述',type:'text',group:'站点信息'},
site_keywords:{label:'站点关键词',type:'text',group:'站点信息'},
// SMTP 邮件
smtp_host:{label:'SMTP 主机',type:'text',group:'邮件服务'},
smtp_port:{label:'SMTP 端口',type:'text',group:'邮件服务'},
smtp_user:{label:'SMTP 用户名',type:'text',group:'邮件服务'},
smtp_pass:{label:'SMTP 密码',type:'password',group:'邮件服务'},
smtp_from:{label:'SMTP 发件地址',type:'text',group:'邮件服务'},
// 安全策略
password_min_length:{label:'密码最小长度',type:'number',group:'安全策略'},
password_require_upper:{label:'需要大写字母',type:'switch',group:'安全策略'},
password_require_lower:{label:'需要小写字母',type:'switch',group:'安全策略'},
password_require_digit:{label:'需要数字',type:'switch',group:'安全策略'},
password_require_special:{label:'需要特殊字符',type:'switch',group:'安全策略'},
password_min_age_days:{label:'密码最短使用天数0=不限)',type:'number',group:'安全策略'},
auth_token_expiry_hours:{label:'Token 过期时间(小时)',type:'number',group:'安全策略'},
reset_token_expiry_minutes:{label:'重置/验证令牌有效期(分钟)',type:'number',group:'安全策略'},
max_api_keys_per_user:{label:'每用户最大 API Key 数',type:'number',group:'安全策略'},
max_login_attempts:{label:'最大登录尝试次数',type:'number',group:'安全策略',note:'超出后锁定'},
login_lockout_minutes:{label:'登录锁定时间(分钟)',type:'number',group:'安全策略'},
// API / 网关默认值
api_key_default_quota:{label:'API Key 默认配额',type:'number',group:'API 默认值'},
subscription_default_quota:{label:'订阅默认配额',type:'number',group:'API 默认值'},
api_key_monthly_reset:{label:'每月重置 Key 用量',type:'switch',group:'API 默认值'},
product_default_timeout_ms:{label:'产品默认超时 (ms)',type:'number',group:'API 默认值'},
product_default_retry_count:{label:'产品默认重试次数',type:'number',group:'API 默认值'},
product_default_cache_ttl:{label:'产品默认缓存 TTL (s)',type:'number',group:'API 默认值'},
// IP / 地区拦截
geo_blocking_enabled:{label:'IP/地区拦截',type:'switch',group:'IP / 地区拦截'},
blocked_countries:{label:'拦截国家(逗号分隔)',type:'text',group:'IP / 地区拦截',note:'如:美国,俄罗斯'},
blocked_ips:{label:'拦截 IP/CIDR逗号分隔',type:'text',group:'IP / 地区拦截',note:'如192.168.1.0/24,10.0.0.1'},
blocked_api_paths:{label:'生效 API 路径前缀(逗号分隔)',type:'text',group:'IP / 地区拦截',note:'留空 = 所有路径'},
// 日志与统计
log_retention_days:{label:'日志保留天数',type:'number',group:'日志与统计'},
analytics_max_records:{label:'统计最大记录数',type:'number',group:'日志与统计'},
stats_cleanup_interval_min:{label:'统计清理间隔(分钟)',type:'number',group:'日志与统计',note:'重启生效'},
stats_log_interval_min:{label:'统计日志间隔(分钟)',type:'number',group:'日志与统计',note:'重启生效'},
// 限流(需配置文件中修改后重启)
rate_limit_enabled:{label:'限流开关',type:'switch',group:'限流(需重启生效)'},
rate_limit_key_rps:{label:'每 Key 每秒请求数',type:'number',group:'限流(需重启生效)'},
rate_limit_key_burst:{label:'每 Key 突发容量',type:'number',group:'限流(需重启生效)'},
rate_limit_ip_rps:{label:'每 IP 每秒请求数',type:'number',group:'限流(需重启生效)'},
rate_limit_ip_burst:{label:'每 IP 突发容量',type:'number',group:'限流(需重启生效)'},
// 熔断(需重启生效)
circuit_breaker_failure_threshold:{label:'熔断连续失败阈值',type:'number',group:'熔断(需重启生效)'},
circuit_breaker_open_timeout_sec:{label:'熔断开启超时(秒)',type:'number',group:'熔断(需重启生效)'},
circuit_breaker_halfopen_max_reqs:{label:'半开最大探测数',type:'number',group:'熔断(需重启生效)'},
// 服务器(需重启生效)
server_read_timeout_sec:{label:'读超时(秒)',type:'number',group:'服务器(需重启生效)'},
server_write_timeout_sec:{label:'写超时(秒)',type:'number',group:'服务器(需重启生效)'},
max_request_body_mb:{label:'最大请求体 (MB)',type:'number',group:'服务器(需重启生效)'},
};
let settingsTabData=null;
const settingsTabs=[{id:'security',label:'🛡️ 安全与功能',groups:['平台功能','安全策略','IP / 地区拦截']},{id:'email',label:'📧 邮件服务',groups:['邮件服务']},{id:'api',label:'⚙️ API 默认值',groups:['API 默认值']},{id:'log',label:'📊 日志与统计',groups:['日志与统计']},{id:'perf',label:'⚡ 性能',groups:['站点信息','限流(需重启生效)','熔断(需重启生效)','服务器(需重启生效)']}];
async function settingsPage(el){
const j=await api('/admin/settings');
if(j.code!==0){el.innerHTML='<div class="card"><p style="color:var(--error)">加载失败</p></div>';return;}
settingsTabData=j.data||{};
const curTab=state.settingsTab||'security';
el.innerHTML=`<div style="display:flex;gap:0;margin-bottom:16px;border-bottom:2px solid var(--border)">${settingsTabs.map((t,i)=>`<a href="#" class="cfg-tab ${t.id===curTab?'active':''}" onclick="state.settingsTab='${t.id}';loadSettingsTab();return false">${t.label}</a>`).join('')}</div><div id="settingsTabContent"></div>`;
loadSettingsTab();
}
function loadSettingsTab(){
const tab=state.settingsTab||'security';
// Update tab active states
const root=document.getElementById('settingsRoot');
if(root)root.querySelectorAll('.cfg-tab').forEach(t=>t.classList.toggle('active',t.getAttribute('onclick')?.includes("'"+tab+"'")||false));
const def=settingsTabs.find(t=>t.id===tab);if(!def)return;
const cfg=settingsTabData;const groups={};
for(const[k,meta]of Object.entries(SysSettingsMeta)){const g=meta.group||'其他';if(!groups[g])groups[g]=[];groups[g].push({key:k,meta});}
let h='<div id="settingsMsg" class="hidden"></div>';
def.groups.forEach(gn=>{
if(!groups[gn]||groups[gn].length===0)return;
h+=`<div class="card"><h3>${esc(gn)}</h3>`;
groups[gn].forEach(({key,meta})=>{
const v=cfg[key]||'';const noteHtml=meta.note?`<span style="font-size:0.7rem;color:var(--warning);margin-left:8px">${esc(meta.note)}</span>`:'';
if(meta.type==='switch'){const on=v==='1'||v==='true';
h+=`<div class="form-row" style="margin-bottom:12px"><label style="min-width:180px">${meta.label}${noteHtml}</label><label style="display:flex;align-items:center;gap:8px;font-size:0.85rem;color:var(--text);cursor:pointer"><input type="checkbox" id="s_${key}" ${on?'checked':''} style="width:auto;margin:0">${on?'已开启':'已关闭'}</label></div>`;
}else{h+=`<div style="margin-bottom:12px"><label>${meta.label}${noteHtml}</label><input type="${meta.type}" id="s_${key}" value="${esc(v)}"></div>`;}
});
if(gn==='邮件服务')h+=`<div style="margin-top:16px;padding-top:12px;border-top:1px solid var(--border)"><label style="font-size:0.78rem;color:var(--text2);margin-bottom:4px;font-weight:500">测试邮件</label><div id="testEmailMsg" class="hidden"></div><div class="form-row"><input type="email" id="testEmailTo" value="${esc(cfg.smtp_from||'')}" placeholder="收件地址" style="margin:0"><button class="btn btn-outline btn-sm" onclick="testEmail()" style="white-space:nowrap;margin:0">📧 发送测试</button></div></div>`;
h+='</div>';
});
h+=`<div style="display:flex;gap:8px;margin-bottom:20px"><button class="btn btn-primary" onclick="saveSettings()">💾 保存</button></div>`;
document.getElementById('settingsTabContent').innerHTML=h;
}
async function saveSettings(){
const cfg={};
for(const k of Object.keys(SysSettingsMeta)){
const el=document.getElementById('s_'+k);
if(!el)continue;
const meta=SysSettingsMeta[k];
cfg[k]=meta.type==='switch'?(el.checked?'1':'0'):el.value;
}
const j=await api('/admin/settings',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(cfg)});
const el=document.getElementById('settingsMsg');
el.className='alert '+(j.code===0?'alert-success':'alert-error');
el.classList.remove('hidden');
el.textContent=j.msg||(j.code===0?'已保存':'保存失败');
}
async function testEmail(){
const to=document.getElementById('testEmailTo').value.trim();
if(!to){showAlert('请输入收件地址');return;}
const j=await api('/admin/settings/test-email',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({to})});
const el=document.getElementById('testEmailMsg');
el.className='alert '+(j.code===0?'alert-success':'alert-error');
el.classList.remove('hidden');
el.textContent=j.msg;
}
// ═══ Modal ═══
function showModal(html,wide){const o=document.getElementById('modalOverlay');const b=document.getElementById('modalBox');b.innerHTML=html;o.classList.remove('hidden');}
function closeModal(e){if(e&&e.target!==document.getElementById('modalOverlay'))return;document.getElementById('modalOverlay').classList.add('hidden');}
function showAlert(msg){return new Promise(r=>{const o=document.getElementById('modalOverlay'),b=document.getElementById('modalBox');b.innerHTML=`<div style="text-align:center"><p style="margin:16px 0 24px;font-size:0.9rem">${esc(msg)}</p><button class="btn btn-primary" id="modalAlertOk">确定</button></div>`;o.classList.remove('hidden');document.getElementById('modalAlertOk').onclick=()=>{o.classList.add('hidden');r();};});}
function showConfirm(msg){return new Promise(r=>{const o=document.getElementById('modalOverlay'),b=document.getElementById('modalBox');b.innerHTML=`<div style="text-align:center"><p style="margin:16px 0 24px;font-size:0.9rem">${esc(msg)}</p><div style="display:flex;gap:10px;justify-content:center"><button class="btn btn-outline" id="modalConfirmNo">取消</button><button class="btn btn-primary" id="modalConfirmYes">确定</button></div></div>`;o.classList.remove('hidden');document.getElementById('modalConfirmYes').onclick=()=>{o.classList.add('hidden');r(true);};document.getElementById('modalConfirmNo').onclick=()=>{o.classList.add('hidden');r(false);};});}
function copyText(t){navigator.clipboard.writeText(t).then(()=>showAlert('已复制')).catch(()=>showAlert('复制失败'));}
// ═══ Init ═══
window.addEventListener('DOMContentLoaded',init);
</script>
</body>
</html>