feat(table): 表格排序与搜索改为后端处理

This commit is contained in:
IanShaw027 2026-04-09 18:14:28 +08:00
parent 66e15a54a4
commit 5f8e60a1b7
79 changed files with 2282 additions and 240 deletions

View File

@ -221,6 +221,8 @@ func (h *AccountHandler) List(c *gin.Context) {
status := c.Query("status") status := c.Query("status")
search := c.Query("search") search := c.Query("search")
privacyMode := strings.TrimSpace(c.Query("privacy_mode")) privacyMode := strings.TrimSpace(c.Query("privacy_mode"))
sortBy := c.DefaultQuery("sort_by", "name")
sortOrder := c.DefaultQuery("sort_order", "asc")
// 标准化和验证 search 参数 // 标准化和验证 search 参数
search = strings.TrimSpace(search) search = strings.TrimSpace(search)
if len(search) > 100 { if len(search) > 100 {
@ -246,7 +248,7 @@ func (h *AccountHandler) List(c *gin.Context) {
} }
} }
accounts, total, err := h.adminService.ListAccounts(c.Request.Context(), page, pageSize, platform, accountType, status, search, groupID, privacyMode) accounts, total, err := h.adminService.ListAccounts(c.Request.Context(), page, pageSize, platform, accountType, status, search, groupID, privacyMode, sortBy, sortOrder)
if err != nil { if err != nil {
response.ErrorFrom(c, err) response.ErrorFrom(c, err)
return return
@ -2029,7 +2031,7 @@ func (h *AccountHandler) BatchRefreshTier(c *gin.Context) {
accounts := make([]*service.Account, 0) accounts := make([]*service.Account, 0)
if len(req.AccountIDs) == 0 { if len(req.AccountIDs) == 0 {
allAccounts, _, err := h.adminService.ListAccounts(ctx, 1, 10000, "gemini", "oauth", "", "", 0, "") allAccounts, _, err := h.adminService.ListAccounts(ctx, 1, 10000, "gemini", "oauth", "", "", 0, "", "name", "asc")
if err != nil { if err != nil {
response.ErrorFrom(c, err) response.ErrorFrom(c, err)
return return

View File

@ -31,6 +31,33 @@ type stubAdminService struct {
platform string platform string
groupIDs []int64 groupIDs []int64
} }
lastListAccounts struct {
platform string
accountType string
status string
search string
groupID int64
privacyMode string
sortBy string
sortOrder string
calls int
}
lastListProxies struct {
protocol string
status string
search string
sortBy string
sortOrder string
calls int
}
lastListRedeemCodes struct {
codeType string
status string
search string
sortBy string
sortOrder string
calls int
}
mu sync.Mutex mu sync.Mutex
} }
@ -99,7 +126,7 @@ func newStubAdminService() *stubAdminService {
} }
} }
func (s *stubAdminService) ListUsers(ctx context.Context, page, pageSize int, filters service.UserListFilters) ([]service.User, int64, error) { func (s *stubAdminService) ListUsers(ctx context.Context, page, pageSize int, filters service.UserListFilters, sortBy, sortOrder string) ([]service.User, int64, error) {
return s.users, int64(len(s.users)), nil return s.users, int64(len(s.users)), nil
} }
@ -132,7 +159,7 @@ func (s *stubAdminService) UpdateUserBalance(ctx context.Context, userID int64,
return &user, nil return &user, nil
} }
func (s *stubAdminService) GetUserAPIKeys(ctx context.Context, userID int64, page, pageSize int) ([]service.APIKey, int64, error) { func (s *stubAdminService) GetUserAPIKeys(ctx context.Context, userID int64, page, pageSize int, sortBy, sortOrder string) ([]service.APIKey, int64, error) {
return s.apiKeys, int64(len(s.apiKeys)), nil return s.apiKeys, int64(len(s.apiKeys)), nil
} }
@ -140,7 +167,7 @@ func (s *stubAdminService) GetUserUsageStats(ctx context.Context, userID int64,
return map[string]any{"user_id": userID}, nil return map[string]any{"user_id": userID}, nil
} }
func (s *stubAdminService) ListGroups(ctx context.Context, page, pageSize int, platform, status, search string, isExclusive *bool) ([]service.Group, int64, error) { func (s *stubAdminService) ListGroups(ctx context.Context, page, pageSize int, platform, status, search string, isExclusive *bool, sortBy, sortOrder string) ([]service.Group, int64, error) {
return s.groups, int64(len(s.groups)), nil return s.groups, int64(len(s.groups)), nil
} }
@ -187,7 +214,16 @@ func (s *stubAdminService) BatchSetGroupRateMultipliers(_ context.Context, _ int
return nil return nil
} }
func (s *stubAdminService) ListAccounts(ctx context.Context, page, pageSize int, platform, accountType, status, search string, groupID int64, privacyMode string) ([]service.Account, int64, error) { func (s *stubAdminService) ListAccounts(ctx context.Context, page, pageSize int, platform, accountType, status, search string, groupID int64, privacyMode string, sortBy, sortOrder string) ([]service.Account, int64, error) {
s.lastListAccounts.platform = platform
s.lastListAccounts.accountType = accountType
s.lastListAccounts.status = status
s.lastListAccounts.search = search
s.lastListAccounts.groupID = groupID
s.lastListAccounts.privacyMode = privacyMode
s.lastListAccounts.sortBy = sortBy
s.lastListAccounts.sortOrder = sortOrder
s.lastListAccounts.calls++
return s.accounts, int64(len(s.accounts)), nil return s.accounts, int64(len(s.accounts)), nil
} }
@ -261,7 +297,13 @@ func (s *stubAdminService) CheckMixedChannelRisk(ctx context.Context, currentAcc
return s.checkMixedErr return s.checkMixedErr
} }
func (s *stubAdminService) ListProxies(ctx context.Context, page, pageSize int, protocol, status, search string) ([]service.Proxy, int64, error) { func (s *stubAdminService) ListProxies(ctx context.Context, page, pageSize int, protocol, status, search string, sortBy, sortOrder string) ([]service.Proxy, int64, error) {
s.lastListProxies.protocol = protocol
s.lastListProxies.status = status
s.lastListProxies.search = search
s.lastListProxies.sortBy = sortBy
s.lastListProxies.sortOrder = sortOrder
s.lastListProxies.calls++
search = strings.TrimSpace(strings.ToLower(search)) search = strings.TrimSpace(strings.ToLower(search))
filtered := make([]service.Proxy, 0, len(s.proxies)) filtered := make([]service.Proxy, 0, len(s.proxies))
for _, proxy := range s.proxies { for _, proxy := range s.proxies {
@ -283,7 +325,7 @@ func (s *stubAdminService) ListProxies(ctx context.Context, page, pageSize int,
return filtered, int64(len(filtered)), nil return filtered, int64(len(filtered)), nil
} }
func (s *stubAdminService) ListProxiesWithAccountCount(ctx context.Context, page, pageSize int, protocol, status, search string) ([]service.ProxyWithAccountCount, int64, error) { func (s *stubAdminService) ListProxiesWithAccountCount(ctx context.Context, page, pageSize int, protocol, status, search string, sortBy, sortOrder string) ([]service.ProxyWithAccountCount, int64, error) {
return s.proxyCounts, int64(len(s.proxyCounts)), nil return s.proxyCounts, int64(len(s.proxyCounts)), nil
} }
@ -384,7 +426,13 @@ func (s *stubAdminService) CheckProxyQuality(ctx context.Context, id int64) (*se
}, nil }, nil
} }
func (s *stubAdminService) ListRedeemCodes(ctx context.Context, page, pageSize int, codeType, status, search string) ([]service.RedeemCode, int64, error) { func (s *stubAdminService) ListRedeemCodes(ctx context.Context, page, pageSize int, codeType, status, search string, sortBy, sortOrder string) ([]service.RedeemCode, int64, error) {
s.lastListRedeemCodes.codeType = codeType
s.lastListRedeemCodes.status = status
s.lastListRedeemCodes.search = search
s.lastListRedeemCodes.sortBy = sortBy
s.lastListRedeemCodes.sortOrder = sortOrder
s.lastListRedeemCodes.calls++
return s.redeems, int64(len(s.redeems)), nil return s.redeems, int64(len(s.redeems)), nil
} }

View File

@ -52,13 +52,17 @@ func (h *AnnouncementHandler) List(c *gin.Context) {
page, pageSize := response.ParsePagination(c) page, pageSize := response.ParsePagination(c)
status := strings.TrimSpace(c.Query("status")) status := strings.TrimSpace(c.Query("status"))
search := strings.TrimSpace(c.Query("search")) search := strings.TrimSpace(c.Query("search"))
sortBy := c.DefaultQuery("sort_by", "created_at")
sortOrder := c.DefaultQuery("sort_order", "desc")
if len(search) > 200 { if len(search) > 200 {
search = search[:200] search = search[:200]
} }
params := pagination.PaginationParams{ params := pagination.PaginationParams{
Page: page, Page: page,
PageSize: pageSize, PageSize: pageSize,
SortBy: sortBy,
SortOrder: sortOrder,
} }
items, paginationResult, err := h.announcementService.List( items, paginationResult, err := h.announcementService.List(
@ -227,8 +231,10 @@ func (h *AnnouncementHandler) ListReadStatus(c *gin.Context) {
page, pageSize := response.ParsePagination(c) page, pageSize := response.ParsePagination(c)
params := pagination.PaginationParams{ params := pagination.PaginationParams{
Page: page, Page: page,
PageSize: pageSize, PageSize: pageSize,
SortBy: c.DefaultQuery("sort_by", "email"),
SortOrder: c.DefaultQuery("sort_order", "asc"),
} }
search := strings.TrimSpace(c.Query("search")) search := strings.TrimSpace(c.Query("search"))
if len(search) > 200 { if len(search) > 200 {

View File

@ -0,0 +1,138 @@
package admin
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
type announcementRepoCapture struct {
service.AnnouncementRepository
listParams pagination.PaginationParams
}
func (r *announcementRepoCapture) List(ctx context.Context, params pagination.PaginationParams, filters service.AnnouncementListFilters) ([]service.Announcement, *pagination.PaginationResult, error) {
r.listParams = params
return []service.Announcement{}, &pagination.PaginationResult{
Total: 0,
Page: params.Page,
PageSize: params.PageSize,
Pages: 0,
}, nil
}
func (r *announcementRepoCapture) GetByID(ctx context.Context, id int64) (*service.Announcement, error) {
return &service.Announcement{
ID: id,
Title: "announcement",
Content: "content",
Status: service.AnnouncementStatusActive,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}, nil
}
type announcementUserRepoCapture struct {
service.UserRepository
listParams pagination.PaginationParams
}
func (r *announcementUserRepoCapture) ListWithFilters(ctx context.Context, params pagination.PaginationParams, filters service.UserListFilters) ([]service.User, *pagination.PaginationResult, error) {
r.listParams = params
return []service.User{}, &pagination.PaginationResult{
Total: 0,
Page: params.Page,
PageSize: params.PageSize,
Pages: 0,
}, nil
}
type announcementReadRepoCapture struct {
service.AnnouncementReadRepository
}
func (r *announcementReadRepoCapture) GetReadMapByUsers(ctx context.Context, announcementID int64, userIDs []int64) (map[int64]time.Time, error) {
return map[int64]time.Time{}, nil
}
type announcementUserSubRepoCapture struct {
service.UserSubscriptionRepository
}
func newAnnouncementSortTestRouter(announcementRepo *announcementRepoCapture, userRepo *announcementUserRepoCapture) *gin.Engine {
gin.SetMode(gin.TestMode)
svc := service.NewAnnouncementService(
announcementRepo,
&announcementReadRepoCapture{},
userRepo,
&announcementUserSubRepoCapture{},
)
handler := NewAnnouncementHandler(svc)
router := gin.New()
router.GET("/admin/announcements", handler.List)
router.GET("/admin/announcements/:id/read-status", handler.ListReadStatus)
return router
}
func TestAdminAnnouncementListSortParams(t *testing.T) {
announcementRepo := &announcementRepoCapture{}
userRepo := &announcementUserRepoCapture{}
router := newAnnouncementSortTestRouter(announcementRepo, userRepo)
req := httptest.NewRequest(http.MethodGet, "/admin/announcements?sort_by=title&sort_order=ASC", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
require.Equal(t, "title", announcementRepo.listParams.SortBy)
require.Equal(t, "ASC", announcementRepo.listParams.SortOrder)
}
func TestAdminAnnouncementListSortDefaults(t *testing.T) {
announcementRepo := &announcementRepoCapture{}
userRepo := &announcementUserRepoCapture{}
router := newAnnouncementSortTestRouter(announcementRepo, userRepo)
req := httptest.NewRequest(http.MethodGet, "/admin/announcements", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
require.Equal(t, "created_at", announcementRepo.listParams.SortBy)
require.Equal(t, "desc", announcementRepo.listParams.SortOrder)
}
func TestAdminAnnouncementReadStatusSortParams(t *testing.T) {
announcementRepo := &announcementRepoCapture{}
userRepo := &announcementUserRepoCapture{}
router := newAnnouncementSortTestRouter(announcementRepo, userRepo)
req := httptest.NewRequest(http.MethodGet, "/admin/announcements/1/read-status?sort_by=balance&sort_order=DESC", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
require.Equal(t, "balance", userRepo.listParams.SortBy)
require.Equal(t, "DESC", userRepo.listParams.SortOrder)
}
func TestAdminAnnouncementReadStatusSortDefaults(t *testing.T) {
announcementRepo := &announcementRepoCapture{}
userRepo := &announcementUserRepoCapture{}
router := newAnnouncementSortTestRouter(announcementRepo, userRepo)
req := httptest.NewRequest(http.MethodGet, "/admin/announcements/1/read-status", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
require.Equal(t, "email", userRepo.listParams.SortBy)
require.Equal(t, "asc", userRepo.listParams.SortOrder)
}

View File

@ -245,7 +245,12 @@ func (h *ChannelHandler) List(c *gin.Context) {
search = search[:100] search = search[:100]
} }
channels, pag, err := h.channelService.List(c.Request.Context(), pagination.PaginationParams{Page: page, PageSize: pageSize}, status, search) channels, pag, err := h.channelService.List(c.Request.Context(), pagination.PaginationParams{
Page: page,
PageSize: pageSize,
SortBy: c.DefaultQuery("sort_by", "created_at"),
SortOrder: c.DefaultQuery("sort_order", "desc"),
}, status, search)
if err != nil { if err != nil {
response.ErrorFrom(c, err) response.ErrorFrom(c, err)
return return

View File

@ -160,6 +160,8 @@ func (h *GroupHandler) List(c *gin.Context) {
search = search[:100] search = search[:100]
} }
isExclusiveStr := c.Query("is_exclusive") isExclusiveStr := c.Query("is_exclusive")
sortBy := c.DefaultQuery("sort_by", "sort_order")
sortOrder := c.DefaultQuery("sort_order", "asc")
var isExclusive *bool var isExclusive *bool
if isExclusiveStr != "" { if isExclusiveStr != "" {
@ -167,7 +169,7 @@ func (h *GroupHandler) List(c *gin.Context) {
isExclusive = &val isExclusive = &val
} }
groups, total, err := h.adminService.ListGroups(c.Request.Context(), page, pageSize, platform, status, search, isExclusive) groups, total, err := h.adminService.ListGroups(c.Request.Context(), page, pageSize, platform, status, search, isExclusive, sortBy, sortOrder)
if err != nil { if err != nil {
response.ErrorFrom(c, err) response.ErrorFrom(c, err)
return return

View File

@ -55,8 +55,10 @@ func (h *PromoHandler) List(c *gin.Context) {
} }
params := pagination.PaginationParams{ params := pagination.PaginationParams{
Page: page, Page: page,
PageSize: pageSize, PageSize: pageSize,
SortBy: c.DefaultQuery("sort_by", "created_at"),
SortOrder: c.DefaultQuery("sort_order", "desc"),
} }
codes, paginationResult, err := h.promoService.List(c.Request.Context(), params, status, search) codes, paginationResult, err := h.promoService.List(c.Request.Context(), params, status, search)

View File

@ -52,13 +52,15 @@ func (h *ProxyHandler) List(c *gin.Context) {
protocol := c.Query("protocol") protocol := c.Query("protocol")
status := c.Query("status") status := c.Query("status")
search := c.Query("search") search := c.Query("search")
sortBy := c.DefaultQuery("sort_by", "id")
sortOrder := c.DefaultQuery("sort_order", "desc")
// 标准化和验证 search 参数 // 标准化和验证 search 参数
search = strings.TrimSpace(search) search = strings.TrimSpace(search)
if len(search) > 100 { if len(search) > 100 {
search = search[:100] search = search[:100]
} }
proxies, total, err := h.adminService.ListProxiesWithAccountCount(c.Request.Context(), page, pageSize, protocol, status, search) proxies, total, err := h.adminService.ListProxiesWithAccountCount(c.Request.Context(), page, pageSize, protocol, status, search, sortBy, sortOrder)
if err != nil { if err != nil {
response.ErrorFrom(c, err) response.ErrorFrom(c, err)
return return

View File

@ -165,7 +165,12 @@ func (h *UsageHandler) List(c *gin.Context) {
endTime = &t endTime = &t
} }
params := pagination.PaginationParams{Page: page, PageSize: pageSize} params := pagination.PaginationParams{
Page: page,
PageSize: pageSize,
SortBy: c.DefaultQuery("sort_by", "created_at"),
SortOrder: c.DefaultQuery("sort_order", "desc"),
}
filters := usagestats.UsageLogFilters{ filters := usagestats.UsageLogFilters{
UserID: userID, UserID: userID,
APIKeyID: apiKeyID, APIKeyID: apiKeyID,
@ -339,7 +344,7 @@ func (h *UsageHandler) SearchUsers(c *gin.Context) {
} }
// Limit to 30 results // Limit to 30 results
users, _, err := h.adminService.ListUsers(c.Request.Context(), 1, 30, service.UserListFilters{Search: keyword}) users, _, err := h.adminService.ListUsers(c.Request.Context(), 1, 30, service.UserListFilters{Search: keyword}, "email", "asc")
if err != nil { if err != nil {
response.ErrorFrom(c, err) response.ErrorFrom(c, err)
return return

View File

@ -15,11 +15,14 @@ import (
type adminUsageRepoCapture struct { type adminUsageRepoCapture struct {
service.UsageLogRepository service.UsageLogRepository
listParams pagination.PaginationParams
listFilters usagestats.UsageLogFilters listFilters usagestats.UsageLogFilters
statsParams pagination.PaginationParams
statsFilters usagestats.UsageLogFilters statsFilters usagestats.UsageLogFilters
} }
func (s *adminUsageRepoCapture) ListWithFilters(ctx context.Context, params pagination.PaginationParams, filters usagestats.UsageLogFilters) ([]service.UsageLog, *pagination.PaginationResult, error) { func (s *adminUsageRepoCapture) ListWithFilters(ctx context.Context, params pagination.PaginationParams, filters usagestats.UsageLogFilters) ([]service.UsageLog, *pagination.PaginationResult, error) {
s.listParams = params
s.listFilters = filters s.listFilters = filters
return []service.UsageLog{}, &pagination.PaginationResult{ return []service.UsageLog{}, &pagination.PaginationResult{
Total: 0, Total: 0,

View File

@ -0,0 +1,35 @@
package admin
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/require"
)
func TestAdminUsageListSortParams(t *testing.T) {
repo := &adminUsageRepoCapture{}
router := newAdminUsageRequestTypeTestRouter(repo)
req := httptest.NewRequest(http.MethodGet, "/admin/usage?sort_by=model&sort_order=ASC", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
require.Equal(t, "model", repo.listParams.SortBy)
require.Equal(t, "ASC", repo.listParams.SortOrder)
}
func TestAdminUsageListSortDefaults(t *testing.T) {
repo := &adminUsageRepoCapture{}
router := newAdminUsageRequestTypeTestRouter(repo)
req := httptest.NewRequest(http.MethodGet, "/admin/usage", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
require.Equal(t, "created_at", repo.listParams.SortBy)
require.Equal(t, "desc", repo.listParams.SortOrder)
}

View File

@ -91,12 +91,14 @@ func (h *UserHandler) List(c *gin.Context) {
GroupName: strings.TrimSpace(c.Query("group_name")), GroupName: strings.TrimSpace(c.Query("group_name")),
Attributes: parseAttributeFilters(c), Attributes: parseAttributeFilters(c),
} }
sortBy := c.DefaultQuery("sort_by", "created_at")
sortOrder := c.DefaultQuery("sort_order", "desc")
if raw, ok := c.GetQuery("include_subscriptions"); ok { if raw, ok := c.GetQuery("include_subscriptions"); ok {
includeSubscriptions := parseBoolQueryWithDefault(raw, true) includeSubscriptions := parseBoolQueryWithDefault(raw, true)
filters.IncludeSubscriptions = &includeSubscriptions filters.IncludeSubscriptions = &includeSubscriptions
} }
users, total, err := h.adminService.ListUsers(c.Request.Context(), page, pageSize, filters) users, total, err := h.adminService.ListUsers(c.Request.Context(), page, pageSize, filters, sortBy, sortOrder)
if err != nil { if err != nil {
response.ErrorFrom(c, err) response.ErrorFrom(c, err)
return return
@ -290,8 +292,10 @@ func (h *UserHandler) GetUserAPIKeys(c *gin.Context) {
} }
page, pageSize := response.ParsePagination(c) page, pageSize := response.ParsePagination(c)
sortBy := c.DefaultQuery("sort_by", "created_at")
sortOrder := c.DefaultQuery("sort_order", "desc")
keys, total, err := h.adminService.GetUserAPIKeys(c.Request.Context(), userID, page, pageSize) keys, total, err := h.adminService.GetUserAPIKeys(c.Request.Context(), userID, page, pageSize, sortBy, sortOrder)
if err != nil { if err != nil {
response.ErrorFrom(c, err) response.ErrorFrom(c, err)
return return

View File

@ -72,7 +72,12 @@ func (h *APIKeyHandler) List(c *gin.Context) {
} }
page, pageSize := response.ParsePagination(c) page, pageSize := response.ParsePagination(c)
params := pagination.PaginationParams{Page: page, PageSize: pageSize} params := pagination.PaginationParams{
Page: page,
PageSize: pageSize,
SortBy: c.DefaultQuery("sort_by", "created_at"),
SortOrder: c.DefaultQuery("sort_order", "desc"),
}
// Parse filter parameters // Parse filter parameters
var filters service.APIKeyListFilters var filters service.APIKeyListFilters

View File

@ -119,7 +119,12 @@ func (h *UsageHandler) List(c *gin.Context) {
endTime = &t endTime = &t
} }
params := pagination.PaginationParams{Page: page, PageSize: pageSize} params := pagination.PaginationParams{
Page: page,
PageSize: pageSize,
SortBy: c.DefaultQuery("sort_by", "created_at"),
SortOrder: c.DefaultQuery("sort_order", "desc"),
}
filters := usagestats.UsageLogFilters{ filters := usagestats.UsageLogFilters{
UserID: subject.UserID, // Always filter by current user for security UserID: subject.UserID, // Always filter by current user for security
APIKeyID: apiKeyID, APIKeyID: apiKeyID,

View File

@ -16,10 +16,12 @@ import (
type userUsageRepoCapture struct { type userUsageRepoCapture struct {
service.UsageLogRepository service.UsageLogRepository
listParams pagination.PaginationParams
listFilters usagestats.UsageLogFilters listFilters usagestats.UsageLogFilters
} }
func (s *userUsageRepoCapture) ListWithFilters(ctx context.Context, params pagination.PaginationParams, filters usagestats.UsageLogFilters) ([]service.UsageLog, *pagination.PaginationResult, error) { func (s *userUsageRepoCapture) ListWithFilters(ctx context.Context, params pagination.PaginationParams, filters usagestats.UsageLogFilters) ([]service.UsageLog, *pagination.PaginationResult, error) {
s.listParams = params
s.listFilters = filters s.listFilters = filters
return []service.UsageLog{}, &pagination.PaginationResult{ return []service.UsageLog{}, &pagination.PaginationResult{
Total: 0, Total: 0,

View File

@ -0,0 +1,35 @@
package handler
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/require"
)
func TestUserUsageListSortParams(t *testing.T) {
repo := &userUsageRepoCapture{}
router := newUserUsageRequestTypeTestRouter(repo)
req := httptest.NewRequest(http.MethodGet, "/usage?sort_by=model&sort_order=ASC", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
require.Equal(t, "model", repo.listParams.SortBy)
require.Equal(t, "ASC", repo.listParams.SortOrder)
}
func TestUserUsageListSortDefaults(t *testing.T) {
repo := &userUsageRepoCapture{}
router := newUserUsageRequestTypeTestRouter(repo)
req := httptest.NewRequest(http.MethodGet, "/usage", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
require.Equal(t, "created_at", repo.listParams.SortBy)
require.Equal(t, "desc", repo.listParams.SortOrder)
}

View File

@ -1,10 +1,19 @@
// Package pagination provides types and helpers for paginated responses. // Package pagination provides types and helpers for paginated responses.
package pagination package pagination
import "strings"
const (
SortOrderAsc = "asc"
SortOrderDesc = "desc"
)
// PaginationParams 分页参数 // PaginationParams 分页参数
type PaginationParams struct { type PaginationParams struct {
Page int Page int
PageSize int PageSize int
SortBy string
SortOrder string
} }
// PaginationResult 分页结果 // PaginationResult 分页结果
@ -18,8 +27,9 @@ type PaginationResult struct {
// DefaultPagination 默认分页参数 // DefaultPagination 默认分页参数
func DefaultPagination() PaginationParams { func DefaultPagination() PaginationParams {
return PaginationParams{ return PaginationParams{
Page: 1, Page: 1,
PageSize: 20, PageSize: 20,
SortOrder: SortOrderDesc,
} }
} }
@ -36,8 +46,32 @@ func (p PaginationParams) Limit() int {
if p.PageSize < 1 { if p.PageSize < 1 {
return 20 return 20
} }
if p.PageSize > 100 { if p.PageSize > 1000 {
return 100 return 1000
} }
return p.PageSize return p.PageSize
} }
// NormalizeSortOrder normalizes sort order to asc/desc and falls back to defaultOrder.
func NormalizeSortOrder(order string, defaultOrder string) string {
switch strings.ToLower(strings.TrimSpace(defaultOrder)) {
case SortOrderAsc:
defaultOrder = SortOrderAsc
default:
defaultOrder = SortOrderDesc
}
switch strings.ToLower(strings.TrimSpace(order)) {
case SortOrderAsc:
return SortOrderAsc
case SortOrderDesc:
return SortOrderDesc
default:
return defaultOrder
}
}
// NormalizedSortOrder returns the normalized sort order using defaultOrder as fallback.
func (p PaginationParams) NormalizedSortOrder(defaultOrder string) string {
return NormalizeSortOrder(p.SortOrder, defaultOrder)
}

View File

@ -0,0 +1,71 @@
package pagination
import "testing"
func TestNormalizeSortOrder(t *testing.T) {
t.Parallel()
tests := []struct {
name string
input string
defaultOrder string
want string
}{
{name: "asc", input: "asc", defaultOrder: "desc", want: "asc"},
{name: "uppercase asc", input: "ASC", defaultOrder: "desc", want: "asc"},
{name: "desc", input: "desc", defaultOrder: "asc", want: "desc"},
{name: "trim spaces", input: " desc ", defaultOrder: "asc", want: "desc"},
{name: "invalid falls back", input: "sideways", defaultOrder: "asc", want: "asc"},
{name: "empty falls back", input: "", defaultOrder: "desc", want: "desc"},
{name: "invalid default falls back to desc", input: "", defaultOrder: "wat", want: "desc"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := NormalizeSortOrder(tt.input, tt.defaultOrder); got != tt.want {
t.Fatalf("NormalizeSortOrder(%q, %q) = %q, want %q", tt.input, tt.defaultOrder, got, tt.want)
}
})
}
}
func TestPaginationParamsNormalizedSortOrder(t *testing.T) {
t.Parallel()
params := PaginationParams{SortOrder: "ASC"}
if got := params.NormalizedSortOrder("desc"); got != "asc" {
t.Fatalf("NormalizedSortOrder = %q, want asc", got)
}
params = PaginationParams{SortOrder: "bad"}
if got := params.NormalizedSortOrder("asc"); got != "asc" {
t.Fatalf("NormalizedSortOrder invalid fallback = %q, want asc", got)
}
}
func TestPaginationParamsLimit(t *testing.T) {
t.Parallel()
tests := []struct {
name string
pageSize int
want int
}{
{name: "non-positive falls back to default", pageSize: 0, want: 20},
{name: "negative falls back to default", pageSize: -1, want: 20},
{name: "normal value keeps", pageSize: 50, want: 50},
{name: "max value keeps", pageSize: 1000, want: 1000},
{name: "beyond max clamps to 1000", pageSize: 1500, want: 1000},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
p := PaginationParams{PageSize: tt.pageSize}
if got := p.Limit(); got != tt.want {
t.Fatalf("Limit() for PageSize=%d = %d, want %d", tt.pageSize, got, tt.want)
}
})
}
}

View File

@ -471,21 +471,58 @@ func (r *accountRepository) ListWithFilters(ctx context.Context, params paginati
case service.StatusActive: case service.StatusActive:
q = q.Where( q = q.Where(
dbaccount.StatusEQ(status), dbaccount.StatusEQ(status),
dbaccount.SchedulableEQ(true),
dbaccount.Or( dbaccount.Or(
dbaccount.RateLimitResetAtIsNil(), dbaccount.RateLimitResetAtIsNil(),
dbaccount.RateLimitResetAtLTE(time.Now()), dbaccount.RateLimitResetAtLTE(time.Now()),
), ),
dbpredicate.Account(func(s *entsql.Selector) {
col := s.C("temp_unschedulable_until")
s.Where(entsql.Or(
entsql.IsNull(col),
entsql.LTE(col, entsql.Expr("NOW()")),
))
}),
) )
case "rate_limited": case "rate_limited":
q = q.Where(dbaccount.RateLimitResetAtGT(time.Now())) q = q.Where(
dbaccount.StatusEQ(service.StatusActive),
dbaccount.RateLimitResetAtGT(time.Now()),
dbpredicate.Account(func(s *entsql.Selector) {
col := s.C("temp_unschedulable_until")
s.Where(entsql.Or(
entsql.IsNull(col),
entsql.LTE(col, entsql.Expr("NOW()")),
))
}),
)
case "temp_unschedulable": case "temp_unschedulable":
q = q.Where(dbpredicate.Account(func(s *entsql.Selector) { q = q.Where(
col := s.C("temp_unschedulable_until") dbaccount.StatusEQ(service.StatusActive),
s.Where(entsql.And( dbpredicate.Account(func(s *entsql.Selector) {
entsql.Not(entsql.IsNull(col)), col := s.C("temp_unschedulable_until")
entsql.GT(col, entsql.Expr("NOW()")), s.Where(entsql.And(
)) entsql.Not(entsql.IsNull(col)),
})) entsql.GT(col, entsql.Expr("NOW()")),
))
}),
)
case "unschedulable":
q = q.Where(
dbaccount.StatusEQ(service.StatusActive),
dbaccount.SchedulableEQ(false),
dbaccount.Or(
dbaccount.RateLimitResetAtIsNil(),
dbaccount.RateLimitResetAtLTE(time.Now()),
),
dbpredicate.Account(func(s *entsql.Selector) {
col := s.C("temp_unschedulable_until")
s.Where(entsql.Or(
entsql.IsNull(col),
entsql.LTE(col, entsql.Expr("NOW()")),
))
}),
)
default: default:
q = q.Where(dbaccount.StatusEQ(status)) q = q.Where(dbaccount.StatusEQ(status))
} }
@ -518,11 +555,14 @@ func (r *accountRepository) ListWithFilters(ctx context.Context, params paginati
return nil, nil, err return nil, nil, err
} }
accounts, err := q. accountsQuery := q.
Offset(params.Offset()). Offset(params.Offset()).
Limit(params.Limit()). Limit(params.Limit())
Order(dbent.Desc(dbaccount.FieldID)). for _, order := range accountListOrder(params) {
All(ctx) accountsQuery = accountsQuery.Order(order)
}
accounts, err := accountsQuery.All(ctx)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
@ -534,6 +574,50 @@ func (r *accountRepository) ListWithFilters(ctx context.Context, params paginati
return outAccounts, paginationResultFromTotal(int64(total), params), nil return outAccounts, paginationResultFromTotal(int64(total), params), nil
} }
func accountListOrder(params pagination.PaginationParams) []func(*entsql.Selector) {
sortBy := strings.ToLower(strings.TrimSpace(params.SortBy))
sortOrder := params.NormalizedSortOrder(pagination.SortOrderAsc)
field := dbaccount.FieldName
defaultOrder := true
switch sortBy {
case "", "name":
field = dbaccount.FieldName
case "id":
field = dbaccount.FieldID
defaultOrder = false
case "status":
field = dbaccount.FieldStatus
defaultOrder = false
case "schedulable":
field = dbaccount.FieldSchedulable
defaultOrder = false
case "priority":
field = dbaccount.FieldPriority
defaultOrder = false
case "rate_multiplier":
field = dbaccount.FieldRateMultiplier
defaultOrder = false
case "last_used_at":
field = dbaccount.FieldLastUsedAt
defaultOrder = false
case "expires_at":
field = dbaccount.FieldExpiresAt
defaultOrder = false
case "created_at":
field = dbaccount.FieldCreatedAt
defaultOrder = false
}
if sortOrder == pagination.SortOrderDesc {
return []func(*entsql.Selector){dbent.Desc(field), dbent.Desc(dbaccount.FieldID)}
}
if defaultOrder {
return []func(*entsql.Selector){dbent.Asc(dbaccount.FieldName), dbent.Asc(dbaccount.FieldID)}
}
return []func(*entsql.Selector){dbent.Asc(field), dbent.Asc(dbaccount.FieldID)}
}
func (r *accountRepository) ListByGroup(ctx context.Context, groupID int64) ([]service.Account, error) { func (r *accountRepository) ListByGroup(ctx context.Context, groupID int64) ([]service.Account, error) {
accounts, err := r.queryAccountsByGroup(ctx, groupID, accountGroupQueryOptions{ accounts, err := r.queryAccountsByGroup(ctx, groupID, accountGroupQueryOptions{
status: service.StatusActive, status: service.StatusActive,

View File

@ -256,7 +256,7 @@ func (s *AccountRepoSuite) TestListWithFilters() {
}, },
}, },
{ {
name: "filter_by_status_active_excludes_rate_limited", name: "filter_by_status_active_excludes_runtime_blocked_accounts",
setup: func(client *dbent.Client) { setup: func(client *dbent.Client) {
mustCreateAccount(s.T(), client, &service.Account{Name: "active-normal", Status: service.StatusActive}) mustCreateAccount(s.T(), client, &service.Account{Name: "active-normal", Status: service.StatusActive})
rateLimited := mustCreateAccount(s.T(), client, &service.Account{Name: "active-rate-limited", Status: service.StatusActive}) rateLimited := mustCreateAccount(s.T(), client, &service.Account{Name: "active-rate-limited", Status: service.StatusActive})
@ -264,6 +264,16 @@ func (s *AccountRepoSuite) TestListWithFilters() {
SetRateLimitResetAt(time.Now().Add(10 * time.Minute)). SetRateLimitResetAt(time.Now().Add(10 * time.Minute)).
Exec(context.Background()) Exec(context.Background())
s.Require().NoError(err) s.Require().NoError(err)
tempUnsched := mustCreateAccount(s.T(), client, &service.Account{Name: "active-temp-unsched", Status: service.StatusActive})
err = client.Account.UpdateOneID(tempUnsched.ID).
SetTempUnschedulableUntil(time.Now().Add(15 * time.Minute)).
Exec(context.Background())
s.Require().NoError(err)
unsched := mustCreateAccount(s.T(), client, &service.Account{Name: "active-unsched", Status: service.StatusActive})
err = client.Account.UpdateOneID(unsched.ID).
SetSchedulable(false).
Exec(context.Background())
s.Require().NoError(err)
}, },
status: service.StatusActive, status: service.StatusActive,
wantCount: 1, wantCount: 1,
@ -271,6 +281,75 @@ func (s *AccountRepoSuite) TestListWithFilters() {
s.Require().Equal("active-normal", accounts[0].Name) s.Require().Equal("active-normal", accounts[0].Name)
}, },
}, },
{
name: "filter_by_status_unschedulable_excludes_rate_limited_and_temp_unschedulable",
setup: func(client *dbent.Client) {
mustCreateAccount(s.T(), client, &service.Account{Name: "active-normal", Status: service.StatusActive, Schedulable: true})
unsched := mustCreateAccount(s.T(), client, &service.Account{Name: "active-unsched", Status: service.StatusActive})
err := client.Account.UpdateOneID(unsched.ID).
SetSchedulable(false).
Exec(context.Background())
s.Require().NoError(err)
rateLimited := mustCreateAccount(s.T(), client, &service.Account{Name: "active-rate-limited", Status: service.StatusActive})
err = client.Account.UpdateOneID(rateLimited.ID).
SetSchedulable(false).
SetRateLimitResetAt(time.Now().Add(10 * time.Minute)).
Exec(context.Background())
s.Require().NoError(err)
tempUnsched := mustCreateAccount(s.T(), client, &service.Account{Name: "active-temp-unsched", Status: service.StatusActive})
err = client.Account.UpdateOneID(tempUnsched.ID).
SetSchedulable(false).
SetTempUnschedulableUntil(time.Now().Add(15 * time.Minute)).
Exec(context.Background())
s.Require().NoError(err)
},
status: "unschedulable",
wantCount: 1,
validate: func(accounts []service.Account) {
s.Require().Equal("active-unsched", accounts[0].Name)
},
},
{
name: "filter_by_status_rate_limited_excludes_temp_unschedulable",
setup: func(client *dbent.Client) {
rateLimited := mustCreateAccount(s.T(), client, &service.Account{Name: "active-rate-limited", Status: service.StatusActive})
err := client.Account.UpdateOneID(rateLimited.ID).
SetRateLimitResetAt(time.Now().Add(10 * time.Minute)).
Exec(context.Background())
s.Require().NoError(err)
tempUnsched := mustCreateAccount(s.T(), client, &service.Account{Name: "active-temp-unsched", Status: service.StatusActive})
err = client.Account.UpdateOneID(tempUnsched.ID).
SetRateLimitResetAt(time.Now().Add(20 * time.Minute)).
SetTempUnschedulableUntil(time.Now().Add(15 * time.Minute)).
Exec(context.Background())
s.Require().NoError(err)
},
status: "rate_limited",
wantCount: 1,
validate: func(accounts []service.Account) {
s.Require().Equal("active-rate-limited", accounts[0].Name)
},
},
{
name: "filter_by_status_temp_unschedulable_excludes_manually_unschedulable",
setup: func(client *dbent.Client) {
tempUnsched := mustCreateAccount(s.T(), client, &service.Account{Name: "active-temp-unsched", Status: service.StatusActive, Schedulable: true})
err := client.Account.UpdateOneID(tempUnsched.ID).
SetTempUnschedulableUntil(time.Now().Add(15 * time.Minute)).
Exec(context.Background())
s.Require().NoError(err)
unsched := mustCreateAccount(s.T(), client, &service.Account{Name: "active-unsched", Status: service.StatusActive})
err = client.Account.UpdateOneID(unsched.ID).
SetSchedulable(false).
Exec(context.Background())
s.Require().NoError(err)
},
status: "temp_unschedulable",
wantCount: 1,
validate: func(accounts []service.Account) {
s.Require().Equal("active-temp-unsched", accounts[0].Name)
},
},
{ {
name: "filter_by_search", name: "filter_by_search",
setup: func(client *dbent.Client) { setup: func(client *dbent.Client) {

View File

@ -0,0 +1,35 @@
//go:build integration
package repository
import (
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
"github.com/Wei-Shaw/sub2api/internal/service"
)
func (s *AccountRepoSuite) TestList_DefaultSortByNameAsc() {
mustCreateAccount(s.T(), s.client, &service.Account{Name: "z-account"})
mustCreateAccount(s.T(), s.client, &service.Account{Name: "a-account"})
accounts, _, err := s.repo.List(s.ctx, pagination.PaginationParams{Page: 1, PageSize: 10})
s.Require().NoError(err)
s.Require().Len(accounts, 2)
s.Require().Equal("a-account", accounts[0].Name)
s.Require().Equal("z-account", accounts[1].Name)
}
func (s *AccountRepoSuite) TestListWithFilters_SortByPriorityDesc() {
mustCreateAccount(s.T(), s.client, &service.Account{Name: "low-priority", Priority: 10})
mustCreateAccount(s.T(), s.client, &service.Account{Name: "high-priority", Priority: 90})
accounts, _, err := s.repo.ListWithFilters(s.ctx, pagination.PaginationParams{
Page: 1,
PageSize: 10,
SortBy: "priority",
SortOrder: "desc",
}, "", "", "", "", 0, "")
s.Require().NoError(err)
s.Require().Len(accounts, 2)
s.Require().Equal("high-priority", accounts[0].Name)
s.Require().Equal("low-priority", accounts[1].Name)
}

View File

@ -2,12 +2,15 @@ package repository
import ( import (
"context" "context"
"strings"
"time" "time"
dbent "github.com/Wei-Shaw/sub2api/ent" dbent "github.com/Wei-Shaw/sub2api/ent"
"github.com/Wei-Shaw/sub2api/ent/announcement" "github.com/Wei-Shaw/sub2api/ent/announcement"
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination" "github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
"github.com/Wei-Shaw/sub2api/internal/service" "github.com/Wei-Shaw/sub2api/internal/service"
entsql "entgo.io/ent/dialect/sql"
) )
type announcementRepository struct { type announcementRepository struct {
@ -128,11 +131,14 @@ func (r *announcementRepository) List(
return nil, nil, err return nil, nil, err
} }
items, err := q. itemsQuery := q.
Offset(params.Offset()). Offset(params.Offset()).
Limit(params.Limit()). Limit(params.Limit())
Order(dbent.Desc(announcement.FieldID)). for _, order := range announcementListOrders(params) {
All(ctx) itemsQuery = itemsQuery.Order(order)
}
items, err := itemsQuery.All(ctx)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
@ -141,6 +147,56 @@ func (r *announcementRepository) List(
return out, paginationResultFromTotal(int64(total), params), nil return out, paginationResultFromTotal(int64(total), params), nil
} }
func announcementListOrder(params pagination.PaginationParams) (string, string) {
sortBy := strings.ToLower(strings.TrimSpace(params.SortBy))
sortOrder := params.NormalizedSortOrder(pagination.SortOrderDesc)
switch sortBy {
case "title":
return announcement.FieldTitle, sortOrder
case "status":
return announcement.FieldStatus, sortOrder
case "notify_mode":
return announcement.FieldNotifyMode, sortOrder
case "starts_at":
return announcement.FieldStartsAt, sortOrder
case "ends_at":
return announcement.FieldEndsAt, sortOrder
case "id":
return announcement.FieldID, sortOrder
case "", "created_at":
return announcement.FieldCreatedAt, sortOrder
default:
return announcement.FieldCreatedAt, pagination.SortOrderDesc
}
}
func announcementListOrders(params pagination.PaginationParams) []func(*entsql.Selector) {
field, sortOrder := announcementListOrder(params)
if sortOrder == pagination.SortOrderAsc {
if field == announcement.FieldID {
return []func(*entsql.Selector){
dbent.Asc(field),
}
}
return []func(*entsql.Selector){
dbent.Asc(field),
dbent.Asc(announcement.FieldID),
}
}
if field == announcement.FieldID {
return []func(*entsql.Selector){
dbent.Desc(field),
}
}
return []func(*entsql.Selector){
dbent.Desc(field),
dbent.Desc(announcement.FieldID),
}
}
func (r *announcementRepository) ListActive(ctx context.Context, now time.Time) ([]service.Announcement, error) { func (r *announcementRepository) ListActive(ctx context.Context, now time.Time) ([]service.Announcement, error) {
q := r.client.Announcement.Query(). q := r.client.Announcement.Query().
Where( Where(

View File

@ -0,0 +1,63 @@
package repository
import (
"testing"
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
)
func TestAnnouncementListOrder(t *testing.T) {
t.Parallel()
tests := []struct {
name string
params pagination.PaginationParams
wantBy string
want string
}{
{
name: "default created_at desc",
params: pagination.PaginationParams{},
wantBy: "created_at",
want: "desc",
},
{
name: "title asc",
params: pagination.PaginationParams{
SortBy: "title",
SortOrder: "ASC",
},
wantBy: "title",
want: "asc",
},
{
name: "status desc",
params: pagination.PaginationParams{
SortBy: "status",
SortOrder: "desc",
},
wantBy: "status",
want: "desc",
},
{
name: "invalid falls back",
params: pagination.PaginationParams{
SortBy: "sideways",
SortOrder: "wat",
},
wantBy: "created_at",
want: "desc",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
gotBy, gotOrder := announcementListOrder(tt.params)
if gotBy != tt.wantBy || gotOrder != tt.want {
t.Fatalf("announcementListOrder(%+v) = (%q, %q), want (%q, %q)", tt.params, gotBy, gotOrder, tt.wantBy, tt.want)
}
})
}
}

View File

@ -4,6 +4,7 @@ import (
"context" "context"
"database/sql" "database/sql"
"fmt" "fmt"
"strings"
"time" "time"
dbent "github.com/Wei-Shaw/sub2api/ent" dbent "github.com/Wei-Shaw/sub2api/ent"
@ -14,6 +15,8 @@ import (
"github.com/Wei-Shaw/sub2api/internal/service" "github.com/Wei-Shaw/sub2api/internal/service"
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination" "github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
entsql "entgo.io/ent/dialect/sql"
) )
type apiKeyRepository struct { type apiKeyRepository struct {
@ -309,12 +312,15 @@ func (r *apiKeyRepository) ListByUserID(ctx context.Context, userID int64, param
return nil, nil, err return nil, nil, err
} }
keys, err := q. keysQuery := q.
WithGroup(). WithGroup().
Offset(params.Offset()). Offset(params.Offset()).
Limit(params.Limit()). Limit(params.Limit())
Order(dbent.Desc(apikey.FieldID)). for _, order := range apiKeyListOrder(params) {
All(ctx) keysQuery = keysQuery.Order(order)
}
keys, err := keysQuery.All(ctx)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
@ -359,12 +365,15 @@ func (r *apiKeyRepository) ListByGroupID(ctx context.Context, groupID int64, par
return nil, nil, err return nil, nil, err
} }
keys, err := q. keysQuery := q.
WithUser(). WithUser().
Offset(params.Offset()). Offset(params.Offset()).
Limit(params.Limit()). Limit(params.Limit())
Order(dbent.Desc(apikey.FieldID)). for _, order := range apiKeyListOrder(params) {
All(ctx) keysQuery = keysQuery.Order(order)
}
keys, err := keysQuery.All(ctx)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
@ -377,6 +386,34 @@ func (r *apiKeyRepository) ListByGroupID(ctx context.Context, groupID int64, par
return outKeys, paginationResultFromTotal(int64(total), params), nil return outKeys, paginationResultFromTotal(int64(total), params), nil
} }
func apiKeyListOrder(params pagination.PaginationParams) []func(*entsql.Selector) {
sortBy := strings.ToLower(strings.TrimSpace(params.SortBy))
sortOrder := params.NormalizedSortOrder(pagination.SortOrderDesc)
field := apikey.FieldID
switch sortBy {
case "name":
field = apikey.FieldName
case "status":
field = apikey.FieldStatus
case "expires_at":
field = apikey.FieldExpiresAt
case "last_used_at":
field = apikey.FieldLastUsedAt
case "created_at":
field = apikey.FieldCreatedAt
case "id", "":
field = apikey.FieldID
default:
field = apikey.FieldID
}
if sortOrder == pagination.SortOrderAsc {
return []func(*entsql.Selector){dbent.Asc(field), dbent.Asc(apikey.FieldID)}
}
return []func(*entsql.Selector){dbent.Desc(field), dbent.Desc(apikey.FieldID)}
}
// SearchAPIKeys searches API keys by user ID and/or keyword (name) // SearchAPIKeys searches API keys by user ID and/or keyword (name)
func (r *apiKeyRepository) SearchAPIKeys(ctx context.Context, userID int64, keyword string, limit int) ([]service.APIKey, error) { func (r *apiKeyRepository) SearchAPIKeys(ctx context.Context, userID int64, keyword string, limit int) ([]service.APIKey, error) {
q := r.activeQuery() q := r.activeQuery()

View File

@ -0,0 +1,25 @@
//go:build integration
package repository
import (
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
"github.com/Wei-Shaw/sub2api/internal/service"
)
func (s *APIKeyRepoSuite) TestListByUserID_SortByNameAsc() {
user := s.mustCreateUser("sort-name@example.com")
s.mustCreateApiKey(user.ID, "sk-z", "z-key", nil)
s.mustCreateApiKey(user.ID, "sk-a", "a-key", nil)
keys, _, err := s.repo.ListByUserID(s.ctx, user.ID, pagination.PaginationParams{
Page: 1,
PageSize: 10,
SortBy: "name",
SortOrder: "asc",
}, service.APIKeyListFilters{})
s.Require().NoError(err)
s.Require().Len(keys, 2)
s.Require().Equal("a-key", keys[0].Name)
s.Require().Equal("z-key", keys[1].Name)
}

View File

@ -188,8 +188,8 @@ func (r *channelRepository) List(ctx context.Context, params pagination.Paginati
// 查询 channel 列表 // 查询 channel 列表
dataQuery := fmt.Sprintf( dataQuery := fmt.Sprintf(
`SELECT c.id, c.name, c.description, c.status, c.model_mapping, c.billing_model_source, c.restrict_models, c.created_at, c.updated_at `SELECT c.id, c.name, c.description, c.status, c.model_mapping, c.billing_model_source, c.restrict_models, c.created_at, c.updated_at
FROM channels c WHERE %s ORDER BY c.id ASC LIMIT $%d OFFSET $%d`, FROM channels c WHERE %s ORDER BY %s LIMIT $%d OFFSET $%d`,
whereClause, argIdx, argIdx+1, whereClause, channelListOrderBy(params), argIdx, argIdx+1,
) )
args = append(args, pageSize, offset) args = append(args, pageSize, offset)
@ -246,6 +246,31 @@ func (r *channelRepository) List(ctx context.Context, params pagination.Paginati
return channels, paginationResult, nil return channels, paginationResult, nil
} }
func channelListOrderBy(params pagination.PaginationParams) string {
sortBy := strings.ToLower(strings.TrimSpace(params.SortBy))
sortOrder := strings.ToUpper(params.NormalizedSortOrder(pagination.SortOrderAsc))
column := "c.id"
switch sortBy {
case "":
column = "c.id"
sortOrder = "ASC"
case "id":
column = "c.id"
case "name":
column = "c.name"
case "status":
column = "c.status"
case "created_at":
column = "c.created_at"
default:
column = "c.id"
sortOrder = "ASC"
}
return fmt.Sprintf("%s %s, c.id %s", column, sortOrder, sortOrder)
}
func (r *channelRepository) ListAll(ctx context.Context) ([]service.Channel, error) { func (r *channelRepository) ListAll(ctx context.Context) ([]service.Channel, error) {
rows, err := r.db.QueryContext(ctx, rows, err := r.db.QueryContext(ctx,
`SELECT id, name, description, status, model_mapping, billing_model_source, restrict_models, created_at, updated_at FROM channels ORDER BY id`, `SELECT id, name, description, status, model_mapping, billing_model_source, restrict_models, created_at, updated_at FROM channels ORDER BY id`,

View File

@ -8,6 +8,7 @@ import (
"fmt" "fmt"
"testing" "testing"
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
"github.com/lib/pq" "github.com/lib/pq"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@ -225,3 +226,12 @@ func TestIsUniqueViolation(t *testing.T) {
}) })
} }
} }
func TestChannelListOrderBy_AllowsDescendingIDSort(t *testing.T) {
params := pagination.PaginationParams{
SortBy: "id",
SortOrder: "desc",
}
require.Equal(t, "c.id DESC, c.id DESC", channelListOrderBy(params))
}

View File

@ -5,6 +5,7 @@ import (
"database/sql" "database/sql"
"errors" "errors"
"fmt" "fmt"
"sort"
"strings" "strings"
dbent "github.com/Wei-Shaw/sub2api/ent" dbent "github.com/Wei-Shaw/sub2api/ent"
@ -14,6 +15,8 @@ import (
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination" "github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
"github.com/Wei-Shaw/sub2api/internal/service" "github.com/Wei-Shaw/sub2api/internal/service"
"github.com/lib/pq" "github.com/lib/pq"
entsql "entgo.io/ent/dialect/sql"
) )
type sqlExecutor interface { type sqlExecutor interface {
@ -231,11 +234,18 @@ func (r *groupRepository) ListWithFilters(ctx context.Context, params pagination
return nil, nil, err return nil, nil, err
} }
groups, err := q. if strings.EqualFold(strings.TrimSpace(params.SortBy), "account_count") {
return r.listWithAccountCountSort(ctx, q, params, total)
}
groupsQuery := q.
Offset(params.Offset()). Offset(params.Offset()).
Limit(params.Limit()). Limit(params.Limit())
Order(dbent.Asc(group.FieldSortOrder), dbent.Asc(group.FieldID)). for _, order := range groupListOrder(params) {
All(ctx) groupsQuery = groupsQuery.Order(order)
}
groups, err := groupsQuery.All(ctx)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
@ -261,6 +271,104 @@ func (r *groupRepository) ListWithFilters(ctx context.Context, params pagination
return outGroups, paginationResultFromTotal(int64(total), params), nil return outGroups, paginationResultFromTotal(int64(total), params), nil
} }
func (r *groupRepository) listWithAccountCountSort(ctx context.Context, q *dbent.GroupQuery, params pagination.PaginationParams, total int) ([]service.Group, *pagination.PaginationResult, error) {
groups, err := q.
Order(dbent.Asc(group.FieldSortOrder), dbent.Asc(group.FieldID)).
All(ctx)
if err != nil {
return nil, nil, err
}
groupIDs := make([]int64, 0, len(groups))
outGroups := make([]service.Group, 0, len(groups))
for i := range groups {
g := groupEntityToService(groups[i])
outGroups = append(outGroups, *g)
groupIDs = append(groupIDs, g.ID)
}
counts, err := r.loadAccountCounts(ctx, groupIDs)
if err != nil {
return nil, nil, err
}
for i := range outGroups {
c := counts[outGroups[i].ID]
outGroups[i].AccountCount = c.Total
outGroups[i].ActiveAccountCount = c.Active
outGroups[i].RateLimitedAccountCount = c.RateLimited
}
sortOrder := params.NormalizedSortOrder(pagination.SortOrderDesc)
sort.SliceStable(outGroups, func(i, j int) bool {
if outGroups[i].AccountCount == outGroups[j].AccountCount {
if outGroups[i].SortOrder == outGroups[j].SortOrder {
return outGroups[i].ID < outGroups[j].ID
}
return outGroups[i].SortOrder < outGroups[j].SortOrder
}
if sortOrder == pagination.SortOrderAsc {
return outGroups[i].AccountCount < outGroups[j].AccountCount
}
return outGroups[i].AccountCount > outGroups[j].AccountCount
})
return paginateSlice(outGroups, params), paginationResultFromTotal(int64(total), params), nil
}
func groupListOrder(params pagination.PaginationParams) []func(*entsql.Selector) {
sortBy := strings.ToLower(strings.TrimSpace(params.SortBy))
sortOrder := params.NormalizedSortOrder(pagination.SortOrderAsc)
field := group.FieldSortOrder
tieField := group.FieldID
defaultOrder := true
switch sortBy {
case "", "sort_order":
field = group.FieldSortOrder
case "name":
field = group.FieldName
defaultOrder = false
case "platform":
field = group.FieldPlatform
defaultOrder = false
case "billing_type", "subscription_type":
field = group.FieldSubscriptionType
defaultOrder = false
case "rate_multiplier":
field = group.FieldRateMultiplier
defaultOrder = false
case "is_exclusive":
field = group.FieldIsExclusive
defaultOrder = false
case "status":
field = group.FieldStatus
defaultOrder = false
case "created_at":
field = group.FieldCreatedAt
defaultOrder = false
case "id":
field = group.FieldID
defaultOrder = false
tieField = ""
default:
field = group.FieldSortOrder
}
if sortOrder == pagination.SortOrderDesc && sortBy != "" {
if tieField == "" {
return []func(*entsql.Selector){dbent.Desc(field)}
}
return []func(*entsql.Selector){dbent.Desc(field), dbent.Desc(tieField)}
}
if defaultOrder {
return []func(*entsql.Selector){dbent.Asc(group.FieldSortOrder), dbent.Asc(group.FieldID)}
}
if tieField == "" {
return []func(*entsql.Selector){dbent.Asc(field)}
}
return []func(*entsql.Selector){dbent.Asc(field), dbent.Asc(tieField)}
}
func (r *groupRepository) ListActive(ctx context.Context) ([]service.Group, error) { func (r *groupRepository) ListActive(ctx context.Context) ([]service.Group, error) {
groups, err := r.client.Group.Query(). groups, err := r.client.Group.Query().
Where(group.StatusEQ(service.StatusActive)). Where(group.StatusEQ(service.StatusActive)).

View File

@ -0,0 +1,44 @@
//go:build integration
package repository
import (
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
"github.com/Wei-Shaw/sub2api/internal/service"
)
func (s *GroupRepoSuite) TestList_DefaultSortBySortOrderAsc() {
g1 := &service.Group{Name: "g1", Platform: service.PlatformAnthropic, RateMultiplier: 1, Status: service.StatusActive, SubscriptionType: service.SubscriptionTypeStandard, SortOrder: 20}
g2 := &service.Group{Name: "g2", Platform: service.PlatformAnthropic, RateMultiplier: 1, Status: service.StatusActive, SubscriptionType: service.SubscriptionTypeStandard, SortOrder: 10}
s.Require().NoError(s.repo.Create(s.ctx, g1))
s.Require().NoError(s.repo.Create(s.ctx, g2))
groups, _, err := s.repo.List(s.ctx, pagination.PaginationParams{Page: 1, PageSize: 10})
s.Require().NoError(err)
s.Require().Len(groups, 2)
s.Require().Equal(g2.ID, groups[0].ID)
s.Require().Equal(g1.ID, groups[1].ID)
}
func (s *GroupRepoSuite) TestList_SortBySortOrderDesc() {
g1 := &service.Group{Name: "g1", Platform: service.PlatformAnthropic, RateMultiplier: 1, Status: service.StatusActive, SubscriptionType: service.SubscriptionTypeStandard, SortOrder: 40}
g2 := &service.Group{Name: "g2", Platform: service.PlatformAnthropic, RateMultiplier: 1, Status: service.StatusActive, SubscriptionType: service.SubscriptionTypeStandard, SortOrder: 50}
s.Require().NoError(s.repo.Create(s.ctx, g1))
s.Require().NoError(s.repo.Create(s.ctx, g2))
groups, _, err := s.repo.List(s.ctx, pagination.PaginationParams{
Page: 1,
PageSize: 10,
SortBy: "sort_order",
SortOrder: "desc",
})
s.Require().NoError(err)
s.Require().GreaterOrEqual(len(groups), 2)
indexByID := make(map[int64]int, len(groups))
for i, group := range groups {
indexByID[group.ID] = i
}
s.Require().Contains(indexByID, g1.ID)
s.Require().Contains(indexByID, g2.ID)
s.Require().Less(indexByID[g2.ID], indexByID[g1.ID])
}

View File

@ -14,3 +14,22 @@ func paginationResultFromTotal(total int64, params pagination.PaginationParams)
Pages: pages, Pages: pages,
} }
} }
func paginateSlice[T any](items []T, params pagination.PaginationParams) []T {
if len(items) == 0 {
return []T{}
}
offset := params.Offset()
if offset >= len(items) {
return []T{}
}
limit := params.Limit()
end := offset + limit
if end > len(items) {
end = len(items)
}
return items[offset:end]
}

View File

@ -2,12 +2,15 @@ package repository
import ( import (
"context" "context"
"strings"
dbent "github.com/Wei-Shaw/sub2api/ent" dbent "github.com/Wei-Shaw/sub2api/ent"
"github.com/Wei-Shaw/sub2api/ent/promocode" "github.com/Wei-Shaw/sub2api/ent/promocode"
"github.com/Wei-Shaw/sub2api/ent/promocodeusage" "github.com/Wei-Shaw/sub2api/ent/promocodeusage"
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination" "github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
"github.com/Wei-Shaw/sub2api/internal/service" "github.com/Wei-Shaw/sub2api/internal/service"
entsql "entgo.io/ent/dialect/sql"
) )
type promoCodeRepository struct { type promoCodeRepository struct {
@ -137,11 +140,14 @@ func (r *promoCodeRepository) ListWithFilters(ctx context.Context, params pagina
return nil, nil, err return nil, nil, err
} }
codes, err := q. codesQuery := q.
Offset(params.Offset()). Offset(params.Offset()).
Limit(params.Limit()). Limit(params.Limit())
Order(dbent.Desc(promocode.FieldID)). for _, order := range promoCodeListOrder(params) {
All(ctx) codesQuery = codesQuery.Order(order)
}
codes, err := codesQuery.All(ctx)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
@ -151,6 +157,34 @@ func (r *promoCodeRepository) ListWithFilters(ctx context.Context, params pagina
return outCodes, paginationResultFromTotal(int64(total), params), nil return outCodes, paginationResultFromTotal(int64(total), params), nil
} }
func promoCodeListOrder(params pagination.PaginationParams) []func(*entsql.Selector) {
sortBy := strings.ToLower(strings.TrimSpace(params.SortBy))
sortOrder := params.NormalizedSortOrder(pagination.SortOrderDesc)
field := promocode.FieldID
switch sortBy {
case "bonus_amount":
field = promocode.FieldBonusAmount
case "status":
field = promocode.FieldStatus
case "expires_at":
field = promocode.FieldExpiresAt
case "created_at":
field = promocode.FieldCreatedAt
case "code":
field = promocode.FieldCode
case "id", "":
field = promocode.FieldID
default:
field = promocode.FieldID
}
if sortOrder == pagination.SortOrderAsc {
return []func(*entsql.Selector){dbent.Asc(field), dbent.Asc(promocode.FieldID)}
}
return []func(*entsql.Selector){dbent.Desc(field), dbent.Desc(promocode.FieldID)}
}
func (r *promoCodeRepository) CreateUsage(ctx context.Context, usage *service.PromoCodeUsage) error { func (r *promoCodeRepository) CreateUsage(ctx context.Context, usage *service.PromoCodeUsage) error {
client := clientFromContext(ctx, r.client) client := clientFromContext(ctx, r.client)
created, err := client.PromoCodeUsage.Create(). created, err := client.PromoCodeUsage.Create().

View File

@ -3,12 +3,16 @@ package repository
import ( import (
"context" "context"
"database/sql" "database/sql"
"sort"
"strings"
dbent "github.com/Wei-Shaw/sub2api/ent" dbent "github.com/Wei-Shaw/sub2api/ent"
"github.com/Wei-Shaw/sub2api/ent/proxy" "github.com/Wei-Shaw/sub2api/ent/proxy"
"github.com/Wei-Shaw/sub2api/internal/service" "github.com/Wei-Shaw/sub2api/internal/service"
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination" "github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
entsql "entgo.io/ent/dialect/sql"
) )
type sqlQuerier interface { type sqlQuerier interface {
@ -135,11 +139,14 @@ func (r *proxyRepository) ListWithFilters(ctx context.Context, params pagination
return nil, nil, err return nil, nil, err
} }
proxies, err := q. proxiesQuery := q.
Offset(params.Offset()). Offset(params.Offset()).
Limit(params.Limit()). Limit(params.Limit())
Order(dbent.Desc(proxy.FieldID)). for _, order := range proxyListOrder(params) {
All(ctx) proxiesQuery = proxiesQuery.Order(order)
}
proxies, err := proxiesQuery.All(ctx)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
@ -170,22 +177,58 @@ func (r *proxyRepository) ListWithFiltersAndAccountCount(ctx context.Context, pa
return nil, nil, err return nil, nil, err
} }
proxies, err := q. if strings.EqualFold(strings.TrimSpace(params.SortBy), "account_count") {
return r.listWithAccountCountSort(ctx, q, params, total)
}
proxiesQuery := q.
Offset(params.Offset()). Offset(params.Offset()).
Limit(params.Limit()). Limit(params.Limit())
for _, order := range proxyListOrder(params) {
proxiesQuery = proxiesQuery.Order(order)
}
proxies, err := proxiesQuery.All(ctx)
if err != nil {
return nil, nil, err
}
return r.buildProxyWithAccountCountResult(ctx, proxies, params, int64(total))
}
func (r *proxyRepository) listWithAccountCountSort(ctx context.Context, q *dbent.ProxyQuery, params pagination.PaginationParams, total int) ([]service.ProxyWithAccountCount, *pagination.PaginationResult, error) {
proxies, err := q.
Order(dbent.Desc(proxy.FieldID)). Order(dbent.Desc(proxy.FieldID)).
All(ctx) All(ctx)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
// Get account counts result, _, err := r.buildProxyWithAccountCountResult(ctx, proxies, params, int64(total))
if err != nil {
return nil, nil, err
}
sortOrder := params.NormalizedSortOrder(pagination.SortOrderDesc)
sort.SliceStable(result, func(i, j int) bool {
if result[i].AccountCount == result[j].AccountCount {
return result[i].ID > result[j].ID
}
if sortOrder == pagination.SortOrderAsc {
return result[i].AccountCount < result[j].AccountCount
}
return result[i].AccountCount > result[j].AccountCount
})
return paginateSlice(result, params), paginationResultFromTotal(int64(total), params), nil
}
func (r *proxyRepository) buildProxyWithAccountCountResult(ctx context.Context, proxies []*dbent.Proxy, params pagination.PaginationParams, total int64) ([]service.ProxyWithAccountCount, *pagination.PaginationResult, error) {
counts, err := r.GetAccountCountsForProxies(ctx) counts, err := r.GetAccountCountsForProxies(ctx)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
// Build result with account counts
result := make([]service.ProxyWithAccountCount, 0, len(proxies)) result := make([]service.ProxyWithAccountCount, 0, len(proxies))
for i := range proxies { for i := range proxies {
proxyOut := proxyEntityToService(proxies[i]) proxyOut := proxyEntityToService(proxies[i])
@ -198,7 +241,33 @@ func (r *proxyRepository) ListWithFiltersAndAccountCount(ctx context.Context, pa
}) })
} }
return result, paginationResultFromTotal(int64(total), params), nil return result, paginationResultFromTotal(total, params), nil
}
func proxyListOrder(params pagination.PaginationParams) []func(*entsql.Selector) {
sortBy := strings.ToLower(strings.TrimSpace(params.SortBy))
sortOrder := params.NormalizedSortOrder(pagination.SortOrderDesc)
field := proxy.FieldID
switch sortBy {
case "name":
field = proxy.FieldName
case "protocol":
field = proxy.FieldProtocol
case "status":
field = proxy.FieldStatus
case "created_at":
field = proxy.FieldCreatedAt
case "id", "":
field = proxy.FieldID
default:
field = proxy.FieldID
}
if sortOrder == pagination.SortOrderAsc {
return []func(*entsql.Selector){dbent.Asc(field), dbent.Asc(proxy.FieldID)}
}
return []func(*entsql.Selector){dbent.Desc(field), dbent.Desc(proxy.FieldID)}
} }
func (r *proxyRepository) ListActive(ctx context.Context) ([]service.Proxy, error) { func (r *proxyRepository) ListActive(ctx context.Context) ([]service.Proxy, error) {

View File

@ -0,0 +1,28 @@
//go:build integration
package repository
import (
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
"github.com/Wei-Shaw/sub2api/internal/service"
)
func (s *ProxyRepoSuite) TestListWithFiltersAndAccountCount_SortByAccountCountDesc() {
p1 := s.mustCreateProxy(&service.Proxy{Name: "p1", Protocol: "http", Host: "127.0.0.1", Port: 8080, Status: service.StatusActive})
p2 := s.mustCreateProxy(&service.Proxy{Name: "p2", Protocol: "http", Host: "127.0.0.1", Port: 8081, Status: service.StatusActive})
s.mustInsertAccount("a1", &p1.ID)
s.mustInsertAccount("a2", &p1.ID)
s.mustInsertAccount("a3", &p2.ID)
proxies, _, err := s.repo.ListWithFiltersAndAccountCount(s.ctx, pagination.PaginationParams{
Page: 1,
PageSize: 10,
SortBy: "account_count",
SortOrder: "desc",
}, "", "", "")
s.Require().NoError(err)
s.Require().Len(proxies, 2)
s.Require().Equal(p1.ID, proxies[0].ID)
s.Require().Equal(int64(2), proxies[0].AccountCount)
s.Require().Equal(p2.ID, proxies[1].ID)
}

View File

@ -2,6 +2,7 @@ package repository
import ( import (
"context" "context"
"strings"
"time" "time"
dbent "github.com/Wei-Shaw/sub2api/ent" dbent "github.com/Wei-Shaw/sub2api/ent"
@ -9,6 +10,8 @@ import (
"github.com/Wei-Shaw/sub2api/ent/user" "github.com/Wei-Shaw/sub2api/ent/user"
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination" "github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
"github.com/Wei-Shaw/sub2api/internal/service" "github.com/Wei-Shaw/sub2api/internal/service"
entsql "entgo.io/ent/dialect/sql"
) )
type redeemCodeRepository struct { type redeemCodeRepository struct {
@ -120,13 +123,16 @@ func (r *redeemCodeRepository) ListWithFilters(ctx context.Context, params pagin
return nil, nil, err return nil, nil, err
} }
codes, err := q. codesQuery := q.
WithUser(). WithUser().
WithGroup(). WithGroup().
Offset(params.Offset()). Offset(params.Offset()).
Limit(params.Limit()). Limit(params.Limit())
Order(dbent.Desc(redeemcode.FieldID)). for _, order := range redeemCodeListOrder(params) {
All(ctx) codesQuery = codesQuery.Order(order)
}
codes, err := codesQuery.All(ctx)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
@ -136,6 +142,36 @@ func (r *redeemCodeRepository) ListWithFilters(ctx context.Context, params pagin
return outCodes, paginationResultFromTotal(int64(total), params), nil return outCodes, paginationResultFromTotal(int64(total), params), nil
} }
func redeemCodeListOrder(params pagination.PaginationParams) []func(*entsql.Selector) {
sortBy := strings.ToLower(strings.TrimSpace(params.SortBy))
sortOrder := params.NormalizedSortOrder(pagination.SortOrderDesc)
field := redeemcode.FieldID
switch sortBy {
case "type":
field = redeemcode.FieldType
case "value":
field = redeemcode.FieldValue
case "status":
field = redeemcode.FieldStatus
case "used_at":
field = redeemcode.FieldUsedAt
case "created_at":
field = redeemcode.FieldCreatedAt
case "code":
field = redeemcode.FieldCode
case "id", "":
field = redeemcode.FieldID
default:
field = redeemcode.FieldID
}
if sortOrder == pagination.SortOrderAsc {
return []func(*entsql.Selector){dbent.Asc(field), dbent.Asc(redeemcode.FieldID)}
}
return []func(*entsql.Selector){dbent.Desc(field), dbent.Desc(redeemcode.FieldID)}
}
func (r *redeemCodeRepository) Update(ctx context.Context, code *service.RedeemCode) error { func (r *redeemCodeRepository) Update(ctx context.Context, code *service.RedeemCode) error {
up := r.client.RedeemCode.UpdateOneID(code.ID). up := r.client.RedeemCode.UpdateOneID(code.ID).
SetCode(code.Code). SetCode(code.Code).

View File

@ -0,0 +1,24 @@
//go:build integration
package repository
import (
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
"github.com/Wei-Shaw/sub2api/internal/service"
)
func (s *RedeemCodeRepoSuite) TestListWithFilters_SortByValueAsc() {
s.Require().NoError(s.repo.Create(s.ctx, &service.RedeemCode{Code: "VALUE-20", Type: service.RedeemTypeBalance, Value: 20, Status: service.StatusUnused}))
s.Require().NoError(s.repo.Create(s.ctx, &service.RedeemCode{Code: "VALUE-10", Type: service.RedeemTypeBalance, Value: 10, Status: service.StatusUnused}))
codes, _, err := s.repo.ListWithFilters(s.ctx, pagination.PaginationParams{
Page: 1,
PageSize: 10,
SortBy: "value",
SortOrder: "asc",
}, "", "", "")
s.Require().NoError(err)
s.Require().Len(codes, 2)
s.Require().Equal("VALUE-10", codes[0].Code)
s.Require().Equal("VALUE-20", codes[1].Code)
}

View File

@ -3771,7 +3771,7 @@ func (r *usageLogRepository) listUsageLogsWithPagination(ctx context.Context, wh
limitPos := len(args) + 1 limitPos := len(args) + 1
offsetPos := len(args) + 2 offsetPos := len(args) + 2
listArgs := append(append([]any{}, args...), params.Limit(), params.Offset()) listArgs := append(append([]any{}, args...), params.Limit(), params.Offset())
query := fmt.Sprintf("SELECT %s FROM usage_logs %s ORDER BY id DESC LIMIT $%d OFFSET $%d", usageLogSelectColumns, whereClause, limitPos, offsetPos) query := fmt.Sprintf("SELECT %s FROM usage_logs %s ORDER BY %s LIMIT $%d OFFSET $%d", usageLogSelectColumns, whereClause, usageLogOrderBy(params), limitPos, offsetPos)
logs, err := r.queryUsageLogs(ctx, query, listArgs...) logs, err := r.queryUsageLogs(ctx, query, listArgs...)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
@ -3786,7 +3786,7 @@ func (r *usageLogRepository) listUsageLogsWithFastPagination(ctx context.Context
limitPos := len(args) + 1 limitPos := len(args) + 1
offsetPos := len(args) + 2 offsetPos := len(args) + 2
listArgs := append(append([]any{}, args...), limit+1, offset) listArgs := append(append([]any{}, args...), limit+1, offset)
query := fmt.Sprintf("SELECT %s FROM usage_logs %s ORDER BY id DESC LIMIT $%d OFFSET $%d", usageLogSelectColumns, whereClause, limitPos, offsetPos) query := fmt.Sprintf("SELECT %s FROM usage_logs %s ORDER BY %s LIMIT $%d OFFSET $%d", usageLogSelectColumns, whereClause, usageLogOrderBy(params), limitPos, offsetPos)
logs, err := r.queryUsageLogs(ctx, query, listArgs...) logs, err := r.queryUsageLogs(ctx, query, listArgs...)
if err != nil { if err != nil {
@ -3808,6 +3808,28 @@ func (r *usageLogRepository) listUsageLogsWithFastPagination(ctx context.Context
return logs, paginationResultFromTotal(total, params), nil return logs, paginationResultFromTotal(total, params), nil
} }
func usageLogOrderBy(params pagination.PaginationParams) string {
sortBy := strings.ToLower(strings.TrimSpace(params.SortBy))
sortOrder := strings.ToUpper(params.NormalizedSortOrder(pagination.SortOrderDesc))
column := "id"
switch sortBy {
case "model":
column = "COALESCE(NULLIF(TRIM(requested_model), ''), model)"
case "created_at":
column = "created_at"
case "id", "":
column = "id"
default:
column = "id"
}
if column == "id" {
return fmt.Sprintf("id %s", sortOrder)
}
return fmt.Sprintf("%s %s, id %s", column, sortOrder, sortOrder)
}
func (r *usageLogRepository) queryUsageLogs(ctx context.Context, query string, args ...any) (logs []service.UsageLog, err error) { func (r *usageLogRepository) queryUsageLogs(ctx context.Context, query string, args ...any) (logs []service.UsageLog, err error) {
rows, err := r.sql.QueryContext(ctx, query, args...) rows, err := r.sql.QueryContext(ctx, query, args...)
if err != nil { if err != nil {

View File

@ -0,0 +1,61 @@
//go:build integration
package repository
import (
"time"
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
"github.com/Wei-Shaw/sub2api/internal/pkg/usagestats"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/google/uuid"
)
func (s *UsageLogRepoSuite) TestListWithFilters_SortByModelAsc() {
user := mustCreateUser(s.T(), s.client, &service.User{Email: "usage-sort@example.com"})
apiKey := mustCreateApiKey(s.T(), s.client, &service.APIKey{UserID: user.ID, Key: "sk-usage-sort", Name: "k"})
account := mustCreateAccount(s.T(), s.client, &service.Account{Name: "usage-sort-account"})
first := &service.UsageLog{
UserID: user.ID,
APIKeyID: apiKey.ID,
AccountID: account.ID,
RequestID: uuid.New().String(),
Model: "z-model",
RequestedModel: "z-model",
InputTokens: 10,
OutputTokens: 20,
TotalCost: 0.5,
ActualCost: 0.5,
CreatedAt: time.Now(),
}
_, err := s.repo.Create(s.ctx, first)
s.Require().NoError(err)
second := &service.UsageLog{
UserID: user.ID,
APIKeyID: apiKey.ID,
AccountID: account.ID,
RequestID: uuid.New().String(),
Model: "a-model",
RequestedModel: "a-model",
InputTokens: 10,
OutputTokens: 20,
TotalCost: 0.5,
ActualCost: 0.5,
CreatedAt: time.Now().Add(time.Second),
}
_, err = s.repo.Create(s.ctx, second)
s.Require().NoError(err)
logs, _, err := s.repo.ListWithFilters(s.ctx, pagination.PaginationParams{
Page: 1,
PageSize: 10,
SortBy: "model",
SortOrder: "asc",
}, usagestats.UsageLogFilters{UserID: user.ID})
s.Require().NoError(err)
s.Require().Len(logs, 2)
s.Require().Equal("a-model", logs[0].RequestedModel)
s.Require().Equal("z-model", logs[1].RequestedModel)
}

View File

@ -17,6 +17,8 @@ import (
"github.com/Wei-Shaw/sub2api/ent/usersubscription" "github.com/Wei-Shaw/sub2api/ent/usersubscription"
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination" "github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
"github.com/Wei-Shaw/sub2api/internal/service" "github.com/Wei-Shaw/sub2api/internal/service"
entsql "entgo.io/ent/dialect/sql"
) )
type userRepository struct { type userRepository struct {
@ -224,11 +226,14 @@ func (r *userRepository) ListWithFilters(ctx context.Context, params pagination.
return nil, nil, err return nil, nil, err
} }
users, err := q. usersQuery := q.
Offset(params.Offset()). Offset(params.Offset()).
Limit(params.Limit()). Limit(params.Limit())
Order(dbent.Desc(dbuser.FieldID)). for _, order := range userListOrder(params) {
All(ctx) usersQuery = usersQuery.Order(order)
}
users, err := usersQuery.All(ctx)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
@ -281,6 +286,52 @@ func (r *userRepository) ListWithFilters(ctx context.Context, params pagination.
return outUsers, paginationResultFromTotal(int64(total), params), nil return outUsers, paginationResultFromTotal(int64(total), params), nil
} }
func userListOrder(params pagination.PaginationParams) []func(*entsql.Selector) {
sortBy := strings.ToLower(strings.TrimSpace(params.SortBy))
sortOrder := params.NormalizedSortOrder(pagination.SortOrderDesc)
field := dbuser.FieldID
defaultField := true
switch sortBy {
case "email":
field = dbuser.FieldEmail
defaultField = false
case "id", "":
field = dbuser.FieldID
case "username":
field = dbuser.FieldUsername
defaultField = false
case "role":
field = dbuser.FieldRole
defaultField = false
case "balance":
field = dbuser.FieldBalance
defaultField = false
case "concurrency":
field = dbuser.FieldConcurrency
defaultField = false
case "status":
field = dbuser.FieldStatus
defaultField = false
case "created_at":
field = dbuser.FieldCreatedAt
defaultField = false
default:
field = dbuser.FieldID
}
if sortOrder == pagination.SortOrderAsc {
if defaultField && field == dbuser.FieldID {
return []func(*entsql.Selector){dbent.Asc(dbuser.FieldID)}
}
return []func(*entsql.Selector){dbent.Asc(field), dbent.Asc(dbuser.FieldID)}
}
if defaultField && field == dbuser.FieldID {
return []func(*entsql.Selector){dbent.Desc(dbuser.FieldID)}
}
return []func(*entsql.Selector){dbent.Desc(field), dbent.Desc(dbuser.FieldID)}
}
// filterUsersByAttributes returns user IDs that match ALL the given attribute filters // filterUsersByAttributes returns user IDs that match ALL the given attribute filters
func (r *userRepository) filterUsersByAttributes(ctx context.Context, attrs map[int64]string) ([]int64, error) { func (r *userRepository) filterUsersByAttributes(ctx context.Context, attrs map[int64]string) ([]int64, error) {
if len(attrs) == 0 { if len(attrs) == 0 {

View File

@ -0,0 +1,39 @@
//go:build integration
package repository
import (
"testing"
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
"github.com/Wei-Shaw/sub2api/internal/service"
)
func (s *UserRepoSuite) TestListWithFilters_SortByEmailAsc() {
s.mustCreateUser(&service.User{Email: "z-last@example.com", Username: "z-user"})
s.mustCreateUser(&service.User{Email: "a-first@example.com", Username: "a-user"})
users, _, err := s.repo.ListWithFilters(s.ctx, pagination.PaginationParams{
Page: 1,
PageSize: 10,
SortBy: "email",
SortOrder: "asc",
}, service.UserListFilters{})
s.Require().NoError(err)
s.Require().Len(users, 2)
s.Require().Equal("a-first@example.com", users[0].Email)
s.Require().Equal("z-last@example.com", users[1].Email)
}
func (s *UserRepoSuite) TestList_DefaultSortByNewestFirst() {
first := s.mustCreateUser(&service.User{Email: "first@example.com"})
second := s.mustCreateUser(&service.User{Email: "second@example.com"})
users, _, err := s.repo.List(s.ctx, pagination.PaginationParams{Page: 1, PageSize: 10})
s.Require().NoError(err)
s.Require().Len(users, 2)
s.Require().Equal(second.ID, users[0].ID)
s.Require().Equal(first.ID, users[1].ID)
}
func TestUserRepoSortSuiteSmoke(_ *testing.T) {}

View File

@ -532,6 +532,8 @@ func TestAPIContracts(t *testing.T) {
"hide_ccs_import_button": false, "hide_ccs_import_button": false,
"purchase_subscription_enabled": false, "purchase_subscription_enabled": false,
"purchase_subscription_url": "", "purchase_subscription_url": "",
"table_default_page_size": 20,
"table_page_size_options": [10, 20, 50],
"min_claude_code_version": "", "min_claude_code_version": "",
"max_claude_code_version": "", "max_claude_code_version": "",
"allow_ungrouped_key_scheduling": false, "allow_ungrouped_key_scheduling": false,

View File

@ -21,13 +21,13 @@ import (
// AdminService interface defines admin management operations // AdminService interface defines admin management operations
type AdminService interface { type AdminService interface {
// User management // User management
ListUsers(ctx context.Context, page, pageSize int, filters UserListFilters) ([]User, int64, error) ListUsers(ctx context.Context, page, pageSize int, filters UserListFilters, sortBy, sortOrder string) ([]User, int64, error)
GetUser(ctx context.Context, id int64) (*User, error) GetUser(ctx context.Context, id int64) (*User, error)
CreateUser(ctx context.Context, input *CreateUserInput) (*User, error) CreateUser(ctx context.Context, input *CreateUserInput) (*User, error)
UpdateUser(ctx context.Context, id int64, input *UpdateUserInput) (*User, error) UpdateUser(ctx context.Context, id int64, input *UpdateUserInput) (*User, error)
DeleteUser(ctx context.Context, id int64) error DeleteUser(ctx context.Context, id int64) error
UpdateUserBalance(ctx context.Context, userID int64, balance float64, operation string, notes string) (*User, error) UpdateUserBalance(ctx context.Context, userID int64, balance float64, operation string, notes string) (*User, error)
GetUserAPIKeys(ctx context.Context, userID int64, page, pageSize int) ([]APIKey, int64, error) GetUserAPIKeys(ctx context.Context, userID int64, page, pageSize int, sortBy, sortOrder string) ([]APIKey, int64, error)
GetUserUsageStats(ctx context.Context, userID int64, period string) (any, error) GetUserUsageStats(ctx context.Context, userID int64, period string) (any, error)
// GetUserBalanceHistory returns paginated balance/concurrency change records for a user. // GetUserBalanceHistory returns paginated balance/concurrency change records for a user.
// codeType is optional - pass empty string to return all types. // codeType is optional - pass empty string to return all types.
@ -35,7 +35,7 @@ type AdminService interface {
GetUserBalanceHistory(ctx context.Context, userID int64, page, pageSize int, codeType string) ([]RedeemCode, int64, float64, error) GetUserBalanceHistory(ctx context.Context, userID int64, page, pageSize int, codeType string) ([]RedeemCode, int64, float64, error)
// Group management // Group management
ListGroups(ctx context.Context, page, pageSize int, platform, status, search string, isExclusive *bool) ([]Group, int64, error) ListGroups(ctx context.Context, page, pageSize int, platform, status, search string, isExclusive *bool, sortBy, sortOrder string) ([]Group, int64, error)
GetAllGroups(ctx context.Context) ([]Group, error) GetAllGroups(ctx context.Context) ([]Group, error)
GetAllGroupsByPlatform(ctx context.Context, platform string) ([]Group, error) GetAllGroupsByPlatform(ctx context.Context, platform string) ([]Group, error)
GetGroup(ctx context.Context, id int64) (*Group, error) GetGroup(ctx context.Context, id int64) (*Group, error)
@ -55,7 +55,7 @@ type AdminService interface {
ReplaceUserGroup(ctx context.Context, userID, oldGroupID, newGroupID int64) (*ReplaceUserGroupResult, error) ReplaceUserGroup(ctx context.Context, userID, oldGroupID, newGroupID int64) (*ReplaceUserGroupResult, error)
// Account management // Account management
ListAccounts(ctx context.Context, page, pageSize int, platform, accountType, status, search string, groupID int64, privacyMode string) ([]Account, int64, error) ListAccounts(ctx context.Context, page, pageSize int, platform, accountType, status, search string, groupID int64, privacyMode string, sortBy, sortOrder string) ([]Account, int64, error)
GetAccount(ctx context.Context, id int64) (*Account, error) GetAccount(ctx context.Context, id int64) (*Account, error)
GetAccountsByIDs(ctx context.Context, ids []int64) ([]*Account, error) GetAccountsByIDs(ctx context.Context, ids []int64) ([]*Account, error)
CreateAccount(ctx context.Context, input *CreateAccountInput) (*Account, error) CreateAccount(ctx context.Context, input *CreateAccountInput) (*Account, error)
@ -77,8 +77,8 @@ type AdminService interface {
CheckMixedChannelRisk(ctx context.Context, currentAccountID int64, currentAccountPlatform string, groupIDs []int64) error CheckMixedChannelRisk(ctx context.Context, currentAccountID int64, currentAccountPlatform string, groupIDs []int64) error
// Proxy management // Proxy management
ListProxies(ctx context.Context, page, pageSize int, protocol, status, search string) ([]Proxy, int64, error) ListProxies(ctx context.Context, page, pageSize int, protocol, status, search string, sortBy, sortOrder string) ([]Proxy, int64, error)
ListProxiesWithAccountCount(ctx context.Context, page, pageSize int, protocol, status, search string) ([]ProxyWithAccountCount, int64, error) ListProxiesWithAccountCount(ctx context.Context, page, pageSize int, protocol, status, search string, sortBy, sortOrder string) ([]ProxyWithAccountCount, int64, error)
GetAllProxies(ctx context.Context) ([]Proxy, error) GetAllProxies(ctx context.Context) ([]Proxy, error)
GetAllProxiesWithAccountCount(ctx context.Context) ([]ProxyWithAccountCount, error) GetAllProxiesWithAccountCount(ctx context.Context) ([]ProxyWithAccountCount, error)
GetProxy(ctx context.Context, id int64) (*Proxy, error) GetProxy(ctx context.Context, id int64) (*Proxy, error)
@ -93,7 +93,7 @@ type AdminService interface {
CheckProxyQuality(ctx context.Context, id int64) (*ProxyQualityCheckResult, error) CheckProxyQuality(ctx context.Context, id int64) (*ProxyQualityCheckResult, error)
// Redeem code management // Redeem code management
ListRedeemCodes(ctx context.Context, page, pageSize int, codeType, status, search string) ([]RedeemCode, int64, error) ListRedeemCodes(ctx context.Context, page, pageSize int, codeType, status, search string, sortBy, sortOrder string) ([]RedeemCode, int64, error)
GetRedeemCode(ctx context.Context, id int64) (*RedeemCode, error) GetRedeemCode(ctx context.Context, id int64) (*RedeemCode, error)
GenerateRedeemCodes(ctx context.Context, input *GenerateRedeemCodesInput) ([]RedeemCode, error) GenerateRedeemCodes(ctx context.Context, input *GenerateRedeemCodesInput) ([]RedeemCode, error)
DeleteRedeemCode(ctx context.Context, id int64) error DeleteRedeemCode(ctx context.Context, id int64) error
@ -483,8 +483,8 @@ func NewAdminService(
} }
// User management implementations // User management implementations
func (s *adminServiceImpl) ListUsers(ctx context.Context, page, pageSize int, filters UserListFilters) ([]User, int64, error) { func (s *adminServiceImpl) ListUsers(ctx context.Context, page, pageSize int, filters UserListFilters, sortBy, sortOrder string) ([]User, int64, error) {
params := pagination.PaginationParams{Page: page, PageSize: pageSize} params := pagination.PaginationParams{Page: page, PageSize: pageSize, SortBy: sortBy, SortOrder: sortOrder}
users, result, err := s.userRepo.ListWithFilters(ctx, params, filters) users, result, err := s.userRepo.ListWithFilters(ctx, params, filters)
if err != nil { if err != nil {
return nil, 0, err return nil, 0, err
@ -751,8 +751,8 @@ func (s *adminServiceImpl) UpdateUserBalance(ctx context.Context, userID int64,
return user, nil return user, nil
} }
func (s *adminServiceImpl) GetUserAPIKeys(ctx context.Context, userID int64, page, pageSize int) ([]APIKey, int64, error) { func (s *adminServiceImpl) GetUserAPIKeys(ctx context.Context, userID int64, page, pageSize int, sortBy, sortOrder string) ([]APIKey, int64, error) {
params := pagination.PaginationParams{Page: page, PageSize: pageSize} params := pagination.PaginationParams{Page: page, PageSize: pageSize, SortBy: sortBy, SortOrder: sortOrder}
keys, result, err := s.apiKeyRepo.ListByUserID(ctx, userID, params, APIKeyListFilters{}) keys, result, err := s.apiKeyRepo.ListByUserID(ctx, userID, params, APIKeyListFilters{})
if err != nil { if err != nil {
return nil, 0, err return nil, 0, err
@ -787,8 +787,8 @@ func (s *adminServiceImpl) GetUserBalanceHistory(ctx context.Context, userID int
} }
// Group management implementations // Group management implementations
func (s *adminServiceImpl) ListGroups(ctx context.Context, page, pageSize int, platform, status, search string, isExclusive *bool) ([]Group, int64, error) { func (s *adminServiceImpl) ListGroups(ctx context.Context, page, pageSize int, platform, status, search string, isExclusive *bool, sortBy, sortOrder string) ([]Group, int64, error) {
params := pagination.PaginationParams{Page: page, PageSize: pageSize} params := pagination.PaginationParams{Page: page, PageSize: pageSize, SortBy: sortBy, SortOrder: sortOrder}
groups, result, err := s.groupRepo.ListWithFilters(ctx, params, platform, status, search, isExclusive) groups, result, err := s.groupRepo.ListWithFilters(ctx, params, platform, status, search, isExclusive)
if err != nil { if err != nil {
return nil, 0, err return nil, 0, err
@ -1456,8 +1456,8 @@ func (s *adminServiceImpl) ReplaceUserGroup(ctx context.Context, userID, oldGrou
} }
// Account management implementations // Account management implementations
func (s *adminServiceImpl) ListAccounts(ctx context.Context, page, pageSize int, platform, accountType, status, search string, groupID int64, privacyMode string) ([]Account, int64, error) { func (s *adminServiceImpl) ListAccounts(ctx context.Context, page, pageSize int, platform, accountType, status, search string, groupID int64, privacyMode string, sortBy, sortOrder string) ([]Account, int64, error) {
params := pagination.PaginationParams{Page: page, PageSize: pageSize} params := pagination.PaginationParams{Page: page, PageSize: pageSize, SortBy: sortBy, SortOrder: sortOrder}
accounts, result, err := s.accountRepo.ListWithFilters(ctx, params, platform, accountType, status, search, groupID, privacyMode) accounts, result, err := s.accountRepo.ListWithFilters(ctx, params, platform, accountType, status, search, groupID, privacyMode)
if err != nil { if err != nil {
return nil, 0, err return nil, 0, err
@ -1885,8 +1885,8 @@ func (s *adminServiceImpl) SetAccountSchedulable(ctx context.Context, id int64,
} }
// Proxy management implementations // Proxy management implementations
func (s *adminServiceImpl) ListProxies(ctx context.Context, page, pageSize int, protocol, status, search string) ([]Proxy, int64, error) { func (s *adminServiceImpl) ListProxies(ctx context.Context, page, pageSize int, protocol, status, search string, sortBy, sortOrder string) ([]Proxy, int64, error) {
params := pagination.PaginationParams{Page: page, PageSize: pageSize} params := pagination.PaginationParams{Page: page, PageSize: pageSize, SortBy: sortBy, SortOrder: sortOrder}
proxies, result, err := s.proxyRepo.ListWithFilters(ctx, params, protocol, status, search) proxies, result, err := s.proxyRepo.ListWithFilters(ctx, params, protocol, status, search)
if err != nil { if err != nil {
return nil, 0, err return nil, 0, err
@ -1894,8 +1894,8 @@ func (s *adminServiceImpl) ListProxies(ctx context.Context, page, pageSize int,
return proxies, result.Total, nil return proxies, result.Total, nil
} }
func (s *adminServiceImpl) ListProxiesWithAccountCount(ctx context.Context, page, pageSize int, protocol, status, search string) ([]ProxyWithAccountCount, int64, error) { func (s *adminServiceImpl) ListProxiesWithAccountCount(ctx context.Context, page, pageSize int, protocol, status, search string, sortBy, sortOrder string) ([]ProxyWithAccountCount, int64, error) {
params := pagination.PaginationParams{Page: page, PageSize: pageSize} params := pagination.PaginationParams{Page: page, PageSize: pageSize, SortBy: sortBy, SortOrder: sortOrder}
proxies, result, err := s.proxyRepo.ListWithFiltersAndAccountCount(ctx, params, protocol, status, search) proxies, result, err := s.proxyRepo.ListWithFiltersAndAccountCount(ctx, params, protocol, status, search)
if err != nil { if err != nil {
return nil, 0, err return nil, 0, err
@ -2032,8 +2032,8 @@ func (s *adminServiceImpl) CheckProxyExists(ctx context.Context, host string, po
} }
// Redeem code management implementations // Redeem code management implementations
func (s *adminServiceImpl) ListRedeemCodes(ctx context.Context, page, pageSize int, codeType, status, search string) ([]RedeemCode, int64, error) { func (s *adminServiceImpl) ListRedeemCodes(ctx context.Context, page, pageSize int, codeType, status, search string, sortBy, sortOrder string) ([]RedeemCode, int64, error) {
params := pagination.PaginationParams{Page: page, PageSize: pageSize} params := pagination.PaginationParams{Page: page, PageSize: pageSize, SortBy: sortBy, SortOrder: sortOrder}
codes, result, err := s.redeemCodeRepo.ListWithFilters(ctx, params, codeType, status, search) codes, result, err := s.redeemCodeRepo.ListWithFilters(ctx, params, codeType, status, search)
if err != nil { if err != nil {
return nil, 0, err return nil, 0, err

View File

@ -120,6 +120,22 @@ func (s *groupRepoStubForAdmin) UpdateSortOrders(_ context.Context, _ []GroupSor
return nil return nil
} }
func TestAdminService_ListGroups_PassesSortParams(t *testing.T) {
repo := &groupRepoStubForAdmin{
listWithFiltersGroups: []Group{{ID: 1, Name: "g1"}},
}
svc := &adminServiceImpl{groupRepo: repo}
_, _, err := svc.ListGroups(context.Background(), 3, 25, PlatformOpenAI, StatusActive, "needle", nil, "account_count", "ASC")
require.NoError(t, err)
require.Equal(t, pagination.PaginationParams{
Page: 3,
PageSize: 25,
SortBy: "account_count",
SortOrder: "ASC",
}, repo.listWithFiltersParams)
}
// TestAdminService_CreateGroup_WithImagePricing 测试创建分组时 ImagePrice 字段正确传递 // TestAdminService_CreateGroup_WithImagePricing 测试创建分组时 ImagePrice 字段正确传递
func TestAdminService_CreateGroup_WithImagePricing(t *testing.T) { func TestAdminService_CreateGroup_WithImagePricing(t *testing.T) {
repo := &groupRepoStubForAdmin{} repo := &groupRepoStubForAdmin{}
@ -258,7 +274,7 @@ func TestAdminService_ListGroups_WithSearch(t *testing.T) {
} }
svc := &adminServiceImpl{groupRepo: repo} svc := &adminServiceImpl{groupRepo: repo}
groups, total, err := svc.ListGroups(context.Background(), 1, 20, "", "", "alpha", nil) groups, total, err := svc.ListGroups(context.Background(), 1, 20, "", "", "alpha", nil, "", "")
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, int64(1), total) require.Equal(t, int64(1), total)
require.Equal(t, []Group{{ID: 1, Name: "alpha"}}, groups) require.Equal(t, []Group{{ID: 1, Name: "alpha"}}, groups)
@ -276,7 +292,7 @@ func TestAdminService_ListGroups_WithSearch(t *testing.T) {
} }
svc := &adminServiceImpl{groupRepo: repo} svc := &adminServiceImpl{groupRepo: repo}
groups, total, err := svc.ListGroups(context.Background(), 2, 10, "", "", "", nil) groups, total, err := svc.ListGroups(context.Background(), 2, 10, "", "", "", nil, "", "")
require.NoError(t, err) require.NoError(t, err)
require.Empty(t, groups) require.Empty(t, groups)
require.Equal(t, int64(0), total) require.Equal(t, int64(0), total)
@ -295,7 +311,7 @@ func TestAdminService_ListGroups_WithSearch(t *testing.T) {
} }
svc := &adminServiceImpl{groupRepo: repo} svc := &adminServiceImpl{groupRepo: repo}
groups, total, err := svc.ListGroups(context.Background(), 3, 50, PlatformAntigravity, StatusActive, "beta", &isExclusive) groups, total, err := svc.ListGroups(context.Background(), 3, 50, PlatformAntigravity, StatusActive, "beta", &isExclusive, "", "")
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, int64(42), total) require.Equal(t, int64(42), total)
require.Equal(t, []Group{{ID: 2, Name: "beta"}}, groups) require.Equal(t, []Group{{ID: 2, Name: "beta"}}, groups)

View File

@ -13,11 +13,13 @@ import (
type userRepoStubForListUsers struct { type userRepoStubForListUsers struct {
userRepoStub userRepoStub
users []User users []User
err error err error
listWithFiltersParams pagination.PaginationParams
} }
func (s *userRepoStubForListUsers) ListWithFilters(_ context.Context, params pagination.PaginationParams, _ UserListFilters) ([]User, *pagination.PaginationResult, error) { func (s *userRepoStubForListUsers) ListWithFilters(_ context.Context, params pagination.PaginationParams, _ UserListFilters) ([]User, *pagination.PaginationResult, error) {
s.listWithFiltersParams = params
if s.err != nil { if s.err != nil {
return nil, nil, s.err return nil, nil, s.err
} }
@ -103,7 +105,7 @@ func TestAdminService_ListUsers_BatchRateFallbackToSingle(t *testing.T) {
userGroupRateRepo: rateRepo, userGroupRateRepo: rateRepo,
} }
users, total, err := svc.ListUsers(context.Background(), 1, 20, UserListFilters{}) users, total, err := svc.ListUsers(context.Background(), 1, 20, UserListFilters{}, "", "")
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, int64(2), total) require.Equal(t, int64(2), total)
require.Len(t, users, 2) require.Len(t, users, 2)
@ -112,3 +114,19 @@ func TestAdminService_ListUsers_BatchRateFallbackToSingle(t *testing.T) {
require.Equal(t, 1.1, users[0].GroupRates[11]) require.Equal(t, 1.1, users[0].GroupRates[11])
require.Equal(t, 2.2, users[1].GroupRates[22]) require.Equal(t, 2.2, users[1].GroupRates[22])
} }
func TestAdminService_ListUsers_PassesSortParams(t *testing.T) {
userRepo := &userRepoStubForListUsers{
users: []User{{ID: 1, Email: "a@example.com"}},
}
svc := &adminServiceImpl{userRepo: userRepo}
_, _, err := svc.ListUsers(context.Background(), 2, 50, UserListFilters{}, "email", "ASC")
require.NoError(t, err)
require.Equal(t, pagination.PaginationParams{
Page: 2,
PageSize: 50,
SortBy: "email",
SortOrder: "ASC",
}, userRepo.listWithFiltersParams)
}

View File

@ -170,13 +170,13 @@ func TestAdminService_ListAccounts_WithSearch(t *testing.T) {
} }
svc := &adminServiceImpl{accountRepo: repo} svc := &adminServiceImpl{accountRepo: repo}
accounts, total, err := svc.ListAccounts(context.Background(), 1, 20, PlatformGemini, AccountTypeOAuth, StatusActive, "acc", 0, "") accounts, total, err := svc.ListAccounts(context.Background(), 1, 20, PlatformGemini, AccountTypeOAuth, StatusActive, "acc", 0, "", "name", "ASC")
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, int64(10), total) require.Equal(t, int64(10), total)
require.Equal(t, []Account{{ID: 1, Name: "acc"}}, accounts) require.Equal(t, []Account{{ID: 1, Name: "acc"}}, accounts)
require.Equal(t, 1, repo.listWithFiltersCalls) require.Equal(t, 1, repo.listWithFiltersCalls)
require.Equal(t, pagination.PaginationParams{Page: 1, PageSize: 20}, repo.listWithFiltersParams) require.Equal(t, pagination.PaginationParams{Page: 1, PageSize: 20, SortBy: "name", SortOrder: "ASC"}, repo.listWithFiltersParams)
require.Equal(t, PlatformGemini, repo.listWithFiltersPlatform) require.Equal(t, PlatformGemini, repo.listWithFiltersPlatform)
require.Equal(t, AccountTypeOAuth, repo.listWithFiltersType) require.Equal(t, AccountTypeOAuth, repo.listWithFiltersType)
require.Equal(t, StatusActive, repo.listWithFiltersStatus) require.Equal(t, StatusActive, repo.listWithFiltersStatus)
@ -192,7 +192,7 @@ func TestAdminService_ListAccounts_WithPrivacyMode(t *testing.T) {
} }
svc := &adminServiceImpl{accountRepo: repo} svc := &adminServiceImpl{accountRepo: repo}
accounts, total, err := svc.ListAccounts(context.Background(), 1, 20, PlatformOpenAI, AccountTypeOAuth, StatusActive, "acc2", 0, PrivacyModeCFBlocked) accounts, total, err := svc.ListAccounts(context.Background(), 1, 20, PlatformOpenAI, AccountTypeOAuth, StatusActive, "acc2", 0, PrivacyModeCFBlocked, "", "")
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, int64(1), total) require.Equal(t, int64(1), total)
require.Equal(t, []Account{{ID: 2, Name: "acc2"}}, accounts) require.Equal(t, []Account{{ID: 2, Name: "acc2"}}, accounts)
@ -208,13 +208,13 @@ func TestAdminService_ListProxies_WithSearch(t *testing.T) {
} }
svc := &adminServiceImpl{proxyRepo: repo} svc := &adminServiceImpl{proxyRepo: repo}
proxies, total, err := svc.ListProxies(context.Background(), 3, 50, "http", StatusActive, "p1") proxies, total, err := svc.ListProxies(context.Background(), 3, 50, "http", StatusActive, "p1", "name", "ASC")
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, int64(7), total) require.Equal(t, int64(7), total)
require.Equal(t, []Proxy{{ID: 2, Name: "p1"}}, proxies) require.Equal(t, []Proxy{{ID: 2, Name: "p1"}}, proxies)
require.Equal(t, 1, repo.listWithFiltersCalls) require.Equal(t, 1, repo.listWithFiltersCalls)
require.Equal(t, pagination.PaginationParams{Page: 3, PageSize: 50}, repo.listWithFiltersParams) require.Equal(t, pagination.PaginationParams{Page: 3, PageSize: 50, SortBy: "name", SortOrder: "ASC"}, repo.listWithFiltersParams)
require.Equal(t, "http", repo.listWithFiltersProtocol) require.Equal(t, "http", repo.listWithFiltersProtocol)
require.Equal(t, StatusActive, repo.listWithFiltersStatus) require.Equal(t, StatusActive, repo.listWithFiltersStatus)
require.Equal(t, "p1", repo.listWithFiltersSearch) require.Equal(t, "p1", repo.listWithFiltersSearch)
@ -229,13 +229,13 @@ func TestAdminService_ListProxiesWithAccountCount_WithSearch(t *testing.T) {
} }
svc := &adminServiceImpl{proxyRepo: repo} svc := &adminServiceImpl{proxyRepo: repo}
proxies, total, err := svc.ListProxiesWithAccountCount(context.Background(), 2, 10, "socks5", StatusDisabled, "p2") proxies, total, err := svc.ListProxiesWithAccountCount(context.Background(), 2, 10, "socks5", StatusDisabled, "p2", "account_count", "DESC")
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, int64(9), total) require.Equal(t, int64(9), total)
require.Equal(t, []ProxyWithAccountCount{{Proxy: Proxy{ID: 3, Name: "p2"}, AccountCount: 5}}, proxies) require.Equal(t, []ProxyWithAccountCount{{Proxy: Proxy{ID: 3, Name: "p2"}, AccountCount: 5}}, proxies)
require.Equal(t, 1, repo.listWithFiltersAndAccountCountCalls) require.Equal(t, 1, repo.listWithFiltersAndAccountCountCalls)
require.Equal(t, pagination.PaginationParams{Page: 2, PageSize: 10}, repo.listWithFiltersAndAccountCountParams) require.Equal(t, pagination.PaginationParams{Page: 2, PageSize: 10, SortBy: "account_count", SortOrder: "DESC"}, repo.listWithFiltersAndAccountCountParams)
require.Equal(t, "socks5", repo.listWithFiltersAndAccountCountProtocol) require.Equal(t, "socks5", repo.listWithFiltersAndAccountCountProtocol)
require.Equal(t, StatusDisabled, repo.listWithFiltersAndAccountCountStatus) require.Equal(t, StatusDisabled, repo.listWithFiltersAndAccountCountStatus)
require.Equal(t, "p2", repo.listWithFiltersAndAccountCountSearch) require.Equal(t, "p2", repo.listWithFiltersAndAccountCountSearch)
@ -250,13 +250,13 @@ func TestAdminService_ListRedeemCodes_WithSearch(t *testing.T) {
} }
svc := &adminServiceImpl{redeemCodeRepo: repo} svc := &adminServiceImpl{redeemCodeRepo: repo}
codes, total, err := svc.ListRedeemCodes(context.Background(), 1, 20, RedeemTypeBalance, StatusUnused, "ABC") codes, total, err := svc.ListRedeemCodes(context.Background(), 1, 20, RedeemTypeBalance, StatusUnused, "ABC", "value", "ASC")
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, int64(3), total) require.Equal(t, int64(3), total)
require.Equal(t, []RedeemCode{{ID: 4, Code: "ABC"}}, codes) require.Equal(t, []RedeemCode{{ID: 4, Code: "ABC"}}, codes)
require.Equal(t, 1, repo.listWithFiltersCalls) require.Equal(t, 1, repo.listWithFiltersCalls)
require.Equal(t, pagination.PaginationParams{Page: 1, PageSize: 20}, repo.listWithFiltersParams) require.Equal(t, pagination.PaginationParams{Page: 1, PageSize: 20, SortBy: "value", SortOrder: "ASC"}, repo.listWithFiltersParams)
require.Equal(t, RedeemTypeBalance, repo.listWithFiltersType) require.Equal(t, RedeemTypeBalance, repo.listWithFiltersType)
require.Equal(t, StatusUnused, repo.listWithFiltersStatus) require.Equal(t, StatusUnused, repo.listWithFiltersStatus)
require.Equal(t, "ABC", repo.listWithFiltersSearch) require.Equal(t, "ABC", repo.listWithFiltersSearch)

View File

@ -116,6 +116,8 @@ const (
SettingKeyHideCcsImportButton = "hide_ccs_import_button" // 是否隐藏 API Keys 页面的导入 CCS 按钮 SettingKeyHideCcsImportButton = "hide_ccs_import_button" // 是否隐藏 API Keys 页面的导入 CCS 按钮
SettingKeyPurchaseSubscriptionEnabled = "purchase_subscription_enabled" // 是否展示"购买订阅"页面入口 SettingKeyPurchaseSubscriptionEnabled = "purchase_subscription_enabled" // 是否展示"购买订阅"页面入口
SettingKeyPurchaseSubscriptionURL = "purchase_subscription_url" // "购买订阅"页面 URL作为 iframe src SettingKeyPurchaseSubscriptionURL = "purchase_subscription_url" // "购买订阅"页面 URL作为 iframe src
SettingKeyTableDefaultPageSize = "table_default_page_size" // 表格默认每页条数
SettingKeyTablePageSizeOptions = "table_page_size_options" // 表格可选每页条数JSON 数组)
SettingKeyCustomMenuItems = "custom_menu_items" // 自定义菜单项JSON 数组) SettingKeyCustomMenuItems = "custom_menu_items" // 自定义菜单项JSON 数组)
SettingKeyCustomEndpoints = "custom_endpoints" // 自定义端点列表JSON 数组) SettingKeyCustomEndpoints = "custom_endpoints" // 自定义端点列表JSON 数组)

View File

@ -492,7 +492,7 @@ func TestAdminService_ListAccounts_ExhaustedCodexExtraReturnsRateLimitedAccount(
} }
svc := &adminServiceImpl{accountRepo: repo} svc := &adminServiceImpl{accountRepo: repo}
accounts, total, err := svc.ListAccounts(context.Background(), 1, 20, PlatformOpenAI, AccountTypeOAuth, "", "", 0, "") accounts, total, err := svc.ListAccounts(context.Background(), 1, 20, PlatformOpenAI, AccountTypeOAuth, "", "", 0, "", "", "")
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, int64(1), total) require.Equal(t, int64(1), total)
require.Len(t, accounts, 1) require.Len(t, accounts, 1)

View File

@ -38,6 +38,8 @@ export async function list(
search?: string search?: string
privacy_mode?: string privacy_mode?: string
lite?: string lite?: string
sort_by?: string
sort_order?: 'asc' | 'desc'
}, },
options?: { options?: {
signal?: AbortSignal signal?: AbortSignal
@ -71,6 +73,8 @@ export async function listWithEtag(
search?: string search?: string
privacy_mode?: string privacy_mode?: string
lite?: string lite?: string
sort_by?: string
sort_order?: 'asc' | 'desc'
}, },
options?: { options?: {
signal?: AbortSignal signal?: AbortSignal
@ -500,7 +504,11 @@ export async function exportData(options?: {
platform?: string platform?: string
type?: string type?: string
status?: string status?: string
group?: string
privacy_mode?: string
search?: string search?: string
sort_by?: string
sort_order?: 'asc' | 'desc'
} }
includeProxies?: boolean includeProxies?: boolean
}): Promise<AdminDataPayload> { }): Promise<AdminDataPayload> {
@ -508,11 +516,15 @@ export async function exportData(options?: {
if (options?.ids && options.ids.length > 0) { if (options?.ids && options.ids.length > 0) {
params.ids = options.ids.join(',') params.ids = options.ids.join(',')
} else if (options?.filters) { } else if (options?.filters) {
const { platform, type, status, search } = options.filters const { platform, type, status, group, privacy_mode, search, sort_by, sort_order } = options.filters
if (platform) params.platform = platform if (platform) params.platform = platform
if (type) params.type = type if (type) params.type = type
if (status) params.status = status if (status) params.status = status
if (group) params.group = group
if (privacy_mode) params.privacy_mode = privacy_mode
if (search) params.search = search if (search) params.search = search
if (sort_by) params.sort_by = sort_by
if (sort_order) params.sort_order = sort_order
} }
if (options?.includeProxies === false) { if (options?.includeProxies === false) {
params.include_proxies = 'false' params.include_proxies = 'false'

View File

@ -17,10 +17,16 @@ export async function list(
filters?: { filters?: {
status?: string status?: string
search?: string search?: string
sort_by?: string
sort_order?: 'asc' | 'desc'
},
options?: {
signal?: AbortSignal
} }
): Promise<BasePaginationResponse<Announcement>> { ): Promise<BasePaginationResponse<Announcement>> {
const { data } = await apiClient.get<BasePaginationResponse<Announcement>>('/admin/announcements', { const { data } = await apiClient.get<BasePaginationResponse<Announcement>>('/admin/announcements', {
params: { page, page_size: pageSize, ...filters } params: { page, page_size: pageSize, ...filters },
signal: options?.signal
}) })
return data return data
} }
@ -49,11 +55,21 @@ export async function getReadStatus(
id: number, id: number,
page: number = 1, page: number = 1,
pageSize: number = 20, pageSize: number = 20,
search: string = '' filters?: {
search?: string
sort_by?: string
sort_order?: 'asc' | 'desc'
},
options?: {
signal?: AbortSignal
}
): Promise<BasePaginationResponse<AnnouncementUserReadStatus>> { ): Promise<BasePaginationResponse<AnnouncementUserReadStatus>> {
const { data } = await apiClient.get<BasePaginationResponse<AnnouncementUserReadStatus>>( const { data } = await apiClient.get<BasePaginationResponse<AnnouncementUserReadStatus>>(
`/admin/announcements/${id}/read-status`, `/admin/announcements/${id}/read-status`,
{ params: { page, page_size: pageSize, search } } {
params: { page, page_size: pageSize, ...filters },
signal: options?.signal
}
) )
return data return data
} }
@ -68,4 +84,3 @@ const announcementsAPI = {
} }
export default announcementsAPI export default announcementsAPI

View File

@ -83,6 +83,8 @@ export async function list(
filters?: { filters?: {
status?: string status?: string
search?: string search?: string
sort_by?: string
sort_order?: 'asc' | 'desc'
}, },
options?: { signal?: AbortSignal } options?: { signal?: AbortSignal }
): Promise<PaginatedResponse<Channel>> { ): Promise<PaginatedResponse<Channel>> {

View File

@ -27,6 +27,8 @@ export async function list(
status?: 'active' | 'inactive' status?: 'active' | 'inactive'
is_exclusive?: boolean is_exclusive?: boolean
search?: string search?: string
sort_by?: string
sort_order?: 'asc' | 'desc'
}, },
options?: { options?: {
signal?: AbortSignal signal?: AbortSignal

View File

@ -17,10 +17,16 @@ export async function list(
filters?: { filters?: {
status?: string status?: string
search?: string search?: string
sort_by?: string
sort_order?: 'asc' | 'desc'
},
options?: {
signal?: AbortSignal
} }
): Promise<BasePaginationResponse<PromoCode>> { ): Promise<BasePaginationResponse<PromoCode>> {
const { data } = await apiClient.get<BasePaginationResponse<PromoCode>>('/admin/promo-codes', { const { data } = await apiClient.get<BasePaginationResponse<PromoCode>>('/admin/promo-codes', {
params: { page, page_size: pageSize, ...filters } params: { page, page_size: pageSize, ...filters },
signal: options?.signal
}) })
return data return data
} }

View File

@ -29,6 +29,8 @@ export async function list(
protocol?: string protocol?: string
status?: 'active' | 'inactive' status?: 'active' | 'inactive'
search?: string search?: string
sort_by?: string
sort_order?: 'asc' | 'desc'
}, },
options?: { options?: {
signal?: AbortSignal signal?: AbortSignal
@ -227,16 +229,20 @@ export async function exportData(options?: {
protocol?: string protocol?: string
status?: 'active' | 'inactive' status?: 'active' | 'inactive'
search?: string search?: string
sort_by?: string
sort_order?: 'asc' | 'desc'
} }
}): Promise<AdminDataPayload> { }): Promise<AdminDataPayload> {
const params: Record<string, string> = {} const params: Record<string, string> = {}
if (options?.ids && options.ids.length > 0) { if (options?.ids && options.ids.length > 0) {
params.ids = options.ids.join(',') params.ids = options.ids.join(',')
} else if (options?.filters) { } else if (options?.filters) {
const { protocol, status, search } = options.filters const { protocol, status, search, sort_by, sort_order } = options.filters
if (protocol) params.protocol = protocol if (protocol) params.protocol = protocol
if (status) params.status = status if (status) params.status = status
if (search) params.search = search if (search) params.search = search
if (sort_by) params.sort_by = sort_by
if (sort_order) params.sort_order = sort_order
} }
const { data } = await apiClient.get<AdminDataPayload>('/admin/proxies/data', { params }) const { data } = await apiClient.get<AdminDataPayload>('/admin/proxies/data', { params })
return data return data

View File

@ -81,6 +81,8 @@ export interface AdminUsageQueryParams extends UsageQueryParams {
user_id?: number user_id?: number
exact_total?: boolean exact_total?: boolean
billing_mode?: string billing_mode?: string
sort_by?: string
sort_order?: 'asc' | 'desc'
} }
// ==================== API Functions ==================== // ==================== API Functions ====================

View File

@ -24,6 +24,8 @@ export async function list(
group_name?: string // fuzzy filter by allowed group name group_name?: string // fuzzy filter by allowed group name
attributes?: Record<number, string> // attributeId -> value attributes?: Record<number, string> // attributeId -> value
include_subscriptions?: boolean include_subscriptions?: boolean
sort_by?: string
sort_order?: 'asc' | 'desc'
}, },
options?: { options?: {
signal?: AbortSignal signal?: AbortSignal
@ -37,7 +39,9 @@ export async function list(
role: filters?.role, role: filters?.role,
search: filters?.search, search: filters?.search,
group_name: filters?.group_name, group_name: filters?.group_name,
include_subscriptions: filters?.include_subscriptions include_subscriptions: filters?.include_subscriptions,
sort_by: filters?.sort_by,
sort_order: filters?.sort_order
} }
// Add attribute filters as attr[id]=value // Add attribute filters as attr[id]=value

View File

@ -17,7 +17,13 @@ import type { ApiKey, CreateApiKeyRequest, UpdateApiKeyRequest, PaginatedRespons
export async function list( export async function list(
page: number = 1, page: number = 1,
pageSize: number = 10, pageSize: number = 10,
filters?: { search?: string; status?: string; group_id?: number | string }, filters?: {
search?: string
status?: string
group_id?: number | string
sort_by?: string
sort_order?: 'asc' | 'desc'
},
options?: { options?: {
signal?: AbortSignal signal?: AbortSignal
} }

View File

@ -91,7 +91,7 @@ export async function list(
* @returns Paginated list of usage logs * @returns Paginated list of usage logs
*/ */
export async function query( export async function query(
params: UsageQueryParams, params: UsageQueryParams & { sort_by?: string; sort_order?: 'asc' | 'desc' },
config: { signal?: AbortSignal } = {} config: { signal?: AbortSignal } = {}
): Promise<PaginatedResponse<UsageLog>> { ): Promise<PaginatedResponse<UsageLog>> {
const { data } = await apiClient.get<PaginatedResponse<UsageLog>>('/usage', { const { data } = await apiClient.get<PaginatedResponse<UsageLog>>('/usage', {

View File

@ -21,7 +21,15 @@
</button> </button>
</div> </div>
<DataTable :columns="columns" :data="items" :loading="loading"> <DataTable
:columns="columns"
:data="items"
:loading="loading"
:server-side-sort="true"
default-sort-key="email"
default-sort-order="asc"
@sort="handleSort"
>
<template #cell-email="{ value }"> <template #cell-email="{ value }">
<span class="font-medium text-gray-900 dark:text-white">{{ value }}</span> <span class="font-medium text-gray-900 dark:text-white">{{ value }}</span>
</template> </template>
@ -62,7 +70,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue' import { computed, onUnmounted, reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
import { adminAPI } from '@/api/admin' import { adminAPI } from '@/api/admin'
@ -98,23 +106,54 @@ const pagination = reactive({
pages: 0 pages: 0
}) })
const sortState = reactive({
sort_by: 'email',
sort_order: 'asc' as 'asc' | 'desc'
})
const items = ref<AnnouncementUserReadStatus[]>([]) const items = ref<AnnouncementUserReadStatus[]>([])
const columns = computed<Column[]>(() => [ const columns = computed<Column[]>(() => [
{ key: 'email', label: t('common.email') }, { key: 'email', label: t('common.email'), sortable: true },
{ key: 'username', label: t('admin.users.columns.username') }, { key: 'username', label: t('admin.users.columns.username'), sortable: true },
{ key: 'balance', label: t('common.balance') }, { key: 'balance', label: t('common.balance'), sortable: true },
{ key: 'eligible', label: t('admin.announcements.eligible') }, { key: 'eligible', label: t('admin.announcements.eligible') },
{ key: 'read_at', label: t('admin.announcements.readAt') } { key: 'read_at', label: t('admin.announcements.readAt') }
]) ])
let currentController: AbortController | null = null let currentController: AbortController | null = null
let searchDebounceTimer: number | null = null
function resetDialogState() {
loading.value = false
search.value = ''
items.value = []
pagination.page = 1
pagination.total = 0
pagination.pages = 0
sortState.sort_by = 'email'
sortState.sort_order = 'asc'
}
function cancelPendingLoad(resetState = false) {
if (searchDebounceTimer) {
window.clearTimeout(searchDebounceTimer)
searchDebounceTimer = null
}
currentController?.abort()
currentController = null
if (resetState) {
resetDialogState()
}
}
async function load() { async function load() {
if (!props.show || !props.announcementId) return if (!props.show || !props.announcementId) return
if (currentController) currentController.abort() currentController?.abort()
currentController = new AbortController() const requestController = new AbortController()
currentController = requestController
const { signal } = requestController
try { try {
loading.value = true loading.value = true
@ -122,20 +161,37 @@ async function load() {
props.announcementId, props.announcementId,
pagination.page, pagination.page,
pagination.page_size, pagination.page_size,
search.value {
search: search.value,
sort_by: sortState.sort_by,
sort_order: sortState.sort_order
},
{ signal }
) )
if (signal.aborted || currentController !== requestController) return
items.value = res.items items.value = res.items
pagination.total = res.total pagination.total = res.total
pagination.pages = res.pages pagination.pages = res.pages
pagination.page = res.page pagination.page = res.page
pagination.page_size = res.page_size pagination.page_size = res.page_size
} catch (error: any) { } catch (error: any) {
if (currentController.signal.aborted || error?.name === 'AbortError') return if (
signal.aborted ||
currentController !== requestController ||
error?.name === 'AbortError' ||
error?.code === 'ERR_CANCELED'
) {
return
}
console.error('Failed to load read status:', error) console.error('Failed to load read status:', error)
appStore.showError(error.response?.data?.detail || t('admin.announcements.failedToLoadReadStatus')) appStore.showError(error.response?.data?.detail || t('admin.announcements.failedToLoadReadStatus'))
} finally { } finally {
loading.value = false if (currentController === requestController) {
loading.value = false
currentController = null
}
} }
} }
@ -150,7 +206,13 @@ function handlePageSizeChange(pageSize: number) {
load() load()
} }
let searchDebounceTimer: number | null = null function handleSort(key: string, order: 'asc' | 'desc') {
sortState.sort_by = key
sortState.sort_order = order
pagination.page = 1
load()
}
function handleSearch() { function handleSearch() {
if (searchDebounceTimer) window.clearTimeout(searchDebounceTimer) if (searchDebounceTimer) window.clearTimeout(searchDebounceTimer)
searchDebounceTimer = window.setTimeout(() => { searchDebounceTimer = window.setTimeout(() => {
@ -160,13 +222,17 @@ function handleSearch() {
} }
function handleClose() { function handleClose() {
cancelPendingLoad(true)
emit('close') emit('close')
} }
watch( watch(
() => props.show, () => props.show,
(v) => { (v) => {
if (!v) return if (!v) {
cancelPendingLoad(true)
return
}
pagination.page = 1 pagination.page = 1
load() load()
} }
@ -181,7 +247,7 @@ watch(
} }
) )
onMounted(() => { onUnmounted(() => {
// noop cancelPendingLoad()
}) })
</script> </script>

View File

@ -0,0 +1,95 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import AnnouncementReadStatusDialog from '../AnnouncementReadStatusDialog.vue'
const { getReadStatus, showError } = vi.hoisted(() => ({
getReadStatus: vi.fn(),
showError: vi.fn(),
}))
vi.mock('@/api/admin', () => ({
adminAPI: {
announcements: {
getReadStatus,
},
},
}))
vi.mock('@/stores/app', () => ({
useAppStore: () => ({
showError,
}),
}))
vi.mock('vue-i18n', async () => {
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
return {
...actual,
useI18n: () => ({
t: (key: string) => key,
}),
}
})
vi.mock('@/composables/usePersistedPageSize', () => ({
getPersistedPageSize: () => 20,
}))
const BaseDialogStub = {
props: ['show', 'title', 'width'],
emits: ['close'],
template: '<div><slot /><slot name="footer" /></div>',
}
describe('AnnouncementReadStatusDialog', () => {
beforeEach(() => {
getReadStatus.mockReset()
showError.mockReset()
vi.useFakeTimers()
})
it('closes by aborting active requests and clearing debounced reloads', async () => {
let activeSignal: AbortSignal | undefined
getReadStatus.mockImplementation(async (...args: any[]) => {
activeSignal = args[4]?.signal
return new Promise(() => {})
})
const wrapper = mount(AnnouncementReadStatusDialog, {
props: {
show: false,
announcementId: 1,
},
global: {
stubs: {
BaseDialog: BaseDialogStub,
DataTable: true,
Pagination: true,
Icon: true,
},
},
})
await wrapper.setProps({ show: true })
await flushPromises()
expect(getReadStatus).toHaveBeenCalledTimes(1)
expect(activeSignal?.aborted).toBe(false)
const setupState = (wrapper.vm as any).$?.setupState
setupState.search = 'alice'
setupState.handleSearch()
setupState.handleClose()
await flushPromises()
expect(activeSignal?.aborted).toBe(true)
expect(wrapper.emitted('close')).toHaveLength(1)
vi.advanceTimersByTime(350)
await flushPromises()
expect(getReadStatus).toHaveBeenCalledTimes(1)
})
})

View File

@ -196,7 +196,6 @@
:total="localEntries.length" :total="localEntries.length"
:page="currentPage" :page="currentPage"
:page-size="pageSize" :page-size="pageSize"
:page-size-options="[10, 20, 50]"
@update:page="currentPage = $event" @update:page="currentPage = $event"
@update:pageSize="handlePageSizeChange" @update:pageSize="handlePageSizeChange"
/> />

View File

@ -1,7 +1,15 @@
<template> <template>
<div class="card overflow-hidden"> <div class="card overflow-hidden">
<div class="overflow-auto"> <div class="overflow-auto">
<DataTable :columns="columns" :data="data" :loading="loading"> <DataTable
:columns="columns"
:data="data"
:loading="loading"
:server-side-sort="serverSideSort"
:default-sort-key="defaultSortKey"
:default-sort-order="defaultSortOrder"
@sort="(key, order) => $emit('sort', key, order)"
>
<template #cell-user="{ row }"> <template #cell-user="{ row }">
<div class="text-sm"> <div class="text-sm">
<button <button
@ -334,9 +342,27 @@ import DataTable from '@/components/common/DataTable.vue'
import EmptyState from '@/components/common/EmptyState.vue' import EmptyState from '@/components/common/EmptyState.vue'
import Icon from '@/components/icons/Icon.vue' import Icon from '@/components/icons/Icon.vue'
import type { AdminUsageLog } from '@/types' import type { AdminUsageLog } from '@/types'
import type { Column } from '@/components/common/types'
defineProps(['data', 'loading', 'columns']) interface Props {
defineEmits(['userClick']) data: AdminUsageLog[]
loading?: boolean
columns: Column[]
serverSideSort?: boolean
defaultSortKey?: string
defaultSortOrder?: 'asc' | 'desc'
}
withDefaults(defineProps<Props>(), {
loading: false,
serverSideSort: false,
defaultSortKey: '',
defaultSortOrder: 'asc'
})
defineEmits<{
userClick: [userID: number, email?: string]
sort: [key: string, order: 'asc' | 'desc']
}>()
const { t } = useI18n() const { t } = useI18n()
// Tooltip state - cost // Tooltip state - cost

View File

@ -52,7 +52,7 @@ Pagination component with page numbers, navigation, and page size selector.
- `total: number` - Total number of items - `total: number` - Total number of items
- `page: number` - Current page (1-indexed) - `page: number` - Current page (1-indexed)
- `pageSize: number` - Items per page - `pageSize: number` - Items per page
- `pageSizeOptions?: number[]` - Available page size options (default: [10, 20, 50, 100]) - `pageSizeOptions?: number[]` - Available page size options (default: [10, 20, 50])
**Events:** **Events:**

View File

@ -2036,6 +2036,7 @@ export default {
rateLimited: 'Rate Limited', rateLimited: 'Rate Limited',
overloaded: 'Overloaded', overloaded: 'Overloaded',
tempUnschedulable: 'Temp Unschedulable', tempUnschedulable: 'Temp Unschedulable',
unschedulable: 'Unschedulable',
rateLimitedUntil: 'Rate limited and removed from scheduling. Auto resumes at {time}', rateLimitedUntil: 'Rate limited and removed from scheduling. Auto resumes at {time}',
rateLimitedAutoResume: 'Auto resumes in {time}', rateLimitedAutoResume: 'Auto resumes in {time}',
modelRateLimitedUntil: '{model} rate limited until {time}', modelRateLimitedUntil: '{model} rate limited until {time}',
@ -4287,6 +4288,15 @@ export default {
apiBaseUrlPlaceholder: 'https://api.example.com', apiBaseUrlPlaceholder: 'https://api.example.com',
apiBaseUrlHint: apiBaseUrlHint:
'Used for "Use Key" and "Import to CC Switch" features. Leave empty to use current site URL.', 'Used for "Use Key" and "Import to CC Switch" features. Leave empty to use current site URL.',
tablePreferencesTitle: 'Global Table Preferences',
tablePreferencesDescription: 'Configure default pagination behavior for shared table components',
tableDefaultPageSize: 'Default Rows Per Page',
tableDefaultPageSizeHint: 'Must be an integer between 5 and 1000',
tablePageSizeOptions: 'Rows Per Page Options',
tablePageSizeOptionsPlaceholder: '10, 20, 50',
tablePageSizeOptionsHint: 'Use commas to separate integers between 5 and 1000; values are deduplicated and sorted on save',
tableDefaultPageSizeRangeError: 'Default rows per page must be between {min} and {max}',
tablePageSizeOptionsFormatError: 'Invalid options format. Enter comma-separated integers between {min} and {max}',
customEndpoints: { customEndpoints: {
title: 'Custom Endpoints', title: 'Custom Endpoints',
description: 'Add additional API endpoint URLs for users to quickly copy on the API Keys page', description: 'Add additional API endpoint URLs for users to quickly copy on the API Keys page',

View File

@ -2220,6 +2220,7 @@ export default {
rateLimited: '限流中', rateLimited: '限流中',
overloaded: '过载中', overloaded: '过载中',
tempUnschedulable: '临时不可调度', tempUnschedulable: '临时不可调度',
unschedulable: '不可调度',
rateLimitedUntil: '限流中,当前不参与调度,预计 {time} 自动恢复', rateLimitedUntil: '限流中,当前不参与调度,预计 {time} 自动恢复',
rateLimitedAutoResume: '{time} 自动恢复', rateLimitedAutoResume: '{time} 自动恢复',
modelRateLimitedUntil: '{model} 限流至 {time}', modelRateLimitedUntil: '{model} 限流至 {time}',
@ -4449,6 +4450,15 @@ export default {
apiBaseUrl: 'API 端点地址', apiBaseUrl: 'API 端点地址',
apiBaseUrlHint: '用于"使用密钥"和"导入到 CC Switch"功能,留空则使用当前站点地址', apiBaseUrlHint: '用于"使用密钥"和"导入到 CC Switch"功能,留空则使用当前站点地址',
apiBaseUrlPlaceholder: 'https://api.example.com', apiBaseUrlPlaceholder: 'https://api.example.com',
tablePreferencesTitle: '通用表格设置',
tablePreferencesDescription: '设置后台与用户侧表格组件的默认分页行为',
tableDefaultPageSize: '默认每页条数',
tableDefaultPageSizeHint: '必须为 5-1000 之间的整数',
tablePageSizeOptions: '可选每页条数列表',
tablePageSizeOptionsPlaceholder: '10, 20, 50',
tablePageSizeOptionsHint: '使用英文逗号分隔,取值范围 5-1000保存时会自动去重并排序',
tableDefaultPageSizeRangeError: '默认每页条数必须在 {min}-{max} 之间',
tablePageSizeOptionsFormatError: '可选每页条数格式无效,请输入 {min}-{max} 之间的整数并用英文逗号分隔',
customEndpoints: { customEndpoints: {
title: '自定义端点', title: '自定义端点',
description: '添加额外的 API 端点地址用户可在「API Keys」页面快速复制', description: '添加额外的 API 端点地址用户可在「API Keys」页面快速复制',

View File

@ -106,6 +106,8 @@ export interface PublicSettings {
hide_ccs_import_button: boolean hide_ccs_import_button: boolean
purchase_subscription_enabled: boolean purchase_subscription_enabled: boolean
purchase_subscription_url: string purchase_subscription_url: string
table_default_page_size: number
table_page_size_options: number[]
custom_menu_items: CustomMenuItem[] custom_menu_items: CustomMenuItem[]
custom_endpoints: CustomEndpoint[] custom_endpoints: CustomEndpoint[]
linuxdo_oauth_enabled: boolean linuxdo_oauth_enabled: boolean
@ -1350,6 +1352,8 @@ export interface UsageQueryParams {
billing_type?: number | null billing_type?: number | null
start_date?: string start_date?: string
end_date?: string end_date?: string
sort_by?: string
sort_order?: 'asc' | 'desc'
} }
// ==================== Account Usage Statistics ==================== // ==================== Account Usage Statistics ====================

View File

@ -148,6 +148,8 @@
:data="accounts" :data="accounts"
:loading="loading" :loading="loading"
row-key="id" row-key="id"
:server-side-sort="true"
@sort="handleSort"
default-sort-key="name" default-sort-key="name"
default-sort-order="asc" default-sort-order="asc"
:sort-storage-key="ACCOUNT_SORT_STORAGE_KEY" :sort-storage-key="ACCOUNT_SORT_STORAGE_KEY"
@ -401,6 +403,37 @@ const HIDDEN_COLUMNS_KEY = 'account-hidden-columns'
// Sorting settings // Sorting settings
const ACCOUNT_SORT_STORAGE_KEY = 'account-table-sort' const ACCOUNT_SORT_STORAGE_KEY = 'account-table-sort'
type AccountSortOrder = 'asc' | 'desc'
type AccountSortState = {
sort_by: string
sort_order: AccountSortOrder
}
const ACCOUNT_SORTABLE_KEYS = new Set([
'name',
'status',
'schedulable',
'priority',
'rate_multiplier',
'last_used_at',
'expires_at'
])
const loadInitialAccountSortState = (): AccountSortState => {
const fallback: AccountSortState = { sort_by: 'name', sort_order: 'asc' }
try {
const raw = localStorage.getItem(ACCOUNT_SORT_STORAGE_KEY)
if (!raw) return fallback
const parsed = JSON.parse(raw) as { key?: string; order?: string }
const key = typeof parsed.key === 'string' ? parsed.key : ''
if (!ACCOUNT_SORTABLE_KEYS.has(key)) return fallback
return {
sort_by: key,
sort_order: parsed.order === 'desc' ? 'desc' : 'asc'
}
} catch {
return fallback
}
}
const sortState = reactive<AccountSortState>(loadInitialAccountSortState())
// Auto refresh settings // Auto refresh settings
const showAutoRefreshDropdown = ref(false) const showAutoRefreshDropdown = ref(false)
@ -594,7 +627,16 @@ const {
handlePageSizeChange: baseHandlePageSizeChange handlePageSizeChange: baseHandlePageSizeChange
} = useTableLoader<Account, any>({ } = useTableLoader<Account, any>({
fetchFn: adminAPI.accounts.list, fetchFn: adminAPI.accounts.list,
initialParams: { platform: '', type: '', status: '', privacy_mode: '', group: '', search: '' } initialParams: {
platform: '',
type: '',
status: '',
privacy_mode: '',
group: '',
search: '',
sort_by: sortState.sort_by,
sort_order: sortState.sort_order
}
}) })
const { const {
@ -671,6 +713,19 @@ const handlePageSizeChange = (size: number) => {
baseHandlePageSizeChange(size) baseHandlePageSizeChange(size)
} }
const handleSort = (key: string, order: AccountSortOrder) => {
sortState.sort_by = key
sortState.sort_order = order
const requestParams = params as any
requestParams.sort_by = key
requestParams.sort_order = order
pagination.page = 1
hasPendingListSync.value = false
resetAutoRefreshCache()
pendingTodayStatsRefresh.value = true
load()
}
watch(loading, (isLoading, wasLoading) => { watch(loading, (isLoading, wasLoading) => {
if (wasLoading && !isLoading && pendingTodayStatsRefresh.value) { if (wasLoading && !isLoading && pendingTodayStatsRefresh.value) {
pendingTodayStatsRefresh.value = false pendingTodayStatsRefresh.value = false
@ -774,6 +829,8 @@ const refreshAccountsIncrementally = async () => {
privacy_mode?: string privacy_mode?: string
group?: string group?: string
search?: string search?: string
sort_by?: string
sort_order?: AccountSortOrder
}, },
{ etag: autoRefreshETag.value } { etag: autoRefreshETag.value }
@ -1103,19 +1160,58 @@ const handleBulkToggleSchedulable = async (schedulable: boolean) => {
} }
const handleBulkUpdated = () => { showBulkEdit.value = false; clearSelection(); reload() } const handleBulkUpdated = () => { showBulkEdit.value = false; clearSelection(); reload() }
const handleDataImported = () => { showImportData.value = false; reload() } const handleDataImported = () => { showImportData.value = false; reload() }
const ACCOUNT_UNGROUPED_GROUP_QUERY_VALUE = 'ungrouped'
const ACCOUNT_PRIVACY_MODE_UNSET_QUERY_VALUE = '__unset__'
const buildAccountQueryFilters = () => ({
platform: params.platform || '',
type: params.type || '',
status: params.status || '',
group: params.group || '',
privacy_mode: params.privacy_mode || '',
search: params.search || '',
sort_by: sortState.sort_by,
sort_order: sortState.sort_order
})
const accountMatchesCurrentFilters = (account: Account) => { const accountMatchesCurrentFilters = (account: Account) => {
if (params.platform && account.platform !== params.platform) return false const filters = buildAccountQueryFilters()
if (params.type && account.type !== params.type) return false if (filters.platform && account.platform !== filters.platform) return false
if (params.status) { if (filters.type && account.type !== filters.type) return false
if (params.status === 'rate_limited') { if (filters.status) {
if (!account.rate_limit_reset_at) return false const now = Date.now()
const resetAt = new Date(account.rate_limit_reset_at).getTime() const rateLimitResetAt = account.rate_limit_reset_at ? new Date(account.rate_limit_reset_at).getTime() : Number.NaN
if (!Number.isFinite(resetAt) || resetAt <= Date.now()) return false const isRateLimited = Number.isFinite(rateLimitResetAt) && rateLimitResetAt > now
} else if (account.status !== params.status) { const tempUnschedUntil = account.temp_unschedulable_until ? new Date(account.temp_unschedulable_until).getTime() : Number.NaN
const isTempUnschedulable = Number.isFinite(tempUnschedUntil) && tempUnschedUntil > now
if (filters.status === 'active') {
if (account.status !== 'active' || isRateLimited || isTempUnschedulable || !account.schedulable) return false
} else if (filters.status === 'rate_limited') {
if (account.status !== 'active' || !isRateLimited || isTempUnschedulable) return false
} else if (filters.status === 'temp_unschedulable') {
if (account.status !== 'active' || !isTempUnschedulable) return false
} else if (filters.status === 'unschedulable') {
if (account.status !== 'active' || account.schedulable || isRateLimited || isTempUnschedulable) return false
} else if (account.status !== filters.status) {
return false return false
} }
} }
const search = String(params.search || '').trim().toLowerCase() if (filters.group) {
const groupIds = account.group_ids ?? account.groups?.map((group) => group.id) ?? []
if (filters.group === ACCOUNT_UNGROUPED_GROUP_QUERY_VALUE) {
if (groupIds.length > 0) return false
} else if (!groupIds.includes(Number(filters.group))) {
return false
}
}
const privacyMode = typeof account.extra?.privacy_mode === 'string' ? account.extra.privacy_mode : ''
if (filters.privacy_mode) {
if (filters.privacy_mode === ACCOUNT_PRIVACY_MODE_UNSET_QUERY_VALUE) {
if (privacyMode.trim() !== '') return false
} else if (privacyMode !== filters.privacy_mode) {
return false
}
}
const search = String(filters.search || '').trim().toLowerCase()
if (search && !account.name.toLowerCase().includes(search)) return false if (search && !account.name.toLowerCase().includes(search)) return false
return true return true
} }
@ -1181,12 +1277,7 @@ const handleExportData = async () => {
? { ids: selIds.value, includeProxies: includeProxyOnExport.value } ? { ids: selIds.value, includeProxies: includeProxyOnExport.value }
: { : {
includeProxies: includeProxyOnExport.value, includeProxies: includeProxyOnExport.value,
filters: { filters: buildAccountQueryFilters()
platform: params.platform,
type: params.type,
status: params.status,
search: params.search
}
} }
) )
const timestamp = formatExportTimestamp() const timestamp = formatExportTimestamp()

View File

@ -39,7 +39,15 @@
</template> </template>
<template #table> <template #table>
<DataTable :columns="columns" :data="announcements" :loading="loading"> <DataTable
:columns="columns"
:data="announcements"
:loading="loading"
:server-side-sort="true"
default-sort-key="created_at"
default-sort-order="desc"
@sort="handleSort"
>
<template #cell-title="{ value, row }"> <template #cell-title="{ value, row }">
<div class="min-w-0"> <div class="min-w-0">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@ -68,7 +76,7 @@
</span> </span>
</template> </template>
<template #cell-notifyMode="{ row }"> <template #cell-notify_mode="{ row }">
<span <span
:class="[ :class="[
'badge', 'badge',
@ -100,7 +108,7 @@
</div> </div>
</template> </template>
<template #cell-createdAt="{ value }"> <template #cell-created_at="{ value }">
<span class="text-sm text-gray-500 dark:text-dark-400">{{ formatDateTime(value) }}</span> <span class="text-sm text-gray-500 dark:text-dark-400">{{ formatDateTime(value) }}</span>
</template> </template>
@ -236,7 +244,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
import { getPersistedPageSize } from '@/composables/usePersistedPageSize' import { getPersistedPageSize } from '@/composables/usePersistedPageSize'
@ -276,6 +284,11 @@ const pagination = reactive({
pages: 0 pages: 0
}) })
const sortState = reactive({
sort_by: 'created_at',
sort_order: 'desc' as 'asc' | 'desc'
})
const statusFilterOptions = computed(() => [ const statusFilterOptions = computed(() => [
{ value: '', label: t('admin.announcements.allStatus') }, { value: '', label: t('admin.announcements.allStatus') },
{ value: 'draft', label: t('admin.announcements.statusLabels.draft') }, { value: 'draft', label: t('admin.announcements.statusLabels.draft') },
@ -295,12 +308,12 @@ const notifyModeOptions = computed(() => [
]) ])
const columns = computed<Column[]>(() => [ const columns = computed<Column[]>(() => [
{ key: 'title', label: t('admin.announcements.columns.title') }, { key: 'title', label: t('admin.announcements.columns.title'), sortable: true },
{ key: 'status', label: t('admin.announcements.columns.status') }, { key: 'status', label: t('admin.announcements.columns.status'), sortable: true },
{ key: 'notifyMode', label: t('admin.announcements.columns.notifyMode') }, { key: 'notify_mode', label: t('admin.announcements.columns.notifyMode'), sortable: true },
{ key: 'targeting', label: t('admin.announcements.columns.targeting') }, { key: 'targeting', label: t('admin.announcements.columns.targeting') },
{ key: 'timeRange', label: t('admin.announcements.columns.timeRange') }, { key: 'timeRange', label: t('admin.announcements.columns.timeRange') },
{ key: 'createdAt', label: t('admin.announcements.columns.createdAt') }, { key: 'created_at', label: t('admin.announcements.columns.createdAt'), sortable: true },
{ key: 'actions', label: t('admin.announcements.columns.actions') } { key: 'actions', label: t('admin.announcements.columns.actions') }
]) ])
@ -321,15 +334,21 @@ const targetingSummary = (targeting: AnnouncementTargeting) => {
let currentController: AbortController | null = null let currentController: AbortController | null = null
async function loadAnnouncements() { async function loadAnnouncements() {
if (currentController) currentController.abort() currentController?.abort()
currentController = new AbortController() const requestController = new AbortController()
currentController = requestController
const { signal } = requestController
try { try {
loading.value = true loading.value = true
const res = await adminAPI.announcements.list(pagination.page, pagination.page_size, { const res = await adminAPI.announcements.list(pagination.page, pagination.page_size, {
status: filters.status || undefined, status: filters.status || undefined,
search: searchQuery.value || undefined search: searchQuery.value || undefined,
}) sort_by: sortState.sort_by,
sort_order: sortState.sort_order
}, { signal })
if (signal.aborted || currentController !== requestController) return
announcements.value = res.items announcements.value = res.items
pagination.total = res.total pagination.total = res.total
@ -337,11 +356,21 @@ async function loadAnnouncements() {
pagination.page = res.page pagination.page = res.page
pagination.page_size = res.page_size pagination.page_size = res.page_size
} catch (error: any) { } catch (error: any) {
if (currentController.signal.aborted || error?.name === 'AbortError') return if (
signal.aborted ||
currentController !== requestController ||
error?.name === 'AbortError' ||
error?.code === 'ERR_CANCELED'
) {
return
}
console.error('Error loading announcements:', error) console.error('Error loading announcements:', error)
appStore.showError(error.response?.data?.detail || t('admin.announcements.failedToLoad')) appStore.showError(error.response?.data?.detail || t('admin.announcements.failedToLoad'))
} finally { } finally {
loading.value = false if (currentController === requestController) {
loading.value = false
currentController = null
}
} }
} }
@ -361,6 +390,13 @@ function handleStatusChange() {
loadAnnouncements() loadAnnouncements()
} }
function handleSort(key: string, order: 'asc' | 'desc') {
sortState.sort_by = key
sortState.sort_order = order
pagination.page = 1
loadAnnouncements()
}
let searchDebounceTimer: number | null = null let searchDebounceTimer: number | null = null
function handleSearch() { function handleSearch() {
if (searchDebounceTimer) window.clearTimeout(searchDebounceTimer) if (searchDebounceTimer) window.clearTimeout(searchDebounceTimer)
@ -562,4 +598,9 @@ onMounted(async () => {
await loadSubscriptionGroups() await loadSubscriptionGroups()
await loadAnnouncements() await loadAnnouncements()
}) })
onUnmounted(() => {
if (searchDebounceTimer) window.clearTimeout(searchDebounceTimer)
currentController?.abort()
})
</script> </script>

View File

@ -48,7 +48,15 @@
</template> </template>
<template #table> <template #table>
<DataTable :columns="columns" :data="channels" :loading="loading"> <DataTable
:columns="columns"
:data="channels"
:loading="loading"
:server-side-sort="true"
default-sort-key="created_at"
default-sort-order="desc"
@sort="handleSort"
>
<template #cell-name="{ value }"> <template #cell-name="{ value }">
<span class="font-medium text-gray-900 dark:text-white">{{ value }}</span> <span class="font-medium text-gray-900 dark:text-white">{{ value }}</span>
</template> </template>
@ -486,6 +494,10 @@ const pagination = reactive({
page_size: getPersistedPageSize(), page_size: getPersistedPageSize(),
total: 0 total: 0
}) })
const sortState = reactive({
sort_by: 'created_at',
sort_order: 'desc' as 'asc' | 'desc'
})
// Dialog state // Dialog state
const showDialog = ref(false) const showDialog = ref(false)
@ -766,7 +778,9 @@ async function loadChannels() {
try { try {
const response = await adminAPI.channels.list(pagination.page, pagination.page_size, { const response = await adminAPI.channels.list(pagination.page, pagination.page_size, {
status: filters.status || undefined, status: filters.status || undefined,
search: searchQuery.value || undefined search: searchQuery.value || undefined,
sort_by: sortState.sort_by,
sort_order: sortState.sort_order
}, { signal: ctrl.signal }) }, { signal: ctrl.signal })
if (ctrl.signal.aborted || abortController !== ctrl) return if (ctrl.signal.aborted || abortController !== ctrl) return
@ -825,6 +839,13 @@ function handlePageSizeChange(pageSize: number) {
loadChannels() loadChannels()
} }
function handleSort(key: string, order: 'asc' | 'desc') {
sortState.sort_by = key
sortState.sort_order = order
pagination.page = 1
loadChannels()
}
// Dialog // Dialog
function resetForm() { function resetForm() {
form.name = '' form.name = ''

View File

@ -73,7 +73,15 @@
</template> </template>
<template #table> <template #table>
<DataTable :columns="columns" :data="groups" :loading="loading"> <DataTable
:columns="columns"
:data="groups"
:loading="loading"
:server-side-sort="true"
default-sort-key="sort_order"
default-sort-order="asc"
@sort="handleSort"
>
<template #cell-name="{ value }"> <template #cell-name="{ value }">
<span class="font-medium text-gray-900 dark:text-white">{{ value }}</span> <span class="font-medium text-gray-900 dark:text-white">{{ value }}</span>
</template> </template>
@ -1983,6 +1991,10 @@ const pagination = reactive({
total: 0, total: 0,
pages: 0 pages: 0
}) })
const sortState = reactive({
sort_by: 'sort_order',
sort_order: 'asc' as 'asc' | 'desc'
})
let abortController: AbortController | null = null let abortController: AbortController | null = null
@ -2297,7 +2309,9 @@ const loadGroups = async () => {
platform: (filters.platform as GroupPlatform) || undefined, platform: (filters.platform as GroupPlatform) || undefined,
status: filters.status as any, status: filters.status as any,
is_exclusive: filters.is_exclusive ? filters.is_exclusive === 'true' : undefined, is_exclusive: filters.is_exclusive ? filters.is_exclusive === 'true' : undefined,
search: searchQuery.value.trim() || undefined search: searchQuery.value.trim() || undefined,
sort_by: sortState.sort_by,
sort_order: sortState.sort_order
}, { signal }) }, { signal })
if (signal.aborted) return if (signal.aborted) return
groups.value = response.items groups.value = response.items
@ -2381,6 +2395,13 @@ const handlePageSizeChange = (pageSize: number) => {
loadGroups() loadGroups()
} }
const handleSort = (key: string, order: 'asc' | 'desc') => {
sortState.sort_by = key
sortState.sort_order = order
pagination.page = 1
loadGroups()
}
const closeCreateModal = () => { const closeCreateModal = () => {
showCreateModal.value = false showCreateModal.value = false
createModelRoutingRules.value.forEach((rule) => { createModelRoutingRules.value.forEach((rule) => {

View File

@ -39,7 +39,15 @@
</template> </template>
<template #table> <template #table>
<DataTable :columns="columns" :data="codes" :loading="loading"> <DataTable
:columns="columns"
:data="codes"
:loading="loading"
:server-side-sort="true"
default-sort-key="created_at"
default-sort-order="desc"
@sort="handleSort"
>
<template #cell-code="{ value }"> <template #cell-code="{ value }">
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<code class="font-mono text-sm text-gray-900 dark:text-gray-100">{{ value }}</code> <code class="font-mono text-sm text-gray-900 dark:text-gray-100">{{ value }}</code>
@ -349,7 +357,6 @@
:page="usagesPage" :page="usagesPage"
:total="usagesTotal" :total="usagesTotal"
:page-size="usagesPageSize" :page-size="usagesPageSize"
:page-size-options="[10, 20, 50]"
@update:page="handleUsagesPageChange" @update:page="handleUsagesPageChange"
@update:page-size="(size: number) => { usagesPageSize = size; usagesPage = 1; loadUsages() }" @update:page-size="(size: number) => { usagesPageSize = size; usagesPage = 1; loadUsages() }"
/> />
@ -418,6 +425,10 @@ const pagination = reactive({
page_size: getPersistedPageSize(), page_size: getPersistedPageSize(),
total: 0 total: 0
}) })
const sortState = reactive({
sort_by: 'created_at',
sort_order: 'desc' as 'asc' | 'desc'
})
// Dialogs // Dialogs
const showCreateDialog = ref(false) const showCreateDialog = ref(false)
@ -514,19 +525,29 @@ const loadCodes = async () => {
pagination.page_size, pagination.page_size,
{ {
status: filters.status || undefined, status: filters.status || undefined,
search: searchQuery.value || undefined search: searchQuery.value || undefined,
} sort_by: sortState.sort_by,
sort_order: sortState.sort_order
},
{ signal: currentController.signal }
) )
if (currentController.signal.aborted) return if (currentController.signal.aborted || abortController !== currentController) return
codes.value = response.items codes.value = response.items
pagination.total = response.total pagination.total = response.total
} catch (error: any) { } catch (error: any) {
if (currentController.signal.aborted || error?.name === 'AbortError') return if (
currentController.signal.aborted ||
abortController !== currentController ||
error?.name === 'AbortError' ||
error?.code === 'ERR_CANCELED'
) {
return
}
appStore.showError(t('admin.promo.failedToLoad')) appStore.showError(t('admin.promo.failedToLoad'))
console.error('Error loading promo codes:', error) console.error('Error loading promo codes:', error)
} finally { } finally {
if (abortController === currentController && !currentController.signal.aborted) { if (abortController === currentController) {
loading.value = false loading.value = false
abortController = null abortController = null
} }
@ -553,6 +574,13 @@ const handlePageSizeChange = (pageSize: number) => {
loadCodes() loadCodes()
} }
const handleSort = (key: string, order: 'asc' | 'desc') => {
sortState.sort_by = key
sortState.sort_order = order
pagination.page = 1
loadCodes()
}
const copyToClipboard = async (text: string) => { const copyToClipboard = async (text: string) => {
const success = await clipboardCopy(text, t('admin.promo.copied')) const success = await clipboardCopy(text, t('admin.promo.copied'))
if (success) { if (success) {

View File

@ -89,7 +89,15 @@
<template #table> <template #table>
<div ref="proxyTableRef" class="flex min-h-0 flex-1 flex-col overflow-hidden"> <div ref="proxyTableRef" class="flex min-h-0 flex-1 flex-col overflow-hidden">
<DataTable :columns="columns" :data="proxies" :loading="loading"> <DataTable
:columns="columns"
:data="proxies"
:loading="loading"
:server-side-sort="true"
default-sort-key="id"
default-sort-order="desc"
@sort="handleSort"
>
<template #header-select> <template #header-select>
<input <input
type="checkbox" type="checkbox"
@ -946,6 +954,10 @@ const pagination = reactive({
total: 0, total: 0,
pages: 0 pages: 0
}) })
const sortState = reactive({
sort_by: 'id',
sort_order: 'desc' as 'asc' | 'desc'
})
const showCreateModal = ref(false) const showCreateModal = ref(false)
const createPasswordVisible = ref(false) const createPasswordVisible = ref(false)
@ -1050,6 +1062,14 @@ const toggleSelectAllVisible = (event: Event) => {
toggleVisible(target.checked) toggleVisible(target.checked)
} }
const buildProxyQueryFilters = () => ({
protocol: filters.protocol || undefined,
status: (filters.status || undefined) as 'active' | 'inactive' | undefined,
search: searchQuery.value || undefined,
sort_by: sortState.sort_by,
sort_order: sortState.sort_order
})
const loadProxies = async () => { const loadProxies = async () => {
if (abortController) { if (abortController) {
abortController.abort() abortController.abort()
@ -1058,11 +1078,12 @@ const loadProxies = async () => {
abortController = currentAbortController abortController = currentAbortController
loading.value = true loading.value = true
try { try {
const response = await adminAPI.proxies.list(pagination.page, pagination.page_size, { const response = await adminAPI.proxies.list(
protocol: filters.protocol || undefined, pagination.page,
status: filters.status as any, pagination.page_size,
search: searchQuery.value || undefined buildProxyQueryFilters(),
}, { signal: currentAbortController.signal }) { signal: currentAbortController.signal }
)
if (currentAbortController.signal.aborted || abortController !== currentAbortController) { if (currentAbortController.signal.aborted || abortController !== currentAbortController) {
return return
} }
@ -1103,6 +1124,13 @@ const handlePageSizeChange = (pageSize: number) => {
loadProxies() loadProxies()
} }
const handleSort = (key: string, order: 'asc' | 'desc') => {
sortState.sort_by = key
sortState.sort_order = order
pagination.page = 1
loadProxies()
}
const closeCreateModal = () => { const closeCreateModal = () => {
showCreateModal.value = false showCreateModal.value = false
createMode.value = 'standard' createMode.value = 'standard'
@ -1581,7 +1609,9 @@ const fetchAllProxiesForBatch = async (): Promise<Proxy[]> => {
{ {
protocol: filters.protocol || undefined, protocol: filters.protocol || undefined,
status: filters.status as any, status: filters.status as any,
search: searchQuery.value || undefined search: searchQuery.value || undefined,
sort_by: sortState.sort_by,
sort_order: sortState.sort_order
} }
) )
result.push(...response.items) result.push(...response.items)
@ -1689,11 +1719,7 @@ const handleExportData = async () => {
selectedCount.value > 0 selectedCount.value > 0
? { ids: Array.from(selectedProxyIds.value) } ? { ids: Array.from(selectedProxyIds.value) }
: { : {
filters: { filters: buildProxyQueryFilters()
protocol: filters.protocol || undefined,
status: (filters.status || undefined) as 'active' | 'inactive' | undefined,
search: searchQuery.value || undefined
}
} }
) )
const timestamp = formatExportTimestamp() const timestamp = formatExportTimestamp()

View File

@ -47,7 +47,15 @@
</template> </template>
<template #table> <template #table>
<DataTable :columns="columns" :data="codes" :loading="loading"> <DataTable
:columns="columns"
:data="codes"
:loading="loading"
:server-side-sort="true"
default-sort-key="id"
default-sort-order="desc"
@sort="handleSort"
>
<template #cell-code="{ value }"> <template #cell-code="{ value }">
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<code class="font-mono text-sm text-gray-900 dark:text-gray-100">{{ value }}</code> <code class="font-mono text-sm text-gray-900 dark:text-gray-100">{{ value }}</code>
@ -537,6 +545,10 @@ const pagination = reactive({
total: 0, total: 0,
pages: 0 pages: 0
}) })
const sortState = reactive({
sort_by: 'id',
sort_order: 'desc' as 'asc' | 'desc'
})
let abortController: AbortController | null = null let abortController: AbortController | null = null
@ -565,6 +577,14 @@ watch(
} }
) )
const buildRedeemQueryFilters = () => ({
type: (filters.type || undefined) as RedeemCodeType | undefined,
status: (filters.status || undefined) as 'used' | 'expired' | 'unused' | undefined,
search: searchQuery.value || undefined,
sort_by: sortState.sort_by,
sort_order: sortState.sort_order
})
const loadCodes = async () => { const loadCodes = async () => {
if (abortController) { if (abortController) {
abortController.abort() abortController.abort()
@ -576,11 +596,7 @@ const loadCodes = async () => {
const response = await adminAPI.redeem.list( const response = await adminAPI.redeem.list(
pagination.page, pagination.page,
pagination.page_size, pagination.page_size,
{ buildRedeemQueryFilters(),
type: filters.type as RedeemCodeType,
status: filters.status as any,
search: searchQuery.value || undefined
},
{ {
signal: currentController.signal signal: currentController.signal
} }
@ -629,6 +645,13 @@ const handlePageSizeChange = (pageSize: number) => {
loadCodes() loadCodes()
} }
const handleSort = (key: string, order: 'asc' | 'desc') => {
sortState.sort_by = key
sortState.sort_order = order
pagination.page = 1
loadCodes()
}
const handleGenerateCodes = async () => { const handleGenerateCodes = async () => {
// //
if (generateForm.type === 'subscription' && !generateForm.group_id) { if (generateForm.type === 'subscription' && !generateForm.group_id) {
@ -672,10 +695,7 @@ const copyToClipboard = async (text: string) => {
const handleExportCodes = async () => { const handleExportCodes = async () => {
try { try {
const blob = await adminAPI.redeem.exportCodes({ const blob = await adminAPI.redeem.exportCodes(buildRedeemQueryFilters())
type: filters.type as RedeemCodeType,
status: filters.status as any
})
// Create download link // Create download link
const url = window.URL.createObjectURL(blob) const url = window.URL.createObjectURL(blob)

View File

@ -174,6 +174,8 @@
:data="subscriptions" :data="subscriptions"
:loading="loading" :loading="loading"
:server-side-sort="true" :server-side-sort="true"
default-sort-key="created_at"
default-sort-order="desc"
@sort="handleSort" @sort="handleSort"
> >
<template #cell-user="{ row }"> <template #cell-user="{ row }">

View File

@ -100,7 +100,16 @@
</div> </div>
</template> </template>
</UsageFilters> </UsageFilters>
<UsageTable :data="usageLogs" :loading="loading" :columns="visibleColumns" @userClick="handleUserClick" /> <UsageTable
:data="usageLogs"
:loading="loading"
:columns="visibleColumns"
:server-side-sort="true"
:default-sort-key="'created_at'"
:default-sort-order="'desc'"
@sort="handleSort"
@userClick="handleUserClick"
/>
<Pagination v-if="pagination.total > 0" :page="pagination.page" :total="pagination.total" :page-size="pagination.page_size" @update:page="handlePageChange" @update:pageSize="handlePageSizeChange" /> <Pagination v-if="pagination.total > 0" :page="pagination.page" :total="pagination.total" :page-size="pagination.page_size" @update:page="handlePageChange" @update:pageSize="handlePageSizeChange" />
</div> </div>
</AppLayout> </AppLayout>
@ -219,6 +228,10 @@ const defaultRange = getLast24HoursRangeDates()
const startDate = ref(defaultRange.start); const endDate = ref(defaultRange.end) const startDate = ref(defaultRange.start); const endDate = ref(defaultRange.end)
const filters = ref<AdminUsageQueryParams>({ user_id: undefined, model: undefined, group_id: undefined, request_type: undefined, billing_type: null, start_date: startDate.value, end_date: endDate.value }) const filters = ref<AdminUsageQueryParams>({ user_id: undefined, model: undefined, group_id: undefined, request_type: undefined, billing_type: null, start_date: startDate.value, end_date: endDate.value })
const pagination = reactive({ page: 1, page_size: getPersistedPageSize(), total: 0 }) const pagination = reactive({ page: 1, page_size: getPersistedPageSize(), total: 0 })
const sortState = reactive({
sort_by: 'created_at',
sort_order: 'desc' as 'asc' | 'desc'
})
const getSingleQueryValue = (value: string | null | Array<string | null> | undefined): string | undefined => { const getSingleQueryValue = (value: string | null | Array<string | null> | undefined): string | undefined => {
if (Array.isArray(value)) return value.find((item): item is string => typeof item === 'string' && item.length > 0) if (Array.isArray(value)) return value.find((item): item is string => typeof item === 'string' && item.length > 0)
@ -265,12 +278,31 @@ const onDateRangeChange = (range: { startDate: string; endDate: string; preset:
applyFilters() applyFilters()
} }
const buildUsageListParams = (
page: number,
pageSize: number,
exactTotal: boolean
): AdminUsageQueryParams => {
const requestType = filters.value.request_type
const legacyStream = requestType ? requestTypeToLegacyStream(requestType) : filters.value.stream
return {
page,
page_size: pageSize,
exact_total: exactTotal,
...filters.value,
stream: legacyStream === null ? undefined : legacyStream,
sort_by: sortState.sort_by,
sort_order: sortState.sort_order
}
}
const loadLogs = async () => { const loadLogs = async () => {
abortController?.abort(); const c = new AbortController(); abortController = c; loading.value = true abortController?.abort(); const c = new AbortController(); abortController = c; loading.value = true
try { try {
const requestType = filters.value.request_type const res = await adminAPI.usage.list(
const legacyStream = requestType ? requestTypeToLegacyStream(requestType) : filters.value.stream buildUsageListParams(pagination.page, pagination.page_size, false),
const res = await adminAPI.usage.list({ page: pagination.page, page_size: pagination.page_size, exact_total: false, ...filters.value, stream: legacyStream === null ? undefined : legacyStream }, { signal: c.signal }) { signal: c.signal }
)
if(!c.signal.aborted) { usageLogs.value = res.items; pagination.total = res.total } if(!c.signal.aborted) { usageLogs.value = res.items; pagination.total = res.total }
} catch (error: any) { if(error?.name !== 'AbortError') console.error('Failed to load usage logs:', error) } finally { if(abortController === c) loading.value = false } } catch (error: any) { if(error?.name !== 'AbortError') console.error('Failed to load usage logs:', error) } finally { if(abortController === c) loading.value = false }
} }
@ -412,6 +444,12 @@ const resetFilters = () => {
} }
const handlePageChange = (p: number) => { pagination.page = p; loadLogs() } const handlePageChange = (p: number) => { pagination.page = p; loadLogs() }
const handlePageSizeChange = (s: number) => { pagination.page_size = s; pagination.page = 1; loadLogs() } const handlePageSizeChange = (s: number) => { pagination.page_size = s; pagination.page = 1; loadLogs() }
const handleSort = (key: string, order: 'asc' | 'desc') => {
sortState.sort_by = key
sortState.sort_order = order
pagination.page = 1
loadLogs()
}
const cancelExport = () => exportAbortController?.abort() const cancelExport = () => exportAbortController?.abort()
const openCleanupDialog = () => { cleanupDialogVisible.value = true } const openCleanupDialog = () => { cleanupDialogVisible.value = true }
const getRequestTypeLabel = (log: AdminUsageLog): string => { const getRequestTypeLabel = (log: AdminUsageLog): string => {
@ -443,9 +481,10 @@ const exportToExcel = async () => {
] ]
const ws = XLSX.utils.aoa_to_sheet([headers]) const ws = XLSX.utils.aoa_to_sheet([headers])
while (true) { while (true) {
const requestType = filters.value.request_type const res = await adminUsageAPI.list(
const legacyStream = requestType ? requestTypeToLegacyStream(requestType) : filters.value.stream buildUsageListParams(p, 100, true),
const res = await adminUsageAPI.list({ page: p, page_size: 100, exact_total: true, ...filters.value, stream: legacyStream === null ? undefined : legacyStream }, { signal: c.signal }) { signal: c.signal }
)
if (c.signal.aborted) break; if (p === 1) { total = res.total; exportProgress.total = total } if (c.signal.aborted) break; if (p === 1) { total = res.total; exportProgress.total = total }
const rows = (res.items || []).map((log: AdminUsageLog) => [ const rows = (res.items || []).map((log: AdminUsageLog) => [
log.created_at, log.user?.email || '', log.api_key?.name || '', log.account?.name || '', log.model, log.created_at, log.user?.email || '', log.api_key?.name || '', log.account?.name || '', log.model,

View File

@ -235,7 +235,17 @@
<!-- Users Table --> <!-- Users Table -->
<template #table> <template #table>
<DataTable :columns="columns" :data="users" :loading="loading" :actions-count="7"> <DataTable
:columns="columns"
:data="users"
:loading="loading"
:actions-count="7"
:server-side-sort="true"
default-sort-key="created_at"
default-sort-order="desc"
:sort-storage-key="USER_SORT_STORAGE_KEY"
@sort="handleSort"
>
<template #cell-email="{ value }"> <template #cell-email="{ value }">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<div <div
@ -774,6 +784,25 @@ const columns = computed<Column[]>(() =>
const users = ref<AdminUser[]>([]) const users = ref<AdminUser[]>([])
const loading = ref(false) const loading = ref(false)
const searchQuery = ref('') const searchQuery = ref('')
const USER_SORT_STORAGE_KEY = 'admin-users-table-sort'
const loadInitialSortState = (): { sort_by: string; sort_order: 'asc' | 'desc' } => {
const fallback = { sort_by: 'created_at', sort_order: 'desc' as 'asc' | 'desc' }
const sortable = new Set(['email', 'id', 'username', 'role', 'balance', 'concurrency', 'status', 'created_at'])
try {
const raw = localStorage.getItem(USER_SORT_STORAGE_KEY)
if (!raw) return fallback
const parsed = JSON.parse(raw) as { key?: string; order?: string }
const key = typeof parsed.key === 'string' ? parsed.key : ''
if (!sortable.has(key)) return fallback
return {
sort_by: key,
sort_order: parsed.order === 'asc' ? 'asc' : 'desc'
}
} catch {
return fallback
}
}
const sortState = reactive(loadInitialSortState())
// Groups data for the groups column // Groups data for the groups column
const allGroups = ref<AdminGroup[]>([]) const allGroups = ref<AdminGroup[]>([])
@ -1125,7 +1154,9 @@ const loadUsers = async () => {
search: searchQuery.value || undefined, search: searchQuery.value || undefined,
group_name: filters.group || undefined, group_name: filters.group || undefined,
attributes: Object.keys(attrFilters).length > 0 ? attrFilters : undefined, attributes: Object.keys(attrFilters).length > 0 ? attrFilters : undefined,
include_subscriptions: hasVisibleSubscriptionsColumn.value include_subscriptions: hasVisibleSubscriptionsColumn.value,
sort_by: sortState.sort_by,
sort_order: sortState.sort_order
}, },
{ signal } { signal }
) )
@ -1184,6 +1215,13 @@ const handlePageSizeChange = (pageSize: number) => {
loadUsers() loadUsers()
} }
const handleSort = (key: string, order: 'asc' | 'desc') => {
sortState.sort_by = key
sortState.sort_order = order
pagination.page = 1
loadUsers()
}
// Filter helpers // Filter helpers
const getAttributeDefinitionName = (attrId: number): string => { const getAttributeDefinitionName = (attrId: number): string => {
const def = attributeDefinitions.value.find(d => d.id === attrId) const def = attributeDefinitions.value.find(d => d.id === attrId)

View File

@ -202,7 +202,6 @@
:total="total" :total="total"
:page="page" :page="page"
:page-size="pageSize" :page-size="pageSize"
:page-size-options="[10]"
@update:page="emit('update:page', $event)" @update:page="emit('update:page', $event)"
@update:pageSize="emit('update:pageSize', $event)" @update:pageSize="emit('update:pageSize', $event)"
/> />

View File

@ -512,7 +512,6 @@ onMounted(async () => {
:total="total" :total="total"
:page="page" :page="page"
:page-size="pageSize" :page-size="pageSize"
:page-size-options="[10, 20, 50, 100, 200]"
@update:page="onPageChange" @update:page="onPageChange"
@update:page-size="onPageSizeChange" @update:page-size="onPageSizeChange"
/> />

View File

@ -49,7 +49,15 @@
</template> </template>
<template #table> <template #table>
<DataTable :columns="columns" :data="apiKeys" :loading="loading"> <DataTable
:columns="columns"
:data="apiKeys"
:loading="loading"
:server-side-sort="true"
default-sort-key="created_at"
default-sort-order="desc"
@sort="handleSort"
>
<template #cell-key="{ value, row }"> <template #cell-key="{ value, row }">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<code class="code text-xs"> <code class="code text-xs">
@ -1114,6 +1122,10 @@ const pagination = ref({
total: 0, total: 0,
pages: 0 pages: 0
}) })
const sortState = ref({
sort_by: 'created_at',
sort_order: 'desc' as 'asc' | 'desc'
})
// Filter state // Filter state
const filterSearch = ref('') const filterSearch = ref('')
@ -1277,10 +1289,18 @@ const loadApiKeys = async () => {
loading.value = true loading.value = true
try { try {
// Build filters // Build filters
const filters: { search?: string; status?: string; group_id?: number | string } = {} const filters: {
search?: string
status?: string
group_id?: number | string
sort_by?: string
sort_order?: 'asc' | 'desc'
} = {}
if (filterSearch.value) filters.search = filterSearch.value if (filterSearch.value) filters.search = filterSearch.value
if (filterStatus.value) filters.status = filterStatus.value if (filterStatus.value) filters.status = filterStatus.value
if (filterGroupId.value !== '') filters.group_id = filterGroupId.value if (filterGroupId.value !== '') filters.group_id = filterGroupId.value
filters.sort_by = sortState.value.sort_by
filters.sort_order = sortState.value.sort_order
const response = await keysAPI.list(pagination.value.page, pagination.value.page_size, filters, { const response = await keysAPI.list(pagination.value.page, pagination.value.page_size, filters, {
signal signal
@ -1360,6 +1380,13 @@ const handlePageSizeChange = (pageSize: number) => {
loadApiKeys() loadApiKeys()
} }
const handleSort = (key: string, order: 'asc' | 'desc') => {
sortState.value.sort_by = key
sortState.value.sort_order = order
pagination.value.page = 1
loadApiKeys()
}
const editKey = (key: ApiKey) => { const editKey = (key: ApiKey) => {
selectedKey.value = key selectedKey.value = key
const hasIPRestriction = (key.ip_whitelist?.length > 0) || (key.ip_blacklist?.length > 0) const hasIPRestriction = (key.ip_whitelist?.length > 0) || (key.ip_blacklist?.length > 0)

View File

@ -149,7 +149,15 @@
</template> </template>
<template #table> <template #table>
<DataTable :columns="columns" :data="usageLogs" :loading="loading"> <DataTable
:columns="columns"
:data="usageLogs"
:loading="loading"
:server-side-sort="true"
default-sort-key="created_at"
default-sort-order="desc"
@sort="handleSort"
>
<template #cell-api_key="{ row }"> <template #cell-api_key="{ row }">
<span class="text-sm text-gray-900 dark:text-white">{{ <span class="text-sm text-gray-900 dark:text-white">{{
row.api_key?.name || '-' row.api_key?.name || '-'
@ -598,6 +606,10 @@ const pagination = reactive({
total: 0, total: 0,
pages: 0 pages: 0
}) })
const sortState = reactive({
sort_by: 'created_at',
sort_order: 'desc' as 'asc' | 'desc'
})
const formatDuration = (ms: number): string => { const formatDuration = (ms: number): string => {
if (ms < 1000) return `${ms.toFixed(0)}ms` if (ms < 1000) return `${ms.toFixed(0)}ms`
@ -660,6 +672,18 @@ const formatTokens = (value: number): string => {
return value.toLocaleString() return value.toLocaleString()
} }
type UsageTableQueryParams = UsageQueryParams & {
sort_by?: string
sort_order?: 'asc' | 'desc'
}
const buildUsageQueryParams = (page: number, pageSize: number): UsageTableQueryParams => ({
page,
page_size: pageSize,
...filters.value,
sort_by: sortState.sort_by,
sort_order: sortState.sort_order
})
const loadUsageLogs = async () => { const loadUsageLogs = async () => {
if (abortController) { if (abortController) {
@ -670,13 +694,10 @@ const loadUsageLogs = async () => {
const { signal } = currentAbortController const { signal } = currentAbortController
loading.value = true loading.value = true
try { try {
const params: UsageQueryParams = { const response = await usageAPI.query(
page: pagination.page, buildUsageQueryParams(pagination.page, pagination.page_size),
page_size: pagination.page_size, { signal }
...filters.value )
}
const response = await usageAPI.query(params, { signal })
if (signal.aborted) { if (signal.aborted) {
return return
} }
@ -758,6 +779,13 @@ const handlePageSizeChange = (pageSize: number) => {
loadUsageLogs() loadUsageLogs()
} }
const handleSort = (key: string, order: 'asc' | 'desc') => {
sortState.sort_by = key
sortState.sort_order = order
pagination.page = 1
loadUsageLogs()
}
/** /**
* Escape CSV value to prevent injection and handle special characters * Escape CSV value to prevent injection and handle special characters
*/ */
@ -795,12 +823,7 @@ const exportToCSV = async () => {
const totalRequests = Math.ceil(pagination.total / pageSize) const totalRequests = Math.ceil(pagination.total / pageSize)
for (let page = 1; page <= totalRequests; page++) { for (let page = 1; page <= totalRequests; page++) {
const params: UsageQueryParams = { const response = await usageAPI.query(buildUsageQueryParams(page, pageSize))
page: page,
page_size: pageSize,
...filters.value
}
const response = await usageAPI.query(params)
allLogs.push(...response.items) allLogs.push(...response.items)
} }

View File

@ -256,6 +256,17 @@ describe('user UsageView tooltip', () => {
await setupState.exportToCSV() await setupState.exportToCSV()
expect(exportedBlob).not.toBeNull() expect(exportedBlob).not.toBeNull()
const hasSortedExportQuery = query.mock.calls.some((call) => {
const params = call[0] as Record<string, unknown> | undefined
const config = call[1]
return (
params?.page_size === 100 &&
params?.sort_by === 'created_at' &&
params?.sort_order === 'desc' &&
config === undefined
)
})
expect(hasSortedExportQuery).toBe(true)
expect(clickSpy).toHaveBeenCalled() expect(clickSpy).toHaveBeenCalled()
expect(showSuccess).toHaveBeenCalled() expect(showSuccess).toHaveBeenCalled()