diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..12888fb
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,11 @@
+node_modules
+dist
+data
+*.db
+.git
+.gitignore
+.env
+.env.*
+*.md
+.vscode
+.playwright-mcp
diff --git a/.gitignore b/.gitignore
index 222c52f..f82bb6c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,3 +5,4 @@ data/
*.db-wal
*.db-shm
.env
+server/fonts/
diff --git a/.opencode/todo.md b/.opencode/todo.md
new file mode 100644
index 0000000..0d3143b
--- /dev/null
+++ b/.opencode/todo.md
@@ -0,0 +1,6 @@
+# Mission Tasks
+
+## Task List
+
+[ ] *Start your mission by creating a task list
+
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..14e7f2e
--- /dev/null
+++ b/Dockerfile
@@ -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"]
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..e4f5119
--- /dev/null
+++ b/docker-compose.yml
@@ -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:
diff --git a/index.html b/index.html
index 48dbc64..4cc68b0 100644
--- a/index.html
+++ b/index.html
@@ -2,8 +2,9 @@
+
- NewAPI Dashboard
+ 小林子的服务平台
diff --git a/public/favicon.svg b/public/favicon.svg
new file mode 100644
index 0000000..02f5e44
--- /dev/null
+++ b/public/favicon.svg
@@ -0,0 +1,10 @@
+
diff --git a/server/db.ts b/server/db.ts
index 70ba6e5..70e9cb2 100644
--- a/server/db.ts
+++ b/server/db.ts
@@ -28,10 +28,18 @@ db.exec(`
site_id INTEGER NOT NULL,
site_url TEXT NOT NULL,
user_info TEXT DEFAULT '{}',
+ is_admin INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
expires_at TEXT NOT NULL,
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
diff --git a/server/index.ts b/server/index.ts
index fb16dcd..630bb1c 100644
--- a/server/index.ts
+++ b/server/index.ts
@@ -1,16 +1,22 @@
import express from 'express'
import cors from 'cors'
+import path from 'path'
+import { fileURLToPath } from 'url'
+import fs from 'fs'
import sitesRouter from './routes/sites.js'
import authRouter from './routes/auth.js'
import proxyRouter from './routes/proxy.js'
import billingRouter from './routes/billing.js'
+import { ensureChineseFont } from './utils/font.js'
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(express.json())
+// API routes
app.get('/api/health', (_req, res) => {
res.json({ success: true, message: 'NewAPI Dashboard BFF running' })
})
@@ -20,8 +26,26 @@ app.use('/api/auth', authRouter)
app.use('/proxy', proxyRouter)
app.use('/api/billing', billingRouter)
-app.listen(PORT, () => {
- console.log(`BFF server running on http://localhost:${PORT}`)
-})
+// Production: serve frontend static files from dist/
+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)`)
+ })
+ })
diff --git a/server/middleware/auth.ts b/server/middleware/auth.ts
index 9f4e022..593813e 100644
--- a/server/middleware/auth.ts
+++ b/server/middleware/auth.ts
@@ -41,9 +41,8 @@ export function sessionAuth(req: Request, res: Response, next: NextFunction) {
export function adminAuth(req: Request, res: Response, next: NextFunction) {
sessionAuth(req, res, () => {
if (!req.session) return
- const userInfo = JSON.parse(req.session.user_info || '{}')
- if (userInfo.role < 10) {
- res.status(403).json({ success: false, message: '需要管理员权限' })
+ if (!(req.session as any).is_admin) {
+ res.status(403).json({ success: false, message: '需要 Dashboard 管理员权限,请先升格' })
return
}
next()
diff --git a/server/routes/auth.ts b/server/routes/auth.ts
index cd11727..9a4bc09 100644
--- a/server/routes/auth.ts
+++ b/server/routes/auth.ts
@@ -5,6 +5,8 @@ import { sessionAuth } from '../middleware/auth.js'
const router = Router()
+const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'newapi-admin'
+
// POST /api/auth/login
router.post('/login', async (req: Request, res: Response) => {
const { userId, accessToken, siteId } = req.body
@@ -22,7 +24,10 @@ router.post('/login', async (req: Request, res: Response) => {
try {
const response = await fetch(`${site.url}/api/user/self`, {
- headers: { 'Authorization': accessToken }
+ headers: {
+ 'Authorization': accessToken,
+ 'New-Api-User': String(userId),
+ }
})
const result = await response.json() as any
@@ -70,9 +75,27 @@ router.get('/me', sessionAuth, (req: Request, res: Response) => {
success: true,
data: {
userInfo: JSON.parse(req.session!.user_info),
- site
+ site,
+ isAdmin: !!(req.session as any).is_admin
}
})
})
+// POST /api/auth/elevate — promote to dashboard admin
+router.post('/elevate', sessionAuth, (req: Request, res: Response) => {
+ const { password } = req.body
+ if (password !== ADMIN_PASSWORD) {
+ res.json({ success: false, message: '管理密码错误' })
+ return
+ }
+ db.prepare('UPDATE sessions SET is_admin = 1 WHERE id = ?').run(req.session!.id)
+ res.json({ success: true, message: '已升格为管理员' })
+})
+
+// POST /api/auth/demote — revoke dashboard admin
+router.post('/demote', sessionAuth, (req: Request, res: Response) => {
+ db.prepare('UPDATE sessions SET is_admin = 0 WHERE id = ?').run(req.session!.id)
+ res.json({ success: true, message: '已取消管理员权限' })
+})
+
export default router
diff --git a/server/routes/billing.ts b/server/routes/billing.ts
index b9f8db9..846380f 100644
--- a/server/routes/billing.ts
+++ b/server/routes/billing.ts
@@ -5,37 +5,81 @@ import db from '../db.js'
const router = Router()
+// Helper: fetch with rate-limit retry
+async function fetchWithRetry(url: string, headers: Record, maxRetries = 3): Promise {
+ 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,
+ params: URLSearchParams,
+ maxItems = 50000,
+ delayMs = 200,
+): Promise {
+ const allLogs: any[] = []
+ let page = 1
+ let hasMore = true
+
+ while (hasMore) {
+ params.set('p', String(page))
+ params.set('page_size', '100')
+ const data = await fetchWithRetry(`${baseUrl}?${params.toString()}`, headers)
+ if (!data.success || !data.data?.items?.length) {
+ hasMore = false
+ } else {
+ allLogs.push(...data.data.items)
+ hasMore = data.data.items.length === 100
+ page++
+ }
+ if (allLogs.length >= maxItems) hasMore = false
+ if (hasMore && delayMs > 0) await new Promise(r => setTimeout(r, delayMs))
+ }
+ return allLogs
+}
+
+// Helper: fetch fresh user info from new-api
+async function fetchUserInfo(siteUrl: string, accessToken: string, userId: string) {
+ const res = await fetch(`${siteUrl}/api/user/self`, {
+ headers: { 'Authorization': accessToken, 'New-Api-User': userId }
+ })
+ const data = await res.json() as any
+ if (!data.success) throw new Error('获取用户信息失败')
+ return data.data
+}
+
// POST /api/billing/export/pdf
router.post('/export/pdf', sessionAuth, async (req: Request, res: Response) => {
const session = req.session!
const { startDate, endDate } = req.body
- const userInfo = JSON.parse(session.user_info)
- const site = db.prepare('SELECT name, url FROM sites WHERE id = ?').get(session.site_id) as any
try {
+ // 实时从 new-api 获取最新用户信息
+ const userInfo = await fetchUserInfo(session.site_url, session.access_token, String(session.user_id))
+ const site = db.prepare('SELECT name, url FROM sites WHERE id = ?').get(session.site_id) as any
const startTs = Math.floor(new Date(startDate).getTime() / 1000)
const endTs = Math.floor(new Date(endDate).getTime() / 1000)
- let allLogs: any[] = []
- let page = 1
- const pageSize = 100
- let hasMore = true
-
- while (hasMore) {
- const logRes = await fetch(
- `${session.site_url}/api/log/self?start_timestamp=${startTs}&end_timestamp=${endTs}&type=2&p=${page}&page_size=${pageSize}`,
- { headers: { 'Authorization': session.access_token } }
- )
- const logData = await logRes.json() as any
- if (!logData.success || !logData.data?.items?.length) {
- hasMore = false
- } else {
- allLogs = allLogs.concat(logData.data.items)
- hasMore = logData.data.items.length === pageSize
- page++
- }
- if (allLogs.length > 10000) hasMore = false
- }
+ const apiHeaders = { 'Authorization': session.access_token, 'New-Api-User': String(session.user_id) }
+ const logParams = new URLSearchParams({
+ start_timestamp: String(startTs), end_timestamp: String(endTs), type: '2',
+ })
+ const allLogs = await paginateLogs(`${session.site_url}/api/log/self`, apiHeaders, logParams)
const modelMap = new Map()
let totalQuota = 0
@@ -52,6 +96,10 @@ router.post('/export/pdf', sessionAuth, async (req: Request, res: Response) => {
.map(([model, data]) => ({ model, ...data }))
.sort((a, b) => b.quota - a.quota)
+ // Fetch topup records
+ const topupParams = new URLSearchParams()
+ const allTopups = await paginateLogs(`${session.site_url}/api/user/topup/self`, apiHeaders, topupParams, 10000)
+
const pdfBuffer = await generateBillingPDF({
siteName: site.name,
siteUrl: site.url,
@@ -63,11 +111,13 @@ router.post('/export/pdf', sessionAuth, async (req: Request, res: Response) => {
totalQuota,
totalRequests: allLogs.length,
modelSummary,
+ topups: allTopups,
logs: allLogs
})
res.setHeader('Content-Type', 'application/pdf')
- res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(site.name)}_billing_${startDate}_${endDate}.pdf"`)
+ const ts = new Date().toISOString().replace(/[-:T]/g, '').slice(0, 14)
+ res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(site.name)}_billing_${startDate}_${endDate}_${ts}.pdf"`)
res.send(pdfBuffer)
} catch (error: any) {
res.status(500).json({ success: false, message: `Failed to generate report: ${error.message}` })
@@ -84,49 +134,141 @@ router.post('/export/csv', sessionAuth, async (req: Request, res: Response) => {
const startTs = Math.floor(new Date(startDate).getTime() / 1000)
const endTs = Math.floor(new Date(endDate).getTime() / 1000)
- let allLogs: any[] = []
- let page = 1
- let hasMore = true
-
- while (hasMore) {
- const logRes = await fetch(
- `${session.site_url}/api/log/self?start_timestamp=${startTs}&end_timestamp=${endTs}&type=2&p=${page}&page_size=100`,
- { headers: { 'Authorization': session.access_token } }
- )
- const logData = await logRes.json() as any
- if (!logData.success || !logData.data?.items?.length) {
- hasMore = false
- } else {
- allLogs = allLogs.concat(logData.data.items)
- hasMore = logData.data.items.length === 100
- page++
- }
- if (allLogs.length > 10000) hasMore = false
- }
+ const apiHeaders = { 'Authorization': session.access_token, 'New-Api-User': String(session.user_id) }
+ const logParams = new URLSearchParams({
+ start_timestamp: String(startTs), end_timestamp: String(endTs), type: '2',
+ })
+ const allLogs = await paginateLogs(`${session.site_url}/api/log/self`, apiHeaders, logParams)
const BOM = '\uFEFF'
- const headers = ['Time', 'Model', 'Token', 'Quota', 'Cost(USD)', 'Prompt Tokens', 'Completion Tokens', 'Request ID']
- const rows = allLogs.map(log => [
- new Date((log.created_at || 0) * 1000).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' }),
- log.model_name || '',
- log.token_name || '',
- log.quota || 0,
- ((log.quota || 0) / 500000).toFixed(6),
- log.prompt_tokens || 0,
- log.completion_tokens || 0,
- log.request_id || ''
- ])
+ const headers = ['时间', '令牌', '分组', '类型', '模型', '流式', '用时(s)', '首字(ms)', '输入', '缓存命中', '缓存创建', '输出', '花费(USD)', '额度', '请求ID']
+ const rows = allLogs.map(log => {
+ let other: any = {}
+ try { other = typeof log.other === 'string' ? JSON.parse(log.other) : (log.other || {}) } catch {}
+ const typeMap: Record = { 1: '充值', 2: '消费', 3: '管理', 4: '系统' }
+ return [
+ new Date((log.created_at || 0) * 1000).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' }),
+ log.token_name || '',
+ log.group || '',
+ typeMap[log.type] || String(log.type || ''),
+ log.model_name || '',
+ log.is_stream ? '是' : '否',
+ log.use_time || 0,
+ other.frt ? Math.round(other.frt) : '',
+ log.prompt_tokens || 0,
+ other.cache_tokens || 0,
+ other.cache_creation_tokens || 0,
+ log.completion_tokens || 0,
+ ((log.quota || 0) / 500000).toFixed(6),
+ log.quota || 0,
+ log.request_id || '',
+ ]
+ })
const csv = BOM + [headers, ...rows].map(row => row.map(cell => `"${cell}"`).join(',')).join('\n')
res.setHeader('Content-Type', 'text/csv; charset=utf-8')
- res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(site.name)}_billing_${startDate}_${endDate}.csv"`)
+ const ts = new Date().toISOString().replace(/[-:T]/g, '').slice(0, 14)
+ res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(site.name)}_billing_${startDate}_${endDate}_${ts}.csv"`)
res.send(csv)
} catch (error: any) {
res.status(500).json({ success: false, message: `Failed to generate CSV: ${error.message}` })
}
})
+// POST /api/billing/stats — aggregate stats for date range
+router.post('/stats', sessionAuth, async (req: Request, res: Response) => {
+ const session = req.session!
+ const { startTimestamp, endTimestamp, type, modelName, tokenName, group, requestId } = req.body
+
+ try {
+ const params = new URLSearchParams()
+ if (startTimestamp) params.set('start_timestamp', String(startTimestamp))
+ if (endTimestamp) params.set('end_timestamp', String(endTimestamp))
+ if (type !== undefined && type !== '') params.set('type', String(type))
+ if (modelName) params.set('model_name', modelName)
+ if (tokenName) params.set('token_name', tokenName)
+ if (group) params.set('group', group)
+ if (requestId) params.set('request_id', requestId)
+
+ const headers = { 'Authorization': session.access_token, 'New-Api-User': String(session.user_id) }
+ const allLogs = await paginateLogs(`${session.site_url}/api/log/self`, headers, params)
+
+ let totalQuota = 0
+ let totalPromptTokens = 0
+ let totalCompletionTokens = 0
+ const modelSet = new Set()
+ 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()
+ 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()
+ for (const log of allLogs) {
+ const model = log.model_name || 'unknown'
+ modelMap.set(model, (modelMap.get(model) || 0) + (log.quota || 0))
+ }
+ const models = Array.from(modelMap.entries())
+ .map(([name, value]) => ({ name, value }))
+ .sort((a, b) => b.value - a.value)
+ .slice(0, 10)
+
+ res.json({ success: true, data: { daily, models, totalLogs: allLogs.length } })
+ } catch (error: any) {
+ res.status(500).json({ success: false, message: error.message })
+ }
+})
+
// POST /api/billing/verify — verify HMAC signature
router.post('/verify', (req: Request, res: Response) => {
const { userId, startDate, endDate, totalQuota, totalRecords, generatedAt, signature } = req.body
diff --git a/server/routes/proxy.ts b/server/routes/proxy.ts
index eeaa13f..21f8495 100644
--- a/server/routes/proxy.ts
+++ b/server/routes/proxy.ts
@@ -11,6 +11,7 @@ router.all('/*', sessionAuth, async (req: Request, res: Response) => {
try {
const headers: Record = {
'Authorization': session.access_token,
+ 'New-Api-User': String(session.user_id),
'Content-Type': req.headers['content-type'] || 'application/json'
}
diff --git a/server/utils/font.ts b/server/utils/font.ts
new file mode 100644
index 0000000..e0318c0
--- /dev/null
+++ b/server/utils/font.ts
@@ -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 {
+ 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
+}
diff --git a/server/utils/pdf.ts b/server/utils/pdf.ts
index f2f8ba5..0bfc32e 100644
--- a/server/utils/pdf.ts
+++ b/server/utils/pdf.ts
@@ -1,7 +1,9 @@
import PDFDocument from 'pdfkit'
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 DASHBOARD_URL = process.env.DASHBOARD_URL || 'https://www.lamclod.cn'
interface BillingReportData {
siteName: string
@@ -14,160 +16,433 @@ interface BillingReportData {
totalQuota: number
totalRequests: 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: {
created_at: number
+ type: number
model_name: string
token_name: string
+ group: string
+ is_stream: boolean
quota: number
prompt_tokens: number
completion_tokens: number
+ use_time: number
+ ip: string
request_id: string
+ other: string
}[]
}
export function generateHmacSignature(data: {
- userId: number
- startDate: string
- endDate: string
- totalQuota: number
- totalRecords: number
- generatedAt: string
+ userId: number; startDate: string; endDate: string;
+ totalQuota: number; totalRecords: number; generatedAt: string;
}): string {
const signStr = `${data.userId}|${data.startDate}|${data.endDate}|${data.totalQuota}|${data.totalRecords}|${data.generatedAt}`
return crypto.createHmac('sha256', HMAC_SECRET).update(signStr).digest('hex')
}
export function verifyHmacSignature(data: {
- userId: number
- startDate: string
- endDate: string
- totalQuota: number
- totalRecords: number
- generatedAt: string
- signature: string
+ userId: number; startDate: string; endDate: string;
+ totalQuota: number; totalRecords: number; generatedAt: string; signature: string;
}): boolean {
const expected = generateHmacSignature(data)
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(data.signature))
}
+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 {
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[] = []
-
- doc.on('data', (chunk: Buffer) => chunks.push(chunk))
+ doc.on('data', (c: Buffer) => chunks.push(c))
doc.on('end', () => resolve(Buffer.concat(chunks)))
doc.on('error', reject)
- const generatedAt = new Date().toISOString()
- const reportId = crypto.createHash('md5')
- .update(`${data.userId}-${generatedAt}`)
- .digest('hex')
- .substring(0, 12)
- .toUpperCase()
+ // Register Chinese font as default
+ doc.font(getChineseFont())
- // -- Watermark --
- doc.save()
- doc.opacity(0.06)
- doc.fontSize(60)
- doc.rotate(-45, { origin: [300, 400] })
- doc.text(data.siteName, 100, 350, { width: 600 })
- doc.restore()
+ const now = new Date()
+ const generatedAt = now.toISOString()
+ const generatedLocal = now.toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false })
+ const reportId = 'RPT-' + crypto.createHash('md5').update(`${data.userId}-${generatedAt}`).digest('hex').substring(0, 12).toUpperCase()
+ const totalUsd = q2usd(data.totalQuota)
+ const days = Math.max(1, Math.ceil((new Date(data.endDate).getTime() - new Date(data.startDate).getTime()) / 86400000))
- // -- Header --
- doc.fontSize(20).text(data.siteName, { align: 'center' })
- doc.fontSize(14).text('API Usage Billing Report', { align: 'center' })
- doc.moveDown(0.5)
- doc.fontSize(9).fillColor('#666')
- .text(`Report ID: RPT-${reportId}`, { align: 'center' })
- doc.moveDown(1)
+ // ===== PAGE 1: COVER =====
+ rect(doc, 0, 0, PAGE_W, 100, BLUE)
+ doc.fontSize(22).fillColor('#ffffff').text(data.siteName, MARGIN, 28, { width: CONTENT_W, align: 'center' })
+ doc.fontSize(11).fillColor('#c0d0e8').text('API 使用账单报告', MARGIN, 58, { width: CONTENT_W, align: 'center' })
+ doc.fontSize(7.5).fillColor('#8fabc8').text(reportId, MARGIN, 78, { width: CONTENT_W, align: 'center' })
- // -- Report Info --
- doc.fillColor('#000').fontSize(10)
- doc.text(`Period: ${data.startDate} ~ ${data.endDate}`)
- doc.text(`User ID: ${data.userId} Username: ${data.username} Group: ${data.group}`)
- doc.text(`Site: ${data.siteUrl}`)
- doc.moveDown(1)
+ // Report info section
+ let y = 120
+ doc.fontSize(12).fillColor(BLUE).text('报告信息', MARGIN, y)
+ hline(doc, y + 18, BLUE)
+ y += 28
- // -- Summary --
- doc.fontSize(12).text('Summary', { underline: true })
- doc.moveDown(0.3)
- doc.fontSize(10)
- doc.text(`Total Quota Consumed: ${(data.totalQuota / 500000).toFixed(4)} USD (${data.totalQuota} quota units)`)
- doc.text(`Total Requests: ${data.totalRequests}`)
- doc.moveDown(0.5)
-
- // Model summary
- if (data.modelSummary.length > 0) {
- doc.fontSize(11).text('By Model:', { underline: true })
- doc.moveDown(0.3)
- doc.fontSize(9)
- for (const m of data.modelSummary) {
- doc.text(` ${m.model}: ${(m.quota / 500000).toFixed(4)} USD, ${m.count} requests`)
- }
- doc.moveDown(1)
+ const info: [string, string][] = [
+ ['报告周期', `${data.startDate} ~ ${data.endDate} (${days} 天)`],
+ ['用户 ID', String(data.userId)],
+ ['用户名', data.username],
+ ['用户组', data.group],
+ ['站点地址', data.siteUrl],
+ ['生成时间', generatedLocal],
+ ['报告编号', reportId],
+ ]
+ for (const [k, v] of info) {
+ doc.fontSize(9).fillColor(GRAY).text(k, MARGIN + 10, y, { width: 100, lineBreak: false })
+ doc.fontSize(9).fillColor(BLACK).text(v, MARGIN + 120, y, { width: CONTENT_W - 130, lineBreak: false })
+ y += 18
}
- // -- Detail Table --
- doc.fontSize(12).text('Usage Details', { underline: true })
- doc.moveDown(0.3)
+ // Summary boxes
+ y += 15
+ doc.fontSize(12).fillColor(BLUE).text('概览', MARGIN, y)
+ hline(doc, y + 18, BLUE)
+ y += 28
- const tableTop = doc.y
- const col = [50, 150, 280, 370, 460]
- doc.fontSize(8).fillColor('#333')
- doc.text('Time', col[0], tableTop)
- doc.text('Model', col[1], tableTop)
- doc.text('Token', col[2], tableTop)
- doc.text('Cost (USD)', col[3], tableTop)
- doc.text('Request ID', col[4], tableTop)
+ const boxW = (CONTENT_W - 20) / 3
+ const boxes = [
+ { title: '总消耗', val: `$ ${totalUsd}`, sub: `${data.totalQuota.toLocaleString()} 额度`, accent: BLUE },
+ { title: '总请求数', val: data.totalRequests.toLocaleString(), sub: `${data.modelSummary.length} 个模型`, accent: '#0a7c50' },
+ { title: '平均单价', val: `$ ${data.totalRequests > 0 ? q2usd(Math.round(data.totalQuota / data.totalRequests), 4) : '0'}`, sub: `共 ${days} 天`, accent: '#b45309' },
+ ]
+ for (let i = 0; i < 3; i++) {
+ 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
- const maxRows = Math.min(data.logs.length, 200)
- for (let i = 0; i < maxRows; i++) {
- if (y > 750) {
+ footer(doc, data.siteName)
+
+ // ===== PAGE 2: FINANCIAL SUMMARY =====
+ doc.addPage()
+
+ 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 = 50
+ y = MARGIN + 10
}
- const log = data.logs[i]
- const time = new Date(log.created_at * 1000).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })
- doc.fontSize(7).fillColor('#000')
- doc.text(time, col[0], y, { width: 95 })
- doc.text(log.model_name || '-', col[1], y, { width: 125 })
- doc.text(log.token_name || '-', col[2], y, { width: 85 })
- doc.text((log.quota / 500000).toFixed(6), col[3], y, { width: 85 })
- doc.text(log.request_id?.substring(0, 12) || '-', col[4], y, { width: 100 })
+ const m = data.modelSummary[i]
+ const pct = data.totalQuota > 0 ? (m.quota / data.totalQuota * 100) : 0
+ if (i % 2 === 0) rect(doc, MARGIN, y - 1, CONTENT_W, 14, GRAY_LIGHT)
+ doc.fontSize(6.5).fillColor(BLACK)
+ doc.text(String(i + 1), mc.n, y + 2, { width: 25, lineBreak: false })
+ doc.text(m.model, mc.m, y + 2, { width: 175, lineBreak: false })
+ doc.text(m.count.toLocaleString(), mc.req, y + 2, { width: 70, lineBreak: false })
+ 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
}
- if (data.logs.length > maxRows) {
- doc.moveDown(0.5)
- doc.fontSize(8).fillColor('#999')
- .text(`... Total ${data.logs.length} records, showing first ${maxRows}`)
+ // Total row
+ hline(doc, y, BLUE)
+ y += 4
+ 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 = { 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
+ }
+
+ footer(doc, data.siteName)
}
- // -- HMAC Signature --
+ // ===== 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 = { 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({
- userId: data.userId,
- startDate: data.startDate,
- endDate: data.endDate,
- totalQuota: data.totalQuota,
- totalRecords: data.logs.length,
- generatedAt
+ userId: data.userId, startDate: data.startDate, endDate: data.endDate,
+ totalQuota: data.totalQuota, totalRecords: data.logs.length, generatedAt,
})
- doc.addPage()
- doc.fontSize(10).fillColor('#000').text('Report Verification', { underline: true })
- doc.moveDown(0.3)
- doc.fontSize(8).fillColor('#333')
- doc.text(`Generated At: ${generatedAt}`)
- doc.text(`Total Records: ${data.logs.length}`)
- doc.text(`HMAC-SHA256 Signature: ${signature}`)
- doc.moveDown(0.5)
- doc.fontSize(7).fillColor('#999')
- doc.text('This report is system-generated. The signature can be verified through the system verification API.')
- doc.text('Verification endpoint: POST /api/billing/verify')
+ rect(doc, MARGIN, y, CONTENT_W, 130, GRAY_LIGHT)
+ y += 10
+ const sigRows: [string, string][] = [
+ ['报告编号', reportId],
+ ['生成时间', generatedLocal],
+ ['记录总数', data.logs.length.toLocaleString()],
+ ['总额度', `${data.totalQuota.toLocaleString()} ( $ ${totalUsd} USD )`],
+ ['算法', 'HMAC-SHA256'],
+ ['签名', signature],
+ ]
+ 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()
})
diff --git a/src/App.tsx b/src/App.tsx
index f96f432..f4bde17 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -26,7 +26,7 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) {
}
function SessionRestore({ children }: { children: React.ReactNode }) {
- const { sessionToken, login, logout, setLoading } = useAuthStore()
+ const { sessionToken, login, logout, setLoading, setAdmin } = useAuthStore()
useEffect(() => {
if (sessionToken) {
@@ -34,6 +34,7 @@ function SessionRestore({ children }: { children: React.ReactNode }) {
.then((res) => {
if (res.data.success) {
login(sessionToken, res.data.data.userInfo, res.data.data.site)
+ setAdmin(!!res.data.data.isAdmin)
} else {
logout()
}
diff --git a/src/api/auth.ts b/src/api/auth.ts
index dc0d218..b3fd41b 100644
--- a/src/api/auth.ts
+++ b/src/api/auth.ts
@@ -5,4 +5,6 @@ export const authApi = {
client.post('/api/auth/login', data),
logout: () => client.post('/api/auth/logout'),
me: () => client.get('/api/auth/me'),
+ elevate: (password: string) => client.post('/api/auth/elevate', { password }),
+ demote: () => client.post('/api/auth/demote'),
}
diff --git a/src/api/billing.ts b/src/api/billing.ts
index d04a327..4a82d05 100644
--- a/src/api/billing.ts
+++ b/src/api/billing.ts
@@ -1,9 +1,16 @@
import client from './client'
+const EXPORT_TIMEOUT = 600000 // 10 minutes for export
+
export const billingApi = {
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) =>
- 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),
+ 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 }),
}
diff --git a/src/api/dashboard.ts b/src/api/dashboard.ts
index 5ae1f7f..bc206c6 100644
--- a/src/api/dashboard.ts
+++ b/src/api/dashboard.ts
@@ -5,9 +5,10 @@ export const dashboardApi = {
getTokens: (params?: { p?: number; page_size?: number }) =>
client.get('/proxy/api/token/', { params }),
getLogs: (params?: {
- p?: number; page_size?: number; type?: number;
+ p?: number; page_size?: number; type?: 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 }),
getTopUps: (params?: { p?: number; page_size?: number; keyword?: string }) =>
client.get('/proxy/api/user/topup/self', { params }),
diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx
index 14c1e1d..33ad9ef 100644
--- a/src/components/Layout.tsx
+++ b/src/components/Layout.tsx
@@ -1,10 +1,10 @@
import { useState } from 'react'
import { Outlet, useNavigate, useLocation } from 'react-router-dom'
-import { Layout as AntLayout, Menu, Avatar, Dropdown, Button, Space, Typography, theme } from 'antd'
+import { Layout as AntLayout, Menu, Avatar, Dropdown, Button, Space, Typography, theme, Modal, Input, message } from 'antd'
import {
DashboardOutlined, FileTextOutlined,
LogoutOutlined, UserOutlined, MenuFoldOutlined, MenuUnfoldOutlined,
- GlobalOutlined
+ GlobalOutlined, CrownOutlined, StopOutlined
} from '@ant-design/icons'
import { useAuthStore } from '@/store/authStore'
import { authApi } from '@/api/auth'
@@ -14,13 +14,14 @@ const { Text } = Typography
export default function Layout() {
const [collapsed, setCollapsed] = useState(false)
+ const [elevateOpen, setElevateOpen] = useState(false)
+ const [adminPassword, setAdminPassword] = useState('')
+ const [elevating, setElevating] = useState(false)
const navigate = useNavigate()
const location = useLocation()
- const { userInfo, site, logout } = useAuthStore()
+ const { userInfo, site, isAdmin, logout, setAdmin } = useAuthStore()
const { token } = theme.useToken()
- const isAdmin = (userInfo?.role || 0) >= 10
-
const menuItems = [
{ key: '/dashboard', icon: , label: '仪表盘' },
{ key: '/billing', icon: , label: '账单' },
@@ -33,55 +34,177 @@ export default function Layout() {
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 = {
items: [
{ key: 'user', label: `${userInfo?.username || '用户'} (ID: ${userInfo?.id})`, disabled: true },
{ key: 'site', label: `站点: ${site?.name || '-'}`, disabled: true },
{ type: 'divider' as const },
+ ...(isAdmin
+ ? [{ key: 'demote', label: '取消管理员', icon: }]
+ : [{ key: 'elevate', label: '升格为管理员', icon: }]
+ ),
+ { type: 'divider' as const },
{ key: 'logout', label: '退出登录', icon: , 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 (
-
- setCollapsed(broken)}>
+
+ setCollapsed(broken)}
+ className="sidebar-glow"
+ style={{
+ background: 'linear-gradient(180deg, #2C1A0E 0%, #1A0F06 100%)',
+ borderRight: 'none',
+ }}
+ >
+ {/* Logo */}
-
- {collapsed ? 'NA' : 'NewAPI Dashboard'}
-
+
+ 林
+
+ {!collapsed && (
+
+ 小林子的服务平台
+
+ )}
-
+
-
- : }
- onClick={() => setCollapsed(!collapsed)} />
+ : }
+ onClick={() => setCollapsed(!collapsed)}
+ style={{ color: '#8B7355' }}
+ />
+ {isAdmin && (
+
+ 管理员
+
+ )}
- } style={{ backgroundColor: token.colorPrimary }} />
- {userInfo?.display_name || userInfo?.username}
+ }
+ style={{
+ background: 'linear-gradient(135deg, #C8956C, #D4A57E)',
+ boxShadow: '0 2px 8px rgba(200, 149, 108, 0.2)',
+ }}
+ />
+
+ {userInfo?.display_name || userInfo?.username}
+
-
-
+
+
+
+
+
+
+ { setElevateOpen(false); setAdminPassword('') }}
+ onOk={handleElevate}
+ confirmLoading={elevating}
+ okText="确认升格"
+ cancelText="取消"
+ >
+ 请输入 Dashboard 管理密码:
+ setAdminPassword(e.target.value)}
+ placeholder="管理密码"
+ onPressEnter={handleElevate}
+ />
+
)
}
diff --git a/src/components/ModelPieChart.tsx b/src/components/ModelPieChart.tsx
index 3dc723b..dd6f676 100644
--- a/src/components/ModelPieChart.tsx
+++ b/src/components/ModelPieChart.tsx
@@ -1,50 +1,73 @@
-import { Card, Empty } from 'antd'
+import { Card, Empty, Spin } from 'antd'
import { PieChartOutlined } from '@ant-design/icons'
import { Pie } from '@ant-design/charts'
-interface LogItem {
- model_name: string
- quota: number
+interface ModelItem {
+ name: string
+ value: number
}
interface Props {
- logs: LogItem[]
+ models: ModelItem[]
+ loading?: boolean
}
-export default function ModelPieChart({ logs }: Props) {
- const modelMap = new Map()
- for (const log of logs) {
- const model = log.model_name || 'unknown'
- modelMap.set(model, (modelMap.get(model) || 0) + (log.quota || 0))
+const WARM_COLORS = [
+ '#C8956C', '#7DB87D', '#7BA4C8', '#E8A850', '#D4645C',
+ '#A69278', '#B8A07D', '#8BB8A4', '#C8A87B', '#9B8EC2',
+]
+
+const CARD_MIN_H = 400
+
+export default function ModelPieChart({ models, loading }: Props) {
+ const title = <> 模型消耗分布>
+
+ if (loading) {
+ return (
+
+
+
+ )
}
- const data = Array.from(modelMap.entries())
- .map(([name, value]) => ({ name, value }))
- .sort((a, b) => b.value - a.value)
- .slice(0, 10) // Top 10
-
- if (data.length === 0) {
- return 模型消耗分布>}>
+ if (models.length === 0) {
+ return (
+
+
+
+
+
+ )
}
+ const total = models.reduce((s, m) => s + m.value, 0)
+
const config = {
- data,
+ data: models,
angleField: 'value',
colorField: 'name',
- radius: 0.9,
- innerRadius: 0.5,
+ radius: 0.65,
+ innerRadius: 0.4,
label: {
- text: 'name',
- position: 'outside' as const,
- style: { fontSize: 11 },
+ text: (d: any) => {
+ const pct = total > 0 ? ((d.value / total) * 100).toFixed(1) : '0'
+ return `${d.name} ${pct}%`
+ },
+ position: 'spider' as const,
+ style: { fontSize: 10 },
},
- legend: { position: 'right' as const },
+ legend: false,
+ scale: { color: { range: WARM_COLORS } },
interaction: { tooltip: { marker: true } },
- height: 280,
+ tooltip: {
+ title: 'name',
+ items: [{ channel: 'y', valueFormatter: (v: number) => `$${(v / 500000).toFixed(4)}` }],
+ },
+ height: 320,
}
return (
- 模型消耗分布>} hoverable>
+
)
diff --git a/src/components/QuotaCard.tsx b/src/components/QuotaCard.tsx
index d083332..513fca2 100644
--- a/src/components/QuotaCard.tsx
+++ b/src/components/QuotaCard.tsx
@@ -15,25 +15,30 @@ export default function QuotaCard({ quota, usedQuota }: Props) {
const remaining = quota
return (
- 额度概览>} hoverable>
+ 额度概览>}
+ hoverable
+ className="stat-accent"
+ >
diff --git a/src/components/RecentLogs.tsx b/src/components/RecentLogs.tsx
index 4588dc5..cf18f08 100644
--- a/src/components/RecentLogs.tsx
+++ b/src/components/RecentLogs.tsx
@@ -19,24 +19,33 @@ interface Props {
}
const logTypeMap: Record = {
- 1: { color: 'green', label: '充值' },
- 2: { color: 'blue', label: '消费' },
- 3: { color: 'orange', label: '管理' },
- 4: { color: 'purple', label: '系统' },
- 5: { color: 'red', label: '错误' },
- 6: { color: 'cyan', label: '退款' },
+ 1: { color: '#7DB87D', label: '充值' },
+ 2: { color: '#C8956C', label: '消费' },
+ 3: { color: '#E8A850', label: '管理' },
+ 4: { color: '#9B8EC2', label: '系统' },
+ 5: { color: '#D4645C', label: '错误' },
+ 6: { color: '#7BA4C8', label: '退款' },
}
export default function RecentLogs({ logs, loading }: Props) {
if (!loading && logs.length === 0) {
- return 最近操作日志>}>
+ return (
+ 最近操作日志>} className="stat-accent">
+
+
+ )
}
return (
- 最近操作日志>} loading={loading} hoverable>
+ 最近操作日志>}
+ loading={loading}
+ hoverable
+ className="stat-accent"
+ >
{
- const typeInfo = logTypeMap[log.type] || { color: 'default', label: '未知' }
+ const typeInfo = logTypeMap[log.type] || { color: '#A69278', label: '未知' }
return {
color: typeInfo.color,
children: (
diff --git a/src/components/TokenOverview.tsx b/src/components/TokenOverview.tsx
index a2cfb4f..34a1efb 100644
--- a/src/components/TokenOverview.tsx
+++ b/src/components/TokenOverview.tsx
@@ -43,7 +43,9 @@ export default function TokenOverview({ tokens, loading }: Props) {
key: 'remain',
width: 120,
render: (_: any, record: TokenItem) =>
- record.unlimited_quota ? 无限 : `$${quotaToUsd(record.remain_quota)}`,
+ record.unlimited_quota
+ ? 无限
+ : `$${quotaToUsd(record.remain_quota)}`,
},
{
title: '已用额度',
@@ -62,7 +64,7 @@ export default function TokenOverview({ tokens, loading }: Props) {
]
return (
- 令牌概览>} hoverable>
+ 令牌概览>} hoverable className="stat-accent">
()
+const CARD_MIN_H = 360
- // Initialize last 7 days
- for (let i = 6; i >= 0; i--) {
- const d = new Date(now)
- d.setDate(d.getDate() - i)
- const key = d.toISOString().split('T')[0]
- dayMap.set(key, { count: 0, quota: 0 })
+export default function UsageChart({ daily, loading }: Props) {
+ const title = <> 近 7 天使用趋势>
+
+ if (loading) {
+ return (
+
+
+
+ )
}
- // Fill data
- for (const log of logs) {
- const date = new Date(log.created_at * 1000).toISOString().split('T')[0]
- if (dayMap.has(date)) {
- const existing = dayMap.get(date)!
- existing.count += 1
- existing.quota += log.quota || 0
- }
+ const hasData = daily.some(d => d.count > 0 || d.quota > 0)
+ if (!hasData) {
+ return (
+
+
+
+
+
+ )
}
const chartData: { date: string; value: number; type: string }[] = []
- dayMap.forEach((val, date) => {
- const shortDate = date.substring(5) // MM-DD
- chartData.push({ date: shortDate, value: val.count, type: '请求次数' })
- chartData.push({ date: shortDate, value: Math.round(val.quota / 500), type: '消耗(K quota)' })
- })
-
- if (chartData.length === 0) {
- return 近 7 天使用趋势>}>
+ for (const d of daily) {
+ const shortDate = d.date.substring(5)
+ chartData.push({ date: shortDate, value: d.count, type: '请求次数' })
+ chartData.push({ date: shortDate, value: Number((d.quota / 500000).toFixed(2)), type: '消耗 (USD)' })
}
const config = {
@@ -54,11 +53,12 @@ export default function UsageChart({ logs }: Props) {
point: { shapeField: 'circle', sizeField: 3 },
interaction: { tooltip: { marker: true } },
style: { lineWidth: 2 },
+ scale: { color: { range: ['#C8956C', '#7DB87D'] } },
height: 280,
}
return (
- 近 7 天使用趋势>} hoverable>
+
)
diff --git a/src/index.css b/src/index.css
new file mode 100644
index 0000000..ad7238b
--- /dev/null
+++ b/src/index.css
@@ -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;
+}
diff --git a/src/main.tsx b/src/main.tsx
index eaad244..fbcf1d0 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -1,9 +1,7 @@
-import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
+import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
-
-
-
+
)
diff --git a/src/pages/Billing.tsx b/src/pages/Billing.tsx
index 8fc62dc..0cb538f 100644
--- a/src/pages/Billing.tsx
+++ b/src/pages/Billing.tsx
@@ -1,5 +1,5 @@
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 { Column } from '@ant-design/charts'
import dayjs, { Dayjs } from 'dayjs'
@@ -10,6 +10,13 @@ import { quotaToUsd, formatTimestamp } from '@/utils/quota'
const { RangePicker } = DatePicker
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() {
// Log state
const [logs, setLogs] = useState([])
@@ -20,11 +27,14 @@ export default function Billing() {
// Filters
const [dateRange, setDateRange] = useState<[Dayjs, Dayjs]>([
- dayjs().subtract(30, 'day'),
+ dayjs().subtract(30, 'day').startOf('day'),
dayjs(),
])
const [modelFilter, setModelFilter] = useState('')
const [tokenFilter, setTokenFilter] = useState('')
+ const [groupFilter, setGroupFilter] = useState('')
+ const [requestIdFilter, setRequestIdFilter] = useState('')
+ const [typeFilter, setTypeFilter] = useState('')
// TopUp state
const [topups, setTopups] = useState([])
@@ -35,25 +45,34 @@ export default function Billing() {
// Chart data
const [chartData, setChartData] = useState([])
+ // 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
const [exportPdfLoading, setExportPdfLoading] = useState(false)
const [exportCsvLoading, setExportCsvLoading] = useState(false)
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) => {
setLogsLoading(true)
try {
- const params: any = {
- p: page,
- page_size: pageSize,
- type: 2, // consume type
- }
- if (dateRange[0]) params.start_timestamp = dateRange[0].startOf('day').unix()
- if (dateRange[1]) params.end_timestamp = dateRange[1].endOf('day').unix()
- if (modelFilter) params.model_name = modelFilter
- if (tokenFilter) params.token_name = tokenFilter
-
+ const params = { ...getFilterParams(), p: page, page_size: pageSize }
const res = await dashboardApi.getLogs(params)
if (res.data.success) {
setLogs(res.data.data.items || [])
@@ -64,7 +83,7 @@ export default function Billing() {
} finally {
setLogsLoading(false)
}
- }, [dateRange, modelFilter, tokenFilter])
+ }, [getFilterParams])
const loadTopups = useCallback(async (page = 1) => {
setTopupsLoading(true)
@@ -83,18 +102,10 @@ export default function Billing() {
const loadChartData = useCallback(async () => {
try {
- const startTs = dateRange[0].startOf('day').unix()
- const endTs = dateRange[1].endOf('day').unix()
- const res = await dashboardApi.getLogs({
- start_timestamp: startTs,
- end_timestamp: endTs,
- type: 2,
- page_size: 100,
- p: 1,
- })
+ const params = { ...getFilterParams(), page_size: 100, p: 1 }
+ const res = await dashboardApi.getLogs(params)
if (res.data.success) {
const items = res.data.data.items || []
- // Aggregate by model
const modelMap = new Map()
for (const log of items) {
const model = log.model_name || 'unknown'
@@ -107,12 +118,31 @@ export default function Billing() {
setChartData(data)
}
} 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(() => {
loadLogs(logPage, logPageSize)
- loadChartData()
- }, [logPage, logPageSize, dateRange, modelFilter, tokenFilter])
+ }, [logPage, logPageSize])
useEffect(() => {
loadTopups(topupPage)
@@ -122,12 +152,19 @@ export default function Billing() {
setLogPage(1)
loadLogs(1, logPageSize)
loadChartData()
+ loadStats()
}
+ // Initial load
+ useEffect(() => { handleSearch() }, [])
+
const handleReset = () => {
- setDateRange([dayjs().subtract(30, 'day'), dayjs()])
+ setDateRange([dayjs().startOf('day'), dayjs()])
setModelFilter('')
setTokenFilter('')
+ setGroupFilter('')
+ setRequestIdFilter('')
+ setTypeFilter('')
setLogPage(1)
}
@@ -137,15 +174,25 @@ export default function Billing() {
const startDate = dateRange[0].format('YYYY-MM-DD')
const endDate = dateRange[1].format('YYYY-MM-DD')
const res = await billingApi.exportPdf(startDate, endDate)
- const url = window.URL.createObjectURL(new Blob([res.data]))
+ // 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')
link.href = url
- link.download = `billing_${startDate}_${endDate}.pdf`
+ link.download = `billing_${startDate}_${endDate}_${dayjs().format('HHmmss')}.pdf`
link.click()
window.URL.revokeObjectURL(url)
messageApi.success('PDF 报表已下载')
- } catch {
- messageApi.error('导出 PDF 失败')
+ } catch (err: any) {
+ 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 {
setExportPdfLoading(false)
}
@@ -157,15 +204,24 @@ export default function Billing() {
const startDate = dateRange[0].format('YYYY-MM-DD')
const endDate = dateRange[1].format('YYYY-MM-DD')
const res = await billingApi.exportCsv(startDate, endDate)
- const url = window.URL.createObjectURL(new Blob([res.data]))
+ 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')
link.href = url
- link.download = `billing_${startDate}_${endDate}.csv`
+ link.download = `billing_${startDate}_${endDate}_${dayjs().format('HHmmss')}.csv`
link.click()
window.URL.revokeObjectURL(url)
messageApi.success('CSV 已下载')
- } catch {
- messageApi.error('导出 CSV 失败')
+ } catch (err: any) {
+ 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 {
setExportCsvLoading(false)
}
@@ -180,6 +236,36 @@ export default function Billing() {
render: (v: number) => formatTimestamp(v),
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 = {
+ 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 {info.text}
+ },
+ },
{
title: '模型',
dataIndex: 'model_name',
@@ -187,35 +273,6 @@ export default function Billing() {
ellipsis: true,
width: 200,
},
- {
- title: '令牌',
- dataIndex: 'token_name',
- key: 'token',
- ellipsis: true,
- width: 120,
- },
- {
- title: '消耗 (USD)',
- dataIndex: 'quota',
- key: 'quota',
- width: 120,
- render: (v: number) => `$${quotaToUsd(v)}`,
- sorter: (a: any, b: any) => a.quota - b.quota,
- },
- {
- title: '提示 Tokens',
- dataIndex: 'prompt_tokens',
- key: 'prompt',
- width: 110,
- render: (v: number) => v?.toLocaleString() || '-',
- },
- {
- title: '完成 Tokens',
- dataIndex: 'completion_tokens',
- key: 'completion',
- width: 110,
- render: (v: number) => v?.toLocaleString() || '-',
- },
{
title: '流式',
dataIndex: 'is_stream',
@@ -224,11 +281,75 @@ export default function Billing() {
render: (v: boolean) => v ? 是 : 否,
},
{
- 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 (
+
+ {useTime}s / {frt ? Math.round(frt) + 'ms' : '-'}
+
+ )
+ },
+ },
+ {
+ 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)
+ ?
+
+ {prompt.toLocaleString()}
+ {cacheHit > 0 && ({cacheHit.toLocaleString()})}
+ {cacheCreate > 0 && [+{cacheCreate.toLocaleString()}]}
+
+
+ : {prompt.toLocaleString()}
+ },
+ 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',
- key: 'request_id',
- width: 140,
+ key: 'detail',
+ width: 200,
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
+ ? {v || '-'}
+ : '-'
+ },
},
]
@@ -293,12 +414,10 @@ export default function Billing() {
x: { labelAutoRotate: true },
y: { title: 'USD' },
},
+ scale: { color: { range: ['#C8956C', '#7DB87D', '#7BA4C8', '#E8A850', '#D4645C', '#A69278', '#B8A07D', '#8BB8A4', '#C8A87B', '#9B8EC2'] } },
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 (
@@ -306,61 +425,98 @@ export default function Billing() {
{/* Filter Bar */}
-
- {
- if (dates && dates[0] && dates[1]) {
- setDateRange([dates[0], dates[1]])
- }
- }}
- format="YYYY-MM-DD"
- />
- setModelFilter(e.target.value)}
- style={{ width: 200 }}
- allowClear
- />
- setTokenFilter(e.target.value)}
- style={{ width: 160 }}
- allowClear
- />
- } onClick={handleSearch}>搜索
- } onClick={handleReset}>重置
- } loading={exportPdfLoading} onClick={handleExportPdf}>
- 导出 PDF
-
- } loading={exportCsvLoading} onClick={handleExportCsv}>
- 导出 CSV
-
+
+
+ {
+ if (dates && dates[0] && dates[1]) {
+ setDateRange([dates[0], dates[1]])
+ }
+ }}
+ format="YYYY-MM-DD HH:mm:ss"
+ style={{ width: 420 }}
+ />
+ }
+ placeholder="令牌名称"
+ value={tokenFilter}
+ onChange={(e) => setTokenFilter(e.target.value)}
+ style={{ width: 160 }}
+ allowClear
+ />
+ }
+ placeholder="模型名称"
+ value={modelFilter}
+ onChange={(e) => setModelFilter(e.target.value)}
+ style={{ width: 200 }}
+ allowClear
+ />
+
+
+ }
+ placeholder="分组"
+ value={groupFilter}
+ onChange={(e) => setGroupFilter(e.target.value)}
+ style={{ width: 160 }}
+ allowClear
+ />
+ }
+ placeholder="Request ID"
+ value={requestIdFilter}
+ onChange={(e) => setRequestIdFilter(e.target.value)}
+ style={{ width: 260 }}
+ allowClear
+ />
+
{/* Summary Stats */}
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
@@ -383,13 +539,15 @@ export default function Billing() {
pageSize: logPageSize,
total: logTotal,
showSizeChanger: true,
+ pageSizeOptions: [20, 50, 100],
showTotal: (total) => `共 ${total} 条`,
+ showQuickJumper: true,
onChange: (page, size) => {
setLogPage(page)
setLogPageSize(size)
},
}}
- scroll={{ x: 1200 }}
+ scroll={{ x: 1400 }}
size="middle"
/>
diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx
index 5feb7e6..dd38737 100644
--- a/src/pages/Dashboard.tsx
+++ b/src/pages/Dashboard.tsx
@@ -6,6 +6,7 @@ import {
} from '@ant-design/icons'
import { useAuthStore } from '@/store/authStore'
import { dashboardApi } from '@/api/dashboard'
+import { billingApi } from '@/api/billing'
import { getRoleName } from '@/utils/quota'
import QuotaCard from '@/components/QuotaCard'
import UsageChart from '@/components/UsageChart'
@@ -19,6 +20,9 @@ export default function Dashboard() {
const [tokens, setTokens] = useState([])
const [logsLoading, setLogsLoading] = 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(() => {
loadData()
@@ -33,7 +37,18 @@ export default function Dashboard() {
}
} 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 {
const sevenDaysAgo = Math.floor((Date.now() - 7 * 24 * 60 * 60 * 1000) / 1000)
const logRes = await dashboardApi.getLogs({
@@ -68,42 +83,42 @@ export default function Dashboard() {
{/* User Info Cards */}
-
+
}
- valueStyle={{ fontSize: 18 }}
+ prefix={}
+ valueStyle={{ fontSize: 18, fontWeight: 600 }}
/>
-
+
}
- valueStyle={{ fontSize: 18 }}
+ prefix={}
+ valueStyle={{ fontSize: 18, fontWeight: 600 }}
/>
-
+
}
- valueStyle={{ fontSize: 18, color: '#1677ff' }}
+ prefix={}
+ valueStyle={{ fontSize: 18, color: '#C8956C', fontWeight: 600 }}
/>
-
+
}
- valueStyle={{ fontSize: 18 }}
+ prefix={}
+ valueStyle={{ fontSize: 18, fontWeight: 600 }}
/>
@@ -115,14 +130,14 @@ export default function Dashboard() {
- l.type === 2)} />
+
{/* Model Pie + Token Overview */}
- l.type === 2)} />
+
diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx
index a711262..0a5cbbd 100644
--- a/src/pages/Login.tsx
+++ b/src/pages/Login.tsx
@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react'
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 { sitesApi } from '@/api/sites'
import { authApi } from '@/api/auth'
@@ -14,6 +14,24 @@ interface Site {
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() {
const [form] = Form.useForm()
const [sites, setSites] = useState([])
@@ -68,123 +86,175 @@ export default function Login() {
}
return (
-
{contextHolder}
- {/* Background decoration */}
+ {/* ===== LEFT: Branding Panel ===== */}
+ {/* Particles on left panel */}
+
+ {PARTICLES.map((p, i) => (
+
+ ))}
+
+
+ {/* Decorative blobs */}
-
+
+ {/* Logo & Title */}
+
+
+ 林
+
+
+
+
+ 小林子的服务平台
+
+
+
+
+ AI API 网关管理面板
+
+
-
-
-
- NewAPI Dashboard
-
- AI API 网关管理面板
-
+ {/* ===== RIGHT: Login Form ===== */}
+
+
+
+
+ 欢迎登录
+
+
+ 请输入您的凭据以访问管理面板
+
+
-
选择站点}
- rules={[{ required: true, message: '请选择站点' }]}
+
-
-
-
-
用户 ID}
- rules={[
- { required: true, message: '请输入用户 ID' },
- { pattern: /^\d+$/, message: '用户 ID 必须是数字' },
- ]}
- >
-
-
-
-
系统令牌 (Access Token)}
- rules={[{ required: true, message: '请输入系统令牌' }]}
- >
-
-
-
-
- }
- block
- style={{ height: 44, borderRadius: 8, fontWeight: 600, fontSize: 15 }}
+ 选择站点}
+ rules={[{ required: true, message: '请选择站点' }]}
>
- 登 录
-
-
-
+
-
- 请在管理面板获取您的用户 ID 和系统令牌
-
-
+
+
+
用户 ID}
+ rules={[
+ { required: true, message: '请输入用户 ID' },
+ { pattern: /^\d+$/, message: '用户 ID 必须是数字' },
+ ]}
+ >
+
+
+
+
系统令牌 (Access Token)}
+ rules={[{ required: true, message: '请输入系统令牌' }]}
+ >
+
+
+
+
+ }
+ block
+ className="glow-btn"
+ style={{
+ height: 48,
+ borderRadius: 12,
+ fontWeight: 600,
+ fontSize: 15,
+ border: 'none',
+ background: 'linear-gradient(135deg, #C8956C, #D4A57E)',
+ }}
+ >
+ 登 录
+
+
+
+
+
+ 请在管理面板获取您的用户 ID 和系统令牌
+
+
+
)
}
diff --git a/src/store/authStore.ts b/src/store/authStore.ts
index 234c63b..5c8cd89 100644
--- a/src/store/authStore.ts
+++ b/src/store/authStore.ts
@@ -26,11 +26,13 @@ interface AuthState {
userInfo: UserInfo | null
site: SiteInfo | null
isLoggedIn: boolean
+ isAdmin: boolean
loading: boolean
login: (sessionToken: string, userInfo: UserInfo, site: SiteInfo) => void
logout: () => void
updateUserInfo: (userInfo: UserInfo) => void
setLoading: (loading: boolean) => void
+ setAdmin: (isAdmin: boolean) => void
}
export const useAuthStore = create((set) => ({
@@ -38,6 +40,7 @@ export const useAuthStore = create((set) => ({
userInfo: null,
site: null,
isLoggedIn: !!localStorage.getItem('sessionToken'),
+ isAdmin: false,
loading: true,
login: (sessionToken, userInfo, site) => {
localStorage.setItem('sessionToken', sessionToken)
@@ -45,8 +48,9 @@ export const useAuthStore = create((set) => ({
},
logout: () => {
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 }),
setLoading: (loading) => set({ loading }),
+ setAdmin: (isAdmin) => set({ isAdmin }),
}))
diff --git a/src/utils/theme.ts b/src/utils/theme.ts
index 0788a7b..b0ff9df 100644
--- a/src/utils/theme.ts
+++ b/src/utils/theme.ts
@@ -2,19 +2,91 @@ import type { ThemeConfig } from 'antd'
export const lightTheme: ThemeConfig = {
token: {
- colorPrimary: '#1677ff',
- borderRadius: 8,
- colorBgContainer: '#ffffff',
- fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
+ colorPrimary: '#C8956C',
+ colorLink: '#C8956C',
+ colorLinkHover: '#B37D56',
+ 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: {
Layout: {
- siderBg: '#001529',
- headerBg: '#ffffff',
+ siderBg: '#1E1209',
+ headerBg: 'rgba(255, 253, 247, 0.75)',
+ bodyBg: '#F5EDD6',
},
Menu: {
- darkItemBg: '#001529',
- darkSubMenuItemBg: '#000c17',
+ darkItemBg: 'transparent',
+ 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',
},
},
}