feat: 普通用户用量统计 + 管理后台用量明细
后端 - /api/usage/stats:当前用户按日聚合统计(请求数/输入/输出/缓存 tokens/费用) - /api/usage/logs:当前用户用量明细分页 - /api/admin/usage/logs:全量明细(分页 + 协议/状态/模型/用户筛选 + 用户名关联) - /api/admin/usage/summary:全量汇总(按用户分组) - dao:UsageFilter 支持筛选分页;DailyUsageDAO.ListAll - 路由加固:新增 middleware.AdminOnly,既有 /api/admin/* 全部迁移到 带管理员角色校验的分组(此前登录即可访问,属安全隐患) 前端 - 普通用户「用量统计」页:统计卡片 + 纯 CSS 每日请求量条形图 + 明细表格分页 - 管理后台「用量明细」页:汇总卡片 + 协议/状态/模型/用户筛选 + 明细表格 + 原始请求/响应查看弹窗(记录开关开启时) - stores/usage.ts 与类型定义;控制台/管理菜单挂载
This commit is contained in:
@@ -0,0 +1,265 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"opencatd-open/internal/dao"
|
||||||
|
"opencatd-open/internal/store"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- 普通用户:自身用量统计与明细 ---
|
||||||
|
|
||||||
|
// MyUsageStats GET /api/usage/stats?days=30 — 当前用户的每日用量聚合。
|
||||||
|
func (h *Handler) MyUsageStats(c *gin.Context) {
|
||||||
|
userID, _ := c.Get("user_id")
|
||||||
|
uid, _ := userID.(uint64)
|
||||||
|
|
||||||
|
days := 30
|
||||||
|
if d := c.Query("days"); d != "" {
|
||||||
|
if n, err := strconv.Atoi(d); err == nil && n > 0 && n <= 365 {
|
||||||
|
days = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end := time.Now()
|
||||||
|
start := end.AddDate(0, 0, -days)
|
||||||
|
|
||||||
|
dailies, err := h.dailyDAO.ListByDateRange(c.Request.Context(), uid, start, end)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load usage"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按日期聚合(每日可能多模型多行)
|
||||||
|
byDate := map[string]*store.UsageDaily{}
|
||||||
|
var dates []string
|
||||||
|
for i := range dailies {
|
||||||
|
d := dailies[i]
|
||||||
|
agg, ok := byDate[d.Date]
|
||||||
|
if !ok {
|
||||||
|
agg = &store.UsageDaily{Date: d.Date}
|
||||||
|
byDate[d.Date] = agg
|
||||||
|
dates = append(dates, d.Date)
|
||||||
|
}
|
||||||
|
agg.Requests += d.Requests
|
||||||
|
agg.InputTokens += d.InputTokens
|
||||||
|
agg.OutputTokens += d.OutputTokens
|
||||||
|
agg.CacheReadTokens += d.CacheReadTokens
|
||||||
|
agg.Cost += d.Cost
|
||||||
|
}
|
||||||
|
|
||||||
|
// 汇总
|
||||||
|
var totalRequests, totalInput, totalOutput, totalCache int64
|
||||||
|
var totalCost float64
|
||||||
|
for _, d := range byDate {
|
||||||
|
totalRequests += d.Requests
|
||||||
|
totalInput += d.InputTokens
|
||||||
|
totalOutput += d.OutputTokens
|
||||||
|
totalCache += d.CacheReadTokens
|
||||||
|
totalCost += d.Cost
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"data": gin.H{
|
||||||
|
"dates": dates,
|
||||||
|
"daily": byDate,
|
||||||
|
"totals": gin.H{
|
||||||
|
"requests": totalRequests,
|
||||||
|
"input_tokens": totalInput,
|
||||||
|
"output_tokens": totalOutput,
|
||||||
|
"cache_read_tokens": totalCache,
|
||||||
|
"cost": totalCost,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// MyUsageLogs GET /api/usage/logs?page=1&pageSize=20 — 当前用户的用量明细(分页)。
|
||||||
|
func (h *Handler) MyUsageLogs(c *gin.Context) {
|
||||||
|
userID, _ := c.Get("user_id")
|
||||||
|
uid, _ := userID.(uint64)
|
||||||
|
|
||||||
|
limit, offset := paginate(c, 20)
|
||||||
|
logs, err := h.usageDAO.ListByUserID(c.Request.Context(), uid, limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load usage logs"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
total, err := h.usageDAO.CountByUserID(c.Request.Context(), uid)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to count usage logs"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
valLogs := make([]store.UsageLog, len(logs))
|
||||||
|
for i, l := range logs {
|
||||||
|
valLogs[i] = *l
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"data": usageLogsToResp(valLogs, nil), "total": total})
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 管理后台:全量用量明细 ---
|
||||||
|
|
||||||
|
// AdminUsageLogs GET /api/admin/usage/logs?page=&pageSize=&protocol=&status=&model=&user_id=
|
||||||
|
func (h *Handler) AdminUsageLogs(c *gin.Context) {
|
||||||
|
f := daoUsageFilter(c)
|
||||||
|
logs, err := h.usageDAO.ListAll(c.Request.Context(), f)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load usage logs"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
total, err := h.usageDAO.CountAll(c.Request.Context(), f)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to count usage logs"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
names := h.userNames(logs)
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"data": usageLogsToResp(logs, names),
|
||||||
|
"total": total,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminUsageSummary GET /api/admin/usage/summary?start=&end=&user_id= — 全量汇总。
|
||||||
|
func (h *Handler) AdminUsageSummary(c *gin.Context) {
|
||||||
|
var uidPtr *uint64
|
||||||
|
if v := c.Query("user_id"); v != "" {
|
||||||
|
if n, err := strconv.ParseUint(v, 10, 64); err == nil && n > 0 {
|
||||||
|
uidPtr = &n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dailies, err := h.dailyDAO.ListAll(c.Request.Context(), uidPtr, c.Query("start"), c.Query("end"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load usage"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalRequests, totalInput, totalOutput, totalCache int64
|
||||||
|
var totalCost float64
|
||||||
|
perUser := map[uint64]*gin.H{}
|
||||||
|
for _, d := range dailies {
|
||||||
|
totalRequests += d.Requests
|
||||||
|
totalInput += d.InputTokens
|
||||||
|
totalOutput += d.OutputTokens
|
||||||
|
totalCache += d.CacheReadTokens
|
||||||
|
totalCost += d.Cost
|
||||||
|
u, ok := perUser[d.UserID]
|
||||||
|
if !ok {
|
||||||
|
u = &gin.H{"user_id": d.UserID, "requests": int64(0), "input_tokens": int64(0), "output_tokens": int64(0), "cost": float64(0)}
|
||||||
|
perUser[d.UserID] = u
|
||||||
|
}
|
||||||
|
(*u)["requests"] = (*u)["requests"].(int64) + d.Requests
|
||||||
|
(*u)["input_tokens"] = (*u)["input_tokens"].(int64) + d.InputTokens
|
||||||
|
(*u)["output_tokens"] = (*u)["output_tokens"].(int64) + d.OutputTokens
|
||||||
|
(*u)["cost"] = (*u)["cost"].(float64) + d.Cost
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"data": gin.H{
|
||||||
|
"totals": gin.H{
|
||||||
|
"requests": totalRequests,
|
||||||
|
"input_tokens": totalInput,
|
||||||
|
"output_tokens": totalOutput,
|
||||||
|
"cache_read_tokens": totalCache,
|
||||||
|
"cost": totalCost,
|
||||||
|
},
|
||||||
|
"per_user": perUser,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- helpers ---
|
||||||
|
|
||||||
|
// paginate 解析 page/pageSize 查询参数,返回 limit/offset。
|
||||||
|
func paginate(c *gin.Context, defSize int) (int, int) {
|
||||||
|
limit := defSize
|
||||||
|
offset := 0
|
||||||
|
if pageSize := c.Query("pageSize"); pageSize != "" {
|
||||||
|
if n, err := strconv.Atoi(pageSize); err == nil && n > 0 && n <= 100 {
|
||||||
|
limit = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if page := c.Query("page"); page != "" {
|
||||||
|
if p, err := strconv.Atoi(page); err == nil && p > 0 {
|
||||||
|
offset = (p - 1) * limit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return limit, offset
|
||||||
|
}
|
||||||
|
|
||||||
|
func daoUsageFilter(c *gin.Context) dao.UsageFilter {
|
||||||
|
limit, offset := paginate(c, 20)
|
||||||
|
f := dao.UsageFilter{Limit: limit, Offset: offset}
|
||||||
|
f.Protocol = c.Query("protocol")
|
||||||
|
f.Status = c.Query("status")
|
||||||
|
f.ModelName = c.Query("model")
|
||||||
|
if v := c.Query("user_id"); v != "" {
|
||||||
|
if n, err := strconv.ParseUint(v, 10, 64); err == nil && n > 0 {
|
||||||
|
f.UserID = &n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
func usageLogsToResp(logs []store.UsageLog, names map[uint64]string) []gin.H {
|
||||||
|
out := make([]gin.H, 0, len(logs))
|
||||||
|
for _, l := range logs {
|
||||||
|
row := gin.H{
|
||||||
|
"id": l.ID,
|
||||||
|
"request_id": l.RequestID,
|
||||||
|
"user_id": l.UserID,
|
||||||
|
"channel_id": l.ChannelID,
|
||||||
|
"model_id": l.ModelID,
|
||||||
|
"model_name": l.ModelName,
|
||||||
|
"protocol": l.Protocol,
|
||||||
|
"input_tokens": l.InputTokens,
|
||||||
|
"output_tokens": l.OutputTokens,
|
||||||
|
"cache_read_tokens": l.CacheReadTokens,
|
||||||
|
"cache_creation_tokens": l.CacheCreationTokens,
|
||||||
|
"cost": l.Cost,
|
||||||
|
"latency_ms": l.LatencyMS,
|
||||||
|
"status": l.Status,
|
||||||
|
"error_code": l.ErrorCode,
|
||||||
|
"created_at": l.CreatedAt,
|
||||||
|
}
|
||||||
|
if names != nil {
|
||||||
|
if u, ok := names[l.UserID]; ok {
|
||||||
|
row["username"] = u
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if l.RawRequest != "" {
|
||||||
|
row["raw_request"] = l.RawRequest
|
||||||
|
}
|
||||||
|
if l.RawResponse != "" {
|
||||||
|
row["raw_response"] = l.RawResponse
|
||||||
|
}
|
||||||
|
out = append(out, row)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// userNames 批量查询 user_id → username 映射。
|
||||||
|
func (h *Handler) userNames(logs []store.UsageLog) map[uint64]string {
|
||||||
|
ids := map[uint64]bool{}
|
||||||
|
for _, l := range logs {
|
||||||
|
ids[l.UserID] = true
|
||||||
|
}
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
idList := make([]uint64, 0, len(ids))
|
||||||
|
for id := range ids {
|
||||||
|
idList = append(idList, id)
|
||||||
|
}
|
||||||
|
var users []store.User
|
||||||
|
if err := h.db.Where("id IN ?", idList).Find(&users).Error; err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := map[uint64]string{}
|
||||||
|
for _, u := range users {
|
||||||
|
out[u.ID] = u.Username
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -55,6 +55,50 @@ func (d *UsageDAO) CountByUserID(ctx context.Context, userID uint64) (int64, err
|
|||||||
return count, err
|
return count, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UsageFilter 用量明细筛选条件(管理后台)。
|
||||||
|
type UsageFilter struct {
|
||||||
|
UserID *uint64 // 指定用户(nil=全部)
|
||||||
|
Protocol string // 协议 chat/messages/responses(空=全部)
|
||||||
|
Status string // success/error/canceled(空=全部)
|
||||||
|
ModelName string // 模型名模糊(空=全部)
|
||||||
|
Limit int
|
||||||
|
Offset int
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAll 管理后台全量用量明细(分页 + 筛选),并带用户名。
|
||||||
|
func (d *UsageDAO) ListAll(ctx context.Context, f UsageFilter) ([]store.UsageLog, error) {
|
||||||
|
q := d.db.WithContext(ctx).Model(&store.UsageLog{})
|
||||||
|
q = applyUsageFilter(q, f)
|
||||||
|
var logs []store.UsageLog
|
||||||
|
err := q.Order("created_at DESC").Limit(f.Limit).Offset(f.Offset).Find(&logs).Error
|
||||||
|
return logs, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountAll 统计符合筛选条件的明细总数。
|
||||||
|
func (d *UsageDAO) CountAll(ctx context.Context, f UsageFilter) (int64, error) {
|
||||||
|
q := d.db.WithContext(ctx).Model(&store.UsageLog{})
|
||||||
|
q = applyUsageFilter(q, f)
|
||||||
|
var count int64
|
||||||
|
err := q.Count(&count).Error
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyUsageFilter(q *gorm.DB, f UsageFilter) *gorm.DB {
|
||||||
|
if f.UserID != nil {
|
||||||
|
q = q.Where("user_id = ?", *f.UserID)
|
||||||
|
}
|
||||||
|
if f.Protocol != "" {
|
||||||
|
q = q.Where("protocol = ?", f.Protocol)
|
||||||
|
}
|
||||||
|
if f.Status != "" {
|
||||||
|
q = q.Where("status = ?", f.Status)
|
||||||
|
}
|
||||||
|
if f.ModelName != "" {
|
||||||
|
q = q.Where("model_name LIKE ?", "%"+f.ModelName+"%")
|
||||||
|
}
|
||||||
|
return q
|
||||||
|
}
|
||||||
|
|
||||||
// UsageDaily DAO
|
// UsageDaily DAO
|
||||||
func (d *DailyUsageDAO) Create(ctx context.Context, log *store.UsageDaily) error {
|
func (d *DailyUsageDAO) Create(ctx context.Context, log *store.UsageDaily) error {
|
||||||
return d.db.WithContext(ctx).Create(log).Error
|
return d.db.WithContext(ctx).Create(log).Error
|
||||||
@@ -106,3 +150,20 @@ func (d *DailyUsageDAO) ListByDateRange(ctx context.Context, userID uint64, star
|
|||||||
Find(&logs).Error
|
Find(&logs).Error
|
||||||
return logs, err
|
return logs, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListAll 管理后台:全部用户的日聚合(可选按用户/日期范围筛选),按日期倒序。
|
||||||
|
func (d *DailyUsageDAO) ListAll(ctx context.Context, userID *uint64, start, end string) ([]store.UsageDaily, error) {
|
||||||
|
q := d.db.WithContext(ctx).Model(&store.UsageDaily{})
|
||||||
|
if userID != nil {
|
||||||
|
q = q.Where("user_id = ?", *userID)
|
||||||
|
}
|
||||||
|
if start != "" {
|
||||||
|
q = q.Where("date >= ?", start)
|
||||||
|
}
|
||||||
|
if end != "" {
|
||||||
|
q = q.Where("date <= ?", end)
|
||||||
|
}
|
||||||
|
var logs []store.UsageDaily
|
||||||
|
err := q.Order("date DESC, user_id ASC").Find(&logs).Error
|
||||||
|
return logs, err
|
||||||
|
}
|
||||||
|
|||||||
@@ -48,6 +48,25 @@ func Auth(db *gorm.DB) gin.HandlerFunc {
|
|||||||
|
|
||||||
func CheckRole(role string) gin.HandlerFunc {
|
func CheckRole(role string) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
|
userRole, _ := c.Get("user_role")
|
||||||
|
if roleStr, ok := userRole.(string); !ok || roleStr != role {
|
||||||
|
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "权限不足"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminOnly 管理后台中间件:要求 user_role 为 admin。
|
||||||
|
// 由 middleware.Auth 先行设置 user_role;缺失时拒绝。
|
||||||
|
func AdminOnly() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
role, _ := c.Get("user_role")
|
||||||
|
roleStr, _ := role.(string)
|
||||||
|
if roleStr != store.RoleAdmin {
|
||||||
|
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "需要管理员权限"})
|
||||||
|
return
|
||||||
|
}
|
||||||
c.Next()
|
c.Next()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+35
-23
@@ -113,34 +113,46 @@ func SetRouter(cfg *config.Config, db *gorm.DB, web *embed.FS) {
|
|||||||
apiGroup.PUT("/models/:id", apiHandler.UpdateModel)
|
apiGroup.PUT("/models/:id", apiHandler.UpdateModel)
|
||||||
apiGroup.DELETE("/models/:id", apiHandler.DeleteModel)
|
apiGroup.DELETE("/models/:id", apiHandler.DeleteModel)
|
||||||
|
|
||||||
|
// 用户自身用量统计
|
||||||
|
apiGroup.GET("/usage/stats", apiHandler.MyUsageStats)
|
||||||
|
apiGroup.GET("/usage/logs", apiHandler.MyUsageLogs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin API (requires admin role)
|
||||||
|
adminGroup := r.Group("/api/admin", middleware.Auth(db), middleware.AdminOnly())
|
||||||
|
{
|
||||||
// Admin channel management (enhanced)
|
// Admin channel management (enhanced)
|
||||||
apiGroup.GET("/admin/channels", apiHandler.AdminChannels)
|
adminGroup.GET("/channels", apiHandler.AdminChannels)
|
||||||
apiGroup.POST("/admin/channels", apiHandler.AdminCreateChannel)
|
adminGroup.POST("/channels", apiHandler.AdminCreateChannel)
|
||||||
apiGroup.PUT("/admin/channels/:id", apiHandler.AdminUpdateChannel)
|
adminGroup.PUT("/channels/:id", apiHandler.AdminUpdateChannel)
|
||||||
apiGroup.DELETE("/admin/channels/:id", apiHandler.AdminDeleteChannel)
|
adminGroup.DELETE("/channels/:id", apiHandler.AdminDeleteChannel)
|
||||||
apiGroup.POST("/admin/channels/:id/test", apiHandler.AdminTestChannel)
|
adminGroup.POST("/channels/:id/test", apiHandler.AdminTestChannel)
|
||||||
apiGroup.GET("/admin/channels/:id/models/remote", apiHandler.AdminChannelRemoteModels)
|
adminGroup.GET("/channels/:id/models/remote", apiHandler.AdminChannelRemoteModels)
|
||||||
apiGroup.GET("/admin/channels/:id/models", apiHandler.AdminChannelModels)
|
adminGroup.GET("/channels/:id/models", apiHandler.AdminChannelModels)
|
||||||
apiGroup.POST("/admin/channels/:id/models", apiHandler.AdminChannelAddModel)
|
adminGroup.POST("/channels/:id/models", apiHandler.AdminChannelAddModel)
|
||||||
apiGroup.PATCH("/admin/channels/:id/models/:bid", apiHandler.AdminChannelUpdateModel)
|
adminGroup.PATCH("/channels/:id/models/:bid", apiHandler.AdminChannelUpdateModel)
|
||||||
apiGroup.DELETE("/admin/channels/:id/models/:bid", apiHandler.AdminChannelDeleteModel)
|
adminGroup.DELETE("/channels/:id/models/:bid", apiHandler.AdminChannelDeleteModel)
|
||||||
|
|
||||||
// Admin model management (enhanced)
|
// Admin model management (enhanced)
|
||||||
apiGroup.GET("/admin/models", apiHandler.AdminModels)
|
adminGroup.GET("/models", apiHandler.AdminModels)
|
||||||
apiGroup.DELETE("/admin/models/unused", apiHandler.AdminDeleteUnusedModels)
|
adminGroup.DELETE("/models/unused", apiHandler.AdminDeleteUnusedModels)
|
||||||
apiGroup.POST("/admin/models", apiHandler.AdminCreateModel)
|
adminGroup.POST("/models", apiHandler.AdminCreateModel)
|
||||||
apiGroup.PUT("/admin/models/:id", apiHandler.AdminUpdateModel)
|
adminGroup.PUT("/models/:id", apiHandler.AdminUpdateModel)
|
||||||
apiGroup.DELETE("/admin/models/:id", apiHandler.AdminDeleteModel)
|
adminGroup.DELETE("/models/:id", apiHandler.AdminDeleteModel)
|
||||||
apiGroup.POST("/admin/models/:id/bindings", apiHandler.AdminCreateModelBinding)
|
adminGroup.POST("/models/:id/bindings", apiHandler.AdminCreateModelBinding)
|
||||||
apiGroup.DELETE("/admin/models/:id/bindings/:bid", apiHandler.AdminDeleteModelBinding)
|
adminGroup.DELETE("/models/:id/bindings/:bid", apiHandler.AdminDeleteModelBinding)
|
||||||
|
|
||||||
// Admin system config
|
// Admin system config
|
||||||
apiGroup.GET("/admin/config", apiHandler.AdminGetConfig)
|
adminGroup.GET("/config", apiHandler.AdminGetConfig)
|
||||||
apiGroup.PUT("/admin/config", apiHandler.AdminUpdateConfig)
|
adminGroup.PUT("/config", apiHandler.AdminUpdateConfig)
|
||||||
apiGroup.GET("/admin/config/registration", apiHandler.AdminGetRegistration)
|
adminGroup.GET("/config/registration", apiHandler.AdminGetRegistration)
|
||||||
apiGroup.PUT("/admin/config/registration", apiHandler.AdminUpdateRegistration)
|
adminGroup.PUT("/config/registration", apiHandler.AdminUpdateRegistration)
|
||||||
apiGroup.GET("/admin/config/password-login", apiHandler.AdminGetPasswordLogin)
|
adminGroup.GET("/config/password-login", apiHandler.AdminGetPasswordLogin)
|
||||||
apiGroup.PUT("/admin/config/password-login", apiHandler.AdminUpdatePasswordLogin)
|
adminGroup.PUT("/config/password-login", apiHandler.AdminUpdatePasswordLogin)
|
||||||
|
|
||||||
|
// Admin usage
|
||||||
|
adminGroup.GET("/usage/logs", apiHandler.AdminUsageLogs)
|
||||||
|
adminGroup.GET("/usage/summary", apiHandler.AdminUsageSummary)
|
||||||
}
|
}
|
||||||
|
|
||||||
// LLM proxy routes
|
// LLM proxy routes
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
// src/stores/usage.ts
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import request from '@/api/client'
|
||||||
|
import type { UsageStatsData, UsageLogItem, AdminUsageSummary } from '@/types'
|
||||||
|
|
||||||
|
export const useUsageStore = defineStore('usage', () => {
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
// 普通用户:每日统计
|
||||||
|
const stats = ref<UsageStatsData | null>(null)
|
||||||
|
|
||||||
|
// 普通用户:自身明细
|
||||||
|
const myLogs = ref<UsageLogItem[]>([])
|
||||||
|
const myLogsTotal = ref(0)
|
||||||
|
|
||||||
|
// 管理后台:全量明细
|
||||||
|
const adminLogs = ref<UsageLogItem[]>([])
|
||||||
|
const adminLogsTotal = ref(0)
|
||||||
|
const adminSummary = ref<AdminUsageSummary | null>(null)
|
||||||
|
|
||||||
|
async function fetchStats(days = 30) {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const res = await request.get('/usage/stats', { params: { days } })
|
||||||
|
stats.value = res.data?.data ?? null
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value = err.response?.data?.error || '获取用量统计失败'
|
||||||
|
throw err
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchMyLogs(pageSize = 20, page = 1) {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const res = await request.get('/usage/logs', { params: { pageSize, page } })
|
||||||
|
myLogs.value = res.data?.data ?? []
|
||||||
|
myLogsTotal.value = res.data?.total ?? 0
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value = err.response?.data?.error || '获取用量明细失败'
|
||||||
|
throw err
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchAdminLogs(params: Record<string, any> = {}) {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const res = await request.get('/admin/usage/logs', { params })
|
||||||
|
adminLogs.value = res.data?.data ?? []
|
||||||
|
adminLogsTotal.value = res.data?.total ?? 0
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value = err.response?.data?.error || '获取用量明细失败'
|
||||||
|
throw err
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchAdminSummary(params: Record<string, any> = {}) {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const res = await request.get('/admin/usage/summary', { params })
|
||||||
|
adminSummary.value = res.data?.data ?? null
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value = err.response?.data?.error || '获取用量汇总失败'
|
||||||
|
throw err
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
stats,
|
||||||
|
myLogs,
|
||||||
|
myLogsTotal,
|
||||||
|
adminLogs,
|
||||||
|
adminLogsTotal,
|
||||||
|
adminSummary,
|
||||||
|
fetchStats,
|
||||||
|
fetchMyLogs,
|
||||||
|
fetchAdminLogs,
|
||||||
|
fetchAdminSummary,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -171,3 +171,58 @@ export interface ModelSummary {
|
|||||||
missing: { channel: string; model_id: number; upstream_model: string }[]
|
missing: { channel: string; model_id: number; upstream_model: string }[]
|
||||||
denied_count: number
|
denied_count: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- 用量统计 ----
|
||||||
|
|
||||||
|
export interface UsageDaily {
|
||||||
|
id?: number
|
||||||
|
user_id?: number
|
||||||
|
model_id?: number
|
||||||
|
date: string
|
||||||
|
requests: number
|
||||||
|
input_tokens: number
|
||||||
|
output_tokens: number
|
||||||
|
cache_read_tokens: number
|
||||||
|
cost: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UsageTotals {
|
||||||
|
requests: number
|
||||||
|
input_tokens: number
|
||||||
|
output_tokens: number
|
||||||
|
cache_read_tokens: number
|
||||||
|
cost: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UsageStatsData {
|
||||||
|
dates: string[]
|
||||||
|
daily: Record<string, UsageDaily>
|
||||||
|
totals: UsageTotals
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UsageLogItem {
|
||||||
|
id: number
|
||||||
|
request_id?: string
|
||||||
|
user_id: number
|
||||||
|
channel_id: number
|
||||||
|
model_id: number
|
||||||
|
model_name: string
|
||||||
|
protocol: string
|
||||||
|
input_tokens: number
|
||||||
|
output_tokens: number
|
||||||
|
cache_read_tokens: number
|
||||||
|
cache_creation_tokens: number
|
||||||
|
cost: number
|
||||||
|
latency_ms: number
|
||||||
|
status: string
|
||||||
|
error_code?: string | null
|
||||||
|
created_at: string
|
||||||
|
username?: string
|
||||||
|
raw_request?: string
|
||||||
|
raw_response?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminUsageSummary {
|
||||||
|
totals: UsageTotals
|
||||||
|
per_user: Record<string, { user_id: number; requests: number; input_tokens: number; output_tokens: number; cost: number }>
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
GlobeIcon,
|
GlobeIcon,
|
||||||
BoxesIcon,
|
BoxesIcon,
|
||||||
SlidersHorizontalIcon,
|
SlidersHorizontalIcon,
|
||||||
|
ChartColumnBig,
|
||||||
} from '@lucide/vue'
|
} from '@lucide/vue'
|
||||||
|
|
||||||
export type MenuLink = { label: string; to: string; icon?: Component }
|
export type MenuLink = { label: string; to: string; icon?: Component }
|
||||||
@@ -40,6 +41,7 @@ export const routes: RouteRecordRaw[] = [
|
|||||||
redirect: '/dashboard/overview',
|
redirect: '/dashboard/overview',
|
||||||
children: [
|
children: [
|
||||||
{ path: 'overview', name: 'Overview', component: () => import('@/views/dashboard/Overview.vue'), meta: { title: '仪表盘' } },
|
{ path: 'overview', name: 'Overview', component: () => import('@/views/dashboard/Overview.vue'), meta: { title: '仪表盘' } },
|
||||||
|
{ path: 'usage', name: 'UsageStats', component: () => import('@/views/dashboard/UsageStats.vue'), meta: { title: '用量统计' } },
|
||||||
{ path: 'apikeys', name: 'ApiKeys', component: () => import('@/views/dashboard/ApiKeys.vue'), meta: { title: 'API Keys' } },
|
{ path: 'apikeys', name: 'ApiKeys', component: () => import('@/views/dashboard/ApiKeys.vue'), meta: { title: 'API Keys' } },
|
||||||
{
|
{
|
||||||
path: 'manager',
|
path: 'manager',
|
||||||
@@ -52,6 +54,7 @@ export const routes: RouteRecordRaw[] = [
|
|||||||
{ path: 'users/view', name: 'UserView', component: () => import('@/views/dashboard/UserView.vue'), meta: { title: '用户详情' } },
|
{ path: 'users/view', name: 'UserView', component: () => import('@/views/dashboard/UserView.vue'), meta: { title: '用户详情' } },
|
||||||
{ path: 'channels', name: 'Channels', component: () => import('@/views/dashboard/ChannelsView.vue'), meta: { title: '渠道管理' } },
|
{ path: 'channels', name: 'Channels', component: () => import('@/views/dashboard/ChannelsView.vue'), meta: { title: '渠道管理' } },
|
||||||
{ path: 'models', name: 'Models', component: () => import('@/views/dashboard/Models.vue'), meta: { title: '模型定价' } },
|
{ path: 'models', name: 'Models', component: () => import('@/views/dashboard/Models.vue'), meta: { title: '模型定价' } },
|
||||||
|
{ path: 'usage-logs', name: 'UsageLogs', component: () => import('@/views/dashboard/UsageLogs.vue'), meta: { title: '用量明细' } },
|
||||||
{ path: 'config', name: 'SystemConfig', component: () => import('@/views/dashboard/SystemConfig.vue'), meta: { title: '系统配置' } },
|
{ path: 'config', name: 'SystemConfig', component: () => import('@/views/dashboard/SystemConfig.vue'), meta: { title: '系统配置' } },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -71,6 +74,7 @@ export const routes: RouteRecordRaw[] = [
|
|||||||
// 控制台菜单(所有登录用户)
|
// 控制台菜单(所有登录用户)
|
||||||
export const consoleMenu: MenuLink[] = [
|
export const consoleMenu: MenuLink[] = [
|
||||||
{ label: '仪表盘', to: '/dashboard/overview', icon: GaugeIcon },
|
{ label: '仪表盘', to: '/dashboard/overview', icon: GaugeIcon },
|
||||||
|
{ label: '用量统计', to: '/dashboard/usage', icon: ChartColumnBig },
|
||||||
{ label: 'API Keys', to: '/dashboard/apikeys', icon: KeyRoundIcon },
|
{ label: 'API Keys', to: '/dashboard/apikeys', icon: KeyRoundIcon },
|
||||||
{ label: '账户设置', to: '/dashboard/settings/profile', icon: SettingsIcon },
|
{ label: '账户设置', to: '/dashboard/settings/profile', icon: SettingsIcon },
|
||||||
]
|
]
|
||||||
@@ -80,5 +84,6 @@ export const adminMenu: MenuLink[] = [
|
|||||||
{ label: '用户管理', to: '/dashboard/manager/users', icon: UsersRoundIcon },
|
{ label: '用户管理', to: '/dashboard/manager/users', icon: UsersRoundIcon },
|
||||||
{ label: '渠道管理', to: '/dashboard/manager/channels', icon: GlobeIcon },
|
{ label: '渠道管理', to: '/dashboard/manager/channels', icon: GlobeIcon },
|
||||||
{ label: '模型定价', to: '/dashboard/manager/models', icon: BoxesIcon },
|
{ label: '模型定价', to: '/dashboard/manager/models', icon: BoxesIcon },
|
||||||
|
{ label: '用量明细', to: '/dashboard/manager/usage-logs', icon: ChartColumnBig },
|
||||||
{ label: '系统配置', to: '/dashboard/manager/config', icon: SlidersHorizontalIcon },
|
{ label: '系统配置', to: '/dashboard/manager/config', icon: SlidersHorizontalIcon },
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,240 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-5">
|
||||||
|
<BreadcrumbHeader />
|
||||||
|
|
||||||
|
<!-- 汇总卡片 -->
|
||||||
|
<div class="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||||
|
<div class="card border border-base-300/60 bg-base-100 p-4 shadow-sm">
|
||||||
|
<p class="text-xs text-base-content/50">请求总数</p>
|
||||||
|
<p class="mt-1 text-2xl font-semibold tabular-nums">{{ fmtInt(summary?.requests) }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="card border border-base-300/60 bg-base-100 p-4 shadow-sm">
|
||||||
|
<p class="text-xs text-base-content/50">输入 Tokens</p>
|
||||||
|
<p class="mt-1 text-2xl font-semibold tabular-nums">{{ fmtInt(summary?.input_tokens) }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="card border border-base-300/60 bg-base-100 p-4 shadow-sm">
|
||||||
|
<p class="text-xs text-base-content/50">输出 Tokens</p>
|
||||||
|
<p class="mt-1 text-2xl font-semibold tabular-nums">{{ fmtInt(summary?.output_tokens) }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="card border border-base-300/60 bg-base-100 p-4 shadow-sm">
|
||||||
|
<p class="text-xs text-base-content/50">总费用 (USD)</p>
|
||||||
|
<p class="mt-1 text-2xl font-semibold tabular-nums">{{ fmtCost(summary?.cost) }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 筛选栏 -->
|
||||||
|
<div class="card border border-base-300/60 bg-base-100 p-4 shadow-sm">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<select v-model="filters.protocol" class="select select-sm border-base-300 bg-base-100" @change="applyFilters">
|
||||||
|
<option value="">全部协议</option>
|
||||||
|
<option value="chat">chat</option>
|
||||||
|
<option value="messages">messages</option>
|
||||||
|
<option value="responses">responses</option>
|
||||||
|
</select>
|
||||||
|
<select v-model="filters.status" class="select select-sm border-base-300 bg-base-100" @change="applyFilters">
|
||||||
|
<option value="">全部状态</option>
|
||||||
|
<option value="success">成功</option>
|
||||||
|
<option value="error">失败</option>
|
||||||
|
<option value="canceled">已取消</option>
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
v-model="filters.model"
|
||||||
|
class="input input-sm w-44 border-base-300 bg-base-100"
|
||||||
|
placeholder="模型名称"
|
||||||
|
@keyup.enter="applyFilters"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
v-model="filters.userId"
|
||||||
|
class="input input-sm w-32 border-base-300 bg-base-100"
|
||||||
|
placeholder="用户 ID"
|
||||||
|
@keyup.enter="applyFilters"
|
||||||
|
/>
|
||||||
|
<button class="btn btn-primary btn-sm" @click="applyFilters">
|
||||||
|
<SearchIcon class="h-4 w-4" aria-hidden="true" />筛选
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-ghost btn-sm" @click="resetFilters">
|
||||||
|
<RotateCcwIcon class="h-4 w-4" aria-hidden="true" />重置
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 明细表格 -->
|
||||||
|
<div class="card border border-base-300/60 bg-base-100 shadow-sm">
|
||||||
|
<div v-if="store.loading && !store.adminLogs.length" class="px-4 py-12 text-center text-sm text-base-content/50">加载中…</div>
|
||||||
|
<div v-else-if="store.adminLogs.length" class="overflow-x-auto">
|
||||||
|
<table class="table table-sm">
|
||||||
|
<thead>
|
||||||
|
<tr class="text-xs uppercase tracking-wider text-base-content/50">
|
||||||
|
<th>ID</th>
|
||||||
|
<th>用户</th>
|
||||||
|
<th>时间</th>
|
||||||
|
<th>模型</th>
|
||||||
|
<th>协议</th>
|
||||||
|
<th class="text-right">输入</th>
|
||||||
|
<th class="text-right">输出</th>
|
||||||
|
<th class="text-right">费用</th>
|
||||||
|
<th>状态</th>
|
||||||
|
<th class="pr-4 text-right"><span class="sr-only">Actions</span></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="l in store.adminLogs" :key="l.id" class="border-base-300/40 hover:bg-base-200/50">
|
||||||
|
<td class="tabular-nums text-base-content/60">{{ l.id }}</td>
|
||||||
|
<td class="whitespace-nowrap font-medium">
|
||||||
|
<span v-if="l.username">{{ l.username }}</span>
|
||||||
|
<span v-else class="text-base-content/50">#{{ l.user_id }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="whitespace-nowrap tabular-nums text-base-content/70">{{ fmtTime(l.created_at) }}</td>
|
||||||
|
<td class="max-w-40 truncate" :title="l.model_name">{{ l.model_name }}</td>
|
||||||
|
<td><span class="badge badge-ghost badge-sm">{{ l.protocol }}</span></td>
|
||||||
|
<td class="text-right tabular-nums">{{ fmtInt(l.input_tokens) }}</td>
|
||||||
|
<td class="text-right tabular-nums">{{ fmtInt(l.output_tokens) }}</td>
|
||||||
|
<td class="text-right tabular-nums">{{ fmtCost(l.cost) }}</td>
|
||||||
|
<td><span class="badge badge-sm" :class="statusClass(l.status)">{{ statusLabel(l.status) }}</span></td>
|
||||||
|
<td class="pr-3">
|
||||||
|
<div class="flex items-center justify-end gap-1">
|
||||||
|
<button
|
||||||
|
v-if="l.raw_request || l.raw_response"
|
||||||
|
class="btn btn-ghost btn-xs btn-square"
|
||||||
|
:aria-label="`View raw data for request ${l.id}`"
|
||||||
|
@click="viewRaw(l)"
|
||||||
|
>
|
||||||
|
<FileTextIcon class="h-4 w-4" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div v-else class="px-4 py-12 text-center text-sm text-base-content/50">暂无用量记录</div>
|
||||||
|
<Pagination
|
||||||
|
v-if="store.adminLogsTotal > 0"
|
||||||
|
:current-page="page"
|
||||||
|
:total-items="store.adminLogsTotal"
|
||||||
|
:page-size="pageSize"
|
||||||
|
:page-size-options="[10, 20, 50, 100]"
|
||||||
|
@change-page="changePage"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 原始请求/响应 查看弹窗 -->
|
||||||
|
<dialog ref="rawModal" class="modal">
|
||||||
|
<div class="modal-box max-w-3xl">
|
||||||
|
<form method="dialog">
|
||||||
|
<button class="btn btn-circle btn-ghost btn-sm absolute right-2 top-2" aria-label="Close">✕</button>
|
||||||
|
</form>
|
||||||
|
<h3 class="text-lg font-semibold">原始数据 #{{ currentRaw?.id }}</h3>
|
||||||
|
<div class="mt-4 space-y-4 text-sm">
|
||||||
|
<div v-if="currentRaw?.raw_request">
|
||||||
|
<h4 class="mb-1 font-medium text-base-content/70">原始请求</h4>
|
||||||
|
<pre class="max-h-64 overflow-auto rounded-lg bg-base-200/50 p-3 text-xs leading-relaxed">{{ currentRaw.raw_request }}</pre>
|
||||||
|
</div>
|
||||||
|
<div v-if="currentRaw?.raw_response">
|
||||||
|
<h4 class="mb-1 font-medium text-base-content/70">原始响应</h4>
|
||||||
|
<pre class="max-h-64 overflow-auto rounded-lg bg-base-200/50 p-3 text-xs leading-relaxed">{{ currentRaw.raw_response }}</pre>
|
||||||
|
</div>
|
||||||
|
<p v-if="!currentRaw?.raw_request && !currentRaw?.raw_response" class="text-base-content/50">该请求未记录原始数据(仅管理员且开关开启时记录)。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form method="dialog" class="modal-backdrop">
|
||||||
|
<button aria-label="Close">close</button>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, onMounted, computed } from 'vue'
|
||||||
|
import BreadcrumbHeader from '@/components/dashboard/BreadcrumbHeader.vue'
|
||||||
|
import Pagination from '@/components/common/Pagination.vue'
|
||||||
|
import { useUsageStore } from '@/stores/usage'
|
||||||
|
import type { UsageLogItem } from '@/types'
|
||||||
|
import { SearchIcon, RotateCcwIcon, FileTextIcon } from '@lucide/vue'
|
||||||
|
|
||||||
|
const store = useUsageStore()
|
||||||
|
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = ref(20)
|
||||||
|
const filters = reactive({ protocol: '', status: '', model: '', userId: '' })
|
||||||
|
|
||||||
|
const summary = computed(() => store.adminSummary?.totals)
|
||||||
|
|
||||||
|
// 原始数据弹窗
|
||||||
|
const rawModal = ref<HTMLDialogElement | null>(null)
|
||||||
|
const currentRaw = ref<UsageLogItem | null>(null)
|
||||||
|
function viewRaw(item: UsageLogItem) {
|
||||||
|
currentRaw.value = item
|
||||||
|
rawModal.value?.showModal()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadLogs() {
|
||||||
|
const params: Record<string, any> = { page: page.value, pageSize: pageSize.value }
|
||||||
|
if (filters.protocol) params.protocol = filters.protocol
|
||||||
|
if (filters.status) params.status = filters.status
|
||||||
|
if (filters.model) params.model = filters.model
|
||||||
|
if (filters.userId) params.user_id = filters.userId
|
||||||
|
try {
|
||||||
|
await store.fetchAdminLogs(params)
|
||||||
|
} catch { /* store 已抛错 */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSummary() {
|
||||||
|
try {
|
||||||
|
await store.fetchAdminSummary()
|
||||||
|
} catch { /* 同上 */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFilters() {
|
||||||
|
page.value = 1
|
||||||
|
loadLogs()
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetFilters() {
|
||||||
|
filters.protocol = ''
|
||||||
|
filters.status = ''
|
||||||
|
filters.model = ''
|
||||||
|
filters.userId = ''
|
||||||
|
applyFilters()
|
||||||
|
}
|
||||||
|
|
||||||
|
function changePage(p: number, s: number) {
|
||||||
|
page.value = p
|
||||||
|
pageSize.value = s
|
||||||
|
loadLogs()
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtInt(n?: number): string {
|
||||||
|
return (n ?? 0).toLocaleString()
|
||||||
|
}
|
||||||
|
function fmtCost(n?: number): string {
|
||||||
|
return `$${(n ?? 0).toFixed(6)}`
|
||||||
|
}
|
||||||
|
function fmtTime(t?: string): string {
|
||||||
|
if (!t) return '—'
|
||||||
|
const d = new Date(t)
|
||||||
|
const pad = (x: number) => String(x).padStart(2, '0')
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||||
|
}
|
||||||
|
function statusLabel(s: string): string {
|
||||||
|
switch (s) {
|
||||||
|
case 'success': return '成功'
|
||||||
|
case 'error': return '失败'
|
||||||
|
case 'canceled': return '已取消'
|
||||||
|
default: return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function statusClass(s: string): string {
|
||||||
|
switch (s) {
|
||||||
|
case 'success': return 'badge-success badge-soft'
|
||||||
|
case 'error': return 'badge-error badge-soft'
|
||||||
|
case 'canceled': return 'badge-warning badge-soft'
|
||||||
|
default: return 'badge-ghost'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadLogs()
|
||||||
|
loadSummary()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-5">
|
||||||
|
<BreadcrumbHeader />
|
||||||
|
|
||||||
|
<div v-if="store.loading && !store.stats" class="py-16 text-center text-sm text-base-content/50">
|
||||||
|
加载中…
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<!-- 统计卡片 -->
|
||||||
|
<div class="grid grid-cols-2 gap-4 lg:grid-cols-5">
|
||||||
|
<div class="card border border-base-300/60 bg-base-100 p-4 shadow-sm">
|
||||||
|
<p class="text-xs text-base-content/50">请求次数</p>
|
||||||
|
<p class="mt-1 text-2xl font-semibold tabular-nums">{{ fmtInt(totals.requests) }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="card border border-base-300/60 bg-base-100 p-4 shadow-sm">
|
||||||
|
<p class="text-xs text-base-content/50">输入 Tokens</p>
|
||||||
|
<p class="mt-1 text-2xl font-semibold tabular-nums">{{ fmtInt(totals.input_tokens) }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="card border border-base-300/60 bg-base-100 p-4 shadow-sm">
|
||||||
|
<p class="text-xs text-base-content/50">输出 Tokens</p>
|
||||||
|
<p class="mt-1 text-2xl font-semibold tabular-nums">{{ fmtInt(totals.output_tokens) }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="card border border-base-300/60 bg-base-100 p-4 shadow-sm">
|
||||||
|
<p class="text-xs text-base-content/50">缓存 Tokens</p>
|
||||||
|
<p class="mt-1 text-2xl font-semibold tabular-nums">{{ fmtInt(totals.cache_read_tokens) }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="card border border-base-300/60 bg-base-100 p-4 shadow-sm">
|
||||||
|
<p class="text-xs text-base-content/50">费用 (USD)</p>
|
||||||
|
<p class="mt-1 text-2xl font-semibold tabular-nums">{{ fmtCost(totals.cost) }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 每日请求量条形图(纯 CSS) -->
|
||||||
|
<div class="card border border-base-300/60 bg-base-100 p-5 shadow-sm">
|
||||||
|
<div class="mb-4 flex items-center justify-between">
|
||||||
|
<h3 class="text-sm font-semibold">每日请求量</h3>
|
||||||
|
<select v-model="days" class="select select-sm border-base-300 bg-base-100" @change="loadStats">
|
||||||
|
<option :value="7">近 7 天</option>
|
||||||
|
<option :value="30">近 30 天</option>
|
||||||
|
<option :value="90">近 90 天</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div v-if="barItems.length" class="flex h-40 items-end gap-1">
|
||||||
|
<div v-for="b in barItems" :key="b.date" class="flex flex-1 flex-col items-center gap-1" :title="`${b.date}: ${b.requests} 次`">
|
||||||
|
<div class="w-full rounded-t bg-primary/70 transition-all" :style="{ height: b.height + 'px' }"></div>
|
||||||
|
<span class="text-[9px] leading-none text-base-content/40">{{ b.label }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="py-10 text-center text-sm text-base-content/50">暂无用量数据</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 明细表格 -->
|
||||||
|
<div class="card border border-base-300/60 bg-base-100 shadow-sm">
|
||||||
|
<div class="flex items-center justify-between px-5 pt-4">
|
||||||
|
<h3 class="text-sm font-semibold">请求明细</h3>
|
||||||
|
</div>
|
||||||
|
<div v-if="store.myLogs.length" class="overflow-x-auto">
|
||||||
|
<table class="table table-sm">
|
||||||
|
<thead>
|
||||||
|
<tr class="text-xs uppercase tracking-wider text-base-content/50">
|
||||||
|
<th>时间</th>
|
||||||
|
<th>模型</th>
|
||||||
|
<th>协议</th>
|
||||||
|
<th class="text-right">输入</th>
|
||||||
|
<th class="text-right">输出</th>
|
||||||
|
<th class="text-right">缓存</th>
|
||||||
|
<th class="text-right">费用</th>
|
||||||
|
<th>状态</th>
|
||||||
|
<th class="text-right">延迟</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="l in store.myLogs" :key="l.id" class="border-base-300/40 hover:bg-base-200/50">
|
||||||
|
<td class="whitespace-nowrap tabular-nums text-base-content/70">{{ fmtTime(l.created_at) }}</td>
|
||||||
|
<td class="max-w-40 truncate font-medium">{{ l.model_name }}</td>
|
||||||
|
<td><span class="badge badge-ghost badge-sm">{{ l.protocol }}</span></td>
|
||||||
|
<td class="text-right tabular-nums">{{ fmtInt(l.input_tokens) }}</td>
|
||||||
|
<td class="text-right tabular-nums">{{ fmtInt(l.output_tokens) }}</td>
|
||||||
|
<td class="text-right tabular-nums">{{ fmtInt(l.cache_read_tokens) }}</td>
|
||||||
|
<td class="text-right tabular-nums">{{ fmtCost(l.cost) }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge badge-sm" :class="statusClass(l.status)">
|
||||||
|
{{ statusLabel(l.status) }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="text-right tabular-nums text-base-content/70">{{ l.latency_ms }}ms</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div v-else class="px-5 py-12 text-center text-sm text-base-content/50">暂无请求记录</div>
|
||||||
|
<Pagination v-if="myLogsTotal > 0" :current-page="page" :total-items="myLogsTotal" :page-size="pageSize"
|
||||||
|
:page-size-options="[10, 20, 50]" @change-page="changePage" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import BreadcrumbHeader from '@/components/dashboard/BreadcrumbHeader.vue'
|
||||||
|
import Pagination from '@/components/common/Pagination.vue'
|
||||||
|
import { useUsageStore } from '@/stores/usage'
|
||||||
|
|
||||||
|
const store = useUsageStore()
|
||||||
|
|
||||||
|
const days = ref(30)
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = ref(20)
|
||||||
|
const myLogsTotal = computed(() => store.myLogsTotal)
|
||||||
|
const totals = computed(() => store.stats?.totals ?? {
|
||||||
|
requests: 0, input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, cost: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const barItems = computed(() => {
|
||||||
|
const stats = store.stats
|
||||||
|
if (!stats) return []
|
||||||
|
const max = Math.max(1, ...stats.dates.map(d => stats.daily[d]?.requests ?? 0))
|
||||||
|
return stats.dates.slice(-14).map(d => ({
|
||||||
|
date: d,
|
||||||
|
requests: stats.daily[d]?.requests ?? 0,
|
||||||
|
height: Math.round(((stats.daily[d]?.requests ?? 0) / max) * 120),
|
||||||
|
label: d.slice(5).replace('-', '/'),
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadStats() {
|
||||||
|
try {
|
||||||
|
await store.fetchStats(days.value)
|
||||||
|
} catch { /* toast 由 store 抛错,页面保持静默 */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadLogs() {
|
||||||
|
try {
|
||||||
|
await store.fetchMyLogs(pageSize.value, page.value)
|
||||||
|
} catch { /* 同上 */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
const changePage = (p: number, s: number) => {
|
||||||
|
page.value = p
|
||||||
|
pageSize.value = s
|
||||||
|
loadLogs()
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtInt(n?: number): string {
|
||||||
|
return (n ?? 0).toLocaleString()
|
||||||
|
}
|
||||||
|
function fmtCost(n?: number): string {
|
||||||
|
return `$${(n ?? 0).toFixed(6)}`
|
||||||
|
}
|
||||||
|
function fmtTime(t?: string): string {
|
||||||
|
if (!t) return '—'
|
||||||
|
const d = new Date(t)
|
||||||
|
const pad = (x: number) => String(x).padStart(2, '0')
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusLabel(s: string): string {
|
||||||
|
switch (s) {
|
||||||
|
case 'success': return '成功'
|
||||||
|
case 'error': return '失败'
|
||||||
|
case 'canceled': return '已取消'
|
||||||
|
default: return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function statusClass(s: string): string {
|
||||||
|
switch (s) {
|
||||||
|
case 'success': return 'badge-success badge-soft'
|
||||||
|
case 'error': return 'badge-error badge-soft'
|
||||||
|
case 'canceled': return 'badge-warning badge-soft'
|
||||||
|
default: return 'badge-ghost'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadStats()
|
||||||
|
loadLogs()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
Reference in New Issue
Block a user