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