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

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