diff --git a/backend/internal/handler/admin/dashboard_handler.go b/backend/internal/handler/admin/dashboard_handler.go index fe54d75f..30cdd914 100644 --- a/backend/internal/handler/admin/dashboard_handler.go +++ b/backend/internal/handler/admin/dashboard_handler.go @@ -26,31 +26,33 @@ func NewDashboardHandler(dashboardService *service.DashboardService) *DashboardH } // parseTimeRange parses start_date, end_date query parameters +// Uses user's timezone if provided, otherwise falls back to server timezone func parseTimeRange(c *gin.Context) (time.Time, time.Time) { - now := timezone.Now() + userTZ := c.Query("timezone") // Get user's timezone from request + now := timezone.NowInUserLocation(userTZ) startDate := c.Query("start_date") endDate := c.Query("end_date") var startTime, endTime time.Time if startDate != "" { - if t, err := timezone.ParseInLocation("2006-01-02", startDate); err == nil { + if t, err := timezone.ParseInUserLocation("2006-01-02", startDate, userTZ); err == nil { startTime = t } else { - startTime = timezone.StartOfDay(now.AddDate(0, 0, -7)) + startTime = timezone.StartOfDayInUserLocation(now.AddDate(0, 0, -7), userTZ) } } else { - startTime = timezone.StartOfDay(now.AddDate(0, 0, -7)) + startTime = timezone.StartOfDayInUserLocation(now.AddDate(0, 0, -7), userTZ) } if endDate != "" { - if t, err := timezone.ParseInLocation("2006-01-02", endDate); err == nil { + if t, err := timezone.ParseInUserLocation("2006-01-02", endDate, userTZ); err == nil { endTime = t.Add(24 * time.Hour) // Include the end date } else { - endTime = timezone.StartOfDay(now.AddDate(0, 0, 1)) + endTime = timezone.StartOfDayInUserLocation(now.AddDate(0, 0, 1), userTZ) } } else { - endTime = timezone.StartOfDay(now.AddDate(0, 0, 1)) + endTime = timezone.StartOfDayInUserLocation(now.AddDate(0, 0, 1), userTZ) } return startTime, endTime diff --git a/backend/internal/handler/admin/usage_handler.go b/backend/internal/handler/admin/usage_handler.go index 37da93d3..9d14afd2 100644 --- a/backend/internal/handler/admin/usage_handler.go +++ b/backend/internal/handler/admin/usage_handler.go @@ -102,8 +102,9 @@ func (h *UsageHandler) List(c *gin.Context) { // Parse date range var startTime, endTime *time.Time + userTZ := c.Query("timezone") // Get user's timezone from request if startDateStr := c.Query("start_date"); startDateStr != "" { - t, err := timezone.ParseInLocation("2006-01-02", startDateStr) + t, err := timezone.ParseInUserLocation("2006-01-02", startDateStr, userTZ) if err != nil { response.BadRequest(c, "Invalid start_date format, use YYYY-MM-DD") return @@ -112,7 +113,7 @@ func (h *UsageHandler) List(c *gin.Context) { } if endDateStr := c.Query("end_date"); endDateStr != "" { - t, err := timezone.ParseInLocation("2006-01-02", endDateStr) + t, err := timezone.ParseInUserLocation("2006-01-02", endDateStr, userTZ) if err != nil { response.BadRequest(c, "Invalid end_date format, use YYYY-MM-DD") return @@ -172,7 +173,8 @@ func (h *UsageHandler) Stats(c *gin.Context) { } // Parse date range - now := timezone.Now() + userTZ := c.Query("timezone") // Get user's timezone from request + now := timezone.NowInUserLocation(userTZ) var startTime, endTime time.Time startDateStr := c.Query("start_date") @@ -180,12 +182,12 @@ func (h *UsageHandler) Stats(c *gin.Context) { if startDateStr != "" && endDateStr != "" { var err error - startTime, err = timezone.ParseInLocation("2006-01-02", startDateStr) + startTime, err = timezone.ParseInUserLocation("2006-01-02", startDateStr, userTZ) if err != nil { response.BadRequest(c, "Invalid start_date format, use YYYY-MM-DD") return } - endTime, err = timezone.ParseInLocation("2006-01-02", endDateStr) + endTime, err = timezone.ParseInUserLocation("2006-01-02", endDateStr, userTZ) if err != nil { response.BadRequest(c, "Invalid end_date format, use YYYY-MM-DD") return @@ -195,13 +197,13 @@ func (h *UsageHandler) Stats(c *gin.Context) { period := c.DefaultQuery("period", "today") switch period { case "today": - startTime = timezone.StartOfDay(now) + startTime = timezone.StartOfDayInUserLocation(now, userTZ) case "week": startTime = now.AddDate(0, 0, -7) case "month": startTime = now.AddDate(0, -1, 0) default: - startTime = timezone.StartOfDay(now) + startTime = timezone.StartOfDayInUserLocation(now, userTZ) } endTime = now } diff --git a/backend/internal/handler/usage_handler.go b/backend/internal/handler/usage_handler.go index 9e503d4c..129dbfa6 100644 --- a/backend/internal/handler/usage_handler.go +++ b/backend/internal/handler/usage_handler.go @@ -88,8 +88,9 @@ func (h *UsageHandler) List(c *gin.Context) { // Parse date range var startTime, endTime *time.Time + userTZ := c.Query("timezone") // Get user's timezone from request if startDateStr := c.Query("start_date"); startDateStr != "" { - t, err := timezone.ParseInLocation("2006-01-02", startDateStr) + t, err := timezone.ParseInUserLocation("2006-01-02", startDateStr, userTZ) if err != nil { response.BadRequest(c, "Invalid start_date format, use YYYY-MM-DD") return @@ -98,7 +99,7 @@ func (h *UsageHandler) List(c *gin.Context) { } if endDateStr := c.Query("end_date"); endDateStr != "" { - t, err := timezone.ParseInLocation("2006-01-02", endDateStr) + t, err := timezone.ParseInUserLocation("2006-01-02", endDateStr, userTZ) if err != nil { response.BadRequest(c, "Invalid end_date format, use YYYY-MM-DD") return @@ -194,7 +195,8 @@ func (h *UsageHandler) Stats(c *gin.Context) { } // 获取时间范围参数 - now := timezone.Now() + userTZ := c.Query("timezone") // Get user's timezone from request + now := timezone.NowInUserLocation(userTZ) var startTime, endTime time.Time // 优先使用 start_date 和 end_date 参数 @@ -204,12 +206,12 @@ func (h *UsageHandler) Stats(c *gin.Context) { if startDateStr != "" && endDateStr != "" { // 使用自定义日期范围 var err error - startTime, err = timezone.ParseInLocation("2006-01-02", startDateStr) + startTime, err = timezone.ParseInUserLocation("2006-01-02", startDateStr, userTZ) if err != nil { response.BadRequest(c, "Invalid start_date format, use YYYY-MM-DD") return } - endTime, err = timezone.ParseInLocation("2006-01-02", endDateStr) + endTime, err = timezone.ParseInUserLocation("2006-01-02", endDateStr, userTZ) if err != nil { response.BadRequest(c, "Invalid end_date format, use YYYY-MM-DD") return @@ -221,13 +223,13 @@ func (h *UsageHandler) Stats(c *gin.Context) { period := c.DefaultQuery("period", "today") switch period { case "today": - startTime = timezone.StartOfDay(now) + startTime = timezone.StartOfDayInUserLocation(now, userTZ) case "week": startTime = now.AddDate(0, 0, -7) case "month": startTime = now.AddDate(0, -1, 0) default: - startTime = timezone.StartOfDay(now) + startTime = timezone.StartOfDayInUserLocation(now, userTZ) } endTime = now } @@ -248,31 +250,33 @@ func (h *UsageHandler) Stats(c *gin.Context) { } // parseUserTimeRange parses start_date, end_date query parameters for user dashboard +// Uses user's timezone if provided, otherwise falls back to server timezone func parseUserTimeRange(c *gin.Context) (time.Time, time.Time) { - now := timezone.Now() + userTZ := c.Query("timezone") // Get user's timezone from request + now := timezone.NowInUserLocation(userTZ) startDate := c.Query("start_date") endDate := c.Query("end_date") var startTime, endTime time.Time if startDate != "" { - if t, err := timezone.ParseInLocation("2006-01-02", startDate); err == nil { + if t, err := timezone.ParseInUserLocation("2006-01-02", startDate, userTZ); err == nil { startTime = t } else { - startTime = timezone.StartOfDay(now.AddDate(0, 0, -7)) + startTime = timezone.StartOfDayInUserLocation(now.AddDate(0, 0, -7), userTZ) } } else { - startTime = timezone.StartOfDay(now.AddDate(0, 0, -7)) + startTime = timezone.StartOfDayInUserLocation(now.AddDate(0, 0, -7), userTZ) } if endDate != "" { - if t, err := timezone.ParseInLocation("2006-01-02", endDate); err == nil { + if t, err := timezone.ParseInUserLocation("2006-01-02", endDate, userTZ); err == nil { endTime = t.Add(24 * time.Hour) // Include the end date } else { - endTime = timezone.StartOfDay(now.AddDate(0, 0, 1)) + endTime = timezone.StartOfDayInUserLocation(now.AddDate(0, 0, 1), userTZ) } } else { - endTime = timezone.StartOfDay(now.AddDate(0, 0, 1)) + endTime = timezone.StartOfDayInUserLocation(now.AddDate(0, 0, 1), userTZ) } return startTime, endTime diff --git a/backend/internal/pkg/timezone/timezone.go b/backend/internal/pkg/timezone/timezone.go index 35795648..40f6e38f 100644 --- a/backend/internal/pkg/timezone/timezone.go +++ b/backend/internal/pkg/timezone/timezone.go @@ -122,3 +122,40 @@ func StartOfMonth(t time.Time) time.Time { func ParseInLocation(layout, value string) (time.Time, error) { return time.ParseInLocation(layout, value, Location()) } + +// ParseInUserLocation parses a time string in the user's timezone. +// If userTZ is empty or invalid, falls back to the configured server timezone. +func ParseInUserLocation(layout, value, userTZ string) (time.Time, error) { + loc := Location() // default to server timezone + if userTZ != "" { + if userLoc, err := time.LoadLocation(userTZ); err == nil { + loc = userLoc + } + } + return time.ParseInLocation(layout, value, loc) +} + +// NowInUserLocation returns the current time in the user's timezone. +// If userTZ is empty or invalid, falls back to the configured server timezone. +func NowInUserLocation(userTZ string) time.Time { + if userTZ == "" { + return Now() + } + if userLoc, err := time.LoadLocation(userTZ); err == nil { + return time.Now().In(userLoc) + } + return Now() +} + +// StartOfDayInUserLocation returns the start of the given day in the user's timezone. +// If userTZ is empty or invalid, falls back to the configured server timezone. +func StartOfDayInUserLocation(t time.Time, userTZ string) time.Time { + loc := Location() + if userTZ != "" { + if userLoc, err := time.LoadLocation(userTZ); err == nil { + loc = userLoc + } + } + t = t.In(loc) + return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, loc) +} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 1cc8e55b..4e53069a 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -21,6 +21,15 @@ export const apiClient: AxiosInstance = axios.create({ // ==================== Request Interceptor ==================== +// Get user's timezone +const getUserTimezone = (): string => { + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone + } catch { + return 'UTC' + } +} + apiClient.interceptors.request.use( (config: InternalAxiosRequestConfig) => { // Attach token from localStorage @@ -34,6 +43,14 @@ apiClient.interceptors.request.use( config.headers['Accept-Language'] = getLocale() } + // Attach timezone for all GET requests (backend may use it for default date ranges) + if (config.method === 'get') { + if (!config.params) { + config.params = {} + } + config.params.timezone = getUserTimezone() + } + return config }, (error) => {