284 lines
18 KiB
HTML
284 lines
18 KiB
HTML
{% extends "base.html" %}
|
|
{% block title %}管理看板 · AlphaX Agent{% endblock %}
|
|
|
|
{% block extra_head_css %}
|
|
<style>
|
|
main { max-width: 1280px; margin: 0 auto; width: 100%; padding: 24px; display: flex; flex-direction: column; gap: 24px; }
|
|
.page-title { font-size: 22px; font-weight: 700; }
|
|
.page-title .sub { font-size: 13px; color: var(--stone); font-weight: 400; margin-left: 8px; }
|
|
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 12px; }
|
|
.stat-card { background: var(--canvas); border: 1px solid var(--hairline-soft); border-radius: var(--radius-xl); padding: 18px 20px; display: flex; flex-direction: column; gap: 4px; }
|
|
.stat-card .label { font-size: 11px; color: var(--stone); text-transform: uppercase; letter-spacing: .5px; }
|
|
.stat-card .value { font-size: 34px; font-weight: 700; letter-spacing: -1px; line-height: 1; color: var(--ink); }
|
|
.stat-card .sub { font-size: 11px; color: var(--stone); margin-top: 2px; }
|
|
.section { display: flex; flex-direction: column; gap: 12px; }
|
|
.section-title { font-size: 13px; font-weight: 600; color: var(--stone); text-transform: uppercase; letter-spacing: .5px; }
|
|
.card { background: var(--canvas); border: 1px solid var(--hairline-soft); border-radius: var(--radius-xl); padding: 20px; overflow: hidden; }
|
|
.card.no-pad { padding: 0; }
|
|
.chart-wrap { min-height: 160px; position: relative; }
|
|
.chart-wrap svg { width: 100%; display: block; }
|
|
.toolbar { display: flex; gap: 8px; margin-bottom: 12px; }
|
|
.toolbar input { flex: 1; padding: 9px 14px; background: var(--surface); border: 1px solid var(--hairline); border-radius: var(--radius-md); color: var(--ink); font-size: 13px; outline: none; }
|
|
.toolbar input:focus { border-color: var(--blue); }
|
|
.toolbar button { padding: 9px 18px; background: var(--blue); color: #fff; border: none; border-radius: var(--radius-md); font-size: 13px; cursor: pointer; font-weight: 500; }
|
|
.toolbar button:hover { opacity: .9; }
|
|
table { width: 100%; border-collapse: collapse; font-size: 13px; table-layout: auto; }
|
|
th { text-align: left; padding: 10px 12px; color: var(--stone); font-weight: 500; border-bottom: 1px solid var(--hairline-soft); font-size: 11px; text-transform: uppercase; letter-spacing: .5px; white-space: nowrap; }
|
|
td { padding: 11px 12px; border-bottom: 1px solid var(--hairline-soft); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--ink); }
|
|
tr:hover td { background: var(--surface); }
|
|
.badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 600; line-height: 1.5; }
|
|
.badge-green { background: rgba(0,180,115,.12); color: var(--green); }
|
|
.badge-yellow { background: rgba(255,208,47,.15); color: var(--yellow-dark); }
|
|
.badge-red { background: rgba(229,62,62,.12); color: var(--red); }
|
|
.badge-gray { background: var(--hairline-soft); color: var(--stone); }
|
|
.admin-tabs { display:flex; gap:6px; padding:4px; background:var(--surface); border:1px solid var(--hairline-soft); border-radius:var(--radius-full); width:fit-content; }
|
|
.admin-tab-btn { border:1px solid transparent; background:var(--canvas); color:var(--steel); padding:9px 20px; border-radius:var(--radius-full); font-weight:700; font-size:14px; cursor:pointer; transition:.15s; box-shadow:0 1px 2px rgba(5,0,56,.03); }
|
|
.admin-tab-btn.active { background:var(--primary); color:var(--on-primary); border-color:var(--primary); }
|
|
.admin-panel { display:none; }
|
|
.admin-panel.active { display:block; }
|
|
.user-tabs { display: flex; gap: 4px; margin-bottom: 12px; flex-wrap: wrap; }
|
|
.tab-btn { padding: 6px 16px; border: 1px solid var(--hairline); border-radius: var(--radius-full); background: transparent; color: var(--stone); font-size: 13px; cursor: pointer; transition: .15s; font-weight: 500; white-space: nowrap; }
|
|
.tab-btn:hover { border-color: var(--hairline-strong); color: var(--ink); }
|
|
.tab-btn.active { background: var(--primary); color: var(--on-primary); border-color: var(--primary); }
|
|
.pagination { display: flex; justify-content: center; align-items: center; gap: 12px; margin-top: 16px; font-size: 13px; color: var(--stone); }
|
|
.pagination button { padding: 6px 14px; background: var(--surface); border: 1px solid var(--hairline); border-radius: var(--radius-md); color: var(--ink); font-size: 13px; cursor: pointer; }
|
|
.pagination button:disabled { opacity: .4; cursor: default; }
|
|
@media(max-width:600px){main{padding:16px}.page-title{font-size:18px}.stats-grid{grid-template-columns:1fr 1fr}.stat-card{padding:14px 16px}.stat-card .value{font-size:26px}}
|
|
</style>
|
|
{% endblock %}
|
|
|
|
{% block content %}
|
|
<main>
|
|
<div class="page-title">管理看板<div class="sub">PV · 用户 · 订阅 · 订单</div></div>
|
|
|
|
<div class="stats-grid" id="stats">
|
|
<div class="stat-card"><div class="label">今日 PV</div><div class="value">--</div></div>
|
|
<div class="stat-card"><div class="label">累计 PV</div><div class="value">--</div></div>
|
|
<div class="stat-card"><div class="label">总用户</div><div class="value">--</div></div>
|
|
<div class="stat-card"><div class="label">今日新增用户</div><div class="value">--</div></div>
|
|
<div class="stat-card"><div class="label">活跃订阅</div><div class="value">--</div></div>
|
|
<div class="stat-card"><div class="label">订单记录</div><div class="value">--</div></div>
|
|
</div>
|
|
|
|
<div class="section">
|
|
<div class="section-title">PV 趋势 · 近 30 天</div>
|
|
<div class="card chart-wrap" id="dauChart"></div>
|
|
</div>
|
|
|
|
<div class="admin-tabs" role="tablist">
|
|
<button class="admin-tab-btn active" data-admin-tab="users" onclick="switchAdminTab('users')">用户管理</button>
|
|
<button class="admin-tab-btn" data-admin-tab="orders" onclick="switchAdminTab('orders')">订阅订单</button>
|
|
</div>
|
|
|
|
<div class="section admin-panel active" id="usersPanel">
|
|
<div class="section-title">用户管理</div>
|
|
<div class="card no-pad">
|
|
<div style="padding:16px 20px 0">
|
|
<div class="user-tabs">
|
|
<button class="tab-btn active" data-tab="all" onclick="switchTab('all')">全部用户</button>
|
|
<button class="tab-btn" data-tab="today_active" onclick="switchTab('today_active')">今日活跃</button>
|
|
<button class="tab-btn" data-tab="admin" onclick="switchTab('admin')">管理员</button>
|
|
</div>
|
|
<div class="toolbar">
|
|
<input type="text" id="userSearch" placeholder="搜索邮箱..." onkeydown="if(event.key==='Enter')loadUsers(0)">
|
|
<button onclick="loadUsers(0)">搜索</button>
|
|
</div>
|
|
</div>
|
|
<div style="overflow-x:auto">
|
|
<table>
|
|
<thead><tr>
|
|
<th>邮箱</th><th>注册状态</th><th>订阅状态</th><th>到期时间</th><th>角色</th><th>注册时间</th><th>最后登录</th>
|
|
</tr></thead>
|
|
<tbody id="userTable"></tbody>
|
|
</table>
|
|
</div>
|
|
<div class="pagination" id="pagination" style="padding-bottom:16px"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="section admin-panel" id="ordersPanel">
|
|
<div class="section-title">订阅订单管理</div>
|
|
<div class="card no-pad">
|
|
<div style="padding:16px 20px 0">
|
|
<div class="user-tabs">
|
|
<button class="tab-btn order-tab active" data-status="all" onclick="switchOrderStatus('all')">全部订单</button>
|
|
<button class="tab-btn order-tab" data-status="pending" onclick="switchOrderStatus('pending')">待支付</button>
|
|
<button class="tab-btn order-tab" data-status="paid" onclick="switchOrderStatus('paid')">已支付</button>
|
|
<button class="tab-btn order-tab" data-status="confirmed" onclick="switchOrderStatus('confirmed')">已确认</button>
|
|
<button class="tab-btn order-tab" data-status="expired" onclick="switchOrderStatus('expired')">已过期</button>
|
|
</div>
|
|
<div class="toolbar">
|
|
<input type="text" id="orderSearch" placeholder="搜索邮箱 / TXID / 订单号..." onkeydown="if(event.key==='Enter')loadOrders(0)">
|
|
<button onclick="loadOrders(0)">搜索</button>
|
|
</div>
|
|
</div>
|
|
<div style="overflow-x:auto">
|
|
<table>
|
|
<thead><tr>
|
|
<th>订单号</th><th>邮箱</th><th>套餐</th><th>金额</th><th>状态</th><th>链 / TXID</th><th>创建时间</th><th>订阅关联</th>
|
|
</tr></thead>
|
|
<tbody id="orderTable"></tbody>
|
|
</table>
|
|
</div>
|
|
<div class="pagination" id="orderPagination" style="padding-bottom:16px"></div>
|
|
</div>
|
|
</div>
|
|
|
|
</main>
|
|
{% endblock %}
|
|
|
|
{% block password_modal %}{% endblock %}
|
|
|
|
{% block extra_script %}
|
|
<script>
|
|
var API = '';
|
|
|
|
var PAGE_SIZE=50,userOffset=0,userTotal=0,currentTab='all';
|
|
var ORDER_PAGE_SIZE=50,orderOffset=0,orderTotal=0,currentOrderStatus='all';
|
|
|
|
async function init(){
|
|
try{var chk=await fetch(API+'/api/admin/check');if(!chk.ok){window.location.href='/subscription';return}
|
|
var info=await chk.json();if(!info.is_admin){window.location.href='/subscription';return}}catch(e){window.location.href='/subscription';return}
|
|
loadStats();loadUsers(0);
|
|
}
|
|
|
|
function switchAdminTab(tab){
|
|
document.querySelectorAll('.admin-tab-btn').forEach(function(b){b.classList.toggle('active',b.dataset.adminTab===tab)});
|
|
document.getElementById('usersPanel').classList.toggle('active',tab==='users');
|
|
document.getElementById('ordersPanel').classList.toggle('active',tab==='orders');
|
|
if(tab==='orders' && orderTotal===0) loadOrders(0);
|
|
}
|
|
|
|
function switchTab(tab){
|
|
currentTab=tab;userOffset=0;
|
|
document.querySelectorAll('[data-tab]').forEach(function(b){b.classList.toggle('active',b.dataset.tab===tab)});
|
|
loadUsers(0);
|
|
}
|
|
|
|
async function loadStats(){
|
|
try{var r=await fetch(API+'/api/admin/stats');if(!r.ok)throw new Error('Unauthorized');var d=await r.json();
|
|
document.getElementById('stats').innerHTML=
|
|
'<div class="stat-card"><div class="label">今日 PV</div><div class="value">'+(d.pv_today||0)+'<div class="sub">页面访问次数,不去重</div></div></div>'+
|
|
'<div class="stat-card"><div class="label">累计 PV</div><div class="value">'+(d.pv_total||0)+'<div class="sub">全部页面访问</div></div></div>'+
|
|
'<div class="stat-card"><div class="label">总用户</div><div class="value">'+(d.total_users||0)+'<div class="sub">DAU '+(d.dau_today||0)+' · WAU '+(d.wau_7d||0)+'</div></div></div>'+
|
|
'<div class="stat-card"><div class="label">今日新增用户</div><div class="value">'+(d.new_users_today||0)+'<div class="sub">新增 / 总用户配对</div></div></div>'+
|
|
'<div class="stat-card"><div class="label">活跃订阅</div><div class="value">'+(d.active_subscriptions||0)+'<div class="sub">当前有效</div></div></div>'+
|
|
'<div class="stat-card"><div class="label">订单记录</div><div class="value">'+(d.total_orders||0)+'<div class="sub">已支付 '+(d.paid_orders||0)+'</div></div></div>';
|
|
renderChart(d.pv_trend||[]);
|
|
}catch(e){}
|
|
}
|
|
function renderChart(trend){
|
|
var wrap=document.getElementById('dauChart');
|
|
if(!trend.length){wrap.innerHTML='<div style="display:flex;align-items:center;justify-content:center;height:160px;color:var(--stone)">暂无数据</div>';return}
|
|
var W=Math.max(wrap.clientWidth||600,300),H=160,pad={t:10,r:16,b:26,l:38},cw=W-pad.l-pad.r,ch=H-pad.t-pad.b;
|
|
var maxVal=Math.max.apply(null,trend.map(function(d){return d.count}))||1;
|
|
var svg='<svg viewBox="0 0 '+W+' '+H+'">';
|
|
for(var i=0;i<=4;i++){var y=pad.t+ch*i/4;svg+='<line x1="'+pad.l+'" x2="'+(W-pad.r)+'" y1="'+y+'" y2="'+y+'" stroke="var(--hairline-soft)" stroke-width="1"/>'}
|
|
var barW=Math.max(2,cw/trend.length*.7),gap=cw/trend.length;
|
|
for(var i=0;i<trend.length;i++){var d=trend[i],x=pad.l+i*gap+(gap-barW)/2,h=d.count/maxVal*ch,by=pad.t+ch-h;
|
|
svg+='<rect x="'+x+'" y="'+by+'" width="'+barW+'" height="'+h+'" rx="1.5" fill="var(--blue)" opacity=".65"/>';
|
|
if(i%7===0)svg+='<text x="'+(x+barW/2)+'" y="'+(pad.t+ch+15)+'" text-anchor="middle" font-size="9" fill="var(--stone)">'+d.day.slice(5)+'</text>'}
|
|
[0,Math.round(maxVal/2),maxVal].forEach(function(v){var y=pad.t+ch-(v/maxVal*ch);svg+='<text x="'+(pad.l-5)+'" y="'+(y+3)+'" text-anchor="end" font-size="9" fill="var(--stone)">'+v+'</text>'});
|
|
svg+='</svg>';wrap.innerHTML=svg;
|
|
}
|
|
|
|
async function loadUsers(offset){
|
|
userOffset=offset;var q=document.getElementById('userSearch').value.trim();
|
|
document.getElementById('userTable').innerHTML='<tr><td colspan="7" style="text-align:center;padding:32px;color:var(--stone)">加载中...</td></tr>';
|
|
try{
|
|
var r=await fetch(API+'/api/admin/users?search='+encodeURIComponent(q)+'&offset='+offset+'&limit='+PAGE_SIZE+'&tab='+currentTab);
|
|
if(!r.ok)throw new Error(r.status);
|
|
var d=await r.json();userTotal=d.total;renderUsers(d.users);renderPagination();
|
|
}catch(e){
|
|
document.getElementById('userTable').innerHTML='<tr><td colspan="7" style="text-align:center;padding:32px;color:var(--red)">加载失败</td></tr>';
|
|
}
|
|
}
|
|
|
|
function renderUsers(users){
|
|
var tb=document.getElementById('userTable');
|
|
if(!users.length){tb.innerHTML='<tr><td colspan="7" style="text-align:center;padding:32px;color:var(--stone)">无匹配用户</td></tr>';return}
|
|
tb.innerHTML=users.map(function(u){
|
|
return '<tr>'+
|
|
'<td style="font-weight:500">'+esc(u.email)+'</td>'+
|
|
'<td>'+registrationBadge(u)+'</td>'+
|
|
'<td>'+subscriptionBadge(u)+'</td>'+
|
|
'<td style="color:var(--stone);font-size:12px">'+(u.subscription_end_at?fmtDate(u.subscription_end_at):'—')+'</td>'+
|
|
'<td>'+(u.is_admin?'<span class="badge badge-yellow">管理员</span>':'<span class="badge badge-gray">用户</span>')+'</td>'+
|
|
'<td style="color:var(--stone);font-size:12px">'+fmtDate(u.created_at)+'</td>'+
|
|
'<td style="color:var(--stone);font-size:12px">'+(u.last_login_at?fmtDate(u.last_login_at):'—')+'</td>'+
|
|
'</tr>';
|
|
}).join('');
|
|
}
|
|
function renderPagination(){
|
|
var pg=document.getElementById('pagination'),totalPages=Math.ceil(userTotal/PAGE_SIZE),cur=Math.floor(userOffset/PAGE_SIZE)+1;
|
|
pg.innerHTML='<button '+(userOffset===0?'disabled':'')+' onclick="loadUsers('+(userOffset-PAGE_SIZE)+')">← 上一页</button>'+
|
|
'<span>第 '+cur+' / '+Math.max(1,totalPages)+' 页 · 共 '+userTotal+' 用户</span>'+
|
|
'<button '+((userOffset+PAGE_SIZE>=userTotal)?'disabled':'')+' onclick="loadUsers('+(userOffset+PAGE_SIZE)+')">下一页 →</button>';
|
|
}
|
|
|
|
function registrationBadge(u){
|
|
return'<span class="badge badge-green">已注册</span>';
|
|
}
|
|
function subscriptionBadge(u){
|
|
var label=u.subscription_status_label||'未开通';
|
|
var plan=u.subscription_plan_name||'未开通';
|
|
var cls=label==='有效'?'badge-green':(label==='已过期'?'badge-red':'badge-gray');
|
|
return '<span class="badge '+cls+'">'+esc(plan)+' · '+esc(label)+'</span>';
|
|
}
|
|
function switchOrderStatus(status){
|
|
currentOrderStatus=status;orderOffset=0;
|
|
document.querySelectorAll('.order-tab').forEach(function(b){b.classList.toggle('active',b.dataset.status===status)});
|
|
loadOrders(0);
|
|
}
|
|
|
|
async function loadOrders(offset){
|
|
orderOffset=offset;var q=document.getElementById('orderSearch').value.trim();
|
|
document.getElementById('orderTable').innerHTML='<tr><td colspan="8" style="text-align:center;padding:32px;color:var(--stone)">加载中...</td></tr>';
|
|
try{
|
|
var r=await fetch(API+'/api/admin/orders?search='+encodeURIComponent(q)+'&offset='+offset+'&limit='+ORDER_PAGE_SIZE+'&status='+currentOrderStatus);
|
|
if(!r.ok)throw new Error(r.status);
|
|
var d=await r.json();orderTotal=d.total;renderOrders(d.orders);renderOrderPagination();
|
|
}catch(e){
|
|
document.getElementById('orderTable').innerHTML='<tr><td colspan="8" style="text-align:center;padding:32px;color:var(--red)">加载失败</td></tr>';
|
|
}
|
|
}
|
|
|
|
function renderOrders(orders){
|
|
var tb=document.getElementById('orderTable');
|
|
if(!orders.length){tb.innerHTML='<tr><td colspan="8" style="text-align:center;padding:32px;color:var(--stone)">无匹配订单</td></tr>';return}
|
|
tb.innerHTML=orders.map(function(o){
|
|
return '<tr>'+
|
|
'<td style="font-weight:700">#'+esc(o.id)+'</td>'+
|
|
'<td>'+esc(o.email)+'</td>'+
|
|
'<td>'+esc(o.plan_name||o.plan_code)+'</td>'+
|
|
'<td style="font-weight:700">'+Number(o.amount_usdt||0).toFixed(0)+' USDT</td>'+
|
|
'<td>'+orderStatusBadge(o.status)+'</td>'+
|
|
'<td style="color:var(--stone);font-size:12px">'+esc(o.chain||'')+(o.txid?' · '+esc(shortText(o.txid,18)):' · —')+'</td>'+
|
|
'<td style="color:var(--stone);font-size:12px">'+fmtDate(o.created_at)+'</td>'+
|
|
'<td>'+(o.subscription_id?'<span class="badge badge-green">订阅 #'+esc(o.subscription_id)+'</span>':'<span class="badge badge-gray">未关联</span>')+'</td>'+
|
|
'</tr>';
|
|
}).join('');
|
|
}
|
|
|
|
function renderOrderPagination(){
|
|
var pg=document.getElementById('orderPagination'),totalPages=Math.ceil(orderTotal/ORDER_PAGE_SIZE),cur=Math.floor(orderOffset/ORDER_PAGE_SIZE)+1;
|
|
pg.innerHTML='<button '+(orderOffset===0?'disabled':'')+' onclick="loadOrders('+(orderOffset-ORDER_PAGE_SIZE)+')">← 上一页</button>'+
|
|
'<span>第 '+cur+' / '+Math.max(1,totalPages)+' 页 · 共 '+orderTotal+' 订单</span>'+
|
|
'<button '+((orderOffset+ORDER_PAGE_SIZE>=orderTotal)?'disabled':'')+' onclick="loadOrders('+(orderOffset+ORDER_PAGE_SIZE)+')">下一页 →</button>';
|
|
}
|
|
|
|
function orderStatusBadge(status){
|
|
var cls=(status==='paid'||status==='confirmed')?'badge-green':(status==='pending'?'badge-yellow':(status==='expired'||status==='cancelled'?'badge-red':'badge-gray'));
|
|
var map={pending:'待支付',paid:'已支付',confirmed:'已确认',expired:'已过期',cancelled:'已取消'};
|
|
return '<span class="badge '+cls+'">'+esc(map[status]||status||'—')+'</span>';
|
|
}
|
|
function shortText(s,n){s=String(s||'');return s.length>n?s.slice(0,n)+'…':s}
|
|
|
|
function esc(s){return String(s||'').replace(/[&<>"]/g,function(c){return{'&':'&','<':'<','>':'>','"':'"'}[c]})}
|
|
function fmtDate(ts){if(!ts)return'—';var m=String(ts).match(/^(\d{4})-(\d{2})-(\d{2})/);return m?m[2]+'/'+m[3]:ts.slice(0,10)}
|
|
function fmtDateTime(ts){if(!ts)return'—';var d=new Date(ts);if(isNaN(d.getTime()))return String(ts).slice(0,19).replace('T',' ');return (d.getMonth()+1)+'/'+d.getDate()+' '+('0'+d.getHours()).slice(-2)+':'+('0'+d.getMinutes()).slice(-2)+':'+('0'+d.getSeconds()).slice(-2)}
|
|
|
|
init();
|
|
</script>
|
|
{% endblock %}
|