Browse Source
Backend: - Add quota/usage/stats API types to ai.api - Add 4 new routes: GET /ai/quota/records (user), GET /ai/quotas, POST /ai/quota/recharge, GET /ai/stats (admin) - Add model methods: AIUserQuotaFindList, AIUserQuotaRecharge, AIUsageRecordFindList, ModelStats, DailyStats, TotalStats - Implement quota list, recharge, usage records, stats logic - Add Casbin policies for new endpoints - Add menu seeds: 用量统计, 额度管理 Frontend: - Add quota/usage/stats TypeScript types and API client methods - Create AIUsagePage with stats cards + usage records table - Create AIQuotaManagementPage with quota table + recharge modal - Register routes, page titles, sidebar icons (BarChart3, Wallet)master
22 changed files with 1525 additions and 6 deletions
@ -0,0 +1,32 @@ |
|||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||
|
// goctl 1.9.2
|
||||
|
|
||||
|
package ai |
||||
|
|
||||
|
import ( |
||||
|
"net/http" |
||||
|
|
||||
|
"github.com/youruser/base/internal/logic/ai" |
||||
|
"github.com/youruser/base/internal/svc" |
||||
|
"github.com/youruser/base/internal/types" |
||||
|
"github.com/zeromicro/go-zero/rest/httpx" |
||||
|
) |
||||
|
|
||||
|
// 获取用户额度列表
|
||||
|
func AiQuotaListHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { |
||||
|
return func(w http.ResponseWriter, r *http.Request) { |
||||
|
var req types.AIQuotaListRequest |
||||
|
if err := httpx.Parse(r, &req); err != nil { |
||||
|
httpx.ErrorCtx(r.Context(), w, err) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
l := ai.NewAiQuotaListLogic(r.Context(), svcCtx) |
||||
|
resp, err := l.AiQuotaList(&req) |
||||
|
if err != nil { |
||||
|
httpx.ErrorCtx(r.Context(), w, err) |
||||
|
} else { |
||||
|
httpx.OkJsonCtx(r.Context(), w, resp) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,32 @@ |
|||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||
|
// goctl 1.9.2
|
||||
|
|
||||
|
package ai |
||||
|
|
||||
|
import ( |
||||
|
"net/http" |
||||
|
|
||||
|
"github.com/youruser/base/internal/logic/ai" |
||||
|
"github.com/youruser/base/internal/svc" |
||||
|
"github.com/youruser/base/internal/types" |
||||
|
"github.com/zeromicro/go-zero/rest/httpx" |
||||
|
) |
||||
|
|
||||
|
// 充值用户额度
|
||||
|
func AiQuotaRechargeHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { |
||||
|
return func(w http.ResponseWriter, r *http.Request) { |
||||
|
var req types.AIQuotaRechargeRequest |
||||
|
if err := httpx.Parse(r, &req); err != nil { |
||||
|
httpx.ErrorCtx(r.Context(), w, err) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
l := ai.NewAiQuotaRechargeLogic(r.Context(), svcCtx) |
||||
|
resp, err := l.AiQuotaRecharge(&req) |
||||
|
if err != nil { |
||||
|
httpx.ErrorCtx(r.Context(), w, err) |
||||
|
} else { |
||||
|
httpx.OkJsonCtx(r.Context(), w, resp) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,32 @@ |
|||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||
|
// goctl 1.9.2
|
||||
|
|
||||
|
package ai |
||||
|
|
||||
|
import ( |
||||
|
"net/http" |
||||
|
|
||||
|
"github.com/youruser/base/internal/logic/ai" |
||||
|
"github.com/youruser/base/internal/svc" |
||||
|
"github.com/youruser/base/internal/types" |
||||
|
"github.com/zeromicro/go-zero/rest/httpx" |
||||
|
) |
||||
|
|
||||
|
// 获取我的用量记录
|
||||
|
func AiUsageRecordListHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { |
||||
|
return func(w http.ResponseWriter, r *http.Request) { |
||||
|
var req types.AIUsageRecordListRequest |
||||
|
if err := httpx.Parse(r, &req); err != nil { |
||||
|
httpx.ErrorCtx(r.Context(), w, err) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
l := ai.NewAiUsageRecordListLogic(r.Context(), svcCtx) |
||||
|
resp, err := l.AiUsageRecordList(&req) |
||||
|
if err != nil { |
||||
|
httpx.ErrorCtx(r.Context(), w, err) |
||||
|
} else { |
||||
|
httpx.OkJsonCtx(r.Context(), w, resp) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,32 @@ |
|||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||
|
// goctl 1.9.2
|
||||
|
|
||||
|
package ai |
||||
|
|
||||
|
import ( |
||||
|
"net/http" |
||||
|
|
||||
|
"github.com/youruser/base/internal/logic/ai" |
||||
|
"github.com/youruser/base/internal/svc" |
||||
|
"github.com/youruser/base/internal/types" |
||||
|
"github.com/zeromicro/go-zero/rest/httpx" |
||||
|
) |
||||
|
|
||||
|
// 获取AI使用统计
|
||||
|
func AiUsageStatsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { |
||||
|
return func(w http.ResponseWriter, r *http.Request) { |
||||
|
var req types.AIUsageStatsRequest |
||||
|
if err := httpx.Parse(r, &req); err != nil { |
||||
|
httpx.ErrorCtx(r.Context(), w, err) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
l := ai.NewAiUsageStatsLogic(r.Context(), svcCtx) |
||||
|
resp, err := l.AiUsageStats(&req) |
||||
|
if err != nil { |
||||
|
httpx.ErrorCtx(r.Context(), w, err) |
||||
|
} else { |
||||
|
httpx.OkJsonCtx(r.Context(), w, resp) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,65 @@ |
|||||
|
package ai |
||||
|
|
||||
|
import ( |
||||
|
"context" |
||||
|
|
||||
|
"github.com/youruser/base/internal/svc" |
||||
|
"github.com/youruser/base/internal/types" |
||||
|
"github.com/youruser/base/model" |
||||
|
|
||||
|
"github.com/zeromicro/go-zero/core/logx" |
||||
|
) |
||||
|
|
||||
|
type AiQuotaListLogic struct { |
||||
|
logx.Logger |
||||
|
ctx context.Context |
||||
|
svcCtx *svc.ServiceContext |
||||
|
} |
||||
|
|
||||
|
func NewAiQuotaListLogic(ctx context.Context, svcCtx *svc.ServiceContext) *AiQuotaListLogic { |
||||
|
return &AiQuotaListLogic{ |
||||
|
Logger: logx.WithContext(ctx), |
||||
|
ctx: ctx, |
||||
|
svcCtx: svcCtx, |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func (l *AiQuotaListLogic) AiQuotaList(req *types.AIQuotaListRequest) (resp *types.AIQuotaListResponse, err error) { |
||||
|
quotas, total, err := model.AIUserQuotaFindList(l.ctx, l.svcCtx.DB, req.Page, req.PageSize) |
||||
|
if err != nil { |
||||
|
return nil, err |
||||
|
} |
||||
|
|
||||
|
// Build user ID list for batch username lookup
|
||||
|
userIds := make([]int64, len(quotas)) |
||||
|
for i, q := range quotas { |
||||
|
userIds[i] = q.UserId |
||||
|
} |
||||
|
|
||||
|
// Lookup usernames
|
||||
|
usernameMap := make(map[int64]string) |
||||
|
if len(userIds) > 0 { |
||||
|
var users []model.User |
||||
|
l.svcCtx.DB.WithContext(l.ctx).Where("id IN ?", userIds).Select("id, username").Find(&users) |
||||
|
for _, u := range users { |
||||
|
usernameMap[u.Id] = u.Username |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
list := make([]types.AIQuotaUserInfo, len(quotas)) |
||||
|
for i, q := range quotas { |
||||
|
list[i] = types.AIQuotaUserInfo{ |
||||
|
UserId: q.UserId, |
||||
|
Username: usernameMap[q.UserId], |
||||
|
Balance: q.Balance, |
||||
|
TotalRecharged: q.TotalRecharged, |
||||
|
TotalConsumed: q.TotalConsumed, |
||||
|
FrozenAmount: q.FrozenAmount, |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return &types.AIQuotaListResponse{ |
||||
|
List: list, |
||||
|
Total: total, |
||||
|
}, nil |
||||
|
} |
||||
@ -0,0 +1,43 @@ |
|||||
|
package ai |
||||
|
|
||||
|
import ( |
||||
|
"context" |
||||
|
"errors" |
||||
|
|
||||
|
"github.com/youruser/base/internal/svc" |
||||
|
"github.com/youruser/base/internal/types" |
||||
|
"github.com/youruser/base/model" |
||||
|
|
||||
|
"github.com/zeromicro/go-zero/core/logx" |
||||
|
) |
||||
|
|
||||
|
type AiQuotaRechargeLogic struct { |
||||
|
logx.Logger |
||||
|
ctx context.Context |
||||
|
svcCtx *svc.ServiceContext |
||||
|
} |
||||
|
|
||||
|
func NewAiQuotaRechargeLogic(ctx context.Context, svcCtx *svc.ServiceContext) *AiQuotaRechargeLogic { |
||||
|
return &AiQuotaRechargeLogic{ |
||||
|
Logger: logx.WithContext(ctx), |
||||
|
ctx: ctx, |
||||
|
svcCtx: svcCtx, |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func (l *AiQuotaRechargeLogic) AiQuotaRecharge(req *types.AIQuotaRechargeRequest) (resp *types.Response, err error) { |
||||
|
if req.Amount <= 0 { |
||||
|
return nil, errors.New("充值金额必须大于0") |
||||
|
} |
||||
|
|
||||
|
err = model.AIUserQuotaRecharge(l.ctx, l.svcCtx.DB, req.UserId, req.Amount) |
||||
|
if err != nil { |
||||
|
return nil, err |
||||
|
} |
||||
|
|
||||
|
return &types.Response{ |
||||
|
Code: 0, |
||||
|
Message: "充值成功", |
||||
|
Success: true, |
||||
|
}, nil |
||||
|
} |
||||
@ -0,0 +1,89 @@ |
|||||
|
package ai |
||||
|
|
||||
|
import ( |
||||
|
"context" |
||||
|
"fmt" |
||||
|
|
||||
|
"github.com/youruser/base/internal/svc" |
||||
|
"github.com/youruser/base/internal/types" |
||||
|
"github.com/youruser/base/model" |
||||
|
|
||||
|
"github.com/zeromicro/go-zero/core/logx" |
||||
|
) |
||||
|
|
||||
|
type AiUsageRecordListLogic struct { |
||||
|
logx.Logger |
||||
|
ctx context.Context |
||||
|
svcCtx *svc.ServiceContext |
||||
|
} |
||||
|
|
||||
|
func NewAiUsageRecordListLogic(ctx context.Context, svcCtx *svc.ServiceContext) *AiUsageRecordListLogic { |
||||
|
return &AiUsageRecordListLogic{ |
||||
|
Logger: logx.WithContext(ctx), |
||||
|
ctx: ctx, |
||||
|
svcCtx: svcCtx, |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func (l *AiUsageRecordListLogic) AiUsageRecordList(req *types.AIUsageRecordListRequest) (resp *types.AIUsageRecordListResponse, err error) { |
||||
|
// For non-admin users, the userId filter is enforced from context
|
||||
|
// The route is user-level (Auth middleware), so we use request userId filter
|
||||
|
// If userId=0 in request, it means show current user's records (from context)
|
||||
|
userId := req.UserId |
||||
|
if userId == 0 { |
||||
|
uid, ok := l.ctx.Value("userId").(int64) |
||||
|
if ok { |
||||
|
userId = uid |
||||
|
} else if uidJson, ok2 := l.ctx.Value("userId").(float64); ok2 { |
||||
|
userId = int64(uidJson) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
records, total, err := model.AIUsageRecordFindList(l.ctx, l.svcCtx.DB, userId, req.ModelId, req.Status, req.Page, req.PageSize) |
||||
|
if err != nil { |
||||
|
return nil, err |
||||
|
} |
||||
|
|
||||
|
// Build lookup caches
|
||||
|
userMap := make(map[int64]string) |
||||
|
providerMap := make(map[int64]string) |
||||
|
|
||||
|
for _, r := range records { |
||||
|
if _, ok := userMap[r.UserId]; !ok { |
||||
|
user, err := model.FindOne(l.ctx, l.svcCtx.DB, r.UserId) |
||||
|
if err == nil { |
||||
|
userMap[r.UserId] = user.Username |
||||
|
} |
||||
|
} |
||||
|
if _, ok := providerMap[r.ProviderId]; !ok { |
||||
|
provider, err := model.AIProviderFindOne(l.ctx, l.svcCtx.DB, r.ProviderId) |
||||
|
if err == nil { |
||||
|
providerMap[r.ProviderId] = provider.DisplayName |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
list := make([]types.AIUsageRecordInfo, len(records)) |
||||
|
for i, r := range records { |
||||
|
list[i] = types.AIUsageRecordInfo{ |
||||
|
Id: r.Id, |
||||
|
UserId: r.UserId, |
||||
|
Username: userMap[r.UserId], |
||||
|
ProviderId: r.ProviderId, |
||||
|
ProviderName: providerMap[r.ProviderId], |
||||
|
ModelId: r.ModelId, |
||||
|
InputTokens: r.InputTokens, |
||||
|
OutputTokens: r.OutputTokens, |
||||
|
Cost: r.Cost, |
||||
|
Status: r.Status, |
||||
|
LatencyMs: r.LatencyMs, |
||||
|
ErrorMessage: r.ErrorMessage, |
||||
|
CreatedAt: fmt.Sprintf("%s", r.CreatedAt.Format("2006-01-02 15:04:05")), |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return &types.AIUsageRecordListResponse{ |
||||
|
List: list, |
||||
|
Total: total, |
||||
|
}, nil |
||||
|
} |
||||
@ -0,0 +1,80 @@ |
|||||
|
package ai |
||||
|
|
||||
|
import ( |
||||
|
"context" |
||||
|
|
||||
|
"github.com/youruser/base/internal/svc" |
||||
|
"github.com/youruser/base/internal/types" |
||||
|
"github.com/youruser/base/model" |
||||
|
|
||||
|
"github.com/zeromicro/go-zero/core/logx" |
||||
|
) |
||||
|
|
||||
|
type AiUsageStatsLogic struct { |
||||
|
logx.Logger |
||||
|
ctx context.Context |
||||
|
svcCtx *svc.ServiceContext |
||||
|
} |
||||
|
|
||||
|
func NewAiUsageStatsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *AiUsageStatsLogic { |
||||
|
return &AiUsageStatsLogic{ |
||||
|
Logger: logx.WithContext(ctx), |
||||
|
ctx: ctx, |
||||
|
svcCtx: svcCtx, |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func (l *AiUsageStatsLogic) AiUsageStats(req *types.AIUsageStatsRequest) (resp *types.AIUsageStatsResponse, err error) { |
||||
|
days := req.Days |
||||
|
if days <= 0 { |
||||
|
days = 30 |
||||
|
} |
||||
|
|
||||
|
// Total stats
|
||||
|
totalStats, err := model.AIUsageRecordTotalStats(l.ctx, l.svcCtx.DB, days) |
||||
|
if err != nil { |
||||
|
return nil, err |
||||
|
} |
||||
|
|
||||
|
// Model stats
|
||||
|
modelResults, err := model.AIUsageRecordModelStats(l.ctx, l.svcCtx.DB, days) |
||||
|
if err != nil { |
||||
|
return nil, err |
||||
|
} |
||||
|
|
||||
|
modelStats := make([]types.AIModelStatItem, len(modelResults)) |
||||
|
for i, m := range modelResults { |
||||
|
modelStats[i] = types.AIModelStatItem{ |
||||
|
ModelId: m.ModelId, |
||||
|
Calls: m.Calls, |
||||
|
InputTokens: m.InputTokens, |
||||
|
OutputTokens: m.OutputTokens, |
||||
|
TotalCost: m.TotalCost, |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Daily stats
|
||||
|
dailyResults, err := model.AIUsageRecordDailyStats(l.ctx, l.svcCtx.DB, days) |
||||
|
if err != nil { |
||||
|
return nil, err |
||||
|
} |
||||
|
|
||||
|
dailyStats := make([]types.AIDailyStatItem, len(dailyResults)) |
||||
|
for i, d := range dailyResults { |
||||
|
dailyStats[i] = types.AIDailyStatItem{ |
||||
|
Date: d.Date, |
||||
|
Calls: d.Calls, |
||||
|
TotalTokens: d.TotalTokens, |
||||
|
TotalCost: d.TotalCost, |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return &types.AIUsageStatsResponse{ |
||||
|
TotalCalls: totalStats.TotalCalls, |
||||
|
TotalTokens: totalStats.TotalTokens, |
||||
|
TotalCost: totalStats.TotalCost, |
||||
|
TotalUsers: totalStats.TotalUsers, |
||||
|
ModelStats: modelStats, |
||||
|
DailyStats: dailyStats, |
||||
|
}, nil |
||||
|
} |
||||
@ -0,0 +1,331 @@ |
|||||
|
import { useState, useEffect, useCallback } from 'react' |
||||
|
import { Search, Wallet, Plus } from 'lucide-react' |
||||
|
import { Button } from '@/components/ui/Button' |
||||
|
import { Input } from '@/components/ui/Input' |
||||
|
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card' |
||||
|
import { Modal } from '@/components/ui/Modal' |
||||
|
import { |
||||
|
Table, |
||||
|
TableHeader, |
||||
|
TableBody, |
||||
|
TableRow, |
||||
|
TableHead, |
||||
|
TableCell, |
||||
|
} from '@/components/ui/Table' |
||||
|
import type { AIQuotaUserInfo, AIQuotaRechargeRequest } from '@/types' |
||||
|
import { apiClient } from '@/services/api' |
||||
|
|
||||
|
export function AIQuotaManagementPage() { |
||||
|
const [quotas, setQuotas] = useState<AIQuotaUserInfo[]>([]) |
||||
|
const [isLoading, setIsLoading] = useState(true) |
||||
|
const [error, setError] = useState<string | null>(null) |
||||
|
const [searchQuery, setSearchQuery] = useState('') |
||||
|
|
||||
|
// Pagination
|
||||
|
const [currentPage, setCurrentPage] = useState(1) |
||||
|
const [pageSize] = useState(20) |
||||
|
const [total, setTotal] = useState(0) |
||||
|
|
||||
|
// Recharge modal
|
||||
|
const [rechargeModalOpen, setRechargeModalOpen] = useState(false) |
||||
|
const [rechargeUser, setRechargeUser] = useState<AIQuotaUserInfo | null>(null) |
||||
|
const [rechargeAmount, setRechargeAmount] = useState('') |
||||
|
const [rechargeRemark, setRechargeRemark] = useState('') |
||||
|
const [isRecharging, setIsRecharging] = useState(false) |
||||
|
|
||||
|
// Fetch quotas
|
||||
|
const fetchQuotas = useCallback(async () => { |
||||
|
try { |
||||
|
setIsLoading(true) |
||||
|
setError(null) |
||||
|
const response = await apiClient.getAIQuotas(currentPage, pageSize) |
||||
|
setQuotas(response.list || []) |
||||
|
setTotal(response.total || 0) |
||||
|
} catch (err) { |
||||
|
console.error('Failed to fetch AI quotas:', err) |
||||
|
setError('获取用户额度列表失败,请稍后重试') |
||||
|
setQuotas([]) |
||||
|
setTotal(0) |
||||
|
} finally { |
||||
|
setIsLoading(false) |
||||
|
} |
||||
|
}, [currentPage, pageSize]) |
||||
|
|
||||
|
useEffect(() => { |
||||
|
fetchQuotas() |
||||
|
}, [fetchQuotas]) |
||||
|
|
||||
|
// --- Recharge ---
|
||||
|
|
||||
|
const openRechargeModal = (quota: AIQuotaUserInfo) => { |
||||
|
setRechargeUser(quota) |
||||
|
setRechargeAmount('') |
||||
|
setRechargeRemark('') |
||||
|
setRechargeModalOpen(true) |
||||
|
} |
||||
|
|
||||
|
const resetRechargeForm = () => { |
||||
|
setRechargeUser(null) |
||||
|
setRechargeAmount('') |
||||
|
setRechargeRemark('') |
||||
|
} |
||||
|
|
||||
|
const handleRecharge = async () => { |
||||
|
if (!rechargeUser) return |
||||
|
|
||||
|
const amount = parseFloat(rechargeAmount) |
||||
|
if (isNaN(amount) || amount <= 0) { |
||||
|
alert('请输入有效的充值金额(必须大于0)') |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
try { |
||||
|
setIsRecharging(true) |
||||
|
const data: AIQuotaRechargeRequest = { |
||||
|
userId: rechargeUser.userId, |
||||
|
amount, |
||||
|
remark: rechargeRemark || undefined, |
||||
|
} |
||||
|
await apiClient.rechargeAIQuota(data) |
||||
|
setRechargeModalOpen(false) |
||||
|
resetRechargeForm() |
||||
|
await fetchQuotas() |
||||
|
} catch (err) { |
||||
|
console.error('Failed to recharge:', err) |
||||
|
alert('充值失败,请重试') |
||||
|
} finally { |
||||
|
setIsRecharging(false) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// --- Pagination ---
|
||||
|
|
||||
|
const totalPages = Math.ceil(total / pageSize) |
||||
|
const canGoPrev = currentPage > 1 |
||||
|
const canGoNext = currentPage < totalPages |
||||
|
|
||||
|
const handlePrevPage = () => { |
||||
|
if (canGoPrev) setCurrentPage(currentPage - 1) |
||||
|
} |
||||
|
|
||||
|
const handleNextPage = () => { |
||||
|
if (canGoNext) setCurrentPage(currentPage + 1) |
||||
|
} |
||||
|
|
||||
|
// --- Filtering ---
|
||||
|
|
||||
|
const filteredQuotas = quotas.filter((quota) => |
||||
|
quota.username.toLowerCase().includes(searchQuery.toLowerCase()) |
||||
|
) |
||||
|
|
||||
|
// --- Format Currency ---
|
||||
|
|
||||
|
const formatCurrency = (value: number): string => { |
||||
|
return `¥${value.toFixed(2)}` |
||||
|
} |
||||
|
|
||||
|
const getBalanceColor = (balance: number): string => { |
||||
|
if (balance > 0) return 'text-green-400' |
||||
|
if (balance < 0) return 'text-red-400' |
||||
|
return 'text-text-secondary' |
||||
|
} |
||||
|
|
||||
|
// --- Render ---
|
||||
|
|
||||
|
return ( |
||||
|
<div className="space-y-6 animate-fade-in"> |
||||
|
{/* Header */} |
||||
|
<Card> |
||||
|
<CardContent className="p-6"> |
||||
|
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4"> |
||||
|
<div className="flex-1 w-full sm:max-w-md"> |
||||
|
<Input |
||||
|
placeholder="搜索用户..." |
||||
|
value={searchQuery} |
||||
|
onChange={(e) => setSearchQuery(e.target.value)} |
||||
|
leftIcon={<Search className="h-4 w-4" />} |
||||
|
/> |
||||
|
</div> |
||||
|
<div className="flex items-center gap-2"> |
||||
|
<span className="text-sm text-text-secondary"> |
||||
|
共 {total} 个用户 |
||||
|
</span> |
||||
|
</div> |
||||
|
</div> |
||||
|
</CardContent> |
||||
|
</Card> |
||||
|
|
||||
|
{/* Error Message */} |
||||
|
{error && ( |
||||
|
<div className="p-4 bg-red-500/10 text-red-400 rounded-lg flex justify-between items-center"> |
||||
|
<span>{error}</span> |
||||
|
<button onClick={fetchQuotas} className="underline hover:text-red-300"> |
||||
|
重试 |
||||
|
</button> |
||||
|
</div> |
||||
|
)} |
||||
|
|
||||
|
{/* Quota Table */} |
||||
|
<Card> |
||||
|
<CardHeader> |
||||
|
<CardTitle className="flex items-center gap-2"> |
||||
|
<Wallet className="h-5 w-5" /> |
||||
|
用户额度列表 |
||||
|
</CardTitle> |
||||
|
</CardHeader> |
||||
|
<CardContent> |
||||
|
<div className="overflow-x-auto"> |
||||
|
<Table> |
||||
|
<TableHeader> |
||||
|
<TableRow> |
||||
|
<TableHead>用户</TableHead> |
||||
|
<TableHead>余额</TableHead> |
||||
|
<TableHead>累计充值</TableHead> |
||||
|
<TableHead>累计消费</TableHead> |
||||
|
<TableHead>冻结金额</TableHead> |
||||
|
<TableHead className="text-right">操作</TableHead> |
||||
|
</TableRow> |
||||
|
</TableHeader> |
||||
|
<TableBody> |
||||
|
{isLoading ? ( |
||||
|
<TableRow> |
||||
|
<TableCell colSpan={6} className="text-center"> |
||||
|
加载中... |
||||
|
</TableCell> |
||||
|
</TableRow> |
||||
|
) : filteredQuotas.length === 0 ? ( |
||||
|
<TableRow> |
||||
|
<TableCell colSpan={6} className="text-center"> |
||||
|
暂无数据 |
||||
|
</TableCell> |
||||
|
</TableRow> |
||||
|
) : ( |
||||
|
filteredQuotas.map((quota) => ( |
||||
|
<TableRow key={quota.userId}> |
||||
|
<TableCell className="font-medium text-foreground"> |
||||
|
{quota.username} |
||||
|
</TableCell> |
||||
|
<TableCell className={`font-medium ${getBalanceColor(quota.balance)}`}> |
||||
|
{formatCurrency(quota.balance)} |
||||
|
</TableCell> |
||||
|
<TableCell className="text-text-secondary"> |
||||
|
{formatCurrency(quota.totalRecharged)} |
||||
|
</TableCell> |
||||
|
<TableCell className="text-text-secondary"> |
||||
|
{formatCurrency(quota.totalConsumed)} |
||||
|
</TableCell> |
||||
|
<TableCell className="text-amber-400 font-medium"> |
||||
|
{formatCurrency(quota.frozenAmount)} |
||||
|
</TableCell> |
||||
|
<TableCell className="text-right"> |
||||
|
<Button |
||||
|
variant="outline" |
||||
|
size="sm" |
||||
|
onClick={() => openRechargeModal(quota)} |
||||
|
className="flex items-center gap-1" |
||||
|
> |
||||
|
<Plus className="h-3.5 w-3.5" /> |
||||
|
充值 |
||||
|
</Button> |
||||
|
</TableCell> |
||||
|
</TableRow> |
||||
|
)) |
||||
|
)} |
||||
|
</TableBody> |
||||
|
</Table> |
||||
|
</div> |
||||
|
|
||||
|
{/* Pagination */} |
||||
|
{!isLoading && totalPages > 1 && ( |
||||
|
<div className="flex items-center justify-between mt-6 pt-4 border-t border-border-secondary"> |
||||
|
<div className="text-sm text-text-secondary"> |
||||
|
第 {currentPage} 页,共 {totalPages} 页 |
||||
|
</div> |
||||
|
<div className="flex items-center gap-2"> |
||||
|
<Button |
||||
|
variant="outline" |
||||
|
size="sm" |
||||
|
onClick={handlePrevPage} |
||||
|
disabled={!canGoPrev} |
||||
|
> |
||||
|
上一页 |
||||
|
</Button> |
||||
|
<Button |
||||
|
variant="outline" |
||||
|
size="sm" |
||||
|
onClick={handleNextPage} |
||||
|
disabled={!canGoNext} |
||||
|
> |
||||
|
下一页 |
||||
|
</Button> |
||||
|
</div> |
||||
|
</div> |
||||
|
)} |
||||
|
</CardContent> |
||||
|
</Card> |
||||
|
|
||||
|
{/* Recharge Modal */} |
||||
|
<Modal |
||||
|
isOpen={rechargeModalOpen} |
||||
|
onClose={() => { |
||||
|
setRechargeModalOpen(false) |
||||
|
resetRechargeForm() |
||||
|
}} |
||||
|
title="充值额度" |
||||
|
size="md" |
||||
|
footer={ |
||||
|
<> |
||||
|
<Button |
||||
|
variant="outline" |
||||
|
onClick={() => { |
||||
|
setRechargeModalOpen(false) |
||||
|
resetRechargeForm() |
||||
|
}} |
||||
|
disabled={isRecharging} |
||||
|
> |
||||
|
取消 |
||||
|
</Button> |
||||
|
<Button onClick={handleRecharge} disabled={isRecharging} isLoading={isRecharging}> |
||||
|
确认充值 |
||||
|
</Button> |
||||
|
</> |
||||
|
} |
||||
|
> |
||||
|
<div className="space-y-4"> |
||||
|
<div> |
||||
|
<label className="block text-sm font-medium text-text-secondary mb-2"> |
||||
|
用户 |
||||
|
</label> |
||||
|
<div className="px-3 py-2 bg-muted/50 border border-border-secondary rounded-lg text-foreground"> |
||||
|
{rechargeUser?.username} |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<Input |
||||
|
label="充值金额" |
||||
|
type="number" |
||||
|
placeholder="请输入充值金额" |
||||
|
value={rechargeAmount} |
||||
|
onChange={(e) => setRechargeAmount(e.target.value)} |
||||
|
min="0" |
||||
|
step="0.01" |
||||
|
required |
||||
|
/> |
||||
|
|
||||
|
<Input |
||||
|
label="备注" |
||||
|
placeholder="请输入备注(可选)" |
||||
|
value={rechargeRemark} |
||||
|
onChange={(e) => setRechargeRemark(e.target.value)} |
||||
|
/> |
||||
|
|
||||
|
<div className="p-3 bg-sky-500/10 border border-sky-500/30 rounded-lg"> |
||||
|
<p className="text-sm text-sky-400"> |
||||
|
充值金额必须大于 0,充值后将立即生效。 |
||||
|
</p> |
||||
|
</div> |
||||
|
</div> |
||||
|
</Modal> |
||||
|
</div> |
||||
|
) |
||||
|
} |
||||
@ -0,0 +1,351 @@ |
|||||
|
import { useState, useEffect, useCallback } from 'react' |
||||
|
import { BarChart3, Activity, Coins, Users } from 'lucide-react' |
||||
|
import { Button } from '@/components/ui/Button' |
||||
|
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card' |
||||
|
import { |
||||
|
Table, |
||||
|
TableHeader, |
||||
|
TableBody, |
||||
|
TableRow, |
||||
|
TableHead, |
||||
|
TableCell, |
||||
|
} from '@/components/ui/Table' |
||||
|
import type { AIUsageRecordInfo, AIUsageStats } from '@/types' |
||||
|
import { apiClient } from '@/services/api' |
||||
|
|
||||
|
export function AIUsagePage() { |
||||
|
// Stats state
|
||||
|
const [stats, setStats] = useState<AIUsageStats | null>(null) |
||||
|
const [isLoadingStats, setIsLoadingStats] = useState(true) |
||||
|
const [selectedPeriod, setSelectedPeriod] = useState<7 | 30 | 90>(30) |
||||
|
|
||||
|
// Records state
|
||||
|
const [records, setRecords] = useState<AIUsageRecordInfo[]>([]) |
||||
|
const [total, setTotal] = useState(0) |
||||
|
const [isLoadingRecords, setIsLoadingRecords] = useState(true) |
||||
|
const [error, setError] = useState<string | null>(null) |
||||
|
|
||||
|
// Pagination state
|
||||
|
const [page, setPage] = useState(1) |
||||
|
const [pageSize] = useState(20) |
||||
|
|
||||
|
// Fetch stats
|
||||
|
const fetchStats = useCallback(async () => { |
||||
|
try { |
||||
|
setIsLoadingStats(true) |
||||
|
setError(null) |
||||
|
const data = await apiClient.getAIUsageStats(selectedPeriod) |
||||
|
setStats(data) |
||||
|
} catch (err) { |
||||
|
console.error('Failed to fetch AI usage stats:', err) |
||||
|
setError('获取统计数据失败,请稍后重试') |
||||
|
} finally { |
||||
|
setIsLoadingStats(false) |
||||
|
} |
||||
|
}, [selectedPeriod]) |
||||
|
|
||||
|
// Fetch records
|
||||
|
const fetchRecords = useCallback(async () => { |
||||
|
try { |
||||
|
setIsLoadingRecords(true) |
||||
|
setError(null) |
||||
|
const response = await apiClient.getAIUsageRecords({ page, pageSize }) |
||||
|
setRecords(response.list || []) |
||||
|
setTotal(response.total || 0) |
||||
|
} catch (err) { |
||||
|
console.error('Failed to fetch AI usage records:', err) |
||||
|
setError('获取使用记录失败,请稍后重试') |
||||
|
setRecords([]) |
||||
|
} finally { |
||||
|
setIsLoadingRecords(false) |
||||
|
} |
||||
|
}, [page, pageSize]) |
||||
|
|
||||
|
useEffect(() => { |
||||
|
fetchStats() |
||||
|
}, [fetchStats]) |
||||
|
|
||||
|
useEffect(() => { |
||||
|
fetchRecords() |
||||
|
}, [fetchRecords]) |
||||
|
|
||||
|
// Format number with comma separators
|
||||
|
const formatNumber = (num: number): string => { |
||||
|
return num.toLocaleString('zh-CN') |
||||
|
} |
||||
|
|
||||
|
// Format cost as currency
|
||||
|
const formatCost = (cost: number): string => { |
||||
|
return `¥${cost.toFixed(2)}` |
||||
|
} |
||||
|
|
||||
|
// Format timestamp
|
||||
|
const formatTimestamp = (timestamp: string): string => { |
||||
|
const date = new Date(timestamp) |
||||
|
return date.toLocaleString('zh-CN', { |
||||
|
year: 'numeric', |
||||
|
month: '2-digit', |
||||
|
day: '2-digit', |
||||
|
hour: '2-digit', |
||||
|
minute: '2-digit', |
||||
|
second: '2-digit', |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
// Pagination handlers
|
||||
|
const totalPages = Math.ceil(total / pageSize) |
||||
|
|
||||
|
const handlePrevPage = () => { |
||||
|
if (page > 1) { |
||||
|
setPage(page - 1) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
const handleNextPage = () => { |
||||
|
if (page < totalPages) { |
||||
|
setPage(page + 1) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return ( |
||||
|
<div className="space-y-6 animate-fade-in"> |
||||
|
{/* Period Selector */} |
||||
|
<div className="flex items-center justify-between"> |
||||
|
<h1 className="text-2xl font-bold text-foreground">AI 使用统计</h1> |
||||
|
<div className="flex gap-2"> |
||||
|
<Button |
||||
|
variant={selectedPeriod === 7 ? 'default' : 'outline'} |
||||
|
size="sm" |
||||
|
onClick={() => setSelectedPeriod(7)} |
||||
|
> |
||||
|
7天 |
||||
|
</Button> |
||||
|
<Button |
||||
|
variant={selectedPeriod === 30 ? 'default' : 'outline'} |
||||
|
size="sm" |
||||
|
onClick={() => setSelectedPeriod(30)} |
||||
|
> |
||||
|
30天 |
||||
|
</Button> |
||||
|
<Button |
||||
|
variant={selectedPeriod === 90 ? 'default' : 'outline'} |
||||
|
size="sm" |
||||
|
onClick={() => setSelectedPeriod(90)} |
||||
|
> |
||||
|
90天 |
||||
|
</Button> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
{/* Error Message */} |
||||
|
{error && ( |
||||
|
<div className="p-4 bg-red-500/10 text-red-400 rounded-lg flex justify-between items-center"> |
||||
|
<span>{error}</span> |
||||
|
<button |
||||
|
onClick={() => { |
||||
|
fetchStats() |
||||
|
fetchRecords() |
||||
|
}} |
||||
|
className="underline hover:text-red-300" |
||||
|
> |
||||
|
重试 |
||||
|
</button> |
||||
|
</div> |
||||
|
)} |
||||
|
|
||||
|
{/* Stats Cards */} |
||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4"> |
||||
|
{/* Total Calls */} |
||||
|
<Card> |
||||
|
<CardContent className="p-6"> |
||||
|
<div className="flex items-center justify-between"> |
||||
|
<div> |
||||
|
<p className="text-sm text-text-muted">总调用次数</p> |
||||
|
{isLoadingStats ? ( |
||||
|
<p className="text-2xl font-bold text-foreground mt-2">--</p> |
||||
|
) : ( |
||||
|
<p className="text-2xl font-bold text-foreground mt-2"> |
||||
|
{formatNumber(stats?.totalCalls || 0)} |
||||
|
</p> |
||||
|
)} |
||||
|
</div> |
||||
|
<div className="h-12 w-12 bg-sky-500/20 rounded-lg flex items-center justify-center"> |
||||
|
<BarChart3 className="h-6 w-6 text-sky-400" /> |
||||
|
</div> |
||||
|
</div> |
||||
|
</CardContent> |
||||
|
</Card> |
||||
|
|
||||
|
{/* Total Tokens */} |
||||
|
<Card> |
||||
|
<CardContent className="p-6"> |
||||
|
<div className="flex items-center justify-between"> |
||||
|
<div> |
||||
|
<p className="text-sm text-text-muted">总 Token 数</p> |
||||
|
{isLoadingStats ? ( |
||||
|
<p className="text-2xl font-bold text-foreground mt-2">--</p> |
||||
|
) : ( |
||||
|
<p className="text-2xl font-bold text-foreground mt-2"> |
||||
|
{formatNumber(stats?.totalTokens || 0)} |
||||
|
</p> |
||||
|
)} |
||||
|
</div> |
||||
|
<div className="h-12 w-12 bg-purple-500/20 rounded-lg flex items-center justify-center"> |
||||
|
<Activity className="h-6 w-6 text-purple-400" /> |
||||
|
</div> |
||||
|
</div> |
||||
|
</CardContent> |
||||
|
</Card> |
||||
|
|
||||
|
{/* Total Cost */} |
||||
|
<Card> |
||||
|
<CardContent className="p-6"> |
||||
|
<div className="flex items-center justify-between"> |
||||
|
<div> |
||||
|
<p className="text-sm text-text-muted">总费用</p> |
||||
|
{isLoadingStats ? ( |
||||
|
<p className="text-2xl font-bold text-foreground mt-2">--</p> |
||||
|
) : ( |
||||
|
<p className="text-2xl font-bold text-foreground mt-2"> |
||||
|
{formatCost(stats?.totalCost || 0)} |
||||
|
</p> |
||||
|
)} |
||||
|
</div> |
||||
|
<div className="h-12 w-12 bg-green-500/20 rounded-lg flex items-center justify-center"> |
||||
|
<Coins className="h-6 w-6 text-green-400" /> |
||||
|
</div> |
||||
|
</div> |
||||
|
</CardContent> |
||||
|
</Card> |
||||
|
|
||||
|
{/* Active Users */} |
||||
|
<Card> |
||||
|
<CardContent className="p-6"> |
||||
|
<div className="flex items-center justify-between"> |
||||
|
<div> |
||||
|
<p className="text-sm text-text-muted">活跃用户数</p> |
||||
|
{isLoadingStats ? ( |
||||
|
<p className="text-2xl font-bold text-foreground mt-2">--</p> |
||||
|
) : ( |
||||
|
<p className="text-2xl font-bold text-foreground mt-2"> |
||||
|
{formatNumber(stats?.totalUsers || 0)} |
||||
|
</p> |
||||
|
)} |
||||
|
</div> |
||||
|
<div className="h-12 w-12 bg-orange-500/20 rounded-lg flex items-center justify-center"> |
||||
|
<Users className="h-6 w-6 text-orange-400" /> |
||||
|
</div> |
||||
|
</div> |
||||
|
</CardContent> |
||||
|
</Card> |
||||
|
</div> |
||||
|
|
||||
|
{/* Usage Records Table */} |
||||
|
<Card> |
||||
|
<CardHeader> |
||||
|
<CardTitle>使用记录 ({total})</CardTitle> |
||||
|
</CardHeader> |
||||
|
<CardContent> |
||||
|
<div className="overflow-x-auto"> |
||||
|
<Table> |
||||
|
<TableHeader> |
||||
|
<TableRow> |
||||
|
<TableHead>时间</TableHead> |
||||
|
<TableHead>用户</TableHead> |
||||
|
<TableHead>模型</TableHead> |
||||
|
<TableHead className="text-right">输入Tokens</TableHead> |
||||
|
<TableHead className="text-right">输出Tokens</TableHead> |
||||
|
<TableHead className="text-right">费用</TableHead> |
||||
|
<TableHead className="text-right">延迟</TableHead> |
||||
|
<TableHead>状态</TableHead> |
||||
|
</TableRow> |
||||
|
</TableHeader> |
||||
|
<TableBody> |
||||
|
{isLoadingRecords ? ( |
||||
|
<TableRow> |
||||
|
<TableCell colSpan={8} className="text-center py-8 text-text-secondary"> |
||||
|
加载中... |
||||
|
</TableCell> |
||||
|
</TableRow> |
||||
|
) : records.length === 0 ? ( |
||||
|
<TableRow> |
||||
|
<TableCell colSpan={8} className="text-center py-8 text-text-muted"> |
||||
|
暂无数据 |
||||
|
</TableCell> |
||||
|
</TableRow> |
||||
|
) : ( |
||||
|
records.map((record) => ( |
||||
|
<TableRow key={record.id}> |
||||
|
<TableCell className="text-text-secondary text-sm"> |
||||
|
{formatTimestamp(record.createdAt)} |
||||
|
</TableCell> |
||||
|
<TableCell className="font-medium text-foreground"> |
||||
|
{record.username} |
||||
|
</TableCell> |
||||
|
<TableCell className="font-mono text-sm text-text-secondary"> |
||||
|
{record.modelId} |
||||
|
</TableCell> |
||||
|
<TableCell className="text-right text-text-secondary tabular-nums"> |
||||
|
{formatNumber(record.inputTokens)} |
||||
|
</TableCell> |
||||
|
<TableCell className="text-right text-text-secondary tabular-nums"> |
||||
|
{formatNumber(record.outputTokens)} |
||||
|
</TableCell> |
||||
|
<TableCell className="text-right font-medium text-foreground tabular-nums"> |
||||
|
{formatCost(record.cost)} |
||||
|
</TableCell> |
||||
|
<TableCell className="text-right text-text-secondary tabular-nums"> |
||||
|
{record.latencyMs}ms |
||||
|
</TableCell> |
||||
|
<TableCell> |
||||
|
{record.status === 'ok' ? ( |
||||
|
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-500/20 text-green-400 border border-green-500/30"> |
||||
|
成功 |
||||
|
</span> |
||||
|
) : ( |
||||
|
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-red-500/20 text-red-400 border border-red-500/30"> |
||||
|
失败 |
||||
|
</span> |
||||
|
)} |
||||
|
</TableCell> |
||||
|
</TableRow> |
||||
|
)) |
||||
|
)} |
||||
|
</TableBody> |
||||
|
</Table> |
||||
|
</div> |
||||
|
|
||||
|
{/* Pagination */} |
||||
|
{!isLoadingRecords && records.length > 0 && ( |
||||
|
<div className="flex items-center justify-between mt-6 pt-4 border-t border-border-secondary"> |
||||
|
<div className="text-sm text-text-muted"> |
||||
|
显示第 {(page - 1) * pageSize + 1} - {Math.min(page * pageSize, total)} 条,共 {total} 条 |
||||
|
</div> |
||||
|
<div className="flex gap-2"> |
||||
|
<Button |
||||
|
variant="outline" |
||||
|
size="sm" |
||||
|
onClick={handlePrevPage} |
||||
|
disabled={page === 1} |
||||
|
> |
||||
|
上一页 |
||||
|
</Button> |
||||
|
<div className="flex items-center px-3 text-sm text-foreground"> |
||||
|
第 {page} / {totalPages} 页 |
||||
|
</div> |
||||
|
<Button |
||||
|
variant="outline" |
||||
|
size="sm" |
||||
|
onClick={handleNextPage} |
||||
|
disabled={page >= totalPages} |
||||
|
> |
||||
|
下一页 |
||||
|
</Button> |
||||
|
</div> |
||||
|
</div> |
||||
|
)} |
||||
|
</CardContent> |
||||
|
</Card> |
||||
|
</div> |
||||
|
) |
||||
|
} |
||||
Loading…
Reference in new issue