From 55b6c672710573bb1b058635e6693b082a55d2ab Mon Sep 17 00:00:00 2001 From: LAMCLOD <2070346656@qq.com> Date: Mon, 9 Mar 2026 07:07:28 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84=E5=85=A8=E6=A0=88=20?= =?UTF-8?q?Dashboard=20=E9=A1=B9=E7=9B=AE=20-=20UI=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E3=80=81Docker=E6=94=AF=E6=8C=81=E3=80=81=E8=B4=A6=E5=8D=95?= =?UTF-8?q?=E7=B3=BB=E7=BB=9F=E7=AD=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dockerignore | 11 + .gitignore | 1 + .opencode/todo.md | 6 + Dockerfile | 50 ++++ docker-compose.yml | 14 + index.html | 3 +- public/favicon.svg | 10 + server/db.ts | 8 + server/index.ts | 34 ++- server/middleware/auth.ts | 5 +- server/routes/auth.ts | 27 +- server/routes/billing.ts | 250 ++++++++++++---- server/routes/proxy.ts | 1 + server/utils/font.ts | 66 +++++ server/utils/pdf.ts | 487 ++++++++++++++++++++++++------- src/App.tsx | 3 +- src/api/auth.ts | 2 + src/api/billing.ts | 11 +- src/api/dashboard.ts | 5 +- src/components/Layout.tsx | 171 +++++++++-- src/components/ModelPieChart.tsx | 75 +++-- src/components/QuotaCard.tsx | 17 +- src/components/RecentLogs.tsx | 27 +- src/components/TokenOverview.tsx | 6 +- src/components/UsageChart.tsx | 62 ++-- src/index.css | 260 +++++++++++++++++ src/main.tsx | 6 +- src/pages/Billing.tsx | 380 +++++++++++++++++------- src/pages/Dashboard.tsx | 45 ++- src/pages/Login.tsx | 268 ++++++++++------- src/store/authStore.ts | 6 +- src/utils/theme.ts | 88 +++++- 32 files changed, 1893 insertions(+), 512 deletions(-) create mode 100644 .dockerignore create mode 100644 .opencode/todo.md create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 public/favicon.svg create mode 100644 server/utils/font.ts create mode 100644 src/index.css 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 && ( + + 小林子的服务平台 + + )}
- navigate(key)} + style={{ background: 'transparent', marginTop: 8, border: 'none' }} /> + + {/* Sidebar bottom decoration */} +
+ -
-
- - + + +
+ +
+ + { 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" + > 80 ? '#ff4d4f' : '#52c41a', + '0%': '#C8956C', + '100%': percent > 80 ? '#D4645C' : '#7DB87D', }} + trailColor="rgba(180, 150, 100, 0.1)" format={() => `${percent}%`} size={120} /> 已使用比例 - - - + + + 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 - /> - - - - + + + { + 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 + /> + ({ - value: s.id, - label: ( - - {s.name} - {s.url} - - ), - }))} - /> - - - - - 用户 ID} - rules={[ - { required: true, message: '请输入用户 ID' }, - { pattern: /^\d+$/, message: '用户 ID 必须是数字' }, - ]} - > - - - - 系统令牌 (Access Token)} - rules={[{ required: true, message: '请输入系统令牌' }]} - > - - - - - - - + + + + 系统令牌 (Access Token)} + rules={[{ required: true, message: '请输入系统令牌' }]} + > + + + + + + + + + + 请在管理面板获取您的用户 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', }, }, }