feat: 完善全栈 Dashboard 项目 - UI优化、Docker支持、账单系统等
This commit is contained in:
11
.dockerignore
Normal file
11
.dockerignore
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
data
|
||||||
|
*.db
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
*.md
|
||||||
|
.vscode
|
||||||
|
.playwright-mcp
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -5,3 +5,4 @@ data/
|
|||||||
*.db-wal
|
*.db-wal
|
||||||
*.db-shm
|
*.db-shm
|
||||||
.env
|
.env
|
||||||
|
server/fonts/
|
||||||
|
|||||||
6
.opencode/todo.md
Normal file
6
.opencode/todo.md
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
# Mission Tasks
|
||||||
|
|
||||||
|
## Task List
|
||||||
|
|
||||||
|
[ ] *Start your mission by creating a task list
|
||||||
|
|
||||||
50
Dockerfile
Normal file
50
Dockerfile
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
# ===== Stage 1: Build =====
|
||||||
|
FROM node:20-bookworm AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install dependencies (need all deps for tsc + vite build)
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
# Copy source and build frontend
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# ===== Stage 2: Production =====
|
||||||
|
FROM node:20-bookworm-slim
|
||||||
|
|
||||||
|
# Install: CJK fonts (PDF), build tools (better-sqlite3 native addon)
|
||||||
|
RUN apt-get update && \
|
||||||
|
apt-get install -y --no-install-recommends \
|
||||||
|
fonts-noto-cjk \
|
||||||
|
python3 \
|
||||||
|
make \
|
||||||
|
g++ \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install production deps + tsx (needed to run TypeScript server at runtime)
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci --omit=dev && npm install tsx
|
||||||
|
|
||||||
|
# Copy built frontend
|
||||||
|
COPY --from=builder /app/dist ./dist
|
||||||
|
|
||||||
|
# Copy server source (runs via tsx, no compile step)
|
||||||
|
COPY server ./server
|
||||||
|
|
||||||
|
# Data directory for SQLite (mount as volume for persistence)
|
||||||
|
RUN mkdir -p /app/data
|
||||||
|
VOLUME /app/data
|
||||||
|
|
||||||
|
# Default port
|
||||||
|
ENV PORT=3001
|
||||||
|
EXPOSE 3001
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||||
|
CMD node -e "fetch('http://localhost:3001/api/health').then(r=>r.ok?process.exit(0):process.exit(1)).catch(()=>process.exit(1))"
|
||||||
|
|
||||||
|
CMD ["npx", "tsx", "server/index.ts"]
|
||||||
14
docker-compose.yml
Normal file
14
docker-compose.yml
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
services:
|
||||||
|
dashboard:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "3001:3001"
|
||||||
|
volumes:
|
||||||
|
- dashboard-data:/app/data
|
||||||
|
environment:
|
||||||
|
- PORT=3001
|
||||||
|
- HMAC_SECRET=${HMAC_SECRET:-newapi-dashboard-default-secret-key-change-in-production}
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
dashboard-data:
|
||||||
@@ -2,8 +2,9 @@
|
|||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>NewAPI Dashboard</title>
|
<title>小林子的服务平台</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
10
public/favicon.svg
Normal file
10
public/favicon.svg
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#C8956C"/>
|
||||||
|
<stop offset="100%" stop-color="#E8B88A"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="32" height="32" rx="8" fill="url(#g)"/>
|
||||||
|
<text x="16" y="23" text-anchor="middle" font-family="system-ui,sans-serif" font-size="20" font-weight="700" fill="#fff">林</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 450 B |
@@ -28,10 +28,18 @@ db.exec(`
|
|||||||
site_id INTEGER NOT NULL,
|
site_id INTEGER NOT NULL,
|
||||||
site_url TEXT NOT NULL,
|
site_url TEXT NOT NULL,
|
||||||
user_info TEXT DEFAULT '{}',
|
user_info TEXT DEFAULT '{}',
|
||||||
|
is_admin INTEGER DEFAULT 0,
|
||||||
created_at TEXT DEFAULT (datetime('now')),
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
expires_at TEXT NOT NULL,
|
expires_at TEXT NOT NULL,
|
||||||
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE
|
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
`)
|
`)
|
||||||
|
|
||||||
|
// Migration: add is_admin column if missing
|
||||||
|
try {
|
||||||
|
db.exec(`ALTER TABLE sessions ADD COLUMN is_admin INTEGER DEFAULT 0`)
|
||||||
|
} catch {
|
||||||
|
// column already exists
|
||||||
|
}
|
||||||
|
|
||||||
export default db
|
export default db
|
||||||
|
|||||||
@@ -1,16 +1,22 @@
|
|||||||
import express from 'express'
|
import express from 'express'
|
||||||
import cors from 'cors'
|
import cors from 'cors'
|
||||||
|
import path from 'path'
|
||||||
|
import { fileURLToPath } from 'url'
|
||||||
|
import fs from 'fs'
|
||||||
import sitesRouter from './routes/sites.js'
|
import sitesRouter from './routes/sites.js'
|
||||||
import authRouter from './routes/auth.js'
|
import authRouter from './routes/auth.js'
|
||||||
import proxyRouter from './routes/proxy.js'
|
import proxyRouter from './routes/proxy.js'
|
||||||
import billingRouter from './routes/billing.js'
|
import billingRouter from './routes/billing.js'
|
||||||
|
import { ensureChineseFont } from './utils/font.js'
|
||||||
|
|
||||||
const app = express()
|
const app = express()
|
||||||
const PORT = 3001
|
const PORT = Number(process.env.PORT) || 3001
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
|
||||||
app.use(cors())
|
app.use(cors())
|
||||||
app.use(express.json())
|
app.use(express.json())
|
||||||
|
|
||||||
|
// API routes
|
||||||
app.get('/api/health', (_req, res) => {
|
app.get('/api/health', (_req, res) => {
|
||||||
res.json({ success: true, message: 'NewAPI Dashboard BFF running' })
|
res.json({ success: true, message: 'NewAPI Dashboard BFF running' })
|
||||||
})
|
})
|
||||||
@@ -20,8 +26,26 @@ app.use('/api/auth', authRouter)
|
|||||||
app.use('/proxy', proxyRouter)
|
app.use('/proxy', proxyRouter)
|
||||||
app.use('/api/billing', billingRouter)
|
app.use('/api/billing', billingRouter)
|
||||||
|
|
||||||
app.listen(PORT, () => {
|
// Production: serve frontend static files from dist/
|
||||||
console.log(`BFF server running on http://localhost:${PORT}`)
|
const distPath = path.join(__dirname, '..', 'dist')
|
||||||
})
|
if (fs.existsSync(path.join(distPath, 'index.html'))) {
|
||||||
|
app.use(express.static(distPath))
|
||||||
|
// SPA fallback: any non-API GET request returns index.html
|
||||||
|
app.get('*', (_req, res) => {
|
||||||
|
res.sendFile(path.join(distPath, 'index.html'))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export default app
|
// Download Chinese font then start server
|
||||||
|
ensureChineseFont()
|
||||||
|
.then(() => {
|
||||||
|
app.listen(PORT, '0.0.0.0', () => {
|
||||||
|
console.log(`BFF server running on http://0.0.0.0:${PORT}`)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error('Font initialization failed:', err.message)
|
||||||
|
app.listen(PORT, '0.0.0.0', () => {
|
||||||
|
console.log(`BFF server running on http://0.0.0.0:${PORT} (font unavailable)`)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -41,9 +41,8 @@ export function sessionAuth(req: Request, res: Response, next: NextFunction) {
|
|||||||
export function adminAuth(req: Request, res: Response, next: NextFunction) {
|
export function adminAuth(req: Request, res: Response, next: NextFunction) {
|
||||||
sessionAuth(req, res, () => {
|
sessionAuth(req, res, () => {
|
||||||
if (!req.session) return
|
if (!req.session) return
|
||||||
const userInfo = JSON.parse(req.session.user_info || '{}')
|
if (!(req.session as any).is_admin) {
|
||||||
if (userInfo.role < 10) {
|
res.status(403).json({ success: false, message: '需要 Dashboard 管理员权限,请先升格' })
|
||||||
res.status(403).json({ success: false, message: '需要管理员权限' })
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
next()
|
next()
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { sessionAuth } from '../middleware/auth.js'
|
|||||||
|
|
||||||
const router = Router()
|
const router = Router()
|
||||||
|
|
||||||
|
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'newapi-admin'
|
||||||
|
|
||||||
// POST /api/auth/login
|
// POST /api/auth/login
|
||||||
router.post('/login', async (req: Request, res: Response) => {
|
router.post('/login', async (req: Request, res: Response) => {
|
||||||
const { userId, accessToken, siteId } = req.body
|
const { userId, accessToken, siteId } = req.body
|
||||||
@@ -22,7 +24,10 @@ router.post('/login', async (req: Request, res: Response) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${site.url}/api/user/self`, {
|
const response = await fetch(`${site.url}/api/user/self`, {
|
||||||
headers: { 'Authorization': accessToken }
|
headers: {
|
||||||
|
'Authorization': accessToken,
|
||||||
|
'New-Api-User': String(userId),
|
||||||
|
}
|
||||||
})
|
})
|
||||||
const result = await response.json() as any
|
const result = await response.json() as any
|
||||||
|
|
||||||
@@ -70,9 +75,27 @@ router.get('/me', sessionAuth, (req: Request, res: Response) => {
|
|||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
userInfo: JSON.parse(req.session!.user_info),
|
userInfo: JSON.parse(req.session!.user_info),
|
||||||
site
|
site,
|
||||||
|
isAdmin: !!(req.session as any).is_admin
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// POST /api/auth/elevate — promote to dashboard admin
|
||||||
|
router.post('/elevate', sessionAuth, (req: Request, res: Response) => {
|
||||||
|
const { password } = req.body
|
||||||
|
if (password !== ADMIN_PASSWORD) {
|
||||||
|
res.json({ success: false, message: '管理密码错误' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
db.prepare('UPDATE sessions SET is_admin = 1 WHERE id = ?').run(req.session!.id)
|
||||||
|
res.json({ success: true, message: '已升格为管理员' })
|
||||||
|
})
|
||||||
|
|
||||||
|
// POST /api/auth/demote — revoke dashboard admin
|
||||||
|
router.post('/demote', sessionAuth, (req: Request, res: Response) => {
|
||||||
|
db.prepare('UPDATE sessions SET is_admin = 0 WHERE id = ?').run(req.session!.id)
|
||||||
|
res.json({ success: true, message: '已取消管理员权限' })
|
||||||
|
})
|
||||||
|
|
||||||
export default router
|
export default router
|
||||||
|
|||||||
@@ -5,37 +5,81 @@ import db from '../db.js'
|
|||||||
|
|
||||||
const router = Router()
|
const router = Router()
|
||||||
|
|
||||||
|
// Helper: fetch with rate-limit retry
|
||||||
|
async function fetchWithRetry(url: string, headers: Record<string, string>, maxRetries = 3): Promise<any> {
|
||||||
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||||
|
const res = await fetch(url, { headers })
|
||||||
|
if (res.status === 429) {
|
||||||
|
if (attempt < maxRetries) {
|
||||||
|
const retryAfter = Number(res.headers.get('retry-after')) || (2 ** attempt)
|
||||||
|
await new Promise(r => setTimeout(r, retryAfter * 1000))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return { success: false, message: 'Rate limited' }
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
return { success: false, message: `HTTP ${res.status}` }
|
||||||
|
}
|
||||||
|
return await res.json()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: paginate logs with rate-limit awareness
|
||||||
|
async function paginateLogs(
|
||||||
|
baseUrl: string,
|
||||||
|
headers: Record<string, string>,
|
||||||
|
params: URLSearchParams,
|
||||||
|
maxItems = 50000,
|
||||||
|
delayMs = 200,
|
||||||
|
): Promise<any[]> {
|
||||||
|
const allLogs: any[] = []
|
||||||
|
let page = 1
|
||||||
|
let hasMore = true
|
||||||
|
|
||||||
|
while (hasMore) {
|
||||||
|
params.set('p', String(page))
|
||||||
|
params.set('page_size', '100')
|
||||||
|
const data = await fetchWithRetry(`${baseUrl}?${params.toString()}`, headers)
|
||||||
|
if (!data.success || !data.data?.items?.length) {
|
||||||
|
hasMore = false
|
||||||
|
} else {
|
||||||
|
allLogs.push(...data.data.items)
|
||||||
|
hasMore = data.data.items.length === 100
|
||||||
|
page++
|
||||||
|
}
|
||||||
|
if (allLogs.length >= maxItems) hasMore = false
|
||||||
|
if (hasMore && delayMs > 0) await new Promise(r => setTimeout(r, delayMs))
|
||||||
|
}
|
||||||
|
return allLogs
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: fetch fresh user info from new-api
|
||||||
|
async function fetchUserInfo(siteUrl: string, accessToken: string, userId: string) {
|
||||||
|
const res = await fetch(`${siteUrl}/api/user/self`, {
|
||||||
|
headers: { 'Authorization': accessToken, 'New-Api-User': userId }
|
||||||
|
})
|
||||||
|
const data = await res.json() as any
|
||||||
|
if (!data.success) throw new Error('获取用户信息失败')
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|
||||||
// POST /api/billing/export/pdf
|
// POST /api/billing/export/pdf
|
||||||
router.post('/export/pdf', sessionAuth, async (req: Request, res: Response) => {
|
router.post('/export/pdf', sessionAuth, async (req: Request, res: Response) => {
|
||||||
const session = req.session!
|
const session = req.session!
|
||||||
const { startDate, endDate } = req.body
|
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 {
|
try {
|
||||||
|
// 实时从 new-api 获取最新用户信息
|
||||||
|
const userInfo = await fetchUserInfo(session.site_url, session.access_token, String(session.user_id))
|
||||||
|
const site = db.prepare('SELECT name, url FROM sites WHERE id = ?').get(session.site_id) as any
|
||||||
const startTs = Math.floor(new Date(startDate).getTime() / 1000)
|
const startTs = Math.floor(new Date(startDate).getTime() / 1000)
|
||||||
const endTs = Math.floor(new Date(endDate).getTime() / 1000)
|
const endTs = Math.floor(new Date(endDate).getTime() / 1000)
|
||||||
|
|
||||||
let allLogs: any[] = []
|
const apiHeaders = { 'Authorization': session.access_token, 'New-Api-User': String(session.user_id) }
|
||||||
let page = 1
|
const logParams = new URLSearchParams({
|
||||||
const pageSize = 100
|
start_timestamp: String(startTs), end_timestamp: String(endTs), type: '2',
|
||||||
let hasMore = true
|
})
|
||||||
|
const allLogs = await paginateLogs(`${session.site_url}/api/log/self`, apiHeaders, logParams)
|
||||||
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 }>()
|
const modelMap = new Map<string, { quota: number; count: number }>()
|
||||||
let totalQuota = 0
|
let totalQuota = 0
|
||||||
@@ -52,6 +96,10 @@ router.post('/export/pdf', sessionAuth, async (req: Request, res: Response) => {
|
|||||||
.map(([model, data]) => ({ model, ...data }))
|
.map(([model, data]) => ({ model, ...data }))
|
||||||
.sort((a, b) => b.quota - a.quota)
|
.sort((a, b) => b.quota - a.quota)
|
||||||
|
|
||||||
|
// Fetch topup records
|
||||||
|
const topupParams = new URLSearchParams()
|
||||||
|
const allTopups = await paginateLogs(`${session.site_url}/api/user/topup/self`, apiHeaders, topupParams, 10000)
|
||||||
|
|
||||||
const pdfBuffer = await generateBillingPDF({
|
const pdfBuffer = await generateBillingPDF({
|
||||||
siteName: site.name,
|
siteName: site.name,
|
||||||
siteUrl: site.url,
|
siteUrl: site.url,
|
||||||
@@ -63,11 +111,13 @@ router.post('/export/pdf', sessionAuth, async (req: Request, res: Response) => {
|
|||||||
totalQuota,
|
totalQuota,
|
||||||
totalRequests: allLogs.length,
|
totalRequests: allLogs.length,
|
||||||
modelSummary,
|
modelSummary,
|
||||||
|
topups: allTopups,
|
||||||
logs: allLogs
|
logs: allLogs
|
||||||
})
|
})
|
||||||
|
|
||||||
res.setHeader('Content-Type', 'application/pdf')
|
res.setHeader('Content-Type', 'application/pdf')
|
||||||
res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(site.name)}_billing_${startDate}_${endDate}.pdf"`)
|
const ts = new Date().toISOString().replace(/[-:T]/g, '').slice(0, 14)
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(site.name)}_billing_${startDate}_${endDate}_${ts}.pdf"`)
|
||||||
res.send(pdfBuffer)
|
res.send(pdfBuffer)
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
res.status(500).json({ success: false, message: `Failed to generate report: ${error.message}` })
|
res.status(500).json({ success: false, message: `Failed to generate report: ${error.message}` })
|
||||||
@@ -84,49 +134,141 @@ router.post('/export/csv', sessionAuth, async (req: Request, res: Response) => {
|
|||||||
const startTs = Math.floor(new Date(startDate).getTime() / 1000)
|
const startTs = Math.floor(new Date(startDate).getTime() / 1000)
|
||||||
const endTs = Math.floor(new Date(endDate).getTime() / 1000)
|
const endTs = Math.floor(new Date(endDate).getTime() / 1000)
|
||||||
|
|
||||||
let allLogs: any[] = []
|
const apiHeaders = { 'Authorization': session.access_token, 'New-Api-User': String(session.user_id) }
|
||||||
let page = 1
|
const logParams = new URLSearchParams({
|
||||||
let hasMore = true
|
start_timestamp: String(startTs), end_timestamp: String(endTs), type: '2',
|
||||||
|
})
|
||||||
while (hasMore) {
|
const allLogs = await paginateLogs(`${session.site_url}/api/log/self`, apiHeaders, logParams)
|
||||||
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 BOM = '\uFEFF'
|
||||||
const headers = ['Time', 'Model', 'Token', 'Quota', 'Cost(USD)', 'Prompt Tokens', 'Completion Tokens', 'Request ID']
|
const headers = ['时间', '令牌', '分组', '类型', '模型', '流式', '用时(s)', '首字(ms)', '输入', '缓存命中', '缓存创建', '输出', '花费(USD)', '额度', '请求ID']
|
||||||
const rows = allLogs.map(log => [
|
const rows = allLogs.map(log => {
|
||||||
|
let other: any = {}
|
||||||
|
try { other = typeof log.other === 'string' ? JSON.parse(log.other) : (log.other || {}) } catch {}
|
||||||
|
const typeMap: Record<number, string> = { 1: '充值', 2: '消费', 3: '管理', 4: '系统' }
|
||||||
|
return [
|
||||||
new Date((log.created_at || 0) * 1000).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' }),
|
new Date((log.created_at || 0) * 1000).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' }),
|
||||||
log.model_name || '',
|
|
||||||
log.token_name || '',
|
log.token_name || '',
|
||||||
log.quota || 0,
|
log.group || '',
|
||||||
((log.quota || 0) / 500000).toFixed(6),
|
typeMap[log.type] || String(log.type || ''),
|
||||||
|
log.model_name || '',
|
||||||
|
log.is_stream ? '是' : '否',
|
||||||
|
log.use_time || 0,
|
||||||
|
other.frt ? Math.round(other.frt) : '',
|
||||||
log.prompt_tokens || 0,
|
log.prompt_tokens || 0,
|
||||||
|
other.cache_tokens || 0,
|
||||||
|
other.cache_creation_tokens || 0,
|
||||||
log.completion_tokens || 0,
|
log.completion_tokens || 0,
|
||||||
log.request_id || ''
|
((log.quota || 0) / 500000).toFixed(6),
|
||||||
])
|
log.quota || 0,
|
||||||
|
log.request_id || '',
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
const csv = BOM + [headers, ...rows].map(row => row.map(cell => `"${cell}"`).join(',')).join('\n')
|
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-Type', 'text/csv; charset=utf-8')
|
||||||
res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(site.name)}_billing_${startDate}_${endDate}.csv"`)
|
const ts = new Date().toISOString().replace(/[-:T]/g, '').slice(0, 14)
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(site.name)}_billing_${startDate}_${endDate}_${ts}.csv"`)
|
||||||
res.send(csv)
|
res.send(csv)
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
res.status(500).json({ success: false, message: `Failed to generate CSV: ${error.message}` })
|
res.status(500).json({ success: false, message: `Failed to generate CSV: ${error.message}` })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// POST /api/billing/stats — aggregate stats for date range
|
||||||
|
router.post('/stats', sessionAuth, async (req: Request, res: Response) => {
|
||||||
|
const session = req.session!
|
||||||
|
const { startTimestamp, endTimestamp, type, modelName, tokenName, group, requestId } = req.body
|
||||||
|
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
if (startTimestamp) params.set('start_timestamp', String(startTimestamp))
|
||||||
|
if (endTimestamp) params.set('end_timestamp', String(endTimestamp))
|
||||||
|
if (type !== undefined && type !== '') params.set('type', String(type))
|
||||||
|
if (modelName) params.set('model_name', modelName)
|
||||||
|
if (tokenName) params.set('token_name', tokenName)
|
||||||
|
if (group) params.set('group', group)
|
||||||
|
if (requestId) params.set('request_id', requestId)
|
||||||
|
|
||||||
|
const headers = { 'Authorization': session.access_token, 'New-Api-User': String(session.user_id) }
|
||||||
|
const allLogs = await paginateLogs(`${session.site_url}/api/log/self`, headers, params)
|
||||||
|
|
||||||
|
let totalQuota = 0
|
||||||
|
let totalPromptTokens = 0
|
||||||
|
let totalCompletionTokens = 0
|
||||||
|
const modelSet = new Set<string>()
|
||||||
|
for (const log of allLogs) {
|
||||||
|
totalQuota += log.quota || 0
|
||||||
|
totalPromptTokens += log.prompt_tokens || 0
|
||||||
|
totalCompletionTokens += log.completion_tokens || 0
|
||||||
|
if (log.model_name) modelSet.add(log.model_name)
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
totalRecords: allLogs.length,
|
||||||
|
totalQuota,
|
||||||
|
totalPromptTokens,
|
||||||
|
totalCompletionTokens,
|
||||||
|
totalTokens: totalPromptTokens + totalCompletionTokens,
|
||||||
|
modelCount: modelSet.size,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (error: any) {
|
||||||
|
res.status(500).json({ success: false, message: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// POST /api/billing/chart-data — aggregated chart data for dashboard
|
||||||
|
router.post('/chart-data', sessionAuth, async (req: Request, res: Response) => {
|
||||||
|
const session = req.session!
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sevenDaysAgo = Math.floor((Date.now() - 7 * 24 * 60 * 60 * 1000) / 1000)
|
||||||
|
const params = new URLSearchParams({ start_timestamp: String(sevenDaysAgo), type: '2' })
|
||||||
|
const headers = { 'Authorization': session.access_token, 'New-Api-User': String(session.user_id) }
|
||||||
|
const allLogs = await paginateLogs(`${session.site_url}/api/log/self`, headers, params)
|
||||||
|
|
||||||
|
// Daily aggregation (last 7 days)
|
||||||
|
const dayMap = new Map<string, { count: number; quota: number }>()
|
||||||
|
const now = new Date()
|
||||||
|
for (let i = 6; i >= 0; i--) {
|
||||||
|
const d = new Date(now)
|
||||||
|
d.setDate(d.getDate() - i)
|
||||||
|
const key = d.toISOString().split('T')[0]
|
||||||
|
dayMap.set(key, { count: 0, quota: 0 })
|
||||||
|
}
|
||||||
|
for (const log of allLogs) {
|
||||||
|
const date = new Date((log.created_at || 0) * 1000).toISOString().split('T')[0]
|
||||||
|
if (dayMap.has(date)) {
|
||||||
|
const day = dayMap.get(date)!
|
||||||
|
day.count += 1
|
||||||
|
day.quota += log.quota || 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const daily = Array.from(dayMap.entries()).map(([date, val]) => ({
|
||||||
|
date, count: val.count, quota: val.quota,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Model distribution (top 10)
|
||||||
|
const modelMap = new Map<string, number>()
|
||||||
|
for (const log of allLogs) {
|
||||||
|
const model = log.model_name || 'unknown'
|
||||||
|
modelMap.set(model, (modelMap.get(model) || 0) + (log.quota || 0))
|
||||||
|
}
|
||||||
|
const models = Array.from(modelMap.entries())
|
||||||
|
.map(([name, value]) => ({ name, value }))
|
||||||
|
.sort((a, b) => b.value - a.value)
|
||||||
|
.slice(0, 10)
|
||||||
|
|
||||||
|
res.json({ success: true, data: { daily, models, totalLogs: allLogs.length } })
|
||||||
|
} catch (error: any) {
|
||||||
|
res.status(500).json({ success: false, message: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// POST /api/billing/verify — verify HMAC signature
|
// POST /api/billing/verify — verify HMAC signature
|
||||||
router.post('/verify', (req: Request, res: Response) => {
|
router.post('/verify', (req: Request, res: Response) => {
|
||||||
const { userId, startDate, endDate, totalQuota, totalRecords, generatedAt, signature } = req.body
|
const { userId, startDate, endDate, totalQuota, totalRecords, generatedAt, signature } = req.body
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ router.all('/*', sessionAuth, async (req: Request, res: Response) => {
|
|||||||
try {
|
try {
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
'Authorization': session.access_token,
|
'Authorization': session.access_token,
|
||||||
|
'New-Api-User': String(session.user_id),
|
||||||
'Content-Type': req.headers['content-type'] || 'application/json'
|
'Content-Type': req.headers['content-type'] || 'application/json'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
66
server/utils/font.ts
Normal file
66
server/utils/font.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import fs from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
import { fileURLToPath } from 'url'
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
const FONTS_DIR = path.join(__dirname, '..', 'fonts')
|
||||||
|
const FONT_FILE = path.join(FONTS_DIR, 'NotoSansSC-Regular.otf')
|
||||||
|
|
||||||
|
const CDN_URLS = [
|
||||||
|
'https://cdn.jsdelivr.net/gh/googlefonts/noto-cjk@main/Sans/SubsetOTF/SC/NotoSansSC-Regular.otf',
|
||||||
|
'https://raw.githubusercontent.com/googlefonts/noto-cjk/main/Sans/SubsetOTF/SC/NotoSansSC-Regular.otf',
|
||||||
|
]
|
||||||
|
|
||||||
|
let fontReady: string | null = null
|
||||||
|
|
||||||
|
export async function ensureChineseFont(): Promise<string> {
|
||||||
|
if (fontReady) return fontReady
|
||||||
|
|
||||||
|
// Check if already downloaded
|
||||||
|
if (fs.existsSync(FONT_FILE) && fs.statSync(FONT_FILE).size > 100000) {
|
||||||
|
fontReady = FONT_FILE
|
||||||
|
return FONT_FILE
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure directory exists
|
||||||
|
if (!fs.existsSync(FONTS_DIR)) fs.mkdirSync(FONTS_DIR, { recursive: true })
|
||||||
|
|
||||||
|
// Try each CDN URL
|
||||||
|
for (const url of CDN_URLS) {
|
||||||
|
try {
|
||||||
|
console.log(`Downloading Chinese font from ${url.split('/')[2]}...`)
|
||||||
|
const res = await fetch(url, { signal: AbortSignal.timeout(60000) })
|
||||||
|
if (!res.ok) continue
|
||||||
|
const buf = Buffer.from(await res.arrayBuffer())
|
||||||
|
if (buf.length < 100000) continue // too small, probably error page
|
||||||
|
fs.writeFileSync(FONT_FILE, buf)
|
||||||
|
console.log(`Chinese font downloaded (${(buf.length / 1024 / 1024).toFixed(1)} MB)`)
|
||||||
|
fontReady = FONT_FILE
|
||||||
|
return FONT_FILE
|
||||||
|
} catch (e: any) {
|
||||||
|
console.warn(`Font download failed from ${url.split('/')[2]}: ${e.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: try local system fonts
|
||||||
|
const fallbacks = [
|
||||||
|
'C:\\Windows\\Fonts\\simhei.ttf',
|
||||||
|
'C:\\Windows\\Fonts\\msyh.ttf',
|
||||||
|
'/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc',
|
||||||
|
'/System/Library/Fonts/PingFang.ttc',
|
||||||
|
]
|
||||||
|
for (const f of fallbacks) {
|
||||||
|
if (fs.existsSync(f)) {
|
||||||
|
console.log(`Using fallback system font: ${f}`)
|
||||||
|
fontReady = f
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('No Chinese font available. Set PDF_FONT_PATH env variable to a TTF/OTF font path.')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getChineseFont(): string {
|
||||||
|
if (!fontReady) throw new Error('Font not initialized. Call ensureChineseFont() first.')
|
||||||
|
return fontReady
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import PDFDocument from 'pdfkit'
|
import PDFDocument from 'pdfkit'
|
||||||
import crypto from 'crypto'
|
import crypto from 'crypto'
|
||||||
|
import { getChineseFont } from './font.js'
|
||||||
|
|
||||||
const HMAC_SECRET = process.env.HMAC_SECRET || 'newapi-dashboard-default-secret-key-change-in-production'
|
const HMAC_SECRET = process.env.HMAC_SECRET || 'newapi-dashboard-default-secret-key-change-in-production'
|
||||||
|
const DASHBOARD_URL = process.env.DASHBOARD_URL || 'https://www.lamclod.cn'
|
||||||
|
|
||||||
interface BillingReportData {
|
interface BillingReportData {
|
||||||
siteName: string
|
siteName: string
|
||||||
@@ -14,160 +16,433 @@ interface BillingReportData {
|
|||||||
totalQuota: number
|
totalQuota: number
|
||||||
totalRequests: number
|
totalRequests: number
|
||||||
modelSummary: { model: string; quota: number; count: number }[]
|
modelSummary: { model: string; quota: number; count: number }[]
|
||||||
|
topups: {
|
||||||
|
create_time: number
|
||||||
|
amount: number
|
||||||
|
money: number
|
||||||
|
payment_method: string
|
||||||
|
status: string
|
||||||
|
trade_no: string
|
||||||
|
}[]
|
||||||
logs: {
|
logs: {
|
||||||
created_at: number
|
created_at: number
|
||||||
|
type: number
|
||||||
model_name: string
|
model_name: string
|
||||||
token_name: string
|
token_name: string
|
||||||
|
group: string
|
||||||
|
is_stream: boolean
|
||||||
quota: number
|
quota: number
|
||||||
prompt_tokens: number
|
prompt_tokens: number
|
||||||
completion_tokens: number
|
completion_tokens: number
|
||||||
|
use_time: number
|
||||||
|
ip: string
|
||||||
request_id: string
|
request_id: string
|
||||||
|
other: string
|
||||||
}[]
|
}[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function generateHmacSignature(data: {
|
export function generateHmacSignature(data: {
|
||||||
userId: number
|
userId: number; startDate: string; endDate: string;
|
||||||
startDate: string
|
totalQuota: number; totalRecords: number; generatedAt: string;
|
||||||
endDate: string
|
|
||||||
totalQuota: number
|
|
||||||
totalRecords: number
|
|
||||||
generatedAt: string
|
|
||||||
}): string {
|
}): string {
|
||||||
const signStr = `${data.userId}|${data.startDate}|${data.endDate}|${data.totalQuota}|${data.totalRecords}|${data.generatedAt}`
|
const signStr = `${data.userId}|${data.startDate}|${data.endDate}|${data.totalQuota}|${data.totalRecords}|${data.generatedAt}`
|
||||||
return crypto.createHmac('sha256', HMAC_SECRET).update(signStr).digest('hex')
|
return crypto.createHmac('sha256', HMAC_SECRET).update(signStr).digest('hex')
|
||||||
}
|
}
|
||||||
|
|
||||||
export function verifyHmacSignature(data: {
|
export function verifyHmacSignature(data: {
|
||||||
userId: number
|
userId: number; startDate: string; endDate: string;
|
||||||
startDate: string
|
totalQuota: number; totalRecords: number; generatedAt: string; signature: string;
|
||||||
endDate: string
|
|
||||||
totalQuota: number
|
|
||||||
totalRecords: number
|
|
||||||
generatedAt: string
|
|
||||||
signature: string
|
|
||||||
}): boolean {
|
}): boolean {
|
||||||
const expected = generateHmacSignature(data)
|
const expected = generateHmacSignature(data)
|
||||||
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(data.signature))
|
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(data.signature))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fmtTime(ts: number): string {
|
||||||
|
return new Date(ts * 1000).toLocaleString('zh-CN', {
|
||||||
|
timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit',
|
||||||
|
hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function q2usd(q: number, d = 2): string { return (q / 500000).toFixed(d) }
|
||||||
|
|
||||||
|
function parseOther(other: string | any): any {
|
||||||
|
if (!other) return {}
|
||||||
|
if (typeof other === 'object') return other
|
||||||
|
try { return JSON.parse(other) } catch { return {} }
|
||||||
|
}
|
||||||
|
|
||||||
|
const BLUE = '#1a4b8c'
|
||||||
|
const BLUE_LIGHT = '#e9eff8'
|
||||||
|
const BLUE_MID = '#3a7bd5'
|
||||||
|
const GRAY = '#666666'
|
||||||
|
const GRAY_LIGHT = '#f6f7f9'
|
||||||
|
const BLACK = '#1a1a1a'
|
||||||
|
const MARGIN = 50
|
||||||
|
const PAGE_W = 595.28 // A4
|
||||||
|
const CONTENT_W = PAGE_W - MARGIN * 2
|
||||||
|
|
||||||
|
// Draw watermark on existing page via switchToPage (final pass only)
|
||||||
|
function drawWatermarkOnPage(doc: PDFKit.PDFDocument, text: string) {
|
||||||
|
doc.save()
|
||||||
|
doc.opacity(0.03)
|
||||||
|
doc.font(getChineseFont()).fontSize(9).fillColor('#999999')
|
||||||
|
const label = `${text} ${text} ${text}`
|
||||||
|
for (let row = 0; row < 6; row++) {
|
||||||
|
doc.text(label, MARGIN, 120 + row * 100, { width: CONTENT_W, align: 'center', lineBreak: false })
|
||||||
|
}
|
||||||
|
doc.restore()
|
||||||
|
}
|
||||||
|
|
||||||
|
function hline(doc: PDFKit.PDFDocument, y: number, color = '#dddddd') {
|
||||||
|
doc.save().moveTo(MARGIN, y).lineTo(MARGIN + CONTENT_W, y).strokeColor(color).lineWidth(0.5).stroke().restore()
|
||||||
|
}
|
||||||
|
|
||||||
|
function rect(doc: PDFKit.PDFDocument, x: number, y: number, w: number, h: number, color: string) {
|
||||||
|
doc.save().rect(x, y, w, h).fill(color).restore()
|
||||||
|
}
|
||||||
|
|
||||||
|
function footer(doc: PDFKit.PDFDocument, site: string) {
|
||||||
|
hline(doc, 778, '#cccccc')
|
||||||
|
doc.font(getChineseFont()).fontSize(6.5).fillColor('#aaaaaa')
|
||||||
|
doc.text(`${site} - API 使用账单报告`, MARGIN, 782, { width: CONTENT_W * 0.6, lineBreak: false })
|
||||||
|
}
|
||||||
|
|
||||||
export function generateBillingPDF(data: BillingReportData): Promise<Buffer> {
|
export function generateBillingPDF(data: BillingReportData): Promise<Buffer> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const doc = new PDFDocument({ size: 'A4', margin: 50 })
|
const doc = new PDFDocument({ size: 'A4', margin: MARGIN, bufferPages: true })
|
||||||
const chunks: Buffer[] = []
|
const chunks: Buffer[] = []
|
||||||
|
doc.on('data', (c: Buffer) => chunks.push(c))
|
||||||
doc.on('data', (chunk: Buffer) => chunks.push(chunk))
|
|
||||||
doc.on('end', () => resolve(Buffer.concat(chunks)))
|
doc.on('end', () => resolve(Buffer.concat(chunks)))
|
||||||
doc.on('error', reject)
|
doc.on('error', reject)
|
||||||
|
|
||||||
const generatedAt = new Date().toISOString()
|
// Register Chinese font as default
|
||||||
const reportId = crypto.createHash('md5')
|
doc.font(getChineseFont())
|
||||||
.update(`${data.userId}-${generatedAt}`)
|
|
||||||
.digest('hex')
|
|
||||||
.substring(0, 12)
|
|
||||||
.toUpperCase()
|
|
||||||
|
|
||||||
// -- Watermark --
|
const now = new Date()
|
||||||
doc.save()
|
const generatedAt = now.toISOString()
|
||||||
doc.opacity(0.06)
|
const generatedLocal = now.toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false })
|
||||||
doc.fontSize(60)
|
const reportId = 'RPT-' + crypto.createHash('md5').update(`${data.userId}-${generatedAt}`).digest('hex').substring(0, 12).toUpperCase()
|
||||||
doc.rotate(-45, { origin: [300, 400] })
|
const totalUsd = q2usd(data.totalQuota)
|
||||||
doc.text(data.siteName, 100, 350, { width: 600 })
|
const days = Math.max(1, Math.ceil((new Date(data.endDate).getTime() - new Date(data.startDate).getTime()) / 86400000))
|
||||||
doc.restore()
|
|
||||||
|
|
||||||
// -- Header --
|
// ===== PAGE 1: COVER =====
|
||||||
doc.fontSize(20).text(data.siteName, { align: 'center' })
|
rect(doc, 0, 0, PAGE_W, 100, BLUE)
|
||||||
doc.fontSize(14).text('API Usage Billing Report', { align: 'center' })
|
doc.fontSize(22).fillColor('#ffffff').text(data.siteName, MARGIN, 28, { width: CONTENT_W, align: 'center' })
|
||||||
doc.moveDown(0.5)
|
doc.fontSize(11).fillColor('#c0d0e8').text('API 使用账单报告', MARGIN, 58, { width: CONTENT_W, align: 'center' })
|
||||||
doc.fontSize(9).fillColor('#666')
|
doc.fontSize(7.5).fillColor('#8fabc8').text(reportId, MARGIN, 78, { width: CONTENT_W, align: 'center' })
|
||||||
.text(`Report ID: RPT-${reportId}`, { align: 'center' })
|
|
||||||
doc.moveDown(1)
|
|
||||||
|
|
||||||
// -- Report Info --
|
// Report info section
|
||||||
doc.fillColor('#000').fontSize(10)
|
let y = 120
|
||||||
doc.text(`Period: ${data.startDate} ~ ${data.endDate}`)
|
doc.fontSize(12).fillColor(BLUE).text('报告信息', MARGIN, y)
|
||||||
doc.text(`User ID: ${data.userId} Username: ${data.username} Group: ${data.group}`)
|
hline(doc, y + 18, BLUE)
|
||||||
doc.text(`Site: ${data.siteUrl}`)
|
y += 28
|
||||||
doc.moveDown(1)
|
|
||||||
|
|
||||||
// -- Summary --
|
const info: [string, string][] = [
|
||||||
doc.fontSize(12).text('Summary', { underline: true })
|
['报告周期', `${data.startDate} ~ ${data.endDate} (${days} 天)`],
|
||||||
doc.moveDown(0.3)
|
['用户 ID', String(data.userId)],
|
||||||
doc.fontSize(10)
|
['用户名', data.username],
|
||||||
doc.text(`Total Quota Consumed: ${(data.totalQuota / 500000).toFixed(4)} USD (${data.totalQuota} quota units)`)
|
['用户组', data.group],
|
||||||
doc.text(`Total Requests: ${data.totalRequests}`)
|
['站点地址', data.siteUrl],
|
||||||
doc.moveDown(0.5)
|
['生成时间', generatedLocal],
|
||||||
|
['报告编号', reportId],
|
||||||
// Model summary
|
]
|
||||||
if (data.modelSummary.length > 0) {
|
for (const [k, v] of info) {
|
||||||
doc.fontSize(11).text('By Model:', { underline: true })
|
doc.fontSize(9).fillColor(GRAY).text(k, MARGIN + 10, y, { width: 100, lineBreak: false })
|
||||||
doc.moveDown(0.3)
|
doc.fontSize(9).fillColor(BLACK).text(v, MARGIN + 120, y, { width: CONTENT_W - 130, lineBreak: false })
|
||||||
doc.fontSize(9)
|
y += 18
|
||||||
for (const m of data.modelSummary) {
|
|
||||||
doc.text(` ${m.model}: ${(m.quota / 500000).toFixed(4)} USD, ${m.count} requests`)
|
|
||||||
}
|
|
||||||
doc.moveDown(1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- Detail Table --
|
// Summary boxes
|
||||||
doc.fontSize(12).text('Usage Details', { underline: true })
|
y += 15
|
||||||
doc.moveDown(0.3)
|
doc.fontSize(12).fillColor(BLUE).text('概览', MARGIN, y)
|
||||||
|
hline(doc, y + 18, BLUE)
|
||||||
|
y += 28
|
||||||
|
|
||||||
const tableTop = doc.y
|
const boxW = (CONTENT_W - 20) / 3
|
||||||
const col = [50, 150, 280, 370, 460]
|
const boxes = [
|
||||||
doc.fontSize(8).fillColor('#333')
|
{ title: '总消耗', val: `$ ${totalUsd}`, sub: `${data.totalQuota.toLocaleString()} 额度`, accent: BLUE },
|
||||||
doc.text('Time', col[0], tableTop)
|
{ title: '总请求数', val: data.totalRequests.toLocaleString(), sub: `${data.modelSummary.length} 个模型`, accent: '#0a7c50' },
|
||||||
doc.text('Model', col[1], tableTop)
|
{ title: '平均单价', val: `$ ${data.totalRequests > 0 ? q2usd(Math.round(data.totalQuota / data.totalRequests), 4) : '0'}`, sub: `共 ${days} 天`, accent: '#b45309' },
|
||||||
doc.text('Token', col[2], tableTop)
|
]
|
||||||
doc.text('Cost (USD)', col[3], tableTop)
|
for (let i = 0; i < 3; i++) {
|
||||||
doc.text('Request ID', col[4], tableTop)
|
const bx = MARGIN + i * (boxW + 10)
|
||||||
|
rect(doc, bx, y, boxW, 60, GRAY_LIGHT)
|
||||||
|
rect(doc, bx, y, 3, 60, boxes[i].accent)
|
||||||
|
doc.fontSize(7.5).fillColor(GRAY).text(boxes[i].title, bx + 12, y + 8, { width: boxW - 20, lineBreak: false })
|
||||||
|
doc.fontSize(15).fillColor(boxes[i].accent).text(boxes[i].val, bx + 12, y + 22, { width: boxW - 20, lineBreak: false })
|
||||||
|
doc.fontSize(7).fillColor('#aaaaaa').text(boxes[i].sub, bx + 12, y + 44, { width: boxW - 20, lineBreak: false })
|
||||||
|
}
|
||||||
|
|
||||||
doc.moveTo(50, tableTop + 12).lineTo(560, tableTop + 12).stroke('#ccc')
|
// Disclaimer
|
||||||
|
y += 80
|
||||||
|
hline(doc, y)
|
||||||
|
y += 8
|
||||||
|
doc.fontSize(7).fillColor('#aaaaaa')
|
||||||
|
doc.text('本报告由系统自动生成,包含 HMAC-SHA256 数字签名用于完整性验证。', MARGIN, y, { width: CONTENT_W })
|
||||||
|
doc.text(`验证接口: POST ${DASHBOARD_URL}/api/billing/verify`, MARGIN, y + 12, { width: CONTENT_W })
|
||||||
|
|
||||||
let y = tableTop + 16
|
footer(doc, data.siteName)
|
||||||
const maxRows = Math.min(data.logs.length, 200)
|
|
||||||
for (let i = 0; i < maxRows; i++) {
|
// ===== PAGE 2: FINANCIAL SUMMARY =====
|
||||||
if (y > 750) {
|
|
||||||
doc.addPage()
|
doc.addPage()
|
||||||
y = 50
|
|
||||||
|
rect(doc, MARGIN, MARGIN, CONTENT_W, 25, BLUE)
|
||||||
|
doc.fontSize(11).fillColor('#ffffff').text('财务汇总 - 按模型消耗统计', MARGIN + 12, MARGIN + 7)
|
||||||
|
|
||||||
|
y = MARGIN + 40
|
||||||
|
|
||||||
|
// Total line
|
||||||
|
rect(doc, MARGIN, y, CONTENT_W, 46, BLUE_LIGHT)
|
||||||
|
doc.fontSize(9).fillColor(BLUE).text('总消耗:', MARGIN + 10, y + 6)
|
||||||
|
doc.fontSize(14).fillColor(BLUE).text(`$ ${totalUsd} USD`, MARGIN + 10, y + 17, { continued: false })
|
||||||
|
doc.fontSize(7.5).fillColor(GRAY).text(
|
||||||
|
`${data.totalQuota.toLocaleString()} 额度 | ${data.totalRequests.toLocaleString()} 次请求`,
|
||||||
|
MARGIN + 10, y + 36, { width: CONTENT_W - 20, lineBreak: false }
|
||||||
|
)
|
||||||
|
y += 56
|
||||||
|
|
||||||
|
// Model table header
|
||||||
|
const mc = { n: MARGIN + 5, m: MARGIN + 35, req: MARGIN + 215, quota: MARGIN + 290, usd: MARGIN + 370, pct: MARGIN + 430 }
|
||||||
|
rect(doc, MARGIN, y, CONTENT_W, 16, BLUE)
|
||||||
|
doc.fontSize(7).fillColor('#ffffff')
|
||||||
|
doc.text('#', mc.n, y + 4, { width: 25, lineBreak: false })
|
||||||
|
doc.text('模型', mc.m, y + 4, { width: 175, lineBreak: false })
|
||||||
|
doc.text('请求数', mc.req, y + 4, { width: 70, lineBreak: false })
|
||||||
|
doc.text('额度', mc.quota, y + 4, { width: 75, lineBreak: false })
|
||||||
|
doc.text('费用 (USD)', mc.usd, y + 4, { width: 55, lineBreak: false })
|
||||||
|
doc.text('占比', mc.pct, y + 4, { width: 60, lineBreak: false })
|
||||||
|
y += 18
|
||||||
|
|
||||||
|
for (let i = 0; i < data.modelSummary.length; i++) {
|
||||||
|
if (y > 740) {
|
||||||
|
footer(doc, data.siteName)
|
||||||
|
doc.addPage()
|
||||||
|
y = MARGIN + 10
|
||||||
}
|
}
|
||||||
const log = data.logs[i]
|
const m = data.modelSummary[i]
|
||||||
const time = new Date(log.created_at * 1000).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })
|
const pct = data.totalQuota > 0 ? (m.quota / data.totalQuota * 100) : 0
|
||||||
doc.fontSize(7).fillColor('#000')
|
if (i % 2 === 0) rect(doc, MARGIN, y - 1, CONTENT_W, 14, GRAY_LIGHT)
|
||||||
doc.text(time, col[0], y, { width: 95 })
|
doc.fontSize(6.5).fillColor(BLACK)
|
||||||
doc.text(log.model_name || '-', col[1], y, { width: 125 })
|
doc.text(String(i + 1), mc.n, y + 2, { width: 25, lineBreak: false })
|
||||||
doc.text(log.token_name || '-', col[2], y, { width: 85 })
|
doc.text(m.model, mc.m, y + 2, { width: 175, lineBreak: false })
|
||||||
doc.text((log.quota / 500000).toFixed(6), col[3], y, { width: 85 })
|
doc.text(m.count.toLocaleString(), mc.req, y + 2, { width: 70, lineBreak: false })
|
||||||
doc.text(log.request_id?.substring(0, 12) || '-', col[4], y, { width: 100 })
|
doc.text(m.quota.toLocaleString(), mc.quota, y + 2, { width: 75, lineBreak: false })
|
||||||
|
doc.text(q2usd(m.quota), mc.usd, y + 2, { width: 55, lineBreak: false })
|
||||||
|
const barW = 40
|
||||||
|
rect(doc, mc.pct, y + 1, barW, 8, '#e0e0e0')
|
||||||
|
rect(doc, mc.pct, y + 1, Math.max(1, barW * pct / 100), 8, BLUE_MID)
|
||||||
|
doc.fontSize(5.5).fillColor(GRAY).text(`${pct.toFixed(1)}%`, mc.pct + barW + 3, y + 2, { width: 25, lineBreak: false })
|
||||||
y += 14
|
y += 14
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.logs.length > maxRows) {
|
// Total row
|
||||||
doc.moveDown(0.5)
|
hline(doc, y, BLUE)
|
||||||
doc.fontSize(8).fillColor('#999')
|
y += 4
|
||||||
.text(`... Total ${data.logs.length} records, showing first ${maxRows}`)
|
doc.fontSize(7).fillColor(BLUE)
|
||||||
|
doc.text('合计', mc.m, y, { width: 175, lineBreak: false })
|
||||||
|
doc.text(data.totalRequests.toLocaleString(), mc.req, y, { width: 70, lineBreak: false })
|
||||||
|
doc.text(data.totalQuota.toLocaleString(), mc.quota, y, { width: 75, lineBreak: false })
|
||||||
|
doc.text(`$ ${totalUsd}`, mc.usd, y, { width: 55, lineBreak: false })
|
||||||
|
doc.text('100%', mc.pct, y, { width: 60, lineBreak: false })
|
||||||
|
|
||||||
|
footer(doc, data.siteName)
|
||||||
|
|
||||||
|
// ===== TOPUP RECORDS =====
|
||||||
|
if (data.topups.length > 0) {
|
||||||
|
doc.addPage()
|
||||||
|
|
||||||
|
rect(doc, MARGIN, MARGIN, CONTENT_W, 25, BLUE)
|
||||||
|
doc.fontSize(11).fillColor('#ffffff').text(`充值记录 (共 ${data.topups.length.toLocaleString()} 条)`, MARGIN + 12, MARGIN + 7)
|
||||||
|
|
||||||
|
y = MARGIN + 35
|
||||||
|
|
||||||
|
const successTopups = data.topups.filter(t => t.status === 'success')
|
||||||
|
const totalTopupAmount = successTopups.reduce((s, t) => s + (t.amount || 0), 0)
|
||||||
|
const totalTopupMoney = successTopups.reduce((s, t) => s + (t.money || 0), 0)
|
||||||
|
rect(doc, MARGIN, y, CONTENT_W, 22, BLUE_LIGHT)
|
||||||
|
doc.fontSize(8).fillColor(BLUE).text(
|
||||||
|
`充值成功: $ ${totalTopupAmount.toFixed(2)} USD | ¥ ${totalTopupMoney.toFixed(2)} CNY | ${successTopups.length} 笔交易`,
|
||||||
|
MARGIN + 10, y + 6, { width: CONTENT_W - 20, lineBreak: false }
|
||||||
|
)
|
||||||
|
y += 30
|
||||||
|
|
||||||
|
const tc = {
|
||||||
|
time: MARGIN + 5,
|
||||||
|
amount: MARGIN + 110,
|
||||||
|
money: MARGIN + 185,
|
||||||
|
method: MARGIN + 260,
|
||||||
|
status: MARGIN + 320,
|
||||||
|
trade: MARGIN + 360,
|
||||||
|
}
|
||||||
|
function drawTopupHeader() {
|
||||||
|
rect(doc, MARGIN, y, CONTENT_W, 16, BLUE)
|
||||||
|
doc.fontSize(7).fillColor('#ffffff')
|
||||||
|
doc.text('时间', tc.time, y + 4, { width: 100, lineBreak: false })
|
||||||
|
doc.text('充值金额 (USD)', tc.amount, y + 4, { width: 70, lineBreak: false })
|
||||||
|
doc.text('支付金额 (CNY)', tc.money, y + 4, { width: 70, lineBreak: false })
|
||||||
|
doc.text('支付方式', tc.method, y + 4, { width: 55, lineBreak: false })
|
||||||
|
doc.text('状态', tc.status, y + 4, { width: 35, lineBreak: false })
|
||||||
|
doc.text('订单号', tc.trade, y + 4, { width: 135, lineBreak: false })
|
||||||
|
y += 18
|
||||||
|
}
|
||||||
|
drawTopupHeader()
|
||||||
|
|
||||||
|
for (let i = 0; i < data.topups.length; i++) {
|
||||||
|
if (y > 760) {
|
||||||
|
footer(doc, data.siteName)
|
||||||
|
doc.addPage()
|
||||||
|
y = MARGIN + 5
|
||||||
|
drawTopupHeader()
|
||||||
|
}
|
||||||
|
const t = data.topups[i]
|
||||||
|
if (i % 2 === 0) rect(doc, MARGIN, y - 1, CONTENT_W, 13, GRAY_LIGHT)
|
||||||
|
doc.fontSize(6.5).fillColor(BLACK)
|
||||||
|
doc.text(fmtTime(t.create_time), tc.time, y + 2, { width: 100, lineBreak: false })
|
||||||
|
doc.text(`$ ${(t.amount || 0).toFixed(2)}`, tc.amount, y + 2, { width: 70, lineBreak: false })
|
||||||
|
doc.text(`¥ ${(t.money || 0).toFixed(2)}`, tc.money, y + 2, { width: 70, lineBreak: false })
|
||||||
|
doc.text(t.payment_method || '-', tc.method, y + 2, { width: 55, lineBreak: false })
|
||||||
|
const statusMap: Record<string, string> = { success: '成功', pending: '待支付', expired: '已过期' }
|
||||||
|
doc.text(statusMap[t.status] || t.status || '-', tc.status, y + 2, { width: 35, lineBreak: false })
|
||||||
|
doc.text(t.trade_no || '-', tc.trade, y + 2, { width: 135, lineBreak: false })
|
||||||
|
y += 13
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- HMAC Signature --
|
footer(doc, data.siteName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== DETAIL RECORDS =====
|
||||||
|
doc.addPage()
|
||||||
|
|
||||||
|
rect(doc, MARGIN, MARGIN, CONTENT_W, 25, BLUE)
|
||||||
|
doc.fontSize(11).fillColor('#ffffff').text(`调用明细 (共 ${data.logs.length.toLocaleString()} 条)`, MARGIN + 12, MARGIN + 7)
|
||||||
|
|
||||||
|
y = MARGIN + 35
|
||||||
|
|
||||||
|
// Column layout: 时间 | 令牌 | 分组 | 类型 | 模型 | 流式 | 用时/首字 | 输入(命中/创建) | 输出 | 花费 | 详情
|
||||||
|
const typeMap: Record<number, string> = { 1: '充值', 2: '消费', 3: '管理', 4: '系统' }
|
||||||
|
const dc = [
|
||||||
|
{ x: MARGIN + 2, w: 68, label: '时间' },
|
||||||
|
{ x: MARGIN + 70, w: 34, label: '令牌' },
|
||||||
|
{ x: MARGIN + 104, w: 28, label: '分组' },
|
||||||
|
{ x: MARGIN + 132, w: 22, label: '类型' },
|
||||||
|
{ x: MARGIN + 154, w: 68, label: '模型' },
|
||||||
|
{ x: MARGIN + 222, w: 18, label: '流式' },
|
||||||
|
{ x: MARGIN + 240, w: 40, label: '用时/首字' },
|
||||||
|
{ x: MARGIN + 280, w: 58, label: '输入(命中/创建)' },
|
||||||
|
{ x: MARGIN + 338, w: 28, label: '输出' },
|
||||||
|
{ x: MARGIN + 366, w: 36, label: '花费' },
|
||||||
|
{ x: MARGIN + 402, w: 93, label: '详情' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function drawDetailHeader() {
|
||||||
|
rect(doc, MARGIN, y, CONTENT_W, 14, BLUE)
|
||||||
|
doc.fontSize(4.5).fillColor('#ffffff')
|
||||||
|
for (const c of dc) doc.text(c.label, c.x, y + 3, { width: c.w, lineBreak: false })
|
||||||
|
y += 16
|
||||||
|
}
|
||||||
|
|
||||||
|
drawDetailHeader()
|
||||||
|
|
||||||
|
for (let i = 0; i < data.logs.length; i++) {
|
||||||
|
if (y > 760) {
|
||||||
|
footer(doc, data.siteName)
|
||||||
|
doc.addPage()
|
||||||
|
y = MARGIN + 5
|
||||||
|
drawDetailHeader()
|
||||||
|
}
|
||||||
|
const log = data.logs[i]
|
||||||
|
const other = parseOther(log.other)
|
||||||
|
const cacheHit = other.cache_tokens || 0
|
||||||
|
const cacheCreate = other.cache_creation_tokens || 0
|
||||||
|
const frt = other.frt || 0
|
||||||
|
if (i % 2 === 0) rect(doc, MARGIN, y - 1, CONTENT_W, 12, GRAY_LIGHT)
|
||||||
|
doc.fontSize(4.5).fillColor(BLACK)
|
||||||
|
doc.text(fmtTime(log.created_at), dc[0].x, y + 1, { width: dc[0].w, lineBreak: false })
|
||||||
|
doc.text(log.token_name || '-', dc[1].x, y + 1, { width: dc[1].w, lineBreak: false })
|
||||||
|
doc.text(log.group || '-', dc[2].x, y + 1, { width: dc[2].w, lineBreak: false })
|
||||||
|
doc.text(typeMap[log.type] || String(log.type || '-'), dc[3].x, y + 1, { width: dc[3].w, lineBreak: false })
|
||||||
|
doc.text(log.model_name || '-', dc[4].x, y + 1, { width: dc[4].w, lineBreak: false })
|
||||||
|
doc.text(log.is_stream ? '是' : '否', dc[5].x, y + 1, { width: dc[5].w, lineBreak: false })
|
||||||
|
const timing = `${log.use_time || 0}s${frt ? '/' + Math.round(frt) + 'ms' : ''}`
|
||||||
|
doc.text(timing, dc[6].x, y + 1, { width: dc[6].w, lineBreak: false })
|
||||||
|
// 输入(命中/创建): e.g. "1234(500/100)"
|
||||||
|
let inputStr = String(log.prompt_tokens || 0)
|
||||||
|
if (cacheHit > 0 || cacheCreate > 0) {
|
||||||
|
inputStr += `(${cacheHit}/${cacheCreate})`
|
||||||
|
}
|
||||||
|
doc.text(inputStr, dc[7].x, y + 1, { width: dc[7].w, lineBreak: false })
|
||||||
|
doc.text(String(log.completion_tokens || 0), dc[8].x, y + 1, { width: dc[8].w, lineBreak: false })
|
||||||
|
doc.text('$' + q2usd(log.quota || 0, 4), dc[9].x, y + 1, { width: dc[9].w, lineBreak: false })
|
||||||
|
doc.text(log.request_id || '-', dc[10].x, y + 1, { width: dc[10].w, lineBreak: false })
|
||||||
|
y += 12
|
||||||
|
}
|
||||||
|
|
||||||
|
footer(doc, data.siteName)
|
||||||
|
|
||||||
|
// ===== LAST PAGE: SIGNATURE =====
|
||||||
|
doc.addPage()
|
||||||
|
|
||||||
|
rect(doc, MARGIN, MARGIN, CONTENT_W, 25, BLUE)
|
||||||
|
doc.fontSize(11).fillColor('#ffffff').text('报告验证与数字签名', MARGIN + 12, MARGIN + 7)
|
||||||
|
|
||||||
|
y = MARGIN + 40
|
||||||
|
|
||||||
const signature = generateHmacSignature({
|
const signature = generateHmacSignature({
|
||||||
userId: data.userId,
|
userId: data.userId, startDate: data.startDate, endDate: data.endDate,
|
||||||
startDate: data.startDate,
|
totalQuota: data.totalQuota, totalRecords: data.logs.length, generatedAt,
|
||||||
endDate: data.endDate,
|
|
||||||
totalQuota: data.totalQuota,
|
|
||||||
totalRecords: data.logs.length,
|
|
||||||
generatedAt
|
|
||||||
})
|
})
|
||||||
|
|
||||||
doc.addPage()
|
rect(doc, MARGIN, y, CONTENT_W, 130, GRAY_LIGHT)
|
||||||
doc.fontSize(10).fillColor('#000').text('Report Verification', { underline: true })
|
y += 10
|
||||||
doc.moveDown(0.3)
|
const sigRows: [string, string][] = [
|
||||||
doc.fontSize(8).fillColor('#333')
|
['报告编号', reportId],
|
||||||
doc.text(`Generated At: ${generatedAt}`)
|
['生成时间', generatedLocal],
|
||||||
doc.text(`Total Records: ${data.logs.length}`)
|
['记录总数', data.logs.length.toLocaleString()],
|
||||||
doc.text(`HMAC-SHA256 Signature: ${signature}`)
|
['总额度', `${data.totalQuota.toLocaleString()} ( $ ${totalUsd} USD )`],
|
||||||
doc.moveDown(0.5)
|
['算法', 'HMAC-SHA256'],
|
||||||
doc.fontSize(7).fillColor('#999')
|
['签名', signature],
|
||||||
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')
|
for (const [k, v] of sigRows) {
|
||||||
|
doc.fontSize(8).fillColor(GRAY).text(k, MARGIN + 12, y, { width: 100, lineBreak: false })
|
||||||
|
const isSig = k === '签名'
|
||||||
|
doc.fontSize(isSig ? 6.5 : 8).fillColor(isSig ? BLUE : BLACK)
|
||||||
|
.text(v, MARGIN + 120, y, { width: CONTENT_W - 140, lineBreak: false })
|
||||||
|
y += 18
|
||||||
|
}
|
||||||
|
|
||||||
|
y += 20
|
||||||
|
doc.fontSize(9).fillColor(BLUE).text('验证说明', MARGIN, y)
|
||||||
|
hline(doc, y + 14, BLUE)
|
||||||
|
y += 22
|
||||||
|
|
||||||
|
const instructions = [
|
||||||
|
'本报告由系统自动生成,包含数字签名用于完整性验证。',
|
||||||
|
'验证方式: 将签名数据提交至 POST /api/billing/verify 接口。',
|
||||||
|
'任何对报告数据的修改都将导致签名验证失败。',
|
||||||
|
'本文件仅供财务审计用途。',
|
||||||
|
]
|
||||||
|
for (let i = 0; i < instructions.length; i++) {
|
||||||
|
doc.fontSize(7.5).fillColor(GRAY).text(`${i + 1}. ${instructions[i]}`, MARGIN + 5, y, { width: CONTENT_W - 10 })
|
||||||
|
y += 14
|
||||||
|
}
|
||||||
|
|
||||||
|
y += 15
|
||||||
|
rect(doc, MARGIN, y, CONTENT_W, 25, '#fff7e0')
|
||||||
|
rect(doc, MARGIN, y, 3, 25, '#b45309')
|
||||||
|
doc.fontSize(7).fillColor('#b45309')
|
||||||
|
doc.text(`由 ${data.siteName} Dashboard 生成 | ${DASHBOARD_URL} | ${generatedLocal}`, MARGIN + 12, y + 8, { width: CONTENT_W - 20, lineBreak: false })
|
||||||
|
|
||||||
|
footer(doc, data.siteName)
|
||||||
|
|
||||||
|
// Final pass: draw watermarks + page numbers on all pages
|
||||||
|
const total = doc.bufferedPageRange().count
|
||||||
|
for (let i = 0; i < total; i++) {
|
||||||
|
doc.switchToPage(i)
|
||||||
|
drawWatermarkOnPage(doc, data.siteName)
|
||||||
|
doc.font(getChineseFont()).fontSize(6.5).fillColor('#aaaaaa')
|
||||||
|
doc.text(`${i + 1} / ${total}`, MARGIN + CONTENT_W - 50, 782, { width: 50, align: 'right', lineBreak: false })
|
||||||
|
}
|
||||||
|
|
||||||
doc.end()
|
doc.end()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function SessionRestore({ children }: { children: React.ReactNode }) {
|
function SessionRestore({ children }: { children: React.ReactNode }) {
|
||||||
const { sessionToken, login, logout, setLoading } = useAuthStore()
|
const { sessionToken, login, logout, setLoading, setAdmin } = useAuthStore()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (sessionToken) {
|
if (sessionToken) {
|
||||||
@@ -34,6 +34,7 @@ function SessionRestore({ children }: { children: React.ReactNode }) {
|
|||||||
.then((res) => {
|
.then((res) => {
|
||||||
if (res.data.success) {
|
if (res.data.success) {
|
||||||
login(sessionToken, res.data.data.userInfo, res.data.data.site)
|
login(sessionToken, res.data.data.userInfo, res.data.data.site)
|
||||||
|
setAdmin(!!res.data.data.isAdmin)
|
||||||
} else {
|
} else {
|
||||||
logout()
|
logout()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,4 +5,6 @@ export const authApi = {
|
|||||||
client.post('/api/auth/login', data),
|
client.post('/api/auth/login', data),
|
||||||
logout: () => client.post('/api/auth/logout'),
|
logout: () => client.post('/api/auth/logout'),
|
||||||
me: () => client.get('/api/auth/me'),
|
me: () => client.get('/api/auth/me'),
|
||||||
|
elevate: (password: string) => client.post('/api/auth/elevate', { password }),
|
||||||
|
demote: () => client.post('/api/auth/demote'),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
import client from './client'
|
import client from './client'
|
||||||
|
|
||||||
|
const EXPORT_TIMEOUT = 600000 // 10 minutes for export
|
||||||
|
|
||||||
export const billingApi = {
|
export const billingApi = {
|
||||||
exportPdf: (startDate: string, endDate: string) =>
|
exportPdf: (startDate: string, endDate: string) =>
|
||||||
client.post('/api/billing/export/pdf', { startDate, endDate }, { responseType: 'blob' }),
|
client.post('/api/billing/export/pdf', { startDate, endDate }, { responseType: 'blob', timeout: EXPORT_TIMEOUT }),
|
||||||
exportCsv: (startDate: string, endDate: string) =>
|
exportCsv: (startDate: string, endDate: string) =>
|
||||||
client.post('/api/billing/export/csv', { startDate, endDate }, { responseType: 'blob' }),
|
client.post('/api/billing/export/csv', { startDate, endDate }, { responseType: 'blob', timeout: EXPORT_TIMEOUT }),
|
||||||
verify: (data: any) => client.post('/api/billing/verify', data),
|
verify: (data: any) => client.post('/api/billing/verify', data),
|
||||||
|
stats: (params: {
|
||||||
|
startTimestamp?: number; endTimestamp?: number; type?: number | '';
|
||||||
|
modelName?: string; tokenName?: string; group?: string; requestId?: string
|
||||||
|
}) => client.post('/api/billing/stats', params, { timeout: EXPORT_TIMEOUT }),
|
||||||
|
chartData: () => client.post('/api/billing/chart-data', {}, { timeout: EXPORT_TIMEOUT }),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,10 @@ export const dashboardApi = {
|
|||||||
getTokens: (params?: { p?: number; page_size?: number }) =>
|
getTokens: (params?: { p?: number; page_size?: number }) =>
|
||||||
client.get('/proxy/api/token/', { params }),
|
client.get('/proxy/api/token/', { params }),
|
||||||
getLogs: (params?: {
|
getLogs: (params?: {
|
||||||
p?: number; page_size?: number; type?: number;
|
p?: number; page_size?: number; type?: number | '';
|
||||||
start_timestamp?: number; end_timestamp?: number;
|
start_timestamp?: number; end_timestamp?: number;
|
||||||
model_name?: string; token_name?: string
|
model_name?: string; token_name?: string;
|
||||||
|
group?: string; request_id?: string
|
||||||
}) => client.get('/proxy/api/log/self', { params }),
|
}) => client.get('/proxy/api/log/self', { params }),
|
||||||
getTopUps: (params?: { p?: number; page_size?: number; keyword?: string }) =>
|
getTopUps: (params?: { p?: number; page_size?: number; keyword?: string }) =>
|
||||||
client.get('/proxy/api/user/topup/self', { params }),
|
client.get('/proxy/api/user/topup/self', { params }),
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Outlet, useNavigate, useLocation } from 'react-router-dom'
|
import { Outlet, useNavigate, useLocation } from 'react-router-dom'
|
||||||
import { Layout as AntLayout, Menu, Avatar, Dropdown, Button, Space, Typography, theme } from 'antd'
|
import { Layout as AntLayout, Menu, Avatar, Dropdown, Button, Space, Typography, theme, Modal, Input, message } from 'antd'
|
||||||
import {
|
import {
|
||||||
DashboardOutlined, FileTextOutlined,
|
DashboardOutlined, FileTextOutlined,
|
||||||
LogoutOutlined, UserOutlined, MenuFoldOutlined, MenuUnfoldOutlined,
|
LogoutOutlined, UserOutlined, MenuFoldOutlined, MenuUnfoldOutlined,
|
||||||
GlobalOutlined
|
GlobalOutlined, CrownOutlined, StopOutlined
|
||||||
} from '@ant-design/icons'
|
} from '@ant-design/icons'
|
||||||
import { useAuthStore } from '@/store/authStore'
|
import { useAuthStore } from '@/store/authStore'
|
||||||
import { authApi } from '@/api/auth'
|
import { authApi } from '@/api/auth'
|
||||||
@@ -14,13 +14,14 @@ const { Text } = Typography
|
|||||||
|
|
||||||
export default function Layout() {
|
export default function Layout() {
|
||||||
const [collapsed, setCollapsed] = useState(false)
|
const [collapsed, setCollapsed] = useState(false)
|
||||||
|
const [elevateOpen, setElevateOpen] = useState(false)
|
||||||
|
const [adminPassword, setAdminPassword] = useState('')
|
||||||
|
const [elevating, setElevating] = useState(false)
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const { userInfo, site, logout } = useAuthStore()
|
const { userInfo, site, isAdmin, logout, setAdmin } = useAuthStore()
|
||||||
const { token } = theme.useToken()
|
const { token } = theme.useToken()
|
||||||
|
|
||||||
const isAdmin = (userInfo?.role || 0) >= 10
|
|
||||||
|
|
||||||
const menuItems = [
|
const menuItems = [
|
||||||
{ key: '/dashboard', icon: <DashboardOutlined />, label: '仪表盘' },
|
{ key: '/dashboard', icon: <DashboardOutlined />, label: '仪表盘' },
|
||||||
{ key: '/billing', icon: <FileTextOutlined />, label: '账单' },
|
{ key: '/billing', icon: <FileTextOutlined />, label: '账单' },
|
||||||
@@ -33,55 +34,177 @@ export default function Layout() {
|
|||||||
navigate('/login')
|
navigate('/login')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleElevate = async () => {
|
||||||
|
setElevating(true)
|
||||||
|
try {
|
||||||
|
const res = await authApi.elevate(adminPassword)
|
||||||
|
if (res.data.success) {
|
||||||
|
message.success('已升格为管理员')
|
||||||
|
setAdmin(true)
|
||||||
|
setElevateOpen(false)
|
||||||
|
setAdminPassword('')
|
||||||
|
} else {
|
||||||
|
message.error(res.data.message || '升格失败')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
message.error('请求失败')
|
||||||
|
}
|
||||||
|
setElevating(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDemote = async () => {
|
||||||
|
try {
|
||||||
|
const res = await authApi.demote()
|
||||||
|
if (res.data.success) {
|
||||||
|
message.success('已取消管理员权限')
|
||||||
|
setAdmin(false)
|
||||||
|
if (location.pathname.startsWith('/admin')) navigate('/dashboard')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
message.error('请求失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const dropdownItems = {
|
const dropdownItems = {
|
||||||
items: [
|
items: [
|
||||||
{ key: 'user', label: `${userInfo?.username || '用户'} (ID: ${userInfo?.id})`, disabled: true },
|
{ key: 'user', label: `${userInfo?.username || '用户'} (ID: ${userInfo?.id})`, disabled: true },
|
||||||
{ key: 'site', label: `站点: ${site?.name || '-'}`, disabled: true },
|
{ key: 'site', label: `站点: ${site?.name || '-'}`, disabled: true },
|
||||||
{ type: 'divider' as const },
|
{ type: 'divider' as const },
|
||||||
|
...(isAdmin
|
||||||
|
? [{ key: 'demote', label: '取消管理员', icon: <StopOutlined /> }]
|
||||||
|
: [{ key: 'elevate', label: '升格为管理员', icon: <CrownOutlined /> }]
|
||||||
|
),
|
||||||
|
{ type: 'divider' as const },
|
||||||
{ key: 'logout', label: '退出登录', icon: <LogoutOutlined />, danger: true },
|
{ key: 'logout', label: '退出登录', icon: <LogoutOutlined />, danger: true },
|
||||||
],
|
],
|
||||||
onClick: ({ key }: { key: string }) => { if (key === 'logout') handleLogout() }
|
onClick: ({ key }: { key: string }) => {
|
||||||
|
if (key === 'logout') handleLogout()
|
||||||
|
else if (key === 'elevate') setElevateOpen(true)
|
||||||
|
else if (key === 'demote') handleDemote()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AntLayout style={{ minHeight: '100vh' }}>
|
<AntLayout style={{ minHeight: '100vh' }} className="grain-overlay">
|
||||||
<Sider trigger={null} collapsible collapsed={collapsed} breakpoint="lg"
|
<Sider
|
||||||
onBreakpoint={(broken) => setCollapsed(broken)}>
|
trigger={null}
|
||||||
|
collapsible
|
||||||
|
collapsed={collapsed}
|
||||||
|
breakpoint="lg"
|
||||||
|
onBreakpoint={(broken) => setCollapsed(broken)}
|
||||||
|
className="sidebar-glow"
|
||||||
|
style={{
|
||||||
|
background: 'linear-gradient(180deg, #2C1A0E 0%, #1A0F06 100%)',
|
||||||
|
borderRight: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Logo */}
|
||||||
<div style={{
|
<div style={{
|
||||||
height: 64, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
height: 64, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
borderBottom: '1px solid rgba(255,255,255,0.1)'
|
gap: 10,
|
||||||
|
borderBottom: '1px solid rgba(200, 149, 108, 0.12)',
|
||||||
}}>
|
}}>
|
||||||
<Text strong style={{ color: '#fff', fontSize: collapsed ? 14 : 18 }}>
|
<div style={{
|
||||||
{collapsed ? 'NA' : 'NewAPI Dashboard'}
|
width: 32, height: 32, borderRadius: 10,
|
||||||
</Text>
|
background: 'linear-gradient(135deg, #C8956C, #E8B88A)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
boxShadow: '0 2px 8px rgba(200, 149, 108, 0.3)',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
<span style={{ fontSize: 16, color: '#fff', fontWeight: 700 }}>林</span>
|
||||||
</div>
|
</div>
|
||||||
<Menu theme="dark" mode="inline" selectedKeys={[location.pathname]}
|
{!collapsed && (
|
||||||
|
<Text strong style={{ color: 'rgba(255,255,255,0.85)', fontSize: 14, whiteSpace: 'nowrap' }}>
|
||||||
|
小林子的服务平台
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Menu
|
||||||
|
theme="dark"
|
||||||
|
mode="inline"
|
||||||
|
selectedKeys={[location.pathname]}
|
||||||
items={menuItems}
|
items={menuItems}
|
||||||
onClick={({ key }) => navigate(key)}
|
onClick={({ key }) => navigate(key)}
|
||||||
|
style={{ background: 'transparent', marginTop: 8, border: 'none' }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Sidebar bottom decoration */}
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', bottom: 0, left: 0, right: 0, height: 80,
|
||||||
|
background: 'linear-gradient(to top, rgba(200,149,108,0.06), transparent)',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}} />
|
||||||
</Sider>
|
</Sider>
|
||||||
|
|
||||||
<AntLayout>
|
<AntLayout>
|
||||||
<Header style={{
|
<Header className="header-glass" style={{
|
||||||
padding: '0 24px', background: token.colorBgContainer,
|
padding: '0 24px',
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||||
borderBottom: `1px solid ${token.colorBorderSecondary}`,
|
borderBottom: '1px solid rgba(180, 150, 100, 0.1)',
|
||||||
boxShadow: '0 1px 4px rgba(0,0,0,0.05)'
|
boxShadow: '0 1px 8px rgba(160, 130, 80, 0.04)',
|
||||||
|
position: 'sticky', top: 0, zIndex: 10,
|
||||||
}}>
|
}}>
|
||||||
<Button type="text" icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
|
<Button
|
||||||
onClick={() => setCollapsed(!collapsed)} />
|
type="text"
|
||||||
|
icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
|
||||||
|
onClick={() => setCollapsed(!collapsed)}
|
||||||
|
style={{ color: '#8B7355' }}
|
||||||
|
/>
|
||||||
<Space>
|
<Space>
|
||||||
|
{isAdmin && (
|
||||||
|
<span style={{
|
||||||
|
background: 'linear-gradient(135deg, #E8A850, #C8956C)',
|
||||||
|
color: '#fff',
|
||||||
|
padding: '2px 10px',
|
||||||
|
borderRadius: 20,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
}}>
|
||||||
|
<CrownOutlined /> 管理员
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<Dropdown menu={dropdownItems}>
|
<Dropdown menu={dropdownItems}>
|
||||||
<Space style={{ cursor: 'pointer' }}>
|
<Space style={{ cursor: 'pointer' }}>
|
||||||
<Avatar icon={<UserOutlined />} style={{ backgroundColor: token.colorPrimary }} />
|
<Avatar
|
||||||
<Text>{userInfo?.display_name || userInfo?.username}</Text>
|
icon={<UserOutlined />}
|
||||||
|
style={{
|
||||||
|
background: 'linear-gradient(135deg, #C8956C, #D4A57E)',
|
||||||
|
boxShadow: '0 2px 8px rgba(200, 149, 108, 0.2)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Text style={{ color: '#5C4A35', fontWeight: 500 }}>
|
||||||
|
{userInfo?.display_name || userInfo?.username}
|
||||||
|
</Text>
|
||||||
</Space>
|
</Space>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
</Space>
|
</Space>
|
||||||
</Header>
|
</Header>
|
||||||
<Content style={{ margin: 24 }}>
|
|
||||||
|
<Content style={{ margin: 24, position: 'relative', zIndex: 1 }}>
|
||||||
|
<div className="page-fade-in">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
|
</div>
|
||||||
</Content>
|
</Content>
|
||||||
</AntLayout>
|
</AntLayout>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title="升格为管理员"
|
||||||
|
open={elevateOpen}
|
||||||
|
onCancel={() => { setElevateOpen(false); setAdminPassword('') }}
|
||||||
|
onOk={handleElevate}
|
||||||
|
confirmLoading={elevating}
|
||||||
|
okText="确认升格"
|
||||||
|
cancelText="取消"
|
||||||
|
>
|
||||||
|
<p style={{ marginBottom: 12, color: '#8B7355' }}>请输入 Dashboard 管理密码:</p>
|
||||||
|
<Input.Password
|
||||||
|
value={adminPassword}
|
||||||
|
onChange={(e) => setAdminPassword(e.target.value)}
|
||||||
|
placeholder="管理密码"
|
||||||
|
onPressEnter={handleElevate}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
</AntLayout>
|
</AntLayout>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,50 +1,73 @@
|
|||||||
import { Card, Empty } from 'antd'
|
import { Card, Empty, Spin } from 'antd'
|
||||||
import { PieChartOutlined } from '@ant-design/icons'
|
import { PieChartOutlined } from '@ant-design/icons'
|
||||||
import { Pie } from '@ant-design/charts'
|
import { Pie } from '@ant-design/charts'
|
||||||
|
|
||||||
interface LogItem {
|
interface ModelItem {
|
||||||
model_name: string
|
name: string
|
||||||
quota: number
|
value: number
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
logs: LogItem[]
|
models: ModelItem[]
|
||||||
|
loading?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ModelPieChart({ logs }: Props) {
|
const WARM_COLORS = [
|
||||||
const modelMap = new Map<string, number>()
|
'#C8956C', '#7DB87D', '#7BA4C8', '#E8A850', '#D4645C',
|
||||||
for (const log of logs) {
|
'#A69278', '#B8A07D', '#8BB8A4', '#C8A87B', '#9B8EC2',
|
||||||
const model = log.model_name || 'unknown'
|
]
|
||||||
modelMap.set(model, (modelMap.get(model) || 0) + (log.quota || 0))
|
|
||||||
|
const CARD_MIN_H = 400
|
||||||
|
|
||||||
|
export default function ModelPieChart({ models, loading }: Props) {
|
||||||
|
const title = <><PieChartOutlined style={{ color: '#C8956C' }} /> 模型消耗分布</>
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Card title={title} hoverable className="stat-accent" style={{ minHeight: CARD_MIN_H }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: 320 }}><Spin /></div>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = Array.from(modelMap.entries())
|
if (models.length === 0) {
|
||||||
.map(([name, value]) => ({ name, value }))
|
return (
|
||||||
.sort((a, b) => b.value - a.value)
|
<Card title={title} hoverable className="stat-accent" style={{ minHeight: CARD_MIN_H }}>
|
||||||
.slice(0, 10) // Top 10
|
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: 320 }}>
|
||||||
|
<Empty description="暂无数据" />
|
||||||
if (data.length === 0) {
|
</div>
|
||||||
return <Card title={<><PieChartOutlined /> 模型消耗分布</>}><Empty description="暂无数据" /></Card>
|
</Card>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const total = models.reduce((s, m) => s + m.value, 0)
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
data,
|
data: models,
|
||||||
angleField: 'value',
|
angleField: 'value',
|
||||||
colorField: 'name',
|
colorField: 'name',
|
||||||
radius: 0.9,
|
radius: 0.65,
|
||||||
innerRadius: 0.5,
|
innerRadius: 0.4,
|
||||||
label: {
|
label: {
|
||||||
text: 'name',
|
text: (d: any) => {
|
||||||
position: 'outside' as const,
|
const pct = total > 0 ? ((d.value / total) * 100).toFixed(1) : '0'
|
||||||
style: { fontSize: 11 },
|
return `${d.name} ${pct}%`
|
||||||
},
|
},
|
||||||
legend: { position: 'right' as const },
|
position: 'spider' as const,
|
||||||
|
style: { fontSize: 10 },
|
||||||
|
},
|
||||||
|
legend: false,
|
||||||
|
scale: { color: { range: WARM_COLORS } },
|
||||||
interaction: { tooltip: { marker: true } },
|
interaction: { tooltip: { marker: true } },
|
||||||
height: 280,
|
tooltip: {
|
||||||
|
title: 'name',
|
||||||
|
items: [{ channel: 'y', valueFormatter: (v: number) => `$${(v / 500000).toFixed(4)}` }],
|
||||||
|
},
|
||||||
|
height: 320,
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card title={<><PieChartOutlined /> 模型消耗分布</>} hoverable>
|
<Card title={title} hoverable className="stat-accent" style={{ minHeight: CARD_MIN_H }}>
|
||||||
<Pie {...config} />
|
<Pie {...config} />
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -15,25 +15,30 @@ export default function QuotaCard({ quota, usedQuota }: Props) {
|
|||||||
const remaining = quota
|
const remaining = quota
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card title={<><WalletOutlined /> 额度概览</>} hoverable>
|
<Card
|
||||||
|
title={<><WalletOutlined style={{ color: '#C8956C' }} /> 额度概览</>}
|
||||||
|
hoverable
|
||||||
|
className="stat-accent"
|
||||||
|
>
|
||||||
<Row gutter={24} align="middle">
|
<Row gutter={24} align="middle">
|
||||||
<Col span={10} style={{ textAlign: 'center' }}>
|
<Col span={10} style={{ textAlign: 'center' }}>
|
||||||
<Progress
|
<Progress
|
||||||
type="dashboard"
|
type="dashboard"
|
||||||
percent={percent}
|
percent={percent}
|
||||||
strokeColor={{
|
strokeColor={{
|
||||||
'0%': '#1677ff',
|
'0%': '#C8956C',
|
||||||
'100%': percent > 80 ? '#ff4d4f' : '#52c41a',
|
'100%': percent > 80 ? '#D4645C' : '#7DB87D',
|
||||||
}}
|
}}
|
||||||
|
trailColor="rgba(180, 150, 100, 0.1)"
|
||||||
format={() => `${percent}%`}
|
format={() => `${percent}%`}
|
||||||
size={120}
|
size={120}
|
||||||
/>
|
/>
|
||||||
<Text type="secondary" style={{ display: 'block', marginTop: 8 }}>已使用比例</Text>
|
<Text type="secondary" style={{ display: 'block', marginTop: 8 }}>已使用比例</Text>
|
||||||
</Col>
|
</Col>
|
||||||
<Col span={14}>
|
<Col span={14}>
|
||||||
<Statistic title="剩余额度" value={quotaToUsd(remaining)} suffix="USD" valueStyle={{ color: '#52c41a' }} />
|
<Statistic title="剩余额度" value={quotaToUsd(remaining)} suffix="USD" valueStyle={{ color: '#7DB87D', fontWeight: 600 }} />
|
||||||
<Statistic title="已用额度" value={quotaToUsd(usedQuota)} suffix="USD" style={{ marginTop: 16 }} valueStyle={{ color: '#1677ff' }} />
|
<Statistic title="已用额度" value={quotaToUsd(usedQuota)} suffix="USD" style={{ marginTop: 16 }} valueStyle={{ color: '#C8956C', fontWeight: 600 }} />
|
||||||
<Statistic title="总额度" value={quotaToUsd(total)} suffix="USD" style={{ marginTop: 16 }} valueStyle={{ fontSize: 14 }} />
|
<Statistic title="总额度" value={quotaToUsd(total)} suffix="USD" style={{ marginTop: 16 }} valueStyle={{ fontSize: 14, color: '#8B7355' }} />
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -19,24 +19,33 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const logTypeMap: Record<number, { color: string; label: string }> = {
|
const logTypeMap: Record<number, { color: string; label: string }> = {
|
||||||
1: { color: 'green', label: '充值' },
|
1: { color: '#7DB87D', label: '充值' },
|
||||||
2: { color: 'blue', label: '消费' },
|
2: { color: '#C8956C', label: '消费' },
|
||||||
3: { color: 'orange', label: '管理' },
|
3: { color: '#E8A850', label: '管理' },
|
||||||
4: { color: 'purple', label: '系统' },
|
4: { color: '#9B8EC2', label: '系统' },
|
||||||
5: { color: 'red', label: '错误' },
|
5: { color: '#D4645C', label: '错误' },
|
||||||
6: { color: 'cyan', label: '退款' },
|
6: { color: '#7BA4C8', label: '退款' },
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RecentLogs({ logs, loading }: Props) {
|
export default function RecentLogs({ logs, loading }: Props) {
|
||||||
if (!loading && logs.length === 0) {
|
if (!loading && logs.length === 0) {
|
||||||
return <Card title={<><HistoryOutlined /> 最近操作日志</>}><Empty description="暂无日志" /></Card>
|
return (
|
||||||
|
<Card title={<><HistoryOutlined style={{ color: '#C8956C' }} /> 最近操作日志</>} className="stat-accent">
|
||||||
|
<Empty description="暂无日志" />
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card title={<><HistoryOutlined /> 最近操作日志</>} loading={loading} hoverable>
|
<Card
|
||||||
|
title={<><HistoryOutlined style={{ color: '#C8956C' }} /> 最近操作日志</>}
|
||||||
|
loading={loading}
|
||||||
|
hoverable
|
||||||
|
className="stat-accent"
|
||||||
|
>
|
||||||
<Timeline
|
<Timeline
|
||||||
items={logs.slice(0, 10).map((log) => {
|
items={logs.slice(0, 10).map((log) => {
|
||||||
const typeInfo = logTypeMap[log.type] || { color: 'default', label: '未知' }
|
const typeInfo = logTypeMap[log.type] || { color: '#A69278', label: '未知' }
|
||||||
return {
|
return {
|
||||||
color: typeInfo.color,
|
color: typeInfo.color,
|
||||||
children: (
|
children: (
|
||||||
|
|||||||
@@ -43,7 +43,9 @@ export default function TokenOverview({ tokens, loading }: Props) {
|
|||||||
key: 'remain',
|
key: 'remain',
|
||||||
width: 120,
|
width: 120,
|
||||||
render: (_: any, record: TokenItem) =>
|
render: (_: any, record: TokenItem) =>
|
||||||
record.unlimited_quota ? <Text type="success">无限</Text> : `$${quotaToUsd(record.remain_quota)}`,
|
record.unlimited_quota
|
||||||
|
? <Text style={{ color: '#7DB87D', fontWeight: 500 }}>无限</Text>
|
||||||
|
: `$${quotaToUsd(record.remain_quota)}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '已用额度',
|
title: '已用额度',
|
||||||
@@ -62,7 +64,7 @@ export default function TokenOverview({ tokens, loading }: Props) {
|
|||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card title={<><KeyOutlined /> 令牌概览</>} hoverable>
|
<Card title={<><KeyOutlined style={{ color: '#C8956C' }} /> 令牌概览</>} hoverable className="stat-accent">
|
||||||
<Table
|
<Table
|
||||||
dataSource={tokens}
|
dataSource={tokens}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
|
|||||||
@@ -1,48 +1,47 @@
|
|||||||
import { Card, Empty } from 'antd'
|
import { Card, Empty, Spin } from 'antd'
|
||||||
import { LineChartOutlined } from '@ant-design/icons'
|
import { LineChartOutlined } from '@ant-design/icons'
|
||||||
import { Line } from '@ant-design/charts'
|
import { Line } from '@ant-design/charts'
|
||||||
|
|
||||||
interface LogItem {
|
interface DailyItem {
|
||||||
created_at: number
|
date: string
|
||||||
|
count: number
|
||||||
quota: number
|
quota: number
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
logs: LogItem[]
|
daily: DailyItem[]
|
||||||
|
loading?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function UsageChart({ logs }: Props) {
|
const CARD_MIN_H = 360
|
||||||
// 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
|
export default function UsageChart({ daily, loading }: Props) {
|
||||||
for (let i = 6; i >= 0; i--) {
|
const title = <><LineChartOutlined style={{ color: '#C8956C' }} /> 近 7 天使用趋势</>
|
||||||
const d = new Date(now)
|
|
||||||
d.setDate(d.getDate() - i)
|
if (loading) {
|
||||||
const key = d.toISOString().split('T')[0]
|
return (
|
||||||
dayMap.set(key, { count: 0, quota: 0 })
|
<Card title={title} hoverable className="stat-accent" style={{ minHeight: CARD_MIN_H }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: 280 }}><Spin /></div>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fill data
|
const hasData = daily.some(d => d.count > 0 || d.quota > 0)
|
||||||
for (const log of logs) {
|
if (!hasData) {
|
||||||
const date = new Date(log.created_at * 1000).toISOString().split('T')[0]
|
return (
|
||||||
if (dayMap.has(date)) {
|
<Card title={title} hoverable className="stat-accent" style={{ minHeight: CARD_MIN_H }}>
|
||||||
const existing = dayMap.get(date)!
|
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: 280 }}>
|
||||||
existing.count += 1
|
<Empty description="暂无数据" />
|
||||||
existing.quota += log.quota || 0
|
</div>
|
||||||
}
|
</Card>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const chartData: { date: string; value: number; type: string }[] = []
|
const chartData: { date: string; value: number; type: string }[] = []
|
||||||
dayMap.forEach((val, date) => {
|
for (const d of daily) {
|
||||||
const shortDate = date.substring(5) // MM-DD
|
const shortDate = d.date.substring(5)
|
||||||
chartData.push({ date: shortDate, value: val.count, type: '请求次数' })
|
chartData.push({ date: shortDate, value: d.count, type: '请求次数' })
|
||||||
chartData.push({ date: shortDate, value: Math.round(val.quota / 500), type: '消耗(K quota)' })
|
chartData.push({ date: shortDate, value: Number((d.quota / 500000).toFixed(2)), type: '消耗 (USD)' })
|
||||||
})
|
|
||||||
|
|
||||||
if (chartData.length === 0) {
|
|
||||||
return <Card title={<><LineChartOutlined /> 近 7 天使用趋势</>}><Empty description="暂无数据" /></Card>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
@@ -54,11 +53,12 @@ export default function UsageChart({ logs }: Props) {
|
|||||||
point: { shapeField: 'circle', sizeField: 3 },
|
point: { shapeField: 'circle', sizeField: 3 },
|
||||||
interaction: { tooltip: { marker: true } },
|
interaction: { tooltip: { marker: true } },
|
||||||
style: { lineWidth: 2 },
|
style: { lineWidth: 2 },
|
||||||
|
scale: { color: { range: ['#C8956C', '#7DB87D'] } },
|
||||||
height: 280,
|
height: 280,
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card title={<><LineChartOutlined /> 近 7 天使用趋势</>} hoverable>
|
<Card title={title} hoverable className="stat-accent" style={{ minHeight: CARD_MIN_H }}>
|
||||||
<Line {...config} />
|
<Line {...config} />
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
|
|||||||
260
src/index.css
Normal file
260
src/index.css
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
/* ========================================================
|
||||||
|
NewAPI Dashboard — Global Styles
|
||||||
|
Color Palette: Cream Yellow + Milky White
|
||||||
|
======================================================== */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--cream: #F5EDD6;
|
||||||
|
--cream-light: #FAF5E8;
|
||||||
|
--milk: #FFFDF7;
|
||||||
|
--milk-pure: #FFFFF5;
|
||||||
|
--gold: #C8956C;
|
||||||
|
--gold-hover: #B37D56;
|
||||||
|
--gold-light: #F5E6D0;
|
||||||
|
--gold-glow: rgba(200, 149, 108, 0.35);
|
||||||
|
--brown-dark: #3D2E1C;
|
||||||
|
--brown-text: #5C4A35;
|
||||||
|
--brown-secondary: #8B7355;
|
||||||
|
--sidebar-bg: #1E1209;
|
||||||
|
--border-warm: rgba(180, 150, 100, 0.15);
|
||||||
|
--shadow-warm: 0 2px 16px rgba(160, 130, 80, 0.08);
|
||||||
|
--shadow-hover: 0 8px 30px rgba(160, 130, 80, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Base ---- */
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body, #root {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--cream);
|
||||||
|
color: var(--brown-dark);
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
::selection {
|
||||||
|
background: var(--gold-light);
|
||||||
|
color: var(--brown-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Custom Scrollbar ---- */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(180, 150, 100, 0.25);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(180, 150, 100, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Card Hover Effects ---- */
|
||||||
|
.ant-card {
|
||||||
|
transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1),
|
||||||
|
box-shadow 0.25s cubic-bezier(0.4, 0, 0.2, 1) !important;
|
||||||
|
}
|
||||||
|
.ant-card[class*="hoverable"]:hover,
|
||||||
|
.ant-card-hoverable:hover {
|
||||||
|
transform: translateY(-2px) !important;
|
||||||
|
box-shadow: var(--shadow-hover) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Stat Card Accent Bar ---- */
|
||||||
|
.stat-accent {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.stat-accent::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 3px;
|
||||||
|
background: linear-gradient(90deg, var(--gold), var(--gold-light));
|
||||||
|
border-radius: 12px 12px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Sidebar Glow ---- */
|
||||||
|
.sidebar-glow {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.sidebar-glow::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: -1px;
|
||||||
|
bottom: 0;
|
||||||
|
width: 1px;
|
||||||
|
background: linear-gradient(
|
||||||
|
180deg,
|
||||||
|
transparent 0%,
|
||||||
|
rgba(200, 149, 108, 0.2) 30%,
|
||||||
|
rgba(200, 149, 108, 0.3) 50%,
|
||||||
|
rgba(200, 149, 108, 0.2) 70%,
|
||||||
|
transparent 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Header Frost Glass ---- */
|
||||||
|
.header-glass {
|
||||||
|
backdrop-filter: blur(12px) saturate(180%);
|
||||||
|
-webkit-backdrop-filter: blur(12px) saturate(180%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Login Particles ---- */
|
||||||
|
@keyframes particle-float {
|
||||||
|
0% {
|
||||||
|
transform: translateY(0) translateX(0) scale(0);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
8% {
|
||||||
|
opacity: 0.7;
|
||||||
|
transform: translateY(-8vh) translateX(5px) scale(1);
|
||||||
|
}
|
||||||
|
92% {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translateY(-100vh) translateX(-10px) scale(0.5);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-particle {
|
||||||
|
position: absolute;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: radial-gradient(circle at 30% 30%,
|
||||||
|
rgba(255, 230, 180, 0.9),
|
||||||
|
rgba(200, 149, 108, 0.4)
|
||||||
|
);
|
||||||
|
pointer-events: none;
|
||||||
|
animation: particle-float linear infinite;
|
||||||
|
box-shadow: 0 0 6px rgba(255, 220, 160, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Login Card Frost ---- */
|
||||||
|
.login-card-frost {
|
||||||
|
backdrop-filter: blur(20px) saturate(180%);
|
||||||
|
-webkit-backdrop-filter: blur(20px) saturate(180%);
|
||||||
|
background: rgba(255, 253, 247, 0.85) !important;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.5) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Login Background Gradient Animation ---- */
|
||||||
|
@keyframes gradient-shift {
|
||||||
|
0% { background-position: 0% 50%; }
|
||||||
|
50% { background-position: 100% 50%; }
|
||||||
|
100% { background-position: 0% 50%; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-bg {
|
||||||
|
background: linear-gradient(-45deg,
|
||||||
|
#E8C88A, #D4A574, #C8956C, #E0B078, #F0D8A8
|
||||||
|
);
|
||||||
|
background-size: 400% 400%;
|
||||||
|
animation: gradient-shift 15s ease infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Login Glow Button ---- */
|
||||||
|
@keyframes btn-glow {
|
||||||
|
0%, 100% { box-shadow: 0 0 5px var(--gold-glow); }
|
||||||
|
50% { box-shadow: 0 0 20px var(--gold-glow), 0 0 40px rgba(200, 149, 108, 0.15); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.glow-btn {
|
||||||
|
animation: btn-glow 2.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.glow-btn:hover {
|
||||||
|
animation: none;
|
||||||
|
box-shadow: 0 4px 20px var(--gold-glow) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Page Fade In ---- */
|
||||||
|
@keyframes fade-in-up {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(12px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.page-fade-in {
|
||||||
|
animation: fade-in-up 0.4s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Shimmer ---- */
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% { background-position: -200% center; }
|
||||||
|
100% { background-position: 200% center; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-shimmer {
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
var(--brown-dark) 0%,
|
||||||
|
var(--gold) 25%,
|
||||||
|
var(--brown-dark) 50%,
|
||||||
|
var(--gold) 75%,
|
||||||
|
var(--brown-dark) 100%
|
||||||
|
);
|
||||||
|
background-size: 200% auto;
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
animation: shimmer 4s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Sidebar Menu Items ---- */
|
||||||
|
.ant-layout-sider .ant-menu-item {
|
||||||
|
border-radius: 8px !important;
|
||||||
|
margin: 4px 8px !important;
|
||||||
|
transition: all 0.3s ease !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Ant Table Warm Override ---- */
|
||||||
|
.ant-table-thead > tr > th {
|
||||||
|
font-weight: 600 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Ant Tag rounded ---- */
|
||||||
|
.ant-tag {
|
||||||
|
border-radius: 6px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Tabs underline ---- */
|
||||||
|
.ant-tabs-tab {
|
||||||
|
transition: color 0.3s ease !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Tooltip warm ---- */
|
||||||
|
.ant-tooltip-inner {
|
||||||
|
border-radius: 8px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Background grain texture overlay (subtle) ---- */
|
||||||
|
.grain-overlay::before {
|
||||||
|
content: '';
|
||||||
|
position: fixed;
|
||||||
|
top: 0; left: 0; right: 0; bottom: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
opacity: 0.03;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E");
|
||||||
|
background-repeat: repeat;
|
||||||
|
background-size: 256px 256px;
|
||||||
|
}
|
||||||
@@ -1,9 +1,7 @@
|
|||||||
import React from 'react'
|
|
||||||
import ReactDOM from 'react-dom/client'
|
import ReactDOM from 'react-dom/client'
|
||||||
import App from './App'
|
import App from './App'
|
||||||
|
import './index.css'
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
<React.StrictMode>
|
|
||||||
<App />
|
<App />
|
||||||
</React.StrictMode>
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { Row, Col, Card, Table, DatePicker, Select, Input, Button, Space, Tag, Statistic, message, Tabs, Typography } from 'antd'
|
import { Row, Col, Card, Table, DatePicker, Select, Input, Button, Space, Tag, Statistic, message, Tabs, Typography, Tooltip } from 'antd'
|
||||||
import { DownloadOutlined, FileExcelOutlined, FilePdfOutlined, SearchOutlined, ReloadOutlined, BarChartOutlined } from '@ant-design/icons'
|
import { DownloadOutlined, FileExcelOutlined, FilePdfOutlined, SearchOutlined, ReloadOutlined, BarChartOutlined } from '@ant-design/icons'
|
||||||
import { Column } from '@ant-design/charts'
|
import { Column } from '@ant-design/charts'
|
||||||
import dayjs, { Dayjs } from 'dayjs'
|
import dayjs, { Dayjs } from 'dayjs'
|
||||||
@@ -10,6 +10,13 @@ import { quotaToUsd, formatTimestamp } from '@/utils/quota'
|
|||||||
const { RangePicker } = DatePicker
|
const { RangePicker } = DatePicker
|
||||||
const { Text, Title } = Typography
|
const { Text, Title } = Typography
|
||||||
|
|
||||||
|
// Parse the `other` JSON string field from new-api logs
|
||||||
|
function parseOther(log: any): any {
|
||||||
|
if (!log?.other) return {}
|
||||||
|
if (typeof log.other === 'object') return log.other
|
||||||
|
try { return JSON.parse(log.other) } catch { return {} }
|
||||||
|
}
|
||||||
|
|
||||||
export default function Billing() {
|
export default function Billing() {
|
||||||
// Log state
|
// Log state
|
||||||
const [logs, setLogs] = useState<any[]>([])
|
const [logs, setLogs] = useState<any[]>([])
|
||||||
@@ -20,11 +27,14 @@ export default function Billing() {
|
|||||||
|
|
||||||
// Filters
|
// Filters
|
||||||
const [dateRange, setDateRange] = useState<[Dayjs, Dayjs]>([
|
const [dateRange, setDateRange] = useState<[Dayjs, Dayjs]>([
|
||||||
dayjs().subtract(30, 'day'),
|
dayjs().subtract(30, 'day').startOf('day'),
|
||||||
dayjs(),
|
dayjs(),
|
||||||
])
|
])
|
||||||
const [modelFilter, setModelFilter] = useState('')
|
const [modelFilter, setModelFilter] = useState('')
|
||||||
const [tokenFilter, setTokenFilter] = useState('')
|
const [tokenFilter, setTokenFilter] = useState('')
|
||||||
|
const [groupFilter, setGroupFilter] = useState('')
|
||||||
|
const [requestIdFilter, setRequestIdFilter] = useState('')
|
||||||
|
const [typeFilter, setTypeFilter] = useState<number | ''>('')
|
||||||
|
|
||||||
// TopUp state
|
// TopUp state
|
||||||
const [topups, setTopups] = useState<any[]>([])
|
const [topups, setTopups] = useState<any[]>([])
|
||||||
@@ -35,25 +45,34 @@ export default function Billing() {
|
|||||||
// Chart data
|
// Chart data
|
||||||
const [chartData, setChartData] = useState<any[]>([])
|
const [chartData, setChartData] = useState<any[]>([])
|
||||||
|
|
||||||
|
// Aggregate stats
|
||||||
|
const [stats, setStats] = useState<{
|
||||||
|
totalRecords: number; totalQuota: number; totalTokens: number; modelCount: number
|
||||||
|
}>({ totalRecords: 0, totalQuota: 0, totalTokens: 0, modelCount: 0 })
|
||||||
|
const [statsLoading, setStatsLoading] = useState(false)
|
||||||
|
|
||||||
// Export loading
|
// Export loading
|
||||||
const [exportPdfLoading, setExportPdfLoading] = useState(false)
|
const [exportPdfLoading, setExportPdfLoading] = useState(false)
|
||||||
const [exportCsvLoading, setExportCsvLoading] = useState(false)
|
const [exportCsvLoading, setExportCsvLoading] = useState(false)
|
||||||
|
|
||||||
const [messageApi, contextHolder] = message.useMessage()
|
const [messageApi, contextHolder] = message.useMessage()
|
||||||
|
|
||||||
|
const getFilterParams = useCallback(() => {
|
||||||
|
const params: any = {}
|
||||||
|
if (dateRange[0]) params.start_timestamp = dateRange[0].unix()
|
||||||
|
if (dateRange[1]) params.end_timestamp = dateRange[1].unix()
|
||||||
|
if (typeFilter !== '') params.type = typeFilter
|
||||||
|
if (modelFilter) params.model_name = modelFilter
|
||||||
|
if (tokenFilter) params.token_name = tokenFilter
|
||||||
|
if (groupFilter) params.group = groupFilter
|
||||||
|
if (requestIdFilter) params.request_id = requestIdFilter
|
||||||
|
return params
|
||||||
|
}, [dateRange, typeFilter, modelFilter, tokenFilter, groupFilter, requestIdFilter])
|
||||||
|
|
||||||
const loadLogs = useCallback(async (page = 1, pageSize = 20) => {
|
const loadLogs = useCallback(async (page = 1, pageSize = 20) => {
|
||||||
setLogsLoading(true)
|
setLogsLoading(true)
|
||||||
try {
|
try {
|
||||||
const params: any = {
|
const params = { ...getFilterParams(), p: page, page_size: pageSize }
|
||||||
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)
|
const res = await dashboardApi.getLogs(params)
|
||||||
if (res.data.success) {
|
if (res.data.success) {
|
||||||
setLogs(res.data.data.items || [])
|
setLogs(res.data.data.items || [])
|
||||||
@@ -64,7 +83,7 @@ export default function Billing() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLogsLoading(false)
|
setLogsLoading(false)
|
||||||
}
|
}
|
||||||
}, [dateRange, modelFilter, tokenFilter])
|
}, [getFilterParams])
|
||||||
|
|
||||||
const loadTopups = useCallback(async (page = 1) => {
|
const loadTopups = useCallback(async (page = 1) => {
|
||||||
setTopupsLoading(true)
|
setTopupsLoading(true)
|
||||||
@@ -83,18 +102,10 @@ export default function Billing() {
|
|||||||
|
|
||||||
const loadChartData = useCallback(async () => {
|
const loadChartData = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const startTs = dateRange[0].startOf('day').unix()
|
const params = { ...getFilterParams(), page_size: 100, p: 1 }
|
||||||
const endTs = dateRange[1].endOf('day').unix()
|
const res = await dashboardApi.getLogs(params)
|
||||||
const res = await dashboardApi.getLogs({
|
|
||||||
start_timestamp: startTs,
|
|
||||||
end_timestamp: endTs,
|
|
||||||
type: 2,
|
|
||||||
page_size: 100,
|
|
||||||
p: 1,
|
|
||||||
})
|
|
||||||
if (res.data.success) {
|
if (res.data.success) {
|
||||||
const items = res.data.data.items || []
|
const items = res.data.data.items || []
|
||||||
// Aggregate by model
|
|
||||||
const modelMap = new Map<string, number>()
|
const modelMap = new Map<string, number>()
|
||||||
for (const log of items) {
|
for (const log of items) {
|
||||||
const model = log.model_name || 'unknown'
|
const model = log.model_name || 'unknown'
|
||||||
@@ -107,12 +118,31 @@ export default function Billing() {
|
|||||||
setChartData(data)
|
setChartData(data)
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
}, [dateRange])
|
}, [getFilterParams])
|
||||||
|
|
||||||
|
const loadStats = useCallback(async () => {
|
||||||
|
setStatsLoading(true)
|
||||||
|
try {
|
||||||
|
const fp = getFilterParams()
|
||||||
|
const res = await billingApi.stats({
|
||||||
|
startTimestamp: fp.start_timestamp,
|
||||||
|
endTimestamp: fp.end_timestamp,
|
||||||
|
type: fp.type,
|
||||||
|
modelName: fp.model_name,
|
||||||
|
tokenName: fp.token_name,
|
||||||
|
group: fp.group,
|
||||||
|
requestId: fp.request_id,
|
||||||
|
})
|
||||||
|
if (res.data.success) {
|
||||||
|
setStats(res.data.data)
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
setStatsLoading(false)
|
||||||
|
}, [getFilterParams])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadLogs(logPage, logPageSize)
|
loadLogs(logPage, logPageSize)
|
||||||
loadChartData()
|
}, [logPage, logPageSize])
|
||||||
}, [logPage, logPageSize, dateRange, modelFilter, tokenFilter])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadTopups(topupPage)
|
loadTopups(topupPage)
|
||||||
@@ -122,12 +152,19 @@ export default function Billing() {
|
|||||||
setLogPage(1)
|
setLogPage(1)
|
||||||
loadLogs(1, logPageSize)
|
loadLogs(1, logPageSize)
|
||||||
loadChartData()
|
loadChartData()
|
||||||
|
loadStats()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initial load
|
||||||
|
useEffect(() => { handleSearch() }, [])
|
||||||
|
|
||||||
const handleReset = () => {
|
const handleReset = () => {
|
||||||
setDateRange([dayjs().subtract(30, 'day'), dayjs()])
|
setDateRange([dayjs().startOf('day'), dayjs()])
|
||||||
setModelFilter('')
|
setModelFilter('')
|
||||||
setTokenFilter('')
|
setTokenFilter('')
|
||||||
|
setGroupFilter('')
|
||||||
|
setRequestIdFilter('')
|
||||||
|
setTypeFilter('')
|
||||||
setLogPage(1)
|
setLogPage(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,15 +174,25 @@ export default function Billing() {
|
|||||||
const startDate = dateRange[0].format('YYYY-MM-DD')
|
const startDate = dateRange[0].format('YYYY-MM-DD')
|
||||||
const endDate = dateRange[1].format('YYYY-MM-DD')
|
const endDate = dateRange[1].format('YYYY-MM-DD')
|
||||||
const res = await billingApi.exportPdf(startDate, endDate)
|
const res = await billingApi.exportPdf(startDate, endDate)
|
||||||
const url = window.URL.createObjectURL(new Blob([res.data]))
|
// Check if response is actually an error JSON (blob)
|
||||||
|
if (res.data instanceof Blob && res.data.type === 'application/json') {
|
||||||
|
const text = await res.data.text()
|
||||||
|
const json = JSON.parse(text)
|
||||||
|
messageApi.error(json.message || '导出 PDF 失败')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const url = window.URL.createObjectURL(new Blob([res.data], { type: 'application/pdf' }))
|
||||||
const link = document.createElement('a')
|
const link = document.createElement('a')
|
||||||
link.href = url
|
link.href = url
|
||||||
link.download = `billing_${startDate}_${endDate}.pdf`
|
link.download = `billing_${startDate}_${endDate}_${dayjs().format('HHmmss')}.pdf`
|
||||||
link.click()
|
link.click()
|
||||||
window.URL.revokeObjectURL(url)
|
window.URL.revokeObjectURL(url)
|
||||||
messageApi.success('PDF 报表已下载')
|
messageApi.success('PDF 报表已下载')
|
||||||
} catch {
|
} catch (err: any) {
|
||||||
messageApi.error('导出 PDF 失败')
|
const msg = err?.response?.data instanceof Blob
|
||||||
|
? await err.response.data.text().then((t: string) => { try { return JSON.parse(t).message } catch { return t } })
|
||||||
|
: err?.message || '导出 PDF 失败'
|
||||||
|
messageApi.error(msg)
|
||||||
} finally {
|
} finally {
|
||||||
setExportPdfLoading(false)
|
setExportPdfLoading(false)
|
||||||
}
|
}
|
||||||
@@ -157,15 +204,24 @@ export default function Billing() {
|
|||||||
const startDate = dateRange[0].format('YYYY-MM-DD')
|
const startDate = dateRange[0].format('YYYY-MM-DD')
|
||||||
const endDate = dateRange[1].format('YYYY-MM-DD')
|
const endDate = dateRange[1].format('YYYY-MM-DD')
|
||||||
const res = await billingApi.exportCsv(startDate, endDate)
|
const res = await billingApi.exportCsv(startDate, endDate)
|
||||||
const url = window.URL.createObjectURL(new Blob([res.data]))
|
if (res.data instanceof Blob && res.data.type === 'application/json') {
|
||||||
|
const text = await res.data.text()
|
||||||
|
const json = JSON.parse(text)
|
||||||
|
messageApi.error(json.message || '导出 CSV 失败')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const url = window.URL.createObjectURL(new Blob([res.data], { type: 'text/csv' }))
|
||||||
const link = document.createElement('a')
|
const link = document.createElement('a')
|
||||||
link.href = url
|
link.href = url
|
||||||
link.download = `billing_${startDate}_${endDate}.csv`
|
link.download = `billing_${startDate}_${endDate}_${dayjs().format('HHmmss')}.csv`
|
||||||
link.click()
|
link.click()
|
||||||
window.URL.revokeObjectURL(url)
|
window.URL.revokeObjectURL(url)
|
||||||
messageApi.success('CSV 已下载')
|
messageApi.success('CSV 已下载')
|
||||||
} catch {
|
} catch (err: any) {
|
||||||
messageApi.error('导出 CSV 失败')
|
const msg = err?.response?.data instanceof Blob
|
||||||
|
? await err.response.data.text().then((t: string) => { try { return JSON.parse(t).message } catch { return t } })
|
||||||
|
: err?.message || '导出 CSV 失败'
|
||||||
|
messageApi.error(msg)
|
||||||
} finally {
|
} finally {
|
||||||
setExportCsvLoading(false)
|
setExportCsvLoading(false)
|
||||||
}
|
}
|
||||||
@@ -180,6 +236,36 @@ export default function Billing() {
|
|||||||
render: (v: number) => formatTimestamp(v),
|
render: (v: number) => formatTimestamp(v),
|
||||||
sorter: (a: any, b: any) => a.created_at - b.created_at,
|
sorter: (a: any, b: any) => a.created_at - b.created_at,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: '令牌',
|
||||||
|
dataIndex: 'token_name',
|
||||||
|
key: 'token',
|
||||||
|
ellipsis: true,
|
||||||
|
width: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '分组',
|
||||||
|
dataIndex: 'group',
|
||||||
|
key: 'group',
|
||||||
|
ellipsis: true,
|
||||||
|
width: 90,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '类型',
|
||||||
|
dataIndex: 'type',
|
||||||
|
key: 'type',
|
||||||
|
width: 80,
|
||||||
|
render: (v: number) => {
|
||||||
|
const map: Record<number, { color: string; text: string }> = {
|
||||||
|
1: { color: 'blue', text: '充值' },
|
||||||
|
2: { color: 'green', text: '消费' },
|
||||||
|
3: { color: 'orange', text: '管理' },
|
||||||
|
4: { color: 'red', text: '系统' },
|
||||||
|
}
|
||||||
|
const info = map[v] || { color: 'default', text: String(v) }
|
||||||
|
return <Tag color={info.color}>{info.text}</Tag>
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '模型',
|
title: '模型',
|
||||||
dataIndex: 'model_name',
|
dataIndex: 'model_name',
|
||||||
@@ -187,35 +273,6 @@ export default function Billing() {
|
|||||||
ellipsis: true,
|
ellipsis: true,
|
||||||
width: 200,
|
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: '流式',
|
title: '流式',
|
||||||
dataIndex: 'is_stream',
|
dataIndex: 'is_stream',
|
||||||
@@ -224,11 +281,75 @@ export default function Billing() {
|
|||||||
render: (v: boolean) => v ? <Tag color="blue">是</Tag> : <Tag>否</Tag>,
|
render: (v: boolean) => v ? <Tag color="blue">是</Tag> : <Tag>否</Tag>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '请求 ID',
|
title: '用时/首字',
|
||||||
|
key: 'timing',
|
||||||
|
width: 120,
|
||||||
|
render: (_: any, log: any) => {
|
||||||
|
const other = parseOther(log)
|
||||||
|
const useTime = log.use_time || 0
|
||||||
|
const frt = other.frt || 0
|
||||||
|
return (
|
||||||
|
<Tooltip title={`总用时: ${useTime}s / 首字: ${frt ? (frt / 1000).toFixed(2) + 's' : '-'}`}>
|
||||||
|
<span>{useTime}s / {frt ? Math.round(frt) + 'ms' : '-'}</span>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '输入',
|
||||||
|
key: 'input',
|
||||||
|
width: 160,
|
||||||
|
render: (_: any, log: any) => {
|
||||||
|
const other = parseOther(log)
|
||||||
|
const prompt = log.prompt_tokens || 0
|
||||||
|
const cacheHit = other.cache_tokens || 0
|
||||||
|
const cacheCreate = other.cache_creation_tokens || 0
|
||||||
|
const parts: string[] = [`提示: ${prompt.toLocaleString()}`]
|
||||||
|
if (cacheHit > 0) parts.push(`缓存命中: ${cacheHit.toLocaleString()}`)
|
||||||
|
if (cacheCreate > 0) parts.push(`缓存创建: ${cacheCreate.toLocaleString()}`)
|
||||||
|
return (cacheHit > 0 || cacheCreate > 0)
|
||||||
|
? <Tooltip title={parts.join(' / ')}>
|
||||||
|
<span>
|
||||||
|
{prompt.toLocaleString()}
|
||||||
|
{cacheHit > 0 && <Text type="success" style={{ fontSize: 12 }}> ({cacheHit.toLocaleString()})</Text>}
|
||||||
|
{cacheCreate > 0 && <Text type="warning" style={{ fontSize: 12 }}> [+{cacheCreate.toLocaleString()}]</Text>}
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
: <span>{prompt.toLocaleString()}</span>
|
||||||
|
},
|
||||||
|
sorter: (a: any, b: any) => (a.prompt_tokens || 0) - (b.prompt_tokens || 0),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '输出',
|
||||||
|
dataIndex: 'completion_tokens',
|
||||||
|
key: 'output',
|
||||||
|
width: 90,
|
||||||
|
render: (v: number) => (v || 0).toLocaleString(),
|
||||||
|
sorter: (a: any, b: any) => (a.completion_tokens || 0) - (b.completion_tokens || 0),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '花费',
|
||||||
|
dataIndex: 'quota',
|
||||||
|
key: 'quota',
|
||||||
|
width: 100,
|
||||||
|
render: (v: number) => `$${quotaToUsd(v)}`,
|
||||||
|
sorter: (a: any, b: any) => a.quota - b.quota,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '详情',
|
||||||
dataIndex: 'request_id',
|
dataIndex: 'request_id',
|
||||||
key: 'request_id',
|
key: 'detail',
|
||||||
width: 140,
|
width: 200,
|
||||||
ellipsis: true,
|
ellipsis: true,
|
||||||
|
render: (v: string, log: any) => {
|
||||||
|
const other = parseOther(log)
|
||||||
|
const parts: string[] = []
|
||||||
|
if (v) parts.push(`ID: ${v}`)
|
||||||
|
if (other.admin_info) parts.push(other.admin_info)
|
||||||
|
return parts.length > 0
|
||||||
|
? <Tooltip title={parts.join('\n')}><span>{v || '-'}</span></Tooltip>
|
||||||
|
: '-'
|
||||||
|
},
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -293,12 +414,10 @@ export default function Billing() {
|
|||||||
x: { labelAutoRotate: true },
|
x: { labelAutoRotate: true },
|
||||||
y: { title: 'USD' },
|
y: { title: 'USD' },
|
||||||
},
|
},
|
||||||
|
scale: { color: { range: ['#C8956C', '#7DB87D', '#7BA4C8', '#E8A850', '#D4645C', '#A69278', '#B8A07D', '#8BB8A4', '#C8A87B', '#9B8EC2'] } },
|
||||||
height: 300,
|
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 (
|
return (
|
||||||
<Space direction="vertical" size={24} style={{ width: '100%' }}>
|
<Space direction="vertical" size={24} style={{ width: '100%' }}>
|
||||||
@@ -306,31 +425,67 @@ export default function Billing() {
|
|||||||
|
|
||||||
{/* Filter Bar */}
|
{/* Filter Bar */}
|
||||||
<Card>
|
<Card>
|
||||||
|
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
||||||
<Space wrap size="middle">
|
<Space wrap size="middle">
|
||||||
<RangePicker
|
<RangePicker
|
||||||
|
showTime
|
||||||
value={dateRange}
|
value={dateRange}
|
||||||
onChange={(dates) => {
|
onChange={(dates) => {
|
||||||
if (dates && dates[0] && dates[1]) {
|
if (dates && dates[0] && dates[1]) {
|
||||||
setDateRange([dates[0], dates[1]])
|
setDateRange([dates[0], dates[1]])
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
format="YYYY-MM-DD"
|
format="YYYY-MM-DD HH:mm:ss"
|
||||||
/>
|
style={{ width: 420 }}
|
||||||
<Input
|
|
||||||
placeholder="模型名称"
|
|
||||||
value={modelFilter}
|
|
||||||
onChange={(e) => setModelFilter(e.target.value)}
|
|
||||||
style={{ width: 200 }}
|
|
||||||
allowClear
|
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
|
prefix={<SearchOutlined />}
|
||||||
placeholder="令牌名称"
|
placeholder="令牌名称"
|
||||||
value={tokenFilter}
|
value={tokenFilter}
|
||||||
onChange={(e) => setTokenFilter(e.target.value)}
|
onChange={(e) => setTokenFilter(e.target.value)}
|
||||||
style={{ width: 160 }}
|
style={{ width: 160 }}
|
||||||
allowClear
|
allowClear
|
||||||
/>
|
/>
|
||||||
<Button type="primary" icon={<SearchOutlined />} onClick={handleSearch}>搜索</Button>
|
<Input
|
||||||
|
prefix={<SearchOutlined />}
|
||||||
|
placeholder="模型名称"
|
||||||
|
value={modelFilter}
|
||||||
|
onChange={(e) => setModelFilter(e.target.value)}
|
||||||
|
style={{ width: 200 }}
|
||||||
|
allowClear
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
<Space wrap size="middle">
|
||||||
|
<Input
|
||||||
|
prefix={<SearchOutlined />}
|
||||||
|
placeholder="分组"
|
||||||
|
value={groupFilter}
|
||||||
|
onChange={(e) => setGroupFilter(e.target.value)}
|
||||||
|
style={{ width: 160 }}
|
||||||
|
allowClear
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
prefix={<SearchOutlined />}
|
||||||
|
placeholder="Request ID"
|
||||||
|
value={requestIdFilter}
|
||||||
|
onChange={(e) => setRequestIdFilter(e.target.value)}
|
||||||
|
style={{ width: 260 }}
|
||||||
|
allowClear
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
value={typeFilter}
|
||||||
|
onChange={(v) => setTypeFilter(v)}
|
||||||
|
style={{ width: 100 }}
|
||||||
|
options={[
|
||||||
|
{ value: '', label: '全部' },
|
||||||
|
{ value: 1, label: '充值' },
|
||||||
|
{ value: 2, label: '消费' },
|
||||||
|
{ value: 3, label: '管理' },
|
||||||
|
{ value: 4, label: '系统' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<div style={{ flex: 1 }} />
|
||||||
|
<Button type="primary" icon={<SearchOutlined />} onClick={handleSearch}>查询</Button>
|
||||||
<Button icon={<ReloadOutlined />} onClick={handleReset}>重置</Button>
|
<Button icon={<ReloadOutlined />} onClick={handleReset}>重置</Button>
|
||||||
<Button icon={<FilePdfOutlined />} loading={exportPdfLoading} onClick={handleExportPdf}>
|
<Button icon={<FilePdfOutlined />} loading={exportPdfLoading} onClick={handleExportPdf}>
|
||||||
导出 PDF
|
导出 PDF
|
||||||
@@ -339,28 +494,29 @@ export default function Billing() {
|
|||||||
导出 CSV
|
导出 CSV
|
||||||
</Button>
|
</Button>
|
||||||
</Space>
|
</Space>
|
||||||
|
</Space>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Summary Stats */}
|
{/* Summary Stats */}
|
||||||
<Row gutter={[16, 16]}>
|
<Row gutter={[16, 16]}>
|
||||||
<Col xs={12} md={6}>
|
<Col xs={12} md={6}>
|
||||||
<Card hoverable>
|
<Card hoverable className="stat-accent">
|
||||||
<Statistic title="当页消耗总额" value={quotaToUsd(totalQuota)} prefix="$" valueStyle={{ color: '#1677ff' }} />
|
<Statistic title="总消耗" value={quotaToUsd(stats.totalQuota)} prefix="$" valueStyle={{ color: '#C8956C', fontWeight: 600 }} loading={statsLoading} />
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={12} md={6}>
|
<Col xs={12} md={6}>
|
||||||
<Card hoverable>
|
<Card hoverable className="stat-accent">
|
||||||
<Statistic title="总记录数" value={logTotal} />
|
<Statistic title="总记录数" value={stats.totalRecords} valueStyle={{ fontWeight: 600 }} loading={statsLoading} />
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={12} md={6}>
|
<Col xs={12} md={6}>
|
||||||
<Card hoverable>
|
<Card hoverable className="stat-accent">
|
||||||
<Statistic title="当页 Tokens 总量" value={totalTokens.toLocaleString()} />
|
<Statistic title="总 Tokens" value={stats.totalTokens.toLocaleString()} valueStyle={{ fontWeight: 600 }} loading={statsLoading} />
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={12} md={6}>
|
<Col xs={12} md={6}>
|
||||||
<Card hoverable>
|
<Card hoverable className="stat-accent">
|
||||||
<Statistic title="充值记录数" value={topupTotal} />
|
<Statistic title="模型数" value={stats.modelCount} valueStyle={{ fontWeight: 600 }} loading={statsLoading} />
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
@@ -383,13 +539,15 @@ export default function Billing() {
|
|||||||
pageSize: logPageSize,
|
pageSize: logPageSize,
|
||||||
total: logTotal,
|
total: logTotal,
|
||||||
showSizeChanger: true,
|
showSizeChanger: true,
|
||||||
|
pageSizeOptions: [20, 50, 100],
|
||||||
showTotal: (total) => `共 ${total} 条`,
|
showTotal: (total) => `共 ${total} 条`,
|
||||||
|
showQuickJumper: true,
|
||||||
onChange: (page, size) => {
|
onChange: (page, size) => {
|
||||||
setLogPage(page)
|
setLogPage(page)
|
||||||
setLogPageSize(size)
|
setLogPageSize(size)
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
scroll={{ x: 1200 }}
|
scroll={{ x: 1400 }}
|
||||||
size="middle"
|
size="middle"
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
} from '@ant-design/icons'
|
} from '@ant-design/icons'
|
||||||
import { useAuthStore } from '@/store/authStore'
|
import { useAuthStore } from '@/store/authStore'
|
||||||
import { dashboardApi } from '@/api/dashboard'
|
import { dashboardApi } from '@/api/dashboard'
|
||||||
|
import { billingApi } from '@/api/billing'
|
||||||
import { getRoleName } from '@/utils/quota'
|
import { getRoleName } from '@/utils/quota'
|
||||||
import QuotaCard from '@/components/QuotaCard'
|
import QuotaCard from '@/components/QuotaCard'
|
||||||
import UsageChart from '@/components/UsageChart'
|
import UsageChart from '@/components/UsageChart'
|
||||||
@@ -19,6 +20,9 @@ export default function Dashboard() {
|
|||||||
const [tokens, setTokens] = useState<any[]>([])
|
const [tokens, setTokens] = useState<any[]>([])
|
||||||
const [logsLoading, setLogsLoading] = useState(true)
|
const [logsLoading, setLogsLoading] = useState(true)
|
||||||
const [tokensLoading, setTokensLoading] = useState(true)
|
const [tokensLoading, setTokensLoading] = useState(true)
|
||||||
|
const [chartDaily, setChartDaily] = useState<{ date: string; count: number; quota: number }[]>([])
|
||||||
|
const [chartModels, setChartModels] = useState<{ name: string; value: number }[]>([])
|
||||||
|
const [chartLoading, setChartLoading] = useState(true)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadData()
|
loadData()
|
||||||
@@ -33,7 +37,18 @@ export default function Dashboard() {
|
|||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
// Load recent logs (last 7 days, type=2 consume)
|
// Load chart data (server-side aggregation of all 7-day logs)
|
||||||
|
try {
|
||||||
|
const chartRes = await billingApi.chartData()
|
||||||
|
if (chartRes.data.success) {
|
||||||
|
setChartDaily(chartRes.data.data.daily || [])
|
||||||
|
setChartModels(chartRes.data.data.models || [])
|
||||||
|
}
|
||||||
|
} catch {} finally {
|
||||||
|
setChartLoading(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load recent logs (first page for the table)
|
||||||
try {
|
try {
|
||||||
const sevenDaysAgo = Math.floor((Date.now() - 7 * 24 * 60 * 60 * 1000) / 1000)
|
const sevenDaysAgo = Math.floor((Date.now() - 7 * 24 * 60 * 60 * 1000) / 1000)
|
||||||
const logRes = await dashboardApi.getLogs({
|
const logRes = await dashboardApi.getLogs({
|
||||||
@@ -68,42 +83,42 @@ export default function Dashboard() {
|
|||||||
{/* User Info Cards */}
|
{/* User Info Cards */}
|
||||||
<Row gutter={[16, 16]}>
|
<Row gutter={[16, 16]}>
|
||||||
<Col xs={24} sm={12} md={6}>
|
<Col xs={24} sm={12} md={6}>
|
||||||
<Card hoverable>
|
<Card hoverable className="stat-accent">
|
||||||
<Statistic
|
<Statistic
|
||||||
title="用户名"
|
title="用户名"
|
||||||
value={userInfo.display_name || userInfo.username}
|
value={userInfo.display_name || userInfo.username}
|
||||||
prefix={<UserOutlined />}
|
prefix={<UserOutlined style={{ color: '#C8956C' }} />}
|
||||||
valueStyle={{ fontSize: 18 }}
|
valueStyle={{ fontSize: 18, fontWeight: 600 }}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={24} sm={12} md={6}>
|
<Col xs={24} sm={12} md={6}>
|
||||||
<Card hoverable>
|
<Card hoverable className="stat-accent">
|
||||||
<Statistic
|
<Statistic
|
||||||
title="角色"
|
title="角色"
|
||||||
value={getRoleName(userInfo.role)}
|
value={getRoleName(userInfo.role)}
|
||||||
prefix={<TeamOutlined />}
|
prefix={<TeamOutlined style={{ color: '#7BA4C8' }} />}
|
||||||
valueStyle={{ fontSize: 18 }}
|
valueStyle={{ fontSize: 18, fontWeight: 600 }}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={24} sm={12} md={6}>
|
<Col xs={24} sm={12} md={6}>
|
||||||
<Card hoverable>
|
<Card hoverable className="stat-accent">
|
||||||
<Statistic
|
<Statistic
|
||||||
title="请求次数"
|
title="请求次数"
|
||||||
value={userInfo.request_count}
|
value={userInfo.request_count}
|
||||||
prefix={<ApiOutlined />}
|
prefix={<ApiOutlined style={{ color: '#7DB87D' }} />}
|
||||||
valueStyle={{ fontSize: 18, color: '#1677ff' }}
|
valueStyle={{ fontSize: 18, color: '#C8956C', fontWeight: 600 }}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={24} sm={12} md={6}>
|
<Col xs={24} sm={12} md={6}>
|
||||||
<Card hoverable>
|
<Card hoverable className="stat-accent">
|
||||||
<Statistic
|
<Statistic
|
||||||
title="分组"
|
title="分组"
|
||||||
value={userInfo.group || 'default'}
|
value={userInfo.group || 'default'}
|
||||||
prefix={<ThunderboltOutlined />}
|
prefix={<ThunderboltOutlined style={{ color: '#E8A850' }} />}
|
||||||
valueStyle={{ fontSize: 18 }}
|
valueStyle={{ fontSize: 18, fontWeight: 600 }}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
@@ -115,14 +130,14 @@ export default function Dashboard() {
|
|||||||
<QuotaCard quota={userInfo.quota} usedQuota={userInfo.used_quota} />
|
<QuotaCard quota={userInfo.quota} usedQuota={userInfo.used_quota} />
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={24} lg={14}>
|
<Col xs={24} lg={14}>
|
||||||
<UsageChart logs={logs.filter(l => l.type === 2)} />
|
<UsageChart daily={chartDaily} loading={chartLoading} />
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
{/* Model Pie + Token Overview */}
|
{/* Model Pie + Token Overview */}
|
||||||
<Row gutter={[16, 16]}>
|
<Row gutter={[16, 16]}>
|
||||||
<Col xs={24} lg={10}>
|
<Col xs={24} lg={10}>
|
||||||
<ModelPieChart logs={logs.filter(l => l.type === 2)} />
|
<ModelPieChart models={chartModels} loading={chartLoading} />
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={24} lg={14}>
|
<Col xs={24} lg={14}>
|
||||||
<TokenOverview tokens={tokens} loading={tokensLoading} />
|
<TokenOverview tokens={tokens} loading={tokensLoading} />
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { Card, Form, Input, Button, Select, Typography, Space, message, Divider } from 'antd'
|
import { Form, Input, Button, Select, Typography, Space, message, Divider } from 'antd'
|
||||||
import { UserOutlined, KeyOutlined, GlobalOutlined, LoginOutlined } from '@ant-design/icons'
|
import { UserOutlined, KeyOutlined, GlobalOutlined, LoginOutlined } from '@ant-design/icons'
|
||||||
import { sitesApi } from '@/api/sites'
|
import { sitesApi } from '@/api/sites'
|
||||||
import { authApi } from '@/api/auth'
|
import { authApi } from '@/api/auth'
|
||||||
@@ -14,6 +14,24 @@ interface Site {
|
|||||||
url: string
|
url: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Generate stable particle configs
|
||||||
|
function makeParticles(count: number) {
|
||||||
|
const particles: { left: string; bottom: string; w: number; dur: number; delay: number }[] = []
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const seed = (i * 7919 + 104729) % 100
|
||||||
|
particles.push({
|
||||||
|
left: `${(seed * 37) % 100}%`,
|
||||||
|
bottom: `${-((seed * 13) % 10)}%`,
|
||||||
|
w: 3 + (seed % 9),
|
||||||
|
dur: 18 + (seed % 25),
|
||||||
|
delay: (seed % 18),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return particles
|
||||||
|
}
|
||||||
|
|
||||||
|
const PARTICLES = makeParticles(25)
|
||||||
|
|
||||||
export default function Login() {
|
export default function Login() {
|
||||||
const [form] = Form.useForm()
|
const [form] = Form.useForm()
|
||||||
const [sites, setSites] = useState<Site[]>([])
|
const [sites, setSites] = useState<Site[]>([])
|
||||||
@@ -68,52 +86,98 @@ export default function Login() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div className="login-bg" style={{
|
||||||
minHeight: '100vh',
|
minHeight: '100vh',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
position: 'relative',
|
||||||
justifyContent: 'center',
|
overflow: 'hidden',
|
||||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
|
||||||
padding: 24,
|
|
||||||
}}>
|
}}>
|
||||||
{contextHolder}
|
{contextHolder}
|
||||||
|
|
||||||
{/* Background decoration */}
|
{/* ===== LEFT: Branding Panel ===== */}
|
||||||
<div style={{
|
<div style={{
|
||||||
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
|
flex: '0 0 45%',
|
||||||
overflow: 'hidden', pointerEvents: 'none', zIndex: 0,
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
position: 'relative',
|
||||||
|
padding: '60px 48px',
|
||||||
|
overflow: 'hidden',
|
||||||
}}>
|
}}>
|
||||||
|
{/* Particles on left panel */}
|
||||||
|
<div style={{ position: 'absolute', inset: 0, overflow: 'hidden', pointerEvents: 'none' }}>
|
||||||
|
{PARTICLES.map((p, i) => (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
className="login-particle"
|
||||||
|
style={{
|
||||||
|
left: p.left,
|
||||||
|
bottom: p.bottom,
|
||||||
|
width: p.w,
|
||||||
|
height: p.w,
|
||||||
|
animationDuration: `${p.dur}s`,
|
||||||
|
animationDelay: `${p.delay}s`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Decorative blobs */}
|
||||||
<div style={{
|
<div style={{
|
||||||
position: 'absolute', width: 400, height: 400, borderRadius: '50%',
|
position: 'absolute', width: 400, height: 400, borderRadius: '50%',
|
||||||
background: 'rgba(255,255,255,0.05)', top: '-10%', right: '-5%',
|
background: 'radial-gradient(circle, rgba(255,255,255,0.1), transparent 70%)',
|
||||||
|
top: '-10%', right: '-15%',
|
||||||
}} />
|
}} />
|
||||||
<div style={{
|
<div style={{
|
||||||
position: 'absolute', width: 300, height: 300, borderRadius: '50%',
|
position: 'absolute', width: 300, height: 300, borderRadius: '50%',
|
||||||
background: 'rgba(255,255,255,0.05)', bottom: '-5%', left: '-5%',
|
background: 'radial-gradient(circle, rgba(255,220,160,0.12), transparent 70%)',
|
||||||
|
bottom: '5%', left: '-10%',
|
||||||
}} />
|
}} />
|
||||||
|
|
||||||
|
{/* Logo & Title */}
|
||||||
|
<div style={{ position: 'relative', zIndex: 1, textAlign: 'center' }}>
|
||||||
<div style={{
|
<div style={{
|
||||||
position: 'absolute', width: 200, height: 200, borderRadius: '50%',
|
width: 110, height: 110, borderRadius: 32, margin: '0 auto 36px',
|
||||||
background: 'rgba(255,255,255,0.08)', top: '40%', left: '20%',
|
background: 'linear-gradient(135deg, #C8956C, #E8B88A)',
|
||||||
}} />
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
boxShadow: '0 12px 40px rgba(200, 149, 108, 0.4)',
|
||||||
|
}}>
|
||||||
|
<span style={{ fontSize: 56, color: '#fff', fontWeight: 700 }}>林</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card
|
<Title level={1} style={{ margin: '0 0 16px', color: '#fff' }}>
|
||||||
style={{
|
<span className="text-shimmer" style={{ fontWeight: 700, fontSize: 38 }}>
|
||||||
width: 420,
|
小林子的服务平台
|
||||||
borderRadius: 16,
|
</span>
|
||||||
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>
|
</Title>
|
||||||
<Text type="secondary">AI API 网关管理面板</Text>
|
|
||||||
</Space>
|
<Paragraph style={{ color: 'rgba(255,255,255,0.7)', fontSize: 18, marginBottom: 0, maxWidth: 380 }}>
|
||||||
|
AI API 网关管理面板
|
||||||
|
</Paragraph>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ===== RIGHT: Login Form ===== */}
|
||||||
|
<div style={{
|
||||||
|
flex: 1,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: '40px 48px',
|
||||||
|
background: 'rgba(255, 253, 247, 0.92)',
|
||||||
|
backdropFilter: 'blur(20px)',
|
||||||
|
borderLeft: '1px solid rgba(200, 149, 108, 0.15)',
|
||||||
|
}}>
|
||||||
|
<div style={{ width: '100%', maxWidth: 420 }}>
|
||||||
|
<div style={{ marginBottom: 40 }}>
|
||||||
|
<Title level={3} style={{ margin: '0 0 8px', color: '#3D2E1C' }}>
|
||||||
|
欢迎登录
|
||||||
|
</Title>
|
||||||
|
<Text style={{ color: '#8B7355', fontSize: 14 }}>
|
||||||
|
请输入您的凭据以访问管理面板
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Form
|
<Form
|
||||||
form={form}
|
form={form}
|
||||||
@@ -124,7 +188,7 @@ export default function Login() {
|
|||||||
>
|
>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="siteId"
|
name="siteId"
|
||||||
label={<Space><GlobalOutlined /> 选择站点</Space>}
|
label={<Space><GlobalOutlined style={{ color: '#C8956C' }} /> 选择站点</Space>}
|
||||||
rules={[{ required: true, message: '请选择站点' }]}
|
rules={[{ required: true, message: '请选择站点' }]}
|
||||||
>
|
>
|
||||||
<Select
|
<Select
|
||||||
@@ -143,11 +207,11 @@ export default function Login() {
|
|||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
<Divider style={{ margin: '16px 0' }} />
|
<Divider style={{ margin: '20px 0', borderColor: 'rgba(180, 150, 100, 0.15)' }} />
|
||||||
|
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="userId"
|
name="userId"
|
||||||
label={<Space><UserOutlined /> 用户 ID</Space>}
|
label={<Space><UserOutlined style={{ color: '#C8956C' }} /> 用户 ID</Space>}
|
||||||
rules={[
|
rules={[
|
||||||
{ required: true, message: '请输入用户 ID' },
|
{ required: true, message: '请输入用户 ID' },
|
||||||
{ pattern: /^\d+$/, message: '用户 ID 必须是数字' },
|
{ pattern: /^\d+$/, message: '用户 ID 必须是数字' },
|
||||||
@@ -158,33 +222,39 @@ export default function Login() {
|
|||||||
|
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="accessToken"
|
name="accessToken"
|
||||||
label={<Space><KeyOutlined /> 系统令牌 (Access Token)</Space>}
|
label={<Space><KeyOutlined style={{ color: '#C8956C' }} /> 系统令牌 (Access Token)</Space>}
|
||||||
rules={[{ required: true, message: '请输入系统令牌' }]}
|
rules={[{ required: true, message: '请输入系统令牌' }]}
|
||||||
>
|
>
|
||||||
<Input.Password placeholder="请输入您的 Access Token" autoComplete="current-password" />
|
<Input.Password placeholder="请输入您的 Access Token" autoComplete="current-password" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
<Form.Item style={{ marginBottom: 16 }}>
|
<Form.Item style={{ marginBottom: 20, marginTop: 32 }}>
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
htmlType="submit"
|
htmlType="submit"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
icon={<LoginOutlined />}
|
icon={<LoginOutlined />}
|
||||||
block
|
block
|
||||||
style={{ height: 44, borderRadius: 8, fontWeight: 600, fontSize: 15 }}
|
className="glow-btn"
|
||||||
|
style={{
|
||||||
|
height: 48,
|
||||||
|
borderRadius: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: 15,
|
||||||
|
border: 'none',
|
||||||
|
background: 'linear-gradient(135deg, #C8956C, #D4A57E)',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
登 录
|
登 录
|
||||||
</Button>
|
</Button>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
|
|
||||||
<Paragraph
|
<Paragraph style={{ textAlign: 'center', fontSize: 12, marginBottom: 0, color: '#A69278' }}>
|
||||||
type="secondary"
|
|
||||||
style={{ textAlign: 'center', fontSize: 12, marginBottom: 0 }}
|
|
||||||
>
|
|
||||||
请在管理面板获取您的用户 ID 和系统令牌
|
请在管理面板获取您的用户 ID 和系统令牌
|
||||||
</Paragraph>
|
</Paragraph>
|
||||||
</Card>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,11 +26,13 @@ interface AuthState {
|
|||||||
userInfo: UserInfo | null
|
userInfo: UserInfo | null
|
||||||
site: SiteInfo | null
|
site: SiteInfo | null
|
||||||
isLoggedIn: boolean
|
isLoggedIn: boolean
|
||||||
|
isAdmin: boolean
|
||||||
loading: boolean
|
loading: boolean
|
||||||
login: (sessionToken: string, userInfo: UserInfo, site: SiteInfo) => void
|
login: (sessionToken: string, userInfo: UserInfo, site: SiteInfo) => void
|
||||||
logout: () => void
|
logout: () => void
|
||||||
updateUserInfo: (userInfo: UserInfo) => void
|
updateUserInfo: (userInfo: UserInfo) => void
|
||||||
setLoading: (loading: boolean) => void
|
setLoading: (loading: boolean) => void
|
||||||
|
setAdmin: (isAdmin: boolean) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useAuthStore = create<AuthState>((set) => ({
|
export const useAuthStore = create<AuthState>((set) => ({
|
||||||
@@ -38,6 +40,7 @@ export const useAuthStore = create<AuthState>((set) => ({
|
|||||||
userInfo: null,
|
userInfo: null,
|
||||||
site: null,
|
site: null,
|
||||||
isLoggedIn: !!localStorage.getItem('sessionToken'),
|
isLoggedIn: !!localStorage.getItem('sessionToken'),
|
||||||
|
isAdmin: false,
|
||||||
loading: true,
|
loading: true,
|
||||||
login: (sessionToken, userInfo, site) => {
|
login: (sessionToken, userInfo, site) => {
|
||||||
localStorage.setItem('sessionToken', sessionToken)
|
localStorage.setItem('sessionToken', sessionToken)
|
||||||
@@ -45,8 +48,9 @@ export const useAuthStore = create<AuthState>((set) => ({
|
|||||||
},
|
},
|
||||||
logout: () => {
|
logout: () => {
|
||||||
localStorage.removeItem('sessionToken')
|
localStorage.removeItem('sessionToken')
|
||||||
set({ sessionToken: null, userInfo: null, site: null, isLoggedIn: false, loading: false })
|
set({ sessionToken: null, userInfo: null, site: null, isLoggedIn: false, isAdmin: false, loading: false })
|
||||||
},
|
},
|
||||||
updateUserInfo: (userInfo) => set({ userInfo }),
|
updateUserInfo: (userInfo) => set({ userInfo }),
|
||||||
setLoading: (loading) => set({ loading }),
|
setLoading: (loading) => set({ loading }),
|
||||||
|
setAdmin: (isAdmin) => set({ isAdmin }),
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -2,19 +2,91 @@ import type { ThemeConfig } from 'antd'
|
|||||||
|
|
||||||
export const lightTheme: ThemeConfig = {
|
export const lightTheme: ThemeConfig = {
|
||||||
token: {
|
token: {
|
||||||
colorPrimary: '#1677ff',
|
colorPrimary: '#C8956C',
|
||||||
borderRadius: 8,
|
colorLink: '#C8956C',
|
||||||
colorBgContainer: '#ffffff',
|
colorLinkHover: '#B37D56',
|
||||||
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
|
colorSuccess: '#7DB87D',
|
||||||
|
colorWarning: '#E8A850',
|
||||||
|
colorError: '#D4645C',
|
||||||
|
colorInfo: '#7BA4C8',
|
||||||
|
colorBgContainer: '#FFFDF7',
|
||||||
|
colorBgLayout: '#F5EDD6',
|
||||||
|
colorBgElevated: '#FFFEF9',
|
||||||
|
colorBorder: 'rgba(180, 150, 100, 0.2)',
|
||||||
|
colorBorderSecondary: 'rgba(180, 150, 100, 0.12)',
|
||||||
|
colorText: '#3D2E1C',
|
||||||
|
colorTextSecondary: '#8B7355',
|
||||||
|
colorTextTertiary: '#A69278',
|
||||||
|
colorTextQuaternary: '#C4B49A',
|
||||||
|
borderRadius: 12,
|
||||||
|
borderRadiusLG: 16,
|
||||||
|
borderRadiusSM: 8,
|
||||||
|
boxShadow: '0 2px 12px rgba(160, 130, 80, 0.06)',
|
||||||
|
boxShadowSecondary: '0 4px 20px rgba(160, 130, 80, 0.1)',
|
||||||
|
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "PingFang SC", "Microsoft YaHei", sans-serif',
|
||||||
},
|
},
|
||||||
components: {
|
components: {
|
||||||
Layout: {
|
Layout: {
|
||||||
siderBg: '#001529',
|
siderBg: '#1E1209',
|
||||||
headerBg: '#ffffff',
|
headerBg: 'rgba(255, 253, 247, 0.75)',
|
||||||
|
bodyBg: '#F5EDD6',
|
||||||
},
|
},
|
||||||
Menu: {
|
Menu: {
|
||||||
darkItemBg: '#001529',
|
darkItemBg: 'transparent',
|
||||||
darkSubMenuItemBg: '#000c17',
|
darkSubMenuItemBg: 'rgba(0,0,0,0.15)',
|
||||||
|
darkItemColor: 'rgba(255,255,255,0.6)',
|
||||||
|
darkItemHoverColor: '#F5E6D0',
|
||||||
|
darkItemHoverBg: 'rgba(200, 149, 108, 0.12)',
|
||||||
|
darkItemSelectedColor: '#F5D8B8',
|
||||||
|
darkItemSelectedBg: 'rgba(200, 149, 108, 0.2)',
|
||||||
|
},
|
||||||
|
Card: {
|
||||||
|
colorBgContainer: '#FFFDF7',
|
||||||
|
boxShadowTertiary: '0 2px 12px rgba(160, 130, 80, 0.06)',
|
||||||
|
},
|
||||||
|
Table: {
|
||||||
|
headerBg: 'rgba(245, 237, 214, 0.6)',
|
||||||
|
headerColor: '#5C4A35',
|
||||||
|
headerSortActiveBg: 'rgba(200, 149, 108, 0.12)',
|
||||||
|
rowHoverBg: 'rgba(200, 149, 108, 0.04)',
|
||||||
|
borderColor: 'rgba(180, 150, 100, 0.1)',
|
||||||
|
},
|
||||||
|
Button: {
|
||||||
|
primaryShadow: '0 2px 8px rgba(200, 149, 108, 0.3)',
|
||||||
|
},
|
||||||
|
Input: {
|
||||||
|
activeBorderColor: '#C8956C',
|
||||||
|
hoverBorderColor: '#D4A57E',
|
||||||
|
activeShadow: '0 0 0 2px rgba(200, 149, 108, 0.1)',
|
||||||
|
},
|
||||||
|
Select: {
|
||||||
|
optionSelectedBg: 'rgba(200, 149, 108, 0.1)',
|
||||||
|
},
|
||||||
|
DatePicker: {
|
||||||
|
activeBorderColor: '#C8956C',
|
||||||
|
hoverBorderColor: '#D4A57E',
|
||||||
|
},
|
||||||
|
Tabs: {
|
||||||
|
inkBarColor: '#C8956C',
|
||||||
|
itemActiveColor: '#C8956C',
|
||||||
|
itemSelectedColor: '#C8956C',
|
||||||
|
itemHoverColor: '#D4A57E',
|
||||||
|
},
|
||||||
|
Progress: {
|
||||||
|
defaultColor: '#C8956C',
|
||||||
|
},
|
||||||
|
Statistic: {
|
||||||
|
contentFontSize: 24,
|
||||||
|
},
|
||||||
|
Timeline: {
|
||||||
|
dotBg: '#FFFDF7',
|
||||||
|
},
|
||||||
|
Modal: {
|
||||||
|
contentBg: '#FFFDF7',
|
||||||
|
headerBg: '#FFFDF7',
|
||||||
|
},
|
||||||
|
Tooltip: {
|
||||||
|
colorBgSpotlight: '#3D2E1C',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user