From 7b65f3abd7be8e97f58f73541d7850604157781a Mon Sep 17 00:00:00 2001 From: awsl233777 Date: Thu, 4 Jun 2026 19:04:37 +0800 Subject: [PATCH 1/5] feat(requests): add time-range filter for proxy request search Co-Authored-By: Claude Opus 4.6 --- internal/handler/admin.go | 156 ++++++++++-------- internal/repository/interfaces.go | 15 ++ internal/repository/sqlite/migrations.go | 40 +++++ internal/repository/sqlite/proxy_request.go | 59 ++++--- .../repository/sqlite/proxy_request_test.go | 52 ++++++ web/src/hooks/queries/use-requests.ts | 118 +++++-------- web/src/lib/transport/http-transport.ts | 36 ++-- web/src/lib/transport/interface.ts | 14 +- web/src/lib/transport/types.ts | 4 + web/src/locales/en.json | 3 + web/src/locales/zh.json | 3 + web/src/pages/requests/index.tsx | 127 ++++++++++++-- 12 files changed, 418 insertions(+), 209 deletions(-) diff --git a/internal/handler/admin.go b/internal/handler/admin.go index 403cd08e..c81bbd9d 100644 --- a/internal/handler/admin.go +++ b/internal/handler/admin.go @@ -827,6 +827,82 @@ func (h *AdminHandler) handleRoutingStrategies(w http.ResponseWriter, r *http.Re // ProxyRequest handlers // Routes: /admin/requests, /admin/requests/count, /admin/requests/active, /admin/requests/{id}, /admin/requests/{id}/attempts, /admin/requests/{id}/recalculate-cost +func parseProxyRequestFilter(r *http.Request) (*repository.ProxyRequestFilter, error) { + query := r.URL.Query() + filter := &repository.ProxyRequestFilter{} + + if providerID, err := parseUintQuery(query.Get("providerId"), "providerId"); err != nil { + return nil, err + } else if providerID != nil { + filter.ProviderID = providerID + } + + if status := query.Get("status"); status != "" { + filter.Status = &status + } + + if apiTokenID, err := parseUintQuery(query.Get("apiTokenId"), "apiTokenId"); err != nil { + return nil, err + } else if apiTokenID != nil { + filter.APITokenID = apiTokenID + } + + if projectID, err := parseUintQuery(query.Get("projectId"), "projectId"); err != nil { + return nil, err + } else if projectID != nil { + filter.ProjectID = projectID + } + + if startTime, err := parseTimeQuery(query.Get("startTime"), "startTime"); err != nil { + return nil, err + } else if startTime != nil { + filter.StartTime = startTime + } + + if endTime, err := parseTimeQuery(query.Get("endTime"), "endTime"); err != nil { + return nil, err + } else if endTime != nil { + filter.EndTime = endTime + } + + if filter.StartTime != nil && filter.EndTime != nil && filter.EndTime.Before(*filter.StartTime) { + return nil, errors.New("endTime must be greater than or equal to startTime") + } + + if filter.IsEmpty() { + return nil, nil + } + return filter, nil +} + +func parseUintQuery(raw, name string) (*uint64, error) { + if raw == "" { + return nil, nil + } + value, err := strconv.ParseUint(raw, 10, 64) + if err != nil { + return nil, errors.New("invalid " + name) + } + return &value, nil +} + +func parseTimeQuery(raw, name string) (*time.Time, error) { + if raw == "" { + return nil, nil + } + if millis, err := strconv.ParseInt(raw, 10, 64); err == nil { + t := time.UnixMilli(millis).UTC() + return &t, nil + } + for _, format := range []string{time.RFC3339Nano, time.RFC3339} { + if t, err := time.Parse(format, raw); err == nil { + utc := t.UTC() + return &utc, nil + } + } + return nil, errors.New("invalid " + name + ": expected millisecond timestamp or RFC3339") +} + func (h *AdminHandler) handleProxyRequests(w http.ResponseWriter, r *http.Request, id uint64, parts []string) { // Check for count endpoint: /admin/requests/count if len(parts) > 2 && parts[2] == "count" { @@ -876,42 +952,10 @@ func (h *AdminHandler) handleProxyRequests(w http.ResponseWriter, r *http.Reques after, _ = strconv.ParseUint(a, 10, 64) } - // 构建过滤条件 - var filter *repository.ProxyRequestFilter - providerIDStr := r.URL.Query().Get("providerId") - statusStr := r.URL.Query().Get("status") - apiTokenIDStr := r.URL.Query().Get("apiTokenId") - projectIDStr := r.URL.Query().Get("projectId") - - if providerIDStr != "" || statusStr != "" || apiTokenIDStr != "" || projectIDStr != "" { - filter = &repository.ProxyRequestFilter{} - if providerIDStr != "" { - providerID, err := strconv.ParseUint(providerIDStr, 10, 64) - if err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid providerId"}) - return - } - filter.ProviderID = &providerID - } - if statusStr != "" { - filter.Status = &statusStr - } - if apiTokenIDStr != "" { - apiTokenID, err := strconv.ParseUint(apiTokenIDStr, 10, 64) - if err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid apiTokenId"}) - return - } - filter.APITokenID = &apiTokenID - } - if projectIDStr != "" { - projectID, err := strconv.ParseUint(projectIDStr, 10, 64) - if err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid projectId"}) - return - } - filter.ProjectID = &projectID - } + filter, err := parseProxyRequestFilter(r) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return } result, err := h.svc.GetProxyRequestsCursor(tenantID, limit, before, after, filter) @@ -935,42 +979,10 @@ func (h *AdminHandler) handleProxyRequestsCount(w http.ResponseWriter, r *http.R tenantID := maxxctx.GetTenantID(r.Context()) - // 解析过滤参数 - var filter *repository.ProxyRequestFilter - providerIDStr := r.URL.Query().Get("providerId") - statusStr := r.URL.Query().Get("status") - apiTokenIDStr := r.URL.Query().Get("apiTokenId") - projectIDStr := r.URL.Query().Get("projectId") - - if providerIDStr != "" || statusStr != "" || apiTokenIDStr != "" || projectIDStr != "" { - filter = &repository.ProxyRequestFilter{} - if providerIDStr != "" { - providerID, err := strconv.ParseUint(providerIDStr, 10, 64) - if err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid providerId"}) - return - } - filter.ProviderID = &providerID - } - if statusStr != "" { - filter.Status = &statusStr - } - if apiTokenIDStr != "" { - apiTokenID, err := strconv.ParseUint(apiTokenIDStr, 10, 64) - if err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid apiTokenId"}) - return - } - filter.APITokenID = &apiTokenID - } - if projectIDStr != "" { - projectID, err := strconv.ParseUint(projectIDStr, 10, 64) - if err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid projectId"}) - return - } - filter.ProjectID = &projectID - } + filter, err := parseProxyRequestFilter(r) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return } count, err := h.svc.GetProxyRequestsCountWithFilter(tenantID, filter) diff --git a/internal/repository/interfaces.go b/internal/repository/interfaces.go index 5655ed27..71de322d 100644 --- a/internal/repository/interfaces.go +++ b/internal/repository/interfaces.go @@ -122,6 +122,21 @@ type ProxyRequestFilter struct { Status *string // 状态,nil 表示不过滤 APITokenID *uint64 // API Token ID,nil 表示不过滤 ProjectID *uint64 // Project ID,nil 表示不过滤 + StartTime *time.Time + EndTime *time.Time +} + +func (f *ProxyRequestFilter) IsEmpty() bool { + if f == nil { + return true + } + return f.TenantID == nil && + f.ProviderID == nil && + f.Status == nil && + f.APITokenID == nil && + f.ProjectID == nil && + f.StartTime == nil && + f.EndTime == nil } type ProxyRequestRepository interface { diff --git a/internal/repository/sqlite/migrations.go b/internal/repository/sqlite/migrations.go index 9eb56d31..ffbb1953 100644 --- a/internal/repository/sqlite/migrations.go +++ b/internal/repository/sqlite/migrations.go @@ -486,6 +486,46 @@ var migrations = []Migration{ } }, }, + { + Version: 16, + Description: "Add proxy request created_at index for time-range search", + Up: func(db *gorm.DB) error { + var rowCount int64 + if err := db.Raw("SELECT COUNT(*) FROM proxy_requests").Scan(&rowCount).Error; err != nil { + log.Printf("[Migration v16] could not count proxy_requests (%v); skipping index build. Apply manually if needed.", err) + return nil + } + + if rowCount > detailCleanupIndexRowThreshold { + log.Printf("[Migration v16] SKIPPING idx_proxy_requests_created_at_id build: proxy_requests has %d rows (> %d threshold). "+ + "Apply manually during a maintenance window: CREATE INDEX idx_proxy_requests_created_at_id ON proxy_requests(created_at, id);", + rowCount, detailCleanupIndexRowThreshold) + return nil + } + + switch db.Dialector.Name() { + case "mysql": + err := db.Exec("CREATE INDEX idx_proxy_requests_created_at_id ON proxy_requests(created_at, id)").Error + if isMySQLDuplicateIndexError(err) { + return nil + } + return err + default: + return db.Exec("CREATE INDEX IF NOT EXISTS idx_proxy_requests_created_at_id ON proxy_requests(created_at, id)").Error + } + }, + Down: func(db *gorm.DB) error { + switch db.Dialector.Name() { + case "mysql": + if err := db.Exec("DROP INDEX idx_proxy_requests_created_at_id ON proxy_requests").Error; err != nil && !isMySQLMissingIndexError(err) { + log.Printf("[Migration] Warning: rollback v16 failed: %v", err) + } + return nil + default: + return db.Exec("DROP INDEX IF EXISTS idx_proxy_requests_created_at_id").Error + } + }, + }, } // runDetailClearedColumnMigration 显式添加 detail_cleared 列到两张大表,带 threshold-skip。 diff --git a/internal/repository/sqlite/proxy_request.go b/internal/repository/sqlite/proxy_request.go index 27c6ba5e..ffb53345 100644 --- a/internal/repository/sqlite/proxy_request.go +++ b/internal/repository/sqlite/proxy_request.go @@ -91,6 +91,31 @@ func (r *ProxyRequestRepository) List(tenantID uint64, limit, offset int) ([]*do return r.toDomainList(models), nil } +func applyProxyRequestFilter(query *gorm.DB, filter *repository.ProxyRequestFilter) *gorm.DB { + if filter == nil { + return query + } + if filter.ProviderID != nil { + query = query.Where("provider_id = ?", *filter.ProviderID) + } + if filter.Status != nil { + query = query.Where("status = ?", *filter.Status) + } + if filter.APITokenID != nil { + query = query.Where("api_token_id = ?", *filter.APITokenID) + } + if filter.ProjectID != nil { + query = query.Where("project_id = ?", *filter.ProjectID) + } + if filter.StartTime != nil { + query = query.Where("created_at >= ?", toTimestamp(*filter.StartTime)) + } + if filter.EndTime != nil { + query = query.Where("created_at <= ?", toTimestamp(*filter.EndTime)) + } + return query +} + // ListCursor 基于游标的分页查询,比 OFFSET 更高效 // before: 获取 id < before 的记录 (向后翻页) // after: 获取 id > after 的记录 (向前翻页/获取新数据) @@ -107,21 +132,7 @@ func (r *ProxyRequestRepository) ListCursor(tenantID uint64, limit int, before, baseQuery = baseQuery.Where("id < ?", before) } - // 应用过滤条件 - if filter != nil { - if filter.ProviderID != nil { - baseQuery = baseQuery.Where("provider_id = ?", *filter.ProviderID) - } - if filter.Status != nil { - baseQuery = baseQuery.Where("status = ?", *filter.Status) - } - if filter.APITokenID != nil { - baseQuery = baseQuery.Where("api_token_id = ?", *filter.APITokenID) - } - if filter.ProjectID != nil { - baseQuery = baseQuery.Where("project_id = ?", *filter.ProjectID) - } - } + baseQuery = applyProxyRequestFilter(baseQuery, filter) orderBy := "id DESC" if after > 0 { @@ -163,27 +174,13 @@ func (r *ProxyRequestRepository) Count(tenantID uint64) (int64, error) { // CountWithFilter 带过滤条件的计数 func (r *ProxyRequestRepository) CountWithFilter(tenantID uint64, filter *repository.ProxyRequestFilter) (int64, error) { // 如果没有过滤条件且没有 tenantID 过滤,使用缓存的总数 - if tenantID == domain.TenantIDAll && (filter == nil || (filter.ProviderID == nil && filter.Status == nil && filter.APITokenID == nil && filter.ProjectID == nil)) { + if tenantID == domain.TenantIDAll && (filter == nil || filter.IsEmpty()) { return atomic.LoadInt64(&r.count), nil } // 有过滤条件时需要查询数据库 var count int64 - query := tenantScope(r.db.gorm.Model(&ProxyRequest{}), tenantID) - if filter != nil { - if filter.ProviderID != nil { - query = query.Where("provider_id = ?", *filter.ProviderID) - } - if filter.Status != nil { - query = query.Where("status = ?", *filter.Status) - } - if filter.APITokenID != nil { - query = query.Where("api_token_id = ?", *filter.APITokenID) - } - if filter.ProjectID != nil { - query = query.Where("project_id = ?", *filter.ProjectID) - } - } + query := applyProxyRequestFilter(tenantScope(r.db.gorm.Model(&ProxyRequest{}), tenantID), filter) if err := query.Count(&count).Error; err != nil { return 0, err } diff --git a/internal/repository/sqlite/proxy_request_test.go b/internal/repository/sqlite/proxy_request_test.go index 0f194607..d9bcc61e 100644 --- a/internal/repository/sqlite/proxy_request_test.go +++ b/internal/repository/sqlite/proxy_request_test.go @@ -7,6 +7,7 @@ import ( "time" "github.com/awsl-project/maxx/internal/domain" + "github.com/awsl-project/maxx/internal/repository" ) func TestDetailCleanupBatchParams(t *testing.T) { @@ -187,6 +188,57 @@ func TestProxyRequestListCursorBeforeCursorDoesNotRepeatOrSkipRecords(t *testing } } +func TestProxyRequestListCursorAndCountFilterByCreatedAtRange(t *testing.T) { + db, err := NewDBWithDSN("sqlite://:memory:") + if err != nil { + t.Fatalf("Failed to create DB: %v", err) + } + defer db.Close() + + repo := NewProxyRequestRepository(db) + base := time.Date(2026, 6, 4, 10, 0, 0, 0, time.UTC) + requests := []*domain.ProxyRequest{ + buildTestProxyRequest("COMPLETED", 1), + buildTestProxyRequest("COMPLETED", 2), + buildTestProxyRequest("COMPLETED", 3), + buildTestProxyRequest("COMPLETED", 4), + } + + for i, request := range requests { + if err := repo.Create(request); err != nil { + t.Fatalf("Failed to create request: %v", err) + } + createdAt := base.Add(time.Duration(i) * time.Hour).UnixMilli() + if err := db.gorm.Model(&ProxyRequest{}).Where("id = ?", request.ID).Update("created_at", createdAt).Error; err != nil { + t.Fatalf("Failed to update created_at: %v", err) + } + } + + start := base.Add(1 * time.Hour) + end := base.Add(2 * time.Hour) + filter := &repository.ProxyRequestFilter{ + StartTime: &start, + EndTime: &end, + } + + items, err := repo.ListCursor(1, 10, 0, 0, filter) + if err != nil { + t.Fatalf("ListCursor failed: %v", err) + } + expected := []uint64{requests[2].ID, requests[1].ID} + if got := collectRequestIDs(items); fmt.Sprint(got) != fmt.Sprint(expected) { + t.Fatalf("expected filtered ids %v, got %v", expected, got) + } + + count, err := repo.CountWithFilter(1, filter) + if err != nil { + t.Fatalf("CountWithFilter failed: %v", err) + } + if count != int64(len(expected)) { + t.Fatalf("expected filtered count %d, got %d", len(expected), count) + } +} + // TestProxyRequestUpdatePreservesRequestInfo 锁定 OOM 优化后的写入不变量: // - Update 永不重写 request_info(Create 后不变),状态类 Update 不能把它清空; // - Update 仅在 ResponseInfo 非 nil 时写 response_info;ResponseInfo 为 nil 时 diff --git a/web/src/hooks/queries/use-requests.ts b/web/src/hooks/queries/use-requests.ts index 121993a5..dca4b88e 100644 --- a/web/src/hooks/queries/use-requests.ts +++ b/web/src/hooks/queries/use-requests.ts @@ -13,18 +13,43 @@ import { } from '@/lib/transport'; import { prioritizeActiveRequests } from '@/lib/request-order'; +export interface RequestFilterParams { + providerId?: number; + status?: string; + apiTokenId?: number; + projectId?: number; + startTime?: string; + endTime?: string; +} + /** Query key factory for proxy request related queries. */ export const requestKeys = { all: ['requests'] as const, lists: () => [...requestKeys.all, 'list'] as const, list: (params?: CursorPaginationParams) => [...requestKeys.lists(), params] as const, - infinite: (providerId?: number, status?: string, apiTokenId?: number, projectId?: number) => - [...requestKeys.all, 'infinite', providerId, status, apiTokenId, projectId] as const, + infinite: (filter?: RequestFilterParams) => + [...requestKeys.all, 'infinite', filter ?? {}] as const, + count: (filter?: RequestFilterParams) => ['requestsCount', filter ?? {}] as const, details: () => [...requestKeys.all, 'detail'] as const, detail: (id: number) => [...requestKeys.details(), id] as const, attempts: (id: number) => [...requestKeys.detail(id), 'attempts'] as const, }; +function matchesRequestFilter(request: ProxyRequest, filter?: RequestFilterParams): boolean { + if (!filter) return true; + if (filter.providerId !== undefined && request.providerID !== filter.providerId) return false; + if (filter.status !== undefined && request.status !== filter.status) return false; + if (filter.apiTokenId !== undefined && request.apiTokenID !== filter.apiTokenId) return false; + if (filter.projectId !== undefined && request.projectID !== filter.projectId) return false; + const createdAtMs = new Date(request.createdAt).getTime(); + if (!Number.isFinite(createdAtMs)) return true; + if (filter.startTime !== undefined && createdAtMs < new Date(filter.startTime).getTime()) + return false; + if (filter.endTime !== undefined && createdAtMs > new Date(filter.endTime).getTime()) + return false; + return true; +} + /** Fetches proxy requests with cursor-based pagination. */ export function useProxyRequests(params?: CursorPaginationParams) { return useQuery({ @@ -37,23 +62,14 @@ export function useProxyRequests(params?: CursorPaginationParams) { * Fetches proxy requests using infinite scroll pagination. * Uses staleTime to avoid redundant refetches within a short window. */ -export function useInfiniteProxyRequests( - providerId?: number, - status?: string, - apiTokenId?: number, - projectId?: number, - enabled = true, -) { +export function useInfiniteProxyRequests(filter?: RequestFilterParams, enabled = true) { return useInfiniteQuery({ - queryKey: requestKeys.infinite(providerId, status, apiTokenId, projectId), + queryKey: requestKeys.infinite(filter), queryFn: ({ pageParam }) => getTransport().getProxyRequests({ limit: 100, before: pageParam, - providerId, - status, - apiTokenId, - projectId, + ...filter, }), getNextPageParam: (lastPage) => (lastPage.hasMore ? lastPage.lastId : undefined), initialPageParam: undefined as number | undefined, @@ -66,16 +82,10 @@ export function useInfiniteProxyRequests( * Fetches the total count of proxy requests matching the given filters. * Polls every 10s as a safety net for missed WebSocket events. */ -export function useProxyRequestsCount( - providerId?: number, - status?: string, - apiTokenId?: number, - projectId?: number, - enabled = true, -) { +export function useProxyRequestsCount(filter?: RequestFilterParams, enabled = true) { return useQuery({ - queryKey: ['requestsCount', providerId, status, apiTokenId, projectId] as const, - queryFn: () => getTransport().getProxyRequestsCount(providerId, status, apiTokenId, projectId), + queryKey: requestKeys.count(filter), + queryFn: () => getTransport().getProxyRequestsCount(filter), enabled, staleTime: 5_000, refetchInterval: enabled ? 10_000 : false, @@ -178,7 +188,7 @@ export function useProxyRequestUpdates() { .findAll({ queryKey: [...requestKeys.all, 'infinite'] }) .filter((q) => q.getObserversCount() > 0); const countQueries = queryCache - .findAll({ queryKey: ['requestsCount'] }) + .findAll({ queryKey: requestKeys.count() }) .filter((q) => q.getObserversCount() > 0); let invalidateDashboard = false; @@ -213,22 +223,9 @@ export function useProxyRequestUpdates() { for (const query of listQueries) { const queryKey = query.queryKey as ReturnType; const params = queryKey[2] as CursorPaginationParams | undefined; - const filterProviderId = params?.providerId; - const filterStatus = params?.status; - const filterAPITokenId = params?.apiTokenId; - const matchesFilter = (request: ProxyRequest) => { - if (filterProviderId !== undefined && request.providerID !== filterProviderId) { - return false; - } - if (filterStatus !== undefined && request.status !== filterStatus) { - return false; - } - if (filterAPITokenId !== undefined && request.apiTokenID !== filterAPITokenId) { - return false; - } - return true; - }; + const matchesFilter = (request: ProxyRequest) => + matchesRequestFilter(request, params); queryClient.setQueryData>(queryKey, (old) => { if (!old || !old.items) return old; @@ -284,26 +281,10 @@ export function useProxyRequestUpdates() { // 更新 Infinite Queries(仅更新正在被观察的 query) for (const query of infiniteQueries) { const queryKey = query.queryKey as ReturnType; - const filterProviderId = queryKey[2] as number | undefined; - const filterStatus = queryKey[3] as string | undefined; - const filterAPITokenId = queryKey[4] as number | undefined; - const filterProjectId = queryKey[5] as number | undefined; - - const matchesFilter = (request: ProxyRequest) => { - if (filterProviderId !== undefined && request.providerID !== filterProviderId) { - return false; - } - if (filterStatus !== undefined && request.status !== filterStatus) { - return false; - } - if (filterAPITokenId !== undefined && request.apiTokenID !== filterAPITokenId) { - return false; - } - if (filterProjectId !== undefined && request.projectID !== filterProjectId) { - return false; - } - return true; - }; + const f = queryKey[2] as RequestFilterParams; + + const matchesFilter = (request: ProxyRequest) => + matchesRequestFilter(request, f); queryClient.setQueryData<{ pages: CursorPaginationResult[]; @@ -368,20 +349,9 @@ export function useProxyRequestUpdates() { if (looksLikeRecentRequest) { for (const query of countQueries) { - const filterProviderId = query.queryKey[1] as number | undefined; - const filterStatus = query.queryKey[2] as string | undefined; - const filterAPITokenId = query.queryKey[3] as number | undefined; - const filterProjectId = query.queryKey[4] as number | undefined; - if (filterProviderId !== undefined && updatedRequest.providerID !== filterProviderId) { - continue; - } - if (filterStatus !== undefined && updatedRequest.status !== filterStatus) { - continue; - } - if (filterAPITokenId !== undefined && updatedRequest.apiTokenID !== filterAPITokenId) { - continue; - } - if (filterProjectId !== undefined && updatedRequest.projectID !== filterProjectId) { + const countKey = query.queryKey as ReturnType; + const cf = countKey[1] as RequestFilterParams; + if (!matchesRequestFilter(updatedRequest, cf)) { continue; } queryClient.setQueryData(query.queryKey, (old) => (old ?? 0) + 1); @@ -461,7 +431,7 @@ export function useProxyRequestUpdates() { void queryClient.refetchQueries({ queryKey: requestKeys.lists(), type: 'active' }); void queryClient.refetchQueries({ queryKey: [...requestKeys.all, 'infinite'], type: 'active' }); void queryClient.refetchQueries({ queryKey: requestKeys.details(), type: 'active' }); - void queryClient.refetchQueries({ queryKey: ['requestsCount'], type: 'active' }); + void queryClient.refetchQueries({ queryKey: requestKeys.count(), type: 'active' }); void queryClient.refetchQueries({ queryKey: ['dashboard'], type: 'active' }); void queryClient.refetchQueries({ queryKey: ['providers', 'stats'], type: 'active' }); void queryClient.refetchQueries({ queryKey: ['cooldowns'], type: 'active' }); diff --git a/web/src/lib/transport/http-transport.ts b/web/src/lib/transport/http-transport.ts index 745dc69e..2b725c22 100644 --- a/web/src/lib/transport/http-transport.ts +++ b/web/src/lib/transport/http-transport.ts @@ -411,24 +411,32 @@ export class HttpTransport implements Transport { return data ?? { items: [], hasMore: false }; } - async getProxyRequestsCount( - providerId?: number, - status?: string, - apiTokenId?: number, - projectId?: number, - ): Promise { + async getProxyRequestsCount(filter?: { + providerId?: number; + status?: string; + apiTokenId?: number; + projectId?: number; + startTime?: string; + endTime?: string; + }): Promise { const params: Record = {}; - if (providerId !== undefined) { - params.providerId = String(providerId); + if (filter?.providerId !== undefined) { + params.providerId = String(filter.providerId); + } + if (filter?.status !== undefined) { + params.status = filter.status; + } + if (filter?.apiTokenId !== undefined) { + params.apiTokenId = String(filter.apiTokenId); } - if (status !== undefined) { - params.status = status; + if (filter?.projectId !== undefined) { + params.projectId = String(filter.projectId); } - if (apiTokenId !== undefined) { - params.apiTokenId = String(apiTokenId); + if (filter?.startTime !== undefined) { + params.startTime = filter.startTime; } - if (projectId !== undefined) { - params.projectId = String(projectId); + if (filter?.endTime !== undefined) { + params.endTime = filter.endTime; } const { data } = await this.adminClient.get('/requests/count', { params }); return data ?? 0; diff --git a/web/src/lib/transport/interface.ts b/web/src/lib/transport/interface.ts index 0f029fc5..5cbe48eb 100644 --- a/web/src/lib/transport/interface.ts +++ b/web/src/lib/transport/interface.ts @@ -129,12 +129,14 @@ export interface Transport { // ===== ProxyRequest API (只读) ===== getProxyRequests(params?: CursorPaginationParams): Promise>; - getProxyRequestsCount( - providerId?: number, - status?: string, - apiTokenId?: number, - projectId?: number, - ): Promise; + getProxyRequestsCount(filter?: { + providerId?: number; + status?: string; + apiTokenId?: number; + projectId?: number; + startTime?: string; + endTime?: string; + }): Promise; getActiveProxyRequests(): Promise; getProxyRequest(id: number): Promise; getProxyUpstreamAttempts(proxyRequestId: number): Promise; diff --git a/web/src/lib/transport/types.ts b/web/src/lib/transport/types.ts index 74b5d752..49909cbc 100644 --- a/web/src/lib/transport/types.ts +++ b/web/src/lib/transport/types.ts @@ -373,6 +373,10 @@ export interface CursorPaginationParams { apiTokenId?: number; /** 按 Project ID 过滤 */ projectId?: number; + /** 按创建时间起点过滤(ISO 字符串或毫秒时间戳字符串) */ + startTime?: string; + /** 按创建时间终点过滤(ISO 字符串或毫秒时间戳字符串) */ + endTime?: string; } /** 游标分页响应 */ diff --git a/web/src/locales/en.json b/web/src/locales/en.json index dfe30d56..0cb28b1e 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -172,6 +172,9 @@ "allProviders": "All Providers", "allProjects": "All Projects", "allStatuses": "All Statuses", + "timeFrom": "Start time", + "timeTo": "End time", + "clearTimeRange": "Clear time range", "time": "Time", "client": "Client", "model": "Model", diff --git a/web/src/locales/zh.json b/web/src/locales/zh.json index cf21ca97..8cb7a7f2 100644 --- a/web/src/locales/zh.json +++ b/web/src/locales/zh.json @@ -171,6 +171,9 @@ "allProviders": "全部供应商", "allProjects": "全部项目", "allStatuses": "全部状态", + "timeFrom": "开始时间", + "timeTo": "结束时间", + "clearTimeRange": "清除时间范围", "time": "时间", "client": "客户端", "model": "模型", diff --git a/web/src/pages/requests/index.tsx b/web/src/pages/requests/index.tsx index 8a6bdc87..abdde41d 100644 --- a/web/src/pages/requests/index.tsx +++ b/web/src/pages/requests/index.tsx @@ -11,7 +11,16 @@ import { useProjects, useVisibleAPITokens, } from '@/hooks/queries'; -import { Activity, RefreshCw, Loader2, CheckCircle, AlertTriangle, Ban } from 'lucide-react'; +import { + Activity, + RefreshCw, + Loader2, + CheckCircle, + AlertTriangle, + Ban, + CalendarRange, + X, +} from 'lucide-react'; import type { APIToken, Project, @@ -35,6 +44,7 @@ import { SelectValue, SelectGroup, SelectLabel, + Input, } from '@/components/ui'; import { cn } from '@/lib/utils'; import { PageHeader } from '@/components/layout/page-header'; @@ -62,6 +72,17 @@ const REQUEST_PROJECT_FILTER_STORAGE_KEY = 'maxx-requests-project-filter'; const REQUESTS_VIRTUALIZE_THRESHOLD = 40; const DEFAULT_DESKTOP_ROW_HEIGHT = 38; +function dateTimeLocalToISOString(value: string): string | undefined { + if (!value) { + return undefined; + } + const date = new Date(value); + if (!Number.isFinite(date.getTime())) { + return undefined; + } + return date.toISOString(); +} + function isServerRestartedFailure(request: Pick): boolean { return request.status === 'FAILED' && request.error.trim() === 'Server restarted'; } @@ -215,6 +236,8 @@ export function RequestsPage() { ); // Status 过滤器 const [selectedStatus, setSelectedStatus] = useState(undefined); + const [startDateTime, setStartDateTime] = useState(''); + const [endDateTime, setEndDateTime] = useState(''); const scrollContainerRef = useRef(null); const loadMoreRef = useRef(null); @@ -225,6 +248,28 @@ export function RequestsPage() { const activeProviderId = filterMode === 'provider' ? selectedProviderId : undefined; const activeTokenId = filterMode === 'token' ? selectedTokenId : undefined; const activeProjectId = filterMode === 'project' ? selectedProjectId : undefined; + const [debouncedStartDateTime, setDebouncedStartDateTime] = useState(''); + const [debouncedEndDateTime, setDebouncedEndDateTime] = useState(''); + + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedStartDateTime(startDateTime); + setDebouncedEndDateTime(endDateTime); + }, 400); + return () => clearTimeout(timer); + }, [startDateTime, endDateTime]); + + const requestFilter = useMemo( + () => ({ + providerId: activeProviderId, + status: selectedStatus, + apiTokenId: activeTokenId, + projectId: activeProjectId, + startTime: dateTimeLocalToISOString(debouncedStartDateTime), + endTime: dateTimeLocalToISOString(debouncedEndDateTime), + }), + [activeProviderId, selectedStatus, activeTokenId, activeProjectId, debouncedStartDateTime, debouncedEndDateTime], + ); const { data: providers = [], isSuccess: providersIsSuccess } = useProviders(); const { data: projects = [], isSuccess: projectsIsSuccess } = useProjects(); @@ -243,19 +288,10 @@ export function RequestsPage() { // 使用 Infinite Query const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading, isFetching, refetch } = - useInfiniteProxyRequests( - activeProviderId, - selectedStatus, - activeTokenId, - activeProjectId, - requestsQueryEnabled, - ); + useInfiniteProxyRequests(requestFilter, requestsQueryEnabled); const { data: totalCount, refetch: refetchCount } = useProxyRequestsCount( - activeProviderId, - selectedStatus, - activeTokenId, - activeProjectId, + requestFilter, requestsQueryEnabled, ); @@ -543,6 +579,16 @@ export function RequestsPage() { scrollContainerRef.current?.scrollTo({ top: 0 }); }; + const handleTimeRangeChange = (nextStartDateTime: string, nextEndDateTime: string) => { + setStartDateTime(nextStartDateTime); + setEndDateTime(nextEndDateTime); + scrollContainerRef.current?.scrollTo({ top: 0 }); + }; + + const handleClearTimeRange = () => { + handleTimeRangeChange('', ''); + }; + const handleOpenRequest = useCallback( (id: number) => { navigate(`/requests/${id}`); @@ -627,6 +673,12 @@ export function RequestsPage() { )} {/* Status Filter */} + + + ); +} + // Status Filter Component using Select function StatusFilter({ selectedStatus, From 07109b76754ba76062fe1f7f2a655a11188d4f4a Mon Sep 17 00:00:00 2001 From: awsl233777 Date: Thu, 4 Jun 2026 22:43:24 +0800 Subject: [PATCH 2/5] fix: revert over-engineered refactors, keep minimal time-range filter + add 13-digit ms validation Co-Authored-By: Claude Opus 4.6 --- internal/handler/admin.go | 4 + internal/repository/sqlite/proxy_request.go | 69 +++++---- web/src/hooks/queries/use-requests.ts | 152 +++++++++++++++----- web/src/lib/transport/http-transport.ts | 40 +++--- web/src/lib/transport/interface.ts | 16 +-- web/src/pages/requests/index.tsx | 41 +++--- 6 files changed, 206 insertions(+), 116 deletions(-) diff --git a/internal/handler/admin.go b/internal/handler/admin.go index c81bbd9d..7bd0e577 100644 --- a/internal/handler/admin.go +++ b/internal/handler/admin.go @@ -891,6 +891,10 @@ func parseTimeQuery(raw, name string) (*time.Time, error) { return nil, nil } if millis, err := strconv.ParseInt(raw, 10, 64); err == nil { + digits := len(strings.TrimLeft(raw, "-")) + if digits != 13 { + return nil, errors.New("invalid " + name + ": expected 13-digit millisecond timestamp or RFC3339") + } t := time.UnixMilli(millis).UTC() return &t, nil } diff --git a/internal/repository/sqlite/proxy_request.go b/internal/repository/sqlite/proxy_request.go index ffb53345..8d9cb05d 100644 --- a/internal/repository/sqlite/proxy_request.go +++ b/internal/repository/sqlite/proxy_request.go @@ -91,31 +91,6 @@ func (r *ProxyRequestRepository) List(tenantID uint64, limit, offset int) ([]*do return r.toDomainList(models), nil } -func applyProxyRequestFilter(query *gorm.DB, filter *repository.ProxyRequestFilter) *gorm.DB { - if filter == nil { - return query - } - if filter.ProviderID != nil { - query = query.Where("provider_id = ?", *filter.ProviderID) - } - if filter.Status != nil { - query = query.Where("status = ?", *filter.Status) - } - if filter.APITokenID != nil { - query = query.Where("api_token_id = ?", *filter.APITokenID) - } - if filter.ProjectID != nil { - query = query.Where("project_id = ?", *filter.ProjectID) - } - if filter.StartTime != nil { - query = query.Where("created_at >= ?", toTimestamp(*filter.StartTime)) - } - if filter.EndTime != nil { - query = query.Where("created_at <= ?", toTimestamp(*filter.EndTime)) - } - return query -} - // ListCursor 基于游标的分页查询,比 OFFSET 更高效 // before: 获取 id < before 的记录 (向后翻页) // after: 获取 id > after 的记录 (向前翻页/获取新数据) @@ -132,7 +107,27 @@ func (r *ProxyRequestRepository) ListCursor(tenantID uint64, limit int, before, baseQuery = baseQuery.Where("id < ?", before) } - baseQuery = applyProxyRequestFilter(baseQuery, filter) + // 应用过滤条件 + if filter != nil { + if filter.ProviderID != nil { + baseQuery = baseQuery.Where("provider_id = ?", *filter.ProviderID) + } + if filter.Status != nil { + baseQuery = baseQuery.Where("status = ?", *filter.Status) + } + if filter.APITokenID != nil { + baseQuery = baseQuery.Where("api_token_id = ?", *filter.APITokenID) + } + if filter.ProjectID != nil { + baseQuery = baseQuery.Where("project_id = ?", *filter.ProjectID) + } + if filter.StartTime != nil { + baseQuery = baseQuery.Where("created_at >= ?", toTimestamp(*filter.StartTime)) + } + if filter.EndTime != nil { + baseQuery = baseQuery.Where("created_at <= ?", toTimestamp(*filter.EndTime)) + } + } orderBy := "id DESC" if after > 0 { @@ -180,7 +175,27 @@ func (r *ProxyRequestRepository) CountWithFilter(tenantID uint64, filter *reposi // 有过滤条件时需要查询数据库 var count int64 - query := applyProxyRequestFilter(tenantScope(r.db.gorm.Model(&ProxyRequest{}), tenantID), filter) + query := tenantScope(r.db.gorm.Model(&ProxyRequest{}), tenantID) + if filter != nil { + if filter.ProviderID != nil { + query = query.Where("provider_id = ?", *filter.ProviderID) + } + if filter.Status != nil { + query = query.Where("status = ?", *filter.Status) + } + if filter.APITokenID != nil { + query = query.Where("api_token_id = ?", *filter.APITokenID) + } + if filter.ProjectID != nil { + query = query.Where("project_id = ?", *filter.ProjectID) + } + if filter.StartTime != nil { + query = query.Where("created_at >= ?", toTimestamp(*filter.StartTime)) + } + if filter.EndTime != nil { + query = query.Where("created_at <= ?", toTimestamp(*filter.EndTime)) + } + } if err := query.Count(&count).Error; err != nil { return 0, err } diff --git a/web/src/hooks/queries/use-requests.ts b/web/src/hooks/queries/use-requests.ts index dca4b88e..3d968154 100644 --- a/web/src/hooks/queries/use-requests.ts +++ b/web/src/hooks/queries/use-requests.ts @@ -13,40 +13,33 @@ import { } from '@/lib/transport'; import { prioritizeActiveRequests } from '@/lib/request-order'; -export interface RequestFilterParams { - providerId?: number; - status?: string; - apiTokenId?: number; - projectId?: number; - startTime?: string; - endTime?: string; -} - /** Query key factory for proxy request related queries. */ export const requestKeys = { all: ['requests'] as const, lists: () => [...requestKeys.all, 'list'] as const, list: (params?: CursorPaginationParams) => [...requestKeys.lists(), params] as const, - infinite: (filter?: RequestFilterParams) => - [...requestKeys.all, 'infinite', filter ?? {}] as const, - count: (filter?: RequestFilterParams) => ['requestsCount', filter ?? {}] as const, + infinite: (providerId?: number, status?: string, apiTokenId?: number, projectId?: number, startTime?: string, endTime?: string) => + [...requestKeys.all, 'infinite', providerId, status, apiTokenId, projectId, startTime, endTime] as const, details: () => [...requestKeys.all, 'detail'] as const, detail: (id: number) => [...requestKeys.details(), id] as const, attempts: (id: number) => [...requestKeys.detail(id), 'attempts'] as const, }; -function matchesRequestFilter(request: ProxyRequest, filter?: RequestFilterParams): boolean { - if (!filter) return true; - if (filter.providerId !== undefined && request.providerID !== filter.providerId) return false; - if (filter.status !== undefined && request.status !== filter.status) return false; - if (filter.apiTokenId !== undefined && request.apiTokenID !== filter.apiTokenId) return false; - if (filter.projectId !== undefined && request.projectID !== filter.projectId) return false; +function matchesRequestTimeRange( + request: ProxyRequest, + startTime?: string, + endTime?: string, +): boolean { const createdAtMs = new Date(request.createdAt).getTime(); - if (!Number.isFinite(createdAtMs)) return true; - if (filter.startTime !== undefined && createdAtMs < new Date(filter.startTime).getTime()) + if (!Number.isFinite(createdAtMs)) { + return true; + } + if (startTime !== undefined && createdAtMs < new Date(startTime).getTime()) { return false; - if (filter.endTime !== undefined && createdAtMs > new Date(filter.endTime).getTime()) + } + if (endTime !== undefined && createdAtMs > new Date(endTime).getTime()) { return false; + } return true; } @@ -62,14 +55,27 @@ export function useProxyRequests(params?: CursorPaginationParams) { * Fetches proxy requests using infinite scroll pagination. * Uses staleTime to avoid redundant refetches within a short window. */ -export function useInfiniteProxyRequests(filter?: RequestFilterParams, enabled = true) { +export function useInfiniteProxyRequests( + providerId?: number, + status?: string, + apiTokenId?: number, + projectId?: number, + startTime?: string, + endTime?: string, + enabled = true, +) { return useInfiniteQuery({ - queryKey: requestKeys.infinite(filter), + queryKey: requestKeys.infinite(providerId, status, apiTokenId, projectId, startTime, endTime), queryFn: ({ pageParam }) => getTransport().getProxyRequests({ limit: 100, before: pageParam, - ...filter, + providerId, + status, + apiTokenId, + projectId, + startTime, + endTime, }), getNextPageParam: (lastPage) => (lastPage.hasMore ? lastPage.lastId : undefined), initialPageParam: undefined as number | undefined, @@ -82,10 +88,19 @@ export function useInfiniteProxyRequests(filter?: RequestFilterParams, enabled = * Fetches the total count of proxy requests matching the given filters. * Polls every 10s as a safety net for missed WebSocket events. */ -export function useProxyRequestsCount(filter?: RequestFilterParams, enabled = true) { +export function useProxyRequestsCount( + providerId?: number, + status?: string, + apiTokenId?: number, + projectId?: number, + startTime?: string, + endTime?: string, + enabled = true, +) { return useQuery({ - queryKey: requestKeys.count(filter), - queryFn: () => getTransport().getProxyRequestsCount(filter), + queryKey: ['requestsCount', providerId, status, apiTokenId, projectId, startTime, endTime] as const, + queryFn: () => + getTransport().getProxyRequestsCount(providerId, status, apiTokenId, projectId, startTime, endTime), enabled, staleTime: 5_000, refetchInterval: enabled ? 10_000 : false, @@ -188,7 +203,7 @@ export function useProxyRequestUpdates() { .findAll({ queryKey: [...requestKeys.all, 'infinite'] }) .filter((q) => q.getObserversCount() > 0); const countQueries = queryCache - .findAll({ queryKey: requestKeys.count() }) + .findAll({ queryKey: ['requestsCount'] }) .filter((q) => q.getObserversCount() > 0); let invalidateDashboard = false; @@ -223,9 +238,31 @@ export function useProxyRequestUpdates() { for (const query of listQueries) { const queryKey = query.queryKey as ReturnType; const params = queryKey[2] as CursorPaginationParams | undefined; - - const matchesFilter = (request: ProxyRequest) => - matchesRequestFilter(request, params); + const filterProviderId = params?.providerId; + const filterStatus = params?.status; + const filterAPITokenId = params?.apiTokenId; + const filterProjectId = params?.projectId; + const filterStartTime = params?.startTime; + const filterEndTime = params?.endTime; + + const matchesFilter = (request: ProxyRequest) => { + if (filterProviderId !== undefined && request.providerID !== filterProviderId) { + return false; + } + if (filterStatus !== undefined && request.status !== filterStatus) { + return false; + } + if (filterAPITokenId !== undefined && request.apiTokenID !== filterAPITokenId) { + return false; + } + if (filterProjectId !== undefined && request.projectID !== filterProjectId) { + return false; + } + if (!matchesRequestTimeRange(request, filterStartTime, filterEndTime)) { + return false; + } + return true; + }; queryClient.setQueryData>(queryKey, (old) => { if (!old || !old.items) return old; @@ -281,10 +318,31 @@ export function useProxyRequestUpdates() { // 更新 Infinite Queries(仅更新正在被观察的 query) for (const query of infiniteQueries) { const queryKey = query.queryKey as ReturnType; - const f = queryKey[2] as RequestFilterParams; - - const matchesFilter = (request: ProxyRequest) => - matchesRequestFilter(request, f); + const filterProviderId = queryKey[2] as number | undefined; + const filterStatus = queryKey[3] as string | undefined; + const filterAPITokenId = queryKey[4] as number | undefined; + const filterProjectId = queryKey[5] as number | undefined; + const filterStartTime = queryKey[6] as string | undefined; + const filterEndTime = queryKey[7] as string | undefined; + + const matchesFilter = (request: ProxyRequest) => { + if (filterProviderId !== undefined && request.providerID !== filterProviderId) { + return false; + } + if (filterStatus !== undefined && request.status !== filterStatus) { + return false; + } + if (filterAPITokenId !== undefined && request.apiTokenID !== filterAPITokenId) { + return false; + } + if (filterProjectId !== undefined && request.projectID !== filterProjectId) { + return false; + } + if (!matchesRequestTimeRange(request, filterStartTime, filterEndTime)) { + return false; + } + return true; + }; queryClient.setQueryData<{ pages: CursorPaginationResult[]; @@ -349,9 +407,25 @@ export function useProxyRequestUpdates() { if (looksLikeRecentRequest) { for (const query of countQueries) { - const countKey = query.queryKey as ReturnType; - const cf = countKey[1] as RequestFilterParams; - if (!matchesRequestFilter(updatedRequest, cf)) { + const filterProviderId = query.queryKey[1] as number | undefined; + const filterStatus = query.queryKey[2] as string | undefined; + const filterAPITokenId = query.queryKey[3] as number | undefined; + const filterProjectId = query.queryKey[4] as number | undefined; + const filterStartTime = query.queryKey[5] as string | undefined; + const filterEndTime = query.queryKey[6] as string | undefined; + if (filterProviderId !== undefined && updatedRequest.providerID !== filterProviderId) { + continue; + } + if (filterStatus !== undefined && updatedRequest.status !== filterStatus) { + continue; + } + if (filterAPITokenId !== undefined && updatedRequest.apiTokenID !== filterAPITokenId) { + continue; + } + if (filterProjectId !== undefined && updatedRequest.projectID !== filterProjectId) { + continue; + } + if (!matchesRequestTimeRange(updatedRequest, filterStartTime, filterEndTime)) { continue; } queryClient.setQueryData(query.queryKey, (old) => (old ?? 0) + 1); @@ -431,7 +505,7 @@ export function useProxyRequestUpdates() { void queryClient.refetchQueries({ queryKey: requestKeys.lists(), type: 'active' }); void queryClient.refetchQueries({ queryKey: [...requestKeys.all, 'infinite'], type: 'active' }); void queryClient.refetchQueries({ queryKey: requestKeys.details(), type: 'active' }); - void queryClient.refetchQueries({ queryKey: requestKeys.count(), type: 'active' }); + void queryClient.refetchQueries({ queryKey: ['requestsCount'], type: 'active' }); void queryClient.refetchQueries({ queryKey: ['dashboard'], type: 'active' }); void queryClient.refetchQueries({ queryKey: ['providers', 'stats'], type: 'active' }); void queryClient.refetchQueries({ queryKey: ['cooldowns'], type: 'active' }); diff --git a/web/src/lib/transport/http-transport.ts b/web/src/lib/transport/http-transport.ts index 2b725c22..432bc272 100644 --- a/web/src/lib/transport/http-transport.ts +++ b/web/src/lib/transport/http-transport.ts @@ -411,32 +411,32 @@ export class HttpTransport implements Transport { return data ?? { items: [], hasMore: false }; } - async getProxyRequestsCount(filter?: { - providerId?: number; - status?: string; - apiTokenId?: number; - projectId?: number; - startTime?: string; - endTime?: string; - }): Promise { + async getProxyRequestsCount( + providerId?: number, + status?: string, + apiTokenId?: number, + projectId?: number, + startTime?: string, + endTime?: string, + ): Promise { const params: Record = {}; - if (filter?.providerId !== undefined) { - params.providerId = String(filter.providerId); + if (providerId !== undefined) { + params.providerId = String(providerId); } - if (filter?.status !== undefined) { - params.status = filter.status; + if (status !== undefined) { + params.status = status; } - if (filter?.apiTokenId !== undefined) { - params.apiTokenId = String(filter.apiTokenId); + if (apiTokenId !== undefined) { + params.apiTokenId = String(apiTokenId); } - if (filter?.projectId !== undefined) { - params.projectId = String(filter.projectId); + if (projectId !== undefined) { + params.projectId = String(projectId); } - if (filter?.startTime !== undefined) { - params.startTime = filter.startTime; + if (startTime !== undefined) { + params.startTime = startTime; } - if (filter?.endTime !== undefined) { - params.endTime = filter.endTime; + if (endTime !== undefined) { + params.endTime = endTime; } const { data } = await this.adminClient.get('/requests/count', { params }); return data ?? 0; diff --git a/web/src/lib/transport/interface.ts b/web/src/lib/transport/interface.ts index 5cbe48eb..d0e09a0a 100644 --- a/web/src/lib/transport/interface.ts +++ b/web/src/lib/transport/interface.ts @@ -129,14 +129,14 @@ export interface Transport { // ===== ProxyRequest API (只读) ===== getProxyRequests(params?: CursorPaginationParams): Promise>; - getProxyRequestsCount(filter?: { - providerId?: number; - status?: string; - apiTokenId?: number; - projectId?: number; - startTime?: string; - endTime?: string; - }): Promise; + getProxyRequestsCount( + providerId?: number, + status?: string, + apiTokenId?: number, + projectId?: number, + startTime?: string, + endTime?: string, + ): Promise; getActiveProxyRequests(): Promise; getProxyRequest(id: number): Promise; getProxyUpstreamAttempts(proxyRequestId: number): Promise; diff --git a/web/src/pages/requests/index.tsx b/web/src/pages/requests/index.tsx index abdde41d..7bce93fd 100644 --- a/web/src/pages/requests/index.tsx +++ b/web/src/pages/requests/index.tsx @@ -248,28 +248,12 @@ export function RequestsPage() { const activeProviderId = filterMode === 'provider' ? selectedProviderId : undefined; const activeTokenId = filterMode === 'token' ? selectedTokenId : undefined; const activeProjectId = filterMode === 'project' ? selectedProjectId : undefined; - const [debouncedStartDateTime, setDebouncedStartDateTime] = useState(''); - const [debouncedEndDateTime, setDebouncedEndDateTime] = useState(''); - useEffect(() => { - const timer = setTimeout(() => { - setDebouncedStartDateTime(startDateTime); - setDebouncedEndDateTime(endDateTime); - }, 400); - return () => clearTimeout(timer); - }, [startDateTime, endDateTime]); - - const requestFilter = useMemo( - () => ({ - providerId: activeProviderId, - status: selectedStatus, - apiTokenId: activeTokenId, - projectId: activeProjectId, - startTime: dateTimeLocalToISOString(debouncedStartDateTime), - endTime: dateTimeLocalToISOString(debouncedEndDateTime), - }), - [activeProviderId, selectedStatus, activeTokenId, activeProjectId, debouncedStartDateTime, debouncedEndDateTime], + const activeStartTime = useMemo( + () => dateTimeLocalToISOString(startDateTime), + [startDateTime], ); + const activeEndTime = useMemo(() => dateTimeLocalToISOString(endDateTime), [endDateTime]); const { data: providers = [], isSuccess: providersIsSuccess } = useProviders(); const { data: projects = [], isSuccess: projectsIsSuccess } = useProjects(); @@ -288,10 +272,23 @@ export function RequestsPage() { // 使用 Infinite Query const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading, isFetching, refetch } = - useInfiniteProxyRequests(requestFilter, requestsQueryEnabled); + useInfiniteProxyRequests( + activeProviderId, + selectedStatus, + activeTokenId, + activeProjectId, + activeStartTime, + activeEndTime, + requestsQueryEnabled, + ); const { data: totalCount, refetch: refetchCount } = useProxyRequestsCount( - requestFilter, + activeProviderId, + selectedStatus, + activeTokenId, + activeProjectId, + activeStartTime, + activeEndTime, requestsQueryEnabled, ); From 9b7f932befbb4881fcfacd793714d88af548e10e Mon Sep 17 00:00:00 2001 From: awsl233777 Date: Thu, 4 Jun 2026 22:59:01 +0800 Subject: [PATCH 3/5] refactor: revert parseProxyRequestFilter extraction, inline startTime/endTime in both handlers Co-Authored-By: Claude Opus 4.6 --- internal/handler/admin.go | 180 +++++++++++++++++++++++--------------- 1 file changed, 111 insertions(+), 69 deletions(-) diff --git a/internal/handler/admin.go b/internal/handler/admin.go index 7bd0e577..12cc40bc 100644 --- a/internal/handler/admin.go +++ b/internal/handler/admin.go @@ -825,67 +825,7 @@ func (h *AdminHandler) handleRoutingStrategies(w http.ResponseWriter, r *http.Re } } -// ProxyRequest handlers -// Routes: /admin/requests, /admin/requests/count, /admin/requests/active, /admin/requests/{id}, /admin/requests/{id}/attempts, /admin/requests/{id}/recalculate-cost -func parseProxyRequestFilter(r *http.Request) (*repository.ProxyRequestFilter, error) { - query := r.URL.Query() - filter := &repository.ProxyRequestFilter{} - - if providerID, err := parseUintQuery(query.Get("providerId"), "providerId"); err != nil { - return nil, err - } else if providerID != nil { - filter.ProviderID = providerID - } - - if status := query.Get("status"); status != "" { - filter.Status = &status - } - - if apiTokenID, err := parseUintQuery(query.Get("apiTokenId"), "apiTokenId"); err != nil { - return nil, err - } else if apiTokenID != nil { - filter.APITokenID = apiTokenID - } - - if projectID, err := parseUintQuery(query.Get("projectId"), "projectId"); err != nil { - return nil, err - } else if projectID != nil { - filter.ProjectID = projectID - } - - if startTime, err := parseTimeQuery(query.Get("startTime"), "startTime"); err != nil { - return nil, err - } else if startTime != nil { - filter.StartTime = startTime - } - - if endTime, err := parseTimeQuery(query.Get("endTime"), "endTime"); err != nil { - return nil, err - } else if endTime != nil { - filter.EndTime = endTime - } - - if filter.StartTime != nil && filter.EndTime != nil && filter.EndTime.Before(*filter.StartTime) { - return nil, errors.New("endTime must be greater than or equal to startTime") - } - - if filter.IsEmpty() { - return nil, nil - } - return filter, nil -} - -func parseUintQuery(raw, name string) (*uint64, error) { - if raw == "" { - return nil, nil - } - value, err := strconv.ParseUint(raw, 10, 64) - if err != nil { - return nil, errors.New("invalid " + name) - } - return &value, nil -} - +// parseTimeQuery parses a time query parameter as either a 13-digit millisecond timestamp or RFC3339. func parseTimeQuery(raw, name string) (*time.Time, error) { if raw == "" { return nil, nil @@ -907,6 +847,8 @@ func parseTimeQuery(raw, name string) (*time.Time, error) { return nil, errors.New("invalid " + name + ": expected millisecond timestamp or RFC3339") } +// ProxyRequest handlers +// Routes: /admin/requests, /admin/requests/count, /admin/requests/active, /admin/requests/{id}, /admin/requests/{id}/attempts, /admin/requests/{id}/recalculate-cost func (h *AdminHandler) handleProxyRequests(w http.ResponseWriter, r *http.Request, id uint64, parts []string) { // Check for count endpoint: /admin/requests/count if len(parts) > 2 && parts[2] == "count" { @@ -956,10 +898,60 @@ func (h *AdminHandler) handleProxyRequests(w http.ResponseWriter, r *http.Reques after, _ = strconv.ParseUint(a, 10, 64) } - filter, err := parseProxyRequestFilter(r) - if err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) - return + // 构建过滤条件 + var filter *repository.ProxyRequestFilter + providerIDStr := r.URL.Query().Get("providerId") + statusStr := r.URL.Query().Get("status") + apiTokenIDStr := r.URL.Query().Get("apiTokenId") + projectIDStr := r.URL.Query().Get("projectId") + startTimeStr := r.URL.Query().Get("startTime") + endTimeStr := r.URL.Query().Get("endTime") + + if providerIDStr != "" || statusStr != "" || apiTokenIDStr != "" || projectIDStr != "" || startTimeStr != "" || endTimeStr != "" { + filter = &repository.ProxyRequestFilter{} + if providerIDStr != "" { + providerID, err := strconv.ParseUint(providerIDStr, 10, 64) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid providerId"}) + return + } + filter.ProviderID = &providerID + } + if statusStr != "" { + filter.Status = &statusStr + } + if apiTokenIDStr != "" { + apiTokenID, err := strconv.ParseUint(apiTokenIDStr, 10, 64) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid apiTokenId"}) + return + } + filter.APITokenID = &apiTokenID + } + if projectIDStr != "" { + projectID, err := strconv.ParseUint(projectIDStr, 10, 64) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid projectId"}) + return + } + filter.ProjectID = &projectID + } + if startTimeStr != "" { + startTime, err := parseTimeQuery(startTimeStr, "startTime") + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + filter.StartTime = startTime + } + if endTimeStr != "" { + endTime, err := parseTimeQuery(endTimeStr, "endTime") + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + filter.EndTime = endTime + } } result, err := h.svc.GetProxyRequestsCursor(tenantID, limit, before, after, filter) @@ -983,10 +975,60 @@ func (h *AdminHandler) handleProxyRequestsCount(w http.ResponseWriter, r *http.R tenantID := maxxctx.GetTenantID(r.Context()) - filter, err := parseProxyRequestFilter(r) - if err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) - return + // 解析过滤参数 + var filter *repository.ProxyRequestFilter + providerIDStr := r.URL.Query().Get("providerId") + statusStr := r.URL.Query().Get("status") + apiTokenIDStr := r.URL.Query().Get("apiTokenId") + projectIDStr := r.URL.Query().Get("projectId") + startTimeStr := r.URL.Query().Get("startTime") + endTimeStr := r.URL.Query().Get("endTime") + + if providerIDStr != "" || statusStr != "" || apiTokenIDStr != "" || projectIDStr != "" || startTimeStr != "" || endTimeStr != "" { + filter = &repository.ProxyRequestFilter{} + if providerIDStr != "" { + providerID, err := strconv.ParseUint(providerIDStr, 10, 64) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid providerId"}) + return + } + filter.ProviderID = &providerID + } + if statusStr != "" { + filter.Status = &statusStr + } + if apiTokenIDStr != "" { + apiTokenID, err := strconv.ParseUint(apiTokenIDStr, 10, 64) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid apiTokenId"}) + return + } + filter.APITokenID = &apiTokenID + } + if projectIDStr != "" { + projectID, err := strconv.ParseUint(projectIDStr, 10, 64) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid projectId"}) + return + } + filter.ProjectID = &projectID + } + if startTimeStr != "" { + startTime, err := parseTimeQuery(startTimeStr, "startTime") + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + filter.StartTime = startTime + } + if endTimeStr != "" { + endTime, err := parseTimeQuery(endTimeStr, "endTime") + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + filter.EndTime = endTime + } } count, err := h.svc.GetProxyRequestsCountWithFilter(tenantID, filter) From 934ac89410b9fe5e8489e11c214a7586f29ad113 Mon Sep 17 00:00:00 2001 From: awsl233777 Date: Thu, 4 Jun 2026 23:51:36 +0800 Subject: [PATCH 4/5] feat(requests): add startTime>endTime validation + upgrade TimeRangeFilter to Calendar popover - Add startTime <= endTime validation in both handlers - Replace raw datetime-local inputs with shadcn Popover + Calendar + time input - Install shadcn calendar and popover components Co-Authored-By: Claude Opus 4.6 --- internal/handler/admin.go | 8 ++ web/package.json | 1 + web/pnpm-lock.yaml | 26 ++++ web/src/components/ui/button.tsx | 52 +++---- web/src/components/ui/calendar.tsx | 219 +++++++++++++++++++++++++++++ web/src/components/ui/popover.tsx | 88 ++++++++++++ web/src/pages/requests/index.tsx | 161 +++++++++++++-------- 7 files changed, 474 insertions(+), 81 deletions(-) create mode 100644 web/src/components/ui/calendar.tsx create mode 100644 web/src/components/ui/popover.tsx diff --git a/internal/handler/admin.go b/internal/handler/admin.go index 12cc40bc..57acb9e2 100644 --- a/internal/handler/admin.go +++ b/internal/handler/admin.go @@ -952,6 +952,10 @@ func (h *AdminHandler) handleProxyRequests(w http.ResponseWriter, r *http.Reques } filter.EndTime = endTime } + if filter.StartTime != nil && filter.EndTime != nil && filter.EndTime.Before(*filter.StartTime) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "endTime must be greater than or equal to startTime"}) + return + } } result, err := h.svc.GetProxyRequestsCursor(tenantID, limit, before, after, filter) @@ -1029,6 +1033,10 @@ func (h *AdminHandler) handleProxyRequestsCount(w http.ResponseWriter, r *http.R } filter.EndTime = endTime } + if filter.StartTime != nil && filter.EndTime != nil && filter.EndTime.Before(*filter.StartTime) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "endTime must be greater than or equal to startTime"}) + return + } } count, err := h.svc.GetProxyRequestsCountWithFilter(tenantID, filter) diff --git a/web/package.json b/web/package.json index 4e5983e0..e9fc18d2 100644 --- a/web/package.json +++ b/web/package.json @@ -46,6 +46,7 @@ "lucide-react": "^0.562.0", "postcss": "^8.5.10", "react": "^19.2.0", + "react-day-picker": "^10.0.1", "react-dom": "^19.2.0", "react-i18next": "^16.5.3", "react-resizable-panels": "^2.1.7", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index a0e5a48d..ff275088 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -65,6 +65,9 @@ importers: react: specifier: ^19.2.0 version: 19.2.3 + react-day-picker: + specifier: ^10.0.1 + version: 10.0.1(@types/react@19.2.8)(react@19.2.3) react-dom: specifier: ^19.2.0 version: 19.2.3(react@19.2.3) @@ -327,6 +330,9 @@ packages: '@types/react': optional: true + '@date-fns/tz@1.5.0': + resolution: {integrity: sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==} + '@dnd-kit/accessibility@3.1.1': resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} peerDependencies: @@ -2424,6 +2430,16 @@ packages: resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} engines: {node: '>= 0.10'} + react-day-picker@10.0.1: + resolution: {integrity: sha512-eNh6BlwcYInWaJtRv18mXQ06Ys/H6rdTZAnTaSdOYJuTpwP1JMCHNd1FDRadA+gbeinq+psdULN5Xnowy9mV8w==} + engines: {node: '>=18'} + peerDependencies: + '@types/react': '>=16.8.0' + react: '>=16.8.0' + peerDependenciesMeta: + '@types/react': + optional: true + react-dom@19.2.3: resolution: {integrity: sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==} peerDependencies: @@ -3212,6 +3228,8 @@ snapshots: optionalDependencies: '@types/react': 19.2.8 + '@date-fns/tz@1.5.0': {} + '@dnd-kit/accessibility@3.1.1(react@19.2.3)': dependencies: react: 19.2.3 @@ -5120,6 +5138,14 @@ snapshots: iconv-lite: 0.7.2 unpipe: 1.0.0 + react-day-picker@10.0.1(@types/react@19.2.8)(react@19.2.3): + dependencies: + '@date-fns/tz': 1.5.0 + date-fns: 4.1.0 + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.8 + react-dom@19.2.3(react@19.2.3): dependencies: react: 19.2.3 diff --git a/web/src/components/ui/button.tsx b/web/src/components/ui/button.tsx index 1a732859..76a87dd7 100644 --- a/web/src/components/ui/button.tsx +++ b/web/src/components/ui/button.tsx @@ -1,49 +1,49 @@ -import { Button as ButtonPrimitive } from '@base-ui/react/button'; -import { cva, type VariantProps } from 'class-variance-authority'; +import { Button as ButtonPrimitive } from "@base-ui/react/button" +import { cva, type VariantProps } from "class-variance-authority" -import { cn } from '@/lib/utils'; +import { cn } from "@/lib/utils" const buttonVariants = cva( - "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-md border border-transparent bg-clip-padding text-sm font-medium focus-visible:ring-[3px] aria-invalid:ring-[3px] [&_svg:not([class*='size-'])]:size-4 inline-flex items-center justify-center whitespace-nowrap transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none shrink-0 [&_svg]:shrink-0 outline-none group/button select-none", + "group/button inline-flex shrink-0 items-center justify-center rounded-md border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", { variants: { variant: { - default: 'bg-primary text-primary-foreground hover:bg-primary/80', + default: "bg-primary text-primary-foreground hover:bg-primary/80", outline: - 'border-border bg-background hover:bg-muted hover:text-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 aria-expanded:bg-muted aria-expanded:text-foreground shadow-xs', + "border-border bg-background shadow-xs hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50", secondary: - 'bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground', + "bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground", ghost: - 'hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground', + "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50", destructive: - 'bg-destructive/10 hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/20 text-destructive focus-visible:border-destructive/40 dark:hover:bg-destructive/30', - link: 'text-primary underline-offset-4 hover:underline', + "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40", + link: "text-primary underline-offset-4 hover:underline", }, size: { default: - 'h-9 gap-1.5 px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2', + "h-9 gap-1.5 px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", xs: "h-6 gap-1 rounded-[min(var(--radius-md),8px)] px-2 text-xs in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3", - sm: 'h-8 gap-1 rounded-[min(var(--radius-md),10px)] px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5', - lg: 'h-10 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3', - icon: 'size-9', - 'icon-xs': + sm: "h-8 gap-1 rounded-[min(var(--radius-md),10px)] px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5", + lg: "h-10 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", + icon: "size-9", + "icon-xs": "size-6 rounded-[min(var(--radius-md),8px)] in-data-[slot=button-group]:rounded-md [&_svg:not([class*='size-'])]:size-3", - 'icon-sm': - 'size-8 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-md', - 'icon-lg': 'size-10', + "icon-sm": + "size-8 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-md", + "icon-lg": "size-10", }, }, defaultVariants: { - variant: 'default', - size: 'default', + variant: "default", + size: "default", }, - }, -); + } +) function Button({ className, - variant = 'default', - size = 'default', + variant = "default", + size = "default", ...props }: ButtonPrimitive.Props & VariantProps) { return ( @@ -52,7 +52,7 @@ function Button({ className={cn(buttonVariants({ variant, size, className }))} {...props} /> - ); + ) } -export { Button, buttonVariants }; +export { Button, buttonVariants } diff --git a/web/src/components/ui/calendar.tsx b/web/src/components/ui/calendar.tsx new file mode 100644 index 00000000..dbcf2d4b --- /dev/null +++ b/web/src/components/ui/calendar.tsx @@ -0,0 +1,219 @@ +import * as React from "react" +import { + DayPicker, + getDefaultClassNames, + type DayButton, + type Locale, +} from "react-day-picker" + +import { cn } from "@/lib/utils" +import { Button, buttonVariants } from "@/components/ui/button" +import { ChevronLeftIcon, ChevronRightIcon, ChevronDownIcon } from "lucide-react" + +function Calendar({ + className, + classNames, + showOutsideDays = true, + captionLayout = "label", + buttonVariant = "ghost", + locale, + formatters, + components, + ...props +}: React.ComponentProps & { + buttonVariant?: React.ComponentProps["variant"] +}) { + const defaultClassNames = getDefaultClassNames() + + return ( + svg]:rotate-180`, + String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`, + className + )} + captionLayout={captionLayout} + locale={locale} + formatters={{ + formatMonthDropdown: (date) => + date.toLocaleString(locale?.code, { month: "short" }), + ...formatters, + }} + classNames={{ + root: cn("w-fit", defaultClassNames.root), + months: cn( + "relative flex flex-col gap-4 md:flex-row", + defaultClassNames.months + ), + month: cn("flex w-full flex-col gap-4", defaultClassNames.month), + nav: cn( + "absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1", + defaultClassNames.nav + ), + button_previous: cn( + buttonVariants({ variant: buttonVariant }), + "size-(--cell-size) p-0 select-none aria-disabled:opacity-50", + defaultClassNames.button_previous + ), + button_next: cn( + buttonVariants({ variant: buttonVariant }), + "size-(--cell-size) p-0 select-none aria-disabled:opacity-50", + defaultClassNames.button_next + ), + month_caption: cn( + "flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)", + defaultClassNames.month_caption + ), + dropdowns: cn( + "flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium", + defaultClassNames.dropdowns + ), + dropdown_root: cn( + "relative rounded-(--cell-radius)", + defaultClassNames.dropdown_root + ), + dropdown: cn( + "absolute inset-0 bg-popover opacity-0", + defaultClassNames.dropdown + ), + caption_label: cn( + "font-medium select-none", + captionLayout === "label" + ? "text-sm" + : "flex items-center gap-1 rounded-(--cell-radius) text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground", + defaultClassNames.caption_label + ), + table: "w-full border-collapse", + weekdays: cn("flex", defaultClassNames.weekdays), + weekday: cn( + "flex-1 rounded-(--cell-radius) text-[0.8rem] font-normal text-muted-foreground select-none", + defaultClassNames.weekday + ), + week: cn("mt-2 flex w-full", defaultClassNames.week), + week_number_header: cn( + "w-(--cell-size) select-none", + defaultClassNames.week_number_header + ), + week_number: cn( + "text-[0.8rem] text-muted-foreground select-none", + defaultClassNames.week_number + ), + day: cn( + "group/day relative aspect-square h-full w-full rounded-(--cell-radius) p-0 text-center select-none [&:last-child[data-selected=true]_button]:rounded-r-(--cell-radius)", + props.showWeekNumber + ? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-(--cell-radius)" + : "[&:first-child[data-selected=true]_button]:rounded-l-(--cell-radius)", + defaultClassNames.day + ), + range_start: cn( + "relative isolate z-0 rounded-l-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:right-0 after:w-4 after:bg-muted", + defaultClassNames.range_start + ), + range_middle: cn("rounded-none", defaultClassNames.range_middle), + range_end: cn( + "relative isolate z-0 rounded-r-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:left-0 after:w-4 after:bg-muted", + defaultClassNames.range_end + ), + today: cn( + "rounded-(--cell-radius) bg-muted text-foreground data-[selected=true]:rounded-none", + defaultClassNames.today + ), + outside: cn( + "text-muted-foreground aria-selected:text-muted-foreground", + defaultClassNames.outside + ), + disabled: cn( + "text-muted-foreground opacity-50", + defaultClassNames.disabled + ), + hidden: cn("invisible", defaultClassNames.hidden), + ...classNames, + }} + components={{ + Root: ({ className, rootRef, ...props }) => { + return ( +
+ ) + }, + Chevron: ({ className, orientation, ...props }) => { + if (orientation === "left") { + return ( + + ) + } + + if (orientation === "right") { + return ( + + ) + } + + return ( + + ) + }, + DayButton: ({ ...props }) => ( + + ), + WeekNumber: ({ children, ...props }) => { + return ( + +
+ {children} +
+ + ) + }, + ...components, + }} + {...props} + /> + ) +} + +function CalendarDayButton({ + className, + day, + modifiers, + locale, + ...props +}: React.ComponentProps & { locale?: Partial }) { + const defaultClassNames = getDefaultClassNames() + + const ref = React.useRef(null) + React.useEffect(() => { + if (modifiers.focused) ref.current?.focus() + }, [modifiers.focused]) + + return ( + + {hasValue && ( + + )}
); } From eac51df56edeb3f4b6e9f7db66a2b662232a11a9 Mon Sep 17 00:00:00 2001 From: awsl233777 Date: Fri, 5 Jun 2026 00:16:19 +0800 Subject: [PATCH 5/5] fix: resolve CI lint/tsc errors in calendar and TimeRangeFilter Co-Authored-By: Claude Opus 4.6 --- web/src/components/ui/calendar.tsx | 2 +- web/src/pages/requests/index.tsx | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/web/src/components/ui/calendar.tsx b/web/src/components/ui/calendar.tsx index dbcf2d4b..2833bf2e 100644 --- a/web/src/components/ui/calendar.tsx +++ b/web/src/components/ui/calendar.tsx @@ -85,7 +85,7 @@ function Calendar({ : "flex items-center gap-1 rounded-(--cell-radius) text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground", defaultClassNames.caption_label ), - table: "w-full border-collapse", + month_grid: "w-full border-collapse", weekdays: cn("flex", defaultClassNames.weekdays), weekday: cn( "flex-1 rounded-(--cell-radius) text-[0.8rem] font-normal text-muted-foreground select-none", diff --git a/web/src/pages/requests/index.tsx b/web/src/pages/requests/index.tsx index 91760c7a..0b0fb854 100644 --- a/web/src/pages/requests/index.tsx +++ b/web/src/pages/requests/index.tsx @@ -47,7 +47,6 @@ import { SelectGroup, SelectLabel, Input, - Button, } from '@/components/ui'; import { Calendar } from '@/components/ui/calendar'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; @@ -1526,7 +1525,7 @@ function DateTimePicker({ mode="single" selected={value} onSelect={handleDateSelect} - initialFocus + autoFocus />