diff --git a/backend/internal/handler/gateway_handler.go b/backend/internal/handler/gateway_handler.go
index 116346b4a6..8e1399f16d 100644
--- a/backend/internal/handler/gateway_handler.go
+++ b/backend/internal/handler/gateway_handler.go
@@ -1454,13 +1454,14 @@ func (h *GatewayHandler) usageUnrestricted(c *gin.Context, ctx context.Context,
remaining := h.calculateSubscriptionRemaining(apiKey.Group, subscription)
resp["remaining"] = remaining
resp["subscription"] = gin.H{
- "daily_usage_usd": subscription.DailyUsageUSD,
- "weekly_usage_usd": subscription.WeeklyUsageUSD,
- "monthly_usage_usd": subscription.MonthlyUsageUSD,
- "daily_limit_usd": apiKey.Group.DailyLimitUSD,
- "weekly_limit_usd": apiKey.Group.WeeklyLimitUSD,
- "monthly_limit_usd": apiKey.Group.MonthlyLimitUSD,
- "expires_at": subscription.ExpiresAt,
+ "daily_usage_usd": subscription.DailyUsageUSD,
+ "weekly_usage_usd": subscription.WeeklyUsageUSD,
+ "monthly_usage_usd": subscription.MonthlyUsageUSD,
+ "daily_limit_usd": apiKey.Group.DailyLimitUSD,
+ "weekly_limit_usd": apiKey.Group.WeeklyLimitUSD,
+ "monthly_limit_usd": apiKey.Group.MonthlyLimitUSD,
+ "weekly_window_start": subscription.WeeklyWindowStart,
+ "expires_at": subscription.ExpiresAt,
}
}
diff --git a/backend/internal/handler/gateway_handler_usage_test.go b/backend/internal/handler/gateway_handler_usage_test.go
new file mode 100644
index 0000000000..b6b0e0efe6
--- /dev/null
+++ b/backend/internal/handler/gateway_handler_usage_test.go
@@ -0,0 +1,51 @@
+package handler
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "github.com/Wei-Shaw/sub2api/internal/server/middleware"
+ "github.com/Wei-Shaw/sub2api/internal/service"
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/require"
+)
+
+func TestUsageUnrestrictedIncludesWeeklyWindowStart(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ recorder := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(recorder)
+ c.Request = httptest.NewRequest(http.MethodGet, "/v1/usage", nil)
+
+ weeklyWindowStart := time.Date(2026, time.July, 13, 0, 30, 0, 0, time.FixedZone("UTC+8", 8*60*60))
+ c.Set(string(middleware.ContextKeySubscription), &service.UserSubscription{
+ WeeklyWindowStart: &weeklyWindowStart,
+ })
+
+ handler := &GatewayHandler{}
+ handler.usageUnrestricted(
+ c,
+ context.Background(),
+ &service.APIKey{Group: &service.Group{
+ Name: "Weekly plan",
+ SubscriptionType: service.SubscriptionTypeSubscription,
+ }},
+ middleware.AuthSubject{},
+ nil,
+ nil,
+ nil,
+ )
+
+ require.Equal(t, http.StatusOK, recorder.Code)
+ var response struct {
+ Subscription struct {
+ WeeklyWindowStart *time.Time `json:"weekly_window_start"`
+ } `json:"subscription"`
+ }
+ require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &response))
+ require.NotNil(t, response.Subscription.WeeklyWindowStart)
+ require.True(t, weeklyWindowStart.Equal(*response.Subscription.WeeklyWindowStart))
+}
diff --git a/backend/internal/pkg/pagination/pagination.go b/backend/internal/pkg/pagination/pagination.go
index ce8e74b8ce..334ba809de 100644
--- a/backend/internal/pkg/pagination/pagination.go
+++ b/backend/internal/pkg/pagination/pagination.go
@@ -38,7 +38,7 @@ func (p PaginationParams) Offset() int {
if p.Page < 1 {
p.Page = 1
}
- return (p.Page - 1) * p.PageSize
+ return (p.Page - 1) * p.Limit()
}
// Limit 获取限制数
diff --git a/backend/internal/pkg/pagination/pagination_test.go b/backend/internal/pkg/pagination/pagination_test.go
index 9a3b069d90..9704449e92 100644
--- a/backend/internal/pkg/pagination/pagination_test.go
+++ b/backend/internal/pkg/pagination/pagination_test.go
@@ -69,3 +69,30 @@ func TestPaginationParamsLimit(t *testing.T) {
})
}
}
+
+func TestPaginationParamsOffsetUsesNormalizedLimit(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ page int
+ pageSize int
+ want int
+ }{
+ {name: "invalid page uses first page", page: 0, pageSize: 50, want: 0},
+ {name: "zero page size uses default", page: 2, pageSize: 0, want: 20},
+ {name: "negative page size uses default", page: 2, pageSize: -1, want: 20},
+ {name: "normal values", page: 3, pageSize: 50, want: 100},
+ {name: "page size beyond max is clamped", page: 2, pageSize: 1500, want: 1000},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ params := PaginationParams{Page: tt.page, PageSize: tt.pageSize}
+ if got := params.Offset(); got != tt.want {
+ t.Fatalf("Offset() for Page=%d, PageSize=%d = %d, want %d", tt.page, tt.pageSize, got, tt.want)
+ }
+ })
+ }
+}
diff --git a/frontend/src/utils/__tests__/formatDateLocalInput.spec.ts b/frontend/src/utils/__tests__/formatDateLocalInput.spec.ts
new file mode 100644
index 0000000000..76b7af851f
--- /dev/null
+++ b/frontend/src/utils/__tests__/formatDateLocalInput.spec.ts
@@ -0,0 +1,18 @@
+import { describe, expect, it, vi } from 'vitest'
+
+import { formatDateLocalInput } from '../format'
+
+describe('formatDateLocalInput', () => {
+ it('formats the calendar date in local time', () => {
+ const localDate = new Date('2026-07-12T16:30:00Z')
+ vi.spyOn(localDate, 'getFullYear').mockReturnValue(2026)
+ vi.spyOn(localDate, 'getMonth').mockReturnValue(6)
+ vi.spyOn(localDate, 'getDate').mockReturnValue(13)
+
+ expect(formatDateLocalInput(localDate)).toBe('2026-07-13')
+ })
+
+ it('returns an empty string for an invalid date', () => {
+ expect(formatDateLocalInput(new Date('invalid'))).toBe('')
+ })
+})
diff --git a/frontend/src/utils/format.ts b/frontend/src/utils/format.ts
index 6f13a065af..481fe397ef 100644
--- a/frontend/src/utils/format.ts
+++ b/frontend/src/utils/format.ts
@@ -149,6 +149,17 @@ export function formatDateTime(
return formatDate(date, options, localeOverride)
}
+/**
+ * 格式化为 date 控件值(YYYY-MM-DD,使用本地时间)
+ */
+export function formatDateLocalInput(date: Date): string {
+ if (isNaN(date.getTime())) return ''
+ const year = date.getFullYear()
+ const month = String(date.getMonth() + 1).padStart(2, '0')
+ const day = String(date.getDate()).padStart(2, '0')
+ return `${year}-${month}-${day}`
+}
+
/**
* 格式化为 datetime-local 控件值(YYYY-MM-DDTHH:mm,使用本地时间)
*/
diff --git a/frontend/src/views/KeyUsageView.vue b/frontend/src/views/KeyUsageView.vue
index 2cbecbcb66..32581a14ef 100644
--- a/frontend/src/views/KeyUsageView.vue
+++ b/frontend/src/views/KeyUsageView.vue
@@ -423,6 +423,7 @@ import { useAppStore } from '@/stores'
import LocaleSwitcher from '@/components/common/LocaleSwitcher.vue'
import Icon from '@/components/icons/Icon.vue'
import { buildGatewayUrl } from '@/api/client'
+import { formatDateLocalInput } from '@/utils/format'
import { sanitizeUrl } from '@/utils/url'
const { t, locale } = useI18n()
@@ -490,7 +491,6 @@ function setDateRange(key: DateRangeKey) {
function getDateParams(): string {
const now = new Date()
- const fmt = (d: Date) => d.toISOString().split('T')[0]
const params = new URLSearchParams()
if (currentRange.value === 'custom') {
@@ -499,13 +499,13 @@ function getDateParams(): string {
params.set('end_date', customEndDate.value)
}
} else {
- const end = fmt(now)
+ const end = formatDateLocalInput(now)
let start: string
switch (currentRange.value) {
case 'today': start = end; break
- case '7d': start = fmt(new Date(now.getTime() - 7 * 86400000)); break
- case '30d': start = fmt(new Date(now.getTime() - 30 * 86400000)); break
- default: start = fmt(new Date(now.getTime() - 30 * 86400000))
+ case '7d': start = formatDateLocalInput(new Date(now.getTime() - 7 * 86400000)); break
+ case '30d': start = formatDateLocalInput(new Date(now.getTime() - 30 * 86400000)); break
+ default: start = formatDateLocalInput(new Date(now.getTime() - 30 * 86400000))
}
params.set('start_date', start)
params.set('end_date', end)
diff --git a/frontend/src/views/__tests__/KeyUsageView.spec.ts b/frontend/src/views/__tests__/KeyUsageView.spec.ts
index c1373bc30f..224485fd49 100644
--- a/frontend/src/views/__tests__/KeyUsageView.spec.ts
+++ b/frontend/src/views/__tests__/KeyUsageView.spec.ts
@@ -162,6 +162,7 @@ describe('KeyUsageView daily detail', () => {
})
afterEach(() => {
+ vi.useRealTimers()
vi.unstubAllGlobals()
})
@@ -205,4 +206,29 @@ describe('KeyUsageView daily detail', () => {
wrapper.unmount()
})
+
+ it('queries the current local calendar date near midnight', async () => {
+ vi.useFakeTimers()
+ vi.setSystemTime(new Date(2026, 6, 13, 0, 30))
+
+ const wrapper = mount(KeyUsageView, {
+ global: {
+ stubs: {
+ RouterLink: { template: '' },
+ LocaleSwitcher: true,
+ Icon: true,
+ },
+ },
+ })
+
+ await wrapper.find('input').setValue('sk-test-key')
+ await wrapper.find('input').trigger('keydown.enter')
+ await flushPromises()
+
+ const requestUrl = String(vi.mocked(fetch).mock.calls[0][0])
+ expect(requestUrl).toContain('start_date=2026-07-13')
+ expect(requestUrl).toContain('end_date=2026-07-13')
+
+ wrapper.unmount()
+ })
})
diff --git a/frontend/src/views/user/DashboardView.vue b/frontend/src/views/user/DashboardView.vue
index 5609ba7abb..815e905182 100644
--- a/frontend/src/views/user/DashboardView.vue
+++ b/frontend/src/views/user/DashboardView.vue
@@ -21,14 +21,14 @@ import UserDashboardStats from '@/components/user/dashboard/UserDashboardStats.v
import UserDashboardRecentUsage from '@/components/user/dashboard/UserDashboardRecentUsage.vue'; import UserDashboardQuickActions from '@/components/user/dashboard/UserDashboardQuickActions.vue'
import type { UsageLog, TrendDataPoint, ModelStat, PlatformQuotaItem } from '@/types'
import { getMyPlatformQuotas } from '@/api/user'
+import { formatDateLocalInput } from '@/utils/format'
const authStore = useAuthStore(); const user = computed(() => authStore.user)
const stats = ref(null); const loading = ref(false); const loadingUsage = ref(false); const loadingCharts = ref(false)
const trendData = ref([]); const modelStats = ref([]); const recentUsage = ref([])
const platformQuotas = ref(null)
-const formatLD = (d: Date) => d.toISOString().split('T')[0]
-const startDate = ref(formatLD(new Date(Date.now() - 6 * 86400000))); const endDate = ref(formatLD(new Date())); const granularity = ref('day')
+const startDate = ref(formatDateLocalInput(new Date(Date.now() - 6 * 86400000))); const endDate = ref(formatDateLocalInput(new Date())); const granularity = ref('day')
const loadStats = async () => { loading.value = true; try { await authStore.refreshUser(); stats.value = await usageAPI.getDashboardStats() } catch (error) { console.error('Failed to load dashboard stats:', error) } finally { loading.value = false } }
const loadCharts = async () => { loadingCharts.value = true; try { const res = await Promise.all([usageAPI.getDashboardTrend({ start_date: startDate.value, end_date: endDate.value, granularity: granularity.value as any }), usageAPI.getDashboardModels({ start_date: startDate.value, end_date: endDate.value })]); trendData.value = res[0].trend || []; modelStats.value = res[1].models || [] } catch (error) { console.error('Failed to load charts:', error) } finally { loadingCharts.value = false } }