Browse Source

feat: Phase 3 billing system — quota management, usage records, stats

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
dark 1 month ago
parent
commit
5e4efc2a0e
  1. 90
      backend/api/ai.api
  2. 16
      backend/base.api
  3. 32
      backend/internal/handler/ai/aiquotalisthandler.go
  4. 32
      backend/internal/handler/ai/aiquotarechargehandler.go
  5. 32
      backend/internal/handler/ai/aiusagerecordlisthandler.go
  6. 32
      backend/internal/handler/ai/aiusagestatshandler.go
  7. 24
      backend/internal/handler/routes.go
  8. 65
      backend/internal/logic/ai/aiquotalistlogic.go
  9. 43
      backend/internal/logic/ai/aiquotarechargelogic.go
  10. 89
      backend/internal/logic/ai/aiusagerecordlistlogic.go
  11. 80
      backend/internal/logic/ai/aiusagestatslogic.go
  12. 18
      backend/internal/svc/servicecontext.go
  13. 82
      backend/internal/types/types.go
  14. 84
      backend/model/ai_usage_record_model.go
  15. 33
      backend/model/ai_user_quota_model.go
  16. 4
      frontend/react-shadcn/pc/src/App.tsx
  17. 2
      frontend/react-shadcn/pc/src/components/layout/MainLayout.tsx
  18. 4
      frontend/react-shadcn/pc/src/components/layout/Sidebar.tsx
  19. 331
      frontend/react-shadcn/pc/src/pages/AIQuotaManagementPage.tsx
  20. 351
      frontend/react-shadcn/pc/src/pages/AIUsagePage.tsx
  21. 29
      frontend/react-shadcn/pc/src/services/api.ts
  22. 58
      frontend/react-shadcn/pc/src/types/index.ts

90
backend/api/ai.api

@ -250,4 +250,94 @@ type (
TotalConsumed float64 `json:"totalConsumed"`
FrozenAmount float64 `json:"frozenAmount"`
}
AIQuotaUserInfo {
UserId int64 `json:"userId,string"`
Username string `json:"username"`
Balance float64 `json:"balance"`
TotalRecharged float64 `json:"totalRecharged"`
TotalConsumed float64 `json:"totalConsumed"`
FrozenAmount float64 `json:"frozenAmount"`
}
AIQuotaListRequest {
Page int64 `form:"page,optional,default=1"`
PageSize int64 `form:"pageSize,optional,default=20"`
}
AIQuotaListResponse {
List []AIQuotaUserInfo `json:"list"`
Total int64 `json:"total"`
}
AIQuotaRechargeRequest {
UserId int64 `json:"userId,string"`
Amount float64 `json:"amount"`
Remark string `json:"remark,optional"`
}
)
// ========== Usage Record Types ==========
type (
AIUsageRecordInfo {
Id int64 `json:"id,string"`
UserId int64 `json:"userId,string"`
Username string `json:"username"`
ProviderId int64 `json:"providerId,string"`
ProviderName string `json:"providerName"`
ModelId string `json:"modelId"`
InputTokens int `json:"inputTokens"`
OutputTokens int `json:"outputTokens"`
Cost float64 `json:"cost"`
Status string `json:"status"`
LatencyMs int `json:"latencyMs"`
ErrorMessage string `json:"errorMessage"`
CreatedAt string `json:"createdAt"`
}
AIUsageRecordListRequest {
Page int64 `form:"page,optional,default=1"`
PageSize int64 `form:"pageSize,optional,default=20"`
UserId int64 `form:"userId,optional"`
ModelId string `form:"modelId,optional"`
Status string `form:"status,optional"`
}
AIUsageRecordListResponse {
List []AIUsageRecordInfo `json:"list"`
Total int64 `json:"total"`
}
)
// ========== Stats Types ==========
type (
AIUsageStatsResponse {
TotalCalls int64 `json:"totalCalls"`
TotalTokens int64 `json:"totalTokens"`
TotalCost float64 `json:"totalCost"`
TotalUsers int64 `json:"totalUsers"`
ModelStats []AIModelStatItem `json:"modelStats"`
DailyStats []AIDailyStatItem `json:"dailyStats"`
}
AIModelStatItem {
ModelId string `json:"modelId"`
Calls int64 `json:"calls"`
InputTokens int64 `json:"inputTokens"`
OutputTokens int64 `json:"outputTokens"`
TotalCost float64 `json:"totalCost"`
}
AIDailyStatItem {
Date string `json:"date"`
Calls int64 `json:"calls"`
TotalTokens int64 `json:"totalTokens"`
TotalCost float64 `json:"totalCost"`
}
AIUsageStatsRequest {
Days int `form:"days,optional,default=30"`
}
)

16
backend/base.api

@ -349,6 +349,10 @@ service base-api {
@doc "删除API Key"
@handler AiApiKeyDelete
delete /ai/key/:id (AIApiKeyDeleteRequest) returns (Response)
@doc "获取我的用量记录"
@handler AiUsageRecordList
get /ai/quota/records (AIUsageRecordListRequest) returns (AIUsageRecordListResponse)
}
// ========== AI 管理(admin 权限)==========
@ -385,5 +389,17 @@ service base-api {
@doc "删除AI模型"
@handler AiModelDelete
delete /ai/model/:id (AIModelDeleteRequest) returns (Response)
@doc "获取用户额度列表"
@handler AiQuotaList
get /ai/quotas (AIQuotaListRequest) returns (AIQuotaListResponse)
@doc "充值用户额度"
@handler AiQuotaRecharge
post /ai/quota/recharge (AIQuotaRechargeRequest) returns (Response)
@doc "获取AI使用统计"
@handler AiUsageStats
get /ai/stats (AIUsageStatsRequest) returns (AIUsageStatsResponse)
}

32
backend/internal/handler/ai/aiquotalisthandler.go

@ -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)
}
}
}

32
backend/internal/handler/ai/aiquotarechargehandler.go

@ -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)
}
}
}

32
backend/internal/handler/ai/aiusagerecordlisthandler.go

@ -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)
}
}
}

32
backend/internal/handler/ai/aiusagestatshandler.go

@ -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)
}
}
}

24
backend/internal/handler/routes.go

@ -97,6 +97,12 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
Path: "/ai/quota/me",
Handler: ai.AiQuotaMeHandler(serverCtx),
},
{
// 获取我的用量记录
Method: http.MethodGet,
Path: "/ai/quota/records",
Handler: ai.AiUsageRecordListHandler(serverCtx),
},
}...,
),
rest.WithPrefix("/api/v1"),
@ -148,6 +154,24 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
Path: "/ai/providers",
Handler: ai.AiProviderListHandler(serverCtx),
},
{
// 充值用户额度
Method: http.MethodPost,
Path: "/ai/quota/recharge",
Handler: ai.AiQuotaRechargeHandler(serverCtx),
},
{
// 获取用户额度列表
Method: http.MethodGet,
Path: "/ai/quotas",
Handler: ai.AiQuotaListHandler(serverCtx),
},
{
// 获取AI使用统计
Method: http.MethodGet,
Path: "/ai/stats",
Handler: ai.AiUsageStatsHandler(serverCtx),
},
}...,
),
rest.WithPrefix("/api/v1"),

65
backend/internal/logic/ai/aiquotalistlogic.go

@ -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
}

43
backend/internal/logic/ai/aiquotarechargelogic.go

@ -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
}

89
backend/internal/logic/ai/aiusagerecordlistlogic.go

@ -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
}

80
backend/internal/logic/ai/aiusagestatslogic.go

@ -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
}

18
backend/internal/svc/servicecontext.go

@ -282,6 +282,9 @@ func seedCasbinPolicies(enforcer *casbin.Enforcer) {
{"user", "/api/v1/ai/key/:id", "PUT"},
{"user", "/api/v1/ai/key/:id", "DELETE"},
// AI: user usage records
{"user", "/api/v1/ai/quota/records", "GET"},
// AI: admin provider/model management
{"admin", "/api/v1/ai/providers", "GET"},
{"admin", "/api/v1/ai/provider", "POST"},
@ -290,6 +293,11 @@ func seedCasbinPolicies(enforcer *casbin.Enforcer) {
{"admin", "/api/v1/ai/model", "POST"},
{"admin", "/api/v1/ai/model/:id", "PUT"},
{"admin", "/api/v1/ai/model/:id", "DELETE"},
// AI: admin quota/stats management
{"admin", "/api/v1/ai/quotas", "GET"},
{"admin", "/api/v1/ai/quota/recharge", "POST"},
{"admin", "/api/v1/ai/stats", "GET"},
}
for _, p := range policies {
@ -330,10 +338,12 @@ func seedMenus(db *gorm.DB) {
{Name: "AI 对话", Path: "/ai/chat", Icon: "MessageSquare", Type: "config", SortOrder: 5, Visible: true, Status: 1},
{Name: "AI 模型", Path: "/ai/models", Icon: "Cpu", Type: "config", SortOrder: 6, Visible: true, Status: 1},
{Name: "API 密钥", Path: "/ai/keys", Icon: "Key", Type: "config", SortOrder: 7, Visible: true, Status: 1},
{Name: "角色管理", Path: "/roles", Icon: "Shield", Type: "config", SortOrder: 8, Visible: true, Status: 1},
{Name: "菜单管理", Path: "/menus", Icon: "Menu", Type: "config", SortOrder: 9, Visible: true, Status: 1},
{Name: "机构管理", Path: "/organizations", Icon: "Building2", Type: "config", SortOrder: 10, Visible: true, Status: 1},
{Name: "设置", Path: "/settings", Icon: "Settings", Type: "default", SortOrder: 11, Visible: true, Status: 1},
{Name: "用量统计", Path: "/ai/usage", Icon: "BarChart3", Type: "config", SortOrder: 8, Visible: true, Status: 1},
{Name: "额度管理", Path: "/ai/quota", Icon: "Wallet", Type: "config", SortOrder: 9, Visible: true, Status: 1},
{Name: "角色管理", Path: "/roles", Icon: "Shield", Type: "config", SortOrder: 10, Visible: true, Status: 1},
{Name: "菜单管理", Path: "/menus", Icon: "Menu", Type: "config", SortOrder: 11, Visible: true, Status: 1},
{Name: "机构管理", Path: "/organizations", Icon: "Building2", Type: "config", SortOrder: 12, Visible: true, Status: 1},
{Name: "设置", Path: "/settings", Icon: "Settings", Type: "default", SortOrder: 13, Visible: true, Status: 1},
}
for _, m := range menus {

82
backend/internal/types/types.go

@ -120,6 +120,13 @@ type AIConversationUpdateRequest struct {
Title string `json:"title"`
}
type AIDailyStatItem struct {
Date string `json:"date"`
Calls int64 `json:"calls"`
TotalTokens int64 `json:"totalTokens"`
TotalCost float64 `json:"totalCost"`
}
type AIMessageInfo struct {
Id int64 `json:"id,string"`
ConversationId int64 `json:"conversationId,string"`
@ -166,6 +173,14 @@ type AIModelListResponse struct {
List []AIModelInfo `json:"list"`
}
type AIModelStatItem struct {
ModelId string `json:"modelId"`
Calls int64 `json:"calls"`
InputTokens int64 `json:"inputTokens"`
OutputTokens int64 `json:"outputTokens"`
TotalCost float64 `json:"totalCost"`
}
type AIModelUpdateRequest struct {
Id int64 `path:"id"`
DisplayName string `json:"displayName,optional"`
@ -231,6 +246,73 @@ type AIQuotaInfo struct {
FrozenAmount float64 `json:"frozenAmount"`
}
type AIQuotaListRequest struct {
Page int64 `form:"page,optional,default=1"`
PageSize int64 `form:"pageSize,optional,default=20"`
}
type AIQuotaListResponse struct {
List []AIQuotaUserInfo `json:"list"`
Total int64 `json:"total"`
}
type AIQuotaRechargeRequest struct {
UserId int64 `json:"userId,string"`
Amount float64 `json:"amount"`
Remark string `json:"remark,optional"`
}
type AIQuotaUserInfo struct {
UserId int64 `json:"userId,string"`
Username string `json:"username"`
Balance float64 `json:"balance"`
TotalRecharged float64 `json:"totalRecharged"`
TotalConsumed float64 `json:"totalConsumed"`
FrozenAmount float64 `json:"frozenAmount"`
}
type AIUsageRecordInfo struct {
Id int64 `json:"id,string"`
UserId int64 `json:"userId,string"`
Username string `json:"username"`
ProviderId int64 `json:"providerId,string"`
ProviderName string `json:"providerName"`
ModelId string `json:"modelId"`
InputTokens int `json:"inputTokens"`
OutputTokens int `json:"outputTokens"`
Cost float64 `json:"cost"`
Status string `json:"status"`
LatencyMs int `json:"latencyMs"`
ErrorMessage string `json:"errorMessage"`
CreatedAt string `json:"createdAt"`
}
type AIUsageRecordListRequest struct {
Page int64 `form:"page,optional,default=1"`
PageSize int64 `form:"pageSize,optional,default=20"`
UserId int64 `form:"userId,optional"`
ModelId string `form:"modelId,optional"`
Status string `form:"status,optional"`
}
type AIUsageRecordListResponse struct {
List []AIUsageRecordInfo `json:"list"`
Total int64 `json:"total"`
}
type AIUsageStatsRequest struct {
Days int `form:"days,optional,default=30"`
}
type AIUsageStatsResponse struct {
TotalCalls int64 `json:"totalCalls"`
TotalTokens int64 `json:"totalTokens"`
TotalCost float64 `json:"totalCost"`
TotalUsers int64 `json:"totalUsers"`
ModelStats []AIModelStatItem `json:"modelStats"`
DailyStats []AIDailyStatItem `json:"dailyStats"`
}
type Activity struct {
Id int64 `json:"id"` // 记录ID
User string `json:"user"` // 用户邮箱

84
backend/model/ai_usage_record_model.go

@ -37,3 +37,87 @@ func AIUsageRecordFindByUser(ctx context.Context, db *gorm.DB, userId int64, pag
return records, total, nil
}
// AIUsageRecordFindList 查询使用记录(支持过滤,admin/user通用)
func AIUsageRecordFindList(ctx context.Context, db *gorm.DB, userId int64, modelId, status string, page, pageSize int64) ([]AIUsageRecord, int64, error) {
var records []AIUsageRecord
var total int64
query := db.WithContext(ctx).Model(&AIUsageRecord{})
if userId > 0 {
query = query.Where("user_id = ?", userId)
}
if modelId != "" {
query = query.Where("model_id = ?", modelId)
}
if status != "" {
query = query.Where("status = ?", status)
}
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
offset := (page - 1) * pageSize
if offset < 0 {
offset = 0
}
err := query.Order("created_at DESC").Offset(int(offset)).Limit(int(pageSize)).Find(&records).Error
return records, total, err
}
// AIUsageRecordModelStats 按模型分组统计(指定天数内)
type ModelStatResult struct {
ModelId string `json:"modelId"`
Calls int64 `json:"calls"`
InputTokens int64 `json:"inputTokens"`
OutputTokens int64 `json:"outputTokens"`
TotalCost float64 `json:"totalCost"`
}
func AIUsageRecordModelStats(ctx context.Context, db *gorm.DB, days int) ([]ModelStatResult, error) {
var results []ModelStatResult
err := db.WithContext(ctx).Model(&AIUsageRecord{}).
Select("model_id, COUNT(*) as calls, SUM(input_tokens) as input_tokens, SUM(output_tokens) as output_tokens, SUM(cost) as total_cost").
Where("created_at >= DATE_SUB(NOW(), INTERVAL ? DAY)", days).
Group("model_id").
Order("total_cost DESC").
Find(&results).Error
return results, err
}
// AIUsageRecordDailyStats 按日统计(指定天数内)
type DailyStatResult struct {
Date string `json:"date"`
Calls int64 `json:"calls"`
TotalTokens int64 `json:"totalTokens"`
TotalCost float64 `json:"totalCost"`
}
func AIUsageRecordDailyStats(ctx context.Context, db *gorm.DB, days int) ([]DailyStatResult, error) {
var results []DailyStatResult
err := db.WithContext(ctx).Model(&AIUsageRecord{}).
Select("DATE(created_at) as date, COUNT(*) as calls, SUM(input_tokens + output_tokens) as total_tokens, SUM(cost) as total_cost").
Where("created_at >= DATE_SUB(NOW(), INTERVAL ? DAY)", days).
Group("DATE(created_at)").
Order("date ASC").
Find(&results).Error
return results, err
}
// AIUsageRecordTotalStats 总体统计(指定天数内)
type TotalStatResult struct {
TotalCalls int64 `json:"totalCalls"`
TotalTokens int64 `json:"totalTokens"`
TotalCost float64 `json:"totalCost"`
TotalUsers int64 `json:"totalUsers"`
}
func AIUsageRecordTotalStats(ctx context.Context, db *gorm.DB, days int) (*TotalStatResult, error) {
var result TotalStatResult
err := db.WithContext(ctx).Model(&AIUsageRecord{}).
Select("COUNT(*) as total_calls, COALESCE(SUM(input_tokens + output_tokens), 0) as total_tokens, COALESCE(SUM(cost), 0) as total_cost, COUNT(DISTINCT user_id) as total_users").
Where("created_at >= DATE_SUB(NOW(), INTERVAL ? DAY)", days).
Find(&result).Error
return &result, err
}

33
backend/model/ai_user_quota_model.go

@ -73,6 +73,39 @@ func AIUserQuotaSettle(ctx context.Context, db *gorm.DB, userId int64, frozenAmo
return nil
}
// AIUserQuotaFindList 分页查询所有用户额度(admin用)
func AIUserQuotaFindList(ctx context.Context, db *gorm.DB, page, pageSize int64) ([]AIUserQuota, int64, error) {
var list []AIUserQuota
var total int64
query := db.WithContext(ctx).Model(&AIUserQuota{})
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
offset := (page - 1) * pageSize
if offset < 0 {
offset = 0
}
err := query.Order("user_id ASC").Offset(int(offset)).Limit(int(pageSize)).Find(&list).Error
return list, total, err
}
// AIUserQuotaRecharge 充值(原子操作:balance += amount, total_recharged += amount)
func AIUserQuotaRecharge(ctx context.Context, db *gorm.DB, userId int64, amount float64) error {
// Ensure record exists
_, err := AIUserQuotaEnsure(ctx, db, userId)
if err != nil {
return err
}
return db.WithContext(ctx).Model(&AIUserQuota{}).
Where("user_id = ?", userId).
Updates(map[string]interface{}{
"balance": gorm.Expr("balance + ?", amount),
"total_recharged": gorm.Expr("total_recharged + ?", amount),
}).Error
}
// AIUserQuotaUnfreeze 解冻额度(原子操作:frozen_amount -= amount, balance += amount)
func AIUserQuotaUnfreeze(ctx context.Context, db *gorm.DB, userId int64, amount float64) error {
result := db.WithContext(ctx).Model(&AIUserQuota{}).

4
frontend/react-shadcn/pc/src/App.tsx

@ -17,6 +17,8 @@ import { OrganizationManagementPage } from './pages/OrganizationManagementPage'
import { AIChatPage } from './pages/AIChatPage'
import { AIModelManagementPage } from './pages/AIModelManagementPage'
import { AIKeyManagementPage } from './pages/AIKeyManagementPage'
import { AIUsagePage } from './pages/AIUsagePage'
import { AIQuotaManagementPage } from './pages/AIQuotaManagementPage'
function App() {
return (
@ -46,6 +48,8 @@ function App() {
<Route path="ai/chat" element={<RouteGuard><AIChatPage /></RouteGuard>} />
<Route path="ai/models" element={<RouteGuard><AIModelManagementPage /></RouteGuard>} />
<Route path="ai/keys" element={<RouteGuard><AIKeyManagementPage /></RouteGuard>} />
<Route path="ai/usage" element={<RouteGuard><AIUsagePage /></RouteGuard>} />
<Route path="ai/quota" element={<RouteGuard><AIQuotaManagementPage /></RouteGuard>} />
</Route>
</Routes>
</BrowserRouter>

2
frontend/react-shadcn/pc/src/components/layout/MainLayout.tsx

@ -14,6 +14,8 @@ const pageTitles: Record<string, { title: string; subtitle?: string }> = {
'/ai/chat': { title: 'AI 对话', subtitle: '智能助手' },
'/ai/models': { title: 'AI 模型', subtitle: '管理平台与模型配置' },
'/ai/keys': { title: 'API 密钥', subtitle: '管理 API 访问密钥' },
'/ai/usage': { title: '用量统计', subtitle: 'AI 调用记录与统计' },
'/ai/quota': { title: '额度管理', subtitle: '管理用户 AI 额度' },
}
export function MainLayout() {

4
frontend/react-shadcn/pc/src/components/layout/Sidebar.tsx

@ -2,7 +2,7 @@ import { NavLink } from 'react-router-dom'
import {
LayoutDashboard, Users, LogOut, Settings, FolderOpen,
Shield, Menu as MenuIcon, Building2, User, ChevronDown,
Cpu, Key, MessageSquare,
Cpu, Key, MessageSquare, BarChart3, Wallet,
} from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import { useAuth } from '@/contexts/AuthContext'
@ -12,7 +12,7 @@ import type { MenuItem } from '@/types'
const iconMap: Record<string, LucideIcon> = {
User, LayoutDashboard, Users, FolderOpen, Shield,
Menu: MenuIcon, Building2, Settings,
Cpu, Key, MessageSquare,
Cpu, Key, MessageSquare, BarChart3, Wallet,
}
function getIcon(iconName: string): LucideIcon {

331
frontend/react-shadcn/pc/src/pages/AIQuotaManagementPage.tsx

@ -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>
)
}

351
frontend/react-shadcn/pc/src/pages/AIUsagePage.tsx

@ -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>
)
}

29
frontend/react-shadcn/pc/src/services/api.ts

@ -40,6 +40,10 @@ import type {
AIApiKeyInfo,
AIApiKeyCreateRequest,
AIApiKeyUpdateRequest,
AIQuotaUserInfo,
AIQuotaRechargeRequest,
AIUsageRecordInfo,
AIUsageStats,
} from '@/types'
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8888/api/v1'
@ -545,6 +549,31 @@ class ApiClient {
await this.request<void>(`/ai/key/${id}`, { method: 'DELETE' })
}
// AI Quota Management (admin)
async getAIQuotas(page: number = 1, pageSize: number = 20): Promise<{ list: AIQuotaUserInfo[]; total: number }> {
return this.request<{ list: AIQuotaUserInfo[]; total: number }>(`/ai/quotas?page=${page}&pageSize=${pageSize}`)
}
async rechargeAIQuota(data: AIQuotaRechargeRequest): Promise<void> {
await this.request<void>('/ai/quota/recharge', { method: 'POST', body: JSON.stringify(data) })
}
// AI Usage Records
async getAIUsageRecords(params: { page?: number; pageSize?: number; userId?: number; modelId?: string; status?: string } = {}): Promise<{ list: AIUsageRecordInfo[]; total: number }> {
const q = new URLSearchParams()
if (params.page) q.append('page', params.page.toString())
if (params.pageSize) q.append('pageSize', params.pageSize.toString())
if (params.userId) q.append('userId', params.userId.toString())
if (params.modelId) q.append('modelId', params.modelId)
if (params.status) q.append('status', params.status)
return this.request<{ list: AIUsageRecordInfo[]; total: number }>(`/ai/quota/records?${q}`)
}
// AI Usage Stats (admin)
async getAIUsageStats(days: number = 30): Promise<AIUsageStats> {
return this.request<AIUsageStats>(`/ai/stats?days=${days}`)
}
// Health check
async healthCheck(): Promise<{ status: string }> {
try {

58
frontend/react-shadcn/pc/src/types/index.ts

@ -419,3 +419,61 @@ export interface AIApiKeyUpdateRequest {
isActive?: boolean
remark?: string
}
// AI Quota Management Types
export interface AIQuotaUserInfo {
userId: number
username: string
balance: number
totalRecharged: number
totalConsumed: number
frozenAmount: number
}
export interface AIQuotaRechargeRequest {
userId: number
amount: number
remark?: string
}
// AI Usage Record Types
export interface AIUsageRecordInfo {
id: number
userId: number
username: string
providerId: number
providerName: string
modelId: string
inputTokens: number
outputTokens: number
cost: number
status: string
latencyMs: number
errorMessage: string
createdAt: string
}
// AI Stats Types
export interface AIUsageStats {
totalCalls: number
totalTokens: number
totalCost: number
totalUsers: number
modelStats: AIModelStatItem[]
dailyStats: AIDailyStatItem[]
}
export interface AIModelStatItem {
modelId: string
calls: number
inputTokens: number
outputTokens: number
totalCost: number
}
export interface AIDailyStatItem {
date: string
calls: number
totalTokens: number
totalCost: number
}

Loading…
Cancel
Save