- 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
1345 lines
100 KiB
HTML
1345 lines
100 KiB
HTML
<!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> |