feat: 完善全栈 Dashboard 项目 - UI优化、Docker支持、账单系统等

This commit is contained in:
LAMCLOD
2026-03-09 07:07:28 +08:00
parent f6036cab66
commit 55b6c67271
32 changed files with 1893 additions and 512 deletions

View File

@@ -5,6 +5,8 @@ import { sessionAuth } from '../middleware/auth.js'
const router = Router()
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'newapi-admin'
// POST /api/auth/login
router.post('/login', async (req: Request, res: Response) => {
const { userId, accessToken, siteId } = req.body
@@ -22,7 +24,10 @@ router.post('/login', async (req: Request, res: Response) => {
try {
const response = await fetch(`${site.url}/api/user/self`, {
headers: { 'Authorization': accessToken }
headers: {
'Authorization': accessToken,
'New-Api-User': String(userId),
}
})
const result = await response.json() as any
@@ -70,9 +75,27 @@ router.get('/me', sessionAuth, (req: Request, res: Response) => {
success: true,
data: {
userInfo: JSON.parse(req.session!.user_info),
site
site,
isAdmin: !!(req.session as any).is_admin
}
})
})
// POST /api/auth/elevate — promote to dashboard admin
router.post('/elevate', sessionAuth, (req: Request, res: Response) => {
const { password } = req.body
if (password !== ADMIN_PASSWORD) {
res.json({ success: false, message: '管理密码错误' })
return
}
db.prepare('UPDATE sessions SET is_admin = 1 WHERE id = ?').run(req.session!.id)
res.json({ success: true, message: '已升格为管理员' })
})
// POST /api/auth/demote — revoke dashboard admin
router.post('/demote', sessionAuth, (req: Request, res: Response) => {
db.prepare('UPDATE sessions SET is_admin = 0 WHERE id = ?').run(req.session!.id)
res.json({ success: true, message: '已取消管理员权限' })
})
export default router

View File

@@ -5,37 +5,81 @@ import db from '../db.js'
const router = Router()
// Helper: fetch with rate-limit retry
async function fetchWithRetry(url: string, headers: Record<string, string>, maxRetries = 3): Promise<any> {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
const res = await fetch(url, { headers })
if (res.status === 429) {
if (attempt < maxRetries) {
const retryAfter = Number(res.headers.get('retry-after')) || (2 ** attempt)
await new Promise(r => setTimeout(r, retryAfter * 1000))
continue
}
return { success: false, message: 'Rate limited' }
}
if (!res.ok) {
return { success: false, message: `HTTP ${res.status}` }
}
return await res.json()
}
}
// Helper: paginate logs with rate-limit awareness
async function paginateLogs(
baseUrl: string,
headers: Record<string, string>,
params: URLSearchParams,
maxItems = 50000,
delayMs = 200,
): Promise<any[]> {
const allLogs: any[] = []
let page = 1
let hasMore = true
while (hasMore) {
params.set('p', String(page))
params.set('page_size', '100')
const data = await fetchWithRetry(`${baseUrl}?${params.toString()}`, headers)
if (!data.success || !data.data?.items?.length) {
hasMore = false
} else {
allLogs.push(...data.data.items)
hasMore = data.data.items.length === 100
page++
}
if (allLogs.length >= maxItems) hasMore = false
if (hasMore && delayMs > 0) await new Promise(r => setTimeout(r, delayMs))
}
return allLogs
}
// Helper: fetch fresh user info from new-api
async function fetchUserInfo(siteUrl: string, accessToken: string, userId: string) {
const res = await fetch(`${siteUrl}/api/user/self`, {
headers: { 'Authorization': accessToken, 'New-Api-User': userId }
})
const data = await res.json() as any
if (!data.success) throw new Error('获取用户信息失败')
return data.data
}
// POST /api/billing/export/pdf
router.post('/export/pdf', sessionAuth, async (req: Request, res: Response) => {
const session = req.session!
const { startDate, endDate } = req.body
const userInfo = JSON.parse(session.user_info)
const site = db.prepare('SELECT name, url FROM sites WHERE id = ?').get(session.site_id) as any
try {
// 实时从 new-api 获取最新用户信息
const userInfo = await fetchUserInfo(session.site_url, session.access_token, String(session.user_id))
const site = db.prepare('SELECT name, url FROM sites WHERE id = ?').get(session.site_id) as any
const startTs = Math.floor(new Date(startDate).getTime() / 1000)
const endTs = Math.floor(new Date(endDate).getTime() / 1000)
let allLogs: any[] = []
let page = 1
const pageSize = 100
let hasMore = true
while (hasMore) {
const logRes = await fetch(
`${session.site_url}/api/log/self?start_timestamp=${startTs}&end_timestamp=${endTs}&type=2&p=${page}&page_size=${pageSize}`,
{ headers: { 'Authorization': session.access_token } }
)
const logData = await logRes.json() as any
if (!logData.success || !logData.data?.items?.length) {
hasMore = false
} else {
allLogs = allLogs.concat(logData.data.items)
hasMore = logData.data.items.length === pageSize
page++
}
if (allLogs.length > 10000) hasMore = false
}
const apiHeaders = { 'Authorization': session.access_token, 'New-Api-User': String(session.user_id) }
const logParams = new URLSearchParams({
start_timestamp: String(startTs), end_timestamp: String(endTs), type: '2',
})
const allLogs = await paginateLogs(`${session.site_url}/api/log/self`, apiHeaders, logParams)
const modelMap = new Map<string, { quota: number; count: number }>()
let totalQuota = 0
@@ -52,6 +96,10 @@ router.post('/export/pdf', sessionAuth, async (req: Request, res: Response) => {
.map(([model, data]) => ({ model, ...data }))
.sort((a, b) => b.quota - a.quota)
// Fetch topup records
const topupParams = new URLSearchParams()
const allTopups = await paginateLogs(`${session.site_url}/api/user/topup/self`, apiHeaders, topupParams, 10000)
const pdfBuffer = await generateBillingPDF({
siteName: site.name,
siteUrl: site.url,
@@ -63,11 +111,13 @@ router.post('/export/pdf', sessionAuth, async (req: Request, res: Response) => {
totalQuota,
totalRequests: allLogs.length,
modelSummary,
topups: allTopups,
logs: allLogs
})
res.setHeader('Content-Type', 'application/pdf')
res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(site.name)}_billing_${startDate}_${endDate}.pdf"`)
const ts = new Date().toISOString().replace(/[-:T]/g, '').slice(0, 14)
res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(site.name)}_billing_${startDate}_${endDate}_${ts}.pdf"`)
res.send(pdfBuffer)
} catch (error: any) {
res.status(500).json({ success: false, message: `Failed to generate report: ${error.message}` })
@@ -84,49 +134,141 @@ router.post('/export/csv', sessionAuth, async (req: Request, res: Response) => {
const startTs = Math.floor(new Date(startDate).getTime() / 1000)
const endTs = Math.floor(new Date(endDate).getTime() / 1000)
let allLogs: any[] = []
let page = 1
let hasMore = true
while (hasMore) {
const logRes = await fetch(
`${session.site_url}/api/log/self?start_timestamp=${startTs}&end_timestamp=${endTs}&type=2&p=${page}&page_size=100`,
{ headers: { 'Authorization': session.access_token } }
)
const logData = await logRes.json() as any
if (!logData.success || !logData.data?.items?.length) {
hasMore = false
} else {
allLogs = allLogs.concat(logData.data.items)
hasMore = logData.data.items.length === 100
page++
}
if (allLogs.length > 10000) hasMore = false
}
const apiHeaders = { 'Authorization': session.access_token, 'New-Api-User': String(session.user_id) }
const logParams = new URLSearchParams({
start_timestamp: String(startTs), end_timestamp: String(endTs), type: '2',
})
const allLogs = await paginateLogs(`${session.site_url}/api/log/self`, apiHeaders, logParams)
const BOM = '\uFEFF'
const headers = ['Time', 'Model', 'Token', 'Quota', 'Cost(USD)', 'Prompt Tokens', 'Completion Tokens', 'Request ID']
const rows = allLogs.map(log => [
new Date((log.created_at || 0) * 1000).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' }),
log.model_name || '',
log.token_name || '',
log.quota || 0,
((log.quota || 0) / 500000).toFixed(6),
log.prompt_tokens || 0,
log.completion_tokens || 0,
log.request_id || ''
])
const headers = ['时间', '令牌', '分组', '类型', '模型', '流式', '用时(s)', '首字(ms)', '输入', '缓存命中', '缓存创建', '输出', '花费(USD)', '额度', '请求ID']
const rows = allLogs.map(log => {
let other: any = {}
try { other = typeof log.other === 'string' ? JSON.parse(log.other) : (log.other || {}) } catch {}
const typeMap: Record<number, string> = { 1: '充值', 2: '消费', 3: '管理', 4: '系统' }
return [
new Date((log.created_at || 0) * 1000).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' }),
log.token_name || '',
log.group || '',
typeMap[log.type] || String(log.type || ''),
log.model_name || '',
log.is_stream ? '是' : '否',
log.use_time || 0,
other.frt ? Math.round(other.frt) : '',
log.prompt_tokens || 0,
other.cache_tokens || 0,
other.cache_creation_tokens || 0,
log.completion_tokens || 0,
((log.quota || 0) / 500000).toFixed(6),
log.quota || 0,
log.request_id || '',
]
})
const csv = BOM + [headers, ...rows].map(row => row.map(cell => `"${cell}"`).join(',')).join('\n')
res.setHeader('Content-Type', 'text/csv; charset=utf-8')
res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(site.name)}_billing_${startDate}_${endDate}.csv"`)
const ts = new Date().toISOString().replace(/[-:T]/g, '').slice(0, 14)
res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(site.name)}_billing_${startDate}_${endDate}_${ts}.csv"`)
res.send(csv)
} catch (error: any) {
res.status(500).json({ success: false, message: `Failed to generate CSV: ${error.message}` })
}
})
// POST /api/billing/stats — aggregate stats for date range
router.post('/stats', sessionAuth, async (req: Request, res: Response) => {
const session = req.session!
const { startTimestamp, endTimestamp, type, modelName, tokenName, group, requestId } = req.body
try {
const params = new URLSearchParams()
if (startTimestamp) params.set('start_timestamp', String(startTimestamp))
if (endTimestamp) params.set('end_timestamp', String(endTimestamp))
if (type !== undefined && type !== '') params.set('type', String(type))
if (modelName) params.set('model_name', modelName)
if (tokenName) params.set('token_name', tokenName)
if (group) params.set('group', group)
if (requestId) params.set('request_id', requestId)
const headers = { 'Authorization': session.access_token, 'New-Api-User': String(session.user_id) }
const allLogs = await paginateLogs(`${session.site_url}/api/log/self`, headers, params)
let totalQuota = 0
let totalPromptTokens = 0
let totalCompletionTokens = 0
const modelSet = new Set<string>()
for (const log of allLogs) {
totalQuota += log.quota || 0
totalPromptTokens += log.prompt_tokens || 0
totalCompletionTokens += log.completion_tokens || 0
if (log.model_name) modelSet.add(log.model_name)
}
res.json({
success: true,
data: {
totalRecords: allLogs.length,
totalQuota,
totalPromptTokens,
totalCompletionTokens,
totalTokens: totalPromptTokens + totalCompletionTokens,
modelCount: modelSet.size,
}
})
} catch (error: any) {
res.status(500).json({ success: false, message: error.message })
}
})
// POST /api/billing/chart-data — aggregated chart data for dashboard
router.post('/chart-data', sessionAuth, async (req: Request, res: Response) => {
const session = req.session!
try {
const sevenDaysAgo = Math.floor((Date.now() - 7 * 24 * 60 * 60 * 1000) / 1000)
const params = new URLSearchParams({ start_timestamp: String(sevenDaysAgo), type: '2' })
const headers = { 'Authorization': session.access_token, 'New-Api-User': String(session.user_id) }
const allLogs = await paginateLogs(`${session.site_url}/api/log/self`, headers, params)
// Daily aggregation (last 7 days)
const dayMap = new Map<string, { count: number; quota: number }>()
const now = new Date()
for (let i = 6; i >= 0; i--) {
const d = new Date(now)
d.setDate(d.getDate() - i)
const key = d.toISOString().split('T')[0]
dayMap.set(key, { count: 0, quota: 0 })
}
for (const log of allLogs) {
const date = new Date((log.created_at || 0) * 1000).toISOString().split('T')[0]
if (dayMap.has(date)) {
const day = dayMap.get(date)!
day.count += 1
day.quota += log.quota || 0
}
}
const daily = Array.from(dayMap.entries()).map(([date, val]) => ({
date, count: val.count, quota: val.quota,
}))
// Model distribution (top 10)
const modelMap = new Map<string, number>()
for (const log of allLogs) {
const model = log.model_name || 'unknown'
modelMap.set(model, (modelMap.get(model) || 0) + (log.quota || 0))
}
const models = Array.from(modelMap.entries())
.map(([name, value]) => ({ name, value }))
.sort((a, b) => b.value - a.value)
.slice(0, 10)
res.json({ success: true, data: { daily, models, totalLogs: allLogs.length } })
} catch (error: any) {
res.status(500).json({ success: false, message: error.message })
}
})
// POST /api/billing/verify — verify HMAC signature
router.post('/verify', (req: Request, res: Response) => {
const { userId, startDate, endDate, totalQuota, totalRecords, generatedAt, signature } = req.body

View File

@@ -11,6 +11,7 @@ router.all('/*', sessionAuth, async (req: Request, res: Response) => {
try {
const headers: Record<string, string> = {
'Authorization': session.access_token,
'New-Api-User': String(session.user_id),
'Content-Type': req.headers['content-type'] || 'application/json'
}