171 lines
15 KiB
JavaScript
171 lines
15 KiB
JavaScript
(() => {
|
|
'use strict'
|
|
|
|
const state = {
|
|
csrf: '',
|
|
plans: [],
|
|
subscriptions: null,
|
|
subscriptionPage: 1,
|
|
subscriptionPageSize: 50,
|
|
captcha: { enabled: false, provider: '' }
|
|
}
|
|
const $ = (selector) => document.querySelector(selector)
|
|
const loginView = $('#login-view')
|
|
const appView = $('#app-view')
|
|
const loginForm = $('#login-form')
|
|
const basePath = (document.body.dataset.basePath || '').replace(/\/$/, '')
|
|
const route = (path) => `${basePath}${path}`
|
|
|
|
function showError(message, target = $('#app-error')) {
|
|
target.textContent = message || '请求失败'
|
|
target.hidden = !message
|
|
}
|
|
function clearError(target = $('#app-error')) { target.textContent = ''; target.hidden = true }
|
|
|
|
async function request(path, options = {}) {
|
|
const headers = new Headers(options.headers || {})
|
|
headers.set('Accept', 'application/json')
|
|
if (options.body && !headers.has('Content-Type')) headers.set('Content-Type', 'application/json')
|
|
if (state.csrf && options.method && options.method !== 'GET') headers.set('X-CSRF-Token', state.csrf)
|
|
const response = await fetch(route(path), { ...options, headers, credentials: 'same-origin' })
|
|
const payload = await response.json().catch(() => ({}))
|
|
if (!response.ok) {
|
|
if (response.status === 401) { state.csrf = ''; loginView.hidden = false; appView.hidden = true }
|
|
throw new Error(payload.error || '请求失败')
|
|
}
|
|
return payload
|
|
}
|
|
|
|
function unwrap(payload) { return payload && payload.code === 0 && Object.prototype.hasOwnProperty.call(payload, 'data') ? payload.data : payload }
|
|
function formatNumber(value) {
|
|
if (value === null || value === undefined || value === '') return '-'
|
|
const number = Number(value)
|
|
return Number.isFinite(number) ? number.toLocaleString('zh-CN', { maximumFractionDigits: 6 }) : String(value)
|
|
}
|
|
function formatDate(value) {
|
|
if (!value) return '-'
|
|
const date = new Date(value)
|
|
return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString('zh-CN', { dateStyle: 'medium', timeStyle: 'short' })
|
|
}
|
|
function escapeHTML(value) { return String(value ?? '').replace(/[&<>'"]/g, (character) => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' }[character])) }
|
|
function statusLabel(value) { const safe = String(value || 'unknown'); return `<span class="pill pill-${safe.toLowerCase()}">${escapeHTML(safe)}</span>` }
|
|
function setView(name) {
|
|
document.querySelectorAll('.tab').forEach((button) => button.classList.toggle('active', button.dataset.view === name))
|
|
document.querySelectorAll('.view').forEach((view) => { view.hidden = view.id !== `view-${name}` })
|
|
if (name === 'plans' && !state.plans.length) loadPlans()
|
|
if (name === 'subscriptions' && !state.subscriptions) loadSubscriptions()
|
|
if (name === 'audit') loadAudit()
|
|
}
|
|
|
|
async function login(event) {
|
|
event.preventDefault(); clearError($('#login-error'))
|
|
const form = new FormData(loginForm); const button = loginForm.querySelector('button[type="submit"]'); button.disabled = true
|
|
try {
|
|
const captchaToken = String(form.get('captcha_token') || '').trim()
|
|
if (state.captcha.enabled && !captchaToken) throw new Error('请先完成安全验证并填写验证结果')
|
|
let payload = await request('/login', { method: 'POST', body: JSON.stringify({
|
|
email: form.get('email'),
|
|
password: form.get('password'),
|
|
turnstile_token: captchaToken || undefined,
|
|
tencent_captcha_ticket: state.captcha.provider === 'tencent' ? captchaToken || undefined : undefined,
|
|
tencent_captcha_randstr: state.captcha.provider === 'tencent' ? String(form.get('captcha_randstr') || '').trim() || undefined : undefined
|
|
}) })
|
|
if (payload.requires_2fa) {
|
|
const code = window.prompt('请输入管理员 2FA 验证码')
|
|
if (!code) throw new Error('需要 2FA 验证码')
|
|
payload = await request('/login/2fa', { method: 'POST', body: JSON.stringify({ pending_token: payload.pending_token, totp_code: code }) })
|
|
}
|
|
if (!payload.ok || !payload.csrf_token) throw new Error('登录响应无效')
|
|
state.csrf = payload.csrf_token; loginView.hidden = true; appView.hidden = false
|
|
const user = payload.user || {}; $('#operator').textContent = user.email || user.username || `管理员 #${user.id || '-'}`
|
|
await Promise.all([loadStatus(), loadOverview()])
|
|
} catch (error) { showError(error.message, $('#login-error')) } finally { button.disabled = false }
|
|
}
|
|
|
|
async function loadPlans() {
|
|
try { const payload = unwrap(await request('/api/plans')); state.plans = Array.isArray(payload) ? payload : []; $('#plan-count').textContent = formatNumber(state.plans.length); renderPlans(); return true }
|
|
catch (error) { showError(error.message); return false }
|
|
}
|
|
async function loadSubscriptions() {
|
|
try {
|
|
const params = new URLSearchParams(); const form = new FormData($('#subscription-filter'))
|
|
const userID = String(form.get('user_id') || '').trim(); const status = String(form.get('status') || '')
|
|
if (userID && !/^[1-9][0-9]*$/.test(userID)) throw new Error('用户 ID 必须是正整数')
|
|
let endpoint = '/api/subscriptions'
|
|
if (userID && !status) endpoint = `/api/users/${encodeURIComponent(userID)}/subscriptions`
|
|
if (endpoint === '/api/subscriptions') { if (userID) params.set('user_id', userID); if (status) params.set('status', status); params.set('page', String(state.subscriptionPage)); params.set('page_size', String(state.subscriptionPageSize)) }
|
|
const payload = unwrap(await request(`${endpoint}${params.toString() ? `?${params.toString()}` : ''}`)) || {}; state.subscriptions = payload
|
|
$('#subscription-count').textContent = formatNumber(Array.isArray(payload) ? payload.length : (payload.total ?? payload.items?.length ?? 0)); renderSubscriptions(payload); return true
|
|
} catch (error) { showError(error.message); return false }
|
|
}
|
|
async function loadHealth() { try { const response = await fetch(route('/healthz'), { credentials: 'same-origin', headers: { Accept: 'application/json' } }); return response.ok } catch { return false } }
|
|
async function loadOverview() {
|
|
clearError(); $('#sync-time').textContent = '同步中'
|
|
const results = await Promise.all([loadHealth(), loadPlans(), loadSubscriptions()])
|
|
const healthy = results.every(Boolean); const degraded = results.some(Boolean)
|
|
setCoreStatus(healthy ? 'ok' : degraded ? 'degraded' : 'bad')
|
|
$('#sync-time').textContent = degraded ? `最近同步:${formatDate(new Date().toISOString())}` : '同步失败'
|
|
}
|
|
async function loadAudit() { try { const payload = await request('/api/audit'); renderAudit(payload.items || []) } catch (error) { showError(error.message) } }
|
|
async function loadStatus() {
|
|
try { const payload = await request('/api/status'); $('#credential-status').textContent = payload.credential_state === 'server_managed' ? '服务端托管(不回显)' : '不可用' }
|
|
catch { $('#credential-status').textContent = '不可用' }
|
|
}
|
|
async function loadCaptchaConfig() {
|
|
try {
|
|
const payload = await request('/api/captcha-config'); state.captcha = payload
|
|
const panel = $('#captcha-panel')
|
|
if (!payload.enabled) { panel.hidden = true; return }
|
|
panel.hidden = false; $('#captcha-label').textContent = `${payload.provider || '安全'}验证`
|
|
const descriptions = { geetest: 'Core 已启用 GeeTest。请在官方验证组件完成挑战后,将返回的 JSON 验证结果粘贴到此处。', turnstile: 'Core 已启用 Cloudflare Turnstile。请完成挑战后填写返回的验证结果。', tencent: 'Core 已启用腾讯验证码。请填写 ticket,并在下方填写 randstr。', aliyun: 'Core 已启用阿里云验证码。请填写 captchaVerifyParam。' }
|
|
$('#captcha-help').textContent = descriptions[payload.provider] || '请完成 Core 配置的验证码后填写验证结果。'
|
|
$('#captcha-randstr-row').hidden = payload.provider !== 'tencent'
|
|
} catch { $('#captcha-panel').hidden = true }
|
|
}
|
|
async function loadBalance(userID) {
|
|
if (!/^[1-9][0-9]*$/.test(userID)) throw new Error('用户 ID 必须是正整数')
|
|
const payload = unwrap(await request(`/api/users/${encodeURIComponent(userID)}`)) || {}; $('#user-balance').textContent = formatNumber(payload.balance)
|
|
}
|
|
async function bootstrap() {
|
|
try {
|
|
const payload = await request('/api/me'); state.csrf = payload.csrf_token || ''
|
|
if (!state.csrf || !payload.user) throw new Error('session unavailable')
|
|
loginView.hidden = true; appView.hidden = false
|
|
const user = payload.user; $('#operator').textContent = user.email || user.username || `管理员 #${user.id || '-'}`
|
|
await Promise.all([loadCaptchaConfig(), loadStatus(), loadOverview()])
|
|
} catch { loginView.hidden = false; appView.hidden = true; await loadCaptchaConfig() }
|
|
}
|
|
function setCoreStatus(stateName) { const element = $('#core-status'); const labels = { ok: '已连接', degraded: '部分可用', bad: '不可用' }; element.textContent = labels[stateName] || '检查中'; element.className = `status ${stateName === 'ok' ? 'ok' : stateName === 'bad' ? 'bad' : ''}` }
|
|
function parseFeatures(features) { if (!features) return []; if (Array.isArray(features)) return features; if (typeof features !== 'string') return []; try { const parsed = JSON.parse(features); return Array.isArray(parsed) ? parsed : [] } catch { return features.split(/[,\n]/).map((item) => item.trim()).filter(Boolean) } }
|
|
function renderPlans() {
|
|
const container = $('#plans-list'); if (!state.plans.length) { container.innerHTML = '<p class="empty">暂无套餐数据</p>'; return }
|
|
container.innerHTML = state.plans.map((plan) => {
|
|
const groups = (plan.included_groups || []).map((group) => `<span class="tag">${escapeHTML(group.name || group.id)}</span>`).join('') || '<span class="muted">未返回分组</span>'
|
|
const features = parseFeatures(plan.features)
|
|
return `<article class="plan-card"><div class="card-heading"><div><h3>${escapeHTML(plan.name || plan.product_name || `套餐 #${plan.id}`)}</h3><p class="muted">${escapeHTML(plan.description || '')}</p></div>${plan.for_sale ? '<span class="pill pill-active">可售</span>' : '<span class="pill">下架</span>'}</div><div class="plan-price">${formatNumber(plan.price)} <small>${escapeHTML(plan.currency || '')}</small></div><dl class="facts"><div><dt>有效期</dt><dd>${formatNumber(plan.validity_days)} ${escapeHTML(plan.validity_unit || '天')}</dd></div><div><dt>周期额度</dt><dd>${formatNumber(plan.cycle_quota_usd)}</dd></div><div><dt>总额度</dt><dd>${formatNumber(plan.total_quota_usd)}</dd></div><div><dt>实例上限</dt><dd>${formatNumber(plan.max_subscriptions_per_user)}</dd></div></dl><div class="tags">${groups}</div>${features.length ? `<ul class="feature-list">${features.map((feature) => `<li>${escapeHTML(feature)}</li>`).join('')}</ul>` : ''}</article>`
|
|
}).join('')
|
|
}
|
|
function renderSubscriptions(payload) {
|
|
const container = $('#subscriptions-list'); const items = Array.isArray(payload) ? payload : (payload.items || []); const pagination = $('#subscription-pagination')
|
|
if (Array.isArray(payload) || !payload.pages) pagination.hidden = true
|
|
else { pagination.hidden = false; $('#subscription-page-info').textContent = `第 ${payload.page || state.subscriptionPage} / ${payload.pages} 页,共 ${formatNumber(payload.total)} 条`; $('#subscription-prev').disabled = (payload.page || state.subscriptionPage) <= 1; $('#subscription-next').disabled = (payload.page || state.subscriptionPage) >= payload.pages }
|
|
if (!items.length) { container.innerHTML = '<p class="empty">暂无订阅数据</p>'; return }
|
|
container.innerHTML = `<table><thead><tr><th>订阅实例</th><th>用户</th><th>套餐</th><th>状态</th><th>有效期</th><th>周期用量</th><th></th></tr></thead><tbody>${items.map((item) => `<tr><td><code>#${escapeHTML(item.id)}</code></td><td>${escapeHTML(item.user?.username || item.user?.email || item.user_id || '-')}</td><td>${escapeHTML(item.plan_name || item.plan_id || '-')}</td><td>${statusLabel(item.status)}</td><td>${formatDate(item.starts_at)}<br><span class="muted">至 ${formatDate(item.expires_at)}</span></td><td>${formatNumber(item.cycle_usage_usd)} / ${formatNumber(item.cycle_quota_usd)}</td><td><button class="link-button detail-button" data-id="${escapeHTML(item.id)}" type="button">详情</button></td></tr>`).join('')}</tbody></table>`
|
|
container.querySelectorAll('.detail-button').forEach((button) => button.addEventListener('click', () => showDetail(button.dataset.id)))
|
|
}
|
|
async function showDetail(id) { try { const payload = unwrap(await request(`/api/subscriptions/${encodeURIComponent(id)}`)); $('#detail-content').textContent = JSON.stringify(payload, null, 2); $('#detail-dialog').showModal() } catch (error) { showError(error.message) } }
|
|
function renderAudit(items) { const container = $('#audit-list'); if (!items.length) { container.innerHTML = '<p class="empty">暂无操作记录</p>'; return }; container.innerHTML = `<table><thead><tr><th>时间</th><th>动作</th><th>结果</th><th>用户 ID</th><th>请求 ID</th></tr></thead><tbody>${items.slice().reverse().map((item) => `<tr><td>${formatDate(item.time)}</td><td><code>${escapeHTML(item.action)}</code></td><td>${statusLabel(item.result)}</td><td>${escapeHTML(item.user_id ?? '-')}</td><td><code>${escapeHTML(item.request_id || '-')}</code></td></tr>`).join('')}</tbody></table>` }
|
|
|
|
loginForm.addEventListener('submit', login)
|
|
$('#logout').addEventListener('click', async () => { try { await request('/logout', { method: 'POST' }) } catch { /* session is cleared locally */ } state.csrf = ''; loginView.hidden = false; appView.hidden = true; loginForm.reset() })
|
|
$('#refresh-all').addEventListener('click', loadOverview)
|
|
$('#subscription-filter').addEventListener('submit', (event) => { event.preventDefault(); state.subscriptionPage = 1; state.subscriptions = null; loadSubscriptions() })
|
|
$('#subscription-prev').addEventListener('click', () => { if (state.subscriptionPage > 1) { state.subscriptionPage -= 1; loadSubscriptions() } })
|
|
$('#subscription-next').addEventListener('click', () => { const pages = Number(state.subscriptions?.pages || 0); if (state.subscriptionPage < pages) { state.subscriptionPage += 1; loadSubscriptions() } })
|
|
$('#balance-form').addEventListener('submit', async (event) => { event.preventDefault(); clearError(); const userID = String(new FormData(event.currentTarget).get('user_id') || '').trim(); try { await loadBalance(userID) } catch (error) { showError(error.message) } })
|
|
$('#close-detail').addEventListener('click', () => $('#detail-dialog').close())
|
|
document.querySelectorAll('.tab').forEach((button) => button.addEventListener('click', () => setView(button.dataset.view)))
|
|
document.querySelectorAll('.reload').forEach((button) => button.addEventListener('click', () => { if (button.dataset.target === 'plans') loadPlans(); if (button.dataset.target === 'subscriptions') { state.subscriptions = null; loadSubscriptions() }; if (button.dataset.target === 'audit') loadAudit() }))
|
|
void bootstrap()
|
|
})()
|