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:
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
data/
|
||||||
|
*.db
|
||||||
|
*.db-wal
|
||||||
|
*.db-shm
|
||||||
|
.env
|
||||||
12
index.html
Normal file
12
index.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>NewAPI Dashboard</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
8216
package-lock.json
generated
Normal file
8216
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
44
package.json
Normal file
44
package.json
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
{
|
||||||
|
"name": "newapi-dashboard",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"server": "tsx watch server/index.ts",
|
||||||
|
"start": "concurrently \"npm run server\" \"npm run dev\""
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-router-dom": "^6.28.0",
|
||||||
|
"antd": "^5.22.0",
|
||||||
|
"@ant-design/icons": "^5.5.0",
|
||||||
|
"@ant-design/charts": "^2.2.0",
|
||||||
|
"zustand": "^5.0.0",
|
||||||
|
"axios": "^1.7.0",
|
||||||
|
"dayjs": "^1.11.13",
|
||||||
|
"express": "^4.21.0",
|
||||||
|
"better-sqlite3": "^11.6.0",
|
||||||
|
"http-proxy-middleware": "^3.0.0",
|
||||||
|
"pdfkit": "^0.15.0",
|
||||||
|
"uuid": "^11.0.0",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"concurrently": "^9.1.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.3.12",
|
||||||
|
"@types/react-dom": "^18.3.1",
|
||||||
|
"@types/express": "^5.0.0",
|
||||||
|
"@types/better-sqlite3": "^7.6.12",
|
||||||
|
"@types/cors": "^2.8.17",
|
||||||
|
"@types/uuid": "^10.0.0",
|
||||||
|
"@types/pdfkit": "^0.13.5",
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"typescript": "^5.6.0",
|
||||||
|
"tsx": "^4.19.0",
|
||||||
|
"vite": "^6.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
37
server/db.ts
Normal file
37
server/db.ts
Normal 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
27
server/index.ts
Normal 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
51
server/middleware/auth.ts
Normal 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
78
server/routes/auth.ts
Normal 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
141
server/routes/billing.ts
Normal 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
49
server/routes/proxy.ts
Normal 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
41
server/routes/sites.ts
Normal 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
174
server/utils/pdf.ts
Normal 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()
|
||||||
|
})
|
||||||
|
}
|
||||||
70
src/App.tsx
Normal file
70
src/App.tsx
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
|
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||||
|
import { ConfigProvider, App as AntApp, Spin } from 'antd'
|
||||||
|
import zhCN from 'antd/locale/zh_CN'
|
||||||
|
import { lightTheme } from '@/utils/theme'
|
||||||
|
import { useAuthStore } from '@/store/authStore'
|
||||||
|
import { authApi } from '@/api/auth'
|
||||||
|
import Layout from '@/components/Layout'
|
||||||
|
import Login from '@/pages/Login'
|
||||||
|
import Dashboard from '@/pages/Dashboard'
|
||||||
|
import Billing from '@/pages/Billing'
|
||||||
|
import SiteManagement from '@/pages/admin/SiteManagement'
|
||||||
|
|
||||||
|
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||||
|
const { isLoggedIn, loading } = useAuthStore()
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
|
||||||
|
<Spin size="large" tip="Loading..." />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return isLoggedIn ? <>{children}</> : <Navigate to="/login" />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SessionRestore({ children }: { children: React.ReactNode }) {
|
||||||
|
const { sessionToken, login, logout, setLoading } = useAuthStore()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (sessionToken) {
|
||||||
|
authApi.me()
|
||||||
|
.then((res) => {
|
||||||
|
if (res.data.success) {
|
||||||
|
login(sessionToken, res.data.data.userInfo, res.data.data.site)
|
||||||
|
} else {
|
||||||
|
logout()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => logout())
|
||||||
|
} else {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return <>{children}</>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<ConfigProvider locale={zhCN} theme={lightTheme}>
|
||||||
|
<AntApp>
|
||||||
|
<BrowserRouter>
|
||||||
|
<SessionRestore>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={<Login />} />
|
||||||
|
<Route path="/" element={<ProtectedRoute><Layout /></ProtectedRoute>}>
|
||||||
|
<Route index element={<Navigate to="/dashboard" />} />
|
||||||
|
<Route path="dashboard" element={<Dashboard />} />
|
||||||
|
<Route path="billing" element={<Billing />} />
|
||||||
|
<Route path="admin/sites" element={<SiteManagement />} />
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
</SessionRestore>
|
||||||
|
</BrowserRouter>
|
||||||
|
</AntApp>
|
||||||
|
</ConfigProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
8
src/api/auth.ts
Normal file
8
src/api/auth.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import client from './client'
|
||||||
|
|
||||||
|
export const authApi = {
|
||||||
|
login: (data: { userId: number; accessToken: string; siteId: number }) =>
|
||||||
|
client.post('/api/auth/login', data),
|
||||||
|
logout: () => client.post('/api/auth/logout'),
|
||||||
|
me: () => client.get('/api/auth/me'),
|
||||||
|
}
|
||||||
9
src/api/billing.ts
Normal file
9
src/api/billing.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import client from './client'
|
||||||
|
|
||||||
|
export const billingApi = {
|
||||||
|
exportPdf: (startDate: string, endDate: string) =>
|
||||||
|
client.post('/api/billing/export/pdf', { startDate, endDate }, { responseType: 'blob' }),
|
||||||
|
exportCsv: (startDate: string, endDate: string) =>
|
||||||
|
client.post('/api/billing/export/csv', { startDate, endDate }, { responseType: 'blob' }),
|
||||||
|
verify: (data: any) => client.post('/api/billing/verify', data),
|
||||||
|
}
|
||||||
27
src/api/client.ts
Normal file
27
src/api/client.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
const client = axios.create({
|
||||||
|
baseURL: '',
|
||||||
|
timeout: 30000,
|
||||||
|
})
|
||||||
|
|
||||||
|
client.interceptors.request.use((config) => {
|
||||||
|
const token = localStorage.getItem('sessionToken')
|
||||||
|
if (token) {
|
||||||
|
config.headers['X-Session-Token'] = token
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
})
|
||||||
|
|
||||||
|
client.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
(error) => {
|
||||||
|
if (error.response?.status === 401) {
|
||||||
|
localStorage.removeItem('sessionToken')
|
||||||
|
window.location.href = '/login'
|
||||||
|
}
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export default client
|
||||||
14
src/api/dashboard.ts
Normal file
14
src/api/dashboard.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import client from './client'
|
||||||
|
|
||||||
|
export const dashboardApi = {
|
||||||
|
getUserInfo: () => client.get('/proxy/api/user/self'),
|
||||||
|
getTokens: (params?: { p?: number; page_size?: number }) =>
|
||||||
|
client.get('/proxy/api/token/', { params }),
|
||||||
|
getLogs: (params?: {
|
||||||
|
p?: number; page_size?: number; type?: number;
|
||||||
|
start_timestamp?: number; end_timestamp?: number;
|
||||||
|
model_name?: string; token_name?: string
|
||||||
|
}) => client.get('/proxy/api/log/self', { params }),
|
||||||
|
getTopUps: (params?: { p?: number; page_size?: number; keyword?: string }) =>
|
||||||
|
client.get('/proxy/api/user/topup/self', { params }),
|
||||||
|
}
|
||||||
8
src/api/sites.ts
Normal file
8
src/api/sites.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import client from './client'
|
||||||
|
|
||||||
|
export const sitesApi = {
|
||||||
|
list: () => client.get('/api/sites'),
|
||||||
|
create: (data: { name: string; url: string }) => client.post('/api/sites', data),
|
||||||
|
update: (id: number, data: { name: string; url: string }) => client.put(`/api/sites/${id}`, data),
|
||||||
|
delete: (id: number) => client.delete(`/api/sites/${id}`),
|
||||||
|
}
|
||||||
87
src/components/Layout.tsx
Normal file
87
src/components/Layout.tsx
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { Outlet, useNavigate, useLocation } from 'react-router-dom'
|
||||||
|
import { Layout as AntLayout, Menu, Avatar, Dropdown, Button, Space, Typography, theme } from 'antd'
|
||||||
|
import {
|
||||||
|
DashboardOutlined, FileTextOutlined,
|
||||||
|
LogoutOutlined, UserOutlined, MenuFoldOutlined, MenuUnfoldOutlined,
|
||||||
|
GlobalOutlined
|
||||||
|
} from '@ant-design/icons'
|
||||||
|
import { useAuthStore } from '@/store/authStore'
|
||||||
|
import { authApi } from '@/api/auth'
|
||||||
|
|
||||||
|
const { Sider, Header, Content } = AntLayout
|
||||||
|
const { Text } = Typography
|
||||||
|
|
||||||
|
export default function Layout() {
|
||||||
|
const [collapsed, setCollapsed] = useState(false)
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const location = useLocation()
|
||||||
|
const { userInfo, site, logout } = useAuthStore()
|
||||||
|
const { token } = theme.useToken()
|
||||||
|
|
||||||
|
const isAdmin = (userInfo?.role || 0) >= 10
|
||||||
|
|
||||||
|
const menuItems = [
|
||||||
|
{ key: '/dashboard', icon: <DashboardOutlined />, label: '仪表盘' },
|
||||||
|
{ key: '/billing', icon: <FileTextOutlined />, label: '账单' },
|
||||||
|
...(isAdmin ? [{ key: '/admin/sites', icon: <GlobalOutlined />, label: '站点管理' }] : []),
|
||||||
|
]
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
try { await authApi.logout() } catch {}
|
||||||
|
logout()
|
||||||
|
navigate('/login')
|
||||||
|
}
|
||||||
|
|
||||||
|
const dropdownItems = {
|
||||||
|
items: [
|
||||||
|
{ key: 'user', label: `${userInfo?.username || '用户'} (ID: ${userInfo?.id})`, disabled: true },
|
||||||
|
{ key: 'site', label: `站点: ${site?.name || '-'}`, disabled: true },
|
||||||
|
{ type: 'divider' as const },
|
||||||
|
{ key: 'logout', label: '退出登录', icon: <LogoutOutlined />, danger: true },
|
||||||
|
],
|
||||||
|
onClick: ({ key }: { key: string }) => { if (key === 'logout') handleLogout() }
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AntLayout style={{ minHeight: '100vh' }}>
|
||||||
|
<Sider trigger={null} collapsible collapsed={collapsed} breakpoint="lg"
|
||||||
|
onBreakpoint={(broken) => setCollapsed(broken)}>
|
||||||
|
<div style={{
|
||||||
|
height: 64, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
borderBottom: '1px solid rgba(255,255,255,0.1)'
|
||||||
|
}}>
|
||||||
|
<Text strong style={{ color: '#fff', fontSize: collapsed ? 14 : 18 }}>
|
||||||
|
{collapsed ? 'NA' : 'NewAPI Dashboard'}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<Menu theme="dark" mode="inline" selectedKeys={[location.pathname]}
|
||||||
|
items={menuItems}
|
||||||
|
onClick={({ key }) => navigate(key)}
|
||||||
|
/>
|
||||||
|
</Sider>
|
||||||
|
<AntLayout>
|
||||||
|
<Header style={{
|
||||||
|
padding: '0 24px', background: token.colorBgContainer,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||||
|
borderBottom: `1px solid ${token.colorBorderSecondary}`,
|
||||||
|
boxShadow: '0 1px 4px rgba(0,0,0,0.05)'
|
||||||
|
}}>
|
||||||
|
<Button type="text" icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
|
||||||
|
onClick={() => setCollapsed(!collapsed)} />
|
||||||
|
<Space>
|
||||||
|
<Dropdown menu={dropdownItems}>
|
||||||
|
<Space style={{ cursor: 'pointer' }}>
|
||||||
|
<Avatar icon={<UserOutlined />} style={{ backgroundColor: token.colorPrimary }} />
|
||||||
|
<Text>{userInfo?.display_name || userInfo?.username}</Text>
|
||||||
|
</Space>
|
||||||
|
</Dropdown>
|
||||||
|
</Space>
|
||||||
|
</Header>
|
||||||
|
<Content style={{ margin: 24 }}>
|
||||||
|
<Outlet />
|
||||||
|
</Content>
|
||||||
|
</AntLayout>
|
||||||
|
</AntLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
51
src/components/ModelPieChart.tsx
Normal file
51
src/components/ModelPieChart.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { Card, Empty } from 'antd'
|
||||||
|
import { PieChartOutlined } from '@ant-design/icons'
|
||||||
|
import { Pie } from '@ant-design/charts'
|
||||||
|
|
||||||
|
interface LogItem {
|
||||||
|
model_name: string
|
||||||
|
quota: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
logs: LogItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ModelPieChart({ logs }: Props) {
|
||||||
|
const modelMap = new Map<string, number>()
|
||||||
|
for (const log of logs) {
|
||||||
|
const model = log.model_name || 'unknown'
|
||||||
|
modelMap.set(model, (modelMap.get(model) || 0) + (log.quota || 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = Array.from(modelMap.entries())
|
||||||
|
.map(([name, value]) => ({ name, value }))
|
||||||
|
.sort((a, b) => b.value - a.value)
|
||||||
|
.slice(0, 10) // Top 10
|
||||||
|
|
||||||
|
if (data.length === 0) {
|
||||||
|
return <Card title={<><PieChartOutlined /> 模型消耗分布</>}><Empty description="暂无数据" /></Card>
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
data,
|
||||||
|
angleField: 'value',
|
||||||
|
colorField: 'name',
|
||||||
|
radius: 0.9,
|
||||||
|
innerRadius: 0.5,
|
||||||
|
label: {
|
||||||
|
text: 'name',
|
||||||
|
position: 'outside' as const,
|
||||||
|
style: { fontSize: 11 },
|
||||||
|
},
|
||||||
|
legend: { position: 'right' as const },
|
||||||
|
interaction: { tooltip: { marker: true } },
|
||||||
|
height: 280,
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card title={<><PieChartOutlined /> 模型消耗分布</>} hoverable>
|
||||||
|
<Pie {...config} />
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
41
src/components/QuotaCard.tsx
Normal file
41
src/components/QuotaCard.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { Card, Progress, Statistic, Row, Col, Typography } from 'antd'
|
||||||
|
import { WalletOutlined } from '@ant-design/icons'
|
||||||
|
import { quotaToUsd } from '@/utils/quota'
|
||||||
|
|
||||||
|
const { Text } = Typography
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
quota: number
|
||||||
|
usedQuota: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function QuotaCard({ quota, usedQuota }: Props) {
|
||||||
|
const total = quota + usedQuota
|
||||||
|
const percent = total > 0 ? Math.round((usedQuota / total) * 100) : 0
|
||||||
|
const remaining = quota
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card title={<><WalletOutlined /> 额度概览</>} hoverable>
|
||||||
|
<Row gutter={24} align="middle">
|
||||||
|
<Col span={10} style={{ textAlign: 'center' }}>
|
||||||
|
<Progress
|
||||||
|
type="dashboard"
|
||||||
|
percent={percent}
|
||||||
|
strokeColor={{
|
||||||
|
'0%': '#1677ff',
|
||||||
|
'100%': percent > 80 ? '#ff4d4f' : '#52c41a',
|
||||||
|
}}
|
||||||
|
format={() => `${percent}%`}
|
||||||
|
size={120}
|
||||||
|
/>
|
||||||
|
<Text type="secondary" style={{ display: 'block', marginTop: 8 }}>已使用比例</Text>
|
||||||
|
</Col>
|
||||||
|
<Col span={14}>
|
||||||
|
<Statistic title="剩余额度" value={quotaToUsd(remaining)} suffix="USD" valueStyle={{ color: '#52c41a' }} />
|
||||||
|
<Statistic title="已用额度" value={quotaToUsd(usedQuota)} suffix="USD" style={{ marginTop: 16 }} valueStyle={{ color: '#1677ff' }} />
|
||||||
|
<Statistic title="总额度" value={quotaToUsd(total)} suffix="USD" style={{ marginTop: 16 }} valueStyle={{ fontSize: 14 }} />
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
64
src/components/RecentLogs.tsx
Normal file
64
src/components/RecentLogs.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { Card, Timeline, Typography, Tag, Empty } from 'antd'
|
||||||
|
import { HistoryOutlined } from '@ant-design/icons'
|
||||||
|
import { formatTimestamp, quotaToUsd } from '@/utils/quota'
|
||||||
|
|
||||||
|
const { Text } = Typography
|
||||||
|
|
||||||
|
interface LogItem {
|
||||||
|
id: number
|
||||||
|
created_at: number
|
||||||
|
model_name: string
|
||||||
|
token_name: string
|
||||||
|
quota: number
|
||||||
|
type: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
logs: LogItem[]
|
||||||
|
loading: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const logTypeMap: Record<number, { color: string; label: string }> = {
|
||||||
|
1: { color: 'green', label: '充值' },
|
||||||
|
2: { color: 'blue', label: '消费' },
|
||||||
|
3: { color: 'orange', label: '管理' },
|
||||||
|
4: { color: 'purple', label: '系统' },
|
||||||
|
5: { color: 'red', label: '错误' },
|
||||||
|
6: { color: 'cyan', label: '退款' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RecentLogs({ logs, loading }: Props) {
|
||||||
|
if (!loading && logs.length === 0) {
|
||||||
|
return <Card title={<><HistoryOutlined /> 最近操作日志</>}><Empty description="暂无日志" /></Card>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card title={<><HistoryOutlined /> 最近操作日志</>} loading={loading} hoverable>
|
||||||
|
<Timeline
|
||||||
|
items={logs.slice(0, 10).map((log) => {
|
||||||
|
const typeInfo = logTypeMap[log.type] || { color: 'default', label: '未知' }
|
||||||
|
return {
|
||||||
|
color: typeInfo.color,
|
||||||
|
children: (
|
||||||
|
<div>
|
||||||
|
<div>
|
||||||
|
<Tag color={typeInfo.color} style={{ marginRight: 8 }}>{typeInfo.label}</Tag>
|
||||||
|
<Text strong>{log.model_name || '-'}</Text>
|
||||||
|
{log.quota > 0 && (
|
||||||
|
<Text type="secondary" style={{ marginLeft: 8 }}>
|
||||||
|
${quotaToUsd(log.quota)}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
{formatTimestamp(log.created_at)}
|
||||||
|
{log.token_name && ` · ${log.token_name}`}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
77
src/components/TokenOverview.tsx
Normal file
77
src/components/TokenOverview.tsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { Card, Table, Tag, Typography } from 'antd'
|
||||||
|
import { KeyOutlined } from '@ant-design/icons'
|
||||||
|
import { quotaToUsd, getTokenStatusTag, formatTimestamp } from '@/utils/quota'
|
||||||
|
|
||||||
|
const { Text } = Typography
|
||||||
|
|
||||||
|
interface TokenItem {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
status: number
|
||||||
|
remain_quota: number
|
||||||
|
used_quota: number
|
||||||
|
unlimited_quota: boolean
|
||||||
|
created_time: number
|
||||||
|
expired_time: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
tokens: TokenItem[]
|
||||||
|
loading: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TokenOverview({ tokens, loading }: Props) {
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
title: '名称',
|
||||||
|
dataIndex: 'name',
|
||||||
|
key: 'name',
|
||||||
|
ellipsis: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'status',
|
||||||
|
key: 'status',
|
||||||
|
width: 80,
|
||||||
|
render: (status: number) => {
|
||||||
|
const tag = getTokenStatusTag(status)
|
||||||
|
return <Tag color={tag.color}>{tag.text}</Tag>
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '剩余额度',
|
||||||
|
key: 'remain',
|
||||||
|
width: 120,
|
||||||
|
render: (_: any, record: TokenItem) =>
|
||||||
|
record.unlimited_quota ? <Text type="success">无限</Text> : `$${quotaToUsd(record.remain_quota)}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '已用额度',
|
||||||
|
dataIndex: 'used_quota',
|
||||||
|
key: 'used',
|
||||||
|
width: 120,
|
||||||
|
render: (val: number) => `$${quotaToUsd(val)}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '过期时间',
|
||||||
|
dataIndex: 'expired_time',
|
||||||
|
key: 'expired',
|
||||||
|
width: 120,
|
||||||
|
render: (val: number) => val === -1 ? '永不过期' : formatTimestamp(val),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card title={<><KeyOutlined /> 令牌概览</>} hoverable>
|
||||||
|
<Table
|
||||||
|
dataSource={tokens}
|
||||||
|
columns={columns}
|
||||||
|
rowKey="id"
|
||||||
|
loading={loading}
|
||||||
|
pagination={false}
|
||||||
|
size="small"
|
||||||
|
scroll={{ y: 240 }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
65
src/components/UsageChart.tsx
Normal file
65
src/components/UsageChart.tsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { Card, Empty } from 'antd'
|
||||||
|
import { LineChartOutlined } from '@ant-design/icons'
|
||||||
|
import { Line } from '@ant-design/charts'
|
||||||
|
|
||||||
|
interface LogItem {
|
||||||
|
created_at: number
|
||||||
|
quota: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
logs: LogItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UsageChart({ logs }: Props) {
|
||||||
|
// Aggregate logs by day for last 7 days
|
||||||
|
const now = new Date()
|
||||||
|
const dayMap = new Map<string, { count: number; quota: number }>()
|
||||||
|
|
||||||
|
// Initialize last 7 days
|
||||||
|
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 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fill data
|
||||||
|
for (const log of logs) {
|
||||||
|
const date = new Date(log.created_at * 1000).toISOString().split('T')[0]
|
||||||
|
if (dayMap.has(date)) {
|
||||||
|
const existing = dayMap.get(date)!
|
||||||
|
existing.count += 1
|
||||||
|
existing.quota += log.quota || 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const chartData: { date: string; value: number; type: string }[] = []
|
||||||
|
dayMap.forEach((val, date) => {
|
||||||
|
const shortDate = date.substring(5) // MM-DD
|
||||||
|
chartData.push({ date: shortDate, value: val.count, type: '请求次数' })
|
||||||
|
chartData.push({ date: shortDate, value: Math.round(val.quota / 500), type: '消耗(K quota)' })
|
||||||
|
})
|
||||||
|
|
||||||
|
if (chartData.length === 0) {
|
||||||
|
return <Card title={<><LineChartOutlined /> 近 7 天使用趋势</>}><Empty description="暂无数据" /></Card>
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
data: chartData,
|
||||||
|
xField: 'date',
|
||||||
|
yField: 'value',
|
||||||
|
colorField: 'type',
|
||||||
|
smooth: true,
|
||||||
|
point: { shapeField: 'circle', sizeField: 3 },
|
||||||
|
interaction: { tooltip: { marker: true } },
|
||||||
|
style: { lineWidth: 2 },
|
||||||
|
height: 280,
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card title={<><LineChartOutlined /> 近 7 天使用趋势</>} hoverable>
|
||||||
|
<Line {...config} />
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
9
src/main.tsx
Normal file
9
src/main.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import App from './App'
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
)
|
||||||
438
src/pages/Billing.tsx
Normal file
438
src/pages/Billing.tsx
Normal file
@@ -0,0 +1,438 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { Row, Col, Card, Table, DatePicker, Select, Input, Button, Space, Tag, Statistic, message, Tabs, Typography } from 'antd'
|
||||||
|
import { DownloadOutlined, FileExcelOutlined, FilePdfOutlined, SearchOutlined, ReloadOutlined, BarChartOutlined } from '@ant-design/icons'
|
||||||
|
import { Column } from '@ant-design/charts'
|
||||||
|
import dayjs, { Dayjs } from 'dayjs'
|
||||||
|
import { dashboardApi } from '@/api/dashboard'
|
||||||
|
import { billingApi } from '@/api/billing'
|
||||||
|
import { quotaToUsd, formatTimestamp } from '@/utils/quota'
|
||||||
|
|
||||||
|
const { RangePicker } = DatePicker
|
||||||
|
const { Text, Title } = Typography
|
||||||
|
|
||||||
|
export default function Billing() {
|
||||||
|
// Log state
|
||||||
|
const [logs, setLogs] = useState<any[]>([])
|
||||||
|
const [logTotal, setLogTotal] = useState(0)
|
||||||
|
const [logPage, setLogPage] = useState(1)
|
||||||
|
const [logPageSize, setLogPageSize] = useState(20)
|
||||||
|
const [logsLoading, setLogsLoading] = useState(false)
|
||||||
|
|
||||||
|
// Filters
|
||||||
|
const [dateRange, setDateRange] = useState<[Dayjs, Dayjs]>([
|
||||||
|
dayjs().subtract(30, 'day'),
|
||||||
|
dayjs(),
|
||||||
|
])
|
||||||
|
const [modelFilter, setModelFilter] = useState('')
|
||||||
|
const [tokenFilter, setTokenFilter] = useState('')
|
||||||
|
|
||||||
|
// TopUp state
|
||||||
|
const [topups, setTopups] = useState<any[]>([])
|
||||||
|
const [topupTotal, setTopupTotal] = useState(0)
|
||||||
|
const [topupPage, setTopupPage] = useState(1)
|
||||||
|
const [topupsLoading, setTopupsLoading] = useState(false)
|
||||||
|
|
||||||
|
// Chart data
|
||||||
|
const [chartData, setChartData] = useState<any[]>([])
|
||||||
|
|
||||||
|
// Export loading
|
||||||
|
const [exportPdfLoading, setExportPdfLoading] = useState(false)
|
||||||
|
const [exportCsvLoading, setExportCsvLoading] = useState(false)
|
||||||
|
|
||||||
|
const [messageApi, contextHolder] = message.useMessage()
|
||||||
|
|
||||||
|
const loadLogs = useCallback(async (page = 1, pageSize = 20) => {
|
||||||
|
setLogsLoading(true)
|
||||||
|
try {
|
||||||
|
const params: any = {
|
||||||
|
p: page,
|
||||||
|
page_size: pageSize,
|
||||||
|
type: 2, // consume type
|
||||||
|
}
|
||||||
|
if (dateRange[0]) params.start_timestamp = dateRange[0].startOf('day').unix()
|
||||||
|
if (dateRange[1]) params.end_timestamp = dateRange[1].endOf('day').unix()
|
||||||
|
if (modelFilter) params.model_name = modelFilter
|
||||||
|
if (tokenFilter) params.token_name = tokenFilter
|
||||||
|
|
||||||
|
const res = await dashboardApi.getLogs(params)
|
||||||
|
if (res.data.success) {
|
||||||
|
setLogs(res.data.data.items || [])
|
||||||
|
setLogTotal(res.data.data.total || 0)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
messageApi.error('加载日志失败')
|
||||||
|
} finally {
|
||||||
|
setLogsLoading(false)
|
||||||
|
}
|
||||||
|
}, [dateRange, modelFilter, tokenFilter])
|
||||||
|
|
||||||
|
const loadTopups = useCallback(async (page = 1) => {
|
||||||
|
setTopupsLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await dashboardApi.getTopUps({ p: page, page_size: 20 })
|
||||||
|
if (res.data.success) {
|
||||||
|
setTopups(res.data.data.items || [])
|
||||||
|
setTopupTotal(res.data.data.total || 0)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
messageApi.error('加载充值记录失败')
|
||||||
|
} finally {
|
||||||
|
setTopupsLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const loadChartData = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const startTs = dateRange[0].startOf('day').unix()
|
||||||
|
const endTs = dateRange[1].endOf('day').unix()
|
||||||
|
const res = await dashboardApi.getLogs({
|
||||||
|
start_timestamp: startTs,
|
||||||
|
end_timestamp: endTs,
|
||||||
|
type: 2,
|
||||||
|
page_size: 100,
|
||||||
|
p: 1,
|
||||||
|
})
|
||||||
|
if (res.data.success) {
|
||||||
|
const items = res.data.data.items || []
|
||||||
|
// Aggregate by model
|
||||||
|
const modelMap = new Map<string, number>()
|
||||||
|
for (const log of items) {
|
||||||
|
const model = log.model_name || 'unknown'
|
||||||
|
modelMap.set(model, (modelMap.get(model) || 0) + (log.quota || 0))
|
||||||
|
}
|
||||||
|
const data = Array.from(modelMap.entries())
|
||||||
|
.map(([model, quota]) => ({ model, quota: Number(quotaToUsd(quota)) }))
|
||||||
|
.sort((a, b) => b.quota - a.quota)
|
||||||
|
.slice(0, 15)
|
||||||
|
setChartData(data)
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}, [dateRange])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadLogs(logPage, logPageSize)
|
||||||
|
loadChartData()
|
||||||
|
}, [logPage, logPageSize, dateRange, modelFilter, tokenFilter])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadTopups(topupPage)
|
||||||
|
}, [topupPage])
|
||||||
|
|
||||||
|
const handleSearch = () => {
|
||||||
|
setLogPage(1)
|
||||||
|
loadLogs(1, logPageSize)
|
||||||
|
loadChartData()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
setDateRange([dayjs().subtract(30, 'day'), dayjs()])
|
||||||
|
setModelFilter('')
|
||||||
|
setTokenFilter('')
|
||||||
|
setLogPage(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleExportPdf = async () => {
|
||||||
|
setExportPdfLoading(true)
|
||||||
|
try {
|
||||||
|
const startDate = dateRange[0].format('YYYY-MM-DD')
|
||||||
|
const endDate = dateRange[1].format('YYYY-MM-DD')
|
||||||
|
const res = await billingApi.exportPdf(startDate, endDate)
|
||||||
|
const url = window.URL.createObjectURL(new Blob([res.data]))
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = url
|
||||||
|
link.download = `billing_${startDate}_${endDate}.pdf`
|
||||||
|
link.click()
|
||||||
|
window.URL.revokeObjectURL(url)
|
||||||
|
messageApi.success('PDF 报表已下载')
|
||||||
|
} catch {
|
||||||
|
messageApi.error('导出 PDF 失败')
|
||||||
|
} finally {
|
||||||
|
setExportPdfLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleExportCsv = async () => {
|
||||||
|
setExportCsvLoading(true)
|
||||||
|
try {
|
||||||
|
const startDate = dateRange[0].format('YYYY-MM-DD')
|
||||||
|
const endDate = dateRange[1].format('YYYY-MM-DD')
|
||||||
|
const res = await billingApi.exportCsv(startDate, endDate)
|
||||||
|
const url = window.URL.createObjectURL(new Blob([res.data]))
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = url
|
||||||
|
link.download = `billing_${startDate}_${endDate}.csv`
|
||||||
|
link.click()
|
||||||
|
window.URL.revokeObjectURL(url)
|
||||||
|
messageApi.success('CSV 已下载')
|
||||||
|
} catch {
|
||||||
|
messageApi.error('导出 CSV 失败')
|
||||||
|
} finally {
|
||||||
|
setExportCsvLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const logColumns = [
|
||||||
|
{
|
||||||
|
title: '时间',
|
||||||
|
dataIndex: 'created_at',
|
||||||
|
key: 'time',
|
||||||
|
width: 170,
|
||||||
|
render: (v: number) => formatTimestamp(v),
|
||||||
|
sorter: (a: any, b: any) => a.created_at - b.created_at,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '模型',
|
||||||
|
dataIndex: 'model_name',
|
||||||
|
key: 'model',
|
||||||
|
ellipsis: true,
|
||||||
|
width: 200,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '令牌',
|
||||||
|
dataIndex: 'token_name',
|
||||||
|
key: 'token',
|
||||||
|
ellipsis: true,
|
||||||
|
width: 120,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '消耗 (USD)',
|
||||||
|
dataIndex: 'quota',
|
||||||
|
key: 'quota',
|
||||||
|
width: 120,
|
||||||
|
render: (v: number) => `$${quotaToUsd(v)}`,
|
||||||
|
sorter: (a: any, b: any) => a.quota - b.quota,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '提示 Tokens',
|
||||||
|
dataIndex: 'prompt_tokens',
|
||||||
|
key: 'prompt',
|
||||||
|
width: 110,
|
||||||
|
render: (v: number) => v?.toLocaleString() || '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '完成 Tokens',
|
||||||
|
dataIndex: 'completion_tokens',
|
||||||
|
key: 'completion',
|
||||||
|
width: 110,
|
||||||
|
render: (v: number) => v?.toLocaleString() || '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '流式',
|
||||||
|
dataIndex: 'is_stream',
|
||||||
|
key: 'stream',
|
||||||
|
width: 60,
|
||||||
|
render: (v: boolean) => v ? <Tag color="blue">是</Tag> : <Tag>否</Tag>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '请求 ID',
|
||||||
|
dataIndex: 'request_id',
|
||||||
|
key: 'request_id',
|
||||||
|
width: 140,
|
||||||
|
ellipsis: true,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const topupColumns = [
|
||||||
|
{
|
||||||
|
title: '充值时间',
|
||||||
|
dataIndex: 'create_time',
|
||||||
|
key: 'time',
|
||||||
|
width: 170,
|
||||||
|
render: (v: number) => formatTimestamp(v),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '充值数量',
|
||||||
|
dataIndex: 'amount',
|
||||||
|
key: 'amount',
|
||||||
|
width: 120,
|
||||||
|
render: (v: number) => `$${(v || 0).toFixed(2)}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '支付金额',
|
||||||
|
dataIndex: 'money',
|
||||||
|
key: 'money',
|
||||||
|
width: 120,
|
||||||
|
render: (v: number) => `¥${(v || 0).toFixed(2)}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '支付方式',
|
||||||
|
dataIndex: 'payment_method',
|
||||||
|
key: 'method',
|
||||||
|
width: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'status',
|
||||||
|
key: 'status',
|
||||||
|
width: 80,
|
||||||
|
render: (v: string) => {
|
||||||
|
const map: Record<string, { color: string; text: string }> = {
|
||||||
|
success: { color: 'green', text: '成功' },
|
||||||
|
pending: { color: 'orange', text: '待支付' },
|
||||||
|
expired: { color: 'red', text: '已过期' },
|
||||||
|
}
|
||||||
|
const info = map[v] || { color: 'default', text: v }
|
||||||
|
return <Tag color={info.color}>{info.text}</Tag>
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '订单号',
|
||||||
|
dataIndex: 'trade_no',
|
||||||
|
key: 'trade',
|
||||||
|
ellipsis: true,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const chartConfig = {
|
||||||
|
data: chartData,
|
||||||
|
xField: 'model',
|
||||||
|
yField: 'quota',
|
||||||
|
colorField: 'model',
|
||||||
|
label: { text: (d: any) => `$${d.quota}`, position: 'outside' as const },
|
||||||
|
axis: {
|
||||||
|
x: { labelAutoRotate: true },
|
||||||
|
y: { title: 'USD' },
|
||||||
|
},
|
||||||
|
height: 300,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate summary stats from current logs
|
||||||
|
const totalQuota = logs.reduce((sum, l) => sum + (l.quota || 0), 0)
|
||||||
|
const totalTokens = logs.reduce((sum, l) => sum + (l.prompt_tokens || 0) + (l.completion_tokens || 0), 0)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Space direction="vertical" size={24} style={{ width: '100%' }}>
|
||||||
|
{contextHolder}
|
||||||
|
|
||||||
|
{/* Filter Bar */}
|
||||||
|
<Card>
|
||||||
|
<Space wrap size="middle">
|
||||||
|
<RangePicker
|
||||||
|
value={dateRange}
|
||||||
|
onChange={(dates) => {
|
||||||
|
if (dates && dates[0] && dates[1]) {
|
||||||
|
setDateRange([dates[0], dates[1]])
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
format="YYYY-MM-DD"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
placeholder="模型名称"
|
||||||
|
value={modelFilter}
|
||||||
|
onChange={(e) => setModelFilter(e.target.value)}
|
||||||
|
style={{ width: 200 }}
|
||||||
|
allowClear
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
placeholder="令牌名称"
|
||||||
|
value={tokenFilter}
|
||||||
|
onChange={(e) => setTokenFilter(e.target.value)}
|
||||||
|
style={{ width: 160 }}
|
||||||
|
allowClear
|
||||||
|
/>
|
||||||
|
<Button type="primary" icon={<SearchOutlined />} onClick={handleSearch}>搜索</Button>
|
||||||
|
<Button icon={<ReloadOutlined />} onClick={handleReset}>重置</Button>
|
||||||
|
<Button icon={<FilePdfOutlined />} loading={exportPdfLoading} onClick={handleExportPdf}>
|
||||||
|
导出 PDF
|
||||||
|
</Button>
|
||||||
|
<Button icon={<FileExcelOutlined />} loading={exportCsvLoading} onClick={handleExportCsv}>
|
||||||
|
导出 CSV
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Summary Stats */}
|
||||||
|
<Row gutter={[16, 16]}>
|
||||||
|
<Col xs={12} md={6}>
|
||||||
|
<Card hoverable>
|
||||||
|
<Statistic title="当页消耗总额" value={quotaToUsd(totalQuota)} prefix="$" valueStyle={{ color: '#1677ff' }} />
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col xs={12} md={6}>
|
||||||
|
<Card hoverable>
|
||||||
|
<Statistic title="总记录数" value={logTotal} />
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col xs={12} md={6}>
|
||||||
|
<Card hoverable>
|
||||||
|
<Statistic title="当页 Tokens 总量" value={totalTokens.toLocaleString()} />
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col xs={12} md={6}>
|
||||||
|
<Card hoverable>
|
||||||
|
<Statistic title="充值记录数" value={topupTotal} />
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Tabs
|
||||||
|
defaultActiveKey="logs"
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
key: 'logs',
|
||||||
|
label: '调用日志',
|
||||||
|
children: (
|
||||||
|
<Card>
|
||||||
|
<Table
|
||||||
|
dataSource={logs}
|
||||||
|
columns={logColumns}
|
||||||
|
rowKey="id"
|
||||||
|
loading={logsLoading}
|
||||||
|
pagination={{
|
||||||
|
current: logPage,
|
||||||
|
pageSize: logPageSize,
|
||||||
|
total: logTotal,
|
||||||
|
showSizeChanger: true,
|
||||||
|
showTotal: (total) => `共 ${total} 条`,
|
||||||
|
onChange: (page, size) => {
|
||||||
|
setLogPage(page)
|
||||||
|
setLogPageSize(size)
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
scroll={{ x: 1200 }}
|
||||||
|
size="middle"
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'chart',
|
||||||
|
label: '消耗分布',
|
||||||
|
children: (
|
||||||
|
<Card title={<><BarChartOutlined /> 按模型消耗分布</>}>
|
||||||
|
{chartData.length > 0 ? (
|
||||||
|
<Column {...chartConfig} />
|
||||||
|
) : (
|
||||||
|
<Text type="secondary">暂无数据</Text>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'topup',
|
||||||
|
label: '充值记录',
|
||||||
|
children: (
|
||||||
|
<Card>
|
||||||
|
<Table
|
||||||
|
dataSource={topups}
|
||||||
|
columns={topupColumns}
|
||||||
|
rowKey="id"
|
||||||
|
loading={topupsLoading}
|
||||||
|
pagination={{
|
||||||
|
current: topupPage,
|
||||||
|
total: topupTotal,
|
||||||
|
pageSize: 20,
|
||||||
|
showTotal: (total) => `共 ${total} 条`,
|
||||||
|
onChange: (page) => setTopupPage(page),
|
||||||
|
}}
|
||||||
|
scroll={{ x: 800 }}
|
||||||
|
size="middle"
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
)
|
||||||
|
}
|
||||||
140
src/pages/Dashboard.tsx
Normal file
140
src/pages/Dashboard.tsx
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Row, Col, Card, Statistic, Space, Tag, Spin } from 'antd'
|
||||||
|
import {
|
||||||
|
UserOutlined, ThunderboltOutlined, ApiOutlined,
|
||||||
|
TeamOutlined, MailOutlined
|
||||||
|
} from '@ant-design/icons'
|
||||||
|
import { useAuthStore } from '@/store/authStore'
|
||||||
|
import { dashboardApi } from '@/api/dashboard'
|
||||||
|
import { getRoleName } from '@/utils/quota'
|
||||||
|
import QuotaCard from '@/components/QuotaCard'
|
||||||
|
import UsageChart from '@/components/UsageChart'
|
||||||
|
import ModelPieChart from '@/components/ModelPieChart'
|
||||||
|
import TokenOverview from '@/components/TokenOverview'
|
||||||
|
import RecentLogs from '@/components/RecentLogs'
|
||||||
|
|
||||||
|
export default function Dashboard() {
|
||||||
|
const { userInfo, updateUserInfo } = useAuthStore()
|
||||||
|
const [logs, setLogs] = useState<any[]>([])
|
||||||
|
const [tokens, setTokens] = useState<any[]>([])
|
||||||
|
const [logsLoading, setLogsLoading] = useState(true)
|
||||||
|
const [tokensLoading, setTokensLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const loadData = async () => {
|
||||||
|
// Refresh user info
|
||||||
|
try {
|
||||||
|
const userRes = await dashboardApi.getUserInfo()
|
||||||
|
if (userRes.data.success) {
|
||||||
|
updateUserInfo(userRes.data.data)
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
// Load recent logs (last 7 days, type=2 consume)
|
||||||
|
try {
|
||||||
|
const sevenDaysAgo = Math.floor((Date.now() - 7 * 24 * 60 * 60 * 1000) / 1000)
|
||||||
|
const logRes = await dashboardApi.getLogs({
|
||||||
|
start_timestamp: sevenDaysAgo,
|
||||||
|
page_size: 100,
|
||||||
|
p: 1,
|
||||||
|
})
|
||||||
|
if (logRes.data.success) {
|
||||||
|
setLogs(logRes.data.data.items || [])
|
||||||
|
}
|
||||||
|
} catch {} finally {
|
||||||
|
setLogsLoading(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load tokens
|
||||||
|
try {
|
||||||
|
const tokenRes = await dashboardApi.getTokens({ page_size: 50 })
|
||||||
|
if (tokenRes.data.success) {
|
||||||
|
setTokens(tokenRes.data.data.items || [])
|
||||||
|
}
|
||||||
|
} catch {} finally {
|
||||||
|
setTokensLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!userInfo) {
|
||||||
|
return <div style={{ textAlign: 'center', padding: 48 }}><Spin size="large" /></div>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Space direction="vertical" size={24} style={{ width: '100%' }}>
|
||||||
|
{/* User Info Cards */}
|
||||||
|
<Row gutter={[16, 16]}>
|
||||||
|
<Col xs={24} sm={12} md={6}>
|
||||||
|
<Card hoverable>
|
||||||
|
<Statistic
|
||||||
|
title="用户名"
|
||||||
|
value={userInfo.display_name || userInfo.username}
|
||||||
|
prefix={<UserOutlined />}
|
||||||
|
valueStyle={{ fontSize: 18 }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} sm={12} md={6}>
|
||||||
|
<Card hoverable>
|
||||||
|
<Statistic
|
||||||
|
title="角色"
|
||||||
|
value={getRoleName(userInfo.role)}
|
||||||
|
prefix={<TeamOutlined />}
|
||||||
|
valueStyle={{ fontSize: 18 }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} sm={12} md={6}>
|
||||||
|
<Card hoverable>
|
||||||
|
<Statistic
|
||||||
|
title="请求次数"
|
||||||
|
value={userInfo.request_count}
|
||||||
|
prefix={<ApiOutlined />}
|
||||||
|
valueStyle={{ fontSize: 18, color: '#1677ff' }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} sm={12} md={6}>
|
||||||
|
<Card hoverable>
|
||||||
|
<Statistic
|
||||||
|
title="分组"
|
||||||
|
value={userInfo.group || 'default'}
|
||||||
|
prefix={<ThunderboltOutlined />}
|
||||||
|
valueStyle={{ fontSize: 18 }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
{/* Quota + Usage Chart */}
|
||||||
|
<Row gutter={[16, 16]}>
|
||||||
|
<Col xs={24} lg={10}>
|
||||||
|
<QuotaCard quota={userInfo.quota} usedQuota={userInfo.used_quota} />
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} lg={14}>
|
||||||
|
<UsageChart logs={logs.filter(l => l.type === 2)} />
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
{/* Model Pie + Token Overview */}
|
||||||
|
<Row gutter={[16, 16]}>
|
||||||
|
<Col xs={24} lg={10}>
|
||||||
|
<ModelPieChart logs={logs.filter(l => l.type === 2)} />
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} lg={14}>
|
||||||
|
<TokenOverview tokens={tokens} loading={tokensLoading} />
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
{/* Recent Logs */}
|
||||||
|
<Row>
|
||||||
|
<Col span={24}>
|
||||||
|
<RecentLogs logs={logs} loading={logsLoading} />
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Space>
|
||||||
|
)
|
||||||
|
}
|
||||||
190
src/pages/Login.tsx
Normal file
190
src/pages/Login.tsx
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { Card, Form, Input, Button, Select, Typography, Space, message, Divider } from 'antd'
|
||||||
|
import { UserOutlined, KeyOutlined, GlobalOutlined, LoginOutlined } from '@ant-design/icons'
|
||||||
|
import { sitesApi } from '@/api/sites'
|
||||||
|
import { authApi } from '@/api/auth'
|
||||||
|
import { useAuthStore } from '@/store/authStore'
|
||||||
|
|
||||||
|
const { Title, Text, Paragraph } = Typography
|
||||||
|
|
||||||
|
interface Site {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Login() {
|
||||||
|
const [form] = Form.useForm()
|
||||||
|
const [sites, setSites] = useState<Site[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [sitesLoading, setSitesLoading] = useState(true)
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { login, isLoggedIn } = useAuthStore()
|
||||||
|
const [messageApi, contextHolder] = message.useMessage()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isLoggedIn) {
|
||||||
|
navigate('/dashboard')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
loadSites()
|
||||||
|
}, [isLoggedIn])
|
||||||
|
|
||||||
|
const loadSites = async () => {
|
||||||
|
try {
|
||||||
|
const res = await sitesApi.list()
|
||||||
|
if (res.data.success) {
|
||||||
|
setSites(res.data.data)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
messageApi.error('无法加载站点列表')
|
||||||
|
} finally {
|
||||||
|
setSitesLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLogin = async (values: { siteId: number; userId: string; accessToken: string }) => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await authApi.login({
|
||||||
|
userId: Number(values.userId),
|
||||||
|
accessToken: values.accessToken,
|
||||||
|
siteId: values.siteId,
|
||||||
|
})
|
||||||
|
if (res.data.success) {
|
||||||
|
const { sessionToken, userInfo, site } = res.data.data
|
||||||
|
login(sessionToken, userInfo, site)
|
||||||
|
messageApi.success(`欢迎回来,${userInfo.display_name || userInfo.username}!`)
|
||||||
|
setTimeout(() => navigate('/dashboard'), 500)
|
||||||
|
} else {
|
||||||
|
messageApi.error(res.data.message || '登录失败')
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
messageApi.error(err.response?.data?.message || '网络错误,请稍后重试')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
minHeight: '100vh',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||||
|
padding: 24,
|
||||||
|
}}>
|
||||||
|
{contextHolder}
|
||||||
|
|
||||||
|
{/* Background decoration */}
|
||||||
|
<div style={{
|
||||||
|
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
|
||||||
|
overflow: 'hidden', pointerEvents: 'none', zIndex: 0,
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', width: 400, height: 400, borderRadius: '50%',
|
||||||
|
background: 'rgba(255,255,255,0.05)', top: '-10%', right: '-5%',
|
||||||
|
}} />
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', width: 300, height: 300, borderRadius: '50%',
|
||||||
|
background: 'rgba(255,255,255,0.05)', bottom: '-5%', left: '-5%',
|
||||||
|
}} />
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', width: 200, height: 200, borderRadius: '50%',
|
||||||
|
background: 'rgba(255,255,255,0.08)', top: '40%', left: '20%',
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
style={{
|
||||||
|
width: 420,
|
||||||
|
borderRadius: 16,
|
||||||
|
boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
|
||||||
|
border: 'none',
|
||||||
|
position: 'relative',
|
||||||
|
zIndex: 1,
|
||||||
|
}}
|
||||||
|
styles={{ body: { padding: '40px 32px' } }}
|
||||||
|
>
|
||||||
|
<Space direction="vertical" size="small" style={{ width: '100%', textAlign: 'center', marginBottom: 32 }}>
|
||||||
|
<Title level={2} style={{ margin: 0, color: '#1677ff' }}>
|
||||||
|
NewAPI Dashboard
|
||||||
|
</Title>
|
||||||
|
<Text type="secondary">AI API 网关管理面板</Text>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
layout="vertical"
|
||||||
|
onFinish={handleLogin}
|
||||||
|
size="large"
|
||||||
|
requiredMark={false}
|
||||||
|
>
|
||||||
|
<Form.Item
|
||||||
|
name="siteId"
|
||||||
|
label={<Space><GlobalOutlined /> 选择站点</Space>}
|
||||||
|
rules={[{ required: true, message: '请选择站点' }]}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
placeholder="请选择要连接的站点"
|
||||||
|
loading={sitesLoading}
|
||||||
|
notFoundContent={sitesLoading ? '加载中...' : '暂无站点,请联系管理员添加'}
|
||||||
|
options={sites.map(s => ({
|
||||||
|
value: s.id,
|
||||||
|
label: (
|
||||||
|
<Space>
|
||||||
|
<Text strong>{s.name}</Text>
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>{s.url}</Text>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Divider style={{ margin: '16px 0' }} />
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="userId"
|
||||||
|
label={<Space><UserOutlined /> 用户 ID</Space>}
|
||||||
|
rules={[
|
||||||
|
{ required: true, message: '请输入用户 ID' },
|
||||||
|
{ pattern: /^\d+$/, message: '用户 ID 必须是数字' },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Input placeholder="请输入您的用户 ID" autoComplete="username" />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="accessToken"
|
||||||
|
label={<Space><KeyOutlined /> 系统令牌 (Access Token)</Space>}
|
||||||
|
rules={[{ required: true, message: '请输入系统令牌' }]}
|
||||||
|
>
|
||||||
|
<Input.Password placeholder="请输入您的 Access Token" autoComplete="current-password" />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item style={{ marginBottom: 16 }}>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
htmlType="submit"
|
||||||
|
loading={loading}
|
||||||
|
icon={<LoginOutlined />}
|
||||||
|
block
|
||||||
|
style={{ height: 44, borderRadius: 8, fontWeight: 600, fontSize: 15 }}
|
||||||
|
>
|
||||||
|
登 录
|
||||||
|
</Button>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
<Paragraph
|
||||||
|
type="secondary"
|
||||||
|
style={{ textAlign: 'center', fontSize: 12, marginBottom: 0 }}
|
||||||
|
>
|
||||||
|
请在管理面板获取您的用户 ID 和系统令牌
|
||||||
|
</Paragraph>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
238
src/pages/admin/SiteManagement.tsx
Normal file
238
src/pages/admin/SiteManagement.tsx
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { Card, Table, Button, Modal, Form, Input, Space, Popconfirm, message, Typography, Tag } from 'antd'
|
||||||
|
import { PlusOutlined, EditOutlined, DeleteOutlined, GlobalOutlined, LinkOutlined } from '@ant-design/icons'
|
||||||
|
import { sitesApi } from '@/api/sites'
|
||||||
|
|
||||||
|
const { Title, Text } = Typography
|
||||||
|
|
||||||
|
interface Site {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
url: string
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SiteManagement() {
|
||||||
|
const [sites, setSites] = useState<Site[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [modalOpen, setModalOpen] = useState(false)
|
||||||
|
const [editingSite, setEditingSite] = useState<Site | null>(null)
|
||||||
|
const [form] = Form.useForm()
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [messageApi, contextHolder] = message.useMessage()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadSites()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const loadSites = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await sitesApi.list()
|
||||||
|
if (res.data.success) {
|
||||||
|
setSites(res.data.data || [])
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
messageApi.error('加载站点列表失败')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openCreateModal = () => {
|
||||||
|
setEditingSite(null)
|
||||||
|
form.resetFields()
|
||||||
|
setModalOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const openEditModal = (site: Site) => {
|
||||||
|
setEditingSite(site)
|
||||||
|
form.setFieldsValue({ name: site.name, url: site.url })
|
||||||
|
setModalOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = async (values: { name: string; url: string }) => {
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
if (editingSite) {
|
||||||
|
const res = await sitesApi.update(editingSite.id, values)
|
||||||
|
if (res.data.success) {
|
||||||
|
messageApi.success('站点已更新')
|
||||||
|
} else {
|
||||||
|
messageApi.error(res.data.message || '更新失败')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const res = await sitesApi.create(values)
|
||||||
|
if (res.data.success) {
|
||||||
|
messageApi.success('站点已创建')
|
||||||
|
} else {
|
||||||
|
messageApi.error(res.data.message || '创建失败')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setModalOpen(false)
|
||||||
|
loadSites()
|
||||||
|
} catch {
|
||||||
|
messageApi.error('操作失败')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (id: number) => {
|
||||||
|
try {
|
||||||
|
const res = await sitesApi.delete(id)
|
||||||
|
if (res.data.success) {
|
||||||
|
messageApi.success('站点已删除')
|
||||||
|
loadSites()
|
||||||
|
} else {
|
||||||
|
messageApi.error(res.data.message || '删除失败')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
messageApi.error('删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
title: 'ID',
|
||||||
|
dataIndex: 'id',
|
||||||
|
key: 'id',
|
||||||
|
width: 60,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '站点名称',
|
||||||
|
dataIndex: 'name',
|
||||||
|
key: 'name',
|
||||||
|
render: (name: string) => (
|
||||||
|
<Space>
|
||||||
|
<GlobalOutlined style={{ color: '#1677ff' }} />
|
||||||
|
<Text strong>{name}</Text>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'API 地址',
|
||||||
|
dataIndex: 'url',
|
||||||
|
key: 'url',
|
||||||
|
render: (url: string) => (
|
||||||
|
<Space>
|
||||||
|
<LinkOutlined />
|
||||||
|
<Text copyable={{ text: url }} style={{ fontFamily: 'monospace', fontSize: 13 }}>
|
||||||
|
{url}
|
||||||
|
</Text>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '创建时间',
|
||||||
|
dataIndex: 'created_at',
|
||||||
|
key: 'created_at',
|
||||||
|
width: 180,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '更新时间',
|
||||||
|
dataIndex: 'updated_at',
|
||||||
|
key: 'updated_at',
|
||||||
|
width: 180,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
key: 'action',
|
||||||
|
width: 160,
|
||||||
|
render: (_: any, record: Site) => (
|
||||||
|
<Space>
|
||||||
|
<Button type="link" icon={<EditOutlined />} onClick={() => openEditModal(record)}>
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
<Popconfirm
|
||||||
|
title="确认删除"
|
||||||
|
description={`确定要删除站点 "${record.name}" 吗?关联的会话也会被删除。`}
|
||||||
|
onConfirm={() => handleDelete(record.id)}
|
||||||
|
okText="删除"
|
||||||
|
cancelText="取消"
|
||||||
|
okButtonProps={{ danger: true }}
|
||||||
|
>
|
||||||
|
<Button type="link" danger icon={<DeleteOutlined />}>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Space direction="vertical" size={24} style={{ width: '100%' }}>
|
||||||
|
{contextHolder}
|
||||||
|
|
||||||
|
<Card
|
||||||
|
title={
|
||||||
|
<Space>
|
||||||
|
<GlobalOutlined />
|
||||||
|
<span>站点管理</span>
|
||||||
|
<Tag color="blue">{sites.length} 个站点</Tag>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
extra={
|
||||||
|
<Button type="primary" icon={<PlusOutlined />} onClick={openCreateModal}>
|
||||||
|
添加站点
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Table
|
||||||
|
dataSource={sites}
|
||||||
|
columns={columns}
|
||||||
|
rowKey="id"
|
||||||
|
loading={loading}
|
||||||
|
pagination={false}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={editingSite ? '编辑站点' : '添加站点'}
|
||||||
|
open={modalOpen}
|
||||||
|
onCancel={() => setModalOpen(false)}
|
||||||
|
footer={null}
|
||||||
|
destroyOnClose
|
||||||
|
>
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
layout="vertical"
|
||||||
|
onFinish={handleSave}
|
||||||
|
style={{ marginTop: 16 }}
|
||||||
|
>
|
||||||
|
<Form.Item
|
||||||
|
name="name"
|
||||||
|
label="站点名称"
|
||||||
|
rules={[{ required: true, message: '请输入站点名称' }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="例如:主站、测试站" />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="url"
|
||||||
|
label="API 地址"
|
||||||
|
rules={[
|
||||||
|
{ required: true, message: '请输入 API 地址' },
|
||||||
|
{ type: 'url', message: '请输入有效的 URL' },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Input placeholder="例如:https://api.example.com" />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item style={{ marginBottom: 0, textAlign: 'right' }}>
|
||||||
|
<Space>
|
||||||
|
<Button onClick={() => setModalOpen(false)}>取消</Button>
|
||||||
|
<Button type="primary" htmlType="submit" loading={saving}>
|
||||||
|
{editingSite ? '保存' : '创建'}
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</Space>
|
||||||
|
)
|
||||||
|
}
|
||||||
52
src/store/authStore.ts
Normal file
52
src/store/authStore.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
|
||||||
|
interface UserInfo {
|
||||||
|
id: number
|
||||||
|
username: string
|
||||||
|
display_name: string
|
||||||
|
role: number
|
||||||
|
status: number
|
||||||
|
email: string
|
||||||
|
group: string
|
||||||
|
quota: number
|
||||||
|
used_quota: number
|
||||||
|
request_count: number
|
||||||
|
aff_code: string
|
||||||
|
inviter_id: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SiteInfo {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthState {
|
||||||
|
sessionToken: string | null
|
||||||
|
userInfo: UserInfo | null
|
||||||
|
site: SiteInfo | null
|
||||||
|
isLoggedIn: boolean
|
||||||
|
loading: boolean
|
||||||
|
login: (sessionToken: string, userInfo: UserInfo, site: SiteInfo) => void
|
||||||
|
logout: () => void
|
||||||
|
updateUserInfo: (userInfo: UserInfo) => void
|
||||||
|
setLoading: (loading: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAuthStore = create<AuthState>((set) => ({
|
||||||
|
sessionToken: localStorage.getItem('sessionToken'),
|
||||||
|
userInfo: null,
|
||||||
|
site: null,
|
||||||
|
isLoggedIn: !!localStorage.getItem('sessionToken'),
|
||||||
|
loading: true,
|
||||||
|
login: (sessionToken, userInfo, site) => {
|
||||||
|
localStorage.setItem('sessionToken', sessionToken)
|
||||||
|
set({ sessionToken, userInfo, site, isLoggedIn: true, loading: false })
|
||||||
|
},
|
||||||
|
logout: () => {
|
||||||
|
localStorage.removeItem('sessionToken')
|
||||||
|
set({ sessionToken: null, userInfo: null, site: null, isLoggedIn: false, loading: false })
|
||||||
|
},
|
||||||
|
updateUserInfo: (userInfo) => set({ userInfo }),
|
||||||
|
setLoading: (loading) => set({ loading }),
|
||||||
|
}))
|
||||||
40
src/utils/quota.ts
Normal file
40
src/utils/quota.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
const QUOTA_PER_UNIT = 500000
|
||||||
|
|
||||||
|
export function quotaToUsd(quota: number): string {
|
||||||
|
return (quota / QUOTA_PER_UNIT).toFixed(4)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function quotaToCny(quota: number, rate: number = 7.2): string {
|
||||||
|
return ((quota / QUOTA_PER_UNIT) * rate).toFixed(4)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatQuota(quota: number): string {
|
||||||
|
if (quota >= 1000000) return `${(quota / 1000000).toFixed(1)}M`
|
||||||
|
if (quota >= 1000) return `${(quota / 1000).toFixed(1)}K`
|
||||||
|
return String(quota)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatTimestamp(ts: number): string {
|
||||||
|
if (!ts) return '-'
|
||||||
|
return new Date(ts * 1000).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRoleName(role: number): string {
|
||||||
|
switch (role) {
|
||||||
|
case 100: return '超级管理员'
|
||||||
|
case 10: return '管理员'
|
||||||
|
case 1: return '普通用户'
|
||||||
|
case 0: return '游客'
|
||||||
|
default: return '未知'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTokenStatusTag(status: number): { color: string; text: string } {
|
||||||
|
switch (status) {
|
||||||
|
case 1: return { color: 'green', text: '已启用' }
|
||||||
|
case 2: return { color: 'red', text: '已禁用' }
|
||||||
|
case 3: return { color: 'orange', text: '已过期' }
|
||||||
|
case 4: return { color: 'volcano', text: '已耗尽' }
|
||||||
|
default: return { color: 'default', text: '未知' }
|
||||||
|
}
|
||||||
|
}
|
||||||
20
src/utils/theme.ts
Normal file
20
src/utils/theme.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import type { ThemeConfig } from 'antd'
|
||||||
|
|
||||||
|
export const lightTheme: ThemeConfig = {
|
||||||
|
token: {
|
||||||
|
colorPrimary: '#1677ff',
|
||||||
|
borderRadius: 8,
|
||||||
|
colorBgContainer: '#ffffff',
|
||||||
|
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
Layout: {
|
||||||
|
siderBg: '#001529',
|
||||||
|
headerBg: '#ffffff',
|
||||||
|
},
|
||||||
|
Menu: {
|
||||||
|
darkItemBg: '#001529',
|
||||||
|
darkSubMenuItemBg: '#000c17',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
1
src/vite-env.d.ts
vendored
Normal file
1
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
23
tsconfig.json
Normal file
23
tsconfig.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": { "@/*": ["src/*"] }
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
13
tsconfig.node.json
Normal file
13
tsconfig.node.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
17
vite.config.ts
Normal file
17
vite.config.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
resolve: {
|
||||||
|
alias: { '@': path.resolve(__dirname, 'src') }
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 3000,
|
||||||
|
proxy: {
|
||||||
|
'/api': 'http://localhost:3001',
|
||||||
|
'/proxy': 'http://localhost:3001'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user