alphax/static/admin.html
2026-05-14 11:21:21 +08:00

294 lines
19 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{% extends "base.html" %}
{% block title %}管理看板 · AlphaX Agent Crypto{% endblock %}
{% block nav_links %}
<a class="sidebar-link" href="/app"><svg class="link-icon"><use href="#svg-dashboard"/></svg>看板</a>
<a class="sidebar-link" href="/sentiment"><svg class="link-icon"><use href="#svg-sentiment"/></svg>舆情</a>
<a class="sidebar-link" href="/subscription"><svg class="link-icon"><use href="#svg-subscribe"/></svg>订阅</a>
<a class="sidebar-link" href="/referral"><svg class="link-icon"><use href="#svg-referral"/></svg>推荐</a>
<div class="sidebar-section-label admin-link" style="display:none">研发</div>
<a class="sidebar-link admin-link" href="/pipeline" style="display:none"><svg class="link-icon"><use href="#svg-pipeline"/></svg>链路日志</a>
<a class="sidebar-link admin-link" href="/strategy" style="display:none"><svg class="link-icon"><use href="#svg-target"/></svg>策略</a>
<a class="sidebar-link admin-link" href="/iteration" style="display:none"><svg class="link-icon"><use href="#svg-iterate"/></svg>迭代</a>
<a class="sidebar-link active admin-link" href="/admin.html" style="display:none"><svg class="link-icon"><use href="#svg-admin"/></svg>管理</a>
{% 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{'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[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)}
init();
</script>
{% endblock %}