* feat(frontend): 前端界面优化与使用统计功能增强
主要改动:
1. 表格布局统一优化
- 新增 TablePageLayout 通用布局组件
- 统一所有管理页面的表格样式和交互
- 优化 DataTable、Pagination、Select 等通用组件
2. 使用统计功能增强
- 管理端: 添加完整的筛选和显示功能
- 用户端: 完善 API Key 列显示
- 后端: 优化使用统计数据结构和查询
3. 账户组件优化
- 优化 AccountStatsModal、AccountUsageCell 等组件
- 统一进度条和统计显示样式
4. 其他改进
- 完善中英文国际化
- 统一页面样式和交互体验
- 优化各视图页面的响应式布局
* fix(test): 修复 stubUsageLogRepo.ListWithFilters 测试 stub
测试用例 GET /api/v1/usage 返回 500 是因为 stub 方法未实现,
现在正确返回基于 UserID 过滤的日志数据。
* feat(frontend): 统一日期时间显示格式
**主要改动**:
1. 增强 utils/format.ts:
- 新增 formatDateOnly() - 格式: YYYY-MM-DD
- 新增 formatDateTime() - 格式: YYYY-MM-DD HH:mm:ss
2. 全局替换视图中的格式化函数:
- 移除各视图中的自定义 formatDate 函数
- 统一导入使用 @/utils/format 中的函数
- created_at/updated_at 使用 formatDateTime
- expires_at 使用 formatDateOnly
3. 受影响的视图 (8个):
- frontend/src/views/user/KeysView.vue
- frontend/src/views/user/DashboardView.vue
- frontend/src/views/user/UsageView.vue
- frontend/src/views/user/RedeemView.vue
- frontend/src/views/admin/UsersView.vue
- frontend/src/views/admin/UsageView.vue
- frontend/src/views/admin/RedeemView.vue
- frontend/src/views/admin/SubscriptionsView.vue
**效果**:
- 日期统一显示为 YYYY-MM-DD
- 时间统一显示为 YYYY-MM-DD HH:mm:ss
- 提升可维护性,避免格式不一致
* fix(frontend): 补充遗漏的时间格式化统一
**补充修复**(基于 code review 发现的遗漏):
1. 增强 utils/format.ts:
- 新增 formatTime() - 格式: HH:mm
2. 修复 4 个遗漏的文件:
- src/views/admin/UsersView.vue
* 删除 formatExpiresAt(),改用 formatDateTime()
* 修复订阅过期时间 tooltip 显示格式不一致问题
- src/views/user/ProfileView.vue
* 删除 formatMemberSince(),改用 formatDate(date, 'YYYY-MM')
* 统一会员起始时间显示格式
- src/views/user/SubscriptionsView.vue
* 修改 formatExpirationDate() 使用 formatDateOnly()
* 保留天数计算逻辑
- src/components/account/AccountStatusIndicator.vue
* 删除本地 formatTime(),改用 utils/format 中的统一函数
* 修复 rate limit 和 overload 重置时间显示
**验证**:
- TypeScript 类型检查通过 ✓
- 前端构建成功 ✓
- 所有剩余的 toLocaleString() 都是数字格式化,属于正确用法 ✓
**效果**:
- 订阅过期时间统一为 YYYY-MM-DD HH:mm:ss
- 会员起始时间统一为 YYYY-MM
- 重置时间统一为 HH:mm
- 消除所有不规范的原生 locale 方法调用
312 lines
7.4 KiB
Go
312 lines
7.4 KiB
Go
package admin
|
|
|
|
import (
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
|
|
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
|
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
|
"github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
|
|
"github.com/Wei-Shaw/sub2api/internal/pkg/usagestats"
|
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
// UsageHandler handles admin usage-related requests
|
|
type UsageHandler struct {
|
|
usageService *service.UsageService
|
|
apiKeyService *service.ApiKeyService
|
|
adminService service.AdminService
|
|
}
|
|
|
|
// NewUsageHandler creates a new admin usage handler
|
|
func NewUsageHandler(
|
|
usageService *service.UsageService,
|
|
apiKeyService *service.ApiKeyService,
|
|
adminService service.AdminService,
|
|
) *UsageHandler {
|
|
return &UsageHandler{
|
|
usageService: usageService,
|
|
apiKeyService: apiKeyService,
|
|
adminService: adminService,
|
|
}
|
|
}
|
|
|
|
// List handles listing all usage records with filters
|
|
// GET /api/v1/admin/usage
|
|
func (h *UsageHandler) List(c *gin.Context) {
|
|
page, pageSize := response.ParsePagination(c)
|
|
|
|
// Parse filters
|
|
var userID, apiKeyID, accountID, groupID int64
|
|
if userIDStr := c.Query("user_id"); userIDStr != "" {
|
|
id, err := strconv.ParseInt(userIDStr, 10, 64)
|
|
if err != nil {
|
|
response.BadRequest(c, "Invalid user_id")
|
|
return
|
|
}
|
|
userID = id
|
|
}
|
|
|
|
if apiKeyIDStr := c.Query("api_key_id"); apiKeyIDStr != "" {
|
|
id, err := strconv.ParseInt(apiKeyIDStr, 10, 64)
|
|
if err != nil {
|
|
response.BadRequest(c, "Invalid api_key_id")
|
|
return
|
|
}
|
|
apiKeyID = id
|
|
}
|
|
|
|
if accountIDStr := c.Query("account_id"); accountIDStr != "" {
|
|
id, err := strconv.ParseInt(accountIDStr, 10, 64)
|
|
if err != nil {
|
|
response.BadRequest(c, "Invalid account_id")
|
|
return
|
|
}
|
|
accountID = id
|
|
}
|
|
|
|
if groupIDStr := c.Query("group_id"); groupIDStr != "" {
|
|
id, err := strconv.ParseInt(groupIDStr, 10, 64)
|
|
if err != nil {
|
|
response.BadRequest(c, "Invalid group_id")
|
|
return
|
|
}
|
|
groupID = id
|
|
}
|
|
|
|
model := c.Query("model")
|
|
|
|
var stream *bool
|
|
if streamStr := c.Query("stream"); streamStr != "" {
|
|
val, err := strconv.ParseBool(streamStr)
|
|
if err != nil {
|
|
response.BadRequest(c, "Invalid stream value, use true or false")
|
|
return
|
|
}
|
|
stream = &val
|
|
}
|
|
|
|
var billingType *int8
|
|
if billingTypeStr := c.Query("billing_type"); billingTypeStr != "" {
|
|
val, err := strconv.ParseInt(billingTypeStr, 10, 8)
|
|
if err != nil {
|
|
response.BadRequest(c, "Invalid billing_type")
|
|
return
|
|
}
|
|
bt := int8(val)
|
|
billingType = &bt
|
|
}
|
|
|
|
// Parse date range
|
|
var startTime, endTime *time.Time
|
|
if startDateStr := c.Query("start_date"); startDateStr != "" {
|
|
t, err := timezone.ParseInLocation("2006-01-02", startDateStr)
|
|
if err != nil {
|
|
response.BadRequest(c, "Invalid start_date format, use YYYY-MM-DD")
|
|
return
|
|
}
|
|
startTime = &t
|
|
}
|
|
|
|
if endDateStr := c.Query("end_date"); endDateStr != "" {
|
|
t, err := timezone.ParseInLocation("2006-01-02", endDateStr)
|
|
if err != nil {
|
|
response.BadRequest(c, "Invalid end_date format, use YYYY-MM-DD")
|
|
return
|
|
}
|
|
// Set end time to end of day
|
|
t = t.Add(24*time.Hour - time.Nanosecond)
|
|
endTime = &t
|
|
}
|
|
|
|
params := pagination.PaginationParams{Page: page, PageSize: pageSize}
|
|
filters := usagestats.UsageLogFilters{
|
|
UserID: userID,
|
|
ApiKeyID: apiKeyID,
|
|
AccountID: accountID,
|
|
GroupID: groupID,
|
|
Model: model,
|
|
Stream: stream,
|
|
BillingType: billingType,
|
|
StartTime: startTime,
|
|
EndTime: endTime,
|
|
}
|
|
|
|
records, result, err := h.usageService.ListWithFilters(c.Request.Context(), params, filters)
|
|
if err != nil {
|
|
response.ErrorFrom(c, err)
|
|
return
|
|
}
|
|
|
|
out := make([]dto.UsageLog, 0, len(records))
|
|
for i := range records {
|
|
out = append(out, *dto.UsageLogFromService(&records[i]))
|
|
}
|
|
response.Paginated(c, out, result.Total, page, pageSize)
|
|
}
|
|
|
|
// Stats handles getting usage statistics with filters
|
|
// GET /api/v1/admin/usage/stats
|
|
func (h *UsageHandler) Stats(c *gin.Context) {
|
|
// Parse filters
|
|
var userID, apiKeyID int64
|
|
if userIDStr := c.Query("user_id"); userIDStr != "" {
|
|
id, err := strconv.ParseInt(userIDStr, 10, 64)
|
|
if err != nil {
|
|
response.BadRequest(c, "Invalid user_id")
|
|
return
|
|
}
|
|
userID = id
|
|
}
|
|
|
|
if apiKeyIDStr := c.Query("api_key_id"); apiKeyIDStr != "" {
|
|
id, err := strconv.ParseInt(apiKeyIDStr, 10, 64)
|
|
if err != nil {
|
|
response.BadRequest(c, "Invalid api_key_id")
|
|
return
|
|
}
|
|
apiKeyID = id
|
|
}
|
|
|
|
// Parse date range
|
|
now := timezone.Now()
|
|
var startTime, endTime time.Time
|
|
|
|
startDateStr := c.Query("start_date")
|
|
endDateStr := c.Query("end_date")
|
|
|
|
if startDateStr != "" && endDateStr != "" {
|
|
var err error
|
|
startTime, err = timezone.ParseInLocation("2006-01-02", startDateStr)
|
|
if err != nil {
|
|
response.BadRequest(c, "Invalid start_date format, use YYYY-MM-DD")
|
|
return
|
|
}
|
|
endTime, err = timezone.ParseInLocation("2006-01-02", endDateStr)
|
|
if err != nil {
|
|
response.BadRequest(c, "Invalid end_date format, use YYYY-MM-DD")
|
|
return
|
|
}
|
|
endTime = endTime.Add(24*time.Hour - time.Nanosecond)
|
|
} else {
|
|
period := c.DefaultQuery("period", "today")
|
|
switch period {
|
|
case "today":
|
|
startTime = timezone.StartOfDay(now)
|
|
case "week":
|
|
startTime = now.AddDate(0, 0, -7)
|
|
case "month":
|
|
startTime = now.AddDate(0, -1, 0)
|
|
default:
|
|
startTime = timezone.StartOfDay(now)
|
|
}
|
|
endTime = now
|
|
}
|
|
|
|
if apiKeyID > 0 {
|
|
stats, err := h.usageService.GetStatsByApiKey(c.Request.Context(), apiKeyID, startTime, endTime)
|
|
if err != nil {
|
|
response.ErrorFrom(c, err)
|
|
return
|
|
}
|
|
response.Success(c, stats)
|
|
return
|
|
}
|
|
|
|
if userID > 0 {
|
|
stats, err := h.usageService.GetStatsByUser(c.Request.Context(), userID, startTime, endTime)
|
|
if err != nil {
|
|
response.ErrorFrom(c, err)
|
|
return
|
|
}
|
|
response.Success(c, stats)
|
|
return
|
|
}
|
|
|
|
// Get global stats
|
|
stats, err := h.usageService.GetGlobalStats(c.Request.Context(), startTime, endTime)
|
|
if err != nil {
|
|
response.ErrorFrom(c, err)
|
|
return
|
|
}
|
|
|
|
response.Success(c, stats)
|
|
}
|
|
|
|
// SearchUsers handles searching users by email keyword
|
|
// GET /api/v1/admin/usage/search-users
|
|
func (h *UsageHandler) SearchUsers(c *gin.Context) {
|
|
keyword := c.Query("q")
|
|
if keyword == "" {
|
|
response.Success(c, []any{})
|
|
return
|
|
}
|
|
|
|
// Limit to 30 results
|
|
users, _, err := h.adminService.ListUsers(c.Request.Context(), 1, 30, "", "", keyword)
|
|
if err != nil {
|
|
response.ErrorFrom(c, err)
|
|
return
|
|
}
|
|
|
|
// Return simplified user list (only id and email)
|
|
type SimpleUser struct {
|
|
ID int64 `json:"id"`
|
|
Email string `json:"email"`
|
|
}
|
|
|
|
result := make([]SimpleUser, len(users))
|
|
for i, u := range users {
|
|
result[i] = SimpleUser{
|
|
ID: u.ID,
|
|
Email: u.Email,
|
|
}
|
|
}
|
|
|
|
response.Success(c, result)
|
|
}
|
|
|
|
// SearchApiKeys handles searching API keys by user
|
|
// GET /api/v1/admin/usage/search-api-keys
|
|
func (h *UsageHandler) SearchApiKeys(c *gin.Context) {
|
|
userIDStr := c.Query("user_id")
|
|
keyword := c.Query("q")
|
|
|
|
var userID int64
|
|
if userIDStr != "" {
|
|
id, err := strconv.ParseInt(userIDStr, 10, 64)
|
|
if err != nil {
|
|
response.BadRequest(c, "Invalid user_id")
|
|
return
|
|
}
|
|
userID = id
|
|
}
|
|
|
|
keys, err := h.apiKeyService.SearchApiKeys(c.Request.Context(), userID, keyword, 30)
|
|
if err != nil {
|
|
response.ErrorFrom(c, err)
|
|
return
|
|
}
|
|
|
|
// Return simplified API key list (only id and name)
|
|
type SimpleApiKey struct {
|
|
ID int64 `json:"id"`
|
|
Name string `json:"name"`
|
|
UserID int64 `json:"user_id"`
|
|
}
|
|
|
|
result := make([]SimpleApiKey, len(keys))
|
|
for i, k := range keys {
|
|
result[i] = SimpleApiKey{
|
|
ID: k.ID,
|
|
Name: k.Name,
|
|
UserID: k.UserID,
|
|
}
|
|
}
|
|
|
|
response.Success(c, result)
|
|
}
|