feat: newapi-dashboard 全栈项目初始化

为 new-api (LLM API 网关) 构建独立前端管理面板:
- React 18 + TypeScript + Vite + Ant Design 5 前端
- Node.js + Express + better-sqlite3 BFF 后端
- 登录页: 站点选择器 + UserID + AccessToken 认证
- 仪表盘: 用户信息、额度环形图、7天趋势图、模型饼图、令牌概览、日志时间线
- 账单页: 筛选日志表格、模型消耗柱状图、充值记录、CSV/PDF 导出(HMAC签名)
- 管理员站点管理: 站点 CRUD
- API 代理: 多站点切换,会话管理

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
LAMCLOD
2026-03-08 18:00:28 +08:00
commit f6036cab66
36 changed files with 10579 additions and 0 deletions

37
server/db.ts Normal file
View File

@@ -0,0 +1,37 @@
import Database from 'better-sqlite3'
import path from 'path'
import { fileURLToPath } from 'url'
import fs from 'fs'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const DB_PATH = path.join(__dirname, '..', 'data', 'dashboard.db')
fs.mkdirSync(path.dirname(DB_PATH), { recursive: true })
const db = new Database(DB_PATH)
db.pragma('journal_mode = WAL')
db.pragma('foreign_keys = ON')
db.exec(`
CREATE TABLE IF NOT EXISTS sites (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
url TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL,
access_token TEXT NOT NULL,
site_id INTEGER NOT NULL,
site_url TEXT NOT NULL,
user_info TEXT DEFAULT '{}',
created_at TEXT DEFAULT (datetime('now')),
expires_at TEXT NOT NULL,
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE
);
`)
export default db

27
server/index.ts Normal file
View File

@@ -0,0 +1,27 @@
import express from 'express'
import cors from 'cors'
import sitesRouter from './routes/sites.js'
import authRouter from './routes/auth.js'
import proxyRouter from './routes/proxy.js'
import billingRouter from './routes/billing.js'
const app = express()
const PORT = 3001
app.use(cors())
app.use(express.json())
app.get('/api/health', (_req, res) => {
res.json({ success: true, message: 'NewAPI Dashboard BFF running' })
})
app.use('/api/sites', sitesRouter)
app.use('/api/auth', authRouter)
app.use('/proxy', proxyRouter)
app.use('/api/billing', billingRouter)
app.listen(PORT, () => {
console.log(`BFF server running on http://localhost:${PORT}`)
})
export default app

51
server/middleware/auth.ts Normal file
View File

@@ -0,0 +1,51 @@
import { Request, Response, NextFunction } from 'express'
import db from '../db.js'
export interface SessionData {
id: string
user_id: number
access_token: string
site_id: number
site_url: string
user_info: string
}
declare global {
namespace Express {
interface Request {
session?: SessionData
}
}
}
export function sessionAuth(req: Request, res: Response, next: NextFunction) {
const token = req.headers['x-session-token'] as string
if (!token) {
res.status(401).json({ success: false, message: '未登录' })
return
}
const session = db.prepare(
"SELECT * FROM sessions WHERE id = ? AND expires_at > datetime('now')"
).get(token) as SessionData | undefined
if (!session) {
res.status(401).json({ success: false, message: '会话已过期,请重新登录' })
return
}
req.session = session
next()
}
export function adminAuth(req: Request, res: Response, next: NextFunction) {
sessionAuth(req, res, () => {
if (!req.session) return
const userInfo = JSON.parse(req.session.user_info || '{}')
if (userInfo.role < 10) {
res.status(403).json({ success: false, message: '需要管理员权限' })
return
}
next()
})
}

78
server/routes/auth.ts Normal file
View File

@@ -0,0 +1,78 @@
import { Router, Request, Response } from 'express'
import { v4 as uuidv4 } from 'uuid'
import db from '../db.js'
import { sessionAuth } from '../middleware/auth.js'
const router = Router()
// POST /api/auth/login
router.post('/login', async (req: Request, res: Response) => {
const { userId, accessToken, siteId } = req.body
if (!userId || !accessToken || !siteId) {
res.json({ success: false, message: '请填写完整信息' })
return
}
const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(siteId) as any
if (!site) {
res.json({ success: false, message: '站点不存在' })
return
}
try {
const response = await fetch(`${site.url}/api/user/self`, {
headers: { 'Authorization': accessToken }
})
const result = await response.json() as any
if (!result.success) {
res.json({ success: false, message: result.message || '认证失败请检查用户ID和令牌' })
return
}
const userInfo = result.data
if (userInfo.id !== Number(userId)) {
res.json({ success: false, message: '用户ID与令牌不匹配' })
return
}
const sessionId = uuidv4()
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString()
db.prepare(
'INSERT INTO sessions (id, user_id, access_token, site_id, site_url, user_info, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?)'
).run(sessionId, userId, accessToken, siteId, site.url, JSON.stringify(userInfo), expiresAt)
res.json({
success: true,
data: {
sessionToken: sessionId,
userInfo,
site: { id: site.id, name: site.name, url: site.url }
}
})
} catch (error: any) {
res.json({ success: false, message: `无法连接站点: ${error.message}` })
}
})
// POST /api/auth/logout
router.post('/logout', sessionAuth, (req: Request, res: Response) => {
db.prepare('DELETE FROM sessions WHERE id = ?').run(req.session!.id)
res.json({ success: true })
})
// GET /api/auth/me
router.get('/me', sessionAuth, (req: Request, res: Response) => {
const site = db.prepare('SELECT id, name, url FROM sites WHERE id = ?').get(req.session!.site_id)
res.json({
success: true,
data: {
userInfo: JSON.parse(req.session!.user_info),
site
}
})
})
export default router

141
server/routes/billing.ts Normal file
View File

@@ -0,0 +1,141 @@
import { Router, Request, Response } from 'express'
import { sessionAuth } from '../middleware/auth.js'
import { generateBillingPDF, verifyHmacSignature } from '../utils/pdf.js'
import db from '../db.js'
const router = Router()
// 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 {
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 modelMap = new Map<string, { quota: number; count: number }>()
let totalQuota = 0
for (const log of allLogs) {
totalQuota += log.quota || 0
const model = log.model_name || 'unknown'
const existing = modelMap.get(model) || { quota: 0, count: 0 }
existing.quota += log.quota || 0
existing.count += 1
modelMap.set(model, existing)
}
const modelSummary = Array.from(modelMap.entries())
.map(([model, data]) => ({ model, ...data }))
.sort((a, b) => b.quota - a.quota)
const pdfBuffer = await generateBillingPDF({
siteName: site.name,
siteUrl: site.url,
userId: userInfo.id,
username: userInfo.username,
group: userInfo.group || 'default',
startDate,
endDate,
totalQuota,
totalRequests: allLogs.length,
modelSummary,
logs: allLogs
})
res.setHeader('Content-Type', 'application/pdf')
res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(site.name)}_billing_${startDate}_${endDate}.pdf"`)
res.send(pdfBuffer)
} catch (error: any) {
res.status(500).json({ success: false, message: `Failed to generate report: ${error.message}` })
}
})
// POST /api/billing/export/csv
router.post('/export/csv', sessionAuth, async (req: Request, res: Response) => {
const session = req.session!
const { startDate, endDate } = req.body
const site = db.prepare('SELECT name FROM sites WHERE id = ?').get(session.site_id) as any
try {
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 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 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"`)
res.send(csv)
} catch (error: any) {
res.status(500).json({ success: false, message: `Failed to generate CSV: ${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
try {
const valid = verifyHmacSignature({ userId, startDate, endDate, totalQuota, totalRecords, generatedAt, signature })
res.json({ success: true, data: { valid } })
} catch {
res.json({ success: true, data: { valid: false } })
}
})
export default router

49
server/routes/proxy.ts Normal file
View File

@@ -0,0 +1,49 @@
import { Router, Request, Response } from 'express'
import { sessionAuth } from '../middleware/auth.js'
const router = Router()
router.all('/*', sessionAuth, async (req: Request, res: Response) => {
const session = req.session!
const targetPath = req.originalUrl.replace(/^\/proxy/, '')
const targetUrl = `${session.site_url}${targetPath}`
try {
const headers: Record<string, string> = {
'Authorization': session.access_token,
'Content-Type': req.headers['content-type'] || 'application/json'
}
const fetchOptions: RequestInit = {
method: req.method,
headers
}
if (['POST', 'PUT', 'PATCH'].includes(req.method) && req.body) {
fetchOptions.body = JSON.stringify(req.body)
}
const response = await fetch(targetUrl, fetchOptions)
const contentType = response.headers.get('content-type') || ''
res.status(response.status)
const forwardHeaders = ['content-type', 'x-request-id']
for (const h of forwardHeaders) {
const val = response.headers.get(h)
if (val) res.setHeader(h, val)
}
if (contentType.includes('application/json')) {
const data = await response.json()
res.json(data)
} else {
const buffer = await response.arrayBuffer()
res.send(Buffer.from(buffer))
}
} catch (error: any) {
res.status(502).json({ success: false, message: `代理请求失败: ${error.message}` })
}
})
export default router

41
server/routes/sites.ts Normal file
View File

@@ -0,0 +1,41 @@
import { Router, Request, Response } from 'express'
import db from '../db.js'
const router = Router()
// GET /api/sites — list all sites (public, for login page)
router.get('/', (_req: Request, res: Response) => {
const sites = db.prepare('SELECT id, name, url, created_at, updated_at FROM sites ORDER BY id').all()
res.json({ success: true, data: sites })
})
// POST /api/sites — create site
router.post('/', (req: Request, res: Response) => {
const { name, url } = req.body
if (!name || !url) {
res.json({ success: false, message: '站点名称和 URL 不能为空' })
return
}
const result = db.prepare('INSERT INTO sites (name, url) VALUES (?, ?)').run(name, url.replace(/\/+$/, ''))
const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(result.lastInsertRowid)
res.json({ success: true, data: site })
})
// PUT /api/sites/:id — update site
router.put('/:id', (req: Request, res: Response) => {
const { name, url } = req.body
const { id } = req.params
db.prepare("UPDATE sites SET name = ?, url = ?, updated_at = datetime('now') WHERE id = ?")
.run(name, url.replace(/\/+$/, ''), id)
const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(id)
res.json({ success: true, data: site })
})
// DELETE /api/sites/:id — delete site
router.delete('/:id', (req: Request, res: Response) => {
const { id } = req.params
db.prepare('DELETE FROM sites WHERE id = ?').run(id)
res.json({ success: true })
})
export default router

174
server/utils/pdf.ts Normal file
View File

@@ -0,0 +1,174 @@
import PDFDocument from 'pdfkit'
import crypto from 'crypto'
const HMAC_SECRET = process.env.HMAC_SECRET || 'newapi-dashboard-default-secret-key-change-in-production'
interface BillingReportData {
siteName: string
siteUrl: string
userId: number
username: string
group: string
startDate: string
endDate: string
totalQuota: number
totalRequests: number
modelSummary: { model: string; quota: number; count: number }[]
logs: {
created_at: number
model_name: string
token_name: string
quota: number
prompt_tokens: number
completion_tokens: number
request_id: string
}[]
}
export function generateHmacSignature(data: {
userId: number
startDate: string
endDate: string
totalQuota: number
totalRecords: number
generatedAt: string
}): string {
const signStr = `${data.userId}|${data.startDate}|${data.endDate}|${data.totalQuota}|${data.totalRecords}|${data.generatedAt}`
return crypto.createHmac('sha256', HMAC_SECRET).update(signStr).digest('hex')
}
export function verifyHmacSignature(data: {
userId: number
startDate: string
endDate: string
totalQuota: number
totalRecords: number
generatedAt: string
signature: string
}): boolean {
const expected = generateHmacSignature(data)
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(data.signature))
}
export function generateBillingPDF(data: BillingReportData): Promise<Buffer> {
return new Promise((resolve, reject) => {
const doc = new PDFDocument({ size: 'A4', margin: 50 })
const chunks: Buffer[] = []
doc.on('data', (chunk: Buffer) => chunks.push(chunk))
doc.on('end', () => resolve(Buffer.concat(chunks)))
doc.on('error', reject)
const generatedAt = new Date().toISOString()
const reportId = crypto.createHash('md5')
.update(`${data.userId}-${generatedAt}`)
.digest('hex')
.substring(0, 12)
.toUpperCase()
// -- Watermark --
doc.save()
doc.opacity(0.06)
doc.fontSize(60)
doc.rotate(-45, { origin: [300, 400] })
doc.text(data.siteName, 100, 350, { width: 600 })
doc.restore()
// -- Header --
doc.fontSize(20).text(data.siteName, { align: 'center' })
doc.fontSize(14).text('API Usage Billing Report', { align: 'center' })
doc.moveDown(0.5)
doc.fontSize(9).fillColor('#666')
.text(`Report ID: RPT-${reportId}`, { align: 'center' })
doc.moveDown(1)
// -- Report Info --
doc.fillColor('#000').fontSize(10)
doc.text(`Period: ${data.startDate} ~ ${data.endDate}`)
doc.text(`User ID: ${data.userId} Username: ${data.username} Group: ${data.group}`)
doc.text(`Site: ${data.siteUrl}`)
doc.moveDown(1)
// -- Summary --
doc.fontSize(12).text('Summary', { underline: true })
doc.moveDown(0.3)
doc.fontSize(10)
doc.text(`Total Quota Consumed: ${(data.totalQuota / 500000).toFixed(4)} USD (${data.totalQuota} quota units)`)
doc.text(`Total Requests: ${data.totalRequests}`)
doc.moveDown(0.5)
// Model summary
if (data.modelSummary.length > 0) {
doc.fontSize(11).text('By Model:', { underline: true })
doc.moveDown(0.3)
doc.fontSize(9)
for (const m of data.modelSummary) {
doc.text(` ${m.model}: ${(m.quota / 500000).toFixed(4)} USD, ${m.count} requests`)
}
doc.moveDown(1)
}
// -- Detail Table --
doc.fontSize(12).text('Usage Details', { underline: true })
doc.moveDown(0.3)
const tableTop = doc.y
const col = [50, 150, 280, 370, 460]
doc.fontSize(8).fillColor('#333')
doc.text('Time', col[0], tableTop)
doc.text('Model', col[1], tableTop)
doc.text('Token', col[2], tableTop)
doc.text('Cost (USD)', col[3], tableTop)
doc.text('Request ID', col[4], tableTop)
doc.moveTo(50, tableTop + 12).lineTo(560, tableTop + 12).stroke('#ccc')
let y = tableTop + 16
const maxRows = Math.min(data.logs.length, 200)
for (let i = 0; i < maxRows; i++) {
if (y > 750) {
doc.addPage()
y = 50
}
const log = data.logs[i]
const time = new Date(log.created_at * 1000).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })
doc.fontSize(7).fillColor('#000')
doc.text(time, col[0], y, { width: 95 })
doc.text(log.model_name || '-', col[1], y, { width: 125 })
doc.text(log.token_name || '-', col[2], y, { width: 85 })
doc.text((log.quota / 500000).toFixed(6), col[3], y, { width: 85 })
doc.text(log.request_id?.substring(0, 12) || '-', col[4], y, { width: 100 })
y += 14
}
if (data.logs.length > maxRows) {
doc.moveDown(0.5)
doc.fontSize(8).fillColor('#999')
.text(`... Total ${data.logs.length} records, showing first ${maxRows}`)
}
// -- HMAC Signature --
const signature = generateHmacSignature({
userId: data.userId,
startDate: data.startDate,
endDate: data.endDate,
totalQuota: data.totalQuota,
totalRecords: data.logs.length,
generatedAt
})
doc.addPage()
doc.fontSize(10).fillColor('#000').text('Report Verification', { underline: true })
doc.moveDown(0.3)
doc.fontSize(8).fillColor('#333')
doc.text(`Generated At: ${generatedAt}`)
doc.text(`Total Records: ${data.logs.length}`)
doc.text(`HMAC-SHA256 Signature: ${signature}`)
doc.moveDown(0.5)
doc.fontSize(7).fillColor('#999')
doc.text('This report is system-generated. The signature can be verified through the system verification API.')
doc.text('Verification endpoint: POST /api/billing/verify')
doc.end()
})
}